summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.js19
-rw-r--r--.gitignore9
-rw-r--r--.gitmodules5
-rw-r--r--.vscode/settings.json98
-rw-r--r--.vscode/tasks.json33
-rw-r--r--API_CHANGES.md36
-rw-r--r--AUTHORS1
-rw-r--r--Makefile179
-rw-r--r--README183
-rwxr-xr-xbootstrap26
-rw-r--r--build-system/Makefile107
-rw-r--r--build-system/configure.py7
m---------build-system/taler-build-scripts0
-rw-r--r--contrib/articles/spending.txt2
-rw-r--r--contrib/articles/ui/ui-cameraready.tex6
-rw-r--r--contrib/articles/ui/ui.tex6
-rw-r--r--contrib/articles/ui/ui_short.tex2
-rwxr-xr-xcontrib/build-fast-web.sh7
-rwxr-xr-xcontrib/bump-taler-version.mjs119
-rw-r--r--contrib/ci/Containerfile27
-rwxr-xr-xcontrib/ci/ci.sh34
-rw-r--r--contrib/ci/jobs/0-codespell/config.ini6
-rw-r--r--contrib/ci/jobs/0-codespell/dictionary.txt20
-rwxr-xr-xcontrib/ci/jobs/0-codespell/job.sh6
-rwxr-xr-xcontrib/ci/jobs/1-build/build.sh6
-rwxr-xr-xcontrib/ci/jobs/1-build/job.sh6
-rwxr-xr-xcontrib/ci/jobs/2-docs/docs.sh8
-rwxr-xr-xcontrib/ci/jobs/2-docs/job.sh6
-rw-r--r--contrib/ci/jobs/3-wallet-cli-deb-package/config.ini6
-rwxr-xr-xcontrib/ci/jobs/3-wallet-cli-deb-package/job.sh36
-rwxr-xr-xcontrib/ci/jobs/3-wallet-cli-deb-package/version.sh17
-rw-r--r--contrib/ci/jobs/4-taler-harness-deb-package/config.ini6
-rwxr-xr-xcontrib/ci/jobs/4-taler-harness-deb-package/job.sh36
-rwxr-xr-xcontrib/ci/jobs/4-taler-harness-deb-package/version.sh17
-rw-r--r--contrib/ci/jobs/5-deploy-packages/config.ini6
-rwxr-xr-xcontrib/ci/jobs/5-deploy-packages/job.sh14
-rwxr-xr-xcontrib/cleanup-prebuilt-dir.sh11
-rwxr-xr-xcontrib/copy-aml-backoffice-into-prebuilt.sh10
-rwxr-xr-xcontrib/copy-auditor-backoffice-into-prebuilt.sh10
-rwxr-xr-xcontrib/copy-backend-into-prebuilt.sh8
-rwxr-xr-xcontrib/copy-backoffice-into-prebuilt.sh10
-rwxr-xr-xcontrib/copy-bank-into-prebuilt.sh10
-rwxr-xr-xcontrib/copy-challenger-into-prebuilt.sh10
-rw-r--r--contrib/custom-protocol/README33
-rwxr-xr-xcontrib/custom-protocol/taler-wallet-cli.desktop12
-rwxr-xr-xcontrib/devrelease.sh2
-rwxr-xr-xcontrib/publish-prebuilt-dir.sh15
-rwxr-xr-xcontrib/publish-prebuilt.sh17
-rw-r--r--contrib/sample-data/history1.json402
m---------contrib/wallet-testdata0
-rw-r--r--debian/changelog51
-rwxr-xr-xdebian/rules36
-rw-r--r--package.json19
-rw-r--r--packages/aml-backoffice-ui/.eslintrc.cjs28
-rw-r--r--packages/aml-backoffice-ui/.gitignore4
-rw-r--r--packages/aml-backoffice-ui/Makefile36
-rw-r--r--packages/aml-backoffice-ui/README.md4
-rwxr-xr-xpackages/aml-backoffice-ui/build.mjs28
-rw-r--r--packages/aml-backoffice-ui/copyleft-header.js15
-rwxr-xr-xpackages/aml-backoffice-ui/dev.mjs40
-rw-r--r--packages/aml-backoffice-ui/package.json59
-rw-r--r--packages/aml-backoffice-ui/postcss.config.js6
-rw-r--r--packages/aml-backoffice-ui/src/App.tsx74
-rw-r--r--packages/aml-backoffice-ui/src/Dashboard.tsx234
-rw-r--r--packages/aml-backoffice-ui/src/assets/home.svg3
-rw-r--r--packages/aml-backoffice-ui/src/assets/logo-2021.svg9
-rw-r--r--packages/aml-backoffice-ui/src/assets/people.svg3
-rw-r--r--packages/aml-backoffice-ui/src/context/config.ts100
-rw-r--r--packages/aml-backoffice-ui/src/declaration.d.ts31
-rw-r--r--packages/aml-backoffice-ui/src/forms.ts24
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_11e.ts133
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_12e.ts421
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_13e.ts510
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_15e.ts172
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_1e.ts660
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_4e.ts787
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_5e.ts255
-rw-r--r--packages/aml-backoffice-ui/src/forms/902_9e.ts119
-rw-r--r--packages/aml-backoffice-ui/src/forms/declaration.ts70
-rw-r--r--packages/aml-backoffice-ui/src/forms/icons.tsx10
-rw-r--r--packages/aml-backoffice-ui/src/forms/index.ts202
-rw-r--r--packages/aml-backoffice-ui/src/forms/simplest.ts85
-rw-r--r--packages/aml-backoffice-ui/src/hooks/useBackend.ts48
-rw-r--r--packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts95
-rw-r--r--packages/aml-backoffice-ui/src/hooks/useCases.ts86
-rw-r--r--packages/aml-backoffice-ui/src/hooks/useOfficer.ts135
-rw-r--r--packages/aml-backoffice-ui/src/hooks/useSettings.ts74
-rw-r--r--packages/aml-backoffice-ui/src/i18n/bank.pot486
-rw-r--r--packages/aml-backoffice-ui/src/i18n/de.po486
-rw-r--r--packages/aml-backoffice-ui/src/i18n/en.po511
-rw-r--r--packages/aml-backoffice-ui/src/i18n/es.po497
-rw-r--r--packages/aml-backoffice-ui/src/i18n/fr.po486
-rw-r--r--packages/aml-backoffice-ui/src/i18n/it.po521
-rw-r--r--packages/aml-backoffice-ui/src/i18n/poheader26
-rw-r--r--packages/aml-backoffice-ui/src/i18n/strings-prelude (renamed from packages/taler-wallet-webextension/src/permissions.ts)9
-rw-r--r--packages/aml-backoffice-ui/src/i18n/strings.ts510
-rw-r--r--packages/aml-backoffice-ui/src/index.html42
-rw-r--r--packages/aml-backoffice-ui/src/index.tsx22
-rw-r--r--packages/aml-backoffice-ui/src/pages.ts44
-rw-r--r--packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx104
-rw-r--r--packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx160
-rw-r--r--packages/aml-backoffice-ui/src/pages/CaseDetails.tsx314
-rw-r--r--packages/aml-backoffice-ui/src/pages/Cases.stories.tsx42
-rw-r--r--packages/aml-backoffice-ui/src/pages/Cases.tsx287
-rw-r--r--packages/aml-backoffice-ui/src/pages/CreateAccount.tsx106
-rw-r--r--packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx35
-rw-r--r--packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx104
-rw-r--r--packages/aml-backoffice-ui/src/pages/Officer.tsx60
-rw-r--r--packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx117
-rw-r--r--packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx173
-rw-r--r--packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx79
-rw-r--r--packages/aml-backoffice-ui/src/pages/index.stories.ts3
-rw-r--r--packages/aml-backoffice-ui/src/route.ts197
-rw-r--r--packages/aml-backoffice-ui/src/scss/main.css3
-rw-r--r--packages/aml-backoffice-ui/src/settings.ts (renamed from packages/taler-wallet-core/src/util/debugFlags.ts)27
-rw-r--r--packages/aml-backoffice-ui/src/stories.test.ts71
-rw-r--r--packages/aml-backoffice-ui/src/stories.tsx66
-rw-r--r--packages/aml-backoffice-ui/src/utils/QR.tsx54
-rw-r--r--packages/aml-backoffice-ui/src/utils/converter.ts31
-rw-r--r--packages/aml-backoffice-ui/src/utils/types.ts124
-rw-r--r--packages/aml-backoffice-ui/tailwind.config.js14
-rwxr-xr-xpackages/aml-backoffice-ui/test.mjs31
-rw-r--r--packages/aml-backoffice-ui/tsconfig.json46
-rw-r--r--packages/anastasis-cli/Makefile42
-rw-r--r--packages/anastasis-cli/README.md4
-rwxr-xr-x[-rw-r--r--]packages/anastasis-cli/bin/anastasis-cli.mjs (renamed from packages/taler-wallet-webextension/src/cta/payback.tsx)22
-rwxr-xr-xpackages/anastasis-cli/build-node.mjs70
-rw-r--r--packages/anastasis-cli/package.json44
-rw-r--r--packages/anastasis-cli/src/import-meta-url.js2
-rw-r--r--packages/anastasis-cli/src/index.ts87
-rw-r--r--packages/anastasis-cli/tsconfig.json33
-rw-r--r--packages/anastasis-core/README.md3
-rwxr-xr-xpackages/anastasis-core/bin/anastasis-ts-reducer.js7
-rw-r--r--packages/anastasis-core/package.json20
-rw-r--r--packages/anastasis-core/src/anastasis-data.ts275
-rw-r--r--packages/anastasis-core/src/challenge-feedback-types.ts159
-rw-r--r--packages/anastasis-core/src/crypto.ts107
-rw-r--r--packages/anastasis-core/src/index.ts2201
-rw-r--r--packages/anastasis-core/src/policy-suggestion.test.ts44
-rw-r--r--packages/anastasis-core/src/policy-suggestion.ts243
-rw-r--r--packages/anastasis-core/src/provider-types.ts142
-rw-r--r--packages/anastasis-core/src/recovery-document-types.ts15
-rw-r--r--packages/anastasis-core/src/reducer-types.ts373
-rw-r--r--packages/anastasis-core/src/validators.ts28
-rw-r--r--packages/anastasis-core/tsconfig.json8
-rw-r--r--packages/anastasis-webui/.storybook/main.js57
-rw-r--r--packages/anastasis-webui/.storybook/preview.js49
-rw-r--r--packages/anastasis-webui/README.md18
-rwxr-xr-xpackages/anastasis-webui/build.mjs27
-rw-r--r--packages/anastasis-webui/copyleft-header.js15
-rwxr-xr-xpackages/anastasis-webui/dev.mjs40
-rw-r--r--packages/anastasis-webui/package.json86
-rw-r--r--packages/anastasis-webui/src/.babelrc5
-rw-r--r--packages/anastasis-webui/src/assets/empty.pngbin0 -> 2785 bytes
-rw-r--r--packages/anastasis-webui/src/assets/example/id1.jpgbin0 -> 103558 bytes
-rw-r--r--packages/anastasis-webui/src/assets/icons/auth_method/email.svg1
-rw-r--r--packages/anastasis-webui/src/assets/icons/auth_method/postal.svg1
-rw-r--r--packages/anastasis-webui/src/assets/icons/auth_method/question.svg1
-rw-r--r--packages/anastasis-webui/src/assets/icons/auth_method/sms.svg1
-rw-r--r--packages/anastasis-webui/src/assets/icons/auth_method/video.svg1
-rw-r--r--packages/anastasis-webui/src/components/AsyncButton.tsx64
-rw-r--r--packages/anastasis-webui/src/components/FlieButton.tsx71
-rw-r--r--packages/anastasis-webui/src/components/InvalidState.tsx21
-rw-r--r--packages/anastasis-webui/src/components/NoReducer.tsx21
-rw-r--r--packages/anastasis-webui/src/components/Notifications.tsx74
-rw-r--r--packages/anastasis-webui/src/components/QR.tsx48
-rw-r--r--packages/anastasis-webui/src/components/app.tsx20
-rw-r--r--packages/anastasis-webui/src/components/fields/DateInput.tsx105
-rw-r--r--packages/anastasis-webui/src/components/fields/EmailInput.tsx72
-rw-r--r--packages/anastasis-webui/src/components/fields/FileInput.tsx105
-rw-r--r--packages/anastasis-webui/src/components/fields/ImageInput.tsx93
-rw-r--r--packages/anastasis-webui/src/components/fields/NumberInput.tsx71
-rw-r--r--packages/anastasis-webui/src/components/fields/TextInput.tsx83
-rw-r--r--packages/anastasis-webui/src/components/menu/LangSelector.tsx117
-rw-r--r--packages/anastasis-webui/src/components/menu/NavigationBar.tsx103
-rw-r--r--packages/anastasis-webui/src/components/menu/SideBar.tsx401
-rw-r--r--packages/anastasis-webui/src/components/menu/index.tsx153
-rw-r--r--packages/anastasis-webui/src/components/picker/DatePicker.tsx352
-rw-r--r--packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx46
-rw-r--r--packages/anastasis-webui/src/components/picker/DurationPicker.tsx211
-rw-r--r--packages/anastasis-webui/src/context/anastasis.ts39
-rw-r--r--packages/anastasis-webui/src/context/translation.ts94
-rw-r--r--packages/anastasis-webui/src/declaration.d.ts46
-rw-r--r--packages/anastasis-webui/src/hooks/async.ts97
-rw-r--r--packages/anastasis-webui/src/hooks/index.ts134
-rw-r--r--packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts213
-rw-r--r--packages/anastasis-webui/src/hooks/useLang.ts30
-rw-r--r--packages/anastasis-webui/src/hooks/useLocalStorage.ts80
-rw-r--r--packages/anastasis-webui/src/i18n/index.tsx203
-rw-r--r--packages/anastasis-webui/src/i18n/poheader16
-rw-r--r--packages/anastasis-webui/src/i18n/strings-prelude16
-rw-r--r--packages/anastasis-webui/src/i18n/strings.ts54
-rw-r--r--packages/anastasis-webui/src/i18n/taler-anastasis.pot16
-rw-r--r--packages/anastasis-webui/src/index.html42
-rw-r--r--packages/anastasis-webui/src/index.test.ts82
-rw-r--r--packages/anastasis-webui/src/index.ts44
-rw-r--r--packages/anastasis-webui/src/manifest.json6
-rw-r--r--packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts104
-rw-r--r--packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts157
-rw-r--r--packages/anastasis-webui/src/pages/home/AddingProviderScreen/stories.tsx93
-rw-r--r--packages/anastasis-webui/src/pages/home/AddingProviderScreen/test.ts45
-rw-r--r--packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx309
-rw-r--r--packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx184
-rw-r--r--packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx288
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx42
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx69
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx46
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx51
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx109
-rw-r--r--packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx300
-rw-r--r--packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx69
-rw-r--r--packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx98
-rw-r--r--packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx297
-rw-r--r--packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx318
-rw-r--r--packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx42
-rw-r--r--packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx45
-rw-r--r--packages/anastasis-webui/src/pages/home/ConfirmModal.tsx84
-rw-r--r--packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx61
-rw-r--r--packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx163
-rw-r--r--packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx27
-rw-r--r--packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx141
-rw-r--r--packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx187
-rw-r--r--packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx65
-rw-r--r--packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx35
-rw-r--r--packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx59
-rw-r--r--packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx132
-rw-r--r--packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx315
-rw-r--r--packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx165
-rw-r--r--packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx54
-rw-r--r--packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx113
-rw-r--r--packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx99
-rw-r--r--packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx497
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx25
-rw-r--r--packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx23
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx23
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx135
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveScreen.tsx277
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx25
-rw-r--r--packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx12
-rw-r--r--packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx43
-rw-r--r--packages/anastasis-webui/src/pages/home/StartScreen.tsx74
-rw-r--r--packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx49
-rw-r--r--packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx32
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx82
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx113
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx92
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx195
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx81
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx129
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx60
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx118
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx82
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx161
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx60
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx160
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx84
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx127
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx239
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx128
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx82
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx127
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx61
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx191
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx80
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx141
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx60
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx125
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/helpers.ts27
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/index.tsx109
-rw-r--r--packages/anastasis-webui/src/pages/home/authMethod/totp.ts79
-rw-r--r--packages/anastasis-webui/src/pages/home/index.stories.tsx52
-rw-r--r--packages/anastasis-webui/src/pages/home/index.tsx336
-rw-r--r--packages/anastasis-webui/src/pages/home/style.css0
-rw-r--r--packages/anastasis-webui/src/pages/notfound/index.tsx16
-rw-r--r--packages/anastasis-webui/src/pages/notfound/style.css0
-rw-r--r--packages/anastasis-webui/src/pages/profile/index.tsx43
-rw-r--r--packages/anastasis-webui/src/pages/profile/style.css0
-rw-r--r--packages/anastasis-webui/src/scss/DurationPicker.scss1
-rw-r--r--packages/anastasis-webui/src/scss/_aside.scss116
-rw-r--r--packages/anastasis-webui/src/scss/_card.scss20
-rw-r--r--packages/anastasis-webui/src/scss/_custom-calendar.scss103
-rw-r--r--packages/anastasis-webui/src/scss/_footer.scss18
-rw-r--r--packages/anastasis-webui/src/scss/_form.scss49
-rw-r--r--packages/anastasis-webui/src/scss/_hero-bar.scss24
-rw-r--r--packages/anastasis-webui/src/scss/_loading.scss16
-rw-r--r--packages/anastasis-webui/src/scss/_main-section.scss18
-rw-r--r--packages/anastasis-webui/src/scss/_misc.scss16
-rw-r--r--packages/anastasis-webui/src/scss/_mixins.scss22
-rw-r--r--packages/anastasis-webui/src/scss/_modal.scss18
-rw-r--r--packages/anastasis-webui/src/scss/_nav-bar.scss32
-rw-r--r--packages/anastasis-webui/src/scss/_table.scss44
-rw-r--r--packages/anastasis-webui/src/scss/_theme-default.scss16
-rw-r--r--packages/anastasis-webui/src/scss/_tiles.scss19
-rw-r--r--packages/anastasis-webui/src/scss/_title-bar.scss24
-rw-r--r--packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.eot (renamed from packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eot)bin844600 -> 844600 bytes
-rw-r--r--packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.ttf (renamed from packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttf)bin844380 -> 844380 bytes
-rw-r--r--packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.woff (renamed from packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff)bin404384 -> 404384 bytes
-rw-r--r--packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.woff2 (renamed from packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2)bin283040 -> 283040 bytes
-rw-r--r--packages/anastasis-webui/src/scss/fonts/nunito.css20
-rw-r--r--packages/anastasis-webui/src/scss/icons/materialdesignicons-4.9.95.min.css15108
-rw-r--r--packages/anastasis-webui/src/scss/libs/_all.scss18
-rw-r--r--packages/anastasis-webui/src/scss/main.scss41
-rw-r--r--packages/anastasis-webui/src/stories.tsx46
-rw-r--r--packages/anastasis-webui/src/style/index.css0
-rw-r--r--packages/anastasis-webui/src/template.html15
-rw-r--r--packages/anastasis-webui/src/utils/index.tsx270
-rwxr-xr-xpackages/anastasis-webui/test.mjs30
-rw-r--r--packages/anastasis-webui/tests/__mocks__/browserMocks.ts21
-rw-r--r--packages/anastasis-webui/tests/__mocks__/fileMocks.ts3
-rw-r--r--packages/anastasis-webui/tests/__mocks__/setupTests.ts6
-rw-r--r--packages/anastasis-webui/tests/declarations.d.ts3
-rw-r--r--packages/anastasis-webui/tsconfig.json45
-rw-r--r--packages/auditor-backoffice-ui/.gitignore6
-rw-r--r--packages/auditor-backoffice-ui/DESIGN.md195
-rw-r--r--packages/auditor-backoffice-ui/Makefile35
-rw-r--r--packages/auditor-backoffice-ui/README.md64
-rwxr-xr-xpackages/auditor-backoffice-ui/build.mjs28
-rwxr-xr-xpackages/auditor-backoffice-ui/contrib/po2ts42
-rw-r--r--packages/auditor-backoffice-ui/copyleft-header.js15
-rwxr-xr-xpackages/auditor-backoffice-ui/dev.mjs40
-rw-r--r--packages/auditor-backoffice-ui/package.json84
-rw-r--r--packages/auditor-backoffice-ui/preact.config.js70
-rw-r--r--packages/auditor-backoffice-ui/preact.single-config.js62
-rw-r--r--packages/auditor-backoffice-ui/remove-link-stylesheet.sh8
-rw-r--r--packages/auditor-backoffice-ui/src/AdminRoutes.tsx53
-rw-r--r--packages/auditor-backoffice-ui/src/Application.tsx165
-rw-r--r--packages/auditor-backoffice-ui/src/ApplicationReadyRoutes.tsx175
-rw-r--r--packages/auditor-backoffice-ui/src/InstanceRoutes.tsx485
-rw-r--r--packages/auditor-backoffice-ui/src/assets/empty.pngbin0 -> 2785 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/assets/icons/android-chrome-192x192.pngbin0 -> 14058 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/assets/icons/android-chrome-512x512.pngbin0 -> 51484 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/assets/icons/apple-touch-icon.pngbin0 -> 12746 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/assets/icons/favicon-16x16.pngbin0 -> 626 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/assets/icons/favicon-32x32.pngbin0 -> 1487 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/assets/icons/languageicon.svg48
-rw-r--r--packages/auditor-backoffice-ui/src/assets/icons/mstile-150x150.pngbin0 -> 9050 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/assets/logo-2021.svg9
-rw-r--r--packages/auditor-backoffice-ui/src/assets/logo.jpegbin0 -> 39336 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/components/exception/AsyncButton.tsx55
-rw-r--r--packages/auditor-backoffice-ui/src/components/exception/QR.tsx49
-rw-r--r--packages/auditor-backoffice-ui/src/components/exception/loading.tsx48
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/FormProvider.tsx109
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/Input.tsx116
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputArray.tsx139
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputBoolean.tsx91
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputCurrency.tsx67
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputDate.tsx164
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputDuration.tsx186
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputGroup.tsx86
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputImage.tsx122
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputLocation.tsx53
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputNumber.tsx60
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputPayto.tsx52
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx47
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx397
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputSearchOnList.tsx204
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputSecured.stories.tsx61
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputSecured.tsx186
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputSelector.tsx94
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputStock.stories.tsx162
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputStock.tsx224
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputTab.tsx90
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputTaxes.tsx147
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputToggle.tsx91
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/InputWithAddon.tsx116
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/JumpToElementById.tsx59
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/TextField.tsx71
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/useField.tsx92
-rw-r--r--packages/auditor-backoffice-ui/src/components/form/useGroupField.tsx41
-rw-r--r--packages/auditor-backoffice-ui/src/components/index.stories.ts17
-rw-r--r--packages/auditor-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx124
-rw-r--r--packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx92
-rw-r--r--packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx72
-rw-r--r--packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx284
-rw-r--r--packages/auditor-backoffice-ui/src/components/menu/index.tsx237
-rw-r--r--packages/auditor-backoffice-ui/src/components/modal/index.tsx496
-rw-r--r--packages/auditor-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx57
-rw-r--r--packages/auditor-backoffice-ui/src/components/notifications/Notifications.stories.tsx62
-rw-r--r--packages/auditor-backoffice-ui/src/components/notifications/index.tsx65
-rw-r--r--packages/auditor-backoffice-ui/src/components/picker/DatePicker.tsx349
-rw-r--r--packages/auditor-backoffice-ui/src/components/picker/DurationPicker.stories.tsx55
-rw-r--r--packages/auditor-backoffice-ui/src/components/picker/DurationPicker.tsx211
-rw-r--r--packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx62
-rw-r--r--packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.tsx127
-rw-r--r--packages/auditor-backoffice-ui/src/components/product/NonInventoryProductForm.tsx215
-rw-r--r--packages/auditor-backoffice-ui/src/components/product/ProductForm.tsx178
-rw-r--r--packages/auditor-backoffice-ui/src/components/product/ProductList.tsx106
-rw-r--r--packages/auditor-backoffice-ui/src/context/backend.test.ts163
-rw-r--r--packages/auditor-backoffice-ui/src/context/backend.ts70
-rw-r--r--packages/auditor-backoffice-ui/src/context/config.ts32
-rw-r--r--packages/auditor-backoffice-ui/src/context/instance.ts36
-rw-r--r--packages/auditor-backoffice-ui/src/custom.d.ts42
-rw-r--r--packages/auditor-backoffice-ui/src/declaration.d.ts1793
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/async.ts77
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/backend.ts477
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/bank.ts217
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts161
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/index.ts151
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/instance.test.ts741
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/instance.ts313
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/listener.ts85
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/notifications.ts56
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/order.test.ts587
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/order.ts289
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/otp.ts223
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/product.test.ts362
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/product.ts177
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/reserve.test.ts448
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/reserves.ts181
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/templates.ts266
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/testing.tsx180
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/transfer.test.ts254
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/transfer.ts188
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/urls.ts303
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/useSettings.ts73
-rw-r--r--packages/auditor-backoffice-ui/src/hooks/webhooks.ts178
-rw-r--r--packages/auditor-backoffice-ui/src/i18n/de.po2742
-rw-r--r--packages/auditor-backoffice-ui/src/i18n/en.po2741
-rw-r--r--packages/auditor-backoffice-ui/src/i18n/es.po2854
-rw-r--r--packages/auditor-backoffice-ui/src/i18n/fr.po2741
-rw-r--r--packages/auditor-backoffice-ui/src/i18n/it.po2742
-rw-r--r--packages/auditor-backoffice-ui/src/i18n/poheader27
-rw-r--r--packages/auditor-backoffice-ui/src/i18n/strings-prelude19
-rw-r--r--packages/auditor-backoffice-ui/src/i18n/strings.ts9655
-rw-r--r--packages/auditor-backoffice-ui/src/i18n/sv.po2741
-rw-r--r--packages/auditor-backoffice-ui/src/i18n/taler-merchant-backoffice.pot2726
-rw-r--r--packages/auditor-backoffice-ui/src/index.html45
-rw-r--r--packages/auditor-backoffice-ui/src/index.tsx24
-rw-r--r--packages/auditor-backoffice-ui/src/paths/admin/create/Create.stories.tsx57
-rw-r--r--packages/auditor-backoffice-ui/src/paths/admin/create/CreatePage.tsx257
-rw-r--r--packages/auditor-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx74
-rw-r--r--packages/auditor-backoffice-ui/src/paths/admin/create/index.tsx82
-rw-r--r--packages/auditor-backoffice-ui/src/paths/admin/create/stories.tsx52
-rw-r--r--packages/auditor-backoffice-ui/src/paths/admin/index.stories.ts18
-rw-r--r--packages/auditor-backoffice-ui/src/paths/admin/list/TableActive.tsx287
-rw-r--r--packages/auditor-backoffice-ui/src/paths/admin/list/View.stories.tsx90
-rw-r--r--packages/auditor-backoffice-ui/src/paths/admin/list/View.tsx110
-rw-r--r--packages/auditor-backoffice-ui/src/paths/admin/list/index.tsx140
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx28
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx173
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/accounts/create/index.tsx65
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx28
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx64
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/accounts/list/Table.tsx385
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/accounts/list/index.tsx107
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx32
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx195
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/accounts/update/index.tsx96
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/Create.stories.tsx (renamed from packages/anastasis-webui/src/pages/home/CountrySelectionScreen.stories.tsx)31
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatePage.tsx80
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatedSuccessfully.tsx69
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/index.tsx46
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/List.stories.tsx43
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/Table.tsx249
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/index.tsx126
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/Update.stories.tsx73
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/UpdatePage.tsx99
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/index.tsx95
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/details/DetailPage.tsx83
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/details/index.tsx87
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/details/stories.tsx68
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/index.stories.ts19
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx58
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx208
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/kyc/list/index.tsx63
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx71
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx705
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx114
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx114
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx135
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx770
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx129
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/details/index.tsx95
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx107
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx226
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/list/Table.tsx417
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/orders/list/index.tsx231
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx28
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx179
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx104
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx70
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx28
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx64
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx211
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx106
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx32
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx186
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx102
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/products/create/Create.stories.tsx (renamed from packages/taler-wallet-webextension/src/popup/Settings.stories.tsx)38
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx80
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx72
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/products/create/index.tsx47
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/products/list/List.stories.tsx61
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/products/list/Table.tsx496
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/products/list/index.tsx150
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx73
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx99
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/products/update/index.tsx95
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/create/Create.stories.tsx43
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx277
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.stories.tsx120
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx190
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/create/index.tsx71
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx266
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx126
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx94
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/details/index.tsx68
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx124
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx102
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx96
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/list/Table.tsx320
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/reserves/list/index.tsx171
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx28
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx259
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/create/index.tsx61
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx28
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx68
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/list/Table.tsx235
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/list/index.tsx152
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/qr/Qr.stories.tsx (renamed from packages/taler-wallet-webextension/tests/__mocks__/fileMocks.ts)13
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx172
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/qr/index.tsx80
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx32
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx254
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/update/index.tsx99
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx27
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx143
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/templates/use/index.tsx101
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/token/DetailPage.tsx183
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/token/index.tsx106
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/token/stories.tsx28
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx45
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx146
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/transfers/create/index.tsx68
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx93
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx134
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/transfers/list/Table.tsx229
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/transfers/list/index.tsx118
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/transfers/update/index.tsx (renamed from packages/taler-wallet-webextension/.storybook/.babelrc)16
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/update/Update.stories.tsx59
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/update/UpdatePage.tsx176
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/update/index.tsx118
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx28
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx183
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/index.tsx61
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx28
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx64
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx218
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/index.tsx109
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx32
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx146
-rw-r--r--packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/index.tsx99
-rw-r--r--packages/auditor-backoffice-ui/src/paths/login/index.tsx202
-rw-r--r--packages/auditor-backoffice-ui/src/paths/notfound/index.tsx34
-rw-r--r--packages/auditor-backoffice-ui/src/paths/settings/index.tsx112
-rw-r--r--packages/auditor-backoffice-ui/src/schemas/index.ts245
-rw-r--r--packages/auditor-backoffice-ui/src/scss/DurationPicker.scss70
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_aside.scss181
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_card.scss69
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_custom-calendar.scss259
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_footer.scss35
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_form.scss71
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_hero-bar.scss55
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_loading.scss51
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_main-section.scss24
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_misc.scss50
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_mixins.scss34
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_modal.scss35
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_nav-bar.scss144
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_table.scss179
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_theme-default.scss136
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_tiles.scss24
-rw-r--r--packages/auditor-backoffice-ui/src/scss/_title-bar.scss50
-rw-r--r--packages/auditor-backoffice-ui/src/scss/fonts/XRXV3I6Li01BKofINeaE.ttfbin0 -> 43752 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/scss/fonts/nunito.css22
-rw-r--r--packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eotbin0 -> 844600 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttfbin0 -> 844380 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woffbin0 -> 404384 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2bin0 -> 283040 bytes
-rw-r--r--packages/auditor-backoffice-ui/src/scss/icons/materialdesignicons-4.9.95.min.css15109
-rw-r--r--packages/auditor-backoffice-ui/src/scss/libs/_all.scss29
-rw-r--r--packages/auditor-backoffice-ui/src/scss/main.scss195
-rw-r--r--packages/auditor-backoffice-ui/src/scss/toggle.scss51
-rw-r--r--packages/auditor-backoffice-ui/src/stories.test.ts44
-rw-r--r--packages/auditor-backoffice-ui/src/stories.tsx48
-rw-r--r--packages/auditor-backoffice-ui/src/sw.js25
-rw-r--r--packages/auditor-backoffice-ui/src/utils/amount.ts71
-rw-r--r--packages/auditor-backoffice-ui/src/utils/constants.ts197
-rw-r--r--packages/auditor-backoffice-ui/src/utils/regex.test.ts88
-rw-r--r--packages/auditor-backoffice-ui/src/utils/table.ts57
-rw-r--r--packages/auditor-backoffice-ui/src/utils/types.ts (renamed from packages/taler-wallet-webextension/tests/__mocks__/fileTransformer.js)26
-rwxr-xr-xpackages/auditor-backoffice-ui/test.mjs31
-rw-r--r--packages/auditor-backoffice-ui/tsconfig.json58
-rw-r--r--packages/bank-ui/.eslintrc.cjs28
-rw-r--r--packages/bank-ui/.gitignore4
-rw-r--r--packages/bank-ui/Makefile37
-rw-r--r--packages/bank-ui/README.md24
-rwxr-xr-xpackages/bank-ui/build.mjs28
-rwxr-xr-xpackages/bank-ui/contrib/po2ts42
-rw-r--r--packages/bank-ui/copyleft-header.js15
-rwxr-xr-xpackages/bank-ui/dev.mjs41
-rw-r--r--packages/bank-ui/package.json52
-rw-r--r--packages/bank-ui/postcss.config.js6
-rw-r--r--packages/bank-ui/src/Routing.tsx612
-rw-r--r--packages/bank-ui/src/app.tsx234
-rw-r--r--packages/bank-ui/src/assets/empty.pngbin0 -> 2785 bytes
-rw-r--r--packages/bank-ui/src/assets/example/id1.jpgbin0 -> 103558 bytes
-rw-r--r--packages/bank-ui/src/assets/favicon.icobin0 -> 15086 bytes
-rw-r--r--packages/bank-ui/src/assets/icons/android-chrome-192x192.pngbin0 -> 14058 bytes
-rw-r--r--packages/bank-ui/src/assets/icons/android-chrome-512x512.pngbin0 -> 51484 bytes
-rw-r--r--packages/bank-ui/src/assets/icons/apple-touch-icon.pngbin0 -> 12746 bytes
-rw-r--r--packages/bank-ui/src/assets/icons/favicon-16x16.pngbin0 -> 626 bytes
-rw-r--r--packages/bank-ui/src/assets/icons/favicon-32x32.pngbin0 -> 1487 bytes
-rw-r--r--packages/bank-ui/src/assets/icons/languageicon.svg48
-rw-r--r--packages/bank-ui/src/assets/icons/mstile-150x150.pngbin0 -> 9050 bytes
-rw-r--r--packages/bank-ui/src/assets/logo-2021.svg9
-rw-r--r--packages/bank-ui/src/assets/logo-white.svg45
-rw-r--r--packages/bank-ui/src/assets/logo.jpegbin0 -> 39336 bytes
-rw-r--r--packages/bank-ui/src/components/Cashouts/index.ts85
-rw-r--r--packages/bank-ui/src/components/Cashouts/state.ts51
-rw-r--r--packages/bank-ui/src/components/Cashouts/stories.tsx29
-rw-r--r--packages/bank-ui/src/components/Cashouts/test.ts68
-rw-r--r--packages/bank-ui/src/components/Cashouts/views.tsx218
-rw-r--r--packages/bank-ui/src/components/EmptyComponentExample/index.ts56
-rw-r--r--packages/bank-ui/src/components/EmptyComponentExample/state.ts25
-rw-r--r--packages/bank-ui/src/components/EmptyComponentExample/stories.tsx29
-rw-r--r--packages/bank-ui/src/components/EmptyComponentExample/test.ts28
-rw-r--r--packages/bank-ui/src/components/EmptyComponentExample/views.tsx25
-rw-r--r--packages/bank-ui/src/components/ErrorLoadingWithDebug.tsx24
-rw-r--r--packages/bank-ui/src/components/QR.tsx51
-rw-r--r--packages/bank-ui/src/components/Time.tsx80
-rw-r--r--packages/bank-ui/src/components/Transactions/index.ts84
-rw-r--r--packages/bank-ui/src/components/Transactions/state.ts90
-rw-r--r--packages/bank-ui/src/components/Transactions/stories.tsx44
-rw-r--r--packages/bank-ui/src/components/Transactions/test.ts202
-rw-r--r--packages/bank-ui/src/components/Transactions/views.tsx252
-rw-r--r--packages/bank-ui/src/components/index.examples.ts17
-rw-r--r--packages/bank-ui/src/context/config.ts320
-rw-r--r--packages/bank-ui/src/context/navigation.ts92
-rw-r--r--packages/bank-ui/src/context/settings.ts44
-rw-r--r--packages/bank-ui/src/context/wallet-integration.ts83
-rw-r--r--packages/bank-ui/src/declaration.d.ts35
-rw-r--r--packages/bank-ui/src/hooks/account.ts313
-rw-r--r--packages/bank-ui/src/hooks/bank-state.ts185
-rw-r--r--packages/bank-ui/src/hooks/form.ts115
-rw-r--r--packages/bank-ui/src/hooks/preferences.ts111
-rw-r--r--packages/bank-ui/src/hooks/regional.ts507
-rw-r--r--packages/bank-ui/src/hooks/session.ts134
-rw-r--r--packages/bank-ui/src/i18n/bank.pot1740
-rw-r--r--packages/bank-ui/src/i18n/de.po1780
-rw-r--r--packages/bank-ui/src/i18n/en.po1784
-rw-r--r--packages/bank-ui/src/i18n/es.po2063
-rw-r--r--packages/bank-ui/src/i18n/fr.po1752
-rw-r--r--packages/bank-ui/src/i18n/it.po1843
-rw-r--r--packages/bank-ui/src/i18n/poheader26
-rw-r--r--packages/bank-ui/src/i18n/strings.ts2295
-rw-r--r--packages/bank-ui/src/i18n/uk.po1743
-rw-r--r--packages/bank-ui/src/index.html41
-rw-r--r--packages/bank-ui/src/index.tsx27
-rw-r--r--packages/bank-ui/src/manifest.json21
-rw-r--r--packages/bank-ui/src/pages/AccountPage/index.ts135
-rw-r--r--packages/bank-ui/src/pages/AccountPage/state.ts122
-rw-r--r--packages/bank-ui/src/pages/AccountPage/stories.tsx29
-rw-r--r--packages/bank-ui/src/pages/AccountPage/test.ts31
-rw-r--r--packages/bank-ui/src/pages/AccountPage/views.tsx156
-rw-r--r--packages/bank-ui/src/pages/BankFrame.stories.tsx29
-rw-r--r--packages/bank-ui/src/pages/BankFrame.tsx368
-rw-r--r--packages/bank-ui/src/pages/LoginForm.tsx230
-rw-r--r--packages/bank-ui/src/pages/OperationState/index.ts157
-rw-r--r--packages/bank-ui/src/pages/OperationState/state.ts234
-rw-r--r--packages/bank-ui/src/pages/OperationState/stories.tsx29
-rw-r--r--packages/bank-ui/src/pages/OperationState/test.ts31
-rw-r--r--packages/bank-ui/src/pages/OperationState/views.tsx447
-rw-r--r--packages/bank-ui/src/pages/PaymentOptions.stories.tsx35
-rw-r--r--packages/bank-ui/src/pages/PaymentOptions.tsx239
-rw-r--r--packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx35
-rw-r--r--packages/bank-ui/src/pages/PaytoWireTransferForm.tsx1000
-rw-r--r--packages/bank-ui/src/pages/ProfileNavigation.tsx202
-rw-r--r--packages/bank-ui/src/pages/PublicHistoriesPage.tsx98
-rw-r--r--packages/bank-ui/src/pages/QrCodeSection.stories.tsx32
-rw-r--r--packages/bank-ui/src/pages/QrCodeSection.tsx152
-rw-r--r--packages/bank-ui/src/pages/RegistrationPage.tsx425
-rw-r--r--packages/bank-ui/src/pages/ShowNotifications.tsx55
-rw-r--r--packages/bank-ui/src/pages/SolveChallengePage.tsx793
-rw-r--r--packages/bank-ui/src/pages/WalletWithdrawForm.tsx404
-rw-r--r--packages/bank-ui/src/pages/WireTransfer.tsx119
-rw-r--r--packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx425
-rw-r--r--packages/bank-ui/src/pages/WithdrawalOperationPage.tsx73
-rw-r--r--packages/bank-ui/src/pages/WithdrawalQRCode.tsx310
-rw-r--r--packages/bank-ui/src/pages/account/CashoutListForAccount.tsx86
-rw-r--r--packages/bank-ui/src/pages/account/ShowAccountDetails.tsx491
-rw-r--r--packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx319
-rw-r--r--packages/bank-ui/src/pages/admin/AccountForm.tsx804
-rw-r--r--packages/bank-ui/src/pages/admin/AccountList.tsx234
-rw-r--r--packages/bank-ui/src/pages/admin/AdminHome.tsx623
-rw-r--r--packages/bank-ui/src/pages/admin/CreateNewAccount.tsx217
-rw-r--r--packages/bank-ui/src/pages/admin/DownloadStats.tsx588
-rw-r--r--packages/bank-ui/src/pages/admin/RemoveAccount.tsx273
-rw-r--r--packages/bank-ui/src/pages/index.stories.tsx20
-rw-r--r--packages/bank-ui/src/pages/regional/ConversionConfig.tsx1170
-rw-r--r--packages/bank-ui/src/pages/regional/CreateCashout.tsx717
-rw-r--r--packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx194
-rw-r--r--packages/bank-ui/src/pages/rnd.ts2907
-rw-r--r--packages/bank-ui/src/route.ts139
-rw-r--r--packages/bank-ui/src/scss/main.css3
-rw-r--r--packages/bank-ui/src/settings.json11
-rw-r--r--packages/bank-ui/src/settings.ts112
-rw-r--r--packages/bank-ui/src/stories.test.ts83
-rw-r--r--packages/bank-ui/src/stories.tsx41
-rw-r--r--packages/bank-ui/src/utils.ts439
-rw-r--r--packages/bank-ui/tailwind.config.js14
-rwxr-xr-xpackages/bank-ui/test.mjs32
-rw-r--r--packages/bank-ui/tsconfig.json46
-rw-r--r--packages/challenger-ui/.gitignore4
-rw-r--r--packages/challenger-ui/Makefile36
-rw-r--r--packages/challenger-ui/README.md4
-rwxr-xr-xpackages/challenger-ui/build.mjs42
-rw-r--r--packages/challenger-ui/copyleft-header.js15
-rwxr-xr-xpackages/challenger-ui/create_must.sh25
-rwxr-xr-xpackages/challenger-ui/dev.mjs52
-rw-r--r--packages/challenger-ui/package.json48
-rw-r--r--packages/challenger-ui/postcss.config.js6
-rw-r--r--packages/challenger-ui/src/assets/home.svg3
-rw-r--r--packages/challenger-ui/src/assets/logo-2021.svg9
-rw-r--r--packages/challenger-ui/src/assets/people.svg3
-rw-r--r--packages/challenger-ui/src/attempts-exhausted.html88
-rw-r--r--packages/challenger-ui/src/enter-address-form.html133
-rw-r--r--packages/challenger-ui/src/enter-email-form.html127
-rw-r--r--packages/challenger-ui/src/enter-file-access-form.html102
-rw-r--r--packages/challenger-ui/src/enter-phone-form.html126
-rw-r--r--packages/challenger-ui/src/enter-tan-form.html117
-rw-r--r--packages/challenger-ui/src/internal-error.html89
-rw-r--r--packages/challenger-ui/src/invalid-pin.html87
-rw-r--r--packages/challenger-ui/src/invalid-request.html88
-rw-r--r--packages/challenger-ui/src/main.js2
-rw-r--r--packages/challenger-ui/src/scss/main.css3
-rw-r--r--packages/challenger-ui/src/validation-unknown.html89
-rw-r--r--packages/challenger-ui/tailwind.config.js14
-rw-r--r--packages/idb-bridge/.gitignore1
-rw-r--r--packages/idb-bridge/package-lock.json2595
-rw-r--r--packages/idb-bridge/package.json41
-rw-r--r--packages/idb-bridge/rollup.config.js31
-rw-r--r--packages/idb-bridge/src/MemoryBackend.test.ts293
-rw-r--r--packages/idb-bridge/src/MemoryBackend.ts1083
-rw-r--r--packages/idb-bridge/src/SqliteBackend.test.ts83
-rw-r--r--packages/idb-bridge/src/SqliteBackend.ts2329
-rw-r--r--packages/idb-bridge/src/backend-common.ts29
-rw-r--r--packages/idb-bridge/src/backend-interface.ts148
-rw-r--r--packages/idb-bridge/src/backends.test.ts740
-rw-r--r--packages/idb-bridge/src/bench.ts110
-rw-r--r--packages/idb-bridge/src/bridge-idb.ts588
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/abort-in-initial-upgradeneeded.test.ts4
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/close-in-upgradeneeded.test.ts78
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/cursor-overloads.test.ts213
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/event-dispatch-active-flag.test.ts16
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbcursor-advance-index.test.ts8
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbcursor-continue-index.test.ts697
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbcursor-continue-objectstore.test.ts441
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-exception-order.test.ts4
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-index.test.ts356
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-objectstore.test.ts332
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbcursor-reused.test.ts8
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbcursor-update-index.test.ts600
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbfactory-cmp.test.ts6
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbfactory-open.test.ts31
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbindex-get.test.ts10
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbindex-openCursor.test.ts127
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-add-put-exception-order.test.ts9
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-add.test.ts8
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-get.test.ts277
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-put.test.ts778
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-rename-store.test.ts5
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/idbtransaction-oncomplete.test.ts4
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/keypath.test.ts6
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/request-bubble-and-capture.test.ts7
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/transaction-requestqueue.test.ts8
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/value.test.ts70
-rw-r--r--packages/idb-bridge/src/idb-wpt-ported/wptsupport.ts36
-rw-r--r--packages/idb-bridge/src/idbpromutil.ts26
-rw-r--r--packages/idb-bridge/src/idbtypes.ts26
-rw-r--r--packages/idb-bridge/src/index.ts62
-rw-r--r--packages/idb-bridge/src/node-sqlite3-impl.ts84
-rw-r--r--packages/idb-bridge/src/sqlite3-interface.ts34
-rw-r--r--packages/idb-bridge/src/testingdb.ts43
-rw-r--r--packages/idb-bridge/src/tree/b+tree.ts873
-rw-r--r--packages/idb-bridge/src/tree/interfaces.ts28
-rw-r--r--packages/idb-bridge/src/util/FakeDomEvent.ts103
-rw-r--r--packages/idb-bridge/src/util/FakeEvent.ts4
-rw-r--r--packages/idb-bridge/src/util/FakeEventTarget.ts8
-rw-r--r--packages/idb-bridge/src/util/canInjectKey.test.ts2
-rw-r--r--packages/idb-bridge/src/util/canInjectKey.ts2
-rw-r--r--packages/idb-bridge/src/util/cmp.ts4
-rw-r--r--packages/idb-bridge/src/util/extractKey.ts10
-rw-r--r--packages/idb-bridge/src/util/fakeDOMStringList.ts3
-rw-r--r--packages/idb-bridge/src/util/getIndexKeys.test.ts14
-rw-r--r--packages/idb-bridge/src/util/getIndexKeys.ts9
-rw-r--r--packages/idb-bridge/src/util/key-storage.test.ts39
-rw-r--r--packages/idb-bridge/src/util/key-storage.ts363
-rw-r--r--packages/idb-bridge/src/util/makeStoreKeyValue.test.ts68
-rw-r--r--packages/idb-bridge/src/util/makeStoreKeyValue.ts30
-rw-r--r--packages/idb-bridge/src/util/normalizeKeyPath.ts2
-rw-r--r--packages/idb-bridge/src/util/queueTask.ts5
-rw-r--r--packages/idb-bridge/src/util/structuredClone.test.ts70
-rw-r--r--packages/idb-bridge/src/util/structuredClone.ts419
-rw-r--r--packages/idb-bridge/src/util/validateKeyPath.ts4
-rw-r--r--packages/idb-bridge/src/util/valueToKey.ts10
-rw-r--r--packages/idb-bridge/tsconfig.json44
-rw-r--r--packages/merchant-backend-ui/.gitignore9
-rw-r--r--packages/merchant-backend-ui/README.md26
-rw-r--r--packages/merchant-backend-ui/babel.config-linaria.json27
-rwxr-xr-xpackages/merchant-backend-ui/build.mjs190
-rwxr-xr-xpackages/merchant-backend-ui/contrib/po2ts42
-rw-r--r--packages/merchant-backend-ui/copyleft-header.js15
-rw-r--r--packages/merchant-backend-ui/package.json69
-rw-r--r--packages/merchant-backend-ui/src/assets/empty.pngbin0 -> 2785 bytes
-rw-r--r--packages/merchant-backend-ui/src/assets/icons/android-chrome-192x192.pngbin0 -> 14058 bytes
-rw-r--r--packages/merchant-backend-ui/src/assets/icons/android-chrome-512x512.pngbin0 -> 51484 bytes
-rw-r--r--packages/merchant-backend-ui/src/assets/icons/apple-touch-icon.pngbin0 -> 12746 bytes
-rw-r--r--packages/merchant-backend-ui/src/assets/icons/favicon-16x16.pngbin0 -> 626 bytes
-rw-r--r--packages/merchant-backend-ui/src/assets/icons/favicon-32x32.pngbin0 -> 1487 bytes
-rw-r--r--packages/merchant-backend-ui/src/assets/icons/languageicon.svg48
-rw-r--r--packages/merchant-backend-ui/src/assets/icons/mstile-150x150.pngbin0 -> 9050 bytes
-rw-r--r--packages/merchant-backend-ui/src/components/Footer.tsx (renamed from packages/taler-wallet-webextension/tests/__mocks__/setupTests.ts)29
-rw-r--r--packages/merchant-backend-ui/src/components/QR.tsx54
-rw-r--r--packages/merchant-backend-ui/src/css/pure-min.css973
-rw-r--r--packages/merchant-backend-ui/src/css/style.css61
-rw-r--r--packages/merchant-backend-ui/src/custom.d.ts (renamed from packages/taler-wallet-webextension/src/hooks/useLang.ts)29
-rw-r--r--packages/merchant-backend-ui/src/declaration.d.ts1387
-rw-r--r--packages/merchant-backend-ui/src/pages/OfferRefund.stories.tsx (renamed from packages/taler-wallet-webextension/src/wallet/ReserveCreated.stories.tsx)27
-rw-r--r--packages/merchant-backend-ui/src/pages/OfferRefund.tsx158
-rw-r--r--packages/merchant-backend-ui/src/pages/RequestPayment.stories.tsx (renamed from packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx)37
-rw-r--r--packages/merchant-backend-ui/src/pages/RequestPayment.tsx203
-rw-r--r--packages/merchant-backend-ui/src/pages/ShowOrderDetails.examples.ts253
-rw-r--r--packages/merchant-backend-ui/src/pages/ShowOrderDetails.stories.tsx49
-rw-r--r--packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx566
-rw-r--r--packages/merchant-backend-ui/src/render-examples.ts112
-rw-r--r--packages/merchant-backend-ui/src/styled/index.tsx178
-rw-r--r--packages/merchant-backend-ui/src/utils.ts (renamed from packages/taler-wallet-webextension/src/popup/Popup.stories.tsx)45
-rw-r--r--packages/merchant-backend-ui/trim-extension.cjs23
-rw-r--r--packages/merchant-backend-ui/tsconfig.json61
-rw-r--r--packages/merchant-backoffice-ui/.eslintrc.cjs28
-rw-r--r--packages/merchant-backoffice-ui/.gitignore6
-rw-r--r--packages/merchant-backoffice-ui/DESIGN.md195
-rw-r--r--packages/merchant-backoffice-ui/Makefile35
-rw-r--r--packages/merchant-backoffice-ui/README.md64
-rwxr-xr-xpackages/merchant-backoffice-ui/build.mjs28
-rwxr-xr-xpackages/merchant-backoffice-ui/contrib/po2ts42
-rw-r--r--packages/merchant-backoffice-ui/copyleft-header.js15
-rwxr-xr-xpackages/merchant-backoffice-ui/dev.mjs40
-rw-r--r--packages/merchant-backoffice-ui/package.json70
-rw-r--r--packages/merchant-backoffice-ui/preact.config.js70
-rw-r--r--packages/merchant-backoffice-ui/preact.single-config.js62
-rw-r--r--packages/merchant-backoffice-ui/remove-link-stylesheet.sh8
-rw-r--r--packages/merchant-backoffice-ui/src/AdminRoutes.tsx52
-rw-r--r--packages/merchant-backoffice-ui/src/Application.tsx364
-rw-r--r--packages/merchant-backoffice-ui/src/Routing.tsx754
-rw-r--r--packages/merchant-backoffice-ui/src/assets/empty.pngbin0 -> 2785 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/assets/icons/android-chrome-192x192.pngbin0 -> 14058 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/assets/icons/android-chrome-512x512.pngbin0 -> 51484 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/assets/icons/apple-touch-icon.pngbin0 -> 12746 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/assets/icons/favicon-16x16.pngbin0 -> 626 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/assets/icons/favicon-32x32.pngbin0 -> 1487 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/assets/icons/languageicon.svg48
-rw-r--r--packages/merchant-backoffice-ui/src/assets/icons/mstile-150x150.pngbin0 -> 9050 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/assets/logo-2021.svg9
-rw-r--r--packages/merchant-backoffice-ui/src/assets/logo.jpegbin0 -> 39336 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/components/ErrorLoadingMerchant.tsx146
-rw-r--r--packages/merchant-backoffice-ui/src/components/exception/AsyncButton.tsx55
-rw-r--r--packages/merchant-backoffice-ui/src/components/exception/QR.tsx49
-rw-r--r--packages/merchant-backoffice-ui/src/components/exception/loading.tsx48
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx109
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/Input.tsx116
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputArray.tsx139
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx91
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx68
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputDate.tsx164
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx189
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx86
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputImage.tsx122
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx53
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx61
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx52
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx47
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx447
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx204
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx61
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx186
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx94
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx162
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputStock.tsx223
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputTab.tsx90
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx147
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx91
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx116
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/JumpToElementById.tsx63
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/TextField.tsx71
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/useField.tsx92
-rw-r--r--packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx41
-rw-r--r--packages/merchant-backoffice-ui/src/components/index.stories.ts17
-rw-r--r--packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx134
-rw-r--r--packages/merchant-backoffice-ui/src/components/menu/LangSelector.tsx92
-rw-r--r--packages/merchant-backoffice-ui/src/components/menu/NavigationBar.tsx72
-rw-r--r--packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx268
-rw-r--r--packages/merchant-backoffice-ui/src/components/menu/index.tsx243
-rw-r--r--packages/merchant-backoffice-ui/src/components/modal/index.tsx496
-rw-r--r--packages/merchant-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx57
-rw-r--r--packages/merchant-backoffice-ui/src/components/notifications/Notifications.stories.tsx62
-rw-r--r--packages/merchant-backoffice-ui/src/components/notifications/index.tsx65
-rw-r--r--packages/merchant-backoffice-ui/src/components/picker/DatePicker.tsx349
-rw-r--r--packages/merchant-backoffice-ui/src/components/picker/DurationPicker.stories.tsx55
-rw-r--r--packages/merchant-backoffice-ui/src/components/picker/DurationPicker.tsx211
-rw-r--r--packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx62
-rw-r--r--packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx127
-rw-r--r--packages/merchant-backoffice-ui/src/components/product/NonInventoryProductForm.tsx215
-rw-r--r--packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx177
-rw-r--r--packages/merchant-backoffice-ui/src/components/product/ProductList.tsx105
-rw-r--r--packages/merchant-backoffice-ui/src/context/session.ts246
-rw-r--r--packages/merchant-backoffice-ui/src/context/settings.ts44
-rw-r--r--packages/merchant-backoffice-ui/src/custom.d.ts42
-rw-r--r--packages/merchant-backoffice-ui/src/declaration.d.ts (renamed from packages/anastasis-webui/.storybook/.babelrc)13
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/async.ts77
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/bank.ts86
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/instance.test.ts741
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/instance.ts124
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/listener.ts85
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/notifications.ts56
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/order.test.ts581
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/order.ts98
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/otp.ts80
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/preference.ts89
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/product.test.ts362
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/product.ts103
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/templates.ts87
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/testing.tsx192
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/transfer.test.ts260
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/transfer.ts68
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/urls.ts235
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/webhooks.ts118
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/de.po2742
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/en.po2741
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/es.po2854
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/fr.po2742
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/it.po2742
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/poheader27
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/strings-prelude19
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/strings.ts9655
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/sv.po2741
-rw-r--r--packages/merchant-backoffice-ui/src/i18n/taler-merchant-backoffice.pot2726
-rw-r--r--packages/merchant-backoffice-ui/src/index.html45
-rw-r--r--packages/merchant-backoffice-ui/src/index.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/create/Create.stories.tsx76
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx273
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx74
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx90
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/create/stories.tsx71
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/index.stories.ts18
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx290
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/list/View.stories.tsx90
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/list/View.tsx107
-rw-r--r--packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx138
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx197
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx236
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx61
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx359
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx111
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx32
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx232
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx153
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx83
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx90
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx87
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/index.stories.ts18
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx57
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx208
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx76
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx71
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx794
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx141
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx120
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx134
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx780
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx129
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx106
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx108
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx222
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx407
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx230
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx181
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx98
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx70
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx59
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx196
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx106
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx32
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx185
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx147
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/create/Create.stories.tsx43
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx80
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx72
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx61
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/list/List.stories.tsx62
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx511
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx153
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx74
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx99
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx94
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx292
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx62
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx63
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx220
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx152
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/qr/Qr.stories.tsx27
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx154
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx66
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx32
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx305
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx97
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx27
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx144
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx108
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx188
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx152
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/token/stories.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx45
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx144
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx72
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx94
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx137
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx214
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx112
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/transfers/update/index.tsx26
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx59
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx175
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx117
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx179
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/index.tsx62
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx62
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx203
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/index.tsx105
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx32
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx145
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx97
-rw-r--r--packages/merchant-backoffice-ui/src/paths/login/index.tsx174
-rw-r--r--packages/merchant-backoffice-ui/src/paths/notfound/index.tsx72
-rw-r--r--packages/merchant-backoffice-ui/src/paths/settings/index.tsx141
-rw-r--r--packages/merchant-backoffice-ui/src/schemas/index.ts224
-rw-r--r--packages/merchant-backoffice-ui/src/scss/DurationPicker.scss70
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_aside.scss181
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_card.scss69
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_custom-calendar.scss259
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_footer.scss35
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_form.scss71
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_hero-bar.scss55
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_loading.scss51
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_main-section.scss24
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_misc.scss50
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_mixins.scss34
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_modal.scss35
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_nav-bar.scss144
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_table.scss179
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_theme-default.scss136
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_tiles.scss24
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_title-bar.scss50
-rw-r--r--packages/merchant-backoffice-ui/src/scss/fonts/XRXV3I6Li01BKofINeaE.ttfbin0 -> 43752 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/scss/fonts/nunito.css22
-rw-r--r--packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eotbin0 -> 844600 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttfbin0 -> 844380 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woffbin0 -> 404384 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2bin0 -> 283040 bytes
-rw-r--r--packages/merchant-backoffice-ui/src/scss/icons/materialdesignicons-4.9.95.min.css15109
-rw-r--r--packages/merchant-backoffice-ui/src/scss/libs/_all.scss29
-rw-r--r--packages/merchant-backoffice-ui/src/scss/main.scss195
-rw-r--r--packages/merchant-backoffice-ui/src/scss/toggle.scss67
-rw-r--r--packages/merchant-backoffice-ui/src/settings.ts84
-rw-r--r--packages/merchant-backoffice-ui/src/stories.test.ts44
-rw-r--r--packages/merchant-backoffice-ui/src/stories.tsx48
-rw-r--r--packages/merchant-backoffice-ui/src/sw.js25
-rw-r--r--packages/merchant-backoffice-ui/src/utils/amount.ts71
-rw-r--r--packages/merchant-backoffice-ui/src/utils/constants.ts194
-rw-r--r--packages/merchant-backoffice-ui/src/utils/regex.test.ts88
-rw-r--r--packages/merchant-backoffice-ui/src/utils/table.ts56
-rw-r--r--packages/merchant-backoffice-ui/src/utils/types.ts31
-rwxr-xr-xpackages/merchant-backoffice-ui/test.mjs31
-rw-r--r--packages/merchant-backoffice-ui/tsconfig.json58
-rwxr-xr-xpackages/pogen/bin/pogen2
-rw-r--r--packages/pogen/example/proj1/package.json5
-rw-r--r--packages/pogen/example/proj1/src/i18n/test.pot (renamed from packages/pogen/example/messages.po)105
-rw-r--r--packages/pogen/example/proj1/src/test.ts (renamed from packages/pogen/example/test.ts)3
-rw-r--r--packages/pogen/example/proj1/src/test2.tsx (renamed from packages/pogen/example/test2.tsx)2
-rw-r--r--packages/pogen/example/proj1/tsconfig.json2
-rw-r--r--packages/pogen/package.json10
-rw-r--r--packages/pogen/po2.js32
-rw-r--r--packages/pogen/src/dumpTree.ts (renamed from packages/pogen/dumpTree.ts)0
-rw-r--r--packages/pogen/src/po2ts.ts146
-rw-r--r--packages/pogen/src/pogen.ts48
-rw-r--r--packages/pogen/src/potextract.ts (renamed from packages/pogen/pogen.ts)213
-rw-r--r--packages/pogen/tsconfig.json23
-rw-r--r--packages/taler-harness/Makefile47
-rw-r--r--packages/taler-harness/README.md13
-rwxr-xr-x[-rw-r--r--]packages/taler-harness/bin/taler-harness.mjs (renamed from packages/taler-wallet-webextension/src/cta/return-coins.tsx)21
-rwxr-xr-xpackages/taler-harness/build.mjs76
-rw-r--r--packages/taler-harness/debian/README (renamed from debian/README)7
-rw-r--r--packages/taler-harness/debian/changelog51
-rw-r--r--packages/taler-harness/debian/control (renamed from debian/control)4
-rwxr-xr-xpackages/taler-harness/debian/rules19
-rw-r--r--packages/taler-harness/package.json45
-rw-r--r--packages/taler-harness/src/bench1.ts190
-rw-r--r--packages/taler-harness/src/bench2.ts186
-rw-r--r--packages/taler-harness/src/bench3.ts211
-rw-r--r--packages/taler-harness/src/benchMerchantIDGenerator.ts84
-rw-r--r--packages/taler-harness/src/env-full.ts101
-rw-r--r--packages/taler-harness/src/env1.ts (renamed from packages/taler-wallet-cli/src/env1.ts)6
-rw-r--r--packages/taler-harness/src/harness/denomStructures.ts (renamed from packages/taler-wallet-cli/src/harness/denomStructures.ts)46
-rw-r--r--packages/taler-harness/src/harness/faultInjection.ts (renamed from packages/taler-wallet-cli/src/harness/faultInjection.ts)17
-rw-r--r--packages/taler-harness/src/harness/harness.ts2255
-rw-r--r--packages/taler-harness/src/harness/helpers.ts711
-rw-r--r--packages/taler-harness/src/harness/sync.ts (renamed from packages/taler-wallet-cli/src/harness/sync.ts)3
-rw-r--r--packages/taler-harness/src/import-meta-url.js2
-rw-r--r--packages/taler-harness/src/index.ts1288
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-deposit.ts114
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-merchant.ts174
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-mixed-merchant.ts131
-rw-r--r--packages/taler-harness/src/integrationtests/test-age-restrictions-peer.ts135
-rw-r--r--packages/taler-harness/src/integrationtests/test-bank-api.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts)73
-rw-r--r--packages/taler-harness/src/integrationtests/test-claim-loop.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-claim-loop.ts)47
-rw-r--r--packages/taler-harness/src/integrationtests/test-clause-schnorr.ts103
-rw-r--r--packages/taler-harness/src/integrationtests/test-currency-scope.ts192
-rw-r--r--packages/taler-harness/src/integrationtests/test-denom-lost.ts81
-rw-r--r--packages/taler-harness/src/integrationtests/test-denom-unoffered.ts163
-rw-r--r--packages/taler-harness/src/integrationtests/test-deposit.ts122
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-deposit.ts159
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts)96
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-management.ts82
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-purse.ts224
-rw-r--r--packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-exchange-timetravel.ts)186
-rw-r--r--packages/taler-harness/src/integrationtests/test-fee-regression.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts)54
-rw-r--r--packages/taler-harness/src/integrationtests/test-forced-selection.ts86
-rw-r--r--packages/taler-harness/src/integrationtests/test-kyc.ts422
-rw-r--r--packages/taler-harness/src/integrationtests/test-libeufin-bank.ts231
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-merchant-exchange-confusion.ts)104
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-delete.ts)48
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-urls.ts)41
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-instances.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-merchant-instances.ts)83
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-merchant-longpolling.ts)66
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-merchant-refund-api.ts)135
-rw-r--r--packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-merchant-spec-public-orders.ts)193
-rw-r--r--packages/taler-harness/src/integrationtests/test-multiexchange.ts172
-rw-r--r--packages/taler-harness/src/integrationtests/test-otp.ts119
-rw-r--r--packages/taler-harness/src/integrationtests/test-pay-paid.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-pay-paid.ts)86
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-abort.ts164
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-claim.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts)66
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-deleted.ts104
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-expired.ts132
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-fault.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts)120
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-forgettable.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-payment-forgettable.ts)30
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-idempotency.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts)54
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-multiple.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts)59
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-share.ts308
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-template.ts105
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-transient.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-payment-transient.ts)80
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment-zero.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-payment-zero.ts)29
-rw-r--r--packages/taler-harness/src/integrationtests/test-payment.ts82
-rw-r--r--packages/taler-harness/src/integrationtests/test-paywall-flow.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-paywall-flow.ts)94
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-repair.ts213
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts285
-rw-r--r--packages/taler-harness/src/integrationtests/test-peer-to-peer-push.ts264
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund-auto.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-refund-auto.ts)53
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund-gone.ts139
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund-incremental.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-refund-incremental.ts)91
-rw-r--r--packages/taler-harness/src/integrationtests/test-refund.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-refund.ts)92
-rw-r--r--packages/taler-harness/src/integrationtests/test-revocation.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-revocation.ts)100
-rw-r--r--packages/taler-harness/src/integrationtests/test-simple-payment.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-payment.ts)35
-rw-r--r--packages/taler-harness/src/integrationtests/test-stored-backups.ts112
-rw-r--r--packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts)151
-rw-r--r--packages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-timetravel-withdraw.ts)61
-rw-r--r--packages/taler-harness/src/integrationtests/test-tos-format.ts101
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-backup-basic.ts189
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-backup-doublespend.ts183
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-balance-notifications.ts111
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-balance-zero.ts64
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-balance.ts129
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts150
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts142
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts177
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-push.ts149
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts101
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-config.ts67
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-cryptoworker.ts42
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-dbless.ts160
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-dd48.ts183
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts154
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-dev-experiments.ts48
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts165
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-gendb.ts111
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts166
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-notifications.ts176
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-observability.ts119
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts107
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-refresh.ts200
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallet-wirefees.ts184
-rw-r--r--packages/taler-harness/src/integrationtests/test-wallettesting.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts)97
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-abort-bank.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts)49
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts192
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts304
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts (renamed from packages/taler-wallet-cli/src/integrationtests/test-withdrawal-fakebank.ts)41
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts171
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts120
-rw-r--r--packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts107
-rw-r--r--packages/taler-harness/src/integrationtests/testrunner.ts (renamed from packages/taler-wallet-cli/src/integrationtests/testrunner.ts)355
-rw-r--r--packages/taler-harness/src/lint.ts (renamed from packages/taler-wallet-cli/src/lint.ts)24
-rw-r--r--packages/taler-harness/src/sandcastle-config.ts10
-rw-r--r--packages/taler-harness/tsconfig.json33
-rw-r--r--packages/taler-util/.eslintrc.cjs28
-rw-r--r--packages/taler-util/Makefile42
-rw-r--r--packages/taler-util/package.json95
-rw-r--r--packages/taler-util/src/CancellationToken.ts285
-rw-r--r--packages/taler-util/src/MerchantApiClient.ts380
-rw-r--r--packages/taler-util/src/RequestThrottler.ts (renamed from packages/taler-wallet-core/src/util/RequestThrottler.ts)22
-rw-r--r--packages/taler-util/src/ReserveStatus.ts22
-rw-r--r--packages/taler-util/src/ReserveTransaction.ts99
-rw-r--r--packages/taler-util/src/TaskThrottler.ts160
-rw-r--r--packages/taler-util/src/amounts.test.ts75
-rw-r--r--packages/taler-util/src/amounts.ts380
-rw-r--r--packages/taler-util/src/argon2-impl.missing.ts9
-rw-r--r--packages/taler-util/src/argon2-impl.node.ts19
-rw-r--r--packages/taler-util/src/argon2.ts17
-rw-r--r--packages/taler-util/src/backup-types.ts42
-rw-r--r--packages/taler-util/src/backupTypes.ts1280
-rw-r--r--packages/taler-util/src/bank-api-client.ts440
-rw-r--r--packages/taler-util/src/base64.ts64
-rw-r--r--packages/taler-util/src/bech32.ts131
-rw-r--r--packages/taler-util/src/bitcoin.test.ts108
-rw-r--r--packages/taler-util/src/bitcoin.ts87
-rw-r--r--packages/taler-util/src/clk.test.ts39
-rw-r--r--packages/taler-util/src/clk.ts633
-rw-r--r--packages/taler-util/src/codec.ts105
-rw-r--r--packages/taler-util/src/compat.d.ts23
-rw-r--r--packages/taler-util/src/compat.node.ts64
-rw-r--r--packages/taler-util/src/compat.qtart.ts57
-rw-r--r--packages/taler-util/src/contract-terms.test.ts (renamed from packages/taler-wallet-core/src/util/contractTerms.test.ts)7
-rw-r--r--packages/taler-util/src/contract-terms.ts (renamed from packages/taler-wallet-core/src/util/contractTerms.ts)44
-rw-r--r--packages/taler-util/src/errors.ts325
-rw-r--r--packages/taler-util/src/fnutils.ts2
-rw-r--r--packages/taler-util/src/globbing/minimatch.ts14
-rw-r--r--packages/taler-util/src/helpers.ts30
-rw-r--r--packages/taler-util/src/http-client/README.md19
-rw-r--r--packages/taler-util/src/http-client/authentication.ts137
-rw-r--r--packages/taler-util/src/http-client/bank-conversion.ts223
-rw-r--r--packages/taler-util/src/http-client/bank-core.ts1033
-rw-r--r--packages/taler-util/src/http-client/bank-integration.ts179
-rw-r--r--packages/taler-util/src/http-client/bank-revenue.ts130
-rw-r--r--packages/taler-util/src/http-client/bank-wire.ts226
-rw-r--r--packages/taler-util/src/http-client/exchange.ts242
-rw-r--r--packages/taler-util/src/http-client/merchant.ts2343
-rw-r--r--packages/taler-util/src/http-client/officer-account.ts105
-rw-r--r--packages/taler-util/src/http-client/types.ts5225
-rw-r--r--packages/taler-util/src/http-client/utils.ts116
-rw-r--r--packages/taler-util/src/http-common.ts485
-rw-r--r--packages/taler-util/src/http-impl.missing.ts38
-rw-r--r--packages/taler-util/src/http-impl.node.d.ts25
-rw-r--r--packages/taler-util/src/http-impl.node.ts324
-rw-r--r--packages/taler-util/src/http-impl.qtart.ts211
-rw-r--r--packages/taler-util/src/http-status-codes.ts379
-rw-r--r--packages/taler-util/src/http.ts37
-rw-r--r--packages/taler-util/src/i18n.ts40
-rw-r--r--packages/taler-util/src/iban.test.ts30
-rw-r--r--packages/taler-util/src/iban.ts296
-rw-r--r--packages/taler-util/src/index.browser.ts4
-rw-r--r--packages/taler-util/src/index.node.ts1
-rw-r--r--packages/taler-util/src/index.qtart.ts (renamed from packages/taler-wallet-webextension/tests/__mocks__/linaria.ts)24
-rw-r--r--packages/taler-util/src/index.ts64
-rw-r--r--packages/taler-util/src/invariants.ts (renamed from packages/taler-wallet-core/src/util/invariants.ts)22
-rw-r--r--packages/taler-util/src/iso-4217.ts1717
-rw-r--r--packages/taler-util/src/kdf.d.ts24
-rw-r--r--packages/taler-util/src/kdf.js95
-rw-r--r--packages/taler-util/src/kdf.ts47
-rw-r--r--packages/taler-util/src/libeufin-api-types.ts31
-rw-r--r--packages/taler-util/src/libtool-version.test.ts4
-rw-r--r--packages/taler-util/src/libtool-version.ts79
-rw-r--r--packages/taler-util/src/logging.ts207
-rw-r--r--packages/taler-util/src/merchant-api-types.ts (renamed from packages/taler-wallet-cli/src/harness/merchantApiTypes.ts)232
-rw-r--r--packages/taler-util/src/nacl-fast.ts386
-rw-r--r--packages/taler-util/src/notifications.ts444
-rw-r--r--packages/taler-util/src/observability.ts98
-rw-r--r--packages/taler-util/src/operation.ts195
-rw-r--r--packages/taler-util/src/payto.ts230
-rw-r--r--packages/taler-util/src/promises.ts112
-rw-r--r--packages/taler-util/src/punycode.ts468
-rw-r--r--packages/taler-util/src/qtart.ts35
-rw-r--r--packages/taler-util/src/rfc3548.ts60
-rw-r--r--packages/taler-util/src/segwit_addr.ts105
-rw-r--r--packages/taler-util/src/sha256.ts77
-rw-r--r--packages/taler-util/src/taler-crypto.test.ts440
-rw-r--r--packages/taler-util/src/taler-crypto.ts1662
-rw-r--r--packages/taler-util/src/taler-error-codes.ts2627
-rw-r--r--packages/taler-util/src/taler-types.ts2416
-rw-r--r--packages/taler-util/src/talerCrypto.test.ts186
-rw-r--r--packages/taler-util/src/talerCrypto.ts502
-rw-r--r--packages/taler-util/src/talerTypes.ts1453
-rw-r--r--packages/taler-util/src/talerconfig.ts324
-rw-r--r--packages/taler-util/src/taleruri.test.ts479
-rw-r--r--packages/taler-util/src/taleruri.ts632
-rw-r--r--packages/taler-util/src/time.test.ts39
-rw-r--r--packages/taler-util/src/time.ts716
-rw-r--r--packages/taler-util/src/timer.ts (renamed from packages/taler-wallet-core/src/util/timer.ts)54
-rw-r--r--packages/taler-util/src/transaction-test-data.ts113
-rw-r--r--packages/taler-util/src/transactions-types.ts794
-rw-r--r--packages/taler-util/src/transactionsTypes.ts370
-rw-r--r--packages/taler-util/src/twrpc-impl.missing.ts26
-rw-r--r--packages/taler-util/src/twrpc-impl.node.ts216
-rw-r--r--packages/taler-util/src/twrpc-impl.qtart.ts26
-rw-r--r--packages/taler-util/src/twrpc.ts63
-rw-r--r--packages/taler-util/src/types-test.ts18
-rw-r--r--packages/taler-util/src/url.ts34
-rw-r--r--packages/taler-util/src/wallet-types.ts3330
-rw-r--r--packages/taler-util/src/walletTypes.ts1047
-rw-r--r--packages/taler-util/src/whatwg-url.ts2126
-rw-r--r--packages/taler-util/tsconfig.json8
-rw-r--r--packages/taler-wallet-cli/Makefile51
-rw-r--r--packages/taler-wallet-cli/README.md9
-rw-r--r--packages/taler-wallet-cli/assets/.gitkeep0
-rwxr-xr-xpackages/taler-wallet-cli/bin/taler-wallet-cli7
-rwxr-xr-xpackages/taler-wallet-cli/bin/taler-wallet-cli-local.mjs8
-rwxr-xr-xpackages/taler-wallet-cli/bin/taler-wallet-cli.mjs19
-rwxr-xr-xpackages/taler-wallet-cli/build-node.mjs75
-rwxr-xr-xpackages/taler-wallet-cli/build-qtart.mjs77
-rw-r--r--packages/taler-wallet-cli/debian/README8
-rw-r--r--packages/taler-wallet-cli/debian/changelog123
-rw-r--r--packages/taler-wallet-cli/debian/control16
-rw-r--r--packages/taler-wallet-cli/debian/copyright (renamed from debian/copyright)0
-rwxr-xr-xpackages/taler-wallet-cli/debian/rules19
-rw-r--r--packages/taler-wallet-cli/package.json36
-rw-r--r--packages/taler-wallet-cli/rollup.config.js32
-rw-r--r--packages/taler-wallet-cli/src/assets.ts50
-rw-r--r--packages/taler-wallet-cli/src/bench1.ts105
-rw-r--r--packages/taler-wallet-cli/src/clk.ts614
-rw-r--r--packages/taler-wallet-cli/src/harness/harness.ts1779
-rw-r--r--packages/taler-wallet-cli/src/harness/helpers.ts406
-rw-r--r--packages/taler-wallet-cli/src/harness/libeufin.ts1676
-rw-r--r--packages/taler-wallet-cli/src/import-meta-url.js2
-rw-r--r--packages/taler-wallet-cli/src/index.ts1667
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/scenario-prompt-payment.ts60
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts131
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-deposit.ts71
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankaccount.ts110
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankconnection.ts63
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-facade-bad-request.ts71
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-facade.ts70
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-permissions.ts64
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-sandbox-camt.ts64
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-sandbox-transactions.ts72
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-scheduling.ts107
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-users.ts63
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-bad-gateway.ts74
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-basic.ts314
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-c5x.ts138
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-facade-anastasis.ts169
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-keyrotation.ts79
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-nexus-balance.ts114
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-refund-multiple-users.ts104
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-refund.ts101
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-sandbox-wire-transfer-cli.ts72
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-libeufin-tutorial.ts128
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-pay-abort.ts156
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-payment-on-demo.ts99
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-refund-gone.ts127
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-tipping.ts130
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts151
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-doublespend.ts168
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts70
-rw-r--r--packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts72
-rw-r--r--packages/taler-wallet-cli/tsconfig.json10
-rw-r--r--packages/taler-wallet-core/.gitignore2
-rw-r--r--packages/taler-wallet-core/README.md4
-rw-r--r--packages/taler-wallet-core/package.json83
-rw-r--r--packages/taler-wallet-core/rollup.config.js66
-rw-r--r--packages/taler-wallet-core/src/attention.ts133
-rw-r--r--packages/taler-wallet-core/src/backup/index.ts956
-rw-r--r--packages/taler-wallet-core/src/backup/state.ts15
-rw-r--r--packages/taler-wallet-core/src/balance.ts762
-rw-r--r--packages/taler-wallet-core/src/coinSelection.test.ts281
-rw-r--r--packages/taler-wallet-core/src/coinSelection.ts1254
-rw-r--r--packages/taler-wallet-core/src/common.ts845
-rw-r--r--packages/taler-wallet-core/src/crypto/cryptoImplementation.ts1755
-rw-r--r--packages/taler-wallet-core/src/crypto/cryptoTypes.ts254
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.test.ts128
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts386
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts457
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts593
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoWorker.ts8
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/cryptoWorkerInterface.ts65
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts64
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts136
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/synchronousWorkerFactoryPlain.ts38
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/synchronousWorkerPlain.ts98
-rw-r--r--packages/taler-wallet-core/src/crypto/workers/worker-common.ts107
-rw-r--r--packages/taler-wallet-core/src/db-utils.ts172
-rw-r--r--packages/taler-wallet-core/src/db.ts3092
-rw-r--r--packages/taler-wallet-core/src/dbless.ts419
-rw-r--r--packages/taler-wallet-core/src/denomSelection.ts199
-rw-r--r--packages/taler-wallet-core/src/denominations.test.ts870
-rw-r--r--packages/taler-wallet-core/src/denominations.ts479
-rw-r--r--packages/taler-wallet-core/src/deposits.ts1755
-rw-r--r--packages/taler-wallet-core/src/dev-experiments.ts147
-rw-r--r--packages/taler-wallet-core/src/errors.ts132
-rw-r--r--packages/taler-wallet-core/src/exchanges.ts2578
-rw-r--r--packages/taler-wallet-core/src/headless/NodeHttpLib.ts175
-rw-r--r--packages/taler-wallet-core/src/headless/helpers.ts164
-rw-r--r--packages/taler-wallet-core/src/host-common.ts60
-rw-r--r--packages/taler-wallet-core/src/host-impl.missing.ts41
-rw-r--r--packages/taler-wallet-core/src/host-impl.node.ts212
-rw-r--r--packages/taler-wallet-core/src/host-impl.qtart.ts219
-rw-r--r--packages/taler-wallet-core/src/host.ts46
-rw-r--r--packages/taler-wallet-core/src/index.browser.ts1
-rw-r--r--packages/taler-wallet-core/src/index.node.ts10
-rw-r--r--packages/taler-wallet-core/src/index.ts46
-rw-r--r--packages/taler-wallet-core/src/instructedAmountConversion.test.ts767
-rw-r--r--packages/taler-wallet-core/src/instructedAmountConversion.ts865
-rw-r--r--packages/taler-wallet-core/src/observable-wrappers.ts282
-rw-r--r--packages/taler-wallet-core/src/operations/README.md7
-rw-r--r--packages/taler-wallet-core/src/operations/backup/export.ts512
-rw-r--r--packages/taler-wallet-core/src/operations/backup/import.ts915
-rw-r--r--packages/taler-wallet-core/src/operations/backup/index.ts1005
-rw-r--r--packages/taler-wallet-core/src/operations/backup/state.ts113
-rw-r--r--packages/taler-wallet-core/src/operations/balance.ts168
-rw-r--r--packages/taler-wallet-core/src/operations/deposits.ts470
-rw-r--r--packages/taler-wallet-core/src/operations/exchanges.ts732
-rw-r--r--packages/taler-wallet-core/src/operations/pay.ts1768
-rw-r--r--packages/taler-wallet-core/src/operations/pending.ts354
-rw-r--r--packages/taler-wallet-core/src/operations/recoup.ts485
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts987
-rw-r--r--packages/taler-wallet-core/src/operations/refund.ts777
-rw-r--r--packages/taler-wallet-core/src/operations/reserves.ts829
-rw-r--r--packages/taler-wallet-core/src/operations/testing.ts435
-rw-r--r--packages/taler-wallet-core/src/operations/tip.ts420
-rw-r--r--packages/taler-wallet-core/src/operations/transactions.ts597
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.test.ts345
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts1072
-rw-r--r--packages/taler-wallet-core/src/pay-merchant.ts3441
-rw-r--r--packages/taler-wallet-core/src/pay-peer-common.ts157
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-credit.ts1201
-rw-r--r--packages/taler-wallet-core/src/pay-peer-pull-debit.ts994
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-credit.ts1034
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-debit.ts1266
-rw-r--r--packages/taler-wallet-core/src/pending-types.ts256
-rw-r--r--packages/taler-wallet-core/src/query.ts (renamed from packages/taler-wallet-core/src/util/query.ts)527
-rw-r--r--packages/taler-wallet-core/src/recoup.ts544
-rw-r--r--packages/taler-wallet-core/src/refresh.ts1857
-rw-r--r--packages/taler-wallet-core/src/remote.ts191
-rw-r--r--packages/taler-wallet-core/src/shepherd.ts1104
-rw-r--r--packages/taler-wallet-core/src/testing.ts917
-rw-r--r--packages/taler-wallet-core/src/transactions.ts2015
-rw-r--r--packages/taler-wallet-core/src/util/asyncMemo.ts87
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.test.ts254
-rw-r--r--packages/taler-wallet-core/src/util/coinSelection.ts332
-rw-r--r--packages/taler-wallet-core/src/util/http.ts342
-rw-r--r--packages/taler-wallet-core/src/util/promiseUtils.ts60
-rw-r--r--packages/taler-wallet-core/src/util/retries.ts85
-rw-r--r--packages/taler-wallet-core/src/versions.ts59
-rw-r--r--packages/taler-wallet-core/src/wallet-api-types.ts1379
-rw-r--r--packages/taler-wallet-core/src/wallet.ts2346
-rw-r--r--packages/taler-wallet-core/src/withdraw.test.ts364
-rw-r--r--packages/taler-wallet-core/src/withdraw.ts3233
-rw-r--r--packages/taler-wallet-core/tsconfig.json16
-rwxr-xr-xpackages/taler-wallet-core/watch_test.sh3
-rw-r--r--packages/taler-wallet-embedded/README.md4
-rwxr-xr-xpackages/taler-wallet-embedded/build.mjs76
-rw-r--r--packages/taler-wallet-embedded/package.json33
-rw-r--r--packages/taler-wallet-embedded/rollup.config.js30
-rw-r--r--packages/taler-wallet-embedded/src/index.ts288
-rw-r--r--packages/taler-wallet-embedded/src/wallet-qjs.ts392
-rw-r--r--packages/taler-wallet-embedded/tsconfig.json8
-rw-r--r--packages/taler-wallet-webextension/.eslintrc.cjs28
-rw-r--r--packages/taler-wallet-webextension/.gitignore1
-rw-r--r--packages/taler-wallet-webextension/.storybook/main.js81
-rw-r--r--packages/taler-wallet-webextension/.storybook/preview.js171
-rw-r--r--packages/taler-wallet-webextension/README.md4
-rwxr-xr-xpackages/taler-wallet-webextension/build.mjs49
-rwxr-xr-xpackages/taler-wallet-webextension/clean_and_build.sh22
-rwxr-xr-xpackages/taler-wallet-webextension/clean_and_build_fast.sh4
-rw-r--r--packages/taler-wallet-webextension/copyleft-header.js15
-rwxr-xr-xpackages/taler-wallet-webextension/dev.mjs70
-rw-r--r--packages/taler-wallet-webextension/manifest-common.json18
-rw-r--r--packages/taler-wallet-webextension/manifest-v2.json85
-rw-r--r--packages/taler-wallet-webextension/manifest-v3.json79
-rw-r--r--packages/taler-wallet-webextension/manifest.json49
-rwxr-xr-xpackages/taler-wallet-webextension/pack.sh54
-rw-r--r--packages/taler-wallet-webextension/package.json116
-rw-r--r--packages/taler-wallet-webextension/rollup.config.js110
-rw-r--r--packages/taler-wallet-webextension/service_worker.js11
-rw-r--r--packages/taler-wallet-webextension/src/NavigationBar.tsx335
-rw-r--r--packages/taler-wallet-webextension/src/background.dev.ts36
-rw-r--r--packages/taler-wallet-webextension/src/background.ts31
-rw-r--r--packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js44
-rw-r--r--packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js.map1
-rw-r--r--packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts17
-rw-r--r--packages/taler-wallet-webextension/src/browserHttpLib.ts165
-rw-r--r--packages/taler-wallet-webextension/src/browserWorkerEntry.ts53
-rw-r--r--packages/taler-wallet-webextension/src/chromeBadge.ts16
-rw-r--r--packages/taler-wallet-webextension/src/compat.js61
-rw-r--r--packages/taler-wallet-webextension/src/compat.ts100
-rw-r--r--packages/taler-wallet-webextension/src/components/Amount.stories.tsx115
-rw-r--r--packages/taler-wallet-webextension/src/components/Amount.tsx107
-rw-r--r--packages/taler-wallet-webextension/src/components/AmountField.stories.tsx69
-rw-r--r--packages/taler-wallet-webextension/src/components/AmountField.tsx223
-rw-r--r--packages/taler-wallet-webextension/src/components/BalanceTable.tsx64
-rw-r--r--packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx360
-rw-r--r--packages/taler-wallet-webextension/src/components/Banner.stories.tsx126
-rw-r--r--packages/taler-wallet-webextension/src/components/Banner.tsx70
-rw-r--r--packages/taler-wallet-webextension/src/components/Checkbox.tsx48
-rw-r--r--packages/taler-wallet-webextension/src/components/CheckboxOutlined.tsx66
-rw-r--r--packages/taler-wallet-webextension/src/components/CopyButton.tsx54
-rw-r--r--packages/taler-wallet-webextension/src/components/CurrentAlerts.tsx147
-rw-r--r--packages/taler-wallet-webextension/src/components/DebugCheckbox.tsx47
-rw-r--r--packages/taler-wallet-webextension/src/components/Diagnostics.tsx93
-rw-r--r--packages/taler-wallet-webextension/src/components/EditableText.tsx79
-rw-r--r--packages/taler-wallet-webextension/src/components/EnabledBySettings.tsx38
-rw-r--r--packages/taler-wallet-webextension/src/components/ErrorMessage.tsx58
-rw-r--r--packages/taler-wallet-webextension/src/components/ErrorTalerOperation.tsx73
-rw-r--r--packages/taler-wallet-webextension/src/components/ExchangeToS.tsx94
-rw-r--r--packages/taler-wallet-webextension/src/components/HistoryItem.tsx432
-rw-r--r--packages/taler-wallet-webextension/src/components/Loading.tsx100
-rw-r--r--packages/taler-wallet-webextension/src/components/LogoHeader.tsx34
-rw-r--r--packages/taler-wallet-webextension/src/components/Modal.tsx95
-rw-r--r--packages/taler-wallet-webextension/src/components/MultiActionButton.tsx129
-rw-r--r--packages/taler-wallet-webextension/src/components/Part.tsx194
-rw-r--r--packages/taler-wallet-webextension/src/components/PaymentButtons.tsx239
-rw-r--r--packages/taler-wallet-webextension/src/components/PendingTransactions.stories.tsx118
-rw-r--r--packages/taler-wallet-webextension/src/components/PendingTransactions.tsx205
-rw-r--r--packages/taler-wallet-webextension/src/components/ProductList.tsx89
-rw-r--r--packages/taler-wallet-webextension/src/components/QR.stories.tsx31
-rw-r--r--packages/taler-wallet-webextension/src/components/QR.tsx55
-rw-r--r--packages/taler-wallet-webextension/src/components/SelectList.tsx111
-rw-r--r--packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx98
-rw-r--r--packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx413
-rw-r--r--packages/taler-wallet-webextension/src/components/TermsOfService/index.ts91
-rw-r--r--packages/taler-wallet-webextension/src/components/TermsOfService/state.ts160
-rw-r--r--packages/taler-wallet-webextension/src/components/TermsOfService/stories.tsx59
-rw-r--r--packages/taler-wallet-webextension/src/components/TermsOfService/test.ts28
-rw-r--r--packages/taler-wallet-webextension/src/components/TermsOfService/utils.ts108
-rw-r--r--packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx225
-rw-r--r--packages/taler-wallet-webextension/src/components/Time.tsx46
-rw-r--r--packages/taler-wallet-webextension/src/components/TransactionItem.tsx190
-rw-r--r--packages/taler-wallet-webextension/src/components/WalletActivity.tsx834
-rw-r--r--packages/taler-wallet-webextension/src/components/index.stories.tsx28
-rw-r--r--packages/taler-wallet-webextension/src/components/styled/index.tsx650
-rw-r--r--packages/taler-wallet-webextension/src/context/alert.ts277
-rw-r--r--packages/taler-wallet-webextension/src/context/backend.ts52
-rw-r--r--packages/taler-wallet-webextension/src/context/devContext.ts42
-rw-r--r--packages/taler-wallet-webextension/src/context/iocContext.ts67
-rw-r--r--packages/taler-wallet-webextension/src/context/translation.ts68
-rw-r--r--packages/taler-wallet-webextension/src/cta/Deposit/index.ts69
-rw-r--r--packages/taler-wallet-webextension/src/cta/Deposit/state.ts79
-rw-r--r--packages/taler-wallet-webextension/src/cta/Deposit/stories.tsx37
-rw-r--r--packages/taler-wallet-webextension/src/cta/Deposit/test.ts118
-rw-r--r--packages/taler-wallet-webextension/src/cta/Deposit/views.tsx72
-rw-r--r--packages/taler-wallet-webextension/src/cta/DevExperiment/index.ts73
-rw-r--r--packages/taler-wallet-webextension/src/cta/DevExperiment/state.ts83
-rw-r--r--packages/taler-wallet-webextension/src/cta/DevExperiment/stories.tsx33
-rw-r--r--packages/taler-wallet-webextension/src/cta/DevExperiment/test.ts65
-rw-r--r--packages/taler-wallet-webextension/src/cta/DevExperiment/views.tsx74
-rw-r--r--packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts82
-rw-r--r--packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts196
-rw-r--r--packages/taler-wallet-webextension/src/cta/InvoiceCreate/stories.tsx52
-rw-r--r--packages/taler-wallet-webextension/src/cta/InvoiceCreate/test.ts28
-rw-r--r--packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx155
-rw-r--r--packages/taler-wallet-webextension/src/cta/InvoicePay/index.ts97
-rw-r--r--packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts171
-rw-r--r--packages/taler-wallet-webextension/src/cta/InvoicePay/stories.tsx56
-rw-r--r--packages/taler-wallet-webextension/src/cta/InvoicePay/test.ts28
-rw-r--r--packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx60
-rw-r--r--packages/taler-wallet-webextension/src/cta/Pay.stories.tsx164
-rw-r--r--packages/taler-wallet-webextension/src/cta/Pay.tsx300
-rw-r--r--packages/taler-wallet-webextension/src/cta/Payment/index.ts101
-rw-r--r--packages/taler-wallet-webextension/src/cta/Payment/state.ts174
-rw-r--r--packages/taler-wallet-webextension/src/cta/Payment/stories.tsx513
-rw-r--r--packages/taler-wallet-webextension/src/cta/Payment/test.ts576
-rw-r--r--packages/taler-wallet-webextension/src/cta/Payment/views.tsx132
-rw-r--r--packages/taler-wallet-webextension/src/cta/PaymentTemplate/index.ts83
-rw-r--r--packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts174
-rw-r--r--packages/taler-wallet-webextension/src/cta/PaymentTemplate/stories.tsx34
-rw-r--r--packages/taler-wallet-webextension/src/cta/PaymentTemplate/test.ts59
-rw-r--r--packages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx77
-rw-r--r--packages/taler-wallet-webextension/src/cta/Recovery/index.ts65
-rw-r--r--packages/taler-wallet-webextension/src/cta/Recovery/state.ts84
-rw-r--r--packages/taler-wallet-webextension/src/cta/Recovery/stories.tsx28
-rw-r--r--packages/taler-wallet-webextension/src/cta/Recovery/test.ts21
-rw-r--r--packages/taler-wallet-webextension/src/cta/Recovery/views.tsx41
-rw-r--r--packages/taler-wallet-webextension/src/cta/Refund.stories.tsx77
-rw-r--r--packages/taler-wallet-webextension/src/cta/Refund.tsx96
-rw-r--r--packages/taler-wallet-webextension/src/cta/Refund/index.ts90
-rw-r--r--packages/taler-wallet-webextension/src/cta/Refund/state.ts141
-rw-r--r--packages/taler-wallet-webextension/src/cta/Refund/stories.tsx82
-rw-r--r--packages/taler-wallet-webextension/src/cta/Refund/test.ts287
-rw-r--r--packages/taler-wallet-webextension/src/cta/Refund/views.tsx123
-rw-r--r--packages/taler-wallet-webextension/src/cta/Tip.stories.tsx59
-rw-r--r--packages/taler-wallet-webextension/src/cta/Tip.tsx111
-rw-r--r--packages/taler-wallet-webextension/src/cta/TransferCreate/index.ts70
-rw-r--r--packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts185
-rw-r--r--packages/taler-wallet-webextension/src/cta/TransferCreate/stories.tsx50
-rw-r--r--packages/taler-wallet-webextension/src/cta/TransferCreate/test.ts28
-rw-r--r--packages/taler-wallet-webextension/src/cta/TransferCreate/views.tsx125
-rw-r--r--packages/taler-wallet-webextension/src/cta/TransferPickup/index.ts75
-rw-r--r--packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts99
-rw-r--r--packages/taler-wallet-webextension/src/cta/TransferPickup/stories.tsx47
-rw-r--r--packages/taler-wallet-webextension/src/cta/TransferPickup/test.ts28
-rw-r--r--packages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx70
-rw-r--r--packages/taler-wallet-webextension/src/cta/Withdraw.tsx383
-rw-r--r--packages/taler-wallet-webextension/src/cta/Withdraw/index.ts138
-rw-r--r--packages/taler-wallet-webextension/src/cta/Withdraw/state.ts488
-rw-r--r--packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx327
-rw-r--r--packages/taler-wallet-webextension/src/cta/Withdraw/test.ts304
-rw-r--r--packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx245
-rw-r--r--packages/taler-wallet-webextension/src/cta/index.stories.ts29
-rw-r--r--packages/taler-wallet-webextension/src/cta/reset-required.tsx97
-rw-r--r--packages/taler-wallet-webextension/src/cta/termsExample.ts (renamed from packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx)467
-rw-r--r--packages/taler-wallet-webextension/src/custom.d.ts10
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts96
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts42
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useBackupStatus.ts71
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useBalances.ts54
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useClipboardPermissions.ts76
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useDiagnostics.ts45
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useExtendedPermissions.ts69
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useIsOnline.ts14
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useLocalStorage.ts65
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts34
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useSelectedExchange.ts141
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useSettings.ts64
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useTalerActionURL.test.ts58
-rw-r--r--packages/taler-wallet-webextension/src/hooks/useTalerActionURL.ts63
-rw-r--r--packages/taler-wallet-webextension/src/i18n/de.po2032
-rw-r--r--packages/taler-wallet-webextension/src/i18n/en-US.po294
-rw-r--r--packages/taler-wallet-webextension/src/i18n/es.po2197
-rw-r--r--packages/taler-wallet-webextension/src/i18n/fi.po1967
-rw-r--r--packages/taler-wallet-webextension/src/i18n/fr.po1911
-rw-r--r--packages/taler-wallet-webextension/src/i18n/it.po1911
-rw-r--r--packages/taler-wallet-webextension/src/i18n/ja.po1976
-rw-r--r--packages/taler-wallet-webextension/src/i18n/nl.po1953
-rw-r--r--packages/taler-wallet-webextension/src/i18n/poheader10
-rw-r--r--packages/taler-wallet-webextension/src/i18n/strings-prelude10
-rw-r--r--packages/taler-wallet-webextension/src/i18n/strings.ts3448
-rw-r--r--packages/taler-wallet-webextension/src/i18n/sv.po2043
-rw-r--r--packages/taler-wallet-webextension/src/i18n/taler-wallet-webex.pot1900
-rw-r--r--packages/taler-wallet-webextension/src/i18n/tr.po2087
-rw-r--r--packages/taler-wallet-webextension/src/i18n/uk.po1956
-rw-r--r--packages/taler-wallet-webextension/src/mui/Alert.stories.tsx107
-rw-r--r--packages/taler-wallet-webextension/src/mui/Alert.tsx175
-rw-r--r--packages/taler-wallet-webextension/src/mui/Avatar.tsx69
-rw-r--r--packages/taler-wallet-webextension/src/mui/Button.stories.tsx163
-rw-r--r--packages/taler-wallet-webextension/src/mui/Button.tsx405
-rw-r--r--packages/taler-wallet-webextension/src/mui/Divider.tsx20
-rw-r--r--packages/taler-wallet-webextension/src/mui/Grid.stories.tsx212
-rw-r--r--packages/taler-wallet-webextension/src/mui/Grid.tsx347
-rw-r--r--packages/taler-wallet-webextension/src/mui/InputFile.tsx78
-rw-r--r--packages/taler-wallet-webextension/src/mui/Menu.stories.tsx171
-rw-r--r--packages/taler-wallet-webextension/src/mui/Menu.tsx135
-rw-r--r--packages/taler-wallet-webextension/src/mui/Modal.tsx152
-rw-r--r--packages/taler-wallet-webextension/src/mui/ModalManager.ts328
-rw-r--r--packages/taler-wallet-webextension/src/mui/Paper.stories.tsx148
-rw-r--r--packages/taler-wallet-webextension/src/mui/Paper.tsx85
-rw-r--r--packages/taler-wallet-webextension/src/mui/Popover.tsx71
-rw-r--r--packages/taler-wallet-webextension/src/mui/Portal.tsx128
-rw-r--r--packages/taler-wallet-webextension/src/mui/TextField.stories.tsx154
-rw-r--r--packages/taler-wallet-webextension/src/mui/TextField.tsx97
-rw-r--r--packages/taler-wallet-webextension/src/mui/Typography.tsx125
-rw-r--r--packages/taler-wallet-webextension/src/mui/colors/constants.ts342
-rw-r--r--packages/taler-wallet-webextension/src/mui/colors/manipulation.test.ts333
-rw-r--r--packages/taler-wallet-webextension/src/mui/colors/manipulation.ts328
-rw-r--r--packages/taler-wallet-webextension/src/mui/handlers.ts82
-rw-r--r--packages/taler-wallet-webextension/src/mui/index.stories.tsx27
-rw-r--r--packages/taler-wallet-webextension/src/mui/input/FormControl.tsx176
-rw-r--r--packages/taler-wallet-webextension/src/mui/input/FormHelperText.tsx70
-rw-r--r--packages/taler-wallet-webextension/src/mui/input/FormLabel.tsx85
-rw-r--r--packages/taler-wallet-webextension/src/mui/input/InputBase.tsx562
-rw-r--r--packages/taler-wallet-webextension/src/mui/input/InputFilled.tsx199
-rw-r--r--packages/taler-wallet-webextension/src/mui/input/InputLabel.tsx114
-rw-r--r--packages/taler-wallet-webextension/src/mui/input/InputStandard.tsx142
-rw-r--r--packages/taler-wallet-webextension/src/mui/input/SelectFilled.tsx (renamed from packages/taler-wallet-core/src/util/assertUnreachable.ts)7
-rw-r--r--packages/taler-wallet-webextension/src/mui/input/SelectOutlined.tsx20
-rw-r--r--packages/taler-wallet-webextension/src/mui/input/SelectStandard.tsx200
-rw-r--r--packages/taler-wallet-webextension/src/mui/style.tsx874
-rw-r--r--packages/taler-wallet-webextension/src/platform/api.ts337
-rw-r--r--packages/taler-wallet-webextension/src/platform/background.ts23
-rw-r--r--packages/taler-wallet-webextension/src/platform/chrome.ts746
-rw-r--r--packages/taler-wallet-webextension/src/platform/dev.ts218
-rw-r--r--packages/taler-wallet-webextension/src/platform/firefox.ts92
-rw-r--r--packages/taler-wallet-webextension/src/platform/foreground.ts22
-rw-r--r--packages/taler-wallet-webextension/src/popup/Application.tsx229
-rw-r--r--packages/taler-wallet-webextension/src/popup/Backup.stories.tsx193
-rw-r--r--packages/taler-wallet-webextension/src/popup/BackupPage.tsx146
-rw-r--r--packages/taler-wallet-webextension/src/popup/Balance.stories.tsx386
-rw-r--r--packages/taler-wallet-webextension/src/popup/BalancePage.tsx294
-rw-r--r--packages/taler-wallet-webextension/src/popup/Debug.tsx64
-rw-r--r--packages/taler-wallet-webextension/src/popup/History.stories.tsx194
-rw-r--r--packages/taler-wallet-webextension/src/popup/History.tsx87
-rw-r--r--packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx53
-rw-r--r--packages/taler-wallet-webextension/src/popup/ProviderAddPage.tsx244
-rw-r--r--packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx53
-rw-r--r--packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx238
-rw-r--r--packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx195
-rw-r--r--packages/taler-wallet-webextension/src/popup/Settings.tsx110
-rw-r--r--packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx39
-rw-r--r--packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx208
-rw-r--r--packages/taler-wallet-webextension/src/popup/index.stories.tsx23
-rw-r--r--packages/taler-wallet-webextension/src/popupEntryPoint.dev.tsx53
-rw-r--r--packages/taler-wallet-webextension/src/popupEntryPoint.tsx106
-rw-r--r--packages/taler-wallet-webextension/src/pwa/index.html114
-rw-r--r--packages/taler-wallet-webextension/src/pwa/manifest.json35
-rw-r--r--packages/taler-wallet-webextension/src/pwa/popup.html39
-rw-r--r--packages/taler-wallet-webextension/src/pwa/static/font/import.css35
-rw-r--r--packages/taler-wallet-webextension/src/pwa/static/font/roboto-italic-400.ttfbin0 -> 130872 bytes
-rw-r--r--packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-300.tffbin0 -> 128256 bytes
-rw-r--r--packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-400.ttfbin0 -> 129584 bytes
-rw-r--r--packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-500.ttfbin0 -> 129768 bytes
-rw-r--r--packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-700.ttfbin0 -> 128676 bytes
-rw-r--r--packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-128.pngbin0 -> 8941 bytes
-rw-r--r--packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-2022.svg468
-rw-r--r--packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-48.pngbin0 -> 2790 bytes
-rw-r--r--packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-512.pngbin0 -> 39994 bytes
-rw-r--r--packages/taler-wallet-webextension/src/pwa/stories.html12
-rw-r--r--packages/taler-wallet-webextension/src/pwa/sw.js6
-rw-r--r--packages/taler-wallet-webextension/src/pwa/tests.html23
-rw-r--r--packages/taler-wallet-webextension/src/pwa/wallet.html29
-rw-r--r--packages/taler-wallet-webextension/src/renderHtml.tsx180
-rw-r--r--packages/taler-wallet-webextension/src/stories.test.ts65
-rw-r--r--packages/taler-wallet-webextension/src/stories.tsx87
-rw-r--r--packages/taler-wallet-webextension/src/svg/check_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/chevron-down.inline.svg6
-rw-r--r--packages/taler-wallet-webextension/src/svg/close_24px.inline.svg4
-rw-r--r--packages/taler-wallet-webextension/src/svg/delete_24px.inline.svg4
-rw-r--r--packages/taler-wallet-webextension/src/svg/download_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/edit_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/error_outline_outlined_24px.inline.svg3
-rw-r--r--packages/taler-wallet-webextension/src/svg/index.tsx38
-rw-r--r--packages/taler-wallet-webextension/src/svg/info_outlined_24px.inline.svg4
-rw-r--r--packages/taler-wallet-webextension/src/svg/logo-2021.inline.svg9
-rw-r--r--packages/taler-wallet-webextension/src/svg/progress.inline.svg12
-rw-r--r--packages/taler-wallet-webextension/src/svg/qr_code_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/refresh_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/refresh_outlined_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/refresh_rounded_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/refresh_sharp_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/refresh_two_tone_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/report_problem_outlined_24px.inline.svg4
-rw-r--r--packages/taler-wallet-webextension/src/svg/ri-bank-line.inline.svg (renamed from packages/taler-wallet-webextension/static/img/ri-bank-line.svg)0
-rw-r--r--packages/taler-wallet-webextension/src/svg/ri-file-unknown-line.svg (renamed from packages/taler-wallet-webextension/static/img/ri-file-unknown-line.svg)0
-rw-r--r--packages/taler-wallet-webextension/src/svg/ri-hand-heart-line.svg (renamed from packages/taler-wallet-webextension/static/img/ri-hand-heart-line.svg)0
-rw-r--r--packages/taler-wallet-webextension/src/svg/ri-refresh-line.svg (renamed from packages/taler-wallet-webextension/static/img/ri-refresh-line.svg)0
-rw-r--r--packages/taler-wallet-webextension/src/svg/ri-refund-2-line.svg (renamed from packages/taler-wallet-webextension/static/img/ri-refund-2-line.svg)0
-rw-r--r--packages/taler-wallet-webextension/src/svg/ri-shopping-cart-line.svg (renamed from packages/taler-wallet-webextension/static/img/ri-shopping-cart-line.svg)0
-rw-r--r--packages/taler-wallet-webextension/src/svg/search_24px.inline.svg4
-rw-r--r--packages/taler-wallet-webextension/src/svg/send_24px.inline.svg4
-rw-r--r--packages/taler-wallet-webextension/src/svg/settings_black_24dp.inline.svg6
-rw-r--r--packages/taler-wallet-webextension/src/svg/spinner-bars.svg (renamed from packages/taler-wallet-webextension/static/img/spinner-bars.svg)0
-rw-r--r--packages/taler-wallet-webextension/src/svg/success_outlined_24px.inline.svg4
-rw-r--r--packages/taler-wallet-webextension/src/svg/taler-logo-2021-plain.svg44
-rw-r--r--packages/taler-wallet-webextension/src/svg/upload_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/warning_24px.inline.svg1
-rw-r--r--packages/taler-wallet-webextension/src/svg/wifi.inline.svg3
-rw-r--r--packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts372
-rw-r--r--packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts200
-rw-r--r--packages/taler-wallet-webextension/src/test-utils.ts202
-rw-r--r--packages/taler-wallet-webextension/src/utils/index.ts119
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts88
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts263
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddBackupProvider/stories.tsx110
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts68
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddBackupProvider/views.tsx158
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts92
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts198
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx27
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts209
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx251
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddNewActionView.stories.tsx33
-rw-r--r--packages/taler-wallet-webextension/src/wallet/AddNewActionView.tsx79
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Application.tsx677
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx316
-rw-r--r--packages/taler-wallet-webextension/src/wallet/BackupPage.tsx374
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Balance.stories.tsx106
-rw-r--r--packages/taler-wallet-webextension/src/wallet/BalancePage.tsx126
-rw-r--r--packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.stories.tsx56
-rw-r--r--packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx57
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts121
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts276
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx116
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts431
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx191
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DestinationSelection/index.ts98
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts198
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DestinationSelection/stories.tsx65
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts153
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx430
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DeveloperPage.stories.tsx50
-rw-r--r--packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx690
-rw-r--r--packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/index.ts60
-rw-r--r--packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/state.ts24
-rw-r--r--packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/stories.tsx29
-rw-r--r--packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/test.ts28
-rw-r--r--packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/views.tsx25
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts115
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts242
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx563
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ExchangeSelection/test.ts23
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx931
-rw-r--r--packages/taler-wallet-webextension/src/wallet/History.stories.tsx672
-rw-r--r--packages/taler-wallet-webextension/src/wallet/History.tsx408
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ManageAccount/index.ts80
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts145
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ManageAccount/stories.tsx207
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ManageAccount/test.ts28
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx602
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ManualWithdrawPage.tsx81
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Notifications/index.ts61
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Notifications/state.ts57
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Notifications/stories.tsx63
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Notifications/test.ts28
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Notifications/views.tsx211
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ProviderAddConfirmProvider.stories.tsx47
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ProviderAddPage.tsx176
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ProviderAddSetUrl.stories.tsx50
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx362
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx409
-rw-r--r--packages/taler-wallet-webextension/src/wallet/QrReader.stories.tsx29
-rw-r--r--packages/taler-wallet-webextension/src/wallet/QrReader.tsx392
-rw-r--r--packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx41
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx80
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Settings.tsx335
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx624
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Transaction.tsx2157
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Welcome.stories.tsx36
-rw-r--r--packages/taler-wallet-webextension/src/wallet/Welcome.tsx111
-rw-r--r--packages/taler-wallet-webextension/src/wallet/index.stories.tsx36
-rw-r--r--packages/taler-wallet-webextension/src/walletEntryPoint.dev.tsx53
-rw-r--r--packages/taler-wallet-webextension/src/walletEntryPoint.tsx95
-rw-r--r--packages/taler-wallet-webextension/src/wxApi.ts510
-rw-r--r--packages/taler-wallet-webextension/src/wxBackend.ts688
-rw-r--r--packages/taler-wallet-webextension/static-dev/beer.pngbin0 -> 52778 bytes
-rw-r--r--packages/taler-wallet-webextension/static-dev/merchant-icon.jpegbin0 -> 60184 bytes
-rw-r--r--packages/taler-wallet-webextension/static/font/import.css35
-rw-r--r--packages/taler-wallet-webextension/static/font/roboto-italic-400.ttfbin0 -> 130872 bytes
-rw-r--r--packages/taler-wallet-webextension/static/font/roboto-normal-300.tffbin0 -> 128256 bytes
-rw-r--r--packages/taler-wallet-webextension/static/font/roboto-normal-400.ttfbin0 -> 129584 bytes
-rw-r--r--packages/taler-wallet-webextension/static/font/roboto-normal-500.ttfbin0 -> 129768 bytes
-rw-r--r--packages/taler-wallet-webextension/static/font/roboto-normal-700.ttfbin0 -> 128676 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/chevron-down.svg7
-rw-r--r--packages/taler-wallet-webextension/static/img/icon.pngbin830 -> 0 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/logo-2015-medium.pngbin65674 -> 0 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/logo-2021.svg1
l---------packages/taler-wallet-webextension/static/img/logo.png1
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-alert-128.pngbin0 -> 8944 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-alert-16.pngbin0 -> 772 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-alert-19.pngbin0 -> 963 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-alert-256.pngbin0 -> 18874 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-alert-32.pngbin0 -> 1796 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-alert-38.pngbin0 -> 2148 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-alert-48.pngbin0 -> 2811 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-alert-512.pngbin0 -> 40380 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-alert-64.pngbin0 -> 4137 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-logo-128.pngbin0 -> 8941 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-logo-16.pngbin0 -> 751 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-logo-19.pngbin0 -> 944 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-logo-2022.svg468
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-logo-256.pngbin0 -> 18664 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-logo-32.pngbin0 -> 1755 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-logo-38.pngbin0 -> 2088 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-logo-48.pngbin0 -> 2790 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-logo-512.pngbin0 -> 39994 bytes
-rw-r--r--packages/taler-wallet-webextension/static/img/taler-logo-64.pngbin0 -> 4138 bytes
-rw-r--r--packages/taler-wallet-webextension/static/popup.html7
-rw-r--r--packages/taler-wallet-webextension/static/wallet.html52
-rwxr-xr-xpackages/taler-wallet-webextension/test.mjs32
-rw-r--r--packages/taler-wallet-webextension/tests/i18n.test.tsx68
-rw-r--r--packages/taler-wallet-webextension/tests/stories.test.tsx70
-rw-r--r--packages/taler-wallet-webextension/trim-extension.cjs25
-rw-r--r--packages/taler-wallet-webextension/tsconfig.json16
-rw-r--r--packages/web-util/README.md3
-rwxr-xr-xpackages/web-util/bin/taler-web-cli.mjs19
-rwxr-xr-xpackages/web-util/build.mjs209
-rw-r--r--packages/web-util/create_certificate.sh48
-rw-r--r--packages/web-util/package.json70
-rw-r--r--packages/web-util/src/assets/lang.svg48
-rw-r--r--packages/web-util/src/assets/logo-2021.svg9
-rw-r--r--packages/web-util/src/assets/logo-white.svg45
-rw-r--r--packages/web-util/src/cli.ts50
-rw-r--r--packages/web-util/src/components/Attention.tsx80
-rw-r--r--packages/web-util/src/components/Button.tsx131
-rw-r--r--packages/web-util/src/components/CopyButton.tsx56
-rw-r--r--packages/web-util/src/components/ErrorLoading.tsx147
-rw-r--r--packages/web-util/src/components/ErrorLoadingMerchant.tsx147
-rw-r--r--packages/web-util/src/components/Footer.tsx48
-rw-r--r--packages/web-util/src/components/Header.tsx183
-rw-r--r--packages/web-util/src/components/LangSelector.tsx115
-rw-r--r--packages/web-util/src/components/Loading.tsx45
-rw-r--r--packages/web-util/src/components/NotificationBanner.tsx33
-rw-r--r--packages/web-util/src/components/ShowInputErrorLabel.tsx29
-rw-r--r--packages/web-util/src/components/ToastBanner.tsx61
-rw-r--r--packages/web-util/src/components/index.ts12
-rw-r--r--packages/web-util/src/components/utils.ts111
-rw-r--r--packages/web-util/src/context/activity.ts68
-rw-r--r--packages/web-util/src/context/api.ts49
-rw-r--r--packages/web-util/src/context/bank-api.ts224
-rw-r--r--packages/web-util/src/context/index.ts10
-rw-r--r--packages/web-util/src/context/merchant-api.ts228
-rw-r--r--packages/web-util/src/context/navigation.ts102
-rw-r--r--packages/web-util/src/context/translation.ts119
-rw-r--r--packages/web-util/src/context/wallet-integration.ts83
-rw-r--r--packages/web-util/src/custom.d.ts12
-rw-r--r--packages/web-util/src/declaration.d.ts35
-rw-r--r--packages/web-util/src/forms/Calendar.tsx119
-rw-r--r--packages/web-util/src/forms/Caption.tsx32
-rw-r--r--packages/web-util/src/forms/DefaultForm.tsx83
-rw-r--r--packages/web-util/src/forms/Dialog.tsx15
-rw-r--r--packages/web-util/src/forms/FormProvider.tsx148
-rw-r--r--packages/web-util/src/forms/Group.tsx41
-rw-r--r--packages/web-util/src/forms/InputAbsoluteTime.stories.tsx60
-rw-r--r--packages/web-util/src/forms/InputAbsoluteTime.tsx77
-rw-r--r--packages/web-util/src/forms/InputAmount.stories.tsx59
-rw-r--r--packages/web-util/src/forms/InputAmount.tsx36
-rw-r--r--packages/web-util/src/forms/InputArray.stories.tsx79
-rw-r--r--packages/web-util/src/forms/InputArray.tsx186
-rw-r--r--packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx69
-rw-r--r--packages/web-util/src/forms/InputChoiceHorizontal.tsx87
-rw-r--r--packages/web-util/src/forms/InputChoiceStacked.stories.tsx69
-rw-r--r--packages/web-util/src/forms/InputChoiceStacked.tsx113
-rw-r--r--packages/web-util/src/forms/InputFile.stories.tsx64
-rw-r--r--packages/web-util/src/forms/InputFile.tsx106
-rw-r--r--packages/web-util/src/forms/InputInteger.stories.tsx55
-rw-r--r--packages/web-util/src/forms/InputInteger.tsx24
-rw-r--r--packages/web-util/src/forms/InputLine.stories.tsx59
-rw-r--r--packages/web-util/src/forms/InputLine.tsx268
-rw-r--r--packages/web-util/src/forms/InputSelectMultiple.stories.tsx90
-rw-r--r--packages/web-util/src/forms/InputSelectMultiple.tsx154
-rw-r--r--packages/web-util/src/forms/InputSelectOne.stories.tsx70
-rw-r--r--packages/web-util/src/forms/InputSelectOne.tsx135
-rw-r--r--packages/web-util/src/forms/InputText.stories.tsx59
-rw-r--r--packages/web-util/src/forms/InputText.tsx9
-rw-r--r--packages/web-util/src/forms/InputTextArea.stories.tsx59
-rw-r--r--packages/web-util/src/forms/InputTextArea.tsx9
-rw-r--r--packages/web-util/src/forms/InputToggle.stories.tsx59
-rw-r--r--packages/web-util/src/forms/InputToggle.tsx38
-rw-r--r--packages/web-util/src/forms/TimePicker.tsx109
-rw-r--r--packages/web-util/src/forms/forms.ts134
-rw-r--r--packages/web-util/src/forms/index.stories.ts13
-rw-r--r--packages/web-util/src/forms/index.ts23
-rw-r--r--packages/web-util/src/forms/useField.ts93
-rw-r--r--packages/web-util/src/hooks/index.ts13
-rw-r--r--packages/web-util/src/hooks/useAsyncAsHook.ts91
-rw-r--r--packages/web-util/src/hooks/useLang.ts61
-rw-r--r--packages/web-util/src/hooks/useLocalStorage.ts139
-rw-r--r--packages/web-util/src/hooks/useMemoryStorage.ts71
-rw-r--r--packages/web-util/src/hooks/useNotifications.ts348
-rw-r--r--packages/web-util/src/index.browser.ts10
-rw-r--r--packages/web-util/src/index.build.ts327
-rw-r--r--packages/web-util/src/index.node.ts1
-rw-r--r--packages/web-util/src/index.testing.ts3
-rw-r--r--packages/web-util/src/keys/ca.crt14
-rw-r--r--packages/web-util/src/keys/ca.key16
-rw-r--r--packages/web-util/src/keys/ca.srl1
-rw-r--r--packages/web-util/src/keys/localhost.crt15
-rw-r--r--packages/web-util/src/keys/localhost.csr10
-rw-r--r--packages/web-util/src/keys/localhost.key16
-rw-r--r--packages/web-util/src/live-reload.ts81
-rw-r--r--packages/web-util/src/serve.ts133
-rw-r--r--packages/web-util/src/stories.html20
-rw-r--r--packages/web-util/src/stories.tsx578
-rw-r--r--packages/web-util/src/tests/hook.ts325
-rw-r--r--packages/web-util/src/tests/mock.ts503
-rw-r--r--packages/web-util/src/tests/swr.ts105
-rw-r--r--packages/web-util/src/utils/base64.ts243
-rw-r--r--packages/web-util/src/utils/http-impl.browser.ts251
-rw-r--r--packages/web-util/src/utils/http-impl.sw.ts215
-rw-r--r--packages/web-util/src/utils/observable.ts283
-rw-r--r--packages/web-util/src/utils/request.ts477
-rw-r--r--packages/web-util/src/utils/route.ts129
-rw-r--r--packages/web-util/tsconfig.json30
-rw-r--r--pnpm-lock.yaml23942
-rw-r--r--tsconfig.build.json5
1986 files changed, 421271 insertions, 68062 deletions
diff --git a/.eslintrc.js b/.eslintrc.js
index 50146fe7b..5e5424b09 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,18 +1,29 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
- plugins: ["@typescript-eslint"],
+ plugins: ["import", "@typescript-eslint",
+ "react",
+ "react-hooks",
+ ],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
- "preact",
+ "plugin:react/recommended",
+ "plugin:react-hooks/recommended",
],
+ settings: {
+ react: {
+ pragma: 'h',
+ version: '16.0'
+ }
+ },
rules: {
"no-constant-condition": ["error", { "checkLoops": false }],
"prefer-const": ["warn", { destructuring: "all" }],
"no-prototype-builtins": "off",
"@typescript-eslint/camelcase": "off",
+ "@typescript-eslint/no-namespace": "off",
"@typescript-eslint/ban-ts-ignore": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
@@ -25,5 +36,9 @@ module.exports = {
"error",
{ functions: false, classes: false },
],
+ "import/extensions": ["error", "ignorePackages"],
+ "react/no-unknown-property": 0,
+ "react/prop-types": 0,
+
},
};
diff --git a/.gitignore b/.gitignore
index fce6dacfc..b7661e6e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,9 +8,8 @@ tsconfig.tsbuildinfo
build/
# GNU-style build system
-/configure
-/build-system/config.mk
-/Makefile
+configure
+.config.mk
# Editor files
\#*\#
@@ -26,6 +25,7 @@ prebuilt/
taler-wallet-*.tar.gz
+anastasis-webui.zip
# debian packaging leftovers
packages/taler-wallet-cli/debian/.debhelper
@@ -33,3 +33,6 @@ packages/taler-wallet-cli/debian/taler-wallet-cli
packages/taler-wallet-cli_*.deb
packages/taler-wallet-cli_*.buildinfo
packages/taler-wallet-cli_*.changes
+
+# Performance benchmarking data
+.clinic
diff --git a/.gitmodules b/.gitmodules
index 9c8b34ee3..e96bbcfc1 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,6 +1,9 @@
[submodule "build-scripts"]
path = build-system/taler-build-scripts
- url = git://taler.net/build-common
+ url = git://git.taler.net/build-common
[submodule "vendor"]
path = vendor
url = https://git.taler.net/node-vendor.git
+[submodule "contrib/wallet-testdata"]
+ path = contrib/wallet-testdata
+ url = https://git.taler.net/wallet-testdata.git
diff --git a/.vscode/settings.json b/.vscode/settings.json
index d8e616936..465ffb3f3 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,53 +1,53 @@
// Place your settings in this file to overwrite default and user settings.
{
- // Use latest language servicesu
- "typescript.tsdk": "./node_modules/typescript/lib",
- // Defines space handling after a comma delimiter
- "typescript.format.insertSpaceAfterCommaDelimiter": true,
- // Defines space handling after a semicolon in a for statement
- "typescript.format.insertSpaceAfterSemicolonInForStatements": true,
- // Defines space handling after a binary operator
- "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": true,
- // Defines space handling after keywords in control flow statement
- "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": true,
- // Defines space handling after function keyword for anonymous functions
- "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": true,
- // Defines space handling after opening and before closing non empty parenthesis
- "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false,
- // Defines space handling after opening and before closing non empty brackets
- "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": false,
- // Defines whether an open brace is put onto a new line for functions or not
- "typescript.format.placeOpenBraceOnNewLineForFunctions": false,
- // Defines whether an open brace is put onto a new line for control blocks or not
- "typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
- // Files hidden in the explorer
- "files.exclude": {
- // include the defaults from VS Code
- "**/.git": true,
- "**/.DS_Store": true,
- // exclude .js and .js.map files, when in a TypeScript project
- "**/*.js": {
- "when": "$(basename).ts"
- },
- "**/*?.js": {
- "when": "$(basename).tsx"
- },
- "**/*.js.map": true
+ // Use latest language servicesu
+ "typescript.tsdk": "./node_modules/typescript/lib",
+ // Defines space handling after a comma delimiter
+ "typescript.format.insertSpaceAfterCommaDelimiter": true,
+ // Defines space handling after a semicolon in a for statement
+ "typescript.format.insertSpaceAfterSemicolonInForStatements": true,
+ // Defines space handling after a binary operator
+ "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": true,
+ // Defines space handling after keywords in control flow statement
+ "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": true,
+ // Defines space handling after function keyword for anonymous functions
+ "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": true,
+ // Defines space handling after opening and before closing non empty parenthesis
+ "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false,
+ // Defines space handling after opening and before closing non empty brackets
+ "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": false,
+ // Defines whether an open brace is put onto a new line for functions or not
+ "typescript.format.placeOpenBraceOnNewLineForFunctions": false,
+ // Defines whether an open brace is put onto a new line for control blocks or not
+ "typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
+ // Files hidden in the explorer
+ "files.exclude": {
+ // include the defaults from VS Code
+ "**/.git": true,
+ "**/.DS_Store": true,
+ // exclude .js and .js.map files, when in a TypeScript project
+ "**/*.js": {
+ "when": "$(basename).ts"
},
- "editor.wrappingIndent": "same",
- "editor.tabSize": 2,
- "search.exclude": {
- "dist": true,
- "prebuilt": true,
- "src/i18n/*.po": true,
- "vendor": true
+ "**/*?.js": {
+ "when": "$(basename).tsx"
},
- "search.collapseResults": "auto",
- "files.associations": {
- "api-extractor.json": "jsonc"
- },
- "typescript.preferences.importModuleSpecifierEnding": "js",
- "typescript.preferences.importModuleSpecifier": "project-relative",
- "javascript.preferences.importModuleSpecifier": "project-relative",
- "javascript.preferences.importModuleSpecifierEnding": "js"
-} \ No newline at end of file
+ "**/*.js.map": true
+ },
+ "editor.wrappingIndent": "same",
+ "editor.tabSize": 2,
+ "search.exclude": {
+ "dist": true,
+ "prebuilt": true,
+ "src/i18n/*.po": true,
+ "vendor": true
+ },
+ "search.collapseResults": "auto",
+ "files.associations": {
+ "api-extractor.json": "jsonc"
+ },
+ "typescript.preferences.importModuleSpecifierEnding": "js",
+ "typescript.preferences.importModuleSpecifier": "project-relative",
+ "javascript.preferences.importModuleSpecifier": "project-relative",
+ "javascript.preferences.importModuleSpecifierEnding": "js"
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 00c61e8a4..4c931ad04 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -1,18 +1,17 @@
{
- // See https://go.microsoft.com/fwlink/?LinkId=733558
- // for the documentation about the tasks.json format
- "version": "2.0.0",
- "tasks": [
- {
- "type": "typescript",
- "tsconfig": "tsconfig.build.json",
- "problemMatcher": [
- "$tsc"
- ],
- "group": {
- "kind": "build",
- "isDefault": true,
- },
- }
- ]
-} \ No newline at end of file
+ // See https://go.microsoft.com/fwlink/?LinkId=733558
+ // for the documentation about the tasks.json format
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "typescript",
+ "tsconfig": "tsconfig.build.json",
+ "problemMatcher": ["$tsc"],
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "label": "tsc: build - tsconfig.build.json"
+ }
+ ]
+}
diff --git a/API_CHANGES.md b/API_CHANGES.md
new file mode 100644
index 000000000..f6fbf17f5
--- /dev/null
+++ b/API_CHANGES.md
@@ -0,0 +1,36 @@
+# API Changes
+
+This files contains all the API changes for the current release:
+
+## wallet-core
+
+- AcceptManualWithdrawalResult.exchangePaytoUris is deprecated
+- WithdrawalExchangeAccountDetails.transferAmount is now optional (if conversion applies)
+- added WithdrawalExchangeAccountDetails.currencySpecification about the transferAmount currency
+- 2023-12-05 dold: added WithdrawalExchangeAccountDetails.{status,conversionError} to inform the client
+ about errors with a particular conversion account instead of failing the whole withdrawal(-info) request.
+- 2023-12-06 dold: added the exchangeBaseUrl to PreparePeerPushCreditResponse, allowing the UI
+ to check the exchange status for the peer push credit.
+- 2023-12-06 dold: added a new getExchangeEntryForUri request, which allows the client to
+ get information about an existing exchange entry with DD48 semantics.
+ The older call "getExchangeDetailedInfo" also computes loads of information
+ for fee comparison and we should eventually rename it to something more appropriate
+ (like getExchangeFeeDetailsForUri).
+- 2023-12-06 dold: Deprecate the tosStatus in the withdrawal details response.
+ This field does not conform to DD48 semantics and the client should
+ request the ToS status separately via a getExchangeEntryForUri request.
+- 2023-12-07 dold: Add the prepareWithdrawExchange request for withdrawals
+ via a taler://withdraw-exchange URI.
+- 2023-12-11 dold: Add exchangeBaseUrl to the checkPeerPushDebit response.
+- 2023-12-11 dold: Add scopeInfo to exchange entry list items.
+- BREAK 2023-12-12 dold: Remove forceUpdate and masterPub arguments from addExchange
+ request. This request has previously been overloaded both to update an
+ exchange entry as well as to add it.
+ To update the entry, updateExchangeEntry should be used instead.
+- 2023-12-12 dold: the getExchangeTos request not accepts an additional
+ acceptLanguage field in the request. The response now contains an optional
+ contentLanguage field that is returned if the exchange reports it.
+- 2023-12-12 2:0:1 dold: The checkPeerPushDebit now returns a maximum
+ expiration date based on the expiry of selected coins.
+- 2023-12-13 3:0:2 dold: getVersion now returns the supported API version
+ ranges for all bank APIs separately.
diff --git a/AUTHORS b/AUTHORS
index 1d3ba2cc7..4c9a53431 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,2 +1,3 @@
Florian Dold
Gabor Toth
+Sebastian Marchano
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000..85eeb748c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,179 @@
+# This Makefile has been placed in the public domain.
+
+tsc = node_modules/typescript/bin/tsc
+pogen = node_modules/@gnu-taler/pogen/bin/pogen.js
+typedoc = node_modules/typedoc/bin/typedoc
+ava = node_modules/.bin/ava
+nyc = node_modules/nyc/bin/nyc.js
+git-archive-all = ./build-system/taler-build-scripts/archive-with-submodules/git_archive_all.py
+
+include .config.mk
+
+# Let recursive Makefiles know that they're being invoked
+# from the top-level makefile.
+export TOPLEVEL := yes
+export TOP_DESTDIR := $(abspath $(DESTDIR))
+
+.PHONY: compile
+compile:
+ pnpm install -r --frozen-lockfile
+ pnpm run compile
+
+
+.PHONY: dist
+dist:
+ $(git-archive-all) \
+ --include ./configure \
+ --include ./packages/taler-wallet-cli/configure \
+ --include ./packages/anastasis-cli/configure \
+ --include ./packages/bank-ui/configure \
+ --include ./packages/taler-harness/configure \
+ --include ./packages/merchant-backoffice-ui/configure \
+ taler-wallet-$(shell git describe --tags --abbrev=0).tar.gz
+
+# Create tarball with git hash prefix in name
+.PHONY: dist-git
+dist-git:
+ $(git-archive-all) --include ./configure taler-wallet-$(shell git describe --tags).tar.gz
+
+.PHONY: publish
+publish:
+ pnpm i -r --frozen-lockfile
+ pnpm run compile
+ pnpm publish -r --no-git-checks
+
+
+.PHONY: prebuilt
+prebuilt:
+ ./contrib/cleanup-prebuilt-dir.sh
+ make backoffice-prebuilt
+ make backend-prebuilt
+ make bank-prebuilt
+ make challenger-prebuilt
+ make aml-backoffice-prebuilt
+ ./contrib/publish-prebuilt-dir.sh
+
+.PHONY: backoffice-prebuilt
+backoffice-prebuilt:
+ pnpm install --frozen-lockfile --filter @gnu-taler/merchant-backoffice-ui...
+ pnpm run --filter @gnu-taler/merchant-backoffice-ui... build
+ ./contrib/copy-backoffice-into-prebuilt.sh
+
+.PHONY: backend-prebuilt
+backend-prebuilt:
+ pnpm install --frozen-lockfile --filter @gnu-taler/merchant-backend-ui...
+ pnpm run --filter @gnu-taler/merchant-backend-ui... build
+ ./contrib/copy-backend-into-prebuilt.sh
+
+.PHONY: aml-backoffice-prebuilt
+aml-backoffice-prebuilt:
+ pnpm install --frozen-lockfile --filter @gnu-taler/aml-backoffice-ui...
+ pnpm run --filter @gnu-taler/aml-backoffice-ui... build
+ ./contrib/copy-aml-backoffice-into-prebuilt.sh
+
+#.PHONY: auditor-backoffice-prebuilt
+#auditor-backoffice-prebuilt:
+# pnpm install --frozen-lockfile --filter @gnu-taler/auditor-backoffice-ui...
+# pnpm run --filter @gnu-taler/auditor-backoffice-ui... build
+# ./contrib/copy-auditor-backoffice-into-prebuilt.sh
+
+.PHONY: challenger-prebuilt
+challenger-prebuilt:
+ pnpm install --frozen-lockfile --filter @gnu-taler/challenger-ui...
+ pnpm run --filter @gnu-taler/challenger-ui... build
+ ./contrib/copy-challenger-into-prebuilt.sh
+
+.PHONY: bank-prebuilt
+bank-prebuilt:
+ pnpm install --frozen-lockfile --filter @gnu-taler/bank-ui...
+ pnpm run --filter @gnu-taler/bank-ui... build
+ ./contrib/copy-bank-into-prebuilt.sh
+
+# make documentation from docstrings
+.PHONY: typedoc
+typedoc:
+ $(typedoc) --out dist/typedoc --readme README
+
+.PHONY: clean
+clean:
+ pnpm run clean
+
+.PHONY: submodules-update
+submodules-update:
+ git submodule update --recursive --remote
+
+.PHONY: check
+check:
+ pnpm install -r --frozen-lockfile
+ pnpm run compile
+ pnpm run check
+
+.PHONY: config-lib
+config-lib:
+ pnpm install --frozen-lockfile --filter @gnu-taler/taler-config-lib...
+ cd ./packages/taler-config-lib/ && pnpm link -g
+
+.PHONY: anastasis-webui
+anastasis-webui:
+ pnpm install --frozen-lockfile --filter . --filter @gnu-taler/anastasis-webui...
+ pnpm run --filter @gnu-taler/anastasis-webui... build
+
+.PHONY: anastasis-webui-dist
+anastasis-webui-dist: anastasis-webui
+ (cd packages/anastasis-webui/dist && zip -r - fonts ui.html) > anastasis-webui.zip
+
+
+.PHONY: anastasis-webui-dev
+anastasis-webui-dev:
+ pnpm install --frozen-lockfile --filter @gnu-taler/anastasis-webui...
+ pnpm run --filter @gnu-taler/anastasis-webui... dev
+
+.PHONY: webextension
+webextension:
+ pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-webextension...
+ pnpm run --filter @gnu-taler/taler-wallet-webextension... compile
+ cd ./packages/taler-wallet-webextension/ && ./pack.sh prod
+
+.PHONY: webextension-dev
+webextension-dev:
+ pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-webextension...
+ pnpm run --filter @gnu-taler/taler-wallet-webextension... dev
+
+.PHONY: embedded
+embedded:
+ pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-embedded...
+ pnpm run --filter @gnu-taler/taler-wallet-embedded... compile
+ @echo built packages/taler-wallet-embedded/dist/taler-wallet-core-qjs.mjs
+
+.PHONY: lint
+lint:
+ ./node_modules/.bin/eslint --ext '.js,.ts,.tsx' 'src'
+
+
+.PHONY: install
+# Build and install everything
+install:
+ pnpm install --frozen-lockfile
+ pnpm run compile
+ $(MAKE) -C packages/taler-wallet-cli install-nodeps
+ $(MAKE) -C packages/anastasis-cli install-nodeps
+ $(MAKE) -C packages/taler-harness install-nodeps
+ $(MAKE) -C packages/bank-ui install-nodeps
+ $(MAKE) -C packages/merchant-backoffice-ui install-nodeps
+ $(MAKE) -C packages/aml-backoffice-ui install-nodeps
+ $(MAKE) -C packages/auditor-backoffice-ui install-nodeps
+
+
+.PHONY: install-tools
+# Install taler-wallet-cli, anastasis-cli and taler-harness
+install-tools:
+ pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/anastasis-cli... --filter @gnu-taler/taler-harness...
+ pnpm run --filter @gnu-taler/taler-wallet-cli... --filter @gnu-taler/anastasis-cli... --filter @gnu-taler/taler-harness... compile
+ $(MAKE) -C packages/taler-wallet-cli install-nodeps
+ $(MAKE) -C packages/anastasis-cli install-nodeps
+ $(MAKE) -C packages/taler-harness install-nodeps
+
+.PHONY: check-migration
+
+check-migration:
+ taler-harness advanced wallet-dbcheck contrib/wallet-testdata/wallet-dbgen-0.9.4-dev.8
diff --git a/README b/README
index b48a53565..471815c0b 100644
--- a/README
+++ b/README
@@ -1,20 +1,18 @@
-# GNU Taler Wallet
+# GNU Taler Wallet & Anastasis Web UI
This repository contains the implementation of a wallet for GNU Taler written
-in TypeScript.
-
+in TypeScript and Anastasis Web UI
## Dependencies
The following dependencies are required to build the wallet:
-* python>=3.8
-* nodejs>=12
-* jq
-* npm
-* pnpm
-* zip
-
+- python>=3.8
+- nodejs>=12
+- jq
+- npm
+- pnpm
+- zip
## Installation
@@ -29,6 +27,26 @@ The CLI version of the wallet supports the normal GNU installation process.
If you are compiling the code from git, you have to run `./bootstrap` before
running `./configure`.
+## Pushing a new prebuilt version
+
+After compiling run
+
+```shell
+make prebuilt
+```
+
+This will create a directory `prebuilt` with a git subtree,
+compile every prebuilt project, copy everything into this subtree
+and create a commit with the default message mentioning from
+which revision the prebuilt was created.
+When the script completes the prebuilt version can should
+be manually pushed.
+
+```shell
+cd prebuilt
+git push
+```
+
### Building the WebExtension
The WebExtension can be built via the 'webextension' make target:
@@ -43,91 +61,128 @@ This will create the zip file with the WebExtension in the directory
packages/taler-wallet-webextension/extension/
```
-We also provide a `Dockerfile` for a container that can build the
-WebExtension. After you install docker, make sure the user is in group
-`docker` and (re-)start the docker daemon:
+### Installing local WebExtension
-```shell
-# Make sure there is a docker group.
-$ grep docker: /etc/group
-$ sudo groupadd docker
+Firefox:
+ - Settings
+ - Add-ons
+ - Manage your extension -> Debug Add-ons
+ - Load temporary Add-on...
+ - Look for the zip file under './packages/taler-wallet-webextension/extension/' folder
-# Make sure USER is defined and is in the docker group.
-$ echo $USER
-$ sudo usermod -aG docker $USER
+Chrome:
+ - Settings
+ - More tools
+ - Extensions
+ - Load unpacked
+ - Look for the folder under './packages/taler-wallet-webextension/extension/'
-# Restart the docker daemon.
-# (This command is OS-specific.)
+You may need to use manifest v2 or v3 depending on the browser version:
+https://blog.mozilla.org/addons/2022/05/18/manifest-v3-in-firefox-recap-next-steps/
+https://developer.chrome.com/docs/extensions/mv3/mv2-sunset/
-# Obtain a new shell. Make sure it includes the `docker` group.
-$ newgrp docker
-$ id
-```
+### Reviewing WebExtension UI examples
-Then, you can proceed with these instructions:
+The WebExtension can be tested using example stories.
+To run a live server use the 'dev-view' target
```shell
-# Download wallet source code and unpack it
-(host)$ tar -xf wallet-core-$version.tar.gz
-
-# Build the image
-(host)$ docker build --tag walletbuilder wallet-core-$version/contrib/wallet-docker
+make webextension-dev-view
+```
-# Start the container
-(host)$ docker run -dti --name walletcontainer walletbuilder /bin/bash
+Stories are defined with a \*.stories.tsx file [1], you are free to create new or edit
+some and commit them in order to create a more complete set of examples.
-# Copy wallet source to container
-(host)$ docker cp ./wallet-core-$version/ walletcontainer:/
+[1] look for them at packages/taler-wallet-webextension/src/\*_/_.stories.tsx
-# Attach to container
-(host)$ docker attach walletcontainer
+### WebExtension UI Components
-# Run build inside container
-(container)$ cd wallet-core-$version
-(container)$ ./configure && make webextension
-(container)$ exit
+Every group of component have a directory and a README.
+Testing component is based in two main category:
-# Copy build artefact(s) to host
-(host)$ docker cp walletcontainer:/wallet-core-$version/packages/taler-wallet-webextension/extension extension
-```
+- UI testing
+- State transition testing
-### Reviewing WebExtension UI examples
+For UI testing, every story example will be taken as a unit test.
+For State testing, every stateful component should have an `useStateComponent` function that will be tested in a \*.test.ts file.
-The WebExtension can be tested using Storybook. Using live server or building
-static html files to deploy into nginx.
+### Testing WebExtension
-To run a live server use the 'dev-view' target
+After building the WebExtension look for the folder `extension`
+Inside you will find v2 and v3 version referring to the manifest version being used.
-```shell
-make webextension-dev-view
-```
+Firefox users:
-A server will start, usually at http://localhost:6006/.
-On the left it will have a navigation panel with examples organized in a tree view.
+- Go to about:addons
+- Then `debug addon` (or about:debugging#/runtime/this-firefox)
+- Then `Load temporary addon...`
+- Select the `taler-wallet-webextension-*.zip`
-Stories are defined with a *.stories.tsx file [1], you are free to create new or edit
-some and commit them in order to create a more complete set of examples.
+Chrome users:
-[1] look for them at packages/taler-wallet-webextension/src/**/*.stories.tsx
+- Settings -> More tools -> Extensions (or go to chrome://extensions/)
+- `Load unpacked` button in the upper left
+- Selected the `unpacked` folder in v2 or v3
# Integration Tests
-This repository comes with integration tests for GNU Taler. To run them,
-install the wallet first. Then use the test runner from the
+This repository comes with integration tests for GNU Taler. To run them,
+install the wallet first. Then use the test runner from the
taler-integrationtests package:
```shell
-cd packages/taler-integrationtests/
-./testrunner '*'
+# List available tests
+taler-wallet-cli testing list-integrationtests
+
+# Run all tests
+taler-wallet-cli testing run-integrationtests
+
+# Run all tests matching pattern
+taler-wallet-cli testing run-integrationtests $GLOB
+
+$ Run all tests from a suite
+taler-wallet-cli testing run-integrationtests --suites=wallet
```
-The test runner accepts a bash glob pattern as parameter. Individual tests can
+The test runner accepts a bash glob pattern as parameter. Individual tests can
be run by specifying their name.
To check coverage, use nyc from the root of the repository and make sure that the taler-wallet-cli
from the source tree is executed, and not the globally installed one:
```
-PATH="$PWD/packages/taler-wallet-cli/bin:$PATH" \
- nyc ./packages/taler-integrationtests/testrunner '*'
+nyc ./packages/taler-wallet-cli/bin/taler-wallet-cli '*'
+```
+
+## Minimum required browser for WebEx
+
+Can be found in:
+ - packages/taler-wallet-webextension/manifest-v2.json
+ - packages/taler-wallet-webextension/manifest-v3.json
+
+## Anastasis Web UI
+
+## Building for deploy
+
+To build the Anastasis SPA run:
+
+```shell
+make anastasis-webui
+```
+
+It will run the test suite and put everything into the dist folder under the project root (packages/anastasis-webui).
+You can run the SPA directly using the file:// protocol.
+
+```shell
+firefox packages/anastasis-webui/dist/ui.html
+```
+
+Additionally you can create a zip file with the content to upload into a web server:
+
+```shell
+make anastasis-webui-dist
```
+
+It creates the zip file named `anastasis-webui.zip`
+
+
diff --git a/bootstrap b/bootstrap
index d862b5652..7e5140ee7 100755
--- a/bootstrap
+++ b/bootstrap
@@ -11,8 +11,24 @@ if ! git --version >/dev/null; then
fi
git submodule update --init
-rm -rf configure
-cp build-system/taler-build-scripts/configure ./configure
-# Try making the configure script read-only to prevent
-# accidental changes in the wrong place.
-chmod ogu-w ./configure || true
+
+copy_configure() {
+ src=$1
+ dst=$2
+ rm -f $dst
+ cp $src $dst
+ # Try making the configure script read-only to prevent
+ # accidental changes in the wrong place.
+ chmod ogu-w $dst || true
+}
+
+# To enable a GNU-style build system, we copy a configure
+# script to each package that can be installed
+our_configure=build-system/taler-build-scripts/configure
+copy_configure "$our_configure" ./configure
+copy_configure "$our_configure" ./packages/taler-wallet-cli/configure
+copy_configure "$our_configure" ./packages/anastasis-cli/configure
+copy_configure "$our_configure" ./packages/bank-ui/configure
+copy_configure "$our_configure" ./packages/taler-harness/configure
+copy_configure "$our_configure" ./packages/taler-util/configure
+copy_configure "$our_configure" ./packages/merchant-backoffice-ui/configure
diff --git a/build-system/Makefile b/build-system/Makefile
deleted file mode 100644
index bbe4bc03e..000000000
--- a/build-system/Makefile
+++ /dev/null
@@ -1,107 +0,0 @@
-# This Makefile has been placed in the public domain.
-
-src = src
-poname = taler-wallet-webex
-
-tsc = node_modules/typescript/bin/tsc
-pogen = node_modules/@gnu-taler/pogen/bin/pogen.js
-typedoc = node_modules/typedoc/bin/typedoc
-ava = node_modules/.bin/ava
-nyc = node_modules/nyc/bin/nyc.js
-git-archive-all = ./build-system/taler-build-scripts/archive-with-submodules/git_archive_all.py
-
-include ./build-system/config.mk
-
-.PHONY: compile
-compile:
- pnpm i -r --frozen-lockfile
- pnpm run compile
-
-.PHONY: dist
-dist:
- $(git-archive-all) --include ./configure taler-wallet-$(shell git describe --tags --abbrev=0).tar.gz
-
-# Create tarball with git hash prefix in name
-.PHONY: dist-git
-dist-git:
- $(git-archive-all) --include ./configure taler-wallet-$(shell git describe --tags).tar.gz
-
-.PHONY: publish
-publish: compile
- pnpm publish -r --no-git-checks
-
-# make documentation from docstrings
-.PHONY: typedoc
-typedoc:
- $(typedoc) --out dist/typedoc --readme README
-
-.PHONY: clean
-clean:
- pnpm run clean
-
-.PHONY: submodules-update
-submodules-update:
- git submodule update --recursive --remote
-
-.PHONY: check
-check: compile
- pnpm run check
-
-.PHONY: webextensions
-webextension: compile
- cd ./packages/taler-wallet-webextension/ && ./pack.sh
-
-.PHONY: webextension-dev-view
-webextension-dev-view: compile
- pnpm run --filter @gnu-taler/taler-wallet-webextension storybook
-
-.PHONY: integrationtests
-integrationtests: compile
- ./packages/taler-integrationtests/testrunner '*'
-
-.PHONY: i18n
-i18n: compile
- # extract translatable strings
- find $(src) \( -name '*.ts' -or -name '*.tsx' \) ! -name '*.d.ts' \
- | xargs node $(pogen) \
- | msguniq \
- | msgmerge src/i18n/poheader - \
- > src/i18n/$(poname).pot
- # merge existing translations
- @for pofile in src/i18n/*.po; do \
- echo merging $$pofile; \
- msgmerge -o $$pofile $$pofile src/i18n/$(poname).pot; \
- done;
- # generate .ts file containing all translations
- cat src/i18n/strings-prelude > src/i18n/strings.ts
- @for pofile in src/i18n/*.po; do \
- echo appending $$pofile; \
- ./contrib/po2ts $$pofile >> src/i18n/strings.ts; \
- done;
- ./node_modules/.bin/prettier --config .prettierrc --write src/i18n/strings.ts
-
-# Some commands are only available when ./configure has been run
-
-ifndef prefix
-.PHONY: warn-noprefix install
-warn-noprefix:
- @echo "no prefix configured, did you run ./configure?"
-install: warn-noprefix
-else
-install_target = $(prefix)/lib/taler-wallet-cli
-.PHONY: install
-install: compile
- install -d $(prefix)/bin
- install -d $(install_target)/bin
- install -d $(install_target)/node_modules/taler-wallet-cli
- install -d $(install_target)/node_modules/taler-wallet-cli/bin
- install -d $(install_target)/node_modules/taler-wallet-cli/dist
- install ./packages/taler-wallet-cli/dist/taler-wallet-cli.js $(install_target)/node_modules/taler-wallet-cli/dist/
- install ./packages/taler-wallet-cli/dist/taler-wallet-cli.js.map $(install_target)/node_modules/taler-wallet-cli/dist/
- install ./packages/taler-wallet-cli/bin/taler-wallet-cli $(install_target)/node_modules/taler-wallet-cli/bin/
- ln -sft $(prefix)/bin $(install_target)/node_modules/taler-wallet-cli/bin/taler-wallet-cli
-endif
-
-.PHONY: lint
-lint:
- ./node_modules/.bin/eslint --ext '.js,.ts,.tsx' 'src'
diff --git a/build-system/configure.py b/build-system/configure.py
index a4a936229..3c5096240 100644
--- a/build-system/configure.py
+++ b/build-system/configure.py
@@ -14,15 +14,12 @@ if getattr(tbc, "serialversion", 0) < 2:
b = tbc.BuildConfig()
b.enable_prefix()
-b.enable_configmk()
+b.enable_configmk(dotfile=True)
b.add_tool(tbc.PosixTool("make"))
b.add_tool(tbc.PosixTool("zip"))
b.add_tool(tbc.PosixTool("find"))
b.add_tool(tbc.PosixTool("jq"))
-b.add_tool(tbc.NodeJsTool(version_spec=">=12"))
+b.add_tool(tbc.NodeJsTool(version_spec=">=18"))
b.add_tool(tbc.GenericTool("npm"))
b.add_tool(tbc.GenericTool("pnpm", hint="Use 'sudo npm install -g pnpm' to install."))
b.run()
-
-print("copying Makefile")
-shutil.copyfile("build-system/Makefile", "Makefile")
diff --git a/build-system/taler-build-scripts b/build-system/taler-build-scripts
-Subproject 38c168b11eeeab93562ffa74b3e2aff4b596c77
+Subproject 001f5dd081fc8729ff8def90c4a1c3f93eb8689
diff --git a/contrib/articles/spending.txt b/contrib/articles/spending.txt
index 7f2c716f8..887a80910 100644
--- a/contrib/articles/spending.txt
+++ b/contrib/articles/spending.txt
@@ -45,7 +45,7 @@ If the above parameters have an optimal assignment, then replacing
v'[x] := 0
gives another optimal solution, as otherwise we'd get a better one for the first situation.
-There is however no assurence that t[x] = price mod v[x] for some x in D, so nievely such solutions give you running times like O(price * |D|), which kinda sucks actually. Just one simplified example :
+There is however no assurence that t[x] = price mod v[x] for some x in D, so naively such solutions give you running times like O(price * |D|), which kinda sucks actually. Just one simplified example :
http://www.codeproject.com/Articles/31002/Coin-Change-Problem-Using-Dynamic-Programming
diff --git a/contrib/articles/ui/ui-cameraready.tex b/contrib/articles/ui/ui-cameraready.tex
index ef79d91ba..b7015b8d3 100644
--- a/contrib/articles/ui/ui-cameraready.tex
+++ b/contrib/articles/ui/ui-cameraready.tex
@@ -881,7 +881,7 @@ the page. Then the wallet inspects the response as it may contain
error reports about a failed payment which the wallet has to handle.
By submitting the payment this way, we also ensure that this
intermediate request does not require JavaScript and still does not
-interfer with navigation. Once the Web shop confirms the payment, the
+interfere with navigation. Once the Web shop confirms the payment, the
wallet causes the fulfillment URL to be reloaded.
If the contract hash does not match a payment which the user
@@ -937,7 +937,7 @@ it has the following key advantages:
other users has the expected behavior
of asking the other user to pay for the resource.
\item Asynchronously transmitting coins from injected JavaScript costs
- one roundtrip, but does not interfer with navigation and allows
+ one roundtrip, but does not interfere with navigation and allows
proper error handling.
\item The different pages of the merchant have clear
delineations: the shopping pages conclude by making an offer, and
@@ -1137,7 +1137,7 @@ passes it to the wallet (sample code for this is shown in
Figure~\ref{listing:http-contract}).
Instead of adding any cryptographic logic to the merchant frontend,
-the Taler merchant backend allows the implementor to delegate
+the Taler merchant backend allows the implementer to delegate
coin handling to the payment backend, which validates the coins,
deposits them at the exchange, and finally validates and persists the
receipt from the exchange. The merchant backend then communicates the
diff --git a/contrib/articles/ui/ui.tex b/contrib/articles/ui/ui.tex
index 37cd8b076..4aef43bd4 100644
--- a/contrib/articles/ui/ui.tex
+++ b/contrib/articles/ui/ui.tex
@@ -933,7 +933,7 @@ the page. Then the wallet inspects the response as it may contain
error reports about a failed payment which the wallet has to handle.
By submitting the payment this way, we also ensure that this
intermediate request does not require JavaScript and still does not
-interfer with navigation. Once the Web shop confirms the payment, the
+interfere with navigation. Once the Web shop confirms the payment, the
wallet causes the fulfillment URL to be reloaded.
If the contract hash does not match a payment which the user
@@ -989,7 +989,7 @@ it has the following key advantages:
other users has the expected behavior
of asking the other user to pay for the resource.
\item Asynchronously transmitting coins from injected JavaScript costs
- one roundtrip, but does not interfer with navigation and allows
+ one roundtrip, but does not interfere with navigation and allows
proper error handling.
\item The different pages of the merchant have clear
delineations: the shopping pages conclude by making an offer, and
@@ -1189,7 +1189,7 @@ passes it to the wallet (sample code for this is shown in
Figure~\ref{listing:contract}).
Instead of adding any cryptographic logic to the merchant frontend,
-the Taler merchant backend allows the implementor to delegate
+the Taler merchant backend allows the implementer to delegate
coin handling to the payment backend, which validates the coins,
deposits them at the exchange, and finally validates and persists the
receipt from the exchange. The merchant backend then communicates the
diff --git a/contrib/articles/ui/ui_short.tex b/contrib/articles/ui/ui_short.tex
index 18f0c1de0..9257a997c 100644
--- a/contrib/articles/ui/ui_short.tex
+++ b/contrib/articles/ui/ui_short.tex
@@ -221,7 +221,7 @@ existing infrastructure.
Instead of adding any cryptographic logic to the merchant front-end,
-the generic Taler merchant backend allows the implementor to delegate
+the generic Taler merchant backend allows the implementer to delegate
handling of the coins to the payment backend, which validates the
coins, deposits them at the exchange, and finally validates and
persists the receipt from the exchange. The merchant backend then
diff --git a/contrib/build-fast-web.sh b/contrib/build-fast-web.sh
index ddc9f10f3..2f213e5f6 100755
--- a/contrib/build-fast-web.sh
+++ b/contrib/build-fast-web.sh
@@ -84,6 +84,12 @@ function build_preact_compat() {
esbuild $BUNDLE_OPTIONS --loader:.js=jsx vendor/preact/test-utils/src/index.js > $DIST/react-dom/test-utils/index.js
}
+function build_qrcode() {
+ mkdir -p $DIST/qrcode-generator
+
+ esbuild $BUNDLE_OPTIONS vendor/qrcode-generator/js/qrcode.js > $DIST/qrcode-generator/index.js
+}
+
function build_history() {
mkdir -p $DIST/{history,resolve-pathname,value-equal,tiny-warning,tiny-invariant}
@@ -133,6 +139,7 @@ build_preact
build_preact-router
build_preact_compat
+build_qrcode
build_history
build_linaria
diff --git a/contrib/bump-taler-version.mjs b/contrib/bump-taler-version.mjs
new file mode 100755
index 000000000..3a57bd01d
--- /dev/null
+++ b/contrib/bump-taler-version.mjs
@@ -0,0 +1,119 @@
+#!/usr/bin/env node
+
+// Bump the package.json versions in Taler-related packagesin
+// this repository.
+// The version must be in one of the following formats:
+// - x.y.z
+// - x.y.z-dev.n
+
+import * as child_process from "child_process";
+import * as fs from "fs";
+
+let requestedVersion = process.argv[2];
+
+let dry = false;
+if (process.argv.includes("--dry")) {
+ dry = true;
+}
+
+let verMatched = false;
+let verMajor = 0;
+let verMinor = 0;
+let verPatch = 0;
+let verDev = undefined;
+
+// Parse the requested version
+const releaseVerRegex = /^(\d+)[.](\d+)[.](\d+)$/;
+const devVerRegex = /^(\d+)[.](\d+)[.](\d+)-dev[.](\d+)$/;
+
+const releaseMatch = requestedVersion.match(releaseVerRegex);
+if (releaseMatch) {
+ verMatched = true;
+ verMajor = releaseMatch[1];
+ verMinor = releaseMatch[2];
+ verPatch = releaseMatch[3];
+}
+
+if (!verMatched) {
+ const devMatch = requestedVersion.match(devVerRegex);
+ if (devMatch) {
+ verMatched = true;
+ verMajor = devMatch[1];
+ verMinor = devMatch[2];
+ verPatch = devMatch[3];
+ verDev = devMatch[4];
+ }
+}
+
+const packages = fs.readdirSync("packages")
+
+for (const pkg of packages) {
+ const p = `packages/${pkg}/package.json`;
+ const data = JSON.parse(fs.readFileSync(p));
+ console.log(p, data.version);
+ if (!dry) {
+ data.version = requestedVersion;
+ fs.writeFileSync(p, JSON.stringify(data, undefined, 2) + "\n");
+ }
+}
+
+{
+ const p = "packages/taler-wallet-webextension/manifest-common.json";
+ const data = JSON.parse(fs.readFileSync(p));
+ console.log("manifest version", data.version);
+ console.log("manifest version_name", data.version_name);
+ // In manifest.json, we transform x.y.z-dev.n info x.y.z.n.
+ // It's necessary because browsers only allow decimals and dots
+ // in that field.
+ let dottedVer = undefined;
+ if (verDev != null) {
+ dottedVer = `${verMajor}.${verMinor}.${verPatch}.${verDev}`;
+ } else {
+ dottedVer = `${verMajor}.${verMinor}.${verPatch}`;
+ }
+ console.log("new manifest version", dottedVer);
+ if (!dry) {
+ data.version = dottedVer;
+ data.version_name = requestedVersion;
+ fs.writeFileSync(p, JSON.stringify(data, undefined, 2) + "\n");
+ }
+}
+
+let debs = ["taler-wallet-cli", "taler-harness"];
+
+for (const deb of debs) {
+ const p = `packages/${deb}/debian/changelog`;
+ const data = fs.readFileSync(p, {
+ encoding: "utf-8",
+ });
+ const lines = data.split("\n");
+ for (const line of lines) {
+ const s = line.trim();
+ if (s == "") {
+ continue;
+ }
+ const re = /\((.*)\)/;
+ const m = s.match(re);
+ const version = m[1];
+ let pfx = "";
+ if (version != requestedVersion) {
+ pfx = "[!] ";
+ if (!dry) {
+ const dateStr = child_process.execSync("date -R", {
+ encoding: "utf-8",
+ });
+ const entryLines = [
+ `${deb} (${requestedVersion}) unstable; urgency=low`,
+ "",
+ ` * Release ${requestedVersion}`,
+ "",
+ ` -- Florian Dold <dold@taler.net> ${dateStr}`,
+ ``,
+ ];
+ fs.writeFileSync(p, `${entryLines.join("\n")}${data}`);
+ }
+ }
+ console.log(`${pfx}${p} is ${version}`);
+ break;
+ }
+}
diff --git a/contrib/ci/Containerfile b/contrib/ci/Containerfile
new file mode 100644
index 000000000..a9da11ae8
--- /dev/null
+++ b/contrib/ci/Containerfile
@@ -0,0 +1,27 @@
+FROM docker.io/library/node:18-slim
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update -yq && \
+ apt-get install -yqq \
+ git \
+ python3 \
+ codespell \
+ python3-distutils \
+ make \
+ zip \
+ jq \
+ # Debian packaging tools \
+ po-debconf \
+ build-essential \
+ debhelper-compat \
+ devscripts \
+ git-buildpackage \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN npm install -g pnpm
+
+# Set our workdir. All subsequent commands will be relative to this path.
+WORKDIR /workdir
+
+CMD ["bash", "/workdir/ci/ci.sh"]
diff --git a/contrib/ci/ci.sh b/contrib/ci/ci.sh
new file mode 100755
index 000000000..0719015b9
--- /dev/null
+++ b/contrib/ci/ci.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+set -exvuo pipefail
+
+# Requires podman
+# Fails if not found in PATH
+OCI_RUNTIME=$(which podman)
+REPO_NAME=$(basename "${PWD}")
+JOB_NAME="${1}"
+JOB_ARCH=$((grep CONTAINER_ARCH contrib/ci/jobs/${JOB_NAME}/config.ini | cut -d' ' -f 3) || echo "${2:-amd64}")
+JOB_CONTAINER=$((grep CONTAINER_NAME contrib/ci/jobs/${JOB_NAME}/config.ini | cut -d' ' -f 3) || echo "localhost/${REPO_NAME}:${JOB_ARCH}")
+CONTAINER_BUILD=$((grep CONTAINER_BUILD contrib/ci/jobs/${JOB_NAME}/config.ini | cut -d' ' -f 3) || echo "True")
+
+echo "Image name: ${JOB_CONTAINER}"
+
+if [ "${CONTAINER_BUILD}" = "True" ] ; then
+ "${OCI_RUNTIME}" build \
+ --arch "${JOB_ARCH}" \
+ -t "${JOB_CONTAINER}" \
+ -f contrib/ci/Containerfile .
+fi
+
+"${OCI_RUNTIME}" run \
+ --rm \
+ -ti \
+ --arch "${JOB_ARCH}" \
+ --env CI_COMMIT_REF="$(git rev-parse HEAD)" \
+ --volume "${PWD}":/workdir \
+ --workdir /workdir \
+ "${JOB_CONTAINER}" \
+ contrib/ci/jobs/"${JOB_NAME}"/job.sh
+
+top_dir=$(dirname "${BASH_SOURCE[0]}")
+
+#"${top_dir}"/build.sh
diff --git a/contrib/ci/jobs/0-codespell/config.ini b/contrib/ci/jobs/0-codespell/config.ini
new file mode 100644
index 000000000..bd7d73860
--- /dev/null
+++ b/contrib/ci/jobs/0-codespell/config.ini
@@ -0,0 +1,6 @@
+[build]
+HALT_ON_FAILURE = False
+WARN_ON_FAILURE = True
+CONTAINER_BUILD = False
+CONTAINER_NAME = nixery.dev/shell/codespell
+CONTAINER_ARCH = amd64
diff --git a/contrib/ci/jobs/0-codespell/dictionary.txt b/contrib/ci/jobs/0-codespell/dictionary.txt
new file mode 100644
index 000000000..aeace0e9e
--- /dev/null
+++ b/contrib/ci/jobs/0-codespell/dictionary.txt
@@ -0,0 +1,20 @@
+# List of "words" that codespell should ignore in our sources.
+#
+# Note: The word sensitivity depends on how the to-be-ignored word is
+# spelled in codespell_lib/data/dictionary.txt. F.e. if there is a word
+# 'foo' and you add 'Foo' _here_, codespell will continue to complain
+# about 'Foo'.
+#
+aci
+cant
+ect
+fo
+som
+te
+ths
+updateing
+vie
+zar
+nam
+pares
+kwanza
diff --git a/contrib/ci/jobs/0-codespell/job.sh b/contrib/ci/jobs/0-codespell/job.sh
new file mode 100755
index 000000000..9271343e6
--- /dev/null
+++ b/contrib/ci/jobs/0-codespell/job.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+set -exuo pipefail
+
+job_dir=$(dirname "${BASH_SOURCE[0]}")
+
+codespell -q 0 -I "${job_dir}"/dictionary.txt -S "*.bib,*.bst,*.cls,*.json,*.png,*.svg,*.wav,*.gz,*/templating/test?/**,**/auditor/*.sql,**/templating/mustach**,*.fees,*key,*.tag,*.info,*.latexmkrc,*.ecc,*.jpg,*.zkey,*.sqlite,*/contrib/hellos/**,*/vpn/tests/**,*.priv,*.file,*.tgz,*.woff,*.gif,*.odt,*.fee,*.deflate,*.dat,*.jpeg,*.eps,*.odg,*/m4/ax_lib_postgresql.m4,*/m4/libgcrypt.m4,*.rpath,config.status,ABOUT-NLS,*/doc/texinfo.tex,*.PNG,*.??.json,*.docx,*.ods,*.doc,*.docx,*.xcf,*.xlsx,*.ecc,*.ttf,*.woff2,*.eot,*.ttf,*.eot,*.mp4,*.pptx,*.epgz,*.min.js,**/*.map,**/fonts/**,*.pack.js,*.po,*.bbl,*/afl-tests/*,*/.git/**,*.pdf,*.epub,**/signing-key.asc,**/pnpm-lock.yaml,**/*.svg,**/*.cls,**/rfc.bib,**/*.bst,*/cbdc-es.tex,*/cbdc-it.tex,**/ExchangeSelection/example.ts,*/testcurl/test_tricky.c,*/i18n/strings.ts,*/src/anastasis-data.ts,**/doc/flows/main.de.tex,*/vendor/**,*/node_modules/**,*.pnpm-store/**"
diff --git a/contrib/ci/jobs/1-build/build.sh b/contrib/ci/jobs/1-build/build.sh
new file mode 100755
index 000000000..25a38946d
--- /dev/null
+++ b/contrib/ci/jobs/1-build/build.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+set -exuo pipefail
+
+./bootstrap
+./configure --prefix=$HOME/local/
+make
diff --git a/contrib/ci/jobs/1-build/job.sh b/contrib/ci/jobs/1-build/job.sh
new file mode 100755
index 000000000..8d79902c5
--- /dev/null
+++ b/contrib/ci/jobs/1-build/job.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+set -exuo pipefail
+
+job_dir=$(dirname "${BASH_SOURCE[0]}")
+
+"${job_dir}"/build.sh
diff --git a/contrib/ci/jobs/2-docs/docs.sh b/contrib/ci/jobs/2-docs/docs.sh
new file mode 100755
index 000000000..ee6abc2ac
--- /dev/null
+++ b/contrib/ci/jobs/2-docs/docs.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+set -exuo pipefail
+
+./bootstrap
+./configure
+make
+
+pnpm run typedoc
diff --git a/contrib/ci/jobs/2-docs/job.sh b/contrib/ci/jobs/2-docs/job.sh
new file mode 100755
index 000000000..a72bca4ba
--- /dev/null
+++ b/contrib/ci/jobs/2-docs/job.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+set -exuo pipefail
+
+job_dir=$(dirname "${BASH_SOURCE[0]}")
+
+"${job_dir}"/docs.sh
diff --git a/contrib/ci/jobs/3-wallet-cli-deb-package/config.ini b/contrib/ci/jobs/3-wallet-cli-deb-package/config.ini
new file mode 100644
index 000000000..fe4fd36a6
--- /dev/null
+++ b/contrib/ci/jobs/3-wallet-cli-deb-package/config.ini
@@ -0,0 +1,6 @@
+[build]
+HALT_ON_FAILURE = False
+WARN_ON_FAILURE = True
+CONTAINER_BUILD = True
+CONTAINER_NAME = localhost/wallet-core
+CONTAINER_ARCH = amd64
diff --git a/contrib/ci/jobs/3-wallet-cli-deb-package/job.sh b/contrib/ci/jobs/3-wallet-cli-deb-package/job.sh
new file mode 100755
index 000000000..ac9132929
--- /dev/null
+++ b/contrib/ci/jobs/3-wallet-cli-deb-package/job.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+set -exuo pipefail
+# This file is in the public domain.
+# Helper script to build the latest DEB packages in the container.
+
+
+unset LD_LIBRARY_PATH
+
+# Run the bootstrap script in case it hasn't been done already at this point
+./bootstrap
+
+# gbp wants the debian directory in the root of the git repo
+# so we symlink in the debian directory for the package we want to create
+rm -f ./debian # in case we already have a symlink there
+ln -s packages/taler-wallet-cli/debian .
+
+# Change to our package directory
+cd packages/taler-wallet-cli
+
+# Install build-time dependencies.
+# Update apt cache first
+apt-get update
+apt-get upgrade -y
+mk-build-deps --install --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control
+
+export VERSION="$(../../contrib/ci/jobs/3-wallet-cli-deb-package/version.sh)"
+echo "Building package version ${VERSION}"
+
+EMAIL=none gbp dch --ignore-branch --debian-tag="%(version)s" --git-author --new-version="${VERSION}"
+echo "Current PWD is $PWD"
+./configure --prefix=$HOME/local/
+dpkg-buildpackage -rfakeroot -b -uc -us
+
+ls -alh ../*.deb
+mkdir -p /artifacts/wallet-core/${CI_COMMIT_REF} # Variable comes from CI environment
+mv ../*.deb /artifacts/wallet-core/${CI_COMMIT_REF}/
diff --git a/contrib/ci/jobs/3-wallet-cli-deb-package/version.sh b/contrib/ci/jobs/3-wallet-cli-deb-package/version.sh
new file mode 100755
index 000000000..52031b23a
--- /dev/null
+++ b/contrib/ci/jobs/3-wallet-cli-deb-package/version.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+set -ex
+
+BRANCH=$(git name-rev --name-only HEAD)
+if [ -z "${BRANCH}" ]; then
+ exit 1
+else
+ # "Unshallow" our checkout, but only our current branch, and exclude the submodules.
+ git fetch --no-recurse-submodules --tags --depth=1000 origin "${BRANCH}"
+ RECENT_VERSION_TAG=$(git describe --tags --match 'v*.*.*' --exclude '*-dev*' --always --abbrev=0 HEAD || exit 1)
+ commits="$(git rev-list ${RECENT_VERSION_TAG}..HEAD --count)"
+ if [ "${commits}" = "0" ]; then
+ git describe --tag HEAD | sed -r 's/^v//' || exit 1
+ else
+ echo $(echo ${RECENT_VERSION_TAG} | sed -r 's/^v//')-${commits}-$(git rev-parse --short=8 HEAD)
+ fi
+fi
diff --git a/contrib/ci/jobs/4-taler-harness-deb-package/config.ini b/contrib/ci/jobs/4-taler-harness-deb-package/config.ini
new file mode 100644
index 000000000..fe4fd36a6
--- /dev/null
+++ b/contrib/ci/jobs/4-taler-harness-deb-package/config.ini
@@ -0,0 +1,6 @@
+[build]
+HALT_ON_FAILURE = False
+WARN_ON_FAILURE = True
+CONTAINER_BUILD = True
+CONTAINER_NAME = localhost/wallet-core
+CONTAINER_ARCH = amd64
diff --git a/contrib/ci/jobs/4-taler-harness-deb-package/job.sh b/contrib/ci/jobs/4-taler-harness-deb-package/job.sh
new file mode 100755
index 000000000..c58c643e6
--- /dev/null
+++ b/contrib/ci/jobs/4-taler-harness-deb-package/job.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+set -exuo pipefail
+# This file is in the public domain.
+# Helper script to build the latest DEB packages in the container.
+
+
+unset LD_LIBRARY_PATH
+
+# Run the bootstrap script in case it hasn't been done already at this point
+./bootstrap
+
+# gbp wants the debian directory in the root of the git repo
+# so we symlink in the debian directory for the package we want to create
+rm -f ./debian # in case we already have a symlink there
+ln -s packages/taler-harness/debian .
+
+# Change to our package directory
+cd packages/taler-harness
+
+# Install build-time dependencies.
+# Update apt cache first
+apt-get update
+apt-get upgrade -y
+mk-build-deps --install --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control
+
+export VERSION="$(../../contrib/ci/jobs/4-taler-harness-deb-package/version.sh)"
+echo "Building package version ${VERSION}"
+
+EMAIL=none gbp dch --ignore-branch --debian-tag="%(version)s" --git-author --new-version="${VERSION}"
+echo "Current PWD is $PWD"
+./configure --prefix=$HOME/local/
+dpkg-buildpackage -rfakeroot -b -uc -us
+
+ls -alh ../*.deb
+mkdir -p /artifacts/wallet-core/${CI_COMMIT_REF} # Variable comes from CI environment
+mv ../*.deb /artifacts/wallet-core/${CI_COMMIT_REF}/
diff --git a/contrib/ci/jobs/4-taler-harness-deb-package/version.sh b/contrib/ci/jobs/4-taler-harness-deb-package/version.sh
new file mode 100755
index 000000000..52031b23a
--- /dev/null
+++ b/contrib/ci/jobs/4-taler-harness-deb-package/version.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+set -ex
+
+BRANCH=$(git name-rev --name-only HEAD)
+if [ -z "${BRANCH}" ]; then
+ exit 1
+else
+ # "Unshallow" our checkout, but only our current branch, and exclude the submodules.
+ git fetch --no-recurse-submodules --tags --depth=1000 origin "${BRANCH}"
+ RECENT_VERSION_TAG=$(git describe --tags --match 'v*.*.*' --exclude '*-dev*' --always --abbrev=0 HEAD || exit 1)
+ commits="$(git rev-list ${RECENT_VERSION_TAG}..HEAD --count)"
+ if [ "${commits}" = "0" ]; then
+ git describe --tag HEAD | sed -r 's/^v//' || exit 1
+ else
+ echo $(echo ${RECENT_VERSION_TAG} | sed -r 's/^v//')-${commits}-$(git rev-parse --short=8 HEAD)
+ fi
+fi
diff --git a/contrib/ci/jobs/5-deploy-packages/config.ini b/contrib/ci/jobs/5-deploy-packages/config.ini
new file mode 100644
index 000000000..08c106f9c
--- /dev/null
+++ b/contrib/ci/jobs/5-deploy-packages/config.ini
@@ -0,0 +1,6 @@
+[build]
+HALT_ON_FAILURE = True
+WARN_ON_FAILURE = True
+CONTAINER_BUILD = False
+CONTAINER_NAME = nixery.dev/shell/rsync
+CONTAINER_ARCH = amd64
diff --git a/contrib/ci/jobs/5-deploy-packages/job.sh b/contrib/ci/jobs/5-deploy-packages/job.sh
new file mode 100755
index 000000000..46ed90f70
--- /dev/null
+++ b/contrib/ci/jobs/5-deploy-packages/job.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+set -exuo pipefail
+
+ARTIFACT_PATH="/artifacts/wallet-core/${CI_COMMIT_REF}/*.deb"
+
+RSYNC_HOST="taler.host.internal"
+RSYNC_PORT=424242
+RSYNC_PATH="incoming_packages/bookworm-taler-ci/"
+RSYNC_DEST="rsync://${RSYNC_HOST}/${RSYNC_PATH}"
+
+
+rsync -vP \
+ --port ${RSYNC_PORT} \
+ ${ARTIFACT_PATH} ${RSYNC_DEST}
diff --git a/contrib/cleanup-prebuilt-dir.sh b/contrib/cleanup-prebuilt-dir.sh
new file mode 100755
index 000000000..5553fb467
--- /dev/null
+++ b/contrib/cleanup-prebuilt-dir.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+set -e
+
+[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
+
+# make sure that the prebuilt directory is clean
+# before building
+# this script is part of the make prebuilt
+cd prebuilt
+git checkout -- .
+git pull
diff --git a/contrib/copy-aml-backoffice-into-prebuilt.sh b/contrib/copy-aml-backoffice-into-prebuilt.sh
new file mode 100755
index 000000000..2b94327e6
--- /dev/null
+++ b/contrib/copy-aml-backoffice-into-prebuilt.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
+
+find packages/aml-backoffice-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/aml-backoffice/bof
+
+while IFS= read -r file; do
+ cp packages/aml-backoffice-ui/dist/prod/$file prebuilt/aml-backoffice/$file
+done < prebuilt/aml-backoffice/bof
+
diff --git a/contrib/copy-auditor-backoffice-into-prebuilt.sh b/contrib/copy-auditor-backoffice-into-prebuilt.sh
new file mode 100755
index 000000000..6b6544183
--- /dev/null
+++ b/contrib/copy-auditor-backoffice-into-prebuilt.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
+
+find packages/auditor-backoffice-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/auditor-backoffice/bof
+
+while IFS= read -r file; do
+ cp packages/auditor-backoffice-ui/dist/prod/$file prebuilt/auditor-backoffice/$file
+done < prebuilt/auditor-backoffice/bof
+
diff --git a/contrib/copy-backend-into-prebuilt.sh b/contrib/copy-backend-into-prebuilt.sh
new file mode 100755
index 000000000..81d38cdea
--- /dev/null
+++ b/contrib/copy-backend-into-prebuilt.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
+
+for file in offer_refund.en.html request_payment.en.html show_order_details.en.html; do
+ cp packages/merchant-backend-ui/dist/pages/$file prebuilt/backend/
+done
+
diff --git a/contrib/copy-backoffice-into-prebuilt.sh b/contrib/copy-backoffice-into-prebuilt.sh
new file mode 100755
index 000000000..d21b91096
--- /dev/null
+++ b/contrib/copy-backoffice-into-prebuilt.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
+
+find packages/merchant-backoffice-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/backoffice/bof
+
+while IFS= read -r file; do
+ cp packages/merchant-backoffice-ui/dist/prod/$file prebuilt/backoffice/$file
+done < prebuilt/backoffice/bof
+
diff --git a/contrib/copy-bank-into-prebuilt.sh b/contrib/copy-bank-into-prebuilt.sh
new file mode 100755
index 000000000..5ad375767
--- /dev/null
+++ b/contrib/copy-bank-into-prebuilt.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
+
+find packages/bank-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/bank/bof
+
+while IFS= read -r file; do
+ cp packages/bank-ui/dist/prod/$file prebuilt/bank/$file
+done < prebuilt/bank/bof
+
diff --git a/contrib/copy-challenger-into-prebuilt.sh b/contrib/copy-challenger-into-prebuilt.sh
new file mode 100755
index 000000000..ebc39192c
--- /dev/null
+++ b/contrib/copy-challenger-into-prebuilt.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+[ ! -d prebuilt ] && git worktree add -f prebuilt prebuilt && exit 1
+
+find packages/challenger-ui/dist/prod/ -type f -printf '%P\n' | sort > prebuilt/challenger/bof
+
+while IFS= read -r file; do
+ cp packages/challenger-ui/dist/prod/$file prebuilt/challenger/$file
+done < prebuilt/challenger/bof
+
diff --git a/contrib/custom-protocol/README b/contrib/custom-protocol/README
new file mode 100644
index 000000000..0ebbeed4d
--- /dev/null
+++ b/contrib/custom-protocol/README
@@ -0,0 +1,33 @@
+Custom protocol handler for taler:// URI
+
+In order to run the wallet when trying to open an html anchor to a ref starting with "taler://" you have to setup a custom protocol handler in your local setup.
+
+
+
+First add this content into file `.config/mimeapps.list` under section `[Default Applications]`
+
+```
+x-scheme-handler/taler=taler-wallet-cli.desktop
+x-scheme-handler/taler+http=taler-wallet-cli.desktop
+```
+
+then create a file named `taler-wallet-cli.desktop` in location `$HOME/.local/share/applications` with content
+
+```
+[Desktop Entry]
+Name=GNU Taler Wallet CLI URL Handler
+GenericName=Wallet
+Comment=Handle URL Scheme taler://
+Exec=bash -c "taler-wallet-cli handle-uri %u; read"
+Terminal=true
+Type=Application
+MimeType=x-scheme-handler/taler;x-scheme-handler/taler+http
+Name[en_US]=GNU Taler Wallet URL Handler
+```
+
+Done, you can test it using the next command:
+
+```
+$ xdg-open taler://withdraw/bank.demo.taler.net/api/793ee3e4-2915-47e8-9abe-bcc36c8e65cf
+```
+
diff --git a/contrib/custom-protocol/taler-wallet-cli.desktop b/contrib/custom-protocol/taler-wallet-cli.desktop
new file mode 100755
index 000000000..35c8e5154
--- /dev/null
+++ b/contrib/custom-protocol/taler-wallet-cli.desktop
@@ -0,0 +1,12 @@
+[Desktop Entry]
+Name=GNU Taler Wallet CLI URL Handler
+GenericName=Wallet
+Comment=Handle URL Scheme taler://
+Exec=bash -c "taler-wallet-cli handle-uri %u; read"
+Terminal=true
+Type=Application
+MimeType=x-scheme-handler/taler;x-scheme-handler/taler+http
+Icon=sublime-text-2
+Categories=TextEditor;Development;Utility;
+Name[en_US]=GNU Taler Wallet URL Handler
+
diff --git a/contrib/devrelease.sh b/contrib/devrelease.sh
index 72b059a01..4418ee539 100755
--- a/contrib/devrelease.sh
+++ b/contrib/devrelease.sh
@@ -16,7 +16,7 @@ fi
mkdir -p prebuilt/$devtag
-cp packages/taler-wallet-android/dist/taler-wallet-android.js prebuilt/$devtag/
+cp packages/taler-wallet-embedded/dist/taler-wallet-embedded.js prebuilt/$devtag/
cd prebuilt
git add -A $devtag
git commit -m "prebuilt files for $devtag" || true
diff --git a/contrib/publish-prebuilt-dir.sh b/contrib/publish-prebuilt-dir.sh
new file mode 100755
index 000000000..a636c6de6
--- /dev/null
+++ b/contrib/publish-prebuilt-dir.sh
@@ -0,0 +1,15 @@
+#!/bin/bash
+
+# this script is part of the make prebuilt procedure
+# is expected on the root folder
+
+# create a commit message with the commit id
+COMMIT=$(git rev-parse --verify HEAD)
+MSG="built from ${COMMIT}"
+
+# after building process has copy everything into
+# the prebuilt folder
+cd prebuilt
+git commit -m "$MSG" -a
+git show --stat
+echo "ready to push"
diff --git a/contrib/publish-prebuilt.sh b/contrib/publish-prebuilt.sh
new file mode 100755
index 000000000..94bd274ff
--- /dev/null
+++ b/contrib/publish-prebuilt.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+# Helper script to publish a prebuilt wallet-core.
+# Assumes that the prebuilt branch is checked out
+# at ./prebuilt as a git worktree.
+
+set -eu
+
+TAG=$1
+
+pnpm run compile
+mkdir prebuilt/$TAG
+cp packages/taler-wallet-embedded/dist/taler-wallet-embedded.js prebuilt/$TAG/taler-wallet-embedded.js
+git -C prebuilt add .
+git -C prebuilt commit -a -m "prebuilt $TAG"
+git -C prebuilt push
+sha256sum prebuilt/$TAG/taler-wallet-android.js
diff --git a/contrib/sample-data/history1.json b/contrib/sample-data/history1.json
deleted file mode 100644
index 8d0076a31..000000000
--- a/contrib/sample-data/history1.json
+++ /dev/null
@@ -1,402 +0,0 @@
-{
- "history": [
- {
- "type": "exchange-added",
- "builtIn": false,
- "eventId": "exchange-added;https%3A%2F%2Fexchange.demo.taler.net%2F",
- "exchangeBaseUrl": "https://exchange.demo.taler.net/",
- "timestamp": {
- "t_ms": 1578334008633
- }
- },
- {
- "type": "exchange-updated",
- "eventId": "exchange-updated;https%3A%2F%2Fexchange.demo.taler.net%2F",
- "exchangeBaseUrl": "https://exchange.demo.taler.net/",
- "timestamp": {
- "t_ms": 1578334009266
- }
- },
- {
- "type": "reserve-balance-updated",
- "eventId": "reserve-balance-updated;HHG1KBFSW4PM8J43D14GVJYB8F5J56RDHANY1EQSW6RTYDAQJC6G",
- "amountExpected": "KUDOS:5",
- "amountReserveBalance": "KUDOS:5",
- "timestamp": {
- "t_ms": 1578334039291
- },
- "newHistoryTransactions": [
- {
- "amount": "KUDOS:5",
- "sender_account_url": "payto://x-taler-bank/bank.demo.taler.net/65",
- "timestamp": {
- "t_ms": 1578334028000
- },
- "wire_reference": "000000000038Y",
- "type": "DEPOSIT"
- }
- ],
- "reserveShortInfo": {
- "exchangeBaseUrl": "https://exchange.demo.taler.net/",
- "reserveCreationDetail": {
- "type": "taler-bank-withdraw",
- "bankUrl": "https://bank.demo.taler.net/api/withdraw-operation/6fd6a78f-3d12-4c91-b5e4-c6fc31f44e8d"
- },
- "reservePub": "JPE7VR8R985WQ7ZX3EEYRTEGJQ1FAFE7P3JK1J7WFJEP7AGNTJD0"
- }
- },
- {
- "type": "withdrawn",
- "withdrawSessionId": "SFW3JS0JV0GZQQ1W07TNQEAGBD84X2QMH38PJ2CCTTKSDKQFCBY0",
- "eventId": "withdrawn;SFW3JS0JV0GZQQ1W07TNQEAGBD84X2QMH38PJ2CCTTKSDKQFCBY0",
- "amountWithdrawnEffective": "KUDOS:4.8",
- "amountWithdrawnRaw": "KUDOS:5",
- "exchangeBaseUrl": "https://exchange.demo.taler.net/",
- "timestamp": {
- "t_ms": 1578334039853
- },
- "withdrawalSource": {
- "type": "reserve",
- "reservePub": "JPE7VR8R985WQ7ZX3EEYRTEGJQ1FAFE7P3JK1J7WFJEP7AGNTJD0"
- }
- },
- {
- "type": "order-accepted",
- "eventId": "order-accepted;RNFEQ2FHF6NPM5M58HJH635TD4CE9S2SRNK3VN9SCMH3H7H0REBG",
- "orderShortInfo": {
- "amount": "KUDOS:0.5",
- "orderId": "2020.006-G1NT65XRPQ8GP",
- "merchantBaseUrl": "https://backend.demo.taler.net/public/instances/FSF/",
- "proposalId": "RNFEQ2FHF6NPM5M58HJH635TD4CE9S2SRNK3VN9SCMH3H7H0REBG",
- "summary": "Essay: 2. The GNU Project"
- },
- "timestamp": {
- "t_ms": 1578334078823
- }
- },
- {
- "type": "order-redirected",
- "eventId": "order-redirected;0W4EBHQJ90XX4TSQ9C0M6K9MBFJ1ENKTWH4R3CXFT986A2QHCESG",
- "alreadyPaidOrderShortInfo": {
- "amount": "KUDOS:0.5",
- "orderId": "2020.006-G1NT65XRPQ8GP",
- "merchantBaseUrl": "https://backend.demo.taler.net/public/instances/FSF/",
- "proposalId": "RNFEQ2FHF6NPM5M58HJH635TD4CE9S2SRNK3VN9SCMH3H7H0REBG",
- "summary": "Essay: 2. The GNU Project"
- },
- "newOrderShortInfo": {
- "amount": "KUDOS:0.5",
- "orderId": "2020.006-00W4ANVVKAHAP",
- "merchantBaseUrl": "https://backend.demo.taler.net/public/instances/FSF/",
- "proposalId": "0W4EBHQJ90XX4TSQ9C0M6K9MBFJ1ENKTWH4R3CXFT986A2QHCESG",
- "summary": "Essay: 2. The GNU Project"
- },
- "timestamp": {
- "t_ms": 1578334108380
- }
- },
- {
- "type": "payment-sent",
- "eventId": "payment-sent;RNFEQ2FHF6NPM5M58HJH635TD4CE9S2SRNK3VN9SCMH3H7H0REBG",
- "orderShortInfo": {
- "amount": "KUDOS:0.5",
- "orderId": "2020.006-G1NT65XRPQ8GP",
- "merchantBaseUrl": "https://backend.demo.taler.net/public/instances/FSF/",
- "proposalId": "RNFEQ2FHF6NPM5M58HJH635TD4CE9S2SRNK3VN9SCMH3H7H0REBG",
- "summary": "Essay: 2. The GNU Project"
- },
- "replay": true,
- "sessionId": "ab48396f-3aa1-4e1f-bfb5-30852d1e0d5e",
- "timestamp": {
- "t_ms": 1578334108677
- },
- "numCoins": 6,
- "amountPaidWithFees": "KUDOS:0.54"
- },
- {
- "type": "exchange-added",
- "builtIn": false,
- "eventId": "exchange-added;https%3A%2F%2Fexchange.test.taler.net%2F",
- "exchangeBaseUrl": "https://exchange.test.taler.net/",
- "timestamp": {
- "t_ms": 1578334134741
- }
- },
- {
- "type": "exchange-updated",
- "eventId": "exchange-updated;https%3A%2F%2Fexchange.test.taler.net%2F",
- "exchangeBaseUrl": "https://exchange.test.taler.net/",
- "timestamp": {
- "t_ms": 1578334135451
- }
- },
- {
- "type": "reserve-balance-updated",
- "eventId": "reserve-balance-updated;498DDH4ZB41QX45FH38T4Y8JM14WX8Q2J1VKKZTE0CMS6TCPYZAG",
- "amountExpected": "TESTKUDOS:5",
- "amountReserveBalance": "TESTKUDOS:5",
- "timestamp": {
- "t_ms": 1578334141843
- },
- "newHistoryTransactions": [
- {
- "amount": "TESTKUDOS:5",
- "sender_account_url": "payto://x-taler-bank/bank.test.taler.net/9",
- "timestamp": {
- "t_ms": 1578334138000
- },
- "wire_reference": "0000000000184",
- "type": "DEPOSIT"
- }
- ],
- "reserveShortInfo": {
- "exchangeBaseUrl": "https://exchange.test.taler.net/",
- "reserveCreationDetail": {
- "type": "taler-bank-withdraw",
- "bankUrl": "https://bank.test.taler.net/api/withdraw-operation/e6210f62-d27b-4f58-815c-c5160de8804c"
- },
- "reservePub": "ZQ2N7V8M035HAD1HTW7ZX22NM9GAXDCGX6GSJECD2KEY9TN3C0V0"
- }
- },
- {
- "type": "withdrawn",
- "withdrawSessionId": "AAVX0GVZ8GRPYX2RWANQ9J279ABA7KNFYEQ3A0C63TW7NMV0GAT0",
- "eventId": "withdrawn;AAVX0GVZ8GRPYX2RWANQ9J279ABA7KNFYEQ3A0C63TW7NMV0GAT0",
- "amountWithdrawnEffective": "TESTKUDOS:5",
- "amountWithdrawnRaw": "TESTKUDOS:5",
- "exchangeBaseUrl": "https://exchange.test.taler.net/",
- "timestamp": {
- "t_ms": 1578334142432
- },
- "withdrawalSource": {
- "type": "reserve",
- "reservePub": "ZQ2N7V8M035HAD1HTW7ZX22NM9GAXDCGX6GSJECD2KEY9TN3C0V0"
- }
- },
- {
- "type": "refreshed",
- "refreshGroupId": "2TARBASBNCE0X7F0D89Z3TJGPXKRARFSBH3HKZ5JFQRKPV9CA5C0",
- "eventId": "refreshed;2TARBASBNCE0X7F0D89Z3TJGPXKRARFSBH3HKZ5JFQRKPV9CA5C0",
- "timestamp": {
- "t_ms": 1578334142528
- },
- "refreshReason": "pay",
- "amountRefreshedEffective": "KUDOS:0",
- "amountRefreshedRaw": "KUDOS:0.06",
- "numInputCoins": 6,
- "numOutputCoins": 0,
- "numRefreshedInputCoins": 0
- },
- {
- "type": "order-accepted",
- "eventId": "order-accepted;W39MQT31M1ZY3NPF9ZSGXM8Q1K5XS5D5R1J10ZSHBREC6EZ570F0",
- "orderShortInfo": {
- "amount": "TESTKUDOS:1",
- "orderId": "2020.006-00GBW7AD1VFRW",
- "merchantBaseUrl": "https://backend.test.taler.net/public/instances/GNUnet/",
- "proposalId": "W39MQT31M1ZY3NPF9ZSGXM8Q1K5XS5D5R1J10ZSHBREC6EZ570F0",
- "summary": "Donation to GNUnet"
- },
- "timestamp": {
- "t_ms": 1578334230099
- }
- },
- {
- "type": "payment-sent",
- "eventId": "payment-sent;W39MQT31M1ZY3NPF9ZSGXM8Q1K5XS5D5R1J10ZSHBREC6EZ570F0",
- "orderShortInfo": {
- "amount": "TESTKUDOS:1",
- "orderId": "2020.006-00GBW7AD1VFRW",
- "merchantBaseUrl": "https://backend.test.taler.net/public/instances/GNUnet/",
- "proposalId": "W39MQT31M1ZY3NPF9ZSGXM8Q1K5XS5D5R1J10ZSHBREC6EZ570F0",
- "summary": "Donation to GNUnet"
- },
- "replay": false,
- "timestamp": {
- "t_ms": 1578334232527
- },
- "numCoins": 4,
- "amountPaidWithFees": "TESTKUDOS:1"
- },
- {
- "type": "order-accepted",
- "eventId": "order-accepted;Y8230SR6DP52J61CHEAPM5NHRVK408YP5KJP6VRBGZ3QZ0TBZQ90",
- "orderShortInfo": {
- "amount": "TESTKUDOS:0.1",
- "orderId": "2020.006-02RFGFSSAZY9Y",
- "merchantBaseUrl": "https://backend.test.taler.net/public/instances/Taler/",
- "proposalId": "Y8230SR6DP52J61CHEAPM5NHRVK408YP5KJP6VRBGZ3QZ0TBZQ90",
- "summary": "Donation to Taler"
- },
- "timestamp": {
- "t_ms": 1578334258703
- }
- },
- {
- "type": "payment-sent",
- "eventId": "payment-sent;Y8230SR6DP52J61CHEAPM5NHRVK408YP5KJP6VRBGZ3QZ0TBZQ90",
- "orderShortInfo": {
- "amount": "TESTKUDOS:0.1",
- "orderId": "2020.006-02RFGFSSAZY9Y",
- "merchantBaseUrl": "https://backend.test.taler.net/public/instances/Taler/",
- "proposalId": "Y8230SR6DP52J61CHEAPM5NHRVK408YP5KJP6VRBGZ3QZ0TBZQ90",
- "summary": "Donation to Taler"
- },
- "replay": false,
- "timestamp": {
- "t_ms": 1578334260497
- },
- "numCoins": 1,
- "amountPaidWithFees": "TESTKUDOS:0.1"
- },
- {
- "type": "reserve-balance-updated",
- "eventId": "reserve-balance-updated;NBZX24YB4GEHFXFXD5NJAC84ZZD63DFAD6Q7YFJQGX8WX9YQ7B90",
- "amountExpected": "TESTKUDOS:15",
- "amountReserveBalance": "TESTKUDOS:15",
- "timestamp": {
- "t_ms": 1578334530519
- },
- "newHistoryTransactions": [
- {
- "amount": "TESTKUDOS:15",
- "sender_account_url": "payto://x-taler-bank/bank.test.taler.net/9",
- "timestamp": {
- "t_ms": 1578334522000
- },
- "wire_reference": "000000000018C",
- "type": "DEPOSIT"
- }
- ],
- "reserveShortInfo": {
- "exchangeBaseUrl": "https://exchange.test.taler.net/",
- "reserveCreationDetail": {
- "type": "taler-bank-withdraw",
- "bankUrl": "https://bank.test.taler.net/api/withdraw-operation/6b5fae55-3634-4e6b-a877-2f8bd76dfaf9"
- },
- "reservePub": "5XZC4DQMNR3482443727Q2RNKRVEBEW7SFJ8N9TYV5AZ74AZJB4G"
- }
- },
- {
- "type": "withdrawn",
- "withdrawSessionId": "312AKNY5BGF08PY1ZK0Z2QBEZ3481NFP9BN36Z08FHJQQZW80EZG",
- "eventId": "withdrawn;312AKNY5BGF08PY1ZK0Z2QBEZ3481NFP9BN36Z08FHJQQZW80EZG",
- "amountWithdrawnEffective": "TESTKUDOS:15",
- "amountWithdrawnRaw": "TESTKUDOS:15",
- "exchangeBaseUrl": "https://exchange.test.taler.net/",
- "timestamp": {
- "t_ms": 1578334531085
- },
- "withdrawalSource": {
- "type": "reserve",
- "reservePub": "5XZC4DQMNR3482443727Q2RNKRVEBEW7SFJ8N9TYV5AZ74AZJB4G"
- }
- },
- {
- "type": "refreshed",
- "refreshGroupId": "3FN9PFD2JCPS3FDHZDPRS50VQT4G7SE54JDTG2ZW2HV1R3PJ9KBG",
- "eventId": "refreshed;3FN9PFD2JCPS3FDHZDPRS50VQT4G7SE54JDTG2ZW2HV1R3PJ9KBG",
- "timestamp": {
- "t_ms": 1578334532478
- },
- "refreshReason": "pay",
- "amountRefreshedEffective": "TESTKUDOS:2.46",
- "amountRefreshedRaw": "TESTKUDOS:2.46",
- "numInputCoins": 1,
- "numOutputCoins": 6,
- "numRefreshedInputCoins": 1
- },
- {
- "type": "refreshed",
- "refreshGroupId": "DF0DQ6MGJ39R0891B0P86MY2QTMPF9FPDJN30PRBMXBZ8XEVHZE0",
- "eventId": "refreshed;DF0DQ6MGJ39R0891B0P86MY2QTMPF9FPDJN30PRBMXBZ8XEVHZE0",
- "timestamp": {
- "t_ms": 1578334533177
- },
- "refreshReason": "pay",
- "amountRefreshedEffective": "TESTKUDOS:1.12",
- "amountRefreshedRaw": "TESTKUDOS:1.12",
- "numInputCoins": 4,
- "numOutputCoins": 3,
- "numRefreshedInputCoins": 1
- },
- {
- "type": "order-accepted",
- "eventId": "order-accepted;KYSVHAENJFQB308KF6ENPM46VJRFAXFRDGW856P7MM90AW60REZ0",
- "orderShortInfo": {
- "amount": "TESTKUDOS:0.5",
- "orderId": "2020.006-03WSPCDEZK6HG",
- "merchantBaseUrl": "https://backend.test.taler.net/public/instances/FSF/",
- "proposalId": "KYSVHAENJFQB308KF6ENPM46VJRFAXFRDGW856P7MM90AW60REZ0",
- "summary": "Essay: 6. Why Software Should Be Free"
- },
- "timestamp": {
- "t_ms": 1578334554161
- }
- },
- {
- "type": "payment-sent",
- "eventId": "payment-sent;KYSVHAENJFQB308KF6ENPM46VJRFAXFRDGW856P7MM90AW60REZ0",
- "orderShortInfo": {
- "amount": "TESTKUDOS:0.5",
- "orderId": "2020.006-03WSPCDEZK6HG",
- "merchantBaseUrl": "https://backend.test.taler.net/public/instances/FSF/",
- "proposalId": "KYSVHAENJFQB308KF6ENPM46VJRFAXFRDGW856P7MM90AW60REZ0",
- "summary": "Essay: 6. Why Software Should Be Free"
- },
- "replay": false,
- "sessionId": "489c048b-7702-49e8-b66f-2de5e33f0008",
- "timestamp": {
- "t_ms": 1578334556292
- },
- "numCoins": 5,
- "amountPaidWithFees": "TESTKUDOS:0.5"
- },
- {
- "type": "refreshed",
- "refreshGroupId": "NG8Z05Q8CM7KCC98PDNDQR0G920J2SGYM15ACBV0PMBE6XA8Q87G",
- "eventId": "refreshed;NG8Z05Q8CM7KCC98PDNDQR0G920J2SGYM15ACBV0PMBE6XA8Q87G",
- "timestamp": {
- "t_ms": 1578334568850
- },
- "refreshReason": "pay",
- "amountRefreshedEffective": "TESTKUDOS:0.06",
- "amountRefreshedRaw": "TESTKUDOS:0.06",
- "numInputCoins": 5,
- "numOutputCoins": 2,
- "numRefreshedInputCoins": 1
- },
- {
- "type": "refund",
- "eventId": "refund;FRW9DGXKPFS9HZEGFYMABDF6FRXDYNMFHH23FT71AAKN8FHGV2EG",
- "refundGroupId": "FRW9DGXKPFS9HZEGFYMABDF6FRXDYNMFHH23FT71AAKN8FHGV2EG",
- "orderShortInfo": {
- "amount": "TESTKUDOS:0.5",
- "orderId": "2020.006-03WSPCDEZK6HG",
- "merchantBaseUrl": "https://backend.test.taler.net/public/instances/FSF/",
- "proposalId": "KYSVHAENJFQB308KF6ENPM46VJRFAXFRDGW856P7MM90AW60REZ0",
- "summary": "Essay: 6. Why Software Should Be Free"
- },
- "timestamp": {
- "t_ms": 1578334581129
- },
- "amountRefundedEffective": "TESTKUDOS:0.5",
- "amountRefundedRaw": "TESTKUDOS:0.5",
- "amountRefundedInvalid": "TESTKUDOS:0"
- },
- {
- "type": "refreshed",
- "refreshGroupId": "TY8G0FDE83YJY3CWBYKMV891CADG06X8MTBZHE42XNQV2B2C95RG",
- "eventId": "refreshed;TY8G0FDE83YJY3CWBYKMV891CADG06X8MTBZHE42XNQV2B2C95RG",
- "timestamp": {
- "t_ms": 1578334585583
- },
- "refreshReason": "refund",
- "amountRefreshedEffective": "TESTKUDOS:0.5",
- "amountRefreshedRaw": "TESTKUDOS:0.5",
- "numInputCoins": 5,
- "numOutputCoins": 6,
- "numRefreshedInputCoins": 5
- }
- ]
-}
diff --git a/contrib/wallet-testdata b/contrib/wallet-testdata
new file mode 160000
+Subproject 7ca3d9b4751cbd513b3a45dfa9e337d4c5980ea
diff --git a/debian/changelog b/debian/changelog
deleted file mode 100644
index e390e4bcb..000000000
--- a/debian/changelog
+++ /dev/null
@@ -1,51 +0,0 @@
-taler-wallet-cli (0.8.2) unstable; urgency=low
-
- * Official 0.8.2 release.
-
- -- Florian Dold <dold@taler.net> Tue, 24 Aug 2021 15:47:15 +0200
-
-taler-wallet-cli (0.0.1-6) unstable; urgency=low
-
- * Fix deposit tracking.
-
- -- Florian Dold <dold@taler.net> Sat, 07 Aug 2021 17:40:08 +0200
-
-taler-wallet-cli (0.0.1-5) unstable; urgency=low
-
- * Performance improvements.
-
- -- Florian Dold <dold@taler.net> Fri, 06 Aug 2021 17:16:18 +0200
-
-taler-wallet-cli (0.0.1-4) unstable; urgency=low
-
- * Deployment linting improvements.
-
- -- Florian Dold <dold@taler.net> Fri, 06 Aug 2021 11:58:34 +0200
-
-taler-wallet-cli (0.0.1-3) unstable; urgency=low
-
- * Deployment linting improvements.
-
- -- Florian Dold <dold@taler.net> Thu, 05 Aug 2021 00:02:33 +0200
-
-taler-wallet-cli (0.0.1-2) unstable; urgency=low
-
- * Improved denomination generator.
-
- -- Florian Dold <dold@taler.net> Wed, 04 Aug 2021 23:26:17 +0200
-
-taler-wallet-cli (0.0.1-1) unstable; urgency=low
-
- * Added exchange deployment linting tool.
-
- -- Florian Dold <dold@taler.net> Wed, 04 Aug 2021 23:05:47 +0200
-
-taler-wallet-cli (0.0.1) unstable; urgency=low
-
- * Initial Release.
-
- -- Florian Dold <dold@taler.net> Sun, 14 Jul 2021 15:00:00 +0100
-
-Local variables:
-mode: debian-changelog
-End:
diff --git a/debian/rules b/debian/rules
deleted file mode 100755
index 41004f155..000000000
--- a/debian/rules
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/usr/bin/make -f
-include /usr/share/dpkg/default.mk
-
-TALER_WALLET_HOME = /usr/share/taler-wallet-cli
-
-cli_dir=packages/taler-wallet-cli
-
-
-build: build-arch build-indep
-build-arch:
- true
-build-indep:
- true
-override_dh_auto_install:
- dh_install $(cli_dir)/bin/taler-wallet-cli $(TALER_WALLET_HOME)/node_modules/taler-wallet-cli/bin
- dh_install $(cli_dir)/dist/taler-wallet-cli.js $(TALER_WALLET_HOME)/node_modules/taler-wallet-cli/dist
- dh_install $(cli_dir)/dist/taler-wallet-cli.js.map $(TALER_WALLET_HOME)/node_modules/taler-wallet-cli/dist
- dh_link $(TALER_WALLET_HOME)/node_modules/taler-wallet-cli/bin/taler-wallet-cli /usr/bin/taler-wallet-cli
-
-binary:
- dh $@
-binary-arch:
- dh $@
-binary-indep:
- dh $@
-
-clean:
- true
-
-# Override this step because it's very slow and likely
-# unnecessary for us.
-override_dh_strip_nondeterminism:
- true
-
-get-orig-source:
- uscan --force-download --rename
diff --git a/package.json b/package.json
index 95ef6f5e1..f53cf87a5 100644
--- a/package.json
+++ b/package.json
@@ -2,14 +2,19 @@
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
- "compile": "pnpm run --filter '{packages}' compile",
- "clean": "pnpm run --filter '{packages}' clean",
- "pretty": "pnpm run --filter '{packages}' pretty",
- "check": "pnpm run --filter '{packages}' --if-present test"
+ "compile": "pnpm run --filter '@gnu-taler/*' compile",
+ "clean": "pnpm run --filter '@gnu-taler/*' clean",
+ "typedoc": "pnpm run --filter '@gnu-taler/*' typedoc",
+ "pretty": "pnpm run --filter '@gnu-taler/*' pretty",
+ "check": "pnpm run --filter '@gnu-taler/*' --if-present --sequential test"
},
"devDependencies": {
- "@linaria/esbuild": "^3.0.0-beta.7",
- "@linaria/shaker": "^3.0.0-beta.7",
- "esbuild": "^0.12.21"
+ "@babel/core": "7.13.16",
+ "@linaria/esbuild": "^3.0.0-beta.15",
+ "@linaria/shaker": "^3.0.0-beta.15",
+ "esbuild": "^0.19.9",
+ "prettier": "^3.1.1",
+ "typedoc": "^0.25.4",
+ "typescript": "^5.3.3"
}
}
diff --git a/packages/aml-backoffice-ui/.eslintrc.cjs b/packages/aml-backoffice-ui/.eslintrc.cjs
new file mode 100644
index 000000000..05618b499
--- /dev/null
+++ b/packages/aml-backoffice-ui/.eslintrc.cjs
@@ -0,0 +1,28 @@
+module.exports = {
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react/recommended',
+ ],
+ parser: '@typescript-eslint/parser',
+ plugins: ['@typescript-eslint', 'header'],
+ root: true,
+ rules: {
+ "react/no-unknown-property": 0,
+ "react/no-unescaped-entities": 0,
+ "@typescript-eslint/no-namespace": 0,
+ "@typescript-eslint/no-unused-vars": [2,{argsIgnorePattern:"^_"}],
+ "header/header": [2,"copyleft-header.js"]
+ },
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ jsx: true,
+ },
+ settings: {
+ react: {
+ version: "18",
+ pragma: "h",
+ }
+ },
+};
diff --git a/packages/aml-backoffice-ui/.gitignore b/packages/aml-backoffice-ui/.gitignore
new file mode 100644
index 000000000..30cb2774c
--- /dev/null
+++ b/packages/aml-backoffice-ui/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+/build
+/*.log
+/demobank-ui-settings.js
diff --git a/packages/aml-backoffice-ui/Makefile b/packages/aml-backoffice-ui/Makefile
new file mode 100644
index 000000000..64f9f83d1
--- /dev/null
+++ b/packages/aml-backoffice-ui/Makefile
@@ -0,0 +1,36 @@
+# This Makefile has been placed in the public domain
+
+ifeq ($(TOPLEVEL), yes)
+ $(info top-level build)
+ -include ../../.config.mk
+ override DESTDIR := $(TOP_DESTDIR)
+else
+ $(info package-level build)
+ -include ../../.config.mk
+ -include .config.mk
+endif
+
+$(info prefix is $(prefix))
+
+.PHONY: all
+all:
+ @echo run \'make install\' to install
+
+spa_dir=$(DESTDIR)$(prefix)/share/taler/aml-backoffice-ui
+
+.PHONY: install-nodeps
+install-nodeps:
+ install -d $(spa_dir)
+ install ./dist/prod/* $(spa_dir)
+
+.PHONY: deps
+deps:
+ pnpm install --frozen-lockfile --filter @gnu-taler/aml-backoffice-ui...
+ pnpm run check
+ pnpm run build
+
+.PHONY: install
+install:
+ $(MAKE) deps
+ $(MAKE) install-nodeps
+
diff --git a/packages/aml-backoffice-ui/README.md b/packages/aml-backoffice-ui/README.md
new file mode 100644
index 000000000..855addd74
--- /dev/null
+++ b/packages/aml-backoffice-ui/README.md
@@ -0,0 +1,4 @@
+# Taler Exchange Backoffice UI
+
+Web-based user interface for the GNU Taler exchange.
+
diff --git a/packages/aml-backoffice-ui/build.mjs b/packages/aml-backoffice-ui/build.mjs
new file mode 100755
index 000000000..bd7a088cf
--- /dev/null
+++ b/packages/aml-backoffice-ui/build.mjs
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build } from "@gnu-taler/web-util/build";
+
+await build({
+ type: "production",
+ source: {
+ js: ["src/index.tsx", "src/forms.ts"],
+ assets: [{ base: "src", files: ["src/index.html"] }],
+ },
+ destination: "./dist/prod",
+ css: "postcss",
+});
diff --git a/packages/aml-backoffice-ui/copyleft-header.js b/packages/aml-backoffice-ui/copyleft-header.js
new file mode 100644
index 000000000..2635717c5
--- /dev/null
+++ b/packages/aml-backoffice-ui/copyleft-header.js
@@ -0,0 +1,15 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
diff --git a/packages/aml-backoffice-ui/dev.mjs b/packages/aml-backoffice-ui/dev.mjs
new file mode 100755
index 000000000..bc6fcd6c1
--- /dev/null
+++ b/packages/aml-backoffice-ui/dev.mjs
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { serve } from "@gnu-taler/web-util/node";
+import { initializeDev } from "@gnu-taler/web-util/build";
+
+const devEntryPoints = ["src/stories.tsx", "src/index.tsx", "src/forms.ts"];
+
+const build = initializeDev({
+ type: "development",
+ source: {
+ js: devEntryPoints,
+ assets: [{ base: "src", files: ["src/index.html"] }],
+ },
+ destination: "./dist/dev",
+ css: "postcss",
+});
+
+await build();
+
+serve({
+ folder: "./dist/dev",
+ port: 8080,
+ source: "./src",
+ onSourceUpdate: build,
+});
diff --git a/packages/aml-backoffice-ui/package.json b/packages/aml-backoffice-ui/package.json
new file mode 100644
index 000000000..68bc358b5
--- /dev/null
+++ b/packages/aml-backoffice-ui/package.json
@@ -0,0 +1,59 @@
+{
+ "private": true,
+ "name": "@gnu-taler/aml-backoffice-ui",
+ "version": "0.10.6",
+ "author": "sebasjm",
+ "license": "AGPL-3.0-OR-LATER",
+ "description": "Back-office SPA for GNU Taler Exchange.",
+ "type": "module",
+ "scripts": {
+ "build": "./build.mjs",
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "check": "tsc",
+ "clean": "rm -rf dist lib",
+ "compile": "tsc && ./build.mjs",
+ "test": "./test.mjs && mocha --require source-map-support/register 'dist/test/**/*.test.js' 'dist/test/**/test.js'",
+ "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
+ "i18n:extract": "pogen extract",
+ "i18n:merge": "pogen merge",
+ "i18n:emit": "pogen emit",
+ "i18n": "pnpm i18n:extract && pnpm i18n:merge && pnpm i18n:emit",
+ "pretty": "prettier --write src"
+ },
+ "dependencies": {
+ "@gnu-taler/taler-util": "workspace:*",
+ "@gnu-taler/web-util": "workspace:*",
+ "@headlessui/react": "^1.7.14",
+ "@heroicons/react": "^2.0.17",
+ "date-fns": "2.29.3",
+ "history": "4.10.1",
+ "jed": "1.1.1",
+ "preact": "10.11.3",
+ "swr": "2.2.2"
+ },
+ "devDependencies": {
+ "@gnu-taler/pogen": "^0.0.5",
+ "@tailwindcss/forms": "^0.5.3",
+ "@tailwindcss/typography": "^0.5.9",
+ "@types/chai": "^4.3.0",
+ "@types/history": "^4.7.8",
+ "@types/mocha": "^10.0.1",
+ "@typescript-eslint/eslint-plugin": "^6.19.0",
+ "@typescript-eslint/parser": "^6.19.0",
+ "autoprefixer": "^10.4.14",
+ "chai": "^4.3.6",
+ "esbuild": "^0.19.9",
+ "eslint": "^8.56.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-react": "^7.33.2",
+ "mocha": "^9.2.0",
+ "po2json": "^0.4.5",
+ "postcss": "^8.4.23",
+ "postcss-cli": "^10.1.0",
+ "tailwindcss": "^3.3.2",
+ "typescript": "5.3.3"
+ },
+ "pogen": {
+ "domain": "aml-backoffice"
+ }
+}
diff --git a/packages/aml-backoffice-ui/postcss.config.js b/packages/aml-backoffice-ui/postcss.config.js
new file mode 100644
index 000000000..2e7af2b7f
--- /dev/null
+++ b/packages/aml-backoffice-ui/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/packages/aml-backoffice-ui/src/App.tsx b/packages/aml-backoffice-ui/src/App.tsx
new file mode 100644
index 000000000..5244476d7
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/App.tsx
@@ -0,0 +1,74 @@
+import { TranslationProvider } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { ExchangeAmlFrame } from "./Dashboard.js";
+import "./scss/main.css";
+import { ExchangeApiProvider } from "./context/config.js";
+import { getInitialBackendBaseURL } from "./hooks/useBackend.js";
+import { HashPathProvider, Router } from "./route.js";
+import { Pages } from "./pages.js";
+import { SWRConfig } from "swr";
+
+const WITH_LOCAL_STORAGE_CACHE = false;
+
+const pageList = Object.values(Pages);
+
+export function App(): VNode {
+ const baseUrl = getInitialBackendBaseURL();
+ return (
+ <TranslationProvider source={{}}>
+ <ExchangeApiProvider baseUrl={baseUrl} frameOnError={ExchangeAmlFrame}>
+ <HashPathProvider>
+ <SWRConfig
+ value={{
+ provider: WITH_LOCAL_STORAGE_CACHE
+ ? localStorageProvider
+ : undefined,
+ // normally, do not revalidate
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ revalidateIfStale: false,
+ revalidateOnMount: undefined,
+ focusThrottleInterval: undefined,
+
+ // normally, do not refresh
+ refreshInterval: undefined,
+ dedupingInterval: 2000,
+ refreshWhenHidden: false,
+ refreshWhenOffline: false,
+
+ // ignore errors
+ shouldRetryOnError: false,
+ errorRetryCount: 0,
+ errorRetryInterval: undefined,
+
+ // do not go to loading again if already has data
+ keepPreviousData: true,
+ }}
+ >
+
+ <ExchangeAmlFrame>
+ <Router
+ pageList={pageList}
+ onNotFound={() => {
+ window.location.href = Pages.cases.url
+ return <div>not found</div>;
+ }}
+ />
+ </ExchangeAmlFrame>
+ </SWRConfig>
+ </HashPathProvider>
+ </ExchangeApiProvider>
+ </TranslationProvider>
+ );
+}
+
+
+function localStorageProvider(): Map<unknown, unknown> {
+ const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]"));
+
+ window.addEventListener("beforeunload", () => {
+ const appCache = JSON.stringify(Array.from(map.entries()));
+ localStorage.setItem("app-cache", appCache);
+ });
+ return map;
+}
diff --git a/packages/aml-backoffice-ui/src/Dashboard.tsx b/packages/aml-backoffice-ui/src/Dashboard.tsx
new file mode 100644
index 000000000..3951b48c7
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/Dashboard.tsx
@@ -0,0 +1,234 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { Footer, ToastBanner, Header, notifyError, notifyException, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { useEffect, useErrorBoundary } from "preact/hooks";
+import { useOfficer } from "./hooks/useOfficer.js";
+import { getAllBooleanSettings, getLabelForSetting, useSettings } from "./hooks/useSettings.js";
+import { Pages } from "./pages.js";
+import { PageEntry, useChangeLocation } from "./route.js";
+import { uiSettings } from "./settings.js";
+
+function classNames(...classes: string[]) {
+ return classes.filter(Boolean).join(" ");
+}
+
+/**
+ * mapping route to view
+ * not found (error page)
+ * nested, index element, relative routes
+ * link interception
+ * form POST interception, call action
+ * fromData => Object.fromEntries
+ * segments in the URL
+ * navigationState: idle, submitting, loading
+ * form GET interception: does a navigateTo
+ * form GET Sync:
+ * 1.- back after submit: useEffect to sync URL to form
+ * 2.- refresh after submit: input default value
+ * useSubmit for form submission onChange, history replace
+ *
+ * post form without redirect
+ *
+ *
+ * @param param0
+ * @returns
+ */
+
+const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
+const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
+
+const versionText = VERSION
+ ? GIT_HASH
+ ? `v${VERSION} (${GIT_HASH.substring(0, 8)})`
+ : VERSION
+ : "";
+
+/**
+ * TO BE FIXED:
+ *
+ * 1.- when the form change to other form and both form share the same structure
+ * the same input component may be rendered in the same place,
+ * since input are uncontrolled the are not re-rendered and since they are
+ * uncontrolled it will keep the value of the previous form.
+ * One solutions could be to remove the form when unloading and when the new
+ * form load it will start without previous vdom, preventing the cache
+ * to create this behavior.
+ * Other solutions could be using IDs in the fields that are constructed
+ * with the ID of the form, so two fields of different form will need to re-render
+ * cleaning up the state of the previous form.
+ *
+ * 2.- currently the design prop and the behavior prop of the flexible form
+ * are two side of the same coin. From the design point of view, it is important
+ * to design the form in a list-of-field manner and there may be additional
+ * content that is not directly mapped to the form structure (object)
+ * So maybe we want to change the current shape so the computation of the state
+ * of the form is in a field level, but this computation required the field value and
+ * the whole form values and state (since one field may be disabled/hidden) because
+ * of the value of other field.
+ *
+ * 3.- given the previous requirement, maybe the name of the field of the form could be
+ * a function (P: F -> V) where F is the form (or parent object) and V is the type of the
+ * property. That will help with the typing of the forms props
+ *
+ * 4.- tooltip are not placed correctly: the arrow should point the question mark
+ * and the text area should be bigger
+ *
+ */
+
+/**
+ * check this fields
+ *
+ * Signature of Contracting partner, 902_9e
+ * Currency and amount of deposited assets, 902_5e
+ * Signature on declaration of trust, 902.13e
+ * also fundations
+ * also life insurance
+ *
+ * no all state are handled by all the inputs
+ * all the input implementation should respect
+ * ui props and state
+ */
+
+export function ExchangeAmlFrame({
+ children,
+}: {
+ children?: ComponentChildren;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [error, resetError] = useErrorBoundary();
+
+ useEffect(() => {
+ if (error) {
+ if (error instanceof Error) {
+ notifyException(i18n.str`Internal error, please report.`, error)
+ } else {
+ notifyError(i18n.str`Internal error, please report.`, String(error) as TranslatedString)
+ }
+ console.log(error)
+ // resetError()
+ }
+ }, [error])
+
+ const officer = useOfficer();
+ const [settings, updateSettings] = useSettings();
+
+ return (<div class="min-h-full flex flex-col m-0 bg-slate-200" style="min-height: 100vh;">
+ <div class="bg-indigo-600 pb-32">
+ <Header
+ title="Exchange"
+ iconLinkURL={uiSettings.backendBaseURL ?? "#"}
+ onLogout={officer.state !== "ready" ? undefined : () => {
+ officer.lock()
+ }}
+ sites={[]}
+ supportedLangs={["en", "es", "de"]}
+ >
+ <li>
+ <div class="text-xs font-semibold leading-6 text-gray-400">
+ <i18n.Translate>Preferences</i18n.Translate>
+ </div>
+ <ul role="list" class="space-y-1">
+ {getAllBooleanSettings().map(set => {
+ const isOn: boolean = !!settings[set]
+ return <li class="mt-2 pl-2">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span class="text-sm text-black font-medium leading-6 " id="availability-label">
+ {getLabelForSetting(set, i18n)}
+ </span>
+ </span>
+ <button type="button" data-enabled={isOn} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
+
+ onClick={() => { updateSettings(set, !isOn); }}>
+ <span aria-hidden="true" data-enabled={isOn} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
+ </button>
+ </div>
+ </li>
+ })}
+ </ul>
+ </li>
+ </Header>
+ </div>
+
+ <div class="fixed z-20 w-full">
+ <div class="mx-auto w-4/5">
+ <ToastBanner />
+ </div>
+ </div>
+
+ <div class="-mt-32 flex grow ">
+ {officer.state !== "ready" ? undefined :
+ <Navigation />
+ }
+ <div class="flex mx-auto my-4">
+ <main class="rounded-lg bg-white px-5 py-6 shadow">
+ {children}
+ </main>
+ </div>
+
+ </div>
+
+ <Footer
+ testingUrlKey="exchange-base-url"
+ GIT_HASH={GIT_HASH}
+ VERSION={VERSION}
+ />
+ </div>
+ );
+}
+
+function Navigation(): VNode {
+ const { i18n } = useTranslationContext()
+ const pageList: Array<PageEntry> = [
+ Pages.officer,
+ Pages.cases
+ ]
+ const location = useChangeLocation();
+ return (
+ <div class="hidden sm:block min-w-min bg-indigo-600 divide-y rounded-r-lg divide-cyan-800 overflow-y-auto overflow-x-clip">
+
+ <nav class="flex flex-1 flex-col mx-4 mt-4 mb-2">
+ <ul role="list" class="flex flex-1 flex-col gap-y-7">
+ <li>
+ <ul role="list" class="-mx-2 space-y-1">
+ {pageList.map(p => {
+
+ return <li>
+ <a href={p.url} data-selected={location == p.url}
+ class="data-[selected=true]:bg-indigo-700 pr-4 data-[selected=true]:text-white text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
+ {p.Icon && <p.Icon />}
+ <span class="hidden md:inline">
+ {p.name}
+ </span>
+ </a>
+ </li>
+
+ })}
+ {/* <li>
+ <a href="#" class="text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
+
+ <i18n.Translate>Officer</i18n.Translate>
+ </a>
+ </li> */}
+ </ul>
+ </li>
+
+ {/* <li class="mt-auto ">
+ <a href="#" class="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-indigo-200 hover:bg-indigo-700 hover:text-white">
+ <svg class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+ </svg>
+ Settings
+ </a>
+ </li> */}
+
+ </ul>
+ </nav>
+ </div>
+ )
+
+}
+
+
diff --git a/packages/aml-backoffice-ui/src/assets/home.svg b/packages/aml-backoffice-ui/src/assets/home.svg
new file mode 100644
index 000000000..35f340162
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/assets/home.svg
@@ -0,0 +1,3 @@
+<svg class="h-6 w-6 shrink-0 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
+</svg> \ No newline at end of file
diff --git a/packages/aml-backoffice-ui/src/assets/logo-2021.svg b/packages/aml-backoffice-ui/src/assets/logo-2021.svg
new file mode 100644
index 000000000..8c5ff3e5b
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/assets/logo-2021.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">
+ <g fill="#0042b3" fill-rule="evenodd" stroke-width=".3">
+ <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />
+ <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />
+ <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />
+ </g>
+ <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />
+</svg> \ No newline at end of file
diff --git a/packages/aml-backoffice-ui/src/assets/people.svg b/packages/aml-backoffice-ui/src/assets/people.svg
new file mode 100644
index 000000000..1dc878b81
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/assets/people.svg
@@ -0,0 +1,3 @@
+<svg class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
+</svg> \ No newline at end of file
diff --git a/packages/aml-backoffice-ui/src/context/config.ts b/packages/aml-backoffice-ui/src/context/config.ts
new file mode 100644
index 000000000..42f73428a
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/context/config.ts
@@ -0,0 +1,100 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TalerExchangeApi, TalerExchangeHttpClient, TalerError } from "@gnu-taler/taler-util";
+import { BrowserFetchHttpLib, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, createContext, FunctionComponent, h, VNode } from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { ErrorLoading } from "@gnu-taler/web-util/browser";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type Type = {
+ url: URL,
+ config: TalerExchangeApi.ExchangeVersionResponse,
+ api: TalerExchangeHttpClient,
+};
+
+const Context = createContext<Type>(undefined as any);
+
+export const useExchangeApiContext = (): Type => useContext(Context);
+export const useMaybeExchangeApiContext = (): Type | undefined => useContext(Context);
+
+export function ExchangeApiContextTesting({ config, children }: { config: TalerExchangeApi.ExchangeVersionResponse, children?: ComponentChildren; }): VNode {
+ return h(Context.Provider, {
+ value: { url: new URL("http://testing"), config, api: null as any },
+ children
+ }
+ )
+}
+
+export type ConfigResult = undefined
+ | { type: "ok", config: TalerExchangeApi.ExchangeVersionResponse }
+ | { type: "incompatible", result: TalerExchangeApi.ExchangeVersionResponse, supported: string }
+ | { type: "error", error: TalerError }
+
+export const ExchangeApiProvider = ({
+ baseUrl,
+ children,
+ frameOnError,
+}: {
+ baseUrl: string,
+ children: ComponentChildren;
+ frameOnError: FunctionComponent<{ children: ComponentChildren }>,
+}): VNode => {
+ const [checked, setChecked] = useState<ConfigResult>()
+ const { i18n } = useTranslationContext();
+ const url = new URL(baseUrl)
+ const api = new TalerExchangeHttpClient(url.href, new BrowserFetchHttpLib())
+ useEffect(() => {
+ api.getConfig()
+ .then((resp) => {
+ if (resp.type === "fail") {
+ setChecked({ type: "error", error: TalerError.fromUncheckedDetail(resp.detail) });
+ } else if (api.isCompatible(resp.body.version)) {
+ setChecked({ type: "ok", config: resp.body });
+ } else {
+ setChecked({ type: "incompatible", result: resp.body, supported: api.PROTOCOL_VERSION })
+ }
+ })
+ .catch((error: unknown) => {
+ if (error instanceof TalerError) {
+ setChecked({ type: "error", error });
+ }
+ });
+ }, []);
+
+ if (checked === undefined) {
+ return h(frameOnError, { children: h("div", {}, "loading...") })
+ }
+ if (checked.type === "error") {
+ return h(frameOnError, { children: h(ErrorLoading, { error: checked.error, showDetail: true }) })
+ }
+ if (checked.type === "incompatible") {
+ return h(frameOnError, { children: h("div", {}, i18n.str`the bank backend is not supported. supported version "${checked.supported}", server version "${checked.result.version}"`) })
+ }
+ const value: Type = {
+ url, config: checked.config, api
+ }
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
+
diff --git a/packages/aml-backoffice-ui/src/declaration.d.ts b/packages/aml-backoffice-ui/src/declaration.d.ts
new file mode 100644
index 000000000..6af72042c
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/declaration.d.ts
@@ -0,0 +1,31 @@
+
+declare const __VERSION__: string;
+declare const __GIT_HASH__: string;
+
+declare module "*.po" {
+ const content: any;
+ export default content;
+}
+declare module "jed" {
+ const x: any;
+ export = x;
+}
+declare module "*.jpeg" {
+ const content: any;
+ export default content;
+}
+declare module "*.png" {
+ const content: any;
+ export default content;
+}
+declare module "*.svg" {
+ const content: any;
+ export default content;
+}
+
+declare module "*.scss" {
+ const content: Record<string, string>;
+ export default content;
+}
+declare const __VERSION__: string;
+declare const __GIT_HASH__: string;
diff --git a/packages/aml-backoffice-ui/src/forms.ts b/packages/aml-backoffice-ui/src/forms.ts
new file mode 100644
index 000000000..cc9e4c7e8
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms.ts
@@ -0,0 +1,24 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export * from "./forms/index.js";
+
+/**
+ * this file is here to have a flat dist folder
+ *
+ * this file is being build in a bundle separated
+ * from the main one.
+ */
diff --git a/packages/aml-backoffice-ui/src/forms/902_11e.ts b/packages/aml-backoffice-ui/src/forms/902_11e.ts
new file mode 100644
index 000000000..71ca8bcf4
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/902_11e.ts
@@ -0,0 +1,133 @@
+import type { TranslatedString } from "@gnu-taler/taler-util";
+import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "./declaration.js";
+import { resolutionSection } from "./simplest.js";
+
+export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_11.Form> => ({
+ design: [
+ {
+ title:
+ i18n.str`Establishing of the controlling person of operating legal entities and partnerships both not quoted on the stock exchange`,
+ description:
+ i18n.str`for operating legal entities and partnership that are contracting partner as well as analogously for operating legal entities and partnership that are beneficial owners.`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ name: "contractingPartner",
+ label: i18n.str`Contracting partner`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "declares",
+ label:
+ i18n.str`The contracting partner hereby declares that`,
+ required: true,
+ choices: [
+ {
+ label:
+ i18n.str`the person(s) listed below is/are holding 25% or more of the contracting partner's shares (capital shares or voting rights)`,
+ value: "25-or-more",
+ },
+ {
+ label:
+ i18n.str`if the capital shares or voting rights cannot be determined or in case there are no capital shares or voting rights 25% or more, the contracting partner hereby declares that the person(s) listed below is/are controlling the contracting partner in other ways`,
+ value: "controlling-in-other-ways",
+ },
+ {
+ label:
+ i18n.str`in case this/these person(s) cannot be determined or this/these person(s) does/do not exist, the contracting partner hereby declares that the person(s) listed below is/are the managing director(s)`,
+ value: "managing-director",
+ },
+ ],
+ },
+ },
+ {
+ type: "array",
+ props: {
+ name: "people",
+ label: i18n.str`People`,
+ required: true,
+ placeholder: i18n.str`this is the placeholder`,
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "lastName",
+ label: i18n.str`Last name(s)`,
+ required: true,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "firstName",
+ label: i18n.str`First name(s)`,
+ required: true,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label: i18n.str`Actual address of domicile`,
+ required: true,
+ },
+ },
+ ],
+ labelField: "lastName",
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "fiduciaryAssets",
+ label: i18n.str`Fiduciary holding assets`,
+ help: i18n.str`Is a third person the beneficial owner of the assets held in the account/securities account?`,
+ required: true,
+ choices: [
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ {
+ label: i18n.str`Yes`,
+ value: "yes",
+ description:
+ i18n.str`The relevant information regarding the beneficial owner has to be obtained by filling in a separate VQF doc. No. 902.9`,
+ },
+ ],
+ },
+ },
+ ],
+ },
+ resolutionSection(current, i18n),
+ ],
+ behavior: function formBehavior(
+ v: Partial<Form902_11.Form>,
+ ): FormState<Form902_11.Form> {
+ return {
+ people: {
+ hidden: v.declares !== "controlling-in-other-ways" &&
+ v.declares !== "managing-director",
+ }
+ };
+ }
+});
+
+namespace Form902_11 {
+ interface Person {
+ lastName: string;
+ firstName: string;
+ address: string;
+ }
+ export interface Form extends BaseForm {
+ contractingPartner: string;
+ declares: "25-or-more" | "controlling-in-other-ways" | "managing-director";
+ people: Person[];
+ fiduciaryAssets: "no" | "yes";
+ signature: string;
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/forms/902_12e.ts b/packages/aml-backoffice-ui/src/forms/902_12e.ts
new file mode 100644
index 000000000..0c08d274c
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/902_12e.ts
@@ -0,0 +1,421 @@
+import type { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
+import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { resolutionSection } from "./simplest.js";
+import { BaseForm } from "./declaration.js";
+
+export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_12.Form> => ({
+ design: [
+ {
+ title: i18n.str`Foundations`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ name: "contractingPartner",
+ label: i18n.str`Contracting partner`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "knownAs",
+ label:
+ i18n.str`The undersigned hereby declare(s) that as board member of the foundation, or of the highest supervisory body of an underlying company of a foundation, known as`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "foundation.name",
+ label:
+ i18n.str`Name and information pertaining to the foundation`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "foundation.type",
+ label: i18n.str`Type of foundation`,
+ choices: [
+ {
+ label: i18n.str`Discretionary foundation`,
+ value: "discretionary",
+ },
+ {
+ label: i18n.str`Non-discretionary foundation`,
+ value: "non-discretionary",
+ },
+ ],
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "foundation.revocability",
+ label: i18n.str`Revocability`,
+ choices: [
+ {
+ label: i18n.str`Revocable foundation`,
+ value: "revocable",
+ },
+ {
+ label: i18n.str`Irrevocable foundation`,
+ value: "irrevocable",
+ },
+ ],
+ },
+ },
+ {
+ type: "array",
+ props: {
+ label:
+ i18n.str`Information pertaining to the (ultimate economic, not fiduciary) founder (individual(s) or entity/ies)`,
+ labelField: "fullName",
+ name: "founders",
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label:
+ i18n.str`Actual address of domicile/registered office`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "country",
+ label: i18n.str`Country`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "dateOfDeath",
+ label: i18n.str`Date of death`,
+ help: i18n.str`if deceased`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "rightToRevoke",
+ required: true,
+ label:
+ i18n.str`Does the founder have the right to revoke the foundation?`,
+ choices: [
+ {
+ label: i18n.str`Yes`,
+ value: "yes",
+ },
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "array",
+ props: {
+ label:
+ i18n.str`If the foundation results from the restructuring of pre-existing foundation (re-settlement) or the merger of pre-existing foundations, the following information pertaining to the (actual) founder(s) of the pre-existing foundation(s) has to be given`,
+ labelField: "fullName",
+ name: "preExistingFounders",
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label:
+ i18n.str`Actual address of domicile/registered office`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "country",
+ label: i18n.str`Country`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "dateOfDeath",
+ label: i18n.str`Date of death`,
+ help: i18n.str`if deceased`,
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "array",
+ props: {
+ label:
+ i18n.str`Pertaining to the beneficiary/-ies at the time of the signing of this form`,
+ labelField: "fullName",
+ name: "beneficiaryWhenSigning",
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label:
+ i18n.str`Actual address of domicile/registered office`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "country",
+ label: i18n.str`Country`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "rightToClaim",
+ label:
+ i18n.str`Has the beneficiary an actual right to claim distribution?`,
+ choices: [
+ {
+ label: i18n.str`Yes`,
+ value: "yes",
+ },
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ ],
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`in addition to certain beneficiaries or if there is/are no defined beneficiary/ies pertaining to (a) group(s) of beneficiaries (e.g. descendants of the founder) known at the time of the signing of this form`,
+ name: "beneficiaryExtra",
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "array",
+ props: {
+ label:
+ i18n.str`Information pertaining to further persons having the right to determine or nominate representatives (e.g.) members of the foundation board), if these representatives may dispose over the assets or have the right to change the distribution of the assets or the nomination of beneficiaries`,
+ labelField: "fullName",
+ name: "withRightToNominate",
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label:
+ i18n.str`Actual address of domicile/registered office`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "country",
+ label: i18n.str`Country`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "rightToClaim",
+ label:
+ i18n.str`has the person the right to revoke the foundation?`,
+ choices: [
+ {
+ label: i18n.str`Yes`,
+ value: "yes",
+ },
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ ],
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`in addition to certain beneficiaries or if there is/are no defined beneficiary/ies pertaining to (a) group(s) of beneficiaries (e.g. descendants of the founder) known at the time of the signing of this form`,
+ name: "beneficiaryExtra",
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "signature",
+ label: i18n.str`Signature`,
+ },
+ },
+ ],
+ },
+ resolutionSection(current, i18n),
+ ],
+ behavior: function formBehavior(
+ v: Partial<Form902_12.Form>,
+ ): FormState<Form902_12.Form> {
+ return {
+ founders: {
+ elements: (v.founders ?? []).map((f) => {
+ return {
+ rightToRevoke: {
+ hidden: v.foundation?.revocability !== "revocable",
+ },
+ };
+ }),
+ },
+ withRightToNominate: {
+ elements: (v.withRightToNominate ?? []).map((f) => {
+ return {
+ rightToRevoke: {
+ hidden: v.foundation?.revocability !== "revocable",
+ },
+ };
+ }),
+ },
+ };
+ },
+});
+
+namespace Form902_12 {
+ interface Foundation {
+ name: string;
+ type: "discretionary" | "non-discretionary";
+ revocability: "revocable" | "irrevocable";
+ }
+ interface Person {
+ fullName: string;
+ address: string;
+ country: string;
+ dateOfBirth: AbsoluteTime;
+ nationality: string;
+ }
+ type WithRevoke<T> = {
+ rightToRevoke: "yes" | "no";
+ } & T;
+ type WithClaim<T> = {
+ rightToClaim: "yes" | "no";
+ } & T;
+ type WithDeath<T> = {
+ dateOfDeath: AbsoluteTime;
+ } & T;
+
+ type Founder = WithRevoke<WithDeath<Person>>;
+ type Beneficiary = WithClaim<Person>;
+
+ export interface Form extends BaseForm {
+ contractingPartner: string;
+ knownAs: string;
+ boardMember: string;
+ foundation: Foundation;
+ founders: Array<Founder>;
+ preExistingFounders: Array<Founder>;
+ beneficiaryWhenSigning: Array<Beneficiary>;
+ beneficiaryExtra: Array<Beneficiary>;
+ withRightToNominate: Array<WithRevoke<Person>>;
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/forms/902_13e.ts b/packages/aml-backoffice-ui/src/forms/902_13e.ts
new file mode 100644
index 000000000..f69884e0e
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/902_13e.ts
@@ -0,0 +1,510 @@
+import type { AbsoluteTime } from "@gnu-taler/taler-util";
+import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "./declaration.js";
+import { resolutionSection } from "./simplest.js";
+
+export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_13.Form> => ({
+ design: [
+ {
+ title: i18n.str`Declaration for trusts`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ name: "contractingPartner",
+ label: i18n.str`Contracting partner`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "knownAs",
+ label:
+ i18n.str`The undersigned hereby declare(s) that as trustee or a member of highest supervisory body of an underlying company of a trust known as`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "trust.name",
+ label:
+ i18n.str`Name and information pertaining to the trust`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "trust.type",
+ label: i18n.str`Type of trust`,
+ choices: [
+ {
+ label: i18n.str`Discretionary trust`,
+ value: "discretionary",
+ },
+ {
+ label: i18n.str`Non-discretionary trust`,
+ value: "non-discretionary",
+ },
+ ],
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "trust.revocability",
+ label: i18n.str`Revocability`,
+ choices: [
+ {
+ label: i18n.str`Revocable foundation`,
+ value: "revocable",
+ },
+ {
+ label: i18n.str`Irrevocable foundation`,
+ value: "irrevocable",
+ },
+ ],
+ },
+ },
+ {
+ type: "array",
+ props: {
+ label:
+ i18n.str`Information pertaining to the (ultimate economic, not fiduciary) settlor of the trust (individual(s) or entity/ies)`,
+ labelField: "fullName",
+ name: "settlors",
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label:
+ i18n.str`Actual address of domicile/registered office`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "country",
+ label: i18n.str`Country`,
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ pattern: "dd/MM/yyyy",
+ // help: i18n.str`format 'dd/MM/yyyy'`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ name: "dateOfDeath",
+ label: i18n.str`Date of death`,
+ pattern: "dd/MM/yyyy",
+ // help: i18n.str`if deceased. format 'dd/MM/yyyy'`,
+ help: i18n.str`if deceased'`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "rightToRevoke",
+ required: true,
+ label:
+ i18n.str`Does the founder have the right to revoke the trust?`,
+ choices: [
+ {
+ label: i18n.str`Yes`,
+ value: "yes",
+ },
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "array",
+ props: {
+ label:
+ i18n.str`If the trust results from the restructuring of pre-existing trust (re-settlement) or the merger of pre-existing trusts, the following information pertaining to the (actual) settlor of the pre-existing trust(s) has to be given`,
+ labelField: "fullName",
+ name: "preExistingSettlors",
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label:
+ i18n.str`Actual address of domicile/registered office`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "country",
+ label: i18n.str`Country`,
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ pattern: "dd/MM/yyyy",
+ // help: i18n.str`format 'dd/MM/yyyy'`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ name: "dateOfDeath",
+ label: i18n.str`Date of death`,
+ pattern: "dd/MM/yyyy",
+ help: i18n.str`if deceased.`,
+ // help: i18n.str`if deceased. format 'dd/MM/yyyy'`,
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "array",
+ props: {
+ label:
+ i18n.str`Pertaining to the beneficiary/-ies at the time of the signing of this form`,
+ labelField: "fullName",
+ name: "beneficiaryWhenSigning",
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label:
+ i18n.str`Actual address of domicile/registered office`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "country",
+ label: i18n.str`Country`,
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ pattern: "dd/MM/yyyy",
+ // help: i18n.str`format 'dd/MM/yyyy'`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "rightToClaim",
+ label:
+ i18n.str`Has the beneficiary an actual right to claim distribution?`,
+ choices: [
+ {
+ label: i18n.str`Yes`,
+ value: "yes",
+ },
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ ],
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`in addition to certain beneficiaries or if there is/are no defined beneficiary/ies pertaining to (a) group(s) of beneficiaries (e.g. descendants of the settlor) known at the time of the signing of this form`,
+ name: "beneficiaryExtra",
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "array",
+ props: {
+ label:
+ i18n.str`Information pertaining to the protector(s) as well as (a) further person(s) having the right to revoke the trust (in case of revocable trusts) or to appoint the trustee of a trust`,
+ labelField: "asd",
+ name: "nothing",
+ fields: [],
+ },
+ },
+
+ {
+ type: "array",
+ props: {
+ label:
+ i18n.str`Information pertaining to the protectors`,
+ labelField: "fullName",
+ name: "protectors",
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label:
+ i18n.str`Actual address of domicile/registered office`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "country",
+ label: i18n.str`Country`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "rightToClaim",
+ label:
+ i18n.str`Does the protector have the right to revoke the trust?`,
+ choices: [
+ {
+ label: i18n.str`Yes`,
+ value: "yes",
+ },
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "array",
+ props: {
+ label:
+ i18n.str`Information pertaining to further persons`,
+ labelField: "fullName",
+ name: "furtherPersons",
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label:
+ i18n.str`Actual address of domicile/registered office`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "country",
+ label: i18n.str`Country`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "rightToClaim",
+ label:
+ i18n.str`Has this further person the right to revoke the trust?`,
+ choices: [
+ {
+ label: i18n.str`Yes`,
+ value: "yes",
+ },
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "signature",
+ label: i18n.str`Signature`,
+ },
+ },
+ ],
+ },
+ resolutionSection(current, i18n),
+ ],
+ behavior: function formBehavior(
+ v: Partial<Form902_13.Form>,
+ ): FormState<Form902_13.Form> {
+ return {
+ settlors: {
+ elements: (v.settlors ?? []).map((f) => {
+ return {
+ rightToRevoke: {
+ hidden: v.foundation?.revocability !== "revocable",
+ },
+ };
+ }),
+ },
+ protectors: {
+ elements: (v.protectors ?? []).map((f) => {
+ return {
+ rightToRevoke: {
+ hidden: v.foundation?.revocability !== "revocable",
+ },
+ };
+ }),
+ },
+ furtherPersons: {
+ elements: (v.furtherPersons ?? []).map((f) => {
+ return {
+ rightToRevoke: {
+ hidden: v.foundation?.revocability !== "revocable",
+ },
+ };
+ }),
+ },
+ };
+ },
+});
+
+namespace Form902_13 {
+ interface Foundation {
+ name: string;
+ type: "discretionary" | "non-discretionary";
+ revocability: "revocable" | "irrevocable";
+ }
+ interface Person {
+ fullName: string;
+ address: string;
+ country: string;
+ dateOfBirth: AbsoluteTime;
+ nationality: string;
+ }
+ type WithRevoke<T> = {
+ rightToRevoke: "yes" | "no";
+ } & T;
+ type WithClaim<T> = {
+ rightToClaim: "yes" | "no";
+ } & T;
+ type WithDeath<T> = {
+ dateOfDeath: AbsoluteTime;
+ } & T;
+
+ type Founder = WithRevoke<WithDeath<Person>>;
+ type Beneficiary = WithClaim<Person>;
+
+ export interface Form extends BaseForm {
+ contractingPartner: string;
+ knownAs: string;
+ boardMember: string;
+ foundation: Foundation;
+ settlors: Array<Founder>;
+ preExistingSettlors: Array<Founder>;
+ beneficiaryWhenSigning: Array<Beneficiary>;
+ beneficiaryExtra: Array<Beneficiary>;
+ protectors: Array<WithRevoke<Person>>;
+ furtherPersons: Array<WithRevoke<Person>>;
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/forms/902_15e.ts b/packages/aml-backoffice-ui/src/forms/902_15e.ts
new file mode 100644
index 000000000..2375de389
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/902_15e.ts
@@ -0,0 +1,172 @@
+import type { AbsoluteTime } from "@gnu-taler/taler-util";
+import type { FlexibleForm, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "./declaration.js";
+import { resolutionSection } from "./simplest.js";
+
+export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_15.Form> => ({
+ design: [
+ {
+ title:
+ i18n.str`Information on life insurance policies with separately managed accounts/securities accounts`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ name: "contractingPartner",
+ label: i18n.str`Contracting partner`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "contractualRelationship",
+ label:
+ i18n.str`Name or number of the contractual relationship between the contracting party and the financial intermediary`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "insurancePolicy",
+ label: i18n.str`Insurance policy`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`The contracting partner confirms in accordance with Art. 41a SRO Regulations that it is a licensed and state-supervised insurance company and that it has entered into the above-mentioned contractual relationship the assets connected to the life insurance policy also mentioned above.`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`In relation with the above insurance policy, the contracting partner gives the following further details`,
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before: i18n.str`Policy holder`,
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "holder.fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "holder.address",
+ label:
+ i18n.str`Actual address of domicile/registered office (incl. country)`,
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ name: "holder.dateOfBirth",
+ label: i18n.str`Date of birth`,
+ pattern: "dd/MM/yyyy",
+ // help: i18n.str`format 'dd/MM/yyyy'`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "holder.nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before:
+ i18n.str`Person actually (not in a fiduciary capacity) paying the premiums (to be filled in if not identical with point 1 above)`,
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "premiumPayer.fullName",
+ label:
+ i18n.str`Last name(s), first name(s)/entity`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "premiumPayer.address",
+ label:
+ i18n.str`Actual address of domicile/registered office (incl. country)`,
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ name: "premiumPayer.dateOfBirth",
+ label: i18n.str`Date of birth`,
+ pattern: "dd/MM/yyyy",
+ // help: i18n.str`format 'dd/MM/yyyy'`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "premiumPayer.nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`The contracting partner hereby undertakes to automatically inform the financial intermediary of any changes. The contracting partner hereby also declares having been given permission by the above individuals and/or entities to transmit their data to the financial intermediary`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "signature",
+ label: i18n.str`Signature`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`It is a criminal offense to deliberately provide false information on this form (article 251 of the Swiss Criminal Code, document forgery)`,
+ },
+ },
+ ],
+ },
+ resolutionSection(current, i18n),
+ ],
+});
+
+namespace Form902_15 {
+ interface Person {
+ fullName: string;
+ address: string;
+ dateOfBirth: AbsoluteTime;
+ nationality: string;
+ }
+
+ export interface Form extends BaseForm {
+ contractingPartner: string;
+ contractualRelationship: string;
+ insurancePolicy: string;
+ holder: Person;
+ premiumsPayer: Person;
+ signature: string;
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/forms/902_1e.ts b/packages/aml-backoffice-ui/src/forms/902_1e.ts
new file mode 100644
index 000000000..2287db369
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/902_1e.ts
@@ -0,0 +1,660 @@
+import type { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
+import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm, uiForms } from "./declaration.js";
+import { resolutionSection } from "./simplest.js";
+
+export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_1.Form> => ({
+ design: [
+ {
+ title: i18n.str`Information on customer`,
+ description:
+ i18n.str`The customer is the person with whom the member concludes the contract with regard to the financial service provided (civil law). Does the member act as director of a domiciliary company, this domiciliary company is the customer.`,
+ fields: [
+ {
+ type: "choiceStacked",
+ props: {
+ name: "customerType",
+ label: i18n.str`Type of customer`,
+ required: true,
+ choices: [
+ {
+ label: i18n.str`Natural person`,
+ value: "natural",
+ },
+ {
+ label: i18n.str`Legal entity`,
+ value: "legal",
+ },
+ ],
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "naturalCustomer.fullName",
+ label: i18n.str`Full name`,
+ required: true,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "naturalCustomer.address",
+ label: i18n.str`Residential address`,
+ required: true,
+ },
+ },
+ {
+ type: "integer",
+ props: {
+ name: "naturalCustomer.telephone",
+ label: i18n.str`Telephone`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "naturalCustomer.email",
+ label: i18n.str`E-mail`,
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ name: "naturalCustomer.dateOfBirth",
+ label: i18n.str`Date of birth`,
+ required: true,
+ // help: i18n.str`format 'dd/MM/yyyy'`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "naturalCustomer.nationality",
+ label: i18n.str`Nationality`,
+ required: true,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "naturalCustomer.document",
+ label: i18n.str`Identification document`,
+ required: true,
+ },
+ },
+ {
+ type: "file",
+ props: {
+ name: "naturalCustomer.documentAttachment",
+ label: i18n.str`Document attachment`,
+ required: true,
+ maxBites: 2 * 1024 * 1024,
+ accept: ".png",
+ help: i18n.str`Max size of 2 mega bytes`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "naturalCustomer.companyName",
+ label: i18n.str`Company name`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "naturalCustomer.office",
+ label: i18n.str`Registered office`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "naturalCustomer.companyDocument",
+ label: i18n.str`Company identification document`,
+ },
+ },
+ {
+ type: "file",
+ props: {
+ name: "naturalCustomer.companyDocumentAttachment",
+ label: i18n.str`Document attachment`,
+ required: true,
+ maxBites: 2 * 1024 * 1024,
+ accept: ".png",
+ help: i18n.str`Max size of 2 mega bytes`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "legalCustomer.companyName",
+ label: i18n.str`Company name`,
+ required: true,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "legalCustomer.domicile",
+ label: i18n.str`Domicile`,
+ required: true,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "legalCustomer.contactPerson",
+ label: i18n.str`Contact person`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "legalCustomer.telephone",
+ label: i18n.str`Telephone`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "legalCustomer.email",
+ label: i18n.str`E-mail`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "legalCustomer.document",
+ label: i18n.str`Identification document`,
+ help: i18n.str`Not older than 12 month`,
+ },
+ },
+ {
+ type: "file",
+ props: {
+ name: "legalCustomer.documentAttachment",
+ label: i18n.str`Document attachment`,
+ required: true,
+ maxBites: 2 * 1024 * 1024,
+ accept: ".png",
+ help: i18n.str`Max size of 2 mega bytes`,
+ },
+ },
+ ],
+ },
+ {
+ title:
+ i18n.str`Information on the natural persons who establish the business relationship for legal entities and partnerships`,
+ description:
+ i18n.str`For legal entities and partnerships the identity of the natural persons who establish the business relationship must be verified.`,
+ fields: [
+ {
+ type: "array",
+ props: {
+ name: "businessEstablisher",
+ label: i18n.str`Persons`,
+ required: true,
+ placeholder: i18n.str`this is the placeholder`,
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "fullName",
+ label: i18n.str`Full name`,
+ required: true,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label: i18n.str`Residential address`,
+ required: true,
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ required: true,
+ // help: i18n.str`format 'dd/MM/yyyy'`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ required: true,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "typeOfAuthorization",
+ label:
+ i18n.str`Type of authorization (signatory of representation)`,
+ required: true,
+ },
+ },
+ {
+ type: "file",
+ props: {
+ name: "documentAttachment",
+ label:
+ i18n.str`Identification document attachment`,
+ required: true,
+ maxBites: 2 * 1024 * 1024,
+ accept: ".png",
+ help: i18n.str`Max size of 2 mega bytes`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "powerOfAttorneyArrangements",
+ label: i18n.str`Power of attorney arrangements`,
+ required: true,
+ choices: [
+ {
+ label: i18n.str`CR extract`,
+ value: "cr",
+ },
+ {
+ label: i18n.str`Mandate`,
+ value: "mandate",
+ },
+ {
+ label: i18n.str`Other`,
+ value: "other",
+ },
+ ],
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "powerOfAttorneyArrangementsOther",
+ label: i18n.str`Power of attorney arrangements`,
+ required: true,
+ },
+ },
+ ],
+ labelField: "fullName",
+ },
+ },
+ ],
+ },
+ {
+ title: i18n.str`Acceptance of business relationship`,
+ fields: [
+ {
+ type: "absoluteTime",
+ props: {
+ name: "acceptance.when",
+ pattern: "dd/MM/yyyy",
+ label: i18n.str`Date (conclusion of contract)`,
+ // help: i18n.str`format 'dd/MM/yyyy'`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "acceptance.acceptedBy",
+ label: i18n.str`Accepted by`,
+ required: true,
+ choices: [
+ {
+ label: i18n.str`Face-to-face meeting with customer`,
+ value: "face-to-face",
+ },
+ {
+ label:
+ i18n.str`Correspondence: authenticated copy of identification document obtained`,
+ value: "correspondence-document",
+ },
+ {
+ label:
+ i18n.str`Correspondence: residential address validated`,
+ value: "correspondence-address",
+ },
+ ],
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ name: "acceptance.typeOfCorrespondence",
+ label: i18n.str`Type of correspondence service`,
+ choices: [
+ {
+ label: i18n.str`to the customer`,
+ value: "customer",
+ },
+ {
+ label: i18n.str`hold at bank`,
+ value: "bank",
+ },
+ {
+ label: i18n.str`to the member`,
+ value: "member",
+ },
+ {
+ label: i18n.str`to a third party`,
+ value: "third-party",
+ },
+ ],
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "acceptance.thirdPartyFullName",
+ label: i18n.str`Third party full name`,
+ required: true,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "acceptance.thirdPartyAddress",
+ label: i18n.str`Third party address`,
+ required: true,
+ },
+ },
+ {
+ type: "selectMultiple",
+ props: {
+ name: "acceptance.language",
+ label: i18n.str`Languages`,
+ choices: uiForms.currencies(i18n),
+ unique: true,
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ name: "acceptance.furtherInformation",
+ label: i18n.str`Further information`,
+ },
+ },
+ ],
+ },
+ {
+ title:
+ i18n.str`Information on the beneficial owner of the assets and/or controlling person`,
+ description:
+ i18n.str`Establishment of the beneficial owner of the assets and/or controlling person`,
+ fields: [
+ {
+ type: "choiceStacked",
+ props: {
+ name: "establishment",
+ label: i18n.str`The customer is`,
+ required: true,
+ choices: [
+ {
+ label:
+ i18n.str`a natural person and there are no doubts that this person is the sole beneficial owner of the assets`,
+ value: "natural",
+ },
+ {
+ label:
+ i18n.str`a foundation (or a similar construct; incl. underlying companies)`,
+ value: "foundation",
+ },
+ {
+ label:
+ i18n.str`a trust (incl. underlying companies)`,
+ value: "trust",
+ },
+ {
+ label:
+ i18n.str`a life insurance policy with separately managed accounts/securities accounts`,
+ value: "insurance-wrapper",
+ },
+ {
+ label: i18n.str`all other cases`,
+ value: "other",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ {
+ title:
+ i18n.str`Evaluation with regard to embargo procedures/terrorism lists on establishing the business relationship`,
+ description:
+ i18n.str`Verification whether the customer, beneficial owners of the assets, controlling persons, authorized representatives or other involved persons are listed on an embargo/terrorism list (date of verification/result)`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ name: "embargoEvaluation",
+ help: i18n.str`The evaluation must be made at the beginning of the business relationship and has to be repeated in the case of permanent business relationship every time the according lists are updated.`,
+ label: i18n.str`Evaluation`,
+ },
+ },
+ ],
+ },
+ {
+ title:
+ i18n.str`In the case of cash transactions/occasional customers: Information on type and purpose of business relationship`,
+ description:
+ i18n.str`These details are only necessary for occasional customers, i.e. money exchange, money and asset transfer or other cash transactions provided that no customer profile (VQF doc. No. 902.5) is created`,
+ fields: [
+ {
+ type: "choiceStacked",
+ props: {
+ name: "cashTransactions.typeOfBusiness",
+ label: i18n.str`Type of business relationship`,
+ choices: [
+ {
+ label: i18n.str`Money exchange`,
+ value: "money-exchange",
+ },
+ {
+ label: i18n.str`Money and asset transfer`,
+ value: "money-and-asset-transfer",
+ },
+ {
+ label:
+ i18n.str`Other cash transactions. Specify below`,
+ value: "other",
+ },
+ ],
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "cashTransactions.otherTypeOfBusiness",
+ required: true,
+ label: i18n.str`Specify other cash transactions:`,
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ name: "cashTransactions.purpose",
+ label:
+ i18n.str`Purpose of the business relationship (purpose of service requested)`,
+ },
+ },
+ ],
+ },
+ resolutionSection(current, i18n),
+ ],
+ behavior: function formBehavior(
+ v: Partial<Form902_1.Form>,
+ ): FormState<Form902_1.Form> {
+ return {
+ fullName: {
+ disabled: true,
+ },
+ businessEstablisher: {
+ elements: (v.businessEstablisher ?? []).map((be) => {
+ return {
+ powerOfAttorneyArrangementsOther: {
+ hidden: be.powerOfAttorneyArrangements !== "other",
+ },
+ };
+ }),
+ },
+ acceptance: {
+ thirdPartyFullName: {
+ hidden: v.acceptance?.typeOfCorrespondence !== "third-party",
+ },
+ thirdPartyAddress: {
+ hidden: v.acceptance?.typeOfCorrespondence !== "third-party",
+ },
+ },
+ cashTransactions: {
+ otherTypeOfBusiness: {
+ hidden: v.cashTransactions?.typeOfBusiness !== "other",
+ },
+ },
+ naturalCustomer: {
+ fullName: {
+ hidden: v.customerType !== "natural",
+ },
+ address: {
+ hidden: v.customerType !== "natural",
+ },
+ telephone: {
+ hidden: v.customerType !== "natural",
+ },
+ email: {
+ hidden: v.customerType !== "natural",
+ },
+ dateOfBirth: {
+ hidden: v.customerType !== "natural",
+ },
+ nationality: {
+ hidden: v.customerType !== "natural",
+ },
+ document: {
+ hidden: v.customerType !== "natural",
+ },
+ companyName: {
+ hidden: v.customerType !== "natural",
+ },
+ office: {
+ hidden: v.customerType !== "natural",
+ },
+ companyDocument: {
+ hidden: v.customerType !== "natural",
+ },
+ companyDocumentAttachment: {
+ hidden: v.customerType !== "natural",
+ },
+ documentAttachment: {
+ hidden: v.customerType !== "natural",
+ },
+ },
+ legalCustomer: {
+ companyName: {
+ hidden: v.customerType !== "legal",
+ },
+ contactPerson: {
+ hidden: v.customerType !== "legal",
+ },
+ document: {
+ hidden: v.customerType !== "legal",
+ },
+ domicile: {
+ hidden: v.customerType !== "legal",
+ },
+ email: {
+ hidden: v.customerType !== "legal",
+ },
+ telephone: {
+ hidden: v.customerType !== "legal",
+ },
+ documentAttachment: {
+ hidden: v.customerType !== "legal",
+ },
+ },
+ };
+ },
+});
+
+namespace Form902_1 {
+ interface LegalEntityCustomer {
+ companyName: string;
+ domicile: string;
+ contactPerson: string;
+ telephone: string;
+ email: string;
+ document: string;
+ documentAttachment: string;
+ }
+ interface NaturalCustomer {
+ fullName: string;
+ address: string;
+ telephone: string;
+ email: string;
+ dateOfBirth: AbsoluteTime;
+ nationality: string;
+ document: string;
+ documentAttachment: string;
+ companyName: string;
+ office: string;
+ companyDocument: string;
+ companyDocumentAttachment: string;
+ }
+
+ interface Person {
+ fullName: string;
+ address: string;
+ dateOfBirth: AbsoluteTime;
+ nationality: string;
+ typeOfAuthorization: string;
+ document: string;
+ documentAttachment: string;
+ powerOfAttorneyArrangements: "cr" | "mandate" | "other";
+ powerOfAttorneyArrangementsOther: string;
+ }
+
+ interface Acceptance {
+ when: AbsoluteTime;
+ acceptedBy: "face-to-face" | "authenticated-copy";
+ typeOfCorrespondence: string;
+ language: string[];
+ furtherInformation: string;
+ thirdPartyFullName: string;
+ thirdPartyAddress: string;
+ }
+
+ interface BeneficialOwner {
+ establishment:
+ | "natural-person"
+ | "foundation"
+ | "trust"
+ | "insurance-wrapper"
+ | "other";
+ }
+
+ interface CashTransactions {
+ typeOfBusiness: "money-exchange" | "money-and-asset-transfer" | "other";
+ otherTypeOfBusiness: string;
+ purpose: string;
+ }
+
+ export interface Form extends BaseForm {
+ fullName: string;
+ customerType: "natural" | "legal";
+ naturalCustomer: NaturalCustomer;
+ legalCustomer: LegalEntityCustomer;
+ businessEstablisher: Array<Person>;
+ acceptance: Acceptance;
+ beneficialOwner: BeneficialOwner;
+ embargoEvaluation: string;
+ cashTransactions: CashTransactions;
+ // enclosures: Enclosures;
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/forms/902_4e.ts b/packages/aml-backoffice-ui/src/forms/902_4e.ts
new file mode 100644
index 000000000..b31a8dcba
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/902_4e.ts
@@ -0,0 +1,787 @@
+import type { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
+import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { h as create } from "preact";
+import { resolutionSection } from "./simplest.js";
+import { BaseForm } from "./declaration.js";
+import { ArrowRightIcon, ChevronRightIcon } from "./icons.js";
+
+export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_4.Form> => ({
+ design: [
+ {
+ title: i18n.str`Risk Profile AMLA`,
+ description:
+ i18n.str`Evaluation of business relationship with increased risk and definition of criteria for transaction monitoring.`,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`The member performs additional clarifications if the business relationship or the transaction is classified as increased risk (Art. 56 SRO Regulations)`,
+ before: create(ArrowRightIcon, { class: "h-6 w-6" }),
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "customer",
+ label: i18n.str`Customer`,
+ help: i18n.str`Pursuant identification form (VQF doc. Nr. 902.1) numeral 1`,
+ },
+ },
+ ],
+ },
+ {
+ title:
+ i18n.str`Evaluation of politically exposed persons (PEP-Check)`,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`This evaluation has to be completed by all members for every business relationship`,
+ before: create(ArrowRightIcon, { class: "h-6 w-6" }),
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Foreign PEP`,
+ // tooltip:
+ // i18n.str`Definition see Art. 7 lit. g numeral 1 SRO Regulations`,
+ help: i18n.str`Is the customer, the beneficial owner or the controlling person or authorized representative a foreign PEP or closely related to such a person?`,
+ name: "pep.foreign",
+ choices: [
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ {
+ label: i18n.str`Yes`,
+ description:
+ i18n.str`The business relationship is compulsory classified as increased risk`,
+ value: "yes",
+ },
+ ],
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label:
+ i18n.str`Domestic PEP and PEP of International Organizations`,
+ // tooltip:
+ // i18n.str`Definition see Art. 7 lit. g numeral 2 and 3 SRO Regulations `,
+ help: i18n.str`Is the customer, the beneficial owner or the controlling person or authorized representative a domestic PEP or PEP in International Organizations or closely related to such a person?`,
+ name: "pep.domestic",
+ choices: [
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ {
+ label:
+ i18n.str`Yes, but NOT risk criterion pursuant to numeral 3 subsequently increased.`,
+ value: "yes-but-no-risk",
+ },
+ {
+ label:
+ i18n.str`Yes, AND a risk criterion pursuant to numeral 3 subsequently increased.`,
+ description:
+ i18n.str`Classification of the business relationship as increased risk is compulsory`,
+ value: "yes",
+ },
+ ],
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ label:
+ i18n.str`The decision of the Senior executive body on the acceptance of a business relationship with a PEP was obtained on`,
+ name: "pep.when",
+ pattern: "dd/MM/yyyy",
+ // placeholder: i18n.str`dd/MM/yyyy`,
+ },
+ },
+ ],
+ },
+ {
+ title:
+ 'Evaluation "high risk" or non-cooperative country' as TranslatedString,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`This evaluation has to be completed by all members for every business relationship`,
+ before: create(ArrowRightIcon, { class: "h-6 w-6" }),
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: '"High risk" or non-cooperative country' as TranslatedString,
+ help: 'Is the customer, the beneficial owner or the controlling person or authorized representative in a country considered by the FATF "high risk" or non-cooperative and for which FATF requires increased diligence?' as TranslatedString,
+ name: "highRisk.evaluation",
+ choices: [
+ {
+ label: i18n.str`No`,
+ value: "no",
+ },
+ {
+ label: i18n.str`Yes`,
+ description:
+ i18n.str`considered as business relationship with increased risk`,
+ value: "yes",
+ },
+ ],
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ label:
+ i18n.str`The decision of the Senior executive body on the acceptance of a business relationship with a PEP was obtained on`,
+ name: "highRisk.when",
+ pattern: "dd/MM/yyyy",
+ // placeholder: i18n.str`dd/MM/yyyy`,
+ },
+ },
+ ],
+ },
+ {
+ title: i18n.str`Evaluation of business relationship risk`,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`This evaluation has to be completed by all members who have in total more than 20 customers for every business relationship. At least two risk categories have to be chosen and assessed`,
+ before: create(ArrowRightIcon, { class: "h-6 w-6" }),
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before: i18n.str`a) Country risk (nationality)`,
+ fields: [
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Domicile/residential address`,
+ name: "evaluation.nationality.address",
+ choices: [
+ {
+ label: i18n.str`Customer`,
+ value: "customer",
+ },
+ {
+ label:
+ i18n.str`Beneficial owner of the assets`,
+ value: "owner",
+ },
+ {
+ label: i18n.str`Controlling person`,
+ value: "controlling",
+ },
+ ],
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Nationality`,
+ name: "evaluation.nationality.nationality",
+ choices: [
+ {
+ label: i18n.str`Customer`,
+ value: "customer",
+ },
+ {
+ label:
+ i18n.str`Beneficial owner of the assets`,
+ value: "owner",
+ },
+ ],
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Risk level`,
+ name: "evaluation.nationality.risk",
+ choices: [
+ {
+ label:
+ i18n.str`Risk 0 acc. to VQF country list (VQF doc. no. 902.4.1)`,
+ value: "low",
+ },
+ {
+ label:
+ i18n.str`Risk 1 acc. to VQF country list (VQF doc. no. 902.4.1)`,
+ value: "medium",
+ },
+ {
+ label:
+ i18n.str`Risk 2 acc. to VQF country list (VQF doc. no. 902.4.1)`,
+ value: "high",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before: i18n.str`b) Country risk (business activity)`,
+ fields: [
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Place of business activity`,
+ name: "evaluation.business.place",
+ choices: [
+ {
+ label: i18n.str`Customer`,
+ value: "customer",
+ },
+ {
+ label:
+ i18n.str`Beneficial owner of the assets`,
+ value: "owner",
+ },
+ ],
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Risk level`,
+ name: "evaluation.business.risk",
+ choices: [
+ {
+ label:
+ i18n.str`Risk 0 acc. to VQF country list (VQF doc. no. 902.4.1)`,
+ value: "low",
+ },
+ {
+ label:
+ i18n.str`Risk 1 acc. to VQF country list (VQF doc. no. 902.4.1)`,
+ value: "medium",
+ },
+ {
+ label:
+ i18n.str`Risk 2 acc. to VQF country list (VQF doc. no. 902.4.1)`,
+ value: "high",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before: i18n.str`c) Country risk (payments)`,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`Country of origin and destination of frequent payments (if known)`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Risk level`,
+ name: "evaluation.payments.risk",
+ choices: [
+ {
+ label:
+ i18n.str`Risk 0 acc. to VQF country list (VQF doc. no. 902.4.1)`,
+ value: "low",
+ },
+ {
+ label:
+ i18n.str`Risk 1 acc. to VQF country list (VQF doc. no. 902.4.1)`,
+ value: "medium",
+ },
+ {
+ label:
+ i18n.str`Risk 2 acc. to VQF country list (VQF doc. no. 902.4.1)`,
+ value: "high",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before: i18n.str`d) Industry risk`,
+ fields: [
+ {
+ type: "choiceStacked",
+ props: {
+ label:
+ i18n.str`Nature of customer's business activity`,
+ name: "evaluation.industry.nature",
+ choices: [
+ {
+ label: i18n.str`Customer`,
+ value: "customer",
+ },
+ {
+ label:
+ i18n.str`Beneficial owner of the assets`,
+ value: "owner",
+ },
+ ],
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Risk level`,
+ name: "evaluation.payments.risk",
+ choices: [
+ {
+ label:
+ i18n.str`Clearly defined, transparent, easily comprehensible business activity well known to the member`,
+ value: "low",
+ },
+ {
+ label:
+ i18n.str`Business activity with a high level of cash transactions`,
+ value: "medium-cash",
+ },
+ {
+ label:
+ i18n.str`Business activity not well known to the member`,
+ value: "medium-unknown",
+ },
+ {
+ label:
+ i18n.str`Trade in munitions/arms, raw gem stones/diamonds, jewelry, international trade in exotic animals, casino and lottery business, trade in erotic wares`,
+ value: "high-restricted",
+ },
+ {
+ label:
+ i18n.str`Member has no personal knowledge of the customer's industry`,
+ value: "high-unknown",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before: i18n.str`e) Contact risk`,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`Types of contact to the customer/ beneficial owner of the assets`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Risk level`,
+ name: "evaluation.contact.risk",
+ choices: [
+ {
+ label:
+ i18n.str`Personal acquaintance between member and customer/beneficial owner of the assets over several years (at least 2) prior to entering into the business relationship`,
+ value: "low",
+ },
+ {
+ label:
+ i18n.str`The customer/beneficial owner was not personally known to the member for several years (at least 2) prior to entering into the business relationship; however (a) no business was entered into in the absence of the customer/beneficial owner, or (b) the customer was at least introduced/brokered by a trusted third party`,
+ value: "medium",
+ },
+ {
+ label:
+ i18n.str`The customer/beneficial owner was not personally known to the member and business was entered into in the absence of the former (relationship by correspondence) and the customer was not introduced/brokered by a trusted third party`,
+ value: "high",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before: i18n.str`f) Product risk`,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`Nature of services and products requested by the customer`,
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Risk level`,
+ name: "evaluation.product.risk",
+ choices: [
+ {
+ label:
+ i18n.str`Easy to understand, transparent services and products whose financial background is easy to comprehend and verify`,
+ value: "low",
+ },
+ {
+ label:
+ i18n.str`More sophisticated services/products whose financial background is not readily easy to comprehend and verify`,
+ value: "medium",
+ },
+ {
+ label:
+ i18n.str`Main focus on offshore business (especially: relationships with domiciliary companies or other such offshore organisations)`,
+ value: "high-offshore",
+ },
+ {
+ label:
+ i18n.str`Complex structures in particular by using a domiciliary company with fiduciary shareholders in a non-transparent jurisdiction, without comprehensible reason or for the purpose of short-term asset placement`,
+ value: "high-structure",
+ },
+ {
+ label:
+ i18n.str`The customer or beneficial owner of the assets has a large number of accounts with pass-through transactions (pass-through accounts)`,
+ value: "high-accounts",
+ },
+ {
+ label:
+ i18n.str`Complex services/products whose financial background can’t be understood or verified with considerable effort`,
+ value: "high-service",
+ },
+ {
+ label:
+ i18n.str`Frequent transactions with increased risks`,
+ value: "high-freq-tx",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before: i18n.str`g) Criteria defined by the member`,
+ fields: [
+ {
+ type: "text",
+ props: {
+ label: i18n.str`Criteria definition`,
+ name: "evaluation.custom.definition",
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Risk level`,
+ name: "evaluation.custom.risk",
+ choices: [
+ {
+ label: i18n.str`Low`,
+ value: "low",
+ },
+ {
+ label: i18n.str`Medium`,
+ value: "medium",
+ },
+ {
+ label: i18n.str`High`,
+ value: "high",
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`Overall assessment of the business relationship`,
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before:
+ i18n.str`A business relationship is classified as increased risk if:`,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`Business relationship with PEP pursuant to numeral 1 (no exception possible)`,
+ before: create(ChevronRightIcon, { class: "h-6 w-6" }),
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ label:
+ 'Relationship with a person from a "high risk" or non-cooperative country according to numeral 2 (no exceptions possible)' as TranslatedString,
+ before: create(ChevronRightIcon, { class: "h-6 w-6" }),
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`Min. one criterion pursuant to numeral 3 was assessed with risk 2 or min. two criteria pursuant to numeral 3 were assessed with risk 1 (exception: justification by the member below why the business relationship overall does not have to be classified as increased risk despite the fact that individual risk criteria are increased)`,
+ before: create(ChevronRightIcon, { class: "h-6 w-6" }),
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`Justification for differing risk assessment`,
+ name: "evaluation.overall.justification",
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Risk classified`,
+ name: "evaluation.overall.risk",
+ choices: [
+ {
+ label:
+ i18n.str`Business relationship _without_ increased risk`,
+ value: "without",
+ },
+ {
+ label:
+ i18n.str`Business relationship __with__ increased risk`,
+ value: "with",
+ },
+ ],
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ label:
+ i18n.str`The decision of the Senior executive body on the acceptance of a business relationship with a PEP was obtained on`,
+ name: "evaluation.when",
+ pattern: "dd/MM/yyyy",
+ // placeholder: i18n.str`dd/MM/yyyy`,
+ },
+ },
+ ],
+ },
+ {
+ title:
+ i18n.str`Criteria for identification of increased risk transactions (transaction monitoring)`,
+ fields: [
+ {
+ type: "group",
+ props: {
+ before: i18n.str`Criteria`,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`Classification as as increased risk is compulsory if`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ before: create(ChevronRightIcon, { class: "w-6 h-6" }),
+ label:
+ i18n.str`Transactions for which assets with an equivalent value of CHF 100'000.- or more are physically introduced at the beginning of the business relationship, either at once or in a staggered manner`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ before: create(ChevronRightIcon, { class: "w-6 h-6" }),
+ label:
+ 'Money and asset transfers ("money transfer") whereby a single transaction or multiple transactions which appear to be related reach or exceed the amount of CHF 5,000.-' as TranslatedString,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ before: create(ChevronRightIcon, { class: "w-6 h-6" }),
+ label:
+ 'Payments from or to a country that is considered to be "high risk" or non-cooperative by the FATF and for which increased diligence is required' as TranslatedString,
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before:
+ i18n.str`Additional criteria defined by the member`,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ before: create(ArrowRightIcon, { class: "w-6 h-6" }),
+ label:
+ i18n.str`All members have to define min. 1 additional criterion for every business relationship to identify unusual transactions`,
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label: i18n.str`Description`,
+ name: "criteria.additional",
+ },
+ },
+ {
+ type: "group",
+ props: {
+ before:
+ i18n.str`Possible criteria (Art. 59 para. 2 SRO Regulations)`,
+ fields: [
+ {
+ type: "caption",
+ props: {
+ before: create(ChevronRightIcon, { class: "w-4 h-4" }),
+ label:
+ i18n.str`the amount of inflowing and outflowing assets`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ before: create(ChevronRightIcon, { class: "w-4 h-4" }),
+ label:
+ i18n.str`type, volume and frequency of transactions usual to the business relationship (considerable variance would be unusual)`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ before: create(ChevronRightIcon, { class: "w-4 h-4" }),
+ label:
+ i18n.str`type, volume and frequency of transactions usual to comparable business relationships (considerable variance would be unusual)`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ before: create(ChevronRightIcon, { class: "w-4 h-4" }),
+ label:
+ i18n.str`description of expected transaction patterns which the client notify the member of (considerable variance would be unusual)`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ before: create(ChevronRightIcon, { class: "w-4 h-4" }),
+ label:
+ 'The country of origin or destination of payments, especially in the case of payments from or to a country considered by the FATF as "high risk" or non-cooperative' as TranslatedString,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ resolutionSection(current, i18n),
+ ],
+ behavior: function formBehavior(
+ v: Partial<Form902_4.Form>,
+ ): FormState<Form902_4.Form> {
+ return {
+ };
+ },
+});
+
+namespace Form902_4 {
+ export interface Form extends BaseForm {
+ customer: string;
+ fullName: string;
+ pep: {
+ foreign: "yes" | "no";
+ domestic: "yes" | "no" | "yes-but-no-risk";
+ when: AbsoluteTime;
+ };
+ highRisk: {
+ evaluation: "yes" | "no";
+ when: AbsoluteTime;
+ };
+ evaluation: {
+ nationality: {
+ address: "customer" | "owner" | "controlling";
+ nationality: "customer" | "owner";
+ risk: "low" | "medium" | "high";
+ };
+ business: {
+ place: "customer" | "owner";
+ risk: "low" | "medium" | "high";
+ };
+ payments: {
+ risk: "low" | "medium" | "high";
+ };
+ industry: {
+ nature: "customer" | "owner";
+ risk:
+ | "low"
+ | "medium-cash"
+ | "medium-unknown"
+ | "high-restricted"
+ | "high-unknown";
+ };
+ contact: {
+ risk: "low" | "medium" | "high";
+ };
+ product: {
+ risk:
+ | "low"
+ | "medium"
+ | "high-offshore"
+ | "high-structure"
+ | "high-accounts"
+ | "high-service"
+ | "high-freq-tx";
+ };
+ custom: {
+ definition: string;
+ risk: "low" | "medium" | "high";
+ };
+ overall: {
+ justification: string;
+ risk: "with" | "without";
+ };
+ };
+ criteria: {
+ additional: string;
+ };
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/forms/902_5e.ts b/packages/aml-backoffice-ui/src/forms/902_5e.ts
new file mode 100644
index 000000000..3af03ed22
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/902_5e.ts
@@ -0,0 +1,255 @@
+import type { TranslatedString } from "@gnu-taler/taler-util";
+import type { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm, uiForms } from "./declaration.js";
+import { resolutionSection } from "./simplest.js";
+
+export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_5.Form> => ({
+ design: [
+ {
+ title: i18n.str`Customer Profile`,
+ description:
+ i18n.str`The information below has to refer to the persons from whom the assets originate ultimately (e.g. beneficial owner of the assets, founder/creator of a trust or foundation). Is the customer an operational legal entity or partnership the information may refer to the entity itself (not to the controlling person), unless the entity holds the assets in trust for a third party.`,
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "customer",
+ label: i18n.str`Customer`,
+ help: i18n.str`Pursuant Identification Form (VQF doc. No. 902.1) numeral 1`,
+ },
+ },
+ ],
+ },
+ {
+ title: i18n.str`Business activity`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ label: i18n.str`Profession, business activities`,
+ name: "businessActivity",
+ help: i18n.str`former, current, potentially planned`,
+ },
+ },
+ ],
+ },
+ {
+ title: i18n.str`Financial circumstances`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ label: i18n.str`Income and assets, liabilities`,
+ name: "financial",
+ help: i18n.str`estimated`,
+ },
+ },
+ ],
+ },
+ {
+ title: i18n.str`Origin of the deposited assets involved`,
+ fields: [
+ {
+ type: "text",
+ props: {
+ label: i18n.str`Nature`,
+ name: "originOfAssets.nature",
+ help: i18n.str`nature of the involved assets`,
+ },
+ },
+ {
+ type: "selectOne",
+ props: {
+ name: "originOfAssets.currency",
+ label: i18n.str`Currency`,
+ choices: uiForms.currencies(i18n),
+ },
+ },
+ {
+ type: "integer",
+ props: {
+ label: i18n.str`Amount`,
+ name: "originOfAssets.amount",
+ },
+ },
+ {
+ type: "choiceStacked",
+ props: {
+ label: i18n.str`Category`,
+ name: "originOfAssets.category",
+ choices: [
+ {
+ label: i18n.str`Savings`,
+ value: "savings",
+ },
+ {
+ label: i18n.str`Own business operations`,
+ value: "own-business",
+ },
+ {
+ label: i18n.str`Inheritance`,
+ value: "inheritance",
+ },
+ {
+ label: i18n.str`Other, what?`,
+ value: "other",
+ },
+ ],
+ },
+ },
+ {
+ type: "text",
+ props: {
+ label: i18n.str`Other category`,
+ name: "originOfAssets.categoryOther",
+ required: true,
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`Detailed description of the origins/economical background of the assets involved in the business relationship`,
+ name: "originOfAssets.details",
+ },
+ },
+ ],
+ },
+ {
+ title:
+ i18n.str`Nature and purpose of the business relationship`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ label: i18n.str`Purpose of the business relationship`,
+ name: "nature.purpose",
+ help: i18n.str`nature of the involved assets`,
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`Information on the planned development of the business relationship and the assets`,
+ name: "nature.plan",
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`Especially in the case of cash or money and asset transfer transactions with regular customers: Details on usual business volume, Information on the beneficiaries, (Full name, address, bank account)`,
+ name: "nature.cashOrMoneyTransfer",
+ },
+ },
+ ],
+ },
+ {
+ title: i18n.str`Relationship with third parties`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`Relation of the customer to the beneficial owner involved in the business relationship`,
+ name: "relations.beneficialOwners",
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`Relation of the customer to the controlling persons involved in the business relationship`,
+ name: "relations.controllingPersons",
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`Relation of the customer to the authorized signatories involved in the business relationship`,
+ name: "relations.authorizedSignatories",
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label:
+ i18n.str`Relation of the customer to other persons involved in the business relationship`,
+ name: "relations.otherPersons",
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label: i18n.str`Relation to other AMLA-Files`,
+ name: "relations.withOtherAmlaFiles",
+ },
+ },
+ {
+ type: "textArea",
+ props: {
+ label: i18n.str`Introducer / agents / references`,
+ name: "relations.references",
+ },
+ },
+ ],
+ },
+ {
+ title: i18n.str`Further information`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ label: i18n.str`Other relevant information`,
+ name: "furtherInformation",
+ },
+ },
+ ],
+ },
+ resolutionSection(current, i18n),
+ ],
+ behavior: function formBehavior(
+ v: Partial<Form902_5.Form>,
+ ): FormState<Form902_5.Form> {
+ return {
+ originOfAssets: {
+ categoryOther: {
+ hidden: v.originOfAssets?.category !== "other",
+ },
+ },
+ };
+ },
+});
+
+namespace Form902_5 {
+ export interface Form extends BaseForm {
+ customer: string;
+ fullName: string;
+ businessActivity: string;
+ financial: string;
+ originOfAssets: {
+ nature: string;
+ currency: string;
+ amount: number;
+ category: "savings" | "own-business" | "inheritance" | "other";
+ categoryOther: string;
+ details: string;
+ };
+ nature: {
+ purpose: string;
+ plan: string;
+ cashOrMoneyTransfer: string;
+ };
+ relations: {
+ beneficialOwners: string;
+ controllingPersons: string;
+ authorizedSignatories: string;
+ otherPersons: string;
+ withOtherAmlaFiles: string;
+ references: string;
+ };
+ furtherInformation: string;
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/forms/902_9e.ts b/packages/aml-backoffice-ui/src/forms/902_9e.ts
new file mode 100644
index 000000000..e0e7a6d65
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/902_9e.ts
@@ -0,0 +1,119 @@
+import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
+import { FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { resolutionSection } from "./simplest.js";
+import { BaseForm } from "./declaration.js";
+
+export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Form902_9.Form> => ({
+ design: [
+ {
+ title:
+ i18n.str`Declaration of identity of the beneficial owner`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ name: "contractingPartner",
+ label: i18n.str`Contracting partner`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`The contracting partner hereby declares that the person(s) listed below is/are the beneficial owner(s) of the assets involved in the business relationship. If the contracting partner is also the sole beneficial owner of the assets, the contracting partner's detail must be set out below`,
+ },
+ },
+ {
+ type: "array",
+ props: {
+ label: i18n.str`Persons`,
+ labelField: "surname",
+ name: "persons",
+ fields: [
+ {
+ type: "text",
+ props: {
+ name: "surname",
+ label: i18n.str`Surname(s)`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "firstName",
+ label: i18n.str`First name(s)`,
+ },
+ },
+ {
+ type: "absoluteTime",
+ props: {
+ name: "dateOfBirth",
+ label: i18n.str`Date of birth`,
+ pattern: "dd/MM/yyyy",
+ // help: i18n.str`format 'dd/MM/yyyy'`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "nationality",
+ label: i18n.str`Nationality`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "address",
+ label: i18n.str`Actual address of domicile`,
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`The contracting partner hereby undertakes to inform automatically of any changes to the information contained herein`,
+ },
+ },
+ {
+ type: "text",
+ props: {
+ name: "signature",
+ label: i18n.str`Signature`,
+ },
+ },
+ {
+ type: "caption",
+ props: {
+ label:
+ i18n.str`It is a criminal offense to deliberately provide false information on this form (article 251 of the Swiss Criminal Code, document forgery)`,
+ },
+ },
+ ],
+ },
+ resolutionSection(current, i18n),
+ ],
+ behavior: function formBehavior(
+ v: Partial<Form902_9.Form>,
+ ): FormState<Form902_9.Form> {
+ return {
+ };
+ },
+});
+
+namespace Form902_9 {
+ interface Person {
+ surname: string;
+ firstName: string;
+ dateOfBirth: AbsoluteTime;
+ nationality: string;
+ address: string;
+ }
+ export interface Form extends BaseForm {
+ contractingPartner: string;
+ persons: Person;
+ signature: string;
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/forms/declaration.ts b/packages/aml-backoffice-ui/src/forms/declaration.ts
new file mode 100644
index 000000000..ec3bc5189
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/declaration.ts
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import type { AmountJson, TranslatedString } from "@gnu-taler/taler-util";
+import type { FlexibleForm, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { AmlExchangeBackend } from "../utils/types.js";
+
+/**
+ * import entry point without hard reference.
+ *
+ * This file just export types and UI Forms
+ * based on what `globalThis` contains.
+ *
+ * `./index.js` must be imported first before
+ * so `globaThis` will have the correct value.
+ */
+
+export interface BaseForm {
+ state: AmlExchangeBackend.AmlState;
+ threshold: AmountJson;
+}
+
+export type FormMetadata<T extends BaseForm> = {
+ label: TranslatedString,
+ id: string,
+ version: number,
+ impl: (current: T) => FlexibleForm<T>
+}
+
+interface LabelValue {
+ label: TranslatedString;
+ value: string,
+}
+
+export interface UiForms {
+ currencies: (i18n: InternationalizationAPI) => LabelValue[],
+ languages: (i18n: InternationalizationAPI) => LabelValue[],
+ forms: (i18n: InternationalizationAPI) => Array<FormMetadata<BaseForm>>,
+}
+
+/**
+ * Global settings for the UI.
+ */
+const defaultUIForms: UiForms = {
+ currencies: () => [],
+ languages: () => [],
+ forms: () => [],
+};
+
+declare global {
+ var amlExchangeBackoffice: UiForms;
+}
+
+export const uiForms: UiForms =
+ "amlExchangeBackoffice" in globalThis
+ ? (globalThis as any).amlExchangeBackoffice
+ : defaultUIForms;
diff --git a/packages/aml-backoffice-ui/src/forms/icons.tsx b/packages/aml-backoffice-ui/src/forms/icons.tsx
new file mode 100644
index 000000000..392790c9c
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/icons.tsx
@@ -0,0 +1,10 @@
+import { h } from "preact";
+
+export const ChevronRightIcon = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
+</svg>
+
+
+export const ArrowRightIcon = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
+</svg>
diff --git a/packages/aml-backoffice-ui/src/forms/index.ts b/packages/aml-backoffice-ui/src/forms/index.ts
new file mode 100644
index 000000000..f41122bc7
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/index.ts
@@ -0,0 +1,202 @@
+import type { InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { v1 as form_902_11e_v1 } from "./902_11e.js";
+import { v1 as form_902_12e_v1 } from "./902_12e.js";
+import { v1 as form_902_13e_v1 } from "./902_13e.js";
+import { v1 as form_902_15e_v1 } from "./902_15e.js";
+import { v1 as form_902_1e_v1 } from "./902_1e.js";
+import { v1 as form_902_4e_v1 } from "./902_4e.js";
+import { v1 as form_902_5e_v1 } from "./902_5e.js";
+import { v1 as form_902_9e_v1 } from "./902_9e.js";
+import { v1 as simplest } from "./simplest.js";
+import { BaseForm, FormMetadata } from "./declaration.js";
+
+const languages = (i18n: InternationalizationAPI) => [
+ {
+ label: i18n.str`Mandarin Chinese`,
+ value: "cmn",
+ },
+ {
+ label: i18n.str`Spanish`,
+ value: "spa",
+ },
+ {
+ label: i18n.str`English`,
+ value: "eng",
+ },
+ {
+ label: i18n.str`Hindi`,
+ value: "hin",
+ },
+ {
+ label: i18n.str`Portuguese`,
+ value: "por",
+ },
+ {
+ label: i18n.str`Bengali`,
+ value: "ben",
+ },
+ {
+ label: i18n.str`Russian`,
+ value: "rus",
+ },
+ {
+ label: i18n.str`Japanese`,
+ value: "jpn",
+ },
+ {
+ label: i18n.str`Yue`,
+ value: "yue",
+ },
+ {
+ label: i18n.str`Vietnamese`,
+ value: "vie",
+ },
+ {
+ label: i18n.str`Turkish`,
+ value: "tur",
+ },
+ {
+ label: i18n.str`Wu`,
+ value: "wuu",
+ },
+ {
+ label: i18n.str`Marathi`,
+ value: "mar",
+ },
+ {
+ label: i18n.str`Telugu`,
+ value: "ten",
+ },
+ {
+ label: i18n.str`Korean`,
+ value: "kor",
+ },
+ {
+ label: i18n.str`French`,
+ value: "fra",
+ },
+ {
+ label: i18n.str`Tamil`,
+ value: "tam",
+ },
+ {
+ label: i18n.str`Egyptian Arabic`,
+ value: "arz",
+ },
+ {
+ label: i18n.str`Standard German`,
+ value: "deu",
+ },
+ {
+ label: i18n.str`Urdu`,
+ value: "urd",
+ },
+ {
+ label: i18n.str`Javanese`,
+ value: "jav",
+ },
+ {
+ label: i18n.str`Punjabi`,
+ value: "pan",
+ },
+ {
+ label: i18n.str`Italian`,
+ value: "ita",
+ },
+ {
+ label: i18n.str`Gujarati`,
+ value: "guj",
+ },
+ {
+ label: i18n.str`Iranian Persian`,
+ value: "pes",
+ },
+ {
+ label: i18n.str`Bhojpuri`,
+ value: "bho",
+ },
+ {
+ label: i18n.str`Hausa`,
+ value: "hau",
+ },
+];
+
+
+const forms: (i18n: InternationalizationAPI) => Array<FormMetadata<BaseForm>> = (i18n) => [
+ {
+ label: i18n.str`Simple comment`,
+ id: "simple_comment",
+ version: 1,
+ impl: simplest(i18n),
+ }, {
+ label: i18n.str`Identification form`,
+ id: "902.1e",
+ version: 1,
+ impl: form_902_1e_v1(i18n),
+ }, {
+ label: i18n.str`Operational legal entity or partnership`,
+ id: "902.11e",
+ version: 1,
+ impl: form_902_11e_v1(i18n),
+ }, {
+ label: i18n.str`Foundations`,
+ id: "902.12e",
+ version: 1,
+ impl: form_902_12e_v1(i18n),
+ }, {
+ label: i18n.str`Declaration for trusts`,
+ id: "902.13e",
+ version: 1,
+ impl: form_902_13e_v1(i18n),
+ }, {
+ label: i18n.str`Information on life insurance policies`,
+ id: "902.15e",
+ version: 1,
+ impl: form_902_15e_v1(i18n),
+ }, {
+ label: i18n.str`Declaration of beneficial owner`,
+ id: "902.9e",
+ version: 1,
+ impl: form_902_9e_v1(i18n),
+ }, {
+ label: i18n.str`Customer profile`,
+ id: "902.5e",
+ version: 1,
+ impl: form_902_5e_v1(i18n),
+ }, {
+ label: i18n.str`Risk profile`,
+ id: "902.4e",
+ version: 1,
+ impl: form_902_4e_v1(i18n),
+ },
+];
+
+
+const currencies = (i18n: InternationalizationAPI) => [
+ {
+ label: i18n.str`United States dollar`,
+ value: "usd",
+ },
+ {
+ label: i18n.str`Euro`,
+ value: "eur",
+ },
+ {
+ label: i18n.str`Swiss franc`,
+ value: "chf",
+ },
+ {
+ label: i18n.str`Argentine peso`,
+ value: "ars",
+ },
+ {
+ label: i18n.str`Mexican peso`,
+ value: "mxn",
+ },
+ {
+ label: i18n.str`Brazilian real`,
+ value: "brl",
+ },
+];
+
+globalThis.amlExchangeBackoffice = { currencies, languages, forms }
diff --git a/packages/aml-backoffice-ui/src/forms/simplest.ts b/packages/aml-backoffice-ui/src/forms/simplest.ts
new file mode 100644
index 000000000..735ca9bfc
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/forms/simplest.ts
@@ -0,0 +1,85 @@
+import type {
+ TranslatedString
+} from "@gnu-taler/taler-util";
+
+import type { DoubleColumnFormSection, FlexibleForm, FormState, InternationalizationAPI } from "@gnu-taler/web-util/browser";
+import { BaseForm } from "./declaration.js";
+import { amlStateConverter } from "../utils/converter.js";
+import { AmlExchangeBackend } from "../utils/types.js";
+
+export const v1 = (i18n: InternationalizationAPI) => (current: BaseForm): FlexibleForm<Simplest.Form> => ({
+ design: [
+ {
+ title: i18n.str`Simple form`,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ name: "comment",
+ label: i18n.str`Comments`,
+ },
+ },
+ ],
+ },
+ resolutionSection(current, i18n),
+ ],
+ behavior: function formBehavior(
+ v: Partial<Simplest.Form>,
+ ): FormState<Simplest.Form> {
+ return {
+ comment: {
+ help: ((v.comment?.length ?? 0) > 100 ? "keep it short" : "") as TranslatedString,
+
+ },
+ threshold: {
+ disabled: v.state === AmlExchangeBackend.AmlState.frozen,
+ },
+ };
+ },
+});
+
+export namespace Simplest {
+ export interface Form extends BaseForm {
+ comment: string;
+ }
+}
+
+export function resolutionSection(current: BaseForm, i18n: InternationalizationAPI): DoubleColumnFormSection {
+ return {
+ title: i18n.str`Resolution`,
+ description: `Current state is ${amlStateConverter.toStringUI(
+ current.state,
+ )} and threshold at ` as TranslatedString,
+ fields: [
+ {
+ type: "choiceHorizontal",
+ props: {
+ name: "state",
+ label: i18n.str`New state`,
+ converter: amlStateConverter,
+ choices: [
+ {
+ value: AmlExchangeBackend.AmlState.frozen,
+ label: i18n.str`Frozen`,
+ },
+ {
+ value: AmlExchangeBackend.AmlState.pending,
+ label: i18n.str`Pending`,
+ },
+ {
+ value: AmlExchangeBackend.AmlState.normal,
+ label: i18n.str`Normal`,
+ },
+ ],
+ },
+ },
+ {
+ type: "amount",
+ props: {
+ name: "threshold",
+ label: i18n.str`New threshold`,
+ },
+ },
+ ],
+ };
+}
diff --git a/packages/aml-backoffice-ui/src/hooks/useBackend.ts b/packages/aml-backoffice-ui/src/hooks/useBackend.ts
new file mode 100644
index 000000000..7b55568c8
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/hooks/useBackend.ts
@@ -0,0 +1,48 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+ import { canonicalizeBaseUrl } from "@gnu-taler/taler-util";
+import { uiSettings } from "../settings.js";
+
+
+export function getInitialBackendBaseURL(): string {
+ const overrideUrl =
+ typeof localStorage !== "undefined"
+ ? localStorage.getItem("exchange-base-url")
+ : undefined;
+
+ let result: string;
+
+ if (!overrideUrl) {
+ //normal path
+ if (!uiSettings.backendBaseURL) {
+ console.error(
+ "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'",
+ );
+ result = typeof (window as any) !== "undefined" ? window.origin : "localhost"
+ } else {
+ result = uiSettings.backendBaseURL;
+ }
+ } else {
+ // testing/development path
+ result = overrideUrl
+ }
+ try {
+ return canonicalizeBaseUrl(result)
+ } catch (e) {
+ //fall back
+ return canonicalizeBaseUrl(window.origin)
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts b/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts
new file mode 100644
index 000000000..dbc6763ba
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts
@@ -0,0 +1,95 @@
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { AmountString, OfficerAccount, PaytoString, TalerExchangeApi, TalerExchangeResultByMethod, TalerHttpError } from "@gnu-taler/taler-util";
+import _useSWR, { SWRHook } from "swr";
+import { useExchangeApiContext } from "../context/config.js";
+import { useOfficer } from "./useOfficer.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function useCaseDetails(paytoHash: string) {
+ const officer = useOfficer();
+ const session = officer.state === "ready" ? officer.account : undefined;
+
+ const { api } = useExchangeApiContext();
+
+ async function fetcher([officer, account]: [OfficerAccount, PaytoString]) {
+ return await api.getDecisionDetails(officer, account)
+ }
+
+ const { data, error } = useSWR<TalerExchangeResultByMethod<"getDecisionDetails">, TalerHttpError>(
+ !session ? undefined : [session, paytoHash], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+const example1: TalerExchangeApi.AmlDecisionDetails = {
+ aml_history: [
+ {
+ justification: "Lack of documentation",
+ decider_pub: "ASDASDASD",
+ decision_time: {
+ t_s: Date.now() / 1000,
+ },
+ new_state: 2,
+ new_threshold: "USD:0" as AmountString,
+ },
+ {
+ justification: "Doing a transfer of high amount",
+ decider_pub: "ASDASDASD",
+ decision_time: {
+ t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 6,
+ },
+ new_state: 1,
+ new_threshold: "USD:2000" as AmountString,
+ },
+ {
+ justification: "Account is known to the system",
+ decider_pub: "ASDASDASD",
+ decision_time: {
+ t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 9,
+ },
+ new_state: 0,
+ new_threshold: "USD:100" as AmountString,
+ },
+ ],
+ kyc_attributes: [
+ {
+ collection_time: {
+ t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 8,
+ },
+ expiration_time: {
+ t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 4,
+ },
+ provider_section: "asdasd",
+ attributes: {
+ name: "Sebastian",
+ },
+ },
+ {
+ collection_time: {
+ t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 5,
+ },
+ expiration_time: {
+ t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 2,
+ },
+ provider_section: "asdasd",
+ attributes: {
+ creditCard: "12312312312",
+ },
+ },
+ ],
+};
+
+
diff --git a/packages/aml-backoffice-ui/src/hooks/useCases.ts b/packages/aml-backoffice-ui/src/hooks/useCases.ts
new file mode 100644
index 000000000..2bc9b5f0f
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/hooks/useCases.ts
@@ -0,0 +1,86 @@
+import { useState } from "preact/hooks";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { OfficerAccount, OfficerId, OperationOk, TalerExchangeResultByMethod, TalerHttpError, decodeCrock, encodeCrock } from "@gnu-taler/taler-util";
+import _useSWR, { SWRHook } from "swr";
+import { useExchangeApiContext } from "../context/config.js";
+import { AmlExchangeBackend } from "../utils/types.js";
+import { useOfficer } from "./useOfficer.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export const PAGINATED_LIST_SIZE = 10;
+// when doing paginated request, ask for one more
+// and use it to know if there are more to request
+export const PAGINATED_LIST_REQUEST = PAGINATED_LIST_SIZE + 1;
+
+/**
+ * FIXME: mutate result when balance change (transaction )
+ * @param account
+ * @param args
+ * @returns
+ */
+export function useCases(state: AmlExchangeBackend.AmlState) {
+ const officer = useOfficer();
+ const session = officer.state === "ready" ? officer.account : undefined;
+ const { api } = useExchangeApiContext();
+
+ const [offset, setOffset] = useState<string>();
+
+ async function fetcher([officer, state, offset]: [OfficerAccount, AmlExchangeBackend.AmlState, string | undefined]) {
+ return await api.getDecisionsByState(officer, state, {
+ order: "asc", offset, limit: PAGINATED_LIST_REQUEST
+ })
+ }
+
+ const { data, error } = useSWR<TalerExchangeResultByMethod<"getDecisionsByState">, TalerHttpError>(
+ !session ? undefined : [session, state, offset, "getDecisionsByState"],
+ fetcher,
+ );
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return buildPaginatedResult(data.body.records, offset, setOffset, (d) => String(d.rowid));
+}
+
+type PaginatedResult<T> = OperationOk<T> & {
+ isLastPage: boolean;
+ isFirstPage: boolean;
+ loadNext(): void;
+ loadFirst(): void;
+}
+
+//TODO: consider sending this to web-util
+export function buildPaginatedResult<R, OffId>(data: R[], offset: OffId | undefined, setOffset: (o: OffId | undefined) => void, getId: (r: R) => OffId): PaginatedResult<R[]> {
+
+ const isLastPage = data.length < PAGINATED_LIST_REQUEST;
+ const isFirstPage = offset === undefined;
+
+ const result = structuredClone(data);
+ if (result.length == PAGINATED_LIST_REQUEST) {
+ result.pop();
+ }
+ return {
+ type: "ok",
+ body: result,
+ isLastPage,
+ isFirstPage,
+ loadNext: () => {
+ if (!result.length) return;
+ const id = getId(result[result.length - 1])
+ setOffset(id);
+ },
+ loadFirst: () => {
+ setOffset(undefined);
+ },
+ };
+}
+
+
+function removeLastElement<T>(list: Array<T>): Array<T> {
+ if (list.length === 0) {
+ return list;
+ }
+ return list.slice(0, -1)
+} \ No newline at end of file
diff --git a/packages/aml-backoffice-ui/src/hooks/useOfficer.ts b/packages/aml-backoffice-ui/src/hooks/useOfficer.ts
new file mode 100644
index 000000000..1bf2b308b
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/hooks/useOfficer.ts
@@ -0,0 +1,135 @@
+import {
+ AbsoluteTime,
+ Codec,
+ LockedAccount,
+ OfficerAccount,
+ OfficerId,
+ SigningKey,
+ buildCodecForObject,
+ codecForAbsoluteTime,
+ codecForString,
+ createNewOfficerAccount,
+ decodeCrock,
+ encodeCrock,
+ unlockOfficerAccount
+} from "@gnu-taler/taler-util";
+import {
+ buildStorageKey,
+ useLocalStorage
+} from "@gnu-taler/web-util/browser";
+import { useMemo } from "preact/hooks";
+import { useMaybeExchangeApiContext } from "../context/config.js";
+
+export interface Officer {
+ account: LockedAccount;
+ when: AbsoluteTime;
+}
+
+const codecForLockedAccount = codecForString() as Codec<LockedAccount>;
+
+type OfficerAccountString = {
+ id: string,
+ strKey: string;
+}
+
+export const codecForOfficerAccount = (): Codec<OfficerAccountString> =>
+ buildCodecForObject<OfficerAccountString>()
+ .property("id", codecForString()) // FIXME
+ .property("strKey", codecForString()) // FIXME
+ .build("OfficerAccount");
+
+export const codecForOfficer = (): Codec<Officer> =>
+ buildCodecForObject<Officer>()
+ .property("account", codecForLockedAccount) // FIXME
+ .property("when", codecForAbsoluteTime) // FIXME
+ .build("Officer");
+
+export type OfficerState = OfficerNotReady | OfficerReady;
+export type OfficerNotReady = OfficerNotFound | OfficerLocked;
+interface OfficerNotFound {
+ state: "not-found";
+ create: (password: string) => Promise<void>;
+}
+interface OfficerLocked {
+ state: "locked";
+ forget: () => void;
+ tryUnlock: (password: string) => Promise<void>;
+}
+interface OfficerReady {
+ state: "ready";
+ account: OfficerAccount;
+ forget: () => void;
+ lock: () => void;
+}
+
+const OFFICER_KEY = buildStorageKey("officer", codecForOfficer());
+const DEV_ACCOUNT_KEY = buildStorageKey("account-dev", codecForOfficerAccount());
+
+export function useOfficer(): OfficerState {
+ const exchangeContext = useMaybeExchangeApiContext();
+ // dev account, is save when reloaded.
+ const accountStorage = useLocalStorage(DEV_ACCOUNT_KEY);
+ const account = useMemo(() => {
+ if (!accountStorage.value) return undefined
+
+ return {
+ id: accountStorage.value.id as OfficerId,
+ signingKey: decodeCrock(accountStorage.value.strKey) as SigningKey
+ }
+ }, [accountStorage.value?.id, accountStorage.value?.strKey])
+
+ const officerStorage = useLocalStorage(OFFICER_KEY);
+ const officer = useMemo(() => {
+ if (!officerStorage.value) return undefined
+ return officerStorage.value
+ }, [officerStorage.value?.account, officerStorage.value?.when.t_ms])
+
+ if (officer === undefined) {
+ return {
+ state: "not-found",
+ create: async (pwd: string) => {
+ if (!exchangeContext) return;
+ const req = await fetch(new URL("seed", exchangeContext.api.baseUrl).href)
+ const b = await req.blob()
+ const ar = await b.arrayBuffer()
+ const uintar = new Uint8Array(ar)
+
+ const { id, safe, signingKey } = await createNewOfficerAccount(pwd, uintar);
+ officerStorage.update({
+ account: safe,
+ when: AbsoluteTime.now(),
+ });
+
+ // accountStorage.update({ id, signingKey });
+ const strKey = encodeCrock(signingKey)
+ accountStorage.update({ id, strKey })
+ },
+ };
+ }
+
+ if (account === undefined) {
+ return {
+ state: "locked",
+ forget: () => {
+ officerStorage.reset();
+ },
+ tryUnlock: async (pwd: string) => {
+ const ac = await unlockOfficerAccount(officer.account, pwd);
+ // accountStorage.update(ac);
+ accountStorage.update({ id: ac.id, strKey: encodeCrock(ac.signingKey) })
+ },
+ };
+ }
+
+ return {
+ state: "ready",
+ account,
+ lock: () => {
+ accountStorage.reset();
+ },
+ forget: () => {
+ officerStorage.reset();
+ accountStorage.reset();
+ },
+ };
+}
diff --git a/packages/aml-backoffice-ui/src/hooks/useSettings.ts b/packages/aml-backoffice-ui/src/hooks/useSettings.ts
new file mode 100644
index 000000000..f1610576e
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/hooks/useSettings.ts
@@ -0,0 +1,74 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Codec,
+ TranslatedString,
+ buildCodecForObject,
+ codecForBoolean,
+ codecForNumber,
+ codecForString,
+ codecOptional
+} from "@gnu-taler/taler-util";
+import { buildStorageKey, useLocalStorage, useTranslationContext } from "@gnu-taler/web-util/browser";
+
+interface Settings {
+ allowInsecurePassword: boolean;
+ keepSessionAfterReload: boolean;
+}
+
+export function getAllBooleanSettings(): Array<keyof Settings> {
+ return ["allowInsecurePassword", "keepSessionAfterReload"]
+}
+
+export function getLabelForSetting(k: keyof Settings, i18n: ReturnType<typeof useTranslationContext>["i18n"]): TranslatedString {
+ switch (k) {
+ case "allowInsecurePassword": return i18n.str`Allow Insecure password`
+ case "keepSessionAfterReload": return i18n.str`Keep session after reload`
+ }
+}
+
+export const codecForSettings = (): Codec<Settings> =>
+ buildCodecForObject<Settings>()
+ .property("allowInsecurePassword", (codecForBoolean()))
+ .property("keepSessionAfterReload", (codecForBoolean()))
+ .build("Settings");
+
+const defaultSettings: Settings = {
+ allowInsecurePassword: false,
+ keepSessionAfterReload: false,
+};
+
+const EXCHANGE_SETTINGS_KEY = buildStorageKey(
+ "exchange-settings",
+ codecForSettings(),
+);
+
+export function useSettings(): [
+ Readonly<Settings>,
+ <T extends keyof Settings>(key: T, value: Settings[T]) => void,
+] {
+ const { value, update } = useLocalStorage(
+ EXCHANGE_SETTINGS_KEY,
+ defaultSettings,
+ );
+
+ function updateField<T extends keyof Settings>(k: T, v: Settings[T]) {
+ const newValue = { ...value, [k]: v };
+ update(newValue);
+ }
+ return [value, updateField];
+}
diff --git a/packages/aml-backoffice-ui/src/i18n/bank.pot b/packages/aml-backoffice-ui/src/i18n/bank.pot
new file mode 100644
index 000000000..66e98976f
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/i18n/bank.pot
@@ -0,0 +1,486 @@
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Bank\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+#: src/pages/home/BankFrame.tsx:55
+#, c-format
+msgid "Logout"
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:73
+#, c-format
+msgid "Skip to main content"
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:82
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would work. "
+"In addition to using your own bank account, you can also see the transaction "
+"history of some %1$s."
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:94
+#, c-format
+msgid "Taler logo"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:41
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:42
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:49
+#, c-format
+msgid "Please login!"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:51
+#, c-format
+msgid "Username:"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:71
+#, c-format
+msgid "Password:"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:100
+#, c-format
+msgid "Login"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:110
+#, c-format
+msgid "Register"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:60
+#, c-format
+msgid "Missing IBAN"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:62
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:64
+#, c-format
+msgid "Missing subject"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:66
+#, c-format
+msgid "Missing amount"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:68
+#, c-format
+msgid "Amount is not valid"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:70
+#, c-format
+msgid "Should be greater than 0"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:79
+#, c-format
+msgid "Receiver IBAN:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "Transfer subject:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:123
+#, c-format
+msgid "Amount:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:177
+#, c-format
+msgid "Field(s) missing."
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:227
+#, c-format
+msgid "Want to try the raw payto://-format?"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:235
+#, c-format
+msgid "Missing payto address"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:237
+#, c-format
+msgid "Payto does not follow the pattern"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:243
+#, c-format
+msgid "Transfer money to account identified by payto:// URI:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:246
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:255
+#, c-format
+msgid "payto address"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:279
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:314
+#, c-format
+msgid "Use wire-transfer form?"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:373
+#, c-format
+msgid "No credentials found."
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:397
+#, c-format
+msgid "Could not create the wire transfer"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:414
+#, c-format
+msgid "Transfer creation gave response error"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:426
+#, c-format
+msgid "Wire transfer created!"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:50
+#, c-format
+msgid "Amount to withdraw:"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:128
+#, c-format
+msgid "No credentials given."
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:155
+#, c-format
+msgid "Could not create withdrawal operation"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:171
+#, c-format
+msgid "Withdrawal creation gave response error"
+msgstr ""
+
+#: src/pages/home/PaymentOptions.tsx:44
+#, c-format
+msgid "Obtain digital cash"
+msgstr ""
+
+#: src/pages/home/PaymentOptions.tsx:52
+#, c-format
+msgid "Transfer to bank account"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:69
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:70
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:72
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/pages/home/QrCodeSection.tsx:41
+#, c-format
+msgid "Transfer to Taler Wallet"
+msgstr ""
+
+#: src/pages/home/QrCodeSection.tsx:44
+#, c-format
+msgid "Use this QR code to withdraw to your mobile wallet:"
+msgstr ""
+
+#: src/pages/home/QrCodeSection.tsx:47
+#, c-format
+msgid "Click %1$s to open your Taler wallet!"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:47
+#, c-format
+msgid "Confirm Withdrawal"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:52
+#, c-format
+msgid "Authorize withdrawal by solving challenge"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:55
+#, c-format
+msgid "What is"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:94
+#, c-format
+msgid "Answer is wrong."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:99
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:113
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:120
+#, c-format
+msgid ""
+"A this point, a %1$s bank would ask for an additional authentication proof "
+"(PIN/TAN, one time password, ..), instead of a simple calculation."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:166
+#, c-format
+msgid "No withdrawal ID found."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:201
+#, c-format
+msgid "Could not confirm the withdrawal"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:219
+#, c-format
+msgid "Withdrawal confirmation gave response error"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:231
+#, c-format
+msgid "Withdrawal confirmed!"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:294
+#, c-format
+msgid "Could not abort the withdrawal."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:311
+#, c-format
+msgid "Withdrawal abortion failed."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:324
+#, c-format
+msgid "Withdrawal aborted!"
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:54
+#, c-format
+msgid "Abort"
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:74
+#, c-format
+msgid "withdrawal (%1$s) was never (correctly) created at the bank..."
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "Waiting the bank to create the operation..."
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:102
+#, c-format
+msgid "This withdrawal was aborted!"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:40
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:133
+#, c-format
+msgid "Username or account label '%1$s' not found. Won't login."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:159
+#, c-format
+msgid "Wrong credentials given."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:169
+#, c-format
+msgid "Account information could not be retrieved."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:210
+#, c-format
+msgid "Welcome, %1$s !"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:221
+#, c-format
+msgid "Bank account balance"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:237
+#, c-format
+msgid "Payments"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:243
+#, c-format
+msgid "Latest transactions:"
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:83
+#, c-format
+msgid "List of public accounts was not found."
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:95
+#, c-format
+msgid "List of public accounts could not be retrieved."
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:143
+#, c-format
+msgid "History of public accounts"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:39
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:68
+#, c-format
+msgid "Use only letter and numbers starting with a lower case letter"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:78
+#, c-format
+msgid "Password don't match"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:89
+#, c-format
+msgid "Please register!"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:126
+#, c-format
+msgid "Repeat Password:"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:226
+#, c-format
+msgid "Registration failed, please report"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:239
+#, c-format
+msgid "That username is already taken"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:248
+#, c-format
+msgid "New registration gave response error"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:53
+#, c-format
+msgid "Bank menu"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:59
+#, c-format
+msgid "Select option1"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:66
+#, c-format
+msgid "Select option2"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
diff --git a/packages/aml-backoffice-ui/src/i18n/de.po b/packages/aml-backoffice-ui/src/i18n/de.po
new file mode 100644
index 000000000..dc76f83e2
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/i18n/de.po
@@ -0,0 +1,486 @@
+# This file is part of GNU Taler
+# (C) 2021 Taler Systems S.A.
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2022-12-26 23:30+0000\n"
+"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
+"Language-Team: German <https://weblate.taler.net/projects/gnu-taler/"
+"taler-bank-spa/de/>\n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.13.1\n"
+
+#: src/pages/home/BankFrame.tsx:55
+#, c-format
+msgid "Logout"
+msgstr "Abmelden"
+
+#: src/pages/home/BankFrame.tsx:73
+#, c-format
+msgid "Skip to main content"
+msgstr "Navigationsmenü überspringen"
+
+#: src/pages/home/BankFrame.tsx:82
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work. In addition to using your own bank account, you can also see the "
+"transaction history of some %1$s."
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:94
+#, c-format
+msgid "Taler logo"
+msgstr "Taler-Logo"
+
+#: src/pages/home/LoginForm.tsx:41
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:42
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:49
+#, c-format
+msgid "Please login!"
+msgstr "Bitte melden Sie sich an!"
+
+#: src/pages/home/LoginForm.tsx:51
+#, c-format
+msgid "Username:"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:71
+#, c-format
+msgid "Password:"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:100
+#, c-format
+msgid "Login"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:110
+#, c-format
+msgid "Register"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:60
+#, c-format
+msgid "Missing IBAN"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:62
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:64
+#, c-format
+msgid "Missing subject"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:66
+#, c-format
+msgid "Missing amount"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:68
+#, c-format
+msgid "Amount is not valid"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:70
+#, c-format
+msgid "Should be greater than 0"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:79
+#, c-format
+msgid "Receiver IBAN:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "Transfer subject:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:123
+#, c-format
+msgid "Amount:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:177
+#, c-format
+msgid "Field(s) missing."
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:227
+#, c-format
+msgid "Want to try the raw payto://-format?"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:235
+#, c-format
+msgid "Missing payto address"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:237
+#, c-format
+msgid "Payto does not follow the pattern"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:243
+#, c-format
+msgid "Transfer money to account identified by payto:// URI:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:246
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:255
+#, c-format
+msgid "payto address"
+msgstr "payto-Adresse"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:279
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:314
+#, c-format
+msgid "Use wire-transfer form?"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:373
+#, c-format
+msgid "No credentials found."
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:397
+#, c-format
+msgid "Could not create the wire transfer"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:414
+#, c-format
+msgid "Transfer creation gave response error"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:426
+#, c-format
+msgid "Wire transfer created!"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:50
+#, c-format
+msgid "Amount to withdraw:"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:128
+#, c-format
+msgid "No credentials given."
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:155
+#, c-format
+msgid "Could not create withdrawal operation"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:171
+#, c-format
+msgid "Withdrawal creation gave response error"
+msgstr ""
+
+#: src/pages/home/PaymentOptions.tsx:44
+#, c-format
+msgid "Obtain digital cash"
+msgstr ""
+
+#: src/pages/home/PaymentOptions.tsx:52
+#, c-format
+msgid "Transfer to bank account"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:69
+#, c-format
+msgid "Date"
+msgstr "Datum"
+
+#: src/pages/home/Transactions.tsx:70
+#, c-format
+msgid "Amount"
+msgstr "Betrag"
+
+#: src/pages/home/Transactions.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr "Empfänger"
+
+#: src/pages/home/Transactions.tsx:72
+#, c-format
+msgid "Subject"
+msgstr "Verwendungszweck"
+
+#: src/pages/home/QrCodeSection.tsx:41
+#, c-format
+msgid "Transfer to Taler Wallet"
+msgstr ""
+
+#: src/pages/home/QrCodeSection.tsx:44
+#, c-format
+msgid "Use this QR code to withdraw to your mobile wallet:"
+msgstr ""
+
+#: src/pages/home/QrCodeSection.tsx:47
+#, c-format
+msgid "Click %1$s to open your Taler wallet!"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:47
+#, c-format
+msgid "Confirm Withdrawal"
+msgstr "Abhebung bestätigen"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:52
+#, c-format
+msgid "Authorize withdrawal by solving challenge"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:55
+#, c-format
+msgid "What is"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:94
+#, c-format
+msgid "Answer is wrong."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:99
+#, c-format
+msgid "Confirm"
+msgstr "Bestätigen"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:113
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:120
+#, c-format
+msgid ""
+"A this point, a %1$s bank would ask for an additional authentication proof "
+"(PIN/TAN, one time password, ..), instead of a simple calculation."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:166
+#, c-format
+msgid "No withdrawal ID found."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:201
+#, c-format
+msgid "Could not confirm the withdrawal"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:219
+#, c-format
+msgid "Withdrawal confirmation gave response error"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:231
+#, c-format
+msgid "Withdrawal confirmed!"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:294
+#, c-format
+msgid "Could not abort the withdrawal."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:311
+#, c-format
+msgid "Withdrawal abortion failed."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:324
+#, c-format
+msgid "Withdrawal aborted!"
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:54
+#, c-format
+msgid "Abort"
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:74
+#, c-format
+msgid "withdrawal (%1$s) was never (correctly) created at the bank..."
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "Waiting the bank to create the operation..."
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:102
+#, c-format
+msgid "This withdrawal was aborted!"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:40
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:133
+#, c-format
+msgid "Username or account label '%1$s' not found. Won't login."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:159
+#, c-format
+msgid "Wrong credentials given."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:169
+#, c-format
+msgid "Account information could not be retrieved."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:210
+#, c-format
+msgid "Welcome, %1$s !"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:221
+#, c-format
+msgid "Bank account balance"
+msgstr "Kontostand"
+
+#: src/pages/home/AccountPage.tsx:237
+#, c-format
+msgid "Payments"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:243
+#, c-format
+msgid "Latest transactions:"
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:83
+#, c-format
+msgid "List of public accounts was not found."
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:95
+#, c-format
+msgid "List of public accounts could not be retrieved."
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:143
+#, c-format
+msgid "History of public accounts"
+msgstr "Buchungen auf öffentlich sichtbaren Konten"
+
+#: src/pages/home/RegistrationPage.tsx:39
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:68
+#, c-format
+msgid "Use only letter and numbers starting with a lower case letter"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:78
+#, c-format
+msgid "Password don't match"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:89
+#, c-format
+msgid "Please register!"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:126
+#, c-format
+msgid "Repeat Password:"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:226
+#, c-format
+msgid "Registration failed, please report"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:239
+#, c-format
+msgid "That username is already taken"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:248
+#, c-format
+msgid "New registration gave response error"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:53
+#, c-format
+msgid "Bank menu"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:59
+#, c-format
+msgid "Select option1"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:66
+#, c-format
+msgid "Select option2"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
diff --git a/packages/aml-backoffice-ui/src/i18n/en.po b/packages/aml-backoffice-ui/src/i18n/en.po
new file mode 100644
index 000000000..83778f785
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/i18n/en.po
@@ -0,0 +1,511 @@
+# This file is part of GNU Taler
+# (C) 2021 Taler Systems S.A.
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2022-01-08 09:57+0100\n"
+"Last-Translator: <translate@taler.net>\n"
+"Language-Team: English\n"
+"Language: en\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: src/pages/home/BankFrame.tsx:55
+#, c-format
+msgid "Logout"
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:73
+#, c-format
+msgid "Skip to main content"
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:82
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work. In addition to using your own bank account, you can also see the "
+"transaction history of some %1$s."
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:94
+#, c-format
+msgid "Taler logo"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:41
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:42
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:49
+#, c-format
+msgid "Please login!"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:51
+#, c-format
+msgid "Username:"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:71
+#, c-format
+msgid "Password:"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:100
+#, c-format
+msgid "Login"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:110
+#, c-format
+msgid "Register"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:60
+#, c-format
+msgid "Missing IBAN"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:62
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:64
+#, c-format
+msgid "Missing subject"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:66
+#, c-format
+msgid "Missing amount"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:68
+#, c-format
+msgid "Amount is not valid"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:70
+#, c-format
+msgid "Should be greater than 0"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:79
+#, c-format
+msgid "Receiver IBAN:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "Transfer subject:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:123
+#, c-format
+msgid "Amount:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:177
+#, c-format
+msgid "Field(s) missing."
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:227
+#, c-format
+msgid "Want to try the raw payto://-format?"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:235
+#, c-format
+msgid "Missing payto address"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:237
+#, c-format
+msgid "Payto does not follow the pattern"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:243
+#, c-format
+msgid "Transfer money to account identified by payto:// URI:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:246
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:255
+#, c-format
+msgid "payto address"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:279
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:314
+#, c-format
+msgid "Use wire-transfer form?"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:373
+#, c-format
+msgid "No credentials found."
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:397
+#, c-format
+msgid "Could not create the wire transfer"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:414
+#, c-format
+msgid "Transfer creation gave response error"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:426
+#, c-format
+msgid "Wire transfer created!"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:50
+#, fuzzy, c-format
+msgid "Amount to withdraw:"
+msgstr "Amount to withdraw"
+
+#: src/pages/home/WalletWithdrawForm.tsx:84
+#, fuzzy, c-format
+msgid "Withdraw"
+msgstr "Confirm withdrawal"
+
+#: src/pages/home/WalletWithdrawForm.tsx:128
+#, c-format
+msgid "No credentials given."
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:155
+#, c-format
+msgid "Could not create withdrawal operation"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:171
+#, c-format
+msgid "Withdrawal creation gave response error"
+msgstr ""
+
+#: src/pages/home/PaymentOptions.tsx:44
+#, c-format
+msgid "Obtain digital cash"
+msgstr ""
+
+#: src/pages/home/PaymentOptions.tsx:52
+#, c-format
+msgid "Transfer to bank account"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:69
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:70
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:72
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/pages/home/QrCodeSection.tsx:41
+#, fuzzy, c-format
+msgid "Transfer to Taler Wallet"
+msgstr "Charge Taler wallet"
+
+#: src/pages/home/QrCodeSection.tsx:44
+#, c-format
+msgid "Use this QR code to withdraw to your mobile wallet:"
+msgstr ""
+
+#: src/pages/home/QrCodeSection.tsx:47
+#, c-format
+msgid "Click %1$s to open your Taler wallet!"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:47
+#, fuzzy, c-format
+msgid "Confirm Withdrawal"
+msgstr "Confirm withdrawal"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:52
+#, c-format
+msgid "Authorize withdrawal by solving challenge"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:55
+#, c-format
+msgid "What is"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:94
+#, c-format
+msgid "Answer is wrong."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:99
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:113
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:120
+#, c-format
+msgid ""
+"A this point, a %1$s bank would ask for an additional authentication proof "
+"(PIN/TAN, one time password, ..), instead of a simple calculation."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:166
+#, c-format
+msgid "No withdrawal ID found."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:201
+#, fuzzy, c-format
+msgid "Could not confirm the withdrawal"
+msgstr "Confirm withdrawal"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:219
+#, c-format
+msgid "Withdrawal confirmation gave response error"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:231
+#, c-format
+msgid "Withdrawal confirmed!"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:294
+#, fuzzy, c-format
+msgid "Could not abort the withdrawal."
+msgstr "Close Taler withdrawal"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:311
+#, c-format
+msgid "Withdrawal abortion failed."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:324
+#, c-format
+msgid "Withdrawal aborted!"
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:54
+#, c-format
+msgid "Abort"
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:74
+#, c-format
+msgid "withdrawal (%1$s) was never (correctly) created at the bank..."
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "Waiting the bank to create the operation..."
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:102
+#, c-format
+msgid "This withdrawal was aborted!"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:40
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:133
+#, c-format
+msgid "Username or account label '%1$s' not found. Won't login."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:159
+#, c-format
+msgid "Wrong credentials given."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:169
+#, c-format
+msgid "Account information could not be retrieved."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:210
+#, c-format
+msgid "Welcome, %1$s !"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:221
+#, c-format
+msgid "Bank account balance"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:237
+#, c-format
+msgid "Payments"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:243
+#, c-format
+msgid "Latest transactions:"
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:83
+#, c-format
+msgid "List of public accounts was not found."
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:95
+#, c-format
+msgid "List of public accounts could not be retrieved."
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:143
+#, c-format
+msgid "History of public accounts"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:39
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:68
+#, c-format
+msgid "Use only letter and numbers starting with a lower case letter"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:78
+#, c-format
+msgid "Password don't match"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:89
+#, c-format
+msgid "Please register!"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:126
+#, c-format
+msgid "Repeat Password:"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:226
+#, c-format
+msgid "Registration failed, please report"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:239
+#, c-format
+msgid "That username is already taken"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:248
+#, c-format
+msgid "New registration gave response error"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:53
+#, c-format
+msgid "Bank menu"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:59
+#, c-format
+msgid "Select option1"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:66
+#, c-format
+msgid "Select option2"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr "days"
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr "hours"
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr "minutes"
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr "seconds"
+
+#~ msgid "Go back"
+#~ msgstr "Go back"
+
+#, fuzzy
+#~ msgid "Start withdrawal"
+#~ msgstr "Start withdrawal"
+
+#, fuzzy
+#~ msgid "Withdraw Money into a Taler wallet"
+#~ msgstr "Charge Taler wallet"
+
+#~ msgid "Page has a problem: logged in but backend state is lost."
+#~ msgstr "Page has a problem: logged in but backend state is lost."
+
+#, fuzzy
+#~ msgid "Welcome to the euFin bank!"
+#~ msgstr "Welcome to euFin bank: Taler+IBAN now possible!"
+
+#~ msgid "Page has a problem:"
+#~ msgstr "Page has a problem:"
+
+#~ msgid "Close"
+#~ msgstr "Close"
+
+#~ msgid "Sign in"
+#~ msgstr "Sign in"
diff --git a/packages/aml-backoffice-ui/src/i18n/es.po b/packages/aml-backoffice-ui/src/i18n/es.po
new file mode 100644
index 000000000..0787b1035
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/i18n/es.po
@@ -0,0 +1,497 @@
+# This file is part of GNU Taler
+# (C) 2021 Taler Systems S.A.
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2022-12-09 14:13+0000\n"
+"Last-Translator: Sebastian Marchano <sebasjm@gmail.com>\n"
+"Language-Team: Spanish <https://weblate.taler.net/projects/gnu-taler/taler-"
+"bank-spa/es/>\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.13.1\n"
+
+#: src/pages/home/BankFrame.tsx:55
+#, c-format
+msgid "Logout"
+msgstr "Cierre de sesión"
+
+#: src/pages/home/BankFrame.tsx:73
+#, c-format
+msgid "Skip to main content"
+msgstr "Saltar el menú de navegación"
+
+#: src/pages/home/BankFrame.tsx:82
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work. In addition to using your own bank account, you can also see the "
+"transaction history of some %1$s."
+msgstr ""
+"Esta parte de la demostración muestra cómo funciona un banco que soporta "
+"Taler directamente. Además de usar tu propia cuenta de banco, también podrás "
+"ver el historial de transacciones de algunas %1$s."
+
+#: src/pages/home/BankFrame.tsx:94
+#, c-format
+msgid "Taler logo"
+msgstr "Logo Taler"
+
+#: src/pages/home/LoginForm.tsx:41
+#, c-format
+msgid "Missing username"
+msgstr "Falta nombre de usuario"
+
+#: src/pages/home/LoginForm.tsx:42
+#, c-format
+msgid "Missing password"
+msgstr "Falta contraseña"
+
+#: src/pages/home/LoginForm.tsx:49
+#, c-format
+msgid "Please login!"
+msgstr "Por favor inicia sesión!"
+
+#: src/pages/home/LoginForm.tsx:51
+#, c-format
+msgid "Username:"
+msgstr "Nombre de usuario:"
+
+#: src/pages/home/LoginForm.tsx:71
+#, c-format
+msgid "Password:"
+msgstr "Password:"
+
+#: src/pages/home/LoginForm.tsx:100
+#, c-format
+msgid "Login"
+msgstr "Iniciar sesión"
+
+#: src/pages/home/LoginForm.tsx:110
+#, c-format
+msgid "Register"
+msgstr "Registrarse"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:60
+#, c-format
+msgid "Missing IBAN"
+msgstr "Falta IBAN"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:62
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr "IBAN debería tener letras mayúsculas y números"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:64
+#, c-format
+msgid "Missing subject"
+msgstr "Falta asunto"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:66
+#, c-format
+msgid "Missing amount"
+msgstr "Falta monto"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:68
+#, c-format
+msgid "Amount is not valid"
+msgstr "Monto no válido"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:70
+#, c-format
+msgid "Should be greater than 0"
+msgstr "Debería ser mas grande que 0"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:79
+#, c-format
+msgid "Receiver IBAN:"
+msgstr "IBAN receptor:"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "Transfer subject:"
+msgstr "Asunto de transferencia:"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:123
+#, c-format
+msgid "Amount:"
+msgstr "Monto:"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:177
+#, c-format
+msgid "Field(s) missing."
+msgstr "Faltan campo(s)."
+
+#: src/pages/home/PaytoWireTransferForm.tsx:227
+#, c-format
+msgid "Want to try the raw payto://-format?"
+msgstr "Quieres probar el formato payto:// ?"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:235
+#, c-format
+msgid "Missing payto address"
+msgstr "Falta direccion payto"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:237
+#, c-format
+msgid "Payto does not follow the pattern"
+msgstr "Payto no sigue el patrón"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:243
+#, c-format
+msgid "Transfer money to account identified by payto:// URI:"
+msgstr "Transferir dinero a la cuenta identificada por la URI payto://:"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:246
+#, c-format
+msgid "payto URI:"
+msgstr "payto URI:"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:255
+#, c-format
+msgid "payto address"
+msgstr "direccion payto"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:279
+#, c-format
+msgid "Send"
+msgstr "Envíar"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:314
+#, c-format
+msgid "Use wire-transfer form?"
+msgstr "Usar el formulario de transferencia bancaria?"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:373
+#, c-format
+msgid "No credentials found."
+msgstr "Se dieron las credenciales incorrectas."
+
+#: src/pages/home/PaytoWireTransferForm.tsx:397
+#, c-format
+msgid "Could not create the wire transfer"
+msgstr "No se pudo create la transferencia bancaria"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:414
+#, c-format
+msgid "Transfer creation gave response error"
+msgstr "La creación de la transferencia dió una respuesta erronea"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:426
+#, c-format
+msgid "Wire transfer created!"
+msgstr "Transferencia bancaria creada!"
+
+#: src/pages/home/WalletWithdrawForm.tsx:50
+#, c-format
+msgid "Amount to withdraw:"
+msgstr "Monto a retirar:"
+
+#: src/pages/home/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "Withdraw"
+msgstr "Retirar"
+
+#: src/pages/home/WalletWithdrawForm.tsx:128
+#, c-format
+msgid "No credentials given."
+msgstr "Se dieron las credenciales incorrectas."
+
+#: src/pages/home/WalletWithdrawForm.tsx:155
+#, c-format
+msgid "Could not create withdrawal operation"
+msgstr "No se pude create la operación de retiro"
+
+#: src/pages/home/WalletWithdrawForm.tsx:171
+#, c-format
+msgid "Withdrawal creation gave response error"
+msgstr "La creación de retiro dió una respuesta errónea"
+
+#: src/pages/home/PaymentOptions.tsx:44
+#, c-format
+msgid "Obtain digital cash"
+msgstr "Obtener dinero digital"
+
+#: src/pages/home/PaymentOptions.tsx:52
+#, c-format
+msgid "Transfer to bank account"
+msgstr "Transferir a una cuenta bancaria"
+
+#: src/pages/home/Transactions.tsx:69
+#, c-format
+msgid "Date"
+msgstr "Fecha"
+
+#: src/pages/home/Transactions.tsx:70
+#, c-format
+msgid "Amount"
+msgstr "Monto"
+
+#: src/pages/home/Transactions.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr "Contraparte"
+
+#: src/pages/home/Transactions.tsx:72
+#, c-format
+msgid "Subject"
+msgstr "Asunto"
+
+#: src/pages/home/QrCodeSection.tsx:41
+#, c-format
+msgid "Transfer to Taler Wallet"
+msgstr "Transferir a una cartera Taler"
+
+#: src/pages/home/QrCodeSection.tsx:44
+#, c-format
+msgid "Use this QR code to withdraw to your mobile wallet:"
+msgstr "Usar el código QR para retirar a tu cartera móvil:"
+
+#: src/pages/home/QrCodeSection.tsx:47
+#, c-format
+msgid "Click %1$s to open your Taler wallet!"
+msgstr "Click %1$s para abrir una cartera Taler!"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:47
+#, c-format
+msgid "Confirm Withdrawal"
+msgstr "Confirmar retirada"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:52
+#, c-format
+msgid "Authorize withdrawal by solving challenge"
+msgstr "Autorizar retiro resolviendo una pregunta"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:55
+#, c-format
+msgid "What is"
+msgstr "Cuanto es"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:94
+#, c-format
+msgid "Answer is wrong."
+msgstr "La respuesta es incorrecta."
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:99
+#, c-format
+msgid "Confirm"
+msgstr "Confirmar"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:113
+#, c-format
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:120
+#, c-format
+msgid ""
+"A this point, a %1$s bank would ask for an additional authentication proof "
+"(PIN/TAN, one time password, ..), instead of a simple calculation."
+msgstr ""
+"En este punto, un banco %1$s preguntaría por una prueba adicional de "
+"autenticación (PIN/TAN, password de un solo uso, ....), en vez de un simple "
+"cálculo."
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:166
+#, c-format
+msgid "No withdrawal ID found."
+msgstr "No ID de retiro encontrado."
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:201
+#, c-format
+msgid "Could not confirm the withdrawal"
+msgstr "No se pudo confirmar la retirada"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:219
+#, c-format
+msgid "Withdrawal confirmation gave response error"
+msgstr "La confirmación de retiro dió una respuesta errónea"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:231
+#, c-format
+msgid "Withdrawal confirmed!"
+msgstr "El retiro fue confirmado!"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:294
+#, c-format
+msgid "Could not abort the withdrawal."
+msgstr "No se pudo cancelar el retiro."
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:311
+#, c-format
+msgid "Withdrawal abortion failed."
+msgstr "La cancelación del retiro falló."
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:324
+#, c-format
+msgid "Withdrawal aborted!"
+msgstr "Este retiro fue cancelado!"
+
+#: src/pages/home/WithdrawalQRCode.tsx:54
+#, c-format
+msgid "Abort"
+msgstr "Cancelar"
+
+#: src/pages/home/WithdrawalQRCode.tsx:74
+#, c-format
+msgid "withdrawal (%1$s) was never (correctly) created at the bank..."
+msgstr "retiro (%1$s) nunca fue (correctamente) generado en el banco..."
+
+#: src/pages/home/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "Waiting the bank to create the operation..."
+msgstr "Esperando que el banco genere la operación...."
+
+#: src/pages/home/WithdrawalQRCode.tsx:102
+#, c-format
+msgid "This withdrawal was aborted!"
+msgstr "Este retiro fue cancelado!"
+
+#: src/pages/home/AccountPage.tsx:40
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr "Bienvenido a %1$s!"
+
+#: src/pages/home/AccountPage.tsx:133
+#, c-format
+msgid "Username or account label '%1$s' not found. Won't login."
+msgstr ""
+"Nombre de usuario o etiqueta de cuenta '%1$s' no encontrada. No se iniciará "
+"sesión."
+
+#: src/pages/home/AccountPage.tsx:159
+#, c-format
+msgid "Wrong credentials given."
+msgstr "Se dieron las credenciales incorrectas."
+
+#: src/pages/home/AccountPage.tsx:169
+#, c-format
+msgid "Account information could not be retrieved."
+msgstr "La información de la cuenta no pudo ser accedida."
+
+#: src/pages/home/AccountPage.tsx:210
+#, c-format
+msgid "Welcome, %1$s !"
+msgstr "Bienvenido/a, %1$s!"
+
+#: src/pages/home/AccountPage.tsx:221
+#, c-format
+msgid "Bank account balance"
+msgstr "Balance de cuenta bancaria"
+
+#: src/pages/home/AccountPage.tsx:237
+#, c-format
+msgid "Payments"
+msgstr "Pagos"
+
+#: src/pages/home/AccountPage.tsx:243
+#, c-format
+msgid "Latest transactions:"
+msgstr "Últimas transacciones:"
+
+#: src/pages/home/PublicHistoriesPage.tsx:83
+#, c-format
+msgid "List of public accounts was not found."
+msgstr "La lista de cuentas públicas no fue encontrada."
+
+#: src/pages/home/PublicHistoriesPage.tsx:95
+#, c-format
+msgid "List of public accounts could not be retrieved."
+msgstr "La lista de cuentas públicas no pudo ser accedida."
+
+#: src/pages/home/PublicHistoriesPage.tsx:143
+#, c-format
+msgid "History of public accounts"
+msgstr "Historial de cuentas públicas"
+
+#: src/pages/home/RegistrationPage.tsx:39
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr "Actualmente, el banco no está aceptado nuevos registros!"
+
+#: src/pages/home/RegistrationPage.tsx:68
+#, c-format
+msgid "Use only letter and numbers starting with a lower case letter"
+msgstr "Solo use letras y números comenzando con una letra minúscula"
+
+#: src/pages/home/RegistrationPage.tsx:78
+#, c-format
+msgid "Password don't match"
+msgstr "La contraseña no coincide"
+
+#: src/pages/home/RegistrationPage.tsx:89
+#, c-format
+msgid "Please register!"
+msgstr "Por favor, registrese!"
+
+#: src/pages/home/RegistrationPage.tsx:126
+#, c-format
+msgid "Repeat Password:"
+msgstr "Repita la contraseña:"
+
+#: src/pages/home/RegistrationPage.tsx:226
+#, c-format
+msgid "Registration failed, please report"
+msgstr "El registro falló, por favor reportelo"
+
+#: src/pages/home/RegistrationPage.tsx:239
+#, c-format
+msgid "That username is already taken"
+msgstr "El nombre del usuario ya está tomado"
+
+#: src/pages/home/RegistrationPage.tsx:248
+#, c-format
+msgid "New registration gave response error"
+msgstr "Nuevo registro dió una respuesta errónea"
+
+#: src/components/menu/SideBar.tsx:53
+#, c-format
+msgid "Bank menu"
+msgstr "Menu del banco"
+
+#: src/components/menu/SideBar.tsx:59
+#, c-format
+msgid "Select option1"
+msgstr "Seleccione opción 1"
+
+#: src/components/menu/SideBar.tsx:66
+#, c-format
+msgid "Select option2"
+msgstr "Seleccione opción 2"
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr "días"
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr "horas"
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr "minutos"
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr "segundos"
+
+#~ msgid "this link"
+#~ msgstr "este link"
diff --git a/packages/aml-backoffice-ui/src/i18n/fr.po b/packages/aml-backoffice-ui/src/i18n/fr.po
new file mode 100644
index 000000000..203d55343
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/i18n/fr.po
@@ -0,0 +1,486 @@
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Bank\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: Automatically generated\n"
+"Language-Team: none\n"
+"Language: fr\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n > 1;\n"
+
+#: src/pages/home/BankFrame.tsx:55
+#, c-format
+msgid "Logout"
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:73
+#, c-format
+msgid "Skip to main content"
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:82
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would work. "
+"In addition to using your own bank account, you can also see the transaction "
+"history of some %1$s."
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:94
+#, c-format
+msgid "Taler logo"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:41
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:42
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:49
+#, c-format
+msgid "Please login!"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:51
+#, c-format
+msgid "Username:"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:71
+#, c-format
+msgid "Password:"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:100
+#, c-format
+msgid "Login"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:110
+#, c-format
+msgid "Register"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:60
+#, c-format
+msgid "Missing IBAN"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:62
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:64
+#, c-format
+msgid "Missing subject"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:66
+#, c-format
+msgid "Missing amount"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:68
+#, c-format
+msgid "Amount is not valid"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:70
+#, c-format
+msgid "Should be greater than 0"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:79
+#, c-format
+msgid "Receiver IBAN:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "Transfer subject:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:123
+#, c-format
+msgid "Amount:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:177
+#, c-format
+msgid "Field(s) missing."
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:227
+#, c-format
+msgid "Want to try the raw payto://-format?"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:235
+#, c-format
+msgid "Missing payto address"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:237
+#, c-format
+msgid "Payto does not follow the pattern"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:243
+#, c-format
+msgid "Transfer money to account identified by payto:// URI:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:246
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:255
+#, c-format
+msgid "payto address"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:279
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:314
+#, c-format
+msgid "Use wire-transfer form?"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:373
+#, c-format
+msgid "No credentials found."
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:397
+#, c-format
+msgid "Could not create the wire transfer"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:414
+#, c-format
+msgid "Transfer creation gave response error"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:426
+#, c-format
+msgid "Wire transfer created!"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:50
+#, c-format
+msgid "Amount to withdraw:"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:128
+#, c-format
+msgid "No credentials given."
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:155
+#, c-format
+msgid "Could not create withdrawal operation"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:171
+#, c-format
+msgid "Withdrawal creation gave response error"
+msgstr ""
+
+#: src/pages/home/PaymentOptions.tsx:44
+#, c-format
+msgid "Obtain digital cash"
+msgstr ""
+
+#: src/pages/home/PaymentOptions.tsx:52
+#, c-format
+msgid "Transfer to bank account"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:69
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:70
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:72
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/pages/home/QrCodeSection.tsx:41
+#, c-format
+msgid "Transfer to Taler Wallet"
+msgstr ""
+
+#: src/pages/home/QrCodeSection.tsx:44
+#, c-format
+msgid "Use this QR code to withdraw to your mobile wallet:"
+msgstr ""
+
+#: src/pages/home/QrCodeSection.tsx:47
+#, c-format
+msgid "Click %1$s to open your Taler wallet!"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:47
+#, c-format
+msgid "Confirm Withdrawal"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:52
+#, c-format
+msgid "Authorize withdrawal by solving challenge"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:55
+#, c-format
+msgid "What is"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:94
+#, c-format
+msgid "Answer is wrong."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:99
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:113
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:120
+#, c-format
+msgid ""
+"A this point, a %1$s bank would ask for an additional authentication proof "
+"(PIN/TAN, one time password, ..), instead of a simple calculation."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:166
+#, c-format
+msgid "No withdrawal ID found."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:201
+#, c-format
+msgid "Could not confirm the withdrawal"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:219
+#, c-format
+msgid "Withdrawal confirmation gave response error"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:231
+#, c-format
+msgid "Withdrawal confirmed!"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:294
+#, c-format
+msgid "Could not abort the withdrawal."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:311
+#, c-format
+msgid "Withdrawal abortion failed."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:324
+#, c-format
+msgid "Withdrawal aborted!"
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:54
+#, c-format
+msgid "Abort"
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:74
+#, c-format
+msgid "withdrawal (%1$s) was never (correctly) created at the bank..."
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "Waiting the bank to create the operation..."
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:102
+#, c-format
+msgid "This withdrawal was aborted!"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:40
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:133
+#, c-format
+msgid "Username or account label '%1$s' not found. Won't login."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:159
+#, c-format
+msgid "Wrong credentials given."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:169
+#, c-format
+msgid "Account information could not be retrieved."
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:210
+#, c-format
+msgid "Welcome, %1$s !"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:221
+#, c-format
+msgid "Bank account balance"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:237
+#, c-format
+msgid "Payments"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:243
+#, c-format
+msgid "Latest transactions:"
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:83
+#, c-format
+msgid "List of public accounts was not found."
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:95
+#, c-format
+msgid "List of public accounts could not be retrieved."
+msgstr ""
+
+#: src/pages/home/PublicHistoriesPage.tsx:143
+#, c-format
+msgid "History of public accounts"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:39
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:68
+#, c-format
+msgid "Use only letter and numbers starting with a lower case letter"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:78
+#, c-format
+msgid "Password don't match"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:89
+#, c-format
+msgid "Please register!"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:126
+#, c-format
+msgid "Repeat Password:"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:226
+#, c-format
+msgid "Registration failed, please report"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:239
+#, c-format
+msgid "That username is already taken"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:248
+#, c-format
+msgid "New registration gave response error"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:53
+#, c-format
+msgid "Bank menu"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:59
+#, c-format
+msgid "Select option1"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:66
+#, c-format
+msgid "Select option2"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
diff --git a/packages/aml-backoffice-ui/src/i18n/it.po b/packages/aml-backoffice-ui/src/i18n/it.po
new file mode 100644
index 000000000..a3a599376
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/i18n/it.po
@@ -0,0 +1,521 @@
+# This file is part of GNU Taler
+# (C) 2021 Taler Systems S.A.
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2022-12-26 23:30+0000\n"
+"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
+"Language-Team: Italian <https://weblate.taler.net/projects/gnu-taler/"
+"taler-bank-spa/it/>\n"
+"Language: it\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.13.1\n"
+
+#: src/pages/home/BankFrame.tsx:55
+#, c-format
+msgid "Logout"
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:73
+#, c-format
+msgid "Skip to main content"
+msgstr "Saltare il menu di navigazione"
+
+#: src/pages/home/BankFrame.tsx:82
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work. In addition to using your own bank account, you can also see the "
+"transaction history of some %1$s."
+msgstr ""
+
+#: src/pages/home/BankFrame.tsx:94
+#, c-format
+msgid "Taler logo"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:41
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:42
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:49
+#, c-format
+msgid "Please login!"
+msgstr "Accedi!"
+
+#: src/pages/home/LoginForm.tsx:51
+#, c-format
+msgid "Username:"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:71
+#, c-format
+msgid "Password:"
+msgstr ""
+
+#: src/pages/home/LoginForm.tsx:100
+#, c-format
+msgid "Login"
+msgstr "Accedi"
+
+#: src/pages/home/LoginForm.tsx:110
+#, c-format
+msgid "Register"
+msgstr "Registrati"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:60
+#, c-format
+msgid "Missing IBAN"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:62
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:64
+#, c-format
+msgid "Missing subject"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:66
+#, c-format
+msgid "Missing amount"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:68
+#, c-format
+msgid "Amount is not valid"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:70
+#, c-format
+msgid "Should be greater than 0"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:79
+#, c-format
+msgid "Receiver IBAN:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "Transfer subject:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:123
+#, fuzzy, c-format
+msgid "Amount:"
+msgstr "Somma"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:177
+#, c-format
+msgid "Field(s) missing."
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:227
+#, c-format
+msgid "Want to try the raw payto://-format?"
+msgstr "Prova il trasferimento tramite il formato Payto!"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:235
+#, fuzzy, c-format
+msgid "Missing payto address"
+msgstr "indirizzo Payto"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:237
+#, c-format
+msgid "Payto does not follow the pattern"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:243
+#, fuzzy, c-format
+msgid "Transfer money to account identified by payto:// URI:"
+msgstr "Trasferisci fondi a un altro conto di questa banca:"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:246
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:255
+#, c-format
+msgid "payto address"
+msgstr "indirizzo Payto"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:279
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:314
+#, fuzzy, c-format
+msgid "Use wire-transfer form?"
+msgstr "Chiudi il bonifico"
+
+#: src/pages/home/PaytoWireTransferForm.tsx:373
+#, fuzzy, c-format
+msgid "No credentials found."
+msgstr "Credenziali invalide."
+
+#: src/pages/home/PaytoWireTransferForm.tsx:397
+#, c-format
+msgid "Could not create the wire transfer"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:414
+#, c-format
+msgid "Transfer creation gave response error"
+msgstr ""
+
+#: src/pages/home/PaytoWireTransferForm.tsx:426
+#, fuzzy, c-format
+msgid "Wire transfer created!"
+msgstr "Bonifico"
+
+#: src/pages/home/WalletWithdrawForm.tsx:50
+#, fuzzy, c-format
+msgid "Amount to withdraw:"
+msgstr "Somma da ritirare"
+
+#: src/pages/home/WalletWithdrawForm.tsx:84
+#, fuzzy, c-format
+msgid "Withdraw"
+msgstr "Conferma il ritiro"
+
+#: src/pages/home/WalletWithdrawForm.tsx:128
+#, fuzzy, c-format
+msgid "No credentials given."
+msgstr "Credenziali invalide."
+
+#: src/pages/home/WalletWithdrawForm.tsx:155
+#, c-format
+msgid "Could not create withdrawal operation"
+msgstr ""
+
+#: src/pages/home/WalletWithdrawForm.tsx:171
+#, c-format
+msgid "Withdrawal creation gave response error"
+msgstr ""
+
+#: src/pages/home/PaymentOptions.tsx:44
+#, c-format
+msgid "Obtain digital cash"
+msgstr ""
+
+#: src/pages/home/PaymentOptions.tsx:52
+#, fuzzy, c-format
+msgid "Transfer to bank account"
+msgstr "Trasferisci fondi a un altro conto di questa banca:"
+
+#: src/pages/home/Transactions.tsx:69
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/pages/home/Transactions.tsx:70
+#, c-format
+msgid "Amount"
+msgstr "Somma"
+
+#: src/pages/home/Transactions.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr "Controparte"
+
+#: src/pages/home/Transactions.tsx:72
+#, c-format
+msgid "Subject"
+msgstr "Causale"
+
+#: src/pages/home/QrCodeSection.tsx:41
+#, fuzzy, c-format
+msgid "Transfer to Taler Wallet"
+msgstr "Ritira contante nel portafoglio Taler"
+
+#: src/pages/home/QrCodeSection.tsx:44
+#, fuzzy, c-format
+msgid "Use this QR code to withdraw to your mobile wallet:"
+msgstr "Usa questo codice QR per ritirare contante nel tuo wallet:"
+
+#: src/pages/home/QrCodeSection.tsx:47
+#, c-format
+msgid "Click %1$s to open your Taler wallet!"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:47
+#, c-format
+msgid "Confirm Withdrawal"
+msgstr "Conferma il ritiro"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:52
+#, c-format
+msgid "Authorize withdrawal by solving challenge"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:55
+#, c-format
+msgid "What is"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:94
+#, c-format
+msgid "Answer is wrong."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:99
+#, c-format
+msgid "Confirm"
+msgstr "Conferma"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:113
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:120
+#, c-format
+msgid ""
+"A this point, a %1$s bank would ask for an additional authentication proof "
+"(PIN/TAN, one time password, ..), instead of a simple calculation."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:166
+#, c-format
+msgid "No withdrawal ID found."
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:201
+#, fuzzy, c-format
+msgid "Could not confirm the withdrawal"
+msgstr "Conferma il ritiro"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:219
+#, c-format
+msgid "Withdrawal confirmation gave response error"
+msgstr ""
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:231
+#, fuzzy, c-format
+msgid "Withdrawal confirmed!"
+msgstr "Questo ritiro è stato annullato!"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:294
+#, fuzzy, c-format
+msgid "Could not abort the withdrawal."
+msgstr "Chiudi il ritiro Taler"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:311
+#, fuzzy, c-format
+msgid "Withdrawal abortion failed."
+msgstr "Questo ritiro è stato annullato!"
+
+#: src/pages/home/WithdrawalConfirmationQuestion.tsx:324
+#, fuzzy, c-format
+msgid "Withdrawal aborted!"
+msgstr "Questo ritiro è stato annullato!"
+
+#: src/pages/home/WithdrawalQRCode.tsx:54
+#, c-format
+msgid "Abort"
+msgstr "Annulla"
+
+#: src/pages/home/WithdrawalQRCode.tsx:74
+#, c-format
+msgid "withdrawal (%1$s) was never (correctly) created at the bank..."
+msgstr ""
+
+#: src/pages/home/WithdrawalQRCode.tsx:88
+#, fuzzy, c-format
+msgid "Waiting the bank to create the operation..."
+msgstr "La banca sta creando l'operazione..."
+
+#: src/pages/home/WithdrawalQRCode.tsx:102
+#, c-format
+msgid "This withdrawal was aborted!"
+msgstr "Questo ritiro è stato annullato!"
+
+#: src/pages/home/AccountPage.tsx:40
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:133
+#, c-format
+msgid "Username or account label '%1$s' not found. Won't login."
+msgstr "L'utente '%1$s' non esiste. Login impossibile"
+
+#: src/pages/home/AccountPage.tsx:159
+#, c-format
+msgid "Wrong credentials given."
+msgstr "Credenziali invalide."
+
+#: src/pages/home/AccountPage.tsx:169
+#, c-format
+msgid "Account information could not be retrieved."
+msgstr "Impossibile ricevere le informazioni relative al conto."
+
+#: src/pages/home/AccountPage.tsx:210
+#, c-format
+msgid "Welcome, %1$s !"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:221
+#, fuzzy, c-format
+msgid "Bank account balance"
+msgstr "Bilancio:"
+
+#: src/pages/home/AccountPage.tsx:237
+#, c-format
+msgid "Payments"
+msgstr ""
+
+#: src/pages/home/AccountPage.tsx:243
+#, c-format
+msgid "Latest transactions:"
+msgstr "Ultime transazioni:"
+
+#: src/pages/home/PublicHistoriesPage.tsx:83
+#, c-format
+msgid "List of public accounts was not found."
+msgstr "Lista conti pubblici non trovata."
+
+#: src/pages/home/PublicHistoriesPage.tsx:95
+#, c-format
+msgid "List of public accounts could not be retrieved."
+msgstr "Lista conti pubblici non pervenuta."
+
+#: src/pages/home/PublicHistoriesPage.tsx:143
+#, c-format
+msgid "History of public accounts"
+msgstr "Storico dei conti pubblici"
+
+#: src/pages/home/RegistrationPage.tsx:39
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:68
+#, c-format
+msgid "Use only letter and numbers starting with a lower case letter"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:78
+#, c-format
+msgid "Password don't match"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:89
+#, fuzzy, c-format
+msgid "Please register!"
+msgstr "Accedi!"
+
+#: src/pages/home/RegistrationPage.tsx:126
+#, c-format
+msgid "Repeat Password:"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:226
+#, fuzzy, c-format
+msgid "Registration failed, please report"
+msgstr "Registrazione"
+
+#: src/pages/home/RegistrationPage.tsx:239
+#, c-format
+msgid "That username is already taken"
+msgstr ""
+
+#: src/pages/home/RegistrationPage.tsx:248
+#, c-format
+msgid "New registration gave response error"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:53
+#, c-format
+msgid "Bank menu"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:59
+#, c-format
+msgid "Select option1"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:66
+#, c-format
+msgid "Select option2"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#~ msgid "this link"
+#~ msgstr "questo link"
+
+#~ msgid "Clear"
+#~ msgstr "Cancella"
+
+#~ msgid "Demo Bank"
+#~ msgstr "Banca 'demo'"
+
+#~ msgid "Go back"
+#~ msgstr "Indietro"
+
+#~ msgid "Transfer money via the Payto system:"
+#~ msgstr "Effettua un bonifico tramite il sistema Payto:"
+
+#~ msgid "Start withdrawal"
+#~ msgstr "Ritira contante"
+
+#~ msgid "Withdraw Money into a Taler wallet"
+#~ msgstr "Ritira contante nel portafoglio Taler"
+
+#~ msgid "Register to the euFin bank!"
+#~ msgstr "Apri un conto in banca euFin!"
+
+#~ msgid "Transfer money manually"
+#~ msgstr "Effettua un bonifico"
+
+#~ msgid "Page has a problem: logged in but backend state is lost."
+#~ msgstr ""
+#~ "Stato inconsistente: accesso utente effettuato ma stato con server perso."
+
+#, fuzzy
+#~ msgid "Welcome to the euFin bank!"
+#~ msgstr "Benvenuti in banca euFin!"
diff --git a/packages/aml-backoffice-ui/src/i18n/poheader b/packages/aml-backoffice-ui/src/i18n/poheader
new file mode 100644
index 000000000..a251e9584
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/i18n/poheader
@@ -0,0 +1,26 @@
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Bank\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
diff --git a/packages/taler-wallet-webextension/src/permissions.ts b/packages/aml-backoffice-ui/src/i18n/strings-prelude
index bcd357fd6..a0aeb8268 100644
--- a/packages/taler-wallet-webextension/src/permissions.ts
+++ b/packages/aml-backoffice-ui/src/i18n/strings-prelude
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,7 +14,6 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-export const extendedPermissions = {
- permissions: ["webRequest", "webRequestBlocking"],
- origins: ["http://*/*", "https://*/*"],
-};
+/*eslint quote-props: ["error", "consistent"]*/
+export const strings: {[s: string]: any} = {};
+
diff --git a/packages/aml-backoffice-ui/src/i18n/strings.ts b/packages/aml-backoffice-ui/src/i18n/strings.ts
new file mode 100644
index 000000000..a779bbc49
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/i18n/strings.ts
@@ -0,0 +1,510 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/*eslint quote-props: ["error", "consistent"]*/
+export const strings: { [s: string]: any } = {};
+
+strings["de"] = {
+ domain: "messages",
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=(n != 1);",
+ lang: "de",
+ },
+ Logout: [""],
+ "Skip to main content": [""],
+ "This part of the demo shows how a bank that supports Taler directly would work. In addition to using your own bank account, you can also see the transaction history of some %1$s.":
+ [""],
+ "Taler logo": [""],
+ "Missing username": [""],
+ "Missing password": [""],
+ "Please login!": [""],
+ "Username:": [""],
+ "Password:": [""],
+ Login: [""],
+ Register: [""],
+ "Missing IBAN": [""],
+ "IBAN should have just uppercased letters and numbers": [""],
+ "Missing subject": [""],
+ "Missing amount": [""],
+ "Amount is not valid": [""],
+ "Should be greater than 0": [""],
+ "Receiver IBAN:": [""],
+ "Transfer subject:": [""],
+ "Amount:": [""],
+ "Field(s) missing.": [""],
+ "Want to try the raw payto://-format?": [""],
+ "Missing payto address": [""],
+ "Payto does not follow the pattern": [""],
+ "Transfer money to account identified by payto:// URI:": [""],
+ "payto URI:": [""],
+ "payto address": [""],
+ Send: [""],
+ "Use wire-transfer form?": [""],
+ "No credentials found.": [""],
+ "Could not create the wire transfer": [""],
+ "Transfer creation gave response error": [""],
+ "Wire transfer created!": [""],
+ "Amount to withdraw:": [""],
+ Withdraw: [""],
+ "No credentials given.": [""],
+ "Could not create withdrawal operation": [""],
+ "Withdrawal creation gave response error": [""],
+ "Obtain digital cash": [""],
+ "Transfer to bank account": [""],
+ Date: [""],
+ Amount: [""],
+ Counterpart: [""],
+ Subject: [""],
+ "Transfer to Taler Wallet": [""],
+ "Use this QR code to withdraw to your mobile wallet:": [""],
+ "Click %1$s to open your Taler wallet!": [""],
+ "Confirm Withdrawal": [""],
+ "Authorize withdrawal by solving challenge": [""],
+ "What is": [""],
+ "Answer is wrong.": [""],
+ Confirm: [""],
+ Cancel: [""],
+ "A this point, a %1$s bank would ask for an additional authentication proof (PIN/TAN, one time password, ..), instead of a simple calculation.":
+ [""],
+ "No withdrawal ID found.": [""],
+ "Could not confirm the withdrawal": [""],
+ "Withdrawal confirmation gave response error": [""],
+ "Withdrawal confirmed!": [""],
+ "Could not abort the withdrawal.": [""],
+ "Withdrawal abortion failed.": [""],
+ "Withdrawal aborted!": [""],
+ Abort: [""],
+ "withdrawal (%1$s) was never (correctly) created at the bank...": [""],
+ "Waiting the bank to create the operation...": [""],
+ "This withdrawal was aborted!": [""],
+ "Welcome to %1$s!": [""],
+ "Username or account label '%1$s' not found. Won't login.": [""],
+ "Wrong credentials given.": [""],
+ "Account information could not be retrieved.": [""],
+ "Welcome, %1$s !": [""],
+ "Bank account balance": [""],
+ Payments: [""],
+ "Latest transactions:": [""],
+ "List of public accounts was not found.": [""],
+ "List of public accounts could not be retrieved.": [""],
+ "History of public accounts": [""],
+ "Currently, the bank is not accepting new registrations!": [""],
+ "Use only letter and numbers starting with a lower case letter": [""],
+ "Password don't match": [""],
+ "Please register!": [""],
+ "Repeat Password:": [""],
+ "Registration failed, please report": [""],
+ "That username is already taken": [""],
+ "New registration gave response error": [""],
+ "Bank menu": [""],
+ "Select option1": [""],
+ "Select option2": [""],
+ days: [""],
+ hours: [""],
+ minutes: [""],
+ seconds: [""],
+ },
+ },
+};
+
+strings["en"] = {
+ domain: "messages",
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=(n != 1);",
+ lang: "en",
+ },
+ Logout: [""],
+ "Skip to main content": [""],
+ "This part of the demo shows how a bank that supports Taler directly would work. In addition to using your own bank account, you can also see the transaction history of some %1$s.":
+ [""],
+ "Taler logo": [""],
+ "Missing username": [""],
+ "Missing password": [""],
+ "Please login!": [""],
+ "Username:": [""],
+ "Password:": [""],
+ Login: [""],
+ Register: [""],
+ "Missing IBAN": [""],
+ "IBAN should have just uppercased letters and numbers": [""],
+ "Missing subject": [""],
+ "Missing amount": [""],
+ "Amount is not valid": [""],
+ "Should be greater than 0": [""],
+ "Receiver IBAN:": [""],
+ "Transfer subject:": [""],
+ "Amount:": [""],
+ "Field(s) missing.": [""],
+ "Want to try the raw payto://-format?": [""],
+ "Missing payto address": [""],
+ "Payto does not follow the pattern": [""],
+ "Transfer money to account identified by payto:// URI:": [""],
+ "payto URI:": [""],
+ "payto address": [""],
+ Send: [""],
+ "Use wire-transfer form?": [""],
+ "No credentials found.": [""],
+ "Could not create the wire transfer": [""],
+ "Transfer creation gave response error": [""],
+ "Wire transfer created!": [""],
+ "Amount to withdraw:": ["Amount to withdraw"],
+ Withdraw: ["Confirm withdrawal"],
+ "No credentials given.": [""],
+ "Could not create withdrawal operation": [""],
+ "Withdrawal creation gave response error": [""],
+ "Obtain digital cash": [""],
+ "Transfer to bank account": [""],
+ Date: [""],
+ Amount: [""],
+ Counterpart: [""],
+ Subject: [""],
+ "Transfer to Taler Wallet": ["Charge Taler wallet"],
+ "Use this QR code to withdraw to your mobile wallet:": [""],
+ "Click %1$s to open your Taler wallet!": [""],
+ "Confirm Withdrawal": ["Confirm withdrawal"],
+ "Authorize withdrawal by solving challenge": [""],
+ "What is": [""],
+ "Answer is wrong.": [""],
+ Confirm: [""],
+ Cancel: [""],
+ "A this point, a %1$s bank would ask for an additional authentication proof (PIN/TAN, one time password, ..), instead of a simple calculation.":
+ [""],
+ "No withdrawal ID found.": [""],
+ "Could not confirm the withdrawal": ["Confirm withdrawal"],
+ "Withdrawal confirmation gave response error": [""],
+ "Withdrawal confirmed!": [""],
+ "Could not abort the withdrawal.": ["Close Taler withdrawal"],
+ "Withdrawal abortion failed.": [""],
+ "Withdrawal aborted!": [""],
+ Abort: [""],
+ "withdrawal (%1$s) was never (correctly) created at the bank...": [""],
+ "Waiting the bank to create the operation...": [""],
+ "This withdrawal was aborted!": [""],
+ "Welcome to %1$s!": [""],
+ "Username or account label '%1$s' not found. Won't login.": [""],
+ "Wrong credentials given.": [""],
+ "Account information could not be retrieved.": [""],
+ "Welcome, %1$s !": [""],
+ "Bank account balance": [""],
+ Payments: [""],
+ "Latest transactions:": [""],
+ "List of public accounts was not found.": [""],
+ "List of public accounts could not be retrieved.": [""],
+ "History of public accounts": [""],
+ "Currently, the bank is not accepting new registrations!": [""],
+ "Use only letter and numbers starting with a lower case letter": [""],
+ "Password don't match": [""],
+ "Please register!": [""],
+ "Repeat Password:": [""],
+ "Registration failed, please report": [""],
+ "That username is already taken": [""],
+ "New registration gave response error": [""],
+ "Bank menu": [""],
+ "Select option1": [""],
+ "Select option2": [""],
+ days: ["days"],
+ hours: ["hours"],
+ minutes: ["minutes"],
+ seconds: ["seconds"],
+ },
+ },
+};
+
+strings["es"] = {
+ domain: "messages",
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "es",
+ },
+ Logout: ["Cierre de sesión"],
+ "Skip to main content": ["Saltar el menú de navegación"],
+ "This part of the demo shows how a bank that supports Taler directly would work. In addition to using your own bank account, you can also see the transaction history of some %1$s.":
+ [
+ "Esta parte de la demostración muestra cómo funciona un banco que soporta Taler directamente. Además de usar tu propia cuenta de banco, también podrás ver el historial de transacciones de algunas %1$s.",
+ ],
+ "Taler logo": ["Logo Taler"],
+ "Missing username": ["Falta nombre de usuario"],
+ "Missing password": ["Falta contraseña"],
+ "Please login!": ["Por favor inicia sesión!"],
+ "Username:": ["Nombre de usuario:"],
+ "Password:": ["Password:"],
+ Login: ["Iniciar sesión"],
+ Register: ["Registrarse"],
+ "Missing IBAN": ["Falta IBAN"],
+ "IBAN should have just uppercased letters and numbers": [
+ "IBAN debería tener letras mayúsculas y números",
+ ],
+ "Missing subject": ["Falta asunto"],
+ "Missing amount": ["Falta monto"],
+ "Amount is not valid": ["Monto no válido"],
+ "Should be greater than 0": ["Debería ser mas grande que 0"],
+ "Receiver IBAN:": ["IBAN receptor:"],
+ "Transfer subject:": ["Asunto de transferencia:"],
+ "Amount:": ["Monto:"],
+ "Field(s) missing.": ["Faltan campo(s)."],
+ "Want to try the raw payto://-format?": [
+ "Quieres probar el formato payto:// ?",
+ ],
+ "Missing payto address": ["Falta direccion payto"],
+ "Payto does not follow the pattern": ["Payto no sigue el patrón"],
+ "Transfer money to account identified by payto:// URI:": [
+ "Transferir dinero a la cuenta identificada por la URI payto://:",
+ ],
+ "payto URI:": ["payto URI:"],
+ "payto address": ["direccion payto"],
+ Send: ["Envíar"],
+ "Use wire-transfer form?": [
+ "Usar el formulario de transferencia bancaria?",
+ ],
+ "No credentials found.": ["Se dieron las credenciales incorrectas."],
+ "Could not create the wire transfer": [
+ "No se pudo create la transferencia bancaria",
+ ],
+ "Transfer creation gave response error": [
+ "La creación de la transferencia dió una respuesta erronea",
+ ],
+ "Wire transfer created!": ["Transferencia bancaria creada!"],
+ "Amount to withdraw:": ["Monto a retirar:"],
+ Withdraw: ["Retirar"],
+ "No credentials given.": ["Se dieron las credenciales incorrectas."],
+ "Could not create withdrawal operation": [
+ "No se pude create la operación de retiro",
+ ],
+ "Withdrawal creation gave response error": [
+ "La creación de retiro dió una respuesta errónea",
+ ],
+ "Obtain digital cash": ["Obtener dinero digital"],
+ "Transfer to bank account": ["Transferir a una cuenta bancaria"],
+ Date: ["Fecha"],
+ Amount: ["Monto"],
+ Counterpart: ["Contraparte"],
+ Subject: ["Asunto"],
+ "Transfer to Taler Wallet": ["Transferir a una cartera Taler"],
+ "Use this QR code to withdraw to your mobile wallet:": [
+ "Usar el código QR para retirar a tu cartera móvil:",
+ ],
+ "Click %1$s to open your Taler wallet!": [
+ "Click %1$s para abrir una cartera Taler!",
+ ],
+ "Confirm Withdrawal": ["Confirmar retirada"],
+ "Authorize withdrawal by solving challenge": [
+ "Autorizar retiro resolviendo una pregunta",
+ ],
+ "What is": ["Cuanto es"],
+ "Answer is wrong.": ["La respuesta es incorrecta."],
+ Confirm: ["Confirmar"],
+ Cancel: ["Cancelar"],
+ "A this point, a %1$s bank would ask for an additional authentication proof (PIN/TAN, one time password, ..), instead of a simple calculation.":
+ [
+ "En este punto, un banco %1$s preguntaría por una prueba adicional de autenticación (PIN/TAN, password de un solo uso, ....), en vez de un simple cálculo.",
+ ],
+ "No withdrawal ID found.": ["No ID de retiro encontrado."],
+ "Could not confirm the withdrawal": ["No se pudo confirmar la retirada"],
+ "Withdrawal confirmation gave response error": [
+ "La confirmación de retiro dió una respuesta errónea",
+ ],
+ "Withdrawal confirmed!": ["El retiro fue confirmado!"],
+ "Could not abort the withdrawal.": ["No se pudo cancelar el retiro."],
+ "Withdrawal abortion failed.": ["La cancelación del retiro falló."],
+ "Withdrawal aborted!": ["Este retiro fue cancelado!"],
+ Abort: ["Cancelar"],
+ "withdrawal (%1$s) was never (correctly) created at the bank...": [
+ "retiro (%1$s) nunca fue (correctamente) generado en el banco...",
+ ],
+ "Waiting the bank to create the operation...": [
+ "Esperando que el banco genere la operación....",
+ ],
+ "This withdrawal was aborted!": ["Este retiro fue cancelado!"],
+ "Welcome to %1$s!": ["Bienvenido a %1$s!"],
+ "Username or account label '%1$s' not found. Won't login.": [
+ "Nombre de usuario o etiqueta de cuenta '%1$s' no encontrada. No se iniciará sesión.",
+ ],
+ "Wrong credentials given.": ["Se dieron las credenciales incorrectas."],
+ "Account information could not be retrieved.": [
+ "La información de la cuenta no pudo ser accedida.",
+ ],
+ "Welcome, %1$s !": ["Bienvenido/a, %1$s!"],
+ "Bank account balance": ["Balance de cuenta bancaria"],
+ Payments: ["Pagos"],
+ "Latest transactions:": ["Últimas transacciones:"],
+ "List of public accounts was not found.": [
+ "La lista de cuentas públicas no fue encontrada.",
+ ],
+ "List of public accounts could not be retrieved.": [
+ "La lista de cuentas públicas no pudo ser accedida.",
+ ],
+ "History of public accounts": ["Historial de cuentas públicas"],
+ "Currently, the bank is not accepting new registrations!": [
+ "Actualmente, el banco no está aceptado nuevos registros!",
+ ],
+ "Use only letter and numbers starting with a lower case letter": [
+ "Solo use letras y números comenzando con una letra minúscula",
+ ],
+ "Password don't match": ["La contraseña no coincide"],
+ "Please register!": ["Por favor, registrese!"],
+ "Repeat Password:": ["Repita la contraseña:"],
+ "Registration failed, please report": [
+ "El registro falló, por favor reportelo",
+ ],
+ "That username is already taken": [
+ "El nombre del usuario ya está tomado",
+ ],
+ "New registration gave response error": [
+ "Nuevo registro dió una respuesta errónea",
+ ],
+ "Bank menu": ["Menu del banco"],
+ "Select option1": ["Seleccione opción 1"],
+ "Select option2": ["Seleccione opción 2"],
+ days: ["días"],
+ hours: ["horas"],
+ minutes: ["minutos"],
+ seconds: ["segundos"],
+ },
+ },
+};
+
+strings["it"] = {
+ domain: "messages",
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=(n != 1);",
+ lang: "it",
+ },
+ Logout: [""],
+ "Skip to main content": [""],
+ "This part of the demo shows how a bank that supports Taler directly would work. In addition to using your own bank account, you can also see the transaction history of some %1$s.":
+ [""],
+ "Taler logo": [""],
+ "Missing username": [""],
+ "Missing password": [""],
+ "Please login!": ["Accedi!"],
+ "Username:": [""],
+ "Password:": [""],
+ Login: ["Accedi"],
+ Register: ["Registrati"],
+ "Missing IBAN": [""],
+ "IBAN should have just uppercased letters and numbers": [""],
+ "Missing subject": [""],
+ "Missing amount": [""],
+ "Amount is not valid": [""],
+ "Should be greater than 0": [""],
+ "Receiver IBAN:": [""],
+ "Transfer subject:": [""],
+ "Amount:": ["Somma"],
+ "Field(s) missing.": [""],
+ "Want to try the raw payto://-format?": [
+ "Prova il trasferimento tramite il formato Payto!",
+ ],
+ "Missing payto address": ["indirizzo Payto"],
+ "Payto does not follow the pattern": [""],
+ "Transfer money to account identified by payto:// URI:": [
+ "Trasferisci fondi a un altro conto di questa banca:",
+ ],
+ "payto URI:": [""],
+ "payto address": ["indirizzo Payto"],
+ Send: [""],
+ "Use wire-transfer form?": ["Chiudi il bonifico"],
+ "No credentials found.": ["Credenziali invalide."],
+ "Could not create the wire transfer": [""],
+ "Transfer creation gave response error": [""],
+ "Wire transfer created!": ["Bonifico"],
+ "Amount to withdraw:": ["Somma da ritirare"],
+ Withdraw: ["Conferma il ritiro"],
+ "No credentials given.": ["Credenziali invalide."],
+ "Could not create withdrawal operation": [""],
+ "Withdrawal creation gave response error": [""],
+ "Obtain digital cash": [""],
+ "Transfer to bank account": [
+ "Trasferisci fondi a un altro conto di questa banca:",
+ ],
+ Date: [""],
+ Amount: ["Somma"],
+ Counterpart: ["Controparte"],
+ Subject: ["Causale"],
+ "Transfer to Taler Wallet": ["Ritira contante nel portafoglio Taler"],
+ "Use this QR code to withdraw to your mobile wallet:": [
+ "Usa questo codice QR per ritirare contante nel tuo wallet:",
+ ],
+ "Click %1$s to open your Taler wallet!": [""],
+ "Confirm Withdrawal": ["Conferma il ritiro"],
+ "Authorize withdrawal by solving challenge": [""],
+ "What is": [""],
+ "Answer is wrong.": [""],
+ Confirm: ["Conferma"],
+ Cancel: [""],
+ "A this point, a %1$s bank would ask for an additional authentication proof (PIN/TAN, one time password, ..), instead of a simple calculation.":
+ [""],
+ "No withdrawal ID found.": [""],
+ "Could not confirm the withdrawal": ["Conferma il ritiro"],
+ "Withdrawal confirmation gave response error": [""],
+ "Withdrawal confirmed!": ["Questo ritiro è stato annullato!"],
+ "Could not abort the withdrawal.": ["Chiudi il ritiro Taler"],
+ "Withdrawal abortion failed.": ["Questo ritiro è stato annullato!"],
+ "Withdrawal aborted!": ["Questo ritiro è stato annullato!"],
+ Abort: ["Annulla"],
+ "withdrawal (%1$s) was never (correctly) created at the bank...": [""],
+ "Waiting the bank to create the operation...": [
+ "La banca sta creando l'operazione...",
+ ],
+ "This withdrawal was aborted!": ["Questo ritiro è stato annullato!"],
+ "Welcome to %1$s!": [""],
+ "Username or account label '%1$s' not found. Won't login.": [
+ "L'utente '%1$s' non esiste. Login impossibile",
+ ],
+ "Wrong credentials given.": ["Credenziali invalide."],
+ "Account information could not be retrieved.": [
+ "Impossibile ricevere le informazioni relative al conto.",
+ ],
+ "Welcome, %1$s !": [""],
+ "Bank account balance": ["Bilancio:"],
+ Payments: [""],
+ "Latest transactions:": ["Ultime transazioni:"],
+ "List of public accounts was not found.": [
+ "Lista conti pubblici non trovata.",
+ ],
+ "List of public accounts could not be retrieved.": [
+ "Lista conti pubblici non pervenuta.",
+ ],
+ "History of public accounts": ["Storico dei conti pubblici"],
+ "Currently, the bank is not accepting new registrations!": [""],
+ "Use only letter and numbers starting with a lower case letter": [""],
+ "Password don't match": [""],
+ "Please register!": ["Accedi!"],
+ "Repeat Password:": [""],
+ "Registration failed, please report": ["Registrazione"],
+ "That username is already taken": [""],
+ "New registration gave response error": [""],
+ "Bank menu": [""],
+ "Select option1": [""],
+ "Select option2": [""],
+ days: [""],
+ hours: [""],
+ minutes: [""],
+ seconds: [""],
+ },
+ },
+};
diff --git a/packages/aml-backoffice-ui/src/index.html b/packages/aml-backoffice-ui/src/index.html
new file mode 100644
index 000000000..c1de73520
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/index.html
@@ -0,0 +1,42 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <title>Exchange Backoffice</title>
+ <!-- Entry point for the SPA. -->
+ <script type="module" src="forms.js"></script>
+ <script type="module" src="index.js"></script>
+ <link rel="stylesheet" href="index.css" />
+</head>
+
+<body>
+ <div id="app"></div>
+</body>
+
+</html>
diff --git a/packages/aml-backoffice-ui/src/index.tsx b/packages/aml-backoffice-ui/src/index.tsx
new file mode 100644
index 000000000..c2ac4c84b
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/index.tsx
@@ -0,0 +1,22 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { App } from "./App.js";
+import { h, render } from "preact";
+
+const app = document.getElementById("app");
+
+render(<App />, app as any);
diff --git a/packages/aml-backoffice-ui/src/pages.ts b/packages/aml-backoffice-ui/src/pages.ts
new file mode 100644
index 000000000..109cd31d0
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages.ts
@@ -0,0 +1,44 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { AntiMoneyLaunderingForm } from "./pages/AntiMoneyLaunderingForm.js";
+import { CaseDetails } from "./pages/CaseDetails.js";
+import { Cases, HomeIcon, PeopleIcon } from "./pages/Cases.js";
+import { NewFormEntry } from "./pages/NewFormEntry.js";
+import { Officer } from "./pages/Officer.js";
+import { PageEntry, pageDefinition } from "./route.js";
+// import homeLogo from "./assets/home.svg";
+// import peopleLogo from "./assets/people.svg";
+const cases: PageEntry = {
+ url: "#/cases",
+ view: Cases,
+ name: "Cases" as TranslatedString,
+ Icon: HomeIcon,
+};
+
+const officer: PageEntry = {
+ url: "#/officer",
+ view: Officer,
+ name: "Officer" as TranslatedString,
+ Icon: PeopleIcon,
+};
+
+const account: PageEntry<{ account: string }> = {
+ url: pageDefinition("#/account/:account"),
+ view: CaseDetails,
+ name: "Account" as TranslatedString,
+ // icon: () => undefined,
+};
+
+const newFormEntry: PageEntry<{ account?: string; type?: string }> = {
+ url: pageDefinition("#/account/:account/new/:type?"),
+ view: NewFormEntry,
+ name: "New Form" as TranslatedString,
+ // icon: () => undefined,
+};
+
+
+export const Pages = {
+ cases,
+ officer,
+ account,
+ newFormEntry,
+};
diff --git a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx
new file mode 100644
index 000000000..0b055f682
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx
@@ -0,0 +1,104 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ AntiMoneyLaunderingForm as TestedComponent,
+} from "./AntiMoneyLaunderingForm.js";
+
+export default {
+ title: "aml form",
+};
+
+export const SimpleComment = tests.createExample(TestedComponent, {
+ account: "the_account",
+ formId: "simple_comment",
+ onSubmit: async (justification, newState, newThreshold) => {
+ alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
+ }
+});
+
+export const Identification = tests.createExample(TestedComponent, {
+ account: "the_account",
+ formId: "902.1e",
+ onSubmit: async (justification, newState, newThreshold) => {
+ alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
+ }
+});
+
+export const OperationalLegalEntity = tests.createExample(TestedComponent, {
+ account: "the_account",
+ formId: "902.11e",
+
+ onSubmit: async (justification, newState, newThreshold) => {
+ alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
+ }
+});
+export const Foundations = tests.createExample(TestedComponent, {
+ account: "the_account",
+ formId: "902.12e",
+
+ onSubmit: async (justification, newState, newThreshold) => {
+ alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
+ }
+});
+export const DelcarationOfTrusts = tests.createExample(TestedComponent, {
+ account: "the_account",
+ formId: "902.13e",
+
+ onSubmit: async (justification, newState, newThreshold) => {
+ alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
+ }
+});
+
+export const InformationOnLifeInsurance = tests.createExample(TestedComponent, {
+ account: "the_account",
+ formId: "902.15e",
+
+ onSubmit: async (justification, newState, newThreshold) => {
+ alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
+ }
+});
+export const DeclarationOfBeneficialOwner = tests.createExample(TestedComponent, {
+ account: "the_account",
+ formId: "902.9e",
+
+ onSubmit: async (justification, newState, newThreshold) => {
+ alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
+ }
+});
+export const CustomerProfile = tests.createExample(TestedComponent, {
+ account: "the_account",
+ formId: "902.5e",
+
+ onSubmit: async (justification, newState, newThreshold) => {
+ alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
+ }
+});
+export const RiskProfile = tests.createExample(TestedComponent, {
+ account: "the_account",
+ formId: "902.4e",
+
+ onSubmit: async (justification, newState, newThreshold) => {
+ alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2))
+ }
+});
+
diff --git a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx
new file mode 100644
index 000000000..c42b1e7af
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx
@@ -0,0 +1,160 @@
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ Codec,
+ OperationFail,
+ OperationOk,
+ OperationResult,
+ buildCodecForObject,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+} from "@gnu-taler/taler-util";
+import {
+ DefaultForm,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { h } from "preact";
+import { useExchangeApiContext } from "../context/config.js";
+import { FormMetadata, uiForms } from "../forms/declaration.js";
+import { Pages } from "../pages.js";
+import { AmlExchangeBackend } from "../utils/types.js";
+
+export function AntiMoneyLaunderingForm({
+ account,
+ formId,
+ onSubmit,
+}: {
+ account: string;
+ formId: string;
+ onSubmit: (
+ justification: Justification,
+ state: AmlExchangeBackend.AmlState,
+ threshold: AmountJson,
+ ) => Promise<void>;
+}) {
+ const { i18n } = useTranslationContext();
+ const theForm = uiForms.forms(i18n).find((v) => v.id === formId);
+ if (!theForm) {
+ return <div>form with id {formId} not found</div>;
+ }
+
+ const { config } = useExchangeApiContext();
+
+ const initial = {
+ when: AbsoluteTime.now(),
+ state: AmlExchangeBackend.AmlState.pending,
+ threshold: Amounts.zeroOfCurrency(config.currency),
+ };
+ return (
+ <DefaultForm
+ initial={initial}
+ form={theForm.impl(initial)}
+ onUpdate={() => { }}
+ onSubmit={(formValue) => {
+ if (formValue.state === undefined || formValue.threshold === undefined)
+ return;
+ const st = formValue.state;
+ const amount = formValue.threshold;
+
+ const justification: Justification = {
+ id: theForm.id,
+ label: theForm.label,
+ version: theForm.version,
+ value: formValue,
+ };
+
+ onSubmit(justification, st, amount);
+ }}
+ >
+ <div class="mt-6 flex items-center justify-end gap-x-6">
+ <a
+ href={Pages.account.url({ account })}
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ </DefaultForm>
+ );
+}
+
+export type Justification<T = any> = {
+ // form values
+ value: T;
+} & Omit<Omit<FormMetadata<any>, "icon">, "impl">;
+
+export function stringifyJustification(j: Justification): string {
+ return JSON.stringify(j);
+}
+
+type SimpleFormMetadata = {
+ version?: number;
+ id?: string;
+};
+
+export const codecForSimpleFormMetadata = (): Codec<SimpleFormMetadata> =>
+ buildCodecForObject<SimpleFormMetadata>()
+ .property("id", codecOptional(codecForString()))
+ .property("version", codecOptional(codecForNumber()))
+ .build("SimpleFormMetadata");
+
+type ParseJustificationFail =
+ | "not-json"
+ | "id-not-found"
+ | "form-not-found"
+ | "version-not-found";
+
+export function parseJustification(
+ s: string,
+ listOfAllKnownForms: FormMetadata<any>[],
+): OperationOk<{ justification: Justification; metadata: FormMetadata<any> }> | OperationFail<ParseJustificationFail> {
+ try {
+ const justification = JSON.parse(s);
+ const info = codecForSimpleFormMetadata().decode(justification);
+ if (!info.id) {
+ return {
+ type: "fail",
+ case: "id-not-found",
+ detail: {} as any,
+ };
+ }
+ if (!info.version) {
+ return {
+ type: "fail",
+ case: "version-not-found",
+ detail: {} as any,
+ };
+ }
+ const found = listOfAllKnownForms.find((f) => {
+ return f.id === info.id && f.version === info.version;
+ });
+ if (!found) {
+ return {
+ type: "fail",
+ case: "form-not-found",
+ detail: {} as any,
+ };
+ }
+ return {
+ type: "ok",
+ body: {
+ justification,
+ metadata: found,
+ },
+ };
+ } catch (e) {
+ return {
+ type: "fail",
+ case: "not-json",
+ detail: {} as any,
+ };
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
new file mode 100644
index 000000000..0875f047b
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
@@ -0,0 +1,314 @@
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ TranslatedString,
+ assertUnreachable
+} from "@gnu-taler/taler-util";
+import { DefaultForm, ErrorLoading, InternationalizationAPI, Loading, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { FormMetadata } from "../forms/declaration.js";
+import { useCaseDetails } from "../hooks/useCaseDetails.js";
+import { Pages } from "../pages.js";
+import { Justification, parseJustification } from "./AntiMoneyLaunderingForm.js";
+import { ShowConsolidated } from "./ShowConsolidated.js";
+import { AmlExchangeBackend } from "../utils/types.js";
+import { uiForms } from "../forms/declaration.js";
+
+export type AmlEvent = AmlFormEvent | AmlFormEventError | KycCollectionEvent | KycExpirationEvent;
+type AmlFormEvent = {
+ type: "aml-form";
+ when: AbsoluteTime;
+ title: TranslatedString;
+ justification: Justification;
+ metadata: FormMetadata<any>;
+ state: AmlExchangeBackend.AmlState;
+ threshold: AmountJson;
+};
+type AmlFormEventError = {
+ type: "aml-form-error";
+ when: AbsoluteTime;
+ title: TranslatedString;
+ justification: undefined,
+ metadata: undefined,
+ state: AmlExchangeBackend.AmlState;
+ threshold: AmountJson;
+};
+type KycCollectionEvent = {
+ type: "kyc-collection";
+ when: AbsoluteTime;
+ title: TranslatedString;
+ values: object;
+ provider: string;
+};
+type KycExpirationEvent = {
+ type: "kyc-expiration";
+ when: AbsoluteTime;
+ title: TranslatedString;
+ fields: string[];
+};
+
+type WithTime = { when: AbsoluteTime };
+
+function selectSooner(a: WithTime, b: WithTime) {
+ return AbsoluteTime.cmp(a.when, b.when);
+}
+
+function titleForJustification(op: ReturnType<typeof parseJustification>, i18n: InternationalizationAPI): TranslatedString {
+ if (op.type === "ok") {
+ return op.body.justification.label as TranslatedString;
+ }
+ switch (op.case) {
+ case "not-json": return "error: the justification is not a form" as TranslatedString
+ case "id-not-found": return "error: justification form's id not found" as TranslatedString
+ case "version-not-found": return "error: justification form's version not found" as TranslatedString
+ case "form-not-found": return `error: justification form not found` as TranslatedString
+ default: {
+ assertUnreachable(op.case)
+ }
+ }
+}
+
+export function getEventsFromAmlHistory(
+ aml: AmlExchangeBackend.AmlDecisionDetail[],
+ kyc: AmlExchangeBackend.KycDetail[],
+ i18n: InternationalizationAPI,
+): AmlEvent[] {
+ const ae: AmlEvent[] = aml.map((a) => {
+
+ const just = parseJustification(a.justification, uiForms.forms(i18n))
+ return {
+ type: just.type === "ok" ? "aml-form" : "aml-form-error",
+ state: a.new_state,
+ threshold: Amounts.parseOrThrow(a.new_threshold),
+ title: titleForJustification(just, i18n),
+ metadata: just.type === "ok" ? just.body.metadata : undefined,
+ justification: just.type === "ok" ? just.body.justification : undefined,
+ when: {
+ t_ms:
+ a.decision_time.t_s === "never"
+ ? "never"
+ : a.decision_time.t_s * 1000,
+ },
+ } as AmlEvent;
+ });
+ const ke = kyc.reduce((prev, k) => {
+ prev.push({
+ type: "kyc-collection",
+ title: i18n.str`collection`,
+ when: AbsoluteTime.fromProtocolTimestamp(k.collection_time),
+ values: !k.attributes ? {} : k.attributes,
+ provider: k.provider_section,
+ });
+ prev.push({
+ type: "kyc-expiration",
+ title: i18n.str`expiration`,
+ when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time),
+ fields: !k.attributes ? [] : Object.keys(k.attributes),
+ });
+ return prev;
+ }, [] as AmlEvent[]);
+ return ae.concat(ke).sort(selectSooner);
+}
+
+export function CaseDetails({ account }: { account: string }) {
+ const [selected, setSelected] = useState<AbsoluteTime>(AbsoluteTime.now());
+ const [showForm, setShowForm] = useState<{ justification: Justification, metadata: FormMetadata<any> }>()
+
+ const { i18n } = useTranslationContext();
+ const details = useCaseDetails(account)
+ if (!details) {
+ return <Loading />
+ }
+ if (details instanceof TalerError) {
+ return <ErrorLoading error={details} />
+ }
+ if (details.type === "fail") {
+ switch (details.case) {
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.Forbidden:
+ case HttpStatusCode.NotFound:
+ case HttpStatusCode.Conflict: return <div />
+ default: assertUnreachable(details)
+ }
+ }
+ const { aml_history, kyc_attributes } = details.body
+
+ const events = getEventsFromAmlHistory(aml_history, kyc_attributes, i18n);
+
+ if (showForm !== undefined) {
+ return <DefaultForm
+ readOnly={true}
+ initial={showForm.justification.value}
+ form={showForm.metadata.impl(showForm.justification.value)}
+ >
+ <div class="mt-6 flex items-center justify-end gap-x-6">
+ <button
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={() => {
+ setShowForm(undefined)
+ }}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ </div>
+
+ </DefaultForm>
+ }
+ return (
+ <div>
+ <a
+ href={Pages.newFormEntry.url({ account })}
+ class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ >
+ <i18n.Translate>
+ New AML form
+ </i18n.Translate>
+ </a>
+
+ <header class="flex items-center justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8">
+ <h1 class="text-base font-semibold leading-7 text-black">
+ <i18n.Translate>
+ Case history for account <span title={account}>{account.substring(0, 16)}...</span>
+ </i18n.Translate>
+ </h1>
+ </header>
+ <ShowTimeline history={events} onSelect={(e) => {
+ switch (e.type) {
+ case "aml-form": {
+ const { justification, metadata } = e
+ setShowForm({ justification, metadata })
+ break;
+ }
+ case "kyc-collection":
+ case "kyc-expiration": {
+ setSelected(e.when);
+ break;
+ }
+ case "aml-form-error":
+ }
+ }} />
+ {/* {selected && <ShowEventDetails event={selected} />} */}
+ {selected && <ShowConsolidated history={events} until={selected} />}
+ </div>
+ );
+}
+
+function AmlStateBadge({ state }: { state: AmlExchangeBackend.AmlState }): VNode {
+ switch (state) {
+ case AmlExchangeBackend.AmlState.normal: {
+ return (
+ <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
+ Normal
+ </span>
+ );
+ }
+ case AmlExchangeBackend.AmlState.pending: {
+ return (
+ <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20">
+ Pending
+ </span>
+ );
+ }
+ case AmlExchangeBackend.AmlState.frozen: {
+ return (
+ <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20">
+ Frozen
+ </span>
+ );
+ }
+ }
+ assertUnreachable(state)
+}
+
+function ShowTimeline({ history, onSelect }: { onSelect: (e: AmlEvent) => void, history: AmlEvent[] }): VNode {
+ return <div class="flow-root">
+ <ul role="list">
+ {history.map((e, idx) => {
+ const isLast = history.length - 1 === idx;
+ return (
+ <li
+ data-ok={e.type !== "aml-form-error"}
+ class="hover:bg-gray-200 p-2 rounded data-[ok=true]:cursor-pointer"
+ onClick={() => {
+ onSelect(e);
+ }}
+ >
+ <div class="relative pb-6">
+ {!isLast ? (
+ <span
+ class="absolute left-4 top-4 -ml-px h-full w-1 bg-gray-200"
+ aria-hidden="true"
+ ></span>
+ ) : undefined}
+ <div class="relative flex space-x-3">
+ {(() => {
+ switch (e.type) {
+ case "aml-form-error":
+ case "aml-form": {
+ return <div>
+ <AmlStateBadge state={e.state} />
+ <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 ">
+ {e.threshold.currency}{" "}
+ {Amounts.stringifyValue(e.threshold)}
+ </span>
+ </div>
+ }
+ case "kyc-collection": {
+ return (
+ // <ArrowDownCircleIcon class="h-8 w-8 text-green-700" />
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+ </svg>
+ );
+ }
+ case "kyc-expiration": {
+ // return <ClockIcon class="h-8 w-8 text-gray-700" />;
+ return <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
+ </svg>
+
+ }
+ }
+ assertUnreachable(e)
+ })()}
+ <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
+ {e.type === "aml-form" ?
+ <span
+ // href={Pages.newFormEntry.url({ account })}
+ class="block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ >
+ {e.title}
+ </span>
+ :
+ <p class="text-sm text-gray-900">{e.title}</p>
+ }
+ <div class="whitespace-nowrap text-right text-sm text-gray-500">
+ {e.when.t_ms === "never" ? (
+ "never"
+ ) : (
+ <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}>
+ {format(e.when.t_ms, "dd MMM yyyy")}
+ </time>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+
+}
+
+function ShowEventDetails({ event }: { event: AmlEvent }): VNode {
+ return <div>type {event.type}</div>;
+}
+
+
diff --git a/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx
new file mode 100644
index 000000000..3b9c8dacf
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx
@@ -0,0 +1,42 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ CasesUI as TestedComponent,
+} from "./Cases.js";
+import { AmountString } from "@gnu-taler/taler-util";
+import { AmlExchangeBackend } from "../utils/types.js";
+
+export default {
+ title: "cases",
+};
+
+export const OneRow = tests.createExample(TestedComponent, {
+ filter: AmlExchangeBackend.AmlState.normal,
+ onChangeFilter: () => null,
+ records: [{
+ current_state: AmlExchangeBackend.AmlState.normal,
+ h_payto: "QWEQWEQWEQWE",
+ rowid: 1,
+ threshold: "USD:1" as AmountString
+ }]
+});
diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx
new file mode 100644
index 000000000..061286f51
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx
@@ -0,0 +1,287 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpStatusCode,
+ TalerError,
+ TalerExchangeApi,
+ assertUnreachable
+} from "@gnu-taler/taler-util";
+import {
+ ErrorLoading,
+ Loading,
+ createNewForm,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useCases } from "../hooks/useCases.js";
+import { Pages } from "../pages.js";
+
+import { amlStateConverter } from "../utils/converter.js";
+import { AmlExchangeBackend } from "../utils/types.js";
+import { Officer } from "./Officer.js";
+
+export function CasesUI({
+ records,
+ filter,
+ onChangeFilter,
+ onFirstPage,
+ onNext,
+}: {
+ onFirstPage?: () => void;
+ onNext?: () => void;
+ filter: AmlExchangeBackend.AmlState;
+ onChangeFilter: (f: AmlExchangeBackend.AmlState) => void;
+ records: TalerExchangeApi.AmlRecord[];
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ const form = createNewForm<{ state: AmlExchangeBackend.AmlState }>();
+
+ return (
+ <div>
+ <div class="sm:flex sm:items-center">
+ <div class="px-2 sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Cases</i18n.Translate>
+ </h1>
+ <p class="mt-2 text-sm text-gray-700 w-80">
+ <i18n.Translate>
+ A list of all the account with the status
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="px-2">
+ <form.Provider
+ initial={{ state: filter }}
+ onUpdate={(v) => {
+ onChangeFilter(v.state ?? filter);
+ }}
+ onSubmit={(_v) => { }}
+ >
+ <form.InputChoiceHorizontal
+ name="state"
+ label={i18n.str`Filter`}
+ converter={amlStateConverter}
+ choices={[
+ {
+ label: i18n.str`Pending`,
+ value: AmlExchangeBackend.AmlState.pending,
+ },
+ {
+ label: i18n.str`Frozen`,
+ value: AmlExchangeBackend.AmlState.frozen,
+ },
+ {
+ label: i18n.str`Normal`,
+ value: AmlExchangeBackend.AmlState.normal,
+ },
+ ]}
+ />
+ </form.Provider>
+ </div>
+ </div>
+ <div class="mt-8 flow-root">
+ <div class="overflow-x-auto">
+ {!records.length ? (
+ <div>empty result </div>
+ ) : (
+ <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
+ <table class="min-w-full divide-y divide-gray-300">
+ <thead>
+ <tr>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-80"
+ >
+ <i18n.Translate>Account Id</i18n.Translate>
+ </th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-40"
+ >
+ <i18n.Translate>Status</i18n.Translate>
+ </th>
+ <th
+ scope="col"
+ class="sm:hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-40"
+ >
+ <i18n.Translate>Threshold</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody class="divide-y divide-gray-200 bg-white">
+ {records.map((r) => {
+ return (
+ <tr key={r.h_payto} class="hover:bg-gray-100 ">
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 ">
+ <div class="text-gray-900">
+ <a
+ href={Pages.account.url({ account: r.h_payto })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ {r.h_payto.substring(0, 16)}...
+ </a>
+ </div>
+ </td>
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500">
+ {((state: AmlExchangeBackend.AmlState): VNode => {
+ switch (state) {
+ case AmlExchangeBackend.AmlState.normal: {
+ return (
+ <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
+ Normal
+ </span>
+ );
+ }
+ case AmlExchangeBackend.AmlState.pending: {
+ return (
+ <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20">
+ Pending
+ </span>
+ );
+ }
+ case AmlExchangeBackend.AmlState.frozen: {
+ return (
+ <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20">
+ Frozen
+ </span>
+ );
+ }
+ }
+ })(r.current_state)}
+ </td>
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900">
+ {r.threshold}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ <Pagination onFirstPage={onFirstPage} onNext={onNext} />
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export function Cases() {
+
+ const [stateFilter, setStateFilter] = useState(
+ AmlExchangeBackend.AmlState.pending,
+ );
+
+ const list = useCases(stateFilter);
+
+ if (!list) {
+ return <Loading />;
+ }
+ if (list instanceof TalerError) {
+ return <ErrorLoading error={list} />;
+ }
+
+ if (list.type === "fail") {
+ switch (list.case) {
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.Forbidden:
+ case HttpStatusCode.NotFound:
+ case HttpStatusCode.Conflict:
+ return <Officer />;
+ default:
+ assertUnreachable(list);
+ }
+ }
+
+ return (
+ <CasesUI
+ records={list.body}
+ onFirstPage={list.isFirstPage ? undefined : list.loadFirst}
+ onNext={list.isLastPage ? undefined : list.loadNext}
+ filter={stateFilter}
+ onChangeFilter={setStateFilter}
+ />
+ );
+}
+
+export const PeopleIcon = () => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
+ />
+ </svg>
+);
+
+export const HomeIcon = () => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
+ />
+ </svg>
+);
+
+function Pagination({
+ onFirstPage,
+ onNext,
+}: {
+ onFirstPage?: () => void;
+ onNext?: () => void;
+}) {
+ const { i18n } = useTranslationContext();
+ return (
+ <nav
+ class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg"
+ aria-label="Pagination"
+ >
+ <div class="flex flex-1 justify-between sm:justify-end">
+ <button
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ disabled={!onFirstPage}
+ onClick={onFirstPage}
+ >
+ <i18n.Translate>First page</i18n.Translate>
+ </button>
+ <button
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ disabled={!onNext}
+ onClick={onNext}
+ >
+ <i18n.Translate>Next</i18n.Translate>
+ </button>
+ </div>
+ </nav>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx
new file mode 100644
index 000000000..603813f8e
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx
@@ -0,0 +1,106 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import {
+ createNewForm,
+ notifyError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useSettings } from "../hooks/useSettings.js";
+
+export function CreateAccount({
+ onNewAccount,
+}: {
+ onNewAccount: (password: string) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const Form = createNewForm<{
+ password: string;
+ repeat: string;
+ }>();
+ const [settings] = useSettings()
+
+ return (
+ <div class="flex min-h-full flex-col ">
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
+ <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
+ <i18n.Translate>Create account</i18n.Translate>
+ </h2>
+ </div>
+
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] ">
+ <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12">
+ <Form.Provider
+ computeFormState={(v) => {
+ return {
+ password: {
+ error: !v.password
+ ? i18n.str`required`
+ : settings.allowInsecurePassword
+ ? undefined
+ : v.password.length < 8
+ ? i18n.str`should have at least 8 characters`
+ : !v.password.match(/[a-z]/) && v.password.match(/[A-Z]/)
+ ? i18n.str`should have lowercase and uppercase characters`
+ : !v.password.match(/\d/)
+ ? i18n.str`should have numbers`
+ : !v.password.match(/[^a-zA-Z\d]/)
+ ? i18n.str`should have at least one character which is not a number or letter`
+ : undefined,
+ },
+ repeat: {
+ error: !v.repeat
+ ? i18n.str`required`
+ : v.repeat !== v.password
+ ? i18n.str`doesn't match`
+ : undefined,
+ },
+ };
+ }}
+ onSubmit={async (v, s) => {
+ const error = s?.password?.error ?? s?.repeat?.error;
+ if (error) {
+ notifyError(
+ i18n.str`Can't create account`,
+ error as TranslatedString,
+ );
+ } else {
+ onNewAccount(v.password!);
+ }
+ }}
+ >
+ <div class="mb-4">
+ <Form.InputLine
+ label={i18n.str`Password`}
+ name="password"
+ type="password"
+ help={
+ settings.allowInsecurePassword
+ ? i18n.str`short password are insecure, turn off insecure password in settings`
+ : i18n.str`lower and upper case letters, number and special character`
+ }
+ required
+ />
+ </div>
+ <div class="mb-4">
+ <Form.InputLine
+ label={i18n.str`Repeat password`}
+ name="repeat"
+ type="password"
+ required
+ />
+ </div>
+
+ <div class="mt-8">
+ <button
+ type="submit"
+ class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Create</i18n.Translate>
+ </button>
+ </div>
+ </Form.Provider>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx b/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx
new file mode 100644
index 000000000..ff800ebdc
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx
@@ -0,0 +1,35 @@
+import { VNode, h } from "preact";
+import { OfficerNotReady } from "../hooks/useOfficer.js";
+import { CreateAccount } from "./CreateAccount.js";
+import { UnlockAccount } from "./UnlockAccount.js";
+import { assertUnreachable } from "@gnu-taler/taler-util";
+
+export function HandleAccountNotReady({
+ officer,
+}: {
+ officer: OfficerNotReady;
+}): VNode {
+ if (officer.state === "not-found") {
+ return (
+ <CreateAccount
+ onNewAccount={(password) => {
+ officer.create(password);
+ }}
+ />
+ );
+ }
+
+ if (officer.state === "locked") {
+ return (
+ <UnlockAccount
+ onRemoveAccount={() => {
+ officer.forget();
+ }}
+ onAccountUnlocked={async (pwd) => {
+ await officer.tryUnlock(pwd);
+ }}
+ />
+ );
+ }
+ assertUnreachable(officer)
+}
diff --git a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx
new file mode 100644
index 000000000..df97cc3a4
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx
@@ -0,0 +1,104 @@
+import { AbsoluteTime, Amounts, HttpStatusCode, TalerExchangeApi, TalerProtocolTimestamp, TranslatedString } from "@gnu-taler/taler-util";
+import { LocalNotificationBanner, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useExchangeApiContext } from "../context/config.js";
+import { useOfficer } from "../hooks/useOfficer.js";
+import { Pages } from "../pages.js";
+import { AntiMoneyLaunderingForm } from "./AntiMoneyLaunderingForm.js";
+import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
+import { uiForms } from "../forms/declaration.js";
+
+export function NewFormEntry({
+ account,
+ type,
+}: {
+ account?: string;
+ type?: string;
+}): VNode {
+ const { i18n } = useTranslationContext()
+ const officer = useOfficer();
+ const { api } = useExchangeApiContext()
+ const [notification, notify, handleError] = useLocalNotification()
+
+ if (!account) {
+ return <div>no account</div>;
+ }
+ if (!type) {
+ return <SelectForm account={account} />;
+ }
+ if (officer.state !== "ready") {
+ return <HandleAccountNotReady officer={officer} />;
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ <AntiMoneyLaunderingForm
+ account={account}
+ formId={type}
+ onSubmit={async (justification, new_state, new_threshold) => {
+
+ const decision: Omit<TalerExchangeApi.AmlDecision, "officer_sig"> = {
+ justification: JSON.stringify(justification),
+ decision_time: TalerProtocolTimestamp.now(),
+ h_payto: account,
+ new_state,
+ new_threshold: Amounts.stringify(new_threshold),
+ kyc_requirements: undefined
+ }
+ await handleError(async () => {
+ const resp = await api.addDecisionDetails(officer.account, decision);
+ if (resp.type === "ok") {
+ window.location.href = Pages.cases.url;
+ return;
+ }
+ switch (resp.case) {
+ case HttpStatusCode.Forbidden:
+ case HttpStatusCode.Unauthorized: return notify({
+ type: "error",
+ title: i18n.str`Wrong credentials for "${officer.account}"`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ })
+ case HttpStatusCode.NotFound: return notify({
+ type: "error",
+ title: i18n.str`Officer or account not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ })
+ case HttpStatusCode.Conflict: return notify({
+ type: "error",
+ title: i18n.str`Officer disabled or more recent decision was already submitted.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ })
+ }
+ })
+ }}
+ />
+ </Fragment>
+ );
+}
+
+function SelectForm({ account }: { account: string }) {
+ const { i18n } = useTranslationContext()
+ return (
+ <div>
+ <pre>New form for account: {account.substring(0, 16)}...</pre>
+ {uiForms.forms(i18n).map((form, idx) => {
+ return (
+ <a
+ href={Pages.newFormEntry.url({ account, type: form.id })}
+ class="m-4 block rounded-md w-fit border-0 p-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-600"
+ >
+ {form.label}
+ </a>
+ );
+ })}
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/pages/Officer.tsx b/packages/aml-backoffice-ui/src/pages/Officer.tsx
new file mode 100644
index 000000000..ec8327814
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/Officer.tsx
@@ -0,0 +1,60 @@
+import { Fragment, h } from "preact";
+import { useOfficer } from "../hooks/useOfficer.js";
+import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { uiSettings } from "../settings.js";
+import { getInitialBackendBaseURL } from "../hooks/useBackend.js";
+
+export function Officer() {
+ const officer = useOfficer();
+ const { i18n } = useTranslationContext()
+ if (officer.state !== "ready") {
+ return <HandleAccountNotReady officer={officer} />;
+ }
+
+ const url = new URL(getInitialBackendBaseURL())
+ const signupEmail = uiSettings.signupEmail ?? `aml-signup@${url.hostname}`
+
+ return (
+ <div>
+ <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 ">
+ <i18n.Translate>Public key</i18n.Translate>
+ </h1>
+ <div class="max-w-xl text-base leading-7 text-gray-700 lg:max-w-lg">
+ <p class="mt-6 font-mono break-all">{officer.account.id}</p>
+ </div>
+ <p>
+ <a
+ href={`mailto:${signupEmail}?subject=${encodeURIComponent("Request AML signup")}&body=${encodeURIComponent(`I want my AML account\n\n\nPubKey: ${officer.account.id}`)}`}
+ target="_blank"
+ rel="noreferrer"
+ class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ >
+ <i18n.Translate>Request account activation</i18n.Translate>
+ </a>
+ </p>
+ <p>
+ <button
+ type="button"
+ onClick={() => {
+ officer.lock();
+ }}
+ class="m-4 block rounded-md border-0 bg-gray-200 px-3 py-2 text-center text-sm text-black shadow-sm "
+ >
+ <i18n.Translate>Lock account</i18n.Translate>
+ </button>
+ </p>
+ <p>
+ <button
+ type="button"
+ onClick={() => {
+ officer.forget();
+ }}
+ class="m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
+ >
+ <i18n.Translate>Forget account</i18n.Translate>
+ </button>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx
new file mode 100644
index 000000000..f985e6ff5
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx
@@ -0,0 +1,117 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { addDays } from "date-fns";
+import {
+ ShowConsolidated as TestedComponent,
+} from "./ShowConsolidated.js";
+import * as tests from "@gnu-taler/web-util/testing";
+import { getEventsFromAmlHistory } from "./CaseDetails.js";
+import { AbsoluteTime, Duration } from "@gnu-taler/taler-util";
+import { InternationalizationAPI } from "@gnu-taler/web-util/browser";
+
+export default {
+ title: "show consolidated",
+};
+
+const nullTranslator: InternationalizationAPI = {
+ str: (str: any) => str,
+ singular: (str: any) => str,
+ translate: (str: any) => str,
+ Translate: (str: any) => str,
+}
+
+export const WithEmptyHistory = tests.createExample(TestedComponent, {
+ history: getEventsFromAmlHistory([], [], nullTranslator),
+ until: AbsoluteTime.now()
+});
+
+export const WithSomeEvents = tests.createExample(TestedComponent, {
+ history: getEventsFromAmlHistory([
+ {
+ "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
+ "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}",
+ "new_threshold": "STATER:0",
+ "new_state": 1,
+ "decision_time": {
+ "t_s": 1700208199
+ }
+ },
+ {
+ "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
+ "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}",
+ "new_threshold": "STATER:0",
+ "new_state": 1,
+ "decision_time": {
+ "t_s": 1700208211
+ }
+ },
+ {
+ "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
+ "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}",
+ "new_threshold": "STATER:0",
+ "new_state": 1,
+ "decision_time": {
+ "t_s": 1700208220
+ }
+ },
+ {
+ "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG",
+ "justification": "{\"index\":4,\"name\":\"Declaration for trusts (902.13e)\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700208362854},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"contractingPartner\":\"f\",\"knownAs\":\"a\",\"trust\":{\"name\":\"b\",\"type\":\"discretionary\",\"revocability\":\"irrevocable\"}}}",
+ "new_threshold": "STATER:0",
+ "new_state": 1,
+ "decision_time": {
+ "t_s": 1700208385
+ }
+ },
+ {
+ "decider_pub": "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG",
+ "justification": "{\"id\":\"simple_comment\",\"label\":\"Simple comment\",\"version\":1,\"value\":{\"when\":{\"t_ms\":1700488420810},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"qwe\"}}",
+ "new_threshold": "STATER:0",
+ "new_state": 1,
+ "decision_time": {
+ "t_s": 1700488423
+ }
+ },
+ {
+ "decider_pub": "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG",
+ "justification": "{\"id\":\"simple_comment\",\"label\":\"Simple comment\",\"version\":1,\"value\":{\"when\":{\"t_ms\":1700488671251},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"asd asd asd \"}}",
+ "new_threshold": "STATER:0",
+ "new_state": 1,
+ "decision_time": {
+ "t_s": 1700488677
+ }
+ }
+ ], [{
+ collection_time: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.subtractDuraction(AbsoluteTime.now(), Duration.fromPrettyString("1d"))
+ ),
+ expiration_time: { t_s: "never" },
+ provider_section: "asd",
+ attributes: {
+ email: "sebasjm@qwdde.com"
+ }
+ }], nullTranslator),
+ until: AbsoluteTime.now()
+});
+
+
+
diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx
new file mode 100644
index 000000000..ad350c0e6
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx
@@ -0,0 +1,173 @@
+import { AbsoluteTime, AmountJson, TranslatedString } from "@gnu-taler/taler-util";
+import { format } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { AmlEvent } from "./CaseDetails.js";
+import { DefaultForm, FlexibleForm, UIFormField, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { amlStateConverter } from "../utils/converter.js";
+import { AmlExchangeBackend } from "../utils/types.js";
+
+export function ShowConsolidated({
+ history,
+ until,
+}: {
+ history: AmlEvent[];
+ until: AbsoluteTime;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ const cons = getConsolidated(history, until);
+
+ const form: FlexibleForm<Consolidated> = {
+ behavior: (form) => {
+ return {
+ aml: {
+ threshold: {
+ hidden: !form.aml
+ },
+ since: {
+ hidden: !form.aml
+ },
+ state: {
+ hidden: !form.aml
+ }
+ }
+ };
+ },
+ design: [
+ {
+ title: i18n.str`AML`,
+ fields: [
+ {
+ type: "amount",
+ props: {
+ label: i18n.str`Threshold`,
+ name: "aml.threshold",
+ },
+ },
+ {
+ type: "choiceHorizontal",
+ props: {
+ label: i18n.str`State`,
+ name: "aml.state",
+ converter: amlStateConverter,
+ choices: [
+ {
+ label: i18n.str`Frozen`,
+ value: AmlExchangeBackend.AmlState.frozen,
+ },
+ {
+ label: i18n.str`Pending`,
+ value: AmlExchangeBackend.AmlState.pending,
+ },
+ {
+ label: i18n.str`Normal`,
+ value: AmlExchangeBackend.AmlState.normal,
+ },
+ ],
+ },
+ },
+ ],
+ },
+ Object.entries(cons.kyc).length > 0
+ ? {
+ title: i18n.str`KYC`,
+ fields: Object.entries(cons.kyc).map(([key, field]) => {
+ const result: UIFormField = {
+ type: "text",
+ props: {
+ label: key as TranslatedString,
+ name: `kyc.${key}.value`,
+ help: `${field.provider} since ${field.since.t_ms === "never"
+ ? "never"
+ : format(field.since.t_ms, "dd/MM/yyyy")
+ }` as TranslatedString,
+ },
+ };
+ return result;
+ }),
+ }
+ : undefined,
+ ],
+ };
+ return (
+ <Fragment>
+ <h1 class="text-base font-semibold leading-7 text-black">
+ Consolidated information {until.t_ms === "never"
+ ? ""
+ : `after ${format(until.t_ms, "dd MMMM yyyy")}`}
+ </h1>
+ <DefaultForm
+ key={`${String(Date.now())}`}
+ form={form}
+ initial={cons}
+ readOnly
+ onUpdate={() => { }}
+ />
+ </Fragment>
+ );
+}
+
+interface Consolidated {
+ aml: {
+ state: AmlExchangeBackend.AmlState;
+ threshold: AmountJson;
+ since: AbsoluteTime;
+ };
+ kyc: {
+ [field: string]: {
+ value: any;
+ provider: string;
+ since: AbsoluteTime;
+ };
+ };
+}
+
+function getConsolidated(
+ history: AmlEvent[],
+ when: AbsoluteTime,
+): Consolidated {
+ const initial: Consolidated = {
+ aml: {
+ state: AmlExchangeBackend.AmlState.normal,
+ threshold: {
+ currency: "ARS",
+ value: 1000,
+ fraction: 0,
+ },
+ since: AbsoluteTime.never()
+ },
+ kyc: {},
+ };
+ return history.reduce((prev, cur) => {
+ if (AbsoluteTime.cmp(when, cur.when) < 0) {
+ return prev;
+ }
+ switch (cur.type) {
+ case "kyc-expiration": {
+ cur.fields.forEach((field) => {
+ delete prev.kyc[field];
+ });
+ break;
+ }
+ case "aml-form": {
+ prev.aml = {
+ since: cur.when,
+ state: cur.state,
+ threshold: cur.threshold
+ }
+ break;
+ }
+ case "kyc-collection": {
+ Object.keys(cur.values).forEach((field) => {
+ prev.kyc[field] = {
+ value: (cur.values as any)[field],
+ provider: cur.provider,
+ since: cur.when,
+ };
+ });
+ break;
+ }
+ }
+ return prev;
+ }, initial);
+} \ No newline at end of file
diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx
new file mode 100644
index 000000000..1b0342b12
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx
@@ -0,0 +1,79 @@
+import { TranslatedString, UnwrapKeyError } from "@gnu-taler/taler-util";
+import { createNewForm, notifyError, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+
+export function UnlockAccount({
+ onAccountUnlocked,
+ onRemoveAccount,
+}: {
+ onAccountUnlocked: (password: string) => Promise<void>;
+ onRemoveAccount: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext()
+ const Form = createNewForm<{
+ password: string;
+ }>();
+
+ return (
+ <div class="flex min-h-full flex-col ">
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
+ <h1 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
+ <i18n.Translate>Account locked</i18n.Translate>
+ </h1>
+ <p class="mt-6 text-lg leading-8 text-gray-600">
+ <i18n.Translate>Your account is normally locked anytime you reload. To unlock type
+ your password again.</i18n.Translate>
+ </p>
+ </div>
+
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] ">
+ <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12">
+ <Form.Provider
+ onSubmit={async (v) => {
+ try {
+ await onAccountUnlocked(v.password!);
+ notifyInfo(i18n.str`Account unlocked`);
+ } catch (e) {
+ if (e instanceof UnwrapKeyError) {
+ notifyError(
+ "Could not unlock account" as any,
+ e.message as any,
+ );
+ } else {
+ throw e;
+ }
+ }
+ }}
+ >
+ <div class="mb-4">
+ <Form.InputLine
+ label={i18n.str`Password`}
+ name="password"
+ type="password"
+ required
+ />
+ </div>
+
+ <div class="mt-8">
+ <button
+ type="submit"
+ class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Unlock</i18n.Translate>
+ </button>
+ </div>
+ </Form.Provider>
+ </div>
+ <button
+ type="button"
+ onClick={() => {
+ onRemoveAccount();
+ }}
+ class="m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
+ >
+ <i18n.Translate>Forget account</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/pages/index.stories.ts b/packages/aml-backoffice-ui/src/pages/index.stories.ts
new file mode 100644
index 000000000..afe73227a
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/index.stories.ts
@@ -0,0 +1,3 @@
+export * as a1 from "./ShowConsolidated.stories.js";
+export * as a2 from "./AntiMoneyLaunderingForm.stories.js";
+export * as a3 from "./Cases.stories.js";
diff --git a/packages/aml-backoffice-ui/src/route.ts b/packages/aml-backoffice-ui/src/route.ts
new file mode 100644
index 000000000..f515a590a
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/route.ts
@@ -0,0 +1,197 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { createHashHistory } from "history";
+import { ComponentChildren, h as create, createContext, VNode } from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
+
+type ContextType = {
+ onChange: (listener: () => void) => VoidFunction
+}
+const nullChangeListener = { onChange: () => () => { } }
+const Context = createContext<ContextType>(nullChangeListener);
+
+export const usePathChangeContext = (): ContextType => useContext(Context);
+
+export function HashPathProvider({ children }: { children: ComponentChildren }): VNode {
+ const history = createHashHistory();
+ return create(Context.Provider, { value: { onChange: history.listen }, children }, children)
+}
+
+type PageDefinition<DynamicPart extends Record<string, string>> = {
+ pattern: string;
+ (params: DynamicPart): string;
+};
+
+function replaceAll(
+ pattern: string,
+ vars: Record<string, string>,
+ values: Record<string, string>,
+): string {
+ let result = pattern;
+ for (const v in vars) {
+ result = result.replace(vars[v], !values[v] ? "" : values[v]);
+ }
+ return result;
+}
+
+export function pageDefinition<T extends Record<string, string>>(
+ pattern: string,
+): PageDefinition<T> {
+ const patternParams = pattern.match(/(:[\w?]*)/g);
+ if (!patternParams)
+ throw Error(
+ `page definition pattern ${pattern} doesn't have any parameter`,
+ );
+
+ const vars = patternParams.reduce((prev, cur) => {
+ const pName = cur.match(/(\w+)/g);
+
+ //skip things like :? in the path pattern
+ if (!pName || !pName[0]) return prev;
+ const name = pName[0];
+ return { ...prev, [name]: cur };
+ }, {} as Record<string, string>);
+
+ const f = (values: T): string => replaceAll(pattern, vars, values);
+ f.pattern = pattern;
+ return f;
+}
+
+export type PageEntry<T = unknown> = T extends Record<string, string>
+ ? {
+ url: PageDefinition<T>;
+ view: (props: T) => VNode;
+ name: TranslatedString,
+ Icon?: () => VNode,
+ }
+ : T extends unknown
+ ? {
+ url: string;
+ view: (props: {}) => VNode;
+ name: TranslatedString,
+ Icon?: () => VNode,
+ }
+ : never;
+
+export function Router({
+ pageList,
+ onNotFound,
+}: {
+ pageList: Array<PageEntry<any>>;
+ onNotFound: () => VNode;
+}): VNode {
+ const current = useCurrentLocation(pageList);
+ if (current !== undefined) {
+ return create(current.page.view, current.values);
+ }
+ return onNotFound();
+}
+
+type Location = {
+ page: PageEntry<any>;
+ path: string;
+ values: Record<string, string>;
+};
+export function useCurrentLocation(pageList: Array<PageEntry<any>>): Location | undefined {
+ const [currentLocation, setCurrentLocation] = useState<Location | null | undefined>(null);
+ const path = usePathChangeContext();
+ useEffect(() => {
+ return path.onChange(() => {
+ const result = doSync(window.location.hash, new URLSearchParams(window.location.search), pageList);
+ setCurrentLocation(result);
+ });
+ }, []);
+ if (currentLocation === null) {
+ return doSync(window.location.hash, new URLSearchParams(window.location.search), pageList);
+ }
+ return currentLocation;
+}
+
+export function useChangeLocation() {
+ const [location, setLocation] = useState(window.location.hash)
+ const path = usePathChangeContext()
+ useEffect(() => {
+ return path.onChange(() => {
+ setLocation(window.location.hash)
+ });
+ }, []);
+ return location;
+}
+
+/**
+ * Search path in the pageList
+ * get the values from the path found
+ * add params from searchParams
+ *
+ * @param path
+ * @param params
+ */
+export function doSync(path: string, params: URLSearchParams, pageList: Array<PageEntry<any>>): Location | undefined {
+ for (let idx = 0; idx < pageList.length; idx++) {
+ const page = pageList[idx];
+ if (typeof page.url === "string") {
+ if (page.url === path) {
+ const values: Record<string, string> = {};
+ params.forEach((v, k) => {
+ values[k] = v;
+ });
+ return { page, values, path };
+ }
+ } else {
+ const values = doestUrlMatchToRoute(path, page.url.pattern);
+ if (values !== undefined) {
+ params.forEach((v, k) => {
+ values[k] = v;
+ });
+ return { page, values, path };
+ }
+ }
+ }
+ return undefined;
+}
+
+function doestUrlMatchToRoute(
+ url: string,
+ route: string,
+): undefined | Record<string, string> {
+ const paramsPattern = /(?:\?([^#]*))?$/;
+ // const paramsPattern = /(?:\?([^#]*))?(#.*)?$/;
+ const params = url.match(paramsPattern);
+ const urlWithoutParams = url.replace(paramsPattern, "");
+
+ const result: Record<string, string> = {};
+ if (params && params[1]) {
+ const paramList = params[1].split("&");
+ for (let i = 0; i < paramList.length; i++) {
+ const idx = paramList[i].indexOf("=");
+ const name = paramList[i].substring(0, idx);
+ const value = paramList[i].substring(idx + 1);
+ result[decodeURIComponent(name)] = decodeURIComponent(value);
+ }
+ }
+ const urlSeg = urlWithoutParams.split("/");
+ const routeSeg = route.split("/");
+ let max = Math.max(urlSeg.length, routeSeg.length);
+ for (let i = 0; i < max; i++) {
+ if (routeSeg[i] && routeSeg[i].charAt(0) === ":") {
+ const param = routeSeg[i].replace(/(^:|[+*?]+$)/g, "");
+
+ const flags = (routeSeg[i].match(/[+*?]+$/) || EMPTY)[0] || "";
+ const plus = ~flags.indexOf("+");
+ const star = ~flags.indexOf("*");
+ const val = urlSeg[i] || "";
+
+ if (!val && !star && (flags.indexOf("?") < 0 || plus)) {
+ return undefined;
+ }
+ result[param] = decodeURIComponent(val);
+ if (plus || star) {
+ result[param] = urlSeg.slice(i).map(decodeURIComponent).join("/");
+ break;
+ }
+ } else if (routeSeg[i] !== urlSeg[i]) {
+ return undefined;
+ }
+ }
+ return result;
+}
+const EMPTY: Record<string, string> = {};
diff --git a/packages/aml-backoffice-ui/src/scss/main.css b/packages/aml-backoffice-ui/src/scss/main.css
new file mode 100644
index 000000000..b5c61c956
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/scss/main.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/packages/taler-wallet-core/src/util/debugFlags.ts b/packages/aml-backoffice-ui/src/settings.ts
index cea249d27..68f44b4df 100644
--- a/packages/taler-wallet-core/src/util/debugFlags.ts
+++ b/packages/aml-backoffice-ui/src/settings.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,19 +14,18 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-/**
- * Debug flags for wallet-core.
- *
- * @author Florian Dold
- */
-
-export interface WalletCoreDebugFlags {
- /**
- * Allow withdrawal of denominations even though they are about to expire.
- */
- denomselAllowLate: boolean;
+export interface UiSettings {
+ backendBaseURL?: string;
+ signupEmail?: string;
}
-export const walletCoreDebugFlags: WalletCoreDebugFlags = {
- denomselAllowLate: false,
+/**
+ * Global settings for the UI.
+ */
+const defaultSettings: UiSettings = {
};
+
+export const uiSettings: UiSettings =
+ "talerExchangeAmlSettings" in globalThis
+ ? (globalThis as any).talerExchangeAmlSettings
+ : defaultSettings;
diff --git a/packages/aml-backoffice-ui/src/stories.test.ts b/packages/aml-backoffice-ui/src/stories.test.ts
new file mode 100644
index 000000000..eca66cb18
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/stories.test.ts
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { TalerExchangeApi, setupI18n } from "@gnu-taler/taler-util";
+import { parseGroupImport } from "@gnu-taler/web-util/browser";
+import * as tests from "@gnu-taler/web-util/testing";
+
+// import * as components from "./components/index.examples.js";
+import * as pages from "./pages/index.stories.js";
+
+import { ComponentChildren, Fragment, VNode, h as create } from "preact";
+import { ExchangeApiContextTesting } from "./context/config.js";
+// import { BackendStateProviderTesting } from "./context/backend.js";
+
+setupI18n("en", { en: {} });
+
+describe("All the examples:", () => {
+ const cms = parseGroupImport({ pages });
+ cms.forEach((group) => {
+ describe(`Example for group "${group.title}:"`, () => {
+ group.list.forEach((component) => {
+ describe(`Component ${component.name}:`, () => {
+ component.examples.forEach((example) => {
+ it(`should render example: ${example.name}`, () => {
+ tests.renderUI(example.render, DefaultTestingContext);
+ });
+ });
+ });
+ });
+ });
+ });
+});
+
+
+function DefaultTestingContext({
+ children,
+}: {
+ children: ComponentChildren;
+}): VNode {
+ const config: TalerExchangeApi.ExchangeVersionResponse = {
+ currency: "ARS",
+ currency_specification: {
+ alt_unit_names: {},
+ name: "ARS",
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2
+ },
+ name: "taler-exchange",
+ supported_kyc_requirements: [],
+ version: "asd",
+ }
+ return create(ExchangeApiContextTesting, { config, children });
+}
diff --git a/packages/aml-backoffice-ui/src/stories.tsx b/packages/aml-backoffice-ui/src/stories.tsx
new file mode 100644
index 000000000..1aa6a44ac
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/stories.tsx
@@ -0,0 +1,66 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { strings } from "./i18n/strings.js";
+
+import * as pages from "./pages/index.stories.js";
+
+import { renderStories } from "@gnu-taler/web-util/browser";
+
+import "./scss/main.css";
+import { h, ComponentChildren, FunctionComponent, VNode } from "preact";
+import { ExchangeApiContextTesting } from "./context/config.js";
+
+function main(): void {
+ renderStories(
+ { pages },
+ {
+ strings,
+ getWrapperForGroup
+ },
+ );
+}
+
+function getWrapperForGroup(): FunctionComponent {
+ return function All({ children }: { children?: ComponentChildren }): VNode {
+ return <ExchangeApiContextTesting
+ config={{
+ currency: "ARS",
+ currency_specification: {
+ alt_unit_names: {},
+ name: "ARS",
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2
+ },
+ name: "taler-exchange",
+ supported_kyc_requirements: [],
+ version: "asd",
+ }}>
+ {children}
+ </ExchangeApiContextTesting>
+ }
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", main);
+} else {
+ main();
+}
diff --git a/packages/aml-backoffice-ui/src/utils/QR.tsx b/packages/aml-backoffice-ui/src/utils/QR.tsx
new file mode 100644
index 000000000..1dc1712b7
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/utils/QR.tsx
@@ -0,0 +1,54 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+import { useEffect, useRef } from "preact/hooks";
+// import qrcode from "qrcode-generator";
+
+export function QR({ text }: { text: string }): VNode {
+ const divRef = useRef<HTMLDivElement>(null);
+ useEffect(() => {
+ // const qr = qrcode(0, "L");
+ // qr.addData(text);
+ // qr.make();
+ // if (divRef.current)
+ // divRef.current.innerHTML = qr.createSvgTag({
+ // scalable: true,
+ // });
+ });
+
+ return (
+ <div
+ style={{
+ width: "100%",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "left",
+ }}
+ >
+ <div
+ style={{
+ width: "50%",
+ minWidth: 200,
+ maxWidth: 300,
+ marginRight: "auto",
+ marginLeft: "auto",
+ }}
+ ref={divRef}
+ />
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/utils/converter.ts b/packages/aml-backoffice-ui/src/utils/converter.ts
new file mode 100644
index 000000000..d2f05ed84
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/utils/converter.ts
@@ -0,0 +1,31 @@
+import { AmlExchangeBackend } from "./types.js";
+
+export const amlStateConverter = {
+ toStringUI: stringifyAmlState,
+ fromStringUI: parseAmlState,
+};
+
+function stringifyAmlState(s: AmlExchangeBackend.AmlState | undefined): string {
+ if (s === undefined) return "";
+ switch (s) {
+ case AmlExchangeBackend.AmlState.normal:
+ return "normal";
+ case AmlExchangeBackend.AmlState.pending:
+ return "pending";
+ case AmlExchangeBackend.AmlState.frozen:
+ return "frozen";
+ }
+}
+
+function parseAmlState(s: string | undefined): AmlExchangeBackend.AmlState {
+ switch (s) {
+ case "normal":
+ return AmlExchangeBackend.AmlState.normal;
+ case "pending":
+ return AmlExchangeBackend.AmlState.pending;
+ case "frozen":
+ return AmlExchangeBackend.AmlState.frozen;
+ default:
+ throw Error(`unknown AML state: ${s}`);
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/utils/types.ts b/packages/aml-backoffice-ui/src/utils/types.ts
new file mode 100644
index 000000000..fd70d4e4d
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/utils/types.ts
@@ -0,0 +1,124 @@
+export namespace AmlExchangeBackend {
+ // FIXME: placeholder
+ export interface AmlError {
+ code: number;
+ hint: string;
+ }
+ export interface AmlDecisionDetails {
+ // Array of AML decisions made for this account. Possibly
+ // contains only the most recent decision if "history" was
+ // not set to 'true'.
+ aml_history: AmlDecisionDetail[];
+
+ // Array of KYC attributes obtained for this account.
+ kyc_attributes: KycDetail[];
+ }
+
+ type AmlOfficerPublicKeyP = string;
+
+ export interface AmlDecisionDetail {
+ // What was the justification given?
+ justification: string;
+
+ // What is the new AML state.
+ new_state: Integer;
+
+ // When was this decision made?
+ decision_time: Timestamp;
+
+ // What is the new AML decision threshold (in monthly transaction volume)?
+ new_threshold: Amount;
+
+ // Who made the decision?
+ decider_pub: AmlOfficerPublicKeyP;
+ }
+ export interface KycDetail {
+ // Name of the configuration section that specifies the provider
+ // which was used to collect the KYC details
+ provider_section: string;
+
+ // The collected KYC data. NULL if the attribute data could not
+ // be decrypted (internal error of the exchange, likely the
+ // attribute key was changed).
+ attributes?: Object;
+
+ // Time when the KYC data was collected
+ collection_time: Timestamp;
+
+ // Time when the validity of the KYC data will expire
+ expiration_time: Timestamp;
+ }
+
+ interface Timestamp {
+ // Seconds since epoch, or the special
+ // value "never" to represent an event that will
+ // never happen.
+ t_s: number | "never";
+ }
+
+ type PaytoHash = string;
+ type Integer = number;
+ type Amount = string;
+ // EdDSA signatures are transmitted as 64-bytes base32
+ // binary-encoded objects with just the R and S values (base32_ binary-only).
+ type EddsaSignature = string;
+
+ export interface AmlRecords {
+ // Array of AML records matching the query.
+ records: AmlRecord[];
+ }
+
+ interface AmlRecord {
+ // Which payto-address is this record about.
+ // Identifies a GNU Taler wallet or an affected bank account.
+ h_payto: PaytoHash;
+
+ // What is the current AML state.
+ current_state: AmlState;
+
+ // Monthly transaction threshold before a review will be triggered
+ threshold: Amount;
+
+ // RowID of the record.
+ rowid: Integer;
+ }
+
+ export enum AmlState {
+ normal = 0,
+ pending = 1,
+ frozen = 2,
+ }
+
+
+ export interface AmlDecision {
+
+ // Human-readable justification for the decision.
+ justification: string;
+
+ // At what monthly transaction volume should the
+ // decision be automatically reviewed?
+ new_threshold: Amount;
+
+ // Which payto-address is the decision about?
+ // Identifies a GNU Taler wallet or an affected bank account.
+ h_payto: PaytoHash;
+
+ // What is the new AML state (e.g. frozen, unfrozen, etc.)
+ // Numerical values are defined in AmlDecisionState.
+ new_state: Integer;
+
+ // Signature by the AML officer over a
+ // TALER_MasterAmlOfficerStatusPS.
+ // Must have purpose TALER_SIGNATURE_MASTER_AML_KEY.
+ officer_sig: EddsaSignature;
+
+ // When was the decision made?
+ decision_time: Timestamp;
+
+ // Optional argument to impose new KYC requirements
+ // that the customer has to satisfy to unblock transactions.
+ kyc_requirements?: string[];
+ }
+
+
+}
diff --git a/packages/aml-backoffice-ui/tailwind.config.js b/packages/aml-backoffice-ui/tailwind.config.js
new file mode 100644
index 000000000..ec51dfbb8
--- /dev/null
+++ b/packages/aml-backoffice-ui/tailwind.config.js
@@ -0,0 +1,14 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: {
+ relative: true,
+ files: [
+ "./src/**/*.{html,tsx}",
+ "./node_modules/@gnu-taler/web-util/src/**/*.{html,tsx}"
+ ],
+ },
+ theme: {
+ extend: {},
+ },
+ plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
+};
diff --git a/packages/aml-backoffice-ui/test.mjs b/packages/aml-backoffice-ui/test.mjs
new file mode 100755
index 000000000..9df844fce
--- /dev/null
+++ b/packages/aml-backoffice-ui/test.mjs
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build } from "@gnu-taler/web-util/build";
+import { getFilesInDirectory } from "@gnu-taler/web-util/build";
+
+const allTestFiles = getFilesInDirectory("./src", /.test.tsx?$/);
+
+await build({
+ type: "test",
+ source: {
+ js: allTestFiles.files,
+ assets: [{base:"src",files:["src/index.html"]}],
+ },
+ destination: "./dist/test",
+ css: "postcss",
+});
diff --git a/packages/aml-backoffice-ui/tsconfig.json b/packages/aml-backoffice-ui/tsconfig.json
new file mode 100644
index 000000000..9826fac07
--- /dev/null
+++ b/packages/aml-backoffice-ui/tsconfig.json
@@ -0,0 +1,46 @@
+{
+ "compilerOptions": {
+ /* Basic Options */
+ "target": "ES2020",
+ "module": "Node16",
+ "lib": ["DOM", "ES2020"],
+ "allowJs": true /* Allow javascript files to be compiled. */,
+ // "checkJs": true, /* Report errors in .js files. */
+ "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
+ "jsxFactory": "h",
+ "jsxFragmentFactory": "Fragment",
+ "noEmit": true /* Do not emit outputs. */,
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+ /* Strict Type-Checking Options */
+ "strict": true /* Enable all strict type-checking options. */,
+ "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
+ /* Additional Checks */
+ // "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+ /* Module Resolution Options */
+ "moduleResolution": "Node16" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
+ "esModuleInterop": true /* */,
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+ /* Source Map Options */
+ // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+ /* Advanced Options */
+ "skipLibCheck": true /* Skip type checking of declaration files. */
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/anastasis-cli/Makefile b/packages/anastasis-cli/Makefile
new file mode 100644
index 000000000..724a5e40d
--- /dev/null
+++ b/packages/anastasis-cli/Makefile
@@ -0,0 +1,42 @@
+# This Makefile has been placed in the public domain.
+
+ifeq ($(TOPLEVEL), yes)
+ $(info top-level build)
+ -include ../../.config.mk
+ override DESTDIR := $(TOP_DESTDIR)
+else
+ $(info package-level build)
+ -include ../../.config.mk
+ -include .config.mk
+endif
+
+$(info prefix is $(prefix))
+
+all:
+ @echo use 'make install' to build and install anastasis-cli
+
+ifndef prefix
+.PHONY: warn-noprefix install
+warn-noprefix:
+ @echo "no prefix configured, did you run ./configure?"
+install: warn-noprefix
+else
+bindir = $(prefix)/bin
+libdir = $(prefix)/lib/anastasis-cli
+nodedir = $(libdir)/node_modules/anastasis-cli
+.PHONY: install install-nodeps deps
+install-nodeps:
+ ./build-node.mjs
+ install -d $(DESTDIR)$(bindir)
+ install -d $(DESTDIR)$(nodedir)/bin
+ install -d $(DESTDIR)$(nodedir)/dist
+ install ./dist/anastasis-cli-bundled.cjs $(DESTDIR)$(nodedir)/dist/
+ install ./dist/anastasis-cli-bundled.cjs.map $(DESTDIR)$(nodedir)/dist/
+ install ./bin/anastasis-cli.mjs $(DESTDIR)$(nodedir)/bin/
+ ln -sf ../lib/anastasis-cli/node_modules/anastasis-cli/bin/anastasis-cli.mjs $(DESTDIR)$(bindir)/anastasis-cli
+deps:
+ pnpm install --frozen-lockfile --filter @gnu-taler/anastasis-cli...
+install:
+ $(MAKE) deps
+ $(MAKE) install-nodeps
+endif
diff --git a/packages/anastasis-cli/README.md b/packages/anastasis-cli/README.md
new file mode 100644
index 000000000..a48fd3c51
--- /dev/null
+++ b/packages/anastasis-cli/README.md
@@ -0,0 +1,4 @@
+# anastasis-cli
+
+This package provides `anastasis-cli`, the command-line interface for the
+Anastasis backup system.
diff --git a/packages/taler-wallet-webextension/src/cta/payback.tsx b/packages/anastasis-cli/bin/anastasis-cli.mjs
index 1e27fd912..7506e4ba7 100644..100755
--- a/packages/taler-wallet-webextension/src/cta/payback.tsx
+++ b/packages/anastasis-cli/bin/anastasis-cli.mjs
@@ -1,6 +1,7 @@
+#!/usr/bin/env node
/*
- This file is part of TALER
- (C) 2017 Inria
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems SA
TALER is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,19 +15,6 @@
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { JSX } from "preact/jsx-runtime";
-import { h } from 'preact';
+import { reducerCliMain } from '../dist/anastasis-cli-bundled.cjs';
-/**
- * View and edit auditors.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-
-export function makePaybackPage(): JSX.Element {
- return <div>not implemented</div>;
-}
+reducerCliMain();
diff --git a/packages/anastasis-cli/build-node.mjs b/packages/anastasis-cli/build-node.mjs
new file mode 100755
index 000000000..04b1c5256
--- /dev/null
+++ b/packages/anastasis-cli/build-node.mjs
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import esbuild from "esbuild";
+import path from "path";
+import fs from "fs";
+
+const BASE = process.cwd();
+
+let GIT_ROOT = BASE;
+while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") {
+ GIT_ROOT = path.join(GIT_ROOT, "../");
+}
+if (GIT_ROOT === "/") {
+ console.log("not found");
+ process.exit(1);
+}
+const GIT_HASH = GIT_ROOT === "/" ? undefined : git_hash();
+
+let _package = JSON.parse(fs.readFileSync(path.join(BASE, "package.json")));
+
+function git_hash() {
+ const rev = fs
+ .readFileSync(path.join(GIT_ROOT, ".git", "HEAD"))
+ .toString()
+ .trim()
+ .split(/.*[: ]/)
+ .slice(-1)[0];
+ if (rev.indexOf("/") === -1) {
+ return rev;
+ } else {
+ return fs.readFileSync(path.join(GIT_ROOT, ".git", rev)).toString().trim();
+ }
+}
+
+export const buildConfig = {
+ entryPoints: ["src/index.ts"],
+ outfile: "dist/anastasis-cli-bundled.cjs",
+ bundle: true,
+ minify: false,
+ target: ["es2020"],
+ format: "cjs",
+ platform: "node",
+ sourcemap: true,
+ inject: ["src/import-meta-url.js"],
+ define: {
+ __VERSION__: `"${_package.version}"`,
+ __GIT_HASH__: `"${GIT_HASH}"`,
+ ["import.meta.url"]: "import_meta_url",
+ },
+};
+
+esbuild.build(buildConfig).catch((e) => {
+ console.log(e);
+ process.exit(1);
+});
diff --git a/packages/anastasis-cli/package.json b/packages/anastasis-cli/package.json
new file mode 100644
index 000000000..03d8ffa8f
--- /dev/null
+++ b/packages/anastasis-cli/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@gnu-taler/anastasis-cli",
+ "version": "0.10.6",
+ "description": "",
+ "engines": {
+ "node": ">=0.18.0"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git://git.taler.net/wallet-core.git"
+ },
+ "author": "Florian Dold",
+ "license": "GPL-3.0",
+ "bin": {
+ "anastasis-cli": "./bin/anastasis-cli.mjs"
+ },
+ "type": "module",
+ "scripts": {
+ "compile": "tsc && ./build-node.mjs",
+ "test": "tsc",
+ "clean": "rm -rf lib dist tsconfig.tsbuildinfo",
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "pretty": "prettier --write src"
+ },
+ "files": [
+ "AUTHORS",
+ "README",
+ "COPYING",
+ "bin/",
+ "dist/node",
+ "src/"
+ ],
+ "devDependencies": {
+ "@types/node": "^18.11.17",
+ "prettier": "^3.1.1",
+ "typedoc": "^0.25.4",
+ "typescript": "^5.3.3"
+ },
+ "dependencies": {
+ "@gnu-taler/anastasis-core": "workspace:*",
+ "@gnu-taler/taler-util": "workspace:*",
+ "tslib": "^2.6.2"
+ }
+}
diff --git a/packages/anastasis-cli/src/import-meta-url.js b/packages/anastasis-cli/src/import-meta-url.js
new file mode 100644
index 000000000..c0e657160
--- /dev/null
+++ b/packages/anastasis-cli/src/import-meta-url.js
@@ -0,0 +1,2 @@
+// Helper to make 'import.meta.url' available in esbuild-bundled code as well.
+export const import_meta_url = require("url").pathToFileURL(__filename);
diff --git a/packages/anastasis-cli/src/index.ts b/packages/anastasis-cli/src/index.ts
new file mode 100644
index 000000000..7c011569f
--- /dev/null
+++ b/packages/anastasis-cli/src/index.ts
@@ -0,0 +1,87 @@
+import { clk } from "@gnu-taler/taler-util/clk";
+import {
+ discoverPolicies,
+ getBackupStartState,
+ getRecoveryStartState,
+ reduceAction,
+} from "@gnu-taler/anastasis-core";
+import fs from "fs";
+import { j2s } from "@gnu-taler/taler-util";
+
+export const reducerCli = clk.program("anastasis-cli", {
+ help: "Command line interface for Anastasis.",
+});
+
+reducerCli
+ .subcommand("reducer", "reduce", {
+ help: "Run the anastasis reducer",
+ })
+ .flag("initBackup", ["-b", "--backup"])
+ .flag("initRecovery", ["-r", "--restore"])
+ .maybeOption("argumentsJson", ["-a", "--arguments"], clk.STRING)
+ .maybeArgument("action", clk.STRING)
+ .maybeArgument("stateFile", clk.STRING)
+ .action(async (x) => {
+ if (x.reducer.initBackup) {
+ console.log(JSON.stringify(await getBackupStartState()));
+ return;
+ } else if (x.reducer.initRecovery) {
+ console.log(JSON.stringify(await getRecoveryStartState()));
+ return;
+ }
+
+ const action = x.reducer.action;
+ if (!action) {
+ console.log("action required");
+ return;
+ }
+
+ let lastState: any;
+ if (x.reducer.stateFile) {
+ const s = fs.readFileSync(x.reducer.stateFile, { encoding: "utf-8" });
+ lastState = JSON.parse(s);
+ } else {
+ const s = await read(process.stdin);
+ lastState = JSON.parse(s);
+ }
+
+ let args: any;
+ if (x.reducer.argumentsJson) {
+ args = JSON.parse(x.reducer.argumentsJson);
+ } else {
+ args = {};
+ }
+
+ const nextState = await reduceAction(lastState, action, args);
+ console.log(JSON.stringify(nextState));
+ });
+
+reducerCli
+ .subcommand("discover", "discover", {
+ help: "Run the anastasis reducer",
+ })
+ .maybeArgument("stateFile", clk.STRING)
+ .action(async (args) => {
+ let lastState: any;
+ if (args.discover.stateFile) {
+ const s = fs.readFileSync(args.discover.stateFile, { encoding: "utf-8" });
+ lastState = JSON.parse(s);
+ } else {
+ const s = await read(process.stdin);
+ lastState = JSON.parse(s);
+ }
+ const res = await discoverPolicies(lastState);
+ console.log(j2s(res));
+ });
+
+async function read(stream: NodeJS.ReadStream): Promise<string> {
+ const chunks = [];
+ for await (const chunk of stream) {
+ chunks.push(chunk);
+ }
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+export function reducerCliMain() {
+ reducerCli.run();
+}
diff --git a/packages/anastasis-cli/tsconfig.json b/packages/anastasis-cli/tsconfig.json
new file mode 100644
index 000000000..7675edfbc
--- /dev/null
+++ b/packages/anastasis-cli/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compileOnSave": true,
+ "compilerOptions": {
+ "composite": true,
+ "target": "ES2020",
+ "module": "Node16",
+ "moduleResolution": "Node16",
+ "sourceMap": true,
+ "lib": ["ES2020"],
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "strict": true,
+ "strictPropertyInitialization": false,
+ "outDir": "lib",
+ "noImplicitAny": true,
+ "noImplicitThis": true,
+ "incremental": true,
+ "esModuleInterop": true,
+ "importHelpers": true,
+ "rootDir": "src",
+ "baseUrl": "./src",
+ "typeRoots": ["./node_modules/@types"]
+ },
+ "include": ["src/**/*"],
+ "references": [
+ {
+ "path": "../anastasis-core/"
+ },
+ {
+ "path": "../taler-util/"
+ }
+ ]
+}
diff --git a/packages/anastasis-core/README.md b/packages/anastasis-core/README.md
new file mode 100644
index 000000000..f3696c768
--- /dev/null
+++ b/packages/anastasis-core/README.md
@@ -0,0 +1,3 @@
+# anastasis-core
+
+This package implements the core client logic of Anastasis in TypeScript.
diff --git a/packages/anastasis-core/bin/anastasis-ts-reducer.js b/packages/anastasis-core/bin/anastasis-ts-reducer.js
new file mode 100755
index 000000000..37d3eaace
--- /dev/null
+++ b/packages/anastasis-core/bin/anastasis-ts-reducer.js
@@ -0,0 +1,7 @@
+#!/usr/bin/env node
+
+async function r() {
+ (await import("../dist/anastasis-cli.js")).reducerCliMain();
+}
+
+r();
diff --git a/packages/anastasis-core/package.json b/packages/anastasis-core/package.json
index 8dbef2d45..619aa9a2e 100644
--- a/packages/anastasis-core/package.json
+++ b/packages/anastasis-core/package.json
@@ -1,31 +1,29 @@
{
- "name": "anastasis-core",
- "version": "0.0.1",
+ "name": "@gnu-taler/anastasis-core",
+ "version": "0.10.6",
"description": "",
"main": "./lib/index.js",
"module": "./lib/index.js",
"types": "./lib/index.d.ts",
"scripts": {
- "prepare": "tsc",
"compile": "tsc",
"pretty": "prettier --write src",
"test": "tsc && ava",
"coverage": "tsc && nyc ava",
- "clean": "rimraf dist lib tsconfig.tsbuildinfo"
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo"
},
"author": "Florian Dold <dold@taler.net>",
"license": "AGPL-3-or-later",
"type": "module",
"devDependencies": {
- "ava": "^3.15.0",
- "typescript": "^4.4.3"
+ "ava": "^6.0.1",
+ "typescript": "^5.3.3"
},
"dependencies": {
- "@gnu-taler/taler-util": "workspace:^0.8.3",
- "fetch-ponyfill": "^7.1.0",
- "fflate": "^0.6.0",
- "hash-wasm": "^4.9.0",
- "node-fetch": "^3.0.0"
+ "@gnu-taler/taler-util": "workspace:*",
+ "fflate": "^0.8.1",
+ "tslib": "^2.6.2"
},
"ava": {
"files": [
diff --git a/packages/anastasis-core/src/anastasis-data.ts b/packages/anastasis-core/src/anastasis-data.ts
index 4946e9dfd..d69bb319b 100644
--- a/packages/anastasis-core/src/anastasis-data.ts
+++ b/packages/anastasis-core/src/anastasis-data.ts
@@ -1,5 +1,5 @@
// This file is auto-generated, do not modify.
-// Generated from v0.2.0-4-g61ea83c on Tue, 05 Oct 2021 10:40:32 +0200
+// Generated from v0.2.0-151-g2ae958d on Thu, 14 Apr 2022 20:38:58 +0200
// To re-generate, run contrib/gen-ts.sh from the main anastasis code base.
export const anastasisData = {
@@ -8,29 +8,45 @@ export const anastasisData = {
"SPDX-License-Identifier": "GPL3.0-or-later",
anastasis_provider: [
{
- url: "https://anastasis.demo.taler.net/",
- currency: "KUDOS",
+ url: "https://v1.anastasis.taler.net/",
+ name: "Bern University of Applied Sciences, Switzerland",
},
{
- url: "https://kudos.demo.anastasis.lu/",
- currency: "KUDOS",
+ url: "https://v1.anastasis.codeblau.de/",
+ name: "Codeblau GmbH, Germany",
},
+ // {
+ // url: "https://v1.anastasis.openw3b.org/",
+ // name: "Openw3b Foundation, India",
+ // },
{
- url: "http://localhost:8086/",
- currency: "TESTKUDOS",
+ url: "https://v1.anastasis.lu/",
+ name: "Anastasis SARL, Luxembourg",
},
{
- url: "http://localhost:8087/",
- currency: "TESTKUDOS",
+ url: "https://v1.anastasis.taler.net/",
+ restricted: "xx",
},
{
- url: "http://localhost:8088/",
- currency: "TESTKUDOS",
- },
- {
- url: "http://localhost:8089/",
- currency: "TESTKUDOS",
+ url: "https://v1.anastasis.lu/",
+ restricted: "xx",
},
+ // {
+ // url: "http://localhost:8086/",
+ // restricted: "xx",
+ // },
+ // {
+ // url: "http://localhost:8087/",
+ // restricted: "xx",
+ // },
+ // {
+ // url: "http://localhost:8088/",
+ // restricted: "xx",
+ // },
+ // {
+ // url: "http://localhost:8089/",
+ // restricted: "xx",
+ // },
],
},
countriesList: {
@@ -45,7 +61,6 @@ export const anastasisData = {
de_DE: "Albanien",
en_UK: "Albania",
},
- currency: "ALL",
call_code: "+355",
},
{
@@ -56,7 +71,6 @@ export const anastasisData = {
de_DE: "Belgien",
en_UK: "Belgium",
},
- currency: "EUR",
call_code: "+32",
},
{
@@ -69,7 +83,6 @@ export const anastasisData = {
fr_FR: "Suisse",
en_UK: "Swiss",
},
- currency: "CHF",
call_code: "+41",
},
{
@@ -79,7 +92,6 @@ export const anastasisData = {
name_i18n: {
en_UK: "Czech Republic",
},
- currency: "CZK",
call_code: "+420",
},
{
@@ -93,7 +105,6 @@ export const anastasisData = {
fr_FR: "Allemagne",
en_UK: "Germany",
},
- currency: "EUR",
call_code: "+49",
},
{
@@ -104,7 +115,6 @@ export const anastasisData = {
name_i18n: {
en_UK: "Denmark",
},
- currency: "DKK",
call_code: "+45",
},
{
@@ -115,10 +125,19 @@ export const anastasisData = {
name_i18n: {
es_ES: "España",
},
- currency: "EUR",
call_code: "+44",
},
{
+ code: "fr",
+ name: "France",
+ continent: "Europe",
+ name_i18n: {
+ de_DE: "Frankreich",
+ fr_FR: "La France",
+ },
+ call_code: "+33",
+ },
+ {
code: "in",
name: "India",
continent: "India",
@@ -129,7 +148,6 @@ export const anastasisData = {
fr_FR: "l'Inde",
en_UK: "India",
},
- currency: "INR",
call_code: "+91",
},
{
@@ -140,7 +158,6 @@ export const anastasisData = {
de_DE: "Italien",
en_UK: "Italy",
},
- currency: "EUR",
call_code: "+39",
},
{
@@ -153,17 +170,26 @@ export const anastasisData = {
de_CH: "Japan",
en_UK: "Japan",
},
- currency: "JPY",
call_code: "+81",
},
{
- code: "sl",
+ code: "nl",
+ name: "Netherlands",
+ continent: "Europe",
+ name_i18n: {
+ de_DE: "Niederlande",
+ nl_NL: "Nederland",
+ en_UK: "Netherlands",
+ },
+ call_code: "+31",
+ },
+ {
+ code: "sk",
name: "Slovakia",
continent: "Europe",
name_i18n: {
en_UK: "Slovakia",
},
- currency: "EUR",
call_code: "+421",
},
{
@@ -177,38 +203,21 @@ export const anastasisData = {
fr_FR: "États-Unis d'Amérique (USA)",
en_UK: "United States of America (USA)",
},
- currency: "USD",
call_code: "+1",
},
- {
- code: "xx",
- name: "Testland",
- continent: "Testcontinent",
- continent_i18n: { de_DE: "Testkontinent" },
- name_i18n: {
- de_DE: "Testlandt",
- de_CH: "Testlandi",
- fr_FR: "Testpais",
- en_UK: "Testland",
- },
- currency: "TESTKUDOS",
- call_code: "+00",
- },
- {
- code: "xy",
- name: "Demoland",
- continent: "Testcontinent",
- continent_i18n: { de_DE: "Testkontinent" },
- name_i18n: {
- de_DE: "Demolandt",
- de_CH: "Demolandi",
- fr_FR: "Demopais",
- en_UK: "Demoland",
- },
- currency: "KUDOS",
- call_code: "+01",
- },
- ],
+ // {
+ // code: "xx",
+ // name: "Testland",
+ // continent: "Demoworld",
+ // name_i18n: {
+ // de_DE: "Testlandt",
+ // de_CH: "Testlandi",
+ // fr_FR: "Testpais",
+ // en_UK: "Testland",
+ // },
+ // call_code: "+00",
+ // },
+ ].sort((a, b) => a.name > b.name ? 1 : (a.name < b.name ? -1 : 0)),
},
countryDetails: {
al: {
@@ -320,8 +329,9 @@ export const anastasisData = {
widget: "anastasis_gtk_ia_ahv",
uuid: "1da87570-ba16-4f62-8a7e-cbda92f51591",
"validation-regex":
- "^(756).[0-9]{4}.[0-9]{4}.[0-9]{2}|(756)[0-9]{10}$",
+ "^(756)\\.[0-9]{4}\\.[0-9]{4}\\.[0-9]{2}|(756)[0-9]{10}$",
"validation-logic": "CH_AHV_check",
+ autocomplete: "???.????.????.??",
},
],
},
@@ -386,19 +396,6 @@ export const anastasisData = {
},
{
type: "string",
- name: "tax_number",
- label: "Taxpayer identification number",
- label_i18n: {
- de_DE: "Steuerliche Identifikationsnummer",
- en: "German taxpayer identification number",
- },
- widget: "anastasis_gtk_ia_tax_de",
- uuid: "dae48f85-e3ff-47a4-a4a3-ed981ed8c3c6",
- "validation-regex": "^[0-9]{11}$",
- "validation-logic": "DE_TIN_check",
- },
- {
- type: "string",
name: "social_security_number",
label: "Social security number",
label_i18n: {
@@ -411,6 +408,19 @@ export const anastasisData = {
"validation-logic": "DE_SVN_check",
optional: true,
},
+ {
+ type: "string",
+ name: "tax_number",
+ label: "Taxpayer identification number",
+ label_i18n: {
+ de_DE: "Steuerliche Identifikationsnummer",
+ en: "German taxpayer identification number",
+ },
+ widget: "anastasis_gtk_ia_tin_de",
+ uuid: "dae48f85-e3ff-47a4-a4a3-ed981ed8c3c6",
+ "validation-regex": "^[0-9]{11}$",
+ "validation-logic": "DE_TIN_check",
+ },
],
},
dk: {
@@ -496,6 +506,46 @@ export const anastasisData = {
},
],
},
+ fr: {
+ license: "GPLv3+",
+ "SPDX-License-Identifier": "GPL3.0-or-later",
+ required_attributes: [
+ {
+ type: "string",
+ name: "full_name",
+ label: "Full name",
+ widget: "anastasis_gtk_ia_full_name",
+ uuid: "9e8f463f-575f-42cb-85f3-759559997331",
+ },
+ {
+ type: "date",
+ name: "birthdate",
+ label: "Birthdate",
+ widget: "anastasis_gtk_ia_birthdate",
+ uuid: "83d655c7-bdb6-484d-904e-80c1058c8854",
+ },
+ {
+ type: "string",
+ name: "birthplace",
+ label: "Birthplace",
+ widget: "anastasis_gtk_ia_birthplace",
+ uuid: "4c822e8e-89c6-11eb-95c4-8b077ad8489f",
+ },
+ {
+ type: "string",
+ name: "social_security_number",
+ label: "Code Insee",
+ label_i18n: {
+ fr_FR: "Code Insee",
+ en: "INSEE code",
+ },
+ widget: "anastasis_gtk_ia_insee_fr",
+ uuid: "2f36a81c-3f6d-41f3-97ee-9c885bc41873",
+ "validation-regex": "^[0-9]{15}$",
+ "validation-logic": "FR_INSEE_check",
+ },
+ ],
+ },
in: {
license: "GPLv3+",
"SPDX-License-Identifier": "GPL3.0-or-later",
@@ -608,6 +658,46 @@ export const anastasisData = {
},
],
},
+ nl: {
+ license: "GPLv3+",
+ "SPDX-License-Identifier": "GPL3.0-or-later",
+ required_attributes: [
+ {
+ type: "string",
+ name: "full_name",
+ label: "Full name",
+ widget: "anastasis_gtk_ia_full_name",
+ uuid: "9e8f463f-575f-42cb-85f3-759559997331",
+ },
+ {
+ type: "date",
+ name: "birthdate",
+ label: "Birthdate",
+ widget: "anastasis_gtk_ia_birthdate",
+ uuid: "83d655c7-bdb6-484d-904e-80c1058c8854",
+ },
+ {
+ type: "string",
+ name: "birthplace",
+ label: "Birthplace",
+ widget: "anastasis_gtk_ia_birthplace",
+ uuid: "4c822e8e-89c6-11eb-95c4-8b077ad8489f",
+ },
+ {
+ type: "string",
+ name: "social_security_number",
+ label: "Citizen Service Number",
+ label_i18n: {
+ nl_NL: "Burgerservicenummer (BSN)",
+ en: "Citizen Service Number",
+ },
+ widget: "anastasis_gtk_ia_ssn_nl",
+ uuid: "b6bf1f14-1f85-4afb-af21-f54b88490bdc",
+ "validation-regex": "^[1-9][0-9]{8}$",
+ "validation-logic": "NL_BSN_check",
+ },
+ ],
+ },
sk: {
license: "GPLv3+",
"SPDX-License-Identifier": "GPL3.0-or-later",
@@ -676,42 +766,15 @@ export const anastasisData = {
},
widget: "anastasis_gtk_ia_ssn_us",
uuid: "310a138c-b0b7-4985-b8b8-d00e765e9f9b",
- "validation-regex": "^d{3}-d{2}-d{4}$",
+ "validation-regex": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$",
+ autocomplete: "???-??-????",
},
],
},
xx: {
license: "GPLv3+",
"SPDX-License-Identifier": "GPL3.0-or-later",
- required_attributes: [
- {
- type: "string",
- name: "full_name",
- label: "Full name",
- widget: "anastasis_gtk_ia_full_name",
- uuid: "9e8f463f-575f-42cb-85f3-759559997331",
- },
- {
- type: "date",
- name: "birthdate",
- label: "Birthdate",
- widget: "anastasis_gtk_ia_birthdate",
- uuid: "83d655c7-bdb6-484d-904e-80c1058c8854",
- },
- {
- type: "string",
- name: "sq_number",
- label: "Square number",
- widget: "anastasis_gtk_xx_square",
- uuid: "ed790bca-89bf-11eb-96f2-233996cf644e",
- "validation-regex": "^[0-9]+$",
- "validation-logic": "XX_SQUARE_check",
- },
- ],
- },
- xy: {
- license: "GPLv3+",
- "SPDX-License-Identifier": "GPL3.0-or-later",
+ restricted: "xx",
required_attributes: [
{
type: "string",
@@ -735,6 +798,16 @@ export const anastasisData = {
uuid: "39190a95-cacb-4412-8bae-1f7da3f980b4",
"validation-regex": "^[0-9]+$",
"validation-logic": "XY_PRIME_check",
+ optional: true,
+ },
+ {
+ type: "string",
+ name: "sq_number",
+ label: "Square number",
+ widget: "anastasis_gtk_xx_square",
+ uuid: "ed790bca-89bf-11eb-96f2-233996cf644e",
+ "validation-regex": "^[0-9]+$",
+ "validation-logic": "XX_SQUARE_check",
},
],
},
diff --git a/packages/anastasis-core/src/challenge-feedback-types.ts b/packages/anastasis-core/src/challenge-feedback-types.ts
new file mode 100644
index 000000000..de615b315
--- /dev/null
+++ b/packages/anastasis-core/src/challenge-feedback-types.ts
@@ -0,0 +1,159 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AmountString, HttpStatusCode } from "@gnu-taler/taler-util";
+
+export enum ChallengeFeedbackStatus {
+ Solved = "solved",
+ CodeInFile = "code-in-file",
+ CodeSent = "code-sent",
+ ServerFailure = "server-failure",
+ TruthUnknown = "truth-unknown",
+ TalerPayment = "taler-payment",
+ Unsupported = "unsupported",
+ RateLimitExceeded = "rate-limit-exceeded",
+ IbanInstructions = "iban-instructions",
+ IncorrectAnswer = "incorrect-answer",
+}
+
+export type ChallengeFeedback =
+ | ChallengeFeedbackSolved
+ | ChallengeFeedbackCodeInFile
+ | ChallengeFeedbackCodeSent
+ | ChallengeFeedbackIncorrectAnswer
+ | ChallengeFeedbackTalerPaymentRequired
+ | ChallengeFeedbackServerFailure
+ | ChallengeFeedbackRateLimitExceeded
+ | ChallengeFeedbackTruthUnknown
+ | ChallengeFeedbackUnsupported
+ | ChallengeFeedbackBankTransferRequired;
+
+/**
+ * Challenge has been solved and the key share has
+ * been retrieved.
+ */
+export interface ChallengeFeedbackSolved {
+ state: ChallengeFeedbackStatus.Solved;
+}
+
+export interface ChallengeFeedbackIncorrectAnswer {
+ state: ChallengeFeedbackStatus.IncorrectAnswer;
+}
+
+export interface ChallengeFeedbackCodeInFile {
+ state: ChallengeFeedbackStatus.CodeInFile;
+ filename: string;
+ display_hint: string;
+}
+
+export interface ChallengeFeedbackCodeSent {
+ state: ChallengeFeedbackStatus.CodeSent;
+ display_hint: string;
+ address_hint: string;
+}
+
+/**
+ * The challenge given by the server is unsupported
+ * by the current anastasis client.
+ */
+export interface ChallengeFeedbackUnsupported {
+ state: ChallengeFeedbackStatus.Unsupported;
+
+ /**
+ * Human-readable identifier of the unsupported method.
+ */
+ unsupported_method: string;
+}
+
+/**
+ * The user tried to answer too often with a wrong answer.
+ */
+export interface ChallengeFeedbackRateLimitExceeded {
+ state: ChallengeFeedbackStatus.RateLimitExceeded;
+}
+
+/**
+ * Instructions for performing authentication via an
+ * IBAN bank transfer.
+ */
+export interface ChallengeFeedbackBankTransferRequired {
+ state: ChallengeFeedbackStatus.IbanInstructions;
+
+ /**
+ * Amount that should be transferred for a successful authentication.
+ */
+ challenge_amount: AmountString;
+
+ /**
+ * Account that should be credited.
+ */
+ target_iban: string;
+
+ /**
+ * Creditor name.
+ */
+ target_business_name: string;
+
+ /**
+ * Unstructured remittance information that should
+ * be contained in the bank transfer.
+ */
+ wire_transfer_subject: string;
+
+ answer_code: number;
+}
+
+/**
+ * The server experienced a temporary failure.
+ */
+export interface ChallengeFeedbackServerFailure {
+ state: ChallengeFeedbackStatus.ServerFailure;
+ http_status: HttpStatusCode | 0;
+
+ /**
+ * Taler-style error response, if available.
+ */
+ error_response?: any;
+}
+
+/**
+ * The truth is unknown to the provider. There
+ * is no reason to continue trying to solve any
+ * challenges in the policy.
+ */
+export interface ChallengeFeedbackTruthUnknown {
+ state: ChallengeFeedbackStatus.TruthUnknown;
+}
+
+/**
+ * A payment is required before the user can
+ * even attempt to solve the challenge.
+ */
+export interface ChallengeFeedbackTalerPaymentRequired {
+ state: ChallengeFeedbackStatus.TalerPayment;
+
+ taler_pay_uri: string;
+
+ provider: string;
+
+ /**
+ * FIXME: Why is this required?!
+ */
+ payment_secret: string;
+}
diff --git a/packages/anastasis-core/src/crypto.ts b/packages/anastasis-core/src/crypto.ts
index da8338636..8bc004e95 100644
--- a/packages/anastasis-core/src/crypto.ts
+++ b/packages/anastasis-core/src/crypto.ts
@@ -1,22 +1,38 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
import {
- bytesToString,
canonicalJson,
decodeCrock,
encodeCrock,
getRandomBytes,
- kdf,
kdfKw,
secretbox,
crypto_sign_keyPair_fromSeed,
stringToBytes,
secretbox_open,
+ hash,
+ bytesToString,
+ hashArgon2id,
} from "@gnu-taler/taler-util";
-import { gzipSync } from "fflate";
-import { argon2id } from "hash-wasm";
export type Flavor<T, FlavorT extends string> = T & {
_flavor?: `anastasis.${FlavorT}`;
};
+
export type FlavorP<T, FlavorT extends string, S extends number> = T & {
_flavor?: `anastasis.${FlavorT}`;
_size?: S;
@@ -55,15 +71,13 @@ export async function userIdentifierDerive(
): Promise<UserIdentifier> {
const canonIdData = canonicalJson(idData);
const hashInput = stringToBytes(canonIdData);
- const result = await argon2id({
- hashLength: 64,
- iterations: 3,
- memorySize: 1024 /* kibibytes */,
- parallelism: 1,
- password: hashInput,
- salt: decodeCrock(serverSalt),
- outputType: "binary",
- });
+ const result = await hashArgon2id(
+ hashInput, // password
+ decodeCrock(serverSalt), // salt
+ 3, // iterations
+ 1024, // memoryLimit (kibibytes)
+ 64, // hashLength
+ );
return encodeCrock(result);
}
@@ -111,6 +125,46 @@ export async function decryptRecoveryDocument(
return anastasisDecrypt(asOpaque(userId), recoveryDocData, "erd");
}
+export interface PolicyMetadata {
+ secret_name: string;
+ policy_hash: string;
+}
+
+export async function encryptPolicyMetadata(
+ userId: UserIdentifier,
+ metadata: PolicyMetadata,
+): Promise<OpaqueData> {
+ const metadataBytes = typedArrayConcat([
+ decodeCrock(metadata.policy_hash),
+ stringToBytes(metadata.secret_name),
+ ]);
+ const nonce = encodeCrock(getRandomBytes(nonceSize));
+ return anastasisEncrypt(
+ nonce,
+ asOpaque(userId),
+ encodeCrock(metadataBytes),
+ "rmd",
+ );
+}
+
+export async function decryptPolicyMetadata(
+ userId: UserIdentifier,
+ metadataEnc: OpaqueData,
+): Promise<PolicyMetadata> {
+ // @ts-ignore
+ console.log("metadataEnc", metadataEnc);
+ const plain = await anastasisDecrypt(asOpaque(userId), metadataEnc, "rmd");
+ // @ts-ignore
+ console.log("plain:", plain);
+ const metadataBytes = decodeCrock(plain);
+ const policyHash = encodeCrock(metadataBytes.slice(0, 64));
+ const secretName = bytesToString(metadataBytes.slice(64));
+ return {
+ policy_hash: policyHash,
+ secret_name: secretName,
+ };
+}
+
export function typedArrayConcat(chunks: Uint8Array[]): Uint8Array {
let payloadLen = 0;
for (const c of chunks) {
@@ -175,11 +229,11 @@ async function anastasisDecrypt(
const nonceBuf = ctBuf.slice(0, nonceSize);
const enc = ctBuf.slice(nonceSize);
const key = await deriveKey(keySeed, encodeCrock(nonceBuf), salt);
- const cipherText = secretbox_open(enc, nonceBuf, key);
- if (!cipherText) {
+ const clearText = secretbox_open(enc, nonceBuf, key);
+ if (!clearText) {
throw Error("could not decrypt");
}
- return encodeCrock(cipherText);
+ return encodeCrock(clearText);
}
export const asOpaque = (x: string): OpaqueData => x;
@@ -248,7 +302,6 @@ export async function coreSecretRecover(args: {
args.encryptedMasterKey,
"emk",
);
- console.log("recovered master key", masterKey);
return await anastasisDecrypt(masterKey, args.encryptedCoreSecret, "cse");
}
@@ -283,20 +336,22 @@ export async function coreSecretEncrypt(
};
}
+export async function pinAnswerHash(pin: number): Promise<SecureAnswerHash> {
+ return encodeCrock(hash(stringToBytes(pin.toString())));
+}
+
export async function secureAnswerHash(
answer: string,
truthUuid: TruthUuid,
questionSalt: TruthSalt,
): Promise<SecureAnswerHash> {
- const powResult = await argon2id({
- hashLength: 64,
- iterations: 3,
- memorySize: 1024 /* kibibytes */,
- parallelism: 1,
- password: stringToBytes(answer),
- salt: decodeCrock(questionSalt),
- outputType: "binary",
- });
+ const powResult = await hashArgon2id(
+ stringToBytes(answer), // password
+ decodeCrock(questionSalt), // salt
+ 3, // iterations
+ 1024, // memorySize (kibibytes)
+ 64, // hashLength
+ );
const kdfResult = kdfKw({
outputLength: 64,
salt: decodeCrock(truthUuid),
diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts
index b4e911ffb..9a774d0ff 100644
--- a/packages/anastasis-core/src/index.ts
+++ b/packages/anastasis-core/src/index.ts
@@ -1,53 +1,102 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
import {
+ AmountJson,
+ AmountLike,
+ Amounts,
AmountString,
buildSigPS,
bytesToString,
Codec,
codecForAny,
decodeCrock,
+ Duration,
eddsaSign,
encodeCrock,
getRandomBytes,
hash,
+ HttpStatusCode,
+ Logger,
+ parsePayUri,
stringToBytes,
TalerErrorCode,
+ TalerProtocolTimestamp,
TalerSignaturePurpose,
- Timestamp,
+ AbsoluteTime,
+ URL,
+ j2s,
} from "@gnu-taler/taler-util";
+import { HttpResponse, createPlatformHttpLib } from "@gnu-taler/taler-util/http";
import { anastasisData } from "./anastasis-data.js";
import {
+ codecForChallengeInstructionMessage,
EscrowConfigurationResponse,
+ RecoveryMetaResponse,
TruthUploadRequest,
} from "./provider-types.js";
import {
- ActionArgAddAuthentication,
- ActionArgDeleteAuthentication,
- ActionArgDeletePolicy,
- ActionArgEnterSecret,
- ActionArgEnterSecretName,
- ActionArgEnterUserAttributes,
+ ActionArgsAddAuthentication,
+ ActionArgsDeleteAuthentication,
+ ActionArgsDeletePolicy,
+ ActionArgsEnterSecret,
+ ActionArgsEnterSecretName,
+ ActionArgsEnterUserAttributes,
+ ActionArgsAddPolicy,
+ ActionArgsSelectContinent,
+ ActionArgsSelectCountry,
ActionArgsSelectChallenge,
ActionArgsSolveChallengeRequest,
+ ActionArgsUpdateExpiration,
AuthenticationProviderStatus,
AuthenticationProviderStatusOk,
AuthMethod,
BackupStates,
+ codecForActionArgsEnterUserAttributes,
+ codecForActionArgsAddPolicy,
+ codecForActionArgsSelectChallenge,
+ codecForActionArgSelectContinent,
+ codecForActionArgSelectCountry,
+ codecForActionArgsUpdateExpiration,
ContinentInfo,
CountryInfo,
- MethodSpec,
- Policy,
- PolicyProvider,
RecoveryInformation,
RecoveryInternalData,
RecoveryStates,
ReducerState,
ReducerStateBackup,
- ReducerStateBackupUserAttributesCollecting,
ReducerStateError,
ReducerStateRecovery,
SuccessDetails,
+ codecForActionArgsChangeVersion,
+ ActionArgsChangeVersion,
+ TruthMetaData,
+ ActionArgsUpdatePolicy,
+ ActionArgsAddProvider,
+ ActionArgsDeleteProvider,
+ DiscoveryCursor,
+ DiscoveryResult,
+ PolicyMetaInfo,
+ ChallengeInfo,
+ AggregatedPolicyMetaInfo,
+ AuthenticationProviderStatusMap,
} from "./reducer-types.js";
-import fetchPonyfill from "fetch-ponyfill";
import {
accountKeypairDerive,
asOpaque,
@@ -61,8 +110,6 @@ import {
PolicySalt,
TruthSalt,
secureAnswerHash,
- TruthKey,
- TruthUuid,
UserIdentifier,
userIdentifierDerive,
typedArrayConcat,
@@ -70,13 +117,33 @@ import {
decryptKeyShare,
KeyShare,
coreSecretRecover,
+ pinAnswerHash,
+ decryptPolicyMetadata,
+ encryptPolicyMetadata,
} from "./crypto.js";
import { unzlibSync, zlibSync } from "fflate";
-import { EscrowMethod, RecoveryDocument } from "./recovery-document-types.js";
-
-const { fetch, Request, Response, Headers } = fetchPonyfill({});
+import {
+ ChallengeType,
+ EscrowMethod,
+ RecoveryDocument,
+} from "./recovery-document-types.js";
+import { ProviderInfo, suggestPolicies } from "./policy-suggestion.js";
+import {
+ ChallengeFeedback,
+ ChallengeFeedbackStatus,
+} from "./challenge-feedback-types.js";
export * from "./reducer-types.js";
+export * as validators from "./validators.js";
+export * from "./challenge-feedback-types.js";
+
+const httpLib = createPlatformHttpLib({
+ enableThrottling: false,
+});
+
+const logger = new Logger("anastasis-core:index.ts");
+
+const ANASTASIS_HTTP_HEADER_POLICY_META_DATA = "Anastasis-Policy-Meta-Data";
function getContinents(): ContinentInfo[] {
const continentSet = new Set<string>();
@@ -94,14 +161,45 @@ function getContinents(): ContinentInfo[] {
return continents;
}
+interface ErrorDetails {
+ code: TalerErrorCode;
+ message?: string;
+ hint?: string;
+}
+
+export class ReducerError extends Error {
+ constructor(public errorJson: ErrorDetails) {
+ super(
+ errorJson.message ??
+ errorJson.hint ??
+ `${TalerErrorCode[errorJson.code]}`,
+ );
+
+ // Set the prototype explicitly.
+ Object.setPrototypeOf(this, ReducerError.prototype);
+ }
+}
+
+/**
+ * Get countries for a continent, abort with ReducerError
+ * exception when continent doesn't exist.
+ */
function getCountries(continent: string): CountryInfo[] {
- return anastasisData.countriesList.countries.filter(
+ const countries = anastasisData.countriesList.countries.filter(
(x) => x.continent === continent,
);
+ if (countries.length <= 0) {
+ throw new ReducerError({
+ code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID,
+ hint: `continent ${continent} not found`,
+ });
+ }
+ return countries;
}
export async function getBackupStartState(): Promise<ReducerStateBackup> {
return {
+ reducer_type: "backup",
backup_state: BackupStates.ContinentSelecting,
continents: getContinents(),
};
@@ -109,30 +207,43 @@ export async function getBackupStartState(): Promise<ReducerStateBackup> {
export async function getRecoveryStartState(): Promise<ReducerStateRecovery> {
return {
+ reducer_type: "recovery",
recovery_state: RecoveryStates.ContinentSelecting,
continents: getContinents(),
};
}
-async function backupSelectCountry(
- state: ReducerStateBackup,
- countryCode: string,
- currencies: string[],
-): Promise<ReducerStateError | ReducerStateBackupUserAttributesCollecting> {
+async function selectCountry(
+ selectedContinent: string,
+ args: ActionArgsSelectCountry,
+): Promise<Partial<ReducerStateBackup> & Partial<ReducerStateRecovery>> {
+ const countryCode = args.country_code;
const country = anastasisData.countriesList.countries.find(
(x) => x.code === countryCode,
);
if (!country) {
- return {
+ throw new ReducerError({
code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
hint: "invalid country selected",
- };
+ });
}
- const providers: { [x: string]: {} } = {};
+ if (country.continent !== selectedContinent) {
+ throw new ReducerError({
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "selected country is not in selected continent",
+ });
+ }
+
+ const providers: { [x: string]: AuthenticationProviderStatus } = {};
for (const prov of anastasisData.providersList.anastasis_provider) {
- if (currencies.includes(prov.currency)) {
- providers[prov.url] = {};
+ let shouldAdd =
+ country.code === prov.restricted ||
+ (country.code !== "xx" && !prov.restricted);
+ if (shouldAdd) {
+ providers[prov.url] = {
+ status: "not-contacted",
+ };
}
}
@@ -140,47 +251,31 @@ async function backupSelectCountry(
.required_attributes;
return {
- ...state,
- backup_state: BackupStates.UserAttributesCollecting,
selected_country: countryCode,
- currencies,
required_attributes: ra,
authentication_providers: providers,
};
}
+async function backupSelectCountry(
+ state: ReducerStateBackup,
+ args: ActionArgsSelectCountry,
+): Promise<ReducerStateError | ReducerStateBackup> {
+ return {
+ ...state,
+ ...(await selectCountry(state.selected_continent!, args)),
+ backup_state: BackupStates.UserAttributesCollecting,
+ };
+}
+
async function recoverySelectCountry(
state: ReducerStateRecovery,
- countryCode: string,
- currencies: string[],
+ args: ActionArgsSelectCountry,
): Promise<ReducerStateError | ReducerStateRecovery> {
- const country = anastasisData.countriesList.countries.find(
- (x) => x.code === countryCode,
- );
- if (!country) {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: "invalid country selected",
- };
- }
-
- const providers: { [x: string]: {} } = {};
- for (const prov of anastasisData.providersList.anastasis_provider) {
- if (currencies.includes(prov.currency)) {
- providers[prov.url] = {};
- }
- }
-
- const ra = (anastasisData.countryDetails as any)[countryCode]
- .required_attributes;
-
return {
...state,
recovery_state: RecoveryStates.UserAttributesCollecting,
- selected_country: countryCode,
- currencies,
- required_attributes: ra,
- authentication_providers: providers,
+ ...(await selectCountry(state.selected_continent!, args)),
};
}
@@ -188,17 +283,19 @@ async function getProviderInfo(
providerBaseUrl: string,
): Promise<AuthenticationProviderStatus> {
// FIXME: Use a reasonable timeout here.
- let resp: Response;
+ let resp: HttpResponse;
try {
- resp = await fetch(new URL("config", providerBaseUrl).href);
+ resp = await httpLib.fetch(new URL("config", providerBaseUrl).href);
} catch (e) {
return {
+ status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
hint: "request to provider failed",
};
}
if (resp.status !== 200) {
return {
+ status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
hint: "unexpected status",
http_status: resp.status,
@@ -206,7 +303,15 @@ async function getProviderInfo(
}
try {
const jsonResp: EscrowConfigurationResponse = await resp.json();
+ if (!jsonResp.provider_salt) {
+ return {
+ status: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_PROVIDER_CONFIG_FAILED,
+ hint: "provider did not have provider salt",
+ };
+ }
return {
+ status: "ok",
http_status: 200,
annual_fee: jsonResp.annual_fee,
business_name: jsonResp.business_name,
@@ -216,12 +321,13 @@ async function getProviderInfo(
type: x.type,
usage_fee: x.cost,
})),
- salt: jsonResp.server_salt,
+ provider_salt: jsonResp.provider_salt,
storage_limit_in_megabytes: jsonResp.storage_limit_in_megabytes,
truth_upload_fee: jsonResp.truth_upload_fee,
- } as AuthenticationProviderStatusOk;
+ };
} catch (e) {
return {
+ status: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
hint: "provider did not return JSON",
};
@@ -230,155 +336,17 @@ async function getProviderInfo(
async function backupEnterUserAttributes(
state: ReducerStateBackup,
- attributes: Record<string, string>,
+ args: ActionArgsEnterUserAttributes,
): Promise<ReducerStateBackup> {
- const providerUrls = Object.keys(state.authentication_providers ?? {});
- const newProviders = state.authentication_providers ?? {};
- for (const url of providerUrls) {
- newProviders[url] = await getProviderInfo(url);
- }
+ const attributes = args.identity_attributes;
const newState = {
...state,
backup_state: BackupStates.AuthenticationsEditing,
- authentication_providers: newProviders,
identity_attributes: attributes,
};
return newState;
}
-interface PolicySelectionResult {
- policies: Policy[];
- policy_providers: PolicyProvider[];
-}
-
-type MethodSelection = number[];
-
-function enumerateSelections(n: number, m: number): MethodSelection[] {
- const selections: MethodSelection[] = [];
- const a = new Array(n);
- const sel = (i: number) => {
- if (i === n) {
- selections.push([...a]);
- return;
- }
- const start = i == 0 ? 0 : a[i - 1] + 1;
- for (let j = start; j < m; j++) {
- a[i] = j;
- sel(i + 1);
- }
- };
- sel(0);
- return selections;
-}
-
-/**
- * Provider information used during provider/method mapping.
- */
-interface ProviderInfo {
- url: string;
- methodCost: Record<string, AmountString>;
-}
-
-/**
- * Assign providers to a method selection.
- */
-function assignProviders(
- methods: AuthMethod[],
- providers: ProviderInfo[],
- methodSelection: number[],
-): Policy | undefined {
- const selectedProviders: string[] = [];
- for (const mi of methodSelection) {
- const m = methods[mi];
- let found = false;
- for (const prov of providers) {
- if (prov.methodCost[m.type]) {
- selectedProviders.push(prov.url);
- found = true;
- break;
- }
- }
- if (!found) {
- /* No provider found for this method */
- return undefined;
- }
- }
- return {
- methods: methodSelection.map((x, i) => {
- return {
- authentication_method: x,
- provider: selectedProviders[i],
- };
- }),
- };
-}
-
-function suggestPolicies(
- methods: AuthMethod[],
- providers: ProviderInfo[],
-): PolicySelectionResult {
- const numMethods = methods.length;
- if (numMethods === 0) {
- throw Error("no methods");
- }
- let numSel: number;
- if (numMethods <= 2) {
- numSel = numMethods;
- } else if (numMethods <= 4) {
- numSel = numMethods - 1;
- } else if (numMethods <= 6) {
- numSel = numMethods - 2;
- } else if (numMethods == 7) {
- numSel = numMethods - 3;
- } else {
- numSel = 4;
- }
- const policies: Policy[] = [];
- const selections = enumerateSelections(numSel, numMethods);
- console.log("selections", selections);
- for (const sel of selections) {
- const p = assignProviders(methods, providers, sel);
- if (p) {
- policies.push(p);
- }
- }
- return {
- policies,
- policy_providers: providers.map((x) => ({
- provider_url: x.url,
- })),
- };
-}
-
-/**
- * Truth data as stored in the reducer.
- */
-interface TruthMetaData {
- uuid: string;
-
- key_share: string;
-
- policy_index: number;
-
- pol_method_index: number;
-
- /**
- * Nonce used for encrypting the truth.
- */
- nonce: string;
-
- /**
- * Key that the truth (i.e. secret question answer, email address, mobile number, ...)
- * is encrypted with when stored at the provider.
- */
- truth_key: string;
-
- /**
- * Truth-specific salt.
- */
- truth_salt: string;
-}
-
async function getTruthValue(
authMethod: AuthMethod,
truthUuid: string,
@@ -398,9 +366,10 @@ async function getTruthValue(
case "email":
case "totp":
case "iban":
+ case "post":
return authMethod.challenge;
default:
- throw Error("unknown auth type");
+ throw Error(`unknown auth type '${authMethod.type}'`);
}
}
@@ -408,7 +377,6 @@ async function getTruthValue(
* Compress the recovery document and add a size header.
*/
async function compressRecoveryDoc(rd: any): Promise<Uint8Array> {
- console.log("recovery document", rd);
const docBytes = stringToBytes(JSON.stringify(rd));
const sizeHeaderBuf = new ArrayBuffer(4);
const dvbuf = new DataView(sizeHeaderBuf);
@@ -424,14 +392,19 @@ async function uncompressRecoveryDoc(zippedRd: Uint8Array): Promise<any> {
return JSON.parse(bytesToString(res));
}
-async function uploadSecret(
+/**
+ * Prepare the recovery document and truth metadata based
+ * on the selected policies.
+ */
+async function prepareRecoveryData(
state: ReducerStateBackup,
-): Promise<ReducerStateBackup | ReducerStateError> {
+): Promise<ReducerStateBackup> {
const policies = state.policies!;
const secretName = state.secret_name!;
const coreSecret: OpaqueData = encodeCrock(
stringToBytes(JSON.stringify(state.core_secret!)),
);
+
// Truth key is `${methodIndex}/${providerUrl}`
const truthMetadataMap: Record<string, TruthMetaData> = {};
@@ -453,7 +426,7 @@ async function uploadSecret(
tm = {
key_share: encodeCrock(getRandomBytes(32)),
nonce: encodeCrock(getRandomBytes(24)),
- truth_salt: encodeCrock(getRandomBytes(16)),
+ master_salt: encodeCrock(getRandomBytes(16)),
truth_key: encodeCrock(getRandomBytes(64)),
uuid: encodeCrock(getRandomBytes(32)),
pol_method_index: methIndex,
@@ -472,17 +445,6 @@ async function uploadSecret(
const csr = await coreSecretEncrypt(policyKeys, coreSecret);
- const uidMap: Record<string, UserIdentifier> = {};
- for (const prov of state.policy_providers!) {
- const provider = state.authentication_providers![
- prov.provider_url
- ] as AuthenticationProviderStatusOk;
- uidMap[prov.provider_url] = await userIdentifierDerive(
- state.identity_attributes!,
- provider.salt,
- );
- }
-
const escrowMethods: EscrowMethod[] = [];
for (const truthKey of Object.keys(truthMetadataMap)) {
@@ -494,24 +456,92 @@ async function uploadSecret(
const provider = state.authentication_providers![
meth.provider
] as AuthenticationProviderStatusOk;
- const truthValue = await getTruthValue(authMethod, tm.uuid, tm.truth_salt);
+ escrowMethods.push({
+ escrow_type: authMethod.type as any,
+ instructions: authMethod.instructions,
+ provider_salt: provider.provider_salt,
+ question_salt: tm.master_salt,
+ truth_key: tm.truth_key,
+ url: meth.provider,
+ uuid: tm.uuid,
+ });
+ }
+
+ const rd: RecoveryDocument = {
+ secret_name: secretName,
+ encrypted_core_secret: csr.encCoreSecret,
+ escrow_methods: escrowMethods,
+ policies: policies.map((x, i) => {
+ return {
+ master_key: csr.encMasterKeys[i],
+ uuids: policyUuids[i],
+ salt: policySalts[i],
+ };
+ }),
+ };
+
+ return {
+ ...state,
+ recovery_data: {
+ recovery_document: rd,
+ truth_metadata: truthMetadataMap,
+ },
+ };
+}
+
+async function uploadSecret(
+ state: ReducerStateBackup,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ if (!state.recovery_data) {
+ state = await prepareRecoveryData(state);
+ }
+
+ const recoveryData = state.recovery_data;
+ if (!recoveryData) {
+ throw Error("invariant failed");
+ }
+
+ const truthMetadataMap = recoveryData.truth_metadata;
+ const rd = recoveryData.recovery_document;
+
+ const truthPayUris: string[] = [];
+ const truthPaySecrets: Record<string, string> = {};
+
+ const userIdCache: Record<string, UserIdentifier> = {};
+ const getUserIdCaching = async (providerUrl: string) => {
+ let userId = userIdCache[providerUrl];
+ if (!userId) {
+ const provider = state.authentication_providers![
+ providerUrl
+ ] as AuthenticationProviderStatusOk;
+ userId = userIdCache[providerUrl] = await userIdentifierDerive(
+ state.identity_attributes!,
+ provider.provider_salt,
+ );
+ }
+ return userId;
+ };
+ for (const truthKey of Object.keys(truthMetadataMap)) {
+ const tm = truthMetadataMap[truthKey];
+ const pol = state.policies![tm.policy_index];
+ const meth = pol.methods[tm.pol_method_index];
+ const authMethod =
+ state.authentication_methods![meth.authentication_method];
+ const truthValue = await getTruthValue(authMethod, tm.uuid, tm.master_salt);
const encryptedTruth = await encryptTruth(
tm.nonce,
tm.truth_key,
truthValue,
);
- const uid = uidMap[meth.provider];
+ logger.info(`uploading truth to ${meth.provider}`);
+ const userId = await getUserIdCaching(meth.provider);
const encryptedKeyShare = await encryptKeyshare(
tm.key_share,
- uid,
+ userId,
authMethod.type === "question"
? bytesToString(decodeCrock(authMethod.challenge))
: undefined,
);
- console.log(
- "encrypted key share len",
- decodeCrock(encryptedKeyShare).length,
- );
const tur: TruthUploadRequest = {
encrypted_truth: encryptedTruth,
key_share_data: encryptedKeyShare,
@@ -519,59 +549,78 @@ async function uploadSecret(
type: authMethod.type,
truth_mime: authMethod.mime_type,
};
- const resp = await fetch(new URL(`truth/${tm.uuid}`, meth.provider).href, {
+ const reqUrl = new URL(`truth/${tm.uuid}`, meth.provider);
+ const paySecret = (state.truth_upload_payment_secrets ?? {})[meth.provider];
+ if (paySecret) {
+ // FIXME: Get this from the params
+ reqUrl.searchParams.set("timeout_ms", "500");
+ }
+ const resp = await httpLib.fetch(reqUrl.href, {
method: "POST",
headers: {
"content-type": "application/json",
+ ...(paySecret
+ ? {
+ "Anastasis-Payment-Identifier": paySecret,
+ }
+ : {}),
},
body: JSON.stringify(tur),
});
- if (resp.status !== 204) {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
- hint: "could not upload policy",
- };
+ if (resp.status === HttpStatusCode.NoContent) {
+ continue;
}
-
- escrowMethods.push({
- escrow_type: authMethod.type,
- instructions: authMethod.instructions,
- provider_salt: provider.salt,
- truth_salt: tm.truth_salt,
- truth_key: tm.truth_key,
- url: meth.provider,
- uuid: tm.uuid,
- });
+ if (resp.status === HttpStatusCode.PaymentRequired) {
+ const talerPayUri = resp.headers.get("Taler");
+ if (!talerPayUri) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE,
+ hint: `payment requested, but no taler://pay URI given`,
+ };
+ }
+ truthPayUris.push(talerPayUri);
+ const parsedUri = parsePayUri(talerPayUri);
+ if (!parsedUri) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE,
+ hint: `payment requested, but no taler://pay URI given`,
+ };
+ }
+ truthPaySecrets[meth.provider] = parsedUri.orderId;
+ continue;
+ }
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
+ hint: `could not upload truth (HTTP status ${resp.status})`,
+ };
}
- // FIXME: We need to store the truth metadata in
- // the state, since it's possible that we'll run into
- // a provider that requests a payment.
-
- console.log("policy UUIDs", policyUuids);
-
- const rd: RecoveryDocument = {
- secret_name: secretName,
- encrypted_core_secret: csr.encCoreSecret,
- escrow_methods: escrowMethods,
- policies: policies.map((x, i) => {
- return {
- master_key: csr.encMasterKeys[i],
- uuids: policyUuids[i],
- salt: policySalts[i],
- };
- }),
- };
+ if (truthPayUris.length > 0) {
+ return {
+ ...state,
+ backup_state: BackupStates.TruthsPaying,
+ truth_upload_payment_secrets: truthPaySecrets,
+ payments: truthPayUris,
+ };
+ }
const successDetails: SuccessDetails = {};
+ const policyPayUris: string[] = [];
+ const policyPayUriMap: Record<string, string> = {};
+ //const policyPaySecrets: Record<string, string> = {};
+
for (const prov of state.policy_providers!) {
- const uid = uidMap[prov.provider_url];
- const acctKeypair = accountKeypairDerive(uid);
+ const userId = await getUserIdCaching(prov.provider_url);
+ const acctKeypair = accountKeypairDerive(userId);
const zippedDoc = await compressRecoveryDoc(rd);
+ const recoveryDocHash = encodeCrock(hash(zippedDoc));
const encRecoveryDoc = await encryptRecoveryDocument(
- uid,
+ userId,
encodeCrock(zippedDoc),
);
const bodyHash = hash(decodeCrock(encRecoveryDoc));
@@ -579,44 +628,164 @@ async function uploadSecret(
.put(bodyHash)
.build();
const sig = eddsaSign(sigPS, decodeCrock(acctKeypair.priv));
- const resp = await fetch(
- new URL(`policy/${acctKeypair.pub}`, prov.provider_url).href,
- {
- method: "POST",
- headers: {
- "Anastasis-Policy-Signature": encodeCrock(sig),
- "If-None-Match": encodeCrock(bodyHash),
- },
- body: decodeCrock(encRecoveryDoc),
+ const metadataEnc = await encryptPolicyMetadata(userId, {
+ policy_hash: recoveryDocHash,
+ secret_name: state.secret_name ?? "<unnamed secret>",
+ });
+ const talerPayUri = state.policy_payment_requests?.find(
+ (x) => x.provider === prov.provider_url,
+ )?.payto;
+ let paySecret: string | undefined;
+ if (talerPayUri) {
+ paySecret = parsePayUri(talerPayUri)!.orderId;
+ }
+ const reqUrl = new URL(`policy/${acctKeypair.pub}`, prov.provider_url);
+ if (paySecret) {
+ // FIXME: Get this from the params
+ reqUrl.searchParams.set("timeout_ms", "500");
+ }
+ logger.info(`uploading policy to ${prov.provider_url}`);
+ const resp = await httpLib.fetch(reqUrl.href, {
+ method: "POST",
+ headers: {
+ "Anastasis-Policy-Signature": encodeCrock(sig),
+ "If-None-Match": encodeCrock(bodyHash),
+ [ANASTASIS_HTTP_HEADER_POLICY_META_DATA]: metadataEnc,
+ ...(paySecret
+ ? {
+ "Anastasis-Payment-Identifier": paySecret,
+ }
+ : {}),
},
- );
- if (resp.status !== 204) {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
- hint: "could not upload policy",
+ body: decodeCrock(encRecoveryDoc),
+ });
+ logger.info(`got response for policy upload (http status ${resp.status})`);
+ if (resp.status === HttpStatusCode.NoContent) {
+ let policyVersion = 0;
+ let policyExpiration: TalerProtocolTimestamp = { t_s: 0 };
+ try {
+ policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
+ } catch (e) {}
+ try {
+ policyExpiration = {
+ t_s: Number(resp.headers.get("Anastasis-Policy-Expiration") ?? "0"),
+ };
+ } catch (e) {}
+ successDetails[prov.provider_url] = {
+ policy_version: policyVersion,
+ policy_expiration: policyExpiration,
};
+ continue;
}
- let policyVersion = 0;
- let policyExpiration: Timestamp = { t_ms: 0 };
- try {
- policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
- } catch (e) {}
- try {
- policyExpiration = {
- t_ms:
- 1000 * Number(resp.headers.get("Anastasis-Policy-Expiration") ?? "0"),
- };
- } catch (e) {}
- successDetails[prov.provider_url] = {
- policy_version: policyVersion,
- policy_expiration: policyExpiration,
+ if (resp.status === HttpStatusCode.PaymentRequired) {
+ const talerPayUri = resp.headers.get("Taler");
+ if (!talerPayUri) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE,
+ hint: `payment requested, but no taler://pay URI given`,
+ };
+ }
+ policyPayUris.push(talerPayUri);
+ const parsedUri = parsePayUri(talerPayUri);
+ if (!parsedUri) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE,
+ hint: `payment requested, but no taler://pay URI given`,
+ };
+ }
+ policyPayUriMap[prov.provider_url] = talerPayUri;
+ continue;
+ }
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED,
+ hint: `could not upload policy (http status ${resp.status})`,
+ };
+ }
+
+ if (policyPayUris.length > 0) {
+ return {
+ ...state,
+ backup_state: BackupStates.PoliciesPaying,
+ payments: policyPayUris,
+ policy_payment_requests: Object.keys(policyPayUriMap).map((x) => {
+ return {
+ payto: policyPayUriMap[x],
+ provider: x,
+ };
+ }),
};
}
+ logger.info("backup finished");
+
return {
...state,
+ core_secret: undefined,
backup_state: BackupStates.BackupFinished,
success_details: successDetails,
+ payments: undefined,
+ };
+}
+
+interface PolicyDownloadResult {
+ recoveryDoc: RecoveryDocument;
+ recoveryData: RecoveryInternalData;
+}
+
+async function downloadPolicyFromProvider(
+ state: ReducerStateRecovery,
+ providerUrl: string,
+ version: number,
+): Promise<PolicyDownloadResult | undefined> {
+ logger.info(`trying to download policy from ${providerUrl}`);
+ const userAttributes = state.identity_attributes!;
+ let pi = state.authentication_providers?.[providerUrl];
+ if (!pi || pi.status !== "ok") {
+ // FIXME: this one blocks!
+ logger.info(`fetching provider info for ${providerUrl}`);
+ pi = await getProviderInfo(providerUrl);
+ }
+ logger.info(`new provider status is ${pi.status}`);
+ if (pi.status !== "ok") {
+ return undefined;
+ }
+ const userId = await userIdentifierDerive(userAttributes, pi.provider_salt);
+ const acctKeypair = accountKeypairDerive(userId);
+ const reqUrl = new URL(`policy/${acctKeypair.pub}`, providerUrl);
+ reqUrl.searchParams.set("version", `${version}`);
+ const resp = await httpLib.fetch(reqUrl.href);
+ if (resp.status !== 200) {
+ logger.info(
+ `Could not download policy from provider ${providerUrl}, status ${resp.status}`,
+ );
+ return undefined;
+ }
+ const body = await resp.bytes();
+ const bodyDecrypted = await decryptRecoveryDocument(
+ userId,
+ encodeCrock(body),
+ );
+ const rd: RecoveryDocument = await uncompressRecoveryDoc(
+ decodeCrock(bodyDecrypted),
+ );
+ // FIXME: Not clear why we do this, since we always have an explicit version by now.
+ let policyVersion = 0;
+ try {
+ policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
+ } catch (e) {
+ logger.warn("Could not read policy version header");
+ policyVersion = version;
+ }
+ return {
+ recoveryDoc: rd,
+ recoveryData: {
+ provider_url: providerUrl,
+ secret_name: rd.secret_name ?? "<unknown>",
+ version: policyVersion,
+ },
};
}
@@ -627,70 +796,40 @@ async function uploadSecret(
async function downloadPolicy(
state: ReducerStateRecovery,
): Promise<ReducerStateRecovery | ReducerStateError> {
- const providerUrls = Object.keys(state.authentication_providers ?? {});
- let foundRecoveryInfo: RecoveryInternalData | undefined = undefined;
- let recoveryDoc: RecoveryDocument | undefined = undefined;
- const newProviderStatus: { [url: string]: AuthenticationProviderStatusOk } =
- {};
- const userAttributes = state.identity_attributes!;
- // FIXME: Shouldn't we also store the status of bad providers?
- for (const url of providerUrls) {
- const pi = await getProviderInfo(url);
- if ("error_code" in pi || !("http_status" in pi)) {
- // Could not even get /config of the provider
- continue;
- }
- newProviderStatus[url] = pi;
+ logger.info("downloading policy");
+ if (!state.selected_version) {
+ throw Error("invalid state");
}
- for (const url of providerUrls) {
- const pi = newProviderStatus[url];
- if (!pi) {
- continue;
+ let policyDownloadResult: PolicyDownloadResult | undefined = undefined;
+ // FIXME: Do this concurrently/asynchronously so that one slow provider doesn't block us.
+ for (const prov of state.selected_version.providers) {
+ const res = await downloadPolicyFromProvider(state, prov.url, prov.version);
+ if (res) {
+ policyDownloadResult = res;
+ break;
}
- const userId = await userIdentifierDerive(userAttributes, pi.salt);
- const acctKeypair = accountKeypairDerive(userId);
- const resp = await fetch(new URL(`policy/${acctKeypair.pub}`, url).href);
- if (resp.status !== 200) {
- continue;
- }
- const body = await resp.arrayBuffer();
- const bodyDecrypted = await decryptRecoveryDocument(
- userId,
- encodeCrock(body),
- );
- const rd: RecoveryDocument = await uncompressRecoveryDoc(
- decodeCrock(bodyDecrypted),
- );
- console.log("rd", rd);
- let policyVersion = 0;
- try {
- policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0");
- } catch (e) {}
- foundRecoveryInfo = {
- provider_url: url,
- secret_name: rd.secret_name ?? "<unknown>",
- version: policyVersion,
- };
- recoveryDoc = rd;
- break;
}
- if (!foundRecoveryInfo || !recoveryDoc) {
+ if (!policyDownloadResult) {
return {
+ reducer_type: "error",
code: TalerErrorCode.ANASTASIS_REDUCER_POLICY_LOOKUP_FAILED,
hint: "No backups found at any provider for your identity information.",
};
}
+
+ const challenges: ChallengeInfo[] = [];
+ const recoveryDoc = policyDownloadResult.recoveryDoc;
+
+ for (const x of recoveryDoc.escrow_methods) {
+ challenges.push({
+ instructions: x.instructions,
+ type: x.escrow_type,
+ uuid: x.uuid,
+ });
+ }
+
const recoveryInfo: RecoveryInformation = {
- challenges: recoveryDoc.escrow_methods.map((x) => {
- console.log("providers", newProviderStatus);
- const prov = newProviderStatus[x.url] as AuthenticationProviderStatusOk;
- return {
- cost: prov.methods.find((m) => m.type === x.escrow_type)?.usage_fee!,
- instructions: x.instructions,
- type: x.escrow_type,
- uuid: x.uuid,
- };
- }),
+ challenges,
policies: recoveryDoc.policies.map((x) => {
return x.uuids.map((m) => {
return {
@@ -701,8 +840,8 @@ async function downloadPolicy(
};
return {
...state,
- recovery_state: RecoveryStates.SecretSelecting,
- recovery_document: foundRecoveryInfo,
+ recovery_state: RecoveryStates.ChallengeSelecting,
+ recovery_document: policyDownloadResult.recoveryData,
recovery_information: recoveryInfo,
verbatim_recovery_document: recoveryDoc,
};
@@ -750,86 +889,223 @@ async function tryRecoverSecret(
return { ...state };
}
-async function solveChallenge(
+/**
+ * Re-check the status of challenges that are solved asynchronously.
+ */
+async function pollChallenges(
state: ReducerStateRecovery,
- ta: ActionArgsSolveChallengeRequest,
+ args: void,
): Promise<ReducerStateRecovery | ReducerStateError> {
- const recDoc: RecoveryDocument = state.verbatim_recovery_document!;
- const truth = recDoc.escrow_methods.find(
- (x) => x.uuid === state.selected_challenge_uuid,
- );
- if (!truth) {
- throw "truth for challenge not found";
+ for (const truthUuid in state.challenge_feedback) {
+ if (state.recovery_state === RecoveryStates.RecoveryFinished) {
+ break;
+ }
+ const feedback = state.challenge_feedback[truthUuid];
+ const truth = state.verbatim_recovery_document!.escrow_methods.find(
+ (x) => x.uuid === truthUuid,
+ );
+ if (!truth) {
+ logger.warn(
+ "truth for challenge feedback entry not found in recovery document",
+ );
+ continue;
+ }
+ if (feedback.state === ChallengeFeedbackStatus.IbanInstructions) {
+ const s2 = await requestTruth(state, truth, {
+ pin: feedback.answer_code,
+ });
+ if (s2.reducer_type === "recovery") {
+ state = s2;
+ }
+ }
}
+ return state;
+}
- const url = new URL(`/truth/${truth.uuid}`, truth.url);
+async function getResponseHash(
+ truth: EscrowMethod,
+ solveRequest: ActionArgsSolveChallengeRequest,
+): Promise<string> {
+ let respHash: string;
+ switch (truth.escrow_type) {
+ case ChallengeType.Question: {
+ if ("answer" in solveRequest) {
+ respHash = await secureAnswerHash(
+ solveRequest.answer,
+ truth.uuid,
+ truth.question_salt,
+ );
+ } else {
+ throw Error("unsupported answer request");
+ }
+ break;
+ }
+ case ChallengeType.Email:
+ case ChallengeType.Sms:
+ case ChallengeType.Post:
+ case ChallengeType.Iban:
+ case ChallengeType.Totp: {
+ if ("answer" in solveRequest) {
+ const s = solveRequest.answer.trim().replace(/^A-/, "");
+ let pin: number;
+ try {
+ pin = Number.parseInt(s);
+ } catch (e) {
+ throw Error("invalid pin format");
+ }
+ respHash = await pinAnswerHash(pin);
+ } else if ("pin" in solveRequest) {
+ respHash = await pinAnswerHash(solveRequest.pin);
+ } else {
+ throw Error("unsupported answer request");
+ }
+ break;
+ }
+ default:
+ throw Error(`unsupported challenge type "${truth.escrow_type}""`);
+ }
+ return respHash;
+}
- // FIXME: This isn't correct for non-question truth responses.
- url.searchParams.set(
- "response",
- await secureAnswerHash(ta.answer, truth.uuid, truth.truth_salt),
- );
+/**
+ * Request a truth, optionally with a challenge solution
+ * provided by the user.
+ */
+async function requestTruth(
+ state: ReducerStateRecovery,
+ truth: EscrowMethod,
+ solveRequest: ActionArgsSolveChallengeRequest,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ const url = new URL(`/truth/${truth.uuid}/solve`, truth.url);
- const resp = await fetch(url.href, {
- headers: {
- "Anastasis-Truth-Decryption-Key": truth.truth_key,
- },
- });
+ const hresp = await getResponseHash(truth, solveRequest);
- console.log(resp);
+ let resp: HttpResponse;
- if (resp.status !== 200) {
+ try {
+ resp = await httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ truth_decryption_key: truth.truth_key,
+ h_response: hresp,
+ }),
+ });
+ } catch (e) {
return {
+ reducer_type: "error",
code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED,
- hint: "got non-200 response",
- http_status: resp.status,
+ hint: "network error",
} as ReducerStateError;
}
- const answerSalt = truth.escrow_type === "question" ? ta.answer : undefined;
-
- const userId = await userIdentifierDerive(
- state.identity_attributes,
- truth.provider_salt,
+ logger.info(
+ `got POST /truth/.../solve response from ${truth.url}, http status ${resp.status}`,
);
- const respBody = new Uint8Array(await resp.arrayBuffer());
- const keyShare = await decryptKeyShare(
- encodeCrock(respBody),
- userId,
- answerSalt,
- );
+ if (resp.status === HttpStatusCode.Ok) {
+ let answerSalt: string | undefined = undefined;
+ if (
+ solveRequest &&
+ truth.escrow_type === "question" &&
+ "answer" in solveRequest
+ ) {
+ answerSalt = solveRequest.answer;
+ }
- const recoveredKeyShares = {
- ...(state.recovered_key_shares ?? {}),
- [truth.uuid]: keyShare,
- };
+ const userId = await userIdentifierDerive(
+ state.identity_attributes,
+ truth.provider_salt,
+ );
- const challengeFeedback = {
- ...state.challenge_feedback,
- [truth.uuid]: {
- state: "solved",
- },
- };
+ const respBody = new Uint8Array(await resp.bytes());
+ const keyShare = await decryptKeyShare(
+ encodeCrock(respBody),
+ userId,
+ answerSalt,
+ );
- const newState: ReducerStateRecovery = {
- ...state,
- recovery_state: RecoveryStates.ChallengeSelecting,
- challenge_feedback: challengeFeedback,
- recovered_key_shares: recoveredKeyShares,
- };
+ const recoveredKeyShares = {
+ ...(state.recovered_key_shares ?? {}),
+ [truth.uuid]: keyShare,
+ };
- return tryRecoverSecret(newState);
+ const challengeFeedback: { [x: string]: ChallengeFeedback } = {
+ ...state.challenge_feedback,
+ [truth.uuid]: {
+ state: ChallengeFeedbackStatus.Solved,
+ },
+ };
+
+ const newState: ReducerStateRecovery = {
+ ...state,
+ recovery_state: RecoveryStates.ChallengeSelecting,
+ challenge_feedback: challengeFeedback,
+ recovered_key_shares: recoveredKeyShares,
+ };
+
+ return tryRecoverSecret(newState);
+ }
+
+ if (resp.status === HttpStatusCode.Forbidden) {
+ const challengeFeedback: { [x: string]: ChallengeFeedback } = {
+ ...state.challenge_feedback,
+ [truth.uuid]: {
+ state: ChallengeFeedbackStatus.IncorrectAnswer,
+ },
+ };
+ return {
+ ...state,
+ challenge_feedback: challengeFeedback,
+ };
+ }
+
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED,
+ hint: "got unexpected /truth/ response status",
+ http_status: resp.status,
+ } as ReducerStateError;
+}
+
+async function solveChallenge(
+ state: ReducerStateRecovery,
+ ta: ActionArgsSolveChallengeRequest,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ const recDoc: RecoveryDocument = state.verbatim_recovery_document!;
+ const truth = recDoc.escrow_methods.find(
+ (x) => x.uuid === state.selected_challenge_uuid,
+ );
+ if (!truth) {
+ throw Error("truth for challenge not found");
+ }
+
+ return requestTruth(state, truth, ta);
}
async function recoveryEnterUserAttributes(
state: ReducerStateRecovery,
- attributes: Record<string, string>,
+ args: ActionArgsEnterUserAttributes,
): Promise<ReducerStateRecovery | ReducerStateError> {
// FIXME: validate attributes
const st: ReducerStateRecovery = {
...state,
- identity_attributes: attributes,
+ recovery_state: RecoveryStates.SecretSelecting,
+ identity_attributes: args.identity_attributes,
+ };
+ return st;
+}
+
+async function changeVersion(
+ state: ReducerStateRecovery,
+ args: ActionArgsChangeVersion,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ const st: ReducerStateRecovery = {
+ ...state,
+ selected_version: args,
};
return downloadPolicy(st);
}
@@ -844,369 +1120,862 @@ async function selectChallenge(
throw "truth for challenge not found";
}
- const url = new URL(`/truth/${truth.uuid}`, truth.url);
+ const url = new URL(`/truth/${truth.uuid}/challenge`, truth.url);
- const resp = await fetch(url.href, {
- headers: {
- "Anastasis-Truth-Decryption-Key": truth.truth_key,
- },
- });
+ const newFeedback = { ...state.challenge_feedback };
+ delete newFeedback[truth.uuid];
+
+ switch (truth.escrow_type) {
+ case ChallengeType.Question:
+ case ChallengeType.Totp: {
+ return {
+ ...state,
+ recovery_state: RecoveryStates.ChallengeSolving,
+ selected_challenge_uuid: truth.uuid,
+ challenge_feedback: newFeedback,
+ };
+ }
+ }
- console.log(resp);
+ let resp: HttpResponse;
+ try {
+ resp = await httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ truth_decryption_key: truth.truth_key,
+ }),
+ });
+ } catch (e) {
+ const feedback: ChallengeFeedback = {
+ state: ChallengeFeedbackStatus.ServerFailure,
+ http_status: 0,
+ };
+ return {
+ ...state,
+ recovery_state: RecoveryStates.ChallengeSelecting,
+ selected_challenge_uuid: truth.uuid,
+ challenge_feedback: {
+ ...state.challenge_feedback,
+ [truth.uuid]: feedback,
+ },
+ };
+ }
+
+ logger.info(
+ `got GET /truth/.../challenge response from ${truth.url}, http status ${resp.status}`,
+ );
+
+ if (resp.status === HttpStatusCode.Ok) {
+ const respBodyJson = await resp.json();
+ logger.info(`validating ${j2s(respBodyJson)}`);
+ const instr = codecForChallengeInstructionMessage().decode(respBodyJson);
+ let feedback: ChallengeFeedback;
+ switch (instr.challenge_type) {
+ case "FILE_WRITTEN": {
+ feedback = {
+ state: ChallengeFeedbackStatus.CodeInFile,
+ display_hint: "TAN code is in file (for debugging)",
+ filename: instr.filename,
+ };
+ break;
+ }
+ case "IBAN_WIRE": {
+ feedback = {
+ state: ChallengeFeedbackStatus.IbanInstructions,
+ answer_code: instr.wire_details.answer_code,
+ target_business_name: instr.wire_details.business_name,
+ challenge_amount: instr.wire_details.challenge_amount,
+ target_iban: instr.wire_details.credit_iban,
+ wire_transfer_subject: instr.wire_details.wire_transfer_subject,
+ };
+ break;
+ }
+ case "TAN_SENT": {
+ feedback = {
+ state: ChallengeFeedbackStatus.CodeSent,
+ address_hint: instr.tan_address_hint,
+ display_hint: "Code sent to address",
+ };
+ }
+ }
+ return {
+ ...state,
+ recovery_state: RecoveryStates.ChallengeSolving,
+ selected_challenge_uuid: truth.uuid,
+ challenge_feedback: {
+ ...state.challenge_feedback,
+ [truth.uuid]: feedback,
+ },
+ };
+ }
+
+ // FIXME: look at more error codes in response
+
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED,
+ hint: `got unexpected /truth/.../challenge response status (${resp.status})`,
+ http_status: resp.status,
+ } as ReducerStateError;
+}
+
+async function backupSelectContinent(
+ state: ReducerStateBackup,
+ args: ActionArgsSelectContinent,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ const countries = getCountries(args.continent);
+ if (countries.length <= 0) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID,
+ hint: "continent not found",
+ };
+ }
return {
...state,
- recovery_state: RecoveryStates.ChallengeSolving,
- selected_challenge_uuid: ta.uuid,
+ backup_state: BackupStates.CountrySelecting,
+ countries,
+ selected_continent: args.continent,
};
}
-export async function reduceAction(
- state: ReducerState,
+async function recoverySelectContinent(
+ state: ReducerStateRecovery,
+ args: ActionArgsSelectContinent,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ const countries = getCountries(args.continent);
+ return {
+ ...state,
+ recovery_state: RecoveryStates.CountrySelecting,
+ countries,
+ selected_continent: args.continent,
+ };
+}
+
+interface TransitionImpl<S, T> {
+ argCodec: Codec<T>;
+ handler: (s: S, args: T) => Promise<S | ReducerStateError>;
+}
+
+interface Transition<S> {
+ [x: string]: TransitionImpl<S, any>;
+}
+
+function transition<S, T>(
action: string,
- args: any,
-): Promise<ReducerState> {
- console.log(`ts reducer: handling action ${action}`);
- if (state.backup_state === BackupStates.ContinentSelecting) {
- if (action === "select_continent") {
- const continent: string = args.continent;
- if (typeof continent !== "string") {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: "continent required",
- };
- }
- return {
- ...state,
- backup_state: BackupStates.CountrySelecting,
- countries: getCountries(continent),
- selected_continent: continent,
- };
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ argCodec: Codec<T>,
+ handler: (s: S, args: T) => Promise<S | ReducerStateError>,
+): Transition<S> {
+ return {
+ [action]: {
+ argCodec,
+ handler,
+ },
+ };
+}
+
+function transitionBackupJump(
+ action: string,
+ st: BackupStates,
+): Transition<ReducerStateBackup> {
+ return {
+ [action]: {
+ argCodec: codecForAny(),
+ handler: async (s, a) => ({ ...s, backup_state: st }),
+ },
+ };
+}
+
+function transitionRecoveryJump(
+ action: string,
+ st: RecoveryStates,
+): Transition<ReducerStateRecovery> {
+ return {
+ [action]: {
+ argCodec: codecForAny(),
+ handler: async (s, a) => ({ ...s, recovery_state: st }),
+ },
+ };
+}
+
+async function addProviderBackup(
+ state: ReducerStateBackup,
+ args: ActionArgsAddProvider,
+): Promise<ReducerStateBackup> {
+ const info = await getProviderInfo(args.provider_url);
+ return {
+ ...state,
+ authentication_providers: {
+ ...(state.authentication_providers ?? {}),
+ [args.provider_url]: info,
+ },
+ };
+}
+
+async function deleteProviderBackup(
+ state: ReducerStateBackup,
+ args: ActionArgsDeleteProvider,
+): Promise<ReducerStateBackup> {
+ const authentication_providers = {
+ ...(state.authentication_providers ?? {}),
+ };
+ delete authentication_providers[args.provider_url];
+ return {
+ ...state,
+ authentication_providers,
+ };
+}
+
+async function addProviderRecovery(
+ state: ReducerStateRecovery,
+ args: ActionArgsAddProvider,
+): Promise<ReducerStateRecovery> {
+ const info = await getProviderInfo(args.provider_url);
+ return {
+ ...state,
+ authentication_providers: {
+ ...(state.authentication_providers ?? {}),
+ [args.provider_url]: info,
+ },
+ };
+}
+
+async function deleteProviderRecovery(
+ state: ReducerStateRecovery,
+ args: ActionArgsDeleteProvider,
+): Promise<ReducerStateRecovery> {
+ const authentication_providers = {
+ ...(state.authentication_providers ?? {}),
+ };
+ delete authentication_providers[args.provider_url];
+ return {
+ ...state,
+ authentication_providers,
+ };
+}
+
+async function addAuthentication(
+ state: ReducerStateBackup,
+ args: ActionArgsAddAuthentication,
+): Promise<ReducerStateBackup> {
+ return {
+ ...state,
+ authentication_methods: [
+ ...(state.authentication_methods ?? []),
+ args.authentication_method,
+ ],
+ };
+}
+
+async function deleteAuthentication(
+ state: ReducerStateBackup,
+ args: ActionArgsDeleteAuthentication,
+): Promise<ReducerStateBackup> {
+ const m = state.authentication_methods ?? [];
+ m.splice(args.authentication_method, 1);
+ return {
+ ...state,
+ authentication_methods: m,
+ };
+}
+
+async function deletePolicy(
+ state: ReducerStateBackup,
+ args: ActionArgsDeletePolicy,
+): Promise<ReducerStateBackup> {
+ const policies = [...(state.policies ?? [])];
+ policies.splice(args.policy_index, 1);
+ return {
+ ...state,
+ policies,
+ };
+}
+
+async function updatePolicy(
+ state: ReducerStateBackup,
+ args: ActionArgsUpdatePolicy,
+): Promise<ReducerStateBackup> {
+ const policies = [...(state.policies ?? [])];
+ policies[args.policy_index] = { methods: args.policy };
+ return {
+ ...state,
+ policies,
+ };
+}
+
+async function addPolicy(
+ state: ReducerStateBackup,
+ args: ActionArgsAddPolicy,
+): Promise<ReducerStateBackup> {
+ return {
+ ...state,
+ policies: [
+ ...(state.policies ?? []),
+ {
+ methods: args.policy,
+ },
+ ],
+ };
+}
+
+async function nextFromAuthenticationsEditing(
+ state: ReducerStateBackup,
+ args: {},
+): Promise<ReducerStateBackup | ReducerStateError> {
+ const methods = state.authentication_methods ?? [];
+ const providers: ProviderInfo[] = [];
+ for (const provUrl of Object.keys(state.authentication_providers ?? {})) {
+ const prov = state.authentication_providers![provUrl];
+ if (prov.status !== "ok") {
+ continue;
}
- }
- if (state.backup_state === BackupStates.CountrySelecting) {
- if (action === "back") {
- return {
- ...state,
- backup_state: BackupStates.ContinentSelecting,
- countries: undefined,
- };
- } else if (action === "select_country") {
- const countryCode = args.country_code;
- if (typeof countryCode !== "string") {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: "country_code required",
- };
- }
- const currencies = args.currencies;
- return backupSelectCountry(state, countryCode, currencies);
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ const methodCost: Record<string, AmountString> = {};
+ for (const meth of prov.methods) {
+ methodCost[meth.type] = meth.usage_fee;
}
+ providers.push({
+ methodCost,
+ url: provUrl,
+ });
}
- if (state.backup_state === BackupStates.UserAttributesCollecting) {
- if (action === "back") {
- return {
- ...state,
- backup_state: BackupStates.CountrySelecting,
- };
- } else if (action === "enter_user_attributes") {
- const ta = args as ActionArgEnterUserAttributes;
- return backupEnterUserAttributes(state, ta.identity_attributes);
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ const pol = suggestPolicies(methods, providers);
+ if (pol.policies.length === 0) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ detail:
+ "Unable to suggest any policies. Check if providers are available and reachable.",
+ };
+ }
+ return {
+ ...state,
+ backup_state: BackupStates.PoliciesReviewing,
+ ...pol,
+ };
+}
+
+async function updateUploadFees(
+ state: ReducerStateBackup,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ const expiration = state.expiration;
+ if (!expiration) {
+ return { ...state };
+ }
+ logger.info("updating upload fees");
+ const feePerCurrency: Record<string, AmountJson> = {};
+ const addFee = (x: AmountLike) => {
+ x = Amounts.jsonifyAmount(x);
+ feePerCurrency[x.currency] = Amounts.add(
+ feePerCurrency[x.currency] ?? Amounts.zeroOfAmount(x),
+ x,
+ ).amount;
+ };
+ const expirationTime = AbsoluteTime.fromProtocolTimestamp(expiration);
+ const years = Duration.toIntegerYears(Duration.getRemaining(expirationTime));
+ logger.info(`computing fees for ${years} years`);
+ // For now, we compute fees for *all* available providers.
+ for (const provUrl in state.authentication_providers ?? {}) {
+ const prov = state.authentication_providers![provUrl];
+ if ("annual_fee" in prov) {
+ const annualFee = Amounts.mult(prov.annual_fee, years).amount;
+ logger.info(`adding annual fee ${Amounts.stringify(annualFee)}`);
+ addFee(annualFee);
}
}
- if (state.backup_state === BackupStates.AuthenticationsEditing) {
- if (action === "back") {
- return {
- ...state,
- backup_state: BackupStates.UserAttributesCollecting,
- };
- } else if (action === "add_authentication") {
- const ta = args as ActionArgAddAuthentication;
- return {
- ...state,
- authentication_methods: [
- ...(state.authentication_methods ?? []),
- ta.authentication_method,
- ],
- };
- } else if (action === "delete_authentication") {
- const ta = args as ActionArgDeleteAuthentication;
- const m = state.authentication_methods ?? [];
- m.splice(ta.authentication_method, 1);
- return {
- ...state,
- authentication_methods: m,
- };
- } else if (action === "next") {
- const methods = state.authentication_methods ?? [];
- const providers: ProviderInfo[] = [];
- for (const provUrl of Object.keys(state.authentication_providers ?? {})) {
- const prov = state.authentication_providers![provUrl];
- if ("error_code" in prov) {
- continue;
- }
- if (!("http_status" in prov && prov.http_status === 200)) {
- continue;
- }
- const methodCost: Record<string, AmountString> = {};
- for (const meth of prov.methods) {
- methodCost[meth.type] = meth.usage_fee;
- }
- providers.push({
- methodCost,
- url: provUrl,
- });
+ const coveredProvTruth = new Set<string>();
+ for (const x of state.policies ?? []) {
+ for (const m of x.methods) {
+ const prov = state.authentication_providers![
+ m.provider
+ ] as AuthenticationProviderStatusOk;
+ const authMethod = state.authentication_methods![m.authentication_method];
+ const key = `${m.authentication_method}@${m.provider}`;
+ if (coveredProvTruth.has(key)) {
+ continue;
}
- const pol = suggestPolicies(methods, providers);
- console.log("policies", pol);
- return {
- ...state,
- backup_state: BackupStates.PoliciesReviewing,
- ...pol,
- };
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ logger.info(
+ `adding cost for auth method ${authMethod.challenge} / "${authMethod.instructions}" at ${m.provider}`,
+ );
+ coveredProvTruth.add(key);
+ addFee(prov.truth_upload_fee);
}
}
- if (state.backup_state === BackupStates.PoliciesReviewing) {
- if (action === "back") {
- return {
- ...state,
- backup_state: BackupStates.AuthenticationsEditing,
- };
- } else if (action === "delete_policy") {
- const ta = args as ActionArgDeletePolicy;
- const policies = [...(state.policies ?? [])];
- policies.splice(ta.policy_index, 1);
- return {
- ...state,
- policies,
- };
- } else if (action === "next") {
- return {
- ...state,
- backup_state: BackupStates.SecretEditing,
- };
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
- }
+ return {
+ ...state,
+ upload_fees: Object.values(feePerCurrency).map((x) => ({
+ fee: Amounts.stringify(x),
+ })),
+ };
+}
+
+async function enterSecret(
+ state: ReducerStateBackup,
+ args: ActionArgsEnterSecret,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ return updateUploadFees({
+ ...state,
+ expiration: args.expiration,
+ core_secret: {
+ mime: args.secret.mime ?? "text/plain",
+ value: args.secret.value,
+ filename: args.secret.filename,
+ },
+ // A new secret invalidates the existing recovery data.
+ recovery_data: undefined,
+ });
+}
+
+async function nextFromChallengeSelecting(
+ state: ReducerStateRecovery,
+ args: void,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ const s2 = await tryRecoverSecret(state);
+ if (
+ s2.reducer_type === "recovery" &&
+ s2.recovery_state === RecoveryStates.RecoveryFinished
+ ) {
+ return s2;
}
- if (state.backup_state === BackupStates.SecretEditing) {
- if (action === "back") {
- return {
- ...state,
- backup_state: BackupStates.PoliciesReviewing,
- };
- } else if (action === "enter_secret_name") {
- const ta = args as ActionArgEnterSecretName;
- return {
- ...state,
- secret_name: ta.name,
- };
- } else if (action === "enter_secret") {
- const ta = args as ActionArgEnterSecret;
- return {
- ...state,
- expiration: ta.expiration,
- core_secret: {
- mime: ta.secret.mime ?? "text/plain",
- value: ta.secret.value,
- },
- };
- } else if (action === "next") {
- return uploadSecret(state);
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
- }
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "Not enough challenges solved",
+ };
+}
+
+async function syncOneProviderRecoveryTransition(
+ state: ReducerStateRecovery,
+ args: void,
+): Promise<ReducerStateRecovery | ReducerStateError> {
+ // FIXME: Should we not add this when we obtain the recovery document?
+ const escrowMethods = state.verbatim_recovery_document?.escrow_methods ?? [];
+ if (escrowMethods.length === 0) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: "Can't sync, no escrow methods in recovery doc.",
+ };
}
- if (state.backup_state === BackupStates.BackupFinished) {
- if (action === "back") {
- return {
- ...state,
- backup_state: BackupStates.SecretEditing,
- };
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ for (const x of escrowMethods) {
+ const pi = state.authentication_providers?.[x.url];
+ if (pi?.status === "ok") {
+ logger.info(`provider ${x.url} is synced`);
+ continue;
}
+ const newPi = await getProviderInfo(x.url);
+ return {
+ ...state,
+ authentication_providers: {
+ ...state.authentication_providers,
+ [x.url]: newPi,
+ },
+ };
}
- if (state.recovery_state === RecoveryStates.ContinentSelecting) {
- if (action === "select_continent") {
- const continent: string = args.continent;
- if (typeof continent !== "string") {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: "continent required",
- };
- }
- return {
- ...state,
- recovery_state: RecoveryStates.CountrySelecting,
- countries: getCountries(continent),
- selected_continent: continent,
- };
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ for (const [provUrl, pi] of Object.entries(
+ state.authentication_providers ?? {},
+ )) {
+ if (
+ pi.status === "ok" ||
+ pi.status === "disabled" ||
+ pi.status === "error"
+ ) {
+ continue;
}
+ const newPi = await getProviderInfo(provUrl);
+ return {
+ ...state,
+ authentication_providers: {
+ ...state.authentication_providers,
+ [provUrl]: newPi,
+ },
+ };
}
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_PROVIDERS_ALREADY_SYNCED,
+ hint: "all providers are already synced",
+ };
+}
- if (state.recovery_state === RecoveryStates.CountrySelecting) {
- if (action === "back") {
- return {
- ...state,
- recovery_state: RecoveryStates.ContinentSelecting,
- countries: undefined,
- };
- } else if (action === "select_country") {
- const countryCode = args.country_code;
- if (typeof countryCode !== "string") {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: "country_code required",
- };
- }
- const currencies = args.currencies;
- return recoverySelectCountry(state, countryCode, currencies);
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+async function syncOneProviderBackupTransition(
+ state: ReducerStateBackup,
+ args: void,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ for (const [provUrl, pi] of Object.entries(
+ state.authentication_providers ?? {},
+ )) {
+ if (
+ pi.status === "ok" ||
+ pi.status === "disabled" ||
+ pi.status === "error"
+ ) {
+ continue;
}
+ const newPi = await getProviderInfo(provUrl);
+ return {
+ ...state,
+ authentication_providers: {
+ ...state.authentication_providers,
+ [provUrl]: newPi,
+ },
+ };
}
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_PROVIDERS_ALREADY_SYNCED,
+ hint: "all providers are already synced",
+ };
+}
- if (state.recovery_state === RecoveryStates.UserAttributesCollecting) {
- if (action === "back") {
- return {
- ...state,
- recovery_state: RecoveryStates.CountrySelecting,
- };
- } else if (action === "enter_user_attributes") {
- const ta = args as ActionArgEnterUserAttributes;
- return recoveryEnterUserAttributes(state, ta.identity_attributes);
+async function enterSecretName(
+ state: ReducerStateBackup,
+ args: ActionArgsEnterSecretName,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ return {
+ ...state,
+ secret_name: args.name,
+ };
+}
+
+async function updateSecretExpiration(
+ state: ReducerStateBackup,
+ args: ActionArgsUpdateExpiration,
+): Promise<ReducerStateBackup | ReducerStateError> {
+ return updateUploadFees({
+ ...state,
+ expiration: args.expiration,
+ });
+}
+
+export function mergeDiscoveryAggregate(
+ newPolicies: PolicyMetaInfo[],
+ oldAgg: AggregatedPolicyMetaInfo[],
+): AggregatedPolicyMetaInfo[] {
+ const aggregatedPolicies: AggregatedPolicyMetaInfo[] = [...oldAgg];
+ const polHashToIndex: Record<string, number> = {};
+ for (const pol of newPolicies) {
+ const oldIndex = polHashToIndex[pol.policy_hash];
+ if (oldIndex != null) {
+ aggregatedPolicies[oldIndex].providers.push({
+ url: pol.provider_url,
+ version: pol.version,
+ });
} else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ aggregatedPolicies.push({
+ attribute_mask: pol.attribute_mask,
+ policy_hash: pol.policy_hash,
+ providers: [
+ {
+ url: pol.provider_url,
+ version: pol.version,
+ },
+ ],
+ secret_name: pol.secret_name,
+ });
+ polHashToIndex[pol.policy_hash] = aggregatedPolicies.length - 1;
}
}
+ return aggregatedPolicies;
+}
- if (state.recovery_state === RecoveryStates.SecretSelecting) {
- if (action === "back") {
- return {
- ...state,
- recovery_state: RecoveryStates.UserAttributesCollecting,
- };
- } else if (action === "next") {
- return {
- ...state,
- recovery_state: RecoveryStates.ChallengeSelecting,
- };
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
- }
+const backupTransitions: Record<
+ BackupStates,
+ Transition<ReducerStateBackup>
+> = {
+ [BackupStates.ContinentSelecting]: {
+ ...transition(
+ "select_continent",
+ codecForActionArgSelectContinent(),
+ backupSelectContinent,
+ ),
+ },
+ [BackupStates.CountrySelecting]: {
+ ...transitionBackupJump("back", BackupStates.ContinentSelecting),
+ ...transition(
+ "select_country",
+ codecForActionArgSelectCountry(),
+ backupSelectCountry,
+ ),
+ ...transition(
+ "select_continent",
+ codecForActionArgSelectContinent(),
+ backupSelectContinent,
+ ),
+ },
+ [BackupStates.UserAttributesCollecting]: {
+ ...transitionBackupJump("back", BackupStates.CountrySelecting),
+ ...transition(
+ "enter_user_attributes",
+ codecForActionArgsEnterUserAttributes(),
+ backupEnterUserAttributes,
+ ),
+ ...transition(
+ "sync_providers",
+ codecForAny(),
+ syncOneProviderBackupTransition,
+ ),
+ },
+ [BackupStates.AuthenticationsEditing]: {
+ ...transitionBackupJump("back", BackupStates.UserAttributesCollecting),
+ ...transition("add_authentication", codecForAny(), addAuthentication),
+ ...transition("delete_authentication", codecForAny(), deleteAuthentication),
+ ...transition("add_provider", codecForAny(), addProviderBackup),
+ ...transition("delete_provider", codecForAny(), deleteProviderBackup),
+ ...transition(
+ "sync_providers",
+ codecForAny(),
+ syncOneProviderBackupTransition,
+ ),
+ ...transition("next", codecForAny(), nextFromAuthenticationsEditing),
+ },
+ [BackupStates.PoliciesReviewing]: {
+ ...transitionBackupJump("back", BackupStates.AuthenticationsEditing),
+ ...transitionBackupJump("next", BackupStates.SecretEditing),
+ ...transition("add_policy", codecForActionArgsAddPolicy(), addPolicy),
+ ...transition("delete_policy", codecForAny(), deletePolicy),
+ ...transition("update_policy", codecForAny(), updatePolicy),
+ },
+ [BackupStates.SecretEditing]: {
+ ...transitionBackupJump("back", BackupStates.PoliciesReviewing),
+ ...transition("next", codecForAny(), uploadSecret),
+ ...transition("enter_secret", codecForAny(), enterSecret),
+ ...transition(
+ "update_expiration",
+ codecForActionArgsUpdateExpiration(),
+ updateSecretExpiration,
+ ),
+ ...transition("enter_secret_name", codecForAny(), enterSecretName),
+ },
+ [BackupStates.PoliciesPaying]: {
+ ...transitionBackupJump("back", BackupStates.SecretEditing),
+ ...transition("pay", codecForAny(), uploadSecret),
+ },
+ [BackupStates.TruthsPaying]: {
+ ...transitionBackupJump("back", BackupStates.SecretEditing),
+ ...transition("pay", codecForAny(), uploadSecret),
+ },
+ [BackupStates.BackupFinished]: {
+ ...transitionBackupJump("back", BackupStates.SecretEditing),
+ },
+};
+
+const recoveryTransitions: Record<
+ RecoveryStates,
+ Transition<ReducerStateRecovery>
+> = {
+ [RecoveryStates.ContinentSelecting]: {
+ ...transition(
+ "select_continent",
+ codecForActionArgSelectContinent(),
+ recoverySelectContinent,
+ ),
+ },
+ [RecoveryStates.CountrySelecting]: {
+ ...transitionRecoveryJump("back", RecoveryStates.ContinentSelecting),
+ ...transition(
+ "select_country",
+ codecForActionArgSelectCountry(),
+ recoverySelectCountry,
+ ),
+ ...transition(
+ "select_continent",
+ codecForActionArgSelectContinent(),
+ recoverySelectContinent,
+ ),
+ },
+ [RecoveryStates.UserAttributesCollecting]: {
+ ...transitionRecoveryJump("back", RecoveryStates.CountrySelecting),
+ ...transition(
+ "enter_user_attributes",
+ codecForActionArgsEnterUserAttributes(),
+ recoveryEnterUserAttributes,
+ ),
+ },
+ [RecoveryStates.SecretSelecting]: {
+ ...transitionRecoveryJump("back", RecoveryStates.UserAttributesCollecting),
+ ...transitionRecoveryJump("next", RecoveryStates.ChallengeSelecting),
+ ...transition("add_provider", codecForAny(), addProviderRecovery),
+ ...transition("delete_provider", codecForAny(), deleteProviderRecovery),
+ ...transition(
+ "select_version",
+ codecForActionArgsChangeVersion(),
+ changeVersion,
+ ),
+ },
+ [RecoveryStates.ChallengeSelecting]: {
+ ...transitionRecoveryJump("back", RecoveryStates.SecretSelecting),
+ ...transition(
+ "select_challenge",
+ codecForActionArgsSelectChallenge(),
+ selectChallenge,
+ ),
+ ...transition("poll", codecForAny(), pollChallenges),
+ ...transition("next", codecForAny(), nextFromChallengeSelecting),
+ ...transition(
+ "sync_providers",
+ codecForAny(),
+ syncOneProviderRecoveryTransition,
+ ),
+ },
+ [RecoveryStates.ChallengeSolving]: {
+ ...transitionRecoveryJump("back", RecoveryStates.ChallengeSelecting),
+ ...transition("solve_challenge", codecForAny(), solveChallenge),
+ },
+ [RecoveryStates.ChallengePaying]: {},
+ [RecoveryStates.RecoveryFinished]: {
+ ...transitionRecoveryJump("back", RecoveryStates.ChallengeSelecting),
+ },
+};
+
+export async function discoverPolicies(
+ state: ReducerState,
+ cursor?: DiscoveryCursor,
+): Promise<DiscoveryResult> {
+ if (state.reducer_type !== "recovery") {
+ throw Error("can only discover providers in recovery state");
}
- if (state.recovery_state === RecoveryStates.ChallengeSelecting) {
- if (action === "select_challenge") {
- const ta: ActionArgsSelectChallenge = args;
- return selectChallenge(state, ta);
- } else if (action === "back") {
- return {
- ...state,
- recovery_state: RecoveryStates.SecretSelecting,
- };
- } else if (action === "next") {
- const s2 = await tryRecoverSecret(state);
- if (s2.recovery_state === RecoveryStates.RecoveryFinished) {
- return s2;
+ const policies: PolicyMetaInfo[] = [];
+
+ const providerUrls = Object.keys(state.authentication_providers || {});
+ // FIXME: Do we need to re-contact providers here / check if they're disabled?
+ // FIXME: Do this concurrently and take the first. Otherwise, one provider might block for a long time.
+
+ for (const providerUrl of providerUrls) {
+ const providerInfo = await getProviderInfo(providerUrl);
+ if (providerInfo.status !== "ok") {
+ continue;
+ }
+ const userId = await userIdentifierDerive(
+ state.identity_attributes!,
+ providerInfo.provider_salt,
+ );
+ const acctKeypair = accountKeypairDerive(userId);
+ const reqUrl = new URL(`policy/${acctKeypair.pub}/meta`, providerUrl);
+ const resp = await httpLib.fetch(reqUrl.href);
+ if (resp.status !== 200) {
+ logger.warn(`Could not fetch policy metadate from ${reqUrl.href}`);
+ continue;
+ }
+ const respJson: RecoveryMetaResponse = await resp.json();
+ const versions = Object.keys(respJson);
+ for (const version of versions) {
+ const item = respJson[version];
+ if (!item.meta) {
+ continue;
}
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: "Not enough challenges solved",
- };
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+ const metaData = await decryptPolicyMetadata(userId, item.meta!);
+ policies.push({
+ attribute_mask: 0,
+ provider_url: providerUrl,
+ server_time: item.upload_time,
+ version: Number.parseInt(version, 10),
+ secret_name: metaData.secret_name,
+ policy_hash: metaData.policy_hash,
+ });
}
}
+ return {
+ policies,
+ cursor: undefined,
+ };
+}
- if (state.recovery_state === RecoveryStates.ChallengeSolving) {
- if (action === "back") {
- const ta: ActionArgsSelectChallenge = args;
- return {
- ...state,
- selected_challenge_uuid: undefined,
- recovery_state: RecoveryStates.ChallengeSelecting,
- };
- } else if (action === "solve_challenge") {
- const ta: ActionArgsSolveChallengeRequest = args;
- return solveChallenge(state, ta);
- } else {
+export async function reduceAction(
+ state: ReducerState,
+ action: string,
+ args: any,
+): Promise<ReducerState> {
+ let h: TransitionImpl<any, any>;
+ let stateName: string;
+ if ("backup_state" in state && state.backup_state) {
+ stateName = state.backup_state;
+ h = backupTransitions[state.backup_state][action];
+ } else if ("recovery_state" in state && state.recovery_state) {
+ stateName = state.recovery_state;
+ h = recoveryTransitions[state.recovery_state][action];
+ } else {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Invalid state (needs backup_state or recovery_state)`,
+ };
+ }
+ if (!h) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
+ hint: `Unsupported action '${action}' in state '${stateName}'`,
+ };
+ }
+ let parsedArgs: any;
+ try {
+ parsedArgs = h.argCodec.decode(args);
+ } catch (e: any) {
+ return {
+ reducer_type: "error",
+ code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID,
+ hint: "argument validation failed",
+ detail: e.toString(),
+ };
+ }
+ try {
+ return await h.handler(state, parsedArgs);
+ } catch (e: any) {
+ logger.error("action handler failed");
+ logger.error(`${e?.stack ?? e}`);
+ if (e instanceof ReducerError) {
return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
+ reducer_type: "error",
+ ...e.errorJson,
};
}
+ throw e;
}
+}
- if (state.recovery_state === RecoveryStates.RecoveryFinished) {
- if (action === "back") {
- const ta: ActionArgsSelectChallenge = args;
- return {
- ...state,
- selected_challenge_uuid: undefined,
- recovery_state: RecoveryStates.ChallengeSelecting,
- };
- } else if (action === "solve_challenge") {
- const ta: ActionArgsSolveChallengeRequest = args;
- return solveChallenge(state, ta);
- } else {
- return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: `Unsupported action '${action}'`,
- };
+/**
+ * Update provider status of providers that we still need to contact.
+ *
+ * Returns updates as soon as new information about at least one provider
+ * is found.
+ *
+ * Returns an empty object if provider information is complete.
+ *
+ * FIXME: Also pass a cancellation token.
+ */
+export async function completeProviderStatus(
+ providerMap: AuthenticationProviderStatusMap,
+): Promise<AuthenticationProviderStatusMap> {
+ const updateTasks: Promise<[string, AuthenticationProviderStatus]>[] = [];
+ for (const [provUrl, pi] of Object.entries(providerMap)) {
+ switch (pi.status) {
+ case "ok":
+ case "error":
+ case "disabled":
+ default:
+ continue;
+ case "not-contacted":
+ updateTasks.push(
+ (async () => {
+ return [provUrl, await getProviderInfo(provUrl)];
+ })(),
+ );
}
}
+ if (updateTasks.length === 0) {
+ return {};
+ }
+
+ const [firstUrl, firstStatus] = await Promise.race(updateTasks);
return {
- code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
- hint: "Reducer action invalid",
+ [firstUrl]: firstStatus,
};
}
diff --git a/packages/anastasis-core/src/policy-suggestion.test.ts b/packages/anastasis-core/src/policy-suggestion.test.ts
new file mode 100644
index 000000000..fd42b708f
--- /dev/null
+++ b/packages/anastasis-core/src/policy-suggestion.test.ts
@@ -0,0 +1,44 @@
+import { AmountString, j2s } from "@gnu-taler/taler-util";
+import test from "ava";
+import { ProviderInfo, suggestPolicies } from "./policy-suggestion.js";
+
+test("policy suggestion", async (t) => {
+ const methods = [
+ {
+ challenge: "XXX",
+ instructions: "SMS to 123",
+ type: "sms",
+ },
+ {
+ challenge: "XXX",
+ instructions: "What is the meaning of life?",
+ type: "question",
+ },
+ {
+ challenge: "XXX",
+ instructions: "email to foo@bar.com",
+ type: "email",
+ },
+ ];
+ const providers: ProviderInfo[] = [
+ {
+ methodCost: {
+ sms: "KUDOS:1" as AmountString,
+ },
+ url: "prov1",
+ },
+ {
+ methodCost: {
+ question: "KUDOS:1" as AmountString,
+ },
+ url: "prov2",
+ },
+ ];
+ const res1 = suggestPolicies(methods, providers);
+ t.assert(res1.policies.length === 1);
+ const res2 = suggestPolicies([...methods].reverse(), providers);
+ t.assert(res2.policies.length === 1);
+
+ const res3 = suggestPolicies(methods, [...providers].reverse());
+ t.assert(res3.policies.length === 1);
+});
diff --git a/packages/anastasis-core/src/policy-suggestion.ts b/packages/anastasis-core/src/policy-suggestion.ts
new file mode 100644
index 000000000..2c25caaa4
--- /dev/null
+++ b/packages/anastasis-core/src/policy-suggestion.ts
@@ -0,0 +1,243 @@
+import { AmountString, j2s, Logger } from "@gnu-taler/taler-util";
+import { AuthMethod, Policy, PolicyProvider } from "./reducer-types.js";
+
+const logger = new Logger("anastasis-core:policy-suggestion.ts");
+
+const maxMethodSelections = 200;
+const maxPolicyEvaluations = 10000;
+
+/**
+ * Provider information used during provider/method mapping.
+ */
+export interface ProviderInfo {
+ url: string;
+ methodCost: Record<string, AmountString>;
+}
+
+export function suggestPolicies(
+ methods: AuthMethod[],
+ providers: ProviderInfo[],
+): PolicySelectionResult {
+ const numMethods = methods.length;
+ if (numMethods === 0) {
+ throw Error("no methods");
+ }
+ let numSel: number;
+ if (numMethods <= 2) {
+ numSel = numMethods;
+ } else if (numMethods <= 4) {
+ numSel = numMethods - 1;
+ } else if (numMethods <= 6) {
+ numSel = numMethods - 2;
+ } else if (numMethods == 7) {
+ numSel = numMethods - 3;
+ } else {
+ numSel = 4;
+ }
+ const policies: Policy[] = [];
+ const selections = enumerateMethodSelections(
+ numSel,
+ numMethods,
+ maxMethodSelections,
+ );
+ logger.info(`selections: ${j2s(selections)}`);
+ for (const sel of selections) {
+ const p = assignProviders(policies, methods, providers, sel);
+ if (p) {
+ policies.push(p);
+ }
+ }
+ logger.info(`suggesting policies ${j2s(policies)}`);
+ return {
+ policies,
+ policy_providers: providers.map((x) => ({
+ provider_url: x.url,
+ })),
+ };
+}
+
+/**
+ * Assign providers to a method selection.
+ *
+ * The evaluation of the assignment is made with respect to
+ * previously generated policies.
+ */
+function assignProviders(
+ existingPolicies: Policy[],
+ methods: AuthMethod[],
+ providers: ProviderInfo[],
+ methodSelection: number[],
+): Policy | undefined {
+ const providerSelections = enumerateProviderMappings(
+ methodSelection.length,
+ providers.length,
+ maxPolicyEvaluations,
+ );
+
+ let bestProvSel: ProviderSelection | undefined;
+ // Number of different providers selected, larger is better
+ let bestDiversity = 0;
+ // Number of identical challenges duplicated at different providers,
+ // smaller is better
+ let bestDuplication = Number.MAX_SAFE_INTEGER;
+
+ for (const provSel of providerSelections) {
+ // First, check if selection is even possible with the methods offered
+ let possible = true;
+ for (const methSelIndex in provSel) {
+ const provIndex = provSel[methSelIndex];
+ if (typeof provIndex !== "number") {
+ throw Error("invariant failed");
+ }
+ const methIndex = methodSelection[methSelIndex];
+ const meth = methods[methIndex];
+ if (!meth) {
+ throw Error("invariant failed");
+ }
+ const prov = providers[provIndex];
+ if (!prov.methodCost[meth.type]) {
+ possible = false;
+ break;
+ }
+ }
+ if (!possible) {
+ continue;
+ }
+ // Evaluate diversity, always prefer policies
+ // that increase diversity.
+ const providerSet = new Set<string>();
+ // The C reducer evaluates diversity only per policy
+ // for (const pol of existingPolicies) {
+ // for (const m of pol.methods) {
+ // providerSet.add(m.provider);
+ // }
+ // }
+ for (const provIndex of provSel) {
+ const prov = providers[provIndex];
+ providerSet.add(prov.url);
+ }
+
+ const diversity = providerSet.size;
+
+ // Number of providers that each method shows up at.
+ const provPerMethod: Set<string>[] = [];
+ for (let i = 0; i < methods.length; i++) {
+ provPerMethod[i] = new Set<string>();
+ }
+ for (const pol of existingPolicies) {
+ for (const m of pol.methods) {
+ provPerMethod[m.authentication_method].add(m.provider);
+ }
+ }
+ for (const methSelIndex in provSel) {
+ const prov = providers[provSel[methSelIndex]];
+ provPerMethod[methodSelection[methSelIndex]].add(prov.url);
+ }
+
+ let duplication = 0;
+ for (const provSet of provPerMethod) {
+ duplication += provSet.size;
+ }
+
+ logger.info(`diversity ${diversity}, duplication ${duplication}`);
+
+ if (!bestProvSel || diversity > bestDiversity) {
+ bestProvSel = provSel;
+ bestDiversity = diversity;
+ bestDuplication = duplication;
+ logger.info(`taking based on diversity`);
+ } else if (diversity == bestDiversity && duplication < bestDuplication) {
+ bestProvSel = provSel;
+ bestDiversity = diversity;
+ bestDuplication = duplication;
+ logger.info(`taking based on duplication`);
+ }
+ // TODO: also evaluate costs
+ }
+
+ if (!bestProvSel) {
+ return undefined;
+ }
+
+ return {
+ methods: bestProvSel.map((x, i) => ({
+ authentication_method: methodSelection[i],
+ provider: providers[x].url,
+ })),
+ };
+}
+
+/**
+ * A provider selection maps a method selection index to a provider index.
+ *
+ * I.e. "PSEL[i] = x" means that provider with index "x" should be used
+ * for method with index "MSEL[i]"
+ */
+type ProviderSelection = number[];
+
+/**
+ * A method selection "MSEL[j] = y" means that policy method j
+ * should use method y.
+ */
+type MethodSelection = number[];
+
+/**
+ * Compute provider mappings.
+ * Enumerates all n-combinations with repetition of m providers.
+ */
+function enumerateProviderMappings(
+ n: number,
+ m: number,
+ limit?: number,
+): ProviderSelection[] {
+ const selections: ProviderSelection[] = [];
+ const a = new Array(n);
+ const sel = (i: number, start: number = 0) => {
+ if (i === n) {
+ selections.push([...a]);
+ return;
+ }
+ for (let j = start; j < m; j++) {
+ a[i] = j;
+ sel(i + 1, 0);
+ if (limit && selections.length >= limit) {
+ break;
+ }
+ }
+ };
+ sel(0);
+ return selections;
+}
+
+interface PolicySelectionResult {
+ policies: Policy[];
+ policy_providers: PolicyProvider[];
+}
+
+/**
+ * Compute method selections.
+ * Enumerates all n-combinations without repetition of m methods.
+ */
+function enumerateMethodSelections(
+ n: number,
+ m: number,
+ limit?: number,
+): MethodSelection[] {
+ const selections: MethodSelection[] = [];
+ const a = new Array(n);
+ const sel = (i: number, start: number = 0) => {
+ if (i === n) {
+ selections.push([...a]);
+ return;
+ }
+ for (let j = start; j < m; j++) {
+ a[i] = j;
+ sel(i + 1, j + 1);
+ if (limit && selections.length >= limit) {
+ break;
+ }
+ }
+ };
+ sel(0);
+ return selections;
+}
diff --git a/packages/anastasis-core/src/provider-types.ts b/packages/anastasis-core/src/provider-types.ts
index b477c09b9..1724b0ed1 100644
--- a/packages/anastasis-core/src/provider-types.ts
+++ b/packages/anastasis-core/src/provider-types.ts
@@ -1,4 +1,31 @@
-import { AmountString } from "@gnu-taler/taler-util";
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountString,
+ buildCodecForObject,
+ buildCodecForUnion,
+ Codec,
+ codecForAmountString,
+ codecForAny,
+ codecForConstString,
+ codecForNumber,
+ codecForString,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
export interface EscrowConfigurationResponse {
// Protocol identifier, clarifies that this is an Anastasis provider.
@@ -35,8 +62,11 @@ export interface EscrowConfigurationResponse {
// **provider salt** is then used in various operations to ensure
// cryptographic operations differ by provider. A provider must
// never change its salt value.
- server_salt: string;
+ provider_salt: string;
+ /**
+ * Human-readable business name of the provider.
+ */
business_name: string;
}
@@ -72,3 +102,111 @@ export interface TruthUploadRequest {
// store the truth?
storage_duration_years: number;
}
+
+export interface IbanExternalAuthResponse {
+ method: "iban";
+ answer_code: number;
+ details: {
+ challenge_amount: AmountString;
+ credit_iban: string;
+ business_name: string;
+ wire_transfer_subject: string;
+ };
+}
+
+export interface RecoveryMetaResponse {
+ /**
+ * Version numbers as a string (!) are used as keys.
+ */
+ [version: string]: RecoveryMetaDataItem;
+}
+
+export interface RecoveryMetaDataItem {
+ // The meta value can be NULL if the document
+ // exists but no meta data was provided.
+ meta?: string;
+
+ // Server-time indicative of when the recovery
+ // document was uploaded.
+ upload_time: TalerProtocolTimestamp;
+}
+
+export type ChallengeInstructionMessage =
+ | FileChallengeInstructionMessage
+ | IbanChallengeInstructionMessage
+ | PinChallengeInstructionMessage;
+
+export interface IbanChallengeInstructionMessage {
+ // What kind of challenge is this?
+ challenge_type: "IBAN_WIRE";
+
+ wire_details: {
+ // How much should be wired?
+ challenge_amount: AmountString;
+
+ // What is the target IBAN?
+ credit_iban: string;
+
+ // What is the receiver name?
+ business_name: string;
+
+ // What is the expected wire transfer subject?
+ wire_transfer_subject: string;
+
+ // What is the numeric code (also part of the
+ // wire transfer subject) to be hashed when
+ // solving the challenge?
+ answer_code: number;
+
+ // Hint about the origin account that must be used.
+ debit_account_hint: string;
+ };
+}
+
+export interface PinChallengeInstructionMessage {
+ // What kind of challenge is this?
+ challenge_type: "TAN_SENT";
+
+ // Where was the PIN code sent? Note that this
+ // address will most likely have been obscured
+ // to improve privacy.
+ tan_address_hint: string;
+}
+
+export interface FileChallengeInstructionMessage {
+ // What kind of challenge is this?
+ challenge_type: "FILE_WRITTEN";
+
+ // Name of the file where the PIN code was written.
+ filename: string;
+}
+
+export const codecForFileChallengeInstructionMessage =
+ (): Codec<FileChallengeInstructionMessage> =>
+ buildCodecForObject<FileChallengeInstructionMessage>()
+ .property("challenge_type", codecForConstString("FILE_WRITTEN"))
+ .property("filename", codecForString())
+ .build("FileChallengeInstructionMessage");
+
+export const codecForPinChallengeInstructionMessage =
+ (): Codec<PinChallengeInstructionMessage> =>
+ buildCodecForObject<PinChallengeInstructionMessage>()
+ .property("challenge_type", codecForConstString("TAN_SENT"))
+ .property("tan_address_hint", codecForString())
+ .build("PinChallengeInstructionMessage");
+
+export const codecForIbanChallengeInstructionMessage =
+ (): Codec<IbanChallengeInstructionMessage> =>
+ buildCodecForObject<IbanChallengeInstructionMessage>()
+ .property("challenge_type", codecForConstString("IBAN_WIRE"))
+ .property("wire_details", codecForAny())
+ .build("IbanChallengeInstructionMessage");
+
+export const codecForChallengeInstructionMessage =
+ (): Codec<ChallengeInstructionMessage> =>
+ buildCodecForUnion<ChallengeInstructionMessage>()
+ .discriminateOn("challenge_type")
+ .alternative("FILE_WRITTEN", codecForFileChallengeInstructionMessage())
+ .alternative("IBAN_WIRE", codecForIbanChallengeInstructionMessage())
+ .alternative("TAN_SENT", codecForPinChallengeInstructionMessage())
+ .build("ChallengeInstructionMessage");
diff --git a/packages/anastasis-core/src/recovery-document-types.ts b/packages/anastasis-core/src/recovery-document-types.ts
index 74003ccb1..f94aa1916 100644
--- a/packages/anastasis-core/src/recovery-document-types.ts
+++ b/packages/anastasis-core/src/recovery-document-types.ts
@@ -1,5 +1,14 @@
import { TruthKey, TruthSalt, TruthUuid } from "./crypto.js";
+export enum ChallengeType {
+ Question = "question",
+ Sms = "sms",
+ Email = "email",
+ Post = "post",
+ Totp = "totp",
+ Iban = "iban",
+}
+
export interface RecoveryDocument {
/**
* Human-readable name of the secret
@@ -9,7 +18,7 @@ export interface RecoveryDocument {
/**
* Encrypted core secret.
- *
+ *
* Variable-size length, base32-crock encoded.
*/
encrypted_core_secret: string;
@@ -56,7 +65,7 @@ export interface EscrowMethod {
/**
* Type of the escrow method (e.g. security question, SMS etc.).
*/
- escrow_type: string;
+ escrow_type: ChallengeType;
/**
* UUID of the escrow method.
@@ -73,7 +82,7 @@ export interface EscrowMethod {
/**
* Salt to hash the security question answer if applicable.
*/
- truth_salt: TruthSalt;
+ question_salt: TruthSalt;
// Salt from the provider to derive the user ID
// at this provider.
diff --git a/packages/anastasis-core/src/reducer-types.ts b/packages/anastasis-core/src/reducer-types.ts
index 1a443bf9b..ad88f40ed 100644
--- a/packages/anastasis-core/src/reducer-types.ts
+++ b/packages/anastasis-core/src/reducer-types.ts
@@ -1,4 +1,30 @@
-import { Duration, Timestamp } from "@gnu-taler/taler-util";
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountString,
+ buildCodecForObject,
+ codecForAny,
+ codecForList,
+ codecForNumber,
+ codecForString,
+ codecForTimestamp,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+import { ChallengeFeedback } from "./challenge-feedback-types.js";
import { KeyShare } from "./crypto.js";
import { RecoveryDocument } from "./recovery-document-types.js";
@@ -15,7 +41,6 @@ export interface CountryInfo {
code: string;
name: string;
continent: string;
- currency: string;
}
export interface Policy {
@@ -23,7 +48,7 @@ export interface Policy {
authentication_method: number;
provider: string;
}[];
-}
+}
export interface PolicyProvider {
provider_url: string;
@@ -32,45 +57,91 @@ export interface PolicyProvider {
export interface SuccessDetails {
[provider_url: string]: {
policy_version: number;
- policy_expiration: Timestamp;
+ policy_expiration: TalerProtocolTimestamp;
};
}
export interface CoreSecret {
mime: string;
value: string;
+ /**
+ * Filename, only set if the secret comes from
+ * a file. Should be set unless the mime type is "text/plain";
+ */
+ filename?: string;
}
export interface ReducerStateBackup {
- recovery_state?: undefined;
+ reducer_type: "backup";
+
backup_state: BackupStates;
- code?: undefined;
- currencies?: string[];
+
continents?: ContinentInfo[];
- countries?: any;
+
+ countries?: CountryInfo[];
+
identity_attributes?: { [n: string]: string };
- authentication_providers?: { [url: string]: AuthenticationProviderStatus };
+
+ authentication_providers?: AuthenticationProviderStatusMap;
+
authentication_methods?: AuthMethod[];
+
required_attributes?: UserAttributeSpec[];
+
selected_continent?: string;
+
selected_country?: string;
+
secret_name?: string;
+
policies?: Policy[];
+
+ recovery_data?: {
+ /**
+ * Map from truth key (`${methodIndex}/${providerUrl}`) to
+ * the truth metadata.
+ */
+ truth_metadata: Record<string, TruthMetaData>;
+ recovery_document: RecoveryDocument;
+ };
+
/**
* Policy providers are providers that we checked to be functional
* and that are actually used in policies.
*/
policy_providers?: PolicyProvider[];
success_details?: SuccessDetails;
+
+ /**
+ * Currently requested payments.
+ *
+ * List of taler://pay URIs.
+ *
+ * FIXME: There should be more information in this,
+ * including the provider and amount.
+ */
payments?: string[];
+
+ /**
+ * FIXME: Why is this not a map from provider to payto?
+ */
policy_payment_requests?: {
+ /**
+ * FIXME: This is not a payto URI, right?!
+ */
payto: string;
provider: string;
}[];
core_secret?: CoreSecret;
- expiration?: Duration;
+ expiration?: TalerProtocolTimestamp;
+
+ upload_fees?: { fee: AmountString }[];
+
+ // FIXME: The payment secrets and pay URIs should
+ // probably be consolidated into a single field.
+ truth_upload_payment_secrets?: Record<string, string>;
}
export interface AuthMethod {
@@ -81,7 +152,6 @@ export interface AuthMethod {
}
export interface ChallengeInfo {
- cost: string;
instructions: string;
type: string;
uuid: string;
@@ -93,6 +163,10 @@ export interface UserAttributeSpec {
type: string;
uuid: string;
widget: string;
+ optional?: boolean;
+ "validation-regex": string | undefined;
+ "validation-logic": string | undefined;
+ autocomplete?: string;
}
export interface RecoveryInternalData {
@@ -111,27 +185,22 @@ export interface RecoveryInformation {
}[][];
}
-export interface ReducerStateRecovery {
- recovery_state: RecoveryStates;
+export interface AuthenticationProviderStatusMap {
+ [url: string]: AuthenticationProviderStatus;
+}
- /**
- * Unused in the recovery states.
- */
- backup_state?: undefined;
+export interface ReducerStateRecovery {
+ reducer_type: "recovery";
- /**
- * Unused in the recovery states.
- */
- code?: undefined;
+ recovery_state: RecoveryStates;
identity_attributes?: { [n: string]: string };
- continents?: any;
- countries?: any;
+ continents?: ContinentInfo[];
+ countries?: CountryInfo[];
selected_continent?: string;
selected_country?: string;
- currencies?: string[];
required_attributes?: UserAttributeSpec[];
@@ -148,6 +217,11 @@ export interface ReducerStateRecovery {
selected_challenge_uuid?: string;
+ /**
+ * Explicitly selected version by the user.
+ */
+ selected_version?: SelectedVersionInfo;
+
challenge_feedback?: { [uuid: string]: ChallengeFeedback };
/**
@@ -155,26 +229,45 @@ export interface ReducerStateRecovery {
*/
recovered_key_shares?: { [truth_uuid: string]: KeyShare };
- core_secret?: {
- mime: string;
- value: string;
- };
-
- authentication_providers?: { [url: string]: AuthenticationProviderStatus };
+ core_secret?: CoreSecret;
- recovery_error?: any;
+ authentication_providers?: AuthenticationProviderStatusMap;
}
-export interface ChallengeFeedback {
- state: string;
+/**
+ * Truth data as stored in the reducer.
+ */
+export interface TruthMetaData {
+ uuid: string;
+
+ key_share: string;
+
+ policy_index: number;
+
+ pol_method_index: number;
+
+ /**
+ * Nonce used for encrypting the truth.
+ */
+ nonce: string;
+
+ /**
+ * Key that the truth (i.e. secret question answer, email address, mobile number, ...)
+ * is encrypted with when stored at the provider.
+ */
+ truth_key: string;
+
+ /**
+ * Truth-specific salt.
+ */
+ master_salt: string;
}
export interface ReducerStateError {
- backup_state?: undefined;
- recovery_state?: undefined;
+ reducer_type: "error";
code: number;
hint?: string;
- message?: string;
+ detail?: string;
}
export enum BackupStates {
@@ -202,31 +295,44 @@ export enum RecoveryStates {
export interface MethodSpec {
type: string;
- usage_fee: string;
+ usage_fee: AmountString;
}
-// FIXME: This should be tagged!
-export type AuthenticationProviderStatusEmpty = {};
+export type AuthenticationProviderStatusNotContacted = {
+ status: "not-contacted";
+};
export interface AuthenticationProviderStatusOk {
+ status: "ok";
+
annual_fee: string;
business_name: string;
currency: string;
http_status: 200;
liability_limit: string;
- salt: string;
+ provider_salt: string;
storage_limit_in_megabytes: number;
truth_upload_fee: string;
methods: MethodSpec[];
+ // FIXME: add timestamp?
+}
+
+export interface AuthenticationProviderStatusDisabled {
+ status: "disabled";
}
export interface AuthenticationProviderStatusError {
- http_status: number;
- error_code: number;
+ status: "error";
+
+ http_status?: number;
+ code: number;
+ hint?: string;
+ // FIXME: add timestamp?
}
export type AuthenticationProviderStatus =
- | AuthenticationProviderStatusEmpty
+ | AuthenticationProviderStatusNotContacted
+ | AuthenticationProviderStatusDisabled
| AuthenticationProviderStatusError
| AuthenticationProviderStatusOk;
@@ -236,14 +342,27 @@ export interface ReducerStateBackupUserAttributesCollecting
selected_country: string;
currencies: string[];
required_attributes: UserAttributeSpec[];
- authentication_providers: { [url: string]: AuthenticationProviderStatus };
+ authentication_providers: AuthenticationProviderStatusMap;
}
-export interface ActionArgEnterUserAttributes {
+export interface ActionArgsEnterUserAttributes {
identity_attributes: Record<string, string>;
}
-export interface ActionArgAddAuthentication {
+export const codecForActionArgsEnterUserAttributes = () =>
+ buildCodecForObject<ActionArgsEnterUserAttributes>()
+ .property("identity_attributes", codecForAny())
+ .build("ActionArgsEnterUserAttributes");
+
+export interface ActionArgsAddProvider {
+ provider_url: string;
+}
+
+export interface ActionArgsDeleteProvider {
+ provider_url: string;
+}
+
+export interface ActionArgsAddAuthentication {
authentication_method: {
type: string;
instructions: string;
@@ -252,32 +371,180 @@ export interface ActionArgAddAuthentication {
};
}
-export interface ActionArgDeleteAuthentication {
+export interface ActionArgsDeleteAuthentication {
authentication_method: number;
}
-export interface ActionArgDeletePolicy {
+export interface ActionArgsDeletePolicy {
policy_index: number;
}
-export interface ActionArgEnterSecretName {
+export interface ActionArgsEnterSecretName {
name: string;
}
-export interface ActionArgEnterSecret {
+export interface ActionArgsEnterSecret {
secret: {
value: string;
mime?: string;
+ filename?: string;
};
- expiration: Duration;
+ expiration: TalerProtocolTimestamp;
+}
+
+export interface ActionArgsSelectContinent {
+ continent: string;
+}
+
+export const codecForActionArgSelectContinent = () =>
+ buildCodecForObject<ActionArgsSelectContinent>()
+ .property("continent", codecForString())
+ .build("ActionArgSelectContinent");
+
+export interface ActionArgsSelectCountry {
+ country_code: string;
}
export interface ActionArgsSelectChallenge {
uuid: string;
}
-export type ActionArgsSolveChallengeRequest = SolveChallengeAnswerRequest;
-
+export type ActionArgsSolveChallengeRequest =
+ | SolveChallengeAnswerRequest
+ | SolveChallengePinRequest
+ | SolveChallengeHashRequest;
+
+/**
+ * Answer to a challenge.
+ *
+ * For "question" challenges, this is a string with the answer.
+ *
+ * For "sms" / "email" / "post" this is a numeric code with optionally
+ * the "A-" prefix.
+ */
export interface SolveChallengeAnswerRequest {
answer: string;
}
+
+/**
+ * Answer to a challenge that requires a numeric response.
+ *
+ * XXX: Should be deprecated in favor of just "answer".
+ */
+export interface SolveChallengePinRequest {
+ pin: number;
+}
+
+/**
+ * Answer to a challenge by directly providing the hash.
+ *
+ * XXX: When / why is this even used?
+ */
+export interface SolveChallengeHashRequest {
+ /**
+ * Base32-crock encoded hash code.
+ */
+ hash: string;
+}
+
+export interface PolicyMember {
+ authentication_method: number;
+ provider: string;
+}
+
+export interface ActionArgsAddPolicy {
+ policy: PolicyMember[];
+}
+
+export interface ActionArgsUpdateExpiration {
+ expiration: TalerProtocolTimestamp;
+}
+
+export interface SelectedVersionInfo {
+ attribute_mask: number;
+ providers: {
+ url: string;
+ version: number;
+ }[];
+}
+
+export type ActionArgsChangeVersion = SelectedVersionInfo;
+
+export interface ActionArgsUpdatePolicy {
+ policy_index: number;
+ policy: PolicyMember[];
+}
+
+/**
+ * Cursor for a provider discovery process.
+ */
+export interface DiscoveryCursor {
+ position: {
+ provider_url: string;
+ mask: number;
+ max_version?: number;
+ }[];
+}
+
+export interface PolicyMetaInfo {
+ policy_hash: string;
+ provider_url: string;
+ version: number;
+ attribute_mask: number;
+ server_time: TalerProtocolTimestamp;
+ secret_name?: string;
+}
+
+/**
+ * Aggregated / de-duplicated policy meta info.
+ */
+export interface AggregatedPolicyMetaInfo {
+ secret_name?: string;
+ policy_hash: string;
+ attribute_mask: number;
+ providers: {
+ url: string;
+ version: number;
+ }[];
+}
+
+export interface DiscoveryResult {
+ /**
+ * Found policies.
+ */
+ policies: PolicyMetaInfo[];
+
+ /**
+ * Cursor that allows getting more results.
+ */
+ cursor?: DiscoveryCursor;
+}
+
+// FIXME: specify schema!
+export const codecForActionArgsChangeVersion = codecForAny;
+
+export const codecForPolicyMember = () =>
+ buildCodecForObject<PolicyMember>()
+ .property("authentication_method", codecForNumber())
+ .property("provider", codecForString())
+ .build("PolicyMember");
+
+export const codecForActionArgsAddPolicy = () =>
+ buildCodecForObject<ActionArgsAddPolicy>()
+ .property("policy", codecForList(codecForPolicyMember()))
+ .build("ActionArgsAddPolicy");
+
+export const codecForActionArgsUpdateExpiration = () =>
+ buildCodecForObject<ActionArgsUpdateExpiration>()
+ .property("expiration", codecForTimestamp)
+ .build("ActionArgsUpdateExpiration");
+
+export const codecForActionArgsSelectChallenge = () =>
+ buildCodecForObject<ActionArgsSelectChallenge>()
+ .property("uuid", codecForString())
+ .build("ActionArgsSelectChallenge");
+
+export const codecForActionArgSelectCountry = () =>
+ buildCodecForObject<ActionArgsSelectCountry>()
+ .property("country_code", codecForString())
+ .build("ActionArgSelectCountry");
diff --git a/packages/anastasis-core/src/validators.ts b/packages/anastasis-core/src/validators.ts
new file mode 100644
index 000000000..1c04bfdb3
--- /dev/null
+++ b/packages/anastasis-core/src/validators.ts
@@ -0,0 +1,28 @@
+function isPrime(num: number): boolean {
+ for (let i = 2, s = Math.sqrt(num); i <= s; i++)
+ if (num % i === 0) return false;
+ return num > 1;
+}
+
+export function AL_NID_check(s: string): boolean { return true }
+export function BE_NRN_check(s: string): boolean { return true }
+export function CH_AHV_check(s: string): boolean { return true }
+export function CZ_BN_check(s: string): boolean { return true }
+export function DE_TIN_check(s: string): boolean { return true }
+export function DE_SVN_check(s: string): boolean { return true }
+export function ES_DNI_check(s: string): boolean { return true }
+export function IN_AADHAR_check(s: string): boolean { return true }
+export function IT_CF_check(s: string): boolean {
+ return true
+}
+
+export function XX_SQUARE_check(s: string): boolean {
+ const n = parseInt(s, 10)
+ const r = Math.sqrt(n)
+ return n === r * r;
+}
+export function XY_PRIME_check(s: string): boolean {
+ const n = parseInt(s, 10)
+ return isPrime(n)
+}
+
diff --git a/packages/anastasis-core/tsconfig.json b/packages/anastasis-core/tsconfig.json
index b5476273c..a12f2e641 100644
--- a/packages/anastasis-core/tsconfig.json
+++ b/packages/anastasis-core/tsconfig.json
@@ -2,11 +2,11 @@
"compileOnSave": true,
"compilerOptions": {
"composite": true,
- "target": "ES2018",
- "module": "ESNext",
- "moduleResolution": "node",
+ "target": "ES2020",
+ "module": "Node16",
+ "moduleResolution": "Node16",
"sourceMap": true,
- "lib": ["es6", "DOM"],
+ "lib": ["ES2020"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
diff --git a/packages/anastasis-webui/.storybook/main.js b/packages/anastasis-webui/.storybook/main.js
deleted file mode 100644
index f8e4bbcc7..000000000
--- a/packages/anastasis-webui/.storybook/main.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-
-module.exports = {
- "stories": [
- "../src/**/*.stories.mdx",
- "../src/**/*.stories.@(js|jsx|ts|tsx)"
- ],
- "addons": [
- "@storybook/preset-scss",
- "@storybook/addon-a11y",
- "@storybook/addon-essentials" //docs, control, actions, viewpot, toolbar, background
- ],
- // sb does not yet support new jsx transform by default
- // https://github.com/storybookjs/storybook/issues/12881
- // https://github.com/storybookjs/storybook/issues/12952
- babel: async (options) => ({
- ...options,
- presets: [
- ...options.presets,
- [
- '@babel/preset-react', {
- runtime: 'automatic',
- },
- 'preset-react-jsx-transform'
- ],
- ],
- }),
- webpackFinal: (config) => {
- // should be removed after storybook 6.3
- // https://github.com/storybookjs/storybook/issues/12853#issuecomment-821576113
- config.resolve.alias = {
- react: "preact/compat",
- "react-dom": "preact/compat",
- };
- return config;
- },
-} \ No newline at end of file
diff --git a/packages/anastasis-webui/.storybook/preview.js b/packages/anastasis-webui/.storybook/preview.js
deleted file mode 100644
index 7cb9405ba..000000000
--- a/packages/anastasis-webui/.storybook/preview.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import "../src/scss/main.scss"
-import { TranslationProvider } from '../src/context/translation'
-import { h } from 'preact';
-
-
-export const parameters = {
- controls: { expanded: true },
-}
-
-export const globalTypes = {
- locale: {
- name: 'Locale',
- description: 'Internationalization locale',
- defaultValue: 'en',
- toolbar: {
- icon: 'globe',
- items: [
- { value: 'en', right: '🇺🇸', title: 'English' },
- { value: 'es', right: '🇪🇸', title: 'Spanish' },
- ],
- },
- },
-};
-
-export const decorators = [
- (Story, { globals }) => {
- document.body.parentElement.classList = "has-aside-left has-aside-mobile-transition has-navbar-fixed-top has-aside-expanded"
- return <Story />
- },
- (Story, { globals }) => <TranslationProvider initial='en' forceLang={globals.locale}>
- <Story />
- </TranslationProvider>,
-];
diff --git a/packages/anastasis-webui/README.md b/packages/anastasis-webui/README.md
index 9f9b5987d..e56da8a1c 100644
--- a/packages/anastasis-webui/README.md
+++ b/packages/anastasis-webui/README.md
@@ -1,19 +1,3 @@
# anastasis-webui
-## CLI Commands
-* `npm install`: Installs dependencies
-
-* `npm run dev`: Run a development, HMR server
-
-* `npm run serve`: Run a production-like server
-
-* `npm run build`: Production-ready build
-
-* `npm run lint`: Pass TypeScript files using ESLint
-
-* `npm run test`: Run Jest and Enzyme with
- [`enzyme-adapter-preact-pure`](https://github.com/preactjs/enzyme-adapter-preact-pure) for
- your tests
-
-
-For detailed explanation on how things work, checkout the [CLI Readme](https://github.com/developit/preact-cli/blob/master/README.md).
+This package implements a web-based client UI for GNU Anastasis.
diff --git a/packages/anastasis-webui/build.mjs b/packages/anastasis-webui/build.mjs
new file mode 100755
index 000000000..c52d5e718
--- /dev/null
+++ b/packages/anastasis-webui/build.mjs
@@ -0,0 +1,27 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { build } from "@gnu-taler/web-util/build";
+
+await build({
+ type: "production",
+ source: {
+ js: ["src/index.ts"],
+ assets: [{base:"src",files:["src/index.html"]}],
+ },
+ destination: "./dist/prod",
+ css: "sass",
+});
diff --git a/packages/anastasis-webui/copyleft-header.js b/packages/anastasis-webui/copyleft-header.js
new file mode 100644
index 000000000..c7e427827
--- /dev/null
+++ b/packages/anastasis-webui/copyleft-header.js
@@ -0,0 +1,15 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
diff --git a/packages/anastasis-webui/dev.mjs b/packages/anastasis-webui/dev.mjs
new file mode 100755
index 000000000..91fcc6a07
--- /dev/null
+++ b/packages/anastasis-webui/dev.mjs
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { serve } from "@gnu-taler/web-util/node";
+import { initializeDev } from "@gnu-taler/web-util/build";
+
+const devEntryPoints = ["src/stories.tsx", "src/index.ts"];
+
+const build = initializeDev({
+ type: "development",
+ source: {
+ js: devEntryPoints,
+ assets: [{base:"src",files:["src/index.html"]}],
+ },
+ css: "sass",
+ destination: "./dist/dev",
+});
+
+await build();
+
+serve({
+ folder: "./dist/dev",
+ port: 8080,
+ source: "./src",
+ onSourceUpdate: build,
+});
diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json
index 57cfdd8d4..eb65a41a0 100644
--- a/packages/anastasis-webui/package.json
+++ b/packages/anastasis-webui/package.json
@@ -1,67 +1,51 @@
{
"private": true,
- "name": "anastasis-webui",
- "version": "0.0.0",
+ "name": "@gnu-taler/anastasis-webui",
+ "version": "0.10.6",
"license": "MIT",
+ "type": "module",
"scripts": {
- "build": "preact build",
- "serve": "sirv build --port 8080 --cors --single",
- "dev": "preact watch",
+ "build": "./build.mjs",
+ "compile": "tsc && ./build.mjs",
+ "dev": "./dev.mjs",
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
- "test": "jest ./tests",
- "build-storybook": "build-storybook",
- "storybook": "start-storybook -p 6006"
- },
- "eslintConfig": {
- "parser": "@typescript-eslint/parser",
- "extends": [
- "preact",
- "plugin:@typescript-eslint/recommended"
- ],
- "ignorePatterns": [
- "build/"
- ]
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "test": "./test.mjs && mocha --require source-map-support/register --enable-source-maps 'dist/**/*test.js'",
+ "pretty": "prettier --write src"
},
"dependencies": {
- "@gnu-taler/taler-util": "workspace:^0.8.3",
- "anastasis-core": "workspace:^0.0.1",
+ "@gnu-taler/anastasis-core": "workspace:*",
+ "@gnu-taler/taler-util": "workspace:*",
+ "@gnu-taler/web-util": "workspace:*",
+ "date-fns": "2.29.2",
"jed": "1.1.1",
- "preact": "^10.3.1",
- "preact-render-to-string": "^5.1.4",
- "preact-router": "^3.2.1"
+ "jssha": "^3.3.0",
+ "preact": "10.11.3",
+ "preact-router": "^3.2.1",
+ "qrcode-generator": "^1.4.4"
+ },
+ "eslintConfig": {
+ "plugins": [
+ "header"
+ ],
+ "rules": {
+ "header/header": [
+ 2,
+ "copyleft-header.js"
+ ]
+ }
},
"devDependencies": {
"@creativebulma/bulma-tooltip": "^1.2.0",
- "@storybook/addon-a11y": "^6.2.9",
- "@storybook/addon-actions": "^6.2.9",
- "@storybook/addon-essentials": "^6.2.9",
- "@storybook/addon-links": "^6.2.9",
- "@storybook/preact": "^6.2.9",
- "@storybook/preset-scss": "^1.0.3",
- "@types/enzyme": "^3.10.5",
- "@types/jest": "^26.0.8",
- "@typescript-eslint/eslint-plugin": "^2.25.0",
- "@typescript-eslint/parser": "^2.25.0",
+ "@types/chai": "^4.3.0",
+ "@types/mocha": "^9.0.0",
"bulma": "^0.9.3",
"bulma-checkbox": "^1.1.1",
"bulma-radio": "^1.1.1",
- "enzyme": "^3.11.0",
- "enzyme-adapter-preact-pure": "^3.1.0",
- "eslint": "^6.8.0",
- "eslint-config-preact": "^1.1.1",
- "jest": "^26.2.2",
- "jest-preset-preact": "^4.0.2",
- "preact-cli": "^3.2.2",
- "sass": "^1.32.13",
- "sass-loader": "^10.1.1",
- "sirv-cli": "^1.0.0-next.3",
- "typescript": "^3.7.5"
- },
- "jest": {
- "preset": "jest-preset-preact",
- "setupFiles": [
- "<rootDir>/tests/__mocks__/browserMocks.ts",
- "<rootDir>/tests/__mocks__/setupTests.ts"
- ]
+ "chai": "^4.3.6",
+ "mocha": "^9.2.0",
+ "sass": "1.56.1",
+ "typescript": "^5.3.3"
}
}
diff --git a/packages/anastasis-webui/src/.babelrc b/packages/anastasis-webui/src/.babelrc
deleted file mode 100644
index 123002210..000000000
--- a/packages/anastasis-webui/src/.babelrc
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "presets": [
- "preact-cli/babel"
- ]
-}
diff --git a/packages/anastasis-webui/src/assets/empty.png b/packages/anastasis-webui/src/assets/empty.png
new file mode 100644
index 000000000..5120d3138
--- /dev/null
+++ b/packages/anastasis-webui/src/assets/empty.png
Binary files differ
diff --git a/packages/anastasis-webui/src/assets/example/id1.jpg b/packages/anastasis-webui/src/assets/example/id1.jpg
new file mode 100644
index 000000000..5d022a379
--- /dev/null
+++ b/packages/anastasis-webui/src/assets/example/id1.jpg
Binary files differ
diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/email.svg b/packages/anastasis-webui/src/assets/icons/auth_method/email.svg
new file mode 100644
index 000000000..3e44b8779
--- /dev/null
+++ b/packages/anastasis-webui/src/assets/icons/auth_method/email.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z"/></svg> \ No newline at end of file
diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/postal.svg b/packages/anastasis-webui/src/assets/icons/auth_method/postal.svg
new file mode 100644
index 000000000..3787b8350
--- /dev/null
+++ b/packages/anastasis-webui/src/assets/icons/auth_method/postal.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 15h2v2h-2zM17 11h2v2h-2zM17 7h2v2h-2zM13.74 7l1.26.84V7z"/><path d="M10 3v1.51l2 1.33V5h9v14h-4v2h6V3z"/><path d="M8.17 5.7L15 10.25V21H1V10.48L8.17 5.7zM10 19h3v-7.84L8.17 8.09 3 11.38V19h3v-6h4v6z"/></svg> \ No newline at end of file
diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/question.svg b/packages/anastasis-webui/src/assets/icons/auth_method/question.svg
new file mode 100644
index 000000000..a346556b2
--- /dev/null
+++ b/packages/anastasis-webui/src/assets/icons/auth_method/question.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M11 23.59v-3.6c-5.01-.26-9-4.42-9-9.49C2 5.26 6.26 1 11.5 1S21 5.26 21 10.5c0 4.95-3.44 9.93-8.57 12.4l-1.43.69zM11.5 3C7.36 3 4 6.36 4 10.5S7.36 18 11.5 18H13v2.3c3.64-2.3 6-6.08 6-9.8C19 6.36 15.64 3 11.5 3zm-1 11.5h2v2h-2zm2-1.5h-2c0-3.25 3-3 3-5 0-1.1-.9-2-2-2s-2 .9-2 2h-2c0-2.21 1.79-4 4-4s4 1.79 4 4c0 2.5-3 2.75-3 5z"/></svg> \ No newline at end of file
diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/sms.svg b/packages/anastasis-webui/src/assets/icons/auth_method/sms.svg
new file mode 100644
index 000000000..ed15679bf
--- /dev/null
+++ b/packages/anastasis-webui/src/assets/icons/auth_method/sms.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 1.01L7 1c-1.1 0-1.99.9-1.99 2v18c0 1.1.89 2 1.99 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/></svg> \ No newline at end of file
diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/video.svg b/packages/anastasis-webui/src/assets/icons/auth_method/video.svg
new file mode 100644
index 000000000..69de5e0b4
--- /dev/null
+++ b/packages/anastasis-webui/src/assets/icons/auth_method/video.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M18,10.48V6c0-1.1-0.9-2-2-2H4C2.9,4,2,4.9,2,6v12c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2v-4.48l4,3.98v-11L18,10.48z M16,9.69V18H4V6h12V9.69z"/><circle cx="10" cy="10" r="2"/><path d="M14,15.43c0-0.81-0.48-1.53-1.22-1.85C11.93,13.21,10.99,13,10,13c-0.99,0-1.93,0.21-2.78,0.58C6.48,13.9,6,14.62,6,15.43 V16h8V15.43z"/></g></g></svg> \ No newline at end of file
diff --git a/packages/anastasis-webui/src/components/AsyncButton.tsx b/packages/anastasis-webui/src/components/AsyncButton.tsx
new file mode 100644
index 000000000..3e0dca1b8
--- /dev/null
+++ b/packages/anastasis-webui/src/components/AsyncButton.tsx
@@ -0,0 +1,64 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ComponentChildren, h, VNode } from "preact";
+import { useLayoutEffect, useRef } from "preact/hooks";
+import { useAsync } from "../hooks/async.js";
+
+type Props = {
+ children: ComponentChildren;
+ disabled?: boolean;
+ onClick?: () => Promise<void>;
+ grabFocus?: boolean;
+ [rest: string]: any;
+};
+
+export function AsyncButton({
+ onClick,
+ grabFocus,
+ disabled,
+ children,
+ ...rest
+}: Props): VNode {
+ const { isLoading, request } = useAsync(onClick);
+
+ const buttonRef = useRef<HTMLButtonElement>(null);
+ useLayoutEffect(() => {
+ if (grabFocus) {
+ buttonRef.current?.focus();
+ }
+ }, [grabFocus]);
+
+ // if (isSlow) {
+ // return <LoadingModal onCancel={cancel} />;
+ // }
+ if (isLoading) {
+ return <button class="button">Loading...</button>;
+ }
+
+ return (
+ <span data-tooltip={rest["data-tooltip"]} style={{ marginLeft: 5 }}>
+ <button {...rest} ref={buttonRef} onClick={request} disabled={disabled}>
+ {children}
+ </button>
+ </span>
+ );
+}
diff --git a/packages/anastasis-webui/src/components/FlieButton.tsx b/packages/anastasis-webui/src/components/FlieButton.tsx
new file mode 100644
index 000000000..1d19ae630
--- /dev/null
+++ b/packages/anastasis-webui/src/components/FlieButton.tsx
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { useRef, useState } from "preact/hooks";
+
+const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024;
+
+export interface FileTypeContent {
+ content: string;
+ type: string;
+ name: string;
+}
+
+interface Props {
+ label: string;
+ onChange: (v: FileTypeContent | undefined) => void;
+}
+export function FileButton(props: Props): VNode {
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const [sizeError, setSizeError] = useState(false);
+ return (
+ <div>
+ <button class="button" onClick={(e) => fileInputRef.current?.click()}>
+ <span>{props.label}</span>
+ </button>
+ <input
+ ref={fileInputRef}
+ style={{ display: "none" }}
+ type="file"
+ onChange={(e) => {
+ const f: FileList | null = e.currentTarget.files;
+ if (!f || f.length != 1) {
+ return props.onChange(undefined);
+ }
+ if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) {
+ setSizeError(true);
+ return props.onChange(undefined);
+ }
+ setSizeError(false);
+ return f[0].arrayBuffer().then((b) => {
+ const content = new Uint8Array(b).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ );
+ return props.onChange({
+ content,
+ name: f[0].name,
+ type: f[0].type,
+ });
+ });
+ }}
+ />
+ {sizeError && (
+ <p class="help is-danger">File should be smaller than 1 MB</p>
+ )}
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/components/InvalidState.tsx b/packages/anastasis-webui/src/components/InvalidState.tsx
new file mode 100644
index 000000000..8e2edde5e
--- /dev/null
+++ b/packages/anastasis-webui/src/components/InvalidState.tsx
@@ -0,0 +1,21 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+
+export default function InvalidState(): VNode {
+ return <div>invalid state</div>;
+}
diff --git a/packages/anastasis-webui/src/components/NoReducer.tsx b/packages/anastasis-webui/src/components/NoReducer.tsx
new file mode 100644
index 000000000..550ddccaa
--- /dev/null
+++ b/packages/anastasis-webui/src/components/NoReducer.tsx
@@ -0,0 +1,21 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+
+export default function NoReducer(): VNode {
+ return <div>no reducer</div>;
+}
diff --git a/packages/anastasis-webui/src/components/Notifications.tsx b/packages/anastasis-webui/src/components/Notifications.tsx
new file mode 100644
index 000000000..b39030de3
--- /dev/null
+++ b/packages/anastasis-webui/src/components/Notifications.tsx
@@ -0,0 +1,74 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+
+export interface Notification {
+ message: string;
+ description?: string | VNode;
+ type: MessageType;
+}
+
+export type MessageType = "INFO" | "WARN" | "ERROR" | "SUCCESS";
+
+interface Props {
+ notifications: Notification[];
+ removeNotification?: (n: Notification) => void;
+}
+
+function messageStyle(type: MessageType): string {
+ switch (type) {
+ case "INFO":
+ return "message is-info";
+ case "WARN":
+ return "message is-warning";
+ case "ERROR":
+ return "message is-danger";
+ case "SUCCESS":
+ return "message is-success";
+ default:
+ return "message";
+ }
+}
+
+export function Notifications({
+ notifications,
+ removeNotification,
+}: Props): VNode {
+ return (
+ <div class="block">
+ {notifications.map((n, i) => (
+ <article key={i} class={messageStyle(n.type)}>
+ <div class="message-header">
+ <p>{n.message}</p>
+ {removeNotification && (
+ <button
+ class="delete"
+ onClick={() => removeNotification && removeNotification(n)}
+ />
+ )}
+ </div>
+ {n.description && <div class="message-body">{n.description}</div>}
+ </article>
+ ))}
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/components/QR.tsx b/packages/anastasis-webui/src/components/QR.tsx
new file mode 100644
index 000000000..364028cf2
--- /dev/null
+++ b/packages/anastasis-webui/src/components/QR.tsx
@@ -0,0 +1,48 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+import { useEffect, useRef } from "preact/hooks";
+import qrcode from "qrcode-generator";
+
+export function QR({ text }: { text: string }): VNode {
+ const divRef = useRef<HTMLDivElement>(null);
+ useEffect(() => {
+ const qr = qrcode(0, "L");
+ qr.addData(text);
+ qr.make();
+ if (divRef.current)
+ divRef.current.innerHTML = qr.createSvgTag({
+ scalable: true,
+ });
+ });
+
+ return (
+ <div
+ style={{
+ width: "100%",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ }}
+ >
+ <div
+ style={{ width: "50%", minWidth: 200, maxWidth: 300 }}
+ ref={divRef}
+ />
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/components/app.tsx b/packages/anastasis-webui/src/components/app.tsx
index c6b4cfc14..3ee6944bf 100644
--- a/packages/anastasis-webui/src/components/app.tsx
+++ b/packages/anastasis-webui/src/components/app.tsx
@@ -1,7 +1,21 @@
-import { FunctionalComponent, h } from "preact";
-import { TranslationProvider } from "../context/translation";
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
-import AnastasisClient from "../pages/home";
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { FunctionalComponent, h } from "preact";
+import { TranslationProvider } from "../context/translation.js";
+import AnastasisClient from "../pages/home/index.js";
const App: FunctionalComponent = () => {
return (
diff --git a/packages/anastasis-webui/src/components/fields/DateInput.tsx b/packages/anastasis-webui/src/components/fields/DateInput.tsx
new file mode 100644
index 000000000..bd3ba26a7
--- /dev/null
+++ b/packages/anastasis-webui/src/components/fields/DateInput.tsx
@@ -0,0 +1,105 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { format, subYears } from "date-fns";
+import { h, VNode } from "preact";
+import { useLayoutEffect, useRef, useState } from "preact/hooks";
+import { DatePicker } from "../picker/DatePicker.js";
+
+export interface DateInputProps {
+ label: string;
+ grabFocus?: boolean;
+ tooltip?: string;
+ error?: string;
+ years?: Array<number>;
+ onConfirm?: () => void;
+ bind: [string, (x: string) => void];
+}
+
+export function DateInput(props: DateInputProps): VNode {
+ const inputRef = useRef<HTMLInputElement>(null);
+ useLayoutEffect(() => {
+ if (props.grabFocus) {
+ inputRef.current?.focus();
+ }
+ }, [props.grabFocus]);
+ const [opened, setOpened] = useState(false);
+
+ const value = props.bind[0] || "";
+ const [dirty, setDirty] = useState(false);
+ const showError = dirty && props.error;
+
+ const calendar = subYears(new Date(), 30);
+
+ return (
+ <div class="field">
+ <label class="label">
+ {props.label}
+ {props.tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={props.tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ <div class="control">
+ <div class="field has-addons">
+ <p class="control">
+ <input
+ type="text"
+ class={showError ? "input is-danger" : "input"}
+ value={value}
+ onKeyPress={(e) => {
+ if (e.key === "Enter" && props.onConfirm) {
+ props.onConfirm();
+ }
+ }}
+ onInput={(e) => {
+ const text = e.currentTarget.value;
+ setDirty(true);
+ props.bind[1](text);
+ }}
+ ref={inputRef}
+ />
+ </p>
+ <p class="control">
+ <a
+ class="button"
+ onClick={() => {
+ setOpened(true);
+ }}
+ >
+ <span class="icon">
+ <i class="mdi mdi-calendar" />
+ </span>
+ </a>
+ </p>
+ </div>
+ </div>
+ <p class="help">Using the format yyyy-mm-dd</p>
+ {showError && <p class="help is-danger">{props.error}</p>}
+ <DatePicker
+ opened={opened}
+ initialDate={calendar}
+ years={props.years}
+ closeFunction={() => setOpened(false)}
+ dateReceiver={(d) => {
+ setDirty(true);
+ const v = format(d, "yyyy-MM-dd");
+ props.bind[1](v);
+ }}
+ />
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/components/fields/EmailInput.tsx b/packages/anastasis-webui/src/components/fields/EmailInput.tsx
new file mode 100644
index 000000000..fc08a13a3
--- /dev/null
+++ b/packages/anastasis-webui/src/components/fields/EmailInput.tsx
@@ -0,0 +1,72 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { useLayoutEffect, useRef, useState } from "preact/hooks";
+
+export interface TextInputProps {
+ label: string;
+ grabFocus?: boolean;
+ error?: string;
+ placeholder?: string;
+ tooltip?: string;
+ onConfirm?: () => void;
+ bind: [string, (x: string) => void];
+}
+
+export function EmailInput(props: TextInputProps): VNode {
+ const inputRef = useRef<HTMLInputElement>(null);
+ useLayoutEffect(() => {
+ if (props.grabFocus) {
+ inputRef.current?.focus();
+ }
+ }, [props.grabFocus]);
+ const value = props.bind[0];
+ const [dirty, setDirty] = useState(false);
+ const showError = dirty && props.error;
+ return (
+ <div class="field">
+ <label class="label">
+ {props.label}
+ {props.tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={props.tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ <div class="control has-icons-right">
+ <input
+ value={value}
+ required
+ placeholder={props.placeholder}
+ type="email"
+ class={showError ? "input is-danger" : "input"}
+ onKeyPress={(e) => {
+ if (e.key === "Enter" && props.onConfirm) {
+ props.onConfirm();
+ }
+ }}
+ onInput={(e) => {
+ setDirty(true);
+ props.bind[1]((e.target as HTMLInputElement).value);
+ }}
+ ref={inputRef}
+ style={{ display: "block" }}
+ />
+ </div>
+ {showError && <p class="help is-danger">{props.error}</p>}
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/components/fields/FileInput.tsx b/packages/anastasis-webui/src/components/fields/FileInput.tsx
new file mode 100644
index 000000000..f20b07c7a
--- /dev/null
+++ b/packages/anastasis-webui/src/components/fields/FileInput.tsx
@@ -0,0 +1,105 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { useLayoutEffect, useRef, useState } from "preact/hooks";
+
+const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024;
+
+export interface FileTypeContent {
+ content: string;
+ type: string;
+ name: string;
+}
+
+export interface FileInputProps {
+ label: string;
+ grabFocus?: boolean;
+ disabled?: boolean;
+ error?: string;
+ placeholder?: string;
+ tooltip?: string;
+ onChange: (v: FileTypeContent | undefined) => void;
+}
+
+export function FileInput(props: FileInputProps): VNode {
+ const inputRef = useRef<HTMLInputElement>(null);
+ useLayoutEffect(() => {
+ if (props.grabFocus) {
+ inputRef.current?.focus();
+ }
+ }, [props.grabFocus]);
+
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const [sizeError, setSizeError] = useState(false);
+ return (
+ <div class="field">
+ <label class="label">
+ <a class="button" onClick={(e) => fileInputRef.current?.click()}>
+ <div class="icon is-small ">
+ <i class="mdi mdi-folder" />
+ </div>
+ <span>{props.label}</span>
+ </a>
+ {props.tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={props.tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ <div class="control">
+ <input
+ ref={fileInputRef}
+ style={{ display: "none" }}
+ type="file"
+ // name={String(name)}
+ onChange={(e) => {
+ const f: FileList | null = e.currentTarget.files;
+ if (!f || f.length != 1) {
+ return props.onChange(undefined);
+ }
+ if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) {
+ setSizeError(true);
+ return props.onChange(undefined);
+ }
+ setSizeError(false);
+ return f[0].arrayBuffer().then((b) => {
+ const b64 = btoa(
+ new Uint8Array(b).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ ),
+ );
+ return props.onChange({
+ content: `data:${f[0].type};base64,${b64}`,
+ name: f[0].name,
+ type: f[0].type,
+ });
+ });
+ }}
+ />
+ {props.error && <p class="help is-danger">{props.error}</p>}
+ {sizeError && (
+ <p class="help is-danger">File should be smaller than 1 MB</p>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/components/fields/ImageInput.tsx b/packages/anastasis-webui/src/components/fields/ImageInput.tsx
new file mode 100644
index 000000000..39404d668
--- /dev/null
+++ b/packages/anastasis-webui/src/components/fields/ImageInput.tsx
@@ -0,0 +1,93 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { useLayoutEffect, useRef, useState } from "preact/hooks";
+import emptyImage from "../../assets/empty.png";
+import { TextInputProps } from "./TextInput.js";
+
+const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024;
+
+export function ImageInput(props: TextInputProps): VNode {
+ const inputRef = useRef<HTMLInputElement>(null);
+ useLayoutEffect(() => {
+ if (props.grabFocus) {
+ inputRef.current?.focus();
+ }
+ }, [props.grabFocus]);
+
+ const value = props.bind[0];
+ // const [dirty, setDirty] = useState(false)
+ const image = useRef<HTMLInputElement>(null);
+ const [sizeError, setSizeError] = useState(false);
+ function onChange(v: string): void {
+ // setDirty(true);
+ props.bind[1](v);
+ }
+ return (
+ <div class="field">
+ <label class="label">
+ {props.label}
+ {props.tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={props.tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ <div class="control">
+ <img
+ src={!value ? emptyImage : value}
+ style={{ width: 200, height: 200 }}
+ onClick={() => image.current?.click()}
+ />
+ <input
+ ref={image}
+ style={{ display: "none" }}
+ type="file"
+ name={String(name)}
+ onChange={(e) => {
+ const f: FileList | null = e.currentTarget.files;
+ if (!f || f.length != 1) {
+ return onChange(emptyImage);
+ }
+ if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) {
+ setSizeError(true);
+ return onChange(emptyImage);
+ }
+ setSizeError(false);
+ return f[0].arrayBuffer().then((b) => {
+ const b64 = btoa(
+ new Uint8Array(b).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ ),
+ );
+ return onChange(`data:${f[0].type};base64,${b64}` as any);
+ });
+ }}
+ />
+ {props.error && <p class="help is-danger">{props.error}</p>}
+ {sizeError && (
+ <p class="help is-danger">Image should be smaller than 1 MB</p>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/components/fields/NumberInput.tsx b/packages/anastasis-webui/src/components/fields/NumberInput.tsx
new file mode 100644
index 000000000..6388843db
--- /dev/null
+++ b/packages/anastasis-webui/src/components/fields/NumberInput.tsx
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { useLayoutEffect, useRef, useState } from "preact/hooks";
+
+export interface TextInputProps {
+ label: string;
+ grabFocus?: boolean;
+ error?: string;
+ placeholder?: string;
+ tooltip?: string;
+ onConfirm?: () => void;
+ bind: [string, (x: string) => void];
+}
+
+export function PhoneNumberInput(props: TextInputProps): VNode {
+ const inputRef = useRef<HTMLInputElement>(null);
+ useLayoutEffect(() => {
+ if (props.grabFocus) {
+ inputRef.current?.focus();
+ }
+ }, [props.grabFocus]);
+ const value = props.bind[0];
+ const [dirty, setDirty] = useState(false);
+ const showError = dirty && props.error;
+ return (
+ <div class="field">
+ <label class="label">
+ {props.label}
+ {props.tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={props.tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ <div class="control has-icons-right">
+ <input
+ value={value}
+ type="tel"
+ placeholder={props.placeholder}
+ class={showError ? "input is-danger" : "input"}
+ onKeyPress={(e) => {
+ if (e.key === "Enter" && props.onConfirm) {
+ props.onConfirm();
+ }
+ }}
+ onInput={(e) => {
+ setDirty(true);
+ props.bind[1]((e.target as HTMLInputElement).value);
+ }}
+ ref={inputRef}
+ style={{ display: "block" }}
+ />
+ </div>
+ {showError && <p class="help is-danger">{props.error}</p>}
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/components/fields/TextInput.tsx b/packages/anastasis-webui/src/components/fields/TextInput.tsx
new file mode 100644
index 000000000..c407649d6
--- /dev/null
+++ b/packages/anastasis-webui/src/components/fields/TextInput.tsx
@@ -0,0 +1,83 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { useLayoutEffect, useRef, useState } from "preact/hooks";
+
+export interface TextInputProps {
+ inputType?: "text" | "number" | "multiline" | "password";
+ label: string;
+ grabFocus?: boolean;
+ disabled?: boolean;
+ error?: string;
+ placeholder?: string;
+ tooltip?: string;
+ onConfirm?: () => void;
+ bind: [string, (x: string) => void];
+}
+
+const TextInputType = function ({ inputType, grabFocus, ...rest }: any): VNode {
+ const inputRef = useRef<HTMLInputElement>(null);
+ useLayoutEffect(() => {
+ if (grabFocus) {
+ inputRef.current?.focus();
+ }
+ }, [grabFocus]);
+
+ return inputType === "multiline" ? (
+ <textarea {...rest} rows={5} ref={inputRef} style={{ height: "unset" }} />
+ ) : (
+ <input {...rest} type={inputType} ref={inputRef} />
+ );
+};
+
+export function TextInput(props: TextInputProps): VNode {
+ const value = props.bind[0];
+ const [dirty, setDirty] = useState(false);
+ const showError = dirty && props.error;
+ return (
+ <div class="field">
+ <label class="label">
+ {props.label}
+ {props.tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={props.tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ <div class="control has-icons-right">
+ <TextInputType
+ inputType={props.inputType}
+ value={value}
+ grabFocus={props.grabFocus}
+ disabled={props.disabled}
+ placeholder={props.placeholder}
+ class={showError ? "input is-danger" : "input"}
+ onKeyPress={(e: any) => {
+ if (e.key === "Enter" && props.onConfirm) {
+ props.onConfirm();
+ }
+ }}
+ onInput={(e: any) => {
+ setDirty(true);
+ props.bind[1]((e.target as HTMLInputElement).value);
+ }}
+ style={{ display: "block" }}
+ />
+ </div>
+ {showError && <p class="help is-danger">{props.error}</p>}
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/components/menu/LangSelector.tsx b/packages/anastasis-webui/src/components/menu/LangSelector.tsx
index 0f91abd7e..5990d758d 100644
--- a/packages/anastasis-webui/src/components/menu/LangSelector.tsx
+++ b/packages/anastasis-webui/src/components/menu/LangSelector.tsx
@@ -1,73 +1,92 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
import { h, VNode } from "preact";
import { useState } from "preact/hooks";
-import langIcon from '../../assets/icons/languageicon.svg';
-import { useTranslationContext } from "../../context/translation";
-import { strings as messages } from '../../i18n/strings'
+import langIcon from "../../assets/icons/languageicon.svg";
+import { useTranslationContext } from "../../context/translation.js";
+import { strings as messages } from "../../i18n/strings.js";
type LangsNames = {
- [P in keyof typeof messages]: string
-}
+ [P in keyof typeof messages]: string;
+};
const names: LangsNames = {
- es: 'Español [es]',
- en: 'English [en]',
- fr: 'Français [fr]',
- de: 'Deutsch [de]',
- sv: 'Svenska [sv]',
- it: 'Italiano [it]',
-}
+ es: "Español [es]",
+ en: "English [en]",
+ fr: "Français [fr]",
+ de: "Deutsch [de]",
+ sv: "Svenska [sv]",
+ it: "Italiano [it]",
+};
function getLangName(s: keyof LangsNames | string): string {
- if (names[s]) return names[s]
- return String(s)
+ if (names[s]) return names[s];
+ return String(s);
}
export function LangSelector(): VNode {
- const [updatingLang, setUpdatingLang] = useState(false)
- const { lang, changeLanguage } = useTranslationContext()
+ const [updatingLang, setUpdatingLang] = useState(false);
+ const { lang, changeLanguage } = useTranslationContext();
- return <div class="dropdown is-active ">
- <div class="dropdown-trigger">
- <button class="button has-tooltip-left"
- data-tooltip="change language selection"
- aria-haspopup="true"
- aria-controls="dropdown-menu" onClick={() => setUpdatingLang(!updatingLang)}>
- <div class="icon is-small is-left">
- <img src={langIcon} />
- </div>
- <span>{getLangName(lang)}</span>
- <div class="icon is-right">
- <i class="mdi mdi-chevron-down" />
+ return (
+ <div class="dropdown is-active ">
+ <div class="dropdown-trigger">
+ <button
+ class="button has-tooltip-left"
+ data-tooltip="change language selection"
+ aria-haspopup="true"
+ aria-controls="dropdown-menu"
+ onClick={() => setUpdatingLang(!updatingLang)}
+ >
+ <div class="icon is-small is-left">
+ <img src={langIcon} />
+ </div>
+ <span>{getLangName(lang)}</span>
+ <div class="icon is-right">
+ <i class="mdi mdi-chevron-down" />
+ </div>
+ </button>
+ </div>
+ {updatingLang && (
+ <div class="dropdown-menu" id="dropdown-menu" role="menu">
+ <div class="dropdown-content">
+ {Object.keys(messages)
+ .filter((l) => l !== lang)
+ .map((l) => (
+ <a
+ key={l}
+ class="dropdown-item"
+ value={l}
+ onClick={() => {
+ changeLanguage(l);
+ setUpdatingLang(false);
+ }}
+ >
+ {getLangName(l)}
+ </a>
+ ))}
+ </div>
</div>
- </button>
+ )}
</div>
- {updatingLang && <div class="dropdown-menu" id="dropdown-menu" role="menu">
- <div class="dropdown-content">
- {Object.keys(messages)
- .filter((l) => l !== lang)
- .map(l => <a key={l} class="dropdown-item" value={l} onClick={() => { changeLanguage(l); setUpdatingLang(false) }}>{getLangName(l)}</a>)}
- </div>
- </div>}
- </div>
-} \ No newline at end of file
+ );
+}
diff --git a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx
index e1bb4c7c0..42b7a23e2 100644
--- a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx
+++ b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx
@@ -1,27 +1,25 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { h, VNode } from 'preact';
-import logo from '../../assets/logo.jpeg';
-import { LangSelector } from './LangSelector';
+import { h, VNode } from "preact";
interface Props {
onMobileMenu: () => void;
@@ -29,30 +27,59 @@ interface Props {
}
export function NavigationBar({ onMobileMenu, title }: Props): VNode {
- return (<nav class="navbar is-fixed-top" role="navigation" aria-label="main navigation">
- <div class="navbar-brand">
- <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}>{title}</span>
-
- <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" onClick={(e) => {
- onMobileMenu()
- e.stopPropagation()
- }}>
- <span aria-hidden="true" />
- <span aria-hidden="true" />
- <span aria-hidden="true" />
- </a>
- </div>
-
- <div class="navbar-menu ">
- <a class="navbar-start is-justify-content-center is-flex-grow-1" href="https://taler.net">
- <img src={logo} style={{ height: 50, maxHeight: 50 }} />
- </a>
- <div class="navbar-end">
- <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}>
- <LangSelector />
+ return (
+ <nav
+ class="navbar is-fixed-top"
+ role="navigation"
+ aria-label="main navigation"
+ >
+ <div class="navbar-brand">
+ <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}>
+ {title}
+ </span>
+ <a
+ href="mailto:contact@anastasis.lu"
+ style={{ alignSelf: "center", padding: "0.5em" }}
+ >
+ Contact us
+ </a>
+ <a
+ href="https://bugs.anastasis.lu/"
+ style={{ alignSelf: "center", padding: "0.5em" }}
+ >
+ Report a bug
+ </a>
+ {/* <a
+ style={{
+ alignSelf: "center",
+ padding: "0.5em",
+ }}
+ >
+ Settings
+ </a> */}
+ {/* <a
+ role="button"
+ class="navbar-burger"
+ aria-label="menu"
+ aria-expanded="false"
+ onClick={(e) => {
+ onMobileMenu();
+ e.stopPropagation();
+ }}
+ >
+ <span aria-hidden="true" />
+ <span aria-hidden="true" />
+ <span aria-hidden="true" />
+ </a> */}
+ </div>
+
+ <div class="navbar-menu ">
+ <div class="navbar-end">
+ <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}>
+ {/* <LangSelector /> */}
+ </div>
</div>
</div>
- </div>
- </nav>
+ </nav>
);
-} \ No newline at end of file
+}
diff --git a/packages/anastasis-webui/src/components/menu/SideBar.tsx b/packages/anastasis-webui/src/components/menu/SideBar.tsx
index df582a5d0..3dac73e04 100644
--- a/packages/anastasis-webui/src/components/menu/SideBar.tsx
+++ b/packages/anastasis-webui/src/components/menu/SideBar.tsx
@@ -1,169 +1,326 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { Fragment, h, VNode } from 'preact';
-import { BackupStates, RecoveryStates } from '../../../../anastasis-core/lib';
-import { useAnastasisContext } from '../../context/anastasis';
-import { Translate } from '../../i18n';
-import { LangSelector } from './LangSelector';
+import { BackupStates, RecoveryStates } from "@gnu-taler/anastasis-core";
+import { Fragment, h, VNode } from "preact";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { useTranslationContext } from "../../context/translation.js";
interface Props {
mobile?: boolean;
}
+const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
+const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
+const VERSION_WITH_HASH = GIT_HASH ? `${VERSION}-${GIT_HASH}` : VERSION;
+
export function Sidebar({ mobile }: Props): VNode {
- // const config = useConfigContext();
- const config = { version: 'none' }
- const process = { env: { __VERSION__: '0.0.0' } }
- const reducer = useAnastasisContext()!
+ const reducer = useAnastasisContext()!;
+ const { i18n } = useTranslationContext();
+
+ function saveSession(): void {
+ const state = reducer.exportState();
+ const link = document.createElement("a");
+ link.download = "anastasis.json";
+ link.href = `data:text/plain,${state}`;
+ link.click();
+ }
return (
<aside class="aside is-placed-left is-expanded">
- {mobile && <div class="footer" onClick={(e) => { return e.stopImmediatePropagation() }}>
+ {/* {mobile && <div class="footer" onClick={(e) => { return e.stopImmediatePropagation() }}>
<LangSelector />
- </div>}
+ </div>} */}
<div class="aside-tools">
<div class="aside-tools-label">
- <div><b>Anastasis</b> Reducer</div>
- <div class="is-size-7 has-text-right" style={{ lineHeight: 0, marginTop: -10 }}>
- {process.env.__VERSION__} ({config.version})
+ <div>
+ <b>Anastasis</b>
+ </div>
+ <div
+ class="is-size-7 has-text-right"
+ style={{ lineHeight: 0, marginTop: -10 }}
+ >
+ Version {VERSION_WITH_HASH}
</div>
</div>
</div>
<div class="menu is-menu-main">
- {!reducer.currentReducerState &&
+ {!reducer.currentReducerState && (
<p class="menu-label">
- <Translate>Backup or Recorver</Translate>
+ <i18n.Translate>Backup or Recorver</i18n.Translate>
</p>
- }
+ )}
<ul class="menu-list">
- {!reducer.currentReducerState &&
+ {!reducer.currentReducerState && (
<li>
<div class="ml-4">
- <span class="menu-item-label"><Translate>Start one options</Translate></span>
- </div>
- </li>
- }
- {reducer.currentReducerState && reducer.currentReducerState.backup_state ? <Fragment>
- <li class={reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting ? 'is-active' : ''}>
- <div class="ml-4">
- <span class="menu-item-label"><Translate>Continent selection</Translate></span>
+ <span class="menu-item-label">
+ <i18n.Translate>Select one option</i18n.Translate>
+ </span>
</div>
</li>
- <li class={reducer.currentReducerState.backup_state === BackupStates.CountrySelecting ? 'is-active' : ''}>
- <div class="ml-4">
- <span class="menu-item-label"><Translate>Country selection</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.backup_state === BackupStates.UserAttributesCollecting ? 'is-active' : ''}>
+ )}
+ {reducer.currentReducerState?.reducer_type === "backup" ? (
+ <Fragment>
+ <li
+ class={
+ reducer.currentReducerState.backup_state ===
+ BackupStates.ContinentSelecting ||
+ reducer.currentReducerState.backup_state ===
+ BackupStates.CountrySelecting
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Location</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ <li
+ class={
+ reducer.currentReducerState.backup_state ===
+ BackupStates.UserAttributesCollecting
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Personal information</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ <li
+ class={
+ reducer.currentReducerState.backup_state ===
+ BackupStates.AuthenticationsEditing
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Authorization methods</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ <li
+ class={
+ reducer.currentReducerState.backup_state ===
+ BackupStates.PoliciesReviewing
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Policies</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ <li
+ class={
+ reducer.currentReducerState.backup_state ===
+ BackupStates.SecretEditing
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Secret input</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ {/* <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesPaying ? 'is-active' : ''}>
<div class="ml-4">
- <span class="menu-item-label"><Translate>User attributes</Translate></span>
+ <span class="menu-item-label"><i18n.Translate>Payment (optional)</i18n.Translate></span>
</div>
- </li>
- <li class={reducer.currentReducerState.backup_state === BackupStates.AuthenticationsEditing ? 'is-active' : ''}>
+ </li> */}
+ <li
+ class={
+ reducer.currentReducerState.backup_state ===
+ BackupStates.BackupFinished
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Backup completed</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ {/* <li class={reducer.currentReducerState.backup_state === BackupStates.TruthsPaying ? 'is-active' : ''}>
<div class="ml-4">
- <span class="menu-item-label"><Translate>Auth methods</Translate></span>
+ <span class="menu-item-label"><i18n.Translate>Truth Paying</i18n.Translate></span>
</div>
- </li>
- <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesReviewing ? 'is-active' : ''}>
- <div class="ml-4">
+ </li> */}
+ {reducer.currentReducerState.backup_state !==
+ BackupStates.BackupFinished && (
+ <li>
+ <div class="buttons ml-4">
+ <button
+ class="button is-primary is-right"
+ onClick={saveSession}
+ >
+ Save backup session
+ </button>
+ </div>
+ </li>
+ )}
+ {reducer.currentReducerState.backup_state !==
+ BackupStates.BackupFinished && (
+ <li>
+ <div class="buttons ml-4">
+ <button
+ class="button is-danger is-right"
+ onClick={() => reducer.reset()}
+ >
+ Reset session
+ </button>
+ </div>
+ </li>
+ )}
+ </Fragment>
+ ) : (
+ reducer.currentReducerState?.reducer_type === "recovery" && (
+ <Fragment>
+ <li
+ class={
+ reducer.currentReducerState.recovery_state ===
+ RecoveryStates.ContinentSelecting ||
+ reducer.currentReducerState.recovery_state ===
+ RecoveryStates.CountrySelecting
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Location</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ <li
+ class={
+ reducer.currentReducerState.recovery_state ===
+ RecoveryStates.UserAttributesCollecting
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Personal information</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ <li
+ class={
+ reducer.currentReducerState.recovery_state ===
+ RecoveryStates.SecretSelecting
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Secret selection</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ <li
+ class={
+ reducer.currentReducerState.recovery_state ===
+ RecoveryStates.ChallengeSelecting ||
+ reducer.currentReducerState.recovery_state ===
+ RecoveryStates.ChallengeSolving
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Solve Challenges</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ <li
+ class={
+ reducer.currentReducerState.recovery_state ===
+ RecoveryStates.RecoveryFinished
+ ? "is-active"
+ : ""
+ }
+ >
+ <div class="ml-4">
+ <span class="menu-item-label">
+ <i18n.Translate>Secret recovered</i18n.Translate>
+ </span>
+ </div>
+ </li>
+ {reducer.currentReducerState.recovery_state !==
+ RecoveryStates.RecoveryFinished && (
+ <li>
+ <div class="buttons ml-4">
+ <button
+ class="button is-primary is-right"
+ onClick={saveSession}
+ >
+ Save recovery session
+ </button>
+ </div>
+ </li>
+ )}
+ {reducer.currentReducerState.recovery_state ===
+ RecoveryStates.RecoveryFinished ? (
+ <Fragment />
+ ) : (
+ <li>
+ <div class="buttons ml-4">
+ <button
+ class="button is-danger is-right"
+ onClick={() => reducer.reset()}
+ >
+ Reset session
+ </button>
+ </div>
+ </li>
+ )}
+ </Fragment>
+ )
+ )}
- <span class="menu-item-label"><Translate>PoliciesReviewing</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.backup_state === BackupStates.SecretEditing ? 'is-active' : ''}>
- <div class="ml-4">
-
- <span class="menu-item-label"><Translate>SecretEditing</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesPaying ? 'is-active' : ''}>
- <div class="ml-4">
-
- <span class="menu-item-label"><Translate>PoliciesPaying</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.backup_state === BackupStates.BackupFinished ? 'is-active' : ''}>
- <div class="ml-4">
-
- <span class="menu-item-label"><Translate>BackupFinished</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.backup_state === BackupStates.TruthsPaying ? 'is-active' : ''}>
- <div class="ml-4">
-
- <span class="menu-item-label"><Translate>TruthsPaying</Translate></span>
- </div>
- </li>
- </Fragment> : (reducer.currentReducerState && reducer.currentReducerState?.recovery_state && <Fragment>
- <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting ? 'is-active' : ''}>
- <div class="ml-4">
- <span class="menu-item-label"><Translate>TruthsPaying</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.recovery_state === RecoveryStates.CountrySelecting ? 'is-active' : ''}>
- <div class="ml-4">
- <span class="menu-item-label"><Translate>CountrySelecting</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.recovery_state === RecoveryStates.UserAttributesCollecting ? 'is-active' : ''}>
- <div class="ml-4">
- <span class="menu-item-label"><Translate>UserAttributesCollecting</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.recovery_state === RecoveryStates.SecretSelecting ? 'is-active' : ''}>
- <div class="ml-4">
- <span class="menu-item-label"><Translate>SecretSelecting</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSelecting ? 'is-active' : ''}>
- <div class="ml-4">
- <span class="menu-item-label"><Translate>ChallengeSelecting</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSolving ? 'is-active' : ''}>
- <div class="ml-4">
- <span class="menu-item-label"><Translate>ChallengeSolving</Translate></span>
- </div>
- </li>
- <li class={reducer.currentReducerState.recovery_state === RecoveryStates.RecoveryFinished ? 'is-active' : ''}>
- <div class="ml-4">
- <span class="menu-item-label"><Translate>RecoveryFinished</Translate></span>
- </div>
- </li>
- </Fragment>)}
- {reducer.currentReducerState &&
- <li>
+ {/* <li>
<div class="buttons ml-4">
- <button class="button is-danger is-right" onClick={() => reducer.reset()}>Reset session</button>
+ <button class="button is-info is-right" >Manage providers</button>
</div>
- </li>
- }
-
+ </li> */}
</ul>
</div>
</aside>
);
}
-
diff --git a/packages/anastasis-webui/src/components/menu/index.tsx b/packages/anastasis-webui/src/components/menu/index.tsx
index febcd79c8..957ab2977 100644
--- a/packages/anastasis-webui/src/components/menu/index.tsx
+++ b/packages/anastasis-webui/src/components/menu/index.tsx
@@ -1,55 +1,67 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { ComponentChildren, Fragment, h, VNode } from "preact";
-import Match from 'preact-router/match';
+import { Match } from "preact-router/match.js";
import { useEffect, useState } from "preact/hooks";
-import { NavigationBar } from "./NavigationBar";
-import { Sidebar } from "./SideBar";
-
-
-
+import { NavigationBar } from "./NavigationBar.js";
+import { Sidebar } from "./SideBar.js";
interface MenuProps {
title: string;
}
-function WithTitle({ title, children }: { title: string; children: ComponentChildren }): VNode {
+function WithTitle({
+ title,
+ children,
+}: {
+ title: string;
+ children: ComponentChildren;
+}): VNode {
useEffect(() => {
- document.title = `Taler Backoffice: ${title}`
- }, [title])
- return <Fragment>{children}</Fragment>
+ document.title = `${title}`;
+ }, [title]);
+ return <Fragment>{children}</Fragment>;
}
export function Menu({ title }: MenuProps): VNode {
- const [mobileOpen, setMobileOpen] = useState(false)
-
- return <Match>{({ path }: { path: string }) => {
- const titleWithSubtitle = title // title ? title : (!admin ? getInstanceTitle(path, instance) : getAdminTitle(path, instance))
- return (<WithTitle title={titleWithSubtitle}>
- <div class={mobileOpen ? "has-aside-mobile-expanded" : ""} onClick={() => setMobileOpen(false)}>
- <NavigationBar onMobileMenu={() => setMobileOpen(!mobileOpen)} title={titleWithSubtitle} />
-
- <Sidebar mobile={mobileOpen} />
-
- </div>
- </WithTitle>
- )
- }}</Match>
-
+ const [mobileOpen, setMobileOpen] = useState(false);
+
+ return (
+ <Match>
+ {({ path }: { path: string }) => {
+ const titleWithSubtitle = title; // title ? title : (!admin ? getInstanceTitle(path, instance) : getAdminTitle(path, instance))
+ return (
+ <WithTitle title={titleWithSubtitle}>
+ <div
+ class={mobileOpen ? "has-aside-mobile-expanded" : ""}
+ onClick={() => setMobileOpen(false)}
+ >
+ <NavigationBar
+ onMobileMenu={() => setMobileOpen(!mobileOpen)}
+ title={titleWithSubtitle}
+ />
+
+ <Sidebar mobile={mobileOpen} />
+ </div>
+ </WithTitle>
+ );
+ }}
+ </Match>
+ );
}
interface NotYetReadyAppMenuProps {
@@ -60,37 +72,57 @@ interface NotYetReadyAppMenuProps {
interface NotifProps {
notification?: Notification;
}
-export function NotificationCard({ notification: n }: NotifProps): VNode | null {
- if (!n) return null
- return <div class="notification">
- <div class="columns is-vcentered">
- <div class="column is-12">
- <article class={n.type === 'ERROR' ? "message is-danger" : (n.type === 'WARN' ? "message is-warning" : "message is-info")}>
- <div class="message-header">
- <p>{n.message}</p>
- </div>
- {n.description &&
- <div class="message-body">
- {n.description}
- </div>}
- </article>
+export function NotificationCard({
+ notification: n,
+}: NotifProps): VNode | null {
+ if (!n) return null;
+ return (
+ <div class="notification">
+ <div class="columns is-vcentered">
+ <div class="column is-12">
+ <article
+ class={
+ n.type === "ERROR"
+ ? "message is-danger"
+ : n.type === "WARN"
+ ? "message is-warning"
+ : "message is-info"
+ }
+ >
+ <div class="message-header">
+ <p>{n.message}</p>
+ </div>
+ {n.description && <div class="message-body">{n.description}</div>}
+ </article>
+ </div>
</div>
</div>
- </div>
+ );
}
-export function NotYetReadyAppMenu({ onLogout, title }: NotYetReadyAppMenuProps): VNode {
- const [mobileOpen, setMobileOpen] = useState(false)
+export function NotYetReadyAppMenu({
+ onLogout,
+ title,
+}: NotYetReadyAppMenuProps): VNode {
+ const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
- document.title = `Taler Backoffice: ${title}`
- }, [title])
-
- return <div class={mobileOpen ? "has-aside-mobile-expanded" : ""} onClick={() => setMobileOpen(false)}>
- <NavigationBar onMobileMenu={() => setMobileOpen(!mobileOpen)} title={title} />
- {onLogout && <Sidebar mobile={mobileOpen} />}
- </div>
-
+ document.title = `Taler Backoffice: ${title}`;
+ }, [title]);
+
+ return (
+ <div
+ class="has-aside-mobile-expanded"
+ // class={mobileOpen ? "has-aside-mobile-expanded" : ""}
+ onClick={() => setMobileOpen(false)}
+ >
+ <NavigationBar
+ onMobileMenu={() => setMobileOpen(!mobileOpen)}
+ title={title}
+ />
+ {onLogout && <Sidebar mobile={mobileOpen} />}
+ </div>
+ );
}
export interface Notification {
@@ -99,6 +131,5 @@ export interface Notification {
type: MessageType;
}
-export type ValueOrFunction<T> = T | ((p: T) => T)
-export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS'
-
+export type ValueOrFunction<T> = T | ((p: T) => T);
+export type MessageType = "INFO" | "WARN" | "ERROR" | "SUCCESS";
diff --git a/packages/anastasis-webui/src/components/picker/DatePicker.tsx b/packages/anastasis-webui/src/components/picker/DatePicker.tsx
new file mode 100644
index 000000000..7b0bf488d
--- /dev/null
+++ b/packages/anastasis-webui/src/components/picker/DatePicker.tsx
@@ -0,0 +1,352 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, Component } from "preact";
+
+interface Props {
+ closeFunction?: () => void;
+ dateReceiver?: (d: Date) => void;
+ initialDate?: Date;
+ years?: Array<number>;
+ opened?: boolean;
+}
+interface State {
+ displayedMonth: number;
+ displayedYear: number;
+ selectYearMode: boolean;
+ currentDate: Date;
+}
+const now = new Date();
+
+const monthArrShortFull = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+];
+
+const monthArrShort = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+];
+
+const dayArr = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+const yearArr: number[] = [];
+
+// inspired by https://codepen.io/m4r1vs/pen/MOOxyE
+export class DatePicker extends Component<Props, State> {
+ closeDatePicker() {
+ this.props.closeFunction && this.props.closeFunction(); // Function gets passed by parent
+ }
+
+ /**
+ * Gets fired when a day gets clicked.
+ * @param {object} e The event thrown by the <span /> element clicked
+ */
+ dayClicked(e: any) {
+ const element = e.target; // the actual element clicked
+
+ if (element.innerHTML === "") return false; // don't continue if <span /> empty
+
+ // get date from clicked element (gets attached when rendered)
+ const date = new Date(element.getAttribute("data-value"));
+
+ // update the state
+ this.setState({ currentDate: date });
+ this.passDateToParent(date);
+ }
+
+ /**
+ * returns days in month as array
+ * @param {number} month the month to display
+ * @param {number} year the year to display
+ */
+ getDaysByMonth(month: number, year: number) {
+ const calendar = [];
+
+ const date = new Date(year, month, 1); // month to display
+
+ const firstDay = new Date(year, month, 1).getDay(); // first weekday of month
+ const lastDate = new Date(year, month + 1, 0).getDate(); // last date of month
+
+ let day: number | null = 0;
+
+ // the calendar is 7*6 fields big, so 42 loops
+ for (let i = 0; i < 42; i++) {
+ if (i >= firstDay && day !== null) day = day + 1;
+ if (day !== null && day > lastDate) day = null;
+
+ // append the calendar Array
+ calendar.push({
+ day: day === 0 || day === null ? null : day, // null or number
+ date: day === 0 || day === null ? null : new Date(year, month, day), // null or Date()
+ today:
+ day === now.getDate() &&
+ month === now.getMonth() &&
+ year === now.getFullYear(), // boolean
+ });
+ }
+
+ return calendar;
+ }
+
+ /**
+ * Display previous month by updating state
+ */
+ displayPrevMonth() {
+ if (this.state.displayedMonth <= 0) {
+ this.setState({
+ displayedMonth: 11,
+ displayedYear: this.state.displayedYear - 1,
+ });
+ } else {
+ this.setState({
+ displayedMonth: this.state.displayedMonth - 1,
+ });
+ }
+ }
+
+ /**
+ * Display next month by updating state
+ */
+ displayNextMonth() {
+ if (this.state.displayedMonth >= 11) {
+ this.setState({
+ displayedMonth: 0,
+ displayedYear: this.state.displayedYear + 1,
+ });
+ } else {
+ this.setState({
+ displayedMonth: this.state.displayedMonth + 1,
+ });
+ }
+ }
+
+ /**
+ * Display the selected month (gets fired when clicking on the date string)
+ */
+ displaySelectedMonth() {
+ if (this.state.selectYearMode) {
+ this.toggleYearSelector();
+ } else {
+ if (!this.state.currentDate) return false;
+ this.setState({
+ displayedMonth: this.state.currentDate.getMonth(),
+ displayedYear: this.state.currentDate.getFullYear(),
+ });
+ }
+ }
+
+ toggleYearSelector() {
+ this.setState({ selectYearMode: !this.state.selectYearMode });
+ }
+
+ changeDisplayedYear(e: any) {
+ const element = e.target;
+ this.toggleYearSelector();
+ this.setState({
+ displayedYear: parseInt(element.innerHTML, 10),
+ displayedMonth: 0,
+ });
+ }
+
+ /**
+ * Pass the selected date to parent when 'OK' is clicked
+ */
+ passSavedDateDateToParent() {
+ this.passDateToParent(this.state.currentDate);
+ }
+ passDateToParent(date: Date) {
+ if (typeof this.props.dateReceiver === "function")
+ this.props.dateReceiver(date);
+ this.closeDatePicker();
+ }
+
+ componentDidUpdate() {
+ // if (this.state.selectYearMode) {
+ // document.getElementsByClassName('selected')[0].scrollIntoView(); // works in every browser incl. IE, replace with scrollIntoViewIfNeeded when browsers support it
+ // }
+ }
+
+ constructor(props: any) {
+ super(props);
+
+ this.closeDatePicker = this.closeDatePicker.bind(this);
+ this.dayClicked = this.dayClicked.bind(this);
+ this.displayNextMonth = this.displayNextMonth.bind(this);
+ this.displayPrevMonth = this.displayPrevMonth.bind(this);
+ this.getDaysByMonth = this.getDaysByMonth.bind(this);
+ this.changeDisplayedYear = this.changeDisplayedYear.bind(this);
+ this.passDateToParent = this.passDateToParent.bind(this);
+ this.toggleYearSelector = this.toggleYearSelector.bind(this);
+ this.displaySelectedMonth = this.displaySelectedMonth.bind(this);
+
+ const initial = props.initialDate || now;
+
+ this.state = {
+ currentDate: initial,
+ displayedMonth: initial.getMonth(),
+ displayedYear: initial.getFullYear(),
+ selectYearMode: false,
+ };
+ }
+
+ render() {
+ const { currentDate, displayedMonth, displayedYear, selectYearMode } =
+ this.state;
+
+ return (
+ <div>
+ <div class={`datePicker ${this.props.opened && "datePicker--opened"}`}>
+ <div class="datePicker--titles">
+ <h3
+ style={{
+ color: selectYearMode
+ ? "rgba(255,255,255,.87)"
+ : "rgba(255,255,255,.57)",
+ }}
+ onClick={this.toggleYearSelector}
+ >
+ {currentDate.getFullYear()}
+ </h3>
+ <h2
+ style={{
+ color: !selectYearMode
+ ? "rgba(255,255,255,.87)"
+ : "rgba(255,255,255,.57)",
+ }}
+ onClick={this.displaySelectedMonth}
+ >
+ {dayArr[currentDate.getDay()]},{" "}
+ {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()}
+ </h2>
+ </div>
+
+ {!selectYearMode && (
+ <nav>
+ <span onClick={this.displayPrevMonth} class="icon">
+ <i
+ style={{ transform: "rotate(180deg)" }}
+ class="mdi mdi-forward"
+ />
+ </span>
+ <h4>
+ {monthArrShortFull[displayedMonth]} {displayedYear}
+ </h4>
+ <span onClick={this.displayNextMonth} class="icon">
+ <i class="mdi mdi-forward" />
+ </span>
+ </nav>
+ )}
+
+ <div class="datePicker--scroll">
+ {!selectYearMode && (
+ <div class="datePicker--calendar">
+ <div class="datePicker--dayNames">
+ {["S", "M", "T", "W", "T", "F", "S"].map((day, i) => (
+ <span key={i}>{day}</span>
+ ))}
+ </div>
+
+ <div onClick={this.dayClicked} class="datePicker--days">
+ {/*
+ Loop through the calendar object returned by getDaysByMonth().
+ */}
+
+ {this.getDaysByMonth(
+ this.state.displayedMonth,
+ this.state.displayedYear,
+ ).map((day) => {
+ let selected = false;
+
+ if (currentDate && day.date)
+ selected =
+ currentDate.toLocaleDateString() ===
+ day.date.toLocaleDateString();
+
+ return (
+ <span
+ key={day.day}
+ class={
+ (day.today ? "datePicker--today " : "") +
+ (selected ? "datePicker--selected" : "")
+ }
+ disabled={!day.date}
+ data-value={day.date}
+ >
+ {day.day}
+ </span>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {selectYearMode && (
+ <div class="datePicker--selectYear">
+ {(this.props.years || yearArr).map((year) => (
+ <span
+ key={year}
+ class={year === displayedYear ? "selected" : ""}
+ onClick={this.changeDisplayedYear}
+ >
+ {year}
+ </span>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div
+ class="datePicker--background"
+ onClick={this.closeDatePicker}
+ style={{
+ display: this.props.opened ? "block" : "none",
+ }}
+ />
+ </div>
+ );
+ }
+}
+
+for (let i = 2010; i <= now.getFullYear() + 10; i++) {
+ yearArr.push(i);
+}
diff --git a/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx b/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx
new file mode 100644
index 000000000..94bce4038
--- /dev/null
+++ b/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx
@@ -0,0 +1,46 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, FunctionalComponent } from "preact";
+import { useState } from "preact/hooks";
+import { DurationPicker as TestedComponent } from "./DurationPicker.js";
+import * as tests from "@gnu-taler/web-util/testing";
+
+export default {
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ goBack: { action: "goBack" },
+ },
+};
+
+export const Example = tests.createExample(TestedComponent, {
+ days: true,
+ minutes: true,
+ hours: true,
+ seconds: true,
+ value: 10000000,
+});
+
+export const WithState = () => {
+ const [v, s] = useState<number>(1000000);
+ return <TestedComponent value={v} onChange={s} days minutes hours seconds />;
+};
diff --git a/packages/anastasis-webui/src/components/picker/DurationPicker.tsx b/packages/anastasis-webui/src/components/picker/DurationPicker.tsx
new file mode 100644
index 000000000..c4caaec9f
--- /dev/null
+++ b/packages/anastasis-webui/src/components/picker/DurationPicker.tsx
@@ -0,0 +1,211 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useTranslationContext } from "../../context/translation.js";
+import "../../scss/DurationPicker.scss";
+
+export interface Props {
+ hours?: boolean;
+ minutes?: boolean;
+ seconds?: boolean;
+ days?: boolean;
+ onChange: (value: number) => void;
+ value: number;
+}
+
+// inspiration taken from https://github.com/flurmbo/react-duration-picker
+export function DurationPicker({
+ days,
+ hours,
+ minutes,
+ seconds,
+ onChange,
+ value,
+}: Props): VNode {
+ const ss = 1000;
+ const ms = ss * 60;
+ const hs = ms * 60;
+ const ds = hs * 24;
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="rdp-picker">
+ {days && (
+ <DurationColumn
+ unit={i18n.str`days`}
+ max={99}
+ value={Math.floor(value / ds)}
+ onDecrease={value >= ds ? () => onChange(value - ds) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + ds) : undefined}
+ onChange={(diff) => onChange(value + diff * ds)}
+ />
+ )}
+ {hours && (
+ <DurationColumn
+ unit={i18n.str`hours`}
+ max={23}
+ min={1}
+ value={Math.floor(value / hs) % 24}
+ onDecrease={value >= hs ? () => onChange(value - hs) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + hs) : undefined}
+ onChange={(diff) => onChange(value + diff * hs)}
+ />
+ )}
+ {minutes && (
+ <DurationColumn
+ unit={i18n.str`minutes`}
+ max={59}
+ min={1}
+ value={Math.floor(value / ms) % 60}
+ onDecrease={value >= ms ? () => onChange(value - ms) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + ms) : undefined}
+ onChange={(diff) => onChange(value + diff * ms)}
+ />
+ )}
+ {seconds && (
+ <DurationColumn
+ unit={i18n.str`seconds`}
+ max={59}
+ value={Math.floor(value / ss) % 60}
+ onDecrease={value >= ss ? () => onChange(value - ss) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + ss) : undefined}
+ onChange={(diff) => onChange(value + diff * ss)}
+ />
+ )}
+ </div>
+ );
+}
+
+interface ColProps {
+ unit: string;
+ min?: number;
+ max: number;
+ value: number;
+ onIncrease?: () => void;
+ onDecrease?: () => void;
+ onChange?: (diff: number) => void;
+}
+
+function InputNumber({
+ initial,
+ onChange,
+}: {
+ initial: number;
+ onChange: (n: number) => void;
+}) {
+ const [value, handler] = useState<{ v: string }>({
+ v: toTwoDigitString(initial),
+ });
+
+ return (
+ <input
+ value={value.v}
+ onBlur={(e) => onChange(parseInt(value.v, 10))}
+ onInput={(e) => {
+ e.preventDefault();
+ const n = Number.parseInt(e.currentTarget.value, 10);
+ if (isNaN(n)) return handler({ v: toTwoDigitString(initial) });
+ return handler({ v: toTwoDigitString(n) });
+ }}
+ style={{
+ width: 50,
+ border: "none",
+ fontSize: "inherit",
+ background: "inherit",
+ }}
+ />
+ );
+}
+
+function DurationColumn({
+ unit,
+ min = 0,
+ max,
+ value,
+ onIncrease,
+ onDecrease,
+ onChange,
+}: ColProps): VNode {
+ const cellHeight = 35;
+ return (
+ <div class="rdp-column-container">
+ <div class="rdp-masked-div">
+ <hr class="rdp-reticule" style={{ top: cellHeight * 2 - 1 }} />
+ <hr class="rdp-reticule" style={{ top: cellHeight * 3 - 1 }} />
+
+ <div class="rdp-column" style={{ top: 0 }}>
+ <div class="rdp-cell" key={value - 2}>
+ {onDecrease && (
+ <button
+ style={{ width: "100%", textAlign: "center", margin: 5 }}
+ onClick={onDecrease}
+ >
+ <span class="icon">
+ <i class="mdi mdi-chevron-up" />
+ </span>
+ </button>
+ )}
+ </div>
+ <div class="rdp-cell" key={value - 1}>
+ {value > min ? toTwoDigitString(value - 1) : ""}
+ </div>
+ <div class="rdp-cell rdp-center" key={value}>
+ {onChange ? (
+ <InputNumber
+ initial={value}
+ onChange={(n) => onChange(n - value)}
+ />
+ ) : (
+ toTwoDigitString(value)
+ )}
+ <div>{unit}</div>
+ </div>
+
+ <div class="rdp-cell" key={value + 1}>
+ {value < max ? toTwoDigitString(value + 1) : ""}
+ </div>
+
+ <div class="rdp-cell" key={value + 2}>
+ {onIncrease && (
+ <button
+ style={{ width: "100%", textAlign: "center", margin: 5 }}
+ onClick={onIncrease}
+ >
+ <span class="icon">
+ <i class="mdi mdi-chevron-down" />
+ </span>
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function toTwoDigitString(n: number) {
+ if (n < 10) {
+ return `0${n}`;
+ }
+ return `${n}`;
+}
diff --git a/packages/anastasis-webui/src/context/anastasis.ts b/packages/anastasis-webui/src/context/anastasis.ts
index e7f93ed43..cfcffdcac 100644
--- a/packages/anastasis-webui/src/context/anastasis.ts
+++ b/packages/anastasis-webui/src/context/anastasis.ts
@@ -1,33 +1,31 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createContext, h, VNode } from 'preact';
-import { useContext } from 'preact/hooks';
-import { AnastasisReducerApi } from '../hooks/use-anastasis-reducer';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-type Type = AnastasisReducerApi | undefined;
+import { createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+import { AnastasisReducerApi } from "../hooks/use-anastasis-reducer.js";
-const initial = undefined
+const initial = undefined;
-const Context = createContext<Type>(initial)
+const Context = createContext<AnastasisReducerApi | undefined>(initial);
interface Props {
value: AnastasisReducerApi;
@@ -36,6 +34,7 @@ interface Props {
export const AnastasisProvider = ({ value, children }: Props): VNode => {
return h(Context.Provider, { value, children });
-}
+};
-export const useAnastasisContext = (): Type => useContext(Context); \ No newline at end of file
+export const useAnastasisContext = (): AnastasisReducerApi | undefined =>
+ useContext(Context);
diff --git a/packages/anastasis-webui/src/context/translation.ts b/packages/anastasis-webui/src/context/translation.ts
index 5ceb5d428..44faaa456 100644
--- a/packages/anastasis-webui/src/context/translation.ts
+++ b/packages/anastasis-webui/src/context/translation.ts
@@ -1,43 +1,62 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { createContext, h, VNode } from 'preact'
-import { useContext, useEffect } from 'preact/hooks'
-import { useLang } from '../hooks'
-import * as jedLib from "jed";
-import { strings } from "../i18n/strings";
+import { i18n, setupI18n } from "@gnu-taler/taler-util";
+import { createContext, h, VNode } from "preact";
+import { useContext, useEffect } from "preact/hooks";
+import { useLang } from "../hooks/useLang.js";
+import { strings } from "../i18n/strings.js";
interface Type {
lang: string;
- handler: any;
+ supportedLang: { [id in keyof typeof supportedLang]: string };
changeLanguage: (l: string) => void;
+ i18n: typeof i18n;
+ isSaved: boolean;
}
+
+const supportedLang = {
+ es: "Español [es]",
+ ja: "日本語 [ja]",
+ en: "English [en]",
+ fr: "Français [fr]",
+ de: "Deutsch [de]",
+ sv: "Svenska [sv]",
+ it: "Italiano [it]",
+ // ko: "한국어 [ko]",
+ // ru: "Ру́сский язы́к [ru]",
+ tr: "Türk [tr]",
+ navigator: "Defined by navigator",
+};
+
const initial = {
- lang: 'en',
- handler: null,
+ lang: "en",
+ supportedLang,
changeLanguage: () => {
// do not change anything
- }
-}
-const Context = createContext<Type>(initial)
+ },
+ i18n,
+ isSaved: false,
+};
+const Context = createContext<Type>(initial);
interface Props {
initial?: string;
@@ -45,15 +64,30 @@ interface Props {
forceLang?: string;
}
-export const TranslationProvider = ({ initial, children, forceLang }: Props): VNode => {
- const [lang, changeLanguage] = useLang(initial)
+export const TranslationProvider = ({
+ initial,
+ children,
+ forceLang,
+}: Props): VNode => {
+ const [lang, changeLanguage, isSaved] = useLang(initial);
useEffect(() => {
if (forceLang) {
- changeLanguage(forceLang)
+ changeLanguage(forceLang);
}
- })
- const handler = new jedLib.Jed(strings[lang] || strings['en']);
- return h(Context.Provider, { value: { lang, handler, changeLanguage }, children });
-}
+ });
+ useEffect(() => {
+ setupI18n(lang, strings);
+ }, [lang]);
+ if (forceLang) {
+ setupI18n(forceLang, strings);
+ } else {
+ setupI18n(lang, strings);
+ }
+
+ return h(Context.Provider, {
+ value: { lang, changeLanguage, supportedLang, i18n, isSaved },
+ children,
+ });
+};
-export const useTranslationContext = (): Type => useContext(Context); \ No newline at end of file
+export const useTranslationContext = (): Type => useContext(Context);
diff --git a/packages/anastasis-webui/src/declaration.d.ts b/packages/anastasis-webui/src/declaration.d.ts
index b32fb70fc..096d46235 100644
--- a/packages/anastasis-webui/src/declaration.d.ts
+++ b/packages/anastasis-webui/src/declaration.d.ts
@@ -1,17 +1,37 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
declare module "*.css" {
- const mapping: Record<string, string>;
- export default mapping;
+ const mapping: Record<string, string>;
+ export default mapping;
}
-declare module '*.svg' {
- const content: any;
- export default content;
+declare module "*.svg" {
+ const content: any;
+ export default content;
}
-declare module '*.jpeg' {
- const content: any;
- export default content;
+declare module "*.jpeg" {
+ const content: any;
+ export default content;
}
-declare module 'jed' {
- const x: any;
- export = x;
- }
- \ No newline at end of file
+declare module "*.png" {
+ const content: any;
+ export default content;
+}
+declare module "jed" {
+ const x: any;
+ export = x;
+}
+declare var __VERSION__ : string;
+declare var __GIT_HASH__ : string;
diff --git a/packages/anastasis-webui/src/hooks/async.ts b/packages/anastasis-webui/src/hooks/async.ts
new file mode 100644
index 000000000..fc757ea81
--- /dev/null
+++ b/packages/anastasis-webui/src/hooks/async.ts
@@ -0,0 +1,97 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useCallback, useEffect, useRef, useState } from "preact/hooks";
+// import { cancelPendingRequest } from "./backend.js";
+
+export interface Options {
+ slowTolerance: number;
+}
+
+export interface AsyncOperationApi<T> {
+ request: (...a: any) => void;
+ cancel: () => void;
+ data: T | undefined;
+ isSlow: boolean;
+ isLoading: boolean;
+ error: string | undefined;
+}
+
+export function useIsMounted() {
+ const isMountedRef = useRef(true);
+ const isMounted = useCallback(() => isMountedRef.current, []);
+
+ useEffect(() => {
+ return () => void (isMountedRef.current = false);
+ }, []);
+
+ return isMounted;
+}
+
+export function useAsync<T>(
+ fn?: (...args: any) => Promise<T>,
+ { slowTolerance: tooLong }: Options = { slowTolerance: 1000 },
+): AsyncOperationApi<T> {
+ const [data, setData] = useState<T | undefined>(undefined);
+ const [isLoading, setLoading] = useState<boolean>(false);
+ const [error, setError] = useState<any>(undefined);
+ const [isSlow, setSlow] = useState(false);
+ const isMounted = useIsMounted();
+
+ const request = async (...args: any) => {
+ if (!fn) return;
+ setLoading(true);
+ const handler = setTimeout(() => {
+ if (!isMounted()) {
+ return;
+ }
+ setSlow(true);
+ }, tooLong);
+
+ try {
+ const result = await fn(...args);
+ if (!isMounted()) {
+ // Possibly calling fn(...) resulted in the component being unmounted.
+ return;
+ }
+ setData(result);
+ } catch (error) {
+ setError(error);
+ }
+ setLoading(false);
+ setSlow(false);
+ clearTimeout(handler);
+ };
+
+ function cancel() {
+ // cancelPendingRequest()
+ setLoading(false);
+ setSlow(false);
+ }
+
+ return {
+ request,
+ cancel,
+ data,
+ isSlow,
+ isLoading,
+ error,
+ };
+}
diff --git a/packages/anastasis-webui/src/hooks/index.ts b/packages/anastasis-webui/src/hooks/index.ts
index 15df4f154..2dbf4fa5c 100644
--- a/packages/anastasis-webui/src/hooks/index.ts
+++ b/packages/anastasis-webui/src/hooks/index.ts
@@ -1,110 +1,74 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { StateUpdater, useState } from "preact/hooks";
-export type ValueOrFunction<T> = T | ((p: T) => T)
+import { StateUpdater } from "preact/hooks";
+import { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js";
+export type ValueOrFunction<T> = T | ((p: T) => T);
const calculateRootPath = () => {
- const rootPath = typeof window !== undefined ? window.location.origin + window.location.pathname : '/'
- return rootPath
-}
-
-export function useBackendURL(url?: string): [string, boolean, StateUpdater<string>, () => void] {
- const [value, setter] = useNotNullLocalStorage('backend-url', url || calculateRootPath())
- const [triedToLog, setTriedToLog] = useLocalStorage('tried-login')
+ const rootPath =
+ typeof window !== undefined
+ ? window.location.origin + window.location.pathname
+ : "/";
+ return rootPath;
+};
+
+export function useBackendURL(
+ url?: string,
+): [string, boolean, StateUpdater<string>, () => void] {
+ const [value, setter] = useNotNullLocalStorage(
+ "backend-url",
+ url || calculateRootPath(),
+ );
+ const [triedToLog, setTriedToLog] = useLocalStorage("tried-login");
const checkedSetter = (v: ValueOrFunction<string>) => {
- setTriedToLog('yes')
- return setter(p => (v instanceof Function ? v(p) : v).replace(/\/$/, ''))
- }
+ setTriedToLog("yes");
+ return setter((p) => (v instanceof Function ? v(p) : v).replace(/\/$/, ""));
+ };
const resetBackend = () => {
- setTriedToLog(undefined)
- }
- return [value, !!triedToLog, checkedSetter, resetBackend]
+ setTriedToLog(undefined);
+ };
+ return [value, !!triedToLog, checkedSetter, resetBackend];
}
-export function useBackendDefaultToken(): [string | undefined, StateUpdater<string | undefined>] {
- return useLocalStorage('backend-token')
+export function useBackendDefaultToken(): [
+ string | undefined,
+ StateUpdater<string | undefined>,
+] {
+ return useLocalStorage("backend-token");
}
-export function useBackendInstanceToken(id: string): [string | undefined, StateUpdater<string | undefined>] {
- const [token, setToken] = useLocalStorage(`backend-token-${id}`)
- const [defaultToken, defaultSetToken] = useBackendDefaultToken()
+export function useBackendInstanceToken(
+ id: string,
+): [string | undefined, StateUpdater<string | undefined>] {
+ const [token, setToken] = useLocalStorage(`backend-token-${id}`);
+ const [defaultToken, defaultSetToken] = useBackendDefaultToken();
// instance named 'default' use the default token
- if (id === 'default') {
- return [defaultToken, defaultSetToken]
+ if (id === "default") {
+ return [defaultToken, defaultSetToken];
}
- return [token, setToken]
-}
-
-export function useLang(initial?: string): [string, StateUpdater<string>] {
- const browserLang = typeof window !== "undefined" ? navigator.language || (navigator as any).userLanguage : undefined;
- const defaultLang = (browserLang || initial || 'en').substring(0, 2)
- return useNotNullLocalStorage('lang-preference', defaultLang)
-}
-
-export function useLocalStorage(key: string, initialValue?: string): [string | undefined, StateUpdater<string | undefined>] {
- const [storedValue, setStoredValue] = useState<string | undefined>((): string | undefined => {
- return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue;
- });
-
- const setValue = (value?: string | ((val?: string) => string | undefined)) => {
- setStoredValue(p => {
- const toStore = value instanceof Function ? value(p) : value
- if (typeof window !== "undefined") {
- if (!toStore) {
- window.localStorage.removeItem(key)
- } else {
- window.localStorage.setItem(key, toStore);
- }
- }
- return toStore
- })
- };
-
- return [storedValue, setValue];
-}
-
-export function useNotNullLocalStorage(key: string, initialValue: string): [string, StateUpdater<string>] {
- const [storedValue, setStoredValue] = useState<string>((): string => {
- return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue;
- });
-
- const setValue = (value: string | ((val: string) => string)) => {
- const valueToStore = value instanceof Function ? value(storedValue) : value;
- setStoredValue(valueToStore);
- if (typeof window !== "undefined") {
- if (!valueToStore) {
- window.localStorage.removeItem(key)
- } else {
- window.localStorage.setItem(key, valueToStore);
- }
- }
- };
-
- return [storedValue, setValue];
-}
-
-
+ return [token, setToken];
+} \ No newline at end of file
diff --git a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts
index 72594749d..fc8c4cf6c 100644
--- a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts
+++ b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts
@@ -1,5 +1,36 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
import { TalerErrorCode } from "@gnu-taler/taler-util";
-import { BackupStates, getBackupStartState, getRecoveryStartState, RecoveryStates, reduceAction, ReducerState } from "anastasis-core";
+import {
+ AggregatedPolicyMetaInfo,
+ BackupStates,
+ completeProviderStatus,
+ discoverPolicies,
+ DiscoveryCursor,
+ getBackupStartState,
+ getRecoveryStartState,
+ mergeDiscoveryAggregate,
+ RecoveryStates,
+ reduceAction,
+ ReducerState,
+} from "@gnu-taler/anastasis-core";
import { useState } from "preact/hooks";
const reducerBaseUrl = "http://localhost:5000/";
@@ -8,6 +39,7 @@ const remoteReducer = false;
interface AnastasisState {
reducerState: ReducerState | undefined;
currentError: any;
+ discoveryState: DiscoveryUiState;
}
async function getBackupStartStateRemote(): Promise<ReducerState> {
@@ -91,20 +123,39 @@ export interface ReducerTransactionHandle {
transition(action: string, args: any): Promise<ReducerState>;
}
+/**
+ * UI-relevant state of the policy discovery process.
+ */
+export interface DiscoveryUiState {
+ state: "none" | "active" | "finished";
+
+ aggregatedPolicies?: AggregatedPolicyMetaInfo[];
+
+ cursor?: DiscoveryCursor;
+}
+
export interface AnastasisReducerApi {
currentReducerState: ReducerState | undefined;
+ // FIXME: Explain better!
currentError: any;
+ discoveryState: DiscoveryUiState;
dismissError: () => void;
startBackup: () => void;
startRecover: () => void;
reset: () => void;
- back: () => void;
- transition(action: string, args: any): void;
+ back: () => Promise<void>;
+ transition(action: string, args: any): Promise<void>;
+ exportState: () => string;
+ importState: (s: string) => void;
+ discoverStart(): Promise<void>;
+ discoverMore(): Promise<void>;
/**
* Run multiple reducer steps in a transaction without
* affecting the UI-visible transition state in-between.
*/
- runTransaction(f: (h: ReducerTransactionHandle) => Promise<void>): void;
+ runTransaction(
+ f: (h: ReducerTransactionHandle) => Promise<void>,
+ ): Promise<void>;
}
function storageGet(key: string): string | null {
@@ -120,18 +171,17 @@ function storageSet(key: string, value: any): void {
}
}
-function restoreState(): any {
+function getStateFromStorage(): any {
let state: any;
try {
const s = storageGet("anastasisReducerState");
if (s === "undefined") {
state = undefined;
} else if (s) {
- console.log("restoring state from", s);
state = JSON.parse(s);
}
} catch (e) {
- console.log(e);
+ console.log("ERROR: getStateFromStorage ", e);
}
return state ?? undefined;
}
@@ -139,8 +189,11 @@ function restoreState(): any {
export function useAnastasisReducer(): AnastasisReducerApi {
const [anastasisState, setAnastasisStateInternal] = useState<AnastasisState>(
() => ({
- reducerState: restoreState(),
+ reducerState: getStateFromStorage(),
currentError: undefined,
+ discoveryState: {
+ state: "none",
+ },
}),
);
@@ -151,25 +204,58 @@ export function useAnastasisReducer(): AnastasisReducerApi {
JSON.stringify(newState.reducerState),
);
} catch (e) {
- console.log(e);
+ console.log("ERROR setAnastasisState", e);
}
setAnastasisStateInternal(newState);
+
+ const tryUpdateProviders = () => {
+ const reducerState = newState.reducerState;
+ if (
+ reducerState?.reducer_type !== "backup" &&
+ reducerState?.reducer_type !== "recovery"
+ ) {
+ return;
+ }
+ const provMap = reducerState.authentication_providers;
+ if (!provMap) {
+ return;
+ }
+ const doUpdate = async () => {
+ const updates = await completeProviderStatus(provMap);
+ if (Object.keys(updates).length === 0) {
+ return;
+ }
+ const rs2 = reducerState;
+ if (rs2.reducer_type !== "backup" && rs2.reducer_type !== "recovery") {
+ return;
+ }
+ setAnastasisState({
+ ...anastasisState,
+ reducerState: {
+ ...rs2,
+ authentication_providers: {
+ ...rs2.authentication_providers,
+ ...updates,
+ },
+ },
+ });
+ };
+ doUpdate().catch((e) => console.log("ERROR doUpdate", e));
+ };
+
+ tryUpdateProviders();
};
- async function doTransition(action: string, args: any) {
- console.log("reducing with", action, args);
+ async function doTransition(action: string, args: any): Promise<void> {
let s: ReducerState;
if (remoteReducer) {
s = await reduceStateRemote(anastasisState.reducerState, action, args);
} else {
s = await reduceAction(anastasisState.reducerState!, action, args);
}
- console.log("got response from reducer", s);
- if (s.code) {
- console.log("response is an error");
+ if (s.reducer_type === "error") {
setAnastasisState({ ...anastasisState, currentError: s });
} else {
- console.log("response is a new state");
setAnastasisState({
...anastasisState,
currentError: undefined,
@@ -181,6 +267,7 @@ export function useAnastasisReducer(): AnastasisReducerApi {
return {
currentReducerState: anastasisState.reducerState,
currentError: anastasisState.currentError,
+ discoveryState: anastasisState.discoveryState,
async startBackup() {
let s: ReducerState;
if (remoteReducer) {
@@ -188,7 +275,7 @@ export function useAnastasisReducer(): AnastasisReducerApi {
} else {
s = await getBackupStartState();
}
- if (s.code !== undefined) {
+ if (s.reducer_type === "error") {
setAnastasisState({
...anastasisState,
currentError: s,
@@ -201,6 +288,39 @@ export function useAnastasisReducer(): AnastasisReducerApi {
});
}
},
+ exportState() {
+ const state = getStateFromStorage();
+ return JSON.stringify(state);
+ },
+ importState(s: string) {
+ try {
+ const state = JSON.parse(s);
+ setAnastasisState({
+ reducerState: state,
+ currentError: undefined,
+ discoveryState: {
+ state: "none",
+ },
+ });
+ } catch (e) {
+ throw Error("could not restore the state");
+ }
+ },
+ async discoverStart(): Promise<void> {
+ const res = await discoverPolicies(this.currentReducerState!, undefined);
+ const aggregatedPolicies = mergeDiscoveryAggregate(res.policies, []);
+ setAnastasisState({
+ ...anastasisState,
+ discoveryState: {
+ state: "finished",
+ aggregatedPolicies,
+ cursor: res.cursor,
+ },
+ });
+ },
+ async discoverMore(): Promise<void> {
+ return;
+ },
async startRecover() {
let s: ReducerState;
if (remoteReducer) {
@@ -208,7 +328,7 @@ export function useAnastasisReducer(): AnastasisReducerApi {
} else {
s = await getRecoveryStartState();
}
- if (s.code !== undefined) {
+ if (s.reducer_type === "error") {
setAnastasisState({
...anastasisState,
currentError: s,
@@ -222,16 +342,18 @@ export function useAnastasisReducer(): AnastasisReducerApi {
}
},
transition(action: string, args: any) {
- doTransition(action, args);
+ return doTransition(action, args);
},
- back() {
+ async back() {
const reducerState = anastasisState.reducerState;
if (!reducerState) {
return;
}
if (
- reducerState.backup_state === BackupStates.ContinentSelecting ||
- reducerState.recovery_state === RecoveryStates.ContinentSelecting
+ (reducerState.reducer_type === "backup" &&
+ reducerState.backup_state === BackupStates.ContinentSelecting) ||
+ (reducerState.reducer_type === "recovery" &&
+ reducerState.recovery_state === RecoveryStates.ContinentSelecting)
) {
setAnastasisState({
...anastasisState,
@@ -239,7 +361,7 @@ export function useAnastasisReducer(): AnastasisReducerApi {
reducerState: undefined,
});
} else {
- doTransition("back", {});
+ await doTransition("back", {});
}
},
dismissError() {
@@ -252,36 +374,32 @@ export function useAnastasisReducer(): AnastasisReducerApi {
reducerState: undefined,
});
},
- runTransaction(f) {
- async function run() {
- const txHandle = new ReducerTxImpl(anastasisState.reducerState!);
- try {
- await f(txHandle);
- } catch (e) {
- console.log("exception during reducer transaction", e);
- }
- const s = txHandle.transactionState;
- console.log("transaction finished, new state", s);
- if (s.code !== undefined) {
- setAnastasisState({
- ...anastasisState,
- currentError: txHandle.transactionState,
- });
- } else {
- setAnastasisState({
- ...anastasisState,
- reducerState: txHandle.transactionState,
- currentError: undefined,
- });
- }
+ async runTransaction(f) {
+ const txHandle = new ReducerTxImpl(anastasisState.reducerState!);
+ try {
+ await f(txHandle);
+ } catch (e) {
+ console.log("exception during reducer transaction", e);
+ }
+ const s = txHandle.transactionState;
+ if (s.reducer_type === "error") {
+ setAnastasisState({
+ ...anastasisState,
+ currentError: txHandle.transactionState,
+ });
+ } else {
+ setAnastasisState({
+ ...anastasisState,
+ reducerState: txHandle.transactionState,
+ currentError: undefined,
+ });
}
- run();
},
};
}
class ReducerTxImpl implements ReducerTransactionHandle {
- constructor(public transactionState: ReducerState) {}
+ constructor(public transactionState: ReducerState) { }
async transition(action: string, args: any): Promise<ReducerState> {
let s: ReducerState;
if (remoteReducer) {
@@ -289,10 +407,9 @@ class ReducerTxImpl implements ReducerTransactionHandle {
} else {
s = await reduceAction(this.transactionState, action, args);
}
- console.log("making transition in transaction", action);
this.transactionState = s;
// Abort transaction as soon as we transition into an error state.
- if (this.transactionState.code !== undefined) {
+ if (this.transactionState.reducer_type === "error") {
throw Error("transition resulted in error");
}
return this.transactionState;
diff --git a/packages/anastasis-webui/src/hooks/useLang.ts b/packages/anastasis-webui/src/hooks/useLang.ts
new file mode 100644
index 000000000..5b02c5255
--- /dev/null
+++ b/packages/anastasis-webui/src/hooks/useLang.ts
@@ -0,0 +1,30 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useNotNullLocalStorage } from "./useLocalStorage.js";
+
+function getBrowserLang(): string | undefined {
+ if (window.navigator.languages) return window.navigator.languages[0];
+ if (window.navigator.language) return window.navigator.language;
+ return undefined;
+}
+
+export function useLang(
+ initial?: string,
+): [string, (s: string) => void, boolean] {
+ const defaultLang = (getBrowserLang() || initial || "en").substring(0, 2);
+ return useNotNullLocalStorage("lang-preference", defaultLang);
+}
diff --git a/packages/anastasis-webui/src/hooks/useLocalStorage.ts b/packages/anastasis-webui/src/hooks/useLocalStorage.ts
new file mode 100644
index 000000000..ed5b491f2
--- /dev/null
+++ b/packages/anastasis-webui/src/hooks/useLocalStorage.ts
@@ -0,0 +1,80 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { StateUpdater, useState } from "preact/hooks";
+
+export function useLocalStorage(
+ key: string,
+ initialValue?: string,
+): [string | undefined, StateUpdater<string | undefined>] {
+ const [storedValue, setStoredValue] = useState<string | undefined>(
+ (): string | undefined => {
+ return typeof window !== "undefined"
+ ? window.localStorage.getItem(key) || initialValue
+ : initialValue;
+ },
+ );
+
+ const setValue = (
+ value?: string | ((val?: string) => string | undefined),
+ ): void => {
+ setStoredValue((p) => {
+ const toStore = value instanceof Function ? value(p) : value;
+ if (typeof window !== "undefined") {
+ if (!toStore) {
+ window.localStorage.removeItem(key);
+ } else {
+ window.localStorage.setItem(key, toStore);
+ }
+ }
+ return toStore;
+ });
+ };
+
+ return [storedValue, setValue];
+}
+
+//TODO: merge with the above function
+export function useNotNullLocalStorage(
+ key: string,
+ initialValue: string,
+): [string, StateUpdater<string>, boolean] {
+ const [storedValue, setStoredValue] = useState<string>((): string => {
+ return typeof window !== "undefined"
+ ? window.localStorage.getItem(key) || initialValue
+ : initialValue;
+ });
+
+ const setValue = (value: string | ((val: string) => string)): void => {
+ const valueToStore = value instanceof Function ? value(storedValue) : value;
+ setStoredValue(valueToStore);
+ if (typeof window !== "undefined") {
+ if (!valueToStore) {
+ window.localStorage.removeItem(key);
+ } else {
+ window.localStorage.setItem(key, valueToStore);
+ }
+ }
+ };
+
+ const isSaved = window.localStorage.getItem(key) !== null;
+ return [storedValue, setValue, isSaved];
+}
diff --git a/packages/anastasis-webui/src/i18n/index.tsx b/packages/anastasis-webui/src/i18n/index.tsx
deleted file mode 100644
index 63c8e1934..000000000
--- a/packages/anastasis-webui/src/i18n/index.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Translation helpers for React components and template literals.
- */
-
-/**
- * Imports
- */
-import { ComponentChild, ComponentChildren, h, Fragment, VNode } from "preact";
-
-import { useTranslationContext } from "../context/translation";
-
-export function useTranslator() {
- const ctx = useTranslationContext();
- const jed = ctx.handler
- return function str(stringSeq: TemplateStringsArray, ...values: any[]): string {
- const s = toI18nString(stringSeq);
- if (!s) return s
- const tr = jed
- .translate(s)
- .ifPlural(1, s)
- .fetch(...values);
- return tr;
- }
-}
-
-
-/**
- * Convert template strings to a msgid
- */
- function toI18nString(stringSeq: ReadonlyArray<string>): string {
- let s = "";
- for (let i = 0; i < stringSeq.length; i++) {
- s += stringSeq[i];
- if (i < stringSeq.length - 1) {
- s += `%${i + 1}$s`;
- }
- }
- return s;
-}
-
-
-interface TranslateSwitchProps {
- target: number;
- children: ComponentChildren;
-}
-
-function stringifyChildren(children: ComponentChildren): string {
- let n = 1;
- const ss = (children instanceof Array ? children : [children]).map((c) => {
- if (typeof c === "string") {
- return c;
- }
- return `%${n++}$s`;
- });
- const s = ss.join("").replace(/ +/g, " ").trim();
- return s;
-}
-
-interface TranslateProps {
- children: ComponentChildren;
- /**
- * Component that the translated element should be wrapped in.
- * Defaults to "div".
- */
- wrap?: any;
-
- /**
- * Props to give to the wrapped component.
- */
- wrapProps?: any;
-}
-
-function getTranslatedChildren(
- translation: string,
- children: ComponentChildren,
-): ComponentChild[] {
- const tr = translation.split(/%(\d+)\$s/);
- const childArray = children instanceof Array ? children : [children];
- // Merge consecutive string children.
- const placeholderChildren = Array<ComponentChild>();
- for (let i = 0; i < childArray.length; i++) {
- const x = childArray[i];
- if (x === undefined) {
- continue;
- } else if (typeof x === "string") {
- continue;
- } else {
- placeholderChildren.push(x);
- }
- }
- const result = Array<ComponentChild>();
- for (let i = 0; i < tr.length; i++) {
- if (i % 2 == 0) {
- // Text
- result.push(tr[i]);
- } else {
- const childIdx = Number.parseInt(tr[i],10) - 1;
- result.push(placeholderChildren[childIdx]);
- }
- }
- return result;
-}
-
-/**
- * Translate text node children of this component.
- * If a child component might produce a text node, it must be wrapped
- * in a another non-text element.
- *
- * Example:
- * ```
- * <Translate>
- * Hello. Your score is <span><PlayerScore player={player} /></span>
- * </Translate>
- * ```
- */
-export function Translate({ children }: TranslateProps): VNode {
- const s = stringifyChildren(children);
- const ctx = useTranslationContext()
- const translation: string = ctx.handler.ngettext(s, s, 1);
- const result = getTranslatedChildren(translation, children)
- return <Fragment>{result}</Fragment>;
-}
-
-/**
- * Switch translation based on singular or plural based on the target prop.
- * Should only contain TranslateSingular and TransplatePlural as children.
- *
- * Example:
- * ```
- * <TranslateSwitch target={n}>
- * <TranslateSingular>I have {n} apple.</TranslateSingular>
- * <TranslatePlural>I have {n} apples.</TranslatePlural>
- * </TranslateSwitch>
- * ```
- */
-export function TranslateSwitch({ children, target }: TranslateSwitchProps) {
- let singular: VNode<TranslationPluralProps> | undefined;
- let plural: VNode<TranslationPluralProps> | undefined;
- // const children = this.props.children;
- if (children) {
- (children instanceof Array ? children : [children]).forEach((child: any) => {
- if (child.type === TranslatePlural) {
- plural = child;
- }
- if (child.type === TranslateSingular) {
- singular = child;
- }
- });
- }
- if (!singular || !plural) {
- console.error("translation not found");
- return h("span", {}, ["translation not found"]);
- }
- singular.props.target = target;
- plural.props.target = target;
- // We're looking up the translation based on the
- // singular, even if we must use the plural form.
- return singular;
-}
-
-interface TranslationPluralProps {
- children: ComponentChildren;
- target: number;
-}
-
-/**
- * See [[TranslateSwitch]].
- */
-export function TranslatePlural({ children, target }: TranslationPluralProps): VNode {
- const s = stringifyChildren(children);
- const ctx = useTranslationContext()
- const translation = ctx.handler.ngettext(s, s, 1);
- const result = getTranslatedChildren(translation, children);
- return <Fragment>{result}</Fragment>;
-}
-
-/**
- * See [[TranslateSwitch]].
- */
-export function TranslateSingular({ children, target }: TranslationPluralProps): VNode {
- const s = stringifyChildren(children);
- const ctx = useTranslationContext()
- const translation = ctx.handler.ngettext(s, s, target);
- const result = getTranslatedChildren(translation, children);
- return <Fragment>{result}</Fragment>;
-
-}
diff --git a/packages/anastasis-webui/src/i18n/poheader b/packages/anastasis-webui/src/i18n/poheader
index ee3fcd7be..ab4747bc2 100644
--- a/packages/anastasis-webui/src/i18n/poheader
+++ b/packages/anastasis-webui/src/i18n/poheader
@@ -1,16 +1,16 @@
-# This file is part of GNU Taler
-# (C) 2021 Taler Systems S.A.
+# This file is part of GNU Anastasis
+# (C) 2021-2022 Anastasis SARL
-# GNU Taler is free software; you can redistribute it and/or modify it under the
-# terms of the GNU General Public License as published by the Free Software
+# GNU Anastasis is free software; you can redistribute it and/or modify it under the
+# terms of the GNU Affero General Public License as published by the Free Software
# Foundation; either version 3, or (at your option) any later version.
-# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
-# You should have received a copy of the GNU General Public License along with
-# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+# You should have received a copy of the GNU Affero General Public License along with
+# GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
#
#, fuzzy
diff --git a/packages/anastasis-webui/src/i18n/strings-prelude b/packages/anastasis-webui/src/i18n/strings-prelude
index cca13afad..ecd15c695 100644
--- a/packages/anastasis-webui/src/i18n/strings-prelude
+++ b/packages/anastasis-webui/src/i18n/strings-prelude
@@ -1,17 +1,17 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/*eslint quote-props: ["error", "consistent"]*/
diff --git a/packages/anastasis-webui/src/i18n/strings.ts b/packages/anastasis-webui/src/i18n/strings.ts
index b4f376ce0..62ef64194 100644
--- a/packages/anastasis-webui/src/i18n/strings.ts
+++ b/packages/anastasis-webui/src/i18n/strings.ts
@@ -1,44 +1,44 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/*eslint quote-props: ["error", "consistent"]*/
-export const strings: {[s: string]: any} = {};
+export const strings: { [s: string]: any } = {};
-strings['de'] = {
- "domain": "messages",
- "locale_data": {
- "messages": {
+strings["de"] = {
+ domain: "messages",
+ locale_data: {
+ messages: {
"": {
- "domain": "messages",
- "plural_forms": "nplurals=2; plural=(n != 1);",
- "lang": ""
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=(n != 1);",
+ lang: "",
},
- }
- }
+ },
+ },
};
-strings['en'] = {
- "domain": "messages",
- "locale_data": {
- "messages": {
+strings["en"] = {
+ domain: "messages",
+ locale_data: {
+ messages: {
"": {
- "domain": "messages",
- "plural_forms": "nplurals=2; plural=(n != 1);",
- "lang": ""
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=(n != 1);",
+ lang: "",
},
- }
- }
+ },
+ },
};
diff --git a/packages/anastasis-webui/src/i18n/taler-anastasis.pot b/packages/anastasis-webui/src/i18n/taler-anastasis.pot
index b8c3be809..b17ca4a26 100644
--- a/packages/anastasis-webui/src/i18n/taler-anastasis.pot
+++ b/packages/anastasis-webui/src/i18n/taler-anastasis.pot
@@ -1,13 +1,13 @@
-# This file is part of GNU Taler
-# (C) 2021 Taler Systems S.A.
-# GNU Taler is free software; you can redistribute it and/or modify it under the
-# terms of the GNU General Public License as published by the Free Software
+# This file is part of GNU Anastasis
+# (C) 2021-2022 Anastasis SARL
+# GNU Anastasis is free software; you can redistribute it and/or modify it under the
+# terms of the GNU Affero General Public License as published by the Free Software
# Foundation; either version 3, or (at your option) any later version.
-# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-# You should have received a copy of the GNU General Public License along with
-# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+# You should have received a copy of the GNU Affero General Public License along with
+# GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
#
#, fuzzy
msgid ""
diff --git a/packages/anastasis-webui/src/index.html b/packages/anastasis-webui/src/index.html
new file mode 100644
index 000000000..d64b627e4
--- /dev/null
+++ b/packages/anastasis-webui/src/index.html
@@ -0,0 +1,42 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html
+ lang="en"
+ class="has-aside-left has-aside-mobile-transition has-navbar-fixed-top has-aside-expanded"
+>
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link
+ rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
+ />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <title>Anastasis</title>
+ <!-- Entry point for the SPA. -->
+ <script type="module" src="index.js"></script>
+ <link rel="stylesheet" href="index.css" />
+ </head>
+ <body>
+ <div id="container"></div>
+ </body>
+</html>
diff --git a/packages/anastasis-webui/src/index.test.ts b/packages/anastasis-webui/src/index.test.ts
new file mode 100644
index 000000000..2f865bc1d
--- /dev/null
+++ b/packages/anastasis-webui/src/index.test.ts
@@ -0,0 +1,82 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { setupI18n } from "@gnu-taler/taler-util";
+import { parseGroupImport } from "@gnu-taler/web-util/browser";
+import * as tests from "@gnu-taler/web-util/testing";
+import * as pages from "./pages/home/index.stories.js";
+import { ComponentChildren, VNode, h as create } from "preact";
+import { AnastasisProvider } from "./context/anastasis.js";
+import { AnastasisReducerApi } from "./hooks/use-anastasis-reducer.js";
+import { ReducerState } from "@gnu-taler/anastasis-core";
+
+setupI18n("en", { en: {} });
+
+describe("All the examples:", () => {
+ const cms = parseGroupImport({ pages });
+ cms.forEach((group) => {
+ describe(`Example for group "${group.title}":`, () => {
+ group.list.forEach((component) => {
+ describe(`Component ${component.name}:`, () => {
+ component.examples.forEach((example) => {
+ it(`should render example: ${example.name}`, () => {
+ tests.renderUI(example.render, DefaultTestingContext);
+ });
+ });
+ });
+ });
+ });
+ });
+});
+
+const noop = async (): Promise<void> => {
+ return;
+};
+
+function DefaultTestingContext({
+ children,
+ ...rest
+}: {
+ children: ComponentChildren;
+}): VNode {
+ //some UI example can specify the state of the reducer
+ const currentReducerState = rest as ReducerState;
+ const value: AnastasisReducerApi = {
+ currentReducerState,
+ discoverMore: noop,
+ discoverStart: noop,
+ discoveryState: {
+ state: "finished",
+ },
+ currentError: undefined,
+ back: noop,
+ dismissError: noop,
+ reset: noop,
+ runTransaction: noop,
+ startBackup: noop,
+ startRecover: noop,
+ transition: noop,
+ exportState: () => {
+ return "{}";
+ },
+ importState: noop,
+ };
+ return create(AnastasisProvider, { value, children });
+}
diff --git a/packages/anastasis-webui/src/index.ts b/packages/anastasis-webui/src/index.ts
index e78b9c194..d7b2164ab 100644
--- a/packages/anastasis-webui/src/index.ts
+++ b/packages/anastasis-webui/src/index.ts
@@ -1,4 +1,42 @@
-import App from './components/app';
-import './scss/main.scss';
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
-export default App;
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { setupI18n } from "@gnu-taler/taler-util";
+import { h, render } from "preact";
+import App from "./components/app.js";
+import "./scss/main.scss";
+
+function main(): void {
+ try {
+ const container = document.getElementById("container");
+ if (!container) {
+ throw Error("container not found, can't mount page contents");
+ }
+ render(h(App, {}), container);
+ } catch (e) {
+ console.error("got error", e);
+ if (e instanceof Error) {
+ document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`;
+ }
+ }
+}
+
+// setupI18n("en", strings);
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", main);
+} else {
+ main();
+}
diff --git a/packages/anastasis-webui/src/manifest.json b/packages/anastasis-webui/src/manifest.json
index 6b44a2b31..2f429eecd 100644
--- a/packages/anastasis-webui/src/manifest.json
+++ b/packages/anastasis-webui/src/manifest.json
@@ -1,6 +1,6 @@
{
- "name": "anastasis-webui",
- "short_name": "anastasis-webui",
+ "name": "GNU Anastasis (git)",
+ "short_name": "Password-less key recovery",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
@@ -18,4 +18,4 @@
"sizes": "512x512"
}
]
-} \ No newline at end of file
+}
diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts
new file mode 100644
index 000000000..0ab275f54
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/index.ts
@@ -0,0 +1,104 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { AuthenticationProviderStatus } from "@gnu-taler/anastasis-core";
+import InvalidState from "../../../components/InvalidState.js";
+import NoReducer from "../../../components/NoReducer.js";
+import { Notification } from "../../../components/Notifications.js";
+import { compose, StateViewMap } from "../../../utils/index.js";
+import useComponentState from "./state.js";
+import { WithoutProviderType, WithProviderType } from "./views.js";
+
+export type AuthProvByStatusMap = Record<
+ AuthenticationProviderStatus["status"],
+ (AuthenticationProviderStatus & { url: string })[]
+>
+
+export type State = NoReducer | InvalidState | WithType | WithoutType;
+
+export interface NoReducer {
+ status: "no-reducer";
+}
+export interface InvalidState {
+ status: "invalid-state";
+}
+
+interface CommonProps {
+ addProvider?: () => Promise<void>;
+ deleteProvider: (url: string) => Promise<void>;
+ authProvidersByStatus: AuthProvByStatusMap;
+ error: string | undefined;
+ onCancel: () => Promise<void>;
+ testing: boolean;
+ setProviderURL: (url: string) => Promise<void>;
+ providerURL: string;
+ errors: string | undefined;
+ notifications: Notification[];
+}
+
+export interface WithType extends CommonProps {
+ status: "with-type";
+ providerLabel: string;
+}
+export interface WithoutType extends CommonProps {
+ status: "without-type";
+}
+
+const map: StateViewMap<State> = {
+ "no-reducer": NoReducer,
+ "invalid-state": InvalidState,
+ "with-type": WithProviderType,
+ "without-type": WithoutProviderType,
+};
+
+export default compose("AddingProviderScreen", useComponentState, map)
+
+
+export async function testProvider(
+ url: string,
+ expectedMethodType?: string,
+): Promise<void> {
+ try {
+ const response = await fetch(new URL("config", url).href);
+ const json = await response.json().catch((d) => ({}));
+ if (!("methods" in json) || !Array.isArray(json.methods)) {
+ throw Error(
+ "This provider doesn't have authentication method. Check the provider URL",
+ );
+ }
+ if (!expectedMethodType) {
+ return;
+ }
+ let found = false;
+ for (let i = 0; i < json.methods.length && !found; i++) {
+ found = json.methods[i].type === expectedMethodType;
+ }
+ if (!found) {
+ throw Error(
+ `This provider does not support authentication method ${expectedMethodType}`,
+ );
+ }
+ return;
+ } catch (e) {
+ console.log("ERROR testProvider", e);
+ const error =
+ e instanceof Error
+ ? Error(
+ `There was an error testing this provider, try another one. ${e.message}`,
+ )
+ : Error(`There was an error testing this provider, try another one.`);
+ throw error;
+ }
+}
diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts
new file mode 100644
index 000000000..f80f1c464
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/state.ts
@@ -0,0 +1,157 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useEffect, useRef, useState } from "preact/hooks";
+import { Notification } from "../../../components/Notifications.js";
+import { useAnastasisContext } from "../../../context/anastasis.js";
+import { authMethods, KnownAuthMethods } from "../authMethod/index.jsx";
+import { AuthProvByStatusMap, State, testProvider } from "./index.js";
+
+interface Props {
+ providerType?: KnownAuthMethods;
+ onCancel: () => Promise<void>;
+ notifications?: Notification[];
+}
+
+export default function useComponentState({
+ providerType,
+ onCancel,
+ notifications = [],
+}: Props): State {
+ const reducer = useAnastasisContext();
+
+ const [providerURL, setProviderURL] = useState("");
+
+ const [error, setError] = useState<string | undefined>();
+ const [testing, setTesting] = useState(false);
+
+ const providerLabel = providerType
+ ? authMethods[providerType].label
+ : undefined;
+
+ const allAuthProviders =
+ !reducer ||
+ !reducer.currentReducerState ||
+ reducer.currentReducerState.reducer_type === "error" ||
+ !reducer.currentReducerState.authentication_providers
+ ? {}
+ : reducer.currentReducerState.authentication_providers;
+
+ const authProvidersByStatus = Object.keys(allAuthProviders).reduce(
+ (prev, url) => {
+ const p = allAuthProviders[url];
+ if (
+ providerLabel &&
+ p.status === "ok" &&
+ p.methods.findIndex((m) => m.type === providerType) !== -1
+ ) {
+ return prev;
+ }
+ prev[p.status].push({ ...p, url });
+ return prev;
+ },
+ {
+ "not-contacted": [],
+ disabled: [],
+ error: [],
+ ok: [],
+ } as AuthProvByStatusMap,
+ );
+ const authProviders = authProvidersByStatus["ok"].map((p) => p.url);
+
+ //FIXME: move this timeout logic into a hook
+ const timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
+ useEffect(() => {
+ if (timeout.current) clearTimeout(timeout.current);
+ timeout.current = setTimeout(async () => {
+ const url = providerURL.endsWith("/") ? providerURL : providerURL + "/";
+ if (!providerURL || authProviders.includes(url)) return;
+ try {
+ setTesting(true);
+ await testProvider(url, providerType);
+ setError("");
+ } catch (e) {
+ if (e instanceof Error) setError(e.message);
+ }
+ setTesting(false);
+ }, 200);
+ }, [providerURL, reducer]);
+
+ if (!reducer) {
+ return {
+ status: "no-reducer",
+ };
+ }
+
+ if (
+ !reducer.currentReducerState ||
+ !("authentication_providers" in reducer.currentReducerState)
+ ) {
+ return {
+ status: "invalid-state",
+ };
+ }
+
+ const addProvider = async (provider_url: string): Promise<void> => {
+ await reducer.transition("add_provider", { provider_url });
+ onCancel();
+ };
+ const deleteProvider = async (provider_url: string): Promise<void> => {
+ reducer.transition("delete_provider", { provider_url });
+ };
+
+ let errors = !providerURL ? "Add provider URL" : undefined;
+ let url: string | undefined;
+ try {
+ url = new URL("", providerURL).href;
+ } catch {
+ errors = "Check the URL";
+ }
+ const _url = url;
+
+ if (!!error && !errors) {
+ errors = error;
+ }
+ if (!errors && authProviders.includes(url!)) {
+ errors = "That provider is already known";
+ }
+
+ const commonState = {
+ addProvider: !_url ? undefined : async () => addProvider(_url),
+ deleteProvider: async (url: string) => deleteProvider(url),
+ allAuthProviders,
+ authProvidersByStatus,
+ onCancel,
+ providerURL,
+ testing,
+ setProviderURL: async (s: string) => setProviderURL(s),
+ errors,
+ error,
+ notifications,
+ };
+
+ if (!providerLabel) {
+ return {
+ status: "without-type",
+ ...commonState,
+ };
+ } else {
+ return {
+ status: "with-type",
+ providerLabel,
+ ...commonState,
+ };
+ }
+}
diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/stories.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/stories.tsx
new file mode 100644
index 000000000..548fc01a5
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/stories.tsx
@@ -0,0 +1,93 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AuthenticationProviderStatusOk } from "@gnu-taler/anastasis-core";
+import * as tests from "@gnu-taler/web-util/testing";
+import { WithoutProviderType, WithProviderType } from "./views.jsx";
+
+export default {
+ title: "Adding Provider Screen",
+ args: {
+ order: 1,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+export const NewProvider = tests.createExample(WithoutProviderType, {
+ authProvidersByStatus: {
+ ok: [
+ {
+ business_name: "X provider",
+ status: "ok",
+ storage_limit_in_megabytes: 5,
+ methods: [
+ {
+ type: "question",
+ usage_fee: "KUDOS:1",
+ },
+ ],
+ url: "",
+ } as AuthenticationProviderStatusOk & { url: string },
+ ],
+ "not-contacted": [],
+ disabled: [],
+ error: [],
+ },
+ notifications: [],
+});
+
+export const NewProviderWithoutProviderList = tests.createExample(
+ WithoutProviderType,
+ {
+ authProvidersByStatus: {
+ ok: [],
+ "not-contacted": [],
+ disabled: [],
+ error: [],
+ },
+ notifications: [],
+ },
+);
+
+export const NewSmsProvider = tests.createExample(WithProviderType, {
+ authProvidersByStatus: {
+ ok: [],
+ "not-contacted": [],
+ disabled: [],
+ error: [],
+ },
+ providerLabel: "sms",
+ notifications: [],
+});
+
+export const NewIBANProvider = tests.createExample(WithProviderType, {
+ authProvidersByStatus: {
+ ok: [],
+ "not-contacted": [],
+ disabled: [],
+ error: [],
+ },
+ providerLabel: "IBAN",
+ notifications: [],
+});
diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/test.ts b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/test.ts
new file mode 100644
index 000000000..0aebbdc6c
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/test.ts
@@ -0,0 +1,45 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+import useComponentState from "./state.js";
+import * as tests from "@gnu-taler/web-util/testing";
+
+describe("AddingProviderScreen states", () => {
+ it("should not load more if has reach the end", async () => {
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ return useComponentState({
+ providerType: "email",
+ async onCancel() {},
+ });
+ },
+ {},
+ [
+ ({ status }) => {
+ expect(status).eq("no-reducer");
+ },
+ ],
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx
new file mode 100644
index 000000000..19557a12f
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen/views.tsx
@@ -0,0 +1,309 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AuthenticationProviderStatusError,
+ AuthenticationProviderStatusOk,
+} from "@gnu-taler/anastasis-core";
+import { h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { TextInput } from "../../../components/fields/TextInput.js";
+import { Notifications } from "../../../components/Notifications.js";
+import { AnastasisClientFrame } from "../index.js";
+import { testProvider, WithoutType, WithType } from "./index.js";
+import { useTranslationContext } from "../../../context/translation.js";
+
+export function WithProviderType(props: WithType): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <AnastasisClientFrame
+ hideNav
+ title="Backup: Manage providers1"
+ hideNext={props.errors}
+ >
+ <div>
+ <Notifications notifications={props.notifications} />
+ <p>{i18n.str`Add a provider url for a ${props.providerLabel} service`}</p>
+ <div class="container">
+ <TextInput
+ label="Provider URL"
+ placeholder="https://provider.com"
+ grabFocus
+ error={props.errors}
+ bind={[props.providerURL, props.setProviderURL]}
+ />
+ </div>
+ <p class="block">Example: https://kudos.demo.anastasis.lu</p>
+ {props.testing && <p class="has-text-info">Testing</p>}
+
+ <div
+ class="block"
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={props.onCancel}>
+ Cancel
+ </button>
+ <span data-tooltip={props.errors}>
+ <button
+ class="button is-info"
+ disabled={props.error !== "" || props.testing}
+ onClick={props.addProvider}
+ >
+ Add
+ </button>
+ </span>
+ </div>
+
+ {props.authProvidersByStatus["ok"].length > 0 ? (
+ <p class="subtitle">
+ Current providers for {props.providerLabel} service
+ </p>
+ ) : (
+ <p class="subtitle">
+ No known providers for {props.providerLabel} service
+ </p>
+ )}
+
+ {props.authProvidersByStatus["ok"].map((k, i) => {
+ const p = k as AuthenticationProviderStatusOk;
+ return (
+ <TableRow
+ key={i}
+ url={k.url}
+ info={p}
+ onDelete={props.deleteProvider}
+ />
+ );
+ })}
+ <p class="subtitle">Providers with errors</p>
+ {props.authProvidersByStatus["error"].map((k, i) => {
+ const p = k as AuthenticationProviderStatusError;
+ return (
+ <TableRowError
+ key={i}
+ url={k.url}
+ info={p}
+ onDelete={props.deleteProvider}
+ />
+ );
+ })}
+ </div>
+ </AnastasisClientFrame>
+ );
+}
+
+export function WithoutProviderType(props: WithoutType): VNode {
+ return (
+ <AnastasisClientFrame
+ hideNav
+ title="Backup: Manage providers"
+ hideNext={props.errors}
+ >
+ <div>
+ <Notifications notifications={props.notifications} />
+ <p>Add a provider url</p>
+ <div class="container">
+ <TextInput
+ label="Provider URL"
+ placeholder="https://provider.com"
+ grabFocus
+ error={props.errors}
+ bind={[props.providerURL, props.setProviderURL]}
+ />
+ </div>
+ <p class="block">Example: https://kudos.demo.anastasis.lu</p>
+ {props.testing && <p class="has-text-info">Testing</p>}
+
+ <div
+ class="block"
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={props.onCancel}>
+ Cancel
+ </button>
+ <span data-tooltip={props.errors}>
+ <button
+ class="button is-info"
+ disabled={props.error !== "" || props.testing}
+ onClick={props.addProvider}
+ >
+ Add
+ </button>
+ </span>
+ </div>
+
+ {props.authProvidersByStatus["ok"].length > 0 ? (
+ <p class="subtitle">Current providers</p>
+ ) : (
+ <p class="subtitle">No known providers, add one.</p>
+ )}
+
+ {props.authProvidersByStatus["ok"].map((k, i) => {
+ const p = k as AuthenticationProviderStatusOk;
+ return (
+ <TableRow
+ key={i}
+ url={k.url}
+ info={p}
+ onDelete={props.deleteProvider}
+ />
+ );
+ })}
+ <p class="subtitle">Providers with errors</p>
+ {props.authProvidersByStatus["error"].map((k, i) => {
+ const p = k as AuthenticationProviderStatusError;
+ return (
+ <TableRowError
+ key={i}
+ url={k.url}
+ info={p}
+ onDelete={props.deleteProvider}
+ />
+ );
+ })}
+ </div>
+ </AnastasisClientFrame>
+ );
+}
+
+function TableRow({
+ url,
+ info,
+ onDelete,
+}: {
+ onDelete: (s: string) => Promise<void>;
+ url: string;
+ info: AuthenticationProviderStatusOk;
+}): VNode {
+ const [status, setStatus] = useState("checking");
+ useEffect(function () {
+ testProvider(url.endsWith("/") ? url.substring(0, url.length - 1) : url)
+ .then(function () {
+ setStatus("responding");
+ })
+ .catch(function () {
+ setStatus("failed to contact");
+ });
+ });
+ return (
+ <div
+ class="box"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <div>
+ <div class="subtitle">{url}</div>
+ <dl>
+ <dt>
+ <b>Business Name</b>
+ </dt>
+ <dd>{info.business_name}</dd>
+ <dt>
+ <b>Supported methods</b>
+ </dt>
+ <dd>{info.methods.map((m) => m.type).join(",")}</dd>
+ <dt>
+ <b>Maximum storage</b>
+ </dt>
+ <dd>{info.storage_limit_in_megabytes} Mb</dd>
+ <dt>
+ <b>Status</b>
+ </dt>
+ <dd>{status}</dd>
+ </dl>
+ </div>
+ <div
+ class="block"
+ style={{
+ marginTop: "auto",
+ marginBottom: "auto",
+ display: "flex",
+ justifyContent: "space-between",
+ flexDirection: "column",
+ }}
+ >
+ <button class="button is-danger" onClick={() => onDelete(url)}>
+ Remove
+ </button>
+ </div>
+ </div>
+ );
+}
+
+function TableRowError({
+ url,
+ info,
+ onDelete,
+}: {
+ onDelete: (s: string) => void;
+ url: string;
+ info: AuthenticationProviderStatusError;
+}): VNode {
+ const [status, setStatus] = useState("checking");
+ useEffect(function () {
+ testProvider(url.endsWith("/") ? url.substring(0, url.length - 1) : url)
+ .then(function () {
+ setStatus("responding");
+ })
+ .catch(function () {
+ setStatus("failed to contact");
+ });
+ });
+ return (
+ <div
+ class="box"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <div>
+ <div class="subtitle">{url}</div>
+ <dl>
+ <dt>
+ <b>Error</b>
+ </dt>
+ <dd>{info.hint}</dd>
+ <dt>
+ <b>Code</b>
+ </dt>
+ <dd>{info.code}</dd>
+ <dt>
+ <b>Status</b>
+ </dt>
+ <dd>{status}</dd>
+ </dl>
+ </div>
+ <div
+ class="block"
+ style={{
+ marginTop: "auto",
+ marginBottom: "auto",
+ display: "flex",
+ justifyContent: "space-between",
+ flexDirection: "column",
+ }}
+ >
+ <button class="button is-danger" onClick={() => onDelete(url)}>
+ Remove
+ </button>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx
index d28a6df43..e6bc5f340 100644
--- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx
@@ -1,63 +1,161 @@
-/* eslint-disable @typescript-eslint/camelcase */
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ReducerState } from 'anastasis-core';
-import { createExample, reducerStatesExample } from '../../utils';
-import { AttributeEntryScreen as TestedComponent } from './AttributeEntryScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../utils/index.js";
+import { AttributeEntryScreen as TestedComponent } from "./AttributeEntryScreen.js";
export default {
- title: 'Pages/AttributeEntryScreen',
+ title: "Attribute Entry Screen",
component: TestedComponent,
+ args: {
+ order: 3,
+ },
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const WithSomeAttributes = createExample(TestedComponent, {
- ...reducerStatesExample.attributeEditing,
- required_attributes: [{
- name: 'first',
- label: 'first',
- type: 'type',
- uuid: 'asdasdsa1',
- widget: 'wid',
- }, {
- name: 'pepe',
- label: 'second',
- type: 'type',
- uuid: 'asdasdsa2',
- widget: 'wid',
- }, {
- name: 'pepe2',
- label: 'third',
- type: 'type',
- uuid: 'asdasdsa3',
- widget: 'calendar',
- }]
+export const Backup = tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.backupAttributeEditing,
+ required_attributes: [
+ {
+ name: "full_name",
+ label: "Full name",
+ type: "string",
+ uuid: "asdasdsa1",
+ widget: "wid",
+ },
+ {
+ name: "birthplace",
+ label: "Birthplace",
+ type: "string",
+ uuid: "asdasdsa2",
+ widget: "wid",
+ },
+ {
+ name: "birthdate",
+ label: "birthdate",
+ type: "date",
+ uuid: "asdasdsa3",
+ widget: "calendar",
+ },
+ ],
} as ReducerState);
-export const Empty = createExample(TestedComponent, {
- ...reducerStatesExample.attributeEditing,
- required_attributes: undefined
+export const Recovery = tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.recoveryAttributeEditing,
+ required_attributes: [
+ {
+ name: "full_name",
+ label: "Full name",
+ type: "string",
+ uuid: "asdasdsa1",
+ widget: "wid",
+ },
+ {
+ name: "birthplace",
+ label: "Birthplace",
+ type: "string",
+ uuid: "asdasdsa2",
+ widget: "wid",
+ },
+ {
+ name: "pepe2",
+ label: "third",
+ type: "date",
+ uuid: "asdasdsa3",
+ widget: "calendar",
+ },
+ ],
} as ReducerState);
+
+export const WithNoRequiredAttribute = tests.createExample(
+ TestedComponent,
+ {},
+ {
+ ...reducerStatesExample.backupAttributeEditing,
+ required_attributes: undefined,
+ } as ReducerState,
+);
+
+const allWidgets = [
+ "anastasis_gtk_ia_aadhar_in",
+ "anastasis_gtk_ia_ahv",
+ "anastasis_gtk_ia_birthdate",
+ "anastasis_gtk_ia_birthnumber_cz",
+ "anastasis_gtk_ia_birthnumber_sk",
+ "anastasis_gtk_ia_birthplace",
+ "anastasis_gtk_ia_cf_it",
+ "anastasis_gtk_ia_cpr_dk",
+ "anastasis_gtk_ia_es_dni",
+ "anastasis_gtk_ia_es_ssn",
+ "anastasis_gtk_ia_full_name",
+ "anastasis_gtk_ia_my_jp",
+ "anastasis_gtk_ia_nid_al",
+ "anastasis_gtk_ia_nid_be",
+ "anastasis_gtk_ia_ssn_de",
+ "anastasis_gtk_ia_ssn_us",
+ "anastasis_gtk_ia_tax_de",
+ "anastasis_gtk_xx_prime",
+ "anastasis_gtk_xx_square",
+];
+
+function typeForWidget(name: string): string {
+ if (["anastasis_gtk_xx_prime", "anastasis_gtk_xx_square"].includes(name))
+ return "number";
+ if (["anastasis_gtk_ia_birthdate"].includes(name)) return "date";
+ return "string";
+}
+
+export const WithAllPosibleWidget = tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.backupAttributeEditing,
+ required_attributes: allWidgets.map((w) => ({
+ name: w,
+ label: `widget: ${w}`,
+ type: typeForWidget(w),
+ uuid: `uuid-${w}`,
+ widget: w,
+ })),
+} as ReducerState);
+
+export const WithAutocompleteFeature = tests.createExample(
+ TestedComponent,
+ {},
+ {
+ ...reducerStatesExample.backupAttributeEditing,
+ required_attributes: [
+ {
+ name: "ahv_number",
+ label: "AHV Number",
+ type: "string",
+ uuid: "asdasdsa1",
+ widget: "wid",
+ "validation-regex":
+ "^(756)\\.[0-9]{4}\\.[0-9]{4}\\.[0-9]{2}|(756)[0-9]{10}$",
+ "validation-logic": "CH_AHV_check",
+ autocomplete: "???.????.????.??",
+ },
+ ],
+ } as ReducerState,
+);
diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx
index 2f804f940..228186a2d 100644
--- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx
@@ -1,65 +1,271 @@
-/* eslint-disable @typescript-eslint/camelcase */
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { UserAttributeSpec, validators } from "@gnu-taler/anastasis-core";
+import { isAfter, parse } from "date-fns";
import { h, VNode } from "preact";
import { useState } from "preact/hooks";
-import { ReducerStateRecovery, ReducerStateBackup, UserAttributeSpec } from "anastasis-core/lib";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisReducerApi } from "../../hooks/use-anastasis-reducer";
-import { AnastasisClientFrame, withProcessLabel, LabeledInput } from "./index";
+import { DateInput } from "../../components/fields/DateInput.js";
+import { PhoneNumberInput } from "../../components/fields/NumberInput.js";
+import { TextInput } from "../../components/fields/TextInput.js";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { ConfirmModal } from "./ConfirmModal.js";
+import { AnastasisClientFrame, withProcessLabel } from "./index.js";
export function AttributeEntryScreen(): VNode {
- const reducer = useAnastasisContext()
- const state = reducer?.currentReducerState
- const currentIdentityAttributes = state && "identity_attributes" in state ? (state.identity_attributes || {}) : {}
- const [attrs, setAttrs] = useState<Record<string, string>>(currentIdentityAttributes);
+ const reducer = useAnastasisContext();
+ const state = reducer?.currentReducerState;
+ const currentIdentityAttributes =
+ state && "identity_attributes" in state
+ ? state.identity_attributes || {}
+ : {};
+ const [attrs, setAttrs] = useState<Record<string, string>>(
+ currentIdentityAttributes,
+ );
+ const isBackup = state?.reducer_type === "backup";
+ const [askUserIfSure, setAskUserIfSure] = useState(false);
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
- if (!reducer.currentReducerState || !("required_attributes" in reducer.currentReducerState)) {
- return <div>invalid state</div>
+ if (
+ !reducer.currentReducerState ||
+ !("required_attributes" in reducer.currentReducerState)
+ ) {
+ return <div>invalid state</div>;
+ }
+ const reqAttr = reducer.currentReducerState.required_attributes || [];
+ let hasErrors = false;
+
+ const fieldList: VNode[] = reqAttr.map((spec, i: number) => {
+ const value = attrs[spec.name];
+ const error = checkIfValid(value, spec);
+
+ function addAutocomplete(newValue: string): string {
+ const ac = spec.autocomplete;
+ if (!ac || ac.length <= newValue.length || ac[newValue.length] === "?")
+ return newValue;
+
+ if (!value || newValue.length < value.length) {
+ return newValue.slice(0, -1);
+ }
+
+ return newValue + ac[newValue.length];
+ }
+
+ hasErrors = hasErrors || error !== undefined;
+ return (
+ <AttributeEntryField
+ key={i}
+ isFirst={i == 0}
+ setValue={(v: string) =>
+ setAttrs({ ...attrs, [spec.name]: addAutocomplete(v) })
+ }
+ spec={spec}
+ errorMessage={error}
+ onConfirm={() => {
+ if (!hasErrors) {
+ setAskUserIfSure(true);
+ }
+ }}
+ value={value}
+ />
+ );
+ });
+
+ const doConfirm = async () => {
+ await reducer.transition("enter_user_attributes", {
+ identity_attributes: {
+ application_id: "anastasis-standalone",
+ ...attrs,
+ },
+ });
+ };
+
+ function saveAsPDF(): void {
+ const printWindow = window.open("", "", "height=400,width=800");
+ const divContents = document.getElementById("printThis");
+ const styleContents = document.getElementById("style-id");
+
+ if (!printWindow || !divContents || !styleContents) return;
+ printWindow.document.write(
+ "<html><head><title>Anastasis Recovery Document</title><style>",
+ );
+ printWindow.document.write(styleContents.innerHTML);
+ printWindow.document.write("</style></head><body>&nbsp;</body></html>");
+ printWindow.document.close();
+ printWindow.document.body.appendChild(divContents.cloneNode(true));
+ printWindow.addEventListener("load", () => {
+ printWindow.print();
+ printWindow.close();
+ });
}
-
+
return (
<AnastasisClientFrame
- title={withProcessLabel(reducer, "Select Country")}
- onNext={() => reducer.transition("enter_user_attributes", {
- identity_attributes: attrs,
- })}
+ title={withProcessLabel(reducer, "Who are you?")}
+ hideNext={hasErrors ? "Complete the form." : undefined}
+ onNext={async () => (isBackup ? setAskUserIfSure(true) : doConfirm())}
>
- {reducer.currentReducerState.required_attributes?.map((x, i: number) => {
- return (
- <AttributeEntryField
- key={i}
- isFirst={i == 0}
- setValue={(v: string) => setAttrs({ ...attrs, [x.name]: v })}
- spec={x}
- value={attrs[x.name]} />
- );
- })}
+ {askUserIfSure ? (
+ <ConfirmModal
+ active
+ onCancel={() => setAskUserIfSure(false)}
+ description="The values in the form must be correct"
+ label="I am sure"
+ cancelLabel="Wait, I want to check"
+ onConfirm={() => doConfirm().then(() => setAskUserIfSure(false))}
+ >
+ You personal information is used to define the location where your
+ secret will be safely stored. If you forget what you have entered or
+ if there is a misspell you will be unable to recover your secret.
+ <p>
+ <a onClick={saveAsPDF}>Save the personal information as PDF</a>
+ </p>
+ </ConfirmModal>
+ ) : undefined}
+
+ <div class="columns" style={{ maxWidth: "unset" }}>
+ <div class="column" id="printThis">
+ {fieldList}
+ </div>
+ <div class="column">
+ <p>This personal information will help to locate your secret.</p>
+ <h1 class="title">This stays private</h1>
+ <p>The information you have entered here:</p>
+ <ul>
+ <li>
+ <span class="icon is-right">
+ <i class="mdi mdi-circle-small" />
+ </span>
+ Will be hashed, and therefore unreadable
+ </li>
+ <li>
+ <span class="icon is-right">
+ <i class="mdi mdi-circle-small" />
+ </span>
+ The non-hashed version is not shared
+ </li>
+ </ul>
+ </div>
+ </div>
</AnastasisClientFrame>
);
}
-interface AttributeEntryProps {
- reducer: AnastasisReducerApi;
- reducerState: ReducerStateRecovery | ReducerStateBackup;
-}
-
-export interface AttributeEntryFieldProps {
+interface AttributeEntryFieldProps {
isFirst: boolean;
value: string;
setValue: (newValue: string) => void;
spec: UserAttributeSpec;
+ errorMessage: string | undefined;
+ onConfirm: () => void;
}
-
-export function AttributeEntryField(props: AttributeEntryFieldProps): VNode {
+const possibleBirthdayYear: Array<number> = [];
+for (let i = 0; i < 100; i++) {
+ possibleBirthdayYear.push(2020 - i);
+}
+function AttributeEntryField(props: AttributeEntryFieldProps): VNode {
return (
- <div>
- <LabeledInput
- grabFocus={props.isFirst}
- label={props.spec.label}
- bind={[props.value, props.setValue]}
- />
+ <div style={{ marginTop: 16 }}>
+ {props.spec.type === "date" && (
+ <DateInput
+ grabFocus={props.isFirst}
+ label={props.spec.label}
+ years={possibleBirthdayYear}
+ onConfirm={props.onConfirm}
+ error={props.errorMessage}
+ bind={[props.value, props.setValue]}
+ />
+ )}
+ {props.spec.type === "number" && (
+ <PhoneNumberInput
+ grabFocus={props.isFirst}
+ label={props.spec.label}
+ onConfirm={props.onConfirm}
+ error={props.errorMessage}
+ bind={[props.value, props.setValue]}
+ />
+ )}
+ {props.spec.type === "string" && (
+ <TextInput
+ grabFocus={props.isFirst}
+ label={props.spec.label}
+ onConfirm={props.onConfirm}
+ error={props.errorMessage}
+ bind={[props.value, props.setValue]}
+ />
+ )}
+ {props.spec.type === "string" && (
+ <div>
+ This field is case-sensitive. You must enter exactly the same value
+ during recovery.
+ </div>
+ )}
+ {props.spec.name === "full_name" && (
+ <div>
+ If possible, use &quot;LASTNAME, Firstname(s)&quot; without
+ abbreviations.
+ </div>
+ )}
+ <div class="block">
+ This stays private
+ <span class="icon is-right">
+ <i class="mdi mdi-eye-off" />
+ </span>
+ </div>
</div>
);
}
+const YEAR_REGEX = /^[0-9]+-[0-9]+-[0-9]+$/;
+
+function checkIfValid(
+ value: string,
+ spec: UserAttributeSpec,
+): string | undefined {
+ const pattern = spec["validation-regex"];
+ if (pattern) {
+ const re = new RegExp(pattern);
+ if (!re.test(value)) return "The value is invalid";
+ }
+ const logic = spec["validation-logic"];
+ if (logic) {
+ const func = (validators as any)[logic];
+ if (func && typeof func === "function" && !func(value))
+ return "Please check the value";
+ }
+ const optional = spec.optional;
+ if (!optional && !value) {
+ return "This value is required";
+ }
+ if ("date" === spec.type) {
+ if (!YEAR_REGEX.test(value)) {
+ return "The date doesn't follow the format";
+ }
+
+ try {
+ const v = parse(value, "yyyy-MM-dd", new Date());
+ if (Number.isNaN(v.getTime())) {
+ return "Some numeric values seems out of range for a date";
+ }
+ if ("birthdate" === spec.name && isAfter(v, new Date())) {
+ return "A birthdate cannot be in the future";
+ }
+ } catch (e) {
+ return "Could not parse the date";
+ }
+ }
+ return undefined;
+}
diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx
deleted file mode 100644
index 9567e0ef7..000000000
--- a/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-/* eslint-disable @typescript-eslint/camelcase */
-import {
- encodeCrock,
- stringToBytes
-} from "@gnu-taler/taler-util";
-import { h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { AuthMethodSetupProps } from "./AuthenticationEditorScreen";
-import { AnastasisClientFrame, LabeledInput } from "./index";
-
-export function AuthMethodEmailSetup(props: AuthMethodSetupProps): VNode {
- const [email, setEmail] = useState("");
- return (
- <AnastasisClientFrame hideNav title="Add email authentication">
- <p>
- For email authentication, you need to provide an email address. When
- recovering your secret, you will need to enter the code you receive by
- email.
- </p>
- <div>
- <LabeledInput
- label="Email address"
- grabFocus
- bind={[email, setEmail]} />
- </div>
- <div>
- <button onClick={() => props.cancel()}>Cancel</button>
- <button
- onClick={() => props.addAuthMethod({
- authentication_method: {
- type: "email",
- instructions: `Email to ${email}`,
- challenge: encodeCrock(stringToBytes(email)),
- },
- })}
- >
- Add
- </button>
- </div>
- </AnastasisClientFrame>
- );
-}
diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx
deleted file mode 100644
index 55e37a968..000000000
--- a/packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-/* eslint-disable @typescript-eslint/camelcase */
-import {
- canonicalJson, encodeCrock,
- stringToBytes
-} from "@gnu-taler/taler-util";
-import { h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { AuthMethodSetupProps } from "./AuthenticationEditorScreen";
-import { LabeledInput } from "./index";
-
-export function AuthMethodPostSetup(props: AuthMethodSetupProps): VNode {
- const [fullName, setFullName] = useState("");
- const [street, setStreet] = useState("");
- const [city, setCity] = useState("");
- const [postcode, setPostcode] = useState("");
- const [country, setCountry] = useState("");
-
- const addPostAuth = () => {
- const challengeJson = {
- full_name: fullName,
- street,
- city,
- postcode,
- country,
- };
- props.addAuthMethod({
- authentication_method: {
- type: "email",
- instructions: `Letter to address in postal code ${postcode}`,
- challenge: encodeCrock(stringToBytes(canonicalJson(challengeJson))),
- },
- });
- };
-
- return (
- <div class="home">
- <h1>Add {props.method} authentication</h1>
- <div>
- <p>
- For postal letter authentication, you need to provide a postal
- address. When recovering your secret, you will be asked to enter a
- code that you will receive in a letter to that address.
- </p>
- <div>
- <LabeledInput
- grabFocus
- label="Full Name"
- bind={[fullName, setFullName]} />
- </div>
- <div>
- <LabeledInput label="Street" bind={[street, setStreet]} />
- </div>
- <div>
- <LabeledInput label="City" bind={[city, setCity]} />
- </div>
- <div>
- <LabeledInput label="Postal Code" bind={[postcode, setPostcode]} />
- </div>
- <div>
- <LabeledInput label="Country" bind={[country, setCountry]} />
- </div>
- <div>
- <button onClick={() => props.cancel()}>Cancel</button>
- <button onClick={() => addPostAuth()}>Add</button>
- </div>
- </div>
- </div>
- );
-}
diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx
deleted file mode 100644
index 7699cdf34..000000000
--- a/packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-/* eslint-disable @typescript-eslint/camelcase */
-import {
- encodeCrock,
- stringToBytes
-} from "@gnu-taler/taler-util";
-import { h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { AuthMethodSetupProps } from "./AuthenticationEditorScreen";
-import { AnastasisClientFrame, LabeledInput } from "./index";
-
-export function AuthMethodQuestionSetup(props: AuthMethodSetupProps): VNode {
- const [questionText, setQuestionText] = useState("");
- const [answerText, setAnswerText] = useState("");
- const addQuestionAuth = (): void => props.addAuthMethod({
- authentication_method: {
- type: "question",
- instructions: questionText,
- challenge: encodeCrock(stringToBytes(answerText)),
- },
- });
- return (
- <AnastasisClientFrame hideNav title="Add Security Question">
- <div>
- <p>
- For security question authentication, you need to provide a question
- and its answer. When recovering your secret, you will be shown the
- question and you will need to type the answer exactly as you typed it
- here.
- </p>
- <div>
- <LabeledInput
- label="Security question"
- grabFocus
- bind={[questionText, setQuestionText]} />
- </div>
- <div>
- <LabeledInput label="Answer" bind={[answerText, setAnswerText]} />
- </div>
- <div>
- <button onClick={() => props.cancel()}>Cancel</button>
- <button onClick={() => addQuestionAuth()}>Add</button>
- </div>
- </div>
- </AnastasisClientFrame>
- );
-}
diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx
deleted file mode 100644
index 6f4797275..000000000
--- a/packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-/* eslint-disable @typescript-eslint/camelcase */
-import {
- encodeCrock,
- stringToBytes
-} from "@gnu-taler/taler-util";
-import { h, VNode } from "preact";
-import { useState, useRef, useLayoutEffect } from "preact/hooks";
-import { AuthMethodSetupProps } from "./AuthenticationEditorScreen";
-import { AnastasisClientFrame } from "./index";
-
-export function AuthMethodSmsSetup(props: AuthMethodSetupProps): VNode {
- const [mobileNumber, setMobileNumber] = useState("");
- const addSmsAuth = (): void => {
- props.addAuthMethod({
- authentication_method: {
- type: "sms",
- instructions: `SMS to ${mobileNumber}`,
- challenge: encodeCrock(stringToBytes(mobileNumber)),
- },
- });
- };
- const inputRef = useRef<HTMLInputElement>(null);
- useLayoutEffect(() => {
- inputRef.current?.focus();
- }, []);
- return (
- <AnastasisClientFrame hideNav title="Add SMS authentication">
- <div>
- <p>
- For SMS authentication, you need to provide a mobile number. When
- recovering your secret, you will be asked to enter the code you
- receive via SMS.
- </p>
- <label>
- Mobile number:{" "}
- <input
- value={mobileNumber}
- ref={inputRef}
- style={{ display: "block" }}
- autoFocus
- onChange={(e) => setMobileNumber((e.target as any).value)}
- type="text" />
- </label>
- <div>
- <button onClick={() => props.cancel()}>Cancel</button>
- <button onClick={() => addSmsAuth()}>Add</button>
- </div>
- </div>
- </AnastasisClientFrame>
- );
-}
diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx
index 44d3795b2..22f8dd697 100644
--- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx
@@ -1,35 +1,108 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createExample, reducerStatesExample } from '../../utils';
-import { AuthenticationEditorScreen as TestedComponent } from './AuthenticationEditorScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../utils/index.js";
+import { AuthenticationEditorScreen as TestedComponent } from "./AuthenticationEditorScreen.js";
export default {
- title: 'Pages/AuthenticationEditorScreen',
+ title: "Authentication Editor Screen",
component: TestedComponent,
+ args: {
+ order: 4,
+ },
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const Example = createExample(TestedComponent, reducerStatesExample.authEditing);
+export const InitialState = tests.createExample(
+ TestedComponent,
+ {},
+ reducerStatesExample.authEditing,
+);
+export const OneAuthMethodConfigured = tests.createExample(
+ TestedComponent,
+ {},
+ {
+ ...reducerStatesExample.authEditing,
+ authentication_methods: [
+ {
+ type: "question",
+ instructions: "what time is it?",
+ challenge: "asd",
+ },
+ ],
+ } as ReducerState,
+);
+
+export const SomeMoreAuthMethodConfigured = tests.createExample(
+ TestedComponent,
+ {},
+ {
+ ...reducerStatesExample.authEditing,
+ authentication_methods: [
+ {
+ type: "question",
+ instructions: "what time is it?",
+ challenge: "asd",
+ },
+ {
+ type: "question",
+ instructions: "what time is it?",
+ challenge: "qwe",
+ },
+ {
+ type: "sms",
+ instructions: "what time is it?",
+ challenge: "asd",
+ },
+ {
+ type: "email",
+ instructions: "what time is it?",
+ challenge: "asd",
+ },
+ {
+ type: "email",
+ instructions: "what time is it?",
+ challenge: "asd",
+ },
+ {
+ type: "email",
+ instructions: "what time is it?",
+ challenge: "asd",
+ },
+ {
+ type: "email",
+ instructions: "what time is it?",
+ challenge: "asd",
+ },
+ ],
+ } as ReducerState,
+);
+
+export const NoAuthMethodProvided = tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.authEditing,
+ authentication_providers: {},
+ authentication_methods: [],
+} as ReducerState);
diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx
index e9ffccbac..54bbc626d 100644
--- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx
@@ -1,112 +1,256 @@
-/* eslint-disable @typescript-eslint/camelcase */
-import { AuthMethod, ReducerStateBackup } from "anastasis-core";
-import { h, VNode } from "preact";
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { AuthMethod, ReducerStateBackup } from "@gnu-taler/anastasis-core";
+import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisReducerApi } from "../../hooks/use-anastasis-reducer";
-import { AuthMethodEmailSetup } from "./AuthMethodEmailSetup";
-import { AuthMethodPostSetup } from "./AuthMethodPostSetup";
-import { AuthMethodQuestionSetup } from "./AuthMethodQuestionSetup";
-import { AuthMethodSmsSetup } from "./AuthMethodSmsSetup";
-import { AnastasisClientFrame } from "./index";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import AddingProviderScreen from "./AddingProviderScreen/index.js";
+import {
+ authMethods,
+ AuthMethodSetupProps,
+ AuthMethodWithRemove,
+ isKnownAuthMethods,
+ KnownAuthMethods,
+} from "./authMethod/index.js";
+import { ConfirmModal } from "./ConfirmModal.js";
+import { AnastasisClientFrame } from "./index.js";
+
+const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T>;
export function AuthenticationEditorScreen(): VNode {
- const [selectedMethod, setSelectedMethod] = useState<string | undefined>(
- undefined
+ const [noProvidersAck, setNoProvidersAck] = useState(false);
+ const [selectedMethod, setSelectedMethod] = useState<
+ KnownAuthMethods | undefined
+ >(undefined);
+ const [tooFewAuths, setTooFewAuths] = useState(false);
+ const [manageProvider, setManageProvider] = useState<string | undefined>(
+ undefined,
);
- const reducer = useAnastasisContext()
+
+ // const [addingProvider, setAddingProvider] = useState<string | undefined>(undefined)
+ const reducer = useAnastasisContext();
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
- if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) {
- return <div>invalid state</div>
+ if (reducer.currentReducerState?.reducer_type !== "backup") {
+ return <div>invalid state</div>;
}
+ const configuredAuthMethods: AuthMethod[] =
+ reducer.currentReducerState.authentication_methods ?? [];
+
+ function removeByIndex(index: number): void {
+ if (reducer)
+ reducer.transition("delete_authentication", {
+ authentication_method: index,
+ });
+ }
+
+ const camByType: { [s: string]: AuthMethodWithRemove[] } = {};
+ for (let index = 0; index < configuredAuthMethods.length; index++) {
+ const cam = {
+ ...configuredAuthMethods[index],
+ remove: () => removeByIndex(index),
+ };
+ const prevValue = camByType[cam.type] || [];
+ prevValue.push(cam);
+ camByType[cam.type] = prevValue;
+ }
+
const providers = reducer.currentReducerState.authentication_providers!;
+
const authAvailableSet = new Set<string>();
for (const provKey of Object.keys(providers)) {
const p = providers[provKey];
- if ("http_status" in p && (!("error_code" in p)) && p.methods) {
+ if (p.status === "ok") {
for (const meth of p.methods) {
authAvailableSet.add(meth.type);
}
}
}
+
+ if (manageProvider !== undefined) {
+ return (
+ <AddingProviderScreen
+ onCancel={async () => setManageProvider(undefined)}
+ providerType={
+ isKnownAuthMethods(manageProvider) ? manageProvider : undefined
+ }
+ />
+ );
+ }
+
if (selectedMethod) {
const cancel = (): void => setSelectedMethod(undefined);
const addMethod = (args: any): void => {
reducer.transition("add_authentication", args);
setSelectedMethod(undefined);
};
- const methodMap: Record<
- string, (props: AuthMethodSetupProps) => h.JSX.Element
- > = {
- sms: AuthMethodSmsSetup,
- question: AuthMethodQuestionSetup,
- email: AuthMethodEmailSetup,
- post: AuthMethodPostSetup,
- };
- const AuthSetup = methodMap[selectedMethod] ?? AuthMethodNotImplemented;
+
+ const AuthSetup =
+ authMethods[selectedMethod].setup ?? AuthMethodNotImplemented;
return (
- <AuthSetup
- cancel={cancel}
- addAuthMethod={addMethod}
- method={selectedMethod} />
+ <Fragment>
+ <AuthSetup
+ cancel={cancel}
+ configured={camByType[selectedMethod] || []}
+ addAuthMethod={addMethod}
+ method={selectedMethod}
+ />
+
+ {!authAvailableSet.has(selectedMethod) && (
+ <ConfirmModal
+ active
+ onCancel={cancel}
+ description="No providers found"
+ label="Add a provider manually"
+ onConfirm={async () => {
+ setManageProvider(selectedMethod);
+ }}
+ >
+ <p>
+ We have found no Anastasis providers that support this
+ authentication method. You can add a provider manually. To add a
+ provider you must know the provider URL (e.g.
+ https://provider.com)
+ </p>
+ <p>
+ <a>Learn more about Anastasis providers</a>
+ </p>
+ </ConfirmModal>
+ )}
+ </Fragment>
);
}
- function MethodButton(props: { method: string; label: string }): VNode {
+
+ function MethodButton(props: { method: KnownAuthMethods }): VNode {
+ if (authMethods[props.method].skip) return <div />;
+
return (
- <button
- disabled={!authAvailableSet.has(props.method)}
- onClick={() => {
- setSelectedMethod(props.method);
- if (reducer) reducer.dismissError();
- }}
- >
- {props.label}
- </button>
+ <div class="block">
+ <button
+ style={{ justifyContent: "space-between" }}
+ class="button is-fullwidth"
+ onClick={() => {
+ setSelectedMethod(props.method);
+ }}
+ >
+ <div style={{ display: "flex" }}>
+ <span class="icon ">{authMethods[props.method].icon}</span>
+ {authAvailableSet.has(props.method) ? (
+ <span>Add a {authMethods[props.method].label} challenge</span>
+ ) : (
+ <span>Add a {authMethods[props.method].label} provider</span>
+ )}
+ </div>
+ {!authAvailableSet.has(props.method) && (
+ <span class="icon has-text-danger">
+ <i class="mdi mdi-exclamation-thick" />
+ </span>
+ )}
+ {camByType[props.method] && (
+ <span class="tag is-info">{camByType[props.method].length}</span>
+ )}
+ </button>
+ </div>
);
}
- const configuredAuthMethods: AuthMethod[] = reducer.currentReducerState.authentication_methods ?? [];
- const haveMethodsConfigured = configuredAuthMethods.length;
+ const errors =
+ configuredAuthMethods.length < 2
+ ? "There is not enough authentication methods."
+ : undefined;
+ const handleNext = async () => {
+ const st = reducer.currentReducerState as ReducerStateBackup;
+ if ((st.authentication_methods ?? []).length <= 2) {
+ setTooFewAuths(true);
+ } else {
+ await reducer.transition("next", {});
+ }
+ };
return (
- <AnastasisClientFrame title="Backup: Configure Authentication Methods">
- <div>
- <MethodButton method="sms" label="SMS" />
- <MethodButton method="email" label="Email" />
- <MethodButton method="question" label="Question" />
- <MethodButton method="post" label="Physical Mail" />
- <MethodButton method="totp" label="TOTP" />
- <MethodButton method="iban" label="IBAN" />
- </div>
- <h2>Configured authentication methods</h2>
- {haveMethodsConfigured ? (
- configuredAuthMethods.map((x, i) => {
- return (
- <p key={i}>
- {x.type} ({x.instructions}){" "}
- <button
- onClick={() => reducer.transition("delete_authentication", {
- authentication_method: i,
- })}
- >
- Delete
- </button>
+ <AnastasisClientFrame
+ title="Backup: Configure Authentication Methods"
+ hideNext={errors}
+ onNext={handleNext}
+ >
+ <div class="columns">
+ <div class="column">
+ <div>
+ {getKeys(authMethods).map((method) => (
+ <MethodButton key={method} method={method} />
+ ))}
+ </div>
+ {tooFewAuths ? (
+ <ConfirmModal
+ active={tooFewAuths}
+ onCancel={() => setTooFewAuths(false)}
+ description="Too few auth methods configured"
+ label="Proceed anyway"
+ onConfirm={() => reducer.transition("next", {})}
+ >
+ You have selected fewer than 3 authentication methods. We
+ recommend that you add at least 3.
+ </ConfirmModal>
+ ) : null}
+ {authAvailableSet.size === 0 && (
+ <ConfirmModal
+ active={!noProvidersAck}
+ onCancel={() => setNoProvidersAck(true)}
+ description="No providers found"
+ label="Add a provider manually"
+ onConfirm={async () => {
+ setManageProvider("");
+ }}
+ >
+ <p>
+ We have found no Anastasis providers for your chosen country /
+ currency. You can add a providers manually. To add a provider
+ you must know the provider URL (e.g. https://provider.com)
+ </p>
+ <p>
+ <a>Learn more about Anastasis providers</a>
+ </p>
+ </ConfirmModal>
+ )}
+ </div>
+ <div class="column">
+ <p class="block">
+ When recovering your secret data, you will be asked to verify your
+ identity via the methods you configure here. The list of
+ authentication method is defined by the backup provider list.
+ </p>
+ <p class="block">
+ <button
+ class="button is-info"
+ onClick={() => setManageProvider("")}
+ >
+ Manage backup providers
+ </button>
+ </p>
+ {authAvailableSet.size > 0 && (
+ <p class="block">
+ We couldn't find provider for some of the authentication
+ methods.
</p>
- );
- })
- ) : (
- <p>No authentication methods configured yet.</p>
- )}
+ )}
+ </div>
+ </div>
</AnastasisClientFrame>
);
}
-export interface AuthMethodSetupProps {
- method: string;
- addAuthMethod: (x: any) => void;
- cancel: () => void;
-}
-
function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode {
return (
<AnastasisClientFrame hideNav title={`Add ${props.method} authentication`}>
@@ -115,9 +259,3 @@ function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode {
</AnastasisClientFrame>
);
}
-
-interface AuthenticationEditorProps {
- reducer: AnastasisReducerApi;
- backupState: ReducerStateBackup;
-}
-
diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx
index 65a2b7e13..a51940615 100644
--- a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx
@@ -1,60 +1,67 @@
-/* eslint-disable @typescript-eslint/camelcase */
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ReducerState } from 'anastasis-core';
-import { createExample, reducerStatesExample } from '../../utils';
-import { BackupFinishedScreen as TestedComponent } from './BackupFinishedScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../utils/index.js";
+import { BackupFinishedScreen as TestedComponent } from "./BackupFinishedScreen.js";
+import * as tests from "@gnu-taler/web-util/testing";
export default {
- title: 'Pages/BackupFinishedScreen',
+ title: "Backup finish",
component: TestedComponent,
+ args: {
+ order: 8,
+ },
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const Simple = createExample(TestedComponent, reducerStatesExample.backupFinished);
+export const WithoutName = tests.createExample(
+ TestedComponent,
+ {},
+ reducerStatesExample.backupFinished,
+);
-export const WithName = createExample(TestedComponent, {...reducerStatesExample.backupFinished,
- secret_name: 'super_secret',
+export const WithName = tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.backupFinished,
+ secret_name: "super_secret",
} as ReducerState);
-export const WithDetails = createExample(TestedComponent, {
+export const WithDetails = tests.createExample(TestedComponent, {}, {
...reducerStatesExample.backupFinished,
- secret_name: 'super_secret',
+ secret_name: "super_secret",
success_details: {
- 'http://anastasis.net': {
+ "https://anastasis.demo.taler.net/": {
policy_expiration: {
- t_ms: 'never'
+ t_s: "never",
},
- policy_version: 0
+ policy_version: 0,
},
- 'http://taler.net': {
+ "https://kudos.demo.anastasis.lu/": {
policy_expiration: {
- t_ms: new Date().getTime() + 60*60*24*1000
+ t_s: new Date().getTime() + 60 * 60 * 24,
},
- policy_version: 1
+ policy_version: 1,
},
- }
+ },
} as ReducerState);
diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx
index 218f1d1fd..9b63c9887 100644
--- a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx
@@ -1,33 +1,81 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { AuthenticationProviderStatusOk } from "@gnu-taler/anastasis-core";
+import { format } from "date-fns";
import { h, VNode } from "preact";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame } from "./index";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { AnastasisClientFrame } from "./index.js";
export function BackupFinishedScreen(): VNode {
- const reducer = useAnastasisContext()
+ const reducer = useAnastasisContext();
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
- if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) {
- return <div>invalid state</div>
+ if (reducer.currentReducerState?.reducer_type !== "backup") {
+ return <div>invalid state</div>;
}
- const details = reducer.currentReducerState.success_details
- return (<AnastasisClientFrame hideNext title="Backup finished">
- <p>
- Your backup of secret "{reducer.currentReducerState.secret_name ?? "??"}" was
- successful.
- </p>
- <p>The backup is stored by the following providers:</p>
+ const details = reducer.currentReducerState.success_details;
+ const providers = reducer.currentReducerState.authentication_providers ?? {};
+
+ return (
+ <AnastasisClientFrame hideNav title="Backup success!">
+ <p>Your backup is complete.</p>
- {details && <ul>
- {Object.keys(details).map((x, i) => {
- const sd = details[x];
- return (
- <li key={i}>
- {x} (Policy version {sd.policy_version})
- </li>
- );
- })}
- </ul>}
- <button onClick={() => reducer.reset()}>Back to start</button>
- </AnastasisClientFrame>);
+ {details && (
+ <div class="block">
+ <p>The backup is stored by the following providers:</p>
+ {Object.keys(details).map((url, i) => {
+ const sd = details[url];
+ const p = providers[url] as AuthenticationProviderStatusOk;
+ return (
+ <div key={i} class="box">
+ <a href={url} target="_blank" rel="noreferrer">
+ {p.business_name}
+ </a>
+ <p>
+ version {sd.policy_version}
+ {sd.policy_expiration.t_s !== "never"
+ ? ` expires at: ${format(
+ new Date(sd.policy_expiration.t_s * 1000),
+ "dd-MM-yyyy",
+ )}`
+ : " without expiration date"}
+ </p>
+ </div>
+ );
+ })}
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "center",
+ }}
+ >
+ <p>
+ <div class="buttons ml-4">
+ <button
+ class="button is-primary is-right"
+ onClick={() => reducer.reset()}
+ >
+ Start again
+ </button>
+ </div>
+ </p>
+ </div>
+ </div>
+ )}
+ </AnastasisClientFrame>
+ );
}
diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx
index 4f186c031..84df615f3 100644
--- a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx
@@ -1,83 +1,272 @@
-/* eslint-disable @typescript-eslint/camelcase */
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ReducerState } from 'anastasis-core';
-import { createExample, reducerStatesExample } from '../../utils';
-import { ChallengeOverviewScreen as TestedComponent } from './ChallengeOverviewScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import {
+ ChallengeFeedbackStatus,
+ RecoveryStates,
+ ReducerState,
+} from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../utils/index.js";
+import { ChallengeOverviewScreen as TestedComponent } from "./ChallengeOverviewScreen.js";
+import * as tests from "@gnu-taler/web-util/testing";
+import { AmountString } from "@gnu-taler/taler-util";
export default {
- title: 'Pages/ChallengeOverviewScreen',
+ title: "Challenge overview",
component: TestedComponent,
+ args: {
+ order: 5,
+ },
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const OneChallenge = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting,
+export const OneUnsolvedPolicy = tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.challengeSelecting,
recovery_information: {
- policies: [[{uuid:'1'}]],
- challenges: [{
- cost: 'USD:1',
- instructions: 'just go for it',
- type: 'question',
- uuid: '1',
- }]
+ policies: [[{ uuid: "1" }]],
+ challenges: [
+ {
+ instructions: "just go for it",
+ type: "question",
+ uuid: "1",
+ },
+ ],
},
} as ReducerState);
-export const MoreChallenges = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting,
+export const SomePoliciesOneSolved = tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.challengeSelecting,
recovery_information: {
- policies: [[{uuid:'1'}, {uuid:'2'}],[{uuid:'3'}]],
- challenges: [{
- cost: 'USD:1',
- instructions: 'just go for it',
- type: 'question',
- uuid: '1',
- },{
- cost: 'USD:1',
- instructions: 'just go for it',
- type: 'question',
- uuid: '2',
- },{
- cost: 'USD:1',
- instructions: 'just go for it',
- type: 'question',
- uuid: '3',
- }]
+ policies: [[{ uuid: "1" }, { uuid: "2" }], [{ uuid: "uuid-3" }]],
+ challenges: [
+ {
+ instructions: "this question cost 1 USD",
+ type: "question",
+ uuid: "1",
+ },
+ {
+ instructions: "answering this question is free",
+ type: "question",
+ uuid: "2",
+ },
+ {
+ instructions: "this question is already answered",
+ type: "question",
+ uuid: "uuid-3",
+ },
+ ],
+ },
+ challenge_feedback: {
+ "uuid-3": {
+ state: "solved",
+ },
},
} as ReducerState);
-export const OneBadConfiguredPolicy = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting,
+export const OneBadConfiguredPolicy = tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.challengeSelecting,
recovery_information: {
- policies: [[{uuid:'2'}]],
- challenges: [{
- cost: 'USD:1',
- instructions: 'just go for it',
- type: 'sasd',
- uuid: '1',
- }]
+ policies: [[{ uuid: "1" }, { uuid: "2" }]],
+ challenges: [
+ {
+ instructions: "this policy has a missing uuid (the other auth method)",
+ type: "totp",
+ uuid: "1",
+ },
+ ],
},
} as ReducerState);
-export const NoPolicies = createExample(TestedComponent, reducerStatesExample.challengeSelecting);
+export const OnePolicyWithAllTheChallenges = tests.createExample(
+ TestedComponent,
+ {},
+ {
+ ...reducerStatesExample.challengeSelecting,
+ recovery_information: {
+ policies: [
+ [
+ { uuid: "1" },
+ { uuid: "2" },
+ { uuid: "3" },
+ { uuid: "4" },
+ { uuid: "5" },
+ { uuid: "6" },
+ { uuid: "7" },
+ { uuid: "8" },
+ ],
+ ],
+ challenges: [
+ {
+ instructions: "Does P equals NP?",
+ type: "question",
+ uuid: "1",
+ },
+ {
+ instructions: "SMS to 555-555",
+ type: "sms",
+ uuid: "2",
+ },
+ {
+ instructions: "Email to qwe@asd.com",
+ type: "email",
+ uuid: "3",
+ },
+ {
+ instructions: 'Enter 8 digits code for "Anastasis"',
+ type: "totp",
+ uuid: "4",
+ },
+ {
+ //
+ instructions:
+ "Wire transfer from ASDXCVQWE123123 with holder Florian",
+ type: "iban",
+ uuid: "5",
+ },
+ {
+ instructions: "Join a video call",
+ type: "video", //Enter 8 digits code for "Anastasis"
+ uuid: "7",
+ },
+ {},
+ {
+ instructions: "Letter to address in postal code DE123123",
+ type: "post", //Enter 8 digits code for "Anastasis"
+ uuid: "8",
+ },
+ {
+ instructions: "instruction for an unknown type of challenge",
+ type: "new-type-of-challenge",
+ uuid: "6",
+ },
+ ],
+ },
+ } as ReducerState,
+);
+
+export const OnePolicyWithAllTheChallengesInDifferentState =
+ tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.challengeSelecting,
+ recovery_state: RecoveryStates.ChallengeSelecting,
+ recovery_information: {
+ policies: [
+ [
+ { uuid: "uuid-1" },
+ { uuid: "uuid-2" },
+ { uuid: "uuid-3" },
+ { uuid: "uuid-4" },
+ { uuid: "uuid-5" },
+ { uuid: "uuid-6" },
+ { uuid: "uuid-7" },
+ { uuid: "uuid-8" },
+ { uuid: "uuid-9" },
+ { uuid: "uuid-10" },
+ ],
+ ],
+ challenges: [
+ {
+ instructions: 'this challenge is in state "solved"',
+ type: "question",
+ uuid: "uuid-1",
+ },
+ {
+ instructions: 'this challenge is in state "code-in-file"',
+ type: "question",
+ uuid: "uuid-2",
+ },
+ {
+ instructions: 'this challenge is in state "code-sent"',
+ type: "question",
+ uuid: "uuid-3",
+ },
+ {
+ instructions: 'this challenge is in state "server-failure "',
+ type: "question",
+ uuid: "uuid-4",
+ },
+ {
+ instructions: 'this challenge is in state "truth-unknown"',
+ type: "question",
+ uuid: "uuid-5",
+ },
+ {
+ instructions: 'this challenge is in state "taler-payment"',
+ type: "question",
+ uuid: "uuid-6",
+ },
+ {
+ instructions: 'this challenge is in state "unsupported"',
+ type: "question",
+ uuid: "uuid-7",
+ },
+ {
+ instructions: 'this challenge is in state "rate-limit-exceeded"',
+ type: "question",
+ uuid: "uuid-8",
+ },
+ {
+ instructions: 'this challenge is in state "iban-instructions"',
+ type: "question",
+ uuid: "uuid-9",
+ },
+ {
+ instructions: 'this challenge is in state "incorrect-answer"',
+ type: "question",
+ uuid: "uuid-10",
+ },
+ ],
+ },
+ challenge_feedback: {
+ "uuid-1": { state: ChallengeFeedbackStatus.Solved.toString() },
+ "uuid-2": { state: ChallengeFeedbackStatus.CodeInFile.toString() },
+ "uuid-3": { state: ChallengeFeedbackStatus.CodeSent.toString() },
+ "uuid-4": {
+ state: ChallengeFeedbackStatus.ServerFailure.toString(),
+ http_status: 500,
+ error_response: "some error message or error object",
+ },
+ "uuid-5": { state: ChallengeFeedbackStatus.TruthUnknown.toString() },
+ "uuid-6": {
+ state: ChallengeFeedbackStatus.TalerPayment.toString(),
+ taler_pay_uri: "taler://pay/...",
+ provider: "https://localhost:8080/",
+ payment_secret: "3P4561HAMHRRYEYD6CM6J7TS5VTD5SR2K2EXJDZEFSX92XKHR4KG",
+ },
+ "uuid-7": { state: ChallengeFeedbackStatus.Unsupported.toString() },
+ "uuid-8": { state: ChallengeFeedbackStatus.RateLimitExceeded.toString() },
+ "uuid-9": {
+ state: ChallengeFeedbackStatus.IbanInstructions.toString(),
+ challenge_amount: "EUR:1" as AmountString,
+ target_iban: "DE12345789000",
+ target_business_name: "Data Loss Incorporated",
+ wire_transfer_subject: "Anastasis 987654321",
+ },
+ "uuid-10": { state: ChallengeFeedbackStatus.IncorrectAnswer.toString() },
+ },
+ } as ReducerState);
+export const NoPolicies = tests.createExample(
+ TestedComponent,
+ {},
+ reducerStatesExample.challengeSelecting,
+);
diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx
index c9b52e91b..5b9c11bab 100644
--- a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx
@@ -1,77 +1,293 @@
-import { h, VNode } from "preact";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame } from "./index";
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ ChallengeFeedback,
+ ChallengeFeedbackStatus,
+} from "@gnu-taler/anastasis-core";
+import { Fragment, h, VNode } from "preact";
+import { AsyncButton } from "../../components/AsyncButton.js";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { authMethods, KnownAuthMethods } from "./authMethod/index.js";
+import { AnastasisClientFrame } from "./index.js";
+
+function OverviewFeedbackDisplay(props: {
+ feedback?: ChallengeFeedback;
+}): VNode {
+ const { feedback } = props;
+ if (!feedback) {
+ return <Fragment />;
+ }
+
+ switch (feedback.state) {
+ case ChallengeFeedbackStatus.Solved:
+ return <div />;
+ case ChallengeFeedbackStatus.IbanInstructions:
+ return <div class="block has-text-info">Payment required.</div>;
+ case ChallengeFeedbackStatus.ServerFailure:
+ return <div class="block has-text-danger">Server error.</div>;
+ case ChallengeFeedbackStatus.RateLimitExceeded:
+ return (
+ <div class="block has-text-danger">
+ There were to many failed attempts.
+ </div>
+ );
+ case ChallengeFeedbackStatus.Unsupported:
+ return (
+ <div class="block has-text-danger">
+ This client doesn&apos;t support solving this type of challenge. Use
+ another version or contact the provider.
+ </div>
+ );
+ case ChallengeFeedbackStatus.TruthUnknown:
+ return (
+ <div class="block has-text-danger">
+ Provider doesn&apos;t recognize the type of challenge. Use another
+ version or contact the provider.
+ </div>
+ );
+ case ChallengeFeedbackStatus.IncorrectAnswer:
+ return (
+ <div class="block has-text-danger">The answer was not correct.</div>
+ );
+ case ChallengeFeedbackStatus.CodeInFile:
+ return <div class="block has-text-info">code in file</div>;
+ case ChallengeFeedbackStatus.CodeSent:
+ return <div class="block has-text-info">Code sent</div>;
+ case ChallengeFeedbackStatus.TalerPayment:
+ return <div class="block has-text-info">Payment required</div>;
+ }
+}
export function ChallengeOverviewScreen(): VNode {
- const reducer = useAnastasisContext()
+ const reducer = useAnastasisContext();
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
- if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) {
- return <div>invalid state</div>
+ if (reducer.currentReducerState?.reducer_type !== "recovery") {
+ return <div>invalid state</div>;
}
- const policies = reducer.currentReducerState.recovery_information?.policies ?? [];
- const chArr = reducer.currentReducerState.recovery_information?.challenges ?? [];
- const challengeFeedback = reducer.currentReducerState?.challenge_feedback;
+ const policies =
+ reducer.currentReducerState.recovery_information?.policies ?? [];
+ const knownChallengesArray =
+ reducer.currentReducerState.recovery_information?.challenges ?? [];
+ const challengeFeedback =
+ reducer.currentReducerState?.challenge_feedback ?? {};
- const challenges: {
+ const knownChallengesMap: {
[uuid: string]: {
type: string;
instructions: string;
- cost: string;
+ feedback: ChallengeFeedback | undefined;
};
} = {};
- for (const ch of chArr) {
- challenges[ch.uuid] = {
+ for (const ch of knownChallengesArray) {
+ knownChallengesMap[ch.uuid] = {
type: ch.type,
- cost: ch.cost,
instructions: ch.instructions,
+ feedback: challengeFeedback[ch.uuid],
};
}
+ const policiesWithInfo = policies
+ .map((row) => {
+ let isPolicySolved = true;
+ const challenges = row
+ .map(({ uuid }) => {
+ const info = knownChallengesMap[uuid];
+ const isChallengeSolved = info?.feedback?.state === "solved";
+ isPolicySolved = isPolicySolved && isChallengeSolved;
+ return { info, uuid, isChallengeSolved };
+ })
+ .filter((ch) => ch.info !== undefined);
+
+ return {
+ isPolicySolved,
+ challenges,
+ corrupted: row.length > challenges.length,
+ };
+ })
+ .filter((p) => !p.corrupted);
+
+ const atLeastThereIsOnePolicySolved =
+ policiesWithInfo.find((p) => p.isPolicySolved) !== undefined;
+
+ const errors = !atLeastThereIsOnePolicySolved
+ ? "Solve one policy before proceeding"
+ : undefined;
return (
- <AnastasisClientFrame title="Recovery: Solve challenges">
- <h2>Policies</h2>
- {!policies.length && <p>
- No policies found
- </p>}
- {policies.map((row, i) => {
- return (
- <div key={i}>
- <h3>Policy #{i + 1}</h3>
- {row.map(column => {
- const ch = challenges[column.uuid];
- if (!ch) return <div>
- There is no challenge for this policy
+ <AnastasisClientFrame hideNext={errors} title="Recovery: Solve challenges">
+ {!policiesWithInfo.length ? (
+ <p class="block">
+ No policies found, try with another version of the secret
+ </p>
+ ) : policiesWithInfo.length === 1 ? (
+ <p class="block">
+ One policy found for this secret. You need to solve all the challenges
+ in order to recover your secret.
+ </p>
+ ) : (
+ <p class="block">
+ We have found {policiesWithInfo.length} polices. You need to solve all
+ the challenges from one policy in order to recover your secret.
+ </p>
+ )}
+ {policiesWithInfo.map((policy, policy_index) => {
+ const tableBody = policy.challenges.map(({ info, uuid }) => {
+ const method = authMethods[info.type as KnownAuthMethods];
+
+ if (!method) {
+ return (
+ <div
+ key={uuid}
+ class="block"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <div style={{ display: "flex", alignItems: "center" }}>
+ <span>unknown challenge</span>
+ </div>
</div>
- const feedback = challengeFeedback?.[column.uuid];
+ );
+ }
+
+ function ChallengeButton({
+ id,
+ feedback,
+ }: {
+ id: string;
+ feedback?: ChallengeFeedback;
+ }): VNode {
+ async function selectChallenge(): Promise<void> {
+ if (reducer) {
+ return reducer.transition("select_challenge", { uuid: id });
+ }
+ }
+ if (!feedback) {
return (
- <div key={column.uuid}
- style={{
- borderLeft: "2px solid gray",
- paddingLeft: "0.5em",
- borderRadius: "0.5em",
- marginTop: "0.5em",
- marginBottom: "0.5em",
- }}
- >
- <h4>
- {ch.type} ({ch.instructions})
- </h4>
- <p>Status: {feedback?.state ?? "unknown"}</p>
- {feedback?.state !== "solved" ? (
- <button
- onClick={() => reducer.transition("select_challenge", {
- uuid: column.uuid,
- })}
+ <div>
+ <AsyncButton
+ class="button"
+ disabled={
+ atLeastThereIsOnePolicySolved && !policy.isPolicySolved
+ }
+ onClick={selectChallenge}
+ >
+ Solve
+ </AsyncButton>
+ </div>
+ );
+ }
+ switch (feedback.state) {
+ case ChallengeFeedbackStatus.ServerFailure:
+ case ChallengeFeedbackStatus.Unsupported:
+ case ChallengeFeedbackStatus.TruthUnknown:
+ case ChallengeFeedbackStatus.RateLimitExceeded:
+ return <div />;
+ case ChallengeFeedbackStatus.IbanInstructions:
+ case ChallengeFeedbackStatus.TalerPayment:
+ return (
+ <div>
+ <AsyncButton
+ class="button"
+ disabled={
+ atLeastThereIsOnePolicySolved && !policy.isPolicySolved
+ }
+ onClick={selectChallenge}
+ >
+ Pay
+ </AsyncButton>
+ </div>
+ );
+ case ChallengeFeedbackStatus.Solved:
+ return (
+ <div>
+ <div class="tag is-success is-large">Solved</div>
+ </div>
+ );
+ default:
+ return (
+ <div>
+ <AsyncButton
+ class="button"
+ disabled={
+ atLeastThereIsOnePolicySolved && !policy.isPolicySolved
+ }
+ onClick={selectChallenge}
>
Solve
- </button>
- ) : null}
+ </AsyncButton>
+ </div>
+ );
+ }
+ }
+ return (
+ <div
+ key={uuid}
+ class="block"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ }}
+ >
+ <div style={{ display: "flex", alignItems: "center" }}>
+ <span class="icon">{method?.icon}</span>
+ <span>{info.instructions}</span>
</div>
- );
- })}
+ <OverviewFeedbackDisplay feedback={info.feedback} />
+ </div>
+
+ <ChallengeButton id={uuid} feedback={info.feedback} />
+ </div>
+ );
+ });
+
+ const policyName = policy.challenges
+ .map((x) => x.info.type)
+ .join(" + ");
+
+ const opa = !atLeastThereIsOnePolicySolved
+ ? undefined
+ : policy.isPolicySolved
+ ? undefined
+ : "0.6";
+
+ return (
+ <div
+ key={policy_index}
+ class="box"
+ style={{
+ opacity: opa,
+ }}
+ >
+ <h3 class="subtitle">
+ Policy #{policy_index + 1}: {policyName}
+ </h3>
+ {policy.challenges.length === 0 && (
+ <p>This policy doesn&apos;t have any challenges.</p>
+ )}
+ {policy.challenges.length === 1 && (
+ <p>This policy has one challenge.</p>
+ )}
+ {policy.challenges.length > 1 && (
+ <p>This policy has {policy.challenges.length} challenges.</p>
+ )}
+ {tableBody}
</div>
);
})}
diff --git a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx
new file mode 100644
index 000000000..0489e5a11
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx
@@ -0,0 +1,42 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../utils/index.js";
+import { ChallengePayingScreen as TestedComponent } from "./ChallengePayingScreen.js";
+
+export default {
+ title: "Challenge paying",
+ component: TestedComponent,
+ args: {
+ order: 10,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+export const Example = tests.createExample(
+ TestedComponent,
+ {},
+ reducerStatesExample.challengePaying,
+);
diff --git a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx
new file mode 100644
index 000000000..9f1201797
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx
@@ -0,0 +1,45 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { AnastasisClientFrame } from "./index.js";
+
+export function ChallengePayingScreen(): VNode {
+ const reducer = useAnastasisContext();
+ if (!reducer) {
+ return <div>no reducer in context</div>;
+ }
+ if (reducer.currentReducerState?.reducer_type !== "recovery") {
+ return <div>invalid state</div>;
+ }
+ const payments = [""]; //reducer.currentReducerState.payments ??
+ return (
+ <AnastasisClientFrame hideNav title="Recovery: Challenge Paying">
+ <p>
+ Some of the providers require a payment to store the encrypted
+ authentication information.
+ </p>
+ <ul>
+ {payments.map((x, i) => {
+ return <li key={i}>{x}</li>;
+ })}
+ </ul>
+ <button onClick={() => reducer.transition("pay", {})}>
+ Check payment status now
+ </button>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx b/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx
new file mode 100644
index 000000000..0111815f5
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx
@@ -0,0 +1,84 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { AsyncButton } from "../../components/AsyncButton.js";
+
+export interface ConfirmModelProps {
+ active?: boolean;
+ description?: string;
+ onCancel?: () => void;
+ onConfirm?: () => Promise<void>;
+ label?: string;
+ cancelLabel?: string;
+ children?: ComponentChildren;
+ danger?: boolean;
+ disabled?: boolean;
+}
+
+export function ConfirmModal({
+ active,
+ description,
+ onCancel,
+ onConfirm,
+ children,
+ danger,
+ disabled,
+ label = "Confirm",
+ cancelLabel = "Dismiss",
+}: ConfirmModelProps): VNode {
+ return (
+ <div class={active ? "modal is-active" : "modal"}>
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card" style={{ maxWidth: 700 }}>
+ <header class="modal-card-head">
+ {!description ? null : (
+ <p class="modal-card-title">
+ <b>{description}</b>
+ </p>
+ )}
+ <button class="delete " aria-label="close" onClick={onCancel} />
+ </header>
+ <section class="modal-card-body">{children}</section>
+ <footer class="modal-card-foot">
+ <button class="button" onClick={onCancel}>
+ {cancelLabel}
+ </button>
+ <div
+ class="buttons is-right"
+ style={{ width: "100%" }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape" && onCancel) onCancel();
+ }}
+ >
+ <AsyncButton
+ grabFocus
+ class={danger ? "button is-danger " : "button is-info "}
+ disabled={disabled}
+ onClick={onConfirm}
+ >
+ {label}
+ </AsyncButton>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx
index aad37cd7f..646165341 100644
--- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx
@@ -1,36 +1,59 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createExample, reducerStatesExample } from '../../utils';
-import { ContinentSelectionScreen as TestedComponent } from './ContinentSelectionScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../utils/index.js";
+import { ContinentSelectionScreen as TestedComponent } from "./ContinentSelectionScreen.js";
+import * as tests from "@gnu-taler/web-util/testing";
export default {
- title: 'Pages/ContinentSelectionScreen',
+ title: "Continent selection",
component: TestedComponent,
+ args: {
+ order: 2,
+ },
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const Backup = createExample(TestedComponent, reducerStatesExample.backupSelectCountry);
-export const Recovery = createExample(TestedComponent, reducerStatesExample.recoverySelectCountry);
+export const BackupSelectContinent = tests.createExample(
+ TestedComponent,
+ {},
+ reducerStatesExample.backupSelectContinent,
+);
+
+export const BackupSelectCountry = tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.backupSelectContinent,
+ selected_continent: "Testcontinent",
+} as ReducerState);
+
+export const RecoverySelectContinent = tests.createExample(
+ TestedComponent,
+ {},
+ reducerStatesExample.recoverySelectContinent,
+);
+
+export const RecoverySelectCountry = tests.createExample(TestedComponent, {}, {
+ ...reducerStatesExample.recoverySelectContinent,
+ selected_continent: "Testcontinent",
+} as ReducerState);
diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx
index ad529a4a7..3231e61e4 100644
--- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx
@@ -1,20 +1,159 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
import { h, VNode } from "preact";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame, withProcessLabel } from "./index";
+import { useState } from "preact/hooks";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { AnastasisClientFrame, withProcessLabel } from "./index.js";
export function ContinentSelectionScreen(): VNode {
- const reducer = useAnastasisContext()
- if (!reducer || !reducer.currentReducerState || !("continents" in reducer.currentReducerState)) {
- return <div />
+ const reducer = useAnastasisContext();
+
+ // FIXME: remove this when #7056 is fixed
+ const countryFromReducer =
+ (reducer?.currentReducerState as any).selected_country || "";
+ const [countryCode, setCountryCode] = useState(countryFromReducer);
+
+ if (
+ !reducer ||
+ !reducer.currentReducerState ||
+ !("continents" in reducer.currentReducerState)
+ ) {
+ return <div />;
}
- const sel = (x: string): void => reducer.transition("select_continent", { continent: x });
+ const selectContinent = (continent: string): void => {
+ reducer.transition("select_continent", { continent });
+ };
+ const selectCountry = (country: string): void => {
+ setCountryCode(country);
+ };
+
+ const continentList = reducer.currentReducerState.continents || [];
+ const countryList = reducer.currentReducerState.countries || [];
+ const theContinent = reducer.currentReducerState.selected_continent || "";
+ // const cc = reducer.currentReducerState.selected_country || "";
+ const theCountry = countryList.find((c) => c.code === countryCode);
+ const selectCountryAction = async () => {
+ // selection should be when the select box changes it value
+ if (!theCountry) return;
+ // FIXME: Why is there no await?
+ reducer.transition("select_country", {
+ country_code: countryCode,
+ });
+ };
+
+ // const step1 = reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting ||
+ // reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting;
+
+ // FIXME: i18n
+ const errors = !theCountry ? "Select a country" : undefined;
+
+ const handleBack = async () => {
+ // We want to go to the start, even if we already selected
+ // a country.
+ // FIXME: What if we don't want to lose all information here?
+ // Can we do some kind of soft reset?
+ reducer.reset();
+ };
+
return (
- <AnastasisClientFrame hideNext title={withProcessLabel(reducer, "Select Continent")}>
- {reducer.currentReducerState.continents.map((x: any) => (
- <button onClick={() => sel(x.name)} key={x.name}>
- {x.name}
- </button>
- ))}
+ <AnastasisClientFrame
+ hideNext={errors}
+ title={withProcessLabel(reducer, "Where do you live?")}
+ onNext={selectCountryAction}
+ onBack={handleBack}
+ >
+ <div class="columns">
+ <div class="column is-one-third">
+ <div class="field">
+ <label class="label">Continent</label>
+ <div class="control is-expanded has-icons-left">
+ <div class="select is-fullwidth">
+ <select
+ onChange={(e) => selectContinent(e.currentTarget.value)}
+ value={theContinent}
+ >
+ <option key="none" disabled selected value="">
+ {" "}
+ Choose a continent{" "}
+ </option>
+ {continentList.map((prov) => (
+ <option key={prov.name} value={prov.name}>
+ {prov.name}
+ </option>
+ ))}
+ </select>
+ <div class="icon is-small is-left">
+ <i class="mdi mdi-earth" />
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="field">
+ <label class="label">Country</label>
+ <div class="control is-expanded has-icons-left">
+ <div class="select is-fullwidth">
+ <select
+ onChange={(e) => selectCountry((e.target as any).value)}
+ disabled={!theContinent}
+ value={theCountry?.code || ""}
+ >
+ <option key="none" disabled selected value="">
+ {" "}
+ Choose a country{" "}
+ </option>
+ {countryList.map((prov) => (
+ <option key={prov.name} value={prov.code}>
+ {prov.name}
+ </option>
+ ))}
+ </select>
+ <div class="icon is-small is-left">
+ <i class="mdi mdi-earth" />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="column is-two-third">
+ <p>
+ Your selection will help us ask right information to uniquely
+ identify you when you want to recover your secret again.
+ </p>
+ <p>
+ Choose the country that issued most of your long-term legal
+ documents or personal identifiers.
+ </p>
+ {/* <div
+ style={{
+ border: "1px solid gray",
+ borderRadius: "0.5em",
+ backgroundColor: "#fbfcbd",
+ padding: "0.5em",
+ }}
+ >
+ <p>
+ If you just want to try out Anastasis, we recommend that you
+ choose <b>Testcontinent</b> with <b>Demoland</b>. For this special
+ country, you will be asked for a simple number and not real,
+ personal identifiable information.
+ </p>
+ </div> */}
+ </div>
+ </div>
</AnastasisClientFrame>
);
}
diff --git a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx
deleted file mode 100644
index 555622c1d..000000000
--- a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-/* eslint-disable @typescript-eslint/camelcase */
-import { h, VNode } from "preact";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame, withProcessLabel } from "./index";
-
-export function CountrySelectionScreen(): VNode {
- const reducer = useAnastasisContext()
- if (!reducer) {
- return <div>no reducer in context</div>
- }
- if (!reducer.currentReducerState || !("countries" in reducer.currentReducerState)) {
- return <div>invalid state</div>
- }
- const sel = (x: any): void => reducer.transition("select_country", {
- country_code: x.code,
- currencies: [x.currency],
- });
- return (
- <AnastasisClientFrame hideNext title={withProcessLabel(reducer, "Select Country")} >
- {reducer.currentReducerState.countries.map((x: any) => (
- <button onClick={() => sel(x)} key={x.name}>
- {x.name} ({x.currency})
- </button>
- ))}
- </AnastasisClientFrame>
- );
-}
diff --git a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx
new file mode 100644
index 000000000..3c9fd7f50
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx
@@ -0,0 +1,141 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../utils/index.js";
+import { EditPoliciesScreen as TestedComponent } from "./EditPoliciesScreen.js";
+
+export default {
+ title: "Edit policies",
+ args: {
+ order: 6,
+ },
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+export const EditingAPolicy = tests.createExample(
+ TestedComponent,
+ { index: 0 },
+ {
+ ...reducerStatesExample.policyReview,
+ policies: [
+ {
+ methods: [
+ {
+ authentication_method: 1,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ {
+ authentication_method: 2,
+ provider: "http://localhost:8086/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 1,
+ provider: "http://localhost:8086/",
+ },
+ ],
+ },
+ ],
+ authentication_methods: [
+ {
+ type: "email",
+ instructions: "Email to qwe@asd.com",
+ challenge: "E5VPA",
+ },
+ {
+ type: "totp",
+ instructions: "Response code for 'Anastasis'",
+ challenge: "E5VPA",
+ },
+ {
+ type: "sms",
+ instructions: "SMS to 6666-6666",
+ challenge: "",
+ },
+ {
+ type: "question",
+ instructions: "How did the chicken cross the road?",
+ challenge: "C5SP8",
+ },
+ ],
+ } as ReducerState,
+);
+
+export const CreatingAPolicy = tests.createExample(
+ TestedComponent,
+ { index: 3 },
+ {
+ ...reducerStatesExample.policyReview,
+ policies: [
+ {
+ methods: [
+ {
+ authentication_method: 1,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ {
+ authentication_method: 2,
+ provider: "http://localhost:8086/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 1,
+ provider: "http://localhost:8086/",
+ },
+ ],
+ },
+ ],
+ authentication_methods: [
+ {
+ type: "email",
+ instructions: "Email to qwe@asd.com",
+ challenge: "E5VPA",
+ },
+ {
+ type: "totp",
+ instructions: "Response code for 'Anastasis'",
+ challenge: "E5VPA",
+ },
+ {
+ type: "sms",
+ instructions: "SMS to 6666-6666",
+ challenge: "",
+ },
+ {
+ type: "question",
+ instructions: "How did the chicken cross the road?",
+ challenge: "C5SP8",
+ },
+ ],
+ } as ReducerState,
+);
diff --git a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx
new file mode 100644
index 000000000..24550f89e
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx
@@ -0,0 +1,187 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { authMethods, KnownAuthMethods } from "./authMethod/index.js";
+import { AnastasisClientFrame } from "./index.js";
+
+export interface ProviderInfo {
+ url: string;
+ cost: string;
+ isFree: boolean;
+}
+
+export type ProviderInfoByType = {
+ [type in KnownAuthMethods]?: ProviderInfo[];
+};
+
+interface Props {
+ index: number;
+ cancel: () => void;
+ confirm: (changes: MethodProvider[]) => void;
+}
+
+export interface MethodProvider {
+ authentication_method: number;
+ provider: string;
+}
+
+export function EditPoliciesScreen({
+ index: policy_index,
+ cancel,
+ confirm,
+}: Props): VNode {
+ const [changedProvider, setChangedProvider] = useState<Array<string>>([]);
+
+ const reducer = useAnastasisContext();
+ if (!reducer) {
+ return <div>no reducer in context</div>;
+ }
+ if (reducer.currentReducerState?.reducer_type !== "backup") {
+ return <div>invalid state</div>;
+ }
+
+ const selectableProviders: ProviderInfoByType = {};
+ const allProviders = Object.entries(
+ reducer.currentReducerState.authentication_providers || {},
+ );
+ for (let index = 0; index < allProviders.length; index++) {
+ const [url, status] = allProviders[index];
+ if ("methods" in status) {
+ status.methods.map((m) => {
+ const type: KnownAuthMethods = m.type as KnownAuthMethods;
+ const values = selectableProviders[type] || [];
+ const isFree = !m.usage_fee || m.usage_fee.endsWith(":0");
+ values.push({ url, cost: m.usage_fee, isFree });
+ selectableProviders[type] = values;
+ });
+ }
+ }
+
+ const allAuthMethods =
+ reducer.currentReducerState.authentication_methods ?? [];
+ const policies = reducer.currentReducerState.policies ?? [];
+ const policy = policies[policy_index];
+
+ for (
+ let method_index = 0;
+ method_index < allAuthMethods.length;
+ method_index++
+ ) {
+ policy?.methods.find((m) => m.authentication_method === method_index)
+ ?.provider;
+ }
+
+ function sendChanges(): void {
+ const newMethods: MethodProvider[] = [];
+ allAuthMethods.forEach((method, index) => {
+ const oldValue = policy?.methods.find(
+ (m) => m.authentication_method === index,
+ );
+ if (changedProvider[index] === undefined && oldValue !== undefined) {
+ newMethods.push(oldValue);
+ }
+ if (
+ changedProvider[index] !== undefined &&
+ changedProvider[index] !== ""
+ ) {
+ newMethods.push({
+ authentication_method: index,
+ provider: changedProvider[index],
+ });
+ }
+ });
+ confirm(newMethods);
+ }
+
+ return (
+ <AnastasisClientFrame
+ hideNav
+ title={!policy ? "Backup: New Policy" : "Backup: Edit Policy"}
+ >
+ <section class="section">
+ {!policy ? (
+ <p>Creating a new policy #{policy_index}</p>
+ ) : (
+ <p>Editing policy #{policy_index}</p>
+ )}
+ {allAuthMethods.map((method, index) => {
+ //take the url from the updated change or from the policy
+ const providerURL =
+ changedProvider[index] === undefined
+ ? policy?.methods.find((m) => m.authentication_method === index)
+ ?.provider
+ : changedProvider[index];
+
+ const type: KnownAuthMethods = method.type as KnownAuthMethods;
+ function changeProviderTo(url: string): void {
+ const copy = [...changedProvider];
+ copy[index] = url;
+ setChangedProvider(copy);
+ }
+ return (
+ <div
+ key={index}
+ class="block"
+ style={{ display: "flex", alignItems: "center" }}
+ >
+ <span class="icon">{authMethods[type]?.icon}</span>
+ <span>{method.instructions}</span>
+ <span>
+ <span class="select ">
+ <select
+ onChange={(e) => changeProviderTo(e.currentTarget.value)}
+ value={providerURL ?? ""}
+ >
+ <option key="none" value="">
+ {" "}
+ &lt;&lt; off &gt;&gt;{" "}
+ </option>
+ {selectableProviders[type]?.map((prov) => (
+ <option key={prov.url} value={prov.url}>
+ {prov.url}
+ </option>
+ ))}
+ </select>
+ </span>
+ </span>
+ </div>
+ );
+ })}
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={cancel}>
+ Cancel
+ </button>
+ <span class="buttons">
+ <button class="button" onClick={() => setChangedProvider([])}>
+ Reset
+ </button>
+ <button class="button is-info" onClick={sendChanges}>
+ Confirm
+ </button>
+ </span>
+ </div>
+ </section>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx
index 1a9462b88..ea88b74a0 100644
--- a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx
@@ -1,47 +1,56 @@
-/* eslint-disable @typescript-eslint/camelcase */
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ReducerState } from 'anastasis-core';
-import { createExample, reducerStatesExample } from '../../utils';
-import { PoliciesPayingScreen as TestedComponent } from './PoliciesPayingScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../utils/index.js";
+import { PoliciesPayingScreen as TestedComponent } from "./PoliciesPayingScreen.js";
+import * as tests from "@gnu-taler/web-util/testing";
export default {
- title: 'Pages/PoliciesPayingScreen',
+ title: "Policies paying",
component: TestedComponent,
+ args: {
+ order: 9,
+ },
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const Example = createExample(TestedComponent, reducerStatesExample.policyPay);
-export const WithSomePaymentRequest = createExample(TestedComponent, {
+export const Example = tests.createExample(
+ TestedComponent,
+ {},
+ reducerStatesExample.policyPay,
+);
+export const WithSomePaymentRequest = tests.createExample(TestedComponent, {}, {
...reducerStatesExample.policyPay,
- policy_payment_requests: [{
- payto: 'payto://x-taler-bank/bank.taler/account-a',
- provider: 'provider1'
- }, {
- payto: 'payto://x-taler-bank/bank.taler/account-b',
- provider: 'provider2'
- }]
+ policy_payment_requests: [
+ {
+ payto: "payto://x-taler-bank/bank.taler/account-a",
+ provider: "provider1",
+ },
+ {
+ payto: "payto://x-taler-bank/bank.taler/account-b",
+ provider: "provider2",
+ },
+ ],
} as ReducerState);
diff --git a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx
index 8a39cf0e4..c48236b9d 100644
--- a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx
@@ -1,22 +1,37 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
import { h, VNode } from "preact";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame } from "./index";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { AnastasisClientFrame } from "./index.js";
export function PoliciesPayingScreen(): VNode {
- const reducer = useAnastasisContext()
+ const reducer = useAnastasisContext();
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
- if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) {
- return <div>invalid state</div>
+ if (reducer.currentReducerState?.reducer_type !== "backup") {
+ return <div>invalid state</div>;
}
const payments = reducer.currentReducerState.policy_payment_requests ?? [];
-
+
return (
- <AnastasisClientFrame hideNext title="Backup: Recovery Document Payments">
+ <AnastasisClientFrame hideNav title="Backup: Recovery Document Payments">
<p>
- Some of the providers require a payment to store the encrypted
- recovery document.
+ Some of the providers require a payment to store the encrypted recovery
+ document.
</p>
<ul>
{payments.map((x, i) => {
diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx
index 0c1842420..97e0821fd 100644
--- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx
@@ -1,42 +1,57 @@
-/* eslint-disable @typescript-eslint/camelcase */
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ReducerState } from 'anastasis-core';
-import { createExample, reducerStatesExample } from '../../utils';
-import { RecoveryFinishedScreen as TestedComponent } from './RecoveryFinishedScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util";
+import { reducerStatesExample } from "../../utils/index.js";
+import { RecoveryFinishedScreen as TestedComponent } from "./RecoveryFinishedScreen.js";
+import * as tests from "@gnu-taler/web-util/testing";
export default {
- title: 'Pages/RecoveryFinishedScreen',
+ title: "Recovery Finished",
+ args: {
+ order: 7,
+ },
component: TestedComponent,
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const NormalEnding = createExample(TestedComponent, {
+export const GoodEnding = tests.createExample(TestedComponent, {}, {
...reducerStatesExample.recoveryFinished,
- core_secret: { mime: 'text/plain', value: 'hello' }
+ recovery_document: {
+ secret_name: "the_name_of_the_secret",
+ },
+ core_secret: {
+ mime: "text/plain",
+ value: encodeCrock(
+ stringToBytes("hello this is my secret, don't tell anybody"),
+ ),
+ },
} as ReducerState);
-export const BadEnding = createExample(TestedComponent, reducerStatesExample.recoveryFinished);
+export const BadEnding = tests.createExample(
+ TestedComponent,
+ {},
+ reducerStatesExample.recoveryFinished,
+);
diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx
index 8c8a2c7c8..62ac410a2 100644
--- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx
@@ -1,34 +1,124 @@
-import {
- bytesToString,
- decodeCrock
-} from "@gnu-taler/taler-util";
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { bytesToString, decodeCrock } from "@gnu-taler/taler-util";
import { h, VNode } from "preact";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame } from "./index";
+import { useEffect, useState } from "preact/hooks";
+import { QR } from "../../components/QR.js";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { AnastasisClientFrame } from "./index.js";
export function RecoveryFinishedScreen(): VNode {
- const reducer = useAnastasisContext()
+ const reducer = useAnastasisContext();
+ const [copied, setCopied] = useState(false);
+ useEffect(() => {
+ setTimeout(() => {
+ setCopied(false);
+ }, 1000);
+ }, [copied]);
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
- if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) {
- return <div>invalid state</div>
+ if (reducer.currentReducerState?.reducer_type !== "recovery") {
+ return <div>invalid state</div>;
}
- const encodedSecret = reducer.currentReducerState.core_secret?.value
+ const secretName = reducer.currentReducerState.recovery_document?.secret_name;
+ const encodedSecret = reducer.currentReducerState.core_secret;
if (!encodedSecret) {
- return <AnastasisClientFrame title="Recovery Problem" hideNext>
- <p>
- Secret not found
- </p>
- </AnastasisClientFrame>
+ return (
+ <AnastasisClientFrame title="Recovery Problem" hideNav>
+ <p>Secret not found</p>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={() => reducer.back()}>
+ Back
+ </button>
+ </div>
+ </AnastasisClientFrame>
+ );
}
- const secret = bytesToString(decodeCrock(encodedSecret))
+ const secret = bytesToString(decodeCrock(encodedSecret.value));
+ const plainText =
+ encodedSecret.value.length < 1000 && encodedSecret.mime === "text/plain";
+ const contentURI = !plainText
+ ? secret
+ : `data:${encodedSecret.mime},${secret}`;
return (
- <AnastasisClientFrame title="Recovery Finished" hideNext>
- <p>
- Secret: {secret}
- </p>
+ <AnastasisClientFrame title="Recovery Success" hideNav>
+ <h2 class="subtitle">Your secret was recovered</h2>
+ {secretName && (
+ <p class="block">
+ <b>Secret name:</b> {secretName}
+ </p>
+ )}
+ <div class="block buttons" disabled={copied}>
+ {plainText ? (
+ <button
+ class="button"
+ onClick={() => {
+ navigator.clipboard.writeText(secret);
+ setCopied(true);
+ }}
+ >
+ {!copied ? "Copy" : "Copied"}
+ </button>
+ ) : undefined}
+
+ <a
+ class="button is-info"
+ download={
+ encodedSecret.filename ? encodedSecret.filename : "secret.file"
+ }
+ href={contentURI}
+ >
+ <div class="icon is-small ">
+ <i class="mdi mdi-download" />
+ </div>
+ <span>Download content</span>
+ </a>
+ </div>
+
+ {plainText ? (
+ <div class="block">
+ <QR text={secret} />
+ </div>
+ ) : undefined}
+
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "center",
+ }}
+ >
+ <p>
+ <div class="buttons ml-4">
+ <button
+ class="button is-primary is-right"
+ onClick={() => reducer.reset()}
+ >
+ Start again
+ </button>
+ </div>
+ </p>
+ </div>
</AnastasisClientFrame>
);
}
diff --git a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx
index b52699e7b..71144917a 100644
--- a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx
@@ -1,81 +1,270 @@
-/* eslint-disable @typescript-eslint/camelcase */
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ReducerState } from 'anastasis-core';
-import { createExample, reducerStatesExample } from '../../utils';
-import { ReviewPoliciesScreen as TestedComponent } from './ReviewPoliciesScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../utils/index.js";
+import { ReviewPoliciesScreen as TestedComponent } from "./ReviewPoliciesScreen.js";
+import * as tests from "@gnu-taler/web-util/testing";
export default {
- title: 'Pages/ReviewPoliciesScreen',
+ title: "Reviewing Policies",
+ args: {
+ order: 6,
+ },
component: TestedComponent,
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const HasPoliciesButMethodListIsEmpty = createExample(TestedComponent, {
- ...reducerStatesExample.policyReview,
- policies: [{
- methods: [{
- authentication_method: 0,
- provider: 'asd'
- },{
- authentication_method: 1,
- provider: 'asd'
- }]
- },{
- methods: [{
- authentication_method: 1,
- provider: 'asd'
- }]
- }],
- authentication_methods: []
-} as ReducerState);
+export const HasPoliciesButMethodListIsEmpty = tests.createExample(
+ TestedComponent,
+ {},
+ {
+ ...reducerStatesExample.policyReview,
+ policies: [
+ {
+ methods: [
+ {
+ authentication_method: 0,
+ provider: "asd",
+ },
+ {
+ authentication_method: 1,
+ provider: "asd",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 1,
+ provider: "asd",
+ },
+ ],
+ },
+ ],
+ authentication_methods: [],
+ } as ReducerState,
+);
-export const SomePoliciesWithMethods = createExample(TestedComponent, {
- ...reducerStatesExample.policyReview,
- policies: [{
- methods: [{
- authentication_method: 0,
- provider: 'asd'
- },{
- authentication_method: 1,
- provider: 'asd'
- }]
- },{
- methods: [{
- authentication_method: 1,
- provider: 'asd'
- }]
- }],
- authentication_methods: [{
- challenge: 'asd',
- instructions: 'ins',
- type: 'type',
- },{
- challenge: 'asd2',
- instructions: 'ins2',
- type: 'type2',
- }]
-} as ReducerState);
+export const SomePoliciesWithMethods = tests.createExample(
+ TestedComponent,
+ {},
+ {
+ ...reducerStatesExample.policyReview,
+ policies: [
+ {
+ methods: [
+ {
+ authentication_method: 0,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 1,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 2,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 0,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 1,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 3,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 0,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 1,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 4,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 0,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 2,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 3,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 0,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 2,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 4,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 0,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 3,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ {
+ authentication_method: 4,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 1,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 2,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 3,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 1,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 2,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 4,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 1,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 3,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ {
+ authentication_method: 4,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ ],
+ },
+ {
+ methods: [
+ {
+ authentication_method: 2,
+ provider: "https://kudos.demo.anastasis.lu/",
+ },
+ {
+ authentication_method: 3,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ {
+ authentication_method: 4,
+ provider: "https://anastasis.demo.taler.net/",
+ },
+ ],
+ },
+ ],
+ authentication_methods: [
+ {
+ type: "email",
+ instructions: "Email to qwe@asd.com",
+ challenge: "E5VPA",
+ },
+ {
+ type: "sms",
+ instructions: "SMS to 555-555",
+ challenge: "",
+ },
+ {
+ type: "question",
+ instructions: "Does P equal NP?",
+ challenge: "C5SP8",
+ },
+ {
+ type: "totp",
+ instructions: "Response code for 'Anastasis'",
+ challenge: "E5VPA",
+ },
+ {
+ type: "sms",
+ instructions: "SMS to 6666-6666",
+ challenge: "",
+ },
+ {
+ type: "question",
+ instructions: "How did the chicken cross the road?",
+ challenge: "C5SP8",
+ },
+ ],
+ } as ReducerState,
+);
diff --git a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx
index b360ccaf0..3755dac9c 100644
--- a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx
@@ -1,51 +1,160 @@
-/* eslint-disable @typescript-eslint/camelcase */
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { AuthenticationProviderStatusOk } from "@gnu-taler/anastasis-core";
import { h, VNode } from "preact";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame } from "./index";
+import { useState } from "preact/hooks";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { authMethods, KnownAuthMethods } from "./authMethod/index.js";
+import { EditPoliciesScreen } from "./EditPoliciesScreen.js";
+import { AnastasisClientFrame } from "./index.js";
export function ReviewPoliciesScreen(): VNode {
- const reducer = useAnastasisContext()
+ const [editingPolicy, setEditingPolicy] = useState<number | undefined>();
+ const reducer = useAnastasisContext();
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
- if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) {
- return <div>invalid state</div>
+ if (reducer.currentReducerState?.reducer_type !== "backup") {
+ return <div>invalid state</div>;
}
- const authMethods = reducer.currentReducerState.authentication_methods ?? [];
+
+ const configuredAuthMethods =
+ reducer.currentReducerState.authentication_methods ?? [];
const policies = reducer.currentReducerState.policies ?? [];
+ const providers = reducer.currentReducerState.authentication_providers ?? {};
+
+ if (editingPolicy !== undefined) {
+ return (
+ <EditPoliciesScreen
+ index={editingPolicy}
+ cancel={() => setEditingPolicy(undefined)}
+ confirm={async (newMethods) => {
+ await reducer.transition("update_policy", {
+ policy_index: editingPolicy,
+ policy: newMethods,
+ });
+ setEditingPolicy(undefined);
+ }}
+ />
+ );
+ }
+
+ const errors = policies.length < 1 ? "Need more policies" : undefined;
return (
- <AnastasisClientFrame title="Backup: Review Recovery Policies">
+ <AnastasisClientFrame
+ hideNext={errors}
+ title="Backup: Review Recovery Policies"
+ >
+ {policies.length > 0 && (
+ <p class="block">
+ Based on your configured authentication method you have created, some
+ policies have been configured. In order to recover your secret you
+ have to solve all the challenges of at least one policy.
+ </p>
+ )}
+ {policies.length < 1 && (
+ <p class="block">
+ No policies had been created. Go back and add more authentication
+ methods.
+ </p>
+ )}
+ <div class="block">
+ <button
+ class="button is-success"
+ style={{ marginLeft: 10 }}
+ onClick={() => setEditingPolicy(policies.length)}
+ >
+ Add new policy
+ </button>
+ </div>
{policies.map((p, policy_index) => {
const methods = p.methods
- .map(x => authMethods[x.authentication_method] && ({ ...authMethods[x.authentication_method], provider: x.provider }))
- .filter(x => !!x)
+ .map(
+ (x) =>
+ configuredAuthMethods[x.authentication_method] && {
+ ...configuredAuthMethods[x.authentication_method],
+ provider: x.provider,
+ },
+ )
+ .filter((x) => !!x);
- const policyName = methods.map(x => x.type).join(" + ");
+ const policyName = methods.map((x) => x.type).join(" + ");
+
+ if (p.methods.length > methods.length) {
+ //there is at least one authentication method that is corrupted
+ return null;
+ }
return (
- <div key={policy_index} class="policy">
- <h3>
- Policy #{policy_index + 1}: {policyName}
- </h3>
- Required Authentications:
- {!methods.length && <p>
- No auth method found
- </p>}
- <ul>
+ <div
+ key={policy_index}
+ class="box"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <div>
+ <h3 class="subtitle">
+ Policy #{policy_index + 1}: {policyName}
+ </h3>
+ {!methods.length && <p>No auth method found</p>}
{methods.map((m, i) => {
+ const p = providers[
+ m.provider
+ ] as AuthenticationProviderStatusOk;
return (
- <li key={i}>
- {m.type} ({m.instructions}) at provider {m.provider}
- </li>
+ <p
+ key={i}
+ class="block"
+ style={{ display: "flex", alignItems: "center" }}
+ >
+ <span class="icon">
+ {authMethods[m.type as KnownAuthMethods]?.icon}
+ </span>
+ <span>
+ {m.instructions} recovery provided by{" "}
+ <a href={m.provider} target="_blank" rel="noreferrer">
+ {p.business_name}
+ </a>
+ </span>
+ </p>
);
})}
- </ul>
- <div>
+ </div>
+ <div
+ style={{
+ marginTop: "auto",
+ marginBottom: "auto",
+ display: "flex",
+ justifyContent: "space-between",
+ flexDirection: "column",
+ }}
+ >
+ <button
+ class="button is-info block"
+ onClick={() => setEditingPolicy(policy_index)}
+ >
+ Edit
+ </button>
<button
- onClick={() => reducer.transition("delete_policy", { policy_index })}
+ class="button is-danger block"
+ onClick={() =>
+ reducer.transition("delete_policy", { policy_index })
+ }
>
- Delete Policy
+ Delete
</button>
</div>
</div>
diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx
index 18560356a..24bbb2927 100644
--- a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx
@@ -1,44 +1,50 @@
-/* eslint-disable @typescript-eslint/camelcase */
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ReducerState } from 'anastasis-core';
-import { createExample, reducerStatesExample } from '../../utils';
-import { SecretEditorScreen as TestedComponent } from './SecretEditorScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../utils/index.js";
+import { SecretEditorScreen as TestedComponent } from "./SecretEditorScreen.js";
+import * as tests from "@gnu-taler/web-util/testing";
export default {
- title: 'Pages/SecretEditorScreen',
+ title: "Secret editor",
component: TestedComponent,
+ args: {
+ order: 7,
+ },
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const WithSecretNamePreselected = createExample(TestedComponent, {
- ...reducerStatesExample.secretEdition,
- secret_name: 'someSecretName',
-} as ReducerState);
+export const WithSecretNamePreselected = tests.createExample(
+ TestedComponent,
+ {},
+ {
+ ...reducerStatesExample.secretEdition,
+ secret_name: "someSecretName",
+ } as ReducerState,
+);
-export const WithoutName = createExample(TestedComponent, {
+export const WithoutName = tests.createExample(TestedComponent, {}, {
...reducerStatesExample.secretEdition,
} as ReducerState);
diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx
index a5235d66c..93a27837c 100644
--- a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx
@@ -1,65 +1,124 @@
-/* eslint-disable @typescript-eslint/camelcase */
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util";
import { h, VNode } from "preact";
import { useState } from "preact/hooks";
-import { useAnastasisContext } from "../../context/anastasis";
import {
- AnastasisClientFrame,
- LabeledInput
-} from "./index";
+ FileInput,
+ FileTypeContent,
+} from "../../components/fields/FileInput.js";
+import { TextInput } from "../../components/fields/TextInput.js";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { AnastasisClientFrame } from "./index.js";
export function SecretEditorScreen(): VNode {
- const reducer = useAnastasisContext()
+ const reducer = useAnastasisContext();
const [secretValue, setSecretValue] = useState("");
+ const [secretFile, _setSecretFile] = useState<FileTypeContent | undefined>(
+ undefined,
+ );
+ function setSecretFile(v: FileTypeContent | undefined): void {
+ setSecretValue(""); // reset secret value when uploading a file
+ _setSecretFile(v);
+ }
- const currentSecretName = reducer?.currentReducerState
- && ("secret_name" in reducer.currentReducerState)
- && reducer.currentReducerState.secret_name;
+ const currentSecretName =
+ reducer?.currentReducerState &&
+ "secret_name" in reducer.currentReducerState &&
+ reducer.currentReducerState.secret_name;
const [secretName, setSecretName] = useState(currentSecretName || "");
-
+
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
- if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) {
- return <div>invalid state</div>
+ if (reducer.currentReducerState?.reducer_type !== "backup") {
+ return <div>invalid state</div>;
}
- const secretNext = (): void => {
- reducer.runTransaction(async (tx) => {
+ const secretNext = async (): Promise<void> => {
+ const secret = secretFile
+ ? {
+ value: encodeCrock(stringToBytes(secretFile.content)),
+ filename: secretFile.name,
+ mime: secretFile.type,
+ }
+ : {
+ value: encodeCrock(stringToBytes(secretValue)),
+ mime: "text/plain",
+ };
+ return reducer.runTransaction(async (tx) => {
await tx.transition("enter_secret_name", {
name: secretName,
});
await tx.transition("enter_secret", {
- secret: {
- value: encodeCrock(stringToBytes(secretValue)),
- mime: "text/plain",
- },
+ secret,
expiration: {
- t_ms: new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 5,
+ t_s: new Date().getTime() + 60 * 60 * 24 * 365 * 5,
},
});
await tx.transition("next", {});
});
};
+ const errors = !secretName
+ ? "Add a secret name"
+ : !secretValue && !secretFile
+ ? "Add a secret value or a choose a file to upload"
+ : undefined;
+ function goNextIfNoErrors(): void {
+ if (!errors) secretNext();
+ }
return (
<AnastasisClientFrame
- title="Backup: Provide secret"
+ hideNext={errors}
+ title="Backup: Provide secret to backup"
onNext={() => secretNext()}
>
- <div>
- <LabeledInput
- label="Secret Name:"
+ <div class="block">
+ <TextInput
+ label="Secret name:"
+ tooltip="This allows you to uniquely identify a secret if you have made multiple back ups. The value entered here will NOT be protected by the authentication checks!"
grabFocus
+ onConfirm={goNextIfNoErrors}
bind={[secretName, setSecretName]}
/>
+ <div>
+ Names should be unique, so that you can easily identify your secret
+ later.
+ </div>
</div>
- <div>
- <LabeledInput
- label="Secret Value:"
+ <div class="block">
+ <TextInput
+ inputType="multiline"
+ disabled={!!secretFile}
+ onConfirm={goNextIfNoErrors}
+ label="Enter the secret as text:"
bind={[secretValue, setSecretValue]}
/>
</div>
+ <div class="block">
+ Or upload a secret file
+ <FileInput label="Choose file" onChange={setSecretFile} />
+ {secretFile && (
+ <div>
+ Uploading secret file <b>{secretFile.name}</b>{" "}
+ <a onClick={() => setSecretFile(undefined)}>cancel</a>
+ </div>
+ )}
+ </div>
</AnastasisClientFrame>
);
}
diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx
index e9c597023..fb3b26e15 100644
--- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx
@@ -1,50 +1,83 @@
-/* eslint-disable @typescript-eslint/camelcase */
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ReducerState } from 'anastasis-core';
-import { createExample, reducerStatesExample } from '../../utils';
-import { SecretSelectionScreen as TestedComponent } from './SecretSelectionScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../utils/index.js";
+import {
+ SecretSelectionScreen,
+ SecretSelectionScreenFound,
+} from "./SecretSelectionScreen.js";
export default {
- title: 'Pages/SecretSelectionScreen',
- component: TestedComponent,
- argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ title: "Secret selection",
+ component: SecretSelectionScreen,
+ args: {
+ order: 4,
},
};
-export const Example = createExample(TestedComponent, {
- ...reducerStatesExample.secretSelection,
- recovery_document: {
- provider_url: 'http://anastasis.url/',
- secret_name: 'secretName',
- version: 1,
+export const Example = tests.createExample(
+ SecretSelectionScreenFound,
+ {
+ policies: [
+ {
+ secret_name: "The secret name 1",
+ attribute_mask: 1,
+ policy_hash: "abcdefghijklmnopqrstuvwxyz",
+ providers: [
+ {
+ url: "http://someurl",
+ version: 1,
+ },
+ ],
+ },
+ {
+ secret_name: "The secret name 2",
+ attribute_mask: 1,
+ policy_hash: "abcdefghijklmnopqrstuvwxyz",
+ providers: [
+ {
+ url: "http://someurl",
+ version: 1,
+ },
+ ],
+ },
+ ],
},
-} as ReducerState);
-
+ {
+ ...reducerStatesExample.secretSelection,
+ recovery_document: {
+ provider_url: "https://kudos.demo.anastasis.lu/",
+ secret_name: "secretName",
+ version: 1,
+ },
+ } as ReducerState,
+);
-export const NoRecoveryDocumentFound = createExample(TestedComponent, {
- ...reducerStatesExample.secretSelection,
- recovery_document: undefined,
-} as ReducerState);
+export const NoRecoveryDocumentFound = tests.createExample(
+ SecretSelectionScreen,
+ {},
+ {
+ ...reducerStatesExample.secretSelection,
+ recovery_document: undefined,
+ } as ReducerState,
+);
diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx
index 903f57868..ce44b0884 100644
--- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx
@@ -1,87 +1,452 @@
-/* eslint-disable @typescript-eslint/camelcase */
-import { h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame } from "./index";
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
-export function SecretSelectionScreen(): VNode {
- const [selectingVersion, setSelectingVersion] = useState<boolean>(false);
- const [otherProvider, setOtherProvider] = useState<string>("");
- const reducer = useAnastasisContext()
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
- const currentVersion = reducer?.currentReducerState
- && ("recovery_document" in reducer.currentReducerState)
- && reducer.currentReducerState.recovery_document?.version;
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- const [otherVersion, setOtherVersion] = useState<number>(currentVersion || 0);
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AggregatedPolicyMetaInfo,
+ AuthenticationProviderStatus,
+ AuthenticationProviderStatusOk,
+} from "@gnu-taler/anastasis-core";
+import { h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { AsyncButton } from "../../components/AsyncButton.js";
+import { PhoneNumberInput } from "../../components/fields/NumberInput.js";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import AddingProviderScreen from "./AddingProviderScreen/index.js";
+import { AnastasisClientFrame } from "./index.js";
+export function SecretSelectionScreenFound({
+ policies,
+ onManageProvider,
+ onNext,
+}: {
+ policies: AggregatedPolicyMetaInfo[];
+ onManageProvider: () => void;
+ onNext: (version: AggregatedPolicyMetaInfo) => void;
+}): VNode {
+ const reducer = useAnastasisContext();
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
- if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) {
- return <div>invalid state</div>
+
+ if (
+ !reducer.currentReducerState ||
+ reducer.currentReducerState.reducer_type !== "recovery"
+ ) {
+ return <div>invalid state</div>;
}
+ return (
+ <AnastasisClientFrame
+ title="Recovery: Select secret"
+ hideNext="Please select version to recover"
+ >
+ <div class="columns">
+ <div class="column">
+ <p class="block">Found versions:</p>
+ {policies.map((version, i) => (
+ <div key={i} class="box">
+ <div
+ class="block"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ }}
+ >
+ <div style={{ display: "flex", alignItems: "center" }}>
+ <b>Name:</b>&nbsp;<span>{version.secret_name}</span>
+ </div>
+ <div style={{ display: "flex", alignItems: "center" }}>
+ <b>Id:</b>&nbsp;
+ <span
+ class="icon has-tooltip-top"
+ data-tooltip={version.policy_hash}
+ >
+ <i class="mdi mdi-information" />
+ </span>
+ <span>{version.policy_hash.substring(0, 22)}...</span>
+ </div>
+ </div>
+
+ <div>
+ <AsyncButton
+ class="button"
+ onClick={async () => onNext(version)}
+ >
+ Recover
+ </AsyncButton>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ <div class="column">
+ <p>
+ Secret found, you can select another version or continue to the
+ challenges solving
+ </p>
+ <p class="block">
+ <a onClick={onManageProvider}>Manage recovery providers</a>
+ </p>
+ </div>
+ </div>
+ </AnastasisClientFrame>
+ );
+}
+
+export function SecretSelectionScreen(): VNode {
+ const reducer = useAnastasisContext();
+ const [manageProvider, setManageProvider] = useState(false);
+
+ useEffect(() => {
+ async function f() {
+ if (reducer) {
+ await reducer.discoverStart();
+ }
+ }
+ f().catch((e) => console.log(e));
+ }, []);
- function selectVersion(p: string, n: number): void {
- if (!reducer) return;
- reducer.runTransaction(async (tx) => {
- await tx.transition("change_version", {
- version: n,
- provider_url: p,
- });
- setSelectingVersion(false);
- });
+ if (!reducer) {
+ return <div>no reducer in context</div>;
}
- const recoveryDocument = reducer.currentReducerState.recovery_document
- if (!recoveryDocument) {
+ if (
+ !reducer.currentReducerState ||
+ reducer.currentReducerState.reducer_type !== "recovery"
+ ) {
+ return <div>invalid state</div>;
+ }
+
+ if (manageProvider) {
return (
- <AnastasisClientFrame hideNav title="Recovery: Problem">
- <p>No recovery document found</p>
- </AnastasisClientFrame>
- )
+ <AddingProviderScreen onCancel={async () => setManageProvider(false)} />
+ );
+ }
+
+ if (
+ reducer.discoveryState.state === "none" ||
+ reducer.discoveryState.state === "active"
+ ) {
+ // Can this even happen?
+ return <SecretSelectionScreenWaiting />;
}
- if (selectingVersion) {
+
+ const policies = reducer.discoveryState.aggregatedPolicies ?? [];
+
+ if (policies.length === 0) {
return (
- <AnastasisClientFrame hideNav title="Recovery: Select secret">
- <p>Select a different version of the secret</p>
- <select onChange={(e) => setOtherProvider((e.target as any).value)}>
- {Object.keys(reducer.currentReducerState.authentication_providers ?? {}).map(
- (x, i) => (
- <option key={i} selected={x === recoveryDocument.provider_url} value={x}>
- {x}
+ <AddingProviderScreen
+ onCancel={async () => setManageProvider(false)}
+ notifications={[
+ {
+ message: "Secret not found",
+ type: "ERROR",
+ description:
+ "With the information you provided we could not found secret in any of the providers. You can try adding more providers if you think the data is correct.",
+ },
+ ]}
+ />
+ );
+ }
+
+ return (
+ <SecretSelectionScreenFound
+ policies={policies}
+ onNext={(version) => reducer.transition("select_version", version)}
+ onManageProvider={async () => setManageProvider(false)}
+ />
+ );
+}
+
+// export function OldSecretSelectionScreen(): VNode {
+// const [selectingVersion, setSelectingVersion] = useState<boolean>(false);
+// const reducer = useAnastasisContext();
+// const [manageProvider, setManageProvider] = useState(false);
+
+// useEffect(() => {
+// async function f() {
+// if (reducer) {
+// await reducer.discoverStart();
+// }
+// }
+// f().catch((e) => console.log(e));
+// }, []);
+
+// const currentVersion =
+// (reducer?.currentReducerState &&
+// "recovery_document" in reducer.currentReducerState &&
+// reducer.currentReducerState.recovery_document?.version) ||
+// 0;
+
+// if (!reducer) {
+// return <div>no reducer in context</div>;
+// }
+// if (
+// !reducer.currentReducerState ||
+// reducer.currentReducerState.reducer_type !== "recovery"
+// ) {
+// return <div>invalid state</div>;
+// }
+
+// async function doSelectVersion(p: string, n: number): Promise<void> {
+// if (!reducer) return Promise.resolve();
+// return reducer.runTransaction(async (tx) => {
+// await tx.transition("select_version", {
+// version: n,
+// provider_url: p,
+// });
+// setSelectingVersion(false);
+// });
+// }
+
+// const provs = reducer.currentReducerState.authentication_providers ?? {};
+// const recoveryDocument = reducer.currentReducerState.recovery_document;
+
+// if (!recoveryDocument) {
+// return (
+// <ChooseAnotherProviderScreen
+// providers={provs}
+// selected=""
+// onChange={(newProv) => doSelectVersion(newProv, 0)}
+// />
+// );
+// }
+
+// if (selectingVersion) {
+// return (
+// <SelectOtherVersionProviderScreen
+// providers={provs}
+// provider={recoveryDocument.provider_url}
+// version={recoveryDocument.version}
+// onCancel={() => setSelectingVersion(false)}
+// onConfirm={doSelectVersion}
+// />
+// );
+// }
+
+// if (manageProvider) {
+// return (
+// <AddingProviderScreen onCancel={async () => setManageProvider(false)} />
+// );
+// }
+
+// const providerInfo = provs[
+// recoveryDocument.provider_url
+// ] as AuthenticationProviderStatusOk;
+
+// return (
+// <AnastasisClientFrame title="Recovery: Select secret">
+// <div class="columns">
+// <div class="column">
+// <div class="box" style={{ border: "2px solid green" }}>
+// <h1 class="subtitle">{providerInfo.business_name}</h1>
+// <div class="block">
+// {currentVersion === 0 ? (
+// <p>Set to recover the latest version</p>
+// ) : (
+// <p>Set to recover the version number {currentVersion}</p>
+// )}
+// </div>
+// <div class="buttons is-right">
+// <button class="button" onClick={(e) => setSelectingVersion(true)}>
+// Change secret&apos;s version
+// </button>
+// </div>
+// </div>
+// </div>
+// <div class="column">
+// <p>
+// Secret found, you can select another version or continue to the
+// challenges solving
+// </p>
+// <p class="block">
+// <a onClick={() => setManageProvider(true)}>
+// Manage recovery providers
+// </a>
+// </p>
+// </div>
+// </div>
+// </AnastasisClientFrame>
+// );
+// }
+
+function ChooseAnotherProviderScreen({
+ onChange,
+}: {
+ onChange: (prov: string) => void;
+}): VNode {
+ const reducer = useAnastasisContext();
+
+ if (!reducer) {
+ return <div>no reducer in context</div>;
+ }
+
+ if (
+ !reducer.currentReducerState ||
+ reducer.currentReducerState.reducer_type !== "recovery"
+ ) {
+ return <div>invalid state</div>;
+ }
+ const providers = reducer.currentReducerState.authentication_providers ?? {};
+
+ return (
+ <AnastasisClientFrame
+ hideNext="Recovery document not found"
+ title="Recovery: Problem"
+ >
+ <p>No recovery document found, try with another provider</p>
+ <div class="field">
+ <label class="label">Provider</label>
+ <div class="control is-expanded has-icons-left">
+ <div class="select is-fullwidth">
+ <select onChange={(e) => onChange(e.currentTarget.value)} value="">
+ <option key="none" disabled selected value="">
+ Choose a provider
</option>
- )
- )}
- </select>
- <div>
- <input
- value={otherVersion}
- onChange={(e) => setOtherVersion(Number((e.target as HTMLInputElement).value))}
- type="number" />
- <button onClick={() => selectVersion(otherProvider, otherVersion)}>
- Use this version
- </button>
+ {Object.keys(providers).map((url) => {
+ const p = providers[url];
+ if (!("methods" in p)) return null;
+ return (
+ <option key={url} value={url}>
+ {p.business_name}
+ </option>
+ );
+ })}
+ </select>
+ <div class="icon is-small is-left">
+ <i class="mdi mdi-earth" />
+ </div>
+ </div>
</div>
- <div>
- <button onClick={() => selectVersion(otherProvider, 0)}>
- Use latest version
- </button>
- </div>
- <div>
- <button onClick={() => setSelectingVersion(false)}>Cancel</button>
+ </div>
+ </AnastasisClientFrame>
+ );
+}
+
+function SelectOtherVersionProviderScreen({
+ providers,
+ provider,
+ version,
+ onConfirm,
+ onCancel,
+}: {
+ onCancel: () => void;
+ provider: string;
+ version: number;
+ providers: { [url: string]: AuthenticationProviderStatus };
+ onConfirm: (prov: string, v: number) => Promise<void>;
+}): VNode {
+ const [otherProvider, setOtherProvider] = useState<string>(provider);
+ const [otherVersion, setOtherVersion] = useState(
+ version > 0 ? String(version) : "",
+ );
+ const otherProviderInfo = providers[
+ otherProvider
+ ] as AuthenticationProviderStatusOk;
+
+ return (
+ <AnastasisClientFrame hideNav title="Recovery: Select secret">
+ <div class="columns">
+ <div class="column">
+ <div class="box">
+ <h1 class="subtitle">Provider {otherProviderInfo.business_name}</h1>
+ <div class="block">
+ {version === 0 ? (
+ <p>Set to recover the latest version</p>
+ ) : (
+ <p>Set to recover the version number {version}</p>
+ )}
+ <p>Specify other version below or use the latest</p>
+ </div>
+
+ <div class="field">
+ <label class="label">Provider</label>
+ <div class="control is-expanded has-icons-left">
+ <div class="select is-fullwidth">
+ <select
+ onChange={(e) => setOtherProvider(e.currentTarget.value)}
+ value={otherProvider}
+ >
+ <option key="none" disabled selected value="">
+ {" "}
+ Choose a provider{" "}
+ </option>
+ {Object.keys(providers).map((url) => {
+ const p = providers[url];
+ if (!("methods" in p)) return null;
+ return (
+ <option key={url} value={url}>
+ {p.business_name}
+ </option>
+ );
+ })}
+ </select>
+ <div class="icon is-small is-left">
+ <i class="mdi mdi-earth" />
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="container">
+ <PhoneNumberInput
+ label="Version"
+ placeholder="version number to recover"
+ grabFocus
+ bind={[otherVersion, setOtherVersion]}
+ />
+ </div>
+ </div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={onCancel}>
+ Cancel
+ </button>
+ <div class="buttons">
+ <AsyncButton
+ class="button"
+ onClick={() => onConfirm(otherProvider, 0)}
+ >
+ Use latest
+ </AsyncButton>
+ <AsyncButton
+ class="button is-info"
+ onClick={() =>
+ onConfirm(otherProvider, parseInt(otherVersion, 10))
+ }
+ >
+ Confirm
+ </AsyncButton>
+ </div>
+ </div>
</div>
- </AnastasisClientFrame>
- );
- }
+ </div>
+ </AnastasisClientFrame>
+ );
+}
+
+function SecretSelectionScreenWaiting(): VNode {
return (
<AnastasisClientFrame title="Recovery: Select secret">
- <p>Provider: {recoveryDocument.provider_url}</p>
- <p>Secret version: {recoveryDocument.version}</p>
- <p>Secret name: {recoveryDocument.secret_name}</p>
- <button onClick={() => setSelectingVersion(true)}>
- Select different secret
- </button>
+ <div>loading secret versions</div>
</AnastasisClientFrame>
);
}
diff --git a/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx
deleted file mode 100644
index 2c27895c2..000000000
--- a/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame, LabeledInput } from "./index";
-import { SolveEntryProps } from "./SolveScreen";
-
-export function SolveEmailEntry({ challenge, feedback }: SolveEntryProps): VNode {
- const [answer, setAnswer] = useState("");
- const reducer = useAnastasisContext()
- const next = (): void => {
- if (reducer) reducer.transition("solve_challenge", {
- answer,
- })
- };
- return (
- <AnastasisClientFrame
- title="Recovery: Solve challenge"
- onNext={() => next()}
- >
- <p>Feedback: {JSON.stringify(feedback)}</p>
- <p>{challenge.instructions}</p>
- <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} />
- </AnastasisClientFrame>
- );
-}
diff --git a/packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx b/packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx
deleted file mode 100644
index 1a824acb8..000000000
--- a/packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame, LabeledInput } from "./index";
-import { SolveEntryProps } from "./SolveScreen";
-
-export function SolvePostEntry({ challenge, feedback }: SolveEntryProps): VNode {
- const [answer, setAnswer] = useState("");
- const reducer = useAnastasisContext()
- const next = (): void => {
- if (reducer) reducer.transition("solve_challenge", { answer })
- };
- return (
- <AnastasisClientFrame
- title="Recovery: Solve challenge"
- onNext={() => next()}
- >
- <p>Feedback: {JSON.stringify(feedback)}</p>
- <p>{challenge.instructions}</p>
- <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} />
- </AnastasisClientFrame>
- );
-}
diff --git a/packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx
deleted file mode 100644
index 72dadbe89..000000000
--- a/packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame, LabeledInput } from "./index";
-import { SolveEntryProps } from "./SolveScreen";
-
-export function SolveQuestionEntry({ challenge, feedback }: SolveEntryProps): VNode {
- const [answer, setAnswer] = useState("");
- const reducer = useAnastasisContext()
- const next = (): void => {
- if (reducer) reducer.transition("solve_challenge", { answer })
- };
- return (
- <AnastasisClientFrame
- title="Recovery: Solve challenge"
- onNext={() => next()}
- >
- <p>Feedback: {JSON.stringify(feedback)}</p>
- <p>Question: {challenge.instructions}</p>
- <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} />
- </AnastasisClientFrame>
- );
-}
diff --git a/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx
index 69af9be42..dc707a052 100644
--- a/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx
@@ -1,121 +1,72 @@
-/* eslint-disable @typescript-eslint/camelcase */
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ReducerState } from 'anastasis-core';
-import { createExample, reducerStatesExample } from '../../utils';
-import { SolveScreen as TestedComponent } from './SolveScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../utils/index.js";
+import { SolveScreen as TestedComponent } from "./SolveScreen.js";
+import * as tests from "@gnu-taler/web-util/testing";
export default {
- title: 'Pages/SolveScreen',
+ title: "Solve Screen",
component: TestedComponent,
+ args: {
+ order: 6,
+ },
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const NoInformation = createExample(TestedComponent, reducerStatesExample.challengeSolving);
-
-export const NotSupportedChallenge = createExample(TestedComponent, {
- ...reducerStatesExample.challengeSolving,
- recovery_information: {
- challenges: [{
- cost: 'USD:1',
- instructions: 'follow htis instructions',
- type: 'chall-type',
- uuid: 'ASDASDSAD!1'
- }],
- policies: [],
- },
- selected_challenge_uuid: 'ASDASDSAD!1'
-} as ReducerState);
-
-export const MismatchedChallengeId = createExample(TestedComponent, {
- ...reducerStatesExample.challengeSolving,
- recovery_information: {
- challenges: [{
- cost: 'USD:1',
- instructions: 'follow htis instructions',
- type: 'chall-type',
- uuid: 'ASDASDSAD!1'
- }],
- policies: [],
- },
- selected_challenge_uuid: 'no-no-no'
-} as ReducerState);
-
-export const SmsChallenge = createExample(TestedComponent, {
- ...reducerStatesExample.challengeSolving,
- recovery_information: {
- challenges: [{
- cost: 'USD:1',
- instructions: 'follow htis instructions',
- type: 'sms',
- uuid: 'ASDASDSAD!1'
- }],
- policies: [],
- },
- selected_challenge_uuid: 'ASDASDSAD!1'
-} as ReducerState);
-
-export const QuestionChallenge = createExample(TestedComponent, {
- ...reducerStatesExample.challengeSolving,
- recovery_information: {
- challenges: [{
- cost: 'USD:1',
- instructions: 'follow htis instructions',
- type: 'question',
- uuid: 'ASDASDSAD!1'
- }],
- policies: [],
- },
- selected_challenge_uuid: 'ASDASDSAD!1'
-} as ReducerState);
+export const NoInformation = tests.createExample(
+ TestedComponent,
+ reducerStatesExample.challengeSolving,
+);
-export const EmailChallenge = createExample(TestedComponent, {
+export const NotSupportedChallenge = tests.createExample(TestedComponent, {}, {
...reducerStatesExample.challengeSolving,
recovery_information: {
- challenges: [{
- cost: 'USD:1',
- instructions: 'follow htis instructions',
- type: 'email',
- uuid: 'ASDASDSAD!1'
- }],
+ challenges: [
+ {
+ instructions: "does P equals NP?",
+ type: "chall-type",
+ uuid: "ASDASDSAD!1",
+ },
+ ],
policies: [],
},
- selected_challenge_uuid: 'ASDASDSAD!1'
+ selected_challenge_uuid: "ASDASDSAD!1",
} as ReducerState);
-export const PostChallenge = createExample(TestedComponent, {
+export const MismatchedChallengeId = tests.createExample(TestedComponent, {}, {
...reducerStatesExample.challengeSolving,
recovery_information: {
- challenges: [{
- cost: 'USD:1',
- instructions: 'follow htis instructions',
- type: 'post',
- uuid: 'ASDASDSAD!1'
- }],
+ challenges: [
+ {
+ instructions: "does P equals NP?",
+ type: "chall-type",
+ uuid: "ASDASDSAD!1",
+ },
+ ],
policies: [],
},
- selected_challenge_uuid: 'ASDASDSAD!1'
+ selected_challenge_uuid: "no-no-no",
} as ReducerState);
diff --git a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx
index 05ae50b48..7f4d5aa18 100644
--- a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx
@@ -1,54 +1,255 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ ChallengeFeedback,
+ ChallengeFeedbackStatus,
+} from "@gnu-taler/anastasis-core";
import { h, VNode } from "preact";
-import { ChallengeFeedback, ChallengeInfo } from "../../../../anastasis-core/lib";
-import { useAnastasisContext } from "../../context/anastasis";
-import { SolveEmailEntry } from "./SolveEmailEntry";
-import { SolvePostEntry } from "./SolvePostEntry";
-import { SolveQuestionEntry } from "./SolveQuestionEntry";
-import { SolveSmsEntry } from "./SolveSmsEntry";
-import { SolveUnsupportedEntry } from "./SolveUnsupportedEntry";
+import { Notifications } from "../../components/Notifications.js";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { authMethods, KnownAuthMethods } from "./authMethod/index.js";
+import { AnastasisClientFrame } from "./index.js";
+
+export function SolveOverviewFeedbackDisplay(props: {
+ feedback?: ChallengeFeedback;
+}): VNode {
+ const { feedback } = props;
+ if (!feedback) {
+ return <div />;
+ }
+ switch (feedback.state) {
+ case ChallengeFeedbackStatus.TalerPayment:
+ return (
+ <Notifications
+ notifications={[
+ {
+ type: "INFO",
+ message: `Message from provider`,
+ description: (
+ <span>
+ To pay you can{" "}
+ <a
+ href={feedback.taler_pay_uri}
+ target="_blank"
+ rel="noreferrer"
+ >
+ click here
+ </a>
+ </span>
+ ),
+ },
+ ]}
+ />
+ );
+ case ChallengeFeedbackStatus.IbanInstructions:
+ return (
+ <Notifications
+ notifications={[
+ {
+ type: "INFO",
+ message: `Message from provider`,
+ description: `Need to send a wire transfer to "${feedback.target_business_name}"`,
+ },
+ ]}
+ />
+ );
+ case ChallengeFeedbackStatus.ServerFailure:
+ return (
+ <Notifications
+ notifications={[
+ {
+ type: "ERROR",
+ message: `Server error: response code ${feedback.http_status}`,
+ description: !feedback.error_response
+ ? undefined
+ : `More information: ${JSON.stringify(
+ feedback.error_response,
+ )}`,
+ },
+ ]}
+ />
+ );
+ case ChallengeFeedbackStatus.RateLimitExceeded:
+ return (
+ <Notifications
+ notifications={[
+ {
+ type: "ERROR",
+ message: "There were to many failed attempts.",
+ },
+ ]}
+ />
+ );
+ case ChallengeFeedbackStatus.Unsupported:
+ return (
+ <Notifications
+ notifications={[
+ {
+ type: "ERROR",
+ message: `This client doesn't support solving this type of challenge`,
+ description: `Use another version or contact the provider. Type of challenge "${feedback.unsupported_method}"`,
+ },
+ ]}
+ />
+ );
+ case ChallengeFeedbackStatus.TruthUnknown:
+ return (
+ <Notifications
+ notifications={[
+ {
+ type: "ERROR",
+ message: `Provider doesn't recognize the type of challenge`,
+ description: "Contact the provider for further information",
+ },
+ ]}
+ />
+ );
+ case ChallengeFeedbackStatus.CodeInFile:
+ return (
+ <Notifications
+ notifications={[
+ {
+ type: "INFO",
+ message: `Required TAN can be found in file "${feedback.filename}"`,
+ description: feedback.display_hint
+ ? `HINT: ${feedback.display_hint}`
+ : undefined,
+ },
+ ]}
+ />
+ );
+ case ChallengeFeedbackStatus.CodeSent:
+ return (
+ <Notifications
+ notifications={[
+ {
+ type: "INFO",
+ message: `Code sent to address "${feedback.address_hint}"`,
+ description: feedback.display_hint
+ ? `HINT: ${feedback.display_hint}`
+ : undefined,
+ },
+ ]}
+ />
+ );
+ case ChallengeFeedbackStatus.IncorrectAnswer:
+ return (
+ <Notifications
+ notifications={[
+ {
+ type: "ERROR",
+ message: `The answer is wrong.`,
+ },
+ ]}
+ />
+ );
+ case ChallengeFeedbackStatus.Solved:
+ return (
+ <Notifications
+ notifications={[
+ {
+ type: "SUCCESS",
+ message: `This challenge is solved`,
+ },
+ ]}
+ />
+ );
+ }
+}
export function SolveScreen(): VNode {
- const reducer = useAnastasisContext()
+ const reducer = useAnastasisContext();
if (!reducer) {
- return <div>no reducer in context</div>
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>no reducer in context</div>
+ </AnastasisClientFrame>
+ );
}
- if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) {
- return <div>invalid state</div>
+ if (reducer.currentReducerState?.reducer_type !== "recovery") {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ </AnastasisClientFrame>
+ );
}
if (!reducer.currentReducerState.recovery_information) {
- return <div>no recovery information found</div>
+ return (
+ <AnastasisClientFrame
+ hideNext="Recovery document not found"
+ title="Recovery problem"
+ >
+ <div>no recovery information found</div>
+ </AnastasisClientFrame>
+ );
}
if (!reducer.currentReducerState.selected_challenge_uuid) {
- return <div>no selected uuid</div>
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={() => reducer.back()}>
+ Back
+ </button>
+ </div>
+ </AnastasisClientFrame>
+ );
}
+ function SolveNotImplemented(): VNode {
+ return (
+ <AnastasisClientFrame hideNav title="Not implemented">
+ <p>
+ The challenge selected is not supported for this UI. Please update
+ this version or try using another policy.
+ </p>
+ {reducer && (
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={() => reducer.back()}>
+ Back
+ </button>
+ </div>
+ )}
+ </AnastasisClientFrame>
+ );
+ }
+
const chArr = reducer.currentReducerState.recovery_information.challenges;
- const challengeFeedback = reducer.currentReducerState.challenge_feedback ?? {};
const selectedUuid = reducer.currentReducerState.selected_challenge_uuid;
- const challenges: {
- [uuid: string]: ChallengeInfo;
- } = {};
- for (const ch of chArr) {
- challenges[ch.uuid] = ch;
- }
- const selectedChallenge = challenges[selectedUuid];
- const dialogMap: Record<string, (p: SolveEntryProps) => h.JSX.Element> = {
- question: SolveQuestionEntry,
- sms: SolveSmsEntry,
- email: SolveEmailEntry,
- post: SolvePostEntry,
- };
- const SolveDialog = dialogMap[selectedChallenge?.type] ?? SolveUnsupportedEntry;
- return (
- <SolveDialog
- challenge={selectedChallenge}
- feedback={challengeFeedback[selectedUuid]} />
- );
-}
+ const selectedChallenge = chArr.find((ch) => ch.uuid === selectedUuid);
-export interface SolveEntryProps {
- challenge: ChallengeInfo;
- feedback?: ChallengeFeedback;
-}
+ const SolveDialog =
+ !selectedChallenge ||
+ !authMethods[selectedChallenge.type as KnownAuthMethods]
+ ? SolveNotImplemented
+ : authMethods[selectedChallenge.type as KnownAuthMethods].solve ??
+ SolveNotImplemented;
+ return <SolveDialog id={selectedUuid} />;
+}
diff --git a/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx
deleted file mode 100644
index 163e0d1f3..000000000
--- a/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame, LabeledInput } from "./index";
-import { SolveEntryProps } from "./SolveScreen";
-
-export function SolveSmsEntry({ challenge, feedback }: SolveEntryProps): VNode {
- const [answer, setAnswer] = useState("");
- const reducer = useAnastasisContext()
- const next = (): void => {
- if (reducer) reducer.transition("solve_challenge", {
- answer,
- })
- };
- return (
- <AnastasisClientFrame
- title="Recovery: Solve challenge"
- onNext={() => next()}
- >
- <p>Feedback: {JSON.stringify(feedback)}</p>
- <p>{challenge.instructions}</p>
- <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} />
- </AnastasisClientFrame>
- );
-}
diff --git a/packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx
deleted file mode 100644
index 7f538d249..000000000
--- a/packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { h, VNode } from "preact";
-import { AnastasisClientFrame } from "./index";
-import { SolveEntryProps } from "./SolveScreen";
-
-export function SolveUnsupportedEntry(props: SolveEntryProps): VNode {
- return (
- <AnastasisClientFrame hideNext title="Recovery: Solve challenge">
- <p>{JSON.stringify(props.challenge)}</p>
- <p>Challenge not supported.</p>
- </AnastasisClientFrame>
- );
-}
diff --git a/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx
index ad84cd8f2..1f6145345 100644
--- a/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx
@@ -1,35 +1,42 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createExample, reducerStatesExample } from '../../utils';
-import { StartScreen as TestedComponent } from './StartScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../utils/index.js";
+import { StartScreen as TestedComponent } from "./StartScreen.js";
export default {
- title: 'Pages/StartScreen',
+ title: "Start screen",
component: TestedComponent,
+ args: {
+ order: 1,
+ },
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const InitialState = createExample(TestedComponent, reducerStatesExample.initial); \ No newline at end of file
+export const InitialState = tests.createExample(
+ TestedComponent,
+ {},
+ reducerStatesExample.initial,
+);
diff --git a/packages/anastasis-webui/src/pages/home/StartScreen.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.tsx
index 6625ec5b8..03399cfba 100644
--- a/packages/anastasis-webui/src/pages/home/StartScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/StartScreen.tsx
@@ -1,33 +1,71 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
import { h, VNode } from "preact";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame } from "./index";
+import { FileButton } from "../../components/FlieButton.js";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { AnastasisClientFrame } from "./index.js";
export function StartScreen(): VNode {
- const reducer = useAnastasisContext()
+ const reducer = useAnastasisContext();
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
return (
<AnastasisClientFrame hideNav title="Home">
- <div>
- <section class="section is-main-section">
- <div class="columns">
- <div class="column" />
- <div class="column is-four-fifths">
-
- <div class="buttons is-right">
- <button class="button is-success" autoFocus onClick={() => reducer.startBackup()}>
- Backup
- </button>
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <div class="buttons">
+ <button
+ class="button is-success"
+ autoFocus
+ onClick={() => reducer.startBackup()}
+ >
+ <div class="icon">
+ <i class="mdi mdi-arrow-up" />
+ </div>
+ <span>Backup a secret</span>
+ </button>
- <button class="button is-info" onClick={() => reducer.startRecover()}>Recover</button>
+ <button
+ class="button is-info"
+ onClick={() => reducer.startRecover()}
+ >
+ <div class="icon">
+ <i class="mdi mdi-arrow-down" />
</div>
+ <span>Recover a secret</span>
+ </button>
+
+ <FileButton
+ label="Restore a session"
+ onChange={(content) => {
+ if (content?.type === "application/json") {
+ reducer.importState(content.content);
+ }
+ }}
+ />
- </div>
- <div class="column" />
+ {/* <button class="button">
+ <div class="icon"><i class="mdi mdi-file" /></div>
+ <span>Restore a session</span>
+ </button> */}
</div>
- </section>
+ </div>
+ <div class="column" />
</div>
</AnastasisClientFrame>
);
diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx
index e2f3d521e..424c4884a 100644
--- a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx
+++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx
@@ -1,40 +1,47 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ReducerState } from 'anastasis-core';
-import { createExample, reducerStatesExample } from '../../utils';
-import { TruthsPayingScreen as TestedComponent } from './TruthsPayingScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../utils/index.js";
+import { TruthsPayingScreen as TestedComponent } from "./TruthsPayingScreen.js";
+import * as tests from "@gnu-taler/web-util/testing";
export default {
- title: 'Pages/TruthsPayingScreen',
+ title: "Truths Paying",
component: TestedComponent,
+ args: {
+ order: 10,
+ },
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
},
};
-export const Example = createExample(TestedComponent, reducerStatesExample.truthsPaying);
-export const WithPaytoList = createExample(TestedComponent, {
+export const Example = tests.createExample(
+ TestedComponent,
+ {},
+ reducerStatesExample.truthsPaying,
+);
+export const WithPaytoList = tests.createExample(TestedComponent, {}, {
...reducerStatesExample.truthsPaying,
- payments: ['payto://x-taler-bank/bank/account']
+ payments: ["payto://x-taler-bank/bank/account"],
} as ReducerState);
diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx
index 319f590a0..c9a555c35 100644
--- a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx
+++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx
@@ -1,21 +1,33 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
import { h, VNode } from "preact";
-import { useAnastasisContext } from "../../context/anastasis";
-import { AnastasisClientFrame } from "./index";
+import { useAnastasisContext } from "../../context/anastasis.js";
+import { AnastasisClientFrame } from "./index.js";
export function TruthsPayingScreen(): VNode {
- const reducer = useAnastasisContext()
+ const reducer = useAnastasisContext();
if (!reducer) {
- return <div>no reducer in context</div>
+ return <div>no reducer in context</div>;
}
- if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) {
- return <div>invalid state</div>
+ if (reducer.currentReducerState?.reducer_type !== "backup") {
+ return <div>invalid state</div>;
}
const payments = reducer.currentReducerState.payments ?? [];
return (
- <AnastasisClientFrame
- hideNext
- title="Backup: Authentication Storage Payments"
- >
+ <AnastasisClientFrame hideNext={"FIXME"} title="Backup: Truths Paying">
<p>
Some of the providers require a payment to store the encrypted
authentication information.
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx
new file mode 100644
index 000000000..aee7829ff
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx
@@ -0,0 +1,82 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js";
+
+export default {
+ title: "Auth method: Email setup",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "email";
+
+export const Empty = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [],
+ },
+ reducerStatesExample.authEditing,
+);
+
+export const WithOneExample = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: "Email to sebasjm@email.com ",
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
+
+export const WithMoreExamples = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: "Email to sebasjm@email.com",
+ remove: () => null,
+ },
+ {
+ challenge: "qwe",
+ type,
+ instructions: "Email to someone@sebasjm.com",
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx
new file mode 100644
index 000000000..b3af0f080
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx
@@ -0,0 +1,113 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { EmailInput } from "../../../components/fields/EmailInput.js";
+import { AnastasisClientFrame } from "../index.js";
+import { AuthMethodSetupProps } from "./index.js";
+
+const EMAIL_PATTERN =
+ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+
+export function AuthMethodEmailSetup({
+ cancel,
+ addAuthMethod,
+ configured,
+}: AuthMethodSetupProps): VNode {
+ const [email, setEmail] = useState("");
+ const addEmailAuth = (): void =>
+ addAuthMethod({
+ authentication_method: {
+ type: "email",
+ instructions: `Email to ${email}`,
+ challenge: encodeCrock(stringToBytes(email)),
+ },
+ });
+ const emailError = !EMAIL_PATTERN.test(email)
+ ? "Email address is not valid"
+ : undefined;
+ const errors = !email ? "Add your email" : emailError;
+
+ function goNextIfNoErrors(): void {
+ if (!errors) addEmailAuth();
+ }
+ return (
+ <AnastasisClientFrame hideNav title="Add email authentication">
+ <p>
+ For email authentication, you need to provide an email address. When
+ recovering your secret, you will need to enter the code you receive by
+ email. Add the uuid from the challenge
+ </p>
+ <div>
+ <EmailInput
+ label="Email address"
+ error={emailError}
+ onConfirm={goNextIfNoErrors}
+ placeholder="email@domain.com"
+ bind={[email, setEmail]}
+ />
+ </div>
+ {configured.length > 0 && (
+ <section class="section">
+ <div class="block">Your emails:</div>
+ <div class="block">
+ {configured.map((c, i) => {
+ return (
+ <div
+ key={i}
+ class="box"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <p style={{ marginBottom: "auto", marginTop: "auto" }}>
+ {c.instructions}
+ </p>
+ <div>
+ <button class="button is-danger" onClick={c.remove}>
+ Delete
+ </button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </section>
+ )}
+ <div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={cancel}>
+ Cancel
+ </button>
+ <span data-tooltip={errors}>
+ <button
+ class="button is-info"
+ disabled={errors !== undefined}
+ onClick={addEmailAuth}
+ >
+ Add
+ </button>
+ </span>
+ </div>
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx
new file mode 100644
index 000000000..075bab2a7
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx
@@ -0,0 +1,92 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ChallengeFeedbackStatus,
+ ReducerState,
+} from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js";
+import * as tests from "@gnu-taler/web-util/testing";
+
+export default {
+ title: "Auth method: Email solve",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "email";
+
+export const WithoutFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {
+ id: "uuid-1",
+ },
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information: {
+ challenges: [
+ {
+ instructions: "Email to me@domain.com",
+ type: "question",
+ uuid: "uuid-1",
+ },
+ ],
+ policies: [],
+ },
+ selected_challenge_uuid: "uuid-1",
+ } as ReducerState,
+);
+
+export const PaymentFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {
+ id: "uuid-1",
+ },
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information: {
+ challenges: [
+ {
+ instructions: "Email to me@domain.com",
+ type: "question",
+ uuid: "uuid-1",
+ },
+ ],
+ policies: [],
+ },
+ selected_challenge_uuid: "uuid-1",
+ challenge_feedback: {
+ "uuid-1": {
+ state: ChallengeFeedbackStatus.TalerPayment,
+ taler_pay_uri: "taler://pay/...",
+ provider: "https://localhost:8080/",
+ payment_secret: "3P4561HAMHRRYEYD6CM6J7TS5VTD5SR2K2EXJDZEFSX92XKHR4KG",
+ },
+ },
+ } as ReducerState,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx
new file mode 100644
index 000000000..6a9595a83
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx
@@ -0,0 +1,195 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { ChallengeInfo } from "@gnu-taler/anastasis-core";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/AsyncButton.js";
+import { TextInput } from "../../../components/fields/TextInput.js";
+import { useAnastasisContext } from "../../../context/anastasis.js";
+import { useTranslationContext } from "../../../context/translation.js";
+import { AnastasisClientFrame } from "../index.js";
+import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js";
+import { shouldHideConfirm } from "./helpers.js";
+import { AuthMethodSolveProps } from "./index.js";
+
+export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode {
+ const [answer, _setAnswer] = useState("A-");
+
+ function setAnswer(str: string): void {
+ //A-12345-678-1234-5678
+ const unformatted = str
+ .replace(/^A-/, "")
+ .replace(/-/g, "")
+ .toLocaleUpperCase();
+
+ let result = `A-${unformatted.substring(0, 5)}`;
+ if (unformatted.length > 5) {
+ result += `-${unformatted.substring(5, 8)}`;
+ }
+ if (unformatted.length > 8) {
+ result += `-${unformatted.substring(8, 12)}`;
+ }
+ if (unformatted.length > 12) {
+ result += `-${unformatted.substring(12)}`;
+ }
+
+ _setAnswer(result);
+ }
+ const [expanded, setExpanded] = useState(false);
+ const { i18n } = useTranslationContext();
+
+ const reducer = useAnastasisContext();
+ if (!reducer) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>no reducer in context</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (reducer.currentReducerState?.reducer_type !== "recovery") {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state, no recovery state</div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ if (!reducer.currentReducerState.recovery_information) {
+ return (
+ <AnastasisClientFrame
+ hideNext="Recovery document not found"
+ title="Recovery problem"
+ >
+ <div>no recovery information found</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (!reducer.currentReducerState.selected_challenge_uuid) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state, no challenge id</div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={() => reducer.back()}>
+ Back
+ </button>
+ </div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ const chArr = reducer.currentReducerState.recovery_information.challenges;
+ const challengeFeedback =
+ reducer.currentReducerState.challenge_feedback ?? {};
+ const selectedUuid = reducer.currentReducerState.selected_challenge_uuid;
+ const challenges: {
+ [uuid: string]: ChallengeInfo;
+ } = {};
+ for (const ch of chArr) {
+ challenges[ch.uuid] = ch;
+ }
+ const selectedChallenge = challenges[selectedUuid];
+ const feedback = challengeFeedback[selectedUuid];
+
+ async function onNext(): Promise<void> {
+ return reducer?.transition("solve_challenge", {
+ answer: `A-${answer.replace(/^A-/, "").replace(/-/g, "").trim()}`,
+ });
+ }
+ function onCancel(): void {
+ reducer?.back();
+ }
+
+ const error =
+ answer.length > 21
+ ? i18n.str`The answer should not be greater than 21 characters.`
+ : undefined;
+
+ return (
+ <AnastasisClientFrame hideNav title="Email challenge">
+ <SolveOverviewFeedbackDisplay feedback={feedback} />
+ <p>
+ An email has been sent to &quot;<b>{selectedChallenge.instructions}</b>
+ &quot;. The message has and identification code and recovery code that
+ starts with &quot;
+ <b>A-</b>&quot;. Wait the message to arrive and the enter the recovery
+ code below.
+ </p>
+ {!expanded ? (
+ <p>
+ The identification code in the email should start with &quot;
+ {selectedUuid.substring(0, 10)}&quot;
+ <span
+ class="icon has-tooltip-top"
+ data-tooltip="click to expand"
+ onClick={() => setExpanded((e) => !e)}
+ >
+ <i class="mdi mdi-information" />
+ </span>
+ </p>
+ ) : (
+ <p>
+ The identification code in the email is &quot;{selectedUuid}&quot;
+ <span
+ class="icon has-tooltip-top"
+ data-tooltip="click to show less code"
+ onClick={() => setExpanded((e) => !e)}
+ >
+ <i class="mdi mdi-information" />
+ </span>
+ </p>
+ )}
+ <TextInput
+ label="Answer"
+ grabFocus
+ onConfirm={onNext}
+ bind={[answer, setAnswer]}
+ error={error}
+ placeholder="A-12345-678-1234-5678"
+ />
+
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={onCancel}>
+ Cancel
+ </button>
+ {!shouldHideConfirm(feedback) && (
+ <AsyncButton
+ class="button is-info"
+ onClick={onNext}
+ disabled={!!error}
+ >
+ Confirm
+ </AsyncButton>
+ )}
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx
new file mode 100644
index 000000000..d571093f7
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx
@@ -0,0 +1,81 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js";
+
+export default {
+ title: "Auth method: IBAN setup",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "iban";
+
+export const Empty = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [],
+ },
+ reducerStatesExample.authEditing,
+);
+
+export const WithOneExample = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: "Wire transfer from QWEASD123123 with holder Sebastian",
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
+export const WithMoreExamples = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: "Wire transfer from QWEASD123123 with holder Javier",
+ remove: () => null,
+ },
+ {
+ challenge: "qwe",
+ type,
+ instructions: "Wire transfer from QWEASD123123 with holder Sebastian",
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx
new file mode 100644
index 000000000..663ccb644
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx
@@ -0,0 +1,129 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ canonicalJson,
+ encodeCrock,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { TextInput } from "../../../components/fields/TextInput.js";
+import { AnastasisClientFrame } from "../index.js";
+import { AuthMethodSetupProps } from "./index.js";
+
+export function AuthMethodIbanSetup({
+ addAuthMethod,
+ cancel,
+ configured,
+}: AuthMethodSetupProps): VNode {
+ const [name, setName] = useState("");
+ const [account, setAccount] = useState("");
+ const addIbanAuth = (): void =>
+ addAuthMethod({
+ authentication_method: {
+ type: "iban",
+ instructions: `Wire transfer from ${account} with holder ${name}`,
+ challenge: encodeCrock(
+ stringToBytes(
+ canonicalJson({
+ name,
+ account,
+ }),
+ ),
+ ),
+ },
+ });
+ const errors = !name
+ ? "Add an account name"
+ : !account
+ ? "Add an account IBAN number"
+ : undefined;
+ function goNextIfNoErrors(): void {
+ if (!errors) addIbanAuth();
+ }
+ return (
+ <AnastasisClientFrame hideNav title="Add bank transfer authentication">
+ <p>
+ For bank transfer authentication, you need to provide a bank account
+ (account holder name and IBAN). When recovering your secret, you will be
+ asked to pay the recovery fee via bank transfer from the account you
+ provided here.
+ </p>
+ <div>
+ <TextInput
+ label="Bank account holder name"
+ grabFocus
+ placeholder="John Smith"
+ onConfirm={goNextIfNoErrors}
+ bind={[name, setName]}
+ />
+ <TextInput
+ label="IBAN"
+ placeholder="DE91100000000123456789"
+ onConfirm={goNextIfNoErrors}
+ bind={[account, setAccount]}
+ />
+ </div>
+ {configured.length > 0 && (
+ <section class="section">
+ <div class="block">Your bank accounts:</div>
+ <div class="block">
+ {configured.map((c, i) => {
+ return (
+ <div
+ key={i}
+ class="box"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <p style={{ marginBottom: "auto", marginTop: "auto" }}>
+ {c.instructions}
+ </p>
+ <div>
+ <button class="button is-danger" onClick={c.remove}>
+ Delete
+ </button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </section>
+ )}
+ <div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={cancel}>
+ Cancel
+ </button>
+ <span data-tooltip={errors}>
+ <button
+ class="button is-info"
+ disabled={errors !== undefined}
+ onClick={addIbanAuth}
+ >
+ Add
+ </button>
+ </span>
+ </div>
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx
new file mode 100644
index 000000000..2a16c8456
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { KnownAuthMethods, authMethods as TestedComponent } from "./index.js";
+
+export default {
+ title: "Auth method: IBAN Solve",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "iban";
+
+export const WithoutFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {
+ id: "uuid-1",
+ },
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information: {
+ challenges: [
+ {
+ instructions: "does P equals NP?",
+ type: "question",
+ uuid: "uuid-1",
+ },
+ ],
+ policies: [],
+ },
+ selected_challenge_uuid: "uuid-1",
+ } as ReducerState,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx
new file mode 100644
index 000000000..dec65812e
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx
@@ -0,0 +1,118 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { ChallengeInfo } from "@gnu-taler/anastasis-core";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/AsyncButton.js";
+import { useAnastasisContext } from "../../../context/anastasis.js";
+import { AnastasisClientFrame } from "../index.js";
+import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js";
+import { shouldHideConfirm } from "./helpers.js";
+import { AuthMethodSolveProps } from "./index.js";
+
+export function AuthMethodIbanSolve({ id }: AuthMethodSolveProps): VNode {
+ const [answer, setAnswer] = useState("");
+
+ const reducer = useAnastasisContext();
+ if (!reducer) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>no reducer in context</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (reducer.currentReducerState?.reducer_type !== "recovery") {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ if (!reducer.currentReducerState.recovery_information) {
+ return (
+ <AnastasisClientFrame
+ hideNext="Recovery document not found"
+ title="Recovery problem"
+ >
+ <div>no recovery information found</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (!reducer.currentReducerState.selected_challenge_uuid) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={() => reducer.back()}>
+ Back
+ </button>
+ </div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ const chArr = reducer.currentReducerState.recovery_information.challenges;
+ const challengeFeedback =
+ reducer.currentReducerState.challenge_feedback ?? {};
+ const selectedUuid = reducer.currentReducerState.selected_challenge_uuid;
+ const challenges: {
+ [uuid: string]: ChallengeInfo;
+ } = {};
+ for (const ch of chArr) {
+ challenges[ch.uuid] = ch;
+ }
+ const selectedChallenge = challenges[selectedUuid];
+ const feedback = challengeFeedback[selectedUuid];
+
+ async function onNext(): Promise<void> {
+ return reducer?.transition("solve_challenge", { answer });
+ }
+ function onCancel(): void {
+ reducer?.back();
+ }
+
+ return (
+ <AnastasisClientFrame hideNav title="IBAN Challenge">
+ <SolveOverviewFeedbackDisplay feedback={feedback} />
+ <p>Send a wire transfer to the address,</p>
+ <button class="button">Check</button>
+
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={onCancel}>
+ Cancel
+ </button>
+ {!shouldHideConfirm(feedback) && (
+ <AsyncButton class="button is-info" onClick={onNext}>
+ Confirm
+ </AsyncButton>
+ )}
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx
new file mode 100644
index 000000000..a893c923e
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx
@@ -0,0 +1,82 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js";
+
+export default {
+ title: "Auth method: Post setup",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "post";
+
+export const Empty = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [],
+ },
+ reducerStatesExample.authEditing,
+);
+
+export const WithOneExample = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: "Letter to address in postal code QWE456",
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
+
+export const WithMoreExamples = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: "Letter to address in postal code QWE456",
+ remove: () => null,
+ },
+ {
+ challenge: "qwe",
+ type,
+ instructions: "Letter to address in postal code ABC123",
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx
new file mode 100644
index 000000000..2a8199783
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx
@@ -0,0 +1,161 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ canonicalJson,
+ encodeCrock,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { TextInput } from "../../../components/fields/TextInput.js";
+import { AnastasisClientFrame } from "../index.js";
+import { AuthMethodSetupProps } from "./index.js";
+
+export function AuthMethodPostSetup({
+ addAuthMethod,
+ cancel,
+ configured,
+}: AuthMethodSetupProps): VNode {
+ const [fullName, setFullName] = useState("");
+ const [street, setStreet] = useState("");
+ const [city, setCity] = useState("");
+ const [postcode, setPostcode] = useState("");
+ const [country, setCountry] = useState("");
+
+ const addPostAuth = () => {
+ const challengeJson = {
+ full_name: fullName,
+ street,
+ city,
+ postcode,
+ country,
+ };
+ addAuthMethod({
+ authentication_method: {
+ type: "post",
+ instructions: `Letter to address in postal code ${postcode}`,
+ challenge: encodeCrock(stringToBytes(canonicalJson(challengeJson))),
+ },
+ });
+ };
+
+ const errors = !fullName
+ ? "The full name is missing"
+ : !street
+ ? "The street is missing"
+ : !city
+ ? "The city is missing"
+ : !postcode
+ ? "The postcode is missing"
+ : !country
+ ? "The country is missing"
+ : undefined;
+
+ function goNextIfNoErrors(): void {
+ if (!errors) addPostAuth();
+ }
+ return (
+ <AnastasisClientFrame hideNav title="Add postal authentication">
+ <p>
+ For postal letter authentication, you need to provide a postal address.
+ When recovering your secret, you will be asked to enter a code that you
+ will receive in a letter to that address.
+ </p>
+ <div>
+ <TextInput
+ grabFocus
+ label="Full Name"
+ bind={[fullName, setFullName]}
+ onConfirm={goNextIfNoErrors}
+ />
+ </div>
+ <div>
+ <TextInput
+ onConfirm={goNextIfNoErrors}
+ label="Street"
+ bind={[street, setStreet]}
+ />
+ </div>
+ <div>
+ <TextInput
+ onConfirm={goNextIfNoErrors}
+ label="City"
+ bind={[city, setCity]}
+ />
+ </div>
+ <div>
+ <TextInput
+ onConfirm={goNextIfNoErrors}
+ label="Postal Code"
+ bind={[postcode, setPostcode]}
+ />
+ </div>
+ <div>
+ <TextInput
+ onConfirm={goNextIfNoErrors}
+ label="Country"
+ bind={[country, setCountry]}
+ />
+ </div>
+
+ {configured.length > 0 && (
+ <section class="section">
+ <div class="block">Your postal code:</div>
+ <div class="block">
+ {configured.map((c, i) => {
+ return (
+ <div
+ key={i}
+ class="box"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <p style={{ marginBottom: "auto", marginTop: "auto" }}>
+ {c.instructions}
+ </p>
+ <div>
+ <button class="button is-danger" onClick={c.remove}>
+ Delete
+ </button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </section>
+ )}
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={cancel}>
+ Cancel
+ </button>
+ <span data-tooltip={errors}>
+ <button
+ class="button is-info"
+ disabled={errors !== undefined}
+ onClick={addPostAuth}
+ >
+ Add
+ </button>
+ </span>
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx
new file mode 100644
index 000000000..3495f7f63
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js";
+import * as tests from "@gnu-taler/web-util/testing";
+
+export default {
+ title: "Auth method: Post solve",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "post";
+
+export const WithoutFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {
+ id: "uuid-1",
+ },
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information: {
+ challenges: [
+ {
+ instructions: "does P equals NP?",
+ type: "question",
+ uuid: "uuid-1",
+ },
+ ],
+ policies: [],
+ },
+ selected_challenge_uuid: "uuid-1",
+ } as ReducerState,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx
new file mode 100644
index 000000000..8204ab1cf
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx
@@ -0,0 +1,160 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { ChallengeInfo } from "@gnu-taler/anastasis-core";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/AsyncButton.js";
+import { TextInput } from "../../../components/fields/TextInput.js";
+import { useAnastasisContext } from "../../../context/anastasis.js";
+import { useTranslationContext } from "../../../context/translation.js";
+import { AnastasisClientFrame } from "../index.js";
+import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js";
+import { shouldHideConfirm } from "./helpers.js";
+import { AuthMethodSolveProps } from "./index.js";
+
+export function AuthMethodPostSolve({ id }: AuthMethodSolveProps): VNode {
+ const [answer, _setAnswer] = useState("A-");
+
+ function setAnswer(str: string): void {
+ //A-12345-678-1234-5678
+ const unformatted = str
+ .replace(/^A-/, "")
+ .replace(/-/g, "")
+ .toLocaleUpperCase();
+
+ let result = `A-${unformatted.substring(0, 5)}`;
+ if (unformatted.length > 5) {
+ result += `-${unformatted.substring(5, 8)}`;
+ }
+ if (unformatted.length > 8) {
+ result += `-${unformatted.substring(8, 12)}`;
+ }
+ if (unformatted.length > 12) {
+ result += `-${unformatted.substring(12)}`;
+ }
+
+ _setAnswer(result);
+ }
+ const { i18n } = useTranslationContext();
+
+ const reducer = useAnastasisContext();
+ if (!reducer) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>no reducer in context</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (reducer.currentReducerState?.reducer_type !== "recovery") {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ if (!reducer.currentReducerState.recovery_information) {
+ return (
+ <AnastasisClientFrame
+ hideNext="Recovery document not found"
+ title="Recovery problem"
+ >
+ <div>no recovery information found</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (!reducer.currentReducerState.selected_challenge_uuid) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={() => reducer.back()}>
+ Back
+ </button>
+ </div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ const chArr = reducer.currentReducerState.recovery_information.challenges;
+ const challengeFeedback =
+ reducer.currentReducerState.challenge_feedback ?? {};
+ const selectedUuid = reducer.currentReducerState.selected_challenge_uuid;
+ const challenges: {
+ [uuid: string]: ChallengeInfo;
+ } = {};
+ for (const ch of chArr) {
+ challenges[ch.uuid] = ch;
+ }
+ const selectedChallenge = challenges[selectedUuid];
+ const feedback = challengeFeedback[selectedUuid];
+
+ async function onNext(): Promise<void> {
+ return reducer?.transition("solve_challenge", {
+ answer: `A-${answer.replace(/^A-/, "").replace(/-/g, "").trim()}`,
+ });
+ }
+ function onCancel(): void {
+ reducer?.back();
+ }
+
+ const error =
+ answer.length > 21
+ ? i18n.str`The answer should not be greater than 21 characters.`
+ : undefined;
+
+ return (
+ <AnastasisClientFrame hideNav title="Postal Challenge">
+ <SolveOverviewFeedbackDisplay feedback={feedback} />
+ <p>Wait for the answer</p>
+ <TextInput
+ onConfirm={onNext}
+ label="Answer"
+ grabFocus
+ placeholder="A-12345-678-1234-5678"
+ error={error}
+ bind={[answer, setAnswer]}
+ />
+
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={onCancel}>
+ Cancel
+ </button>
+ {!shouldHideConfirm(feedback) && (
+ <AsyncButton
+ class="button is-info"
+ onClick={onNext}
+ disabled={!!error}
+ >
+ Confirm
+ </AsyncButton>
+ )}
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx
new file mode 100644
index 000000000..c9bc127f7
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx
@@ -0,0 +1,84 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js";
+
+export default {
+ title: "Auth method: Question setup",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "question";
+
+export const Empty = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [],
+ },
+ reducerStatesExample.authEditing,
+);
+
+export const WithOneExample = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions:
+ "Is integer factorization polynomial? (non-quantum computer)",
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
+
+export const WithMoreExamples = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: "Does P equal NP?",
+ remove: () => null,
+ },
+ {
+ challenge: "asd",
+ type,
+ instructions:
+ "Are continuous groups automatically differential groups?",
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx
new file mode 100644
index 000000000..7dc6fcc0c
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx
@@ -0,0 +1,127 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { TextInput } from "../../../components/fields/TextInput.js";
+import { AnastasisClientFrame } from "../index.js";
+import { AuthMethodSetupProps } from "./index.js";
+
+export function AuthMethodQuestionSetup({
+ cancel,
+ addAuthMethod,
+ configured,
+}: AuthMethodSetupProps): VNode {
+ const [questionText, setQuestionText] = useState("");
+ const [answerText, setAnswerText] = useState("");
+ const addQuestionAuth = (): void =>
+ addAuthMethod({
+ authentication_method: {
+ type: "question",
+ instructions: questionText,
+ challenge: encodeCrock(stringToBytes(answerText)),
+ },
+ });
+
+ const errors = !questionText
+ ? "Add your security question"
+ : !answerText
+ ? "Add the answer to your question"
+ : undefined;
+ function goNextIfNoErrors(): void {
+ if (!errors) addQuestionAuth();
+ }
+ return (
+ <AnastasisClientFrame hideNav title="Add Security Question">
+ <div>
+ <p>
+ For security question authentication, you need to provide a question
+ and its answer. When recovering your secret, you will be shown the
+ question and you will need to type the answer exactly as you typed it
+ here.
+ </p>
+ <p class="notification is-warning">
+ Note that the answer is case-sensitive and must be entered in exactly
+ the same way (punctuation, spaces) during recovery.
+ </p>
+ <div>
+ <TextInput
+ label="Security question"
+ grabFocus
+ onConfirm={goNextIfNoErrors}
+ placeholder="Your question"
+ bind={[questionText, setQuestionText]}
+ />
+ </div>
+ <div>
+ <TextInput
+ label="Answer"
+ onConfirm={goNextIfNoErrors}
+ placeholder="Your answer"
+ bind={[answerText, setAnswerText]}
+ />
+ </div>
+
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={cancel}>
+ Cancel
+ </button>
+ <span data-tooltip={errors}>
+ <button
+ class="button is-info"
+ disabled={errors !== undefined}
+ onClick={addQuestionAuth}
+ >
+ Add
+ </button>
+ </span>
+ </div>
+
+ {configured.length > 0 && (
+ <section class="section">
+ <div class="block">Your security questions:</div>
+ <div class="block">
+ {configured.map((c, i) => {
+ return (
+ <div
+ key={i}
+ class="box"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <p style={{ marginBottom: "auto", marginTop: "auto" }}>
+ {c.instructions}
+ </p>
+ <div>
+ <button class="button is-danger" onClick={c.remove}>
+ Delete
+ </button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </section>
+ )}
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx
new file mode 100644
index 000000000..dbb17ddab
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx
@@ -0,0 +1,239 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ChallengeFeedbackStatus,
+ ReducerState,
+} from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js";
+import * as tests from "@gnu-taler/web-util/testing";
+import { AmountString } from "@gnu-taler/taler-util";
+
+export default {
+ title: "Auth method: Question solve",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "question";
+
+export const WithoutFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {
+ id: "uuid-1",
+ },
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information: {
+ challenges: [
+ {
+ instructions: "does P equals NP?",
+ type: "question",
+ uuid: "uuid-1",
+ },
+ ],
+ policies: [],
+ },
+ selected_challenge_uuid: "uuid-1",
+ } as ReducerState,
+);
+
+const recovery_information = {
+ challenges: [
+ {
+ instructions: "does P equal NP?",
+ type: "question",
+ uuid: "ASDASDSAD!1",
+ },
+ ],
+ policies: [],
+};
+
+export const CodeInFileFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {},
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information,
+ selected_challenge_uuid: "ASDASDSAD!1",
+ challenge_feedback: {
+ "ASDASDSAD!1": {
+ state: ChallengeFeedbackStatus.CodeInFile,
+ filename: "asd",
+ display_hint: "hint",
+ },
+ },
+ } as ReducerState,
+);
+
+export const CodeSentFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {},
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information,
+ selected_challenge_uuid: "ASDASDSAD!1",
+ challenge_feedback: {
+ "ASDASDSAD!1": {
+ state: ChallengeFeedbackStatus.CodeSent,
+ address_hint: "asdasd",
+ display_hint: "qweqweqw",
+ },
+ },
+ } as ReducerState,
+);
+
+export const SolvedFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {},
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information,
+ selected_challenge_uuid: "ASDASDSAD!1",
+ challenge_feedback: {
+ "ASDASDSAD!1": {
+ state: ChallengeFeedbackStatus.Solved,
+ },
+ },
+ } as ReducerState,
+);
+
+export const ServerFailureFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {},
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information,
+ selected_challenge_uuid: "ASDASDSAD!1",
+ challenge_feedback: {
+ "ASDASDSAD!1": {
+ state: ChallengeFeedbackStatus.ServerFailure,
+ http_status: 500,
+ },
+ },
+ } as ReducerState,
+);
+
+export const TruthUnknownFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {},
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information,
+ selected_challenge_uuid: "ASDASDSAD!1",
+ challenge_feedback: {
+ "ASDASDSAD!1": {
+ state: ChallengeFeedbackStatus.TruthUnknown,
+ },
+ },
+ } as ReducerState,
+);
+
+export const TalerPaymentFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {},
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information,
+ selected_challenge_uuid: "ASDASDSAD!1",
+ challenge_feedback: {
+ "ASDASDSAD!1": {
+ state: ChallengeFeedbackStatus.TalerPayment,
+ payment_secret: "secret",
+ provider: "asdasdas",
+ taler_pay_uri: "taler://pay/...",
+ },
+ },
+ } as ReducerState,
+);
+
+export const UnsupportedFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {},
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information,
+ selected_challenge_uuid: "ASDASDSAD!1",
+ challenge_feedback: {
+ "ASDASDSAD!1": {
+ state: ChallengeFeedbackStatus.Unsupported,
+ unsupported_method: "method",
+ },
+ },
+ } as ReducerState,
+);
+
+export const RateLimitExceededFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {},
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information,
+ selected_challenge_uuid: "ASDASDSAD!1",
+ challenge_feedback: {
+ "ASDASDSAD!1": {
+ state: ChallengeFeedbackStatus.RateLimitExceeded,
+ },
+ },
+ } as ReducerState,
+);
+
+export const IbanInstructionsFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {},
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information,
+ selected_challenge_uuid: "ASDASDSAD!1",
+ challenge_feedback: {
+ "ASDASDSAD!1": {
+ state: ChallengeFeedbackStatus.IbanInstructions,
+ challenge_amount: "EUR:1" as AmountString,
+ target_iban: "DE12345789000",
+ target_business_name: "Data Loss Incorporated",
+ wire_transfer_subject: "Anastasis 987654321",
+ answer_code: 987654321,
+ },
+ },
+ } as ReducerState,
+);
+
+export const IncorrectAnswerFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {},
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information,
+ selected_challenge_uuid: "ASDASDSAD!1",
+ challenge_feedback: {
+ "ASDASDSAD!1": {
+ state: ChallengeFeedbackStatus.IncorrectAnswer,
+ },
+ },
+ } as ReducerState,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx
new file mode 100644
index 000000000..abb200eb1
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx
@@ -0,0 +1,128 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { ChallengeInfo } from "@gnu-taler/anastasis-core";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/AsyncButton.js";
+import { TextInput } from "../../../components/fields/TextInput.js";
+import { useAnastasisContext } from "../../../context/anastasis.js";
+import { AnastasisClientFrame } from "../index.js";
+import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js";
+import { shouldHideConfirm } from "./helpers.js";
+import { AuthMethodSolveProps } from "./index.js";
+
+export function AuthMethodQuestionSolve({ id }: AuthMethodSolveProps): VNode {
+ const [answer, setAnswer] = useState("");
+
+ const reducer = useAnastasisContext();
+ if (!reducer) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>no reducer in context</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (reducer.currentReducerState?.reducer_type !== "recovery") {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ if (!reducer.currentReducerState.recovery_information) {
+ return (
+ <AnastasisClientFrame
+ hideNext="Recovery document not found"
+ title="Recovery problem"
+ >
+ <div>no recovery information found</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (!reducer.currentReducerState.selected_challenge_uuid) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={() => reducer.back()}>
+ Back
+ </button>
+ </div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ const chArr = reducer.currentReducerState.recovery_information.challenges;
+ const challengeFeedback =
+ reducer.currentReducerState.challenge_feedback ?? {};
+ const selectedUuid = reducer.currentReducerState.selected_challenge_uuid;
+ const challenges: {
+ [uuid: string]: ChallengeInfo;
+ } = {};
+ for (const ch of chArr) {
+ challenges[ch.uuid] = ch;
+ }
+ const selectedChallenge = challenges[selectedUuid];
+ const feedback = challengeFeedback[selectedUuid];
+
+ async function onNext(): Promise<void> {
+ return reducer?.transition("solve_challenge", { answer });
+ }
+ function onCancel(): void {
+ reducer?.back();
+ }
+
+ return (
+ <AnastasisClientFrame hideNav title="Question challenge">
+ <SolveOverviewFeedbackDisplay feedback={feedback} />
+ <p>
+ In this challenge you need to provide the answer for the next question:
+ </p>
+ <pre>{selectedChallenge.instructions}</pre>
+ <p>Type the answer below</p>
+ <TextInput
+ label="Answer"
+ onConfirm={onNext}
+ grabFocus
+ bind={[answer, setAnswer]}
+ />
+
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={onCancel}>
+ Cancel
+ </button>
+ {!shouldHideConfirm(feedback) && (
+ <AsyncButton class="button is-info" onClick={onNext}>
+ Confirm
+ </AsyncButton>
+ )}
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx
new file mode 100644
index 000000000..fbf345779
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx
@@ -0,0 +1,82 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js";
+
+export default {
+ title: "Auth method: SMS setup",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "sms";
+
+export const Empty = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [],
+ },
+ reducerStatesExample.authEditing,
+);
+
+export const WithOneExample = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: "SMS to +11-1234-2345",
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
+
+export const WithMoreExamples = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: "SMS to +11-1234-2345",
+ remove: () => null,
+ },
+ {
+ challenge: "qwe",
+ type,
+ instructions: "SMS to +11-5555-2345",
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx
new file mode 100644
index 000000000..87064237c
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx
@@ -0,0 +1,127 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { useLayoutEffect, useRef, useState } from "preact/hooks";
+import { PhoneNumberInput } from "../../../components/fields/NumberInput.js";
+import { AnastasisClientFrame } from "../index.js";
+import { AuthMethodSetupProps } from "./index.js";
+
+const REGEX_JUST_NUMBERS = /^\+[0-9 ]*$/;
+
+function isJustNumbers(str: string): boolean {
+ return REGEX_JUST_NUMBERS.test(str);
+}
+
+export function AuthMethodSmsSetup({
+ addAuthMethod,
+ cancel,
+ configured,
+}: AuthMethodSetupProps): VNode {
+ const [mobileNumber, setMobileNumber] = useState("+");
+ const addSmsAuth = (): void => {
+ addAuthMethod({
+ authentication_method: {
+ type: "sms",
+ instructions: `SMS to ${mobileNumber}`,
+ challenge: encodeCrock(stringToBytes(mobileNumber)),
+ },
+ });
+ };
+ const inputRef = useRef<HTMLInputElement>(null);
+ useLayoutEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+ const errors = !mobileNumber
+ ? "Add a mobile number"
+ : !mobileNumber.startsWith("+")
+ ? "Mobile number should start with '+'"
+ : !isJustNumbers(mobileNumber)
+ ? "Mobile number can't have other than numbers"
+ : undefined;
+ function goNextIfNoErrors(): void {
+ if (!errors) addSmsAuth();
+ }
+ return (
+ <AnastasisClientFrame hideNav title="Add SMS authentication">
+ <div>
+ <p>
+ For SMS authentication, you need to provide a mobile number. When
+ recovering your secret, you will be asked to enter the code you
+ receive via SMS.
+ </p>
+ <div class="container">
+ <PhoneNumberInput
+ label="Mobile number"
+ placeholder="Your mobile number"
+ onConfirm={goNextIfNoErrors}
+ error={errors}
+ grabFocus
+ bind={[mobileNumber, setMobileNumber]}
+ />
+ <div>
+ Enter mobile number including +CC international dialing prefix.
+ </div>
+ </div>
+ {configured.length > 0 && (
+ <section class="section">
+ <div class="block">Your mobile numbers:</div>
+ <div class="block">
+ {configured.map((c, i) => {
+ return (
+ <div
+ key={i}
+ class="box"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <p style={{ marginTop: "auto", marginBottom: "auto" }}>
+ {c.instructions}
+ </p>
+ <div>
+ <button class="button is-danger" onClick={c.remove}>
+ Delete
+ </button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </section>
+ )}
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={cancel}>
+ Cancel
+ </button>
+ <span data-tooltip={errors}>
+ <button
+ class="button is-info"
+ disabled={errors !== undefined}
+ onClick={addSmsAuth}
+ >
+ Add
+ </button>
+ </span>
+ </div>
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx
new file mode 100644
index 000000000..8e3fb1a16
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js";
+import * as tests from "@gnu-taler/web-util/testing";
+
+export default {
+ title: "Auth method: SMS solve",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "sms";
+
+export const WithoutFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {
+ id: "AHCC4ZJ3Z1AF8TWBKGVGEKCQ3R7HXHJ51MJ45NHNZMHYZTKJ9NW0",
+ },
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information: {
+ challenges: [
+ {
+ instructions: "SMS to +54 11 2233 4455",
+ type: "question",
+ uuid: "AHCC4ZJ3Z1AF8TWBKGVGEKCQ3R7HXHJ51MJ45NHNZMHYZTKJ9NW0",
+ },
+ ],
+ policies: [],
+ },
+ selected_challenge_uuid:
+ "AHCC4ZJ3Z1AF8TWBKGVGEKCQ3R7HXHJ51MJ45NHNZMHYZTKJ9NW0",
+ } as ReducerState,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx
new file mode 100644
index 000000000..58bb53c4f
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx
@@ -0,0 +1,191 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { ChallengeInfo } from "@gnu-taler/anastasis-core";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/AsyncButton.js";
+import { TextInput } from "../../../components/fields/TextInput.js";
+import { useAnastasisContext } from "../../../context/anastasis.js";
+import { useTranslationContext } from "../../../context/translation.js";
+import { AnastasisClientFrame } from "../index.js";
+import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js";
+import { shouldHideConfirm } from "./helpers.js";
+import { AuthMethodSolveProps } from "./index.js";
+
+export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode {
+ const [answer, _setAnswer] = useState("A-");
+
+ function setAnswer(str: string): void {
+ //A-12345-678-1234-5678
+ const unformatted = str
+ .replace(/^A-/, "")
+ .replace(/-/g, "")
+ .toLocaleUpperCase();
+
+ let result = `A-${unformatted.substring(0, 5)}`;
+ if (unformatted.length > 5) {
+ result += `-${unformatted.substring(5, 8)}`;
+ }
+ if (unformatted.length > 8) {
+ result += `-${unformatted.substring(8, 12)}`;
+ }
+ if (unformatted.length > 12) {
+ result += `-${unformatted.substring(12)}`;
+ }
+
+ _setAnswer(result);
+ }
+ const { i18n } = useTranslationContext();
+
+ const [expanded, setExpanded] = useState(false);
+ const reducer = useAnastasisContext();
+ if (!reducer) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>no reducer in context</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (reducer.currentReducerState?.reducer_type !== "recovery") {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ if (!reducer.currentReducerState.recovery_information) {
+ return (
+ <AnastasisClientFrame
+ hideNext="Recovery document not found"
+ title="Recovery problem"
+ >
+ <div>no recovery information found</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (!reducer.currentReducerState.selected_challenge_uuid) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={() => reducer.back()}>
+ Back
+ </button>
+ </div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ const chArr = reducer.currentReducerState.recovery_information.challenges;
+ const challengeFeedback =
+ reducer.currentReducerState.challenge_feedback ?? {};
+ const selectedUuid = reducer.currentReducerState.selected_challenge_uuid;
+ const challenges: {
+ [uuid: string]: ChallengeInfo;
+ } = {};
+ for (const ch of chArr) {
+ challenges[ch.uuid] = ch;
+ }
+ const selectedChallenge = challenges[selectedUuid];
+ const feedback = challengeFeedback[selectedUuid];
+
+ async function onNext(): Promise<void> {
+ return reducer?.transition("solve_challenge", {
+ answer: `A-${answer.replace(/^A-/, "").replace(/-/g, "").trim()}`,
+ });
+ }
+ function onCancel(): void {
+ reducer?.back();
+ }
+
+ const error =
+ answer.length > 21
+ ? i18n.str`The answer should not be greater than 21 characters.`
+ : undefined;
+
+ return (
+ <AnastasisClientFrame hideNav title="SMS Challenge">
+ <SolveOverviewFeedbackDisplay feedback={feedback} />
+ <p>
+ An sms has been sent to &quot;<b>{selectedChallenge.instructions}</b>
+ &quot;. The message has and identification code and recovery code that
+ starts with &quot;
+ <b>A-</b>&quot;. Wait the message to arrive and the enter the recovery
+ code below.
+ </p>
+ {!expanded ? (
+ <p>
+ The identification code in the SMS should start with &quot;
+ {selectedUuid.substring(0, 10)}&quot;
+ <span
+ class="icon has-tooltip-top"
+ data-tooltip="click to expand"
+ onClick={() => setExpanded((e) => !e)}
+ >
+ <i class="mdi mdi-information" />
+ </span>
+ </p>
+ ) : (
+ <p>
+ The identification code in the SMS is &quot;{selectedUuid}&quot;
+ <span
+ class="icon has-tooltip-top"
+ data-tooltip="click to show less code"
+ onClick={() => setExpanded((e) => !e)}
+ >
+ <i class="mdi mdi-information" />
+ </span>
+ </p>
+ )}
+ <TextInput
+ label="Answer"
+ grabFocus
+ onConfirm={onNext}
+ bind={[answer, setAnswer]}
+ error={error}
+ placeholder="A-12345-678-1234-5678"
+ />
+
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={onCancel}>
+ Cancel
+ </button>
+ {!shouldHideConfirm(feedback) && (
+ <AsyncButton
+ class="button is-info"
+ onClick={onNext}
+ disabled={!!error}
+ >
+ Confirm
+ </AsyncButton>
+ )}
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx
new file mode 100644
index 000000000..ee66fcee1
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx
@@ -0,0 +1,80 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { authMethods as TestedComponent, KnownAuthMethods } from "./index.js";
+
+export default {
+ title: "Auth method: Totp setup",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "totp";
+
+export const Empty = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [],
+ },
+ reducerStatesExample.authEditing,
+);
+export const WithOneExample = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: 'Enter 8 digits code for "Anastasis"',
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
+export const WithMoreExample = tests.createExample(
+ TestedComponent[type].setup,
+ {
+ configured: [
+ {
+ challenge: "qwe",
+ type,
+ instructions: 'Enter 8 digits code for "Anastasis1"',
+ remove: () => null,
+ },
+ {
+ challenge: "qwe",
+ type,
+ instructions: 'Enter 8 digits code for "Anastasis2"',
+ remove: () => null,
+ },
+ ],
+ },
+ reducerStatesExample.authEditing,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx
new file mode 100644
index 000000000..acdcef3ac
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx
@@ -0,0 +1,141 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { encodeCrock } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { useMemo, useState } from "preact/hooks";
+import { TextInput } from "../../../components/fields/TextInput.js";
+import { QR } from "../../../components/QR.js";
+import { AnastasisClientFrame } from "../index.js";
+import { AuthMethodSetupProps } from "./index.js";
+import { base32enc, computeTOTPandCheck } from "./totp.js";
+
+/**
+ * This is hard-coded in the protocol for TOTP auth.
+ */
+const ANASTASIS_TOTP_DIGITS = 8;
+
+export function AuthMethodTotpSetup({
+ addAuthMethod,
+ cancel,
+ configured,
+}: AuthMethodSetupProps): VNode {
+ const [name, setName] = useState("anastasis");
+ const [test, setTest] = useState("");
+ const secretKey = useMemo(() => {
+ const array = new Uint8Array(32);
+ if (typeof window === "undefined") return array;
+ return window.crypto.getRandomValues(array);
+ }, []);
+
+ const secret32 = base32enc(secretKey);
+ const totpURL = `otpauth://totp/${name}?digits=${ANASTASIS_TOTP_DIGITS}&secret=${secret32}`;
+
+ const addTotpAuth = (): void =>
+ addAuthMethod({
+ authentication_method: {
+ type: "totp",
+ instructions: `Enter ${ANASTASIS_TOTP_DIGITS} digits code for "${name}"`,
+ challenge: encodeCrock(secretKey),
+ },
+ });
+
+ const testCodeMatches = computeTOTPandCheck(secretKey, 8, parseInt(test, 10));
+
+ const errors = !name
+ ? "The TOTP name is missing"
+ : !testCodeMatches
+ ? "The test code doesn't match"
+ : undefined;
+ function goNextIfNoErrors(): void {
+ if (!errors) addTotpAuth();
+ }
+ return (
+ <AnastasisClientFrame hideNav title="Add TOTP authentication">
+ <p>
+ For Time-based One-Time Password (TOTP) authentication, you need to set
+ a name for the TOTP secret. Then, you must scan the generated QR code
+ with your TOTP App to import the TOTP secret into your TOTP App.
+ </p>
+ <div class="block">
+ <TextInput label="TOTP Name" grabFocus bind={[name, setName]} />
+ </div>
+ <div style={{ height: 300 }}>
+ <QR text={totpURL} />
+ </div>
+ <p>
+ Confirm that your TOTP App works by entering the current 8-digit TOTP
+ code here:
+ </p>
+ <TextInput
+ label="Test code"
+ onConfirm={goNextIfNoErrors}
+ bind={[test, setTest]}
+ />
+ <div>
+ We note that Google&apos;s implementation of TOTP is incomplete and will
+ not work. We recommend using FreeOTP+.
+ </div>
+
+ {configured.length > 0 && (
+ <section class="section">
+ <div class="block">Your TOTP numbers:</div>
+ <div class="block">
+ {configured.map((c, i) => {
+ return (
+ <div
+ key={i}
+ class="box"
+ style={{ display: "flex", justifyContent: "space-between" }}
+ >
+ <p style={{ marginTop: "auto", marginBottom: "auto" }}>
+ {c.instructions}
+ </p>
+ <div>
+ <button class="button is-danger" onClick={c.remove}>
+ Delete
+ </button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </section>
+ )}
+ <div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={cancel}>
+ Cancel
+ </button>
+ <span data-tooltip={errors}>
+ <button
+ class="button is-info"
+ disabled={errors !== undefined}
+ onClick={addTotpAuth}
+ >
+ Add
+ </button>
+ </span>
+ </div>
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx
new file mode 100644
index 000000000..c120aaadc
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ReducerState } from "@gnu-taler/anastasis-core";
+import * as tests from "@gnu-taler/web-util/testing";
+import { reducerStatesExample } from "../../../utils/index.js";
+import { KnownAuthMethods, authMethods as TestedComponent } from "./index.js";
+
+export default {
+ title: "Auth method: Totp solve",
+ component: TestedComponent,
+ args: {
+ order: 5,
+ },
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+const type: KnownAuthMethods = "totp";
+
+export const WithoutFeedback = tests.createExample(
+ TestedComponent[type].solve,
+ {
+ id: "uuid-1",
+ },
+ {
+ ...reducerStatesExample.challengeSolving,
+ recovery_information: {
+ challenges: [
+ {
+ instructions: "does P equals NP?",
+ type: "question",
+ uuid: "uuid-1",
+ },
+ ],
+ policies: [],
+ },
+ selected_challenge_uuid: "uuid-1",
+ } as ReducerState,
+);
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx
new file mode 100644
index 000000000..0ce0e1016
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx
@@ -0,0 +1,125 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { ChallengeInfo } from "@gnu-taler/anastasis-core";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/AsyncButton.js";
+import { TextInput } from "../../../components/fields/TextInput.js";
+import { useAnastasisContext } from "../../../context/anastasis.js";
+import { AnastasisClientFrame } from "../index.js";
+import { SolveOverviewFeedbackDisplay } from "../SolveScreen.js";
+import { shouldHideConfirm } from "./helpers.js";
+import { AuthMethodSolveProps } from "./index.js";
+
+export function AuthMethodTotpSolve(props: AuthMethodSolveProps): VNode {
+ const [answerCode, setAnswerCode] = useState("");
+
+ const reducer = useAnastasisContext();
+ if (!reducer) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>no reducer in context</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (reducer.currentReducerState?.reducer_type !== "recovery") {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ if (!reducer.currentReducerState.recovery_information) {
+ return (
+ <AnastasisClientFrame
+ hideNext="Recovery document not found"
+ title="Recovery problem"
+ >
+ <div>no recovery information found</div>
+ </AnastasisClientFrame>
+ );
+ }
+ if (!reducer.currentReducerState.selected_challenge_uuid) {
+ return (
+ <AnastasisClientFrame hideNav title="Recovery problem">
+ <div>invalid state</div>
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={() => reducer.back()}>
+ Back
+ </button>
+ </div>
+ </AnastasisClientFrame>
+ );
+ }
+
+ const chArr = reducer.currentReducerState.recovery_information.challenges;
+ const challengeFeedback =
+ reducer.currentReducerState.challenge_feedback ?? {};
+ const selectedUuid = reducer.currentReducerState.selected_challenge_uuid;
+ const challenges: {
+ [uuid: string]: ChallengeInfo;
+ } = {};
+ for (const ch of chArr) {
+ challenges[ch.uuid] = ch;
+ }
+ const feedback = challengeFeedback[selectedUuid];
+
+ async function onNext(): Promise<void> {
+ return reducer?.transition("solve_challenge", {
+ answer: answerCode,
+ });
+ }
+ function onCancel(): void {
+ reducer?.back();
+ }
+
+ return (
+ <AnastasisClientFrame hideNav title="TOTP Challenge">
+ <SolveOverviewFeedbackDisplay feedback={feedback} />
+ <p>enter the totp solution</p>
+ <TextInput
+ label="Answer"
+ onConfirm={onNext}
+ grabFocus
+ bind={[answerCode, setAnswerCode]}
+ />
+
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={onCancel}>
+ Cancel
+ </button>
+ {!shouldHideConfirm(feedback) && (
+ <AsyncButton class="button is-info" onClick={onNext}>
+ Confirm
+ </AsyncButton>
+ )}
+ </div>
+ </AnastasisClientFrame>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/helpers.ts b/packages/anastasis-webui/src/pages/home/authMethod/helpers.ts
new file mode 100644
index 000000000..b6d9f5bbd
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/helpers.ts
@@ -0,0 +1,27 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ ChallengeFeedback,
+ ChallengeFeedbackStatus,
+} from "@gnu-taler/anastasis-core";
+
+export function shouldHideConfirm(feedback: ChallengeFeedback): boolean {
+ return (
+ feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded ||
+ feedback?.state === ChallengeFeedbackStatus.Unsupported ||
+ feedback?.state === ChallengeFeedbackStatus.TruthUnknown
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/index.tsx b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx
new file mode 100644
index 000000000..9f7f4a197
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx
@@ -0,0 +1,109 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { AuthMethod } from "@gnu-taler/anastasis-core";
+import { h, VNode } from "preact";
+import postalIcon from "../../../assets/icons/auth_method/postal.svg";
+import questionIcon from "../../../assets/icons/auth_method/question.svg";
+import smsIcon from "../../../assets/icons/auth_method/sms.svg";
+import { AuthMethodEmailSetup as EmailSetup } from "./AuthMethodEmailSetup.js";
+import { AuthMethodEmailSolve as EmailSolve } from "./AuthMethodEmailSolve.js";
+import { AuthMethodIbanSetup as IbanSetup } from "./AuthMethodIbanSetup.js";
+import { AuthMethodIbanSolve as IbanSolve } from "./AuthMethodIbanSolve.js";
+import { AuthMethodPostSetup as PostalSetup } from "./AuthMethodPostSetup.js";
+import { AuthMethodPostSolve as PostalSolve } from "./AuthMethodPostSolve.js";
+import { AuthMethodQuestionSetup as QuestionSetup } from "./AuthMethodQuestionSetup.js";
+import { AuthMethodQuestionSolve as QuestionSolve } from "./AuthMethodQuestionSolve.js";
+import { AuthMethodSmsSetup as SmsSetup } from "./AuthMethodSmsSetup.js";
+import { AuthMethodSmsSolve as SmsSolve } from "./AuthMethodSmsSolve.js";
+import { AuthMethodTotpSetup as TotpSetup } from "./AuthMethodTotpSetup.js";
+import { AuthMethodTotpSolve as TotpSolve } from "./AuthMethodTotpSolve.js";
+
+export type AuthMethodWithRemove = AuthMethod & { remove: () => void };
+
+export interface AuthMethodSetupProps {
+ method: string;
+ addAuthMethod: (x: any) => void;
+ configured: AuthMethodWithRemove[];
+ cancel: () => void;
+}
+
+export interface AuthMethodSolveProps {
+ id: string;
+}
+
+interface AuthMethodConfiguration {
+ icon: VNode;
+ label: string;
+ setup: (props: AuthMethodSetupProps) => VNode;
+ solve: (props: AuthMethodSolveProps) => VNode;
+ skip?: boolean;
+}
+
+const ALL_METHODS = [
+ "sms",
+ "email",
+ "post",
+ "question",
+ "totp",
+ "iban",
+] as const;
+export type KnownAuthMethods = typeof ALL_METHODS[number];
+export function isKnownAuthMethods(value: string): value is KnownAuthMethods {
+ return ALL_METHODS.includes(value as KnownAuthMethods);
+}
+
+type KnowMethodConfig = {
+ [name in KnownAuthMethods]: AuthMethodConfiguration;
+};
+
+export const authMethods: KnowMethodConfig = {
+ question: {
+ icon: <img src={questionIcon} />,
+ label: "Question",
+ setup: QuestionSetup,
+ solve: QuestionSolve,
+ },
+ sms: {
+ icon: <img src={smsIcon} />,
+ label: "SMS",
+ setup: SmsSetup,
+ solve: SmsSolve,
+ },
+ email: {
+ icon: <i class="mdi mdi-email" />,
+ label: "Email",
+ setup: EmailSetup,
+ solve: EmailSolve,
+ },
+ iban: {
+ icon: <i class="mdi mdi-bank" />,
+ label: "IBAN",
+ setup: IbanSetup,
+ solve: IbanSolve,
+ },
+ post: {
+ icon: <img src={postalIcon} />,
+ label: "Physical mail",
+ setup: PostalSetup,
+ solve: PostalSolve,
+ },
+ totp: {
+ icon: <i class="mdi mdi-devices" />,
+ label: "TOTP",
+ setup: TotpSetup,
+ solve: TotpSolve,
+ },
+};
diff --git a/packages/anastasis-webui/src/pages/home/authMethod/totp.ts b/packages/anastasis-webui/src/pages/home/authMethod/totp.ts
new file mode 100644
index 000000000..ff8027ced
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/authMethod/totp.ts
@@ -0,0 +1,79 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+//@ts-ignore
+import jssha from "jssha";
+
+const SEARCH_RANGE = 16;
+const timeStep = 30;
+
+export function computeTOTPandCheck(
+ secretKey: Uint8Array,
+ digits: number,
+ code: number,
+): boolean {
+ const now = new Date().getTime();
+ const epoch = Math.floor(Math.round(now / 1000.0) / timeStep);
+
+ for (let ms = -SEARCH_RANGE; ms < SEARCH_RANGE; ms++) {
+ const movingFactor = (epoch + ms).toString(16).padStart(16, "0");
+
+ const hmacSha = new jssha("SHA-1", "HEX", {
+ hmacKey: { value: secretKey, format: "UINT8ARRAY" },
+ });
+ hmacSha.update(movingFactor);
+ const hmac_text = hmacSha.getHMAC("UINT8ARRAY");
+
+ const offset = hmac_text[hmac_text.length - 1] & 0xf;
+
+ const otp =
+ (((hmac_text[offset + 0] << 24) +
+ (hmac_text[offset + 1] << 16) +
+ (hmac_text[offset + 2] << 8) +
+ hmac_text[offset + 3]) &
+ 0x7fffffff) %
+ Math.pow(10, digits);
+
+ if (otp == code) return true;
+ }
+ return false;
+}
+
+const encTable__ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".split("");
+export function base32enc(buffer: Uint8Array): string {
+ let rpos = 0;
+ let bits = 0;
+ let vbit = 0;
+
+ let result = "";
+ while (rpos < buffer.length || vbit > 0) {
+ if (rpos < buffer.length && vbit < 5) {
+ bits = (bits << 8) | buffer[rpos++];
+ vbit += 8;
+ }
+ if (vbit < 5) {
+ bits <<= 5 - vbit;
+ vbit = 5;
+ }
+ result += encTable__[(bits >> (vbit - 5)) & 31];
+ vbit -= 5;
+ }
+ return result;
+}
+
+// const array = new Uint8Array(256)
+// const secretKey = window.crypto.getRandomValues(array)
+// console.log(base32enc(secretKey))
diff --git a/packages/anastasis-webui/src/pages/home/index.stories.tsx b/packages/anastasis-webui/src/pages/home/index.stories.tsx
new file mode 100644
index 000000000..b4525b423
--- /dev/null
+++ b/packages/anastasis-webui/src/pages/home/index.stories.tsx
@@ -0,0 +1,52 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export * as AddingProviderScreen from "./AddingProviderScreen/stories.js";
+export * as AttributeEntryScreen from "./AttributeEntryScreen.stories.js";
+
+export * as AuthenticationEditorScreen from "./AuthenticationEditorScreen.stories.js";
+export * as authMethod_AuthMethodEmailSetup from "./authMethod/AuthMethodEmailSetup.stories.js";
+export * as authMethod_AuthMethodEmailSolve from "./authMethod/AuthMethodEmailSolve.stories.js";
+export * as authMethod_AuthMethodIbanSetup from "./authMethod/AuthMethodIbanSetup.stories.js";
+export * as authMethod_AuthMethodIbanSolve from "./authMethod/AuthMethodIbanSolve.stories.js";
+export * as authMethod_AuthMethodPostSetup from "./authMethod/AuthMethodPostSetup.stories.js";
+export * as authMethod_AuthMethodPostSolve from "./authMethod/AuthMethodPostSolve.stories.js";
+export * as authMethod_AuthMethodQuestionSetup from "./authMethod/AuthMethodQuestionSetup.stories.js";
+export * as authMethod_AuthMethodQuestionSolve from "./authMethod/AuthMethodQuestionSolve.stories.js";
+export * as authMethod_AuthMethodSmsSetup from "./authMethod/AuthMethodSmsSetup.stories.js";
+export * as authMethod_AuthMethodSmsSolve from "./authMethod/AuthMethodSmsSolve.stories.js";
+export * as authMethod_AuthMethodTotpSetup from "./authMethod/AuthMethodTotpSetup.stories.js";
+export * as authMethod_AuthMethodTotpSolve from "./authMethod/AuthMethodTotpSolve.stories.js";
+
+export * as BackupFinishedScreen from "./BackupFinishedScreen.stories.js";
+export * as ChallengeOverviewScreen from "./ChallengeOverviewScreen.stories.js";
+export * as ChallengePayingScreen from "./ChallengePayingScreen.stories.js";
+export * as ContinentSelectionScreen from "./ContinentSelectionScreen.stories.js";
+export * as EditPoliciesScreen from "./EditPoliciesScreen.stories.js";
+export * as PoliciesPayingScreen from "./PoliciesPayingScreen.stories.js";
+export * as RecoveryFinishedScreen from "./RecoveryFinishedScreen.stories.js";
+
+export * as ReviewPoliciesScreen from "./ReviewPoliciesScreen.stories.js";
+export * as SecretEditorScreen from "./SecretEditorScreen.stories.js";
+export * as SecretSelectionScreen from "./SecretSelectionScreen.stories.js";
+export * as SolveScreen from "./SolveScreen.stories.js";
+export * as StartScreen from "./StartScreen.stories.js";
+export * as TruthsPayingScreen from "./TruthsPayingScreen.stories.js";
diff --git a/packages/anastasis-webui/src/pages/home/index.tsx b/packages/anastasis-webui/src/pages/home/index.tsx
index 4cec47ec8..c665144a4 100644
--- a/packages/anastasis-webui/src/pages/home/index.tsx
+++ b/packages/anastasis-webui/src/pages/home/index.tsx
@@ -1,43 +1,55 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { BackupStates, RecoveryStates } from "@gnu-taler/anastasis-core";
import {
- BackupStates,
- RecoveryStates,
- ReducerStateBackup,
- ReducerStateRecovery
-} from "anastasis-core";
-import {
- ComponentChildren, Fragment,
+ ComponentChildren,
+ Fragment,
FunctionalComponent,
h,
- VNode
+ VNode,
} from "preact";
+import { useCallback, useEffect, useErrorBoundary } from "preact/hooks";
+import { AsyncButton } from "../../components/AsyncButton.js";
+import { Menu } from "../../components/menu/index.js";
+import { Notifications } from "../../components/Notifications.js";
import {
- useErrorBoundary,
- useLayoutEffect,
- useRef
-} from "preact/hooks";
-import { Menu } from "../../components/menu";
-import { AnastasisProvider, useAnastasisContext } from "../../context/anastasis";
+ AnastasisProvider,
+ useAnastasisContext,
+} from "../../context/anastasis.js";
import {
AnastasisReducerApi,
- useAnastasisReducer
-} from "../../hooks/use-anastasis-reducer";
-import { AttributeEntryScreen } from "./AttributeEntryScreen";
-import { AuthenticationEditorScreen } from "./AuthenticationEditorScreen";
-import { BackupFinishedScreen } from "./BackupFinishedScreen";
-import { ChallengeOverviewScreen } from "./ChallengeOverviewScreen";
-import { ContinentSelectionScreen } from "./ContinentSelectionScreen";
-import { CountrySelectionScreen } from "./CountrySelectionScreen";
-import { PoliciesPayingScreen } from "./PoliciesPayingScreen";
-import { RecoveryFinishedScreen } from "./RecoveryFinishedScreen";
-import { ReviewPoliciesScreen } from "./ReviewPoliciesScreen";
-import { SecretEditorScreen } from "./SecretEditorScreen";
-import { SecretSelectionScreen } from "./SecretSelectionScreen";
-import { SolveScreen } from "./SolveScreen";
-import { StartScreen } from "./StartScreen";
-import { TruthsPayingScreen } from "./TruthsPayingScreen";
+ useAnastasisReducer,
+} from "../../hooks/use-anastasis-reducer.js";
+import { AttributeEntryScreen } from "./AttributeEntryScreen.js";
+import { AuthenticationEditorScreen } from "./AuthenticationEditorScreen.js";
+import { BackupFinishedScreen } from "./BackupFinishedScreen.js";
+import { ChallengeOverviewScreen } from "./ChallengeOverviewScreen.js";
+import { ChallengePayingScreen } from "./ChallengePayingScreen.js";
+import { ContinentSelectionScreen } from "./ContinentSelectionScreen.js";
+import { PoliciesPayingScreen } from "./PoliciesPayingScreen.js";
+import { RecoveryFinishedScreen } from "./RecoveryFinishedScreen.js";
+import { ReviewPoliciesScreen } from "./ReviewPoliciesScreen.js";
+import { SecretEditorScreen } from "./SecretEditorScreen.js";
+import { SecretSelectionScreen } from "./SecretSelectionScreen.js";
+import { SolveScreen } from "./SolveScreen.js";
+import { StartScreen } from "./StartScreen.js";
+import { TruthsPayingScreen } from "./TruthsPayingScreen.js";
function isBackup(reducer: AnastasisReducerApi): boolean {
- return !!reducer.currentReducerState?.backup_state;
+ return reducer.currentReducerState?.reducer_type === "backup";
}
export function withProcessLabel(
@@ -51,7 +63,11 @@ export function withProcessLabel(
}
interface AnastasisClientFrameProps {
- onNext?(): void;
+ onNext?(): Promise<void>;
+ /**
+ * Override for the "back" functionality.
+ */
+ onBack?(): Promise<void>;
title: string;
children: ComponentChildren;
/**
@@ -61,7 +77,7 @@ interface AnastasisClientFrameProps {
/**
* Hide only the "next" button.
*/
- hideNext?: boolean;
+ hideNext?: string;
}
function ErrorBoundary(props: {
@@ -69,7 +85,7 @@ function ErrorBoundary(props: {
children: ComponentChildren;
}): VNode {
const [error, resetError] = useErrorBoundary((error) =>
- console.log("got error", error),
+ console.log("ErrorBoundary got error", error),
);
if (error) {
return (
@@ -91,39 +107,100 @@ function ErrorBoundary(props: {
return <div>{props.children}</div>;
}
+let currentHistoryId = 0;
+
export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode {
const reducer = useAnastasisContext();
- if (!reducer) {
- return <p>Fatal: Reducer must be in context.</p>;
- }
- const next = (): void => {
+
+ const doBack = async (): Promise<void> => {
+ if (props.onBack) {
+ await props.onBack();
+ } else {
+ if (!reducer) return;
+ await reducer.back();
+ }
+ };
+ const doNext = async (fromPopstate?: boolean): Promise<void> => {
+ if (!fromPopstate) {
+ try {
+ const nextId: number =
+ (history.state && typeof history.state.id === "number"
+ ? history.state.id
+ : 0) + 1;
+
+ currentHistoryId = nextId;
+
+ history.pushState({ id: nextId }, "unused", `#${nextId}`);
+ } catch (e) {
+ console.log("ERROR doNext ", e);
+ }
+ }
+
if (props.onNext) {
- props.onNext();
+ await props.onNext();
} else {
- reducer.transition("next", {});
+ if (!reducer) return;
+ await reducer.transition("next", {});
}
};
const handleKeyPress = (
e: h.JSX.TargetedKeyboardEvent<HTMLDivElement>,
): void => {
- console.log("Got key press", e.key);
+ // console.log("Got key press", e.key);
// FIXME: By default, "next" action should be executed here
};
+
+ const browserOnBackButton = useCallback(async (ev: PopStateEvent) => {
+ //check if we are going back or forward
+ if (!ev.state || ev.state.id === 0 || ev.state.id < currentHistoryId) {
+ await doBack();
+ } else {
+ await doNext(true);
+ }
+
+ // reducer
+ return false;
+ }, []);
+ useEffect(() => {
+ window.addEventListener("popstate", browserOnBackButton);
+
+ return () => {
+ window.removeEventListener("popstate", browserOnBackButton);
+ };
+ }, []);
+ // if (!reducer) {
+ // return <p>Fatal: Reducer must be in context.</p>;
+ // }
+
return (
<Fragment>
- <Menu title="Anastasis" />
- <div>
- <div class="home" onKeyPress={(e) => handleKeyPress(e)}>
- <h1>{props.title}</h1>
- <ErrorBanner />
+ <div class="home" onKeyPress={(e) => handleKeyPress(e)}>
+ <h1 class="title">{props.title}</h1>
+ <ErrorBanner />
+ <section class="section is-main-section">
{props.children}
{!props.hideNav ? (
- <div>
- <button onClick={() => reducer.back()}>Back</button>
- {!props.hideNext ? <button onClick={next}>Next</button> : null}
+ <div
+ style={{
+ marginTop: "2em",
+ display: "flex",
+ justifyContent: "space-between",
+ }}
+ >
+ <button class="button" onClick={() => doBack()}>
+ Back
+ </button>
+ <AsyncButton
+ class="button is-info"
+ data-tooltip={props.hideNext}
+ onClick={() => doNext()}
+ disabled={props.hideNext !== undefined}
+ >
+ Next
+ </AsyncButton>
</div>
) : null}
- </div>
+ </section>
</div>
</Fragment>
);
@@ -134,14 +211,15 @@ const AnastasisClient: FunctionalComponent = () => {
return (
<AnastasisProvider value={reducer}>
<ErrorBoundary reducer={reducer}>
+ <Menu title="Anastasis" />
<AnastasisClientImpl />
</ErrorBoundary>
</AnastasisProvider>
);
};
-const AnastasisClientImpl: FunctionalComponent = () => {
- const reducer = useAnastasisContext()
+function AnastasisClientImpl(): VNode {
+ const reducer = useAnastasisContext();
if (!reducer) {
return <p>Fatal: Reducer must be in context.</p>;
}
@@ -149,115 +227,113 @@ const AnastasisClientImpl: FunctionalComponent = () => {
if (!state) {
return <StartScreen />;
}
- console.log("state", reducer.currentReducerState);
+
+ // FIXME: Use switch statements here!
if (
- state.backup_state === BackupStates.ContinentSelecting ||
- state.recovery_state === RecoveryStates.ContinentSelecting
+ (state.reducer_type === "backup" &&
+ state.backup_state === BackupStates.ContinentSelecting) ||
+ (state.reducer_type === "recovery" &&
+ state.recovery_state === RecoveryStates.ContinentSelecting) ||
+ (state.reducer_type === "backup" &&
+ state.backup_state === BackupStates.CountrySelecting) ||
+ (state.reducer_type === "recovery" &&
+ state.recovery_state === RecoveryStates.CountrySelecting)
) {
- return (
- <ContinentSelectionScreen />
- );
+ return <ContinentSelectionScreen />;
}
if (
- state.backup_state === BackupStates.CountrySelecting ||
- state.recovery_state === RecoveryStates.CountrySelecting
+ (state.reducer_type === "backup" &&
+ state.backup_state === BackupStates.UserAttributesCollecting) ||
+ (state.reducer_type === "recovery" &&
+ state.recovery_state === RecoveryStates.UserAttributesCollecting)
) {
- return (
- <CountrySelectionScreen />
- );
+ return <AttributeEntryScreen />;
}
if (
- state.backup_state === BackupStates.UserAttributesCollecting ||
- state.recovery_state === RecoveryStates.UserAttributesCollecting
+ state.reducer_type === "backup" &&
+ state.backup_state === BackupStates.AuthenticationsEditing
) {
- return (
- <AttributeEntryScreen />
- );
+ return <AuthenticationEditorScreen />;
}
- if (state.backup_state === BackupStates.AuthenticationsEditing) {
- return (
- <AuthenticationEditorScreen />
- );
- }
- if (state.backup_state === BackupStates.PoliciesReviewing) {
- return (
- <ReviewPoliciesScreen />
- );
+ if (
+ state.reducer_type === "backup" &&
+ state.backup_state === BackupStates.PoliciesReviewing
+ ) {
+ return <ReviewPoliciesScreen />;
}
- if (state.backup_state === BackupStates.SecretEditing) {
+ if (
+ state.reducer_type === "backup" &&
+ state.backup_state === BackupStates.SecretEditing
+ ) {
return <SecretEditorScreen />;
}
- if (state.backup_state === BackupStates.BackupFinished) {
+ if (
+ state.reducer_type === "backup" &&
+ state.backup_state === BackupStates.BackupFinished
+ ) {
return <BackupFinishedScreen />;
}
- if (state.backup_state === BackupStates.TruthsPaying) {
+ if (
+ state.reducer_type === "backup" &&
+ state.backup_state === BackupStates.TruthsPaying
+ ) {
return <TruthsPayingScreen />;
}
- if (state.backup_state === BackupStates.PoliciesPaying) {
+ if (
+ state.reducer_type === "backup" &&
+ state.backup_state === BackupStates.PoliciesPaying
+ ) {
return <PoliciesPayingScreen />;
}
- if (state.recovery_state === RecoveryStates.SecretSelecting) {
- return (
- <SecretSelectionScreen />
- );
+ if (
+ state.reducer_type === "recovery" &&
+ state.recovery_state === RecoveryStates.SecretSelecting
+ ) {
+ return <SecretSelectionScreen />;
}
- if (state.recovery_state === RecoveryStates.ChallengeSelecting) {
- return (
- <ChallengeOverviewScreen />
- );
+ if (
+ state.reducer_type === "recovery" &&
+ state.recovery_state === RecoveryStates.ChallengeSelecting
+ ) {
+ return <ChallengeOverviewScreen />;
}
- if (state.recovery_state === RecoveryStates.ChallengeSolving) {
+ if (
+ state.reducer_type === "recovery" &&
+ state.recovery_state === RecoveryStates.ChallengeSolving
+ ) {
return <SolveScreen />;
}
- if (state.recovery_state === RecoveryStates.RecoveryFinished) {
- return (
- <RecoveryFinishedScreen />
- );
+ if (
+ state.reducer_type === "recovery" &&
+ state.recovery_state === RecoveryStates.RecoveryFinished
+ ) {
+ return <RecoveryFinishedScreen />;
+ }
+ if (
+ state.reducer_type === "recovery" &&
+ state.recovery_state === RecoveryStates.ChallengePaying
+ ) {
+ return <ChallengePayingScreen />;
}
-
console.log("unknown state", reducer.currentReducerState);
return (
<AnastasisClientFrame hideNav title="Bug">
<p>Bug: Unknown state.</p>
<div class="buttons is-right">
- <button class="button" onClick={() => reducer.reset()}>Reset</button>
+ <button class="button" onClick={() => reducer.reset()}>
+ Reset
+ </button>
</div>
</AnastasisClientFrame>
);
-};
-
-interface LabeledInputProps {
- label: string;
- grabFocus?: boolean;
- bind: [string, (x: string) => void];
-}
-
-export function LabeledInput(props: LabeledInputProps): VNode {
- const inputRef = useRef<HTMLInputElement>(null);
- useLayoutEffect(() => {
- if (props.grabFocus) {
- inputRef.current?.focus();
- }
- }, [props.grabFocus]);
- return (
- <label>
- {props.label}
- <input
- value={props.bind[0]}
- onChange={(e) => props.bind[1]((e.target as HTMLInputElement).value)}
- ref={inputRef}
- style={{ display: "block" }}
- />
- </label>
- );
}
/**
@@ -267,12 +343,16 @@ function ErrorBanner(): VNode | null {
const reducer = useAnastasisContext();
if (!reducer || !reducer.currentError) return null;
return (
- <div id="error">
- <p>Error: {JSON.stringify(reducer.currentError)}</p>
- <button onClick={() => reducer.dismissError()}>
- Dismiss Error
- </button>
- </div>
+ <Notifications
+ removeNotification={reducer.dismissError}
+ notifications={[
+ {
+ type: "ERROR",
+ message: `Error code: ${reducer.currentError.code}`,
+ description: reducer.currentError.hint,
+ },
+ ]}
+ />
);
}
diff --git a/packages/anastasis-webui/src/pages/home/style.css b/packages/anastasis-webui/src/pages/home/style.css
deleted file mode 100644
index e69de29bb..000000000
--- a/packages/anastasis-webui/src/pages/home/style.css
+++ /dev/null
diff --git a/packages/anastasis-webui/src/pages/notfound/index.tsx b/packages/anastasis-webui/src/pages/notfound/index.tsx
deleted file mode 100644
index 4e74d1d9f..000000000
--- a/packages/anastasis-webui/src/pages/notfound/index.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { FunctionalComponent, h } from 'preact';
-import { Link } from 'preact-router/match';
-
-const Notfound: FunctionalComponent = () => {
- return (
- <div>
- <h1>Error 404</h1>
- <p>That page doesn&apos;t exist.</p>
- <Link href="/">
- <h4>Back to Home</h4>
- </Link>
- </div>
- );
-};
-
-export default Notfound;
diff --git a/packages/anastasis-webui/src/pages/notfound/style.css b/packages/anastasis-webui/src/pages/notfound/style.css
deleted file mode 100644
index e69de29bb..000000000
--- a/packages/anastasis-webui/src/pages/notfound/style.css
+++ /dev/null
diff --git a/packages/anastasis-webui/src/pages/profile/index.tsx b/packages/anastasis-webui/src/pages/profile/index.tsx
deleted file mode 100644
index 859a83ed4..000000000
--- a/packages/anastasis-webui/src/pages/profile/index.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { FunctionalComponent, h } from 'preact';
-import { useEffect, useState } from 'preact/hooks';
-
-interface Props {
- user: string;
-}
-
-const Profile: FunctionalComponent<Props> = (props: Props) => {
- const { user } = props;
- const [time, setTime] = useState<number>(Date.now());
- const [count, setCount] = useState<number>(0);
-
- // gets called when this route is navigated to
- useEffect(() => {
- const timer = window.setInterval(() => setTime(Date.now()), 1000);
-
- // gets called just before navigating away from the route
- return (): void => {
- clearInterval(timer);
- };
- }, []);
-
- // update the current time
- const increment = (): void => {
- setCount(count + 1);
- };
-
- return (
- <div>
- <h1>Profile: {user}</h1>
- <p>This is the user profile for a user named {user}.</p>
-
- <div>Current time: {new Date(time).toLocaleString()}</div>
-
- <p>
- <button onClick={increment}>Click Me</button> Clicked {count}{' '}
- times.
- </p>
- </div>
- );
-};
-
-export default Profile;
diff --git a/packages/anastasis-webui/src/pages/profile/style.css b/packages/anastasis-webui/src/pages/profile/style.css
deleted file mode 100644
index e69de29bb..000000000
--- a/packages/anastasis-webui/src/pages/profile/style.css
+++ /dev/null
diff --git a/packages/anastasis-webui/src/scss/DurationPicker.scss b/packages/anastasis-webui/src/scss/DurationPicker.scss
index a35575324..aa75b9916 100644
--- a/packages/anastasis-webui/src/scss/DurationPicker.scss
+++ b/packages/anastasis-webui/src/scss/DurationPicker.scss
@@ -1,4 +1,3 @@
-
.rdp-picker {
display: flex;
height: 175px;
diff --git a/packages/anastasis-webui/src/scss/_aside.scss b/packages/anastasis-webui/src/scss/_aside.scss
index c9332b252..2b2b0f47a 100644
--- a/packages/anastasis-webui/src/scss/_aside.scss
+++ b/packages/anastasis-webui/src/scss/_aside.scss
@@ -1,17 +1,17 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
@@ -19,37 +19,35 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-@include desktop {
- html {
- &.has-aside-left {
- &.has-aside-expanded {
- nav.navbar,
- body {
- padding-left: $aside-width;
- }
- }
- aside.is-placed-left {
- display: block;
+html {
+ &.has-aside-left {
+ &.has-aside-expanded {
+ nav.navbar,
+ body {
+ padding-left: $aside-width;
}
}
+ aside.is-placed-left {
+ display: block;
+ }
}
+}
- aside.aside.is-expanded {
- width: $aside-width;
+aside.aside.is-expanded {
+ width: $aside-width;
- .menu-list {
- @include icon-with-update-mark($aside-icon-width);
+ .menu-list {
+ @include icon-with-update-mark($aside-icon-width);
- span.menu-item-label {
- display: inline-block;
- }
+ span.menu-item-label {
+ display: inline-block;
+ }
- li.is-active {
- ul {
- display: block;
- }
- background-color: $body-background-color;
+ li.is-active {
+ ul {
+ display: block;
}
+ background-color: $body-background-color;
}
}
}
@@ -128,59 +126,3 @@ aside.aside {
margin-bottom: $default-padding * 0.5;
}
}
-
-@include touch {
- nav.navbar {
- @include transition(margin-left);
- }
- aside.aside {
- @include transition(left);
- }
- html.has-aside-mobile-transition {
- body {
- overflow-x: hidden;
- }
- body,
- nav.navbar {
- width: 100vw;
- }
- aside.aside {
- width: $aside-mobile-width;
- display: block;
- left: $aside-mobile-width * -1;
-
- .image {
- img {
- max-width: $aside-mobile-width * 0.33;
- }
- }
-
- .menu-list {
- li.is-active {
- ul {
- display: block;
- }
- background-color: $body-background-color;
- }
- li {
- @include icon-with-update-mark($aside-icon-width);
- margin-top: 8px;
- margin-bottom: 8px;
- }
- a {
- span.menu-item-label {
- display: inline-block;
- }
- }
- }
- }
- }
- div.has-aside-mobile-expanded {
- nav.navbar {
- margin-left: $aside-mobile-width;
- }
- aside.aside {
- left: 0;
- }
- }
-}
diff --git a/packages/anastasis-webui/src/scss/_card.scss b/packages/anastasis-webui/src/scss/_card.scss
index b2eec27a1..5403041f3 100644
--- a/packages/anastasis-webui/src/scss/_card.scss
+++ b/packages/anastasis-webui/src/scss/_card.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
@@ -39,7 +39,7 @@
&.is-card-widget {
.card-content {
- padding: $default-padding * .5;
+ padding: $default-padding * 0.5;
}
}
diff --git a/packages/anastasis-webui/src/scss/_custom-calendar.scss b/packages/anastasis-webui/src/scss/_custom-calendar.scss
index 9ac877ce0..21d767f1e 100644
--- a/packages/anastasis-webui/src/scss/_custom-calendar.scss
+++ b/packages/anastasis-webui/src/scss/_custom-calendar.scss
@@ -1,46 +1,49 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
:root {
--primary-color: #3298dc;
-
- --primary-text-color-dark: rgba(0,0,0,.87);
- --secondary-text-color-dark: rgba(0,0,0,.57);
- --disabled-text-color-dark: rgba(0,0,0,.13);
-
- --primary-text-color-light: rgba(255,255,255,.87);
- --secondary-text-color-light: rgba(255,255,255,.57);
- --disabled-text-color-light: rgba(255,255,255,.13);
-
- --font-stack: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-
+
+ --primary-text-color-dark: rgba(0, 0, 0, 0.87);
+ --secondary-text-color-dark: rgba(0, 0, 0, 0.57);
+ --disabled-text-color-dark: rgba(0, 0, 0, 0.13);
+
+ --primary-text-color-light: rgba(255, 255, 255, 0.87);
+ --secondary-text-color-light: rgba(255, 255, 255, 0.57);
+ --disabled-text-color-light: rgba(255, 255, 255, 0.13);
+
+ --font-stack: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif;
+
--primary-card-color: #fff;
--primary-background-color: #f2f2f2;
-
+
--box-shadow-lvl-1: 0 1px 3px rgba(0, 0, 0, 0.12),
- 0 1px 2px rgba(0, 0, 0, 0.24);
+ 0 1px 2px rgba(0, 0, 0, 0.24);
--box-shadow-lvl-2: 0 3px 6px rgba(0, 0, 0, 0.16),
- 0 3px 6px rgba(0, 0, 0, 0.23);
+ 0 3px 6px rgba(0, 0, 0, 0.23);
--box-shadow-lvl-3: 0 10px 20px rgba(0, 0, 0, 0.19),
- 0 6px 6px rgba(0, 0, 0, 0.23);
+ 0 6px 6px rgba(0, 0, 0, 0.23);
--box-shadow-lvl-4: 0 14px 28px rgba(0, 0, 0, 0.25),
- 0 10px 10px rgba(0, 0, 0, 0.22);
+ 0 10px 10px rgba(0, 0, 0, 0.22);
}
-
+.home .datePicker div {
+ margin-top: 0px;
+ margin-bottom: 0px;
+}
.datePicker {
text-align: left;
background: var(--primary-card-color);
@@ -52,7 +55,7 @@
width: 90vw;
max-width: 448px;
transform-origin: top left;
- transition: transform .22s ease-in-out, opacity .22s ease-in-out;
+ transition: transform 0.22s ease-in-out, opacity 0.22s ease-in-out;
top: 50%;
left: 50%;
opacity: 0;
@@ -63,7 +66,7 @@
opacity: 1;
transform: scale(1) translate(-50%, -50%);
}
-
+
.datePicker--titles {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
@@ -71,7 +74,8 @@
height: 100px;
background: var(--primary-color);
- h2, h3 {
+ h2,
+ h3 {
cursor: pointer;
color: #fff;
line-height: 1;
@@ -81,7 +85,7 @@
}
h3 {
- color: rgba(255,255,255,.57);
+ color: rgba(255, 255, 255, 0.57);
font-size: 18px;
padding-bottom: 2px;
}
@@ -110,13 +114,13 @@
font-size: 26px;
user-select: none;
border-radius: 50%;
-
+
&:hover {
background: var(--disabled-text-color-dark);
}
}
}
-
+
.datePicker--scroll {
overflow-y: auto;
max-height: calc(90vh - 56px - 100px);
@@ -129,9 +133,11 @@
width: 100%;
display: grid;
text-align: center;
-
+
// there's probably a better way to do this, but wanted to try out CSS grid
- grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7);
+ grid-template-columns:
+ calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7)
+ calc(100% / 7) calc(100% / 7) calc(100% / 7);
span {
color: var(--secondary-text-color-dark);
@@ -145,14 +151,16 @@
width: 100%;
display: grid;
text-align: center;
- grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7);
+ grid-template-columns:
+ calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7)
+ calc(100% / 7) calc(100% / 7) calc(100% / 7);
span {
color: var(--primary-text-color-dark);
line-height: 42px;
font-size: 14px;
display: inline-grid;
- transition: color .22s;
+ transition: color 0.22s;
height: 42px;
position: relative;
cursor: pointer;
@@ -160,7 +168,7 @@
border-radius: 50%;
&::before {
- content: '';
+ content: "";
position: absolute;
z-index: -1;
height: 42px;
@@ -168,12 +176,12 @@
left: calc(50% - 21px);
background: var(--primary-color);
border-radius: 50%;
- transition: transform .22s, opacity .22s;
+ transition: transform 0.22s, opacity 0.22s;
transform: scale(0);
opacity: 0;
}
-
- &[disabled=true] {
+
+ &[disabled="true"] {
cursor: unset;
}
@@ -182,7 +190,7 @@
}
&.datePicker--selected {
- color: rgba(255,255,255,.87);
+ color: rgba(255, 255, 255, 0.87);
&:before {
transform: scale(1);
@@ -192,21 +200,21 @@
}
}
}
-
+
.datePicker--selectYear {
padding: 0 20px;
display: block;
width: 100%;
text-align: center;
max-height: 362px;
-
+
span {
display: block;
width: 100%;
font-size: 24px;
margin: 20px auto;
cursor: pointer;
-
+
&.selected {
font-size: 42px;
color: var(--primary-color);
@@ -232,9 +240,10 @@
appearance: none;
padding: 0 16px;
border-radius: 3px;
- transition: background-color .13s;
+ transition: background-color 0.13s;
- &:hover, &:focus {
+ &:hover,
+ &:focus {
outline: none;
background-color: var(--disabled-text-color-dark);
}
@@ -249,6 +258,6 @@
left: 0;
bottom: 0;
right: 0;
- background: rgba(0,0,0,.52);
- animation: fadeIn .22s forwards;
+ background: rgba(0, 0, 0, 0.52);
+ animation: fadeIn 0.22s forwards;
}
diff --git a/packages/anastasis-webui/src/scss/_footer.scss b/packages/anastasis-webui/src/scss/_footer.scss
index 027a5ca8b..2ecc95b06 100644
--- a/packages/anastasis-webui/src/scss/_footer.scss
+++ b/packages/anastasis-webui/src/scss/_footer.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
diff --git a/packages/anastasis-webui/src/scss/_form.scss b/packages/anastasis-webui/src/scss/_form.scss
index 71f0d4da4..35e257e61 100644
--- a/packages/anastasis-webui/src/scss/_form.scss
+++ b/packages/anastasis-webui/src/scss/_form.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
@@ -22,11 +22,12 @@
.field {
&.has-check {
.field-body {
- margin-top: $default-padding * .125;
+ margin-top: $default-padding * 0.125;
}
}
.control {
- .mdi-24px.mdi-set, .mdi-24px.mdi:before {
+ .mdi-24px.mdi-set,
+ .mdi-24px.mdi:before {
font-size: inherit;
}
}
@@ -37,28 +38,34 @@
}
}
-.input, .textarea, select {
+.input,
+.textarea,
+select {
box-shadow: none;
- &:focus, &:active {
- box-shadow: none!important;
+ &:focus,
+ &:active {
+ box-shadow: none !important;
}
}
-.switch input[type=checkbox]+.check:before {
+.switch input[type="checkbox"] + .check:before {
box-shadow: none;
}
-.switch, .b-checkbox.checkbox {
- input[type=checkbox] {
- &:focus + .check, &:focus:checked + .check {
- box-shadow: none!important;
+.switch,
+.b-checkbox.checkbox {
+ input[type="checkbox"] {
+ &:focus + .check,
+ &:focus:checked + .check {
+ box-shadow: none !important;
}
}
}
-.b-checkbox.checkbox input[type=checkbox], .b-radio.radio input[type=radio] {
- &+.check {
+.b-checkbox.checkbox input[type="checkbox"],
+.b-radio.radio input[type="radio"] {
+ & + .check {
border: $checkbox-border;
}
}
diff --git a/packages/anastasis-webui/src/scss/_hero-bar.scss b/packages/anastasis-webui/src/scss/_hero-bar.scss
index 90b67a2ed..e81d69501 100644
--- a/packages/anastasis-webui/src/scss/_hero-bar.scss
+++ b/packages/anastasis-webui/src/scss/_hero-bar.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
@@ -32,17 +32,17 @@ section.hero.is-hero-bar {
}
> div > .level {
- margin-bottom: $default-padding * .5;
+ margin-bottom: $default-padding * 0.5;
}
.subtitle + p {
- margin-top: $default-padding * .5;
+ margin-top: $default-padding * 0.5;
}
}
.button {
&.is-hero-button {
- background-color: rgba($white, .5);
+ background-color: rgba($white, 0.5);
font-weight: 300;
@include transition(background-color);
diff --git a/packages/anastasis-webui/src/scss/_loading.scss b/packages/anastasis-webui/src/scss/_loading.scss
index d25bf8048..e34ae2e6c 100644
--- a/packages/anastasis-webui/src/scss/_loading.scss
+++ b/packages/anastasis-webui/src/scss/_loading.scss
@@ -1,17 +1,17 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
.lds-ring {
diff --git a/packages/anastasis-webui/src/scss/_main-section.scss b/packages/anastasis-webui/src/scss/_main-section.scss
index 1a4fad81d..e1f46b421 100644
--- a/packages/anastasis-webui/src/scss/_main-section.scss
+++ b/packages/anastasis-webui/src/scss/_main-section.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
diff --git a/packages/anastasis-webui/src/scss/_misc.scss b/packages/anastasis-webui/src/scss/_misc.scss
index 65bd28dbd..d2aa1e4df 100644
--- a/packages/anastasis-webui/src/scss/_misc.scss
+++ b/packages/anastasis-webui/src/scss/_misc.scss
@@ -1,17 +1,17 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
diff --git a/packages/anastasis-webui/src/scss/_mixins.scss b/packages/anastasis-webui/src/scss/_mixins.scss
index 0809033ed..a0fe6e93e 100644
--- a/packages/anastasis-webui/src/scss/_mixins.scss
+++ b/packages/anastasis-webui/src/scss/_mixins.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
@@ -23,12 +23,12 @@
transition: $t 250ms ease-in-out 50ms;
}
-@mixin icon-with-update-mark ($icon-base-width) {
+@mixin icon-with-update-mark($icon-base-width) {
.icon {
width: $icon-base-width;
&.has-update-mark:after {
- right: ($icon-base-width / 2) - .85;
+ right: calc($icon-base-width / 2) - 0.85;
}
}
}
diff --git a/packages/anastasis-webui/src/scss/_modal.scss b/packages/anastasis-webui/src/scss/_modal.scss
index 3edbb8d3a..b7d15de54 100644
--- a/packages/anastasis-webui/src/scss/_modal.scss
+++ b/packages/anastasis-webui/src/scss/_modal.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
diff --git a/packages/anastasis-webui/src/scss/_nav-bar.scss b/packages/anastasis-webui/src/scss/_nav-bar.scss
index 09f1e2326..3d4c7b272 100644
--- a/packages/anastasis-webui/src/scss/_nav-bar.scss
+++ b/packages/anastasis-webui/src/scss/_nav-bar.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
@@ -25,7 +25,7 @@ nav.navbar {
.navbar-item {
&.has-user-avatar {
.is-user-avatar {
- margin-right: $default-padding * .5;
+ margin-right: $default-padding * 0.5;
display: inline-flex;
width: $navbar-avatar-size;
height: $navbar-avatar-size;
@@ -98,11 +98,11 @@ nav.navbar {
.navbar-item {
.icon:first-child {
- margin-right: $default-padding * .5;
+ margin-right: $default-padding * 0.5;
}
&.has-dropdown {
- >.navbar-link {
+ > .navbar-link {
background-color: $white-ter;
.icon:last-child {
display: none;
@@ -111,11 +111,11 @@ nav.navbar {
}
&.has-user-avatar {
- >.navbar-link {
+ > .navbar-link {
display: flex;
align-items: center;
- padding-top: $default-padding * .5;
- padding-bottom: $default-padding * .5;
+ padding-top: $default-padding * 0.5;
+ padding-bottom: $default-padding * 0.5;
}
}
}
@@ -131,7 +131,7 @@ nav.navbar {
&:not(.is-desktop-icon-only) {
.icon:first-child {
- margin-right: $default-padding * .5;
+ margin-right: $default-padding * 0.5;
}
}
&.is-desktop-icon-only {
diff --git a/packages/anastasis-webui/src/scss/_table.scss b/packages/anastasis-webui/src/scss/_table.scss
index 9cf6f4dcd..8349f0430 100644
--- a/packages/anastasis-webui/src/scss/_table.scss
+++ b/packages/anastasis-webui/src/scss/_table.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
@@ -26,7 +26,8 @@ table.table {
}
}
- td, th {
+ td,
+ th {
&.checkbox-cell {
.b-checkbox.checkbox:not(.button) {
margin-right: 0;
@@ -83,7 +84,9 @@ table.table {
}
}
- .pagination-previous, .pagination-next, .pagination-link {
+ .pagination-previous,
+ .pagination-next,
+ .pagination-link {
border-color: $button-border-color;
color: $base-color;
@@ -108,24 +111,25 @@ table.table {
&.has-mobile-sort-spaced {
.b-table {
.field.table-mobile-sort {
- padding-top: $default-padding * .5;
+ padding-top: $default-padding * 0.5;
}
}
}
}
.b-table {
.field.table-mobile-sort {
- padding: 0 $default-padding * .5;
+ padding: 0 $default-padding * 0.5;
}
.table-wrapper.has-mobile-cards {
tr {
box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1);
- margin-bottom: 3px!important;
+ margin-bottom: 3px !important;
}
td {
&.is-progress-col {
- span, progress {
+ span,
+ progress {
display: flex;
width: 45%;
align-items: center;
@@ -133,11 +137,13 @@ table.table {
}
}
- &.checkbox-cell, &.is-image-cell {
- border-bottom: 0!important;
+ &.checkbox-cell,
+ &.is-image-cell {
+ border-bottom: 0 !important;
}
- &.checkbox-cell, &.is-actions-cell {
+ &.checkbox-cell,
+ &.is-actions-cell {
&:before {
display: none;
}
@@ -163,7 +169,7 @@ table.table {
.image {
width: $table-avatar-size-mobile;
height: auto;
- margin: 0 auto $default-padding * .25;
+ margin: 0 auto $default-padding * 0.25;
}
}
}
diff --git a/packages/anastasis-webui/src/scss/_theme-default.scss b/packages/anastasis-webui/src/scss/_theme-default.scss
index 538dfd4da..d0f97d0c7 100644
--- a/packages/anastasis-webui/src/scss/_theme-default.scss
+++ b/packages/anastasis-webui/src/scss/_theme-default.scss
@@ -1,17 +1,17 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
diff --git a/packages/anastasis-webui/src/scss/_tiles.scss b/packages/anastasis-webui/src/scss/_tiles.scss
index 94fc04e70..ea6778dd6 100644
--- a/packages/anastasis-webui/src/scss/_tiles.scss
+++ b/packages/anastasis-webui/src/scss/_tiles.scss
@@ -1,25 +1,24 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
-
.is-tiles-wrapper {
margin-bottom: $default-padding;
}
diff --git a/packages/anastasis-webui/src/scss/_title-bar.scss b/packages/anastasis-webui/src/scss/_title-bar.scss
index 736f26cbd..2ef027f9e 100644
--- a/packages/anastasis-webui/src/scss/_title-bar.scss
+++ b/packages/anastasis-webui/src/scss/_title-bar.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
@@ -26,14 +26,14 @@ section.section.is-title-bar {
ul {
li {
display: inline-block;
- padding: 0 $default-padding * .5 0 0;
+ padding: 0 $default-padding * 0.5 0 0;
font-size: $default-padding;
color: $title-bar-color;
&:after {
display: inline-block;
- content: '/';
- padding-left: $default-padding * .5;
+ content: "/";
+ padding-left: $default-padding * 0.5;
}
&:last-child {
diff --git a/packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eot b/packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.eot
index ab6b25ded..ab6b25ded 100644
--- a/packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eot
+++ b/packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.eot
Binary files differ
diff --git a/packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttf b/packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.ttf
index 824be10fa..824be10fa 100644
--- a/packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttf
+++ b/packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.ttf
Binary files differ
diff --git a/packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff b/packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.woff
index 7e087c1de..7e087c1de 100644
--- a/packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff
+++ b/packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.woff
Binary files differ
diff --git a/packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2 b/packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.woff2
index b5caa4ddc..b5caa4ddc 100644
--- a/packages/anastasis-webui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2
+++ b/packages/anastasis-webui/src/scss/fonts/materialdesignicons-webfont-4.9.95.woff2
Binary files differ
diff --git a/packages/anastasis-webui/src/scss/fonts/nunito.css b/packages/anastasis-webui/src/scss/fonts/nunito.css
index ab30db36b..a8fe85ed8 100644
--- a/packages/anastasis-webui/src/scss/fonts/nunito.css
+++ b/packages/anastasis-webui/src/scss/fonts/nunito.css
@@ -1,22 +1,22 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
@font-face {
- font-family: 'Nunito';
+ font-family: "Nunito";
font-style: normal;
font-weight: 400;
- src: url(./XRXV3I6Li01BKofINeaE.ttf) format('truetype');
+ src: url(./fonts/XRXV3I6Li01BKofINeaE.ttf) format("truetype");
}
diff --git a/packages/anastasis-webui/src/scss/icons/materialdesignicons-4.9.95.min.css b/packages/anastasis-webui/src/scss/icons/materialdesignicons-4.9.95.min.css
index 24a89d639..2b8a2b244 100644
--- a/packages/anastasis-webui/src/scss/icons/materialdesignicons-4.9.95.min.css
+++ b/packages/anastasis-webui/src/scss/icons/materialdesignicons-4.9.95.min.css
@@ -1,3 +1,15109 @@
-@font-face{font-family:"Material Design Icons";src:url("./fonts/materialdesignicons-webfont-4.9.95.eot");src:url("./fonts/materialdesignicons-webfont-4.9.95.woff2") format("woff2"),url("./fonts/materialdesignicons-webfont-4.9.95.woff") format("woff"),url("./fonts/materialdesignicons-webfont-4.9.95.ttf") format("truetype");font-weight:normal;font-style:normal}.mdi:before,.mdi-set{display:inline-block;font:normal normal normal 24px/1 "Material Design Icons";font-size:inherit;text-rendering:auto;line-height:inherit;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.mdi-ab-testing::before{content:"\F001C"}.mdi-abjad-arabic::before{content:"\F0353"}.mdi-abjad-hebrew::before{content:"\F0354"}.mdi-abugida-devanagari::before{content:"\F0355"}.mdi-abugida-thai::before{content:"\F0356"}.mdi-access-point::before{content:"\F002"}.mdi-access-point-network::before{content:"\F003"}.mdi-access-point-network-off::before{content:"\FBBD"}.mdi-account::before{content:"\F004"}.mdi-account-alert::before{content:"\F005"}.mdi-account-alert-outline::before{content:"\FB2C"}.mdi-account-arrow-left::before{content:"\FB2D"}.mdi-account-arrow-left-outline::before{content:"\FB2E"}.mdi-account-arrow-right::before{content:"\FB2F"}.mdi-account-arrow-right-outline::before{content:"\FB30"}.mdi-account-badge::before{content:"\FD83"}.mdi-account-badge-alert::before{content:"\FD84"}.mdi-account-badge-alert-outline::before{content:"\FD85"}.mdi-account-badge-horizontal::before{content:"\FDF0"}.mdi-account-badge-horizontal-outline::before{content:"\FDF1"}.mdi-account-badge-outline::before{content:"\FD86"}.mdi-account-box::before{content:"\F006"}.mdi-account-box-multiple::before{content:"\F933"}.mdi-account-box-multiple-outline::before{content:"\F002C"}.mdi-account-box-outline::before{content:"\F007"}.mdi-account-cancel::before{content:"\F030A"}.mdi-account-cancel-outline::before{content:"\F030B"}.mdi-account-card-details::before{content:"\F5D2"}.mdi-account-card-details-outline::before{content:"\FD87"}.mdi-account-cash::before{content:"\F00C2"}.mdi-account-cash-outline::before{content:"\F00C3"}.mdi-account-check::before{content:"\F008"}.mdi-account-check-outline::before{content:"\FBBE"}.mdi-account-child::before{content:"\FA88"}.mdi-account-child-circle::before{content:"\FA89"}.mdi-account-child-outline::before{content:"\F00F3"}.mdi-account-circle::before{content:"\F009"}.mdi-account-circle-outline::before{content:"\FB31"}.mdi-account-clock::before{content:"\FB32"}.mdi-account-clock-outline::before{content:"\FB33"}.mdi-account-cog::before{content:"\F039B"}.mdi-account-cog-outline::before{content:"\F039C"}.mdi-account-convert::before{content:"\F00A"}.mdi-account-convert-outline::before{content:"\F032C"}.mdi-account-details::before{content:"\F631"}.mdi-account-details-outline::before{content:"\F039D"}.mdi-account-edit::before{content:"\F6BB"}.mdi-account-edit-outline::before{content:"\F001D"}.mdi-account-group::before{content:"\F848"}.mdi-account-group-outline::before{content:"\FB34"}.mdi-account-heart::before{content:"\F898"}.mdi-account-heart-outline::before{content:"\FBBF"}.mdi-account-key::before{content:"\F00B"}.mdi-account-key-outline::before{content:"\FBC0"}.mdi-account-lock::before{content:"\F0189"}.mdi-account-lock-outline::before{content:"\F018A"}.mdi-account-minus::before{content:"\F00D"}.mdi-account-minus-outline::before{content:"\FAEB"}.mdi-account-multiple::before{content:"\F00E"}.mdi-account-multiple-check::before{content:"\F8C4"}.mdi-account-multiple-check-outline::before{content:"\F0229"}.mdi-account-multiple-minus::before{content:"\F5D3"}.mdi-account-multiple-minus-outline::before{content:"\FBC1"}.mdi-account-multiple-outline::before{content:"\F00F"}.mdi-account-multiple-plus::before{content:"\F010"}.mdi-account-multiple-plus-outline::before{content:"\F7FF"}.mdi-account-multiple-remove::before{content:"\F0235"}.mdi-account-multiple-remove-outline::before{content:"\F0236"}.mdi-account-network::before{content:"\F011"}.mdi-account-network-outline::before{content:"\FBC2"}.mdi-account-off::before{content:"\F012"}.mdi-account-off-outline::before{content:"\FBC3"}.mdi-account-outline::before{content:"\F013"}.mdi-account-plus::before{content:"\F014"}.mdi-account-plus-outline::before{content:"\F800"}.mdi-account-question::before{content:"\FB35"}.mdi-account-question-outline::before{content:"\FB36"}.mdi-account-remove::before{content:"\F015"}.mdi-account-remove-outline::before{content:"\FAEC"}.mdi-account-search::before{content:"\F016"}.mdi-account-search-outline::before{content:"\F934"}.mdi-account-settings::before{content:"\F630"}.mdi-account-settings-outline::before{content:"\F00F4"}.mdi-account-star::before{content:"\F017"}.mdi-account-star-outline::before{content:"\FBC4"}.mdi-account-supervisor::before{content:"\FA8A"}.mdi-account-supervisor-circle::before{content:"\FA8B"}.mdi-account-supervisor-outline::before{content:"\F0158"}.mdi-account-switch::before{content:"\F019"}.mdi-account-tie::before{content:"\FCBF"}.mdi-account-tie-outline::before{content:"\F00F5"}.mdi-account-tie-voice::before{content:"\F0333"}.mdi-account-tie-voice-off::before{content:"\F0335"}.mdi-account-tie-voice-off-outline::before{content:"\F0336"}.mdi-account-tie-voice-outline::before{content:"\F0334"}.mdi-accusoft::before{content:"\F849"}.mdi-adjust::before{content:"\F01A"}.mdi-adobe::before{content:"\F935"}.mdi-adobe-acrobat::before{content:"\FFBD"}.mdi-air-conditioner::before{content:"\F01B"}.mdi-air-filter::before{content:"\FD1F"}.mdi-air-horn::before{content:"\FD88"}.mdi-air-humidifier::before{content:"\F00C4"}.mdi-air-purifier::before{content:"\FD20"}.mdi-airbag::before{content:"\FBC5"}.mdi-airballoon::before{content:"\F01C"}.mdi-airballoon-outline::before{content:"\F002D"}.mdi-airplane::before{content:"\F01D"}.mdi-airplane-landing::before{content:"\F5D4"}.mdi-airplane-off::before{content:"\F01E"}.mdi-airplane-takeoff::before{content:"\F5D5"}.mdi-airplay::before{content:"\F01F"}.mdi-airport::before{content:"\F84A"}.mdi-alarm::before{content:"\F020"}.mdi-alarm-bell::before{content:"\F78D"}.mdi-alarm-check::before{content:"\F021"}.mdi-alarm-light::before{content:"\F78E"}.mdi-alarm-light-outline::before{content:"\FBC6"}.mdi-alarm-multiple::before{content:"\F022"}.mdi-alarm-note::before{content:"\FE8E"}.mdi-alarm-note-off::before{content:"\FE8F"}.mdi-alarm-off::before{content:"\F023"}.mdi-alarm-plus::before{content:"\F024"}.mdi-alarm-snooze::before{content:"\F68D"}.mdi-album::before{content:"\F025"}.mdi-alert::before{content:"\F026"}.mdi-alert-box::before{content:"\F027"}.mdi-alert-box-outline::before{content:"\FCC0"}.mdi-alert-circle::before{content:"\F028"}.mdi-alert-circle-check::before{content:"\F0218"}.mdi-alert-circle-check-outline::before{content:"\F0219"}.mdi-alert-circle-outline::before{content:"\F5D6"}.mdi-alert-decagram::before{content:"\F6BC"}.mdi-alert-decagram-outline::before{content:"\FCC1"}.mdi-alert-octagon::before{content:"\F029"}.mdi-alert-octagon-outline::before{content:"\FCC2"}.mdi-alert-octagram::before{content:"\F766"}.mdi-alert-octagram-outline::before{content:"\FCC3"}.mdi-alert-outline::before{content:"\F02A"}.mdi-alert-rhombus::before{content:"\F01F9"}.mdi-alert-rhombus-outline::before{content:"\F01FA"}.mdi-alien::before{content:"\F899"}.mdi-alien-outline::before{content:"\F00F6"}.mdi-align-horizontal-center::before{content:"\F01EE"}.mdi-align-horizontal-left::before{content:"\F01ED"}.mdi-align-horizontal-right::before{content:"\F01EF"}.mdi-align-vertical-bottom::before{content:"\F01F0"}.mdi-align-vertical-center::before{content:"\F01F1"}.mdi-align-vertical-top::before{content:"\F01F2"}.mdi-all-inclusive::before{content:"\F6BD"}.mdi-allergy::before{content:"\F0283"}.mdi-alpha::before{content:"\F02B"}.mdi-alpha-a::before{content:"\41"}.mdi-alpha-a-box::before{content:"\FAED"}.mdi-alpha-a-box-outline::before{content:"\FBC7"}.mdi-alpha-a-circle::before{content:"\FBC8"}.mdi-alpha-a-circle-outline::before{content:"\FBC9"}.mdi-alpha-b::before{content:"\42"}.mdi-alpha-b-box::before{content:"\FAEE"}.mdi-alpha-b-box-outline::before{content:"\FBCA"}.mdi-alpha-b-circle::before{content:"\FBCB"}.mdi-alpha-b-circle-outline::before{content:"\FBCC"}.mdi-alpha-c::before{content:"\43"}.mdi-alpha-c-box::before{content:"\FAEF"}.mdi-alpha-c-box-outline::before{content:"\FBCD"}.mdi-alpha-c-circle::before{content:"\FBCE"}.mdi-alpha-c-circle-outline::before{content:"\FBCF"}.mdi-alpha-d::before{content:"\44"}.mdi-alpha-d-box::before{content:"\FAF0"}.mdi-alpha-d-box-outline::before{content:"\FBD0"}.mdi-alpha-d-circle::before{content:"\FBD1"}.mdi-alpha-d-circle-outline::before{content:"\FBD2"}.mdi-alpha-e::before{content:"\45"}.mdi-alpha-e-box::before{content:"\FAF1"}.mdi-alpha-e-box-outline::before{content:"\FBD3"}.mdi-alpha-e-circle::before{content:"\FBD4"}.mdi-alpha-e-circle-outline::before{content:"\FBD5"}.mdi-alpha-f::before{content:"\46"}.mdi-alpha-f-box::before{content:"\FAF2"}.mdi-alpha-f-box-outline::before{content:"\FBD6"}.mdi-alpha-f-circle::before{content:"\FBD7"}.mdi-alpha-f-circle-outline::before{content:"\FBD8"}.mdi-alpha-g::before{content:"\47"}.mdi-alpha-g-box::before{content:"\FAF3"}.mdi-alpha-g-box-outline::before{content:"\FBD9"}.mdi-alpha-g-circle::before{content:"\FBDA"}.mdi-alpha-g-circle-outline::before{content:"\FBDB"}.mdi-alpha-h::before{content:"\48"}.mdi-alpha-h-box::before{content:"\FAF4"}.mdi-alpha-h-box-outline::before{content:"\FBDC"}.mdi-alpha-h-circle::before{content:"\FBDD"}.mdi-alpha-h-circle-outline::before{content:"\FBDE"}.mdi-alpha-i::before{content:"\49"}.mdi-alpha-i-box::before{content:"\FAF5"}.mdi-alpha-i-box-outline::before{content:"\FBDF"}.mdi-alpha-i-circle::before{content:"\FBE0"}.mdi-alpha-i-circle-outline::before{content:"\FBE1"}.mdi-alpha-j::before{content:"\4A"}.mdi-alpha-j-box::before{content:"\FAF6"}.mdi-alpha-j-box-outline::before{content:"\FBE2"}.mdi-alpha-j-circle::before{content:"\FBE3"}.mdi-alpha-j-circle-outline::before{content:"\FBE4"}.mdi-alpha-k::before{content:"\4B"}.mdi-alpha-k-box::before{content:"\FAF7"}.mdi-alpha-k-box-outline::before{content:"\FBE5"}.mdi-alpha-k-circle::before{content:"\FBE6"}.mdi-alpha-k-circle-outline::before{content:"\FBE7"}.mdi-alpha-l::before{content:"\4C"}.mdi-alpha-l-box::before{content:"\FAF8"}.mdi-alpha-l-box-outline::before{content:"\FBE8"}.mdi-alpha-l-circle::before{content:"\FBE9"}.mdi-alpha-l-circle-outline::before{content:"\FBEA"}.mdi-alpha-m::before{content:"\4D"}.mdi-alpha-m-box::before{content:"\FAF9"}.mdi-alpha-m-box-outline::before{content:"\FBEB"}.mdi-alpha-m-circle::before{content:"\FBEC"}.mdi-alpha-m-circle-outline::before{content:"\FBED"}.mdi-alpha-n::before{content:"\4E"}.mdi-alpha-n-box::before{content:"\FAFA"}.mdi-alpha-n-box-outline::before{content:"\FBEE"}.mdi-alpha-n-circle::before{content:"\FBEF"}.mdi-alpha-n-circle-outline::before{content:"\FBF0"}.mdi-alpha-o::before{content:"\4F"}.mdi-alpha-o-box::before{content:"\FAFB"}.mdi-alpha-o-box-outline::before{content:"\FBF1"}.mdi-alpha-o-circle::before{content:"\FBF2"}.mdi-alpha-o-circle-outline::before{content:"\FBF3"}.mdi-alpha-p::before{content:"\50"}.mdi-alpha-p-box::before{content:"\FAFC"}.mdi-alpha-p-box-outline::before{content:"\FBF4"}.mdi-alpha-p-circle::before{content:"\FBF5"}.mdi-alpha-p-circle-outline::before{content:"\FBF6"}.mdi-alpha-q::before{content:"\51"}.mdi-alpha-q-box::before{content:"\FAFD"}.mdi-alpha-q-box-outline::before{content:"\FBF7"}.mdi-alpha-q-circle::before{content:"\FBF8"}.mdi-alpha-q-circle-outline::before{content:"\FBF9"}.mdi-alpha-r::before{content:"\52"}.mdi-alpha-r-box::before{content:"\FAFE"}.mdi-alpha-r-box-outline::before{content:"\FBFA"}.mdi-alpha-r-circle::before{content:"\FBFB"}.mdi-alpha-r-circle-outline::before{content:"\FBFC"}.mdi-alpha-s::before{content:"\53"}.mdi-alpha-s-box::before{content:"\FAFF"}.mdi-alpha-s-box-outline::before{content:"\FBFD"}.mdi-alpha-s-circle::before{content:"\FBFE"}.mdi-alpha-s-circle-outline::before{content:"\FBFF"}.mdi-alpha-t::before{content:"\54"}.mdi-alpha-t-box::before{content:"\FB00"}.mdi-alpha-t-box-outline::before{content:"\FC00"}.mdi-alpha-t-circle::before{content:"\FC01"}.mdi-alpha-t-circle-outline::before{content:"\FC02"}.mdi-alpha-u::before{content:"\55"}.mdi-alpha-u-box::before{content:"\FB01"}.mdi-alpha-u-box-outline::before{content:"\FC03"}.mdi-alpha-u-circle::before{content:"\FC04"}.mdi-alpha-u-circle-outline::before{content:"\FC05"}.mdi-alpha-v::before{content:"\56"}.mdi-alpha-v-box::before{content:"\FB02"}.mdi-alpha-v-box-outline::before{content:"\FC06"}.mdi-alpha-v-circle::before{content:"\FC07"}.mdi-alpha-v-circle-outline::before{content:"\FC08"}.mdi-alpha-w::before{content:"\57"}.mdi-alpha-w-box::before{content:"\FB03"}.mdi-alpha-w-box-outline::before{content:"\FC09"}.mdi-alpha-w-circle::before{content:"\FC0A"}.mdi-alpha-w-circle-outline::before{content:"\FC0B"}.mdi-alpha-x::before{content:"\58"}.mdi-alpha-x-box::before{content:"\FB04"}.mdi-alpha-x-box-outline::before{content:"\FC0C"}.mdi-alpha-x-circle::before{content:"\FC0D"}.mdi-alpha-x-circle-outline::before{content:"\FC0E"}.mdi-alpha-y::before{content:"\59"}.mdi-alpha-y-box::before{content:"\FB05"}.mdi-alpha-y-box-outline::before{content:"\FC0F"}.mdi-alpha-y-circle::before{content:"\FC10"}.mdi-alpha-y-circle-outline::before{content:"\FC11"}.mdi-alpha-z::before{content:"\5A"}.mdi-alpha-z-box::before{content:"\FB06"}.mdi-alpha-z-box-outline::before{content:"\FC12"}.mdi-alpha-z-circle::before{content:"\FC13"}.mdi-alpha-z-circle-outline::before{content:"\FC14"}.mdi-alphabet-aurebesh::before{content:"\F0357"}.mdi-alphabet-cyrillic::before{content:"\F0358"}.mdi-alphabet-greek::before{content:"\F0359"}.mdi-alphabet-latin::before{content:"\F035A"}.mdi-alphabet-piqad::before{content:"\F035B"}.mdi-alphabet-tengwar::before{content:"\F0362"}.mdi-alphabetical::before{content:"\F02C"}.mdi-alphabetical-off::before{content:"\F002E"}.mdi-alphabetical-variant::before{content:"\F002F"}.mdi-alphabetical-variant-off::before{content:"\F0030"}.mdi-altimeter::before{content:"\F5D7"}.mdi-amazon::before{content:"\F02D"}.mdi-amazon-alexa::before{content:"\F8C5"}.mdi-amazon-drive::before{content:"\F02E"}.mdi-ambulance::before{content:"\F02F"}.mdi-ammunition::before{content:"\FCC4"}.mdi-ampersand::before{content:"\FA8C"}.mdi-amplifier::before{content:"\F030"}.mdi-amplifier-off::before{content:"\F01E0"}.mdi-anchor::before{content:"\F031"}.mdi-android::before{content:"\F032"}.mdi-android-auto::before{content:"\FA8D"}.mdi-android-debug-bridge::before{content:"\F033"}.mdi-android-head::before{content:"\F78F"}.mdi-android-messages::before{content:"\FD21"}.mdi-android-studio::before{content:"\F034"}.mdi-angle-acute::before{content:"\F936"}.mdi-angle-obtuse::before{content:"\F937"}.mdi-angle-right::before{content:"\F938"}.mdi-angular::before{content:"\F6B1"}.mdi-angularjs::before{content:"\F6BE"}.mdi-animation::before{content:"\F5D8"}.mdi-animation-outline::before{content:"\FA8E"}.mdi-animation-play::before{content:"\F939"}.mdi-animation-play-outline::before{content:"\FA8F"}.mdi-ansible::before{content:"\F00C5"}.mdi-antenna::before{content:"\F0144"}.mdi-anvil::before{content:"\F89A"}.mdi-apache-kafka::before{content:"\F0031"}.mdi-api::before{content:"\F00C6"}.mdi-api-off::before{content:"\F0282"}.mdi-apple::before{content:"\F035"}.mdi-apple-finder::before{content:"\F036"}.mdi-apple-icloud::before{content:"\F038"}.mdi-apple-ios::before{content:"\F037"}.mdi-apple-keyboard-caps::before{content:"\F632"}.mdi-apple-keyboard-command::before{content:"\F633"}.mdi-apple-keyboard-control::before{content:"\F634"}.mdi-apple-keyboard-option::before{content:"\F635"}.mdi-apple-keyboard-shift::before{content:"\F636"}.mdi-apple-safari::before{content:"\F039"}.mdi-application::before{content:"\F614"}.mdi-application-export::before{content:"\FD89"}.mdi-application-import::before{content:"\FD8A"}.mdi-approximately-equal::before{content:"\FFBE"}.mdi-approximately-equal-box::before{content:"\FFBF"}.mdi-apps::before{content:"\F03B"}.mdi-apps-box::before{content:"\FD22"}.mdi-arch::before{content:"\F8C6"}.mdi-archive::before{content:"\F03C"}.mdi-archive-arrow-down::before{content:"\F0284"}.mdi-archive-arrow-down-outline::before{content:"\F0285"}.mdi-archive-arrow-up::before{content:"\F0286"}.mdi-archive-arrow-up-outline::before{content:"\F0287"}.mdi-archive-outline::before{content:"\F0239"}.mdi-arm-flex::before{content:"\F008F"}.mdi-arm-flex-outline::before{content:"\F0090"}.mdi-arrange-bring-forward::before{content:"\F03D"}.mdi-arrange-bring-to-front::before{content:"\F03E"}.mdi-arrange-send-backward::before{content:"\F03F"}.mdi-arrange-send-to-back::before{content:"\F040"}.mdi-arrow-all::before{content:"\F041"}.mdi-arrow-bottom-left::before{content:"\F042"}.mdi-arrow-bottom-left-bold-outline::before{content:"\F9B6"}.mdi-arrow-bottom-left-thick::before{content:"\F9B7"}.mdi-arrow-bottom-right::before{content:"\F043"}.mdi-arrow-bottom-right-bold-outline::before{content:"\F9B8"}.mdi-arrow-bottom-right-thick::before{content:"\F9B9"}.mdi-arrow-collapse::before{content:"\F615"}.mdi-arrow-collapse-all::before{content:"\F044"}.mdi-arrow-collapse-down::before{content:"\F791"}.mdi-arrow-collapse-horizontal::before{content:"\F84B"}.mdi-arrow-collapse-left::before{content:"\F792"}.mdi-arrow-collapse-right::before{content:"\F793"}.mdi-arrow-collapse-up::before{content:"\F794"}.mdi-arrow-collapse-vertical::before{content:"\F84C"}.mdi-arrow-decision::before{content:"\F9BA"}.mdi-arrow-decision-auto::before{content:"\F9BB"}.mdi-arrow-decision-auto-outline::before{content:"\F9BC"}.mdi-arrow-decision-outline::before{content:"\F9BD"}.mdi-arrow-down::before{content:"\F045"}.mdi-arrow-down-bold::before{content:"\F72D"}.mdi-arrow-down-bold-box::before{content:"\F72E"}.mdi-arrow-down-bold-box-outline::before{content:"\F72F"}.mdi-arrow-down-bold-circle::before{content:"\F047"}.mdi-arrow-down-bold-circle-outline::before{content:"\F048"}.mdi-arrow-down-bold-hexagon-outline::before{content:"\F049"}.mdi-arrow-down-bold-outline::before{content:"\F9BE"}.mdi-arrow-down-box::before{content:"\F6BF"}.mdi-arrow-down-circle::before{content:"\FCB7"}.mdi-arrow-down-circle-outline::before{content:"\FCB8"}.mdi-arrow-down-drop-circle::before{content:"\F04A"}.mdi-arrow-down-drop-circle-outline::before{content:"\F04B"}.mdi-arrow-down-thick::before{content:"\F046"}.mdi-arrow-expand::before{content:"\F616"}.mdi-arrow-expand-all::before{content:"\F04C"}.mdi-arrow-expand-down::before{content:"\F795"}.mdi-arrow-expand-horizontal::before{content:"\F84D"}.mdi-arrow-expand-left::before{content:"\F796"}.mdi-arrow-expand-right::before{content:"\F797"}.mdi-arrow-expand-up::before{content:"\F798"}.mdi-arrow-expand-vertical::before{content:"\F84E"}.mdi-arrow-horizontal-lock::before{content:"\F0186"}.mdi-arrow-left::before{content:"\F04D"}.mdi-arrow-left-bold::before{content:"\F730"}.mdi-arrow-left-bold-box::before{content:"\F731"}.mdi-arrow-left-bold-box-outline::before{content:"\F732"}.mdi-arrow-left-bold-circle::before{content:"\F04F"}.mdi-arrow-left-bold-circle-outline::before{content:"\F050"}.mdi-arrow-left-bold-hexagon-outline::before{content:"\F051"}.mdi-arrow-left-bold-outline::before{content:"\F9BF"}.mdi-arrow-left-box::before{content:"\F6C0"}.mdi-arrow-left-circle::before{content:"\FCB9"}.mdi-arrow-left-circle-outline::before{content:"\FCBA"}.mdi-arrow-left-drop-circle::before{content:"\F052"}.mdi-arrow-left-drop-circle-outline::before{content:"\F053"}.mdi-arrow-left-right::before{content:"\FE90"}.mdi-arrow-left-right-bold::before{content:"\FE91"}.mdi-arrow-left-right-bold-outline::before{content:"\F9C0"}.mdi-arrow-left-thick::before{content:"\F04E"}.mdi-arrow-right::before{content:"\F054"}.mdi-arrow-right-bold::before{content:"\F733"}.mdi-arrow-right-bold-box::before{content:"\F734"}.mdi-arrow-right-bold-box-outline::before{content:"\F735"}.mdi-arrow-right-bold-circle::before{content:"\F056"}.mdi-arrow-right-bold-circle-outline::before{content:"\F057"}.mdi-arrow-right-bold-hexagon-outline::before{content:"\F058"}.mdi-arrow-right-bold-outline::before{content:"\F9C1"}.mdi-arrow-right-box::before{content:"\F6C1"}.mdi-arrow-right-circle::before{content:"\FCBB"}.mdi-arrow-right-circle-outline::before{content:"\FCBC"}.mdi-arrow-right-drop-circle::before{content:"\F059"}.mdi-arrow-right-drop-circle-outline::before{content:"\F05A"}.mdi-arrow-right-thick::before{content:"\F055"}.mdi-arrow-split-horizontal::before{content:"\F93A"}.mdi-arrow-split-vertical::before{content:"\F93B"}.mdi-arrow-top-left::before{content:"\F05B"}.mdi-arrow-top-left-bold-outline::before{content:"\F9C2"}.mdi-arrow-top-left-bottom-right::before{content:"\FE92"}.mdi-arrow-top-left-bottom-right-bold::before{content:"\FE93"}.mdi-arrow-top-left-thick::before{content:"\F9C3"}.mdi-arrow-top-right::before{content:"\F05C"}.mdi-arrow-top-right-bold-outline::before{content:"\F9C4"}.mdi-arrow-top-right-bottom-left::before{content:"\FE94"}.mdi-arrow-top-right-bottom-left-bold::before{content:"\FE95"}.mdi-arrow-top-right-thick::before{content:"\F9C5"}.mdi-arrow-up::before{content:"\F05D"}.mdi-arrow-up-bold::before{content:"\F736"}.mdi-arrow-up-bold-box::before{content:"\F737"}.mdi-arrow-up-bold-box-outline::before{content:"\F738"}.mdi-arrow-up-bold-circle::before{content:"\F05F"}.mdi-arrow-up-bold-circle-outline::before{content:"\F060"}.mdi-arrow-up-bold-hexagon-outline::before{content:"\F061"}.mdi-arrow-up-bold-outline::before{content:"\F9C6"}.mdi-arrow-up-box::before{content:"\F6C2"}.mdi-arrow-up-circle::before{content:"\FCBD"}.mdi-arrow-up-circle-outline::before{content:"\FCBE"}.mdi-arrow-up-down::before{content:"\FE96"}.mdi-arrow-up-down-bold::before{content:"\FE97"}.mdi-arrow-up-down-bold-outline::before{content:"\F9C7"}.mdi-arrow-up-drop-circle::before{content:"\F062"}.mdi-arrow-up-drop-circle-outline::before{content:"\F063"}.mdi-arrow-up-thick::before{content:"\F05E"}.mdi-arrow-vertical-lock::before{content:"\F0187"}.mdi-artist::before{content:"\F802"}.mdi-artist-outline::before{content:"\FCC5"}.mdi-artstation::before{content:"\FB37"}.mdi-aspect-ratio::before{content:"\FA23"}.mdi-assistant::before{content:"\F064"}.mdi-asterisk::before{content:"\F6C3"}.mdi-at::before{content:"\F065"}.mdi-atlassian::before{content:"\F803"}.mdi-atm::before{content:"\FD23"}.mdi-atom::before{content:"\F767"}.mdi-atom-variant::before{content:"\FE98"}.mdi-attachment::before{content:"\F066"}.mdi-audio-video::before{content:"\F93C"}.mdi-audio-video-off::before{content:"\F01E1"}.mdi-audiobook::before{content:"\F067"}.mdi-augmented-reality::before{content:"\F84F"}.mdi-auto-download::before{content:"\F03A9"}.mdi-auto-fix::before{content:"\F068"}.mdi-auto-upload::before{content:"\F069"}.mdi-autorenew::before{content:"\F06A"}.mdi-av-timer::before{content:"\F06B"}.mdi-aws::before{content:"\FDF2"}.mdi-axe::before{content:"\F8C7"}.mdi-axis::before{content:"\FD24"}.mdi-axis-arrow::before{content:"\FD25"}.mdi-axis-arrow-lock::before{content:"\FD26"}.mdi-axis-lock::before{content:"\FD27"}.mdi-axis-x-arrow::before{content:"\FD28"}.mdi-axis-x-arrow-lock::before{content:"\FD29"}.mdi-axis-x-rotate-clockwise::before{content:"\FD2A"}.mdi-axis-x-rotate-counterclockwise::before{content:"\FD2B"}.mdi-axis-x-y-arrow-lock::before{content:"\FD2C"}.mdi-axis-y-arrow::before{content:"\FD2D"}.mdi-axis-y-arrow-lock::before{content:"\FD2E"}.mdi-axis-y-rotate-clockwise::before{content:"\FD2F"}.mdi-axis-y-rotate-counterclockwise::before{content:"\FD30"}.mdi-axis-z-arrow::before{content:"\FD31"}.mdi-axis-z-arrow-lock::before{content:"\FD32"}.mdi-axis-z-rotate-clockwise::before{content:"\FD33"}.mdi-axis-z-rotate-counterclockwise::before{content:"\FD34"}.mdi-azure::before{content:"\F804"}.mdi-azure-devops::before{content:"\F0091"}.mdi-babel::before{content:"\FA24"}.mdi-baby::before{content:"\F06C"}.mdi-baby-bottle::before{content:"\FF56"}.mdi-baby-bottle-outline::before{content:"\FF57"}.mdi-baby-carriage::before{content:"\F68E"}.mdi-baby-carriage-off::before{content:"\FFC0"}.mdi-baby-face::before{content:"\FE99"}.mdi-baby-face-outline::before{content:"\FE9A"}.mdi-backburger::before{content:"\F06D"}.mdi-backspace::before{content:"\F06E"}.mdi-backspace-outline::before{content:"\FB38"}.mdi-backspace-reverse::before{content:"\FE9B"}.mdi-backspace-reverse-outline::before{content:"\FE9C"}.mdi-backup-restore::before{content:"\F06F"}.mdi-bacteria::before{content:"\FEF2"}.mdi-bacteria-outline::before{content:"\FEF3"}.mdi-badminton::before{content:"\F850"}.mdi-bag-carry-on::before{content:"\FF58"}.mdi-bag-carry-on-check::before{content:"\FD41"}.mdi-bag-carry-on-off::before{content:"\FF59"}.mdi-bag-checked::before{content:"\FF5A"}.mdi-bag-personal::before{content:"\FDF3"}.mdi-bag-personal-off::before{content:"\FDF4"}.mdi-bag-personal-off-outline::before{content:"\FDF5"}.mdi-bag-personal-outline::before{content:"\FDF6"}.mdi-baguette::before{content:"\FF5B"}.mdi-balloon::before{content:"\FA25"}.mdi-ballot::before{content:"\F9C8"}.mdi-ballot-outline::before{content:"\F9C9"}.mdi-ballot-recount::before{content:"\FC15"}.mdi-ballot-recount-outline::before{content:"\FC16"}.mdi-bandage::before{content:"\FD8B"}.mdi-bandcamp::before{content:"\F674"}.mdi-bank::before{content:"\F070"}.mdi-bank-minus::before{content:"\FD8C"}.mdi-bank-outline::before{content:"\FE9D"}.mdi-bank-plus::before{content:"\FD8D"}.mdi-bank-remove::before{content:"\FD8E"}.mdi-bank-transfer::before{content:"\FA26"}.mdi-bank-transfer-in::before{content:"\FA27"}.mdi-bank-transfer-out::before{content:"\FA28"}.mdi-barcode::before{content:"\F071"}.mdi-barcode-off::before{content:"\F0261"}.mdi-barcode-scan::before{content:"\F072"}.mdi-barley::before{content:"\F073"}.mdi-barley-off::before{content:"\FB39"}.mdi-barn::before{content:"\FB3A"}.mdi-barrel::before{content:"\F074"}.mdi-baseball::before{content:"\F851"}.mdi-baseball-bat::before{content:"\F852"}.mdi-basecamp::before{content:"\F075"}.mdi-bash::before{content:"\F01AE"}.mdi-basket::before{content:"\F076"}.mdi-basket-fill::before{content:"\F077"}.mdi-basket-outline::before{content:"\F01AC"}.mdi-basket-unfill::before{content:"\F078"}.mdi-basketball::before{content:"\F805"}.mdi-basketball-hoop::before{content:"\FC17"}.mdi-basketball-hoop-outline::before{content:"\FC18"}.mdi-bat::before{content:"\FB3B"}.mdi-battery::before{content:"\F079"}.mdi-battery-10::before{content:"\F07A"}.mdi-battery-10-bluetooth::before{content:"\F93D"}.mdi-battery-20::before{content:"\F07B"}.mdi-battery-20-bluetooth::before{content:"\F93E"}.mdi-battery-30::before{content:"\F07C"}.mdi-battery-30-bluetooth::before{content:"\F93F"}.mdi-battery-40::before{content:"\F07D"}.mdi-battery-40-bluetooth::before{content:"\F940"}.mdi-battery-50::before{content:"\F07E"}.mdi-battery-50-bluetooth::before{content:"\F941"}.mdi-battery-60::before{content:"\F07F"}.mdi-battery-60-bluetooth::before{content:"\F942"}.mdi-battery-70::before{content:"\F080"}.mdi-battery-70-bluetooth::before{content:"\F943"}.mdi-battery-80::before{content:"\F081"}.mdi-battery-80-bluetooth::before{content:"\F944"}.mdi-battery-90::before{content:"\F082"}.mdi-battery-90-bluetooth::before{content:"\F945"}.mdi-battery-alert::before{content:"\F083"}.mdi-battery-alert-bluetooth::before{content:"\F946"}.mdi-battery-alert-variant::before{content:"\F00F7"}.mdi-battery-alert-variant-outline::before{content:"\F00F8"}.mdi-battery-bluetooth::before{content:"\F947"}.mdi-battery-bluetooth-variant::before{content:"\F948"}.mdi-battery-charging::before{content:"\F084"}.mdi-battery-charging-10::before{content:"\F89B"}.mdi-battery-charging-100::before{content:"\F085"}.mdi-battery-charging-20::before{content:"\F086"}.mdi-battery-charging-30::before{content:"\F087"}.mdi-battery-charging-40::before{content:"\F088"}.mdi-battery-charging-50::before{content:"\F89C"}.mdi-battery-charging-60::before{content:"\F089"}.mdi-battery-charging-70::before{content:"\F89D"}.mdi-battery-charging-80::before{content:"\F08A"}.mdi-battery-charging-90::before{content:"\F08B"}.mdi-battery-charging-high::before{content:"\F02D1"}.mdi-battery-charging-low::before{content:"\F02CF"}.mdi-battery-charging-medium::before{content:"\F02D0"}.mdi-battery-charging-outline::before{content:"\F89E"}.mdi-battery-charging-wireless::before{content:"\F806"}.mdi-battery-charging-wireless-10::before{content:"\F807"}.mdi-battery-charging-wireless-20::before{content:"\F808"}.mdi-battery-charging-wireless-30::before{content:"\F809"}.mdi-battery-charging-wireless-40::before{content:"\F80A"}.mdi-battery-charging-wireless-50::before{content:"\F80B"}.mdi-battery-charging-wireless-60::before{content:"\F80C"}.mdi-battery-charging-wireless-70::before{content:"\F80D"}.mdi-battery-charging-wireless-80::before{content:"\F80E"}.mdi-battery-charging-wireless-90::before{content:"\F80F"}.mdi-battery-charging-wireless-alert::before{content:"\F810"}.mdi-battery-charging-wireless-outline::before{content:"\F811"}.mdi-battery-heart::before{content:"\F023A"}.mdi-battery-heart-outline::before{content:"\F023B"}.mdi-battery-heart-variant::before{content:"\F023C"}.mdi-battery-high::before{content:"\F02CE"}.mdi-battery-low::before{content:"\F02CC"}.mdi-battery-medium::before{content:"\F02CD"}.mdi-battery-minus::before{content:"\F08C"}.mdi-battery-negative::before{content:"\F08D"}.mdi-battery-off::before{content:"\F0288"}.mdi-battery-off-outline::before{content:"\F0289"}.mdi-battery-outline::before{content:"\F08E"}.mdi-battery-plus::before{content:"\F08F"}.mdi-battery-positive::before{content:"\F090"}.mdi-battery-unknown::before{content:"\F091"}.mdi-battery-unknown-bluetooth::before{content:"\F949"}.mdi-battlenet::before{content:"\FB3C"}.mdi-beach::before{content:"\F092"}.mdi-beaker::before{content:"\FCC6"}.mdi-beaker-alert::before{content:"\F0254"}.mdi-beaker-alert-outline::before{content:"\F0255"}.mdi-beaker-check::before{content:"\F0256"}.mdi-beaker-check-outline::before{content:"\F0257"}.mdi-beaker-minus::before{content:"\F0258"}.mdi-beaker-minus-outline::before{content:"\F0259"}.mdi-beaker-outline::before{content:"\F68F"}.mdi-beaker-plus::before{content:"\F025A"}.mdi-beaker-plus-outline::before{content:"\F025B"}.mdi-beaker-question::before{content:"\F025C"}.mdi-beaker-question-outline::before{content:"\F025D"}.mdi-beaker-remove::before{content:"\F025E"}.mdi-beaker-remove-outline::before{content:"\F025F"}.mdi-beats::before{content:"\F097"}.mdi-bed-double::before{content:"\F0092"}.mdi-bed-double-outline::before{content:"\F0093"}.mdi-bed-empty::before{content:"\F89F"}.mdi-bed-king::before{content:"\F0094"}.mdi-bed-king-outline::before{content:"\F0095"}.mdi-bed-queen::before{content:"\F0096"}.mdi-bed-queen-outline::before{content:"\F0097"}.mdi-bed-single::before{content:"\F0098"}.mdi-bed-single-outline::before{content:"\F0099"}.mdi-bee::before{content:"\FFC1"}.mdi-bee-flower::before{content:"\FFC2"}.mdi-beehive-outline::before{content:"\F00F9"}.mdi-beer::before{content:"\F098"}.mdi-beer-outline::before{content:"\F0337"}.mdi-behance::before{content:"\F099"}.mdi-bell::before{content:"\F09A"}.mdi-bell-alert::before{content:"\FD35"}.mdi-bell-alert-outline::before{content:"\FE9E"}.mdi-bell-check::before{content:"\F0210"}.mdi-bell-check-outline::before{content:"\F0211"}.mdi-bell-circle::before{content:"\FD36"}.mdi-bell-circle-outline::before{content:"\FD37"}.mdi-bell-off::before{content:"\F09B"}.mdi-bell-off-outline::before{content:"\FA90"}.mdi-bell-outline::before{content:"\F09C"}.mdi-bell-plus::before{content:"\F09D"}.mdi-bell-plus-outline::before{content:"\FA91"}.mdi-bell-ring::before{content:"\F09E"}.mdi-bell-ring-outline::before{content:"\F09F"}.mdi-bell-sleep::before{content:"\F0A0"}.mdi-bell-sleep-outline::before{content:"\FA92"}.mdi-beta::before{content:"\F0A1"}.mdi-betamax::before{content:"\F9CA"}.mdi-biathlon::before{content:"\FDF7"}.mdi-bible::before{content:"\F0A2"}.mdi-bicycle::before{content:"\F00C7"}.mdi-bicycle-basket::before{content:"\F0260"}.mdi-bike::before{content:"\F0A3"}.mdi-bike-fast::before{content:"\F014A"}.mdi-billboard::before{content:"\F0032"}.mdi-billiards::before{content:"\FB3D"}.mdi-billiards-rack::before{content:"\FB3E"}.mdi-bing::before{content:"\F0A4"}.mdi-binoculars::before{content:"\F0A5"}.mdi-bio::before{content:"\F0A6"}.mdi-biohazard::before{content:"\F0A7"}.mdi-bitbucket::before{content:"\F0A8"}.mdi-bitcoin::before{content:"\F812"}.mdi-black-mesa::before{content:"\F0A9"}.mdi-blackberry::before{content:"\F0AA"}.mdi-blender::before{content:"\FCC7"}.mdi-blender-software::before{content:"\F0AB"}.mdi-blinds::before{content:"\F0AC"}.mdi-blinds-open::before{content:"\F0033"}.mdi-block-helper::before{content:"\F0AD"}.mdi-blogger::before{content:"\F0AE"}.mdi-blood-bag::before{content:"\FCC8"}.mdi-bluetooth::before{content:"\F0AF"}.mdi-bluetooth-audio::before{content:"\F0B0"}.mdi-bluetooth-connect::before{content:"\F0B1"}.mdi-bluetooth-off::before{content:"\F0B2"}.mdi-bluetooth-settings::before{content:"\F0B3"}.mdi-bluetooth-transfer::before{content:"\F0B4"}.mdi-blur::before{content:"\F0B5"}.mdi-blur-linear::before{content:"\F0B6"}.mdi-blur-off::before{content:"\F0B7"}.mdi-blur-radial::before{content:"\F0B8"}.mdi-bolnisi-cross::before{content:"\FCC9"}.mdi-bolt::before{content:"\FD8F"}.mdi-bomb::before{content:"\F690"}.mdi-bomb-off::before{content:"\F6C4"}.mdi-bone::before{content:"\F0B9"}.mdi-book::before{content:"\F0BA"}.mdi-book-information-variant::before{content:"\F009A"}.mdi-book-lock::before{content:"\F799"}.mdi-book-lock-open::before{content:"\F79A"}.mdi-book-minus::before{content:"\F5D9"}.mdi-book-minus-multiple::before{content:"\FA93"}.mdi-book-multiple::before{content:"\F0BB"}.mdi-book-open::before{content:"\F0BD"}.mdi-book-open-outline::before{content:"\FB3F"}.mdi-book-open-page-variant::before{content:"\F5DA"}.mdi-book-open-variant::before{content:"\F0BE"}.mdi-book-outline::before{content:"\FB40"}.mdi-book-play::before{content:"\FE9F"}.mdi-book-play-outline::before{content:"\FEA0"}.mdi-book-plus::before{content:"\F5DB"}.mdi-book-plus-multiple::before{content:"\FA94"}.mdi-book-remove::before{content:"\FA96"}.mdi-book-remove-multiple::before{content:"\FA95"}.mdi-book-search::before{content:"\FEA1"}.mdi-book-search-outline::before{content:"\FEA2"}.mdi-book-variant::before{content:"\F0BF"}.mdi-book-variant-multiple::before{content:"\F0BC"}.mdi-bookmark::before{content:"\F0C0"}.mdi-bookmark-check::before{content:"\F0C1"}.mdi-bookmark-check-outline::before{content:"\F03A6"}.mdi-bookmark-minus::before{content:"\F9CB"}.mdi-bookmark-minus-outline::before{content:"\F9CC"}.mdi-bookmark-multiple::before{content:"\FDF8"}.mdi-bookmark-multiple-outline::before{content:"\FDF9"}.mdi-bookmark-music::before{content:"\F0C2"}.mdi-bookmark-music-outline::before{content:"\F03A4"}.mdi-bookmark-off::before{content:"\F9CD"}.mdi-bookmark-off-outline::before{content:"\F9CE"}.mdi-bookmark-outline::before{content:"\F0C3"}.mdi-bookmark-plus::before{content:"\F0C5"}.mdi-bookmark-plus-outline::before{content:"\F0C4"}.mdi-bookmark-remove::before{content:"\F0C6"}.mdi-bookmark-remove-outline::before{content:"\F03A5"}.mdi-bookshelf::before{content:"\F028A"}.mdi-boom-gate::before{content:"\FEA3"}.mdi-boom-gate-alert::before{content:"\FEA4"}.mdi-boom-gate-alert-outline::before{content:"\FEA5"}.mdi-boom-gate-down::before{content:"\FEA6"}.mdi-boom-gate-down-outline::before{content:"\FEA7"}.mdi-boom-gate-outline::before{content:"\FEA8"}.mdi-boom-gate-up::before{content:"\FEA9"}.mdi-boom-gate-up-outline::before{content:"\FEAA"}.mdi-boombox::before{content:"\F5DC"}.mdi-boomerang::before{content:"\F00FA"}.mdi-bootstrap::before{content:"\F6C5"}.mdi-border-all::before{content:"\F0C7"}.mdi-border-all-variant::before{content:"\F8A0"}.mdi-border-bottom::before{content:"\F0C8"}.mdi-border-bottom-variant::before{content:"\F8A1"}.mdi-border-color::before{content:"\F0C9"}.mdi-border-horizontal::before{content:"\F0CA"}.mdi-border-inside::before{content:"\F0CB"}.mdi-border-left::before{content:"\F0CC"}.mdi-border-left-variant::before{content:"\F8A2"}.mdi-border-none::before{content:"\F0CD"}.mdi-border-none-variant::before{content:"\F8A3"}.mdi-border-outside::before{content:"\F0CE"}.mdi-border-right::before{content:"\F0CF"}.mdi-border-right-variant::before{content:"\F8A4"}.mdi-border-style::before{content:"\F0D0"}.mdi-border-top::before{content:"\F0D1"}.mdi-border-top-variant::before{content:"\F8A5"}.mdi-border-vertical::before{content:"\F0D2"}.mdi-bottle-soda::before{content:"\F009B"}.mdi-bottle-soda-classic::before{content:"\F009C"}.mdi-bottle-soda-classic-outline::before{content:"\F038E"}.mdi-bottle-soda-outline::before{content:"\F009D"}.mdi-bottle-tonic::before{content:"\F0159"}.mdi-bottle-tonic-outline::before{content:"\F015A"}.mdi-bottle-tonic-plus::before{content:"\F015B"}.mdi-bottle-tonic-plus-outline::before{content:"\F015C"}.mdi-bottle-tonic-skull::before{content:"\F015D"}.mdi-bottle-tonic-skull-outline::before{content:"\F015E"}.mdi-bottle-wine::before{content:"\F853"}.mdi-bottle-wine-outline::before{content:"\F033B"}.mdi-bow-tie::before{content:"\F677"}.mdi-bowl::before{content:"\F617"}.mdi-bowling::before{content:"\F0D3"}.mdi-box::before{content:"\F0D4"}.mdi-box-cutter::before{content:"\F0D5"}.mdi-box-shadow::before{content:"\F637"}.mdi-boxing-glove::before{content:"\FB41"}.mdi-braille::before{content:"\F9CF"}.mdi-brain::before{content:"\F9D0"}.mdi-bread-slice::before{content:"\FCCA"}.mdi-bread-slice-outline::before{content:"\FCCB"}.mdi-bridge::before{content:"\F618"}.mdi-briefcase::before{content:"\F0D6"}.mdi-briefcase-account::before{content:"\FCCC"}.mdi-briefcase-account-outline::before{content:"\FCCD"}.mdi-briefcase-check::before{content:"\F0D7"}.mdi-briefcase-check-outline::before{content:"\F0349"}.mdi-briefcase-clock::before{content:"\F00FB"}.mdi-briefcase-clock-outline::before{content:"\F00FC"}.mdi-briefcase-download::before{content:"\F0D8"}.mdi-briefcase-download-outline::before{content:"\FC19"}.mdi-briefcase-edit::before{content:"\FA97"}.mdi-briefcase-edit-outline::before{content:"\FC1A"}.mdi-briefcase-minus::before{content:"\FA29"}.mdi-briefcase-minus-outline::before{content:"\FC1B"}.mdi-briefcase-outline::before{content:"\F813"}.mdi-briefcase-plus::before{content:"\FA2A"}.mdi-briefcase-plus-outline::before{content:"\FC1C"}.mdi-briefcase-remove::before{content:"\FA2B"}.mdi-briefcase-remove-outline::before{content:"\FC1D"}.mdi-briefcase-search::before{content:"\FA2C"}.mdi-briefcase-search-outline::before{content:"\FC1E"}.mdi-briefcase-upload::before{content:"\F0D9"}.mdi-briefcase-upload-outline::before{content:"\FC1F"}.mdi-brightness-1::before{content:"\F0DA"}.mdi-brightness-2::before{content:"\F0DB"}.mdi-brightness-3::before{content:"\F0DC"}.mdi-brightness-4::before{content:"\F0DD"}.mdi-brightness-5::before{content:"\F0DE"}.mdi-brightness-6::before{content:"\F0DF"}.mdi-brightness-7::before{content:"\F0E0"}.mdi-brightness-auto::before{content:"\F0E1"}.mdi-brightness-percent::before{content:"\FCCE"}.mdi-broom::before{content:"\F0E2"}.mdi-brush::before{content:"\F0E3"}.mdi-buddhism::before{content:"\F94A"}.mdi-buffer::before{content:"\F619"}.mdi-bug::before{content:"\F0E4"}.mdi-bug-check::before{content:"\FA2D"}.mdi-bug-check-outline::before{content:"\FA2E"}.mdi-bug-outline::before{content:"\FA2F"}.mdi-bugle::before{content:"\FD90"}.mdi-bulldozer::before{content:"\FB07"}.mdi-bullet::before{content:"\FCCF"}.mdi-bulletin-board::before{content:"\F0E5"}.mdi-bullhorn::before{content:"\F0E6"}.mdi-bullhorn-outline::before{content:"\FB08"}.mdi-bullseye::before{content:"\F5DD"}.mdi-bullseye-arrow::before{content:"\F8C8"}.mdi-bulma::before{content:"\F0312"}.mdi-bunk-bed::before{content:"\F032D"}.mdi-bus::before{content:"\F0E7"}.mdi-bus-alert::before{content:"\FA98"}.mdi-bus-articulated-end::before{content:"\F79B"}.mdi-bus-articulated-front::before{content:"\F79C"}.mdi-bus-clock::before{content:"\F8C9"}.mdi-bus-double-decker::before{content:"\F79D"}.mdi-bus-marker::before{content:"\F023D"}.mdi-bus-multiple::before{content:"\FF5C"}.mdi-bus-school::before{content:"\F79E"}.mdi-bus-side::before{content:"\F79F"}.mdi-bus-stop::before{content:"\F0034"}.mdi-bus-stop-covered::before{content:"\F0035"}.mdi-bus-stop-uncovered::before{content:"\F0036"}.mdi-cached::before{content:"\F0E8"}.mdi-cactus::before{content:"\FD91"}.mdi-cake::before{content:"\F0E9"}.mdi-cake-layered::before{content:"\F0EA"}.mdi-cake-variant::before{content:"\F0EB"}.mdi-calculator::before{content:"\F0EC"}.mdi-calculator-variant::before{content:"\FA99"}.mdi-calendar::before{content:"\F0ED"}.mdi-calendar-account::before{content:"\FEF4"}.mdi-calendar-account-outline::before{content:"\FEF5"}.mdi-calendar-alert::before{content:"\FA30"}.mdi-calendar-arrow-left::before{content:"\F015F"}.mdi-calendar-arrow-right::before{content:"\F0160"}.mdi-calendar-blank::before{content:"\F0EE"}.mdi-calendar-blank-multiple::before{content:"\F009E"}.mdi-calendar-blank-outline::before{content:"\FB42"}.mdi-calendar-check::before{content:"\F0EF"}.mdi-calendar-check-outline::before{content:"\FC20"}.mdi-calendar-clock::before{content:"\F0F0"}.mdi-calendar-edit::before{content:"\F8A6"}.mdi-calendar-export::before{content:"\FB09"}.mdi-calendar-heart::before{content:"\F9D1"}.mdi-calendar-import::before{content:"\FB0A"}.mdi-calendar-minus::before{content:"\FD38"}.mdi-calendar-month::before{content:"\FDFA"}.mdi-calendar-month-outline::before{content:"\FDFB"}.mdi-calendar-multiple::before{content:"\F0F1"}.mdi-calendar-multiple-check::before{content:"\F0F2"}.mdi-calendar-multiselect::before{content:"\FA31"}.mdi-calendar-outline::before{content:"\FB43"}.mdi-calendar-plus::before{content:"\F0F3"}.mdi-calendar-question::before{content:"\F691"}.mdi-calendar-range::before{content:"\F678"}.mdi-calendar-range-outline::before{content:"\FB44"}.mdi-calendar-remove::before{content:"\F0F4"}.mdi-calendar-remove-outline::before{content:"\FC21"}.mdi-calendar-repeat::before{content:"\FEAB"}.mdi-calendar-repeat-outline::before{content:"\FEAC"}.mdi-calendar-search::before{content:"\F94B"}.mdi-calendar-star::before{content:"\F9D2"}.mdi-calendar-text::before{content:"\F0F5"}.mdi-calendar-text-outline::before{content:"\FC22"}.mdi-calendar-today::before{content:"\F0F6"}.mdi-calendar-week::before{content:"\FA32"}.mdi-calendar-week-begin::before{content:"\FA33"}.mdi-calendar-weekend::before{content:"\FEF6"}.mdi-calendar-weekend-outline::before{content:"\FEF7"}.mdi-call-made::before{content:"\F0F7"}.mdi-call-merge::before{content:"\F0F8"}.mdi-call-missed::before{content:"\F0F9"}.mdi-call-received::before{content:"\F0FA"}.mdi-call-split::before{content:"\F0FB"}.mdi-camcorder::before{content:"\F0FC"}.mdi-camcorder-box::before{content:"\F0FD"}.mdi-camcorder-box-off::before{content:"\F0FE"}.mdi-camcorder-off::before{content:"\F0FF"}.mdi-camera::before{content:"\F100"}.mdi-camera-account::before{content:"\F8CA"}.mdi-camera-burst::before{content:"\F692"}.mdi-camera-control::before{content:"\FB45"}.mdi-camera-enhance::before{content:"\F101"}.mdi-camera-enhance-outline::before{content:"\FB46"}.mdi-camera-front::before{content:"\F102"}.mdi-camera-front-variant::before{content:"\F103"}.mdi-camera-gopro::before{content:"\F7A0"}.mdi-camera-image::before{content:"\F8CB"}.mdi-camera-iris::before{content:"\F104"}.mdi-camera-metering-center::before{content:"\F7A1"}.mdi-camera-metering-matrix::before{content:"\F7A2"}.mdi-camera-metering-partial::before{content:"\F7A3"}.mdi-camera-metering-spot::before{content:"\F7A4"}.mdi-camera-off::before{content:"\F5DF"}.mdi-camera-outline::before{content:"\FD39"}.mdi-camera-party-mode::before{content:"\F105"}.mdi-camera-plus::before{content:"\FEF8"}.mdi-camera-plus-outline::before{content:"\FEF9"}.mdi-camera-rear::before{content:"\F106"}.mdi-camera-rear-variant::before{content:"\F107"}.mdi-camera-retake::before{content:"\FDFC"}.mdi-camera-retake-outline::before{content:"\FDFD"}.mdi-camera-switch::before{content:"\F108"}.mdi-camera-timer::before{content:"\F109"}.mdi-camera-wireless::before{content:"\FD92"}.mdi-camera-wireless-outline::before{content:"\FD93"}.mdi-campfire::before{content:"\FEFA"}.mdi-cancel::before{content:"\F739"}.mdi-candle::before{content:"\F5E2"}.mdi-candycane::before{content:"\F10A"}.mdi-cannabis::before{content:"\F7A5"}.mdi-caps-lock::before{content:"\FA9A"}.mdi-car::before{content:"\F10B"}.mdi-car-2-plus::before{content:"\F0037"}.mdi-car-3-plus::before{content:"\F0038"}.mdi-car-back::before{content:"\FDFE"}.mdi-car-battery::before{content:"\F10C"}.mdi-car-brake-abs::before{content:"\FC23"}.mdi-car-brake-alert::before{content:"\FC24"}.mdi-car-brake-hold::before{content:"\FD3A"}.mdi-car-brake-parking::before{content:"\FD3B"}.mdi-car-brake-retarder::before{content:"\F0039"}.mdi-car-child-seat::before{content:"\FFC3"}.mdi-car-clutch::before{content:"\F003A"}.mdi-car-connected::before{content:"\F10D"}.mdi-car-convertible::before{content:"\F7A6"}.mdi-car-coolant-level::before{content:"\F003B"}.mdi-car-cruise-control::before{content:"\FD3C"}.mdi-car-defrost-front::before{content:"\FD3D"}.mdi-car-defrost-rear::before{content:"\FD3E"}.mdi-car-door::before{content:"\FB47"}.mdi-car-door-lock::before{content:"\F00C8"}.mdi-car-electric::before{content:"\FB48"}.mdi-car-esp::before{content:"\FC25"}.mdi-car-estate::before{content:"\F7A7"}.mdi-car-hatchback::before{content:"\F7A8"}.mdi-car-info::before{content:"\F01E9"}.mdi-car-key::before{content:"\FB49"}.mdi-car-light-dimmed::before{content:"\FC26"}.mdi-car-light-fog::before{content:"\FC27"}.mdi-car-light-high::before{content:"\FC28"}.mdi-car-limousine::before{content:"\F8CC"}.mdi-car-multiple::before{content:"\FB4A"}.mdi-car-off::before{content:"\FDFF"}.mdi-car-parking-lights::before{content:"\FD3F"}.mdi-car-pickup::before{content:"\F7A9"}.mdi-car-seat::before{content:"\FFC4"}.mdi-car-seat-cooler::before{content:"\FFC5"}.mdi-car-seat-heater::before{content:"\FFC6"}.mdi-car-shift-pattern::before{content:"\FF5D"}.mdi-car-side::before{content:"\F7AA"}.mdi-car-sports::before{content:"\F7AB"}.mdi-car-tire-alert::before{content:"\FC29"}.mdi-car-traction-control::before{content:"\FD40"}.mdi-car-turbocharger::before{content:"\F003C"}.mdi-car-wash::before{content:"\F10E"}.mdi-car-windshield::before{content:"\F003D"}.mdi-car-windshield-outline::before{content:"\F003E"}.mdi-caravan::before{content:"\F7AC"}.mdi-card::before{content:"\FB4B"}.mdi-card-bulleted::before{content:"\FB4C"}.mdi-card-bulleted-off::before{content:"\FB4D"}.mdi-card-bulleted-off-outline::before{content:"\FB4E"}.mdi-card-bulleted-outline::before{content:"\FB4F"}.mdi-card-bulleted-settings::before{content:"\FB50"}.mdi-card-bulleted-settings-outline::before{content:"\FB51"}.mdi-card-outline::before{content:"\FB52"}.mdi-card-plus::before{content:"\F022A"}.mdi-card-plus-outline::before{content:"\F022B"}.mdi-card-search::before{content:"\F009F"}.mdi-card-search-outline::before{content:"\F00A0"}.mdi-card-text::before{content:"\FB53"}.mdi-card-text-outline::before{content:"\FB54"}.mdi-cards::before{content:"\F638"}.mdi-cards-club::before{content:"\F8CD"}.mdi-cards-diamond::before{content:"\F8CE"}.mdi-cards-diamond-outline::before{content:"\F003F"}.mdi-cards-heart::before{content:"\F8CF"}.mdi-cards-outline::before{content:"\F639"}.mdi-cards-playing-outline::before{content:"\F63A"}.mdi-cards-spade::before{content:"\F8D0"}.mdi-cards-variant::before{content:"\F6C6"}.mdi-carrot::before{content:"\F10F"}.mdi-cart::before{content:"\F110"}.mdi-cart-arrow-down::before{content:"\FD42"}.mdi-cart-arrow-right::before{content:"\FC2A"}.mdi-cart-arrow-up::before{content:"\FD43"}.mdi-cart-minus::before{content:"\FD44"}.mdi-cart-off::before{content:"\F66B"}.mdi-cart-outline::before{content:"\F111"}.mdi-cart-plus::before{content:"\F112"}.mdi-cart-remove::before{content:"\FD45"}.mdi-case-sensitive-alt::before{content:"\F113"}.mdi-cash::before{content:"\F114"}.mdi-cash-100::before{content:"\F115"}.mdi-cash-marker::before{content:"\FD94"}.mdi-cash-minus::before{content:"\F028B"}.mdi-cash-multiple::before{content:"\F116"}.mdi-cash-plus::before{content:"\F028C"}.mdi-cash-refund::before{content:"\FA9B"}.mdi-cash-register::before{content:"\FCD0"}.mdi-cash-remove::before{content:"\F028D"}.mdi-cash-usd::before{content:"\F01A1"}.mdi-cash-usd-outline::before{content:"\F117"}.mdi-cassette::before{content:"\F9D3"}.mdi-cast::before{content:"\F118"}.mdi-cast-audio::before{content:"\F0040"}.mdi-cast-connected::before{content:"\F119"}.mdi-cast-education::before{content:"\FE6D"}.mdi-cast-off::before{content:"\F789"}.mdi-castle::before{content:"\F11A"}.mdi-cat::before{content:"\F11B"}.mdi-cctv::before{content:"\F7AD"}.mdi-ceiling-light::before{content:"\F768"}.mdi-cellphone::before{content:"\F11C"}.mdi-cellphone-android::before{content:"\F11D"}.mdi-cellphone-arrow-down::before{content:"\F9D4"}.mdi-cellphone-basic::before{content:"\F11E"}.mdi-cellphone-dock::before{content:"\F11F"}.mdi-cellphone-erase::before{content:"\F94C"}.mdi-cellphone-information::before{content:"\FF5E"}.mdi-cellphone-iphone::before{content:"\F120"}.mdi-cellphone-key::before{content:"\F94D"}.mdi-cellphone-link::before{content:"\F121"}.mdi-cellphone-link-off::before{content:"\F122"}.mdi-cellphone-lock::before{content:"\F94E"}.mdi-cellphone-message::before{content:"\F8D2"}.mdi-cellphone-message-off::before{content:"\F00FD"}.mdi-cellphone-nfc::before{content:"\FEAD"}.mdi-cellphone-nfc-off::before{content:"\F0303"}.mdi-cellphone-off::before{content:"\F94F"}.mdi-cellphone-play::before{content:"\F0041"}.mdi-cellphone-screenshot::before{content:"\FA34"}.mdi-cellphone-settings::before{content:"\F123"}.mdi-cellphone-settings-variant::before{content:"\F950"}.mdi-cellphone-sound::before{content:"\F951"}.mdi-cellphone-text::before{content:"\F8D1"}.mdi-cellphone-wireless::before{content:"\F814"}.mdi-celtic-cross::before{content:"\FCD1"}.mdi-centos::before{content:"\F0145"}.mdi-certificate::before{content:"\F124"}.mdi-certificate-outline::before{content:"\F01B3"}.mdi-chair-rolling::before{content:"\FFBA"}.mdi-chair-school::before{content:"\F125"}.mdi-charity::before{content:"\FC2B"}.mdi-chart-arc::before{content:"\F126"}.mdi-chart-areaspline::before{content:"\F127"}.mdi-chart-areaspline-variant::before{content:"\FEAE"}.mdi-chart-bar::before{content:"\F128"}.mdi-chart-bar-stacked::before{content:"\F769"}.mdi-chart-bell-curve::before{content:"\FC2C"}.mdi-chart-bell-curve-cumulative::before{content:"\FFC7"}.mdi-chart-bubble::before{content:"\F5E3"}.mdi-chart-donut::before{content:"\F7AE"}.mdi-chart-donut-variant::before{content:"\F7AF"}.mdi-chart-gantt::before{content:"\F66C"}.mdi-chart-histogram::before{content:"\F129"}.mdi-chart-line::before{content:"\F12A"}.mdi-chart-line-stacked::before{content:"\F76A"}.mdi-chart-line-variant::before{content:"\F7B0"}.mdi-chart-multiline::before{content:"\F8D3"}.mdi-chart-multiple::before{content:"\F023E"}.mdi-chart-pie::before{content:"\F12B"}.mdi-chart-ppf::before{content:"\F03AB"}.mdi-chart-scatter-plot::before{content:"\FEAF"}.mdi-chart-scatter-plot-hexbin::before{content:"\F66D"}.mdi-chart-snakey::before{content:"\F020A"}.mdi-chart-snakey-variant::before{content:"\F020B"}.mdi-chart-timeline::before{content:"\F66E"}.mdi-chart-timeline-variant::before{content:"\FEB0"}.mdi-chart-tree::before{content:"\FEB1"}.mdi-chat::before{content:"\FB55"}.mdi-chat-alert::before{content:"\FB56"}.mdi-chat-alert-outline::before{content:"\F02F4"}.mdi-chat-outline::before{content:"\FEFB"}.mdi-chat-processing::before{content:"\FB57"}.mdi-chat-processing-outline::before{content:"\F02F5"}.mdi-chat-sleep::before{content:"\F02FC"}.mdi-chat-sleep-outline::before{content:"\F02FD"}.mdi-check::before{content:"\F12C"}.mdi-check-all::before{content:"\F12D"}.mdi-check-bold::before{content:"\FE6E"}.mdi-check-box-multiple-outline::before{content:"\FC2D"}.mdi-check-box-outline::before{content:"\FC2E"}.mdi-check-circle::before{content:"\F5E0"}.mdi-check-circle-outline::before{content:"\F5E1"}.mdi-check-decagram::before{content:"\F790"}.mdi-check-network::before{content:"\FC2F"}.mdi-check-network-outline::before{content:"\FC30"}.mdi-check-outline::before{content:"\F854"}.mdi-check-underline::before{content:"\FE70"}.mdi-check-underline-circle::before{content:"\FE71"}.mdi-check-underline-circle-outline::before{content:"\FE72"}.mdi-checkbook::before{content:"\FA9C"}.mdi-checkbox-blank::before{content:"\F12E"}.mdi-checkbox-blank-circle::before{content:"\F12F"}.mdi-checkbox-blank-circle-outline::before{content:"\F130"}.mdi-checkbox-blank-off::before{content:"\F0317"}.mdi-checkbox-blank-off-outline::before{content:"\F0318"}.mdi-checkbox-blank-outline::before{content:"\F131"}.mdi-checkbox-intermediate::before{content:"\F855"}.mdi-checkbox-marked::before{content:"\F132"}.mdi-checkbox-marked-circle::before{content:"\F133"}.mdi-checkbox-marked-circle-outline::before{content:"\F134"}.mdi-checkbox-marked-outline::before{content:"\F135"}.mdi-checkbox-multiple-blank::before{content:"\F136"}.mdi-checkbox-multiple-blank-circle::before{content:"\F63B"}.mdi-checkbox-multiple-blank-circle-outline::before{content:"\F63C"}.mdi-checkbox-multiple-blank-outline::before{content:"\F137"}.mdi-checkbox-multiple-marked::before{content:"\F138"}.mdi-checkbox-multiple-marked-circle::before{content:"\F63D"}.mdi-checkbox-multiple-marked-circle-outline::before{content:"\F63E"}.mdi-checkbox-multiple-marked-outline::before{content:"\F139"}.mdi-checkerboard::before{content:"\F13A"}.mdi-checkerboard-minus::before{content:"\F022D"}.mdi-checkerboard-plus::before{content:"\F022C"}.mdi-checkerboard-remove::before{content:"\F022E"}.mdi-cheese::before{content:"\F02E4"}.mdi-chef-hat::before{content:"\FB58"}.mdi-chemical-weapon::before{content:"\F13B"}.mdi-chess-bishop::before{content:"\F85B"}.mdi-chess-king::before{content:"\F856"}.mdi-chess-knight::before{content:"\F857"}.mdi-chess-pawn::before{content:"\F858"}.mdi-chess-queen::before{content:"\F859"}.mdi-chess-rook::before{content:"\F85A"}.mdi-chevron-double-down::before{content:"\F13C"}.mdi-chevron-double-left::before{content:"\F13D"}.mdi-chevron-double-right::before{content:"\F13E"}.mdi-chevron-double-up::before{content:"\F13F"}.mdi-chevron-down::before{content:"\F140"}.mdi-chevron-down-box::before{content:"\F9D5"}.mdi-chevron-down-box-outline::before{content:"\F9D6"}.mdi-chevron-down-circle::before{content:"\FB0B"}.mdi-chevron-down-circle-outline::before{content:"\FB0C"}.mdi-chevron-left::before{content:"\F141"}.mdi-chevron-left-box::before{content:"\F9D7"}.mdi-chevron-left-box-outline::before{content:"\F9D8"}.mdi-chevron-left-circle::before{content:"\FB0D"}.mdi-chevron-left-circle-outline::before{content:"\FB0E"}.mdi-chevron-right::before{content:"\F142"}.mdi-chevron-right-box::before{content:"\F9D9"}.mdi-chevron-right-box-outline::before{content:"\F9DA"}.mdi-chevron-right-circle::before{content:"\FB0F"}.mdi-chevron-right-circle-outline::before{content:"\FB10"}.mdi-chevron-triple-down::before{content:"\FD95"}.mdi-chevron-triple-left::before{content:"\FD96"}.mdi-chevron-triple-right::before{content:"\FD97"}.mdi-chevron-triple-up::before{content:"\FD98"}.mdi-chevron-up::before{content:"\F143"}.mdi-chevron-up-box::before{content:"\F9DB"}.mdi-chevron-up-box-outline::before{content:"\F9DC"}.mdi-chevron-up-circle::before{content:"\FB11"}.mdi-chevron-up-circle-outline::before{content:"\FB12"}.mdi-chili-hot::before{content:"\F7B1"}.mdi-chili-medium::before{content:"\F7B2"}.mdi-chili-mild::before{content:"\F7B3"}.mdi-chip::before{content:"\F61A"}.mdi-christianity::before{content:"\F952"}.mdi-christianity-outline::before{content:"\FCD2"}.mdi-church::before{content:"\F144"}.mdi-cigar::before{content:"\F01B4"}.mdi-circle::before{content:"\F764"}.mdi-circle-double::before{content:"\FEB2"}.mdi-circle-edit-outline::before{content:"\F8D4"}.mdi-circle-expand::before{content:"\FEB3"}.mdi-circle-medium::before{content:"\F9DD"}.mdi-circle-off-outline::before{content:"\F00FE"}.mdi-circle-outline::before{content:"\F765"}.mdi-circle-slice-1::before{content:"\FA9D"}.mdi-circle-slice-2::before{content:"\FA9E"}.mdi-circle-slice-3::before{content:"\FA9F"}.mdi-circle-slice-4::before{content:"\FAA0"}.mdi-circle-slice-5::before{content:"\FAA1"}.mdi-circle-slice-6::before{content:"\FAA2"}.mdi-circle-slice-7::before{content:"\FAA3"}.mdi-circle-slice-8::before{content:"\FAA4"}.mdi-circle-small::before{content:"\F9DE"}.mdi-circular-saw::before{content:"\FE73"}.mdi-cisco-webex::before{content:"\F145"}.mdi-city::before{content:"\F146"}.mdi-city-variant::before{content:"\FA35"}.mdi-city-variant-outline::before{content:"\FA36"}.mdi-clipboard::before{content:"\F147"}.mdi-clipboard-account::before{content:"\F148"}.mdi-clipboard-account-outline::before{content:"\FC31"}.mdi-clipboard-alert::before{content:"\F149"}.mdi-clipboard-alert-outline::before{content:"\FCD3"}.mdi-clipboard-arrow-down::before{content:"\F14A"}.mdi-clipboard-arrow-down-outline::before{content:"\FC32"}.mdi-clipboard-arrow-left::before{content:"\F14B"}.mdi-clipboard-arrow-left-outline::before{content:"\FCD4"}.mdi-clipboard-arrow-right::before{content:"\FCD5"}.mdi-clipboard-arrow-right-outline::before{content:"\FCD6"}.mdi-clipboard-arrow-up::before{content:"\FC33"}.mdi-clipboard-arrow-up-outline::before{content:"\FC34"}.mdi-clipboard-check::before{content:"\F14C"}.mdi-clipboard-check-multiple::before{content:"\F028E"}.mdi-clipboard-check-multiple-outline::before{content:"\F028F"}.mdi-clipboard-check-outline::before{content:"\F8A7"}.mdi-clipboard-file::before{content:"\F0290"}.mdi-clipboard-file-outline::before{content:"\F0291"}.mdi-clipboard-flow::before{content:"\F6C7"}.mdi-clipboard-flow-outline::before{content:"\F0142"}.mdi-clipboard-list::before{content:"\F00FF"}.mdi-clipboard-list-outline::before{content:"\F0100"}.mdi-clipboard-multiple::before{content:"\F0292"}.mdi-clipboard-multiple-outline::before{content:"\F0293"}.mdi-clipboard-outline::before{content:"\F14D"}.mdi-clipboard-play::before{content:"\FC35"}.mdi-clipboard-play-multiple::before{content:"\F0294"}.mdi-clipboard-play-multiple-outline::before{content:"\F0295"}.mdi-clipboard-play-outline::before{content:"\FC36"}.mdi-clipboard-plus::before{content:"\F750"}.mdi-clipboard-plus-outline::before{content:"\F034A"}.mdi-clipboard-pulse::before{content:"\F85C"}.mdi-clipboard-pulse-outline::before{content:"\F85D"}.mdi-clipboard-text::before{content:"\F14E"}.mdi-clipboard-text-multiple::before{content:"\F0296"}.mdi-clipboard-text-multiple-outline::before{content:"\F0297"}.mdi-clipboard-text-outline::before{content:"\FA37"}.mdi-clipboard-text-play::before{content:"\FC37"}.mdi-clipboard-text-play-outline::before{content:"\FC38"}.mdi-clippy::before{content:"\F14F"}.mdi-clock::before{content:"\F953"}.mdi-clock-alert::before{content:"\F954"}.mdi-clock-alert-outline::before{content:"\F5CE"}.mdi-clock-check::before{content:"\FFC8"}.mdi-clock-check-outline::before{content:"\FFC9"}.mdi-clock-digital::before{content:"\FEB4"}.mdi-clock-end::before{content:"\F151"}.mdi-clock-fast::before{content:"\F152"}.mdi-clock-in::before{content:"\F153"}.mdi-clock-out::before{content:"\F154"}.mdi-clock-outline::before{content:"\F150"}.mdi-clock-start::before{content:"\F155"}.mdi-close::before{content:"\F156"}.mdi-close-box::before{content:"\F157"}.mdi-close-box-multiple::before{content:"\FC39"}.mdi-close-box-multiple-outline::before{content:"\FC3A"}.mdi-close-box-outline::before{content:"\F158"}.mdi-close-circle::before{content:"\F159"}.mdi-close-circle-outline::before{content:"\F15A"}.mdi-close-network::before{content:"\F15B"}.mdi-close-network-outline::before{content:"\FC3B"}.mdi-close-octagon::before{content:"\F15C"}.mdi-close-octagon-outline::before{content:"\F15D"}.mdi-close-outline::before{content:"\F6C8"}.mdi-closed-caption::before{content:"\F15E"}.mdi-closed-caption-outline::before{content:"\FD99"}.mdi-cloud::before{content:"\F15F"}.mdi-cloud-alert::before{content:"\F9DF"}.mdi-cloud-braces::before{content:"\F7B4"}.mdi-cloud-check::before{content:"\F160"}.mdi-cloud-check-outline::before{content:"\F02F7"}.mdi-cloud-circle::before{content:"\F161"}.mdi-cloud-download::before{content:"\F162"}.mdi-cloud-download-outline::before{content:"\FB59"}.mdi-cloud-lock::before{content:"\F021C"}.mdi-cloud-lock-outline::before{content:"\F021D"}.mdi-cloud-off-outline::before{content:"\F164"}.mdi-cloud-outline::before{content:"\F163"}.mdi-cloud-print::before{content:"\F165"}.mdi-cloud-print-outline::before{content:"\F166"}.mdi-cloud-question::before{content:"\FA38"}.mdi-cloud-search::before{content:"\F955"}.mdi-cloud-search-outline::before{content:"\F956"}.mdi-cloud-sync::before{content:"\F63F"}.mdi-cloud-sync-outline::before{content:"\F0301"}.mdi-cloud-tags::before{content:"\F7B5"}.mdi-cloud-upload::before{content:"\F167"}.mdi-cloud-upload-outline::before{content:"\FB5A"}.mdi-clover::before{content:"\F815"}.mdi-coach-lamp::before{content:"\F0042"}.mdi-coat-rack::before{content:"\F00C9"}.mdi-code-array::before{content:"\F168"}.mdi-code-braces::before{content:"\F169"}.mdi-code-braces-box::before{content:"\F0101"}.mdi-code-brackets::before{content:"\F16A"}.mdi-code-equal::before{content:"\F16B"}.mdi-code-greater-than::before{content:"\F16C"}.mdi-code-greater-than-or-equal::before{content:"\F16D"}.mdi-code-less-than::before{content:"\F16E"}.mdi-code-less-than-or-equal::before{content:"\F16F"}.mdi-code-not-equal::before{content:"\F170"}.mdi-code-not-equal-variant::before{content:"\F171"}.mdi-code-parentheses::before{content:"\F172"}.mdi-code-parentheses-box::before{content:"\F0102"}.mdi-code-string::before{content:"\F173"}.mdi-code-tags::before{content:"\F174"}.mdi-code-tags-check::before{content:"\F693"}.mdi-codepen::before{content:"\F175"}.mdi-coffee::before{content:"\F176"}.mdi-coffee-maker::before{content:"\F00CA"}.mdi-coffee-off::before{content:"\FFCA"}.mdi-coffee-off-outline::before{content:"\FFCB"}.mdi-coffee-outline::before{content:"\F6C9"}.mdi-coffee-to-go::before{content:"\F177"}.mdi-coffee-to-go-outline::before{content:"\F0339"}.mdi-coffin::before{content:"\FB5B"}.mdi-cog-clockwise::before{content:"\F0208"}.mdi-cog-counterclockwise::before{content:"\F0209"}.mdi-cogs::before{content:"\F8D5"}.mdi-coin::before{content:"\F0196"}.mdi-coin-outline::before{content:"\F178"}.mdi-coins::before{content:"\F694"}.mdi-collage::before{content:"\F640"}.mdi-collapse-all::before{content:"\FAA5"}.mdi-collapse-all-outline::before{content:"\FAA6"}.mdi-color-helper::before{content:"\F179"}.mdi-comma::before{content:"\FE74"}.mdi-comma-box::before{content:"\FE75"}.mdi-comma-box-outline::before{content:"\FE76"}.mdi-comma-circle::before{content:"\FE77"}.mdi-comma-circle-outline::before{content:"\FE78"}.mdi-comment::before{content:"\F17A"}.mdi-comment-account::before{content:"\F17B"}.mdi-comment-account-outline::before{content:"\F17C"}.mdi-comment-alert::before{content:"\F17D"}.mdi-comment-alert-outline::before{content:"\F17E"}.mdi-comment-arrow-left::before{content:"\F9E0"}.mdi-comment-arrow-left-outline::before{content:"\F9E1"}.mdi-comment-arrow-right::before{content:"\F9E2"}.mdi-comment-arrow-right-outline::before{content:"\F9E3"}.mdi-comment-check::before{content:"\F17F"}.mdi-comment-check-outline::before{content:"\F180"}.mdi-comment-edit::before{content:"\F01EA"}.mdi-comment-edit-outline::before{content:"\F02EF"}.mdi-comment-eye::before{content:"\FA39"}.mdi-comment-eye-outline::before{content:"\FA3A"}.mdi-comment-multiple::before{content:"\F85E"}.mdi-comment-multiple-outline::before{content:"\F181"}.mdi-comment-outline::before{content:"\F182"}.mdi-comment-plus::before{content:"\F9E4"}.mdi-comment-plus-outline::before{content:"\F183"}.mdi-comment-processing::before{content:"\F184"}.mdi-comment-processing-outline::before{content:"\F185"}.mdi-comment-question::before{content:"\F816"}.mdi-comment-question-outline::before{content:"\F186"}.mdi-comment-quote::before{content:"\F0043"}.mdi-comment-quote-outline::before{content:"\F0044"}.mdi-comment-remove::before{content:"\F5DE"}.mdi-comment-remove-outline::before{content:"\F187"}.mdi-comment-search::before{content:"\FA3B"}.mdi-comment-search-outline::before{content:"\FA3C"}.mdi-comment-text::before{content:"\F188"}.mdi-comment-text-multiple::before{content:"\F85F"}.mdi-comment-text-multiple-outline::before{content:"\F860"}.mdi-comment-text-outline::before{content:"\F189"}.mdi-compare::before{content:"\F18A"}.mdi-compass::before{content:"\F18B"}.mdi-compass-off::before{content:"\FB5C"}.mdi-compass-off-outline::before{content:"\FB5D"}.mdi-compass-outline::before{content:"\F18C"}.mdi-compass-rose::before{content:"\F03AD"}.mdi-concourse-ci::before{content:"\F00CB"}.mdi-console::before{content:"\F18D"}.mdi-console-line::before{content:"\F7B6"}.mdi-console-network::before{content:"\F8A8"}.mdi-console-network-outline::before{content:"\FC3C"}.mdi-consolidate::before{content:"\F0103"}.mdi-contact-mail::before{content:"\F18E"}.mdi-contact-mail-outline::before{content:"\FEB5"}.mdi-contact-phone::before{content:"\FEB6"}.mdi-contact-phone-outline::before{content:"\FEB7"}.mdi-contactless-payment::before{content:"\FD46"}.mdi-contacts::before{content:"\F6CA"}.mdi-contain::before{content:"\FA3D"}.mdi-contain-end::before{content:"\FA3E"}.mdi-contain-start::before{content:"\FA3F"}.mdi-content-copy::before{content:"\F18F"}.mdi-content-cut::before{content:"\F190"}.mdi-content-duplicate::before{content:"\F191"}.mdi-content-paste::before{content:"\F192"}.mdi-content-save::before{content:"\F193"}.mdi-content-save-alert::before{content:"\FF5F"}.mdi-content-save-alert-outline::before{content:"\FF60"}.mdi-content-save-all::before{content:"\F194"}.mdi-content-save-all-outline::before{content:"\FF61"}.mdi-content-save-edit::before{content:"\FCD7"}.mdi-content-save-edit-outline::before{content:"\FCD8"}.mdi-content-save-move::before{content:"\FE79"}.mdi-content-save-move-outline::before{content:"\FE7A"}.mdi-content-save-outline::before{content:"\F817"}.mdi-content-save-settings::before{content:"\F61B"}.mdi-content-save-settings-outline::before{content:"\FB13"}.mdi-contrast::before{content:"\F195"}.mdi-contrast-box::before{content:"\F196"}.mdi-contrast-circle::before{content:"\F197"}.mdi-controller-classic::before{content:"\FB5E"}.mdi-controller-classic-outline::before{content:"\FB5F"}.mdi-cookie::before{content:"\F198"}.mdi-coolant-temperature::before{content:"\F3C8"}.mdi-copyright::before{content:"\F5E6"}.mdi-cordova::before{content:"\F957"}.mdi-corn::before{content:"\F7B7"}.mdi-counter::before{content:"\F199"}.mdi-cow::before{content:"\F19A"}.mdi-cowboy::before{content:"\FEB8"}.mdi-cpu-32-bit::before{content:"\FEFC"}.mdi-cpu-64-bit::before{content:"\FEFD"}.mdi-crane::before{content:"\F861"}.mdi-creation::before{content:"\F1C9"}.mdi-creative-commons::before{content:"\FD47"}.mdi-credit-card::before{content:"\F0010"}.mdi-credit-card-clock::before{content:"\FEFE"}.mdi-credit-card-clock-outline::before{content:"\FFBC"}.mdi-credit-card-marker::before{content:"\F6A7"}.mdi-credit-card-marker-outline::before{content:"\FD9A"}.mdi-credit-card-minus::before{content:"\FFCC"}.mdi-credit-card-minus-outline::before{content:"\FFCD"}.mdi-credit-card-multiple::before{content:"\F0011"}.mdi-credit-card-multiple-outline::before{content:"\F19C"}.mdi-credit-card-off::before{content:"\F0012"}.mdi-credit-card-off-outline::before{content:"\F5E4"}.mdi-credit-card-outline::before{content:"\F19B"}.mdi-credit-card-plus::before{content:"\F0013"}.mdi-credit-card-plus-outline::before{content:"\F675"}.mdi-credit-card-refund::before{content:"\F0014"}.mdi-credit-card-refund-outline::before{content:"\FAA7"}.mdi-credit-card-remove::before{content:"\FFCE"}.mdi-credit-card-remove-outline::before{content:"\FFCF"}.mdi-credit-card-scan::before{content:"\F0015"}.mdi-credit-card-scan-outline::before{content:"\F19D"}.mdi-credit-card-settings::before{content:"\F0016"}.mdi-credit-card-settings-outline::before{content:"\F8D6"}.mdi-credit-card-wireless::before{content:"\F801"}.mdi-credit-card-wireless-outline::before{content:"\FD48"}.mdi-cricket::before{content:"\FD49"}.mdi-crop::before{content:"\F19E"}.mdi-crop-free::before{content:"\F19F"}.mdi-crop-landscape::before{content:"\F1A0"}.mdi-crop-portrait::before{content:"\F1A1"}.mdi-crop-rotate::before{content:"\F695"}.mdi-crop-square::before{content:"\F1A2"}.mdi-crosshairs::before{content:"\F1A3"}.mdi-crosshairs-gps::before{content:"\F1A4"}.mdi-crosshairs-off::before{content:"\FF62"}.mdi-crosshairs-question::before{content:"\F0161"}.mdi-crown::before{content:"\F1A5"}.mdi-crown-outline::before{content:"\F01FB"}.mdi-cryengine::before{content:"\F958"}.mdi-crystal-ball::before{content:"\FB14"}.mdi-cube::before{content:"\F1A6"}.mdi-cube-outline::before{content:"\F1A7"}.mdi-cube-scan::before{content:"\FB60"}.mdi-cube-send::before{content:"\F1A8"}.mdi-cube-unfolded::before{content:"\F1A9"}.mdi-cup::before{content:"\F1AA"}.mdi-cup-off::before{content:"\F5E5"}.mdi-cup-off-outline::before{content:"\F03A8"}.mdi-cup-outline::before{content:"\F033A"}.mdi-cup-water::before{content:"\F1AB"}.mdi-cupboard::before{content:"\FF63"}.mdi-cupboard-outline::before{content:"\FF64"}.mdi-cupcake::before{content:"\F959"}.mdi-curling::before{content:"\F862"}.mdi-currency-bdt::before{content:"\F863"}.mdi-currency-brl::before{content:"\FB61"}.mdi-currency-btc::before{content:"\F1AC"}.mdi-currency-cny::before{content:"\F7B9"}.mdi-currency-eth::before{content:"\F7BA"}.mdi-currency-eur::before{content:"\F1AD"}.mdi-currency-eur-off::before{content:"\F0340"}.mdi-currency-gbp::before{content:"\F1AE"}.mdi-currency-ils::before{content:"\FC3D"}.mdi-currency-inr::before{content:"\F1AF"}.mdi-currency-jpy::before{content:"\F7BB"}.mdi-currency-krw::before{content:"\F7BC"}.mdi-currency-kzt::before{content:"\F864"}.mdi-currency-ngn::before{content:"\F1B0"}.mdi-currency-php::before{content:"\F9E5"}.mdi-currency-rial::before{content:"\FEB9"}.mdi-currency-rub::before{content:"\F1B1"}.mdi-currency-sign::before{content:"\F7BD"}.mdi-currency-try::before{content:"\F1B2"}.mdi-currency-twd::before{content:"\F7BE"}.mdi-currency-usd::before{content:"\F1B3"}.mdi-currency-usd-off::before{content:"\F679"}.mdi-current-ac::before{content:"\F95A"}.mdi-current-dc::before{content:"\F95B"}.mdi-cursor-default::before{content:"\F1B4"}.mdi-cursor-default-click::before{content:"\FCD9"}.mdi-cursor-default-click-outline::before{content:"\FCDA"}.mdi-cursor-default-gesture::before{content:"\F0152"}.mdi-cursor-default-gesture-outline::before{content:"\F0153"}.mdi-cursor-default-outline::before{content:"\F1B5"}.mdi-cursor-move::before{content:"\F1B6"}.mdi-cursor-pointer::before{content:"\F1B7"}.mdi-cursor-text::before{content:"\F5E7"}.mdi-database::before{content:"\F1B8"}.mdi-database-check::before{content:"\FAA8"}.mdi-database-edit::before{content:"\FB62"}.mdi-database-export::before{content:"\F95D"}.mdi-database-import::before{content:"\F95C"}.mdi-database-lock::before{content:"\FAA9"}.mdi-database-marker::before{content:"\F0321"}.mdi-database-minus::before{content:"\F1B9"}.mdi-database-plus::before{content:"\F1BA"}.mdi-database-refresh::before{content:"\FCDB"}.mdi-database-remove::before{content:"\FCDC"}.mdi-database-search::before{content:"\F865"}.mdi-database-settings::before{content:"\FCDD"}.mdi-death-star::before{content:"\F8D7"}.mdi-death-star-variant::before{content:"\F8D8"}.mdi-deathly-hallows::before{content:"\FB63"}.mdi-debian::before{content:"\F8D9"}.mdi-debug-step-into::before{content:"\F1BB"}.mdi-debug-step-out::before{content:"\F1BC"}.mdi-debug-step-over::before{content:"\F1BD"}.mdi-decagram::before{content:"\F76B"}.mdi-decagram-outline::before{content:"\F76C"}.mdi-decimal::before{content:"\F00CC"}.mdi-decimal-comma::before{content:"\F00CD"}.mdi-decimal-comma-decrease::before{content:"\F00CE"}.mdi-decimal-comma-increase::before{content:"\F00CF"}.mdi-decimal-decrease::before{content:"\F1BE"}.mdi-decimal-increase::before{content:"\F1BF"}.mdi-delete::before{content:"\F1C0"}.mdi-delete-alert::before{content:"\F00D0"}.mdi-delete-alert-outline::before{content:"\F00D1"}.mdi-delete-circle::before{content:"\F682"}.mdi-delete-circle-outline::before{content:"\FB64"}.mdi-delete-empty::before{content:"\F6CB"}.mdi-delete-empty-outline::before{content:"\FEBA"}.mdi-delete-forever::before{content:"\F5E8"}.mdi-delete-forever-outline::before{content:"\FB65"}.mdi-delete-off::before{content:"\F00D2"}.mdi-delete-off-outline::before{content:"\F00D3"}.mdi-delete-outline::before{content:"\F9E6"}.mdi-delete-restore::before{content:"\F818"}.mdi-delete-sweep::before{content:"\F5E9"}.mdi-delete-sweep-outline::before{content:"\FC3E"}.mdi-delete-variant::before{content:"\F1C1"}.mdi-delta::before{content:"\F1C2"}.mdi-desk::before{content:"\F0264"}.mdi-desk-lamp::before{content:"\F95E"}.mdi-deskphone::before{content:"\F1C3"}.mdi-desktop-classic::before{content:"\F7BF"}.mdi-desktop-mac::before{content:"\F1C4"}.mdi-desktop-mac-dashboard::before{content:"\F9E7"}.mdi-desktop-tower::before{content:"\F1C5"}.mdi-desktop-tower-monitor::before{content:"\FAAA"}.mdi-details::before{content:"\F1C6"}.mdi-dev-to::before{content:"\FD4A"}.mdi-developer-board::before{content:"\F696"}.mdi-deviantart::before{content:"\F1C7"}.mdi-devices::before{content:"\FFD0"}.mdi-diabetes::before{content:"\F0151"}.mdi-dialpad::before{content:"\F61C"}.mdi-diameter::before{content:"\FC3F"}.mdi-diameter-outline::before{content:"\FC40"}.mdi-diameter-variant::before{content:"\FC41"}.mdi-diamond::before{content:"\FB66"}.mdi-diamond-outline::before{content:"\FB67"}.mdi-diamond-stone::before{content:"\F1C8"}.mdi-dice-1::before{content:"\F1CA"}.mdi-dice-1-outline::before{content:"\F0175"}.mdi-dice-2::before{content:"\F1CB"}.mdi-dice-2-outline::before{content:"\F0176"}.mdi-dice-3::before{content:"\F1CC"}.mdi-dice-3-outline::before{content:"\F0177"}.mdi-dice-4::before{content:"\F1CD"}.mdi-dice-4-outline::before{content:"\F0178"}.mdi-dice-5::before{content:"\F1CE"}.mdi-dice-5-outline::before{content:"\F0179"}.mdi-dice-6::before{content:"\F1CF"}.mdi-dice-6-outline::before{content:"\F017A"}.mdi-dice-d10::before{content:"\F017E"}.mdi-dice-d10-outline::before{content:"\F76E"}.mdi-dice-d12::before{content:"\F017F"}.mdi-dice-d12-outline::before{content:"\F866"}.mdi-dice-d20::before{content:"\F0180"}.mdi-dice-d20-outline::before{content:"\F5EA"}.mdi-dice-d4::before{content:"\F017B"}.mdi-dice-d4-outline::before{content:"\F5EB"}.mdi-dice-d6::before{content:"\F017C"}.mdi-dice-d6-outline::before{content:"\F5EC"}.mdi-dice-d8::before{content:"\F017D"}.mdi-dice-d8-outline::before{content:"\F5ED"}.mdi-dice-multiple::before{content:"\F76D"}.mdi-dice-multiple-outline::before{content:"\F0181"}.mdi-dictionary::before{content:"\F61D"}.mdi-digital-ocean::before{content:"\F0262"}.mdi-dip-switch::before{content:"\F7C0"}.mdi-directions::before{content:"\F1D0"}.mdi-directions-fork::before{content:"\F641"}.mdi-disc::before{content:"\F5EE"}.mdi-disc-alert::before{content:"\F1D1"}.mdi-disc-player::before{content:"\F95F"}.mdi-discord::before{content:"\F66F"}.mdi-dishwasher::before{content:"\FAAB"}.mdi-dishwasher-alert::before{content:"\F01E3"}.mdi-dishwasher-off::before{content:"\F01E4"}.mdi-disqus::before{content:"\F1D2"}.mdi-disqus-outline::before{content:"\F1D3"}.mdi-distribute-horizontal-center::before{content:"\F01F4"}.mdi-distribute-horizontal-left::before{content:"\F01F3"}.mdi-distribute-horizontal-right::before{content:"\F01F5"}.mdi-distribute-vertical-bottom::before{content:"\F01F6"}.mdi-distribute-vertical-center::before{content:"\F01F7"}.mdi-distribute-vertical-top::before{content:"\F01F8"}.mdi-diving-flippers::before{content:"\FD9B"}.mdi-diving-helmet::before{content:"\FD9C"}.mdi-diving-scuba::before{content:"\FD9D"}.mdi-diving-scuba-flag::before{content:"\FD9E"}.mdi-diving-scuba-tank::before{content:"\FD9F"}.mdi-diving-scuba-tank-multiple::before{content:"\FDA0"}.mdi-diving-snorkel::before{content:"\FDA1"}.mdi-division::before{content:"\F1D4"}.mdi-division-box::before{content:"\F1D5"}.mdi-dlna::before{content:"\FA40"}.mdi-dna::before{content:"\F683"}.mdi-dns::before{content:"\F1D6"}.mdi-dns-outline::before{content:"\FB68"}.mdi-do-not-disturb::before{content:"\F697"}.mdi-do-not-disturb-off::before{content:"\F698"}.mdi-dock-bottom::before{content:"\F00D4"}.mdi-dock-left::before{content:"\F00D5"}.mdi-dock-right::before{content:"\F00D6"}.mdi-dock-window::before{content:"\F00D7"}.mdi-docker::before{content:"\F867"}.mdi-doctor::before{content:"\FA41"}.mdi-dog::before{content:"\FA42"}.mdi-dog-service::before{content:"\FAAC"}.mdi-dog-side::before{content:"\FA43"}.mdi-dolby::before{content:"\F6B2"}.mdi-dolly::before{content:"\FEBB"}.mdi-domain::before{content:"\F1D7"}.mdi-domain-off::before{content:"\FD4B"}.mdi-domain-plus::before{content:"\F00D8"}.mdi-domain-remove::before{content:"\F00D9"}.mdi-domino-mask::before{content:"\F0045"}.mdi-donkey::before{content:"\F7C1"}.mdi-door::before{content:"\F819"}.mdi-door-closed::before{content:"\F81A"}.mdi-door-closed-lock::before{content:"\F00DA"}.mdi-door-open::before{content:"\F81B"}.mdi-doorbell::before{content:"\F0311"}.mdi-doorbell-video::before{content:"\F868"}.mdi-dot-net::before{content:"\FAAD"}.mdi-dots-horizontal::before{content:"\F1D8"}.mdi-dots-horizontal-circle::before{content:"\F7C2"}.mdi-dots-horizontal-circle-outline::before{content:"\FB69"}.mdi-dots-vertical::before{content:"\F1D9"}.mdi-dots-vertical-circle::before{content:"\F7C3"}.mdi-dots-vertical-circle-outline::before{content:"\FB6A"}.mdi-douban::before{content:"\F699"}.mdi-download::before{content:"\F1DA"}.mdi-download-lock::before{content:"\F034B"}.mdi-download-lock-outline::before{content:"\F034C"}.mdi-download-multiple::before{content:"\F9E8"}.mdi-download-network::before{content:"\F6F3"}.mdi-download-network-outline::before{content:"\FC42"}.mdi-download-off::before{content:"\F00DB"}.mdi-download-off-outline::before{content:"\F00DC"}.mdi-download-outline::before{content:"\FB6B"}.mdi-drag::before{content:"\F1DB"}.mdi-drag-horizontal::before{content:"\F1DC"}.mdi-drag-horizontal-variant::before{content:"\F031B"}.mdi-drag-variant::before{content:"\FB6C"}.mdi-drag-vertical::before{content:"\F1DD"}.mdi-drag-vertical-variant::before{content:"\F031C"}.mdi-drama-masks::before{content:"\FCDE"}.mdi-draw::before{content:"\FF66"}.mdi-drawing::before{content:"\F1DE"}.mdi-drawing-box::before{content:"\F1DF"}.mdi-dresser::before{content:"\FF67"}.mdi-dresser-outline::before{content:"\FF68"}.mdi-dribbble::before{content:"\F1E0"}.mdi-dribbble-box::before{content:"\F1E1"}.mdi-drone::before{content:"\F1E2"}.mdi-dropbox::before{content:"\F1E3"}.mdi-drupal::before{content:"\F1E4"}.mdi-duck::before{content:"\F1E5"}.mdi-dumbbell::before{content:"\F1E6"}.mdi-dump-truck::before{content:"\FC43"}.mdi-ear-hearing::before{content:"\F7C4"}.mdi-ear-hearing-off::before{content:"\FA44"}.mdi-earth::before{content:"\F1E7"}.mdi-earth-arrow-right::before{content:"\F033C"}.mdi-earth-box::before{content:"\F6CC"}.mdi-earth-box-off::before{content:"\F6CD"}.mdi-earth-off::before{content:"\F1E8"}.mdi-edge::before{content:"\F1E9"}.mdi-edge-legacy::before{content:"\F027B"}.mdi-egg::before{content:"\FAAE"}.mdi-egg-easter::before{content:"\FAAF"}.mdi-eight-track::before{content:"\F9E9"}.mdi-eject::before{content:"\F1EA"}.mdi-eject-outline::before{content:"\FB6D"}.mdi-electric-switch::before{content:"\FEBC"}.mdi-electric-switch-closed::before{content:"\F0104"}.mdi-electron-framework::before{content:"\F0046"}.mdi-elephant::before{content:"\F7C5"}.mdi-elevation-decline::before{content:"\F1EB"}.mdi-elevation-rise::before{content:"\F1EC"}.mdi-elevator::before{content:"\F1ED"}.mdi-elevator-down::before{content:"\F02ED"}.mdi-elevator-passenger::before{content:"\F03AC"}.mdi-elevator-up::before{content:"\F02EC"}.mdi-ellipse::before{content:"\FEBD"}.mdi-ellipse-outline::before{content:"\FEBE"}.mdi-email::before{content:"\F1EE"}.mdi-email-alert::before{content:"\F6CE"}.mdi-email-alert-outline::before{content:"\FD1E"}.mdi-email-box::before{content:"\FCDF"}.mdi-email-check::before{content:"\FAB0"}.mdi-email-check-outline::before{content:"\FAB1"}.mdi-email-edit::before{content:"\FF00"}.mdi-email-edit-outline::before{content:"\FF01"}.mdi-email-lock::before{content:"\F1F1"}.mdi-email-mark-as-unread::before{content:"\FB6E"}.mdi-email-minus::before{content:"\FF02"}.mdi-email-minus-outline::before{content:"\FF03"}.mdi-email-multiple::before{content:"\FF04"}.mdi-email-multiple-outline::before{content:"\FF05"}.mdi-email-newsletter::before{content:"\FFD1"}.mdi-email-open::before{content:"\F1EF"}.mdi-email-open-multiple::before{content:"\FF06"}.mdi-email-open-multiple-outline::before{content:"\FF07"}.mdi-email-open-outline::before{content:"\F5EF"}.mdi-email-outline::before{content:"\F1F0"}.mdi-email-plus::before{content:"\F9EA"}.mdi-email-plus-outline::before{content:"\F9EB"}.mdi-email-receive::before{content:"\F0105"}.mdi-email-receive-outline::before{content:"\F0106"}.mdi-email-search::before{content:"\F960"}.mdi-email-search-outline::before{content:"\F961"}.mdi-email-send::before{content:"\F0107"}.mdi-email-send-outline::before{content:"\F0108"}.mdi-email-sync::before{content:"\F02F2"}.mdi-email-sync-outline::before{content:"\F02F3"}.mdi-email-variant::before{content:"\F5F0"}.mdi-ember::before{content:"\FB15"}.mdi-emby::before{content:"\F6B3"}.mdi-emoticon::before{content:"\FC44"}.mdi-emoticon-angry::before{content:"\FC45"}.mdi-emoticon-angry-outline::before{content:"\FC46"}.mdi-emoticon-confused::before{content:"\F0109"}.mdi-emoticon-confused-outline::before{content:"\F010A"}.mdi-emoticon-cool::before{content:"\FC47"}.mdi-emoticon-cool-outline::before{content:"\F1F3"}.mdi-emoticon-cry::before{content:"\FC48"}.mdi-emoticon-cry-outline::before{content:"\FC49"}.mdi-emoticon-dead::before{content:"\FC4A"}.mdi-emoticon-dead-outline::before{content:"\F69A"}.mdi-emoticon-devil::before{content:"\FC4B"}.mdi-emoticon-devil-outline::before{content:"\F1F4"}.mdi-emoticon-excited::before{content:"\FC4C"}.mdi-emoticon-excited-outline::before{content:"\F69B"}.mdi-emoticon-frown::before{content:"\FF69"}.mdi-emoticon-frown-outline::before{content:"\FF6A"}.mdi-emoticon-happy::before{content:"\FC4D"}.mdi-emoticon-happy-outline::before{content:"\F1F5"}.mdi-emoticon-kiss::before{content:"\FC4E"}.mdi-emoticon-kiss-outline::before{content:"\FC4F"}.mdi-emoticon-lol::before{content:"\F023F"}.mdi-emoticon-lol-outline::before{content:"\F0240"}.mdi-emoticon-neutral::before{content:"\FC50"}.mdi-emoticon-neutral-outline::before{content:"\F1F6"}.mdi-emoticon-outline::before{content:"\F1F2"}.mdi-emoticon-poop::before{content:"\F1F7"}.mdi-emoticon-poop-outline::before{content:"\FC51"}.mdi-emoticon-sad::before{content:"\FC52"}.mdi-emoticon-sad-outline::before{content:"\F1F8"}.mdi-emoticon-tongue::before{content:"\F1F9"}.mdi-emoticon-tongue-outline::before{content:"\FC53"}.mdi-emoticon-wink::before{content:"\FC54"}.mdi-emoticon-wink-outline::before{content:"\FC55"}.mdi-engine::before{content:"\F1FA"}.mdi-engine-off::before{content:"\FA45"}.mdi-engine-off-outline::before{content:"\FA46"}.mdi-engine-outline::before{content:"\F1FB"}.mdi-epsilon::before{content:"\F010B"}.mdi-equal::before{content:"\F1FC"}.mdi-equal-box::before{content:"\F1FD"}.mdi-equalizer::before{content:"\FEBF"}.mdi-equalizer-outline::before{content:"\FEC0"}.mdi-eraser::before{content:"\F1FE"}.mdi-eraser-variant::before{content:"\F642"}.mdi-escalator::before{content:"\F1FF"}.mdi-escalator-down::before{content:"\F02EB"}.mdi-escalator-up::before{content:"\F02EA"}.mdi-eslint::before{content:"\FC56"}.mdi-et::before{content:"\FAB2"}.mdi-ethereum::before{content:"\F869"}.mdi-ethernet::before{content:"\F200"}.mdi-ethernet-cable::before{content:"\F201"}.mdi-ethernet-cable-off::before{content:"\F202"}.mdi-etsy::before{content:"\F203"}.mdi-ev-station::before{content:"\F5F1"}.mdi-eventbrite::before{content:"\F7C6"}.mdi-evernote::before{content:"\F204"}.mdi-excavator::before{content:"\F0047"}.mdi-exclamation::before{content:"\F205"}.mdi-exclamation-thick::before{content:"\F0263"}.mdi-exit-run::before{content:"\FA47"}.mdi-exit-to-app::before{content:"\F206"}.mdi-expand-all::before{content:"\FAB3"}.mdi-expand-all-outline::before{content:"\FAB4"}.mdi-expansion-card::before{content:"\F8AD"}.mdi-expansion-card-variant::before{content:"\FFD2"}.mdi-exponent::before{content:"\F962"}.mdi-exponent-box::before{content:"\F963"}.mdi-export::before{content:"\F207"}.mdi-export-variant::before{content:"\FB6F"}.mdi-eye::before{content:"\F208"}.mdi-eye-check::before{content:"\FCE0"}.mdi-eye-check-outline::before{content:"\FCE1"}.mdi-eye-circle::before{content:"\FB70"}.mdi-eye-circle-outline::before{content:"\FB71"}.mdi-eye-minus::before{content:"\F0048"}.mdi-eye-minus-outline::before{content:"\F0049"}.mdi-eye-off::before{content:"\F209"}.mdi-eye-off-outline::before{content:"\F6D0"}.mdi-eye-outline::before{content:"\F6CF"}.mdi-eye-plus::before{content:"\F86A"}.mdi-eye-plus-outline::before{content:"\F86B"}.mdi-eye-settings::before{content:"\F86C"}.mdi-eye-settings-outline::before{content:"\F86D"}.mdi-eyedropper::before{content:"\F20A"}.mdi-eyedropper-variant::before{content:"\F20B"}.mdi-face::before{content:"\F643"}.mdi-face-agent::before{content:"\FD4C"}.mdi-face-outline::before{content:"\FB72"}.mdi-face-profile::before{content:"\F644"}.mdi-face-profile-woman::before{content:"\F00A1"}.mdi-face-recognition::before{content:"\FC57"}.mdi-face-woman::before{content:"\F00A2"}.mdi-face-woman-outline::before{content:"\F00A3"}.mdi-facebook::before{content:"\F20C"}.mdi-facebook-box::before{content:"\F20D"}.mdi-facebook-messenger::before{content:"\F20E"}.mdi-facebook-workplace::before{content:"\FB16"}.mdi-factory::before{content:"\F20F"}.mdi-fan::before{content:"\F210"}.mdi-fan-off::before{content:"\F81C"}.mdi-fast-forward::before{content:"\F211"}.mdi-fast-forward-10::before{content:"\FD4D"}.mdi-fast-forward-30::before{content:"\FCE2"}.mdi-fast-forward-5::before{content:"\F0223"}.mdi-fast-forward-outline::before{content:"\F6D1"}.mdi-fax::before{content:"\F212"}.mdi-feather::before{content:"\F6D2"}.mdi-feature-search::before{content:"\FA48"}.mdi-feature-search-outline::before{content:"\FA49"}.mdi-fedora::before{content:"\F8DA"}.mdi-ferris-wheel::before{content:"\FEC1"}.mdi-ferry::before{content:"\F213"}.mdi-file::before{content:"\F214"}.mdi-file-account::before{content:"\F73A"}.mdi-file-account-outline::before{content:"\F004A"}.mdi-file-alert::before{content:"\FA4A"}.mdi-file-alert-outline::before{content:"\FA4B"}.mdi-file-cabinet::before{content:"\FAB5"}.mdi-file-cad::before{content:"\FF08"}.mdi-file-cad-box::before{content:"\FF09"}.mdi-file-cancel::before{content:"\FDA2"}.mdi-file-cancel-outline::before{content:"\FDA3"}.mdi-file-certificate::before{content:"\F01B1"}.mdi-file-certificate-outline::before{content:"\F01B2"}.mdi-file-chart::before{content:"\F215"}.mdi-file-chart-outline::before{content:"\F004B"}.mdi-file-check::before{content:"\F216"}.mdi-file-check-outline::before{content:"\FE7B"}.mdi-file-clock::before{content:"\F030C"}.mdi-file-clock-outline::before{content:"\F030D"}.mdi-file-cloud::before{content:"\F217"}.mdi-file-cloud-outline::before{content:"\F004C"}.mdi-file-code::before{content:"\F22E"}.mdi-file-code-outline::before{content:"\F004D"}.mdi-file-compare::before{content:"\F8A9"}.mdi-file-delimited::before{content:"\F218"}.mdi-file-delimited-outline::before{content:"\FEC2"}.mdi-file-document::before{content:"\F219"}.mdi-file-document-box::before{content:"\F21A"}.mdi-file-document-box-check::before{content:"\FEC3"}.mdi-file-document-box-check-outline::before{content:"\FEC4"}.mdi-file-document-box-minus::before{content:"\FEC5"}.mdi-file-document-box-minus-outline::before{content:"\FEC6"}.mdi-file-document-box-multiple::before{content:"\FAB6"}.mdi-file-document-box-multiple-outline::before{content:"\FAB7"}.mdi-file-document-box-outline::before{content:"\F9EC"}.mdi-file-document-box-plus::before{content:"\FEC7"}.mdi-file-document-box-plus-outline::before{content:"\FEC8"}.mdi-file-document-box-remove::before{content:"\FEC9"}.mdi-file-document-box-remove-outline::before{content:"\FECA"}.mdi-file-document-box-search::before{content:"\FECB"}.mdi-file-document-box-search-outline::before{content:"\FECC"}.mdi-file-document-edit::before{content:"\FDA4"}.mdi-file-document-edit-outline::before{content:"\FDA5"}.mdi-file-document-outline::before{content:"\F9ED"}.mdi-file-download::before{content:"\F964"}.mdi-file-download-outline::before{content:"\F965"}.mdi-file-edit::before{content:"\F0212"}.mdi-file-edit-outline::before{content:"\F0213"}.mdi-file-excel::before{content:"\F21B"}.mdi-file-excel-box::before{content:"\F21C"}.mdi-file-excel-box-outline::before{content:"\F004E"}.mdi-file-excel-outline::before{content:"\F004F"}.mdi-file-export::before{content:"\F21D"}.mdi-file-export-outline::before{content:"\F0050"}.mdi-file-eye::before{content:"\FDA6"}.mdi-file-eye-outline::before{content:"\FDA7"}.mdi-file-find::before{content:"\F21E"}.mdi-file-find-outline::before{content:"\FB73"}.mdi-file-hidden::before{content:"\F613"}.mdi-file-image::before{content:"\F21F"}.mdi-file-image-outline::before{content:"\FECD"}.mdi-file-import::before{content:"\F220"}.mdi-file-import-outline::before{content:"\F0051"}.mdi-file-key::before{content:"\F01AF"}.mdi-file-key-outline::before{content:"\F01B0"}.mdi-file-link::before{content:"\F01A2"}.mdi-file-link-outline::before{content:"\F01A3"}.mdi-file-lock::before{content:"\F221"}.mdi-file-lock-outline::before{content:"\F0052"}.mdi-file-move::before{content:"\FAB8"}.mdi-file-move-outline::before{content:"\F0053"}.mdi-file-multiple::before{content:"\F222"}.mdi-file-multiple-outline::before{content:"\F0054"}.mdi-file-music::before{content:"\F223"}.mdi-file-music-outline::before{content:"\FE7C"}.mdi-file-outline::before{content:"\F224"}.mdi-file-pdf::before{content:"\F225"}.mdi-file-pdf-box::before{content:"\F226"}.mdi-file-pdf-box-outline::before{content:"\FFD3"}.mdi-file-pdf-outline::before{content:"\FE7D"}.mdi-file-percent::before{content:"\F81D"}.mdi-file-percent-outline::before{content:"\F0055"}.mdi-file-phone::before{content:"\F01A4"}.mdi-file-phone-outline::before{content:"\F01A5"}.mdi-file-plus::before{content:"\F751"}.mdi-file-plus-outline::before{content:"\FF0A"}.mdi-file-powerpoint::before{content:"\F227"}.mdi-file-powerpoint-box::before{content:"\F228"}.mdi-file-powerpoint-box-outline::before{content:"\F0056"}.mdi-file-powerpoint-outline::before{content:"\F0057"}.mdi-file-presentation-box::before{content:"\F229"}.mdi-file-question::before{content:"\F86E"}.mdi-file-question-outline::before{content:"\F0058"}.mdi-file-remove::before{content:"\FB74"}.mdi-file-remove-outline::before{content:"\F0059"}.mdi-file-replace::before{content:"\FB17"}.mdi-file-replace-outline::before{content:"\FB18"}.mdi-file-restore::before{content:"\F670"}.mdi-file-restore-outline::before{content:"\F005A"}.mdi-file-search::before{content:"\FC58"}.mdi-file-search-outline::before{content:"\FC59"}.mdi-file-send::before{content:"\F22A"}.mdi-file-send-outline::before{content:"\F005B"}.mdi-file-settings::before{content:"\F00A4"}.mdi-file-settings-outline::before{content:"\F00A5"}.mdi-file-settings-variant::before{content:"\F00A6"}.mdi-file-settings-variant-outline::before{content:"\F00A7"}.mdi-file-star::before{content:"\F005C"}.mdi-file-star-outline::before{content:"\F005D"}.mdi-file-swap::before{content:"\FFD4"}.mdi-file-swap-outline::before{content:"\FFD5"}.mdi-file-sync::before{content:"\F0241"}.mdi-file-sync-outline::before{content:"\F0242"}.mdi-file-table::before{content:"\FC5A"}.mdi-file-table-box::before{content:"\F010C"}.mdi-file-table-box-multiple::before{content:"\F010D"}.mdi-file-table-box-multiple-outline::before{content:"\F010E"}.mdi-file-table-box-outline::before{content:"\F010F"}.mdi-file-table-outline::before{content:"\FC5B"}.mdi-file-tree::before{content:"\F645"}.mdi-file-undo::before{content:"\F8DB"}.mdi-file-undo-outline::before{content:"\F005E"}.mdi-file-upload::before{content:"\FA4C"}.mdi-file-upload-outline::before{content:"\FA4D"}.mdi-file-video::before{content:"\F22B"}.mdi-file-video-outline::before{content:"\FE10"}.mdi-file-word::before{content:"\F22C"}.mdi-file-word-box::before{content:"\F22D"}.mdi-file-word-box-outline::before{content:"\F005F"}.mdi-file-word-outline::before{content:"\F0060"}.mdi-film::before{content:"\F22F"}.mdi-filmstrip::before{content:"\F230"}.mdi-filmstrip-off::before{content:"\F231"}.mdi-filter::before{content:"\F232"}.mdi-filter-menu::before{content:"\F0110"}.mdi-filter-menu-outline::before{content:"\F0111"}.mdi-filter-minus::before{content:"\FF0B"}.mdi-filter-minus-outline::before{content:"\FF0C"}.mdi-filter-outline::before{content:"\F233"}.mdi-filter-plus::before{content:"\FF0D"}.mdi-filter-plus-outline::before{content:"\FF0E"}.mdi-filter-remove::before{content:"\F234"}.mdi-filter-remove-outline::before{content:"\F235"}.mdi-filter-variant::before{content:"\F236"}.mdi-filter-variant-minus::before{content:"\F013D"}.mdi-filter-variant-plus::before{content:"\F013E"}.mdi-filter-variant-remove::before{content:"\F0061"}.mdi-finance::before{content:"\F81E"}.mdi-find-replace::before{content:"\F6D3"}.mdi-fingerprint::before{content:"\F237"}.mdi-fingerprint-off::before{content:"\FECE"}.mdi-fire::before{content:"\F238"}.mdi-fire-extinguisher::before{content:"\FF0F"}.mdi-fire-hydrant::before{content:"\F0162"}.mdi-fire-hydrant-alert::before{content:"\F0163"}.mdi-fire-hydrant-off::before{content:"\F0164"}.mdi-fire-truck::before{content:"\F8AA"}.mdi-firebase::before{content:"\F966"}.mdi-firefox::before{content:"\F239"}.mdi-fireplace::before{content:"\FE11"}.mdi-fireplace-off::before{content:"\FE12"}.mdi-firework::before{content:"\FE13"}.mdi-fish::before{content:"\F23A"}.mdi-fishbowl::before{content:"\FF10"}.mdi-fishbowl-outline::before{content:"\FF11"}.mdi-fit-to-page::before{content:"\FF12"}.mdi-fit-to-page-outline::before{content:"\FF13"}.mdi-flag::before{content:"\F23B"}.mdi-flag-checkered::before{content:"\F23C"}.mdi-flag-minus::before{content:"\FB75"}.mdi-flag-minus-outline::before{content:"\F00DD"}.mdi-flag-outline::before{content:"\F23D"}.mdi-flag-plus::before{content:"\FB76"}.mdi-flag-plus-outline::before{content:"\F00DE"}.mdi-flag-remove::before{content:"\FB77"}.mdi-flag-remove-outline::before{content:"\F00DF"}.mdi-flag-triangle::before{content:"\F23F"}.mdi-flag-variant::before{content:"\F240"}.mdi-flag-variant-outline::before{content:"\F23E"}.mdi-flare::before{content:"\FD4E"}.mdi-flash::before{content:"\F241"}.mdi-flash-alert::before{content:"\FF14"}.mdi-flash-alert-outline::before{content:"\FF15"}.mdi-flash-auto::before{content:"\F242"}.mdi-flash-circle::before{content:"\F81F"}.mdi-flash-off::before{content:"\F243"}.mdi-flash-outline::before{content:"\F6D4"}.mdi-flash-red-eye::before{content:"\F67A"}.mdi-flashlight::before{content:"\F244"}.mdi-flashlight-off::before{content:"\F245"}.mdi-flask::before{content:"\F093"}.mdi-flask-empty::before{content:"\F094"}.mdi-flask-empty-minus::before{content:"\F0265"}.mdi-flask-empty-minus-outline::before{content:"\F0266"}.mdi-flask-empty-outline::before{content:"\F095"}.mdi-flask-empty-plus::before{content:"\F0267"}.mdi-flask-empty-plus-outline::before{content:"\F0268"}.mdi-flask-empty-remove::before{content:"\F0269"}.mdi-flask-empty-remove-outline::before{content:"\F026A"}.mdi-flask-minus::before{content:"\F026B"}.mdi-flask-minus-outline::before{content:"\F026C"}.mdi-flask-outline::before{content:"\F096"}.mdi-flask-plus::before{content:"\F026D"}.mdi-flask-plus-outline::before{content:"\F026E"}.mdi-flask-remove::before{content:"\F026F"}.mdi-flask-remove-outline::before{content:"\F0270"}.mdi-flask-round-bottom::before{content:"\F0276"}.mdi-flask-round-bottom-empty::before{content:"\F0277"}.mdi-flask-round-bottom-empty-outline::before{content:"\F0278"}.mdi-flask-round-bottom-outline::before{content:"\F0279"}.mdi-flattr::before{content:"\F246"}.mdi-fleur-de-lis::before{content:"\F032E"}.mdi-flickr::before{content:"\FCE3"}.mdi-flip-horizontal::before{content:"\F0112"}.mdi-flip-to-back::before{content:"\F247"}.mdi-flip-to-front::before{content:"\F248"}.mdi-flip-vertical::before{content:"\F0113"}.mdi-floor-lamp::before{content:"\F8DC"}.mdi-floor-lamp-dual::before{content:"\F0062"}.mdi-floor-lamp-variant::before{content:"\F0063"}.mdi-floor-plan::before{content:"\F820"}.mdi-floppy::before{content:"\F249"}.mdi-floppy-variant::before{content:"\F9EE"}.mdi-flower::before{content:"\F24A"}.mdi-flower-outline::before{content:"\F9EF"}.mdi-flower-poppy::before{content:"\FCE4"}.mdi-flower-tulip::before{content:"\F9F0"}.mdi-flower-tulip-outline::before{content:"\F9F1"}.mdi-focus-auto::before{content:"\FF6B"}.mdi-focus-field::before{content:"\FF6C"}.mdi-focus-field-horizontal::before{content:"\FF6D"}.mdi-focus-field-vertical::before{content:"\FF6E"}.mdi-folder::before{content:"\F24B"}.mdi-folder-account::before{content:"\F24C"}.mdi-folder-account-outline::before{content:"\FB78"}.mdi-folder-alert::before{content:"\FDA8"}.mdi-folder-alert-outline::before{content:"\FDA9"}.mdi-folder-clock::before{content:"\FAB9"}.mdi-folder-clock-outline::before{content:"\FABA"}.mdi-folder-download::before{content:"\F24D"}.mdi-folder-download-outline::before{content:"\F0114"}.mdi-folder-edit::before{content:"\F8DD"}.mdi-folder-edit-outline::before{content:"\FDAA"}.mdi-folder-google-drive::before{content:"\F24E"}.mdi-folder-heart::before{content:"\F0115"}.mdi-folder-heart-outline::before{content:"\F0116"}.mdi-folder-home::before{content:"\F00E0"}.mdi-folder-home-outline::before{content:"\F00E1"}.mdi-folder-image::before{content:"\F24F"}.mdi-folder-information::before{content:"\F00E2"}.mdi-folder-information-outline::before{content:"\F00E3"}.mdi-folder-key::before{content:"\F8AB"}.mdi-folder-key-network::before{content:"\F8AC"}.mdi-folder-key-network-outline::before{content:"\FC5C"}.mdi-folder-key-outline::before{content:"\F0117"}.mdi-folder-lock::before{content:"\F250"}.mdi-folder-lock-open::before{content:"\F251"}.mdi-folder-marker::before{content:"\F0298"}.mdi-folder-marker-outline::before{content:"\F0299"}.mdi-folder-move::before{content:"\F252"}.mdi-folder-move-outline::before{content:"\F0271"}.mdi-folder-multiple::before{content:"\F253"}.mdi-folder-multiple-image::before{content:"\F254"}.mdi-folder-multiple-outline::before{content:"\F255"}.mdi-folder-music::before{content:"\F0384"}.mdi-folder-music-outline::before{content:"\F0385"}.mdi-folder-network::before{content:"\F86F"}.mdi-folder-network-outline::before{content:"\FC5D"}.mdi-folder-open::before{content:"\F76F"}.mdi-folder-open-outline::before{content:"\FDAB"}.mdi-folder-outline::before{content:"\F256"}.mdi-folder-plus::before{content:"\F257"}.mdi-folder-plus-outline::before{content:"\FB79"}.mdi-folder-pound::before{content:"\FCE5"}.mdi-folder-pound-outline::before{content:"\FCE6"}.mdi-folder-remove::before{content:"\F258"}.mdi-folder-remove-outline::before{content:"\FB7A"}.mdi-folder-search::before{content:"\F967"}.mdi-folder-search-outline::before{content:"\F968"}.mdi-folder-settings::before{content:"\F00A8"}.mdi-folder-settings-outline::before{content:"\F00A9"}.mdi-folder-settings-variant::before{content:"\F00AA"}.mdi-folder-settings-variant-outline::before{content:"\F00AB"}.mdi-folder-star::before{content:"\F69C"}.mdi-folder-star-outline::before{content:"\FB7B"}.mdi-folder-swap::before{content:"\FFD6"}.mdi-folder-swap-outline::before{content:"\FFD7"}.mdi-folder-sync::before{content:"\FCE7"}.mdi-folder-sync-outline::before{content:"\FCE8"}.mdi-folder-table::before{content:"\F030E"}.mdi-folder-table-outline::before{content:"\F030F"}.mdi-folder-text::before{content:"\FC5E"}.mdi-folder-text-outline::before{content:"\FC5F"}.mdi-folder-upload::before{content:"\F259"}.mdi-folder-upload-outline::before{content:"\F0118"}.mdi-folder-zip::before{content:"\F6EA"}.mdi-folder-zip-outline::before{content:"\F7B8"}.mdi-font-awesome::before{content:"\F03A"}.mdi-food::before{content:"\F25A"}.mdi-food-apple::before{content:"\F25B"}.mdi-food-apple-outline::before{content:"\FC60"}.mdi-food-croissant::before{content:"\F7C7"}.mdi-food-fork-drink::before{content:"\F5F2"}.mdi-food-off::before{content:"\F5F3"}.mdi-food-variant::before{content:"\F25C"}.mdi-foot-print::before{content:"\FF6F"}.mdi-football::before{content:"\F25D"}.mdi-football-australian::before{content:"\F25E"}.mdi-football-helmet::before{content:"\F25F"}.mdi-forklift::before{content:"\F7C8"}.mdi-format-align-bottom::before{content:"\F752"}.mdi-format-align-center::before{content:"\F260"}.mdi-format-align-justify::before{content:"\F261"}.mdi-format-align-left::before{content:"\F262"}.mdi-format-align-middle::before{content:"\F753"}.mdi-format-align-right::before{content:"\F263"}.mdi-format-align-top::before{content:"\F754"}.mdi-format-annotation-minus::before{content:"\FABB"}.mdi-format-annotation-plus::before{content:"\F646"}.mdi-format-bold::before{content:"\F264"}.mdi-format-clear::before{content:"\F265"}.mdi-format-color-fill::before{content:"\F266"}.mdi-format-color-highlight::before{content:"\FE14"}.mdi-format-color-marker-cancel::before{content:"\F033E"}.mdi-format-color-text::before{content:"\F69D"}.mdi-format-columns::before{content:"\F8DE"}.mdi-format-float-center::before{content:"\F267"}.mdi-format-float-left::before{content:"\F268"}.mdi-format-float-none::before{content:"\F269"}.mdi-format-float-right::before{content:"\F26A"}.mdi-format-font::before{content:"\F6D5"}.mdi-format-font-size-decrease::before{content:"\F9F2"}.mdi-format-font-size-increase::before{content:"\F9F3"}.mdi-format-header-1::before{content:"\F26B"}.mdi-format-header-2::before{content:"\F26C"}.mdi-format-header-3::before{content:"\F26D"}.mdi-format-header-4::before{content:"\F26E"}.mdi-format-header-5::before{content:"\F26F"}.mdi-format-header-6::before{content:"\F270"}.mdi-format-header-decrease::before{content:"\F271"}.mdi-format-header-equal::before{content:"\F272"}.mdi-format-header-increase::before{content:"\F273"}.mdi-format-header-pound::before{content:"\F274"}.mdi-format-horizontal-align-center::before{content:"\F61E"}.mdi-format-horizontal-align-left::before{content:"\F61F"}.mdi-format-horizontal-align-right::before{content:"\F620"}.mdi-format-indent-decrease::before{content:"\F275"}.mdi-format-indent-increase::before{content:"\F276"}.mdi-format-italic::before{content:"\F277"}.mdi-format-letter-case::before{content:"\FB19"}.mdi-format-letter-case-lower::before{content:"\FB1A"}.mdi-format-letter-case-upper::before{content:"\FB1B"}.mdi-format-letter-ends-with::before{content:"\FFD8"}.mdi-format-letter-matches::before{content:"\FFD9"}.mdi-format-letter-starts-with::before{content:"\FFDA"}.mdi-format-line-spacing::before{content:"\F278"}.mdi-format-line-style::before{content:"\F5C8"}.mdi-format-line-weight::before{content:"\F5C9"}.mdi-format-list-bulleted::before{content:"\F279"}.mdi-format-list-bulleted-square::before{content:"\FDAC"}.mdi-format-list-bulleted-triangle::before{content:"\FECF"}.mdi-format-list-bulleted-type::before{content:"\F27A"}.mdi-format-list-checkbox::before{content:"\F969"}.mdi-format-list-checks::before{content:"\F755"}.mdi-format-list-numbered::before{content:"\F27B"}.mdi-format-list-numbered-rtl::before{content:"\FCE9"}.mdi-format-list-text::before{content:"\F029A"}.mdi-format-overline::before{content:"\FED0"}.mdi-format-page-break::before{content:"\F6D6"}.mdi-format-paint::before{content:"\F27C"}.mdi-format-paragraph::before{content:"\F27D"}.mdi-format-pilcrow::before{content:"\F6D7"}.mdi-format-quote-close::before{content:"\F27E"}.mdi-format-quote-close-outline::before{content:"\F01D3"}.mdi-format-quote-open::before{content:"\F756"}.mdi-format-quote-open-outline::before{content:"\F01D2"}.mdi-format-rotate-90::before{content:"\F6A9"}.mdi-format-section::before{content:"\F69E"}.mdi-format-size::before{content:"\F27F"}.mdi-format-strikethrough::before{content:"\F280"}.mdi-format-strikethrough-variant::before{content:"\F281"}.mdi-format-subscript::before{content:"\F282"}.mdi-format-superscript::before{content:"\F283"}.mdi-format-text::before{content:"\F284"}.mdi-format-text-rotation-angle-down::before{content:"\FFDB"}.mdi-format-text-rotation-angle-up::before{content:"\FFDC"}.mdi-format-text-rotation-down::before{content:"\FD4F"}.mdi-format-text-rotation-down-vertical::before{content:"\FFDD"}.mdi-format-text-rotation-none::before{content:"\FD50"}.mdi-format-text-rotation-up::before{content:"\FFDE"}.mdi-format-text-rotation-vertical::before{content:"\FFDF"}.mdi-format-text-variant::before{content:"\FE15"}.mdi-format-text-wrapping-clip::before{content:"\FCEA"}.mdi-format-text-wrapping-overflow::before{content:"\FCEB"}.mdi-format-text-wrapping-wrap::before{content:"\FCEC"}.mdi-format-textbox::before{content:"\FCED"}.mdi-format-textdirection-l-to-r::before{content:"\F285"}.mdi-format-textdirection-r-to-l::before{content:"\F286"}.mdi-format-title::before{content:"\F5F4"}.mdi-format-underline::before{content:"\F287"}.mdi-format-vertical-align-bottom::before{content:"\F621"}.mdi-format-vertical-align-center::before{content:"\F622"}.mdi-format-vertical-align-top::before{content:"\F623"}.mdi-format-wrap-inline::before{content:"\F288"}.mdi-format-wrap-square::before{content:"\F289"}.mdi-format-wrap-tight::before{content:"\F28A"}.mdi-format-wrap-top-bottom::before{content:"\F28B"}.mdi-forum::before{content:"\F28C"}.mdi-forum-outline::before{content:"\F821"}.mdi-forward::before{content:"\F28D"}.mdi-forwardburger::before{content:"\FD51"}.mdi-fountain::before{content:"\F96A"}.mdi-fountain-pen::before{content:"\FCEE"}.mdi-fountain-pen-tip::before{content:"\FCEF"}.mdi-foursquare::before{content:"\F28E"}.mdi-freebsd::before{content:"\F8DF"}.mdi-frequently-asked-questions::before{content:"\FED1"}.mdi-fridge::before{content:"\F290"}.mdi-fridge-alert::before{content:"\F01DC"}.mdi-fridge-alert-outline::before{content:"\F01DD"}.mdi-fridge-bottom::before{content:"\F292"}.mdi-fridge-off::before{content:"\F01DA"}.mdi-fridge-off-outline::before{content:"\F01DB"}.mdi-fridge-outline::before{content:"\F28F"}.mdi-fridge-top::before{content:"\F291"}.mdi-fruit-cherries::before{content:"\F0064"}.mdi-fruit-citrus::before{content:"\F0065"}.mdi-fruit-grapes::before{content:"\F0066"}.mdi-fruit-grapes-outline::before{content:"\F0067"}.mdi-fruit-pineapple::before{content:"\F0068"}.mdi-fruit-watermelon::before{content:"\F0069"}.mdi-fuel::before{content:"\F7C9"}.mdi-fullscreen::before{content:"\F293"}.mdi-fullscreen-exit::before{content:"\F294"}.mdi-function::before{content:"\F295"}.mdi-function-variant::before{content:"\F870"}.mdi-furigana-horizontal::before{content:"\F00AC"}.mdi-furigana-vertical::before{content:"\F00AD"}.mdi-fuse::before{content:"\FC61"}.mdi-fuse-blade::before{content:"\FC62"}.mdi-gamepad::before{content:"\F296"}.mdi-gamepad-circle::before{content:"\FE16"}.mdi-gamepad-circle-down::before{content:"\FE17"}.mdi-gamepad-circle-left::before{content:"\FE18"}.mdi-gamepad-circle-outline::before{content:"\FE19"}.mdi-gamepad-circle-right::before{content:"\FE1A"}.mdi-gamepad-circle-up::before{content:"\FE1B"}.mdi-gamepad-down::before{content:"\FE1C"}.mdi-gamepad-left::before{content:"\FE1D"}.mdi-gamepad-right::before{content:"\FE1E"}.mdi-gamepad-round::before{content:"\FE1F"}.mdi-gamepad-round-down::before{content:"\FE7E"}.mdi-gamepad-round-left::before{content:"\FE7F"}.mdi-gamepad-round-outline::before{content:"\FE80"}.mdi-gamepad-round-right::before{content:"\FE81"}.mdi-gamepad-round-up::before{content:"\FE82"}.mdi-gamepad-square::before{content:"\FED2"}.mdi-gamepad-square-outline::before{content:"\FED3"}.mdi-gamepad-up::before{content:"\FE83"}.mdi-gamepad-variant::before{content:"\F297"}.mdi-gamepad-variant-outline::before{content:"\FED4"}.mdi-gamma::before{content:"\F0119"}.mdi-gantry-crane::before{content:"\FDAD"}.mdi-garage::before{content:"\F6D8"}.mdi-garage-alert::before{content:"\F871"}.mdi-garage-alert-variant::before{content:"\F0300"}.mdi-garage-open::before{content:"\F6D9"}.mdi-garage-open-variant::before{content:"\F02FF"}.mdi-garage-variant::before{content:"\F02FE"}.mdi-gas-cylinder::before{content:"\F647"}.mdi-gas-station::before{content:"\F298"}.mdi-gas-station-outline::before{content:"\FED5"}.mdi-gate::before{content:"\F299"}.mdi-gate-and::before{content:"\F8E0"}.mdi-gate-arrow-right::before{content:"\F0194"}.mdi-gate-nand::before{content:"\F8E1"}.mdi-gate-nor::before{content:"\F8E2"}.mdi-gate-not::before{content:"\F8E3"}.mdi-gate-open::before{content:"\F0195"}.mdi-gate-or::before{content:"\F8E4"}.mdi-gate-xnor::before{content:"\F8E5"}.mdi-gate-xor::before{content:"\F8E6"}.mdi-gatsby::before{content:"\FE84"}.mdi-gauge::before{content:"\F29A"}.mdi-gauge-empty::before{content:"\F872"}.mdi-gauge-full::before{content:"\F873"}.mdi-gauge-low::before{content:"\F874"}.mdi-gavel::before{content:"\F29B"}.mdi-gender-female::before{content:"\F29C"}.mdi-gender-male::before{content:"\F29D"}.mdi-gender-male-female::before{content:"\F29E"}.mdi-gender-male-female-variant::before{content:"\F016A"}.mdi-gender-non-binary::before{content:"\F016B"}.mdi-gender-transgender::before{content:"\F29F"}.mdi-gentoo::before{content:"\F8E7"}.mdi-gesture::before{content:"\F7CA"}.mdi-gesture-double-tap::before{content:"\F73B"}.mdi-gesture-pinch::before{content:"\FABC"}.mdi-gesture-spread::before{content:"\FABD"}.mdi-gesture-swipe::before{content:"\FD52"}.mdi-gesture-swipe-down::before{content:"\F73C"}.mdi-gesture-swipe-horizontal::before{content:"\FABE"}.mdi-gesture-swipe-left::before{content:"\F73D"}.mdi-gesture-swipe-right::before{content:"\F73E"}.mdi-gesture-swipe-up::before{content:"\F73F"}.mdi-gesture-swipe-vertical::before{content:"\FABF"}.mdi-gesture-tap::before{content:"\F740"}.mdi-gesture-tap-box::before{content:"\F02D4"}.mdi-gesture-tap-button::before{content:"\F02D3"}.mdi-gesture-tap-hold::before{content:"\FD53"}.mdi-gesture-two-double-tap::before{content:"\F741"}.mdi-gesture-two-tap::before{content:"\F742"}.mdi-ghost::before{content:"\F2A0"}.mdi-ghost-off::before{content:"\F9F4"}.mdi-gif::before{content:"\FD54"}.mdi-gift::before{content:"\FE85"}.mdi-gift-outline::before{content:"\F2A1"}.mdi-git::before{content:"\F2A2"}.mdi-github-box::before{content:"\F2A3"}.mdi-github-circle::before{content:"\F2A4"}.mdi-github-face::before{content:"\F6DA"}.mdi-gitlab::before{content:"\FB7C"}.mdi-glass-cocktail::before{content:"\F356"}.mdi-glass-flute::before{content:"\F2A5"}.mdi-glass-mug::before{content:"\F2A6"}.mdi-glass-mug-variant::before{content:"\F0141"}.mdi-glass-pint-outline::before{content:"\F0338"}.mdi-glass-stange::before{content:"\F2A7"}.mdi-glass-tulip::before{content:"\F2A8"}.mdi-glass-wine::before{content:"\F875"}.mdi-glassdoor::before{content:"\F2A9"}.mdi-glasses::before{content:"\F2AA"}.mdi-globe-light::before{content:"\F0302"}.mdi-globe-model::before{content:"\F8E8"}.mdi-gmail::before{content:"\F2AB"}.mdi-gnome::before{content:"\F2AC"}.mdi-go-kart::before{content:"\FD55"}.mdi-go-kart-track::before{content:"\FD56"}.mdi-gog::before{content:"\FB7D"}.mdi-gold::before{content:"\F027A"}.mdi-golf::before{content:"\F822"}.mdi-golf-cart::before{content:"\F01CF"}.mdi-golf-tee::before{content:"\F00AE"}.mdi-gondola::before{content:"\F685"}.mdi-goodreads::before{content:"\FD57"}.mdi-google::before{content:"\F2AD"}.mdi-google-adwords::before{content:"\FC63"}.mdi-google-analytics::before{content:"\F7CB"}.mdi-google-assistant::before{content:"\F7CC"}.mdi-google-cardboard::before{content:"\F2AE"}.mdi-google-chrome::before{content:"\F2AF"}.mdi-google-circles::before{content:"\F2B0"}.mdi-google-circles-communities::before{content:"\F2B1"}.mdi-google-circles-extended::before{content:"\F2B2"}.mdi-google-circles-group::before{content:"\F2B3"}.mdi-google-classroom::before{content:"\F2C0"}.mdi-google-cloud::before{content:"\F0221"}.mdi-google-controller::before{content:"\F2B4"}.mdi-google-controller-off::before{content:"\F2B5"}.mdi-google-downasaur::before{content:"\F038D"}.mdi-google-drive::before{content:"\F2B6"}.mdi-google-earth::before{content:"\F2B7"}.mdi-google-fit::before{content:"\F96B"}.mdi-google-glass::before{content:"\F2B8"}.mdi-google-hangouts::before{content:"\F2C9"}.mdi-google-home::before{content:"\F823"}.mdi-google-keep::before{content:"\F6DB"}.mdi-google-lens::before{content:"\F9F5"}.mdi-google-maps::before{content:"\F5F5"}.mdi-google-my-business::before{content:"\F006A"}.mdi-google-nearby::before{content:"\F2B9"}.mdi-google-pages::before{content:"\F2BA"}.mdi-google-photos::before{content:"\F6DC"}.mdi-google-physical-web::before{content:"\F2BB"}.mdi-google-play::before{content:"\F2BC"}.mdi-google-plus::before{content:"\F2BD"}.mdi-google-plus-box::before{content:"\F2BE"}.mdi-google-podcast::before{content:"\FED6"}.mdi-google-spreadsheet::before{content:"\F9F6"}.mdi-google-street-view::before{content:"\FC64"}.mdi-google-translate::before{content:"\F2BF"}.mdi-gradient::before{content:"\F69F"}.mdi-grain::before{content:"\FD58"}.mdi-graph::before{content:"\F006B"}.mdi-graph-outline::before{content:"\F006C"}.mdi-graphql::before{content:"\F876"}.mdi-grave-stone::before{content:"\FB7E"}.mdi-grease-pencil::before{content:"\F648"}.mdi-greater-than::before{content:"\F96C"}.mdi-greater-than-or-equal::before{content:"\F96D"}.mdi-grid::before{content:"\F2C1"}.mdi-grid-large::before{content:"\F757"}.mdi-grid-off::before{content:"\F2C2"}.mdi-grill::before{content:"\FE86"}.mdi-grill-outline::before{content:"\F01B5"}.mdi-group::before{content:"\F2C3"}.mdi-guitar-acoustic::before{content:"\F770"}.mdi-guitar-electric::before{content:"\F2C4"}.mdi-guitar-pick::before{content:"\F2C5"}.mdi-guitar-pick-outline::before{content:"\F2C6"}.mdi-guy-fawkes-mask::before{content:"\F824"}.mdi-hackernews::before{content:"\F624"}.mdi-hail::before{content:"\FAC0"}.mdi-hair-dryer::before{content:"\F011A"}.mdi-hair-dryer-outline::before{content:"\F011B"}.mdi-halloween::before{content:"\FB7F"}.mdi-hamburger::before{content:"\F684"}.mdi-hammer::before{content:"\F8E9"}.mdi-hammer-screwdriver::before{content:"\F034D"}.mdi-hammer-wrench::before{content:"\F034E"}.mdi-hand::before{content:"\FA4E"}.mdi-hand-heart::before{content:"\F011C"}.mdi-hand-left::before{content:"\FE87"}.mdi-hand-okay::before{content:"\FA4F"}.mdi-hand-peace::before{content:"\FA50"}.mdi-hand-peace-variant::before{content:"\FA51"}.mdi-hand-pointing-down::before{content:"\FA52"}.mdi-hand-pointing-left::before{content:"\FA53"}.mdi-hand-pointing-right::before{content:"\F2C7"}.mdi-hand-pointing-up::before{content:"\FA54"}.mdi-hand-right::before{content:"\FE88"}.mdi-hand-saw::before{content:"\FE89"}.mdi-handball::before{content:"\FF70"}.mdi-handcuffs::before{content:"\F0169"}.mdi-handshake::before{content:"\F0243"}.mdi-hanger::before{content:"\F2C8"}.mdi-hard-hat::before{content:"\F96E"}.mdi-harddisk::before{content:"\F2CA"}.mdi-harddisk-plus::before{content:"\F006D"}.mdi-harddisk-remove::before{content:"\F006E"}.mdi-hat-fedora::before{content:"\FB80"}.mdi-hazard-lights::before{content:"\FC65"}.mdi-hdr::before{content:"\FD59"}.mdi-hdr-off::before{content:"\FD5A"}.mdi-head::before{content:"\F0389"}.mdi-head-alert::before{content:"\F0363"}.mdi-head-alert-outline::before{content:"\F0364"}.mdi-head-check::before{content:"\F0365"}.mdi-head-check-outline::before{content:"\F0366"}.mdi-head-cog::before{content:"\F0367"}.mdi-head-cog-outline::before{content:"\F0368"}.mdi-head-dots-horizontal::before{content:"\F0369"}.mdi-head-dots-horizontal-outline::before{content:"\F036A"}.mdi-head-flash::before{content:"\F036B"}.mdi-head-flash-outline::before{content:"\F036C"}.mdi-head-heart::before{content:"\F036D"}.mdi-head-heart-outline::before{content:"\F036E"}.mdi-head-lightbulb::before{content:"\F036F"}.mdi-head-lightbulb-outline::before{content:"\F0370"}.mdi-head-minus::before{content:"\F0371"}.mdi-head-minus-outline::before{content:"\F0372"}.mdi-head-outline::before{content:"\F038A"}.mdi-head-plus::before{content:"\F0373"}.mdi-head-plus-outline::before{content:"\F0374"}.mdi-head-question::before{content:"\F0375"}.mdi-head-question-outline::before{content:"\F0376"}.mdi-head-remove::before{content:"\F0377"}.mdi-head-remove-outline::before{content:"\F0378"}.mdi-head-snowflake::before{content:"\F0379"}.mdi-head-snowflake-outline::before{content:"\F037A"}.mdi-head-sync::before{content:"\F037B"}.mdi-head-sync-outline::before{content:"\F037C"}.mdi-headphones::before{content:"\F2CB"}.mdi-headphones-bluetooth::before{content:"\F96F"}.mdi-headphones-box::before{content:"\F2CC"}.mdi-headphones-off::before{content:"\F7CD"}.mdi-headphones-settings::before{content:"\F2CD"}.mdi-headset::before{content:"\F2CE"}.mdi-headset-dock::before{content:"\F2CF"}.mdi-headset-off::before{content:"\F2D0"}.mdi-heart::before{content:"\F2D1"}.mdi-heart-box::before{content:"\F2D2"}.mdi-heart-box-outline::before{content:"\F2D3"}.mdi-heart-broken::before{content:"\F2D4"}.mdi-heart-broken-outline::before{content:"\FCF0"}.mdi-heart-circle::before{content:"\F970"}.mdi-heart-circle-outline::before{content:"\F971"}.mdi-heart-flash::before{content:"\FF16"}.mdi-heart-half::before{content:"\F6DE"}.mdi-heart-half-full::before{content:"\F6DD"}.mdi-heart-half-outline::before{content:"\F6DF"}.mdi-heart-multiple::before{content:"\FA55"}.mdi-heart-multiple-outline::before{content:"\FA56"}.mdi-heart-off::before{content:"\F758"}.mdi-heart-outline::before{content:"\F2D5"}.mdi-heart-pulse::before{content:"\F5F6"}.mdi-helicopter::before{content:"\FAC1"}.mdi-help::before{content:"\F2D6"}.mdi-help-box::before{content:"\F78A"}.mdi-help-circle::before{content:"\F2D7"}.mdi-help-circle-outline::before{content:"\F625"}.mdi-help-network::before{content:"\F6F4"}.mdi-help-network-outline::before{content:"\FC66"}.mdi-help-rhombus::before{content:"\FB81"}.mdi-help-rhombus-outline::before{content:"\FB82"}.mdi-hexadecimal::before{content:"\F02D2"}.mdi-hexagon::before{content:"\F2D8"}.mdi-hexagon-multiple::before{content:"\F6E0"}.mdi-hexagon-multiple-outline::before{content:"\F011D"}.mdi-hexagon-outline::before{content:"\F2D9"}.mdi-hexagon-slice-1::before{content:"\FAC2"}.mdi-hexagon-slice-2::before{content:"\FAC3"}.mdi-hexagon-slice-3::before{content:"\FAC4"}.mdi-hexagon-slice-4::before{content:"\FAC5"}.mdi-hexagon-slice-5::before{content:"\FAC6"}.mdi-hexagon-slice-6::before{content:"\FAC7"}.mdi-hexagram::before{content:"\FAC8"}.mdi-hexagram-outline::before{content:"\FAC9"}.mdi-high-definition::before{content:"\F7CE"}.mdi-high-definition-box::before{content:"\F877"}.mdi-highway::before{content:"\F5F7"}.mdi-hiking::before{content:"\FD5B"}.mdi-hinduism::before{content:"\F972"}.mdi-history::before{content:"\F2DA"}.mdi-hockey-puck::before{content:"\F878"}.mdi-hockey-sticks::before{content:"\F879"}.mdi-hololens::before{content:"\F2DB"}.mdi-home::before{content:"\F2DC"}.mdi-home-account::before{content:"\F825"}.mdi-home-alert::before{content:"\F87A"}.mdi-home-analytics::before{content:"\FED7"}.mdi-home-assistant::before{content:"\F7CF"}.mdi-home-automation::before{content:"\F7D0"}.mdi-home-circle::before{content:"\F7D1"}.mdi-home-circle-outline::before{content:"\F006F"}.mdi-home-city::before{content:"\FCF1"}.mdi-home-city-outline::before{content:"\FCF2"}.mdi-home-currency-usd::before{content:"\F8AE"}.mdi-home-edit::before{content:"\F0184"}.mdi-home-edit-outline::before{content:"\F0185"}.mdi-home-export-outline::before{content:"\FFB8"}.mdi-home-flood::before{content:"\FF17"}.mdi-home-floor-0::before{content:"\FDAE"}.mdi-home-floor-1::before{content:"\FD5C"}.mdi-home-floor-2::before{content:"\FD5D"}.mdi-home-floor-3::before{content:"\FD5E"}.mdi-home-floor-a::before{content:"\FD5F"}.mdi-home-floor-b::before{content:"\FD60"}.mdi-home-floor-g::before{content:"\FD61"}.mdi-home-floor-l::before{content:"\FD62"}.mdi-home-floor-negative-1::before{content:"\FDAF"}.mdi-home-group::before{content:"\FDB0"}.mdi-home-heart::before{content:"\F826"}.mdi-home-import-outline::before{content:"\FFB9"}.mdi-home-lightbulb::before{content:"\F027C"}.mdi-home-lightbulb-outline::before{content:"\F027D"}.mdi-home-lock::before{content:"\F8EA"}.mdi-home-lock-open::before{content:"\F8EB"}.mdi-home-map-marker::before{content:"\F5F8"}.mdi-home-minus::before{content:"\F973"}.mdi-home-modern::before{content:"\F2DD"}.mdi-home-outline::before{content:"\F6A0"}.mdi-home-plus::before{content:"\F974"}.mdi-home-remove::before{content:"\F0272"}.mdi-home-roof::before{content:"\F0156"}.mdi-home-thermometer::before{content:"\FF71"}.mdi-home-thermometer-outline::before{content:"\FF72"}.mdi-home-variant::before{content:"\F2DE"}.mdi-home-variant-outline::before{content:"\FB83"}.mdi-hook::before{content:"\F6E1"}.mdi-hook-off::before{content:"\F6E2"}.mdi-hops::before{content:"\F2DF"}.mdi-horizontal-rotate-clockwise::before{content:"\F011E"}.mdi-horizontal-rotate-counterclockwise::before{content:"\F011F"}.mdi-horseshoe::before{content:"\FA57"}.mdi-hospital::before{content:"\F0017"}.mdi-hospital-box::before{content:"\F2E0"}.mdi-hospital-box-outline::before{content:"\F0018"}.mdi-hospital-building::before{content:"\F2E1"}.mdi-hospital-marker::before{content:"\F2E2"}.mdi-hot-tub::before{content:"\F827"}.mdi-hotel::before{content:"\F2E3"}.mdi-houzz::before{content:"\F2E4"}.mdi-houzz-box::before{content:"\F2E5"}.mdi-hubspot::before{content:"\FCF3"}.mdi-hulu::before{content:"\F828"}.mdi-human::before{content:"\F2E6"}.mdi-human-child::before{content:"\F2E7"}.mdi-human-female::before{content:"\F649"}.mdi-human-female-boy::before{content:"\FA58"}.mdi-human-female-female::before{content:"\FA59"}.mdi-human-female-girl::before{content:"\FA5A"}.mdi-human-greeting::before{content:"\F64A"}.mdi-human-handsdown::before{content:"\F64B"}.mdi-human-handsup::before{content:"\F64C"}.mdi-human-male::before{content:"\F64D"}.mdi-human-male-boy::before{content:"\FA5B"}.mdi-human-male-female::before{content:"\F2E8"}.mdi-human-male-girl::before{content:"\FA5C"}.mdi-human-male-height::before{content:"\FF18"}.mdi-human-male-height-variant::before{content:"\FF19"}.mdi-human-male-male::before{content:"\FA5D"}.mdi-human-pregnant::before{content:"\F5CF"}.mdi-humble-bundle::before{content:"\F743"}.mdi-hvac::before{content:"\F037D"}.mdi-hydraulic-oil-level::before{content:"\F034F"}.mdi-hydraulic-oil-temperature::before{content:"\F0350"}.mdi-hydro-power::before{content:"\F0310"}.mdi-ice-cream::before{content:"\F829"}.mdi-ice-pop::before{content:"\FF1A"}.mdi-id-card::before{content:"\FFE0"}.mdi-identifier::before{content:"\FF1B"}.mdi-ideogram-cjk::before{content:"\F035C"}.mdi-ideogram-cjk-variant::before{content:"\F035D"}.mdi-iframe::before{content:"\FC67"}.mdi-iframe-array::before{content:"\F0120"}.mdi-iframe-array-outline::before{content:"\F0121"}.mdi-iframe-braces::before{content:"\F0122"}.mdi-iframe-braces-outline::before{content:"\F0123"}.mdi-iframe-outline::before{content:"\FC68"}.mdi-iframe-parentheses::before{content:"\F0124"}.mdi-iframe-parentheses-outline::before{content:"\F0125"}.mdi-iframe-variable::before{content:"\F0126"}.mdi-iframe-variable-outline::before{content:"\F0127"}.mdi-image::before{content:"\F2E9"}.mdi-image-album::before{content:"\F2EA"}.mdi-image-area::before{content:"\F2EB"}.mdi-image-area-close::before{content:"\F2EC"}.mdi-image-auto-adjust::before{content:"\FFE1"}.mdi-image-broken::before{content:"\F2ED"}.mdi-image-broken-variant::before{content:"\F2EE"}.mdi-image-edit::before{content:"\F020E"}.mdi-image-edit-outline::before{content:"\F020F"}.mdi-image-filter::before{content:"\F2EF"}.mdi-image-filter-black-white::before{content:"\F2F0"}.mdi-image-filter-center-focus::before{content:"\F2F1"}.mdi-image-filter-center-focus-strong::before{content:"\FF1C"}.mdi-image-filter-center-focus-strong-outline::before{content:"\FF1D"}.mdi-image-filter-center-focus-weak::before{content:"\F2F2"}.mdi-image-filter-drama::before{content:"\F2F3"}.mdi-image-filter-frames::before{content:"\F2F4"}.mdi-image-filter-hdr::before{content:"\F2F5"}.mdi-image-filter-none::before{content:"\F2F6"}.mdi-image-filter-tilt-shift::before{content:"\F2F7"}.mdi-image-filter-vintage::before{content:"\F2F8"}.mdi-image-frame::before{content:"\FE8A"}.mdi-image-move::before{content:"\F9F7"}.mdi-image-multiple::before{content:"\F2F9"}.mdi-image-off::before{content:"\F82A"}.mdi-image-off-outline::before{content:"\F01FC"}.mdi-image-outline::before{content:"\F975"}.mdi-image-plus::before{content:"\F87B"}.mdi-image-search::before{content:"\F976"}.mdi-image-search-outline::before{content:"\F977"}.mdi-image-size-select-actual::before{content:"\FC69"}.mdi-image-size-select-large::before{content:"\FC6A"}.mdi-image-size-select-small::before{content:"\FC6B"}.mdi-import::before{content:"\F2FA"}.mdi-inbox::before{content:"\F686"}.mdi-inbox-arrow-down::before{content:"\F2FB"}.mdi-inbox-arrow-down-outline::before{content:"\F029B"}.mdi-inbox-arrow-up::before{content:"\F3D1"}.mdi-inbox-arrow-up-outline::before{content:"\F029C"}.mdi-inbox-full::before{content:"\F029D"}.mdi-inbox-full-outline::before{content:"\F029E"}.mdi-inbox-multiple::before{content:"\F8AF"}.mdi-inbox-multiple-outline::before{content:"\FB84"}.mdi-inbox-outline::before{content:"\F029F"}.mdi-incognito::before{content:"\F5F9"}.mdi-infinity::before{content:"\F6E3"}.mdi-information::before{content:"\F2FC"}.mdi-information-outline::before{content:"\F2FD"}.mdi-information-variant::before{content:"\F64E"}.mdi-instagram::before{content:"\F2FE"}.mdi-instapaper::before{content:"\F2FF"}.mdi-instrument-triangle::before{content:"\F0070"}.mdi-internet-explorer::before{content:"\F300"}.mdi-invert-colors::before{content:"\F301"}.mdi-invert-colors-off::before{content:"\FE8B"}.mdi-iobroker::before{content:"\F0313"}.mdi-ip::before{content:"\FA5E"}.mdi-ip-network::before{content:"\FA5F"}.mdi-ip-network-outline::before{content:"\FC6C"}.mdi-ipod::before{content:"\FC6D"}.mdi-islam::before{content:"\F978"}.mdi-island::before{content:"\F0071"}.mdi-itunes::before{content:"\F676"}.mdi-iv-bag::before{content:"\F00E4"}.mdi-jabber::before{content:"\FDB1"}.mdi-jeepney::before{content:"\F302"}.mdi-jellyfish::before{content:"\FF1E"}.mdi-jellyfish-outline::before{content:"\FF1F"}.mdi-jira::before{content:"\F303"}.mdi-jquery::before{content:"\F87C"}.mdi-jsfiddle::before{content:"\F304"}.mdi-json::before{content:"\F626"}.mdi-judaism::before{content:"\F979"}.mdi-jump-rope::before{content:"\F032A"}.mdi-kabaddi::before{content:"\FD63"}.mdi-karate::before{content:"\F82B"}.mdi-keg::before{content:"\F305"}.mdi-kettle::before{content:"\F5FA"}.mdi-kettle-alert::before{content:"\F0342"}.mdi-kettle-alert-outline::before{content:"\F0343"}.mdi-kettle-off::before{content:"\F0346"}.mdi-kettle-off-outline::before{content:"\F0347"}.mdi-kettle-outline::before{content:"\FF73"}.mdi-kettle-steam::before{content:"\F0344"}.mdi-kettle-steam-outline::before{content:"\F0345"}.mdi-kettlebell::before{content:"\F032B"}.mdi-key::before{content:"\F306"}.mdi-key-arrow-right::before{content:"\F033D"}.mdi-key-change::before{content:"\F307"}.mdi-key-link::before{content:"\F01CA"}.mdi-key-minus::before{content:"\F308"}.mdi-key-outline::before{content:"\FDB2"}.mdi-key-plus::before{content:"\F309"}.mdi-key-remove::before{content:"\F30A"}.mdi-key-star::before{content:"\F01C9"}.mdi-key-variant::before{content:"\F30B"}.mdi-key-wireless::before{content:"\FFE2"}.mdi-keyboard::before{content:"\F30C"}.mdi-keyboard-backspace::before{content:"\F30D"}.mdi-keyboard-caps::before{content:"\F30E"}.mdi-keyboard-close::before{content:"\F30F"}.mdi-keyboard-esc::before{content:"\F02E2"}.mdi-keyboard-f1::before{content:"\F02D6"}.mdi-keyboard-f10::before{content:"\F02DF"}.mdi-keyboard-f11::before{content:"\F02E0"}.mdi-keyboard-f12::before{content:"\F02E1"}.mdi-keyboard-f2::before{content:"\F02D7"}.mdi-keyboard-f3::before{content:"\F02D8"}.mdi-keyboard-f4::before{content:"\F02D9"}.mdi-keyboard-f5::before{content:"\F02DA"}.mdi-keyboard-f6::before{content:"\F02DB"}.mdi-keyboard-f7::before{content:"\F02DC"}.mdi-keyboard-f8::before{content:"\F02DD"}.mdi-keyboard-f9::before{content:"\F02DE"}.mdi-keyboard-off::before{content:"\F310"}.mdi-keyboard-off-outline::before{content:"\FE8C"}.mdi-keyboard-outline::before{content:"\F97A"}.mdi-keyboard-return::before{content:"\F311"}.mdi-keyboard-settings::before{content:"\F9F8"}.mdi-keyboard-settings-outline::before{content:"\F9F9"}.mdi-keyboard-space::before{content:"\F0072"}.mdi-keyboard-tab::before{content:"\F312"}.mdi-keyboard-variant::before{content:"\F313"}.mdi-khanda::before{content:"\F0128"}.mdi-kickstarter::before{content:"\F744"}.mdi-klingon::before{content:"\F0386"}.mdi-knife::before{content:"\F9FA"}.mdi-knife-military::before{content:"\F9FB"}.mdi-kodi::before{content:"\F314"}.mdi-kotlin::before{content:"\F0244"}.mdi-kubernetes::before{content:"\F0129"}.mdi-label::before{content:"\F315"}.mdi-label-multiple::before{content:"\F03A0"}.mdi-label-multiple-outline::before{content:"\F03A1"}.mdi-label-off::before{content:"\FACA"}.mdi-label-off-outline::before{content:"\FACB"}.mdi-label-outline::before{content:"\F316"}.mdi-label-percent::before{content:"\F0315"}.mdi-label-percent-outline::before{content:"\F0316"}.mdi-label-variant::before{content:"\FACC"}.mdi-label-variant-outline::before{content:"\FACD"}.mdi-ladybug::before{content:"\F82C"}.mdi-lambda::before{content:"\F627"}.mdi-lamp::before{content:"\F6B4"}.mdi-lan::before{content:"\F317"}.mdi-lan-check::before{content:"\F02D5"}.mdi-lan-connect::before{content:"\F318"}.mdi-lan-disconnect::before{content:"\F319"}.mdi-lan-pending::before{content:"\F31A"}.mdi-language-c::before{content:"\F671"}.mdi-language-cpp::before{content:"\F672"}.mdi-language-csharp::before{content:"\F31B"}.mdi-language-css3::before{content:"\F31C"}.mdi-language-fortran::before{content:"\F0245"}.mdi-language-go::before{content:"\F7D2"}.mdi-language-haskell::before{content:"\FC6E"}.mdi-language-html5::before{content:"\F31D"}.mdi-language-java::before{content:"\FB1C"}.mdi-language-javascript::before{content:"\F31E"}.mdi-language-lua::before{content:"\F8B0"}.mdi-language-php::before{content:"\F31F"}.mdi-language-python::before{content:"\F320"}.mdi-language-python-text::before{content:"\F321"}.mdi-language-r::before{content:"\F7D3"}.mdi-language-ruby-on-rails::before{content:"\FACE"}.mdi-language-swift::before{content:"\F6E4"}.mdi-language-typescript::before{content:"\F6E5"}.mdi-laptop::before{content:"\F322"}.mdi-laptop-chromebook::before{content:"\F323"}.mdi-laptop-mac::before{content:"\F324"}.mdi-laptop-off::before{content:"\F6E6"}.mdi-laptop-windows::before{content:"\F325"}.mdi-laravel::before{content:"\FACF"}.mdi-lasso::before{content:"\FF20"}.mdi-lastfm::before{content:"\F326"}.mdi-lastpass::before{content:"\F446"}.mdi-latitude::before{content:"\FF74"}.mdi-launch::before{content:"\F327"}.mdi-lava-lamp::before{content:"\F7D4"}.mdi-layers::before{content:"\F328"}.mdi-layers-minus::before{content:"\FE8D"}.mdi-layers-off::before{content:"\F329"}.mdi-layers-off-outline::before{content:"\F9FC"}.mdi-layers-outline::before{content:"\F9FD"}.mdi-layers-plus::before{content:"\FE30"}.mdi-layers-remove::before{content:"\FE31"}.mdi-layers-search::before{content:"\F0231"}.mdi-layers-search-outline::before{content:"\F0232"}.mdi-layers-triple::before{content:"\FF75"}.mdi-layers-triple-outline::before{content:"\FF76"}.mdi-lead-pencil::before{content:"\F64F"}.mdi-leaf::before{content:"\F32A"}.mdi-leaf-maple::before{content:"\FC6F"}.mdi-leaf-maple-off::before{content:"\F0305"}.mdi-leaf-off::before{content:"\F0304"}.mdi-leak::before{content:"\FDB3"}.mdi-leak-off::before{content:"\FDB4"}.mdi-led-off::before{content:"\F32B"}.mdi-led-on::before{content:"\F32C"}.mdi-led-outline::before{content:"\F32D"}.mdi-led-strip::before{content:"\F7D5"}.mdi-led-strip-variant::before{content:"\F0073"}.mdi-led-variant-off::before{content:"\F32E"}.mdi-led-variant-on::before{content:"\F32F"}.mdi-led-variant-outline::before{content:"\F330"}.mdi-leek::before{content:"\F01A8"}.mdi-less-than::before{content:"\F97B"}.mdi-less-than-or-equal::before{content:"\F97C"}.mdi-library::before{content:"\F331"}.mdi-library-books::before{content:"\F332"}.mdi-library-movie::before{content:"\FCF4"}.mdi-library-music::before{content:"\F333"}.mdi-library-music-outline::before{content:"\FF21"}.mdi-library-shelves::before{content:"\FB85"}.mdi-library-video::before{content:"\FCF5"}.mdi-license::before{content:"\FFE3"}.mdi-lifebuoy::before{content:"\F87D"}.mdi-light-switch::before{content:"\F97D"}.mdi-lightbulb::before{content:"\F335"}.mdi-lightbulb-cfl::before{content:"\F0233"}.mdi-lightbulb-cfl-off::before{content:"\F0234"}.mdi-lightbulb-cfl-spiral::before{content:"\F02A0"}.mdi-lightbulb-cfl-spiral-off::before{content:"\F02EE"}.mdi-lightbulb-group::before{content:"\F027E"}.mdi-lightbulb-group-off::before{content:"\F02F8"}.mdi-lightbulb-group-off-outline::before{content:"\F02F9"}.mdi-lightbulb-group-outline::before{content:"\F027F"}.mdi-lightbulb-multiple::before{content:"\F0280"}.mdi-lightbulb-multiple-off::before{content:"\F02FA"}.mdi-lightbulb-multiple-off-outline::before{content:"\F02FB"}.mdi-lightbulb-multiple-outline::before{content:"\F0281"}.mdi-lightbulb-off::before{content:"\FE32"}.mdi-lightbulb-off-outline::before{content:"\FE33"}.mdi-lightbulb-on::before{content:"\F6E7"}.mdi-lightbulb-on-outline::before{content:"\F6E8"}.mdi-lightbulb-outline::before{content:"\F336"}.mdi-lighthouse::before{content:"\F9FE"}.mdi-lighthouse-on::before{content:"\F9FF"}.mdi-link::before{content:"\F337"}.mdi-link-box::before{content:"\FCF6"}.mdi-link-box-outline::before{content:"\FCF7"}.mdi-link-box-variant::before{content:"\FCF8"}.mdi-link-box-variant-outline::before{content:"\FCF9"}.mdi-link-lock::before{content:"\F00E5"}.mdi-link-off::before{content:"\F338"}.mdi-link-plus::before{content:"\FC70"}.mdi-link-variant::before{content:"\F339"}.mdi-link-variant-minus::before{content:"\F012A"}.mdi-link-variant-off::before{content:"\F33A"}.mdi-link-variant-plus::before{content:"\F012B"}.mdi-link-variant-remove::before{content:"\F012C"}.mdi-linkedin::before{content:"\F33B"}.mdi-linkedin-box::before{content:"\F33C"}.mdi-linux::before{content:"\F33D"}.mdi-linux-mint::before{content:"\F8EC"}.mdi-litecoin::before{content:"\FA60"}.mdi-loading::before{content:"\F771"}.mdi-location-enter::before{content:"\FFE4"}.mdi-location-exit::before{content:"\FFE5"}.mdi-lock::before{content:"\F33E"}.mdi-lock-alert::before{content:"\F8ED"}.mdi-lock-clock::before{content:"\F97E"}.mdi-lock-open::before{content:"\F33F"}.mdi-lock-open-outline::before{content:"\F340"}.mdi-lock-open-variant::before{content:"\FFE6"}.mdi-lock-open-variant-outline::before{content:"\FFE7"}.mdi-lock-outline::before{content:"\F341"}.mdi-lock-pattern::before{content:"\F6E9"}.mdi-lock-plus::before{content:"\F5FB"}.mdi-lock-question::before{content:"\F8EE"}.mdi-lock-reset::before{content:"\F772"}.mdi-lock-smart::before{content:"\F8B1"}.mdi-locker::before{content:"\F7D6"}.mdi-locker-multiple::before{content:"\F7D7"}.mdi-login::before{content:"\F342"}.mdi-login-variant::before{content:"\F5FC"}.mdi-logout::before{content:"\F343"}.mdi-logout-variant::before{content:"\F5FD"}.mdi-longitude::before{content:"\FF77"}.mdi-looks::before{content:"\F344"}.mdi-loupe::before{content:"\F345"}.mdi-lumx::before{content:"\F346"}.mdi-lungs::before{content:"\F00AF"}.mdi-lyft::before{content:"\FB1D"}.mdi-magnet::before{content:"\F347"}.mdi-magnet-on::before{content:"\F348"}.mdi-magnify::before{content:"\F349"}.mdi-magnify-close::before{content:"\F97F"}.mdi-magnify-minus::before{content:"\F34A"}.mdi-magnify-minus-cursor::before{content:"\FA61"}.mdi-magnify-minus-outline::before{content:"\F6EB"}.mdi-magnify-plus::before{content:"\F34B"}.mdi-magnify-plus-cursor::before{content:"\FA62"}.mdi-magnify-plus-outline::before{content:"\F6EC"}.mdi-magnify-remove-cursor::before{content:"\F0237"}.mdi-magnify-remove-outline::before{content:"\F0238"}.mdi-magnify-scan::before{content:"\F02A1"}.mdi-mail::before{content:"\FED8"}.mdi-mail-ru::before{content:"\F34C"}.mdi-mailbox::before{content:"\F6ED"}.mdi-mailbox-open::before{content:"\FD64"}.mdi-mailbox-open-outline::before{content:"\FD65"}.mdi-mailbox-open-up::before{content:"\FD66"}.mdi-mailbox-open-up-outline::before{content:"\FD67"}.mdi-mailbox-outline::before{content:"\FD68"}.mdi-mailbox-up::before{content:"\FD69"}.mdi-mailbox-up-outline::before{content:"\FD6A"}.mdi-map::before{content:"\F34D"}.mdi-map-check::before{content:"\FED9"}.mdi-map-check-outline::before{content:"\FEDA"}.mdi-map-clock::before{content:"\FCFA"}.mdi-map-clock-outline::before{content:"\FCFB"}.mdi-map-legend::before{content:"\FA00"}.mdi-map-marker::before{content:"\F34E"}.mdi-map-marker-alert::before{content:"\FF22"}.mdi-map-marker-alert-outline::before{content:"\FF23"}.mdi-map-marker-check::before{content:"\FC71"}.mdi-map-marker-check-outline::before{content:"\F0326"}.mdi-map-marker-circle::before{content:"\F34F"}.mdi-map-marker-distance::before{content:"\F8EF"}.mdi-map-marker-down::before{content:"\F012D"}.mdi-map-marker-left::before{content:"\F0306"}.mdi-map-marker-left-outline::before{content:"\F0308"}.mdi-map-marker-minus::before{content:"\F650"}.mdi-map-marker-minus-outline::before{content:"\F0324"}.mdi-map-marker-multiple::before{content:"\F350"}.mdi-map-marker-multiple-outline::before{content:"\F02A2"}.mdi-map-marker-off::before{content:"\F351"}.mdi-map-marker-off-outline::before{content:"\F0328"}.mdi-map-marker-outline::before{content:"\F7D8"}.mdi-map-marker-path::before{content:"\FCFC"}.mdi-map-marker-plus::before{content:"\F651"}.mdi-map-marker-plus-outline::before{content:"\F0323"}.mdi-map-marker-question::before{content:"\FF24"}.mdi-map-marker-question-outline::before{content:"\FF25"}.mdi-map-marker-radius::before{content:"\F352"}.mdi-map-marker-radius-outline::before{content:"\F0327"}.mdi-map-marker-remove::before{content:"\FF26"}.mdi-map-marker-remove-outline::before{content:"\F0325"}.mdi-map-marker-remove-variant::before{content:"\FF27"}.mdi-map-marker-right::before{content:"\F0307"}.mdi-map-marker-right-outline::before{content:"\F0309"}.mdi-map-marker-up::before{content:"\F012E"}.mdi-map-minus::before{content:"\F980"}.mdi-map-outline::before{content:"\F981"}.mdi-map-plus::before{content:"\F982"}.mdi-map-search::before{content:"\F983"}.mdi-map-search-outline::before{content:"\F984"}.mdi-mapbox::before{content:"\FB86"}.mdi-margin::before{content:"\F353"}.mdi-markdown::before{content:"\F354"}.mdi-markdown-outline::before{content:"\FF78"}.mdi-marker::before{content:"\F652"}.mdi-marker-cancel::before{content:"\FDB5"}.mdi-marker-check::before{content:"\F355"}.mdi-mastodon::before{content:"\FAD0"}.mdi-mastodon-variant::before{content:"\FAD1"}.mdi-material-design::before{content:"\F985"}.mdi-material-ui::before{content:"\F357"}.mdi-math-compass::before{content:"\F358"}.mdi-math-cos::before{content:"\FC72"}.mdi-math-integral::before{content:"\FFE8"}.mdi-math-integral-box::before{content:"\FFE9"}.mdi-math-log::before{content:"\F00B0"}.mdi-math-norm::before{content:"\FFEA"}.mdi-math-norm-box::before{content:"\FFEB"}.mdi-math-sin::before{content:"\FC73"}.mdi-math-tan::before{content:"\FC74"}.mdi-matrix::before{content:"\F628"}.mdi-medal::before{content:"\F986"}.mdi-medal-outline::before{content:"\F0351"}.mdi-medical-bag::before{content:"\F6EE"}.mdi-meditation::before{content:"\F01A6"}.mdi-medium::before{content:"\F35A"}.mdi-meetup::before{content:"\FAD2"}.mdi-memory::before{content:"\F35B"}.mdi-menu::before{content:"\F35C"}.mdi-menu-down::before{content:"\F35D"}.mdi-menu-down-outline::before{content:"\F6B5"}.mdi-menu-left::before{content:"\F35E"}.mdi-menu-left-outline::before{content:"\FA01"}.mdi-menu-open::before{content:"\FB87"}.mdi-menu-right::before{content:"\F35F"}.mdi-menu-right-outline::before{content:"\FA02"}.mdi-menu-swap::before{content:"\FA63"}.mdi-menu-swap-outline::before{content:"\FA64"}.mdi-menu-up::before{content:"\F360"}.mdi-menu-up-outline::before{content:"\F6B6"}.mdi-merge::before{content:"\FF79"}.mdi-message::before{content:"\F361"}.mdi-message-alert::before{content:"\F362"}.mdi-message-alert-outline::before{content:"\FA03"}.mdi-message-arrow-left::before{content:"\F031D"}.mdi-message-arrow-left-outline::before{content:"\F031E"}.mdi-message-arrow-right::before{content:"\F031F"}.mdi-message-arrow-right-outline::before{content:"\F0320"}.mdi-message-bulleted::before{content:"\F6A1"}.mdi-message-bulleted-off::before{content:"\F6A2"}.mdi-message-draw::before{content:"\F363"}.mdi-message-image::before{content:"\F364"}.mdi-message-image-outline::before{content:"\F0197"}.mdi-message-lock::before{content:"\FFEC"}.mdi-message-lock-outline::before{content:"\F0198"}.mdi-message-minus::before{content:"\F0199"}.mdi-message-minus-outline::before{content:"\F019A"}.mdi-message-outline::before{content:"\F365"}.mdi-message-plus::before{content:"\F653"}.mdi-message-plus-outline::before{content:"\F00E6"}.mdi-message-processing::before{content:"\F366"}.mdi-message-processing-outline::before{content:"\F019B"}.mdi-message-reply::before{content:"\F367"}.mdi-message-reply-text::before{content:"\F368"}.mdi-message-settings::before{content:"\F6EF"}.mdi-message-settings-outline::before{content:"\F019C"}.mdi-message-settings-variant::before{content:"\F6F0"}.mdi-message-settings-variant-outline::before{content:"\F019D"}.mdi-message-text::before{content:"\F369"}.mdi-message-text-clock::before{content:"\F019E"}.mdi-message-text-clock-outline::before{content:"\F019F"}.mdi-message-text-lock::before{content:"\FFED"}.mdi-message-text-lock-outline::before{content:"\F01A0"}.mdi-message-text-outline::before{content:"\F36A"}.mdi-message-video::before{content:"\F36B"}.mdi-meteor::before{content:"\F629"}.mdi-metronome::before{content:"\F7D9"}.mdi-metronome-tick::before{content:"\F7DA"}.mdi-micro-sd::before{content:"\F7DB"}.mdi-microphone::before{content:"\F36C"}.mdi-microphone-minus::before{content:"\F8B2"}.mdi-microphone-off::before{content:"\F36D"}.mdi-microphone-outline::before{content:"\F36E"}.mdi-microphone-plus::before{content:"\F8B3"}.mdi-microphone-settings::before{content:"\F36F"}.mdi-microphone-variant::before{content:"\F370"}.mdi-microphone-variant-off::before{content:"\F371"}.mdi-microscope::before{content:"\F654"}.mdi-microsoft::before{content:"\F372"}.mdi-microsoft-dynamics::before{content:"\F987"}.mdi-microwave::before{content:"\FC75"}.mdi-middleware::before{content:"\FF7A"}.mdi-middleware-outline::before{content:"\FF7B"}.mdi-midi::before{content:"\F8F0"}.mdi-midi-port::before{content:"\F8F1"}.mdi-mine::before{content:"\FDB6"}.mdi-minecraft::before{content:"\F373"}.mdi-mini-sd::before{content:"\FA04"}.mdi-minidisc::before{content:"\FA05"}.mdi-minus::before{content:"\F374"}.mdi-minus-box::before{content:"\F375"}.mdi-minus-box-multiple::before{content:"\F016C"}.mdi-minus-box-multiple-outline::before{content:"\F016D"}.mdi-minus-box-outline::before{content:"\F6F1"}.mdi-minus-circle::before{content:"\F376"}.mdi-minus-circle-outline::before{content:"\F377"}.mdi-minus-network::before{content:"\F378"}.mdi-minus-network-outline::before{content:"\FC76"}.mdi-mirror::before{content:"\F0228"}.mdi-mixcloud::before{content:"\F62A"}.mdi-mixed-martial-arts::before{content:"\FD6B"}.mdi-mixed-reality::before{content:"\F87E"}.mdi-mixer::before{content:"\F7DC"}.mdi-molecule::before{content:"\FB88"}.mdi-monitor::before{content:"\F379"}.mdi-monitor-cellphone::before{content:"\F988"}.mdi-monitor-cellphone-star::before{content:"\F989"}.mdi-monitor-clean::before{content:"\F012F"}.mdi-monitor-dashboard::before{content:"\FA06"}.mdi-monitor-edit::before{content:"\F02F1"}.mdi-monitor-lock::before{content:"\FDB7"}.mdi-monitor-multiple::before{content:"\F37A"}.mdi-monitor-off::before{content:"\FD6C"}.mdi-monitor-screenshot::before{content:"\FE34"}.mdi-monitor-speaker::before{content:"\FF7C"}.mdi-monitor-speaker-off::before{content:"\FF7D"}.mdi-monitor-star::before{content:"\FDB8"}.mdi-moon-first-quarter::before{content:"\FF7E"}.mdi-moon-full::before{content:"\FF7F"}.mdi-moon-last-quarter::before{content:"\FF80"}.mdi-moon-new::before{content:"\FF81"}.mdi-moon-waning-crescent::before{content:"\FF82"}.mdi-moon-waning-gibbous::before{content:"\FF83"}.mdi-moon-waxing-crescent::before{content:"\FF84"}.mdi-moon-waxing-gibbous::before{content:"\FF85"}.mdi-moped::before{content:"\F00B1"}.mdi-more::before{content:"\F37B"}.mdi-mother-heart::before{content:"\F033F"}.mdi-mother-nurse::before{content:"\FCFD"}.mdi-motion-sensor::before{content:"\FD6D"}.mdi-motorbike::before{content:"\F37C"}.mdi-mouse::before{content:"\F37D"}.mdi-mouse-bluetooth::before{content:"\F98A"}.mdi-mouse-off::before{content:"\F37E"}.mdi-mouse-variant::before{content:"\F37F"}.mdi-mouse-variant-off::before{content:"\F380"}.mdi-move-resize::before{content:"\F655"}.mdi-move-resize-variant::before{content:"\F656"}.mdi-movie::before{content:"\F381"}.mdi-movie-edit::before{content:"\F014D"}.mdi-movie-edit-outline::before{content:"\F014E"}.mdi-movie-filter::before{content:"\F014F"}.mdi-movie-filter-outline::before{content:"\F0150"}.mdi-movie-open::before{content:"\FFEE"}.mdi-movie-open-outline::before{content:"\FFEF"}.mdi-movie-outline::before{content:"\FDB9"}.mdi-movie-roll::before{content:"\F7DD"}.mdi-movie-search::before{content:"\F01FD"}.mdi-movie-search-outline::before{content:"\F01FE"}.mdi-muffin::before{content:"\F98B"}.mdi-multiplication::before{content:"\F382"}.mdi-multiplication-box::before{content:"\F383"}.mdi-mushroom::before{content:"\F7DE"}.mdi-mushroom-outline::before{content:"\F7DF"}.mdi-music::before{content:"\F759"}.mdi-music-accidental-double-flat::before{content:"\FF86"}.mdi-music-accidental-double-sharp::before{content:"\FF87"}.mdi-music-accidental-flat::before{content:"\FF88"}.mdi-music-accidental-natural::before{content:"\FF89"}.mdi-music-accidental-sharp::before{content:"\FF8A"}.mdi-music-box::before{content:"\F384"}.mdi-music-box-outline::before{content:"\F385"}.mdi-music-circle::before{content:"\F386"}.mdi-music-circle-outline::before{content:"\FAD3"}.mdi-music-clef-alto::before{content:"\FF8B"}.mdi-music-clef-bass::before{content:"\FF8C"}.mdi-music-clef-treble::before{content:"\FF8D"}.mdi-music-note::before{content:"\F387"}.mdi-music-note-bluetooth::before{content:"\F5FE"}.mdi-music-note-bluetooth-off::before{content:"\F5FF"}.mdi-music-note-eighth::before{content:"\F388"}.mdi-music-note-eighth-dotted::before{content:"\FF8E"}.mdi-music-note-half::before{content:"\F389"}.mdi-music-note-half-dotted::before{content:"\FF8F"}.mdi-music-note-off::before{content:"\F38A"}.mdi-music-note-off-outline::before{content:"\FF90"}.mdi-music-note-outline::before{content:"\FF91"}.mdi-music-note-plus::before{content:"\FDBA"}.mdi-music-note-quarter::before{content:"\F38B"}.mdi-music-note-quarter-dotted::before{content:"\FF92"}.mdi-music-note-sixteenth::before{content:"\F38C"}.mdi-music-note-sixteenth-dotted::before{content:"\FF93"}.mdi-music-note-whole::before{content:"\F38D"}.mdi-music-note-whole-dotted::before{content:"\FF94"}.mdi-music-off::before{content:"\F75A"}.mdi-music-rest-eighth::before{content:"\FF95"}.mdi-music-rest-half::before{content:"\FF96"}.mdi-music-rest-quarter::before{content:"\FF97"}.mdi-music-rest-sixteenth::before{content:"\FF98"}.mdi-music-rest-whole::before{content:"\FF99"}.mdi-nail::before{content:"\FDBB"}.mdi-nas::before{content:"\F8F2"}.mdi-nativescript::before{content:"\F87F"}.mdi-nature::before{content:"\F38E"}.mdi-nature-people::before{content:"\F38F"}.mdi-navigation::before{content:"\F390"}.mdi-near-me::before{content:"\F5CD"}.mdi-necklace::before{content:"\FF28"}.mdi-needle::before{content:"\F391"}.mdi-netflix::before{content:"\F745"}.mdi-network::before{content:"\F6F2"}.mdi-network-off::before{content:"\FC77"}.mdi-network-off-outline::before{content:"\FC78"}.mdi-network-outline::before{content:"\FC79"}.mdi-network-router::before{content:"\F00B2"}.mdi-network-strength-1::before{content:"\F8F3"}.mdi-network-strength-1-alert::before{content:"\F8F4"}.mdi-network-strength-2::before{content:"\F8F5"}.mdi-network-strength-2-alert::before{content:"\F8F6"}.mdi-network-strength-3::before{content:"\F8F7"}.mdi-network-strength-3-alert::before{content:"\F8F8"}.mdi-network-strength-4::before{content:"\F8F9"}.mdi-network-strength-4-alert::before{content:"\F8FA"}.mdi-network-strength-off::before{content:"\F8FB"}.mdi-network-strength-off-outline::before{content:"\F8FC"}.mdi-network-strength-outline::before{content:"\F8FD"}.mdi-new-box::before{content:"\F394"}.mdi-newspaper::before{content:"\F395"}.mdi-newspaper-minus::before{content:"\FF29"}.mdi-newspaper-plus::before{content:"\FF2A"}.mdi-newspaper-variant::before{content:"\F0023"}.mdi-newspaper-variant-multiple::before{content:"\F0024"}.mdi-newspaper-variant-multiple-outline::before{content:"\F0025"}.mdi-newspaper-variant-outline::before{content:"\F0026"}.mdi-nfc::before{content:"\F396"}.mdi-nfc-off::before{content:"\FE35"}.mdi-nfc-search-variant::before{content:"\FE36"}.mdi-nfc-tap::before{content:"\F397"}.mdi-nfc-variant::before{content:"\F398"}.mdi-nfc-variant-off::before{content:"\FE37"}.mdi-ninja::before{content:"\F773"}.mdi-nintendo-switch::before{content:"\F7E0"}.mdi-nix::before{content:"\F0130"}.mdi-nodejs::before{content:"\F399"}.mdi-noodles::before{content:"\F01A9"}.mdi-not-equal::before{content:"\F98C"}.mdi-not-equal-variant::before{content:"\F98D"}.mdi-note::before{content:"\F39A"}.mdi-note-multiple::before{content:"\F6B7"}.mdi-note-multiple-outline::before{content:"\F6B8"}.mdi-note-outline::before{content:"\F39B"}.mdi-note-plus::before{content:"\F39C"}.mdi-note-plus-outline::before{content:"\F39D"}.mdi-note-text::before{content:"\F39E"}.mdi-note-text-outline::before{content:"\F0202"}.mdi-notebook::before{content:"\F82D"}.mdi-notebook-multiple::before{content:"\FE38"}.mdi-notebook-outline::before{content:"\FEDC"}.mdi-notification-clear-all::before{content:"\F39F"}.mdi-npm::before{content:"\F6F6"}.mdi-npm-variant::before{content:"\F98E"}.mdi-npm-variant-outline::before{content:"\F98F"}.mdi-nuke::before{content:"\F6A3"}.mdi-null::before{content:"\F7E1"}.mdi-numeric::before{content:"\F3A0"}.mdi-numeric-0::before{content:"\30"}.mdi-numeric-0-box::before{content:"\F3A1"}.mdi-numeric-0-box-multiple::before{content:"\FF2B"}.mdi-numeric-0-box-multiple-outline::before{content:"\F3A2"}.mdi-numeric-0-box-outline::before{content:"\F3A3"}.mdi-numeric-0-circle::before{content:"\FC7A"}.mdi-numeric-0-circle-outline::before{content:"\FC7B"}.mdi-numeric-1::before{content:"\31"}.mdi-numeric-1-box::before{content:"\F3A4"}.mdi-numeric-1-box-multiple::before{content:"\FF2C"}.mdi-numeric-1-box-multiple-outline::before{content:"\F3A5"}.mdi-numeric-1-box-outline::before{content:"\F3A6"}.mdi-numeric-1-circle::before{content:"\FC7C"}.mdi-numeric-1-circle-outline::before{content:"\FC7D"}.mdi-numeric-10::before{content:"\F000A"}.mdi-numeric-10-box::before{content:"\FF9A"}.mdi-numeric-10-box-multiple::before{content:"\F000B"}.mdi-numeric-10-box-multiple-outline::before{content:"\F000C"}.mdi-numeric-10-box-outline::before{content:"\FF9B"}.mdi-numeric-10-circle::before{content:"\F000D"}.mdi-numeric-10-circle-outline::before{content:"\F000E"}.mdi-numeric-2::before{content:"\32"}.mdi-numeric-2-box::before{content:"\F3A7"}.mdi-numeric-2-box-multiple::before{content:"\FF2D"}.mdi-numeric-2-box-multiple-outline::before{content:"\F3A8"}.mdi-numeric-2-box-outline::before{content:"\F3A9"}.mdi-numeric-2-circle::before{content:"\FC7E"}.mdi-numeric-2-circle-outline::before{content:"\FC7F"}.mdi-numeric-3::before{content:"\33"}.mdi-numeric-3-box::before{content:"\F3AA"}.mdi-numeric-3-box-multiple::before{content:"\FF2E"}.mdi-numeric-3-box-multiple-outline::before{content:"\F3AB"}.mdi-numeric-3-box-outline::before{content:"\F3AC"}.mdi-numeric-3-circle::before{content:"\FC80"}.mdi-numeric-3-circle-outline::before{content:"\FC81"}.mdi-numeric-4::before{content:"\34"}.mdi-numeric-4-box::before{content:"\F3AD"}.mdi-numeric-4-box-multiple::before{content:"\FF2F"}.mdi-numeric-4-box-multiple-outline::before{content:"\F3AE"}.mdi-numeric-4-box-outline::before{content:"\F3AF"}.mdi-numeric-4-circle::before{content:"\FC82"}.mdi-numeric-4-circle-outline::before{content:"\FC83"}.mdi-numeric-5::before{content:"\35"}.mdi-numeric-5-box::before{content:"\F3B0"}.mdi-numeric-5-box-multiple::before{content:"\FF30"}.mdi-numeric-5-box-multiple-outline::before{content:"\F3B1"}.mdi-numeric-5-box-outline::before{content:"\F3B2"}.mdi-numeric-5-circle::before{content:"\FC84"}.mdi-numeric-5-circle-outline::before{content:"\FC85"}.mdi-numeric-6::before{content:"\36"}.mdi-numeric-6-box::before{content:"\F3B3"}.mdi-numeric-6-box-multiple::before{content:"\FF31"}.mdi-numeric-6-box-multiple-outline::before{content:"\F3B4"}.mdi-numeric-6-box-outline::before{content:"\F3B5"}.mdi-numeric-6-circle::before{content:"\FC86"}.mdi-numeric-6-circle-outline::before{content:"\FC87"}.mdi-numeric-7::before{content:"\37"}.mdi-numeric-7-box::before{content:"\F3B6"}.mdi-numeric-7-box-multiple::before{content:"\FF32"}.mdi-numeric-7-box-multiple-outline::before{content:"\F3B7"}.mdi-numeric-7-box-outline::before{content:"\F3B8"}.mdi-numeric-7-circle::before{content:"\FC88"}.mdi-numeric-7-circle-outline::before{content:"\FC89"}.mdi-numeric-8::before{content:"\38"}.mdi-numeric-8-box::before{content:"\F3B9"}.mdi-numeric-8-box-multiple::before{content:"\FF33"}.mdi-numeric-8-box-multiple-outline::before{content:"\F3BA"}.mdi-numeric-8-box-outline::before{content:"\F3BB"}.mdi-numeric-8-circle::before{content:"\FC8A"}.mdi-numeric-8-circle-outline::before{content:"\FC8B"}.mdi-numeric-9::before{content:"\39"}.mdi-numeric-9-box::before{content:"\F3BC"}.mdi-numeric-9-box-multiple::before{content:"\FF34"}.mdi-numeric-9-box-multiple-outline::before{content:"\F3BD"}.mdi-numeric-9-box-outline::before{content:"\F3BE"}.mdi-numeric-9-circle::before{content:"\FC8C"}.mdi-numeric-9-circle-outline::before{content:"\FC8D"}.mdi-numeric-9-plus::before{content:"\F000F"}.mdi-numeric-9-plus-box::before{content:"\F3BF"}.mdi-numeric-9-plus-box-multiple::before{content:"\FF35"}.mdi-numeric-9-plus-box-multiple-outline::before{content:"\F3C0"}.mdi-numeric-9-plus-box-outline::before{content:"\F3C1"}.mdi-numeric-9-plus-circle::before{content:"\FC8E"}.mdi-numeric-9-plus-circle-outline::before{content:"\FC8F"}.mdi-numeric-negative-1::before{content:"\F0074"}.mdi-nut::before{content:"\F6F7"}.mdi-nutrition::before{content:"\F3C2"}.mdi-nuxt::before{content:"\F0131"}.mdi-oar::before{content:"\F67B"}.mdi-ocarina::before{content:"\FDBC"}.mdi-oci::before{content:"\F0314"}.mdi-ocr::before{content:"\F0165"}.mdi-octagon::before{content:"\F3C3"}.mdi-octagon-outline::before{content:"\F3C4"}.mdi-octagram::before{content:"\F6F8"}.mdi-octagram-outline::before{content:"\F774"}.mdi-odnoklassniki::before{content:"\F3C5"}.mdi-offer::before{content:"\F0246"}.mdi-office::before{content:"\F3C6"}.mdi-office-building::before{content:"\F990"}.mdi-oil::before{content:"\F3C7"}.mdi-oil-lamp::before{content:"\FF36"}.mdi-oil-level::before{content:"\F0075"}.mdi-oil-temperature::before{content:"\F0019"}.mdi-omega::before{content:"\F3C9"}.mdi-one-up::before{content:"\FB89"}.mdi-onedrive::before{content:"\F3CA"}.mdi-onenote::before{content:"\F746"}.mdi-onepassword::before{content:"\F880"}.mdi-opacity::before{content:"\F5CC"}.mdi-open-in-app::before{content:"\F3CB"}.mdi-open-in-new::before{content:"\F3CC"}.mdi-open-source-initiative::before{content:"\FB8A"}.mdi-openid::before{content:"\F3CD"}.mdi-opera::before{content:"\F3CE"}.mdi-orbit::before{content:"\F018"}.mdi-origin::before{content:"\FB2B"}.mdi-ornament::before{content:"\F3CF"}.mdi-ornament-variant::before{content:"\F3D0"}.mdi-outdoor-lamp::before{content:"\F0076"}.mdi-outlook::before{content:"\FCFE"}.mdi-overscan::before{content:"\F0027"}.mdi-owl::before{content:"\F3D2"}.mdi-pac-man::before{content:"\FB8B"}.mdi-package::before{content:"\F3D3"}.mdi-package-down::before{content:"\F3D4"}.mdi-package-up::before{content:"\F3D5"}.mdi-package-variant::before{content:"\F3D6"}.mdi-package-variant-closed::before{content:"\F3D7"}.mdi-page-first::before{content:"\F600"}.mdi-page-last::before{content:"\F601"}.mdi-page-layout-body::before{content:"\F6F9"}.mdi-page-layout-footer::before{content:"\F6FA"}.mdi-page-layout-header::before{content:"\F6FB"}.mdi-page-layout-header-footer::before{content:"\FF9C"}.mdi-page-layout-sidebar-left::before{content:"\F6FC"}.mdi-page-layout-sidebar-right::before{content:"\F6FD"}.mdi-page-next::before{content:"\FB8C"}.mdi-page-next-outline::before{content:"\FB8D"}.mdi-page-previous::before{content:"\FB8E"}.mdi-page-previous-outline::before{content:"\FB8F"}.mdi-palette::before{content:"\F3D8"}.mdi-palette-advanced::before{content:"\F3D9"}.mdi-palette-outline::before{content:"\FE6C"}.mdi-palette-swatch::before{content:"\F8B4"}.mdi-palette-swatch-outline::before{content:"\F0387"}.mdi-palm-tree::before{content:"\F0077"}.mdi-pan::before{content:"\FB90"}.mdi-pan-bottom-left::before{content:"\FB91"}.mdi-pan-bottom-right::before{content:"\FB92"}.mdi-pan-down::before{content:"\FB93"}.mdi-pan-horizontal::before{content:"\FB94"}.mdi-pan-left::before{content:"\FB95"}.mdi-pan-right::before{content:"\FB96"}.mdi-pan-top-left::before{content:"\FB97"}.mdi-pan-top-right::before{content:"\FB98"}.mdi-pan-up::before{content:"\FB99"}.mdi-pan-vertical::before{content:"\FB9A"}.mdi-panda::before{content:"\F3DA"}.mdi-pandora::before{content:"\F3DB"}.mdi-panorama::before{content:"\F3DC"}.mdi-panorama-fisheye::before{content:"\F3DD"}.mdi-panorama-horizontal::before{content:"\F3DE"}.mdi-panorama-vertical::before{content:"\F3DF"}.mdi-panorama-wide-angle::before{content:"\F3E0"}.mdi-paper-cut-vertical::before{content:"\F3E1"}.mdi-paper-roll::before{content:"\F0182"}.mdi-paper-roll-outline::before{content:"\F0183"}.mdi-paperclip::before{content:"\F3E2"}.mdi-parachute::before{content:"\FC90"}.mdi-parachute-outline::before{content:"\FC91"}.mdi-parking::before{content:"\F3E3"}.mdi-party-popper::before{content:"\F0078"}.mdi-passport::before{content:"\F7E2"}.mdi-passport-biometric::before{content:"\FDBD"}.mdi-pasta::before{content:"\F018B"}.mdi-patio-heater::before{content:"\FF9D"}.mdi-patreon::before{content:"\F881"}.mdi-pause::before{content:"\F3E4"}.mdi-pause-circle::before{content:"\F3E5"}.mdi-pause-circle-outline::before{content:"\F3E6"}.mdi-pause-octagon::before{content:"\F3E7"}.mdi-pause-octagon-outline::before{content:"\F3E8"}.mdi-paw::before{content:"\F3E9"}.mdi-paw-off::before{content:"\F657"}.mdi-paypal::before{content:"\F882"}.mdi-pdf-box::before{content:"\FE39"}.mdi-peace::before{content:"\F883"}.mdi-peanut::before{content:"\F001E"}.mdi-peanut-off::before{content:"\F001F"}.mdi-peanut-off-outline::before{content:"\F0021"}.mdi-peanut-outline::before{content:"\F0020"}.mdi-pen::before{content:"\F3EA"}.mdi-pen-lock::before{content:"\FDBE"}.mdi-pen-minus::before{content:"\FDBF"}.mdi-pen-off::before{content:"\FDC0"}.mdi-pen-plus::before{content:"\FDC1"}.mdi-pen-remove::before{content:"\FDC2"}.mdi-pencil::before{content:"\F3EB"}.mdi-pencil-box::before{content:"\F3EC"}.mdi-pencil-box-multiple::before{content:"\F016F"}.mdi-pencil-box-multiple-outline::before{content:"\F0170"}.mdi-pencil-box-outline::before{content:"\F3ED"}.mdi-pencil-circle::before{content:"\F6FE"}.mdi-pencil-circle-outline::before{content:"\F775"}.mdi-pencil-lock::before{content:"\F3EE"}.mdi-pencil-lock-outline::before{content:"\FDC3"}.mdi-pencil-minus::before{content:"\FDC4"}.mdi-pencil-minus-outline::before{content:"\FDC5"}.mdi-pencil-off::before{content:"\F3EF"}.mdi-pencil-off-outline::before{content:"\FDC6"}.mdi-pencil-outline::before{content:"\FC92"}.mdi-pencil-plus::before{content:"\FDC7"}.mdi-pencil-plus-outline::before{content:"\FDC8"}.mdi-pencil-remove::before{content:"\FDC9"}.mdi-pencil-remove-outline::before{content:"\FDCA"}.mdi-pencil-ruler::before{content:"\F037E"}.mdi-penguin::before{content:"\FEDD"}.mdi-pentagon::before{content:"\F6FF"}.mdi-pentagon-outline::before{content:"\F700"}.mdi-percent::before{content:"\F3F0"}.mdi-percent-outline::before{content:"\F02A3"}.mdi-periodic-table::before{content:"\F8B5"}.mdi-periodic-table-co::before{content:"\F0329"}.mdi-periodic-table-co2::before{content:"\F7E3"}.mdi-periscope::before{content:"\F747"}.mdi-perspective-less::before{content:"\FCFF"}.mdi-perspective-more::before{content:"\FD00"}.mdi-pharmacy::before{content:"\F3F1"}.mdi-phone::before{content:"\F3F2"}.mdi-phone-alert::before{content:"\FF37"}.mdi-phone-alert-outline::before{content:"\F01B9"}.mdi-phone-bluetooth::before{content:"\F3F3"}.mdi-phone-bluetooth-outline::before{content:"\F01BA"}.mdi-phone-cancel::before{content:"\F00E7"}.mdi-phone-cancel-outline::before{content:"\F01BB"}.mdi-phone-check::before{content:"\F01D4"}.mdi-phone-check-outline::before{content:"\F01D5"}.mdi-phone-classic::before{content:"\F602"}.mdi-phone-classic-off::before{content:"\F02A4"}.mdi-phone-forward::before{content:"\F3F4"}.mdi-phone-forward-outline::before{content:"\F01BC"}.mdi-phone-hangup::before{content:"\F3F5"}.mdi-phone-hangup-outline::before{content:"\F01BD"}.mdi-phone-in-talk::before{content:"\F3F6"}.mdi-phone-in-talk-outline::before{content:"\F01AD"}.mdi-phone-incoming::before{content:"\F3F7"}.mdi-phone-incoming-outline::before{content:"\F01BE"}.mdi-phone-lock::before{content:"\F3F8"}.mdi-phone-lock-outline::before{content:"\F01BF"}.mdi-phone-log::before{content:"\F3F9"}.mdi-phone-log-outline::before{content:"\F01C0"}.mdi-phone-message::before{content:"\F01C1"}.mdi-phone-message-outline::before{content:"\F01C2"}.mdi-phone-minus::before{content:"\F658"}.mdi-phone-minus-outline::before{content:"\F01C3"}.mdi-phone-missed::before{content:"\F3FA"}.mdi-phone-missed-outline::before{content:"\F01D0"}.mdi-phone-off::before{content:"\FDCB"}.mdi-phone-off-outline::before{content:"\F01D1"}.mdi-phone-outgoing::before{content:"\F3FB"}.mdi-phone-outgoing-outline::before{content:"\F01C4"}.mdi-phone-outline::before{content:"\FDCC"}.mdi-phone-paused::before{content:"\F3FC"}.mdi-phone-paused-outline::before{content:"\F01C5"}.mdi-phone-plus::before{content:"\F659"}.mdi-phone-plus-outline::before{content:"\F01C6"}.mdi-phone-return::before{content:"\F82E"}.mdi-phone-return-outline::before{content:"\F01C7"}.mdi-phone-ring::before{content:"\F01D6"}.mdi-phone-ring-outline::before{content:"\F01D7"}.mdi-phone-rotate-landscape::before{content:"\F884"}.mdi-phone-rotate-portrait::before{content:"\F885"}.mdi-phone-settings::before{content:"\F3FD"}.mdi-phone-settings-outline::before{content:"\F01C8"}.mdi-phone-voip::before{content:"\F3FE"}.mdi-pi::before{content:"\F3FF"}.mdi-pi-box::before{content:"\F400"}.mdi-pi-hole::before{content:"\FDCD"}.mdi-piano::before{content:"\F67C"}.mdi-pickaxe::before{content:"\F8B6"}.mdi-picture-in-picture-bottom-right::before{content:"\FE3A"}.mdi-picture-in-picture-bottom-right-outline::before{content:"\FE3B"}.mdi-picture-in-picture-top-right::before{content:"\FE3C"}.mdi-picture-in-picture-top-right-outline::before{content:"\FE3D"}.mdi-pier::before{content:"\F886"}.mdi-pier-crane::before{content:"\F887"}.mdi-pig::before{content:"\F401"}.mdi-pig-variant::before{content:"\F0028"}.mdi-piggy-bank::before{content:"\F0029"}.mdi-pill::before{content:"\F402"}.mdi-pillar::before{content:"\F701"}.mdi-pin::before{content:"\F403"}.mdi-pin-off::before{content:"\F404"}.mdi-pin-off-outline::before{content:"\F92F"}.mdi-pin-outline::before{content:"\F930"}.mdi-pine-tree::before{content:"\F405"}.mdi-pine-tree-box::before{content:"\F406"}.mdi-pinterest::before{content:"\F407"}.mdi-pinterest-box::before{content:"\F408"}.mdi-pinwheel::before{content:"\FAD4"}.mdi-pinwheel-outline::before{content:"\FAD5"}.mdi-pipe::before{content:"\F7E4"}.mdi-pipe-disconnected::before{content:"\F7E5"}.mdi-pipe-leak::before{content:"\F888"}.mdi-pipe-wrench::before{content:"\F037F"}.mdi-pirate::before{content:"\FA07"}.mdi-pistol::before{content:"\F702"}.mdi-piston::before{content:"\F889"}.mdi-pizza::before{content:"\F409"}.mdi-play::before{content:"\F40A"}.mdi-play-box::before{content:"\F02A5"}.mdi-play-box-outline::before{content:"\F40B"}.mdi-play-circle::before{content:"\F40C"}.mdi-play-circle-outline::before{content:"\F40D"}.mdi-play-network::before{content:"\F88A"}.mdi-play-network-outline::before{content:"\FC93"}.mdi-play-outline::before{content:"\FF38"}.mdi-play-pause::before{content:"\F40E"}.mdi-play-protected-content::before{content:"\F40F"}.mdi-play-speed::before{content:"\F8FE"}.mdi-playlist-check::before{content:"\F5C7"}.mdi-playlist-edit::before{content:"\F8FF"}.mdi-playlist-minus::before{content:"\F410"}.mdi-playlist-music::before{content:"\FC94"}.mdi-playlist-music-outline::before{content:"\FC95"}.mdi-playlist-play::before{content:"\F411"}.mdi-playlist-plus::before{content:"\F412"}.mdi-playlist-remove::before{content:"\F413"}.mdi-playlist-star::before{content:"\FDCE"}.mdi-playstation::before{content:"\F414"}.mdi-plex::before{content:"\F6B9"}.mdi-plus::before{content:"\F415"}.mdi-plus-box::before{content:"\F416"}.mdi-plus-box-multiple::before{content:"\F334"}.mdi-plus-box-multiple-outline::before{content:"\F016E"}.mdi-plus-box-outline::before{content:"\F703"}.mdi-plus-circle::before{content:"\F417"}.mdi-plus-circle-multiple-outline::before{content:"\F418"}.mdi-plus-circle-outline::before{content:"\F419"}.mdi-plus-minus::before{content:"\F991"}.mdi-plus-minus-box::before{content:"\F992"}.mdi-plus-network::before{content:"\F41A"}.mdi-plus-network-outline::before{content:"\FC96"}.mdi-plus-one::before{content:"\F41B"}.mdi-plus-outline::before{content:"\F704"}.mdi-plus-thick::before{content:"\F0217"}.mdi-pocket::before{content:"\F41C"}.mdi-podcast::before{content:"\F993"}.mdi-podium::before{content:"\FD01"}.mdi-podium-bronze::before{content:"\FD02"}.mdi-podium-gold::before{content:"\FD03"}.mdi-podium-silver::before{content:"\FD04"}.mdi-point-of-sale::before{content:"\FD6E"}.mdi-pokeball::before{content:"\F41D"}.mdi-pokemon-go::before{content:"\FA08"}.mdi-poker-chip::before{content:"\F82F"}.mdi-polaroid::before{content:"\F41E"}.mdi-police-badge::before{content:"\F0192"}.mdi-police-badge-outline::before{content:"\F0193"}.mdi-poll::before{content:"\F41F"}.mdi-poll-box::before{content:"\F420"}.mdi-poll-box-outline::before{content:"\F02A6"}.mdi-polymer::before{content:"\F421"}.mdi-pool::before{content:"\F606"}.mdi-popcorn::before{content:"\F422"}.mdi-post::before{content:"\F002A"}.mdi-post-outline::before{content:"\F002B"}.mdi-postage-stamp::before{content:"\FC97"}.mdi-pot::before{content:"\F65A"}.mdi-pot-mix::before{content:"\F65B"}.mdi-pound::before{content:"\F423"}.mdi-pound-box::before{content:"\F424"}.mdi-pound-box-outline::before{content:"\F01AA"}.mdi-power::before{content:"\F425"}.mdi-power-cycle::before{content:"\F900"}.mdi-power-off::before{content:"\F901"}.mdi-power-on::before{content:"\F902"}.mdi-power-plug::before{content:"\F6A4"}.mdi-power-plug-off::before{content:"\F6A5"}.mdi-power-settings::before{content:"\F426"}.mdi-power-sleep::before{content:"\F903"}.mdi-power-socket::before{content:"\F427"}.mdi-power-socket-au::before{content:"\F904"}.mdi-power-socket-de::before{content:"\F0132"}.mdi-power-socket-eu::before{content:"\F7E6"}.mdi-power-socket-fr::before{content:"\F0133"}.mdi-power-socket-jp::before{content:"\F0134"}.mdi-power-socket-uk::before{content:"\F7E7"}.mdi-power-socket-us::before{content:"\F7E8"}.mdi-power-standby::before{content:"\F905"}.mdi-powershell::before{content:"\FA09"}.mdi-prescription::before{content:"\F705"}.mdi-presentation::before{content:"\F428"}.mdi-presentation-play::before{content:"\F429"}.mdi-printer::before{content:"\F42A"}.mdi-printer-3d::before{content:"\F42B"}.mdi-printer-3d-nozzle::before{content:"\FE3E"}.mdi-printer-3d-nozzle-alert::before{content:"\F01EB"}.mdi-printer-3d-nozzle-alert-outline::before{content:"\F01EC"}.mdi-printer-3d-nozzle-outline::before{content:"\FE3F"}.mdi-printer-alert::before{content:"\F42C"}.mdi-printer-check::before{content:"\F0171"}.mdi-printer-off::before{content:"\FE40"}.mdi-printer-pos::before{content:"\F0079"}.mdi-printer-settings::before{content:"\F706"}.mdi-printer-wireless::before{content:"\FA0A"}.mdi-priority-high::before{content:"\F603"}.mdi-priority-low::before{content:"\F604"}.mdi-professional-hexagon::before{content:"\F42D"}.mdi-progress-alert::before{content:"\FC98"}.mdi-progress-check::before{content:"\F994"}.mdi-progress-clock::before{content:"\F995"}.mdi-progress-close::before{content:"\F0135"}.mdi-progress-download::before{content:"\F996"}.mdi-progress-upload::before{content:"\F997"}.mdi-progress-wrench::before{content:"\FC99"}.mdi-projector::before{content:"\F42E"}.mdi-projector-screen::before{content:"\F42F"}.mdi-propane-tank::before{content:"\F0382"}.mdi-propane-tank-outline::before{content:"\F0383"}.mdi-protocol::before{content:"\FFF9"}.mdi-publish::before{content:"\F6A6"}.mdi-pulse::before{content:"\F430"}.mdi-pumpkin::before{content:"\FB9B"}.mdi-purse::before{content:"\FF39"}.mdi-purse-outline::before{content:"\FF3A"}.mdi-puzzle::before{content:"\F431"}.mdi-puzzle-outline::before{content:"\FA65"}.mdi-qi::before{content:"\F998"}.mdi-qqchat::before{content:"\F605"}.mdi-qrcode::before{content:"\F432"}.mdi-qrcode-edit::before{content:"\F8B7"}.mdi-qrcode-minus::before{content:"\F01B7"}.mdi-qrcode-plus::before{content:"\F01B6"}.mdi-qrcode-remove::before{content:"\F01B8"}.mdi-qrcode-scan::before{content:"\F433"}.mdi-quadcopter::before{content:"\F434"}.mdi-quality-high::before{content:"\F435"}.mdi-quality-low::before{content:"\FA0B"}.mdi-quality-medium::before{content:"\FA0C"}.mdi-quicktime::before{content:"\F436"}.mdi-quora::before{content:"\FD05"}.mdi-rabbit::before{content:"\F906"}.mdi-racing-helmet::before{content:"\FD6F"}.mdi-racquetball::before{content:"\FD70"}.mdi-radar::before{content:"\F437"}.mdi-radiator::before{content:"\F438"}.mdi-radiator-disabled::before{content:"\FAD6"}.mdi-radiator-off::before{content:"\FAD7"}.mdi-radio::before{content:"\F439"}.mdi-radio-am::before{content:"\FC9A"}.mdi-radio-fm::before{content:"\FC9B"}.mdi-radio-handheld::before{content:"\F43A"}.mdi-radio-off::before{content:"\F0247"}.mdi-radio-tower::before{content:"\F43B"}.mdi-radioactive::before{content:"\F43C"}.mdi-radioactive-off::before{content:"\FEDE"}.mdi-radiobox-blank::before{content:"\F43D"}.mdi-radiobox-marked::before{content:"\F43E"}.mdi-radius::before{content:"\FC9C"}.mdi-radius-outline::before{content:"\FC9D"}.mdi-railroad-light::before{content:"\FF3B"}.mdi-raspberry-pi::before{content:"\F43F"}.mdi-ray-end::before{content:"\F440"}.mdi-ray-end-arrow::before{content:"\F441"}.mdi-ray-start::before{content:"\F442"}.mdi-ray-start-arrow::before{content:"\F443"}.mdi-ray-start-end::before{content:"\F444"}.mdi-ray-vertex::before{content:"\F445"}.mdi-react::before{content:"\F707"}.mdi-read::before{content:"\F447"}.mdi-receipt::before{content:"\F449"}.mdi-record::before{content:"\F44A"}.mdi-record-circle::before{content:"\FEDF"}.mdi-record-circle-outline::before{content:"\FEE0"}.mdi-record-player::before{content:"\F999"}.mdi-record-rec::before{content:"\F44B"}.mdi-rectangle::before{content:"\FE41"}.mdi-rectangle-outline::before{content:"\FE42"}.mdi-recycle::before{content:"\F44C"}.mdi-reddit::before{content:"\F44D"}.mdi-redhat::before{content:"\F0146"}.mdi-redo::before{content:"\F44E"}.mdi-redo-variant::before{content:"\F44F"}.mdi-reflect-horizontal::before{content:"\FA0D"}.mdi-reflect-vertical::before{content:"\FA0E"}.mdi-refresh::before{content:"\F450"}.mdi-refresh-circle::before{content:"\F03A2"}.mdi-regex::before{content:"\F451"}.mdi-registered-trademark::before{content:"\FA66"}.mdi-relative-scale::before{content:"\F452"}.mdi-reload::before{content:"\F453"}.mdi-reload-alert::before{content:"\F0136"}.mdi-reminder::before{content:"\F88B"}.mdi-remote::before{content:"\F454"}.mdi-remote-desktop::before{content:"\F8B8"}.mdi-remote-off::before{content:"\FEE1"}.mdi-remote-tv::before{content:"\FEE2"}.mdi-remote-tv-off::before{content:"\FEE3"}.mdi-rename-box::before{content:"\F455"}.mdi-reorder-horizontal::before{content:"\F687"}.mdi-reorder-vertical::before{content:"\F688"}.mdi-repeat::before{content:"\F456"}.mdi-repeat-off::before{content:"\F457"}.mdi-repeat-once::before{content:"\F458"}.mdi-replay::before{content:"\F459"}.mdi-reply::before{content:"\F45A"}.mdi-reply-all::before{content:"\F45B"}.mdi-reply-all-outline::before{content:"\FF3C"}.mdi-reply-circle::before{content:"\F01D9"}.mdi-reply-outline::before{content:"\FF3D"}.mdi-reproduction::before{content:"\F45C"}.mdi-resistor::before{content:"\FB1F"}.mdi-resistor-nodes::before{content:"\FB20"}.mdi-resize::before{content:"\FA67"}.mdi-resize-bottom-right::before{content:"\F45D"}.mdi-responsive::before{content:"\F45E"}.mdi-restart::before{content:"\F708"}.mdi-restart-alert::before{content:"\F0137"}.mdi-restart-off::before{content:"\FD71"}.mdi-restore::before{content:"\F99A"}.mdi-restore-alert::before{content:"\F0138"}.mdi-rewind::before{content:"\F45F"}.mdi-rewind-10::before{content:"\FD06"}.mdi-rewind-30::before{content:"\FD72"}.mdi-rewind-5::before{content:"\F0224"}.mdi-rewind-outline::before{content:"\F709"}.mdi-rhombus::before{content:"\F70A"}.mdi-rhombus-medium::before{content:"\FA0F"}.mdi-rhombus-outline::before{content:"\F70B"}.mdi-rhombus-split::before{content:"\FA10"}.mdi-ribbon::before{content:"\F460"}.mdi-rice::before{content:"\F7E9"}.mdi-ring::before{content:"\F7EA"}.mdi-rivet::before{content:"\FE43"}.mdi-road::before{content:"\F461"}.mdi-road-variant::before{content:"\F462"}.mdi-robber::before{content:"\F007A"}.mdi-robot::before{content:"\F6A8"}.mdi-robot-industrial::before{content:"\FB21"}.mdi-robot-mower::before{content:"\F0222"}.mdi-robot-mower-outline::before{content:"\F021E"}.mdi-robot-vacuum::before{content:"\F70C"}.mdi-robot-vacuum-variant::before{content:"\F907"}.mdi-rocket::before{content:"\F463"}.mdi-rodent::before{content:"\F0352"}.mdi-roller-skate::before{content:"\FD07"}.mdi-rollerblade::before{content:"\FD08"}.mdi-rollupjs::before{content:"\FB9C"}.mdi-roman-numeral-1::before{content:"\F00B3"}.mdi-roman-numeral-10::before{content:"\F00BC"}.mdi-roman-numeral-2::before{content:"\F00B4"}.mdi-roman-numeral-3::before{content:"\F00B5"}.mdi-roman-numeral-4::before{content:"\F00B6"}.mdi-roman-numeral-5::before{content:"\F00B7"}.mdi-roman-numeral-6::before{content:"\F00B8"}.mdi-roman-numeral-7::before{content:"\F00B9"}.mdi-roman-numeral-8::before{content:"\F00BA"}.mdi-roman-numeral-9::before{content:"\F00BB"}.mdi-room-service::before{content:"\F88C"}.mdi-room-service-outline::before{content:"\FD73"}.mdi-rotate-3d::before{content:"\FEE4"}.mdi-rotate-3d-variant::before{content:"\F464"}.mdi-rotate-left::before{content:"\F465"}.mdi-rotate-left-variant::before{content:"\F466"}.mdi-rotate-orbit::before{content:"\FD74"}.mdi-rotate-right::before{content:"\F467"}.mdi-rotate-right-variant::before{content:"\F468"}.mdi-rounded-corner::before{content:"\F607"}.mdi-router::before{content:"\F020D"}.mdi-router-wireless::before{content:"\F469"}.mdi-router-wireless-settings::before{content:"\FA68"}.mdi-routes::before{content:"\F46A"}.mdi-routes-clock::before{content:"\F007B"}.mdi-rowing::before{content:"\F608"}.mdi-rss::before{content:"\F46B"}.mdi-rss-box::before{content:"\F46C"}.mdi-rss-off::before{content:"\FF3E"}.mdi-ruby::before{content:"\FD09"}.mdi-rugby::before{content:"\FD75"}.mdi-ruler::before{content:"\F46D"}.mdi-ruler-square::before{content:"\FC9E"}.mdi-ruler-square-compass::before{content:"\FEDB"}.mdi-run::before{content:"\F70D"}.mdi-run-fast::before{content:"\F46E"}.mdi-rv-truck::before{content:"\F01FF"}.mdi-sack::before{content:"\FD0A"}.mdi-sack-percent::before{content:"\FD0B"}.mdi-safe::before{content:"\FA69"}.mdi-safe-square::before{content:"\F02A7"}.mdi-safe-square-outline::before{content:"\F02A8"}.mdi-safety-goggles::before{content:"\FD0C"}.mdi-sailing::before{content:"\FEE5"}.mdi-sale::before{content:"\F46F"}.mdi-salesforce::before{content:"\F88D"}.mdi-sass::before{content:"\F7EB"}.mdi-satellite::before{content:"\F470"}.mdi-satellite-uplink::before{content:"\F908"}.mdi-satellite-variant::before{content:"\F471"}.mdi-sausage::before{content:"\F8B9"}.mdi-saw-blade::before{content:"\FE44"}.mdi-saxophone::before{content:"\F609"}.mdi-scale::before{content:"\F472"}.mdi-scale-balance::before{content:"\F5D1"}.mdi-scale-bathroom::before{content:"\F473"}.mdi-scale-off::before{content:"\F007C"}.mdi-scanner::before{content:"\F6AA"}.mdi-scanner-off::before{content:"\F909"}.mdi-scatter-plot::before{content:"\FEE6"}.mdi-scatter-plot-outline::before{content:"\FEE7"}.mdi-school::before{content:"\F474"}.mdi-school-outline::before{content:"\F01AB"}.mdi-scissors-cutting::before{content:"\FA6A"}.mdi-scooter::before{content:"\F0214"}.mdi-scoreboard::before{content:"\F02A9"}.mdi-scoreboard-outline::before{content:"\F02AA"}.mdi-screen-rotation::before{content:"\F475"}.mdi-screen-rotation-lock::before{content:"\F476"}.mdi-screw-flat-top::before{content:"\FDCF"}.mdi-screw-lag::before{content:"\FE54"}.mdi-screw-machine-flat-top::before{content:"\FE55"}.mdi-screw-machine-round-top::before{content:"\FE56"}.mdi-screw-round-top::before{content:"\FE57"}.mdi-screwdriver::before{content:"\F477"}.mdi-script::before{content:"\FB9D"}.mdi-script-outline::before{content:"\F478"}.mdi-script-text::before{content:"\FB9E"}.mdi-script-text-outline::before{content:"\FB9F"}.mdi-sd::before{content:"\F479"}.mdi-seal::before{content:"\F47A"}.mdi-seal-variant::before{content:"\FFFA"}.mdi-search-web::before{content:"\F70E"}.mdi-seat::before{content:"\FC9F"}.mdi-seat-flat::before{content:"\F47B"}.mdi-seat-flat-angled::before{content:"\F47C"}.mdi-seat-individual-suite::before{content:"\F47D"}.mdi-seat-legroom-extra::before{content:"\F47E"}.mdi-seat-legroom-normal::before{content:"\F47F"}.mdi-seat-legroom-reduced::before{content:"\F480"}.mdi-seat-outline::before{content:"\FCA0"}.mdi-seat-passenger::before{content:"\F0274"}.mdi-seat-recline-extra::before{content:"\F481"}.mdi-seat-recline-normal::before{content:"\F482"}.mdi-seatbelt::before{content:"\FCA1"}.mdi-security::before{content:"\F483"}.mdi-security-network::before{content:"\F484"}.mdi-seed::before{content:"\FE45"}.mdi-seed-outline::before{content:"\FE46"}.mdi-segment::before{content:"\FEE8"}.mdi-select::before{content:"\F485"}.mdi-select-all::before{content:"\F486"}.mdi-select-color::before{content:"\FD0D"}.mdi-select-compare::before{content:"\FAD8"}.mdi-select-drag::before{content:"\FA6B"}.mdi-select-group::before{content:"\FF9F"}.mdi-select-inverse::before{content:"\F487"}.mdi-select-marker::before{content:"\F02AB"}.mdi-select-multiple::before{content:"\F02AC"}.mdi-select-multiple-marker::before{content:"\F02AD"}.mdi-select-off::before{content:"\F488"}.mdi-select-place::before{content:"\FFFB"}.mdi-select-search::before{content:"\F022F"}.mdi-selection::before{content:"\F489"}.mdi-selection-drag::before{content:"\FA6C"}.mdi-selection-ellipse::before{content:"\FD0E"}.mdi-selection-ellipse-arrow-inside::before{content:"\FF3F"}.mdi-selection-marker::before{content:"\F02AE"}.mdi-selection-multiple-marker::before{content:"\F02AF"}.mdi-selection-mutliple::before{content:"\F02B0"}.mdi-selection-off::before{content:"\F776"}.mdi-selection-search::before{content:"\F0230"}.mdi-semantic-web::before{content:"\F0341"}.mdi-send::before{content:"\F48A"}.mdi-send-check::before{content:"\F018C"}.mdi-send-check-outline::before{content:"\F018D"}.mdi-send-circle::before{content:"\FE58"}.mdi-send-circle-outline::before{content:"\FE59"}.mdi-send-clock::before{content:"\F018E"}.mdi-send-clock-outline::before{content:"\F018F"}.mdi-send-lock::before{content:"\F7EC"}.mdi-send-lock-outline::before{content:"\F0191"}.mdi-send-outline::before{content:"\F0190"}.mdi-serial-port::before{content:"\F65C"}.mdi-server::before{content:"\F48B"}.mdi-server-minus::before{content:"\F48C"}.mdi-server-network::before{content:"\F48D"}.mdi-server-network-off::before{content:"\F48E"}.mdi-server-off::before{content:"\F48F"}.mdi-server-plus::before{content:"\F490"}.mdi-server-remove::before{content:"\F491"}.mdi-server-security::before{content:"\F492"}.mdi-set-all::before{content:"\F777"}.mdi-set-center::before{content:"\F778"}.mdi-set-center-right::before{content:"\F779"}.mdi-set-left::before{content:"\F77A"}.mdi-set-left-center::before{content:"\F77B"}.mdi-set-left-right::before{content:"\F77C"}.mdi-set-none::before{content:"\F77D"}.mdi-set-right::before{content:"\F77E"}.mdi-set-top-box::before{content:"\F99E"}.mdi-settings::before{content:"\F493"}.mdi-settings-box::before{content:"\F494"}.mdi-settings-helper::before{content:"\FA6D"}.mdi-settings-outline::before{content:"\F8BA"}.mdi-settings-transfer::before{content:"\F007D"}.mdi-settings-transfer-outline::before{content:"\F007E"}.mdi-shaker::before{content:"\F0139"}.mdi-shaker-outline::before{content:"\F013A"}.mdi-shape::before{content:"\F830"}.mdi-shape-circle-plus::before{content:"\F65D"}.mdi-shape-outline::before{content:"\F831"}.mdi-shape-oval-plus::before{content:"\F0225"}.mdi-shape-plus::before{content:"\F495"}.mdi-shape-polygon-plus::before{content:"\F65E"}.mdi-shape-rectangle-plus::before{content:"\F65F"}.mdi-shape-square-plus::before{content:"\F660"}.mdi-share::before{content:"\F496"}.mdi-share-all::before{content:"\F021F"}.mdi-share-all-outline::before{content:"\F0220"}.mdi-share-circle::before{content:"\F01D8"}.mdi-share-off::before{content:"\FF40"}.mdi-share-off-outline::before{content:"\FF41"}.mdi-share-outline::before{content:"\F931"}.mdi-share-variant::before{content:"\F497"}.mdi-sheep::before{content:"\FCA2"}.mdi-shield::before{content:"\F498"}.mdi-shield-account::before{content:"\F88E"}.mdi-shield-account-outline::before{content:"\FA11"}.mdi-shield-airplane::before{content:"\F6BA"}.mdi-shield-airplane-outline::before{content:"\FCA3"}.mdi-shield-alert::before{content:"\FEE9"}.mdi-shield-alert-outline::before{content:"\FEEA"}.mdi-shield-car::before{content:"\FFA0"}.mdi-shield-check::before{content:"\F565"}.mdi-shield-check-outline::before{content:"\FCA4"}.mdi-shield-cross::before{content:"\FCA5"}.mdi-shield-cross-outline::before{content:"\FCA6"}.mdi-shield-edit::before{content:"\F01CB"}.mdi-shield-edit-outline::before{content:"\F01CC"}.mdi-shield-half::before{content:"\F038B"}.mdi-shield-half-full::before{content:"\F77F"}.mdi-shield-home::before{content:"\F689"}.mdi-shield-home-outline::before{content:"\FCA7"}.mdi-shield-key::before{content:"\FBA0"}.mdi-shield-key-outline::before{content:"\FBA1"}.mdi-shield-link-variant::before{content:"\FD0F"}.mdi-shield-link-variant-outline::before{content:"\FD10"}.mdi-shield-lock::before{content:"\F99C"}.mdi-shield-lock-outline::before{content:"\FCA8"}.mdi-shield-off::before{content:"\F99D"}.mdi-shield-off-outline::before{content:"\F99B"}.mdi-shield-outline::before{content:"\F499"}.mdi-shield-plus::before{content:"\FAD9"}.mdi-shield-plus-outline::before{content:"\FADA"}.mdi-shield-refresh::before{content:"\F01CD"}.mdi-shield-refresh-outline::before{content:"\F01CE"}.mdi-shield-remove::before{content:"\FADB"}.mdi-shield-remove-outline::before{content:"\FADC"}.mdi-shield-search::before{content:"\FD76"}.mdi-shield-star::before{content:"\F0166"}.mdi-shield-star-outline::before{content:"\F0167"}.mdi-shield-sun::before{content:"\F007F"}.mdi-shield-sun-outline::before{content:"\F0080"}.mdi-ship-wheel::before{content:"\F832"}.mdi-shoe-formal::before{content:"\FB22"}.mdi-shoe-heel::before{content:"\FB23"}.mdi-shoe-print::before{content:"\FE5A"}.mdi-shopify::before{content:"\FADD"}.mdi-shopping::before{content:"\F49A"}.mdi-shopping-music::before{content:"\F49B"}.mdi-shopping-outline::before{content:"\F0200"}.mdi-shopping-search::before{content:"\FFA1"}.mdi-shovel::before{content:"\F70F"}.mdi-shovel-off::before{content:"\F710"}.mdi-shower::before{content:"\F99F"}.mdi-shower-head::before{content:"\F9A0"}.mdi-shredder::before{content:"\F49C"}.mdi-shuffle::before{content:"\F49D"}.mdi-shuffle-disabled::before{content:"\F49E"}.mdi-shuffle-variant::before{content:"\F49F"}.mdi-shuriken::before{content:"\F03AA"}.mdi-sigma::before{content:"\F4A0"}.mdi-sigma-lower::before{content:"\F62B"}.mdi-sign-caution::before{content:"\F4A1"}.mdi-sign-direction::before{content:"\F780"}.mdi-sign-direction-minus::before{content:"\F0022"}.mdi-sign-direction-plus::before{content:"\FFFD"}.mdi-sign-direction-remove::before{content:"\FFFE"}.mdi-sign-real-estate::before{content:"\F0143"}.mdi-sign-text::before{content:"\F781"}.mdi-signal::before{content:"\F4A2"}.mdi-signal-2g::before{content:"\F711"}.mdi-signal-3g::before{content:"\F712"}.mdi-signal-4g::before{content:"\F713"}.mdi-signal-5g::before{content:"\FA6E"}.mdi-signal-cellular-1::before{content:"\F8BB"}.mdi-signal-cellular-2::before{content:"\F8BC"}.mdi-signal-cellular-3::before{content:"\F8BD"}.mdi-signal-cellular-outline::before{content:"\F8BE"}.mdi-signal-distance-variant::before{content:"\FE47"}.mdi-signal-hspa::before{content:"\F714"}.mdi-signal-hspa-plus::before{content:"\F715"}.mdi-signal-off::before{content:"\F782"}.mdi-signal-variant::before{content:"\F60A"}.mdi-signature::before{content:"\FE5B"}.mdi-signature-freehand::before{content:"\FE5C"}.mdi-signature-image::before{content:"\FE5D"}.mdi-signature-text::before{content:"\FE5E"}.mdi-silo::before{content:"\FB24"}.mdi-silverware::before{content:"\F4A3"}.mdi-silverware-clean::before{content:"\FFFF"}.mdi-silverware-fork::before{content:"\F4A4"}.mdi-silverware-fork-knife::before{content:"\FA6F"}.mdi-silverware-spoon::before{content:"\F4A5"}.mdi-silverware-variant::before{content:"\F4A6"}.mdi-sim::before{content:"\F4A7"}.mdi-sim-alert::before{content:"\F4A8"}.mdi-sim-off::before{content:"\F4A9"}.mdi-simple-icons::before{content:"\F0348"}.mdi-sina-weibo::before{content:"\FADE"}.mdi-sitemap::before{content:"\F4AA"}.mdi-skate::before{content:"\FD11"}.mdi-skew-less::before{content:"\FD12"}.mdi-skew-more::before{content:"\FD13"}.mdi-ski::before{content:"\F032F"}.mdi-ski-cross-country::before{content:"\F0330"}.mdi-ski-water::before{content:"\F0331"}.mdi-skip-backward::before{content:"\F4AB"}.mdi-skip-backward-outline::before{content:"\FF42"}.mdi-skip-forward::before{content:"\F4AC"}.mdi-skip-forward-outline::before{content:"\FF43"}.mdi-skip-next::before{content:"\F4AD"}.mdi-skip-next-circle::before{content:"\F661"}.mdi-skip-next-circle-outline::before{content:"\F662"}.mdi-skip-next-outline::before{content:"\FF44"}.mdi-skip-previous::before{content:"\F4AE"}.mdi-skip-previous-circle::before{content:"\F663"}.mdi-skip-previous-circle-outline::before{content:"\F664"}.mdi-skip-previous-outline::before{content:"\FF45"}.mdi-skull::before{content:"\F68B"}.mdi-skull-crossbones::before{content:"\FBA2"}.mdi-skull-crossbones-outline::before{content:"\FBA3"}.mdi-skull-outline::before{content:"\FBA4"}.mdi-skype::before{content:"\F4AF"}.mdi-skype-business::before{content:"\F4B0"}.mdi-slack::before{content:"\F4B1"}.mdi-slackware::before{content:"\F90A"}.mdi-slash-forward::before{content:"\F0000"}.mdi-slash-forward-box::before{content:"\F0001"}.mdi-sleep::before{content:"\F4B2"}.mdi-sleep-off::before{content:"\F4B3"}.mdi-slope-downhill::before{content:"\FE5F"}.mdi-slope-uphill::before{content:"\FE60"}.mdi-slot-machine::before{content:"\F013F"}.mdi-slot-machine-outline::before{content:"\F0140"}.mdi-smart-card::before{content:"\F00E8"}.mdi-smart-card-outline::before{content:"\F00E9"}.mdi-smart-card-reader::before{content:"\F00EA"}.mdi-smart-card-reader-outline::before{content:"\F00EB"}.mdi-smog::before{content:"\FA70"}.mdi-smoke-detector::before{content:"\F392"}.mdi-smoking::before{content:"\F4B4"}.mdi-smoking-off::before{content:"\F4B5"}.mdi-snapchat::before{content:"\F4B6"}.mdi-snowboard::before{content:"\F0332"}.mdi-snowflake::before{content:"\F716"}.mdi-snowflake-alert::before{content:"\FF46"}.mdi-snowflake-melt::before{content:"\F02F6"}.mdi-snowflake-variant::before{content:"\FF47"}.mdi-snowman::before{content:"\F4B7"}.mdi-soccer::before{content:"\F4B8"}.mdi-soccer-field::before{content:"\F833"}.mdi-sofa::before{content:"\F4B9"}.mdi-solar-panel::before{content:"\FD77"}.mdi-solar-panel-large::before{content:"\FD78"}.mdi-solar-power::before{content:"\FA71"}.mdi-soldering-iron::before{content:"\F00BD"}.mdi-solid::before{content:"\F68C"}.mdi-sort::before{content:"\F4BA"}.mdi-sort-alphabetical::before{content:"\F4BB"}.mdi-sort-alphabetical-ascending::before{content:"\F0173"}.mdi-sort-alphabetical-descending::before{content:"\F0174"}.mdi-sort-ascending::before{content:"\F4BC"}.mdi-sort-descending::before{content:"\F4BD"}.mdi-sort-numeric::before{content:"\F4BE"}.mdi-sort-variant::before{content:"\F4BF"}.mdi-sort-variant-lock::before{content:"\FCA9"}.mdi-sort-variant-lock-open::before{content:"\FCAA"}.mdi-sort-variant-remove::before{content:"\F0172"}.mdi-soundcloud::before{content:"\F4C0"}.mdi-source-branch::before{content:"\F62C"}.mdi-source-commit::before{content:"\F717"}.mdi-source-commit-end::before{content:"\F718"}.mdi-source-commit-end-local::before{content:"\F719"}.mdi-source-commit-local::before{content:"\F71A"}.mdi-source-commit-next-local::before{content:"\F71B"}.mdi-source-commit-start::before{content:"\F71C"}.mdi-source-commit-start-next-local::before{content:"\F71D"}.mdi-source-fork::before{content:"\F4C1"}.mdi-source-merge::before{content:"\F62D"}.mdi-source-pull::before{content:"\F4C2"}.mdi-source-repository::before{content:"\FCAB"}.mdi-source-repository-multiple::before{content:"\FCAC"}.mdi-soy-sauce::before{content:"\F7ED"}.mdi-spa::before{content:"\FCAD"}.mdi-spa-outline::before{content:"\FCAE"}.mdi-space-invaders::before{content:"\FBA5"}.mdi-space-station::before{content:"\F03AE"}.mdi-spade::before{content:"\FE48"}.mdi-speaker::before{content:"\F4C3"}.mdi-speaker-bluetooth::before{content:"\F9A1"}.mdi-speaker-multiple::before{content:"\FD14"}.mdi-speaker-off::before{content:"\F4C4"}.mdi-speaker-wireless::before{content:"\F71E"}.mdi-speedometer::before{content:"\F4C5"}.mdi-speedometer-medium::before{content:"\FFA2"}.mdi-speedometer-slow::before{content:"\FFA3"}.mdi-spellcheck::before{content:"\F4C6"}.mdi-spider::before{content:"\F0215"}.mdi-spider-thread::before{content:"\F0216"}.mdi-spider-web::before{content:"\FBA6"}.mdi-spotify::before{content:"\F4C7"}.mdi-spotlight::before{content:"\F4C8"}.mdi-spotlight-beam::before{content:"\F4C9"}.mdi-spray::before{content:"\F665"}.mdi-spray-bottle::before{content:"\FADF"}.mdi-sprinkler::before{content:"\F0081"}.mdi-sprinkler-variant::before{content:"\F0082"}.mdi-sprout::before{content:"\FE49"}.mdi-sprout-outline::before{content:"\FE4A"}.mdi-square::before{content:"\F763"}.mdi-square-edit-outline::before{content:"\F90B"}.mdi-square-inc::before{content:"\F4CA"}.mdi-square-inc-cash::before{content:"\F4CB"}.mdi-square-medium::before{content:"\FA12"}.mdi-square-medium-outline::before{content:"\FA13"}.mdi-square-off::before{content:"\F0319"}.mdi-square-off-outline::before{content:"\F031A"}.mdi-square-outline::before{content:"\F762"}.mdi-square-root::before{content:"\F783"}.mdi-square-root-box::before{content:"\F9A2"}.mdi-square-small::before{content:"\FA14"}.mdi-squeegee::before{content:"\FAE0"}.mdi-ssh::before{content:"\F8BF"}.mdi-stack-exchange::before{content:"\F60B"}.mdi-stack-overflow::before{content:"\F4CC"}.mdi-stackpath::before{content:"\F359"}.mdi-stadium::before{content:"\F001A"}.mdi-stadium-variant::before{content:"\F71F"}.mdi-stairs::before{content:"\F4CD"}.mdi-stairs-down::before{content:"\F02E9"}.mdi-stairs-up::before{content:"\F02E8"}.mdi-stamper::before{content:"\FD15"}.mdi-standard-definition::before{content:"\F7EE"}.mdi-star::before{content:"\F4CE"}.mdi-star-box::before{content:"\FA72"}.mdi-star-box-multiple::before{content:"\F02B1"}.mdi-star-box-multiple-outline::before{content:"\F02B2"}.mdi-star-box-outline::before{content:"\FA73"}.mdi-star-circle::before{content:"\F4CF"}.mdi-star-circle-outline::before{content:"\F9A3"}.mdi-star-face::before{content:"\F9A4"}.mdi-star-four-points::before{content:"\FAE1"}.mdi-star-four-points-outline::before{content:"\FAE2"}.mdi-star-half::before{content:"\F4D0"}.mdi-star-off::before{content:"\F4D1"}.mdi-star-outline::before{content:"\F4D2"}.mdi-star-three-points::before{content:"\FAE3"}.mdi-star-three-points-outline::before{content:"\FAE4"}.mdi-state-machine::before{content:"\F021A"}.mdi-steam::before{content:"\F4D3"}.mdi-steam-box::before{content:"\F90C"}.mdi-steering::before{content:"\F4D4"}.mdi-steering-off::before{content:"\F90D"}.mdi-step-backward::before{content:"\F4D5"}.mdi-step-backward-2::before{content:"\F4D6"}.mdi-step-forward::before{content:"\F4D7"}.mdi-step-forward-2::before{content:"\F4D8"}.mdi-stethoscope::before{content:"\F4D9"}.mdi-sticker::before{content:"\F038F"}.mdi-sticker-alert::before{content:"\F0390"}.mdi-sticker-alert-outline::before{content:"\F0391"}.mdi-sticker-check::before{content:"\F0392"}.mdi-sticker-check-outline::before{content:"\F0393"}.mdi-sticker-circle-outline::before{content:"\F5D0"}.mdi-sticker-emoji::before{content:"\F784"}.mdi-sticker-minus::before{content:"\F0394"}.mdi-sticker-minus-outline::before{content:"\F0395"}.mdi-sticker-outline::before{content:"\F0396"}.mdi-sticker-plus::before{content:"\F0397"}.mdi-sticker-plus-outline::before{content:"\F0398"}.mdi-sticker-remove::before{content:"\F0399"}.mdi-sticker-remove-outline::before{content:"\F039A"}.mdi-stocking::before{content:"\F4DA"}.mdi-stomach::before{content:"\F00BE"}.mdi-stop::before{content:"\F4DB"}.mdi-stop-circle::before{content:"\F666"}.mdi-stop-circle-outline::before{content:"\F667"}.mdi-store::before{content:"\F4DC"}.mdi-store-24-hour::before{content:"\F4DD"}.mdi-store-outline::before{content:"\F038C"}.mdi-storefront::before{content:"\F00EC"}.mdi-stove::before{content:"\F4DE"}.mdi-strategy::before{content:"\F0201"}.mdi-strava::before{content:"\FB25"}.mdi-stretch-to-page::before{content:"\FF48"}.mdi-stretch-to-page-outline::before{content:"\FF49"}.mdi-string-lights::before{content:"\F02E5"}.mdi-string-lights-off::before{content:"\F02E6"}.mdi-subdirectory-arrow-left::before{content:"\F60C"}.mdi-subdirectory-arrow-right::before{content:"\F60D"}.mdi-subtitles::before{content:"\FA15"}.mdi-subtitles-outline::before{content:"\FA16"}.mdi-subway::before{content:"\F6AB"}.mdi-subway-alert-variant::before{content:"\FD79"}.mdi-subway-variant::before{content:"\F4DF"}.mdi-summit::before{content:"\F785"}.mdi-sunglasses::before{content:"\F4E0"}.mdi-surround-sound::before{content:"\F5C5"}.mdi-surround-sound-2-0::before{content:"\F7EF"}.mdi-surround-sound-3-1::before{content:"\F7F0"}.mdi-surround-sound-5-1::before{content:"\F7F1"}.mdi-surround-sound-7-1::before{content:"\F7F2"}.mdi-svg::before{content:"\F720"}.mdi-swap-horizontal::before{content:"\F4E1"}.mdi-swap-horizontal-bold::before{content:"\FBA9"}.mdi-swap-horizontal-circle::before{content:"\F0002"}.mdi-swap-horizontal-circle-outline::before{content:"\F0003"}.mdi-swap-horizontal-variant::before{content:"\F8C0"}.mdi-swap-vertical::before{content:"\F4E2"}.mdi-swap-vertical-bold::before{content:"\FBAA"}.mdi-swap-vertical-circle::before{content:"\F0004"}.mdi-swap-vertical-circle-outline::before{content:"\F0005"}.mdi-swap-vertical-variant::before{content:"\F8C1"}.mdi-swim::before{content:"\F4E3"}.mdi-switch::before{content:"\F4E4"}.mdi-sword::before{content:"\F4E5"}.mdi-sword-cross::before{content:"\F786"}.mdi-syllabary-hangul::before{content:"\F035E"}.mdi-syllabary-hiragana::before{content:"\F035F"}.mdi-syllabary-katakana::before{content:"\F0360"}.mdi-syllabary-katakana-half-width::before{content:"\F0361"}.mdi-symfony::before{content:"\FAE5"}.mdi-sync::before{content:"\F4E6"}.mdi-sync-alert::before{content:"\F4E7"}.mdi-sync-circle::before{content:"\F03A3"}.mdi-sync-off::before{content:"\F4E8"}.mdi-tab::before{content:"\F4E9"}.mdi-tab-minus::before{content:"\FB26"}.mdi-tab-plus::before{content:"\F75B"}.mdi-tab-remove::before{content:"\FB27"}.mdi-tab-unselected::before{content:"\F4EA"}.mdi-table::before{content:"\F4EB"}.mdi-table-border::before{content:"\FA17"}.mdi-table-chair::before{content:"\F0083"}.mdi-table-column::before{content:"\F834"}.mdi-table-column-plus-after::before{content:"\F4EC"}.mdi-table-column-plus-before::before{content:"\F4ED"}.mdi-table-column-remove::before{content:"\F4EE"}.mdi-table-column-width::before{content:"\F4EF"}.mdi-table-edit::before{content:"\F4F0"}.mdi-table-eye::before{content:"\F00BF"}.mdi-table-headers-eye::before{content:"\F0248"}.mdi-table-headers-eye-off::before{content:"\F0249"}.mdi-table-large::before{content:"\F4F1"}.mdi-table-large-plus::before{content:"\FFA4"}.mdi-table-large-remove::before{content:"\FFA5"}.mdi-table-merge-cells::before{content:"\F9A5"}.mdi-table-of-contents::before{content:"\F835"}.mdi-table-plus::before{content:"\FA74"}.mdi-table-remove::before{content:"\FA75"}.mdi-table-row::before{content:"\F836"}.mdi-table-row-height::before{content:"\F4F2"}.mdi-table-row-plus-after::before{content:"\F4F3"}.mdi-table-row-plus-before::before{content:"\F4F4"}.mdi-table-row-remove::before{content:"\F4F5"}.mdi-table-search::before{content:"\F90E"}.mdi-table-settings::before{content:"\F837"}.mdi-table-tennis::before{content:"\FE4B"}.mdi-tablet::before{content:"\F4F6"}.mdi-tablet-android::before{content:"\F4F7"}.mdi-tablet-cellphone::before{content:"\F9A6"}.mdi-tablet-dashboard::before{content:"\FEEB"}.mdi-tablet-ipad::before{content:"\F4F8"}.mdi-taco::before{content:"\F761"}.mdi-tag::before{content:"\F4F9"}.mdi-tag-faces::before{content:"\F4FA"}.mdi-tag-heart::before{content:"\F68A"}.mdi-tag-heart-outline::before{content:"\FBAB"}.mdi-tag-minus::before{content:"\F90F"}.mdi-tag-minus-outline::before{content:"\F024A"}.mdi-tag-multiple::before{content:"\F4FB"}.mdi-tag-multiple-outline::before{content:"\F0322"}.mdi-tag-off::before{content:"\F024B"}.mdi-tag-off-outline::before{content:"\F024C"}.mdi-tag-outline::before{content:"\F4FC"}.mdi-tag-plus::before{content:"\F721"}.mdi-tag-plus-outline::before{content:"\F024D"}.mdi-tag-remove::before{content:"\F722"}.mdi-tag-remove-outline::before{content:"\F024E"}.mdi-tag-text::before{content:"\F024F"}.mdi-tag-text-outline::before{content:"\F4FD"}.mdi-tank::before{content:"\FD16"}.mdi-tanker-truck::before{content:"\F0006"}.mdi-tape-measure::before{content:"\FB28"}.mdi-target::before{content:"\F4FE"}.mdi-target-account::before{content:"\FBAC"}.mdi-target-variant::before{content:"\FA76"}.mdi-taxi::before{content:"\F4FF"}.mdi-tea::before{content:"\FD7A"}.mdi-tea-outline::before{content:"\FD7B"}.mdi-teach::before{content:"\F88F"}.mdi-teamviewer::before{content:"\F500"}.mdi-telegram::before{content:"\F501"}.mdi-telescope::before{content:"\FB29"}.mdi-television::before{content:"\F502"}.mdi-television-ambient-light::before{content:"\F0381"}.mdi-television-box::before{content:"\F838"}.mdi-television-classic::before{content:"\F7F3"}.mdi-television-classic-off::before{content:"\F839"}.mdi-television-clean::before{content:"\F013B"}.mdi-television-guide::before{content:"\F503"}.mdi-television-off::before{content:"\F83A"}.mdi-television-pause::before{content:"\FFA6"}.mdi-television-play::before{content:"\FEEC"}.mdi-television-stop::before{content:"\FFA7"}.mdi-temperature-celsius::before{content:"\F504"}.mdi-temperature-fahrenheit::before{content:"\F505"}.mdi-temperature-kelvin::before{content:"\F506"}.mdi-tennis::before{content:"\FD7C"}.mdi-tennis-ball::before{content:"\F507"}.mdi-tent::before{content:"\F508"}.mdi-terraform::before{content:"\F0084"}.mdi-terrain::before{content:"\F509"}.mdi-test-tube::before{content:"\F668"}.mdi-test-tube-empty::before{content:"\F910"}.mdi-test-tube-off::before{content:"\F911"}.mdi-text::before{content:"\F9A7"}.mdi-text-recognition::before{content:"\F0168"}.mdi-text-shadow::before{content:"\F669"}.mdi-text-short::before{content:"\F9A8"}.mdi-text-subject::before{content:"\F9A9"}.mdi-text-to-speech::before{content:"\F50A"}.mdi-text-to-speech-off::before{content:"\F50B"}.mdi-textarea::before{content:"\F00C0"}.mdi-textbox::before{content:"\F60E"}.mdi-textbox-lock::before{content:"\F0388"}.mdi-textbox-password::before{content:"\F7F4"}.mdi-texture::before{content:"\F50C"}.mdi-texture-box::before{content:"\F0007"}.mdi-theater::before{content:"\F50D"}.mdi-theme-light-dark::before{content:"\F50E"}.mdi-thermometer::before{content:"\F50F"}.mdi-thermometer-alert::before{content:"\FE61"}.mdi-thermometer-chevron-down::before{content:"\FE62"}.mdi-thermometer-chevron-up::before{content:"\FE63"}.mdi-thermometer-high::before{content:"\F00ED"}.mdi-thermometer-lines::before{content:"\F510"}.mdi-thermometer-low::before{content:"\F00EE"}.mdi-thermometer-minus::before{content:"\FE64"}.mdi-thermometer-plus::before{content:"\FE65"}.mdi-thermostat::before{content:"\F393"}.mdi-thermostat-box::before{content:"\F890"}.mdi-thought-bubble::before{content:"\F7F5"}.mdi-thought-bubble-outline::before{content:"\F7F6"}.mdi-thumb-down::before{content:"\F511"}.mdi-thumb-down-outline::before{content:"\F512"}.mdi-thumb-up::before{content:"\F513"}.mdi-thumb-up-outline::before{content:"\F514"}.mdi-thumbs-up-down::before{content:"\F515"}.mdi-ticket::before{content:"\F516"}.mdi-ticket-account::before{content:"\F517"}.mdi-ticket-confirmation::before{content:"\F518"}.mdi-ticket-outline::before{content:"\F912"}.mdi-ticket-percent::before{content:"\F723"}.mdi-tie::before{content:"\F519"}.mdi-tilde::before{content:"\F724"}.mdi-timelapse::before{content:"\F51A"}.mdi-timeline::before{content:"\FBAD"}.mdi-timeline-alert::before{content:"\FFB2"}.mdi-timeline-alert-outline::before{content:"\FFB5"}.mdi-timeline-clock::before{content:"\F0226"}.mdi-timeline-clock-outline::before{content:"\F0227"}.mdi-timeline-help::before{content:"\FFB6"}.mdi-timeline-help-outline::before{content:"\FFB7"}.mdi-timeline-outline::before{content:"\FBAE"}.mdi-timeline-plus::before{content:"\FFB3"}.mdi-timeline-plus-outline::before{content:"\FFB4"}.mdi-timeline-text::before{content:"\FBAF"}.mdi-timeline-text-outline::before{content:"\FBB0"}.mdi-timer::before{content:"\F51B"}.mdi-timer-10::before{content:"\F51C"}.mdi-timer-3::before{content:"\F51D"}.mdi-timer-off::before{content:"\F51E"}.mdi-timer-sand::before{content:"\F51F"}.mdi-timer-sand-empty::before{content:"\F6AC"}.mdi-timer-sand-full::before{content:"\F78B"}.mdi-timetable::before{content:"\F520"}.mdi-toaster::before{content:"\F0085"}.mdi-toaster-off::before{content:"\F01E2"}.mdi-toaster-oven::before{content:"\FCAF"}.mdi-toggle-switch::before{content:"\F521"}.mdi-toggle-switch-off::before{content:"\F522"}.mdi-toggle-switch-off-outline::before{content:"\FA18"}.mdi-toggle-switch-outline::before{content:"\FA19"}.mdi-toilet::before{content:"\F9AA"}.mdi-toolbox::before{content:"\F9AB"}.mdi-toolbox-outline::before{content:"\F9AC"}.mdi-tools::before{content:"\F0086"}.mdi-tooltip::before{content:"\F523"}.mdi-tooltip-account::before{content:"\F00C"}.mdi-tooltip-edit::before{content:"\F524"}.mdi-tooltip-edit-outline::before{content:"\F02F0"}.mdi-tooltip-image::before{content:"\F525"}.mdi-tooltip-image-outline::before{content:"\FBB1"}.mdi-tooltip-outline::before{content:"\F526"}.mdi-tooltip-plus::before{content:"\FBB2"}.mdi-tooltip-plus-outline::before{content:"\F527"}.mdi-tooltip-text::before{content:"\F528"}.mdi-tooltip-text-outline::before{content:"\FBB3"}.mdi-tooth::before{content:"\F8C2"}.mdi-tooth-outline::before{content:"\F529"}.mdi-toothbrush::before{content:"\F0154"}.mdi-toothbrush-electric::before{content:"\F0157"}.mdi-toothbrush-paste::before{content:"\F0155"}.mdi-tor::before{content:"\F52A"}.mdi-tortoise::before{content:"\FD17"}.mdi-toslink::before{content:"\F02E3"}.mdi-tournament::before{content:"\F9AD"}.mdi-tower-beach::before{content:"\F680"}.mdi-tower-fire::before{content:"\F681"}.mdi-towing::before{content:"\F83B"}.mdi-toy-brick::before{content:"\F02B3"}.mdi-toy-brick-marker::before{content:"\F02B4"}.mdi-toy-brick-marker-outline::before{content:"\F02B5"}.mdi-toy-brick-minus::before{content:"\F02B6"}.mdi-toy-brick-minus-outline::before{content:"\F02B7"}.mdi-toy-brick-outline::before{content:"\F02B8"}.mdi-toy-brick-plus::before{content:"\F02B9"}.mdi-toy-brick-plus-outline::before{content:"\F02BA"}.mdi-toy-brick-remove::before{content:"\F02BB"}.mdi-toy-brick-remove-outline::before{content:"\F02BC"}.mdi-toy-brick-search::before{content:"\F02BD"}.mdi-toy-brick-search-outline::before{content:"\F02BE"}.mdi-track-light::before{content:"\F913"}.mdi-trackpad::before{content:"\F7F7"}.mdi-trackpad-lock::before{content:"\F932"}.mdi-tractor::before{content:"\F891"}.mdi-trademark::before{content:"\FA77"}.mdi-traffic-cone::before{content:"\F03A7"}.mdi-traffic-light::before{content:"\F52B"}.mdi-train::before{content:"\F52C"}.mdi-train-car::before{content:"\FBB4"}.mdi-train-variant::before{content:"\F8C3"}.mdi-tram::before{content:"\F52D"}.mdi-tram-side::before{content:"\F0008"}.mdi-transcribe::before{content:"\F52E"}.mdi-transcribe-close::before{content:"\F52F"}.mdi-transfer::before{content:"\F0087"}.mdi-transfer-down::before{content:"\FD7D"}.mdi-transfer-left::before{content:"\FD7E"}.mdi-transfer-right::before{content:"\F530"}.mdi-transfer-up::before{content:"\FD7F"}.mdi-transit-connection::before{content:"\FD18"}.mdi-transit-connection-variant::before{content:"\FD19"}.mdi-transit-detour::before{content:"\FFA8"}.mdi-transit-transfer::before{content:"\F6AD"}.mdi-transition::before{content:"\F914"}.mdi-transition-masked::before{content:"\F915"}.mdi-translate::before{content:"\F5CA"}.mdi-translate-off::before{content:"\FE66"}.mdi-transmission-tower::before{content:"\FD1A"}.mdi-trash-can::before{content:"\FA78"}.mdi-trash-can-outline::before{content:"\FA79"}.mdi-tray::before{content:"\F02BF"}.mdi-tray-alert::before{content:"\F02C0"}.mdi-tray-full::before{content:"\F02C1"}.mdi-tray-minus::before{content:"\F02C2"}.mdi-tray-plus::before{content:"\F02C3"}.mdi-tray-remove::before{content:"\F02C4"}.mdi-treasure-chest::before{content:"\F725"}.mdi-tree::before{content:"\F531"}.mdi-tree-outline::before{content:"\FE4C"}.mdi-trello::before{content:"\F532"}.mdi-trending-down::before{content:"\F533"}.mdi-trending-neutral::before{content:"\F534"}.mdi-trending-up::before{content:"\F535"}.mdi-triangle::before{content:"\F536"}.mdi-triangle-outline::before{content:"\F537"}.mdi-triforce::before{content:"\FBB5"}.mdi-trophy::before{content:"\F538"}.mdi-trophy-award::before{content:"\F539"}.mdi-trophy-broken::before{content:"\FD80"}.mdi-trophy-outline::before{content:"\F53A"}.mdi-trophy-variant::before{content:"\F53B"}.mdi-trophy-variant-outline::before{content:"\F53C"}.mdi-truck::before{content:"\F53D"}.mdi-truck-check::before{content:"\FCB0"}.mdi-truck-check-outline::before{content:"\F02C5"}.mdi-truck-delivery::before{content:"\F53E"}.mdi-truck-delivery-outline::before{content:"\F02C6"}.mdi-truck-fast::before{content:"\F787"}.mdi-truck-fast-outline::before{content:"\F02C7"}.mdi-truck-outline::before{content:"\F02C8"}.mdi-truck-trailer::before{content:"\F726"}.mdi-trumpet::before{content:"\F00C1"}.mdi-tshirt-crew::before{content:"\FA7A"}.mdi-tshirt-crew-outline::before{content:"\F53F"}.mdi-tshirt-v::before{content:"\FA7B"}.mdi-tshirt-v-outline::before{content:"\F540"}.mdi-tumble-dryer::before{content:"\F916"}.mdi-tumble-dryer-alert::before{content:"\F01E5"}.mdi-tumble-dryer-off::before{content:"\F01E6"}.mdi-tumblr::before{content:"\F541"}.mdi-tumblr-box::before{content:"\F917"}.mdi-tumblr-reblog::before{content:"\F542"}.mdi-tune::before{content:"\F62E"}.mdi-tune-vertical::before{content:"\F66A"}.mdi-turnstile::before{content:"\FCB1"}.mdi-turnstile-outline::before{content:"\FCB2"}.mdi-turtle::before{content:"\FCB3"}.mdi-twitch::before{content:"\F543"}.mdi-twitter::before{content:"\F544"}.mdi-twitter-box::before{content:"\F545"}.mdi-twitter-circle::before{content:"\F546"}.mdi-twitter-retweet::before{content:"\F547"}.mdi-two-factor-authentication::before{content:"\F9AE"}.mdi-typewriter::before{content:"\FF4A"}.mdi-uber::before{content:"\F748"}.mdi-ubisoft::before{content:"\FBB6"}.mdi-ubuntu::before{content:"\F548"}.mdi-ufo::before{content:"\F00EF"}.mdi-ufo-outline::before{content:"\F00F0"}.mdi-ultra-high-definition::before{content:"\F7F8"}.mdi-umbraco::before{content:"\F549"}.mdi-umbrella::before{content:"\F54A"}.mdi-umbrella-closed::before{content:"\F9AF"}.mdi-umbrella-outline::before{content:"\F54B"}.mdi-undo::before{content:"\F54C"}.mdi-undo-variant::before{content:"\F54D"}.mdi-unfold-less-horizontal::before{content:"\F54E"}.mdi-unfold-less-vertical::before{content:"\F75F"}.mdi-unfold-more-horizontal::before{content:"\F54F"}.mdi-unfold-more-vertical::before{content:"\F760"}.mdi-ungroup::before{content:"\F550"}.mdi-unicode::before{content:"\FEED"}.mdi-unity::before{content:"\F6AE"}.mdi-unreal::before{content:"\F9B0"}.mdi-untappd::before{content:"\F551"}.mdi-update::before{content:"\F6AF"}.mdi-upload::before{content:"\F552"}.mdi-upload-lock::before{content:"\F039E"}.mdi-upload-lock-outline::before{content:"\F039F"}.mdi-upload-multiple::before{content:"\F83C"}.mdi-upload-network::before{content:"\F6F5"}.mdi-upload-network-outline::before{content:"\FCB4"}.mdi-upload-off::before{content:"\F00F1"}.mdi-upload-off-outline::before{content:"\F00F2"}.mdi-upload-outline::before{content:"\FE67"}.mdi-usb::before{content:"\F553"}.mdi-usb-flash-drive::before{content:"\F02C9"}.mdi-usb-flash-drive-outline::before{content:"\F02CA"}.mdi-usb-port::before{content:"\F021B"}.mdi-valve::before{content:"\F0088"}.mdi-valve-closed::before{content:"\F0089"}.mdi-valve-open::before{content:"\F008A"}.mdi-van-passenger::before{content:"\F7F9"}.mdi-van-utility::before{content:"\F7FA"}.mdi-vanish::before{content:"\F7FB"}.mdi-vanity-light::before{content:"\F020C"}.mdi-variable::before{content:"\FAE6"}.mdi-variable-box::before{content:"\F013C"}.mdi-vector-arrange-above::before{content:"\F554"}.mdi-vector-arrange-below::before{content:"\F555"}.mdi-vector-bezier::before{content:"\FAE7"}.mdi-vector-circle::before{content:"\F556"}.mdi-vector-circle-variant::before{content:"\F557"}.mdi-vector-combine::before{content:"\F558"}.mdi-vector-curve::before{content:"\F559"}.mdi-vector-difference::before{content:"\F55A"}.mdi-vector-difference-ab::before{content:"\F55B"}.mdi-vector-difference-ba::before{content:"\F55C"}.mdi-vector-ellipse::before{content:"\F892"}.mdi-vector-intersection::before{content:"\F55D"}.mdi-vector-line::before{content:"\F55E"}.mdi-vector-link::before{content:"\F0009"}.mdi-vector-point::before{content:"\F55F"}.mdi-vector-polygon::before{content:"\F560"}.mdi-vector-polyline::before{content:"\F561"}.mdi-vector-polyline-edit::before{content:"\F0250"}.mdi-vector-polyline-minus::before{content:"\F0251"}.mdi-vector-polyline-plus::before{content:"\F0252"}.mdi-vector-polyline-remove::before{content:"\F0253"}.mdi-vector-radius::before{content:"\F749"}.mdi-vector-rectangle::before{content:"\F5C6"}.mdi-vector-selection::before{content:"\F562"}.mdi-vector-square::before{content:"\F001"}.mdi-vector-triangle::before{content:"\F563"}.mdi-vector-union::before{content:"\F564"}.mdi-venmo::before{content:"\F578"}.mdi-vhs::before{content:"\FA1A"}.mdi-vibrate::before{content:"\F566"}.mdi-vibrate-off::before{content:"\FCB5"}.mdi-video::before{content:"\F567"}.mdi-video-3d::before{content:"\F7FC"}.mdi-video-3d-variant::before{content:"\FEEE"}.mdi-video-4k-box::before{content:"\F83D"}.mdi-video-account::before{content:"\F918"}.mdi-video-check::before{content:"\F008B"}.mdi-video-check-outline::before{content:"\F008C"}.mdi-video-image::before{content:"\F919"}.mdi-video-input-antenna::before{content:"\F83E"}.mdi-video-input-component::before{content:"\F83F"}.mdi-video-input-hdmi::before{content:"\F840"}.mdi-video-input-scart::before{content:"\FFA9"}.mdi-video-input-svideo::before{content:"\F841"}.mdi-video-minus::before{content:"\F9B1"}.mdi-video-off::before{content:"\F568"}.mdi-video-off-outline::before{content:"\FBB7"}.mdi-video-outline::before{content:"\FBB8"}.mdi-video-plus::before{content:"\F9B2"}.mdi-video-stabilization::before{content:"\F91A"}.mdi-video-switch::before{content:"\F569"}.mdi-video-vintage::before{content:"\FA1B"}.mdi-video-wireless::before{content:"\FEEF"}.mdi-video-wireless-outline::before{content:"\FEF0"}.mdi-view-agenda::before{content:"\F56A"}.mdi-view-agenda-outline::before{content:"\F0203"}.mdi-view-array::before{content:"\F56B"}.mdi-view-carousel::before{content:"\F56C"}.mdi-view-column::before{content:"\F56D"}.mdi-view-comfy::before{content:"\FE4D"}.mdi-view-compact::before{content:"\FE4E"}.mdi-view-compact-outline::before{content:"\FE4F"}.mdi-view-dashboard::before{content:"\F56E"}.mdi-view-dashboard-outline::before{content:"\FA1C"}.mdi-view-dashboard-variant::before{content:"\F842"}.mdi-view-day::before{content:"\F56F"}.mdi-view-grid::before{content:"\F570"}.mdi-view-grid-outline::before{content:"\F0204"}.mdi-view-grid-plus::before{content:"\FFAA"}.mdi-view-grid-plus-outline::before{content:"\F0205"}.mdi-view-headline::before{content:"\F571"}.mdi-view-list::before{content:"\F572"}.mdi-view-module::before{content:"\F573"}.mdi-view-parallel::before{content:"\F727"}.mdi-view-quilt::before{content:"\F574"}.mdi-view-sequential::before{content:"\F728"}.mdi-view-split-horizontal::before{content:"\FBA7"}.mdi-view-split-vertical::before{content:"\FBA8"}.mdi-view-stream::before{content:"\F575"}.mdi-view-week::before{content:"\F576"}.mdi-vimeo::before{content:"\F577"}.mdi-violin::before{content:"\F60F"}.mdi-virtual-reality::before{content:"\F893"}.mdi-visual-studio::before{content:"\F610"}.mdi-visual-studio-code::before{content:"\FA1D"}.mdi-vk::before{content:"\F579"}.mdi-vk-box::before{content:"\F57A"}.mdi-vk-circle::before{content:"\F57B"}.mdi-vlc::before{content:"\F57C"}.mdi-voice::before{content:"\F5CB"}.mdi-voice-off::before{content:"\FEF1"}.mdi-voicemail::before{content:"\F57D"}.mdi-volleyball::before{content:"\F9B3"}.mdi-volume-high::before{content:"\F57E"}.mdi-volume-low::before{content:"\F57F"}.mdi-volume-medium::before{content:"\F580"}.mdi-volume-minus::before{content:"\F75D"}.mdi-volume-mute::before{content:"\F75E"}.mdi-volume-off::before{content:"\F581"}.mdi-volume-plus::before{content:"\F75C"}.mdi-volume-source::before{content:"\F014B"}.mdi-volume-variant-off::before{content:"\FE68"}.mdi-volume-vibrate::before{content:"\F014C"}.mdi-vote::before{content:"\FA1E"}.mdi-vote-outline::before{content:"\FA1F"}.mdi-vpn::before{content:"\F582"}.mdi-vuejs::before{content:"\F843"}.mdi-vuetify::before{content:"\FE50"}.mdi-walk::before{content:"\F583"}.mdi-wall::before{content:"\F7FD"}.mdi-wall-sconce::before{content:"\F91B"}.mdi-wall-sconce-flat::before{content:"\F91C"}.mdi-wall-sconce-variant::before{content:"\F91D"}.mdi-wallet::before{content:"\F584"}.mdi-wallet-giftcard::before{content:"\F585"}.mdi-wallet-membership::before{content:"\F586"}.mdi-wallet-outline::before{content:"\FBB9"}.mdi-wallet-plus::before{content:"\FFAB"}.mdi-wallet-plus-outline::before{content:"\FFAC"}.mdi-wallet-travel::before{content:"\F587"}.mdi-wallpaper::before{content:"\FE69"}.mdi-wan::before{content:"\F588"}.mdi-wardrobe::before{content:"\FFAD"}.mdi-wardrobe-outline::before{content:"\FFAE"}.mdi-warehouse::before{content:"\FFBB"}.mdi-washing-machine::before{content:"\F729"}.mdi-washing-machine-alert::before{content:"\F01E7"}.mdi-washing-machine-off::before{content:"\F01E8"}.mdi-watch::before{content:"\F589"}.mdi-watch-export::before{content:"\F58A"}.mdi-watch-export-variant::before{content:"\F894"}.mdi-watch-import::before{content:"\F58B"}.mdi-watch-import-variant::before{content:"\F895"}.mdi-watch-variant::before{content:"\F896"}.mdi-watch-vibrate::before{content:"\F6B0"}.mdi-watch-vibrate-off::before{content:"\FCB6"}.mdi-water::before{content:"\F58C"}.mdi-water-boiler::before{content:"\FFAF"}.mdi-water-boiler-alert::before{content:"\F01DE"}.mdi-water-boiler-off::before{content:"\F01DF"}.mdi-water-off::before{content:"\F58D"}.mdi-water-outline::before{content:"\FE6A"}.mdi-water-percent::before{content:"\F58E"}.mdi-water-polo::before{content:"\F02CB"}.mdi-water-pump::before{content:"\F58F"}.mdi-water-pump-off::before{content:"\FFB0"}.mdi-water-well::before{content:"\F008D"}.mdi-water-well-outline::before{content:"\F008E"}.mdi-watermark::before{content:"\F612"}.mdi-wave::before{content:"\FF4B"}.mdi-waves::before{content:"\F78C"}.mdi-waze::before{content:"\FBBA"}.mdi-weather-cloudy::before{content:"\F590"}.mdi-weather-cloudy-alert::before{content:"\FF4C"}.mdi-weather-cloudy-arrow-right::before{content:"\FE51"}.mdi-weather-fog::before{content:"\F591"}.mdi-weather-hail::before{content:"\F592"}.mdi-weather-hazy::before{content:"\FF4D"}.mdi-weather-hurricane::before{content:"\F897"}.mdi-weather-lightning::before{content:"\F593"}.mdi-weather-lightning-rainy::before{content:"\F67D"}.mdi-weather-night::before{content:"\F594"}.mdi-weather-night-partly-cloudy::before{content:"\FF4E"}.mdi-weather-partly-cloudy::before{content:"\F595"}.mdi-weather-partly-lightning::before{content:"\FF4F"}.mdi-weather-partly-rainy::before{content:"\FF50"}.mdi-weather-partly-snowy::before{content:"\FF51"}.mdi-weather-partly-snowy-rainy::before{content:"\FF52"}.mdi-weather-pouring::before{content:"\F596"}.mdi-weather-rainy::before{content:"\F597"}.mdi-weather-snowy::before{content:"\F598"}.mdi-weather-snowy-heavy::before{content:"\FF53"}.mdi-weather-snowy-rainy::before{content:"\F67E"}.mdi-weather-sunny::before{content:"\F599"}.mdi-weather-sunny-alert::before{content:"\FF54"}.mdi-weather-sunset::before{content:"\F59A"}.mdi-weather-sunset-down::before{content:"\F59B"}.mdi-weather-sunset-up::before{content:"\F59C"}.mdi-weather-tornado::before{content:"\FF55"}.mdi-weather-windy::before{content:"\F59D"}.mdi-weather-windy-variant::before{content:"\F59E"}.mdi-web::before{content:"\F59F"}.mdi-web-box::before{content:"\FFB1"}.mdi-web-clock::before{content:"\F0275"}.mdi-webcam::before{content:"\F5A0"}.mdi-webhook::before{content:"\F62F"}.mdi-webpack::before{content:"\F72A"}.mdi-webrtc::before{content:"\F0273"}.mdi-wechat::before{content:"\F611"}.mdi-weight::before{content:"\F5A1"}.mdi-weight-gram::before{content:"\FD1B"}.mdi-weight-kilogram::before{content:"\F5A2"}.mdi-weight-lifter::before{content:"\F0188"}.mdi-weight-pound::before{content:"\F9B4"}.mdi-whatsapp::before{content:"\F5A3"}.mdi-wheelchair-accessibility::before{content:"\F5A4"}.mdi-whistle::before{content:"\F9B5"}.mdi-whistle-outline::before{content:"\F02E7"}.mdi-white-balance-auto::before{content:"\F5A5"}.mdi-white-balance-incandescent::before{content:"\F5A6"}.mdi-white-balance-iridescent::before{content:"\F5A7"}.mdi-white-balance-sunny::before{content:"\F5A8"}.mdi-widgets::before{content:"\F72B"}.mdi-widgets-outline::before{content:"\F0380"}.mdi-wifi::before{content:"\F5A9"}.mdi-wifi-off::before{content:"\F5AA"}.mdi-wifi-star::before{content:"\FE6B"}.mdi-wifi-strength-1::before{content:"\F91E"}.mdi-wifi-strength-1-alert::before{content:"\F91F"}.mdi-wifi-strength-1-lock::before{content:"\F920"}.mdi-wifi-strength-2::before{content:"\F921"}.mdi-wifi-strength-2-alert::before{content:"\F922"}.mdi-wifi-strength-2-lock::before{content:"\F923"}.mdi-wifi-strength-3::before{content:"\F924"}.mdi-wifi-strength-3-alert::before{content:"\F925"}.mdi-wifi-strength-3-lock::before{content:"\F926"}.mdi-wifi-strength-4::before{content:"\F927"}.mdi-wifi-strength-4-alert::before{content:"\F928"}.mdi-wifi-strength-4-lock::before{content:"\F929"}.mdi-wifi-strength-alert-outline::before{content:"\F92A"}.mdi-wifi-strength-lock-outline::before{content:"\F92B"}.mdi-wifi-strength-off::before{content:"\F92C"}.mdi-wifi-strength-off-outline::before{content:"\F92D"}.mdi-wifi-strength-outline::before{content:"\F92E"}.mdi-wii::before{content:"\F5AB"}.mdi-wiiu::before{content:"\F72C"}.mdi-wikipedia::before{content:"\F5AC"}.mdi-wind-turbine::before{content:"\FD81"}.mdi-window-close::before{content:"\F5AD"}.mdi-window-closed::before{content:"\F5AE"}.mdi-window-closed-variant::before{content:"\F0206"}.mdi-window-maximize::before{content:"\F5AF"}.mdi-window-minimize::before{content:"\F5B0"}.mdi-window-open::before{content:"\F5B1"}.mdi-window-open-variant::before{content:"\F0207"}.mdi-window-restore::before{content:"\F5B2"}.mdi-window-shutter::before{content:"\F0147"}.mdi-window-shutter-alert::before{content:"\F0148"}.mdi-window-shutter-open::before{content:"\F0149"}.mdi-windows::before{content:"\F5B3"}.mdi-windows-classic::before{content:"\FA20"}.mdi-wiper::before{content:"\FAE8"}.mdi-wiper-wash::before{content:"\FD82"}.mdi-wordpress::before{content:"\F5B4"}.mdi-worker::before{content:"\F5B5"}.mdi-wrap::before{content:"\F5B6"}.mdi-wrap-disabled::before{content:"\FBBB"}.mdi-wrench::before{content:"\F5B7"}.mdi-wrench-outline::before{content:"\FBBC"}.mdi-wunderlist::before{content:"\F5B8"}.mdi-xamarin::before{content:"\F844"}.mdi-xamarin-outline::before{content:"\F845"}.mdi-xaml::before{content:"\F673"}.mdi-xbox::before{content:"\F5B9"}.mdi-xbox-controller::before{content:"\F5BA"}.mdi-xbox-controller-battery-alert::before{content:"\F74A"}.mdi-xbox-controller-battery-charging::before{content:"\FA21"}.mdi-xbox-controller-battery-empty::before{content:"\F74B"}.mdi-xbox-controller-battery-full::before{content:"\F74C"}.mdi-xbox-controller-battery-low::before{content:"\F74D"}.mdi-xbox-controller-battery-medium::before{content:"\F74E"}.mdi-xbox-controller-battery-unknown::before{content:"\F74F"}.mdi-xbox-controller-menu::before{content:"\FE52"}.mdi-xbox-controller-off::before{content:"\F5BB"}.mdi-xbox-controller-view::before{content:"\FE53"}.mdi-xda::before{content:"\F5BC"}.mdi-xing::before{content:"\F5BD"}.mdi-xing-box::before{content:"\F5BE"}.mdi-xing-circle::before{content:"\F5BF"}.mdi-xml::before{content:"\F5C0"}.mdi-xmpp::before{content:"\F7FE"}.mdi-yahoo::before{content:"\FB2A"}.mdi-yammer::before{content:"\F788"}.mdi-yeast::before{content:"\F5C1"}.mdi-yelp::before{content:"\F5C2"}.mdi-yin-yang::before{content:"\F67F"}.mdi-yoga::before{content:"\F01A7"}.mdi-youtube::before{content:"\F5C3"}.mdi-youtube-creator-studio::before{content:"\F846"}.mdi-youtube-gaming::before{content:"\F847"}.mdi-youtube-subscription::before{content:"\FD1C"}.mdi-youtube-tv::before{content:"\F448"}.mdi-z-wave::before{content:"\FAE9"}.mdi-zend::before{content:"\FAEA"}.mdi-zigbee::before{content:"\FD1D"}.mdi-zip-box::before{content:"\F5C4"}.mdi-zip-box-outline::before{content:"\F001B"}.mdi-zip-disk::before{content:"\FA22"}.mdi-zodiac-aquarius::before{content:"\FA7C"}.mdi-zodiac-aries::before{content:"\FA7D"}.mdi-zodiac-cancer::before{content:"\FA7E"}.mdi-zodiac-capricorn::before{content:"\FA7F"}.mdi-zodiac-gemini::before{content:"\FA80"}.mdi-zodiac-leo::before{content:"\FA81"}.mdi-zodiac-libra::before{content:"\FA82"}.mdi-zodiac-pisces::before{content:"\FA83"}.mdi-zodiac-sagittarius::before{content:"\FA84"}.mdi-zodiac-scorpio::before{content:"\FA85"}.mdi-zodiac-taurus::before{content:"\FA86"}.mdi-zodiac-virgo::before{content:"\FA87"}.mdi-blank::before{content:"\F68C";visibility:hidden}.mdi-18px.mdi-set,.mdi-18px.mdi:before{font-size:18px}.mdi-24px.mdi-set,.mdi-24px.mdi:before{font-size:24px}.mdi-36px.mdi-set,.mdi-36px.mdi:before{font-size:36px}.mdi-48px.mdi-set,.mdi-48px.mdi:before{font-size:48px}.mdi-dark:before{color:rgba(0,0,0,0.54)}.mdi-dark.mdi-inactive:before{color:rgba(0,0,0,0.26)}.mdi-light:before{color:#fff}.mdi-light.mdi-inactive:before{color:rgba(255,255,255,0.3)}.mdi-rotate-45:before{-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.mdi-rotate-90:before{-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.mdi-rotate-135:before{-webkit-transform:rotate(135deg);-ms-transform:rotate(135deg);transform:rotate(135deg)}.mdi-rotate-180:before{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.mdi-rotate-225:before{-webkit-transform:rotate(225deg);-ms-transform:rotate(225deg);transform:rotate(225deg)}.mdi-rotate-270:before{-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.mdi-rotate-315:before{-webkit-transform:rotate(315deg);-ms-transform:rotate(315deg);transform:rotate(315deg)}.mdi-flip-h:before{-webkit-transform:scaleX(-1);transform:scaleX(-1);filter:FlipH;-ms-filter:"FlipH"}.mdi-flip-v:before{-webkit-transform:scaleY(-1);transform:scaleY(-1);filter:FlipV;-ms-filter:"FlipV"}.mdi-spin:before{-webkit-animation:mdi-spin 2s infinite linear;animation:mdi-spin 2s infinite linear}@-webkit-keyframes mdi-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes mdi-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}
+@font-face {
+ font-family: "Material Design Icons";
+ src: url("./fonts/materialdesignicons-webfont-4.9.95.eot");
+ src: url("./fonts/materialdesignicons-webfont-4.9.95.woff2") format("woff2"),
+ url("./fonts/materialdesignicons-webfont-4.9.95.woff") format("woff"),
+ url("./fonts/materialdesignicons-webfont-4.9.95.ttf") format("truetype");
+ font-weight: normal;
+ font-style: normal;
+}
+.mdi:before,
+.mdi-set {
+ display: inline-block;
+ font: normal normal normal 24px/1 "Material Design Icons";
+ font-size: inherit;
+ text-rendering: auto;
+ line-height: inherit;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+.mdi-ab-testing::before {
+ content: "\F001C";
+}
+.mdi-abjad-arabic::before {
+ content: "\F0353";
+}
+.mdi-abjad-hebrew::before {
+ content: "\F0354";
+}
+.mdi-abugida-devanagari::before {
+ content: "\F0355";
+}
+.mdi-abugida-thai::before {
+ content: "\F0356";
+}
+.mdi-access-point::before {
+ content: "\F002";
+}
+.mdi-access-point-network::before {
+ content: "\F003";
+}
+.mdi-access-point-network-off::before {
+ content: "\FBBD";
+}
+.mdi-account::before {
+ content: "\F004";
+}
+.mdi-account-alert::before {
+ content: "\F005";
+}
+.mdi-account-alert-outline::before {
+ content: "\FB2C";
+}
+.mdi-account-arrow-left::before {
+ content: "\FB2D";
+}
+.mdi-account-arrow-left-outline::before {
+ content: "\FB2E";
+}
+.mdi-account-arrow-right::before {
+ content: "\FB2F";
+}
+.mdi-account-arrow-right-outline::before {
+ content: "\FB30";
+}
+.mdi-account-badge::before {
+ content: "\FD83";
+}
+.mdi-account-badge-alert::before {
+ content: "\FD84";
+}
+.mdi-account-badge-alert-outline::before {
+ content: "\FD85";
+}
+.mdi-account-badge-horizontal::before {
+ content: "\FDF0";
+}
+.mdi-account-badge-horizontal-outline::before {
+ content: "\FDF1";
+}
+.mdi-account-badge-outline::before {
+ content: "\FD86";
+}
+.mdi-account-box::before {
+ content: "\F006";
+}
+.mdi-account-box-multiple::before {
+ content: "\F933";
+}
+.mdi-account-box-multiple-outline::before {
+ content: "\F002C";
+}
+.mdi-account-box-outline::before {
+ content: "\F007";
+}
+.mdi-account-cancel::before {
+ content: "\F030A";
+}
+.mdi-account-cancel-outline::before {
+ content: "\F030B";
+}
+.mdi-account-card-details::before {
+ content: "\F5D2";
+}
+.mdi-account-card-details-outline::before {
+ content: "\FD87";
+}
+.mdi-account-cash::before {
+ content: "\F00C2";
+}
+.mdi-account-cash-outline::before {
+ content: "\F00C3";
+}
+.mdi-account-check::before {
+ content: "\F008";
+}
+.mdi-account-check-outline::before {
+ content: "\FBBE";
+}
+.mdi-account-child::before {
+ content: "\FA88";
+}
+.mdi-account-child-circle::before {
+ content: "\FA89";
+}
+.mdi-account-child-outline::before {
+ content: "\F00F3";
+}
+.mdi-account-circle::before {
+ content: "\F009";
+}
+.mdi-account-circle-outline::before {
+ content: "\FB31";
+}
+.mdi-account-clock::before {
+ content: "\FB32";
+}
+.mdi-account-clock-outline::before {
+ content: "\FB33";
+}
+.mdi-account-cog::before {
+ content: "\F039B";
+}
+.mdi-account-cog-outline::before {
+ content: "\F039C";
+}
+.mdi-account-convert::before {
+ content: "\F00A";
+}
+.mdi-account-convert-outline::before {
+ content: "\F032C";
+}
+.mdi-account-details::before {
+ content: "\F631";
+}
+.mdi-account-details-outline::before {
+ content: "\F039D";
+}
+.mdi-account-edit::before {
+ content: "\F6BB";
+}
+.mdi-account-edit-outline::before {
+ content: "\F001D";
+}
+.mdi-account-group::before {
+ content: "\F848";
+}
+.mdi-account-group-outline::before {
+ content: "\FB34";
+}
+.mdi-account-heart::before {
+ content: "\F898";
+}
+.mdi-account-heart-outline::before {
+ content: "\FBBF";
+}
+.mdi-account-key::before {
+ content: "\F00B";
+}
+.mdi-account-key-outline::before {
+ content: "\FBC0";
+}
+.mdi-account-lock::before {
+ content: "\F0189";
+}
+.mdi-account-lock-outline::before {
+ content: "\F018A";
+}
+.mdi-account-minus::before {
+ content: "\F00D";
+}
+.mdi-account-minus-outline::before {
+ content: "\FAEB";
+}
+.mdi-account-multiple::before {
+ content: "\F00E";
+}
+.mdi-account-multiple-check::before {
+ content: "\F8C4";
+}
+.mdi-account-multiple-check-outline::before {
+ content: "\F0229";
+}
+.mdi-account-multiple-minus::before {
+ content: "\F5D3";
+}
+.mdi-account-multiple-minus-outline::before {
+ content: "\FBC1";
+}
+.mdi-account-multiple-outline::before {
+ content: "\F00F";
+}
+.mdi-account-multiple-plus::before {
+ content: "\F010";
+}
+.mdi-account-multiple-plus-outline::before {
+ content: "\F7FF";
+}
+.mdi-account-multiple-remove::before {
+ content: "\F0235";
+}
+.mdi-account-multiple-remove-outline::before {
+ content: "\F0236";
+}
+.mdi-account-network::before {
+ content: "\F011";
+}
+.mdi-account-network-outline::before {
+ content: "\FBC2";
+}
+.mdi-account-off::before {
+ content: "\F012";
+}
+.mdi-account-off-outline::before {
+ content: "\FBC3";
+}
+.mdi-account-outline::before {
+ content: "\F013";
+}
+.mdi-account-plus::before {
+ content: "\F014";
+}
+.mdi-account-plus-outline::before {
+ content: "\F800";
+}
+.mdi-account-question::before {
+ content: "\FB35";
+}
+.mdi-account-question-outline::before {
+ content: "\FB36";
+}
+.mdi-account-remove::before {
+ content: "\F015";
+}
+.mdi-account-remove-outline::before {
+ content: "\FAEC";
+}
+.mdi-account-search::before {
+ content: "\F016";
+}
+.mdi-account-search-outline::before {
+ content: "\F934";
+}
+.mdi-account-settings::before {
+ content: "\F630";
+}
+.mdi-account-settings-outline::before {
+ content: "\F00F4";
+}
+.mdi-account-star::before {
+ content: "\F017";
+}
+.mdi-account-star-outline::before {
+ content: "\FBC4";
+}
+.mdi-account-supervisor::before {
+ content: "\FA8A";
+}
+.mdi-account-supervisor-circle::before {
+ content: "\FA8B";
+}
+.mdi-account-supervisor-outline::before {
+ content: "\F0158";
+}
+.mdi-account-switch::before {
+ content: "\F019";
+}
+.mdi-account-tie::before {
+ content: "\FCBF";
+}
+.mdi-account-tie-outline::before {
+ content: "\F00F5";
+}
+.mdi-account-tie-voice::before {
+ content: "\F0333";
+}
+.mdi-account-tie-voice-off::before {
+ content: "\F0335";
+}
+.mdi-account-tie-voice-off-outline::before {
+ content: "\F0336";
+}
+.mdi-account-tie-voice-outline::before {
+ content: "\F0334";
+}
+.mdi-accusoft::before {
+ content: "\F849";
+}
+.mdi-adjust::before {
+ content: "\F01A";
+}
+.mdi-adobe::before {
+ content: "\F935";
+}
+.mdi-adobe-acrobat::before {
+ content: "\FFBD";
+}
+.mdi-air-conditioner::before {
+ content: "\F01B";
+}
+.mdi-air-filter::before {
+ content: "\FD1F";
+}
+.mdi-air-horn::before {
+ content: "\FD88";
+}
+.mdi-air-humidifier::before {
+ content: "\F00C4";
+}
+.mdi-air-purifier::before {
+ content: "\FD20";
+}
+.mdi-airbag::before {
+ content: "\FBC5";
+}
+.mdi-airballoon::before {
+ content: "\F01C";
+}
+.mdi-airballoon-outline::before {
+ content: "\F002D";
+}
+.mdi-airplane::before {
+ content: "\F01D";
+}
+.mdi-airplane-landing::before {
+ content: "\F5D4";
+}
+.mdi-airplane-off::before {
+ content: "\F01E";
+}
+.mdi-airplane-takeoff::before {
+ content: "\F5D5";
+}
+.mdi-airplay::before {
+ content: "\F01F";
+}
+.mdi-airport::before {
+ content: "\F84A";
+}
+.mdi-alarm::before {
+ content: "\F020";
+}
+.mdi-alarm-bell::before {
+ content: "\F78D";
+}
+.mdi-alarm-check::before {
+ content: "\F021";
+}
+.mdi-alarm-light::before {
+ content: "\F78E";
+}
+.mdi-alarm-light-outline::before {
+ content: "\FBC6";
+}
+.mdi-alarm-multiple::before {
+ content: "\F022";
+}
+.mdi-alarm-note::before {
+ content: "\FE8E";
+}
+.mdi-alarm-note-off::before {
+ content: "\FE8F";
+}
+.mdi-alarm-off::before {
+ content: "\F023";
+}
+.mdi-alarm-plus::before {
+ content: "\F024";
+}
+.mdi-alarm-snooze::before {
+ content: "\F68D";
+}
+.mdi-album::before {
+ content: "\F025";
+}
+.mdi-alert::before {
+ content: "\F026";
+}
+.mdi-alert-box::before {
+ content: "\F027";
+}
+.mdi-alert-box-outline::before {
+ content: "\FCC0";
+}
+.mdi-alert-circle::before {
+ content: "\F028";
+}
+.mdi-alert-circle-check::before {
+ content: "\F0218";
+}
+.mdi-alert-circle-check-outline::before {
+ content: "\F0219";
+}
+.mdi-alert-circle-outline::before {
+ content: "\F5D6";
+}
+.mdi-alert-decagram::before {
+ content: "\F6BC";
+}
+.mdi-alert-decagram-outline::before {
+ content: "\FCC1";
+}
+.mdi-alert-octagon::before {
+ content: "\F029";
+}
+.mdi-alert-octagon-outline::before {
+ content: "\FCC2";
+}
+.mdi-alert-octagram::before {
+ content: "\F766";
+}
+.mdi-alert-octagram-outline::before {
+ content: "\FCC3";
+}
+.mdi-alert-outline::before {
+ content: "\F02A";
+}
+.mdi-alert-rhombus::before {
+ content: "\F01F9";
+}
+.mdi-alert-rhombus-outline::before {
+ content: "\F01FA";
+}
+.mdi-alien::before {
+ content: "\F899";
+}
+.mdi-alien-outline::before {
+ content: "\F00F6";
+}
+.mdi-align-horizontal-center::before {
+ content: "\F01EE";
+}
+.mdi-align-horizontal-left::before {
+ content: "\F01ED";
+}
+.mdi-align-horizontal-right::before {
+ content: "\F01EF";
+}
+.mdi-align-vertical-bottom::before {
+ content: "\F01F0";
+}
+.mdi-align-vertical-center::before {
+ content: "\F01F1";
+}
+.mdi-align-vertical-top::before {
+ content: "\F01F2";
+}
+.mdi-all-inclusive::before {
+ content: "\F6BD";
+}
+.mdi-allergy::before {
+ content: "\F0283";
+}
+.mdi-alpha::before {
+ content: "\F02B";
+}
+.mdi-alpha-a::before {
+ content: "\41";
+}
+.mdi-alpha-a-box::before {
+ content: "\FAED";
+}
+.mdi-alpha-a-box-outline::before {
+ content: "\FBC7";
+}
+.mdi-alpha-a-circle::before {
+ content: "\FBC8";
+}
+.mdi-alpha-a-circle-outline::before {
+ content: "\FBC9";
+}
+.mdi-alpha-b::before {
+ content: "\42";
+}
+.mdi-alpha-b-box::before {
+ content: "\FAEE";
+}
+.mdi-alpha-b-box-outline::before {
+ content: "\FBCA";
+}
+.mdi-alpha-b-circle::before {
+ content: "\FBCB";
+}
+.mdi-alpha-b-circle-outline::before {
+ content: "\FBCC";
+}
+.mdi-alpha-c::before {
+ content: "\43";
+}
+.mdi-alpha-c-box::before {
+ content: "\FAEF";
+}
+.mdi-alpha-c-box-outline::before {
+ content: "\FBCD";
+}
+.mdi-alpha-c-circle::before {
+ content: "\FBCE";
+}
+.mdi-alpha-c-circle-outline::before {
+ content: "\FBCF";
+}
+.mdi-alpha-d::before {
+ content: "\44";
+}
+.mdi-alpha-d-box::before {
+ content: "\FAF0";
+}
+.mdi-alpha-d-box-outline::before {
+ content: "\FBD0";
+}
+.mdi-alpha-d-circle::before {
+ content: "\FBD1";
+}
+.mdi-alpha-d-circle-outline::before {
+ content: "\FBD2";
+}
+.mdi-alpha-e::before {
+ content: "\45";
+}
+.mdi-alpha-e-box::before {
+ content: "\FAF1";
+}
+.mdi-alpha-e-box-outline::before {
+ content: "\FBD3";
+}
+.mdi-alpha-e-circle::before {
+ content: "\FBD4";
+}
+.mdi-alpha-e-circle-outline::before {
+ content: "\FBD5";
+}
+.mdi-alpha-f::before {
+ content: "\46";
+}
+.mdi-alpha-f-box::before {
+ content: "\FAF2";
+}
+.mdi-alpha-f-box-outline::before {
+ content: "\FBD6";
+}
+.mdi-alpha-f-circle::before {
+ content: "\FBD7";
+}
+.mdi-alpha-f-circle-outline::before {
+ content: "\FBD8";
+}
+.mdi-alpha-g::before {
+ content: "\47";
+}
+.mdi-alpha-g-box::before {
+ content: "\FAF3";
+}
+.mdi-alpha-g-box-outline::before {
+ content: "\FBD9";
+}
+.mdi-alpha-g-circle::before {
+ content: "\FBDA";
+}
+.mdi-alpha-g-circle-outline::before {
+ content: "\FBDB";
+}
+.mdi-alpha-h::before {
+ content: "\48";
+}
+.mdi-alpha-h-box::before {
+ content: "\FAF4";
+}
+.mdi-alpha-h-box-outline::before {
+ content: "\FBDC";
+}
+.mdi-alpha-h-circle::before {
+ content: "\FBDD";
+}
+.mdi-alpha-h-circle-outline::before {
+ content: "\FBDE";
+}
+.mdi-alpha-i::before {
+ content: "\49";
+}
+.mdi-alpha-i-box::before {
+ content: "\FAF5";
+}
+.mdi-alpha-i-box-outline::before {
+ content: "\FBDF";
+}
+.mdi-alpha-i-circle::before {
+ content: "\FBE0";
+}
+.mdi-alpha-i-circle-outline::before {
+ content: "\FBE1";
+}
+.mdi-alpha-j::before {
+ content: "\4A";
+}
+.mdi-alpha-j-box::before {
+ content: "\FAF6";
+}
+.mdi-alpha-j-box-outline::before {
+ content: "\FBE2";
+}
+.mdi-alpha-j-circle::before {
+ content: "\FBE3";
+}
+.mdi-alpha-j-circle-outline::before {
+ content: "\FBE4";
+}
+.mdi-alpha-k::before {
+ content: "\4B";
+}
+.mdi-alpha-k-box::before {
+ content: "\FAF7";
+}
+.mdi-alpha-k-box-outline::before {
+ content: "\FBE5";
+}
+.mdi-alpha-k-circle::before {
+ content: "\FBE6";
+}
+.mdi-alpha-k-circle-outline::before {
+ content: "\FBE7";
+}
+.mdi-alpha-l::before {
+ content: "\4C";
+}
+.mdi-alpha-l-box::before {
+ content: "\FAF8";
+}
+.mdi-alpha-l-box-outline::before {
+ content: "\FBE8";
+}
+.mdi-alpha-l-circle::before {
+ content: "\FBE9";
+}
+.mdi-alpha-l-circle-outline::before {
+ content: "\FBEA";
+}
+.mdi-alpha-m::before {
+ content: "\4D";
+}
+.mdi-alpha-m-box::before {
+ content: "\FAF9";
+}
+.mdi-alpha-m-box-outline::before {
+ content: "\FBEB";
+}
+.mdi-alpha-m-circle::before {
+ content: "\FBEC";
+}
+.mdi-alpha-m-circle-outline::before {
+ content: "\FBED";
+}
+.mdi-alpha-n::before {
+ content: "\4E";
+}
+.mdi-alpha-n-box::before {
+ content: "\FAFA";
+}
+.mdi-alpha-n-box-outline::before {
+ content: "\FBEE";
+}
+.mdi-alpha-n-circle::before {
+ content: "\FBEF";
+}
+.mdi-alpha-n-circle-outline::before {
+ content: "\FBF0";
+}
+.mdi-alpha-o::before {
+ content: "\4F";
+}
+.mdi-alpha-o-box::before {
+ content: "\FAFB";
+}
+.mdi-alpha-o-box-outline::before {
+ content: "\FBF1";
+}
+.mdi-alpha-o-circle::before {
+ content: "\FBF2";
+}
+.mdi-alpha-o-circle-outline::before {
+ content: "\FBF3";
+}
+.mdi-alpha-p::before {
+ content: "\50";
+}
+.mdi-alpha-p-box::before {
+ content: "\FAFC";
+}
+.mdi-alpha-p-box-outline::before {
+ content: "\FBF4";
+}
+.mdi-alpha-p-circle::before {
+ content: "\FBF5";
+}
+.mdi-alpha-p-circle-outline::before {
+ content: "\FBF6";
+}
+.mdi-alpha-q::before {
+ content: "\51";
+}
+.mdi-alpha-q-box::before {
+ content: "\FAFD";
+}
+.mdi-alpha-q-box-outline::before {
+ content: "\FBF7";
+}
+.mdi-alpha-q-circle::before {
+ content: "\FBF8";
+}
+.mdi-alpha-q-circle-outline::before {
+ content: "\FBF9";
+}
+.mdi-alpha-r::before {
+ content: "\52";
+}
+.mdi-alpha-r-box::before {
+ content: "\FAFE";
+}
+.mdi-alpha-r-box-outline::before {
+ content: "\FBFA";
+}
+.mdi-alpha-r-circle::before {
+ content: "\FBFB";
+}
+.mdi-alpha-r-circle-outline::before {
+ content: "\FBFC";
+}
+.mdi-alpha-s::before {
+ content: "\53";
+}
+.mdi-alpha-s-box::before {
+ content: "\FAFF";
+}
+.mdi-alpha-s-box-outline::before {
+ content: "\FBFD";
+}
+.mdi-alpha-s-circle::before {
+ content: "\FBFE";
+}
+.mdi-alpha-s-circle-outline::before {
+ content: "\FBFF";
+}
+.mdi-alpha-t::before {
+ content: "\54";
+}
+.mdi-alpha-t-box::before {
+ content: "\FB00";
+}
+.mdi-alpha-t-box-outline::before {
+ content: "\FC00";
+}
+.mdi-alpha-t-circle::before {
+ content: "\FC01";
+}
+.mdi-alpha-t-circle-outline::before {
+ content: "\FC02";
+}
+.mdi-alpha-u::before {
+ content: "\55";
+}
+.mdi-alpha-u-box::before {
+ content: "\FB01";
+}
+.mdi-alpha-u-box-outline::before {
+ content: "\FC03";
+}
+.mdi-alpha-u-circle::before {
+ content: "\FC04";
+}
+.mdi-alpha-u-circle-outline::before {
+ content: "\FC05";
+}
+.mdi-alpha-v::before {
+ content: "\56";
+}
+.mdi-alpha-v-box::before {
+ content: "\FB02";
+}
+.mdi-alpha-v-box-outline::before {
+ content: "\FC06";
+}
+.mdi-alpha-v-circle::before {
+ content: "\FC07";
+}
+.mdi-alpha-v-circle-outline::before {
+ content: "\FC08";
+}
+.mdi-alpha-w::before {
+ content: "\57";
+}
+.mdi-alpha-w-box::before {
+ content: "\FB03";
+}
+.mdi-alpha-w-box-outline::before {
+ content: "\FC09";
+}
+.mdi-alpha-w-circle::before {
+ content: "\FC0A";
+}
+.mdi-alpha-w-circle-outline::before {
+ content: "\FC0B";
+}
+.mdi-alpha-x::before {
+ content: "\58";
+}
+.mdi-alpha-x-box::before {
+ content: "\FB04";
+}
+.mdi-alpha-x-box-outline::before {
+ content: "\FC0C";
+}
+.mdi-alpha-x-circle::before {
+ content: "\FC0D";
+}
+.mdi-alpha-x-circle-outline::before {
+ content: "\FC0E";
+}
+.mdi-alpha-y::before {
+ content: "\59";
+}
+.mdi-alpha-y-box::before {
+ content: "\FB05";
+}
+.mdi-alpha-y-box-outline::before {
+ content: "\FC0F";
+}
+.mdi-alpha-y-circle::before {
+ content: "\FC10";
+}
+.mdi-alpha-y-circle-outline::before {
+ content: "\FC11";
+}
+.mdi-alpha-z::before {
+ content: "\5A";
+}
+.mdi-alpha-z-box::before {
+ content: "\FB06";
+}
+.mdi-alpha-z-box-outline::before {
+ content: "\FC12";
+}
+.mdi-alpha-z-circle::before {
+ content: "\FC13";
+}
+.mdi-alpha-z-circle-outline::before {
+ content: "\FC14";
+}
+.mdi-alphabet-aurebesh::before {
+ content: "\F0357";
+}
+.mdi-alphabet-cyrillic::before {
+ content: "\F0358";
+}
+.mdi-alphabet-greek::before {
+ content: "\F0359";
+}
+.mdi-alphabet-latin::before {
+ content: "\F035A";
+}
+.mdi-alphabet-piqad::before {
+ content: "\F035B";
+}
+.mdi-alphabet-tengwar::before {
+ content: "\F0362";
+}
+.mdi-alphabetical::before {
+ content: "\F02C";
+}
+.mdi-alphabetical-off::before {
+ content: "\F002E";
+}
+.mdi-alphabetical-variant::before {
+ content: "\F002F";
+}
+.mdi-alphabetical-variant-off::before {
+ content: "\F0030";
+}
+.mdi-altimeter::before {
+ content: "\F5D7";
+}
+.mdi-amazon::before {
+ content: "\F02D";
+}
+.mdi-amazon-alexa::before {
+ content: "\F8C5";
+}
+.mdi-amazon-drive::before {
+ content: "\F02E";
+}
+.mdi-ambulance::before {
+ content: "\F02F";
+}
+.mdi-ammunition::before {
+ content: "\FCC4";
+}
+.mdi-ampersand::before {
+ content: "\FA8C";
+}
+.mdi-amplifier::before {
+ content: "\F030";
+}
+.mdi-amplifier-off::before {
+ content: "\F01E0";
+}
+.mdi-anchor::before {
+ content: "\F031";
+}
+.mdi-android::before {
+ content: "\F032";
+}
+.mdi-android-auto::before {
+ content: "\FA8D";
+}
+.mdi-android-debug-bridge::before {
+ content: "\F033";
+}
+.mdi-android-head::before {
+ content: "\F78F";
+}
+.mdi-android-messages::before {
+ content: "\FD21";
+}
+.mdi-android-studio::before {
+ content: "\F034";
+}
+.mdi-angle-acute::before {
+ content: "\F936";
+}
+.mdi-angle-obtuse::before {
+ content: "\F937";
+}
+.mdi-angle-right::before {
+ content: "\F938";
+}
+.mdi-angular::before {
+ content: "\F6B1";
+}
+.mdi-angularjs::before {
+ content: "\F6BE";
+}
+.mdi-animation::before {
+ content: "\F5D8";
+}
+.mdi-animation-outline::before {
+ content: "\FA8E";
+}
+.mdi-animation-play::before {
+ content: "\F939";
+}
+.mdi-animation-play-outline::before {
+ content: "\FA8F";
+}
+.mdi-ansible::before {
+ content: "\F00C5";
+}
+.mdi-antenna::before {
+ content: "\F0144";
+}
+.mdi-anvil::before {
+ content: "\F89A";
+}
+.mdi-apache-kafka::before {
+ content: "\F0031";
+}
+.mdi-api::before {
+ content: "\F00C6";
+}
+.mdi-api-off::before {
+ content: "\F0282";
+}
+.mdi-apple::before {
+ content: "\F035";
+}
+.mdi-apple-finder::before {
+ content: "\F036";
+}
+.mdi-apple-icloud::before {
+ content: "\F038";
+}
+.mdi-apple-ios::before {
+ content: "\F037";
+}
+.mdi-apple-keyboard-caps::before {
+ content: "\F632";
+}
+.mdi-apple-keyboard-command::before {
+ content: "\F633";
+}
+.mdi-apple-keyboard-control::before {
+ content: "\F634";
+}
+.mdi-apple-keyboard-option::before {
+ content: "\F635";
+}
+.mdi-apple-keyboard-shift::before {
+ content: "\F636";
+}
+.mdi-apple-safari::before {
+ content: "\F039";
+}
+.mdi-application::before {
+ content: "\F614";
+}
+.mdi-application-export::before {
+ content: "\FD89";
+}
+.mdi-application-import::before {
+ content: "\FD8A";
+}
+.mdi-approximately-equal::before {
+ content: "\FFBE";
+}
+.mdi-approximately-equal-box::before {
+ content: "\FFBF";
+}
+.mdi-apps::before {
+ content: "\F03B";
+}
+.mdi-apps-box::before {
+ content: "\FD22";
+}
+.mdi-arch::before {
+ content: "\F8C6";
+}
+.mdi-archive::before {
+ content: "\F03C";
+}
+.mdi-archive-arrow-down::before {
+ content: "\F0284";
+}
+.mdi-archive-arrow-down-outline::before {
+ content: "\F0285";
+}
+.mdi-archive-arrow-up::before {
+ content: "\F0286";
+}
+.mdi-archive-arrow-up-outline::before {
+ content: "\F0287";
+}
+.mdi-archive-outline::before {
+ content: "\F0239";
+}
+.mdi-arm-flex::before {
+ content: "\F008F";
+}
+.mdi-arm-flex-outline::before {
+ content: "\F0090";
+}
+.mdi-arrange-bring-forward::before {
+ content: "\F03D";
+}
+.mdi-arrange-bring-to-front::before {
+ content: "\F03E";
+}
+.mdi-arrange-send-backward::before {
+ content: "\F03F";
+}
+.mdi-arrange-send-to-back::before {
+ content: "\F040";
+}
+.mdi-arrow-all::before {
+ content: "\F041";
+}
+.mdi-arrow-bottom-left::before {
+ content: "\F042";
+}
+.mdi-arrow-bottom-left-bold-outline::before {
+ content: "\F9B6";
+}
+.mdi-arrow-bottom-left-thick::before {
+ content: "\F9B7";
+}
+.mdi-arrow-bottom-right::before {
+ content: "\F043";
+}
+.mdi-arrow-bottom-right-bold-outline::before {
+ content: "\F9B8";
+}
+.mdi-arrow-bottom-right-thick::before {
+ content: "\F9B9";
+}
+.mdi-arrow-collapse::before {
+ content: "\F615";
+}
+.mdi-arrow-collapse-all::before {
+ content: "\F044";
+}
+.mdi-arrow-collapse-down::before {
+ content: "\F791";
+}
+.mdi-arrow-collapse-horizontal::before {
+ content: "\F84B";
+}
+.mdi-arrow-collapse-left::before {
+ content: "\F792";
+}
+.mdi-arrow-collapse-right::before {
+ content: "\F793";
+}
+.mdi-arrow-collapse-up::before {
+ content: "\F794";
+}
+.mdi-arrow-collapse-vertical::before {
+ content: "\F84C";
+}
+.mdi-arrow-decision::before {
+ content: "\F9BA";
+}
+.mdi-arrow-decision-auto::before {
+ content: "\F9BB";
+}
+.mdi-arrow-decision-auto-outline::before {
+ content: "\F9BC";
+}
+.mdi-arrow-decision-outline::before {
+ content: "\F9BD";
+}
+.mdi-arrow-down::before {
+ content: "\F045";
+}
+.mdi-arrow-down-bold::before {
+ content: "\F72D";
+}
+.mdi-arrow-down-bold-box::before {
+ content: "\F72E";
+}
+.mdi-arrow-down-bold-box-outline::before {
+ content: "\F72F";
+}
+.mdi-arrow-down-bold-circle::before {
+ content: "\F047";
+}
+.mdi-arrow-down-bold-circle-outline::before {
+ content: "\F048";
+}
+.mdi-arrow-down-bold-hexagon-outline::before {
+ content: "\F049";
+}
+.mdi-arrow-down-bold-outline::before {
+ content: "\F9BE";
+}
+.mdi-arrow-down-box::before {
+ content: "\F6BF";
+}
+.mdi-arrow-down-circle::before {
+ content: "\FCB7";
+}
+.mdi-arrow-down-circle-outline::before {
+ content: "\FCB8";
+}
+.mdi-arrow-down-drop-circle::before {
+ content: "\F04A";
+}
+.mdi-arrow-down-drop-circle-outline::before {
+ content: "\F04B";
+}
+.mdi-arrow-down-thick::before {
+ content: "\F046";
+}
+.mdi-arrow-expand::before {
+ content: "\F616";
+}
+.mdi-arrow-expand-all::before {
+ content: "\F04C";
+}
+.mdi-arrow-expand-down::before {
+ content: "\F795";
+}
+.mdi-arrow-expand-horizontal::before {
+ content: "\F84D";
+}
+.mdi-arrow-expand-left::before {
+ content: "\F796";
+}
+.mdi-arrow-expand-right::before {
+ content: "\F797";
+}
+.mdi-arrow-expand-up::before {
+ content: "\F798";
+}
+.mdi-arrow-expand-vertical::before {
+ content: "\F84E";
+}
+.mdi-arrow-horizontal-lock::before {
+ content: "\F0186";
+}
+.mdi-arrow-left::before {
+ content: "\F04D";
+}
+.mdi-arrow-left-bold::before {
+ content: "\F730";
+}
+.mdi-arrow-left-bold-box::before {
+ content: "\F731";
+}
+.mdi-arrow-left-bold-box-outline::before {
+ content: "\F732";
+}
+.mdi-arrow-left-bold-circle::before {
+ content: "\F04F";
+}
+.mdi-arrow-left-bold-circle-outline::before {
+ content: "\F050";
+}
+.mdi-arrow-left-bold-hexagon-outline::before {
+ content: "\F051";
+}
+.mdi-arrow-left-bold-outline::before {
+ content: "\F9BF";
+}
+.mdi-arrow-left-box::before {
+ content: "\F6C0";
+}
+.mdi-arrow-left-circle::before {
+ content: "\FCB9";
+}
+.mdi-arrow-left-circle-outline::before {
+ content: "\FCBA";
+}
+.mdi-arrow-left-drop-circle::before {
+ content: "\F052";
+}
+.mdi-arrow-left-drop-circle-outline::before {
+ content: "\F053";
+}
+.mdi-arrow-left-right::before {
+ content: "\FE90";
+}
+.mdi-arrow-left-right-bold::before {
+ content: "\FE91";
+}
+.mdi-arrow-left-right-bold-outline::before {
+ content: "\F9C0";
+}
+.mdi-arrow-left-thick::before {
+ content: "\F04E";
+}
+.mdi-arrow-right::before {
+ content: "\F054";
+}
+.mdi-arrow-right-bold::before {
+ content: "\F733";
+}
+.mdi-arrow-right-bold-box::before {
+ content: "\F734";
+}
+.mdi-arrow-right-bold-box-outline::before {
+ content: "\F735";
+}
+.mdi-arrow-right-bold-circle::before {
+ content: "\F056";
+}
+.mdi-arrow-right-bold-circle-outline::before {
+ content: "\F057";
+}
+.mdi-arrow-right-bold-hexagon-outline::before {
+ content: "\F058";
+}
+.mdi-arrow-right-bold-outline::before {
+ content: "\F9C1";
+}
+.mdi-arrow-right-box::before {
+ content: "\F6C1";
+}
+.mdi-arrow-right-circle::before {
+ content: "\FCBB";
+}
+.mdi-arrow-right-circle-outline::before {
+ content: "\FCBC";
+}
+.mdi-arrow-right-drop-circle::before {
+ content: "\F059";
+}
+.mdi-arrow-right-drop-circle-outline::before {
+ content: "\F05A";
+}
+.mdi-arrow-right-thick::before {
+ content: "\F055";
+}
+.mdi-arrow-split-horizontal::before {
+ content: "\F93A";
+}
+.mdi-arrow-split-vertical::before {
+ content: "\F93B";
+}
+.mdi-arrow-top-left::before {
+ content: "\F05B";
+}
+.mdi-arrow-top-left-bold-outline::before {
+ content: "\F9C2";
+}
+.mdi-arrow-top-left-bottom-right::before {
+ content: "\FE92";
+}
+.mdi-arrow-top-left-bottom-right-bold::before {
+ content: "\FE93";
+}
+.mdi-arrow-top-left-thick::before {
+ content: "\F9C3";
+}
+.mdi-arrow-top-right::before {
+ content: "\F05C";
+}
+.mdi-arrow-top-right-bold-outline::before {
+ content: "\F9C4";
+}
+.mdi-arrow-top-right-bottom-left::before {
+ content: "\FE94";
+}
+.mdi-arrow-top-right-bottom-left-bold::before {
+ content: "\FE95";
+}
+.mdi-arrow-top-right-thick::before {
+ content: "\F9C5";
+}
+.mdi-arrow-up::before {
+ content: "\F05D";
+}
+.mdi-arrow-up-bold::before {
+ content: "\F736";
+}
+.mdi-arrow-up-bold-box::before {
+ content: "\F737";
+}
+.mdi-arrow-up-bold-box-outline::before {
+ content: "\F738";
+}
+.mdi-arrow-up-bold-circle::before {
+ content: "\F05F";
+}
+.mdi-arrow-up-bold-circle-outline::before {
+ content: "\F060";
+}
+.mdi-arrow-up-bold-hexagon-outline::before {
+ content: "\F061";
+}
+.mdi-arrow-up-bold-outline::before {
+ content: "\F9C6";
+}
+.mdi-arrow-up-box::before {
+ content: "\F6C2";
+}
+.mdi-arrow-up-circle::before {
+ content: "\FCBD";
+}
+.mdi-arrow-up-circle-outline::before {
+ content: "\FCBE";
+}
+.mdi-arrow-up-down::before {
+ content: "\FE96";
+}
+.mdi-arrow-up-down-bold::before {
+ content: "\FE97";
+}
+.mdi-arrow-up-down-bold-outline::before {
+ content: "\F9C7";
+}
+.mdi-arrow-up-drop-circle::before {
+ content: "\F062";
+}
+.mdi-arrow-up-drop-circle-outline::before {
+ content: "\F063";
+}
+.mdi-arrow-up-thick::before {
+ content: "\F05E";
+}
+.mdi-arrow-vertical-lock::before {
+ content: "\F0187";
+}
+.mdi-artist::before {
+ content: "\F802";
+}
+.mdi-artist-outline::before {
+ content: "\FCC5";
+}
+.mdi-artstation::before {
+ content: "\FB37";
+}
+.mdi-aspect-ratio::before {
+ content: "\FA23";
+}
+.mdi-assistant::before {
+ content: "\F064";
+}
+.mdi-asterisk::before {
+ content: "\F6C3";
+}
+.mdi-at::before {
+ content: "\F065";
+}
+.mdi-atlassian::before {
+ content: "\F803";
+}
+.mdi-atm::before {
+ content: "\FD23";
+}
+.mdi-atom::before {
+ content: "\F767";
+}
+.mdi-atom-variant::before {
+ content: "\FE98";
+}
+.mdi-attachment::before {
+ content: "\F066";
+}
+.mdi-audio-video::before {
+ content: "\F93C";
+}
+.mdi-audio-video-off::before {
+ content: "\F01E1";
+}
+.mdi-audiobook::before {
+ content: "\F067";
+}
+.mdi-augmented-reality::before {
+ content: "\F84F";
+}
+.mdi-auto-download::before {
+ content: "\F03A9";
+}
+.mdi-auto-fix::before {
+ content: "\F068";
+}
+.mdi-auto-upload::before {
+ content: "\F069";
+}
+.mdi-autorenew::before {
+ content: "\F06A";
+}
+.mdi-av-timer::before {
+ content: "\F06B";
+}
+.mdi-aws::before {
+ content: "\FDF2";
+}
+.mdi-axe::before {
+ content: "\F8C7";
+}
+.mdi-axis::before {
+ content: "\FD24";
+}
+.mdi-axis-arrow::before {
+ content: "\FD25";
+}
+.mdi-axis-arrow-lock::before {
+ content: "\FD26";
+}
+.mdi-axis-lock::before {
+ content: "\FD27";
+}
+.mdi-axis-x-arrow::before {
+ content: "\FD28";
+}
+.mdi-axis-x-arrow-lock::before {
+ content: "\FD29";
+}
+.mdi-axis-x-rotate-clockwise::before {
+ content: "\FD2A";
+}
+.mdi-axis-x-rotate-counterclockwise::before {
+ content: "\FD2B";
+}
+.mdi-axis-x-y-arrow-lock::before {
+ content: "\FD2C";
+}
+.mdi-axis-y-arrow::before {
+ content: "\FD2D";
+}
+.mdi-axis-y-arrow-lock::before {
+ content: "\FD2E";
+}
+.mdi-axis-y-rotate-clockwise::before {
+ content: "\FD2F";
+}
+.mdi-axis-y-rotate-counterclockwise::before {
+ content: "\FD30";
+}
+.mdi-axis-z-arrow::before {
+ content: "\FD31";
+}
+.mdi-axis-z-arrow-lock::before {
+ content: "\FD32";
+}
+.mdi-axis-z-rotate-clockwise::before {
+ content: "\FD33";
+}
+.mdi-axis-z-rotate-counterclockwise::before {
+ content: "\FD34";
+}
+.mdi-azure::before {
+ content: "\F804";
+}
+.mdi-azure-devops::before {
+ content: "\F0091";
+}
+.mdi-babel::before {
+ content: "\FA24";
+}
+.mdi-baby::before {
+ content: "\F06C";
+}
+.mdi-baby-bottle::before {
+ content: "\FF56";
+}
+.mdi-baby-bottle-outline::before {
+ content: "\FF57";
+}
+.mdi-baby-carriage::before {
+ content: "\F68E";
+}
+.mdi-baby-carriage-off::before {
+ content: "\FFC0";
+}
+.mdi-baby-face::before {
+ content: "\FE99";
+}
+.mdi-baby-face-outline::before {
+ content: "\FE9A";
+}
+.mdi-backburger::before {
+ content: "\F06D";
+}
+.mdi-backspace::before {
+ content: "\F06E";
+}
+.mdi-backspace-outline::before {
+ content: "\FB38";
+}
+.mdi-backspace-reverse::before {
+ content: "\FE9B";
+}
+.mdi-backspace-reverse-outline::before {
+ content: "\FE9C";
+}
+.mdi-backup-restore::before {
+ content: "\F06F";
+}
+.mdi-bacteria::before {
+ content: "\FEF2";
+}
+.mdi-bacteria-outline::before {
+ content: "\FEF3";
+}
+.mdi-badminton::before {
+ content: "\F850";
+}
+.mdi-bag-carry-on::before {
+ content: "\FF58";
+}
+.mdi-bag-carry-on-check::before {
+ content: "\FD41";
+}
+.mdi-bag-carry-on-off::before {
+ content: "\FF59";
+}
+.mdi-bag-checked::before {
+ content: "\FF5A";
+}
+.mdi-bag-personal::before {
+ content: "\FDF3";
+}
+.mdi-bag-personal-off::before {
+ content: "\FDF4";
+}
+.mdi-bag-personal-off-outline::before {
+ content: "\FDF5";
+}
+.mdi-bag-personal-outline::before {
+ content: "\FDF6";
+}
+.mdi-baguette::before {
+ content: "\FF5B";
+}
+.mdi-balloon::before {
+ content: "\FA25";
+}
+.mdi-ballot::before {
+ content: "\F9C8";
+}
+.mdi-ballot-outline::before {
+ content: "\F9C9";
+}
+.mdi-ballot-recount::before {
+ content: "\FC15";
+}
+.mdi-ballot-recount-outline::before {
+ content: "\FC16";
+}
+.mdi-bandage::before {
+ content: "\FD8B";
+}
+.mdi-bandcamp::before {
+ content: "\F674";
+}
+.mdi-bank::before {
+ content: "\F070";
+}
+.mdi-bank-minus::before {
+ content: "\FD8C";
+}
+.mdi-bank-outline::before {
+ content: "\FE9D";
+}
+.mdi-bank-plus::before {
+ content: "\FD8D";
+}
+.mdi-bank-remove::before {
+ content: "\FD8E";
+}
+.mdi-bank-transfer::before {
+ content: "\FA26";
+}
+.mdi-bank-transfer-in::before {
+ content: "\FA27";
+}
+.mdi-bank-transfer-out::before {
+ content: "\FA28";
+}
+.mdi-barcode::before {
+ content: "\F071";
+}
+.mdi-barcode-off::before {
+ content: "\F0261";
+}
+.mdi-barcode-scan::before {
+ content: "\F072";
+}
+.mdi-barley::before {
+ content: "\F073";
+}
+.mdi-barley-off::before {
+ content: "\FB39";
+}
+.mdi-barn::before {
+ content: "\FB3A";
+}
+.mdi-barrel::before {
+ content: "\F074";
+}
+.mdi-baseball::before {
+ content: "\F851";
+}
+.mdi-baseball-bat::before {
+ content: "\F852";
+}
+.mdi-basecamp::before {
+ content: "\F075";
+}
+.mdi-bash::before {
+ content: "\F01AE";
+}
+.mdi-basket::before {
+ content: "\F076";
+}
+.mdi-basket-fill::before {
+ content: "\F077";
+}
+.mdi-basket-outline::before {
+ content: "\F01AC";
+}
+.mdi-basket-unfill::before {
+ content: "\F078";
+}
+.mdi-basketball::before {
+ content: "\F805";
+}
+.mdi-basketball-hoop::before {
+ content: "\FC17";
+}
+.mdi-basketball-hoop-outline::before {
+ content: "\FC18";
+}
+.mdi-bat::before {
+ content: "\FB3B";
+}
+.mdi-battery::before {
+ content: "\F079";
+}
+.mdi-battery-10::before {
+ content: "\F07A";
+}
+.mdi-battery-10-bluetooth::before {
+ content: "\F93D";
+}
+.mdi-battery-20::before {
+ content: "\F07B";
+}
+.mdi-battery-20-bluetooth::before {
+ content: "\F93E";
+}
+.mdi-battery-30::before {
+ content: "\F07C";
+}
+.mdi-battery-30-bluetooth::before {
+ content: "\F93F";
+}
+.mdi-battery-40::before {
+ content: "\F07D";
+}
+.mdi-battery-40-bluetooth::before {
+ content: "\F940";
+}
+.mdi-battery-50::before {
+ content: "\F07E";
+}
+.mdi-battery-50-bluetooth::before {
+ content: "\F941";
+}
+.mdi-battery-60::before {
+ content: "\F07F";
+}
+.mdi-battery-60-bluetooth::before {
+ content: "\F942";
+}
+.mdi-battery-70::before {
+ content: "\F080";
+}
+.mdi-battery-70-bluetooth::before {
+ content: "\F943";
+}
+.mdi-battery-80::before {
+ content: "\F081";
+}
+.mdi-battery-80-bluetooth::before {
+ content: "\F944";
+}
+.mdi-battery-90::before {
+ content: "\F082";
+}
+.mdi-battery-90-bluetooth::before {
+ content: "\F945";
+}
+.mdi-battery-alert::before {
+ content: "\F083";
+}
+.mdi-battery-alert-bluetooth::before {
+ content: "\F946";
+}
+.mdi-battery-alert-variant::before {
+ content: "\F00F7";
+}
+.mdi-battery-alert-variant-outline::before {
+ content: "\F00F8";
+}
+.mdi-battery-bluetooth::before {
+ content: "\F947";
+}
+.mdi-battery-bluetooth-variant::before {
+ content: "\F948";
+}
+.mdi-battery-charging::before {
+ content: "\F084";
+}
+.mdi-battery-charging-10::before {
+ content: "\F89B";
+}
+.mdi-battery-charging-100::before {
+ content: "\F085";
+}
+.mdi-battery-charging-20::before {
+ content: "\F086";
+}
+.mdi-battery-charging-30::before {
+ content: "\F087";
+}
+.mdi-battery-charging-40::before {
+ content: "\F088";
+}
+.mdi-battery-charging-50::before {
+ content: "\F89C";
+}
+.mdi-battery-charging-60::before {
+ content: "\F089";
+}
+.mdi-battery-charging-70::before {
+ content: "\F89D";
+}
+.mdi-battery-charging-80::before {
+ content: "\F08A";
+}
+.mdi-battery-charging-90::before {
+ content: "\F08B";
+}
+.mdi-battery-charging-high::before {
+ content: "\F02D1";
+}
+.mdi-battery-charging-low::before {
+ content: "\F02CF";
+}
+.mdi-battery-charging-medium::before {
+ content: "\F02D0";
+}
+.mdi-battery-charging-outline::before {
+ content: "\F89E";
+}
+.mdi-battery-charging-wireless::before {
+ content: "\F806";
+}
+.mdi-battery-charging-wireless-10::before {
+ content: "\F807";
+}
+.mdi-battery-charging-wireless-20::before {
+ content: "\F808";
+}
+.mdi-battery-charging-wireless-30::before {
+ content: "\F809";
+}
+.mdi-battery-charging-wireless-40::before {
+ content: "\F80A";
+}
+.mdi-battery-charging-wireless-50::before {
+ content: "\F80B";
+}
+.mdi-battery-charging-wireless-60::before {
+ content: "\F80C";
+}
+.mdi-battery-charging-wireless-70::before {
+ content: "\F80D";
+}
+.mdi-battery-charging-wireless-80::before {
+ content: "\F80E";
+}
+.mdi-battery-charging-wireless-90::before {
+ content: "\F80F";
+}
+.mdi-battery-charging-wireless-alert::before {
+ content: "\F810";
+}
+.mdi-battery-charging-wireless-outline::before {
+ content: "\F811";
+}
+.mdi-battery-heart::before {
+ content: "\F023A";
+}
+.mdi-battery-heart-outline::before {
+ content: "\F023B";
+}
+.mdi-battery-heart-variant::before {
+ content: "\F023C";
+}
+.mdi-battery-high::before {
+ content: "\F02CE";
+}
+.mdi-battery-low::before {
+ content: "\F02CC";
+}
+.mdi-battery-medium::before {
+ content: "\F02CD";
+}
+.mdi-battery-minus::before {
+ content: "\F08C";
+}
+.mdi-battery-negative::before {
+ content: "\F08D";
+}
+.mdi-battery-off::before {
+ content: "\F0288";
+}
+.mdi-battery-off-outline::before {
+ content: "\F0289";
+}
+.mdi-battery-outline::before {
+ content: "\F08E";
+}
+.mdi-battery-plus::before {
+ content: "\F08F";
+}
+.mdi-battery-positive::before {
+ content: "\F090";
+}
+.mdi-battery-unknown::before {
+ content: "\F091";
+}
+.mdi-battery-unknown-bluetooth::before {
+ content: "\F949";
+}
+.mdi-battlenet::before {
+ content: "\FB3C";
+}
+.mdi-beach::before {
+ content: "\F092";
+}
+.mdi-beaker::before {
+ content: "\FCC6";
+}
+.mdi-beaker-alert::before {
+ content: "\F0254";
+}
+.mdi-beaker-alert-outline::before {
+ content: "\F0255";
+}
+.mdi-beaker-check::before {
+ content: "\F0256";
+}
+.mdi-beaker-check-outline::before {
+ content: "\F0257";
+}
+.mdi-beaker-minus::before {
+ content: "\F0258";
+}
+.mdi-beaker-minus-outline::before {
+ content: "\F0259";
+}
+.mdi-beaker-outline::before {
+ content: "\F68F";
+}
+.mdi-beaker-plus::before {
+ content: "\F025A";
+}
+.mdi-beaker-plus-outline::before {
+ content: "\F025B";
+}
+.mdi-beaker-question::before {
+ content: "\F025C";
+}
+.mdi-beaker-question-outline::before {
+ content: "\F025D";
+}
+.mdi-beaker-remove::before {
+ content: "\F025E";
+}
+.mdi-beaker-remove-outline::before {
+ content: "\F025F";
+}
+.mdi-beats::before {
+ content: "\F097";
+}
+.mdi-bed-double::before {
+ content: "\F0092";
+}
+.mdi-bed-double-outline::before {
+ content: "\F0093";
+}
+.mdi-bed-empty::before {
+ content: "\F89F";
+}
+.mdi-bed-king::before {
+ content: "\F0094";
+}
+.mdi-bed-king-outline::before {
+ content: "\F0095";
+}
+.mdi-bed-queen::before {
+ content: "\F0096";
+}
+.mdi-bed-queen-outline::before {
+ content: "\F0097";
+}
+.mdi-bed-single::before {
+ content: "\F0098";
+}
+.mdi-bed-single-outline::before {
+ content: "\F0099";
+}
+.mdi-bee::before {
+ content: "\FFC1";
+}
+.mdi-bee-flower::before {
+ content: "\FFC2";
+}
+.mdi-beehive-outline::before {
+ content: "\F00F9";
+}
+.mdi-beer::before {
+ content: "\F098";
+}
+.mdi-beer-outline::before {
+ content: "\F0337";
+}
+.mdi-behance::before {
+ content: "\F099";
+}
+.mdi-bell::before {
+ content: "\F09A";
+}
+.mdi-bell-alert::before {
+ content: "\FD35";
+}
+.mdi-bell-alert-outline::before {
+ content: "\FE9E";
+}
+.mdi-bell-check::before {
+ content: "\F0210";
+}
+.mdi-bell-check-outline::before {
+ content: "\F0211";
+}
+.mdi-bell-circle::before {
+ content: "\FD36";
+}
+.mdi-bell-circle-outline::before {
+ content: "\FD37";
+}
+.mdi-bell-off::before {
+ content: "\F09B";
+}
+.mdi-bell-off-outline::before {
+ content: "\FA90";
+}
+.mdi-bell-outline::before {
+ content: "\F09C";
+}
+.mdi-bell-plus::before {
+ content: "\F09D";
+}
+.mdi-bell-plus-outline::before {
+ content: "\FA91";
+}
+.mdi-bell-ring::before {
+ content: "\F09E";
+}
+.mdi-bell-ring-outline::before {
+ content: "\F09F";
+}
+.mdi-bell-sleep::before {
+ content: "\F0A0";
+}
+.mdi-bell-sleep-outline::before {
+ content: "\FA92";
+}
+.mdi-beta::before {
+ content: "\F0A1";
+}
+.mdi-betamax::before {
+ content: "\F9CA";
+}
+.mdi-biathlon::before {
+ content: "\FDF7";
+}
+.mdi-bible::before {
+ content: "\F0A2";
+}
+.mdi-bicycle::before {
+ content: "\F00C7";
+}
+.mdi-bicycle-basket::before {
+ content: "\F0260";
+}
+.mdi-bike::before {
+ content: "\F0A3";
+}
+.mdi-bike-fast::before {
+ content: "\F014A";
+}
+.mdi-billboard::before {
+ content: "\F0032";
+}
+.mdi-billiards::before {
+ content: "\FB3D";
+}
+.mdi-billiards-rack::before {
+ content: "\FB3E";
+}
+.mdi-bing::before {
+ content: "\F0A4";
+}
+.mdi-binoculars::before {
+ content: "\F0A5";
+}
+.mdi-bio::before {
+ content: "\F0A6";
+}
+.mdi-biohazard::before {
+ content: "\F0A7";
+}
+.mdi-bitbucket::before {
+ content: "\F0A8";
+}
+.mdi-bitcoin::before {
+ content: "\F812";
+}
+.mdi-black-mesa::before {
+ content: "\F0A9";
+}
+.mdi-blackberry::before {
+ content: "\F0AA";
+}
+.mdi-blender::before {
+ content: "\FCC7";
+}
+.mdi-blender-software::before {
+ content: "\F0AB";
+}
+.mdi-blinds::before {
+ content: "\F0AC";
+}
+.mdi-blinds-open::before {
+ content: "\F0033";
+}
+.mdi-block-helper::before {
+ content: "\F0AD";
+}
+.mdi-blogger::before {
+ content: "\F0AE";
+}
+.mdi-blood-bag::before {
+ content: "\FCC8";
+}
+.mdi-bluetooth::before {
+ content: "\F0AF";
+}
+.mdi-bluetooth-audio::before {
+ content: "\F0B0";
+}
+.mdi-bluetooth-connect::before {
+ content: "\F0B1";
+}
+.mdi-bluetooth-off::before {
+ content: "\F0B2";
+}
+.mdi-bluetooth-settings::before {
+ content: "\F0B3";
+}
+.mdi-bluetooth-transfer::before {
+ content: "\F0B4";
+}
+.mdi-blur::before {
+ content: "\F0B5";
+}
+.mdi-blur-linear::before {
+ content: "\F0B6";
+}
+.mdi-blur-off::before {
+ content: "\F0B7";
+}
+.mdi-blur-radial::before {
+ content: "\F0B8";
+}
+.mdi-bolnisi-cross::before {
+ content: "\FCC9";
+}
+.mdi-bolt::before {
+ content: "\FD8F";
+}
+.mdi-bomb::before {
+ content: "\F690";
+}
+.mdi-bomb-off::before {
+ content: "\F6C4";
+}
+.mdi-bone::before {
+ content: "\F0B9";
+}
+.mdi-book::before {
+ content: "\F0BA";
+}
+.mdi-book-information-variant::before {
+ content: "\F009A";
+}
+.mdi-book-lock::before {
+ content: "\F799";
+}
+.mdi-book-lock-open::before {
+ content: "\F79A";
+}
+.mdi-book-minus::before {
+ content: "\F5D9";
+}
+.mdi-book-minus-multiple::before {
+ content: "\FA93";
+}
+.mdi-book-multiple::before {
+ content: "\F0BB";
+}
+.mdi-book-open::before {
+ content: "\F0BD";
+}
+.mdi-book-open-outline::before {
+ content: "\FB3F";
+}
+.mdi-book-open-page-variant::before {
+ content: "\F5DA";
+}
+.mdi-book-open-variant::before {
+ content: "\F0BE";
+}
+.mdi-book-outline::before {
+ content: "\FB40";
+}
+.mdi-book-play::before {
+ content: "\FE9F";
+}
+.mdi-book-play-outline::before {
+ content: "\FEA0";
+}
+.mdi-book-plus::before {
+ content: "\F5DB";
+}
+.mdi-book-plus-multiple::before {
+ content: "\FA94";
+}
+.mdi-book-remove::before {
+ content: "\FA96";
+}
+.mdi-book-remove-multiple::before {
+ content: "\FA95";
+}
+.mdi-book-search::before {
+ content: "\FEA1";
+}
+.mdi-book-search-outline::before {
+ content: "\FEA2";
+}
+.mdi-book-variant::before {
+ content: "\F0BF";
+}
+.mdi-book-variant-multiple::before {
+ content: "\F0BC";
+}
+.mdi-bookmark::before {
+ content: "\F0C0";
+}
+.mdi-bookmark-check::before {
+ content: "\F0C1";
+}
+.mdi-bookmark-check-outline::before {
+ content: "\F03A6";
+}
+.mdi-bookmark-minus::before {
+ content: "\F9CB";
+}
+.mdi-bookmark-minus-outline::before {
+ content: "\F9CC";
+}
+.mdi-bookmark-multiple::before {
+ content: "\FDF8";
+}
+.mdi-bookmark-multiple-outline::before {
+ content: "\FDF9";
+}
+.mdi-bookmark-music::before {
+ content: "\F0C2";
+}
+.mdi-bookmark-music-outline::before {
+ content: "\F03A4";
+}
+.mdi-bookmark-off::before {
+ content: "\F9CD";
+}
+.mdi-bookmark-off-outline::before {
+ content: "\F9CE";
+}
+.mdi-bookmark-outline::before {
+ content: "\F0C3";
+}
+.mdi-bookmark-plus::before {
+ content: "\F0C5";
+}
+.mdi-bookmark-plus-outline::before {
+ content: "\F0C4";
+}
+.mdi-bookmark-remove::before {
+ content: "\F0C6";
+}
+.mdi-bookmark-remove-outline::before {
+ content: "\F03A5";
+}
+.mdi-bookshelf::before {
+ content: "\F028A";
+}
+.mdi-boom-gate::before {
+ content: "\FEA3";
+}
+.mdi-boom-gate-alert::before {
+ content: "\FEA4";
+}
+.mdi-boom-gate-alert-outline::before {
+ content: "\FEA5";
+}
+.mdi-boom-gate-down::before {
+ content: "\FEA6";
+}
+.mdi-boom-gate-down-outline::before {
+ content: "\FEA7";
+}
+.mdi-boom-gate-outline::before {
+ content: "\FEA8";
+}
+.mdi-boom-gate-up::before {
+ content: "\FEA9";
+}
+.mdi-boom-gate-up-outline::before {
+ content: "\FEAA";
+}
+.mdi-boombox::before {
+ content: "\F5DC";
+}
+.mdi-boomerang::before {
+ content: "\F00FA";
+}
+.mdi-bootstrap::before {
+ content: "\F6C5";
+}
+.mdi-border-all::before {
+ content: "\F0C7";
+}
+.mdi-border-all-variant::before {
+ content: "\F8A0";
+}
+.mdi-border-bottom::before {
+ content: "\F0C8";
+}
+.mdi-border-bottom-variant::before {
+ content: "\F8A1";
+}
+.mdi-border-color::before {
+ content: "\F0C9";
+}
+.mdi-border-horizontal::before {
+ content: "\F0CA";
+}
+.mdi-border-inside::before {
+ content: "\F0CB";
+}
+.mdi-border-left::before {
+ content: "\F0CC";
+}
+.mdi-border-left-variant::before {
+ content: "\F8A2";
+}
+.mdi-border-none::before {
+ content: "\F0CD";
+}
+.mdi-border-none-variant::before {
+ content: "\F8A3";
+}
+.mdi-border-outside::before {
+ content: "\F0CE";
+}
+.mdi-border-right::before {
+ content: "\F0CF";
+}
+.mdi-border-right-variant::before {
+ content: "\F8A4";
+}
+.mdi-border-style::before {
+ content: "\F0D0";
+}
+.mdi-border-top::before {
+ content: "\F0D1";
+}
+.mdi-border-top-variant::before {
+ content: "\F8A5";
+}
+.mdi-border-vertical::before {
+ content: "\F0D2";
+}
+.mdi-bottle-soda::before {
+ content: "\F009B";
+}
+.mdi-bottle-soda-classic::before {
+ content: "\F009C";
+}
+.mdi-bottle-soda-classic-outline::before {
+ content: "\F038E";
+}
+.mdi-bottle-soda-outline::before {
+ content: "\F009D";
+}
+.mdi-bottle-tonic::before {
+ content: "\F0159";
+}
+.mdi-bottle-tonic-outline::before {
+ content: "\F015A";
+}
+.mdi-bottle-tonic-plus::before {
+ content: "\F015B";
+}
+.mdi-bottle-tonic-plus-outline::before {
+ content: "\F015C";
+}
+.mdi-bottle-tonic-skull::before {
+ content: "\F015D";
+}
+.mdi-bottle-tonic-skull-outline::before {
+ content: "\F015E";
+}
+.mdi-bottle-wine::before {
+ content: "\F853";
+}
+.mdi-bottle-wine-outline::before {
+ content: "\F033B";
+}
+.mdi-bow-tie::before {
+ content: "\F677";
+}
+.mdi-bowl::before {
+ content: "\F617";
+}
+.mdi-bowling::before {
+ content: "\F0D3";
+}
+.mdi-box::before {
+ content: "\F0D4";
+}
+.mdi-box-cutter::before {
+ content: "\F0D5";
+}
+.mdi-box-shadow::before {
+ content: "\F637";
+}
+.mdi-boxing-glove::before {
+ content: "\FB41";
+}
+.mdi-braille::before {
+ content: "\F9CF";
+}
+.mdi-brain::before {
+ content: "\F9D0";
+}
+.mdi-bread-slice::before {
+ content: "\FCCA";
+}
+.mdi-bread-slice-outline::before {
+ content: "\FCCB";
+}
+.mdi-bridge::before {
+ content: "\F618";
+}
+.mdi-briefcase::before {
+ content: "\F0D6";
+}
+.mdi-briefcase-account::before {
+ content: "\FCCC";
+}
+.mdi-briefcase-account-outline::before {
+ content: "\FCCD";
+}
+.mdi-briefcase-check::before {
+ content: "\F0D7";
+}
+.mdi-briefcase-check-outline::before {
+ content: "\F0349";
+}
+.mdi-briefcase-clock::before {
+ content: "\F00FB";
+}
+.mdi-briefcase-clock-outline::before {
+ content: "\F00FC";
+}
+.mdi-briefcase-download::before {
+ content: "\F0D8";
+}
+.mdi-briefcase-download-outline::before {
+ content: "\FC19";
+}
+.mdi-briefcase-edit::before {
+ content: "\FA97";
+}
+.mdi-briefcase-edit-outline::before {
+ content: "\FC1A";
+}
+.mdi-briefcase-minus::before {
+ content: "\FA29";
+}
+.mdi-briefcase-minus-outline::before {
+ content: "\FC1B";
+}
+.mdi-briefcase-outline::before {
+ content: "\F813";
+}
+.mdi-briefcase-plus::before {
+ content: "\FA2A";
+}
+.mdi-briefcase-plus-outline::before {
+ content: "\FC1C";
+}
+.mdi-briefcase-remove::before {
+ content: "\FA2B";
+}
+.mdi-briefcase-remove-outline::before {
+ content: "\FC1D";
+}
+.mdi-briefcase-search::before {
+ content: "\FA2C";
+}
+.mdi-briefcase-search-outline::before {
+ content: "\FC1E";
+}
+.mdi-briefcase-upload::before {
+ content: "\F0D9";
+}
+.mdi-briefcase-upload-outline::before {
+ content: "\FC1F";
+}
+.mdi-brightness-1::before {
+ content: "\F0DA";
+}
+.mdi-brightness-2::before {
+ content: "\F0DB";
+}
+.mdi-brightness-3::before {
+ content: "\F0DC";
+}
+.mdi-brightness-4::before {
+ content: "\F0DD";
+}
+.mdi-brightness-5::before {
+ content: "\F0DE";
+}
+.mdi-brightness-6::before {
+ content: "\F0DF";
+}
+.mdi-brightness-7::before {
+ content: "\F0E0";
+}
+.mdi-brightness-auto::before {
+ content: "\F0E1";
+}
+.mdi-brightness-percent::before {
+ content: "\FCCE";
+}
+.mdi-broom::before {
+ content: "\F0E2";
+}
+.mdi-brush::before {
+ content: "\F0E3";
+}
+.mdi-buddhism::before {
+ content: "\F94A";
+}
+.mdi-buffer::before {
+ content: "\F619";
+}
+.mdi-bug::before {
+ content: "\F0E4";
+}
+.mdi-bug-check::before {
+ content: "\FA2D";
+}
+.mdi-bug-check-outline::before {
+ content: "\FA2E";
+}
+.mdi-bug-outline::before {
+ content: "\FA2F";
+}
+.mdi-bugle::before {
+ content: "\FD90";
+}
+.mdi-bulldozer::before {
+ content: "\FB07";
+}
+.mdi-bullet::before {
+ content: "\FCCF";
+}
+.mdi-bulletin-board::before {
+ content: "\F0E5";
+}
+.mdi-bullhorn::before {
+ content: "\F0E6";
+}
+.mdi-bullhorn-outline::before {
+ content: "\FB08";
+}
+.mdi-bullseye::before {
+ content: "\F5DD";
+}
+.mdi-bullseye-arrow::before {
+ content: "\F8C8";
+}
+.mdi-bulma::before {
+ content: "\F0312";
+}
+.mdi-bunk-bed::before {
+ content: "\F032D";
+}
+.mdi-bus::before {
+ content: "\F0E7";
+}
+.mdi-bus-alert::before {
+ content: "\FA98";
+}
+.mdi-bus-articulated-end::before {
+ content: "\F79B";
+}
+.mdi-bus-articulated-front::before {
+ content: "\F79C";
+}
+.mdi-bus-clock::before {
+ content: "\F8C9";
+}
+.mdi-bus-double-decker::before {
+ content: "\F79D";
+}
+.mdi-bus-marker::before {
+ content: "\F023D";
+}
+.mdi-bus-multiple::before {
+ content: "\FF5C";
+}
+.mdi-bus-school::before {
+ content: "\F79E";
+}
+.mdi-bus-side::before {
+ content: "\F79F";
+}
+.mdi-bus-stop::before {
+ content: "\F0034";
+}
+.mdi-bus-stop-covered::before {
+ content: "\F0035";
+}
+.mdi-bus-stop-uncovered::before {
+ content: "\F0036";
+}
+.mdi-cached::before {
+ content: "\F0E8";
+}
+.mdi-cactus::before {
+ content: "\FD91";
+}
+.mdi-cake::before {
+ content: "\F0E9";
+}
+.mdi-cake-layered::before {
+ content: "\F0EA";
+}
+.mdi-cake-variant::before {
+ content: "\F0EB";
+}
+.mdi-calculator::before {
+ content: "\F0EC";
+}
+.mdi-calculator-variant::before {
+ content: "\FA99";
+}
+.mdi-calendar::before {
+ content: "\F0ED";
+}
+.mdi-calendar-account::before {
+ content: "\FEF4";
+}
+.mdi-calendar-account-outline::before {
+ content: "\FEF5";
+}
+.mdi-calendar-alert::before {
+ content: "\FA30";
+}
+.mdi-calendar-arrow-left::before {
+ content: "\F015F";
+}
+.mdi-calendar-arrow-right::before {
+ content: "\F0160";
+}
+.mdi-calendar-blank::before {
+ content: "\F0EE";
+}
+.mdi-calendar-blank-multiple::before {
+ content: "\F009E";
+}
+.mdi-calendar-blank-outline::before {
+ content: "\FB42";
+}
+.mdi-calendar-check::before {
+ content: "\F0EF";
+}
+.mdi-calendar-check-outline::before {
+ content: "\FC20";
+}
+.mdi-calendar-clock::before {
+ content: "\F0F0";
+}
+.mdi-calendar-edit::before {
+ content: "\F8A6";
+}
+.mdi-calendar-export::before {
+ content: "\FB09";
+}
+.mdi-calendar-heart::before {
+ content: "\F9D1";
+}
+.mdi-calendar-import::before {
+ content: "\FB0A";
+}
+.mdi-calendar-minus::before {
+ content: "\FD38";
+}
+.mdi-calendar-month::before {
+ content: "\FDFA";
+}
+.mdi-calendar-month-outline::before {
+ content: "\FDFB";
+}
+.mdi-calendar-multiple::before {
+ content: "\F0F1";
+}
+.mdi-calendar-multiple-check::before {
+ content: "\F0F2";
+}
+.mdi-calendar-multiselect::before {
+ content: "\FA31";
+}
+.mdi-calendar-outline::before {
+ content: "\FB43";
+}
+.mdi-calendar-plus::before {
+ content: "\F0F3";
+}
+.mdi-calendar-question::before {
+ content: "\F691";
+}
+.mdi-calendar-range::before {
+ content: "\F678";
+}
+.mdi-calendar-range-outline::before {
+ content: "\FB44";
+}
+.mdi-calendar-remove::before {
+ content: "\F0F4";
+}
+.mdi-calendar-remove-outline::before {
+ content: "\FC21";
+}
+.mdi-calendar-repeat::before {
+ content: "\FEAB";
+}
+.mdi-calendar-repeat-outline::before {
+ content: "\FEAC";
+}
+.mdi-calendar-search::before {
+ content: "\F94B";
+}
+.mdi-calendar-star::before {
+ content: "\F9D2";
+}
+.mdi-calendar-text::before {
+ content: "\F0F5";
+}
+.mdi-calendar-text-outline::before {
+ content: "\FC22";
+}
+.mdi-calendar-today::before {
+ content: "\F0F6";
+}
+.mdi-calendar-week::before {
+ content: "\FA32";
+}
+.mdi-calendar-week-begin::before {
+ content: "\FA33";
+}
+.mdi-calendar-weekend::before {
+ content: "\FEF6";
+}
+.mdi-calendar-weekend-outline::before {
+ content: "\FEF7";
+}
+.mdi-call-made::before {
+ content: "\F0F7";
+}
+.mdi-call-merge::before {
+ content: "\F0F8";
+}
+.mdi-call-missed::before {
+ content: "\F0F9";
+}
+.mdi-call-received::before {
+ content: "\F0FA";
+}
+.mdi-call-split::before {
+ content: "\F0FB";
+}
+.mdi-camcorder::before {
+ content: "\F0FC";
+}
+.mdi-camcorder-box::before {
+ content: "\F0FD";
+}
+.mdi-camcorder-box-off::before {
+ content: "\F0FE";
+}
+.mdi-camcorder-off::before {
+ content: "\F0FF";
+}
+.mdi-camera::before {
+ content: "\F100";
+}
+.mdi-camera-account::before {
+ content: "\F8CA";
+}
+.mdi-camera-burst::before {
+ content: "\F692";
+}
+.mdi-camera-control::before {
+ content: "\FB45";
+}
+.mdi-camera-enhance::before {
+ content: "\F101";
+}
+.mdi-camera-enhance-outline::before {
+ content: "\FB46";
+}
+.mdi-camera-front::before {
+ content: "\F102";
+}
+.mdi-camera-front-variant::before {
+ content: "\F103";
+}
+.mdi-camera-gopro::before {
+ content: "\F7A0";
+}
+.mdi-camera-image::before {
+ content: "\F8CB";
+}
+.mdi-camera-iris::before {
+ content: "\F104";
+}
+.mdi-camera-metering-center::before {
+ content: "\F7A1";
+}
+.mdi-camera-metering-matrix::before {
+ content: "\F7A2";
+}
+.mdi-camera-metering-partial::before {
+ content: "\F7A3";
+}
+.mdi-camera-metering-spot::before {
+ content: "\F7A4";
+}
+.mdi-camera-off::before {
+ content: "\F5DF";
+}
+.mdi-camera-outline::before {
+ content: "\FD39";
+}
+.mdi-camera-party-mode::before {
+ content: "\F105";
+}
+.mdi-camera-plus::before {
+ content: "\FEF8";
+}
+.mdi-camera-plus-outline::before {
+ content: "\FEF9";
+}
+.mdi-camera-rear::before {
+ content: "\F106";
+}
+.mdi-camera-rear-variant::before {
+ content: "\F107";
+}
+.mdi-camera-retake::before {
+ content: "\FDFC";
+}
+.mdi-camera-retake-outline::before {
+ content: "\FDFD";
+}
+.mdi-camera-switch::before {
+ content: "\F108";
+}
+.mdi-camera-timer::before {
+ content: "\F109";
+}
+.mdi-camera-wireless::before {
+ content: "\FD92";
+}
+.mdi-camera-wireless-outline::before {
+ content: "\FD93";
+}
+.mdi-campfire::before {
+ content: "\FEFA";
+}
+.mdi-cancel::before {
+ content: "\F739";
+}
+.mdi-candle::before {
+ content: "\F5E2";
+}
+.mdi-candycane::before {
+ content: "\F10A";
+}
+.mdi-cannabis::before {
+ content: "\F7A5";
+}
+.mdi-caps-lock::before {
+ content: "\FA9A";
+}
+.mdi-car::before {
+ content: "\F10B";
+}
+.mdi-car-2-plus::before {
+ content: "\F0037";
+}
+.mdi-car-3-plus::before {
+ content: "\F0038";
+}
+.mdi-car-back::before {
+ content: "\FDFE";
+}
+.mdi-car-battery::before {
+ content: "\F10C";
+}
+.mdi-car-brake-abs::before {
+ content: "\FC23";
+}
+.mdi-car-brake-alert::before {
+ content: "\FC24";
+}
+.mdi-car-brake-hold::before {
+ content: "\FD3A";
+}
+.mdi-car-brake-parking::before {
+ content: "\FD3B";
+}
+.mdi-car-brake-retarder::before {
+ content: "\F0039";
+}
+.mdi-car-child-seat::before {
+ content: "\FFC3";
+}
+.mdi-car-clutch::before {
+ content: "\F003A";
+}
+.mdi-car-connected::before {
+ content: "\F10D";
+}
+.mdi-car-convertible::before {
+ content: "\F7A6";
+}
+.mdi-car-coolant-level::before {
+ content: "\F003B";
+}
+.mdi-car-cruise-control::before {
+ content: "\FD3C";
+}
+.mdi-car-defrost-front::before {
+ content: "\FD3D";
+}
+.mdi-car-defrost-rear::before {
+ content: "\FD3E";
+}
+.mdi-car-door::before {
+ content: "\FB47";
+}
+.mdi-car-door-lock::before {
+ content: "\F00C8";
+}
+.mdi-car-electric::before {
+ content: "\FB48";
+}
+.mdi-car-esp::before {
+ content: "\FC25";
+}
+.mdi-car-estate::before {
+ content: "\F7A7";
+}
+.mdi-car-hatchback::before {
+ content: "\F7A8";
+}
+.mdi-car-info::before {
+ content: "\F01E9";
+}
+.mdi-car-key::before {
+ content: "\FB49";
+}
+.mdi-car-light-dimmed::before {
+ content: "\FC26";
+}
+.mdi-car-light-fog::before {
+ content: "\FC27";
+}
+.mdi-car-light-high::before {
+ content: "\FC28";
+}
+.mdi-car-limousine::before {
+ content: "\F8CC";
+}
+.mdi-car-multiple::before {
+ content: "\FB4A";
+}
+.mdi-car-off::before {
+ content: "\FDFF";
+}
+.mdi-car-parking-lights::before {
+ content: "\FD3F";
+}
+.mdi-car-pickup::before {
+ content: "\F7A9";
+}
+.mdi-car-seat::before {
+ content: "\FFC4";
+}
+.mdi-car-seat-cooler::before {
+ content: "\FFC5";
+}
+.mdi-car-seat-heater::before {
+ content: "\FFC6";
+}
+.mdi-car-shift-pattern::before {
+ content: "\FF5D";
+}
+.mdi-car-side::before {
+ content: "\F7AA";
+}
+.mdi-car-sports::before {
+ content: "\F7AB";
+}
+.mdi-car-tire-alert::before {
+ content: "\FC29";
+}
+.mdi-car-traction-control::before {
+ content: "\FD40";
+}
+.mdi-car-turbocharger::before {
+ content: "\F003C";
+}
+.mdi-car-wash::before {
+ content: "\F10E";
+}
+.mdi-car-windshield::before {
+ content: "\F003D";
+}
+.mdi-car-windshield-outline::before {
+ content: "\F003E";
+}
+.mdi-caravan::before {
+ content: "\F7AC";
+}
+.mdi-card::before {
+ content: "\FB4B";
+}
+.mdi-card-bulleted::before {
+ content: "\FB4C";
+}
+.mdi-card-bulleted-off::before {
+ content: "\FB4D";
+}
+.mdi-card-bulleted-off-outline::before {
+ content: "\FB4E";
+}
+.mdi-card-bulleted-outline::before {
+ content: "\FB4F";
+}
+.mdi-card-bulleted-settings::before {
+ content: "\FB50";
+}
+.mdi-card-bulleted-settings-outline::before {
+ content: "\FB51";
+}
+.mdi-card-outline::before {
+ content: "\FB52";
+}
+.mdi-card-plus::before {
+ content: "\F022A";
+}
+.mdi-card-plus-outline::before {
+ content: "\F022B";
+}
+.mdi-card-search::before {
+ content: "\F009F";
+}
+.mdi-card-search-outline::before {
+ content: "\F00A0";
+}
+.mdi-card-text::before {
+ content: "\FB53";
+}
+.mdi-card-text-outline::before {
+ content: "\FB54";
+}
+.mdi-cards::before {
+ content: "\F638";
+}
+.mdi-cards-club::before {
+ content: "\F8CD";
+}
+.mdi-cards-diamond::before {
+ content: "\F8CE";
+}
+.mdi-cards-diamond-outline::before {
+ content: "\F003F";
+}
+.mdi-cards-heart::before {
+ content: "\F8CF";
+}
+.mdi-cards-outline::before {
+ content: "\F639";
+}
+.mdi-cards-playing-outline::before {
+ content: "\F63A";
+}
+.mdi-cards-spade::before {
+ content: "\F8D0";
+}
+.mdi-cards-variant::before {
+ content: "\F6C6";
+}
+.mdi-carrot::before {
+ content: "\F10F";
+}
+.mdi-cart::before {
+ content: "\F110";
+}
+.mdi-cart-arrow-down::before {
+ content: "\FD42";
+}
+.mdi-cart-arrow-right::before {
+ content: "\FC2A";
+}
+.mdi-cart-arrow-up::before {
+ content: "\FD43";
+}
+.mdi-cart-minus::before {
+ content: "\FD44";
+}
+.mdi-cart-off::before {
+ content: "\F66B";
+}
+.mdi-cart-outline::before {
+ content: "\F111";
+}
+.mdi-cart-plus::before {
+ content: "\F112";
+}
+.mdi-cart-remove::before {
+ content: "\FD45";
+}
+.mdi-case-sensitive-alt::before {
+ content: "\F113";
+}
+.mdi-cash::before {
+ content: "\F114";
+}
+.mdi-cash-100::before {
+ content: "\F115";
+}
+.mdi-cash-marker::before {
+ content: "\FD94";
+}
+.mdi-cash-minus::before {
+ content: "\F028B";
+}
+.mdi-cash-multiple::before {
+ content: "\F116";
+}
+.mdi-cash-plus::before {
+ content: "\F028C";
+}
+.mdi-cash-refund::before {
+ content: "\FA9B";
+}
+.mdi-cash-register::before {
+ content: "\FCD0";
+}
+.mdi-cash-remove::before {
+ content: "\F028D";
+}
+.mdi-cash-usd::before {
+ content: "\F01A1";
+}
+.mdi-cash-usd-outline::before {
+ content: "\F117";
+}
+.mdi-cassette::before {
+ content: "\F9D3";
+}
+.mdi-cast::before {
+ content: "\F118";
+}
+.mdi-cast-audio::before {
+ content: "\F0040";
+}
+.mdi-cast-connected::before {
+ content: "\F119";
+}
+.mdi-cast-education::before {
+ content: "\FE6D";
+}
+.mdi-cast-off::before {
+ content: "\F789";
+}
+.mdi-castle::before {
+ content: "\F11A";
+}
+.mdi-cat::before {
+ content: "\F11B";
+}
+.mdi-cctv::before {
+ content: "\F7AD";
+}
+.mdi-ceiling-light::before {
+ content: "\F768";
+}
+.mdi-cellphone::before {
+ content: "\F11C";
+}
+.mdi-cellphone-android::before {
+ content: "\F11D";
+}
+.mdi-cellphone-arrow-down::before {
+ content: "\F9D4";
+}
+.mdi-cellphone-basic::before {
+ content: "\F11E";
+}
+.mdi-cellphone-dock::before {
+ content: "\F11F";
+}
+.mdi-cellphone-erase::before {
+ content: "\F94C";
+}
+.mdi-cellphone-information::before {
+ content: "\FF5E";
+}
+.mdi-cellphone-iphone::before {
+ content: "\F120";
+}
+.mdi-cellphone-key::before {
+ content: "\F94D";
+}
+.mdi-cellphone-link::before {
+ content: "\F121";
+}
+.mdi-cellphone-link-off::before {
+ content: "\F122";
+}
+.mdi-cellphone-lock::before {
+ content: "\F94E";
+}
+.mdi-cellphone-message::before {
+ content: "\F8D2";
+}
+.mdi-cellphone-message-off::before {
+ content: "\F00FD";
+}
+.mdi-cellphone-nfc::before {
+ content: "\FEAD";
+}
+.mdi-cellphone-nfc-off::before {
+ content: "\F0303";
+}
+.mdi-cellphone-off::before {
+ content: "\F94F";
+}
+.mdi-cellphone-play::before {
+ content: "\F0041";
+}
+.mdi-cellphone-screenshot::before {
+ content: "\FA34";
+}
+.mdi-cellphone-settings::before {
+ content: "\F123";
+}
+.mdi-cellphone-settings-variant::before {
+ content: "\F950";
+}
+.mdi-cellphone-sound::before {
+ content: "\F951";
+}
+.mdi-cellphone-text::before {
+ content: "\F8D1";
+}
+.mdi-cellphone-wireless::before {
+ content: "\F814";
+}
+.mdi-celtic-cross::before {
+ content: "\FCD1";
+}
+.mdi-centos::before {
+ content: "\F0145";
+}
+.mdi-certificate::before {
+ content: "\F124";
+}
+.mdi-certificate-outline::before {
+ content: "\F01B3";
+}
+.mdi-chair-rolling::before {
+ content: "\FFBA";
+}
+.mdi-chair-school::before {
+ content: "\F125";
+}
+.mdi-charity::before {
+ content: "\FC2B";
+}
+.mdi-chart-arc::before {
+ content: "\F126";
+}
+.mdi-chart-areaspline::before {
+ content: "\F127";
+}
+.mdi-chart-areaspline-variant::before {
+ content: "\FEAE";
+}
+.mdi-chart-bar::before {
+ content: "\F128";
+}
+.mdi-chart-bar-stacked::before {
+ content: "\F769";
+}
+.mdi-chart-bell-curve::before {
+ content: "\FC2C";
+}
+.mdi-chart-bell-curve-cumulative::before {
+ content: "\FFC7";
+}
+.mdi-chart-bubble::before {
+ content: "\F5E3";
+}
+.mdi-chart-donut::before {
+ content: "\F7AE";
+}
+.mdi-chart-donut-variant::before {
+ content: "\F7AF";
+}
+.mdi-chart-gantt::before {
+ content: "\F66C";
+}
+.mdi-chart-histogram::before {
+ content: "\F129";
+}
+.mdi-chart-line::before {
+ content: "\F12A";
+}
+.mdi-chart-line-stacked::before {
+ content: "\F76A";
+}
+.mdi-chart-line-variant::before {
+ content: "\F7B0";
+}
+.mdi-chart-multiline::before {
+ content: "\F8D3";
+}
+.mdi-chart-multiple::before {
+ content: "\F023E";
+}
+.mdi-chart-pie::before {
+ content: "\F12B";
+}
+.mdi-chart-ppf::before {
+ content: "\F03AB";
+}
+.mdi-chart-scatter-plot::before {
+ content: "\FEAF";
+}
+.mdi-chart-scatter-plot-hexbin::before {
+ content: "\F66D";
+}
+.mdi-chart-snakey::before {
+ content: "\F020A";
+}
+.mdi-chart-snakey-variant::before {
+ content: "\F020B";
+}
+.mdi-chart-timeline::before {
+ content: "\F66E";
+}
+.mdi-chart-timeline-variant::before {
+ content: "\FEB0";
+}
+.mdi-chart-tree::before {
+ content: "\FEB1";
+}
+.mdi-chat::before {
+ content: "\FB55";
+}
+.mdi-chat-alert::before {
+ content: "\FB56";
+}
+.mdi-chat-alert-outline::before {
+ content: "\F02F4";
+}
+.mdi-chat-outline::before {
+ content: "\FEFB";
+}
+.mdi-chat-processing::before {
+ content: "\FB57";
+}
+.mdi-chat-processing-outline::before {
+ content: "\F02F5";
+}
+.mdi-chat-sleep::before {
+ content: "\F02FC";
+}
+.mdi-chat-sleep-outline::before {
+ content: "\F02FD";
+}
+.mdi-check::before {
+ content: "\F12C";
+}
+.mdi-check-all::before {
+ content: "\F12D";
+}
+.mdi-check-bold::before {
+ content: "\FE6E";
+}
+.mdi-check-box-multiple-outline::before {
+ content: "\FC2D";
+}
+.mdi-check-box-outline::before {
+ content: "\FC2E";
+}
+.mdi-check-circle::before {
+ content: "\F5E0";
+}
+.mdi-check-circle-outline::before {
+ content: "\F5E1";
+}
+.mdi-check-decagram::before {
+ content: "\F790";
+}
+.mdi-check-network::before {
+ content: "\FC2F";
+}
+.mdi-check-network-outline::before {
+ content: "\FC30";
+}
+.mdi-check-outline::before {
+ content: "\F854";
+}
+.mdi-check-underline::before {
+ content: "\FE70";
+}
+.mdi-check-underline-circle::before {
+ content: "\FE71";
+}
+.mdi-check-underline-circle-outline::before {
+ content: "\FE72";
+}
+.mdi-checkbook::before {
+ content: "\FA9C";
+}
+.mdi-checkbox-blank::before {
+ content: "\F12E";
+}
+.mdi-checkbox-blank-circle::before {
+ content: "\F12F";
+}
+.mdi-checkbox-blank-circle-outline::before {
+ content: "\F130";
+}
+.mdi-checkbox-blank-off::before {
+ content: "\F0317";
+}
+.mdi-checkbox-blank-off-outline::before {
+ content: "\F0318";
+}
+.mdi-checkbox-blank-outline::before {
+ content: "\F131";
+}
+.mdi-checkbox-intermediate::before {
+ content: "\F855";
+}
+.mdi-checkbox-marked::before {
+ content: "\F132";
+}
+.mdi-checkbox-marked-circle::before {
+ content: "\F133";
+}
+.mdi-checkbox-marked-circle-outline::before {
+ content: "\F134";
+}
+.mdi-checkbox-marked-outline::before {
+ content: "\F135";
+}
+.mdi-checkbox-multiple-blank::before {
+ content: "\F136";
+}
+.mdi-checkbox-multiple-blank-circle::before {
+ content: "\F63B";
+}
+.mdi-checkbox-multiple-blank-circle-outline::before {
+ content: "\F63C";
+}
+.mdi-checkbox-multiple-blank-outline::before {
+ content: "\F137";
+}
+.mdi-checkbox-multiple-marked::before {
+ content: "\F138";
+}
+.mdi-checkbox-multiple-marked-circle::before {
+ content: "\F63D";
+}
+.mdi-checkbox-multiple-marked-circle-outline::before {
+ content: "\F63E";
+}
+.mdi-checkbox-multiple-marked-outline::before {
+ content: "\F139";
+}
+.mdi-checkerboard::before {
+ content: "\F13A";
+}
+.mdi-checkerboard-minus::before {
+ content: "\F022D";
+}
+.mdi-checkerboard-plus::before {
+ content: "\F022C";
+}
+.mdi-checkerboard-remove::before {
+ content: "\F022E";
+}
+.mdi-cheese::before {
+ content: "\F02E4";
+}
+.mdi-chef-hat::before {
+ content: "\FB58";
+}
+.mdi-chemical-weapon::before {
+ content: "\F13B";
+}
+.mdi-chess-bishop::before {
+ content: "\F85B";
+}
+.mdi-chess-king::before {
+ content: "\F856";
+}
+.mdi-chess-knight::before {
+ content: "\F857";
+}
+.mdi-chess-pawn::before {
+ content: "\F858";
+}
+.mdi-chess-queen::before {
+ content: "\F859";
+}
+.mdi-chess-rook::before {
+ content: "\F85A";
+}
+.mdi-chevron-double-down::before {
+ content: "\F13C";
+}
+.mdi-chevron-double-left::before {
+ content: "\F13D";
+}
+.mdi-chevron-double-right::before {
+ content: "\F13E";
+}
+.mdi-chevron-double-up::before {
+ content: "\F13F";
+}
+.mdi-chevron-down::before {
+ content: "\F140";
+}
+.mdi-chevron-down-box::before {
+ content: "\F9D5";
+}
+.mdi-chevron-down-box-outline::before {
+ content: "\F9D6";
+}
+.mdi-chevron-down-circle::before {
+ content: "\FB0B";
+}
+.mdi-chevron-down-circle-outline::before {
+ content: "\FB0C";
+}
+.mdi-chevron-left::before {
+ content: "\F141";
+}
+.mdi-chevron-left-box::before {
+ content: "\F9D7";
+}
+.mdi-chevron-left-box-outline::before {
+ content: "\F9D8";
+}
+.mdi-chevron-left-circle::before {
+ content: "\FB0D";
+}
+.mdi-chevron-left-circle-outline::before {
+ content: "\FB0E";
+}
+.mdi-chevron-right::before {
+ content: "\F142";
+}
+.mdi-chevron-right-box::before {
+ content: "\F9D9";
+}
+.mdi-chevron-right-box-outline::before {
+ content: "\F9DA";
+}
+.mdi-chevron-right-circle::before {
+ content: "\FB0F";
+}
+.mdi-chevron-right-circle-outline::before {
+ content: "\FB10";
+}
+.mdi-chevron-triple-down::before {
+ content: "\FD95";
+}
+.mdi-chevron-triple-left::before {
+ content: "\FD96";
+}
+.mdi-chevron-triple-right::before {
+ content: "\FD97";
+}
+.mdi-chevron-triple-up::before {
+ content: "\FD98";
+}
+.mdi-chevron-up::before {
+ content: "\F143";
+}
+.mdi-chevron-up-box::before {
+ content: "\F9DB";
+}
+.mdi-chevron-up-box-outline::before {
+ content: "\F9DC";
+}
+.mdi-chevron-up-circle::before {
+ content: "\FB11";
+}
+.mdi-chevron-up-circle-outline::before {
+ content: "\FB12";
+}
+.mdi-chili-hot::before {
+ content: "\F7B1";
+}
+.mdi-chili-medium::before {
+ content: "\F7B2";
+}
+.mdi-chili-mild::before {
+ content: "\F7B3";
+}
+.mdi-chip::before {
+ content: "\F61A";
+}
+.mdi-christianity::before {
+ content: "\F952";
+}
+.mdi-christianity-outline::before {
+ content: "\FCD2";
+}
+.mdi-church::before {
+ content: "\F144";
+}
+.mdi-cigar::before {
+ content: "\F01B4";
+}
+.mdi-circle::before {
+ content: "\F764";
+}
+.mdi-circle-double::before {
+ content: "\FEB2";
+}
+.mdi-circle-edit-outline::before {
+ content: "\F8D4";
+}
+.mdi-circle-expand::before {
+ content: "\FEB3";
+}
+.mdi-circle-medium::before {
+ content: "\F9DD";
+}
+.mdi-circle-off-outline::before {
+ content: "\F00FE";
+}
+.mdi-circle-outline::before {
+ content: "\F765";
+}
+.mdi-circle-slice-1::before {
+ content: "\FA9D";
+}
+.mdi-circle-slice-2::before {
+ content: "\FA9E";
+}
+.mdi-circle-slice-3::before {
+ content: "\FA9F";
+}
+.mdi-circle-slice-4::before {
+ content: "\FAA0";
+}
+.mdi-circle-slice-5::before {
+ content: "\FAA1";
+}
+.mdi-circle-slice-6::before {
+ content: "\FAA2";
+}
+.mdi-circle-slice-7::before {
+ content: "\FAA3";
+}
+.mdi-circle-slice-8::before {
+ content: "\FAA4";
+}
+.mdi-circle-small::before {
+ content: "\F9DE";
+}
+.mdi-circular-saw::before {
+ content: "\FE73";
+}
+.mdi-cisco-webex::before {
+ content: "\F145";
+}
+.mdi-city::before {
+ content: "\F146";
+}
+.mdi-city-variant::before {
+ content: "\FA35";
+}
+.mdi-city-variant-outline::before {
+ content: "\FA36";
+}
+.mdi-clipboard::before {
+ content: "\F147";
+}
+.mdi-clipboard-account::before {
+ content: "\F148";
+}
+.mdi-clipboard-account-outline::before {
+ content: "\FC31";
+}
+.mdi-clipboard-alert::before {
+ content: "\F149";
+}
+.mdi-clipboard-alert-outline::before {
+ content: "\FCD3";
+}
+.mdi-clipboard-arrow-down::before {
+ content: "\F14A";
+}
+.mdi-clipboard-arrow-down-outline::before {
+ content: "\FC32";
+}
+.mdi-clipboard-arrow-left::before {
+ content: "\F14B";
+}
+.mdi-clipboard-arrow-left-outline::before {
+ content: "\FCD4";
+}
+.mdi-clipboard-arrow-right::before {
+ content: "\FCD5";
+}
+.mdi-clipboard-arrow-right-outline::before {
+ content: "\FCD6";
+}
+.mdi-clipboard-arrow-up::before {
+ content: "\FC33";
+}
+.mdi-clipboard-arrow-up-outline::before {
+ content: "\FC34";
+}
+.mdi-clipboard-check::before {
+ content: "\F14C";
+}
+.mdi-clipboard-check-multiple::before {
+ content: "\F028E";
+}
+.mdi-clipboard-check-multiple-outline::before {
+ content: "\F028F";
+}
+.mdi-clipboard-check-outline::before {
+ content: "\F8A7";
+}
+.mdi-clipboard-file::before {
+ content: "\F0290";
+}
+.mdi-clipboard-file-outline::before {
+ content: "\F0291";
+}
+.mdi-clipboard-flow::before {
+ content: "\F6C7";
+}
+.mdi-clipboard-flow-outline::before {
+ content: "\F0142";
+}
+.mdi-clipboard-list::before {
+ content: "\F00FF";
+}
+.mdi-clipboard-list-outline::before {
+ content: "\F0100";
+}
+.mdi-clipboard-multiple::before {
+ content: "\F0292";
+}
+.mdi-clipboard-multiple-outline::before {
+ content: "\F0293";
+}
+.mdi-clipboard-outline::before {
+ content: "\F14D";
+}
+.mdi-clipboard-play::before {
+ content: "\FC35";
+}
+.mdi-clipboard-play-multiple::before {
+ content: "\F0294";
+}
+.mdi-clipboard-play-multiple-outline::before {
+ content: "\F0295";
+}
+.mdi-clipboard-play-outline::before {
+ content: "\FC36";
+}
+.mdi-clipboard-plus::before {
+ content: "\F750";
+}
+.mdi-clipboard-plus-outline::before {
+ content: "\F034A";
+}
+.mdi-clipboard-pulse::before {
+ content: "\F85C";
+}
+.mdi-clipboard-pulse-outline::before {
+ content: "\F85D";
+}
+.mdi-clipboard-text::before {
+ content: "\F14E";
+}
+.mdi-clipboard-text-multiple::before {
+ content: "\F0296";
+}
+.mdi-clipboard-text-multiple-outline::before {
+ content: "\F0297";
+}
+.mdi-clipboard-text-outline::before {
+ content: "\FA37";
+}
+.mdi-clipboard-text-play::before {
+ content: "\FC37";
+}
+.mdi-clipboard-text-play-outline::before {
+ content: "\FC38";
+}
+.mdi-clippy::before {
+ content: "\F14F";
+}
+.mdi-clock::before {
+ content: "\F953";
+}
+.mdi-clock-alert::before {
+ content: "\F954";
+}
+.mdi-clock-alert-outline::before {
+ content: "\F5CE";
+}
+.mdi-clock-check::before {
+ content: "\FFC8";
+}
+.mdi-clock-check-outline::before {
+ content: "\FFC9";
+}
+.mdi-clock-digital::before {
+ content: "\FEB4";
+}
+.mdi-clock-end::before {
+ content: "\F151";
+}
+.mdi-clock-fast::before {
+ content: "\F152";
+}
+.mdi-clock-in::before {
+ content: "\F153";
+}
+.mdi-clock-out::before {
+ content: "\F154";
+}
+.mdi-clock-outline::before {
+ content: "\F150";
+}
+.mdi-clock-start::before {
+ content: "\F155";
+}
+.mdi-close::before {
+ content: "\F156";
+}
+.mdi-close-box::before {
+ content: "\F157";
+}
+.mdi-close-box-multiple::before {
+ content: "\FC39";
+}
+.mdi-close-box-multiple-outline::before {
+ content: "\FC3A";
+}
+.mdi-close-box-outline::before {
+ content: "\F158";
+}
+.mdi-close-circle::before {
+ content: "\F159";
+}
+.mdi-close-circle-outline::before {
+ content: "\F15A";
+}
+.mdi-close-network::before {
+ content: "\F15B";
+}
+.mdi-close-network-outline::before {
+ content: "\FC3B";
+}
+.mdi-close-octagon::before {
+ content: "\F15C";
+}
+.mdi-close-octagon-outline::before {
+ content: "\F15D";
+}
+.mdi-close-outline::before {
+ content: "\F6C8";
+}
+.mdi-closed-caption::before {
+ content: "\F15E";
+}
+.mdi-closed-caption-outline::before {
+ content: "\FD99";
+}
+.mdi-cloud::before {
+ content: "\F15F";
+}
+.mdi-cloud-alert::before {
+ content: "\F9DF";
+}
+.mdi-cloud-braces::before {
+ content: "\F7B4";
+}
+.mdi-cloud-check::before {
+ content: "\F160";
+}
+.mdi-cloud-check-outline::before {
+ content: "\F02F7";
+}
+.mdi-cloud-circle::before {
+ content: "\F161";
+}
+.mdi-cloud-download::before {
+ content: "\F162";
+}
+.mdi-cloud-download-outline::before {
+ content: "\FB59";
+}
+.mdi-cloud-lock::before {
+ content: "\F021C";
+}
+.mdi-cloud-lock-outline::before {
+ content: "\F021D";
+}
+.mdi-cloud-off-outline::before {
+ content: "\F164";
+}
+.mdi-cloud-outline::before {
+ content: "\F163";
+}
+.mdi-cloud-print::before {
+ content: "\F165";
+}
+.mdi-cloud-print-outline::before {
+ content: "\F166";
+}
+.mdi-cloud-question::before {
+ content: "\FA38";
+}
+.mdi-cloud-search::before {
+ content: "\F955";
+}
+.mdi-cloud-search-outline::before {
+ content: "\F956";
+}
+.mdi-cloud-sync::before {
+ content: "\F63F";
+}
+.mdi-cloud-sync-outline::before {
+ content: "\F0301";
+}
+.mdi-cloud-tags::before {
+ content: "\F7B5";
+}
+.mdi-cloud-upload::before {
+ content: "\F167";
+}
+.mdi-cloud-upload-outline::before {
+ content: "\FB5A";
+}
+.mdi-clover::before {
+ content: "\F815";
+}
+.mdi-coach-lamp::before {
+ content: "\F0042";
+}
+.mdi-coat-rack::before {
+ content: "\F00C9";
+}
+.mdi-code-array::before {
+ content: "\F168";
+}
+.mdi-code-braces::before {
+ content: "\F169";
+}
+.mdi-code-braces-box::before {
+ content: "\F0101";
+}
+.mdi-code-brackets::before {
+ content: "\F16A";
+}
+.mdi-code-equal::before {
+ content: "\F16B";
+}
+.mdi-code-greater-than::before {
+ content: "\F16C";
+}
+.mdi-code-greater-than-or-equal::before {
+ content: "\F16D";
+}
+.mdi-code-less-than::before {
+ content: "\F16E";
+}
+.mdi-code-less-than-or-equal::before {
+ content: "\F16F";
+}
+.mdi-code-not-equal::before {
+ content: "\F170";
+}
+.mdi-code-not-equal-variant::before {
+ content: "\F171";
+}
+.mdi-code-parentheses::before {
+ content: "\F172";
+}
+.mdi-code-parentheses-box::before {
+ content: "\F0102";
+}
+.mdi-code-string::before {
+ content: "\F173";
+}
+.mdi-code-tags::before {
+ content: "\F174";
+}
+.mdi-code-tags-check::before {
+ content: "\F693";
+}
+.mdi-codepen::before {
+ content: "\F175";
+}
+.mdi-coffee::before {
+ content: "\F176";
+}
+.mdi-coffee-maker::before {
+ content: "\F00CA";
+}
+.mdi-coffee-off::before {
+ content: "\FFCA";
+}
+.mdi-coffee-off-outline::before {
+ content: "\FFCB";
+}
+.mdi-coffee-outline::before {
+ content: "\F6C9";
+}
+.mdi-coffee-to-go::before {
+ content: "\F177";
+}
+.mdi-coffee-to-go-outline::before {
+ content: "\F0339";
+}
+.mdi-coffin::before {
+ content: "\FB5B";
+}
+.mdi-cog-clockwise::before {
+ content: "\F0208";
+}
+.mdi-cog-counterclockwise::before {
+ content: "\F0209";
+}
+.mdi-cogs::before {
+ content: "\F8D5";
+}
+.mdi-coin::before {
+ content: "\F0196";
+}
+.mdi-coin-outline::before {
+ content: "\F178";
+}
+.mdi-coins::before {
+ content: "\F694";
+}
+.mdi-collage::before {
+ content: "\F640";
+}
+.mdi-collapse-all::before {
+ content: "\FAA5";
+}
+.mdi-collapse-all-outline::before {
+ content: "\FAA6";
+}
+.mdi-color-helper::before {
+ content: "\F179";
+}
+.mdi-comma::before {
+ content: "\FE74";
+}
+.mdi-comma-box::before {
+ content: "\FE75";
+}
+.mdi-comma-box-outline::before {
+ content: "\FE76";
+}
+.mdi-comma-circle::before {
+ content: "\FE77";
+}
+.mdi-comma-circle-outline::before {
+ content: "\FE78";
+}
+.mdi-comment::before {
+ content: "\F17A";
+}
+.mdi-comment-account::before {
+ content: "\F17B";
+}
+.mdi-comment-account-outline::before {
+ content: "\F17C";
+}
+.mdi-comment-alert::before {
+ content: "\F17D";
+}
+.mdi-comment-alert-outline::before {
+ content: "\F17E";
+}
+.mdi-comment-arrow-left::before {
+ content: "\F9E0";
+}
+.mdi-comment-arrow-left-outline::before {
+ content: "\F9E1";
+}
+.mdi-comment-arrow-right::before {
+ content: "\F9E2";
+}
+.mdi-comment-arrow-right-outline::before {
+ content: "\F9E3";
+}
+.mdi-comment-check::before {
+ content: "\F17F";
+}
+.mdi-comment-check-outline::before {
+ content: "\F180";
+}
+.mdi-comment-edit::before {
+ content: "\F01EA";
+}
+.mdi-comment-edit-outline::before {
+ content: "\F02EF";
+}
+.mdi-comment-eye::before {
+ content: "\FA39";
+}
+.mdi-comment-eye-outline::before {
+ content: "\FA3A";
+}
+.mdi-comment-multiple::before {
+ content: "\F85E";
+}
+.mdi-comment-multiple-outline::before {
+ content: "\F181";
+}
+.mdi-comment-outline::before {
+ content: "\F182";
+}
+.mdi-comment-plus::before {
+ content: "\F9E4";
+}
+.mdi-comment-plus-outline::before {
+ content: "\F183";
+}
+.mdi-comment-processing::before {
+ content: "\F184";
+}
+.mdi-comment-processing-outline::before {
+ content: "\F185";
+}
+.mdi-comment-question::before {
+ content: "\F816";
+}
+.mdi-comment-question-outline::before {
+ content: "\F186";
+}
+.mdi-comment-quote::before {
+ content: "\F0043";
+}
+.mdi-comment-quote-outline::before {
+ content: "\F0044";
+}
+.mdi-comment-remove::before {
+ content: "\F5DE";
+}
+.mdi-comment-remove-outline::before {
+ content: "\F187";
+}
+.mdi-comment-search::before {
+ content: "\FA3B";
+}
+.mdi-comment-search-outline::before {
+ content: "\FA3C";
+}
+.mdi-comment-text::before {
+ content: "\F188";
+}
+.mdi-comment-text-multiple::before {
+ content: "\F85F";
+}
+.mdi-comment-text-multiple-outline::before {
+ content: "\F860";
+}
+.mdi-comment-text-outline::before {
+ content: "\F189";
+}
+.mdi-compare::before {
+ content: "\F18A";
+}
+.mdi-compass::before {
+ content: "\F18B";
+}
+.mdi-compass-off::before {
+ content: "\FB5C";
+}
+.mdi-compass-off-outline::before {
+ content: "\FB5D";
+}
+.mdi-compass-outline::before {
+ content: "\F18C";
+}
+.mdi-compass-rose::before {
+ content: "\F03AD";
+}
+.mdi-concourse-ci::before {
+ content: "\F00CB";
+}
+.mdi-console::before {
+ content: "\F18D";
+}
+.mdi-console-line::before {
+ content: "\F7B6";
+}
+.mdi-console-network::before {
+ content: "\F8A8";
+}
+.mdi-console-network-outline::before {
+ content: "\FC3C";
+}
+.mdi-consolidate::before {
+ content: "\F0103";
+}
+.mdi-contact-mail::before {
+ content: "\F18E";
+}
+.mdi-contact-mail-outline::before {
+ content: "\FEB5";
+}
+.mdi-contact-phone::before {
+ content: "\FEB6";
+}
+.mdi-contact-phone-outline::before {
+ content: "\FEB7";
+}
+.mdi-contactless-payment::before {
+ content: "\FD46";
+}
+.mdi-contacts::before {
+ content: "\F6CA";
+}
+.mdi-contain::before {
+ content: "\FA3D";
+}
+.mdi-contain-end::before {
+ content: "\FA3E";
+}
+.mdi-contain-start::before {
+ content: "\FA3F";
+}
+.mdi-content-copy::before {
+ content: "\F18F";
+}
+.mdi-content-cut::before {
+ content: "\F190";
+}
+.mdi-content-duplicate::before {
+ content: "\F191";
+}
+.mdi-content-paste::before {
+ content: "\F192";
+}
+.mdi-content-save::before {
+ content: "\F193";
+}
+.mdi-content-save-alert::before {
+ content: "\FF5F";
+}
+.mdi-content-save-alert-outline::before {
+ content: "\FF60";
+}
+.mdi-content-save-all::before {
+ content: "\F194";
+}
+.mdi-content-save-all-outline::before {
+ content: "\FF61";
+}
+.mdi-content-save-edit::before {
+ content: "\FCD7";
+}
+.mdi-content-save-edit-outline::before {
+ content: "\FCD8";
+}
+.mdi-content-save-move::before {
+ content: "\FE79";
+}
+.mdi-content-save-move-outline::before {
+ content: "\FE7A";
+}
+.mdi-content-save-outline::before {
+ content: "\F817";
+}
+.mdi-content-save-settings::before {
+ content: "\F61B";
+}
+.mdi-content-save-settings-outline::before {
+ content: "\FB13";
+}
+.mdi-contrast::before {
+ content: "\F195";
+}
+.mdi-contrast-box::before {
+ content: "\F196";
+}
+.mdi-contrast-circle::before {
+ content: "\F197";
+}
+.mdi-controller-classic::before {
+ content: "\FB5E";
+}
+.mdi-controller-classic-outline::before {
+ content: "\FB5F";
+}
+.mdi-cookie::before {
+ content: "\F198";
+}
+.mdi-coolant-temperature::before {
+ content: "\F3C8";
+}
+.mdi-copyright::before {
+ content: "\F5E6";
+}
+.mdi-cordova::before {
+ content: "\F957";
+}
+.mdi-corn::before {
+ content: "\F7B7";
+}
+.mdi-counter::before {
+ content: "\F199";
+}
+.mdi-cow::before {
+ content: "\F19A";
+}
+.mdi-cowboy::before {
+ content: "\FEB8";
+}
+.mdi-cpu-32-bit::before {
+ content: "\FEFC";
+}
+.mdi-cpu-64-bit::before {
+ content: "\FEFD";
+}
+.mdi-crane::before {
+ content: "\F861";
+}
+.mdi-creation::before {
+ content: "\F1C9";
+}
+.mdi-creative-commons::before {
+ content: "\FD47";
+}
+.mdi-credit-card::before {
+ content: "\F0010";
+}
+.mdi-credit-card-clock::before {
+ content: "\FEFE";
+}
+.mdi-credit-card-clock-outline::before {
+ content: "\FFBC";
+}
+.mdi-credit-card-marker::before {
+ content: "\F6A7";
+}
+.mdi-credit-card-marker-outline::before {
+ content: "\FD9A";
+}
+.mdi-credit-card-minus::before {
+ content: "\FFCC";
+}
+.mdi-credit-card-minus-outline::before {
+ content: "\FFCD";
+}
+.mdi-credit-card-multiple::before {
+ content: "\F0011";
+}
+.mdi-credit-card-multiple-outline::before {
+ content: "\F19C";
+}
+.mdi-credit-card-off::before {
+ content: "\F0012";
+}
+.mdi-credit-card-off-outline::before {
+ content: "\F5E4";
+}
+.mdi-credit-card-outline::before {
+ content: "\F19B";
+}
+.mdi-credit-card-plus::before {
+ content: "\F0013";
+}
+.mdi-credit-card-plus-outline::before {
+ content: "\F675";
+}
+.mdi-credit-card-refund::before {
+ content: "\F0014";
+}
+.mdi-credit-card-refund-outline::before {
+ content: "\FAA7";
+}
+.mdi-credit-card-remove::before {
+ content: "\FFCE";
+}
+.mdi-credit-card-remove-outline::before {
+ content: "\FFCF";
+}
+.mdi-credit-card-scan::before {
+ content: "\F0015";
+}
+.mdi-credit-card-scan-outline::before {
+ content: "\F19D";
+}
+.mdi-credit-card-settings::before {
+ content: "\F0016";
+}
+.mdi-credit-card-settings-outline::before {
+ content: "\F8D6";
+}
+.mdi-credit-card-wireless::before {
+ content: "\F801";
+}
+.mdi-credit-card-wireless-outline::before {
+ content: "\FD48";
+}
+.mdi-cricket::before {
+ content: "\FD49";
+}
+.mdi-crop::before {
+ content: "\F19E";
+}
+.mdi-crop-free::before {
+ content: "\F19F";
+}
+.mdi-crop-landscape::before {
+ content: "\F1A0";
+}
+.mdi-crop-portrait::before {
+ content: "\F1A1";
+}
+.mdi-crop-rotate::before {
+ content: "\F695";
+}
+.mdi-crop-square::before {
+ content: "\F1A2";
+}
+.mdi-crosshairs::before {
+ content: "\F1A3";
+}
+.mdi-crosshairs-gps::before {
+ content: "\F1A4";
+}
+.mdi-crosshairs-off::before {
+ content: "\FF62";
+}
+.mdi-crosshairs-question::before {
+ content: "\F0161";
+}
+.mdi-crown::before {
+ content: "\F1A5";
+}
+.mdi-crown-outline::before {
+ content: "\F01FB";
+}
+.mdi-cryengine::before {
+ content: "\F958";
+}
+.mdi-crystal-ball::before {
+ content: "\FB14";
+}
+.mdi-cube::before {
+ content: "\F1A6";
+}
+.mdi-cube-outline::before {
+ content: "\F1A7";
+}
+.mdi-cube-scan::before {
+ content: "\FB60";
+}
+.mdi-cube-send::before {
+ content: "\F1A8";
+}
+.mdi-cube-unfolded::before {
+ content: "\F1A9";
+}
+.mdi-cup::before {
+ content: "\F1AA";
+}
+.mdi-cup-off::before {
+ content: "\F5E5";
+}
+.mdi-cup-off-outline::before {
+ content: "\F03A8";
+}
+.mdi-cup-outline::before {
+ content: "\F033A";
+}
+.mdi-cup-water::before {
+ content: "\F1AB";
+}
+.mdi-cupboard::before {
+ content: "\FF63";
+}
+.mdi-cupboard-outline::before {
+ content: "\FF64";
+}
+.mdi-cupcake::before {
+ content: "\F959";
+}
+.mdi-curling::before {
+ content: "\F862";
+}
+.mdi-currency-bdt::before {
+ content: "\F863";
+}
+.mdi-currency-brl::before {
+ content: "\FB61";
+}
+.mdi-currency-btc::before {
+ content: "\F1AC";
+}
+.mdi-currency-cny::before {
+ content: "\F7B9";
+}
+.mdi-currency-eth::before {
+ content: "\F7BA";
+}
+.mdi-currency-eur::before {
+ content: "\F1AD";
+}
+.mdi-currency-eur-off::before {
+ content: "\F0340";
+}
+.mdi-currency-gbp::before {
+ content: "\F1AE";
+}
+.mdi-currency-ils::before {
+ content: "\FC3D";
+}
+.mdi-currency-inr::before {
+ content: "\F1AF";
+}
+.mdi-currency-jpy::before {
+ content: "\F7BB";
+}
+.mdi-currency-krw::before {
+ content: "\F7BC";
+}
+.mdi-currency-kzt::before {
+ content: "\F864";
+}
+.mdi-currency-ngn::before {
+ content: "\F1B0";
+}
+.mdi-currency-php::before {
+ content: "\F9E5";
+}
+.mdi-currency-rial::before {
+ content: "\FEB9";
+}
+.mdi-currency-rub::before {
+ content: "\F1B1";
+}
+.mdi-currency-sign::before {
+ content: "\F7BD";
+}
+.mdi-currency-try::before {
+ content: "\F1B2";
+}
+.mdi-currency-twd::before {
+ content: "\F7BE";
+}
+.mdi-currency-usd::before {
+ content: "\F1B3";
+}
+.mdi-currency-usd-off::before {
+ content: "\F679";
+}
+.mdi-current-ac::before {
+ content: "\F95A";
+}
+.mdi-current-dc::before {
+ content: "\F95B";
+}
+.mdi-cursor-default::before {
+ content: "\F1B4";
+}
+.mdi-cursor-default-click::before {
+ content: "\FCD9";
+}
+.mdi-cursor-default-click-outline::before {
+ content: "\FCDA";
+}
+.mdi-cursor-default-gesture::before {
+ content: "\F0152";
+}
+.mdi-cursor-default-gesture-outline::before {
+ content: "\F0153";
+}
+.mdi-cursor-default-outline::before {
+ content: "\F1B5";
+}
+.mdi-cursor-move::before {
+ content: "\F1B6";
+}
+.mdi-cursor-pointer::before {
+ content: "\F1B7";
+}
+.mdi-cursor-text::before {
+ content: "\F5E7";
+}
+.mdi-database::before {
+ content: "\F1B8";
+}
+.mdi-database-check::before {
+ content: "\FAA8";
+}
+.mdi-database-edit::before {
+ content: "\FB62";
+}
+.mdi-database-export::before {
+ content: "\F95D";
+}
+.mdi-database-import::before {
+ content: "\F95C";
+}
+.mdi-database-lock::before {
+ content: "\FAA9";
+}
+.mdi-database-marker::before {
+ content: "\F0321";
+}
+.mdi-database-minus::before {
+ content: "\F1B9";
+}
+.mdi-database-plus::before {
+ content: "\F1BA";
+}
+.mdi-database-refresh::before {
+ content: "\FCDB";
+}
+.mdi-database-remove::before {
+ content: "\FCDC";
+}
+.mdi-database-search::before {
+ content: "\F865";
+}
+.mdi-database-settings::before {
+ content: "\FCDD";
+}
+.mdi-death-star::before {
+ content: "\F8D7";
+}
+.mdi-death-star-variant::before {
+ content: "\F8D8";
+}
+.mdi-deathly-hallows::before {
+ content: "\FB63";
+}
+.mdi-debian::before {
+ content: "\F8D9";
+}
+.mdi-debug-step-into::before {
+ content: "\F1BB";
+}
+.mdi-debug-step-out::before {
+ content: "\F1BC";
+}
+.mdi-debug-step-over::before {
+ content: "\F1BD";
+}
+.mdi-decagram::before {
+ content: "\F76B";
+}
+.mdi-decagram-outline::before {
+ content: "\F76C";
+}
+.mdi-decimal::before {
+ content: "\F00CC";
+}
+.mdi-decimal-comma::before {
+ content: "\F00CD";
+}
+.mdi-decimal-comma-decrease::before {
+ content: "\F00CE";
+}
+.mdi-decimal-comma-increase::before {
+ content: "\F00CF";
+}
+.mdi-decimal-decrease::before {
+ content: "\F1BE";
+}
+.mdi-decimal-increase::before {
+ content: "\F1BF";
+}
+.mdi-delete::before {
+ content: "\F1C0";
+}
+.mdi-delete-alert::before {
+ content: "\F00D0";
+}
+.mdi-delete-alert-outline::before {
+ content: "\F00D1";
+}
+.mdi-delete-circle::before {
+ content: "\F682";
+}
+.mdi-delete-circle-outline::before {
+ content: "\FB64";
+}
+.mdi-delete-empty::before {
+ content: "\F6CB";
+}
+.mdi-delete-empty-outline::before {
+ content: "\FEBA";
+}
+.mdi-delete-forever::before {
+ content: "\F5E8";
+}
+.mdi-delete-forever-outline::before {
+ content: "\FB65";
+}
+.mdi-delete-off::before {
+ content: "\F00D2";
+}
+.mdi-delete-off-outline::before {
+ content: "\F00D3";
+}
+.mdi-delete-outline::before {
+ content: "\F9E6";
+}
+.mdi-delete-restore::before {
+ content: "\F818";
+}
+.mdi-delete-sweep::before {
+ content: "\F5E9";
+}
+.mdi-delete-sweep-outline::before {
+ content: "\FC3E";
+}
+.mdi-delete-variant::before {
+ content: "\F1C1";
+}
+.mdi-delta::before {
+ content: "\F1C2";
+}
+.mdi-desk::before {
+ content: "\F0264";
+}
+.mdi-desk-lamp::before {
+ content: "\F95E";
+}
+.mdi-deskphone::before {
+ content: "\F1C3";
+}
+.mdi-desktop-classic::before {
+ content: "\F7BF";
+}
+.mdi-desktop-mac::before {
+ content: "\F1C4";
+}
+.mdi-desktop-mac-dashboard::before {
+ content: "\F9E7";
+}
+.mdi-desktop-tower::before {
+ content: "\F1C5";
+}
+.mdi-desktop-tower-monitor::before {
+ content: "\FAAA";
+}
+.mdi-details::before {
+ content: "\F1C6";
+}
+.mdi-dev-to::before {
+ content: "\FD4A";
+}
+.mdi-developer-board::before {
+ content: "\F696";
+}
+.mdi-deviantart::before {
+ content: "\F1C7";
+}
+.mdi-devices::before {
+ content: "\FFD0";
+}
+.mdi-diabetes::before {
+ content: "\F0151";
+}
+.mdi-dialpad::before {
+ content: "\F61C";
+}
+.mdi-diameter::before {
+ content: "\FC3F";
+}
+.mdi-diameter-outline::before {
+ content: "\FC40";
+}
+.mdi-diameter-variant::before {
+ content: "\FC41";
+}
+.mdi-diamond::before {
+ content: "\FB66";
+}
+.mdi-diamond-outline::before {
+ content: "\FB67";
+}
+.mdi-diamond-stone::before {
+ content: "\F1C8";
+}
+.mdi-dice-1::before {
+ content: "\F1CA";
+}
+.mdi-dice-1-outline::before {
+ content: "\F0175";
+}
+.mdi-dice-2::before {
+ content: "\F1CB";
+}
+.mdi-dice-2-outline::before {
+ content: "\F0176";
+}
+.mdi-dice-3::before {
+ content: "\F1CC";
+}
+.mdi-dice-3-outline::before {
+ content: "\F0177";
+}
+.mdi-dice-4::before {
+ content: "\F1CD";
+}
+.mdi-dice-4-outline::before {
+ content: "\F0178";
+}
+.mdi-dice-5::before {
+ content: "\F1CE";
+}
+.mdi-dice-5-outline::before {
+ content: "\F0179";
+}
+.mdi-dice-6::before {
+ content: "\F1CF";
+}
+.mdi-dice-6-outline::before {
+ content: "\F017A";
+}
+.mdi-dice-d10::before {
+ content: "\F017E";
+}
+.mdi-dice-d10-outline::before {
+ content: "\F76E";
+}
+.mdi-dice-d12::before {
+ content: "\F017F";
+}
+.mdi-dice-d12-outline::before {
+ content: "\F866";
+}
+.mdi-dice-d20::before {
+ content: "\F0180";
+}
+.mdi-dice-d20-outline::before {
+ content: "\F5EA";
+}
+.mdi-dice-d4::before {
+ content: "\F017B";
+}
+.mdi-dice-d4-outline::before {
+ content: "\F5EB";
+}
+.mdi-dice-d6::before {
+ content: "\F017C";
+}
+.mdi-dice-d6-outline::before {
+ content: "\F5EC";
+}
+.mdi-dice-d8::before {
+ content: "\F017D";
+}
+.mdi-dice-d8-outline::before {
+ content: "\F5ED";
+}
+.mdi-dice-multiple::before {
+ content: "\F76D";
+}
+.mdi-dice-multiple-outline::before {
+ content: "\F0181";
+}
+.mdi-dictionary::before {
+ content: "\F61D";
+}
+.mdi-digital-ocean::before {
+ content: "\F0262";
+}
+.mdi-dip-switch::before {
+ content: "\F7C0";
+}
+.mdi-directions::before {
+ content: "\F1D0";
+}
+.mdi-directions-fork::before {
+ content: "\F641";
+}
+.mdi-disc::before {
+ content: "\F5EE";
+}
+.mdi-disc-alert::before {
+ content: "\F1D1";
+}
+.mdi-disc-player::before {
+ content: "\F95F";
+}
+.mdi-discord::before {
+ content: "\F66F";
+}
+.mdi-dishwasher::before {
+ content: "\FAAB";
+}
+.mdi-dishwasher-alert::before {
+ content: "\F01E3";
+}
+.mdi-dishwasher-off::before {
+ content: "\F01E4";
+}
+.mdi-disqus::before {
+ content: "\F1D2";
+}
+.mdi-disqus-outline::before {
+ content: "\F1D3";
+}
+.mdi-distribute-horizontal-center::before {
+ content: "\F01F4";
+}
+.mdi-distribute-horizontal-left::before {
+ content: "\F01F3";
+}
+.mdi-distribute-horizontal-right::before {
+ content: "\F01F5";
+}
+.mdi-distribute-vertical-bottom::before {
+ content: "\F01F6";
+}
+.mdi-distribute-vertical-center::before {
+ content: "\F01F7";
+}
+.mdi-distribute-vertical-top::before {
+ content: "\F01F8";
+}
+.mdi-diving-flippers::before {
+ content: "\FD9B";
+}
+.mdi-diving-helmet::before {
+ content: "\FD9C";
+}
+.mdi-diving-scuba::before {
+ content: "\FD9D";
+}
+.mdi-diving-scuba-flag::before {
+ content: "\FD9E";
+}
+.mdi-diving-scuba-tank::before {
+ content: "\FD9F";
+}
+.mdi-diving-scuba-tank-multiple::before {
+ content: "\FDA0";
+}
+.mdi-diving-snorkel::before {
+ content: "\FDA1";
+}
+.mdi-division::before {
+ content: "\F1D4";
+}
+.mdi-division-box::before {
+ content: "\F1D5";
+}
+.mdi-dlna::before {
+ content: "\FA40";
+}
+.mdi-dna::before {
+ content: "\F683";
+}
+.mdi-dns::before {
+ content: "\F1D6";
+}
+.mdi-dns-outline::before {
+ content: "\FB68";
+}
+.mdi-do-not-disturb::before {
+ content: "\F697";
+}
+.mdi-do-not-disturb-off::before {
+ content: "\F698";
+}
+.mdi-dock-bottom::before {
+ content: "\F00D4";
+}
+.mdi-dock-left::before {
+ content: "\F00D5";
+}
+.mdi-dock-right::before {
+ content: "\F00D6";
+}
+.mdi-dock-window::before {
+ content: "\F00D7";
+}
+.mdi-docker::before {
+ content: "\F867";
+}
+.mdi-doctor::before {
+ content: "\FA41";
+}
+.mdi-dog::before {
+ content: "\FA42";
+}
+.mdi-dog-service::before {
+ content: "\FAAC";
+}
+.mdi-dog-side::before {
+ content: "\FA43";
+}
+.mdi-dolby::before {
+ content: "\F6B2";
+}
+.mdi-dolly::before {
+ content: "\FEBB";
+}
+.mdi-domain::before {
+ content: "\F1D7";
+}
+.mdi-domain-off::before {
+ content: "\FD4B";
+}
+.mdi-domain-plus::before {
+ content: "\F00D8";
+}
+.mdi-domain-remove::before {
+ content: "\F00D9";
+}
+.mdi-domino-mask::before {
+ content: "\F0045";
+}
+.mdi-donkey::before {
+ content: "\F7C1";
+}
+.mdi-door::before {
+ content: "\F819";
+}
+.mdi-door-closed::before {
+ content: "\F81A";
+}
+.mdi-door-closed-lock::before {
+ content: "\F00DA";
+}
+.mdi-door-open::before {
+ content: "\F81B";
+}
+.mdi-doorbell::before {
+ content: "\F0311";
+}
+.mdi-doorbell-video::before {
+ content: "\F868";
+}
+.mdi-dot-net::before {
+ content: "\FAAD";
+}
+.mdi-dots-horizontal::before {
+ content: "\F1D8";
+}
+.mdi-dots-horizontal-circle::before {
+ content: "\F7C2";
+}
+.mdi-dots-horizontal-circle-outline::before {
+ content: "\FB69";
+}
+.mdi-dots-vertical::before {
+ content: "\F1D9";
+}
+.mdi-dots-vertical-circle::before {
+ content: "\F7C3";
+}
+.mdi-dots-vertical-circle-outline::before {
+ content: "\FB6A";
+}
+.mdi-douban::before {
+ content: "\F699";
+}
+.mdi-download::before {
+ content: "\F1DA";
+}
+.mdi-download-lock::before {
+ content: "\F034B";
+}
+.mdi-download-lock-outline::before {
+ content: "\F034C";
+}
+.mdi-download-multiple::before {
+ content: "\F9E8";
+}
+.mdi-download-network::before {
+ content: "\F6F3";
+}
+.mdi-download-network-outline::before {
+ content: "\FC42";
+}
+.mdi-download-off::before {
+ content: "\F00DB";
+}
+.mdi-download-off-outline::before {
+ content: "\F00DC";
+}
+.mdi-download-outline::before {
+ content: "\FB6B";
+}
+.mdi-drag::before {
+ content: "\F1DB";
+}
+.mdi-drag-horizontal::before {
+ content: "\F1DC";
+}
+.mdi-drag-horizontal-variant::before {
+ content: "\F031B";
+}
+.mdi-drag-variant::before {
+ content: "\FB6C";
+}
+.mdi-drag-vertical::before {
+ content: "\F1DD";
+}
+.mdi-drag-vertical-variant::before {
+ content: "\F031C";
+}
+.mdi-drama-masks::before {
+ content: "\FCDE";
+}
+.mdi-draw::before {
+ content: "\FF66";
+}
+.mdi-drawing::before {
+ content: "\F1DE";
+}
+.mdi-drawing-box::before {
+ content: "\F1DF";
+}
+.mdi-dresser::before {
+ content: "\FF67";
+}
+.mdi-dresser-outline::before {
+ content: "\FF68";
+}
+.mdi-dribbble::before {
+ content: "\F1E0";
+}
+.mdi-dribbble-box::before {
+ content: "\F1E1";
+}
+.mdi-drone::before {
+ content: "\F1E2";
+}
+.mdi-dropbox::before {
+ content: "\F1E3";
+}
+.mdi-drupal::before {
+ content: "\F1E4";
+}
+.mdi-duck::before {
+ content: "\F1E5";
+}
+.mdi-dumbbell::before {
+ content: "\F1E6";
+}
+.mdi-dump-truck::before {
+ content: "\FC43";
+}
+.mdi-ear-hearing::before {
+ content: "\F7C4";
+}
+.mdi-ear-hearing-off::before {
+ content: "\FA44";
+}
+.mdi-earth::before {
+ content: "\F1E7";
+}
+.mdi-earth-arrow-right::before {
+ content: "\F033C";
+}
+.mdi-earth-box::before {
+ content: "\F6CC";
+}
+.mdi-earth-box-off::before {
+ content: "\F6CD";
+}
+.mdi-earth-off::before {
+ content: "\F1E8";
+}
+.mdi-edge::before {
+ content: "\F1E9";
+}
+.mdi-edge-legacy::before {
+ content: "\F027B";
+}
+.mdi-egg::before {
+ content: "\FAAE";
+}
+.mdi-egg-easter::before {
+ content: "\FAAF";
+}
+.mdi-eight-track::before {
+ content: "\F9E9";
+}
+.mdi-eject::before {
+ content: "\F1EA";
+}
+.mdi-eject-outline::before {
+ content: "\FB6D";
+}
+.mdi-electric-switch::before {
+ content: "\FEBC";
+}
+.mdi-electric-switch-closed::before {
+ content: "\F0104";
+}
+.mdi-electron-framework::before {
+ content: "\F0046";
+}
+.mdi-elephant::before {
+ content: "\F7C5";
+}
+.mdi-elevation-decline::before {
+ content: "\F1EB";
+}
+.mdi-elevation-rise::before {
+ content: "\F1EC";
+}
+.mdi-elevator::before {
+ content: "\F1ED";
+}
+.mdi-elevator-down::before {
+ content: "\F02ED";
+}
+.mdi-elevator-passenger::before {
+ content: "\F03AC";
+}
+.mdi-elevator-up::before {
+ content: "\F02EC";
+}
+.mdi-ellipse::before {
+ content: "\FEBD";
+}
+.mdi-ellipse-outline::before {
+ content: "\FEBE";
+}
+.mdi-email::before {
+ content: "\F1EE";
+}
+.mdi-email-alert::before {
+ content: "\F6CE";
+}
+.mdi-email-alert-outline::before {
+ content: "\FD1E";
+}
+.mdi-email-box::before {
+ content: "\FCDF";
+}
+.mdi-email-check::before {
+ content: "\FAB0";
+}
+.mdi-email-check-outline::before {
+ content: "\FAB1";
+}
+.mdi-email-edit::before {
+ content: "\FF00";
+}
+.mdi-email-edit-outline::before {
+ content: "\FF01";
+}
+.mdi-email-lock::before {
+ content: "\F1F1";
+}
+.mdi-email-mark-as-unread::before {
+ content: "\FB6E";
+}
+.mdi-email-minus::before {
+ content: "\FF02";
+}
+.mdi-email-minus-outline::before {
+ content: "\FF03";
+}
+.mdi-email-multiple::before {
+ content: "\FF04";
+}
+.mdi-email-multiple-outline::before {
+ content: "\FF05";
+}
+.mdi-email-newsletter::before {
+ content: "\FFD1";
+}
+.mdi-email-open::before {
+ content: "\F1EF";
+}
+.mdi-email-open-multiple::before {
+ content: "\FF06";
+}
+.mdi-email-open-multiple-outline::before {
+ content: "\FF07";
+}
+.mdi-email-open-outline::before {
+ content: "\F5EF";
+}
+.mdi-email-outline::before {
+ content: "\F1F0";
+}
+.mdi-email-plus::before {
+ content: "\F9EA";
+}
+.mdi-email-plus-outline::before {
+ content: "\F9EB";
+}
+.mdi-email-receive::before {
+ content: "\F0105";
+}
+.mdi-email-receive-outline::before {
+ content: "\F0106";
+}
+.mdi-email-search::before {
+ content: "\F960";
+}
+.mdi-email-search-outline::before {
+ content: "\F961";
+}
+.mdi-email-send::before {
+ content: "\F0107";
+}
+.mdi-email-send-outline::before {
+ content: "\F0108";
+}
+.mdi-email-sync::before {
+ content: "\F02F2";
+}
+.mdi-email-sync-outline::before {
+ content: "\F02F3";
+}
+.mdi-email-variant::before {
+ content: "\F5F0";
+}
+.mdi-ember::before {
+ content: "\FB15";
+}
+.mdi-emby::before {
+ content: "\F6B3";
+}
+.mdi-emoticon::before {
+ content: "\FC44";
+}
+.mdi-emoticon-angry::before {
+ content: "\FC45";
+}
+.mdi-emoticon-angry-outline::before {
+ content: "\FC46";
+}
+.mdi-emoticon-confused::before {
+ content: "\F0109";
+}
+.mdi-emoticon-confused-outline::before {
+ content: "\F010A";
+}
+.mdi-emoticon-cool::before {
+ content: "\FC47";
+}
+.mdi-emoticon-cool-outline::before {
+ content: "\F1F3";
+}
+.mdi-emoticon-cry::before {
+ content: "\FC48";
+}
+.mdi-emoticon-cry-outline::before {
+ content: "\FC49";
+}
+.mdi-emoticon-dead::before {
+ content: "\FC4A";
+}
+.mdi-emoticon-dead-outline::before {
+ content: "\F69A";
+}
+.mdi-emoticon-devil::before {
+ content: "\FC4B";
+}
+.mdi-emoticon-devil-outline::before {
+ content: "\F1F4";
+}
+.mdi-emoticon-excited::before {
+ content: "\FC4C";
+}
+.mdi-emoticon-excited-outline::before {
+ content: "\F69B";
+}
+.mdi-emoticon-frown::before {
+ content: "\FF69";
+}
+.mdi-emoticon-frown-outline::before {
+ content: "\FF6A";
+}
+.mdi-emoticon-happy::before {
+ content: "\FC4D";
+}
+.mdi-emoticon-happy-outline::before {
+ content: "\F1F5";
+}
+.mdi-emoticon-kiss::before {
+ content: "\FC4E";
+}
+.mdi-emoticon-kiss-outline::before {
+ content: "\FC4F";
+}
+.mdi-emoticon-lol::before {
+ content: "\F023F";
+}
+.mdi-emoticon-lol-outline::before {
+ content: "\F0240";
+}
+.mdi-emoticon-neutral::before {
+ content: "\FC50";
+}
+.mdi-emoticon-neutral-outline::before {
+ content: "\F1F6";
+}
+.mdi-emoticon-outline::before {
+ content: "\F1F2";
+}
+.mdi-emoticon-poop::before {
+ content: "\F1F7";
+}
+.mdi-emoticon-poop-outline::before {
+ content: "\FC51";
+}
+.mdi-emoticon-sad::before {
+ content: "\FC52";
+}
+.mdi-emoticon-sad-outline::before {
+ content: "\F1F8";
+}
+.mdi-emoticon-tongue::before {
+ content: "\F1F9";
+}
+.mdi-emoticon-tongue-outline::before {
+ content: "\FC53";
+}
+.mdi-emoticon-wink::before {
+ content: "\FC54";
+}
+.mdi-emoticon-wink-outline::before {
+ content: "\FC55";
+}
+.mdi-engine::before {
+ content: "\F1FA";
+}
+.mdi-engine-off::before {
+ content: "\FA45";
+}
+.mdi-engine-off-outline::before {
+ content: "\FA46";
+}
+.mdi-engine-outline::before {
+ content: "\F1FB";
+}
+.mdi-epsilon::before {
+ content: "\F010B";
+}
+.mdi-equal::before {
+ content: "\F1FC";
+}
+.mdi-equal-box::before {
+ content: "\F1FD";
+}
+.mdi-equalizer::before {
+ content: "\FEBF";
+}
+.mdi-equalizer-outline::before {
+ content: "\FEC0";
+}
+.mdi-eraser::before {
+ content: "\F1FE";
+}
+.mdi-eraser-variant::before {
+ content: "\F642";
+}
+.mdi-escalator::before {
+ content: "\F1FF";
+}
+.mdi-escalator-down::before {
+ content: "\F02EB";
+}
+.mdi-escalator-up::before {
+ content: "\F02EA";
+}
+.mdi-eslint::before {
+ content: "\FC56";
+}
+.mdi-et::before {
+ content: "\FAB2";
+}
+.mdi-ethereum::before {
+ content: "\F869";
+}
+.mdi-ethernet::before {
+ content: "\F200";
+}
+.mdi-ethernet-cable::before {
+ content: "\F201";
+}
+.mdi-ethernet-cable-off::before {
+ content: "\F202";
+}
+.mdi-etsy::before {
+ content: "\F203";
+}
+.mdi-ev-station::before {
+ content: "\F5F1";
+}
+.mdi-eventbrite::before {
+ content: "\F7C6";
+}
+.mdi-evernote::before {
+ content: "\F204";
+}
+.mdi-excavator::before {
+ content: "\F0047";
+}
+.mdi-exclamation::before {
+ content: "\F205";
+}
+.mdi-exclamation-thick::before {
+ content: "\F0263";
+}
+.mdi-exit-run::before {
+ content: "\FA47";
+}
+.mdi-exit-to-app::before {
+ content: "\F206";
+}
+.mdi-expand-all::before {
+ content: "\FAB3";
+}
+.mdi-expand-all-outline::before {
+ content: "\FAB4";
+}
+.mdi-expansion-card::before {
+ content: "\F8AD";
+}
+.mdi-expansion-card-variant::before {
+ content: "\FFD2";
+}
+.mdi-exponent::before {
+ content: "\F962";
+}
+.mdi-exponent-box::before {
+ content: "\F963";
+}
+.mdi-export::before {
+ content: "\F207";
+}
+.mdi-export-variant::before {
+ content: "\FB6F";
+}
+.mdi-eye::before {
+ content: "\F208";
+}
+.mdi-eye-check::before {
+ content: "\FCE0";
+}
+.mdi-eye-check-outline::before {
+ content: "\FCE1";
+}
+.mdi-eye-circle::before {
+ content: "\FB70";
+}
+.mdi-eye-circle-outline::before {
+ content: "\FB71";
+}
+.mdi-eye-minus::before {
+ content: "\F0048";
+}
+.mdi-eye-minus-outline::before {
+ content: "\F0049";
+}
+.mdi-eye-off::before {
+ content: "\F209";
+}
+.mdi-eye-off-outline::before {
+ content: "\F6D0";
+}
+.mdi-eye-outline::before {
+ content: "\F6CF";
+}
+.mdi-eye-plus::before {
+ content: "\F86A";
+}
+.mdi-eye-plus-outline::before {
+ content: "\F86B";
+}
+.mdi-eye-settings::before {
+ content: "\F86C";
+}
+.mdi-eye-settings-outline::before {
+ content: "\F86D";
+}
+.mdi-eyedropper::before {
+ content: "\F20A";
+}
+.mdi-eyedropper-variant::before {
+ content: "\F20B";
+}
+.mdi-face::before {
+ content: "\F643";
+}
+.mdi-face-agent::before {
+ content: "\FD4C";
+}
+.mdi-face-outline::before {
+ content: "\FB72";
+}
+.mdi-face-profile::before {
+ content: "\F644";
+}
+.mdi-face-profile-woman::before {
+ content: "\F00A1";
+}
+.mdi-face-recognition::before {
+ content: "\FC57";
+}
+.mdi-face-woman::before {
+ content: "\F00A2";
+}
+.mdi-face-woman-outline::before {
+ content: "\F00A3";
+}
+.mdi-facebook::before {
+ content: "\F20C";
+}
+.mdi-facebook-box::before {
+ content: "\F20D";
+}
+.mdi-facebook-messenger::before {
+ content: "\F20E";
+}
+.mdi-facebook-workplace::before {
+ content: "\FB16";
+}
+.mdi-factory::before {
+ content: "\F20F";
+}
+.mdi-fan::before {
+ content: "\F210";
+}
+.mdi-fan-off::before {
+ content: "\F81C";
+}
+.mdi-fast-forward::before {
+ content: "\F211";
+}
+.mdi-fast-forward-10::before {
+ content: "\FD4D";
+}
+.mdi-fast-forward-30::before {
+ content: "\FCE2";
+}
+.mdi-fast-forward-5::before {
+ content: "\F0223";
+}
+.mdi-fast-forward-outline::before {
+ content: "\F6D1";
+}
+.mdi-fax::before {
+ content: "\F212";
+}
+.mdi-feather::before {
+ content: "\F6D2";
+}
+.mdi-feature-search::before {
+ content: "\FA48";
+}
+.mdi-feature-search-outline::before {
+ content: "\FA49";
+}
+.mdi-fedora::before {
+ content: "\F8DA";
+}
+.mdi-ferris-wheel::before {
+ content: "\FEC1";
+}
+.mdi-ferry::before {
+ content: "\F213";
+}
+.mdi-file::before {
+ content: "\F214";
+}
+.mdi-file-account::before {
+ content: "\F73A";
+}
+.mdi-file-account-outline::before {
+ content: "\F004A";
+}
+.mdi-file-alert::before {
+ content: "\FA4A";
+}
+.mdi-file-alert-outline::before {
+ content: "\FA4B";
+}
+.mdi-file-cabinet::before {
+ content: "\FAB5";
+}
+.mdi-file-cad::before {
+ content: "\FF08";
+}
+.mdi-file-cad-box::before {
+ content: "\FF09";
+}
+.mdi-file-cancel::before {
+ content: "\FDA2";
+}
+.mdi-file-cancel-outline::before {
+ content: "\FDA3";
+}
+.mdi-file-certificate::before {
+ content: "\F01B1";
+}
+.mdi-file-certificate-outline::before {
+ content: "\F01B2";
+}
+.mdi-file-chart::before {
+ content: "\F215";
+}
+.mdi-file-chart-outline::before {
+ content: "\F004B";
+}
+.mdi-file-check::before {
+ content: "\F216";
+}
+.mdi-file-check-outline::before {
+ content: "\FE7B";
+}
+.mdi-file-clock::before {
+ content: "\F030C";
+}
+.mdi-file-clock-outline::before {
+ content: "\F030D";
+}
+.mdi-file-cloud::before {
+ content: "\F217";
+}
+.mdi-file-cloud-outline::before {
+ content: "\F004C";
+}
+.mdi-file-code::before {
+ content: "\F22E";
+}
+.mdi-file-code-outline::before {
+ content: "\F004D";
+}
+.mdi-file-compare::before {
+ content: "\F8A9";
+}
+.mdi-file-delimited::before {
+ content: "\F218";
+}
+.mdi-file-delimited-outline::before {
+ content: "\FEC2";
+}
+.mdi-file-document::before {
+ content: "\F219";
+}
+.mdi-file-document-box::before {
+ content: "\F21A";
+}
+.mdi-file-document-box-check::before {
+ content: "\FEC3";
+}
+.mdi-file-document-box-check-outline::before {
+ content: "\FEC4";
+}
+.mdi-file-document-box-minus::before {
+ content: "\FEC5";
+}
+.mdi-file-document-box-minus-outline::before {
+ content: "\FEC6";
+}
+.mdi-file-document-box-multiple::before {
+ content: "\FAB6";
+}
+.mdi-file-document-box-multiple-outline::before {
+ content: "\FAB7";
+}
+.mdi-file-document-box-outline::before {
+ content: "\F9EC";
+}
+.mdi-file-document-box-plus::before {
+ content: "\FEC7";
+}
+.mdi-file-document-box-plus-outline::before {
+ content: "\FEC8";
+}
+.mdi-file-document-box-remove::before {
+ content: "\FEC9";
+}
+.mdi-file-document-box-remove-outline::before {
+ content: "\FECA";
+}
+.mdi-file-document-box-search::before {
+ content: "\FECB";
+}
+.mdi-file-document-box-search-outline::before {
+ content: "\FECC";
+}
+.mdi-file-document-edit::before {
+ content: "\FDA4";
+}
+.mdi-file-document-edit-outline::before {
+ content: "\FDA5";
+}
+.mdi-file-document-outline::before {
+ content: "\F9ED";
+}
+.mdi-file-download::before {
+ content: "\F964";
+}
+.mdi-file-download-outline::before {
+ content: "\F965";
+}
+.mdi-file-edit::before {
+ content: "\F0212";
+}
+.mdi-file-edit-outline::before {
+ content: "\F0213";
+}
+.mdi-file-excel::before {
+ content: "\F21B";
+}
+.mdi-file-excel-box::before {
+ content: "\F21C";
+}
+.mdi-file-excel-box-outline::before {
+ content: "\F004E";
+}
+.mdi-file-excel-outline::before {
+ content: "\F004F";
+}
+.mdi-file-export::before {
+ content: "\F21D";
+}
+.mdi-file-export-outline::before {
+ content: "\F0050";
+}
+.mdi-file-eye::before {
+ content: "\FDA6";
+}
+.mdi-file-eye-outline::before {
+ content: "\FDA7";
+}
+.mdi-file-find::before {
+ content: "\F21E";
+}
+.mdi-file-find-outline::before {
+ content: "\FB73";
+}
+.mdi-file-hidden::before {
+ content: "\F613";
+}
+.mdi-file-image::before {
+ content: "\F21F";
+}
+.mdi-file-image-outline::before {
+ content: "\FECD";
+}
+.mdi-file-import::before {
+ content: "\F220";
+}
+.mdi-file-import-outline::before {
+ content: "\F0051";
+}
+.mdi-file-key::before {
+ content: "\F01AF";
+}
+.mdi-file-key-outline::before {
+ content: "\F01B0";
+}
+.mdi-file-link::before {
+ content: "\F01A2";
+}
+.mdi-file-link-outline::before {
+ content: "\F01A3";
+}
+.mdi-file-lock::before {
+ content: "\F221";
+}
+.mdi-file-lock-outline::before {
+ content: "\F0052";
+}
+.mdi-file-move::before {
+ content: "\FAB8";
+}
+.mdi-file-move-outline::before {
+ content: "\F0053";
+}
+.mdi-file-multiple::before {
+ content: "\F222";
+}
+.mdi-file-multiple-outline::before {
+ content: "\F0054";
+}
+.mdi-file-music::before {
+ content: "\F223";
+}
+.mdi-file-music-outline::before {
+ content: "\FE7C";
+}
+.mdi-file-outline::before {
+ content: "\F224";
+}
+.mdi-file-pdf::before {
+ content: "\F225";
+}
+.mdi-file-pdf-box::before {
+ content: "\F226";
+}
+.mdi-file-pdf-box-outline::before {
+ content: "\FFD3";
+}
+.mdi-file-pdf-outline::before {
+ content: "\FE7D";
+}
+.mdi-file-percent::before {
+ content: "\F81D";
+}
+.mdi-file-percent-outline::before {
+ content: "\F0055";
+}
+.mdi-file-phone::before {
+ content: "\F01A4";
+}
+.mdi-file-phone-outline::before {
+ content: "\F01A5";
+}
+.mdi-file-plus::before {
+ content: "\F751";
+}
+.mdi-file-plus-outline::before {
+ content: "\FF0A";
+}
+.mdi-file-powerpoint::before {
+ content: "\F227";
+}
+.mdi-file-powerpoint-box::before {
+ content: "\F228";
+}
+.mdi-file-powerpoint-box-outline::before {
+ content: "\F0056";
+}
+.mdi-file-powerpoint-outline::before {
+ content: "\F0057";
+}
+.mdi-file-presentation-box::before {
+ content: "\F229";
+}
+.mdi-file-question::before {
+ content: "\F86E";
+}
+.mdi-file-question-outline::before {
+ content: "\F0058";
+}
+.mdi-file-remove::before {
+ content: "\FB74";
+}
+.mdi-file-remove-outline::before {
+ content: "\F0059";
+}
+.mdi-file-replace::before {
+ content: "\FB17";
+}
+.mdi-file-replace-outline::before {
+ content: "\FB18";
+}
+.mdi-file-restore::before {
+ content: "\F670";
+}
+.mdi-file-restore-outline::before {
+ content: "\F005A";
+}
+.mdi-file-search::before {
+ content: "\FC58";
+}
+.mdi-file-search-outline::before {
+ content: "\FC59";
+}
+.mdi-file-send::before {
+ content: "\F22A";
+}
+.mdi-file-send-outline::before {
+ content: "\F005B";
+}
+.mdi-file-settings::before {
+ content: "\F00A4";
+}
+.mdi-file-settings-outline::before {
+ content: "\F00A5";
+}
+.mdi-file-settings-variant::before {
+ content: "\F00A6";
+}
+.mdi-file-settings-variant-outline::before {
+ content: "\F00A7";
+}
+.mdi-file-star::before {
+ content: "\F005C";
+}
+.mdi-file-star-outline::before {
+ content: "\F005D";
+}
+.mdi-file-swap::before {
+ content: "\FFD4";
+}
+.mdi-file-swap-outline::before {
+ content: "\FFD5";
+}
+.mdi-file-sync::before {
+ content: "\F0241";
+}
+.mdi-file-sync-outline::before {
+ content: "\F0242";
+}
+.mdi-file-table::before {
+ content: "\FC5A";
+}
+.mdi-file-table-box::before {
+ content: "\F010C";
+}
+.mdi-file-table-box-multiple::before {
+ content: "\F010D";
+}
+.mdi-file-table-box-multiple-outline::before {
+ content: "\F010E";
+}
+.mdi-file-table-box-outline::before {
+ content: "\F010F";
+}
+.mdi-file-table-outline::before {
+ content: "\FC5B";
+}
+.mdi-file-tree::before {
+ content: "\F645";
+}
+.mdi-file-undo::before {
+ content: "\F8DB";
+}
+.mdi-file-undo-outline::before {
+ content: "\F005E";
+}
+.mdi-file-upload::before {
+ content: "\FA4C";
+}
+.mdi-file-upload-outline::before {
+ content: "\FA4D";
+}
+.mdi-file-video::before {
+ content: "\F22B";
+}
+.mdi-file-video-outline::before {
+ content: "\FE10";
+}
+.mdi-file-word::before {
+ content: "\F22C";
+}
+.mdi-file-word-box::before {
+ content: "\F22D";
+}
+.mdi-file-word-box-outline::before {
+ content: "\F005F";
+}
+.mdi-file-word-outline::before {
+ content: "\F0060";
+}
+.mdi-film::before {
+ content: "\F22F";
+}
+.mdi-filmstrip::before {
+ content: "\F230";
+}
+.mdi-filmstrip-off::before {
+ content: "\F231";
+}
+.mdi-filter::before {
+ content: "\F232";
+}
+.mdi-filter-menu::before {
+ content: "\F0110";
+}
+.mdi-filter-menu-outline::before {
+ content: "\F0111";
+}
+.mdi-filter-minus::before {
+ content: "\FF0B";
+}
+.mdi-filter-minus-outline::before {
+ content: "\FF0C";
+}
+.mdi-filter-outline::before {
+ content: "\F233";
+}
+.mdi-filter-plus::before {
+ content: "\FF0D";
+}
+.mdi-filter-plus-outline::before {
+ content: "\FF0E";
+}
+.mdi-filter-remove::before {
+ content: "\F234";
+}
+.mdi-filter-remove-outline::before {
+ content: "\F235";
+}
+.mdi-filter-variant::before {
+ content: "\F236";
+}
+.mdi-filter-variant-minus::before {
+ content: "\F013D";
+}
+.mdi-filter-variant-plus::before {
+ content: "\F013E";
+}
+.mdi-filter-variant-remove::before {
+ content: "\F0061";
+}
+.mdi-finance::before {
+ content: "\F81E";
+}
+.mdi-find-replace::before {
+ content: "\F6D3";
+}
+.mdi-fingerprint::before {
+ content: "\F237";
+}
+.mdi-fingerprint-off::before {
+ content: "\FECE";
+}
+.mdi-fire::before {
+ content: "\F238";
+}
+.mdi-fire-extinguisher::before {
+ content: "\FF0F";
+}
+.mdi-fire-hydrant::before {
+ content: "\F0162";
+}
+.mdi-fire-hydrant-alert::before {
+ content: "\F0163";
+}
+.mdi-fire-hydrant-off::before {
+ content: "\F0164";
+}
+.mdi-fire-truck::before {
+ content: "\F8AA";
+}
+.mdi-firebase::before {
+ content: "\F966";
+}
+.mdi-firefox::before {
+ content: "\F239";
+}
+.mdi-fireplace::before {
+ content: "\FE11";
+}
+.mdi-fireplace-off::before {
+ content: "\FE12";
+}
+.mdi-firework::before {
+ content: "\FE13";
+}
+.mdi-fish::before {
+ content: "\F23A";
+}
+.mdi-fishbowl::before {
+ content: "\FF10";
+}
+.mdi-fishbowl-outline::before {
+ content: "\FF11";
+}
+.mdi-fit-to-page::before {
+ content: "\FF12";
+}
+.mdi-fit-to-page-outline::before {
+ content: "\FF13";
+}
+.mdi-flag::before {
+ content: "\F23B";
+}
+.mdi-flag-checkered::before {
+ content: "\F23C";
+}
+.mdi-flag-minus::before {
+ content: "\FB75";
+}
+.mdi-flag-minus-outline::before {
+ content: "\F00DD";
+}
+.mdi-flag-outline::before {
+ content: "\F23D";
+}
+.mdi-flag-plus::before {
+ content: "\FB76";
+}
+.mdi-flag-plus-outline::before {
+ content: "\F00DE";
+}
+.mdi-flag-remove::before {
+ content: "\FB77";
+}
+.mdi-flag-remove-outline::before {
+ content: "\F00DF";
+}
+.mdi-flag-triangle::before {
+ content: "\F23F";
+}
+.mdi-flag-variant::before {
+ content: "\F240";
+}
+.mdi-flag-variant-outline::before {
+ content: "\F23E";
+}
+.mdi-flare::before {
+ content: "\FD4E";
+}
+.mdi-flash::before {
+ content: "\F241";
+}
+.mdi-flash-alert::before {
+ content: "\FF14";
+}
+.mdi-flash-alert-outline::before {
+ content: "\FF15";
+}
+.mdi-flash-auto::before {
+ content: "\F242";
+}
+.mdi-flash-circle::before {
+ content: "\F81F";
+}
+.mdi-flash-off::before {
+ content: "\F243";
+}
+.mdi-flash-outline::before {
+ content: "\F6D4";
+}
+.mdi-flash-red-eye::before {
+ content: "\F67A";
+}
+.mdi-flashlight::before {
+ content: "\F244";
+}
+.mdi-flashlight-off::before {
+ content: "\F245";
+}
+.mdi-flask::before {
+ content: "\F093";
+}
+.mdi-flask-empty::before {
+ content: "\F094";
+}
+.mdi-flask-empty-minus::before {
+ content: "\F0265";
+}
+.mdi-flask-empty-minus-outline::before {
+ content: "\F0266";
+}
+.mdi-flask-empty-outline::before {
+ content: "\F095";
+}
+.mdi-flask-empty-plus::before {
+ content: "\F0267";
+}
+.mdi-flask-empty-plus-outline::before {
+ content: "\F0268";
+}
+.mdi-flask-empty-remove::before {
+ content: "\F0269";
+}
+.mdi-flask-empty-remove-outline::before {
+ content: "\F026A";
+}
+.mdi-flask-minus::before {
+ content: "\F026B";
+}
+.mdi-flask-minus-outline::before {
+ content: "\F026C";
+}
+.mdi-flask-outline::before {
+ content: "\F096";
+}
+.mdi-flask-plus::before {
+ content: "\F026D";
+}
+.mdi-flask-plus-outline::before {
+ content: "\F026E";
+}
+.mdi-flask-remove::before {
+ content: "\F026F";
+}
+.mdi-flask-remove-outline::before {
+ content: "\F0270";
+}
+.mdi-flask-round-bottom::before {
+ content: "\F0276";
+}
+.mdi-flask-round-bottom-empty::before {
+ content: "\F0277";
+}
+.mdi-flask-round-bottom-empty-outline::before {
+ content: "\F0278";
+}
+.mdi-flask-round-bottom-outline::before {
+ content: "\F0279";
+}
+.mdi-flattr::before {
+ content: "\F246";
+}
+.mdi-fleur-de-lis::before {
+ content: "\F032E";
+}
+.mdi-flickr::before {
+ content: "\FCE3";
+}
+.mdi-flip-horizontal::before {
+ content: "\F0112";
+}
+.mdi-flip-to-back::before {
+ content: "\F247";
+}
+.mdi-flip-to-front::before {
+ content: "\F248";
+}
+.mdi-flip-vertical::before {
+ content: "\F0113";
+}
+.mdi-floor-lamp::before {
+ content: "\F8DC";
+}
+.mdi-floor-lamp-dual::before {
+ content: "\F0062";
+}
+.mdi-floor-lamp-variant::before {
+ content: "\F0063";
+}
+.mdi-floor-plan::before {
+ content: "\F820";
+}
+.mdi-floppy::before {
+ content: "\F249";
+}
+.mdi-floppy-variant::before {
+ content: "\F9EE";
+}
+.mdi-flower::before {
+ content: "\F24A";
+}
+.mdi-flower-outline::before {
+ content: "\F9EF";
+}
+.mdi-flower-poppy::before {
+ content: "\FCE4";
+}
+.mdi-flower-tulip::before {
+ content: "\F9F0";
+}
+.mdi-flower-tulip-outline::before {
+ content: "\F9F1";
+}
+.mdi-focus-auto::before {
+ content: "\FF6B";
+}
+.mdi-focus-field::before {
+ content: "\FF6C";
+}
+.mdi-focus-field-horizontal::before {
+ content: "\FF6D";
+}
+.mdi-focus-field-vertical::before {
+ content: "\FF6E";
+}
+.mdi-folder::before {
+ content: "\F24B";
+}
+.mdi-folder-account::before {
+ content: "\F24C";
+}
+.mdi-folder-account-outline::before {
+ content: "\FB78";
+}
+.mdi-folder-alert::before {
+ content: "\FDA8";
+}
+.mdi-folder-alert-outline::before {
+ content: "\FDA9";
+}
+.mdi-folder-clock::before {
+ content: "\FAB9";
+}
+.mdi-folder-clock-outline::before {
+ content: "\FABA";
+}
+.mdi-folder-download::before {
+ content: "\F24D";
+}
+.mdi-folder-download-outline::before {
+ content: "\F0114";
+}
+.mdi-folder-edit::before {
+ content: "\F8DD";
+}
+.mdi-folder-edit-outline::before {
+ content: "\FDAA";
+}
+.mdi-folder-google-drive::before {
+ content: "\F24E";
+}
+.mdi-folder-heart::before {
+ content: "\F0115";
+}
+.mdi-folder-heart-outline::before {
+ content: "\F0116";
+}
+.mdi-folder-home::before {
+ content: "\F00E0";
+}
+.mdi-folder-home-outline::before {
+ content: "\F00E1";
+}
+.mdi-folder-image::before {
+ content: "\F24F";
+}
+.mdi-folder-information::before {
+ content: "\F00E2";
+}
+.mdi-folder-information-outline::before {
+ content: "\F00E3";
+}
+.mdi-folder-key::before {
+ content: "\F8AB";
+}
+.mdi-folder-key-network::before {
+ content: "\F8AC";
+}
+.mdi-folder-key-network-outline::before {
+ content: "\FC5C";
+}
+.mdi-folder-key-outline::before {
+ content: "\F0117";
+}
+.mdi-folder-lock::before {
+ content: "\F250";
+}
+.mdi-folder-lock-open::before {
+ content: "\F251";
+}
+.mdi-folder-marker::before {
+ content: "\F0298";
+}
+.mdi-folder-marker-outline::before {
+ content: "\F0299";
+}
+.mdi-folder-move::before {
+ content: "\F252";
+}
+.mdi-folder-move-outline::before {
+ content: "\F0271";
+}
+.mdi-folder-multiple::before {
+ content: "\F253";
+}
+.mdi-folder-multiple-image::before {
+ content: "\F254";
+}
+.mdi-folder-multiple-outline::before {
+ content: "\F255";
+}
+.mdi-folder-music::before {
+ content: "\F0384";
+}
+.mdi-folder-music-outline::before {
+ content: "\F0385";
+}
+.mdi-folder-network::before {
+ content: "\F86F";
+}
+.mdi-folder-network-outline::before {
+ content: "\FC5D";
+}
+.mdi-folder-open::before {
+ content: "\F76F";
+}
+.mdi-folder-open-outline::before {
+ content: "\FDAB";
+}
+.mdi-folder-outline::before {
+ content: "\F256";
+}
+.mdi-folder-plus::before {
+ content: "\F257";
+}
+.mdi-folder-plus-outline::before {
+ content: "\FB79";
+}
+.mdi-folder-pound::before {
+ content: "\FCE5";
+}
+.mdi-folder-pound-outline::before {
+ content: "\FCE6";
+}
+.mdi-folder-remove::before {
+ content: "\F258";
+}
+.mdi-folder-remove-outline::before {
+ content: "\FB7A";
+}
+.mdi-folder-search::before {
+ content: "\F967";
+}
+.mdi-folder-search-outline::before {
+ content: "\F968";
+}
+.mdi-folder-settings::before {
+ content: "\F00A8";
+}
+.mdi-folder-settings-outline::before {
+ content: "\F00A9";
+}
+.mdi-folder-settings-variant::before {
+ content: "\F00AA";
+}
+.mdi-folder-settings-variant-outline::before {
+ content: "\F00AB";
+}
+.mdi-folder-star::before {
+ content: "\F69C";
+}
+.mdi-folder-star-outline::before {
+ content: "\FB7B";
+}
+.mdi-folder-swap::before {
+ content: "\FFD6";
+}
+.mdi-folder-swap-outline::before {
+ content: "\FFD7";
+}
+.mdi-folder-sync::before {
+ content: "\FCE7";
+}
+.mdi-folder-sync-outline::before {
+ content: "\FCE8";
+}
+.mdi-folder-table::before {
+ content: "\F030E";
+}
+.mdi-folder-table-outline::before {
+ content: "\F030F";
+}
+.mdi-folder-text::before {
+ content: "\FC5E";
+}
+.mdi-folder-text-outline::before {
+ content: "\FC5F";
+}
+.mdi-folder-upload::before {
+ content: "\F259";
+}
+.mdi-folder-upload-outline::before {
+ content: "\F0118";
+}
+.mdi-folder-zip::before {
+ content: "\F6EA";
+}
+.mdi-folder-zip-outline::before {
+ content: "\F7B8";
+}
+.mdi-font-awesome::before {
+ content: "\F03A";
+}
+.mdi-food::before {
+ content: "\F25A";
+}
+.mdi-food-apple::before {
+ content: "\F25B";
+}
+.mdi-food-apple-outline::before {
+ content: "\FC60";
+}
+.mdi-food-croissant::before {
+ content: "\F7C7";
+}
+.mdi-food-fork-drink::before {
+ content: "\F5F2";
+}
+.mdi-food-off::before {
+ content: "\F5F3";
+}
+.mdi-food-variant::before {
+ content: "\F25C";
+}
+.mdi-foot-print::before {
+ content: "\FF6F";
+}
+.mdi-football::before {
+ content: "\F25D";
+}
+.mdi-football-australian::before {
+ content: "\F25E";
+}
+.mdi-football-helmet::before {
+ content: "\F25F";
+}
+.mdi-forklift::before {
+ content: "\F7C8";
+}
+.mdi-format-align-bottom::before {
+ content: "\F752";
+}
+.mdi-format-align-center::before {
+ content: "\F260";
+}
+.mdi-format-align-justify::before {
+ content: "\F261";
+}
+.mdi-format-align-left::before {
+ content: "\F262";
+}
+.mdi-format-align-middle::before {
+ content: "\F753";
+}
+.mdi-format-align-right::before {
+ content: "\F263";
+}
+.mdi-format-align-top::before {
+ content: "\F754";
+}
+.mdi-format-annotation-minus::before {
+ content: "\FABB";
+}
+.mdi-format-annotation-plus::before {
+ content: "\F646";
+}
+.mdi-format-bold::before {
+ content: "\F264";
+}
+.mdi-format-clear::before {
+ content: "\F265";
+}
+.mdi-format-color-fill::before {
+ content: "\F266";
+}
+.mdi-format-color-highlight::before {
+ content: "\FE14";
+}
+.mdi-format-color-marker-cancel::before {
+ content: "\F033E";
+}
+.mdi-format-color-text::before {
+ content: "\F69D";
+}
+.mdi-format-columns::before {
+ content: "\F8DE";
+}
+.mdi-format-float-center::before {
+ content: "\F267";
+}
+.mdi-format-float-left::before {
+ content: "\F268";
+}
+.mdi-format-float-none::before {
+ content: "\F269";
+}
+.mdi-format-float-right::before {
+ content: "\F26A";
+}
+.mdi-format-font::before {
+ content: "\F6D5";
+}
+.mdi-format-font-size-decrease::before {
+ content: "\F9F2";
+}
+.mdi-format-font-size-increase::before {
+ content: "\F9F3";
+}
+.mdi-format-header-1::before {
+ content: "\F26B";
+}
+.mdi-format-header-2::before {
+ content: "\F26C";
+}
+.mdi-format-header-3::before {
+ content: "\F26D";
+}
+.mdi-format-header-4::before {
+ content: "\F26E";
+}
+.mdi-format-header-5::before {
+ content: "\F26F";
+}
+.mdi-format-header-6::before {
+ content: "\F270";
+}
+.mdi-format-header-decrease::before {
+ content: "\F271";
+}
+.mdi-format-header-equal::before {
+ content: "\F272";
+}
+.mdi-format-header-increase::before {
+ content: "\F273";
+}
+.mdi-format-header-pound::before {
+ content: "\F274";
+}
+.mdi-format-horizontal-align-center::before {
+ content: "\F61E";
+}
+.mdi-format-horizontal-align-left::before {
+ content: "\F61F";
+}
+.mdi-format-horizontal-align-right::before {
+ content: "\F620";
+}
+.mdi-format-indent-decrease::before {
+ content: "\F275";
+}
+.mdi-format-indent-increase::before {
+ content: "\F276";
+}
+.mdi-format-italic::before {
+ content: "\F277";
+}
+.mdi-format-letter-case::before {
+ content: "\FB19";
+}
+.mdi-format-letter-case-lower::before {
+ content: "\FB1A";
+}
+.mdi-format-letter-case-upper::before {
+ content: "\FB1B";
+}
+.mdi-format-letter-ends-with::before {
+ content: "\FFD8";
+}
+.mdi-format-letter-matches::before {
+ content: "\FFD9";
+}
+.mdi-format-letter-starts-with::before {
+ content: "\FFDA";
+}
+.mdi-format-line-spacing::before {
+ content: "\F278";
+}
+.mdi-format-line-style::before {
+ content: "\F5C8";
+}
+.mdi-format-line-weight::before {
+ content: "\F5C9";
+}
+.mdi-format-list-bulleted::before {
+ content: "\F279";
+}
+.mdi-format-list-bulleted-square::before {
+ content: "\FDAC";
+}
+.mdi-format-list-bulleted-triangle::before {
+ content: "\FECF";
+}
+.mdi-format-list-bulleted-type::before {
+ content: "\F27A";
+}
+.mdi-format-list-checkbox::before {
+ content: "\F969";
+}
+.mdi-format-list-checks::before {
+ content: "\F755";
+}
+.mdi-format-list-numbered::before {
+ content: "\F27B";
+}
+.mdi-format-list-numbered-rtl::before {
+ content: "\FCE9";
+}
+.mdi-format-list-text::before {
+ content: "\F029A";
+}
+.mdi-format-overline::before {
+ content: "\FED0";
+}
+.mdi-format-page-break::before {
+ content: "\F6D6";
+}
+.mdi-format-paint::before {
+ content: "\F27C";
+}
+.mdi-format-paragraph::before {
+ content: "\F27D";
+}
+.mdi-format-pilcrow::before {
+ content: "\F6D7";
+}
+.mdi-format-quote-close::before {
+ content: "\F27E";
+}
+.mdi-format-quote-close-outline::before {
+ content: "\F01D3";
+}
+.mdi-format-quote-open::before {
+ content: "\F756";
+}
+.mdi-format-quote-open-outline::before {
+ content: "\F01D2";
+}
+.mdi-format-rotate-90::before {
+ content: "\F6A9";
+}
+.mdi-format-section::before {
+ content: "\F69E";
+}
+.mdi-format-size::before {
+ content: "\F27F";
+}
+.mdi-format-strikethrough::before {
+ content: "\F280";
+}
+.mdi-format-strikethrough-variant::before {
+ content: "\F281";
+}
+.mdi-format-subscript::before {
+ content: "\F282";
+}
+.mdi-format-superscript::before {
+ content: "\F283";
+}
+.mdi-format-text::before {
+ content: "\F284";
+}
+.mdi-format-text-rotation-angle-down::before {
+ content: "\FFDB";
+}
+.mdi-format-text-rotation-angle-up::before {
+ content: "\FFDC";
+}
+.mdi-format-text-rotation-down::before {
+ content: "\FD4F";
+}
+.mdi-format-text-rotation-down-vertical::before {
+ content: "\FFDD";
+}
+.mdi-format-text-rotation-none::before {
+ content: "\FD50";
+}
+.mdi-format-text-rotation-up::before {
+ content: "\FFDE";
+}
+.mdi-format-text-rotation-vertical::before {
+ content: "\FFDF";
+}
+.mdi-format-text-variant::before {
+ content: "\FE15";
+}
+.mdi-format-text-wrapping-clip::before {
+ content: "\FCEA";
+}
+.mdi-format-text-wrapping-overflow::before {
+ content: "\FCEB";
+}
+.mdi-format-text-wrapping-wrap::before {
+ content: "\FCEC";
+}
+.mdi-format-textbox::before {
+ content: "\FCED";
+}
+.mdi-format-textdirection-l-to-r::before {
+ content: "\F285";
+}
+.mdi-format-textdirection-r-to-l::before {
+ content: "\F286";
+}
+.mdi-format-title::before {
+ content: "\F5F4";
+}
+.mdi-format-underline::before {
+ content: "\F287";
+}
+.mdi-format-vertical-align-bottom::before {
+ content: "\F621";
+}
+.mdi-format-vertical-align-center::before {
+ content: "\F622";
+}
+.mdi-format-vertical-align-top::before {
+ content: "\F623";
+}
+.mdi-format-wrap-inline::before {
+ content: "\F288";
+}
+.mdi-format-wrap-square::before {
+ content: "\F289";
+}
+.mdi-format-wrap-tight::before {
+ content: "\F28A";
+}
+.mdi-format-wrap-top-bottom::before {
+ content: "\F28B";
+}
+.mdi-forum::before {
+ content: "\F28C";
+}
+.mdi-forum-outline::before {
+ content: "\F821";
+}
+.mdi-forward::before {
+ content: "\F28D";
+}
+.mdi-forwardburger::before {
+ content: "\FD51";
+}
+.mdi-fountain::before {
+ content: "\F96A";
+}
+.mdi-fountain-pen::before {
+ content: "\FCEE";
+}
+.mdi-fountain-pen-tip::before {
+ content: "\FCEF";
+}
+.mdi-foursquare::before {
+ content: "\F28E";
+}
+.mdi-freebsd::before {
+ content: "\F8DF";
+}
+.mdi-frequently-asked-questions::before {
+ content: "\FED1";
+}
+.mdi-fridge::before {
+ content: "\F290";
+}
+.mdi-fridge-alert::before {
+ content: "\F01DC";
+}
+.mdi-fridge-alert-outline::before {
+ content: "\F01DD";
+}
+.mdi-fridge-bottom::before {
+ content: "\F292";
+}
+.mdi-fridge-off::before {
+ content: "\F01DA";
+}
+.mdi-fridge-off-outline::before {
+ content: "\F01DB";
+}
+.mdi-fridge-outline::before {
+ content: "\F28F";
+}
+.mdi-fridge-top::before {
+ content: "\F291";
+}
+.mdi-fruit-cherries::before {
+ content: "\F0064";
+}
+.mdi-fruit-citrus::before {
+ content: "\F0065";
+}
+.mdi-fruit-grapes::before {
+ content: "\F0066";
+}
+.mdi-fruit-grapes-outline::before {
+ content: "\F0067";
+}
+.mdi-fruit-pineapple::before {
+ content: "\F0068";
+}
+.mdi-fruit-watermelon::before {
+ content: "\F0069";
+}
+.mdi-fuel::before {
+ content: "\F7C9";
+}
+.mdi-fullscreen::before {
+ content: "\F293";
+}
+.mdi-fullscreen-exit::before {
+ content: "\F294";
+}
+.mdi-function::before {
+ content: "\F295";
+}
+.mdi-function-variant::before {
+ content: "\F870";
+}
+.mdi-furigana-horizontal::before {
+ content: "\F00AC";
+}
+.mdi-furigana-vertical::before {
+ content: "\F00AD";
+}
+.mdi-fuse::before {
+ content: "\FC61";
+}
+.mdi-fuse-blade::before {
+ content: "\FC62";
+}
+.mdi-gamepad::before {
+ content: "\F296";
+}
+.mdi-gamepad-circle::before {
+ content: "\FE16";
+}
+.mdi-gamepad-circle-down::before {
+ content: "\FE17";
+}
+.mdi-gamepad-circle-left::before {
+ content: "\FE18";
+}
+.mdi-gamepad-circle-outline::before {
+ content: "\FE19";
+}
+.mdi-gamepad-circle-right::before {
+ content: "\FE1A";
+}
+.mdi-gamepad-circle-up::before {
+ content: "\FE1B";
+}
+.mdi-gamepad-down::before {
+ content: "\FE1C";
+}
+.mdi-gamepad-left::before {
+ content: "\FE1D";
+}
+.mdi-gamepad-right::before {
+ content: "\FE1E";
+}
+.mdi-gamepad-round::before {
+ content: "\FE1F";
+}
+.mdi-gamepad-round-down::before {
+ content: "\FE7E";
+}
+.mdi-gamepad-round-left::before {
+ content: "\FE7F";
+}
+.mdi-gamepad-round-outline::before {
+ content: "\FE80";
+}
+.mdi-gamepad-round-right::before {
+ content: "\FE81";
+}
+.mdi-gamepad-round-up::before {
+ content: "\FE82";
+}
+.mdi-gamepad-square::before {
+ content: "\FED2";
+}
+.mdi-gamepad-square-outline::before {
+ content: "\FED3";
+}
+.mdi-gamepad-up::before {
+ content: "\FE83";
+}
+.mdi-gamepad-variant::before {
+ content: "\F297";
+}
+.mdi-gamepad-variant-outline::before {
+ content: "\FED4";
+}
+.mdi-gamma::before {
+ content: "\F0119";
+}
+.mdi-gantry-crane::before {
+ content: "\FDAD";
+}
+.mdi-garage::before {
+ content: "\F6D8";
+}
+.mdi-garage-alert::before {
+ content: "\F871";
+}
+.mdi-garage-alert-variant::before {
+ content: "\F0300";
+}
+.mdi-garage-open::before {
+ content: "\F6D9";
+}
+.mdi-garage-open-variant::before {
+ content: "\F02FF";
+}
+.mdi-garage-variant::before {
+ content: "\F02FE";
+}
+.mdi-gas-cylinder::before {
+ content: "\F647";
+}
+.mdi-gas-station::before {
+ content: "\F298";
+}
+.mdi-gas-station-outline::before {
+ content: "\FED5";
+}
+.mdi-gate::before {
+ content: "\F299";
+}
+.mdi-gate-and::before {
+ content: "\F8E0";
+}
+.mdi-gate-arrow-right::before {
+ content: "\F0194";
+}
+.mdi-gate-nand::before {
+ content: "\F8E1";
+}
+.mdi-gate-nor::before {
+ content: "\F8E2";
+}
+.mdi-gate-not::before {
+ content: "\F8E3";
+}
+.mdi-gate-open::before {
+ content: "\F0195";
+}
+.mdi-gate-or::before {
+ content: "\F8E4";
+}
+.mdi-gate-xnor::before {
+ content: "\F8E5";
+}
+.mdi-gate-xor::before {
+ content: "\F8E6";
+}
+.mdi-gatsby::before {
+ content: "\FE84";
+}
+.mdi-gauge::before {
+ content: "\F29A";
+}
+.mdi-gauge-empty::before {
+ content: "\F872";
+}
+.mdi-gauge-full::before {
+ content: "\F873";
+}
+.mdi-gauge-low::before {
+ content: "\F874";
+}
+.mdi-gavel::before {
+ content: "\F29B";
+}
+.mdi-gender-female::before {
+ content: "\F29C";
+}
+.mdi-gender-male::before {
+ content: "\F29D";
+}
+.mdi-gender-male-female::before {
+ content: "\F29E";
+}
+.mdi-gender-male-female-variant::before {
+ content: "\F016A";
+}
+.mdi-gender-non-binary::before {
+ content: "\F016B";
+}
+.mdi-gender-transgender::before {
+ content: "\F29F";
+}
+.mdi-gentoo::before {
+ content: "\F8E7";
+}
+.mdi-gesture::before {
+ content: "\F7CA";
+}
+.mdi-gesture-double-tap::before {
+ content: "\F73B";
+}
+.mdi-gesture-pinch::before {
+ content: "\FABC";
+}
+.mdi-gesture-spread::before {
+ content: "\FABD";
+}
+.mdi-gesture-swipe::before {
+ content: "\FD52";
+}
+.mdi-gesture-swipe-down::before {
+ content: "\F73C";
+}
+.mdi-gesture-swipe-horizontal::before {
+ content: "\FABE";
+}
+.mdi-gesture-swipe-left::before {
+ content: "\F73D";
+}
+.mdi-gesture-swipe-right::before {
+ content: "\F73E";
+}
+.mdi-gesture-swipe-up::before {
+ content: "\F73F";
+}
+.mdi-gesture-swipe-vertical::before {
+ content: "\FABF";
+}
+.mdi-gesture-tap::before {
+ content: "\F740";
+}
+.mdi-gesture-tap-box::before {
+ content: "\F02D4";
+}
+.mdi-gesture-tap-button::before {
+ content: "\F02D3";
+}
+.mdi-gesture-tap-hold::before {
+ content: "\FD53";
+}
+.mdi-gesture-two-double-tap::before {
+ content: "\F741";
+}
+.mdi-gesture-two-tap::before {
+ content: "\F742";
+}
+.mdi-ghost::before {
+ content: "\F2A0";
+}
+.mdi-ghost-off::before {
+ content: "\F9F4";
+}
+.mdi-gif::before {
+ content: "\FD54";
+}
+.mdi-gift::before {
+ content: "\FE85";
+}
+.mdi-gift-outline::before {
+ content: "\F2A1";
+}
+.mdi-git::before {
+ content: "\F2A2";
+}
+.mdi-github-box::before {
+ content: "\F2A3";
+}
+.mdi-github-circle::before {
+ content: "\F2A4";
+}
+.mdi-github-face::before {
+ content: "\F6DA";
+}
+.mdi-gitlab::before {
+ content: "\FB7C";
+}
+.mdi-glass-cocktail::before {
+ content: "\F356";
+}
+.mdi-glass-flute::before {
+ content: "\F2A5";
+}
+.mdi-glass-mug::before {
+ content: "\F2A6";
+}
+.mdi-glass-mug-variant::before {
+ content: "\F0141";
+}
+.mdi-glass-pint-outline::before {
+ content: "\F0338";
+}
+.mdi-glass-stange::before {
+ content: "\F2A7";
+}
+.mdi-glass-tulip::before {
+ content: "\F2A8";
+}
+.mdi-glass-wine::before {
+ content: "\F875";
+}
+.mdi-glassdoor::before {
+ content: "\F2A9";
+}
+.mdi-glasses::before {
+ content: "\F2AA";
+}
+.mdi-globe-light::before {
+ content: "\F0302";
+}
+.mdi-globe-model::before {
+ content: "\F8E8";
+}
+.mdi-gmail::before {
+ content: "\F2AB";
+}
+.mdi-gnome::before {
+ content: "\F2AC";
+}
+.mdi-go-kart::before {
+ content: "\FD55";
+}
+.mdi-go-kart-track::before {
+ content: "\FD56";
+}
+.mdi-gog::before {
+ content: "\FB7D";
+}
+.mdi-gold::before {
+ content: "\F027A";
+}
+.mdi-golf::before {
+ content: "\F822";
+}
+.mdi-golf-cart::before {
+ content: "\F01CF";
+}
+.mdi-golf-tee::before {
+ content: "\F00AE";
+}
+.mdi-gondola::before {
+ content: "\F685";
+}
+.mdi-goodreads::before {
+ content: "\FD57";
+}
+.mdi-google::before {
+ content: "\F2AD";
+}
+.mdi-google-adwords::before {
+ content: "\FC63";
+}
+.mdi-google-analytics::before {
+ content: "\F7CB";
+}
+.mdi-google-assistant::before {
+ content: "\F7CC";
+}
+.mdi-google-cardboard::before {
+ content: "\F2AE";
+}
+.mdi-google-chrome::before {
+ content: "\F2AF";
+}
+.mdi-google-circles::before {
+ content: "\F2B0";
+}
+.mdi-google-circles-communities::before {
+ content: "\F2B1";
+}
+.mdi-google-circles-extended::before {
+ content: "\F2B2";
+}
+.mdi-google-circles-group::before {
+ content: "\F2B3";
+}
+.mdi-google-classroom::before {
+ content: "\F2C0";
+}
+.mdi-google-cloud::before {
+ content: "\F0221";
+}
+.mdi-google-controller::before {
+ content: "\F2B4";
+}
+.mdi-google-controller-off::before {
+ content: "\F2B5";
+}
+.mdi-google-downasaur::before {
+ content: "\F038D";
+}
+.mdi-google-drive::before {
+ content: "\F2B6";
+}
+.mdi-google-earth::before {
+ content: "\F2B7";
+}
+.mdi-google-fit::before {
+ content: "\F96B";
+}
+.mdi-google-glass::before {
+ content: "\F2B8";
+}
+.mdi-google-hangouts::before {
+ content: "\F2C9";
+}
+.mdi-google-home::before {
+ content: "\F823";
+}
+.mdi-google-keep::before {
+ content: "\F6DB";
+}
+.mdi-google-lens::before {
+ content: "\F9F5";
+}
+.mdi-google-maps::before {
+ content: "\F5F5";
+}
+.mdi-google-my-business::before {
+ content: "\F006A";
+}
+.mdi-google-nearby::before {
+ content: "\F2B9";
+}
+.mdi-google-pages::before {
+ content: "\F2BA";
+}
+.mdi-google-photos::before {
+ content: "\F6DC";
+}
+.mdi-google-physical-web::before {
+ content: "\F2BB";
+}
+.mdi-google-play::before {
+ content: "\F2BC";
+}
+.mdi-google-plus::before {
+ content: "\F2BD";
+}
+.mdi-google-plus-box::before {
+ content: "\F2BE";
+}
+.mdi-google-podcast::before {
+ content: "\FED6";
+}
+.mdi-google-spreadsheet::before {
+ content: "\F9F6";
+}
+.mdi-google-street-view::before {
+ content: "\FC64";
+}
+.mdi-google-translate::before {
+ content: "\F2BF";
+}
+.mdi-gradient::before {
+ content: "\F69F";
+}
+.mdi-grain::before {
+ content: "\FD58";
+}
+.mdi-graph::before {
+ content: "\F006B";
+}
+.mdi-graph-outline::before {
+ content: "\F006C";
+}
+.mdi-graphql::before {
+ content: "\F876";
+}
+.mdi-grave-stone::before {
+ content: "\FB7E";
+}
+.mdi-grease-pencil::before {
+ content: "\F648";
+}
+.mdi-greater-than::before {
+ content: "\F96C";
+}
+.mdi-greater-than-or-equal::before {
+ content: "\F96D";
+}
+.mdi-grid::before {
+ content: "\F2C1";
+}
+.mdi-grid-large::before {
+ content: "\F757";
+}
+.mdi-grid-off::before {
+ content: "\F2C2";
+}
+.mdi-grill::before {
+ content: "\FE86";
+}
+.mdi-grill-outline::before {
+ content: "\F01B5";
+}
+.mdi-group::before {
+ content: "\F2C3";
+}
+.mdi-guitar-acoustic::before {
+ content: "\F770";
+}
+.mdi-guitar-electric::before {
+ content: "\F2C4";
+}
+.mdi-guitar-pick::before {
+ content: "\F2C5";
+}
+.mdi-guitar-pick-outline::before {
+ content: "\F2C6";
+}
+.mdi-guy-fawkes-mask::before {
+ content: "\F824";
+}
+.mdi-hackernews::before {
+ content: "\F624";
+}
+.mdi-hail::before {
+ content: "\FAC0";
+}
+.mdi-hair-dryer::before {
+ content: "\F011A";
+}
+.mdi-hair-dryer-outline::before {
+ content: "\F011B";
+}
+.mdi-halloween::before {
+ content: "\FB7F";
+}
+.mdi-hamburger::before {
+ content: "\F684";
+}
+.mdi-hammer::before {
+ content: "\F8E9";
+}
+.mdi-hammer-screwdriver::before {
+ content: "\F034D";
+}
+.mdi-hammer-wrench::before {
+ content: "\F034E";
+}
+.mdi-hand::before {
+ content: "\FA4E";
+}
+.mdi-hand-heart::before {
+ content: "\F011C";
+}
+.mdi-hand-left::before {
+ content: "\FE87";
+}
+.mdi-hand-okay::before {
+ content: "\FA4F";
+}
+.mdi-hand-peace::before {
+ content: "\FA50";
+}
+.mdi-hand-peace-variant::before {
+ content: "\FA51";
+}
+.mdi-hand-pointing-down::before {
+ content: "\FA52";
+}
+.mdi-hand-pointing-left::before {
+ content: "\FA53";
+}
+.mdi-hand-pointing-right::before {
+ content: "\F2C7";
+}
+.mdi-hand-pointing-up::before {
+ content: "\FA54";
+}
+.mdi-hand-right::before {
+ content: "\FE88";
+}
+.mdi-hand-saw::before {
+ content: "\FE89";
+}
+.mdi-handball::before {
+ content: "\FF70";
+}
+.mdi-handcuffs::before {
+ content: "\F0169";
+}
+.mdi-handshake::before {
+ content: "\F0243";
+}
+.mdi-hanger::before {
+ content: "\F2C8";
+}
+.mdi-hard-hat::before {
+ content: "\F96E";
+}
+.mdi-harddisk::before {
+ content: "\F2CA";
+}
+.mdi-harddisk-plus::before {
+ content: "\F006D";
+}
+.mdi-harddisk-remove::before {
+ content: "\F006E";
+}
+.mdi-hat-fedora::before {
+ content: "\FB80";
+}
+.mdi-hazard-lights::before {
+ content: "\FC65";
+}
+.mdi-hdr::before {
+ content: "\FD59";
+}
+.mdi-hdr-off::before {
+ content: "\FD5A";
+}
+.mdi-head::before {
+ content: "\F0389";
+}
+.mdi-head-alert::before {
+ content: "\F0363";
+}
+.mdi-head-alert-outline::before {
+ content: "\F0364";
+}
+.mdi-head-check::before {
+ content: "\F0365";
+}
+.mdi-head-check-outline::before {
+ content: "\F0366";
+}
+.mdi-head-cog::before {
+ content: "\F0367";
+}
+.mdi-head-cog-outline::before {
+ content: "\F0368";
+}
+.mdi-head-dots-horizontal::before {
+ content: "\F0369";
+}
+.mdi-head-dots-horizontal-outline::before {
+ content: "\F036A";
+}
+.mdi-head-flash::before {
+ content: "\F036B";
+}
+.mdi-head-flash-outline::before {
+ content: "\F036C";
+}
+.mdi-head-heart::before {
+ content: "\F036D";
+}
+.mdi-head-heart-outline::before {
+ content: "\F036E";
+}
+.mdi-head-lightbulb::before {
+ content: "\F036F";
+}
+.mdi-head-lightbulb-outline::before {
+ content: "\F0370";
+}
+.mdi-head-minus::before {
+ content: "\F0371";
+}
+.mdi-head-minus-outline::before {
+ content: "\F0372";
+}
+.mdi-head-outline::before {
+ content: "\F038A";
+}
+.mdi-head-plus::before {
+ content: "\F0373";
+}
+.mdi-head-plus-outline::before {
+ content: "\F0374";
+}
+.mdi-head-question::before {
+ content: "\F0375";
+}
+.mdi-head-question-outline::before {
+ content: "\F0376";
+}
+.mdi-head-remove::before {
+ content: "\F0377";
+}
+.mdi-head-remove-outline::before {
+ content: "\F0378";
+}
+.mdi-head-snowflake::before {
+ content: "\F0379";
+}
+.mdi-head-snowflake-outline::before {
+ content: "\F037A";
+}
+.mdi-head-sync::before {
+ content: "\F037B";
+}
+.mdi-head-sync-outline::before {
+ content: "\F037C";
+}
+.mdi-headphones::before {
+ content: "\F2CB";
+}
+.mdi-headphones-bluetooth::before {
+ content: "\F96F";
+}
+.mdi-headphones-box::before {
+ content: "\F2CC";
+}
+.mdi-headphones-off::before {
+ content: "\F7CD";
+}
+.mdi-headphones-settings::before {
+ content: "\F2CD";
+}
+.mdi-headset::before {
+ content: "\F2CE";
+}
+.mdi-headset-dock::before {
+ content: "\F2CF";
+}
+.mdi-headset-off::before {
+ content: "\F2D0";
+}
+.mdi-heart::before {
+ content: "\F2D1";
+}
+.mdi-heart-box::before {
+ content: "\F2D2";
+}
+.mdi-heart-box-outline::before {
+ content: "\F2D3";
+}
+.mdi-heart-broken::before {
+ content: "\F2D4";
+}
+.mdi-heart-broken-outline::before {
+ content: "\FCF0";
+}
+.mdi-heart-circle::before {
+ content: "\F970";
+}
+.mdi-heart-circle-outline::before {
+ content: "\F971";
+}
+.mdi-heart-flash::before {
+ content: "\FF16";
+}
+.mdi-heart-half::before {
+ content: "\F6DE";
+}
+.mdi-heart-half-full::before {
+ content: "\F6DD";
+}
+.mdi-heart-half-outline::before {
+ content: "\F6DF";
+}
+.mdi-heart-multiple::before {
+ content: "\FA55";
+}
+.mdi-heart-multiple-outline::before {
+ content: "\FA56";
+}
+.mdi-heart-off::before {
+ content: "\F758";
+}
+.mdi-heart-outline::before {
+ content: "\F2D5";
+}
+.mdi-heart-pulse::before {
+ content: "\F5F6";
+}
+.mdi-helicopter::before {
+ content: "\FAC1";
+}
+.mdi-help::before {
+ content: "\F2D6";
+}
+.mdi-help-box::before {
+ content: "\F78A";
+}
+.mdi-help-circle::before {
+ content: "\F2D7";
+}
+.mdi-help-circle-outline::before {
+ content: "\F625";
+}
+.mdi-help-network::before {
+ content: "\F6F4";
+}
+.mdi-help-network-outline::before {
+ content: "\FC66";
+}
+.mdi-help-rhombus::before {
+ content: "\FB81";
+}
+.mdi-help-rhombus-outline::before {
+ content: "\FB82";
+}
+.mdi-hexadecimal::before {
+ content: "\F02D2";
+}
+.mdi-hexagon::before {
+ content: "\F2D8";
+}
+.mdi-hexagon-multiple::before {
+ content: "\F6E0";
+}
+.mdi-hexagon-multiple-outline::before {
+ content: "\F011D";
+}
+.mdi-hexagon-outline::before {
+ content: "\F2D9";
+}
+.mdi-hexagon-slice-1::before {
+ content: "\FAC2";
+}
+.mdi-hexagon-slice-2::before {
+ content: "\FAC3";
+}
+.mdi-hexagon-slice-3::before {
+ content: "\FAC4";
+}
+.mdi-hexagon-slice-4::before {
+ content: "\FAC5";
+}
+.mdi-hexagon-slice-5::before {
+ content: "\FAC6";
+}
+.mdi-hexagon-slice-6::before {
+ content: "\FAC7";
+}
+.mdi-hexagram::before {
+ content: "\FAC8";
+}
+.mdi-hexagram-outline::before {
+ content: "\FAC9";
+}
+.mdi-high-definition::before {
+ content: "\F7CE";
+}
+.mdi-high-definition-box::before {
+ content: "\F877";
+}
+.mdi-highway::before {
+ content: "\F5F7";
+}
+.mdi-hiking::before {
+ content: "\FD5B";
+}
+.mdi-hinduism::before {
+ content: "\F972";
+}
+.mdi-history::before {
+ content: "\F2DA";
+}
+.mdi-hockey-puck::before {
+ content: "\F878";
+}
+.mdi-hockey-sticks::before {
+ content: "\F879";
+}
+.mdi-hololens::before {
+ content: "\F2DB";
+}
+.mdi-home::before {
+ content: "\F2DC";
+}
+.mdi-home-account::before {
+ content: "\F825";
+}
+.mdi-home-alert::before {
+ content: "\F87A";
+}
+.mdi-home-analytics::before {
+ content: "\FED7";
+}
+.mdi-home-assistant::before {
+ content: "\F7CF";
+}
+.mdi-home-automation::before {
+ content: "\F7D0";
+}
+.mdi-home-circle::before {
+ content: "\F7D1";
+}
+.mdi-home-circle-outline::before {
+ content: "\F006F";
+}
+.mdi-home-city::before {
+ content: "\FCF1";
+}
+.mdi-home-city-outline::before {
+ content: "\FCF2";
+}
+.mdi-home-currency-usd::before {
+ content: "\F8AE";
+}
+.mdi-home-edit::before {
+ content: "\F0184";
+}
+.mdi-home-edit-outline::before {
+ content: "\F0185";
+}
+.mdi-home-export-outline::before {
+ content: "\FFB8";
+}
+.mdi-home-flood::before {
+ content: "\FF17";
+}
+.mdi-home-floor-0::before {
+ content: "\FDAE";
+}
+.mdi-home-floor-1::before {
+ content: "\FD5C";
+}
+.mdi-home-floor-2::before {
+ content: "\FD5D";
+}
+.mdi-home-floor-3::before {
+ content: "\FD5E";
+}
+.mdi-home-floor-a::before {
+ content: "\FD5F";
+}
+.mdi-home-floor-b::before {
+ content: "\FD60";
+}
+.mdi-home-floor-g::before {
+ content: "\FD61";
+}
+.mdi-home-floor-l::before {
+ content: "\FD62";
+}
+.mdi-home-floor-negative-1::before {
+ content: "\FDAF";
+}
+.mdi-home-group::before {
+ content: "\FDB0";
+}
+.mdi-home-heart::before {
+ content: "\F826";
+}
+.mdi-home-import-outline::before {
+ content: "\FFB9";
+}
+.mdi-home-lightbulb::before {
+ content: "\F027C";
+}
+.mdi-home-lightbulb-outline::before {
+ content: "\F027D";
+}
+.mdi-home-lock::before {
+ content: "\F8EA";
+}
+.mdi-home-lock-open::before {
+ content: "\F8EB";
+}
+.mdi-home-map-marker::before {
+ content: "\F5F8";
+}
+.mdi-home-minus::before {
+ content: "\F973";
+}
+.mdi-home-modern::before {
+ content: "\F2DD";
+}
+.mdi-home-outline::before {
+ content: "\F6A0";
+}
+.mdi-home-plus::before {
+ content: "\F974";
+}
+.mdi-home-remove::before {
+ content: "\F0272";
+}
+.mdi-home-roof::before {
+ content: "\F0156";
+}
+.mdi-home-thermometer::before {
+ content: "\FF71";
+}
+.mdi-home-thermometer-outline::before {
+ content: "\FF72";
+}
+.mdi-home-variant::before {
+ content: "\F2DE";
+}
+.mdi-home-variant-outline::before {
+ content: "\FB83";
+}
+.mdi-hook::before {
+ content: "\F6E1";
+}
+.mdi-hook-off::before {
+ content: "\F6E2";
+}
+.mdi-hops::before {
+ content: "\F2DF";
+}
+.mdi-horizontal-rotate-clockwise::before {
+ content: "\F011E";
+}
+.mdi-horizontal-rotate-counterclockwise::before {
+ content: "\F011F";
+}
+.mdi-horseshoe::before {
+ content: "\FA57";
+}
+.mdi-hospital::before {
+ content: "\F0017";
+}
+.mdi-hospital-box::before {
+ content: "\F2E0";
+}
+.mdi-hospital-box-outline::before {
+ content: "\F0018";
+}
+.mdi-hospital-building::before {
+ content: "\F2E1";
+}
+.mdi-hospital-marker::before {
+ content: "\F2E2";
+}
+.mdi-hot-tub::before {
+ content: "\F827";
+}
+.mdi-hotel::before {
+ content: "\F2E3";
+}
+.mdi-houzz::before {
+ content: "\F2E4";
+}
+.mdi-houzz-box::before {
+ content: "\F2E5";
+}
+.mdi-hubspot::before {
+ content: "\FCF3";
+}
+.mdi-hulu::before {
+ content: "\F828";
+}
+.mdi-human::before {
+ content: "\F2E6";
+}
+.mdi-human-child::before {
+ content: "\F2E7";
+}
+.mdi-human-female::before {
+ content: "\F649";
+}
+.mdi-human-female-boy::before {
+ content: "\FA58";
+}
+.mdi-human-female-female::before {
+ content: "\FA59";
+}
+.mdi-human-female-girl::before {
+ content: "\FA5A";
+}
+.mdi-human-greeting::before {
+ content: "\F64A";
+}
+.mdi-human-handsdown::before {
+ content: "\F64B";
+}
+.mdi-human-handsup::before {
+ content: "\F64C";
+}
+.mdi-human-male::before {
+ content: "\F64D";
+}
+.mdi-human-male-boy::before {
+ content: "\FA5B";
+}
+.mdi-human-male-female::before {
+ content: "\F2E8";
+}
+.mdi-human-male-girl::before {
+ content: "\FA5C";
+}
+.mdi-human-male-height::before {
+ content: "\FF18";
+}
+.mdi-human-male-height-variant::before {
+ content: "\FF19";
+}
+.mdi-human-male-male::before {
+ content: "\FA5D";
+}
+.mdi-human-pregnant::before {
+ content: "\F5CF";
+}
+.mdi-humble-bundle::before {
+ content: "\F743";
+}
+.mdi-hvac::before {
+ content: "\F037D";
+}
+.mdi-hydraulic-oil-level::before {
+ content: "\F034F";
+}
+.mdi-hydraulic-oil-temperature::before {
+ content: "\F0350";
+}
+.mdi-hydro-power::before {
+ content: "\F0310";
+}
+.mdi-ice-cream::before {
+ content: "\F829";
+}
+.mdi-ice-pop::before {
+ content: "\FF1A";
+}
+.mdi-id-card::before {
+ content: "\FFE0";
+}
+.mdi-identifier::before {
+ content: "\FF1B";
+}
+.mdi-ideogram-cjk::before {
+ content: "\F035C";
+}
+.mdi-ideogram-cjk-variant::before {
+ content: "\F035D";
+}
+.mdi-iframe::before {
+ content: "\FC67";
+}
+.mdi-iframe-array::before {
+ content: "\F0120";
+}
+.mdi-iframe-array-outline::before {
+ content: "\F0121";
+}
+.mdi-iframe-braces::before {
+ content: "\F0122";
+}
+.mdi-iframe-braces-outline::before {
+ content: "\F0123";
+}
+.mdi-iframe-outline::before {
+ content: "\FC68";
+}
+.mdi-iframe-parentheses::before {
+ content: "\F0124";
+}
+.mdi-iframe-parentheses-outline::before {
+ content: "\F0125";
+}
+.mdi-iframe-variable::before {
+ content: "\F0126";
+}
+.mdi-iframe-variable-outline::before {
+ content: "\F0127";
+}
+.mdi-image::before {
+ content: "\F2E9";
+}
+.mdi-image-album::before {
+ content: "\F2EA";
+}
+.mdi-image-area::before {
+ content: "\F2EB";
+}
+.mdi-image-area-close::before {
+ content: "\F2EC";
+}
+.mdi-image-auto-adjust::before {
+ content: "\FFE1";
+}
+.mdi-image-broken::before {
+ content: "\F2ED";
+}
+.mdi-image-broken-variant::before {
+ content: "\F2EE";
+}
+.mdi-image-edit::before {
+ content: "\F020E";
+}
+.mdi-image-edit-outline::before {
+ content: "\F020F";
+}
+.mdi-image-filter::before {
+ content: "\F2EF";
+}
+.mdi-image-filter-black-white::before {
+ content: "\F2F0";
+}
+.mdi-image-filter-center-focus::before {
+ content: "\F2F1";
+}
+.mdi-image-filter-center-focus-strong::before {
+ content: "\FF1C";
+}
+.mdi-image-filter-center-focus-strong-outline::before {
+ content: "\FF1D";
+}
+.mdi-image-filter-center-focus-weak::before {
+ content: "\F2F2";
+}
+.mdi-image-filter-drama::before {
+ content: "\F2F3";
+}
+.mdi-image-filter-frames::before {
+ content: "\F2F4";
+}
+.mdi-image-filter-hdr::before {
+ content: "\F2F5";
+}
+.mdi-image-filter-none::before {
+ content: "\F2F6";
+}
+.mdi-image-filter-tilt-shift::before {
+ content: "\F2F7";
+}
+.mdi-image-filter-vintage::before {
+ content: "\F2F8";
+}
+.mdi-image-frame::before {
+ content: "\FE8A";
+}
+.mdi-image-move::before {
+ content: "\F9F7";
+}
+.mdi-image-multiple::before {
+ content: "\F2F9";
+}
+.mdi-image-off::before {
+ content: "\F82A";
+}
+.mdi-image-off-outline::before {
+ content: "\F01FC";
+}
+.mdi-image-outline::before {
+ content: "\F975";
+}
+.mdi-image-plus::before {
+ content: "\F87B";
+}
+.mdi-image-search::before {
+ content: "\F976";
+}
+.mdi-image-search-outline::before {
+ content: "\F977";
+}
+.mdi-image-size-select-actual::before {
+ content: "\FC69";
+}
+.mdi-image-size-select-large::before {
+ content: "\FC6A";
+}
+.mdi-image-size-select-small::before {
+ content: "\FC6B";
+}
+.mdi-import::before {
+ content: "\F2FA";
+}
+.mdi-inbox::before {
+ content: "\F686";
+}
+.mdi-inbox-arrow-down::before {
+ content: "\F2FB";
+}
+.mdi-inbox-arrow-down-outline::before {
+ content: "\F029B";
+}
+.mdi-inbox-arrow-up::before {
+ content: "\F3D1";
+}
+.mdi-inbox-arrow-up-outline::before {
+ content: "\F029C";
+}
+.mdi-inbox-full::before {
+ content: "\F029D";
+}
+.mdi-inbox-full-outline::before {
+ content: "\F029E";
+}
+.mdi-inbox-multiple::before {
+ content: "\F8AF";
+}
+.mdi-inbox-multiple-outline::before {
+ content: "\FB84";
+}
+.mdi-inbox-outline::before {
+ content: "\F029F";
+}
+.mdi-incognito::before {
+ content: "\F5F9";
+}
+.mdi-infinity::before {
+ content: "\F6E3";
+}
+.mdi-information::before {
+ content: "\F2FC";
+}
+.mdi-information-outline::before {
+ content: "\F2FD";
+}
+.mdi-information-variant::before {
+ content: "\F64E";
+}
+.mdi-instagram::before {
+ content: "\F2FE";
+}
+.mdi-instapaper::before {
+ content: "\F2FF";
+}
+.mdi-instrument-triangle::before {
+ content: "\F0070";
+}
+.mdi-internet-explorer::before {
+ content: "\F300";
+}
+.mdi-invert-colors::before {
+ content: "\F301";
+}
+.mdi-invert-colors-off::before {
+ content: "\FE8B";
+}
+.mdi-iobroker::before {
+ content: "\F0313";
+}
+.mdi-ip::before {
+ content: "\FA5E";
+}
+.mdi-ip-network::before {
+ content: "\FA5F";
+}
+.mdi-ip-network-outline::before {
+ content: "\FC6C";
+}
+.mdi-ipod::before {
+ content: "\FC6D";
+}
+.mdi-islam::before {
+ content: "\F978";
+}
+.mdi-island::before {
+ content: "\F0071";
+}
+.mdi-itunes::before {
+ content: "\F676";
+}
+.mdi-iv-bag::before {
+ content: "\F00E4";
+}
+.mdi-jabber::before {
+ content: "\FDB1";
+}
+.mdi-jeepney::before {
+ content: "\F302";
+}
+.mdi-jellyfish::before {
+ content: "\FF1E";
+}
+.mdi-jellyfish-outline::before {
+ content: "\FF1F";
+}
+.mdi-jira::before {
+ content: "\F303";
+}
+.mdi-jquery::before {
+ content: "\F87C";
+}
+.mdi-jsfiddle::before {
+ content: "\F304";
+}
+.mdi-json::before {
+ content: "\F626";
+}
+.mdi-judaism::before {
+ content: "\F979";
+}
+.mdi-jump-rope::before {
+ content: "\F032A";
+}
+.mdi-kabaddi::before {
+ content: "\FD63";
+}
+.mdi-karate::before {
+ content: "\F82B";
+}
+.mdi-keg::before {
+ content: "\F305";
+}
+.mdi-kettle::before {
+ content: "\F5FA";
+}
+.mdi-kettle-alert::before {
+ content: "\F0342";
+}
+.mdi-kettle-alert-outline::before {
+ content: "\F0343";
+}
+.mdi-kettle-off::before {
+ content: "\F0346";
+}
+.mdi-kettle-off-outline::before {
+ content: "\F0347";
+}
+.mdi-kettle-outline::before {
+ content: "\FF73";
+}
+.mdi-kettle-steam::before {
+ content: "\F0344";
+}
+.mdi-kettle-steam-outline::before {
+ content: "\F0345";
+}
+.mdi-kettlebell::before {
+ content: "\F032B";
+}
+.mdi-key::before {
+ content: "\F306";
+}
+.mdi-key-arrow-right::before {
+ content: "\F033D";
+}
+.mdi-key-change::before {
+ content: "\F307";
+}
+.mdi-key-link::before {
+ content: "\F01CA";
+}
+.mdi-key-minus::before {
+ content: "\F308";
+}
+.mdi-key-outline::before {
+ content: "\FDB2";
+}
+.mdi-key-plus::before {
+ content: "\F309";
+}
+.mdi-key-remove::before {
+ content: "\F30A";
+}
+.mdi-key-star::before {
+ content: "\F01C9";
+}
+.mdi-key-variant::before {
+ content: "\F30B";
+}
+.mdi-key-wireless::before {
+ content: "\FFE2";
+}
+.mdi-keyboard::before {
+ content: "\F30C";
+}
+.mdi-keyboard-backspace::before {
+ content: "\F30D";
+}
+.mdi-keyboard-caps::before {
+ content: "\F30E";
+}
+.mdi-keyboard-close::before {
+ content: "\F30F";
+}
+.mdi-keyboard-esc::before {
+ content: "\F02E2";
+}
+.mdi-keyboard-f1::before {
+ content: "\F02D6";
+}
+.mdi-keyboard-f10::before {
+ content: "\F02DF";
+}
+.mdi-keyboard-f11::before {
+ content: "\F02E0";
+}
+.mdi-keyboard-f12::before {
+ content: "\F02E1";
+}
+.mdi-keyboard-f2::before {
+ content: "\F02D7";
+}
+.mdi-keyboard-f3::before {
+ content: "\F02D8";
+}
+.mdi-keyboard-f4::before {
+ content: "\F02D9";
+}
+.mdi-keyboard-f5::before {
+ content: "\F02DA";
+}
+.mdi-keyboard-f6::before {
+ content: "\F02DB";
+}
+.mdi-keyboard-f7::before {
+ content: "\F02DC";
+}
+.mdi-keyboard-f8::before {
+ content: "\F02DD";
+}
+.mdi-keyboard-f9::before {
+ content: "\F02DE";
+}
+.mdi-keyboard-off::before {
+ content: "\F310";
+}
+.mdi-keyboard-off-outline::before {
+ content: "\FE8C";
+}
+.mdi-keyboard-outline::before {
+ content: "\F97A";
+}
+.mdi-keyboard-return::before {
+ content: "\F311";
+}
+.mdi-keyboard-settings::before {
+ content: "\F9F8";
+}
+.mdi-keyboard-settings-outline::before {
+ content: "\F9F9";
+}
+.mdi-keyboard-space::before {
+ content: "\F0072";
+}
+.mdi-keyboard-tab::before {
+ content: "\F312";
+}
+.mdi-keyboard-variant::before {
+ content: "\F313";
+}
+.mdi-khanda::before {
+ content: "\F0128";
+}
+.mdi-kickstarter::before {
+ content: "\F744";
+}
+.mdi-klingon::before {
+ content: "\F0386";
+}
+.mdi-knife::before {
+ content: "\F9FA";
+}
+.mdi-knife-military::before {
+ content: "\F9FB";
+}
+.mdi-kodi::before {
+ content: "\F314";
+}
+.mdi-kotlin::before {
+ content: "\F0244";
+}
+.mdi-kubernetes::before {
+ content: "\F0129";
+}
+.mdi-label::before {
+ content: "\F315";
+}
+.mdi-label-multiple::before {
+ content: "\F03A0";
+}
+.mdi-label-multiple-outline::before {
+ content: "\F03A1";
+}
+.mdi-label-off::before {
+ content: "\FACA";
+}
+.mdi-label-off-outline::before {
+ content: "\FACB";
+}
+.mdi-label-outline::before {
+ content: "\F316";
+}
+.mdi-label-percent::before {
+ content: "\F0315";
+}
+.mdi-label-percent-outline::before {
+ content: "\F0316";
+}
+.mdi-label-variant::before {
+ content: "\FACC";
+}
+.mdi-label-variant-outline::before {
+ content: "\FACD";
+}
+.mdi-ladybug::before {
+ content: "\F82C";
+}
+.mdi-lambda::before {
+ content: "\F627";
+}
+.mdi-lamp::before {
+ content: "\F6B4";
+}
+.mdi-lan::before {
+ content: "\F317";
+}
+.mdi-lan-check::before {
+ content: "\F02D5";
+}
+.mdi-lan-connect::before {
+ content: "\F318";
+}
+.mdi-lan-disconnect::before {
+ content: "\F319";
+}
+.mdi-lan-pending::before {
+ content: "\F31A";
+}
+.mdi-language-c::before {
+ content: "\F671";
+}
+.mdi-language-cpp::before {
+ content: "\F672";
+}
+.mdi-language-csharp::before {
+ content: "\F31B";
+}
+.mdi-language-css3::before {
+ content: "\F31C";
+}
+.mdi-language-fortran::before {
+ content: "\F0245";
+}
+.mdi-language-go::before {
+ content: "\F7D2";
+}
+.mdi-language-haskell::before {
+ content: "\FC6E";
+}
+.mdi-language-html5::before {
+ content: "\F31D";
+}
+.mdi-language-java::before {
+ content: "\FB1C";
+}
+.mdi-language-javascript::before {
+ content: "\F31E";
+}
+.mdi-language-lua::before {
+ content: "\F8B0";
+}
+.mdi-language-php::before {
+ content: "\F31F";
+}
+.mdi-language-python::before {
+ content: "\F320";
+}
+.mdi-language-python-text::before {
+ content: "\F321";
+}
+.mdi-language-r::before {
+ content: "\F7D3";
+}
+.mdi-language-ruby-on-rails::before {
+ content: "\FACE";
+}
+.mdi-language-swift::before {
+ content: "\F6E4";
+}
+.mdi-language-typescript::before {
+ content: "\F6E5";
+}
+.mdi-laptop::before {
+ content: "\F322";
+}
+.mdi-laptop-chromebook::before {
+ content: "\F323";
+}
+.mdi-laptop-mac::before {
+ content: "\F324";
+}
+.mdi-laptop-off::before {
+ content: "\F6E6";
+}
+.mdi-laptop-windows::before {
+ content: "\F325";
+}
+.mdi-laravel::before {
+ content: "\FACF";
+}
+.mdi-lasso::before {
+ content: "\FF20";
+}
+.mdi-lastfm::before {
+ content: "\F326";
+}
+.mdi-lastpass::before {
+ content: "\F446";
+}
+.mdi-latitude::before {
+ content: "\FF74";
+}
+.mdi-launch::before {
+ content: "\F327";
+}
+.mdi-lava-lamp::before {
+ content: "\F7D4";
+}
+.mdi-layers::before {
+ content: "\F328";
+}
+.mdi-layers-minus::before {
+ content: "\FE8D";
+}
+.mdi-layers-off::before {
+ content: "\F329";
+}
+.mdi-layers-off-outline::before {
+ content: "\F9FC";
+}
+.mdi-layers-outline::before {
+ content: "\F9FD";
+}
+.mdi-layers-plus::before {
+ content: "\FE30";
+}
+.mdi-layers-remove::before {
+ content: "\FE31";
+}
+.mdi-layers-search::before {
+ content: "\F0231";
+}
+.mdi-layers-search-outline::before {
+ content: "\F0232";
+}
+.mdi-layers-triple::before {
+ content: "\FF75";
+}
+.mdi-layers-triple-outline::before {
+ content: "\FF76";
+}
+.mdi-lead-pencil::before {
+ content: "\F64F";
+}
+.mdi-leaf::before {
+ content: "\F32A";
+}
+.mdi-leaf-maple::before {
+ content: "\FC6F";
+}
+.mdi-leaf-maple-off::before {
+ content: "\F0305";
+}
+.mdi-leaf-off::before {
+ content: "\F0304";
+}
+.mdi-leak::before {
+ content: "\FDB3";
+}
+.mdi-leak-off::before {
+ content: "\FDB4";
+}
+.mdi-led-off::before {
+ content: "\F32B";
+}
+.mdi-led-on::before {
+ content: "\F32C";
+}
+.mdi-led-outline::before {
+ content: "\F32D";
+}
+.mdi-led-strip::before {
+ content: "\F7D5";
+}
+.mdi-led-strip-variant::before {
+ content: "\F0073";
+}
+.mdi-led-variant-off::before {
+ content: "\F32E";
+}
+.mdi-led-variant-on::before {
+ content: "\F32F";
+}
+.mdi-led-variant-outline::before {
+ content: "\F330";
+}
+.mdi-leek::before {
+ content: "\F01A8";
+}
+.mdi-less-than::before {
+ content: "\F97B";
+}
+.mdi-less-than-or-equal::before {
+ content: "\F97C";
+}
+.mdi-library::before {
+ content: "\F331";
+}
+.mdi-library-books::before {
+ content: "\F332";
+}
+.mdi-library-movie::before {
+ content: "\FCF4";
+}
+.mdi-library-music::before {
+ content: "\F333";
+}
+.mdi-library-music-outline::before {
+ content: "\FF21";
+}
+.mdi-library-shelves::before {
+ content: "\FB85";
+}
+.mdi-library-video::before {
+ content: "\FCF5";
+}
+.mdi-license::before {
+ content: "\FFE3";
+}
+.mdi-lifebuoy::before {
+ content: "\F87D";
+}
+.mdi-light-switch::before {
+ content: "\F97D";
+}
+.mdi-lightbulb::before {
+ content: "\F335";
+}
+.mdi-lightbulb-cfl::before {
+ content: "\F0233";
+}
+.mdi-lightbulb-cfl-off::before {
+ content: "\F0234";
+}
+.mdi-lightbulb-cfl-spiral::before {
+ content: "\F02A0";
+}
+.mdi-lightbulb-cfl-spiral-off::before {
+ content: "\F02EE";
+}
+.mdi-lightbulb-group::before {
+ content: "\F027E";
+}
+.mdi-lightbulb-group-off::before {
+ content: "\F02F8";
+}
+.mdi-lightbulb-group-off-outline::before {
+ content: "\F02F9";
+}
+.mdi-lightbulb-group-outline::before {
+ content: "\F027F";
+}
+.mdi-lightbulb-multiple::before {
+ content: "\F0280";
+}
+.mdi-lightbulb-multiple-off::before {
+ content: "\F02FA";
+}
+.mdi-lightbulb-multiple-off-outline::before {
+ content: "\F02FB";
+}
+.mdi-lightbulb-multiple-outline::before {
+ content: "\F0281";
+}
+.mdi-lightbulb-off::before {
+ content: "\FE32";
+}
+.mdi-lightbulb-off-outline::before {
+ content: "\FE33";
+}
+.mdi-lightbulb-on::before {
+ content: "\F6E7";
+}
+.mdi-lightbulb-on-outline::before {
+ content: "\F6E8";
+}
+.mdi-lightbulb-outline::before {
+ content: "\F336";
+}
+.mdi-lighthouse::before {
+ content: "\F9FE";
+}
+.mdi-lighthouse-on::before {
+ content: "\F9FF";
+}
+.mdi-link::before {
+ content: "\F337";
+}
+.mdi-link-box::before {
+ content: "\FCF6";
+}
+.mdi-link-box-outline::before {
+ content: "\FCF7";
+}
+.mdi-link-box-variant::before {
+ content: "\FCF8";
+}
+.mdi-link-box-variant-outline::before {
+ content: "\FCF9";
+}
+.mdi-link-lock::before {
+ content: "\F00E5";
+}
+.mdi-link-off::before {
+ content: "\F338";
+}
+.mdi-link-plus::before {
+ content: "\FC70";
+}
+.mdi-link-variant::before {
+ content: "\F339";
+}
+.mdi-link-variant-minus::before {
+ content: "\F012A";
+}
+.mdi-link-variant-off::before {
+ content: "\F33A";
+}
+.mdi-link-variant-plus::before {
+ content: "\F012B";
+}
+.mdi-link-variant-remove::before {
+ content: "\F012C";
+}
+.mdi-linkedin::before {
+ content: "\F33B";
+}
+.mdi-linkedin-box::before {
+ content: "\F33C";
+}
+.mdi-linux::before {
+ content: "\F33D";
+}
+.mdi-linux-mint::before {
+ content: "\F8EC";
+}
+.mdi-litecoin::before {
+ content: "\FA60";
+}
+.mdi-loading::before {
+ content: "\F771";
+}
+.mdi-location-enter::before {
+ content: "\FFE4";
+}
+.mdi-location-exit::before {
+ content: "\FFE5";
+}
+.mdi-lock::before {
+ content: "\F33E";
+}
+.mdi-lock-alert::before {
+ content: "\F8ED";
+}
+.mdi-lock-clock::before {
+ content: "\F97E";
+}
+.mdi-lock-open::before {
+ content: "\F33F";
+}
+.mdi-lock-open-outline::before {
+ content: "\F340";
+}
+.mdi-lock-open-variant::before {
+ content: "\FFE6";
+}
+.mdi-lock-open-variant-outline::before {
+ content: "\FFE7";
+}
+.mdi-lock-outline::before {
+ content: "\F341";
+}
+.mdi-lock-pattern::before {
+ content: "\F6E9";
+}
+.mdi-lock-plus::before {
+ content: "\F5FB";
+}
+.mdi-lock-question::before {
+ content: "\F8EE";
+}
+.mdi-lock-reset::before {
+ content: "\F772";
+}
+.mdi-lock-smart::before {
+ content: "\F8B1";
+}
+.mdi-locker::before {
+ content: "\F7D6";
+}
+.mdi-locker-multiple::before {
+ content: "\F7D7";
+}
+.mdi-login::before {
+ content: "\F342";
+}
+.mdi-login-variant::before {
+ content: "\F5FC";
+}
+.mdi-logout::before {
+ content: "\F343";
+}
+.mdi-logout-variant::before {
+ content: "\F5FD";
+}
+.mdi-longitude::before {
+ content: "\FF77";
+}
+.mdi-looks::before {
+ content: "\F344";
+}
+.mdi-loupe::before {
+ content: "\F345";
+}
+.mdi-lumx::before {
+ content: "\F346";
+}
+.mdi-lungs::before {
+ content: "\F00AF";
+}
+.mdi-lyft::before {
+ content: "\FB1D";
+}
+.mdi-magnet::before {
+ content: "\F347";
+}
+.mdi-magnet-on::before {
+ content: "\F348";
+}
+.mdi-magnify::before {
+ content: "\F349";
+}
+.mdi-magnify-close::before {
+ content: "\F97F";
+}
+.mdi-magnify-minus::before {
+ content: "\F34A";
+}
+.mdi-magnify-minus-cursor::before {
+ content: "\FA61";
+}
+.mdi-magnify-minus-outline::before {
+ content: "\F6EB";
+}
+.mdi-magnify-plus::before {
+ content: "\F34B";
+}
+.mdi-magnify-plus-cursor::before {
+ content: "\FA62";
+}
+.mdi-magnify-plus-outline::before {
+ content: "\F6EC";
+}
+.mdi-magnify-remove-cursor::before {
+ content: "\F0237";
+}
+.mdi-magnify-remove-outline::before {
+ content: "\F0238";
+}
+.mdi-magnify-scan::before {
+ content: "\F02A1";
+}
+.mdi-mail::before {
+ content: "\FED8";
+}
+.mdi-mail-ru::before {
+ content: "\F34C";
+}
+.mdi-mailbox::before {
+ content: "\F6ED";
+}
+.mdi-mailbox-open::before {
+ content: "\FD64";
+}
+.mdi-mailbox-open-outline::before {
+ content: "\FD65";
+}
+.mdi-mailbox-open-up::before {
+ content: "\FD66";
+}
+.mdi-mailbox-open-up-outline::before {
+ content: "\FD67";
+}
+.mdi-mailbox-outline::before {
+ content: "\FD68";
+}
+.mdi-mailbox-up::before {
+ content: "\FD69";
+}
+.mdi-mailbox-up-outline::before {
+ content: "\FD6A";
+}
+.mdi-map::before {
+ content: "\F34D";
+}
+.mdi-map-check::before {
+ content: "\FED9";
+}
+.mdi-map-check-outline::before {
+ content: "\FEDA";
+}
+.mdi-map-clock::before {
+ content: "\FCFA";
+}
+.mdi-map-clock-outline::before {
+ content: "\FCFB";
+}
+.mdi-map-legend::before {
+ content: "\FA00";
+}
+.mdi-map-marker::before {
+ content: "\F34E";
+}
+.mdi-map-marker-alert::before {
+ content: "\FF22";
+}
+.mdi-map-marker-alert-outline::before {
+ content: "\FF23";
+}
+.mdi-map-marker-check::before {
+ content: "\FC71";
+}
+.mdi-map-marker-check-outline::before {
+ content: "\F0326";
+}
+.mdi-map-marker-circle::before {
+ content: "\F34F";
+}
+.mdi-map-marker-distance::before {
+ content: "\F8EF";
+}
+.mdi-map-marker-down::before {
+ content: "\F012D";
+}
+.mdi-map-marker-left::before {
+ content: "\F0306";
+}
+.mdi-map-marker-left-outline::before {
+ content: "\F0308";
+}
+.mdi-map-marker-minus::before {
+ content: "\F650";
+}
+.mdi-map-marker-minus-outline::before {
+ content: "\F0324";
+}
+.mdi-map-marker-multiple::before {
+ content: "\F350";
+}
+.mdi-map-marker-multiple-outline::before {
+ content: "\F02A2";
+}
+.mdi-map-marker-off::before {
+ content: "\F351";
+}
+.mdi-map-marker-off-outline::before {
+ content: "\F0328";
+}
+.mdi-map-marker-outline::before {
+ content: "\F7D8";
+}
+.mdi-map-marker-path::before {
+ content: "\FCFC";
+}
+.mdi-map-marker-plus::before {
+ content: "\F651";
+}
+.mdi-map-marker-plus-outline::before {
+ content: "\F0323";
+}
+.mdi-map-marker-question::before {
+ content: "\FF24";
+}
+.mdi-map-marker-question-outline::before {
+ content: "\FF25";
+}
+.mdi-map-marker-radius::before {
+ content: "\F352";
+}
+.mdi-map-marker-radius-outline::before {
+ content: "\F0327";
+}
+.mdi-map-marker-remove::before {
+ content: "\FF26";
+}
+.mdi-map-marker-remove-outline::before {
+ content: "\F0325";
+}
+.mdi-map-marker-remove-variant::before {
+ content: "\FF27";
+}
+.mdi-map-marker-right::before {
+ content: "\F0307";
+}
+.mdi-map-marker-right-outline::before {
+ content: "\F0309";
+}
+.mdi-map-marker-up::before {
+ content: "\F012E";
+}
+.mdi-map-minus::before {
+ content: "\F980";
+}
+.mdi-map-outline::before {
+ content: "\F981";
+}
+.mdi-map-plus::before {
+ content: "\F982";
+}
+.mdi-map-search::before {
+ content: "\F983";
+}
+.mdi-map-search-outline::before {
+ content: "\F984";
+}
+.mdi-mapbox::before {
+ content: "\FB86";
+}
+.mdi-margin::before {
+ content: "\F353";
+}
+.mdi-markdown::before {
+ content: "\F354";
+}
+.mdi-markdown-outline::before {
+ content: "\FF78";
+}
+.mdi-marker::before {
+ content: "\F652";
+}
+.mdi-marker-cancel::before {
+ content: "\FDB5";
+}
+.mdi-marker-check::before {
+ content: "\F355";
+}
+.mdi-mastodon::before {
+ content: "\FAD0";
+}
+.mdi-mastodon-variant::before {
+ content: "\FAD1";
+}
+.mdi-material-design::before {
+ content: "\F985";
+}
+.mdi-material-ui::before {
+ content: "\F357";
+}
+.mdi-math-compass::before {
+ content: "\F358";
+}
+.mdi-math-cos::before {
+ content: "\FC72";
+}
+.mdi-math-integral::before {
+ content: "\FFE8";
+}
+.mdi-math-integral-box::before {
+ content: "\FFE9";
+}
+.mdi-math-log::before {
+ content: "\F00B0";
+}
+.mdi-math-norm::before {
+ content: "\FFEA";
+}
+.mdi-math-norm-box::before {
+ content: "\FFEB";
+}
+.mdi-math-sin::before {
+ content: "\FC73";
+}
+.mdi-math-tan::before {
+ content: "\FC74";
+}
+.mdi-matrix::before {
+ content: "\F628";
+}
+.mdi-medal::before {
+ content: "\F986";
+}
+.mdi-medal-outline::before {
+ content: "\F0351";
+}
+.mdi-medical-bag::before {
+ content: "\F6EE";
+}
+.mdi-meditation::before {
+ content: "\F01A6";
+}
+.mdi-medium::before {
+ content: "\F35A";
+}
+.mdi-meetup::before {
+ content: "\FAD2";
+}
+.mdi-memory::before {
+ content: "\F35B";
+}
+.mdi-menu::before {
+ content: "\F35C";
+}
+.mdi-menu-down::before {
+ content: "\F35D";
+}
+.mdi-menu-down-outline::before {
+ content: "\F6B5";
+}
+.mdi-menu-left::before {
+ content: "\F35E";
+}
+.mdi-menu-left-outline::before {
+ content: "\FA01";
+}
+.mdi-menu-open::before {
+ content: "\FB87";
+}
+.mdi-menu-right::before {
+ content: "\F35F";
+}
+.mdi-menu-right-outline::before {
+ content: "\FA02";
+}
+.mdi-menu-swap::before {
+ content: "\FA63";
+}
+.mdi-menu-swap-outline::before {
+ content: "\FA64";
+}
+.mdi-menu-up::before {
+ content: "\F360";
+}
+.mdi-menu-up-outline::before {
+ content: "\F6B6";
+}
+.mdi-merge::before {
+ content: "\FF79";
+}
+.mdi-message::before {
+ content: "\F361";
+}
+.mdi-message-alert::before {
+ content: "\F362";
+}
+.mdi-message-alert-outline::before {
+ content: "\FA03";
+}
+.mdi-message-arrow-left::before {
+ content: "\F031D";
+}
+.mdi-message-arrow-left-outline::before {
+ content: "\F031E";
+}
+.mdi-message-arrow-right::before {
+ content: "\F031F";
+}
+.mdi-message-arrow-right-outline::before {
+ content: "\F0320";
+}
+.mdi-message-bulleted::before {
+ content: "\F6A1";
+}
+.mdi-message-bulleted-off::before {
+ content: "\F6A2";
+}
+.mdi-message-draw::before {
+ content: "\F363";
+}
+.mdi-message-image::before {
+ content: "\F364";
+}
+.mdi-message-image-outline::before {
+ content: "\F0197";
+}
+.mdi-message-lock::before {
+ content: "\FFEC";
+}
+.mdi-message-lock-outline::before {
+ content: "\F0198";
+}
+.mdi-message-minus::before {
+ content: "\F0199";
+}
+.mdi-message-minus-outline::before {
+ content: "\F019A";
+}
+.mdi-message-outline::before {
+ content: "\F365";
+}
+.mdi-message-plus::before {
+ content: "\F653";
+}
+.mdi-message-plus-outline::before {
+ content: "\F00E6";
+}
+.mdi-message-processing::before {
+ content: "\F366";
+}
+.mdi-message-processing-outline::before {
+ content: "\F019B";
+}
+.mdi-message-reply::before {
+ content: "\F367";
+}
+.mdi-message-reply-text::before {
+ content: "\F368";
+}
+.mdi-message-settings::before {
+ content: "\F6EF";
+}
+.mdi-message-settings-outline::before {
+ content: "\F019C";
+}
+.mdi-message-settings-variant::before {
+ content: "\F6F0";
+}
+.mdi-message-settings-variant-outline::before {
+ content: "\F019D";
+}
+.mdi-message-text::before {
+ content: "\F369";
+}
+.mdi-message-text-clock::before {
+ content: "\F019E";
+}
+.mdi-message-text-clock-outline::before {
+ content: "\F019F";
+}
+.mdi-message-text-lock::before {
+ content: "\FFED";
+}
+.mdi-message-text-lock-outline::before {
+ content: "\F01A0";
+}
+.mdi-message-text-outline::before {
+ content: "\F36A";
+}
+.mdi-message-video::before {
+ content: "\F36B";
+}
+.mdi-meteor::before {
+ content: "\F629";
+}
+.mdi-metronome::before {
+ content: "\F7D9";
+}
+.mdi-metronome-tick::before {
+ content: "\F7DA";
+}
+.mdi-micro-sd::before {
+ content: "\F7DB";
+}
+.mdi-microphone::before {
+ content: "\F36C";
+}
+.mdi-microphone-minus::before {
+ content: "\F8B2";
+}
+.mdi-microphone-off::before {
+ content: "\F36D";
+}
+.mdi-microphone-outline::before {
+ content: "\F36E";
+}
+.mdi-microphone-plus::before {
+ content: "\F8B3";
+}
+.mdi-microphone-settings::before {
+ content: "\F36F";
+}
+.mdi-microphone-variant::before {
+ content: "\F370";
+}
+.mdi-microphone-variant-off::before {
+ content: "\F371";
+}
+.mdi-microscope::before {
+ content: "\F654";
+}
+.mdi-microsoft::before {
+ content: "\F372";
+}
+.mdi-microsoft-dynamics::before {
+ content: "\F987";
+}
+.mdi-microwave::before {
+ content: "\FC75";
+}
+.mdi-middleware::before {
+ content: "\FF7A";
+}
+.mdi-middleware-outline::before {
+ content: "\FF7B";
+}
+.mdi-midi::before {
+ content: "\F8F0";
+}
+.mdi-midi-port::before {
+ content: "\F8F1";
+}
+.mdi-mine::before {
+ content: "\FDB6";
+}
+.mdi-minecraft::before {
+ content: "\F373";
+}
+.mdi-mini-sd::before {
+ content: "\FA04";
+}
+.mdi-minidisc::before {
+ content: "\FA05";
+}
+.mdi-minus::before {
+ content: "\F374";
+}
+.mdi-minus-box::before {
+ content: "\F375";
+}
+.mdi-minus-box-multiple::before {
+ content: "\F016C";
+}
+.mdi-minus-box-multiple-outline::before {
+ content: "\F016D";
+}
+.mdi-minus-box-outline::before {
+ content: "\F6F1";
+}
+.mdi-minus-circle::before {
+ content: "\F376";
+}
+.mdi-minus-circle-outline::before {
+ content: "\F377";
+}
+.mdi-minus-network::before {
+ content: "\F378";
+}
+.mdi-minus-network-outline::before {
+ content: "\FC76";
+}
+.mdi-mirror::before {
+ content: "\F0228";
+}
+.mdi-mixcloud::before {
+ content: "\F62A";
+}
+.mdi-mixed-martial-arts::before {
+ content: "\FD6B";
+}
+.mdi-mixed-reality::before {
+ content: "\F87E";
+}
+.mdi-mixer::before {
+ content: "\F7DC";
+}
+.mdi-molecule::before {
+ content: "\FB88";
+}
+.mdi-monitor::before {
+ content: "\F379";
+}
+.mdi-monitor-cellphone::before {
+ content: "\F988";
+}
+.mdi-monitor-cellphone-star::before {
+ content: "\F989";
+}
+.mdi-monitor-clean::before {
+ content: "\F012F";
+}
+.mdi-monitor-dashboard::before {
+ content: "\FA06";
+}
+.mdi-monitor-edit::before {
+ content: "\F02F1";
+}
+.mdi-monitor-lock::before {
+ content: "\FDB7";
+}
+.mdi-monitor-multiple::before {
+ content: "\F37A";
+}
+.mdi-monitor-off::before {
+ content: "\FD6C";
+}
+.mdi-monitor-screenshot::before {
+ content: "\FE34";
+}
+.mdi-monitor-speaker::before {
+ content: "\FF7C";
+}
+.mdi-monitor-speaker-off::before {
+ content: "\FF7D";
+}
+.mdi-monitor-star::before {
+ content: "\FDB8";
+}
+.mdi-moon-first-quarter::before {
+ content: "\FF7E";
+}
+.mdi-moon-full::before {
+ content: "\FF7F";
+}
+.mdi-moon-last-quarter::before {
+ content: "\FF80";
+}
+.mdi-moon-new::before {
+ content: "\FF81";
+}
+.mdi-moon-waning-crescent::before {
+ content: "\FF82";
+}
+.mdi-moon-waning-gibbous::before {
+ content: "\FF83";
+}
+.mdi-moon-waxing-crescent::before {
+ content: "\FF84";
+}
+.mdi-moon-waxing-gibbous::before {
+ content: "\FF85";
+}
+.mdi-moped::before {
+ content: "\F00B1";
+}
+.mdi-more::before {
+ content: "\F37B";
+}
+.mdi-mother-heart::before {
+ content: "\F033F";
+}
+.mdi-mother-nurse::before {
+ content: "\FCFD";
+}
+.mdi-motion-sensor::before {
+ content: "\FD6D";
+}
+.mdi-motorbike::before {
+ content: "\F37C";
+}
+.mdi-mouse::before {
+ content: "\F37D";
+}
+.mdi-mouse-bluetooth::before {
+ content: "\F98A";
+}
+.mdi-mouse-off::before {
+ content: "\F37E";
+}
+.mdi-mouse-variant::before {
+ content: "\F37F";
+}
+.mdi-mouse-variant-off::before {
+ content: "\F380";
+}
+.mdi-move-resize::before {
+ content: "\F655";
+}
+.mdi-move-resize-variant::before {
+ content: "\F656";
+}
+.mdi-movie::before {
+ content: "\F381";
+}
+.mdi-movie-edit::before {
+ content: "\F014D";
+}
+.mdi-movie-edit-outline::before {
+ content: "\F014E";
+}
+.mdi-movie-filter::before {
+ content: "\F014F";
+}
+.mdi-movie-filter-outline::before {
+ content: "\F0150";
+}
+.mdi-movie-open::before {
+ content: "\FFEE";
+}
+.mdi-movie-open-outline::before {
+ content: "\FFEF";
+}
+.mdi-movie-outline::before {
+ content: "\FDB9";
+}
+.mdi-movie-roll::before {
+ content: "\F7DD";
+}
+.mdi-movie-search::before {
+ content: "\F01FD";
+}
+.mdi-movie-search-outline::before {
+ content: "\F01FE";
+}
+.mdi-muffin::before {
+ content: "\F98B";
+}
+.mdi-multiplication::before {
+ content: "\F382";
+}
+.mdi-multiplication-box::before {
+ content: "\F383";
+}
+.mdi-mushroom::before {
+ content: "\F7DE";
+}
+.mdi-mushroom-outline::before {
+ content: "\F7DF";
+}
+.mdi-music::before {
+ content: "\F759";
+}
+.mdi-music-accidental-double-flat::before {
+ content: "\FF86";
+}
+.mdi-music-accidental-double-sharp::before {
+ content: "\FF87";
+}
+.mdi-music-accidental-flat::before {
+ content: "\FF88";
+}
+.mdi-music-accidental-natural::before {
+ content: "\FF89";
+}
+.mdi-music-accidental-sharp::before {
+ content: "\FF8A";
+}
+.mdi-music-box::before {
+ content: "\F384";
+}
+.mdi-music-box-outline::before {
+ content: "\F385";
+}
+.mdi-music-circle::before {
+ content: "\F386";
+}
+.mdi-music-circle-outline::before {
+ content: "\FAD3";
+}
+.mdi-music-clef-alto::before {
+ content: "\FF8B";
+}
+.mdi-music-clef-bass::before {
+ content: "\FF8C";
+}
+.mdi-music-clef-treble::before {
+ content: "\FF8D";
+}
+.mdi-music-note::before {
+ content: "\F387";
+}
+.mdi-music-note-bluetooth::before {
+ content: "\F5FE";
+}
+.mdi-music-note-bluetooth-off::before {
+ content: "\F5FF";
+}
+.mdi-music-note-eighth::before {
+ content: "\F388";
+}
+.mdi-music-note-eighth-dotted::before {
+ content: "\FF8E";
+}
+.mdi-music-note-half::before {
+ content: "\F389";
+}
+.mdi-music-note-half-dotted::before {
+ content: "\FF8F";
+}
+.mdi-music-note-off::before {
+ content: "\F38A";
+}
+.mdi-music-note-off-outline::before {
+ content: "\FF90";
+}
+.mdi-music-note-outline::before {
+ content: "\FF91";
+}
+.mdi-music-note-plus::before {
+ content: "\FDBA";
+}
+.mdi-music-note-quarter::before {
+ content: "\F38B";
+}
+.mdi-music-note-quarter-dotted::before {
+ content: "\FF92";
+}
+.mdi-music-note-sixteenth::before {
+ content: "\F38C";
+}
+.mdi-music-note-sixteenth-dotted::before {
+ content: "\FF93";
+}
+.mdi-music-note-whole::before {
+ content: "\F38D";
+}
+.mdi-music-note-whole-dotted::before {
+ content: "\FF94";
+}
+.mdi-music-off::before {
+ content: "\F75A";
+}
+.mdi-music-rest-eighth::before {
+ content: "\FF95";
+}
+.mdi-music-rest-half::before {
+ content: "\FF96";
+}
+.mdi-music-rest-quarter::before {
+ content: "\FF97";
+}
+.mdi-music-rest-sixteenth::before {
+ content: "\FF98";
+}
+.mdi-music-rest-whole::before {
+ content: "\FF99";
+}
+.mdi-nail::before {
+ content: "\FDBB";
+}
+.mdi-nas::before {
+ content: "\F8F2";
+}
+.mdi-nativescript::before {
+ content: "\F87F";
+}
+.mdi-nature::before {
+ content: "\F38E";
+}
+.mdi-nature-people::before {
+ content: "\F38F";
+}
+.mdi-navigation::before {
+ content: "\F390";
+}
+.mdi-near-me::before {
+ content: "\F5CD";
+}
+.mdi-necklace::before {
+ content: "\FF28";
+}
+.mdi-needle::before {
+ content: "\F391";
+}
+.mdi-netflix::before {
+ content: "\F745";
+}
+.mdi-network::before {
+ content: "\F6F2";
+}
+.mdi-network-off::before {
+ content: "\FC77";
+}
+.mdi-network-off-outline::before {
+ content: "\FC78";
+}
+.mdi-network-outline::before {
+ content: "\FC79";
+}
+.mdi-network-router::before {
+ content: "\F00B2";
+}
+.mdi-network-strength-1::before {
+ content: "\F8F3";
+}
+.mdi-network-strength-1-alert::before {
+ content: "\F8F4";
+}
+.mdi-network-strength-2::before {
+ content: "\F8F5";
+}
+.mdi-network-strength-2-alert::before {
+ content: "\F8F6";
+}
+.mdi-network-strength-3::before {
+ content: "\F8F7";
+}
+.mdi-network-strength-3-alert::before {
+ content: "\F8F8";
+}
+.mdi-network-strength-4::before {
+ content: "\F8F9";
+}
+.mdi-network-strength-4-alert::before {
+ content: "\F8FA";
+}
+.mdi-network-strength-off::before {
+ content: "\F8FB";
+}
+.mdi-network-strength-off-outline::before {
+ content: "\F8FC";
+}
+.mdi-network-strength-outline::before {
+ content: "\F8FD";
+}
+.mdi-new-box::before {
+ content: "\F394";
+}
+.mdi-newspaper::before {
+ content: "\F395";
+}
+.mdi-newspaper-minus::before {
+ content: "\FF29";
+}
+.mdi-newspaper-plus::before {
+ content: "\FF2A";
+}
+.mdi-newspaper-variant::before {
+ content: "\F0023";
+}
+.mdi-newspaper-variant-multiple::before {
+ content: "\F0024";
+}
+.mdi-newspaper-variant-multiple-outline::before {
+ content: "\F0025";
+}
+.mdi-newspaper-variant-outline::before {
+ content: "\F0026";
+}
+.mdi-nfc::before {
+ content: "\F396";
+}
+.mdi-nfc-off::before {
+ content: "\FE35";
+}
+.mdi-nfc-search-variant::before {
+ content: "\FE36";
+}
+.mdi-nfc-tap::before {
+ content: "\F397";
+}
+.mdi-nfc-variant::before {
+ content: "\F398";
+}
+.mdi-nfc-variant-off::before {
+ content: "\FE37";
+}
+.mdi-ninja::before {
+ content: "\F773";
+}
+.mdi-nintendo-switch::before {
+ content: "\F7E0";
+}
+.mdi-nix::before {
+ content: "\F0130";
+}
+.mdi-nodejs::before {
+ content: "\F399";
+}
+.mdi-noodles::before {
+ content: "\F01A9";
+}
+.mdi-not-equal::before {
+ content: "\F98C";
+}
+.mdi-not-equal-variant::before {
+ content: "\F98D";
+}
+.mdi-note::before {
+ content: "\F39A";
+}
+.mdi-note-multiple::before {
+ content: "\F6B7";
+}
+.mdi-note-multiple-outline::before {
+ content: "\F6B8";
+}
+.mdi-note-outline::before {
+ content: "\F39B";
+}
+.mdi-note-plus::before {
+ content: "\F39C";
+}
+.mdi-note-plus-outline::before {
+ content: "\F39D";
+}
+.mdi-note-text::before {
+ content: "\F39E";
+}
+.mdi-note-text-outline::before {
+ content: "\F0202";
+}
+.mdi-notebook::before {
+ content: "\F82D";
+}
+.mdi-notebook-multiple::before {
+ content: "\FE38";
+}
+.mdi-notebook-outline::before {
+ content: "\FEDC";
+}
+.mdi-notification-clear-all::before {
+ content: "\F39F";
+}
+.mdi-npm::before {
+ content: "\F6F6";
+}
+.mdi-npm-variant::before {
+ content: "\F98E";
+}
+.mdi-npm-variant-outline::before {
+ content: "\F98F";
+}
+.mdi-nuke::before {
+ content: "\F6A3";
+}
+.mdi-null::before {
+ content: "\F7E1";
+}
+.mdi-numeric::before {
+ content: "\F3A0";
+}
+.mdi-numeric-0::before {
+ content: "\30";
+}
+.mdi-numeric-0-box::before {
+ content: "\F3A1";
+}
+.mdi-numeric-0-box-multiple::before {
+ content: "\FF2B";
+}
+.mdi-numeric-0-box-multiple-outline::before {
+ content: "\F3A2";
+}
+.mdi-numeric-0-box-outline::before {
+ content: "\F3A3";
+}
+.mdi-numeric-0-circle::before {
+ content: "\FC7A";
+}
+.mdi-numeric-0-circle-outline::before {
+ content: "\FC7B";
+}
+.mdi-numeric-1::before {
+ content: "\31";
+}
+.mdi-numeric-1-box::before {
+ content: "\F3A4";
+}
+.mdi-numeric-1-box-multiple::before {
+ content: "\FF2C";
+}
+.mdi-numeric-1-box-multiple-outline::before {
+ content: "\F3A5";
+}
+.mdi-numeric-1-box-outline::before {
+ content: "\F3A6";
+}
+.mdi-numeric-1-circle::before {
+ content: "\FC7C";
+}
+.mdi-numeric-1-circle-outline::before {
+ content: "\FC7D";
+}
+.mdi-numeric-10::before {
+ content: "\F000A";
+}
+.mdi-numeric-10-box::before {
+ content: "\FF9A";
+}
+.mdi-numeric-10-box-multiple::before {
+ content: "\F000B";
+}
+.mdi-numeric-10-box-multiple-outline::before {
+ content: "\F000C";
+}
+.mdi-numeric-10-box-outline::before {
+ content: "\FF9B";
+}
+.mdi-numeric-10-circle::before {
+ content: "\F000D";
+}
+.mdi-numeric-10-circle-outline::before {
+ content: "\F000E";
+}
+.mdi-numeric-2::before {
+ content: "\32";
+}
+.mdi-numeric-2-box::before {
+ content: "\F3A7";
+}
+.mdi-numeric-2-box-multiple::before {
+ content: "\FF2D";
+}
+.mdi-numeric-2-box-multiple-outline::before {
+ content: "\F3A8";
+}
+.mdi-numeric-2-box-outline::before {
+ content: "\F3A9";
+}
+.mdi-numeric-2-circle::before {
+ content: "\FC7E";
+}
+.mdi-numeric-2-circle-outline::before {
+ content: "\FC7F";
+}
+.mdi-numeric-3::before {
+ content: "\33";
+}
+.mdi-numeric-3-box::before {
+ content: "\F3AA";
+}
+.mdi-numeric-3-box-multiple::before {
+ content: "\FF2E";
+}
+.mdi-numeric-3-box-multiple-outline::before {
+ content: "\F3AB";
+}
+.mdi-numeric-3-box-outline::before {
+ content: "\F3AC";
+}
+.mdi-numeric-3-circle::before {
+ content: "\FC80";
+}
+.mdi-numeric-3-circle-outline::before {
+ content: "\FC81";
+}
+.mdi-numeric-4::before {
+ content: "\34";
+}
+.mdi-numeric-4-box::before {
+ content: "\F3AD";
+}
+.mdi-numeric-4-box-multiple::before {
+ content: "\FF2F";
+}
+.mdi-numeric-4-box-multiple-outline::before {
+ content: "\F3AE";
+}
+.mdi-numeric-4-box-outline::before {
+ content: "\F3AF";
+}
+.mdi-numeric-4-circle::before {
+ content: "\FC82";
+}
+.mdi-numeric-4-circle-outline::before {
+ content: "\FC83";
+}
+.mdi-numeric-5::before {
+ content: "\35";
+}
+.mdi-numeric-5-box::before {
+ content: "\F3B0";
+}
+.mdi-numeric-5-box-multiple::before {
+ content: "\FF30";
+}
+.mdi-numeric-5-box-multiple-outline::before {
+ content: "\F3B1";
+}
+.mdi-numeric-5-box-outline::before {
+ content: "\F3B2";
+}
+.mdi-numeric-5-circle::before {
+ content: "\FC84";
+}
+.mdi-numeric-5-circle-outline::before {
+ content: "\FC85";
+}
+.mdi-numeric-6::before {
+ content: "\36";
+}
+.mdi-numeric-6-box::before {
+ content: "\F3B3";
+}
+.mdi-numeric-6-box-multiple::before {
+ content: "\FF31";
+}
+.mdi-numeric-6-box-multiple-outline::before {
+ content: "\F3B4";
+}
+.mdi-numeric-6-box-outline::before {
+ content: "\F3B5";
+}
+.mdi-numeric-6-circle::before {
+ content: "\FC86";
+}
+.mdi-numeric-6-circle-outline::before {
+ content: "\FC87";
+}
+.mdi-numeric-7::before {
+ content: "\37";
+}
+.mdi-numeric-7-box::before {
+ content: "\F3B6";
+}
+.mdi-numeric-7-box-multiple::before {
+ content: "\FF32";
+}
+.mdi-numeric-7-box-multiple-outline::before {
+ content: "\F3B7";
+}
+.mdi-numeric-7-box-outline::before {
+ content: "\F3B8";
+}
+.mdi-numeric-7-circle::before {
+ content: "\FC88";
+}
+.mdi-numeric-7-circle-outline::before {
+ content: "\FC89";
+}
+.mdi-numeric-8::before {
+ content: "\38";
+}
+.mdi-numeric-8-box::before {
+ content: "\F3B9";
+}
+.mdi-numeric-8-box-multiple::before {
+ content: "\FF33";
+}
+.mdi-numeric-8-box-multiple-outline::before {
+ content: "\F3BA";
+}
+.mdi-numeric-8-box-outline::before {
+ content: "\F3BB";
+}
+.mdi-numeric-8-circle::before {
+ content: "\FC8A";
+}
+.mdi-numeric-8-circle-outline::before {
+ content: "\FC8B";
+}
+.mdi-numeric-9::before {
+ content: "\39";
+}
+.mdi-numeric-9-box::before {
+ content: "\F3BC";
+}
+.mdi-numeric-9-box-multiple::before {
+ content: "\FF34";
+}
+.mdi-numeric-9-box-multiple-outline::before {
+ content: "\F3BD";
+}
+.mdi-numeric-9-box-outline::before {
+ content: "\F3BE";
+}
+.mdi-numeric-9-circle::before {
+ content: "\FC8C";
+}
+.mdi-numeric-9-circle-outline::before {
+ content: "\FC8D";
+}
+.mdi-numeric-9-plus::before {
+ content: "\F000F";
+}
+.mdi-numeric-9-plus-box::before {
+ content: "\F3BF";
+}
+.mdi-numeric-9-plus-box-multiple::before {
+ content: "\FF35";
+}
+.mdi-numeric-9-plus-box-multiple-outline::before {
+ content: "\F3C0";
+}
+.mdi-numeric-9-plus-box-outline::before {
+ content: "\F3C1";
+}
+.mdi-numeric-9-plus-circle::before {
+ content: "\FC8E";
+}
+.mdi-numeric-9-plus-circle-outline::before {
+ content: "\FC8F";
+}
+.mdi-numeric-negative-1::before {
+ content: "\F0074";
+}
+.mdi-nut::before {
+ content: "\F6F7";
+}
+.mdi-nutrition::before {
+ content: "\F3C2";
+}
+.mdi-nuxt::before {
+ content: "\F0131";
+}
+.mdi-oar::before {
+ content: "\F67B";
+}
+.mdi-ocarina::before {
+ content: "\FDBC";
+}
+.mdi-oci::before {
+ content: "\F0314";
+}
+.mdi-ocr::before {
+ content: "\F0165";
+}
+.mdi-octagon::before {
+ content: "\F3C3";
+}
+.mdi-octagon-outline::before {
+ content: "\F3C4";
+}
+.mdi-octagram::before {
+ content: "\F6F8";
+}
+.mdi-octagram-outline::before {
+ content: "\F774";
+}
+.mdi-odnoklassniki::before {
+ content: "\F3C5";
+}
+.mdi-offer::before {
+ content: "\F0246";
+}
+.mdi-office::before {
+ content: "\F3C6";
+}
+.mdi-office-building::before {
+ content: "\F990";
+}
+.mdi-oil::before {
+ content: "\F3C7";
+}
+.mdi-oil-lamp::before {
+ content: "\FF36";
+}
+.mdi-oil-level::before {
+ content: "\F0075";
+}
+.mdi-oil-temperature::before {
+ content: "\F0019";
+}
+.mdi-omega::before {
+ content: "\F3C9";
+}
+.mdi-one-up::before {
+ content: "\FB89";
+}
+.mdi-onedrive::before {
+ content: "\F3CA";
+}
+.mdi-onenote::before {
+ content: "\F746";
+}
+.mdi-onepassword::before {
+ content: "\F880";
+}
+.mdi-opacity::before {
+ content: "\F5CC";
+}
+.mdi-open-in-app::before {
+ content: "\F3CB";
+}
+.mdi-open-in-new::before {
+ content: "\F3CC";
+}
+.mdi-open-source-initiative::before {
+ content: "\FB8A";
+}
+.mdi-openid::before {
+ content: "\F3CD";
+}
+.mdi-opera::before {
+ content: "\F3CE";
+}
+.mdi-orbit::before {
+ content: "\F018";
+}
+.mdi-origin::before {
+ content: "\FB2B";
+}
+.mdi-ornament::before {
+ content: "\F3CF";
+}
+.mdi-ornament-variant::before {
+ content: "\F3D0";
+}
+.mdi-outdoor-lamp::before {
+ content: "\F0076";
+}
+.mdi-outlook::before {
+ content: "\FCFE";
+}
+.mdi-overscan::before {
+ content: "\F0027";
+}
+.mdi-owl::before {
+ content: "\F3D2";
+}
+.mdi-pac-man::before {
+ content: "\FB8B";
+}
+.mdi-package::before {
+ content: "\F3D3";
+}
+.mdi-package-down::before {
+ content: "\F3D4";
+}
+.mdi-package-up::before {
+ content: "\F3D5";
+}
+.mdi-package-variant::before {
+ content: "\F3D6";
+}
+.mdi-package-variant-closed::before {
+ content: "\F3D7";
+}
+.mdi-page-first::before {
+ content: "\F600";
+}
+.mdi-page-last::before {
+ content: "\F601";
+}
+.mdi-page-layout-body::before {
+ content: "\F6F9";
+}
+.mdi-page-layout-footer::before {
+ content: "\F6FA";
+}
+.mdi-page-layout-header::before {
+ content: "\F6FB";
+}
+.mdi-page-layout-header-footer::before {
+ content: "\FF9C";
+}
+.mdi-page-layout-sidebar-left::before {
+ content: "\F6FC";
+}
+.mdi-page-layout-sidebar-right::before {
+ content: "\F6FD";
+}
+.mdi-page-next::before {
+ content: "\FB8C";
+}
+.mdi-page-next-outline::before {
+ content: "\FB8D";
+}
+.mdi-page-previous::before {
+ content: "\FB8E";
+}
+.mdi-page-previous-outline::before {
+ content: "\FB8F";
+}
+.mdi-palette::before {
+ content: "\F3D8";
+}
+.mdi-palette-advanced::before {
+ content: "\F3D9";
+}
+.mdi-palette-outline::before {
+ content: "\FE6C";
+}
+.mdi-palette-swatch::before {
+ content: "\F8B4";
+}
+.mdi-palette-swatch-outline::before {
+ content: "\F0387";
+}
+.mdi-palm-tree::before {
+ content: "\F0077";
+}
+.mdi-pan::before {
+ content: "\FB90";
+}
+.mdi-pan-bottom-left::before {
+ content: "\FB91";
+}
+.mdi-pan-bottom-right::before {
+ content: "\FB92";
+}
+.mdi-pan-down::before {
+ content: "\FB93";
+}
+.mdi-pan-horizontal::before {
+ content: "\FB94";
+}
+.mdi-pan-left::before {
+ content: "\FB95";
+}
+.mdi-pan-right::before {
+ content: "\FB96";
+}
+.mdi-pan-top-left::before {
+ content: "\FB97";
+}
+.mdi-pan-top-right::before {
+ content: "\FB98";
+}
+.mdi-pan-up::before {
+ content: "\FB99";
+}
+.mdi-pan-vertical::before {
+ content: "\FB9A";
+}
+.mdi-panda::before {
+ content: "\F3DA";
+}
+.mdi-pandora::before {
+ content: "\F3DB";
+}
+.mdi-panorama::before {
+ content: "\F3DC";
+}
+.mdi-panorama-fisheye::before {
+ content: "\F3DD";
+}
+.mdi-panorama-horizontal::before {
+ content: "\F3DE";
+}
+.mdi-panorama-vertical::before {
+ content: "\F3DF";
+}
+.mdi-panorama-wide-angle::before {
+ content: "\F3E0";
+}
+.mdi-paper-cut-vertical::before {
+ content: "\F3E1";
+}
+.mdi-paper-roll::before {
+ content: "\F0182";
+}
+.mdi-paper-roll-outline::before {
+ content: "\F0183";
+}
+.mdi-paperclip::before {
+ content: "\F3E2";
+}
+.mdi-parachute::before {
+ content: "\FC90";
+}
+.mdi-parachute-outline::before {
+ content: "\FC91";
+}
+.mdi-parking::before {
+ content: "\F3E3";
+}
+.mdi-party-popper::before {
+ content: "\F0078";
+}
+.mdi-passport::before {
+ content: "\F7E2";
+}
+.mdi-passport-biometric::before {
+ content: "\FDBD";
+}
+.mdi-pasta::before {
+ content: "\F018B";
+}
+.mdi-patio-heater::before {
+ content: "\FF9D";
+}
+.mdi-patreon::before {
+ content: "\F881";
+}
+.mdi-pause::before {
+ content: "\F3E4";
+}
+.mdi-pause-circle::before {
+ content: "\F3E5";
+}
+.mdi-pause-circle-outline::before {
+ content: "\F3E6";
+}
+.mdi-pause-octagon::before {
+ content: "\F3E7";
+}
+.mdi-pause-octagon-outline::before {
+ content: "\F3E8";
+}
+.mdi-paw::before {
+ content: "\F3E9";
+}
+.mdi-paw-off::before {
+ content: "\F657";
+}
+.mdi-paypal::before {
+ content: "\F882";
+}
+.mdi-pdf-box::before {
+ content: "\FE39";
+}
+.mdi-peace::before {
+ content: "\F883";
+}
+.mdi-peanut::before {
+ content: "\F001E";
+}
+.mdi-peanut-off::before {
+ content: "\F001F";
+}
+.mdi-peanut-off-outline::before {
+ content: "\F0021";
+}
+.mdi-peanut-outline::before {
+ content: "\F0020";
+}
+.mdi-pen::before {
+ content: "\F3EA";
+}
+.mdi-pen-lock::before {
+ content: "\FDBE";
+}
+.mdi-pen-minus::before {
+ content: "\FDBF";
+}
+.mdi-pen-off::before {
+ content: "\FDC0";
+}
+.mdi-pen-plus::before {
+ content: "\FDC1";
+}
+.mdi-pen-remove::before {
+ content: "\FDC2";
+}
+.mdi-pencil::before {
+ content: "\F3EB";
+}
+.mdi-pencil-box::before {
+ content: "\F3EC";
+}
+.mdi-pencil-box-multiple::before {
+ content: "\F016F";
+}
+.mdi-pencil-box-multiple-outline::before {
+ content: "\F0170";
+}
+.mdi-pencil-box-outline::before {
+ content: "\F3ED";
+}
+.mdi-pencil-circle::before {
+ content: "\F6FE";
+}
+.mdi-pencil-circle-outline::before {
+ content: "\F775";
+}
+.mdi-pencil-lock::before {
+ content: "\F3EE";
+}
+.mdi-pencil-lock-outline::before {
+ content: "\FDC3";
+}
+.mdi-pencil-minus::before {
+ content: "\FDC4";
+}
+.mdi-pencil-minus-outline::before {
+ content: "\FDC5";
+}
+.mdi-pencil-off::before {
+ content: "\F3EF";
+}
+.mdi-pencil-off-outline::before {
+ content: "\FDC6";
+}
+.mdi-pencil-outline::before {
+ content: "\FC92";
+}
+.mdi-pencil-plus::before {
+ content: "\FDC7";
+}
+.mdi-pencil-plus-outline::before {
+ content: "\FDC8";
+}
+.mdi-pencil-remove::before {
+ content: "\FDC9";
+}
+.mdi-pencil-remove-outline::before {
+ content: "\FDCA";
+}
+.mdi-pencil-ruler::before {
+ content: "\F037E";
+}
+.mdi-penguin::before {
+ content: "\FEDD";
+}
+.mdi-pentagon::before {
+ content: "\F6FF";
+}
+.mdi-pentagon-outline::before {
+ content: "\F700";
+}
+.mdi-percent::before {
+ content: "\F3F0";
+}
+.mdi-percent-outline::before {
+ content: "\F02A3";
+}
+.mdi-periodic-table::before {
+ content: "\F8B5";
+}
+.mdi-periodic-table-co::before {
+ content: "\F0329";
+}
+.mdi-periodic-table-co2::before {
+ content: "\F7E3";
+}
+.mdi-periscope::before {
+ content: "\F747";
+}
+.mdi-perspective-less::before {
+ content: "\FCFF";
+}
+.mdi-perspective-more::before {
+ content: "\FD00";
+}
+.mdi-pharmacy::before {
+ content: "\F3F1";
+}
+.mdi-phone::before {
+ content: "\F3F2";
+}
+.mdi-phone-alert::before {
+ content: "\FF37";
+}
+.mdi-phone-alert-outline::before {
+ content: "\F01B9";
+}
+.mdi-phone-bluetooth::before {
+ content: "\F3F3";
+}
+.mdi-phone-bluetooth-outline::before {
+ content: "\F01BA";
+}
+.mdi-phone-cancel::before {
+ content: "\F00E7";
+}
+.mdi-phone-cancel-outline::before {
+ content: "\F01BB";
+}
+.mdi-phone-check::before {
+ content: "\F01D4";
+}
+.mdi-phone-check-outline::before {
+ content: "\F01D5";
+}
+.mdi-phone-classic::before {
+ content: "\F602";
+}
+.mdi-phone-classic-off::before {
+ content: "\F02A4";
+}
+.mdi-phone-forward::before {
+ content: "\F3F4";
+}
+.mdi-phone-forward-outline::before {
+ content: "\F01BC";
+}
+.mdi-phone-hangup::before {
+ content: "\F3F5";
+}
+.mdi-phone-hangup-outline::before {
+ content: "\F01BD";
+}
+.mdi-phone-in-talk::before {
+ content: "\F3F6";
+}
+.mdi-phone-in-talk-outline::before {
+ content: "\F01AD";
+}
+.mdi-phone-incoming::before {
+ content: "\F3F7";
+}
+.mdi-phone-incoming-outline::before {
+ content: "\F01BE";
+}
+.mdi-phone-lock::before {
+ content: "\F3F8";
+}
+.mdi-phone-lock-outline::before {
+ content: "\F01BF";
+}
+.mdi-phone-log::before {
+ content: "\F3F9";
+}
+.mdi-phone-log-outline::before {
+ content: "\F01C0";
+}
+.mdi-phone-message::before {
+ content: "\F01C1";
+}
+.mdi-phone-message-outline::before {
+ content: "\F01C2";
+}
+.mdi-phone-minus::before {
+ content: "\F658";
+}
+.mdi-phone-minus-outline::before {
+ content: "\F01C3";
+}
+.mdi-phone-missed::before {
+ content: "\F3FA";
+}
+.mdi-phone-missed-outline::before {
+ content: "\F01D0";
+}
+.mdi-phone-off::before {
+ content: "\FDCB";
+}
+.mdi-phone-off-outline::before {
+ content: "\F01D1";
+}
+.mdi-phone-outgoing::before {
+ content: "\F3FB";
+}
+.mdi-phone-outgoing-outline::before {
+ content: "\F01C4";
+}
+.mdi-phone-outline::before {
+ content: "\FDCC";
+}
+.mdi-phone-paused::before {
+ content: "\F3FC";
+}
+.mdi-phone-paused-outline::before {
+ content: "\F01C5";
+}
+.mdi-phone-plus::before {
+ content: "\F659";
+}
+.mdi-phone-plus-outline::before {
+ content: "\F01C6";
+}
+.mdi-phone-return::before {
+ content: "\F82E";
+}
+.mdi-phone-return-outline::before {
+ content: "\F01C7";
+}
+.mdi-phone-ring::before {
+ content: "\F01D6";
+}
+.mdi-phone-ring-outline::before {
+ content: "\F01D7";
+}
+.mdi-phone-rotate-landscape::before {
+ content: "\F884";
+}
+.mdi-phone-rotate-portrait::before {
+ content: "\F885";
+}
+.mdi-phone-settings::before {
+ content: "\F3FD";
+}
+.mdi-phone-settings-outline::before {
+ content: "\F01C8";
+}
+.mdi-phone-voip::before {
+ content: "\F3FE";
+}
+.mdi-pi::before {
+ content: "\F3FF";
+}
+.mdi-pi-box::before {
+ content: "\F400";
+}
+.mdi-pi-hole::before {
+ content: "\FDCD";
+}
+.mdi-piano::before {
+ content: "\F67C";
+}
+.mdi-pickaxe::before {
+ content: "\F8B6";
+}
+.mdi-picture-in-picture-bottom-right::before {
+ content: "\FE3A";
+}
+.mdi-picture-in-picture-bottom-right-outline::before {
+ content: "\FE3B";
+}
+.mdi-picture-in-picture-top-right::before {
+ content: "\FE3C";
+}
+.mdi-picture-in-picture-top-right-outline::before {
+ content: "\FE3D";
+}
+.mdi-pier::before {
+ content: "\F886";
+}
+.mdi-pier-crane::before {
+ content: "\F887";
+}
+.mdi-pig::before {
+ content: "\F401";
+}
+.mdi-pig-variant::before {
+ content: "\F0028";
+}
+.mdi-piggy-bank::before {
+ content: "\F0029";
+}
+.mdi-pill::before {
+ content: "\F402";
+}
+.mdi-pillar::before {
+ content: "\F701";
+}
+.mdi-pin::before {
+ content: "\F403";
+}
+.mdi-pin-off::before {
+ content: "\F404";
+}
+.mdi-pin-off-outline::before {
+ content: "\F92F";
+}
+.mdi-pin-outline::before {
+ content: "\F930";
+}
+.mdi-pine-tree::before {
+ content: "\F405";
+}
+.mdi-pine-tree-box::before {
+ content: "\F406";
+}
+.mdi-pinterest::before {
+ content: "\F407";
+}
+.mdi-pinterest-box::before {
+ content: "\F408";
+}
+.mdi-pinwheel::before {
+ content: "\FAD4";
+}
+.mdi-pinwheel-outline::before {
+ content: "\FAD5";
+}
+.mdi-pipe::before {
+ content: "\F7E4";
+}
+.mdi-pipe-disconnected::before {
+ content: "\F7E5";
+}
+.mdi-pipe-leak::before {
+ content: "\F888";
+}
+.mdi-pipe-wrench::before {
+ content: "\F037F";
+}
+.mdi-pirate::before {
+ content: "\FA07";
+}
+.mdi-pistol::before {
+ content: "\F702";
+}
+.mdi-piston::before {
+ content: "\F889";
+}
+.mdi-pizza::before {
+ content: "\F409";
+}
+.mdi-play::before {
+ content: "\F40A";
+}
+.mdi-play-box::before {
+ content: "\F02A5";
+}
+.mdi-play-box-outline::before {
+ content: "\F40B";
+}
+.mdi-play-circle::before {
+ content: "\F40C";
+}
+.mdi-play-circle-outline::before {
+ content: "\F40D";
+}
+.mdi-play-network::before {
+ content: "\F88A";
+}
+.mdi-play-network-outline::before {
+ content: "\FC93";
+}
+.mdi-play-outline::before {
+ content: "\FF38";
+}
+.mdi-play-pause::before {
+ content: "\F40E";
+}
+.mdi-play-protected-content::before {
+ content: "\F40F";
+}
+.mdi-play-speed::before {
+ content: "\F8FE";
+}
+.mdi-playlist-check::before {
+ content: "\F5C7";
+}
+.mdi-playlist-edit::before {
+ content: "\F8FF";
+}
+.mdi-playlist-minus::before {
+ content: "\F410";
+}
+.mdi-playlist-music::before {
+ content: "\FC94";
+}
+.mdi-playlist-music-outline::before {
+ content: "\FC95";
+}
+.mdi-playlist-play::before {
+ content: "\F411";
+}
+.mdi-playlist-plus::before {
+ content: "\F412";
+}
+.mdi-playlist-remove::before {
+ content: "\F413";
+}
+.mdi-playlist-star::before {
+ content: "\FDCE";
+}
+.mdi-playstation::before {
+ content: "\F414";
+}
+.mdi-plex::before {
+ content: "\F6B9";
+}
+.mdi-plus::before {
+ content: "\F415";
+}
+.mdi-plus-box::before {
+ content: "\F416";
+}
+.mdi-plus-box-multiple::before {
+ content: "\F334";
+}
+.mdi-plus-box-multiple-outline::before {
+ content: "\F016E";
+}
+.mdi-plus-box-outline::before {
+ content: "\F703";
+}
+.mdi-plus-circle::before {
+ content: "\F417";
+}
+.mdi-plus-circle-multiple-outline::before {
+ content: "\F418";
+}
+.mdi-plus-circle-outline::before {
+ content: "\F419";
+}
+.mdi-plus-minus::before {
+ content: "\F991";
+}
+.mdi-plus-minus-box::before {
+ content: "\F992";
+}
+.mdi-plus-network::before {
+ content: "\F41A";
+}
+.mdi-plus-network-outline::before {
+ content: "\FC96";
+}
+.mdi-plus-one::before {
+ content: "\F41B";
+}
+.mdi-plus-outline::before {
+ content: "\F704";
+}
+.mdi-plus-thick::before {
+ content: "\F0217";
+}
+.mdi-pocket::before {
+ content: "\F41C";
+}
+.mdi-podcast::before {
+ content: "\F993";
+}
+.mdi-podium::before {
+ content: "\FD01";
+}
+.mdi-podium-bronze::before {
+ content: "\FD02";
+}
+.mdi-podium-gold::before {
+ content: "\FD03";
+}
+.mdi-podium-silver::before {
+ content: "\FD04";
+}
+.mdi-point-of-sale::before {
+ content: "\FD6E";
+}
+.mdi-pokeball::before {
+ content: "\F41D";
+}
+.mdi-pokemon-go::before {
+ content: "\FA08";
+}
+.mdi-poker-chip::before {
+ content: "\F82F";
+}
+.mdi-polaroid::before {
+ content: "\F41E";
+}
+.mdi-police-badge::before {
+ content: "\F0192";
+}
+.mdi-police-badge-outline::before {
+ content: "\F0193";
+}
+.mdi-poll::before {
+ content: "\F41F";
+}
+.mdi-poll-box::before {
+ content: "\F420";
+}
+.mdi-poll-box-outline::before {
+ content: "\F02A6";
+}
+.mdi-polymer::before {
+ content: "\F421";
+}
+.mdi-pool::before {
+ content: "\F606";
+}
+.mdi-popcorn::before {
+ content: "\F422";
+}
+.mdi-post::before {
+ content: "\F002A";
+}
+.mdi-post-outline::before {
+ content: "\F002B";
+}
+.mdi-postage-stamp::before {
+ content: "\FC97";
+}
+.mdi-pot::before {
+ content: "\F65A";
+}
+.mdi-pot-mix::before {
+ content: "\F65B";
+}
+.mdi-pound::before {
+ content: "\F423";
+}
+.mdi-pound-box::before {
+ content: "\F424";
+}
+.mdi-pound-box-outline::before {
+ content: "\F01AA";
+}
+.mdi-power::before {
+ content: "\F425";
+}
+.mdi-power-cycle::before {
+ content: "\F900";
+}
+.mdi-power-off::before {
+ content: "\F901";
+}
+.mdi-power-on::before {
+ content: "\F902";
+}
+.mdi-power-plug::before {
+ content: "\F6A4";
+}
+.mdi-power-plug-off::before {
+ content: "\F6A5";
+}
+.mdi-power-settings::before {
+ content: "\F426";
+}
+.mdi-power-sleep::before {
+ content: "\F903";
+}
+.mdi-power-socket::before {
+ content: "\F427";
+}
+.mdi-power-socket-au::before {
+ content: "\F904";
+}
+.mdi-power-socket-de::before {
+ content: "\F0132";
+}
+.mdi-power-socket-eu::before {
+ content: "\F7E6";
+}
+.mdi-power-socket-fr::before {
+ content: "\F0133";
+}
+.mdi-power-socket-jp::before {
+ content: "\F0134";
+}
+.mdi-power-socket-uk::before {
+ content: "\F7E7";
+}
+.mdi-power-socket-us::before {
+ content: "\F7E8";
+}
+.mdi-power-standby::before {
+ content: "\F905";
+}
+.mdi-powershell::before {
+ content: "\FA09";
+}
+.mdi-prescription::before {
+ content: "\F705";
+}
+.mdi-presentation::before {
+ content: "\F428";
+}
+.mdi-presentation-play::before {
+ content: "\F429";
+}
+.mdi-printer::before {
+ content: "\F42A";
+}
+.mdi-printer-3d::before {
+ content: "\F42B";
+}
+.mdi-printer-3d-nozzle::before {
+ content: "\FE3E";
+}
+.mdi-printer-3d-nozzle-alert::before {
+ content: "\F01EB";
+}
+.mdi-printer-3d-nozzle-alert-outline::before {
+ content: "\F01EC";
+}
+.mdi-printer-3d-nozzle-outline::before {
+ content: "\FE3F";
+}
+.mdi-printer-alert::before {
+ content: "\F42C";
+}
+.mdi-printer-check::before {
+ content: "\F0171";
+}
+.mdi-printer-off::before {
+ content: "\FE40";
+}
+.mdi-printer-pos::before {
+ content: "\F0079";
+}
+.mdi-printer-settings::before {
+ content: "\F706";
+}
+.mdi-printer-wireless::before {
+ content: "\FA0A";
+}
+.mdi-priority-high::before {
+ content: "\F603";
+}
+.mdi-priority-low::before {
+ content: "\F604";
+}
+.mdi-professional-hexagon::before {
+ content: "\F42D";
+}
+.mdi-progress-alert::before {
+ content: "\FC98";
+}
+.mdi-progress-check::before {
+ content: "\F994";
+}
+.mdi-progress-clock::before {
+ content: "\F995";
+}
+.mdi-progress-close::before {
+ content: "\F0135";
+}
+.mdi-progress-download::before {
+ content: "\F996";
+}
+.mdi-progress-upload::before {
+ content: "\F997";
+}
+.mdi-progress-wrench::before {
+ content: "\FC99";
+}
+.mdi-projector::before {
+ content: "\F42E";
+}
+.mdi-projector-screen::before {
+ content: "\F42F";
+}
+.mdi-propane-tank::before {
+ content: "\F0382";
+}
+.mdi-propane-tank-outline::before {
+ content: "\F0383";
+}
+.mdi-protocol::before {
+ content: "\FFF9";
+}
+.mdi-publish::before {
+ content: "\F6A6";
+}
+.mdi-pulse::before {
+ content: "\F430";
+}
+.mdi-pumpkin::before {
+ content: "\FB9B";
+}
+.mdi-purse::before {
+ content: "\FF39";
+}
+.mdi-purse-outline::before {
+ content: "\FF3A";
+}
+.mdi-puzzle::before {
+ content: "\F431";
+}
+.mdi-puzzle-outline::before {
+ content: "\FA65";
+}
+.mdi-qi::before {
+ content: "\F998";
+}
+.mdi-qqchat::before {
+ content: "\F605";
+}
+.mdi-qrcode::before {
+ content: "\F432";
+}
+.mdi-qrcode-edit::before {
+ content: "\F8B7";
+}
+.mdi-qrcode-minus::before {
+ content: "\F01B7";
+}
+.mdi-qrcode-plus::before {
+ content: "\F01B6";
+}
+.mdi-qrcode-remove::before {
+ content: "\F01B8";
+}
+.mdi-qrcode-scan::before {
+ content: "\F433";
+}
+.mdi-quadcopter::before {
+ content: "\F434";
+}
+.mdi-quality-high::before {
+ content: "\F435";
+}
+.mdi-quality-low::before {
+ content: "\FA0B";
+}
+.mdi-quality-medium::before {
+ content: "\FA0C";
+}
+.mdi-quicktime::before {
+ content: "\F436";
+}
+.mdi-quora::before {
+ content: "\FD05";
+}
+.mdi-rabbit::before {
+ content: "\F906";
+}
+.mdi-racing-helmet::before {
+ content: "\FD6F";
+}
+.mdi-racquetball::before {
+ content: "\FD70";
+}
+.mdi-radar::before {
+ content: "\F437";
+}
+.mdi-radiator::before {
+ content: "\F438";
+}
+.mdi-radiator-disabled::before {
+ content: "\FAD6";
+}
+.mdi-radiator-off::before {
+ content: "\FAD7";
+}
+.mdi-radio::before {
+ content: "\F439";
+}
+.mdi-radio-am::before {
+ content: "\FC9A";
+}
+.mdi-radio-fm::before {
+ content: "\FC9B";
+}
+.mdi-radio-handheld::before {
+ content: "\F43A";
+}
+.mdi-radio-off::before {
+ content: "\F0247";
+}
+.mdi-radio-tower::before {
+ content: "\F43B";
+}
+.mdi-radioactive::before {
+ content: "\F43C";
+}
+.mdi-radioactive-off::before {
+ content: "\FEDE";
+}
+.mdi-radiobox-blank::before {
+ content: "\F43D";
+}
+.mdi-radiobox-marked::before {
+ content: "\F43E";
+}
+.mdi-radius::before {
+ content: "\FC9C";
+}
+.mdi-radius-outline::before {
+ content: "\FC9D";
+}
+.mdi-railroad-light::before {
+ content: "\FF3B";
+}
+.mdi-raspberry-pi::before {
+ content: "\F43F";
+}
+.mdi-ray-end::before {
+ content: "\F440";
+}
+.mdi-ray-end-arrow::before {
+ content: "\F441";
+}
+.mdi-ray-start::before {
+ content: "\F442";
+}
+.mdi-ray-start-arrow::before {
+ content: "\F443";
+}
+.mdi-ray-start-end::before {
+ content: "\F444";
+}
+.mdi-ray-vertex::before {
+ content: "\F445";
+}
+.mdi-react::before {
+ content: "\F707";
+}
+.mdi-read::before {
+ content: "\F447";
+}
+.mdi-receipt::before {
+ content: "\F449";
+}
+.mdi-record::before {
+ content: "\F44A";
+}
+.mdi-record-circle::before {
+ content: "\FEDF";
+}
+.mdi-record-circle-outline::before {
+ content: "\FEE0";
+}
+.mdi-record-player::before {
+ content: "\F999";
+}
+.mdi-record-rec::before {
+ content: "\F44B";
+}
+.mdi-rectangle::before {
+ content: "\FE41";
+}
+.mdi-rectangle-outline::before {
+ content: "\FE42";
+}
+.mdi-recycle::before {
+ content: "\F44C";
+}
+.mdi-reddit::before {
+ content: "\F44D";
+}
+.mdi-redhat::before {
+ content: "\F0146";
+}
+.mdi-redo::before {
+ content: "\F44E";
+}
+.mdi-redo-variant::before {
+ content: "\F44F";
+}
+.mdi-reflect-horizontal::before {
+ content: "\FA0D";
+}
+.mdi-reflect-vertical::before {
+ content: "\FA0E";
+}
+.mdi-refresh::before {
+ content: "\F450";
+}
+.mdi-refresh-circle::before {
+ content: "\F03A2";
+}
+.mdi-regex::before {
+ content: "\F451";
+}
+.mdi-registered-trademark::before {
+ content: "\FA66";
+}
+.mdi-relative-scale::before {
+ content: "\F452";
+}
+.mdi-reload::before {
+ content: "\F453";
+}
+.mdi-reload-alert::before {
+ content: "\F0136";
+}
+.mdi-reminder::before {
+ content: "\F88B";
+}
+.mdi-remote::before {
+ content: "\F454";
+}
+.mdi-remote-desktop::before {
+ content: "\F8B8";
+}
+.mdi-remote-off::before {
+ content: "\FEE1";
+}
+.mdi-remote-tv::before {
+ content: "\FEE2";
+}
+.mdi-remote-tv-off::before {
+ content: "\FEE3";
+}
+.mdi-rename-box::before {
+ content: "\F455";
+}
+.mdi-reorder-horizontal::before {
+ content: "\F687";
+}
+.mdi-reorder-vertical::before {
+ content: "\F688";
+}
+.mdi-repeat::before {
+ content: "\F456";
+}
+.mdi-repeat-off::before {
+ content: "\F457";
+}
+.mdi-repeat-once::before {
+ content: "\F458";
+}
+.mdi-replay::before {
+ content: "\F459";
+}
+.mdi-reply::before {
+ content: "\F45A";
+}
+.mdi-reply-all::before {
+ content: "\F45B";
+}
+.mdi-reply-all-outline::before {
+ content: "\FF3C";
+}
+.mdi-reply-circle::before {
+ content: "\F01D9";
+}
+.mdi-reply-outline::before {
+ content: "\FF3D";
+}
+.mdi-reproduction::before {
+ content: "\F45C";
+}
+.mdi-resistor::before {
+ content: "\FB1F";
+}
+.mdi-resistor-nodes::before {
+ content: "\FB20";
+}
+.mdi-resize::before {
+ content: "\FA67";
+}
+.mdi-resize-bottom-right::before {
+ content: "\F45D";
+}
+.mdi-responsive::before {
+ content: "\F45E";
+}
+.mdi-restart::before {
+ content: "\F708";
+}
+.mdi-restart-alert::before {
+ content: "\F0137";
+}
+.mdi-restart-off::before {
+ content: "\FD71";
+}
+.mdi-restore::before {
+ content: "\F99A";
+}
+.mdi-restore-alert::before {
+ content: "\F0138";
+}
+.mdi-rewind::before {
+ content: "\F45F";
+}
+.mdi-rewind-10::before {
+ content: "\FD06";
+}
+.mdi-rewind-30::before {
+ content: "\FD72";
+}
+.mdi-rewind-5::before {
+ content: "\F0224";
+}
+.mdi-rewind-outline::before {
+ content: "\F709";
+}
+.mdi-rhombus::before {
+ content: "\F70A";
+}
+.mdi-rhombus-medium::before {
+ content: "\FA0F";
+}
+.mdi-rhombus-outline::before {
+ content: "\F70B";
+}
+.mdi-rhombus-split::before {
+ content: "\FA10";
+}
+.mdi-ribbon::before {
+ content: "\F460";
+}
+.mdi-rice::before {
+ content: "\F7E9";
+}
+.mdi-ring::before {
+ content: "\F7EA";
+}
+.mdi-rivet::before {
+ content: "\FE43";
+}
+.mdi-road::before {
+ content: "\F461";
+}
+.mdi-road-variant::before {
+ content: "\F462";
+}
+.mdi-robber::before {
+ content: "\F007A";
+}
+.mdi-robot::before {
+ content: "\F6A8";
+}
+.mdi-robot-industrial::before {
+ content: "\FB21";
+}
+.mdi-robot-mower::before {
+ content: "\F0222";
+}
+.mdi-robot-mower-outline::before {
+ content: "\F021E";
+}
+.mdi-robot-vacuum::before {
+ content: "\F70C";
+}
+.mdi-robot-vacuum-variant::before {
+ content: "\F907";
+}
+.mdi-rocket::before {
+ content: "\F463";
+}
+.mdi-rodent::before {
+ content: "\F0352";
+}
+.mdi-roller-skate::before {
+ content: "\FD07";
+}
+.mdi-rollerblade::before {
+ content: "\FD08";
+}
+.mdi-rollupjs::before {
+ content: "\FB9C";
+}
+.mdi-roman-numeral-1::before {
+ content: "\F00B3";
+}
+.mdi-roman-numeral-10::before {
+ content: "\F00BC";
+}
+.mdi-roman-numeral-2::before {
+ content: "\F00B4";
+}
+.mdi-roman-numeral-3::before {
+ content: "\F00B5";
+}
+.mdi-roman-numeral-4::before {
+ content: "\F00B6";
+}
+.mdi-roman-numeral-5::before {
+ content: "\F00B7";
+}
+.mdi-roman-numeral-6::before {
+ content: "\F00B8";
+}
+.mdi-roman-numeral-7::before {
+ content: "\F00B9";
+}
+.mdi-roman-numeral-8::before {
+ content: "\F00BA";
+}
+.mdi-roman-numeral-9::before {
+ content: "\F00BB";
+}
+.mdi-room-service::before {
+ content: "\F88C";
+}
+.mdi-room-service-outline::before {
+ content: "\FD73";
+}
+.mdi-rotate-3d::before {
+ content: "\FEE4";
+}
+.mdi-rotate-3d-variant::before {
+ content: "\F464";
+}
+.mdi-rotate-left::before {
+ content: "\F465";
+}
+.mdi-rotate-left-variant::before {
+ content: "\F466";
+}
+.mdi-rotate-orbit::before {
+ content: "\FD74";
+}
+.mdi-rotate-right::before {
+ content: "\F467";
+}
+.mdi-rotate-right-variant::before {
+ content: "\F468";
+}
+.mdi-rounded-corner::before {
+ content: "\F607";
+}
+.mdi-router::before {
+ content: "\F020D";
+}
+.mdi-router-wireless::before {
+ content: "\F469";
+}
+.mdi-router-wireless-settings::before {
+ content: "\FA68";
+}
+.mdi-routes::before {
+ content: "\F46A";
+}
+.mdi-routes-clock::before {
+ content: "\F007B";
+}
+.mdi-rowing::before {
+ content: "\F608";
+}
+.mdi-rss::before {
+ content: "\F46B";
+}
+.mdi-rss-box::before {
+ content: "\F46C";
+}
+.mdi-rss-off::before {
+ content: "\FF3E";
+}
+.mdi-ruby::before {
+ content: "\FD09";
+}
+.mdi-rugby::before {
+ content: "\FD75";
+}
+.mdi-ruler::before {
+ content: "\F46D";
+}
+.mdi-ruler-square::before {
+ content: "\FC9E";
+}
+.mdi-ruler-square-compass::before {
+ content: "\FEDB";
+}
+.mdi-run::before {
+ content: "\F70D";
+}
+.mdi-run-fast::before {
+ content: "\F46E";
+}
+.mdi-rv-truck::before {
+ content: "\F01FF";
+}
+.mdi-sack::before {
+ content: "\FD0A";
+}
+.mdi-sack-percent::before {
+ content: "\FD0B";
+}
+.mdi-safe::before {
+ content: "\FA69";
+}
+.mdi-safe-square::before {
+ content: "\F02A7";
+}
+.mdi-safe-square-outline::before {
+ content: "\F02A8";
+}
+.mdi-safety-goggles::before {
+ content: "\FD0C";
+}
+.mdi-sailing::before {
+ content: "\FEE5";
+}
+.mdi-sale::before {
+ content: "\F46F";
+}
+.mdi-salesforce::before {
+ content: "\F88D";
+}
+.mdi-sass::before {
+ content: "\F7EB";
+}
+.mdi-satellite::before {
+ content: "\F470";
+}
+.mdi-satellite-uplink::before {
+ content: "\F908";
+}
+.mdi-satellite-variant::before {
+ content: "\F471";
+}
+.mdi-sausage::before {
+ content: "\F8B9";
+}
+.mdi-saw-blade::before {
+ content: "\FE44";
+}
+.mdi-saxophone::before {
+ content: "\F609";
+}
+.mdi-scale::before {
+ content: "\F472";
+}
+.mdi-scale-balance::before {
+ content: "\F5D1";
+}
+.mdi-scale-bathroom::before {
+ content: "\F473";
+}
+.mdi-scale-off::before {
+ content: "\F007C";
+}
+.mdi-scanner::before {
+ content: "\F6AA";
+}
+.mdi-scanner-off::before {
+ content: "\F909";
+}
+.mdi-scatter-plot::before {
+ content: "\FEE6";
+}
+.mdi-scatter-plot-outline::before {
+ content: "\FEE7";
+}
+.mdi-school::before {
+ content: "\F474";
+}
+.mdi-school-outline::before {
+ content: "\F01AB";
+}
+.mdi-scissors-cutting::before {
+ content: "\FA6A";
+}
+.mdi-scooter::before {
+ content: "\F0214";
+}
+.mdi-scoreboard::before {
+ content: "\F02A9";
+}
+.mdi-scoreboard-outline::before {
+ content: "\F02AA";
+}
+.mdi-screen-rotation::before {
+ content: "\F475";
+}
+.mdi-screen-rotation-lock::before {
+ content: "\F476";
+}
+.mdi-screw-flat-top::before {
+ content: "\FDCF";
+}
+.mdi-screw-lag::before {
+ content: "\FE54";
+}
+.mdi-screw-machine-flat-top::before {
+ content: "\FE55";
+}
+.mdi-screw-machine-round-top::before {
+ content: "\FE56";
+}
+.mdi-screw-round-top::before {
+ content: "\FE57";
+}
+.mdi-screwdriver::before {
+ content: "\F477";
+}
+.mdi-script::before {
+ content: "\FB9D";
+}
+.mdi-script-outline::before {
+ content: "\F478";
+}
+.mdi-script-text::before {
+ content: "\FB9E";
+}
+.mdi-script-text-outline::before {
+ content: "\FB9F";
+}
+.mdi-sd::before {
+ content: "\F479";
+}
+.mdi-seal::before {
+ content: "\F47A";
+}
+.mdi-seal-variant::before {
+ content: "\FFFA";
+}
+.mdi-search-web::before {
+ content: "\F70E";
+}
+.mdi-seat::before {
+ content: "\FC9F";
+}
+.mdi-seat-flat::before {
+ content: "\F47B";
+}
+.mdi-seat-flat-angled::before {
+ content: "\F47C";
+}
+.mdi-seat-individual-suite::before {
+ content: "\F47D";
+}
+.mdi-seat-legroom-extra::before {
+ content: "\F47E";
+}
+.mdi-seat-legroom-normal::before {
+ content: "\F47F";
+}
+.mdi-seat-legroom-reduced::before {
+ content: "\F480";
+}
+.mdi-seat-outline::before {
+ content: "\FCA0";
+}
+.mdi-seat-passenger::before {
+ content: "\F0274";
+}
+.mdi-seat-recline-extra::before {
+ content: "\F481";
+}
+.mdi-seat-recline-normal::before {
+ content: "\F482";
+}
+.mdi-seatbelt::before {
+ content: "\FCA1";
+}
+.mdi-security::before {
+ content: "\F483";
+}
+.mdi-security-network::before {
+ content: "\F484";
+}
+.mdi-seed::before {
+ content: "\FE45";
+}
+.mdi-seed-outline::before {
+ content: "\FE46";
+}
+.mdi-segment::before {
+ content: "\FEE8";
+}
+.mdi-select::before {
+ content: "\F485";
+}
+.mdi-select-all::before {
+ content: "\F486";
+}
+.mdi-select-color::before {
+ content: "\FD0D";
+}
+.mdi-select-compare::before {
+ content: "\FAD8";
+}
+.mdi-select-drag::before {
+ content: "\FA6B";
+}
+.mdi-select-group::before {
+ content: "\FF9F";
+}
+.mdi-select-inverse::before {
+ content: "\F487";
+}
+.mdi-select-marker::before {
+ content: "\F02AB";
+}
+.mdi-select-multiple::before {
+ content: "\F02AC";
+}
+.mdi-select-multiple-marker::before {
+ content: "\F02AD";
+}
+.mdi-select-off::before {
+ content: "\F488";
+}
+.mdi-select-place::before {
+ content: "\FFFB";
+}
+.mdi-select-search::before {
+ content: "\F022F";
+}
+.mdi-selection::before {
+ content: "\F489";
+}
+.mdi-selection-drag::before {
+ content: "\FA6C";
+}
+.mdi-selection-ellipse::before {
+ content: "\FD0E";
+}
+.mdi-selection-ellipse-arrow-inside::before {
+ content: "\FF3F";
+}
+.mdi-selection-marker::before {
+ content: "\F02AE";
+}
+.mdi-selection-multiple-marker::before {
+ content: "\F02AF";
+}
+.mdi-selection-mutliple::before {
+ content: "\F02B0";
+}
+.mdi-selection-off::before {
+ content: "\F776";
+}
+.mdi-selection-search::before {
+ content: "\F0230";
+}
+.mdi-semantic-web::before {
+ content: "\F0341";
+}
+.mdi-send::before {
+ content: "\F48A";
+}
+.mdi-send-check::before {
+ content: "\F018C";
+}
+.mdi-send-check-outline::before {
+ content: "\F018D";
+}
+.mdi-send-circle::before {
+ content: "\FE58";
+}
+.mdi-send-circle-outline::before {
+ content: "\FE59";
+}
+.mdi-send-clock::before {
+ content: "\F018E";
+}
+.mdi-send-clock-outline::before {
+ content: "\F018F";
+}
+.mdi-send-lock::before {
+ content: "\F7EC";
+}
+.mdi-send-lock-outline::before {
+ content: "\F0191";
+}
+.mdi-send-outline::before {
+ content: "\F0190";
+}
+.mdi-serial-port::before {
+ content: "\F65C";
+}
+.mdi-server::before {
+ content: "\F48B";
+}
+.mdi-server-minus::before {
+ content: "\F48C";
+}
+.mdi-server-network::before {
+ content: "\F48D";
+}
+.mdi-server-network-off::before {
+ content: "\F48E";
+}
+.mdi-server-off::before {
+ content: "\F48F";
+}
+.mdi-server-plus::before {
+ content: "\F490";
+}
+.mdi-server-remove::before {
+ content: "\F491";
+}
+.mdi-server-security::before {
+ content: "\F492";
+}
+.mdi-set-all::before {
+ content: "\F777";
+}
+.mdi-set-center::before {
+ content: "\F778";
+}
+.mdi-set-center-right::before {
+ content: "\F779";
+}
+.mdi-set-left::before {
+ content: "\F77A";
+}
+.mdi-set-left-center::before {
+ content: "\F77B";
+}
+.mdi-set-left-right::before {
+ content: "\F77C";
+}
+.mdi-set-none::before {
+ content: "\F77D";
+}
+.mdi-set-right::before {
+ content: "\F77E";
+}
+.mdi-set-top-box::before {
+ content: "\F99E";
+}
+.mdi-settings::before {
+ content: "\F493";
+}
+.mdi-settings-box::before {
+ content: "\F494";
+}
+.mdi-settings-helper::before {
+ content: "\FA6D";
+}
+.mdi-settings-outline::before {
+ content: "\F8BA";
+}
+.mdi-settings-transfer::before {
+ content: "\F007D";
+}
+.mdi-settings-transfer-outline::before {
+ content: "\F007E";
+}
+.mdi-shaker::before {
+ content: "\F0139";
+}
+.mdi-shaker-outline::before {
+ content: "\F013A";
+}
+.mdi-shape::before {
+ content: "\F830";
+}
+.mdi-shape-circle-plus::before {
+ content: "\F65D";
+}
+.mdi-shape-outline::before {
+ content: "\F831";
+}
+.mdi-shape-oval-plus::before {
+ content: "\F0225";
+}
+.mdi-shape-plus::before {
+ content: "\F495";
+}
+.mdi-shape-polygon-plus::before {
+ content: "\F65E";
+}
+.mdi-shape-rectangle-plus::before {
+ content: "\F65F";
+}
+.mdi-shape-square-plus::before {
+ content: "\F660";
+}
+.mdi-share::before {
+ content: "\F496";
+}
+.mdi-share-all::before {
+ content: "\F021F";
+}
+.mdi-share-all-outline::before {
+ content: "\F0220";
+}
+.mdi-share-circle::before {
+ content: "\F01D8";
+}
+.mdi-share-off::before {
+ content: "\FF40";
+}
+.mdi-share-off-outline::before {
+ content: "\FF41";
+}
+.mdi-share-outline::before {
+ content: "\F931";
+}
+.mdi-share-variant::before {
+ content: "\F497";
+}
+.mdi-sheep::before {
+ content: "\FCA2";
+}
+.mdi-shield::before {
+ content: "\F498";
+}
+.mdi-shield-account::before {
+ content: "\F88E";
+}
+.mdi-shield-account-outline::before {
+ content: "\FA11";
+}
+.mdi-shield-airplane::before {
+ content: "\F6BA";
+}
+.mdi-shield-airplane-outline::before {
+ content: "\FCA3";
+}
+.mdi-shield-alert::before {
+ content: "\FEE9";
+}
+.mdi-shield-alert-outline::before {
+ content: "\FEEA";
+}
+.mdi-shield-car::before {
+ content: "\FFA0";
+}
+.mdi-shield-check::before {
+ content: "\F565";
+}
+.mdi-shield-check-outline::before {
+ content: "\FCA4";
+}
+.mdi-shield-cross::before {
+ content: "\FCA5";
+}
+.mdi-shield-cross-outline::before {
+ content: "\FCA6";
+}
+.mdi-shield-edit::before {
+ content: "\F01CB";
+}
+.mdi-shield-edit-outline::before {
+ content: "\F01CC";
+}
+.mdi-shield-half::before {
+ content: "\F038B";
+}
+.mdi-shield-half-full::before {
+ content: "\F77F";
+}
+.mdi-shield-home::before {
+ content: "\F689";
+}
+.mdi-shield-home-outline::before {
+ content: "\FCA7";
+}
+.mdi-shield-key::before {
+ content: "\FBA0";
+}
+.mdi-shield-key-outline::before {
+ content: "\FBA1";
+}
+.mdi-shield-link-variant::before {
+ content: "\FD0F";
+}
+.mdi-shield-link-variant-outline::before {
+ content: "\FD10";
+}
+.mdi-shield-lock::before {
+ content: "\F99C";
+}
+.mdi-shield-lock-outline::before {
+ content: "\FCA8";
+}
+.mdi-shield-off::before {
+ content: "\F99D";
+}
+.mdi-shield-off-outline::before {
+ content: "\F99B";
+}
+.mdi-shield-outline::before {
+ content: "\F499";
+}
+.mdi-shield-plus::before {
+ content: "\FAD9";
+}
+.mdi-shield-plus-outline::before {
+ content: "\FADA";
+}
+.mdi-shield-refresh::before {
+ content: "\F01CD";
+}
+.mdi-shield-refresh-outline::before {
+ content: "\F01CE";
+}
+.mdi-shield-remove::before {
+ content: "\FADB";
+}
+.mdi-shield-remove-outline::before {
+ content: "\FADC";
+}
+.mdi-shield-search::before {
+ content: "\FD76";
+}
+.mdi-shield-star::before {
+ content: "\F0166";
+}
+.mdi-shield-star-outline::before {
+ content: "\F0167";
+}
+.mdi-shield-sun::before {
+ content: "\F007F";
+}
+.mdi-shield-sun-outline::before {
+ content: "\F0080";
+}
+.mdi-ship-wheel::before {
+ content: "\F832";
+}
+.mdi-shoe-formal::before {
+ content: "\FB22";
+}
+.mdi-shoe-heel::before {
+ content: "\FB23";
+}
+.mdi-shoe-print::before {
+ content: "\FE5A";
+}
+.mdi-shopify::before {
+ content: "\FADD";
+}
+.mdi-shopping::before {
+ content: "\F49A";
+}
+.mdi-shopping-music::before {
+ content: "\F49B";
+}
+.mdi-shopping-outline::before {
+ content: "\F0200";
+}
+.mdi-shopping-search::before {
+ content: "\FFA1";
+}
+.mdi-shovel::before {
+ content: "\F70F";
+}
+.mdi-shovel-off::before {
+ content: "\F710";
+}
+.mdi-shower::before {
+ content: "\F99F";
+}
+.mdi-shower-head::before {
+ content: "\F9A0";
+}
+.mdi-shredder::before {
+ content: "\F49C";
+}
+.mdi-shuffle::before {
+ content: "\F49D";
+}
+.mdi-shuffle-disabled::before {
+ content: "\F49E";
+}
+.mdi-shuffle-variant::before {
+ content: "\F49F";
+}
+.mdi-shuriken::before {
+ content: "\F03AA";
+}
+.mdi-sigma::before {
+ content: "\F4A0";
+}
+.mdi-sigma-lower::before {
+ content: "\F62B";
+}
+.mdi-sign-caution::before {
+ content: "\F4A1";
+}
+.mdi-sign-direction::before {
+ content: "\F780";
+}
+.mdi-sign-direction-minus::before {
+ content: "\F0022";
+}
+.mdi-sign-direction-plus::before {
+ content: "\FFFD";
+}
+.mdi-sign-direction-remove::before {
+ content: "\FFFE";
+}
+.mdi-sign-real-estate::before {
+ content: "\F0143";
+}
+.mdi-sign-text::before {
+ content: "\F781";
+}
+.mdi-signal::before {
+ content: "\F4A2";
+}
+.mdi-signal-2g::before {
+ content: "\F711";
+}
+.mdi-signal-3g::before {
+ content: "\F712";
+}
+.mdi-signal-4g::before {
+ content: "\F713";
+}
+.mdi-signal-5g::before {
+ content: "\FA6E";
+}
+.mdi-signal-cellular-1::before {
+ content: "\F8BB";
+}
+.mdi-signal-cellular-2::before {
+ content: "\F8BC";
+}
+.mdi-signal-cellular-3::before {
+ content: "\F8BD";
+}
+.mdi-signal-cellular-outline::before {
+ content: "\F8BE";
+}
+.mdi-signal-distance-variant::before {
+ content: "\FE47";
+}
+.mdi-signal-hspa::before {
+ content: "\F714";
+}
+.mdi-signal-hspa-plus::before {
+ content: "\F715";
+}
+.mdi-signal-off::before {
+ content: "\F782";
+}
+.mdi-signal-variant::before {
+ content: "\F60A";
+}
+.mdi-signature::before {
+ content: "\FE5B";
+}
+.mdi-signature-freehand::before {
+ content: "\FE5C";
+}
+.mdi-signature-image::before {
+ content: "\FE5D";
+}
+.mdi-signature-text::before {
+ content: "\FE5E";
+}
+.mdi-silo::before {
+ content: "\FB24";
+}
+.mdi-silverware::before {
+ content: "\F4A3";
+}
+.mdi-silverware-clean::before {
+ content: "\FFFF";
+}
+.mdi-silverware-fork::before {
+ content: "\F4A4";
+}
+.mdi-silverware-fork-knife::before {
+ content: "\FA6F";
+}
+.mdi-silverware-spoon::before {
+ content: "\F4A5";
+}
+.mdi-silverware-variant::before {
+ content: "\F4A6";
+}
+.mdi-sim::before {
+ content: "\F4A7";
+}
+.mdi-sim-alert::before {
+ content: "\F4A8";
+}
+.mdi-sim-off::before {
+ content: "\F4A9";
+}
+.mdi-simple-icons::before {
+ content: "\F0348";
+}
+.mdi-sina-weibo::before {
+ content: "\FADE";
+}
+.mdi-sitemap::before {
+ content: "\F4AA";
+}
+.mdi-skate::before {
+ content: "\FD11";
+}
+.mdi-skew-less::before {
+ content: "\FD12";
+}
+.mdi-skew-more::before {
+ content: "\FD13";
+}
+.mdi-ski::before {
+ content: "\F032F";
+}
+.mdi-ski-cross-country::before {
+ content: "\F0330";
+}
+.mdi-ski-water::before {
+ content: "\F0331";
+}
+.mdi-skip-backward::before {
+ content: "\F4AB";
+}
+.mdi-skip-backward-outline::before {
+ content: "\FF42";
+}
+.mdi-skip-forward::before {
+ content: "\F4AC";
+}
+.mdi-skip-forward-outline::before {
+ content: "\FF43";
+}
+.mdi-skip-next::before {
+ content: "\F4AD";
+}
+.mdi-skip-next-circle::before {
+ content: "\F661";
+}
+.mdi-skip-next-circle-outline::before {
+ content: "\F662";
+}
+.mdi-skip-next-outline::before {
+ content: "\FF44";
+}
+.mdi-skip-previous::before {
+ content: "\F4AE";
+}
+.mdi-skip-previous-circle::before {
+ content: "\F663";
+}
+.mdi-skip-previous-circle-outline::before {
+ content: "\F664";
+}
+.mdi-skip-previous-outline::before {
+ content: "\FF45";
+}
+.mdi-skull::before {
+ content: "\F68B";
+}
+.mdi-skull-crossbones::before {
+ content: "\FBA2";
+}
+.mdi-skull-crossbones-outline::before {
+ content: "\FBA3";
+}
+.mdi-skull-outline::before {
+ content: "\FBA4";
+}
+.mdi-skype::before {
+ content: "\F4AF";
+}
+.mdi-skype-business::before {
+ content: "\F4B0";
+}
+.mdi-slack::before {
+ content: "\F4B1";
+}
+.mdi-slackware::before {
+ content: "\F90A";
+}
+.mdi-slash-forward::before {
+ content: "\F0000";
+}
+.mdi-slash-forward-box::before {
+ content: "\F0001";
+}
+.mdi-sleep::before {
+ content: "\F4B2";
+}
+.mdi-sleep-off::before {
+ content: "\F4B3";
+}
+.mdi-slope-downhill::before {
+ content: "\FE5F";
+}
+.mdi-slope-uphill::before {
+ content: "\FE60";
+}
+.mdi-slot-machine::before {
+ content: "\F013F";
+}
+.mdi-slot-machine-outline::before {
+ content: "\F0140";
+}
+.mdi-smart-card::before {
+ content: "\F00E8";
+}
+.mdi-smart-card-outline::before {
+ content: "\F00E9";
+}
+.mdi-smart-card-reader::before {
+ content: "\F00EA";
+}
+.mdi-smart-card-reader-outline::before {
+ content: "\F00EB";
+}
+.mdi-smog::before {
+ content: "\FA70";
+}
+.mdi-smoke-detector::before {
+ content: "\F392";
+}
+.mdi-smoking::before {
+ content: "\F4B4";
+}
+.mdi-smoking-off::before {
+ content: "\F4B5";
+}
+.mdi-snapchat::before {
+ content: "\F4B6";
+}
+.mdi-snowboard::before {
+ content: "\F0332";
+}
+.mdi-snowflake::before {
+ content: "\F716";
+}
+.mdi-snowflake-alert::before {
+ content: "\FF46";
+}
+.mdi-snowflake-melt::before {
+ content: "\F02F6";
+}
+.mdi-snowflake-variant::before {
+ content: "\FF47";
+}
+.mdi-snowman::before {
+ content: "\F4B7";
+}
+.mdi-soccer::before {
+ content: "\F4B8";
+}
+.mdi-soccer-field::before {
+ content: "\F833";
+}
+.mdi-sofa::before {
+ content: "\F4B9";
+}
+.mdi-solar-panel::before {
+ content: "\FD77";
+}
+.mdi-solar-panel-large::before {
+ content: "\FD78";
+}
+.mdi-solar-power::before {
+ content: "\FA71";
+}
+.mdi-soldering-iron::before {
+ content: "\F00BD";
+}
+.mdi-solid::before {
+ content: "\F68C";
+}
+.mdi-sort::before {
+ content: "\F4BA";
+}
+.mdi-sort-alphabetical::before {
+ content: "\F4BB";
+}
+.mdi-sort-alphabetical-ascending::before {
+ content: "\F0173";
+}
+.mdi-sort-alphabetical-descending::before {
+ content: "\F0174";
+}
+.mdi-sort-ascending::before {
+ content: "\F4BC";
+}
+.mdi-sort-descending::before {
+ content: "\F4BD";
+}
+.mdi-sort-numeric::before {
+ content: "\F4BE";
+}
+.mdi-sort-variant::before {
+ content: "\F4BF";
+}
+.mdi-sort-variant-lock::before {
+ content: "\FCA9";
+}
+.mdi-sort-variant-lock-open::before {
+ content: "\FCAA";
+}
+.mdi-sort-variant-remove::before {
+ content: "\F0172";
+}
+.mdi-soundcloud::before {
+ content: "\F4C0";
+}
+.mdi-source-branch::before {
+ content: "\F62C";
+}
+.mdi-source-commit::before {
+ content: "\F717";
+}
+.mdi-source-commit-end::before {
+ content: "\F718";
+}
+.mdi-source-commit-end-local::before {
+ content: "\F719";
+}
+.mdi-source-commit-local::before {
+ content: "\F71A";
+}
+.mdi-source-commit-next-local::before {
+ content: "\F71B";
+}
+.mdi-source-commit-start::before {
+ content: "\F71C";
+}
+.mdi-source-commit-start-next-local::before {
+ content: "\F71D";
+}
+.mdi-source-fork::before {
+ content: "\F4C1";
+}
+.mdi-source-merge::before {
+ content: "\F62D";
+}
+.mdi-source-pull::before {
+ content: "\F4C2";
+}
+.mdi-source-repository::before {
+ content: "\FCAB";
+}
+.mdi-source-repository-multiple::before {
+ content: "\FCAC";
+}
+.mdi-soy-sauce::before {
+ content: "\F7ED";
+}
+.mdi-spa::before {
+ content: "\FCAD";
+}
+.mdi-spa-outline::before {
+ content: "\FCAE";
+}
+.mdi-space-invaders::before {
+ content: "\FBA5";
+}
+.mdi-space-station::before {
+ content: "\F03AE";
+}
+.mdi-spade::before {
+ content: "\FE48";
+}
+.mdi-speaker::before {
+ content: "\F4C3";
+}
+.mdi-speaker-bluetooth::before {
+ content: "\F9A1";
+}
+.mdi-speaker-multiple::before {
+ content: "\FD14";
+}
+.mdi-speaker-off::before {
+ content: "\F4C4";
+}
+.mdi-speaker-wireless::before {
+ content: "\F71E";
+}
+.mdi-speedometer::before {
+ content: "\F4C5";
+}
+.mdi-speedometer-medium::before {
+ content: "\FFA2";
+}
+.mdi-speedometer-slow::before {
+ content: "\FFA3";
+}
+.mdi-spellcheck::before {
+ content: "\F4C6";
+}
+.mdi-spider::before {
+ content: "\F0215";
+}
+.mdi-spider-thread::before {
+ content: "\F0216";
+}
+.mdi-spider-web::before {
+ content: "\FBA6";
+}
+.mdi-spotify::before {
+ content: "\F4C7";
+}
+.mdi-spotlight::before {
+ content: "\F4C8";
+}
+.mdi-spotlight-beam::before {
+ content: "\F4C9";
+}
+.mdi-spray::before {
+ content: "\F665";
+}
+.mdi-spray-bottle::before {
+ content: "\FADF";
+}
+.mdi-sprinkler::before {
+ content: "\F0081";
+}
+.mdi-sprinkler-variant::before {
+ content: "\F0082";
+}
+.mdi-sprout::before {
+ content: "\FE49";
+}
+.mdi-sprout-outline::before {
+ content: "\FE4A";
+}
+.mdi-square::before {
+ content: "\F763";
+}
+.mdi-square-edit-outline::before {
+ content: "\F90B";
+}
+.mdi-square-inc::before {
+ content: "\F4CA";
+}
+.mdi-square-inc-cash::before {
+ content: "\F4CB";
+}
+.mdi-square-medium::before {
+ content: "\FA12";
+}
+.mdi-square-medium-outline::before {
+ content: "\FA13";
+}
+.mdi-square-off::before {
+ content: "\F0319";
+}
+.mdi-square-off-outline::before {
+ content: "\F031A";
+}
+.mdi-square-outline::before {
+ content: "\F762";
+}
+.mdi-square-root::before {
+ content: "\F783";
+}
+.mdi-square-root-box::before {
+ content: "\F9A2";
+}
+.mdi-square-small::before {
+ content: "\FA14";
+}
+.mdi-squeegee::before {
+ content: "\FAE0";
+}
+.mdi-ssh::before {
+ content: "\F8BF";
+}
+.mdi-stack-exchange::before {
+ content: "\F60B";
+}
+.mdi-stack-overflow::before {
+ content: "\F4CC";
+}
+.mdi-stackpath::before {
+ content: "\F359";
+}
+.mdi-stadium::before {
+ content: "\F001A";
+}
+.mdi-stadium-variant::before {
+ content: "\F71F";
+}
+.mdi-stairs::before {
+ content: "\F4CD";
+}
+.mdi-stairs-down::before {
+ content: "\F02E9";
+}
+.mdi-stairs-up::before {
+ content: "\F02E8";
+}
+.mdi-stamper::before {
+ content: "\FD15";
+}
+.mdi-standard-definition::before {
+ content: "\F7EE";
+}
+.mdi-star::before {
+ content: "\F4CE";
+}
+.mdi-star-box::before {
+ content: "\FA72";
+}
+.mdi-star-box-multiple::before {
+ content: "\F02B1";
+}
+.mdi-star-box-multiple-outline::before {
+ content: "\F02B2";
+}
+.mdi-star-box-outline::before {
+ content: "\FA73";
+}
+.mdi-star-circle::before {
+ content: "\F4CF";
+}
+.mdi-star-circle-outline::before {
+ content: "\F9A3";
+}
+.mdi-star-face::before {
+ content: "\F9A4";
+}
+.mdi-star-four-points::before {
+ content: "\FAE1";
+}
+.mdi-star-four-points-outline::before {
+ content: "\FAE2";
+}
+.mdi-star-half::before {
+ content: "\F4D0";
+}
+.mdi-star-off::before {
+ content: "\F4D1";
+}
+.mdi-star-outline::before {
+ content: "\F4D2";
+}
+.mdi-star-three-points::before {
+ content: "\FAE3";
+}
+.mdi-star-three-points-outline::before {
+ content: "\FAE4";
+}
+.mdi-state-machine::before {
+ content: "\F021A";
+}
+.mdi-steam::before {
+ content: "\F4D3";
+}
+.mdi-steam-box::before {
+ content: "\F90C";
+}
+.mdi-steering::before {
+ content: "\F4D4";
+}
+.mdi-steering-off::before {
+ content: "\F90D";
+}
+.mdi-step-backward::before {
+ content: "\F4D5";
+}
+.mdi-step-backward-2::before {
+ content: "\F4D6";
+}
+.mdi-step-forward::before {
+ content: "\F4D7";
+}
+.mdi-step-forward-2::before {
+ content: "\F4D8";
+}
+.mdi-stethoscope::before {
+ content: "\F4D9";
+}
+.mdi-sticker::before {
+ content: "\F038F";
+}
+.mdi-sticker-alert::before {
+ content: "\F0390";
+}
+.mdi-sticker-alert-outline::before {
+ content: "\F0391";
+}
+.mdi-sticker-check::before {
+ content: "\F0392";
+}
+.mdi-sticker-check-outline::before {
+ content: "\F0393";
+}
+.mdi-sticker-circle-outline::before {
+ content: "\F5D0";
+}
+.mdi-sticker-emoji::before {
+ content: "\F784";
+}
+.mdi-sticker-minus::before {
+ content: "\F0394";
+}
+.mdi-sticker-minus-outline::before {
+ content: "\F0395";
+}
+.mdi-sticker-outline::before {
+ content: "\F0396";
+}
+.mdi-sticker-plus::before {
+ content: "\F0397";
+}
+.mdi-sticker-plus-outline::before {
+ content: "\F0398";
+}
+.mdi-sticker-remove::before {
+ content: "\F0399";
+}
+.mdi-sticker-remove-outline::before {
+ content: "\F039A";
+}
+.mdi-stocking::before {
+ content: "\F4DA";
+}
+.mdi-stomach::before {
+ content: "\F00BE";
+}
+.mdi-stop::before {
+ content: "\F4DB";
+}
+.mdi-stop-circle::before {
+ content: "\F666";
+}
+.mdi-stop-circle-outline::before {
+ content: "\F667";
+}
+.mdi-store::before {
+ content: "\F4DC";
+}
+.mdi-store-24-hour::before {
+ content: "\F4DD";
+}
+.mdi-store-outline::before {
+ content: "\F038C";
+}
+.mdi-storefront::before {
+ content: "\F00EC";
+}
+.mdi-stove::before {
+ content: "\F4DE";
+}
+.mdi-strategy::before {
+ content: "\F0201";
+}
+.mdi-strava::before {
+ content: "\FB25";
+}
+.mdi-stretch-to-page::before {
+ content: "\FF48";
+}
+.mdi-stretch-to-page-outline::before {
+ content: "\FF49";
+}
+.mdi-string-lights::before {
+ content: "\F02E5";
+}
+.mdi-string-lights-off::before {
+ content: "\F02E6";
+}
+.mdi-subdirectory-arrow-left::before {
+ content: "\F60C";
+}
+.mdi-subdirectory-arrow-right::before {
+ content: "\F60D";
+}
+.mdi-subtitles::before {
+ content: "\FA15";
+}
+.mdi-subtitles-outline::before {
+ content: "\FA16";
+}
+.mdi-subway::before {
+ content: "\F6AB";
+}
+.mdi-subway-alert-variant::before {
+ content: "\FD79";
+}
+.mdi-subway-variant::before {
+ content: "\F4DF";
+}
+.mdi-summit::before {
+ content: "\F785";
+}
+.mdi-sunglasses::before {
+ content: "\F4E0";
+}
+.mdi-surround-sound::before {
+ content: "\F5C5";
+}
+.mdi-surround-sound-2-0::before {
+ content: "\F7EF";
+}
+.mdi-surround-sound-3-1::before {
+ content: "\F7F0";
+}
+.mdi-surround-sound-5-1::before {
+ content: "\F7F1";
+}
+.mdi-surround-sound-7-1::before {
+ content: "\F7F2";
+}
+.mdi-svg::before {
+ content: "\F720";
+}
+.mdi-swap-horizontal::before {
+ content: "\F4E1";
+}
+.mdi-swap-horizontal-bold::before {
+ content: "\FBA9";
+}
+.mdi-swap-horizontal-circle::before {
+ content: "\F0002";
+}
+.mdi-swap-horizontal-circle-outline::before {
+ content: "\F0003";
+}
+.mdi-swap-horizontal-variant::before {
+ content: "\F8C0";
+}
+.mdi-swap-vertical::before {
+ content: "\F4E2";
+}
+.mdi-swap-vertical-bold::before {
+ content: "\FBAA";
+}
+.mdi-swap-vertical-circle::before {
+ content: "\F0004";
+}
+.mdi-swap-vertical-circle-outline::before {
+ content: "\F0005";
+}
+.mdi-swap-vertical-variant::before {
+ content: "\F8C1";
+}
+.mdi-swim::before {
+ content: "\F4E3";
+}
+.mdi-switch::before {
+ content: "\F4E4";
+}
+.mdi-sword::before {
+ content: "\F4E5";
+}
+.mdi-sword-cross::before {
+ content: "\F786";
+}
+.mdi-syllabary-hangul::before {
+ content: "\F035E";
+}
+.mdi-syllabary-hiragana::before {
+ content: "\F035F";
+}
+.mdi-syllabary-katakana::before {
+ content: "\F0360";
+}
+.mdi-syllabary-katakana-half-width::before {
+ content: "\F0361";
+}
+.mdi-symfony::before {
+ content: "\FAE5";
+}
+.mdi-sync::before {
+ content: "\F4E6";
+}
+.mdi-sync-alert::before {
+ content: "\F4E7";
+}
+.mdi-sync-circle::before {
+ content: "\F03A3";
+}
+.mdi-sync-off::before {
+ content: "\F4E8";
+}
+.mdi-tab::before {
+ content: "\F4E9";
+}
+.mdi-tab-minus::before {
+ content: "\FB26";
+}
+.mdi-tab-plus::before {
+ content: "\F75B";
+}
+.mdi-tab-remove::before {
+ content: "\FB27";
+}
+.mdi-tab-unselected::before {
+ content: "\F4EA";
+}
+.mdi-table::before {
+ content: "\F4EB";
+}
+.mdi-table-border::before {
+ content: "\FA17";
+}
+.mdi-table-chair::before {
+ content: "\F0083";
+}
+.mdi-table-column::before {
+ content: "\F834";
+}
+.mdi-table-column-plus-after::before {
+ content: "\F4EC";
+}
+.mdi-table-column-plus-before::before {
+ content: "\F4ED";
+}
+.mdi-table-column-remove::before {
+ content: "\F4EE";
+}
+.mdi-table-column-width::before {
+ content: "\F4EF";
+}
+.mdi-table-edit::before {
+ content: "\F4F0";
+}
+.mdi-table-eye::before {
+ content: "\F00BF";
+}
+.mdi-table-headers-eye::before {
+ content: "\F0248";
+}
+.mdi-table-headers-eye-off::before {
+ content: "\F0249";
+}
+.mdi-table-large::before {
+ content: "\F4F1";
+}
+.mdi-table-large-plus::before {
+ content: "\FFA4";
+}
+.mdi-table-large-remove::before {
+ content: "\FFA5";
+}
+.mdi-table-merge-cells::before {
+ content: "\F9A5";
+}
+.mdi-table-of-contents::before {
+ content: "\F835";
+}
+.mdi-table-plus::before {
+ content: "\FA74";
+}
+.mdi-table-remove::before {
+ content: "\FA75";
+}
+.mdi-table-row::before {
+ content: "\F836";
+}
+.mdi-table-row-height::before {
+ content: "\F4F2";
+}
+.mdi-table-row-plus-after::before {
+ content: "\F4F3";
+}
+.mdi-table-row-plus-before::before {
+ content: "\F4F4";
+}
+.mdi-table-row-remove::before {
+ content: "\F4F5";
+}
+.mdi-table-search::before {
+ content: "\F90E";
+}
+.mdi-table-settings::before {
+ content: "\F837";
+}
+.mdi-table-tennis::before {
+ content: "\FE4B";
+}
+.mdi-tablet::before {
+ content: "\F4F6";
+}
+.mdi-tablet-android::before {
+ content: "\F4F7";
+}
+.mdi-tablet-cellphone::before {
+ content: "\F9A6";
+}
+.mdi-tablet-dashboard::before {
+ content: "\FEEB";
+}
+.mdi-tablet-ipad::before {
+ content: "\F4F8";
+}
+.mdi-taco::before {
+ content: "\F761";
+}
+.mdi-tag::before {
+ content: "\F4F9";
+}
+.mdi-tag-faces::before {
+ content: "\F4FA";
+}
+.mdi-tag-heart::before {
+ content: "\F68A";
+}
+.mdi-tag-heart-outline::before {
+ content: "\FBAB";
+}
+.mdi-tag-minus::before {
+ content: "\F90F";
+}
+.mdi-tag-minus-outline::before {
+ content: "\F024A";
+}
+.mdi-tag-multiple::before {
+ content: "\F4FB";
+}
+.mdi-tag-multiple-outline::before {
+ content: "\F0322";
+}
+.mdi-tag-off::before {
+ content: "\F024B";
+}
+.mdi-tag-off-outline::before {
+ content: "\F024C";
+}
+.mdi-tag-outline::before {
+ content: "\F4FC";
+}
+.mdi-tag-plus::before {
+ content: "\F721";
+}
+.mdi-tag-plus-outline::before {
+ content: "\F024D";
+}
+.mdi-tag-remove::before {
+ content: "\F722";
+}
+.mdi-tag-remove-outline::before {
+ content: "\F024E";
+}
+.mdi-tag-text::before {
+ content: "\F024F";
+}
+.mdi-tag-text-outline::before {
+ content: "\F4FD";
+}
+.mdi-tank::before {
+ content: "\FD16";
+}
+.mdi-tanker-truck::before {
+ content: "\F0006";
+}
+.mdi-tape-measure::before {
+ content: "\FB28";
+}
+.mdi-target::before {
+ content: "\F4FE";
+}
+.mdi-target-account::before {
+ content: "\FBAC";
+}
+.mdi-target-variant::before {
+ content: "\FA76";
+}
+.mdi-taxi::before {
+ content: "\F4FF";
+}
+.mdi-tea::before {
+ content: "\FD7A";
+}
+.mdi-tea-outline::before {
+ content: "\FD7B";
+}
+.mdi-teach::before {
+ content: "\F88F";
+}
+.mdi-teamviewer::before {
+ content: "\F500";
+}
+.mdi-telegram::before {
+ content: "\F501";
+}
+.mdi-telescope::before {
+ content: "\FB29";
+}
+.mdi-television::before {
+ content: "\F502";
+}
+.mdi-television-ambient-light::before {
+ content: "\F0381";
+}
+.mdi-television-box::before {
+ content: "\F838";
+}
+.mdi-television-classic::before {
+ content: "\F7F3";
+}
+.mdi-television-classic-off::before {
+ content: "\F839";
+}
+.mdi-television-clean::before {
+ content: "\F013B";
+}
+.mdi-television-guide::before {
+ content: "\F503";
+}
+.mdi-television-off::before {
+ content: "\F83A";
+}
+.mdi-television-pause::before {
+ content: "\FFA6";
+}
+.mdi-television-play::before {
+ content: "\FEEC";
+}
+.mdi-television-stop::before {
+ content: "\FFA7";
+}
+.mdi-temperature-celsius::before {
+ content: "\F504";
+}
+.mdi-temperature-fahrenheit::before {
+ content: "\F505";
+}
+.mdi-temperature-kelvin::before {
+ content: "\F506";
+}
+.mdi-tennis::before {
+ content: "\FD7C";
+}
+.mdi-tennis-ball::before {
+ content: "\F507";
+}
+.mdi-tent::before {
+ content: "\F508";
+}
+.mdi-terraform::before {
+ content: "\F0084";
+}
+.mdi-terrain::before {
+ content: "\F509";
+}
+.mdi-test-tube::before {
+ content: "\F668";
+}
+.mdi-test-tube-empty::before {
+ content: "\F910";
+}
+.mdi-test-tube-off::before {
+ content: "\F911";
+}
+.mdi-text::before {
+ content: "\F9A7";
+}
+.mdi-text-recognition::before {
+ content: "\F0168";
+}
+.mdi-text-shadow::before {
+ content: "\F669";
+}
+.mdi-text-short::before {
+ content: "\F9A8";
+}
+.mdi-text-subject::before {
+ content: "\F9A9";
+}
+.mdi-text-to-speech::before {
+ content: "\F50A";
+}
+.mdi-text-to-speech-off::before {
+ content: "\F50B";
+}
+.mdi-textarea::before {
+ content: "\F00C0";
+}
+.mdi-textbox::before {
+ content: "\F60E";
+}
+.mdi-textbox-lock::before {
+ content: "\F0388";
+}
+.mdi-textbox-password::before {
+ content: "\F7F4";
+}
+.mdi-texture::before {
+ content: "\F50C";
+}
+.mdi-texture-box::before {
+ content: "\F0007";
+}
+.mdi-theater::before {
+ content: "\F50D";
+}
+.mdi-theme-light-dark::before {
+ content: "\F50E";
+}
+.mdi-thermometer::before {
+ content: "\F50F";
+}
+.mdi-thermometer-alert::before {
+ content: "\FE61";
+}
+.mdi-thermometer-chevron-down::before {
+ content: "\FE62";
+}
+.mdi-thermometer-chevron-up::before {
+ content: "\FE63";
+}
+.mdi-thermometer-high::before {
+ content: "\F00ED";
+}
+.mdi-thermometer-lines::before {
+ content: "\F510";
+}
+.mdi-thermometer-low::before {
+ content: "\F00EE";
+}
+.mdi-thermometer-minus::before {
+ content: "\FE64";
+}
+.mdi-thermometer-plus::before {
+ content: "\FE65";
+}
+.mdi-thermostat::before {
+ content: "\F393";
+}
+.mdi-thermostat-box::before {
+ content: "\F890";
+}
+.mdi-thought-bubble::before {
+ content: "\F7F5";
+}
+.mdi-thought-bubble-outline::before {
+ content: "\F7F6";
+}
+.mdi-thumb-down::before {
+ content: "\F511";
+}
+.mdi-thumb-down-outline::before {
+ content: "\F512";
+}
+.mdi-thumb-up::before {
+ content: "\F513";
+}
+.mdi-thumb-up-outline::before {
+ content: "\F514";
+}
+.mdi-thumbs-up-down::before {
+ content: "\F515";
+}
+.mdi-ticket::before {
+ content: "\F516";
+}
+.mdi-ticket-account::before {
+ content: "\F517";
+}
+.mdi-ticket-confirmation::before {
+ content: "\F518";
+}
+.mdi-ticket-outline::before {
+ content: "\F912";
+}
+.mdi-ticket-percent::before {
+ content: "\F723";
+}
+.mdi-tie::before {
+ content: "\F519";
+}
+.mdi-tilde::before {
+ content: "\F724";
+}
+.mdi-timelapse::before {
+ content: "\F51A";
+}
+.mdi-timeline::before {
+ content: "\FBAD";
+}
+.mdi-timeline-alert::before {
+ content: "\FFB2";
+}
+.mdi-timeline-alert-outline::before {
+ content: "\FFB5";
+}
+.mdi-timeline-clock::before {
+ content: "\F0226";
+}
+.mdi-timeline-clock-outline::before {
+ content: "\F0227";
+}
+.mdi-timeline-help::before {
+ content: "\FFB6";
+}
+.mdi-timeline-help-outline::before {
+ content: "\FFB7";
+}
+.mdi-timeline-outline::before {
+ content: "\FBAE";
+}
+.mdi-timeline-plus::before {
+ content: "\FFB3";
+}
+.mdi-timeline-plus-outline::before {
+ content: "\FFB4";
+}
+.mdi-timeline-text::before {
+ content: "\FBAF";
+}
+.mdi-timeline-text-outline::before {
+ content: "\FBB0";
+}
+.mdi-timer::before {
+ content: "\F51B";
+}
+.mdi-timer-10::before {
+ content: "\F51C";
+}
+.mdi-timer-3::before {
+ content: "\F51D";
+}
+.mdi-timer-off::before {
+ content: "\F51E";
+}
+.mdi-timer-sand::before {
+ content: "\F51F";
+}
+.mdi-timer-sand-empty::before {
+ content: "\F6AC";
+}
+.mdi-timer-sand-full::before {
+ content: "\F78B";
+}
+.mdi-timetable::before {
+ content: "\F520";
+}
+.mdi-toaster::before {
+ content: "\F0085";
+}
+.mdi-toaster-off::before {
+ content: "\F01E2";
+}
+.mdi-toaster-oven::before {
+ content: "\FCAF";
+}
+.mdi-toggle-switch::before {
+ content: "\F521";
+}
+.mdi-toggle-switch-off::before {
+ content: "\F522";
+}
+.mdi-toggle-switch-off-outline::before {
+ content: "\FA18";
+}
+.mdi-toggle-switch-outline::before {
+ content: "\FA19";
+}
+.mdi-toilet::before {
+ content: "\F9AA";
+}
+.mdi-toolbox::before {
+ content: "\F9AB";
+}
+.mdi-toolbox-outline::before {
+ content: "\F9AC";
+}
+.mdi-tools::before {
+ content: "\F0086";
+}
+.mdi-tooltip::before {
+ content: "\F523";
+}
+.mdi-tooltip-account::before {
+ content: "\F00C";
+}
+.mdi-tooltip-edit::before {
+ content: "\F524";
+}
+.mdi-tooltip-edit-outline::before {
+ content: "\F02F0";
+}
+.mdi-tooltip-image::before {
+ content: "\F525";
+}
+.mdi-tooltip-image-outline::before {
+ content: "\FBB1";
+}
+.mdi-tooltip-outline::before {
+ content: "\F526";
+}
+.mdi-tooltip-plus::before {
+ content: "\FBB2";
+}
+.mdi-tooltip-plus-outline::before {
+ content: "\F527";
+}
+.mdi-tooltip-text::before {
+ content: "\F528";
+}
+.mdi-tooltip-text-outline::before {
+ content: "\FBB3";
+}
+.mdi-tooth::before {
+ content: "\F8C2";
+}
+.mdi-tooth-outline::before {
+ content: "\F529";
+}
+.mdi-toothbrush::before {
+ content: "\F0154";
+}
+.mdi-toothbrush-electric::before {
+ content: "\F0157";
+}
+.mdi-toothbrush-paste::before {
+ content: "\F0155";
+}
+.mdi-tor::before {
+ content: "\F52A";
+}
+.mdi-tortoise::before {
+ content: "\FD17";
+}
+.mdi-toslink::before {
+ content: "\F02E3";
+}
+.mdi-tournament::before {
+ content: "\F9AD";
+}
+.mdi-tower-beach::before {
+ content: "\F680";
+}
+.mdi-tower-fire::before {
+ content: "\F681";
+}
+.mdi-towing::before {
+ content: "\F83B";
+}
+.mdi-toy-brick::before {
+ content: "\F02B3";
+}
+.mdi-toy-brick-marker::before {
+ content: "\F02B4";
+}
+.mdi-toy-brick-marker-outline::before {
+ content: "\F02B5";
+}
+.mdi-toy-brick-minus::before {
+ content: "\F02B6";
+}
+.mdi-toy-brick-minus-outline::before {
+ content: "\F02B7";
+}
+.mdi-toy-brick-outline::before {
+ content: "\F02B8";
+}
+.mdi-toy-brick-plus::before {
+ content: "\F02B9";
+}
+.mdi-toy-brick-plus-outline::before {
+ content: "\F02BA";
+}
+.mdi-toy-brick-remove::before {
+ content: "\F02BB";
+}
+.mdi-toy-brick-remove-outline::before {
+ content: "\F02BC";
+}
+.mdi-toy-brick-search::before {
+ content: "\F02BD";
+}
+.mdi-toy-brick-search-outline::before {
+ content: "\F02BE";
+}
+.mdi-track-light::before {
+ content: "\F913";
+}
+.mdi-trackpad::before {
+ content: "\F7F7";
+}
+.mdi-trackpad-lock::before {
+ content: "\F932";
+}
+.mdi-tractor::before {
+ content: "\F891";
+}
+.mdi-trademark::before {
+ content: "\FA77";
+}
+.mdi-traffic-cone::before {
+ content: "\F03A7";
+}
+.mdi-traffic-light::before {
+ content: "\F52B";
+}
+.mdi-train::before {
+ content: "\F52C";
+}
+.mdi-train-car::before {
+ content: "\FBB4";
+}
+.mdi-train-variant::before {
+ content: "\F8C3";
+}
+.mdi-tram::before {
+ content: "\F52D";
+}
+.mdi-tram-side::before {
+ content: "\F0008";
+}
+.mdi-transcribe::before {
+ content: "\F52E";
+}
+.mdi-transcribe-close::before {
+ content: "\F52F";
+}
+.mdi-transfer::before {
+ content: "\F0087";
+}
+.mdi-transfer-down::before {
+ content: "\FD7D";
+}
+.mdi-transfer-left::before {
+ content: "\FD7E";
+}
+.mdi-transfer-right::before {
+ content: "\F530";
+}
+.mdi-transfer-up::before {
+ content: "\FD7F";
+}
+.mdi-transit-connection::before {
+ content: "\FD18";
+}
+.mdi-transit-connection-variant::before {
+ content: "\FD19";
+}
+.mdi-transit-detour::before {
+ content: "\FFA8";
+}
+.mdi-transit-transfer::before {
+ content: "\F6AD";
+}
+.mdi-transition::before {
+ content: "\F914";
+}
+.mdi-transition-masked::before {
+ content: "\F915";
+}
+.mdi-translate::before {
+ content: "\F5CA";
+}
+.mdi-translate-off::before {
+ content: "\FE66";
+}
+.mdi-transmission-tower::before {
+ content: "\FD1A";
+}
+.mdi-trash-can::before {
+ content: "\FA78";
+}
+.mdi-trash-can-outline::before {
+ content: "\FA79";
+}
+.mdi-tray::before {
+ content: "\F02BF";
+}
+.mdi-tray-alert::before {
+ content: "\F02C0";
+}
+.mdi-tray-full::before {
+ content: "\F02C1";
+}
+.mdi-tray-minus::before {
+ content: "\F02C2";
+}
+.mdi-tray-plus::before {
+ content: "\F02C3";
+}
+.mdi-tray-remove::before {
+ content: "\F02C4";
+}
+.mdi-treasure-chest::before {
+ content: "\F725";
+}
+.mdi-tree::before {
+ content: "\F531";
+}
+.mdi-tree-outline::before {
+ content: "\FE4C";
+}
+.mdi-trello::before {
+ content: "\F532";
+}
+.mdi-trending-down::before {
+ content: "\F533";
+}
+.mdi-trending-neutral::before {
+ content: "\F534";
+}
+.mdi-trending-up::before {
+ content: "\F535";
+}
+.mdi-triangle::before {
+ content: "\F536";
+}
+.mdi-triangle-outline::before {
+ content: "\F537";
+}
+.mdi-triforce::before {
+ content: "\FBB5";
+}
+.mdi-trophy::before {
+ content: "\F538";
+}
+.mdi-trophy-award::before {
+ content: "\F539";
+}
+.mdi-trophy-broken::before {
+ content: "\FD80";
+}
+.mdi-trophy-outline::before {
+ content: "\F53A";
+}
+.mdi-trophy-variant::before {
+ content: "\F53B";
+}
+.mdi-trophy-variant-outline::before {
+ content: "\F53C";
+}
+.mdi-truck::before {
+ content: "\F53D";
+}
+.mdi-truck-check::before {
+ content: "\FCB0";
+}
+.mdi-truck-check-outline::before {
+ content: "\F02C5";
+}
+.mdi-truck-delivery::before {
+ content: "\F53E";
+}
+.mdi-truck-delivery-outline::before {
+ content: "\F02C6";
+}
+.mdi-truck-fast::before {
+ content: "\F787";
+}
+.mdi-truck-fast-outline::before {
+ content: "\F02C7";
+}
+.mdi-truck-outline::before {
+ content: "\F02C8";
+}
+.mdi-truck-trailer::before {
+ content: "\F726";
+}
+.mdi-trumpet::before {
+ content: "\F00C1";
+}
+.mdi-tshirt-crew::before {
+ content: "\FA7A";
+}
+.mdi-tshirt-crew-outline::before {
+ content: "\F53F";
+}
+.mdi-tshirt-v::before {
+ content: "\FA7B";
+}
+.mdi-tshirt-v-outline::before {
+ content: "\F540";
+}
+.mdi-tumble-dryer::before {
+ content: "\F916";
+}
+.mdi-tumble-dryer-alert::before {
+ content: "\F01E5";
+}
+.mdi-tumble-dryer-off::before {
+ content: "\F01E6";
+}
+.mdi-tumblr::before {
+ content: "\F541";
+}
+.mdi-tumblr-box::before {
+ content: "\F917";
+}
+.mdi-tumblr-reblog::before {
+ content: "\F542";
+}
+.mdi-tune::before {
+ content: "\F62E";
+}
+.mdi-tune-vertical::before {
+ content: "\F66A";
+}
+.mdi-turnstile::before {
+ content: "\FCB1";
+}
+.mdi-turnstile-outline::before {
+ content: "\FCB2";
+}
+.mdi-turtle::before {
+ content: "\FCB3";
+}
+.mdi-twitch::before {
+ content: "\F543";
+}
+.mdi-twitter::before {
+ content: "\F544";
+}
+.mdi-twitter-box::before {
+ content: "\F545";
+}
+.mdi-twitter-circle::before {
+ content: "\F546";
+}
+.mdi-twitter-retweet::before {
+ content: "\F547";
+}
+.mdi-two-factor-authentication::before {
+ content: "\F9AE";
+}
+.mdi-typewriter::before {
+ content: "\FF4A";
+}
+.mdi-uber::before {
+ content: "\F748";
+}
+.mdi-ubisoft::before {
+ content: "\FBB6";
+}
+.mdi-ubuntu::before {
+ content: "\F548";
+}
+.mdi-ufo::before {
+ content: "\F00EF";
+}
+.mdi-ufo-outline::before {
+ content: "\F00F0";
+}
+.mdi-ultra-high-definition::before {
+ content: "\F7F8";
+}
+.mdi-umbraco::before {
+ content: "\F549";
+}
+.mdi-umbrella::before {
+ content: "\F54A";
+}
+.mdi-umbrella-closed::before {
+ content: "\F9AF";
+}
+.mdi-umbrella-outline::before {
+ content: "\F54B";
+}
+.mdi-undo::before {
+ content: "\F54C";
+}
+.mdi-undo-variant::before {
+ content: "\F54D";
+}
+.mdi-unfold-less-horizontal::before {
+ content: "\F54E";
+}
+.mdi-unfold-less-vertical::before {
+ content: "\F75F";
+}
+.mdi-unfold-more-horizontal::before {
+ content: "\F54F";
+}
+.mdi-unfold-more-vertical::before {
+ content: "\F760";
+}
+.mdi-ungroup::before {
+ content: "\F550";
+}
+.mdi-unicode::before {
+ content: "\FEED";
+}
+.mdi-unity::before {
+ content: "\F6AE";
+}
+.mdi-unreal::before {
+ content: "\F9B0";
+}
+.mdi-untappd::before {
+ content: "\F551";
+}
+.mdi-update::before {
+ content: "\F6AF";
+}
+.mdi-upload::before {
+ content: "\F552";
+}
+.mdi-upload-lock::before {
+ content: "\F039E";
+}
+.mdi-upload-lock-outline::before {
+ content: "\F039F";
+}
+.mdi-upload-multiple::before {
+ content: "\F83C";
+}
+.mdi-upload-network::before {
+ content: "\F6F5";
+}
+.mdi-upload-network-outline::before {
+ content: "\FCB4";
+}
+.mdi-upload-off::before {
+ content: "\F00F1";
+}
+.mdi-upload-off-outline::before {
+ content: "\F00F2";
+}
+.mdi-upload-outline::before {
+ content: "\FE67";
+}
+.mdi-usb::before {
+ content: "\F553";
+}
+.mdi-usb-flash-drive::before {
+ content: "\F02C9";
+}
+.mdi-usb-flash-drive-outline::before {
+ content: "\F02CA";
+}
+.mdi-usb-port::before {
+ content: "\F021B";
+}
+.mdi-valve::before {
+ content: "\F0088";
+}
+.mdi-valve-closed::before {
+ content: "\F0089";
+}
+.mdi-valve-open::before {
+ content: "\F008A";
+}
+.mdi-van-passenger::before {
+ content: "\F7F9";
+}
+.mdi-van-utility::before {
+ content: "\F7FA";
+}
+.mdi-vanish::before {
+ content: "\F7FB";
+}
+.mdi-vanity-light::before {
+ content: "\F020C";
+}
+.mdi-variable::before {
+ content: "\FAE6";
+}
+.mdi-variable-box::before {
+ content: "\F013C";
+}
+.mdi-vector-arrange-above::before {
+ content: "\F554";
+}
+.mdi-vector-arrange-below::before {
+ content: "\F555";
+}
+.mdi-vector-bezier::before {
+ content: "\FAE7";
+}
+.mdi-vector-circle::before {
+ content: "\F556";
+}
+.mdi-vector-circle-variant::before {
+ content: "\F557";
+}
+.mdi-vector-combine::before {
+ content: "\F558";
+}
+.mdi-vector-curve::before {
+ content: "\F559";
+}
+.mdi-vector-difference::before {
+ content: "\F55A";
+}
+.mdi-vector-difference-ab::before {
+ content: "\F55B";
+}
+.mdi-vector-difference-ba::before {
+ content: "\F55C";
+}
+.mdi-vector-ellipse::before {
+ content: "\F892";
+}
+.mdi-vector-intersection::before {
+ content: "\F55D";
+}
+.mdi-vector-line::before {
+ content: "\F55E";
+}
+.mdi-vector-link::before {
+ content: "\F0009";
+}
+.mdi-vector-point::before {
+ content: "\F55F";
+}
+.mdi-vector-polygon::before {
+ content: "\F560";
+}
+.mdi-vector-polyline::before {
+ content: "\F561";
+}
+.mdi-vector-polyline-edit::before {
+ content: "\F0250";
+}
+.mdi-vector-polyline-minus::before {
+ content: "\F0251";
+}
+.mdi-vector-polyline-plus::before {
+ content: "\F0252";
+}
+.mdi-vector-polyline-remove::before {
+ content: "\F0253";
+}
+.mdi-vector-radius::before {
+ content: "\F749";
+}
+.mdi-vector-rectangle::before {
+ content: "\F5C6";
+}
+.mdi-vector-selection::before {
+ content: "\F562";
+}
+.mdi-vector-square::before {
+ content: "\F001";
+}
+.mdi-vector-triangle::before {
+ content: "\F563";
+}
+.mdi-vector-union::before {
+ content: "\F564";
+}
+.mdi-venmo::before {
+ content: "\F578";
+}
+.mdi-vhs::before {
+ content: "\FA1A";
+}
+.mdi-vibrate::before {
+ content: "\F566";
+}
+.mdi-vibrate-off::before {
+ content: "\FCB5";
+}
+.mdi-video::before {
+ content: "\F567";
+}
+.mdi-video-3d::before {
+ content: "\F7FC";
+}
+.mdi-video-3d-variant::before {
+ content: "\FEEE";
+}
+.mdi-video-4k-box::before {
+ content: "\F83D";
+}
+.mdi-video-account::before {
+ content: "\F918";
+}
+.mdi-video-check::before {
+ content: "\F008B";
+}
+.mdi-video-check-outline::before {
+ content: "\F008C";
+}
+.mdi-video-image::before {
+ content: "\F919";
+}
+.mdi-video-input-antenna::before {
+ content: "\F83E";
+}
+.mdi-video-input-component::before {
+ content: "\F83F";
+}
+.mdi-video-input-hdmi::before {
+ content: "\F840";
+}
+.mdi-video-input-scart::before {
+ content: "\FFA9";
+}
+.mdi-video-input-svideo::before {
+ content: "\F841";
+}
+.mdi-video-minus::before {
+ content: "\F9B1";
+}
+.mdi-video-off::before {
+ content: "\F568";
+}
+.mdi-video-off-outline::before {
+ content: "\FBB7";
+}
+.mdi-video-outline::before {
+ content: "\FBB8";
+}
+.mdi-video-plus::before {
+ content: "\F9B2";
+}
+.mdi-video-stabilization::before {
+ content: "\F91A";
+}
+.mdi-video-switch::before {
+ content: "\F569";
+}
+.mdi-video-vintage::before {
+ content: "\FA1B";
+}
+.mdi-video-wireless::before {
+ content: "\FEEF";
+}
+.mdi-video-wireless-outline::before {
+ content: "\FEF0";
+}
+.mdi-view-agenda::before {
+ content: "\F56A";
+}
+.mdi-view-agenda-outline::before {
+ content: "\F0203";
+}
+.mdi-view-array::before {
+ content: "\F56B";
+}
+.mdi-view-carousel::before {
+ content: "\F56C";
+}
+.mdi-view-column::before {
+ content: "\F56D";
+}
+.mdi-view-comfy::before {
+ content: "\FE4D";
+}
+.mdi-view-compact::before {
+ content: "\FE4E";
+}
+.mdi-view-compact-outline::before {
+ content: "\FE4F";
+}
+.mdi-view-dashboard::before {
+ content: "\F56E";
+}
+.mdi-view-dashboard-outline::before {
+ content: "\FA1C";
+}
+.mdi-view-dashboard-variant::before {
+ content: "\F842";
+}
+.mdi-view-day::before {
+ content: "\F56F";
+}
+.mdi-view-grid::before {
+ content: "\F570";
+}
+.mdi-view-grid-outline::before {
+ content: "\F0204";
+}
+.mdi-view-grid-plus::before {
+ content: "\FFAA";
+}
+.mdi-view-grid-plus-outline::before {
+ content: "\F0205";
+}
+.mdi-view-headline::before {
+ content: "\F571";
+}
+.mdi-view-list::before {
+ content: "\F572";
+}
+.mdi-view-module::before {
+ content: "\F573";
+}
+.mdi-view-parallel::before {
+ content: "\F727";
+}
+.mdi-view-quilt::before {
+ content: "\F574";
+}
+.mdi-view-sequential::before {
+ content: "\F728";
+}
+.mdi-view-split-horizontal::before {
+ content: "\FBA7";
+}
+.mdi-view-split-vertical::before {
+ content: "\FBA8";
+}
+.mdi-view-stream::before {
+ content: "\F575";
+}
+.mdi-view-week::before {
+ content: "\F576";
+}
+.mdi-vimeo::before {
+ content: "\F577";
+}
+.mdi-violin::before {
+ content: "\F60F";
+}
+.mdi-virtual-reality::before {
+ content: "\F893";
+}
+.mdi-visual-studio::before {
+ content: "\F610";
+}
+.mdi-visual-studio-code::before {
+ content: "\FA1D";
+}
+.mdi-vk::before {
+ content: "\F579";
+}
+.mdi-vk-box::before {
+ content: "\F57A";
+}
+.mdi-vk-circle::before {
+ content: "\F57B";
+}
+.mdi-vlc::before {
+ content: "\F57C";
+}
+.mdi-voice::before {
+ content: "\F5CB";
+}
+.mdi-voice-off::before {
+ content: "\FEF1";
+}
+.mdi-voicemail::before {
+ content: "\F57D";
+}
+.mdi-volleyball::before {
+ content: "\F9B3";
+}
+.mdi-volume-high::before {
+ content: "\F57E";
+}
+.mdi-volume-low::before {
+ content: "\F57F";
+}
+.mdi-volume-medium::before {
+ content: "\F580";
+}
+.mdi-volume-minus::before {
+ content: "\F75D";
+}
+.mdi-volume-mute::before {
+ content: "\F75E";
+}
+.mdi-volume-off::before {
+ content: "\F581";
+}
+.mdi-volume-plus::before {
+ content: "\F75C";
+}
+.mdi-volume-source::before {
+ content: "\F014B";
+}
+.mdi-volume-variant-off::before {
+ content: "\FE68";
+}
+.mdi-volume-vibrate::before {
+ content: "\F014C";
+}
+.mdi-vote::before {
+ content: "\FA1E";
+}
+.mdi-vote-outline::before {
+ content: "\FA1F";
+}
+.mdi-vpn::before {
+ content: "\F582";
+}
+.mdi-vuejs::before {
+ content: "\F843";
+}
+.mdi-vuetify::before {
+ content: "\FE50";
+}
+.mdi-walk::before {
+ content: "\F583";
+}
+.mdi-wall::before {
+ content: "\F7FD";
+}
+.mdi-wall-sconce::before {
+ content: "\F91B";
+}
+.mdi-wall-sconce-flat::before {
+ content: "\F91C";
+}
+.mdi-wall-sconce-variant::before {
+ content: "\F91D";
+}
+.mdi-wallet::before {
+ content: "\F584";
+}
+.mdi-wallet-giftcard::before {
+ content: "\F585";
+}
+.mdi-wallet-membership::before {
+ content: "\F586";
+}
+.mdi-wallet-outline::before {
+ content: "\FBB9";
+}
+.mdi-wallet-plus::before {
+ content: "\FFAB";
+}
+.mdi-wallet-plus-outline::before {
+ content: "\FFAC";
+}
+.mdi-wallet-travel::before {
+ content: "\F587";
+}
+.mdi-wallpaper::before {
+ content: "\FE69";
+}
+.mdi-wan::before {
+ content: "\F588";
+}
+.mdi-wardrobe::before {
+ content: "\FFAD";
+}
+.mdi-wardrobe-outline::before {
+ content: "\FFAE";
+}
+.mdi-warehouse::before {
+ content: "\FFBB";
+}
+.mdi-washing-machine::before {
+ content: "\F729";
+}
+.mdi-washing-machine-alert::before {
+ content: "\F01E7";
+}
+.mdi-washing-machine-off::before {
+ content: "\F01E8";
+}
+.mdi-watch::before {
+ content: "\F589";
+}
+.mdi-watch-export::before {
+ content: "\F58A";
+}
+.mdi-watch-export-variant::before {
+ content: "\F894";
+}
+.mdi-watch-import::before {
+ content: "\F58B";
+}
+.mdi-watch-import-variant::before {
+ content: "\F895";
+}
+.mdi-watch-variant::before {
+ content: "\F896";
+}
+.mdi-watch-vibrate::before {
+ content: "\F6B0";
+}
+.mdi-watch-vibrate-off::before {
+ content: "\FCB6";
+}
+.mdi-water::before {
+ content: "\F58C";
+}
+.mdi-water-boiler::before {
+ content: "\FFAF";
+}
+.mdi-water-boiler-alert::before {
+ content: "\F01DE";
+}
+.mdi-water-boiler-off::before {
+ content: "\F01DF";
+}
+.mdi-water-off::before {
+ content: "\F58D";
+}
+.mdi-water-outline::before {
+ content: "\FE6A";
+}
+.mdi-water-percent::before {
+ content: "\F58E";
+}
+.mdi-water-polo::before {
+ content: "\F02CB";
+}
+.mdi-water-pump::before {
+ content: "\F58F";
+}
+.mdi-water-pump-off::before {
+ content: "\FFB0";
+}
+.mdi-water-well::before {
+ content: "\F008D";
+}
+.mdi-water-well-outline::before {
+ content: "\F008E";
+}
+.mdi-watermark::before {
+ content: "\F612";
+}
+.mdi-wave::before {
+ content: "\FF4B";
+}
+.mdi-waves::before {
+ content: "\F78C";
+}
+.mdi-waze::before {
+ content: "\FBBA";
+}
+.mdi-weather-cloudy::before {
+ content: "\F590";
+}
+.mdi-weather-cloudy-alert::before {
+ content: "\FF4C";
+}
+.mdi-weather-cloudy-arrow-right::before {
+ content: "\FE51";
+}
+.mdi-weather-fog::before {
+ content: "\F591";
+}
+.mdi-weather-hail::before {
+ content: "\F592";
+}
+.mdi-weather-hazy::before {
+ content: "\FF4D";
+}
+.mdi-weather-hurricane::before {
+ content: "\F897";
+}
+.mdi-weather-lightning::before {
+ content: "\F593";
+}
+.mdi-weather-lightning-rainy::before {
+ content: "\F67D";
+}
+.mdi-weather-night::before {
+ content: "\F594";
+}
+.mdi-weather-night-partly-cloudy::before {
+ content: "\FF4E";
+}
+.mdi-weather-partly-cloudy::before {
+ content: "\F595";
+}
+.mdi-weather-partly-lightning::before {
+ content: "\FF4F";
+}
+.mdi-weather-partly-rainy::before {
+ content: "\FF50";
+}
+.mdi-weather-partly-snowy::before {
+ content: "\FF51";
+}
+.mdi-weather-partly-snowy-rainy::before {
+ content: "\FF52";
+}
+.mdi-weather-pouring::before {
+ content: "\F596";
+}
+.mdi-weather-rainy::before {
+ content: "\F597";
+}
+.mdi-weather-snowy::before {
+ content: "\F598";
+}
+.mdi-weather-snowy-heavy::before {
+ content: "\FF53";
+}
+.mdi-weather-snowy-rainy::before {
+ content: "\F67E";
+}
+.mdi-weather-sunny::before {
+ content: "\F599";
+}
+.mdi-weather-sunny-alert::before {
+ content: "\FF54";
+}
+.mdi-weather-sunset::before {
+ content: "\F59A";
+}
+.mdi-weather-sunset-down::before {
+ content: "\F59B";
+}
+.mdi-weather-sunset-up::before {
+ content: "\F59C";
+}
+.mdi-weather-tornado::before {
+ content: "\FF55";
+}
+.mdi-weather-windy::before {
+ content: "\F59D";
+}
+.mdi-weather-windy-variant::before {
+ content: "\F59E";
+}
+.mdi-web::before {
+ content: "\F59F";
+}
+.mdi-web-box::before {
+ content: "\FFB1";
+}
+.mdi-web-clock::before {
+ content: "\F0275";
+}
+.mdi-webcam::before {
+ content: "\F5A0";
+}
+.mdi-webhook::before {
+ content: "\F62F";
+}
+.mdi-webpack::before {
+ content: "\F72A";
+}
+.mdi-webrtc::before {
+ content: "\F0273";
+}
+.mdi-wechat::before {
+ content: "\F611";
+}
+.mdi-weight::before {
+ content: "\F5A1";
+}
+.mdi-weight-gram::before {
+ content: "\FD1B";
+}
+.mdi-weight-kilogram::before {
+ content: "\F5A2";
+}
+.mdi-weight-lifter::before {
+ content: "\F0188";
+}
+.mdi-weight-pound::before {
+ content: "\F9B4";
+}
+.mdi-whatsapp::before {
+ content: "\F5A3";
+}
+.mdi-wheelchair-accessibility::before {
+ content: "\F5A4";
+}
+.mdi-whistle::before {
+ content: "\F9B5";
+}
+.mdi-whistle-outline::before {
+ content: "\F02E7";
+}
+.mdi-white-balance-auto::before {
+ content: "\F5A5";
+}
+.mdi-white-balance-incandescent::before {
+ content: "\F5A6";
+}
+.mdi-white-balance-iridescent::before {
+ content: "\F5A7";
+}
+.mdi-white-balance-sunny::before {
+ content: "\F5A8";
+}
+.mdi-widgets::before {
+ content: "\F72B";
+}
+.mdi-widgets-outline::before {
+ content: "\F0380";
+}
+.mdi-wifi::before {
+ content: "\F5A9";
+}
+.mdi-wifi-off::before {
+ content: "\F5AA";
+}
+.mdi-wifi-star::before {
+ content: "\FE6B";
+}
+.mdi-wifi-strength-1::before {
+ content: "\F91E";
+}
+.mdi-wifi-strength-1-alert::before {
+ content: "\F91F";
+}
+.mdi-wifi-strength-1-lock::before {
+ content: "\F920";
+}
+.mdi-wifi-strength-2::before {
+ content: "\F921";
+}
+.mdi-wifi-strength-2-alert::before {
+ content: "\F922";
+}
+.mdi-wifi-strength-2-lock::before {
+ content: "\F923";
+}
+.mdi-wifi-strength-3::before {
+ content: "\F924";
+}
+.mdi-wifi-strength-3-alert::before {
+ content: "\F925";
+}
+.mdi-wifi-strength-3-lock::before {
+ content: "\F926";
+}
+.mdi-wifi-strength-4::before {
+ content: "\F927";
+}
+.mdi-wifi-strength-4-alert::before {
+ content: "\F928";
+}
+.mdi-wifi-strength-4-lock::before {
+ content: "\F929";
+}
+.mdi-wifi-strength-alert-outline::before {
+ content: "\F92A";
+}
+.mdi-wifi-strength-lock-outline::before {
+ content: "\F92B";
+}
+.mdi-wifi-strength-off::before {
+ content: "\F92C";
+}
+.mdi-wifi-strength-off-outline::before {
+ content: "\F92D";
+}
+.mdi-wifi-strength-outline::before {
+ content: "\F92E";
+}
+.mdi-wii::before {
+ content: "\F5AB";
+}
+.mdi-wiiu::before {
+ content: "\F72C";
+}
+.mdi-wikipedia::before {
+ content: "\F5AC";
+}
+.mdi-wind-turbine::before {
+ content: "\FD81";
+}
+.mdi-window-close::before {
+ content: "\F5AD";
+}
+.mdi-window-closed::before {
+ content: "\F5AE";
+}
+.mdi-window-closed-variant::before {
+ content: "\F0206";
+}
+.mdi-window-maximize::before {
+ content: "\F5AF";
+}
+.mdi-window-minimize::before {
+ content: "\F5B0";
+}
+.mdi-window-open::before {
+ content: "\F5B1";
+}
+.mdi-window-open-variant::before {
+ content: "\F0207";
+}
+.mdi-window-restore::before {
+ content: "\F5B2";
+}
+.mdi-window-shutter::before {
+ content: "\F0147";
+}
+.mdi-window-shutter-alert::before {
+ content: "\F0148";
+}
+.mdi-window-shutter-open::before {
+ content: "\F0149";
+}
+.mdi-windows::before {
+ content: "\F5B3";
+}
+.mdi-windows-classic::before {
+ content: "\FA20";
+}
+.mdi-wiper::before {
+ content: "\FAE8";
+}
+.mdi-wiper-wash::before {
+ content: "\FD82";
+}
+.mdi-wordpress::before {
+ content: "\F5B4";
+}
+.mdi-worker::before {
+ content: "\F5B5";
+}
+.mdi-wrap::before {
+ content: "\F5B6";
+}
+.mdi-wrap-disabled::before {
+ content: "\FBBB";
+}
+.mdi-wrench::before {
+ content: "\F5B7";
+}
+.mdi-wrench-outline::before {
+ content: "\FBBC";
+}
+.mdi-wunderlist::before {
+ content: "\F5B8";
+}
+.mdi-xamarin::before {
+ content: "\F844";
+}
+.mdi-xamarin-outline::before {
+ content: "\F845";
+}
+.mdi-xaml::before {
+ content: "\F673";
+}
+.mdi-xbox::before {
+ content: "\F5B9";
+}
+.mdi-xbox-controller::before {
+ content: "\F5BA";
+}
+.mdi-xbox-controller-battery-alert::before {
+ content: "\F74A";
+}
+.mdi-xbox-controller-battery-charging::before {
+ content: "\FA21";
+}
+.mdi-xbox-controller-battery-empty::before {
+ content: "\F74B";
+}
+.mdi-xbox-controller-battery-full::before {
+ content: "\F74C";
+}
+.mdi-xbox-controller-battery-low::before {
+ content: "\F74D";
+}
+.mdi-xbox-controller-battery-medium::before {
+ content: "\F74E";
+}
+.mdi-xbox-controller-battery-unknown::before {
+ content: "\F74F";
+}
+.mdi-xbox-controller-menu::before {
+ content: "\FE52";
+}
+.mdi-xbox-controller-off::before {
+ content: "\F5BB";
+}
+.mdi-xbox-controller-view::before {
+ content: "\FE53";
+}
+.mdi-xda::before {
+ content: "\F5BC";
+}
+.mdi-xing::before {
+ content: "\F5BD";
+}
+.mdi-xing-box::before {
+ content: "\F5BE";
+}
+.mdi-xing-circle::before {
+ content: "\F5BF";
+}
+.mdi-xml::before {
+ content: "\F5C0";
+}
+.mdi-xmpp::before {
+ content: "\F7FE";
+}
+.mdi-yahoo::before {
+ content: "\FB2A";
+}
+.mdi-yammer::before {
+ content: "\F788";
+}
+.mdi-yeast::before {
+ content: "\F5C1";
+}
+.mdi-yelp::before {
+ content: "\F5C2";
+}
+.mdi-yin-yang::before {
+ content: "\F67F";
+}
+.mdi-yoga::before {
+ content: "\F01A7";
+}
+.mdi-youtube::before {
+ content: "\F5C3";
+}
+.mdi-youtube-creator-studio::before {
+ content: "\F846";
+}
+.mdi-youtube-gaming::before {
+ content: "\F847";
+}
+.mdi-youtube-subscription::before {
+ content: "\FD1C";
+}
+.mdi-youtube-tv::before {
+ content: "\F448";
+}
+.mdi-z-wave::before {
+ content: "\FAE9";
+}
+.mdi-zend::before {
+ content: "\FAEA";
+}
+.mdi-zigbee::before {
+ content: "\FD1D";
+}
+.mdi-zip-box::before {
+ content: "\F5C4";
+}
+.mdi-zip-box-outline::before {
+ content: "\F001B";
+}
+.mdi-zip-disk::before {
+ content: "\FA22";
+}
+.mdi-zodiac-aquarius::before {
+ content: "\FA7C";
+}
+.mdi-zodiac-aries::before {
+ content: "\FA7D";
+}
+.mdi-zodiac-cancer::before {
+ content: "\FA7E";
+}
+.mdi-zodiac-capricorn::before {
+ content: "\FA7F";
+}
+.mdi-zodiac-gemini::before {
+ content: "\FA80";
+}
+.mdi-zodiac-leo::before {
+ content: "\FA81";
+}
+.mdi-zodiac-libra::before {
+ content: "\FA82";
+}
+.mdi-zodiac-pisces::before {
+ content: "\FA83";
+}
+.mdi-zodiac-sagittarius::before {
+ content: "\FA84";
+}
+.mdi-zodiac-scorpio::before {
+ content: "\FA85";
+}
+.mdi-zodiac-taurus::before {
+ content: "\FA86";
+}
+.mdi-zodiac-virgo::before {
+ content: "\FA87";
+}
+.mdi-blank::before {
+ content: "\F68C";
+ visibility: hidden;
+}
+.mdi-18px.mdi-set,
+.mdi-18px.mdi:before {
+ font-size: 18px;
+}
+.mdi-24px.mdi-set,
+.mdi-24px.mdi:before {
+ font-size: 24px;
+}
+.mdi-36px.mdi-set,
+.mdi-36px.mdi:before {
+ font-size: 36px;
+}
+.mdi-48px.mdi-set,
+.mdi-48px.mdi:before {
+ font-size: 48px;
+}
+.mdi-dark:before {
+ color: rgba(0, 0, 0, 0.54);
+}
+.mdi-dark.mdi-inactive:before {
+ color: rgba(0, 0, 0, 0.26);
+}
+.mdi-light:before {
+ color: #fff;
+}
+.mdi-light.mdi-inactive:before {
+ color: rgba(255, 255, 255, 0.3);
+}
+.mdi-rotate-45:before {
+ -webkit-transform: rotate(45deg);
+ -ms-transform: rotate(45deg);
+ transform: rotate(45deg);
+}
+.mdi-rotate-90:before {
+ -webkit-transform: rotate(90deg);
+ -ms-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+.mdi-rotate-135:before {
+ -webkit-transform: rotate(135deg);
+ -ms-transform: rotate(135deg);
+ transform: rotate(135deg);
+}
+.mdi-rotate-180:before {
+ -webkit-transform: rotate(180deg);
+ -ms-transform: rotate(180deg);
+ transform: rotate(180deg);
+}
+.mdi-rotate-225:before {
+ -webkit-transform: rotate(225deg);
+ -ms-transform: rotate(225deg);
+ transform: rotate(225deg);
+}
+.mdi-rotate-270:before {
+ -webkit-transform: rotate(270deg);
+ -ms-transform: rotate(270deg);
+ transform: rotate(270deg);
+}
+.mdi-rotate-315:before {
+ -webkit-transform: rotate(315deg);
+ -ms-transform: rotate(315deg);
+ transform: rotate(315deg);
+}
+.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1);
+ transform: scaleX(-1);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+}
+.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1);
+ transform: scaleY(-1);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+}
+.mdi-spin:before {
+ -webkit-animation: mdi-spin 2s infinite linear;
+ animation: mdi-spin 2s infinite linear;
+}
+@-webkit-keyframes mdi-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
+@keyframes mdi-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
/*# sourceMappingURL=materialdesignicons.css.map */
diff --git a/packages/anastasis-webui/src/scss/libs/_all.scss b/packages/anastasis-webui/src/scss/libs/_all.scss
index 08bd76cd1..95d7352be 100644
--- a/packages/anastasis-webui/src/scss/libs/_all.scss
+++ b/packages/anastasis-webui/src/scss/libs/_all.scss
@@ -1,20 +1,20 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
diff --git a/packages/anastasis-webui/src/scss/main.scss b/packages/anastasis-webui/src/scss/main.scss
index 2e60bf6f9..7826df78d 100644
--- a/packages/anastasis-webui/src/scss/main.scss
+++ b/packages/anastasis-webui/src/scss/main.scss
@@ -1,17 +1,17 @@
/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
@@ -44,12 +44,12 @@
@import "custom-calendar";
@import "loading";
-@import "fonts/nunito.css";
-@import "icons/materialdesignicons-4.9.95.min.css";
+@import "fonts/nunito";
+@import "icons/materialdesignicons-4.9.95.min";
$tooltip-color: red;
-@import "../../node_modules/@creativebulma/bulma-tooltip/dist/bulma-tooltip.min.css";
+@import "node_modules/@creativebulma/bulma-tooltip/dist/bulma-tooltip";
// @import "../../node_modules/bulma-timeline/dist/css/bulma-timeline.min.css";
.notification {
@@ -190,18 +190,17 @@ div[data-tooltip]::before {
border: solid 1px #f2e9bf;
}
-
.home {
padding: 1em 1em;
min-height: 100%;
width: 100%;
- max-width: 40em;
+ // max-width: 40em;
}
-.home div {
- margin-top: 0.5em;
- margin-bottom: 0.5em;
-}
+// .home div {
+// margin-top: 0.5em;
+// margin-bottom: 0.5em;
+// }
.policy {
padding: 0.5em;
@@ -218,9 +217,9 @@ div[data-tooltip]::before {
}
.profile {
- padding: 56px 20px;
- min-height: 100%;
- width: 100%;
+ padding: 56px 20px;
+ min-height: 100%;
+ width: 100%;
}
.notfound {
@@ -232,4 +231,4 @@ h1 {
font-size: 1.5em;
margin-top: 0.8em;
margin-bottom: 0.8em;
-} \ No newline at end of file
+}
diff --git a/packages/anastasis-webui/src/stories.tsx b/packages/anastasis-webui/src/stories.tsx
new file mode 100644
index 000000000..cdaa4022f
--- /dev/null
+++ b/packages/anastasis-webui/src/stories.tsx
@@ -0,0 +1,46 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { strings } from "./i18n/strings.js";
+
+import * as pages from "./pages/home/index.stories.js";
+
+import { renderStories } from "@gnu-taler/web-util/browser";
+
+import "./scss/main.scss";
+
+function SortStories(a: any, b: any): number {
+ return (a?.order ?? 0) - (b?.order ?? 0);
+}
+
+function main(): void {
+ renderStories(
+ { pages },
+ {
+ strings,
+ },
+ );
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", main);
+} else {
+ main();
+}
diff --git a/packages/anastasis-webui/src/style/index.css b/packages/anastasis-webui/src/style/index.css
deleted file mode 100644
index e69de29bb..000000000
--- a/packages/anastasis-webui/src/style/index.css
+++ /dev/null
diff --git a/packages/anastasis-webui/src/template.html b/packages/anastasis-webui/src/template.html
deleted file mode 100644
index 351f1829c..000000000
--- a/packages/anastasis-webui/src/template.html
+++ /dev/null
@@ -1,15 +0,0 @@
-<!DOCTYPE html>
-<html lang="en" class="has-aside-left has-aside-mobile-transition has-navbar-fixed-top has-aside-expanded">
- <head>
- <meta charset="utf-8">
- <title><% preact.title %></title>
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <meta name="mobile-web-app-capable" content="yes">
- <meta name="apple-mobile-web-app-capable" content="yes">
- <link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png">
- <% preact.headEnd %>
- </head>
- <body>
- <% preact.bodyEnd %>
- </body>
-</html>
diff --git a/packages/anastasis-webui/src/utils/index.tsx b/packages/anastasis-webui/src/utils/index.tsx
index d1d861469..88bcac551 100644
--- a/packages/anastasis-webui/src/utils/index.tsx
+++ b/packages/anastasis-webui/src/utils/index.tsx
@@ -1,45 +1,49 @@
-/* eslint-disable @typescript-eslint/camelcase */
-import { BackupStates, RecoveryStates, ReducerState } from 'anastasis-core';
-import { FunctionalComponent, h, VNode } from 'preact';
-import { AnastasisProvider } from '../context/anastasis';
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
-export function createExample<Props>(Component: FunctionalComponent<Props>, currentReducerState?: ReducerState, props?: Partial<Props>): { (args: Props): VNode } {
- const r = (args: Props): VNode => {
- return <AnastasisProvider value={{
- currentReducerState,
- currentError: undefined,
- back: () => { null },
- dismissError: () => { null },
- reset: () => { null },
- runTransaction: () => { null },
- startBackup: () => { null },
- startRecover: () => { null },
- transition: () => { null },
- }}>
- <Component {...args} />
- </AnastasisProvider>
- }
- r.args = props
- return r
-}
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AuthenticationProviderStatusError,
+ AuthenticationProviderStatusOk,
+ BackupStates,
+ RecoveryStates,
+ ReducerState,
+ ReducerStateRecovery,
+} from "@gnu-taler/anastasis-core";
+import { VNode } from "preact";
+
+const noop = async (): Promise<void> => {
+ return;
+};
const base = {
continents: [
{
- name: "Europe"
+ name: "Europe",
},
{
- name: "India"
+ name: "India",
},
{
- name: "Asia"
+ name: "Asia",
},
{
- name: "North America"
+ name: "North America",
},
{
- name: "Testcontinent"
- }
+ name: "Testcontinent",
+ },
],
countries: [
{
@@ -47,115 +51,231 @@ const base = {
name: "Testland",
continent: "Testcontinent",
continent_i18n: {
- de_DE: "Testkontinent"
+ de_DE: "Testkontinent",
},
name_i18n: {
de_DE: "Testlandt",
de_CH: "Testlandi",
fr_FR: "Testpais",
- en_UK: "Testland"
+ en_UK: "Testland",
},
currency: "TESTKUDOS",
- call_code: "+00"
+ call_code: "+00",
},
{
code: "xy",
name: "Demoland",
continent: "Testcontinent",
continent_i18n: {
- de_DE: "Testkontinent"
+ de_DE: "Testkontinent",
},
name_i18n: {
de_DE: "Demolandt",
de_CH: "Demolandi",
fr_FR: "Demopais",
- en_UK: "Demoland"
+ en_UK: "Demoland",
},
currency: "KUDOS",
- call_code: "+01"
- }
+ call_code: "+01",
+ },
],
authentication_providers: {
"http://localhost:8086/": {
+ status: "ok",
http_status: 200,
annual_fee: "COL:0",
- business_name: "ana",
+ business_name: "Anastasis Local",
currency: "COL",
liability_limit: "COL:10",
methods: [
{
type: "question",
- usage_fee: "COL:0"
- }
+ usage_fee: "COL:0",
+ },
+ {
+ type: "sms",
+ usage_fee: "COL:0",
+ },
+ {
+ type: "email",
+ usage_fee: "COL:0",
+ },
],
- salt: "WBMDD76BR1E90YQ5AHBMKPH7GW",
+ provider_salt: "WBMDD76BR1E90YQ5AHBMKPH7GW",
storage_limit_in_megabytes: 16,
- truth_upload_fee: "COL:0"
- },
+ truth_upload_fee: "COL:0",
+ } as AuthenticationProviderStatusOk,
+ "https://kudos.demo.anastasis.lu/": {
+ status: "ok",
+ http_status: 200,
+ annual_fee: "COL:0",
+ business_name: "Anastasis Kudo",
+ currency: "COL",
+ liability_limit: "COL:10",
+ methods: [
+ {
+ type: "question",
+ usage_fee: "COL:0",
+ },
+ {
+ type: "email",
+ usage_fee: "COL:0",
+ },
+ ],
+ provider_salt: "WBMDD76BR1E90YQ5AHBMKPH7GW",
+ storage_limit_in_megabytes: 16,
+ truth_upload_fee: "COL:0",
+ } as AuthenticationProviderStatusOk,
+ "https://anastasis.demo.taler.net/": {
+ status: "ok",
+ http_status: 200,
+ annual_fee: "COL:0",
+ business_name: "Anastasis Demo",
+ currency: "COL",
+ liability_limit: "COL:10",
+ methods: [
+ {
+ type: "question",
+ usage_fee: "COL:0",
+ },
+ {
+ type: "sms",
+ usage_fee: "COL:0",
+ },
+ {
+ type: "totp",
+ usage_fee: "COL:0",
+ },
+ ],
+ provider_salt: "WBMDD76BR1E90YQ5AHBMKPH7GW",
+ storage_limit_in_megabytes: 16,
+ truth_upload_fee: "COL:0",
+ } as AuthenticationProviderStatusOk,
+
"http://localhost:8087/": {
+ status: "error",
code: 8414,
- hint: "request to provider failed"
- },
+ hint: "request to provider failed",
+ } as AuthenticationProviderStatusError,
"http://localhost:8088/": {
+ status: "error",
code: 8414,
- hint: "request to provider failed"
- },
+ hint: "request to provider failed",
+ } as AuthenticationProviderStatusError,
"http://localhost:8089/": {
+ status: "error",
code: 8414,
- hint: "request to provider failed"
- }
+ hint: "request to provider failed",
+ } as AuthenticationProviderStatusError,
},
- // expiration: {
- // d_ms: 1792525051855 // check t_ms
- // },
-} as Partial<ReducerState>
+} as Partial<ReducerState>;
export const reducerStatesExample = {
initial: undefined,
- recoverySelectCountry: {...base,
- recovery_state: RecoveryStates.CountrySelecting
- } as ReducerState,
- backupSelectCountry: {...base,
- backup_state: BackupStates.CountrySelecting
+ recoverySelectCountry: {
+ ...base,
+ reducer_type: "recovery",
+ recovery_state: RecoveryStates.CountrySelecting,
} as ReducerState,
- recoverySelectContinent: {...base,
+ recoverySelectContinent: {
+ ...base,
+ reducer_type: "recovery",
recovery_state: RecoveryStates.ContinentSelecting,
} as ReducerState,
- backupSelectContinent: {...base,
- backup_state: BackupStates.ContinentSelecting,
- } as ReducerState,
- secretSelection: {...base,
+ secretSelection: {
+ ...base,
+ reducer_type: "recovery",
recovery_state: RecoveryStates.SecretSelecting,
} as ReducerState,
- recoveryFinished: {...base,
+ recoveryFinished: {
+ ...base,
+ reducer_type: "recovery",
recovery_state: RecoveryStates.RecoveryFinished,
} as ReducerState,
- challengeSelecting: {...base,
+ challengeSelecting: {
+ ...base,
+ reducer_type: "recovery",
recovery_state: RecoveryStates.ChallengeSelecting,
} as ReducerState,
- challengeSolving: {...base,
+ challengeSolving: {
+ ...base,
+ reducer_type: "recovery",
recovery_state: RecoveryStates.ChallengeSolving,
+ } as ReducerStateRecovery,
+ challengePaying: {
+ ...base,
+ reducer_type: "recovery",
+ recovery_state: RecoveryStates.ChallengePaying,
+ } as ReducerState,
+ recoveryAttributeEditing: {
+ ...base,
+ reducer_type: "recovery",
+ recovery_state: RecoveryStates.UserAttributesCollecting,
} as ReducerState,
- secretEdition: {...base,
+ backupSelectCountry: {
+ ...base,
+ reducer_type: "backup",
+ backup_state: BackupStates.CountrySelecting,
+ } as ReducerState,
+ backupSelectContinent: {
+ ...base,
+ reducer_type: "backup",
+ backup_state: BackupStates.ContinentSelecting,
+ } as ReducerState,
+ secretEdition: {
+ ...base,
+ reducer_type: "backup",
backup_state: BackupStates.SecretEditing,
} as ReducerState,
- policyReview: {...base,
+ policyReview: {
+ ...base,
+ reducer_type: "backup",
backup_state: BackupStates.PoliciesReviewing,
} as ReducerState,
- policyPay: {...base,
+ policyPay: {
+ ...base,
+ reducer_type: "backup",
backup_state: BackupStates.PoliciesPaying,
} as ReducerState,
- backupFinished: {...base,
+ backupFinished: {
+ ...base,
+ reducer_type: "backup",
backup_state: BackupStates.BackupFinished,
} as ReducerState,
- authEditing: {...base,
- backup_state: BackupStates.AuthenticationsEditing
+ authEditing: {
+ ...base,
+ backup_state: BackupStates.AuthenticationsEditing,
+ reducer_type: "backup",
} as ReducerState,
- attributeEditing: {...base,
- backup_state: BackupStates.UserAttributesCollecting
+ backupAttributeEditing: {
+ ...base,
+ reducer_type: "backup",
+ backup_state: BackupStates.UserAttributesCollecting,
} as ReducerState,
- truthsPaying: {...base,
- backup_state: BackupStates.TruthsPaying
+ truthsPaying: {
+ ...base,
+ reducer_type: "backup",
+ backup_state: BackupStates.TruthsPaying,
} as ReducerState,
+};
+
+export type StateFunc<S> = (p: S) => VNode;
+
+export type StateViewMap<StateType extends { status: string }> = {
+ [S in StateType as S["status"]]: StateFunc<S>;
+};
+export function compose<SType extends { status: string }, PType>(
+ name: string,
+ hook: (p: PType) => SType,
+ vs: StateViewMap<SType>,
+): (p: PType) => VNode {
+ const Component = (p: PType): VNode => {
+ const state = hook(p);
+ const s = state.status as unknown as SType["status"];
+ const c = vs[s] as unknown as StateFunc<SType>;
+ return c(state);
+ };
+ // Component.name = `${name}`;
+ return Component;
}
diff --git a/packages/anastasis-webui/test.mjs b/packages/anastasis-webui/test.mjs
new file mode 100755
index 000000000..ba3257de8
--- /dev/null
+++ b/packages/anastasis-webui/test.mjs
@@ -0,0 +1,30 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { build } from "@gnu-taler/web-util/build";
+import { getFilesInDirectory } from "@gnu-taler/web-util/build";
+
+const allTestFiles = getFilesInDirectory("src", /.test.tsx?$/);
+
+await build({
+ type: "test",
+ source: {
+ js: allTestFiles.files,
+ assets: [{base:"src",files:["src/index.html"]}],
+ },
+ destination: "./dist/prod",
+ css: "sass",
+});
diff --git a/packages/anastasis-webui/tests/__mocks__/browserMocks.ts b/packages/anastasis-webui/tests/__mocks__/browserMocks.ts
deleted file mode 100644
index 5be8c3ce6..000000000
--- a/packages/anastasis-webui/tests/__mocks__/browserMocks.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-// Mock Browser API's which are not supported by JSDOM, e.g. ServiceWorker, LocalStorage
-/**
- * An example how to mock localStorage is given below 👇
- */
-
-/*
-// Mocks localStorage
-const localStorageMock = (function() {
- let store = {};
-
- return {
- getItem: (key) => store[key] || null,
- setItem: (key, value) => store[key] = value.toString(),
- clear: () => store = {}
- };
-
-})();
-
-Object.defineProperty(window, 'localStorage', {
- value: localStorageMock
-}); */
diff --git a/packages/anastasis-webui/tests/__mocks__/fileMocks.ts b/packages/anastasis-webui/tests/__mocks__/fileMocks.ts
deleted file mode 100644
index 87109e355..000000000
--- a/packages/anastasis-webui/tests/__mocks__/fileMocks.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-// This fixed an error related to the CSS and loading gif breaking my Jest test
-// See https://facebook.github.io/jest/docs/en/webpack.html#handling-static-assets
-export default 'test-file-stub';
diff --git a/packages/anastasis-webui/tests/__mocks__/setupTests.ts b/packages/anastasis-webui/tests/__mocks__/setupTests.ts
deleted file mode 100644
index 01dc92a29..000000000
--- a/packages/anastasis-webui/tests/__mocks__/setupTests.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { configure } from 'enzyme';
-import Adapter from 'enzyme-adapter-preact-pure';
-
-configure({
- adapter: new Adapter()
-});
diff --git a/packages/anastasis-webui/tests/declarations.d.ts b/packages/anastasis-webui/tests/declarations.d.ts
deleted file mode 100644
index 67e940277..000000000
--- a/packages/anastasis-webui/tests/declarations.d.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-// Enable enzyme adapter's integration with TypeScript
-// See: https://github.com/preactjs/enzyme-adapter-preact-pure#usage-with-typescript
-/// <reference types="enzyme-adapter-preact-pure" />
diff --git a/packages/anastasis-webui/tsconfig.json b/packages/anastasis-webui/tsconfig.json
index e2491daa0..9e52f2b7e 100644
--- a/packages/anastasis-webui/tsconfig.json
+++ b/packages/anastasis-webui/tsconfig.json
@@ -1,68 +1,51 @@
{
"compilerOptions": {
/* Basic Options */
- "target": "ES5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */,
- "module": "ESNext" /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
- // "lib": [], /* Specify library files to be included in the compilation: */
+ "target": "ES2020",
+ "module": "Node16",
+ "lib": [
+ "DOM",
+ "ES2020"
+ ],
"allowJs": true /* Allow javascript files to be compiled. */,
// "checkJs": true, /* Report errors in .js files. */
"jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
- "jsxFactory": "h" /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */,
- // "declaration": true, /* Generates corresponding '.d.ts' file. */
- // "sourceMap": true, /* Generates corresponding '.map' file. */
- // "outFile": "./", /* Concatenate and emit output to single file. */
- // "outDir": "./", /* Redirect output structure to the directory. */
- // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
- // "removeComments": true, /* Do not emit comments to output. */
+ "jsxFactory": "h",
+ "jsxFragmentFactory": "Fragment",
"noEmit": true /* Do not emit outputs. */,
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
-
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
- // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
- // "strictNullChecks": true, /* Enable strict null checks. */
- // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
- // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
-
+ "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
-
/* Module Resolution Options */
- "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
+ "moduleResolution": "Node16" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
"esModuleInterop": true /* */,
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
- // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+ "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
-
/* Source Map Options */
// "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
-
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
-
/* Advanced Options */
"skipLibCheck": true /* Skip type checking of declaration files. */
},
- "references": [
- {
- "path": "../taler-util/"
- },
- {
- "path": "../anastasis-core/"
- }
- ],
- "include": ["src/**/*", "tests/**/*"]
+ "include": [
+ "src/**/*"
+ ]
}
diff --git a/packages/auditor-backoffice-ui/.gitignore b/packages/auditor-backoffice-ui/.gitignore
new file mode 100644
index 000000000..df149101c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/.gitignore
@@ -0,0 +1,6 @@
+/build
+/size-plugin.json
+/storybook-static
+/docs
+/single
+/coverage
diff --git a/packages/auditor-backoffice-ui/DESIGN.md b/packages/auditor-backoffice-ui/DESIGN.md
new file mode 100644
index 000000000..d6252ccdc
--- /dev/null
+++ b/packages/auditor-backoffice-ui/DESIGN.md
@@ -0,0 +1,195 @@
+# Page internal routing
+
+* The SPA is loaded from the BACKOFFICE_URL
+
+* The view to be rendered is decided by the URL fragment
+
+* Query parameters that may affect routing
+
+ - instance: use from the default instance to mimic another instance management
+
+* The user must provide BACKEND_URL or BACKOFFICE_URL will use as default
+
+* Token for querying the backend will be saved in localStorage under
+ backend-token-${name}
+
+# HTTP queries to the backend
+
+HTTP queries will have 4 states:
+
+* loading: request did not end yet. data and error are undefined
+
+* ok: data has information, http response status == 200
+
+* clientError: http response status is between 400 and 499
+
+ - notfound: http status 404
+
+ - unauthorized: http status 401
+
+* serverError: http response status is grater than 500
+
+There are categories of queries:
+
+ * sync: getting information for the page rendering
+
+ * async: performing an CRUD operation
+
+## Loading the page information (sync)
+
+In this scenario, a failed request will make the app flow to break.
+
+When receiving an not found error a generic not found page will be shown. If the
+BACKEND_URL points to a default instance it should send the user to create the
+instance.
+
+When receiving an unauthorized error, the user should be prompted with a login form.
+
+When receiving an another error (400 < http status < 600), the login form should
+be shown with an error message using the hint from the backend.
+
+On other unexpected error (like network error), the login form should be shown
+with an error message.
+
+## CRUD operation (async)
+
+In this scenario, a failed request does not break the flow but a message will be
+prompted.
+
+# Forms
+
+All the input components should be placed in the folder `src/components/from`.
+
+The core concepts are:
+
+ * <FormProvider<T> /> places instead of <form /> it should be mapped to an
+ object of type T
+
+ * <Input /> an others: defines UI, create <input /> DOM controls and access the
+ form with useField()
+
+To use it you will need a state somewhere with the object holding all the form
+information.
+
+```
+const [state, setState] = useState({ name: '', age: 11 })
+```
+
+Optionally an error object an be built with the error messages
+
+```
+const errors = {
+ field1: undefined,
+ field2: 'should be greater than 18',
+}
+```
+
+These 3 elements are used to setup the FormProvider
+
+```
+<FormProvider errors={errors} object={state} valueHandler={setState}>
+...inputs
+</FormProvider>
+```
+
+Inputs should handle UI rendering and use `useField(name)` to get:
+
+ * error: the field has been modified and the value is not correct
+ * required: the field need to be corrected
+ * value: the current value of the object
+ * initial: original value before change
+ * onChange: function to update the current field
+
+Also, every input must be ready to receive these properties
+
+ * name: property of the form object being manipulated
+ * label: how the name of the property will be shown in the UI
+ * placeholder: optional, inplace text when there is no value yet
+ * readonly: default to false, will prevent change the value
+ * help: optional, example text below the input text to help the user
+ * tooltip: optional, will add a (i) with a popup to describe the field
+
+
+# Custom Hooks
+
+All the general purpose hooks should be placed in folder `src/hooks` and tests
+under `tests/hooks`. Starts with the `use` word.
+
+# Contexts
+
+All the contexts should be placed in the folder `src/context` as a function.
+Should expose provider as a component `<XxxContextProvider />` and consumer as a
+hook function `useXxxContext()` (where XXX is the name)
+
+# Components
+
+Type of components:
+
+ * main entry point: src/index.tsx, mostly initialization
+
+ * routing: in the `src` folder, deciding who is going to take the work. That's
+ when the page is loading but also create navigation handlers
+
+ * pages: in the `paths` folder, setup page information (like querying the
+ backend for the list of things), handlers for CRUD events, delegated routing
+ to parent and UI to children.
+
+Some other guidelines:
+
+ * Hooks over classes are preferred
+
+ * Components that are ready to be reused on any place should be in
+ `src/components` folder
+
+ * Since one of the build targets is a single bundle with all the pages, we are
+ avoiding route based code splitting
+ https://github.com/preactjs/preact-cli#route-based-code-splitting
+
+
+# Testing
+
+Every components should have examples using storybook (xxx.stories.tsx). There
+is an automated test that check that every example can be rendered so we make
+sure that we do not add a regression.
+
+Every hook should have examples under `tests/hooks` with common usage trying to
+follow this structure:
+
+ * (Given) set some context of the initial condition
+
+ * (When) some action to be tested. May be the initialization of a hook or an
+ action associated with it
+
+ * (Then) a particular set of observable consequences should be expected
+
+# Accessibility
+
+Pages and components should be built with accessibility in mind.
+
+https://github.com/nickcolley/jest-axe
+https://orkhanhuseyn.medium.com/accessibility-testing-in-react-with-jest-axe-e08c2a3f3289
+http://accesibilidadweb.dlsi.ua.es/?menu=jaws
+https://webaim.org/projects/screenreadersurvey8/#intro
+https://www.gov.uk/service-manual/technology/testing-with-assistive-technologies#how-to-test
+https://es.reactjs.org/docs/accessibility.html
+
+# Internationalization
+
+Every non translated message should be written in English and wrapped into:
+
+ * i18n function from useTranslator() hook
+ * <Translate /> component
+
+Makefile has a i18n that will parse source files and update the po template.
+When *.po are updated, running the i18n target will create the strings.ts that
+the application will use in runtime.
+
+# Documentation Conventions
+
+* labels
+ * begin w/ a capital letter
+ * acronyms (e.g., "URL") are upper case
+* tooltips
+ * begin w/ a lower case letter
+ * do not end w/ punctuation (period)
+ * avoid leading article ("a", "an", "the")
diff --git a/packages/auditor-backoffice-ui/Makefile b/packages/auditor-backoffice-ui/Makefile
new file mode 100644
index 000000000..57b3e0cb5
--- /dev/null
+++ b/packages/auditor-backoffice-ui/Makefile
@@ -0,0 +1,35 @@
+# This Makefile has been placed in the public domain
+
+ifeq ($(TOPLEVEL), yes)
+ $(info top-level build)
+ -include ../../.config.mk
+ override DESTDIR := $(TOP_DESTDIR)
+else
+ $(info package-level build)
+ -include ../../.config.mk
+ -include .config.mk
+endif
+
+$(info prefix is $(prefix))
+
+.PHONY: all
+all:
+ @echo run \'make install\' to install
+
+spa_dir=$(DESTDIR)$(prefix)/share/taler/auditor-backoffice
+
+.PHONY: deps
+deps:
+ pnpm install --frozen-lockfile --filter @gnu-taler/auditor-backoffice...
+ pnpm run build
+
+.PHONY: install-nodeps
+install-nodeps:
+ (cd dist/prod && find . -type f -exec install -D "{}" "$(spa_dir)/{}" \;)
+
+
+.PHONY: install
+install:
+ $(MAKE) deps
+ $(MAKE) install-nodeps
+
diff --git a/packages/auditor-backoffice-ui/README.md b/packages/auditor-backoffice-ui/README.md
new file mode 100644
index 000000000..b10fa6a94
--- /dev/null
+++ b/packages/auditor-backoffice-ui/README.md
@@ -0,0 +1,64 @@
+## AUditor Admin Frontend
+
+Auditor Admin Frontend is a Single Page Application (SPA) that connects with a running Auditor Backend and lets you audit the exchange.
+
+## System requirements
+
+- Node: v16.15.0
+- pnpm: 7.14.2
+- make
+
+## Compiling from source
+
+Run `pnpm install --frozen-lockfile --filter @gnu-taler/auditor-backoffice...` to install all the nodejs dependencies.
+
+Then the command `pnpm build` create the distribution in the `dist` folder.
+
+By default the installation prefix will be `/usr/local/share/taler/auditor-backoffice/` but it can be overridden by `--prefix` in the configuration process:
+
+```shell
+./configure --prefix=/another/directory
+```
+
+To install run `make install`
+
+## Running develop
+
+To run a development server run:
+
+```shell
+./dev.mjs
+```
+
+This should start a watch process that will reload the server every time that a file is saved.
+
+The application need to connect to a auditor-backend properly configured to run.
+
+## Building for deploy
+
+To build and deploy the SPA in your local server run the install script:
+
+```shell
+make install
+```
+
+## Runtime dependencies
+
+* preact: Fast 3kB alternative to React with the same modern API
+
+* preact-router: URL component router for Preact
+
+* SWR: React Hooks library for data fetching (stale-while-revalidate)
+
+* Yup: schema builder for value parsing and validation (to be deprecated)
+
+* Date-fns: library for manipulating javascript date
+
+* qrcode-generator: simplest qr implementation based on JIS X 0510:1999
+
+* @gnu-taler/taler-util: types and tooling
+
+* history: manage the history stack, navigate, and persist state between sessions
+
+* jed: gettext like library for internationalization
+
diff --git a/packages/auditor-backoffice-ui/build.mjs b/packages/auditor-backoffice-ui/build.mjs
new file mode 100755
index 000000000..b6d6e5127
--- /dev/null
+++ b/packages/auditor-backoffice-ui/build.mjs
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build } from "@gnu-taler/web-util/build";
+
+await build({
+ type: "production",
+ source: {
+ js: ["src/index.tsx"],
+ assets: [{base:"src",files:["src/index.html"]}],
+ },
+ destination: "./dist/prod",
+ css: "sass",
+});
diff --git a/packages/auditor-backoffice-ui/contrib/po2ts b/packages/auditor-backoffice-ui/contrib/po2ts
new file mode 100755
index 000000000..d32e922ba
--- /dev/null
+++ b/packages/auditor-backoffice-ui/contrib/po2ts
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Convert a <lang>.po file into a JavaScript / TypeScript expression.
+ */
+
+const po2json = require("po2json");
+
+const filename = process.argv[2];
+
+if (!filename) {
+ console.error("error: missing filename");
+ process.exit(1);
+}
+
+const m = filename.match(/([a-zA-Z0-9-_]+).po/);
+
+if (!m) {
+ console.error("error: unexpected filename (expected <lang>.po)");
+ process.exit(1);
+}
+
+const lang = m[1];
+const pojson = po2json.parseFileSync(filename, { format: "jed1.x", fuzzy: true });
+const s =
+ "strings['" + lang + "'] = " + JSON.stringify(pojson, null, " ") + ";\n";
+console.log(s);
diff --git a/packages/auditor-backoffice-ui/copyleft-header.js b/packages/auditor-backoffice-ui/copyleft-header.js
new file mode 100644
index 000000000..2589fdc92
--- /dev/null
+++ b/packages/auditor-backoffice-ui/copyleft-header.js
@@ -0,0 +1,15 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
diff --git a/packages/auditor-backoffice-ui/dev.mjs b/packages/auditor-backoffice-ui/dev.mjs
new file mode 100755
index 000000000..14d5737de
--- /dev/null
+++ b/packages/auditor-backoffice-ui/dev.mjs
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { serve } from "@gnu-taler/web-util/node";
+import { initializeDev } from "@gnu-taler/web-util/build";
+
+const devEntryPoints = ["src/stories.tsx", "src/index.tsx"];
+
+const build = initializeDev({
+ type: "development",
+ source: {
+ js: devEntryPoints,
+ assets: [{base:"src",files:["src/index.html"]}],
+ },
+ css: "sass",
+ destination: "./dist/dev",
+});
+
+await build();
+
+serve({
+ folder: "./dist/dev",
+ port: 8080,
+ source: "./src",
+ onSourceUpdate: build,
+});
diff --git a/packages/auditor-backoffice-ui/package.json b/packages/auditor-backoffice-ui/package.json
new file mode 100644
index 000000000..1f9fbb15c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/package.json
@@ -0,0 +1,84 @@
+{
+ "private": true,
+ "name": "@gnu-taler/auditor-backoffice-ui",
+ "version": "0.10.6",
+ "license": "AGPL-3.0-or-later",
+ "type": "module",
+ "scripts": {
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo",
+ "build": "./build.mjs",
+ "check": "tsc",
+ "compile": "tsc && ./build.mjs",
+ "dev": "preact watch --port ${PORT:=8080} --no-sw --no-esm",
+ "test": "./test.mjs && mocha --require source-map-support/register 'dist/**/*.test.js' 'dist/**/test.js'",
+ "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
+ "i18n:extract": "pogen extract",
+ "i18n:merge": "pogen merge",
+ "i18n:emit": "pogen emit",
+ "i18n": "pnpm i18n:extract && pnpm i18n:merge && pnpm i18n:emit",
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "pretty": "prettier --write src"
+ },
+ "eslintConfig": {
+ "plugins": [
+ "header"
+ ],
+ "rules": {
+ "header/header": [
+ 2,
+ "copyleft-header.js"
+ ]
+ },
+ "extends": [
+ "prettier"
+ ]
+ },
+ "dependencies": {
+ "@gnu-taler/taler-util": "workspace:*",
+ "@gnu-taler/web-util": "workspace:*",
+ "date-fns": "2.29.3",
+ "history": "4.10.1",
+ "jed": "1.1.1",
+ "preact": "10.11.3",
+ "preact-router": "3.2.1",
+ "qrcode-generator": "1.4.4",
+ "swr": "2.2.2",
+ "yup": "^0.32.9"
+ },
+ "devDependencies": {
+ "@creativebulma/bulma-tooltip": "^1.2.0",
+ "@gnu-taler/pogen": "^0.0.5",
+ "@types/chai": "^4.3.0",
+ "@types/history": "^4.7.8",
+ "@types/mocha": "^8.2.3",
+ "@types/node": "^18.11.17",
+ "@typescript-eslint/eslint-plugin": "^4.22.0",
+ "@typescript-eslint/parser": "^4.22.0",
+ "base64-inline-loader": "^1.1.1",
+ "bulma": "^0.9.2",
+ "bulma-checkbox": "^1.1.1",
+ "bulma-radio": "^1.1.1",
+ "bulma-responsive-tables": "^1.2.3",
+ "bulma-switch-control": "^1.1.1",
+ "bulma-timeline": "^3.0.4",
+ "bulma-upload-control": "^1.2.0",
+ "chai": "^4.3.6",
+ "dotenv": "^8.2.0",
+ "eslint": "^7.25.0",
+ "eslint-config-preact": "^1.1.4",
+ "eslint-plugin-header": "^3.1.1",
+ "html-webpack-inline-chunk-plugin": "^1.1.1",
+ "html-webpack-inline-source-plugin": "0.0.10",
+ "html-webpack-skip-assets-plugin": "^1.0.1",
+ "inline-chunk-html-plugin": "^1.1.1",
+ "mocha": "^9.2.0",
+ "preact-render-to-string": "^5.2.6",
+ "sass": "1.56.1",
+ "source-map-support": "^0.5.21",
+ "typedoc": "^0.25.4",
+ "typescript": "5.3.3"
+ },
+ "pogen": {
+ "domain": "taler-auditor-backoffice"
+ }
+}
diff --git a/packages/auditor-backoffice-ui/preact.config.js b/packages/auditor-backoffice-ui/preact.config.js
new file mode 100644
index 000000000..9b65d3ec7
--- /dev/null
+++ b/packages/auditor-backoffice-ui/preact.config.js
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+
+import { DefinePlugin } from 'webpack';
+
+import pack from './package.json';
+import * as cp from 'child_process';
+
+const commitHash = cp.execSync('git rev-parse --short HEAD').toString();
+
+export default {
+ webpack(config, env, helpers) {
+ // ensure that process.env will not be undefined on runtime
+ config.node.process = 'mock'
+
+ // add __VERSION__ to be use in the html
+ config.plugins.push(
+ new DefinePlugin({
+ 'process.env.__VERSION__': JSON.stringify(env.isProd ? pack.version : `dev-${commitHash}`) ,
+ }),
+ );
+
+ // suddenly getting out of memory error from build process, error below [1]
+ // FIXME: remove preact-cli, use rollup
+ let { index } = helpers.getPluginsByName(config, 'WebpackFixStyleOnlyEntriesPlugin')[0]
+ config.plugins.splice(index, 1)
+ }
+}
+
+
+
+/* [1] from this error decided to remove plugin 'webpack-fix-style-only-entries
+ leaving this error for future reference
+
+
+<--- Last few GCs --->
+
+[32479:0x2e01870] 19969 ms: Mark-sweep 1869.4 (1950.2) -> 1443.1 (1504.1) MB, 497.5 / 0.0 ms (average mu = 0.631, current mu = 0.455) allocation failure scavenge might not succeed
+[32479:0x2e01870] 21907 ms: Mark-sweep 2016.9 (2077.9) -> 1628.6 (1681.4) MB, 1596.0 / 0.0 ms (average mu = 0.354, current mu = 0.176) allocation failure scavenge might not succeed
+
+<--- JS stacktrace --->
+
+==== JS stack trace =========================================
+
+ 0: ExitFrame [pc: 0x13cf099]
+Security context: 0x2f4ca66c08d1 <JSObject>
+ 1: /* anonymous * / [0x35d05555b4b9] [...path/merchant-backoffice/node_modules/.pnpm/webpack-fix-style-only-entries@0.5.2/node_modules/webpack-fix-style-only-entries/index.js:~80] [pc=0x2145e699d1a4](this=0x1149465410e9 <GlobalObject Object map = 0xff481b5b5f9>,0x047e52e36a49 <Dependency map = 0x1ed1fe41cd19>)
+ 2: arguments adaptor frame: 3...
+
+FATAL ERROR: invalid array length Allocation failed - JavaScript heap out of memory
+
+*/ \ No newline at end of file
diff --git a/packages/auditor-backoffice-ui/preact.single-config.js b/packages/auditor-backoffice-ui/preact.single-config.js
new file mode 100644
index 000000000..849269d6e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/preact.single-config.js
@@ -0,0 +1,62 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+
+import defaultConfig from './preact.config'
+
+export default {
+ webpack(config, env, helpers, options) {
+ defaultConfig.webpack(config, env, helpers, options)
+
+ //1. check no file is under /routers or /component/{routers,async} to prevent async components
+ // https://github.com/preactjs/preact-cli#route-based-code-splitting
+
+ //2. remove devtools to prevent sourcemaps
+ config.devtool = false
+
+ //3. change assetLoader to load assets inline
+ const loaders = helpers.getLoaders(config)
+ const assetsLoader = loaders.find(lo => lo.rule.test.test('something.woff'))
+ if (assetsLoader) {
+ assetsLoader.rule.use = 'base64-inline-loader'
+ assetsLoader.rule.loader = undefined
+ }
+
+ //4. remove critters
+ //critters remove the css bundle from htmlWebpackPlugin.files.css
+ //for now, pushing all the content into the html is enough
+ const crittersWrapper = helpers.getPluginsByName(config, 'Critters')
+ if (crittersWrapper && crittersWrapper.length > 0) {
+ const [{ index }] = crittersWrapper
+ config.plugins.splice(index, 1)
+ }
+
+ //5. remove favicon from src/assets
+
+ //6. remove performance hints since we now that this is going to be big
+ if (config.performance) {
+ config.performance.hints = false
+ }
+
+ //7. template.html should have a favicon and add js/css content
+
+ //last, after building remove the mysterious link to stylesheet with remove-link-stylesheet.sh
+ }
+}
diff --git a/packages/auditor-backoffice-ui/remove-link-stylesheet.sh b/packages/auditor-backoffice-ui/remove-link-stylesheet.sh
new file mode 100644
index 000000000..fdf8f241c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/remove-link-stylesheet.sh
@@ -0,0 +1,8 @@
+# This script has been placed in the public domain.
+
+FILE=$(ls single/bundle.*.css)
+BUNDLE=${FILE#single}
+grep -q '<link href="'$BUNDLE'" rel="stylesheet">' single/index.html || { echo bundle $BUNDLE not found in index.html; exit 1; }
+echo -n Removing link from index.html ...
+sed 's_<link href="'$BUNDLE'" rel="stylesheet">__' -i single/index.html
+echo done
diff --git a/packages/auditor-backoffice-ui/src/AdminRoutes.tsx b/packages/auditor-backoffice-ui/src/AdminRoutes.tsx
new file mode 100644
index 000000000..91dec09b0
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/AdminRoutes.tsx
@@ -0,0 +1,53 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { Router, route, Route } from "preact-router";
+import InstanceCreatePage from "./paths/admin/create/index.js";
+import InstanceListPage from "./paths/admin/list/index.js";
+
+export enum AdminPaths {
+ list_instances = "/instances",
+ new_instance = "/instance/new",
+}
+
+export function AdminRoutes(): VNode {
+ return (
+ <Router>
+ <Route
+ path={AdminPaths.list_instances}
+ component={InstanceListPage}
+ onCreate={() => {
+ route(AdminPaths.new_instance);
+ }}
+ onUpdate={(id: string): void => {
+ route(`/instance/${id}/update`);
+ }}
+ />
+
+ <Route
+ path={AdminPaths.new_instance}
+ component={InstanceCreatePage}
+ onBack={() => route(AdminPaths.list_instances)}
+ onConfirm={() => {
+ // route(AdminPaths.list_instances);
+ }}
+
+ // onError={(error: any) => {
+ // }}
+ />
+ </Router>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/Application.tsx b/packages/auditor-backoffice-ui/src/Application.tsx
new file mode 100644
index 000000000..3e5cfc273
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/Application.tsx
@@ -0,0 +1,165 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, LibtoolVersion } from "@gnu-taler/taler-util";
+import {
+ ErrorType,
+ TranslationProvider,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useMemo } from "preact/hooks";
+import { ApplicationReadyRoutes } from "./ApplicationReadyRoutes.js";
+import { Loading } from "./components/exception/loading.js";
+import {
+ NotConnectedAppMenu,
+ NotificationCard
+} from "./components/menu/index.js";
+import {
+ BackendContextProvider
+} from "./context/backend.js";
+import { ConfigContextProvider } from "./context/config.js";
+import { useBackendConfig } from "./hooks/backend.js";
+import { strings } from "./i18n/strings.js";
+
+export function Application(): VNode {
+ return (
+ <BackendContextProvider>
+ <TranslationProvider source={strings}>
+ <ApplicationStatusRoutes />
+ </TranslationProvider>
+ </BackendContextProvider>
+ );
+}
+
+/**
+ * Check connection testing against /config
+ *
+ * @returns
+ */
+function ApplicationStatusRoutes(): VNode {
+ const result = useBackendConfig();
+ const { i18n } = useTranslationContext();
+
+ const { currency, version } = result.ok && result.data
+ ? result.data
+ : { currency: "unknown", version: "unknown" };
+ const ctx = useMemo(() => ({ currency, version }), [currency, version]);
+
+ if (!result.ok) {
+ if (result.loading) return <Loading />;
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ ) {
+ return (
+ <Fragment>
+ <NotConnectedAppMenu title="Login" />
+ <NotificationCard
+ notification={{
+ message: i18n.str`Checking the /config endpoint got authorization error`,
+ type: "ERROR",
+ description: `The /config endpoint of the backend server should be accessible`,
+ }}
+ />
+ </Fragment>
+ );
+ }
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ ) {
+ return (
+ <Fragment>
+ <NotConnectedAppMenu title="Error" />
+ <NotificationCard
+ notification={{
+ message: i18n.str`Could not find /config endpoint on this URL`,
+ type: "ERROR",
+ description: `Check the URL or contact the system administrator.`,
+ }}
+ />
+ </Fragment>
+ );
+ }
+ if (result.type === ErrorType.SERVER) {
+ <Fragment>
+ <NotConnectedAppMenu title="Error" />
+ <NotificationCard
+ notification={{
+ message: i18n.str`Server response with an error code`,
+ type: "ERROR",
+ description: i18n.str`Got message "${result.message}" from ${result.info?.url}`,
+ }}
+ />
+ </Fragment>;
+ }
+ if (result.type === ErrorType.UNREADABLE) {
+ <Fragment>
+ <NotConnectedAppMenu title="Error" />
+ <NotificationCard
+ notification={{
+ message: i18n.str`Response from server is unreadable, http status: ${result.status}`,
+ type: "ERROR",
+ description: i18n.str`Got message "${result.message}" from ${result.info?.url}`,
+ }}
+ />
+ </Fragment>;
+ }
+ return (
+ <Fragment>
+ <NotConnectedAppMenu title="Error" />
+ <NotificationCard
+ notification={{
+ message: i18n.str`Unexpected Error`,
+ type: "ERROR",
+ description: i18n.str`Got message "${result.message}" from ${result.info?.url}`,
+ }}
+ />
+ </Fragment>
+ );
+ }
+
+ const SUPPORTED_VERSION = "18:0:1"
+ if (result.data && !LibtoolVersion.compare(
+ SUPPORTED_VERSION,
+ result.data.version,
+ )?.compatible) {
+ return <Fragment>
+ <NotConnectedAppMenu title="Error" />
+ <NotificationCard
+ notification={{
+ message: i18n.str`Incompatible version`,
+ type: "ERROR",
+ description: i18n.str`Merchant backend server version ${result.data.version} is not compatible with the supported version ${SUPPORTED_VERSION}`,
+ }}
+ />
+ </Fragment>
+ }
+
+ return (
+ <div class="has-navbar-fixed-top">
+ <ConfigContextProvider value={ctx}>
+ <ApplicationReadyRoutes />
+ </ConfigContextProvider>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/ApplicationReadyRoutes.tsx b/packages/auditor-backoffice-ui/src/ApplicationReadyRoutes.tsx
new file mode 100644
index 000000000..414eee39d
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/ApplicationReadyRoutes.tsx
@@ -0,0 +1,175 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { createHashHistory } from "history";
+import { Fragment, VNode, h } from "preact";
+import { Route, Router, route } from "preact-router";
+import { useState } from "preact/hooks";
+import { InstanceRoutes } from "./InstanceRoutes.js";
+import {
+ NotConnectedAppMenu,
+ NotYetReadyAppMenu,
+ NotificationCard,
+} from "./components/menu/index.js";
+import { useBackendContext } from "./context/backend.js";
+import { LoginToken } from "./declaration.js";
+import { useBackendInstancesTestForAdmin } from "./hooks/backend.js";
+import { LoginPage } from "./paths/login/index.js";
+import { Settings } from "./paths/settings/index.js";
+import { INSTANCE_ID_LOOKUP } from "./utils/constants.js";
+
+/**
+ * Check if admin against /management/instances
+ * @returns
+ */
+export function ApplicationReadyRoutes(): VNode {
+ const { i18n } = useTranslationContext();
+ const [unauthorized, setUnauthorized] = useState(false)
+ const {
+ url: backendURL,
+ updateToken,
+ alreadyTriedLogin,
+ } = useBackendContext();
+
+ function updateLoginStatus(token: LoginToken | undefined) {
+ updateToken(token)
+ setUnauthorized(false)
+ }
+
+ const result = useBackendInstancesTestForAdmin();
+
+ const clearTokenAndGoToRoot = () => {
+ route("/");
+ };
+ const [showSettings, setShowSettings] = useState(false)
+ const unauthorizedAdmin = !result.loading
+ && !result.ok
+ && result.type === ErrorType.CLIENT
+ && result.status === HttpStatusCode.Unauthorized;
+
+ if (!alreadyTriedLogin && !result.ok) {
+ return (
+ <Fragment>
+ <NotConnectedAppMenu title="Welcome!" />
+ <LoginPage onConfirm={updateToken} />
+ </Fragment>
+ );
+ }
+
+ if (showSettings) {
+ return <Fragment>
+ <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />
+ <Settings onClose={() => setShowSettings(false)} />
+ </Fragment>
+ }
+
+ if (result.loading) {
+ return <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Loading..." isPasswordOk={false} />;
+ }
+
+ let admin = result.ok || unauthorizedAdmin;
+ let instanceNameByBackendURL: string | undefined;
+
+ if (!admin) {
+ // * the testing against admin endpoint failed and it's not
+ // an authorization problem
+ // * merchant backend will return this SPA under the main
+ // endpoint or /instance/<id> endpoint
+ // => trying to infer the instance id
+ const path = new URL(backendURL).pathname;
+ const match = INSTANCE_ID_LOOKUP.exec(path);
+ if (!match || !match[1]) {
+ // this should be rare because
+ // query to /config is ok but the URL
+ // does not match our pattern
+ return (
+ <Fragment>
+ <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Error" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />
+ <NotificationCard
+ notification={{
+ message: i18n.str`Couldn't access the server.`,
+ description: i18n.str`Could not infer instance id from url ${backendURL}`,
+ type: "ERROR",
+ }}
+ />
+ {/* <ConnectionPage onConfirm={changeBackend} /> */}
+ </Fragment>
+ );
+ }
+
+ instanceNameByBackendURL = match[1];
+ }
+
+ if (unauthorized || unauthorizedAdmin) {
+ return <Fragment>
+ <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />
+ <NotificationCard
+ notification={{
+ message: i18n.str`Access denied`,
+ description: i18n.str`Check your token is valid`,
+ type: "ERROR",
+ }}
+ />
+ <LoginPage onConfirm={updateLoginStatus} />
+ </Fragment>
+ }
+
+ const history = createHashHistory();
+ return (
+ <Router history={history}>
+ <Route
+ default
+ component={DefaultMainRoute}
+ admin={admin}
+ onUnauthorized={() => setUnauthorized(true)}
+ onLoginPass={() => {
+ setUnauthorized(false)
+ }}
+ instanceNameByBackendURL={instanceNameByBackendURL}
+ />
+ </Router>
+ );
+}
+
+function DefaultMainRoute({
+ instance,
+ admin,
+ onUnauthorized,
+ onLoginPass,
+ instanceNameByBackendURL,
+ url, //from preact-router
+}: any): VNode {
+ const [instanceName, setInstanceName] = useState(
+ instanceNameByBackendURL || instance || "default",
+ );
+
+ return (
+ <InstanceRoutes
+ admin={admin}
+ path={url}
+ onUnauthorized={onUnauthorized}
+ onLoginPass={onLoginPass}
+ id={instanceName}
+ setInstanceName={setInstanceName}
+ />
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/InstanceRoutes.tsx b/packages/auditor-backoffice-ui/src/InstanceRoutes.tsx
new file mode 100644
index 000000000..163438654
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/InstanceRoutes.tsx
@@ -0,0 +1,485 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ * @author Nic Eigel
+ */
+
+import {
+ useTranslationContext,
+ HttpError,
+ ErrorType,
+} from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, FunctionComponent, h, VNode } from "preact";
+import { Route, route, Router } from "preact-router";
+import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
+import { Loading } from "./components/exception/loading.js";
+import { Menu, NotificationCard } from "./components/menu/index.js";
+import { useBackendContext } from "./context/backend.js";
+import { InstanceContextProvider } from "./context/instance.js";
+import {
+ useBackendDefaultToken,
+ useBackendInstanceToken,
+ useSimpleLocalStorage,
+} from "./hooks/index.js";
+import { useInstanceKYCDetails } from "./hooks/instance.js";
+import InstanceCreatePage from "./paths/admin/create/index.js";
+import InstanceListPage from "./paths/admin/list/index.js";
+import TokenPage from "./paths/instance/token/index.js";
+import ListKYCPage from "./paths/instance/kyc/list/index.js";
+import OrderCreatePage from "./paths/instance/orders/create/index.js";
+import OrderDetailsPage from "./paths/instance/orders/details/index.js";
+import OrderListPage from "./paths/instance/orders/list/index.js";
+import DepositConfirmationCreatePage from "./paths/instance/deposit_confirmations/create/index.js";
+import DepositConfirmationListPage from "./paths/instance/deposit_confirmations/list/index.js";
+import DepositConfirmationUpdatePage from "./paths/instance/deposit_confirmations/update/index.js";
+import ProductCreatePage from "./paths/instance/products/create/index.js";
+import ProductListPage from "./paths/instance/products/list/index.js";
+import ProductUpdatePage from "./paths/instance/products/update/index.js";
+import BankAccountCreatePage from "./paths/instance/accounts/create/index.js";
+import BankAccountListPage from "./paths/instance/accounts/list/index.js";
+import BankAccountUpdatePage from "./paths/instance/accounts/update/index.js";
+import ReservesCreatePage from "./paths/instance/reserves/create/index.js";
+import ReservesDetailsPage from "./paths/instance/reserves/details/index.js";
+import ReservesListPage from "./paths/instance/reserves/list/index.js";
+import TemplateCreatePage from "./paths/instance/templates/create/index.js";
+import TemplateUsePage from "./paths/instance/templates/use/index.js";
+import TemplateQrPage from "./paths/instance/templates/qr/index.js";
+import TemplateListPage from "./paths/instance/templates/list/index.js";
+import TemplateUpdatePage from "./paths/instance/templates/update/index.js";
+import WebhookCreatePage from "./paths/instance/webhooks/create/index.js";
+import WebhookListPage from "./paths/instance/webhooks/list/index.js";
+import WebhookUpdatePage from "./paths/instance/webhooks/update/index.js";
+import ValidatorCreatePage from "./paths/instance/otp_devices/create/index.js";
+import ValidatorListPage from "./paths/instance/otp_devices/list/index.js";
+import ValidatorUpdatePage from "./paths/instance/otp_devices/update/index.js";
+import TransferCreatePage from "./paths/instance/transfers/create/index.js";
+import TransferListPage from "./paths/instance/transfers/list/index.js";
+import InstanceUpdatePage, {
+ AdminUpdate as InstanceAdminUpdatePage,
+ Props as InstanceUpdatePageProps,
+} from "./paths/instance/update/index.js";
+import { LoginPage } from "./paths/login/index.js";
+import NotFoundPage from "./paths/notfound/index.js";
+import { Notification } from "./utils/types.js";
+import { LoginToken, MerchantBackend } from "./declaration.js";
+import { Settings } from "./paths/settings/index.js";
+import { dateFormatForSettings, useSettings } from "./hooks/useSettings.js";
+
+export enum InstancePaths {
+ error = "/error",
+ settings = "/settings",
+ token = "/token",
+
+ inventory_list = "/inventory",
+ inventory_update = "/inventory/:pid/update",
+ inventory_new = "/inventory/new",
+
+ deposit_confirmation_list = "/deposit-confirmation",
+ deposit_confirmation_update = "/deposit-confirmation/:pid/update",
+ deposit_confirmation_new = "/deposit-confirmation/new",
+
+ interface = "/interface",
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-function
+const noop = () => { };
+
+export enum AdminPaths {
+ list_instances = "/instances",
+ new_instance = "/instance/new",
+ update_instance = "/instance/:id/update",
+}
+
+export interface Props {
+ id: string;
+ admin?: boolean;
+ path: string;
+ onUnauthorized: () => void;
+ onLoginPass: () => void;
+ setInstanceName: (s: string) => void;
+}
+
+export function InstanceRoutes({
+ id,
+ admin,
+ path,
+ // onUnauthorized,
+ onLoginPass,
+ setInstanceName,
+}: Props): VNode {
+ const [defaultToken, updateDefaultToken] = useBackendDefaultToken();
+ const [token, updateToken] = useBackendInstanceToken(id);
+ const { i18n } = useTranslationContext();
+
+ type GlobalNotifState = (Notification & { to: string }) | undefined;
+ const [globalNotification, setGlobalNotification] =
+ useState<GlobalNotifState>(undefined);
+
+ const changeToken = (token?: LoginToken) => {
+ if (admin) {
+ updateToken(token);
+ } else {
+ updateDefaultToken(token);
+ }
+ onLoginPass()
+ };
+ // const updateLoginStatus = (url: string, token?: string) => {
+ // changeToken(token);
+ // };
+
+ const value = useMemo(
+ () => ({ id, token, admin, changeToken }),
+ [id, token, admin],
+ );
+
+ function ServerErrorRedirectTo(to: InstancePaths | AdminPaths) {
+ return function ServerErrorRedirectToImpl(
+ error: HttpError<MerchantBackend.ErrorDetail>,
+ ) {
+ if (error.type === ErrorType.TIMEOUT) {
+ setGlobalNotification({
+ message: i18n.str`The request to the backend take too long and was cancelled`,
+ description: i18n.str`Diagnostic from ${error.info.url} is "${error.message}"`,
+ type: "ERROR",
+ to,
+ });
+ } else {
+ setGlobalNotification({
+ message: i18n.str`The backend reported a problem: HTTP status #${error.status}`,
+ description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`,
+ details:
+ error.type === ErrorType.CLIENT || error.type === ErrorType.SERVER
+ ? error.payload.detail
+ : undefined,
+ type: "ERROR",
+ to,
+ });
+ }
+ return <Redirect to={to} />;
+ };
+ }
+
+ // const LoginPageAccessDeniend = onUnauthorized
+ const LoginPageAccessDenied = () => {
+ return <Fragment>
+ <NotificationCard
+ notification={{
+ message: i18n.str`Access denied`,
+ description: i18n.str`Session expired or password changed.`,
+ type: "ERROR",
+ }}
+ />
+ <LoginPage onConfirm={changeToken} />
+ </Fragment>
+
+ }
+
+ function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) {
+ return function IfAdminCreateDefaultOrImpl(props?: T) {
+ if (admin && id === "default") {
+ return (
+ <Fragment>
+ <NotificationCard
+ notification={{
+ message: i18n.str`No 'default' instance configured yet.`,
+ description: i18n.str`Create a 'default' instance to begin using the merchant backoffice.`,
+ type: "INFO",
+ }}
+ />
+ </Fragment>
+ );
+ }
+ if (props) {
+ return <Next {...props} />;
+ }
+ return <Next />;
+ };
+ }
+
+ const clearTokenAndGoToRoot = () => {
+ route("/");
+ // clear all tokens
+ updateToken(undefined)
+ updateDefaultToken(undefined)
+ };
+
+ return (
+ <InstanceContextProvider value={value}>
+ <Menu
+ instance={id}
+ admin={admin}
+ onShowSettings={() => {
+ route(InstancePaths.interface)
+ }}
+ path={path}
+ onLogout={clearTokenAndGoToRoot}
+ setInstanceName={setInstanceName}
+ isPasswordOk={defaultToken !== undefined}
+ />
+ <KycBanner />
+ <NotificationCard notification={globalNotification} />
+
+ <Router
+ onChange={(e) => {
+ const movingOutFromNotification =
+ globalNotification && e.url !== globalNotification.to;
+ if (movingOutFromNotification) {
+ setGlobalNotification(undefined);
+ }
+ }}
+ >
+ {/**
+ * Admin pages
+ */}
+ {admin && (
+ <Route
+ path={AdminPaths.list_instances}
+ component={InstanceListPage}
+ onCreate={() => {
+ route(AdminPaths.new_instance);
+ }}
+ onUpdate={(id: string): void => {
+ route(`/instance/${id}/update`);
+ }}
+ setInstanceName={setInstanceName}
+ onUnauthorized={LoginPageAccessDenied}
+ onLoadError={ServerErrorRedirectTo(InstancePaths.error)}
+ />
+ )}
+ {admin && (
+ <Route
+ path={AdminPaths.update_instance}
+ component={AdminInstanceUpdatePage}
+ onBack={() => route(AdminPaths.list_instances)}
+ onConfirm={() => {
+ route(AdminPaths.list_instances);
+ }}
+ onUpdateError={ServerErrorRedirectTo(AdminPaths.list_instances)}
+ onLoadError={ServerErrorRedirectTo(AdminPaths.list_instances)}
+ onNotFound={NotFoundPage}
+ />
+ )}
+ {/**
+ * Update instance page
+ */}
+ <Route
+ path={InstancePaths.settings}
+ component={InstanceUpdatePage}
+ onBack={() => {
+ route(`/`);
+ }}
+ onConfirm={() => {
+ route(`/`);
+ }}
+ onUpdateError={noop}
+ onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
+ onUnauthorized={LoginPageAccessDenied}
+ onLoadError={ServerErrorRedirectTo(InstancePaths.error)}
+ />
+ {/**
+ * Inventory pages
+ */}
+ <Route
+ path={InstancePaths.inventory_list}
+ component={ProductListPage}
+ onUnauthorized={LoginPageAccessDenied}
+ onLoadError={ServerErrorRedirectTo(InstancePaths.settings)}
+ onCreate={() => {
+ route(InstancePaths.inventory_new);
+ }}
+ onSelect={(id: string) => {
+ route(InstancePaths.inventory_update.replace(":pid", id));
+ }}
+ onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
+ />
+ <Route
+ path={InstancePaths.inventory_update}
+ component={ProductUpdatePage}
+ onUnauthorized={LoginPageAccessDenied}
+ onLoadError={ServerErrorRedirectTo(InstancePaths.inventory_list)}
+ onConfirm={() => {
+ route(InstancePaths.inventory_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.inventory_list);
+ }}
+ onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
+ />
+ <Route
+ path={InstancePaths.inventory_new}
+ component={ProductCreatePage}
+ onConfirm={() => {
+ route(InstancePaths.inventory_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.inventory_list);
+ }}
+ />
+ {/**
+ * Deposit confirmation pages
+ */}
+ <Route
+ path={InstancePaths.deposit_confirmation_list}
+ component={DepositConfirmationListPage}
+ onUnauthorized={LoginPageAccessDenied}
+ onLoadError={ServerErrorRedirectTo(InstancePaths.settings)}
+ onCreate={() => {
+ route(InstancePaths.deposit_confirmation_new);
+ }}
+ onSelect={(id: string) => {
+ route(InstancePaths.deposit_confirmation_update.replace(":pid", id));
+ }}
+ onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
+ />
+ <Route
+ path={InstancePaths.deposit_confirmation_update}
+ component={DepositConfirmationUpdatePage}
+ onUnauthorized={LoginPageAccessDenied}
+ onLoadError={ServerErrorRedirectTo(InstancePaths.deposit_confirmation_list)}
+ onConfirm={() => {
+ route(InstancePaths.deposit_confirmation_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.deposit_confirmation_list);
+ }}
+ onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
+ />
+ <Route
+ path={InstancePaths.deposit_confirmation_new}
+ component={DepositConfirmationCreatePage}
+ onConfirm={() => {
+ route(InstancePaths.deposit_confirmation_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.deposit_confirmation_list);
+ }}
+ />
+ <Route path={InstancePaths.interface} component={Settings} />
+ {/**
+ * Example pages
+ */}
+ <Route path="/loading" component={Loading} />
+ <Route default component={NotFoundPage} />
+ </Router>
+ </InstanceContextProvider>
+ );
+}
+
+export function Redirect({ to }: { to: string }): null {
+ useEffect(() => {
+ route(to, true);
+ });
+ return null;
+}
+
+function AdminInstanceUpdatePage({
+ id,
+ ...rest
+}: { id: string } & InstanceUpdatePageProps): VNode {
+ const [token, changeToken] = useBackendInstanceToken(id);
+ const updateLoginStatus = (token?: LoginToken): void => {
+ changeToken(token);
+ };
+ const value = useMemo(
+ () => ({ id, token, admin: true, changeToken }),
+ [id, token],
+ );
+ const { i18n } = useTranslationContext();
+
+ return (
+ <InstanceContextProvider value={value}>
+ <InstanceAdminUpdatePage
+ {...rest}
+ instanceId={id}
+ onLoadError={(error: HttpError<MerchantBackend.ErrorDetail>) => {
+ const notif =
+ error.type === ErrorType.TIMEOUT
+ ? {
+ message: i18n.str`The request to the backend take too long and was cancelled`,
+ description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`,
+ type: "ERROR" as const,
+ }
+ : {
+ message: i18n.str`The backend reported a problem: HTTP status #${error.status}`,
+ description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`,
+ details:
+ error.type === ErrorType.CLIENT ||
+ error.type === ErrorType.SERVER
+ ? error.payload.detail
+ : undefined,
+ type: "ERROR" as const,
+ };
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <LoginPage onConfirm={updateLoginStatus} />
+ </Fragment>
+ );
+ }}
+ onUnauthorized={() => {
+ return (
+ <Fragment>
+ <NotificationCard
+ notification={{
+ message: i18n.str`Access denied`,
+ description: i18n.str`The access token provided is invalid`,
+ type: "ERROR",
+ }}
+ />
+ <LoginPage onConfirm={updateLoginStatus} />
+ </Fragment>
+ );
+ }}
+ />
+ </InstanceContextProvider>
+ );
+}
+
+function KycBanner(): VNode {
+ const kycStatus = useInstanceKYCDetails();
+ const { i18n } = useTranslationContext();
+ const [settings] = useSettings();
+ const today = format(new Date(), dateFormatForSettings(settings));
+ const [lastHide, setLastHide] = useSimpleLocalStorage("kyc-last-hide");
+ const hasBeenHidden = today === lastHide;
+ const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect";
+ if (hasBeenHidden || !needsToBeShown) return <Fragment />;
+ return (
+ <NotificationCard
+ notification={{
+ type: "WARN",
+ message: "KYC verification needed",
+ description: (
+ <div>
+ <p>
+ Some transfer are on hold until a KYC process is completed. Go to
+ the KYC section in the left panel for more information
+ </p>
+ <div class="buttons is-right">
+ <button class="button" onClick={() => setLastHide(today)}>
+ <i18n.Translate>Hide for today</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ ),
+ }}
+ />
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/assets/empty.png b/packages/auditor-backoffice-ui/src/assets/empty.png
new file mode 100644
index 000000000..5120d3138
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/assets/empty.png
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/assets/icons/android-chrome-192x192.png b/packages/auditor-backoffice-ui/src/assets/icons/android-chrome-192x192.png
new file mode 100644
index 000000000..93ebe2e2c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/assets/icons/android-chrome-192x192.png
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/assets/icons/android-chrome-512x512.png b/packages/auditor-backoffice-ui/src/assets/icons/android-chrome-512x512.png
new file mode 100644
index 000000000..52d1623ea
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/assets/icons/android-chrome-512x512.png
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/assets/icons/apple-touch-icon.png b/packages/auditor-backoffice-ui/src/assets/icons/apple-touch-icon.png
new file mode 100644
index 000000000..254e4bb4d
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/assets/icons/apple-touch-icon.png
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/assets/icons/favicon-16x16.png b/packages/auditor-backoffice-ui/src/assets/icons/favicon-16x16.png
new file mode 100644
index 000000000..e81177dcb
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/assets/icons/favicon-16x16.png
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/assets/icons/favicon-32x32.png b/packages/auditor-backoffice-ui/src/assets/icons/favicon-32x32.png
new file mode 100644
index 000000000..40e9b5b47
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/assets/icons/favicon-32x32.png
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/assets/icons/languageicon.svg b/packages/auditor-backoffice-ui/src/assets/icons/languageicon.svg
new file mode 100644
index 000000000..22d58da65
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/assets/icons/languageicon.svg
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 2411.2 2794" style="enable-background:new 0 0 2411.2 2794;" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#FFFFFF;}
+ .st1{fill-rule:evenodd;clip-rule:evenodd;}
+ .st2{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
+</style>
+<g id="Layer_2">
+</g>
+<g id="Layer_x5F_1_x5F_1">
+ <g>
+ <polygon points="1204.6,359.2 271.8,30 271.8,2060.1 1204.6,1758.3 "/>
+ <polygon class="st0" points="1182.2,358.1 2150.6,29 2150.6,2059 1182.2,1757.3 "/>
+ <polygon class="st0" points="30,2415.4 1182.2,2031.4 1182.2,357.9 30,742 "/>
+ <polygon points="1707.2,2440.7 1870.5,2709.4 1956.6,2459.8 "/>
+ <g>
+ <path d="M421.7,934.8c-6.1-6,8,49.1,27.6,68.9c34.8,35.1,61.9,39.6,76.4,40.2c32,1.3,71.5-8,94.9-17.8
+ c22.7-9.7,62.4-30,77.5-59.6c3.2-6.3,11.9-17,6.4-43.2c-4.2-20.2-17-27.3-32.7-26.2c-15.7,1.1-63.2,13.7-86.1,20.8
+ c-23,7-70.3,21.4-90.9,25.8C474.3,948.2,429,941.7,421.7,934.8z"/>
+ <path d="M1003.1,1593.7c-9.1-3.3-196.9-81.1-223.6-93.9c-21.8-10.5-75.2-33.1-100.4-43.3c70.8-109.2,115.5-191.6,121.5-204.1
+ c11-23,86-169.6,87.7-178.7c1.7-9.1,3.8-42.9,2.2-51c-1.7-8.2-29.1,7.6-66.4,20.2c-37.4,12.6-108.4,58.8-135.8,64.6
+ c-27.5,5.7-115.5,39.1-160.5,54c-45,14.9-130.2,40.9-165.2,50.4c-35.1,9.5-65.7,10.2-85.3,16.2c0,0,2.6,27.5,7.8,35.7
+ c5.2,8.2,23.7,28.4,45.3,34.1c21.6,5.7,57.3,3.4,73.6-0.3c16.3-3.8,44.4-17.5,48.2-23.6c3.8-6.1-2-24.9,4.5-30.6
+ c6.5-5.6,92.2-25.7,124.6-35.4c32.4-10,156.3-52.6,173.1-50.5c-5.3,17.7-105,215.1-137.1,274c-32.1,58.9-218.6,318-258.3,363.6
+ c-30.1,34.7-103.2,123.5-128.5,143.6c6.4,1.8,51.6-2.1,59.9-7.2c51.3-31.6,136.9-138.1,164.4-170.5
+ c81.9-96,153.8-196.8,210.8-283.4h0.1c11.1,4.6,100.9,77.8,124.4,94c23.4,16.2,115.9,67.8,136,76.4c20,8.7,97.1,44.2,100.3,32.2
+ C1029.4,1668,1012.2,1597.1,1003.1,1593.7z"/>
+ </g>
+ <path class="st1" d="M569,2572c18,11,35,20,54,29c38,19,81,39,122,54c56,21,112,38,168,51c31,7,65,13,98,18c3,0,92,11,110,11h90
+ c35-3,68-5,103-10c28-4,59-9,89-16c22-5,45-10,67-17c21-6,45-14,68-22c15-5,31-12,47-18c13-6,29-13,44-19c18-8,39-19,59-29
+ c16-8,34-18,51-28c13-7,43-30,59-30c18,0,30,16,30,30c0,29-39,38-57,51c-19,13-42,23-62,34c-40,21-81,39-120,54
+ c-51,19-107,37-157,49c-19,4-38,9-57,12c-10,2-114,18-143,18h-132c-35-3-72-7-107-12c-31-5-64-11-95-18c-24-5-50-12-73-19
+ c-40-11-79-25-117-40c-69-26-141-60-209-105c-12-8-13-16-13-25c0-15,11-29,29-29C531,2546,563,2569,569,2572z"/>
+ <path class="st1" d="M1151,2009L61,2372V764l1090-363V2009z M1212,354v1680c-1,5-3,10-7,15c-2,3-6,7-9,8c-25,10-1151,388-1166,388
+ c-12,0-23-8-29-21c0-1-1-2-1-4V739c2-5,3-12,7-16c8-11,22-13,31-16c17-6,1126-378,1142-378C1190,329,1212,336,1212,354z"/>
+ <path class="st1" d="M2120,2017l-907-282V380l907-308V2017z M2181,32v2023c-1,23-17,33-32,33c-13,0-107-32-123-37
+ c-126-39-253-78-378-117c-28-9-57-18-84-27c-24-7-50-15-74-23c-107-33-216-66-323-102c-4-1-14-15-14-18V351c2-5,4-11,9-15
+ c8-9,351-123,486-168c36-13,487-168,501-168C2167,0,2181,13,2181,32z"/>
+ <polygon points="2411.2,2440.7 1199.5,2054.5 1204.6,373.2 2411.2,757.2 "/>
+ <g>
+ <path class="st2" d="M1800.3,1124.6L1681.4,1412l218.6,66.3L1800.3,1124.6z M1729,853.2l156.1,47.3l284.4,1025l-160.3-48.7
+ l-57.6-210.4L1620.2,1566l-71.3,171.4l-160.4-48.7L1729,853.2z"/>
+ </g>
+ </g>
+</g>
+</svg>
diff --git a/packages/auditor-backoffice-ui/src/assets/icons/mstile-150x150.png b/packages/auditor-backoffice-ui/src/assets/icons/mstile-150x150.png
new file mode 100644
index 000000000..9cfb889be
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/assets/icons/mstile-150x150.png
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/assets/logo-2021.svg b/packages/auditor-backoffice-ui/src/assets/logo-2021.svg
new file mode 100644
index 000000000..8c5ff3e5b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/assets/logo-2021.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">
+ <g fill="#0042b3" fill-rule="evenodd" stroke-width=".3">
+ <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />
+ <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />
+ <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />
+ </g>
+ <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />
+</svg> \ No newline at end of file
diff --git a/packages/auditor-backoffice-ui/src/assets/logo.jpeg b/packages/auditor-backoffice-ui/src/assets/logo.jpeg
new file mode 100644
index 000000000..489832f7c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/assets/logo.jpeg
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/components/exception/AsyncButton.tsx b/packages/auditor-backoffice-ui/src/components/exception/AsyncButton.tsx
new file mode 100644
index 000000000..b1fc33877
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/exception/AsyncButton.tsx
@@ -0,0 +1,55 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ComponentChildren, h } from "preact";
+import { LoadingModal } from "../modal/index.js";
+import { useAsync } from "../../hooks/async.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+
+type Props = {
+ children: ComponentChildren;
+ disabled: boolean;
+ onClick?: () => Promise<void>;
+ [rest: string]: any;
+};
+
+export function AsyncButton({ onClick, disabled, children, ...rest }: Props) {
+ const { isSlow, isLoading, request, cancel } = useAsync(onClick);
+ const { i18n } = useTranslationContext();
+ if (isSlow) {
+ return <LoadingModal onCancel={cancel} />;
+ }
+ if (isLoading) {
+ return (
+ <button class="button">
+ <i18n.Translate>Loading...</i18n.Translate>
+ </button>
+ );
+ }
+
+ return (
+ <span {...rest}>
+ <button class="button is-success" onClick={request} disabled={disabled}>
+ {children}
+ </button>
+ </span>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/exception/QR.tsx b/packages/auditor-backoffice-ui/src/components/exception/QR.tsx
new file mode 100644
index 000000000..c9340ea76
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/exception/QR.tsx
@@ -0,0 +1,49 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+import { useEffect, useRef } from "preact/hooks";
+import qrcode from "qrcode-generator";
+
+export function QR({ text }: { text: string }): VNode {
+ const divRef = useRef<HTMLDivElement>(null);
+ useEffect(() => {
+ const qr = qrcode(0, "L");
+ qr.addData(text);
+ qr.make();
+ if (divRef.current) {
+ divRef.current.innerHTML = qr.createSvgTag({
+ scalable: true,
+ });
+ }
+ });
+
+ return (
+ <div
+ style={{
+ width: "100%",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ }}
+ >
+ <div
+ style={{ width: "50%", minWidth: 200, maxWidth: 300 }}
+ ref={divRef}
+ />
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/exception/loading.tsx b/packages/auditor-backoffice-ui/src/components/exception/loading.tsx
new file mode 100644
index 000000000..a043b81eb
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/exception/loading.tsx
@@ -0,0 +1,48 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+
+export function Loading(): VNode {
+ return (
+ <div
+ class="columns is-centered is-vcentered"
+ style={{
+ height: "calc(100% - 3rem)",
+ position: "absolute",
+ width: "100%",
+ }}
+ >
+ <Spinner />
+ </div>
+ );
+}
+
+export function Spinner(): VNode {
+ return (
+ <div class="lds-ring">
+ <div />
+ <div />
+ <div />
+ <div />
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/FormProvider.tsx b/packages/auditor-backoffice-ui/src/components/form/FormProvider.tsx
new file mode 100644
index 000000000..0d53c4d08
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/FormProvider.tsx
@@ -0,0 +1,109 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext, useMemo } from "preact/hooks";
+
+type Updater<S> = (value: (prevState: S) => S) => void;
+
+export interface Props<T> {
+ object?: Partial<T>;
+ errors?: FormErrors<T>;
+ name?: string;
+ valueHandler: Updater<Partial<T>> | null;
+ children: ComponentChildren;
+}
+
+const noUpdater: Updater<Partial<unknown>> = () => (s: unknown) => s;
+
+export function FormProvider<T>({
+ object = {},
+ errors = {},
+ name = "",
+ valueHandler,
+ children,
+}: Props<T>): VNode {
+ const initialObject = useMemo(() => object, []);
+ const value = useMemo<FormType<T>>(
+ () => ({
+ errors,
+ object,
+ initialObject,
+ valueHandler: valueHandler ? valueHandler : noUpdater,
+ name,
+ toStr: {},
+ fromStr: {},
+ }),
+ [errors, object, valueHandler],
+ );
+
+ return (
+ <FormContext.Provider value={value}>
+ <form
+ class="field"
+ onSubmit={(e) => {
+ e.preventDefault();
+ // if (valueHandler) valueHandler(object);
+ }}
+ >
+ {children}
+ </form>
+ </FormContext.Provider>
+ );
+}
+
+export interface FormType<T> {
+ object: Partial<T>;
+ initialObject: Partial<T>;
+ errors: FormErrors<T>;
+ toStr: FormtoStr<T>;
+ name: string;
+ fromStr: FormfromStr<T>;
+ valueHandler: Updater<Partial<T>>;
+}
+
+const FormContext = createContext<FormType<unknown>>(null!);
+
+/**
+ * FIXME:
+ * USE MEMORY EVENTS INSTEAD OF CONTEXT
+ * @deprecated
+ */
+
+export function useFormContext<T>() {
+ return useContext<FormType<T>>(FormContext);
+}
+
+export type FormErrors<T> = {
+ [P in keyof T]?: string | FormErrors<T[P]>;
+};
+
+export type FormtoStr<T> = {
+ [P in keyof T]?: (f?: T[P]) => string;
+};
+
+export type FormfromStr<T> = {
+ [P in keyof T]?: (f: string) => T[P];
+};
+
+export type FormUpdater<T> = {
+ [P in keyof T]?: (f: keyof T) => (v: T[P]) => void;
+};
diff --git a/packages/auditor-backoffice-ui/src/components/form/Input.tsx b/packages/auditor-backoffice-ui/src/components/form/Input.tsx
new file mode 100644
index 000000000..c1ddcb064
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/Input.tsx
@@ -0,0 +1,116 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useField, InputProps } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ inputType?: "text" | "number" | "multiline" | "password";
+ expand?: boolean;
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+ inputExtra?: any;
+ side?: ComponentChildren;
+ children?: ComponentChildren;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+const TextInput = ({ inputType, error, ...rest }: any) =>
+ inputType === "multiline" ? (
+ <textarea
+ {...rest}
+ class={error ? "textarea is-danger" : "textarea"}
+ rows="3"
+ />
+ ) : (
+ <input
+ {...rest}
+ class={error ? "input is-danger" : "input"}
+ type={inputType}
+ />
+ );
+
+export function Input<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ expand,
+ help,
+ children,
+ inputType,
+ inputExtra,
+ side,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p
+ class={
+ expand
+ ? "control is-expanded has-icons-right"
+ : "control has-icons-right"
+ }
+ >
+ <TextInput
+ error={error}
+ {...inputExtra}
+ inputType={inputType}
+ placeholder={placeholder}
+ readonly={readonly}
+ disabled={readonly}
+ name={String(name)}
+ value={toStr(value)}
+ onChange={(e: h.JSX.TargetedEvent<HTMLInputElement>): void =>
+ onChange(fromStr(e.currentTarget.value))
+ }
+ />
+ {help}
+ {children}
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ {side}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputArray.tsx b/packages/auditor-backoffice-ui/src/components/form/InputArray.tsx
new file mode 100644
index 000000000..4ed4c4b28
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputArray.tsx
@@ -0,0 +1,139 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ isValid?: (e: any) => boolean;
+ addonBefore?: string;
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputArray<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ addonBefore,
+ isValid = () => true,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error: formError, value, onChange, required } = useField<T>(name);
+ const [localError, setLocalError] = useState<string | null>(null);
+
+ const error = localError || formError;
+
+ const array: any[] = (value ? value! : []) as any;
+ const [currentValue, setCurrentValue] = useState("");
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <div class="field has-addons">
+ {addonBefore && (
+ <div class="control">
+ <a class="button is-static">{addonBefore}</a>
+ </div>
+ )}
+ <p class="control is-expanded has-icons-right">
+ <input
+ class={error ? "input is-danger" : "input"}
+ type="text"
+ placeholder={placeholder}
+ readonly={readonly}
+ disabled={readonly}
+ name={String(name)}
+ value={currentValue}
+ onChange={(e): void => setCurrentValue(e.currentTarget.value)}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ <p class="control">
+ <button
+ class="button is-info has-tooltip-left"
+ disabled={!currentValue}
+ onClick={(): void => {
+ const v = fromStr(currentValue);
+ if (!isValid(v)) {
+ setLocalError(
+ i18n.str`The value ${v} is invalid for a payment url`,
+ );
+ return;
+ }
+ setLocalError(null);
+ onChange([v, ...array] as any);
+ setCurrentValue("");
+ }}
+ data-tooltip={i18n.str`add element to the list`}
+ >
+ <i18n.Translate>add</i18n.Translate>
+ </button>
+ </p>
+ </div>
+ {help}
+ {error && <p class="help is-danger"> {error} </p>}
+ {array.map((v, i) => (
+ <div key={i} class="tags has-addons mt-3 mb-0">
+ <span
+ class="tag is-medium is-info mb-0"
+ style={{ maxWidth: "90%" }}
+ >
+ {v}
+ </span>
+ <a
+ class="tag is-medium is-danger is-delete mb-0"
+ onClick={() => {
+ onChange(array.filter((f) => f !== v) as any);
+ setCurrentValue(toStr(v));
+ }}
+ />
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputBoolean.tsx b/packages/auditor-backoffice-ui/src/components/form/InputBoolean.tsx
new file mode 100644
index 000000000..f79e16c07
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputBoolean.tsx
@@ -0,0 +1,91 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ name: T;
+ readonly?: boolean;
+ expand?: boolean;
+ threeState?: boolean;
+ toBoolean?: (v?: any) => boolean | undefined;
+ fromBoolean?: (s: boolean | undefined) => any;
+}
+
+const defaultToBoolean = (f?: any): boolean | undefined => f || "";
+const defaultFromBoolean = (v: boolean | undefined): any => v as any;
+
+export function InputBoolean<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ threeState,
+ expand,
+ fromBoolean = defaultFromBoolean,
+ toBoolean = defaultToBoolean,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange } = useField<T>(name);
+
+ const onCheckboxClick = (): void => {
+ const c = toBoolean(value);
+ if (c === false && threeState) return onChange(undefined as any);
+ return onChange(fromBoolean(!c));
+ };
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class={expand ? "control is-expanded" : "control"}>
+ <label class="b-checkbox checkbox">
+ <input
+ type="checkbox"
+ class={toBoolean(value) === undefined ? "is-indeterminate" : ""}
+ checked={toBoolean(value)}
+ placeholder={placeholder}
+ readonly={readonly}
+ name={String(name)}
+ disabled={readonly}
+ onChange={onCheckboxClick}
+ />
+ <span class="check" />
+ </label>
+ {help}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputCurrency.tsx b/packages/auditor-backoffice-ui/src/components/form/InputCurrency.tsx
new file mode 100644
index 000000000..b02354d7c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputCurrency.tsx
@@ -0,0 +1,67 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useConfigContext } from "../../context/config.js";
+import { Amount } from "../../declaration.js";
+import { InputWithAddon } from "./InputWithAddon.js";
+import { InputProps } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ addonAfter?: ComponentChildren;
+ children?: ComponentChildren;
+ side?: ComponentChildren;
+}
+
+export function InputCurrency<T>({
+ name,
+ readonly,
+ label,
+ placeholder,
+ help,
+ tooltip,
+ expand,
+ addonAfter,
+ children,
+ side,
+}: Props<keyof T>): VNode {
+ const config = useConfigContext();
+ return (
+ <InputWithAddon<T>
+ name={name}
+ readonly={readonly}
+ addonBefore={config.currency}
+ side={side}
+ label={label}
+ placeholder={placeholder}
+ help={help}
+ tooltip={tooltip}
+ addonAfter={addonAfter}
+ inputType="number"
+ expand={expand}
+ toStr={(v?: Amount) => v?.split(":")[1] || ""}
+ fromStr={(v: string) => (!v ? undefined : `${config.currency}:${v}`)}
+ inputExtra={{ min: 0 }}
+ >
+ {children}
+ </InputWithAddon>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputDate.tsx b/packages/auditor-backoffice-ui/src/components/form/InputDate.tsx
new file mode 100644
index 000000000..a398629dc
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputDate.tsx
@@ -0,0 +1,164 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { ComponentChildren, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { DatePicker } from "../picker/DatePicker.js";
+import { InputProps, useField } from "./useField.js";
+import { dateFormatForSettings, useSettings } from "../../hooks/useSettings.js";
+
+export interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ //FIXME: create separated components InputDate and InputTimestamp
+ withTimestampSupport?: boolean;
+ side?: ComponentChildren;
+}
+
+export function InputDate<T>({
+ name,
+ readonly,
+ label,
+ placeholder,
+ help,
+ tooltip,
+ expand,
+ withTimestampSupport,
+ side,
+}: Props<keyof T>): VNode {
+ const [opened, setOpened] = useState(false);
+ const { i18n } = useTranslationContext();
+ const [settings] = useSettings()
+
+ const { error, required, value, onChange } = useField<T>(name);
+
+ let strValue = "";
+ if (!value) {
+ strValue = withTimestampSupport ? "unknown" : "";
+ } else if (value instanceof Date) {
+ strValue = format(value, dateFormatForSettings(settings));
+ } else if (value.t_s) {
+ strValue =
+ value.t_s === "never"
+ ? withTimestampSupport
+ ? "never"
+ : ""
+ : format(new Date(value.t_s * 1000), dateFormatForSettings(settings));
+ }
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <div class="field has-addons">
+ <p
+ class={
+ expand
+ ? "control is-expanded has-icons-right"
+ : "control has-icons-right"
+ }
+ >
+ <input
+ class="input"
+ type="text"
+ readonly
+ value={strValue}
+ placeholder={placeholder}
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {help}
+ </p>
+ <div
+ class="control"
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ >
+ <a class="button is-static">
+ <span class="icon">
+ <i class="mdi mdi-calendar" />
+ </span>
+ </a>
+ </div>
+ </div>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+
+ {!readonly && (
+ <span
+ data-tooltip={
+ withTimestampSupport
+ ? i18n.str`change value to unknown date`
+ : i18n.str`change value to empty`
+ }
+ >
+ <button
+ class="button is-info mr-3"
+ onClick={() => onChange(undefined as any)}
+ >
+ <i18n.Translate>clear</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {withTimestampSupport && (
+ <span data-tooltip={i18n.str`change value to never`}>
+ <button
+ class="button is-info"
+ onClick={() => onChange({ t_s: "never" } as any)}
+ >
+ <i18n.Translate>never</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {side}
+ </div>
+ <DatePicker
+ opened={opened}
+ closeFunction={() => setOpened(false)}
+ dateReceiver={(d) => {
+ if (withTimestampSupport) {
+ onChange({ t_s: d.getTime() / 1000 } as any);
+ } else {
+ onChange(d as any);
+ }
+ }}
+ />
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputDuration.tsx b/packages/auditor-backoffice-ui/src/components/form/InputDuration.tsx
new file mode 100644
index 000000000..7aa2703a4
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputDuration.tsx
@@ -0,0 +1,186 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { formatDuration, intervalToDuration } from "date-fns";
+import { ComponentChildren, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { SimpleModal } from "../modal/index.js";
+import { DurationPicker } from "../picker/DurationPicker.js";
+import { InputProps, useField } from "./useField.js";
+import { Duration } from "@gnu-taler/taler-util";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ readonly?: boolean;
+ withForever?: boolean;
+ side?: ComponentChildren;
+ withoutClear?: boolean;
+}
+
+export function InputDuration<T>({
+ name,
+ expand,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ readonly,
+ withForever,
+ withoutClear,
+ side,
+}: Props<keyof T>): VNode {
+ const [opened, setOpened] = useState(false);
+ const { i18n } = useTranslationContext();
+
+ const { error, required, value: anyValue, onChange } = useField<T>(name);
+ let strValue = "";
+ const value: Duration = anyValue
+ if (!value) {
+ strValue = "";
+ } else if (value.d_ms === "forever") {
+ strValue = i18n.str`forever`;
+ } else {
+ strValue = formatDuration(
+ intervalToDuration({ start: 0, end: value.d_ms }),
+ {
+ locale: {
+ formatDistance: (name, value) => {
+ switch (name) {
+ case "xMonths":
+ return i18n.str`${value}M`;
+ case "xYears":
+ return i18n.str`${value}Y`;
+ case "xDays":
+ return i18n.str`${value}d`;
+ case "xHours":
+ return i18n.str`${value}h`;
+ case "xMinutes":
+ return i18n.str`${value}min`;
+ case "xSeconds":
+ return i18n.str`${value}sec`;
+ }
+ },
+ localize: {
+ day: () => "s",
+ month: () => "m",
+ ordinalNumber: () => "th",
+ dayPeriod: () => "p",
+ quarter: () => "w",
+ era: () => "e",
+ },
+ },
+ },
+ );
+ }
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal is-flex-grow-3">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+
+ <div class="is-flex-grow-3">
+ <div class="field-body ">
+ <div class="field">
+ <div class="field has-addons">
+ <p class={expand ? "control is-expanded " : "control "}>
+ <input
+ class="input"
+ type="text"
+ readonly
+ value={strValue}
+ placeholder={placeholder}
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ <div
+ class="control"
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ >
+ <a class="button is-static">
+ <span class="icon">
+ <i class="mdi mdi-clock" />
+ </span>
+ </a>
+ </div>
+ </div>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ {withForever && (
+ <span data-tooltip={i18n.str`change value to never`}>
+ <button
+ class="button is-info mr-3"
+ onClick={() => onChange({ d_ms: "forever" } as any)}
+ >
+ <i18n.Translate>forever</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {!readonly && !withoutClear && (
+ <span data-tooltip={i18n.str`change value to empty`}>
+ <button
+ class="button is-info "
+ onClick={() => onChange(undefined as any)}
+ >
+ <i18n.Translate>clear</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {side}
+ </div>
+ <span>
+ {help}
+ </span>
+ </div>
+
+
+ {opened && (
+ <SimpleModal onCancel={() => setOpened(false)}>
+ <DurationPicker
+ days
+ hours
+ minutes
+ value={!value || value.d_ms === "forever" ? 0 : value.d_ms}
+ onChange={(v) => {
+ onChange({ d_ms: v } as any);
+ }}
+ />
+ </SimpleModal>
+ )}
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputGroup.tsx b/packages/auditor-backoffice-ui/src/components/form/InputGroup.tsx
new file mode 100644
index 000000000..b5e0bd52b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputGroup.tsx
@@ -0,0 +1,86 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useGroupField } from "./useGroupField.js";
+
+export interface Props<T> {
+ name: T;
+ children: ComponentChildren;
+ label: ComponentChildren;
+ tooltip?: ComponentChildren;
+ alternative?: ComponentChildren;
+ fixed?: boolean;
+ initialActive?: boolean;
+}
+
+export function InputGroup<T>({
+ name,
+ label,
+ children,
+ tooltip,
+ alternative,
+ fixed,
+ initialActive,
+}: Props<keyof T>): VNode {
+ const [active, setActive] = useState(initialActive || fixed);
+ const group = useGroupField<T>(name);
+
+ return (
+ <div class="card">
+ <header class="card-header">
+ <p class="card-header-title">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ {group?.hasError && (
+ <span class="icon has-text-danger" data-tooltip={tooltip}>
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ {!fixed && (
+ <button
+ class="card-header-icon"
+ aria-label="more options"
+ onClick={(): void => setActive(!active)}
+ >
+ <span class="icon">
+ {active ? (
+ <i class="mdi mdi-arrow-up" />
+ ) : (
+ <i class="mdi mdi-arrow-down" />
+ )}
+ </span>
+ </button>
+ )}
+ </header>
+ {active ? (
+ <div class="card-content">{children}</div>
+ ) : alternative ? (
+ <div class="card-content">{alternative}</div>
+ ) : undefined}
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputImage.tsx b/packages/auditor-backoffice-ui/src/components/form/InputImage.tsx
new file mode 100644
index 000000000..b024e2c6b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputImage.tsx
@@ -0,0 +1,122 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, h, VNode } from "preact";
+import { useRef, useState } from "preact/hooks";
+import { MAX_IMAGE_SIZE as MAX_IMAGE_UPLOAD_SIZE } from "../../utils/constants.js";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ addonAfter?: ComponentChildren;
+ children?: ComponentChildren;
+}
+
+export function InputImage<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ children,
+ expand,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange } = useField<T>(name);
+
+ const image = useRef<HTMLInputElement>(null);
+ const { i18n } = useTranslationContext();
+ const [sizeError, setSizeError] = useState(false);
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class={expand ? "control is-expanded" : "control"}>
+ {value && (
+ <img
+ src={value}
+ style={{ width: 200, height: 200 }}
+ onClick={() => image.current?.click()}
+ />
+ )}
+ <input
+ ref={image}
+ style={{ display: "none" }}
+ type="file"
+ name={String(name)}
+ placeholder={placeholder}
+ readonly={readonly}
+ onChange={(e) => {
+ const f: FileList | null = e.currentTarget.files;
+ if (!f || f.length != 1) {
+ return onChange(undefined!);
+ }
+ if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) {
+ setSizeError(true);
+ return onChange(undefined!);
+ }
+ setSizeError(false);
+ return f[0].arrayBuffer().then((b) => {
+ const b64 = window.btoa(
+ new Uint8Array(b).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ ),
+ );
+ return onChange(`data:${f[0].type};base64,${b64}` as any);
+ });
+ }}
+ />
+ {help}
+ {children}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ {sizeError && (
+ <p class="help is-danger">
+ <i18n.Translate>Image should be smaller than 1 MB</i18n.Translate>
+ </p>
+ )}
+ {!value && (
+ <button class="button" onClick={() => image.current?.click()}>
+ <i18n.Translate>Add</i18n.Translate>
+ </button>
+ )}
+ {value && (
+ <button class="button" onClick={() => onChange(undefined!)}>
+ <i18n.Translate>Remove</i18n.Translate>
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputLocation.tsx b/packages/auditor-backoffice-ui/src/components/form/InputLocation.tsx
new file mode 100644
index 000000000..a2fc8113e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputLocation.tsx
@@ -0,0 +1,53 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { Fragment, h } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Input } from "./Input.js";
+
+export function InputLocation({ name }: { name: string }) {
+ const { i18n } = useTranslationContext();
+ return (
+ <>
+ <Input name={`${name}.country`} label={i18n.str`Country`} />
+ <Input
+ name={`${name}.address_lines`}
+ inputType="multiline"
+ label={i18n.str`Address`}
+ toStr={(v: string[] | undefined) => (!v ? "" : v.join("\n"))}
+ fromStr={(v: string) => v.split("\n")}
+ />
+ <Input
+ name={`${name}.building_number`}
+ label={i18n.str`Building number`}
+ />
+ <Input name={`${name}.building_name`} label={i18n.str`Building name`} />
+ <Input name={`${name}.street`} label={i18n.str`Street`} />
+ <Input name={`${name}.post_code`} label={i18n.str`Post code`} />
+ <Input name={`${name}.town_location`} label={i18n.str`Town location`} />
+ <Input name={`${name}.town`} label={i18n.str`Town`} />
+ <Input name={`${name}.district`} label={i18n.str`District`} />
+ <Input
+ name={`${name}.country_subdivision`}
+ label={i18n.str`Country subdivision`}
+ />
+ </>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputNumber.tsx b/packages/auditor-backoffice-ui/src/components/form/InputNumber.tsx
new file mode 100644
index 000000000..3b5df1474
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputNumber.tsx
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h } from "preact";
+import { InputWithAddon } from "./InputWithAddon.js";
+import { InputProps } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ side?: ComponentChildren;
+ children?: ComponentChildren;
+}
+
+export function InputNumber<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ expand,
+ children,
+ side,
+}: Props<keyof T>) {
+ return (
+ <InputWithAddon<T>
+ name={name}
+ readonly={readonly}
+ fromStr={(v) => (!v ? undefined : parseInt(v, 10))}
+ toStr={(v) => `${v}`}
+ inputType="number"
+ expand={expand}
+ label={label}
+ placeholder={placeholder}
+ help={help}
+ tooltip={tooltip}
+ inputExtra={{ min: 0 }}
+ children={children}
+ side={side}
+ />
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputPayto.tsx b/packages/auditor-backoffice-ui/src/components/form/InputPayto.tsx
new file mode 100644
index 000000000..6e88e8f2c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputPayto.tsx
@@ -0,0 +1,52 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputArray } from "./InputArray.js";
+import { PAYTO_REGEX } from "../../utils/constants.js";
+import { InputProps } from "./useField.js";
+
+export type Props<T> = InputProps<T>;
+
+const PAYTO_START_REGEX = /^payto:\/\//;
+
+export function InputPayto<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+}: Props<keyof T>): VNode {
+ return (
+ <InputArray<T>
+ name={name}
+ readonly={readonly}
+ addonBefore="payto://"
+ label={label}
+ placeholder={placeholder}
+ help={help}
+ tooltip={tooltip}
+ isValid={(v) => v && PAYTO_REGEX.test(v)}
+ toStr={(v?: string) => (!v ? "" : v.replace(PAYTO_START_REGEX, ""))}
+ fromStr={(v: string) => `payto://${v}`}
+ />
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx
new file mode 100644
index 000000000..282e52278
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx
@@ -0,0 +1,47 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h } from "preact";
+import * as tests from "@gnu-taler/web-util/testing";
+import { InputPaytoForm } from "./InputPaytoForm.js";
+import { FormProvider } from "./FormProvider.js";
+import { useState } from "preact/hooks";
+
+export default {
+ title: "Components/Form/PayTo",
+ component: InputPaytoForm,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+export const Example = tests.createExample(() => {
+ const initial = {
+ accounts: [],
+ };
+ const [form, updateForm] = useState<Partial<typeof initial>>(initial);
+ return (
+ <FormProvider valueHandler={updateForm} object={form}>
+ <InputPaytoForm name="accounts" label="Accounts:" />
+ </FormProvider>
+ );
+}, {});
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx
new file mode 100644
index 000000000..32545c89a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx
@@ -0,0 +1,397 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { parsePaytoUri, PaytoUriGeneric, stringifyPaytoUri } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { COUNTRY_TABLE } from "../../utils/constants.js";
+import { undefinedIfEmpty } from "../../utils/table.js";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { Input } from "./Input.js";
+import { InputGroup } from "./InputGroup.js";
+import { InputSelector } from "./InputSelector.js";
+import { InputProps, useField } from "./useField.js";
+import { useEffect, useState } from "preact/hooks";
+
+export interface Props<T> extends InputProps<T> {
+ isValid?: (e: any) => boolean;
+}
+
+// type Entity = PaytoUriGeneric
+// https://datatracker.ietf.org/doc/html/rfc8905
+type Entity = {
+ // iban, bitcoin, x-taler-bank. it defined the format
+ target: string;
+ // path1 if the first field to be used
+ path1?: string;
+ // path2 if the second field to be used, optional
+ path2?: string;
+ // params of the payto uri
+ params: {
+ "receiver-name"?: string;
+ sender?: string;
+ message?: string;
+ amount?: string;
+ instruction?: string;
+ [name: string]: string | undefined;
+ };
+};
+
+function isEthereumAddress(address: string) {
+ if (!/^(0x)?[0-9a-f]{40}$/i.test(address)) {
+ return false;
+ } else if (
+ /^(0x|0X)?[0-9a-f]{40}$/.test(address) ||
+ /^(0x|0X)?[0-9A-F]{40}$/.test(address)
+ ) {
+ return true;
+ }
+ return checkAddressChecksum(address);
+}
+
+function checkAddressChecksum(address: string) {
+ //TODO implement ethereum checksum
+ return true;
+}
+
+function validateBitcoin(
+ addr: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ try {
+ const valid = /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/.test(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid bitcoin address.`;
+}
+
+function validateEthereum(
+ addr: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ try {
+ const valid = isEthereumAddress(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid Ethereum address.`;
+}
+
+/**
+ * An IBAN is validated by converting it into an integer and performing a
+ * basic mod-97 operation (as described in ISO 7064) on it.
+ * If the IBAN is valid, the remainder equals 1.
+ *
+ * The algorithm of IBAN validation is as follows:
+ * 1.- Check that the total IBAN length is correct as per the country. If not, the IBAN is invalid
+ * 2.- Move the four initial characters to the end of the string
+ * 3.- Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35
+ * 4.- Interpret the string as a decimal integer and compute the remainder of that number on division by 97
+ *
+ * If the remainder is 1, the check digit test is passed and the IBAN might be valid.
+ *
+ */
+function validateIBAN(
+ iban: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ // Check total length
+ if (iban.length < 4)
+ return i18n.str`IBAN numbers usually have more that 4 digits`;
+ if (iban.length > 34)
+ return i18n.str`IBAN numbers usually have less that 34 digits`;
+
+ const A_code = "A".charCodeAt(0);
+ const Z_code = "Z".charCodeAt(0);
+ const IBAN = iban.toUpperCase();
+ // check supported country
+ const code = IBAN.substr(0, 2);
+ const found = code in COUNTRY_TABLE;
+ if (!found) return i18n.str`IBAN country code not found`;
+
+ // 2.- Move the four initial characters to the end of the string
+ const step2 = IBAN.substr(4) + iban.substr(0, 4);
+ const step3 = Array.from(step2)
+ .map((letter) => {
+ const code = letter.charCodeAt(0);
+ if (code < A_code || code > Z_code) return letter;
+ return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`;
+ })
+ .join("");
+
+ function calculate_iban_checksum(str: string): number {
+ const numberStr = str.substr(0, 5);
+ const rest = str.substr(5);
+ const number = parseInt(numberStr, 10);
+ const result = number % 97;
+ if (rest.length > 0) {
+ return calculate_iban_checksum(`${result}${rest}`);
+ }
+ return result;
+ }
+
+ const checksum = calculate_iban_checksum(step3);
+ if (checksum !== 1)
+ return i18n.str`IBAN number is not valid, checksum is wrong`;
+ return undefined;
+}
+
+// const targets = ['ach', 'bic', 'iban', 'upi', 'bitcoin', 'ilp', 'void', 'x-taler-bank']
+const targets = [
+ "Choose one...",
+ "iban",
+ "x-taler-bank",
+ "bitcoin",
+ "ethereum",
+];
+const noTargetValue = targets[0];
+const defaultTarget: Entity = {
+ target: noTargetValue,
+ params: {},
+};
+
+export function InputPaytoForm<T>({
+ name,
+ readonly,
+ label,
+ tooltip,
+}: Props<keyof T>): VNode {
+ const { value: initialValueStr, onChange } = useField<T>(name);
+
+ const initialPayto = parsePaytoUri(initialValueStr ?? "")
+ const paths = !initialPayto ? [] : initialPayto.targetPath.split("/")
+ const initialPath1 = paths.length >= 1 ? paths[0] : undefined;
+ const initialPath2 = paths.length >= 2 ? paths[1] : undefined;
+ const initial: Entity = initialPayto === undefined ? defaultTarget : {
+ target: initialPayto.targetType,
+ params: initialPayto.params,
+ path1: initialPath1,
+ path2: initialPath2,
+ }
+ const [value, setValue] = useState<Partial<Entity>>(initial)
+
+ const { i18n } = useTranslationContext();
+
+ const errors: FormErrors<Entity> = {
+ target:
+ value.target === noTargetValue
+ ? i18n.str`required`
+ : undefined,
+ path1: !value.path1
+ ? i18n.str`required`
+ : value.target === "iban"
+ ? validateIBAN(value.path1, i18n)
+ : value.target === "bitcoin"
+ ? validateBitcoin(value.path1, i18n)
+ : value.target === "ethereum"
+ ? validateEthereum(value.path1, i18n)
+ : undefined,
+ path2:
+ value.target === "x-taler-bank"
+ ? !value.path2
+ ? i18n.str`required`
+ : undefined
+ : undefined,
+ params: undefinedIfEmpty({
+ "receiver-name": !value.params?.["receiver-name"]
+ ? i18n.str`required`
+ : undefined,
+ }),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+ const str = hasErrors || !value.target ? undefined : stringifyPaytoUri({
+ targetType: value.target,
+ targetPath: value.path2 ? `${value.path1}/${value.path2}` : (value.path1 ?? ""),
+ params: value.params ?? {} as any,
+ isKnown: false,
+ })
+ useEffect(() => {
+ onChange(str as any)
+ }, [str])
+
+ // const submit = useCallback((): void => {
+ // // const accounts: MerchantBackend.BankAccounts.AccountAddDetails[] = paytos;
+ // // const alreadyExists =
+ // // accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1;
+ // // if (!alreadyExists) {
+ // const newValue: MerchantBackend.BankAccounts.AccountAddDetails = {
+ // payto_uri: paytoURL,
+ // };
+ // if (value.auth) {
+ // if (value.auth.url) {
+ // newValue.credit_facade_url = value.auth.url;
+ // }
+ // if (value.auth.type === "none") {
+ // newValue.credit_facade_credentials = {
+ // type: "none",
+ // };
+ // }
+ // if (value.auth.type === "basic") {
+ // newValue.credit_facade_credentials = {
+ // type: "basic",
+ // username: value.auth.username ?? "",
+ // password: value.auth.password ?? "",
+ // };
+ // }
+ // }
+ // onChange(newValue as any);
+ // // }
+ // // valueHandler(defaultTarget);
+ // }, [value]);
+
+ //FIXME: translating plural singular
+ return (
+ <InputGroup name="payto" label={label} fixed tooltip={tooltip}>
+ <FormProvider<Entity>
+ name="tax"
+ errors={errors}
+ object={value}
+ valueHandler={setValue}
+ >
+ <InputSelector<Entity>
+ name="target"
+ label={i18n.str`Account type`}
+ tooltip={i18n.str`Method to use for wire transfer`}
+ values={targets}
+ readonly={readonly}
+ toStr={(v) => (v === noTargetValue ? i18n.str`Choose one...` : v)}
+ />
+
+ {value.target === "ach" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n.str`Routing`}
+ readonly={readonly}
+ tooltip={i18n.str`Routing number.`}
+ />
+ <Input<Entity>
+ name="path2"
+ label={i18n.str`Account`}
+ readonly={readonly}
+ tooltip={i18n.str`Account number.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "bic" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n.str`Code`}
+ readonly={readonly}
+ tooltip={i18n.str`Business Identifier Code.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "iban" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n.str`IBAN`}
+ tooltip={i18n.str`International Bank Account Number.`}
+ readonly={readonly}
+ placeholder="DE1231231231"
+ inputExtra={{ style: { textTransform: "uppercase" } }}
+ />
+ </Fragment>
+ )}
+ {value.target === "upi" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Account`}
+ tooltip={i18n.str`Unified Payment Interface.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "bitcoin" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Bitcoin protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "ethereum" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Ethereum protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "ilp" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Interledger protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "void" && <Fragment />}
+ {value.target === "x-taler-bank" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Host`}
+ tooltip={i18n.str`Bank host.`}
+ />
+ <Input<Entity>
+ name="path2"
+ readonly={readonly}
+ label={i18n.str`Account`}
+ tooltip={i18n.str`Bank account.`}
+ />
+ </Fragment>
+ )}
+
+ {/**
+ * Show additional fields apart from the payto
+ */}
+ {value.target !== noTargetValue && (
+ <Fragment>
+ <Input
+ name="params.receiver-name"
+ readonly={readonly}
+ label={i18n.str`Owner's name`}
+ tooltip={i18n.str`Legal name of the person holding the account.`}
+ />
+ </Fragment>
+ )}
+
+ </FormProvider>
+ </InputGroup>
+ );
+}
+
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSearchOnList.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSearchOnList.tsx
new file mode 100644
index 000000000..be5800d14
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputSearchOnList.tsx
@@ -0,0 +1,204 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import emptyImage from "../../assets/empty.png";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { InputWithAddon } from "./InputWithAddon.js";
+import { TranslatedString } from "@gnu-taler/taler-util";
+
+type Entity = {
+ id: string,
+ description: string;
+ image?: string;
+ extra?: string;
+};
+
+export interface Props<T extends Entity> {
+ selected?: T;
+ onChange: (p?: T) => void;
+ label: TranslatedString;
+ list: T[];
+ withImage?: boolean;
+}
+
+interface Search {
+ name: string;
+}
+
+export function InputSearchOnList<T extends Entity>({
+ selected,
+ onChange,
+ label,
+ list,
+ withImage,
+}: Props<T>): VNode {
+ const [nameForm, setNameForm] = useState<Partial<Search>>({
+ name: "",
+ });
+
+ const errors: FormErrors<Search> = {
+ name: undefined,
+ };
+ const { i18n } = useTranslationContext();
+
+ if (selected) {
+ return (
+ <article class="media">
+ {withImage &&
+ <figure class="media-left">
+ <p class="image is-128x128">
+ <img src={selected.image ? selected.image : emptyImage} />
+ </p>
+ </figure>
+ }
+ <div class="media-content">
+ <div class="content">
+ <p class="media-meta">
+ <i18n.Translate>ID</i18n.Translate>: <b>{selected.id}</b>
+ </p>
+ <p>
+ <i18n.Translate>Description</i18n.Translate>:{" "}
+ {selected.description}
+ </p>
+ <div class="buttons is-right mt-5">
+ <button
+ class="button is-info"
+ onClick={() => onChange(undefined)}
+ >
+ clear
+ </button>
+ </div>
+ </div>
+ </div>
+ </article>
+ );
+ }
+
+ return (
+ <FormProvider<Search>
+ errors={errors}
+ object={nameForm}
+ valueHandler={setNameForm}
+ >
+ <InputWithAddon<Search>
+ name="name"
+ label={label}
+ tooltip={i18n.str`enter description or id`}
+ addonAfter={
+ <span class="icon">
+ <i class="mdi mdi-magnify" />
+ </span>
+ }
+ >
+ <div>
+ <DropdownList
+ name={nameForm.name}
+ list={list}
+ onSelect={(p) => {
+ setNameForm({ name: "" });
+ onChange(p);
+ }}
+ withImage={!!withImage}
+ />
+ </div>
+ </InputWithAddon>
+ </FormProvider>
+ );
+}
+
+interface DropdownListProps<T extends Entity> {
+ name?: string;
+ onSelect: (p: T) => void;
+ list: T[];
+ withImage: boolean;
+}
+
+function DropdownList<T extends Entity>({ name, onSelect, list, withImage }: DropdownListProps<T>) {
+ const { i18n } = useTranslationContext();
+ if (!name) {
+ /* FIXME
+ this BR is added to occupy the space that will be added when the
+ dropdown appears
+ */
+ return (
+ <div>
+ <br />
+ </div>
+ );
+ }
+ const filtered = list.filter(
+ (p) => p.id.includes(name) || p.description.includes(name),
+ );
+
+ return (
+ <div class="dropdown is-active">
+ <div
+ class="dropdown-menu"
+ id="dropdown-menu"
+ role="menu"
+ style={{ minWidth: "20rem" }}
+ >
+ <div class="dropdown-content">
+ {!filtered.length ? (
+ <div class="dropdown-item">
+ <i18n.Translate>
+ no match found with that description or id
+ </i18n.Translate>
+ </div>
+ ) : (
+ filtered.map((p) => (
+ <div
+ key={p.id}
+ class="dropdown-item"
+ onClick={() => onSelect(p)}
+ style={{ cursor: "pointer" }}
+ >
+ <article class="media">
+ {withImage &&
+ <div class="media-left">
+ <div class="image" style={{ minWidth: 64 }}>
+ <img
+ src={p.image ? p.image : emptyImage}
+ style={{ width: 64, height: 64 }}
+ />
+ </div>
+ </div>
+ }
+ <div class="media-content">
+ <div class="content">
+ <p>
+ <strong>{p.id}</strong> {p.extra !== undefined ? <small>{p.extra}</small> : undefined}
+ <br />
+ {p.description}
+ </p>
+ </div>
+ </div>
+ </article>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSecured.stories.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSecured.stories.tsx
new file mode 100644
index 000000000..12ce6c6aa
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputSecured.stories.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider } from "./FormProvider.js";
+import { InputSecured } from "./InputSecured.js";
+
+export default {
+ title: "Components/Form/InputSecured",
+ component: InputSecured,
+};
+
+type T = { auth_token: string | null };
+
+export const InitialValueEmpty = (): VNode => {
+ const [state, setState] = useState<Partial<T>>({ auth_token: "" });
+ return (
+ <FormProvider<T> object={state} errors={{}} valueHandler={setState}>
+ Initial value: ''
+ <InputSecured<T> name="auth_token" label="Access token" />
+ </FormProvider>
+ );
+};
+
+export const InitialValueToken = (): VNode => {
+ const [state, setState] = useState<Partial<T>>({ auth_token: "token" });
+ return (
+ <FormProvider<T> object={state} errors={{}} valueHandler={setState}>
+ <InputSecured<T> name="auth_token" label="Access token" />
+ </FormProvider>
+ );
+};
+
+export const InitialValueNull = (): VNode => {
+ const [state, setState] = useState<Partial<T>>({ auth_token: null });
+ return (
+ <FormProvider<T> object={state} errors={{}} valueHandler={setState}>
+ Initial value: ''
+ <InputSecured<T> name="auth_token" label="Access token" />
+ </FormProvider>
+ );
+};
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSecured.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSecured.tsx
new file mode 100644
index 000000000..9d1a3ab8e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputSecured.tsx
@@ -0,0 +1,186 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { InputProps, useField } from "./useField.js";
+
+export type Props<T> = InputProps<T>;
+
+const TokenStatus = ({ prev, post }: any) => {
+ const { i18n } = useTranslationContext();
+ if (
+ (prev === undefined || prev === null) &&
+ (post === undefined || post === null)
+ )
+ return null;
+ return prev === post ? null : post === null ? (
+ <span class="tag is-danger is-align-self-center ml-2">
+ <i18n.Translate>Deleting</i18n.Translate>
+ </span>
+ ) : (
+ <span class="tag is-warning is-align-self-center ml-2">
+ <i18n.Translate>Changing</i18n.Translate>
+ </span>
+ );
+};
+
+export function InputSecured<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+}: Props<keyof T>): VNode {
+ const { error, value, initial, onChange, toStr, fromStr } = useField<T>(name);
+
+ const [active, setActive] = useState(false);
+ const [newValue, setNuewValue] = useState("");
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ {!active ? (
+ <Fragment>
+ <div class="field has-addons">
+ <button
+ class="button"
+ onClick={(): void => {
+ setActive(!active);
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-reset" />
+ </div>
+ <span>
+ <i18n.Translate>Manage access token</i18n.Translate>
+ </span>
+ </button>
+ <TokenStatus prev={initial} post={value} />
+ </div>
+ </Fragment>
+ ) : (
+ <Fragment>
+ <div class="field has-addons">
+ <div class="control">
+ <a class="button is-static">secret-token:</a>
+ </div>
+ <div class="control is-expanded">
+ <input
+ class="input"
+ type="text"
+ placeholder={placeholder}
+ readonly={readonly || !active}
+ disabled={readonly || !active}
+ name={String(name)}
+ value={newValue}
+ onInput={(e): void => {
+ setNuewValue(e.currentTarget.value);
+ }}
+ />
+ {help}
+ </div>
+ <div class="control">
+ <button
+ class="button is-info"
+ disabled={fromStr(newValue) === value}
+ onClick={(): void => {
+ onChange(fromStr(newValue));
+ setActive(!active);
+ setNuewValue("");
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-outline" />
+ </div>
+ <span>
+ <i18n.Translate>Update</i18n.Translate>
+ </span>
+ </button>
+ </div>
+ </div>
+ </Fragment>
+ )}
+ {error ? <p class="help is-danger">{error}</p> : null}
+ </div>
+ </div>
+ {active && (
+ <div class="field is-horizontal">
+ <div class="field-body is-flex-grow-3">
+ <div class="level" style={{ width: "100%" }}>
+ <div class="level-right is-flex-grow-1">
+ <div class="level-item">
+ <button
+ class="button is-danger"
+ disabled={null === value || undefined === value}
+ onClick={(): void => {
+ onChange(null!);
+ setActive(!active);
+ setNuewValue("");
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-open-variant" />
+ </div>
+ <span>
+ <i18n.Translate>Remove</i18n.Translate>
+ </span>
+ </button>
+ </div>
+ <div class="level-item">
+ <button
+ class="button "
+ onClick={(): void => {
+ onChange(initial!);
+ setActive(!active);
+ setNuewValue("");
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-open-variant" />
+ </div>
+ <span>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSelector.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSelector.tsx
new file mode 100644
index 000000000..a8dad5d89
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputSelector.tsx
@@ -0,0 +1,94 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ values: any[];
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputSelector<T>({
+ name,
+ readonly,
+ expand,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ values,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field has-icons-right">
+ <p class={expand ? "control is-expanded select" : "control select "}>
+ <select
+ class={error ? "select is-danger" : "select"}
+ name={String(name)}
+ disabled={readonly}
+ readonly={readonly}
+ onChange={(e) => {
+ onChange(fromStr(e.currentTarget.value));
+ }}
+ >
+ {placeholder && <option>{placeholder}</option>}
+ {values.map((v, i) => {
+ return (
+ <option key={i} value={v} selected={value === v}>
+ {toStr(v)}
+ </option>
+ );
+ })}
+ </select>
+
+ {help}
+ </p>
+ {required && (
+ <span class="icon has-text-danger is-right" style={{height: "2.5em"}}>
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputStock.stories.tsx b/packages/auditor-backoffice-ui/src/components/form/InputStock.stories.tsx
new file mode 100644
index 000000000..668c65ea7
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputStock.stories.tsx
@@ -0,0 +1,162 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { addDays } from "date-fns";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider } from "./FormProvider.js";
+import { InputStock, Stock } from "./InputStock.js";
+
+export default {
+ title: "Components/Form/InputStock",
+ component: InputStock,
+};
+
+type T = { stock?: Stock };
+
+export const CreateStockEmpty = () => {
+ const [state, setState] = useState<Partial<T>>({});
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const CreateStockUnknownRestock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 10,
+ lost: 0,
+ sold: 0,
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const CreateStockNoRestock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 10,
+ lost: 0,
+ sold: 0,
+ nextRestock: { t_s: "never" },
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const CreateStockWithRestock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 15,
+ lost: 0,
+ sold: 0,
+ nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 },
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const UpdatingProductWithManagedStock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 100,
+ lost: 0,
+ sold: 0,
+ nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 },
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" alreadyExist />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const UpdatingProductWithInfiniteStock = () => {
+ const [state, setState] = useState<Partial<T>>({});
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" alreadyExist />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputStock.tsx b/packages/auditor-backoffice-ui/src/components/form/InputStock.tsx
new file mode 100644
index 000000000..1d18685c5
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputStock.tsx
@@ -0,0 +1,224 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h } from "preact";
+import { useLayoutEffect, useState } from "preact/hooks";
+import { MerchantBackend, Timestamp } from "../../declaration.js";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { InputDate } from "./InputDate.js";
+import { InputGroup } from "./InputGroup.js";
+import { InputLocation } from "./InputLocation.js";
+import { InputNumber } from "./InputNumber.js";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ alreadyExist?: boolean;
+}
+
+type Entity = Stock;
+
+export interface Stock {
+ current: number;
+ lost: number;
+ sold: number;
+ address?: MerchantBackend.Location;
+ nextRestock?: Timestamp;
+}
+
+interface StockDelta {
+ incoming: number;
+ lost: number;
+}
+
+export function InputStock<T>({
+ name,
+ tooltip,
+ label,
+ alreadyExist,
+}: Props<keyof T>) {
+ const { error, value, onChange } = useField<T>(name);
+
+ const [errors, setErrors] = useState<FormErrors<Entity>>({});
+
+ const [formValue, valueHandler] = useState<Partial<Entity>>(value);
+ const [addedStock, setAddedStock] = useState<StockDelta>({
+ incoming: 0,
+ lost: 0,
+ });
+ const { i18n } = useTranslationContext();
+
+ useLayoutEffect(() => {
+ if (!formValue) {
+ onChange(undefined as any);
+ } else {
+ onChange({
+ ...formValue,
+ current: (formValue?.current || 0) + addedStock.incoming,
+ lost: (formValue?.lost || 0) + addedStock.lost,
+ } as any);
+ }
+ }, [formValue, addedStock]);
+
+ if (!formValue) {
+ return (
+ <Fragment>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field has-addons">
+ {!alreadyExist ? (
+ <button
+ class="button"
+ data-tooltip={i18n.str`click here to configure the stock of the product, leave it as is and the backend will not control stock`}
+ onClick={(): void => {
+ valueHandler({
+ current: 0,
+ lost: 0,
+ sold: 0,
+ } as Stock as any);
+ }}
+ >
+ <span>
+ <i18n.Translate>Manage stock</i18n.Translate>
+ </span>
+ </button>
+ ) : (
+ <button
+ class="button"
+ data-tooltip={i18n.str`this product has been configured without stock control`}
+ disabled
+ >
+ <span>
+ <i18n.Translate>Infinite</i18n.Translate>
+ </span>
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+ }
+
+ const currentStock =
+ (formValue.current || 0) - (formValue.lost || 0) - (formValue.sold || 0);
+
+ const stockAddedErrors: FormErrors<typeof addedStock> = {
+ lost:
+ currentStock + addedStock.incoming < addedStock.lost
+ ? i18n.str`lost cannot be greater than current and incoming (max ${
+ currentStock + addedStock.incoming
+ })`
+ : undefined,
+ };
+
+ // const stockUpdateDescription = stockAddedErrors.lost ? '' : (
+ // !!addedStock.incoming || !!addedStock.lost ?
+ // i18n.str`current stock will change from ${currentStock} to ${currentStock + addedStock.incoming - addedStock.lost}` :
+ // i18n.str`current stock will stay at ${currentStock}`
+ // )
+
+ return (
+ <Fragment>
+ <div class="card">
+ <header class="card-header">
+ <p class="card-header-title">
+ {label}
+ {tooltip && (
+ <span class="icon" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </p>
+ </header>
+ <div class="card-content">
+ <FormProvider<Entity>
+ name="stock"
+ errors={errors}
+ object={formValue}
+ valueHandler={valueHandler}
+ >
+ {alreadyExist ? (
+ <Fragment>
+ <FormProvider
+ name="added"
+ errors={stockAddedErrors}
+ object={addedStock}
+ valueHandler={setAddedStock as any}
+ >
+ <InputNumber name="incoming" label={i18n.str`Incoming`} />
+ <InputNumber name="lost" label={i18n.str`Lost`} />
+ </FormProvider>
+
+ {/* <div class="field is-horizontal">
+ <div class="field-label is-normal" />
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ {stockUpdateDescription}
+ </div>
+ </div>
+ </div> */}
+ </Fragment>
+ ) : (
+ <InputNumber<Entity>
+ name="current"
+ label={i18n.str`Current`}
+ side={
+ <button
+ class="button is-danger"
+ data-tooltip={i18n.str`remove stock control for this product`}
+ onClick={(): void => {
+ valueHandler(undefined as any);
+ }}
+ >
+ <span>
+ <i18n.Translate>without stock</i18n.Translate>
+ </span>
+ </button>
+ }
+ />
+ )}
+
+ <InputDate<Entity>
+ name="nextRestock"
+ label={i18n.str`Next restock`}
+ withTimestampSupport
+ />
+
+ <InputGroup<Entity> name="address" label={i18n.str`Warehouse address`}>
+ <InputLocation name="address" />
+ </InputGroup>
+ </FormProvider>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+// (
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputTab.tsx b/packages/auditor-backoffice-ui/src/components/form/InputTab.tsx
new file mode 100644
index 000000000..2701768aa
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputTab.tsx
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ values: any[];
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputTab<T>({
+ name,
+ readonly,
+ expand,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ values,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field has-icons-right">
+ <p class={expand ? "control is-expanded " : "control "}>
+ <div class="tabs is-toggle is-fullwidth is-small">
+ <ul>
+ {values.map((v, i) => {
+ return (
+ <li key={i} class={value === v ? "is-active" : ""}
+ onClick={(e) => { onChange(v) }}
+ >
+ <a style={{ cursor: "initial" }}>
+ <span>{toStr(v)}</span>
+ </a>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ {help}
+ </p>
+ {required && (
+ <span class="icon has-text-danger is-right" style={{ height: "2.5em" }}>
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputTaxes.tsx b/packages/auditor-backoffice-ui/src/components/form/InputTaxes.tsx
new file mode 100644
index 000000000..b5722e4ec
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputTaxes.tsx
@@ -0,0 +1,147 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useCallback, useState } from "preact/hooks";
+import * as yup from "yup";
+import { MerchantBackend } from "../../declaration.js";
+import { TaxSchema as schema } from "../../schemas/index.js";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { Input } from "./Input.js";
+import { InputGroup } from "./InputGroup.js";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ isValid?: (e: any) => boolean;
+}
+
+type Entity = MerchantBackend.Tax;
+export function InputTaxes<T>({
+ name,
+ readonly,
+ label,
+}: Props<keyof T>): VNode {
+ const { value: taxes, onChange } = useField<T>(name);
+
+ const [value, valueHandler] = useState<Partial<Entity>>({});
+ // const [errors, setErrors] = useState<FormErrors<Entity>>({})
+
+ let errors: FormErrors<Entity> = {};
+
+ try {
+ schema.validateSync(value, { abortEarly: false });
+ } catch (err) {
+ if (err instanceof yup.ValidationError) {
+ const yupErrors = err.inner as yup.ValidationError[];
+ errors = yupErrors.reduce(
+ (prev, cur) =>
+ !cur.path ? prev : { ...prev, [cur.path]: cur.message },
+ {},
+ );
+ }
+ }
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submit = useCallback((): void => {
+ onChange([value as any, ...taxes] as any);
+ valueHandler({});
+ }, [value]);
+
+ const { i18n } = useTranslationContext();
+
+ //FIXME: translating plural singular
+ return (
+ <InputGroup
+ name="tax"
+ label={label}
+ alternative={
+ taxes.length > 0 && (
+ <p>This product has {taxes.length} applicable taxes configured.</p>
+ )
+ }
+ >
+ <FormProvider<Entity>
+ name="tax"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <div class="field is-horizontal">
+ <div class="field-label is-normal" />
+ <div class="field-body" style={{ display: "block" }}>
+ {taxes.map((v: any, i: number) => (
+ <div
+ key={i}
+ class="tags has-addons mt-3 mb-0 mr-3"
+ style={{ flexWrap: "nowrap" }}
+ >
+ <span
+ class="tag is-medium is-info mb-0"
+ style={{ maxWidth: "90%" }}
+ >
+ <b>{v.tax}</b>: {v.name}
+ </span>
+ <a
+ class="tag is-medium is-danger is-delete mb-0"
+ onClick={() => {
+ onChange(taxes.filter((f: any) => f !== v) as any);
+ valueHandler(v);
+ }}
+ />
+ </div>
+ ))}
+ {!taxes.length && i18n.str`No taxes configured for this product.`}
+ </div>
+ </div>
+
+ <Input<Entity>
+ name="tax"
+ label={i18n.str`Amount`}
+ tooltip={i18n.str`Taxes can be in currencies that differ from the main currency used by the merchant.`}
+ >
+ <i18n.Translate>
+ Enter currency and value separated with a colon, e.g.
+ &quot;USD:2.3&quot;.
+ </i18n.Translate>
+ </Input>
+
+ <Input<Entity>
+ name="name"
+ label={i18n.str`Description`}
+ tooltip={i18n.str`Legal name of the tax, e.g. VAT or import duties.`}
+ />
+
+ <div class="buttons is-right mt-5">
+ <button
+ class="button is-info"
+ data-tooltip={i18n.str`add tax to the tax list`}
+ disabled={hasErrors}
+ onClick={submit}
+ >
+ <i18n.Translate>Add</i18n.Translate>
+ </button>
+ </div>
+ </FormProvider>
+ </InputGroup>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputToggle.tsx b/packages/auditor-backoffice-ui/src/components/form/InputToggle.tsx
new file mode 100644
index 000000000..f95dfcd05
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputToggle.tsx
@@ -0,0 +1,91 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ name: T;
+ readonly?: boolean;
+ expand?: boolean;
+ threeState?: boolean;
+ toBoolean?: (v?: any) => boolean | undefined;
+ fromBoolean?: (s: boolean | undefined) => any;
+}
+
+const defaultToBoolean = (f?: any): boolean | undefined => f || "";
+const defaultFromBoolean = (v: boolean | undefined): any => v as any;
+
+export function InputToggle<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ threeState,
+ expand,
+ fromBoolean = defaultFromBoolean,
+ toBoolean = defaultToBoolean,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange } = useField<T>(name);
+
+ const onCheckboxClick = (): void => {
+ const c = toBoolean(value);
+ if (c === false && threeState) return onChange(undefined as any);
+ return onChange(fromBoolean(!c));
+ };
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label" >
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class={expand ? "control is-expanded" : "control"}>
+ <label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}>
+ <input
+ type="checkbox"
+ class={toBoolean(value) === undefined ? "is-indeterminate" : "toggle-checkbox"}
+ checked={toBoolean(value)}
+ placeholder={placeholder}
+ readonly={readonly}
+ name={String(name)}
+ disabled={readonly}
+ onChange={onCheckboxClick}
+ />
+ <div class="toggle-switch"></div>
+ </label>
+ {help}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/InputWithAddon.tsx b/packages/auditor-backoffice-ui/src/components/form/InputWithAddon.tsx
new file mode 100644
index 000000000..e9fd88770
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/InputWithAddon.tsx
@@ -0,0 +1,116 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ inputType?: "text" | "number" | "password";
+ addonBefore?: ComponentChildren;
+ addonAfter?: ComponentChildren;
+ addonAfterAction?: () => void;
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+ inputExtra?: any;
+ children?: ComponentChildren;
+ side?: ComponentChildren;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputWithAddon<T>({
+ name,
+ readonly,
+ addonBefore,
+ children,
+ expand,
+ label,
+ placeholder,
+ help,
+ tooltip,
+ inputType,
+ inputExtra,
+ side,
+ addonAfter,
+ addonAfterAction,
+ toStr = defaultToString,
+ fromStr = defaultFromString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <div class="field has-addons">
+ {addonBefore && (
+ <div class="control">
+ <a class="button is-static">{addonBefore}</a>
+ </div>
+ )}
+ <p
+ class={`control${expand ? " is-expanded" : ""}${required ? " has-icons-right" : ""
+ }`}
+ >
+ <input
+ {...(inputExtra || {})}
+ class={error ? "input is-danger" : "input"}
+ type={inputType}
+ placeholder={placeholder}
+ readonly={readonly}
+ disabled={readonly}
+ name={String(name)}
+ value={toStr(value)}
+ onChange={(e): void => onChange(fromStr(e.currentTarget.value))}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {children}
+ </p>
+ {addonAfter && (
+ <div class="control" onClick={addonAfterAction} style={{ cursor: addonAfterAction ? "pointer" : undefined }}>
+ <a class="button is-static">{addonAfter}</a>
+ </div>
+ )}
+ </div>
+ {error && <p class="help is-danger">{error}</p>}
+ <span class="has-text-grey">{help}</span>
+ </div>
+ {expand ? <div>{side}</div> : side}
+ </div>
+
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/JumpToElementById.tsx b/packages/auditor-backoffice-ui/src/components/form/JumpToElementById.tsx
new file mode 100644
index 000000000..a0e1d6ae4
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/JumpToElementById.tsx
@@ -0,0 +1,59 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+
+export function JumpToElementById({ testIfExist, onSelect, placeholder, description }: { placeholder: TranslatedString, description: TranslatedString, testIfExist: (id: string) => Promise<any>, onSelect: (id: string) => void }): VNode {
+ const { i18n } = useTranslationContext()
+
+ const [error, setError] = useState<string | undefined>(
+ undefined,
+ );
+
+ const [id, setId] = useState<string>()
+ async function check(currentId: string | undefined): Promise<void> {
+ if (!currentId) {
+ setError(i18n.str`missing id`);
+ return;
+ }
+ try {
+ await testIfExist(currentId);
+ onSelect(currentId);
+ setError(undefined);
+ } catch {
+ setError(i18n.str`not found`);
+ }
+ }
+
+ return <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <div class="field has-addons">
+ <div class="control">
+ <input
+ class={error ? "input is-danger" : "input"}
+ type="text"
+ value={id ?? ""}
+ onChange={(e) => setId(e.currentTarget.value)}
+ placeholder={placeholder}
+ />
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ <span
+ class="has-tooltip-bottom"
+ data-tooltip={description}
+ >
+ <button
+ class="button"
+ onClick={(e) => check(id)}
+ >
+ <span class="icon">
+ <i class="mdi mdi-arrow-right" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/TextField.tsx b/packages/auditor-backoffice-ui/src/components/form/TextField.tsx
new file mode 100644
index 000000000..03f36dcbb
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/TextField.tsx
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useField, InputProps } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ inputType?: "text" | "number" | "multiline" | "password";
+ expand?: boolean;
+ side?: ComponentChildren;
+ children: ComponentChildren;
+}
+
+export function TextField<T>({
+ name,
+ tooltip,
+ label,
+ expand,
+ help,
+ children,
+ side,
+}: Props<keyof T>): VNode {
+ const { error } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p
+ class={
+ expand
+ ? "control is-expanded has-icons-right"
+ : "control has-icons-right"
+ }
+ >
+ {children}
+ {help}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ {side}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/useField.tsx b/packages/auditor-backoffice-ui/src/components/form/useField.tsx
new file mode 100644
index 000000000..c7559faae
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/useField.tsx
@@ -0,0 +1,92 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ComponentChildren, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useFormContext } from "./FormProvider.js";
+
+interface Use<V> {
+ error?: string;
+ required: boolean;
+ value: any;
+ initial: any;
+ onChange: (v: V) => void;
+ toStr: (f: V | undefined) => string;
+ fromStr: (v: string) => V;
+}
+
+export function useField<T>(name: keyof T): Use<T[typeof name]> {
+ const { errors, object, initialObject, toStr, fromStr, valueHandler } =
+ useFormContext<T>();
+ type P = typeof name;
+ type V = T[P];
+ const [isDirty, setDirty] = useState(false);
+ const updateField =
+ (field: P) =>
+ (value: V): void => {
+ setDirty(true);
+ return valueHandler((prev) => {
+ return setValueDeeper(prev, String(field).split("."), value);
+ });
+ };
+
+ const defaultToString = (f?: V): string => String(!f ? "" : f);
+ const defaultFromString = (v: string): V => v as any;
+ const value = readField(object, String(name));
+ const initial = readField(initialObject, String(name));
+ const hasError = readField(errors, String(name));
+ return {
+ error: isDirty ? hasError : undefined,
+ required: !isDirty && hasError,
+ value,
+ initial,
+ onChange: updateField(name) as any,
+ toStr: toStr[name] ? toStr[name]! : defaultToString,
+ fromStr: fromStr[name] ? fromStr[name]! : defaultFromString,
+ };
+}
+/**
+ * read the field of an object an support accessing it using '.'
+ *
+ * @param object
+ * @param name
+ * @returns
+ */
+const readField = (object: any, name: string) => {
+ return name
+ .split(".")
+ .reduce((prev, current) => prev && prev[current], object);
+};
+
+const setValueDeeper = (object: any, names: string[], value: any): any => {
+ if (names.length === 0) return value;
+ const [head, ...rest] = names;
+ return { ...object, [head]: setValueDeeper(object[head] || {}, rest, value) };
+};
+
+export interface InputProps<T> {
+ name: T;
+ label: ComponentChildren;
+ placeholder?: string;
+ tooltip?: ComponentChildren;
+ readonly?: boolean;
+ help?: ComponentChildren;
+}
diff --git a/packages/auditor-backoffice-ui/src/components/form/useGroupField.tsx b/packages/auditor-backoffice-ui/src/components/form/useGroupField.tsx
new file mode 100644
index 000000000..9a445eb32
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/form/useGroupField.tsx
@@ -0,0 +1,41 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useFormContext } from "./FormProvider.js";
+
+interface Use {
+ hasError?: boolean;
+}
+
+export function useGroupField<T>(name: keyof T): Use {
+ const f = useFormContext<T>();
+ if (!f) return {};
+
+ return {
+ hasError: readField(f.errors, String(name)),
+ };
+}
+
+const readField = (object: any, name: string) => {
+ return name
+ .split(".")
+ .reduce((prev, current) => prev && prev[current], object);
+};
diff --git a/packages/auditor-backoffice-ui/src/components/index.stories.ts b/packages/auditor-backoffice-ui/src/components/index.stories.ts
new file mode 100644
index 000000000..c57ddab14
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/index.stories.ts
@@ -0,0 +1,17 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export * as payto from "./form/InputPaytoForm.stories.js";
diff --git a/packages/auditor-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/auditor-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx
new file mode 100644
index 000000000..6f5881fc0
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx
@@ -0,0 +1,124 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useBackendContext } from "../../context/backend.js";
+import { Entity } from "../../paths/admin/create/CreatePage.js";
+import { Input } from "../form/Input.js";
+import { InputDuration } from "../form/InputDuration.js";
+import { InputGroup } from "../form/InputGroup.js";
+import { InputImage } from "../form/InputImage.js";
+import { InputLocation } from "../form/InputLocation.js";
+import { InputSelector } from "../form/InputSelector.js";
+import { InputToggle } from "../form/InputToggle.js";
+import { InputWithAddon } from "../form/InputWithAddon.js";
+
+export function DefaultInstanceFormFields({
+ readonlyId,
+ showId,
+}: {
+ readonlyId?: boolean;
+ showId: boolean;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { url: backendURL } = useBackendContext()
+ return (
+ <Fragment>
+ {showId && (
+ <InputWithAddon<Entity>
+ name="id"
+ addonBefore={`${backendURL}/instances/`}
+ readonly={readonlyId}
+ label={i18n.str`Identifier`}
+ tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`}
+ />
+ )}
+
+ <Input<Entity>
+ name="name"
+ label={i18n.str`Business name`}
+ tooltip={i18n.str`Legal name of the business represented by this instance.`}
+ />
+
+ <InputSelector<Entity>
+ name="user_type"
+ label={i18n.str`Type`}
+ tooltip={i18n.str`Different type of account can have different rules and requirements.`}
+ values={["business", "individual"]}
+ />
+
+ <Input<Entity>
+ name="email"
+ label={i18n.str`Email`}
+ tooltip={i18n.str`Contact email`}
+ />
+
+ <Input<Entity>
+ name="website"
+ label={i18n.str`Website URL`}
+ tooltip={i18n.str`URL.`}
+ />
+
+ <InputImage<Entity>
+ name="logo"
+ label={i18n.str`Logo`}
+ tooltip={i18n.str`Logo image.`}
+ />
+
+ <InputToggle<Entity>
+ name="use_stefan"
+ label={i18n.str`Pay transaction fee`}
+ tooltip={i18n.str`Assume the cost of the transaction of let the user pay for it.`}
+ />
+
+ <InputGroup
+ name="address"
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Physical location of the merchant.`}
+ >
+ <InputLocation name="address" />
+ </InputGroup>
+
+ <InputGroup
+ name="jurisdiction"
+ label={i18n.str`Jurisdiction`}
+ tooltip={i18n.str`Jurisdiction for legal disputes with the merchant.`}
+ >
+ <InputLocation name="jurisdiction" />
+ </InputGroup>
+
+ <InputDuration<Entity>
+ name="default_pay_delay"
+ label={i18n.str`Default payment delay`}
+ withForever
+ tooltip={i18n.str`Time customers have to pay an order before the offer expires by default.`}
+ />
+
+ <InputDuration<Entity>
+ name="default_wire_transfer_delay"
+ label={i18n.str`Default wire transfer delay`}
+ tooltip={i18n.str`Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.`}
+ withForever
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx b/packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx
new file mode 100644
index 000000000..41fe1374a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx
@@ -0,0 +1,92 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import langIcon from "../../assets/icons/languageicon.svg";
+import { strings as messages } from "../../i18n/strings.js";
+
+type LangsNames = {
+ [P in keyof typeof messages]: string;
+};
+
+const names: LangsNames = {
+ es: "Español [es]",
+ en: "English [en]",
+ fr: "Français [fr]",
+ de: "Deutsch [de]",
+ sv: "Svenska [sv]",
+ it: "Italiano [it]",
+};
+
+function getLangName(s: keyof LangsNames | string) {
+ if (names[s]) return names[s];
+ return s;
+}
+
+export function LangSelector(): VNode {
+ const [updatingLang, setUpdatingLang] = useState(false);
+ const { lang, changeLanguage } = useTranslationContext();
+
+ return (
+ <div class="dropdown is-active ">
+ <div class="dropdown-trigger">
+ <button
+ class="button has-tooltip-left"
+ data-tooltip="change language selection"
+ aria-haspopup="true"
+ aria-controls="dropdown-menu"
+ onClick={() => setUpdatingLang(!updatingLang)}
+ >
+ <div class="icon is-small is-left">
+ <img src={langIcon} />
+ </div>
+ <span>{getLangName(lang)}</span>
+ <div class="icon is-right">
+ <i class="mdi mdi-chevron-down" />
+ </div>
+ </button>
+ </div>
+ {updatingLang && (
+ <div class="dropdown-menu" id="dropdown-menu" role="menu">
+ <div class="dropdown-content">
+ {Object.keys(messages)
+ .filter((l) => l !== lang)
+ .map((l) => (
+ <a
+ key={l}
+ class="dropdown-item"
+ value={l}
+ onClick={() => {
+ changeLanguage(l);
+ setUpdatingLang(false);
+ }}
+ >
+ {getLangName(l)}
+ </a>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx b/packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx
new file mode 100644
index 000000000..9f1b33893
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx
@@ -0,0 +1,72 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import logo from "../../assets/logo-2021.svg";
+
+interface Props {
+ onMobileMenu: () => void;
+ title: string;
+}
+
+export function NavigationBar({ onMobileMenu, title }: Props): VNode {
+ return (
+ <nav
+ class="navbar is-fixed-top"
+ role="navigation"
+ aria-label="main navigation"
+ >
+ <div class="navbar-brand">
+ <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}>
+ {title}
+ </span>
+
+ <a
+ role="button"
+ class="navbar-burger"
+ aria-label="menu"
+ aria-expanded="false"
+ onClick={(e) => {
+ onMobileMenu();
+ e.stopPropagation();
+ }}
+ >
+ <span aria-hidden="true" />
+ <span aria-hidden="true" />
+ <span aria-hidden="true" />
+ </a>
+ </div>
+
+ <div class="navbar-menu ">
+ <a
+ class="navbar-start is-justify-content-center is-flex-grow-1"
+ href="https://taler.net"
+ >
+ <img src={logo} style={{ height: 35, margin: 10 }} />
+ </a>
+ <div class="navbar-end">
+ <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}>
+ </div>
+ </div>
+ </div>
+ </nav>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx b/packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx
new file mode 100644
index 000000000..cfc00148e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx
@@ -0,0 +1,284 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useBackendContext } from "../../context/backend.js";
+import { useConfigContext } from "../../context/config.js";
+import { useInstanceKYCDetails } from "../../hooks/instance.js";
+import { LangSelector } from "./LangSelector.js";
+
+const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
+const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
+
+interface Props {
+ onLogout: () => void;
+ onShowSettings: () => void;
+ mobile?: boolean;
+ instance: string;
+ admin?: boolean;
+ mimic?: boolean;
+ isPasswordOk: boolean;
+}
+
+export function Sidebar({
+ mobile,
+ instance,
+ onShowSettings,
+ onLogout,
+ admin,
+ mimic,
+ isPasswordOk
+}: Props): VNode {
+ const config = useConfigContext();
+ const { url: backendURL } = useBackendContext()
+ const { i18n } = useTranslationContext();
+ const kycStatus = useInstanceKYCDetails();
+ const needKYC = kycStatus.ok && kycStatus.data.type === "redirect";
+
+ return (
+ <aside class="aside is-placed-left is-expanded" style={{ overflowY: "scroll" }}>
+ {mobile && (
+ <div
+ class="footer"
+ onClick={(e) => {
+ return e.stopImmediatePropagation();
+ }}
+ >
+ <LangSelector />
+ </div>
+ )}
+ <div class="aside-tools">
+ <div class="aside-tools-label">
+ <div>
+ <b>Taler</b> Backoffice
+ </div>
+ <div
+ class="is-size-7 has-text-right"
+ style={{ lineHeight: 0, marginTop: -10 }}
+ >
+ {VERSION} ({config.version})
+ </div>
+ </div>
+ </div>
+ <div class="menu is-menu-main">
+ {instance ? (
+ <Fragment>
+ <ul class="menu-list">
+ <li>
+ <a href={"/orders"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-cash-register" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Orders</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/inventory"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-shopping" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Inventory</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/transfers"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-arrow-left-right" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Transfers</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/templates"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Templates</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ {needKYC && (
+ <li>
+ <a href={"/kyc"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-account-check" />
+ </span>
+ <span class="menu-item-label">KYC Status</span>
+ </a>
+ </li>
+ )}
+ </ul>
+ <p class="menu-label">
+ <i18n.Translate>Configuration</i18n.Translate>
+ </p>
+ <ul class="menu-list">
+ <li>
+ <a href={"/bank"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-bank" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Bank account</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/otp-devices"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-lock" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>OTP Devices</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/reserves"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-cash" />
+ </span>
+ <span class="menu-item-label">Reserves</span>
+ </a>
+ </li>
+ <li>
+ <a href={"/webhooks"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Webhooks</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/settings"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-square-edit-outline" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Settings</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/token"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-security" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Access token</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ </ul>
+ </Fragment>
+ ) : undefined}
+ <p class="menu-label">
+ <i18n.Translate>Connection</i18n.Translate>
+ </p>
+ <ul class="menu-list">
+ <li>
+ <a class="has-icon is-state-info is-hoverable"
+ onClick={(): void => onShowSettings()}
+ >
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Interface</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <div>
+ <span style={{ width: "3rem" }} class="icon">
+ <i class="mdi mdi-web" />
+ </span>
+ <span class="menu-item-label">
+ {new URL(backendURL).hostname}
+ </span>
+ </div>
+ </li>
+ <li>
+ <div>
+ <span style={{ width: "3rem" }} class="icon">
+ ID
+ </span>
+ <span class="menu-item-label">
+ {!instance ? "default" : instance}
+ </span>
+ </div>
+ </li>
+ {admin && !mimic && (
+ <Fragment>
+ <p class="menu-label">
+ <i18n.Translate>Instances</i18n.Translate>
+ </p>
+ <li>
+ <a href={"/instance/new"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-plus" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>New</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/instances"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-format-list-bulleted" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>List</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ </Fragment>
+ )}
+ {isPasswordOk ?
+ <li>
+ <a
+ class="has-icon is-state-info is-hoverable"
+ onClick={(): void => onLogout()}
+ >
+ <span class="icon">
+ <i class="mdi mdi-logout default" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Log out</i18n.Translate>
+ </span>
+ </a>
+ </li> : undefined
+ }
+ </ul>
+ </div>
+ </aside>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/menu/index.tsx b/packages/auditor-backoffice-ui/src/components/menu/index.tsx
new file mode 100644
index 000000000..015d3bd05
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/menu/index.tsx
@@ -0,0 +1,237 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { AdminPaths } from "../../AdminRoutes.js";
+import { InstancePaths } from "../../InstanceRoutes.js";
+import { Notification } from "../../utils/types.js";
+import { NavigationBar } from "./NavigationBar.js";
+import { Sidebar } from "./SideBar.js";
+
+function getInstanceTitle(path: string, id: string): string {
+ switch (path) {
+ case InstancePaths.settings:
+ return `${id}: Settings`;
+ case InstancePaths.inventory_list:
+ return `${id}: Inventory`;
+ case InstancePaths.deposit_confirmation_list:
+ return `${id}: Deposit Confirmation`;
+ case InstancePaths.inventory_new:
+ return `${id}: New product`;
+ case InstancePaths.inventory_update:
+ return `${id}: Update product`;
+ case InstancePaths.interface:
+ return `${id}: Interface`;
+ default:
+ return "";
+ }
+}
+
+function getAdminTitle(path: string, instance: string) {
+ if (path === AdminPaths.new_instance) return `New instance`;
+ if (path === AdminPaths.list_instances) return `Instances`;
+ return getInstanceTitle(path, instance);
+}
+
+interface MenuProps {
+ title?: string;
+ path: string;
+ instance: string;
+ admin?: boolean;
+ onLogout?: () => void;
+ onShowSettings: () => void;
+ setInstanceName: (s: string) => void;
+ isPasswordOk: boolean;
+}
+
+function WithTitle({
+ title,
+ children,
+}: {
+ title: string;
+ children: ComponentChildren;
+}): VNode {
+ useEffect(() => {
+ document.title = `Taler Backoffice: ${title}`;
+ }, [title]);
+ return <Fragment>{children}</Fragment>;
+}
+
+export function Menu({
+ onLogout,
+ onShowSettings,
+ title,
+ instance,
+ path,
+ admin,
+ setInstanceName,
+ isPasswordOk
+}: MenuProps): VNode {
+ const [mobileOpen, setMobileOpen] = useState(false);
+
+ const titleWithSubtitle = title
+ ? title
+ : !admin
+ ? getInstanceTitle(path, instance)
+ : getAdminTitle(path, instance);
+ const adminInstance = instance === "default";
+ const mimic = admin && !adminInstance;
+ return (
+ <WithTitle title={titleWithSubtitle}>
+ <div
+ class={mobileOpen ? "has-aside-mobile-expanded" : ""}
+ onClick={() => setMobileOpen(false)}
+ >
+ <NavigationBar
+ onMobileMenu={() => setMobileOpen(!mobileOpen)}
+ title={titleWithSubtitle}
+ />
+
+ {onLogout && (
+ <Sidebar
+ onShowSettings={onShowSettings}
+ onLogout={onLogout}
+ admin={admin}
+ mimic={mimic}
+ instance={instance}
+ mobile={mobileOpen}
+ isPasswordOk={isPasswordOk}
+ />
+ )}
+
+ {mimic && (
+ <nav class="level" style={{
+ zIndex: 100,
+ position: "fixed",
+ width: "50%",
+ marginLeft: "20%"
+ }}>
+ <div class="level-item has-text-centered has-background-warning">
+ <p class="is-size-5">
+ You are viewing the instance <b>&quot;{instance}&quot;</b>.{" "}
+ <a
+ href="#/instances"
+ onClick={(e) => {
+ setInstanceName("default");
+ }}
+ >
+ go back
+ </a>
+ </p>
+ </div>
+ </nav>
+ )}
+ </div>
+ </WithTitle>
+ );
+}
+
+interface NotYetReadyAppMenuProps {
+ title: string;
+ onShowSettings: () => void;
+ onLogout?: () => void;
+ isPasswordOk: boolean;
+}
+
+interface NotifProps {
+ notification?: Notification;
+}
+export function NotificationCard({
+ notification: n,
+}: NotifProps): VNode | null {
+ if (!n) return null;
+ return (
+ <div class="notification">
+ <div class="columns is-vcentered">
+ <div class="column is-12">
+ <article
+ class={
+ n.type === "ERROR"
+ ? "message is-danger"
+ : n.type === "WARN"
+ ? "message is-warning"
+ : "message is-info"
+ }
+ >
+ <div class="message-header">
+ <p>{n.message}</p>
+ </div>
+ {n.description && (
+ <div class="message-body">
+ <div>{n.description}</div>
+ {n.details && <pre>{n.details}</pre>}
+ </div>
+ )}
+ </article>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+interface NotConnectedAppMenuProps {
+ title: string;
+}
+export function NotConnectedAppMenu({
+ title,
+}: NotConnectedAppMenuProps): VNode {
+ const [mobileOpen, setMobileOpen] = useState(false);
+
+ useEffect(() => {
+ document.title = `Taler Backoffice: ${title}`;
+ }, [title]);
+
+ return (
+ <div
+ class={mobileOpen ? "has-aside-mobile-expanded" : ""}
+ onClick={() => setMobileOpen(false)}
+ >
+ <NavigationBar
+ onMobileMenu={() => setMobileOpen(!mobileOpen)}
+ title={title}
+ />
+ </div>
+ );
+}
+
+export function NotYetReadyAppMenu({
+ onLogout,
+ onShowSettings,
+ title,
+ isPasswordOk
+}: NotYetReadyAppMenuProps): VNode {
+ const [mobileOpen, setMobileOpen] = useState(false);
+
+ useEffect(() => {
+ document.title = `Taler Backoffice: ${title}`;
+ }, [title]);
+
+ return (
+ <div
+ class={mobileOpen ? "has-aside-mobile-expanded" : ""}
+ onClick={() => setMobileOpen(false)}
+ >
+ <NavigationBar
+ onMobileMenu={() => setMobileOpen(!mobileOpen)}
+ title={title}
+ />
+ {onLogout && (
+ <Sidebar onShowSettings={onShowSettings} onLogout={onLogout} instance="" mobile={mobileOpen} isPasswordOk={isPasswordOk} />
+ )}
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/modal/index.tsx b/packages/auditor-backoffice-ui/src/components/modal/index.tsx
new file mode 100644
index 000000000..8372c84cc
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/modal/index.tsx
@@ -0,0 +1,496 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useInstanceContext } from "../../context/instance.js";
+import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js";
+import { Spinner } from "../exception/loading.js";
+import { FormProvider } from "../form/FormProvider.js";
+import { Input } from "../form/Input.js";
+
+interface Props {
+ active?: boolean;
+ description?: string;
+ onCancel?: () => void;
+ onConfirm?: () => void;
+ label?: string;
+ children?: ComponentChildren;
+ danger?: boolean;
+ disabled?: boolean;
+}
+
+export function ConfirmModal({
+ active,
+ description,
+ onCancel,
+ onConfirm,
+ children,
+ danger,
+ disabled,
+ label = "Confirm",
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class={active ? "modal is-active" : "modal"}>
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card" style={{ maxWidth: 700 }}>
+ <header class="modal-card-head">
+ {!description ? null : (
+ <p class="modal-card-title">
+ <b>{description}</b>
+ </p>
+ )}
+ <button class="delete " aria-label="close" onClick={onCancel} />
+ </header>
+ <section class="modal-card-body">{children}</section>
+ <footer class="modal-card-foot">
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ {onConfirm ? (
+ <Fragment>
+ <button class="button " onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+
+ <button
+ class={danger ? "button is-danger " : "button is-info "}
+ disabled={disabled}
+ onClick={onConfirm}
+ >
+ <i18n.Translate>{label}</i18n.Translate>
+ </button>
+ </Fragment>
+ ) : (
+ <button class="button " onClick={onCancel}>
+ <i18n.Translate>Close</i18n.Translate>
+ </button>
+ )}
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
+
+export function ContinueModal({
+ active,
+ description,
+ onCancel,
+ onConfirm,
+ children,
+ disabled,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class={active ? "modal is-active" : "modal"}>
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card">
+ <header class="modal-card-head has-background-success">
+ {!description ? null : <p class="modal-card-title">{description}</p>}
+ <button class="delete " aria-label="close" onClick={onCancel} />
+ </header>
+ <section class="modal-card-body">{children}</section>
+ <footer class="modal-card-foot">
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ <button
+ class="button is-success "
+ disabled={disabled}
+ onClick={onConfirm}
+ >
+ <i18n.Translate>Continue</i18n.Translate>
+ </button>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
+
+export function SimpleModal({ onCancel, children }: any): VNode {
+ return (
+ <div class="modal is-active">
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card">
+ <section class="modal-card-body is-main-section">{children}</section>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
+
+export function ClearConfirmModal({
+ description,
+ onCancel,
+ onClear,
+ onConfirm,
+ children,
+}: Props & { onClear?: () => void }): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="modal is-active">
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card">
+ <header class="modal-card-head">
+ {!description ? null : <p class="modal-card-title">{description}</p>}
+ <button class="delete " aria-label="close" onClick={onCancel} />
+ </header>
+ <section class="modal-card-body is-main-section">{children}</section>
+ <footer class="modal-card-foot">
+ {onClear && (
+ <button
+ class="button is-danger"
+ onClick={onClear}
+ disabled={onClear === undefined}
+ >
+ <i18n.Translate>Clear</i18n.Translate>
+ </button>
+ )}
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ <button class="button " onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ class="button is-info"
+ onClick={onConfirm}
+ disabled={onConfirm === undefined}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
+
+interface DeleteModalProps {
+ element: { id: string; name: string };
+ onCancel: () => void;
+ onConfirm: (id: string) => void;
+}
+
+export function DeleteModal({
+ element,
+ onCancel,
+ onConfirm,
+}: DeleteModalProps): VNode {
+ return (
+ <ConfirmModal
+ label={`Delete instance`}
+ description={`Delete the instance "${element.name}"`}
+ danger
+ active
+ onCancel={onCancel}
+ onConfirm={() => onConfirm(element.id)}
+ >
+ <p>
+ If you delete the instance named <b>&quot;{element.name}&quot;</b> (ID:{" "}
+ <b>{element.id}</b>), the merchant will no longer be able to process
+ orders or refunds
+ </p>
+ <p>
+ This action deletes the instance private key, but preserves all
+ transaction data. You can still access that data after deleting the
+ instance.
+ </p>
+ <p class="warning">
+ Deleting an instance <b>cannot be undone</b>.
+ </p>
+ </ConfirmModal>
+ );
+}
+
+export function PurgeModal({
+ element,
+ onCancel,
+ onConfirm,
+}: DeleteModalProps): VNode {
+ return (
+ <ConfirmModal
+ label={`Purge the instance`}
+ description={`Purge the instance "${element.name}"`}
+ danger
+ active
+ onCancel={onCancel}
+ onConfirm={() => onConfirm(element.id)}
+ >
+ <p>
+ If you purge the instance named <b>&quot;{element.name}&quot;</b> (ID:{" "}
+ <b>{element.id}</b>), you will also delete all it&apos;s transaction
+ data.
+ </p>
+ <p>
+ The instance will disappear from your list, and you will no longer be
+ able to access it&apos;s data.
+ </p>
+ <p class="warning">
+ Purging an instance <b>cannot be undone</b>.
+ </p>
+ </ConfirmModal>
+ );
+}
+
+interface UpdateTokenModalProps {
+ oldToken?: string;
+ onCancel: () => void;
+ onConfirm: (value: string) => void;
+ onClear: () => void;
+}
+
+//FIXME: merge UpdateTokenModal with SetTokenNewInstanceModal
+export function UpdateTokenModal({
+ onCancel,
+ onClear,
+ onConfirm,
+ oldToken,
+}: UpdateTokenModalProps): VNode {
+ type State = { old_token: string; new_token: string; repeat_token: string };
+ const [form, setValue] = useState<Partial<State>>({
+ old_token: "",
+ new_token: "",
+ repeat_token: "",
+ });
+ const { i18n } = useTranslationContext();
+
+ const hasInputTheCorrectOldToken = oldToken && oldToken !== form.old_token;
+ const errors = {
+ old_token: hasInputTheCorrectOldToken
+ ? i18n.str`is not the same as the current access token`
+ : undefined,
+ new_token: !form.new_token
+ ? i18n.str`cannot be empty`
+ : form.new_token === form.old_token
+ ? i18n.str`cannot be the same as the old token`
+ : undefined,
+ repeat_token:
+ form.new_token !== form.repeat_token
+ ? i18n.str`is not the same`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const instance = useInstanceContext();
+
+ const text = i18n.str`You are updating the access token from instance with id ${instance.id}`;
+
+ return (
+ <ClearConfirmModal
+ description={text}
+ onCancel={onCancel}
+ onConfirm={!hasErrors ? () => onConfirm(form.new_token!) : undefined}
+ onClear={!hasInputTheCorrectOldToken && oldToken ? onClear : undefined}
+ >
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider errors={errors} object={form} valueHandler={setValue}>
+ {oldToken && (
+ <Input<State>
+ name="old_token"
+ label={i18n.str`Old access token`}
+ tooltip={i18n.str`access token currently in use`}
+ inputType="password"
+ />
+ )}
+ <Input<State>
+ name="new_token"
+ label={i18n.str`New access token`}
+ tooltip={i18n.str`next access token to be used`}
+ inputType="password"
+ />
+ <Input<State>
+ name="repeat_token"
+ label={i18n.str`Repeat access token`}
+ tooltip={i18n.str`confirm the same access token`}
+ inputType="password"
+ />
+ </FormProvider>
+ <p>
+ <i18n.Translate>
+ Clearing the access token will mean public access to the instance
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="column" />
+ </div>
+ </ClearConfirmModal>
+ );
+}
+
+export function SetTokenNewInstanceModal({
+ onCancel,
+ onClear,
+ onConfirm,
+}: UpdateTokenModalProps): VNode {
+ type State = { old_token: string; new_token: string; repeat_token: string };
+ const [form, setValue] = useState<Partial<State>>({
+ new_token: "",
+ repeat_token: "",
+ });
+ const { i18n } = useTranslationContext();
+
+ const errors = {
+ new_token: !form.new_token
+ ? i18n.str`cannot be empty`
+ : form.new_token === form.old_token
+ ? i18n.str`cannot be the same as the old access token`
+ : undefined,
+ repeat_token:
+ form.new_token !== form.repeat_token
+ ? i18n.str`is not the same`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ return (
+ <div class="modal is-active">
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card">
+ <header class="modal-card-head">
+ <p class="modal-card-title">{i18n.str`You are setting the access token for the new instance`}</p>
+ <button class="delete " aria-label="close" onClick={onCancel} />
+ </header>
+ <section class="modal-card-body is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ errors={errors}
+ object={form}
+ valueHandler={setValue}
+ >
+ <Input<State>
+ name="new_token"
+ label={i18n.str`New access token`}
+ tooltip={i18n.str`next access token to be used`}
+ inputType="password"
+ />
+ <Input<State>
+ name="repeat_token"
+ label={i18n.str`Repeat access token`}
+ tooltip={i18n.str`confirm the same access token`}
+ inputType="password"
+ />
+ </FormProvider>
+ <p>
+ <i18n.Translate>
+ With external authorization method no check will be done by
+ the merchant backend
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ <footer class="modal-card-foot">
+ {onClear && (
+ <button
+ class="button is-danger"
+ onClick={onClear}
+ disabled={onClear === undefined}
+ >
+ <i18n.Translate>Set external authorization</i18n.Translate>
+ </button>
+ )}
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ <button class="button " onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ class="button is-info"
+ onClick={() => onConfirm(form.new_token!)}
+ disabled={hasErrors}
+ >
+ <i18n.Translate>Set access token</i18n.Translate>
+ </button>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
+
+export function LoadingModal({ onCancel }: { onCancel: () => void }): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="modal is-active">
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card">
+ <header class="modal-card-head">
+ <p class="modal-card-title">
+ <i18n.Translate>Operation in progress...</i18n.Translate>
+ </p>
+ </header>
+ <section class="modal-card-body">
+ <div class="columns">
+ <div class="column" />
+ <Spinner />
+ <div class="column" />
+ </div>
+ <p>{i18n.str`The operation will be automatically canceled after ${DEFAULT_REQUEST_TIMEOUT} seconds`}</p>
+ </section>
+ <footer class="modal-card-foot">
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ <button class="button " onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx
new file mode 100644
index 000000000..073382fb1
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx
@@ -0,0 +1,57 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+
+interface Props {
+ onCreateAnother?: () => void;
+ onConfirm: () => void;
+ children: ComponentChildren;
+}
+
+export function CreatedSuccessfully({
+ children,
+ onConfirm,
+ onCreateAnother,
+}: Props): VNode {
+ return (
+ <div class="columns is-fullwidth is-vcentered mt-3">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <div class="card">
+ <header class="card-header has-background-success">
+ <p class="card-header-title has-text-white-ter">Success.</p>
+ </header>
+ <div class="card-content">{children}</div>
+ </div>
+ <div class="buttons is-right">
+ {onCreateAnother && (
+ <button class="button is-info" onClick={onCreateAnother}>
+ Create another
+ </button>
+ )}
+ <button class="button is-info" onClick={onConfirm}>
+ Continue
+ </button>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/notifications/Notifications.stories.tsx b/packages/auditor-backoffice-ui/src/components/notifications/Notifications.stories.tsx
new file mode 100644
index 000000000..af594de0f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/notifications/Notifications.stories.tsx
@@ -0,0 +1,62 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h } from "preact";
+import { Notifications } from "./index.js";
+
+export default {
+ title: "Components/Notification",
+ component: Notifications,
+ argTypes: {
+ removeNotification: { action: "removeNotification" },
+ },
+};
+
+export const Info = (a: any) => <Notifications {...a} />;
+Info.args = {
+ notifications: [
+ {
+ message: "Title",
+ description: "Some large description",
+ type: "INFO",
+ },
+ ],
+};
+export const Warn = (a: any) => <Notifications {...a} />;
+Warn.args = {
+ notifications: [
+ {
+ message: "Title",
+ description: "Some large description",
+ type: "WARN",
+ },
+ ],
+};
+export const Error = (a: any) => <Notifications {...a} />;
+Error.args = {
+ notifications: [
+ {
+ message: "Title",
+ description: "Some large description",
+ type: "ERROR",
+ },
+ ],
+};
diff --git a/packages/auditor-backoffice-ui/src/components/notifications/index.tsx b/packages/auditor-backoffice-ui/src/components/notifications/index.tsx
new file mode 100644
index 000000000..235c75577
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/notifications/index.tsx
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { MessageType, Notification } from "../../utils/types.js";
+
+interface Props {
+ notifications: Notification[];
+ removeNotification?: (n: Notification) => void;
+}
+
+function messageStyle(type: MessageType): string {
+ switch (type) {
+ case "INFO":
+ return "message is-info";
+ case "WARN":
+ return "message is-warning";
+ case "ERROR":
+ return "message is-danger";
+ case "SUCCESS":
+ return "message is-success";
+ default:
+ return "message";
+ }
+}
+
+export function Notifications({
+ notifications,
+ removeNotification,
+}: Props): VNode {
+ return (
+ <div class="toast">
+ {notifications.map((n, i) => (
+ <article key={i} class={messageStyle(n.type)}>
+ <div class="message-header">
+ <p>{n.message}</p>
+ <button
+ class="delete"
+ onClick={() => removeNotification && removeNotification(n)}
+ />
+ </div>
+ {n.description && <div class="message-body">{n.description}</div>}
+ </article>
+ ))}
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/picker/DatePicker.tsx b/packages/auditor-backoffice-ui/src/components/picker/DatePicker.tsx
new file mode 100644
index 000000000..0bc629d46
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/picker/DatePicker.tsx
@@ -0,0 +1,349 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, Component } from "preact";
+
+interface Props {
+ closeFunction?: () => void;
+ dateReceiver?: (d: Date) => void;
+ opened?: boolean;
+}
+interface State {
+ displayedMonth: number;
+ displayedYear: number;
+ selectYearMode: boolean;
+ currentDate: Date;
+}
+
+// inspired by https://codepen.io/m4r1vs/pen/MOOxyE
+export class DatePicker extends Component<Props, State> {
+ closeDatePicker() {
+ this.props.closeFunction && this.props.closeFunction(); // Function gets passed by parent
+ }
+
+ /**
+ * Gets fired when a day gets clicked.
+ * @param {object} e The event thrown by the <span /> element clicked
+ */
+ dayClicked(e: any) {
+ const element = e.target; // the actual element clicked
+
+ if (element.innerHTML === "") return false; // don't continue if <span /> empty
+
+ // get date from clicked element (gets attached when rendered)
+ const date = new Date(element.getAttribute("data-value"));
+
+ // update the state
+ this.setState({ currentDate: date });
+ this.passDateToParent(date);
+ }
+
+ /**
+ * returns days in month as array
+ * @param {number} month the month to display
+ * @param {number} year the year to display
+ */
+ getDaysByMonth(month: number, year: number) {
+ const calendar = [];
+
+ const date = new Date(year, month, 1); // month to display
+
+ const firstDay = new Date(year, month, 1).getDay(); // first weekday of month
+ const lastDate = new Date(year, month + 1, 0).getDate(); // last date of month
+
+ let day: number | null = 0;
+
+ // the calendar is 7*6 fields big, so 42 loops
+ for (let i = 0; i < 42; i++) {
+ if (i >= firstDay && day !== null) day = day + 1;
+ if (day !== null && day > lastDate) day = null;
+
+ // append the calendar Array
+ calendar.push({
+ day: day === 0 || day === null ? null : day, // null or number
+ date: day === 0 || day === null ? null : new Date(year, month, day), // null or Date()
+ today:
+ day === now.getDate() &&
+ month === now.getMonth() &&
+ year === now.getFullYear(), // boolean
+ });
+ }
+
+ return calendar;
+ }
+
+ /**
+ * Display previous month by updating state
+ */
+ displayPrevMonth() {
+ if (this.state.displayedMonth <= 0) {
+ this.setState({
+ displayedMonth: 11,
+ displayedYear: this.state.displayedYear - 1,
+ });
+ } else {
+ this.setState({
+ displayedMonth: this.state.displayedMonth - 1,
+ });
+ }
+ }
+
+ /**
+ * Display next month by updating state
+ */
+ displayNextMonth() {
+ if (this.state.displayedMonth >= 11) {
+ this.setState({
+ displayedMonth: 0,
+ displayedYear: this.state.displayedYear + 1,
+ });
+ } else {
+ this.setState({
+ displayedMonth: this.state.displayedMonth + 1,
+ });
+ }
+ }
+
+ /**
+ * Display the selected month (gets fired when clicking on the date string)
+ */
+ displaySelectedMonth() {
+ if (this.state.selectYearMode) {
+ this.toggleYearSelector();
+ } else {
+ if (!this.state.currentDate) return false;
+ this.setState({
+ displayedMonth: this.state.currentDate.getMonth(),
+ displayedYear: this.state.currentDate.getFullYear(),
+ });
+ }
+ }
+
+ toggleYearSelector() {
+ this.setState({ selectYearMode: !this.state.selectYearMode });
+ }
+
+ changeDisplayedYear(e: any) {
+ const element = e.target;
+ this.toggleYearSelector();
+ this.setState({
+ displayedYear: parseInt(element.innerHTML, 10),
+ displayedMonth: 0,
+ });
+ }
+
+ /**
+ * Pass the selected date to parent when 'OK' is clicked
+ */
+ passSavedDateDateToParent() {
+ this.passDateToParent(this.state.currentDate);
+ }
+ passDateToParent(date: Date) {
+ if (typeof this.props.dateReceiver === "function")
+ this.props.dateReceiver(date);
+ this.closeDatePicker();
+ }
+
+ componentDidUpdate() {
+ if (this.state.selectYearMode) {
+ document.getElementsByClassName("selected")[0].scrollIntoView(); // works in every browser incl. IE, replace with scrollIntoViewIfNeeded when browsers support it
+ }
+ }
+
+ constructor() {
+ super();
+
+ this.closeDatePicker = this.closeDatePicker.bind(this);
+ this.dayClicked = this.dayClicked.bind(this);
+ this.displayNextMonth = this.displayNextMonth.bind(this);
+ this.displayPrevMonth = this.displayPrevMonth.bind(this);
+ this.getDaysByMonth = this.getDaysByMonth.bind(this);
+ this.changeDisplayedYear = this.changeDisplayedYear.bind(this);
+ this.passDateToParent = this.passDateToParent.bind(this);
+ this.toggleYearSelector = this.toggleYearSelector.bind(this);
+ this.displaySelectedMonth = this.displaySelectedMonth.bind(this);
+
+ this.state = {
+ currentDate: now,
+ displayedMonth: now.getMonth(),
+ displayedYear: now.getFullYear(),
+ selectYearMode: false,
+ };
+ }
+
+ render() {
+ const { currentDate, displayedMonth, displayedYear, selectYearMode } =
+ this.state;
+
+ return (
+ <div>
+ <div class={`datePicker ${this.props.opened && "datePicker--opened"}`}>
+ <div class="datePicker--titles">
+ <h3
+ style={{
+ color: selectYearMode
+ ? "rgba(255,255,255,.87)"
+ : "rgba(255,255,255,.57)",
+ }}
+ onClick={this.toggleYearSelector}
+ >
+ {currentDate.getFullYear()}
+ </h3>
+ <h2
+ style={{
+ color: !selectYearMode
+ ? "rgba(255,255,255,.87)"
+ : "rgba(255,255,255,.57)",
+ }}
+ onClick={this.displaySelectedMonth}
+ >
+ {dayArr[currentDate.getDay()]},{" "}
+ {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()}
+ </h2>
+ </div>
+
+ {!selectYearMode && (
+ <nav>
+ <span onClick={this.displayPrevMonth} class="icon">
+ <i
+ style={{ transform: "rotate(180deg)" }}
+ class="mdi mdi-forward"
+ />
+ </span>
+ <h4>
+ {monthArrShortFull[displayedMonth]} {displayedYear}
+ </h4>
+ <span onClick={this.displayNextMonth} class="icon">
+ <i class="mdi mdi-forward" />
+ </span>
+ </nav>
+ )}
+
+ <div class="datePicker--scroll">
+ {!selectYearMode && (
+ <div class="datePicker--calendar">
+ <div class="datePicker--dayNames">
+ {["S", "M", "T", "W", "T", "F", "S"].map((day, i) => (
+ <span key={i}>{day}</span>
+ ))}
+ </div>
+
+ <div onClick={this.dayClicked} class="datePicker--days">
+ {/*
+ Loop through the calendar object returned by getDaysByMonth().
+ */}
+
+ {this.getDaysByMonth(
+ this.state.displayedMonth,
+ this.state.displayedYear,
+ ).map((day) => {
+ let selected = false;
+
+ if (currentDate && day.date)
+ selected =
+ currentDate.toLocaleDateString() ===
+ day.date.toLocaleDateString();
+
+ return (
+ <span
+ key={day.day}
+ class={
+ (day.today ? "datePicker--today " : "") +
+ (selected ? "datePicker--selected" : "")
+ }
+ disabled={!day.date}
+ data-value={day.date}
+ >
+ {day.day}
+ </span>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {selectYearMode && (
+ <div class="datePicker--selectYear">
+ {yearArr.map((year) => (
+ <span
+ key={year}
+ class={year === displayedYear ? "selected" : ""}
+ onClick={this.changeDisplayedYear}
+ >
+ {year}
+ </span>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div
+ class="datePicker--background"
+ onClick={this.closeDatePicker}
+ style={{
+ display: this.props.opened ? "block" : "none",
+ }}
+ />
+ </div>
+ );
+ }
+}
+
+const monthArrShortFull = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+];
+
+const monthArrShort = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+];
+
+const dayArr = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+const now = new Date();
+
+const yearArr: number[] = [];
+
+for (let i = 2010; i <= now.getFullYear() + 10; i++) {
+ yearArr.push(i);
+}
diff --git a/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.stories.tsx b/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.stories.tsx
new file mode 100644
index 000000000..8f74d55ac
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.stories.tsx
@@ -0,0 +1,55 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, FunctionalComponent } from "preact";
+import { useState } from "preact/hooks";
+import { DurationPicker as TestedComponent } from "./DurationPicker.js";
+
+export default {
+ title: "Components/Picker/Duration",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ goBack: { action: "goBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ days: true,
+ minutes: true,
+ hours: true,
+ seconds: true,
+ value: 10000000,
+});
+
+export const WithState = () => {
+ const [v, s] = useState<number>(1000000);
+ return <TestedComponent value={v} onChange={s} days minutes hours seconds />;
+};
diff --git a/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.tsx b/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.tsx
new file mode 100644
index 000000000..ba003cce5
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.tsx
@@ -0,0 +1,211 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import "../../scss/DurationPicker.scss";
+
+export interface Props {
+ hours?: boolean;
+ minutes?: boolean;
+ seconds?: boolean;
+ days?: boolean;
+ onChange: (value: number) => void;
+ value: number;
+}
+
+// inspiration taken from https://github.com/flurmbo/react-duration-picker
+export function DurationPicker({
+ days,
+ hours,
+ minutes,
+ seconds,
+ onChange,
+ value,
+}: Props): VNode {
+ const ss = 1000;
+ const ms = ss * 60;
+ const hs = ms * 60;
+ const ds = hs * 24;
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="rdp-picker">
+ {days && (
+ <DurationColumn
+ unit={i18n.str`days`}
+ max={99}
+ value={Math.floor(value / ds)}
+ onDecrease={value >= ds ? () => onChange(value - ds) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + ds) : undefined}
+ onChange={(diff) => onChange(value + diff * ds)}
+ />
+ )}
+ {hours && (
+ <DurationColumn
+ unit={i18n.str`hours`}
+ max={23}
+ min={1}
+ value={Math.floor(value / hs) % 24}
+ onDecrease={value >= hs ? () => onChange(value - hs) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + hs) : undefined}
+ onChange={(diff) => onChange(value + diff * hs)}
+ />
+ )}
+ {minutes && (
+ <DurationColumn
+ unit={i18n.str`minutes`}
+ max={59}
+ min={1}
+ value={Math.floor(value / ms) % 60}
+ onDecrease={value >= ms ? () => onChange(value - ms) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + ms) : undefined}
+ onChange={(diff) => onChange(value + diff * ms)}
+ />
+ )}
+ {seconds && (
+ <DurationColumn
+ unit={i18n.str`seconds`}
+ max={59}
+ value={Math.floor(value / ss) % 60}
+ onDecrease={value >= ss ? () => onChange(value - ss) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + ss) : undefined}
+ onChange={(diff) => onChange(value + diff * ss)}
+ />
+ )}
+ </div>
+ );
+}
+
+interface ColProps {
+ unit: string;
+ min?: number;
+ max: number;
+ value: number;
+ onIncrease?: () => void;
+ onDecrease?: () => void;
+ onChange?: (diff: number) => void;
+}
+
+function InputNumber({
+ initial,
+ onChange,
+}: {
+ initial: number;
+ onChange: (n: number) => void;
+}) {
+ const [value, handler] = useState<{ v: string }>({
+ v: toTwoDigitString(initial),
+ });
+
+ return (
+ <input
+ value={value.v}
+ onBlur={(e) => onChange(parseInt(value.v, 10))}
+ onInput={(e) => {
+ e.preventDefault();
+ const n = Number.parseInt(e.currentTarget.value, 10);
+ if (isNaN(n)) return handler({ v: toTwoDigitString(initial) });
+ return handler({ v: toTwoDigitString(n) });
+ }}
+ style={{
+ width: 50,
+ border: "none",
+ fontSize: "inherit",
+ background: "inherit",
+ }}
+ />
+ );
+}
+
+function DurationColumn({
+ unit,
+ min = 0,
+ max,
+ value,
+ onIncrease,
+ onDecrease,
+ onChange,
+}: ColProps): VNode {
+ const cellHeight = 35;
+ return (
+ <div class="rdp-column-container">
+ <div class="rdp-masked-div">
+ <hr class="rdp-reticule" style={{ top: cellHeight * 2 - 1 }} />
+ <hr class="rdp-reticule" style={{ top: cellHeight * 3 - 1 }} />
+
+ <div class="rdp-column" style={{ top: 0 }}>
+ <div class="rdp-cell" key={value - 2}>
+ {onDecrease && (
+ <button
+ style={{ width: "100%", textAlign: "center", margin: 5 }}
+ onClick={onDecrease}
+ >
+ <span class="icon">
+ <i class="mdi mdi-chevron-up" />
+ </span>
+ </button>
+ )}
+ </div>
+ <div class="rdp-cell" key={value - 1}>
+ {value > min ? toTwoDigitString(value - 1) : ""}
+ </div>
+ <div class="rdp-cell rdp-center" key={value}>
+ {onChange ? (
+ <InputNumber
+ initial={value}
+ onChange={(n) => onChange(n - value)}
+ />
+ ) : (
+ toTwoDigitString(value)
+ )}
+ <div>{unit}</div>
+ </div>
+
+ <div class="rdp-cell" key={value + 1}>
+ {value < max ? toTwoDigitString(value + 1) : ""}
+ </div>
+
+ <div class="rdp-cell" key={value + 2}>
+ {onIncrease && (
+ <button
+ style={{ width: "100%", textAlign: "center", margin: 5 }}
+ onClick={onIncrease}
+ >
+ <span class="icon">
+ <i class="mdi mdi-chevron-down" />
+ </span>
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function toTwoDigitString(n: number) {
+ if (n < 10) {
+ return `0${n}`;
+ }
+ return `${n}`;
+}
diff --git a/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx b/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx
new file mode 100644
index 000000000..2d5a54cde
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx
@@ -0,0 +1,62 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { InventoryProductForm as TestedComponent } from "./InventoryProductForm.js";
+
+export default {
+ title: "Components/Product/Add",
+ component: TestedComponent,
+ argTypes: {
+ onAddProduct: { action: "onAddProduct" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const WithASimpleList = createExample(TestedComponent, {
+ inventory: [
+ {
+ id: "this id",
+ description: "this is the description",
+ } as any,
+ ],
+});
+
+export const WithAProductSelected = createExample(TestedComponent, {
+ inventory: [],
+ currentProducts: {
+ thisid: {
+ quantity: 1,
+ product: {
+ id: "asd",
+ description: "asdsadsad",
+ } as any,
+ },
+ },
+});
diff --git a/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.tsx b/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.tsx
new file mode 100644
index 000000000..377d9c1ba
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.tsx
@@ -0,0 +1,127 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { MerchantBackend, WithId } from "../../declaration.js";
+import { ProductMap } from "../../paths/instance/orders/create/CreatePage.js";
+import { FormErrors, FormProvider } from "../form/FormProvider.js";
+import { InputNumber } from "../form/InputNumber.js";
+import { InputSearchOnList } from "../form/InputSearchOnList.js";
+
+type Form = {
+ product: MerchantBackend.Products.ProductDetail & WithId;
+ quantity: number;
+};
+
+interface Props {
+ currentProducts: ProductMap;
+ onAddProduct: (
+ product: MerchantBackend.Products.ProductDetail & WithId,
+ quantity: number,
+ ) => void;
+ inventory: (MerchantBackend.Products.ProductDetail & WithId)[];
+}
+
+export function InventoryProductForm({
+ currentProducts,
+ onAddProduct,
+ inventory,
+}: Props): VNode {
+ const initialState = { quantity: 1 };
+ const [state, setState] = useState<Partial<Form>>(initialState);
+ const [errors, setErrors] = useState<FormErrors<Form>>({});
+
+ const { i18n } = useTranslationContext();
+
+ const productWithInfiniteStock =
+ state.product && state.product.total_stock === -1;
+
+ const submit = (): void => {
+ if (!state.product) {
+ setErrors({
+ product: i18n.str`You must enter a valid product identifier.`,
+ });
+ return;
+ }
+ if (productWithInfiniteStock) {
+ onAddProduct(state.product, 1);
+ } else {
+ if (!state.quantity || state.quantity <= 0) {
+ setErrors({ quantity: i18n.str`Quantity must be greater than 0!` });
+ return;
+ }
+ const currentStock =
+ state.product.total_stock -
+ state.product.total_lost -
+ state.product.total_sold;
+ const p = currentProducts[state.product.id];
+ if (p) {
+ if (state.quantity + p.quantity > currentStock) {
+ const left = currentStock - p.quantity;
+ setErrors({
+ quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`,
+ });
+ return;
+ }
+ onAddProduct(state.product, state.quantity + p.quantity);
+ } else {
+ if (state.quantity > currentStock) {
+ const left = currentStock;
+ setErrors({
+ quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`,
+ });
+ return;
+ }
+ onAddProduct(state.product, state.quantity);
+ }
+ }
+
+ setState(initialState);
+ };
+
+ return (
+ <FormProvider<Form> errors={errors} object={state} valueHandler={setState}>
+ <InputSearchOnList
+ label={i18n.str`Search product`}
+ selected={state.product}
+ onChange={(p) => setState((v) => ({ ...v, product: p }))}
+ list={inventory}
+ withImage
+ />
+ {state.product && (
+ <div class="columns mt-5">
+ <div class="column is-two-thirds">
+ {!productWithInfiniteStock && (
+ <InputNumber<Form>
+ name="quantity"
+ label={i18n.str`Quantity`}
+ tooltip={i18n.str`how many products will be added`}
+ />
+ )}
+ </div>
+ <div class="column">
+ <div class="buttons is-right">
+ <button class="button is-success" onClick={submit}>
+ <i18n.Translate>Add from inventory</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+ </FormProvider>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/product/NonInventoryProductForm.tsx b/packages/auditor-backoffice-ui/src/components/product/NonInventoryProductForm.tsx
new file mode 100644
index 000000000..c6d280f94
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/product/NonInventoryProductForm.tsx
@@ -0,0 +1,215 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useCallback, useEffect, useState } from "preact/hooks";
+import * as yup from "yup";
+import { MerchantBackend } from "../../declaration.js";
+import { useListener } from "../../hooks/listener.js";
+import { NonInventoryProductSchema as schema } from "../../schemas/index.js";
+import { FormErrors, FormProvider } from "../form/FormProvider.js";
+import { Input } from "../form/Input.js";
+import { InputCurrency } from "../form/InputCurrency.js";
+import { InputImage } from "../form/InputImage.js";
+import { InputNumber } from "../form/InputNumber.js";
+import { InputTaxes } from "../form/InputTaxes.js";
+
+type Entity = MerchantBackend.Product;
+
+interface Props {
+ onAddProduct: (p: Entity) => Promise<void>;
+ productToEdit?: Entity;
+}
+export function NonInventoryProductFrom({
+ productToEdit,
+ onAddProduct,
+}: Props): VNode {
+ const [showCreateProduct, setShowCreateProduct] = useState(false);
+
+ const isEditing = !!productToEdit;
+
+ useEffect(() => {
+ setShowCreateProduct(isEditing);
+ }, [isEditing]);
+
+ const [submitForm, addFormSubmitter] = useListener<
+ Partial<MerchantBackend.Product> | undefined
+ >((result) => {
+ if (result) {
+ setShowCreateProduct(false);
+ return onAddProduct({
+ quantity: result.quantity || 0,
+ taxes: result.taxes || [],
+ description: result.description || "",
+ image: result.image || "",
+ price: result.price || "",
+ unit: result.unit || "",
+ });
+ }
+ return Promise.resolve();
+ });
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <div class="buttons">
+ <button
+ class="button is-success"
+ data-tooltip={i18n.str`describe and add a product that is not in the inventory list`}
+ onClick={() => setShowCreateProduct(true)}
+ >
+ <i18n.Translate>Add custom product</i18n.Translate>
+ </button>
+ </div>
+ {showCreateProduct && (
+ <div class="modal is-active">
+ <div
+ class="modal-background "
+ onClick={() => setShowCreateProduct(false)}
+ />
+ <div class="modal-card">
+ <header class="modal-card-head">
+ <p class="modal-card-title">{i18n.str`Complete information of the product`}</p>
+ <button
+ class="delete "
+ aria-label="close"
+ onClick={() => setShowCreateProduct(false)}
+ />
+ </header>
+ <section class="modal-card-body">
+ <ProductForm
+ initial={productToEdit}
+ onSubscribe={addFormSubmitter}
+ />
+ </section>
+ <footer class="modal-card-foot">
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ <button
+ class="button "
+ onClick={() => setShowCreateProduct(false)}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ class="button is-info "
+ disabled={!submitForm}
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={() => setShowCreateProduct(false)}
+ />
+ </div>
+ )}
+ </Fragment>
+ );
+}
+
+interface ProductProps {
+ onSubscribe: (c?: () => Entity | undefined) => void;
+ initial?: Partial<Entity>;
+}
+
+interface NonInventoryProduct {
+ quantity: number;
+ description: string;
+ unit: string;
+ price: string;
+ image: string;
+ taxes: MerchantBackend.Tax[];
+}
+
+export function ProductForm({ onSubscribe, initial }: ProductProps): VNode {
+ const [value, valueHandler] = useState<Partial<NonInventoryProduct>>({
+ taxes: [],
+ ...initial,
+ });
+ let errors: FormErrors<Entity> = {};
+ try {
+ schema.validateSync(value, { abortEarly: false });
+ } catch (err) {
+ if (err instanceof yup.ValidationError) {
+ const yupErrors = err.inner as yup.ValidationError[];
+ errors = yupErrors.reduce(
+ (prev, cur) =>
+ !cur.path ? prev : { ...prev, [cur.path]: cur.message },
+ {},
+ );
+ }
+ }
+
+ const submit = useCallback((): Entity | undefined => {
+ return value as MerchantBackend.Product;
+ }, [value]);
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ useEffect(() => {
+ onSubscribe(hasErrors ? undefined : submit);
+ }, [submit, hasErrors]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <FormProvider<NonInventoryProduct>
+ name="product"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <InputImage<NonInventoryProduct>
+ name="image"
+ label={i18n.str`Image`}
+ tooltip={i18n.str`photo of the product`}
+ />
+ <Input<NonInventoryProduct>
+ name="description"
+ inputType="multiline"
+ label={i18n.str`Description`}
+ tooltip={i18n.str`full product description`}
+ />
+ <Input<NonInventoryProduct>
+ name="unit"
+ label={i18n.str`Unit`}
+ tooltip={i18n.str`name of the product unit`}
+ />
+ <InputCurrency<NonInventoryProduct>
+ name="price"
+ label={i18n.str`Price`}
+ tooltip={i18n.str`amount in the current currency`}
+ />
+
+ <InputNumber<NonInventoryProduct>
+ name="quantity"
+ label={i18n.str`Quantity`}
+ tooltip={i18n.str`how many products will be added`}
+ />
+
+ <InputTaxes<NonInventoryProduct> name="taxes" label={i18n.str`Taxes`} />
+ </FormProvider>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/product/ProductForm.tsx b/packages/auditor-backoffice-ui/src/components/product/ProductForm.tsx
new file mode 100644
index 000000000..e91e8c876
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/product/ProductForm.tsx
@@ -0,0 +1,178 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h } from "preact";
+import { useCallback, useEffect, useState } from "preact/hooks";
+import * as yup from "yup";
+import { useBackendContext } from "../../context/backend.js";
+import { MerchantBackend } from "../../declaration.js";
+import {
+ ProductCreateSchema as createSchema,
+ ProductUpdateSchema as updateSchema,
+} from "../../schemas/index.js";
+import { FormErrors, FormProvider } from "../form/FormProvider.js";
+import { Input } from "../form/Input.js";
+import { InputCurrency } from "../form/InputCurrency.js";
+import { InputImage } from "../form/InputImage.js";
+import { InputNumber } from "../form/InputNumber.js";
+import { InputStock, Stock } from "../form/InputStock.js";
+import { InputTaxes } from "../form/InputTaxes.js";
+import { InputWithAddon } from "../form/InputWithAddon.js";
+
+type Entity = MerchantBackend.Products.ProductDetail & { product_id: string };
+
+interface Props {
+ onSubscribe: (c?: () => Entity | undefined) => void;
+ initial?: Partial<Entity>;
+ alreadyExist?: boolean;
+}
+
+export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
+ const [value, valueHandler] = useState<Partial<Entity & { stock: Stock }>>({
+ address: {},
+ description_i18n: {},
+ taxes: [],
+ next_restock: { t_s: "never" },
+ price: ":0",
+ ...initial,
+ stock:
+ !initial || initial.total_stock === -1
+ ? undefined
+ : {
+ current: initial.total_stock || 0,
+ lost: initial.total_lost || 0,
+ sold: initial.total_sold || 0,
+ address: initial.address,
+ nextRestock: initial.next_restock,
+ },
+ });
+ let errors: FormErrors<Entity> = {};
+
+ try {
+ (alreadyExist ? updateSchema : createSchema).validateSync(value, {
+ abortEarly: false,
+ });
+ } catch (err) {
+ if (err instanceof yup.ValidationError) {
+ const yupErrors = err.inner as yup.ValidationError[];
+ errors = yupErrors.reduce(
+ (prev, cur) =>
+ !cur.path ? prev : { ...prev, [cur.path]: cur.message },
+ {},
+ );
+ }
+ }
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submit = useCallback((): Entity | undefined => {
+ const stock: Stock = (value as any).stock;
+
+ if (!stock) {
+ value.total_stock = -1;
+ } else {
+ value.total_stock = stock.current;
+ value.total_lost = stock.lost;
+ value.next_restock =
+ stock.nextRestock instanceof Date
+ ? { t_s: stock.nextRestock.getTime() / 1000 }
+ : stock.nextRestock;
+ value.address = stock.address;
+ }
+ delete (value as any).stock;
+
+ if (typeof value.minimum_age !== "undefined" && value.minimum_age < 1) {
+ delete value.minimum_age;
+ }
+
+ return value as MerchantBackend.Products.ProductDetail & {
+ product_id: string;
+ };
+ }, [value]);
+
+ useEffect(() => {
+ onSubscribe(hasErrors ? undefined : submit);
+ }, [submit, hasErrors]);
+
+ const { url: backendURL } = useBackendContext()
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <FormProvider<Entity>
+ name="product"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ {alreadyExist ? undefined : (
+ <InputWithAddon<Entity>
+ name="product_id"
+ addonBefore={`${backendURL}/product/`}
+ label={i18n.str`ID`}
+ tooltip={i18n.str`product identification to use in URLs (for internal use only)`}
+ />
+ )}
+ <InputImage<Entity>
+ name="image"
+ label={i18n.str`Image`}
+ tooltip={i18n.str`illustration of the product for customers`}
+ />
+ <Input<Entity>
+ name="description"
+ inputType="multiline"
+ label={i18n.str`Description`}
+ tooltip={i18n.str`product description for customers`}
+ />
+ <InputNumber<Entity>
+ name="minimum_age"
+ label={i18n.str`Age restriction`}
+ tooltip={i18n.str`is this product restricted for customer below certain age?`}
+ help={i18n.str`minimum age of the buyer`}
+ />
+ <Input<Entity>
+ name="unit"
+ label={i18n.str`Unit name`}
+ tooltip={i18n.str`unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers`}
+ help={i18n.str`exajmple: kg, items or liters`}
+ />
+ <InputCurrency<Entity>
+ name="price"
+ label={i18n.str`Price per unit`}
+ tooltip={i18n.str`sale price for customers, including taxes, for above units of the product`}
+ />
+ <InputStock
+ name="stock"
+ label={i18n.str`Stock`}
+ alreadyExist={alreadyExist}
+ tooltip={i18n.str`inventory for products with finite supply (for internal use only)`}
+ />
+ <InputTaxes<Entity>
+ name="taxes"
+ label={i18n.str`Taxes`}
+ tooltip={i18n.str`taxes included in the product price, exposed to customers`}
+ />
+ </FormProvider>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/components/product/ProductList.tsx b/packages/auditor-backoffice-ui/src/components/product/ProductList.tsx
new file mode 100644
index 000000000..25751dd96
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/components/product/ProductList.tsx
@@ -0,0 +1,106 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { Amounts } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import emptyImage from "../../assets/empty.png";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { MerchantBackend } from "../../declaration.js";
+
+interface Props {
+ list: MerchantBackend.Product[];
+ actions?: {
+ name: string;
+ tooltip: string;
+ handler: (d: MerchantBackend.Product, index: number) => void;
+ }[];
+}
+export function ProductList({ list, actions = [] }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>image</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>description</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>quantity</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>unit price</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>total price</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {list.map((entry, index) => {
+ const unitPrice = !entry.price ? "0" : entry.price;
+ const totalPrice = !entry.price
+ ? "0"
+ : Amounts.stringify(
+ Amounts.mult(
+ Amounts.parseOrThrow(entry.price),
+ entry.quantity,
+ ).amount,
+ );
+
+ return (
+ <tr key={index}>
+ <td>
+ <img
+ style={{ height: 32, width: 32 }}
+ src={entry.image ? entry.image : emptyImage}
+ />
+ </td>
+ <td>{entry.description}</td>
+ <td>
+ {entry.quantity === 0
+ ? "--"
+ : `${entry.quantity} ${entry.unit}`}
+ </td>
+ <td>{unitPrice}</td>
+ <td>{totalPrice}</td>
+ <td class="is-actions-cell right-sticky">
+ {actions.map((a, i) => {
+ return (
+ <div key={i} class="buttons is-right">
+ <button
+ class="button is-small is-danger has-tooltip-left"
+ data-tooltip={a.tooltip}
+ type="button"
+ onClick={() => a.handler(entry, index)}
+ >
+ {a.name}
+ </button>
+ </div>
+ );
+ })}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/context/backend.test.ts b/packages/auditor-backoffice-ui/src/context/backend.test.ts
new file mode 100644
index 000000000..359859819
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/context/backend.test.ts
@@ -0,0 +1,163 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ComponentChildren, h, VNode } from "preact";
+import { AccessToken, MerchantBackend } from "../declaration.js";
+import {
+ useAdminAPI,
+ useInstanceAPI,
+ useManagementAPI,
+} from "../hooks/instance.js";
+import { expect } from "chai";
+import { ApiMockEnvironment } from "../hooks/testing.js";
+import {
+ API_CREATE_INSTANCE,
+ API_NEW_LOGIN,
+ API_UPDATE_CURRENT_INSTANCE_AUTH,
+ API_UPDATE_INSTANCE_AUTH_BY_ID,
+} from "../hooks/urls.js";
+
+interface TestingContextProps {
+ children?: ComponentChildren;
+}
+
+describe("backend context api ", () => {
+ it("should use new token after updating the instance token in the settings as user", async () => {
+ const env = new ApiMockEnvironment();
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const instance = useInstanceAPI();
+ const management = useManagementAPI("default");
+ const admin = useAdminAPI();
+
+ return { instance, management, admin };
+ },
+ {},
+ [
+ ({ instance, management, admin }) => {
+ env.addRequestExpectation(API_UPDATE_INSTANCE_AUTH_BY_ID("default"), {
+ request: {
+ method: "token",
+ token: "another_token",
+ },
+ response: {
+ name: "instance_name",
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+ env.addRequestExpectation(API_NEW_LOGIN, {
+ auth: "another_token",
+ request: {
+ scope: "write",
+ duration: {
+ "d_us": "forever",
+ },
+ refreshable: true,
+ },
+
+ });
+
+ management.setNewAccessToken(undefined,"another_token" as AccessToken);
+ },
+ ({ instance, management, admin }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ env.addRequestExpectation(API_CREATE_INSTANCE, {
+ // auth: "another_token",
+ request: {
+ id: "new_instance_id",
+ } as MerchantBackend.Instances.InstanceConfigurationMessage,
+ });
+
+ admin.createInstance({
+ id: "new_instance_id",
+ } as MerchantBackend.Instances.InstanceConfigurationMessage);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should use new token after updating the instance token in the settings as admin", async () => {
+ const env = new ApiMockEnvironment();
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const instance = useInstanceAPI();
+ const management = useManagementAPI("default");
+ const admin = useAdminAPI();
+
+ return { instance, management, admin };
+ },
+ {},
+ [
+ ({ instance, management, admin }) => {
+ env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, {
+ request: {
+ method: "token",
+ token: "another_token",
+ },
+ response: {
+ name: "instance_name",
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+ env.addRequestExpectation(API_NEW_LOGIN, {
+ auth: "another_token",
+ request: {
+ scope: "write",
+ duration: {
+ "d_us": "forever",
+ },
+ refreshable: true,
+ },
+ });
+ instance.setNewAccessToken(undefined, "another_token" as AccessToken);
+ },
+ ({ instance, management, admin }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ env.addRequestExpectation(API_CREATE_INSTANCE, {
+ // auth: "another_token",
+ request: {
+ id: "new_instance_id",
+ } as MerchantBackend.Instances.InstanceConfigurationMessage,
+ });
+
+ admin.createInstance({
+ id: "new_instance_id",
+ } as MerchantBackend.Instances.InstanceConfigurationMessage);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/context/backend.ts b/packages/auditor-backoffice-ui/src/context/backend.ts
new file mode 100644
index 000000000..b13b92c42
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/context/backend.ts
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useMemoryStorage } from "@gnu-taler/web-util/browser";
+import { createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+import { LoginToken } from "../declaration.js";
+import { useBackendDefaultToken, useBackendURL } from "../hooks/index.js";
+
+interface BackendContextType {
+ url: string,
+ alreadyTriedLogin: boolean;
+ token?: LoginToken;
+ updateToken: (token: LoginToken | undefined) => void;
+}
+
+const BackendContext = createContext<BackendContextType>({
+ url: "",
+ alreadyTriedLogin: false,
+ token: undefined,
+ updateToken: () => null,
+});
+
+function useBackendContextState(
+ defaultUrl?: string,
+): BackendContextType {
+const [url] = useBackendURL(defaultUrl);
+ //const url = "http://localhost:8081";
+ const [token, updateToken] = useBackendDefaultToken();
+
+ return {
+ url,
+ token,
+ alreadyTriedLogin: token !== undefined,
+ updateToken,
+ };
+}
+
+export const BackendContextProvider = ({
+ children,
+ defaultUrl,
+}: {
+ children: any;
+ defaultUrl?: string;
+}): VNode => {
+ const value = useBackendContextState(defaultUrl);
+
+ return h(BackendContext.Provider, { value, children });
+};
+
+export const useBackendContext = (): BackendContextType =>
+ useContext(BackendContext);
diff --git a/packages/auditor-backoffice-ui/src/context/config.ts b/packages/auditor-backoffice-ui/src/context/config.ts
new file mode 100644
index 000000000..def45ea64
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/context/config.ts
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { createContext } from "preact";
+import { useContext } from "preact/hooks";
+
+interface Type {
+ currency: string;
+ version: string;
+}
+const Context = createContext<Type>(null!);
+
+export const ConfigContextProvider = Context.Provider;
+export const useConfigContext = (): Type => useContext(Context);
diff --git a/packages/auditor-backoffice-ui/src/context/instance.ts b/packages/auditor-backoffice-ui/src/context/instance.ts
new file mode 100644
index 000000000..5800ade7e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/context/instance.ts
@@ -0,0 +1,36 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { createContext } from "preact";
+import { useContext } from "preact/hooks";
+import { LoginToken } from "../declaration.js";
+
+interface Type {
+ id: string;
+ token?: LoginToken;
+ admin?: boolean;
+ changeToken: (t?: LoginToken) => void;
+}
+
+const Context = createContext<Type>({} as any);
+
+export const InstanceContextProvider = Context.Provider;
+export const useInstanceContext = (): Type => useContext(Context);
diff --git a/packages/auditor-backoffice-ui/src/custom.d.ts b/packages/auditor-backoffice-ui/src/custom.d.ts
new file mode 100644
index 000000000..34522a2dd
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/custom.d.ts
@@ -0,0 +1,42 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+declare module "*.po" {
+ const content: any;
+ export default content;
+}
+declare module "jed" {
+ const x: any;
+ export = x;
+}
+declare module "*.jpeg" {
+ const content: any;
+ export default content;
+}
+declare module "*.png" {
+ const content: any;
+ export default content;
+}
+declare module "*.svg" {
+ const content: any;
+ export default content;
+}
+
+declare module "*.scss" {
+ const content: Record<string, string>;
+ export default content;
+}
+declare const __VERSION__: string;
+declare const __GIT_HASH__: string;
diff --git a/packages/auditor-backoffice-ui/src/declaration.d.ts b/packages/auditor-backoffice-ui/src/declaration.d.ts
new file mode 100644
index 000000000..0c6f599f7
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/declaration.d.ts
@@ -0,0 +1,1793 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+type HashCode = string;
+type EddsaPublicKey = string;
+type EddsaSignature = string;
+type WireTransferIdentifierRawP = string;
+type RelativeTime = TalerProtocolDuration;
+type ImageDataUrl = string;
+type MerchantUserType = "business" | "individual";
+
+
+export interface WithId {
+ id: string;
+}
+
+interface Timestamp {
+ // Milliseconds since epoch, or the special
+ // value "forever" to represent an event that will
+ // never happen.
+ t_s: number | "never";
+}
+interface TalerProtocolDuration {
+ d_us: number | "forever";
+}
+interface Duration {
+ d_ms: number | "forever";
+}
+
+interface WithId {
+ id: string;
+}
+
+type Amount = string;
+type UUID = string;
+type Integer = number;
+
+interface WireAccount {
+ // payto:// URI identifying the account and wire method
+ payto_uri: string;
+
+ // URI to convert amounts from or to the currency used by
+ // this wire account of the exchange. Missing if no
+ // conversion is applicable.
+ conversion_url?: string;
+
+ // Restrictions that apply to bank accounts that would send
+ // funds to the exchange (crediting this exchange bank account).
+ // Optional, empty array for unrestricted.
+ credit_restrictions: AccountRestriction[];
+
+ // Restrictions that apply to bank accounts that would receive
+ // funds from the exchange (debiting this exchange bank account).
+ // Optional, empty array for unrestricted.
+ debit_restrictions: AccountRestriction[];
+
+ // Signature using the exchange's offline key over
+ // a TALER_MasterWireDetailsPS
+ // with purpose TALER_SIGNATURE_MASTER_WIRE_DETAILS.
+ master_sig: EddsaSignature;
+}
+
+type AccountRestriction = RegexAccountRestriction | DenyAllAccountRestriction;
+
+// Account restriction that disables this type of
+// account for the indicated operation categorically.
+interface DenyAllAccountRestriction {
+ type: "deny";
+}
+
+// Accounts interacting with this type of account
+// restriction must have a payto://-URI matching
+// the given regex.
+interface RegexAccountRestriction {
+ type: "regex";
+
+ // Regular expression that the payto://-URI of the
+ // partner account must follow. The regular expression
+ // should follow posix-egrep, but without support for character
+ // classes, GNU extensions, back-references or intervals. See
+ // https://www.gnu.org/software/findutils/manual/html_node/find_html/posix_002degrep-regular-expression-syntax.html
+ // for a description of the posix-egrep syntax. Applications
+ // may support regexes with additional features, but exchanges
+ // must not use such regexes.
+ payto_regex: string;
+
+ // Hint for a human to understand the restriction
+ // (that is hopefully easier to comprehend than the regex itself).
+ human_hint: string;
+
+ // Map from IETF BCP 47 language tags to localized
+ // human hints.
+ human_hint_i18n?: { [lang_tag: string]: string };
+}
+interface LoginToken {
+ token: string,
+ expiration: Timestamp,
+}
+// token used to get loginToken
+// must forget after used
+declare const __ac_token: unique symbol;
+type AccessToken = string & {
+ [__ac_token]: true;
+};
+
+export namespace ExchangeBackend {
+ interface WireResponse {
+ // Master public key of the exchange, must match the key returned in /keys.
+ master_public_key: EddsaPublicKey;
+
+ // Array of wire accounts operated by the exchange for
+ // incoming wire transfers.
+ accounts: WireAccount[];
+
+ // Object mapping names of wire methods (i.e. "sepa" or "x-taler-bank")
+ // to wire fees.
+ fees: { method: AggregateTransferFee };
+ }
+ interface AggregateTransferFee {
+ // Per transfer wire transfer fee.
+ wire_fee: Amount;
+
+ // Per transfer closing fee.
+ closing_fee: Amount;
+
+ // What date (inclusive) does this fee go into effect?
+ // The different fees must cover the full time period in which
+ // any of the denomination keys are valid without overlap.
+ start_date: Timestamp;
+
+ // What date (exclusive) does this fee stop going into effect?
+ // The different fees must cover the full time period in which
+ // any of the denomination keys are valid without overlap.
+ end_date: Timestamp;
+
+ // Signature of TALER_MasterWireFeePS with
+ // purpose TALER_SIGNATURE_MASTER_WIRE_FEES.
+ sig: EddsaSignature;
+ }
+}
+export namespace AuditorBackend {
+ interface ErrorDetail {
+ // Numeric error code unique to the condition.
+ // The other arguments are specific to the error value reported here.
+ code: number;
+
+ // Human-readable description of the error, i.e. "missing parameter", "commitment violation", ...
+ // Should give a human-readable hint about the error's nature. Optional, may change without notice!
+ hint?: string;
+
+ // Optional detail about the specific input value that failed. May change without notice!
+ detail?: string;
+
+ // Name of the parameter that was bogus (if applicable).
+ parameter?: string;
+
+ // Path to the argument that was bogus (if applicable).
+ path?: string;
+
+ // Offset of the argument that was bogus (if applicable).
+ offset?: string;
+
+ // Index of the argument that was bogus (if applicable).
+ index?: string;
+
+ // Name of the object that was bogus (if applicable).
+ object?: string;
+
+ // Name of the currency than was problematic (if applicable).
+ currency?: string;
+
+ // Expected type (if applicable).
+ type_expected?: string;
+
+ // Type that was provided instead (if applicable).
+ type_actual?: string;
+ }
+ interface Exchange {
+ // the exchange's base URL
+ url: string;
+
+ // master public key of the exchange
+ master_pub: EddsaPublicKey;
+ }
+ namespace DepositConfirmation {
+ // POST /deposit-confirmation
+ interface ProductAddDetail {
+ // product ID to use.
+ product_id: string;
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n: { [lang_tag: string]: string };
+
+ // unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: Amount;
+
+ // An optional base64-encoded product image
+ image: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for one unit of this product
+ taxes: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Identifies where the product is in stock.
+ address: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age?: Integer;
+ }
+ // PATCH /private/products/$PRODUCT_ID
+ interface ProductPatchDetail {
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n: { [lang_tag: string]: string };
+
+ // unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: Amount;
+
+ // An optional base64-encoded product image
+ image: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for one unit of this product
+ taxes: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Number of units of the product that were lost (spoiled, stolen, etc.)
+ total_lost: Integer;
+
+ // Identifies where the product is in stock.
+ address: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age?: Integer;
+ }
+
+ // GET /deposit-confirmation
+ interface DepositConfirmationList {
+ depositConfirmations: DepositConfirmation [];
+ }
+ interface DepositConfirmation {
+ serial_id: string;
+ timestamp: string;
+ refund_deadline: string;
+ wire_deadline: string;
+ amount_without_fee: string;
+ }
+
+ // GET /deposit-confirmation/$SERIAL_ID
+ interface DepositConfirmationDetail {
+ serial_id: string;
+ timestamp: string;
+ refund_deadline: string;
+ wire_deadline: string;
+ amount_without_fee: string;
+ }
+ }
+
+}
+export namespace MerchantBackend {
+ interface ErrorDetail {
+ // Numeric error code unique to the condition.
+ // The other arguments are specific to the error value reported here.
+ code: number;
+
+ // Human-readable description of the error, i.e. "missing parameter", "commitment violation", ...
+ // Should give a human-readable hint about the error's nature. Optional, may change without notice!
+ hint?: string;
+
+ // Optional detail about the specific input value that failed. May change without notice!
+ detail?: string;
+
+ // Name of the parameter that was bogus (if applicable).
+ parameter?: string;
+
+ // Path to the argument that was bogus (if applicable).
+ path?: string;
+
+ // Offset of the argument that was bogus (if applicable).
+ offset?: string;
+
+ // Index of the argument that was bogus (if applicable).
+ index?: string;
+
+ // Name of the object that was bogus (if applicable).
+ object?: string;
+
+ // Name of the currency than was problematic (if applicable).
+ currency?: string;
+
+ // Expected type (if applicable).
+ type_expected?: string;
+
+ // Type that was provided instead (if applicable).
+ type_actual?: string;
+ }
+
+ // Delivery location, loosely modeled as a subset of
+ // ISO20022's PostalAddress25.
+ interface Tax {
+ // the name of the tax
+ name: string;
+
+ // amount paid in tax
+ tax: Amount;
+ }
+
+ interface Auditor {
+ // official name
+ name: string;
+
+ // Auditor's public key
+ auditor_pub: EddsaPublicKey;
+
+ // Base URL of the auditor
+ url: string;
+ }
+ interface Exchange {
+ // the exchange's base URL
+ url: string;
+
+ // master public key of the exchange
+ master_pub: EddsaPublicKey;
+ }
+
+ interface Product {
+ // merchant-internal identifier for the product.
+ product_id?: string;
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n?: { [lang_tag: string]: string };
+
+ // The number of units of the product to deliver to the customer.
+ quantity: Integer;
+
+ // The unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit: string;
+
+ // The price of the product; this is the total price for quantity times unit of this product.
+ price?: Amount;
+
+ // An optional base64-encoded product image
+ image: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for this product. Can be empty.
+ taxes: Tax[];
+
+ // time indicating when this product should be delivered
+ delivery_date?: TalerProtocolTimestamp;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age?: Integer;
+ }
+ interface Merchant {
+ // label for a location with the business address of the merchant
+ address: Location;
+
+ // the merchant's legal name of business
+ name: string;
+
+ // label for a location that denotes the jurisdiction for disputes.
+ // Some of the typical fields for a location (such as a street address) may be absent.
+ jurisdiction: Location;
+ }
+
+ interface VersionResponse {
+ // libtool-style representation of the Merchant protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // Name of the protocol.
+ name: "taler-merchant";
+
+ // Currency supported by this backend.
+ currency: string;
+ }
+ interface Location {
+ // Nation with its own government.
+ country?: string;
+
+ // Identifies a subdivision of a country such as state, region, county.
+ country_subdivision?: string;
+
+ // Identifies a subdivision within a country sub-division.
+ district?: string;
+
+ // Name of a built-up area, with defined boundaries, and a local government.
+ town?: string;
+
+ // Specific location name within the town.
+ town_location?: string;
+
+ // Identifier consisting of a group of letters and/or numbers that
+ // is added to a postal address to assist the sorting of mail.
+ post_code?: string;
+
+ // Name of a street or thoroughfare.
+ street?: string;
+
+ // Name of the building or house.
+ building_name?: string;
+
+ // Number that identifies the position of a building on a street.
+ building_number?: string;
+
+ // Free-form address lines, should not exceed 7 elements.
+ address_lines?: string[];
+ }
+ namespace Instances {
+ //POST /private/instances/$INSTANCE/auth
+ interface InstanceAuthConfigurationMessage {
+ // Type of authentication.
+ // "external": The mechant backend does not do
+ // any authentication checks. Instead an API
+ // gateway must do the authentication.
+ // "token": The merchant checks an auth token.
+ // See "token" for details.
+ method: "external" | "token";
+
+ // For method "external", this field is mandatory.
+ // The token MUST begin with the string "secret-token:".
+ // After the auth token has been set (with method "token"),
+ // the value must be provided in a "Authorization: Bearer $token"
+ // header.
+ token?: string;
+ }
+ //POST /private/instances
+ interface InstanceConfigurationMessage {
+ // Name of the merchant instance to create (will become $INSTANCE).
+ id: string;
+
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // Type of the user (business or individual).
+ // Defaults to 'business'. Should become mandatory field
+ // in the future, left as optional for API compatibility for now.
+ user_type?: MerchantUserType;
+
+ // Merchant email for customer contact.
+ email?: string;
+
+ // Merchant public website.
+ website?: string;
+
+ // Merchant logo.
+ logo?: ImageDataUrl;
+
+ // "Authentication" header required to authorize management access the instance.
+ // Optional, if not given authentication will be disabled for
+ // this instance (hopefully authentication checks are still
+ // done by some reverse proxy).
+ auth: InstanceAuthConfigurationMessage;
+
+ // The merchant's physical address (to be put into contracts).
+ address: Location;
+
+ // The jurisdiction under which the merchant conducts its business
+ // (to be put into contracts).
+ jurisdiction: Location;
+
+ // Use STEFAN curves to determine default fees?
+ // If false, no fees are allowed by default.
+ // Can always be overridden by the frontend on a per-order basis.
+ use_stefan: boolean;
+
+ // If the frontend does NOT specify an execution date, how long should
+ // we tell the exchange to wait to aggregate transactions before
+ // executing the wire transfer? This delay is added to the current
+ // time when we generate the advisory execution time for the exchange.
+ default_wire_transfer_delay: RelativeTime;
+
+ // If the frontend does NOT specify a payment deadline, how long should
+ // offers we make be valid by default?
+ default_pay_delay: RelativeTime;
+ }
+
+ // PATCH /private/instances/$INSTANCE
+ interface InstanceReconfigurationMessage {
+
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // Type of the user (business or individual).
+ // Defaults to 'business'. Should become mandatory field
+ // in the future, left as optional for API compatibility for now.
+ user_type?: MerchantUserType;
+
+ // Merchant email for customer contact.
+ email?: string;
+
+ // Merchant public website.
+ website?: string;
+
+ // Merchant logo.
+ logo?: ImageDataUrl;
+
+ // The merchant's physical address (to be put into contracts).
+ address: Location;
+
+ // The jurisdiction under which the merchant conducts its business
+ // (to be put into contracts).
+ jurisdiction: Location;
+
+ // Use STEFAN curves to determine default fees?
+ // If false, no fees are allowed by default.
+ // Can always be overridden by the frontend on a per-order basis.
+ use_stefan: boolean;
+
+ // If the frontend does NOT specify an execution date, how long should
+ // we tell the exchange to wait to aggregate transactions before
+ // executing the wire transfer? This delay is added to the current
+ // time when we generate the advisory execution time for the exchange.
+ default_wire_transfer_delay: RelativeTime;
+
+ // If the frontend does NOT specify a payment deadline, how long should
+ // offers we make be valid by default?
+ default_pay_delay: RelativeTime;
+ }
+
+ // GET /private/instances
+ interface InstancesResponse {
+ // List of instances that are present in the backend (see Instance)
+ instances: Instance[];
+ }
+
+ interface Instance {
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // Type of the user ("business" or "individual").
+ user_type: MerchantUserType;
+
+ // Merchant public website.
+ website?: string;
+
+ // Merchant logo.
+ logo?: ImageDataUrl;
+
+ // Merchant instance this response is about ($INSTANCE)
+ id: string;
+
+ // Public key of the merchant/instance, in Crockford Base32 encoding.
+ merchant_pub: EddsaPublicKey;
+
+ // List of the payment targets supported by this instance. Clients can
+ // specify the desired payment target in /order requests. Note that
+ // front-ends do not have to support wallets selecting payment targets.
+ payment_targets: string[];
+
+ // Has this instance been deleted (but not purged)?
+ deleted: boolean;
+ }
+
+ //GET /private/instances/$INSTANCE
+ interface QueryInstancesResponse {
+
+ // Merchant name corresponding to this instance.
+ name: string;
+ // Type of the user ("business" or "individual").
+ user_type: MerchantUserType;
+
+ // Merchant email for customer contact.
+ email?: string;
+
+ // Merchant public website.
+ website?: string;
+
+ // Merchant logo.
+ logo?: ImageDataUrl;
+
+ // Public key of the merchant/instance, in Crockford Base32 encoding.
+ merchant_pub: EddsaPublicKey;
+
+ // The merchant's physical address (to be put into contracts).
+ address: Location;
+
+ // The jurisdiction under which the merchant conducts its business
+ // (to be put into contracts).
+ jurisdiction: Location;
+
+ // Use STEFAN curves to determine default fees?
+ // If false, no fees are allowed by default.
+ // Can always be overridden by the frontend on a per-order basis.
+ use_stefan: boolean;
+
+ // If the frontend does NOT specify an execution date, how long should
+ // we tell the exchange to wait to aggregate transactions before
+ // executing the wire transfer? This delay is added to the current
+ // time when we generate the advisory execution time for the exchange.
+ default_wire_transfer_delay: RelativeTime;
+
+ // If the frontend does NOT specify a payment deadline, how long should
+ // offers we make be valid by default?
+ default_pay_delay: RelativeTime;
+
+ // Authentication configuration.
+ // Does not contain the token when token auth is configured.
+ auth: {
+ method: "external" | "token";
+ };
+ }
+ // DELETE /private/instances/$INSTANCE
+ interface LoginTokenRequest {
+ // Scope of the token (which kinds of operations it will allow)
+ scope: "readonly" | "write";
+
+ // Server may impose its own upper bound
+ // on the token validity duration
+ duration?: RelativeTime;
+
+ // Can this token be refreshed?
+ // Defaults to false.
+ refreshable?: boolean;
+ }
+ interface LoginTokenSuccessResponse {
+ // The login token that can be used to access resources
+ // that are in scope for some time. Must be prefixed
+ // with "Bearer " when used in the "Authorization" HTTP header.
+ // Will already begin with the RFC 8959 prefix.
+ token: string;
+
+ // Scope of the token (which kinds of operations it will allow)
+ scope: "readonly" | "write";
+
+ // Server may impose its own upper bound
+ // on the token validity duration
+ expiration: Timestamp;
+
+ // Can this token be refreshed?
+ refreshable: boolean;
+ }
+ }
+
+ namespace KYC {
+ //GET /private/instances/$INSTANCE/kyc
+ interface AccountKycRedirects {
+ // Array of pending KYCs.
+ pending_kycs: MerchantAccountKycRedirect[];
+
+ // Array of exchanges with no reply.
+ timeout_kycs: ExchangeKycTimeout[];
+ }
+ interface MerchantAccountKycRedirect {
+ // URL that the user should open in a browser to
+ // proceed with the KYC process (as returned
+ // by the exchange's /kyc-check/ endpoint).
+ // Optional, missing if the account is blocked
+ // due to AML and not due to KYC.
+ kyc_url?: string;
+
+ // Base URL of the exchange this is about.
+ exchange_url: string;
+
+ // AML status of the account.
+ aml_status: number;
+
+ // Our bank wire account this is about.
+ payto_uri: string;
+ }
+ interface ExchangeKycTimeout {
+ // Base URL of the exchange this is about.
+ exchange_url: string;
+
+ // Numeric error code indicating errors the exchange
+ // returned, or TALER_EC_INVALID for none.
+ exchange_code: number;
+
+ // HTTP status code returned by the exchange when we asked for
+ // information about the KYC status.
+ // 0 if there was no response at all.
+ exchange_http_status: number;
+ }
+
+ }
+
+ namespace BankAccounts {
+
+ interface AccountAddDetails {
+
+ // payto:// URI of the account.
+ payto_uri: string;
+
+ // URL from where the merchant can download information
+ // about incoming wire transfers to this account.
+ credit_facade_url?: string;
+
+ // Credentials to use when accessing the credit facade.
+ // Never returned on a GET (as this may be somewhat
+ // sensitive data). Can be set in POST
+ // or PATCH requests to update (or delete) credentials.
+ // To really delete credentials, set them to the type: "none".
+ credit_facade_credentials?: FacadeCredentials;
+
+ }
+
+ type FacadeCredentials =
+ | NoFacadeCredentials
+ | BasicAuthFacadeCredentials;
+
+ interface NoFacadeCredentials {
+ type: "none";
+ }
+
+ interface BasicAuthFacadeCredentials {
+ type: "basic";
+
+ // Username to use to authenticate
+ username: string;
+
+ // Password to use to authenticate
+ password: string;
+ }
+
+ interface AccountAddResponse {
+ // Hash over the wire details (including over the salt).
+ h_wire: HashCode;
+
+ // Salt used to compute h_wire.
+ salt: HashCode;
+ }
+
+ interface AccountPatchDetails {
+
+ // URL from where the merchant can download information
+ // about incoming wire transfers to this account.
+ credit_facade_url?: string;
+
+ // Credentials to use when accessing the credit facade.
+ // Never returned on a GET (as this may be somewhat
+ // sensitive data). Can be set in POST
+ // or PATCH requests to update (or delete) credentials.
+ // To really delete credentials, set them to the type: "none".
+ credit_facade_credentials?: FacadeCredentials;
+ }
+
+
+ interface AccountsSummaryResponse {
+
+ // List of accounts that are known for the instance.
+ accounts: BankAccountEntry[];
+ }
+
+ interface BankAccountEntry {
+ // payto:// URI of the account.
+ payto_uri: string;
+
+ // Hash over the wire details (including over the salt)
+ h_wire: HashCode;
+
+ // salt used to compute h_wire
+ salt: HashCode;
+
+ // URL from where the merchant can download information
+ // about incoming wire transfers to this account.
+ credit_facade_url?: string;
+
+ // Credentials to use when accessing the credit facade.
+ // Never returned on a GET (as this may be somewhat
+ // sensitive data). Can be set in POST
+ // or PATCH requests to update (or delete) credentials.
+ credit_facade_credentials?: FacadeCredentials;
+
+ // true if this account is active,
+ // false if it is historic.
+ active: boolean;
+ }
+
+ }
+
+ namespace Products {
+ // POST /private/products
+ interface ProductAddDetail {
+ // product ID to use.
+ product_id: string;
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n: { [lang_tag: string]: string };
+
+ // unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: Amount;
+
+ // An optional base64-encoded product image
+ image: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for one unit of this product
+ taxes: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Identifies where the product is in stock.
+ address: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age?: Integer;
+ }
+ // PATCH /private/products/$PRODUCT_ID
+ interface ProductPatchDetail {
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n: { [lang_tag: string]: string };
+
+ // unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: Amount;
+
+ // An optional base64-encoded product image
+ image: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for one unit of this product
+ taxes: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Number of units of the product that were lost (spoiled, stolen, etc.)
+ total_lost: Integer;
+
+ // Identifies where the product is in stock.
+ address: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age?: Integer;
+ }
+
+ // GET /private/products
+ interface InventorySummaryResponse {
+ // List of products that are present in the inventory
+ products: InventoryEntry[];
+ }
+ interface InventoryEntry {
+ // Product identifier, as found in the product.
+ product_id: string;
+ }
+
+ // GET /private/products/$PRODUCT_ID
+ interface ProductDetail {
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n: { [lang_tag: string]: string };
+
+ // unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: Amount;
+
+ // An optional base64-encoded product image
+ image: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for one unit of this product
+ taxes: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Number of units of the product that have already been sold.
+ total_sold: Integer;
+
+ // Number of units of the product that were lost (spoiled, stolen, etc.)
+ total_lost: Integer;
+
+ // Identifies where the product is in stock.
+ address: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age?: Integer;
+ }
+
+ // POST /private/products/$PRODUCT_ID/lock
+ interface LockRequest {
+ // UUID that identifies the frontend performing the lock
+ // It is suggested that clients use a timeflake for this,
+ // see https://github.com/anthonynsimon/timeflake
+ lock_uuid: UUID;
+
+ // How long does the frontend intend to hold the lock
+ duration: RelativeTime;
+
+ // How many units should be locked?
+ quantity: Integer;
+ }
+
+ // DELETE /private/products/$PRODUCT_ID
+ }
+
+ namespace Orders {
+ type MerchantOrderStatusResponse =
+ | CheckPaymentPaidResponse
+ | CheckPaymentClaimedResponse
+ | CheckPaymentUnpaidResponse;
+ interface CheckPaymentPaidResponse {
+ // The customer paid for this contract.
+ order_status: "paid";
+
+ // Was the payment refunded (even partially)?
+ refunded: boolean;
+
+ // True if there are any approved refunds that the wallet has
+ // not yet obtained.
+ refund_pending: boolean;
+
+ // Did the exchange wire us the funds?
+ wired: boolean;
+
+ // Total amount the exchange deposited into our bank account
+ // for this contract, excluding fees.
+ deposit_total: Amount;
+
+ // Numeric error code indicating errors the exchange
+ // encountered tracking the wire transfer for this purchase (before
+ // we even got to specific coin issues).
+ // 0 if there were no issues.
+ exchange_ec: number;
+
+ // HTTP status code returned by the exchange when we asked for
+ // information to track the wire transfer for this purchase.
+ // 0 if there were no issues.
+ exchange_hc: number;
+
+ // Total amount that was refunded, 0 if refunded is false.
+ refund_amount: Amount;
+
+ // Contract terms.
+ contract_terms: ContractTerms;
+
+ // The wire transfer status from the exchange for this order if
+ // available, otherwise empty array.
+ wire_details: TransactionWireTransfer[];
+
+ // Reports about trouble obtaining wire transfer details,
+ // empty array if no trouble were encountered.
+ wire_reports: TransactionWireReport[];
+
+ // The refund details for this order. One entry per
+ // refunded coin; empty array if there are no refunds.
+ refund_details: RefundDetails[];
+
+ // Status URL, can be used as a redirect target for the browser
+ // to show the order QR code / trigger the wallet.
+ order_status_url: string;
+ }
+ interface CheckPaymentClaimedResponse {
+ // A wallet claimed the order, but did not yet pay for the contract.
+ order_status: "claimed";
+
+ // Contract terms.
+ contract_terms: ContractTerms;
+ }
+ interface CheckPaymentUnpaidResponse {
+ // The order was neither claimed nor paid.
+ order_status: "unpaid";
+
+ // when was the order created
+ creation_time: Timestamp;
+
+ // Order summary text.
+ summary: string;
+
+ // Total amount of the order (to be paid by the customer).
+ total_amount: Amount;
+
+ // URI that the wallet must process to complete the payment.
+ taler_pay_uri: string;
+
+ // Alternative order ID which was paid for already in the same session.
+ // Only given if the same product was purchased before in the same session.
+ already_paid_order_id?: string;
+
+ // Fulfillment URL of an already paid order. Only given if under this
+ // session an already paid order with a fulfillment URL exists.
+ already_paid_fulfillment_url?: string;
+
+ // Status URL, can be used as a redirect target for the browser
+ // to show the order QR code / trigger the wallet.
+ order_status_url: string;
+
+ // We do we NOT return the contract terms here because they may not
+ // exist in case the wallet did not yet claim them.
+ }
+ interface RefundDetails {
+ // Reason given for the refund.
+ reason: string;
+
+ // When was the refund approved.
+ timestamp: Timestamp;
+
+ // Set to true if a refund is still available for the wallet for this payment.
+ pending: boolean;
+
+ // Total amount that was refunded (minus a refund fee).
+ amount: Amount;
+ }
+ interface TransactionWireTransfer {
+ // Responsible exchange.
+ exchange_url: string;
+
+ // 32-byte wire transfer identifier.
+ wtid: Base32;
+
+ // Execution time of the wire transfer.
+ execution_time: Timestamp;
+
+ // Total amount that has been wire transferred
+ // to the merchant.
+ amount: Amount;
+
+ // Was this transfer confirmed by the merchant via the
+ // POST /transfers API, or is it merely claimed by the exchange?
+ confirmed: boolean;
+ }
+ interface TransactionWireReport {
+ // Numerical error code.
+ code: number;
+
+ // Human-readable error description.
+ hint: string;
+
+ // Numerical error code from the exchange.
+ exchange_ec: number;
+
+ // HTTP status code received from the exchange.
+ exchange_hc: number;
+
+ // Public key of the coin for which we got the exchange error.
+ coin_pub: CoinPublicKey;
+ }
+
+ interface OrderHistory {
+ // timestamp-sorted array of all orders matching the query.
+ // The order of the sorting depends on the sign of delta.
+ orders: OrderHistoryEntry[];
+ }
+ interface OrderHistoryEntry {
+ // order ID of the transaction related to this entry.
+ order_id: string;
+
+ // row ID of the order in the database
+ row_id: number;
+
+ // when the order was created
+ timestamp: Timestamp;
+
+ // the amount of money the order is for
+ amount: Amount;
+
+ // the summary of the order
+ summary: string;
+
+ // whether some part of the order is refundable,
+ // that is the refund deadline has not yet expired
+ // and the total amount refunded so far is below
+ // the value of the original transaction.
+ refundable: boolean;
+
+ // whether the order has been paid or not
+ paid: boolean;
+ }
+
+ interface PostOrderRequest {
+ // The order must at least contain the minimal
+ // order detail, but can override all
+ order: Order;
+
+ // if set, the backend will then set the refund deadline to the current
+ // time plus the specified delay. If it's not set, refunds will not be
+ // possible.
+ refund_delay?: RelativeTime;
+
+ // specifies the payment target preferred by the client. Can be used
+ // to select among the various (active) wire methods supported by the instance.
+ payment_target?: string;
+
+ // specifies that some products are to be included in the
+ // order from the inventory. For these inventory management
+ // is performed (so the products must be in stock) and
+ // details are completed from the product data of the backend.
+ inventory_products?: MinimalInventoryProduct[];
+
+ // Specifies a lock identifier that was used to
+ // lock a product in the inventory. Only useful if
+ // manage_inventory is set. Used in case a frontend
+ // reserved quantities of the individual products while
+ // the shopping card was being built. Multiple UUIDs can
+ // be used in case different UUIDs were used for different
+ // products (i.e. in case the user started with multiple
+ // shopping sessions that were combined during checkout).
+ lock_uuids?: UUID[];
+
+ // Should a token for claiming the order be generated?
+ // False can make sense if the ORDER_ID is sufficiently
+ // high entropy to prevent adversarial claims (like it is
+ // if the backend auto-generates one). Default is 'true'.
+ create_token?: boolean;
+
+ // OTP device ID to associate with the order.
+ // This parameter is optional.
+ otp_id?: string;
+ }
+ type Order = MinimalOrderDetail | ContractTerms;
+
+ interface MinimalOrderDetail {
+ // Amount to be paid by the customer
+ amount: Amount;
+
+ // Short summary of the order
+ summary: string;
+
+ // URL that will show that the order was successful after
+ // it has been paid for. Optional. When POSTing to the
+ // merchant, the placeholder "${ORDER_ID}" will be
+ // replaced with the actual order ID (useful if the
+ // order ID is generated server-side and needs to be
+ // in the URL).
+ fulfillment_url?: string;
+ }
+
+ interface MinimalInventoryProduct {
+ // Which product is requested (here mandatory!)
+ product_id: string;
+
+ // How many units of the product are requested
+ quantity: Integer;
+ }
+ interface PostOrderResponse {
+ // Order ID of the response that was just created
+ order_id: string;
+
+ // Token that authorizes the wallet to claim the order.
+ // Provided only if "create_token" was set to 'true'
+ // in the request.
+ token?: ClaimToken;
+ }
+ interface OutOfStockResponse {
+ // Product ID of an out-of-stock item
+ product_id: string;
+
+ // Requested quantity
+ requested_quantity: Integer;
+
+ // Available quantity (must be below requested_quanitity)
+ available_quantity: Integer;
+
+ // When do we expect the product to be again in stock?
+ // Optional, not given if unknown.
+ restock_expected?: Timestamp;
+ }
+
+ interface ForgetRequest {
+ // Array of valid JSON paths to forgettable fields in the order's
+ // contract terms.
+ fields: string[];
+ }
+ interface RefundRequest {
+ // Amount to be refunded
+ refund: Amount;
+
+ // Human-readable refund justification
+ reason: string;
+ }
+ interface MerchantRefundResponse {
+ // URL (handled by the backend) that the wallet should access to
+ // trigger refund processing.
+ // taler://refund/...
+ taler_refund_uri: string;
+
+ // Contract hash that a client may need to authenticate an
+ // HTTP request to obtain the above URI in a wallet-friendly way.
+ h_contract: HashCode;
+ }
+ }
+
+ namespace Rewards {
+ // GET /private/reserves
+ interface RewardReserveStatus {
+ // Array of all known reserves (possibly empty!)
+ reserves: ReserveStatusEntry[];
+ }
+ interface ReserveStatusEntry {
+ // Public key of the reserve
+ reserve_pub: EddsaPublicKey;
+
+ // Timestamp when it was established
+ creation_time: Timestamp;
+
+ // Timestamp when it expires
+ expiration_time: Timestamp;
+
+ // Initial amount as per reserve creation call
+ merchant_initial_amount: Amount;
+
+ // Initial amount as per exchange, 0 if exchange did
+ // not confirm reserve creation yet.
+ exchange_initial_amount: Amount;
+
+ // Amount picked up so far.
+ pickup_amount: Amount;
+
+ // Amount approved for rewards that exceeds the pickup_amount.
+ committed_amount: Amount;
+
+ // Is this reserve active (false if it was deleted but not purged)
+ active: boolean;
+ }
+
+ interface ReserveCreateRequest {
+ // Amount that the merchant promises to put into the reserve
+ initial_balance: Amount;
+
+ // Exchange the merchant intends to use for reward
+ exchange_url: string;
+
+ // Desired wire method, for example "iban" or "x-taler-bank"
+ wire_method: string;
+ }
+ interface ReserveCreateConfirmation {
+ // Public key identifying the reserve
+ reserve_pub: EddsaPublicKey;
+
+ // Wire accounts of the exchange where to transfer the funds.
+ accounts: WireAccount[];
+ }
+ interface RewardCreateRequest {
+ // Amount that the customer should be reward
+ amount: Amount;
+
+ // Justification for giving the reward
+ justification: string;
+
+ // URL that the user should be directed to after rewarding,
+ // will be included in the reward_token.
+ next_url: string;
+ }
+ interface RewardCreateConfirmation {
+ // Unique reward identifier for the reward that was created.
+ reward_id: HashCode;
+
+ // taler://reward URI for the reward
+ taler_reward_uri: string;
+
+ // URL that will directly trigger processing
+ // the reward when the browser is redirected to it
+ reward_status_url: string;
+
+ // when does the reward expire
+ reward_expiration: Timestamp;
+ }
+
+ interface ReserveDetail {
+ // Timestamp when it was established.
+ creation_time: Timestamp;
+
+ // Timestamp when it expires.
+ expiration_time: Timestamp;
+
+ // Initial amount as per reserve creation call.
+ merchant_initial_amount: Amount;
+
+ // Initial amount as per exchange, 0 if exchange did
+ // not confirm reserve creation yet.
+ exchange_initial_amount: Amount;
+
+ // Amount picked up so far.
+ pickup_amount: Amount;
+
+ // Amount approved for rewards that exceeds the pickup_amount.
+ committed_amount: Amount;
+
+ // Array of all rewards created by this reserves (possibly empty!).
+ // Only present if asked for explicitly.
+ rewards?: RewardStatusEntry[];
+
+ // Is this reserve active (false if it was deleted but not purged)?
+ active: boolean;
+
+ // Array of wire accounts of the exchange that could
+ // be used to fill the reserve, can be NULL
+ // if the reserve is inactive or was already filled
+ accounts?: WireAccount[];
+
+ // URL of the exchange hosting the reserve,
+ // NULL if the reserve is inactive
+ exchange_url: string;
+ }
+
+ interface RewardStatusEntry {
+ // Unique identifier for the reward.
+ reward_id: HashCode;
+
+ // Total amount of the reward that can be withdrawn.
+ total_amount: Amount;
+
+ // Human-readable reason for why the reward was granted.
+ reason: string;
+ }
+
+ interface RewardDetails {
+ // Amount that we authorized for this reward.
+ total_authorized: Amount;
+
+ // Amount that was picked up by the user already.
+ total_picked_up: Amount;
+
+ // Human-readable reason given when authorizing the reward.
+ reason: string;
+
+ // Timestamp indicating when the reward is set to expire (may be in the past).
+ expiration: Timestamp;
+
+ // Reserve public key from which the reward is funded.
+ reserve_pub: EddsaPublicKey;
+
+ // Array showing the pickup operations of the wallet (possibly empty!).
+ // Only present if asked for explicitly.
+ pickups?: PickupDetail[];
+ }
+ interface PickupDetail {
+ // Unique identifier for the pickup operation.
+ pickup_id: HashCode;
+
+ // Number of planchets involved.
+ num_planchets: Integer;
+
+ // Total amount requested for this pickup_id.
+ requested_amount: Amount;
+ }
+ }
+
+ namespace Transfers {
+ interface TransferList {
+ // list of all the transfers that fit the filter that we know
+ transfers: TransferDetails[];
+ }
+ interface TransferDetails {
+ // how much was wired to the merchant (minus fees)
+ credit_amount: Amount;
+
+ // raw wire transfer identifier identifying the wire transfer (a base32-encoded value)
+ wtid: string;
+
+ // target account that received the wire transfer
+ payto_uri: string;
+
+ // base URL of the exchange that made the wire transfer
+ exchange_url: string;
+
+ // Serial number identifying the transfer in the merchant backend.
+ // Used for filgering via offset.
+ transfer_serial_id: number;
+
+ // Time of the execution of the wire transfer by the exchange, according to the exchange
+ // Only provided if we did get an answer from the exchange.
+ execution_time?: Timestamp;
+
+ // True if we checked the exchange's answer and are happy with it.
+ // False if we have an answer and are unhappy, missing if we
+ // do not have an answer from the exchange.
+ verified?: boolean;
+
+ // True if the merchant uses the POST /transfers API to confirm
+ // that this wire transfer took place (and it is thus not
+ // something merely claimed by the exchange).
+ confirmed?: boolean;
+ }
+
+ interface TransferInformation {
+ // how much was wired to the merchant (minus fees)
+ credit_amount: Amount;
+
+ // raw wire transfer identifier identifying the wire transfer (a base32-encoded value)
+ wtid: WireTransferIdentifierRawP;
+
+ // target account that received the wire transfer
+ payto_uri: string;
+
+ // base URL of the exchange that made the wire transfer
+ exchange_url: string;
+ }
+ }
+
+ namespace OTP {
+ interface OtpDeviceAddDetails {
+ // Device ID to use.
+ otp_device_id: string;
+
+ // Human-readable description for the device.
+ otp_device_description: string;
+
+ // A base64-encoded key
+ otp_key: string;
+
+ // Algorithm for computing the POS confirmation.
+ otp_algorithm: Integer;
+
+ // Counter for counter-based OTP devices.
+ otp_ctr?: Integer;
+ }
+
+ interface OtpDevicePatchDetails {
+ // Human-readable description for the device.
+ otp_device_description: string;
+
+ // A base64-encoded key
+ otp_key: string | undefined;
+
+ // Algorithm for computing the POS confirmation.
+ otp_algorithm: Integer;
+
+ // Counter for counter-based OTP devices.
+ otp_ctr?: Integer;
+ }
+
+ interface OtpDeviceSummaryResponse {
+ // Array of devices that are present in our backend.
+ otp_devices: OtpDeviceEntry[];
+ }
+ interface OtpDeviceEntry {
+ // Device identifier.
+ otp_device_id: string;
+
+ // Human-readable description for the device.
+ device_description: string;
+ }
+
+ interface OtpDeviceDetails {
+ // Human-readable description for the device.
+ device_description: string;
+
+ // Algorithm for computing the POS confirmation.
+ otp_algorithm: Integer;
+
+ // Counter for counter-based OTP devices.
+ otp_ctr?: Integer;
+ }
+
+
+ }
+ namespace Template {
+ interface TemplateAddDetails {
+ // Template ID to use.
+ template_id: string;
+
+ // Human-readable description for the template.
+ template_description: string;
+
+ // OTP device ID.
+ // This parameter is optional.
+ otp_id?: string;
+
+ // Additional information in a separate template.
+ template_contract: TemplateContractDetails;
+ }
+ interface TemplateContractDetails {
+ // Human-readable summary for the template.
+ summary?: string;
+
+ // The price is imposed by the merchant and cannot be changed by the customer.
+ // This parameter is optional.
+ amount?: Amount;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age: Integer;
+
+ // The time the customer need to pay before his order will be deleted.
+ // It is deleted if the customer did not pay and if the duration is over.
+ pay_duration: RelativeTime;
+ }
+ interface TemplatePatchDetails {
+ // Human-readable description for the template.
+ template_description: string;
+
+ // OTP device ID.
+ // This parameter is optional.
+ otp_id?: string;
+
+ // Additional information in a separate template.
+ template_contract: TemplateContractDetails;
+ }
+
+ interface TemplateSummaryResponse {
+ // List of templates that are present in our backend.
+ templates: TemplateEntry[];
+ }
+
+ interface TemplateEntry {
+ // Template identifier, as found in the template.
+ template_id: string;
+
+ // Human-readable description for the template.
+ template_description: string;
+ }
+
+ interface TemplateDetails {
+ // Human-readable description for the template.
+ template_description: string;
+
+ // OTP device ID.
+ // This parameter is optional.
+ otp_id?: string;
+
+ // Additional information in a separate template.
+ template_contract: TemplateContractDetails;
+ }
+
+ interface UsingTemplateDetails {
+ // Subject of the template
+ summary?: string;
+
+ // The amount entered by the customer.
+ amount?: Amount;
+ }
+
+ interface UsingTemplateResponse {
+ // After enter the request. The user will be pay with a taler URL.
+ order_id: string;
+ token: string;
+ }
+ }
+
+ namespace Webhooks {
+ type MerchantWebhookType = "pay" | "refund";
+ interface WebhookAddDetails {
+ // Webhook ID to use.
+ webhook_id: string;
+
+ // The event of the webhook: why the webhook is used.
+ event_type: MerchantWebhookType;
+
+ // URL of the webhook where the customer will be redirected.
+ url: string;
+
+ // Method used by the webhook
+ http_method: string;
+
+ // Header template of the webhook
+ header_template?: string;
+
+ // Body template by the webhook
+ body_template?: string;
+ }
+ interface WebhookPatchDetails {
+ // The event of the webhook: why the webhook is used.
+ event_type: string;
+
+ // URL of the webhook where the customer will be redirected.
+ url: string;
+
+ // Method used by the webhook
+ http_method: string;
+
+ // Header template of the webhook
+ header_template?: string;
+
+ // Body template by the webhook
+ body_template?: string;
+ }
+ interface WebhookSummaryResponse {
+ // List of webhooks that are present in our backend.
+ webhooks: WebhookEntry[];
+ }
+ interface WebhookEntry {
+ // Webhook identifier, as found in the webhook.
+ webhook_id: string;
+
+ // The event of the webhook: why the webhook is used.
+ event_type: string;
+ }
+ interface WebhookDetails {
+ // The event of the webhook: why the webhook is used.
+ event_type: string;
+
+ // URL of the webhook where the customer will be redirected.
+ url: string;
+
+ // Method used by the webhook
+ http_method: string;
+
+ // Header template of the webhook
+ header_template?: string;
+
+ // Body template by the webhook
+ body_template?: string;
+ }
+ }
+
+ interface ContractTerms {
+ // Human-readable description of the whole purchase
+ summary: string;
+
+ // Map from IETF BCP 47 language tags to localized summaries
+ summary_i18n?: { [lang_tag: string]: string };
+
+ // Unique, free-form identifier for the proposal.
+ // Must be unique within a merchant instance.
+ // For merchants that do not store proposals in their DB
+ // before the customer paid for them, the order_id can be used
+ // by the frontend to restore a proposal from the information
+ // encoded in it (such as a short product identifier and timestamp).
+ order_id: string;
+
+ // Total price for the transaction.
+ // The exchange will subtract deposit fees from that amount
+ // before transferring it to the merchant.
+ amount: Amount;
+
+ // The URL for this purchase. Every time is is visited, the merchant
+ // will send back to the customer the same proposal. Clearly, this URL
+ // can be bookmarked and shared by users.
+ fulfillment_url?: string;
+
+ // Maximum total deposit fee accepted by the merchant for this contract
+ max_fee: Amount;
+
+ // List of products that are part of the purchase (see Product).
+ products: Product[];
+
+ // Time when this contract was generated
+ timestamp: TalerProtocolTimestamp;
+
+ // After this deadline has passed, no refunds will be accepted.
+ refund_deadline: TalerProtocolTimestamp;
+
+ // After this deadline, the merchant won't accept payments for the contact
+ pay_deadline: TalerProtocolTimestamp;
+
+ // Transfer deadline for the exchange. Must be in the
+ // deposit permissions of coins used to pay for this order.
+ wire_transfer_deadline: TalerProtocolTimestamp;
+
+ // Merchant's public key used to sign this proposal; this information
+ // is typically added by the backend Note that this can be an ephemeral key.
+ merchant_pub: EddsaPublicKey;
+
+ // Base URL of the (public!) merchant backend API.
+ // Must be an absolute URL that ends with a slash.
+ merchant_base_url: string;
+
+ // More info about the merchant, see below
+ merchant: Merchant;
+
+ // The hash of the merchant instance's wire details.
+ h_wire: HashCode;
+
+ // Wire transfer method identifier for the wire method associated with h_wire.
+ // The wallet may only select exchanges via a matching auditor if the
+ // exchange also supports this wire method.
+ // The wire transfer fees must be added based on this wire transfer method.
+ wire_method: string;
+
+ // Any exchanges audited by these auditors are accepted by the merchant.
+ auditors: Auditor[];
+
+ // Exchanges that the merchant accepts even if it does not accept any auditors that audit them.
+ exchanges: Exchange[];
+
+ // Delivery location for (all!) products.
+ delivery_location?: Location;
+
+ // Time indicating when the order should be delivered.
+ // May be overwritten by individual products.
+ delivery_date?: TalerProtocolTimestamp;
+
+ // Nonce generated by the wallet and echoed by the merchant
+ // in this field when the proposal is generated.
+ nonce: string;
+
+ // Specifies for how long the wallet should try to get an
+ // automatic refund for the purchase. If this field is
+ // present, the wallet should wait for a few seconds after
+ // the purchase and then automatically attempt to obtain
+ // a refund. The wallet should probe until "delay"
+ // after the payment was successful (i.e. via long polling
+ // or via explicit requests with exponential back-off).
+ //
+ // In particular, if the wallet is offline
+ // at that time, it MUST repeat the request until it gets
+ // one response from the merchant after the delay has expired.
+ // If the refund is granted, the wallet MUST automatically
+ // recover the payment. This is used in case a merchant
+ // knows that it might be unable to satisfy the contract and
+ // desires for the wallet to attempt to get the refund without any
+ // customer interaction. Note that it is NOT an error if the
+ // merchant does not grant a refund.
+ auto_refund?: RelativeTime;
+
+ // Extra data that is only interpreted by the merchant frontend.
+ // Useful when the merchant needs to store extra information on a
+ // contract without storing it separately in their database.
+ extra?: any;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age?: Integer;
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/async.ts b/packages/auditor-backoffice-ui/src/hooks/async.ts
new file mode 100644
index 000000000..f22badc88
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/async.ts
@@ -0,0 +1,77 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useState } from "preact/hooks";
+
+export interface Options {
+ slowTolerance: number;
+}
+
+export interface AsyncOperationApi<T> {
+ request: (...a: any) => void;
+ cancel: () => void;
+ data: T | undefined;
+ isSlow: boolean;
+ isLoading: boolean;
+ error: string | undefined;
+}
+
+export function useAsync<T>(
+ fn?: (...args: any) => Promise<T>,
+ { slowTolerance: tooLong }: Options = { slowTolerance: 1000 },
+): AsyncOperationApi<T> {
+ const [data, setData] = useState<T | undefined>(undefined);
+ const [isLoading, setLoading] = useState<boolean>(false);
+ const [error, setError] = useState<any>(undefined);
+ const [isSlow, setSlow] = useState(false);
+
+ const request = async (...args: any) => {
+ if (!fn) return;
+ setLoading(true);
+
+ const handler = setTimeout(() => {
+ setSlow(true);
+ }, tooLong);
+
+ try {
+ const result = await fn(...args);
+ setData(result);
+ } catch (error) {
+ setError(error);
+ }
+ setLoading(false);
+ setSlow(false);
+ clearTimeout(handler);
+ };
+
+ function cancel(): void {
+ setLoading(false);
+ setSlow(false);
+ }
+
+ return {
+ request,
+ cancel,
+ data,
+ isSlow,
+ isLoading,
+ error,
+ };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/backend.ts b/packages/auditor-backoffice-ui/src/hooks/backend.ts
new file mode 100644
index 000000000..8d99546a8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/backend.ts
@@ -0,0 +1,477 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util";
+import {
+ ErrorType,
+ HttpError,
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+ RequestOptions,
+ useApiContext,
+} from "@gnu-taler/web-util/browser";
+import { useCallback, useEffect, useState } from "preact/hooks";
+import { useSWRConfig } from "swr";
+import { useBackendContext } from "../context/backend.js";
+import { useInstanceContext } from "../context/instance.js";
+import { AccessToken, LoginToken, MerchantBackend, Timestamp } from "../declaration.js";
+
+
+export function useMatchMutate(): (
+ re?: RegExp,
+ value?: unknown,
+) => Promise<any> {
+ const { cache, mutate } = useSWRConfig();
+
+ if (!(cache instanceof Map)) {
+ throw new Error(
+ "matchMutate requires the cache provider to be a Map instance",
+ );
+ }
+
+ return function matchRegexMutate(re?: RegExp) {
+ return mutate((key) => {
+ // evict if no key or regex === all
+ if (!key || !re) return true
+ // match string
+ if (typeof key === 'string' && re.test(key)) return true
+ // record or object have the path at [0]
+ if (typeof key === 'object' && re.test(key[0])) return true
+ //key didn't match regex
+ return false
+ }, undefined, {
+ revalidate: true,
+ });
+ };
+}
+
+export function useBackendInstancesTestForAdmin(): HttpResponse<
+ MerchantBackend.Instances.InstancesResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { request } = useBackendBaseRequest();
+
+ type Type = MerchantBackend.Instances.InstancesResponse;
+
+ const [result, setResult] = useState<
+ HttpResponse<Type, MerchantBackend.ErrorDetail>
+ >({ loading: true });
+
+ useEffect(() => {
+ request<Type>(`/management/instances`)
+ .then((data) => setResult(data))
+ .catch((error: RequestError<MerchantBackend.ErrorDetail>) =>
+ setResult(error.cause),
+ );
+ }, [request]);
+
+ return result;
+}
+
+const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000;
+const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000;
+
+export function useBackendConfig(): HttpResponse<
+ MerchantBackend.VersionResponse | undefined,
+ RequestError<MerchantBackend.ErrorDetail>
+> {
+ const { request } = useBackendBaseRequest();
+
+ type Type = MerchantBackend.VersionResponse;
+ type State = { data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>, timer: number }
+ const [result, setResult] = useState<State>({ data: { loading: true }, timer: 0 });
+
+ useEffect(() => {
+ if (result.timer) {
+ clearTimeout(result.timer)
+ }
+ function tryConfig(): void {
+ request<Type>(`/config`)
+ .then((data) => {
+ const timer: any = setTimeout(() => {
+ tryConfig()
+ }, CHECK_CONFIG_INTERVAL_OK)
+ setResult({ data, timer })
+ })
+ .catch((error) => {
+ const timer: any = setTimeout(() => {
+ tryConfig()
+ }, CHECK_CONFIG_INTERVAL_FAIL)
+ const data = error.cause
+ setResult({ data, timer })
+ });
+ }
+ tryConfig()
+ }, [request]);
+
+ return result.data;
+}
+
+interface useBackendInstanceRequestType {
+ request: <T>(
+ endpoint: string,
+ options?: RequestOptions,
+ ) => Promise<HttpResponseOk<T>>;
+ fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
+ reserveDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
+ rewardsDetailFetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>;
+ multiFetcher: <T>(params: [url: string[]]) => Promise<HttpResponseOk<T>[]>;
+ orderFetcher: <T>(
+ params: [endpoint: string,
+ paid?: YesOrNo,
+ refunded?: YesOrNo,
+ wired?: YesOrNo,
+ searchDate?: Date,
+ delta?: number,]
+ ) => Promise<HttpResponseOk<T>>;
+ transferFetcher: <T>(
+ params: [endpoint: string,
+ payto_uri?: string,
+ verified?: string,
+ position?: string,
+ delta?: number,]
+ ) => Promise<HttpResponseOk<T>>;
+ templateFetcher: <T>(
+ params: [endpoint: string,
+ position?: string,
+ delta?: number]
+ ) => Promise<HttpResponseOk<T>>;
+ webhookFetcher: <T>(
+ params: [endpoint: string,
+ position?: string,
+ delta?: number]
+ ) => Promise<HttpResponseOk<T>>;
+}
+interface useBackendBaseRequestType {
+ request: <T>(
+ endpoint: string,
+ options?: RequestOptions,
+ ) => Promise<HttpResponseOk<T>>;
+}
+
+type YesOrNo = "yes" | "no";
+type LoginResult = {
+ valid: true;
+ token: string;
+ expiration: Timestamp;
+} | {
+ valid: false;
+ cause: HttpError<{}>;
+}
+
+export function useCredentialsChecker() {
+ const { request } = useApiContext();
+ //check against instance details endpoint
+ //while merchant backend doesn't have a login endpoint
+ async function requestNewLoginToken(
+ baseUrl: string,
+ token: AccessToken,
+ ): Promise<LoginResult> {
+ const data: MerchantBackend.Instances.LoginTokenRequest = {
+ scope: "write",
+ duration: {
+ d_us: "forever"
+ },
+ refreshable: true,
+ }
+ try {
+ const response = await request<MerchantBackend.Instances.LoginTokenSuccessResponse>(baseUrl, `/private/token`, {
+ method: "POST",
+ token,
+ data
+ });
+ return { valid: true, token: response.data.token, expiration: response.data.expiration };
+ } catch (error) {
+ if (error instanceof RequestError) {
+ return { valid: false, cause: error.cause };
+ }
+
+ return {
+ valid: false, cause: {
+ type: ErrorType.UNEXPECTED,
+ loading: false,
+ info: {
+ hasToken: true,
+ status: 0,
+ options: {},
+ url: `/private/token`,
+ payload: {}
+ },
+ exception: error,
+ message: (error instanceof Error ? error.message : "unpexepected error")
+ }
+ };
+ }
+ };
+
+ async function refreshLoginToken(
+ baseUrl: string,
+ token: LoginToken
+ ): Promise<LoginResult> {
+
+ if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) {
+ return {
+ valid: false, cause: {
+ type: ErrorType.CLIENT,
+ status: HttpStatusCode.Unauthorized,
+ message: "login token expired, login again.",
+ info: {
+ hasToken: true,
+ status: 401,
+ options: {},
+ url: `/private/token`,
+ payload: {}
+ },
+ payload: {}
+ },
+ }
+ }
+
+ return requestNewLoginToken(baseUrl, token.token as AccessToken)
+ }
+ return { requestNewLoginToken, refreshLoginToken }
+}
+
+/**
+ *
+ * @param root the request is intended to the base URL and no the instance URL
+ * @returns request handler to
+ */
+export function useBackendBaseRequest(): useBackendBaseRequestType {
+ const { url: backend, token: loginToken } = useBackendContext();
+ const { request: requestHandler } = useApiContext();
+ const token = loginToken?.token;
+
+ const request = useCallback(
+ function requestImpl<T>(
+ endpoint: string,
+ options: RequestOptions = {},
+ ): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(backend, endpoint, { ...options, token }).then(res => {
+ return res
+ }).catch(err => {
+ throw err
+ });
+ },
+ [backend, token],
+ );
+
+ return { request };
+}
+
+export function useBackendInstanceRequest(): useBackendInstanceRequestType {
+ const { url: rootBackendUrl, token: rootToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+ const { request: requestHandler } = useApiContext();
+
+ const { baseUrl, token: loginToken } = !admin
+ ? { baseUrl: rootBackendUrl, token: rootToken }
+ : { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken };
+
+ const token = loginToken?.token;
+
+ const request = useCallback(
+ function requestImpl<T>(
+ endpoint: string,
+ options: RequestOptions = {},
+ ): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, { token, ...options });
+ },
+ [baseUrl, token],
+ );
+
+ const multiFetcher = useCallback(
+ function multiFetcherImpl<T>(
+ args: [endpoints: string[]],
+ ): Promise<HttpResponseOk<T>[]> {
+ const [endpoints] = args
+ return Promise.all(
+ endpoints.map((endpoint) =>
+ requestHandler<T>(baseUrl, endpoint, { token }),
+ ),
+ );
+ },
+ [baseUrl, token],
+ );
+
+ const fetcher = useCallback(
+ function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, { token });
+ },
+ [baseUrl, token],
+ );
+
+ const orderFetcher = useCallback(
+ function orderFetcherImpl<T>(
+ args: [endpoint: string,
+ paid?: YesOrNo,
+ refunded?: YesOrNo,
+ wired?: YesOrNo,
+ searchDate?: Date,
+ delta?: number,]
+ ): Promise<HttpResponseOk<T>> {
+ const [endpoint, paid, refunded, wired, searchDate, delta] = args
+ const date_s =
+ delta && delta < 0 && searchDate
+ ? Math.floor(searchDate.getTime() / 1000) + 1
+ : searchDate !== undefined ? Math.floor(searchDate.getTime() / 1000) : undefined;
+ const params: any = {};
+ if (paid !== undefined) params.paid = paid;
+ if (delta !== undefined) params.delta = delta;
+ if (refunded !== undefined) params.refunded = refunded;
+ if (wired !== undefined) params.wired = wired;
+ if (date_s !== undefined) params.date_s = date_s;
+ if (delta === 0) {
+ //in this case we can already assume the response
+ //and avoid network
+ return Promise.resolve({
+ ok: true,
+ data: { orders: [] } as T,
+ })
+ }
+ return requestHandler<T>(baseUrl, endpoint, { params, token });
+ },
+ [baseUrl, token],
+ );
+
+ const reserveDetailFetcher = useCallback(
+ function reserveDetailFetcherImpl<T>(
+ endpoint: string,
+ ): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, {
+ params: {
+ rewards: "yes",
+ },
+ token,
+ });
+ },
+ [baseUrl, token],
+ );
+
+ const rewardsDetailFetcher = useCallback(
+ function rewardsDetailFetcherImpl<T>(
+ endpoint: string,
+ ): Promise<HttpResponseOk<T>> {
+ return requestHandler<T>(baseUrl, endpoint, {
+ params: {
+ pickups: "yes",
+ },
+ token,
+ });
+ },
+ [baseUrl, token],
+ );
+
+ const transferFetcher = useCallback(
+ function transferFetcherImpl<T>(
+ args: [endpoint: string,
+ payto_uri?: string,
+ verified?: string,
+ position?: string,
+ delta?: number,]
+ ): Promise<HttpResponseOk<T>> {
+ const [endpoint, payto_uri, verified, position, delta] = args
+ const params: any = {};
+ if (payto_uri !== undefined) params.payto_uri = payto_uri;
+ if (verified !== undefined) params.verified = verified;
+ if (delta === 0) {
+ //in this case we can already assume the response
+ //and avoid network
+ return Promise.resolve({
+ ok: true,
+ data: { transfers: [] } as T,
+ })
+ }
+ if (delta !== undefined) {
+ params.limit = delta;
+ }
+ if (position !== undefined) params.offset = position;
+
+ return requestHandler<T>(baseUrl, endpoint, { params, token });
+ },
+ [baseUrl, token],
+ );
+
+ const templateFetcher = useCallback(
+ function templateFetcherImpl<T>(
+ args: [endpoint: string,
+ position?: string,
+ delta?: number,]
+ ): Promise<HttpResponseOk<T>> {
+ const [endpoint, position, delta] = args
+ const params: any = {};
+ if (delta === 0) {
+ //in this case we can already assume the response
+ //and avoid network
+ return Promise.resolve({
+ ok: true,
+ data: { templates: [] } as T,
+ })
+ }
+ if (delta !== undefined) {
+ params.limit = delta;
+ }
+ if (position !== undefined) params.offset = position;
+
+ return requestHandler<T>(baseUrl, endpoint, { params, token });
+ },
+ [baseUrl, token],
+ );
+
+ const webhookFetcher = useCallback(
+ function webhookFetcherImpl<T>(
+ args: [endpoint: string,
+ position?: string,
+ delta?: number,]
+ ): Promise<HttpResponseOk<T>> {
+ const [endpoint, position, delta] = args
+ const params: any = {};
+ if (delta === 0) {
+ //in this case we can already assume the response
+ //and avoid network
+ return Promise.resolve({
+ ok: true,
+ data: { webhooks: [] } as T,
+ })
+ }
+ if (delta !== undefined) {
+ params.limit = delta;
+ }
+ if (position !== undefined) params.offset = position;
+
+ return requestHandler<T>(baseUrl, endpoint, { params, token });
+ },
+ [baseUrl, token],
+ );
+
+ return {
+ request,
+ fetcher,
+ multiFetcher,
+ orderFetcher,
+ reserveDetailFetcher,
+ rewardsDetailFetcher,
+ transferFetcher,
+ templateFetcher,
+ webhookFetcher,
+ };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/bank.ts b/packages/auditor-backoffice-ui/src/hooks/bank.ts
new file mode 100644
index 000000000..03b064646
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/bank.ts
@@ -0,0 +1,217 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+// const MOCKED_ACCOUNTS: Record<string, MerchantBackend.BankAccounts.AccountAddDetails> = {
+// "hwire1": {
+// h_wire: "hwire1",
+// payto_uri: "payto://fake/iban/123",
+// salt: "qwe",
+// },
+// "hwire2": {
+// h_wire: "hwire2",
+// payto_uri: "payto://fake/iban/123",
+// salt: "qwe2",
+// },
+// }
+
+export function useBankAccountAPI(): BankAccountAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const createBankAccount = async (
+ data: MerchantBackend.BankAccounts.AccountAddDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ // MOCKED_ACCOUNTS[data.h_wire] = data
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/accounts`, {
+ method: "POST",
+ data,
+ });
+ await mutateAll(/.*private\/accounts.*/);
+ return res;
+ };
+
+ const updateBankAccount = async (
+ h_wire: string,
+ data: MerchantBackend.BankAccounts.AccountPatchDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ // MOCKED_ACCOUNTS[h_wire].credit_facade_credentials = data.credit_facade_credentials
+ // MOCKED_ACCOUNTS[h_wire].credit_facade_url = data.credit_facade_url
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/accounts/${h_wire}`, {
+ method: "PATCH",
+ data,
+ });
+ await mutateAll(/.*private\/accounts.*/);
+ return res;
+ };
+
+ const deleteBankAccount = async (
+ h_wire: string,
+ ): Promise<HttpResponseOk<void>> => {
+ // delete MOCKED_ACCOUNTS[h_wire]
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/accounts/${h_wire}`, {
+ method: "DELETE",
+ });
+ await mutateAll(/.*private\/accounts.*/);
+ return res;
+ };
+
+ return {
+ createBankAccount,
+ updateBankAccount,
+ deleteBankAccount,
+ };
+}
+
+export interface BankAccountAPI {
+ createBankAccount: (
+ data: MerchantBackend.BankAccounts.AccountAddDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ updateBankAccount: (
+ id: string,
+ data: MerchantBackend.BankAccounts.AccountPatchDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ deleteBankAccount: (id: string) => Promise<HttpResponseOk<void>>;
+}
+
+export interface InstanceBankAccountFilter {
+}
+
+export function useInstanceBankAccounts(
+ args?: InstanceBankAccountFilter,
+ updatePosition?: (id: string) => void,
+): HttpResponsePaginated<
+ MerchantBackend.BankAccounts.AccountsSummaryResponse,
+ MerchantBackend.ErrorDetail
+> {
+ // return {
+ // ok: true,
+ // loadMore() { },
+ // loadMorePrev() { },
+ // data: {
+ // accounts: Object.values(MOCKED_ACCOUNTS).map(e => ({
+ // ...e,
+ // active: true,
+ // }))
+ // }
+ // }
+ const { fetcher } = useBackendInstanceRequest();
+
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.BankAccounts.AccountsSummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/accounts`], fetcher);
+
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.BankAccounts.AccountsSummaryResponse,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ }, [afterData /*, beforeData*/]);
+
+ if (afterError) return afterError.cause;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data.accounts.length < totalAfter;
+ const isReachingStart = false;
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.accounts.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from = `${afterData.data.accounts[afterData.data.accounts.length - 1]
+ .h_wire
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ },
+ };
+
+ const accounts = !afterData ? [] : (afterData || lastAfter).data.accounts;
+ if (loadingAfter /* || loadingBefore */)
+ return { loading: true, data: { accounts } };
+ if (/*beforeData &&*/ afterData) {
+ return { ok: true, data: { accounts }, ...pagination };
+ }
+ return { loading: true };
+}
+
+export function useBankAccountDetails(
+ h_wire: string,
+): HttpResponse<
+ MerchantBackend.BankAccounts.BankAccountEntry,
+ MerchantBackend.ErrorDetail
+> {
+ // return {
+ // ok: true,
+ // data: {
+ // ...MOCKED_ACCOUNTS[h_wire],
+ // active: true,
+ // }
+ // }
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.BankAccounts.BankAccountEntry>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/accounts/${h_wire}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) {
+ return data;
+ }
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts b/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts
new file mode 100644
index 000000000..e4ec9a2f2
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/deposit_confirmations.ts
@@ -0,0 +1,161 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { AuditorBackend, MerchantBackend, WithId } from "../declaration.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook, useSWRConfig } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface DepositConfirmationAPI {
+ getDepositConfirmation: (
+ id: string,
+ ) => Promise<void>;
+ createDepositConfirmation: (
+ data: MerchantBackend.Products.ProductAddDetail,
+ ) => Promise<void>;
+ updateDepositConfirmation: (
+ id: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ) => Promise<void>;
+ deleteDepositConfirmation: (id: string) => Promise<void>;
+}
+
+export function useDepositConfirmationAPI(): DepositConfirmationAPI {
+ const mutateAll = useMatchMutate();
+ const { mutate } = useSWRConfig();
+
+ const { request } = useBackendInstanceRequest();
+
+ const createDepositConfirmation = async (
+ data: MerchantBackend.Products.ProductAddDetail,
+ ): Promise<void> => {
+ const res = await request(`/private/products`, {
+ method: "POST",
+ data,
+ });
+
+ return await mutateAll(/.*\/private\/products.*/);
+ };
+
+ const updateDepositConfirmation = async (
+ productId: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ): Promise<void> => {
+ const r = await request(`/private/products/${productId}`, {
+ method: "PATCH",
+ data,
+ });
+
+ return await mutateAll(/.*\/private\/products.*/);
+ };
+
+ const deleteDepositConfirmation = async (productId: string): Promise<void> => {
+ await request(`/private/products/${productId}`, {
+ method: "DELETE",
+ });
+ await mutate([`/private/products`]);
+ };
+
+ const getDepositConfirmation = async (
+ serialId: string,
+ ): Promise<void> => {
+ await request(`/deposit-confirmation/${serialId}`, {
+ method: "GET",
+ });
+
+ return
+ };
+
+ return {createDepositConfirmation, updateDepositConfirmation, deleteDepositConfirmation, getDepositConfirmation};
+}
+
+export function useDepositConfirmation(): HttpResponse<
+ (AuditorBackend.DepositConfirmation.DepositConfirmationDetail & WithId)[],
+ AuditorBackend.ErrorDetail
+> {
+ const { fetcher, multiFetcher } = useBackendInstanceRequest();
+
+ const { data: list, error: listError } = useSWR<
+ HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationList>,
+ RequestError<AuditorBackend.ErrorDetail>
+ >([`/deposit-confirmation`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ const paths = (list?.data.depositConfirmations || []).map(
+ (p) => `/deposit-confirmation/${p.serial_id}`,
+ );
+ const { data: depositConfirmations, error: depositConfirmationError } = useSWR<
+ HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationDetail>[],
+ RequestError<AuditorBackend.ErrorDetail>
+ >([paths], multiFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (listError) return listError.cause;
+ if (depositConfirmationError) return depositConfirmationError.cause;
+
+ if (depositConfirmations) {
+ const dataWithId = depositConfirmations.map((d) => {
+ //take the id from the queried url
+ return {
+ ...d.data,
+ id: d.info?.url.replace(/.*\/deposit-confirmation\//, "") || "",
+ };
+ });
+ return { ok: true, data: dataWithId };
+ }
+ return { loading: true };
+}
+
+export function useDepositConfirmationDetails(
+ serialId: string,
+): HttpResponse<
+ AuditorBackend.DepositConfirmation.DepositConfirmationDetail,
+ AuditorBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<AuditorBackend.DepositConfirmation.DepositConfirmationDetail>,
+ RequestError<AuditorBackend.ErrorDetail>
+ >([`/deposit-confirmation/${serialId}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/index.ts b/packages/auditor-backoffice-ui/src/hooks/index.ts
new file mode 100644
index 000000000..61afbc94a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/index.ts
@@ -0,0 +1,151 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { buildCodecForObject, codecForMap, codecForString, codecForTimestamp } from "@gnu-taler/taler-util";
+import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
+import { StateUpdater, useEffect, useState } from "preact/hooks";
+import { LoginToken } from "../declaration.js";
+import { ValueOrFunction } from "../utils/types.js";
+import { useMatchMutate } from "./backend.js";
+
+const calculateRootPath = () => {
+ const rootPath =
+ typeof window !== undefined
+ ? window.location.origin + window.location.pathname
+ : "/";
+
+ /**
+ * By default, merchant backend serves the html content
+ * from the /webui root. This should cover most of the
+ * cases and the rootPath will be the merchant backend
+ * URL where the instances are
+ */
+ return rootPath.replace("/webui/", "");
+};
+
+const loginTokenCodec = buildCodecForObject<LoginToken>()
+ .property("token", codecForString())
+ .property("expiration", codecForTimestamp)
+ .build("loginToken")
+const TOKENS_KEY = buildStorageKey("auditor-token", codecForMap(loginTokenCodec));
+
+
+export function useBackendURL(
+ url?: string,
+): [string, StateUpdater<string>] {
+ const [value, setter] = useSimpleLocalStorage(
+ "auditor-base-url",
+ url || calculateRootPath(),
+ );
+
+ const checkedSetter = (v: ValueOrFunction<string>) => {
+ return setter((p) => (v instanceof Function ? v(p ?? "") : v).replace(/\/$/, ""));
+ };
+
+ return [value!, checkedSetter];
+}
+
+export function useBackendDefaultToken(
+): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] {
+ const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})
+
+ const tokenOfDefaultInstance = tokenMap["default"]
+ const clearCache = useMatchMutate()
+ useEffect(() => {
+ clearCache()
+ }, [tokenOfDefaultInstance])
+
+ function updateToken(
+ value: (LoginToken | undefined)
+ ): void {
+ if (value === undefined) {
+ reset()
+ } else {
+ const res = { ...tokenMap, "default": value }
+ setToken(res)
+ }
+ }
+ return [tokenMap["default"], updateToken];
+}
+
+export function useBackendInstanceToken(
+ id: string,
+): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] {
+ const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})
+ const [defaultToken, defaultSetToken] = useBackendDefaultToken();
+
+ // instance named 'default' use the default token
+ if (id === "default") {
+ return [defaultToken, defaultSetToken];
+ }
+ function updateToken(
+ value: (LoginToken | undefined)
+ ): void {
+ if (value === undefined) {
+ reset()
+ } else {
+ const res = { ...tokenMap, [id]: value }
+ setToken(res)
+ }
+ }
+
+ return [tokenMap[id], updateToken];
+}
+
+export function useLang(initial?: string): [string, StateUpdater<string>] {
+ const browserLang =
+ typeof window !== "undefined"
+ ? navigator.language || (navigator as any).userLanguage
+ : undefined;
+ const defaultLang = (browserLang || initial || "en").substring(0, 2);
+ return useSimpleLocalStorage("lang-preference", defaultLang) as [string, StateUpdater<string>];
+}
+
+export function useSimpleLocalStorage(
+ key: string,
+ initialValue?: string,
+): [string | undefined, StateUpdater<string | undefined>] {
+ const [storedValue, setStoredValue] = useState<string | undefined>(
+ (): string | undefined => {
+ return typeof window !== "undefined"
+ ? window.localStorage.getItem(key) || initialValue
+ : initialValue;
+ },
+ );
+
+ const setValue = (
+ value?: string | ((val?: string) => string | undefined),
+ ) => {
+ setStoredValue((p) => {
+ const toStore = value instanceof Function ? value(p) : value;
+ if (typeof window !== "undefined") {
+ if (!toStore) {
+ window.localStorage.removeItem(key);
+ } else {
+ window.localStorage.setItem(key, toStore);
+ }
+ }
+ return toStore;
+ });
+ };
+
+ return [storedValue, setValue];
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/instance.test.ts b/packages/auditor-backoffice-ui/src/hooks/instance.test.ts
new file mode 100644
index 000000000..ee1576764
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/instance.test.ts
@@ -0,0 +1,741 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { AccessToken, MerchantBackend } from "../declaration.js";
+import {
+ useAdminAPI,
+ useBackendInstances,
+ useInstanceAPI,
+ useInstanceDetails,
+ useManagementAPI,
+} from "./instance.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_CREATE_INSTANCE,
+ API_DELETE_INSTANCE,
+ API_GET_CURRENT_INSTANCE,
+ API_LIST_INSTANCES,
+ API_NEW_LOGIN,
+ API_UPDATE_CURRENT_INSTANCE,
+ API_UPDATE_CURRENT_INSTANCE_AUTH,
+ API_UPDATE_INSTANCE_BY_ID,
+} from "./urls.js";
+
+describe("instance api interaction with details", () => {
+ it("should evict cache when updating an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useInstanceAPI();
+ const query = useInstanceDetails();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "instance_name",
+ });
+ env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE, {
+ request: {
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceReconfigurationMessage,
+ });
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "other_name",
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+ api.updateInstance({
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceReconfigurationMessage);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "other_name",
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when setting the instance's token", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "not-secret",
+ },
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useInstanceAPI();
+ const query = useInstanceDetails();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "instance_name",
+ auth: {
+ method: "token",
+ },
+ });
+ env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, {
+ request: {
+ method: "token",
+ token: "secret",
+ } as MerchantBackend.Instances.InstanceAuthConfigurationMessage,
+ });
+ env.addRequestExpectation(API_NEW_LOGIN, {
+ auth: "secret",
+ request: {
+ scope: "write",
+ duration: {
+ "d_us": "forever",
+ },
+ refreshable: true,
+ },
+ });
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "secret",
+ },
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+ api.setNewAccessToken(undefined, "secret" as AccessToken);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "secret",
+ },
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when clearing the instance's token", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "not-secret",
+ },
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useInstanceAPI();
+ const query = useInstanceDetails();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "not-secret",
+ },
+ });
+ env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, {
+ request: {
+ method: "external",
+ } as MerchantBackend.Instances.InstanceAuthConfigurationMessage,
+ });
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "external",
+ },
+ } as MerchantBackend.Instances.QueryInstancesResponse,
+ });
+
+ api.clearAccessToken(undefined);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ name: "instance_name",
+ auth: {
+ method: "external",
+ },
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ // const { result, waitForNextUpdate } = renderHook(
+ // () => {
+ // const api = useInstanceAPI();
+ // const query = useInstanceDetails();
+
+ // return { query, api };
+ // },
+ // { wrapper: TestingContext }
+ // );
+
+ // expect(result.current).not.undefined;
+ // if (!result.current) {
+ // return;
+ // }
+ // expect(result.current.query.loading).true;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+
+ // expect(result.current?.query.ok).true;
+ // if (!result.current?.query.ok) return;
+
+ // expect(result.current.query.data).equals({
+ // name: 'instance_name',
+ // auth: {
+ // method: 'token',
+ // token: 'not-secret',
+ // }
+ // });
+
+ // act(async () => {
+ // await result.current?.api.clearToken();
+ // });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+ // expect(result.current.query.ok).true;
+
+ // expect(result.current.query.data).equals({
+ // name: 'instance_name',
+ // auth: {
+ // method: 'external',
+ // }
+ // });
+ });
+});
+
+describe("instance admin api interaction with listing", () => {
+ it("should evict cache when creating a new instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useAdminAPI();
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ name: "instance_name",
+ },
+ ],
+ });
+
+ env.addRequestExpectation(API_CREATE_INSTANCE, {
+ request: {
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceConfigurationMessage,
+ });
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ {
+ name: "other_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ api.createInstance({
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceConfigurationMessage);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ name: "instance_name",
+ },
+ {
+ name: "other_name",
+ },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ {
+ id: "the_id",
+ name: "second_instance",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useAdminAPI();
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ },
+ {
+ id: "the_id",
+ name: "second_instance",
+ },
+ ],
+ });
+
+ env.addRequestExpectation(API_DELETE_INSTANCE("the_id"), {});
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ api.deleteInstance("the_id");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // const { result, waitForNextUpdate } = renderHook(
+ // () => {
+ // const api = useAdminAPI();
+ // const query = useBackendInstances();
+
+ // return { query, api };
+ // },
+ // { wrapper: TestingContext }
+ // );
+
+ // expect(result.current).not.undefined;
+ // if (!result.current) {
+ // return;
+ // }
+ // expect(result.current.query.loading).true;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+
+ // expect(result.current?.query.ok).true;
+ // if (!result.current?.query.ok) return;
+
+ // expect(result.current.query.data).equals({
+ // instances: [{
+ // id: 'default',
+ // name: 'instance_name'
+ // }, {
+ // id: 'the_id',
+ // name: 'second_instance'
+ // }]
+ // });
+
+ // env.addRequestExpectation(API_DELETE_INSTANCE('the_id'), {});
+
+ // act(async () => {
+ // await result.current?.api.deleteInstance('the_id');
+ // });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // env.addRequestExpectation(API_LIST_INSTANCES, {
+ // response: {
+ // instances: [{
+ // id: 'default',
+ // name: 'instance_name'
+ // } as MerchantBackend.Instances.Instance]
+ // },
+ // });
+
+ // expect(result.current.query.loading).false;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+ // expect(result.current.query.ok).true;
+
+ // expect(result.current.query.data).equals({
+ // instances: [{
+ // id: 'default',
+ // name: 'instance_name'
+ // }]
+ // });
+ });
+
+ it("should evict cache when deleting (purge) an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ {
+ id: "the_id",
+ name: "second_instance",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useAdminAPI();
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ },
+ {
+ id: "the_id",
+ name: "second_instance",
+ },
+ ],
+ });
+
+ env.addRequestExpectation(API_DELETE_INSTANCE("the_id"), {
+ qparam: {
+ purge: "YES",
+ },
+ });
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ api.purgeInstance("the_id");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("instance management api interaction with listing", () => {
+ it("should evict cache when updating an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "managed",
+ name: "instance_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useManagementAPI("managed");
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "managed",
+ name: "instance_name",
+ },
+ ],
+ });
+
+ env.addRequestExpectation(API_UPDATE_INSTANCE_BY_ID("managed"), {
+ request: {
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceReconfigurationMessage,
+ });
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "managed",
+ name: "other_name",
+ } as MerchantBackend.Instances.Instance,
+ ],
+ },
+ });
+
+ api.updateInstance({
+ name: "other_name",
+ } as MerchantBackend.Instances.InstanceConfigurationMessage);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ instances: [
+ {
+ id: "managed",
+ name: "other_name",
+ },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/hooks/instance.ts b/packages/auditor-backoffice-ui/src/hooks/instance.ts
new file mode 100644
index 000000000..0677191db
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/instance.ts
@@ -0,0 +1,313 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useBackendContext } from "../context/backend.js";
+import { AccessToken, MerchantBackend } from "../declaration.js";
+import {
+ useBackendBaseRequest,
+ useBackendInstanceRequest,
+ useCredentialsChecker,
+ useMatchMutate,
+} from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook, useSWRConfig } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+interface InstanceAPI {
+ updateInstance: (
+ data: MerchantBackend.Instances.InstanceReconfigurationMessage,
+ ) => Promise<void>;
+ deleteInstance: () => Promise<void>;
+ clearAccessToken: (currentToken: AccessToken | undefined) => Promise<void>;
+ setNewAccessToken: (currentToken: AccessToken | undefined, token: AccessToken) => Promise<void>;
+}
+
+export function useAdminAPI(): AdminAPI {
+ const { request } = useBackendBaseRequest();
+ const mutateAll = useMatchMutate();
+
+ const createInstance = async (
+ instance: MerchantBackend.Instances.InstanceConfigurationMessage,
+ ): Promise<void> => {
+ await request(`/management/instances`, {
+ method: "POST",
+ data: instance,
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const deleteInstance = async (id: string): Promise<void> => {
+ await request(`/management/instances/${id}`, {
+ method: "DELETE",
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const purgeInstance = async (id: string): Promise<void> => {
+ await request(`/management/instances/${id}`, {
+ method: "DELETE",
+ params: {
+ purge: "YES",
+ },
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ return { createInstance, deleteInstance, purgeInstance };
+}
+
+export interface AdminAPI {
+ createInstance: (
+ data: MerchantBackend.Instances.InstanceConfigurationMessage,
+ ) => Promise<void>;
+ deleteInstance: (id: string) => Promise<void>;
+ purgeInstance: (id: string) => Promise<void>;
+}
+
+export function useManagementAPI(instanceId: string): InstanceAPI {
+ const mutateAll = useMatchMutate();
+ const { url: backendURL } = useBackendContext()
+ const { updateToken } = useBackendContext();
+ const { request } = useBackendBaseRequest();
+ const { requestNewLoginToken } = useCredentialsChecker()
+
+ const updateInstance = async (
+ instance: MerchantBackend.Instances.InstanceReconfigurationMessage,
+ ): Promise<void> => {
+ await request(`/management/instances/${instanceId}`, {
+ method: "PATCH",
+ data: instance,
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const deleteInstance = async (): Promise<void> => {
+ await request(`/management/instances/${instanceId}`, {
+ method: "DELETE",
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const clearAccessToken = async (currentToken: AccessToken | undefined): Promise<void> => {
+ await request(`/management/instances/${instanceId}/auth`, {
+ method: "POST",
+ token: currentToken,
+ data: { method: "external" },
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const setNewAccessToken = async (currentToken: AccessToken | undefined, newToken: AccessToken): Promise<void> => {
+ await request(`/management/instances/${instanceId}/auth`, {
+ method: "POST",
+ token: currentToken,
+ data: { method: "token", token: newToken },
+ });
+
+ const resp = await requestNewLoginToken(backendURL, newToken)
+ if (resp.valid) {
+ const { token, expiration } = resp
+ updateToken({ token, expiration });
+ } else {
+ updateToken(undefined)
+ }
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ return { updateInstance, deleteInstance, setNewAccessToken, clearAccessToken };
+}
+
+export function useInstanceAPI(): InstanceAPI {
+ const { mutate } = useSWRConfig();
+ const { url: backendURL, updateToken } = useBackendContext()
+
+ const {
+ token: adminToken,
+ } = useBackendContext();
+ const { request } = useBackendInstanceRequest();
+ const { requestNewLoginToken } = useCredentialsChecker()
+
+ const updateInstance = async (
+ instance: MerchantBackend.Instances.InstanceReconfigurationMessage,
+ ): Promise<void> => {
+ await request(`/private/`, {
+ method: "PATCH",
+ data: instance,
+ });
+
+ if (adminToken) mutate(["/private/instances", adminToken, backendURL], null);
+ mutate([`/private/`], null);
+ };
+
+ const deleteInstance = async (): Promise<void> => {
+ await request(`/private/`, {
+ method: "DELETE",
+ // token: adminToken,
+ });
+
+ if (adminToken) mutate(["/private/instances", adminToken, backendURL], null);
+ mutate([`/private/`], null);
+ };
+
+ const clearAccessToken = async (currentToken: AccessToken | undefined): Promise<void> => {
+ await request(`/private/auth`, {
+ method: "POST",
+ token: currentToken,
+ data: { method: "external" },
+ });
+
+ mutate([`/private/`], null);
+ };
+
+ const setNewAccessToken = async (currentToken: AccessToken | undefined, newToken: AccessToken): Promise<void> => {
+ await request(`/private/auth`, {
+ method: "POST",
+ token: currentToken,
+ data: { method: "token", token: newToken },
+ });
+
+ const resp = await requestNewLoginToken(backendURL, newToken)
+ if (resp.valid) {
+ const { token, expiration } = resp
+ updateToken({ token, expiration });
+ } else {
+ updateToken(undefined)
+ }
+
+ mutate([`/private/`], null);
+ };
+
+ return { updateInstance, deleteInstance, setNewAccessToken, clearAccessToken };
+}
+
+export function useInstanceDetails(): HttpResponse<
+ MerchantBackend.Instances.QueryInstancesResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ revalidateIfStale: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+type KYCStatus =
+ | { type: "ok" }
+ | { type: "redirect"; status: MerchantBackend.KYC.AccountKycRedirects };
+
+export function useInstanceKYCDetails(): HttpResponse<
+ KYCStatus,
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error } = useSWR<
+ HttpResponseOk<MerchantBackend.KYC.AccountKycRedirects>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/kyc`], fetcher, {
+ refreshInterval: 60 * 1000,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateIfStale: false,
+ revalidateOnMount: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ });
+
+ if (data) {
+ if (data.info?.status === 202)
+ return { ok: true, data: { type: "redirect", status: data.data } };
+ return { ok: true, data: { type: "ok" } };
+ }
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+export function useManagedInstanceDetails(
+ instanceId: string,
+): HttpResponse<
+ MerchantBackend.Instances.QueryInstancesResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { request } = useBackendBaseRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/management/instances/${instanceId}`], request, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+export function useBackendInstances(): HttpResponse<
+ MerchantBackend.Instances.InstancesResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { request } = useBackendBaseRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Instances.InstancesResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >(["/management/instances"], request);
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/listener.ts b/packages/auditor-backoffice-ui/src/hooks/listener.ts
new file mode 100644
index 000000000..d101f7bb8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/listener.ts
@@ -0,0 +1,85 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useState } from "preact/hooks";
+
+/**
+ * This component is used when a component wants one child to have a trigger for
+ * an action (a button) and other child have the action implemented (like
+ * gathering information with a form). The difference with other approaches is
+ * that in this case the parent component is not holding the state.
+ *
+ * It will return a subscriber and activator.
+ *
+ * The activator may be undefined, if it is undefined it is indicating that the
+ * subscriber is not ready to be called.
+ *
+ * The subscriber will receive a function (the listener) that will be call when the
+ * activator runs. The listener must return the collected information.
+ *
+ * As a result, when the activator is triggered by a child component, the
+ * @action function is called receives the information from the listener defined by other
+ * child component
+ *
+ * @param action from <T> to <R>
+ * @returns activator and subscriber, undefined activator means that there is not subscriber
+ */
+
+export function useListener<T, R = any>(
+ action: (r: T) => Promise<R>,
+): [undefined | (() => Promise<R>), (listener?: () => T) => void] {
+ type RunnerHandler = { toBeRan?: () => Promise<R> };
+ const [state, setState] = useState<RunnerHandler>({});
+
+ /**
+ * subscriber will receive a method that will be call when the activator runs
+ *
+ * @param listener function to be run when the activator runs
+ */
+ const subscriber = (listener?: () => T) => {
+ if (listener) {
+ setState({
+ toBeRan: () => {
+ const whatWeGetFromTheListener = listener();
+ return action(whatWeGetFromTheListener);
+ },
+ });
+ } else {
+ setState({
+ toBeRan: undefined,
+ });
+ }
+ };
+
+ /**
+ * activator will call runner if there is someone subscribed
+ */
+ const activator = state.toBeRan
+ ? async () => {
+ if (state.toBeRan) {
+ return state.toBeRan();
+ }
+ return Promise.reject();
+ }
+ : undefined;
+
+ return [activator, subscriber];
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/notifications.ts b/packages/auditor-backoffice-ui/src/hooks/notifications.ts
new file mode 100644
index 000000000..133ddd80b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/notifications.ts
@@ -0,0 +1,56 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useState } from "preact/hooks";
+import { Notification } from "../utils/types.js";
+
+interface Result {
+ notifications: Notification[];
+ pushNotification: (n: Notification) => void;
+ removeNotification: (n: Notification) => void;
+}
+
+type NotificationWithDate = Notification & { since: Date };
+
+export function useNotifications(
+ initial: Notification[] = [],
+ timeout = 3000,
+): Result {
+ const [notifications, setNotifications] = useState<NotificationWithDate[]>(
+ initial.map((i) => ({ ...i, since: new Date() })),
+ );
+
+ const pushNotification = (n: Notification): void => {
+ const entry = { ...n, since: new Date() };
+ setNotifications((ns) => [...ns, entry]);
+ if (n.type !== "ERROR")
+ setTimeout(() => {
+ setNotifications((ns) => ns.filter((x) => x.since !== entry.since));
+ }, timeout);
+ };
+
+ const removeNotification = (notif: Notification) => {
+ setNotifications((ns: NotificationWithDate[]) =>
+ ns.filter((n) => n !== notif),
+ );
+ };
+ return { notifications, pushNotification, removeNotification };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/order.test.ts b/packages/auditor-backoffice-ui/src/hooks/order.test.ts
new file mode 100644
index 000000000..c243309a8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/order.test.ts
@@ -0,0 +1,587 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { MerchantBackend } from "../declaration.js";
+import { useInstanceOrders, useOrderAPI, useOrderDetails } from "./order.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_CREATE_ORDER,
+ API_DELETE_ORDER,
+ API_FORGET_ORDER_BY_ID,
+ API_GET_ORDER_BY_ID,
+ API_LIST_ORDERS,
+ API_REFUND_ORDER_BY_ID,
+} from "./urls.js";
+
+describe("order api interaction with listing", () => {
+ it("should evict cache when creating an order", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "1" }, { order_id: "2" } as MerchantBackend.Orders.OrderHistoryEntry],
+ },
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceOrders({ paid: "yes" }, newDate);
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [{ order_id: "1" }, { order_id: "2" }],
+ });
+
+ env.addRequestExpectation(API_CREATE_ORDER, {
+ request: {
+ order: { amount: "ARS:12", summary: "pay me" },
+ },
+ response: { order_id: "3" },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "1" }, { order_id: "2" } as any, { order_id: "3" } as any],
+ },
+ });
+
+ api.createOrder({
+ order: { amount: "ARS:12", summary: "pay me" },
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [{ order_id: "1" }, { order_id: "2" }, { order_id: "3" }],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when doing a refund", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: { orders: [{
+ order_id: "1",
+ amount: "EUR:12",
+ refundable: true,
+ } as MerchantBackend.Orders.OrderHistoryEntry] },
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceOrders({ paid: "yes" }, newDate);
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [
+ {
+ order_id: "1",
+ amount: "EUR:12",
+ refundable: true,
+ },
+ ],
+ });
+ env.addRequestExpectation(API_REFUND_ORDER_BY_ID("1"), {
+ request: {
+ reason: "double pay",
+ refund: "EUR:1",
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: { orders: [
+ { order_id: "1", amount: "EUR:12", refundable: false } as any,
+ ] },
+ });
+
+ api.refundOrder("1", {
+ reason: "double pay",
+ refund: "EUR:1",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [
+ {
+ order_id: "1",
+ amount: "EUR:12",
+ refundable: false,
+ },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting an order", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "1" }, { order_id: "2" } as MerchantBackend.Orders.OrderHistoryEntry],
+ },
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceOrders({ paid: "yes" }, newDate);
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [{ order_id: "1" }, { order_id: "2" }],
+ });
+
+ env.addRequestExpectation(API_DELETE_ORDER("1"), {});
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "2" } as any],
+ },
+ });
+
+ api.deleteOrder("1");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [{ order_id: "2" }],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("order api interaction with details", () => {
+ it("should evict cache when doing a refund", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ // qparam: { delta: 0, paid: "yes" },
+ response: {
+ summary: "description",
+ refund_amount: "EUR:0",
+ } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse,
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useOrderDetails("1");
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ summary: "description",
+ refund_amount: "EUR:0",
+ });
+ env.addRequestExpectation(API_REFUND_ORDER_BY_ID("1"), {
+ request: {
+ reason: "double pay",
+ refund: "EUR:1",
+ },
+ });
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ response: {
+ summary: "description",
+ refund_amount: "EUR:1",
+ } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse,
+ });
+
+ api.refundOrder("1", {
+ reason: "double pay",
+ refund: "EUR:1",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ summary: "description",
+ refund_amount: "EUR:1",
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when doing a forget", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ // qparam: { delta: 0, paid: "yes" },
+ response: {
+ summary: "description",
+ refund_amount: "EUR:0",
+ } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse,
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useOrderDetails("1");
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ summary: "description",
+ refund_amount: "EUR:0",
+ });
+ env.addRequestExpectation(API_FORGET_ORDER_BY_ID("1"), {
+ request: {
+ fields: ["$.summary"],
+ },
+ });
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ response: {
+ summary: undefined,
+ } as unknown as MerchantBackend.Orders.CheckPaymentPaidResponse,
+ });
+
+ api.forgetOrder("1", {
+ fields: ["$.summary"],
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ summary: undefined,
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("order listing pagination", () => {
+ it("should not load more if has reach the end", async () => {
+ const env = new ApiMockEnvironment();
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: 20, wired: "yes", date_s: 12 },
+ response: {
+ orders: [{ order_id: "1" } as any],
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, wired: "yes", date_s: 13 },
+ response: {
+ orders: [{ order_id: "2" } as any],
+ },
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const date = new Date(12000);
+ const query = useInstanceOrders({ wired: "yes", date }, newDate);
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [{ order_id: "1" }, { order_id: "2" }],
+ });
+ expect(query.isReachingEnd).true;
+ expect(query.isReachingStart).true;
+
+ // should not trigger new state update or query
+ query.loadMore();
+ query.loadMorePrev();
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should load more if result brings more that PAGE_SIZE", async () => {
+ const env = new ApiMockEnvironment();
+
+ const ordersFrom0to20 = Array.from({ length: 20 }).map((e, i) => ({
+ order_id: String(i),
+ }));
+ const ordersFrom20to40 = Array.from({ length: 20 }).map((e, i) => ({
+ order_id: String(i + 20),
+ }));
+ const ordersFrom20to0 = [...ordersFrom0to20].reverse();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: 20, wired: "yes", date_s: 12 },
+ response: {
+ orders: ordersFrom0to20,
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, wired: "yes", date_s: 13 },
+ response: {
+ orders: ordersFrom20to40,
+ },
+ });
+
+ const newDate = (d: Date) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const date = new Date(12000);
+ const query = useInstanceOrders({ wired: "yes", date }, newDate);
+ const api = useOrderAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [...ordersFrom20to0, ...ordersFrom20to40],
+ });
+ expect(query.isReachingEnd).false;
+ expect(query.isReachingStart).false;
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -40, wired: "yes", date_s: 13 },
+ response: {
+ orders: [...ordersFrom20to40, { order_id: "41" }],
+ },
+ });
+
+ query.loadMore();
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [
+ ...ordersFrom20to0,
+ ...ordersFrom20to40,
+ { order_id: "41" },
+ ],
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: 40, wired: "yes", date_s: 12 },
+ response: {
+ orders: [...ordersFrom0to20, { order_id: "-1" }],
+ },
+ });
+
+ query.loadMorePrev();
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ orders: [
+ { order_id: "-1" },
+ ...ordersFrom20to0,
+ ...ordersFrom20to40,
+ { order_id: "41" },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/hooks/order.ts b/packages/auditor-backoffice-ui/src/hooks/order.ts
new file mode 100644
index 000000000..e7a893f2c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/order.ts
@@ -0,0 +1,289 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface OrderAPI {
+ //FIXME: add OutOfStockResponse on 410
+ createOrder: (
+ data: MerchantBackend.Orders.PostOrderRequest,
+ ) => Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>>;
+ forgetOrder: (
+ id: string,
+ data: MerchantBackend.Orders.ForgetRequest,
+ ) => Promise<HttpResponseOk<void>>;
+ refundOrder: (
+ id: string,
+ data: MerchantBackend.Orders.RefundRequest,
+ ) => Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>>;
+ deleteOrder: (id: string) => Promise<HttpResponseOk<void>>;
+ getPaymentURL: (id: string) => Promise<HttpResponseOk<string>>;
+}
+
+type YesOrNo = "yes" | "no";
+
+export function useOrderAPI(): OrderAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const createOrder = async (
+ data: MerchantBackend.Orders.PostOrderRequest,
+ ): Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>> => {
+ const res = await request<MerchantBackend.Orders.PostOrderResponse>(
+ `/private/orders`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+ await mutateAll(/.*private\/orders.*/);
+ // mutate('')
+ return res;
+ };
+ const refundOrder = async (
+ orderId: string,
+ data: MerchantBackend.Orders.RefundRequest,
+ ): Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>> => {
+ mutateAll(/@"\/private\/orders"@/);
+ const res = request<MerchantBackend.Orders.MerchantRefundResponse>(
+ `/private/orders/${orderId}/refund`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+
+ // order list returns refundable information, so we must evict everything
+ await mutateAll(/.*private\/orders.*/);
+ return res;
+ };
+
+ const forgetOrder = async (
+ orderId: string,
+ data: MerchantBackend.Orders.ForgetRequest,
+ ): Promise<HttpResponseOk<void>> => {
+ mutateAll(/@"\/private\/orders"@/);
+ const res = request<void>(`/private/orders/${orderId}/forget`, {
+ method: "PATCH",
+ data,
+ });
+ // we may be forgetting some fields that are pare of the listing, so we must evict everything
+ await mutateAll(/.*private\/orders.*/);
+ return res;
+ };
+ const deleteOrder = async (
+ orderId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ mutateAll(/@"\/private\/orders"@/);
+ const res = request<void>(`/private/orders/${orderId}`, {
+ method: "DELETE",
+ });
+ await mutateAll(/.*private\/orders.*/);
+ return res;
+ };
+
+ const getPaymentURL = async (
+ orderId: string,
+ ): Promise<HttpResponseOk<string>> => {
+ return request<MerchantBackend.Orders.MerchantOrderStatusResponse>(
+ `/private/orders/${orderId}`,
+ {
+ method: "GET",
+ },
+ ).then((res) => {
+ const url =
+ res.data.order_status === "unpaid"
+ ? res.data.taler_pay_uri
+ : res.data.contract_terms.fulfillment_url;
+ const response: HttpResponseOk<string> = res as any;
+ response.data = url || "";
+ return response;
+ });
+ };
+
+ return { createOrder, forgetOrder, deleteOrder, refundOrder, getPaymentURL };
+}
+
+export function useOrderDetails(
+ oderId: string,
+): HttpResponse<
+ MerchantBackend.Orders.MerchantOrderStatusResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Orders.MerchantOrderStatusResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/orders/${oderId}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+export interface InstanceOrderFilter {
+ paid?: YesOrNo;
+ refunded?: YesOrNo;
+ wired?: YesOrNo;
+ date?: Date;
+}
+
+export function useInstanceOrders(
+ args?: InstanceOrderFilter,
+ updateFilter?: (d: Date) => void,
+): HttpResponsePaginated<
+ MerchantBackend.Orders.OrderHistory,
+ MerchantBackend.ErrorDetail
+> {
+ const { orderFetcher } = useBackendInstanceRequest();
+
+ const [pageBefore, setPageBefore] = useState(1);
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const totalBefore = args?.date ? pageBefore * PAGE_SIZE : 0;
+
+ /**
+ * FIXME: this can be cleaned up a little
+ *
+ * the logic of double query should be inside the orderFetch so from the hook perspective and cache
+ * is just one query and one error status
+ */
+ const {
+ data: beforeData,
+ error: beforeError,
+ isValidating: loadingBefore,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Orders.OrderHistory>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >(
+ [
+ `/private/orders`,
+ args?.paid,
+ args?.refunded,
+ args?.wired,
+ args?.date,
+ totalBefore,
+ ],
+ orderFetcher,
+ );
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Orders.OrderHistory>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >(
+ [
+ `/private/orders`,
+ args?.paid,
+ args?.refunded,
+ args?.wired,
+ args?.date,
+ -totalAfter,
+ ],
+ orderFetcher,
+ );
+
+ //this will save last result
+ const [lastBefore, setLastBefore] = useState<
+ HttpResponse<
+ MerchantBackend.Orders.OrderHistory,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.Orders.OrderHistory,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ if (beforeData) setLastBefore(beforeData);
+ }, [afterData, beforeData]);
+
+ if (beforeError) return beforeError.cause;
+ if (afterError) return afterError.cause;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd = afterData && afterData.data.orders.length < totalAfter;
+ const isReachingStart =
+ args?.date === undefined ||
+ (beforeData && beforeData.data.orders.length < totalBefore);
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.orders.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from =
+ afterData.data.orders[afterData.data.orders.length - 1].timestamp.t_s;
+ if (from && from !== "never" && updateFilter)
+ updateFilter(new Date(from * 1000));
+ }
+ },
+ loadMorePrev: () => {
+ if (!beforeData || isReachingStart) return;
+ if (beforeData.data.orders.length < MAX_RESULT_SIZE) {
+ setPageBefore(pageBefore + 1);
+ } else if (beforeData) {
+ const from =
+ beforeData.data.orders[beforeData.data.orders.length - 1].timestamp
+ .t_s;
+ if (from && from !== "never" && updateFilter)
+ updateFilter(new Date(from * 1000));
+ }
+ },
+ };
+
+ const orders =
+ !beforeData || !afterData
+ ? []
+ : (beforeData || lastBefore).data.orders
+ .slice()
+ .reverse()
+ .concat((afterData || lastAfter).data.orders);
+ if (loadingAfter || loadingBefore) return { loading: true, data: { orders } };
+ if (beforeData && afterData) {
+ return { ok: true, data: { orders }, ...pagination };
+ }
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/otp.ts b/packages/auditor-backoffice-ui/src/hooks/otp.ts
new file mode 100644
index 000000000..b045e365a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/otp.ts
@@ -0,0 +1,223 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+const MOCKED_DEVICES: Record<string, MerchantBackend.OTP.OtpDeviceAddDetails> = {
+ "1": {
+ otp_device_description: "first device",
+ otp_algorithm: 1,
+ otp_device_id: "1",
+ otp_key: "123",
+ },
+ "2": {
+ otp_device_description: "second device",
+ otp_algorithm: 0,
+ otp_device_id: "2",
+ otp_key: "456",
+ }
+}
+
+export function useOtpDeviceAPI(): OtpDeviceAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const createOtpDevice = async (
+ data: MerchantBackend.OTP.OtpDeviceAddDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ // MOCKED_DEVICES[data.otp_device_id] = data
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/otp-devices`, {
+ method: "POST",
+ data,
+ });
+ await mutateAll(/.*private\/otp-devices.*/);
+ return res;
+ };
+
+ const updateOtpDevice = async (
+ deviceId: string,
+ data: MerchantBackend.OTP.OtpDevicePatchDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ // MOCKED_DEVICES[deviceId].otp_algorithm = data.otp_algorithm
+ // MOCKED_DEVICES[deviceId].otp_ctr = data.otp_ctr
+ // MOCKED_DEVICES[deviceId].otp_device_description = data.otp_device_description
+ // MOCKED_DEVICES[deviceId].otp_key = data.otp_key
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/otp-devices/${deviceId}`, {
+ method: "PATCH",
+ data,
+ });
+ await mutateAll(/.*private\/otp-devices.*/);
+ return res;
+ };
+
+ const deleteOtpDevice = async (
+ deviceId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ // delete MOCKED_DEVICES[deviceId]
+ // return Promise.resolve({ ok: true, data: undefined });
+ const res = await request<void>(`/private/otp-devices/${deviceId}`, {
+ method: "DELETE",
+ });
+ await mutateAll(/.*private\/otp-devices.*/);
+ return res;
+ };
+
+ return {
+ createOtpDevice,
+ updateOtpDevice,
+ deleteOtpDevice,
+ };
+}
+
+export interface OtpDeviceAPI {
+ createOtpDevice: (
+ data: MerchantBackend.OTP.OtpDeviceAddDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ updateOtpDevice: (
+ id: string,
+ data: MerchantBackend.OTP.OtpDevicePatchDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ deleteOtpDevice: (id: string) => Promise<HttpResponseOk<void>>;
+}
+
+export interface InstanceOtpDeviceFilter {
+}
+
+export function useInstanceOtpDevices(
+ args?: InstanceOtpDeviceFilter,
+ updatePosition?: (id: string) => void,
+): HttpResponsePaginated<
+ MerchantBackend.OTP.OtpDeviceSummaryResponse,
+ MerchantBackend.ErrorDetail
+> {
+ // return {
+ // ok: true,
+ // loadMore: () => { },
+ // loadMorePrev: () => { },
+ // data: {
+ // otp_devices: Object.values(MOCKED_DEVICES).map(d => ({
+ // device_description: d.otp_device_description,
+ // otp_device_id: d.otp_device_id
+ // }))
+ // }
+ // }
+
+ const { fetcher } = useBackendInstanceRequest();
+
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.OTP.OtpDeviceSummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/otp-devices`], fetcher);
+
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.OTP.OtpDeviceSummaryResponse,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ }, [afterData /*, beforeData*/]);
+
+ if (afterError) return afterError.cause;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data.otp_devices.length < totalAfter;
+ const isReachingStart = true;
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.otp_devices.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from = `${afterData.data.otp_devices[afterData.data.otp_devices.length - 1]
+ .otp_device_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ },
+ };
+
+ const otp_devices = !afterData ? [] : (afterData || lastAfter).data.otp_devices;
+ if (loadingAfter /* || loadingBefore */)
+ return { loading: true, data: { otp_devices } };
+ if (/*beforeData &&*/ afterData) {
+ return { ok: true, data: { otp_devices }, ...pagination };
+ }
+ return { loading: true };
+}
+
+export function useOtpDeviceDetails(
+ deviceId: string,
+): HttpResponse<
+ MerchantBackend.OTP.OtpDeviceDetails,
+ MerchantBackend.ErrorDetail
+> {
+ // return {
+ // ok: true,
+ // data: {
+ // device_description: MOCKED_DEVICES[deviceId].otp_device_description,
+ // otp_algorithm: MOCKED_DEVICES[deviceId].otp_algorithm,
+ // otp_ctr: MOCKED_DEVICES[deviceId].otp_ctr
+ // }
+ // }
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.OTP.OtpDeviceDetails>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/otp-devices/${deviceId}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) {
+ return data;
+ }
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/product.test.ts b/packages/auditor-backoffice-ui/src/hooks/product.test.ts
new file mode 100644
index 000000000..7cac10e25
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/product.test.ts
@@ -0,0 +1,362 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { MerchantBackend } from "../declaration.js";
+import {
+ useInstanceProducts,
+ useProductAPI,
+ useProductDetails,
+} from "./product.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_CREATE_PRODUCT,
+ API_DELETE_PRODUCT,
+ API_GET_PRODUCT_BY_ID,
+ API_LIST_PRODUCTS,
+ API_UPDATE_PRODUCT_BY_ID,
+} from "./urls.js";
+
+describe("product api interaction with listing", () => {
+ it("should evict cache when creating a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceProducts();
+ const api = useProductAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]);
+
+ env.addRequestExpectation(API_CREATE_PRODUCT, {
+ request: {
+ price: "ARS:23",
+ } as MerchantBackend.Products.ProductAddDetail,
+ });
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }, { product_id: "2345" }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:12",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:12",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("2345"), {
+ response: {
+ price: "ARS:23",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+
+ api.createProduct({
+ price: "ARS:23",
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([
+ {
+ id: "1234",
+ price: "ARS:12",
+ },
+ {
+ id: "2345",
+ price: "ARS:23",
+ },
+ ]);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when updating a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceProducts();
+ const api = useProductAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]);
+
+ env.addRequestExpectation(API_UPDATE_PRODUCT_BY_ID("1234"), {
+ request: {
+ price: "ARS:13",
+ } as MerchantBackend.Products.ProductPatchDetail,
+ });
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:13",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+
+ api.updateProduct("1234", {
+ price: "ARS:13",
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([
+ {
+ id: "1234",
+ price: "ARS:13",
+ },
+ ]);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }, { product_id: "2345" }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: { price: "ARS:12" } as MerchantBackend.Products.ProductDetail,
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("2345"), {
+ response: { price: "ARS:23" } as MerchantBackend.Products.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceProducts();
+ const api = useProductAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([
+ { id: "1234", price: "ARS:12" },
+ { id: "2345", price: "ARS:23" },
+ ]);
+
+ env.addRequestExpectation(API_DELETE_PRODUCT("2345"), {});
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234" }],
+ },
+ });
+
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:12",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+ api.deleteProduct("2345");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("product api interaction with details", () => {
+ it("should evict cache when updating a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("12"), {
+ response: {
+ description: "this is a description",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useProductDetails("12");
+ const api = useProductAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ description: "this is a description",
+ });
+
+ env.addRequestExpectation(API_UPDATE_PRODUCT_BY_ID("12"), {
+ request: {
+ description: "other description",
+ } as MerchantBackend.Products.ProductPatchDetail,
+ });
+
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("12"), {
+ response: {
+ description: "other description",
+ } as MerchantBackend.Products.ProductDetail,
+ });
+
+ api.updateProduct("12", {
+ description: "other description",
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ description: "other description",
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/hooks/product.ts b/packages/auditor-backoffice-ui/src/hooks/product.ts
new file mode 100644
index 000000000..8ca8d2724
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/product.ts
@@ -0,0 +1,177 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { MerchantBackend, WithId } from "../declaration.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook, useSWRConfig } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface ProductAPI {
+ getProduct: (
+ id: string,
+ ) => Promise<void>;
+ createProduct: (
+ data: MerchantBackend.Products.ProductAddDetail,
+ ) => Promise<void>;
+ updateProduct: (
+ id: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ) => Promise<void>;
+ deleteProduct: (id: string) => Promise<void>;
+ lockProduct: (
+ id: string,
+ data: MerchantBackend.Products.LockRequest,
+ ) => Promise<void>;
+}
+
+export function useProductAPI(): ProductAPI {
+ const mutateAll = useMatchMutate();
+ const { mutate } = useSWRConfig();
+
+ const { request } = useBackendInstanceRequest();
+
+ const createProduct = async (
+ data: MerchantBackend.Products.ProductAddDetail,
+ ): Promise<void> => {
+ const res = await request(`/private/products`, {
+ method: "POST",
+ data,
+ });
+
+ return await mutateAll(/.*\/private\/products.*/);
+ };
+
+ const updateProduct = async (
+ productId: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ): Promise<void> => {
+ const r = await request(`/private/products/${productId}`, {
+ method: "PATCH",
+ data,
+ });
+
+ return await mutateAll(/.*\/private\/products.*/);
+ };
+
+ const deleteProduct = async (productId: string): Promise<void> => {
+ await request(`/private/products/${productId}`, {
+ method: "DELETE",
+ });
+ await mutate([`/private/products`]);
+ };
+
+ const lockProduct = async (
+ productId: string,
+ data: MerchantBackend.Products.LockRequest,
+ ): Promise<void> => {
+ await request(`/private/products/${productId}/lock`, {
+ method: "POST",
+ data,
+ });
+
+ return await mutateAll(/.*"\/private\/products.*/);
+ };
+
+ const getProduct = async (
+ productId: string,
+ ): Promise<void> => {
+ await request(`/private/products/${productId}`, {
+ method: "GET",
+ });
+
+ return
+ };
+
+ return { createProduct, updateProduct, deleteProduct, lockProduct, getProduct };
+}
+
+export function useInstanceProducts(): HttpResponse<
+ (MerchantBackend.Products.ProductDetail & WithId)[],
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher, multiFetcher } = useBackendInstanceRequest();
+
+ const { data: list, error: listError } = useSWR<
+ HttpResponseOk<MerchantBackend.Products.InventorySummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/products`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ const paths = (list?.data.products || []).map(
+ (p) => `/private/products/${p.product_id}`,
+ );
+ const { data: products, error: productError } = useSWR<
+ HttpResponseOk<MerchantBackend.Products.ProductDetail>[],
+ RequestError<MerchantBackend.ErrorDetail>
+ >([paths], multiFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (listError) return listError.cause;
+ if (productError) return productError.cause;
+
+ if (products) {
+ const dataWithId = products.map((d) => {
+ //take the id from the queried url
+ return {
+ ...d.data,
+ id: d.info?.url.replace(/.*\/private\/products\//, "") || "",
+ };
+ });
+ return { ok: true, data: dataWithId };
+ }
+ return { loading: true };
+}
+
+export function useProductDetails(
+ productId: string,
+): HttpResponse<
+ MerchantBackend.Products.ProductDetail,
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Products.ProductDetail>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/products/${productId}`], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts b/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts
new file mode 100644
index 000000000..b3eecd754
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/reserve.test.ts
@@ -0,0 +1,448 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+import { MerchantBackend } from "../declaration.js";
+import {
+ useInstanceReserves,
+ useReserveDetails,
+ useReservesAPI,
+ useRewardDetails,
+} from "./reserves.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_AUTHORIZE_REWARD,
+ API_AUTHORIZE_REWARD_FOR_RESERVE,
+ API_CREATE_RESERVE,
+ API_DELETE_RESERVE,
+ API_GET_RESERVE_BY_ID,
+ API_GET_REWARD_BY_ID,
+ API_LIST_RESERVES,
+} from "./urls.js";
+import * as tests from "@gnu-taler/web-util/testing";
+
+describe("reserve api interaction with listing", () => {
+ it("should evict cache when creating a reserve", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_RESERVES, {
+ response: {
+ reserves: [
+ {
+ reserve_pub: "11",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useReservesAPI();
+ const query = useInstanceReserves();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ reserves: [{ reserve_pub: "11" }],
+ });
+
+ env.addRequestExpectation(API_CREATE_RESERVE, {
+ request: {
+ initial_balance: "ARS:3333",
+ exchange_url: "http://url",
+ wire_method: "iban",
+ },
+ response: {
+ reserve_pub: "22",
+ accounts: [],
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_RESERVES, {
+ response: {
+ reserves: [
+ {
+ reserve_pub: "11",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ {
+ reserve_pub: "22",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ ],
+ },
+ });
+
+ api.createReserve({
+ initial_balance: "ARS:3333",
+ exchange_url: "http://url",
+ wire_method: "iban",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+
+ expect(query.data).deep.equals({
+ reserves: [
+ {
+ reserve_pub: "11",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ {
+ reserve_pub: "22",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting a reserve", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_RESERVES, {
+ response: {
+ reserves: [
+ {
+ reserve_pub: "11",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ {
+ reserve_pub: "22",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ {
+ reserve_pub: "33",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useReservesAPI();
+ const query = useInstanceReserves();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ reserves: [
+ { reserve_pub: "11" },
+ { reserve_pub: "22" },
+ { reserve_pub: "33" },
+ ],
+ });
+
+ env.addRequestExpectation(API_DELETE_RESERVE("11"), {});
+ env.addRequestExpectation(API_LIST_RESERVES, {
+ response: {
+ reserves: [
+ {
+ reserve_pub: "22",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ {
+ reserve_pub: "33",
+ } as MerchantBackend.Rewards.ReserveStatusEntry,
+ ],
+ },
+ });
+
+ api.deleteReserve("11");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ reserves: [{ reserve_pub: "22" }, { reserve_pub: "33" }],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("reserve api interaction with details", () => {
+ it("should evict cache when adding a reward for a specific reserve", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
+ response: {
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
+ } as MerchantBackend.Rewards.ReserveDetail,
+ qparam: {
+ rewards: "yes",
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useReservesAPI();
+ const query = useReserveDetails("11");
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
+ });
+
+ env.addRequestExpectation(API_AUTHORIZE_REWARD_FOR_RESERVE("11"), {
+ request: {
+ amount: "USD:12",
+ justification: "not",
+ next_url: "http://taler.net",
+ },
+ response: {
+ reward_id: "id2",
+ taler_reward_uri: "uri",
+ reward_expiration: { t_s: 1 },
+ reward_status_url: "url",
+ },
+ });
+
+ env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
+ response: {
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [
+ { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
+ { reason: "not", reward_id: "id2", total_amount: "USD:12" },
+ ],
+ } as MerchantBackend.Rewards.ReserveDetail,
+ qparam: {
+ rewards: "yes",
+ },
+ });
+
+ api.authorizeRewardReserve("11", {
+ amount: "USD:12",
+ justification: "not",
+ next_url: "http://taler.net",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+
+ expect(query.data).deep.equals({
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [
+ { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
+ { reason: "not", reward_id: "id2", total_amount: "USD:12" },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when adding a reward for a random reserve", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
+ response: {
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
+ } as MerchantBackend.Rewards.ReserveDetail,
+ qparam: {
+ rewards: "yes",
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const api = useReservesAPI();
+ const query = useReserveDetails("11");
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [{ reason: "why?", reward_id: "id1", total_amount: "USD:10" }],
+ });
+
+ env.addRequestExpectation(API_AUTHORIZE_REWARD, {
+ request: {
+ amount: "USD:12",
+ justification: "not",
+ next_url: "http://taler.net",
+ },
+ response: {
+ reward_id: "id2",
+ taler_reward_uri: "uri",
+ reward_expiration: { t_s: 1 },
+ reward_status_url: "url",
+ },
+ });
+
+ env.addRequestExpectation(API_GET_RESERVE_BY_ID("11"), {
+ response: {
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [
+ { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
+ { reason: "not", reward_id: "id2", total_amount: "USD:12" },
+ ],
+ } as MerchantBackend.Rewards.ReserveDetail,
+ qparam: {
+ rewards: "yes",
+ },
+ });
+
+ api.authorizeReward({
+ amount: "USD:12",
+ justification: "not",
+ next_url: "http://taler.net",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+
+ expect(query.data).deep.equals({
+ accounts: [{ payto_uri: "payto://here" }],
+ rewards: [
+ { reason: "why?", reward_id: "id1", total_amount: "USD:10" },
+ { reason: "not", reward_id: "id2", total_amount: "USD:12" },
+ ],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("reserve api interaction with reward details", () => {
+ it("should list rewards", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_REWARD_BY_ID("11"), {
+ response: {
+ total_picked_up: "USD:12",
+ reason: "not",
+ } as MerchantBackend.Rewards.RewardDetails,
+ qparam: {
+ pickups: "yes",
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useRewardDetails("11");
+ return { query };
+ },
+ {},
+ [
+ ({ query }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ ({ query }) => {
+ expect(query.loading).false;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ total_picked_up: "USD:12",
+ reason: "not",
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/hooks/reserves.ts b/packages/auditor-backoffice-ui/src/hooks/reserves.ts
new file mode 100644
index 000000000..b719bfbe6
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/reserves.ts
@@ -0,0 +1,181 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { MerchantBackend } from "../declaration.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook, useSWRConfig } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function useReservesAPI(): ReserveMutateAPI {
+ const mutateAll = useMatchMutate();
+ const { mutate } = useSWRConfig();
+ const { request } = useBackendInstanceRequest();
+
+ const createReserve = async (
+ data: MerchantBackend.Rewards.ReserveCreateRequest,
+ ): Promise<
+ HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation>
+ > => {
+ const res = await request<MerchantBackend.Rewards.ReserveCreateConfirmation>(
+ `/private/reserves`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+
+ //evict reserve list query
+ await mutateAll(/.*private\/reserves.*/);
+
+ return res;
+ };
+
+ const authorizeRewardReserve = async (
+ pub: string,
+ data: MerchantBackend.Rewards.RewardCreateRequest,
+ ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => {
+ const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>(
+ `/private/reserves/${pub}/authorize-reward`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+
+ //evict reserve details query
+ await mutate([`/private/reserves/${pub}`]);
+
+ return res;
+ };
+
+ const authorizeReward = async (
+ data: MerchantBackend.Rewards.RewardCreateRequest,
+ ): Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>> => {
+ const res = await request<MerchantBackend.Rewards.RewardCreateConfirmation>(
+ `/private/rewards`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+
+ //evict all details query
+ await mutateAll(/.*private\/reserves\/.*/);
+
+ return res;
+ };
+
+ const deleteReserve = async (
+ pub: string,
+ ): Promise<HttpResponse<void, MerchantBackend.ErrorDetail>> => {
+ const res = await request<void>(`/private/reserves/${pub}`, {
+ method: "DELETE",
+ });
+
+ //evict reserve list query
+ await mutateAll(/.*private\/reserves.*/);
+
+ return res;
+ };
+
+ return { createReserve, authorizeReward, authorizeRewardReserve, deleteReserve };
+}
+
+export interface ReserveMutateAPI {
+ createReserve: (
+ data: MerchantBackend.Rewards.ReserveCreateRequest,
+ ) => Promise<HttpResponseOk<MerchantBackend.Rewards.ReserveCreateConfirmation>>;
+ authorizeRewardReserve: (
+ id: string,
+ data: MerchantBackend.Rewards.RewardCreateRequest,
+ ) => Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>>;
+ authorizeReward: (
+ data: MerchantBackend.Rewards.RewardCreateRequest,
+ ) => Promise<HttpResponseOk<MerchantBackend.Rewards.RewardCreateConfirmation>>;
+ deleteReserve: (
+ id: string,
+ ) => Promise<HttpResponse<void, MerchantBackend.ErrorDetail>>;
+}
+
+export function useInstanceReserves(): HttpResponse<
+ MerchantBackend.Rewards.RewardReserveStatus,
+ MerchantBackend.ErrorDetail
+> {
+ const { fetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Rewards.RewardReserveStatus>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/reserves`], fetcher);
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+export function useReserveDetails(
+ reserveId: string,
+): HttpResponse<
+ MerchantBackend.Rewards.ReserveDetail,
+ MerchantBackend.ErrorDetail
+> {
+ const { reserveDetailFetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Rewards.ReserveDetail>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/reserves/${reserveId}`], reserveDetailFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
+
+export function useRewardDetails(
+ rewardId: string,
+): HttpResponse<MerchantBackend.Rewards.RewardDetails, MerchantBackend.ErrorDetail> {
+ const { rewardsDetailFetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Rewards.RewardDetails>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/rewards/${rewardId}`], rewardsDetailFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/templates.ts b/packages/auditor-backoffice-ui/src/hooks/templates.ts
new file mode 100644
index 000000000..ee8728cc8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/templates.ts
@@ -0,0 +1,266 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function useTemplateAPI(): TemplateAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const createTemplate = async (
+ data: MerchantBackend.Template.TemplateAddDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/templates`, {
+ method: "POST",
+ data,
+ });
+ await mutateAll(/.*private\/templates.*/);
+ return res;
+ };
+
+ const updateTemplate = async (
+ templateId: string,
+ data: MerchantBackend.Template.TemplatePatchDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/templates/${templateId}`, {
+ method: "PATCH",
+ data,
+ });
+ await mutateAll(/.*private\/templates.*/);
+ return res;
+ };
+
+ const deleteTemplate = async (
+ templateId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/templates/${templateId}`, {
+ method: "DELETE",
+ });
+ await mutateAll(/.*private\/templates.*/);
+ return res;
+ };
+
+ const createOrderFromTemplate = async (
+ templateId: string,
+ data: MerchantBackend.Template.UsingTemplateDetails,
+ ): Promise<
+ HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse>
+ > => {
+ const res = await request<MerchantBackend.Template.UsingTemplateResponse>(
+ `/templates/${templateId}`,
+ {
+ method: "POST",
+ data,
+ },
+ );
+ await mutateAll(/.*private\/templates.*/);
+ return res;
+ };
+
+ const testTemplateExist = async (
+ templateId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/templates/${templateId}`, { method: "GET", });
+ return res;
+ };
+
+
+ return {
+ createTemplate,
+ updateTemplate,
+ deleteTemplate,
+ testTemplateExist,
+ createOrderFromTemplate,
+ };
+}
+
+export interface TemplateAPI {
+ createTemplate: (
+ data: MerchantBackend.Template.TemplateAddDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ updateTemplate: (
+ id: string,
+ data: MerchantBackend.Template.TemplatePatchDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ testTemplateExist: (
+ id: string
+ ) => Promise<HttpResponseOk<void>>;
+ deleteTemplate: (id: string) => Promise<HttpResponseOk<void>>;
+ createOrderFromTemplate: (
+ id: string,
+ data: MerchantBackend.Template.UsingTemplateDetails,
+ ) => Promise<HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse>>;
+}
+
+export interface InstanceTemplateFilter {
+ //FIXME: add filter to the template list
+ position?: string;
+}
+
+export function useInstanceTemplates(
+ args?: InstanceTemplateFilter,
+ updatePosition?: (id: string) => void,
+): HttpResponsePaginated<
+ MerchantBackend.Template.TemplateSummaryResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { templateFetcher } = useBackendInstanceRequest();
+
+ const [pageBefore, setPageBefore] = useState(1);
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const totalBefore = args?.position ? pageBefore * PAGE_SIZE : 0;
+
+ /**
+ * FIXME: this can be cleaned up a little
+ *
+ * the logic of double query should be inside the orderFetch so from the hook perspective and cache
+ * is just one query and one error status
+ */
+ const {
+ data: beforeData,
+ error: beforeError,
+ isValidating: loadingBefore,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Template.TemplateSummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>>(
+ [
+ `/private/templates`,
+ args?.position,
+ totalBefore,
+ ],
+ templateFetcher,
+ );
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Template.TemplateSummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/templates`, args?.position, -totalAfter], templateFetcher);
+
+ //this will save last result
+ const [lastBefore, setLastBefore] = useState<
+ HttpResponse<
+ MerchantBackend.Template.TemplateSummaryResponse,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.Template.TemplateSummaryResponse,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ if (beforeData) setLastBefore(beforeData);
+ }, [afterData, beforeData]);
+
+ if (beforeError) return beforeError.cause;
+ if (afterError) return afterError.cause;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data.templates.length < totalAfter;
+ const isReachingStart = args?.position === undefined
+ ||
+ (beforeData && beforeData.data.templates.length < totalBefore);
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.templates.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from = `${afterData.data.templates[afterData.data.templates.length - 1]
+ .template_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ if (!beforeData || isReachingStart) return;
+ if (beforeData.data.templates.length < MAX_RESULT_SIZE) {
+ setPageBefore(pageBefore + 1);
+ } else if (beforeData) {
+ const from = `${beforeData.data.templates[beforeData.data.templates.length - 1]
+ .template_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ };
+
+ // const templates = !afterData ? [] : (afterData || lastAfter).data.templates;
+ const templates =
+ !beforeData || !afterData
+ ? []
+ : (beforeData || lastBefore).data.templates
+ .slice()
+ .reverse()
+ .concat((afterData || lastAfter).data.templates);
+ if (loadingAfter || loadingBefore)
+ return { loading: true, data: { templates } };
+ if (beforeData && afterData) {
+ return { ok: true, data: { templates }, ...pagination };
+ }
+ return { loading: true };
+}
+
+export function useTemplateDetails(
+ templateId: string,
+): HttpResponse<
+ MerchantBackend.Template.TemplateDetails,
+ MerchantBackend.ErrorDetail
+> {
+ const { templateFetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Template.TemplateDetails>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/templates/${templateId}`], templateFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) {
+ return data;
+ }
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/testing.tsx b/packages/auditor-backoffice-ui/src/hooks/testing.tsx
new file mode 100644
index 000000000..7955f832a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/testing.tsx
@@ -0,0 +1,180 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { MockEnvironment } from "@gnu-taler/web-util/testing";
+import { ComponentChildren, FunctionalComponent, h, VNode } from "preact";
+import { HttpRequestLibrary, HttpRequestOptions, HttpResponse } from "@gnu-taler/taler-util/http";
+import { SWRConfig } from "swr";
+import { ApiContextProvider } from "@gnu-taler/web-util/browser";
+import { BackendContextProvider } from "../context/backend.js";
+import { InstanceContextProvider } from "../context/instance.js";
+import { HttpResponseOk, RequestOptions } from "@gnu-taler/web-util/browser";
+import { TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient } from "@gnu-taler/taler-util";
+
+export class ApiMockEnvironment extends MockEnvironment {
+ constructor(debug = false) {
+ super(debug);
+ }
+
+ mockApiIfNeeded(): void {
+ null; // do nothing
+ }
+
+ public buildTestingContext(): FunctionalComponent<{
+ children: ComponentChildren;
+ }> {
+ const __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE =
+ this.saveRequestAndGetMockedResponse.bind(this);
+
+ return function TestingContext({
+ children,
+ }: {
+ children: ComponentChildren;
+ }): VNode {
+
+ async function request<T>(
+ base: string,
+ path: string,
+ options: RequestOptions = {},
+ ): Promise<HttpResponseOk<T>> {
+ const _url = new URL(`${base}${path}`);
+ // Object.entries(options.params ?? {}).forEach(([key, value]) => {
+ // _url.searchParams.set(key, String(value));
+ // });
+
+ const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE(
+ {
+ method: options.method ?? "GET",
+ url: _url.href,
+ },
+ {
+ qparam: options.params,
+ auth: options.token,
+ request: options.data,
+ },
+ );
+ const status = mocked.expectedQuery?.query.code ?? 200;
+ const requestPayload = mocked.expectedQuery?.params?.request;
+ const responsePayload = mocked.expectedQuery?.params?.response;
+
+ return {
+ ok: true,
+ data: responsePayload as T,
+ loading: false,
+ clientError: false,
+ serverError: false,
+ info: {
+ hasToken: !!options.token,
+ status,
+ url: _url.href,
+ payload: options.data,
+ options: {},
+ },
+ };
+ }
+ const SC: any = SWRConfig;
+
+ const mockHttpClient = new class implements HttpRequestLibrary {
+ async fetch(url: string, options?: HttpRequestOptions | undefined): Promise<HttpResponse> {
+ const _url = new URL(url);
+ const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE(
+ {
+ method: options?.method ?? "GET",
+ url: _url.href,
+ },
+ {
+ qparam: _url.searchParams,
+ auth: options as any,
+ request: options?.body as any,
+ },
+ );
+ const status = mocked.expectedQuery?.query.code ?? 200;
+ const requestPayload = mocked.expectedQuery?.params?.request;
+ const responsePayload = mocked.expectedQuery?.params?.response;
+
+ // FIXME: complete this implementation to mock any query
+ const resp: HttpResponse = {
+ requestUrl: _url.href,
+ status: status,
+ headers: {} as any,
+ requestMethod: options?.method ?? "GET",
+ json: async () => responsePayload,
+ text: async () => responsePayload as any as string,
+ bytes: async () => responsePayload as ArrayBuffer,
+ };
+ return resp
+ }
+ get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
+ return this.fetch(url, {
+ method: "GET",
+ ...opt,
+ });
+ }
+
+ postJson(
+ url: string,
+ body: any,
+ opt?: HttpRequestOptions,
+ ): Promise<HttpResponse> {
+ return this.fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ ...opt,
+ });
+ }
+
+ }
+ const bankCore = new TalerCoreBankHttpClient("http://localhost", mockHttpClient)
+ const bankIntegration = new TalerBankIntegrationHttpClient(bankCore.getIntegrationAPI().href, mockHttpClient)
+ const bankRevenue = new TalerRevenueHttpClient(bankCore.getRevenueAPI("a").href, mockHttpClient)
+ const bankWire = new TalerWireGatewayHttpClient(bankCore.getWireGatewayAPI("b").href, "b", mockHttpClient)
+
+ return (
+ <BackendContextProvider defaultUrl="http://backend">
+ <InstanceContextProvider
+ value={{
+ token: undefined,
+ id: "default",
+ admin: true,
+ changeToken: () => null,
+ }}
+ >
+ <ApiContextProvider value={{ request, bankCore, bankIntegration, bankRevenue, bankWire }}>
+ <SC
+ value={{
+ loadingTimeout: 0,
+ dedupingInterval: 0,
+ shouldRetryOnError: false,
+ errorRetryInterval: 0,
+ errorRetryCount: 0,
+ provider: () => new Map(),
+ }}
+ >
+ {children}
+ </SC>
+ </ApiContextProvider>
+ </InstanceContextProvider>
+ </BackendContextProvider>
+ );
+ };
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts b/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts
new file mode 100644
index 000000000..a7187af27
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/transfer.test.ts
@@ -0,0 +1,254 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { MerchantBackend } from "../declaration.js";
+import { API_INFORM_TRANSFERS, API_LIST_TRANSFERS } from "./urls.js";
+import { ApiMockEnvironment } from "./testing.js";
+import { useInstanceTransfers, useTransferAPI } from "./transfer.js";
+
+describe("transfer api interaction with listing", () => {
+ it("should evict cache when informing a transfer", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20 },
+ response: {
+ transfers: [{ wtid: "2" } as MerchantBackend.Transfers.TransferDetails],
+ },
+ });
+
+ const moveCursor = (d: string) => {
+ console.log("new position", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceTransfers({}, moveCursor);
+ const api = useTransferAPI();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ expect(query.loading).true;
+ },
+
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ transfers: [{ wtid: "2" }],
+ });
+
+ env.addRequestExpectation(API_INFORM_TRANSFERS, {
+ request: {
+ wtid: "3",
+ credit_amount: "EUR:1",
+ exchange_url: "exchange.url",
+ payto_uri: "payto://",
+ },
+ response: { total: "" } as any,
+ });
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20 },
+ response: {
+ transfers: [{ wtid: "3" } as any, { wtid: "2" } as any],
+ },
+ });
+
+ api.informTransfer({
+ wtid: "3",
+ credit_amount: "EUR:1",
+ exchange_url: "exchange.url",
+ payto_uri: "payto://",
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+
+ expect(query.data).deep.equals({
+ transfers: [{ wtid: "3" }, { wtid: "2" }],
+ });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("transfer listing pagination", () => {
+ it("should not load more if has reach the end", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20, payto_uri: "payto://" },
+ response: {
+ transfers: [{ wtid: "2" }, { wtid: "1" } as any],
+ },
+ });
+
+ const moveCursor = (d: string) => {
+ console.log("new position", d);
+ };
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ return useInstanceTransfers({ payto_uri: "payto://" }, moveCursor);
+ },
+ {},
+ [
+ (query) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(query.loading).true;
+ },
+ (query) => {
+ expect(query.loading).undefined;
+ expect(query.ok).true;
+ if (!query.ok) return;
+ expect(query.data).deep.equals({
+ transfers: [{ wtid: "2" }, { wtid: "1" }],
+ });
+ expect(query.isReachingEnd).true;
+ expect(query.isReachingStart).true;
+
+ //check that this button won't trigger more updates since
+ //has reach end and start
+ query.loadMore();
+ query.loadMorePrev();
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ });
+
+ it("should load more if result brings more that PAGE_SIZE", async () => {
+ const env = new ApiMockEnvironment();
+
+ const transfersFrom0to20 = Array.from({ length: 20 }).map((e, i) => ({
+ wtid: String(i),
+ }));
+ const transfersFrom20to40 = Array.from({ length: 20 }).map((e, i) => ({
+ wtid: String(i + 20),
+ }));
+ const transfersFrom20to0 = [...transfersFrom0to20].reverse();
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: 20, payto_uri: "payto://", offset: "1" },
+ response: {
+ transfers: transfersFrom0to20,
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20, payto_uri: "payto://", offset: "1" },
+ response: {
+ transfers: transfersFrom20to40,
+ },
+ });
+
+ const moveCursor = (d: string) => {
+ console.log("new position", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ return useInstanceTransfers(
+ { payto_uri: "payto://", position: "1" },
+ moveCursor,
+ );
+ },
+ {},
+ [
+ (result) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(result.loading).true;
+ },
+ (result) => {
+ expect(result.loading).undefined;
+ expect(result.ok).true;
+ if (!result.ok) return;
+ expect(result.data).deep.equals({
+ transfers: [...transfersFrom20to0, ...transfersFrom20to40],
+ });
+ expect(result.isReachingEnd).false;
+ expect(result.isReachingStart).false;
+
+ //query more
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -40, payto_uri: "payto://", offset: "1" },
+ response: {
+ transfers: [...transfersFrom20to40, { wtid: "41" }],
+ },
+ });
+ result.loadMore();
+ },
+ (result) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(result.loading).true;
+ },
+ (result) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ expect(result.loading).undefined;
+ expect(result.ok).true;
+ if (!result.ok) return;
+ expect(result.data).deep.equals({
+ transfers: [
+ ...transfersFrom20to0,
+ ...transfersFrom20to40,
+ { wtid: "41" },
+ ],
+ });
+ expect(result.isReachingEnd).true;
+ expect(result.isReachingStart).false;
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/hooks/transfer.ts b/packages/auditor-backoffice-ui/src/hooks/transfer.ts
new file mode 100644
index 000000000..27c3bdc75
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/transfer.ts
@@ -0,0 +1,188 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function useTransferAPI(): TransferAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const informTransfer = async (
+ data: MerchantBackend.Transfers.TransferInformation,
+ ): Promise<HttpResponseOk<{}>> => {
+ const res = await request<{}>(`/private/transfers`, {
+ method: "POST",
+ data,
+ });
+
+ await mutateAll(/.*private\/transfers.*/);
+ return res;
+ };
+
+ return { informTransfer };
+}
+
+export interface TransferAPI {
+ informTransfer: (
+ data: MerchantBackend.Transfers.TransferInformation,
+ ) => Promise<HttpResponseOk<{}>>;
+}
+
+export interface InstanceTransferFilter {
+ payto_uri?: string;
+ verified?: "yes" | "no";
+ position?: string;
+}
+
+export function useInstanceTransfers(
+ args?: InstanceTransferFilter,
+ updatePosition?: (id: string) => void,
+): HttpResponsePaginated<
+ MerchantBackend.Transfers.TransferList,
+ MerchantBackend.ErrorDetail
+> {
+ const { transferFetcher } = useBackendInstanceRequest();
+
+ const [pageBefore, setPageBefore] = useState(1);
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const totalBefore = args?.position !== undefined ? pageBefore * PAGE_SIZE : 0;
+
+ /**
+ * FIXME: this can be cleaned up a little
+ *
+ * the logic of double query should be inside the orderFetch so from the hook perspective and cache
+ * is just one query and one error status
+ */
+ const {
+ data: beforeData,
+ error: beforeError,
+ isValidating: loadingBefore,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Transfers.TransferList>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >(
+ [
+ `/private/transfers`,
+ args?.payto_uri,
+ args?.verified,
+ args?.position,
+ totalBefore,
+ ],
+ transferFetcher,
+ );
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Transfers.TransferList>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >(
+ [
+ `/private/transfers`,
+ args?.payto_uri,
+ args?.verified,
+ args?.position,
+ -totalAfter,
+ ],
+ transferFetcher,
+ );
+
+ //this will save last result
+ const [lastBefore, setLastBefore] = useState<
+ HttpResponse<
+ MerchantBackend.Transfers.TransferList,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.Transfers.TransferList,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ if (beforeData) setLastBefore(beforeData);
+ }, [afterData, beforeData]);
+
+ if (beforeError) return beforeError.cause;
+ if (afterError) return afterError.cause;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd =
+ afterData && afterData.data.transfers.length < totalAfter;
+ const isReachingStart =
+ args?.position === undefined ||
+ (beforeData && beforeData.data.transfers.length < totalBefore);
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.transfers.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from = `${
+ afterData.data.transfers[afterData.data.transfers.length - 1]
+ .transfer_serial_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ if (!beforeData || isReachingStart) return;
+ if (beforeData.data.transfers.length < MAX_RESULT_SIZE) {
+ setPageBefore(pageBefore + 1);
+ } else if (beforeData) {
+ const from = `${
+ beforeData.data.transfers[beforeData.data.transfers.length - 1]
+ .transfer_serial_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ };
+
+ const transfers =
+ !beforeData || !afterData
+ ? []
+ : (beforeData || lastBefore).data.transfers
+ .slice()
+ .reverse()
+ .concat((afterData || lastAfter).data.transfers);
+ if (loadingAfter || loadingBefore)
+ return { loading: true, data: { transfers } };
+ if (beforeData && afterData) {
+ return { ok: true, data: { transfers }, ...pagination };
+ }
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/hooks/urls.ts b/packages/auditor-backoffice-ui/src/hooks/urls.ts
new file mode 100644
index 000000000..b6485259f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/urls.ts
@@ -0,0 +1,303 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { Query } from "@gnu-taler/web-util/testing";
+import { MerchantBackend } from "../declaration.js";
+
+////////////////////
+// ORDER
+////////////////////
+
+export const API_CREATE_ORDER: Query<
+ MerchantBackend.Orders.PostOrderRequest,
+ MerchantBackend.Orders.PostOrderResponse
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/orders",
+};
+
+export const API_GET_ORDER_BY_ID = (
+ id: string,
+): Query<unknown, MerchantBackend.Orders.MerchantOrderStatusResponse> => ({
+ method: "GET",
+ url: `http://backend/instances/default/private/orders/${id}`,
+});
+
+export const API_LIST_ORDERS: Query<
+ unknown,
+ MerchantBackend.Orders.OrderHistory
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/orders",
+};
+
+export const API_REFUND_ORDER_BY_ID = (
+ id: string,
+): Query<
+ MerchantBackend.Orders.RefundRequest,
+ MerchantBackend.Orders.MerchantRefundResponse
+> => ({
+ method: "POST",
+ url: `http://backend/instances/default/private/orders/${id}/refund`,
+});
+
+export const API_FORGET_ORDER_BY_ID = (
+ id: string,
+): Query<MerchantBackend.Orders.ForgetRequest, unknown> => ({
+ method: "PATCH",
+ url: `http://backend/instances/default/private/orders/${id}/forget`,
+});
+
+export const API_DELETE_ORDER = (
+ id: string,
+): Query<MerchantBackend.Orders.ForgetRequest, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/instances/default/private/orders/${id}`,
+});
+
+////////////////////
+// TRANSFER
+////////////////////
+
+export const API_LIST_TRANSFERS: Query<
+ unknown,
+ MerchantBackend.Transfers.TransferList
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/transfers",
+};
+
+export const API_INFORM_TRANSFERS: Query<
+ MerchantBackend.Transfers.TransferInformation,
+ {}
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/transfers",
+};
+
+////////////////////
+// PRODUCT
+////////////////////
+
+export const API_CREATE_PRODUCT: Query<
+ MerchantBackend.Products.ProductAddDetail,
+ unknown
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/products",
+};
+
+export const API_LIST_PRODUCTS: Query<
+ unknown,
+ MerchantBackend.Products.InventorySummaryResponse
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/products",
+};
+
+export const API_GET_PRODUCT_BY_ID = (
+ id: string,
+): Query<unknown, MerchantBackend.Products.ProductDetail> => ({
+ method: "GET",
+ url: `http://backend/instances/default/private/products/${id}`,
+});
+
+export const API_UPDATE_PRODUCT_BY_ID = (
+ id: string,
+): Query<
+ MerchantBackend.Products.ProductPatchDetail,
+ MerchantBackend.Products.InventorySummaryResponse
+> => ({
+ method: "PATCH",
+ url: `http://backend/instances/default/private/products/${id}`,
+});
+
+export const API_DELETE_PRODUCT = (id: string): Query<unknown, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/instances/default/private/products/${id}`,
+});
+
+////////////////////
+// RESERVES
+////////////////////
+
+export const API_CREATE_RESERVE: Query<
+ MerchantBackend.Rewards.ReserveCreateRequest,
+ MerchantBackend.Rewards.ReserveCreateConfirmation
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/reserves",
+};
+export const API_LIST_RESERVES: Query<
+ unknown,
+ MerchantBackend.Rewards.RewardReserveStatus
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/reserves",
+};
+
+export const API_GET_RESERVE_BY_ID = (
+ pub: string,
+): Query<unknown, MerchantBackend.Rewards.ReserveDetail> => ({
+ method: "GET",
+ url: `http://backend/instances/default/private/reserves/${pub}`,
+});
+
+export const API_GET_REWARD_BY_ID = (
+ pub: string,
+): Query<unknown, MerchantBackend.Rewards.RewardDetails> => ({
+ method: "GET",
+ url: `http://backend/instances/default/private/rewards/${pub}`,
+});
+
+export const API_AUTHORIZE_REWARD_FOR_RESERVE = (
+ pub: string,
+): Query<
+ MerchantBackend.Rewards.RewardCreateRequest,
+ MerchantBackend.Rewards.RewardCreateConfirmation
+> => ({
+ method: "POST",
+ url: `http://backend/instances/default/private/reserves/${pub}/authorize-reward`,
+});
+
+export const API_AUTHORIZE_REWARD: Query<
+ MerchantBackend.Rewards.RewardCreateRequest,
+ MerchantBackend.Rewards.RewardCreateConfirmation
+> = {
+ method: "POST",
+ url: `http://backend/instances/default/private/rewards`,
+};
+
+export const API_DELETE_RESERVE = (id: string): Query<unknown, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/instances/default/private/reserves/${id}`,
+});
+
+////////////////////
+// INSTANCE ADMIN
+////////////////////
+
+export const API_CREATE_INSTANCE: Query<
+ MerchantBackend.Instances.InstanceConfigurationMessage,
+ unknown
+> = {
+ method: "POST",
+ url: "http://backend/management/instances",
+};
+
+export const API_GET_INSTANCE_BY_ID = (
+ id: string,
+): Query<unknown, MerchantBackend.Instances.QueryInstancesResponse> => ({
+ method: "GET",
+ url: `http://backend/management/instances/${id}`,
+});
+
+export const API_GET_INSTANCE_KYC_BY_ID = (
+ id: string,
+): Query<unknown, MerchantBackend.KYC.AccountKycRedirects> => ({
+ method: "GET",
+ url: `http://backend/management/instances/${id}/kyc`,
+});
+
+export const API_LIST_INSTANCES: Query<
+ unknown,
+ MerchantBackend.Instances.InstancesResponse
+> = {
+ method: "GET",
+ url: "http://backend/management/instances",
+};
+
+export const API_UPDATE_INSTANCE_BY_ID = (
+ id: string,
+): Query<
+ MerchantBackend.Instances.InstanceReconfigurationMessage,
+ unknown
+> => ({
+ method: "PATCH",
+ url: `http://backend/management/instances/${id}`,
+});
+
+export const API_UPDATE_INSTANCE_AUTH_BY_ID = (
+ id: string,
+): Query<
+ MerchantBackend.Instances.InstanceAuthConfigurationMessage,
+ unknown
+> => ({
+ method: "POST",
+ url: `http://backend/management/instances/${id}/auth`,
+});
+
+export const API_DELETE_INSTANCE = (id: string): Query<unknown, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/management/instances/${id}`,
+});
+
+////////////////////
+// AUTH
+////////////////////
+
+export const API_NEW_LOGIN: Query<
+ MerchantBackend.Instances.LoginTokenRequest,
+ unknown
+> = ({
+ method: "POST",
+ url: `http://backend/private/token`,
+});
+
+////////////////////
+// INSTANCE
+////////////////////
+
+export const API_GET_CURRENT_INSTANCE: Query<
+ unknown,
+ MerchantBackend.Instances.QueryInstancesResponse
+> = {
+ method: "GET",
+ url: `http://backend/instances/default/private/`,
+};
+
+export const API_GET_CURRENT_INSTANCE_KYC: Query<
+ unknown,
+ MerchantBackend.KYC.AccountKycRedirects
+> = {
+ method: "GET",
+ url: `http://backend/instances/default/private/kyc`,
+};
+
+export const API_UPDATE_CURRENT_INSTANCE: Query<
+ MerchantBackend.Instances.InstanceReconfigurationMessage,
+ unknown
+> = {
+ method: "PATCH",
+ url: `http://backend/instances/default/private/`,
+};
+
+export const API_UPDATE_CURRENT_INSTANCE_AUTH: Query<
+ MerchantBackend.Instances.InstanceAuthConfigurationMessage,
+ unknown
+> = {
+ method: "POST",
+ url: `http://backend/instances/default/private/auth`,
+};
+
+export const API_DELETE_CURRENT_INSTANCE: Query<unknown, unknown> = {
+ method: "DELETE",
+ url: `http://backend/instances/default/private`,
+};
diff --git a/packages/auditor-backoffice-ui/src/hooks/useSettings.ts b/packages/auditor-backoffice-ui/src/hooks/useSettings.ts
new file mode 100644
index 000000000..8c1ebd9f6
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/useSettings.ts
@@ -0,0 +1,73 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
+import {
+ Codec,
+ buildCodecForObject,
+ codecForBoolean,
+ codecForConstString,
+ codecForEither,
+ codecForString,
+} from "@gnu-taler/taler-util";
+
+export interface Settings {
+ advanceOrderMode: boolean;
+ dateFormat: "ymd" | "dmy" | "mdy";
+}
+
+const defaultSettings: Settings = {
+ advanceOrderMode: false,
+ dateFormat: "ymd",
+}
+
+export const codecForSettings = (): Codec<Settings> =>
+ buildCodecForObject<Settings>()
+ .property("advanceOrderMode", codecForBoolean())
+ .property("dateFormat", codecForEither(
+ codecForConstString("ymd"),
+ codecForConstString("dmy"),
+ codecForConstString("mdy"),
+ ))
+ .build("Settings");
+
+const SETTINGS_KEY = buildStorageKey("merchant-settings", codecForSettings());
+
+export function useSettings(): [
+ Readonly<Settings>,
+ (s: Settings) => void,
+] {
+ const { value, update } = useLocalStorage(SETTINGS_KEY, defaultSettings);
+
+ // const parsed: Settings = value ?? defaultSettings;
+ // function updateField<T extends keyof Settings>(k: T, v: Settings[T]) {
+ // const next = { ...parsed, [k]: v }
+ // update(next);
+ // }
+ return [value, update];
+}
+
+export function dateFormatForSettings(s: Settings): string {
+ switch (s.dateFormat) {
+ case "ymd": return "yyyy/MM/dd"
+ case "dmy": return "dd/MM/yyyy"
+ case "mdy": return "MM/dd/yyyy"
+ }
+}
+
+export function datetimeFormatForSettings(s: Settings): string {
+ return dateFormatForSettings(s) + " HH:mm:ss"
+} \ No newline at end of file
diff --git a/packages/auditor-backoffice-ui/src/hooks/webhooks.ts b/packages/auditor-backoffice-ui/src/hooks/webhooks.ts
new file mode 100644
index 000000000..ad6bf96e2
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/hooks/webhooks.ts
@@ -0,0 +1,178 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ RequestError,
+} from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../declaration.js";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js";
+import { useBackendInstanceRequest, useMatchMutate } from "./backend.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook } from "swr";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function useWebhookAPI(): WebhookAPI {
+ const mutateAll = useMatchMutate();
+ const { request } = useBackendInstanceRequest();
+
+ const createWebhook = async (
+ data: MerchantBackend.Webhooks.WebhookAddDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/webhooks`, {
+ method: "POST",
+ data,
+ });
+ await mutateAll(/.*private\/webhooks.*/);
+ return res;
+ };
+
+ const updateWebhook = async (
+ webhookId: string,
+ data: MerchantBackend.Webhooks.WebhookPatchDetails,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/webhooks/${webhookId}`, {
+ method: "PATCH",
+ data,
+ });
+ await mutateAll(/.*private\/webhooks.*/);
+ return res;
+ };
+
+ const deleteWebhook = async (
+ webhookId: string,
+ ): Promise<HttpResponseOk<void>> => {
+ const res = await request<void>(`/private/webhooks/${webhookId}`, {
+ method: "DELETE",
+ });
+ await mutateAll(/.*private\/webhooks.*/);
+ return res;
+ };
+
+ return { createWebhook, updateWebhook, deleteWebhook };
+}
+
+export interface WebhookAPI {
+ createWebhook: (
+ data: MerchantBackend.Webhooks.WebhookAddDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ updateWebhook: (
+ id: string,
+ data: MerchantBackend.Webhooks.WebhookPatchDetails,
+ ) => Promise<HttpResponseOk<void>>;
+ deleteWebhook: (id: string) => Promise<HttpResponseOk<void>>;
+}
+
+export interface InstanceWebhookFilter {
+ //FIXME: add filter to the webhook list
+ position?: string;
+}
+
+export function useInstanceWebhooks(
+ args?: InstanceWebhookFilter,
+ updatePosition?: (id: string) => void,
+): HttpResponsePaginated<
+ MerchantBackend.Webhooks.WebhookSummaryResponse,
+ MerchantBackend.ErrorDetail
+> {
+ const { webhookFetcher } = useBackendInstanceRequest();
+
+ const [pageBefore, setPageBefore] = useState(1);
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const totalBefore = args?.position ? pageBefore * PAGE_SIZE : 0;
+
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<
+ HttpResponseOk<MerchantBackend.Webhooks.WebhookSummaryResponse>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/webhooks`, args?.position, -totalAfter], webhookFetcher);
+
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<
+ MerchantBackend.Webhooks.WebhookSummaryResponse,
+ MerchantBackend.ErrorDetail
+ >
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ }, [afterData]);
+
+ if (afterError) return afterError.cause;
+
+ const isReachingEnd =
+ afterData && afterData.data.webhooks.length < totalAfter;
+ const isReachingStart = true;
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.webhooks.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from = `${
+ afterData.data.webhooks[afterData.data.webhooks.length - 1].webhook_id
+ }`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ return;
+ },
+ };
+
+ const webhooks = !afterData ? [] : (afterData || lastAfter).data.webhooks;
+
+ if (loadingAfter) return { loading: true, data: { webhooks } };
+ if (afterData) {
+ return { ok: true, data: { webhooks }, ...pagination };
+ }
+ return { loading: true };
+}
+
+export function useWebhookDetails(
+ webhookId: string,
+): HttpResponse<
+ MerchantBackend.Webhooks.WebhookDetails,
+ MerchantBackend.ErrorDetail
+> {
+ const { webhookFetcher } = useBackendInstanceRequest();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Webhooks.WebhookDetails>,
+ RequestError<MerchantBackend.ErrorDetail>
+ >([`/private/webhooks/${webhookId}`], webhookFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error.cause;
+ return { loading: true };
+}
diff --git a/packages/auditor-backoffice-ui/src/i18n/de.po b/packages/auditor-backoffice-ui/src/i18n/de.po
new file mode 100644
index 000000000..2cf0a7c1c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/i18n/de.po
@@ -0,0 +1,2742 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2023-12-04 13:44+0000\n"
+"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
+"Language-Team: German <https://weblate.taler.net/projects/gnu-taler/"
+"merchant-backoffice/de/>\n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr "Zurück"
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr "Rückerstattet"
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
diff --git a/packages/auditor-backoffice-ui/src/i18n/en.po b/packages/auditor-backoffice-ui/src/i18n/en.po
new file mode 100644
index 000000000..d8d0bae29
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/i18n/en.po
@@ -0,0 +1,2741 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
diff --git a/packages/auditor-backoffice-ui/src/i18n/es.po b/packages/auditor-backoffice-ui/src/i18n/es.po
new file mode 100644
index 000000000..10ec0cf3b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/i18n/es.po
@@ -0,0 +1,2854 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2023-08-13 10:14+0000\n"
+"Last-Translator: Javier Sepulveda <javier.sepulveda@uv.es>\n"
+"Language-Team: Spanish <https://weblate.taler.net/projects/gnu-taler/"
+"merchant-backoffice/es/>\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.13.1\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr "%1$s"
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr "Continuar"
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr "Limpiar"
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr "Confirmar"
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr "no es el mismo que el token de acceso actual"
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr "no puede ser vacío"
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr "no puede ser igual al viejo token"
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr "no son iguales"
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr "Está actualizando el token de acceso para la instancia con id %1$s"
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr "Viejo token de acceso"
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr "acceder al token en uso actualmente"
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr "Nuevo token de acceso"
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr "siguiente token de acceso a usar"
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr "Repetir token de acceso"
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr "confirmar el mismo token de acceso"
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr "Limpiar el token de acceso significa acceso público a la instancia"
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr "no puede ser igual al anterior token de acceso"
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr "Está estableciendo el token de acceso para la nueva instancia"
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+"Con el método de autorización externa no se hará ninguna revisión por el "
+"backend del comerciante"
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr "Establecer autorización externa"
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr "Establecer token de acceso"
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr "Operación en progreso..."
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr "La operación será automáticamente cancelada luego de %1$s segundos"
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr "Instancias"
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr "Eliminar"
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr "agregar nueva instancia"
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr "ID"
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr "Nombre"
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr "Editar"
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr "Purgar"
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr "Todavía no hay instancias, agregue más presionando el signo +"
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr "Solo mostrar instancias activas"
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr "Activo"
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr "Mostrar solo instancias eliminadas"
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr "Eliminado"
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr "Mostrar todas las instancias"
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr "Todo"
+
+#: src/paths/admin/list/index.tsx:101
+#, fuzzy, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr "La instancia '%1$s' (ID: %2$s) fue eliminada"
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr "Fallo al eliminar instancia"
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr "Instance '%1$s' (ID: %2$s) ha sido deshabilitada"
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr "Fallo al purgar la instancia"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr "Verificación KYC pendiente"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr "Expirado"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr "Exchange"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr "Cuenta objetivo"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr "URL de KYC"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr "Código"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr "Estado http"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr "¡No hay verificación kyc pendiente!"
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr "cambiar valor a fecha desconocida"
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr "cambiar valor a vacío"
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr "limpiar"
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr "cambiar valor a nunca"
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr "nunca"
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr "País"
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr "Dirección"
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr "Número de edificio"
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr "Nombre de edificio"
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr "Calle"
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr "Código postal"
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr "Ubicación de ciudad"
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr "Ciudad"
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr "Distrito"
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr "Subdivisión de país"
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr "Id de producto"
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr "Descripcion"
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, fuzzy, c-format
+msgid "Product"
+msgstr "Productos"
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr "buscar productos por su descripción o ID"
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr "no se encontraron productos con esa descripción"
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr "Debe ingresar un identificador de producto válido."
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr "¡Cantidad debe ser mayor que 0!"
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, fuzzy, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+"Esta cantidad excede las existencias restantes. Actualmente, solo quedan "
+"%1$s unidades sin reservar en las existencias."
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr "Cantidad"
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr "cuántos productos serán agregados"
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr "Agregar del inventario"
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr "La imagen debe ser mas chica que 1 MB"
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr "Agregar"
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr "Eliminar"
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr "Ningun impuesto configurado para este producto."
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr "Monto"
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+"Impuestos pueden estar en divisas que difieren de la principal divisa usada "
+"por el comerciante."
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+"Ingrese divisa y valor separado por dos puntos, e.g. &quot;USD:2.3&quot;."
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr "Nombre legal del impuesto, e.g. IVA o arancel."
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr "agregar impuesto a la lista de impuestos"
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr "describa y agregue un producto que no está en la lista de inventarios"
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr "Agregue un producto personalizado"
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr "Complete información del producto"
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr "Imagen"
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr "foto del producto"
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr "descripción completa del producto"
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr "Unidad"
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr "nombre de la unidad del producto"
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr "Precio"
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr "monto de la divisa actual"
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr "Impuestos"
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr "imagen"
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr "descripción"
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr "cantidad"
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr "precio unitario"
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr "precio total"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr "requerido"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, fuzzy, c-format
+msgid "not valid"
+msgstr "no es un json válido"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr "debe ser mayor que 0"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr "no es un json válido"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr "deberían ser en el futuro"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr "plazo de reembolso no puede ser antes que el plazo de pago"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+"el plazo de la transferencia bancaria no puede ser antes que el plazo de "
+"reembolso"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+"el plazo de la transferencia bancaria no puede ser antes que el plazo de pago"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr "debería tener un plazo de reembolso"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr "reembolso automático no puede ser después qu el plazo de reembolso"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr "Manejar productos en orden"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr "Manejar lista de productos en la orden."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr "Remover este producto de la orden."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr "Precio total"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr "precio total de producto agregado"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr "Monto a ser pagado por el cliente"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr "Precio de la orden"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr "Precio final de la orden"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr "Resumen"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr "Título de la orden a ser mostrado al cliente"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr "Envío y cumplimiento"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr "Fecha de entrega"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr "Plazo para la entrega física asegurado por el comerciante."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr "Ubicación"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr "dirección a donde los productos serán entregados"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr "URL de cumplimiento"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr "URL al cual el usuario será redirigido luego de pago exitoso."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr "Opciones de pago de Taler"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr "Sobreescribir pagos por omisión de Taler para esta orden"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, fuzzy, c-format
+msgid "Payment deadline"
+msgstr "Plazo de pago"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+"Plazo límite para que el cliente pague por la oferta antes de que expire. "
+"Productos del inventario serán reservados hasta este plazo límite."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr "Plazo de reembolso"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+"Tiempo hasta el cual la orden puede ser reembolsada por el comerciante."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr "Plazo de la transferencia"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr "Plazo para que el exchange haga la transferencia."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, fuzzy, c-format
+msgid "Auto-refund deadline"
+msgstr "Plazo de reembolso automático"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+"Tiempo hasta el cual la billetera será automáticamente revisada por "
+"reembolsos win interación por parte del usuario."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr "Máxima tarifa de depósito"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+"Máxima tarifa de depósito que el comerciante esta dispuesto a cubir para "
+"esta orden. Mayores tarifas de depósito deben ser cubiertas completamente "
+"por el consumidor."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr "Máxima tarifa de transferencia"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr "Amortización de comisión de transferencia"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, fuzzy, c-format
+msgid "Create token"
+msgstr "Administrar token"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, fuzzy, c-format
+msgid "Minimum age required"
+msgstr "Login necesario"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, fuzzy, c-format
+msgid "Additional information"
+msgstr "Información extra"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr "días"
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr "horas"
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr "minutos"
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr "segundos"
+
+#: src/components/form/InputDuration.tsx:53
+#, fuzzy, c-format
+msgid "forever"
+msgstr "nunca"
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr "Órdenes"
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, fuzzy, c-format
+msgid "create order"
+msgstr "creado"
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr "cargar nuevas ordenes"
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr "Fecha"
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr "Devolución"
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr "copiar url"
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr "cargar viejas ordenes"
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr "¡No se encontraron órdenes que emparejen su búsqueda!"
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr "duplicado"
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr "formato inválido"
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr "este monto excede el monto reembolsable"
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr "fecha"
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr "monto"
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr "razón"
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr "monto a ser reembolsado"
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr "Máximo reembolzable:"
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr "Razón"
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr "Elija uno..."
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr "pedido por el consumidor"
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr "otro"
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr "por qué esta orden está siendo reembolsada"
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr "más información para dar contexto"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr "Términos de contrato"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr "descripción legible de toda la compra"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr "precio total de la transacción"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr "URL para esta compra"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr "Máxima comisión"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr "Impuesto de transferencia máximo"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr "Creado en"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, fuzzy, c-format
+msgid "Auto-refund delay"
+msgstr "Plazo de reembolso automático"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, fuzzy, c-format
+msgid "Extra info"
+msgstr "Información extra"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr "Orden"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr "reclamado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, fuzzy, c-format
+msgid "claimed at"
+msgstr "reclamado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr "Cronología"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr "Detalles de pago"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr "Estado de orden"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr "Lista de producto"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr "pagados"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr "transferido"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr "reembolzado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, fuzzy, c-format
+msgid "refund order"
+msgstr "reembolzado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, fuzzy, c-format
+msgid "not refundable"
+msgstr "Máximo reembolzable:"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr "reembolzar"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr "Monto reembolzado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, fuzzy, c-format
+msgid "Refund taken"
+msgstr "Reembolzado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, fuzzy, c-format
+msgid "Status URL"
+msgstr "URL de estado de orden"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, fuzzy, c-format
+msgid "Refund URI"
+msgstr "Devolución"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr "impago"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr "pagar en"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr "creado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr "URL de estado de orden"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, fuzzy, c-format
+msgid "Payment URI"
+msgstr "URI de pago"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+"Estado de orden desconocido. Esto es un error, por favor contacte a su "
+"administrador."
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr "reembolzo creado satisfactoriamente"
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, fuzzy, c-format
+msgid "order id"
+msgstr "ir a id de orden"
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr "Pagado"
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, fuzzy, c-format
+msgid "only show orders with refunds"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr "Reembolsado"
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr "No transferido"
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, fuzzy, c-format
+msgid "Enter an order id"
+msgstr "ir a id de orden"
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, fuzzy, c-format
+msgid "order not found"
+msgstr "Servidor no encontrado"
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, fuzzy, c-format
+msgid "could not get the order to refund"
+msgstr "No se pudo create el reembolso"
+
+#: src/components/exception/AsyncButton.tsx:43
+#, fuzzy, c-format
+msgid "Loading..."
+msgstr "Cargando..."
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr "Administrar stock"
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr "Inifinito"
+
+#: src/components/form/InputStock.tsx:136
+#, fuzzy, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr "la pérdida no puede ser mayor al stock actual + entrante (max %1$s )"
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr "Ingresando"
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr "Perdido"
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr "Actual"
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr "sin stock"
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr "Próximo reabastecimiento"
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr "Dirección de entrega"
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr "Existencias"
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr "no se pudo crear el producto"
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr "Productos"
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr "Venta"
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr "Ganancia"
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr "Vendido"
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr "Gratis"
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, fuzzy, c-format
+msgid "go to product update page"
+msgstr "producto actualizado correctamente"
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr "Actualizar"
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, fuzzy, c-format
+msgid "new price for the product"
+msgstr "no se pudo actualizar el producto"
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, fuzzy, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr "No hay propinas todavía, agregar mas presionando el signo +"
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr "producto actualizado correctamente"
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr "no se pudo actualizar el producto"
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr "producto fue eliminado correctamente"
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr "no se pudo eliminar el producto"
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, fuzzy, c-format
+msgid "Product id:"
+msgstr "Id de producto"
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, fuzzy, c-format
+msgid "it should be greater than 0"
+msgstr "Debe ser mayor a 0"
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, fuzzy, c-format
+msgid "Initial balance"
+msgstr "Instancia"
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr "URL del Exchange"
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr "Siguiente"
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, fuzzy, c-format
+msgid "method to use for wire transfer"
+msgstr "no se pudo informar la transferencia"
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, fuzzy, c-format
+msgid "could not create reserve"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr "Válido hasta"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, fuzzy, c-format
+msgid "Created balance"
+msgstr "creado"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, fuzzy, c-format
+msgid "Exchange balance"
+msgstr "Monto inicial"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, fuzzy, c-format
+msgid "Committed"
+msgstr "Monto confirmado"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr "Dirección de cuenta"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr "Asunto"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr "Propinas"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, fuzzy, c-format
+msgid "Authorized"
+msgstr "Token de autorización"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, fuzzy, c-format
+msgid "Expiration"
+msgstr "Información extra"
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, fuzzy, c-format
+msgid "amount of tip"
+msgstr "monto"
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, fuzzy, c-format
+msgid "Justification"
+msgstr "Jurisdicción"
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, fuzzy, c-format
+msgid "Reserves not yet funded"
+msgstr "Servidor no encontrado"
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, fuzzy, c-format
+msgid "add new reserve"
+msgstr "cargar nuevas transferencias"
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, fuzzy, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr "No hay transferencias todavía, agregar mas presionando el signo +"
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, fuzzy, c-format
+msgid "Expected Balance"
+msgstr "Ejecutado en"
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, fuzzy, c-format
+msgid "could not create the tip"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, fuzzy, c-format
+msgid "should not be empty"
+msgstr "no puede ser vacío"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, fuzzy, c-format
+msgid "should be greater that 0"
+msgstr "Debe ser mayor a 0"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, fuzzy, c-format
+msgid "can't be empty"
+msgstr "no puede ser vacío"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, fuzzy, c-format
+msgid "Fixed summary"
+msgstr "Estado de orden"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, fuzzy, c-format
+msgid "Fixed price"
+msgstr "precio unitario"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr "Edad mínima"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, fuzzy, c-format
+msgid "Payment timeout"
+msgstr "Opciones de pago"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, fuzzy, c-format
+msgid "could not inform template"
+msgstr "no se pudo informar la transferencia"
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, fuzzy, c-format
+msgid "Amount is required"
+msgstr "Login necesario"
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, fuzzy, c-format
+msgid "New order for template"
+msgstr "cargar viejas transferencias"
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, fuzzy, c-format
+msgid "Order summary"
+msgstr "Estado de orden"
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, fuzzy, c-format
+msgid "could not create order from template"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, fuzzy, c-format
+msgid "Fixed amount"
+msgstr "Monto reembolzado"
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, fuzzy, c-format
+msgid "Default amount"
+msgstr "Monto reembolzado"
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, fuzzy, c-format
+msgid "Default summary"
+msgstr "Estado de orden"
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, fuzzy, c-format
+msgid "load newer templates"
+msgstr "cargar nuevas transferencias"
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, fuzzy, c-format
+msgid "create qr code for the template"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, fuzzy, c-format
+msgid "load older templates"
+msgstr "cargar viejas transferencias"
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, fuzzy, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr "No hay propinas todavía, agregar mas presionando el signo +"
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, fuzzy, c-format
+msgid "template delete successfully"
+msgstr "producto fue eliminado correctamente"
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, fuzzy, c-format
+msgid "could not delete the template"
+msgstr "no se pudo eliminar el producto"
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, fuzzy, c-format
+msgid "could not update template"
+msgstr "no se pudo actualizar el producto"
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, fuzzy, c-format
+msgid "should be one of '%1$s'"
+msgstr "deberían ser iguales"
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr "URL"
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, fuzzy, c-format
+msgid "load newer webhooks"
+msgstr "cargar nuevas ordenes"
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, fuzzy, c-format
+msgid "load older webhooks"
+msgstr "cargar viejas ordenes"
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, fuzzy, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr "No hay propinas todavía, agregar mas presionando el signo +"
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, fuzzy, c-format
+msgid "webhook delete successfully"
+msgstr "producto fue eliminado correctamente"
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, fuzzy, c-format
+msgid "could not delete the webhook"
+msgstr "no se pudo eliminar el producto"
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, fuzzy, c-format
+msgid "check the id, does not look valid"
+msgstr "verificar el id, no parece válido"
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr "debería tener 52 caracteres, actualmente %1$s"
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr "La URL no tiene el formato correcto"
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, fuzzy, c-format
+msgid "Wire transfer ID"
+msgstr "Id de transferencia"
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr "no se pudo informar la transferencia"
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr "Transferencias"
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, fuzzy, c-format
+msgid "add new transfer"
+msgstr "cargar nuevas transferencias"
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr "cargar nuevas transferencias"
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr "Crédito"
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr "Confirmado"
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr "Verificado"
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr "Ejecutado en"
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr "si"
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr "no"
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr "desconocido"
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr "eliminar transferencia seleccionada de la base de datos"
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr "cargue más transferencia luego de la última"
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr "cargar viejas transferencias"
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr "No hay transferencias todavía, agregar mas presionando el signo +"
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, fuzzy, c-format
+msgid "filter by account address"
+msgstr "Dirección de cuenta"
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, fuzzy, c-format
+msgid "Unverified"
+msgstr "Verificado"
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, fuzzy, c-format
+msgid "is not a number"
+msgstr "Número de edificio"
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr "debe ser 1 o mayor"
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr "máximo 7 líneas"
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr "cambiar configuración de autorización"
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr "Necesita completar campos marcados y escoger un método de autorización"
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr "Esta no es una dirección de bitcoin válida."
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr "Esta no es una dirección de Ethereum válida."
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr "Números IBAN usualmente tienen más de 4 dígitos"
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr "Número IBAN usualmente tienen menos de 34 dígitos"
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr "Código IBAN de país no encontrado"
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr "Número IBAN no es válido, la suma de verificación es incorrecta"
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr "Tipo objetivo"
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr "Método a usar para la transferencia"
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr "Enrutamiento"
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr "Número de enrutamiento."
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr "Cuenta"
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, fuzzy, c-format
+msgid "Account number."
+msgstr "Dirección de cuenta"
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr "Interfaz de pago unificado."
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, fuzzy, c-format
+msgid "Business name"
+msgstr "Nombre de edificio"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr "URL de sitio web"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr "Cuenta bancaria"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr "Impuesto máximo de deposito por omisión"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr "Impuesto máximo de transferencia por omisión"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr "Amortización de impuesto de transferencia por omisión"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr "Jurisdicción"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr "Jurisdicción para disputas legales con el comerciante."
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, fuzzy, c-format
+msgid "Default payment delay"
+msgstr "Retrazo de pago por omisión"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr "Retrazo de transferencia por omisión"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr "ID de instancia"
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, fuzzy, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+"Limpiar el token de autorización significa acceso público a la instancia"
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr "Administrar token de acceso"
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr "Fallo al crear la instancia"
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr "Login necesario"
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, fuzzy, c-format
+msgid "Access Token"
+msgstr "Acceso denegado"
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, fuzzy, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr "Servidir reporto un problema: HTTP status #%1$s"
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr "Acceso denegado"
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, fuzzy, c-format
+msgid "No 'default' instance configured yet."
+msgstr "Sin instancia default"
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr "Instancia"
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr "Configuración"
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr "Conexión"
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr "Nuevo"
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr "Lista"
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr "Salir"
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr "Verifica que el token sea valido"
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr "No se pudo acceder al servidor."
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr "No se pudo inferir el id de la instancia con la url %1$s"
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr "Servidor no encontrado"
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr "Recibimos el mensaje %1$s desde %2$s"
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr "Error inesperado"
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr "El valor %1$s es invalido para una URL de pago"
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr "agregar elemento a la lista"
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr "Agregar"
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr "Borrando"
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr "Cambiando"
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr "ID de pedido"
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr "URL de pago"
+
+#, c-format
+#~ msgid "Couldn't access the server"
+#~ msgstr "No se pudo aceder al servidor"
+
+#, c-format
+#~ msgid "HTTP status #%1$s: Server reported a problem"
+#~ msgstr "HTTP status #%1$s: Servidor reporto un problema"
+
+#, c-format
+#~ msgid "Got message: \"%1$s\" from: %2$s"
+#~ msgstr "Recibimos el mensaje: %1$s desde %2$s"
+
+#, c-format
+#~ msgid ""
+#~ "in order to use merchant backoffice, you should create the default "
+#~ "instance"
+#~ msgstr ""
+#~ "para usar el merchant backoffice, debería crear la instancia default"
+
+#, c-format
+#~ msgid "Got message: %1$s from: %2$s"
+#~ msgstr "Recibimos el mensaje %1$s desde %2$s"
+
+#, c-format
+#~ msgid ""
+#~ "Please enter your auth token. Token should have \"secret-token:\" and "
+#~ "start with Bearer or ApiKey"
+#~ msgstr ""
+#~ "Por favor ingrese su token de autorización. El token debe tener \"secret-"
+#~ "token\" y comenzar con Bearer o ApiKey"
+
+#, c-format
+#~ msgid "pick a date"
+#~ msgstr "elegir una fecha"
+
+#, c-format
+#~ msgid "no results"
+#~ msgstr "Sin resultados"
+
+#, c-format
+#~ msgid "current stock will change from %1$s to %2$s"
+#~ msgstr "stock actual cambiará desde %1$s a %2$s"
+
+#, c-format
+#~ msgid "current stock will stay at %1$s"
+#~ msgstr "stock actual seguirá en %1$s"
+
+#, c-format
+#~ msgid "this product has no taxes"
+#~ msgstr "este producto no tiene impuestos"
+
+#, c-format
+#~ msgid "Inventory products"
+#~ msgstr "Productos de inventario"
+
+#, c-format
+#~ msgid "Total tax"
+#~ msgstr "Impuesto total"
+
+#, c-format
+#~ msgid "Net"
+#~ msgstr "Neto"
+
+#, c-format
+#~ msgid "select a product first"
+#~ msgstr "seleccione un producto primero"
+
+#, c-format
+#~ msgid ""
+#~ "cannot be greater than current stock and quantity previously added. max: "
+#~ "%1$s"
+#~ msgstr ""
+#~ "no puede ser mayor al stock actual y la cantidad previamente agregada. "
+#~ "máximo: %1$s"
+
+#, c-format
+#~ msgid "cannot be greater than current stock %1$s"
+#~ msgstr "no puede ser mayor al stock actual %1$s"
+
+#, c-format
+#~ msgid "Deposit total"
+#~ msgstr "Total depositado"
+
+#, c-format
+#~ msgid "Merchant initial amount"
+#~ msgstr "Monto inicial"
+
+#, c-format
+#~ msgid "Account Address"
+#~ msgstr "Dirección de cuenta"
diff --git a/packages/auditor-backoffice-ui/src/i18n/fr.po b/packages/auditor-backoffice-ui/src/i18n/fr.po
new file mode 100644
index 000000000..d8d0bae29
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/i18n/fr.po
@@ -0,0 +1,2741 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
diff --git a/packages/auditor-backoffice-ui/src/i18n/it.po b/packages/auditor-backoffice-ui/src/i18n/it.po
new file mode 100644
index 000000000..4055af10e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/i18n/it.po
@@ -0,0 +1,2742 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2023-08-16 12:43+0000\n"
+"Last-Translator: Krystian Baran <kiszkot@murena.io>\n"
+"Language-Team: Italian <https://weblate.taler.net/projects/gnu-taler/"
+"merchant-backoffice/it/>\n"
+"Language: it\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.13.1\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr "Importo"
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr "Data"
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr "Indietro"
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr "Rimborsato"
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr "Soggetto"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr "Impostazioni"
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
diff --git a/packages/auditor-backoffice-ui/src/i18n/poheader b/packages/auditor-backoffice-ui/src/i18n/poheader
new file mode 100644
index 000000000..7ddcf49b8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/i18n/poheader
@@ -0,0 +1,27 @@
+# This file is part of GNU Taler
+# (C) 2021-2023 Taler Systems S.A.
+
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
diff --git a/packages/auditor-backoffice-ui/src/i18n/strings-prelude b/packages/auditor-backoffice-ui/src/i18n/strings-prelude
new file mode 100644
index 000000000..6c68662de
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/i18n/strings-prelude
@@ -0,0 +1,19 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/*eslint quote-props: ["error", "consistent"]*/
+export const strings: {[s: string]: any} = {};
+
diff --git a/packages/auditor-backoffice-ui/src/i18n/strings.ts b/packages/auditor-backoffice-ui/src/i18n/strings.ts
new file mode 100644
index 000000000..65dc41358
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/i18n/strings.ts
@@ -0,0 +1,9655 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/*eslint quote-props: ["error", "consistent"]*/
+export const strings: {[s: string]: any} = {};
+
+strings['de'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=(n != 1);",
+ "lang": ""
+ },
+ "Cancel": [
+ ""
+ ],
+ "%1$s": [
+ ""
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ ""
+ ],
+ "Clear": [
+ ""
+ ],
+ "Confirm": [
+ ""
+ ],
+ "is not the same as the current access token": [
+ ""
+ ],
+ "cannot be empty": [
+ ""
+ ],
+ "cannot be the same as the old token": [
+ ""
+ ],
+ "is not the same": [
+ ""
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ ""
+ ],
+ "Old access token": [
+ ""
+ ],
+ "access token currently in use": [
+ ""
+ ],
+ "New access token": [
+ ""
+ ],
+ "next access token to be used": [
+ ""
+ ],
+ "Repeat access token": [
+ ""
+ ],
+ "confirm the same access token": [
+ ""
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ ""
+ ],
+ "cannot be the same as the old access token": [
+ ""
+ ],
+ "You are setting the access token for the new instance": [
+ ""
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ ""
+ ],
+ "Set external authorization": [
+ ""
+ ],
+ "Set access token": [
+ ""
+ ],
+ "Operation in progress...": [
+ ""
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ ""
+ ],
+ "Instances": [
+ ""
+ ],
+ "Delete": [
+ ""
+ ],
+ "add new instance": [
+ ""
+ ],
+ "ID": [
+ ""
+ ],
+ "Name": [
+ ""
+ ],
+ "Edit": [
+ ""
+ ],
+ "Purge": [
+ ""
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ ""
+ ],
+ "Only show active instances": [
+ ""
+ ],
+ "Active": [
+ ""
+ ],
+ "Only show deleted instances": [
+ ""
+ ],
+ "Deleted": [
+ ""
+ ],
+ "Show all instances": [
+ ""
+ ],
+ "All": [
+ ""
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ ""
+ ],
+ "Failed to delete instance": [
+ ""
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ ""
+ ],
+ "Failed to purge instance": [
+ ""
+ ],
+ "Pending KYC verification": [
+ ""
+ ],
+ "Timed out": [
+ ""
+ ],
+ "Exchange": [
+ ""
+ ],
+ "Target account": [
+ ""
+ ],
+ "KYC URL": [
+ ""
+ ],
+ "Code": [
+ ""
+ ],
+ "Http Status": [
+ ""
+ ],
+ "No pending kyc verification!": [
+ ""
+ ],
+ "change value to unknown date": [
+ ""
+ ],
+ "change value to empty": [
+ ""
+ ],
+ "clear": [
+ ""
+ ],
+ "change value to never": [
+ ""
+ ],
+ "never": [
+ ""
+ ],
+ "Country": [
+ ""
+ ],
+ "Address": [
+ ""
+ ],
+ "Building number": [
+ ""
+ ],
+ "Building name": [
+ ""
+ ],
+ "Street": [
+ ""
+ ],
+ "Post code": [
+ ""
+ ],
+ "Town location": [
+ ""
+ ],
+ "Town": [
+ ""
+ ],
+ "District": [
+ ""
+ ],
+ "Country subdivision": [
+ ""
+ ],
+ "Product id": [
+ ""
+ ],
+ "Description": [
+ ""
+ ],
+ "Product": [
+ ""
+ ],
+ "search products by it's description or id": [
+ ""
+ ],
+ "no products found with that description": [
+ ""
+ ],
+ "You must enter a valid product identifier.": [
+ ""
+ ],
+ "Quantity must be greater than 0!": [
+ ""
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ ""
+ ],
+ "Quantity": [
+ ""
+ ],
+ "how many products will be added": [
+ ""
+ ],
+ "Add from inventory": [
+ ""
+ ],
+ "Image should be smaller than 1 MB": [
+ ""
+ ],
+ "Add": [
+ ""
+ ],
+ "Remove": [
+ ""
+ ],
+ "No taxes configured for this product.": [
+ ""
+ ],
+ "Amount": [
+ ""
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ ""
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ ""
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ ""
+ ],
+ "add tax to the tax list": [
+ ""
+ ],
+ "describe and add a product that is not in the inventory list": [
+ ""
+ ],
+ "Add custom product": [
+ ""
+ ],
+ "Complete information of the product": [
+ ""
+ ],
+ "Image": [
+ ""
+ ],
+ "photo of the product": [
+ ""
+ ],
+ "full product description": [
+ ""
+ ],
+ "Unit": [
+ ""
+ ],
+ "name of the product unit": [
+ ""
+ ],
+ "Price": [
+ ""
+ ],
+ "amount in the current currency": [
+ ""
+ ],
+ "Taxes": [
+ ""
+ ],
+ "image": [
+ ""
+ ],
+ "description": [
+ ""
+ ],
+ "quantity": [
+ ""
+ ],
+ "unit price": [
+ ""
+ ],
+ "total price": [
+ ""
+ ],
+ "required": [
+ ""
+ ],
+ "not valid": [
+ ""
+ ],
+ "must be greater than 0": [
+ ""
+ ],
+ "not a valid json": [
+ ""
+ ],
+ "should be in the future": [
+ ""
+ ],
+ "refund deadline cannot be before pay deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ ""
+ ],
+ "should have a refund deadline": [
+ ""
+ ],
+ "auto refund cannot be after refund deadline": [
+ ""
+ ],
+ "Manage products in order": [
+ ""
+ ],
+ "Manage list of products in the order.": [
+ ""
+ ],
+ "Remove this product from the order.": [
+ ""
+ ],
+ "Total price": [
+ ""
+ ],
+ "total product price added up": [
+ ""
+ ],
+ "Amount to be paid by the customer": [
+ ""
+ ],
+ "Order price": [
+ ""
+ ],
+ "final order price": [
+ ""
+ ],
+ "Summary": [
+ ""
+ ],
+ "Title of the order to be shown to the customer": [
+ ""
+ ],
+ "Shipping and Fulfillment": [
+ ""
+ ],
+ "Delivery date": [
+ ""
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ ""
+ ],
+ "Location": [
+ ""
+ ],
+ "address where the products will be delivered": [
+ ""
+ ],
+ "Fulfillment URL": [
+ ""
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ ""
+ ],
+ "Taler payment options": [
+ ""
+ ],
+ "Override default Taler payment settings for this order": [
+ ""
+ ],
+ "Payment deadline": [
+ ""
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ ""
+ ],
+ "Refund deadline": [
+ ""
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ ""
+ ],
+ "Wire transfer deadline": [
+ ""
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ ""
+ ],
+ "Auto-refund deadline": [
+ ""
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ ""
+ ],
+ "Maximum deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ ""
+ ],
+ "Maximum wire fee": [
+ ""
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ ""
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ ""
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ ""
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ ""
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ ""
+ ],
+ "hours": [
+ ""
+ ],
+ "minutes": [
+ ""
+ ],
+ "seconds": [
+ ""
+ ],
+ "forever": [
+ ""
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ ""
+ ],
+ "create order": [
+ ""
+ ],
+ "load newer orders": [
+ ""
+ ],
+ "Date": [
+ ""
+ ],
+ "Refund": [
+ ""
+ ],
+ "copy url": [
+ ""
+ ],
+ "load older orders": [
+ ""
+ ],
+ "No orders have been found matching your query!": [
+ ""
+ ],
+ "duplicated": [
+ ""
+ ],
+ "invalid format": [
+ ""
+ ],
+ "this value exceed the refundable amount": [
+ ""
+ ],
+ "date": [
+ ""
+ ],
+ "amount": [
+ ""
+ ],
+ "reason": [
+ ""
+ ],
+ "amount to be refunded": [
+ ""
+ ],
+ "Max refundable:": [
+ ""
+ ],
+ "Reason": [
+ ""
+ ],
+ "Choose one...": [
+ ""
+ ],
+ "requested by the customer": [
+ ""
+ ],
+ "other": [
+ ""
+ ],
+ "why this order is being refunded": [
+ ""
+ ],
+ "more information to give context": [
+ ""
+ ],
+ "Contract Terms": [
+ ""
+ ],
+ "human-readable description of the whole purchase": [
+ ""
+ ],
+ "total price for the transaction": [
+ ""
+ ],
+ "URL for this purchase": [
+ ""
+ ],
+ "Max fee": [
+ ""
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ ""
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ ""
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ ""
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ ""
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ ""
+ ],
+ "claimed": [
+ ""
+ ],
+ "claimed at": [
+ ""
+ ],
+ "Timeline": [
+ ""
+ ],
+ "Payment details": [
+ ""
+ ],
+ "Order status": [
+ ""
+ ],
+ "Product list": [
+ ""
+ ],
+ "paid": [
+ ""
+ ],
+ "wired": [
+ ""
+ ],
+ "refunded": [
+ ""
+ ],
+ "refund order": [
+ ""
+ ],
+ "not refundable": [
+ ""
+ ],
+ "refund": [
+ ""
+ ],
+ "Refunded amount": [
+ ""
+ ],
+ "Refund taken": [
+ ""
+ ],
+ "Status URL": [
+ ""
+ ],
+ "Refund URI": [
+ ""
+ ],
+ "unpaid": [
+ ""
+ ],
+ "pay at": [
+ ""
+ ],
+ "created at": [
+ ""
+ ],
+ "Order status URL": [
+ ""
+ ],
+ "Payment URI": [
+ ""
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ ""
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ ""
+ ],
+ "could not create the refund": [
+ ""
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ ""
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ ""
+ ],
+ "only show orders with refunds": [
+ ""
+ ],
+ "Refunded": [
+ ""
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ ""
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ ""
+ ],
+ "order not found": [
+ ""
+ ],
+ "could not get the order to refund": [
+ ""
+ ],
+ "Loading...": [
+ ""
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ ""
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ ""
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ ""
+ ],
+ "Incoming": [
+ ""
+ ],
+ "Lost": [
+ ""
+ ],
+ "Current": [
+ ""
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ ""
+ ],
+ "Next restock": [
+ ""
+ ],
+ "Delivery address": [
+ ""
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ ""
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ ""
+ ],
+ "Products": [
+ ""
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ ""
+ ],
+ "Profit": [
+ ""
+ ],
+ "Sold": [
+ ""
+ ],
+ "free": [
+ ""
+ ],
+ "go to product update page": [
+ ""
+ ],
+ "Update": [
+ ""
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ ""
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ ""
+ ],
+ "product updated successfully": [
+ ""
+ ],
+ "could not update the product": [
+ ""
+ ],
+ "product delete successfully": [
+ ""
+ ],
+ "could not delete the product": [
+ ""
+ ],
+ "Product id:": [
+ ""
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ ""
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ ""
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ ""
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ ""
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ ""
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ ""
+ ],
+ "Valid until": [
+ ""
+ ],
+ "Created balance": [
+ ""
+ ],
+ "Exchange balance": [
+ ""
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ ""
+ ],
+ "Account address": [
+ ""
+ ],
+ "Subject": [
+ ""
+ ],
+ "Tips": [
+ ""
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ ""
+ ],
+ "Expiration": [
+ ""
+ ],
+ "amount of tip": [
+ ""
+ ],
+ "Justification": [
+ ""
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ ""
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ ""
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ ""
+ ],
+ "Expected Balance": [
+ ""
+ ],
+ "could not create the tip": [
+ ""
+ ],
+ "should not be empty": [
+ ""
+ ],
+ "should be greater that 0": [
+ ""
+ ],
+ "can't be empty": [
+ ""
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ ""
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ ""
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ ""
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ ""
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ ""
+ ],
+ "Amount is required": [
+ ""
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ ""
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ ""
+ ],
+ "could not create order from template": [
+ ""
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ ""
+ ],
+ "Default amount": [
+ ""
+ ],
+ "Default summary": [
+ ""
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ ""
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ ""
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ ""
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ ""
+ ],
+ "template delete successfully": [
+ ""
+ ],
+ "could not delete the template": [
+ ""
+ ],
+ "could not update template": [
+ ""
+ ],
+ "should be one of '%1$s'": [
+ ""
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ ""
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ ""
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ ""
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ ""
+ ],
+ "webhook delete successfully": [
+ ""
+ ],
+ "could not delete the webhook": [
+ ""
+ ],
+ "check the id, does not look valid": [
+ ""
+ ],
+ "should have 52 characters, current %1$s": [
+ ""
+ ],
+ "URL doesn't have the right format": [
+ ""
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ ""
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ ""
+ ],
+ "Transfers": [
+ ""
+ ],
+ "add new transfer": [
+ ""
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ ""
+ ],
+ "Credit": [
+ ""
+ ],
+ "Confirmed": [
+ ""
+ ],
+ "Verified": [
+ ""
+ ],
+ "Executed at": [
+ ""
+ ],
+ "yes": [
+ ""
+ ],
+ "no": [
+ ""
+ ],
+ "unknown": [
+ ""
+ ],
+ "delete selected transfer from the database": [
+ ""
+ ],
+ "load more transfer after the last one": [
+ ""
+ ],
+ "load older transfers": [
+ ""
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ ""
+ ],
+ "filter by account address": [
+ ""
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ ""
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ ""
+ ],
+ "must be 1 or greater": [
+ ""
+ ],
+ "max 7 lines": [
+ ""
+ ],
+ "change authorization configuration": [
+ ""
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ ""
+ ],
+ "This is not a valid bitcoin address.": [
+ ""
+ ],
+ "This is not a valid Ethereum address.": [
+ ""
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ ""
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ ""
+ ],
+ "IBAN country code not found": [
+ ""
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ ""
+ ],
+ "Target type": [
+ ""
+ ],
+ "Method to use for wire transfer": [
+ ""
+ ],
+ "Routing": [
+ ""
+ ],
+ "Routing number.": [
+ ""
+ ],
+ "Account": [
+ ""
+ ],
+ "Account number.": [
+ ""
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ ""
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ ""
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ ""
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ ""
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ ""
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ ""
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ ""
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ ""
+ ],
+ "Default payment delay": [
+ ""
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ ""
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ ""
+ ],
+ "Change the authorization method use for this instance.": [
+ ""
+ ],
+ "Manage access token": [
+ ""
+ ],
+ "Failed to create instance": [
+ ""
+ ],
+ "Login required": [
+ ""
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ ""
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ ""
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ ""
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ ""
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ ""
+ ],
+ "Settings": [
+ ""
+ ],
+ "Connection": [
+ ""
+ ],
+ "New": [
+ ""
+ ],
+ "List": [
+ ""
+ ],
+ "Log out": [
+ ""
+ ],
+ "Check your token is valid": [
+ ""
+ ],
+ "Couldn't access the server.": [
+ ""
+ ],
+ "Could not infer instance id from url %1$s": [
+ ""
+ ],
+ "Server not found": [
+ ""
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ ""
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ ""
+ ],
+ "The value %1$s is invalid for a payment url": [
+ ""
+ ],
+ "add element to the list": [
+ ""
+ ],
+ "add": [
+ ""
+ ],
+ "Deleting": [
+ ""
+ ],
+ "Changing": [
+ ""
+ ],
+ "Order ID": [
+ ""
+ ],
+ "Payment URL": [
+ ""
+ ]
+ }
+ }
+};
+
+strings['en'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=(n != 1);",
+ "lang": ""
+ },
+ "Cancel": [
+ ""
+ ],
+ "%1$s": [
+ ""
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ ""
+ ],
+ "Clear": [
+ ""
+ ],
+ "Confirm": [
+ ""
+ ],
+ "is not the same as the current access token": [
+ ""
+ ],
+ "cannot be empty": [
+ ""
+ ],
+ "cannot be the same as the old token": [
+ ""
+ ],
+ "is not the same": [
+ ""
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ ""
+ ],
+ "Old access token": [
+ ""
+ ],
+ "access token currently in use": [
+ ""
+ ],
+ "New access token": [
+ ""
+ ],
+ "next access token to be used": [
+ ""
+ ],
+ "Repeat access token": [
+ ""
+ ],
+ "confirm the same access token": [
+ ""
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ ""
+ ],
+ "cannot be the same as the old access token": [
+ ""
+ ],
+ "You are setting the access token for the new instance": [
+ ""
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ ""
+ ],
+ "Set external authorization": [
+ ""
+ ],
+ "Set access token": [
+ ""
+ ],
+ "Operation in progress...": [
+ ""
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ ""
+ ],
+ "Instances": [
+ ""
+ ],
+ "Delete": [
+ ""
+ ],
+ "add new instance": [
+ ""
+ ],
+ "ID": [
+ ""
+ ],
+ "Name": [
+ ""
+ ],
+ "Edit": [
+ ""
+ ],
+ "Purge": [
+ ""
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ ""
+ ],
+ "Only show active instances": [
+ ""
+ ],
+ "Active": [
+ ""
+ ],
+ "Only show deleted instances": [
+ ""
+ ],
+ "Deleted": [
+ ""
+ ],
+ "Show all instances": [
+ ""
+ ],
+ "All": [
+ ""
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ ""
+ ],
+ "Failed to delete instance": [
+ ""
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ ""
+ ],
+ "Failed to purge instance": [
+ ""
+ ],
+ "Pending KYC verification": [
+ ""
+ ],
+ "Timed out": [
+ ""
+ ],
+ "Exchange": [
+ ""
+ ],
+ "Target account": [
+ ""
+ ],
+ "KYC URL": [
+ ""
+ ],
+ "Code": [
+ ""
+ ],
+ "Http Status": [
+ ""
+ ],
+ "No pending kyc verification!": [
+ ""
+ ],
+ "change value to unknown date": [
+ ""
+ ],
+ "change value to empty": [
+ ""
+ ],
+ "clear": [
+ ""
+ ],
+ "change value to never": [
+ ""
+ ],
+ "never": [
+ ""
+ ],
+ "Country": [
+ ""
+ ],
+ "Address": [
+ ""
+ ],
+ "Building number": [
+ ""
+ ],
+ "Building name": [
+ ""
+ ],
+ "Street": [
+ ""
+ ],
+ "Post code": [
+ ""
+ ],
+ "Town location": [
+ ""
+ ],
+ "Town": [
+ ""
+ ],
+ "District": [
+ ""
+ ],
+ "Country subdivision": [
+ ""
+ ],
+ "Product id": [
+ ""
+ ],
+ "Description": [
+ ""
+ ],
+ "Product": [
+ ""
+ ],
+ "search products by it's description or id": [
+ ""
+ ],
+ "no products found with that description": [
+ ""
+ ],
+ "You must enter a valid product identifier.": [
+ ""
+ ],
+ "Quantity must be greater than 0!": [
+ ""
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ ""
+ ],
+ "Quantity": [
+ ""
+ ],
+ "how many products will be added": [
+ ""
+ ],
+ "Add from inventory": [
+ ""
+ ],
+ "Image should be smaller than 1 MB": [
+ ""
+ ],
+ "Add": [
+ ""
+ ],
+ "Remove": [
+ ""
+ ],
+ "No taxes configured for this product.": [
+ ""
+ ],
+ "Amount": [
+ ""
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ ""
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ ""
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ ""
+ ],
+ "add tax to the tax list": [
+ ""
+ ],
+ "describe and add a product that is not in the inventory list": [
+ ""
+ ],
+ "Add custom product": [
+ ""
+ ],
+ "Complete information of the product": [
+ ""
+ ],
+ "Image": [
+ ""
+ ],
+ "photo of the product": [
+ ""
+ ],
+ "full product description": [
+ ""
+ ],
+ "Unit": [
+ ""
+ ],
+ "name of the product unit": [
+ ""
+ ],
+ "Price": [
+ ""
+ ],
+ "amount in the current currency": [
+ ""
+ ],
+ "Taxes": [
+ ""
+ ],
+ "image": [
+ ""
+ ],
+ "description": [
+ ""
+ ],
+ "quantity": [
+ ""
+ ],
+ "unit price": [
+ ""
+ ],
+ "total price": [
+ ""
+ ],
+ "required": [
+ ""
+ ],
+ "not valid": [
+ ""
+ ],
+ "must be greater than 0": [
+ ""
+ ],
+ "not a valid json": [
+ ""
+ ],
+ "should be in the future": [
+ ""
+ ],
+ "refund deadline cannot be before pay deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ ""
+ ],
+ "should have a refund deadline": [
+ ""
+ ],
+ "auto refund cannot be after refund deadline": [
+ ""
+ ],
+ "Manage products in order": [
+ ""
+ ],
+ "Manage list of products in the order.": [
+ ""
+ ],
+ "Remove this product from the order.": [
+ ""
+ ],
+ "Total price": [
+ ""
+ ],
+ "total product price added up": [
+ ""
+ ],
+ "Amount to be paid by the customer": [
+ ""
+ ],
+ "Order price": [
+ ""
+ ],
+ "final order price": [
+ ""
+ ],
+ "Summary": [
+ ""
+ ],
+ "Title of the order to be shown to the customer": [
+ ""
+ ],
+ "Shipping and Fulfillment": [
+ ""
+ ],
+ "Delivery date": [
+ ""
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ ""
+ ],
+ "Location": [
+ ""
+ ],
+ "address where the products will be delivered": [
+ ""
+ ],
+ "Fulfillment URL": [
+ ""
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ ""
+ ],
+ "Taler payment options": [
+ ""
+ ],
+ "Override default Taler payment settings for this order": [
+ ""
+ ],
+ "Payment deadline": [
+ ""
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ ""
+ ],
+ "Refund deadline": [
+ ""
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ ""
+ ],
+ "Wire transfer deadline": [
+ ""
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ ""
+ ],
+ "Auto-refund deadline": [
+ ""
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ ""
+ ],
+ "Maximum deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ ""
+ ],
+ "Maximum wire fee": [
+ ""
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ ""
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ ""
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ ""
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ ""
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ ""
+ ],
+ "hours": [
+ ""
+ ],
+ "minutes": [
+ ""
+ ],
+ "seconds": [
+ ""
+ ],
+ "forever": [
+ ""
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ ""
+ ],
+ "create order": [
+ ""
+ ],
+ "load newer orders": [
+ ""
+ ],
+ "Date": [
+ ""
+ ],
+ "Refund": [
+ ""
+ ],
+ "copy url": [
+ ""
+ ],
+ "load older orders": [
+ ""
+ ],
+ "No orders have been found matching your query!": [
+ ""
+ ],
+ "duplicated": [
+ ""
+ ],
+ "invalid format": [
+ ""
+ ],
+ "this value exceed the refundable amount": [
+ ""
+ ],
+ "date": [
+ ""
+ ],
+ "amount": [
+ ""
+ ],
+ "reason": [
+ ""
+ ],
+ "amount to be refunded": [
+ ""
+ ],
+ "Max refundable:": [
+ ""
+ ],
+ "Reason": [
+ ""
+ ],
+ "Choose one...": [
+ ""
+ ],
+ "requested by the customer": [
+ ""
+ ],
+ "other": [
+ ""
+ ],
+ "why this order is being refunded": [
+ ""
+ ],
+ "more information to give context": [
+ ""
+ ],
+ "Contract Terms": [
+ ""
+ ],
+ "human-readable description of the whole purchase": [
+ ""
+ ],
+ "total price for the transaction": [
+ ""
+ ],
+ "URL for this purchase": [
+ ""
+ ],
+ "Max fee": [
+ ""
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ ""
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ ""
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ ""
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ ""
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ ""
+ ],
+ "claimed": [
+ ""
+ ],
+ "claimed at": [
+ ""
+ ],
+ "Timeline": [
+ ""
+ ],
+ "Payment details": [
+ ""
+ ],
+ "Order status": [
+ ""
+ ],
+ "Product list": [
+ ""
+ ],
+ "paid": [
+ ""
+ ],
+ "wired": [
+ ""
+ ],
+ "refunded": [
+ ""
+ ],
+ "refund order": [
+ ""
+ ],
+ "not refundable": [
+ ""
+ ],
+ "refund": [
+ ""
+ ],
+ "Refunded amount": [
+ ""
+ ],
+ "Refund taken": [
+ ""
+ ],
+ "Status URL": [
+ ""
+ ],
+ "Refund URI": [
+ ""
+ ],
+ "unpaid": [
+ ""
+ ],
+ "pay at": [
+ ""
+ ],
+ "created at": [
+ ""
+ ],
+ "Order status URL": [
+ ""
+ ],
+ "Payment URI": [
+ ""
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ ""
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ ""
+ ],
+ "could not create the refund": [
+ ""
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ ""
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ ""
+ ],
+ "only show orders with refunds": [
+ ""
+ ],
+ "Refunded": [
+ ""
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ ""
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ ""
+ ],
+ "order not found": [
+ ""
+ ],
+ "could not get the order to refund": [
+ ""
+ ],
+ "Loading...": [
+ ""
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ ""
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ ""
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ ""
+ ],
+ "Incoming": [
+ ""
+ ],
+ "Lost": [
+ ""
+ ],
+ "Current": [
+ ""
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ ""
+ ],
+ "Next restock": [
+ ""
+ ],
+ "Delivery address": [
+ ""
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ ""
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ ""
+ ],
+ "Products": [
+ ""
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ ""
+ ],
+ "Profit": [
+ ""
+ ],
+ "Sold": [
+ ""
+ ],
+ "free": [
+ ""
+ ],
+ "go to product update page": [
+ ""
+ ],
+ "Update": [
+ ""
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ ""
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ ""
+ ],
+ "product updated successfully": [
+ ""
+ ],
+ "could not update the product": [
+ ""
+ ],
+ "product delete successfully": [
+ ""
+ ],
+ "could not delete the product": [
+ ""
+ ],
+ "Product id:": [
+ ""
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ ""
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ ""
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ ""
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ ""
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ ""
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ ""
+ ],
+ "Valid until": [
+ ""
+ ],
+ "Created balance": [
+ ""
+ ],
+ "Exchange balance": [
+ ""
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ ""
+ ],
+ "Account address": [
+ ""
+ ],
+ "Subject": [
+ ""
+ ],
+ "Tips": [
+ ""
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ ""
+ ],
+ "Expiration": [
+ ""
+ ],
+ "amount of tip": [
+ ""
+ ],
+ "Justification": [
+ ""
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ ""
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ ""
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ ""
+ ],
+ "Expected Balance": [
+ ""
+ ],
+ "could not create the tip": [
+ ""
+ ],
+ "should not be empty": [
+ ""
+ ],
+ "should be greater that 0": [
+ ""
+ ],
+ "can't be empty": [
+ ""
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ ""
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ ""
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ ""
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ ""
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ ""
+ ],
+ "Amount is required": [
+ ""
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ ""
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ ""
+ ],
+ "could not create order from template": [
+ ""
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ ""
+ ],
+ "Default amount": [
+ ""
+ ],
+ "Default summary": [
+ ""
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ ""
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ ""
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ ""
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ ""
+ ],
+ "template delete successfully": [
+ ""
+ ],
+ "could not delete the template": [
+ ""
+ ],
+ "could not update template": [
+ ""
+ ],
+ "should be one of '%1$s'": [
+ ""
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ ""
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ ""
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ ""
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ ""
+ ],
+ "webhook delete successfully": [
+ ""
+ ],
+ "could not delete the webhook": [
+ ""
+ ],
+ "check the id, does not look valid": [
+ ""
+ ],
+ "should have 52 characters, current %1$s": [
+ ""
+ ],
+ "URL doesn't have the right format": [
+ ""
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ ""
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ ""
+ ],
+ "Transfers": [
+ ""
+ ],
+ "add new transfer": [
+ ""
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ ""
+ ],
+ "Credit": [
+ ""
+ ],
+ "Confirmed": [
+ ""
+ ],
+ "Verified": [
+ ""
+ ],
+ "Executed at": [
+ ""
+ ],
+ "yes": [
+ ""
+ ],
+ "no": [
+ ""
+ ],
+ "unknown": [
+ ""
+ ],
+ "delete selected transfer from the database": [
+ ""
+ ],
+ "load more transfer after the last one": [
+ ""
+ ],
+ "load older transfers": [
+ ""
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ ""
+ ],
+ "filter by account address": [
+ ""
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ ""
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ ""
+ ],
+ "must be 1 or greater": [
+ ""
+ ],
+ "max 7 lines": [
+ ""
+ ],
+ "change authorization configuration": [
+ ""
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ ""
+ ],
+ "This is not a valid bitcoin address.": [
+ ""
+ ],
+ "This is not a valid Ethereum address.": [
+ ""
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ ""
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ ""
+ ],
+ "IBAN country code not found": [
+ ""
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ ""
+ ],
+ "Target type": [
+ ""
+ ],
+ "Method to use for wire transfer": [
+ ""
+ ],
+ "Routing": [
+ ""
+ ],
+ "Routing number.": [
+ ""
+ ],
+ "Account": [
+ ""
+ ],
+ "Account number.": [
+ ""
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ ""
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ ""
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ ""
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ ""
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ ""
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ ""
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ ""
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ ""
+ ],
+ "Default payment delay": [
+ ""
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ ""
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ ""
+ ],
+ "Change the authorization method use for this instance.": [
+ ""
+ ],
+ "Manage access token": [
+ ""
+ ],
+ "Failed to create instance": [
+ ""
+ ],
+ "Login required": [
+ ""
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ ""
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ ""
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ ""
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ ""
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ ""
+ ],
+ "Settings": [
+ ""
+ ],
+ "Connection": [
+ ""
+ ],
+ "New": [
+ ""
+ ],
+ "List": [
+ ""
+ ],
+ "Log out": [
+ ""
+ ],
+ "Check your token is valid": [
+ ""
+ ],
+ "Couldn't access the server.": [
+ ""
+ ],
+ "Could not infer instance id from url %1$s": [
+ ""
+ ],
+ "Server not found": [
+ ""
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ ""
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ ""
+ ],
+ "The value %1$s is invalid for a payment url": [
+ ""
+ ],
+ "add element to the list": [
+ ""
+ ],
+ "add": [
+ ""
+ ],
+ "Deleting": [
+ ""
+ ],
+ "Changing": [
+ ""
+ ],
+ "Order ID": [
+ ""
+ ],
+ "Payment URL": [
+ ""
+ ]
+ }
+ }
+};
+
+strings['es'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=n != 1;",
+ "lang": "es"
+ },
+ "Cancel": [
+ "Cancelar"
+ ],
+ "%1$s": [
+ "%1$s"
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ "Continuar"
+ ],
+ "Clear": [
+ "Limpiar"
+ ],
+ "Confirm": [
+ "Confirmar"
+ ],
+ "is not the same as the current access token": [
+ "no es el mismo que el token de acceso actual"
+ ],
+ "cannot be empty": [
+ "no puede ser vacío"
+ ],
+ "cannot be the same as the old token": [
+ "no puede ser igual al viejo token"
+ ],
+ "is not the same": [
+ "no son iguales"
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ "Está actualizando el token de acceso para la instancia con id %1$s"
+ ],
+ "Old access token": [
+ "Viejo token de acceso"
+ ],
+ "access token currently in use": [
+ "acceder al token en uso actualmente"
+ ],
+ "New access token": [
+ "Nuevo token de acceso"
+ ],
+ "next access token to be used": [
+ "siguiente token de acceso a usar"
+ ],
+ "Repeat access token": [
+ "Repetir token de acceso"
+ ],
+ "confirm the same access token": [
+ "confirmar el mismo token de acceso"
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ "Limpiar el token de acceso significa acceso público a la instancia"
+ ],
+ "cannot be the same as the old access token": [
+ "no puede ser igual al anterior token de acceso"
+ ],
+ "You are setting the access token for the new instance": [
+ "Está estableciendo el token de acceso para la nueva instancia"
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ "Con el método de autorización externa no se hará ninguna revisión por el backend del comerciante"
+ ],
+ "Set external authorization": [
+ "Establecer autorización externa"
+ ],
+ "Set access token": [
+ "Establecer token de acceso"
+ ],
+ "Operation in progress...": [
+ "Operación en progreso..."
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ "La operación será automáticamente cancelada luego de %1$s segundos"
+ ],
+ "Instances": [
+ "Instancias"
+ ],
+ "Delete": [
+ "Eliminar"
+ ],
+ "add new instance": [
+ "agregar nueva instancia"
+ ],
+ "ID": [
+ "ID"
+ ],
+ "Name": [
+ "Nombre"
+ ],
+ "Edit": [
+ "Editar"
+ ],
+ "Purge": [
+ "Purgar"
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ "Todavía no hay instancias, agregue más presionando el signo +"
+ ],
+ "Only show active instances": [
+ "Solo mostrar instancias activas"
+ ],
+ "Active": [
+ "Activo"
+ ],
+ "Only show deleted instances": [
+ "Mostrar solo instancias eliminadas"
+ ],
+ "Deleted": [
+ "Eliminado"
+ ],
+ "Show all instances": [
+ "Mostrar todas las instancias"
+ ],
+ "All": [
+ "Todo"
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ "La instancia '%1$s' (ID: %2$s) fue eliminada"
+ ],
+ "Failed to delete instance": [
+ "Fallo al eliminar instancia"
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ "Instance '%1$s' (ID: %2$s) ha sido deshabilitada"
+ ],
+ "Failed to purge instance": [
+ "Fallo al purgar la instancia"
+ ],
+ "Pending KYC verification": [
+ "Verificación KYC pendiente"
+ ],
+ "Timed out": [
+ "Expirado"
+ ],
+ "Exchange": [
+ "Exchange"
+ ],
+ "Target account": [
+ "Cuenta objetivo"
+ ],
+ "KYC URL": [
+ "URL de KYC"
+ ],
+ "Code": [
+ "Código"
+ ],
+ "Http Status": [
+ "Estado http"
+ ],
+ "No pending kyc verification!": [
+ "¡No hay verificación kyc pendiente!"
+ ],
+ "change value to unknown date": [
+ "cambiar valor a fecha desconocida"
+ ],
+ "change value to empty": [
+ "cambiar valor a vacío"
+ ],
+ "clear": [
+ "limpiar"
+ ],
+ "change value to never": [
+ "cambiar valor a nunca"
+ ],
+ "never": [
+ "nunca"
+ ],
+ "Country": [
+ "País"
+ ],
+ "Address": [
+ "Dirección"
+ ],
+ "Building number": [
+ "Número de edificio"
+ ],
+ "Building name": [
+ "Nombre de edificio"
+ ],
+ "Street": [
+ "Calle"
+ ],
+ "Post code": [
+ "Código postal"
+ ],
+ "Town location": [
+ "Ubicación de ciudad"
+ ],
+ "Town": [
+ "Ciudad"
+ ],
+ "District": [
+ "Distrito"
+ ],
+ "Country subdivision": [
+ "Subdivisión de país"
+ ],
+ "Product id": [
+ "Id de producto"
+ ],
+ "Description": [
+ "Descripcion"
+ ],
+ "Product": [
+ "Productos"
+ ],
+ "search products by it's description or id": [
+ "buscar productos por su descripción o ID"
+ ],
+ "no products found with that description": [
+ "no se encontraron productos con esa descripción"
+ ],
+ "You must enter a valid product identifier.": [
+ "Debe ingresar un identificador de producto válido."
+ ],
+ "Quantity must be greater than 0!": [
+ "¡Cantidad debe ser mayor que 0!"
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ "Esta cantidad excede las existencias restantes. Actualmente, solo quedan %1$s unidades sin reservar en las existencias."
+ ],
+ "Quantity": [
+ "Cantidad"
+ ],
+ "how many products will be added": [
+ "cuántos productos serán agregados"
+ ],
+ "Add from inventory": [
+ "Agregar del inventario"
+ ],
+ "Image should be smaller than 1 MB": [
+ "La imagen debe ser mas chica que 1 MB"
+ ],
+ "Add": [
+ "Agregar"
+ ],
+ "Remove": [
+ "Eliminar"
+ ],
+ "No taxes configured for this product.": [
+ "Ningun impuesto configurado para este producto."
+ ],
+ "Amount": [
+ "Monto"
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ "Impuestos pueden estar en divisas que difieren de la principal divisa usada por el comerciante."
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ "Ingrese divisa y valor separado por dos puntos, e.g. &quot;USD:2.3&quot;."
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ "Nombre legal del impuesto, e.g. IVA o arancel."
+ ],
+ "add tax to the tax list": [
+ "agregar impuesto a la lista de impuestos"
+ ],
+ "describe and add a product that is not in the inventory list": [
+ "describa y agregue un producto que no está en la lista de inventarios"
+ ],
+ "Add custom product": [
+ "Agregue un producto personalizado"
+ ],
+ "Complete information of the product": [
+ "Complete información del producto"
+ ],
+ "Image": [
+ "Imagen"
+ ],
+ "photo of the product": [
+ "foto del producto"
+ ],
+ "full product description": [
+ "descripción completa del producto"
+ ],
+ "Unit": [
+ "Unidad"
+ ],
+ "name of the product unit": [
+ "nombre de la unidad del producto"
+ ],
+ "Price": [
+ "Precio"
+ ],
+ "amount in the current currency": [
+ "monto de la divisa actual"
+ ],
+ "Taxes": [
+ "Impuestos"
+ ],
+ "image": [
+ "imagen"
+ ],
+ "description": [
+ "descripción"
+ ],
+ "quantity": [
+ "cantidad"
+ ],
+ "unit price": [
+ "precio unitario"
+ ],
+ "total price": [
+ "precio total"
+ ],
+ "required": [
+ "requerido"
+ ],
+ "not valid": [
+ "no es un json válido"
+ ],
+ "must be greater than 0": [
+ "debe ser mayor que 0"
+ ],
+ "not a valid json": [
+ "no es un json válido"
+ ],
+ "should be in the future": [
+ "deberían ser en el futuro"
+ ],
+ "refund deadline cannot be before pay deadline": [
+ "plazo de reembolso no puede ser antes que el plazo de pago"
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ "el plazo de la transferencia bancaria no puede ser antes que el plazo de reembolso"
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ "el plazo de la transferencia bancaria no puede ser antes que el plazo de pago"
+ ],
+ "should have a refund deadline": [
+ "debería tener un plazo de reembolso"
+ ],
+ "auto refund cannot be after refund deadline": [
+ "reembolso automático no puede ser después qu el plazo de reembolso"
+ ],
+ "Manage products in order": [
+ "Manejar productos en orden"
+ ],
+ "Manage list of products in the order.": [
+ "Manejar lista de productos en la orden."
+ ],
+ "Remove this product from the order.": [
+ "Remover este producto de la orden."
+ ],
+ "Total price": [
+ "Precio total"
+ ],
+ "total product price added up": [
+ "precio total de producto agregado"
+ ],
+ "Amount to be paid by the customer": [
+ "Monto a ser pagado por el cliente"
+ ],
+ "Order price": [
+ "Precio de la orden"
+ ],
+ "final order price": [
+ "Precio final de la orden"
+ ],
+ "Summary": [
+ "Resumen"
+ ],
+ "Title of the order to be shown to the customer": [
+ "Título de la orden a ser mostrado al cliente"
+ ],
+ "Shipping and Fulfillment": [
+ "Envío y cumplimiento"
+ ],
+ "Delivery date": [
+ "Fecha de entrega"
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ "Plazo para la entrega física asegurado por el comerciante."
+ ],
+ "Location": [
+ "Ubicación"
+ ],
+ "address where the products will be delivered": [
+ "dirección a donde los productos serán entregados"
+ ],
+ "Fulfillment URL": [
+ "URL de cumplimiento"
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ "URL al cual el usuario será redirigido luego de pago exitoso."
+ ],
+ "Taler payment options": [
+ "Opciones de pago de Taler"
+ ],
+ "Override default Taler payment settings for this order": [
+ "Sobreescribir pagos por omisión de Taler para esta orden"
+ ],
+ "Payment deadline": [
+ "Plazo de pago"
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ "Plazo límite para que el cliente pague por la oferta antes de que expire. Productos del inventario serán reservados hasta este plazo límite."
+ ],
+ "Refund deadline": [
+ "Plazo de reembolso"
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ "Tiempo hasta el cual la orden puede ser reembolsada por el comerciante."
+ ],
+ "Wire transfer deadline": [
+ "Plazo de la transferencia"
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ "Plazo para que el exchange haga la transferencia."
+ ],
+ "Auto-refund deadline": [
+ "Plazo de reembolso automático"
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ "Tiempo hasta el cual la billetera será automáticamente revisada por reembolsos win interación por parte del usuario."
+ ],
+ "Maximum deposit fee": [
+ "Máxima tarifa de depósito"
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ "Máxima tarifa de depósito que el comerciante esta dispuesto a cubir para esta orden. Mayores tarifas de depósito deben ser cubiertas completamente por el consumidor."
+ ],
+ "Maximum wire fee": [
+ "Máxima tarifa de transferencia"
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ "Amortización de comisión de transferencia"
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ "Administrar token"
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ "Login necesario"
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ "Información extra"
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ "días"
+ ],
+ "hours": [
+ "horas"
+ ],
+ "minutes": [
+ "minutos"
+ ],
+ "seconds": [
+ "segundos"
+ ],
+ "forever": [
+ "nunca"
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ "Órdenes"
+ ],
+ "create order": [
+ "creado"
+ ],
+ "load newer orders": [
+ "cargar nuevas ordenes"
+ ],
+ "Date": [
+ "Fecha"
+ ],
+ "Refund": [
+ "Devolución"
+ ],
+ "copy url": [
+ "copiar url"
+ ],
+ "load older orders": [
+ "cargar viejas ordenes"
+ ],
+ "No orders have been found matching your query!": [
+ "¡No se encontraron órdenes que emparejen su búsqueda!"
+ ],
+ "duplicated": [
+ "duplicado"
+ ],
+ "invalid format": [
+ "formato inválido"
+ ],
+ "this value exceed the refundable amount": [
+ "este monto excede el monto reembolsable"
+ ],
+ "date": [
+ "fecha"
+ ],
+ "amount": [
+ "monto"
+ ],
+ "reason": [
+ "razón"
+ ],
+ "amount to be refunded": [
+ "monto a ser reembolsado"
+ ],
+ "Max refundable:": [
+ "Máximo reembolzable:"
+ ],
+ "Reason": [
+ "Razón"
+ ],
+ "Choose one...": [
+ "Elija uno..."
+ ],
+ "requested by the customer": [
+ "pedido por el consumidor"
+ ],
+ "other": [
+ "otro"
+ ],
+ "why this order is being refunded": [
+ "por qué esta orden está siendo reembolsada"
+ ],
+ "more information to give context": [
+ "más información para dar contexto"
+ ],
+ "Contract Terms": [
+ "Términos de contrato"
+ ],
+ "human-readable description of the whole purchase": [
+ "descripción legible de toda la compra"
+ ],
+ "total price for the transaction": [
+ "precio total de la transacción"
+ ],
+ "URL for this purchase": [
+ "URL para esta compra"
+ ],
+ "Max fee": [
+ "Máxima comisión"
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ "Impuesto de transferencia máximo"
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ "Creado en"
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ "Plazo de reembolso automático"
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ "Información extra"
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ "Orden"
+ ],
+ "claimed": [
+ "reclamado"
+ ],
+ "claimed at": [
+ "reclamado"
+ ],
+ "Timeline": [
+ "Cronología"
+ ],
+ "Payment details": [
+ "Detalles de pago"
+ ],
+ "Order status": [
+ "Estado de orden"
+ ],
+ "Product list": [
+ "Lista de producto"
+ ],
+ "paid": [
+ "pagados"
+ ],
+ "wired": [
+ "transferido"
+ ],
+ "refunded": [
+ "reembolzado"
+ ],
+ "refund order": [
+ "reembolzado"
+ ],
+ "not refundable": [
+ "Máximo reembolzable:"
+ ],
+ "refund": [
+ "reembolzar"
+ ],
+ "Refunded amount": [
+ "Monto reembolzado"
+ ],
+ "Refund taken": [
+ "Reembolzado"
+ ],
+ "Status URL": [
+ "URL de estado de orden"
+ ],
+ "Refund URI": [
+ "Devolución"
+ ],
+ "unpaid": [
+ "impago"
+ ],
+ "pay at": [
+ "pagar en"
+ ],
+ "created at": [
+ "creado"
+ ],
+ "Order status URL": [
+ "URL de estado de orden"
+ ],
+ "Payment URI": [
+ "URI de pago"
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ "Estado de orden desconocido. Esto es un error, por favor contacte a su administrador."
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ "reembolzo creado satisfactoriamente"
+ ],
+ "could not create the refund": [
+ "No se pudo create el reembolso"
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ "ir a id de orden"
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ "Pagado"
+ ],
+ "only show orders with refunds": [
+ "No se pudo create el reembolso"
+ ],
+ "Refunded": [
+ "Reembolzado"
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ "No transferido"
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ "ir a id de orden"
+ ],
+ "order not found": [
+ "Servidor no encontrado"
+ ],
+ "could not get the order to refund": [
+ "No se pudo create el reembolso"
+ ],
+ "Loading...": [
+ "Cargando..."
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ "Administrar stock"
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ "Inifinito"
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ "la pérdida no puede ser mayor al stock actual + entrante (max %1$s )"
+ ],
+ "Incoming": [
+ "Ingresando"
+ ],
+ "Lost": [
+ "Perdido"
+ ],
+ "Current": [
+ "Actual"
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ "sin stock"
+ ],
+ "Next restock": [
+ "Próximo reabastecimiento"
+ ],
+ "Delivery address": [
+ "Dirección de entrega"
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ "Existencias"
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ "no se pudo crear el producto"
+ ],
+ "Products": [
+ "Productos"
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ "Venta"
+ ],
+ "Profit": [
+ "Ganancia"
+ ],
+ "Sold": [
+ "Vendido"
+ ],
+ "free": [
+ "Gratis"
+ ],
+ "go to product update page": [
+ "producto actualizado correctamente"
+ ],
+ "Update": [
+ "Actualizar"
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ "no se pudo actualizar el producto"
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ "No hay propinas todavía, agregar mas presionando el signo +"
+ ],
+ "product updated successfully": [
+ "producto actualizado correctamente"
+ ],
+ "could not update the product": [
+ "no se pudo actualizar el producto"
+ ],
+ "product delete successfully": [
+ "producto fue eliminado correctamente"
+ ],
+ "could not delete the product": [
+ "no se pudo eliminar el producto"
+ ],
+ "Product id:": [
+ "Id de producto"
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ "Debe ser mayor a 0"
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ "Instancia"
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ "URL del Exchange"
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ "Siguiente"
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ "no se pudo informar la transferencia"
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ "No se pudo create el reembolso"
+ ],
+ "Valid until": [
+ "Válido hasta"
+ ],
+ "Created balance": [
+ "creado"
+ ],
+ "Exchange balance": [
+ "Monto inicial"
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ "Monto confirmado"
+ ],
+ "Account address": [
+ "Dirección de cuenta"
+ ],
+ "Subject": [
+ "Asunto"
+ ],
+ "Tips": [
+ "Propinas"
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ "Token de autorización"
+ ],
+ "Expiration": [
+ "Información extra"
+ ],
+ "amount of tip": [
+ "monto"
+ ],
+ "Justification": [
+ "Jurisdicción"
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ "Servidor no encontrado"
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ "cargar nuevas transferencias"
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ "No hay transferencias todavía, agregar mas presionando el signo +"
+ ],
+ "Expected Balance": [
+ "Ejecutado en"
+ ],
+ "could not create the tip": [
+ "No se pudo create el reembolso"
+ ],
+ "should not be empty": [
+ "no puede ser vacío"
+ ],
+ "should be greater that 0": [
+ "Debe ser mayor a 0"
+ ],
+ "can't be empty": [
+ "no puede ser vacío"
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ "Estado de orden"
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ "precio unitario"
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ "Edad mínima"
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ "Opciones de pago"
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ "no se pudo informar la transferencia"
+ ],
+ "Amount is required": [
+ "Login necesario"
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ "cargar viejas transferencias"
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ "Estado de orden"
+ ],
+ "could not create order from template": [
+ "No se pudo create el reembolso"
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ "Monto reembolzado"
+ ],
+ "Default amount": [
+ "Monto reembolzado"
+ ],
+ "Default summary": [
+ "Estado de orden"
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ "cargar nuevas transferencias"
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ "No se pudo create el reembolso"
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ "cargar viejas transferencias"
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ "No hay propinas todavía, agregar mas presionando el signo +"
+ ],
+ "template delete successfully": [
+ "producto fue eliminado correctamente"
+ ],
+ "could not delete the template": [
+ "no se pudo eliminar el producto"
+ ],
+ "could not update template": [
+ "no se pudo actualizar el producto"
+ ],
+ "should be one of '%1$s'": [
+ "deberían ser iguales"
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ "URL"
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ "cargar nuevas ordenes"
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ "cargar viejas ordenes"
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ "No hay propinas todavía, agregar mas presionando el signo +"
+ ],
+ "webhook delete successfully": [
+ "producto fue eliminado correctamente"
+ ],
+ "could not delete the webhook": [
+ "no se pudo eliminar el producto"
+ ],
+ "check the id, does not look valid": [
+ "verificar el id, no parece válido"
+ ],
+ "should have 52 characters, current %1$s": [
+ "debería tener 52 caracteres, actualmente %1$s"
+ ],
+ "URL doesn't have the right format": [
+ "La URL no tiene el formato correcto"
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ "Id de transferencia"
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ "no se pudo informar la transferencia"
+ ],
+ "Transfers": [
+ "Transferencias"
+ ],
+ "add new transfer": [
+ "cargar nuevas transferencias"
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ "cargar nuevas transferencias"
+ ],
+ "Credit": [
+ "Crédito"
+ ],
+ "Confirmed": [
+ "Confirmado"
+ ],
+ "Verified": [
+ "Verificado"
+ ],
+ "Executed at": [
+ "Ejecutado en"
+ ],
+ "yes": [
+ "si"
+ ],
+ "no": [
+ "no"
+ ],
+ "unknown": [
+ "desconocido"
+ ],
+ "delete selected transfer from the database": [
+ "eliminar transferencia seleccionada de la base de datos"
+ ],
+ "load more transfer after the last one": [
+ "cargue más transferencia luego de la última"
+ ],
+ "load older transfers": [
+ "cargar viejas transferencias"
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ "No hay transferencias todavía, agregar mas presionando el signo +"
+ ],
+ "filter by account address": [
+ "Dirección de cuenta"
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ "Verificado"
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ "Número de edificio"
+ ],
+ "must be 1 or greater": [
+ "debe ser 1 o mayor"
+ ],
+ "max 7 lines": [
+ "máximo 7 líneas"
+ ],
+ "change authorization configuration": [
+ "cambiar configuración de autorización"
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ "Necesita completar campos marcados y escoger un método de autorización"
+ ],
+ "This is not a valid bitcoin address.": [
+ "Esta no es una dirección de bitcoin válida."
+ ],
+ "This is not a valid Ethereum address.": [
+ "Esta no es una dirección de Ethereum válida."
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ "Números IBAN usualmente tienen más de 4 dígitos"
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ "Número IBAN usualmente tienen menos de 34 dígitos"
+ ],
+ "IBAN country code not found": [
+ "Código IBAN de país no encontrado"
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ "Número IBAN no es válido, la suma de verificación es incorrecta"
+ ],
+ "Target type": [
+ "Tipo objetivo"
+ ],
+ "Method to use for wire transfer": [
+ "Método a usar para la transferencia"
+ ],
+ "Routing": [
+ "Enrutamiento"
+ ],
+ "Routing number.": [
+ "Número de enrutamiento."
+ ],
+ "Account": [
+ "Cuenta"
+ ],
+ "Account number.": [
+ "Dirección de cuenta"
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ "Interfaz de pago unificado."
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ "Nombre de edificio"
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ "URL de sitio web"
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ "Cuenta bancaria"
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ "Impuesto máximo de deposito por omisión"
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ "Impuesto máximo de transferencia por omisión"
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ "Amortización de impuesto de transferencia por omisión"
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ "Jurisdicción"
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ "Jurisdicción para disputas legales con el comerciante."
+ ],
+ "Default payment delay": [
+ "Retrazo de pago por omisión"
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ "Retrazo de transferencia por omisión"
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ "ID de instancia"
+ ],
+ "Change the authorization method use for this instance.": [
+ "Limpiar el token de autorización significa acceso público a la instancia"
+ ],
+ "Manage access token": [
+ "Administrar token de acceso"
+ ],
+ "Failed to create instance": [
+ "Fallo al crear la instancia"
+ ],
+ "Login required": [
+ "Login necesario"
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ "Acceso denegado"
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ "Servidir reporto un problema: HTTP status #%1$s"
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ "Acceso denegado"
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ "Sin instancia default"
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ "Instancia"
+ ],
+ "Settings": [
+ "Configuración"
+ ],
+ "Connection": [
+ "Conexión"
+ ],
+ "New": [
+ "Nuevo"
+ ],
+ "List": [
+ "Lista"
+ ],
+ "Log out": [
+ "Salir"
+ ],
+ "Check your token is valid": [
+ "Verifica que el token sea valido"
+ ],
+ "Couldn't access the server.": [
+ "No se pudo acceder al servidor."
+ ],
+ "Could not infer instance id from url %1$s": [
+ "No se pudo inferir el id de la instancia con la url %1$s"
+ ],
+ "Server not found": [
+ "Servidor no encontrado"
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ "Recibimos el mensaje %1$s desde %2$s"
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ "Error inesperado"
+ ],
+ "The value %1$s is invalid for a payment url": [
+ "El valor %1$s es invalido para una URL de pago"
+ ],
+ "add element to the list": [
+ "agregar elemento a la lista"
+ ],
+ "add": [
+ "Agregar"
+ ],
+ "Deleting": [
+ "Borrando"
+ ],
+ "Changing": [
+ "Cambiando"
+ ],
+ "Order ID": [
+ "ID de pedido"
+ ],
+ "Payment URL": [
+ "URL de pago"
+ ]
+ }
+ }
+};
+
+strings['fr'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=(n != 1);",
+ "lang": ""
+ },
+ "Cancel": [
+ ""
+ ],
+ "%1$s": [
+ ""
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ ""
+ ],
+ "Clear": [
+ ""
+ ],
+ "Confirm": [
+ ""
+ ],
+ "is not the same as the current access token": [
+ ""
+ ],
+ "cannot be empty": [
+ ""
+ ],
+ "cannot be the same as the old token": [
+ ""
+ ],
+ "is not the same": [
+ ""
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ ""
+ ],
+ "Old access token": [
+ ""
+ ],
+ "access token currently in use": [
+ ""
+ ],
+ "New access token": [
+ ""
+ ],
+ "next access token to be used": [
+ ""
+ ],
+ "Repeat access token": [
+ ""
+ ],
+ "confirm the same access token": [
+ ""
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ ""
+ ],
+ "cannot be the same as the old access token": [
+ ""
+ ],
+ "You are setting the access token for the new instance": [
+ ""
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ ""
+ ],
+ "Set external authorization": [
+ ""
+ ],
+ "Set access token": [
+ ""
+ ],
+ "Operation in progress...": [
+ ""
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ ""
+ ],
+ "Instances": [
+ ""
+ ],
+ "Delete": [
+ ""
+ ],
+ "add new instance": [
+ ""
+ ],
+ "ID": [
+ ""
+ ],
+ "Name": [
+ ""
+ ],
+ "Edit": [
+ ""
+ ],
+ "Purge": [
+ ""
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ ""
+ ],
+ "Only show active instances": [
+ ""
+ ],
+ "Active": [
+ ""
+ ],
+ "Only show deleted instances": [
+ ""
+ ],
+ "Deleted": [
+ ""
+ ],
+ "Show all instances": [
+ ""
+ ],
+ "All": [
+ ""
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ ""
+ ],
+ "Failed to delete instance": [
+ ""
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ ""
+ ],
+ "Failed to purge instance": [
+ ""
+ ],
+ "Pending KYC verification": [
+ ""
+ ],
+ "Timed out": [
+ ""
+ ],
+ "Exchange": [
+ ""
+ ],
+ "Target account": [
+ ""
+ ],
+ "KYC URL": [
+ ""
+ ],
+ "Code": [
+ ""
+ ],
+ "Http Status": [
+ ""
+ ],
+ "No pending kyc verification!": [
+ ""
+ ],
+ "change value to unknown date": [
+ ""
+ ],
+ "change value to empty": [
+ ""
+ ],
+ "clear": [
+ ""
+ ],
+ "change value to never": [
+ ""
+ ],
+ "never": [
+ ""
+ ],
+ "Country": [
+ ""
+ ],
+ "Address": [
+ ""
+ ],
+ "Building number": [
+ ""
+ ],
+ "Building name": [
+ ""
+ ],
+ "Street": [
+ ""
+ ],
+ "Post code": [
+ ""
+ ],
+ "Town location": [
+ ""
+ ],
+ "Town": [
+ ""
+ ],
+ "District": [
+ ""
+ ],
+ "Country subdivision": [
+ ""
+ ],
+ "Product id": [
+ ""
+ ],
+ "Description": [
+ ""
+ ],
+ "Product": [
+ ""
+ ],
+ "search products by it's description or id": [
+ ""
+ ],
+ "no products found with that description": [
+ ""
+ ],
+ "You must enter a valid product identifier.": [
+ ""
+ ],
+ "Quantity must be greater than 0!": [
+ ""
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ ""
+ ],
+ "Quantity": [
+ ""
+ ],
+ "how many products will be added": [
+ ""
+ ],
+ "Add from inventory": [
+ ""
+ ],
+ "Image should be smaller than 1 MB": [
+ ""
+ ],
+ "Add": [
+ ""
+ ],
+ "Remove": [
+ ""
+ ],
+ "No taxes configured for this product.": [
+ ""
+ ],
+ "Amount": [
+ ""
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ ""
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ ""
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ ""
+ ],
+ "add tax to the tax list": [
+ ""
+ ],
+ "describe and add a product that is not in the inventory list": [
+ ""
+ ],
+ "Add custom product": [
+ ""
+ ],
+ "Complete information of the product": [
+ ""
+ ],
+ "Image": [
+ ""
+ ],
+ "photo of the product": [
+ ""
+ ],
+ "full product description": [
+ ""
+ ],
+ "Unit": [
+ ""
+ ],
+ "name of the product unit": [
+ ""
+ ],
+ "Price": [
+ ""
+ ],
+ "amount in the current currency": [
+ ""
+ ],
+ "Taxes": [
+ ""
+ ],
+ "image": [
+ ""
+ ],
+ "description": [
+ ""
+ ],
+ "quantity": [
+ ""
+ ],
+ "unit price": [
+ ""
+ ],
+ "total price": [
+ ""
+ ],
+ "required": [
+ ""
+ ],
+ "not valid": [
+ ""
+ ],
+ "must be greater than 0": [
+ ""
+ ],
+ "not a valid json": [
+ ""
+ ],
+ "should be in the future": [
+ ""
+ ],
+ "refund deadline cannot be before pay deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ ""
+ ],
+ "should have a refund deadline": [
+ ""
+ ],
+ "auto refund cannot be after refund deadline": [
+ ""
+ ],
+ "Manage products in order": [
+ ""
+ ],
+ "Manage list of products in the order.": [
+ ""
+ ],
+ "Remove this product from the order.": [
+ ""
+ ],
+ "Total price": [
+ ""
+ ],
+ "total product price added up": [
+ ""
+ ],
+ "Amount to be paid by the customer": [
+ ""
+ ],
+ "Order price": [
+ ""
+ ],
+ "final order price": [
+ ""
+ ],
+ "Summary": [
+ ""
+ ],
+ "Title of the order to be shown to the customer": [
+ ""
+ ],
+ "Shipping and Fulfillment": [
+ ""
+ ],
+ "Delivery date": [
+ ""
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ ""
+ ],
+ "Location": [
+ ""
+ ],
+ "address where the products will be delivered": [
+ ""
+ ],
+ "Fulfillment URL": [
+ ""
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ ""
+ ],
+ "Taler payment options": [
+ ""
+ ],
+ "Override default Taler payment settings for this order": [
+ ""
+ ],
+ "Payment deadline": [
+ ""
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ ""
+ ],
+ "Refund deadline": [
+ ""
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ ""
+ ],
+ "Wire transfer deadline": [
+ ""
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ ""
+ ],
+ "Auto-refund deadline": [
+ ""
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ ""
+ ],
+ "Maximum deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ ""
+ ],
+ "Maximum wire fee": [
+ ""
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ ""
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ ""
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ ""
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ ""
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ ""
+ ],
+ "hours": [
+ ""
+ ],
+ "minutes": [
+ ""
+ ],
+ "seconds": [
+ ""
+ ],
+ "forever": [
+ ""
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ ""
+ ],
+ "create order": [
+ ""
+ ],
+ "load newer orders": [
+ ""
+ ],
+ "Date": [
+ ""
+ ],
+ "Refund": [
+ ""
+ ],
+ "copy url": [
+ ""
+ ],
+ "load older orders": [
+ ""
+ ],
+ "No orders have been found matching your query!": [
+ ""
+ ],
+ "duplicated": [
+ ""
+ ],
+ "invalid format": [
+ ""
+ ],
+ "this value exceed the refundable amount": [
+ ""
+ ],
+ "date": [
+ ""
+ ],
+ "amount": [
+ ""
+ ],
+ "reason": [
+ ""
+ ],
+ "amount to be refunded": [
+ ""
+ ],
+ "Max refundable:": [
+ ""
+ ],
+ "Reason": [
+ ""
+ ],
+ "Choose one...": [
+ ""
+ ],
+ "requested by the customer": [
+ ""
+ ],
+ "other": [
+ ""
+ ],
+ "why this order is being refunded": [
+ ""
+ ],
+ "more information to give context": [
+ ""
+ ],
+ "Contract Terms": [
+ ""
+ ],
+ "human-readable description of the whole purchase": [
+ ""
+ ],
+ "total price for the transaction": [
+ ""
+ ],
+ "URL for this purchase": [
+ ""
+ ],
+ "Max fee": [
+ ""
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ ""
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ ""
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ ""
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ ""
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ ""
+ ],
+ "claimed": [
+ ""
+ ],
+ "claimed at": [
+ ""
+ ],
+ "Timeline": [
+ ""
+ ],
+ "Payment details": [
+ ""
+ ],
+ "Order status": [
+ ""
+ ],
+ "Product list": [
+ ""
+ ],
+ "paid": [
+ ""
+ ],
+ "wired": [
+ ""
+ ],
+ "refunded": [
+ ""
+ ],
+ "refund order": [
+ ""
+ ],
+ "not refundable": [
+ ""
+ ],
+ "refund": [
+ ""
+ ],
+ "Refunded amount": [
+ ""
+ ],
+ "Refund taken": [
+ ""
+ ],
+ "Status URL": [
+ ""
+ ],
+ "Refund URI": [
+ ""
+ ],
+ "unpaid": [
+ ""
+ ],
+ "pay at": [
+ ""
+ ],
+ "created at": [
+ ""
+ ],
+ "Order status URL": [
+ ""
+ ],
+ "Payment URI": [
+ ""
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ ""
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ ""
+ ],
+ "could not create the refund": [
+ ""
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ ""
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ ""
+ ],
+ "only show orders with refunds": [
+ ""
+ ],
+ "Refunded": [
+ ""
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ ""
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ ""
+ ],
+ "order not found": [
+ ""
+ ],
+ "could not get the order to refund": [
+ ""
+ ],
+ "Loading...": [
+ ""
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ ""
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ ""
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ ""
+ ],
+ "Incoming": [
+ ""
+ ],
+ "Lost": [
+ ""
+ ],
+ "Current": [
+ ""
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ ""
+ ],
+ "Next restock": [
+ ""
+ ],
+ "Delivery address": [
+ ""
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ ""
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ ""
+ ],
+ "Products": [
+ ""
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ ""
+ ],
+ "Profit": [
+ ""
+ ],
+ "Sold": [
+ ""
+ ],
+ "free": [
+ ""
+ ],
+ "go to product update page": [
+ ""
+ ],
+ "Update": [
+ ""
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ ""
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ ""
+ ],
+ "product updated successfully": [
+ ""
+ ],
+ "could not update the product": [
+ ""
+ ],
+ "product delete successfully": [
+ ""
+ ],
+ "could not delete the product": [
+ ""
+ ],
+ "Product id:": [
+ ""
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ ""
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ ""
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ ""
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ ""
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ ""
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ ""
+ ],
+ "Valid until": [
+ ""
+ ],
+ "Created balance": [
+ ""
+ ],
+ "Exchange balance": [
+ ""
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ ""
+ ],
+ "Account address": [
+ ""
+ ],
+ "Subject": [
+ ""
+ ],
+ "Tips": [
+ ""
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ ""
+ ],
+ "Expiration": [
+ ""
+ ],
+ "amount of tip": [
+ ""
+ ],
+ "Justification": [
+ ""
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ ""
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ ""
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ ""
+ ],
+ "Expected Balance": [
+ ""
+ ],
+ "could not create the tip": [
+ ""
+ ],
+ "should not be empty": [
+ ""
+ ],
+ "should be greater that 0": [
+ ""
+ ],
+ "can't be empty": [
+ ""
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ ""
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ ""
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ ""
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ ""
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ ""
+ ],
+ "Amount is required": [
+ ""
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ ""
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ ""
+ ],
+ "could not create order from template": [
+ ""
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ ""
+ ],
+ "Default amount": [
+ ""
+ ],
+ "Default summary": [
+ ""
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ ""
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ ""
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ ""
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ ""
+ ],
+ "template delete successfully": [
+ ""
+ ],
+ "could not delete the template": [
+ ""
+ ],
+ "could not update template": [
+ ""
+ ],
+ "should be one of '%1$s'": [
+ ""
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ ""
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ ""
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ ""
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ ""
+ ],
+ "webhook delete successfully": [
+ ""
+ ],
+ "could not delete the webhook": [
+ ""
+ ],
+ "check the id, does not look valid": [
+ ""
+ ],
+ "should have 52 characters, current %1$s": [
+ ""
+ ],
+ "URL doesn't have the right format": [
+ ""
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ ""
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ ""
+ ],
+ "Transfers": [
+ ""
+ ],
+ "add new transfer": [
+ ""
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ ""
+ ],
+ "Credit": [
+ ""
+ ],
+ "Confirmed": [
+ ""
+ ],
+ "Verified": [
+ ""
+ ],
+ "Executed at": [
+ ""
+ ],
+ "yes": [
+ ""
+ ],
+ "no": [
+ ""
+ ],
+ "unknown": [
+ ""
+ ],
+ "delete selected transfer from the database": [
+ ""
+ ],
+ "load more transfer after the last one": [
+ ""
+ ],
+ "load older transfers": [
+ ""
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ ""
+ ],
+ "filter by account address": [
+ ""
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ ""
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ ""
+ ],
+ "must be 1 or greater": [
+ ""
+ ],
+ "max 7 lines": [
+ ""
+ ],
+ "change authorization configuration": [
+ ""
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ ""
+ ],
+ "This is not a valid bitcoin address.": [
+ ""
+ ],
+ "This is not a valid Ethereum address.": [
+ ""
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ ""
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ ""
+ ],
+ "IBAN country code not found": [
+ ""
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ ""
+ ],
+ "Target type": [
+ ""
+ ],
+ "Method to use for wire transfer": [
+ ""
+ ],
+ "Routing": [
+ ""
+ ],
+ "Routing number.": [
+ ""
+ ],
+ "Account": [
+ ""
+ ],
+ "Account number.": [
+ ""
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ ""
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ ""
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ ""
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ ""
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ ""
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ ""
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ ""
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ ""
+ ],
+ "Default payment delay": [
+ ""
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ ""
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ ""
+ ],
+ "Change the authorization method use for this instance.": [
+ ""
+ ],
+ "Manage access token": [
+ ""
+ ],
+ "Failed to create instance": [
+ ""
+ ],
+ "Login required": [
+ ""
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ ""
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ ""
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ ""
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ ""
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ ""
+ ],
+ "Settings": [
+ ""
+ ],
+ "Connection": [
+ ""
+ ],
+ "New": [
+ ""
+ ],
+ "List": [
+ ""
+ ],
+ "Log out": [
+ ""
+ ],
+ "Check your token is valid": [
+ ""
+ ],
+ "Couldn't access the server.": [
+ ""
+ ],
+ "Could not infer instance id from url %1$s": [
+ ""
+ ],
+ "Server not found": [
+ ""
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ ""
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ ""
+ ],
+ "The value %1$s is invalid for a payment url": [
+ ""
+ ],
+ "add element to the list": [
+ ""
+ ],
+ "add": [
+ ""
+ ],
+ "Deleting": [
+ ""
+ ],
+ "Changing": [
+ ""
+ ],
+ "Order ID": [
+ ""
+ ],
+ "Payment URL": [
+ ""
+ ]
+ }
+ }
+};
+
+strings['it'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=(n != 1);",
+ "lang": ""
+ },
+ "Cancel": [
+ ""
+ ],
+ "%1$s": [
+ ""
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ ""
+ ],
+ "Clear": [
+ ""
+ ],
+ "Confirm": [
+ ""
+ ],
+ "is not the same as the current access token": [
+ ""
+ ],
+ "cannot be empty": [
+ ""
+ ],
+ "cannot be the same as the old token": [
+ ""
+ ],
+ "is not the same": [
+ ""
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ ""
+ ],
+ "Old access token": [
+ ""
+ ],
+ "access token currently in use": [
+ ""
+ ],
+ "New access token": [
+ ""
+ ],
+ "next access token to be used": [
+ ""
+ ],
+ "Repeat access token": [
+ ""
+ ],
+ "confirm the same access token": [
+ ""
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ ""
+ ],
+ "cannot be the same as the old access token": [
+ ""
+ ],
+ "You are setting the access token for the new instance": [
+ ""
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ ""
+ ],
+ "Set external authorization": [
+ ""
+ ],
+ "Set access token": [
+ ""
+ ],
+ "Operation in progress...": [
+ ""
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ ""
+ ],
+ "Instances": [
+ ""
+ ],
+ "Delete": [
+ ""
+ ],
+ "add new instance": [
+ ""
+ ],
+ "ID": [
+ ""
+ ],
+ "Name": [
+ ""
+ ],
+ "Edit": [
+ ""
+ ],
+ "Purge": [
+ ""
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ ""
+ ],
+ "Only show active instances": [
+ ""
+ ],
+ "Active": [
+ ""
+ ],
+ "Only show deleted instances": [
+ ""
+ ],
+ "Deleted": [
+ ""
+ ],
+ "Show all instances": [
+ ""
+ ],
+ "All": [
+ ""
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ ""
+ ],
+ "Failed to delete instance": [
+ ""
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ ""
+ ],
+ "Failed to purge instance": [
+ ""
+ ],
+ "Pending KYC verification": [
+ ""
+ ],
+ "Timed out": [
+ ""
+ ],
+ "Exchange": [
+ ""
+ ],
+ "Target account": [
+ ""
+ ],
+ "KYC URL": [
+ ""
+ ],
+ "Code": [
+ ""
+ ],
+ "Http Status": [
+ ""
+ ],
+ "No pending kyc verification!": [
+ ""
+ ],
+ "change value to unknown date": [
+ ""
+ ],
+ "change value to empty": [
+ ""
+ ],
+ "clear": [
+ ""
+ ],
+ "change value to never": [
+ ""
+ ],
+ "never": [
+ ""
+ ],
+ "Country": [
+ ""
+ ],
+ "Address": [
+ ""
+ ],
+ "Building number": [
+ ""
+ ],
+ "Building name": [
+ ""
+ ],
+ "Street": [
+ ""
+ ],
+ "Post code": [
+ ""
+ ],
+ "Town location": [
+ ""
+ ],
+ "Town": [
+ ""
+ ],
+ "District": [
+ ""
+ ],
+ "Country subdivision": [
+ ""
+ ],
+ "Product id": [
+ ""
+ ],
+ "Description": [
+ ""
+ ],
+ "Product": [
+ ""
+ ],
+ "search products by it's description or id": [
+ ""
+ ],
+ "no products found with that description": [
+ ""
+ ],
+ "You must enter a valid product identifier.": [
+ ""
+ ],
+ "Quantity must be greater than 0!": [
+ ""
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ ""
+ ],
+ "Quantity": [
+ ""
+ ],
+ "how many products will be added": [
+ ""
+ ],
+ "Add from inventory": [
+ ""
+ ],
+ "Image should be smaller than 1 MB": [
+ ""
+ ],
+ "Add": [
+ ""
+ ],
+ "Remove": [
+ ""
+ ],
+ "No taxes configured for this product.": [
+ ""
+ ],
+ "Amount": [
+ ""
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ ""
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ ""
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ ""
+ ],
+ "add tax to the tax list": [
+ ""
+ ],
+ "describe and add a product that is not in the inventory list": [
+ ""
+ ],
+ "Add custom product": [
+ ""
+ ],
+ "Complete information of the product": [
+ ""
+ ],
+ "Image": [
+ ""
+ ],
+ "photo of the product": [
+ ""
+ ],
+ "full product description": [
+ ""
+ ],
+ "Unit": [
+ ""
+ ],
+ "name of the product unit": [
+ ""
+ ],
+ "Price": [
+ ""
+ ],
+ "amount in the current currency": [
+ ""
+ ],
+ "Taxes": [
+ ""
+ ],
+ "image": [
+ ""
+ ],
+ "description": [
+ ""
+ ],
+ "quantity": [
+ ""
+ ],
+ "unit price": [
+ ""
+ ],
+ "total price": [
+ ""
+ ],
+ "required": [
+ ""
+ ],
+ "not valid": [
+ ""
+ ],
+ "must be greater than 0": [
+ ""
+ ],
+ "not a valid json": [
+ ""
+ ],
+ "should be in the future": [
+ ""
+ ],
+ "refund deadline cannot be before pay deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ ""
+ ],
+ "should have a refund deadline": [
+ ""
+ ],
+ "auto refund cannot be after refund deadline": [
+ ""
+ ],
+ "Manage products in order": [
+ ""
+ ],
+ "Manage list of products in the order.": [
+ ""
+ ],
+ "Remove this product from the order.": [
+ ""
+ ],
+ "Total price": [
+ ""
+ ],
+ "total product price added up": [
+ ""
+ ],
+ "Amount to be paid by the customer": [
+ ""
+ ],
+ "Order price": [
+ ""
+ ],
+ "final order price": [
+ ""
+ ],
+ "Summary": [
+ ""
+ ],
+ "Title of the order to be shown to the customer": [
+ ""
+ ],
+ "Shipping and Fulfillment": [
+ ""
+ ],
+ "Delivery date": [
+ ""
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ ""
+ ],
+ "Location": [
+ ""
+ ],
+ "address where the products will be delivered": [
+ ""
+ ],
+ "Fulfillment URL": [
+ ""
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ ""
+ ],
+ "Taler payment options": [
+ ""
+ ],
+ "Override default Taler payment settings for this order": [
+ ""
+ ],
+ "Payment deadline": [
+ ""
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ ""
+ ],
+ "Refund deadline": [
+ ""
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ ""
+ ],
+ "Wire transfer deadline": [
+ ""
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ ""
+ ],
+ "Auto-refund deadline": [
+ ""
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ ""
+ ],
+ "Maximum deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ ""
+ ],
+ "Maximum wire fee": [
+ ""
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ ""
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ ""
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ ""
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ ""
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ ""
+ ],
+ "hours": [
+ ""
+ ],
+ "minutes": [
+ ""
+ ],
+ "seconds": [
+ ""
+ ],
+ "forever": [
+ ""
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ ""
+ ],
+ "create order": [
+ ""
+ ],
+ "load newer orders": [
+ ""
+ ],
+ "Date": [
+ ""
+ ],
+ "Refund": [
+ ""
+ ],
+ "copy url": [
+ ""
+ ],
+ "load older orders": [
+ ""
+ ],
+ "No orders have been found matching your query!": [
+ ""
+ ],
+ "duplicated": [
+ ""
+ ],
+ "invalid format": [
+ ""
+ ],
+ "this value exceed the refundable amount": [
+ ""
+ ],
+ "date": [
+ ""
+ ],
+ "amount": [
+ ""
+ ],
+ "reason": [
+ ""
+ ],
+ "amount to be refunded": [
+ ""
+ ],
+ "Max refundable:": [
+ ""
+ ],
+ "Reason": [
+ ""
+ ],
+ "Choose one...": [
+ ""
+ ],
+ "requested by the customer": [
+ ""
+ ],
+ "other": [
+ ""
+ ],
+ "why this order is being refunded": [
+ ""
+ ],
+ "more information to give context": [
+ ""
+ ],
+ "Contract Terms": [
+ ""
+ ],
+ "human-readable description of the whole purchase": [
+ ""
+ ],
+ "total price for the transaction": [
+ ""
+ ],
+ "URL for this purchase": [
+ ""
+ ],
+ "Max fee": [
+ ""
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ ""
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ ""
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ ""
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ ""
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ ""
+ ],
+ "claimed": [
+ ""
+ ],
+ "claimed at": [
+ ""
+ ],
+ "Timeline": [
+ ""
+ ],
+ "Payment details": [
+ ""
+ ],
+ "Order status": [
+ ""
+ ],
+ "Product list": [
+ ""
+ ],
+ "paid": [
+ ""
+ ],
+ "wired": [
+ ""
+ ],
+ "refunded": [
+ ""
+ ],
+ "refund order": [
+ ""
+ ],
+ "not refundable": [
+ ""
+ ],
+ "refund": [
+ ""
+ ],
+ "Refunded amount": [
+ ""
+ ],
+ "Refund taken": [
+ ""
+ ],
+ "Status URL": [
+ ""
+ ],
+ "Refund URI": [
+ ""
+ ],
+ "unpaid": [
+ ""
+ ],
+ "pay at": [
+ ""
+ ],
+ "created at": [
+ ""
+ ],
+ "Order status URL": [
+ ""
+ ],
+ "Payment URI": [
+ ""
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ ""
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ ""
+ ],
+ "could not create the refund": [
+ ""
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ ""
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ ""
+ ],
+ "only show orders with refunds": [
+ ""
+ ],
+ "Refunded": [
+ ""
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ ""
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ ""
+ ],
+ "order not found": [
+ ""
+ ],
+ "could not get the order to refund": [
+ ""
+ ],
+ "Loading...": [
+ ""
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ ""
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ ""
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ ""
+ ],
+ "Incoming": [
+ ""
+ ],
+ "Lost": [
+ ""
+ ],
+ "Current": [
+ ""
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ ""
+ ],
+ "Next restock": [
+ ""
+ ],
+ "Delivery address": [
+ ""
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ ""
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ ""
+ ],
+ "Products": [
+ ""
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ ""
+ ],
+ "Profit": [
+ ""
+ ],
+ "Sold": [
+ ""
+ ],
+ "free": [
+ ""
+ ],
+ "go to product update page": [
+ ""
+ ],
+ "Update": [
+ ""
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ ""
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ ""
+ ],
+ "product updated successfully": [
+ ""
+ ],
+ "could not update the product": [
+ ""
+ ],
+ "product delete successfully": [
+ ""
+ ],
+ "could not delete the product": [
+ ""
+ ],
+ "Product id:": [
+ ""
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ ""
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ ""
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ ""
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ ""
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ ""
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ ""
+ ],
+ "Valid until": [
+ ""
+ ],
+ "Created balance": [
+ ""
+ ],
+ "Exchange balance": [
+ ""
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ ""
+ ],
+ "Account address": [
+ ""
+ ],
+ "Subject": [
+ ""
+ ],
+ "Tips": [
+ ""
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ ""
+ ],
+ "Expiration": [
+ ""
+ ],
+ "amount of tip": [
+ ""
+ ],
+ "Justification": [
+ ""
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ ""
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ ""
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ ""
+ ],
+ "Expected Balance": [
+ ""
+ ],
+ "could not create the tip": [
+ ""
+ ],
+ "should not be empty": [
+ ""
+ ],
+ "should be greater that 0": [
+ ""
+ ],
+ "can't be empty": [
+ ""
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ ""
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ ""
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ ""
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ ""
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ ""
+ ],
+ "Amount is required": [
+ ""
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ ""
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ ""
+ ],
+ "could not create order from template": [
+ ""
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ ""
+ ],
+ "Default amount": [
+ ""
+ ],
+ "Default summary": [
+ ""
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ ""
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ ""
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ ""
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ ""
+ ],
+ "template delete successfully": [
+ ""
+ ],
+ "could not delete the template": [
+ ""
+ ],
+ "could not update template": [
+ ""
+ ],
+ "should be one of '%1$s'": [
+ ""
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ ""
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ ""
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ ""
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ ""
+ ],
+ "webhook delete successfully": [
+ ""
+ ],
+ "could not delete the webhook": [
+ ""
+ ],
+ "check the id, does not look valid": [
+ ""
+ ],
+ "should have 52 characters, current %1$s": [
+ ""
+ ],
+ "URL doesn't have the right format": [
+ ""
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ ""
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ ""
+ ],
+ "Transfers": [
+ ""
+ ],
+ "add new transfer": [
+ ""
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ ""
+ ],
+ "Credit": [
+ ""
+ ],
+ "Confirmed": [
+ ""
+ ],
+ "Verified": [
+ ""
+ ],
+ "Executed at": [
+ ""
+ ],
+ "yes": [
+ ""
+ ],
+ "no": [
+ ""
+ ],
+ "unknown": [
+ ""
+ ],
+ "delete selected transfer from the database": [
+ ""
+ ],
+ "load more transfer after the last one": [
+ ""
+ ],
+ "load older transfers": [
+ ""
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ ""
+ ],
+ "filter by account address": [
+ ""
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ ""
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ ""
+ ],
+ "must be 1 or greater": [
+ ""
+ ],
+ "max 7 lines": [
+ ""
+ ],
+ "change authorization configuration": [
+ ""
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ ""
+ ],
+ "This is not a valid bitcoin address.": [
+ ""
+ ],
+ "This is not a valid Ethereum address.": [
+ ""
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ ""
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ ""
+ ],
+ "IBAN country code not found": [
+ ""
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ ""
+ ],
+ "Target type": [
+ ""
+ ],
+ "Method to use for wire transfer": [
+ ""
+ ],
+ "Routing": [
+ ""
+ ],
+ "Routing number.": [
+ ""
+ ],
+ "Account": [
+ ""
+ ],
+ "Account number.": [
+ ""
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ ""
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ ""
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ ""
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ ""
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ ""
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ ""
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ ""
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ ""
+ ],
+ "Default payment delay": [
+ ""
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ ""
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ ""
+ ],
+ "Change the authorization method use for this instance.": [
+ ""
+ ],
+ "Manage access token": [
+ ""
+ ],
+ "Failed to create instance": [
+ ""
+ ],
+ "Login required": [
+ ""
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ ""
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ ""
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ ""
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ ""
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ ""
+ ],
+ "Settings": [
+ ""
+ ],
+ "Connection": [
+ ""
+ ],
+ "New": [
+ ""
+ ],
+ "List": [
+ ""
+ ],
+ "Log out": [
+ ""
+ ],
+ "Check your token is valid": [
+ ""
+ ],
+ "Couldn't access the server.": [
+ ""
+ ],
+ "Could not infer instance id from url %1$s": [
+ ""
+ ],
+ "Server not found": [
+ ""
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ ""
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ ""
+ ],
+ "The value %1$s is invalid for a payment url": [
+ ""
+ ],
+ "add element to the list": [
+ ""
+ ],
+ "add": [
+ ""
+ ],
+ "Deleting": [
+ ""
+ ],
+ "Changing": [
+ ""
+ ],
+ "Order ID": [
+ ""
+ ],
+ "Payment URL": [
+ ""
+ ]
+ }
+ }
+};
+
+strings['sv'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=(n != 1);",
+ "lang": ""
+ },
+ "Cancel": [
+ ""
+ ],
+ "%1$s": [
+ ""
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ ""
+ ],
+ "Clear": [
+ ""
+ ],
+ "Confirm": [
+ ""
+ ],
+ "is not the same as the current access token": [
+ ""
+ ],
+ "cannot be empty": [
+ ""
+ ],
+ "cannot be the same as the old token": [
+ ""
+ ],
+ "is not the same": [
+ ""
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ ""
+ ],
+ "Old access token": [
+ ""
+ ],
+ "access token currently in use": [
+ ""
+ ],
+ "New access token": [
+ ""
+ ],
+ "next access token to be used": [
+ ""
+ ],
+ "Repeat access token": [
+ ""
+ ],
+ "confirm the same access token": [
+ ""
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ ""
+ ],
+ "cannot be the same as the old access token": [
+ ""
+ ],
+ "You are setting the access token for the new instance": [
+ ""
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ ""
+ ],
+ "Set external authorization": [
+ ""
+ ],
+ "Set access token": [
+ ""
+ ],
+ "Operation in progress...": [
+ ""
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ ""
+ ],
+ "Instances": [
+ ""
+ ],
+ "Delete": [
+ ""
+ ],
+ "add new instance": [
+ ""
+ ],
+ "ID": [
+ ""
+ ],
+ "Name": [
+ ""
+ ],
+ "Edit": [
+ ""
+ ],
+ "Purge": [
+ ""
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ ""
+ ],
+ "Only show active instances": [
+ ""
+ ],
+ "Active": [
+ ""
+ ],
+ "Only show deleted instances": [
+ ""
+ ],
+ "Deleted": [
+ ""
+ ],
+ "Show all instances": [
+ ""
+ ],
+ "All": [
+ ""
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ ""
+ ],
+ "Failed to delete instance": [
+ ""
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ ""
+ ],
+ "Failed to purge instance": [
+ ""
+ ],
+ "Pending KYC verification": [
+ ""
+ ],
+ "Timed out": [
+ ""
+ ],
+ "Exchange": [
+ ""
+ ],
+ "Target account": [
+ ""
+ ],
+ "KYC URL": [
+ ""
+ ],
+ "Code": [
+ ""
+ ],
+ "Http Status": [
+ ""
+ ],
+ "No pending kyc verification!": [
+ ""
+ ],
+ "change value to unknown date": [
+ ""
+ ],
+ "change value to empty": [
+ ""
+ ],
+ "clear": [
+ ""
+ ],
+ "change value to never": [
+ ""
+ ],
+ "never": [
+ ""
+ ],
+ "Country": [
+ ""
+ ],
+ "Address": [
+ ""
+ ],
+ "Building number": [
+ ""
+ ],
+ "Building name": [
+ ""
+ ],
+ "Street": [
+ ""
+ ],
+ "Post code": [
+ ""
+ ],
+ "Town location": [
+ ""
+ ],
+ "Town": [
+ ""
+ ],
+ "District": [
+ ""
+ ],
+ "Country subdivision": [
+ ""
+ ],
+ "Product id": [
+ ""
+ ],
+ "Description": [
+ ""
+ ],
+ "Product": [
+ ""
+ ],
+ "search products by it's description or id": [
+ ""
+ ],
+ "no products found with that description": [
+ ""
+ ],
+ "You must enter a valid product identifier.": [
+ ""
+ ],
+ "Quantity must be greater than 0!": [
+ ""
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ ""
+ ],
+ "Quantity": [
+ ""
+ ],
+ "how many products will be added": [
+ ""
+ ],
+ "Add from inventory": [
+ ""
+ ],
+ "Image should be smaller than 1 MB": [
+ ""
+ ],
+ "Add": [
+ ""
+ ],
+ "Remove": [
+ ""
+ ],
+ "No taxes configured for this product.": [
+ ""
+ ],
+ "Amount": [
+ ""
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ ""
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ ""
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ ""
+ ],
+ "add tax to the tax list": [
+ ""
+ ],
+ "describe and add a product that is not in the inventory list": [
+ ""
+ ],
+ "Add custom product": [
+ ""
+ ],
+ "Complete information of the product": [
+ ""
+ ],
+ "Image": [
+ ""
+ ],
+ "photo of the product": [
+ ""
+ ],
+ "full product description": [
+ ""
+ ],
+ "Unit": [
+ ""
+ ],
+ "name of the product unit": [
+ ""
+ ],
+ "Price": [
+ ""
+ ],
+ "amount in the current currency": [
+ ""
+ ],
+ "Taxes": [
+ ""
+ ],
+ "image": [
+ ""
+ ],
+ "description": [
+ ""
+ ],
+ "quantity": [
+ ""
+ ],
+ "unit price": [
+ ""
+ ],
+ "total price": [
+ ""
+ ],
+ "required": [
+ ""
+ ],
+ "not valid": [
+ ""
+ ],
+ "must be greater than 0": [
+ ""
+ ],
+ "not a valid json": [
+ ""
+ ],
+ "should be in the future": [
+ ""
+ ],
+ "refund deadline cannot be before pay deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ ""
+ ],
+ "should have a refund deadline": [
+ ""
+ ],
+ "auto refund cannot be after refund deadline": [
+ ""
+ ],
+ "Manage products in order": [
+ ""
+ ],
+ "Manage list of products in the order.": [
+ ""
+ ],
+ "Remove this product from the order.": [
+ ""
+ ],
+ "Total price": [
+ ""
+ ],
+ "total product price added up": [
+ ""
+ ],
+ "Amount to be paid by the customer": [
+ ""
+ ],
+ "Order price": [
+ ""
+ ],
+ "final order price": [
+ ""
+ ],
+ "Summary": [
+ ""
+ ],
+ "Title of the order to be shown to the customer": [
+ ""
+ ],
+ "Shipping and Fulfillment": [
+ ""
+ ],
+ "Delivery date": [
+ ""
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ ""
+ ],
+ "Location": [
+ ""
+ ],
+ "address where the products will be delivered": [
+ ""
+ ],
+ "Fulfillment URL": [
+ ""
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ ""
+ ],
+ "Taler payment options": [
+ ""
+ ],
+ "Override default Taler payment settings for this order": [
+ ""
+ ],
+ "Payment deadline": [
+ ""
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ ""
+ ],
+ "Refund deadline": [
+ ""
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ ""
+ ],
+ "Wire transfer deadline": [
+ ""
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ ""
+ ],
+ "Auto-refund deadline": [
+ ""
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ ""
+ ],
+ "Maximum deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ ""
+ ],
+ "Maximum wire fee": [
+ ""
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ ""
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ ""
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ ""
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ ""
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ ""
+ ],
+ "hours": [
+ ""
+ ],
+ "minutes": [
+ ""
+ ],
+ "seconds": [
+ ""
+ ],
+ "forever": [
+ ""
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ ""
+ ],
+ "create order": [
+ ""
+ ],
+ "load newer orders": [
+ ""
+ ],
+ "Date": [
+ ""
+ ],
+ "Refund": [
+ ""
+ ],
+ "copy url": [
+ ""
+ ],
+ "load older orders": [
+ ""
+ ],
+ "No orders have been found matching your query!": [
+ ""
+ ],
+ "duplicated": [
+ ""
+ ],
+ "invalid format": [
+ ""
+ ],
+ "this value exceed the refundable amount": [
+ ""
+ ],
+ "date": [
+ ""
+ ],
+ "amount": [
+ ""
+ ],
+ "reason": [
+ ""
+ ],
+ "amount to be refunded": [
+ ""
+ ],
+ "Max refundable:": [
+ ""
+ ],
+ "Reason": [
+ ""
+ ],
+ "Choose one...": [
+ ""
+ ],
+ "requested by the customer": [
+ ""
+ ],
+ "other": [
+ ""
+ ],
+ "why this order is being refunded": [
+ ""
+ ],
+ "more information to give context": [
+ ""
+ ],
+ "Contract Terms": [
+ ""
+ ],
+ "human-readable description of the whole purchase": [
+ ""
+ ],
+ "total price for the transaction": [
+ ""
+ ],
+ "URL for this purchase": [
+ ""
+ ],
+ "Max fee": [
+ ""
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ ""
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ ""
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ ""
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ ""
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ ""
+ ],
+ "claimed": [
+ ""
+ ],
+ "claimed at": [
+ ""
+ ],
+ "Timeline": [
+ ""
+ ],
+ "Payment details": [
+ ""
+ ],
+ "Order status": [
+ ""
+ ],
+ "Product list": [
+ ""
+ ],
+ "paid": [
+ ""
+ ],
+ "wired": [
+ ""
+ ],
+ "refunded": [
+ ""
+ ],
+ "refund order": [
+ ""
+ ],
+ "not refundable": [
+ ""
+ ],
+ "refund": [
+ ""
+ ],
+ "Refunded amount": [
+ ""
+ ],
+ "Refund taken": [
+ ""
+ ],
+ "Status URL": [
+ ""
+ ],
+ "Refund URI": [
+ ""
+ ],
+ "unpaid": [
+ ""
+ ],
+ "pay at": [
+ ""
+ ],
+ "created at": [
+ ""
+ ],
+ "Order status URL": [
+ ""
+ ],
+ "Payment URI": [
+ ""
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ ""
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ ""
+ ],
+ "could not create the refund": [
+ ""
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ ""
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ ""
+ ],
+ "only show orders with refunds": [
+ ""
+ ],
+ "Refunded": [
+ ""
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ ""
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ ""
+ ],
+ "order not found": [
+ ""
+ ],
+ "could not get the order to refund": [
+ ""
+ ],
+ "Loading...": [
+ ""
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ ""
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ ""
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ ""
+ ],
+ "Incoming": [
+ ""
+ ],
+ "Lost": [
+ ""
+ ],
+ "Current": [
+ ""
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ ""
+ ],
+ "Next restock": [
+ ""
+ ],
+ "Delivery address": [
+ ""
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ ""
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ ""
+ ],
+ "Products": [
+ ""
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ ""
+ ],
+ "Profit": [
+ ""
+ ],
+ "Sold": [
+ ""
+ ],
+ "free": [
+ ""
+ ],
+ "go to product update page": [
+ ""
+ ],
+ "Update": [
+ ""
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ ""
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ ""
+ ],
+ "product updated successfully": [
+ ""
+ ],
+ "could not update the product": [
+ ""
+ ],
+ "product delete successfully": [
+ ""
+ ],
+ "could not delete the product": [
+ ""
+ ],
+ "Product id:": [
+ ""
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ ""
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ ""
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ ""
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ ""
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ ""
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ ""
+ ],
+ "Valid until": [
+ ""
+ ],
+ "Created balance": [
+ ""
+ ],
+ "Exchange balance": [
+ ""
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ ""
+ ],
+ "Account address": [
+ ""
+ ],
+ "Subject": [
+ ""
+ ],
+ "Tips": [
+ ""
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ ""
+ ],
+ "Expiration": [
+ ""
+ ],
+ "amount of tip": [
+ ""
+ ],
+ "Justification": [
+ ""
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ ""
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ ""
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ ""
+ ],
+ "Expected Balance": [
+ ""
+ ],
+ "could not create the tip": [
+ ""
+ ],
+ "should not be empty": [
+ ""
+ ],
+ "should be greater that 0": [
+ ""
+ ],
+ "can't be empty": [
+ ""
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ ""
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ ""
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ ""
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ ""
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ ""
+ ],
+ "Amount is required": [
+ ""
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ ""
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ ""
+ ],
+ "could not create order from template": [
+ ""
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ ""
+ ],
+ "Default amount": [
+ ""
+ ],
+ "Default summary": [
+ ""
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ ""
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ ""
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ ""
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ ""
+ ],
+ "template delete successfully": [
+ ""
+ ],
+ "could not delete the template": [
+ ""
+ ],
+ "could not update template": [
+ ""
+ ],
+ "should be one of '%1$s'": [
+ ""
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ ""
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ ""
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ ""
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ ""
+ ],
+ "webhook delete successfully": [
+ ""
+ ],
+ "could not delete the webhook": [
+ ""
+ ],
+ "check the id, does not look valid": [
+ ""
+ ],
+ "should have 52 characters, current %1$s": [
+ ""
+ ],
+ "URL doesn't have the right format": [
+ ""
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ ""
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ ""
+ ],
+ "Transfers": [
+ ""
+ ],
+ "add new transfer": [
+ ""
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ ""
+ ],
+ "Credit": [
+ ""
+ ],
+ "Confirmed": [
+ ""
+ ],
+ "Verified": [
+ ""
+ ],
+ "Executed at": [
+ ""
+ ],
+ "yes": [
+ ""
+ ],
+ "no": [
+ ""
+ ],
+ "unknown": [
+ ""
+ ],
+ "delete selected transfer from the database": [
+ ""
+ ],
+ "load more transfer after the last one": [
+ ""
+ ],
+ "load older transfers": [
+ ""
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ ""
+ ],
+ "filter by account address": [
+ ""
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ ""
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ ""
+ ],
+ "must be 1 or greater": [
+ ""
+ ],
+ "max 7 lines": [
+ ""
+ ],
+ "change authorization configuration": [
+ ""
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ ""
+ ],
+ "This is not a valid bitcoin address.": [
+ ""
+ ],
+ "This is not a valid Ethereum address.": [
+ ""
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ ""
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ ""
+ ],
+ "IBAN country code not found": [
+ ""
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ ""
+ ],
+ "Target type": [
+ ""
+ ],
+ "Method to use for wire transfer": [
+ ""
+ ],
+ "Routing": [
+ ""
+ ],
+ "Routing number.": [
+ ""
+ ],
+ "Account": [
+ ""
+ ],
+ "Account number.": [
+ ""
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ ""
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ ""
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ ""
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ ""
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ ""
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ ""
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ ""
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ ""
+ ],
+ "Default payment delay": [
+ ""
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ ""
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ ""
+ ],
+ "Change the authorization method use for this instance.": [
+ ""
+ ],
+ "Manage access token": [
+ ""
+ ],
+ "Failed to create instance": [
+ ""
+ ],
+ "Login required": [
+ ""
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ ""
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ ""
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ ""
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ ""
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ ""
+ ],
+ "Settings": [
+ ""
+ ],
+ "Connection": [
+ ""
+ ],
+ "New": [
+ ""
+ ],
+ "List": [
+ ""
+ ],
+ "Log out": [
+ ""
+ ],
+ "Check your token is valid": [
+ ""
+ ],
+ "Couldn't access the server.": [
+ ""
+ ],
+ "Could not infer instance id from url %1$s": [
+ ""
+ ],
+ "Server not found": [
+ ""
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ ""
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ ""
+ ],
+ "The value %1$s is invalid for a payment url": [
+ ""
+ ],
+ "add element to the list": [
+ ""
+ ],
+ "add": [
+ ""
+ ],
+ "Deleting": [
+ ""
+ ],
+ "Changing": [
+ ""
+ ],
+ "Order ID": [
+ ""
+ ],
+ "Payment URL": [
+ ""
+ ]
+ }
+ }
+};
+
diff --git a/packages/auditor-backoffice-ui/src/i18n/sv.po b/packages/auditor-backoffice-ui/src/i18n/sv.po
new file mode 100644
index 000000000..d8d0bae29
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/i18n/sv.po
@@ -0,0 +1,2741 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
diff --git a/packages/auditor-backoffice-ui/src/i18n/taler-merchant-backoffice.pot b/packages/auditor-backoffice-ui/src/i18n/taler-merchant-backoffice.pot
new file mode 100644
index 000000000..5ef56ca05
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/i18n/taler-merchant-backoffice.pot
@@ -0,0 +1,2726 @@
+# This file is part of GNU Taler
+# (C) 2021-2023 Taler Systems S.A.
+
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid "With external authorization method no check will be done by the merchant backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without user "
+"interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to determine "
+"the share of excess wire fees to be paid explicitly by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with enough "
+"entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this contract. "
+"If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize wire "
+"fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid "after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid "how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid "Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment provider "
+"are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the backend "
+"will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 "
+"meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid "sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid "product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to the "
+"indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid "There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the wire "
+"transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it is "
+"used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid "Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid "Maximum wire fees this merchant is willing to pay per wire transfer by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid "Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
+
diff --git a/packages/auditor-backoffice-ui/src/index.html b/packages/auditor-backoffice-ui/src/index.html
new file mode 100644
index 000000000..c73dd1936
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/index.html
@@ -0,0 +1,45 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html
+ lang="en"
+ class="has-aside-left has-aside-mobile-transition has-navbar-fixed-top has-aside-expanded"
+>
+ <head>
+ <meta charset="utf-8" />
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+
+ <link
+ rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
+ />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <title>Auditor Backoffice</title>
+ <!-- Optional customization script. -->
+ <script src="auditor-backoffice-ui-settings.js"></script>
+ <!-- Entry point for the SPA. -->
+ <script type="module" src="index.js"></script>
+ <link rel="stylesheet" href="index.css" />
+ </head>
+ <body>
+ <div id="app"></div>
+ </body>
+</html>
diff --git a/packages/auditor-backoffice-ui/src/index.tsx b/packages/auditor-backoffice-ui/src/index.tsx
new file mode 100644
index 000000000..7fdf7c1c3
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/index.tsx
@@ -0,0 +1,24 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Application } from "./Application.js";
+
+import { h, render } from "preact";
+import "./scss/main.scss";
+
+const app = document.getElementById("app");
+
+render(<Application />, app as any);
diff --git a/packages/auditor-backoffice-ui/src/paths/admin/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/admin/create/Create.stories.tsx
new file mode 100644
index 000000000..91b6b4b56
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/admin/create/Create.stories.tsx
@@ -0,0 +1,57 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { ConfigContextProvider } from "../../../context/config.js";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Instance/Create",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ goBack: { action: "goBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => (
+ <ConfigContextProvider
+ value={{
+ currency: "ARS",
+ version: "1",
+ }}
+ >
+ <Component {...args} />
+ </ConfigContextProvider>
+ );
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {});
+// export const Example = (a: any): VNode => <CreatePage {...a} />;
+// Example.args = {
+// isLoading: false
+// }
diff --git a/packages/auditor-backoffice-ui/src/paths/admin/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/admin/create/CreatePage.tsx
new file mode 100644
index 000000000..d13b7e929
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/admin/create/CreatePage.tsx
@@ -0,0 +1,257 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../components/form/FormProvider.js";
+import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js";
+import { MerchantBackend } from "../../../declaration.js";
+import { INSTANCE_ID_REGEX } from "../../../utils/constants.js";
+import { undefinedIfEmpty } from "../../../utils/table.js";
+import { SetTokenNewInstanceModal } from "../../../components/modal/index.js";
+import { Duration } from "@gnu-taler/taler-util";
+
+export type Entity = Omit<Omit<MerchantBackend.Instances.InstanceConfigurationMessage, "default_pay_delay">, "default_wire_transfer_delay"> & {
+ auth_token?: string;
+ default_pay_delay: Duration,
+ default_wire_transfer_delay: Duration,
+};
+
+interface Props {
+ onCreate: (d: MerchantBackend.Instances.InstanceConfigurationMessage) => Promise<void>;
+ onBack?: () => void;
+ forceId?: string;
+}
+
+function with_defaults(id?: string): Partial<Entity> {
+ return {
+ id,
+ // accounts: [],
+ user_type: "business",
+ use_stefan: true,
+ default_pay_delay: { d_ms: 2 * 60 * 60 * 1000 }, // two hours
+ default_wire_transfer_delay: { d_ms: 2 * 60 * 60 * 24 * 1000 }, // two days
+ };
+}
+
+export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
+ const [value, valueHandler] = useState(with_defaults(forceId));
+ const [isTokenSet, updateIsTokenSet] = useState<boolean>(false);
+ const [isTokenDialogActive, updateIsTokenDialogActive] =
+ useState<boolean>(false);
+
+ const { i18n } = useTranslationContext();
+
+ const errors: FormErrors<Entity> = {
+ id: !value.id
+ ? i18n.str`required`
+ : !INSTANCE_ID_REGEX.test(value.id)
+ ? i18n.str`is not valid`
+ : undefined,
+ name: !value.name ? i18n.str`required` : undefined,
+
+ user_type: !value.user_type
+ ? i18n.str`required`
+ : value.user_type !== "business" && value.user_type !== "individual"
+ ? i18n.str`should be business or individual`
+ : undefined,
+ // accounts:
+ // !value.accounts || !value.accounts.length
+ // ? i18n.str`required`
+ // : undefinedIfEmpty(
+ // value.accounts.map((p) => {
+ // return !PAYTO_REGEX.test(p.payto_uri)
+ // ? i18n.str`is not valid`
+ // : undefined;
+ // }),
+ // ),
+ default_pay_delay: !value.default_pay_delay
+ ? i18n.str`required`
+ : !!value.default_wire_transfer_delay &&
+ value.default_wire_transfer_delay.d_ms !== "forever" &&
+ value.default_pay_delay.d_ms !== "forever" &&
+ value.default_pay_delay.d_ms > value.default_wire_transfer_delay.d_ms ?
+ i18n.str`pay delay can't be greater than wire transfer delay` : undefined,
+ default_wire_transfer_delay: !value.default_wire_transfer_delay
+ ? i18n.str`required`
+ : undefined,
+ address: undefinedIfEmpty({
+ address_lines:
+ value.address?.address_lines && value.address?.address_lines.length > 7
+ ? i18n.str`max 7 lines`
+ : undefined,
+ }),
+ jurisdiction: undefinedIfEmpty({
+ address_lines:
+ value.address?.address_lines && value.address?.address_lines.length > 7
+ ? i18n.str`max 7 lines`
+ : undefined,
+ }),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submit = (): Promise<void> => {
+ // use conversion instead of this
+ const newToken = value.auth_token;
+ value.auth_token = undefined;
+ value.auth = newToken === null || newToken === undefined
+ ? { method: "external" }
+ : { method: "token", token: `secret-token:${newToken}` };
+ if (!value.address) value.address = {};
+ if (!value.jurisdiction) value.jurisdiction = {};
+ // remove above use conversion
+ // schema.validateSync(value, { abortEarly: false })
+ value.default_pay_delay = Duration.toTalerProtocolDuration(value.default_pay_delay!) as any
+ value.default_wire_transfer_delay = Duration.toTalerProtocolDuration(value.default_wire_transfer_delay!) as any
+ // delete value.default_pay_delay;
+ // delete value.default_wire_transfer_delay;
+
+ return onCreate(value as any as MerchantBackend.Instances.InstanceConfigurationMessage);
+ };
+
+ function updateToken(token: string | null) {
+ valueHandler((old) => ({
+ ...old,
+ auth_token: token === null ? undefined : token,
+ }));
+ }
+
+ return (
+ <div>
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ {isTokenDialogActive && (
+ <SetTokenNewInstanceModal
+ onCancel={() => {
+ updateIsTokenDialogActive(false);
+ updateIsTokenSet(false);
+ }}
+ onClear={() => {
+ updateToken(null);
+ updateIsTokenDialogActive(false);
+ updateIsTokenSet(true);
+ }}
+ onConfirm={(newToken) => {
+ updateToken(newToken);
+ updateIsTokenDialogActive(false);
+ updateIsTokenSet(true);
+ }}
+ />
+ )}
+ </div>
+ <div class="column" />
+ </div>
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider<Entity>
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <DefaultInstanceFormFields readonlyId={!!forceId} showId={true} />
+ </FormProvider>
+
+ <div class="level">
+ <div class="level-item has-text-centered">
+ <h1 class="title">
+ <button
+ class={
+ !isTokenSet
+ ? "button is-danger has-tooltip-bottom"
+ : !value.auth_token
+ ? "button has-tooltip-bottom"
+ : "button is-info has-tooltip-bottom"
+ }
+ data-tooltip={i18n.str`change authorization configuration`}
+ onClick={() => updateIsTokenDialogActive(true)}
+ >
+ <div class="icon is-centered">
+ <i class="mdi mdi-lock-reset" />
+ </div>
+ <span>
+ <i18n.Translate>Set access token</i18n.Translate>
+ </span>
+ </button>
+ </h1>
+ </div>
+ </div>
+ <div class="level">
+ <div class="level-item has-text-centered">
+ {!isTokenSet ? (
+ <p class="is-size-6">
+ <i18n.Translate>
+ Access token is not yet configured. This instance can't be
+ created.
+ </i18n.Translate>
+ </p>
+ ) : value.auth_token === undefined ? (
+ <p class="is-size-6">
+ <i18n.Translate>
+ No access token. Authorization must be handled externally.
+ </i18n.Translate>
+ </p>
+ ) : (
+ <p class="is-size-6">
+ <i18n.Translate>
+ Access token is set. Authorization is handled by the
+ merchant backend.
+ </i18n.Translate>
+ </p>
+ )}
+ </div>
+ </div>
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ onClick={submit}
+ disabled={hasErrors || !isTokenSet}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields and choose authorization method`
+ : "confirm operation"
+ }
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx
new file mode 100644
index 000000000..c620c6482
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx
@@ -0,0 +1,74 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { CreatedSuccessfully } from "../../../components/notifications/CreatedSuccessfully.js";
+import { Entity } from "./index.js";
+
+export function InstanceCreatedSuccessfully({
+ entity,
+ onConfirm,
+}: {
+ entity: Entity;
+ onConfirm: () => void;
+}): VNode {
+ return (
+ <CreatedSuccessfully onConfirm={onConfirm}>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">ID</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input" readonly value={entity.id} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Business Name</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input" readonly value={entity.name} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Access token</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ {entity.auth.method === "external" && "external"}
+ {entity.auth.method === "token" && (
+ <input class="input" readonly value={entity.auth.token} />
+ )}
+ </p>
+ </div>
+ </div>
+ </div>
+ </CreatedSuccessfully>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/admin/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/admin/create/index.tsx
new file mode 100644
index 000000000..23f41ecff
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/admin/create/index.tsx
@@ -0,0 +1,82 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../components/menu/index.js";
+import { AccessToken, MerchantBackend } from "../../../declaration.js";
+import { useAdminAPI, useInstanceAPI } from "../../../hooks/instance.js";
+import { Notification } from "../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+import { useCredentialsChecker } from "../../../hooks/backend.js";
+import { useBackendContext } from "../../../context/backend.js";
+
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ forceId?: string;
+}
+export type Entity = MerchantBackend.Instances.InstanceConfigurationMessage;
+
+export default function Create({ onBack, onConfirm, forceId }: Props): VNode {
+ const { createInstance } = useAdminAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const { requestNewLoginToken } = useCredentialsChecker()
+ const { url: backendURL, updateToken } = useBackendContext()
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <CreatePage
+ onBack={onBack}
+ forceId={forceId}
+ onCreate={async (
+ d: MerchantBackend.Instances.InstanceConfigurationMessage,
+ ) => {
+ try {
+ await createInstance(d)
+ if (d.auth.token) {
+ const resp = await requestNewLoginToken(backendURL, d.auth.token as AccessToken)
+ if (resp.valid) {
+ const { token, expiration } = resp
+ updateToken({ token, expiration });
+ } else {
+ updateToken(undefined)
+ }
+ }
+ onConfirm();
+ } catch (ex) {
+ if (ex instanceof Error) {
+ setNotif({
+ message: i18n.str`Failed to create instance`,
+ type: "ERROR",
+ description: ex.message,
+ });
+ } else {
+ console.error(ex)
+ }
+ }
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/admin/create/stories.tsx b/packages/auditor-backoffice-ui/src/paths/admin/create/stories.tsx
new file mode 100644
index 000000000..0012f9b9b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/admin/create/stories.tsx
@@ -0,0 +1,52 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { ConfigContextProvider } from "../../../context/config.js";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Instance/Create",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ goBack: { action: "goBack" },
+ },
+};
+
+function createExample<Props>(
+ Internal: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const component = (args: any) => (
+ <ConfigContextProvider
+ value={{
+ currency: "TESTKUDOS",
+ version: "1",
+ }}
+ >
+ <Internal {...(props as any)} />
+ </ConfigContextProvider>
+ );
+ return { component, props };
+}
+
+export const Example = createExample(TestedComponent, {});
diff --git a/packages/auditor-backoffice-ui/src/paths/admin/index.stories.ts b/packages/auditor-backoffice-ui/src/paths/admin/index.stories.ts
new file mode 100644
index 000000000..fdae1a24d
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/admin/index.stories.ts
@@ -0,0 +1,18 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+// export * as list from "./list/stories.js";
+export * as create from "./create/stories.js";
diff --git a/packages/auditor-backoffice-ui/src/paths/admin/list/TableActive.tsx b/packages/auditor-backoffice-ui/src/paths/admin/list/TableActive.tsx
new file mode 100644
index 000000000..885a351d2
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/admin/list/TableActive.tsx
@@ -0,0 +1,287 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { StateUpdater, useEffect, useState } from "preact/hooks";
+import { MerchantBackend } from "../../../declaration.js";
+
+interface Props {
+ instances: MerchantBackend.Instances.Instance[];
+ onUpdate: (id: string) => void;
+ onDelete: (id: MerchantBackend.Instances.Instance) => void;
+ onPurge: (id: MerchantBackend.Instances.Instance) => void;
+ onCreate: () => void;
+ selected?: boolean;
+ setInstanceName: (s: string) => void;
+}
+
+export function CardTable({
+ instances,
+ onCreate,
+ onUpdate,
+ onPurge,
+ setInstanceName,
+ onDelete,
+ selected,
+}: Props): VNode {
+ const [actionQueue, actionQueueHandler] = useState<Actions[]>([]);
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ useEffect(() => {
+ if (
+ actionQueue.length > 0 &&
+ !selected &&
+ actionQueue[0].type == "DELETE"
+ ) {
+ onDelete(actionQueue[0].element);
+ actionQueueHandler(actionQueue.slice(1));
+ }
+ }, [actionQueue, selected, onDelete]);
+
+ useEffect(() => {
+ if (
+ actionQueue.length > 0 &&
+ !selected &&
+ actionQueue[0].type == "UPDATE"
+ ) {
+ onUpdate(actionQueue[0].element.id);
+ actionQueueHandler(actionQueue.slice(1));
+ }
+ }, [actionQueue, selected, onUpdate]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-desktop-mac" />
+ </span>
+ <i18n.Translate>Instances</i18n.Translate>
+ </p>
+
+ <div class="card-header-icon" aria-label="more options">
+ <button
+ class={rowSelection.length > 0 ? "button is-danger" : "is-hidden"}
+ type="button"
+ onClick={(): void =>
+ actionQueueHandler(
+ buildActions(instances, rowSelection, "DELETE"),
+ )
+ }
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </button>
+ </div>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new instance`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {instances.length > 0 ? (
+ <Table
+ instances={instances}
+ onPurge={onPurge}
+ onUpdate={onUpdate}
+ setInstanceName={setInstanceName}
+ onDelete={onDelete}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: MerchantBackend.Instances.Instance[];
+ onUpdate: (id: string) => void;
+ onDelete: (id: MerchantBackend.Instances.Instance) => void;
+ onPurge: (id: MerchantBackend.Instances.Instance) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ setInstanceName: (s: string) => void;
+}
+
+function toggleSelected<T>(id: T): (prev: T[]) => T[] {
+ return (prev: T[]): T[] =>
+ prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id);
+}
+
+function Table({
+ rowSelection,
+ rowSelectionHandler,
+ setInstanceName,
+ instances,
+ onUpdate,
+ onDelete,
+ onPurge,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th class="is-checkbox-cell">
+ <label class="b-checkbox checkbox">
+ <input
+ type="checkbox"
+ checked={rowSelection.length === instances.length}
+ onClick={(): void =>
+ rowSelectionHandler(
+ rowSelection.length === instances.length
+ ? []
+ : instances.map((i) => i.id),
+ )
+ }
+ />
+ <span class="check" />
+ </label>
+ </th>
+ <th>
+ <i18n.Translate>ID</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Name</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.id}>
+ <td class="is-checkbox-cell">
+ <label class="b-checkbox checkbox">
+ <input
+ type="checkbox"
+ checked={rowSelection.indexOf(i.id) != -1}
+ onClick={(): void =>
+ rowSelectionHandler(toggleSelected(i.id))
+ }
+ />
+ <span class="check" />
+ </label>
+ </td>
+ <td>
+ <a
+ href={`#/orders?instance=${i.id}`}
+ onClick={(e) => {
+ setInstanceName(i.id);
+ }}
+ >
+ {i.id}
+ </a>
+ </td>
+ <td>{i.name}</td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-small is-success jb-modal"
+ type="button"
+ onClick={(): void => onUpdate(i.id)}
+ >
+ <i18n.Translate>Edit</i18n.Translate>
+ </button>
+ {!i.deleted && (
+ <button
+ class="button is-small is-danger jb-modal is-outlined"
+ type="button"
+ onClick={(): void => onDelete(i)}
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </button>
+ )}
+ {i.deleted && (
+ <button
+ class="button is-small is-danger jb-modal"
+ type="button"
+ onClick={(): void => onPurge(i)}
+ >
+ <i18n.Translate>Purge</i18n.Translate>
+ </button>
+ )}
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no instances yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
+
+interface Actions {
+ element: MerchantBackend.Instances.Instance;
+ type: "DELETE" | "UPDATE";
+}
+
+function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
+ return value !== null && value !== undefined;
+}
+
+function buildActions(
+ instances: MerchantBackend.Instances.Instance[],
+ selected: string[],
+ action: "DELETE",
+): Actions[] {
+ return selected
+ .map((id) => instances.find((i) => i.id === id))
+ .filter(notEmpty)
+ .map((id) => ({ element: id, type: action }));
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/admin/list/View.stories.tsx b/packages/auditor-backoffice-ui/src/paths/admin/list/View.stories.tsx
new file mode 100644
index 000000000..e0f5d5430
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/admin/list/View.stories.tsx
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h } from "preact";
+import { View } from "./View.js";
+
+export default {
+ title: "Pages/Instance/List",
+ component: View,
+ argTypes: {
+ onSelect: { action: "onSelect" },
+ },
+};
+
+export const Empty = (a: any) => <View {...a} />;
+Empty.args = {
+ instances: [],
+};
+
+export const WithDefaultInstance = (a: any) => <View {...a} />;
+WithDefaultInstance.args = {
+ instances: [
+ {
+ id: "default",
+ name: "the default instance",
+ merchant_pub: "abcdef",
+ payment_targets: [],
+ },
+ ],
+};
+
+export const WithFiveInstance = (a: any) => <View {...a} />;
+WithFiveInstance.args = {
+ instances: [
+ {
+ id: "first",
+ name: "the first instance",
+ merchant_pub: "abcdefgh",
+ payment_targets: ["asd"],
+ },
+ {
+ id: "second",
+ name: "the second instance",
+ merchant_pub: "zxczxcz",
+ payment_targets: ["asd"],
+ },
+ {
+ id: "third",
+ name: "the third instance",
+ merchant_pub: "QWEQWEWQE",
+ payment_targets: ["asd"],
+ },
+ {
+ id: "other",
+ name: "the other instance",
+ merchant_pub: "FHJHGJGHJ",
+ payment_targets: ["asd"],
+ },
+ {
+ id: "another",
+ name: "the another instance",
+ merchant_pub: "abcd3423423efgh",
+ payment_targets: ["asd"],
+ },
+ {
+ id: "last",
+ name: "last instance",
+ merchant_pub: "zxcvvbnm",
+ payment_targets: ["pay-to", "asd"],
+ },
+ ],
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/admin/list/View.tsx b/packages/auditor-backoffice-ui/src/paths/admin/list/View.tsx
new file mode 100644
index 000000000..b59112338
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/admin/list/View.tsx
@@ -0,0 +1,110 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { MerchantBackend } from "../../../declaration.js";
+import { CardTable as CardTableActive } from "./TableActive.js";
+
+interface Props {
+ instances: MerchantBackend.Instances.Instance[];
+ onCreate: () => void;
+ onUpdate: (id: string) => void;
+ onDelete: (id: MerchantBackend.Instances.Instance) => void;
+ onPurge: (id: MerchantBackend.Instances.Instance) => void;
+ selected?: boolean;
+ setInstanceName: (s: string) => void;
+}
+
+export function View({
+ instances,
+ onCreate,
+ onDelete,
+ onPurge,
+ onUpdate,
+ setInstanceName,
+ selected,
+}: Props): VNode {
+ const [show, setShow] = useState<"active" | "deleted" | null>("active");
+ const showIsActive = show === "active" ? "is-active" : "";
+ const showIsDeleted = show === "deleted" ? "is-active" : "";
+ const showAll = show === null ? "is-active" : "";
+ const { i18n } = useTranslationContext();
+
+ const showingInstances = showIsDeleted
+ ? instances.filter((i) => i.deleted)
+ : showIsActive
+ ? instances.filter((i) => !i.deleted)
+ : instances;
+
+ return (
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column is-two-thirds">
+ <div class="tabs" style={{ overflow: "inherit" }}>
+ <ul>
+ <li class={showIsActive}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`Only show active instances`}
+ >
+ <a onClick={() => setShow("active")}>
+ <i18n.Translate>Active</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={showIsDeleted}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`Only show deleted instances`}
+ >
+ <a onClick={() => setShow("deleted")}>
+ <i18n.Translate>Deleted</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={showAll}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`Show all instances`}
+ >
+ <a onClick={() => setShow(null)}>
+ <i18n.Translate>All</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ <CardTableActive
+ instances={showingInstances}
+ onDelete={onDelete}
+ onPurge={onPurge}
+ setInstanceName={setInstanceName}
+ onUpdate={onUpdate}
+ selected={selected}
+ onCreate={onCreate}
+ />
+ </section>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/admin/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/admin/list/index.tsx
new file mode 100644
index 000000000..2f839291b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/admin/list/index.tsx
@@ -0,0 +1,140 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../components/exception/loading.js";
+import { NotificationCard } from "../../../components/menu/index.js";
+import { DeleteModal, PurgeModal } from "../../../components/modal/index.js";
+import { MerchantBackend } from "../../../declaration.js";
+import { useAdminAPI, useBackendInstances } from "../../../hooks/instance.js";
+import { Notification } from "../../../utils/types.js";
+import { View } from "./View.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+interface Props {
+ onCreate: () => void;
+ onUpdate: (id: string) => void;
+ instances: MerchantBackend.Instances.Instance[];
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ setInstanceName: (s: string) => void;
+}
+
+export default function Instances({
+ onUnauthorized,
+ onLoadError,
+ onNotFound,
+ onCreate,
+ onUpdate,
+ setInstanceName,
+}: Props): VNode {
+ const result = useBackendInstances();
+ const [deleting, setDeleting] =
+ useState<MerchantBackend.Instances.Instance | null>(null);
+ const [purging, setPurging] =
+ useState<MerchantBackend.Instances.Instance | null>(null);
+ const { deleteInstance, purgeInstance } = useAdminAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <View
+ instances={result.data.instances}
+ onDelete={setDeleting}
+ onCreate={onCreate}
+ onPurge={setPurging}
+ onUpdate={onUpdate}
+ setInstanceName={setInstanceName}
+ selected={!!deleting}
+ />
+ {deleting && (
+ <DeleteModal
+ element={deleting}
+ onCancel={() => setDeleting(null)}
+ onConfirm={async (): Promise<void> => {
+ try {
+ await deleteInstance(deleting.id);
+ // pushNotification({ message: 'delete_success', type: 'SUCCESS' })
+ setNotif({
+ message: i18n.str`Instance "${deleting.name}" (ID: ${deleting.id}) has been deleted`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`Failed to delete instance`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ // pushNotification({ message: 'delete_error', type: 'ERROR' })
+ }
+ setDeleting(null);
+ }}
+ />
+ )}
+ {purging && (
+ <PurgeModal
+ element={purging}
+ onCancel={() => setPurging(null)}
+ onConfirm={async (): Promise<void> => {
+ try {
+ await purgeInstance(purging.id);
+ setNotif({
+ message: i18n.str`Instance '${purging.name}' (ID: ${purging.id}) has been disabled`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`Failed to purge instance`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+ setPurging(null);
+ }}
+ />
+ )}
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx
new file mode 100644
index 000000000..3336c53a4
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Accounts/Create",
+ component: TestedComponent,
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx
new file mode 100644
index 000000000..6e4786a47
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx
@@ -0,0 +1,173 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { undefinedIfEmpty } from "../../../../utils/table.js";
+
+type Entity = MerchantBackend.BankAccounts.AccountAddDetails & { repeatPassword: string };
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+const accountAuthType = ["none", "basic"];
+
+function isValidURL(s: string): boolean {
+ try {
+ const u = new URL(s)
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>({});
+ const errors: FormErrors<Entity> = {
+ payto_uri: !state.payto_uri ? i18n.str`required` : undefined,
+
+ credit_facade_credentials: !state.credit_facade_credentials
+ ? undefined
+ : undefinedIfEmpty({
+ username:
+ state.credit_facade_credentials.type === "basic" && !state.credit_facade_credentials.username
+ ? i18n.str`required`
+ : undefined,
+ password:
+ state.credit_facade_credentials.type === "basic" && !state.credit_facade_credentials.password
+ ? i18n.str`required`
+ : undefined,
+ }),
+ credit_facade_url: !state.credit_facade_url
+ ? undefined
+ : !isValidURL(state.credit_facade_url) ? i18n.str`not valid url`
+ : undefined,
+ repeatPassword:
+ !state.credit_facade_credentials
+ ? undefined
+ : state.credit_facade_credentials.type === "basic" && (!state.credit_facade_credentials.password || state.credit_facade_credentials.password !== state.repeatPassword)
+ ? i18n.str`is not the same`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ delete state.repeatPassword
+ return onCreate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputPaytoForm<Entity>
+ name="payto_uri"
+ label={i18n.str`Account`}
+ />
+ <Input<Entity>
+ name="credit_facade_url"
+ label={i18n.str`Account info URL`}
+ help="https://bank.com"
+ expand
+ tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`}
+ />
+ <InputSelector
+ name="credit_facade_credentials.type"
+ label={i18n.str`Auth type`}
+ tooltip={i18n.str`Choose the authentication type for the account info URL`}
+ values={accountAuthType}
+ toStr={(str) => {
+ if (str === "none") return "Without authentication";
+ return "Username and password";
+ }}
+ />
+ {state.credit_facade_credentials?.type === "basic" ? (
+ <Fragment>
+ <Input
+ name="credit_facade_credentials.username"
+ label={i18n.str`Username`}
+ tooltip={i18n.str`Username to access the account information.`}
+ />
+ <Input
+ name="credit_facade_credentials.password"
+ inputType="password"
+ label={i18n.str`Password`}
+ tooltip={i18n.str`Password to access the account information.`}
+ />
+ <Input
+ name="repeatPassword"
+ inputType="password"
+ label={i18n.str`Repeat password`}
+ />
+ </Fragment>
+ ) : undefined}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/index.tsx
new file mode 100644
index 000000000..7d33d25ce
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/accounts/create/index.tsx
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useWebhookAPI } from "../../../../hooks/webhooks.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+import { useOtpDeviceAPI } from "../../../../hooks/otp.js";
+import { useBankAccountAPI } from "../../../../hooks/bank.js";
+
+export type Entity = MerchantBackend.BankAccounts.AccountAddDetails;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+
+export default function CreateValidator({ onConfirm, onBack }: Props): VNode {
+ const { createBankAccount } = useBankAccountAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: Entity) => {
+ return createBankAccount(request)
+ .then((d) => {
+ onConfirm()
+ })
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create device`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx
new file mode 100644
index 000000000..6b4b63735
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/Accounts/List",
+ component: TestedComponent,
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx
new file mode 100644
index 000000000..24da755b9
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx
@@ -0,0 +1,64 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { CardTable } from "./Table.js";
+
+export interface Props {
+ devices: MerchantBackend.BankAccounts.BankAccountEntry[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+ onCreate: () => void;
+ onDelete: (e: MerchantBackend.BankAccounts.BankAccountEntry) => void;
+ onSelect: (e: MerchantBackend.BankAccounts.BankAccountEntry) => void;
+}
+
+export function ListPage({
+ devices,
+ onCreate,
+ onDelete,
+ onSelect,
+ onLoadMoreBefore,
+ onLoadMoreAfter,
+}: Props): VNode {
+ const form = { payto_uri: "" };
+
+ const { i18n } = useTranslationContext();
+ return (
+ <section class="section is-main-section">
+ <CardTable
+ accounts={devices.map((o) => ({
+ ...o,
+ id: String(o.h_wire),
+ }))}
+ onCreate={onCreate}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreBefore={!onLoadMoreBefore}
+ onLoadMoreAfter={onLoadMoreAfter}
+ hasMoreAfter={!onLoadMoreAfter}
+ />
+ </section>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/Table.tsx
new file mode 100644
index 000000000..7d6db0782
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/Table.tsx
@@ -0,0 +1,385 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import { MerchantBackend } from "../../../../declaration.js";
+import { parsePaytoUri, PaytoType, PaytoUri, PaytoUriBitcoin, PaytoUriIBAN, PaytoUriTalerBank, PaytoUriUnknown } from "@gnu-taler/taler-util";
+
+type Entity = MerchantBackend.BankAccounts.BankAccountEntry;
+
+interface Props {
+ accounts: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ onCreate: () => void;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ accounts,
+ onCreate,
+ onDelete,
+ onSelect,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <i18n.Translate>Bank accounts</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new accounts`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {accounts.length > 0 ? (
+ <Table
+ accounts={accounts}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreAfter={hasMoreAfter}
+ hasMoreBefore={hasMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ accounts: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+function toggleSelected<T>(id: T): (prev: T[]) => T[] {
+ return (prev: T[]): T[] =>
+ prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id);
+}
+
+function Table({
+ accounts,
+ onLoadMoreAfter,
+ onDelete,
+ onSelect,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const emptyList: Record<PaytoType | "unknown", { parsed: PaytoUri, acc: Entity }[]> = { "bitcoin": [], "x-taler-bank": [], "iban": [], "unknown": [], }
+ const accountsByType = accounts.reduce((prev, acc) => {
+ const parsed = parsePaytoUri(acc.payto_uri)
+ if (!parsed) return prev //skip
+ if (parsed.targetType !== "bitcoin" && parsed.targetType !== "x-taler-bank" && parsed.targetType !== "iban") {
+ prev["unknown"].push({ parsed, acc })
+ } else {
+ prev[parsed.targetType].push({ parsed, acc })
+ }
+ return prev
+ }, emptyList)
+
+ const bitcoinAccounts = accountsByType["bitcoin"]
+ const talerbankAccounts = accountsByType["x-taler-bank"]
+ const ibanAccounts = accountsByType["iban"]
+ const unkownAccounts = accountsByType["unknown"]
+
+
+ return (
+ <Fragment>
+
+ {bitcoinAccounts.length > 0 && <div class="table-container">
+ <p class="card-header-title"><i18n.Translate>Bitcoin type accounts</i18n.Translate></p>
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Address</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Sewgit 1</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Sewgit 2</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {bitcoinAccounts.map(({ parsed, acc }, idx) => {
+ const ac = parsed as PaytoUriBitcoin
+ return (
+ <tr key={idx}>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.targetPath}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.segwitAddrs[0]}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.segwitAddrs[1]}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected accounts from the database`}
+ onClick={() => onDelete(acc)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>}
+
+
+
+ {talerbankAccounts.length > 0 && <div class="table-container">
+ <p class="card-header-title"><i18n.Translate>Taler type accounts</i18n.Translate></p>
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Host</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Account name</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {talerbankAccounts.map(({ parsed, acc }, idx) => {
+ const ac = parsed as PaytoUriTalerBank
+ return (
+ <tr key={idx}>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.host}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.account}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected accounts from the database`}
+ onClick={() => onDelete(acc)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>}
+
+ {ibanAccounts.length > 0 && <div class="table-container">
+ <p class="card-header-title"><i18n.Translate>IBAN type accounts</i18n.Translate></p>
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Account name</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>IBAN</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>BIC</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {ibanAccounts.map(({ parsed, acc }, idx) => {
+ const ac = parsed as PaytoUriIBAN
+ return (
+ <tr key={idx}>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.params["receiver-name"]}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.iban}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.bic ?? ""}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected accounts from the database`}
+ onClick={() => onDelete(acc)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>}
+
+ {unkownAccounts.length > 0 && <div class="table-container">
+ <p class="card-header-title"><i18n.Translate>Other type accounts</i18n.Translate></p>
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Type</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Path</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {unkownAccounts.map(({ parsed, acc }, idx) => {
+ const ac = parsed as PaytoUriUnknown
+ return (
+ <tr key={idx}>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.targetType}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.targetPath}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected accounts from the database`}
+ onClick={() => onDelete(acc)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>}
+ </Fragment>
+
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no accounts yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/index.tsx
new file mode 100644
index 000000000..100241e22
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/accounts/list/index.tsx
@@ -0,0 +1,107 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useInstanceOtpDevices, useOtpDeviceAPI } from "../../../../hooks/otp.js";
+import { Notification } from "../../../../utils/types.js";
+import { ListPage } from "./ListPage.js";
+import { useBankAccountAPI, useInstanceBankAccounts } from "../../../../hooks/bank.js";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onNotFound: () => VNode;
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+}
+
+export default function ListOtpDevices({
+ onUnauthorized,
+ onLoadError,
+ onCreate,
+ onSelect,
+ onNotFound,
+}: Props): VNode {
+ const [position, setPosition] = useState<string | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { deleteBankAccount } = useBankAccountAPI();
+ const result = useInstanceBankAccounts({ position }, (id) => setPosition(id));
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <ListPage
+ devices={result.data.accounts}
+ onLoadMoreBefore={
+ result.isReachingStart ? result.loadMorePrev : undefined
+ }
+ onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined}
+ onCreate={onCreate}
+ onSelect={(e) => {
+ onSelect(e.h_wire);
+ }}
+ onDelete={(e: MerchantBackend.BankAccounts.BankAccountEntry) =>
+ deleteBankAccount(e.h_wire)
+ .then(() =>
+ setNotif({
+ message: i18n.str`bank account delete successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not delete the bank account`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ )
+ }
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx
new file mode 100644
index 000000000..d6b1d65e0
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/OtpDevices/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx
new file mode 100644
index 000000000..0d20879e8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx
@@ -0,0 +1,195 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js";
+import { undefinedIfEmpty } from "../../../../utils/table.js";
+
+type Entity = MerchantBackend.BankAccounts.BankAccountEntry
+ & WithId;
+
+const accountAuthType = ["unedit", "none", "basic"];
+interface Props {
+ onUpdate: (d: MerchantBackend.BankAccounts.AccountPatchDetails) => Promise<void>;
+ onBack?: () => void;
+ account: Entity;
+}
+
+
+export function UpdatePage({ account, onUpdate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<MerchantBackend.BankAccounts.AccountPatchDetails>>(account);
+
+ const errors: FormErrors<MerchantBackend.BankAccounts.AccountPatchDetails> = {
+ credit_facade_url: !state.credit_facade_url ? i18n.str`required` : !isValidURL(state.credit_facade_url) ? i18n.str`invalid url` : undefined,
+ credit_facade_credentials: undefinedIfEmpty({
+
+ username: state.credit_facade_credentials?.type !== "basic" ? undefined
+ : !state.credit_facade_credentials.username ? i18n.str`required` : undefined,
+
+ password: state.credit_facade_credentials?.type !== "basic" ? undefined
+ : !state.credit_facade_credentials.password ? i18n.str`required` : undefined,
+
+ repeatPassword: state.credit_facade_credentials?.type !== "basic" ? undefined
+ : !(state.credit_facade_credentials as any).repeatPassword ? i18n.str`required` :
+ (state.credit_facade_credentials as any).repeatPassword !== state.credit_facade_credentials.password ? i18n.str`doesn't match`
+ : undefined,
+ }),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+
+ const creds: typeof state.credit_facade_credentials =
+ state.credit_facade_credentials?.type === "basic" ? {
+ type: "basic",
+ password: state.credit_facade_credentials.password,
+ username: state.credit_facade_credentials.username,
+ } : state.credit_facade_credentials?.type === "none" ? {
+ type: "none"
+ } : undefined;
+
+ return onUpdate({
+ credit_facade_credentials: creds,
+ credit_facade_url: state.credit_facade_url,
+ });
+ };
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ Account: <b>{account.id.substring(0, 8)}...</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputPaytoForm<Entity>
+ name="payto_uri"
+ label={i18n.str`Account`}
+ readonly
+ />
+ <Input<Entity>
+ name="credit_facade_url"
+ label={i18n.str`Account info URL`}
+ help="https://bank.com"
+ expand
+ tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`}
+ />
+ <InputSelector
+ name="credit_facade_credentials.type"
+ label={i18n.str`Auth type`}
+ tooltip={i18n.str`Choose the authentication type for the account info URL`}
+ values={accountAuthType}
+ toStr={(str) => {
+ if (str === "none") return "Without authentication";
+ if (str === "basic") return "With authentication";
+ return "Do not change"
+ }}
+ />
+ {state.credit_facade_credentials?.type === "basic" ? (
+ <Fragment>
+ <Input
+ name="credit_facade_credentials.username"
+ label={i18n.str`Username`}
+ tooltip={i18n.str`Username to access the account information.`}
+ />
+ <Input
+ name="credit_facade_credentials.password"
+ inputType="password"
+ label={i18n.str`Password`}
+ tooltip={i18n.str`Password to access the account information.`}
+ />
+ <Input
+ name="credit_facade_credentials.repeatPassword"
+ inputType="password"
+ label={i18n.str`Repeat password`}
+ />
+ </Fragment>
+ ) : undefined}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ </div>
+ </section>
+ </section>
+ </div>
+ );
+}
+
+function isValidURL(s: string): boolean {
+ try {
+ const u = new URL(s)
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/index.tsx
new file mode 100644
index 000000000..44dee7651
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/accounts/update/index.tsx
@@ -0,0 +1,96 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import { useBankAccountAPI, useBankAccountDetails } from "../../../../hooks/bank.js";
+import { Notification } from "../../../../utils/types.js";
+import { UpdatePage } from "./UpdatePage.js";
+
+export type Entity = MerchantBackend.BankAccounts.AccountPatchDetails & WithId;
+
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ bid: string;
+}
+export default function UpdateValidator({
+ bid,
+ onConfirm,
+ onBack,
+ onUnauthorized,
+ onNotFound,
+ onLoadError,
+}: Props): VNode {
+ const { updateBankAccount } = useBankAccountAPI();
+ const result = useBankAccountDetails(bid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ account={{ ...result.data, id: bid }}
+ onBack={onBack}
+ onUpdate={(data) => {
+ return updateBankAccount(bid, data)
+ .then(onConfirm)
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not update account`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/Create.stories.tsx
index adf36980f..2fc0819bb 100644
--- a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.stories.tsx
+++ b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/Create.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2021-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,22 +15,29 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createExample, reducerStatesExample } from '../../utils';
-import { CountrySelectionScreen as TestedComponent } from './CountrySelectionScreen';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
export default {
- title: 'Pages/CountrySelectionScreen',
+ title: "Pages/Product/Create",
component: TestedComponent,
argTypes: {
- onUpdate: { action: 'onUpdate' },
- onBack: { action: 'onBack' },
+ onCreate: { action: "onCreate" },
+ onBack: { action: "onBack" },
},
};
-export const Backup = createExample(TestedComponent, reducerStatesExample.backupSelectCountry);
-export const Recovery = createExample(TestedComponent, reducerStatesExample.recoverySelectCountry);
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatePage.tsx
new file mode 100644
index 000000000..becaf8f3a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatePage.tsx
@@ -0,0 +1,80 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import { ProductForm } from "../../../../components/product/ProductForm.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useListener } from "../../../../hooks/listener.js";
+
+type Entity = MerchantBackend.Products.ProductAddDetail & {
+ product_id: string;
+};
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const [submitForm, addFormSubmitter] = useListener<Entity | undefined>(
+ (result) => {
+ if (result) return onCreate(result);
+ return Promise.reject();
+ },
+ );
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <ProductForm onSubscribe={addFormSubmitter} />
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ onClick={submitForm}
+ data-tooltip={
+ !submitForm
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ disabled={!submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatedSuccessfully.tsx
new file mode 100644
index 000000000..573064aea
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/CreatedSuccessfully.tsx
@@ -0,0 +1,69 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
+import { Entity } from "./index.js";
+import emptyImage from "../../assets/empty.png";
+
+interface Props {
+ entity: Entity;
+ onConfirm: () => void;
+ onCreateAnother?: () => void;
+}
+
+export function CreatedSuccessfully({
+ entity,
+ onConfirm,
+ onCreateAnother,
+}: Props): VNode {
+ return (
+ <Template onConfirm={onConfirm} onCreateAnother={onCreateAnother}>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Image</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Description</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Price</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ </p>
+ </div>
+ </div>
+ </div>
+ </Template>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/index.tsx
new file mode 100644
index 000000000..99599cfab
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/create/index.tsx
@@ -0,0 +1,46 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { AuditorBackend } from "../../../../declaration.js";
+import { useDepositConfirmationAPI } from "../../../../hooks/deposit_confirmations.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+
+export type Entity = AuditorBackend.DepositConfirmation.DepositConfirmationDetail;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+export default function CreateProduct({ onConfirm, onBack }: Props): VNode {
+ const { createDepositConfirmation } = useDepositConfirmationAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/List.stories.tsx
new file mode 100644
index 000000000..41c297d5b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/List.stories.tsx
@@ -0,0 +1,43 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CardTable as TestedComponent } from "./Table.js";
+
+export default {
+ title: "Pages/Product/List",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ onSelect: { action: "onSelect" },
+ onDelete: { action: "onDelete" },
+ onUpdate: { action: "onUpdate" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/Table.tsx
new file mode 100644
index 000000000..2c97b59e8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/Table.tsx
@@ -0,0 +1,249 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import emptyImage from "../../../../assets/empty.png";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputNumber } from "../../../../components/form/InputNumber.js";
+import { AuditorBackend, WithId } from "../../../../declaration.js";
+import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
+
+type Entity = AuditorBackend.DepositConfirmation.DepositConfirmationDetail & WithId;
+
+interface Props {
+ instances: Entity[];
+ onDelete: (id: Entity) => void;
+ onSelect: (depositConfirmation: Entity) => void;
+ onUpdate: (
+ id: string,
+ data: AuditorBackend.DepositConfirmation.DepositConfirmationDetail,
+ ) => Promise<void>;
+ onCreate: () => void;
+ selected?: boolean;
+}
+
+export function CardTable({
+ instances,
+ onCreate,
+ onSelect,
+ onUpdate,
+ onDelete,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string | undefined>(
+ undefined,
+ );
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-shopping" />
+ </span>
+ <i18n.Translate>Deposit Confirmations</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add deposit-confirmation`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {instances.length > 0 ? (
+ <Table
+ instances={instances}
+ onSelect={onSelect}
+ onDelete={onDelete}
+ onUpdate={onUpdate}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string | undefined;
+ instances: Entity[];
+ onSelect: (id: Entity) => void;
+ onUpdate: (
+ id: string,
+ data: AuditorBackend.DepositConfirmation.DepositConfirmationDetail,
+ ) => Promise<void>;
+ onDelete: (serial_id: Entity) => void;
+ rowSelectionHandler: StateUpdater<string | undefined>;
+}
+
+function Table({
+ rowSelection,
+ rowSelectionHandler,
+ instances,
+ onSelect,
+ onUpdate,
+ onDelete,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = useSettings();
+ return (
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Image</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Description</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Price per unit</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Taxes</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Sales</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Stock</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Sold</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+
+ return (
+ <Fragment key={i.id}>
+ <tr key="info">
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ </td>
+
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <span
+ class="has-tooltip-bottom"
+ data-tooltip={i18n.str`go to product update page`}
+ >
+ <button
+ class="button is-small is-success "
+ type="button"
+ onClick={(): void => onSelect(i)}
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+ </span>
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`remove this product from the database`}
+ >
+ <button
+ class="button is-small is-danger"
+ type="button"
+ onClick={(): void => onDelete(i)}
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </button>
+ </span>
+ </div>
+ </td>
+ </tr>
+ {rowSelection === i.id && (
+ <tr key="form">
+ <td colSpan={10}>
+ </td>
+ </tr>
+ )}
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+interface FastProductUpdate {
+ incoming: number;
+ lost: number;
+ price: string;
+}
+interface UpdatePrice {
+ price: string;
+}
+
+
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no products yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
+
+function difference(price: string, tax: number) {
+ if (!tax) return price;
+ const ps = price.split(":");
+ const p = parseInt(ps[1], 10);
+ ps[1] = `${p - tax}`;
+ return ps.join(":");
+} \ No newline at end of file
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/index.tsx
new file mode 100644
index 000000000..a99cfd2ef
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/list/index.tsx
@@ -0,0 +1,126 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ * @author Nic Eigel
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { AuditorBackend, WithId } from "../../../../declaration.js";
+import {
+ useDepositConfirmation,
+ useDepositConfirmationAPI,
+} from "../../../../hooks/deposit_confirmations.js";
+import { Notification } from "../../../../utils/types.js";
+import { CardTable } from "./Table.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import { ConfirmModal, DeleteModal } from "../../../../components/modal/index.js";
+import { JumpToElementById } from "../../../../components/form/JumpToElementById.js";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+ onLoadError: (e: HttpError<AuditorBackend.ErrorDetail>) => VNode;
+}
+export default function DepositConfirmationList({
+ onUnauthorized,
+ onLoadError,
+ onCreate,
+ onSelect,
+ onNotFound,
+}: Props): VNode {
+ const result = useDepositConfirmation();
+ const { deleteDepositConfirmation, updateDepositConfirmation, getDepositConfirmation } = useDepositConfirmationAPI();
+ const [deleting, setDeleting] =
+ useState<AuditorBackend.DepositConfirmation.DepositConfirmationDetail & WithId | null>(null);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <section class="section is-main-section">
+ <NotificationCard notification={notif} />
+
+ <JumpToElementById
+ testIfExist={getDepositConfirmation}
+ onSelect={onSelect}
+ description={i18n.str`jump to deposit_confirmation with the given serial ID`}
+ placeholder={i18n.str`serial id`}
+ />
+
+ {deleting && (
+ <ConfirmModal
+ label={`Delete deposit-confirmation`}
+ description={`Delete the deposit-cofirmation "${deleting.serial_id}"`}
+ danger
+ active
+ onCancel={() => setDeleting(null)}
+ onConfirm={async (): Promise<void> => {
+ try {
+ await deleteDepositConfirmation(deleting.serial_id);
+ setNotif({
+ message: i18n.str`Deposit-confirmation "${deleting.serial_id}" (ID: ${deleting.serial_id}) has been deleted`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`Failed to delete deposit-confirmation`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+ setDeleting(null);
+ }}
+ >
+ <p>
+ If you delete the deposit-confirmation (ID:{" "}
+ <b>{deleting.serial_id}</b>), the stock and related information will be lost
+ </p>
+ <p class="warning">
+ Deleting a deposit-confirmation <b>cannot be undone</b>.
+ </p>
+ </ConfirmModal>
+ )}
+ </section>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/Update.stories.tsx
new file mode 100644
index 000000000..a85b13b8b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/Update.stories.tsx
@@ -0,0 +1,73 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/Product/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const WithManagedStock = createExample(TestedComponent, {
+ product: {
+ product_id: "20102-ASDAS-QWE",
+ description: "description1",
+ description_i18n: {} as any,
+ image: "",
+ price: "TESTKUDOS:10",
+ taxes: [],
+ total_lost: 10,
+ total_sold: 5,
+ total_stock: 15,
+ unit: "bar",
+ address: {},
+ },
+});
+
+export const WithInfiniteStock = createExample(TestedComponent, {
+ product: {
+ product_id: "20102-ASDAS-QWE",
+ description: "description1",
+ description_i18n: {} as any,
+ image: "",
+ price: "TESTKUDOS:10",
+ taxes: [],
+ total_lost: 10,
+ total_sold: 5,
+ total_stock: -1,
+ unit: "bar",
+ address: {},
+ },
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/UpdatePage.tsx
new file mode 100644
index 000000000..97715171e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/UpdatePage.tsx
@@ -0,0 +1,99 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import { ProductForm } from "../../../../components/product/ProductForm.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useListener } from "../../../../hooks/listener.js";
+
+type Entity = MerchantBackend.Products.ProductDetail & { product_id: string };
+
+interface Props {
+ onUpdate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+ product: Entity;
+}
+
+export function UpdatePage({ product, onUpdate, onBack }: Props): VNode {
+ const [submitForm, addFormSubmitter] = useListener<Entity | undefined>(
+ (result) => {
+ if (result) return onUpdate(result);
+ return Promise.resolve();
+ },
+ );
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ <i18n.Translate>Product id:</i18n.Translate>
+ <b>{product.product_id}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <ProductForm
+ initial={product}
+ onSubscribe={addFormSubmitter}
+ alreadyExist
+ />
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ onClick={submitForm}
+ data-tooltip={
+ !submitForm
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ disabled={!submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/index.tsx
new file mode 100644
index 000000000..8e0f7647f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/deposit_confirmations/update/index.tsx
@@ -0,0 +1,95 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useProductAPI, useProductDetails } from "../../../../hooks/product.js";
+import { Notification } from "../../../../utils/types.js";
+import { UpdatePage } from "./UpdatePage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+export type Entity = MerchantBackend.Products.ProductAddDetail;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ pid: string;
+}
+export default function UpdateProduct({
+ pid,
+ onConfirm,
+ onBack,
+ onUnauthorized,
+ onNotFound,
+ onLoadError,
+}: Props): VNode {
+ const { updateProduct } = useProductAPI();
+ const result = useProductDetails(pid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ product={{ ...result.data, product_id: pid }}
+ onBack={onBack}
+ onUpdate={(data) => {
+ return updateProduct(pid, data)
+ .then(onConfirm)
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create product`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/details/DetailPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/details/DetailPage.tsx
new file mode 100644
index 000000000..21dadb1e3
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/details/DetailPage.tsx
@@ -0,0 +1,83 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider } from "../../../components/form/FormProvider.js";
+import { Input } from "../../../components/form/Input.js";
+import { MerchantBackend } from "../../../declaration.js";
+
+type Entity = MerchantBackend.Instances.InstanceReconfigurationMessage;
+interface Props {
+ onUpdate: () => void;
+ onDelete: () => void;
+ selected: MerchantBackend.Instances.QueryInstancesResponse;
+}
+
+function convert(
+ from: MerchantBackend.Instances.QueryInstancesResponse,
+): Entity {
+ const defaults = {
+ default_wire_fee_amortization: 1,
+ use_stefan: true,
+ default_pay_delay: { d_us: 1000 * 60 * 60 * 1000 }, //one hour
+ default_wire_transfer_delay: { d_us: 1000 * 60 * 60 * 2 * 1000 }, //two hours
+ };
+ return { ...defaults, ...from };
+}
+
+export function DetailPage({ selected }: Props): VNode {
+ const [value, valueHandler] = useState<Partial<Entity>>(convert(selected));
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <h1 class="title">Here goes the instance description</h1>
+ </div>
+ </div>
+ <div class="level-right" style="display: none;">
+ <div class="level-item" />
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-6">
+ <FormProvider<Entity> object={value} valueHandler={valueHandler}>
+ <Input<Entity> name="name" readonly label={i18n.str`Name`} />
+ </FormProvider>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/details/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/details/index.tsx
new file mode 100644
index 000000000..9b393b818
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/details/index.tsx
@@ -0,0 +1,87 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { ErrorType, HttpError } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../components/exception/loading.js";
+import { DeleteModal } from "../../../components/modal/index.js";
+import { useInstanceContext } from "../../../context/instance.js";
+import { MerchantBackend } from "../../../declaration.js";
+import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js";
+import { DetailPage } from "./DetailPage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onUpdate: () => void;
+ onNotFound: () => VNode;
+ onDelete: () => void;
+}
+
+export default function Detail({
+ onUpdate,
+ onLoadError,
+ onUnauthorized,
+ onDelete,
+ onNotFound,
+}: Props): VNode {
+ const { id } = useInstanceContext();
+ const result = useInstanceDetails();
+ const [deleting, setDeleting] = useState<boolean>(false);
+
+ const { deleteInstance } = useInstanceAPI();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <DetailPage
+ selected={result.data}
+ onUpdate={onUpdate}
+ onDelete={() => setDeleting(true)}
+ />
+ {deleting && (
+ <DeleteModal
+ element={{ name: result.data.name, id }}
+ onCancel={() => setDeleting(false)}
+ onConfirm={async (): Promise<void> => {
+ try {
+ await deleteInstance();
+ onDelete();
+ } catch (error) {
+ //FIXME: show message error
+ }
+ setDeleting(false);
+ }}
+ />
+ )}
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/details/stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/details/stories.tsx
new file mode 100644
index 000000000..367fabce2
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/details/stories.tsx
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { ConfigContextProvider } from "../../../context/config.js";
+import { DetailPage as TestedComponent } from "./DetailPage.js";
+
+export default {
+ title: "Pages/Instance/Detail",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Internal: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const component = (args: any) => (
+ <ConfigContextProvider
+ value={{
+ currency: "TESTKUDOS",
+ version: "1",
+ }}
+ >
+ <Internal {...(props as any)} />
+ </ConfigContextProvider>
+ );
+ return { component, props };
+}
+
+export const Example = createExample(TestedComponent, {
+ selected: {
+ name: "name",
+ auth: { method: "external" },
+ address: {},
+ user_type: "business",
+ jurisdiction: {},
+ use_stefan: true,
+ default_pay_delay: {
+ d_us: 1000 * 1000, //one second
+ },
+ default_wire_transfer_delay: {
+ d_us: 1000 * 1000, //one second
+ },
+ merchant_pub: "ASDWQEKASJDKSADJ",
+ },
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/index.stories.ts b/packages/auditor-backoffice-ui/src/paths/instance/index.stories.ts
new file mode 100644
index 000000000..1d8c76ff9
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/index.stories.ts
@@ -0,0 +1,19 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export * as details from "./details/stories.js";
+export * as kycList from "./kyc/list/ListPage.stories.js";
+export * as reserve from "./reserves/create/CreatedSuccessfully.stories.js";
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx
new file mode 100644
index 000000000..d33f64ada
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx
@@ -0,0 +1,58 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+import * as tests from "@gnu-taler/web-util/testing";
+import { MerchantBackend } from "../../../../declaration.js";
+
+export default {
+ title: "Pages/KYC/List",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+export const Example = tests.createExample(TestedComponent, {
+ status: {
+ timeout_kycs: [],
+ pending_kycs: [
+ {
+ aml_status: 0,
+ exchange_url: "http://exchange.taler",
+ payto_uri: "payto://iban/de123123123",
+ kyc_url: "http://exchange.taler/kyc",
+ },
+ {
+ aml_status: 1,
+ exchange_url: "http://exchange.taler",
+ payto_uri: "payto://iban/de123123123",
+ },
+ {
+ aml_status: 2,
+ exchange_url: "http://exchange.taler",
+ payto_uri: "payto://iban/de123123123",
+ },
+ ],
+ } as MerchantBackend.KYC.AccountKycRedirects,
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx
new file mode 100644
index 000000000..338081886
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx
@@ -0,0 +1,208 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { MerchantBackend } from "../../../../declaration.js";
+
+export interface Props {
+ status: MerchantBackend.KYC.AccountKycRedirects;
+}
+
+export function ListPage({ status }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <section class="section is-main-section">
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-clock" />
+ </span>
+ <i18n.Translate>Pending KYC verification</i18n.Translate>
+ </p>
+
+ <div class="card-header-icon" aria-label="more options" />
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {status.pending_kycs.length > 0 ? (
+ <PendingTable entries={status.pending_kycs} />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {status.timeout_kycs.length > 0 ? (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-clock" />
+ </span>
+ <i18n.Translate>Timed out</i18n.Translate>
+ </p>
+
+ <div class="card-header-icon" aria-label="more options" />
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {status.timeout_kycs.length > 0 ? (
+ <TimedOutTable entries={status.timeout_kycs} />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ ) : undefined}
+ </section>
+ );
+}
+interface PendingTableProps {
+ entries: MerchantBackend.KYC.MerchantAccountKycRedirect[];
+}
+
+interface TimedOutTableProps {
+ entries: MerchantBackend.KYC.ExchangeKycTimeout[];
+}
+
+function PendingTable({ entries }: PendingTableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ <table class="table is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Exchange</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Target account</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Reason</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {entries.map((e, i) => {
+ if (e.kyc_url === undefined) {
+ // blocked by AML
+ return (
+ <tr key={i}>
+ <td>{e.exchange_url}</td>
+ <td>{e.payto_uri}</td>
+ <td>
+ {e.aml_status === 1 ? (
+ <i18n.Translate>
+ There is an anti-money laundering process pending to
+ complete.
+ </i18n.Translate>
+ ) : (
+ <i18n.Translate>
+ The account is frozen due to the anti-money laundering
+ rules. Contact the exchange service provider for further
+ instructions.
+ </i18n.Translate>
+ )}
+ </td>
+ </tr>
+ );
+ } else {
+ // blocked by KYC
+ return (
+ <tr key={i}>
+ <td>{e.exchange_url}</td>
+ <td>{e.payto_uri}</td>
+ <td>
+ <a href={e.kyc_url} target="_black" rel="noreferrer">
+ <i18n.Translate>
+ Pending KYC process, click here to complete
+ </i18n.Translate>
+ </a>
+ </td>
+ </tr>
+ );
+ }
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+function TimedOutTable({ entries }: TimedOutTableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ <table class="table is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Exchange</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Code</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Http Status</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {entries.map((e, i) => {
+ return (
+ <tr key={i}>
+ <td>{e.exchange_url}</td>
+ <td>{e.exchange_code}</td>
+ <td>{e.exchange_http_status}</td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-happy mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>No pending kyc verification!</i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/index.tsx
new file mode 100644
index 000000000..5b93ac169
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/kyc/list/index.tsx
@@ -0,0 +1,63 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ErrorType, HttpError } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { Loading } from "../../../../components/exception/loading.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useInstanceKYCDetails } from "../../../../hooks/instance.js";
+import { ListPage } from "./ListPage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onNotFound: () => VNode;
+}
+
+export default function ListKYC({
+ onUnauthorized,
+ onLoadError,
+ onNotFound,
+}: Props): VNode {
+ const result = useInstanceKYCDetails();
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ const status = result.data.type === "ok" ? undefined : result.data.status;
+
+ if (!status) {
+ return <div>no kyc required</div>;
+ }
+ return <ListPage status={status} />;
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx
new file mode 100644
index 000000000..bd9f65718
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Order/Create",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ goBack: { action: "goBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ instanceConfig: {
+ default_pay_delay: {
+ d_us: 1000 * 1000 * 60 * 60, //one hour
+ },
+ default_wire_transfer_delay: {
+ d_us: 1000 * 1000 * 60 * 60, //one hour
+ },
+ use_stefan: true,
+ },
+ instanceInventory: [
+ {
+ id: "t-shirt-1",
+ description: "a m size t-shirt",
+ price: "TESTKUDOS:1",
+ total_stock: -1,
+ },
+ {
+ id: "t-shirt-2",
+ price: "TESTKUDOS:1",
+ description: "a xl size t-shirt",
+ } as any,
+ {
+ id: "t-shirt-3",
+ price: "TESTKUDOS:1",
+ description: "a s size t-shirt",
+ } as any,
+ ],
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx
new file mode 100644
index 000000000..62ceaa24b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx
@@ -0,0 +1,705 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AbsoluteTime, Amounts, Duration, TalerProtocolDuration } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format, isFuture } from "date-fns";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputDate } from "../../../../components/form/InputDate.js";
+import { InputDuration } from "../../../../components/form/InputDuration.js";
+import { InputGroup } from "../../../../components/form/InputGroup.js";
+import { InputLocation } from "../../../../components/form/InputLocation.js";
+import { InputNumber } from "../../../../components/form/InputNumber.js";
+import { InputToggle } from "../../../../components/form/InputToggle.js";
+import { InventoryProductForm } from "../../../../components/product/InventoryProductForm.js";
+import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm.js";
+import { ProductList } from "../../../../components/product/ProductList.js";
+import { useConfigContext } from "../../../../context/config.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import { useSettings } from "../../../../hooks/useSettings.js";
+import { OrderCreateSchema as schema } from "../../../../schemas/index.js";
+import { rate } from "../../../../utils/amount.js";
+import { undefinedIfEmpty } from "../../../../utils/table.js";
+
+interface Props {
+ onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void;
+ onBack?: () => void;
+ instanceConfig: InstanceConfig;
+ instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[];
+}
+interface InstanceConfig {
+ use_stefan: boolean;
+ default_pay_delay: TalerProtocolDuration;
+ default_wire_transfer_delay: TalerProtocolDuration;
+}
+
+function with_defaults(config: InstanceConfig, currency: string): Partial<Entity> {
+ const defaultPayDeadline = Duration.fromTalerProtocolDuration(config.default_pay_delay);
+ const defaultWireDeadline = Duration.fromTalerProtocolDuration(config.default_wire_transfer_delay);
+
+ return {
+ inventoryProducts: {},
+ products: [],
+ pricing: {},
+ payments: {
+ max_fee: undefined,
+ createToken: true,
+ pay_deadline: (defaultPayDeadline),
+ refund_deadline: (defaultPayDeadline),
+ wire_transfer_deadline: (defaultWireDeadline),
+ },
+ shipping: {},
+ extra: {},
+ };
+}
+
+interface ProductAndQuantity {
+ product: MerchantBackend.Products.ProductDetail & WithId;
+ quantity: number;
+}
+export interface ProductMap {
+ [id: string]: ProductAndQuantity;
+}
+
+interface Pricing {
+ products_price: string;
+ order_price: string;
+ summary: string;
+}
+interface Shipping {
+ delivery_date?: Date;
+ delivery_location?: MerchantBackend.Location;
+ fullfilment_url?: string;
+}
+interface Payments {
+ refund_deadline: Duration;
+ pay_deadline: Duration;
+ wire_transfer_deadline: Duration;
+ auto_refund_deadline: Duration;
+ max_fee?: string;
+ createToken: boolean;
+ minimum_age?: number;
+}
+interface Entity {
+ inventoryProducts: ProductMap;
+ products: MerchantBackend.Product[];
+ pricing: Partial<Pricing>;
+ payments: Partial<Payments>;
+ shipping: Partial<Shipping>;
+ extra: Record<string, string>;
+}
+
+const stringIsValidJSON = (value: string) => {
+ try {
+ JSON.parse(value.trim());
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export function CreatePage({
+ onCreate,
+ onBack,
+ instanceConfig,
+ instanceInventory,
+}: Props): VNode {
+ const config = useConfigContext();
+ const instance_default = with_defaults(instanceConfig, config.currency)
+ const [value, valueHandler] = useState(instance_default);
+ const zero = Amounts.zeroOfCurrency(config.currency);
+ const [settings, updateSettings] = useSettings()
+ const inventoryList = Object.values(value.inventoryProducts || {});
+ const productList = Object.values(value.products || {});
+
+ const { i18n } = useTranslationContext();
+
+ const parsedPrice = !value.pricing?.order_price
+ ? undefined
+ : Amounts.parse(value.pricing.order_price);
+
+ const errors: FormErrors<Entity> = {
+ pricing: undefinedIfEmpty({
+ summary: !value.pricing?.summary ? i18n.str`required` : undefined,
+ order_price: !value.pricing?.order_price
+ ? i18n.str`required`
+ : !parsedPrice
+ ? i18n.str`not valid`
+ : Amounts.isZero(parsedPrice)
+ ? i18n.str`must be greater than 0`
+ : undefined,
+ }),
+ payments: undefinedIfEmpty({
+ refund_deadline: !value.payments?.refund_deadline
+ ? undefined
+ : value.payments.pay_deadline &&
+ Duration.cmp(value.payments.refund_deadline, value.payments.pay_deadline) === -1
+ ? i18n.str`refund deadline cannot be before pay deadline`
+ : value.payments.wire_transfer_deadline &&
+ Duration.cmp(
+ value.payments.wire_transfer_deadline,
+ value.payments.refund_deadline,
+ ) === -1
+ ? i18n.str`wire transfer deadline cannot be before refund deadline`
+ : undefined,
+ pay_deadline: !value.payments?.pay_deadline
+ ? i18n.str`required`
+ : value.payments.wire_transfer_deadline &&
+ Duration.cmp(
+ value.payments.wire_transfer_deadline,
+ value.payments.pay_deadline,
+ ) === -1
+ ? i18n.str`wire transfer deadline cannot be before pay deadline`
+ : undefined,
+ wire_transfer_deadline: !value.payments?.wire_transfer_deadline
+ ? i18n.str`required`
+ : undefined,
+ auto_refund_deadline: !value.payments?.auto_refund_deadline
+ ? undefined
+ : !value.payments?.refund_deadline
+ ? i18n.str`should have a refund deadline`
+ : Duration.cmp(
+ value.payments.refund_deadline,
+ value.payments.auto_refund_deadline,
+ ) == -1
+ ? i18n.str`auto refund cannot be after refund deadline`
+ : undefined,
+
+ }),
+ shipping: undefinedIfEmpty({
+ delivery_date: !value.shipping?.delivery_date
+ ? undefined
+ : !isFuture(value.shipping.delivery_date)
+ ? i18n.str`should be in the future`
+ : undefined,
+ }),
+ };
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submit = (): void => {
+ const order = value as any; //schema.cast(value);
+ if (!value.payments) return;
+ if (!value.shipping) return;
+
+ const request: MerchantBackend.Orders.PostOrderRequest = {
+ order: {
+ amount: order.pricing.order_price,
+ summary: order.pricing.summary,
+ products: productList,
+ extra: undefinedIfEmpty(value.extra),
+ pay_deadline: !value.payments.pay_deadline ?
+ i18n.str`required` :
+ AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.pay_deadline))
+ ,// : undefined,
+ wire_transfer_deadline: value.payments.wire_transfer_deadline
+ ? AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.wire_transfer_deadline))
+ : undefined,
+ refund_deadline: value.payments.refund_deadline
+ ? AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.refund_deadline))
+ : undefined,
+ auto_refund: value.payments.auto_refund_deadline
+ ? Duration.toTalerProtocolDuration(value.payments.auto_refund_deadline)
+ : undefined,
+ max_fee: value.payments.max_fee as string,
+
+ delivery_date: value.shipping.delivery_date
+ ? { t_s: value.shipping.delivery_date.getTime() / 1000 }
+ : undefined,
+ delivery_location: value.shipping.delivery_location,
+ fulfillment_url: value.shipping.fullfilment_url,
+ minimum_age: value.payments.minimum_age,
+ },
+ inventory_products: inventoryList.map((p) => ({
+ product_id: p.product.id,
+ quantity: p.quantity,
+ })),
+ create_token: value.payments.createToken,
+ };
+
+ onCreate(request);
+ };
+
+ const addProductToTheInventoryList = (
+ product: MerchantBackend.Products.ProductDetail & WithId,
+ quantity: number,
+ ) => {
+ valueHandler((v) => {
+ const inventoryProducts = { ...v.inventoryProducts };
+ inventoryProducts[product.id] = { product, quantity };
+ return { ...v, inventoryProducts };
+ });
+ };
+
+ const removeProductFromTheInventoryList = (id: string) => {
+ valueHandler((v) => {
+ const inventoryProducts = { ...v.inventoryProducts };
+ delete inventoryProducts[id];
+ return { ...v, inventoryProducts };
+ });
+ };
+
+ const addNewProduct = async (product: MerchantBackend.Product) => {
+ return valueHandler((v) => {
+ const products = v.products ? [...v.products, product] : [];
+ return { ...v, products };
+ });
+ };
+
+ const removeFromNewProduct = (index: number) => {
+ valueHandler((v) => {
+ const products = v.products ? [...v.products] : [];
+ products.splice(index, 1);
+ return { ...v, products };
+ });
+ };
+
+ const [editingProduct, setEditingProduct] = useState<
+ MerchantBackend.Product | undefined
+ >(undefined);
+
+ const totalPriceInventory = inventoryList.reduce((prev, cur) => {
+ const p = Amounts.parseOrThrow(cur.product.price);
+ return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount;
+ }, zero);
+
+ const totalPriceProducts = productList.reduce((prev, cur) => {
+ if (!cur.price) return zero;
+ const p = Amounts.parseOrThrow(cur.price);
+ return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount;
+ }, zero);
+
+ const hasProducts = inventoryList.length > 0 || productList.length > 0;
+ const totalPrice = Amounts.add(totalPriceInventory, totalPriceProducts);
+
+ const totalAsString = Amounts.stringify(totalPrice.amount);
+ const allProducts = productList.concat(inventoryList.map(asProduct));
+
+ const [newField, setNewField] = useState("")
+
+ useEffect(() => {
+ valueHandler((v) => {
+ return {
+ ...v,
+ pricing: {
+ ...v.pricing,
+ products_price: hasProducts ? totalAsString : undefined,
+ order_price: hasProducts ? totalAsString : undefined,
+ },
+ };
+ });
+ }, [hasProducts, totalAsString]);
+
+ const discountOrRise = rate(
+ parsedPrice ?? Amounts.zeroOfCurrency(config.currency),
+ totalPrice.amount,
+ );
+
+ const minAgeByProducts = allProducts.reduce(
+ (cur, prev) =>
+ !prev.minimum_age || cur > prev.minimum_age ? cur : prev.minimum_age,
+ 0,
+ );
+
+ // if there is no default pay deadline
+ const noDefault_payDeadline = !instance_default.payments || !instance_default.payments.pay_deadline
+ // and there is no default wire deadline
+ const noDefault_wireDeadline = !instance_default.payments || !instance_default.payments.wire_transfer_deadline
+ // user required to set the taler options
+ const requiresSomeTalerOptions = noDefault_payDeadline || noDefault_wireDeadline
+
+
+ return (
+ <div>
+
+ <section class="section is-main-section">
+ <div class="tabs is-toggle is-fullwidth is-small">
+ <ul>
+ <li class={!settings.advanceOrderMode ? "is-active" : ""} onClick={() => {
+ updateSettings({
+ ...settings,
+ advanceOrderMode: false
+ })
+ }}>
+ <a >
+ <span><i18n.Translate>Simple</i18n.Translate></span>
+ </a>
+ </li>
+ <li class={settings.advanceOrderMode ? "is-active" : ""} onClick={() => {
+ updateSettings({
+ ...settings,
+ advanceOrderMode: true
+ })
+ }}>
+ <a >
+ <span><i18n.Translate>Advanced</i18n.Translate></span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ {/* // FIXME: translating plural singular */}
+ <InputGroup
+ name="inventory_products"
+ label={i18n.str`Manage products in order`}
+ alternative={
+ allProducts.length > 0 && (
+ <p>
+ {allProducts.length} products with a total price of{" "}
+ {totalAsString}.
+ </p>
+ )
+ }
+ tooltip={i18n.str`Manage list of products in the order.`}
+ >
+ <InventoryProductForm
+ currentProducts={value.inventoryProducts || {}}
+ onAddProduct={addProductToTheInventoryList}
+ inventory={instanceInventory}
+ />
+
+ {settings.advanceOrderMode &&
+ <NonInventoryProductFrom
+ productToEdit={editingProduct}
+ onAddProduct={(p) => {
+ setEditingProduct(undefined);
+ return addNewProduct(p);
+ }}
+ />
+ }
+
+ {allProducts.length > 0 && (
+ <ProductList
+ list={allProducts}
+ actions={[
+ {
+ name: i18n.str`Remove`,
+ tooltip: i18n.str`Remove this product from the order.`,
+ handler: (e, index) => {
+ if (e.product_id) {
+ removeProductFromTheInventoryList(e.product_id);
+ } else {
+ removeFromNewProduct(index);
+ setEditingProduct(e);
+ }
+ },
+ },
+ ]}
+ />
+ )}
+ </InputGroup>
+
+ <FormProvider<Entity>
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler as any}
+ >
+ {hasProducts ? (
+ <Fragment>
+ <InputCurrency
+ name="pricing.products_price"
+ label={i18n.str`Total price`}
+ readonly
+ tooltip={i18n.str`total product price added up`}
+ />
+ <InputCurrency
+ name="pricing.order_price"
+ label={i18n.str`Total price`}
+ addonAfter={
+ discountOrRise > 0 &&
+ (discountOrRise < 1
+ ? `discount of %${Math.round(
+ (1 - discountOrRise) * 100,
+ )}`
+ : `rise of %${Math.round((discountOrRise - 1) * 100)}`)
+ }
+ tooltip={i18n.str`Amount to be paid by the customer`}
+ />
+ </Fragment>
+ ) : (
+ <InputCurrency
+ name="pricing.order_price"
+ label={i18n.str`Order price`}
+ tooltip={i18n.str`final order price`}
+ />
+ )}
+
+ <Input
+ name="pricing.summary"
+ inputType="multiline"
+ label={i18n.str`Summary`}
+ tooltip={i18n.str`Title of the order to be shown to the customer`}
+ />
+
+ {settings.advanceOrderMode &&
+ <InputGroup
+ name="shipping"
+ label={i18n.str`Shipping and Fulfillment`}
+ initialActive
+ >
+ <InputDate
+ name="shipping.delivery_date"
+ label={i18n.str`Delivery date`}
+ tooltip={i18n.str`Deadline for physical delivery assured by the merchant.`}
+ />
+ {value.shipping?.delivery_date && (
+ <InputGroup
+ name="shipping.delivery_location"
+ label={i18n.str`Location`}
+ tooltip={i18n.str`address where the products will be delivered`}
+ >
+ <InputLocation name="shipping.delivery_location" />
+ </InputGroup>
+ )}
+ <Input
+ name="shipping.fullfilment_url"
+ label={i18n.str`Fulfillment URL`}
+ tooltip={i18n.str`URL to which the user will be redirected after successful payment.`}
+ />
+ </InputGroup>
+ }
+
+ {(settings.advanceOrderMode || requiresSomeTalerOptions) &&
+ <InputGroup
+ name="payments"
+ label={i18n.str`Taler payment options`}
+ tooltip={i18n.str`Override default Taler payment settings for this order`}
+ >
+ {(settings.advanceOrderMode || noDefault_payDeadline) && <InputDuration
+ name="payments.pay_deadline"
+ label={i18n.str`Payment time`}
+ help={<DeadlineHelp duration={value.payments?.pay_deadline} />}
+ withForever
+ withoutClear
+ tooltip={i18n.str`Time for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline. Time start to run after the order is created.`}
+ side={
+ <span>
+ <button class="button" onClick={() => {
+ const c = {
+ ...value,
+ payments: {
+ ...(value.payments ?? {}),
+ pay_deadline: instance_default.payments?.pay_deadline
+ }
+ }
+ valueHandler(c)
+ }}>
+ <i18n.Translate>default</i18n.Translate>
+ </button>
+ </span>
+ }
+ />}
+ {settings.advanceOrderMode && <InputDuration
+ name="payments.refund_deadline"
+ label={i18n.str`Refund time`}
+ help={<DeadlineHelp duration={value.payments?.refund_deadline} />}
+ withForever
+ withoutClear
+ tooltip={i18n.str`Time while the order can be refunded by the merchant. Time starts after the order is created.`}
+ side={
+ <span>
+ <button class="button" onClick={() => {
+ valueHandler({
+ ...value,
+ payments: {
+ ...(value.payments ?? {}),
+ refund_deadline: instance_default.payments?.refund_deadline
+ }
+ })
+ }}>
+ <i18n.Translate>default</i18n.Translate>
+ </button>
+ </span>
+ }
+ />}
+ {(settings.advanceOrderMode || noDefault_wireDeadline) && <InputDuration
+ name="payments.wire_transfer_deadline"
+ label={i18n.str`Wire transfer time`}
+ help={<DeadlineHelp duration={value.payments?.wire_transfer_deadline} />}
+ withoutClear
+ withForever
+ tooltip={i18n.str`Time for the exchange to make the wire transfer. Time starts after the order is created.`}
+ side={
+ <span>
+ <button class="button" onClick={() => {
+ valueHandler({
+ ...value,
+ payments: {
+ ...(value.payments ?? {}),
+ wire_transfer_deadline: instance_default.payments?.wire_transfer_deadline
+ }
+ })
+ }}>
+ <i18n.Translate>default</i18n.Translate>
+ </button>
+ </span>
+ }
+ />}
+ {settings.advanceOrderMode && <InputDuration
+ name="payments.auto_refund_deadline"
+ label={i18n.str`Auto-refund time`}
+ help={<DeadlineHelp duration={value.payments?.auto_refund_deadline} />}
+ tooltip={i18n.str`Time until which the wallet will automatically check for refunds without user interaction.`}
+ withForever
+ />}
+
+ {settings.advanceOrderMode && <InputCurrency
+ name="payments.max_fee"
+ label={i18n.str`Maximum fee`}
+ tooltip={i18n.str`Maximum fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`}
+ />}
+ {settings.advanceOrderMode && <InputToggle
+ name="payments.createToken"
+ label={i18n.str`Create token`}
+ tooltip={i18n.str`If the order ID is easy to guess the token will prevent user to steal orders from others.`}
+ />}
+ {settings.advanceOrderMode && <InputNumber
+ name="payments.minimum_age"
+ label={i18n.str`Minimum age required`}
+ tooltip={i18n.str`Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products`}
+ help={
+ minAgeByProducts > 0
+ ? i18n.str`Min age defined by the producs is ${minAgeByProducts}`
+ : i18n.str`No product with age restriction in this order`
+ }
+ />}
+ </InputGroup>
+ }
+
+ {settings.advanceOrderMode &&
+ <InputGroup
+ name="extra"
+ label={i18n.str`Additional information`}
+ tooltip={i18n.str`Custom information to be included in the contract for this order.`}
+ >
+ {Object.keys(value.extra ?? {}).map((key) => {
+
+ return <Input
+ name={`extra.${key}`}
+ inputType="multiline"
+ label={key}
+ tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`}
+ side={
+ <button class="button" onClick={(e) => {
+ if (value.extra && value.extra[key] !== undefined) {
+ console.log(value.extra)
+ delete value.extra[key]
+ }
+ valueHandler({
+ ...value,
+ })
+ }}>remove</button>
+ }
+ />
+ })}
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Custom field name</i18n.Translate>
+ <span class="icon has-tooltip-right" data-tooltip={"new extra field"}>
+ <i class="mdi mdi-information" />
+ </span>
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input " value={newField} onChange={(e) => setNewField(e.currentTarget.value)} />
+ </p>
+ </div>
+ </div>
+ <button class="button" onClick={(e) => {
+ setNewField("")
+ valueHandler({
+ ...value,
+ extra: {
+ ...(value.extra ?? {}),
+ [newField]: ""
+ }
+ })
+ }}>add</button>
+ </div>
+ </InputGroup>
+ }
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <button
+ class="button is-success"
+ onClick={submit}
+ disabled={hasErrors}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
+
+function asProduct(p: ProductAndQuantity): MerchantBackend.Product {
+ return {
+ product_id: p.product.id,
+ image: p.product.image,
+ price: p.product.price,
+ unit: p.product.unit,
+ quantity: p.quantity,
+ description: p.product.description,
+ taxes: p.product.taxes,
+ minimum_age: p.product.minimum_age,
+ };
+}
+
+
+function DeadlineHelp({ duration }: { duration?: Duration }): VNode {
+ const { i18n } = useTranslationContext();
+ const [now, setNow] = useState(AbsoluteTime.now())
+ useEffect(() => {
+ const iid = setInterval(() => {
+ setNow(AbsoluteTime.now())
+ }, 60 * 1000)
+ return () => {
+ clearInterval(iid)
+ }
+ })
+ if (!duration) return <i18n.Translate>Disabled</i18n.Translate>
+ const when = AbsoluteTime.addDuration(now, duration)
+ if (when.t_ms === "never") return <i18n.Translate>No deadline</i18n.Translate>
+ return <i18n.Translate>Deadline at {format(when.t_ms, "dd/MM/yy HH:mm")}</i18n.Translate>
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx
new file mode 100644
index 000000000..88a984c97
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx
@@ -0,0 +1,114 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { CreatedSuccessfully } from "../../../../components/notifications/CreatedSuccessfully.js";
+import { useOrderAPI } from "../../../../hooks/order.js";
+import { Entity } from "./index.js";
+
+interface Props {
+ entity: Entity;
+ onConfirm: () => void;
+ onCreateAnother?: () => void;
+}
+
+export function OrderCreatedSuccessfully({
+ entity,
+ onConfirm,
+ onCreateAnother,
+}: Props): VNode {
+ const { getPaymentURL } = useOrderAPI();
+ const [url, setURL] = useState<string | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ useEffect(() => {
+ getPaymentURL(entity.response.order_id).then((response) => {
+ setURL(response.data);
+ });
+ }, [getPaymentURL, entity.response.order_id]);
+
+ return (
+ <CreatedSuccessfully
+ onConfirm={onConfirm}
+ onCreateAnother={onCreateAnother}
+ >
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Amount</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ class="input"
+ readonly
+ value={entity.request.order.amount}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Summary</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ class="input"
+ readonly
+ value={entity.request.order.summary}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Order ID</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input" readonly value={entity.response.order_id} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Payment URL</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input" readonly value={url} />
+ </p>
+ </div>
+ </div>
+ </div>
+ </CreatedSuccessfully>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx
new file mode 100644
index 000000000..2474fd042
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx
@@ -0,0 +1,114 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ErrorType, HttpError } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useInstanceDetails } from "../../../../hooks/instance.js";
+import { useOrderAPI } from "../../../../hooks/order.js";
+import { useInstanceProducts } from "../../../../hooks/product.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+export type Entity = {
+ request: MerchantBackend.Orders.PostOrderRequest;
+ response: MerchantBackend.Orders.PostOrderResponse;
+};
+interface Props {
+ onBack?: () => void;
+ onConfirm: (id: string) => void;
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+}
+export default function OrderCreate({
+ onConfirm,
+ onBack,
+ onLoadError,
+ onNotFound,
+ onUnauthorized,
+}: Props): VNode {
+ const { createOrder } = useOrderAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const detailsResult = useInstanceDetails();
+ const inventoryResult = useInstanceProducts();
+
+ if (detailsResult.loading) return <Loading />;
+ if (inventoryResult.loading) return <Loading />;
+
+ if (!detailsResult.ok) {
+ if (
+ detailsResult.type === ErrorType.CLIENT &&
+ detailsResult.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ detailsResult.type === ErrorType.CLIENT &&
+ detailsResult.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(detailsResult);
+ }
+
+ if (!inventoryResult.ok) {
+ if (
+ inventoryResult.type === ErrorType.CLIENT &&
+ inventoryResult.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ inventoryResult.type === ErrorType.CLIENT &&
+ inventoryResult.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(inventoryResult);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: MerchantBackend.Orders.PostOrderRequest) => {
+ createOrder(request)
+ .then((r) => {
+ return onConfirm(r.data.order_id)
+ })
+ .catch((error) => {
+ setNotif({
+ message: "could not create order",
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ instanceConfig={detailsResult.data}
+ instanceInventory={inventoryResult.data}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx
new file mode 100644
index 000000000..6e73a01a5
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx
@@ -0,0 +1,135 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { addDays } from "date-fns";
+import { h, VNode, FunctionalComponent } from "preact";
+import { MerchantBackend } from "../../../../declaration.js";
+import { DetailPage as TestedComponent } from "./DetailPage.js";
+
+export default {
+ title: "Pages/Order/Detail",
+ component: TestedComponent,
+ argTypes: {
+ onRefund: { action: "onRefund" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+const defaultContractTerm = {
+ amount: "TESTKUDOS:10",
+ timestamp: {
+ t_s: new Date().getTime() / 1000,
+ },
+ auditors: [],
+ exchanges: [],
+ max_fee: "TESTKUDOS:1",
+ merchant: {} as any,
+ merchant_base_url: "http://merchant.url/",
+ order_id: "2021.165-03GDFC26Y1NNG",
+ products: [],
+ summary: "text summary",
+ wire_transfer_deadline: {
+ t_s: "never",
+ },
+ refund_deadline: { t_s: "never" },
+ merchant_pub: "ASDASDASDSd",
+ nonce: "QWEQWEQWE",
+ pay_deadline: {
+ t_s: "never",
+ },
+ wire_method: "x-taler-bank",
+ h_wire: "asd",
+} as MerchantBackend.ContractTerms;
+
+// contract_terms: defaultContracTerm,
+export const Claimed = createExample(TestedComponent, {
+ id: "2021.165-03GDFC26Y1NNG",
+ selected: {
+ order_status: "claimed",
+ contract_terms: defaultContractTerm,
+ },
+});
+
+export const PaidNotRefundable = createExample(TestedComponent, {
+ id: "2021.165-03GDFC26Y1NNG",
+ selected: {
+ order_status: "paid",
+ contract_terms: defaultContractTerm,
+ refunded: false,
+ deposit_total: "TESTKUDOS:10",
+ exchange_ec: 0,
+ order_status_url: "http://merchant.backend/status",
+ exchange_hc: 0,
+ refund_amount: "TESTKUDOS:0",
+ refund_details: [],
+ refund_pending: false,
+ wire_details: [],
+ wire_reports: [],
+ wired: false,
+ },
+});
+
+export const PaidRefundable = createExample(TestedComponent, {
+ id: "2021.165-03GDFC26Y1NNG",
+ selected: {
+ order_status: "paid",
+ contract_terms: {
+ ...defaultContractTerm,
+ refund_deadline: {
+ t_s: addDays(new Date(), 2).getTime() / 1000,
+ },
+ },
+ refunded: false,
+ deposit_total: "TESTKUDOS:10",
+ exchange_ec: 0,
+ order_status_url: "http://merchant.backend/status",
+ exchange_hc: 0,
+ refund_amount: "TESTKUDOS:0",
+ refund_details: [],
+ refund_pending: false,
+ wire_details: [],
+ wire_reports: [],
+ wired: false,
+ },
+});
+
+export const Unpaid = createExample(TestedComponent, {
+ id: "2021.165-03GDFC26Y1NNG",
+ selected: {
+ order_status: "unpaid",
+ order_status_url: "http://merchant.backend/status",
+ creation_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ summary: "text summary",
+ taler_pay_uri: "pay uri",
+ total_amount: "TESTKUDOS:10",
+ },
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
new file mode 100644
index 000000000..5ff76e37a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
@@ -0,0 +1,770 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format, formatDistance } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider } from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputDate } from "../../../../components/form/InputDate.js";
+import { InputDuration } from "../../../../components/form/InputDuration.js";
+import { InputGroup } from "../../../../components/form/InputGroup.js";
+import { InputLocation } from "../../../../components/form/InputLocation.js";
+import { TextField } from "../../../../components/form/TextField.js";
+import { ProductList } from "../../../../components/product/ProductList.js";
+import { useBackendContext } from "../../../../context/backend.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
+import { mergeRefunds } from "../../../../utils/amount.js";
+import { RefundModal } from "../list/Table.js";
+import { Event, Timeline } from "./Timeline.js";
+
+type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse;
+type CT = MerchantBackend.ContractTerms;
+
+interface Props {
+ onBack: () => void;
+ selected: Entity;
+ id: string;
+ onRefund: (id: string, value: MerchantBackend.Orders.RefundRequest) => void;
+}
+
+type Paid = MerchantBackend.Orders.CheckPaymentPaidResponse & {
+ refund_taken: string;
+};
+type Unpaid = MerchantBackend.Orders.CheckPaymentUnpaidResponse;
+type Claimed = MerchantBackend.Orders.CheckPaymentClaimedResponse;
+
+function ContractTerms({ value }: { value: CT }) {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <InputGroup name="contract_terms" label={i18n.str`Contract Terms`}>
+ <FormProvider<CT> object={value} valueHandler={null}>
+ <Input<CT>
+ readonly
+ name="summary"
+ label={i18n.str`Summary`}
+ tooltip={i18n.str`human-readable description of the whole purchase`}
+ />
+ <InputCurrency<CT>
+ readonly
+ name="amount"
+ label={i18n.str`Amount`}
+ tooltip={i18n.str`total price for the transaction`}
+ />
+ {value.fulfillment_url && (
+ <Input<CT>
+ readonly
+ name="fulfillment_url"
+ label={i18n.str`Fulfillment URL`}
+ tooltip={i18n.str`URL for this purchase`}
+ />
+ )}
+ <Input<CT>
+ readonly
+ name="max_fee"
+ label={i18n.str`Max fee`}
+ tooltip={i18n.str`maximum total deposit fee accepted by the merchant for this contract`}
+ />
+ <InputDate<CT>
+ readonly
+ name="timestamp"
+ label={i18n.str`Created at`}
+ tooltip={i18n.str`time when this contract was generated`}
+ />
+ <InputDate<CT>
+ readonly
+ name="refund_deadline"
+ label={i18n.str`Refund deadline`}
+ tooltip={i18n.str`after this deadline has passed no refunds will be accepted`}
+ />
+ <InputDate<CT>
+ readonly
+ name="pay_deadline"
+ label={i18n.str`Payment deadline`}
+ tooltip={i18n.str`after this deadline, the merchant won't accept payments for the contract`}
+ />
+ <InputDate<CT>
+ readonly
+ name="wire_transfer_deadline"
+ label={i18n.str`Wire transfer deadline`}
+ tooltip={i18n.str`transfer deadline for the exchange`}
+ />
+ <InputDate<CT>
+ readonly
+ name="delivery_date"
+ label={i18n.str`Delivery date`}
+ tooltip={i18n.str`time indicating when the order should be delivered`}
+ />
+ {value.delivery_date && (
+ <InputGroup
+ name="delivery_location"
+ label={i18n.str`Location`}
+ tooltip={i18n.str`where the order will be delivered`}
+ >
+ <InputLocation name="payments.delivery_location" />
+ </InputGroup>
+ )}
+ <InputDuration<CT>
+ readonly
+ name="auto_refund"
+ label={i18n.str`Auto-refund delay`}
+ tooltip={i18n.str`how long the wallet should try to get an automatic refund for the purchase`}
+ />
+ <Input<CT>
+ readonly
+ name="extra"
+ label={i18n.str`Extra info`}
+ tooltip={i18n.str`extra data that is only interpreted by the merchant frontend`}
+ />
+ </FormProvider>
+ </InputGroup>
+ );
+}
+
+function ClaimedPage({
+ id,
+ order,
+}: {
+ id: string;
+ order: MerchantBackend.Orders.CheckPaymentClaimedResponse;
+}) {
+ const events: Event[] = [];
+ if (order.contract_terms.timestamp.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.timestamp.t_s * 1000),
+ description: "order created",
+ type: "start",
+ });
+ }
+ if (order.contract_terms.pay_deadline.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.pay_deadline.t_s * 1000),
+ description: "pay deadline",
+ type: "deadline",
+ });
+ }
+ if (order.contract_terms.refund_deadline.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.refund_deadline.t_s * 1000),
+ description: "refund deadline",
+ type: "deadline",
+ });
+ }
+ if (order.contract_terms.wire_transfer_deadline.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.wire_transfer_deadline.t_s * 1000),
+ description: "wire deadline",
+ type: "deadline",
+ });
+ }
+ if (
+ order.contract_terms.delivery_date &&
+ order.contract_terms.delivery_date.t_s !== "never"
+ ) {
+ events.push({
+ when: new Date(order.contract_terms.delivery_date?.t_s * 1000),
+ description: "delivery",
+ type: "delivery",
+ });
+ }
+
+ const [value, valueHandler] = useState<Partial<Claimed>>(order);
+ const { i18n } = useTranslationContext();
+ const [settings] = useSettings()
+
+ return (
+ <div>
+ <section class="section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-10">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <i18n.Translate>Order</i18n.Translate> #{id}
+ <div class="tag is-info ml-4">
+ <i18n.Translate>claimed</i18n.Translate>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <h1 class="title">{order.contract_terms.amount}</h1>
+ </div>
+ </div>
+ </div>
+
+ <div class="level">
+ <div class="level-left" style={{ maxWidth: "100%" }}>
+ <div class="level-item" style={{ maxWidth: "100%" }}>
+ <div
+ class="content"
+ style={{
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ }}
+ >
+ <p>
+ <b>
+ <i18n.Translate>claimed at</i18n.Translate>:
+ </b>{" "}
+ {format(
+ new Date(order.contract_terms.timestamp.t_s * 1000),
+ datetimeFormatForSettings(settings)
+ )}
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="columns">
+ <div class="column is-4">
+ <div class="title">
+ <i18n.Translate>Timeline</i18n.Translate>
+ </div>
+ <Timeline events={events} />
+ </div>
+ <div class="column is-8">
+ <div class="title">
+ <i18n.Translate>Payment details</i18n.Translate>
+ </div>
+ <FormProvider<Claimed>
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <Input
+ name="contract_terms.summary"
+ readonly
+ inputType="multiline"
+ label={i18n.str`Summary`}
+ />
+ <InputCurrency
+ name="contract_terms.amount"
+ readonly
+ label={i18n.str`Amount`}
+ />
+ <Input<Claimed>
+ name="order_status"
+ readonly
+ label={i18n.str`Order status`}
+ />
+ </FormProvider>
+ </div>
+ </div>
+ </section>
+
+ {order.contract_terms.products.length ? (
+ <Fragment>
+ <div class="title">
+ <i18n.Translate>Product list</i18n.Translate>
+ </div>
+ <ProductList list={order.contract_terms.products} />
+ </Fragment>
+ ) : undefined}
+
+ {value.contract_terms && (
+ <ContractTerms value={value.contract_terms} />
+ )}
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
+function PaidPage({
+ id,
+ order,
+ onRefund,
+}: {
+ id: string;
+ order: MerchantBackend.Orders.CheckPaymentPaidResponse;
+ onRefund: (id: string) => void;
+}) {
+ const events: Event[] = [];
+ if (order.contract_terms.timestamp.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.timestamp.t_s * 1000),
+ description: "order created",
+ type: "start",
+ });
+ }
+ if (order.contract_terms.pay_deadline.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.pay_deadline.t_s * 1000),
+ description: "pay deadline",
+ type: "deadline",
+ });
+ }
+ if (order.contract_terms.refund_deadline.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.refund_deadline.t_s * 1000),
+ description: "refund deadline",
+ type: "deadline",
+ });
+ }
+ if (order.contract_terms.wire_transfer_deadline.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.wire_transfer_deadline.t_s * 1000),
+ description: "wire deadline",
+ type: "deadline",
+ });
+ }
+ if (
+ order.contract_terms.delivery_date &&
+ order.contract_terms.delivery_date.t_s !== "never"
+ ) {
+ if (order.contract_terms.delivery_date)
+ events.push({
+ when: new Date(order.contract_terms.delivery_date?.t_s * 1000),
+ description: "delivery",
+ type: "delivery",
+ });
+ }
+ order.refund_details.reduce(mergeRefunds, []).forEach((e) => {
+ if (e.timestamp.t_s !== "never") {
+ events.push({
+ when: new Date(e.timestamp.t_s * 1000),
+ description: `refund: ${e.amount}: ${e.reason}`,
+ type: e.pending ? "refund" : "refund-taken",
+ });
+ }
+ });
+ if (order.wire_details && order.wire_details.length) {
+ if (order.wire_details.length > 1) {
+ let last: MerchantBackend.Orders.TransactionWireTransfer | null = null;
+ let first: MerchantBackend.Orders.TransactionWireTransfer | null = null;
+ let total: AmountJson | null = null;
+
+ order.wire_details.forEach((w) => {
+ if (last === null || last.execution_time.t_s < w.execution_time.t_s) {
+ last = w;
+ }
+ if (first === null || first.execution_time.t_s > w.execution_time.t_s) {
+ first = w;
+ }
+ total =
+ total === null
+ ? Amounts.parseOrThrow(w.amount)
+ : Amounts.add(total, Amounts.parseOrThrow(w.amount)).amount;
+ });
+ const last_time = last!.execution_time.t_s;
+ if (last_time !== "never") {
+ events.push({
+ when: new Date(last_time * 1000),
+ description: `wired ${Amounts.stringify(total!)}`,
+ type: "wired-range",
+ });
+ }
+ const first_time = first!.execution_time.t_s;
+ if (first_time !== "never") {
+ events.push({
+ when: new Date(first_time * 1000),
+ description: `wire transfer started...`,
+ type: "wired-range",
+ });
+ }
+ } else {
+ order.wire_details.forEach((e) => {
+ if (e.execution_time.t_s !== "never") {
+ events.push({
+ when: new Date(e.execution_time.t_s * 1000),
+ description: `wired ${e.amount}`,
+ type: "wired",
+ });
+ }
+ });
+ }
+ }
+
+ const now = new Date()
+ const nextEvent = events.find((e) => {
+ return e.when.getTime() > now.getTime()
+ })
+
+ const [value, valueHandler] = useState<Partial<Paid>>(order);
+ const { url: backendURL } = useBackendContext()
+ const refundurl = stringifyRefundUri({
+ merchantBaseUrl: backendURL,
+ orderId: order.contract_terms.order_id
+ })
+ const refundable =
+ new Date().getTime() < order.contract_terms.refund_deadline.t_s * 1000;
+ const { i18n } = useTranslationContext();
+
+ const amount = Amounts.parseOrThrow(order.contract_terms.amount);
+ const refund_taken = order.refund_details.reduce((prev, cur) => {
+ if (cur.pending) return prev;
+ return Amounts.add(prev, Amounts.parseOrThrow(cur.amount)).amount;
+ }, Amounts.zeroOfCurrency(amount.currency));
+ value.refund_taken = Amounts.stringify(refund_taken);
+
+ return (
+ <div>
+ <section class="section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-10">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <i18n.Translate>Order</i18n.Translate> #{id}
+ <div class="tag is-success ml-4">
+ <i18n.Translate>paid</i18n.Translate>
+ </div>
+ {order.wired ? (
+ <div class="tag is-success ml-4">
+ <i18n.Translate>wired</i18n.Translate>
+ </div>
+ ) : null}
+ {order.refunded ? (
+ <div class="tag is-danger ml-4">
+ <i18n.Translate>refunded</i18n.Translate>
+ </div>
+ ) : null}
+ </div>
+ </div>
+ </div>
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <h1 class="title">{order.contract_terms.amount}</h1>
+ </div>
+ </div>
+ <div class="level-right">
+ <div class="level-item">
+ <h1 class="title">
+ <div class="buttons">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={
+ refundable
+ ? i18n.str`refund order`
+ : i18n.str`not refundable`
+ }
+ >
+ <button
+ class="button is-danger"
+ disabled={!refundable}
+ onClick={() => onRefund(id)}
+ >
+ <i18n.Translate>refund</i18n.Translate>
+ </button>
+ </span>
+ </div>
+ </h1>
+ </div>
+ </div>
+ </div>
+
+ <div class="level">
+ <div class="level-left" style={{ maxWidth: "100%" }}>
+ <div class="level-item" style={{ maxWidth: "100%" }}>
+ <div
+ class="content"
+ style={{
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ }}
+ >
+ {nextEvent &&
+ <p>
+ <i18n.Translate>Next event in </i18n.Translate> {formatDistance(
+ nextEvent.when,
+ new Date(),
+ // "yyyy/MM/dd HH:mm:ss",
+ )}
+ </p>
+ }
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="columns">
+ <div class="column is-4">
+ <div class="title">
+ <i18n.Translate>Timeline</i18n.Translate>
+ </div>
+ <Timeline events={events} />
+ </div>
+ <div class="column is-8">
+ <div class="title">
+ <i18n.Translate>Payment details</i18n.Translate>
+ </div>
+ <FormProvider<Paid>
+ object={value}
+ valueHandler={valueHandler}
+ >
+ {/* <InputCurrency<Paid> name="deposit_total" readonly label={i18n.str`Deposit total`} /> */}
+ {order.refunded && (
+ <InputCurrency<Paid>
+ name="refund_amount"
+ readonly
+ label={i18n.str`Refunded amount`}
+ />
+ )}
+ {order.refunded && (
+ <InputCurrency<Paid>
+ name="refund_taken"
+ readonly
+ label={i18n.str`Refund taken`}
+ />
+ )}
+ <Input<Paid>
+ name="order_status"
+ readonly
+ label={i18n.str`Order status`}
+ />
+ <TextField<Paid>
+ name="order_status_url"
+ label={i18n.str`Status URL`}
+ >
+ <a
+ target="_blank"
+ rel="noreferrer"
+ href={order.order_status_url}
+ >
+ {order.order_status_url}
+ </a>
+ </TextField>
+ {order.refunded && (
+ <TextField<Paid>
+ name="order_status_url"
+ label={i18n.str`Refund URI`}
+ >
+ <a target="_blank" rel="noreferrer" href={refundurl}>
+ {refundurl}
+ </a>
+ </TextField>
+ )}
+ </FormProvider>
+ </div>
+ </div>
+ </section>
+
+ {order.contract_terms.products.length ? (
+ <Fragment>
+ <div class="title">
+ <i18n.Translate>Product list</i18n.Translate>
+ </div>
+ <ProductList list={order.contract_terms.products} />
+ </Fragment>
+ ) : undefined}
+
+ {value.contract_terms && (
+ <ContractTerms value={value.contract_terms} />
+ )}
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
+
+function UnpaidPage({
+ id,
+ order,
+}: {
+ id: string;
+ order: MerchantBackend.Orders.CheckPaymentUnpaidResponse;
+}) {
+ const [value, valueHandler] = useState<Partial<Unpaid>>(order);
+ const { i18n } = useTranslationContext();
+ const [settings] = useSettings()
+ return (
+ <div>
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <h1 class="title">
+ <i18n.Translate>Order</i18n.Translate> #{id}
+ </h1>
+ </div>
+ <div class="tag is-dark">
+ <i18n.Translate>unpaid</i18n.Translate>
+ </div>
+ </div>
+ </div>
+
+ <div class="level">
+ <div class="level-left" style={{ maxWidth: "100%" }}>
+ <div class="level-item" style={{ maxWidth: "100%" }}>
+ <div
+ class="content"
+ style={{
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ }}
+ >
+ <p>
+ <b>
+ <i18n.Translate>pay at</i18n.Translate>:
+ </b>{" "}
+ <a
+ href={order.order_status_url}
+ rel="nofollow"
+ target="new"
+ >
+ {order.order_status_url}
+ </a>
+ </p>
+ <p>
+ <b>
+ <i18n.Translate>created at</i18n.Translate>:
+ </b>{" "}
+ {order.creation_time.t_s === "never"
+ ? "never"
+ : format(
+ new Date(order.creation_time.t_s * 1000),
+ datetimeFormatForSettings(settings)
+ )}
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider<Unpaid> object={value} valueHandler={valueHandler}>
+ <Input<Unpaid>
+ readonly
+ name="summary"
+ label={i18n.str`Summary`}
+ tooltip={i18n.str`human-readable description of the whole purchase`}
+ />
+ <InputCurrency<Unpaid>
+ readonly
+ name="total_amount"
+ label={i18n.str`Amount`}
+ tooltip={i18n.str`total price for the transaction`}
+ />
+ <Input<Unpaid>
+ name="order_status"
+ readonly
+ label={i18n.str`Order status`}
+ />
+ <Input<Unpaid>
+ name="order_status_url"
+ readonly
+ label={i18n.str`Order status URL`}
+ />
+ <TextField<Unpaid>
+ name="taler_pay_uri"
+ label={i18n.str`Payment URI`}
+ >
+ <a target="_blank" rel="noreferrer" href={value.taler_pay_uri}>
+ {value.taler_pay_uri}
+ </a>
+ </TextField>
+ </FormProvider>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
+
+export function DetailPage({ id, selected, onRefund, onBack }: Props): VNode {
+ const [showRefund, setShowRefund] = useState<string | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const DetailByStatus = function () {
+ switch (selected.order_status) {
+ case "claimed":
+ return <ClaimedPage id={id} order={selected} />;
+ case "paid":
+ return <PaidPage id={id} order={selected} onRefund={setShowRefund} />;
+ case "unpaid":
+ return <UnpaidPage id={id} order={selected} />;
+ default:
+ return (
+ <div>
+ <i18n.Translate>
+ Unknown order status. This is an error, please contact the
+ administrator.
+ </i18n.Translate>
+ </div>
+ );
+ }
+ };
+
+ return (
+ <Fragment>
+ {DetailByStatus()}
+ {showRefund && (
+ <RefundModal
+ order={selected}
+ onCancel={() => setShowRefund(undefined)}
+ onConfirm={(value) => {
+ onRefund(showRefund, value);
+ setShowRefund(undefined);
+ }}
+ />
+ )}
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <div class="buttons is-right mt-5">
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Back</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </Fragment>
+ );
+}
+
+async function copyToClipboard(text: string) {
+ return navigator.clipboard.writeText(text);
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx
new file mode 100644
index 000000000..8c863f386
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx
@@ -0,0 +1,129 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { format } from "date-fns";
+import { h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
+
+interface Props {
+ events: Event[];
+}
+
+export function Timeline({ events: e }: Props) {
+ const events = [...e];
+ events.push({
+ when: new Date(),
+ description: "now",
+ type: "now",
+ });
+
+ events.sort((a, b) => a.when.getTime() - b.when.getTime());
+ const [settings] = useSettings();
+ const [state, setState] = useState(events);
+ useEffect(() => {
+ const handle = setTimeout(() => {
+ const eventsWithoutNow = state.filter((e) => e.type !== "now");
+ eventsWithoutNow.push({
+ when: new Date(),
+ description: "now",
+ type: "now",
+ });
+ setState(eventsWithoutNow);
+ }, 1000);
+ return () => {
+ clearTimeout(handle);
+ };
+ });
+ return (
+ <div class="timeline">
+ {events.map((e, i) => {
+ return (
+ <div key={i} class="timeline-item">
+ {(() => {
+ switch (e.type) {
+ case "deadline":
+ return (
+ <div class="timeline-marker is-icon ">
+ <i class="mdi mdi-flag" />
+ </div>
+ );
+ case "delivery":
+ return (
+ <div class="timeline-marker is-icon ">
+ <i class="mdi mdi-delivery" />
+ </div>
+ );
+ case "start":
+ return (
+ <div class="timeline-marker is-icon">
+ <i class="mdi mdi-flag " />
+ </div>
+ );
+ case "wired":
+ return (
+ <div class="timeline-marker is-icon is-success">
+ <i class="mdi mdi-cash" />
+ </div>
+ );
+ case "wired-range":
+ return (
+ <div class="timeline-marker is-icon is-success">
+ <i class="mdi mdi-cash" />
+ </div>
+ );
+ case "refund":
+ return (
+ <div class="timeline-marker is-icon is-danger">
+ <i class="mdi mdi-cash" />
+ </div>
+ );
+ case "refund-taken":
+ return (
+ <div class="timeline-marker is-icon is-success">
+ <i class="mdi mdi-cash" />
+ </div>
+ );
+ case "now":
+ return (
+ <div class="timeline-marker is-icon is-info">
+ <i class="mdi mdi-clock" />
+ </div>
+ );
+ }
+ })()}
+ <div class="timeline-content">
+ {e.description !== "now" && <p class="heading">{format(e.when, datetimeFormatForSettings(settings))}</p>}
+ <p>{e.description}</p>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ );
+}
+export interface Event {
+ when: Date;
+ description: string;
+ type:
+ | "start"
+ | "refund"
+ | "refund-taken"
+ | "wired"
+ | "wired-range"
+ | "deadline"
+ | "delivery"
+ | "now";
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/index.tsx
new file mode 100644
index 000000000..1517a3c42
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/index.tsx
@@ -0,0 +1,95 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ useTranslationContext,
+ HttpError,
+ ErrorType,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useOrderAPI, useOrderDetails } from "../../../../hooks/order.js";
+import { Notification } from "../../../../utils/types.js";
+import { DetailPage } from "./DetailPage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+export interface Props {
+ oid: string;
+
+ onBack: () => void;
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+}
+
+export default function Update({
+ oid,
+ onBack,
+ onLoadError,
+ onNotFound,
+ onUnauthorized,
+}: Props): VNode {
+ const { refundOrder } = useOrderAPI();
+ const result = useOrderDetails(oid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <DetailPage
+ onBack={onBack}
+ id={oid}
+ onRefund={(id, value) =>
+ refundOrder(id, value)
+ .then(() =>
+ setNotif({
+ message: i18n.str`refund created successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not create the refund`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ )
+ }
+ selected={result.data}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx
new file mode 100644
index 000000000..156c577f4
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx
@@ -0,0 +1,107 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/Order/List",
+ component: TestedComponent,
+ argTypes: {
+ onShowAll: { action: "onShowAll" },
+ onShowPaid: { action: "onShowPaid" },
+ onShowRefunded: { action: "onShowRefunded" },
+ onShowNotWired: { action: "onShowNotWired" },
+ onCopyURL: { action: "onCopyURL" },
+ onSelectDate: { action: "onSelectDate" },
+ onLoadMoreBefore: { action: "onLoadMoreBefore" },
+ onLoadMoreAfter: { action: "onLoadMoreAfter" },
+ onSelectOrder: { action: "onSelectOrder" },
+ onRefundOrder: { action: "onRefundOrder" },
+ onSearchOrderById: { action: "onSearchOrderById" },
+ onCreate: { action: "onCreate" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ orders: [
+ {
+ id: "123",
+ amount: "TESTKUDOS:10",
+ paid: false,
+ refundable: true,
+ row_id: 1,
+ summary: "summary",
+ timestamp: {
+ t_s: new Date().getTime() / 1000,
+ },
+ order_id: "123",
+ },
+ {
+ id: "234",
+ amount: "TESTKUDOS:12",
+ paid: true,
+ refundable: true,
+ row_id: 2,
+ summary:
+ "summary with long text, very very long text that someone want to add as a description of the order",
+ timestamp: {
+ t_s: new Date().getTime() / 1000,
+ },
+ order_id: "234",
+ },
+ {
+ id: "456",
+ amount: "TESTKUDOS:1",
+ paid: false,
+ refundable: false,
+ row_id: 3,
+ summary:
+ "summary with long text, very very long text that someone want to add as a description of the order",
+ timestamp: {
+ t_s: new Date().getTime() / 1000,
+ },
+ order_id: "456",
+ },
+ {
+ id: "234",
+ amount: "TESTKUDOS:12",
+ paid: false,
+ refundable: false,
+ row_id: 4,
+ summary:
+ "summary with long text, very very long text that someone want to add as a description of the order",
+ timestamp: {
+ t_s: new Date().getTime() / 1000,
+ },
+ order_id: "234",
+ },
+ ],
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx
new file mode 100644
index 000000000..9f80719a1
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx
@@ -0,0 +1,226 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { h, VNode, Fragment } from "preact";
+import { useState } from "preact/hooks";
+import { DatePicker } from "../../../../components/picker/DatePicker.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import { CardTable } from "./Table.js";
+import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
+
+export interface ListPageProps {
+ onShowAll: () => void;
+ onShowNotPaid: () => void;
+ onShowPaid: () => void;
+ onShowRefunded: () => void;
+ onShowNotWired: () => void;
+ onShowWired: () => void;
+ onCopyURL: (id: string) => void;
+ isAllActive: string;
+ isPaidActive: string;
+ isNotPaidActive: string;
+ isRefundedActive: string;
+ isNotWiredActive: string;
+ isWiredActive: string;
+
+ jumpToDate?: Date;
+ onSelectDate: (date?: Date) => void;
+
+ orders: (MerchantBackend.Orders.OrderHistoryEntry & WithId)[];
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+
+ onSelectOrder: (o: MerchantBackend.Orders.OrderHistoryEntry & WithId) => void;
+ onRefundOrder: (o: MerchantBackend.Orders.OrderHistoryEntry & WithId) => void;
+ onCreate: () => void;
+}
+
+export function ListPage({
+ hasMoreAfter,
+ hasMoreBefore,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+ orders,
+ isAllActive,
+ onSelectOrder,
+ onRefundOrder,
+ jumpToDate,
+ onCopyURL,
+ onShowAll,
+ onShowPaid,
+ onShowNotPaid,
+ onShowRefunded,
+ onShowNotWired,
+ onShowWired,
+ onSelectDate,
+ isPaidActive,
+ isRefundedActive,
+ isNotWiredActive,
+ onCreate,
+ isNotPaidActive,
+ isWiredActive,
+}: ListPageProps): VNode {
+ const { i18n } = useTranslationContext();
+ const dateTooltip = i18n.str`select date to show nearby orders`;
+ const [pickDate, setPickDate] = useState(false);
+ const [settings] = useSettings();
+
+ return (
+ <Fragment>
+ <div class="columns">
+ <div class="column is-two-thirds">
+ <div class="tabs" style={{ overflow: "inherit" }}>
+ <ul>
+ <li class={isNotPaidActive}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`only show paid orders`}
+ >
+ <a onClick={onShowNotPaid}>
+ <i18n.Translate>New</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isPaidActive}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`only show paid orders`}
+ >
+ <a onClick={onShowPaid}>
+ <i18n.Translate>Paid</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isRefundedActive}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`only show orders with refunds`}
+ >
+ <a onClick={onShowRefunded}>
+ <i18n.Translate>Refunded</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isNotWiredActive}>
+ <div
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`only show orders where customers paid, but wire payments from payment provider are still pending`}
+ >
+ <a onClick={onShowNotWired}>
+ <i18n.Translate>Not wired</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isWiredActive}>
+ <div
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`only show orders where customers paid, but wire payments from payment provider are still pending`}
+ >
+ <a onClick={onShowWired}>
+ <i18n.Translate>Completed</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isAllActive}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`remove all filters`}
+ >
+ <a onClick={onShowAll}>
+ <i18n.Translate>All</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ <div class="column ">
+ <div class="buttons is-right">
+ <div class="field has-addons">
+ {jumpToDate && (
+ <div class="control">
+ <a class="button is-fullwidth" onClick={() => onSelectDate(undefined)}>
+ <span
+ class="icon"
+ data-tooltip={i18n.str`clear date filter`}
+ >
+ <i class="mdi mdi-close" />
+ </span>
+ </a>
+ </div>
+ )}
+ <div class="control">
+ <span class="has-tooltip-top" data-tooltip={dateTooltip}>
+ <input
+ class="input"
+ type="text"
+ readonly
+ value={!jumpToDate ? "" : format(jumpToDate, dateFormatForSettings(settings))}
+ placeholder={i18n.str`date (${dateFormatForSettings(settings)})`}
+ onClick={() => {
+ setPickDate(true);
+ }}
+ />
+ </span>
+ </div>
+ <div class="control">
+ <span class="has-tooltip-left" data-tooltip={dateTooltip}>
+ <a
+ class="button is-fullwidth"
+ onClick={() => {
+ setPickDate(true);
+ }}
+ >
+ <span class="icon">
+ <i class="mdi mdi-calendar" />
+ </span>
+ </a>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DatePicker
+ opened={pickDate}
+ closeFunction={() => setPickDate(false)}
+ dateReceiver={onSelectDate}
+ />
+
+ <CardTable
+ orders={orders}
+ onCreate={onCreate}
+ onCopyURL={onCopyURL}
+ onSelect={onSelectOrder}
+ onRefund={onRefundOrder}
+ hasMoreAfter={hasMoreAfter}
+ hasMoreBefore={hasMoreBefore}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/Table.tsx
new file mode 100644
index 000000000..b2806bb79
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/Table.tsx
@@ -0,0 +1,417 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputGroup } from "../../../../components/form/InputGroup.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { ConfirmModal } from "../../../../components/modal/index.js";
+import { useConfigContext } from "../../../../context/config.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import { mergeRefunds } from "../../../../utils/amount.js";
+import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
+
+type Entity = MerchantBackend.Orders.OrderHistoryEntry & WithId;
+interface Props {
+ orders: Entity[];
+ onRefund: (value: Entity) => void;
+ onCopyURL: (id: string) => void;
+ onCreate: () => void;
+ onSelect: (order: Entity) => void;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ orders,
+ onCreate,
+ onRefund,
+ onCopyURL,
+ onSelect,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-cash-register" />
+ </span>
+ <i18n.Translate>Orders</i18n.Translate>
+ </p>
+
+ <div class="card-header-icon" aria-label="more options" />
+
+ <div class="card-header-icon" aria-label="more options">
+ <span class="has-tooltip-left" data-tooltip={i18n.str`create order`}>
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {orders.length > 0 ? (
+ <Table
+ instances={orders}
+ onSelect={onSelect}
+ onRefund={onRefund}
+ onCopyURL={(o) => onCopyURL(o.id)}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreAfter={hasMoreAfter}
+ hasMoreBefore={hasMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: Entity[];
+ onRefund: (id: Entity) => void;
+ onCopyURL: (id: Entity) => void;
+ onSelect: (id: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+function Table({
+ instances,
+ onSelect,
+ onRefund,
+ onCopyURL,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = useSettings();
+ return (
+ <div class="table-container">
+ {hasMoreBefore && (
+ <button
+ class="button is-fullwidth"
+ onClick={onLoadMoreBefore}
+ >
+ <i18n.Translate>load newer orders</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th style={{ minWidth: 100 }}>
+ <i18n.Translate>Date</i18n.Translate>
+ </th>
+ <th style={{ minWidth: 100 }}>
+ <i18n.Translate>Amount</i18n.Translate>
+ </th>
+ <th style={{ minWidth: 400 }}>
+ <i18n.Translate>Summary</i18n.Translate>
+ </th>
+ <th style={{ minWidth: 50 }} />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.id}>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.timestamp.t_s === "never"
+ ? "never"
+ : format(
+ new Date(i.timestamp.t_s * 1000),
+ datetimeFormatForSettings(settings),
+ )}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.amount}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.summary}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ {i.refundable && (
+ <button
+ class="button is-small is-danger jb-modal"
+ type="button"
+ onClick={(): void => onRefund(i)}
+ >
+ <i18n.Translate>Refund</i18n.Translate>
+ </button>
+ )}
+ {!i.paid && (
+ <button
+ class="button is-small is-info jb-modal"
+ type="button"
+ onClick={(): void => onCopyURL(i)}
+ >
+ <i18n.Translate>copy url</i18n.Translate>
+ </button>
+ )}
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {hasMoreAfter && (
+ <button
+ class="button is-fullwidth"
+ onClick={onLoadMoreAfter}
+ >
+ <i18n.Translate>load older orders</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ No orders have been found matching your query!
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
+
+interface RefundModalProps {
+ onCancel: () => void;
+ onConfirm: (value: MerchantBackend.Orders.RefundRequest) => void;
+ order: MerchantBackend.Orders.MerchantOrderStatusResponse;
+}
+
+export function RefundModal({
+ order,
+ onCancel,
+ onConfirm,
+}: RefundModalProps): VNode {
+ type State = { mainReason?: string; description?: string; refund?: string };
+ const [form, setValue] = useState<State>({});
+ const [settings] = useSettings();
+ const { i18n } = useTranslationContext();
+ // const [errors, setErrors] = useState<FormErrors<State>>({});
+
+ const refunds = (
+ order.order_status === "paid" ? order.refund_details : []
+ ).reduce(mergeRefunds, []);
+
+ const config = useConfigContext();
+ const totalRefunded = refunds
+ .map((r) => r.amount)
+ .reduce(
+ (p, c) => Amounts.add(p, Amounts.parseOrThrow(c)).amount,
+ Amounts.zeroOfCurrency(config.currency),
+ );
+ const orderPrice =
+ order.order_status === "paid"
+ ? Amounts.parseOrThrow(order.contract_terms.amount)
+ : undefined;
+ const totalRefundable = !orderPrice
+ ? Amounts.zeroOfCurrency(totalRefunded.currency)
+ : refunds.length
+ ? Amounts.sub(orderPrice, totalRefunded).amount
+ : orderPrice;
+
+ const isRefundable = Amounts.isNonZero(totalRefundable);
+ const duplicatedText = i18n.str`duplicated`;
+
+ const errors: FormErrors<State> = {
+ mainReason: !form.mainReason ? i18n.str`required` : undefined,
+ description:
+ !form.description && form.mainReason !== duplicatedText
+ ? i18n.str`required`
+ : undefined,
+ refund: !form.refund
+ ? i18n.str`required`
+ : !Amounts.parse(form.refund)
+ ? i18n.str`invalid format`
+ : Amounts.cmp(totalRefundable, Amounts.parse(form.refund)!) === -1
+ ? i18n.str`this value exceed the refundable amount`
+ : undefined,
+ };
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const validateAndConfirm = () => {
+ try {
+ if (!form.refund) return;
+ onConfirm({
+ refund: Amounts.stringify(
+ Amounts.add(Amounts.parse(form.refund)!, totalRefunded).amount,
+ ),
+ reason:
+ form.description === undefined
+ ? form.mainReason || ""
+ : `${form.mainReason}: ${form.description}`,
+ });
+ } catch (err) {
+ console.log(err);
+ }
+ };
+
+ //FIXME: parameters in the translation
+ return (
+ <ConfirmModal
+ description="refund"
+ danger
+ active
+ disabled={!isRefundable || hasErrors}
+ onCancel={onCancel}
+ onConfirm={validateAndConfirm}
+ >
+ {refunds.length > 0 && (
+ <div class="columns">
+ <div class="column is-12">
+ <InputGroup
+ name="asd"
+ label={`${Amounts.stringify(totalRefunded)} was already refunded`}
+ >
+ <table class="table is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>date</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>amount</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>reason</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {refunds.map((r) => {
+ return (
+ <tr key={r.timestamp.t_s}>
+ <td>
+ {r.timestamp.t_s === "never"
+ ? "never"
+ : format(
+ new Date(r.timestamp.t_s * 1000),
+ datetimeFormatForSettings(settings),
+ )}
+ </td>
+ <td>{r.amount}</td>
+ <td>{r.reason}</td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </InputGroup>
+ </div>
+ </div>
+ )}
+
+ {isRefundable && (
+ <FormProvider<State>
+ errors={errors}
+ object={form}
+ valueHandler={(d) => setValue(d as any)}
+ >
+ <InputCurrency<State>
+ name="refund"
+ label={i18n.str`Refund`}
+ tooltip={i18n.str`amount to be refunded`}
+ >
+ <i18n.Translate>Max refundable:</i18n.Translate>{" "}
+ {Amounts.stringify(totalRefundable)}
+ </InputCurrency>
+ <InputSelector
+ name="mainReason"
+ label={i18n.str`Reason`}
+ values={[
+ i18n.str`Choose one...`,
+ duplicatedText,
+ i18n.str`requested by the customer`,
+ i18n.str`other`,
+ ]}
+ tooltip={i18n.str`why this order is being refunded`}
+ />
+ {form.mainReason && form.mainReason !== duplicatedText ? (
+ <Input<State>
+ label={i18n.str`Description`}
+ name="description"
+ tooltip={i18n.str`more information to give context`}
+ />
+ ) : undefined}
+ </FormProvider>
+ )}
+ </ConfirmModal>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/index.tsx
new file mode 100644
index 000000000..92e714fb8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/list/index.tsx
@@ -0,0 +1,231 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import {
+ InstanceOrderFilter,
+ useInstanceOrders,
+ useOrderAPI,
+ useOrderDetails,
+} from "../../../../hooks/order.js";
+import { Notification } from "../../../../utils/types.js";
+import { ListPage } from "./ListPage.js";
+import { RefundModal } from "./Table.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import { JumpToElementById } from "../../../../components/form/JumpToElementById.js";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onNotFound: () => VNode;
+ onSelect: (id: string) => void;
+ onCreate: () => void;
+}
+
+export default function OrderList({
+ onUnauthorized,
+ onLoadError,
+ onCreate,
+ onSelect,
+ onNotFound,
+}: Props): VNode {
+ const [filter, setFilter] = useState<InstanceOrderFilter>({ paid: "no" });
+ const [orderToBeRefunded, setOrderToBeRefunded] = useState<
+ MerchantBackend.Orders.OrderHistoryEntry | undefined
+ >(undefined);
+
+ const setNewDate = (date?: Date): void =>
+ setFilter((prev) => ({ ...prev, date }));
+
+ const result = useInstanceOrders(filter, setNewDate);
+ const { refundOrder, getPaymentURL } = useOrderAPI();
+
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ const isNotPaidActive = filter.paid === "no" ? "is-active" : "";
+ const isPaidActive = filter.paid === "yes" && filter.wired === undefined ? "is-active" : "";
+ const isRefundedActive = filter.refunded === "yes" ? "is-active" : "";
+ const isNotWiredActive = filter.wired === "no" && filter.paid === "yes" ? "is-active" : "";
+ const isWiredActive = filter.wired === "yes" ? "is-active" : "";
+ const isAllActive =
+ filter.paid === undefined &&
+ filter.refunded === undefined &&
+ filter.wired === undefined
+ ? "is-active"
+ : "";
+
+ return (
+ <section class="section is-main-section">
+ <NotificationCard notification={notif} />
+
+ <JumpToElementById
+ testIfExist={getPaymentURL}
+ onSelect={onSelect}
+ description={i18n.str`jump to order with the given product ID`}
+ placeholder={i18n.str`order id`}
+ />
+
+ <ListPage
+ orders={result.data.orders.map((o) => ({ ...o, id: o.order_id }))}
+ onLoadMoreBefore={result.loadMorePrev}
+ hasMoreBefore={!result.isReachingStart}
+ onLoadMoreAfter={result.loadMore}
+ hasMoreAfter={!result.isReachingEnd}
+ onSelectOrder={(order) => onSelect(order.id)}
+ onRefundOrder={(value) => setOrderToBeRefunded(value)}
+ isAllActive={isAllActive}
+ isNotWiredActive={isNotWiredActive}
+ isWiredActive={isWiredActive}
+ isPaidActive={isPaidActive}
+ isNotPaidActive={isNotPaidActive}
+ isRefundedActive={isRefundedActive}
+ jumpToDate={filter.date}
+ onCopyURL={(id) =>
+ getPaymentURL(id).then((resp) => copyToClipboard(resp.data))
+ }
+ onCreate={onCreate}
+ onSelectDate={setNewDate}
+ onShowAll={() => setFilter({})}
+ onShowNotPaid={() => setFilter({ paid: "no" })}
+ onShowPaid={() => setFilter({ paid: "yes" })}
+ onShowRefunded={() => setFilter({ refunded: "yes" })}
+ onShowNotWired={() => setFilter({ wired: "no", paid: "yes" })}
+ onShowWired={() => setFilter({ wired: "yes" })}
+ />
+
+ {orderToBeRefunded && (
+ <RefundModalForTable
+ id={orderToBeRefunded.order_id}
+ onCancel={() => setOrderToBeRefunded(undefined)}
+ onConfirm={(value) =>
+ refundOrder(orderToBeRefunded.order_id, value)
+ .then(() =>
+ setNotif({
+ message: i18n.str`refund created successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not create the refund`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ )
+ .then(() => setOrderToBeRefunded(undefined))
+ }
+ onLoadError={(error) => {
+ setNotif({
+ message: i18n.str`could not create the refund`,
+ type: "ERROR",
+ description: error.message,
+ });
+ setOrderToBeRefunded(undefined);
+ return <div />;
+ }}
+ onUnauthorized={onUnauthorized}
+ onNotFound={() => {
+ setNotif({
+ message: i18n.str`could not get the order to refund`,
+ type: "ERROR",
+ // description: error.message
+ });
+ setOrderToBeRefunded(undefined);
+ return <div />;
+ }}
+ />
+ )}
+ </section>
+ );
+}
+
+interface RefundProps {
+ id: string;
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onNotFound: () => VNode;
+ onCancel: () => void;
+ onConfirm: (m: MerchantBackend.Orders.RefundRequest) => void;
+}
+
+function RefundModalForTable({
+ id,
+ onUnauthorized,
+ onLoadError,
+ onNotFound,
+ onConfirm,
+ onCancel,
+}: RefundProps): VNode {
+ const result = useOrderDetails(id);
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <RefundModal
+ order={result.data}
+ onCancel={onCancel}
+ onConfirm={onConfirm}
+ />
+ );
+}
+
+async function copyToClipboard(text: string): Promise<void> {
+ return navigator.clipboard.writeText(text);
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx
new file mode 100644
index 000000000..26f851cc8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/OtpDevices/Create",
+ component: TestedComponent,
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx
new file mode 100644
index 000000000..ffeaa064a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx
@@ -0,0 +1,179 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { isRfc3548Base32Charset, randomRfc3548Base32Key } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
+import { useBackendContext } from "../../../../context/backend.js";
+import { MerchantBackend } from "../../../../declaration.js";
+
+type Entity = MerchantBackend.OTP.OtpDeviceAddDetails;
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+const algorithms = [0, 1, 2];
+const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"];
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const backend = useBackendContext();
+
+ const [state, setState] = useState<Partial<Entity>>({});
+
+ const [showKey, setShowKey] = useState(false);
+
+ const errors: FormErrors<Entity> = {
+ otp_device_id: !state.otp_device_id
+ ? i18n.str`required`
+ : !/[a-zA-Z0-9]*/.test(state.otp_device_id)
+ ? i18n.str`no valid. only characters and numbers`
+ : undefined,
+ otp_algorithm: !state.otp_algorithm ? i18n.str`required` : undefined,
+ otp_key: !state.otp_key
+ ? i18n.str`required`
+ : !isRfc3548Base32Charset(state.otp_key)
+ ? i18n.str`just letters and numbers from 2 to 7`
+ : state.otp_key.length !== 32
+ ? i18n.str`size of the key should be 32`
+ : undefined,
+ otp_device_description: !state.otp_device_description
+ ? i18n.str`required`
+ : !/[a-zA-Z0-9]*/.test(state.otp_device_description)
+ ? i18n.str`no valid. only characters and numbers`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onCreate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <Input<Entity>
+ name="otp_device_id"
+ label={i18n.str`ID`}
+ tooltip={i18n.str`Internal id on the system`}
+ />
+ <Input<Entity>
+ name="otp_device_description"
+ label={i18n.str`Descripiton`}
+ tooltip={i18n.str`Useful to identify the device physically`}
+ />
+ <InputSelector<Entity>
+ name="otp_algorithm"
+ label={i18n.str`Verification algorithm`}
+ tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`}
+ values={algorithms}
+ toStr={(v) => algorithmsNames[v]}
+ fromStr={(v) => Number(v)}
+ />
+ {state.otp_algorithm && state.otp_algorithm > 0 ? (
+ <Fragment>
+ <InputWithAddon<Entity>
+ expand
+ name="otp_key"
+ label={i18n.str`Device key`}
+ inputType={showKey ? "text" : "password"}
+ help="Be sure to be very hard to guess or use the random generator"
+ tooltip={i18n.str`Your device need to have exactly the same value`}
+ fromStr={(v) => v.toUpperCase()}
+ addonAfterAction={() => {
+ setShowKey(!showKey);
+ }}
+ addonAfter={
+ <span class="icon">
+ {showKey ? (
+ <i class="mdi mdi-eye" />
+ ) : (
+ <i class="mdi mdi-eye-off" />
+ )}
+ </span>
+ }
+ side={
+ <button
+ data-tooltip={i18n.str`generate random secret key`}
+ class="button is-info mr-3"
+ onClick={(e) => {
+ setState((s) => ({
+ ...s,
+ otp_key: randomRfc3548Base32Key(),
+ }));
+ }}
+ >
+ <i18n.Translate>random</i18n.Translate>
+ </button>
+ }
+ />
+ </Fragment>
+ ) : undefined}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx
new file mode 100644
index 000000000..db3842711
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx
@@ -0,0 +1,104 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { QR } from "../../../../components/exception/QR.js";
+import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
+import { useInstanceContext } from "../../../../context/instance.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useBackendContext } from "../../../../context/backend.js";
+
+type Entity = MerchantBackend.OTP.OtpDeviceAddDetails;
+
+interface Props {
+ entity: Entity;
+ onConfirm: () => void;
+}
+
+function isNotUndefined<X>(x: X | undefined): x is X {
+ return !!x;
+}
+
+export function CreatedSuccessfully({
+ entity,
+ onConfirm,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { url: backendURL } = useBackendContext()
+ const { id: instanceId } = useInstanceContext();
+ const issuer = new URL(backendURL).hostname;
+ const qrText = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`;
+ const qrTextSafe = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`;
+
+ return (
+ <Template onConfirm={onConfirm} >
+ <p class="is-size-5">
+ <i18n.Translate>
+ You can scan the next QR code with your device or safe the key before continue.
+ </i18n.Translate>
+ </p>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">ID</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ readonly
+ class="input"
+ value={entity.otp_device_id}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label"><i18n.Translate>Description</i18n.Translate></label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ class="input"
+ readonly
+ value={entity.otp_device_description}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ <QR
+ text={qrText}
+ />
+ <div
+ style={{
+ color: "grey",
+ fontSize: "small",
+ width: 200,
+ textAlign: "center",
+ margin: "auto",
+ wordBreak: "break-all",
+ }}
+ >
+ {qrTextSafe}
+ </div>
+ </Template>
+ );
+}
+
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx
new file mode 100644
index 000000000..648846793
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useWebhookAPI } from "../../../../hooks/webhooks.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+import { useOtpDeviceAPI } from "../../../../hooks/otp.js";
+import { CreatedSuccessfully } from "./CreatedSuccessfully.js";
+
+export type Entity = MerchantBackend.OTP.OtpDeviceAddDetails;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+
+export default function CreateValidator({ onConfirm, onBack }: Props): VNode {
+ const { createOtpDevice } = useOtpDeviceAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const [created, setCreated] = useState<MerchantBackend.OTP.OtpDeviceAddDetails | null>(null)
+
+ if (created) {
+ return <CreatedSuccessfully entity={created} onConfirm={onConfirm} />
+ }
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: Entity) => {
+ return createOtpDevice(request)
+ .then((d) => {
+ setCreated(request)
+ })
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create device`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx
new file mode 100644
index 000000000..b18049674
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/OtpDevices/List",
+ component: TestedComponent,
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx
new file mode 100644
index 000000000..4efee9781
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx
@@ -0,0 +1,64 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { CardTable } from "./Table.js";
+
+export interface Props {
+ devices: MerchantBackend.OTP.OtpDeviceEntry[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+ onCreate: () => void;
+ onDelete: (e: MerchantBackend.OTP.OtpDeviceEntry) => void;
+ onSelect: (e: MerchantBackend.OTP.OtpDeviceEntry) => void;
+}
+
+export function ListPage({
+ devices,
+ onCreate,
+ onDelete,
+ onSelect,
+ onLoadMoreBefore,
+ onLoadMoreAfter,
+}: Props): VNode {
+ const form = { payto_uri: "" };
+
+ const { i18n } = useTranslationContext();
+ return (
+ <section class="section is-main-section">
+ <CardTable
+ devices={devices.map((o) => ({
+ ...o,
+ id: String(o.otp_device_id),
+ }))}
+ onCreate={onCreate}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreBefore={!onLoadMoreBefore}
+ onLoadMoreAfter={onLoadMoreAfter}
+ hasMoreAfter={!onLoadMoreAfter}
+ />
+ </section>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx
new file mode 100644
index 000000000..0c28027fe
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx
@@ -0,0 +1,211 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import { MerchantBackend } from "../../../../declaration.js";
+
+type Entity = MerchantBackend.OTP.OtpDeviceEntry;
+
+interface Props {
+ devices: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ onCreate: () => void;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ devices,
+ onCreate,
+ onDelete,
+ onSelect,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <i18n.Translate>OTP Devices</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new devices`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {devices.length > 0 ? (
+ <Table
+ instances={devices}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreAfter={hasMoreAfter}
+ hasMoreBefore={hasMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+function toggleSelected<T>(id: T): (prev: T[]) => T[] {
+ return (prev: T[]): T[] =>
+ prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id);
+}
+
+function Table({
+ instances,
+ onLoadMoreAfter,
+ onDelete,
+ onSelect,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ {hasMoreBefore && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more devices before the first one`}
+ onClick={onLoadMoreBefore}
+ >
+ <i18n.Translate>load newer devices</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>ID</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Description</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.otp_device_id}>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.otp_device_id}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.otp_device_id}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected devices from the database`}
+ onClick={() => onDelete(i)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {hasMoreAfter && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more devices after the last one`}
+ onClick={onLoadMoreAfter}
+ >
+ <i18n.Translate>load older devices</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no devices yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx
new file mode 100644
index 000000000..2aae8738a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx
@@ -0,0 +1,106 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useInstanceOtpDevices, useOtpDeviceAPI } from "../../../../hooks/otp.js";
+import { Notification } from "../../../../utils/types.js";
+import { ListPage } from "./ListPage.js";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onNotFound: () => VNode;
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+}
+
+export default function ListOtpDevices({
+ onUnauthorized,
+ onLoadError,
+ onCreate,
+ onSelect,
+ onNotFound,
+}: Props): VNode {
+ const [position, setPosition] = useState<string | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { deleteOtpDevice } = useOtpDeviceAPI();
+ const result = useInstanceOtpDevices({ position }, (id) => setPosition(id));
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <ListPage
+ devices={result.data.otp_devices}
+ onLoadMoreBefore={
+ result.isReachingStart ? result.loadMorePrev : undefined
+ }
+ onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined}
+ onCreate={onCreate}
+ onSelect={(e) => {
+ onSelect(e.otp_device_id);
+ }}
+ onDelete={(e: MerchantBackend.OTP.OtpDeviceEntry) =>
+ deleteOtpDevice(e.otp_device_id)
+ .then(() =>
+ setNotif({
+ message: i18n.str`validator delete successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not delete the validator`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ )
+ }
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx
new file mode 100644
index 000000000..d6b1d65e0
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/OtpDevices/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx
new file mode 100644
index 000000000..85bb272aa
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx
@@ -0,0 +1,186 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { randomRfc3548Base32Key } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+
+type Entity = MerchantBackend.OTP.OtpDevicePatchDetails & WithId;
+
+interface Props {
+ onUpdate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+ device: Entity;
+}
+const algorithms = [0, 1, 2];
+const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"];
+export function UpdatePage({ device, onUpdate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>(device);
+ const [showKey, setShowKey] = useState(false);
+
+ const errors: FormErrors<Entity> = {};
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onUpdate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ Device: <b>{device.id}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <Input<Entity>
+ name="otp_device_description"
+ label={i18n.str`Description`}
+ tooltip={i18n.str`Useful to identify the device physically`}
+ />
+ <InputSelector<Entity>
+ name="otp_algorithm"
+ label={i18n.str`Verification algorithm`}
+ tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`}
+ values={algorithms}
+ toStr={(v) => algorithmsNames[v]}
+ fromStr={(v) => Number(v)}
+ />
+ {state.otp_algorithm && state.otp_algorithm > 0 ? (
+ <Fragment>
+ <InputWithAddon<Entity>
+ name="otp_key"
+ label={i18n.str`Device key`}
+ readonly={state.otp_key === undefined}
+ inputType={showKey ? "text" : "password"}
+ help={
+ state.otp_key === undefined
+ ? "Not modified"
+ : "Be sure to be very hard to guess or use the random generator"
+ }
+ tooltip={i18n.str`Your device need to have exactly the same value`}
+ fromStr={(v) => v.toUpperCase()}
+ addonAfterAction={() => {
+ setShowKey(!showKey);
+ }}
+ addonAfter={
+ <span
+ class="icon"
+ onClick={() => {
+ setShowKey(!showKey);
+ }}
+ >
+ {showKey ? (
+ <i class="mdi mdi-eye" />
+ ) : (
+ <i class="mdi mdi-eye-off" />
+ )}
+ </span>
+ }
+ side={
+ state.otp_key === undefined ? (
+ <button
+ onClick={(e) => {
+ setState((s) => ({ ...s, otp_key: "" }));
+ }}
+ class="button"
+ >
+ change key
+ </button>
+ ) : (
+ <button
+ data-tooltip={i18n.str`generate random secret key`}
+ class="button is-info mr-3"
+ onClick={(e) => {
+ setState((s) => ({
+ ...s,
+ otp_key: randomRfc3548Base32Key(),
+ }));
+ }}
+ >
+ <i18n.Translate>random</i18n.Translate>
+ </button>
+ )
+ }
+ />
+ </Fragment>
+ ) : undefined}{" "}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ </div>
+ </section>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx
new file mode 100644
index 000000000..52f6c6c29
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx
@@ -0,0 +1,102 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import { Notification } from "../../../../utils/types.js";
+import { UpdatePage } from "./UpdatePage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import { useOtpDeviceAPI, useOtpDeviceDetails } from "../../../../hooks/otp.js";
+
+export type Entity = MerchantBackend.OTP.OtpDevicePatchDetails & WithId;
+
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ vid: string;
+}
+export default function UpdateValidator({
+ vid,
+ onConfirm,
+ onBack,
+ onUnauthorized,
+ onNotFound,
+ onLoadError,
+}: Props): VNode {
+ const { updateOtpDevice } = useOtpDeviceAPI();
+ const result = useOtpDeviceDetails(vid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ device={{
+ id: vid,
+ otp_algorithm: result.data.otp_algorithm,
+ otp_device_description: result.data.device_description,
+ otp_key: undefined,
+ otp_ctr: result.data.otp_ctr
+ }}
+ onBack={onBack}
+ onUpdate={(data) => {
+ return updateOtpDevice(vid, data)
+ .then(onConfirm)
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not update template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/popup/Settings.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/create/Create.stories.tsx
index 06e33c9d3..2fc0819bb 100644
--- a/packages/taler-wallet-webextension/src/popup/Settings.stories.tsx
+++ b/packages/auditor-backoffice-ui/src/paths/instance/products/create/Create.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2021-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,29 +15,29 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { createExample } from '../test-utils';
-import { SettingsView as TestedComponent } from './Settings';
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
export default {
- title: 'popup/settings',
+ title: "Pages/Product/Create",
component: TestedComponent,
argTypes: {
- setDeviceName: () => Promise.resolve(),
- }
+ onCreate: { action: "onCreate" },
+ onBack: { action: "onBack" },
+ },
};
-export const AllOff = createExample(TestedComponent, {
- deviceName: 'this-is-the-device-name',
- setDeviceName: () => Promise.resolve(),
-});
-
-export const OneChecked = createExample(TestedComponent, {
- deviceName: 'this-is-the-device-name',
- permissionsEnabled: true,
- setDeviceName: () => Promise.resolve(),
-});
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+export const Example = createExample(TestedComponent, {});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx
new file mode 100644
index 000000000..becaf8f3a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx
@@ -0,0 +1,80 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import { ProductForm } from "../../../../components/product/ProductForm.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useListener } from "../../../../hooks/listener.js";
+
+type Entity = MerchantBackend.Products.ProductAddDetail & {
+ product_id: string;
+};
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const [submitForm, addFormSubmitter] = useListener<Entity | undefined>(
+ (result) => {
+ if (result) return onCreate(result);
+ return Promise.reject();
+ },
+ );
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <ProductForm onSubscribe={addFormSubmitter} />
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ onClick={submitForm}
+ data-tooltip={
+ !submitForm
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ disabled={!submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx
new file mode 100644
index 000000000..6b02430cc
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx
@@ -0,0 +1,72 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
+import { Entity } from "./index.js";
+import emptyImage from "../../assets/empty.png";
+
+interface Props {
+ entity: Entity;
+ onConfirm: () => void;
+ onCreateAnother?: () => void;
+}
+
+export function CreatedSuccessfully({
+ entity,
+ onConfirm,
+ onCreateAnother,
+}: Props): VNode {
+ return (
+ <Template onConfirm={onConfirm} onCreateAnother={onCreateAnother}>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Image</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <img src={entity.image} style={{ width: 200, height: 200 }} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Description</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <textarea class="input" readonly value={entity.description} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Price</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input" readonly value={entity.price} />
+ </p>
+ </div>
+ </div>
+ </div>
+ </Template>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/create/index.tsx
new file mode 100644
index 000000000..0c30ff14c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/products/create/index.tsx
@@ -0,0 +1,47 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { AuditorBackend, MerchantBackend } from "../../../../declaration.js";
+import { useProductAPI } from "../../../../hooks/product.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+
+export type Entity = MerchantBackend.Products.ProductDetail;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+export default function CreateProduct({ onConfirm, onBack }: Props): VNode {
+ const { createProduct } = useProductAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/list/List.stories.tsx
new file mode 100644
index 000000000..c2c4d548c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/products/list/List.stories.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CardTable as TestedComponent } from "./Table.js";
+
+export default {
+ title: "Pages/Product/List",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ onSelect: { action: "onSelect" },
+ onDelete: { action: "onDelete" },
+ onUpdate: { action: "onUpdate" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ instances: [
+ {
+ id: "orderid",
+ description: "description1",
+ description_i18n: {} as any,
+ image: "",
+ price: "TESTKUDOS:10",
+ taxes: [],
+ total_lost: 10,
+ total_sold: 5,
+ total_stock: 15,
+ unit: "bar",
+ address: {},
+ },
+ ],
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/list/Table.tsx
new file mode 100644
index 000000000..275f855cb
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/products/list/Table.tsx
@@ -0,0 +1,496 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import emptyImage from "../../../../assets/empty.png";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputNumber } from "../../../../components/form/InputNumber.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
+
+type Entity = MerchantBackend.Products.ProductDetail & WithId;
+
+interface Props {
+ instances: Entity[];
+ onDelete: (id: Entity) => void;
+ onSelect: (product: Entity) => void;
+ onUpdate: (
+ id: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ) => Promise<void>;
+ onCreate: () => void;
+ selected?: boolean;
+}
+
+export function CardTable({
+ instances,
+ onCreate,
+ onSelect,
+ onUpdate,
+ onDelete,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string | undefined>(
+ undefined,
+ );
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-shopping" />
+ </span>
+ <i18n.Translate>Inventory</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add product to inventory`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {instances.length > 0 ? (
+ <Table
+ instances={instances}
+ onSelect={onSelect}
+ onDelete={onDelete}
+ onUpdate={onUpdate}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string | undefined;
+ instances: Entity[];
+ onSelect: (id: Entity) => void;
+ onUpdate: (
+ id: string,
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ) => Promise<void>;
+ onDelete: (id: Entity) => void;
+ rowSelectionHandler: StateUpdater<string | undefined>;
+}
+
+function Table({
+ rowSelection,
+ rowSelectionHandler,
+ instances,
+ onSelect,
+ onUpdate,
+ onDelete,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = useSettings();
+ return (
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Image</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Description</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Price per unit</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Taxes</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Sales</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Stock</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Sold</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ const restStockInfo = !i.next_restock
+ ? ""
+ : i.next_restock.t_s === "never"
+ ? "never"
+ : `restock at ${format(
+ new Date(i.next_restock.t_s * 1000),
+ dateFormatForSettings(settings),
+ )}`;
+ let stockInfo: ComponentChildren = "";
+ if (i.total_stock < 0) {
+ stockInfo = "infinite";
+ } else {
+ const totalStock = i.total_stock - i.total_lost - i.total_sold;
+ stockInfo = (
+ <label title={restStockInfo}>
+ {totalStock} {i.unit}
+ </label>
+ );
+ }
+
+ const isFree = Amounts.isZero(Amounts.parseOrThrow(i.price));
+
+ return (
+ <Fragment key={i.id}>
+ <tr key="info">
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ <img
+ src={i.image ? i.image : emptyImage}
+ style={{
+ border: "solid black 1px",
+ maxHeight: "2em",
+ width: "auto",
+ height: "auto",
+ }}
+ />
+ </td>
+ <td
+ class="has-tooltip-right"
+ data-tooltip={i.description}
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ {i.description.length > 30 ? i.description.substring(0, 30) + "..." : i.description}
+ </td>
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ {isFree ? i18n.str`free` : `${i.price} / ${i.unit}`}
+ </td>
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ {sum(i.taxes)}
+ </td>
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ {difference(i.price, sum(i.taxes))}
+ </td>
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ {stockInfo}
+ </td>
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ <span style={{"whiteSpace":"nowrap"}}>
+
+ {i.total_sold} {i.unit}
+ </span>
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <span
+ class="has-tooltip-bottom"
+ data-tooltip={i18n.str`go to product update page`}
+ >
+ <button
+ class="button is-small is-success "
+ type="button"
+ onClick={(): void => onSelect(i)}
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+ </span>
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`remove this product from the database`}
+ >
+ <button
+ class="button is-small is-danger"
+ type="button"
+ onClick={(): void => onDelete(i)}
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </button>
+ </span>
+ </div>
+ </td>
+ </tr>
+ {rowSelection === i.id && (
+ <tr key="form">
+ <td colSpan={10}>
+ <FastProductUpdateForm
+ product={i}
+ onUpdate={(prod) =>
+ onUpdate(i.id, prod).then((r) =>
+ rowSelectionHandler(undefined),
+ )
+ }
+ onCancel={() => rowSelectionHandler(undefined)}
+ />
+ </td>
+ </tr>
+ )}
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+interface FastProductUpdateFormProps {
+ product: Entity;
+ onUpdate: (
+ data: MerchantBackend.Products.ProductPatchDetail,
+ ) => Promise<void>;
+ onCancel: () => void;
+}
+interface FastProductUpdate {
+ incoming: number;
+ lost: number;
+ price: string;
+}
+interface UpdatePrice {
+ price: string;
+}
+
+function FastProductWithInfiniteStockUpdateForm({
+ product,
+ onUpdate,
+ onCancel,
+}: FastProductUpdateFormProps) {
+ const [value, valueHandler] = useState<UpdatePrice>({ price: product.price });
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <FormProvider<FastProductUpdate>
+ name="added"
+ object={value}
+ valueHandler={valueHandler as any}
+ >
+ <InputCurrency<FastProductUpdate>
+ name="price"
+ label={i18n.str`Price`}
+ tooltip={i18n.str`update the product with new price`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-expanded">
+
+ <div class="buttons mt-5">
+
+ <button class="button mt-5" onClick={onCancel}>
+ <i18n.Translate>Clone</i18n.Translate>
+ </button>
+ </div>
+ <div class="buttons is-right mt-5">
+ <button class="button" onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`update product with new price`}
+ >
+ <button
+ class="button is-info"
+ onClick={() =>
+ onUpdate({
+ ...product,
+ price: value.price,
+ })
+ }
+ >
+ <i18n.Translate>Confirm update</i18n.Translate>
+ </button>
+ </span>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+
+function FastProductWithManagedStockUpdateForm({
+ product,
+ onUpdate,
+ onCancel,
+}: FastProductUpdateFormProps) {
+ const [value, valueHandler] = useState<FastProductUpdate>({
+ incoming: 0,
+ lost: 0,
+ price: product.price,
+ });
+
+ const currentStock =
+ product.total_stock - product.total_sold - product.total_lost;
+
+ const errors: FormErrors<FastProductUpdate> = {
+ lost:
+ currentStock + value.incoming < value.lost
+ ? `lost cannot be greater that current + incoming (max ${currentStock + value.incoming
+ })`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <FormProvider<FastProductUpdate>
+ name="added"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler as any}
+ >
+ <InputNumber<FastProductUpdate>
+ name="incoming"
+ label={i18n.str`Incoming`}
+ tooltip={i18n.str`add more elements to the inventory`}
+ />
+ <InputNumber<FastProductUpdate>
+ name="lost"
+ label={i18n.str`Lost`}
+ tooltip={i18n.str`report elements lost in the inventory`}
+ />
+ <InputCurrency<FastProductUpdate>
+ name="price"
+ label={i18n.str`Price`}
+ tooltip={i18n.str`new price for the product`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ <button class="button" onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <span
+ class="has-tooltip-left"
+ data-tooltip={
+ hasErrors
+ ? i18n.str`the are value with errors`
+ : i18n.str`update product with new stock and price`
+ }
+ >
+ <button
+ class="button is-info"
+ disabled={hasErrors}
+ onClick={() =>
+ onUpdate({
+ ...product,
+ total_stock: product.total_stock + value.incoming,
+ total_lost: product.total_lost + value.lost,
+ price: value.price,
+ })
+ }
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </span>
+ </div>
+ </Fragment>
+ );
+}
+
+function FastProductUpdateForm(props: FastProductUpdateFormProps) {
+ return props.product.total_stock === -1 ? (
+ <FastProductWithInfiniteStockUpdateForm {...props} />
+ ) : (
+ <FastProductWithManagedStockUpdateForm {...props} />
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no products yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
+
+function difference(price: string, tax: number) {
+ if (!tax) return price;
+ const ps = price.split(":");
+ const p = parseInt(ps[1], 10);
+ ps[1] = `${p - tax}`;
+ return ps.join(":");
+}
+function sum(taxes: MerchantBackend.Tax[]) {
+ return taxes.reduce((p, c) => p + parseInt(c.tax.split(":")[1], 10), 0);
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/list/index.tsx
new file mode 100644
index 000000000..34b21daa2
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/products/list/index.tsx
@@ -0,0 +1,150 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import {
+ useInstanceProducts,
+ useProductAPI,
+} from "../../../../hooks/product.js";
+import { Notification } from "../../../../utils/types.js";
+import { CardTable } from "./Table.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import { ConfirmModal, DeleteModal } from "../../../../components/modal/index.js";
+import { JumpToElementById } from "../../../../components/form/JumpToElementById.js";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+}
+export default function ProductList({
+ onUnauthorized,
+ onLoadError,
+ onCreate,
+ onSelect,
+ onNotFound,
+}: Props): VNode {
+ const result = useInstanceProducts();
+ const { deleteProduct, updateProduct, getProduct } = useProductAPI();
+ const [deleting, setDeleting] =
+ useState<MerchantBackend.Products.ProductDetail & WithId | null>(null);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <section class="section is-main-section">
+ <NotificationCard notification={notif} />
+
+ <JumpToElementById
+ testIfExist={getProduct}
+ onSelect={onSelect}
+ description={i18n.str`jump to product with the given product ID`}
+ placeholder={i18n.str`product id`}
+ />
+
+ <CardTable
+ instances={result.data}
+ onCreate={onCreate}
+ onUpdate={(id, prod) =>
+ updateProduct(id, prod)
+ .then(() =>
+ setNotif({
+ message: i18n.str`product updated successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not update the product`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ )
+ }
+ onSelect={(product) => onSelect(product.id)}
+ onDelete={(prod: MerchantBackend.Products.ProductDetail & WithId) =>
+ setDeleting(prod)
+ }
+ />
+
+ {deleting && (
+ <ConfirmModal
+ label={`Delete product`}
+ description={`Delete the product "${deleting.description}"`}
+ danger
+ active
+ onCancel={() => setDeleting(null)}
+ onConfirm={async (): Promise<void> => {
+ try {
+ await deleteProduct(deleting.id);
+ setNotif({
+ message: i18n.str`Product "${deleting.description}" (ID: ${deleting.id}) has been deleted`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`Failed to delete product`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+ setDeleting(null);
+ }}
+ >
+ <p>
+ If you delete the product named <b>&quot;{deleting.description}&quot;</b> (ID:{" "}
+ <b>{deleting.id}</b>), the stock and related information will be lost
+ </p>
+ <p class="warning">
+ Deleting an product <b>cannot be undone</b>.
+ </p>
+ </ConfirmModal>
+ )}
+ </section>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx
new file mode 100644
index 000000000..a85b13b8b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx
@@ -0,0 +1,73 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/Product/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const WithManagedStock = createExample(TestedComponent, {
+ product: {
+ product_id: "20102-ASDAS-QWE",
+ description: "description1",
+ description_i18n: {} as any,
+ image: "",
+ price: "TESTKUDOS:10",
+ taxes: [],
+ total_lost: 10,
+ total_sold: 5,
+ total_stock: 15,
+ unit: "bar",
+ address: {},
+ },
+});
+
+export const WithInfiniteStock = createExample(TestedComponent, {
+ product: {
+ product_id: "20102-ASDAS-QWE",
+ description: "description1",
+ description_i18n: {} as any,
+ image: "",
+ price: "TESTKUDOS:10",
+ taxes: [],
+ total_lost: 10,
+ total_sold: 5,
+ total_stock: -1,
+ unit: "bar",
+ address: {},
+ },
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx
new file mode 100644
index 000000000..97715171e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx
@@ -0,0 +1,99 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import { ProductForm } from "../../../../components/product/ProductForm.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useListener } from "../../../../hooks/listener.js";
+
+type Entity = MerchantBackend.Products.ProductDetail & { product_id: string };
+
+interface Props {
+ onUpdate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+ product: Entity;
+}
+
+export function UpdatePage({ product, onUpdate, onBack }: Props): VNode {
+ const [submitForm, addFormSubmitter] = useListener<Entity | undefined>(
+ (result) => {
+ if (result) return onUpdate(result);
+ return Promise.resolve();
+ },
+ );
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ <i18n.Translate>Product id:</i18n.Translate>
+ <b>{product.product_id}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <ProductForm
+ initial={product}
+ onSubscribe={addFormSubmitter}
+ alreadyExist
+ />
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ onClick={submitForm}
+ data-tooltip={
+ !submitForm
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ disabled={!submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/products/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/products/update/index.tsx
new file mode 100644
index 000000000..8e0f7647f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/products/update/index.tsx
@@ -0,0 +1,95 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useProductAPI, useProductDetails } from "../../../../hooks/product.js";
+import { Notification } from "../../../../utils/types.js";
+import { UpdatePage } from "./UpdatePage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+export type Entity = MerchantBackend.Products.ProductAddDetail;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ pid: string;
+}
+export default function UpdateProduct({
+ pid,
+ onConfirm,
+ onBack,
+ onUnauthorized,
+ onNotFound,
+ onLoadError,
+}: Props): VNode {
+ const { updateProduct } = useProductAPI();
+ const result = useProductDetails(pid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ product={{ ...result.data, product_id: pid }}
+ onBack={onBack}
+ onUpdate={(data) => {
+ return updateProduct(pid, data)
+ .then(onConfirm)
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create product`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/Create.stories.tsx
new file mode 100644
index 000000000..5542c028a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/Create.stories.tsx
@@ -0,0 +1,43 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Reserve/Create",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx
new file mode 100644
index 000000000..e46941b6d
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx
@@ -0,0 +1,277 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpError, RequestError, useApiContext, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { StateUpdater, useEffect, useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import {
+ PAYTO_WIRE_METHOD_LOOKUP,
+ URL_REGEX,
+} from "../../../../utils/constants.js";
+import { useBackendBaseRequest } from "../../../../hooks/backend.js";
+import { parsePaytoUri } from "@gnu-taler/taler-util";
+
+type Entity = MerchantBackend.Rewards.ReserveCreateRequest;
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+enum Steps {
+ EXCHANGE,
+ WIRE_METHOD,
+}
+
+interface ViewProps {
+ step: Steps;
+ setCurrentStep: (s: Steps) => void;
+ reserve: Partial<Entity>;
+ onBack?: () => void;
+ submitForm: () => Promise<void>;
+ setReserve: StateUpdater<Partial<Entity>>;
+}
+function ViewStep({
+ step,
+ setCurrentStep,
+ reserve,
+ onBack,
+ submitForm,
+ setReserve,
+}: ViewProps): VNode {
+ const { i18n } = useTranslationContext();
+ const {request} = useApiContext()
+ const [wireMethods, setWireMethods] = useState<Array<string>>([]);
+ const [exchangeQueryError, setExchangeQueryError] = useState<
+ string | undefined
+ >(undefined);
+
+ useEffect(() => {
+ setExchangeQueryError(undefined);
+ }, [reserve.exchange_url]);
+
+ switch (step) {
+ case Steps.EXCHANGE: {
+ const errors: FormErrors<Entity> = {
+ initial_balance: !reserve.initial_balance
+ ? "cannot be empty"
+ : !(parseInt(reserve.initial_balance.split(":")[1], 10) > 0)
+ ? i18n.str`it should be greater than 0`
+ : undefined,
+ exchange_url: !reserve.exchange_url
+ ? i18n.str`cannot be empty`
+ : !URL_REGEX.test(reserve.exchange_url)
+ ? i18n.str`must be a valid URL`
+ : !exchangeQueryError
+ ? undefined
+ : exchangeQueryError,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ return (
+ <Fragment>
+ <FormProvider<Entity>
+ object={reserve}
+ errors={errors}
+ valueHandler={setReserve}
+ >
+ <InputCurrency<Entity>
+ name="initial_balance"
+ label={i18n.str`Initial balance`}
+ tooltip={i18n.str`balance prior to deposit`}
+ />
+ <Input<Entity>
+ name="exchange_url"
+ label={i18n.str`Exchange URL`}
+ tooltip={i18n.str`URL of exchange`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ class="has-tooltip-left"
+ onClick={() => {
+ if (!reserve.exchange_url) {
+ return Promise.resolve();
+ }
+
+ return request<any>(reserve.exchange_url, "keys")
+ .then((r) => {
+ console.log(r)
+ if (r.loading) return;
+ if (r.ok) {
+ const wireMethods = r.data.accounts.map((a: any) => {
+ const p = parsePaytoUri(a.payto_uri);
+ const r = p?.targetType
+ return r
+ }).filter((x:any) => !!x);
+ setWireMethods(Array.from(new Set(wireMethods)));
+ }
+ setCurrentStep(Steps.WIRE_METHOD);
+ return;
+ })
+ .catch((r: RequestError<{}>) => {
+ console.log(r.cause)
+ setExchangeQueryError(r.cause.message);
+ });
+ }}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ disabled={hasErrors}
+ >
+ <i18n.Translate>Next</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </Fragment>
+ );
+ }
+
+ case Steps.WIRE_METHOD: {
+ const errors: FormErrors<Entity> = {
+ wire_method: !reserve.wire_method
+ ? i18n.str`cannot be empty`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+ return (
+ <Fragment>
+ <FormProvider<Entity>
+ object={reserve}
+ errors={errors}
+ valueHandler={setReserve}
+ >
+ <InputCurrency<Entity>
+ name="initial_balance"
+ label={i18n.str`Initial balance`}
+ tooltip={i18n.str`balance prior to deposit`}
+ readonly
+ />
+ <Input<Entity>
+ name="exchange_url"
+ label={i18n.str`Exchange URL`}
+ tooltip={i18n.str`URL of exchange`}
+ readonly
+ />
+ <InputSelector<Entity>
+ name="wire_method"
+ label={i18n.str`Wire method`}
+ tooltip={i18n.str`method to use for wire transfer`}
+ values={wireMethods}
+ placeholder={i18n.str`Select one wire method`}
+ />
+ </FormProvider>
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button
+ class="button"
+ onClick={() => setCurrentStep(Steps.EXCHANGE)}
+ >
+ <i18n.Translate>Back</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ onClick={submitForm}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ disabled={hasErrors}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </Fragment>
+ );
+ }
+ }
+}
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const [reserve, setReserve] = useState<Partial<Entity>>({});
+
+ const submitForm = () => {
+ return onCreate(reserve as Entity);
+ };
+
+ const [currentStep, setCurrentStep] = useState(Steps.EXCHANGE);
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <div class="tabs is-toggle is-fullwidth is-small">
+ <ul>
+ <li class={currentStep === Steps.EXCHANGE ? "is-active" : ""}>
+ <a style={{ cursor: "initial" }}>
+ <span>Step 1: Specify exchange</span>
+ </a>
+ </li>
+ <li
+ class={currentStep === Steps.WIRE_METHOD ? "is-active" : ""}
+ >
+ <a style={{ cursor: "initial" }}>
+ <span>Step 2: Select wire method</span>
+ </a>
+ </li>
+ </ul>
+ </div>
+
+ <ViewStep
+ step={currentStep}
+ reserve={reserve}
+ setCurrentStep={setCurrentStep}
+ setReserve={setReserve}
+ submitForm={submitForm}
+ onBack={onBack}
+ />
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.stories.tsx
new file mode 100644
index 000000000..445ca3ef0
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.stories.tsx
@@ -0,0 +1,120 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatedSuccessfully as TestedComponent } from "./CreatedSuccessfully.js";
+import * as tests from "@gnu-taler/web-util/testing";
+
+export default {
+ title: "Pages/Reserve/CreatedSuccessfully",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+export const OneBankAccount = tests.createExample(TestedComponent, {
+ entity: {
+ request: {
+ exchange_url: "http://exchange.taler/",
+ initial_balance: "TESTKUDOS:1",
+ wire_method: "x-taler-bank",
+ },
+ response: {
+ accounts: [
+ {
+ payto_uri: "payto://x-taler-bank/bank.taler:8080/exchange_account",
+ credit_restrictions: [],
+ debit_restrictions: [],
+ master_sig: "asd",
+ conversion_url: "",
+ },
+ ],
+ reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS",
+ },
+ },
+});
+
+export const ThreeBankAccount = tests.createExample(TestedComponent, {
+ entity: {
+ request: {
+ exchange_url: "http://exchange.taler/",
+ initial_balance: "TESTKUDOS:1",
+ wire_method: "x-taler-bank",
+ },
+ response: {
+ accounts: [
+ {
+ payto_uri: "payto://x-taler-bank/bank.taler:8080/exchange_account",
+ credit_restrictions: [],
+ debit_restrictions: [],
+ master_sig: "asd",
+ conversion_url: "",
+ },
+ {
+ payto_uri: "payto://x-taler-bank/bank1.taler:8080/asd",
+ credit_restrictions: [],
+ debit_restrictions: [],
+ master_sig: "asd",
+ conversion_url: "",
+ },
+ {
+ payto_uri: "payto://x-taler-bank/bank2.taler:8080/qwe",
+ credit_restrictions: [],
+ debit_restrictions: [],
+ master_sig: "asd",
+ conversion_url: "",
+ },
+ ],
+ reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS",
+ },
+ },
+});
+
+export const NoBankAccount = tests.createExample(TestedComponent, {
+ entity: {
+ request: {
+ exchange_url: "http://exchange.taler/",
+ initial_balance: "TESTKUDOS:1",
+ wire_method: "x-taler-bank",
+ },
+ response: {
+ accounts: [
+ {
+ payto_uri: "payo://x-talr-bank/bank.taler:8080/exchange_account",
+ credit_restrictions: [],
+ debit_restrictions: [],
+ master_sig: "asd",
+ conversion_url: "",
+ },
+ {
+ payto_uri: "payto://x-taler-bank",
+ credit_restrictions: [],
+ debit_restrictions: [],
+ master_sig: "asd",
+ conversion_url: "",
+ },
+ ],
+ reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS",
+ },
+ },
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx
new file mode 100644
index 000000000..1d512c843
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx
@@ -0,0 +1,190 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { QR } from "../../../../components/exception/QR.js";
+import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
+import { MerchantBackend, WireAccount } from "../../../../declaration.js";
+
+type Entity = {
+ request: MerchantBackend.Rewards.ReserveCreateRequest;
+ response: MerchantBackend.Rewards.ReserveCreateConfirmation;
+};
+
+interface Props {
+ entity: Entity;
+ onConfirm: () => void;
+ onCreateAnother?: () => void;
+}
+
+function isNotUndefined<X>(x: X | undefined): x is X {
+ return !!x;
+}
+
+export function CreatedSuccessfully({
+ entity,
+ onConfirm,
+ onCreateAnother,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Template onConfirm={onConfirm} onCreateAnother={onCreateAnother}>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Amount</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ readonly
+ class="input"
+ value={entity.request.initial_balance}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Wire transfer subject</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ class="input"
+ readonly
+ value={entity.response.reserve_pub}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ <ShowAccountsOfReserveAsQRWithLink
+ accounts={entity.response.accounts ?? []}
+ message={entity.response.reserve_pub}
+ amount={entity.request.initial_balance}
+ />
+ </Template>
+ );
+}
+
+export function ShowAccountsOfReserveAsQRWithLink({
+ accounts,
+ message,
+ amount,
+}: {
+ accounts: WireAccount[];
+ message: string;
+ amount: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const accountsInfo = !accounts
+ ? []
+ : accounts
+ .map((acc) => {
+ const p = parsePaytoUri(acc.payto_uri);
+ if (p) {
+ p.params["message"] = message;
+ p.params["amount"] = amount;
+ }
+ return p;
+ })
+ .filter(isNotUndefined);
+
+ const links = accountsInfo.map((a) => stringifyPaytoUri(a));
+
+ if (links.length === 0) {
+ return (
+ <Fragment>
+ <p class="is-size-5">
+ The reserve have invalid accounts. List of invalid payto URIs below:
+ </p>
+ <ul>
+ {accounts.map((a, idx) => {
+ return <li key={idx}>{a.payto_uri}</li>;
+ })}
+ </ul>
+ </Fragment>
+ );
+ }
+
+ if (links.length === 1) {
+ return (
+ <Fragment>
+ <p class="is-size-5">
+ <i18n.Translate>
+ To complete the setup of the reserve, you must now initiate a wire
+ transfer using the given wire transfer subject and crediting the
+ specified amount to the indicated account of the exchange.
+ </i18n.Translate>
+ </p>
+ <p style={{ margin: 10 }}>
+ <b>Exchange bank account</b>
+ </p>
+ <QR text={links[0]} />
+ <p class="is-size-5">
+ <i18n.Translate>
+ If your system supports RFC 8905, you can do this by opening this
+ URI:
+ </i18n.Translate>
+ </p>
+ <pre>
+ <a target="_blank" rel="noreferrer" href={links[0]}>
+ {links[0]}
+ </a>
+ </pre>
+ </Fragment>
+ );
+ }
+
+ return (
+ <div>
+ <p class="is-size-5">
+ <i18n.Translate>
+ To complete the setup of the reserve, you must now initiate a wire
+ transfer using the given wire transfer subject and crediting the
+ specified amount to one of the indicated account of the exchange.
+ </i18n.Translate>
+ </p>
+
+ <p style={{ margin: 10 }}>
+ <b>Exchange bank accounts</b>
+ </p>
+ <p class="is-size-5">
+ <i18n.Translate>
+ If your system supports RFC 8905, you can do this by clicking on the
+ URI below the QR code:
+ </i18n.Translate>
+ </p>
+ {links.map((link) => {
+ return (
+ <Fragment>
+ <QR text={link} />
+ <pre>
+ <a target="_blank" rel="noreferrer" href={link}>
+ {link}
+ </a>
+ </pre>
+ </Fragment>
+ );
+ })}
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/index.tsx
new file mode 100644
index 000000000..4bbaf1459
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/create/index.tsx
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useReservesAPI } from "../../../../hooks/reserves.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatedSuccessfully } from "./CreatedSuccessfully.js";
+import { CreatePage } from "./CreatePage.js";
+interface Props {
+ onBack: () => void;
+ onConfirm: () => void;
+}
+export default function CreateReserve({ onBack, onConfirm }: Props): VNode {
+ const { createReserve } = useReservesAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ const [createdOk, setCreatedOk] = useState<
+ | {
+ request: MerchantBackend.Rewards.ReserveCreateRequest;
+ response: MerchantBackend.Rewards.ReserveCreateConfirmation;
+ }
+ | undefined
+ >(undefined);
+
+ if (createdOk) {
+ return <CreatedSuccessfully entity={createdOk} onConfirm={onConfirm} />;
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: MerchantBackend.Rewards.ReserveCreateRequest) => {
+ return createReserve(request)
+ .then((r) => setCreatedOk({ request, response: r.data }))
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create reserve`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx
new file mode 100644
index 000000000..d8840eeac
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx
@@ -0,0 +1,266 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ Amounts,
+ parsePaytoUri,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { QR } from "../../../../components/exception/QR.js";
+import { FormProvider } from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputDate } from "../../../../components/form/InputDate.js";
+import { TextField } from "../../../../components/form/TextField.js";
+import { SimpleModal } from "../../../../components/modal/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useRewardDetails } from "../../../../hooks/reserves.js";
+import { RewardInfo } from "./RewardInfo.js";
+import { ShowAccountsOfReserveAsQRWithLink } from "../create/CreatedSuccessfully.js";
+import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
+
+type Entity = MerchantBackend.Rewards.ReserveDetail;
+type CT = MerchantBackend.ContractTerms;
+
+interface Props {
+ onBack: () => void;
+ selected: Entity;
+ id: string;
+}
+
+export function DetailPage({ id, selected, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const didExchangeAckTransfer = Amounts.isNonZero(
+ Amounts.parseOrThrow(selected.exchange_initial_amount),
+ );
+
+ return (
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <div class="section main-section">
+ <FormProvider object={{ ...selected, id }} valueHandler={null}>
+ <InputDate<Entity>
+ name="creation_time"
+ label={i18n.str`Created at`}
+ readonly
+ />
+ <InputDate<Entity>
+ name="expiration_time"
+ label={i18n.str`Valid until`}
+ readonly
+ />
+ <InputCurrency<Entity>
+ name="merchant_initial_amount"
+ label={i18n.str`Created balance`}
+ readonly
+ />
+ <TextField<Entity>
+ name="exchange_url"
+ label={i18n.str`Exchange URL`}
+ readonly
+ >
+ <a target="_blank" rel="noreferrer" href={selected.exchange_url}>
+ {selected.exchange_url}
+ </a>
+ </TextField>
+
+ {didExchangeAckTransfer && (
+ <Fragment>
+ <InputCurrency<Entity>
+ name="exchange_initial_amount"
+ label={i18n.str`Exchange balance`}
+ readonly
+ />
+ <InputCurrency<Entity>
+ name="pickup_amount"
+ label={i18n.str`Picked up`}
+ readonly
+ />
+ <InputCurrency<Entity>
+ name="committed_amount"
+ label={i18n.str`Committed`}
+ readonly
+ />
+ </Fragment>
+ )}
+ <Input name="id" label={i18n.str`Subject`} readonly />
+ </FormProvider>
+
+ {didExchangeAckTransfer ? (
+ <Fragment>
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-cash-register" />
+ </span>
+ <i18n.Translate>Rewards</i18n.Translate>
+ </p>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {selected.rewards && selected.rewards.length > 0 ? (
+ <Table rewards={selected.rewards} />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ ) : selected.accounts ? (
+ <ShowAccountsOfReserveAsQRWithLink
+ accounts={selected.accounts}
+ amount={selected.merchant_initial_amount}
+ message={id}
+ />
+ ) : undefined}
+
+ <div class="buttons is-right mt-5">
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Back</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ No reward has been authorized from this reserve
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
+
+interface TableProps {
+ rewards: MerchantBackend.Rewards.RewardStatusEntry[];
+}
+
+function Table({ rewards }: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Authorized</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Picked up</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Reason</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Expiration</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {rewards.map((t, i) => {
+ return <RewardRow id={t.reward_id} key={i} entry={t} />;
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+function RewardRow({
+ id,
+ entry,
+}: {
+ id: string;
+ entry: MerchantBackend.Rewards.RewardStatusEntry;
+}) {
+ const [selected, setSelected] = useState(false);
+ const result = useRewardDetails(id);
+ const [settings] = useSettings();
+ if (result.loading) {
+ return (
+ <tr>
+ <td>...</td>
+ <td>...</td>
+ <td>...</td>
+ <td>...</td>
+ </tr>
+ );
+ }
+ if (!result.ok) {
+ return (
+ <tr>
+ <td>...</td> {/* authorized */}
+ <td>{entry.total_amount}</td>
+ <td>{entry.reason}</td>
+ <td>...</td> {/* expired */}
+ </tr>
+ );
+ }
+ const info = result.data;
+ function onSelect() {
+ setSelected(true);
+ }
+ return (
+ <Fragment>
+ {selected && (
+ <SimpleModal
+ description="reward"
+ active
+ onCancel={() => setSelected(false)}
+ >
+ <RewardInfo id={id} amount={info.total_authorized} entity={info} />
+ </SimpleModal>
+ )}
+ <tr>
+ <td onClick={onSelect}>{info.total_authorized}</td>
+ <td onClick={onSelect}>{info.total_picked_up}</td>
+ <td onClick={onSelect}>{info.reason}</td>
+ <td onClick={onSelect}>
+ {info.expiration.t_s === "never"
+ ? "never"
+ : format(info.expiration.t_s * 1000, datetimeFormatForSettings(settings))}
+ </td>
+ </tr>
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx
new file mode 100644
index 000000000..41c715f20
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx
@@ -0,0 +1,126 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { DetailPage as TestedComponent } from "./DetailPage.js";
+
+export default {
+ title: "Pages/Reserve/Detail",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Funded = createExample(TestedComponent, {
+ id: "THISISTHERESERVEID",
+ selected: {
+ active: true,
+ committed_amount: "TESTKUDOS:10",
+ creation_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ exchange_initial_amount: "TESTKUDOS:10",
+ expiration_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ merchant_initial_amount: "TESTKUDOS:10",
+ pickup_amount: "TESTKUDOS:10",
+ accounts: [
+ {
+ payto_uri: "payto://x-taler-bank/bank.taler:8080/account",
+ credit_restrictions: [],
+ debit_restrictions: [],
+ master_sig: "",
+ },
+ ],
+ exchange_url: "http://exchange.taler/",
+ },
+});
+
+export const NotYetFunded = createExample(TestedComponent, {
+ id: "THISISTHERESERVEID",
+ selected: {
+ active: true,
+ committed_amount: "TESTKUDOS:10",
+ creation_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ exchange_initial_amount: "TESTKUDOS:0",
+ expiration_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ merchant_initial_amount: "TESTKUDOS:10",
+ pickup_amount: "TESTKUDOS:10",
+ accounts: [
+ {
+ payto_uri: "payto://x-taler-bank/bank.taler:8080/account",
+ credit_restrictions: [],
+ debit_restrictions: [],
+ master_sig: "",
+ },
+ ],
+ exchange_url: "http://exchange.taler/",
+ },
+});
+
+export const FundedWithEmptyRewards = createExample(TestedComponent, {
+ id: "THISISTHERESERVEID",
+ selected: {
+ active: true,
+ committed_amount: "TESTKUDOS:10",
+ creation_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ exchange_initial_amount: "TESTKUDOS:10",
+ expiration_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ merchant_initial_amount: "TESTKUDOS:10",
+ pickup_amount: "TESTKUDOS:10",
+ accounts: [
+ {
+ payto_uri: "payto://x-taler-bank/bank.taler:8080/account",
+ credit_restrictions: [],
+ debit_restrictions: [],
+ master_sig: "",
+ },
+ ],
+ exchange_url: "http://exchange.taler/",
+ rewards: [
+ {
+ reason: "asdasd",
+ reward_id: "123",
+ total_amount: "TESTKUDOS:1",
+ },
+ ],
+ },
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx
new file mode 100644
index 000000000..491028695
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx
@@ -0,0 +1,94 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { format } from "date-fns";
+import { Fragment, h, VNode } from "preact";
+import { useBackendContext } from "../../../../context/backend.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import {
+ datetimeFormatForSettings,
+ useSettings,
+} from "../../../../hooks/useSettings.js";
+
+type Entity = MerchantBackend.Rewards.RewardDetails;
+
+interface Props {
+ id: string;
+ entity: Entity;
+ amount: string;
+}
+
+export function RewardInfo({
+ id: merchantRewardId,
+ amount,
+ entity,
+}: Props): VNode {
+ const { url: backendURL } = useBackendContext();
+ const [settings] = useSettings();
+ const rewardURL = "not-supported";
+ return (
+ <Fragment>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Amount</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input readonly class="input" value={amount} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">URL</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field" style={{ overflowWrap: "anywhere" }}>
+ <p class="control">
+ <a target="_blank" rel="noreferrer" href={rewardURL}>
+ {rewardURL}
+ </a>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Valid until</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ class="input"
+ readonly
+ value={
+ !entity.expiration || entity.expiration.t_s === "never"
+ ? "never"
+ : format(
+ entity.expiration.t_s * 1000,
+ datetimeFormatForSettings(settings),
+ )
+ }
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/index.tsx
new file mode 100644
index 000000000..8e2a74529
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/details/index.tsx
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ErrorType, HttpError } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { Loading } from "../../../../components/exception/loading.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useReserveDetails } from "../../../../hooks/reserves.js";
+import { DetailPage } from "./DetailPage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+interface Props {
+ rid: string;
+
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onNotFound: () => VNode;
+ onDelete: () => void;
+ onBack: () => void;
+}
+export default function DetailReserve({
+ rid,
+ onUnauthorized,
+ onLoadError,
+ onNotFound,
+ onBack,
+ onDelete,
+}: Props): VNode {
+ const result = useReserveDetails(rid);
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+ return (
+ <Fragment>
+ <DetailPage selected={result.data} onBack={onBack} id={rid} />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx
new file mode 100644
index 000000000..e205ee621
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx
@@ -0,0 +1,124 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import * as yup from "yup";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import {
+ ConfirmModal,
+ ContinueModal,
+} from "../../../../components/modal/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { AuthorizeRewardSchema } from "../../../../schemas/index.js";
+import { CreatedSuccessfully } from "./CreatedSuccessfully.js";
+
+interface AuthorizeRewardModalProps {
+ onCancel: () => void;
+ onConfirm: (value: MerchantBackend.Rewards.RewardCreateRequest) => void;
+ rewardAuthorized?: {
+ response: MerchantBackend.Rewards.RewardCreateConfirmation;
+ request: MerchantBackend.Rewards.RewardCreateRequest;
+ };
+}
+
+export function AuthorizeRewardModal({
+ onCancel,
+ onConfirm,
+ rewardAuthorized,
+}: AuthorizeRewardModalProps): VNode {
+ // const result = useOrderDetails(id)
+ type State = MerchantBackend.Rewards.RewardCreateRequest;
+ const [form, setValue] = useState<Partial<State>>({});
+ const { i18n } = useTranslationContext();
+
+ // const [errors, setErrors] = useState<FormErrors<State>>({})
+ let errors: FormErrors<State> = {};
+ try {
+ AuthorizeRewardSchema.validateSync(form, { abortEarly: false });
+ } catch (err) {
+ if (err instanceof yup.ValidationError) {
+ const yupErrors = err.inner as any[];
+ errors = yupErrors.reduce(
+ (prev, cur) =>
+ !cur.path ? prev : { ...prev, [cur.path]: cur.message },
+ {},
+ );
+ }
+ }
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const validateAndConfirm = () => {
+ onConfirm(form as State);
+ };
+ if (rewardAuthorized) {
+ return (
+ <ContinueModal description="reward" active onConfirm={onCancel}>
+ <CreatedSuccessfully
+ entity={rewardAuthorized.response}
+ request={rewardAuthorized.request}
+ onConfirm={onCancel}
+ />
+ </ContinueModal>
+ );
+ }
+
+ return (
+ <ConfirmModal
+ description="New reward"
+ active
+ onCancel={onCancel}
+ disabled={hasErrors}
+ onConfirm={validateAndConfirm}
+ >
+ <FormProvider<State>
+ errors={errors}
+ object={form}
+ valueHandler={setValue}
+ >
+ <InputCurrency<State>
+ name="amount"
+ label={i18n.str`Amount`}
+ tooltip={i18n.str`amount of reward`}
+ />
+ <Input<State>
+ name="justification"
+ label={i18n.str`Justification`}
+ inputType="multiline"
+ tooltip={i18n.str`reason for the reward`}
+ />
+ <Input<State>
+ name="next_url"
+ label={i18n.str`URL after reward`}
+ tooltip={i18n.str`URL to visit after reward payment`}
+ />
+ </FormProvider>
+ </ConfirmModal>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx
new file mode 100644
index 000000000..b78236bc7
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx
@@ -0,0 +1,102 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { format } from "date-fns";
+import { Fragment, h, VNode } from "preact";
+import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
+
+type Entity = MerchantBackend.Rewards.RewardCreateConfirmation;
+
+interface Props {
+ entity: Entity;
+ request: MerchantBackend.Rewards.RewardCreateRequest;
+ onConfirm: () => void;
+ onCreateAnother?: () => void;
+}
+
+export function CreatedSuccessfully({
+ request,
+ entity,
+ onConfirm,
+ onCreateAnother,
+}: Props): VNode {
+ const [settings] = useSettings();
+ return (
+ <Fragment>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Amount</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input readonly class="input" value={request.amount} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Justification</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input readonly class="input" value={request.justification} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">URL</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input readonly class="input" value={entity.reward_status_url} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Valid until</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ class="input"
+ readonly
+ value={
+ !entity.reward_expiration ||
+ entity.reward_expiration.t_s === "never"
+ ? "never"
+ : format(
+ entity.reward_expiration.t_s * 1000,
+ datetimeFormatForSettings(settings),
+ )
+ }
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx
new file mode 100644
index 000000000..b070bbde3
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx
@@ -0,0 +1,96 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CardTable as TestedComponent } from "./Table.js";
+
+export default {
+ title: "Pages/Reserve/List",
+ component: TestedComponent,
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const AllFunded = createExample(TestedComponent, {
+ instances: [
+ {
+ id: "reseverId",
+ active: true,
+ committed_amount: "TESTKUDOS:10",
+ creation_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ exchange_initial_amount: "TESTKUDOS:10",
+ expiration_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ merchant_initial_amount: "TESTKUDOS:10",
+ pickup_amount: "TESTKUDOS:10",
+ reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS",
+ },
+ {
+ id: "reseverId2",
+ active: true,
+ committed_amount: "TESTKUDOS:13",
+ creation_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ exchange_initial_amount: "TESTKUDOS:10",
+ expiration_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ merchant_initial_amount: "TESTKUDOS:10",
+ pickup_amount: "TESTKUDOS:10",
+ reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS",
+ },
+ ],
+});
+
+export const Empty = createExample(TestedComponent, {
+ instances: [],
+});
+
+export const OneNotYetFunded = createExample(TestedComponent, {
+ instances: [
+ {
+ id: "reseverId",
+ active: true,
+ committed_amount: "TESTKUDOS:0",
+ creation_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ exchange_initial_amount: "TESTKUDOS:0",
+ expiration_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ merchant_initial_amount: "TESTKUDOS:10",
+ pickup_amount: "TESTKUDOS:10",
+ reserve_pub: "WEQWDASDQWEASDADASDQWEQWEASDAS",
+ },
+ ],
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/Table.tsx
new file mode 100644
index 000000000..795e7ec82
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/Table.tsx
@@ -0,0 +1,320 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, h, VNode } from "preact";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
+
+type Entity = MerchantBackend.Rewards.ReserveStatusEntry & WithId;
+
+interface Props {
+ instances: Entity[];
+ onNewReward: (id: Entity) => void;
+ onSelect: (id: Entity) => void;
+ onDelete: (id: Entity) => void;
+ onCreate: () => void;
+}
+
+export function CardTable({
+ instances,
+ onCreate,
+ onSelect,
+ onNewReward,
+ onDelete,
+}: Props): VNode {
+ const [withoutFunds, withFunds] = instances.reduce((prev, current) => {
+ const amount = current.exchange_initial_amount;
+ if (amount.endsWith(":0")) {
+ prev[0] = prev[0].concat(current);
+ } else {
+ prev[1] = prev[1].concat(current);
+ }
+ return prev;
+ }, new Array<Array<Entity>>([], []));
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ {withoutFunds.length > 0 && (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-cash" />
+ </span>
+ <i18n.Translate>Reserves not yet funded</i18n.Translate>
+ </p>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ <TableWithoutFund
+ instances={withoutFunds}
+ onNewReward={onNewReward}
+ onSelect={onSelect}
+ onDelete={onDelete}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-cash" />
+ </span>
+ <i18n.Translate>Reserves ready</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options" />
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new reserve`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {withFunds.length > 0 ? (
+ <Table
+ instances={withFunds}
+ onNewReward={onNewReward}
+ onSelect={onSelect}
+ onDelete={onDelete}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+interface TableProps {
+ instances: Entity[];
+ onNewReward: (id: Entity) => void;
+ onDelete: (id: Entity) => void;
+ onSelect: (id: Entity) => void;
+}
+
+function Table({ instances, onNewReward, onSelect, onDelete }: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = useSettings();
+ return (
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Created at</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Expires at</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Initial</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Picked up</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Committed</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.id}>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.creation_time.t_s === "never"
+ ? "never"
+ : format(i.creation_time.t_s * 1000, datetimeFormatForSettings(settings))}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.expiration_time.t_s === "never"
+ ? "never"
+ : format(
+ i.expiration_time.t_s * 1000,
+ datetimeFormatForSettings(settings),
+ )}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.exchange_initial_amount}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.pickup_amount}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.committed_amount}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-small is-danger has-tooltip-left"
+ data-tooltip={i18n.str`delete selected reserve from the database`}
+ type="button"
+ onClick={(): void => onDelete(i)}
+ >
+ Delete
+ </button>
+ <button
+ class="button is-small is-info has-tooltip-left"
+ data-tooltip={i18n.str`authorize new reward from selected reserve`}
+ type="button"
+ onClick={(): void => onNewReward(i)}
+ >
+ New Reward
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no ready reserves yet, add more pressing the + sign or fund
+ them
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
+
+function TableWithoutFund({
+ instances,
+ onSelect,
+ onDelete,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = useSettings();
+ return (
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Created at</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Expires at</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Expected Balance</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.id}>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.creation_time.t_s === "never"
+ ? "never"
+ : format(i.creation_time.t_s * 1000, datetimeFormatForSettings(settings))}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.expiration_time.t_s === "never"
+ ? "never"
+ : format(
+ i.expiration_time.t_s * 1000,
+ datetimeFormatForSettings(settings),
+ )}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.merchant_initial_amount}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-small is-danger jb-modal has-tooltip-left"
+ type="button"
+ data-tooltip={i18n.str`delete selected reserve from the database`}
+ onClick={(): void => onDelete(i)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/index.tsx
new file mode 100644
index 000000000..b26ff0000
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/reserves/list/index.tsx
@@ -0,0 +1,171 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import {
+ useInstanceReserves,
+ useReservesAPI,
+} from "../../../../hooks/reserves.js";
+import { Notification } from "../../../../utils/types.js";
+import { AuthorizeRewardModal } from "./AutorizeRewardModal.js";
+import { CardTable } from "./Table.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import { ConfirmModal } from "../../../../components/modal/index.js";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onSelect: (id: string) => void;
+ onNotFound: () => VNode;
+ onCreate: () => void;
+}
+
+interface RewardConfirmation {
+ response: MerchantBackend.Rewards.RewardCreateConfirmation;
+ request: MerchantBackend.Rewards.RewardCreateRequest;
+}
+
+export default function ListRewards({
+ onUnauthorized,
+ onLoadError,
+ onNotFound,
+ onSelect,
+ onCreate,
+}: Props): VNode {
+ const result = useInstanceReserves();
+ const { deleteReserve, authorizeRewardReserve } = useReservesAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const [reserveForReward, setReserveForReward] = useState<string | undefined>(
+ undefined,
+ );
+ const [deleting, setDeleting] =
+ useState<MerchantBackend.Rewards.ReserveStatusEntry | null>(null);
+ const [rewardAuthorized, setRewardAuthorized] = useState<
+ RewardConfirmation | undefined
+ >(undefined);
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <section class="section is-main-section">
+ <NotificationCard notification={notif} />
+
+ {reserveForReward && (
+ <AuthorizeRewardModal
+ onCancel={() => {
+ setReserveForReward(undefined);
+ setRewardAuthorized(undefined);
+ }}
+ rewardAuthorized={rewardAuthorized}
+ onConfirm={async (request) => {
+ try {
+ const response = await authorizeRewardReserve(
+ reserveForReward,
+ request,
+ );
+ setRewardAuthorized({
+ request,
+ response: response.data,
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`could not create the reward`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ setReserveForReward(undefined);
+ }
+ }}
+ />
+ )}
+
+ <CardTable
+ instances={result.data.reserves
+ .filter((r) => r.active)
+ .map((o) => ({ ...o, id: o.reserve_pub }))}
+ onCreate={onCreate}
+ onDelete={(reserve) => {
+ setDeleting(reserve)
+ }}
+ onSelect={(reserve) => onSelect(reserve.id)}
+ onNewReward={(reserve) => setReserveForReward(reserve.id)}
+ />
+
+ {deleting && (
+ <ConfirmModal
+ label={`Delete reserve`}
+ description={`Delete the reserve`}
+ danger
+ active
+ onCancel={() => setDeleting(null)}
+ onConfirm={async (): Promise<void> => {
+ try {
+ await deleteReserve(deleting.reserve_pub);
+ setNotif({
+ message: i18n.str`Reserve for "${deleting.merchant_initial_amount}" (ID: ${deleting.reserve_pub}) has been deleted`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`Failed to delete reserve`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+ setDeleting(null);
+ }}
+ >
+ <p>
+ If you delete the reserve for <b>&quot;{deleting.merchant_initial_amount}&quot;</b> you won't be able to create more rewards. <br />
+ Reserve ID: <b>{deleting.reserve_pub}</b>
+ </p>
+ <p class="warning">
+ Deleting an template <b>cannot be undone</b>.
+ </p>
+ </ConfirmModal>
+ )}
+
+ </section>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx
new file mode 100644
index 000000000..c9d17ea3b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Templates/Create",
+ component: TestedComponent,
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx
new file mode 100644
index 000000000..947f3572c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx
@@ -0,0 +1,259 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ Amounts,
+ MerchantTemplateContractDetails,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputDuration } from "../../../../components/form/InputDuration.js";
+import { InputNumber } from "../../../../components/form/InputNumber.js";
+import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js";
+import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
+import { useBackendContext } from "../../../../context/backend.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useInstanceOtpDevices } from "../../../../hooks/otp.js";
+import { undefinedIfEmpty } from "../../../../utils/table.js";
+import { InputTab } from "../../../../components/form/InputTab.js";
+
+enum Steps {
+ BOTH_FIXED,
+ FIXED_PRICE,
+ FIXED_SUMMARY,
+ NON_FIXED,
+}
+
+type Entity = MerchantBackend.Template.TemplateAddDetails & { type: Steps };
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { url: backendURL } = useBackendContext()
+ const devices = useInstanceOtpDevices()
+
+ const [state, setState] = useState<Partial<Entity>>({
+ template_contract: {
+ minimum_age: 0,
+ pay_duration: {
+ d_us: 1000 * 1000 * 60 * 30, //30 min
+ },
+ },
+ type: Steps.NON_FIXED,
+ });
+
+ const parsedPrice = !state.template_contract?.amount
+ ? undefined
+ : Amounts.parse(state.template_contract?.amount);
+
+ const errors: FormErrors<Entity> = {
+ template_id: !state.template_id
+ ? i18n.str`should not be empty`
+ : !/[a-zA-Z0-9]*/.test(state.template_id)
+ ? i18n.str`no valid. only characters and numbers`
+ : undefined,
+ template_description: !state.template_description
+ ? i18n.str`should not be empty`
+ : undefined,
+ template_contract: !state.template_contract
+ ? undefined
+ : undefinedIfEmpty({
+ amount: !(state.type === Steps.FIXED_PRICE || state.type === Steps.BOTH_FIXED)
+ ? undefined
+ : !state.template_contract?.amount
+ ? i18n.str`required`
+ : !parsedPrice
+ ? i18n.str`not valid`
+ : Amounts.isZero(parsedPrice)
+ ? i18n.str`must be greater than 0`
+ : undefined,
+ summary: !(state.type === Steps.FIXED_SUMMARY || state.type === Steps.BOTH_FIXED)
+ ? undefined
+ : !state.template_contract?.summary
+ ? i18n.str`required`
+ : undefined,
+ minimum_age:
+ state.template_contract.minimum_age < 0
+ ? i18n.str`should be greater that 0`
+ : undefined,
+ pay_duration: !state.template_contract.pay_duration
+ ? i18n.str`can't be empty`
+ : state.template_contract.pay_duration.d_us === "forever"
+ ? undefined
+ : state.template_contract.pay_duration.d_us < 1000 * 1000 //less than one second
+ ? i18n.str`to short`
+ : undefined,
+ } as Partial<MerchantTemplateContractDetails>),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ if (state.template_contract) {
+ if (state.type === Steps.NON_FIXED) {
+ delete state.template_contract.amount;
+ delete state.template_contract.summary;
+ } else if (state.type === Steps.FIXED_SUMMARY) {
+ delete state.template_contract.amount;
+ } else if (state.type === Steps.FIXED_PRICE) {
+ delete state.template_contract.summary;
+ }
+ }
+ delete state.type
+ return onCreate(state as any);
+ };
+
+ const deviceList = !devices.ok ? [] : devices.data.otp_devices
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputWithAddon<Entity>
+ name="template_id"
+ help={`${backendURL}/templates/${state.template_id ?? ""}`}
+ label={i18n.str`Identifier`}
+ tooltip={i18n.str`Name of the template in URLs.`}
+ />
+ <Input<Entity>
+ name="template_description"
+ label={i18n.str`Description`}
+ help=""
+ tooltip={i18n.str`Describe what this template stands for`}
+ />
+ <InputTab
+ name="type"
+ label={i18n.str`Type`}
+ help={(() => {
+ switch (state.type) {
+ case Steps.NON_FIXED: return i18n.str`User will be able to input price and summary before payment.`
+ case Steps.FIXED_PRICE: return i18n.str`User will be able to add a summary before payment.`
+ case Steps.FIXED_SUMMARY: return i18n.str`User will be able to set the price before payment.`
+ case Steps.BOTH_FIXED: return i18n.str`User will not be able to change the price or the summary.`
+ }
+ })()}
+ tooltip={i18n.str`Define what the user be allowed to modify`}
+ values={[
+ Steps.NON_FIXED,
+ Steps.FIXED_PRICE,
+ Steps.FIXED_SUMMARY,
+ Steps.BOTH_FIXED,
+ ]}
+ toStr={(v: Steps): string => {
+ switch (v) {
+ case Steps.NON_FIXED: return i18n.str`Simple`
+ case Steps.FIXED_PRICE: return i18n.str`With price`
+ case Steps.FIXED_SUMMARY: return i18n.str`With summary`
+ case Steps.BOTH_FIXED: return i18n.str`With price and summary`
+ }
+ }}
+ />
+ {state.type === Steps.BOTH_FIXED || state.type === Steps.FIXED_SUMMARY ?
+ <Input
+ name="template_contract.summary"
+ inputType="multiline"
+ label={i18n.str`Fixed summary`}
+ tooltip={i18n.str`If specified, this template will create order with the same summary`}
+ />
+ : undefined}
+ {state.type === Steps.BOTH_FIXED || state.type === Steps.FIXED_PRICE ?
+ <InputCurrency
+ name="template_contract.amount"
+ label={i18n.str`Fixed price`}
+ tooltip={i18n.str`If specified, this template will create order with the same price`}
+ />
+ : undefined}
+ <InputNumber
+ name="template_contract.minimum_age"
+ label={i18n.str`Minimum age`}
+ help=""
+ tooltip={i18n.str`Is this contract restricted to some age?`}
+ />
+ <InputDuration
+ name="template_contract.pay_duration"
+ label={i18n.str`Payment timeout`}
+ help=""
+ tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`}
+ />
+ <Input<Entity>
+ name="otp_id"
+ label={i18n.str`OTP device`}
+ readonly
+ tooltip={i18n.str`Use to verify transaction in offline mode.`}
+ />
+ <InputSearchOnList
+ label={i18n.str`Search device`}
+ onChange={(p) => setState((v) => ({ ...v, otp_id: p?.id }))}
+ list={deviceList.map(e => ({
+ description: e.device_description,
+ id: e.otp_device_id
+ }))}
+ />
+
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/create/index.tsx
new file mode 100644
index 000000000..a29ee53b6
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/create/index.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useTemplateAPI } from "../../../../hooks/templates.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+
+export type Entity = MerchantBackend.Transfers.TransferInformation;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+
+export default function CreateTransfer({ onConfirm, onBack }: Props): VNode {
+ const { createTemplate } = useTemplateAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: MerchantBackend.Template.TemplateAddDetails) => {
+ return createTemplate(request)
+ .then(() => onConfirm())
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not inform template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx
new file mode 100644
index 000000000..702e9ba4a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/Templates/List",
+ component: TestedComponent,
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx
new file mode 100644
index 000000000..bf6062c34
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { CardTable } from "./Table.js";
+
+export interface Props {
+ templates: MerchantBackend.Template.TemplateEntry[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+ onCreate: () => void;
+ onDelete: (e: MerchantBackend.Template.TemplateEntry) => void;
+ onSelect: (e: MerchantBackend.Template.TemplateEntry) => void;
+ onNewOrder: (e: MerchantBackend.Template.TemplateEntry) => void;
+ onQR: (e: MerchantBackend.Template.TemplateEntry) => void;
+}
+
+export function ListPage({
+ templates,
+ onCreate,
+ onDelete,
+ onSelect,
+ onNewOrder,
+ onQR,
+ onLoadMoreBefore,
+ onLoadMoreAfter,
+}: Props): VNode {
+ const form = { payto_uri: "" };
+
+ const { i18n } = useTranslationContext();
+ return (
+ <CardTable
+ templates={templates.map((o) => ({
+ ...o,
+ id: String(o.template_id),
+ }))}
+ onQR={onQR}
+ onCreate={onCreate}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ onNewOrder={onNewOrder}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreBefore={!onLoadMoreBefore}
+ onLoadMoreAfter={onLoadMoreAfter}
+ hasMoreAfter={!onLoadMoreAfter}
+ />
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/Table.tsx
new file mode 100644
index 000000000..9fdf4ead9
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/Table.tsx
@@ -0,0 +1,235 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import { MerchantBackend } from "../../../../declaration.js";
+
+type Entity = MerchantBackend.Template.TemplateEntry;
+
+interface Props {
+ templates: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ onNewOrder: (e: Entity) => void;
+ onQR: (e: Entity) => void;
+ onCreate: () => void;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ templates,
+ onCreate,
+ onDelete,
+ onSelect,
+ onQR,
+ onNewOrder,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <i18n.Translate>Templates</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new templates`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {templates.length > 0 ? (
+ <Table
+ instances={templates}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ onNewOrder={onNewOrder}
+ onQR={onQR}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreAfter={hasMoreAfter}
+ hasMoreBefore={hasMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: Entity[];
+ onDelete: (e: Entity) => void;
+ onNewOrder: (e: Entity) => void;
+ onQR: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+function toggleSelected<T>(id: T): (prev: T[]) => T[] {
+ return (prev: T[]): T[] =>
+ prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id);
+}
+
+function Table({
+ instances,
+ onLoadMoreAfter,
+ onDelete,
+ onNewOrder,
+ onQR,
+ onSelect,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ {hasMoreBefore && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more templates before the first one`}
+ onClick={onLoadMoreBefore}
+ >
+ <i18n.Translate>load newer templates</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>ID</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Description</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.template_id}>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.template_id}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.template_description}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected templates from the database`}
+ onClick={() => onDelete(i)}
+ >
+ Delete
+ </button>
+ <button
+ class="button is-info is-small has-tooltip-left"
+ data-tooltip={i18n.str`use template to create new order`}
+ onClick={() => onNewOrder(i)}
+ >
+ Use template
+ </button>
+ <button
+ class="button is-info is-small has-tooltip-left"
+ data-tooltip={i18n.str`create qr code for the template`}
+ onClick={() => onQR(i)}
+ >
+ QR
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {hasMoreAfter && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more templates after the last one`}
+ onClick={onLoadMoreAfter}
+ >
+ <i18n.Translate>load older templates</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no templates yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/index.tsx
new file mode 100644
index 000000000..c7927b772
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/list/index.tsx
@@ -0,0 +1,152 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import {
+ useInstanceTemplates,
+ useTemplateAPI,
+} from "../../../../hooks/templates.js";
+import { Notification } from "../../../../utils/types.js";
+import { ListPage } from "./ListPage.js";
+import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
+import { ConfirmModal } from "../../../../components/modal/index.js";
+import { JumpToElementById } from "../../../../components/form/JumpToElementById.js";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onNotFound: () => VNode;
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+ onNewOrder: (id: string) => void;
+ onQR: (id: string) => void;
+}
+
+export default function ListTemplates({
+ onUnauthorized,
+ onLoadError,
+ onCreate,
+ onQR,
+ onSelect,
+ onNewOrder,
+ onNotFound,
+}: Props): VNode {
+ const [position, setPosition] = useState<string | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { deleteTemplate, testTemplateExist } = useTemplateAPI();
+ const result = useInstanceTemplates({ position }, (id) => setPosition(id));
+ const [deleting, setDeleting] =
+ useState<MerchantBackend.Template.TemplateEntry | null>(null);
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <section class="section is-main-section">
+ <NotificationCard notification={notif} />
+
+ <JumpToElementById
+ testIfExist={testTemplateExist}
+ onSelect={onSelect}
+ description={i18n.str`jump to template with the given template ID`}
+ placeholder={i18n.str`template id`}
+ />
+
+ <ListPage
+ templates={result.data.templates}
+ onLoadMoreBefore={
+ result.isReachingStart ? result.loadMorePrev : undefined
+ }
+ onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined}
+ onCreate={onCreate}
+ onSelect={(e) => {
+ onSelect(e.template_id);
+ }}
+ onNewOrder={(e) => {
+ onNewOrder(e.template_id);
+ }}
+ onQR={(e) => {
+ onQR(e.template_id);
+ }}
+ onDelete={(e: MerchantBackend.Template.TemplateEntry) => {
+ setDeleting(e)
+ }
+ }
+ />
+
+ {deleting && (
+ <ConfirmModal
+ label={`Delete template`}
+ description={`Delete the template "${deleting.template_description}"`}
+ danger
+ active
+ onCancel={() => setDeleting(null)}
+ onConfirm={async (): Promise<void> => {
+ try {
+ await deleteTemplate(deleting.template_id);
+ setNotif({
+ message: i18n.str`Template "${deleting.template_description}" (ID: ${deleting.template_id}) has been deleted`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`Failed to delete template`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+ setDeleting(null);
+ }}
+ >
+ <p>
+ If you delete the template <b>&quot;{deleting.template_description}&quot;</b> (ID:{" "}
+ <b>{deleting.template_id}</b>) you may loose information
+ </p>
+ <p class="warning">
+ Deleting an template <b>cannot be undone</b>.
+ </p>
+ </ConfirmModal>
+ )}
+ </section>
+ );
+}
diff --git a/packages/taler-wallet-webextension/tests/__mocks__/fileMocks.ts b/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/Qr.stories.tsx
index 0c045e9d1..eb853c8ff 100644
--- a/packages/taler-wallet-webextension/tests/__mocks__/fileMocks.ts
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/Qr.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2021-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,11 +14,14 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
-// This fixed an error related to the CSS and loading gif breaking my Jest test
-// See https://facebook.github.io/jest/docs/en/webpack.html#handling-static-assets
-export default 'test-file-stub';
+import { QrPage as TestedComponent } from "./QrPage.js";
+
+export default {
+ title: "Pages/Templates/QR",
+ component: TestedComponent,
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx
new file mode 100644
index 000000000..5140aae3a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx
@@ -0,0 +1,172 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { stringifyPayTemplateUri } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { QR } from "../../../../components/exception/QR.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { useBackendContext } from "../../../../context/backend.js";
+import { useConfigContext } from "../../../../context/config.js";
+import { useInstanceContext } from "../../../../context/instance.js";
+import { MerchantBackend } from "../../../../declaration.js";
+
+type Entity = MerchantBackend.Template.UsingTemplateDetails;
+
+interface Props {
+ contract: MerchantBackend.Template.TemplateContractDetails;
+ id: string;
+ onBack?: () => void;
+}
+
+export function QrPage({ contract, id: templateId, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { url: backendURL } = useBackendContext()
+ const { id: instanceId } = useInstanceContext();
+ const config = useConfigContext();
+
+ const [state, setState] = useState<Partial<Entity>>({
+ amount: contract.amount,
+ summary: contract.summary,
+ });
+
+ const errors: FormErrors<Entity> = {};
+
+ const fixedAmount = !!contract.amount;
+ const fixedSummary = !!contract.summary;
+
+ const templateParams: Record<string, string> = {}
+ if (!fixedAmount) {
+ if (state.amount) {
+ templateParams.amount = state.amount
+ } else {
+ templateParams.amount = config.currency
+ }
+ }
+
+ if (!fixedSummary) {
+ templateParams.summary = state.summary ?? ""
+ }
+
+ const merchantBaseUrl = new URL(backendURL).href;
+
+ const payTemplateUri = stringifyPayTemplateUri({
+ merchantBaseUrl,
+ templateId,
+ templateParams
+ })
+
+ const issuer = encodeURIComponent(
+ `${new URL(backendURL).host}/${instanceId}`,
+ );
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <p class="is-size-5 mt-5 mb-5">
+ <i18n.Translate>
+ Here you can specify a default value for fields that are not
+ fixed. Default values can be edited by the customer before the
+ payment.
+ </i18n.Translate>
+ </p>
+
+ <p></p>
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputCurrency<Entity>
+ name="amount"
+ label={
+ fixedAmount
+ ? i18n.str`Fixed amount`
+ : i18n.str`Default amount`
+ }
+ readonly={fixedAmount}
+ tooltip={i18n.str`Amount of the order`}
+ />
+ <Input<Entity>
+ name="summary"
+ inputType="multiline"
+ readonly={fixedSummary}
+ label={
+ fixedSummary
+ ? i18n.str`Fixed summary`
+ : i18n.str`Default summary`
+ }
+ tooltip={i18n.str`Title of the order to be shown to the customer`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <button
+ class="button is-info"
+ onClick={() => saveAsPDF(templateId)}
+ >
+ <i18n.Translate>Print</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ <section id="printThis">
+ <QR text={payTemplateUri} />
+ <pre style={{ textAlign: "center" }}>
+ <a href={payTemplateUri}>{payTemplateUri}</a>
+ </pre>
+ </section>
+ </div>
+ );
+}
+
+function saveAsPDF(name: string): void {
+ const printWindow = window.open("", "", "height=400,width=800");
+ if (!printWindow) return;
+ const divContents = document.getElementById("printThis");
+ if (!divContents) return;
+ printWindow.document.write(
+ `<html><head><title>Order template for ${name}</title><style>`,
+ );
+ printWindow.document.write("</style></head><body>&nbsp;</body></html>");
+ printWindow.document.close();
+ printWindow.document.body.appendChild(divContents.cloneNode(true));
+ printWindow.addEventListener("load", () => {
+ printWindow.print();
+ printWindow.close();
+ });
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/index.tsx
new file mode 100644
index 000000000..7db7478f7
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/qr/index.tsx
@@ -0,0 +1,80 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import {
+ useTemplateAPI,
+ useTemplateDetails,
+} from "../../../../hooks/templates.js";
+import { Notification } from "../../../../utils/types.js";
+import { QrPage } from "./QrPage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+export type Entity = MerchantBackend.Transfers.TransferInformation;
+interface Props {
+ onBack?: () => void;
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ tid: string;
+}
+
+export default function TemplateQrPage({
+ tid,
+ onBack,
+ onLoadError,
+ onNotFound,
+ onUnauthorized,
+}: Props): VNode {
+ const result = useTemplateDetails(tid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <QrPage contract={result.data.template_contract} id={tid} onBack={onBack} />
+ </>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx
new file mode 100644
index 000000000..8d07cb31f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/Templates/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx
new file mode 100644
index 000000000..b578d4664
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx
@@ -0,0 +1,254 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ Amounts,
+ MerchantTemplateContractDetails,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputDuration } from "../../../../components/form/InputDuration.js";
+import { InputNumber } from "../../../../components/form/InputNumber.js";
+import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
+import { useBackendContext } from "../../../../context/backend.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import { undefinedIfEmpty } from "../../../../utils/table.js";
+import { InputTab } from "../../../../components/form/InputTab.js";
+
+enum Steps {
+ BOTH_FIXED,
+ FIXED_PRICE,
+ FIXED_SUMMARY,
+ NON_FIXED,
+}
+
+type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId;
+
+interface Props {
+ onUpdate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+ template: Entity;
+}
+
+export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { url: backendURL } = useBackendContext()
+
+ const intialStep =
+ template.template_contract?.amount === undefined && template.template_contract?.summary === undefined
+ ? Steps.NON_FIXED
+ : template.template_contract?.summary === undefined
+ ? Steps.FIXED_PRICE
+ : template.template_contract?.amount === undefined
+ ? Steps.FIXED_SUMMARY
+ : Steps.BOTH_FIXED;
+
+ const [state, setState] = useState<Partial<Entity & { type: Steps }>>({ ...template, type: intialStep });
+
+ const parsedPrice = !state.template_contract?.amount
+ ? undefined
+ : Amounts.parse(state.template_contract?.amount);
+
+ const errors: FormErrors<Entity> = {
+ template_description: !state.template_description
+ ? i18n.str`should not be empty`
+ : undefined,
+ template_contract: !state.template_contract
+ ? undefined
+ : undefinedIfEmpty({
+ amount: !(state.type === Steps.FIXED_PRICE || state.type === Steps.BOTH_FIXED)
+ ? undefined
+ : !state.template_contract?.amount
+ ? i18n.str`required`
+ : !parsedPrice
+ ? i18n.str`not valid`
+ : Amounts.isZero(parsedPrice)
+ ? i18n.str`must be greater than 0`
+ : undefined,
+ summary: !(state.type === Steps.FIXED_SUMMARY || state.type === Steps.BOTH_FIXED)
+ ? undefined
+ : !state.template_contract?.summary
+ ? i18n.str`required`
+ : undefined,
+ minimum_age:
+ state.template_contract.minimum_age < 0
+ ? i18n.str`should be greater that 0`
+ : undefined,
+ pay_duration: !state.template_contract.pay_duration
+ ? i18n.str`can't be empty`
+ : state.template_contract.pay_duration.d_us === "forever"
+ ? undefined
+ : state.template_contract.pay_duration.d_us < 1000 * 1000 // less than one second
+ ? i18n.str`to short`
+ : undefined,
+ } as Partial<MerchantTemplateContractDetails>),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ if (state.template_contract) {
+ if (state.type === Steps.NON_FIXED) {
+ delete state.template_contract.amount;
+ delete state.template_contract.summary;
+ } else if (state.type === Steps.FIXED_SUMMARY) {
+ delete state.template_contract.amount;
+ } else if (state.type === Steps.FIXED_PRICE) {
+ delete state.template_contract.summary;
+ }
+ }
+ delete state.type
+ return onUpdate(state as any);
+ };
+
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ {backendURL}/templates/{template.id}
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputWithAddon<Entity>
+ name="id"
+ addonBefore={`templates/`}
+ readonly
+ label={i18n.str`Identifier`}
+ tooltip={i18n.str`Name of the template in URLs.`}
+ />
+
+ <Input<Entity>
+ name="template_description"
+ label={i18n.str`Description`}
+ help=""
+ tooltip={i18n.str`Describe what this template stands for`}
+ />
+ <InputTab
+ name="type"
+ label={i18n.str`Type`}
+ help={(() => {
+ switch (state.type) {
+ case Steps.NON_FIXED: return i18n.str`User will be able to input price and summary before payment.`
+ case Steps.FIXED_PRICE: return i18n.str`User will be able to add a summary before payment.`
+ case Steps.FIXED_SUMMARY: return i18n.str`User will be able to set the price before payment.`
+ case Steps.BOTH_FIXED: return i18n.str`User will not be able to change the price or the summary.`
+ }
+ })()}
+ tooltip={i18n.str`Define what the user be allowed to modify`}
+ values={[
+ Steps.NON_FIXED,
+ Steps.FIXED_PRICE,
+ Steps.FIXED_SUMMARY,
+ Steps.BOTH_FIXED,
+ ]}
+ toStr={(v: Steps): string => {
+ switch (v) {
+ case Steps.NON_FIXED: return i18n.str`Simple`
+ case Steps.FIXED_PRICE: return i18n.str`With price`
+ case Steps.FIXED_SUMMARY: return i18n.str`With summary`
+ case Steps.BOTH_FIXED: return i18n.str`With price and summary`
+ }
+ }}
+ />
+ {state.type === Steps.BOTH_FIXED || state.type === Steps.FIXED_SUMMARY ?
+ <Input
+ name="template_contract.summary"
+ inputType="multiline"
+ label={i18n.str`Fixed summary`}
+ tooltip={i18n.str`If specified, this template will create order with the same summary`}
+ />
+ : undefined}
+ {state.type === Steps.BOTH_FIXED || state.type === Steps.FIXED_PRICE ?
+ <InputCurrency
+ name="template_contract.amount"
+ label={i18n.str`Fixed price`}
+ tooltip={i18n.str`If specified, this template will create order with the same price`}
+ />
+ : undefined}
+ <InputNumber
+ name="template_contract.minimum_age"
+ label={i18n.str`Minimum age`}
+ help=""
+ tooltip={i18n.str`Is this contract restricted to some age?`}
+ />
+ <InputDuration
+ name="template_contract.pay_duration"
+ label={i18n.str`Payment timeout`}
+ help=""
+ tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ </div>
+ </section>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/update/index.tsx
new file mode 100644
index 000000000..3adca45db
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/update/index.tsx
@@ -0,0 +1,99 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import {
+ useTemplateAPI,
+ useTemplateDetails,
+} from "../../../../hooks/templates.js";
+import { Notification } from "../../../../utils/types.js";
+import { UpdatePage } from "./UpdatePage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+export type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId;
+
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ tid: string;
+}
+export default function UpdateTemplate({
+ tid,
+ onConfirm,
+ onBack,
+ onUnauthorized,
+ onNotFound,
+ onLoadError,
+}: Props): VNode {
+ const { updateTemplate } = useTemplateAPI();
+ const result = useTemplateDetails(tid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ template={{ ...result.data, id: tid }}
+ onBack={onBack}
+ onUpdate={(data) => {
+ return updateTemplate(tid, data)
+ .then(onConfirm)
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not update template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx
new file mode 100644
index 000000000..13576d94d
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx
@@ -0,0 +1,27 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { UsePage as TestedComponent } from "./UsePage.js";
+
+export default {
+ title: "Pages/Templates/Create",
+ component: TestedComponent,
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx
new file mode 100644
index 000000000..983804d3e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx
@@ -0,0 +1,143 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { MerchantBackend } from "../../../../declaration.js";
+
+type Entity = MerchantBackend.Template.UsingTemplateDetails;
+
+interface Props {
+ id: string;
+ template: MerchantBackend.Template.TemplateDetails;
+ onCreateOrder: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+export function UsePage({ id, template, onCreateOrder, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>({
+ amount: template.template_contract.amount,
+ summary: template.template_contract.summary,
+ });
+
+ const errors: FormErrors<Entity> = {
+ amount:
+ !template.template_contract.amount && !state.amount
+ ? i18n.str`Amount is required`
+ : undefined,
+ summary:
+ !template.template_contract.summary && !state.summary
+ ? i18n.str`Order summary is required`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ if (template.template_contract.amount) {
+ delete state.amount;
+ }
+ if (template.template_contract.summary) {
+ delete state.summary;
+ }
+ return onCreateOrder(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ <i18n.Translate>New order for template</i18n.Translate>:{" "}
+ <b>{id}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ </section>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputCurrency<Entity>
+ name="amount"
+ label={i18n.str`Amount`}
+ readonly={!!template.template_contract.amount}
+ tooltip={i18n.str`Amount of the order`}
+ />
+ <Input<Entity>
+ name="summary"
+ inputType="multiline"
+ label={i18n.str`Order summary`}
+ readonly={!!template.template_contract.summary}
+ tooltip={i18n.str`Title of the order to be shown to the customer`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/templates/use/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/templates/use/index.tsx
new file mode 100644
index 000000000..ed1242ef5
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/templates/use/index.tsx
@@ -0,0 +1,101 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import {
+ useTemplateAPI,
+ useTemplateDetails,
+} from "../../../../hooks/templates.js";
+import { Notification } from "../../../../utils/types.js";
+import { UsePage } from "./UsePage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+export type Entity = MerchantBackend.Transfers.TransferInformation;
+interface Props {
+ onBack?: () => void;
+ onOrderCreated: (id: string) => void;
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ tid: string;
+}
+
+export default function TemplateUsePage({
+ tid,
+ onOrderCreated,
+ onBack,
+ onLoadError,
+ onNotFound,
+ onUnauthorized,
+}: Props): VNode {
+ const { createOrderFromTemplate } = useTemplateAPI();
+ const result = useTemplateDetails(tid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <UsePage
+ template={result.data}
+ id={tid}
+ onBack={onBack}
+ onCreateOrder={(
+ request: MerchantBackend.Template.UsingTemplateDetails,
+ ) => {
+ return createOrderFromTemplate(tid, request)
+ .then((res) => onOrderCreated(res.data.order_id))
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create order from template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/token/DetailPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/token/DetailPage.tsx
new file mode 100644
index 000000000..549e7581f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/token/DetailPage.tsx
@@ -0,0 +1,183 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/exception/AsyncButton.js";
+import { FormProvider } from "../../../components/form/FormProvider.js";
+import { Input } from "../../../components/form/Input.js";
+import { useInstanceContext } from "../../../context/instance.js";
+import { AccessToken } from "../../../declaration.js";
+import { NotificationCard } from "../../../components/menu/index.js";
+
+interface Props {
+ instanceId: string;
+ hasToken: boolean | undefined;
+ onClearToken: (c: AccessToken | undefined) => void;
+ onNewToken: (c: AccessToken | undefined, s: AccessToken) => void;
+ onBack?: () => void;
+}
+
+export function DetailPage({ instanceId, hasToken, onBack, onNewToken, onClearToken }: Props): VNode {
+ type State = { old_token: string; new_token: string; repeat_token: string };
+ const [form, setValue] = useState<Partial<State>>({
+ old_token: "",
+ new_token: "",
+ repeat_token: "",
+ });
+ const { i18n } = useTranslationContext();
+
+ const errors = {
+ old_token: hasToken && !form.old_token
+ ? i18n.str`you need your access token to perform the operation`
+ : undefined,
+ new_token: !form.new_token
+ ? i18n.str`cannot be empty`
+ : form.new_token === form.old_token
+ ? i18n.str`cannot be the same as the old token`
+ : undefined,
+ repeat_token:
+ form.new_token !== form.repeat_token
+ ? i18n.str`is not the same`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const instance = useInstanceContext();
+
+ const text = i18n.str`You are updating the access token from instance with id "${instance.id}"`;
+
+ async function submitForm() {
+ if (hasErrors) return;
+ const oldToken = hasToken ? `secret-token:${form.old_token}` as AccessToken : undefined;
+ const newToken = `secret-token:${form.new_token}` as AccessToken;
+ onNewToken(oldToken, newToken)
+ }
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ {text}
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ {!hasToken &&
+ <NotificationCard
+ notification={{
+ message: i18n.str`This instance doesn't have authentication token.`,
+ description: i18n.str`You can leave it empty if there is another layer of security.`,
+ type: "WARN",
+ }}
+ />
+ }
+
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider errors={errors} object={form} valueHandler={setValue}>
+ <Fragment>
+ {hasToken && (
+ <Fragment>
+ <Input<State>
+ name="old_token"
+ label={i18n.str`Current access token`}
+ tooltip={i18n.str`access token currently in use`}
+ inputType="password"
+ />
+ <p>
+ <i18n.Translate>
+ Clearing the access token will mean public access to the instance.
+ </i18n.Translate>
+ </p>
+ <div class="buttons is-right mt-5">
+ <button
+ class="button"
+ onClick={() => {
+ if (hasToken) {
+ const oldToken = `secret-token:${form.old_token}` as AccessToken;
+ onClearToken(oldToken)
+ } else {
+ onClearToken(undefined)
+ }
+ }}
+ >
+ <i18n.Translate>Clear token</i18n.Translate>
+ </button>
+ </div>
+ </Fragment>
+ )}
+
+
+ <Input<State>
+ name="new_token"
+ label={i18n.str`New access token`}
+ tooltip={i18n.str`next access token to be used`}
+ inputType="password"
+ />
+ <Input<State>
+ name="repeat_token"
+ label={i18n.str`Repeat access token`}
+ tooltip={i18n.str`confirm the same access token`}
+ inputType="password"
+ />
+ </Fragment>
+ </FormProvider>
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm change</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/token/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/token/index.tsx
new file mode 100644
index 000000000..22365c9e1
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/token/index.tsx
@@ -0,0 +1,106 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import { ErrorType, HttpError, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { Loading } from "../../../components/exception/loading.js";
+import { AccessToken, MerchantBackend } from "../../../declaration.js";
+import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js";
+import { DetailPage } from "./DetailPage.js";
+import { useInstanceContext } from "../../../context/instance.js";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../components/menu/index.js";
+import { Notification } from "../../../utils/types.js";
+import { useBackendContext } from "../../../context/backend.js";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onChange: () => void;
+ onNotFound: () => VNode;
+ onCancel: () => void;
+}
+
+export default function Token({
+ onLoadError,
+ onChange,
+ onUnauthorized,
+ onNotFound,
+ onCancel,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { clearAccessToken, setNewAccessToken } = useInstanceAPI();
+ const { id } = useInstanceContext();
+ const result = useInstanceDetails()
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ const hasToken = result.data.auth.method === "token"
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <DetailPage
+ instanceId={id}
+ onBack={onCancel}
+ hasToken={hasToken}
+ onClearToken={async (currentToken): Promise<void> => {
+ try {
+ await clearAccessToken(currentToken);
+ onChange();
+ } catch (error) {
+ if (error instanceof Error) {
+ setNotif({
+ message: i18n.str`Failed to clear token`,
+ type: "ERROR",
+ description: error.message,
+ });
+ }
+ }
+ }}
+ onNewToken={async (currentToken, newToken): Promise<void> => {
+ try {
+ await setNewAccessToken(currentToken, newToken);
+ onChange();
+ } catch (error) {
+ if (error instanceof Error) {
+ setNotif({
+ message: i18n.str`Failed to set new token`,
+ type: "ERROR",
+ description: error.message,
+ });
+ }
+ }
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/token/stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/token/stories.tsx
new file mode 100644
index 000000000..5f0f56f2d
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/token/stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { DetailPage as TestedComponent } from "./DetailPage.js";
+
+export default {
+ title: "Pages/Token",
+ component: TestedComponent,
+};
+
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx
new file mode 100644
index 000000000..64b67335c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx
@@ -0,0 +1,45 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Transfer/Create",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ accounts: ["payto://x-taler-bank/account1", "payto://x-taler-bank/account2"],
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx
new file mode 100644
index 000000000..13f5f3c12
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx
@@ -0,0 +1,146 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { useConfigContext } from "../../../../context/config.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import {
+ CROCKFORD_BASE32_REGEX,
+ URL_REGEX,
+} from "../../../../utils/constants.js";
+
+type Entity = MerchantBackend.Transfers.TransferInformation;
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+ accounts: string[];
+}
+
+export function CreatePage({ accounts, onCreate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { currency } = useConfigContext();
+
+ const [state, setState] = useState<Partial<Entity>>({
+ wtid: "",
+ // payto_uri: ,
+ // exchange_url: 'http://exchange.taler:8081/',
+ credit_amount: ``,
+ });
+
+ const errors: FormErrors<Entity> = {
+ wtid: !state.wtid
+ ? i18n.str`cannot be empty`
+ : !CROCKFORD_BASE32_REGEX.test(state.wtid)
+ ? i18n.str`check the id, does not look valid`
+ : state.wtid.length !== 52
+ ? i18n.str`should have 52 characters, current ${state.wtid.length}`
+ : undefined,
+ payto_uri: !state.payto_uri ? i18n.str`cannot be empty` : undefined,
+ credit_amount: !state.credit_amount ? i18n.str`cannot be empty` : undefined,
+ exchange_url: !state.exchange_url
+ ? i18n.str`cannot be empty`
+ : !URL_REGEX.test(state.exchange_url)
+ ? i18n.str`URL doesn't have the right format`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onCreate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputSelector
+ name="payto_uri"
+ label={i18n.str`Credited bank account`}
+ values={accounts}
+ placeholder={i18n.str`Select one account`}
+ tooltip={i18n.str`Bank account of the merchant where the payment was received`}
+ />
+ <Input<Entity>
+ name="wtid"
+ label={i18n.str`Wire transfer ID`}
+ help=""
+ tooltip={i18n.str`unique identifier of the wire transfer used by the exchange, must be 52 characters long`}
+ />
+ <Input<Entity>
+ name="exchange_url"
+ label={i18n.str`Exchange URL`}
+ tooltip={i18n.str`Base URL of the exchange that made the transfer, should have been in the wire transfer subject`}
+ help="http://exchange.taler:8081/"
+ />
+ <InputCurrency<Entity>
+ name="credit_amount"
+ label={i18n.str`Amount credited`}
+ tooltip={i18n.str`Actual amount that was wired to the merchant's bank account`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/index.tsx
new file mode 100644
index 000000000..25551a031
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/transfers/create/index.tsx
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useInstanceDetails } from "../../../../hooks/instance.js";
+import { useTransferAPI } from "../../../../hooks/transfer.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+import { useBankAccountDetails, useInstanceBankAccounts } from "../../../../hooks/bank.js";
+
+export type Entity = MerchantBackend.Transfers.TransferInformation;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+
+export default function CreateTransfer({ onConfirm, onBack }: Props): VNode {
+ const { informTransfer } = useTransferAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const instance = useInstanceBankAccounts();
+ const accounts = !instance.ok
+ ? []
+ : instance.data.accounts.map((a) => a.payto_uri);
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ accounts={accounts}
+ onCreate={(request: MerchantBackend.Transfers.TransferInformation) => {
+ return informTransfer(request)
+ .then(() => onConfirm())
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not inform transfer`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx
new file mode 100644
index 000000000..92b3f9853
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx
@@ -0,0 +1,93 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/Transfer/List",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ onDelete: { action: "onDelete" },
+ onLoadMoreBefore: { action: "onLoadMoreBefore" },
+ onLoadMoreAfter: { action: "onLoadMoreAfter" },
+ onShowAll: { action: "onShowAll" },
+ onShowVerified: { action: "onShowVerified" },
+ onShowUnverified: { action: "onShowUnverified" },
+ onChangePayTo: { action: "onChangePayTo" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ transfers: [
+ {
+ exchange_url: "http://exchange.url/",
+ credit_amount: "TESTKUDOS:10",
+ payto_uri: "payto//x-taler-bank/bank:8080/account",
+ transfer_serial_id: 123123123,
+ wtid: "!@KJELQKWEJ!L@K#!J@",
+ confirmed: true,
+ execution_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ verified: false,
+ },
+ {
+ exchange_url: "http://exchange.url/",
+ credit_amount: "TESTKUDOS:10",
+ payto_uri: "payto//x-taler-bank/bank:8080/account",
+ transfer_serial_id: 123123123,
+ wtid: "!@KJELQKWEJ!L@K#!J@",
+ confirmed: true,
+ execution_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ verified: false,
+ },
+ {
+ exchange_url: "http://exchange.url/",
+ credit_amount: "TESTKUDOS:10",
+ payto_uri: "payto//x-taler-bank/bank:8080/account",
+ transfer_serial_id: 123123123,
+ wtid: "!@KJELQKWEJ!L@K#!J@",
+ confirmed: true,
+ execution_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ verified: false,
+ },
+ ],
+ accounts: ["payto://x-taler-bank/bank/some_account"],
+});
+export const Empty = createExample(TestedComponent, {
+ transfers: [],
+ accounts: [],
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx
new file mode 100644
index 000000000..02b12c4c2
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx
@@ -0,0 +1,134 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { FormProvider } from "../../../../components/form/FormProvider.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { CardTable } from "./Table.js";
+
+export interface Props {
+ transfers: MerchantBackend.Transfers.TransferDetails[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+ onShowAll: () => void;
+ onShowVerified: () => void;
+ onShowUnverified: () => void;
+ isVerifiedTransfers?: boolean;
+ isNonVerifiedTransfers?: boolean;
+ isAllTransfers?: boolean;
+ accounts: string[];
+ onChangePayTo: (p?: string) => void;
+ payTo?: string;
+ onCreate: () => void;
+ onDelete: () => void;
+}
+
+export function ListPage({
+ payTo,
+ onChangePayTo,
+ transfers,
+ onCreate,
+ onDelete,
+ accounts,
+ onLoadMoreBefore,
+ onLoadMoreAfter,
+ isAllTransfers,
+ isNonVerifiedTransfers,
+ isVerifiedTransfers,
+ onShowAll,
+ onShowUnverified,
+ onShowVerified,
+}: Props): VNode {
+ const form = { payto_uri: payTo };
+
+ const { i18n } = useTranslationContext();
+ return (
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-10">
+ <FormProvider
+ object={form}
+ valueHandler={(updater) => onChangePayTo(updater(form).payto_uri)}
+ >
+ <InputSelector
+ name="payto_uri"
+ label={i18n.str`Account URI`}
+ values={accounts}
+ placeholder={i18n.str`Select one account`}
+ tooltip={i18n.str`filter by account address`}
+ />
+ </FormProvider>
+ </div>
+ <div class="column" />
+ </div>
+ <div class="tabs">
+ <ul>
+ <li class={isAllTransfers ? "is-active" : ""}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`remove all filters`}
+ >
+ <a onClick={onShowAll}>
+ <i18n.Translate>All</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isVerifiedTransfers ? "is-active" : ""}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`only show wire transfers confirmed by the merchant`}
+ >
+ <a onClick={onShowVerified}>
+ <i18n.Translate>Verified</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isNonVerifiedTransfers ? "is-active" : ""}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`only show wire transfers claimed by the exchange`}
+ >
+ <a onClick={onShowUnverified}>
+ <i18n.Translate>Unverified</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ </ul>
+ </div>
+ <CardTable
+ transfers={transfers.map((o) => ({
+ ...o,
+ id: String(o.transfer_serial_id),
+ }))}
+ accounts={accounts}
+ onCreate={onCreate}
+ onDelete={onDelete}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreBefore={!onLoadMoreBefore}
+ onLoadMoreAfter={onLoadMoreAfter}
+ hasMoreAfter={!onLoadMoreAfter}
+ />
+ </section>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/Table.tsx
new file mode 100644
index 000000000..b6b1cf328
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/Table.tsx
@@ -0,0 +1,229 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
+
+type Entity = MerchantBackend.Transfers.TransferDetails & WithId;
+
+interface Props {
+ transfers: Entity[];
+ onDelete: (id: Entity) => void;
+ onCreate: () => void;
+ accounts: string[];
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ transfers,
+ onCreate,
+ onDelete,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-arrow-left-right" />
+ </span>
+ <i18n.Translate>Transfers</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new transfer`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {transfers.length > 0 ? (
+ <Table
+ instances={transfers}
+ onDelete={onDelete}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreAfter={hasMoreAfter}
+ hasMoreBefore={hasMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: Entity[];
+ onDelete: (id: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+function toggleSelected<T>(id: T): (prev: T[]) => T[] {
+ return (prev: T[]): T[] =>
+ prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id);
+}
+
+function Table({
+ instances,
+ onLoadMoreAfter,
+ onDelete,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = useSettings();
+ return (
+ <div class="table-container">
+ {hasMoreBefore && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more transfers before the first one`}
+ onClick={onLoadMoreBefore}
+ >
+ <i18n.Translate>load newer transfers</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>ID</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Credit</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Address</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Exchange URL</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Confirmed</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Verified</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Executed at</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.id}>
+ <td>{i.id}</td>
+ <td>{i.credit_amount}</td>
+ <td>{i.payto_uri}</td>
+ <td>{i.exchange_url}</td>
+ <td>{i.confirmed ? i18n.str`yes` : i18n.str`no`}</td>
+ <td>{i.verified ? i18n.str`yes` : i18n.str`no`}</td>
+ <td>
+ {i.execution_time
+ ? i.execution_time.t_s == "never"
+ ? i18n.str`never`
+ : format(
+ i.execution_time.t_s * 1000,
+ datetimeFormatForSettings(settings),
+ )
+ : i18n.str`unknown`}
+ </td>
+ <td>
+ {i.verified === undefined ? (
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected transfer from the database`}
+ onClick={() => onDelete(i)}
+ >
+ Delete
+ </button>
+ ) : undefined}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {hasMoreAfter && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more transfer after the last one`}
+ onClick={onLoadMoreAfter}
+ >
+ <i18n.Translate>load older transfers</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no transfer yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/index.tsx
new file mode 100644
index 000000000..0fdbb9bc3
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/transfers/list/index.tsx
@@ -0,0 +1,118 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ErrorType, HttpError } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useInstanceDetails } from "../../../../hooks/instance.js";
+import { useInstanceTransfers } from "../../../../hooks/transfer.js";
+import { ListPage } from "./ListPage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import { useInstanceBankAccounts } from "../../../../hooks/bank.js";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onNotFound: () => VNode;
+ onCreate: () => void;
+}
+interface Form {
+ verified?: "yes" | "no";
+ payto_uri?: string;
+}
+
+export default function ListTransfer({
+ onUnauthorized,
+ onLoadError,
+ onCreate,
+ onNotFound,
+}: Props): VNode {
+ const setFilter = (s?: "yes" | "no") => setForm({ ...form, verified: s });
+
+ const [position, setPosition] = useState<string | undefined>(undefined);
+
+ const instance = useInstanceBankAccounts();
+ const accounts = !instance.ok
+ ? []
+ : instance.data.accounts.map((a) => a.payto_uri);
+ const [form, setForm] = useState<Form>({ payto_uri: "" });
+
+ const shoulUseDefaultAccount = accounts.length === 1
+ useEffect(() => {
+ if (shoulUseDefaultAccount) {
+ setForm({...form, payto_uri: accounts[0]})
+ }
+ }, [shoulUseDefaultAccount])
+
+ const isVerifiedTransfers = form.verified === "yes";
+ const isNonVerifiedTransfers = form.verified === "no";
+ const isAllTransfers = form.verified === undefined;
+
+ const result = useInstanceTransfers(
+ {
+ position,
+ payto_uri: form.payto_uri === "" ? undefined : form.payto_uri,
+ verified: form.verified,
+ },
+ (id) => setPosition(id),
+ );
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <ListPage
+ accounts={accounts}
+ transfers={result.data.transfers}
+ onLoadMoreBefore={
+ result.isReachingStart ? result.loadMorePrev : undefined
+ }
+ onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined}
+ onCreate={onCreate}
+ onDelete={() => {
+ null;
+ }}
+ // position={position} setPosition={setPosition}
+ onShowAll={() => setFilter(undefined)}
+ onShowUnverified={() => setFilter("no")}
+ onShowVerified={() => setFilter("yes")}
+ isAllTransfers={isAllTransfers}
+ isVerifiedTransfers={isVerifiedTransfers}
+ isNonVerifiedTransfers={isNonVerifiedTransfers}
+ payTo={form.payto_uri}
+ onChangePayTo={(p) => setForm((v) => ({ ...v, payto_uri: p }))}
+ />
+ );
+}
diff --git a/packages/taler-wallet-webextension/.storybook/.babelrc b/packages/auditor-backoffice-ui/src/paths/instance/transfers/update/index.tsx
index 4476798e2..84cc95e72 100644
--- a/packages/taler-wallet-webextension/.storybook/.babelrc
+++ b/packages/auditor-backoffice-ui/src/paths/instance/transfers/update/index.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2021-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,13 +14,13 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
-{
- //FIXME: check if we can remove this preset and just use default storybook presets
- "presets": [
- "preact-cli/babel",
- ]
-} \ No newline at end of file
+
+import { h, VNode } from "preact";
+
+export default function UpdateTransfer(): VNode {
+ return <div>order transfer page</div>;
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/update/Update.stories.tsx
new file mode 100644
index 000000000..817a7025c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/update/Update.stories.tsx
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/Instance/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ selected: {
+ name: "name",
+ auth: { method: "external" },
+ address: {},
+ user_type: "business",
+ use_stefan: true,
+ jurisdiction: {},
+ default_pay_delay: {
+ d_us: 1000 * 1000, //one second
+ },
+ default_wire_transfer_delay: {
+ d_us: 1000 * 1000, //one second
+ },
+ merchant_pub: "ASDWQEKASJDKSADJ",
+ },
+});
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/update/UpdatePage.tsx
new file mode 100644
index 000000000..a27a0cb06
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/update/UpdatePage.tsx
@@ -0,0 +1,176 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../components/form/FormProvider.js";
+import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js";
+import { useInstanceContext } from "../../../context/instance.js";
+import { MerchantBackend } from "../../../declaration.js";
+import { undefinedIfEmpty } from "../../../utils/table.js";
+import { Duration } from "@gnu-taler/taler-util";
+
+export type Entity = Omit<Omit<MerchantBackend.Instances.InstanceReconfigurationMessage, "default_pay_delay">, "default_wire_transfer_delay"> & {
+ default_pay_delay: Duration,
+ default_wire_transfer_delay: Duration,
+};
+
+//MerchantBackend.Instances.InstanceAuthConfigurationMessage
+interface Props {
+ onUpdate: (d: MerchantBackend.Instances.InstanceReconfigurationMessage) => void;
+ selected: MerchantBackend.Instances.QueryInstancesResponse;
+ isLoading: boolean;
+ onBack: () => void;
+}
+
+function convert(
+ from: MerchantBackend.Instances.QueryInstancesResponse,
+): Entity {
+ const { default_pay_delay, default_wire_transfer_delay, ...rest } = from;
+
+ const defaults = {
+ use_stefan: false,
+ default_pay_delay: Duration.fromTalerProtocolDuration(default_pay_delay),
+ default_wire_transfer_delay: Duration.fromTalerProtocolDuration(default_wire_transfer_delay),
+ };
+ return { ...defaults, ...rest };
+}
+
+export function UpdatePage({
+ onUpdate,
+ selected,
+ onBack,
+}: Props): VNode {
+ const { id } = useInstanceContext();
+
+ const [value, valueHandler] = useState<Partial<Entity>>(convert(selected));
+
+ const { i18n } = useTranslationContext();
+
+ const errors: FormErrors<Entity> = {
+ name: !value.name ? i18n.str`required` : undefined,
+ user_type: !value.user_type
+ ? i18n.str`required`
+ : value.user_type !== "business" && value.user_type !== "individual"
+ ? i18n.str`should be business or individual`
+ : undefined,
+ default_pay_delay: !value.default_pay_delay
+ ? i18n.str`required`
+ : !!value.default_wire_transfer_delay &&
+ value.default_wire_transfer_delay.d_ms !== "forever" &&
+ value.default_pay_delay.d_ms !== "forever" &&
+ value.default_pay_delay.d_ms > value.default_wire_transfer_delay.d_ms ?
+ i18n.str`pay delay can't be greater than wire transfer delay` : undefined,
+ default_wire_transfer_delay: !value.default_wire_transfer_delay
+ ? i18n.str`required`
+ : undefined,
+ address: undefinedIfEmpty({
+ address_lines:
+ value.address?.address_lines && value.address?.address_lines.length > 7
+ ? i18n.str`max 7 lines`
+ : undefined,
+ }),
+ jurisdiction: undefinedIfEmpty({
+ address_lines:
+ value.address?.address_lines && value.address?.address_lines.length > 7
+ ? i18n.str`max 7 lines`
+ : undefined,
+ }),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submit = async (): Promise<void> => {
+ const { default_pay_delay, default_wire_transfer_delay, ...rest } = value as Required<Entity>;
+ const result: MerchantBackend.Instances.InstanceReconfigurationMessage = {
+ default_pay_delay: Duration.toTalerProtocolDuration(default_pay_delay),
+ default_wire_transfer_delay: Duration.toTalerProtocolDuration(default_wire_transfer_delay),
+ ...rest,
+ }
+ await onUpdate(result);
+ };
+ // const [active, setActive] = useState(false);
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ <i18n.Translate>Instance id</i18n.Translate>: <b>{id}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <hr />
+
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider<Entity>
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <DefaultInstanceFormFields showId={false} />
+ </FormProvider>
+
+ <div class="buttons is-right mt-4">
+ <button
+ class="button"
+ onClick={onBack}
+ data-tooltip="cancel operation"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+
+ <AsyncButton
+ onClick={submit}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ disabled={hasErrors}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/update/index.tsx
new file mode 100644
index 000000000..e44cf5c0f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/update/index.tsx
@@ -0,0 +1,118 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import {
+ ErrorType,
+ HttpError,
+ HttpResponse,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../components/exception/loading.js";
+import { NotificationCard } from "../../../components/menu/index.js";
+import { useInstanceContext } from "../../../context/instance.js";
+import { AccessToken, MerchantBackend } from "../../../declaration.js";
+import {
+ useInstanceAPI,
+ useInstanceDetails,
+ useManagedInstanceDetails,
+ useManagementAPI,
+} from "../../../hooks/instance.js";
+import { Notification } from "../../../utils/types.js";
+import { UpdatePage } from "./UpdatePage.js";
+
+export interface Props {
+ onBack: () => void;
+ onConfirm: () => void;
+
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onUpdateError: (e: HttpError<MerchantBackend.ErrorDetail>) => void;
+}
+
+export default function Update(props: Props): VNode {
+ const { updateInstance } = useInstanceAPI();
+ const result = useInstanceDetails();
+ return CommonUpdate(props, result, updateInstance, );
+}
+
+export function AdminUpdate(props: Props & { instanceId: string }): VNode {
+ const { updateInstance } = useManagementAPI(
+ props.instanceId,
+ );
+ const result = useManagedInstanceDetails(props.instanceId);
+ return CommonUpdate(props, result, updateInstance, );
+}
+
+function CommonUpdate(
+ {
+ onBack,
+ onConfirm,
+ onLoadError,
+ onNotFound,
+ onUpdateError,
+ onUnauthorized,
+ }: Props,
+ result: HttpResponse<
+ MerchantBackend.Instances.QueryInstancesResponse,
+ MerchantBackend.ErrorDetail
+ >,
+ updateInstance: any,
+): VNode {
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ onBack={onBack}
+ isLoading={false}
+ selected={result.data}
+ onUpdate={(
+ d: MerchantBackend.Instances.InstanceReconfigurationMessage,
+ ): Promise<void> => {
+ return updateInstance(d)
+ .then(onConfirm)
+ .catch((error: Error) =>
+ setNotif({
+ message: i18n.str`Failed to create instance`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ );
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx
new file mode 100644
index 000000000..4857ede97
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Webhooks/Create",
+ component: TestedComponent,
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx
new file mode 100644
index 000000000..bfa2a883e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx
@@ -0,0 +1,183 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputDuration } from "../../../../components/form/InputDuration.js";
+import { InputNumber } from "../../../../components/form/InputNumber.js";
+import { useBackendContext } from "../../../../context/backend.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+
+type Entity = MerchantBackend.Webhooks.WebhookAddDetails;
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+const validMethod = ["GET", "POST", "PUT", "PATCH", "HEAD"];
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>({});
+
+ const errors: FormErrors<Entity> = {
+ webhook_id: !state.webhook_id ? i18n.str`required` : undefined,
+ event_type: !state.event_type ? i18n.str`required`
+ : state.event_type !== "pay" && state.event_type !== "refund" ? i18n.str`it should be "pay" or "refund"`
+ : undefined,
+ http_method: !state.http_method
+ ? i18n.str`required`
+ : !validMethod.includes(state.http_method)
+ ? i18n.str`should be one of '${validMethod.join(", ")}'`
+ : undefined,
+ url: !state.url ? i18n.str`required` : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onCreate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <Input<Entity>
+ name="webhook_id"
+ label={i18n.str`ID`}
+ tooltip={i18n.str`Webhook ID to use`}
+ />
+ <InputSelector
+ name="event_type"
+ label={i18n.str`Event`}
+ values={[
+ i18n.str`Choose one...`,
+ i18n.str`pay`,
+ i18n.str`refund`,
+ ]}
+ tooltip={i18n.str`The event of the webhook: why the webhook is used`}
+ />
+ <InputSelector
+ name="http_method"
+ label={i18n.str`Method`}
+ values={[
+ i18n.str`Choose one...`,
+ i18n.str`GET`,
+ i18n.str`POST`,
+ i18n.str`PUT`,
+ i18n.str`PATCH`,
+ i18n.str`HEAD`,
+ ]}
+ tooltip={i18n.str`Method used by the webhook`}
+ />
+
+ <Input<Entity>
+ name="url"
+ label={i18n.str`URL`}
+ tooltip={i18n.str`URL of the webhook where the customer will be redirected`}
+ />
+
+ <p>
+ The text below support <a target="_blank" rel="noreferrer" href="https://mustache.github.io/mustache.5.html">mustache</a> template engine. Any string
+ between <pre style={{ display: "inline", padding: 0 }}>&#123;&#123;</pre> and <pre style={{ display: "inline", padding: 0 }}>&#125;&#125;</pre> will
+ be replaced with replaced with the value of the corresponding variable.
+ </p>
+ <p>
+ For example <pre style={{ display: "inline", padding: 0 }}>&#123;&#123;contract_terms.amount&#125;&#125;</pre> will be replaced
+ with the the order's price
+ </p>
+ <p>
+ The short list of variables are:
+ </p>
+ <div class="menu">
+
+ <ul class="menu-list" style={{ listStyleType: "disc", marginLeft: 20 }}>
+ <li><b>contract_terms.summary:</b> order's description </li>
+ <li><b>contract_terms.amount:</b> order's price </li>
+ <li><b>order_id:</b> order's unique identification </li>
+ {state.event_type === "refund" && <Fragment>
+ <li><b>refund_amout:</b> the amount that was being refunded</li>
+ <li><b>reason:</b> the reason entered by the merchant staff for granting the refund</li>
+ <li><b>timestamp:</b> time of the refund in nanoseconds since 1970</li>
+ </Fragment>}
+ </ul>
+ </div>
+ {/* <Input<Entity>
+ name="header_template"
+ label={i18n.str`Http header`}
+ inputType="multiline"
+ tooltip={i18n.str`Header template of the webhook`}
+ /> */}
+ <Input<Entity>
+ name="body_template"
+ inputType="multiline"
+ label={i18n.str`Http body`}
+ tooltip={i18n.str`Body template by the webhook`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/index.tsx
new file mode 100644
index 000000000..924e6d9b8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/create/index.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useWebhookAPI } from "../../../../hooks/webhooks.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+
+export type Entity = MerchantBackend.Webhooks.WebhookAddDetails;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+
+export default function CreateWebhook({ onConfirm, onBack }: Props): VNode {
+ const { createWebhook } = useWebhookAPI();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: MerchantBackend.Webhooks.WebhookAddDetails) => {
+ return createWebhook(request)
+ .then(() => onConfirm())
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not inform template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx
new file mode 100644
index 000000000..702e9ba4a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/Templates/List",
+ component: TestedComponent,
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx
new file mode 100644
index 000000000..87e221e3c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx
@@ -0,0 +1,64 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { MerchantBackend } from "../../../../declaration.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { CardTable } from "./Table.js";
+
+export interface Props {
+ webhooks: MerchantBackend.Webhooks.WebhookEntry[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+ onCreate: () => void;
+ onDelete: (e: MerchantBackend.Webhooks.WebhookEntry) => void;
+ onSelect: (e: MerchantBackend.Webhooks.WebhookEntry) => void;
+}
+
+export function ListPage({
+ webhooks,
+ onCreate,
+ onDelete,
+ onSelect,
+ onLoadMoreBefore,
+ onLoadMoreAfter,
+}: Props): VNode {
+ const form = { payto_uri: "" };
+
+ const { i18n } = useTranslationContext();
+ return (
+ <section class="section is-main-section">
+ <CardTable
+ webhooks={webhooks.map((o) => ({
+ ...o,
+ id: String(o.webhook_id),
+ }))}
+ onCreate={onCreate}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreBefore={!onLoadMoreBefore}
+ onLoadMoreAfter={onLoadMoreAfter}
+ hasMoreAfter={!onLoadMoreAfter}
+ />
+ </section>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx
new file mode 100644
index 000000000..42a179d2c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx
@@ -0,0 +1,218 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import { MerchantBackend } from "../../../../declaration.js";
+
+type Entity = MerchantBackend.Webhooks.WebhookEntry;
+
+interface Props {
+ webhooks: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ onCreate: () => void;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ webhooks,
+ onCreate,
+ onDelete,
+ onSelect,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <i18n.Translate>Webhooks</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new webhooks`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {webhooks.length > 0 ? (
+ <Table
+ instances={webhooks}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ hasMoreAfter={hasMoreAfter}
+ hasMoreBefore={hasMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ hasMoreBefore?: boolean;
+ hasMoreAfter?: boolean;
+ onLoadMoreAfter?: () => void;
+}
+
+function toggleSelected<T>(id: T): (prev: T[]) => T[] {
+ return (prev: T[]): T[] =>
+ prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id);
+}
+
+function Table({
+ instances,
+ onLoadMoreAfter,
+ onDelete,
+ onSelect,
+ onLoadMoreBefore,
+ hasMoreAfter,
+ hasMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ {hasMoreBefore && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more webhooks before the first one`}
+ onClick={onLoadMoreBefore}
+ >
+ <i18n.Translate>load newer webhooks</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>ID</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Event type</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.webhook_id}>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.webhook_id}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.event_type}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected webhook from the database`}
+ onClick={() => onDelete(i)}
+ >
+ Delete
+ </button>
+ {/* <button
+ class="button is-info is-small has-tooltip-left"
+ data-tooltip={i18n.str`test webhook`}
+ onClick={() => onNewOrder(i)}
+ >
+ Test
+ </button> */}
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {hasMoreAfter && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more webhooks after the last one`}
+ onClick={onLoadMoreAfter}
+ >
+ <i18n.Translate>load older webhooks</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-sad mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no webhooks yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/index.tsx
new file mode 100644
index 000000000..a6f6f1511
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/list/index.tsx
@@ -0,0 +1,109 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend } from "../../../../declaration.js";
+import {
+ useInstanceWebhooks,
+ useWebhookAPI,
+} from "../../../../hooks/webhooks.js";
+import { Notification } from "../../../../utils/types.js";
+import { ListPage } from "./ListPage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+interface Props {
+ onUnauthorized: () => VNode;
+ onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ onNotFound: () => VNode;
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+}
+
+export default function ListWebhooks({
+ onUnauthorized,
+ onLoadError,
+ onCreate,
+ onSelect,
+ onNotFound,
+}: Props): VNode {
+ const [position, setPosition] = useState<string | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { deleteWebhook } = useWebhookAPI();
+ const result = useInstanceWebhooks({ position }, (id) => setPosition(id));
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <ListPage
+ webhooks={result.data.webhooks}
+ onLoadMoreBefore={
+ result.isReachingStart ? result.loadMorePrev : undefined
+ }
+ onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined}
+ onCreate={onCreate}
+ onSelect={(e) => {
+ onSelect(e.webhook_id);
+ }}
+ onDelete={(e: MerchantBackend.Webhooks.WebhookEntry) =>
+ deleteWebhook(e.webhook_id)
+ .then(() =>
+ setNotif({
+ message: i18n.str`webhook delete successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not delete the webhook`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ )
+ }
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx
new file mode 100644
index 000000000..8d07cb31f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/Templates/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx
new file mode 100644
index 000000000..76a23b6e5
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx
@@ -0,0 +1,146 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { useBackendContext } from "../../../../context/backend.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+
+type Entity = MerchantBackend.Webhooks.WebhookPatchDetails & WithId;
+
+interface Props {
+ onUpdate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+ webhook: Entity;
+}
+const validMethod = ["GET", "POST", "PUT", "PATCH", "HEAD"];
+
+export function UpdatePage({ webhook, onUpdate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>(webhook);
+
+ const errors: FormErrors<Entity> = {
+ event_type: !state.event_type ? i18n.str`required` : undefined,
+ http_method: !state.http_method
+ ? i18n.str`required`
+ : !validMethod.includes(state.http_method)
+ ? i18n.str`should be one of '${validMethod.join(", ")}'`
+ : undefined,
+ url: !state.url ? i18n.str`required` : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onUpdate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ Webhook: <b>{webhook.id}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <Input<Entity>
+ name="event_type"
+ label={i18n.str`Event`}
+ tooltip={i18n.str`The event of the webhook: why the webhook is used`}
+ />
+ <Input<Entity>
+ name="http_method"
+ label={i18n.str`Method`}
+ tooltip={i18n.str`Method used by the webhook`}
+ />
+ <Input<Entity>
+ name="url"
+ label={i18n.str`URL`}
+ tooltip={i18n.str`URL of the webhook where the customer will be redirected`}
+ />
+ <Input<Entity>
+ name="header_template"
+ label={i18n.str`Header`}
+ inputType="multiline"
+ tooltip={i18n.str`Header template of the webhook`}
+ />
+ <Input<Entity>
+ name="body_template"
+ inputType="multiline"
+ label={i18n.str`Body`}
+ tooltip={i18n.str`Body template by the webhook`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ </div>
+ </section>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/index.tsx
new file mode 100644
index 000000000..3f723ed87
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/instance/webhooks/update/index.tsx
@@ -0,0 +1,99 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ErrorType,
+ HttpError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { MerchantBackend, WithId } from "../../../../declaration.js";
+import {
+ useWebhookAPI,
+ useWebhookDetails,
+} from "../../../../hooks/webhooks.js";
+import { Notification } from "../../../../utils/types.js";
+import { UpdatePage } from "./UpdatePage.js";
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+
+export type Entity = MerchantBackend.Webhooks.WebhookPatchDetails & WithId;
+
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ onUnauthorized: () => VNode;
+ onNotFound: () => VNode;
+ onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode;
+ tid: string;
+}
+export default function UpdateWebhook({
+ tid,
+ onConfirm,
+ onBack,
+ onUnauthorized,
+ onNotFound,
+ onLoadError,
+}: Props): VNode {
+ const { updateWebhook } = useWebhookAPI();
+ const result = useWebhookDetails(tid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <Loading />;
+ if (!result.ok) {
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.Unauthorized
+ )
+ return onUnauthorized();
+ if (
+ result.type === ErrorType.CLIENT &&
+ result.status === HttpStatusCode.NotFound
+ )
+ return onNotFound();
+ return onLoadError(result);
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ webhook={{ ...result.data, id: tid }}
+ onBack={onBack}
+ onUpdate={(data) => {
+ return updateWebhook(tid, data)
+ .then(onConfirm)
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not update template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/login/index.tsx b/packages/auditor-backoffice-ui/src/paths/login/index.tsx
new file mode 100644
index 000000000..1c98b7c9b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/login/index.tsx
@@ -0,0 +1,202 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, h, VNode } from "preact";
+import { useCallback, useEffect, useState } from "preact/hooks";
+import { useBackendContext } from "../../context/backend.js";
+import { useInstanceContext } from "../../context/instance.js";
+import { AccessToken, LoginToken } from "../../declaration.js";
+import { useCredentialsChecker } from "../../hooks/backend.js";
+
+interface Props {
+ onConfirm: (token: LoginToken | undefined) => void;
+}
+
+function normalizeToken(r: string): AccessToken {
+ return `secret-token:${r}` as AccessToken;
+}
+
+export function LoginPage({ onConfirm }: Props): VNode {
+ const { url: backendURL } = useBackendContext();
+ const { admin, id } = useInstanceContext();
+ const { requestNewLoginToken } = useCredentialsChecker();
+ const [token, setToken] = useState("");
+
+ const { i18n } = useTranslationContext();
+
+
+ const doLogin = useCallback(async function doLoginImpl() {
+ const secretToken = normalizeToken(token);
+ const baseUrl = id === undefined ? backendURL : `${backendURL}/instances/${id}`
+ const result = await requestNewLoginToken(baseUrl, secretToken);
+ if (result.valid) {
+ const { token, expiration } = result
+ onConfirm({ token, expiration });
+ } else {
+ onConfirm(undefined);
+ }
+ }, [id, token])
+
+ if (admin && id !== "default") {
+ //admin trying to access another instance
+ return (<div class="columns is-centered" style={{ margin: "auto" }}>
+ <div class="column is-two-thirds ">
+ <div class="modal-card" style={{ width: "100%", margin: 0 }}>
+ <header
+ class="modal-card-head"
+ style={{ border: "1px solid", borderBottom: 0 }}
+ >
+ <p class="modal-card-title">{i18n.str`Login required`}</p>
+ </header>
+ <section
+ class="modal-card-body"
+ style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
+ >
+ <p>
+ <i18n.Translate>Need the access token for the instance.</i18n.Translate>
+ </p>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Access Token</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body">
+ <div class="field">
+ <p class="control is-expanded">
+ <input
+ class="input"
+ type="password"
+ placeholder={"current access token"}
+ name="token"
+ onKeyPress={(e) =>
+ e.keyCode === 13
+ ? doLogin()
+ : null
+ }
+ value={token}
+ onInput={(e): void => setToken(e?.currentTarget.value)}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ </section>
+ <footer
+ class="modal-card-foot "
+ style={{
+ justifyContent: "flex-end",
+ border: "1px solid",
+ borderTop: 0,
+ }}
+ >
+ <AsyncButton
+ onClick={doLogin}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </footer>
+ </div>
+ </div>
+ </div>)
+ }
+
+ return (
+ <div class="columns is-centered" style={{ margin: "auto" }}>
+ <div class="column is-two-thirds ">
+ <div class="modal-card" style={{ width: "100%", margin: 0 }}>
+ <header
+ class="modal-card-head"
+ style={{ border: "1px solid", borderBottom: 0 }}
+ >
+ <p class="modal-card-title">{i18n.str`Login required`}</p>
+ </header>
+ <section
+ class="modal-card-body"
+ style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
+ >
+ <i18n.Translate>Please enter your access token.</i18n.Translate>
+
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Access Token</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body">
+ <div class="field">
+ <p class="control is-expanded">
+ <input
+ class="input"
+ type="password"
+ placeholder={"current access token"}
+ name="token"
+ onKeyPress={(e) =>
+ e.keyCode === 13
+ ? doLogin()
+ : null
+ }
+ value={token}
+ onInput={(e): void => setToken(e?.currentTarget.value)}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ </section>
+ <footer
+ class="modal-card-foot "
+ style={{
+ justifyContent: "space-between",
+ border: "1px solid",
+ borderTop: 0,
+ }}
+ >
+ <div />
+ <AsyncButton
+ type="is-info"
+ onClick={doLogin}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </footer>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function AsyncButton({ onClick, disabled, type = "", children }: { type?: string, disabled?: boolean, onClick: () => Promise<void>, children: ComponentChildren }): VNode {
+ const [running, setRunning] = useState(false)
+ return <button class={"button " + type} disabled={disabled || running} onClick={() => {
+ setRunning(true)
+ onClick().then(() => {
+ setRunning(false)
+ }).catch(() => {
+ setRunning(false)
+ })
+ }}>
+ {children}
+ </button>
+}
+
+
diff --git a/packages/auditor-backoffice-ui/src/paths/notfound/index.tsx b/packages/auditor-backoffice-ui/src/paths/notfound/index.tsx
new file mode 100644
index 000000000..061a67025
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/notfound/index.tsx
@@ -0,0 +1,34 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { Link } from "preact-router";
+
+export default function NotFoundPage(): VNode {
+ return (
+ <div>
+ <p>That page doesn&apos;t exist.</p>
+ <Link href="/">
+ <h4>Back to Home</h4>
+ </Link>
+ </div>
+ );
+}
diff --git a/packages/auditor-backoffice-ui/src/paths/settings/index.tsx b/packages/auditor-backoffice-ui/src/paths/settings/index.tsx
new file mode 100644
index 000000000..093c3d09d
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/paths/settings/index.tsx
@@ -0,0 +1,112 @@
+import { useLang, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { FormErrors, FormProvider } from "../../components/form/FormProvider.js";
+import { InputSelector } from "../../components/form/InputSelector.js";
+import { InputToggle } from "../../components/form/InputToggle.js";
+import { LangSelector } from "../../components/menu/LangSelector.js";
+import { Settings, useSettings } from "../../hooks/useSettings.js";
+
+function getBrowserLang(): string | undefined {
+ if (typeof window === "undefined") return undefined;
+ if (window.navigator.languages) return window.navigator.languages[0];
+ if (window.navigator.language) return window.navigator.language;
+ return undefined;
+}
+
+export function Settings({ onClose }: { onClose?: () => void }): VNode {
+ const { i18n } = useTranslationContext()
+ const borwserLang = getBrowserLang()
+ //const { update } = useLang()
+
+ const [value, updateValue] = useSettings()
+ const errors: FormErrors<Settings> = {
+ }
+
+ function valueHandler(s: (d: Partial<Settings>) => Partial<Settings>): void {
+ const next = s(value)
+ const v: Settings = {
+ advanceOrderMode: next.advanceOrderMode ?? false,
+ dateFormat: next.dateFormat ?? "ymd"
+ }
+ updateValue(v)
+ }
+
+ return <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <div>
+
+ <FormProvider<Settings>
+ name="settings"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Language</i18n.Translate>
+ <span class="icon has-tooltip-right" data-tooltip={"Force language setting instance of taking the browser"}>
+ <i class="mdi mdi-information" />
+ </span>
+ </label>
+ </div>
+ <div class="field field-body has-addons is-flex-grow-3">
+ <LangSelector />
+ &nbsp;
+ {borwserLang !== undefined && <button
+ data-tooltip={i18n.str`generate random secret key`}
+ class="button is-info mr-2"
+ onClick={(e) => {
+ //update(borwserLang.substring(0, 2))
+ }}
+ >
+ <i18n.Translate>Set default</i18n.Translate>
+ </button>}
+ </div>
+ </div>
+ <InputToggle<Settings>
+ label={i18n.str`Advance order creation`}
+ tooltip={i18n.str`Shows more options in the order creation form`}
+ name="advanceOrderMode"
+ />
+ <InputSelector<Settings>
+ name="dateFormat"
+ label={i18n.str`Date format`}
+ expand={true}
+ help={
+ value.dateFormat === "dmy" ? "31/12/2001" : value.dateFormat === "mdy" ? "12/31/2001" : value.dateFormat === "ymd" ? "2001/12/31" : ""
+ }
+ toStr={(e) => {
+ if (e === "ymd") return "year month day"
+ if (e === "mdy") return "month day year"
+ if (e === "dmy") return "day month year"
+ return "choose one"
+ }}
+ values={[
+ "ymd",
+ "mdy",
+ "dmy",
+ ]}
+ tooltip={i18n.str`how the date is going to be displayed`}
+ />
+ </FormProvider>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section >
+ {onClose &&
+ <section class="section is-main-section">
+ <button
+ class="button"
+ onClick={onClose}
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </button>
+ </section>
+ }
+ </div >
+} \ No newline at end of file
diff --git a/packages/auditor-backoffice-ui/src/schemas/index.ts b/packages/auditor-backoffice-ui/src/schemas/index.ts
new file mode 100644
index 000000000..380466e13
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/schemas/index.ts
@@ -0,0 +1,245 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { isAfter, isFuture } from "date-fns";
+import * as yup from "yup";
+import { AMOUNT_REGEX, PAYTO_REGEX } from "../utils/constants.js";
+import { Amounts } from "@gnu-taler/taler-util";
+
+yup.setLocale({
+ mixed: {
+ default: "field_invalid",
+ },
+ number: {
+ min: ({ min }: any) => ({ key: "field_too_short", values: { min } }),
+ max: ({ max }: any) => ({ key: "field_too_big", values: { max } }),
+ },
+});
+
+function listOfPayToUrisAreValid(values?: (string | undefined)[]): boolean {
+ return !!values && values.every((v) => v && PAYTO_REGEX.test(v));
+}
+
+function currencyWithAmountIsValid(value?: string): boolean {
+ return !!value && Amounts.parse(value) !== undefined;
+}
+function currencyGreaterThan0(value?: string) {
+ if (value) {
+ try {
+ const [, amount] = value.split(":");
+ const intAmount = parseInt(amount, 10);
+ return intAmount > 0;
+ } catch {
+ return false;
+ }
+ }
+ return true;
+}
+
+export const InstanceSchema = yup.object().shape({
+ id: yup.string().required().meta({ type: "url" }),
+ name: yup.string().required(),
+ auth: yup.object().shape({
+ method: yup.string().matches(/^(external|token)$/),
+ token: yup.string().optional().nullable(),
+ }),
+ payto_uris: yup
+ .array()
+ .of(yup.string())
+ .min(1)
+ .meta({ type: "array" })
+ .test("payto", "{path} is not valid", listOfPayToUrisAreValid),
+ default_max_deposit_fee: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid)
+ .meta({ type: "amount" }),
+ default_max_wire_fee: yup
+ .string()
+ .required()
+ .test("amount", "{path} is not valid", currencyWithAmountIsValid)
+ .meta({ type: "amount" }),
+ default_wire_fee_amortization: yup.number().required(),
+ address: yup
+ .object()
+ .shape({
+ country: yup.string().optional(),
+ address_lines: yup.array().of(yup.string()).max(7).optional(),
+ building_number: yup.string().optional(),
+ building_name: yup.string().optional(),
+ street: yup.string().optional(),
+ post_code: yup.string().optional(),
+ town_location: yup.string().optional(),
+ town: yup.string(),
+ district: yup.string().optional(),
+ country_subdivision: yup.string().optional(),
+ })
+ .meta({ type: "group" }),
+ jurisdiction: yup
+ .object()
+ .shape({
+ country: yup.string().optional(),
+ address_lines: yup.array().of(yup.string()).max(7).optional(),
+ building_number: yup.string().optional(),
+ building_name: yup.string().optional(),
+ street: yup.string().optional(),
+ post_code: yup.string().optional(),
+ town_location: yup.string().optional(),
+ town: yup.string(),
+ district: yup.string().optional(),
+ country_subdivision: yup.string().optional(),
+ })
+ .meta({ type: "group" }),
+ // default_pay_delay: yup.object()
+ // .shape({ d_us: yup.number() })
+ // .required()
+ // .meta({ type: 'duration' }),
+ // .transform(numberToDuration),
+ default_wire_transfer_delay: yup
+ .object()
+ .shape({ d_us: yup.number() })
+ .required()
+ .meta({ type: "duration" }),
+ // .transform(numberToDuration),
+});
+
+export const InstanceUpdateSchema = InstanceSchema.clone().omit(["id"]);
+export const InstanceCreateSchema = InstanceSchema.clone();
+
+export const AuthorizeRewardSchema = yup.object().shape({
+ justification: yup.string().required(),
+ amount: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid)
+ .test("amount_positive", "the amount is not valid", currencyGreaterThan0),
+ next_url: yup.string().required(),
+});
+
+const stringIsValidJSON = (value?: string) => {
+ const p = value?.trim();
+ if (!p) return true;
+ try {
+ JSON.parse(p);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export const OrderCreateSchema = yup.object().shape({
+ pricing: yup
+ .object()
+ .required()
+ .shape({
+ summary: yup.string().ensure().required(),
+ order_price: yup
+ .string()
+ .ensure()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid)
+ .test(
+ "amount_positive",
+ "the amount should be greater than 0",
+ currencyGreaterThan0,
+ ),
+ }),
+ // extra: yup.object().test("extra", "is not a JSON format", stringIsValidJSON),
+ payments: yup
+ .object()
+ .required()
+ .shape({
+ refund_deadline: yup
+ .date()
+ .test("future", "should be in the future", (d) =>
+ d ? isFuture(d) : true,
+ ),
+ pay_deadline: yup
+ .date()
+ .test("future", "should be in the future", (d) =>
+ d ? isFuture(d) : true,
+ ),
+ auto_refund_deadline: yup
+ .date()
+ .test("future", "should be in the future", (d) =>
+ d ? isFuture(d) : true,
+ ),
+ delivery_date: yup
+ .date()
+ .test("future", "should be in the future", (d) =>
+ d ? isFuture(d) : true,
+ ),
+ })
+ .test("payment", "dates", (d) => {
+ if (
+ d.pay_deadline &&
+ d.refund_deadline &&
+ isAfter(d.refund_deadline, d.pay_deadline)
+ ) {
+ return new yup.ValidationError(
+ "pay deadline should be greater than refund",
+ "asd",
+ "payments.pay_deadline",
+ );
+ }
+ return true;
+ }),
+});
+
+export const ProductCreateSchema = yup.object().shape({
+ product_id: yup.string().ensure().required(),
+ description: yup.string().required(),
+ unit: yup.string().ensure().required(),
+ price: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid),
+ stock: yup.object({}).optional(),
+ minimum_age: yup.number().optional().min(0),
+});
+
+export const ProductUpdateSchema = yup.object().shape({
+ description: yup.string().required(),
+ price: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid),
+ stock: yup.object({}).optional(),
+ minimum_age: yup.number().optional().min(0),
+});
+
+export const TaxSchema = yup.object().shape({
+ name: yup.string().required().ensure(),
+ tax: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid),
+});
+
+export const NonInventoryProductSchema = yup.object().shape({
+ quantity: yup.number().required().positive(),
+ description: yup.string().required(),
+ unit: yup.string().ensure().required(),
+ price: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid),
+});
diff --git a/packages/auditor-backoffice-ui/src/scss/DurationPicker.scss b/packages/auditor-backoffice-ui/src/scss/DurationPicker.scss
new file mode 100644
index 000000000..aa75b9916
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/DurationPicker.scss
@@ -0,0 +1,70 @@
+.rdp-picker {
+ display: flex;
+ height: 175px;
+}
+
+@media (max-width: 400px) {
+ .rdp-picker {
+ width: 250px;
+ }
+}
+
+.rdp-masked-div {
+ overflow: hidden;
+ height: 175px;
+ position: relative;
+}
+
+.rdp-column-container {
+ flex-grow: 1;
+ display: inline-block;
+}
+
+.rdp-column {
+ position: absolute;
+ z-index: 0;
+ width: 100%;
+}
+
+.rdp-reticule {
+ border: 0;
+ border-top: 2px solid rgba(109, 202, 236, 1);
+ height: 2px;
+ position: absolute;
+ width: 80%;
+ margin: 0;
+ z-index: 100;
+ left: 50%;
+ -webkit-transform: translateX(-50%);
+ transform: translateX(-50%);
+}
+
+.rdp-text-overlay {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 35px;
+ font-size: 20px;
+ left: 50%;
+ -webkit-transform: translateX(-50%);
+ transform: translateX(-50%);
+}
+
+.rdp-cell div {
+ font-size: 17px;
+ color: gray;
+ font-style: italic;
+}
+
+.rdp-cell {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 35px;
+ font-size: 18px;
+}
+
+.rdp-center {
+ font-size: 25px;
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_aside.scss b/packages/auditor-backoffice-ui/src/scss/_aside.scss
new file mode 100644
index 000000000..e0922093b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_aside.scss
@@ -0,0 +1,181 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+@include desktop {
+ html {
+ &.has-aside-left {
+ &.has-aside-expanded {
+ nav.navbar,
+ body {
+ padding-left: $aside-width;
+ }
+ }
+ aside.is-placed-left {
+ display: block;
+ }
+ }
+ }
+
+ aside.aside.is-expanded {
+ width: $aside-width;
+
+ .menu-list {
+ @include icon-with-update-mark($aside-icon-width);
+
+ span.menu-item-label {
+ display: inline-block;
+ }
+
+ li.is-active {
+ ul {
+ display: block;
+ }
+ }
+ }
+ }
+}
+
+aside.aside {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 40;
+ height: 100vh;
+ padding: 0;
+ box-shadow: $aside-box-shadow;
+ background: $aside-background-color;
+
+ .aside-tools {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ background-color: $aside-tools-background-color;
+ color: $aside-tools-color;
+ line-height: $navbar-height;
+ height: $navbar-height;
+ padding-left: $default-padding * 0.5;
+ flex: 1;
+
+ .icon {
+ margin-right: $default-padding * 0.5;
+ }
+ }
+
+ .menu-list {
+ li {
+ a {
+ &.has-dropdown-icon {
+ position: relative;
+ padding-right: $aside-icon-width;
+
+ .dropdown-icon {
+ position: absolute;
+ top: $size-base * 0.5;
+ right: 0;
+ }
+ }
+ }
+ ul {
+ display: none;
+ border-left: 0;
+ background-color: darken($base-color, 2.5%);
+ padding-left: 0;
+ margin: 0 0 $default-padding * 0.5;
+
+ li {
+ a {
+ padding: $default-padding * 0.5 0 $default-padding * 0.5
+ $default-padding * 0.5;
+ font-size: $aside-submenu-font-size;
+
+ &.has-icon {
+ padding-left: 0;
+ }
+ &.is-active {
+ &:not(:hover) {
+ background: transparent;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .menu-label {
+ padding: 0 $default-padding * 0.5;
+ margin-top: $default-padding * 0.5;
+ margin-bottom: $default-padding * 0.5;
+ }
+}
+
+@include touch {
+ nav.navbar {
+ @include transition(margin-left);
+ }
+ aside.aside {
+ @include transition(left);
+ }
+ html.has-aside-mobile-transition {
+ body {
+ overflow-x: hidden;
+ }
+ body,
+ nav.navbar {
+ width: 100vw;
+ }
+ aside.aside {
+ width: $aside-mobile-width;
+ display: block;
+ left: $aside-mobile-width * -1;
+
+ .image {
+ img {
+ max-width: $aside-mobile-width * 0.33;
+ }
+ }
+
+ .menu-list {
+ li.is-active {
+ ul {
+ display: block;
+ }
+ }
+ a {
+ @include icon-with-update-mark($aside-icon-width);
+
+ span.menu-item-label {
+ display: inline-block;
+ }
+ }
+ }
+ }
+ }
+ div.has-aside-mobile-expanded {
+ nav.navbar {
+ margin-left: $aside-mobile-width;
+ }
+ aside.aside {
+ left: 0;
+ }
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_card.scss b/packages/auditor-backoffice-ui/src/scss/_card.scss
new file mode 100644
index 000000000..62db7f457
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_card.scss
@@ -0,0 +1,69 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+.card:not(:last-child) {
+ margin-bottom: $default-padding;
+}
+
+.card {
+ border-radius: $radius-large;
+ border: $card-border;
+
+ &.has-table {
+ .card-content {
+ padding: 0;
+ }
+ .b-table {
+ border-radius: $radius-large;
+ overflow: hidden;
+ }
+ }
+
+ &.is-card-widget {
+ .card-content {
+ padding: $default-padding * 0.5;
+ }
+ }
+
+ .card-header {
+ border-bottom: 1px solid $base-color-light;
+ }
+
+ .card-content {
+ hr {
+ margin-left: $card-content-padding * -1;
+ margin-right: $card-content-padding * -1;
+ }
+ }
+
+ .is-widget-icon {
+ .icon {
+ width: 5rem;
+ height: 5rem;
+ }
+ }
+
+ .is-widget-label {
+ .subtitle {
+ color: $grey;
+ }
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_custom-calendar.scss b/packages/auditor-backoffice-ui/src/scss/_custom-calendar.scss
new file mode 100644
index 000000000..34c40092b
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_custom-calendar.scss
@@ -0,0 +1,259 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+:root {
+ --primary-color: #3298dc;
+
+ --primary-text-color-dark: rgba(0, 0, 0, 0.87);
+ --secondary-text-color-dark: rgba(0, 0, 0, 0.57);
+ --disabled-text-color-dark: rgba(0, 0, 0, 0.13);
+
+ --primary-text-color-light: rgba(255, 255, 255, 0.87);
+ --secondary-text-color-light: rgba(255, 255, 255, 0.57);
+ --disabled-text-color-light: rgba(255, 255, 255, 0.13);
+
+ --font-stack: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif;
+
+ --primary-card-color: #fff;
+ --primary-background-color: #f2f2f2;
+
+ --box-shadow-lvl-1: 0 1px 3px rgba(0, 0, 0, 0.12),
+ 0 1px 2px rgba(0, 0, 0, 0.24);
+ --box-shadow-lvl-2: 0 3px 6px rgba(0, 0, 0, 0.16),
+ 0 3px 6px rgba(0, 0, 0, 0.23);
+ --box-shadow-lvl-3: 0 10px 20px rgba(0, 0, 0, 0.19),
+ 0 6px 6px rgba(0, 0, 0, 0.23);
+ --box-shadow-lvl-4: 0 14px 28px rgba(0, 0, 0, 0.25),
+ 0 10px 10px rgba(0, 0, 0, 0.22);
+}
+
+.datePicker {
+ text-align: left;
+ background: var(--primary-card-color);
+ border-radius: 3px;
+ z-index: 200;
+ position: fixed;
+ height: auto;
+ max-height: 90vh;
+ width: 90vw;
+ max-width: 448px;
+ transform-origin: top left;
+ transition: transform 0.22s ease-in-out, opacity 0.22s ease-in-out;
+ top: 50%;
+ left: 50%;
+ opacity: 0;
+ transform: scale(0) translate(-50%, -50%);
+ user-select: none;
+
+ &.datePicker--opened {
+ opacity: 1;
+ transform: scale(1) translate(-50%, -50%);
+ }
+
+ .datePicker--titles {
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+ padding: 24px;
+ height: 100px;
+ background: var(--primary-color);
+
+ h2,
+ h3 {
+ cursor: pointer;
+ color: #fff;
+ line-height: 1;
+ padding: 0;
+ margin: 0;
+ font-size: 32px;
+ }
+
+ h3 {
+ color: rgba(255, 255, 255, 0.57);
+ font-size: 18px;
+ padding-bottom: 2px;
+ }
+ }
+
+ nav {
+ padding: 20px;
+ height: 56px;
+
+ h4 {
+ width: calc(100% - 60px);
+ text-align: center;
+ display: inline-block;
+ padding: 0;
+ font-size: 14px;
+ line-height: 24px;
+ margin: 0;
+ position: relative;
+ top: -9px;
+ color: var(--primary-text-color);
+ }
+
+ i {
+ cursor: pointer;
+ color: var(--secondary-text-color);
+ font-size: 26px;
+ user-select: none;
+ border-radius: 50%;
+
+ &:hover {
+ background: var(--disabled-text-color-dark);
+ }
+ }
+ }
+
+ .datePicker--scroll {
+ overflow-y: auto;
+ max-height: calc(90vh - 56px - 100px);
+ }
+
+ .datePicker--calendar {
+ padding: 0 20px;
+
+ .datePicker--dayNames {
+ width: 100%;
+ display: grid;
+ text-align: center;
+
+ // there's probably a better way to do this, but wanted to try out CSS grid
+ grid-template-columns:
+ calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7)
+ calc(100% / 7) calc(100% / 7) calc(100% / 7);
+
+ span {
+ color: var(--secondary-text-color-dark);
+ font-size: 14px;
+ line-height: 42px;
+ display: inline-grid;
+ }
+ }
+
+ .datePicker--days {
+ width: 100%;
+ display: grid;
+ text-align: center;
+ grid-template-columns:
+ calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7)
+ calc(100% / 7) calc(100% / 7) calc(100% / 7);
+
+ span {
+ color: var(--primary-text-color-dark);
+ line-height: 42px;
+ font-size: 14px;
+ display: inline-grid;
+ transition: color 0.22s;
+ height: 42px;
+ position: relative;
+ cursor: pointer;
+ user-select: none;
+ border-radius: 50%;
+
+ &::before {
+ content: "";
+ position: absolute;
+ z-index: -1;
+ height: 42px;
+ width: 42px;
+ left: calc(50% - 21px);
+ background: var(--primary-color);
+ border-radius: 50%;
+ transition: transform 0.22s, opacity 0.22s;
+ transform: scale(0);
+ opacity: 0;
+ }
+
+ &[disabled="true"] {
+ cursor: unset;
+ }
+
+ &.datePicker--today {
+ font-weight: 700;
+ }
+
+ &.datePicker--selected {
+ color: rgba(255, 255, 255, 0.87);
+
+ &:before {
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+ }
+ }
+ }
+
+ .datePicker--selectYear {
+ padding: 0 20px;
+ display: block;
+ width: 100%;
+ text-align: center;
+ max-height: 362px;
+
+ span {
+ display: block;
+ width: 100%;
+ font-size: 24px;
+ margin: 20px auto;
+ cursor: pointer;
+
+ &.selected {
+ font-size: 42px;
+ color: var(--primary-color);
+ }
+ }
+ }
+
+ div.datePicker--actions {
+ width: 100%;
+ padding: 8px;
+ text-align: right;
+
+ button {
+ margin-bottom: 0;
+ font-size: 15px;
+ cursor: pointer;
+ color: var(--primary-text-color);
+ border: none;
+ margin-left: 8px;
+ min-width: 64px;
+ line-height: 36px;
+ background-color: transparent;
+ appearance: none;
+ padding: 0 16px;
+ border-radius: 3px;
+ transition: background-color 0.13s;
+
+ &:hover,
+ &:focus {
+ outline: none;
+ background-color: var(--disabled-text-color-dark);
+ }
+ }
+ }
+}
+
+.datePicker--background {
+ z-index: 199;
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.52);
+ animation: fadeIn 0.22s forwards;
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_footer.scss b/packages/auditor-backoffice-ui/src/scss/_footer.scss
new file mode 100644
index 000000000..5855af742
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_footer.scss
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+footer.footer {
+ .logo {
+ img {
+ width: auto;
+ height: $footer-logo-height;
+ }
+ }
+}
+
+@include mobile {
+ .footer-copyright {
+ text-align: center;
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_form.scss b/packages/auditor-backoffice-ui/src/scss/_form.scss
new file mode 100644
index 000000000..bd28a17cf
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_form.scss
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+.field {
+ &.has-check {
+ .field-body {
+ margin-top: $default-padding * 0.125;
+ }
+ }
+ .control {
+ .mdi-24px.mdi-set,
+ .mdi-24px.mdi:before {
+ font-size: inherit;
+ }
+ }
+}
+.upload {
+ .upload-draggable {
+ display: block;
+ }
+}
+
+.input,
+.textarea,
+select {
+ box-shadow: none;
+
+ &:focus,
+ &:active {
+ box-shadow: none !important;
+ }
+}
+
+.switch input[type="checkbox"] + .check:before {
+ box-shadow: none;
+}
+
+.switch,
+.b-checkbox.checkbox {
+ input[type="checkbox"] {
+ &:focus + .check,
+ &:focus:checked + .check {
+ box-shadow: none !important;
+ }
+ }
+}
+
+.b-checkbox.checkbox input[type="checkbox"],
+.b-radio.radio input[type="radio"] {
+ & + .check {
+ border: $checkbox-border;
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_hero-bar.scss b/packages/auditor-backoffice-ui/src/scss/_hero-bar.scss
new file mode 100644
index 000000000..0276468d7
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_hero-bar.scss
@@ -0,0 +1,55 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+section.hero.is-hero-bar {
+ background-color: $hero-bar-background;
+ border-bottom: $light-border;
+
+ .hero-body {
+ padding: $default-padding;
+
+ .level-item {
+ &.is-hero-avatar-item {
+ margin-right: $default-padding;
+ }
+
+ > div > .level {
+ margin-bottom: $default-padding * 0.5;
+ }
+
+ .subtitle + p {
+ margin-top: $default-padding * 0.5;
+ }
+ }
+
+ .button {
+ &.is-hero-button {
+ background-color: rgba($white, 0.5);
+ font-weight: 300;
+ @include transition(background-color);
+
+ &:hover {
+ background-color: $white;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_loading.scss b/packages/auditor-backoffice-ui/src/scss/_loading.scss
new file mode 100644
index 000000000..d88d8c355
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_loading.scss
@@ -0,0 +1,51 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+.lds-ring {
+ display: inline-block;
+ position: relative;
+ width: 80px;
+ height: 80px;
+}
+.lds-ring div {
+ box-sizing: border-box;
+ display: block;
+ position: absolute;
+ width: 64px;
+ height: 64px;
+ margin: 8px;
+ border: 8px solid black;
+ border-radius: 50%;
+ animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
+ border-color: black transparent transparent transparent;
+}
+.lds-ring div:nth-child(1) {
+ animation-delay: -0.45s;
+}
+.lds-ring div:nth-child(2) {
+ animation-delay: -0.3s;
+}
+.lds-ring div:nth-child(3) {
+ animation-delay: -0.15s;
+}
+@keyframes lds-ring {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_main-section.scss b/packages/auditor-backoffice-ui/src/scss/_main-section.scss
new file mode 100644
index 000000000..5a8b20ba0
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_main-section.scss
@@ -0,0 +1,24 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+section.section.is-main-section {
+ padding-top: $default-padding;
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_misc.scss b/packages/auditor-backoffice-ui/src/scss/_misc.scss
new file mode 100644
index 000000000..045d087e2
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_misc.scss
@@ -0,0 +1,50 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+.is-user-avatar {
+ &.has-max-width {
+ max-width: $size-base * 7;
+ }
+
+ &.is-aligned-center {
+ margin: 0 auto;
+ }
+
+ img {
+ margin: 0 auto;
+ border-radius: $radius-rounded;
+ }
+}
+
+.icon.has-update-mark {
+ position: relative;
+
+ &:after {
+ content: "";
+ width: $icon-update-mark-size;
+ height: $icon-update-mark-size;
+ position: absolute;
+ top: 1px;
+ right: 1px;
+ background-color: $icon-update-mark-color;
+ border-radius: $radius-rounded;
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_mixins.scss b/packages/auditor-backoffice-ui/src/scss/_mixins.scss
new file mode 100644
index 000000000..f119ec68a
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_mixins.scss
@@ -0,0 +1,34 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+@mixin transition($t) {
+ transition: $t 250ms ease-in-out 50ms;
+}
+
+@mixin icon-with-update-mark($icon-base-width) {
+ .icon {
+ width: $icon-base-width;
+
+ &.has-update-mark:after {
+ right: calc($icon-base-width / 2) - 0.85;
+ }
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_modal.scss b/packages/auditor-backoffice-ui/src/scss/_modal.scss
new file mode 100644
index 000000000..b2bfd3e9e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_modal.scss
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+.modal-card {
+ width: $modal-card-width;
+}
+
+.modal-card-foot {
+ background-color: $modal-card-foot-background-color;
+}
+
+@include mobile {
+ .modal .animation-content .modal-card {
+ width: $modal-card-width-mobile;
+ margin: 0 auto;
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_nav-bar.scss b/packages/auditor-backoffice-ui/src/scss/_nav-bar.scss
new file mode 100644
index 000000000..406e0392f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_nav-bar.scss
@@ -0,0 +1,144 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+nav.navbar {
+ box-shadow: $navbar-box-shadow;
+
+ .navbar-item {
+ &.has-user-avatar {
+ .is-user-avatar {
+ margin-right: $default-padding * 0.5;
+ display: inline-flex;
+ width: $navbar-avatar-size;
+ height: $navbar-avatar-size;
+ }
+ }
+
+ &.has-divider {
+ border-right: $navbar-divider-border;
+ }
+
+ &.no-left-space {
+ padding-left: 0;
+ }
+
+ &.has-dropdown {
+ padding-right: 0;
+ padding-left: 0;
+
+ .navbar-link {
+ padding-right: $navbar-item-h-padding;
+ padding-left: $navbar-item-h-padding;
+ }
+ }
+
+ &.has-control {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ .control {
+ .input {
+ color: $navbar-input-color;
+ border: 0;
+ box-shadow: none;
+ background: transparent;
+
+ &::placeholder {
+ color: $navbar-input-placeholder-color;
+ }
+ }
+ }
+ }
+}
+
+@include touch {
+ nav.navbar {
+ display: flex;
+ padding-right: 0;
+
+ .navbar-brand {
+ flex: 1;
+
+ &.is-right {
+ flex: none;
+ }
+ }
+
+ .navbar-item {
+ &.no-left-space-touch {
+ padding-left: 0;
+ }
+ }
+
+ .navbar-menu {
+ position: absolute;
+ width: 100vw;
+ padding-top: 0;
+ top: $navbar-height;
+ left: 0;
+
+ .navbar-item {
+ .icon:first-child {
+ margin-right: $default-padding * 0.5;
+ }
+
+ &.has-dropdown {
+ > .navbar-link {
+ background-color: $white-ter;
+ .icon:last-child {
+ display: none;
+ }
+ }
+ }
+
+ &.has-user-avatar {
+ > .navbar-link {
+ display: flex;
+ align-items: center;
+ padding-top: $default-padding * 0.5;
+ padding-bottom: $default-padding * 0.5;
+ }
+ }
+ }
+ }
+ }
+}
+
+@include desktop {
+ nav.navbar {
+ .navbar-item {
+ padding-right: $navbar-item-h-padding;
+ padding-left: $navbar-item-h-padding;
+
+ &:not(.is-desktop-icon-only) {
+ .icon:first-child {
+ margin-right: $default-padding * 0.5;
+ }
+ }
+ &.is-desktop-icon-only {
+ span:not(.icon) {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_table.scss b/packages/auditor-backoffice-ui/src/scss/_table.scss
new file mode 100644
index 000000000..e4fbfc7b3
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_table.scss
@@ -0,0 +1,179 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+table.table {
+ thead {
+ th {
+ border-bottom-width: 1px;
+ }
+ }
+
+ td,
+ th {
+ &.checkbox-cell {
+ .b-checkbox.checkbox:not(.button) {
+ margin-right: 0;
+ width: 20px;
+
+ .control-label {
+ display: none;
+ padding: 0;
+ }
+ }
+ }
+ }
+
+ td {
+ .image {
+ margin: 0 auto;
+ width: $table-avatar-size;
+ height: $table-avatar-size;
+ }
+
+ &.is-progress-col {
+ min-width: 5rem;
+ vertical-align: middle;
+ }
+ }
+}
+
+.b-table {
+ .table {
+ border: 0;
+ border-radius: 0;
+ }
+
+ /* This stylizes buefy's pagination */
+ .table-wrapper {
+ margin-bottom: 0;
+ }
+
+ .table-wrapper + .level {
+ padding: $notification-padding;
+ padding-left: $card-content-padding;
+ padding-right: $card-content-padding;
+ margin: 0;
+ border-top: $base-color-light;
+ background: $notification-background-color;
+
+ .pagination-link {
+ background: $button-background-color;
+ color: $button-color;
+ border-color: $button-border-color;
+
+ &.is-current {
+ border-color: $button-active-border-color;
+ }
+ }
+
+ .pagination-previous,
+ .pagination-next,
+ .pagination-link {
+ border-color: $button-border-color;
+ color: $base-color;
+
+ &[disabled] {
+ background-color: transparent;
+ }
+ }
+ }
+}
+
+@include mobile {
+ .card {
+ &.has-table {
+ .b-table {
+ .table-wrapper + .level {
+ .level-left + .level-right {
+ margin-top: 0;
+ }
+ }
+ }
+ }
+ &.has-mobile-sort-spaced {
+ .b-table {
+ .field.table-mobile-sort {
+ padding-top: $default-padding * 0.5;
+ }
+ }
+ }
+ }
+ .b-table {
+ .field.table-mobile-sort {
+ padding: 0 $default-padding * 0.5;
+ }
+
+ .table-wrapper.has-mobile-cards {
+ tr {
+ box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1);
+ margin-bottom: 3px !important;
+ }
+ td {
+ &.is-progress-col {
+ span,
+ progress {
+ display: flex;
+ width: 45%;
+ align-items: center;
+ align-self: center;
+ }
+ }
+
+ &.checkbox-cell,
+ &.is-image-cell {
+ border-bottom: 0 !important;
+ }
+
+ &.checkbox-cell,
+ &.is-actions-cell {
+ &:before {
+ display: none;
+ }
+ }
+
+ &.has-no-head-mobile {
+ &:before {
+ display: none;
+ }
+
+ span {
+ display: block;
+ width: 100%;
+ }
+
+ &.is-progress-col {
+ progress {
+ width: 100%;
+ }
+ }
+
+ &.is-image-cell {
+ .image {
+ width: $table-avatar-size-mobile;
+ height: auto;
+ margin: 0 auto $default-padding * 0.25;
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_theme-default.scss b/packages/auditor-backoffice-ui/src/scss/_theme-default.scss
new file mode 100644
index 000000000..e74ece0e9
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_theme-default.scss
@@ -0,0 +1,136 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+/* We'll need some initial vars to use here */
+@import "node_modules/bulma/sass/utilities/initial-variables";
+
+/* Base: Size */
+$size-base: 1rem;
+$default-padding: $size-base * 1.5;
+
+/* Default font */
+$family-sans-serif: "Nunito", sans-serif;
+
+/* Base color */
+$base-color: #2e323a;
+$base-color-light: rgba(24, 28, 33, 0.06);
+
+/* General overrides */
+$primary: $turquoise;
+$body-background-color: #f8f8f8;
+$link: $blue;
+$link-visited: $purple;
+$light-border: 1px solid $base-color-light;
+$hr-height: 1px;
+
+/* NavBar: specifics */
+$navbar-input-color: $grey-darker;
+$navbar-input-placeholder-color: $grey-lighter;
+$navbar-box-shadow: 0 1px 0 rgba(24, 28, 33, 0.04);
+$navbar-divider-border: 1px solid rgba($grey-lighter, 0.25);
+$navbar-item-h-padding: $default-padding * 0.75;
+$navbar-avatar-size: 1.75rem;
+
+/* Aside: Bulma override */
+$menu-item-radius: 0;
+$menu-list-link-padding: $size-base * 0.5 0;
+$menu-label-color: lighten($base-color, 25%);
+$menu-item-color: lighten($base-color, 30%);
+$menu-item-hover-color: $white;
+$menu-item-hover-background-color: darken($base-color, 3.5%);
+$menu-item-active-color: $white;
+$menu-item-active-background-color: darken($base-color, 2.5%);
+
+/* Aside: specifics */
+$aside-width: $size-base * 14;
+$aside-mobile-width: $size-base * 15;
+$aside-icon-width: $size-base * 3;
+$aside-submenu-font-size: $size-base * 0.95;
+$aside-box-shadow: none;
+$aside-background-color: $base-color;
+$aside-tools-background-color: darken($aside-background-color, 10%);
+$aside-tools-color: $white;
+
+/* Title Bar: specifics */
+$title-bar-color: $grey;
+$title-bar-active-color: $black-ter;
+
+/* Hero Bar: specifics */
+$hero-bar-background: $white;
+
+/* Card: Bulma override */
+$card-shadow: none;
+$card-header-shadow: none;
+
+/* Card: specifics */
+$card-border: 1px solid $base-color-light;
+$card-header-border-bottom-color: $base-color-light;
+
+/* Table: Bulma override */
+$table-cell-border: 1px solid $white-bis;
+
+/* Table: specifics */
+$table-avatar-size: $size-base * 1.5;
+$table-avatar-size-mobile: 25vw;
+
+/* Form */
+$checkbox-border: 1px solid $base-color;
+
+/* Modal card: Bulma override */
+$modal-card-head-background-color: $white-ter;
+$modal-card-title-size: $size-base;
+$modal-card-body-padding: $default-padding 20px;
+$modal-card-head-border-bottom: 1px solid $white-ter;
+$modal-card-foot-border-top: 0;
+
+/* Modal card: specifics */
+$modal-card-width: 80vw;
+$modal-card-width-mobile: 90vw;
+$modal-card-foot-background-color: $white-ter;
+
+/* Notification: Bulma override */
+$notification-padding: $default-padding * 0.75 $default-padding;
+
+/* Footer: Bulma override */
+$footer-background-color: $white;
+$footer-padding: $default-padding * 0.33 $default-padding;
+
+/* Footer: specifics */
+$footer-logo-height: $size-base * 2;
+
+/* Progress: Bulma override */
+$progress-bar-background-color: $grey-lighter;
+
+/* Icon: specifics */
+$icon-update-mark-size: $size-base * 0.5;
+$icon-update-mark-color: $yellow;
+
+$input-disabled-border-color: $grey-lighter;
+$table-row-hover-background-color: hsl(0, 0%, 80%);
+
+.menu-list {
+ div {
+ border-radius: $menu-item-radius;
+ color: $menu-item-color;
+ display: block;
+ padding: $menu-list-link-padding;
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_tiles.scss b/packages/auditor-backoffice-ui/src/scss/_tiles.scss
new file mode 100644
index 000000000..94dd6c21d
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_tiles.scss
@@ -0,0 +1,24 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+.is-tiles-wrapper {
+ margin-bottom: $default-padding;
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/_title-bar.scss b/packages/auditor-backoffice-ui/src/scss/_title-bar.scss
new file mode 100644
index 000000000..bac3f6b42
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/_title-bar.scss
@@ -0,0 +1,50 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+section.section.is-title-bar {
+ padding: $default-padding;
+ border-bottom: $light-border;
+
+ ul {
+ li {
+ display: inline-block;
+ padding: 0 $default-padding * 0.5 0 0;
+ font-size: $default-padding;
+ color: $title-bar-color;
+
+ &:after {
+ display: inline-block;
+ content: "/";
+ padding-left: $default-padding * 0.5;
+ }
+
+ &:last-child {
+ padding-right: 0;
+ font-weight: 900;
+ color: $title-bar-active-color;
+
+ &:after {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/fonts/XRXV3I6Li01BKofINeaE.ttf b/packages/auditor-backoffice-ui/src/scss/fonts/XRXV3I6Li01BKofINeaE.ttf
new file mode 100644
index 000000000..7665ee336
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/fonts/XRXV3I6Li01BKofINeaE.ttf
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/scss/fonts/nunito.css b/packages/auditor-backoffice-ui/src/scss/fonts/nunito.css
new file mode 100644
index 000000000..a578506e8
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/fonts/nunito.css
@@ -0,0 +1,22 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+@font-face {
+ font-family: "Nunito";
+ font-style: normal;
+ font-weight: 400;
+ src: url(./XRXV3I6Li01BKofINeaE.ttf) format("truetype");
+}
diff --git a/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eot b/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eot
new file mode 100644
index 000000000..ab6b25ded
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eot
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttf b/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttf
new file mode 100644
index 000000000..824be10fa
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttf
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff b/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff
new file mode 100644
index 000000000..7e087c1de
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2 b/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2
new file mode 100644
index 000000000..b5caa4ddc
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2
Binary files differ
diff --git a/packages/auditor-backoffice-ui/src/scss/icons/materialdesignicons-4.9.95.min.css b/packages/auditor-backoffice-ui/src/scss/icons/materialdesignicons-4.9.95.min.css
new file mode 100644
index 000000000..2b8a2b244
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/icons/materialdesignicons-4.9.95.min.css
@@ -0,0 +1,15109 @@
+@font-face {
+ font-family: "Material Design Icons";
+ src: url("./fonts/materialdesignicons-webfont-4.9.95.eot");
+ src: url("./fonts/materialdesignicons-webfont-4.9.95.woff2") format("woff2"),
+ url("./fonts/materialdesignicons-webfont-4.9.95.woff") format("woff"),
+ url("./fonts/materialdesignicons-webfont-4.9.95.ttf") format("truetype");
+ font-weight: normal;
+ font-style: normal;
+}
+.mdi:before,
+.mdi-set {
+ display: inline-block;
+ font: normal normal normal 24px/1 "Material Design Icons";
+ font-size: inherit;
+ text-rendering: auto;
+ line-height: inherit;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+.mdi-ab-testing::before {
+ content: "\F001C";
+}
+.mdi-abjad-arabic::before {
+ content: "\F0353";
+}
+.mdi-abjad-hebrew::before {
+ content: "\F0354";
+}
+.mdi-abugida-devanagari::before {
+ content: "\F0355";
+}
+.mdi-abugida-thai::before {
+ content: "\F0356";
+}
+.mdi-access-point::before {
+ content: "\F002";
+}
+.mdi-access-point-network::before {
+ content: "\F003";
+}
+.mdi-access-point-network-off::before {
+ content: "\FBBD";
+}
+.mdi-account::before {
+ content: "\F004";
+}
+.mdi-account-alert::before {
+ content: "\F005";
+}
+.mdi-account-alert-outline::before {
+ content: "\FB2C";
+}
+.mdi-account-arrow-left::before {
+ content: "\FB2D";
+}
+.mdi-account-arrow-left-outline::before {
+ content: "\FB2E";
+}
+.mdi-account-arrow-right::before {
+ content: "\FB2F";
+}
+.mdi-account-arrow-right-outline::before {
+ content: "\FB30";
+}
+.mdi-account-badge::before {
+ content: "\FD83";
+}
+.mdi-account-badge-alert::before {
+ content: "\FD84";
+}
+.mdi-account-badge-alert-outline::before {
+ content: "\FD85";
+}
+.mdi-account-badge-horizontal::before {
+ content: "\FDF0";
+}
+.mdi-account-badge-horizontal-outline::before {
+ content: "\FDF1";
+}
+.mdi-account-badge-outline::before {
+ content: "\FD86";
+}
+.mdi-account-box::before {
+ content: "\F006";
+}
+.mdi-account-box-multiple::before {
+ content: "\F933";
+}
+.mdi-account-box-multiple-outline::before {
+ content: "\F002C";
+}
+.mdi-account-box-outline::before {
+ content: "\F007";
+}
+.mdi-account-cancel::before {
+ content: "\F030A";
+}
+.mdi-account-cancel-outline::before {
+ content: "\F030B";
+}
+.mdi-account-card-details::before {
+ content: "\F5D2";
+}
+.mdi-account-card-details-outline::before {
+ content: "\FD87";
+}
+.mdi-account-cash::before {
+ content: "\F00C2";
+}
+.mdi-account-cash-outline::before {
+ content: "\F00C3";
+}
+.mdi-account-check::before {
+ content: "\F008";
+}
+.mdi-account-check-outline::before {
+ content: "\FBBE";
+}
+.mdi-account-child::before {
+ content: "\FA88";
+}
+.mdi-account-child-circle::before {
+ content: "\FA89";
+}
+.mdi-account-child-outline::before {
+ content: "\F00F3";
+}
+.mdi-account-circle::before {
+ content: "\F009";
+}
+.mdi-account-circle-outline::before {
+ content: "\FB31";
+}
+.mdi-account-clock::before {
+ content: "\FB32";
+}
+.mdi-account-clock-outline::before {
+ content: "\FB33";
+}
+.mdi-account-cog::before {
+ content: "\F039B";
+}
+.mdi-account-cog-outline::before {
+ content: "\F039C";
+}
+.mdi-account-convert::before {
+ content: "\F00A";
+}
+.mdi-account-convert-outline::before {
+ content: "\F032C";
+}
+.mdi-account-details::before {
+ content: "\F631";
+}
+.mdi-account-details-outline::before {
+ content: "\F039D";
+}
+.mdi-account-edit::before {
+ content: "\F6BB";
+}
+.mdi-account-edit-outline::before {
+ content: "\F001D";
+}
+.mdi-account-group::before {
+ content: "\F848";
+}
+.mdi-account-group-outline::before {
+ content: "\FB34";
+}
+.mdi-account-heart::before {
+ content: "\F898";
+}
+.mdi-account-heart-outline::before {
+ content: "\FBBF";
+}
+.mdi-account-key::before {
+ content: "\F00B";
+}
+.mdi-account-key-outline::before {
+ content: "\FBC0";
+}
+.mdi-account-lock::before {
+ content: "\F0189";
+}
+.mdi-account-lock-outline::before {
+ content: "\F018A";
+}
+.mdi-account-minus::before {
+ content: "\F00D";
+}
+.mdi-account-minus-outline::before {
+ content: "\FAEB";
+}
+.mdi-account-multiple::before {
+ content: "\F00E";
+}
+.mdi-account-multiple-check::before {
+ content: "\F8C4";
+}
+.mdi-account-multiple-check-outline::before {
+ content: "\F0229";
+}
+.mdi-account-multiple-minus::before {
+ content: "\F5D3";
+}
+.mdi-account-multiple-minus-outline::before {
+ content: "\FBC1";
+}
+.mdi-account-multiple-outline::before {
+ content: "\F00F";
+}
+.mdi-account-multiple-plus::before {
+ content: "\F010";
+}
+.mdi-account-multiple-plus-outline::before {
+ content: "\F7FF";
+}
+.mdi-account-multiple-remove::before {
+ content: "\F0235";
+}
+.mdi-account-multiple-remove-outline::before {
+ content: "\F0236";
+}
+.mdi-account-network::before {
+ content: "\F011";
+}
+.mdi-account-network-outline::before {
+ content: "\FBC2";
+}
+.mdi-account-off::before {
+ content: "\F012";
+}
+.mdi-account-off-outline::before {
+ content: "\FBC3";
+}
+.mdi-account-outline::before {
+ content: "\F013";
+}
+.mdi-account-plus::before {
+ content: "\F014";
+}
+.mdi-account-plus-outline::before {
+ content: "\F800";
+}
+.mdi-account-question::before {
+ content: "\FB35";
+}
+.mdi-account-question-outline::before {
+ content: "\FB36";
+}
+.mdi-account-remove::before {
+ content: "\F015";
+}
+.mdi-account-remove-outline::before {
+ content: "\FAEC";
+}
+.mdi-account-search::before {
+ content: "\F016";
+}
+.mdi-account-search-outline::before {
+ content: "\F934";
+}
+.mdi-account-settings::before {
+ content: "\F630";
+}
+.mdi-account-settings-outline::before {
+ content: "\F00F4";
+}
+.mdi-account-star::before {
+ content: "\F017";
+}
+.mdi-account-star-outline::before {
+ content: "\FBC4";
+}
+.mdi-account-supervisor::before {
+ content: "\FA8A";
+}
+.mdi-account-supervisor-circle::before {
+ content: "\FA8B";
+}
+.mdi-account-supervisor-outline::before {
+ content: "\F0158";
+}
+.mdi-account-switch::before {
+ content: "\F019";
+}
+.mdi-account-tie::before {
+ content: "\FCBF";
+}
+.mdi-account-tie-outline::before {
+ content: "\F00F5";
+}
+.mdi-account-tie-voice::before {
+ content: "\F0333";
+}
+.mdi-account-tie-voice-off::before {
+ content: "\F0335";
+}
+.mdi-account-tie-voice-off-outline::before {
+ content: "\F0336";
+}
+.mdi-account-tie-voice-outline::before {
+ content: "\F0334";
+}
+.mdi-accusoft::before {
+ content: "\F849";
+}
+.mdi-adjust::before {
+ content: "\F01A";
+}
+.mdi-adobe::before {
+ content: "\F935";
+}
+.mdi-adobe-acrobat::before {
+ content: "\FFBD";
+}
+.mdi-air-conditioner::before {
+ content: "\F01B";
+}
+.mdi-air-filter::before {
+ content: "\FD1F";
+}
+.mdi-air-horn::before {
+ content: "\FD88";
+}
+.mdi-air-humidifier::before {
+ content: "\F00C4";
+}
+.mdi-air-purifier::before {
+ content: "\FD20";
+}
+.mdi-airbag::before {
+ content: "\FBC5";
+}
+.mdi-airballoon::before {
+ content: "\F01C";
+}
+.mdi-airballoon-outline::before {
+ content: "\F002D";
+}
+.mdi-airplane::before {
+ content: "\F01D";
+}
+.mdi-airplane-landing::before {
+ content: "\F5D4";
+}
+.mdi-airplane-off::before {
+ content: "\F01E";
+}
+.mdi-airplane-takeoff::before {
+ content: "\F5D5";
+}
+.mdi-airplay::before {
+ content: "\F01F";
+}
+.mdi-airport::before {
+ content: "\F84A";
+}
+.mdi-alarm::before {
+ content: "\F020";
+}
+.mdi-alarm-bell::before {
+ content: "\F78D";
+}
+.mdi-alarm-check::before {
+ content: "\F021";
+}
+.mdi-alarm-light::before {
+ content: "\F78E";
+}
+.mdi-alarm-light-outline::before {
+ content: "\FBC6";
+}
+.mdi-alarm-multiple::before {
+ content: "\F022";
+}
+.mdi-alarm-note::before {
+ content: "\FE8E";
+}
+.mdi-alarm-note-off::before {
+ content: "\FE8F";
+}
+.mdi-alarm-off::before {
+ content: "\F023";
+}
+.mdi-alarm-plus::before {
+ content: "\F024";
+}
+.mdi-alarm-snooze::before {
+ content: "\F68D";
+}
+.mdi-album::before {
+ content: "\F025";
+}
+.mdi-alert::before {
+ content: "\F026";
+}
+.mdi-alert-box::before {
+ content: "\F027";
+}
+.mdi-alert-box-outline::before {
+ content: "\FCC0";
+}
+.mdi-alert-circle::before {
+ content: "\F028";
+}
+.mdi-alert-circle-check::before {
+ content: "\F0218";
+}
+.mdi-alert-circle-check-outline::before {
+ content: "\F0219";
+}
+.mdi-alert-circle-outline::before {
+ content: "\F5D6";
+}
+.mdi-alert-decagram::before {
+ content: "\F6BC";
+}
+.mdi-alert-decagram-outline::before {
+ content: "\FCC1";
+}
+.mdi-alert-octagon::before {
+ content: "\F029";
+}
+.mdi-alert-octagon-outline::before {
+ content: "\FCC2";
+}
+.mdi-alert-octagram::before {
+ content: "\F766";
+}
+.mdi-alert-octagram-outline::before {
+ content: "\FCC3";
+}
+.mdi-alert-outline::before {
+ content: "\F02A";
+}
+.mdi-alert-rhombus::before {
+ content: "\F01F9";
+}
+.mdi-alert-rhombus-outline::before {
+ content: "\F01FA";
+}
+.mdi-alien::before {
+ content: "\F899";
+}
+.mdi-alien-outline::before {
+ content: "\F00F6";
+}
+.mdi-align-horizontal-center::before {
+ content: "\F01EE";
+}
+.mdi-align-horizontal-left::before {
+ content: "\F01ED";
+}
+.mdi-align-horizontal-right::before {
+ content: "\F01EF";
+}
+.mdi-align-vertical-bottom::before {
+ content: "\F01F0";
+}
+.mdi-align-vertical-center::before {
+ content: "\F01F1";
+}
+.mdi-align-vertical-top::before {
+ content: "\F01F2";
+}
+.mdi-all-inclusive::before {
+ content: "\F6BD";
+}
+.mdi-allergy::before {
+ content: "\F0283";
+}
+.mdi-alpha::before {
+ content: "\F02B";
+}
+.mdi-alpha-a::before {
+ content: "\41";
+}
+.mdi-alpha-a-box::before {
+ content: "\FAED";
+}
+.mdi-alpha-a-box-outline::before {
+ content: "\FBC7";
+}
+.mdi-alpha-a-circle::before {
+ content: "\FBC8";
+}
+.mdi-alpha-a-circle-outline::before {
+ content: "\FBC9";
+}
+.mdi-alpha-b::before {
+ content: "\42";
+}
+.mdi-alpha-b-box::before {
+ content: "\FAEE";
+}
+.mdi-alpha-b-box-outline::before {
+ content: "\FBCA";
+}
+.mdi-alpha-b-circle::before {
+ content: "\FBCB";
+}
+.mdi-alpha-b-circle-outline::before {
+ content: "\FBCC";
+}
+.mdi-alpha-c::before {
+ content: "\43";
+}
+.mdi-alpha-c-box::before {
+ content: "\FAEF";
+}
+.mdi-alpha-c-box-outline::before {
+ content: "\FBCD";
+}
+.mdi-alpha-c-circle::before {
+ content: "\FBCE";
+}
+.mdi-alpha-c-circle-outline::before {
+ content: "\FBCF";
+}
+.mdi-alpha-d::before {
+ content: "\44";
+}
+.mdi-alpha-d-box::before {
+ content: "\FAF0";
+}
+.mdi-alpha-d-box-outline::before {
+ content: "\FBD0";
+}
+.mdi-alpha-d-circle::before {
+ content: "\FBD1";
+}
+.mdi-alpha-d-circle-outline::before {
+ content: "\FBD2";
+}
+.mdi-alpha-e::before {
+ content: "\45";
+}
+.mdi-alpha-e-box::before {
+ content: "\FAF1";
+}
+.mdi-alpha-e-box-outline::before {
+ content: "\FBD3";
+}
+.mdi-alpha-e-circle::before {
+ content: "\FBD4";
+}
+.mdi-alpha-e-circle-outline::before {
+ content: "\FBD5";
+}
+.mdi-alpha-f::before {
+ content: "\46";
+}
+.mdi-alpha-f-box::before {
+ content: "\FAF2";
+}
+.mdi-alpha-f-box-outline::before {
+ content: "\FBD6";
+}
+.mdi-alpha-f-circle::before {
+ content: "\FBD7";
+}
+.mdi-alpha-f-circle-outline::before {
+ content: "\FBD8";
+}
+.mdi-alpha-g::before {
+ content: "\47";
+}
+.mdi-alpha-g-box::before {
+ content: "\FAF3";
+}
+.mdi-alpha-g-box-outline::before {
+ content: "\FBD9";
+}
+.mdi-alpha-g-circle::before {
+ content: "\FBDA";
+}
+.mdi-alpha-g-circle-outline::before {
+ content: "\FBDB";
+}
+.mdi-alpha-h::before {
+ content: "\48";
+}
+.mdi-alpha-h-box::before {
+ content: "\FAF4";
+}
+.mdi-alpha-h-box-outline::before {
+ content: "\FBDC";
+}
+.mdi-alpha-h-circle::before {
+ content: "\FBDD";
+}
+.mdi-alpha-h-circle-outline::before {
+ content: "\FBDE";
+}
+.mdi-alpha-i::before {
+ content: "\49";
+}
+.mdi-alpha-i-box::before {
+ content: "\FAF5";
+}
+.mdi-alpha-i-box-outline::before {
+ content: "\FBDF";
+}
+.mdi-alpha-i-circle::before {
+ content: "\FBE0";
+}
+.mdi-alpha-i-circle-outline::before {
+ content: "\FBE1";
+}
+.mdi-alpha-j::before {
+ content: "\4A";
+}
+.mdi-alpha-j-box::before {
+ content: "\FAF6";
+}
+.mdi-alpha-j-box-outline::before {
+ content: "\FBE2";
+}
+.mdi-alpha-j-circle::before {
+ content: "\FBE3";
+}
+.mdi-alpha-j-circle-outline::before {
+ content: "\FBE4";
+}
+.mdi-alpha-k::before {
+ content: "\4B";
+}
+.mdi-alpha-k-box::before {
+ content: "\FAF7";
+}
+.mdi-alpha-k-box-outline::before {
+ content: "\FBE5";
+}
+.mdi-alpha-k-circle::before {
+ content: "\FBE6";
+}
+.mdi-alpha-k-circle-outline::before {
+ content: "\FBE7";
+}
+.mdi-alpha-l::before {
+ content: "\4C";
+}
+.mdi-alpha-l-box::before {
+ content: "\FAF8";
+}
+.mdi-alpha-l-box-outline::before {
+ content: "\FBE8";
+}
+.mdi-alpha-l-circle::before {
+ content: "\FBE9";
+}
+.mdi-alpha-l-circle-outline::before {
+ content: "\FBEA";
+}
+.mdi-alpha-m::before {
+ content: "\4D";
+}
+.mdi-alpha-m-box::before {
+ content: "\FAF9";
+}
+.mdi-alpha-m-box-outline::before {
+ content: "\FBEB";
+}
+.mdi-alpha-m-circle::before {
+ content: "\FBEC";
+}
+.mdi-alpha-m-circle-outline::before {
+ content: "\FBED";
+}
+.mdi-alpha-n::before {
+ content: "\4E";
+}
+.mdi-alpha-n-box::before {
+ content: "\FAFA";
+}
+.mdi-alpha-n-box-outline::before {
+ content: "\FBEE";
+}
+.mdi-alpha-n-circle::before {
+ content: "\FBEF";
+}
+.mdi-alpha-n-circle-outline::before {
+ content: "\FBF0";
+}
+.mdi-alpha-o::before {
+ content: "\4F";
+}
+.mdi-alpha-o-box::before {
+ content: "\FAFB";
+}
+.mdi-alpha-o-box-outline::before {
+ content: "\FBF1";
+}
+.mdi-alpha-o-circle::before {
+ content: "\FBF2";
+}
+.mdi-alpha-o-circle-outline::before {
+ content: "\FBF3";
+}
+.mdi-alpha-p::before {
+ content: "\50";
+}
+.mdi-alpha-p-box::before {
+ content: "\FAFC";
+}
+.mdi-alpha-p-box-outline::before {
+ content: "\FBF4";
+}
+.mdi-alpha-p-circle::before {
+ content: "\FBF5";
+}
+.mdi-alpha-p-circle-outline::before {
+ content: "\FBF6";
+}
+.mdi-alpha-q::before {
+ content: "\51";
+}
+.mdi-alpha-q-box::before {
+ content: "\FAFD";
+}
+.mdi-alpha-q-box-outline::before {
+ content: "\FBF7";
+}
+.mdi-alpha-q-circle::before {
+ content: "\FBF8";
+}
+.mdi-alpha-q-circle-outline::before {
+ content: "\FBF9";
+}
+.mdi-alpha-r::before {
+ content: "\52";
+}
+.mdi-alpha-r-box::before {
+ content: "\FAFE";
+}
+.mdi-alpha-r-box-outline::before {
+ content: "\FBFA";
+}
+.mdi-alpha-r-circle::before {
+ content: "\FBFB";
+}
+.mdi-alpha-r-circle-outline::before {
+ content: "\FBFC";
+}
+.mdi-alpha-s::before {
+ content: "\53";
+}
+.mdi-alpha-s-box::before {
+ content: "\FAFF";
+}
+.mdi-alpha-s-box-outline::before {
+ content: "\FBFD";
+}
+.mdi-alpha-s-circle::before {
+ content: "\FBFE";
+}
+.mdi-alpha-s-circle-outline::before {
+ content: "\FBFF";
+}
+.mdi-alpha-t::before {
+ content: "\54";
+}
+.mdi-alpha-t-box::before {
+ content: "\FB00";
+}
+.mdi-alpha-t-box-outline::before {
+ content: "\FC00";
+}
+.mdi-alpha-t-circle::before {
+ content: "\FC01";
+}
+.mdi-alpha-t-circle-outline::before {
+ content: "\FC02";
+}
+.mdi-alpha-u::before {
+ content: "\55";
+}
+.mdi-alpha-u-box::before {
+ content: "\FB01";
+}
+.mdi-alpha-u-box-outline::before {
+ content: "\FC03";
+}
+.mdi-alpha-u-circle::before {
+ content: "\FC04";
+}
+.mdi-alpha-u-circle-outline::before {
+ content: "\FC05";
+}
+.mdi-alpha-v::before {
+ content: "\56";
+}
+.mdi-alpha-v-box::before {
+ content: "\FB02";
+}
+.mdi-alpha-v-box-outline::before {
+ content: "\FC06";
+}
+.mdi-alpha-v-circle::before {
+ content: "\FC07";
+}
+.mdi-alpha-v-circle-outline::before {
+ content: "\FC08";
+}
+.mdi-alpha-w::before {
+ content: "\57";
+}
+.mdi-alpha-w-box::before {
+ content: "\FB03";
+}
+.mdi-alpha-w-box-outline::before {
+ content: "\FC09";
+}
+.mdi-alpha-w-circle::before {
+ content: "\FC0A";
+}
+.mdi-alpha-w-circle-outline::before {
+ content: "\FC0B";
+}
+.mdi-alpha-x::before {
+ content: "\58";
+}
+.mdi-alpha-x-box::before {
+ content: "\FB04";
+}
+.mdi-alpha-x-box-outline::before {
+ content: "\FC0C";
+}
+.mdi-alpha-x-circle::before {
+ content: "\FC0D";
+}
+.mdi-alpha-x-circle-outline::before {
+ content: "\FC0E";
+}
+.mdi-alpha-y::before {
+ content: "\59";
+}
+.mdi-alpha-y-box::before {
+ content: "\FB05";
+}
+.mdi-alpha-y-box-outline::before {
+ content: "\FC0F";
+}
+.mdi-alpha-y-circle::before {
+ content: "\FC10";
+}
+.mdi-alpha-y-circle-outline::before {
+ content: "\FC11";
+}
+.mdi-alpha-z::before {
+ content: "\5A";
+}
+.mdi-alpha-z-box::before {
+ content: "\FB06";
+}
+.mdi-alpha-z-box-outline::before {
+ content: "\FC12";
+}
+.mdi-alpha-z-circle::before {
+ content: "\FC13";
+}
+.mdi-alpha-z-circle-outline::before {
+ content: "\FC14";
+}
+.mdi-alphabet-aurebesh::before {
+ content: "\F0357";
+}
+.mdi-alphabet-cyrillic::before {
+ content: "\F0358";
+}
+.mdi-alphabet-greek::before {
+ content: "\F0359";
+}
+.mdi-alphabet-latin::before {
+ content: "\F035A";
+}
+.mdi-alphabet-piqad::before {
+ content: "\F035B";
+}
+.mdi-alphabet-tengwar::before {
+ content: "\F0362";
+}
+.mdi-alphabetical::before {
+ content: "\F02C";
+}
+.mdi-alphabetical-off::before {
+ content: "\F002E";
+}
+.mdi-alphabetical-variant::before {
+ content: "\F002F";
+}
+.mdi-alphabetical-variant-off::before {
+ content: "\F0030";
+}
+.mdi-altimeter::before {
+ content: "\F5D7";
+}
+.mdi-amazon::before {
+ content: "\F02D";
+}
+.mdi-amazon-alexa::before {
+ content: "\F8C5";
+}
+.mdi-amazon-drive::before {
+ content: "\F02E";
+}
+.mdi-ambulance::before {
+ content: "\F02F";
+}
+.mdi-ammunition::before {
+ content: "\FCC4";
+}
+.mdi-ampersand::before {
+ content: "\FA8C";
+}
+.mdi-amplifier::before {
+ content: "\F030";
+}
+.mdi-amplifier-off::before {
+ content: "\F01E0";
+}
+.mdi-anchor::before {
+ content: "\F031";
+}
+.mdi-android::before {
+ content: "\F032";
+}
+.mdi-android-auto::before {
+ content: "\FA8D";
+}
+.mdi-android-debug-bridge::before {
+ content: "\F033";
+}
+.mdi-android-head::before {
+ content: "\F78F";
+}
+.mdi-android-messages::before {
+ content: "\FD21";
+}
+.mdi-android-studio::before {
+ content: "\F034";
+}
+.mdi-angle-acute::before {
+ content: "\F936";
+}
+.mdi-angle-obtuse::before {
+ content: "\F937";
+}
+.mdi-angle-right::before {
+ content: "\F938";
+}
+.mdi-angular::before {
+ content: "\F6B1";
+}
+.mdi-angularjs::before {
+ content: "\F6BE";
+}
+.mdi-animation::before {
+ content: "\F5D8";
+}
+.mdi-animation-outline::before {
+ content: "\FA8E";
+}
+.mdi-animation-play::before {
+ content: "\F939";
+}
+.mdi-animation-play-outline::before {
+ content: "\FA8F";
+}
+.mdi-ansible::before {
+ content: "\F00C5";
+}
+.mdi-antenna::before {
+ content: "\F0144";
+}
+.mdi-anvil::before {
+ content: "\F89A";
+}
+.mdi-apache-kafka::before {
+ content: "\F0031";
+}
+.mdi-api::before {
+ content: "\F00C6";
+}
+.mdi-api-off::before {
+ content: "\F0282";
+}
+.mdi-apple::before {
+ content: "\F035";
+}
+.mdi-apple-finder::before {
+ content: "\F036";
+}
+.mdi-apple-icloud::before {
+ content: "\F038";
+}
+.mdi-apple-ios::before {
+ content: "\F037";
+}
+.mdi-apple-keyboard-caps::before {
+ content: "\F632";
+}
+.mdi-apple-keyboard-command::before {
+ content: "\F633";
+}
+.mdi-apple-keyboard-control::before {
+ content: "\F634";
+}
+.mdi-apple-keyboard-option::before {
+ content: "\F635";
+}
+.mdi-apple-keyboard-shift::before {
+ content: "\F636";
+}
+.mdi-apple-safari::before {
+ content: "\F039";
+}
+.mdi-application::before {
+ content: "\F614";
+}
+.mdi-application-export::before {
+ content: "\FD89";
+}
+.mdi-application-import::before {
+ content: "\FD8A";
+}
+.mdi-approximately-equal::before {
+ content: "\FFBE";
+}
+.mdi-approximately-equal-box::before {
+ content: "\FFBF";
+}
+.mdi-apps::before {
+ content: "\F03B";
+}
+.mdi-apps-box::before {
+ content: "\FD22";
+}
+.mdi-arch::before {
+ content: "\F8C6";
+}
+.mdi-archive::before {
+ content: "\F03C";
+}
+.mdi-archive-arrow-down::before {
+ content: "\F0284";
+}
+.mdi-archive-arrow-down-outline::before {
+ content: "\F0285";
+}
+.mdi-archive-arrow-up::before {
+ content: "\F0286";
+}
+.mdi-archive-arrow-up-outline::before {
+ content: "\F0287";
+}
+.mdi-archive-outline::before {
+ content: "\F0239";
+}
+.mdi-arm-flex::before {
+ content: "\F008F";
+}
+.mdi-arm-flex-outline::before {
+ content: "\F0090";
+}
+.mdi-arrange-bring-forward::before {
+ content: "\F03D";
+}
+.mdi-arrange-bring-to-front::before {
+ content: "\F03E";
+}
+.mdi-arrange-send-backward::before {
+ content: "\F03F";
+}
+.mdi-arrange-send-to-back::before {
+ content: "\F040";
+}
+.mdi-arrow-all::before {
+ content: "\F041";
+}
+.mdi-arrow-bottom-left::before {
+ content: "\F042";
+}
+.mdi-arrow-bottom-left-bold-outline::before {
+ content: "\F9B6";
+}
+.mdi-arrow-bottom-left-thick::before {
+ content: "\F9B7";
+}
+.mdi-arrow-bottom-right::before {
+ content: "\F043";
+}
+.mdi-arrow-bottom-right-bold-outline::before {
+ content: "\F9B8";
+}
+.mdi-arrow-bottom-right-thick::before {
+ content: "\F9B9";
+}
+.mdi-arrow-collapse::before {
+ content: "\F615";
+}
+.mdi-arrow-collapse-all::before {
+ content: "\F044";
+}
+.mdi-arrow-collapse-down::before {
+ content: "\F791";
+}
+.mdi-arrow-collapse-horizontal::before {
+ content: "\F84B";
+}
+.mdi-arrow-collapse-left::before {
+ content: "\F792";
+}
+.mdi-arrow-collapse-right::before {
+ content: "\F793";
+}
+.mdi-arrow-collapse-up::before {
+ content: "\F794";
+}
+.mdi-arrow-collapse-vertical::before {
+ content: "\F84C";
+}
+.mdi-arrow-decision::before {
+ content: "\F9BA";
+}
+.mdi-arrow-decision-auto::before {
+ content: "\F9BB";
+}
+.mdi-arrow-decision-auto-outline::before {
+ content: "\F9BC";
+}
+.mdi-arrow-decision-outline::before {
+ content: "\F9BD";
+}
+.mdi-arrow-down::before {
+ content: "\F045";
+}
+.mdi-arrow-down-bold::before {
+ content: "\F72D";
+}
+.mdi-arrow-down-bold-box::before {
+ content: "\F72E";
+}
+.mdi-arrow-down-bold-box-outline::before {
+ content: "\F72F";
+}
+.mdi-arrow-down-bold-circle::before {
+ content: "\F047";
+}
+.mdi-arrow-down-bold-circle-outline::before {
+ content: "\F048";
+}
+.mdi-arrow-down-bold-hexagon-outline::before {
+ content: "\F049";
+}
+.mdi-arrow-down-bold-outline::before {
+ content: "\F9BE";
+}
+.mdi-arrow-down-box::before {
+ content: "\F6BF";
+}
+.mdi-arrow-down-circle::before {
+ content: "\FCB7";
+}
+.mdi-arrow-down-circle-outline::before {
+ content: "\FCB8";
+}
+.mdi-arrow-down-drop-circle::before {
+ content: "\F04A";
+}
+.mdi-arrow-down-drop-circle-outline::before {
+ content: "\F04B";
+}
+.mdi-arrow-down-thick::before {
+ content: "\F046";
+}
+.mdi-arrow-expand::before {
+ content: "\F616";
+}
+.mdi-arrow-expand-all::before {
+ content: "\F04C";
+}
+.mdi-arrow-expand-down::before {
+ content: "\F795";
+}
+.mdi-arrow-expand-horizontal::before {
+ content: "\F84D";
+}
+.mdi-arrow-expand-left::before {
+ content: "\F796";
+}
+.mdi-arrow-expand-right::before {
+ content: "\F797";
+}
+.mdi-arrow-expand-up::before {
+ content: "\F798";
+}
+.mdi-arrow-expand-vertical::before {
+ content: "\F84E";
+}
+.mdi-arrow-horizontal-lock::before {
+ content: "\F0186";
+}
+.mdi-arrow-left::before {
+ content: "\F04D";
+}
+.mdi-arrow-left-bold::before {
+ content: "\F730";
+}
+.mdi-arrow-left-bold-box::before {
+ content: "\F731";
+}
+.mdi-arrow-left-bold-box-outline::before {
+ content: "\F732";
+}
+.mdi-arrow-left-bold-circle::before {
+ content: "\F04F";
+}
+.mdi-arrow-left-bold-circle-outline::before {
+ content: "\F050";
+}
+.mdi-arrow-left-bold-hexagon-outline::before {
+ content: "\F051";
+}
+.mdi-arrow-left-bold-outline::before {
+ content: "\F9BF";
+}
+.mdi-arrow-left-box::before {
+ content: "\F6C0";
+}
+.mdi-arrow-left-circle::before {
+ content: "\FCB9";
+}
+.mdi-arrow-left-circle-outline::before {
+ content: "\FCBA";
+}
+.mdi-arrow-left-drop-circle::before {
+ content: "\F052";
+}
+.mdi-arrow-left-drop-circle-outline::before {
+ content: "\F053";
+}
+.mdi-arrow-left-right::before {
+ content: "\FE90";
+}
+.mdi-arrow-left-right-bold::before {
+ content: "\FE91";
+}
+.mdi-arrow-left-right-bold-outline::before {
+ content: "\F9C0";
+}
+.mdi-arrow-left-thick::before {
+ content: "\F04E";
+}
+.mdi-arrow-right::before {
+ content: "\F054";
+}
+.mdi-arrow-right-bold::before {
+ content: "\F733";
+}
+.mdi-arrow-right-bold-box::before {
+ content: "\F734";
+}
+.mdi-arrow-right-bold-box-outline::before {
+ content: "\F735";
+}
+.mdi-arrow-right-bold-circle::before {
+ content: "\F056";
+}
+.mdi-arrow-right-bold-circle-outline::before {
+ content: "\F057";
+}
+.mdi-arrow-right-bold-hexagon-outline::before {
+ content: "\F058";
+}
+.mdi-arrow-right-bold-outline::before {
+ content: "\F9C1";
+}
+.mdi-arrow-right-box::before {
+ content: "\F6C1";
+}
+.mdi-arrow-right-circle::before {
+ content: "\FCBB";
+}
+.mdi-arrow-right-circle-outline::before {
+ content: "\FCBC";
+}
+.mdi-arrow-right-drop-circle::before {
+ content: "\F059";
+}
+.mdi-arrow-right-drop-circle-outline::before {
+ content: "\F05A";
+}
+.mdi-arrow-right-thick::before {
+ content: "\F055";
+}
+.mdi-arrow-split-horizontal::before {
+ content: "\F93A";
+}
+.mdi-arrow-split-vertical::before {
+ content: "\F93B";
+}
+.mdi-arrow-top-left::before {
+ content: "\F05B";
+}
+.mdi-arrow-top-left-bold-outline::before {
+ content: "\F9C2";
+}
+.mdi-arrow-top-left-bottom-right::before {
+ content: "\FE92";
+}
+.mdi-arrow-top-left-bottom-right-bold::before {
+ content: "\FE93";
+}
+.mdi-arrow-top-left-thick::before {
+ content: "\F9C3";
+}
+.mdi-arrow-top-right::before {
+ content: "\F05C";
+}
+.mdi-arrow-top-right-bold-outline::before {
+ content: "\F9C4";
+}
+.mdi-arrow-top-right-bottom-left::before {
+ content: "\FE94";
+}
+.mdi-arrow-top-right-bottom-left-bold::before {
+ content: "\FE95";
+}
+.mdi-arrow-top-right-thick::before {
+ content: "\F9C5";
+}
+.mdi-arrow-up::before {
+ content: "\F05D";
+}
+.mdi-arrow-up-bold::before {
+ content: "\F736";
+}
+.mdi-arrow-up-bold-box::before {
+ content: "\F737";
+}
+.mdi-arrow-up-bold-box-outline::before {
+ content: "\F738";
+}
+.mdi-arrow-up-bold-circle::before {
+ content: "\F05F";
+}
+.mdi-arrow-up-bold-circle-outline::before {
+ content: "\F060";
+}
+.mdi-arrow-up-bold-hexagon-outline::before {
+ content: "\F061";
+}
+.mdi-arrow-up-bold-outline::before {
+ content: "\F9C6";
+}
+.mdi-arrow-up-box::before {
+ content: "\F6C2";
+}
+.mdi-arrow-up-circle::before {
+ content: "\FCBD";
+}
+.mdi-arrow-up-circle-outline::before {
+ content: "\FCBE";
+}
+.mdi-arrow-up-down::before {
+ content: "\FE96";
+}
+.mdi-arrow-up-down-bold::before {
+ content: "\FE97";
+}
+.mdi-arrow-up-down-bold-outline::before {
+ content: "\F9C7";
+}
+.mdi-arrow-up-drop-circle::before {
+ content: "\F062";
+}
+.mdi-arrow-up-drop-circle-outline::before {
+ content: "\F063";
+}
+.mdi-arrow-up-thick::before {
+ content: "\F05E";
+}
+.mdi-arrow-vertical-lock::before {
+ content: "\F0187";
+}
+.mdi-artist::before {
+ content: "\F802";
+}
+.mdi-artist-outline::before {
+ content: "\FCC5";
+}
+.mdi-artstation::before {
+ content: "\FB37";
+}
+.mdi-aspect-ratio::before {
+ content: "\FA23";
+}
+.mdi-assistant::before {
+ content: "\F064";
+}
+.mdi-asterisk::before {
+ content: "\F6C3";
+}
+.mdi-at::before {
+ content: "\F065";
+}
+.mdi-atlassian::before {
+ content: "\F803";
+}
+.mdi-atm::before {
+ content: "\FD23";
+}
+.mdi-atom::before {
+ content: "\F767";
+}
+.mdi-atom-variant::before {
+ content: "\FE98";
+}
+.mdi-attachment::before {
+ content: "\F066";
+}
+.mdi-audio-video::before {
+ content: "\F93C";
+}
+.mdi-audio-video-off::before {
+ content: "\F01E1";
+}
+.mdi-audiobook::before {
+ content: "\F067";
+}
+.mdi-augmented-reality::before {
+ content: "\F84F";
+}
+.mdi-auto-download::before {
+ content: "\F03A9";
+}
+.mdi-auto-fix::before {
+ content: "\F068";
+}
+.mdi-auto-upload::before {
+ content: "\F069";
+}
+.mdi-autorenew::before {
+ content: "\F06A";
+}
+.mdi-av-timer::before {
+ content: "\F06B";
+}
+.mdi-aws::before {
+ content: "\FDF2";
+}
+.mdi-axe::before {
+ content: "\F8C7";
+}
+.mdi-axis::before {
+ content: "\FD24";
+}
+.mdi-axis-arrow::before {
+ content: "\FD25";
+}
+.mdi-axis-arrow-lock::before {
+ content: "\FD26";
+}
+.mdi-axis-lock::before {
+ content: "\FD27";
+}
+.mdi-axis-x-arrow::before {
+ content: "\FD28";
+}
+.mdi-axis-x-arrow-lock::before {
+ content: "\FD29";
+}
+.mdi-axis-x-rotate-clockwise::before {
+ content: "\FD2A";
+}
+.mdi-axis-x-rotate-counterclockwise::before {
+ content: "\FD2B";
+}
+.mdi-axis-x-y-arrow-lock::before {
+ content: "\FD2C";
+}
+.mdi-axis-y-arrow::before {
+ content: "\FD2D";
+}
+.mdi-axis-y-arrow-lock::before {
+ content: "\FD2E";
+}
+.mdi-axis-y-rotate-clockwise::before {
+ content: "\FD2F";
+}
+.mdi-axis-y-rotate-counterclockwise::before {
+ content: "\FD30";
+}
+.mdi-axis-z-arrow::before {
+ content: "\FD31";
+}
+.mdi-axis-z-arrow-lock::before {
+ content: "\FD32";
+}
+.mdi-axis-z-rotate-clockwise::before {
+ content: "\FD33";
+}
+.mdi-axis-z-rotate-counterclockwise::before {
+ content: "\FD34";
+}
+.mdi-azure::before {
+ content: "\F804";
+}
+.mdi-azure-devops::before {
+ content: "\F0091";
+}
+.mdi-babel::before {
+ content: "\FA24";
+}
+.mdi-baby::before {
+ content: "\F06C";
+}
+.mdi-baby-bottle::before {
+ content: "\FF56";
+}
+.mdi-baby-bottle-outline::before {
+ content: "\FF57";
+}
+.mdi-baby-carriage::before {
+ content: "\F68E";
+}
+.mdi-baby-carriage-off::before {
+ content: "\FFC0";
+}
+.mdi-baby-face::before {
+ content: "\FE99";
+}
+.mdi-baby-face-outline::before {
+ content: "\FE9A";
+}
+.mdi-backburger::before {
+ content: "\F06D";
+}
+.mdi-backspace::before {
+ content: "\F06E";
+}
+.mdi-backspace-outline::before {
+ content: "\FB38";
+}
+.mdi-backspace-reverse::before {
+ content: "\FE9B";
+}
+.mdi-backspace-reverse-outline::before {
+ content: "\FE9C";
+}
+.mdi-backup-restore::before {
+ content: "\F06F";
+}
+.mdi-bacteria::before {
+ content: "\FEF2";
+}
+.mdi-bacteria-outline::before {
+ content: "\FEF3";
+}
+.mdi-badminton::before {
+ content: "\F850";
+}
+.mdi-bag-carry-on::before {
+ content: "\FF58";
+}
+.mdi-bag-carry-on-check::before {
+ content: "\FD41";
+}
+.mdi-bag-carry-on-off::before {
+ content: "\FF59";
+}
+.mdi-bag-checked::before {
+ content: "\FF5A";
+}
+.mdi-bag-personal::before {
+ content: "\FDF3";
+}
+.mdi-bag-personal-off::before {
+ content: "\FDF4";
+}
+.mdi-bag-personal-off-outline::before {
+ content: "\FDF5";
+}
+.mdi-bag-personal-outline::before {
+ content: "\FDF6";
+}
+.mdi-baguette::before {
+ content: "\FF5B";
+}
+.mdi-balloon::before {
+ content: "\FA25";
+}
+.mdi-ballot::before {
+ content: "\F9C8";
+}
+.mdi-ballot-outline::before {
+ content: "\F9C9";
+}
+.mdi-ballot-recount::before {
+ content: "\FC15";
+}
+.mdi-ballot-recount-outline::before {
+ content: "\FC16";
+}
+.mdi-bandage::before {
+ content: "\FD8B";
+}
+.mdi-bandcamp::before {
+ content: "\F674";
+}
+.mdi-bank::before {
+ content: "\F070";
+}
+.mdi-bank-minus::before {
+ content: "\FD8C";
+}
+.mdi-bank-outline::before {
+ content: "\FE9D";
+}
+.mdi-bank-plus::before {
+ content: "\FD8D";
+}
+.mdi-bank-remove::before {
+ content: "\FD8E";
+}
+.mdi-bank-transfer::before {
+ content: "\FA26";
+}
+.mdi-bank-transfer-in::before {
+ content: "\FA27";
+}
+.mdi-bank-transfer-out::before {
+ content: "\FA28";
+}
+.mdi-barcode::before {
+ content: "\F071";
+}
+.mdi-barcode-off::before {
+ content: "\F0261";
+}
+.mdi-barcode-scan::before {
+ content: "\F072";
+}
+.mdi-barley::before {
+ content: "\F073";
+}
+.mdi-barley-off::before {
+ content: "\FB39";
+}
+.mdi-barn::before {
+ content: "\FB3A";
+}
+.mdi-barrel::before {
+ content: "\F074";
+}
+.mdi-baseball::before {
+ content: "\F851";
+}
+.mdi-baseball-bat::before {
+ content: "\F852";
+}
+.mdi-basecamp::before {
+ content: "\F075";
+}
+.mdi-bash::before {
+ content: "\F01AE";
+}
+.mdi-basket::before {
+ content: "\F076";
+}
+.mdi-basket-fill::before {
+ content: "\F077";
+}
+.mdi-basket-outline::before {
+ content: "\F01AC";
+}
+.mdi-basket-unfill::before {
+ content: "\F078";
+}
+.mdi-basketball::before {
+ content: "\F805";
+}
+.mdi-basketball-hoop::before {
+ content: "\FC17";
+}
+.mdi-basketball-hoop-outline::before {
+ content: "\FC18";
+}
+.mdi-bat::before {
+ content: "\FB3B";
+}
+.mdi-battery::before {
+ content: "\F079";
+}
+.mdi-battery-10::before {
+ content: "\F07A";
+}
+.mdi-battery-10-bluetooth::before {
+ content: "\F93D";
+}
+.mdi-battery-20::before {
+ content: "\F07B";
+}
+.mdi-battery-20-bluetooth::before {
+ content: "\F93E";
+}
+.mdi-battery-30::before {
+ content: "\F07C";
+}
+.mdi-battery-30-bluetooth::before {
+ content: "\F93F";
+}
+.mdi-battery-40::before {
+ content: "\F07D";
+}
+.mdi-battery-40-bluetooth::before {
+ content: "\F940";
+}
+.mdi-battery-50::before {
+ content: "\F07E";
+}
+.mdi-battery-50-bluetooth::before {
+ content: "\F941";
+}
+.mdi-battery-60::before {
+ content: "\F07F";
+}
+.mdi-battery-60-bluetooth::before {
+ content: "\F942";
+}
+.mdi-battery-70::before {
+ content: "\F080";
+}
+.mdi-battery-70-bluetooth::before {
+ content: "\F943";
+}
+.mdi-battery-80::before {
+ content: "\F081";
+}
+.mdi-battery-80-bluetooth::before {
+ content: "\F944";
+}
+.mdi-battery-90::before {
+ content: "\F082";
+}
+.mdi-battery-90-bluetooth::before {
+ content: "\F945";
+}
+.mdi-battery-alert::before {
+ content: "\F083";
+}
+.mdi-battery-alert-bluetooth::before {
+ content: "\F946";
+}
+.mdi-battery-alert-variant::before {
+ content: "\F00F7";
+}
+.mdi-battery-alert-variant-outline::before {
+ content: "\F00F8";
+}
+.mdi-battery-bluetooth::before {
+ content: "\F947";
+}
+.mdi-battery-bluetooth-variant::before {
+ content: "\F948";
+}
+.mdi-battery-charging::before {
+ content: "\F084";
+}
+.mdi-battery-charging-10::before {
+ content: "\F89B";
+}
+.mdi-battery-charging-100::before {
+ content: "\F085";
+}
+.mdi-battery-charging-20::before {
+ content: "\F086";
+}
+.mdi-battery-charging-30::before {
+ content: "\F087";
+}
+.mdi-battery-charging-40::before {
+ content: "\F088";
+}
+.mdi-battery-charging-50::before {
+ content: "\F89C";
+}
+.mdi-battery-charging-60::before {
+ content: "\F089";
+}
+.mdi-battery-charging-70::before {
+ content: "\F89D";
+}
+.mdi-battery-charging-80::before {
+ content: "\F08A";
+}
+.mdi-battery-charging-90::before {
+ content: "\F08B";
+}
+.mdi-battery-charging-high::before {
+ content: "\F02D1";
+}
+.mdi-battery-charging-low::before {
+ content: "\F02CF";
+}
+.mdi-battery-charging-medium::before {
+ content: "\F02D0";
+}
+.mdi-battery-charging-outline::before {
+ content: "\F89E";
+}
+.mdi-battery-charging-wireless::before {
+ content: "\F806";
+}
+.mdi-battery-charging-wireless-10::before {
+ content: "\F807";
+}
+.mdi-battery-charging-wireless-20::before {
+ content: "\F808";
+}
+.mdi-battery-charging-wireless-30::before {
+ content: "\F809";
+}
+.mdi-battery-charging-wireless-40::before {
+ content: "\F80A";
+}
+.mdi-battery-charging-wireless-50::before {
+ content: "\F80B";
+}
+.mdi-battery-charging-wireless-60::before {
+ content: "\F80C";
+}
+.mdi-battery-charging-wireless-70::before {
+ content: "\F80D";
+}
+.mdi-battery-charging-wireless-80::before {
+ content: "\F80E";
+}
+.mdi-battery-charging-wireless-90::before {
+ content: "\F80F";
+}
+.mdi-battery-charging-wireless-alert::before {
+ content: "\F810";
+}
+.mdi-battery-charging-wireless-outline::before {
+ content: "\F811";
+}
+.mdi-battery-heart::before {
+ content: "\F023A";
+}
+.mdi-battery-heart-outline::before {
+ content: "\F023B";
+}
+.mdi-battery-heart-variant::before {
+ content: "\F023C";
+}
+.mdi-battery-high::before {
+ content: "\F02CE";
+}
+.mdi-battery-low::before {
+ content: "\F02CC";
+}
+.mdi-battery-medium::before {
+ content: "\F02CD";
+}
+.mdi-battery-minus::before {
+ content: "\F08C";
+}
+.mdi-battery-negative::before {
+ content: "\F08D";
+}
+.mdi-battery-off::before {
+ content: "\F0288";
+}
+.mdi-battery-off-outline::before {
+ content: "\F0289";
+}
+.mdi-battery-outline::before {
+ content: "\F08E";
+}
+.mdi-battery-plus::before {
+ content: "\F08F";
+}
+.mdi-battery-positive::before {
+ content: "\F090";
+}
+.mdi-battery-unknown::before {
+ content: "\F091";
+}
+.mdi-battery-unknown-bluetooth::before {
+ content: "\F949";
+}
+.mdi-battlenet::before {
+ content: "\FB3C";
+}
+.mdi-beach::before {
+ content: "\F092";
+}
+.mdi-beaker::before {
+ content: "\FCC6";
+}
+.mdi-beaker-alert::before {
+ content: "\F0254";
+}
+.mdi-beaker-alert-outline::before {
+ content: "\F0255";
+}
+.mdi-beaker-check::before {
+ content: "\F0256";
+}
+.mdi-beaker-check-outline::before {
+ content: "\F0257";
+}
+.mdi-beaker-minus::before {
+ content: "\F0258";
+}
+.mdi-beaker-minus-outline::before {
+ content: "\F0259";
+}
+.mdi-beaker-outline::before {
+ content: "\F68F";
+}
+.mdi-beaker-plus::before {
+ content: "\F025A";
+}
+.mdi-beaker-plus-outline::before {
+ content: "\F025B";
+}
+.mdi-beaker-question::before {
+ content: "\F025C";
+}
+.mdi-beaker-question-outline::before {
+ content: "\F025D";
+}
+.mdi-beaker-remove::before {
+ content: "\F025E";
+}
+.mdi-beaker-remove-outline::before {
+ content: "\F025F";
+}
+.mdi-beats::before {
+ content: "\F097";
+}
+.mdi-bed-double::before {
+ content: "\F0092";
+}
+.mdi-bed-double-outline::before {
+ content: "\F0093";
+}
+.mdi-bed-empty::before {
+ content: "\F89F";
+}
+.mdi-bed-king::before {
+ content: "\F0094";
+}
+.mdi-bed-king-outline::before {
+ content: "\F0095";
+}
+.mdi-bed-queen::before {
+ content: "\F0096";
+}
+.mdi-bed-queen-outline::before {
+ content: "\F0097";
+}
+.mdi-bed-single::before {
+ content: "\F0098";
+}
+.mdi-bed-single-outline::before {
+ content: "\F0099";
+}
+.mdi-bee::before {
+ content: "\FFC1";
+}
+.mdi-bee-flower::before {
+ content: "\FFC2";
+}
+.mdi-beehive-outline::before {
+ content: "\F00F9";
+}
+.mdi-beer::before {
+ content: "\F098";
+}
+.mdi-beer-outline::before {
+ content: "\F0337";
+}
+.mdi-behance::before {
+ content: "\F099";
+}
+.mdi-bell::before {
+ content: "\F09A";
+}
+.mdi-bell-alert::before {
+ content: "\FD35";
+}
+.mdi-bell-alert-outline::before {
+ content: "\FE9E";
+}
+.mdi-bell-check::before {
+ content: "\F0210";
+}
+.mdi-bell-check-outline::before {
+ content: "\F0211";
+}
+.mdi-bell-circle::before {
+ content: "\FD36";
+}
+.mdi-bell-circle-outline::before {
+ content: "\FD37";
+}
+.mdi-bell-off::before {
+ content: "\F09B";
+}
+.mdi-bell-off-outline::before {
+ content: "\FA90";
+}
+.mdi-bell-outline::before {
+ content: "\F09C";
+}
+.mdi-bell-plus::before {
+ content: "\F09D";
+}
+.mdi-bell-plus-outline::before {
+ content: "\FA91";
+}
+.mdi-bell-ring::before {
+ content: "\F09E";
+}
+.mdi-bell-ring-outline::before {
+ content: "\F09F";
+}
+.mdi-bell-sleep::before {
+ content: "\F0A0";
+}
+.mdi-bell-sleep-outline::before {
+ content: "\FA92";
+}
+.mdi-beta::before {
+ content: "\F0A1";
+}
+.mdi-betamax::before {
+ content: "\F9CA";
+}
+.mdi-biathlon::before {
+ content: "\FDF7";
+}
+.mdi-bible::before {
+ content: "\F0A2";
+}
+.mdi-bicycle::before {
+ content: "\F00C7";
+}
+.mdi-bicycle-basket::before {
+ content: "\F0260";
+}
+.mdi-bike::before {
+ content: "\F0A3";
+}
+.mdi-bike-fast::before {
+ content: "\F014A";
+}
+.mdi-billboard::before {
+ content: "\F0032";
+}
+.mdi-billiards::before {
+ content: "\FB3D";
+}
+.mdi-billiards-rack::before {
+ content: "\FB3E";
+}
+.mdi-bing::before {
+ content: "\F0A4";
+}
+.mdi-binoculars::before {
+ content: "\F0A5";
+}
+.mdi-bio::before {
+ content: "\F0A6";
+}
+.mdi-biohazard::before {
+ content: "\F0A7";
+}
+.mdi-bitbucket::before {
+ content: "\F0A8";
+}
+.mdi-bitcoin::before {
+ content: "\F812";
+}
+.mdi-black-mesa::before {
+ content: "\F0A9";
+}
+.mdi-blackberry::before {
+ content: "\F0AA";
+}
+.mdi-blender::before {
+ content: "\FCC7";
+}
+.mdi-blender-software::before {
+ content: "\F0AB";
+}
+.mdi-blinds::before {
+ content: "\F0AC";
+}
+.mdi-blinds-open::before {
+ content: "\F0033";
+}
+.mdi-block-helper::before {
+ content: "\F0AD";
+}
+.mdi-blogger::before {
+ content: "\F0AE";
+}
+.mdi-blood-bag::before {
+ content: "\FCC8";
+}
+.mdi-bluetooth::before {
+ content: "\F0AF";
+}
+.mdi-bluetooth-audio::before {
+ content: "\F0B0";
+}
+.mdi-bluetooth-connect::before {
+ content: "\F0B1";
+}
+.mdi-bluetooth-off::before {
+ content: "\F0B2";
+}
+.mdi-bluetooth-settings::before {
+ content: "\F0B3";
+}
+.mdi-bluetooth-transfer::before {
+ content: "\F0B4";
+}
+.mdi-blur::before {
+ content: "\F0B5";
+}
+.mdi-blur-linear::before {
+ content: "\F0B6";
+}
+.mdi-blur-off::before {
+ content: "\F0B7";
+}
+.mdi-blur-radial::before {
+ content: "\F0B8";
+}
+.mdi-bolnisi-cross::before {
+ content: "\FCC9";
+}
+.mdi-bolt::before {
+ content: "\FD8F";
+}
+.mdi-bomb::before {
+ content: "\F690";
+}
+.mdi-bomb-off::before {
+ content: "\F6C4";
+}
+.mdi-bone::before {
+ content: "\F0B9";
+}
+.mdi-book::before {
+ content: "\F0BA";
+}
+.mdi-book-information-variant::before {
+ content: "\F009A";
+}
+.mdi-book-lock::before {
+ content: "\F799";
+}
+.mdi-book-lock-open::before {
+ content: "\F79A";
+}
+.mdi-book-minus::before {
+ content: "\F5D9";
+}
+.mdi-book-minus-multiple::before {
+ content: "\FA93";
+}
+.mdi-book-multiple::before {
+ content: "\F0BB";
+}
+.mdi-book-open::before {
+ content: "\F0BD";
+}
+.mdi-book-open-outline::before {
+ content: "\FB3F";
+}
+.mdi-book-open-page-variant::before {
+ content: "\F5DA";
+}
+.mdi-book-open-variant::before {
+ content: "\F0BE";
+}
+.mdi-book-outline::before {
+ content: "\FB40";
+}
+.mdi-book-play::before {
+ content: "\FE9F";
+}
+.mdi-book-play-outline::before {
+ content: "\FEA0";
+}
+.mdi-book-plus::before {
+ content: "\F5DB";
+}
+.mdi-book-plus-multiple::before {
+ content: "\FA94";
+}
+.mdi-book-remove::before {
+ content: "\FA96";
+}
+.mdi-book-remove-multiple::before {
+ content: "\FA95";
+}
+.mdi-book-search::before {
+ content: "\FEA1";
+}
+.mdi-book-search-outline::before {
+ content: "\FEA2";
+}
+.mdi-book-variant::before {
+ content: "\F0BF";
+}
+.mdi-book-variant-multiple::before {
+ content: "\F0BC";
+}
+.mdi-bookmark::before {
+ content: "\F0C0";
+}
+.mdi-bookmark-check::before {
+ content: "\F0C1";
+}
+.mdi-bookmark-check-outline::before {
+ content: "\F03A6";
+}
+.mdi-bookmark-minus::before {
+ content: "\F9CB";
+}
+.mdi-bookmark-minus-outline::before {
+ content: "\F9CC";
+}
+.mdi-bookmark-multiple::before {
+ content: "\FDF8";
+}
+.mdi-bookmark-multiple-outline::before {
+ content: "\FDF9";
+}
+.mdi-bookmark-music::before {
+ content: "\F0C2";
+}
+.mdi-bookmark-music-outline::before {
+ content: "\F03A4";
+}
+.mdi-bookmark-off::before {
+ content: "\F9CD";
+}
+.mdi-bookmark-off-outline::before {
+ content: "\F9CE";
+}
+.mdi-bookmark-outline::before {
+ content: "\F0C3";
+}
+.mdi-bookmark-plus::before {
+ content: "\F0C5";
+}
+.mdi-bookmark-plus-outline::before {
+ content: "\F0C4";
+}
+.mdi-bookmark-remove::before {
+ content: "\F0C6";
+}
+.mdi-bookmark-remove-outline::before {
+ content: "\F03A5";
+}
+.mdi-bookshelf::before {
+ content: "\F028A";
+}
+.mdi-boom-gate::before {
+ content: "\FEA3";
+}
+.mdi-boom-gate-alert::before {
+ content: "\FEA4";
+}
+.mdi-boom-gate-alert-outline::before {
+ content: "\FEA5";
+}
+.mdi-boom-gate-down::before {
+ content: "\FEA6";
+}
+.mdi-boom-gate-down-outline::before {
+ content: "\FEA7";
+}
+.mdi-boom-gate-outline::before {
+ content: "\FEA8";
+}
+.mdi-boom-gate-up::before {
+ content: "\FEA9";
+}
+.mdi-boom-gate-up-outline::before {
+ content: "\FEAA";
+}
+.mdi-boombox::before {
+ content: "\F5DC";
+}
+.mdi-boomerang::before {
+ content: "\F00FA";
+}
+.mdi-bootstrap::before {
+ content: "\F6C5";
+}
+.mdi-border-all::before {
+ content: "\F0C7";
+}
+.mdi-border-all-variant::before {
+ content: "\F8A0";
+}
+.mdi-border-bottom::before {
+ content: "\F0C8";
+}
+.mdi-border-bottom-variant::before {
+ content: "\F8A1";
+}
+.mdi-border-color::before {
+ content: "\F0C9";
+}
+.mdi-border-horizontal::before {
+ content: "\F0CA";
+}
+.mdi-border-inside::before {
+ content: "\F0CB";
+}
+.mdi-border-left::before {
+ content: "\F0CC";
+}
+.mdi-border-left-variant::before {
+ content: "\F8A2";
+}
+.mdi-border-none::before {
+ content: "\F0CD";
+}
+.mdi-border-none-variant::before {
+ content: "\F8A3";
+}
+.mdi-border-outside::before {
+ content: "\F0CE";
+}
+.mdi-border-right::before {
+ content: "\F0CF";
+}
+.mdi-border-right-variant::before {
+ content: "\F8A4";
+}
+.mdi-border-style::before {
+ content: "\F0D0";
+}
+.mdi-border-top::before {
+ content: "\F0D1";
+}
+.mdi-border-top-variant::before {
+ content: "\F8A5";
+}
+.mdi-border-vertical::before {
+ content: "\F0D2";
+}
+.mdi-bottle-soda::before {
+ content: "\F009B";
+}
+.mdi-bottle-soda-classic::before {
+ content: "\F009C";
+}
+.mdi-bottle-soda-classic-outline::before {
+ content: "\F038E";
+}
+.mdi-bottle-soda-outline::before {
+ content: "\F009D";
+}
+.mdi-bottle-tonic::before {
+ content: "\F0159";
+}
+.mdi-bottle-tonic-outline::before {
+ content: "\F015A";
+}
+.mdi-bottle-tonic-plus::before {
+ content: "\F015B";
+}
+.mdi-bottle-tonic-plus-outline::before {
+ content: "\F015C";
+}
+.mdi-bottle-tonic-skull::before {
+ content: "\F015D";
+}
+.mdi-bottle-tonic-skull-outline::before {
+ content: "\F015E";
+}
+.mdi-bottle-wine::before {
+ content: "\F853";
+}
+.mdi-bottle-wine-outline::before {
+ content: "\F033B";
+}
+.mdi-bow-tie::before {
+ content: "\F677";
+}
+.mdi-bowl::before {
+ content: "\F617";
+}
+.mdi-bowling::before {
+ content: "\F0D3";
+}
+.mdi-box::before {
+ content: "\F0D4";
+}
+.mdi-box-cutter::before {
+ content: "\F0D5";
+}
+.mdi-box-shadow::before {
+ content: "\F637";
+}
+.mdi-boxing-glove::before {
+ content: "\FB41";
+}
+.mdi-braille::before {
+ content: "\F9CF";
+}
+.mdi-brain::before {
+ content: "\F9D0";
+}
+.mdi-bread-slice::before {
+ content: "\FCCA";
+}
+.mdi-bread-slice-outline::before {
+ content: "\FCCB";
+}
+.mdi-bridge::before {
+ content: "\F618";
+}
+.mdi-briefcase::before {
+ content: "\F0D6";
+}
+.mdi-briefcase-account::before {
+ content: "\FCCC";
+}
+.mdi-briefcase-account-outline::before {
+ content: "\FCCD";
+}
+.mdi-briefcase-check::before {
+ content: "\F0D7";
+}
+.mdi-briefcase-check-outline::before {
+ content: "\F0349";
+}
+.mdi-briefcase-clock::before {
+ content: "\F00FB";
+}
+.mdi-briefcase-clock-outline::before {
+ content: "\F00FC";
+}
+.mdi-briefcase-download::before {
+ content: "\F0D8";
+}
+.mdi-briefcase-download-outline::before {
+ content: "\FC19";
+}
+.mdi-briefcase-edit::before {
+ content: "\FA97";
+}
+.mdi-briefcase-edit-outline::before {
+ content: "\FC1A";
+}
+.mdi-briefcase-minus::before {
+ content: "\FA29";
+}
+.mdi-briefcase-minus-outline::before {
+ content: "\FC1B";
+}
+.mdi-briefcase-outline::before {
+ content: "\F813";
+}
+.mdi-briefcase-plus::before {
+ content: "\FA2A";
+}
+.mdi-briefcase-plus-outline::before {
+ content: "\FC1C";
+}
+.mdi-briefcase-remove::before {
+ content: "\FA2B";
+}
+.mdi-briefcase-remove-outline::before {
+ content: "\FC1D";
+}
+.mdi-briefcase-search::before {
+ content: "\FA2C";
+}
+.mdi-briefcase-search-outline::before {
+ content: "\FC1E";
+}
+.mdi-briefcase-upload::before {
+ content: "\F0D9";
+}
+.mdi-briefcase-upload-outline::before {
+ content: "\FC1F";
+}
+.mdi-brightness-1::before {
+ content: "\F0DA";
+}
+.mdi-brightness-2::before {
+ content: "\F0DB";
+}
+.mdi-brightness-3::before {
+ content: "\F0DC";
+}
+.mdi-brightness-4::before {
+ content: "\F0DD";
+}
+.mdi-brightness-5::before {
+ content: "\F0DE";
+}
+.mdi-brightness-6::before {
+ content: "\F0DF";
+}
+.mdi-brightness-7::before {
+ content: "\F0E0";
+}
+.mdi-brightness-auto::before {
+ content: "\F0E1";
+}
+.mdi-brightness-percent::before {
+ content: "\FCCE";
+}
+.mdi-broom::before {
+ content: "\F0E2";
+}
+.mdi-brush::before {
+ content: "\F0E3";
+}
+.mdi-buddhism::before {
+ content: "\F94A";
+}
+.mdi-buffer::before {
+ content: "\F619";
+}
+.mdi-bug::before {
+ content: "\F0E4";
+}
+.mdi-bug-check::before {
+ content: "\FA2D";
+}
+.mdi-bug-check-outline::before {
+ content: "\FA2E";
+}
+.mdi-bug-outline::before {
+ content: "\FA2F";
+}
+.mdi-bugle::before {
+ content: "\FD90";
+}
+.mdi-bulldozer::before {
+ content: "\FB07";
+}
+.mdi-bullet::before {
+ content: "\FCCF";
+}
+.mdi-bulletin-board::before {
+ content: "\F0E5";
+}
+.mdi-bullhorn::before {
+ content: "\F0E6";
+}
+.mdi-bullhorn-outline::before {
+ content: "\FB08";
+}
+.mdi-bullseye::before {
+ content: "\F5DD";
+}
+.mdi-bullseye-arrow::before {
+ content: "\F8C8";
+}
+.mdi-bulma::before {
+ content: "\F0312";
+}
+.mdi-bunk-bed::before {
+ content: "\F032D";
+}
+.mdi-bus::before {
+ content: "\F0E7";
+}
+.mdi-bus-alert::before {
+ content: "\FA98";
+}
+.mdi-bus-articulated-end::before {
+ content: "\F79B";
+}
+.mdi-bus-articulated-front::before {
+ content: "\F79C";
+}
+.mdi-bus-clock::before {
+ content: "\F8C9";
+}
+.mdi-bus-double-decker::before {
+ content: "\F79D";
+}
+.mdi-bus-marker::before {
+ content: "\F023D";
+}
+.mdi-bus-multiple::before {
+ content: "\FF5C";
+}
+.mdi-bus-school::before {
+ content: "\F79E";
+}
+.mdi-bus-side::before {
+ content: "\F79F";
+}
+.mdi-bus-stop::before {
+ content: "\F0034";
+}
+.mdi-bus-stop-covered::before {
+ content: "\F0035";
+}
+.mdi-bus-stop-uncovered::before {
+ content: "\F0036";
+}
+.mdi-cached::before {
+ content: "\F0E8";
+}
+.mdi-cactus::before {
+ content: "\FD91";
+}
+.mdi-cake::before {
+ content: "\F0E9";
+}
+.mdi-cake-layered::before {
+ content: "\F0EA";
+}
+.mdi-cake-variant::before {
+ content: "\F0EB";
+}
+.mdi-calculator::before {
+ content: "\F0EC";
+}
+.mdi-calculator-variant::before {
+ content: "\FA99";
+}
+.mdi-calendar::before {
+ content: "\F0ED";
+}
+.mdi-calendar-account::before {
+ content: "\FEF4";
+}
+.mdi-calendar-account-outline::before {
+ content: "\FEF5";
+}
+.mdi-calendar-alert::before {
+ content: "\FA30";
+}
+.mdi-calendar-arrow-left::before {
+ content: "\F015F";
+}
+.mdi-calendar-arrow-right::before {
+ content: "\F0160";
+}
+.mdi-calendar-blank::before {
+ content: "\F0EE";
+}
+.mdi-calendar-blank-multiple::before {
+ content: "\F009E";
+}
+.mdi-calendar-blank-outline::before {
+ content: "\FB42";
+}
+.mdi-calendar-check::before {
+ content: "\F0EF";
+}
+.mdi-calendar-check-outline::before {
+ content: "\FC20";
+}
+.mdi-calendar-clock::before {
+ content: "\F0F0";
+}
+.mdi-calendar-edit::before {
+ content: "\F8A6";
+}
+.mdi-calendar-export::before {
+ content: "\FB09";
+}
+.mdi-calendar-heart::before {
+ content: "\F9D1";
+}
+.mdi-calendar-import::before {
+ content: "\FB0A";
+}
+.mdi-calendar-minus::before {
+ content: "\FD38";
+}
+.mdi-calendar-month::before {
+ content: "\FDFA";
+}
+.mdi-calendar-month-outline::before {
+ content: "\FDFB";
+}
+.mdi-calendar-multiple::before {
+ content: "\F0F1";
+}
+.mdi-calendar-multiple-check::before {
+ content: "\F0F2";
+}
+.mdi-calendar-multiselect::before {
+ content: "\FA31";
+}
+.mdi-calendar-outline::before {
+ content: "\FB43";
+}
+.mdi-calendar-plus::before {
+ content: "\F0F3";
+}
+.mdi-calendar-question::before {
+ content: "\F691";
+}
+.mdi-calendar-range::before {
+ content: "\F678";
+}
+.mdi-calendar-range-outline::before {
+ content: "\FB44";
+}
+.mdi-calendar-remove::before {
+ content: "\F0F4";
+}
+.mdi-calendar-remove-outline::before {
+ content: "\FC21";
+}
+.mdi-calendar-repeat::before {
+ content: "\FEAB";
+}
+.mdi-calendar-repeat-outline::before {
+ content: "\FEAC";
+}
+.mdi-calendar-search::before {
+ content: "\F94B";
+}
+.mdi-calendar-star::before {
+ content: "\F9D2";
+}
+.mdi-calendar-text::before {
+ content: "\F0F5";
+}
+.mdi-calendar-text-outline::before {
+ content: "\FC22";
+}
+.mdi-calendar-today::before {
+ content: "\F0F6";
+}
+.mdi-calendar-week::before {
+ content: "\FA32";
+}
+.mdi-calendar-week-begin::before {
+ content: "\FA33";
+}
+.mdi-calendar-weekend::before {
+ content: "\FEF6";
+}
+.mdi-calendar-weekend-outline::before {
+ content: "\FEF7";
+}
+.mdi-call-made::before {
+ content: "\F0F7";
+}
+.mdi-call-merge::before {
+ content: "\F0F8";
+}
+.mdi-call-missed::before {
+ content: "\F0F9";
+}
+.mdi-call-received::before {
+ content: "\F0FA";
+}
+.mdi-call-split::before {
+ content: "\F0FB";
+}
+.mdi-camcorder::before {
+ content: "\F0FC";
+}
+.mdi-camcorder-box::before {
+ content: "\F0FD";
+}
+.mdi-camcorder-box-off::before {
+ content: "\F0FE";
+}
+.mdi-camcorder-off::before {
+ content: "\F0FF";
+}
+.mdi-camera::before {
+ content: "\F100";
+}
+.mdi-camera-account::before {
+ content: "\F8CA";
+}
+.mdi-camera-burst::before {
+ content: "\F692";
+}
+.mdi-camera-control::before {
+ content: "\FB45";
+}
+.mdi-camera-enhance::before {
+ content: "\F101";
+}
+.mdi-camera-enhance-outline::before {
+ content: "\FB46";
+}
+.mdi-camera-front::before {
+ content: "\F102";
+}
+.mdi-camera-front-variant::before {
+ content: "\F103";
+}
+.mdi-camera-gopro::before {
+ content: "\F7A0";
+}
+.mdi-camera-image::before {
+ content: "\F8CB";
+}
+.mdi-camera-iris::before {
+ content: "\F104";
+}
+.mdi-camera-metering-center::before {
+ content: "\F7A1";
+}
+.mdi-camera-metering-matrix::before {
+ content: "\F7A2";
+}
+.mdi-camera-metering-partial::before {
+ content: "\F7A3";
+}
+.mdi-camera-metering-spot::before {
+ content: "\F7A4";
+}
+.mdi-camera-off::before {
+ content: "\F5DF";
+}
+.mdi-camera-outline::before {
+ content: "\FD39";
+}
+.mdi-camera-party-mode::before {
+ content: "\F105";
+}
+.mdi-camera-plus::before {
+ content: "\FEF8";
+}
+.mdi-camera-plus-outline::before {
+ content: "\FEF9";
+}
+.mdi-camera-rear::before {
+ content: "\F106";
+}
+.mdi-camera-rear-variant::before {
+ content: "\F107";
+}
+.mdi-camera-retake::before {
+ content: "\FDFC";
+}
+.mdi-camera-retake-outline::before {
+ content: "\FDFD";
+}
+.mdi-camera-switch::before {
+ content: "\F108";
+}
+.mdi-camera-timer::before {
+ content: "\F109";
+}
+.mdi-camera-wireless::before {
+ content: "\FD92";
+}
+.mdi-camera-wireless-outline::before {
+ content: "\FD93";
+}
+.mdi-campfire::before {
+ content: "\FEFA";
+}
+.mdi-cancel::before {
+ content: "\F739";
+}
+.mdi-candle::before {
+ content: "\F5E2";
+}
+.mdi-candycane::before {
+ content: "\F10A";
+}
+.mdi-cannabis::before {
+ content: "\F7A5";
+}
+.mdi-caps-lock::before {
+ content: "\FA9A";
+}
+.mdi-car::before {
+ content: "\F10B";
+}
+.mdi-car-2-plus::before {
+ content: "\F0037";
+}
+.mdi-car-3-plus::before {
+ content: "\F0038";
+}
+.mdi-car-back::before {
+ content: "\FDFE";
+}
+.mdi-car-battery::before {
+ content: "\F10C";
+}
+.mdi-car-brake-abs::before {
+ content: "\FC23";
+}
+.mdi-car-brake-alert::before {
+ content: "\FC24";
+}
+.mdi-car-brake-hold::before {
+ content: "\FD3A";
+}
+.mdi-car-brake-parking::before {
+ content: "\FD3B";
+}
+.mdi-car-brake-retarder::before {
+ content: "\F0039";
+}
+.mdi-car-child-seat::before {
+ content: "\FFC3";
+}
+.mdi-car-clutch::before {
+ content: "\F003A";
+}
+.mdi-car-connected::before {
+ content: "\F10D";
+}
+.mdi-car-convertible::before {
+ content: "\F7A6";
+}
+.mdi-car-coolant-level::before {
+ content: "\F003B";
+}
+.mdi-car-cruise-control::before {
+ content: "\FD3C";
+}
+.mdi-car-defrost-front::before {
+ content: "\FD3D";
+}
+.mdi-car-defrost-rear::before {
+ content: "\FD3E";
+}
+.mdi-car-door::before {
+ content: "\FB47";
+}
+.mdi-car-door-lock::before {
+ content: "\F00C8";
+}
+.mdi-car-electric::before {
+ content: "\FB48";
+}
+.mdi-car-esp::before {
+ content: "\FC25";
+}
+.mdi-car-estate::before {
+ content: "\F7A7";
+}
+.mdi-car-hatchback::before {
+ content: "\F7A8";
+}
+.mdi-car-info::before {
+ content: "\F01E9";
+}
+.mdi-car-key::before {
+ content: "\FB49";
+}
+.mdi-car-light-dimmed::before {
+ content: "\FC26";
+}
+.mdi-car-light-fog::before {
+ content: "\FC27";
+}
+.mdi-car-light-high::before {
+ content: "\FC28";
+}
+.mdi-car-limousine::before {
+ content: "\F8CC";
+}
+.mdi-car-multiple::before {
+ content: "\FB4A";
+}
+.mdi-car-off::before {
+ content: "\FDFF";
+}
+.mdi-car-parking-lights::before {
+ content: "\FD3F";
+}
+.mdi-car-pickup::before {
+ content: "\F7A9";
+}
+.mdi-car-seat::before {
+ content: "\FFC4";
+}
+.mdi-car-seat-cooler::before {
+ content: "\FFC5";
+}
+.mdi-car-seat-heater::before {
+ content: "\FFC6";
+}
+.mdi-car-shift-pattern::before {
+ content: "\FF5D";
+}
+.mdi-car-side::before {
+ content: "\F7AA";
+}
+.mdi-car-sports::before {
+ content: "\F7AB";
+}
+.mdi-car-tire-alert::before {
+ content: "\FC29";
+}
+.mdi-car-traction-control::before {
+ content: "\FD40";
+}
+.mdi-car-turbocharger::before {
+ content: "\F003C";
+}
+.mdi-car-wash::before {
+ content: "\F10E";
+}
+.mdi-car-windshield::before {
+ content: "\F003D";
+}
+.mdi-car-windshield-outline::before {
+ content: "\F003E";
+}
+.mdi-caravan::before {
+ content: "\F7AC";
+}
+.mdi-card::before {
+ content: "\FB4B";
+}
+.mdi-card-bulleted::before {
+ content: "\FB4C";
+}
+.mdi-card-bulleted-off::before {
+ content: "\FB4D";
+}
+.mdi-card-bulleted-off-outline::before {
+ content: "\FB4E";
+}
+.mdi-card-bulleted-outline::before {
+ content: "\FB4F";
+}
+.mdi-card-bulleted-settings::before {
+ content: "\FB50";
+}
+.mdi-card-bulleted-settings-outline::before {
+ content: "\FB51";
+}
+.mdi-card-outline::before {
+ content: "\FB52";
+}
+.mdi-card-plus::before {
+ content: "\F022A";
+}
+.mdi-card-plus-outline::before {
+ content: "\F022B";
+}
+.mdi-card-search::before {
+ content: "\F009F";
+}
+.mdi-card-search-outline::before {
+ content: "\F00A0";
+}
+.mdi-card-text::before {
+ content: "\FB53";
+}
+.mdi-card-text-outline::before {
+ content: "\FB54";
+}
+.mdi-cards::before {
+ content: "\F638";
+}
+.mdi-cards-club::before {
+ content: "\F8CD";
+}
+.mdi-cards-diamond::before {
+ content: "\F8CE";
+}
+.mdi-cards-diamond-outline::before {
+ content: "\F003F";
+}
+.mdi-cards-heart::before {
+ content: "\F8CF";
+}
+.mdi-cards-outline::before {
+ content: "\F639";
+}
+.mdi-cards-playing-outline::before {
+ content: "\F63A";
+}
+.mdi-cards-spade::before {
+ content: "\F8D0";
+}
+.mdi-cards-variant::before {
+ content: "\F6C6";
+}
+.mdi-carrot::before {
+ content: "\F10F";
+}
+.mdi-cart::before {
+ content: "\F110";
+}
+.mdi-cart-arrow-down::before {
+ content: "\FD42";
+}
+.mdi-cart-arrow-right::before {
+ content: "\FC2A";
+}
+.mdi-cart-arrow-up::before {
+ content: "\FD43";
+}
+.mdi-cart-minus::before {
+ content: "\FD44";
+}
+.mdi-cart-off::before {
+ content: "\F66B";
+}
+.mdi-cart-outline::before {
+ content: "\F111";
+}
+.mdi-cart-plus::before {
+ content: "\F112";
+}
+.mdi-cart-remove::before {
+ content: "\FD45";
+}
+.mdi-case-sensitive-alt::before {
+ content: "\F113";
+}
+.mdi-cash::before {
+ content: "\F114";
+}
+.mdi-cash-100::before {
+ content: "\F115";
+}
+.mdi-cash-marker::before {
+ content: "\FD94";
+}
+.mdi-cash-minus::before {
+ content: "\F028B";
+}
+.mdi-cash-multiple::before {
+ content: "\F116";
+}
+.mdi-cash-plus::before {
+ content: "\F028C";
+}
+.mdi-cash-refund::before {
+ content: "\FA9B";
+}
+.mdi-cash-register::before {
+ content: "\FCD0";
+}
+.mdi-cash-remove::before {
+ content: "\F028D";
+}
+.mdi-cash-usd::before {
+ content: "\F01A1";
+}
+.mdi-cash-usd-outline::before {
+ content: "\F117";
+}
+.mdi-cassette::before {
+ content: "\F9D3";
+}
+.mdi-cast::before {
+ content: "\F118";
+}
+.mdi-cast-audio::before {
+ content: "\F0040";
+}
+.mdi-cast-connected::before {
+ content: "\F119";
+}
+.mdi-cast-education::before {
+ content: "\FE6D";
+}
+.mdi-cast-off::before {
+ content: "\F789";
+}
+.mdi-castle::before {
+ content: "\F11A";
+}
+.mdi-cat::before {
+ content: "\F11B";
+}
+.mdi-cctv::before {
+ content: "\F7AD";
+}
+.mdi-ceiling-light::before {
+ content: "\F768";
+}
+.mdi-cellphone::before {
+ content: "\F11C";
+}
+.mdi-cellphone-android::before {
+ content: "\F11D";
+}
+.mdi-cellphone-arrow-down::before {
+ content: "\F9D4";
+}
+.mdi-cellphone-basic::before {
+ content: "\F11E";
+}
+.mdi-cellphone-dock::before {
+ content: "\F11F";
+}
+.mdi-cellphone-erase::before {
+ content: "\F94C";
+}
+.mdi-cellphone-information::before {
+ content: "\FF5E";
+}
+.mdi-cellphone-iphone::before {
+ content: "\F120";
+}
+.mdi-cellphone-key::before {
+ content: "\F94D";
+}
+.mdi-cellphone-link::before {
+ content: "\F121";
+}
+.mdi-cellphone-link-off::before {
+ content: "\F122";
+}
+.mdi-cellphone-lock::before {
+ content: "\F94E";
+}
+.mdi-cellphone-message::before {
+ content: "\F8D2";
+}
+.mdi-cellphone-message-off::before {
+ content: "\F00FD";
+}
+.mdi-cellphone-nfc::before {
+ content: "\FEAD";
+}
+.mdi-cellphone-nfc-off::before {
+ content: "\F0303";
+}
+.mdi-cellphone-off::before {
+ content: "\F94F";
+}
+.mdi-cellphone-play::before {
+ content: "\F0041";
+}
+.mdi-cellphone-screenshot::before {
+ content: "\FA34";
+}
+.mdi-cellphone-settings::before {
+ content: "\F123";
+}
+.mdi-cellphone-settings-variant::before {
+ content: "\F950";
+}
+.mdi-cellphone-sound::before {
+ content: "\F951";
+}
+.mdi-cellphone-text::before {
+ content: "\F8D1";
+}
+.mdi-cellphone-wireless::before {
+ content: "\F814";
+}
+.mdi-celtic-cross::before {
+ content: "\FCD1";
+}
+.mdi-centos::before {
+ content: "\F0145";
+}
+.mdi-certificate::before {
+ content: "\F124";
+}
+.mdi-certificate-outline::before {
+ content: "\F01B3";
+}
+.mdi-chair-rolling::before {
+ content: "\FFBA";
+}
+.mdi-chair-school::before {
+ content: "\F125";
+}
+.mdi-charity::before {
+ content: "\FC2B";
+}
+.mdi-chart-arc::before {
+ content: "\F126";
+}
+.mdi-chart-areaspline::before {
+ content: "\F127";
+}
+.mdi-chart-areaspline-variant::before {
+ content: "\FEAE";
+}
+.mdi-chart-bar::before {
+ content: "\F128";
+}
+.mdi-chart-bar-stacked::before {
+ content: "\F769";
+}
+.mdi-chart-bell-curve::before {
+ content: "\FC2C";
+}
+.mdi-chart-bell-curve-cumulative::before {
+ content: "\FFC7";
+}
+.mdi-chart-bubble::before {
+ content: "\F5E3";
+}
+.mdi-chart-donut::before {
+ content: "\F7AE";
+}
+.mdi-chart-donut-variant::before {
+ content: "\F7AF";
+}
+.mdi-chart-gantt::before {
+ content: "\F66C";
+}
+.mdi-chart-histogram::before {
+ content: "\F129";
+}
+.mdi-chart-line::before {
+ content: "\F12A";
+}
+.mdi-chart-line-stacked::before {
+ content: "\F76A";
+}
+.mdi-chart-line-variant::before {
+ content: "\F7B0";
+}
+.mdi-chart-multiline::before {
+ content: "\F8D3";
+}
+.mdi-chart-multiple::before {
+ content: "\F023E";
+}
+.mdi-chart-pie::before {
+ content: "\F12B";
+}
+.mdi-chart-ppf::before {
+ content: "\F03AB";
+}
+.mdi-chart-scatter-plot::before {
+ content: "\FEAF";
+}
+.mdi-chart-scatter-plot-hexbin::before {
+ content: "\F66D";
+}
+.mdi-chart-snakey::before {
+ content: "\F020A";
+}
+.mdi-chart-snakey-variant::before {
+ content: "\F020B";
+}
+.mdi-chart-timeline::before {
+ content: "\F66E";
+}
+.mdi-chart-timeline-variant::before {
+ content: "\FEB0";
+}
+.mdi-chart-tree::before {
+ content: "\FEB1";
+}
+.mdi-chat::before {
+ content: "\FB55";
+}
+.mdi-chat-alert::before {
+ content: "\FB56";
+}
+.mdi-chat-alert-outline::before {
+ content: "\F02F4";
+}
+.mdi-chat-outline::before {
+ content: "\FEFB";
+}
+.mdi-chat-processing::before {
+ content: "\FB57";
+}
+.mdi-chat-processing-outline::before {
+ content: "\F02F5";
+}
+.mdi-chat-sleep::before {
+ content: "\F02FC";
+}
+.mdi-chat-sleep-outline::before {
+ content: "\F02FD";
+}
+.mdi-check::before {
+ content: "\F12C";
+}
+.mdi-check-all::before {
+ content: "\F12D";
+}
+.mdi-check-bold::before {
+ content: "\FE6E";
+}
+.mdi-check-box-multiple-outline::before {
+ content: "\FC2D";
+}
+.mdi-check-box-outline::before {
+ content: "\FC2E";
+}
+.mdi-check-circle::before {
+ content: "\F5E0";
+}
+.mdi-check-circle-outline::before {
+ content: "\F5E1";
+}
+.mdi-check-decagram::before {
+ content: "\F790";
+}
+.mdi-check-network::before {
+ content: "\FC2F";
+}
+.mdi-check-network-outline::before {
+ content: "\FC30";
+}
+.mdi-check-outline::before {
+ content: "\F854";
+}
+.mdi-check-underline::before {
+ content: "\FE70";
+}
+.mdi-check-underline-circle::before {
+ content: "\FE71";
+}
+.mdi-check-underline-circle-outline::before {
+ content: "\FE72";
+}
+.mdi-checkbook::before {
+ content: "\FA9C";
+}
+.mdi-checkbox-blank::before {
+ content: "\F12E";
+}
+.mdi-checkbox-blank-circle::before {
+ content: "\F12F";
+}
+.mdi-checkbox-blank-circle-outline::before {
+ content: "\F130";
+}
+.mdi-checkbox-blank-off::before {
+ content: "\F0317";
+}
+.mdi-checkbox-blank-off-outline::before {
+ content: "\F0318";
+}
+.mdi-checkbox-blank-outline::before {
+ content: "\F131";
+}
+.mdi-checkbox-intermediate::before {
+ content: "\F855";
+}
+.mdi-checkbox-marked::before {
+ content: "\F132";
+}
+.mdi-checkbox-marked-circle::before {
+ content: "\F133";
+}
+.mdi-checkbox-marked-circle-outline::before {
+ content: "\F134";
+}
+.mdi-checkbox-marked-outline::before {
+ content: "\F135";
+}
+.mdi-checkbox-multiple-blank::before {
+ content: "\F136";
+}
+.mdi-checkbox-multiple-blank-circle::before {
+ content: "\F63B";
+}
+.mdi-checkbox-multiple-blank-circle-outline::before {
+ content: "\F63C";
+}
+.mdi-checkbox-multiple-blank-outline::before {
+ content: "\F137";
+}
+.mdi-checkbox-multiple-marked::before {
+ content: "\F138";
+}
+.mdi-checkbox-multiple-marked-circle::before {
+ content: "\F63D";
+}
+.mdi-checkbox-multiple-marked-circle-outline::before {
+ content: "\F63E";
+}
+.mdi-checkbox-multiple-marked-outline::before {
+ content: "\F139";
+}
+.mdi-checkerboard::before {
+ content: "\F13A";
+}
+.mdi-checkerboard-minus::before {
+ content: "\F022D";
+}
+.mdi-checkerboard-plus::before {
+ content: "\F022C";
+}
+.mdi-checkerboard-remove::before {
+ content: "\F022E";
+}
+.mdi-cheese::before {
+ content: "\F02E4";
+}
+.mdi-chef-hat::before {
+ content: "\FB58";
+}
+.mdi-chemical-weapon::before {
+ content: "\F13B";
+}
+.mdi-chess-bishop::before {
+ content: "\F85B";
+}
+.mdi-chess-king::before {
+ content: "\F856";
+}
+.mdi-chess-knight::before {
+ content: "\F857";
+}
+.mdi-chess-pawn::before {
+ content: "\F858";
+}
+.mdi-chess-queen::before {
+ content: "\F859";
+}
+.mdi-chess-rook::before {
+ content: "\F85A";
+}
+.mdi-chevron-double-down::before {
+ content: "\F13C";
+}
+.mdi-chevron-double-left::before {
+ content: "\F13D";
+}
+.mdi-chevron-double-right::before {
+ content: "\F13E";
+}
+.mdi-chevron-double-up::before {
+ content: "\F13F";
+}
+.mdi-chevron-down::before {
+ content: "\F140";
+}
+.mdi-chevron-down-box::before {
+ content: "\F9D5";
+}
+.mdi-chevron-down-box-outline::before {
+ content: "\F9D6";
+}
+.mdi-chevron-down-circle::before {
+ content: "\FB0B";
+}
+.mdi-chevron-down-circle-outline::before {
+ content: "\FB0C";
+}
+.mdi-chevron-left::before {
+ content: "\F141";
+}
+.mdi-chevron-left-box::before {
+ content: "\F9D7";
+}
+.mdi-chevron-left-box-outline::before {
+ content: "\F9D8";
+}
+.mdi-chevron-left-circle::before {
+ content: "\FB0D";
+}
+.mdi-chevron-left-circle-outline::before {
+ content: "\FB0E";
+}
+.mdi-chevron-right::before {
+ content: "\F142";
+}
+.mdi-chevron-right-box::before {
+ content: "\F9D9";
+}
+.mdi-chevron-right-box-outline::before {
+ content: "\F9DA";
+}
+.mdi-chevron-right-circle::before {
+ content: "\FB0F";
+}
+.mdi-chevron-right-circle-outline::before {
+ content: "\FB10";
+}
+.mdi-chevron-triple-down::before {
+ content: "\FD95";
+}
+.mdi-chevron-triple-left::before {
+ content: "\FD96";
+}
+.mdi-chevron-triple-right::before {
+ content: "\FD97";
+}
+.mdi-chevron-triple-up::before {
+ content: "\FD98";
+}
+.mdi-chevron-up::before {
+ content: "\F143";
+}
+.mdi-chevron-up-box::before {
+ content: "\F9DB";
+}
+.mdi-chevron-up-box-outline::before {
+ content: "\F9DC";
+}
+.mdi-chevron-up-circle::before {
+ content: "\FB11";
+}
+.mdi-chevron-up-circle-outline::before {
+ content: "\FB12";
+}
+.mdi-chili-hot::before {
+ content: "\F7B1";
+}
+.mdi-chili-medium::before {
+ content: "\F7B2";
+}
+.mdi-chili-mild::before {
+ content: "\F7B3";
+}
+.mdi-chip::before {
+ content: "\F61A";
+}
+.mdi-christianity::before {
+ content: "\F952";
+}
+.mdi-christianity-outline::before {
+ content: "\FCD2";
+}
+.mdi-church::before {
+ content: "\F144";
+}
+.mdi-cigar::before {
+ content: "\F01B4";
+}
+.mdi-circle::before {
+ content: "\F764";
+}
+.mdi-circle-double::before {
+ content: "\FEB2";
+}
+.mdi-circle-edit-outline::before {
+ content: "\F8D4";
+}
+.mdi-circle-expand::before {
+ content: "\FEB3";
+}
+.mdi-circle-medium::before {
+ content: "\F9DD";
+}
+.mdi-circle-off-outline::before {
+ content: "\F00FE";
+}
+.mdi-circle-outline::before {
+ content: "\F765";
+}
+.mdi-circle-slice-1::before {
+ content: "\FA9D";
+}
+.mdi-circle-slice-2::before {
+ content: "\FA9E";
+}
+.mdi-circle-slice-3::before {
+ content: "\FA9F";
+}
+.mdi-circle-slice-4::before {
+ content: "\FAA0";
+}
+.mdi-circle-slice-5::before {
+ content: "\FAA1";
+}
+.mdi-circle-slice-6::before {
+ content: "\FAA2";
+}
+.mdi-circle-slice-7::before {
+ content: "\FAA3";
+}
+.mdi-circle-slice-8::before {
+ content: "\FAA4";
+}
+.mdi-circle-small::before {
+ content: "\F9DE";
+}
+.mdi-circular-saw::before {
+ content: "\FE73";
+}
+.mdi-cisco-webex::before {
+ content: "\F145";
+}
+.mdi-city::before {
+ content: "\F146";
+}
+.mdi-city-variant::before {
+ content: "\FA35";
+}
+.mdi-city-variant-outline::before {
+ content: "\FA36";
+}
+.mdi-clipboard::before {
+ content: "\F147";
+}
+.mdi-clipboard-account::before {
+ content: "\F148";
+}
+.mdi-clipboard-account-outline::before {
+ content: "\FC31";
+}
+.mdi-clipboard-alert::before {
+ content: "\F149";
+}
+.mdi-clipboard-alert-outline::before {
+ content: "\FCD3";
+}
+.mdi-clipboard-arrow-down::before {
+ content: "\F14A";
+}
+.mdi-clipboard-arrow-down-outline::before {
+ content: "\FC32";
+}
+.mdi-clipboard-arrow-left::before {
+ content: "\F14B";
+}
+.mdi-clipboard-arrow-left-outline::before {
+ content: "\FCD4";
+}
+.mdi-clipboard-arrow-right::before {
+ content: "\FCD5";
+}
+.mdi-clipboard-arrow-right-outline::before {
+ content: "\FCD6";
+}
+.mdi-clipboard-arrow-up::before {
+ content: "\FC33";
+}
+.mdi-clipboard-arrow-up-outline::before {
+ content: "\FC34";
+}
+.mdi-clipboard-check::before {
+ content: "\F14C";
+}
+.mdi-clipboard-check-multiple::before {
+ content: "\F028E";
+}
+.mdi-clipboard-check-multiple-outline::before {
+ content: "\F028F";
+}
+.mdi-clipboard-check-outline::before {
+ content: "\F8A7";
+}
+.mdi-clipboard-file::before {
+ content: "\F0290";
+}
+.mdi-clipboard-file-outline::before {
+ content: "\F0291";
+}
+.mdi-clipboard-flow::before {
+ content: "\F6C7";
+}
+.mdi-clipboard-flow-outline::before {
+ content: "\F0142";
+}
+.mdi-clipboard-list::before {
+ content: "\F00FF";
+}
+.mdi-clipboard-list-outline::before {
+ content: "\F0100";
+}
+.mdi-clipboard-multiple::before {
+ content: "\F0292";
+}
+.mdi-clipboard-multiple-outline::before {
+ content: "\F0293";
+}
+.mdi-clipboard-outline::before {
+ content: "\F14D";
+}
+.mdi-clipboard-play::before {
+ content: "\FC35";
+}
+.mdi-clipboard-play-multiple::before {
+ content: "\F0294";
+}
+.mdi-clipboard-play-multiple-outline::before {
+ content: "\F0295";
+}
+.mdi-clipboard-play-outline::before {
+ content: "\FC36";
+}
+.mdi-clipboard-plus::before {
+ content: "\F750";
+}
+.mdi-clipboard-plus-outline::before {
+ content: "\F034A";
+}
+.mdi-clipboard-pulse::before {
+ content: "\F85C";
+}
+.mdi-clipboard-pulse-outline::before {
+ content: "\F85D";
+}
+.mdi-clipboard-text::before {
+ content: "\F14E";
+}
+.mdi-clipboard-text-multiple::before {
+ content: "\F0296";
+}
+.mdi-clipboard-text-multiple-outline::before {
+ content: "\F0297";
+}
+.mdi-clipboard-text-outline::before {
+ content: "\FA37";
+}
+.mdi-clipboard-text-play::before {
+ content: "\FC37";
+}
+.mdi-clipboard-text-play-outline::before {
+ content: "\FC38";
+}
+.mdi-clippy::before {
+ content: "\F14F";
+}
+.mdi-clock::before {
+ content: "\F953";
+}
+.mdi-clock-alert::before {
+ content: "\F954";
+}
+.mdi-clock-alert-outline::before {
+ content: "\F5CE";
+}
+.mdi-clock-check::before {
+ content: "\FFC8";
+}
+.mdi-clock-check-outline::before {
+ content: "\FFC9";
+}
+.mdi-clock-digital::before {
+ content: "\FEB4";
+}
+.mdi-clock-end::before {
+ content: "\F151";
+}
+.mdi-clock-fast::before {
+ content: "\F152";
+}
+.mdi-clock-in::before {
+ content: "\F153";
+}
+.mdi-clock-out::before {
+ content: "\F154";
+}
+.mdi-clock-outline::before {
+ content: "\F150";
+}
+.mdi-clock-start::before {
+ content: "\F155";
+}
+.mdi-close::before {
+ content: "\F156";
+}
+.mdi-close-box::before {
+ content: "\F157";
+}
+.mdi-close-box-multiple::before {
+ content: "\FC39";
+}
+.mdi-close-box-multiple-outline::before {
+ content: "\FC3A";
+}
+.mdi-close-box-outline::before {
+ content: "\F158";
+}
+.mdi-close-circle::before {
+ content: "\F159";
+}
+.mdi-close-circle-outline::before {
+ content: "\F15A";
+}
+.mdi-close-network::before {
+ content: "\F15B";
+}
+.mdi-close-network-outline::before {
+ content: "\FC3B";
+}
+.mdi-close-octagon::before {
+ content: "\F15C";
+}
+.mdi-close-octagon-outline::before {
+ content: "\F15D";
+}
+.mdi-close-outline::before {
+ content: "\F6C8";
+}
+.mdi-closed-caption::before {
+ content: "\F15E";
+}
+.mdi-closed-caption-outline::before {
+ content: "\FD99";
+}
+.mdi-cloud::before {
+ content: "\F15F";
+}
+.mdi-cloud-alert::before {
+ content: "\F9DF";
+}
+.mdi-cloud-braces::before {
+ content: "\F7B4";
+}
+.mdi-cloud-check::before {
+ content: "\F160";
+}
+.mdi-cloud-check-outline::before {
+ content: "\F02F7";
+}
+.mdi-cloud-circle::before {
+ content: "\F161";
+}
+.mdi-cloud-download::before {
+ content: "\F162";
+}
+.mdi-cloud-download-outline::before {
+ content: "\FB59";
+}
+.mdi-cloud-lock::before {
+ content: "\F021C";
+}
+.mdi-cloud-lock-outline::before {
+ content: "\F021D";
+}
+.mdi-cloud-off-outline::before {
+ content: "\F164";
+}
+.mdi-cloud-outline::before {
+ content: "\F163";
+}
+.mdi-cloud-print::before {
+ content: "\F165";
+}
+.mdi-cloud-print-outline::before {
+ content: "\F166";
+}
+.mdi-cloud-question::before {
+ content: "\FA38";
+}
+.mdi-cloud-search::before {
+ content: "\F955";
+}
+.mdi-cloud-search-outline::before {
+ content: "\F956";
+}
+.mdi-cloud-sync::before {
+ content: "\F63F";
+}
+.mdi-cloud-sync-outline::before {
+ content: "\F0301";
+}
+.mdi-cloud-tags::before {
+ content: "\F7B5";
+}
+.mdi-cloud-upload::before {
+ content: "\F167";
+}
+.mdi-cloud-upload-outline::before {
+ content: "\FB5A";
+}
+.mdi-clover::before {
+ content: "\F815";
+}
+.mdi-coach-lamp::before {
+ content: "\F0042";
+}
+.mdi-coat-rack::before {
+ content: "\F00C9";
+}
+.mdi-code-array::before {
+ content: "\F168";
+}
+.mdi-code-braces::before {
+ content: "\F169";
+}
+.mdi-code-braces-box::before {
+ content: "\F0101";
+}
+.mdi-code-brackets::before {
+ content: "\F16A";
+}
+.mdi-code-equal::before {
+ content: "\F16B";
+}
+.mdi-code-greater-than::before {
+ content: "\F16C";
+}
+.mdi-code-greater-than-or-equal::before {
+ content: "\F16D";
+}
+.mdi-code-less-than::before {
+ content: "\F16E";
+}
+.mdi-code-less-than-or-equal::before {
+ content: "\F16F";
+}
+.mdi-code-not-equal::before {
+ content: "\F170";
+}
+.mdi-code-not-equal-variant::before {
+ content: "\F171";
+}
+.mdi-code-parentheses::before {
+ content: "\F172";
+}
+.mdi-code-parentheses-box::before {
+ content: "\F0102";
+}
+.mdi-code-string::before {
+ content: "\F173";
+}
+.mdi-code-tags::before {
+ content: "\F174";
+}
+.mdi-code-tags-check::before {
+ content: "\F693";
+}
+.mdi-codepen::before {
+ content: "\F175";
+}
+.mdi-coffee::before {
+ content: "\F176";
+}
+.mdi-coffee-maker::before {
+ content: "\F00CA";
+}
+.mdi-coffee-off::before {
+ content: "\FFCA";
+}
+.mdi-coffee-off-outline::before {
+ content: "\FFCB";
+}
+.mdi-coffee-outline::before {
+ content: "\F6C9";
+}
+.mdi-coffee-to-go::before {
+ content: "\F177";
+}
+.mdi-coffee-to-go-outline::before {
+ content: "\F0339";
+}
+.mdi-coffin::before {
+ content: "\FB5B";
+}
+.mdi-cog-clockwise::before {
+ content: "\F0208";
+}
+.mdi-cog-counterclockwise::before {
+ content: "\F0209";
+}
+.mdi-cogs::before {
+ content: "\F8D5";
+}
+.mdi-coin::before {
+ content: "\F0196";
+}
+.mdi-coin-outline::before {
+ content: "\F178";
+}
+.mdi-coins::before {
+ content: "\F694";
+}
+.mdi-collage::before {
+ content: "\F640";
+}
+.mdi-collapse-all::before {
+ content: "\FAA5";
+}
+.mdi-collapse-all-outline::before {
+ content: "\FAA6";
+}
+.mdi-color-helper::before {
+ content: "\F179";
+}
+.mdi-comma::before {
+ content: "\FE74";
+}
+.mdi-comma-box::before {
+ content: "\FE75";
+}
+.mdi-comma-box-outline::before {
+ content: "\FE76";
+}
+.mdi-comma-circle::before {
+ content: "\FE77";
+}
+.mdi-comma-circle-outline::before {
+ content: "\FE78";
+}
+.mdi-comment::before {
+ content: "\F17A";
+}
+.mdi-comment-account::before {
+ content: "\F17B";
+}
+.mdi-comment-account-outline::before {
+ content: "\F17C";
+}
+.mdi-comment-alert::before {
+ content: "\F17D";
+}
+.mdi-comment-alert-outline::before {
+ content: "\F17E";
+}
+.mdi-comment-arrow-left::before {
+ content: "\F9E0";
+}
+.mdi-comment-arrow-left-outline::before {
+ content: "\F9E1";
+}
+.mdi-comment-arrow-right::before {
+ content: "\F9E2";
+}
+.mdi-comment-arrow-right-outline::before {
+ content: "\F9E3";
+}
+.mdi-comment-check::before {
+ content: "\F17F";
+}
+.mdi-comment-check-outline::before {
+ content: "\F180";
+}
+.mdi-comment-edit::before {
+ content: "\F01EA";
+}
+.mdi-comment-edit-outline::before {
+ content: "\F02EF";
+}
+.mdi-comment-eye::before {
+ content: "\FA39";
+}
+.mdi-comment-eye-outline::before {
+ content: "\FA3A";
+}
+.mdi-comment-multiple::before {
+ content: "\F85E";
+}
+.mdi-comment-multiple-outline::before {
+ content: "\F181";
+}
+.mdi-comment-outline::before {
+ content: "\F182";
+}
+.mdi-comment-plus::before {
+ content: "\F9E4";
+}
+.mdi-comment-plus-outline::before {
+ content: "\F183";
+}
+.mdi-comment-processing::before {
+ content: "\F184";
+}
+.mdi-comment-processing-outline::before {
+ content: "\F185";
+}
+.mdi-comment-question::before {
+ content: "\F816";
+}
+.mdi-comment-question-outline::before {
+ content: "\F186";
+}
+.mdi-comment-quote::before {
+ content: "\F0043";
+}
+.mdi-comment-quote-outline::before {
+ content: "\F0044";
+}
+.mdi-comment-remove::before {
+ content: "\F5DE";
+}
+.mdi-comment-remove-outline::before {
+ content: "\F187";
+}
+.mdi-comment-search::before {
+ content: "\FA3B";
+}
+.mdi-comment-search-outline::before {
+ content: "\FA3C";
+}
+.mdi-comment-text::before {
+ content: "\F188";
+}
+.mdi-comment-text-multiple::before {
+ content: "\F85F";
+}
+.mdi-comment-text-multiple-outline::before {
+ content: "\F860";
+}
+.mdi-comment-text-outline::before {
+ content: "\F189";
+}
+.mdi-compare::before {
+ content: "\F18A";
+}
+.mdi-compass::before {
+ content: "\F18B";
+}
+.mdi-compass-off::before {
+ content: "\FB5C";
+}
+.mdi-compass-off-outline::before {
+ content: "\FB5D";
+}
+.mdi-compass-outline::before {
+ content: "\F18C";
+}
+.mdi-compass-rose::before {
+ content: "\F03AD";
+}
+.mdi-concourse-ci::before {
+ content: "\F00CB";
+}
+.mdi-console::before {
+ content: "\F18D";
+}
+.mdi-console-line::before {
+ content: "\F7B6";
+}
+.mdi-console-network::before {
+ content: "\F8A8";
+}
+.mdi-console-network-outline::before {
+ content: "\FC3C";
+}
+.mdi-consolidate::before {
+ content: "\F0103";
+}
+.mdi-contact-mail::before {
+ content: "\F18E";
+}
+.mdi-contact-mail-outline::before {
+ content: "\FEB5";
+}
+.mdi-contact-phone::before {
+ content: "\FEB6";
+}
+.mdi-contact-phone-outline::before {
+ content: "\FEB7";
+}
+.mdi-contactless-payment::before {
+ content: "\FD46";
+}
+.mdi-contacts::before {
+ content: "\F6CA";
+}
+.mdi-contain::before {
+ content: "\FA3D";
+}
+.mdi-contain-end::before {
+ content: "\FA3E";
+}
+.mdi-contain-start::before {
+ content: "\FA3F";
+}
+.mdi-content-copy::before {
+ content: "\F18F";
+}
+.mdi-content-cut::before {
+ content: "\F190";
+}
+.mdi-content-duplicate::before {
+ content: "\F191";
+}
+.mdi-content-paste::before {
+ content: "\F192";
+}
+.mdi-content-save::before {
+ content: "\F193";
+}
+.mdi-content-save-alert::before {
+ content: "\FF5F";
+}
+.mdi-content-save-alert-outline::before {
+ content: "\FF60";
+}
+.mdi-content-save-all::before {
+ content: "\F194";
+}
+.mdi-content-save-all-outline::before {
+ content: "\FF61";
+}
+.mdi-content-save-edit::before {
+ content: "\FCD7";
+}
+.mdi-content-save-edit-outline::before {
+ content: "\FCD8";
+}
+.mdi-content-save-move::before {
+ content: "\FE79";
+}
+.mdi-content-save-move-outline::before {
+ content: "\FE7A";
+}
+.mdi-content-save-outline::before {
+ content: "\F817";
+}
+.mdi-content-save-settings::before {
+ content: "\F61B";
+}
+.mdi-content-save-settings-outline::before {
+ content: "\FB13";
+}
+.mdi-contrast::before {
+ content: "\F195";
+}
+.mdi-contrast-box::before {
+ content: "\F196";
+}
+.mdi-contrast-circle::before {
+ content: "\F197";
+}
+.mdi-controller-classic::before {
+ content: "\FB5E";
+}
+.mdi-controller-classic-outline::before {
+ content: "\FB5F";
+}
+.mdi-cookie::before {
+ content: "\F198";
+}
+.mdi-coolant-temperature::before {
+ content: "\F3C8";
+}
+.mdi-copyright::before {
+ content: "\F5E6";
+}
+.mdi-cordova::before {
+ content: "\F957";
+}
+.mdi-corn::before {
+ content: "\F7B7";
+}
+.mdi-counter::before {
+ content: "\F199";
+}
+.mdi-cow::before {
+ content: "\F19A";
+}
+.mdi-cowboy::before {
+ content: "\FEB8";
+}
+.mdi-cpu-32-bit::before {
+ content: "\FEFC";
+}
+.mdi-cpu-64-bit::before {
+ content: "\FEFD";
+}
+.mdi-crane::before {
+ content: "\F861";
+}
+.mdi-creation::before {
+ content: "\F1C9";
+}
+.mdi-creative-commons::before {
+ content: "\FD47";
+}
+.mdi-credit-card::before {
+ content: "\F0010";
+}
+.mdi-credit-card-clock::before {
+ content: "\FEFE";
+}
+.mdi-credit-card-clock-outline::before {
+ content: "\FFBC";
+}
+.mdi-credit-card-marker::before {
+ content: "\F6A7";
+}
+.mdi-credit-card-marker-outline::before {
+ content: "\FD9A";
+}
+.mdi-credit-card-minus::before {
+ content: "\FFCC";
+}
+.mdi-credit-card-minus-outline::before {
+ content: "\FFCD";
+}
+.mdi-credit-card-multiple::before {
+ content: "\F0011";
+}
+.mdi-credit-card-multiple-outline::before {
+ content: "\F19C";
+}
+.mdi-credit-card-off::before {
+ content: "\F0012";
+}
+.mdi-credit-card-off-outline::before {
+ content: "\F5E4";
+}
+.mdi-credit-card-outline::before {
+ content: "\F19B";
+}
+.mdi-credit-card-plus::before {
+ content: "\F0013";
+}
+.mdi-credit-card-plus-outline::before {
+ content: "\F675";
+}
+.mdi-credit-card-refund::before {
+ content: "\F0014";
+}
+.mdi-credit-card-refund-outline::before {
+ content: "\FAA7";
+}
+.mdi-credit-card-remove::before {
+ content: "\FFCE";
+}
+.mdi-credit-card-remove-outline::before {
+ content: "\FFCF";
+}
+.mdi-credit-card-scan::before {
+ content: "\F0015";
+}
+.mdi-credit-card-scan-outline::before {
+ content: "\F19D";
+}
+.mdi-credit-card-settings::before {
+ content: "\F0016";
+}
+.mdi-credit-card-settings-outline::before {
+ content: "\F8D6";
+}
+.mdi-credit-card-wireless::before {
+ content: "\F801";
+}
+.mdi-credit-card-wireless-outline::before {
+ content: "\FD48";
+}
+.mdi-cricket::before {
+ content: "\FD49";
+}
+.mdi-crop::before {
+ content: "\F19E";
+}
+.mdi-crop-free::before {
+ content: "\F19F";
+}
+.mdi-crop-landscape::before {
+ content: "\F1A0";
+}
+.mdi-crop-portrait::before {
+ content: "\F1A1";
+}
+.mdi-crop-rotate::before {
+ content: "\F695";
+}
+.mdi-crop-square::before {
+ content: "\F1A2";
+}
+.mdi-crosshairs::before {
+ content: "\F1A3";
+}
+.mdi-crosshairs-gps::before {
+ content: "\F1A4";
+}
+.mdi-crosshairs-off::before {
+ content: "\FF62";
+}
+.mdi-crosshairs-question::before {
+ content: "\F0161";
+}
+.mdi-crown::before {
+ content: "\F1A5";
+}
+.mdi-crown-outline::before {
+ content: "\F01FB";
+}
+.mdi-cryengine::before {
+ content: "\F958";
+}
+.mdi-crystal-ball::before {
+ content: "\FB14";
+}
+.mdi-cube::before {
+ content: "\F1A6";
+}
+.mdi-cube-outline::before {
+ content: "\F1A7";
+}
+.mdi-cube-scan::before {
+ content: "\FB60";
+}
+.mdi-cube-send::before {
+ content: "\F1A8";
+}
+.mdi-cube-unfolded::before {
+ content: "\F1A9";
+}
+.mdi-cup::before {
+ content: "\F1AA";
+}
+.mdi-cup-off::before {
+ content: "\F5E5";
+}
+.mdi-cup-off-outline::before {
+ content: "\F03A8";
+}
+.mdi-cup-outline::before {
+ content: "\F033A";
+}
+.mdi-cup-water::before {
+ content: "\F1AB";
+}
+.mdi-cupboard::before {
+ content: "\FF63";
+}
+.mdi-cupboard-outline::before {
+ content: "\FF64";
+}
+.mdi-cupcake::before {
+ content: "\F959";
+}
+.mdi-curling::before {
+ content: "\F862";
+}
+.mdi-currency-bdt::before {
+ content: "\F863";
+}
+.mdi-currency-brl::before {
+ content: "\FB61";
+}
+.mdi-currency-btc::before {
+ content: "\F1AC";
+}
+.mdi-currency-cny::before {
+ content: "\F7B9";
+}
+.mdi-currency-eth::before {
+ content: "\F7BA";
+}
+.mdi-currency-eur::before {
+ content: "\F1AD";
+}
+.mdi-currency-eur-off::before {
+ content: "\F0340";
+}
+.mdi-currency-gbp::before {
+ content: "\F1AE";
+}
+.mdi-currency-ils::before {
+ content: "\FC3D";
+}
+.mdi-currency-inr::before {
+ content: "\F1AF";
+}
+.mdi-currency-jpy::before {
+ content: "\F7BB";
+}
+.mdi-currency-krw::before {
+ content: "\F7BC";
+}
+.mdi-currency-kzt::before {
+ content: "\F864";
+}
+.mdi-currency-ngn::before {
+ content: "\F1B0";
+}
+.mdi-currency-php::before {
+ content: "\F9E5";
+}
+.mdi-currency-rial::before {
+ content: "\FEB9";
+}
+.mdi-currency-rub::before {
+ content: "\F1B1";
+}
+.mdi-currency-sign::before {
+ content: "\F7BD";
+}
+.mdi-currency-try::before {
+ content: "\F1B2";
+}
+.mdi-currency-twd::before {
+ content: "\F7BE";
+}
+.mdi-currency-usd::before {
+ content: "\F1B3";
+}
+.mdi-currency-usd-off::before {
+ content: "\F679";
+}
+.mdi-current-ac::before {
+ content: "\F95A";
+}
+.mdi-current-dc::before {
+ content: "\F95B";
+}
+.mdi-cursor-default::before {
+ content: "\F1B4";
+}
+.mdi-cursor-default-click::before {
+ content: "\FCD9";
+}
+.mdi-cursor-default-click-outline::before {
+ content: "\FCDA";
+}
+.mdi-cursor-default-gesture::before {
+ content: "\F0152";
+}
+.mdi-cursor-default-gesture-outline::before {
+ content: "\F0153";
+}
+.mdi-cursor-default-outline::before {
+ content: "\F1B5";
+}
+.mdi-cursor-move::before {
+ content: "\F1B6";
+}
+.mdi-cursor-pointer::before {
+ content: "\F1B7";
+}
+.mdi-cursor-text::before {
+ content: "\F5E7";
+}
+.mdi-database::before {
+ content: "\F1B8";
+}
+.mdi-database-check::before {
+ content: "\FAA8";
+}
+.mdi-database-edit::before {
+ content: "\FB62";
+}
+.mdi-database-export::before {
+ content: "\F95D";
+}
+.mdi-database-import::before {
+ content: "\F95C";
+}
+.mdi-database-lock::before {
+ content: "\FAA9";
+}
+.mdi-database-marker::before {
+ content: "\F0321";
+}
+.mdi-database-minus::before {
+ content: "\F1B9";
+}
+.mdi-database-plus::before {
+ content: "\F1BA";
+}
+.mdi-database-refresh::before {
+ content: "\FCDB";
+}
+.mdi-database-remove::before {
+ content: "\FCDC";
+}
+.mdi-database-search::before {
+ content: "\F865";
+}
+.mdi-database-settings::before {
+ content: "\FCDD";
+}
+.mdi-death-star::before {
+ content: "\F8D7";
+}
+.mdi-death-star-variant::before {
+ content: "\F8D8";
+}
+.mdi-deathly-hallows::before {
+ content: "\FB63";
+}
+.mdi-debian::before {
+ content: "\F8D9";
+}
+.mdi-debug-step-into::before {
+ content: "\F1BB";
+}
+.mdi-debug-step-out::before {
+ content: "\F1BC";
+}
+.mdi-debug-step-over::before {
+ content: "\F1BD";
+}
+.mdi-decagram::before {
+ content: "\F76B";
+}
+.mdi-decagram-outline::before {
+ content: "\F76C";
+}
+.mdi-decimal::before {
+ content: "\F00CC";
+}
+.mdi-decimal-comma::before {
+ content: "\F00CD";
+}
+.mdi-decimal-comma-decrease::before {
+ content: "\F00CE";
+}
+.mdi-decimal-comma-increase::before {
+ content: "\F00CF";
+}
+.mdi-decimal-decrease::before {
+ content: "\F1BE";
+}
+.mdi-decimal-increase::before {
+ content: "\F1BF";
+}
+.mdi-delete::before {
+ content: "\F1C0";
+}
+.mdi-delete-alert::before {
+ content: "\F00D0";
+}
+.mdi-delete-alert-outline::before {
+ content: "\F00D1";
+}
+.mdi-delete-circle::before {
+ content: "\F682";
+}
+.mdi-delete-circle-outline::before {
+ content: "\FB64";
+}
+.mdi-delete-empty::before {
+ content: "\F6CB";
+}
+.mdi-delete-empty-outline::before {
+ content: "\FEBA";
+}
+.mdi-delete-forever::before {
+ content: "\F5E8";
+}
+.mdi-delete-forever-outline::before {
+ content: "\FB65";
+}
+.mdi-delete-off::before {
+ content: "\F00D2";
+}
+.mdi-delete-off-outline::before {
+ content: "\F00D3";
+}
+.mdi-delete-outline::before {
+ content: "\F9E6";
+}
+.mdi-delete-restore::before {
+ content: "\F818";
+}
+.mdi-delete-sweep::before {
+ content: "\F5E9";
+}
+.mdi-delete-sweep-outline::before {
+ content: "\FC3E";
+}
+.mdi-delete-variant::before {
+ content: "\F1C1";
+}
+.mdi-delta::before {
+ content: "\F1C2";
+}
+.mdi-desk::before {
+ content: "\F0264";
+}
+.mdi-desk-lamp::before {
+ content: "\F95E";
+}
+.mdi-deskphone::before {
+ content: "\F1C3";
+}
+.mdi-desktop-classic::before {
+ content: "\F7BF";
+}
+.mdi-desktop-mac::before {
+ content: "\F1C4";
+}
+.mdi-desktop-mac-dashboard::before {
+ content: "\F9E7";
+}
+.mdi-desktop-tower::before {
+ content: "\F1C5";
+}
+.mdi-desktop-tower-monitor::before {
+ content: "\FAAA";
+}
+.mdi-details::before {
+ content: "\F1C6";
+}
+.mdi-dev-to::before {
+ content: "\FD4A";
+}
+.mdi-developer-board::before {
+ content: "\F696";
+}
+.mdi-deviantart::before {
+ content: "\F1C7";
+}
+.mdi-devices::before {
+ content: "\FFD0";
+}
+.mdi-diabetes::before {
+ content: "\F0151";
+}
+.mdi-dialpad::before {
+ content: "\F61C";
+}
+.mdi-diameter::before {
+ content: "\FC3F";
+}
+.mdi-diameter-outline::before {
+ content: "\FC40";
+}
+.mdi-diameter-variant::before {
+ content: "\FC41";
+}
+.mdi-diamond::before {
+ content: "\FB66";
+}
+.mdi-diamond-outline::before {
+ content: "\FB67";
+}
+.mdi-diamond-stone::before {
+ content: "\F1C8";
+}
+.mdi-dice-1::before {
+ content: "\F1CA";
+}
+.mdi-dice-1-outline::before {
+ content: "\F0175";
+}
+.mdi-dice-2::before {
+ content: "\F1CB";
+}
+.mdi-dice-2-outline::before {
+ content: "\F0176";
+}
+.mdi-dice-3::before {
+ content: "\F1CC";
+}
+.mdi-dice-3-outline::before {
+ content: "\F0177";
+}
+.mdi-dice-4::before {
+ content: "\F1CD";
+}
+.mdi-dice-4-outline::before {
+ content: "\F0178";
+}
+.mdi-dice-5::before {
+ content: "\F1CE";
+}
+.mdi-dice-5-outline::before {
+ content: "\F0179";
+}
+.mdi-dice-6::before {
+ content: "\F1CF";
+}
+.mdi-dice-6-outline::before {
+ content: "\F017A";
+}
+.mdi-dice-d10::before {
+ content: "\F017E";
+}
+.mdi-dice-d10-outline::before {
+ content: "\F76E";
+}
+.mdi-dice-d12::before {
+ content: "\F017F";
+}
+.mdi-dice-d12-outline::before {
+ content: "\F866";
+}
+.mdi-dice-d20::before {
+ content: "\F0180";
+}
+.mdi-dice-d20-outline::before {
+ content: "\F5EA";
+}
+.mdi-dice-d4::before {
+ content: "\F017B";
+}
+.mdi-dice-d4-outline::before {
+ content: "\F5EB";
+}
+.mdi-dice-d6::before {
+ content: "\F017C";
+}
+.mdi-dice-d6-outline::before {
+ content: "\F5EC";
+}
+.mdi-dice-d8::before {
+ content: "\F017D";
+}
+.mdi-dice-d8-outline::before {
+ content: "\F5ED";
+}
+.mdi-dice-multiple::before {
+ content: "\F76D";
+}
+.mdi-dice-multiple-outline::before {
+ content: "\F0181";
+}
+.mdi-dictionary::before {
+ content: "\F61D";
+}
+.mdi-digital-ocean::before {
+ content: "\F0262";
+}
+.mdi-dip-switch::before {
+ content: "\F7C0";
+}
+.mdi-directions::before {
+ content: "\F1D0";
+}
+.mdi-directions-fork::before {
+ content: "\F641";
+}
+.mdi-disc::before {
+ content: "\F5EE";
+}
+.mdi-disc-alert::before {
+ content: "\F1D1";
+}
+.mdi-disc-player::before {
+ content: "\F95F";
+}
+.mdi-discord::before {
+ content: "\F66F";
+}
+.mdi-dishwasher::before {
+ content: "\FAAB";
+}
+.mdi-dishwasher-alert::before {
+ content: "\F01E3";
+}
+.mdi-dishwasher-off::before {
+ content: "\F01E4";
+}
+.mdi-disqus::before {
+ content: "\F1D2";
+}
+.mdi-disqus-outline::before {
+ content: "\F1D3";
+}
+.mdi-distribute-horizontal-center::before {
+ content: "\F01F4";
+}
+.mdi-distribute-horizontal-left::before {
+ content: "\F01F3";
+}
+.mdi-distribute-horizontal-right::before {
+ content: "\F01F5";
+}
+.mdi-distribute-vertical-bottom::before {
+ content: "\F01F6";
+}
+.mdi-distribute-vertical-center::before {
+ content: "\F01F7";
+}
+.mdi-distribute-vertical-top::before {
+ content: "\F01F8";
+}
+.mdi-diving-flippers::before {
+ content: "\FD9B";
+}
+.mdi-diving-helmet::before {
+ content: "\FD9C";
+}
+.mdi-diving-scuba::before {
+ content: "\FD9D";
+}
+.mdi-diving-scuba-flag::before {
+ content: "\FD9E";
+}
+.mdi-diving-scuba-tank::before {
+ content: "\FD9F";
+}
+.mdi-diving-scuba-tank-multiple::before {
+ content: "\FDA0";
+}
+.mdi-diving-snorkel::before {
+ content: "\FDA1";
+}
+.mdi-division::before {
+ content: "\F1D4";
+}
+.mdi-division-box::before {
+ content: "\F1D5";
+}
+.mdi-dlna::before {
+ content: "\FA40";
+}
+.mdi-dna::before {
+ content: "\F683";
+}
+.mdi-dns::before {
+ content: "\F1D6";
+}
+.mdi-dns-outline::before {
+ content: "\FB68";
+}
+.mdi-do-not-disturb::before {
+ content: "\F697";
+}
+.mdi-do-not-disturb-off::before {
+ content: "\F698";
+}
+.mdi-dock-bottom::before {
+ content: "\F00D4";
+}
+.mdi-dock-left::before {
+ content: "\F00D5";
+}
+.mdi-dock-right::before {
+ content: "\F00D6";
+}
+.mdi-dock-window::before {
+ content: "\F00D7";
+}
+.mdi-docker::before {
+ content: "\F867";
+}
+.mdi-doctor::before {
+ content: "\FA41";
+}
+.mdi-dog::before {
+ content: "\FA42";
+}
+.mdi-dog-service::before {
+ content: "\FAAC";
+}
+.mdi-dog-side::before {
+ content: "\FA43";
+}
+.mdi-dolby::before {
+ content: "\F6B2";
+}
+.mdi-dolly::before {
+ content: "\FEBB";
+}
+.mdi-domain::before {
+ content: "\F1D7";
+}
+.mdi-domain-off::before {
+ content: "\FD4B";
+}
+.mdi-domain-plus::before {
+ content: "\F00D8";
+}
+.mdi-domain-remove::before {
+ content: "\F00D9";
+}
+.mdi-domino-mask::before {
+ content: "\F0045";
+}
+.mdi-donkey::before {
+ content: "\F7C1";
+}
+.mdi-door::before {
+ content: "\F819";
+}
+.mdi-door-closed::before {
+ content: "\F81A";
+}
+.mdi-door-closed-lock::before {
+ content: "\F00DA";
+}
+.mdi-door-open::before {
+ content: "\F81B";
+}
+.mdi-doorbell::before {
+ content: "\F0311";
+}
+.mdi-doorbell-video::before {
+ content: "\F868";
+}
+.mdi-dot-net::before {
+ content: "\FAAD";
+}
+.mdi-dots-horizontal::before {
+ content: "\F1D8";
+}
+.mdi-dots-horizontal-circle::before {
+ content: "\F7C2";
+}
+.mdi-dots-horizontal-circle-outline::before {
+ content: "\FB69";
+}
+.mdi-dots-vertical::before {
+ content: "\F1D9";
+}
+.mdi-dots-vertical-circle::before {
+ content: "\F7C3";
+}
+.mdi-dots-vertical-circle-outline::before {
+ content: "\FB6A";
+}
+.mdi-douban::before {
+ content: "\F699";
+}
+.mdi-download::before {
+ content: "\F1DA";
+}
+.mdi-download-lock::before {
+ content: "\F034B";
+}
+.mdi-download-lock-outline::before {
+ content: "\F034C";
+}
+.mdi-download-multiple::before {
+ content: "\F9E8";
+}
+.mdi-download-network::before {
+ content: "\F6F3";
+}
+.mdi-download-network-outline::before {
+ content: "\FC42";
+}
+.mdi-download-off::before {
+ content: "\F00DB";
+}
+.mdi-download-off-outline::before {
+ content: "\F00DC";
+}
+.mdi-download-outline::before {
+ content: "\FB6B";
+}
+.mdi-drag::before {
+ content: "\F1DB";
+}
+.mdi-drag-horizontal::before {
+ content: "\F1DC";
+}
+.mdi-drag-horizontal-variant::before {
+ content: "\F031B";
+}
+.mdi-drag-variant::before {
+ content: "\FB6C";
+}
+.mdi-drag-vertical::before {
+ content: "\F1DD";
+}
+.mdi-drag-vertical-variant::before {
+ content: "\F031C";
+}
+.mdi-drama-masks::before {
+ content: "\FCDE";
+}
+.mdi-draw::before {
+ content: "\FF66";
+}
+.mdi-drawing::before {
+ content: "\F1DE";
+}
+.mdi-drawing-box::before {
+ content: "\F1DF";
+}
+.mdi-dresser::before {
+ content: "\FF67";
+}
+.mdi-dresser-outline::before {
+ content: "\FF68";
+}
+.mdi-dribbble::before {
+ content: "\F1E0";
+}
+.mdi-dribbble-box::before {
+ content: "\F1E1";
+}
+.mdi-drone::before {
+ content: "\F1E2";
+}
+.mdi-dropbox::before {
+ content: "\F1E3";
+}
+.mdi-drupal::before {
+ content: "\F1E4";
+}
+.mdi-duck::before {
+ content: "\F1E5";
+}
+.mdi-dumbbell::before {
+ content: "\F1E6";
+}
+.mdi-dump-truck::before {
+ content: "\FC43";
+}
+.mdi-ear-hearing::before {
+ content: "\F7C4";
+}
+.mdi-ear-hearing-off::before {
+ content: "\FA44";
+}
+.mdi-earth::before {
+ content: "\F1E7";
+}
+.mdi-earth-arrow-right::before {
+ content: "\F033C";
+}
+.mdi-earth-box::before {
+ content: "\F6CC";
+}
+.mdi-earth-box-off::before {
+ content: "\F6CD";
+}
+.mdi-earth-off::before {
+ content: "\F1E8";
+}
+.mdi-edge::before {
+ content: "\F1E9";
+}
+.mdi-edge-legacy::before {
+ content: "\F027B";
+}
+.mdi-egg::before {
+ content: "\FAAE";
+}
+.mdi-egg-easter::before {
+ content: "\FAAF";
+}
+.mdi-eight-track::before {
+ content: "\F9E9";
+}
+.mdi-eject::before {
+ content: "\F1EA";
+}
+.mdi-eject-outline::before {
+ content: "\FB6D";
+}
+.mdi-electric-switch::before {
+ content: "\FEBC";
+}
+.mdi-electric-switch-closed::before {
+ content: "\F0104";
+}
+.mdi-electron-framework::before {
+ content: "\F0046";
+}
+.mdi-elephant::before {
+ content: "\F7C5";
+}
+.mdi-elevation-decline::before {
+ content: "\F1EB";
+}
+.mdi-elevation-rise::before {
+ content: "\F1EC";
+}
+.mdi-elevator::before {
+ content: "\F1ED";
+}
+.mdi-elevator-down::before {
+ content: "\F02ED";
+}
+.mdi-elevator-passenger::before {
+ content: "\F03AC";
+}
+.mdi-elevator-up::before {
+ content: "\F02EC";
+}
+.mdi-ellipse::before {
+ content: "\FEBD";
+}
+.mdi-ellipse-outline::before {
+ content: "\FEBE";
+}
+.mdi-email::before {
+ content: "\F1EE";
+}
+.mdi-email-alert::before {
+ content: "\F6CE";
+}
+.mdi-email-alert-outline::before {
+ content: "\FD1E";
+}
+.mdi-email-box::before {
+ content: "\FCDF";
+}
+.mdi-email-check::before {
+ content: "\FAB0";
+}
+.mdi-email-check-outline::before {
+ content: "\FAB1";
+}
+.mdi-email-edit::before {
+ content: "\FF00";
+}
+.mdi-email-edit-outline::before {
+ content: "\FF01";
+}
+.mdi-email-lock::before {
+ content: "\F1F1";
+}
+.mdi-email-mark-as-unread::before {
+ content: "\FB6E";
+}
+.mdi-email-minus::before {
+ content: "\FF02";
+}
+.mdi-email-minus-outline::before {
+ content: "\FF03";
+}
+.mdi-email-multiple::before {
+ content: "\FF04";
+}
+.mdi-email-multiple-outline::before {
+ content: "\FF05";
+}
+.mdi-email-newsletter::before {
+ content: "\FFD1";
+}
+.mdi-email-open::before {
+ content: "\F1EF";
+}
+.mdi-email-open-multiple::before {
+ content: "\FF06";
+}
+.mdi-email-open-multiple-outline::before {
+ content: "\FF07";
+}
+.mdi-email-open-outline::before {
+ content: "\F5EF";
+}
+.mdi-email-outline::before {
+ content: "\F1F0";
+}
+.mdi-email-plus::before {
+ content: "\F9EA";
+}
+.mdi-email-plus-outline::before {
+ content: "\F9EB";
+}
+.mdi-email-receive::before {
+ content: "\F0105";
+}
+.mdi-email-receive-outline::before {
+ content: "\F0106";
+}
+.mdi-email-search::before {
+ content: "\F960";
+}
+.mdi-email-search-outline::before {
+ content: "\F961";
+}
+.mdi-email-send::before {
+ content: "\F0107";
+}
+.mdi-email-send-outline::before {
+ content: "\F0108";
+}
+.mdi-email-sync::before {
+ content: "\F02F2";
+}
+.mdi-email-sync-outline::before {
+ content: "\F02F3";
+}
+.mdi-email-variant::before {
+ content: "\F5F0";
+}
+.mdi-ember::before {
+ content: "\FB15";
+}
+.mdi-emby::before {
+ content: "\F6B3";
+}
+.mdi-emoticon::before {
+ content: "\FC44";
+}
+.mdi-emoticon-angry::before {
+ content: "\FC45";
+}
+.mdi-emoticon-angry-outline::before {
+ content: "\FC46";
+}
+.mdi-emoticon-confused::before {
+ content: "\F0109";
+}
+.mdi-emoticon-confused-outline::before {
+ content: "\F010A";
+}
+.mdi-emoticon-cool::before {
+ content: "\FC47";
+}
+.mdi-emoticon-cool-outline::before {
+ content: "\F1F3";
+}
+.mdi-emoticon-cry::before {
+ content: "\FC48";
+}
+.mdi-emoticon-cry-outline::before {
+ content: "\FC49";
+}
+.mdi-emoticon-dead::before {
+ content: "\FC4A";
+}
+.mdi-emoticon-dead-outline::before {
+ content: "\F69A";
+}
+.mdi-emoticon-devil::before {
+ content: "\FC4B";
+}
+.mdi-emoticon-devil-outline::before {
+ content: "\F1F4";
+}
+.mdi-emoticon-excited::before {
+ content: "\FC4C";
+}
+.mdi-emoticon-excited-outline::before {
+ content: "\F69B";
+}
+.mdi-emoticon-frown::before {
+ content: "\FF69";
+}
+.mdi-emoticon-frown-outline::before {
+ content: "\FF6A";
+}
+.mdi-emoticon-happy::before {
+ content: "\FC4D";
+}
+.mdi-emoticon-happy-outline::before {
+ content: "\F1F5";
+}
+.mdi-emoticon-kiss::before {
+ content: "\FC4E";
+}
+.mdi-emoticon-kiss-outline::before {
+ content: "\FC4F";
+}
+.mdi-emoticon-lol::before {
+ content: "\F023F";
+}
+.mdi-emoticon-lol-outline::before {
+ content: "\F0240";
+}
+.mdi-emoticon-neutral::before {
+ content: "\FC50";
+}
+.mdi-emoticon-neutral-outline::before {
+ content: "\F1F6";
+}
+.mdi-emoticon-outline::before {
+ content: "\F1F2";
+}
+.mdi-emoticon-poop::before {
+ content: "\F1F7";
+}
+.mdi-emoticon-poop-outline::before {
+ content: "\FC51";
+}
+.mdi-emoticon-sad::before {
+ content: "\FC52";
+}
+.mdi-emoticon-sad-outline::before {
+ content: "\F1F8";
+}
+.mdi-emoticon-tongue::before {
+ content: "\F1F9";
+}
+.mdi-emoticon-tongue-outline::before {
+ content: "\FC53";
+}
+.mdi-emoticon-wink::before {
+ content: "\FC54";
+}
+.mdi-emoticon-wink-outline::before {
+ content: "\FC55";
+}
+.mdi-engine::before {
+ content: "\F1FA";
+}
+.mdi-engine-off::before {
+ content: "\FA45";
+}
+.mdi-engine-off-outline::before {
+ content: "\FA46";
+}
+.mdi-engine-outline::before {
+ content: "\F1FB";
+}
+.mdi-epsilon::before {
+ content: "\F010B";
+}
+.mdi-equal::before {
+ content: "\F1FC";
+}
+.mdi-equal-box::before {
+ content: "\F1FD";
+}
+.mdi-equalizer::before {
+ content: "\FEBF";
+}
+.mdi-equalizer-outline::before {
+ content: "\FEC0";
+}
+.mdi-eraser::before {
+ content: "\F1FE";
+}
+.mdi-eraser-variant::before {
+ content: "\F642";
+}
+.mdi-escalator::before {
+ content: "\F1FF";
+}
+.mdi-escalator-down::before {
+ content: "\F02EB";
+}
+.mdi-escalator-up::before {
+ content: "\F02EA";
+}
+.mdi-eslint::before {
+ content: "\FC56";
+}
+.mdi-et::before {
+ content: "\FAB2";
+}
+.mdi-ethereum::before {
+ content: "\F869";
+}
+.mdi-ethernet::before {
+ content: "\F200";
+}
+.mdi-ethernet-cable::before {
+ content: "\F201";
+}
+.mdi-ethernet-cable-off::before {
+ content: "\F202";
+}
+.mdi-etsy::before {
+ content: "\F203";
+}
+.mdi-ev-station::before {
+ content: "\F5F1";
+}
+.mdi-eventbrite::before {
+ content: "\F7C6";
+}
+.mdi-evernote::before {
+ content: "\F204";
+}
+.mdi-excavator::before {
+ content: "\F0047";
+}
+.mdi-exclamation::before {
+ content: "\F205";
+}
+.mdi-exclamation-thick::before {
+ content: "\F0263";
+}
+.mdi-exit-run::before {
+ content: "\FA47";
+}
+.mdi-exit-to-app::before {
+ content: "\F206";
+}
+.mdi-expand-all::before {
+ content: "\FAB3";
+}
+.mdi-expand-all-outline::before {
+ content: "\FAB4";
+}
+.mdi-expansion-card::before {
+ content: "\F8AD";
+}
+.mdi-expansion-card-variant::before {
+ content: "\FFD2";
+}
+.mdi-exponent::before {
+ content: "\F962";
+}
+.mdi-exponent-box::before {
+ content: "\F963";
+}
+.mdi-export::before {
+ content: "\F207";
+}
+.mdi-export-variant::before {
+ content: "\FB6F";
+}
+.mdi-eye::before {
+ content: "\F208";
+}
+.mdi-eye-check::before {
+ content: "\FCE0";
+}
+.mdi-eye-check-outline::before {
+ content: "\FCE1";
+}
+.mdi-eye-circle::before {
+ content: "\FB70";
+}
+.mdi-eye-circle-outline::before {
+ content: "\FB71";
+}
+.mdi-eye-minus::before {
+ content: "\F0048";
+}
+.mdi-eye-minus-outline::before {
+ content: "\F0049";
+}
+.mdi-eye-off::before {
+ content: "\F209";
+}
+.mdi-eye-off-outline::before {
+ content: "\F6D0";
+}
+.mdi-eye-outline::before {
+ content: "\F6CF";
+}
+.mdi-eye-plus::before {
+ content: "\F86A";
+}
+.mdi-eye-plus-outline::before {
+ content: "\F86B";
+}
+.mdi-eye-settings::before {
+ content: "\F86C";
+}
+.mdi-eye-settings-outline::before {
+ content: "\F86D";
+}
+.mdi-eyedropper::before {
+ content: "\F20A";
+}
+.mdi-eyedropper-variant::before {
+ content: "\F20B";
+}
+.mdi-face::before {
+ content: "\F643";
+}
+.mdi-face-agent::before {
+ content: "\FD4C";
+}
+.mdi-face-outline::before {
+ content: "\FB72";
+}
+.mdi-face-profile::before {
+ content: "\F644";
+}
+.mdi-face-profile-woman::before {
+ content: "\F00A1";
+}
+.mdi-face-recognition::before {
+ content: "\FC57";
+}
+.mdi-face-woman::before {
+ content: "\F00A2";
+}
+.mdi-face-woman-outline::before {
+ content: "\F00A3";
+}
+.mdi-facebook::before {
+ content: "\F20C";
+}
+.mdi-facebook-box::before {
+ content: "\F20D";
+}
+.mdi-facebook-messenger::before {
+ content: "\F20E";
+}
+.mdi-facebook-workplace::before {
+ content: "\FB16";
+}
+.mdi-factory::before {
+ content: "\F20F";
+}
+.mdi-fan::before {
+ content: "\F210";
+}
+.mdi-fan-off::before {
+ content: "\F81C";
+}
+.mdi-fast-forward::before {
+ content: "\F211";
+}
+.mdi-fast-forward-10::before {
+ content: "\FD4D";
+}
+.mdi-fast-forward-30::before {
+ content: "\FCE2";
+}
+.mdi-fast-forward-5::before {
+ content: "\F0223";
+}
+.mdi-fast-forward-outline::before {
+ content: "\F6D1";
+}
+.mdi-fax::before {
+ content: "\F212";
+}
+.mdi-feather::before {
+ content: "\F6D2";
+}
+.mdi-feature-search::before {
+ content: "\FA48";
+}
+.mdi-feature-search-outline::before {
+ content: "\FA49";
+}
+.mdi-fedora::before {
+ content: "\F8DA";
+}
+.mdi-ferris-wheel::before {
+ content: "\FEC1";
+}
+.mdi-ferry::before {
+ content: "\F213";
+}
+.mdi-file::before {
+ content: "\F214";
+}
+.mdi-file-account::before {
+ content: "\F73A";
+}
+.mdi-file-account-outline::before {
+ content: "\F004A";
+}
+.mdi-file-alert::before {
+ content: "\FA4A";
+}
+.mdi-file-alert-outline::before {
+ content: "\FA4B";
+}
+.mdi-file-cabinet::before {
+ content: "\FAB5";
+}
+.mdi-file-cad::before {
+ content: "\FF08";
+}
+.mdi-file-cad-box::before {
+ content: "\FF09";
+}
+.mdi-file-cancel::before {
+ content: "\FDA2";
+}
+.mdi-file-cancel-outline::before {
+ content: "\FDA3";
+}
+.mdi-file-certificate::before {
+ content: "\F01B1";
+}
+.mdi-file-certificate-outline::before {
+ content: "\F01B2";
+}
+.mdi-file-chart::before {
+ content: "\F215";
+}
+.mdi-file-chart-outline::before {
+ content: "\F004B";
+}
+.mdi-file-check::before {
+ content: "\F216";
+}
+.mdi-file-check-outline::before {
+ content: "\FE7B";
+}
+.mdi-file-clock::before {
+ content: "\F030C";
+}
+.mdi-file-clock-outline::before {
+ content: "\F030D";
+}
+.mdi-file-cloud::before {
+ content: "\F217";
+}
+.mdi-file-cloud-outline::before {
+ content: "\F004C";
+}
+.mdi-file-code::before {
+ content: "\F22E";
+}
+.mdi-file-code-outline::before {
+ content: "\F004D";
+}
+.mdi-file-compare::before {
+ content: "\F8A9";
+}
+.mdi-file-delimited::before {
+ content: "\F218";
+}
+.mdi-file-delimited-outline::before {
+ content: "\FEC2";
+}
+.mdi-file-document::before {
+ content: "\F219";
+}
+.mdi-file-document-box::before {
+ content: "\F21A";
+}
+.mdi-file-document-box-check::before {
+ content: "\FEC3";
+}
+.mdi-file-document-box-check-outline::before {
+ content: "\FEC4";
+}
+.mdi-file-document-box-minus::before {
+ content: "\FEC5";
+}
+.mdi-file-document-box-minus-outline::before {
+ content: "\FEC6";
+}
+.mdi-file-document-box-multiple::before {
+ content: "\FAB6";
+}
+.mdi-file-document-box-multiple-outline::before {
+ content: "\FAB7";
+}
+.mdi-file-document-box-outline::before {
+ content: "\F9EC";
+}
+.mdi-file-document-box-plus::before {
+ content: "\FEC7";
+}
+.mdi-file-document-box-plus-outline::before {
+ content: "\FEC8";
+}
+.mdi-file-document-box-remove::before {
+ content: "\FEC9";
+}
+.mdi-file-document-box-remove-outline::before {
+ content: "\FECA";
+}
+.mdi-file-document-box-search::before {
+ content: "\FECB";
+}
+.mdi-file-document-box-search-outline::before {
+ content: "\FECC";
+}
+.mdi-file-document-edit::before {
+ content: "\FDA4";
+}
+.mdi-file-document-edit-outline::before {
+ content: "\FDA5";
+}
+.mdi-file-document-outline::before {
+ content: "\F9ED";
+}
+.mdi-file-download::before {
+ content: "\F964";
+}
+.mdi-file-download-outline::before {
+ content: "\F965";
+}
+.mdi-file-edit::before {
+ content: "\F0212";
+}
+.mdi-file-edit-outline::before {
+ content: "\F0213";
+}
+.mdi-file-excel::before {
+ content: "\F21B";
+}
+.mdi-file-excel-box::before {
+ content: "\F21C";
+}
+.mdi-file-excel-box-outline::before {
+ content: "\F004E";
+}
+.mdi-file-excel-outline::before {
+ content: "\F004F";
+}
+.mdi-file-export::before {
+ content: "\F21D";
+}
+.mdi-file-export-outline::before {
+ content: "\F0050";
+}
+.mdi-file-eye::before {
+ content: "\FDA6";
+}
+.mdi-file-eye-outline::before {
+ content: "\FDA7";
+}
+.mdi-file-find::before {
+ content: "\F21E";
+}
+.mdi-file-find-outline::before {
+ content: "\FB73";
+}
+.mdi-file-hidden::before {
+ content: "\F613";
+}
+.mdi-file-image::before {
+ content: "\F21F";
+}
+.mdi-file-image-outline::before {
+ content: "\FECD";
+}
+.mdi-file-import::before {
+ content: "\F220";
+}
+.mdi-file-import-outline::before {
+ content: "\F0051";
+}
+.mdi-file-key::before {
+ content: "\F01AF";
+}
+.mdi-file-key-outline::before {
+ content: "\F01B0";
+}
+.mdi-file-link::before {
+ content: "\F01A2";
+}
+.mdi-file-link-outline::before {
+ content: "\F01A3";
+}
+.mdi-file-lock::before {
+ content: "\F221";
+}
+.mdi-file-lock-outline::before {
+ content: "\F0052";
+}
+.mdi-file-move::before {
+ content: "\FAB8";
+}
+.mdi-file-move-outline::before {
+ content: "\F0053";
+}
+.mdi-file-multiple::before {
+ content: "\F222";
+}
+.mdi-file-multiple-outline::before {
+ content: "\F0054";
+}
+.mdi-file-music::before {
+ content: "\F223";
+}
+.mdi-file-music-outline::before {
+ content: "\FE7C";
+}
+.mdi-file-outline::before {
+ content: "\F224";
+}
+.mdi-file-pdf::before {
+ content: "\F225";
+}
+.mdi-file-pdf-box::before {
+ content: "\F226";
+}
+.mdi-file-pdf-box-outline::before {
+ content: "\FFD3";
+}
+.mdi-file-pdf-outline::before {
+ content: "\FE7D";
+}
+.mdi-file-percent::before {
+ content: "\F81D";
+}
+.mdi-file-percent-outline::before {
+ content: "\F0055";
+}
+.mdi-file-phone::before {
+ content: "\F01A4";
+}
+.mdi-file-phone-outline::before {
+ content: "\F01A5";
+}
+.mdi-file-plus::before {
+ content: "\F751";
+}
+.mdi-file-plus-outline::before {
+ content: "\FF0A";
+}
+.mdi-file-powerpoint::before {
+ content: "\F227";
+}
+.mdi-file-powerpoint-box::before {
+ content: "\F228";
+}
+.mdi-file-powerpoint-box-outline::before {
+ content: "\F0056";
+}
+.mdi-file-powerpoint-outline::before {
+ content: "\F0057";
+}
+.mdi-file-presentation-box::before {
+ content: "\F229";
+}
+.mdi-file-question::before {
+ content: "\F86E";
+}
+.mdi-file-question-outline::before {
+ content: "\F0058";
+}
+.mdi-file-remove::before {
+ content: "\FB74";
+}
+.mdi-file-remove-outline::before {
+ content: "\F0059";
+}
+.mdi-file-replace::before {
+ content: "\FB17";
+}
+.mdi-file-replace-outline::before {
+ content: "\FB18";
+}
+.mdi-file-restore::before {
+ content: "\F670";
+}
+.mdi-file-restore-outline::before {
+ content: "\F005A";
+}
+.mdi-file-search::before {
+ content: "\FC58";
+}
+.mdi-file-search-outline::before {
+ content: "\FC59";
+}
+.mdi-file-send::before {
+ content: "\F22A";
+}
+.mdi-file-send-outline::before {
+ content: "\F005B";
+}
+.mdi-file-settings::before {
+ content: "\F00A4";
+}
+.mdi-file-settings-outline::before {
+ content: "\F00A5";
+}
+.mdi-file-settings-variant::before {
+ content: "\F00A6";
+}
+.mdi-file-settings-variant-outline::before {
+ content: "\F00A7";
+}
+.mdi-file-star::before {
+ content: "\F005C";
+}
+.mdi-file-star-outline::before {
+ content: "\F005D";
+}
+.mdi-file-swap::before {
+ content: "\FFD4";
+}
+.mdi-file-swap-outline::before {
+ content: "\FFD5";
+}
+.mdi-file-sync::before {
+ content: "\F0241";
+}
+.mdi-file-sync-outline::before {
+ content: "\F0242";
+}
+.mdi-file-table::before {
+ content: "\FC5A";
+}
+.mdi-file-table-box::before {
+ content: "\F010C";
+}
+.mdi-file-table-box-multiple::before {
+ content: "\F010D";
+}
+.mdi-file-table-box-multiple-outline::before {
+ content: "\F010E";
+}
+.mdi-file-table-box-outline::before {
+ content: "\F010F";
+}
+.mdi-file-table-outline::before {
+ content: "\FC5B";
+}
+.mdi-file-tree::before {
+ content: "\F645";
+}
+.mdi-file-undo::before {
+ content: "\F8DB";
+}
+.mdi-file-undo-outline::before {
+ content: "\F005E";
+}
+.mdi-file-upload::before {
+ content: "\FA4C";
+}
+.mdi-file-upload-outline::before {
+ content: "\FA4D";
+}
+.mdi-file-video::before {
+ content: "\F22B";
+}
+.mdi-file-video-outline::before {
+ content: "\FE10";
+}
+.mdi-file-word::before {
+ content: "\F22C";
+}
+.mdi-file-word-box::before {
+ content: "\F22D";
+}
+.mdi-file-word-box-outline::before {
+ content: "\F005F";
+}
+.mdi-file-word-outline::before {
+ content: "\F0060";
+}
+.mdi-film::before {
+ content: "\F22F";
+}
+.mdi-filmstrip::before {
+ content: "\F230";
+}
+.mdi-filmstrip-off::before {
+ content: "\F231";
+}
+.mdi-filter::before {
+ content: "\F232";
+}
+.mdi-filter-menu::before {
+ content: "\F0110";
+}
+.mdi-filter-menu-outline::before {
+ content: "\F0111";
+}
+.mdi-filter-minus::before {
+ content: "\FF0B";
+}
+.mdi-filter-minus-outline::before {
+ content: "\FF0C";
+}
+.mdi-filter-outline::before {
+ content: "\F233";
+}
+.mdi-filter-plus::before {
+ content: "\FF0D";
+}
+.mdi-filter-plus-outline::before {
+ content: "\FF0E";
+}
+.mdi-filter-remove::before {
+ content: "\F234";
+}
+.mdi-filter-remove-outline::before {
+ content: "\F235";
+}
+.mdi-filter-variant::before {
+ content: "\F236";
+}
+.mdi-filter-variant-minus::before {
+ content: "\F013D";
+}
+.mdi-filter-variant-plus::before {
+ content: "\F013E";
+}
+.mdi-filter-variant-remove::before {
+ content: "\F0061";
+}
+.mdi-finance::before {
+ content: "\F81E";
+}
+.mdi-find-replace::before {
+ content: "\F6D3";
+}
+.mdi-fingerprint::before {
+ content: "\F237";
+}
+.mdi-fingerprint-off::before {
+ content: "\FECE";
+}
+.mdi-fire::before {
+ content: "\F238";
+}
+.mdi-fire-extinguisher::before {
+ content: "\FF0F";
+}
+.mdi-fire-hydrant::before {
+ content: "\F0162";
+}
+.mdi-fire-hydrant-alert::before {
+ content: "\F0163";
+}
+.mdi-fire-hydrant-off::before {
+ content: "\F0164";
+}
+.mdi-fire-truck::before {
+ content: "\F8AA";
+}
+.mdi-firebase::before {
+ content: "\F966";
+}
+.mdi-firefox::before {
+ content: "\F239";
+}
+.mdi-fireplace::before {
+ content: "\FE11";
+}
+.mdi-fireplace-off::before {
+ content: "\FE12";
+}
+.mdi-firework::before {
+ content: "\FE13";
+}
+.mdi-fish::before {
+ content: "\F23A";
+}
+.mdi-fishbowl::before {
+ content: "\FF10";
+}
+.mdi-fishbowl-outline::before {
+ content: "\FF11";
+}
+.mdi-fit-to-page::before {
+ content: "\FF12";
+}
+.mdi-fit-to-page-outline::before {
+ content: "\FF13";
+}
+.mdi-flag::before {
+ content: "\F23B";
+}
+.mdi-flag-checkered::before {
+ content: "\F23C";
+}
+.mdi-flag-minus::before {
+ content: "\FB75";
+}
+.mdi-flag-minus-outline::before {
+ content: "\F00DD";
+}
+.mdi-flag-outline::before {
+ content: "\F23D";
+}
+.mdi-flag-plus::before {
+ content: "\FB76";
+}
+.mdi-flag-plus-outline::before {
+ content: "\F00DE";
+}
+.mdi-flag-remove::before {
+ content: "\FB77";
+}
+.mdi-flag-remove-outline::before {
+ content: "\F00DF";
+}
+.mdi-flag-triangle::before {
+ content: "\F23F";
+}
+.mdi-flag-variant::before {
+ content: "\F240";
+}
+.mdi-flag-variant-outline::before {
+ content: "\F23E";
+}
+.mdi-flare::before {
+ content: "\FD4E";
+}
+.mdi-flash::before {
+ content: "\F241";
+}
+.mdi-flash-alert::before {
+ content: "\FF14";
+}
+.mdi-flash-alert-outline::before {
+ content: "\FF15";
+}
+.mdi-flash-auto::before {
+ content: "\F242";
+}
+.mdi-flash-circle::before {
+ content: "\F81F";
+}
+.mdi-flash-off::before {
+ content: "\F243";
+}
+.mdi-flash-outline::before {
+ content: "\F6D4";
+}
+.mdi-flash-red-eye::before {
+ content: "\F67A";
+}
+.mdi-flashlight::before {
+ content: "\F244";
+}
+.mdi-flashlight-off::before {
+ content: "\F245";
+}
+.mdi-flask::before {
+ content: "\F093";
+}
+.mdi-flask-empty::before {
+ content: "\F094";
+}
+.mdi-flask-empty-minus::before {
+ content: "\F0265";
+}
+.mdi-flask-empty-minus-outline::before {
+ content: "\F0266";
+}
+.mdi-flask-empty-outline::before {
+ content: "\F095";
+}
+.mdi-flask-empty-plus::before {
+ content: "\F0267";
+}
+.mdi-flask-empty-plus-outline::before {
+ content: "\F0268";
+}
+.mdi-flask-empty-remove::before {
+ content: "\F0269";
+}
+.mdi-flask-empty-remove-outline::before {
+ content: "\F026A";
+}
+.mdi-flask-minus::before {
+ content: "\F026B";
+}
+.mdi-flask-minus-outline::before {
+ content: "\F026C";
+}
+.mdi-flask-outline::before {
+ content: "\F096";
+}
+.mdi-flask-plus::before {
+ content: "\F026D";
+}
+.mdi-flask-plus-outline::before {
+ content: "\F026E";
+}
+.mdi-flask-remove::before {
+ content: "\F026F";
+}
+.mdi-flask-remove-outline::before {
+ content: "\F0270";
+}
+.mdi-flask-round-bottom::before {
+ content: "\F0276";
+}
+.mdi-flask-round-bottom-empty::before {
+ content: "\F0277";
+}
+.mdi-flask-round-bottom-empty-outline::before {
+ content: "\F0278";
+}
+.mdi-flask-round-bottom-outline::before {
+ content: "\F0279";
+}
+.mdi-flattr::before {
+ content: "\F246";
+}
+.mdi-fleur-de-lis::before {
+ content: "\F032E";
+}
+.mdi-flickr::before {
+ content: "\FCE3";
+}
+.mdi-flip-horizontal::before {
+ content: "\F0112";
+}
+.mdi-flip-to-back::before {
+ content: "\F247";
+}
+.mdi-flip-to-front::before {
+ content: "\F248";
+}
+.mdi-flip-vertical::before {
+ content: "\F0113";
+}
+.mdi-floor-lamp::before {
+ content: "\F8DC";
+}
+.mdi-floor-lamp-dual::before {
+ content: "\F0062";
+}
+.mdi-floor-lamp-variant::before {
+ content: "\F0063";
+}
+.mdi-floor-plan::before {
+ content: "\F820";
+}
+.mdi-floppy::before {
+ content: "\F249";
+}
+.mdi-floppy-variant::before {
+ content: "\F9EE";
+}
+.mdi-flower::before {
+ content: "\F24A";
+}
+.mdi-flower-outline::before {
+ content: "\F9EF";
+}
+.mdi-flower-poppy::before {
+ content: "\FCE4";
+}
+.mdi-flower-tulip::before {
+ content: "\F9F0";
+}
+.mdi-flower-tulip-outline::before {
+ content: "\F9F1";
+}
+.mdi-focus-auto::before {
+ content: "\FF6B";
+}
+.mdi-focus-field::before {
+ content: "\FF6C";
+}
+.mdi-focus-field-horizontal::before {
+ content: "\FF6D";
+}
+.mdi-focus-field-vertical::before {
+ content: "\FF6E";
+}
+.mdi-folder::before {
+ content: "\F24B";
+}
+.mdi-folder-account::before {
+ content: "\F24C";
+}
+.mdi-folder-account-outline::before {
+ content: "\FB78";
+}
+.mdi-folder-alert::before {
+ content: "\FDA8";
+}
+.mdi-folder-alert-outline::before {
+ content: "\FDA9";
+}
+.mdi-folder-clock::before {
+ content: "\FAB9";
+}
+.mdi-folder-clock-outline::before {
+ content: "\FABA";
+}
+.mdi-folder-download::before {
+ content: "\F24D";
+}
+.mdi-folder-download-outline::before {
+ content: "\F0114";
+}
+.mdi-folder-edit::before {
+ content: "\F8DD";
+}
+.mdi-folder-edit-outline::before {
+ content: "\FDAA";
+}
+.mdi-folder-google-drive::before {
+ content: "\F24E";
+}
+.mdi-folder-heart::before {
+ content: "\F0115";
+}
+.mdi-folder-heart-outline::before {
+ content: "\F0116";
+}
+.mdi-folder-home::before {
+ content: "\F00E0";
+}
+.mdi-folder-home-outline::before {
+ content: "\F00E1";
+}
+.mdi-folder-image::before {
+ content: "\F24F";
+}
+.mdi-folder-information::before {
+ content: "\F00E2";
+}
+.mdi-folder-information-outline::before {
+ content: "\F00E3";
+}
+.mdi-folder-key::before {
+ content: "\F8AB";
+}
+.mdi-folder-key-network::before {
+ content: "\F8AC";
+}
+.mdi-folder-key-network-outline::before {
+ content: "\FC5C";
+}
+.mdi-folder-key-outline::before {
+ content: "\F0117";
+}
+.mdi-folder-lock::before {
+ content: "\F250";
+}
+.mdi-folder-lock-open::before {
+ content: "\F251";
+}
+.mdi-folder-marker::before {
+ content: "\F0298";
+}
+.mdi-folder-marker-outline::before {
+ content: "\F0299";
+}
+.mdi-folder-move::before {
+ content: "\F252";
+}
+.mdi-folder-move-outline::before {
+ content: "\F0271";
+}
+.mdi-folder-multiple::before {
+ content: "\F253";
+}
+.mdi-folder-multiple-image::before {
+ content: "\F254";
+}
+.mdi-folder-multiple-outline::before {
+ content: "\F255";
+}
+.mdi-folder-music::before {
+ content: "\F0384";
+}
+.mdi-folder-music-outline::before {
+ content: "\F0385";
+}
+.mdi-folder-network::before {
+ content: "\F86F";
+}
+.mdi-folder-network-outline::before {
+ content: "\FC5D";
+}
+.mdi-folder-open::before {
+ content: "\F76F";
+}
+.mdi-folder-open-outline::before {
+ content: "\FDAB";
+}
+.mdi-folder-outline::before {
+ content: "\F256";
+}
+.mdi-folder-plus::before {
+ content: "\F257";
+}
+.mdi-folder-plus-outline::before {
+ content: "\FB79";
+}
+.mdi-folder-pound::before {
+ content: "\FCE5";
+}
+.mdi-folder-pound-outline::before {
+ content: "\FCE6";
+}
+.mdi-folder-remove::before {
+ content: "\F258";
+}
+.mdi-folder-remove-outline::before {
+ content: "\FB7A";
+}
+.mdi-folder-search::before {
+ content: "\F967";
+}
+.mdi-folder-search-outline::before {
+ content: "\F968";
+}
+.mdi-folder-settings::before {
+ content: "\F00A8";
+}
+.mdi-folder-settings-outline::before {
+ content: "\F00A9";
+}
+.mdi-folder-settings-variant::before {
+ content: "\F00AA";
+}
+.mdi-folder-settings-variant-outline::before {
+ content: "\F00AB";
+}
+.mdi-folder-star::before {
+ content: "\F69C";
+}
+.mdi-folder-star-outline::before {
+ content: "\FB7B";
+}
+.mdi-folder-swap::before {
+ content: "\FFD6";
+}
+.mdi-folder-swap-outline::before {
+ content: "\FFD7";
+}
+.mdi-folder-sync::before {
+ content: "\FCE7";
+}
+.mdi-folder-sync-outline::before {
+ content: "\FCE8";
+}
+.mdi-folder-table::before {
+ content: "\F030E";
+}
+.mdi-folder-table-outline::before {
+ content: "\F030F";
+}
+.mdi-folder-text::before {
+ content: "\FC5E";
+}
+.mdi-folder-text-outline::before {
+ content: "\FC5F";
+}
+.mdi-folder-upload::before {
+ content: "\F259";
+}
+.mdi-folder-upload-outline::before {
+ content: "\F0118";
+}
+.mdi-folder-zip::before {
+ content: "\F6EA";
+}
+.mdi-folder-zip-outline::before {
+ content: "\F7B8";
+}
+.mdi-font-awesome::before {
+ content: "\F03A";
+}
+.mdi-food::before {
+ content: "\F25A";
+}
+.mdi-food-apple::before {
+ content: "\F25B";
+}
+.mdi-food-apple-outline::before {
+ content: "\FC60";
+}
+.mdi-food-croissant::before {
+ content: "\F7C7";
+}
+.mdi-food-fork-drink::before {
+ content: "\F5F2";
+}
+.mdi-food-off::before {
+ content: "\F5F3";
+}
+.mdi-food-variant::before {
+ content: "\F25C";
+}
+.mdi-foot-print::before {
+ content: "\FF6F";
+}
+.mdi-football::before {
+ content: "\F25D";
+}
+.mdi-football-australian::before {
+ content: "\F25E";
+}
+.mdi-football-helmet::before {
+ content: "\F25F";
+}
+.mdi-forklift::before {
+ content: "\F7C8";
+}
+.mdi-format-align-bottom::before {
+ content: "\F752";
+}
+.mdi-format-align-center::before {
+ content: "\F260";
+}
+.mdi-format-align-justify::before {
+ content: "\F261";
+}
+.mdi-format-align-left::before {
+ content: "\F262";
+}
+.mdi-format-align-middle::before {
+ content: "\F753";
+}
+.mdi-format-align-right::before {
+ content: "\F263";
+}
+.mdi-format-align-top::before {
+ content: "\F754";
+}
+.mdi-format-annotation-minus::before {
+ content: "\FABB";
+}
+.mdi-format-annotation-plus::before {
+ content: "\F646";
+}
+.mdi-format-bold::before {
+ content: "\F264";
+}
+.mdi-format-clear::before {
+ content: "\F265";
+}
+.mdi-format-color-fill::before {
+ content: "\F266";
+}
+.mdi-format-color-highlight::before {
+ content: "\FE14";
+}
+.mdi-format-color-marker-cancel::before {
+ content: "\F033E";
+}
+.mdi-format-color-text::before {
+ content: "\F69D";
+}
+.mdi-format-columns::before {
+ content: "\F8DE";
+}
+.mdi-format-float-center::before {
+ content: "\F267";
+}
+.mdi-format-float-left::before {
+ content: "\F268";
+}
+.mdi-format-float-none::before {
+ content: "\F269";
+}
+.mdi-format-float-right::before {
+ content: "\F26A";
+}
+.mdi-format-font::before {
+ content: "\F6D5";
+}
+.mdi-format-font-size-decrease::before {
+ content: "\F9F2";
+}
+.mdi-format-font-size-increase::before {
+ content: "\F9F3";
+}
+.mdi-format-header-1::before {
+ content: "\F26B";
+}
+.mdi-format-header-2::before {
+ content: "\F26C";
+}
+.mdi-format-header-3::before {
+ content: "\F26D";
+}
+.mdi-format-header-4::before {
+ content: "\F26E";
+}
+.mdi-format-header-5::before {
+ content: "\F26F";
+}
+.mdi-format-header-6::before {
+ content: "\F270";
+}
+.mdi-format-header-decrease::before {
+ content: "\F271";
+}
+.mdi-format-header-equal::before {
+ content: "\F272";
+}
+.mdi-format-header-increase::before {
+ content: "\F273";
+}
+.mdi-format-header-pound::before {
+ content: "\F274";
+}
+.mdi-format-horizontal-align-center::before {
+ content: "\F61E";
+}
+.mdi-format-horizontal-align-left::before {
+ content: "\F61F";
+}
+.mdi-format-horizontal-align-right::before {
+ content: "\F620";
+}
+.mdi-format-indent-decrease::before {
+ content: "\F275";
+}
+.mdi-format-indent-increase::before {
+ content: "\F276";
+}
+.mdi-format-italic::before {
+ content: "\F277";
+}
+.mdi-format-letter-case::before {
+ content: "\FB19";
+}
+.mdi-format-letter-case-lower::before {
+ content: "\FB1A";
+}
+.mdi-format-letter-case-upper::before {
+ content: "\FB1B";
+}
+.mdi-format-letter-ends-with::before {
+ content: "\FFD8";
+}
+.mdi-format-letter-matches::before {
+ content: "\FFD9";
+}
+.mdi-format-letter-starts-with::before {
+ content: "\FFDA";
+}
+.mdi-format-line-spacing::before {
+ content: "\F278";
+}
+.mdi-format-line-style::before {
+ content: "\F5C8";
+}
+.mdi-format-line-weight::before {
+ content: "\F5C9";
+}
+.mdi-format-list-bulleted::before {
+ content: "\F279";
+}
+.mdi-format-list-bulleted-square::before {
+ content: "\FDAC";
+}
+.mdi-format-list-bulleted-triangle::before {
+ content: "\FECF";
+}
+.mdi-format-list-bulleted-type::before {
+ content: "\F27A";
+}
+.mdi-format-list-checkbox::before {
+ content: "\F969";
+}
+.mdi-format-list-checks::before {
+ content: "\F755";
+}
+.mdi-format-list-numbered::before {
+ content: "\F27B";
+}
+.mdi-format-list-numbered-rtl::before {
+ content: "\FCE9";
+}
+.mdi-format-list-text::before {
+ content: "\F029A";
+}
+.mdi-format-overline::before {
+ content: "\FED0";
+}
+.mdi-format-page-break::before {
+ content: "\F6D6";
+}
+.mdi-format-paint::before {
+ content: "\F27C";
+}
+.mdi-format-paragraph::before {
+ content: "\F27D";
+}
+.mdi-format-pilcrow::before {
+ content: "\F6D7";
+}
+.mdi-format-quote-close::before {
+ content: "\F27E";
+}
+.mdi-format-quote-close-outline::before {
+ content: "\F01D3";
+}
+.mdi-format-quote-open::before {
+ content: "\F756";
+}
+.mdi-format-quote-open-outline::before {
+ content: "\F01D2";
+}
+.mdi-format-rotate-90::before {
+ content: "\F6A9";
+}
+.mdi-format-section::before {
+ content: "\F69E";
+}
+.mdi-format-size::before {
+ content: "\F27F";
+}
+.mdi-format-strikethrough::before {
+ content: "\F280";
+}
+.mdi-format-strikethrough-variant::before {
+ content: "\F281";
+}
+.mdi-format-subscript::before {
+ content: "\F282";
+}
+.mdi-format-superscript::before {
+ content: "\F283";
+}
+.mdi-format-text::before {
+ content: "\F284";
+}
+.mdi-format-text-rotation-angle-down::before {
+ content: "\FFDB";
+}
+.mdi-format-text-rotation-angle-up::before {
+ content: "\FFDC";
+}
+.mdi-format-text-rotation-down::before {
+ content: "\FD4F";
+}
+.mdi-format-text-rotation-down-vertical::before {
+ content: "\FFDD";
+}
+.mdi-format-text-rotation-none::before {
+ content: "\FD50";
+}
+.mdi-format-text-rotation-up::before {
+ content: "\FFDE";
+}
+.mdi-format-text-rotation-vertical::before {
+ content: "\FFDF";
+}
+.mdi-format-text-variant::before {
+ content: "\FE15";
+}
+.mdi-format-text-wrapping-clip::before {
+ content: "\FCEA";
+}
+.mdi-format-text-wrapping-overflow::before {
+ content: "\FCEB";
+}
+.mdi-format-text-wrapping-wrap::before {
+ content: "\FCEC";
+}
+.mdi-format-textbox::before {
+ content: "\FCED";
+}
+.mdi-format-textdirection-l-to-r::before {
+ content: "\F285";
+}
+.mdi-format-textdirection-r-to-l::before {
+ content: "\F286";
+}
+.mdi-format-title::before {
+ content: "\F5F4";
+}
+.mdi-format-underline::before {
+ content: "\F287";
+}
+.mdi-format-vertical-align-bottom::before {
+ content: "\F621";
+}
+.mdi-format-vertical-align-center::before {
+ content: "\F622";
+}
+.mdi-format-vertical-align-top::before {
+ content: "\F623";
+}
+.mdi-format-wrap-inline::before {
+ content: "\F288";
+}
+.mdi-format-wrap-square::before {
+ content: "\F289";
+}
+.mdi-format-wrap-tight::before {
+ content: "\F28A";
+}
+.mdi-format-wrap-top-bottom::before {
+ content: "\F28B";
+}
+.mdi-forum::before {
+ content: "\F28C";
+}
+.mdi-forum-outline::before {
+ content: "\F821";
+}
+.mdi-forward::before {
+ content: "\F28D";
+}
+.mdi-forwardburger::before {
+ content: "\FD51";
+}
+.mdi-fountain::before {
+ content: "\F96A";
+}
+.mdi-fountain-pen::before {
+ content: "\FCEE";
+}
+.mdi-fountain-pen-tip::before {
+ content: "\FCEF";
+}
+.mdi-foursquare::before {
+ content: "\F28E";
+}
+.mdi-freebsd::before {
+ content: "\F8DF";
+}
+.mdi-frequently-asked-questions::before {
+ content: "\FED1";
+}
+.mdi-fridge::before {
+ content: "\F290";
+}
+.mdi-fridge-alert::before {
+ content: "\F01DC";
+}
+.mdi-fridge-alert-outline::before {
+ content: "\F01DD";
+}
+.mdi-fridge-bottom::before {
+ content: "\F292";
+}
+.mdi-fridge-off::before {
+ content: "\F01DA";
+}
+.mdi-fridge-off-outline::before {
+ content: "\F01DB";
+}
+.mdi-fridge-outline::before {
+ content: "\F28F";
+}
+.mdi-fridge-top::before {
+ content: "\F291";
+}
+.mdi-fruit-cherries::before {
+ content: "\F0064";
+}
+.mdi-fruit-citrus::before {
+ content: "\F0065";
+}
+.mdi-fruit-grapes::before {
+ content: "\F0066";
+}
+.mdi-fruit-grapes-outline::before {
+ content: "\F0067";
+}
+.mdi-fruit-pineapple::before {
+ content: "\F0068";
+}
+.mdi-fruit-watermelon::before {
+ content: "\F0069";
+}
+.mdi-fuel::before {
+ content: "\F7C9";
+}
+.mdi-fullscreen::before {
+ content: "\F293";
+}
+.mdi-fullscreen-exit::before {
+ content: "\F294";
+}
+.mdi-function::before {
+ content: "\F295";
+}
+.mdi-function-variant::before {
+ content: "\F870";
+}
+.mdi-furigana-horizontal::before {
+ content: "\F00AC";
+}
+.mdi-furigana-vertical::before {
+ content: "\F00AD";
+}
+.mdi-fuse::before {
+ content: "\FC61";
+}
+.mdi-fuse-blade::before {
+ content: "\FC62";
+}
+.mdi-gamepad::before {
+ content: "\F296";
+}
+.mdi-gamepad-circle::before {
+ content: "\FE16";
+}
+.mdi-gamepad-circle-down::before {
+ content: "\FE17";
+}
+.mdi-gamepad-circle-left::before {
+ content: "\FE18";
+}
+.mdi-gamepad-circle-outline::before {
+ content: "\FE19";
+}
+.mdi-gamepad-circle-right::before {
+ content: "\FE1A";
+}
+.mdi-gamepad-circle-up::before {
+ content: "\FE1B";
+}
+.mdi-gamepad-down::before {
+ content: "\FE1C";
+}
+.mdi-gamepad-left::before {
+ content: "\FE1D";
+}
+.mdi-gamepad-right::before {
+ content: "\FE1E";
+}
+.mdi-gamepad-round::before {
+ content: "\FE1F";
+}
+.mdi-gamepad-round-down::before {
+ content: "\FE7E";
+}
+.mdi-gamepad-round-left::before {
+ content: "\FE7F";
+}
+.mdi-gamepad-round-outline::before {
+ content: "\FE80";
+}
+.mdi-gamepad-round-right::before {
+ content: "\FE81";
+}
+.mdi-gamepad-round-up::before {
+ content: "\FE82";
+}
+.mdi-gamepad-square::before {
+ content: "\FED2";
+}
+.mdi-gamepad-square-outline::before {
+ content: "\FED3";
+}
+.mdi-gamepad-up::before {
+ content: "\FE83";
+}
+.mdi-gamepad-variant::before {
+ content: "\F297";
+}
+.mdi-gamepad-variant-outline::before {
+ content: "\FED4";
+}
+.mdi-gamma::before {
+ content: "\F0119";
+}
+.mdi-gantry-crane::before {
+ content: "\FDAD";
+}
+.mdi-garage::before {
+ content: "\F6D8";
+}
+.mdi-garage-alert::before {
+ content: "\F871";
+}
+.mdi-garage-alert-variant::before {
+ content: "\F0300";
+}
+.mdi-garage-open::before {
+ content: "\F6D9";
+}
+.mdi-garage-open-variant::before {
+ content: "\F02FF";
+}
+.mdi-garage-variant::before {
+ content: "\F02FE";
+}
+.mdi-gas-cylinder::before {
+ content: "\F647";
+}
+.mdi-gas-station::before {
+ content: "\F298";
+}
+.mdi-gas-station-outline::before {
+ content: "\FED5";
+}
+.mdi-gate::before {
+ content: "\F299";
+}
+.mdi-gate-and::before {
+ content: "\F8E0";
+}
+.mdi-gate-arrow-right::before {
+ content: "\F0194";
+}
+.mdi-gate-nand::before {
+ content: "\F8E1";
+}
+.mdi-gate-nor::before {
+ content: "\F8E2";
+}
+.mdi-gate-not::before {
+ content: "\F8E3";
+}
+.mdi-gate-open::before {
+ content: "\F0195";
+}
+.mdi-gate-or::before {
+ content: "\F8E4";
+}
+.mdi-gate-xnor::before {
+ content: "\F8E5";
+}
+.mdi-gate-xor::before {
+ content: "\F8E6";
+}
+.mdi-gatsby::before {
+ content: "\FE84";
+}
+.mdi-gauge::before {
+ content: "\F29A";
+}
+.mdi-gauge-empty::before {
+ content: "\F872";
+}
+.mdi-gauge-full::before {
+ content: "\F873";
+}
+.mdi-gauge-low::before {
+ content: "\F874";
+}
+.mdi-gavel::before {
+ content: "\F29B";
+}
+.mdi-gender-female::before {
+ content: "\F29C";
+}
+.mdi-gender-male::before {
+ content: "\F29D";
+}
+.mdi-gender-male-female::before {
+ content: "\F29E";
+}
+.mdi-gender-male-female-variant::before {
+ content: "\F016A";
+}
+.mdi-gender-non-binary::before {
+ content: "\F016B";
+}
+.mdi-gender-transgender::before {
+ content: "\F29F";
+}
+.mdi-gentoo::before {
+ content: "\F8E7";
+}
+.mdi-gesture::before {
+ content: "\F7CA";
+}
+.mdi-gesture-double-tap::before {
+ content: "\F73B";
+}
+.mdi-gesture-pinch::before {
+ content: "\FABC";
+}
+.mdi-gesture-spread::before {
+ content: "\FABD";
+}
+.mdi-gesture-swipe::before {
+ content: "\FD52";
+}
+.mdi-gesture-swipe-down::before {
+ content: "\F73C";
+}
+.mdi-gesture-swipe-horizontal::before {
+ content: "\FABE";
+}
+.mdi-gesture-swipe-left::before {
+ content: "\F73D";
+}
+.mdi-gesture-swipe-right::before {
+ content: "\F73E";
+}
+.mdi-gesture-swipe-up::before {
+ content: "\F73F";
+}
+.mdi-gesture-swipe-vertical::before {
+ content: "\FABF";
+}
+.mdi-gesture-tap::before {
+ content: "\F740";
+}
+.mdi-gesture-tap-box::before {
+ content: "\F02D4";
+}
+.mdi-gesture-tap-button::before {
+ content: "\F02D3";
+}
+.mdi-gesture-tap-hold::before {
+ content: "\FD53";
+}
+.mdi-gesture-two-double-tap::before {
+ content: "\F741";
+}
+.mdi-gesture-two-tap::before {
+ content: "\F742";
+}
+.mdi-ghost::before {
+ content: "\F2A0";
+}
+.mdi-ghost-off::before {
+ content: "\F9F4";
+}
+.mdi-gif::before {
+ content: "\FD54";
+}
+.mdi-gift::before {
+ content: "\FE85";
+}
+.mdi-gift-outline::before {
+ content: "\F2A1";
+}
+.mdi-git::before {
+ content: "\F2A2";
+}
+.mdi-github-box::before {
+ content: "\F2A3";
+}
+.mdi-github-circle::before {
+ content: "\F2A4";
+}
+.mdi-github-face::before {
+ content: "\F6DA";
+}
+.mdi-gitlab::before {
+ content: "\FB7C";
+}
+.mdi-glass-cocktail::before {
+ content: "\F356";
+}
+.mdi-glass-flute::before {
+ content: "\F2A5";
+}
+.mdi-glass-mug::before {
+ content: "\F2A6";
+}
+.mdi-glass-mug-variant::before {
+ content: "\F0141";
+}
+.mdi-glass-pint-outline::before {
+ content: "\F0338";
+}
+.mdi-glass-stange::before {
+ content: "\F2A7";
+}
+.mdi-glass-tulip::before {
+ content: "\F2A8";
+}
+.mdi-glass-wine::before {
+ content: "\F875";
+}
+.mdi-glassdoor::before {
+ content: "\F2A9";
+}
+.mdi-glasses::before {
+ content: "\F2AA";
+}
+.mdi-globe-light::before {
+ content: "\F0302";
+}
+.mdi-globe-model::before {
+ content: "\F8E8";
+}
+.mdi-gmail::before {
+ content: "\F2AB";
+}
+.mdi-gnome::before {
+ content: "\F2AC";
+}
+.mdi-go-kart::before {
+ content: "\FD55";
+}
+.mdi-go-kart-track::before {
+ content: "\FD56";
+}
+.mdi-gog::before {
+ content: "\FB7D";
+}
+.mdi-gold::before {
+ content: "\F027A";
+}
+.mdi-golf::before {
+ content: "\F822";
+}
+.mdi-golf-cart::before {
+ content: "\F01CF";
+}
+.mdi-golf-tee::before {
+ content: "\F00AE";
+}
+.mdi-gondola::before {
+ content: "\F685";
+}
+.mdi-goodreads::before {
+ content: "\FD57";
+}
+.mdi-google::before {
+ content: "\F2AD";
+}
+.mdi-google-adwords::before {
+ content: "\FC63";
+}
+.mdi-google-analytics::before {
+ content: "\F7CB";
+}
+.mdi-google-assistant::before {
+ content: "\F7CC";
+}
+.mdi-google-cardboard::before {
+ content: "\F2AE";
+}
+.mdi-google-chrome::before {
+ content: "\F2AF";
+}
+.mdi-google-circles::before {
+ content: "\F2B0";
+}
+.mdi-google-circles-communities::before {
+ content: "\F2B1";
+}
+.mdi-google-circles-extended::before {
+ content: "\F2B2";
+}
+.mdi-google-circles-group::before {
+ content: "\F2B3";
+}
+.mdi-google-classroom::before {
+ content: "\F2C0";
+}
+.mdi-google-cloud::before {
+ content: "\F0221";
+}
+.mdi-google-controller::before {
+ content: "\F2B4";
+}
+.mdi-google-controller-off::before {
+ content: "\F2B5";
+}
+.mdi-google-downasaur::before {
+ content: "\F038D";
+}
+.mdi-google-drive::before {
+ content: "\F2B6";
+}
+.mdi-google-earth::before {
+ content: "\F2B7";
+}
+.mdi-google-fit::before {
+ content: "\F96B";
+}
+.mdi-google-glass::before {
+ content: "\F2B8";
+}
+.mdi-google-hangouts::before {
+ content: "\F2C9";
+}
+.mdi-google-home::before {
+ content: "\F823";
+}
+.mdi-google-keep::before {
+ content: "\F6DB";
+}
+.mdi-google-lens::before {
+ content: "\F9F5";
+}
+.mdi-google-maps::before {
+ content: "\F5F5";
+}
+.mdi-google-my-business::before {
+ content: "\F006A";
+}
+.mdi-google-nearby::before {
+ content: "\F2B9";
+}
+.mdi-google-pages::before {
+ content: "\F2BA";
+}
+.mdi-google-photos::before {
+ content: "\F6DC";
+}
+.mdi-google-physical-web::before {
+ content: "\F2BB";
+}
+.mdi-google-play::before {
+ content: "\F2BC";
+}
+.mdi-google-plus::before {
+ content: "\F2BD";
+}
+.mdi-google-plus-box::before {
+ content: "\F2BE";
+}
+.mdi-google-podcast::before {
+ content: "\FED6";
+}
+.mdi-google-spreadsheet::before {
+ content: "\F9F6";
+}
+.mdi-google-street-view::before {
+ content: "\FC64";
+}
+.mdi-google-translate::before {
+ content: "\F2BF";
+}
+.mdi-gradient::before {
+ content: "\F69F";
+}
+.mdi-grain::before {
+ content: "\FD58";
+}
+.mdi-graph::before {
+ content: "\F006B";
+}
+.mdi-graph-outline::before {
+ content: "\F006C";
+}
+.mdi-graphql::before {
+ content: "\F876";
+}
+.mdi-grave-stone::before {
+ content: "\FB7E";
+}
+.mdi-grease-pencil::before {
+ content: "\F648";
+}
+.mdi-greater-than::before {
+ content: "\F96C";
+}
+.mdi-greater-than-or-equal::before {
+ content: "\F96D";
+}
+.mdi-grid::before {
+ content: "\F2C1";
+}
+.mdi-grid-large::before {
+ content: "\F757";
+}
+.mdi-grid-off::before {
+ content: "\F2C2";
+}
+.mdi-grill::before {
+ content: "\FE86";
+}
+.mdi-grill-outline::before {
+ content: "\F01B5";
+}
+.mdi-group::before {
+ content: "\F2C3";
+}
+.mdi-guitar-acoustic::before {
+ content: "\F770";
+}
+.mdi-guitar-electric::before {
+ content: "\F2C4";
+}
+.mdi-guitar-pick::before {
+ content: "\F2C5";
+}
+.mdi-guitar-pick-outline::before {
+ content: "\F2C6";
+}
+.mdi-guy-fawkes-mask::before {
+ content: "\F824";
+}
+.mdi-hackernews::before {
+ content: "\F624";
+}
+.mdi-hail::before {
+ content: "\FAC0";
+}
+.mdi-hair-dryer::before {
+ content: "\F011A";
+}
+.mdi-hair-dryer-outline::before {
+ content: "\F011B";
+}
+.mdi-halloween::before {
+ content: "\FB7F";
+}
+.mdi-hamburger::before {
+ content: "\F684";
+}
+.mdi-hammer::before {
+ content: "\F8E9";
+}
+.mdi-hammer-screwdriver::before {
+ content: "\F034D";
+}
+.mdi-hammer-wrench::before {
+ content: "\F034E";
+}
+.mdi-hand::before {
+ content: "\FA4E";
+}
+.mdi-hand-heart::before {
+ content: "\F011C";
+}
+.mdi-hand-left::before {
+ content: "\FE87";
+}
+.mdi-hand-okay::before {
+ content: "\FA4F";
+}
+.mdi-hand-peace::before {
+ content: "\FA50";
+}
+.mdi-hand-peace-variant::before {
+ content: "\FA51";
+}
+.mdi-hand-pointing-down::before {
+ content: "\FA52";
+}
+.mdi-hand-pointing-left::before {
+ content: "\FA53";
+}
+.mdi-hand-pointing-right::before {
+ content: "\F2C7";
+}
+.mdi-hand-pointing-up::before {
+ content: "\FA54";
+}
+.mdi-hand-right::before {
+ content: "\FE88";
+}
+.mdi-hand-saw::before {
+ content: "\FE89";
+}
+.mdi-handball::before {
+ content: "\FF70";
+}
+.mdi-handcuffs::before {
+ content: "\F0169";
+}
+.mdi-handshake::before {
+ content: "\F0243";
+}
+.mdi-hanger::before {
+ content: "\F2C8";
+}
+.mdi-hard-hat::before {
+ content: "\F96E";
+}
+.mdi-harddisk::before {
+ content: "\F2CA";
+}
+.mdi-harddisk-plus::before {
+ content: "\F006D";
+}
+.mdi-harddisk-remove::before {
+ content: "\F006E";
+}
+.mdi-hat-fedora::before {
+ content: "\FB80";
+}
+.mdi-hazard-lights::before {
+ content: "\FC65";
+}
+.mdi-hdr::before {
+ content: "\FD59";
+}
+.mdi-hdr-off::before {
+ content: "\FD5A";
+}
+.mdi-head::before {
+ content: "\F0389";
+}
+.mdi-head-alert::before {
+ content: "\F0363";
+}
+.mdi-head-alert-outline::before {
+ content: "\F0364";
+}
+.mdi-head-check::before {
+ content: "\F0365";
+}
+.mdi-head-check-outline::before {
+ content: "\F0366";
+}
+.mdi-head-cog::before {
+ content: "\F0367";
+}
+.mdi-head-cog-outline::before {
+ content: "\F0368";
+}
+.mdi-head-dots-horizontal::before {
+ content: "\F0369";
+}
+.mdi-head-dots-horizontal-outline::before {
+ content: "\F036A";
+}
+.mdi-head-flash::before {
+ content: "\F036B";
+}
+.mdi-head-flash-outline::before {
+ content: "\F036C";
+}
+.mdi-head-heart::before {
+ content: "\F036D";
+}
+.mdi-head-heart-outline::before {
+ content: "\F036E";
+}
+.mdi-head-lightbulb::before {
+ content: "\F036F";
+}
+.mdi-head-lightbulb-outline::before {
+ content: "\F0370";
+}
+.mdi-head-minus::before {
+ content: "\F0371";
+}
+.mdi-head-minus-outline::before {
+ content: "\F0372";
+}
+.mdi-head-outline::before {
+ content: "\F038A";
+}
+.mdi-head-plus::before {
+ content: "\F0373";
+}
+.mdi-head-plus-outline::before {
+ content: "\F0374";
+}
+.mdi-head-question::before {
+ content: "\F0375";
+}
+.mdi-head-question-outline::before {
+ content: "\F0376";
+}
+.mdi-head-remove::before {
+ content: "\F0377";
+}
+.mdi-head-remove-outline::before {
+ content: "\F0378";
+}
+.mdi-head-snowflake::before {
+ content: "\F0379";
+}
+.mdi-head-snowflake-outline::before {
+ content: "\F037A";
+}
+.mdi-head-sync::before {
+ content: "\F037B";
+}
+.mdi-head-sync-outline::before {
+ content: "\F037C";
+}
+.mdi-headphones::before {
+ content: "\F2CB";
+}
+.mdi-headphones-bluetooth::before {
+ content: "\F96F";
+}
+.mdi-headphones-box::before {
+ content: "\F2CC";
+}
+.mdi-headphones-off::before {
+ content: "\F7CD";
+}
+.mdi-headphones-settings::before {
+ content: "\F2CD";
+}
+.mdi-headset::before {
+ content: "\F2CE";
+}
+.mdi-headset-dock::before {
+ content: "\F2CF";
+}
+.mdi-headset-off::before {
+ content: "\F2D0";
+}
+.mdi-heart::before {
+ content: "\F2D1";
+}
+.mdi-heart-box::before {
+ content: "\F2D2";
+}
+.mdi-heart-box-outline::before {
+ content: "\F2D3";
+}
+.mdi-heart-broken::before {
+ content: "\F2D4";
+}
+.mdi-heart-broken-outline::before {
+ content: "\FCF0";
+}
+.mdi-heart-circle::before {
+ content: "\F970";
+}
+.mdi-heart-circle-outline::before {
+ content: "\F971";
+}
+.mdi-heart-flash::before {
+ content: "\FF16";
+}
+.mdi-heart-half::before {
+ content: "\F6DE";
+}
+.mdi-heart-half-full::before {
+ content: "\F6DD";
+}
+.mdi-heart-half-outline::before {
+ content: "\F6DF";
+}
+.mdi-heart-multiple::before {
+ content: "\FA55";
+}
+.mdi-heart-multiple-outline::before {
+ content: "\FA56";
+}
+.mdi-heart-off::before {
+ content: "\F758";
+}
+.mdi-heart-outline::before {
+ content: "\F2D5";
+}
+.mdi-heart-pulse::before {
+ content: "\F5F6";
+}
+.mdi-helicopter::before {
+ content: "\FAC1";
+}
+.mdi-help::before {
+ content: "\F2D6";
+}
+.mdi-help-box::before {
+ content: "\F78A";
+}
+.mdi-help-circle::before {
+ content: "\F2D7";
+}
+.mdi-help-circle-outline::before {
+ content: "\F625";
+}
+.mdi-help-network::before {
+ content: "\F6F4";
+}
+.mdi-help-network-outline::before {
+ content: "\FC66";
+}
+.mdi-help-rhombus::before {
+ content: "\FB81";
+}
+.mdi-help-rhombus-outline::before {
+ content: "\FB82";
+}
+.mdi-hexadecimal::before {
+ content: "\F02D2";
+}
+.mdi-hexagon::before {
+ content: "\F2D8";
+}
+.mdi-hexagon-multiple::before {
+ content: "\F6E0";
+}
+.mdi-hexagon-multiple-outline::before {
+ content: "\F011D";
+}
+.mdi-hexagon-outline::before {
+ content: "\F2D9";
+}
+.mdi-hexagon-slice-1::before {
+ content: "\FAC2";
+}
+.mdi-hexagon-slice-2::before {
+ content: "\FAC3";
+}
+.mdi-hexagon-slice-3::before {
+ content: "\FAC4";
+}
+.mdi-hexagon-slice-4::before {
+ content: "\FAC5";
+}
+.mdi-hexagon-slice-5::before {
+ content: "\FAC6";
+}
+.mdi-hexagon-slice-6::before {
+ content: "\FAC7";
+}
+.mdi-hexagram::before {
+ content: "\FAC8";
+}
+.mdi-hexagram-outline::before {
+ content: "\FAC9";
+}
+.mdi-high-definition::before {
+ content: "\F7CE";
+}
+.mdi-high-definition-box::before {
+ content: "\F877";
+}
+.mdi-highway::before {
+ content: "\F5F7";
+}
+.mdi-hiking::before {
+ content: "\FD5B";
+}
+.mdi-hinduism::before {
+ content: "\F972";
+}
+.mdi-history::before {
+ content: "\F2DA";
+}
+.mdi-hockey-puck::before {
+ content: "\F878";
+}
+.mdi-hockey-sticks::before {
+ content: "\F879";
+}
+.mdi-hololens::before {
+ content: "\F2DB";
+}
+.mdi-home::before {
+ content: "\F2DC";
+}
+.mdi-home-account::before {
+ content: "\F825";
+}
+.mdi-home-alert::before {
+ content: "\F87A";
+}
+.mdi-home-analytics::before {
+ content: "\FED7";
+}
+.mdi-home-assistant::before {
+ content: "\F7CF";
+}
+.mdi-home-automation::before {
+ content: "\F7D0";
+}
+.mdi-home-circle::before {
+ content: "\F7D1";
+}
+.mdi-home-circle-outline::before {
+ content: "\F006F";
+}
+.mdi-home-city::before {
+ content: "\FCF1";
+}
+.mdi-home-city-outline::before {
+ content: "\FCF2";
+}
+.mdi-home-currency-usd::before {
+ content: "\F8AE";
+}
+.mdi-home-edit::before {
+ content: "\F0184";
+}
+.mdi-home-edit-outline::before {
+ content: "\F0185";
+}
+.mdi-home-export-outline::before {
+ content: "\FFB8";
+}
+.mdi-home-flood::before {
+ content: "\FF17";
+}
+.mdi-home-floor-0::before {
+ content: "\FDAE";
+}
+.mdi-home-floor-1::before {
+ content: "\FD5C";
+}
+.mdi-home-floor-2::before {
+ content: "\FD5D";
+}
+.mdi-home-floor-3::before {
+ content: "\FD5E";
+}
+.mdi-home-floor-a::before {
+ content: "\FD5F";
+}
+.mdi-home-floor-b::before {
+ content: "\FD60";
+}
+.mdi-home-floor-g::before {
+ content: "\FD61";
+}
+.mdi-home-floor-l::before {
+ content: "\FD62";
+}
+.mdi-home-floor-negative-1::before {
+ content: "\FDAF";
+}
+.mdi-home-group::before {
+ content: "\FDB0";
+}
+.mdi-home-heart::before {
+ content: "\F826";
+}
+.mdi-home-import-outline::before {
+ content: "\FFB9";
+}
+.mdi-home-lightbulb::before {
+ content: "\F027C";
+}
+.mdi-home-lightbulb-outline::before {
+ content: "\F027D";
+}
+.mdi-home-lock::before {
+ content: "\F8EA";
+}
+.mdi-home-lock-open::before {
+ content: "\F8EB";
+}
+.mdi-home-map-marker::before {
+ content: "\F5F8";
+}
+.mdi-home-minus::before {
+ content: "\F973";
+}
+.mdi-home-modern::before {
+ content: "\F2DD";
+}
+.mdi-home-outline::before {
+ content: "\F6A0";
+}
+.mdi-home-plus::before {
+ content: "\F974";
+}
+.mdi-home-remove::before {
+ content: "\F0272";
+}
+.mdi-home-roof::before {
+ content: "\F0156";
+}
+.mdi-home-thermometer::before {
+ content: "\FF71";
+}
+.mdi-home-thermometer-outline::before {
+ content: "\FF72";
+}
+.mdi-home-variant::before {
+ content: "\F2DE";
+}
+.mdi-home-variant-outline::before {
+ content: "\FB83";
+}
+.mdi-hook::before {
+ content: "\F6E1";
+}
+.mdi-hook-off::before {
+ content: "\F6E2";
+}
+.mdi-hops::before {
+ content: "\F2DF";
+}
+.mdi-horizontal-rotate-clockwise::before {
+ content: "\F011E";
+}
+.mdi-horizontal-rotate-counterclockwise::before {
+ content: "\F011F";
+}
+.mdi-horseshoe::before {
+ content: "\FA57";
+}
+.mdi-hospital::before {
+ content: "\F0017";
+}
+.mdi-hospital-box::before {
+ content: "\F2E0";
+}
+.mdi-hospital-box-outline::before {
+ content: "\F0018";
+}
+.mdi-hospital-building::before {
+ content: "\F2E1";
+}
+.mdi-hospital-marker::before {
+ content: "\F2E2";
+}
+.mdi-hot-tub::before {
+ content: "\F827";
+}
+.mdi-hotel::before {
+ content: "\F2E3";
+}
+.mdi-houzz::before {
+ content: "\F2E4";
+}
+.mdi-houzz-box::before {
+ content: "\F2E5";
+}
+.mdi-hubspot::before {
+ content: "\FCF3";
+}
+.mdi-hulu::before {
+ content: "\F828";
+}
+.mdi-human::before {
+ content: "\F2E6";
+}
+.mdi-human-child::before {
+ content: "\F2E7";
+}
+.mdi-human-female::before {
+ content: "\F649";
+}
+.mdi-human-female-boy::before {
+ content: "\FA58";
+}
+.mdi-human-female-female::before {
+ content: "\FA59";
+}
+.mdi-human-female-girl::before {
+ content: "\FA5A";
+}
+.mdi-human-greeting::before {
+ content: "\F64A";
+}
+.mdi-human-handsdown::before {
+ content: "\F64B";
+}
+.mdi-human-handsup::before {
+ content: "\F64C";
+}
+.mdi-human-male::before {
+ content: "\F64D";
+}
+.mdi-human-male-boy::before {
+ content: "\FA5B";
+}
+.mdi-human-male-female::before {
+ content: "\F2E8";
+}
+.mdi-human-male-girl::before {
+ content: "\FA5C";
+}
+.mdi-human-male-height::before {
+ content: "\FF18";
+}
+.mdi-human-male-height-variant::before {
+ content: "\FF19";
+}
+.mdi-human-male-male::before {
+ content: "\FA5D";
+}
+.mdi-human-pregnant::before {
+ content: "\F5CF";
+}
+.mdi-humble-bundle::before {
+ content: "\F743";
+}
+.mdi-hvac::before {
+ content: "\F037D";
+}
+.mdi-hydraulic-oil-level::before {
+ content: "\F034F";
+}
+.mdi-hydraulic-oil-temperature::before {
+ content: "\F0350";
+}
+.mdi-hydro-power::before {
+ content: "\F0310";
+}
+.mdi-ice-cream::before {
+ content: "\F829";
+}
+.mdi-ice-pop::before {
+ content: "\FF1A";
+}
+.mdi-id-card::before {
+ content: "\FFE0";
+}
+.mdi-identifier::before {
+ content: "\FF1B";
+}
+.mdi-ideogram-cjk::before {
+ content: "\F035C";
+}
+.mdi-ideogram-cjk-variant::before {
+ content: "\F035D";
+}
+.mdi-iframe::before {
+ content: "\FC67";
+}
+.mdi-iframe-array::before {
+ content: "\F0120";
+}
+.mdi-iframe-array-outline::before {
+ content: "\F0121";
+}
+.mdi-iframe-braces::before {
+ content: "\F0122";
+}
+.mdi-iframe-braces-outline::before {
+ content: "\F0123";
+}
+.mdi-iframe-outline::before {
+ content: "\FC68";
+}
+.mdi-iframe-parentheses::before {
+ content: "\F0124";
+}
+.mdi-iframe-parentheses-outline::before {
+ content: "\F0125";
+}
+.mdi-iframe-variable::before {
+ content: "\F0126";
+}
+.mdi-iframe-variable-outline::before {
+ content: "\F0127";
+}
+.mdi-image::before {
+ content: "\F2E9";
+}
+.mdi-image-album::before {
+ content: "\F2EA";
+}
+.mdi-image-area::before {
+ content: "\F2EB";
+}
+.mdi-image-area-close::before {
+ content: "\F2EC";
+}
+.mdi-image-auto-adjust::before {
+ content: "\FFE1";
+}
+.mdi-image-broken::before {
+ content: "\F2ED";
+}
+.mdi-image-broken-variant::before {
+ content: "\F2EE";
+}
+.mdi-image-edit::before {
+ content: "\F020E";
+}
+.mdi-image-edit-outline::before {
+ content: "\F020F";
+}
+.mdi-image-filter::before {
+ content: "\F2EF";
+}
+.mdi-image-filter-black-white::before {
+ content: "\F2F0";
+}
+.mdi-image-filter-center-focus::before {
+ content: "\F2F1";
+}
+.mdi-image-filter-center-focus-strong::before {
+ content: "\FF1C";
+}
+.mdi-image-filter-center-focus-strong-outline::before {
+ content: "\FF1D";
+}
+.mdi-image-filter-center-focus-weak::before {
+ content: "\F2F2";
+}
+.mdi-image-filter-drama::before {
+ content: "\F2F3";
+}
+.mdi-image-filter-frames::before {
+ content: "\F2F4";
+}
+.mdi-image-filter-hdr::before {
+ content: "\F2F5";
+}
+.mdi-image-filter-none::before {
+ content: "\F2F6";
+}
+.mdi-image-filter-tilt-shift::before {
+ content: "\F2F7";
+}
+.mdi-image-filter-vintage::before {
+ content: "\F2F8";
+}
+.mdi-image-frame::before {
+ content: "\FE8A";
+}
+.mdi-image-move::before {
+ content: "\F9F7";
+}
+.mdi-image-multiple::before {
+ content: "\F2F9";
+}
+.mdi-image-off::before {
+ content: "\F82A";
+}
+.mdi-image-off-outline::before {
+ content: "\F01FC";
+}
+.mdi-image-outline::before {
+ content: "\F975";
+}
+.mdi-image-plus::before {
+ content: "\F87B";
+}
+.mdi-image-search::before {
+ content: "\F976";
+}
+.mdi-image-search-outline::before {
+ content: "\F977";
+}
+.mdi-image-size-select-actual::before {
+ content: "\FC69";
+}
+.mdi-image-size-select-large::before {
+ content: "\FC6A";
+}
+.mdi-image-size-select-small::before {
+ content: "\FC6B";
+}
+.mdi-import::before {
+ content: "\F2FA";
+}
+.mdi-inbox::before {
+ content: "\F686";
+}
+.mdi-inbox-arrow-down::before {
+ content: "\F2FB";
+}
+.mdi-inbox-arrow-down-outline::before {
+ content: "\F029B";
+}
+.mdi-inbox-arrow-up::before {
+ content: "\F3D1";
+}
+.mdi-inbox-arrow-up-outline::before {
+ content: "\F029C";
+}
+.mdi-inbox-full::before {
+ content: "\F029D";
+}
+.mdi-inbox-full-outline::before {
+ content: "\F029E";
+}
+.mdi-inbox-multiple::before {
+ content: "\F8AF";
+}
+.mdi-inbox-multiple-outline::before {
+ content: "\FB84";
+}
+.mdi-inbox-outline::before {
+ content: "\F029F";
+}
+.mdi-incognito::before {
+ content: "\F5F9";
+}
+.mdi-infinity::before {
+ content: "\F6E3";
+}
+.mdi-information::before {
+ content: "\F2FC";
+}
+.mdi-information-outline::before {
+ content: "\F2FD";
+}
+.mdi-information-variant::before {
+ content: "\F64E";
+}
+.mdi-instagram::before {
+ content: "\F2FE";
+}
+.mdi-instapaper::before {
+ content: "\F2FF";
+}
+.mdi-instrument-triangle::before {
+ content: "\F0070";
+}
+.mdi-internet-explorer::before {
+ content: "\F300";
+}
+.mdi-invert-colors::before {
+ content: "\F301";
+}
+.mdi-invert-colors-off::before {
+ content: "\FE8B";
+}
+.mdi-iobroker::before {
+ content: "\F0313";
+}
+.mdi-ip::before {
+ content: "\FA5E";
+}
+.mdi-ip-network::before {
+ content: "\FA5F";
+}
+.mdi-ip-network-outline::before {
+ content: "\FC6C";
+}
+.mdi-ipod::before {
+ content: "\FC6D";
+}
+.mdi-islam::before {
+ content: "\F978";
+}
+.mdi-island::before {
+ content: "\F0071";
+}
+.mdi-itunes::before {
+ content: "\F676";
+}
+.mdi-iv-bag::before {
+ content: "\F00E4";
+}
+.mdi-jabber::before {
+ content: "\FDB1";
+}
+.mdi-jeepney::before {
+ content: "\F302";
+}
+.mdi-jellyfish::before {
+ content: "\FF1E";
+}
+.mdi-jellyfish-outline::before {
+ content: "\FF1F";
+}
+.mdi-jira::before {
+ content: "\F303";
+}
+.mdi-jquery::before {
+ content: "\F87C";
+}
+.mdi-jsfiddle::before {
+ content: "\F304";
+}
+.mdi-json::before {
+ content: "\F626";
+}
+.mdi-judaism::before {
+ content: "\F979";
+}
+.mdi-jump-rope::before {
+ content: "\F032A";
+}
+.mdi-kabaddi::before {
+ content: "\FD63";
+}
+.mdi-karate::before {
+ content: "\F82B";
+}
+.mdi-keg::before {
+ content: "\F305";
+}
+.mdi-kettle::before {
+ content: "\F5FA";
+}
+.mdi-kettle-alert::before {
+ content: "\F0342";
+}
+.mdi-kettle-alert-outline::before {
+ content: "\F0343";
+}
+.mdi-kettle-off::before {
+ content: "\F0346";
+}
+.mdi-kettle-off-outline::before {
+ content: "\F0347";
+}
+.mdi-kettle-outline::before {
+ content: "\FF73";
+}
+.mdi-kettle-steam::before {
+ content: "\F0344";
+}
+.mdi-kettle-steam-outline::before {
+ content: "\F0345";
+}
+.mdi-kettlebell::before {
+ content: "\F032B";
+}
+.mdi-key::before {
+ content: "\F306";
+}
+.mdi-key-arrow-right::before {
+ content: "\F033D";
+}
+.mdi-key-change::before {
+ content: "\F307";
+}
+.mdi-key-link::before {
+ content: "\F01CA";
+}
+.mdi-key-minus::before {
+ content: "\F308";
+}
+.mdi-key-outline::before {
+ content: "\FDB2";
+}
+.mdi-key-plus::before {
+ content: "\F309";
+}
+.mdi-key-remove::before {
+ content: "\F30A";
+}
+.mdi-key-star::before {
+ content: "\F01C9";
+}
+.mdi-key-variant::before {
+ content: "\F30B";
+}
+.mdi-key-wireless::before {
+ content: "\FFE2";
+}
+.mdi-keyboard::before {
+ content: "\F30C";
+}
+.mdi-keyboard-backspace::before {
+ content: "\F30D";
+}
+.mdi-keyboard-caps::before {
+ content: "\F30E";
+}
+.mdi-keyboard-close::before {
+ content: "\F30F";
+}
+.mdi-keyboard-esc::before {
+ content: "\F02E2";
+}
+.mdi-keyboard-f1::before {
+ content: "\F02D6";
+}
+.mdi-keyboard-f10::before {
+ content: "\F02DF";
+}
+.mdi-keyboard-f11::before {
+ content: "\F02E0";
+}
+.mdi-keyboard-f12::before {
+ content: "\F02E1";
+}
+.mdi-keyboard-f2::before {
+ content: "\F02D7";
+}
+.mdi-keyboard-f3::before {
+ content: "\F02D8";
+}
+.mdi-keyboard-f4::before {
+ content: "\F02D9";
+}
+.mdi-keyboard-f5::before {
+ content: "\F02DA";
+}
+.mdi-keyboard-f6::before {
+ content: "\F02DB";
+}
+.mdi-keyboard-f7::before {
+ content: "\F02DC";
+}
+.mdi-keyboard-f8::before {
+ content: "\F02DD";
+}
+.mdi-keyboard-f9::before {
+ content: "\F02DE";
+}
+.mdi-keyboard-off::before {
+ content: "\F310";
+}
+.mdi-keyboard-off-outline::before {
+ content: "\FE8C";
+}
+.mdi-keyboard-outline::before {
+ content: "\F97A";
+}
+.mdi-keyboard-return::before {
+ content: "\F311";
+}
+.mdi-keyboard-settings::before {
+ content: "\F9F8";
+}
+.mdi-keyboard-settings-outline::before {
+ content: "\F9F9";
+}
+.mdi-keyboard-space::before {
+ content: "\F0072";
+}
+.mdi-keyboard-tab::before {
+ content: "\F312";
+}
+.mdi-keyboard-variant::before {
+ content: "\F313";
+}
+.mdi-khanda::before {
+ content: "\F0128";
+}
+.mdi-kickstarter::before {
+ content: "\F744";
+}
+.mdi-klingon::before {
+ content: "\F0386";
+}
+.mdi-knife::before {
+ content: "\F9FA";
+}
+.mdi-knife-military::before {
+ content: "\F9FB";
+}
+.mdi-kodi::before {
+ content: "\F314";
+}
+.mdi-kotlin::before {
+ content: "\F0244";
+}
+.mdi-kubernetes::before {
+ content: "\F0129";
+}
+.mdi-label::before {
+ content: "\F315";
+}
+.mdi-label-multiple::before {
+ content: "\F03A0";
+}
+.mdi-label-multiple-outline::before {
+ content: "\F03A1";
+}
+.mdi-label-off::before {
+ content: "\FACA";
+}
+.mdi-label-off-outline::before {
+ content: "\FACB";
+}
+.mdi-label-outline::before {
+ content: "\F316";
+}
+.mdi-label-percent::before {
+ content: "\F0315";
+}
+.mdi-label-percent-outline::before {
+ content: "\F0316";
+}
+.mdi-label-variant::before {
+ content: "\FACC";
+}
+.mdi-label-variant-outline::before {
+ content: "\FACD";
+}
+.mdi-ladybug::before {
+ content: "\F82C";
+}
+.mdi-lambda::before {
+ content: "\F627";
+}
+.mdi-lamp::before {
+ content: "\F6B4";
+}
+.mdi-lan::before {
+ content: "\F317";
+}
+.mdi-lan-check::before {
+ content: "\F02D5";
+}
+.mdi-lan-connect::before {
+ content: "\F318";
+}
+.mdi-lan-disconnect::before {
+ content: "\F319";
+}
+.mdi-lan-pending::before {
+ content: "\F31A";
+}
+.mdi-language-c::before {
+ content: "\F671";
+}
+.mdi-language-cpp::before {
+ content: "\F672";
+}
+.mdi-language-csharp::before {
+ content: "\F31B";
+}
+.mdi-language-css3::before {
+ content: "\F31C";
+}
+.mdi-language-fortran::before {
+ content: "\F0245";
+}
+.mdi-language-go::before {
+ content: "\F7D2";
+}
+.mdi-language-haskell::before {
+ content: "\FC6E";
+}
+.mdi-language-html5::before {
+ content: "\F31D";
+}
+.mdi-language-java::before {
+ content: "\FB1C";
+}
+.mdi-language-javascript::before {
+ content: "\F31E";
+}
+.mdi-language-lua::before {
+ content: "\F8B0";
+}
+.mdi-language-php::before {
+ content: "\F31F";
+}
+.mdi-language-python::before {
+ content: "\F320";
+}
+.mdi-language-python-text::before {
+ content: "\F321";
+}
+.mdi-language-r::before {
+ content: "\F7D3";
+}
+.mdi-language-ruby-on-rails::before {
+ content: "\FACE";
+}
+.mdi-language-swift::before {
+ content: "\F6E4";
+}
+.mdi-language-typescript::before {
+ content: "\F6E5";
+}
+.mdi-laptop::before {
+ content: "\F322";
+}
+.mdi-laptop-chromebook::before {
+ content: "\F323";
+}
+.mdi-laptop-mac::before {
+ content: "\F324";
+}
+.mdi-laptop-off::before {
+ content: "\F6E6";
+}
+.mdi-laptop-windows::before {
+ content: "\F325";
+}
+.mdi-laravel::before {
+ content: "\FACF";
+}
+.mdi-lasso::before {
+ content: "\FF20";
+}
+.mdi-lastfm::before {
+ content: "\F326";
+}
+.mdi-lastpass::before {
+ content: "\F446";
+}
+.mdi-latitude::before {
+ content: "\FF74";
+}
+.mdi-launch::before {
+ content: "\F327";
+}
+.mdi-lava-lamp::before {
+ content: "\F7D4";
+}
+.mdi-layers::before {
+ content: "\F328";
+}
+.mdi-layers-minus::before {
+ content: "\FE8D";
+}
+.mdi-layers-off::before {
+ content: "\F329";
+}
+.mdi-layers-off-outline::before {
+ content: "\F9FC";
+}
+.mdi-layers-outline::before {
+ content: "\F9FD";
+}
+.mdi-layers-plus::before {
+ content: "\FE30";
+}
+.mdi-layers-remove::before {
+ content: "\FE31";
+}
+.mdi-layers-search::before {
+ content: "\F0231";
+}
+.mdi-layers-search-outline::before {
+ content: "\F0232";
+}
+.mdi-layers-triple::before {
+ content: "\FF75";
+}
+.mdi-layers-triple-outline::before {
+ content: "\FF76";
+}
+.mdi-lead-pencil::before {
+ content: "\F64F";
+}
+.mdi-leaf::before {
+ content: "\F32A";
+}
+.mdi-leaf-maple::before {
+ content: "\FC6F";
+}
+.mdi-leaf-maple-off::before {
+ content: "\F0305";
+}
+.mdi-leaf-off::before {
+ content: "\F0304";
+}
+.mdi-leak::before {
+ content: "\FDB3";
+}
+.mdi-leak-off::before {
+ content: "\FDB4";
+}
+.mdi-led-off::before {
+ content: "\F32B";
+}
+.mdi-led-on::before {
+ content: "\F32C";
+}
+.mdi-led-outline::before {
+ content: "\F32D";
+}
+.mdi-led-strip::before {
+ content: "\F7D5";
+}
+.mdi-led-strip-variant::before {
+ content: "\F0073";
+}
+.mdi-led-variant-off::before {
+ content: "\F32E";
+}
+.mdi-led-variant-on::before {
+ content: "\F32F";
+}
+.mdi-led-variant-outline::before {
+ content: "\F330";
+}
+.mdi-leek::before {
+ content: "\F01A8";
+}
+.mdi-less-than::before {
+ content: "\F97B";
+}
+.mdi-less-than-or-equal::before {
+ content: "\F97C";
+}
+.mdi-library::before {
+ content: "\F331";
+}
+.mdi-library-books::before {
+ content: "\F332";
+}
+.mdi-library-movie::before {
+ content: "\FCF4";
+}
+.mdi-library-music::before {
+ content: "\F333";
+}
+.mdi-library-music-outline::before {
+ content: "\FF21";
+}
+.mdi-library-shelves::before {
+ content: "\FB85";
+}
+.mdi-library-video::before {
+ content: "\FCF5";
+}
+.mdi-license::before {
+ content: "\FFE3";
+}
+.mdi-lifebuoy::before {
+ content: "\F87D";
+}
+.mdi-light-switch::before {
+ content: "\F97D";
+}
+.mdi-lightbulb::before {
+ content: "\F335";
+}
+.mdi-lightbulb-cfl::before {
+ content: "\F0233";
+}
+.mdi-lightbulb-cfl-off::before {
+ content: "\F0234";
+}
+.mdi-lightbulb-cfl-spiral::before {
+ content: "\F02A0";
+}
+.mdi-lightbulb-cfl-spiral-off::before {
+ content: "\F02EE";
+}
+.mdi-lightbulb-group::before {
+ content: "\F027E";
+}
+.mdi-lightbulb-group-off::before {
+ content: "\F02F8";
+}
+.mdi-lightbulb-group-off-outline::before {
+ content: "\F02F9";
+}
+.mdi-lightbulb-group-outline::before {
+ content: "\F027F";
+}
+.mdi-lightbulb-multiple::before {
+ content: "\F0280";
+}
+.mdi-lightbulb-multiple-off::before {
+ content: "\F02FA";
+}
+.mdi-lightbulb-multiple-off-outline::before {
+ content: "\F02FB";
+}
+.mdi-lightbulb-multiple-outline::before {
+ content: "\F0281";
+}
+.mdi-lightbulb-off::before {
+ content: "\FE32";
+}
+.mdi-lightbulb-off-outline::before {
+ content: "\FE33";
+}
+.mdi-lightbulb-on::before {
+ content: "\F6E7";
+}
+.mdi-lightbulb-on-outline::before {
+ content: "\F6E8";
+}
+.mdi-lightbulb-outline::before {
+ content: "\F336";
+}
+.mdi-lighthouse::before {
+ content: "\F9FE";
+}
+.mdi-lighthouse-on::before {
+ content: "\F9FF";
+}
+.mdi-link::before {
+ content: "\F337";
+}
+.mdi-link-box::before {
+ content: "\FCF6";
+}
+.mdi-link-box-outline::before {
+ content: "\FCF7";
+}
+.mdi-link-box-variant::before {
+ content: "\FCF8";
+}
+.mdi-link-box-variant-outline::before {
+ content: "\FCF9";
+}
+.mdi-link-lock::before {
+ content: "\F00E5";
+}
+.mdi-link-off::before {
+ content: "\F338";
+}
+.mdi-link-plus::before {
+ content: "\FC70";
+}
+.mdi-link-variant::before {
+ content: "\F339";
+}
+.mdi-link-variant-minus::before {
+ content: "\F012A";
+}
+.mdi-link-variant-off::before {
+ content: "\F33A";
+}
+.mdi-link-variant-plus::before {
+ content: "\F012B";
+}
+.mdi-link-variant-remove::before {
+ content: "\F012C";
+}
+.mdi-linkedin::before {
+ content: "\F33B";
+}
+.mdi-linkedin-box::before {
+ content: "\F33C";
+}
+.mdi-linux::before {
+ content: "\F33D";
+}
+.mdi-linux-mint::before {
+ content: "\F8EC";
+}
+.mdi-litecoin::before {
+ content: "\FA60";
+}
+.mdi-loading::before {
+ content: "\F771";
+}
+.mdi-location-enter::before {
+ content: "\FFE4";
+}
+.mdi-location-exit::before {
+ content: "\FFE5";
+}
+.mdi-lock::before {
+ content: "\F33E";
+}
+.mdi-lock-alert::before {
+ content: "\F8ED";
+}
+.mdi-lock-clock::before {
+ content: "\F97E";
+}
+.mdi-lock-open::before {
+ content: "\F33F";
+}
+.mdi-lock-open-outline::before {
+ content: "\F340";
+}
+.mdi-lock-open-variant::before {
+ content: "\FFE6";
+}
+.mdi-lock-open-variant-outline::before {
+ content: "\FFE7";
+}
+.mdi-lock-outline::before {
+ content: "\F341";
+}
+.mdi-lock-pattern::before {
+ content: "\F6E9";
+}
+.mdi-lock-plus::before {
+ content: "\F5FB";
+}
+.mdi-lock-question::before {
+ content: "\F8EE";
+}
+.mdi-lock-reset::before {
+ content: "\F772";
+}
+.mdi-lock-smart::before {
+ content: "\F8B1";
+}
+.mdi-locker::before {
+ content: "\F7D6";
+}
+.mdi-locker-multiple::before {
+ content: "\F7D7";
+}
+.mdi-login::before {
+ content: "\F342";
+}
+.mdi-login-variant::before {
+ content: "\F5FC";
+}
+.mdi-logout::before {
+ content: "\F343";
+}
+.mdi-logout-variant::before {
+ content: "\F5FD";
+}
+.mdi-longitude::before {
+ content: "\FF77";
+}
+.mdi-looks::before {
+ content: "\F344";
+}
+.mdi-loupe::before {
+ content: "\F345";
+}
+.mdi-lumx::before {
+ content: "\F346";
+}
+.mdi-lungs::before {
+ content: "\F00AF";
+}
+.mdi-lyft::before {
+ content: "\FB1D";
+}
+.mdi-magnet::before {
+ content: "\F347";
+}
+.mdi-magnet-on::before {
+ content: "\F348";
+}
+.mdi-magnify::before {
+ content: "\F349";
+}
+.mdi-magnify-close::before {
+ content: "\F97F";
+}
+.mdi-magnify-minus::before {
+ content: "\F34A";
+}
+.mdi-magnify-minus-cursor::before {
+ content: "\FA61";
+}
+.mdi-magnify-minus-outline::before {
+ content: "\F6EB";
+}
+.mdi-magnify-plus::before {
+ content: "\F34B";
+}
+.mdi-magnify-plus-cursor::before {
+ content: "\FA62";
+}
+.mdi-magnify-plus-outline::before {
+ content: "\F6EC";
+}
+.mdi-magnify-remove-cursor::before {
+ content: "\F0237";
+}
+.mdi-magnify-remove-outline::before {
+ content: "\F0238";
+}
+.mdi-magnify-scan::before {
+ content: "\F02A1";
+}
+.mdi-mail::before {
+ content: "\FED8";
+}
+.mdi-mail-ru::before {
+ content: "\F34C";
+}
+.mdi-mailbox::before {
+ content: "\F6ED";
+}
+.mdi-mailbox-open::before {
+ content: "\FD64";
+}
+.mdi-mailbox-open-outline::before {
+ content: "\FD65";
+}
+.mdi-mailbox-open-up::before {
+ content: "\FD66";
+}
+.mdi-mailbox-open-up-outline::before {
+ content: "\FD67";
+}
+.mdi-mailbox-outline::before {
+ content: "\FD68";
+}
+.mdi-mailbox-up::before {
+ content: "\FD69";
+}
+.mdi-mailbox-up-outline::before {
+ content: "\FD6A";
+}
+.mdi-map::before {
+ content: "\F34D";
+}
+.mdi-map-check::before {
+ content: "\FED9";
+}
+.mdi-map-check-outline::before {
+ content: "\FEDA";
+}
+.mdi-map-clock::before {
+ content: "\FCFA";
+}
+.mdi-map-clock-outline::before {
+ content: "\FCFB";
+}
+.mdi-map-legend::before {
+ content: "\FA00";
+}
+.mdi-map-marker::before {
+ content: "\F34E";
+}
+.mdi-map-marker-alert::before {
+ content: "\FF22";
+}
+.mdi-map-marker-alert-outline::before {
+ content: "\FF23";
+}
+.mdi-map-marker-check::before {
+ content: "\FC71";
+}
+.mdi-map-marker-check-outline::before {
+ content: "\F0326";
+}
+.mdi-map-marker-circle::before {
+ content: "\F34F";
+}
+.mdi-map-marker-distance::before {
+ content: "\F8EF";
+}
+.mdi-map-marker-down::before {
+ content: "\F012D";
+}
+.mdi-map-marker-left::before {
+ content: "\F0306";
+}
+.mdi-map-marker-left-outline::before {
+ content: "\F0308";
+}
+.mdi-map-marker-minus::before {
+ content: "\F650";
+}
+.mdi-map-marker-minus-outline::before {
+ content: "\F0324";
+}
+.mdi-map-marker-multiple::before {
+ content: "\F350";
+}
+.mdi-map-marker-multiple-outline::before {
+ content: "\F02A2";
+}
+.mdi-map-marker-off::before {
+ content: "\F351";
+}
+.mdi-map-marker-off-outline::before {
+ content: "\F0328";
+}
+.mdi-map-marker-outline::before {
+ content: "\F7D8";
+}
+.mdi-map-marker-path::before {
+ content: "\FCFC";
+}
+.mdi-map-marker-plus::before {
+ content: "\F651";
+}
+.mdi-map-marker-plus-outline::before {
+ content: "\F0323";
+}
+.mdi-map-marker-question::before {
+ content: "\FF24";
+}
+.mdi-map-marker-question-outline::before {
+ content: "\FF25";
+}
+.mdi-map-marker-radius::before {
+ content: "\F352";
+}
+.mdi-map-marker-radius-outline::before {
+ content: "\F0327";
+}
+.mdi-map-marker-remove::before {
+ content: "\FF26";
+}
+.mdi-map-marker-remove-outline::before {
+ content: "\F0325";
+}
+.mdi-map-marker-remove-variant::before {
+ content: "\FF27";
+}
+.mdi-map-marker-right::before {
+ content: "\F0307";
+}
+.mdi-map-marker-right-outline::before {
+ content: "\F0309";
+}
+.mdi-map-marker-up::before {
+ content: "\F012E";
+}
+.mdi-map-minus::before {
+ content: "\F980";
+}
+.mdi-map-outline::before {
+ content: "\F981";
+}
+.mdi-map-plus::before {
+ content: "\F982";
+}
+.mdi-map-search::before {
+ content: "\F983";
+}
+.mdi-map-search-outline::before {
+ content: "\F984";
+}
+.mdi-mapbox::before {
+ content: "\FB86";
+}
+.mdi-margin::before {
+ content: "\F353";
+}
+.mdi-markdown::before {
+ content: "\F354";
+}
+.mdi-markdown-outline::before {
+ content: "\FF78";
+}
+.mdi-marker::before {
+ content: "\F652";
+}
+.mdi-marker-cancel::before {
+ content: "\FDB5";
+}
+.mdi-marker-check::before {
+ content: "\F355";
+}
+.mdi-mastodon::before {
+ content: "\FAD0";
+}
+.mdi-mastodon-variant::before {
+ content: "\FAD1";
+}
+.mdi-material-design::before {
+ content: "\F985";
+}
+.mdi-material-ui::before {
+ content: "\F357";
+}
+.mdi-math-compass::before {
+ content: "\F358";
+}
+.mdi-math-cos::before {
+ content: "\FC72";
+}
+.mdi-math-integral::before {
+ content: "\FFE8";
+}
+.mdi-math-integral-box::before {
+ content: "\FFE9";
+}
+.mdi-math-log::before {
+ content: "\F00B0";
+}
+.mdi-math-norm::before {
+ content: "\FFEA";
+}
+.mdi-math-norm-box::before {
+ content: "\FFEB";
+}
+.mdi-math-sin::before {
+ content: "\FC73";
+}
+.mdi-math-tan::before {
+ content: "\FC74";
+}
+.mdi-matrix::before {
+ content: "\F628";
+}
+.mdi-medal::before {
+ content: "\F986";
+}
+.mdi-medal-outline::before {
+ content: "\F0351";
+}
+.mdi-medical-bag::before {
+ content: "\F6EE";
+}
+.mdi-meditation::before {
+ content: "\F01A6";
+}
+.mdi-medium::before {
+ content: "\F35A";
+}
+.mdi-meetup::before {
+ content: "\FAD2";
+}
+.mdi-memory::before {
+ content: "\F35B";
+}
+.mdi-menu::before {
+ content: "\F35C";
+}
+.mdi-menu-down::before {
+ content: "\F35D";
+}
+.mdi-menu-down-outline::before {
+ content: "\F6B5";
+}
+.mdi-menu-left::before {
+ content: "\F35E";
+}
+.mdi-menu-left-outline::before {
+ content: "\FA01";
+}
+.mdi-menu-open::before {
+ content: "\FB87";
+}
+.mdi-menu-right::before {
+ content: "\F35F";
+}
+.mdi-menu-right-outline::before {
+ content: "\FA02";
+}
+.mdi-menu-swap::before {
+ content: "\FA63";
+}
+.mdi-menu-swap-outline::before {
+ content: "\FA64";
+}
+.mdi-menu-up::before {
+ content: "\F360";
+}
+.mdi-menu-up-outline::before {
+ content: "\F6B6";
+}
+.mdi-merge::before {
+ content: "\FF79";
+}
+.mdi-message::before {
+ content: "\F361";
+}
+.mdi-message-alert::before {
+ content: "\F362";
+}
+.mdi-message-alert-outline::before {
+ content: "\FA03";
+}
+.mdi-message-arrow-left::before {
+ content: "\F031D";
+}
+.mdi-message-arrow-left-outline::before {
+ content: "\F031E";
+}
+.mdi-message-arrow-right::before {
+ content: "\F031F";
+}
+.mdi-message-arrow-right-outline::before {
+ content: "\F0320";
+}
+.mdi-message-bulleted::before {
+ content: "\F6A1";
+}
+.mdi-message-bulleted-off::before {
+ content: "\F6A2";
+}
+.mdi-message-draw::before {
+ content: "\F363";
+}
+.mdi-message-image::before {
+ content: "\F364";
+}
+.mdi-message-image-outline::before {
+ content: "\F0197";
+}
+.mdi-message-lock::before {
+ content: "\FFEC";
+}
+.mdi-message-lock-outline::before {
+ content: "\F0198";
+}
+.mdi-message-minus::before {
+ content: "\F0199";
+}
+.mdi-message-minus-outline::before {
+ content: "\F019A";
+}
+.mdi-message-outline::before {
+ content: "\F365";
+}
+.mdi-message-plus::before {
+ content: "\F653";
+}
+.mdi-message-plus-outline::before {
+ content: "\F00E6";
+}
+.mdi-message-processing::before {
+ content: "\F366";
+}
+.mdi-message-processing-outline::before {
+ content: "\F019B";
+}
+.mdi-message-reply::before {
+ content: "\F367";
+}
+.mdi-message-reply-text::before {
+ content: "\F368";
+}
+.mdi-message-settings::before {
+ content: "\F6EF";
+}
+.mdi-message-settings-outline::before {
+ content: "\F019C";
+}
+.mdi-message-settings-variant::before {
+ content: "\F6F0";
+}
+.mdi-message-settings-variant-outline::before {
+ content: "\F019D";
+}
+.mdi-message-text::before {
+ content: "\F369";
+}
+.mdi-message-text-clock::before {
+ content: "\F019E";
+}
+.mdi-message-text-clock-outline::before {
+ content: "\F019F";
+}
+.mdi-message-text-lock::before {
+ content: "\FFED";
+}
+.mdi-message-text-lock-outline::before {
+ content: "\F01A0";
+}
+.mdi-message-text-outline::before {
+ content: "\F36A";
+}
+.mdi-message-video::before {
+ content: "\F36B";
+}
+.mdi-meteor::before {
+ content: "\F629";
+}
+.mdi-metronome::before {
+ content: "\F7D9";
+}
+.mdi-metronome-tick::before {
+ content: "\F7DA";
+}
+.mdi-micro-sd::before {
+ content: "\F7DB";
+}
+.mdi-microphone::before {
+ content: "\F36C";
+}
+.mdi-microphone-minus::before {
+ content: "\F8B2";
+}
+.mdi-microphone-off::before {
+ content: "\F36D";
+}
+.mdi-microphone-outline::before {
+ content: "\F36E";
+}
+.mdi-microphone-plus::before {
+ content: "\F8B3";
+}
+.mdi-microphone-settings::before {
+ content: "\F36F";
+}
+.mdi-microphone-variant::before {
+ content: "\F370";
+}
+.mdi-microphone-variant-off::before {
+ content: "\F371";
+}
+.mdi-microscope::before {
+ content: "\F654";
+}
+.mdi-microsoft::before {
+ content: "\F372";
+}
+.mdi-microsoft-dynamics::before {
+ content: "\F987";
+}
+.mdi-microwave::before {
+ content: "\FC75";
+}
+.mdi-middleware::before {
+ content: "\FF7A";
+}
+.mdi-middleware-outline::before {
+ content: "\FF7B";
+}
+.mdi-midi::before {
+ content: "\F8F0";
+}
+.mdi-midi-port::before {
+ content: "\F8F1";
+}
+.mdi-mine::before {
+ content: "\FDB6";
+}
+.mdi-minecraft::before {
+ content: "\F373";
+}
+.mdi-mini-sd::before {
+ content: "\FA04";
+}
+.mdi-minidisc::before {
+ content: "\FA05";
+}
+.mdi-minus::before {
+ content: "\F374";
+}
+.mdi-minus-box::before {
+ content: "\F375";
+}
+.mdi-minus-box-multiple::before {
+ content: "\F016C";
+}
+.mdi-minus-box-multiple-outline::before {
+ content: "\F016D";
+}
+.mdi-minus-box-outline::before {
+ content: "\F6F1";
+}
+.mdi-minus-circle::before {
+ content: "\F376";
+}
+.mdi-minus-circle-outline::before {
+ content: "\F377";
+}
+.mdi-minus-network::before {
+ content: "\F378";
+}
+.mdi-minus-network-outline::before {
+ content: "\FC76";
+}
+.mdi-mirror::before {
+ content: "\F0228";
+}
+.mdi-mixcloud::before {
+ content: "\F62A";
+}
+.mdi-mixed-martial-arts::before {
+ content: "\FD6B";
+}
+.mdi-mixed-reality::before {
+ content: "\F87E";
+}
+.mdi-mixer::before {
+ content: "\F7DC";
+}
+.mdi-molecule::before {
+ content: "\FB88";
+}
+.mdi-monitor::before {
+ content: "\F379";
+}
+.mdi-monitor-cellphone::before {
+ content: "\F988";
+}
+.mdi-monitor-cellphone-star::before {
+ content: "\F989";
+}
+.mdi-monitor-clean::before {
+ content: "\F012F";
+}
+.mdi-monitor-dashboard::before {
+ content: "\FA06";
+}
+.mdi-monitor-edit::before {
+ content: "\F02F1";
+}
+.mdi-monitor-lock::before {
+ content: "\FDB7";
+}
+.mdi-monitor-multiple::before {
+ content: "\F37A";
+}
+.mdi-monitor-off::before {
+ content: "\FD6C";
+}
+.mdi-monitor-screenshot::before {
+ content: "\FE34";
+}
+.mdi-monitor-speaker::before {
+ content: "\FF7C";
+}
+.mdi-monitor-speaker-off::before {
+ content: "\FF7D";
+}
+.mdi-monitor-star::before {
+ content: "\FDB8";
+}
+.mdi-moon-first-quarter::before {
+ content: "\FF7E";
+}
+.mdi-moon-full::before {
+ content: "\FF7F";
+}
+.mdi-moon-last-quarter::before {
+ content: "\FF80";
+}
+.mdi-moon-new::before {
+ content: "\FF81";
+}
+.mdi-moon-waning-crescent::before {
+ content: "\FF82";
+}
+.mdi-moon-waning-gibbous::before {
+ content: "\FF83";
+}
+.mdi-moon-waxing-crescent::before {
+ content: "\FF84";
+}
+.mdi-moon-waxing-gibbous::before {
+ content: "\FF85";
+}
+.mdi-moped::before {
+ content: "\F00B1";
+}
+.mdi-more::before {
+ content: "\F37B";
+}
+.mdi-mother-heart::before {
+ content: "\F033F";
+}
+.mdi-mother-nurse::before {
+ content: "\FCFD";
+}
+.mdi-motion-sensor::before {
+ content: "\FD6D";
+}
+.mdi-motorbike::before {
+ content: "\F37C";
+}
+.mdi-mouse::before {
+ content: "\F37D";
+}
+.mdi-mouse-bluetooth::before {
+ content: "\F98A";
+}
+.mdi-mouse-off::before {
+ content: "\F37E";
+}
+.mdi-mouse-variant::before {
+ content: "\F37F";
+}
+.mdi-mouse-variant-off::before {
+ content: "\F380";
+}
+.mdi-move-resize::before {
+ content: "\F655";
+}
+.mdi-move-resize-variant::before {
+ content: "\F656";
+}
+.mdi-movie::before {
+ content: "\F381";
+}
+.mdi-movie-edit::before {
+ content: "\F014D";
+}
+.mdi-movie-edit-outline::before {
+ content: "\F014E";
+}
+.mdi-movie-filter::before {
+ content: "\F014F";
+}
+.mdi-movie-filter-outline::before {
+ content: "\F0150";
+}
+.mdi-movie-open::before {
+ content: "\FFEE";
+}
+.mdi-movie-open-outline::before {
+ content: "\FFEF";
+}
+.mdi-movie-outline::before {
+ content: "\FDB9";
+}
+.mdi-movie-roll::before {
+ content: "\F7DD";
+}
+.mdi-movie-search::before {
+ content: "\F01FD";
+}
+.mdi-movie-search-outline::before {
+ content: "\F01FE";
+}
+.mdi-muffin::before {
+ content: "\F98B";
+}
+.mdi-multiplication::before {
+ content: "\F382";
+}
+.mdi-multiplication-box::before {
+ content: "\F383";
+}
+.mdi-mushroom::before {
+ content: "\F7DE";
+}
+.mdi-mushroom-outline::before {
+ content: "\F7DF";
+}
+.mdi-music::before {
+ content: "\F759";
+}
+.mdi-music-accidental-double-flat::before {
+ content: "\FF86";
+}
+.mdi-music-accidental-double-sharp::before {
+ content: "\FF87";
+}
+.mdi-music-accidental-flat::before {
+ content: "\FF88";
+}
+.mdi-music-accidental-natural::before {
+ content: "\FF89";
+}
+.mdi-music-accidental-sharp::before {
+ content: "\FF8A";
+}
+.mdi-music-box::before {
+ content: "\F384";
+}
+.mdi-music-box-outline::before {
+ content: "\F385";
+}
+.mdi-music-circle::before {
+ content: "\F386";
+}
+.mdi-music-circle-outline::before {
+ content: "\FAD3";
+}
+.mdi-music-clef-alto::before {
+ content: "\FF8B";
+}
+.mdi-music-clef-bass::before {
+ content: "\FF8C";
+}
+.mdi-music-clef-treble::before {
+ content: "\FF8D";
+}
+.mdi-music-note::before {
+ content: "\F387";
+}
+.mdi-music-note-bluetooth::before {
+ content: "\F5FE";
+}
+.mdi-music-note-bluetooth-off::before {
+ content: "\F5FF";
+}
+.mdi-music-note-eighth::before {
+ content: "\F388";
+}
+.mdi-music-note-eighth-dotted::before {
+ content: "\FF8E";
+}
+.mdi-music-note-half::before {
+ content: "\F389";
+}
+.mdi-music-note-half-dotted::before {
+ content: "\FF8F";
+}
+.mdi-music-note-off::before {
+ content: "\F38A";
+}
+.mdi-music-note-off-outline::before {
+ content: "\FF90";
+}
+.mdi-music-note-outline::before {
+ content: "\FF91";
+}
+.mdi-music-note-plus::before {
+ content: "\FDBA";
+}
+.mdi-music-note-quarter::before {
+ content: "\F38B";
+}
+.mdi-music-note-quarter-dotted::before {
+ content: "\FF92";
+}
+.mdi-music-note-sixteenth::before {
+ content: "\F38C";
+}
+.mdi-music-note-sixteenth-dotted::before {
+ content: "\FF93";
+}
+.mdi-music-note-whole::before {
+ content: "\F38D";
+}
+.mdi-music-note-whole-dotted::before {
+ content: "\FF94";
+}
+.mdi-music-off::before {
+ content: "\F75A";
+}
+.mdi-music-rest-eighth::before {
+ content: "\FF95";
+}
+.mdi-music-rest-half::before {
+ content: "\FF96";
+}
+.mdi-music-rest-quarter::before {
+ content: "\FF97";
+}
+.mdi-music-rest-sixteenth::before {
+ content: "\FF98";
+}
+.mdi-music-rest-whole::before {
+ content: "\FF99";
+}
+.mdi-nail::before {
+ content: "\FDBB";
+}
+.mdi-nas::before {
+ content: "\F8F2";
+}
+.mdi-nativescript::before {
+ content: "\F87F";
+}
+.mdi-nature::before {
+ content: "\F38E";
+}
+.mdi-nature-people::before {
+ content: "\F38F";
+}
+.mdi-navigation::before {
+ content: "\F390";
+}
+.mdi-near-me::before {
+ content: "\F5CD";
+}
+.mdi-necklace::before {
+ content: "\FF28";
+}
+.mdi-needle::before {
+ content: "\F391";
+}
+.mdi-netflix::before {
+ content: "\F745";
+}
+.mdi-network::before {
+ content: "\F6F2";
+}
+.mdi-network-off::before {
+ content: "\FC77";
+}
+.mdi-network-off-outline::before {
+ content: "\FC78";
+}
+.mdi-network-outline::before {
+ content: "\FC79";
+}
+.mdi-network-router::before {
+ content: "\F00B2";
+}
+.mdi-network-strength-1::before {
+ content: "\F8F3";
+}
+.mdi-network-strength-1-alert::before {
+ content: "\F8F4";
+}
+.mdi-network-strength-2::before {
+ content: "\F8F5";
+}
+.mdi-network-strength-2-alert::before {
+ content: "\F8F6";
+}
+.mdi-network-strength-3::before {
+ content: "\F8F7";
+}
+.mdi-network-strength-3-alert::before {
+ content: "\F8F8";
+}
+.mdi-network-strength-4::before {
+ content: "\F8F9";
+}
+.mdi-network-strength-4-alert::before {
+ content: "\F8FA";
+}
+.mdi-network-strength-off::before {
+ content: "\F8FB";
+}
+.mdi-network-strength-off-outline::before {
+ content: "\F8FC";
+}
+.mdi-network-strength-outline::before {
+ content: "\F8FD";
+}
+.mdi-new-box::before {
+ content: "\F394";
+}
+.mdi-newspaper::before {
+ content: "\F395";
+}
+.mdi-newspaper-minus::before {
+ content: "\FF29";
+}
+.mdi-newspaper-plus::before {
+ content: "\FF2A";
+}
+.mdi-newspaper-variant::before {
+ content: "\F0023";
+}
+.mdi-newspaper-variant-multiple::before {
+ content: "\F0024";
+}
+.mdi-newspaper-variant-multiple-outline::before {
+ content: "\F0025";
+}
+.mdi-newspaper-variant-outline::before {
+ content: "\F0026";
+}
+.mdi-nfc::before {
+ content: "\F396";
+}
+.mdi-nfc-off::before {
+ content: "\FE35";
+}
+.mdi-nfc-search-variant::before {
+ content: "\FE36";
+}
+.mdi-nfc-tap::before {
+ content: "\F397";
+}
+.mdi-nfc-variant::before {
+ content: "\F398";
+}
+.mdi-nfc-variant-off::before {
+ content: "\FE37";
+}
+.mdi-ninja::before {
+ content: "\F773";
+}
+.mdi-nintendo-switch::before {
+ content: "\F7E0";
+}
+.mdi-nix::before {
+ content: "\F0130";
+}
+.mdi-nodejs::before {
+ content: "\F399";
+}
+.mdi-noodles::before {
+ content: "\F01A9";
+}
+.mdi-not-equal::before {
+ content: "\F98C";
+}
+.mdi-not-equal-variant::before {
+ content: "\F98D";
+}
+.mdi-note::before {
+ content: "\F39A";
+}
+.mdi-note-multiple::before {
+ content: "\F6B7";
+}
+.mdi-note-multiple-outline::before {
+ content: "\F6B8";
+}
+.mdi-note-outline::before {
+ content: "\F39B";
+}
+.mdi-note-plus::before {
+ content: "\F39C";
+}
+.mdi-note-plus-outline::before {
+ content: "\F39D";
+}
+.mdi-note-text::before {
+ content: "\F39E";
+}
+.mdi-note-text-outline::before {
+ content: "\F0202";
+}
+.mdi-notebook::before {
+ content: "\F82D";
+}
+.mdi-notebook-multiple::before {
+ content: "\FE38";
+}
+.mdi-notebook-outline::before {
+ content: "\FEDC";
+}
+.mdi-notification-clear-all::before {
+ content: "\F39F";
+}
+.mdi-npm::before {
+ content: "\F6F6";
+}
+.mdi-npm-variant::before {
+ content: "\F98E";
+}
+.mdi-npm-variant-outline::before {
+ content: "\F98F";
+}
+.mdi-nuke::before {
+ content: "\F6A3";
+}
+.mdi-null::before {
+ content: "\F7E1";
+}
+.mdi-numeric::before {
+ content: "\F3A0";
+}
+.mdi-numeric-0::before {
+ content: "\30";
+}
+.mdi-numeric-0-box::before {
+ content: "\F3A1";
+}
+.mdi-numeric-0-box-multiple::before {
+ content: "\FF2B";
+}
+.mdi-numeric-0-box-multiple-outline::before {
+ content: "\F3A2";
+}
+.mdi-numeric-0-box-outline::before {
+ content: "\F3A3";
+}
+.mdi-numeric-0-circle::before {
+ content: "\FC7A";
+}
+.mdi-numeric-0-circle-outline::before {
+ content: "\FC7B";
+}
+.mdi-numeric-1::before {
+ content: "\31";
+}
+.mdi-numeric-1-box::before {
+ content: "\F3A4";
+}
+.mdi-numeric-1-box-multiple::before {
+ content: "\FF2C";
+}
+.mdi-numeric-1-box-multiple-outline::before {
+ content: "\F3A5";
+}
+.mdi-numeric-1-box-outline::before {
+ content: "\F3A6";
+}
+.mdi-numeric-1-circle::before {
+ content: "\FC7C";
+}
+.mdi-numeric-1-circle-outline::before {
+ content: "\FC7D";
+}
+.mdi-numeric-10::before {
+ content: "\F000A";
+}
+.mdi-numeric-10-box::before {
+ content: "\FF9A";
+}
+.mdi-numeric-10-box-multiple::before {
+ content: "\F000B";
+}
+.mdi-numeric-10-box-multiple-outline::before {
+ content: "\F000C";
+}
+.mdi-numeric-10-box-outline::before {
+ content: "\FF9B";
+}
+.mdi-numeric-10-circle::before {
+ content: "\F000D";
+}
+.mdi-numeric-10-circle-outline::before {
+ content: "\F000E";
+}
+.mdi-numeric-2::before {
+ content: "\32";
+}
+.mdi-numeric-2-box::before {
+ content: "\F3A7";
+}
+.mdi-numeric-2-box-multiple::before {
+ content: "\FF2D";
+}
+.mdi-numeric-2-box-multiple-outline::before {
+ content: "\F3A8";
+}
+.mdi-numeric-2-box-outline::before {
+ content: "\F3A9";
+}
+.mdi-numeric-2-circle::before {
+ content: "\FC7E";
+}
+.mdi-numeric-2-circle-outline::before {
+ content: "\FC7F";
+}
+.mdi-numeric-3::before {
+ content: "\33";
+}
+.mdi-numeric-3-box::before {
+ content: "\F3AA";
+}
+.mdi-numeric-3-box-multiple::before {
+ content: "\FF2E";
+}
+.mdi-numeric-3-box-multiple-outline::before {
+ content: "\F3AB";
+}
+.mdi-numeric-3-box-outline::before {
+ content: "\F3AC";
+}
+.mdi-numeric-3-circle::before {
+ content: "\FC80";
+}
+.mdi-numeric-3-circle-outline::before {
+ content: "\FC81";
+}
+.mdi-numeric-4::before {
+ content: "\34";
+}
+.mdi-numeric-4-box::before {
+ content: "\F3AD";
+}
+.mdi-numeric-4-box-multiple::before {
+ content: "\FF2F";
+}
+.mdi-numeric-4-box-multiple-outline::before {
+ content: "\F3AE";
+}
+.mdi-numeric-4-box-outline::before {
+ content: "\F3AF";
+}
+.mdi-numeric-4-circle::before {
+ content: "\FC82";
+}
+.mdi-numeric-4-circle-outline::before {
+ content: "\FC83";
+}
+.mdi-numeric-5::before {
+ content: "\35";
+}
+.mdi-numeric-5-box::before {
+ content: "\F3B0";
+}
+.mdi-numeric-5-box-multiple::before {
+ content: "\FF30";
+}
+.mdi-numeric-5-box-multiple-outline::before {
+ content: "\F3B1";
+}
+.mdi-numeric-5-box-outline::before {
+ content: "\F3B2";
+}
+.mdi-numeric-5-circle::before {
+ content: "\FC84";
+}
+.mdi-numeric-5-circle-outline::before {
+ content: "\FC85";
+}
+.mdi-numeric-6::before {
+ content: "\36";
+}
+.mdi-numeric-6-box::before {
+ content: "\F3B3";
+}
+.mdi-numeric-6-box-multiple::before {
+ content: "\FF31";
+}
+.mdi-numeric-6-box-multiple-outline::before {
+ content: "\F3B4";
+}
+.mdi-numeric-6-box-outline::before {
+ content: "\F3B5";
+}
+.mdi-numeric-6-circle::before {
+ content: "\FC86";
+}
+.mdi-numeric-6-circle-outline::before {
+ content: "\FC87";
+}
+.mdi-numeric-7::before {
+ content: "\37";
+}
+.mdi-numeric-7-box::before {
+ content: "\F3B6";
+}
+.mdi-numeric-7-box-multiple::before {
+ content: "\FF32";
+}
+.mdi-numeric-7-box-multiple-outline::before {
+ content: "\F3B7";
+}
+.mdi-numeric-7-box-outline::before {
+ content: "\F3B8";
+}
+.mdi-numeric-7-circle::before {
+ content: "\FC88";
+}
+.mdi-numeric-7-circle-outline::before {
+ content: "\FC89";
+}
+.mdi-numeric-8::before {
+ content: "\38";
+}
+.mdi-numeric-8-box::before {
+ content: "\F3B9";
+}
+.mdi-numeric-8-box-multiple::before {
+ content: "\FF33";
+}
+.mdi-numeric-8-box-multiple-outline::before {
+ content: "\F3BA";
+}
+.mdi-numeric-8-box-outline::before {
+ content: "\F3BB";
+}
+.mdi-numeric-8-circle::before {
+ content: "\FC8A";
+}
+.mdi-numeric-8-circle-outline::before {
+ content: "\FC8B";
+}
+.mdi-numeric-9::before {
+ content: "\39";
+}
+.mdi-numeric-9-box::before {
+ content: "\F3BC";
+}
+.mdi-numeric-9-box-multiple::before {
+ content: "\FF34";
+}
+.mdi-numeric-9-box-multiple-outline::before {
+ content: "\F3BD";
+}
+.mdi-numeric-9-box-outline::before {
+ content: "\F3BE";
+}
+.mdi-numeric-9-circle::before {
+ content: "\FC8C";
+}
+.mdi-numeric-9-circle-outline::before {
+ content: "\FC8D";
+}
+.mdi-numeric-9-plus::before {
+ content: "\F000F";
+}
+.mdi-numeric-9-plus-box::before {
+ content: "\F3BF";
+}
+.mdi-numeric-9-plus-box-multiple::before {
+ content: "\FF35";
+}
+.mdi-numeric-9-plus-box-multiple-outline::before {
+ content: "\F3C0";
+}
+.mdi-numeric-9-plus-box-outline::before {
+ content: "\F3C1";
+}
+.mdi-numeric-9-plus-circle::before {
+ content: "\FC8E";
+}
+.mdi-numeric-9-plus-circle-outline::before {
+ content: "\FC8F";
+}
+.mdi-numeric-negative-1::before {
+ content: "\F0074";
+}
+.mdi-nut::before {
+ content: "\F6F7";
+}
+.mdi-nutrition::before {
+ content: "\F3C2";
+}
+.mdi-nuxt::before {
+ content: "\F0131";
+}
+.mdi-oar::before {
+ content: "\F67B";
+}
+.mdi-ocarina::before {
+ content: "\FDBC";
+}
+.mdi-oci::before {
+ content: "\F0314";
+}
+.mdi-ocr::before {
+ content: "\F0165";
+}
+.mdi-octagon::before {
+ content: "\F3C3";
+}
+.mdi-octagon-outline::before {
+ content: "\F3C4";
+}
+.mdi-octagram::before {
+ content: "\F6F8";
+}
+.mdi-octagram-outline::before {
+ content: "\F774";
+}
+.mdi-odnoklassniki::before {
+ content: "\F3C5";
+}
+.mdi-offer::before {
+ content: "\F0246";
+}
+.mdi-office::before {
+ content: "\F3C6";
+}
+.mdi-office-building::before {
+ content: "\F990";
+}
+.mdi-oil::before {
+ content: "\F3C7";
+}
+.mdi-oil-lamp::before {
+ content: "\FF36";
+}
+.mdi-oil-level::before {
+ content: "\F0075";
+}
+.mdi-oil-temperature::before {
+ content: "\F0019";
+}
+.mdi-omega::before {
+ content: "\F3C9";
+}
+.mdi-one-up::before {
+ content: "\FB89";
+}
+.mdi-onedrive::before {
+ content: "\F3CA";
+}
+.mdi-onenote::before {
+ content: "\F746";
+}
+.mdi-onepassword::before {
+ content: "\F880";
+}
+.mdi-opacity::before {
+ content: "\F5CC";
+}
+.mdi-open-in-app::before {
+ content: "\F3CB";
+}
+.mdi-open-in-new::before {
+ content: "\F3CC";
+}
+.mdi-open-source-initiative::before {
+ content: "\FB8A";
+}
+.mdi-openid::before {
+ content: "\F3CD";
+}
+.mdi-opera::before {
+ content: "\F3CE";
+}
+.mdi-orbit::before {
+ content: "\F018";
+}
+.mdi-origin::before {
+ content: "\FB2B";
+}
+.mdi-ornament::before {
+ content: "\F3CF";
+}
+.mdi-ornament-variant::before {
+ content: "\F3D0";
+}
+.mdi-outdoor-lamp::before {
+ content: "\F0076";
+}
+.mdi-outlook::before {
+ content: "\FCFE";
+}
+.mdi-overscan::before {
+ content: "\F0027";
+}
+.mdi-owl::before {
+ content: "\F3D2";
+}
+.mdi-pac-man::before {
+ content: "\FB8B";
+}
+.mdi-package::before {
+ content: "\F3D3";
+}
+.mdi-package-down::before {
+ content: "\F3D4";
+}
+.mdi-package-up::before {
+ content: "\F3D5";
+}
+.mdi-package-variant::before {
+ content: "\F3D6";
+}
+.mdi-package-variant-closed::before {
+ content: "\F3D7";
+}
+.mdi-page-first::before {
+ content: "\F600";
+}
+.mdi-page-last::before {
+ content: "\F601";
+}
+.mdi-page-layout-body::before {
+ content: "\F6F9";
+}
+.mdi-page-layout-footer::before {
+ content: "\F6FA";
+}
+.mdi-page-layout-header::before {
+ content: "\F6FB";
+}
+.mdi-page-layout-header-footer::before {
+ content: "\FF9C";
+}
+.mdi-page-layout-sidebar-left::before {
+ content: "\F6FC";
+}
+.mdi-page-layout-sidebar-right::before {
+ content: "\F6FD";
+}
+.mdi-page-next::before {
+ content: "\FB8C";
+}
+.mdi-page-next-outline::before {
+ content: "\FB8D";
+}
+.mdi-page-previous::before {
+ content: "\FB8E";
+}
+.mdi-page-previous-outline::before {
+ content: "\FB8F";
+}
+.mdi-palette::before {
+ content: "\F3D8";
+}
+.mdi-palette-advanced::before {
+ content: "\F3D9";
+}
+.mdi-palette-outline::before {
+ content: "\FE6C";
+}
+.mdi-palette-swatch::before {
+ content: "\F8B4";
+}
+.mdi-palette-swatch-outline::before {
+ content: "\F0387";
+}
+.mdi-palm-tree::before {
+ content: "\F0077";
+}
+.mdi-pan::before {
+ content: "\FB90";
+}
+.mdi-pan-bottom-left::before {
+ content: "\FB91";
+}
+.mdi-pan-bottom-right::before {
+ content: "\FB92";
+}
+.mdi-pan-down::before {
+ content: "\FB93";
+}
+.mdi-pan-horizontal::before {
+ content: "\FB94";
+}
+.mdi-pan-left::before {
+ content: "\FB95";
+}
+.mdi-pan-right::before {
+ content: "\FB96";
+}
+.mdi-pan-top-left::before {
+ content: "\FB97";
+}
+.mdi-pan-top-right::before {
+ content: "\FB98";
+}
+.mdi-pan-up::before {
+ content: "\FB99";
+}
+.mdi-pan-vertical::before {
+ content: "\FB9A";
+}
+.mdi-panda::before {
+ content: "\F3DA";
+}
+.mdi-pandora::before {
+ content: "\F3DB";
+}
+.mdi-panorama::before {
+ content: "\F3DC";
+}
+.mdi-panorama-fisheye::before {
+ content: "\F3DD";
+}
+.mdi-panorama-horizontal::before {
+ content: "\F3DE";
+}
+.mdi-panorama-vertical::before {
+ content: "\F3DF";
+}
+.mdi-panorama-wide-angle::before {
+ content: "\F3E0";
+}
+.mdi-paper-cut-vertical::before {
+ content: "\F3E1";
+}
+.mdi-paper-roll::before {
+ content: "\F0182";
+}
+.mdi-paper-roll-outline::before {
+ content: "\F0183";
+}
+.mdi-paperclip::before {
+ content: "\F3E2";
+}
+.mdi-parachute::before {
+ content: "\FC90";
+}
+.mdi-parachute-outline::before {
+ content: "\FC91";
+}
+.mdi-parking::before {
+ content: "\F3E3";
+}
+.mdi-party-popper::before {
+ content: "\F0078";
+}
+.mdi-passport::before {
+ content: "\F7E2";
+}
+.mdi-passport-biometric::before {
+ content: "\FDBD";
+}
+.mdi-pasta::before {
+ content: "\F018B";
+}
+.mdi-patio-heater::before {
+ content: "\FF9D";
+}
+.mdi-patreon::before {
+ content: "\F881";
+}
+.mdi-pause::before {
+ content: "\F3E4";
+}
+.mdi-pause-circle::before {
+ content: "\F3E5";
+}
+.mdi-pause-circle-outline::before {
+ content: "\F3E6";
+}
+.mdi-pause-octagon::before {
+ content: "\F3E7";
+}
+.mdi-pause-octagon-outline::before {
+ content: "\F3E8";
+}
+.mdi-paw::before {
+ content: "\F3E9";
+}
+.mdi-paw-off::before {
+ content: "\F657";
+}
+.mdi-paypal::before {
+ content: "\F882";
+}
+.mdi-pdf-box::before {
+ content: "\FE39";
+}
+.mdi-peace::before {
+ content: "\F883";
+}
+.mdi-peanut::before {
+ content: "\F001E";
+}
+.mdi-peanut-off::before {
+ content: "\F001F";
+}
+.mdi-peanut-off-outline::before {
+ content: "\F0021";
+}
+.mdi-peanut-outline::before {
+ content: "\F0020";
+}
+.mdi-pen::before {
+ content: "\F3EA";
+}
+.mdi-pen-lock::before {
+ content: "\FDBE";
+}
+.mdi-pen-minus::before {
+ content: "\FDBF";
+}
+.mdi-pen-off::before {
+ content: "\FDC0";
+}
+.mdi-pen-plus::before {
+ content: "\FDC1";
+}
+.mdi-pen-remove::before {
+ content: "\FDC2";
+}
+.mdi-pencil::before {
+ content: "\F3EB";
+}
+.mdi-pencil-box::before {
+ content: "\F3EC";
+}
+.mdi-pencil-box-multiple::before {
+ content: "\F016F";
+}
+.mdi-pencil-box-multiple-outline::before {
+ content: "\F0170";
+}
+.mdi-pencil-box-outline::before {
+ content: "\F3ED";
+}
+.mdi-pencil-circle::before {
+ content: "\F6FE";
+}
+.mdi-pencil-circle-outline::before {
+ content: "\F775";
+}
+.mdi-pencil-lock::before {
+ content: "\F3EE";
+}
+.mdi-pencil-lock-outline::before {
+ content: "\FDC3";
+}
+.mdi-pencil-minus::before {
+ content: "\FDC4";
+}
+.mdi-pencil-minus-outline::before {
+ content: "\FDC5";
+}
+.mdi-pencil-off::before {
+ content: "\F3EF";
+}
+.mdi-pencil-off-outline::before {
+ content: "\FDC6";
+}
+.mdi-pencil-outline::before {
+ content: "\FC92";
+}
+.mdi-pencil-plus::before {
+ content: "\FDC7";
+}
+.mdi-pencil-plus-outline::before {
+ content: "\FDC8";
+}
+.mdi-pencil-remove::before {
+ content: "\FDC9";
+}
+.mdi-pencil-remove-outline::before {
+ content: "\FDCA";
+}
+.mdi-pencil-ruler::before {
+ content: "\F037E";
+}
+.mdi-penguin::before {
+ content: "\FEDD";
+}
+.mdi-pentagon::before {
+ content: "\F6FF";
+}
+.mdi-pentagon-outline::before {
+ content: "\F700";
+}
+.mdi-percent::before {
+ content: "\F3F0";
+}
+.mdi-percent-outline::before {
+ content: "\F02A3";
+}
+.mdi-periodic-table::before {
+ content: "\F8B5";
+}
+.mdi-periodic-table-co::before {
+ content: "\F0329";
+}
+.mdi-periodic-table-co2::before {
+ content: "\F7E3";
+}
+.mdi-periscope::before {
+ content: "\F747";
+}
+.mdi-perspective-less::before {
+ content: "\FCFF";
+}
+.mdi-perspective-more::before {
+ content: "\FD00";
+}
+.mdi-pharmacy::before {
+ content: "\F3F1";
+}
+.mdi-phone::before {
+ content: "\F3F2";
+}
+.mdi-phone-alert::before {
+ content: "\FF37";
+}
+.mdi-phone-alert-outline::before {
+ content: "\F01B9";
+}
+.mdi-phone-bluetooth::before {
+ content: "\F3F3";
+}
+.mdi-phone-bluetooth-outline::before {
+ content: "\F01BA";
+}
+.mdi-phone-cancel::before {
+ content: "\F00E7";
+}
+.mdi-phone-cancel-outline::before {
+ content: "\F01BB";
+}
+.mdi-phone-check::before {
+ content: "\F01D4";
+}
+.mdi-phone-check-outline::before {
+ content: "\F01D5";
+}
+.mdi-phone-classic::before {
+ content: "\F602";
+}
+.mdi-phone-classic-off::before {
+ content: "\F02A4";
+}
+.mdi-phone-forward::before {
+ content: "\F3F4";
+}
+.mdi-phone-forward-outline::before {
+ content: "\F01BC";
+}
+.mdi-phone-hangup::before {
+ content: "\F3F5";
+}
+.mdi-phone-hangup-outline::before {
+ content: "\F01BD";
+}
+.mdi-phone-in-talk::before {
+ content: "\F3F6";
+}
+.mdi-phone-in-talk-outline::before {
+ content: "\F01AD";
+}
+.mdi-phone-incoming::before {
+ content: "\F3F7";
+}
+.mdi-phone-incoming-outline::before {
+ content: "\F01BE";
+}
+.mdi-phone-lock::before {
+ content: "\F3F8";
+}
+.mdi-phone-lock-outline::before {
+ content: "\F01BF";
+}
+.mdi-phone-log::before {
+ content: "\F3F9";
+}
+.mdi-phone-log-outline::before {
+ content: "\F01C0";
+}
+.mdi-phone-message::before {
+ content: "\F01C1";
+}
+.mdi-phone-message-outline::before {
+ content: "\F01C2";
+}
+.mdi-phone-minus::before {
+ content: "\F658";
+}
+.mdi-phone-minus-outline::before {
+ content: "\F01C3";
+}
+.mdi-phone-missed::before {
+ content: "\F3FA";
+}
+.mdi-phone-missed-outline::before {
+ content: "\F01D0";
+}
+.mdi-phone-off::before {
+ content: "\FDCB";
+}
+.mdi-phone-off-outline::before {
+ content: "\F01D1";
+}
+.mdi-phone-outgoing::before {
+ content: "\F3FB";
+}
+.mdi-phone-outgoing-outline::before {
+ content: "\F01C4";
+}
+.mdi-phone-outline::before {
+ content: "\FDCC";
+}
+.mdi-phone-paused::before {
+ content: "\F3FC";
+}
+.mdi-phone-paused-outline::before {
+ content: "\F01C5";
+}
+.mdi-phone-plus::before {
+ content: "\F659";
+}
+.mdi-phone-plus-outline::before {
+ content: "\F01C6";
+}
+.mdi-phone-return::before {
+ content: "\F82E";
+}
+.mdi-phone-return-outline::before {
+ content: "\F01C7";
+}
+.mdi-phone-ring::before {
+ content: "\F01D6";
+}
+.mdi-phone-ring-outline::before {
+ content: "\F01D7";
+}
+.mdi-phone-rotate-landscape::before {
+ content: "\F884";
+}
+.mdi-phone-rotate-portrait::before {
+ content: "\F885";
+}
+.mdi-phone-settings::before {
+ content: "\F3FD";
+}
+.mdi-phone-settings-outline::before {
+ content: "\F01C8";
+}
+.mdi-phone-voip::before {
+ content: "\F3FE";
+}
+.mdi-pi::before {
+ content: "\F3FF";
+}
+.mdi-pi-box::before {
+ content: "\F400";
+}
+.mdi-pi-hole::before {
+ content: "\FDCD";
+}
+.mdi-piano::before {
+ content: "\F67C";
+}
+.mdi-pickaxe::before {
+ content: "\F8B6";
+}
+.mdi-picture-in-picture-bottom-right::before {
+ content: "\FE3A";
+}
+.mdi-picture-in-picture-bottom-right-outline::before {
+ content: "\FE3B";
+}
+.mdi-picture-in-picture-top-right::before {
+ content: "\FE3C";
+}
+.mdi-picture-in-picture-top-right-outline::before {
+ content: "\FE3D";
+}
+.mdi-pier::before {
+ content: "\F886";
+}
+.mdi-pier-crane::before {
+ content: "\F887";
+}
+.mdi-pig::before {
+ content: "\F401";
+}
+.mdi-pig-variant::before {
+ content: "\F0028";
+}
+.mdi-piggy-bank::before {
+ content: "\F0029";
+}
+.mdi-pill::before {
+ content: "\F402";
+}
+.mdi-pillar::before {
+ content: "\F701";
+}
+.mdi-pin::before {
+ content: "\F403";
+}
+.mdi-pin-off::before {
+ content: "\F404";
+}
+.mdi-pin-off-outline::before {
+ content: "\F92F";
+}
+.mdi-pin-outline::before {
+ content: "\F930";
+}
+.mdi-pine-tree::before {
+ content: "\F405";
+}
+.mdi-pine-tree-box::before {
+ content: "\F406";
+}
+.mdi-pinterest::before {
+ content: "\F407";
+}
+.mdi-pinterest-box::before {
+ content: "\F408";
+}
+.mdi-pinwheel::before {
+ content: "\FAD4";
+}
+.mdi-pinwheel-outline::before {
+ content: "\FAD5";
+}
+.mdi-pipe::before {
+ content: "\F7E4";
+}
+.mdi-pipe-disconnected::before {
+ content: "\F7E5";
+}
+.mdi-pipe-leak::before {
+ content: "\F888";
+}
+.mdi-pipe-wrench::before {
+ content: "\F037F";
+}
+.mdi-pirate::before {
+ content: "\FA07";
+}
+.mdi-pistol::before {
+ content: "\F702";
+}
+.mdi-piston::before {
+ content: "\F889";
+}
+.mdi-pizza::before {
+ content: "\F409";
+}
+.mdi-play::before {
+ content: "\F40A";
+}
+.mdi-play-box::before {
+ content: "\F02A5";
+}
+.mdi-play-box-outline::before {
+ content: "\F40B";
+}
+.mdi-play-circle::before {
+ content: "\F40C";
+}
+.mdi-play-circle-outline::before {
+ content: "\F40D";
+}
+.mdi-play-network::before {
+ content: "\F88A";
+}
+.mdi-play-network-outline::before {
+ content: "\FC93";
+}
+.mdi-play-outline::before {
+ content: "\FF38";
+}
+.mdi-play-pause::before {
+ content: "\F40E";
+}
+.mdi-play-protected-content::before {
+ content: "\F40F";
+}
+.mdi-play-speed::before {
+ content: "\F8FE";
+}
+.mdi-playlist-check::before {
+ content: "\F5C7";
+}
+.mdi-playlist-edit::before {
+ content: "\F8FF";
+}
+.mdi-playlist-minus::before {
+ content: "\F410";
+}
+.mdi-playlist-music::before {
+ content: "\FC94";
+}
+.mdi-playlist-music-outline::before {
+ content: "\FC95";
+}
+.mdi-playlist-play::before {
+ content: "\F411";
+}
+.mdi-playlist-plus::before {
+ content: "\F412";
+}
+.mdi-playlist-remove::before {
+ content: "\F413";
+}
+.mdi-playlist-star::before {
+ content: "\FDCE";
+}
+.mdi-playstation::before {
+ content: "\F414";
+}
+.mdi-plex::before {
+ content: "\F6B9";
+}
+.mdi-plus::before {
+ content: "\F415";
+}
+.mdi-plus-box::before {
+ content: "\F416";
+}
+.mdi-plus-box-multiple::before {
+ content: "\F334";
+}
+.mdi-plus-box-multiple-outline::before {
+ content: "\F016E";
+}
+.mdi-plus-box-outline::before {
+ content: "\F703";
+}
+.mdi-plus-circle::before {
+ content: "\F417";
+}
+.mdi-plus-circle-multiple-outline::before {
+ content: "\F418";
+}
+.mdi-plus-circle-outline::before {
+ content: "\F419";
+}
+.mdi-plus-minus::before {
+ content: "\F991";
+}
+.mdi-plus-minus-box::before {
+ content: "\F992";
+}
+.mdi-plus-network::before {
+ content: "\F41A";
+}
+.mdi-plus-network-outline::before {
+ content: "\FC96";
+}
+.mdi-plus-one::before {
+ content: "\F41B";
+}
+.mdi-plus-outline::before {
+ content: "\F704";
+}
+.mdi-plus-thick::before {
+ content: "\F0217";
+}
+.mdi-pocket::before {
+ content: "\F41C";
+}
+.mdi-podcast::before {
+ content: "\F993";
+}
+.mdi-podium::before {
+ content: "\FD01";
+}
+.mdi-podium-bronze::before {
+ content: "\FD02";
+}
+.mdi-podium-gold::before {
+ content: "\FD03";
+}
+.mdi-podium-silver::before {
+ content: "\FD04";
+}
+.mdi-point-of-sale::before {
+ content: "\FD6E";
+}
+.mdi-pokeball::before {
+ content: "\F41D";
+}
+.mdi-pokemon-go::before {
+ content: "\FA08";
+}
+.mdi-poker-chip::before {
+ content: "\F82F";
+}
+.mdi-polaroid::before {
+ content: "\F41E";
+}
+.mdi-police-badge::before {
+ content: "\F0192";
+}
+.mdi-police-badge-outline::before {
+ content: "\F0193";
+}
+.mdi-poll::before {
+ content: "\F41F";
+}
+.mdi-poll-box::before {
+ content: "\F420";
+}
+.mdi-poll-box-outline::before {
+ content: "\F02A6";
+}
+.mdi-polymer::before {
+ content: "\F421";
+}
+.mdi-pool::before {
+ content: "\F606";
+}
+.mdi-popcorn::before {
+ content: "\F422";
+}
+.mdi-post::before {
+ content: "\F002A";
+}
+.mdi-post-outline::before {
+ content: "\F002B";
+}
+.mdi-postage-stamp::before {
+ content: "\FC97";
+}
+.mdi-pot::before {
+ content: "\F65A";
+}
+.mdi-pot-mix::before {
+ content: "\F65B";
+}
+.mdi-pound::before {
+ content: "\F423";
+}
+.mdi-pound-box::before {
+ content: "\F424";
+}
+.mdi-pound-box-outline::before {
+ content: "\F01AA";
+}
+.mdi-power::before {
+ content: "\F425";
+}
+.mdi-power-cycle::before {
+ content: "\F900";
+}
+.mdi-power-off::before {
+ content: "\F901";
+}
+.mdi-power-on::before {
+ content: "\F902";
+}
+.mdi-power-plug::before {
+ content: "\F6A4";
+}
+.mdi-power-plug-off::before {
+ content: "\F6A5";
+}
+.mdi-power-settings::before {
+ content: "\F426";
+}
+.mdi-power-sleep::before {
+ content: "\F903";
+}
+.mdi-power-socket::before {
+ content: "\F427";
+}
+.mdi-power-socket-au::before {
+ content: "\F904";
+}
+.mdi-power-socket-de::before {
+ content: "\F0132";
+}
+.mdi-power-socket-eu::before {
+ content: "\F7E6";
+}
+.mdi-power-socket-fr::before {
+ content: "\F0133";
+}
+.mdi-power-socket-jp::before {
+ content: "\F0134";
+}
+.mdi-power-socket-uk::before {
+ content: "\F7E7";
+}
+.mdi-power-socket-us::before {
+ content: "\F7E8";
+}
+.mdi-power-standby::before {
+ content: "\F905";
+}
+.mdi-powershell::before {
+ content: "\FA09";
+}
+.mdi-prescription::before {
+ content: "\F705";
+}
+.mdi-presentation::before {
+ content: "\F428";
+}
+.mdi-presentation-play::before {
+ content: "\F429";
+}
+.mdi-printer::before {
+ content: "\F42A";
+}
+.mdi-printer-3d::before {
+ content: "\F42B";
+}
+.mdi-printer-3d-nozzle::before {
+ content: "\FE3E";
+}
+.mdi-printer-3d-nozzle-alert::before {
+ content: "\F01EB";
+}
+.mdi-printer-3d-nozzle-alert-outline::before {
+ content: "\F01EC";
+}
+.mdi-printer-3d-nozzle-outline::before {
+ content: "\FE3F";
+}
+.mdi-printer-alert::before {
+ content: "\F42C";
+}
+.mdi-printer-check::before {
+ content: "\F0171";
+}
+.mdi-printer-off::before {
+ content: "\FE40";
+}
+.mdi-printer-pos::before {
+ content: "\F0079";
+}
+.mdi-printer-settings::before {
+ content: "\F706";
+}
+.mdi-printer-wireless::before {
+ content: "\FA0A";
+}
+.mdi-priority-high::before {
+ content: "\F603";
+}
+.mdi-priority-low::before {
+ content: "\F604";
+}
+.mdi-professional-hexagon::before {
+ content: "\F42D";
+}
+.mdi-progress-alert::before {
+ content: "\FC98";
+}
+.mdi-progress-check::before {
+ content: "\F994";
+}
+.mdi-progress-clock::before {
+ content: "\F995";
+}
+.mdi-progress-close::before {
+ content: "\F0135";
+}
+.mdi-progress-download::before {
+ content: "\F996";
+}
+.mdi-progress-upload::before {
+ content: "\F997";
+}
+.mdi-progress-wrench::before {
+ content: "\FC99";
+}
+.mdi-projector::before {
+ content: "\F42E";
+}
+.mdi-projector-screen::before {
+ content: "\F42F";
+}
+.mdi-propane-tank::before {
+ content: "\F0382";
+}
+.mdi-propane-tank-outline::before {
+ content: "\F0383";
+}
+.mdi-protocol::before {
+ content: "\FFF9";
+}
+.mdi-publish::before {
+ content: "\F6A6";
+}
+.mdi-pulse::before {
+ content: "\F430";
+}
+.mdi-pumpkin::before {
+ content: "\FB9B";
+}
+.mdi-purse::before {
+ content: "\FF39";
+}
+.mdi-purse-outline::before {
+ content: "\FF3A";
+}
+.mdi-puzzle::before {
+ content: "\F431";
+}
+.mdi-puzzle-outline::before {
+ content: "\FA65";
+}
+.mdi-qi::before {
+ content: "\F998";
+}
+.mdi-qqchat::before {
+ content: "\F605";
+}
+.mdi-qrcode::before {
+ content: "\F432";
+}
+.mdi-qrcode-edit::before {
+ content: "\F8B7";
+}
+.mdi-qrcode-minus::before {
+ content: "\F01B7";
+}
+.mdi-qrcode-plus::before {
+ content: "\F01B6";
+}
+.mdi-qrcode-remove::before {
+ content: "\F01B8";
+}
+.mdi-qrcode-scan::before {
+ content: "\F433";
+}
+.mdi-quadcopter::before {
+ content: "\F434";
+}
+.mdi-quality-high::before {
+ content: "\F435";
+}
+.mdi-quality-low::before {
+ content: "\FA0B";
+}
+.mdi-quality-medium::before {
+ content: "\FA0C";
+}
+.mdi-quicktime::before {
+ content: "\F436";
+}
+.mdi-quora::before {
+ content: "\FD05";
+}
+.mdi-rabbit::before {
+ content: "\F906";
+}
+.mdi-racing-helmet::before {
+ content: "\FD6F";
+}
+.mdi-racquetball::before {
+ content: "\FD70";
+}
+.mdi-radar::before {
+ content: "\F437";
+}
+.mdi-radiator::before {
+ content: "\F438";
+}
+.mdi-radiator-disabled::before {
+ content: "\FAD6";
+}
+.mdi-radiator-off::before {
+ content: "\FAD7";
+}
+.mdi-radio::before {
+ content: "\F439";
+}
+.mdi-radio-am::before {
+ content: "\FC9A";
+}
+.mdi-radio-fm::before {
+ content: "\FC9B";
+}
+.mdi-radio-handheld::before {
+ content: "\F43A";
+}
+.mdi-radio-off::before {
+ content: "\F0247";
+}
+.mdi-radio-tower::before {
+ content: "\F43B";
+}
+.mdi-radioactive::before {
+ content: "\F43C";
+}
+.mdi-radioactive-off::before {
+ content: "\FEDE";
+}
+.mdi-radiobox-blank::before {
+ content: "\F43D";
+}
+.mdi-radiobox-marked::before {
+ content: "\F43E";
+}
+.mdi-radius::before {
+ content: "\FC9C";
+}
+.mdi-radius-outline::before {
+ content: "\FC9D";
+}
+.mdi-railroad-light::before {
+ content: "\FF3B";
+}
+.mdi-raspberry-pi::before {
+ content: "\F43F";
+}
+.mdi-ray-end::before {
+ content: "\F440";
+}
+.mdi-ray-end-arrow::before {
+ content: "\F441";
+}
+.mdi-ray-start::before {
+ content: "\F442";
+}
+.mdi-ray-start-arrow::before {
+ content: "\F443";
+}
+.mdi-ray-start-end::before {
+ content: "\F444";
+}
+.mdi-ray-vertex::before {
+ content: "\F445";
+}
+.mdi-react::before {
+ content: "\F707";
+}
+.mdi-read::before {
+ content: "\F447";
+}
+.mdi-receipt::before {
+ content: "\F449";
+}
+.mdi-record::before {
+ content: "\F44A";
+}
+.mdi-record-circle::before {
+ content: "\FEDF";
+}
+.mdi-record-circle-outline::before {
+ content: "\FEE0";
+}
+.mdi-record-player::before {
+ content: "\F999";
+}
+.mdi-record-rec::before {
+ content: "\F44B";
+}
+.mdi-rectangle::before {
+ content: "\FE41";
+}
+.mdi-rectangle-outline::before {
+ content: "\FE42";
+}
+.mdi-recycle::before {
+ content: "\F44C";
+}
+.mdi-reddit::before {
+ content: "\F44D";
+}
+.mdi-redhat::before {
+ content: "\F0146";
+}
+.mdi-redo::before {
+ content: "\F44E";
+}
+.mdi-redo-variant::before {
+ content: "\F44F";
+}
+.mdi-reflect-horizontal::before {
+ content: "\FA0D";
+}
+.mdi-reflect-vertical::before {
+ content: "\FA0E";
+}
+.mdi-refresh::before {
+ content: "\F450";
+}
+.mdi-refresh-circle::before {
+ content: "\F03A2";
+}
+.mdi-regex::before {
+ content: "\F451";
+}
+.mdi-registered-trademark::before {
+ content: "\FA66";
+}
+.mdi-relative-scale::before {
+ content: "\F452";
+}
+.mdi-reload::before {
+ content: "\F453";
+}
+.mdi-reload-alert::before {
+ content: "\F0136";
+}
+.mdi-reminder::before {
+ content: "\F88B";
+}
+.mdi-remote::before {
+ content: "\F454";
+}
+.mdi-remote-desktop::before {
+ content: "\F8B8";
+}
+.mdi-remote-off::before {
+ content: "\FEE1";
+}
+.mdi-remote-tv::before {
+ content: "\FEE2";
+}
+.mdi-remote-tv-off::before {
+ content: "\FEE3";
+}
+.mdi-rename-box::before {
+ content: "\F455";
+}
+.mdi-reorder-horizontal::before {
+ content: "\F687";
+}
+.mdi-reorder-vertical::before {
+ content: "\F688";
+}
+.mdi-repeat::before {
+ content: "\F456";
+}
+.mdi-repeat-off::before {
+ content: "\F457";
+}
+.mdi-repeat-once::before {
+ content: "\F458";
+}
+.mdi-replay::before {
+ content: "\F459";
+}
+.mdi-reply::before {
+ content: "\F45A";
+}
+.mdi-reply-all::before {
+ content: "\F45B";
+}
+.mdi-reply-all-outline::before {
+ content: "\FF3C";
+}
+.mdi-reply-circle::before {
+ content: "\F01D9";
+}
+.mdi-reply-outline::before {
+ content: "\FF3D";
+}
+.mdi-reproduction::before {
+ content: "\F45C";
+}
+.mdi-resistor::before {
+ content: "\FB1F";
+}
+.mdi-resistor-nodes::before {
+ content: "\FB20";
+}
+.mdi-resize::before {
+ content: "\FA67";
+}
+.mdi-resize-bottom-right::before {
+ content: "\F45D";
+}
+.mdi-responsive::before {
+ content: "\F45E";
+}
+.mdi-restart::before {
+ content: "\F708";
+}
+.mdi-restart-alert::before {
+ content: "\F0137";
+}
+.mdi-restart-off::before {
+ content: "\FD71";
+}
+.mdi-restore::before {
+ content: "\F99A";
+}
+.mdi-restore-alert::before {
+ content: "\F0138";
+}
+.mdi-rewind::before {
+ content: "\F45F";
+}
+.mdi-rewind-10::before {
+ content: "\FD06";
+}
+.mdi-rewind-30::before {
+ content: "\FD72";
+}
+.mdi-rewind-5::before {
+ content: "\F0224";
+}
+.mdi-rewind-outline::before {
+ content: "\F709";
+}
+.mdi-rhombus::before {
+ content: "\F70A";
+}
+.mdi-rhombus-medium::before {
+ content: "\FA0F";
+}
+.mdi-rhombus-outline::before {
+ content: "\F70B";
+}
+.mdi-rhombus-split::before {
+ content: "\FA10";
+}
+.mdi-ribbon::before {
+ content: "\F460";
+}
+.mdi-rice::before {
+ content: "\F7E9";
+}
+.mdi-ring::before {
+ content: "\F7EA";
+}
+.mdi-rivet::before {
+ content: "\FE43";
+}
+.mdi-road::before {
+ content: "\F461";
+}
+.mdi-road-variant::before {
+ content: "\F462";
+}
+.mdi-robber::before {
+ content: "\F007A";
+}
+.mdi-robot::before {
+ content: "\F6A8";
+}
+.mdi-robot-industrial::before {
+ content: "\FB21";
+}
+.mdi-robot-mower::before {
+ content: "\F0222";
+}
+.mdi-robot-mower-outline::before {
+ content: "\F021E";
+}
+.mdi-robot-vacuum::before {
+ content: "\F70C";
+}
+.mdi-robot-vacuum-variant::before {
+ content: "\F907";
+}
+.mdi-rocket::before {
+ content: "\F463";
+}
+.mdi-rodent::before {
+ content: "\F0352";
+}
+.mdi-roller-skate::before {
+ content: "\FD07";
+}
+.mdi-rollerblade::before {
+ content: "\FD08";
+}
+.mdi-rollupjs::before {
+ content: "\FB9C";
+}
+.mdi-roman-numeral-1::before {
+ content: "\F00B3";
+}
+.mdi-roman-numeral-10::before {
+ content: "\F00BC";
+}
+.mdi-roman-numeral-2::before {
+ content: "\F00B4";
+}
+.mdi-roman-numeral-3::before {
+ content: "\F00B5";
+}
+.mdi-roman-numeral-4::before {
+ content: "\F00B6";
+}
+.mdi-roman-numeral-5::before {
+ content: "\F00B7";
+}
+.mdi-roman-numeral-6::before {
+ content: "\F00B8";
+}
+.mdi-roman-numeral-7::before {
+ content: "\F00B9";
+}
+.mdi-roman-numeral-8::before {
+ content: "\F00BA";
+}
+.mdi-roman-numeral-9::before {
+ content: "\F00BB";
+}
+.mdi-room-service::before {
+ content: "\F88C";
+}
+.mdi-room-service-outline::before {
+ content: "\FD73";
+}
+.mdi-rotate-3d::before {
+ content: "\FEE4";
+}
+.mdi-rotate-3d-variant::before {
+ content: "\F464";
+}
+.mdi-rotate-left::before {
+ content: "\F465";
+}
+.mdi-rotate-left-variant::before {
+ content: "\F466";
+}
+.mdi-rotate-orbit::before {
+ content: "\FD74";
+}
+.mdi-rotate-right::before {
+ content: "\F467";
+}
+.mdi-rotate-right-variant::before {
+ content: "\F468";
+}
+.mdi-rounded-corner::before {
+ content: "\F607";
+}
+.mdi-router::before {
+ content: "\F020D";
+}
+.mdi-router-wireless::before {
+ content: "\F469";
+}
+.mdi-router-wireless-settings::before {
+ content: "\FA68";
+}
+.mdi-routes::before {
+ content: "\F46A";
+}
+.mdi-routes-clock::before {
+ content: "\F007B";
+}
+.mdi-rowing::before {
+ content: "\F608";
+}
+.mdi-rss::before {
+ content: "\F46B";
+}
+.mdi-rss-box::before {
+ content: "\F46C";
+}
+.mdi-rss-off::before {
+ content: "\FF3E";
+}
+.mdi-ruby::before {
+ content: "\FD09";
+}
+.mdi-rugby::before {
+ content: "\FD75";
+}
+.mdi-ruler::before {
+ content: "\F46D";
+}
+.mdi-ruler-square::before {
+ content: "\FC9E";
+}
+.mdi-ruler-square-compass::before {
+ content: "\FEDB";
+}
+.mdi-run::before {
+ content: "\F70D";
+}
+.mdi-run-fast::before {
+ content: "\F46E";
+}
+.mdi-rv-truck::before {
+ content: "\F01FF";
+}
+.mdi-sack::before {
+ content: "\FD0A";
+}
+.mdi-sack-percent::before {
+ content: "\FD0B";
+}
+.mdi-safe::before {
+ content: "\FA69";
+}
+.mdi-safe-square::before {
+ content: "\F02A7";
+}
+.mdi-safe-square-outline::before {
+ content: "\F02A8";
+}
+.mdi-safety-goggles::before {
+ content: "\FD0C";
+}
+.mdi-sailing::before {
+ content: "\FEE5";
+}
+.mdi-sale::before {
+ content: "\F46F";
+}
+.mdi-salesforce::before {
+ content: "\F88D";
+}
+.mdi-sass::before {
+ content: "\F7EB";
+}
+.mdi-satellite::before {
+ content: "\F470";
+}
+.mdi-satellite-uplink::before {
+ content: "\F908";
+}
+.mdi-satellite-variant::before {
+ content: "\F471";
+}
+.mdi-sausage::before {
+ content: "\F8B9";
+}
+.mdi-saw-blade::before {
+ content: "\FE44";
+}
+.mdi-saxophone::before {
+ content: "\F609";
+}
+.mdi-scale::before {
+ content: "\F472";
+}
+.mdi-scale-balance::before {
+ content: "\F5D1";
+}
+.mdi-scale-bathroom::before {
+ content: "\F473";
+}
+.mdi-scale-off::before {
+ content: "\F007C";
+}
+.mdi-scanner::before {
+ content: "\F6AA";
+}
+.mdi-scanner-off::before {
+ content: "\F909";
+}
+.mdi-scatter-plot::before {
+ content: "\FEE6";
+}
+.mdi-scatter-plot-outline::before {
+ content: "\FEE7";
+}
+.mdi-school::before {
+ content: "\F474";
+}
+.mdi-school-outline::before {
+ content: "\F01AB";
+}
+.mdi-scissors-cutting::before {
+ content: "\FA6A";
+}
+.mdi-scooter::before {
+ content: "\F0214";
+}
+.mdi-scoreboard::before {
+ content: "\F02A9";
+}
+.mdi-scoreboard-outline::before {
+ content: "\F02AA";
+}
+.mdi-screen-rotation::before {
+ content: "\F475";
+}
+.mdi-screen-rotation-lock::before {
+ content: "\F476";
+}
+.mdi-screw-flat-top::before {
+ content: "\FDCF";
+}
+.mdi-screw-lag::before {
+ content: "\FE54";
+}
+.mdi-screw-machine-flat-top::before {
+ content: "\FE55";
+}
+.mdi-screw-machine-round-top::before {
+ content: "\FE56";
+}
+.mdi-screw-round-top::before {
+ content: "\FE57";
+}
+.mdi-screwdriver::before {
+ content: "\F477";
+}
+.mdi-script::before {
+ content: "\FB9D";
+}
+.mdi-script-outline::before {
+ content: "\F478";
+}
+.mdi-script-text::before {
+ content: "\FB9E";
+}
+.mdi-script-text-outline::before {
+ content: "\FB9F";
+}
+.mdi-sd::before {
+ content: "\F479";
+}
+.mdi-seal::before {
+ content: "\F47A";
+}
+.mdi-seal-variant::before {
+ content: "\FFFA";
+}
+.mdi-search-web::before {
+ content: "\F70E";
+}
+.mdi-seat::before {
+ content: "\FC9F";
+}
+.mdi-seat-flat::before {
+ content: "\F47B";
+}
+.mdi-seat-flat-angled::before {
+ content: "\F47C";
+}
+.mdi-seat-individual-suite::before {
+ content: "\F47D";
+}
+.mdi-seat-legroom-extra::before {
+ content: "\F47E";
+}
+.mdi-seat-legroom-normal::before {
+ content: "\F47F";
+}
+.mdi-seat-legroom-reduced::before {
+ content: "\F480";
+}
+.mdi-seat-outline::before {
+ content: "\FCA0";
+}
+.mdi-seat-passenger::before {
+ content: "\F0274";
+}
+.mdi-seat-recline-extra::before {
+ content: "\F481";
+}
+.mdi-seat-recline-normal::before {
+ content: "\F482";
+}
+.mdi-seatbelt::before {
+ content: "\FCA1";
+}
+.mdi-security::before {
+ content: "\F483";
+}
+.mdi-security-network::before {
+ content: "\F484";
+}
+.mdi-seed::before {
+ content: "\FE45";
+}
+.mdi-seed-outline::before {
+ content: "\FE46";
+}
+.mdi-segment::before {
+ content: "\FEE8";
+}
+.mdi-select::before {
+ content: "\F485";
+}
+.mdi-select-all::before {
+ content: "\F486";
+}
+.mdi-select-color::before {
+ content: "\FD0D";
+}
+.mdi-select-compare::before {
+ content: "\FAD8";
+}
+.mdi-select-drag::before {
+ content: "\FA6B";
+}
+.mdi-select-group::before {
+ content: "\FF9F";
+}
+.mdi-select-inverse::before {
+ content: "\F487";
+}
+.mdi-select-marker::before {
+ content: "\F02AB";
+}
+.mdi-select-multiple::before {
+ content: "\F02AC";
+}
+.mdi-select-multiple-marker::before {
+ content: "\F02AD";
+}
+.mdi-select-off::before {
+ content: "\F488";
+}
+.mdi-select-place::before {
+ content: "\FFFB";
+}
+.mdi-select-search::before {
+ content: "\F022F";
+}
+.mdi-selection::before {
+ content: "\F489";
+}
+.mdi-selection-drag::before {
+ content: "\FA6C";
+}
+.mdi-selection-ellipse::before {
+ content: "\FD0E";
+}
+.mdi-selection-ellipse-arrow-inside::before {
+ content: "\FF3F";
+}
+.mdi-selection-marker::before {
+ content: "\F02AE";
+}
+.mdi-selection-multiple-marker::before {
+ content: "\F02AF";
+}
+.mdi-selection-mutliple::before {
+ content: "\F02B0";
+}
+.mdi-selection-off::before {
+ content: "\F776";
+}
+.mdi-selection-search::before {
+ content: "\F0230";
+}
+.mdi-semantic-web::before {
+ content: "\F0341";
+}
+.mdi-send::before {
+ content: "\F48A";
+}
+.mdi-send-check::before {
+ content: "\F018C";
+}
+.mdi-send-check-outline::before {
+ content: "\F018D";
+}
+.mdi-send-circle::before {
+ content: "\FE58";
+}
+.mdi-send-circle-outline::before {
+ content: "\FE59";
+}
+.mdi-send-clock::before {
+ content: "\F018E";
+}
+.mdi-send-clock-outline::before {
+ content: "\F018F";
+}
+.mdi-send-lock::before {
+ content: "\F7EC";
+}
+.mdi-send-lock-outline::before {
+ content: "\F0191";
+}
+.mdi-send-outline::before {
+ content: "\F0190";
+}
+.mdi-serial-port::before {
+ content: "\F65C";
+}
+.mdi-server::before {
+ content: "\F48B";
+}
+.mdi-server-minus::before {
+ content: "\F48C";
+}
+.mdi-server-network::before {
+ content: "\F48D";
+}
+.mdi-server-network-off::before {
+ content: "\F48E";
+}
+.mdi-server-off::before {
+ content: "\F48F";
+}
+.mdi-server-plus::before {
+ content: "\F490";
+}
+.mdi-server-remove::before {
+ content: "\F491";
+}
+.mdi-server-security::before {
+ content: "\F492";
+}
+.mdi-set-all::before {
+ content: "\F777";
+}
+.mdi-set-center::before {
+ content: "\F778";
+}
+.mdi-set-center-right::before {
+ content: "\F779";
+}
+.mdi-set-left::before {
+ content: "\F77A";
+}
+.mdi-set-left-center::before {
+ content: "\F77B";
+}
+.mdi-set-left-right::before {
+ content: "\F77C";
+}
+.mdi-set-none::before {
+ content: "\F77D";
+}
+.mdi-set-right::before {
+ content: "\F77E";
+}
+.mdi-set-top-box::before {
+ content: "\F99E";
+}
+.mdi-settings::before {
+ content: "\F493";
+}
+.mdi-settings-box::before {
+ content: "\F494";
+}
+.mdi-settings-helper::before {
+ content: "\FA6D";
+}
+.mdi-settings-outline::before {
+ content: "\F8BA";
+}
+.mdi-settings-transfer::before {
+ content: "\F007D";
+}
+.mdi-settings-transfer-outline::before {
+ content: "\F007E";
+}
+.mdi-shaker::before {
+ content: "\F0139";
+}
+.mdi-shaker-outline::before {
+ content: "\F013A";
+}
+.mdi-shape::before {
+ content: "\F830";
+}
+.mdi-shape-circle-plus::before {
+ content: "\F65D";
+}
+.mdi-shape-outline::before {
+ content: "\F831";
+}
+.mdi-shape-oval-plus::before {
+ content: "\F0225";
+}
+.mdi-shape-plus::before {
+ content: "\F495";
+}
+.mdi-shape-polygon-plus::before {
+ content: "\F65E";
+}
+.mdi-shape-rectangle-plus::before {
+ content: "\F65F";
+}
+.mdi-shape-square-plus::before {
+ content: "\F660";
+}
+.mdi-share::before {
+ content: "\F496";
+}
+.mdi-share-all::before {
+ content: "\F021F";
+}
+.mdi-share-all-outline::before {
+ content: "\F0220";
+}
+.mdi-share-circle::before {
+ content: "\F01D8";
+}
+.mdi-share-off::before {
+ content: "\FF40";
+}
+.mdi-share-off-outline::before {
+ content: "\FF41";
+}
+.mdi-share-outline::before {
+ content: "\F931";
+}
+.mdi-share-variant::before {
+ content: "\F497";
+}
+.mdi-sheep::before {
+ content: "\FCA2";
+}
+.mdi-shield::before {
+ content: "\F498";
+}
+.mdi-shield-account::before {
+ content: "\F88E";
+}
+.mdi-shield-account-outline::before {
+ content: "\FA11";
+}
+.mdi-shield-airplane::before {
+ content: "\F6BA";
+}
+.mdi-shield-airplane-outline::before {
+ content: "\FCA3";
+}
+.mdi-shield-alert::before {
+ content: "\FEE9";
+}
+.mdi-shield-alert-outline::before {
+ content: "\FEEA";
+}
+.mdi-shield-car::before {
+ content: "\FFA0";
+}
+.mdi-shield-check::before {
+ content: "\F565";
+}
+.mdi-shield-check-outline::before {
+ content: "\FCA4";
+}
+.mdi-shield-cross::before {
+ content: "\FCA5";
+}
+.mdi-shield-cross-outline::before {
+ content: "\FCA6";
+}
+.mdi-shield-edit::before {
+ content: "\F01CB";
+}
+.mdi-shield-edit-outline::before {
+ content: "\F01CC";
+}
+.mdi-shield-half::before {
+ content: "\F038B";
+}
+.mdi-shield-half-full::before {
+ content: "\F77F";
+}
+.mdi-shield-home::before {
+ content: "\F689";
+}
+.mdi-shield-home-outline::before {
+ content: "\FCA7";
+}
+.mdi-shield-key::before {
+ content: "\FBA0";
+}
+.mdi-shield-key-outline::before {
+ content: "\FBA1";
+}
+.mdi-shield-link-variant::before {
+ content: "\FD0F";
+}
+.mdi-shield-link-variant-outline::before {
+ content: "\FD10";
+}
+.mdi-shield-lock::before {
+ content: "\F99C";
+}
+.mdi-shield-lock-outline::before {
+ content: "\FCA8";
+}
+.mdi-shield-off::before {
+ content: "\F99D";
+}
+.mdi-shield-off-outline::before {
+ content: "\F99B";
+}
+.mdi-shield-outline::before {
+ content: "\F499";
+}
+.mdi-shield-plus::before {
+ content: "\FAD9";
+}
+.mdi-shield-plus-outline::before {
+ content: "\FADA";
+}
+.mdi-shield-refresh::before {
+ content: "\F01CD";
+}
+.mdi-shield-refresh-outline::before {
+ content: "\F01CE";
+}
+.mdi-shield-remove::before {
+ content: "\FADB";
+}
+.mdi-shield-remove-outline::before {
+ content: "\FADC";
+}
+.mdi-shield-search::before {
+ content: "\FD76";
+}
+.mdi-shield-star::before {
+ content: "\F0166";
+}
+.mdi-shield-star-outline::before {
+ content: "\F0167";
+}
+.mdi-shield-sun::before {
+ content: "\F007F";
+}
+.mdi-shield-sun-outline::before {
+ content: "\F0080";
+}
+.mdi-ship-wheel::before {
+ content: "\F832";
+}
+.mdi-shoe-formal::before {
+ content: "\FB22";
+}
+.mdi-shoe-heel::before {
+ content: "\FB23";
+}
+.mdi-shoe-print::before {
+ content: "\FE5A";
+}
+.mdi-shopify::before {
+ content: "\FADD";
+}
+.mdi-shopping::before {
+ content: "\F49A";
+}
+.mdi-shopping-music::before {
+ content: "\F49B";
+}
+.mdi-shopping-outline::before {
+ content: "\F0200";
+}
+.mdi-shopping-search::before {
+ content: "\FFA1";
+}
+.mdi-shovel::before {
+ content: "\F70F";
+}
+.mdi-shovel-off::before {
+ content: "\F710";
+}
+.mdi-shower::before {
+ content: "\F99F";
+}
+.mdi-shower-head::before {
+ content: "\F9A0";
+}
+.mdi-shredder::before {
+ content: "\F49C";
+}
+.mdi-shuffle::before {
+ content: "\F49D";
+}
+.mdi-shuffle-disabled::before {
+ content: "\F49E";
+}
+.mdi-shuffle-variant::before {
+ content: "\F49F";
+}
+.mdi-shuriken::before {
+ content: "\F03AA";
+}
+.mdi-sigma::before {
+ content: "\F4A0";
+}
+.mdi-sigma-lower::before {
+ content: "\F62B";
+}
+.mdi-sign-caution::before {
+ content: "\F4A1";
+}
+.mdi-sign-direction::before {
+ content: "\F780";
+}
+.mdi-sign-direction-minus::before {
+ content: "\F0022";
+}
+.mdi-sign-direction-plus::before {
+ content: "\FFFD";
+}
+.mdi-sign-direction-remove::before {
+ content: "\FFFE";
+}
+.mdi-sign-real-estate::before {
+ content: "\F0143";
+}
+.mdi-sign-text::before {
+ content: "\F781";
+}
+.mdi-signal::before {
+ content: "\F4A2";
+}
+.mdi-signal-2g::before {
+ content: "\F711";
+}
+.mdi-signal-3g::before {
+ content: "\F712";
+}
+.mdi-signal-4g::before {
+ content: "\F713";
+}
+.mdi-signal-5g::before {
+ content: "\FA6E";
+}
+.mdi-signal-cellular-1::before {
+ content: "\F8BB";
+}
+.mdi-signal-cellular-2::before {
+ content: "\F8BC";
+}
+.mdi-signal-cellular-3::before {
+ content: "\F8BD";
+}
+.mdi-signal-cellular-outline::before {
+ content: "\F8BE";
+}
+.mdi-signal-distance-variant::before {
+ content: "\FE47";
+}
+.mdi-signal-hspa::before {
+ content: "\F714";
+}
+.mdi-signal-hspa-plus::before {
+ content: "\F715";
+}
+.mdi-signal-off::before {
+ content: "\F782";
+}
+.mdi-signal-variant::before {
+ content: "\F60A";
+}
+.mdi-signature::before {
+ content: "\FE5B";
+}
+.mdi-signature-freehand::before {
+ content: "\FE5C";
+}
+.mdi-signature-image::before {
+ content: "\FE5D";
+}
+.mdi-signature-text::before {
+ content: "\FE5E";
+}
+.mdi-silo::before {
+ content: "\FB24";
+}
+.mdi-silverware::before {
+ content: "\F4A3";
+}
+.mdi-silverware-clean::before {
+ content: "\FFFF";
+}
+.mdi-silverware-fork::before {
+ content: "\F4A4";
+}
+.mdi-silverware-fork-knife::before {
+ content: "\FA6F";
+}
+.mdi-silverware-spoon::before {
+ content: "\F4A5";
+}
+.mdi-silverware-variant::before {
+ content: "\F4A6";
+}
+.mdi-sim::before {
+ content: "\F4A7";
+}
+.mdi-sim-alert::before {
+ content: "\F4A8";
+}
+.mdi-sim-off::before {
+ content: "\F4A9";
+}
+.mdi-simple-icons::before {
+ content: "\F0348";
+}
+.mdi-sina-weibo::before {
+ content: "\FADE";
+}
+.mdi-sitemap::before {
+ content: "\F4AA";
+}
+.mdi-skate::before {
+ content: "\FD11";
+}
+.mdi-skew-less::before {
+ content: "\FD12";
+}
+.mdi-skew-more::before {
+ content: "\FD13";
+}
+.mdi-ski::before {
+ content: "\F032F";
+}
+.mdi-ski-cross-country::before {
+ content: "\F0330";
+}
+.mdi-ski-water::before {
+ content: "\F0331";
+}
+.mdi-skip-backward::before {
+ content: "\F4AB";
+}
+.mdi-skip-backward-outline::before {
+ content: "\FF42";
+}
+.mdi-skip-forward::before {
+ content: "\F4AC";
+}
+.mdi-skip-forward-outline::before {
+ content: "\FF43";
+}
+.mdi-skip-next::before {
+ content: "\F4AD";
+}
+.mdi-skip-next-circle::before {
+ content: "\F661";
+}
+.mdi-skip-next-circle-outline::before {
+ content: "\F662";
+}
+.mdi-skip-next-outline::before {
+ content: "\FF44";
+}
+.mdi-skip-previous::before {
+ content: "\F4AE";
+}
+.mdi-skip-previous-circle::before {
+ content: "\F663";
+}
+.mdi-skip-previous-circle-outline::before {
+ content: "\F664";
+}
+.mdi-skip-previous-outline::before {
+ content: "\FF45";
+}
+.mdi-skull::before {
+ content: "\F68B";
+}
+.mdi-skull-crossbones::before {
+ content: "\FBA2";
+}
+.mdi-skull-crossbones-outline::before {
+ content: "\FBA3";
+}
+.mdi-skull-outline::before {
+ content: "\FBA4";
+}
+.mdi-skype::before {
+ content: "\F4AF";
+}
+.mdi-skype-business::before {
+ content: "\F4B0";
+}
+.mdi-slack::before {
+ content: "\F4B1";
+}
+.mdi-slackware::before {
+ content: "\F90A";
+}
+.mdi-slash-forward::before {
+ content: "\F0000";
+}
+.mdi-slash-forward-box::before {
+ content: "\F0001";
+}
+.mdi-sleep::before {
+ content: "\F4B2";
+}
+.mdi-sleep-off::before {
+ content: "\F4B3";
+}
+.mdi-slope-downhill::before {
+ content: "\FE5F";
+}
+.mdi-slope-uphill::before {
+ content: "\FE60";
+}
+.mdi-slot-machine::before {
+ content: "\F013F";
+}
+.mdi-slot-machine-outline::before {
+ content: "\F0140";
+}
+.mdi-smart-card::before {
+ content: "\F00E8";
+}
+.mdi-smart-card-outline::before {
+ content: "\F00E9";
+}
+.mdi-smart-card-reader::before {
+ content: "\F00EA";
+}
+.mdi-smart-card-reader-outline::before {
+ content: "\F00EB";
+}
+.mdi-smog::before {
+ content: "\FA70";
+}
+.mdi-smoke-detector::before {
+ content: "\F392";
+}
+.mdi-smoking::before {
+ content: "\F4B4";
+}
+.mdi-smoking-off::before {
+ content: "\F4B5";
+}
+.mdi-snapchat::before {
+ content: "\F4B6";
+}
+.mdi-snowboard::before {
+ content: "\F0332";
+}
+.mdi-snowflake::before {
+ content: "\F716";
+}
+.mdi-snowflake-alert::before {
+ content: "\FF46";
+}
+.mdi-snowflake-melt::before {
+ content: "\F02F6";
+}
+.mdi-snowflake-variant::before {
+ content: "\FF47";
+}
+.mdi-snowman::before {
+ content: "\F4B7";
+}
+.mdi-soccer::before {
+ content: "\F4B8";
+}
+.mdi-soccer-field::before {
+ content: "\F833";
+}
+.mdi-sofa::before {
+ content: "\F4B9";
+}
+.mdi-solar-panel::before {
+ content: "\FD77";
+}
+.mdi-solar-panel-large::before {
+ content: "\FD78";
+}
+.mdi-solar-power::before {
+ content: "\FA71";
+}
+.mdi-soldering-iron::before {
+ content: "\F00BD";
+}
+.mdi-solid::before {
+ content: "\F68C";
+}
+.mdi-sort::before {
+ content: "\F4BA";
+}
+.mdi-sort-alphabetical::before {
+ content: "\F4BB";
+}
+.mdi-sort-alphabetical-ascending::before {
+ content: "\F0173";
+}
+.mdi-sort-alphabetical-descending::before {
+ content: "\F0174";
+}
+.mdi-sort-ascending::before {
+ content: "\F4BC";
+}
+.mdi-sort-descending::before {
+ content: "\F4BD";
+}
+.mdi-sort-numeric::before {
+ content: "\F4BE";
+}
+.mdi-sort-variant::before {
+ content: "\F4BF";
+}
+.mdi-sort-variant-lock::before {
+ content: "\FCA9";
+}
+.mdi-sort-variant-lock-open::before {
+ content: "\FCAA";
+}
+.mdi-sort-variant-remove::before {
+ content: "\F0172";
+}
+.mdi-soundcloud::before {
+ content: "\F4C0";
+}
+.mdi-source-branch::before {
+ content: "\F62C";
+}
+.mdi-source-commit::before {
+ content: "\F717";
+}
+.mdi-source-commit-end::before {
+ content: "\F718";
+}
+.mdi-source-commit-end-local::before {
+ content: "\F719";
+}
+.mdi-source-commit-local::before {
+ content: "\F71A";
+}
+.mdi-source-commit-next-local::before {
+ content: "\F71B";
+}
+.mdi-source-commit-start::before {
+ content: "\F71C";
+}
+.mdi-source-commit-start-next-local::before {
+ content: "\F71D";
+}
+.mdi-source-fork::before {
+ content: "\F4C1";
+}
+.mdi-source-merge::before {
+ content: "\F62D";
+}
+.mdi-source-pull::before {
+ content: "\F4C2";
+}
+.mdi-source-repository::before {
+ content: "\FCAB";
+}
+.mdi-source-repository-multiple::before {
+ content: "\FCAC";
+}
+.mdi-soy-sauce::before {
+ content: "\F7ED";
+}
+.mdi-spa::before {
+ content: "\FCAD";
+}
+.mdi-spa-outline::before {
+ content: "\FCAE";
+}
+.mdi-space-invaders::before {
+ content: "\FBA5";
+}
+.mdi-space-station::before {
+ content: "\F03AE";
+}
+.mdi-spade::before {
+ content: "\FE48";
+}
+.mdi-speaker::before {
+ content: "\F4C3";
+}
+.mdi-speaker-bluetooth::before {
+ content: "\F9A1";
+}
+.mdi-speaker-multiple::before {
+ content: "\FD14";
+}
+.mdi-speaker-off::before {
+ content: "\F4C4";
+}
+.mdi-speaker-wireless::before {
+ content: "\F71E";
+}
+.mdi-speedometer::before {
+ content: "\F4C5";
+}
+.mdi-speedometer-medium::before {
+ content: "\FFA2";
+}
+.mdi-speedometer-slow::before {
+ content: "\FFA3";
+}
+.mdi-spellcheck::before {
+ content: "\F4C6";
+}
+.mdi-spider::before {
+ content: "\F0215";
+}
+.mdi-spider-thread::before {
+ content: "\F0216";
+}
+.mdi-spider-web::before {
+ content: "\FBA6";
+}
+.mdi-spotify::before {
+ content: "\F4C7";
+}
+.mdi-spotlight::before {
+ content: "\F4C8";
+}
+.mdi-spotlight-beam::before {
+ content: "\F4C9";
+}
+.mdi-spray::before {
+ content: "\F665";
+}
+.mdi-spray-bottle::before {
+ content: "\FADF";
+}
+.mdi-sprinkler::before {
+ content: "\F0081";
+}
+.mdi-sprinkler-variant::before {
+ content: "\F0082";
+}
+.mdi-sprout::before {
+ content: "\FE49";
+}
+.mdi-sprout-outline::before {
+ content: "\FE4A";
+}
+.mdi-square::before {
+ content: "\F763";
+}
+.mdi-square-edit-outline::before {
+ content: "\F90B";
+}
+.mdi-square-inc::before {
+ content: "\F4CA";
+}
+.mdi-square-inc-cash::before {
+ content: "\F4CB";
+}
+.mdi-square-medium::before {
+ content: "\FA12";
+}
+.mdi-square-medium-outline::before {
+ content: "\FA13";
+}
+.mdi-square-off::before {
+ content: "\F0319";
+}
+.mdi-square-off-outline::before {
+ content: "\F031A";
+}
+.mdi-square-outline::before {
+ content: "\F762";
+}
+.mdi-square-root::before {
+ content: "\F783";
+}
+.mdi-square-root-box::before {
+ content: "\F9A2";
+}
+.mdi-square-small::before {
+ content: "\FA14";
+}
+.mdi-squeegee::before {
+ content: "\FAE0";
+}
+.mdi-ssh::before {
+ content: "\F8BF";
+}
+.mdi-stack-exchange::before {
+ content: "\F60B";
+}
+.mdi-stack-overflow::before {
+ content: "\F4CC";
+}
+.mdi-stackpath::before {
+ content: "\F359";
+}
+.mdi-stadium::before {
+ content: "\F001A";
+}
+.mdi-stadium-variant::before {
+ content: "\F71F";
+}
+.mdi-stairs::before {
+ content: "\F4CD";
+}
+.mdi-stairs-down::before {
+ content: "\F02E9";
+}
+.mdi-stairs-up::before {
+ content: "\F02E8";
+}
+.mdi-stamper::before {
+ content: "\FD15";
+}
+.mdi-standard-definition::before {
+ content: "\F7EE";
+}
+.mdi-star::before {
+ content: "\F4CE";
+}
+.mdi-star-box::before {
+ content: "\FA72";
+}
+.mdi-star-box-multiple::before {
+ content: "\F02B1";
+}
+.mdi-star-box-multiple-outline::before {
+ content: "\F02B2";
+}
+.mdi-star-box-outline::before {
+ content: "\FA73";
+}
+.mdi-star-circle::before {
+ content: "\F4CF";
+}
+.mdi-star-circle-outline::before {
+ content: "\F9A3";
+}
+.mdi-star-face::before {
+ content: "\F9A4";
+}
+.mdi-star-four-points::before {
+ content: "\FAE1";
+}
+.mdi-star-four-points-outline::before {
+ content: "\FAE2";
+}
+.mdi-star-half::before {
+ content: "\F4D0";
+}
+.mdi-star-off::before {
+ content: "\F4D1";
+}
+.mdi-star-outline::before {
+ content: "\F4D2";
+}
+.mdi-star-three-points::before {
+ content: "\FAE3";
+}
+.mdi-star-three-points-outline::before {
+ content: "\FAE4";
+}
+.mdi-state-machine::before {
+ content: "\F021A";
+}
+.mdi-steam::before {
+ content: "\F4D3";
+}
+.mdi-steam-box::before {
+ content: "\F90C";
+}
+.mdi-steering::before {
+ content: "\F4D4";
+}
+.mdi-steering-off::before {
+ content: "\F90D";
+}
+.mdi-step-backward::before {
+ content: "\F4D5";
+}
+.mdi-step-backward-2::before {
+ content: "\F4D6";
+}
+.mdi-step-forward::before {
+ content: "\F4D7";
+}
+.mdi-step-forward-2::before {
+ content: "\F4D8";
+}
+.mdi-stethoscope::before {
+ content: "\F4D9";
+}
+.mdi-sticker::before {
+ content: "\F038F";
+}
+.mdi-sticker-alert::before {
+ content: "\F0390";
+}
+.mdi-sticker-alert-outline::before {
+ content: "\F0391";
+}
+.mdi-sticker-check::before {
+ content: "\F0392";
+}
+.mdi-sticker-check-outline::before {
+ content: "\F0393";
+}
+.mdi-sticker-circle-outline::before {
+ content: "\F5D0";
+}
+.mdi-sticker-emoji::before {
+ content: "\F784";
+}
+.mdi-sticker-minus::before {
+ content: "\F0394";
+}
+.mdi-sticker-minus-outline::before {
+ content: "\F0395";
+}
+.mdi-sticker-outline::before {
+ content: "\F0396";
+}
+.mdi-sticker-plus::before {
+ content: "\F0397";
+}
+.mdi-sticker-plus-outline::before {
+ content: "\F0398";
+}
+.mdi-sticker-remove::before {
+ content: "\F0399";
+}
+.mdi-sticker-remove-outline::before {
+ content: "\F039A";
+}
+.mdi-stocking::before {
+ content: "\F4DA";
+}
+.mdi-stomach::before {
+ content: "\F00BE";
+}
+.mdi-stop::before {
+ content: "\F4DB";
+}
+.mdi-stop-circle::before {
+ content: "\F666";
+}
+.mdi-stop-circle-outline::before {
+ content: "\F667";
+}
+.mdi-store::before {
+ content: "\F4DC";
+}
+.mdi-store-24-hour::before {
+ content: "\F4DD";
+}
+.mdi-store-outline::before {
+ content: "\F038C";
+}
+.mdi-storefront::before {
+ content: "\F00EC";
+}
+.mdi-stove::before {
+ content: "\F4DE";
+}
+.mdi-strategy::before {
+ content: "\F0201";
+}
+.mdi-strava::before {
+ content: "\FB25";
+}
+.mdi-stretch-to-page::before {
+ content: "\FF48";
+}
+.mdi-stretch-to-page-outline::before {
+ content: "\FF49";
+}
+.mdi-string-lights::before {
+ content: "\F02E5";
+}
+.mdi-string-lights-off::before {
+ content: "\F02E6";
+}
+.mdi-subdirectory-arrow-left::before {
+ content: "\F60C";
+}
+.mdi-subdirectory-arrow-right::before {
+ content: "\F60D";
+}
+.mdi-subtitles::before {
+ content: "\FA15";
+}
+.mdi-subtitles-outline::before {
+ content: "\FA16";
+}
+.mdi-subway::before {
+ content: "\F6AB";
+}
+.mdi-subway-alert-variant::before {
+ content: "\FD79";
+}
+.mdi-subway-variant::before {
+ content: "\F4DF";
+}
+.mdi-summit::before {
+ content: "\F785";
+}
+.mdi-sunglasses::before {
+ content: "\F4E0";
+}
+.mdi-surround-sound::before {
+ content: "\F5C5";
+}
+.mdi-surround-sound-2-0::before {
+ content: "\F7EF";
+}
+.mdi-surround-sound-3-1::before {
+ content: "\F7F0";
+}
+.mdi-surround-sound-5-1::before {
+ content: "\F7F1";
+}
+.mdi-surround-sound-7-1::before {
+ content: "\F7F2";
+}
+.mdi-svg::before {
+ content: "\F720";
+}
+.mdi-swap-horizontal::before {
+ content: "\F4E1";
+}
+.mdi-swap-horizontal-bold::before {
+ content: "\FBA9";
+}
+.mdi-swap-horizontal-circle::before {
+ content: "\F0002";
+}
+.mdi-swap-horizontal-circle-outline::before {
+ content: "\F0003";
+}
+.mdi-swap-horizontal-variant::before {
+ content: "\F8C0";
+}
+.mdi-swap-vertical::before {
+ content: "\F4E2";
+}
+.mdi-swap-vertical-bold::before {
+ content: "\FBAA";
+}
+.mdi-swap-vertical-circle::before {
+ content: "\F0004";
+}
+.mdi-swap-vertical-circle-outline::before {
+ content: "\F0005";
+}
+.mdi-swap-vertical-variant::before {
+ content: "\F8C1";
+}
+.mdi-swim::before {
+ content: "\F4E3";
+}
+.mdi-switch::before {
+ content: "\F4E4";
+}
+.mdi-sword::before {
+ content: "\F4E5";
+}
+.mdi-sword-cross::before {
+ content: "\F786";
+}
+.mdi-syllabary-hangul::before {
+ content: "\F035E";
+}
+.mdi-syllabary-hiragana::before {
+ content: "\F035F";
+}
+.mdi-syllabary-katakana::before {
+ content: "\F0360";
+}
+.mdi-syllabary-katakana-half-width::before {
+ content: "\F0361";
+}
+.mdi-symfony::before {
+ content: "\FAE5";
+}
+.mdi-sync::before {
+ content: "\F4E6";
+}
+.mdi-sync-alert::before {
+ content: "\F4E7";
+}
+.mdi-sync-circle::before {
+ content: "\F03A3";
+}
+.mdi-sync-off::before {
+ content: "\F4E8";
+}
+.mdi-tab::before {
+ content: "\F4E9";
+}
+.mdi-tab-minus::before {
+ content: "\FB26";
+}
+.mdi-tab-plus::before {
+ content: "\F75B";
+}
+.mdi-tab-remove::before {
+ content: "\FB27";
+}
+.mdi-tab-unselected::before {
+ content: "\F4EA";
+}
+.mdi-table::before {
+ content: "\F4EB";
+}
+.mdi-table-border::before {
+ content: "\FA17";
+}
+.mdi-table-chair::before {
+ content: "\F0083";
+}
+.mdi-table-column::before {
+ content: "\F834";
+}
+.mdi-table-column-plus-after::before {
+ content: "\F4EC";
+}
+.mdi-table-column-plus-before::before {
+ content: "\F4ED";
+}
+.mdi-table-column-remove::before {
+ content: "\F4EE";
+}
+.mdi-table-column-width::before {
+ content: "\F4EF";
+}
+.mdi-table-edit::before {
+ content: "\F4F0";
+}
+.mdi-table-eye::before {
+ content: "\F00BF";
+}
+.mdi-table-headers-eye::before {
+ content: "\F0248";
+}
+.mdi-table-headers-eye-off::before {
+ content: "\F0249";
+}
+.mdi-table-large::before {
+ content: "\F4F1";
+}
+.mdi-table-large-plus::before {
+ content: "\FFA4";
+}
+.mdi-table-large-remove::before {
+ content: "\FFA5";
+}
+.mdi-table-merge-cells::before {
+ content: "\F9A5";
+}
+.mdi-table-of-contents::before {
+ content: "\F835";
+}
+.mdi-table-plus::before {
+ content: "\FA74";
+}
+.mdi-table-remove::before {
+ content: "\FA75";
+}
+.mdi-table-row::before {
+ content: "\F836";
+}
+.mdi-table-row-height::before {
+ content: "\F4F2";
+}
+.mdi-table-row-plus-after::before {
+ content: "\F4F3";
+}
+.mdi-table-row-plus-before::before {
+ content: "\F4F4";
+}
+.mdi-table-row-remove::before {
+ content: "\F4F5";
+}
+.mdi-table-search::before {
+ content: "\F90E";
+}
+.mdi-table-settings::before {
+ content: "\F837";
+}
+.mdi-table-tennis::before {
+ content: "\FE4B";
+}
+.mdi-tablet::before {
+ content: "\F4F6";
+}
+.mdi-tablet-android::before {
+ content: "\F4F7";
+}
+.mdi-tablet-cellphone::before {
+ content: "\F9A6";
+}
+.mdi-tablet-dashboard::before {
+ content: "\FEEB";
+}
+.mdi-tablet-ipad::before {
+ content: "\F4F8";
+}
+.mdi-taco::before {
+ content: "\F761";
+}
+.mdi-tag::before {
+ content: "\F4F9";
+}
+.mdi-tag-faces::before {
+ content: "\F4FA";
+}
+.mdi-tag-heart::before {
+ content: "\F68A";
+}
+.mdi-tag-heart-outline::before {
+ content: "\FBAB";
+}
+.mdi-tag-minus::before {
+ content: "\F90F";
+}
+.mdi-tag-minus-outline::before {
+ content: "\F024A";
+}
+.mdi-tag-multiple::before {
+ content: "\F4FB";
+}
+.mdi-tag-multiple-outline::before {
+ content: "\F0322";
+}
+.mdi-tag-off::before {
+ content: "\F024B";
+}
+.mdi-tag-off-outline::before {
+ content: "\F024C";
+}
+.mdi-tag-outline::before {
+ content: "\F4FC";
+}
+.mdi-tag-plus::before {
+ content: "\F721";
+}
+.mdi-tag-plus-outline::before {
+ content: "\F024D";
+}
+.mdi-tag-remove::before {
+ content: "\F722";
+}
+.mdi-tag-remove-outline::before {
+ content: "\F024E";
+}
+.mdi-tag-text::before {
+ content: "\F024F";
+}
+.mdi-tag-text-outline::before {
+ content: "\F4FD";
+}
+.mdi-tank::before {
+ content: "\FD16";
+}
+.mdi-tanker-truck::before {
+ content: "\F0006";
+}
+.mdi-tape-measure::before {
+ content: "\FB28";
+}
+.mdi-target::before {
+ content: "\F4FE";
+}
+.mdi-target-account::before {
+ content: "\FBAC";
+}
+.mdi-target-variant::before {
+ content: "\FA76";
+}
+.mdi-taxi::before {
+ content: "\F4FF";
+}
+.mdi-tea::before {
+ content: "\FD7A";
+}
+.mdi-tea-outline::before {
+ content: "\FD7B";
+}
+.mdi-teach::before {
+ content: "\F88F";
+}
+.mdi-teamviewer::before {
+ content: "\F500";
+}
+.mdi-telegram::before {
+ content: "\F501";
+}
+.mdi-telescope::before {
+ content: "\FB29";
+}
+.mdi-television::before {
+ content: "\F502";
+}
+.mdi-television-ambient-light::before {
+ content: "\F0381";
+}
+.mdi-television-box::before {
+ content: "\F838";
+}
+.mdi-television-classic::before {
+ content: "\F7F3";
+}
+.mdi-television-classic-off::before {
+ content: "\F839";
+}
+.mdi-television-clean::before {
+ content: "\F013B";
+}
+.mdi-television-guide::before {
+ content: "\F503";
+}
+.mdi-television-off::before {
+ content: "\F83A";
+}
+.mdi-television-pause::before {
+ content: "\FFA6";
+}
+.mdi-television-play::before {
+ content: "\FEEC";
+}
+.mdi-television-stop::before {
+ content: "\FFA7";
+}
+.mdi-temperature-celsius::before {
+ content: "\F504";
+}
+.mdi-temperature-fahrenheit::before {
+ content: "\F505";
+}
+.mdi-temperature-kelvin::before {
+ content: "\F506";
+}
+.mdi-tennis::before {
+ content: "\FD7C";
+}
+.mdi-tennis-ball::before {
+ content: "\F507";
+}
+.mdi-tent::before {
+ content: "\F508";
+}
+.mdi-terraform::before {
+ content: "\F0084";
+}
+.mdi-terrain::before {
+ content: "\F509";
+}
+.mdi-test-tube::before {
+ content: "\F668";
+}
+.mdi-test-tube-empty::before {
+ content: "\F910";
+}
+.mdi-test-tube-off::before {
+ content: "\F911";
+}
+.mdi-text::before {
+ content: "\F9A7";
+}
+.mdi-text-recognition::before {
+ content: "\F0168";
+}
+.mdi-text-shadow::before {
+ content: "\F669";
+}
+.mdi-text-short::before {
+ content: "\F9A8";
+}
+.mdi-text-subject::before {
+ content: "\F9A9";
+}
+.mdi-text-to-speech::before {
+ content: "\F50A";
+}
+.mdi-text-to-speech-off::before {
+ content: "\F50B";
+}
+.mdi-textarea::before {
+ content: "\F00C0";
+}
+.mdi-textbox::before {
+ content: "\F60E";
+}
+.mdi-textbox-lock::before {
+ content: "\F0388";
+}
+.mdi-textbox-password::before {
+ content: "\F7F4";
+}
+.mdi-texture::before {
+ content: "\F50C";
+}
+.mdi-texture-box::before {
+ content: "\F0007";
+}
+.mdi-theater::before {
+ content: "\F50D";
+}
+.mdi-theme-light-dark::before {
+ content: "\F50E";
+}
+.mdi-thermometer::before {
+ content: "\F50F";
+}
+.mdi-thermometer-alert::before {
+ content: "\FE61";
+}
+.mdi-thermometer-chevron-down::before {
+ content: "\FE62";
+}
+.mdi-thermometer-chevron-up::before {
+ content: "\FE63";
+}
+.mdi-thermometer-high::before {
+ content: "\F00ED";
+}
+.mdi-thermometer-lines::before {
+ content: "\F510";
+}
+.mdi-thermometer-low::before {
+ content: "\F00EE";
+}
+.mdi-thermometer-minus::before {
+ content: "\FE64";
+}
+.mdi-thermometer-plus::before {
+ content: "\FE65";
+}
+.mdi-thermostat::before {
+ content: "\F393";
+}
+.mdi-thermostat-box::before {
+ content: "\F890";
+}
+.mdi-thought-bubble::before {
+ content: "\F7F5";
+}
+.mdi-thought-bubble-outline::before {
+ content: "\F7F6";
+}
+.mdi-thumb-down::before {
+ content: "\F511";
+}
+.mdi-thumb-down-outline::before {
+ content: "\F512";
+}
+.mdi-thumb-up::before {
+ content: "\F513";
+}
+.mdi-thumb-up-outline::before {
+ content: "\F514";
+}
+.mdi-thumbs-up-down::before {
+ content: "\F515";
+}
+.mdi-ticket::before {
+ content: "\F516";
+}
+.mdi-ticket-account::before {
+ content: "\F517";
+}
+.mdi-ticket-confirmation::before {
+ content: "\F518";
+}
+.mdi-ticket-outline::before {
+ content: "\F912";
+}
+.mdi-ticket-percent::before {
+ content: "\F723";
+}
+.mdi-tie::before {
+ content: "\F519";
+}
+.mdi-tilde::before {
+ content: "\F724";
+}
+.mdi-timelapse::before {
+ content: "\F51A";
+}
+.mdi-timeline::before {
+ content: "\FBAD";
+}
+.mdi-timeline-alert::before {
+ content: "\FFB2";
+}
+.mdi-timeline-alert-outline::before {
+ content: "\FFB5";
+}
+.mdi-timeline-clock::before {
+ content: "\F0226";
+}
+.mdi-timeline-clock-outline::before {
+ content: "\F0227";
+}
+.mdi-timeline-help::before {
+ content: "\FFB6";
+}
+.mdi-timeline-help-outline::before {
+ content: "\FFB7";
+}
+.mdi-timeline-outline::before {
+ content: "\FBAE";
+}
+.mdi-timeline-plus::before {
+ content: "\FFB3";
+}
+.mdi-timeline-plus-outline::before {
+ content: "\FFB4";
+}
+.mdi-timeline-text::before {
+ content: "\FBAF";
+}
+.mdi-timeline-text-outline::before {
+ content: "\FBB0";
+}
+.mdi-timer::before {
+ content: "\F51B";
+}
+.mdi-timer-10::before {
+ content: "\F51C";
+}
+.mdi-timer-3::before {
+ content: "\F51D";
+}
+.mdi-timer-off::before {
+ content: "\F51E";
+}
+.mdi-timer-sand::before {
+ content: "\F51F";
+}
+.mdi-timer-sand-empty::before {
+ content: "\F6AC";
+}
+.mdi-timer-sand-full::before {
+ content: "\F78B";
+}
+.mdi-timetable::before {
+ content: "\F520";
+}
+.mdi-toaster::before {
+ content: "\F0085";
+}
+.mdi-toaster-off::before {
+ content: "\F01E2";
+}
+.mdi-toaster-oven::before {
+ content: "\FCAF";
+}
+.mdi-toggle-switch::before {
+ content: "\F521";
+}
+.mdi-toggle-switch-off::before {
+ content: "\F522";
+}
+.mdi-toggle-switch-off-outline::before {
+ content: "\FA18";
+}
+.mdi-toggle-switch-outline::before {
+ content: "\FA19";
+}
+.mdi-toilet::before {
+ content: "\F9AA";
+}
+.mdi-toolbox::before {
+ content: "\F9AB";
+}
+.mdi-toolbox-outline::before {
+ content: "\F9AC";
+}
+.mdi-tools::before {
+ content: "\F0086";
+}
+.mdi-tooltip::before {
+ content: "\F523";
+}
+.mdi-tooltip-account::before {
+ content: "\F00C";
+}
+.mdi-tooltip-edit::before {
+ content: "\F524";
+}
+.mdi-tooltip-edit-outline::before {
+ content: "\F02F0";
+}
+.mdi-tooltip-image::before {
+ content: "\F525";
+}
+.mdi-tooltip-image-outline::before {
+ content: "\FBB1";
+}
+.mdi-tooltip-outline::before {
+ content: "\F526";
+}
+.mdi-tooltip-plus::before {
+ content: "\FBB2";
+}
+.mdi-tooltip-plus-outline::before {
+ content: "\F527";
+}
+.mdi-tooltip-text::before {
+ content: "\F528";
+}
+.mdi-tooltip-text-outline::before {
+ content: "\FBB3";
+}
+.mdi-tooth::before {
+ content: "\F8C2";
+}
+.mdi-tooth-outline::before {
+ content: "\F529";
+}
+.mdi-toothbrush::before {
+ content: "\F0154";
+}
+.mdi-toothbrush-electric::before {
+ content: "\F0157";
+}
+.mdi-toothbrush-paste::before {
+ content: "\F0155";
+}
+.mdi-tor::before {
+ content: "\F52A";
+}
+.mdi-tortoise::before {
+ content: "\FD17";
+}
+.mdi-toslink::before {
+ content: "\F02E3";
+}
+.mdi-tournament::before {
+ content: "\F9AD";
+}
+.mdi-tower-beach::before {
+ content: "\F680";
+}
+.mdi-tower-fire::before {
+ content: "\F681";
+}
+.mdi-towing::before {
+ content: "\F83B";
+}
+.mdi-toy-brick::before {
+ content: "\F02B3";
+}
+.mdi-toy-brick-marker::before {
+ content: "\F02B4";
+}
+.mdi-toy-brick-marker-outline::before {
+ content: "\F02B5";
+}
+.mdi-toy-brick-minus::before {
+ content: "\F02B6";
+}
+.mdi-toy-brick-minus-outline::before {
+ content: "\F02B7";
+}
+.mdi-toy-brick-outline::before {
+ content: "\F02B8";
+}
+.mdi-toy-brick-plus::before {
+ content: "\F02B9";
+}
+.mdi-toy-brick-plus-outline::before {
+ content: "\F02BA";
+}
+.mdi-toy-brick-remove::before {
+ content: "\F02BB";
+}
+.mdi-toy-brick-remove-outline::before {
+ content: "\F02BC";
+}
+.mdi-toy-brick-search::before {
+ content: "\F02BD";
+}
+.mdi-toy-brick-search-outline::before {
+ content: "\F02BE";
+}
+.mdi-track-light::before {
+ content: "\F913";
+}
+.mdi-trackpad::before {
+ content: "\F7F7";
+}
+.mdi-trackpad-lock::before {
+ content: "\F932";
+}
+.mdi-tractor::before {
+ content: "\F891";
+}
+.mdi-trademark::before {
+ content: "\FA77";
+}
+.mdi-traffic-cone::before {
+ content: "\F03A7";
+}
+.mdi-traffic-light::before {
+ content: "\F52B";
+}
+.mdi-train::before {
+ content: "\F52C";
+}
+.mdi-train-car::before {
+ content: "\FBB4";
+}
+.mdi-train-variant::before {
+ content: "\F8C3";
+}
+.mdi-tram::before {
+ content: "\F52D";
+}
+.mdi-tram-side::before {
+ content: "\F0008";
+}
+.mdi-transcribe::before {
+ content: "\F52E";
+}
+.mdi-transcribe-close::before {
+ content: "\F52F";
+}
+.mdi-transfer::before {
+ content: "\F0087";
+}
+.mdi-transfer-down::before {
+ content: "\FD7D";
+}
+.mdi-transfer-left::before {
+ content: "\FD7E";
+}
+.mdi-transfer-right::before {
+ content: "\F530";
+}
+.mdi-transfer-up::before {
+ content: "\FD7F";
+}
+.mdi-transit-connection::before {
+ content: "\FD18";
+}
+.mdi-transit-connection-variant::before {
+ content: "\FD19";
+}
+.mdi-transit-detour::before {
+ content: "\FFA8";
+}
+.mdi-transit-transfer::before {
+ content: "\F6AD";
+}
+.mdi-transition::before {
+ content: "\F914";
+}
+.mdi-transition-masked::before {
+ content: "\F915";
+}
+.mdi-translate::before {
+ content: "\F5CA";
+}
+.mdi-translate-off::before {
+ content: "\FE66";
+}
+.mdi-transmission-tower::before {
+ content: "\FD1A";
+}
+.mdi-trash-can::before {
+ content: "\FA78";
+}
+.mdi-trash-can-outline::before {
+ content: "\FA79";
+}
+.mdi-tray::before {
+ content: "\F02BF";
+}
+.mdi-tray-alert::before {
+ content: "\F02C0";
+}
+.mdi-tray-full::before {
+ content: "\F02C1";
+}
+.mdi-tray-minus::before {
+ content: "\F02C2";
+}
+.mdi-tray-plus::before {
+ content: "\F02C3";
+}
+.mdi-tray-remove::before {
+ content: "\F02C4";
+}
+.mdi-treasure-chest::before {
+ content: "\F725";
+}
+.mdi-tree::before {
+ content: "\F531";
+}
+.mdi-tree-outline::before {
+ content: "\FE4C";
+}
+.mdi-trello::before {
+ content: "\F532";
+}
+.mdi-trending-down::before {
+ content: "\F533";
+}
+.mdi-trending-neutral::before {
+ content: "\F534";
+}
+.mdi-trending-up::before {
+ content: "\F535";
+}
+.mdi-triangle::before {
+ content: "\F536";
+}
+.mdi-triangle-outline::before {
+ content: "\F537";
+}
+.mdi-triforce::before {
+ content: "\FBB5";
+}
+.mdi-trophy::before {
+ content: "\F538";
+}
+.mdi-trophy-award::before {
+ content: "\F539";
+}
+.mdi-trophy-broken::before {
+ content: "\FD80";
+}
+.mdi-trophy-outline::before {
+ content: "\F53A";
+}
+.mdi-trophy-variant::before {
+ content: "\F53B";
+}
+.mdi-trophy-variant-outline::before {
+ content: "\F53C";
+}
+.mdi-truck::before {
+ content: "\F53D";
+}
+.mdi-truck-check::before {
+ content: "\FCB0";
+}
+.mdi-truck-check-outline::before {
+ content: "\F02C5";
+}
+.mdi-truck-delivery::before {
+ content: "\F53E";
+}
+.mdi-truck-delivery-outline::before {
+ content: "\F02C6";
+}
+.mdi-truck-fast::before {
+ content: "\F787";
+}
+.mdi-truck-fast-outline::before {
+ content: "\F02C7";
+}
+.mdi-truck-outline::before {
+ content: "\F02C8";
+}
+.mdi-truck-trailer::before {
+ content: "\F726";
+}
+.mdi-trumpet::before {
+ content: "\F00C1";
+}
+.mdi-tshirt-crew::before {
+ content: "\FA7A";
+}
+.mdi-tshirt-crew-outline::before {
+ content: "\F53F";
+}
+.mdi-tshirt-v::before {
+ content: "\FA7B";
+}
+.mdi-tshirt-v-outline::before {
+ content: "\F540";
+}
+.mdi-tumble-dryer::before {
+ content: "\F916";
+}
+.mdi-tumble-dryer-alert::before {
+ content: "\F01E5";
+}
+.mdi-tumble-dryer-off::before {
+ content: "\F01E6";
+}
+.mdi-tumblr::before {
+ content: "\F541";
+}
+.mdi-tumblr-box::before {
+ content: "\F917";
+}
+.mdi-tumblr-reblog::before {
+ content: "\F542";
+}
+.mdi-tune::before {
+ content: "\F62E";
+}
+.mdi-tune-vertical::before {
+ content: "\F66A";
+}
+.mdi-turnstile::before {
+ content: "\FCB1";
+}
+.mdi-turnstile-outline::before {
+ content: "\FCB2";
+}
+.mdi-turtle::before {
+ content: "\FCB3";
+}
+.mdi-twitch::before {
+ content: "\F543";
+}
+.mdi-twitter::before {
+ content: "\F544";
+}
+.mdi-twitter-box::before {
+ content: "\F545";
+}
+.mdi-twitter-circle::before {
+ content: "\F546";
+}
+.mdi-twitter-retweet::before {
+ content: "\F547";
+}
+.mdi-two-factor-authentication::before {
+ content: "\F9AE";
+}
+.mdi-typewriter::before {
+ content: "\FF4A";
+}
+.mdi-uber::before {
+ content: "\F748";
+}
+.mdi-ubisoft::before {
+ content: "\FBB6";
+}
+.mdi-ubuntu::before {
+ content: "\F548";
+}
+.mdi-ufo::before {
+ content: "\F00EF";
+}
+.mdi-ufo-outline::before {
+ content: "\F00F0";
+}
+.mdi-ultra-high-definition::before {
+ content: "\F7F8";
+}
+.mdi-umbraco::before {
+ content: "\F549";
+}
+.mdi-umbrella::before {
+ content: "\F54A";
+}
+.mdi-umbrella-closed::before {
+ content: "\F9AF";
+}
+.mdi-umbrella-outline::before {
+ content: "\F54B";
+}
+.mdi-undo::before {
+ content: "\F54C";
+}
+.mdi-undo-variant::before {
+ content: "\F54D";
+}
+.mdi-unfold-less-horizontal::before {
+ content: "\F54E";
+}
+.mdi-unfold-less-vertical::before {
+ content: "\F75F";
+}
+.mdi-unfold-more-horizontal::before {
+ content: "\F54F";
+}
+.mdi-unfold-more-vertical::before {
+ content: "\F760";
+}
+.mdi-ungroup::before {
+ content: "\F550";
+}
+.mdi-unicode::before {
+ content: "\FEED";
+}
+.mdi-unity::before {
+ content: "\F6AE";
+}
+.mdi-unreal::before {
+ content: "\F9B0";
+}
+.mdi-untappd::before {
+ content: "\F551";
+}
+.mdi-update::before {
+ content: "\F6AF";
+}
+.mdi-upload::before {
+ content: "\F552";
+}
+.mdi-upload-lock::before {
+ content: "\F039E";
+}
+.mdi-upload-lock-outline::before {
+ content: "\F039F";
+}
+.mdi-upload-multiple::before {
+ content: "\F83C";
+}
+.mdi-upload-network::before {
+ content: "\F6F5";
+}
+.mdi-upload-network-outline::before {
+ content: "\FCB4";
+}
+.mdi-upload-off::before {
+ content: "\F00F1";
+}
+.mdi-upload-off-outline::before {
+ content: "\F00F2";
+}
+.mdi-upload-outline::before {
+ content: "\FE67";
+}
+.mdi-usb::before {
+ content: "\F553";
+}
+.mdi-usb-flash-drive::before {
+ content: "\F02C9";
+}
+.mdi-usb-flash-drive-outline::before {
+ content: "\F02CA";
+}
+.mdi-usb-port::before {
+ content: "\F021B";
+}
+.mdi-valve::before {
+ content: "\F0088";
+}
+.mdi-valve-closed::before {
+ content: "\F0089";
+}
+.mdi-valve-open::before {
+ content: "\F008A";
+}
+.mdi-van-passenger::before {
+ content: "\F7F9";
+}
+.mdi-van-utility::before {
+ content: "\F7FA";
+}
+.mdi-vanish::before {
+ content: "\F7FB";
+}
+.mdi-vanity-light::before {
+ content: "\F020C";
+}
+.mdi-variable::before {
+ content: "\FAE6";
+}
+.mdi-variable-box::before {
+ content: "\F013C";
+}
+.mdi-vector-arrange-above::before {
+ content: "\F554";
+}
+.mdi-vector-arrange-below::before {
+ content: "\F555";
+}
+.mdi-vector-bezier::before {
+ content: "\FAE7";
+}
+.mdi-vector-circle::before {
+ content: "\F556";
+}
+.mdi-vector-circle-variant::before {
+ content: "\F557";
+}
+.mdi-vector-combine::before {
+ content: "\F558";
+}
+.mdi-vector-curve::before {
+ content: "\F559";
+}
+.mdi-vector-difference::before {
+ content: "\F55A";
+}
+.mdi-vector-difference-ab::before {
+ content: "\F55B";
+}
+.mdi-vector-difference-ba::before {
+ content: "\F55C";
+}
+.mdi-vector-ellipse::before {
+ content: "\F892";
+}
+.mdi-vector-intersection::before {
+ content: "\F55D";
+}
+.mdi-vector-line::before {
+ content: "\F55E";
+}
+.mdi-vector-link::before {
+ content: "\F0009";
+}
+.mdi-vector-point::before {
+ content: "\F55F";
+}
+.mdi-vector-polygon::before {
+ content: "\F560";
+}
+.mdi-vector-polyline::before {
+ content: "\F561";
+}
+.mdi-vector-polyline-edit::before {
+ content: "\F0250";
+}
+.mdi-vector-polyline-minus::before {
+ content: "\F0251";
+}
+.mdi-vector-polyline-plus::before {
+ content: "\F0252";
+}
+.mdi-vector-polyline-remove::before {
+ content: "\F0253";
+}
+.mdi-vector-radius::before {
+ content: "\F749";
+}
+.mdi-vector-rectangle::before {
+ content: "\F5C6";
+}
+.mdi-vector-selection::before {
+ content: "\F562";
+}
+.mdi-vector-square::before {
+ content: "\F001";
+}
+.mdi-vector-triangle::before {
+ content: "\F563";
+}
+.mdi-vector-union::before {
+ content: "\F564";
+}
+.mdi-venmo::before {
+ content: "\F578";
+}
+.mdi-vhs::before {
+ content: "\FA1A";
+}
+.mdi-vibrate::before {
+ content: "\F566";
+}
+.mdi-vibrate-off::before {
+ content: "\FCB5";
+}
+.mdi-video::before {
+ content: "\F567";
+}
+.mdi-video-3d::before {
+ content: "\F7FC";
+}
+.mdi-video-3d-variant::before {
+ content: "\FEEE";
+}
+.mdi-video-4k-box::before {
+ content: "\F83D";
+}
+.mdi-video-account::before {
+ content: "\F918";
+}
+.mdi-video-check::before {
+ content: "\F008B";
+}
+.mdi-video-check-outline::before {
+ content: "\F008C";
+}
+.mdi-video-image::before {
+ content: "\F919";
+}
+.mdi-video-input-antenna::before {
+ content: "\F83E";
+}
+.mdi-video-input-component::before {
+ content: "\F83F";
+}
+.mdi-video-input-hdmi::before {
+ content: "\F840";
+}
+.mdi-video-input-scart::before {
+ content: "\FFA9";
+}
+.mdi-video-input-svideo::before {
+ content: "\F841";
+}
+.mdi-video-minus::before {
+ content: "\F9B1";
+}
+.mdi-video-off::before {
+ content: "\F568";
+}
+.mdi-video-off-outline::before {
+ content: "\FBB7";
+}
+.mdi-video-outline::before {
+ content: "\FBB8";
+}
+.mdi-video-plus::before {
+ content: "\F9B2";
+}
+.mdi-video-stabilization::before {
+ content: "\F91A";
+}
+.mdi-video-switch::before {
+ content: "\F569";
+}
+.mdi-video-vintage::before {
+ content: "\FA1B";
+}
+.mdi-video-wireless::before {
+ content: "\FEEF";
+}
+.mdi-video-wireless-outline::before {
+ content: "\FEF0";
+}
+.mdi-view-agenda::before {
+ content: "\F56A";
+}
+.mdi-view-agenda-outline::before {
+ content: "\F0203";
+}
+.mdi-view-array::before {
+ content: "\F56B";
+}
+.mdi-view-carousel::before {
+ content: "\F56C";
+}
+.mdi-view-column::before {
+ content: "\F56D";
+}
+.mdi-view-comfy::before {
+ content: "\FE4D";
+}
+.mdi-view-compact::before {
+ content: "\FE4E";
+}
+.mdi-view-compact-outline::before {
+ content: "\FE4F";
+}
+.mdi-view-dashboard::before {
+ content: "\F56E";
+}
+.mdi-view-dashboard-outline::before {
+ content: "\FA1C";
+}
+.mdi-view-dashboard-variant::before {
+ content: "\F842";
+}
+.mdi-view-day::before {
+ content: "\F56F";
+}
+.mdi-view-grid::before {
+ content: "\F570";
+}
+.mdi-view-grid-outline::before {
+ content: "\F0204";
+}
+.mdi-view-grid-plus::before {
+ content: "\FFAA";
+}
+.mdi-view-grid-plus-outline::before {
+ content: "\F0205";
+}
+.mdi-view-headline::before {
+ content: "\F571";
+}
+.mdi-view-list::before {
+ content: "\F572";
+}
+.mdi-view-module::before {
+ content: "\F573";
+}
+.mdi-view-parallel::before {
+ content: "\F727";
+}
+.mdi-view-quilt::before {
+ content: "\F574";
+}
+.mdi-view-sequential::before {
+ content: "\F728";
+}
+.mdi-view-split-horizontal::before {
+ content: "\FBA7";
+}
+.mdi-view-split-vertical::before {
+ content: "\FBA8";
+}
+.mdi-view-stream::before {
+ content: "\F575";
+}
+.mdi-view-week::before {
+ content: "\F576";
+}
+.mdi-vimeo::before {
+ content: "\F577";
+}
+.mdi-violin::before {
+ content: "\F60F";
+}
+.mdi-virtual-reality::before {
+ content: "\F893";
+}
+.mdi-visual-studio::before {
+ content: "\F610";
+}
+.mdi-visual-studio-code::before {
+ content: "\FA1D";
+}
+.mdi-vk::before {
+ content: "\F579";
+}
+.mdi-vk-box::before {
+ content: "\F57A";
+}
+.mdi-vk-circle::before {
+ content: "\F57B";
+}
+.mdi-vlc::before {
+ content: "\F57C";
+}
+.mdi-voice::before {
+ content: "\F5CB";
+}
+.mdi-voice-off::before {
+ content: "\FEF1";
+}
+.mdi-voicemail::before {
+ content: "\F57D";
+}
+.mdi-volleyball::before {
+ content: "\F9B3";
+}
+.mdi-volume-high::before {
+ content: "\F57E";
+}
+.mdi-volume-low::before {
+ content: "\F57F";
+}
+.mdi-volume-medium::before {
+ content: "\F580";
+}
+.mdi-volume-minus::before {
+ content: "\F75D";
+}
+.mdi-volume-mute::before {
+ content: "\F75E";
+}
+.mdi-volume-off::before {
+ content: "\F581";
+}
+.mdi-volume-plus::before {
+ content: "\F75C";
+}
+.mdi-volume-source::before {
+ content: "\F014B";
+}
+.mdi-volume-variant-off::before {
+ content: "\FE68";
+}
+.mdi-volume-vibrate::before {
+ content: "\F014C";
+}
+.mdi-vote::before {
+ content: "\FA1E";
+}
+.mdi-vote-outline::before {
+ content: "\FA1F";
+}
+.mdi-vpn::before {
+ content: "\F582";
+}
+.mdi-vuejs::before {
+ content: "\F843";
+}
+.mdi-vuetify::before {
+ content: "\FE50";
+}
+.mdi-walk::before {
+ content: "\F583";
+}
+.mdi-wall::before {
+ content: "\F7FD";
+}
+.mdi-wall-sconce::before {
+ content: "\F91B";
+}
+.mdi-wall-sconce-flat::before {
+ content: "\F91C";
+}
+.mdi-wall-sconce-variant::before {
+ content: "\F91D";
+}
+.mdi-wallet::before {
+ content: "\F584";
+}
+.mdi-wallet-giftcard::before {
+ content: "\F585";
+}
+.mdi-wallet-membership::before {
+ content: "\F586";
+}
+.mdi-wallet-outline::before {
+ content: "\FBB9";
+}
+.mdi-wallet-plus::before {
+ content: "\FFAB";
+}
+.mdi-wallet-plus-outline::before {
+ content: "\FFAC";
+}
+.mdi-wallet-travel::before {
+ content: "\F587";
+}
+.mdi-wallpaper::before {
+ content: "\FE69";
+}
+.mdi-wan::before {
+ content: "\F588";
+}
+.mdi-wardrobe::before {
+ content: "\FFAD";
+}
+.mdi-wardrobe-outline::before {
+ content: "\FFAE";
+}
+.mdi-warehouse::before {
+ content: "\FFBB";
+}
+.mdi-washing-machine::before {
+ content: "\F729";
+}
+.mdi-washing-machine-alert::before {
+ content: "\F01E7";
+}
+.mdi-washing-machine-off::before {
+ content: "\F01E8";
+}
+.mdi-watch::before {
+ content: "\F589";
+}
+.mdi-watch-export::before {
+ content: "\F58A";
+}
+.mdi-watch-export-variant::before {
+ content: "\F894";
+}
+.mdi-watch-import::before {
+ content: "\F58B";
+}
+.mdi-watch-import-variant::before {
+ content: "\F895";
+}
+.mdi-watch-variant::before {
+ content: "\F896";
+}
+.mdi-watch-vibrate::before {
+ content: "\F6B0";
+}
+.mdi-watch-vibrate-off::before {
+ content: "\FCB6";
+}
+.mdi-water::before {
+ content: "\F58C";
+}
+.mdi-water-boiler::before {
+ content: "\FFAF";
+}
+.mdi-water-boiler-alert::before {
+ content: "\F01DE";
+}
+.mdi-water-boiler-off::before {
+ content: "\F01DF";
+}
+.mdi-water-off::before {
+ content: "\F58D";
+}
+.mdi-water-outline::before {
+ content: "\FE6A";
+}
+.mdi-water-percent::before {
+ content: "\F58E";
+}
+.mdi-water-polo::before {
+ content: "\F02CB";
+}
+.mdi-water-pump::before {
+ content: "\F58F";
+}
+.mdi-water-pump-off::before {
+ content: "\FFB0";
+}
+.mdi-water-well::before {
+ content: "\F008D";
+}
+.mdi-water-well-outline::before {
+ content: "\F008E";
+}
+.mdi-watermark::before {
+ content: "\F612";
+}
+.mdi-wave::before {
+ content: "\FF4B";
+}
+.mdi-waves::before {
+ content: "\F78C";
+}
+.mdi-waze::before {
+ content: "\FBBA";
+}
+.mdi-weather-cloudy::before {
+ content: "\F590";
+}
+.mdi-weather-cloudy-alert::before {
+ content: "\FF4C";
+}
+.mdi-weather-cloudy-arrow-right::before {
+ content: "\FE51";
+}
+.mdi-weather-fog::before {
+ content: "\F591";
+}
+.mdi-weather-hail::before {
+ content: "\F592";
+}
+.mdi-weather-hazy::before {
+ content: "\FF4D";
+}
+.mdi-weather-hurricane::before {
+ content: "\F897";
+}
+.mdi-weather-lightning::before {
+ content: "\F593";
+}
+.mdi-weather-lightning-rainy::before {
+ content: "\F67D";
+}
+.mdi-weather-night::before {
+ content: "\F594";
+}
+.mdi-weather-night-partly-cloudy::before {
+ content: "\FF4E";
+}
+.mdi-weather-partly-cloudy::before {
+ content: "\F595";
+}
+.mdi-weather-partly-lightning::before {
+ content: "\FF4F";
+}
+.mdi-weather-partly-rainy::before {
+ content: "\FF50";
+}
+.mdi-weather-partly-snowy::before {
+ content: "\FF51";
+}
+.mdi-weather-partly-snowy-rainy::before {
+ content: "\FF52";
+}
+.mdi-weather-pouring::before {
+ content: "\F596";
+}
+.mdi-weather-rainy::before {
+ content: "\F597";
+}
+.mdi-weather-snowy::before {
+ content: "\F598";
+}
+.mdi-weather-snowy-heavy::before {
+ content: "\FF53";
+}
+.mdi-weather-snowy-rainy::before {
+ content: "\F67E";
+}
+.mdi-weather-sunny::before {
+ content: "\F599";
+}
+.mdi-weather-sunny-alert::before {
+ content: "\FF54";
+}
+.mdi-weather-sunset::before {
+ content: "\F59A";
+}
+.mdi-weather-sunset-down::before {
+ content: "\F59B";
+}
+.mdi-weather-sunset-up::before {
+ content: "\F59C";
+}
+.mdi-weather-tornado::before {
+ content: "\FF55";
+}
+.mdi-weather-windy::before {
+ content: "\F59D";
+}
+.mdi-weather-windy-variant::before {
+ content: "\F59E";
+}
+.mdi-web::before {
+ content: "\F59F";
+}
+.mdi-web-box::before {
+ content: "\FFB1";
+}
+.mdi-web-clock::before {
+ content: "\F0275";
+}
+.mdi-webcam::before {
+ content: "\F5A0";
+}
+.mdi-webhook::before {
+ content: "\F62F";
+}
+.mdi-webpack::before {
+ content: "\F72A";
+}
+.mdi-webrtc::before {
+ content: "\F0273";
+}
+.mdi-wechat::before {
+ content: "\F611";
+}
+.mdi-weight::before {
+ content: "\F5A1";
+}
+.mdi-weight-gram::before {
+ content: "\FD1B";
+}
+.mdi-weight-kilogram::before {
+ content: "\F5A2";
+}
+.mdi-weight-lifter::before {
+ content: "\F0188";
+}
+.mdi-weight-pound::before {
+ content: "\F9B4";
+}
+.mdi-whatsapp::before {
+ content: "\F5A3";
+}
+.mdi-wheelchair-accessibility::before {
+ content: "\F5A4";
+}
+.mdi-whistle::before {
+ content: "\F9B5";
+}
+.mdi-whistle-outline::before {
+ content: "\F02E7";
+}
+.mdi-white-balance-auto::before {
+ content: "\F5A5";
+}
+.mdi-white-balance-incandescent::before {
+ content: "\F5A6";
+}
+.mdi-white-balance-iridescent::before {
+ content: "\F5A7";
+}
+.mdi-white-balance-sunny::before {
+ content: "\F5A8";
+}
+.mdi-widgets::before {
+ content: "\F72B";
+}
+.mdi-widgets-outline::before {
+ content: "\F0380";
+}
+.mdi-wifi::before {
+ content: "\F5A9";
+}
+.mdi-wifi-off::before {
+ content: "\F5AA";
+}
+.mdi-wifi-star::before {
+ content: "\FE6B";
+}
+.mdi-wifi-strength-1::before {
+ content: "\F91E";
+}
+.mdi-wifi-strength-1-alert::before {
+ content: "\F91F";
+}
+.mdi-wifi-strength-1-lock::before {
+ content: "\F920";
+}
+.mdi-wifi-strength-2::before {
+ content: "\F921";
+}
+.mdi-wifi-strength-2-alert::before {
+ content: "\F922";
+}
+.mdi-wifi-strength-2-lock::before {
+ content: "\F923";
+}
+.mdi-wifi-strength-3::before {
+ content: "\F924";
+}
+.mdi-wifi-strength-3-alert::before {
+ content: "\F925";
+}
+.mdi-wifi-strength-3-lock::before {
+ content: "\F926";
+}
+.mdi-wifi-strength-4::before {
+ content: "\F927";
+}
+.mdi-wifi-strength-4-alert::before {
+ content: "\F928";
+}
+.mdi-wifi-strength-4-lock::before {
+ content: "\F929";
+}
+.mdi-wifi-strength-alert-outline::before {
+ content: "\F92A";
+}
+.mdi-wifi-strength-lock-outline::before {
+ content: "\F92B";
+}
+.mdi-wifi-strength-off::before {
+ content: "\F92C";
+}
+.mdi-wifi-strength-off-outline::before {
+ content: "\F92D";
+}
+.mdi-wifi-strength-outline::before {
+ content: "\F92E";
+}
+.mdi-wii::before {
+ content: "\F5AB";
+}
+.mdi-wiiu::before {
+ content: "\F72C";
+}
+.mdi-wikipedia::before {
+ content: "\F5AC";
+}
+.mdi-wind-turbine::before {
+ content: "\FD81";
+}
+.mdi-window-close::before {
+ content: "\F5AD";
+}
+.mdi-window-closed::before {
+ content: "\F5AE";
+}
+.mdi-window-closed-variant::before {
+ content: "\F0206";
+}
+.mdi-window-maximize::before {
+ content: "\F5AF";
+}
+.mdi-window-minimize::before {
+ content: "\F5B0";
+}
+.mdi-window-open::before {
+ content: "\F5B1";
+}
+.mdi-window-open-variant::before {
+ content: "\F0207";
+}
+.mdi-window-restore::before {
+ content: "\F5B2";
+}
+.mdi-window-shutter::before {
+ content: "\F0147";
+}
+.mdi-window-shutter-alert::before {
+ content: "\F0148";
+}
+.mdi-window-shutter-open::before {
+ content: "\F0149";
+}
+.mdi-windows::before {
+ content: "\F5B3";
+}
+.mdi-windows-classic::before {
+ content: "\FA20";
+}
+.mdi-wiper::before {
+ content: "\FAE8";
+}
+.mdi-wiper-wash::before {
+ content: "\FD82";
+}
+.mdi-wordpress::before {
+ content: "\F5B4";
+}
+.mdi-worker::before {
+ content: "\F5B5";
+}
+.mdi-wrap::before {
+ content: "\F5B6";
+}
+.mdi-wrap-disabled::before {
+ content: "\FBBB";
+}
+.mdi-wrench::before {
+ content: "\F5B7";
+}
+.mdi-wrench-outline::before {
+ content: "\FBBC";
+}
+.mdi-wunderlist::before {
+ content: "\F5B8";
+}
+.mdi-xamarin::before {
+ content: "\F844";
+}
+.mdi-xamarin-outline::before {
+ content: "\F845";
+}
+.mdi-xaml::before {
+ content: "\F673";
+}
+.mdi-xbox::before {
+ content: "\F5B9";
+}
+.mdi-xbox-controller::before {
+ content: "\F5BA";
+}
+.mdi-xbox-controller-battery-alert::before {
+ content: "\F74A";
+}
+.mdi-xbox-controller-battery-charging::before {
+ content: "\FA21";
+}
+.mdi-xbox-controller-battery-empty::before {
+ content: "\F74B";
+}
+.mdi-xbox-controller-battery-full::before {
+ content: "\F74C";
+}
+.mdi-xbox-controller-battery-low::before {
+ content: "\F74D";
+}
+.mdi-xbox-controller-battery-medium::before {
+ content: "\F74E";
+}
+.mdi-xbox-controller-battery-unknown::before {
+ content: "\F74F";
+}
+.mdi-xbox-controller-menu::before {
+ content: "\FE52";
+}
+.mdi-xbox-controller-off::before {
+ content: "\F5BB";
+}
+.mdi-xbox-controller-view::before {
+ content: "\FE53";
+}
+.mdi-xda::before {
+ content: "\F5BC";
+}
+.mdi-xing::before {
+ content: "\F5BD";
+}
+.mdi-xing-box::before {
+ content: "\F5BE";
+}
+.mdi-xing-circle::before {
+ content: "\F5BF";
+}
+.mdi-xml::before {
+ content: "\F5C0";
+}
+.mdi-xmpp::before {
+ content: "\F7FE";
+}
+.mdi-yahoo::before {
+ content: "\FB2A";
+}
+.mdi-yammer::before {
+ content: "\F788";
+}
+.mdi-yeast::before {
+ content: "\F5C1";
+}
+.mdi-yelp::before {
+ content: "\F5C2";
+}
+.mdi-yin-yang::before {
+ content: "\F67F";
+}
+.mdi-yoga::before {
+ content: "\F01A7";
+}
+.mdi-youtube::before {
+ content: "\F5C3";
+}
+.mdi-youtube-creator-studio::before {
+ content: "\F846";
+}
+.mdi-youtube-gaming::before {
+ content: "\F847";
+}
+.mdi-youtube-subscription::before {
+ content: "\FD1C";
+}
+.mdi-youtube-tv::before {
+ content: "\F448";
+}
+.mdi-z-wave::before {
+ content: "\FAE9";
+}
+.mdi-zend::before {
+ content: "\FAEA";
+}
+.mdi-zigbee::before {
+ content: "\FD1D";
+}
+.mdi-zip-box::before {
+ content: "\F5C4";
+}
+.mdi-zip-box-outline::before {
+ content: "\F001B";
+}
+.mdi-zip-disk::before {
+ content: "\FA22";
+}
+.mdi-zodiac-aquarius::before {
+ content: "\FA7C";
+}
+.mdi-zodiac-aries::before {
+ content: "\FA7D";
+}
+.mdi-zodiac-cancer::before {
+ content: "\FA7E";
+}
+.mdi-zodiac-capricorn::before {
+ content: "\FA7F";
+}
+.mdi-zodiac-gemini::before {
+ content: "\FA80";
+}
+.mdi-zodiac-leo::before {
+ content: "\FA81";
+}
+.mdi-zodiac-libra::before {
+ content: "\FA82";
+}
+.mdi-zodiac-pisces::before {
+ content: "\FA83";
+}
+.mdi-zodiac-sagittarius::before {
+ content: "\FA84";
+}
+.mdi-zodiac-scorpio::before {
+ content: "\FA85";
+}
+.mdi-zodiac-taurus::before {
+ content: "\FA86";
+}
+.mdi-zodiac-virgo::before {
+ content: "\FA87";
+}
+.mdi-blank::before {
+ content: "\F68C";
+ visibility: hidden;
+}
+.mdi-18px.mdi-set,
+.mdi-18px.mdi:before {
+ font-size: 18px;
+}
+.mdi-24px.mdi-set,
+.mdi-24px.mdi:before {
+ font-size: 24px;
+}
+.mdi-36px.mdi-set,
+.mdi-36px.mdi:before {
+ font-size: 36px;
+}
+.mdi-48px.mdi-set,
+.mdi-48px.mdi:before {
+ font-size: 48px;
+}
+.mdi-dark:before {
+ color: rgba(0, 0, 0, 0.54);
+}
+.mdi-dark.mdi-inactive:before {
+ color: rgba(0, 0, 0, 0.26);
+}
+.mdi-light:before {
+ color: #fff;
+}
+.mdi-light.mdi-inactive:before {
+ color: rgba(255, 255, 255, 0.3);
+}
+.mdi-rotate-45:before {
+ -webkit-transform: rotate(45deg);
+ -ms-transform: rotate(45deg);
+ transform: rotate(45deg);
+}
+.mdi-rotate-90:before {
+ -webkit-transform: rotate(90deg);
+ -ms-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+.mdi-rotate-135:before {
+ -webkit-transform: rotate(135deg);
+ -ms-transform: rotate(135deg);
+ transform: rotate(135deg);
+}
+.mdi-rotate-180:before {
+ -webkit-transform: rotate(180deg);
+ -ms-transform: rotate(180deg);
+ transform: rotate(180deg);
+}
+.mdi-rotate-225:before {
+ -webkit-transform: rotate(225deg);
+ -ms-transform: rotate(225deg);
+ transform: rotate(225deg);
+}
+.mdi-rotate-270:before {
+ -webkit-transform: rotate(270deg);
+ -ms-transform: rotate(270deg);
+ transform: rotate(270deg);
+}
+.mdi-rotate-315:before {
+ -webkit-transform: rotate(315deg);
+ -ms-transform: rotate(315deg);
+ transform: rotate(315deg);
+}
+.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1);
+ transform: scaleX(-1);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+}
+.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1);
+ transform: scaleY(-1);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+}
+.mdi-spin:before {
+ -webkit-animation: mdi-spin 2s infinite linear;
+ animation: mdi-spin 2s infinite linear;
+}
+@-webkit-keyframes mdi-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
+@keyframes mdi-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
+
+/*# sourceMappingURL=materialdesignicons.css.map */
diff --git a/packages/auditor-backoffice-ui/src/scss/libs/_all.scss b/packages/auditor-backoffice-ui/src/scss/libs/_all.scss
new file mode 100644
index 000000000..cba6f26eb
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/libs/_all.scss
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+@import "node_modules/bulma-radio/bulma-radio";
+// @import "node_modules/bulma-responsive-tables/bulma-responsive-tables";
+@import "node_modules/bulma-checkbox/bulma-checkbox";
+@import "node_modules/bulma-switch-control/bulma-switch-control";
+@import "node_modules/bulma-upload-control/bulma-upload-control";
+
+/* Bulma */
+@import "node_modules/bulma/bulma";
diff --git a/packages/auditor-backoffice-ui/src/scss/main.scss b/packages/auditor-backoffice-ui/src/scss/main.scss
new file mode 100644
index 000000000..c4be8aa73
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/main.scss
@@ -0,0 +1,195 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+/* Theme style (colors & sizes) */
+@import "theme-default";
+
+/* Core Libs & Lib configs */
+@import "libs/all";
+
+/* Mixins */
+@import "mixins";
+
+/* Theme components */
+@import "nav-bar";
+@import "aside";
+@import "title-bar";
+@import "hero-bar";
+@import "card";
+@import "table";
+@import "tiles";
+@import "form";
+@import "main-section";
+@import "modal";
+@import "footer";
+@import "misc";
+@import "custom-calendar";
+@import "loading";
+
+@import "fonts/nunito.css";
+@import "icons/materialdesignicons-4.9.95.min.css";
+
+$tooltip-color: red;
+
+@import "../../node_modules/@creativebulma/bulma-tooltip/dist/bulma-tooltip.min.css";
+@import "../../node_modules/bulma-timeline/dist/css/bulma-timeline.min.css";
+
+@import "toggle";
+
+.notification {
+ background-color: transparent;
+}
+
+.timeline .timeline-item .timeline-content {
+ padding-top: 0;
+}
+
+.timeline .timeline-item:last-child::before {
+ display: none;
+}
+
+.timeline .timeline-item .timeline-marker {
+ top: 0;
+}
+
+.toast {
+ position: absolute;
+ width: 60%;
+ margin-left: 10%;
+ margin-right: 10%;
+ z-index: 999;
+
+ display: flex;
+ flex-direction: column;
+ padding: 15px;
+ text-align: center;
+ pointer-events: none;
+}
+
+.toast>.message {
+ white-space: pre-wrap;
+ opacity: 80%;
+}
+
+div {
+ &.is-loading {
+ position: relative;
+ pointer-events: none;
+ opacity: 0.5;
+
+ &:after {
+ // @include loader;
+ position: absolute;
+ top: calc(50% - 2.5em);
+ left: calc(50% - 2.5em);
+ width: 5em;
+ height: 5em;
+ border-width: 0.25em;
+ }
+ }
+}
+
+input[type="checkbox"]:indeterminate+.check {
+ background: red !important;
+}
+
+.right-sticky {
+ position: sticky;
+ right: 0px;
+ background-color: $white;
+}
+
+.right-sticky .buttons {
+ flex-wrap: nowrap;
+}
+
+.table.is-striped tbody tr:not(.is-selected):nth-child(even) .right-sticky {
+ background-color: #fafafa;
+}
+
+tr:hover .right-sticky {
+ background-color: hsl(0, 0%, 80%);
+}
+
+.table.is-striped tbody tr:nth-child(even):hover .right-sticky {
+ background-color: hsl(0, 0%, 95%);
+}
+
+.content-full-size {
+ height: calc(100% - 3rem);
+ position: absolute;
+ width: calc(100% - 14rem);
+ display: flex;
+}
+
+.content-full-size .column .card {
+ min-width: 200px;
+}
+
+@include touch {
+ .content-full-size {
+ height: 100%;
+ position: absolute;
+ width: 100%;
+ }
+}
+
+.column.is-half {
+ flex: none;
+ width: 50%;
+}
+
+input:read-only {
+ cursor: initial;
+}
+
+[data-tooltip]:before {
+ max-width: 15rem;
+ width: max-content;
+ text-align: left;
+ transition: opacity 0.1s linear 1s;
+ // transform: inherit !important;
+ white-space: pre-wrap !important;
+ font-weight: normal;
+ // position: relative;
+}
+
+.icon[data-tooltip]:before {
+ transition: none;
+ z-index: 5;
+}
+
+span[data-tooltip] {
+ border-bottom: none;
+}
+
+div[data-tooltip]::before {
+ position: absolute;
+}
+
+.modal-card-body>p {
+ padding: 1em;
+}
+
+.modal-card-body>p.warning {
+ background-color: #fffbdd;
+ border: solid 1px #f2e9bf;
+} \ No newline at end of file
diff --git a/packages/auditor-backoffice-ui/src/scss/toggle.scss b/packages/auditor-backoffice-ui/src/scss/toggle.scss
new file mode 100644
index 000000000..24636da2f
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/scss/toggle.scss
@@ -0,0 +1,51 @@
+$green: #56c080;
+
+.toggle {
+ cursor: pointer;
+ display: inline-block;
+}
+.toggle-switch {
+ display: inline-block;
+ background: #ccc;
+ border-radius: 16px;
+ width: 58px;
+ height: 32px;
+ position: relative;
+ vertical-align: middle;
+ transition: background 0.25s;
+ &:before,
+ &:after {
+ content: "";
+ }
+ &:before {
+ display: block;
+ background: linear-gradient(to bottom, #fff 0%, #eee 100%);
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
+ width: 24px;
+ height: 24px;
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ transition: left 0.25s;
+ }
+ .toggle:hover &:before {
+ background: linear-gradient(to bottom, #fff 0%, #fff 100%);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
+ }
+ .toggle-checkbox:checked + & {
+ background: $green;
+ &:before {
+ left: 30px;
+ }
+ }
+}
+.toggle-checkbox {
+ position: absolute;
+ visibility: hidden;
+}
+.toggle-label {
+ margin-left: 5px;
+ position: relative;
+ top: 2px;
+}
diff --git a/packages/auditor-backoffice-ui/src/stories.test.ts b/packages/auditor-backoffice-ui/src/stories.test.ts
new file mode 100644
index 000000000..abd993550
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/stories.test.ts
@@ -0,0 +1,44 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { setupI18n } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { parseGroupImport } from "@gnu-taler/web-util/browser";
+import * as admin from "./paths/admin/index.stories.js";
+import * as instance from "./paths/instance/index.stories.js";
+
+setupI18n("en", { en: {} });
+
+describe("All the examples:", () => {
+ const cms = parseGroupImport({ admin, instance });
+ cms.forEach((group) => {
+ describe(`Example for group: ${group.title}`, () => {
+ group.list.forEach((component) => {
+ describe(`Component: ${component.name}`, () => {
+ component.examples.forEach((example) => {
+ it(`should render example: ${example.name}`, () => {
+ tests.renderUI(example.render);
+ });
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/stories.tsx b/packages/auditor-backoffice-ui/src/stories.tsx
new file mode 100644
index 000000000..8bb06b8cb
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/stories.tsx
@@ -0,0 +1,48 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { strings } from "./i18n/strings.js";
+
+import * as admin from "./paths/admin/index.stories.js";
+import * as instance from "./paths/instance/index.stories.js";
+import * as components from "./components/index.stories.js";
+
+import { renderStories } from "@gnu-taler/web-util/browser";
+
+import "./scss/main.scss";
+
+function SortStories(a: any, b: any): number {
+ return (a?.order ?? 0) - (b?.order ?? 0);
+}
+
+function main(): void {
+ renderStories(
+ { admin, instance, components },
+ {
+ strings,
+ },
+ );
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", main);
+} else {
+ main();
+}
diff --git a/packages/auditor-backoffice-ui/src/sw.js b/packages/auditor-backoffice-ui/src/sw.js
new file mode 100644
index 000000000..bf52db6fa
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/sw.js
@@ -0,0 +1,25 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+// import { getFiles, setupPrecaching, setupRouting } from 'preact-cli/sw/';
+
+// setupRouting();
+// setupPrecaching(getFiles());
diff --git a/packages/auditor-backoffice-ui/src/utils/amount.ts b/packages/auditor-backoffice-ui/src/utils/amount.ts
new file mode 100644
index 000000000..475489d3e
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/utils/amount.ts
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ amountFractionalBase,
+ AmountJson,
+ Amounts,
+} from "@gnu-taler/taler-util";
+import { MerchantBackend } from "../declaration.js";
+
+/**
+ * merge refund with the same description and a difference less than one minute
+ * @param prev list of refunds that will hold the merged refunds
+ * @param cur new refund to add to the list
+ * @returns list with the new refund, may be merged with the last
+ */
+export function mergeRefunds(
+ prev: MerchantBackend.Orders.RefundDetails[],
+ cur: MerchantBackend.Orders.RefundDetails,
+): MerchantBackend.Orders.RefundDetails[] {
+ let tail;
+
+ if (
+ prev.length === 0 || //empty list
+ cur.timestamp.t_s === "never" || //current does not have timestamp
+ (tail = prev[prev.length - 1]).timestamp.t_s === "never" || // last does not have timestamp
+ cur.reason !== tail.reason || //different reason
+ cur.pending !== tail.pending || //different pending state
+ Math.abs(cur.timestamp.t_s - tail.timestamp.t_s) > 1000 * 60
+ ) {
+ //more than 1 minute difference
+
+ //can't merge refunds, they are different or to distant in time
+ prev.push(cur);
+ return prev;
+ }
+
+ const a = Amounts.parseOrThrow(tail.amount);
+ const b = Amounts.parseOrThrow(cur.amount);
+ const r = Amounts.add(a, b).amount;
+
+ prev[prev.length - 1] = {
+ ...tail,
+ amount: Amounts.stringify(r),
+ };
+
+ return prev;
+}
+
+export function rate(a: AmountJson, b: AmountJson): number {
+ const af = toFloat(a);
+ const bf = toFloat(b);
+ if (bf === 0) return 0;
+ return af / bf;
+}
+
+function toFloat(amount: AmountJson): number {
+ return amount.value + amount.fraction / amountFractionalBase;
+}
diff --git a/packages/auditor-backoffice-ui/src/utils/constants.ts b/packages/auditor-backoffice-ui/src/utils/constants.ts
new file mode 100644
index 000000000..7c4e288b3
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/utils/constants.ts
@@ -0,0 +1,197 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+//https://tools.ietf.org/html/rfc8905
+export const PAYTO_REGEX =
+ /^payto:\/\/[a-zA-Z][a-zA-Z0-9-.]+(\/[a-zA-Z0-9\-\.\~\(\)@_%:!$&'*+,;=]*)*\??((amount|receiver-name|sender-name|instruction|message)=[a-zA-Z0-9\-\.\~\(\)@_%:!$'*+,;=]*&?)*$/;
+export const PAYTO_WIRE_METHOD_LOOKUP =
+ /payto:\/\/([a-zA-Z][a-zA-Z0-9-.]+)\/.*/;
+
+export const AMOUNT_REGEX = /^[a-zA-Z][a-zA-Z]{1,11}:[0-9][0-9,]*\.?[0-9,]*$/;
+
+export const INSTANCE_ID_LOOKUP = /\/instances\/([^/]*)\/?$/;
+
+export const AMOUNT_ZERO_REGEX = /^[a-zA-Z][a-zA-Z]*:0$/;
+
+export const CROCKFORD_BASE32_REGEX =
+ /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]+[*~$=U]*$/;
+
+export const URL_REGEX =
+ /^((https?:)(\/\/\/?)([\w]*(?::[\w]*)?@)?([\d\w\.-]+)(?::(\d+))?)\/$/;
+
+// how much rows we add every time user hit load more
+export const PAGE_SIZE = 20;
+// how bigger can be the result set
+// after this threshold, load more with move the cursor
+export const MAX_RESULT_SIZE = PAGE_SIZE * 2 - 1;
+
+// how much we will wait for all request, in seconds
+export const DEFAULT_REQUEST_TIMEOUT = 10;
+
+export const MAX_IMAGE_SIZE = 1024 * 1024;
+
+export const INSTANCE_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.@-]+$/;
+
+export const COUNTRY_TABLE = {
+ AE: "U.A.E.",
+ AF: "Afghanistan",
+ AL: "Albania",
+ AM: "Armenia",
+ AN: "Netherlands Antilles",
+ AR: "Argentina",
+ AT: "Austria",
+ AU: "Australia",
+ AZ: "Azerbaijan",
+ BA: "Bosnia and Herzegovina",
+ BD: "Bangladesh",
+ BE: "Belgium",
+ BG: "Bulgaria",
+ BH: "Bahrain",
+ BN: "Brunei Darussalam",
+ BO: "Bolivia",
+ BR: "Brazil",
+ BT: "Bhutan",
+ BY: "Belarus",
+ BZ: "Belize",
+ CA: "Canada",
+ CG: "Congo",
+ CH: "Switzerland",
+ CI: "Cote d'Ivoire",
+ CL: "Chile",
+ CM: "Cameroon",
+ CN: "People's Republic of China",
+ CO: "Colombia",
+ CR: "Costa Rica",
+ CS: "Serbia and Montenegro",
+ CZ: "Czech Republic",
+ DE: "Germany",
+ DK: "Denmark",
+ DO: "Dominican Republic",
+ DZ: "Algeria",
+ EC: "Ecuador",
+ EE: "Estonia",
+ EG: "Egypt",
+ ER: "Eritrea",
+ ES: "Spain",
+ ET: "Ethiopia",
+ FI: "Finland",
+ FO: "Faroe Islands",
+ FR: "France",
+ GB: "United Kingdom",
+ GD: "Caribbean",
+ GE: "Georgia",
+ GL: "Greenland",
+ GR: "Greece",
+ GT: "Guatemala",
+ HK: "Hong Kong",
+ // HK: "Hong Kong S.A.R.",
+ HN: "Honduras",
+ HR: "Croatia",
+ HT: "Haiti",
+ HU: "Hungary",
+ ID: "Indonesia",
+ IE: "Ireland",
+ IL: "Israel",
+ IN: "India",
+ IQ: "Iraq",
+ IR: "Iran",
+ IS: "Iceland",
+ IT: "Italy",
+ JM: "Jamaica",
+ JO: "Jordan",
+ JP: "Japan",
+ KE: "Kenya",
+ KG: "Kyrgyzstan",
+ KH: "Cambodia",
+ KR: "South Korea",
+ KW: "Kuwait",
+ KZ: "Kazakhstan",
+ LA: "Laos",
+ LB: "Lebanon",
+ LI: "Liechtenstein",
+ LK: "Sri Lanka",
+ LT: "Lithuania",
+ LU: "Luxembourg",
+ LV: "Latvia",
+ LY: "Libya",
+ MA: "Morocco",
+ MC: "Principality of Monaco",
+ MD: "Moldava",
+ // MD: "Moldova",
+ ME: "Montenegro",
+ MK: "Former Yugoslav Republic of Macedonia",
+ ML: "Mali",
+ MM: "Myanmar",
+ MN: "Mongolia",
+ MO: "Macau S.A.R.",
+ MT: "Malta",
+ MV: "Maldives",
+ MX: "Mexico",
+ MY: "Malaysia",
+ NG: "Nigeria",
+ NI: "Nicaragua",
+ NL: "Netherlands",
+ NO: "Norway",
+ NP: "Nepal",
+ NZ: "New Zealand",
+ OM: "Oman",
+ PA: "Panama",
+ PE: "Peru",
+ PH: "Philippines",
+ PK: "Islamic Republic of Pakistan",
+ PL: "Poland",
+ PR: "Puerto Rico",
+ PT: "Portugal",
+ PY: "Paraguay",
+ QA: "Qatar",
+ RE: "Reunion",
+ RO: "Romania",
+ RS: "Serbia",
+ RU: "Russia",
+ RW: "Rwanda",
+ SA: "Saudi Arabia",
+ SE: "Sweden",
+ SG: "Singapore",
+ SI: "Slovenia",
+ SK: "Slovak",
+ SN: "Senegal",
+ SO: "Somalia",
+ SR: "Suriname",
+ SV: "El Salvador",
+ SY: "Syria",
+ TH: "Thailand",
+ TJ: "Tajikistan",
+ TM: "Turkmenistan",
+ TN: "Tunisia",
+ TR: "Turkey",
+ TT: "Trinidad and Tobago",
+ TW: "Taiwan",
+ TZ: "Tanzania",
+ UA: "Ukraine",
+ US: "United States",
+ UY: "Uruguay",
+ VA: "Vatican",
+ VE: "Venezuela",
+ VN: "Viet Nam",
+ YE: "Yemen",
+ ZA: "South Africa",
+ ZW: "Zimbabwe",
+};
diff --git a/packages/auditor-backoffice-ui/src/utils/regex.test.ts b/packages/auditor-backoffice-ui/src/utils/regex.test.ts
new file mode 100644
index 000000000..984f1a472
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/utils/regex.test.ts
@@ -0,0 +1,88 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+import { AMOUNT_REGEX, PAYTO_REGEX } from "./constants.js";
+
+describe("payto uri format", () => {
+ const valids = [
+ "payto://iban/DE75512108001245126199?amount=EUR:200.0&message=hello",
+ "payto://ach/122000661/1234",
+ "payto://upi/alice@example.com?receiver-name=Alice&amount=INR:200",
+ "payto://void/?amount=EUR:10.5",
+ "payto://ilp/g.acme.bob",
+ ];
+
+ it("should be valid", () => {
+ valids.forEach((v) => expect(v).match(PAYTO_REGEX));
+ });
+
+ const invalids = [
+ // has two question marks
+ "payto://iban/DE75?512108001245126199?amount=EUR:200.0&message=hello",
+ // has a space
+ "payto://ach /122000661/1234",
+ // has a space
+ "payto://upi/alice@ example.com?receiver-name=Alice&amount=INR:200",
+ // invalid field name (mount instead of amount)
+ "payto://void/?mount=EUR:10.5",
+ // payto:// is incomplete
+ "payto: //ilp/g.acme.bob",
+ ];
+
+ it("should not be valid", () => {
+ invalids.forEach((v) => expect(v).not.match(PAYTO_REGEX));
+ });
+});
+
+describe("amount format", () => {
+ const valids = [
+ "ARS:10",
+ "COL:10.2",
+ "UY:1,000.2",
+ "ARS:10.123,123",
+ "ARS:1,000,000",
+ "ARSCOL:10",
+ "LONGESTCURR:1,000,000.123,123",
+ ];
+
+
+ it("should be valid", () => {
+ valids.forEach((v) => expect(v).match(AMOUNT_REGEX));
+ });
+
+ const invalids = [
+ //no currency name
+ ":10",
+ //use . instead of ,
+ "ARS:1.000.000",
+ //currency name with numbers
+ "1ARS:10",
+ //currency name with numbers
+ "AR5:10",
+ //missing value
+ "USD:",
+ ];
+
+ it("should not be valid", () => {
+ invalids.forEach((v) => expect(v).not.match(AMOUNT_REGEX));
+ });
+});
diff --git a/packages/auditor-backoffice-ui/src/utils/table.ts b/packages/auditor-backoffice-ui/src/utils/table.ts
new file mode 100644
index 000000000..db2b2021c
--- /dev/null
+++ b/packages/auditor-backoffice-ui/src/utils/table.ts
@@ -0,0 +1,57 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { WithId } from "../declaration.js";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export interface Actions<T extends WithId> {
+ element: T;
+ type: "DELETE" | "UPDATE";
+}
+
+function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
+ return value !== null && value !== undefined;
+}
+
+export function buildActions<T extends WithId>(
+ instances: T[],
+ selected: string[],
+ action: "DELETE",
+): Actions<T>[] {
+ return selected
+ .map((id) => instances.find((i) => i.id === id))
+ .filter(notEmpty)
+ .map((id) => ({ element: id, type: action }));
+}
+
+/**
+ * For any object or array, return the same object if is not empty.
+ * not empty:
+ * - for arrays: at least one element not undefined
+ * - for objects: at least one property not undefined
+ * @param obj
+ * @returns
+ */
+export function undefinedIfEmpty<
+ T extends Record<string, unknown> | Array<unknown>,
+>(obj: T | undefined): T | undefined {
+ if (obj === undefined) return undefined;
+ return Object.values(obj).some((v) => v !== undefined) ? obj : undefined;
+}
diff --git a/packages/taler-wallet-webextension/tests/__mocks__/fileTransformer.js b/packages/auditor-backoffice-ui/src/utils/types.ts
index e6193f8fd..0d249f3c4 100644
--- a/packages/taler-wallet-webextension/tests/__mocks__/fileTransformer.js
+++ b/packages/auditor-backoffice-ui/src/utils/types.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2021-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,18 +14,18 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-// fileTransformer.js
+import { VNode } from "preact";
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-const path = require('path');
+export interface KeyValue {
+ [key: string]: string;
+}
-module.exports = {
- process(src, filename, config, options) {
- return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';';
- },
-};
+export interface Notification {
+ message: string;
+ description?: string | VNode;
+ details?: string | VNode;
+ type: MessageType;
+}
+export type ValueOrFunction<T> = T | ((p: T) => T);
+export type MessageType = "INFO" | "WARN" | "ERROR" | "SUCCESS";
diff --git a/packages/auditor-backoffice-ui/test.mjs b/packages/auditor-backoffice-ui/test.mjs
new file mode 100755
index 000000000..be76348e5
--- /dev/null
+++ b/packages/auditor-backoffice-ui/test.mjs
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build } from "@gnu-taler/web-util/build";
+import { getFilesInDirectory } from "@gnu-taler/web-util/build";
+
+const allTestFiles = getFilesInDirectory("src", /.test.tsx?$/);
+
+await build({
+ type: "test",
+ source: {
+ js: allTestFiles.files,
+ assets: [{base:"src",files:["src/index.html"]}],
+ },
+ destination: "./dist/test",
+ css: "sass",
+});
diff --git a/packages/auditor-backoffice-ui/tsconfig.json b/packages/auditor-backoffice-ui/tsconfig.json
new file mode 100644
index 000000000..396f1e9e7
--- /dev/null
+++ b/packages/auditor-backoffice-ui/tsconfig.json
@@ -0,0 +1,58 @@
+{
+ "compilerOptions": {
+ /* Basic Options */
+ "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */,
+ "module": "Node16" /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
+ "lib": [
+ "es2020",
+ "dom"
+ ] /* Specify library files to be included in the compilation: */,
+ // "allowJs": true, /* Allow javascript files to be compiled. */
+ // "checkJs": true, /* Report errors in .js files. */
+ "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
+ "jsxFactory": "h" /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */,
+ "jsxFragmentFactory": "Fragment", // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#custom-jsx-factories
+ // "declaration": true, /* Generates corresponding '.d.ts' file. */
+ // "sourceMap": true, /* Generates corresponding '.map' file. */
+ // "outFile": "./", /* Concatenate and emit output to single file. */
+ // "outDir": "./", /* Redirect output structure to the directory. */
+ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+ // "removeComments": true, /* Do not emit comments to output. */
+ "noEmit": true /* Do not emit outputs. */,
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+ /* Strict Type-Checking Options */
+ "strict": true /* Enable all strict type-checking options. */,
+ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* Enable strict null checks. */
+ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
+ /* Additional Checks */
+ // "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+ /* Module Resolution Options */
+ "moduleResolution": "node16" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
+ "esModuleInterop": true /* */,
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+ /* Source Map Options */
+ // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+ /* Advanced Options */
+ "skipLibCheck": true /* Skip type checking of declaration files. */
+ },
+ "include": ["src/**/*", "tests/**/*"]
+}
diff --git a/packages/bank-ui/.eslintrc.cjs b/packages/bank-ui/.eslintrc.cjs
new file mode 100644
index 000000000..05618b499
--- /dev/null
+++ b/packages/bank-ui/.eslintrc.cjs
@@ -0,0 +1,28 @@
+module.exports = {
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react/recommended',
+ ],
+ parser: '@typescript-eslint/parser',
+ plugins: ['@typescript-eslint', 'header'],
+ root: true,
+ rules: {
+ "react/no-unknown-property": 0,
+ "react/no-unescaped-entities": 0,
+ "@typescript-eslint/no-namespace": 0,
+ "@typescript-eslint/no-unused-vars": [2,{argsIgnorePattern:"^_"}],
+ "header/header": [2,"copyleft-header.js"]
+ },
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ jsx: true,
+ },
+ settings: {
+ react: {
+ version: "18",
+ pragma: "h",
+ }
+ },
+};
diff --git a/packages/bank-ui/.gitignore b/packages/bank-ui/.gitignore
new file mode 100644
index 000000000..30cb2774c
--- /dev/null
+++ b/packages/bank-ui/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+/build
+/*.log
+/demobank-ui-settings.js
diff --git a/packages/bank-ui/Makefile b/packages/bank-ui/Makefile
new file mode 100644
index 000000000..036e6fd3e
--- /dev/null
+++ b/packages/bank-ui/Makefile
@@ -0,0 +1,37 @@
+# This Makefile has been placed in the public domain
+
+ifeq ($(TOPLEVEL), yes)
+ $(info top-level build)
+ -include ../../.config.mk
+ override DESTDIR := $(TOP_DESTDIR)
+else
+ $(info package-level build)
+ -include ../../.config.mk
+ -include .config.mk
+endif
+
+$(info prefix is $(prefix))
+
+.PHONY: all
+all:
+ @echo run \'make install\' to install
+
+spa_dir=$(DESTDIR)$(prefix)/share/taler/bank-ui
+
+.PHONY: deps
+deps:
+ pnpm install --frozen-lockfile --filter @gnu-taler/bank-ui...
+ pnpm run --filter @gnu-taler/bank-ui... compile
+ pnpm run check
+ pnpm run build
+
+.PHONY: install-nodeps
+install-nodeps:
+ install -d $(spa_dir)
+ install ./dist/prod/* $(spa_dir)
+
+.PHONY: install
+install:
+ $(MAKE) deps
+ $(MAKE) install-nodeps
+
diff --git a/packages/bank-ui/README.md b/packages/bank-ui/README.md
new file mode 100644
index 000000000..4275cce57
--- /dev/null
+++ b/packages/bank-ui/README.md
@@ -0,0 +1,24 @@
+# Taler Bank UI
+
+Web-based user interface for the libeufin bank ui.
+
+## CLI Commands
+
+- `./dev.mjs` development setup. Will listen in :8080 and reload every time a file is save.
+- `./build.mjs` build for production.
+- `./test.mjs` build and run unit test
+
+## Testing
+
+By default, the bank-ui will expect the backend to be in `window.origin` but that can be overridden using the `settings.json` file or by session in the localStorage.
+
+```
+localStorage.setItem("bank-base-url", OTHER_URL);
+```
+
+## Customizing Per-Deployment Settings
+
+To customize per-deployment settings, make sure that the
+`settings.json` file is served alongside the UI.
+
+For more information about the values check the file `settings.ts` in the src folder.
diff --git a/packages/bank-ui/build.mjs b/packages/bank-ui/build.mjs
new file mode 100755
index 000000000..04a6f646b
--- /dev/null
+++ b/packages/bank-ui/build.mjs
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build } from "@gnu-taler/web-util/build";
+
+await build({
+ type: "production",
+ source: {
+ js: ["src/index.tsx"],
+ assets: [{ base: "src", files: ["src/index.html"] }],
+ },
+ destination: "./dist/prod",
+ css: "postcss",
+});
diff --git a/packages/bank-ui/contrib/po2ts b/packages/bank-ui/contrib/po2ts
new file mode 100755
index 000000000..a135da61b
--- /dev/null
+++ b/packages/bank-ui/contrib/po2ts
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Convert a <lang>.po file into a JavaScript / TypeScript expression.
+ */
+
+const po2json = require("po2json");
+
+const filename = process.argv[2];
+
+if (!filename) {
+ console.error("error: missing filename");
+ process.exit(1);
+}
+
+const m = filename.match(/([a-zA-Z0-9-_]+).po/);
+
+if (!m) {
+ console.error("error: unexpected filename (expected <lang>.po)");
+ process.exit(1);
+}
+
+const lang = m[1];
+const pojson = po2json.parseFileSync(filename, { format: "jed1.x", fuzzy: true });
+const s =
+ "strings['" + lang + "'] = " + JSON.stringify(pojson, null, " ") + ";\n";
+console.log(s);
diff --git a/packages/bank-ui/copyleft-header.js b/packages/bank-ui/copyleft-header.js
new file mode 100644
index 000000000..7fa276bea
--- /dev/null
+++ b/packages/bank-ui/copyleft-header.js
@@ -0,0 +1,15 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
diff --git a/packages/bank-ui/dev.mjs b/packages/bank-ui/dev.mjs
new file mode 100755
index 000000000..7b4f719ae
--- /dev/null
+++ b/packages/bank-ui/dev.mjs
@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { serve } from "@gnu-taler/web-util/node";
+import { initializeDev } from "@gnu-taler/web-util/build";
+
+const devEntryPoints = ["src/stories.tsx", "src/index.tsx"];
+
+const build = initializeDev({
+ type: "development",
+ source: {
+ js: devEntryPoints,
+ assets: [{ base: "src", files: ["src/index.html", "src/settings.json"] }],
+ },
+ destination: "./dist/dev",
+ public: "/app",
+ css: "postcss",
+});
+
+await build();
+
+serve({
+ folder: "./dist/dev",
+ port: 8080,
+ source: "./src",
+ onSourceUpdate: build,
+});
diff --git a/packages/bank-ui/package.json b/packages/bank-ui/package.json
new file mode 100644
index 000000000..d4e8f9593
--- /dev/null
+++ b/packages/bank-ui/package.json
@@ -0,0 +1,52 @@
+{
+ "private": true,
+ "name": "@gnu-taler/bank-ui",
+ "version": "0.10.6",
+ "license": "AGPL-3.0-OR-LATER",
+ "type": "module",
+ "scripts": {
+ "build": "./build.mjs",
+ "check": "tsc",
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo",
+ "compile": "tsc && ./build.mjs",
+ "test": "./test.mjs && mocha --require source-map-support/register 'dist/test/**/*.test.js' 'dist/test/**/test.js'",
+ "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "i18n:strings": "pogen extract && pogen merge",
+ "i18n:translations": "pogen emit",
+ "pretty": "prettier --write src"
+ },
+ "dependencies": {
+ "@gnu-taler/taler-util": "workspace:*",
+ "@gnu-taler/web-util": "workspace:*",
+ "date-fns": "2.29.3",
+ "jed": "1.1.1",
+ "preact": "10.11.3",
+ "qrcode-generator": "^1.4.4",
+ "swr": "2.0.3"
+ },
+ "devDependencies": {
+ "eslint": "^8.56.0",
+ "@typescript-eslint/eslint-plugin": "^6.19.0",
+ "@typescript-eslint/parser": "^6.19.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-react": "^7.33.2",
+ "@gnu-taler/pogen": "^0.0.5",
+ "@tailwindcss/forms": "^0.5.3",
+ "@tailwindcss/typography": "^0.5.9",
+ "@types/chai": "^4.3.0",
+ "@types/history": "^4.7.8",
+ "@types/mocha": "^10.0.1",
+ "@types/node": "^18.11.17",
+ "autoprefixer": "^10.4.14",
+ "chai": "^4.3.6",
+ "esbuild": "^0.19.9",
+ "mocha": "9.2.0",
+ "po2json": "^0.4.5",
+ "tailwindcss": "^3.3.2",
+ "typescript": "5.3.3"
+ },
+ "pogen": {
+ "domain": "bank"
+ }
+}
diff --git a/packages/bank-ui/postcss.config.js b/packages/bank-ui/postcss.config.js
new file mode 100644
index 000000000..2e7af2b7f
--- /dev/null
+++ b/packages/bank-ui/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx
new file mode 100644
index 000000000..23635d4cd
--- /dev/null
+++ b/packages/bank-ui/src/Routing.tsx
@@ -0,0 +1,612 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ LocalNotificationBanner,
+ urlPattern,
+ useBankCoreApiContext,
+ useCurrentLocation,
+ useLocalNotification,
+ useNavigationContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+
+import {
+ AbsoluteTime,
+ AccessToken,
+ HttpStatusCode,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import { useEffect } from "preact/hooks";
+import { useSessionState } from "./hooks/session.js";
+import { AccountPage } from "./pages/AccountPage/index.js";
+import { BankFrame } from "./pages/BankFrame.js";
+import { LoginForm } from "./pages/LoginForm.js";
+import { PublicHistoriesPage } from "./pages/PublicHistoriesPage.js";
+import { RegistrationPage } from "./pages/RegistrationPage.js";
+import { ShowNotifications } from "./pages/ShowNotifications.js";
+import { SolveChallengePage } from "./pages/SolveChallengePage.js";
+import { WireTransfer } from "./pages/WireTransfer.js";
+import { WithdrawalOperationPage } from "./pages/WithdrawalOperationPage.js";
+import { CashoutListForAccount } from "./pages/account/CashoutListForAccount.js";
+import { ShowAccountDetails } from "./pages/account/ShowAccountDetails.js";
+import { UpdateAccountPassword } from "./pages/account/UpdateAccountPassword.js";
+import { AdminHome } from "./pages/admin/AdminHome.js";
+import { CreateNewAccount } from "./pages/admin/CreateNewAccount.js";
+import { DownloadStats } from "./pages/admin/DownloadStats.js";
+import { RemoveAccount } from "./pages/admin/RemoveAccount.js";
+import { ConversionConfig } from "./pages/regional/ConversionConfig.js";
+import { CreateCashout } from "./pages/regional/CreateCashout.js";
+import { ShowCashoutDetails } from "./pages/regional/ShowCashoutDetails.js";
+
+export function Routing(): VNode {
+ const session = useSessionState();
+
+ if (session.state.status === "loggedIn") {
+ const { isUserAdministrator, username } = session.state;
+ return (
+ <BankFrame
+ account={username}
+ routeAccountDetails={privatePages.myAccountDetails}
+ >
+ <PrivateRouting username={username} isAdmin={isUserAdministrator} />
+ </BankFrame>
+ );
+ }
+ return (
+ <BankFrame>
+ <PublicRounting
+ onLoggedUser={(username, token) => {
+ session.logIn({ username, token: token });
+ }}
+ />
+ </BankFrame>
+ );
+}
+
+const publicPages = {
+ login: urlPattern(/\/login/, () => "#/login"),
+ register: urlPattern(/\/register/, () => "#/register"),
+ publicAccounts: urlPattern(/\/public-accounts/, () => "#/public-accounts"),
+ operationDetails: urlPattern<{ wopid: string }>(
+ /\/operation\/(?<wopid>[a-zA-Z0-9-]+)/,
+ ({ wopid }) => `#/operation/${wopid}`,
+ ),
+ solveSecondFactor: urlPattern(/\/2fa/, () => "#/2fa"),
+};
+
+function PublicRounting({
+ onLoggedUser,
+}: {
+ onLoggedUser: (username: string, token: AccessToken) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const location = useCurrentLocation(publicPages);
+ const { navigateTo } = useNavigationContext();
+ const { config, lib } = useBankCoreApiContext();
+ const [notification, notify, handleError] = useLocalNotification();
+
+ useEffect(() => {
+ if (location === undefined) {
+ navigateTo(publicPages.login.url({}));
+ }
+ }, [location]);
+
+ if (location === undefined) {
+ return <Fragment />;
+ }
+
+ async function doAutomaticLogin(username: string, password: string) {
+ await handleError(async () => {
+ const resp = await lib
+ .auth(username)
+ .createAccessTokenBasic(username, password, {
+ scope: "readwrite",
+ duration: { d_us: "forever" },
+ refreshable: true,
+ });
+ if (resp.type === "ok") {
+ onLoggedUser(username, resp.body.access_token);
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`Wrong credentials for "${username}"`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`Account not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ switch (location.name) {
+ case "login": {
+ return (
+ <Fragment>
+ <div class="sm:mx-auto sm:w-full sm:max-w-sm">
+ <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Welcome to ${config.bank_name}!`}</h2>
+ </div>
+ <LoginForm routeRegister={publicPages.register} />
+ </Fragment>
+ );
+ }
+ case "publicAccounts": {
+ return <PublicHistoriesPage />;
+ }
+ case "operationDetails": {
+ return (
+ <WithdrawalOperationPage
+ operationId={location.values.wopid}
+ routeWithdrawalDetails={publicPages.operationDetails}
+ purpose="after-confirmation"
+ onOperationAborted={() => navigateTo(publicPages.login.url({}))}
+ routeClose={publicPages.login}
+ onAuthorizationRequired={() =>
+ navigateTo(publicPages.solveSecondFactor.url({}))
+ }
+ />
+ );
+ }
+ case "register": {
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+ <RegistrationPage
+ onRegistrationSuccesful={doAutomaticLogin}
+ routeCancel={publicPages.login}
+ />
+ </Fragment>
+ );
+ }
+ case "solveSecondFactor": {
+ return (
+ <SolveChallengePage
+ onChallengeCompleted={() => navigateTo(publicPages.login.url({}))}
+ routeClose={publicPages.login}
+ />
+ );
+ }
+ default:
+ assertUnreachable(location);
+ }
+}
+
+export const privatePages = {
+ homeChargeWallet: urlPattern(
+ /\/account\/charge-wallet/,
+ () => "#/account/charge-wallet",
+ ),
+ homeWireTransfer: urlPattern<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>(/\/account\/wire-transfer/, () => "#/account/wire-transfer"),
+ home: urlPattern(/\/account/, () => "#/account"),
+ notifications: urlPattern(/\/notifications/, () => "#/notifications"),
+ solveSecondFactor: urlPattern(/\/2fa/, () => "#/2fa"),
+ cashoutCreate: urlPattern(/\/new-cashout/, () => "#/new-cashout"),
+ cashoutDetails: urlPattern<{ cid: string }>(
+ /\/cashout\/(?<cid>[a-zA-Z0-9]+)/,
+ ({ cid }) => `#/cashout/${cid}`,
+ ),
+ wireTranserCreate: urlPattern<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>(
+ /\/wire-transfer\/(?<account>[a-zA-Z0-9]+)/,
+ ({ account }) => `#/wire-transfer/${account}`,
+ ),
+ publicAccountList: urlPattern(/\/public-accounts/, () => "#/public-accounts"),
+ statsDownload: urlPattern(/\/download-stats/, () => "#/download-stats"),
+ accountCreate: urlPattern(/\/new-account/, () => "#/new-account"),
+ myAccountDelete: urlPattern(
+ /\/delete-my-account/,
+ () => "#/delete-my-account",
+ ),
+ myAccountDetails: urlPattern(/\/my-profile/, () => "#/my-profile"),
+ myAccountPassword: urlPattern(/\/my-password/, () => "#/my-password"),
+ myAccountCashouts: urlPattern(/\/my-cashouts/, () => "#/my-cashouts"),
+ conversionConfig: urlPattern(/\/conversion/, () => "#/conversion"),
+ accountDetails: urlPattern<{ account: string }>(
+ /\/profile\/(?<account>[a-zA-Z0-9_-]+)\/details/,
+ ({ account }) => `#/profile/${account}/details`,
+ ),
+ accountChangePassword: urlPattern<{ account: string }>(
+ /\/profile\/(?<account>[a-zA-Z0-9_-]+)\/change-password/,
+ ({ account }) => `#/profile/${account}/change-password`,
+ ),
+ accountDelete: urlPattern<{ account: string }>(
+ /\/profile\/(?<account>[a-zA-Z0-9_-]+)\/delete/,
+ ({ account }) => `#/profile/${account}/delete`,
+ ),
+ accountCashouts: urlPattern<{ account: string }>(
+ /\/profile\/(?<account>[a-zA-Z0-9_-]+)\/cashouts/,
+ ({ account }) => `#/profile/${account}/cashouts`,
+ ),
+ startOperation: urlPattern<{ wopid: string }>(
+ /\/start-operation\/(?<wopid>[a-zA-Z0-9-]+)/,
+ ({ wopid }) => `#/start-operation/${wopid}`,
+ ),
+ operationDetails: urlPattern<{ wopid: string }>(
+ /\/operation\/(?<wopid>[a-zA-Z0-9-]+)/,
+ ({ wopid }) => `#/operation/${wopid}`,
+ ),
+};
+
+function PrivateRouting({
+ username,
+ isAdmin,
+}: {
+ username: string;
+ isAdmin: boolean;
+}): VNode {
+ const { navigateTo } = useNavigationContext();
+ const location = useCurrentLocation(privatePages);
+ useEffect(() => {
+ if (location === undefined) {
+ navigateTo(privatePages.home.url({}));
+ }
+ }, [location]);
+
+ if (location === undefined) {
+ return <Fragment />;
+ }
+
+ switch (location.name) {
+ case "operationDetails": {
+ return (
+ <WithdrawalOperationPage
+ operationId={location.values.wopid}
+ routeWithdrawalDetails={privatePages.operationDetails}
+ purpose="after-confirmation"
+ onOperationAborted={() => navigateTo(privatePages.home.url({}))}
+ routeClose={privatePages.home}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ />
+ );
+ }
+ case "startOperation": {
+ return (
+ <WithdrawalOperationPage
+ operationId={location.values.wopid}
+ routeWithdrawalDetails={privatePages.operationDetails}
+ purpose="after-creation"
+ onOperationAborted={() => navigateTo(privatePages.home.url({}))}
+ routeClose={privatePages.home}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ />
+ );
+ }
+ case "solveSecondFactor": {
+ return (
+ <SolveChallengePage
+ onChallengeCompleted={() => navigateTo(privatePages.home.url({}))}
+ routeClose={privatePages.home}
+ />
+ );
+ }
+ case "publicAccountList": {
+ return <PublicHistoriesPage />;
+ }
+ case "statsDownload": {
+ return <DownloadStats routeCancel={privatePages.home} />;
+ }
+ case "accountCreate": {
+ return (
+ <CreateNewAccount
+ routeCancel={privatePages.home}
+ onCreateSuccess={() => navigateTo(privatePages.home.url({}))}
+ />
+ );
+ }
+ case "accountDetails": {
+ return (
+ <ShowAccountDetails
+ account={location.values.account}
+ onUpdateSuccess={() => navigateTo(privatePages.home.url({}))}
+ routeHere={privatePages.accountDetails}
+ routeMyAccountCashout={privatePages.myAccountCashouts}
+ routeMyAccountDelete={privatePages.myAccountDelete}
+ routeMyAccountDetails={privatePages.myAccountDetails}
+ routeMyAccountPassword={privatePages.myAccountPassword}
+ routeConversionConfig={privatePages.conversionConfig}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ routeClose={privatePages.home}
+ />
+ );
+ }
+ case "accountChangePassword": {
+ return (
+ <UpdateAccountPassword
+ focus
+ account={location.values.account}
+ routeHere={privatePages.accountChangePassword}
+ onUpdateSuccess={() => navigateTo(privatePages.home.url({}))}
+ routeMyAccountCashout={privatePages.myAccountCashouts}
+ routeMyAccountDelete={privatePages.myAccountDelete}
+ routeMyAccountDetails={privatePages.myAccountDetails}
+ routeMyAccountPassword={privatePages.myAccountPassword}
+ routeConversionConfig={privatePages.conversionConfig}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ routeClose={privatePages.home}
+ />
+ );
+ }
+ case "accountDelete": {
+ return (
+ <RemoveAccount
+ account={location.values.account}
+ routeHere={privatePages.accountDelete}
+ onUpdateSuccess={() => navigateTo(privatePages.home.url({}))}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ routeCancel={privatePages.home}
+ />
+ );
+ }
+ case "accountCashouts": {
+ return (
+ <CashoutListForAccount
+ account={location.values.account}
+ routeCreateCashout={privatePages.cashoutCreate}
+ routeCashoutDetails={privatePages.cashoutDetails}
+ routeClose={privatePages.home}
+ routeMyAccountCashout={privatePages.myAccountCashouts}
+ routeMyAccountDelete={privatePages.myAccountDelete}
+ routeMyAccountDetails={privatePages.myAccountDetails}
+ routeMyAccountPassword={privatePages.myAccountPassword}
+ routeConversionConfig={privatePages.conversionConfig}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ />
+ );
+ }
+ case "myAccountDelete": {
+ return (
+ <RemoveAccount
+ account={username}
+ routeHere={privatePages.accountDelete}
+ onUpdateSuccess={() => navigateTo(privatePages.home.url({}))}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ routeCancel={privatePages.home}
+ />
+ );
+ }
+ case "myAccountDetails": {
+ return (
+ <ShowAccountDetails
+ account={username}
+ routeHere={privatePages.accountDetails}
+ onUpdateSuccess={() => navigateTo(privatePages.home.url({}))}
+ routeMyAccountCashout={privatePages.myAccountCashouts}
+ routeConversionConfig={privatePages.conversionConfig}
+ routeMyAccountDelete={privatePages.myAccountDelete}
+ routeMyAccountDetails={privatePages.myAccountDetails}
+ routeMyAccountPassword={privatePages.myAccountPassword}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ routeClose={privatePages.home}
+ />
+ );
+ }
+ case "myAccountPassword": {
+ return (
+ <UpdateAccountPassword
+ focus
+ account={username}
+ routeHere={privatePages.accountChangePassword}
+ onUpdateSuccess={() => navigateTo(privatePages.home.url({}))}
+ routeMyAccountCashout={privatePages.myAccountCashouts}
+ routeMyAccountDelete={privatePages.myAccountDelete}
+ routeMyAccountDetails={privatePages.myAccountDetails}
+ routeMyAccountPassword={privatePages.myAccountPassword}
+ routeConversionConfig={privatePages.conversionConfig}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ routeClose={privatePages.home}
+ />
+ );
+ }
+ case "myAccountCashouts": {
+ return (
+ <CashoutListForAccount
+ account={username}
+ routeCashoutDetails={privatePages.cashoutDetails}
+ routeCreateCashout={privatePages.cashoutCreate}
+ routeMyAccountCashout={privatePages.myAccountCashouts}
+ routeMyAccountDelete={privatePages.myAccountDelete}
+ routeMyAccountDetails={privatePages.myAccountDetails}
+ routeMyAccountPassword={privatePages.myAccountPassword}
+ routeConversionConfig={privatePages.conversionConfig}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ routeClose={privatePages.home}
+ />
+ );
+ }
+ case "home": {
+ if (isAdmin) {
+ return (
+ <AdminHome
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ routeCreate={privatePages.accountCreate}
+ routeRemoveAccount={privatePages.accountDelete}
+ routeShowAccount={privatePages.accountDetails}
+ routeShowCashoutsAccount={privatePages.accountCashouts}
+ routeUpdatePasswordAccount={privatePages.accountChangePassword}
+ routeCreateWireTransfer={privatePages.wireTranserCreate}
+ routeDownloadStats={privatePages.statsDownload}
+ />
+ );
+ }
+ return (
+ <AccountPage
+ account={username}
+ tab={undefined}
+ routeCreateWireTransfer={privatePages.wireTranserCreate}
+ routePublicAccounts={privatePages.publicAccountList}
+ routeOperationDetails={privatePages.startOperation}
+ routeChargeWallet={privatePages.homeChargeWallet}
+ routeWireTransfer={privatePages.homeWireTransfer}
+ routeSolveSecondFactor={privatePages.solveSecondFactor}
+ routeCashout={privatePages.myAccountCashouts}
+ routeClose={privatePages.home}
+ onClose={() => navigateTo(privatePages.home.url({}))}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ onOperationCreated={(wopid) =>
+ navigateTo(privatePages.startOperation.url({ wopid }))
+ }
+ />
+ );
+ }
+ case "cashoutCreate": {
+ return (
+ <CreateCashout
+ account={username}
+ routeHere={privatePages.cashoutCreate}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ routeClose={privatePages.home}
+ />
+ );
+ }
+ case "cashoutDetails": {
+ return (
+ <ShowCashoutDetails
+ id={location.values.cid}
+ routeClose={privatePages.myAccountCashouts}
+ />
+ );
+ }
+ case "wireTranserCreate": {
+ return (
+ <WireTransfer
+ toAccount={location.values.account}
+ withAmount={location.values.amount}
+ withSubject={location.values.subject}
+ routeHere={privatePages.wireTranserCreate}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ routeCancel={privatePages.home}
+ onSuccess={() => navigateTo(privatePages.home.url({}))}
+ />
+ );
+ }
+ case "homeChargeWallet": {
+ return (
+ <AccountPage
+ account={username}
+ tab="charge-wallet"
+ routeChargeWallet={privatePages.homeChargeWallet}
+ routeWireTransfer={privatePages.homeWireTransfer}
+ routeCreateWireTransfer={privatePages.wireTranserCreate}
+ routePublicAccounts={privatePages.publicAccountList}
+ routeOperationDetails={privatePages.startOperation}
+ routeCashout={privatePages.myAccountCashouts}
+ routeSolveSecondFactor={privatePages.solveSecondFactor}
+ routeClose={privatePages.home}
+ onClose={() => navigateTo(privatePages.home.url({}))}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ onOperationCreated={(wopid) =>
+ navigateTo(privatePages.startOperation.url({ wopid }))
+ }
+ />
+ );
+ }
+ case "conversionConfig": {
+ return (
+ <ConversionConfig
+ routeMyAccountCashout={privatePages.myAccountCashouts}
+ routeMyAccountDelete={privatePages.myAccountDelete}
+ routeMyAccountDetails={privatePages.myAccountDetails}
+ routeMyAccountPassword={privatePages.myAccountPassword}
+ routeConversionConfig={privatePages.conversionConfig}
+ routeCancel={privatePages.home}
+ onUpdateSuccess={() => {
+ navigateTo(privatePages.home.url({}));
+ }}
+ />
+ );
+ }
+ case "homeWireTransfer": {
+ return (
+ <AccountPage
+ account={username}
+ tab="wire-transfer"
+ routeChargeWallet={privatePages.homeChargeWallet}
+ routeWireTransfer={privatePages.homeWireTransfer}
+ routeCreateWireTransfer={privatePages.wireTranserCreate}
+ routePublicAccounts={privatePages.publicAccountList}
+ routeOperationDetails={privatePages.startOperation}
+ routeSolveSecondFactor={privatePages.solveSecondFactor}
+ routeCashout={privatePages.myAccountCashouts}
+ routeClose={privatePages.home}
+ onClose={() => navigateTo(privatePages.home.url({}))}
+ onAuthorizationRequired={() =>
+ navigateTo(privatePages.solveSecondFactor.url({}))
+ }
+ onOperationCreated={(wopid) =>
+ navigateTo(privatePages.startOperation.url({ wopid }))
+ }
+ />
+ );
+ }
+ case "notifications": {
+ return <ShowNotifications />;
+ }
+ default:
+ assertUnreachable(location);
+ }
+}
diff --git a/packages/bank-ui/src/app.tsx b/packages/bank-ui/src/app.tsx
new file mode 100644
index 000000000..a2aa6ec37
--- /dev/null
+++ b/packages/bank-ui/src/app.tsx
@@ -0,0 +1,234 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ CacheEvictor,
+ TalerBankConversionCacheEviction,
+ TalerCoreBankCacheEviction,
+ assertUnreachable,
+ canonicalizeBaseUrl,
+ getGlobalLogLevel,
+ setGlobalLogLevelFromString,
+} from "@gnu-taler/taler-util";
+import {
+ BankApiProvider,
+ BrowserHashNavigationProvider,
+ Loading,
+ TalerWalletIntegrationBrowserProvider,
+ TranslationProvider,
+} from "@gnu-taler/web-util/browser";
+import { h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { SWRConfig } from "swr";
+import { Routing } from "./Routing.js";
+// import { BankCoreApiProvider } from "./context/config.js";
+// import { BrowserHashNavigationProvider } from "./context/navigation.js";
+import { SettingsProvider } from "./context/settings.js";
+// import { TalerWalletIntegrationBrowserProvider } from "./context/wallet-integration.js";
+import { strings } from "./i18n/strings.js";
+import { BankFrame } from "./pages/BankFrame.js";
+import { BankUiSettings, fetchSettings } from "./settings.js";
+import {
+ revalidateAccountDetails,
+ revalidatePublicAccounts,
+ revalidateTransactions,
+} from "./hooks/account.js";
+import {
+ revalidateBusinessAccounts,
+ revalidateCashouts,
+ revalidateConversionInfo,
+} from "./hooks/regional.js";
+const WITH_LOCAL_STORAGE_CACHE = false;
+
+export function App() {
+ const [settings, setSettings] = useState<BankUiSettings>();
+ useEffect(() => {
+ fetchSettings(setSettings);
+ }, []);
+ if (!settings) return <Loading />;
+
+ const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL);
+ return (
+ <SettingsProvider value={settings}>
+ <TranslationProvider
+ source={strings}
+ completeness={{
+ es: strings["es"].completeness,
+ de: strings["de"].completeness,
+ }}
+ >
+ <BankApiProvider
+ baseUrl={new URL("/", baseUrl)}
+ frameOnError={BankFrame}
+ evictors={{
+ bank: evictBankSwrCache,
+ conversion: evictConversionSwrCache,
+ }}
+ >
+ <SWRConfig
+ value={{
+ provider: WITH_LOCAL_STORAGE_CACHE
+ ? localStorageProvider
+ : undefined,
+ // normally, do not revalidate
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ revalidateIfStale: false,
+ revalidateOnMount: undefined,
+ focusThrottleInterval: undefined,
+
+ // normally, do not refresh
+ refreshInterval: undefined,
+ dedupingInterval: 2000,
+ refreshWhenHidden: false,
+ refreshWhenOffline: false,
+
+ // ignore errors
+ shouldRetryOnError: false,
+ errorRetryCount: 0,
+ errorRetryInterval: undefined,
+
+ // do not go to loading again if already has data
+ keepPreviousData: true,
+ }}
+ >
+ <TalerWalletIntegrationBrowserProvider>
+ <BrowserHashNavigationProvider>
+ <Routing />
+ </BrowserHashNavigationProvider>
+ </TalerWalletIntegrationBrowserProvider>
+ </SWRConfig>
+ </BankApiProvider>
+ </TranslationProvider>
+ </SettingsProvider>
+ );
+}
+
+// @ts-expect-error creating a new property for window object
+window.setGlobalLogLevelFromString = setGlobalLogLevelFromString;
+// @ts-expect-error creating a new property for window object
+window.getGlobalLevel = getGlobalLogLevel;
+
+function localStorageProvider(): Map<unknown, unknown> {
+ const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]"));
+
+ window.addEventListener("beforeunload", () => {
+ const appCache = JSON.stringify(Array.from(map.entries()));
+ localStorage.setItem("app-cache", appCache);
+ });
+ return map;
+}
+
+function getInitialBackendBaseURL(
+ backendFromSettings: string | undefined,
+): string {
+ const overrideUrl =
+ typeof localStorage !== "undefined"
+ ? localStorage.getItem("corebank-api-base-url")
+ : undefined;
+ let result: string;
+
+ if (!overrideUrl) {
+ // normal path
+ if (!backendFromSettings) {
+ console.error(
+ "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'",
+ );
+ result = window.origin;
+ } else {
+ result = backendFromSettings;
+ }
+ } else {
+ // testing/development path
+ result = overrideUrl;
+ }
+ try {
+ return canonicalizeBaseUrl(result);
+ } catch (e) {
+ // fall back
+ return canonicalizeBaseUrl(window.origin);
+ }
+}
+
+const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = {
+ async notifySuccess(op) {
+ switch (op) {
+ case TalerCoreBankCacheEviction.DELETE_ACCOUNT: {
+ await Promise.all([
+ revalidatePublicAccounts(),
+ revalidateBusinessAccounts(),
+ ]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.CREATE_ACCOUNT: {
+ // admin balance change on new account
+ await Promise.all([
+ revalidateAccountDetails(),
+ revalidateTransactions(),
+ revalidatePublicAccounts(),
+ revalidateBusinessAccounts(),
+ ]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.UPDATE_ACCOUNT: {
+ await Promise.all([revalidateAccountDetails()]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.CREATE_TRANSACTION: {
+ await Promise.all([
+ revalidateAccountDetails(),
+ revalidateTransactions(),
+ ]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.CONFIRM_WITHDRAWAL: {
+ await Promise.all([
+ revalidateAccountDetails(),
+ revalidateTransactions(),
+ ]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.CREATE_CASHOUT: {
+ await Promise.all([
+ revalidateAccountDetails(),
+ revalidateCashouts(),
+ revalidateTransactions(),
+ ]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.UPDATE_PASSWORD:
+ case TalerCoreBankCacheEviction.ABORT_WITHDRAWAL:
+ case TalerCoreBankCacheEviction.CREATE_WITHDRAWAL:
+ return;
+ default:
+ assertUnreachable(op);
+ }
+ },
+};
+
+const evictConversionSwrCache: CacheEvictor<TalerBankConversionCacheEviction> =
+ {
+ async notifySuccess(op) {
+ switch (op) {
+ case TalerBankConversionCacheEviction.UPDATE_RATE: {
+ await revalidateConversionInfo();
+ return;
+ }
+ default:
+ assertUnreachable(op);
+ }
+ },
+ };
diff --git a/packages/bank-ui/src/assets/empty.png b/packages/bank-ui/src/assets/empty.png
new file mode 100644
index 000000000..5120d3138
--- /dev/null
+++ b/packages/bank-ui/src/assets/empty.png
Binary files differ
diff --git a/packages/bank-ui/src/assets/example/id1.jpg b/packages/bank-ui/src/assets/example/id1.jpg
new file mode 100644
index 000000000..5d022a379
--- /dev/null
+++ b/packages/bank-ui/src/assets/example/id1.jpg
Binary files differ
diff --git a/packages/bank-ui/src/assets/favicon.ico b/packages/bank-ui/src/assets/favicon.ico
new file mode 100644
index 000000000..07419145b
--- /dev/null
+++ b/packages/bank-ui/src/assets/favicon.ico
Binary files differ
diff --git a/packages/bank-ui/src/assets/icons/android-chrome-192x192.png b/packages/bank-ui/src/assets/icons/android-chrome-192x192.png
new file mode 100644
index 000000000..93ebe2e2c
--- /dev/null
+++ b/packages/bank-ui/src/assets/icons/android-chrome-192x192.png
Binary files differ
diff --git a/packages/bank-ui/src/assets/icons/android-chrome-512x512.png b/packages/bank-ui/src/assets/icons/android-chrome-512x512.png
new file mode 100644
index 000000000..52d1623ea
--- /dev/null
+++ b/packages/bank-ui/src/assets/icons/android-chrome-512x512.png
Binary files differ
diff --git a/packages/bank-ui/src/assets/icons/apple-touch-icon.png b/packages/bank-ui/src/assets/icons/apple-touch-icon.png
new file mode 100644
index 000000000..254e4bb4d
--- /dev/null
+++ b/packages/bank-ui/src/assets/icons/apple-touch-icon.png
Binary files differ
diff --git a/packages/bank-ui/src/assets/icons/favicon-16x16.png b/packages/bank-ui/src/assets/icons/favicon-16x16.png
new file mode 100644
index 000000000..e81177dcb
--- /dev/null
+++ b/packages/bank-ui/src/assets/icons/favicon-16x16.png
Binary files differ
diff --git a/packages/bank-ui/src/assets/icons/favicon-32x32.png b/packages/bank-ui/src/assets/icons/favicon-32x32.png
new file mode 100644
index 000000000..40e9b5b47
--- /dev/null
+++ b/packages/bank-ui/src/assets/icons/favicon-32x32.png
Binary files differ
diff --git a/packages/bank-ui/src/assets/icons/languageicon.svg b/packages/bank-ui/src/assets/icons/languageicon.svg
new file mode 100644
index 000000000..22d58da65
--- /dev/null
+++ b/packages/bank-ui/src/assets/icons/languageicon.svg
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 2411.2 2794" style="enable-background:new 0 0 2411.2 2794;" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#FFFFFF;}
+ .st1{fill-rule:evenodd;clip-rule:evenodd;}
+ .st2{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
+</style>
+<g id="Layer_2">
+</g>
+<g id="Layer_x5F_1_x5F_1">
+ <g>
+ <polygon points="1204.6,359.2 271.8,30 271.8,2060.1 1204.6,1758.3 "/>
+ <polygon class="st0" points="1182.2,358.1 2150.6,29 2150.6,2059 1182.2,1757.3 "/>
+ <polygon class="st0" points="30,2415.4 1182.2,2031.4 1182.2,357.9 30,742 "/>
+ <polygon points="1707.2,2440.7 1870.5,2709.4 1956.6,2459.8 "/>
+ <g>
+ <path d="M421.7,934.8c-6.1-6,8,49.1,27.6,68.9c34.8,35.1,61.9,39.6,76.4,40.2c32,1.3,71.5-8,94.9-17.8
+ c22.7-9.7,62.4-30,77.5-59.6c3.2-6.3,11.9-17,6.4-43.2c-4.2-20.2-17-27.3-32.7-26.2c-15.7,1.1-63.2,13.7-86.1,20.8
+ c-23,7-70.3,21.4-90.9,25.8C474.3,948.2,429,941.7,421.7,934.8z"/>
+ <path d="M1003.1,1593.7c-9.1-3.3-196.9-81.1-223.6-93.9c-21.8-10.5-75.2-33.1-100.4-43.3c70.8-109.2,115.5-191.6,121.5-204.1
+ c11-23,86-169.6,87.7-178.7c1.7-9.1,3.8-42.9,2.2-51c-1.7-8.2-29.1,7.6-66.4,20.2c-37.4,12.6-108.4,58.8-135.8,64.6
+ c-27.5,5.7-115.5,39.1-160.5,54c-45,14.9-130.2,40.9-165.2,50.4c-35.1,9.5-65.7,10.2-85.3,16.2c0,0,2.6,27.5,7.8,35.7
+ c5.2,8.2,23.7,28.4,45.3,34.1c21.6,5.7,57.3,3.4,73.6-0.3c16.3-3.8,44.4-17.5,48.2-23.6c3.8-6.1-2-24.9,4.5-30.6
+ c6.5-5.6,92.2-25.7,124.6-35.4c32.4-10,156.3-52.6,173.1-50.5c-5.3,17.7-105,215.1-137.1,274c-32.1,58.9-218.6,318-258.3,363.6
+ c-30.1,34.7-103.2,123.5-128.5,143.6c6.4,1.8,51.6-2.1,59.9-7.2c51.3-31.6,136.9-138.1,164.4-170.5
+ c81.9-96,153.8-196.8,210.8-283.4h0.1c11.1,4.6,100.9,77.8,124.4,94c23.4,16.2,115.9,67.8,136,76.4c20,8.7,97.1,44.2,100.3,32.2
+ C1029.4,1668,1012.2,1597.1,1003.1,1593.7z"/>
+ </g>
+ <path class="st1" d="M569,2572c18,11,35,20,54,29c38,19,81,39,122,54c56,21,112,38,168,51c31,7,65,13,98,18c3,0,92,11,110,11h90
+ c35-3,68-5,103-10c28-4,59-9,89-16c22-5,45-10,67-17c21-6,45-14,68-22c15-5,31-12,47-18c13-6,29-13,44-19c18-8,39-19,59-29
+ c16-8,34-18,51-28c13-7,43-30,59-30c18,0,30,16,30,30c0,29-39,38-57,51c-19,13-42,23-62,34c-40,21-81,39-120,54
+ c-51,19-107,37-157,49c-19,4-38,9-57,12c-10,2-114,18-143,18h-132c-35-3-72-7-107-12c-31-5-64-11-95-18c-24-5-50-12-73-19
+ c-40-11-79-25-117-40c-69-26-141-60-209-105c-12-8-13-16-13-25c0-15,11-29,29-29C531,2546,563,2569,569,2572z"/>
+ <path class="st1" d="M1151,2009L61,2372V764l1090-363V2009z M1212,354v1680c-1,5-3,10-7,15c-2,3-6,7-9,8c-25,10-1151,388-1166,388
+ c-12,0-23-8-29-21c0-1-1-2-1-4V739c2-5,3-12,7-16c8-11,22-13,31-16c17-6,1126-378,1142-378C1190,329,1212,336,1212,354z"/>
+ <path class="st1" d="M2120,2017l-907-282V380l907-308V2017z M2181,32v2023c-1,23-17,33-32,33c-13,0-107-32-123-37
+ c-126-39-253-78-378-117c-28-9-57-18-84-27c-24-7-50-15-74-23c-107-33-216-66-323-102c-4-1-14-15-14-18V351c2-5,4-11,9-15
+ c8-9,351-123,486-168c36-13,487-168,501-168C2167,0,2181,13,2181,32z"/>
+ <polygon points="2411.2,2440.7 1199.5,2054.5 1204.6,373.2 2411.2,757.2 "/>
+ <g>
+ <path class="st2" d="M1800.3,1124.6L1681.4,1412l218.6,66.3L1800.3,1124.6z M1729,853.2l156.1,47.3l284.4,1025l-160.3-48.7
+ l-57.6-210.4L1620.2,1566l-71.3,171.4l-160.4-48.7L1729,853.2z"/>
+ </g>
+ </g>
+</g>
+</svg>
diff --git a/packages/bank-ui/src/assets/icons/mstile-150x150.png b/packages/bank-ui/src/assets/icons/mstile-150x150.png
new file mode 100644
index 000000000..9cfb889be
--- /dev/null
+++ b/packages/bank-ui/src/assets/icons/mstile-150x150.png
Binary files differ
diff --git a/packages/bank-ui/src/assets/logo-2021.svg b/packages/bank-ui/src/assets/logo-2021.svg
new file mode 100644
index 000000000..8c5ff3e5b
--- /dev/null
+++ b/packages/bank-ui/src/assets/logo-2021.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">
+ <g fill="#0042b3" fill-rule="evenodd" stroke-width=".3">
+ <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />
+ <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />
+ <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />
+ </g>
+ <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />
+</svg> \ No newline at end of file
diff --git a/packages/bank-ui/src/assets/logo-white.svg b/packages/bank-ui/src/assets/logo-white.svg
new file mode 100644
index 000000000..cb1f023c5
--- /dev/null
+++ b/packages/bank-ui/src/assets/logo-white.svg
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ width="670"
+ height="300"
+ viewBox="0 0 201 90"
+ version="1.1"
+ id="svg8">
+ <g
+ id="logo">
+ <g
+ id="circles"
+ style="fill:#FFF;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943">
+ <path
+ d="m 86.662153,1.1211936 c 15.589697,0 29.129227,9.4011664 35.961027,23.2018054 h -5.81736 C 110.4866,13.623304 99.349002,6.5180852 86.662153,6.5180852 c -19.690571,0 -35.652876,17.1120008 -35.652876,38.2205688 0,10.331797 3.825597,19.704678 10.03957,26.582945 -1.342357,1.120912 -2.771532,2.127905 -4.275488,3.006754 C 50.071485,66.553412 45.974857,56.15992 45.974857,44.738654 c 0,-24.089211 18.216325,-43.6174604 40.687296,-43.6174604 z M 122.51416,65.375898 c -6.86645,13.680134 -20.34561,22.980218 -35.852007,22.980218 -1.052702,0 -2.096093,-0.04291 -3.128683,-0.127026 3.052192,-1.561167 5.913582,-3.480387 8.538307,-5.707305 10.320963,-1.684389 19.185983,-8.113638 24.601813,-17.145887 z"
+ id="path2350" />
+ <path
+ d="m 64.212372,1.1211936 c 1.052607,0 2.095998,0.042919 3.128684,0.1270583 C 64.288864,2.8094199 61.427378,4.728606 58.802653,6.9555572 41.679542,9.7498571 28.559494,25.601563 28.559494,44.738654 c 0,14.264563 7.29059,26.702023 18.093843,33.268925 -1.593656,0.26719 -3.226966,0.406948 -4.890748,0.406948 -1.239545,0 -2.46151,-0.07952 -3.663522,-0.229364 C 29.191129,70.184015 23.525076,58.171633 23.525076,44.738654 23.525076,20.649443 41.7414,1.1211936 64.212372,1.1211936 Z M 69.62209,82.521785 C 79.943207,80.837396 88.808164,74.407841 94.224059,65.375422 h 5.840511 c -6.866354,13.680305 -20.345548,22.980694 -35.852198,22.980694 -1.052703,0 -2.095999,-0.04291 -3.128684,-0.127026 3.052002,-1.561371 5.913836,-3.480218 8.538402,-5.707305 z M 94.355885,24.322999 c -3.13939,-5.314721 -7.467551,-9.74275 -12.584511,-12.853269 1.593656,-0.26719 3.226904,-0.406948 4.890779,-0.406948 1.239451,0 2.461512,0.07952 3.663524,0.229364 4.016018,3.607242 7.373195,8.030111 9.849053,13.030853 z"
+ id="path2352" />
+ <path
+ d="m 41.762589,1.1211936 c 1.064296,0 2.118804,0.044379 3.162607,0.1302161 -3.046523,1.558961 -5.903162,3.4745139 -8.52358,5.6968133 C 19.254624,9.7205882 6.1097128,25.583465 6.1097128,44.738654 c 0,21.108568 15.9624012,38.22057 35.6528762,38.22057 12.599746,0 23.672446,-7.007056 30.013748,-17.583802 h 5.838515 C 70.748498,79.055727 57.26924,88.356116 41.762589,88.356116 c -22.470907,0 -40.6871998,-19.52825 -40.6871998,-43.617462 0,-24.089211 18.2162928,-43.6174604 40.6871998,-43.6174604 z M 71.905375,24.322999 c -1.31192,-2.220567 -2.830984,-4.287049 -4.528877,-6.166508 1.342452,-1.120945 2.771374,-2.128381 4.275139,-3.00723 2.372984,2.753011 4.418875,5.834636 6.072489,9.173738 z"
+ id="path2354" />
+ </g>
+ <g
+ id="letters"
+ style="fill:#FFF">
+ <path
+ d="m 76.135411,34.409066 h 9.161042 V 29.36588 H 61.857537 v 5.043186 h 9.161137 v 25.92317 h 5.116737 z"
+ id="path2346" />
+ <path
+ d="m 92.647571,52.856334 h 13.659009 l 2.93009,7.476072 h 5.36461 L 101.89122,29.144903 H 97.187186 L 84.477089,60.332406 h 5.199533 z m 11.802109,-4.822276 h -9.944771 l 4.951718,-12.386462 z"
+ id="path2362" />
+ <path
+ d="m 123.80641,29.366084 h -4.58038 v 30.966322 h 20.54728 v -4.910253 c -5.32227,0 -10.64463,0 -15.9669,0 z"
+ id="path2356" />
+ <path
+ d="m 166.4722,29.366084 h -21.37564 v 30.966322 h 21.58203 v -4.910253 h -16.54771 v -8.27275 h 14.48439 V 42.23925 h -14.48439 v -7.962811 h 16.34132 z"
+ id="path2360" />
+ <path
+ d="m 191.19035,39.474593 c 0,1.59947 -0.53646,2.87535 -1.61628,3.818883 -1.07281,0.95124 -2.52409,1.422837 -4.34678,1.422837 h -7.44851 V 34.276439 h 7.4073 c 1.9051,0 3.38376,0.435027 4.42939,1.312178 1.05226,0.870258 1.57488,2.167734 1.57488,3.885976 z m 6.06602,20.857813 -7.79911,-11.723191 c 1.01771,-0.294794 1.94631,-0.714813 2.78553,-1.260566 0.83885,-0.545619 1.56122,-1.209263 2.16629,-1.990627 0.60541,-0.781738 1.07981,-1.681096 1.42369,-2.698345 0.34378,-1.017553 0.51561,-2.175238 0.51561,-3.472883 0,-1.50409 -0.24743,-2.867948 -0.74267,-4.092048 -0.49515,-1.223794 -1.20344,-2.256186 -2.12499,-3.096734 -0.92173,-0.840446 -2.04957,-1.489252 -3.38375,-1.946452 -1.33447,-0.457267 -2.82692,-0.685476 -4.4774,-0.685476 h -12.87512 v 30.966322 h 5.03433 V 49.538522 h 6.37569 l 7.11829,10.793884 z"
+ id="path2358" />
+ </g>
+ </g>
+</svg>
diff --git a/packages/bank-ui/src/assets/logo.jpeg b/packages/bank-ui/src/assets/logo.jpeg
new file mode 100644
index 000000000..489832f7c
--- /dev/null
+++ b/packages/bank-ui/src/assets/logo.jpeg
Binary files differ
diff --git a/packages/bank-ui/src/components/Cashouts/index.ts b/packages/bank-ui/src/components/Cashouts/index.ts
new file mode 100644
index 000000000..99a946865
--- /dev/null
+++ b/packages/bank-ui/src/components/Cashouts/index.ts
@@ -0,0 +1,85 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Loading, RouteDefinition, utils } from "@gnu-taler/web-util/browser";
+import {
+ AbsoluteTime,
+ AmountJson,
+ TalerCoreBankErrorsByMethod,
+ TalerCorebankApi,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import { ErrorLoadingWithDebug } from "../ErrorLoadingWithDebug.js";
+import { useComponentState } from "./state.js";
+import { FailedView, ReadyView } from "./views.js";
+
+export interface Props {
+ account: string;
+ routeCashoutDetails: RouteDefinition<{ cid: string }>;
+}
+
+export type State =
+ | State.Loading
+ | State.Failed
+ | State.LoadingUriError
+ | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "loading-error";
+ error: TalerError;
+ }
+
+ export interface Failed {
+ status: "failed";
+ error: TalerCoreBankErrorsByMethod<"getAccountCashouts">;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ cashouts: (TalerCorebankApi.CashoutStatusResponse & { id: number })[];
+ routeCashoutDetails: RouteDefinition<{ cid: string }>;
+ }
+}
+
+export interface Transaction {
+ negative: boolean;
+ counterpart: string;
+ when: AbsoluteTime;
+ amount: AmountJson | undefined;
+ subject: string;
+}
+
+const viewMapping: utils.StateViewMap<State> = {
+ loading: Loading,
+ "loading-error": ErrorLoadingWithDebug,
+ failed: FailedView,
+ ready: ReadyView,
+};
+
+export const Cashouts = utils.compose(
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/bank-ui/src/components/Cashouts/state.ts b/packages/bank-ui/src/components/Cashouts/state.ts
new file mode 100644
index 000000000..8616faa1b
--- /dev/null
+++ b/packages/bank-ui/src/components/Cashouts/state.ts
@@ -0,0 +1,51 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TalerError } from "@gnu-taler/taler-util";
+import { useCashouts } from "../../hooks/regional.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ account,
+ routeCashoutDetails,
+}: Props): State {
+ const result = useCashouts(account);
+ if (!result) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (result instanceof TalerError) {
+ return {
+ status: "loading-error",
+ error: result,
+ };
+ }
+ if (result.type === "fail") {
+ return {
+ status: "failed",
+ error: result,
+ };
+ }
+
+ return {
+ status: "ready",
+ error: undefined,
+ cashouts: result.body.cashouts,
+ routeCashoutDetails,
+ };
+}
diff --git a/packages/bank-ui/src/components/Cashouts/stories.tsx b/packages/bank-ui/src/components/Cashouts/stories.tsx
new file mode 100644
index 000000000..37ab64108
--- /dev/null
+++ b/packages/bank-ui/src/components/Cashouts/stories.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "transaction list",
+};
+
+export const Ready = tests.createExample(ReadyView, {});
diff --git a/packages/bank-ui/src/components/Cashouts/test.ts b/packages/bank-ui/src/components/Cashouts/test.ts
new file mode 100644
index 000000000..4ed0d7c11
--- /dev/null
+++ b/packages/bank-ui/src/components/Cashouts/test.ts
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { SwrMockEnvironment } from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { Props } from "./index.js";
+import { useComponentState } from "./state.js";
+import { buildNullRoutDefinition } from "@gnu-taler/web-util/browser";
+
+describe("Cashout states", () => {
+ it.skip("should query backend and render transactions", async () => {
+ const env = new SwrMockEnvironment();
+
+ const props: Props = {
+ account: "123",
+ routeCashoutDetails: buildNullRoutDefinition(),
+ };
+
+ // env.addRequestExpectation(CASHOUT_API_EXAMPLE.LIST_FIRST_PAGE, {
+ // response: {
+ // cashouts: [],
+ // },
+ // });
+
+ // env.addRequestExpectation(CASHOUT_API_EXAMPLE.MULTI_GET_EMPTY_FIRST_PAGE, {
+ // response: [],
+ // });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ ({ status, error }) => {
+ expect(status).equals("ready");
+ expect(error).undefined;
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/bank-ui/src/components/Cashouts/views.tsx b/packages/bank-ui/src/components/Cashouts/views.tsx
new file mode 100644
index 000000000..22b8d8c1b
--- /dev/null
+++ b/packages/bank-ui/src/components/Cashouts/views.tsx
@@ -0,0 +1,218 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useConversionInfo } from "../../hooks/regional.js";
+import { RenderAmount } from "../../pages/PaytoWireTransferForm.js";
+import { ErrorLoadingWithDebug } from "../ErrorLoadingWithDebug.js";
+import { Time } from "../Time.js";
+import { State } from "./index.js";
+
+export function FailedView({ error }: State.Failed) {
+ const { i18n } = useTranslationContext();
+ switch (error.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(error.case);
+ }
+}
+
+export function ReadyView({
+ cashouts,
+ routeCashoutDetails,
+}: State.Ready): VNode {
+ const { i18n, dateLocale } = useTranslationContext();
+ const resp = useConversionInfo();
+ if (!resp) {
+ return <Loading />;
+ }
+ if (resp instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={resp} />;
+ }
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(resp.case);
+ }
+ }
+
+ if (!cashouts.length) return <div />;
+ const txByDate = cashouts.reduce(
+ (prev, cur) => {
+ const d =
+ cur.creation_time.t_s === "never"
+ ? ""
+ : format(cur.creation_time.t_s * 1000, "dd/MM/yyyy", {
+ locale: dateLocale,
+ });
+ if (!prev[d]) {
+ prev[d] = [];
+ }
+ prev[d].push(cur);
+ return prev;
+ },
+ {} as Record<string, typeof cashouts>,
+ );
+ return (
+ <div class="px-4 mt-4">
+ <div class="sm:flex sm:items-center">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Latest cashouts</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+ <div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white">
+ <table class="min-w-full divide-y divide-gray-300">
+ <thead>
+ <tr>
+ <th
+ scope="col"
+ class=" pl-2 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Created`}</th>
+ <th
+ scope="col"
+ class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Total debit`}</th>
+ <th
+ scope="col"
+ class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Total credit`}</th>
+ <th
+ scope="col"
+ class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Subject`}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {Object.entries(txByDate).map(([date, txs], idx) => {
+ return (
+ <Fragment key={idx}>
+ <tr class="border-t border-gray-200">
+ <th
+ colSpan={6}
+ scope="colgroup"
+ class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3"
+ >
+ {date}
+ </th>
+ </tr>
+ {txs.map((item) => {
+ return (
+ <a
+ name="cashout details"
+ key={idx}
+ class="table-row border-b border-gray-200 hover:bg-gray-200 last:border-none"
+ // class="table-row"
+ href={routeCashoutDetails.url({
+ cid: String(item.id),
+ })}
+ >
+ <td class="relative py-2 pl-2 pr-2 text-sm ">
+ <div class="font-medium text-gray-900">
+ <Time
+ format="HH:mm:ss"
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ item.creation_time,
+ )}
+ // relative={Duration.fromSpec({ days: 1 })}
+ />
+ </div>
+ {
+ //FIXME: implement responsive view
+ }
+ {/* <dl class="font-normal sm:hidden">
+ <dt class="sr-only sm:hidden"><i18n.Translate>Amount</i18n.Translate></dt>
+ <dd class="mt-1 truncate text-gray-700">
+ {item.negative ? i18n.str`sent` : i18n.str`received`} {item.amount ? (
+ <span data-negative={item.negative ? "true" : "false"} class="data-[negative=false]:text-green-600 data-[negative=true]:text-red-600">
+ <RenderAmount value={item.amount} />
+ </span>
+ ) : (
+ <span style={{ color: "grey" }}>&lt;{i18n.str`invalid value`}&gt;</span>
+ )}</dd>
+
+ <dt class="sr-only sm:hidden"><i18n.Translate>Counterpart</i18n.Translate></dt>
+ <dd class="mt-1 truncate text-gray-500 sm:hidden">
+ {item.negative ? i18n.str`to` : i18n.str`from`} {item.counterpart}
+ </dd>
+ <dd class="mt-1 text-gray-500 sm:hidden" >
+ <pre class="break-words w-56 whitespace-break-spaces p-2 rounded-md mx-auto my-2 bg-gray-100">
+ {item.subject}
+ </pre>
+ </dd>
+ </dl> */}
+ </td>
+ <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-red-600 cursor-pointer">
+ <RenderAmount
+ value={Amounts.parseOrThrow(item.amount_debit)}
+ spec={resp.body.regional_currency_specification}
+ />
+ </td>
+ <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-green-600 cursor-pointer">
+ <RenderAmount
+ value={Amounts.parseOrThrow(item.amount_credit)}
+ spec={resp.body.fiat_currency_specification}
+ />
+ </td>
+
+ <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md">
+ {item.subject}
+ </td>
+ </a>
+ );
+ })}
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/components/EmptyComponentExample/index.ts b/packages/bank-ui/src/components/EmptyComponentExample/index.ts
new file mode 100644
index 000000000..da84f9921
--- /dev/null
+++ b/packages/bank-ui/src/components/EmptyComponentExample/index.ts
@@ -0,0 +1,56 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { HookError, Loading, utils } from "@gnu-taler/web-util/browser";
+import { useComponentState } from "./state.js";
+import { LoadingUriView, ReadyView } from "./views.js";
+
+export interface Props {
+ p: string;
+}
+
+export type State = State.Loading | State.LoadingUriError | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "loading-error";
+ error: HookError;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ }
+}
+
+const viewMapping: utils.StateViewMap<State> = {
+ loading: Loading,
+ "loading-error": LoadingUriView,
+ ready: ReadyView,
+};
+
+export const ComponentName = utils.compose(
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/bank-ui/src/components/EmptyComponentExample/state.ts b/packages/bank-ui/src/components/EmptyComponentExample/state.ts
new file mode 100644
index 000000000..057664983
--- /dev/null
+++ b/packages/bank-ui/src/components/EmptyComponentExample/state.ts
@@ -0,0 +1,25 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+// import { wxApi } from "../../wxApi.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({ p: _p }: Props): State {
+ return {
+ status: "ready",
+ error: undefined,
+ };
+}
diff --git a/packages/bank-ui/src/components/EmptyComponentExample/stories.tsx b/packages/bank-ui/src/components/EmptyComponentExample/stories.tsx
new file mode 100644
index 000000000..160acdf79
--- /dev/null
+++ b/packages/bank-ui/src/components/EmptyComponentExample/stories.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "example",
+};
+
+export const Ready = tests.createExample(ReadyView, {});
diff --git a/packages/bank-ui/src/components/EmptyComponentExample/test.ts b/packages/bank-ui/src/components/EmptyComponentExample/test.ts
new file mode 100644
index 000000000..629948d91
--- /dev/null
+++ b/packages/bank-ui/src/components/EmptyComponentExample/test.ts
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+
+describe("test description", () => {
+ it("should assert", () => {
+ expect([]).deep.equals([]);
+ });
+});
diff --git a/packages/bank-ui/src/components/EmptyComponentExample/views.tsx b/packages/bank-ui/src/components/EmptyComponentExample/views.tsx
new file mode 100644
index 000000000..457933a5f
--- /dev/null
+++ b/packages/bank-ui/src/components/EmptyComponentExample/views.tsx
@@ -0,0 +1,25 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+
+export function LoadingUriView(): VNode {
+ return <div></div>;
+}
+
+export function ReadyView(): VNode {
+ return <div />;
+}
diff --git a/packages/bank-ui/src/components/ErrorLoadingWithDebug.tsx b/packages/bank-ui/src/components/ErrorLoadingWithDebug.tsx
new file mode 100644
index 000000000..8679af050
--- /dev/null
+++ b/packages/bank-ui/src/components/ErrorLoadingWithDebug.tsx
@@ -0,0 +1,24 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { TalerError } from "@gnu-taler/taler-util";
+import { ErrorLoading } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { usePreferences } from "../hooks/preferences.js";
+
+export function ErrorLoadingWithDebug({ error }: { error: TalerError }): VNode {
+ const [pref] = usePreferences();
+ return <ErrorLoading error={error} showDetail={pref.showDebugInfo} />;
+}
diff --git a/packages/bank-ui/src/components/QR.tsx b/packages/bank-ui/src/components/QR.tsx
new file mode 100644
index 000000000..b039bbd1e
--- /dev/null
+++ b/packages/bank-ui/src/components/QR.tsx
@@ -0,0 +1,51 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+import { useEffect, useRef } from "preact/hooks";
+import qrcode from "qrcode-generator";
+
+export function QR({ text }: { text: string }): VNode {
+ const divRef = useRef<HTMLDivElement>(null);
+ useEffect(() => {
+ const qr = qrcode(0, "L");
+ qr.addData(text);
+ qr.make();
+ if (divRef.current)
+ divRef.current.innerHTML = qr.createSvgTag({
+ scalable: true,
+ });
+ });
+
+ return (
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "left",
+ }}
+ >
+ <div
+ style={{
+ width: "100%",
+ marginRight: "auto",
+ marginLeft: "auto",
+ }}
+ ref={divRef}
+ />
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/components/Time.tsx b/packages/bank-ui/src/components/Time.tsx
new file mode 100644
index 000000000..5c8afe212
--- /dev/null
+++ b/packages/bank-ui/src/components/Time.tsx
@@ -0,0 +1,80 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AbsoluteTime, Duration } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import {
+ formatISO,
+ format,
+ formatDuration,
+ intervalToDuration,
+} from "date-fns";
+import { Fragment, h, VNode } from "preact";
+
+/**
+ *
+ * @param timestamp time to be formatted
+ * @param relative duration threshold, if the difference is lower
+ * the timestamp will be formatted as relative time from "now"
+ *
+ * @returns
+ */
+export function Time({
+ timestamp,
+ relative,
+ format: formatString,
+}: {
+ timestamp: AbsoluteTime | undefined;
+ relative?: Duration;
+ format: string;
+}): VNode {
+ const { i18n, dateLocale } = useTranslationContext();
+ if (!timestamp) return <Fragment />;
+
+ if (timestamp.t_ms === "never") {
+ return <time>{i18n.str`never`}</time>;
+ }
+
+ const now = AbsoluteTime.now();
+ const diff = AbsoluteTime.difference(now, timestamp);
+ if (relative && now.t_ms !== "never" && Duration.cmp(diff, relative) === -1) {
+ const d = intervalToDuration({
+ start: now.t_ms,
+ end: timestamp.t_ms,
+ });
+ d.seconds = 0;
+ const duration = formatDuration(d, { locale: dateLocale });
+ const isFuture = AbsoluteTime.cmp(now, timestamp) < 0;
+ if (isFuture) {
+ return (
+ <time dateTime={formatISO(timestamp.t_ms)}>
+ <i18n.Translate>in {duration}</i18n.Translate>
+ </time>
+ );
+ } else {
+ return (
+ <time dateTime={formatISO(timestamp.t_ms)}>
+ <i18n.Translate>{duration} ago</i18n.Translate>
+ </time>
+ );
+ }
+ }
+ return (
+ <time dateTime={formatISO(timestamp.t_ms)}>
+ {format(timestamp.t_ms, formatString, { locale: dateLocale })}
+ </time>
+ );
+}
diff --git a/packages/bank-ui/src/components/Transactions/index.ts b/packages/bank-ui/src/components/Transactions/index.ts
new file mode 100644
index 000000000..6fccfcd79
--- /dev/null
+++ b/packages/bank-ui/src/components/Transactions/index.ts
@@ -0,0 +1,84 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AbsoluteTime, AmountJson, TalerError } from "@gnu-taler/taler-util";
+import { Loading, utils } from "@gnu-taler/web-util/browser";
+import { ErrorLoadingWithDebug } from "../ErrorLoadingWithDebug.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export interface Props {
+ account: string;
+ routeCreateWireTransfer:
+ | RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>
+ | undefined;
+}
+
+export type State = State.Loading | State.LoadingUriError | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "loading-error";
+ error: TalerError;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ routeCreateWireTransfer:
+ | RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>
+ | undefined;
+ transactions: Transaction[];
+ onGoStart?: () => void;
+ onGoNext?: () => void;
+ }
+}
+
+export interface Transaction {
+ negative: boolean;
+ counterpart: string;
+ when: AbsoluteTime;
+ amount: AmountJson | undefined;
+ subject: string;
+}
+
+const viewMapping: utils.StateViewMap<State> = {
+ loading: Loading,
+ "loading-error": ErrorLoadingWithDebug,
+ ready: ReadyView,
+};
+
+export const Transactions = utils.compose(
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/bank-ui/src/components/Transactions/state.ts b/packages/bank-ui/src/components/Transactions/state.ts
new file mode 100644
index 000000000..ce6338e57
--- /dev/null
+++ b/packages/bank-ui/src/components/Transactions/state.ts
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import { useTransactions } from "../../hooks/account.js";
+import { Props, State, Transaction } from "./index.js";
+
+export function useComponentState({
+ account,
+ routeCreateWireTransfer,
+}: Props): State {
+ const result = useTransactions(account);
+ if (!result) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (result instanceof TalerError) {
+ return {
+ status: "loading-error",
+ error: result,
+ };
+ }
+ if (result.type === "fail") {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+
+ const transactions = result.body
+ .map((tx) => {
+ const negative = tx.direction === "debit";
+ const cp = parsePaytoUri(
+ negative ? tx.creditor_payto_uri : tx.debtor_payto_uri,
+ );
+ const counterpart =
+ (cp === undefined || !cp.isKnown
+ ? undefined
+ : cp.targetType === "iban"
+ ? cp.iban
+ : cp.targetType === "x-taler-bank"
+ ? cp.account
+ : cp.targetType === "bitcoin"
+ ? `${cp.address.substring(0, 6)}...`
+ : undefined) ?? "unknown";
+
+ const when = AbsoluteTime.fromProtocolTimestamp(tx.date);
+ const amount = Amounts.parse(tx.amount);
+ const subject = tx.subject;
+ return {
+ negative,
+ counterpart,
+ when,
+ amount,
+ subject,
+ };
+ })
+ .filter((x): x is Transaction => x !== undefined);
+
+ return {
+ status: "ready",
+ error: undefined,
+ routeCreateWireTransfer,
+ transactions,
+ onGoNext: result.isLastPage ? undefined : result.loadNext,
+ onGoStart: result.isFirstPage ? undefined : result.loadFirst,
+ };
+}
diff --git a/packages/bank-ui/src/components/Transactions/stories.tsx b/packages/bank-ui/src/components/Transactions/stories.tsx
new file mode 100644
index 000000000..95014574b
--- /dev/null
+++ b/packages/bank-ui/src/components/Transactions/stories.tsx
@@ -0,0 +1,44 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+import { AbsoluteTime } from "@gnu-taler/taler-util";
+
+export default {
+ title: "transaction list",
+};
+
+export const Ready = tests.createExample(ReadyView, {
+ transactions: [
+ {
+ amount: {
+ currency: "USD",
+ fraction: 0,
+ value: 1,
+ },
+ counterpart: "ASD",
+ negative: false,
+ subject: "Some",
+ when: AbsoluteTime.now(),
+ },
+ ],
+});
diff --git a/packages/bank-ui/src/components/Transactions/test.ts b/packages/bank-ui/src/components/Transactions/test.ts
new file mode 100644
index 000000000..d9442c742
--- /dev/null
+++ b/packages/bank-ui/src/components/Transactions/test.ts
@@ -0,0 +1,202 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerErrorCode } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { SwrMockEnvironment } from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { Props } from "./index.js";
+import { useComponentState } from "./state.js";
+
+describe("Transaction states", () => {
+ it.skip("should query backend and render transactions", async () => {
+ const env = new SwrMockEnvironment();
+
+ const props: Props = {
+ account: "myAccount",
+ routeCreateWireTransfer: undefined,
+ };
+
+ // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_FIRST_PAGE, {
+ // response: {
+ // data: {
+ // transactions: [
+ // {
+ // creditorIban: "DE159593",
+ // creditorBic: "SANDBOXX",
+ // creditorName: "exchange company",
+ // debtorIban: "DE118695",
+ // debtorBic: "SANDBOXX",
+ // debtorName: "Name unknown",
+ // amount: "1",
+ // currency: "KUDOS",
+ // subject:
+ // "Taler Withdrawal N588V8XE9TR49HKAXFQ20P0EQ0EYW2AC9NNANV8ZP5P59N6N0410",
+ // date: "2022-12-12Z",
+ // uid: "8PPFR9EM",
+ // direction: "DBIT",
+ // pmtInfId: null,
+ // msgId: null,
+ // },
+ // {
+ // creditorIban: "DE159593",
+ // creditorBic: "SANDBOXX",
+ // creditorName: "exchange company",
+ // debtorIban: "DE118695",
+ // debtorBic: "SANDBOXX",
+ // debtorName: "Name unknown",
+ // amount: "5.00",
+ // currency: "KUDOS",
+ // subject: "HNEWWT679TQC5P1BVXJS48FX9NW18FWM6PTK2N80Z8GVT0ACGNK0",
+ // date: "2022-12-07Z",
+ // uid: "7FZJC3RJ",
+ // direction: "DBIT",
+ // pmtInfId: null,
+ // msgId: null,
+ // },
+ // {
+ // creditorIban: "DE118695",
+ // creditorBic: "SANDBOXX",
+ // creditorName: "Name unknown",
+ // debtorIban: "DE579516",
+ // debtorBic: "SANDBOXX",
+ // debtorName: "The Bank",
+ // amount: "100",
+ // currency: "KUDOS",
+ // subject: "Sign-up bonus",
+ // date: "2022-12-07Z",
+ // uid: "I31A06J8",
+ // direction: "CRDT",
+ // pmtInfId: null,
+ // msgId: null,
+ // },
+ // ],
+ // },
+ // },
+ // });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ ({ status, error }) => {
+ expect(status).equals("ready");
+ expect(error).undefined;
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ // it("should show error message on not found", async () => {
+ // const env = new SwrMockEnvironment();
+
+ // const props: Props = {
+ // account: "myAccount",
+ // };
+
+ // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_NOT_FOUND, {
+ // response: {
+ // error: {
+ // description: "Transaction page 0 could not be retrieved.",
+ // },
+ // },
+ // });
+
+ // const hookBehavior = await tests.hookBehaveLikeThis(
+ // useComponentState,
+ // props,
+ // [
+ // ({ status, error }) => {
+ // expect(status).equals("loading");
+ // expect(error).undefined;
+ // },
+ // ({ status, error }) => {
+ // expect(status).equals("loading-error");
+ // if (error === undefined || error.type !== ErrorType.CLIENT) {
+ // throw Error("not the expected error");
+ // }
+ // expect(error.payload).deep.equal({
+ // error: {
+ // description: "Transaction page 0 could not be retrieved.",
+ // },
+ // });
+ // },
+ // ],
+ // env.buildTestingContext(),
+ // );
+
+ // expect(hookBehavior).deep.eq({ result: "ok" });
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ // });
+
+ it.skip("should show error message on server error", async () => {
+ const env = new SwrMockEnvironment();
+
+ const props: Props = {
+ account: "myAccount",
+ routeCreateWireTransfer: undefined,
+ };
+
+ // env.addRequestExpectation(TRANSACTION_API_EXAMPLE.LIST_ERROR, {
+ // response: {
+ // error: {
+ // code: TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
+ // },
+ // },
+ // });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ ({ status, error }) => {
+ expect(status).equals("loading-error");
+ if (
+ error === undefined ||
+ !error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED)
+ ) {
+ throw Error("not the expected error");
+ }
+ expect(error.errorDetail.code).deep.equal(
+ TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
+ );
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/bank-ui/src/components/Transactions/views.tsx b/packages/bank-ui/src/components/Transactions/views.tsx
new file mode 100644
index 000000000..10d63e6af
--- /dev/null
+++ b/packages/bank-ui/src/components/Transactions/views.tsx
@@ -0,0 +1,252 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Attention,
+ useBankCoreApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { RenderAmount } from "../../pages/PaytoWireTransferForm.js";
+import { Time } from "../Time.js";
+import { State } from "./index.js";
+
+export function ReadyView({
+ transactions,
+ routeCreateWireTransfer,
+ onGoNext,
+ onGoStart,
+}: State.Ready): VNode {
+ const { i18n, dateLocale } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+
+ if (!transactions.length) {
+ return (
+ <div class="px-4 mt-4">
+ <div class="sm:flex sm:items-center">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Transactions history</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+
+ <Attention type="low" title={i18n.str`No transactions yet.`}>
+ <i18n.Translate>
+ You can start sending a wire transfer or withdrawing to your wallet.
+ </i18n.Translate>
+ </Attention>
+ </div>
+ );
+ }
+
+ const txByDate = transactions.reduce(
+ (prev, cur) => {
+ const d =
+ cur.when.t_ms === "never"
+ ? ""
+ : format(cur.when.t_ms, "dd/MM/yyyy", { locale: dateLocale });
+ if (!prev[d]) {
+ prev[d] = [];
+ }
+ prev[d].push(cur);
+ return prev;
+ },
+ {} as Record<string, typeof transactions>,
+ );
+ return (
+ <div class="px-4 mt-8">
+ <div class="sm:flex sm:items-center">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Transactions history</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+ <div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white">
+ <table class="min-w-full divide-y divide-gray-300">
+ <thead>
+ <tr>
+ <th
+ scope="col"
+ class="pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 "
+ >{i18n.str`Date`}</th>
+ <th
+ scope="col"
+ class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 "
+ >{i18n.str`Amount`}</th>
+ <th
+ scope="col"
+ class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 "
+ >{i18n.str`Counterpart`}</th>
+ <th
+ scope="col"
+ class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900 "
+ >{i18n.str`Subject`}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {Object.entries(txByDate).map(([date, txs], idx) => {
+ return (
+ <Fragment key={idx}>
+ <tr class="border-t border-gray-200">
+ <th
+ colSpan={4}
+ scope="colgroup"
+ class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3"
+ >
+ {date}
+ </th>
+ </tr>
+ {txs.map((item) => {
+ return (
+ <tr
+ key={idx}
+ class="border-b border-gray-200 last:border-none"
+ >
+ <td class="relative py-2 pl-2 pr-2 text-sm ">
+ <div class="font-medium text-gray-900">
+ <Time
+ format="HH:mm:ss"
+ timestamp={item.when}
+ // relative={Duration.fromSpec({ days: 1 })}
+ />
+ </div>
+ <dl class="font-normal sm:hidden">
+ <dt class="sr-only sm:hidden">
+ <i18n.Translate>Amount</i18n.Translate>
+ </dt>
+ <dd class="mt-1 truncate text-gray-700">
+ {item.negative
+ ? i18n.str`sent`
+ : i18n.str`received`}{" "}
+ {item.amount ? (
+ <span
+ data-negative={
+ item.negative ? "true" : "false"
+ }
+ class="data-[negative=false]:text-green-600 data-[negative=true]:text-red-600"
+ >
+ <RenderAmount
+ value={item.amount}
+ spec={config.currency_specification}
+ />
+ </span>
+ ) : (
+ <span style={{ color: "grey" }}>
+ &lt;{i18n.str`Invalid value`}&gt;
+ </span>
+ )}
+ </dd>
+
+ <dt class="sr-only sm:hidden">
+ <i18n.Translate>Counterpart</i18n.Translate>
+ </dt>
+ <dd class="mt-1 truncate text-gray-500 sm:hidden">
+ {item.negative ? i18n.str`to` : i18n.str`from`}{" "}
+ {!routeCreateWireTransfer ? (
+ item.counterpart
+ ) : (
+ <a
+ name={`transfer to ${item.counterpart}`}
+ href={routeCreateWireTransfer.url({
+ account: item.counterpart,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ {item.counterpart}
+ </a>
+ )}
+ </dd>
+ <dd class="mt-1 text-gray-500 sm:hidden">
+ <pre class="break-words w-56 whitespace-break-spaces p-2 rounded-md mx-auto my-2 bg-gray-100">
+ {item.subject}
+ </pre>
+ </dd>
+ </dl>
+ </td>
+ <td
+ data-negative={item.negative ? "true" : "false"}
+ class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 "
+ >
+ {item.amount ? (
+ <RenderAmount
+ value={item.amount}
+ negative={item.negative}
+ withColor
+ spec={config.currency_specification}
+ />
+ ) : (
+ <span style={{ color: "grey" }}>
+ &lt;{i18n.str`Invalid value`}&gt;
+ </span>
+ )}
+ </td>
+ <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500">
+ {!routeCreateWireTransfer ? (
+ item.counterpart
+ ) : (
+ <a
+ name={`wire transfer to ${item.counterpart}`}
+ href={routeCreateWireTransfer.url({
+ account: item.counterpart,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ {item.counterpart}
+ </a>
+ )}
+ </td>
+ <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md">
+ {item.subject}
+ </td>
+ </tr>
+ );
+ })}
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+
+ <nav
+ class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg"
+ aria-label="Pagination"
+ >
+ <div class="flex flex-1 justify-between sm:justify-end">
+ <button
+ name="first page"
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ disabled={!onGoStart}
+ onClick={onGoStart}
+ >
+ <i18n.Translate>First page</i18n.Translate>
+ </button>
+ <button
+ name="next page"
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ disabled={!onGoNext}
+ onClick={onGoNext}
+ >
+ <i18n.Translate>Next</i18n.Translate>
+ </button>
+ </div>
+ </nav>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/components/index.examples.ts b/packages/bank-ui/src/components/index.examples.ts
new file mode 100644
index 000000000..20e013070
--- /dev/null
+++ b/packages/bank-ui/src/components/index.examples.ts
@@ -0,0 +1,17 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export * as tx from "./Transactions/stories.js";
diff --git a/packages/bank-ui/src/context/config.ts b/packages/bank-ui/src/context/config.ts
new file mode 100644
index 000000000..342a65c4f
--- /dev/null
+++ b/packages/bank-ui/src/context/config.ts
@@ -0,0 +1,320 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ LibtoolVersion,
+ ObservableHttpClientLibrary,
+ TalerAuthenticationHttpClient,
+ TalerBankConversionCacheEviction,
+ TalerBankConversionHttpClient,
+ TalerCoreBankCacheEviction,
+ TalerCoreBankHttpClient,
+ TalerCorebankApi,
+ TalerError,
+ assertUnreachable,
+ CacheEvictor,
+ ObservabilityEvent,
+} from "@gnu-taler/taler-util";
+import {
+ BrowserFetchHttpLib,
+ ErrorLoading,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import {
+ ComponentChildren,
+ FunctionComponent,
+ VNode,
+ createContext,
+ h,
+} from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
+import {
+ revalidateAccountDetails,
+ revalidatePublicAccounts,
+ revalidateTransactions,
+} from "../hooks/account.js";
+import {
+ revalidateBusinessAccounts,
+ revalidateCashouts,
+ revalidateConversionInfo,
+} from "../hooks/regional.js";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type Type = {
+ url: URL;
+ config: TalerCorebankApi.Config;
+ bank: TalerCoreBankHttpClient;
+ conversion: TalerBankConversionHttpClient;
+ authenticator: (user: string) => TalerAuthenticationHttpClient;
+ hints: VersionHint[];
+ onBackendActivity: (fn: Listener) => Unsuscriber;
+ cancelRequest: (eventId: string) => void;
+};
+
+// FIXME: below
+// @ts-expect-error default value to undefined, should it be another thing?
+const Context = createContext<Type>(undefined);
+
+export const useBankCoreApiContext = (): Type => useContext(Context);
+
+export enum VersionHint {
+ /**
+ * when this flag is on, server is running an old version with cashout before implementing 2fa API
+ */
+ CASHOUT_BEFORE_2FA,
+}
+
+const observers = new Array<(e: ObservabilityEvent) => void>();
+type Listener = (e: ObservabilityEvent) => void;
+type Unsuscriber = () => void;
+
+const activity = Object.freeze({
+ notify: (data: ObservabilityEvent) =>
+ observers.forEach((observer) => observer(data)),
+ subscribe: (func: Listener): Unsuscriber => {
+ observers.push(func);
+ return () => {
+ observers.forEach((observer, index) => {
+ if (observer === func) {
+ observers.splice(index, 1);
+ }
+ });
+ };
+ },
+});
+
+export type ConfigResult =
+ | undefined
+ | { type: "ok"; config: TalerCorebankApi.Config; hints: VersionHint[] }
+ | { type: "incompatible"; result: TalerCorebankApi.Config; supported: string }
+ | { type: "error"; error: TalerError };
+
+export const BankCoreApiProvider = ({
+ baseUrl,
+ children,
+ frameOnError,
+}: {
+ baseUrl: string;
+ children: ComponentChildren;
+ frameOnError: FunctionComponent<{ children: ComponentChildren }>;
+}): VNode => {
+ const [checked, setChecked] = useState<ConfigResult>();
+ const { i18n } = useTranslationContext();
+
+ const { bankClient, conversionClient, authClient, cancelRequest } =
+ buildApiClient(new URL(baseUrl));
+
+ useEffect(() => {
+ bankClient
+ .getConfig()
+ .then((resp) => {
+ if (resp.type === "fail") {
+ setChecked({ type: "error", error: TalerError.fromUncheckedDetail(resp.detail) });
+ } else if (bankClient.isCompatible(resp.body.version)) {
+ setChecked({ type: "ok", config: resp.body, hints: [] });
+ } else {
+ // this API supports version 3.0.3
+ const compare = LibtoolVersion.compare("3:0:3", resp.body.version);
+ if (compare?.compatible ?? false) {
+ setChecked({
+ type: "ok",
+ config: resp.body,
+ hints: [VersionHint.CASHOUT_BEFORE_2FA],
+ });
+ } else {
+ setChecked({
+ type: "incompatible",
+ result: resp.body,
+ supported: bankClient.PROTOCOL_VERSION,
+ });
+ }
+ }
+ })
+ .catch((error: unknown) => {
+ if (error instanceof TalerError) {
+ setChecked({ type: "error", error });
+ }
+ });
+ }, []);
+
+ if (checked === undefined) {
+ return h(frameOnError, { children: h("div", {}, "loading...") });
+ }
+ if (checked.type === "error") {
+ return h(frameOnError, {
+ children: h(ErrorLoading, { error: checked.error, showDetail: true }),
+ });
+ }
+ if (checked.type === "incompatible") {
+ return h(frameOnError, {
+ children: h(
+ "div",
+ {},
+ i18n.str`The bank backend is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`,
+ ),
+ });
+ }
+ const value: Type = {
+ url: new URL(bankClient.baseUrl),
+ config: checked.config,
+ bank: bankClient,
+ onBackendActivity: activity.subscribe,
+ conversion: conversionClient,
+ authenticator: authClient,
+ cancelRequest,
+ hints: checked.hints,
+ };
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
+
+/**
+ * build http client with cache breaker due to SWR
+ * @param url
+ * @returns
+ */
+function buildApiClient(url: URL) {
+ const httpFetch = new BrowserFetchHttpLib({
+ enableThrottling: true,
+ requireTls: false,
+ });
+ const httpLib = new ObservableHttpClientLibrary(httpFetch, {
+ observe(ev) {
+ activity.notify(ev);
+ },
+ });
+
+ function cancelRequest(id: string) {
+ httpLib.cancelRequest(id);
+ }
+
+ const bankClient = new TalerCoreBankHttpClient(
+ url.href,
+ httpLib,
+ evictBankSwrCache,
+ );
+ const conversionClient = new TalerBankConversionHttpClient(
+ bankClient.getConversionInfoAPI().href,
+ httpLib,
+ evictConversionSwrCache,
+ );
+ const authClient = (user: string) =>
+ new TalerAuthenticationHttpClient(
+ bankClient.getAuthenticationAPI(user).href,
+ httpLib,
+ );
+
+ return { bankClient, conversionClient, authClient, cancelRequest };
+}
+
+export const BankCoreApiProviderTesting = ({
+ children,
+ state,
+ url,
+}: {
+ children: ComponentChildren;
+ state: TalerCorebankApi.Config;
+ url: string;
+}): VNode => {
+ const value: Type = {
+ url: new URL(url),
+ config: state,
+ // @ts-expect-error this API is not being used, not really needed
+ bank: undefined,
+ hints: [],
+ };
+
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
+
+const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = {
+ async notifySuccess(op) {
+ switch (op) {
+ case TalerCoreBankCacheEviction.DELETE_ACCOUNT: {
+ await Promise.all([
+ revalidatePublicAccounts(),
+ revalidateBusinessAccounts(),
+ ]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.CREATE_ACCOUNT: {
+ // admin balance change on new account
+ await Promise.all([
+ revalidateAccountDetails(),
+ revalidateTransactions(),
+ revalidatePublicAccounts(),
+ revalidateBusinessAccounts(),
+ ]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.UPDATE_ACCOUNT: {
+ await Promise.all([revalidateAccountDetails()]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.CREATE_TRANSACTION: {
+ await Promise.all([
+ revalidateAccountDetails(),
+ revalidateTransactions(),
+ ]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.CONFIRM_WITHDRAWAL: {
+ await Promise.all([
+ revalidateAccountDetails(),
+ revalidateTransactions(),
+ ]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.CREATE_CASHOUT: {
+ await Promise.all([
+ revalidateAccountDetails(),
+ revalidateCashouts(),
+ revalidateTransactions(),
+ ]);
+ return;
+ }
+ case TalerCoreBankCacheEviction.UPDATE_PASSWORD:
+ case TalerCoreBankCacheEviction.ABORT_WITHDRAWAL:
+ case TalerCoreBankCacheEviction.CREATE_WITHDRAWAL:
+ return;
+ default:
+ assertUnreachable(op);
+ }
+ },
+};
+
+const evictConversionSwrCache: CacheEvictor<TalerBankConversionCacheEviction> =
+ {
+ async notifySuccess(op) {
+ switch (op) {
+ case TalerBankConversionCacheEviction.UPDATE_RATE: {
+ await revalidateConversionInfo();
+ return;
+ }
+ default:
+ assertUnreachable(op);
+ }
+ },
+ };
diff --git a/packages/bank-ui/src/context/navigation.ts b/packages/bank-ui/src/context/navigation.ts
new file mode 100644
index 000000000..9552bf899
--- /dev/null
+++ b/packages/bank-ui/src/context/navigation.ts
@@ -0,0 +1,92 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { AppLocation } from "../route.js";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type Type = {
+ path: string;
+ params: Record<string, string>;
+ navigateTo: (path: AppLocation) => void;
+ // addNavigationListener: (listener: (path: string, params: Record<string, string>) => void) => (() => void);
+};
+
+// @ts-expect-error should not be used without provider
+const Context = createContext<Type>(undefined);
+
+export const useNavigationContext = (): Type => useContext(Context);
+
+function getPathAndParamsFromWindow() {
+ const path =
+ typeof window !== "undefined" ? window.location.hash.substring(1) : "/";
+ const params: Record<string, string> = {};
+ if (typeof window !== "undefined") {
+ for (const [key, value] of new URLSearchParams(window.location.search)) {
+ params[key] = value;
+ }
+ }
+ return { path, params };
+}
+
+const { path: initialPath, params: initialParams } =
+ getPathAndParamsFromWindow();
+
+// there is a possibility that if the browser does a redirection
+// (which doesn't go through navigatTo function) and that executed
+// too early (before addEventListener runs) it won't be taking
+// into account
+const PopStateEventType = "popstate";
+
+export const BrowserHashNavigationProvider = ({
+ children,
+}: {
+ children: ComponentChildren;
+}): VNode => {
+ const [{ path, params }, setState] = useState({
+ path: initialPath,
+ params: initialParams,
+ });
+ if (typeof window === "undefined") {
+ throw Error(
+ "Can't use BrowserHashNavigationProvider if there is no window object",
+ );
+ }
+ function navigateTo(path: string) {
+ const { params } = getPathAndParamsFromWindow();
+ setState({ path, params });
+ window.location.href = path;
+ }
+
+ useEffect(() => {
+ function eventListener() {
+ setState(getPathAndParamsFromWindow());
+ }
+ window.addEventListener(PopStateEventType, eventListener);
+ return () => {
+ window.removeEventListener(PopStateEventType, eventListener);
+ };
+ }, []);
+ return h(Context.Provider, {
+ value: { path, params, navigateTo },
+ children,
+ });
+};
diff --git a/packages/bank-ui/src/context/settings.ts b/packages/bank-ui/src/context/settings.ts
new file mode 100644
index 000000000..053fcbd12
--- /dev/null
+++ b/packages/bank-ui/src/context/settings.ts
@@ -0,0 +1,44 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+import { BankUiSettings } from "../settings.js";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type Type = BankUiSettings;
+
+const initial: BankUiSettings = {};
+const Context = createContext<Type>(initial);
+
+export const useSettingsContext = (): Type => useContext(Context);
+
+export const SettingsProvider = ({
+ children,
+ value,
+}: {
+ value: BankUiSettings;
+ children: ComponentChildren;
+}): VNode => {
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/bank-ui/src/context/wallet-integration.ts b/packages/bank-ui/src/context/wallet-integration.ts
new file mode 100644
index 000000000..e14988ed1
--- /dev/null
+++ b/packages/bank-ui/src/context/wallet-integration.ts
@@ -0,0 +1,83 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { stringifyTalerUri, TalerUri } from "@gnu-taler/taler-util";
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+
+/**
+ * https://docs.taler.net/design-documents/039-taler-browser-integration.html
+ *
+ * @param uri
+ */
+function createHeadMetaTag(uri: TalerUri, onNotFound?: () => void) {
+ const meta = document.createElement("meta");
+ meta.setAttribute("name", "taler-uri");
+ meta.setAttribute("content", stringifyTalerUri(uri));
+
+ document.head.appendChild(meta);
+
+ let walletFound = false;
+ window.addEventListener("beforeunload", () => {
+ walletFound = true;
+ });
+ setTimeout(() => {
+ if (!walletFound && onNotFound) {
+ onNotFound();
+ }
+ }, 10); //very short timeout
+}
+interface Type {
+ /**
+ * Tell the active wallet that an action is found
+ *
+ * @param uri
+ * @returns
+ */
+ publishTalerAction: (uri: TalerUri, onNotFound?: () => void) => void;
+}
+
+// @ts-expect-error default value to undefined, should it be another thing?
+const Context = createContext<Type>(undefined);
+
+export const useTalerWalletIntegrationAPI = (): Type => useContext(Context);
+
+export const TalerWalletIntegrationBrowserProvider = ({
+ children,
+}: {
+ children: ComponentChildren;
+}): VNode => {
+ const value: Type = {
+ publishTalerAction: createHeadMetaTag,
+ };
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
+
+export const TalerWalletIntegrationTestingProvider = ({
+ children,
+ value,
+}: {
+ children: ComponentChildren;
+ value: Type;
+}): VNode => {
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/bank-ui/src/declaration.d.ts b/packages/bank-ui/src/declaration.d.ts
new file mode 100644
index 000000000..581cbcd07
--- /dev/null
+++ b/packages/bank-ui/src/declaration.d.ts
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+declare module "*.css" {
+ const mapping: Record<string, string>;
+ export default mapping;
+}
+declare module "*.svg" {
+ const content: string;
+ export default content;
+}
+declare module "*.jpeg" {
+ const content: string;
+ export default content;
+}
+declare module "*.png" {
+ const content: string;
+ export default content;
+}
+
+declare const __VERSION__: string;
+declare const __GIT_HASH__: string;
diff --git a/packages/bank-ui/src/hooks/account.ts b/packages/bank-ui/src/hooks/account.ts
new file mode 100644
index 000000000..43d43a3f2
--- /dev/null
+++ b/packages/bank-ui/src/hooks/account.ts
@@ -0,0 +1,313 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AccessToken,
+ OperationOk,
+ TalerCoreBankResultByMethod,
+ TalerHttpError,
+ WithdrawalOperationStatus,
+} from "@gnu-taler/taler-util";
+import { useEffect, useState } from "preact/hooks";
+import { useSessionState } from "./session.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { PAGINATED_LIST_REQUEST } from "../utils.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface InstanceTemplateFilter {
+ // FIXME: add filter to the template list
+ position?: string;
+}
+
+export function revalidateAccountDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getAccount",
+ undefined,
+ { revalidate: true },
+ );
+}
+
+export function useAccountDetails(account: string) {
+ const { state: credentials } = useSessionState();
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ async function fetcher([username, token]: [string, AccessToken]) {
+ return await api.getAccount({ username, token });
+ }
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+ const { data, error } = useSWR<
+ TalerCoreBankResultByMethod<"getAccount">,
+ TalerHttpError
+ >([account, token, "getAccount"], fetcher, {});
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+export function revalidateWithdrawalDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getWithdrawalById",
+ undefined,
+ { revalidate: true },
+ );
+}
+
+export function useWithdrawalDetails(wid: string) {
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+ const [latestStatus, setLatestStatus] = useState<WithdrawalOperationStatus>();
+
+ async function fetcher([wid, old_state]: [
+ string,
+ WithdrawalOperationStatus | undefined,
+ ]) {
+ return await api.getWithdrawalById(
+ wid,
+ old_state === undefined ? undefined : { old_state, timeoutMs: 15000 },
+ );
+ }
+
+ const { data, error } = useSWR<
+ TalerCoreBankResultByMethod<"getWithdrawalById">,
+ TalerHttpError
+ >([wid, latestStatus, "getWithdrawalById"], fetcher, {
+ refreshInterval: 3000,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ const currentStatus =
+ data !== undefined && data.type === "ok" ? data.body.status : undefined;
+
+ useEffect(() => {
+ if (currentStatus !== undefined && currentStatus !== latestStatus) {
+ setLatestStatus(currentStatus);
+ }
+ }, [currentStatus]);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+export function revalidateTransactionDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getTransactionById",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useTransactionDetails(account: string, tid: number) {
+ const { state: credentials } = useSessionState();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ async function fetcher([username, token, txid]: [
+ string,
+ AccessToken,
+ number,
+ ]) {
+ return await api.getTransactionById({ username, token }, txid);
+ }
+
+ const { data, error } = useSWR<
+ TalerCoreBankResultByMethod<"getTransactionById">,
+ TalerHttpError
+ >([account, token, tid, "getTransactionById"], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+export async function revalidatePublicAccounts() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getPublicAccounts",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function usePublicAccounts(
+ filterAccount: string | undefined,
+ initial?: number,
+) {
+ const [offset, setOffset] = useState<number | undefined>(initial);
+
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ async function fetcher([account, txid]: [
+ string | undefined,
+ number | undefined,
+ ]) {
+ return await api.getPublicAccounts(
+ { account },
+ {
+ limit: PAGINATED_LIST_REQUEST,
+ offset: txid ? String(txid) : undefined,
+ order: "asc",
+ },
+ );
+ }
+
+ const { data, error } = useSWR<
+ TalerCoreBankResultByMethod<"getPublicAccounts">,
+ TalerHttpError
+ >([filterAccount, offset, "getPublicAccounts"], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ // if (data.type !== "ok") return data;
+
+ //TODO: row_id should not be optional
+ return buildPaginatedResult(
+ data.body.public_accounts,
+ offset,
+ setOffset,
+ (d) => d.row_id ?? 0,
+ );
+}
+
+type PaginatedResult<T> = OperationOk<T> & {
+ isLastPage: boolean;
+ isFirstPage: boolean;
+ loadNext(): void;
+ loadFirst(): void;
+};
+//TODO: consider sending this to web-util
+export function buildPaginatedResult<DataType, OffsetId>(
+ data: DataType[],
+ offset: OffsetId | undefined,
+ setOffset: (o: OffsetId | undefined) => void,
+ getId: (r: DataType) => OffsetId,
+): PaginatedResult<DataType[]> {
+ const isLastPage = data.length < PAGINATED_LIST_REQUEST;
+ const isFirstPage = offset === undefined;
+
+ const result = structuredClone(data);
+ if (result.length == PAGINATED_LIST_REQUEST) {
+ //do now show the last element, used to know if this is the last page
+ result.pop();
+ }
+ return {
+ type: "ok",
+ body: result,
+ isLastPage,
+ isFirstPage,
+ loadNext: () => {
+ if (!result.length) return;
+ const id = getId(result[result.length - 1]);
+ setOffset(id);
+ },
+ loadFirst: () => {
+ setOffset(undefined);
+ },
+ };
+}
+
+export function revalidateTransactions() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getTransactions",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useTransactions(account: string, initial?: number) {
+ const { state: credentials } = useSessionState();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+
+ const [offset, setOffset] = useState<number | undefined>(initial);
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ async function fetcher([username, token, txid]: [
+ string,
+ AccessToken,
+ number | undefined,
+ ]) {
+ return await api.getTransactions(
+ { username, token },
+ {
+ limit: PAGINATED_LIST_REQUEST,
+ offset: txid ? String(txid) : undefined,
+ order: "dec",
+ },
+ );
+ }
+
+ const { data, error } = useSWR<
+ TalerCoreBankResultByMethod<"getTransactions">,
+ TalerHttpError
+ >([account, token, offset, "getTransactions"], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ refreshWhenOffline: false,
+ // revalidateOnMount: false,
+ revalidateIfStale: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ });
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return buildPaginatedResult(
+ data.body.transactions,
+ offset,
+ setOffset,
+ (d) => d.row_id,
+ );
+}
diff --git a/packages/bank-ui/src/hooks/bank-state.ts b/packages/bank-ui/src/hooks/bank-state.ts
new file mode 100644
index 000000000..616678ddc
--- /dev/null
+++ b/packages/bank-ui/src/hooks/bank-state.ts
@@ -0,0 +1,185 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Codec,
+ TalerCorebankApi,
+ buildCodecForObject,
+ buildCodecForUnion,
+ codecForAbsoluteTime,
+ codecForAny,
+ codecForConstString,
+ codecForString,
+ codecForTanTransmission,
+ codecOptional,
+} from "@gnu-taler/taler-util";
+import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
+import { AppLocation } from "@gnu-taler/web-util/browser";
+
+export type ChallengeInProgess =
+ | DeleteAccountChallenge
+ | UpdateAccountChallenge
+ | UpdatePasswordChallenge
+ | CreateTransactionChallenge
+ | ConfirmWithdrawalChallenge
+ | CashoutChallenge;
+
+type BaseChallenge<OpType extends string, ReqType> = {
+ id: string;
+ operation: OpType;
+ sent: AbsoluteTime;
+ location: AppLocation;
+ info?: TalerCorebankApi.TanTransmission;
+ request: ReqType;
+};
+
+type DeleteAccountChallenge = BaseChallenge<"delete-account", string>;
+type UpdateAccountChallenge = BaseChallenge<
+ "update-account",
+ TalerCorebankApi.AccountReconfiguration
+>;
+type UpdatePasswordChallenge = BaseChallenge<
+ "update-password",
+ TalerCorebankApi.AccountPasswordChange
+>;
+type CreateTransactionChallenge = BaseChallenge<
+ "create-transaction",
+ TalerCorebankApi.CreateTransactionRequest
+>;
+type ConfirmWithdrawalChallenge = BaseChallenge<"confirm-withdrawal", string>;
+type CashoutChallenge = BaseChallenge<
+ "create-cashout",
+ TalerCorebankApi.CashoutRequest
+>;
+
+const codecForChallengeUpdatePassword = (): Codec<UpdatePasswordChallenge> =>
+ buildCodecForObject<UpdatePasswordChallenge>()
+ .property("operation", codecForConstString("update-password"))
+ .property("id", codecForString())
+ .property("location", codecForAppLocation())
+ .property("sent", codecForAbsoluteTime)
+ .property("info", codecOptional(codecForTanTransmission()))
+ .property("request", codecForAny())
+ .build("UpdatePasswordChallenge");
+
+const codecForChallengeDeleteAccount = (): Codec<DeleteAccountChallenge> =>
+ buildCodecForObject<DeleteAccountChallenge>()
+ .property("operation", codecForConstString("delete-account"))
+ .property("id", codecForString())
+ .property("location", codecForAppLocation())
+ .property("sent", codecForAbsoluteTime)
+ .property("request", codecForString())
+ .property("info", codecOptional(codecForTanTransmission()))
+ .build("DeleteAccountChallenge");
+
+const codecForChallengeUpdateAccount = (): Codec<UpdateAccountChallenge> =>
+ buildCodecForObject<UpdateAccountChallenge>()
+ .property("operation", codecForConstString("update-account"))
+ .property("id", codecForString())
+ .property("location", codecForAppLocation())
+ .property("sent", codecForAbsoluteTime)
+ .property("info", codecOptional(codecForTanTransmission()))
+ .property("request", codecForAny())
+ .build("UpdateAccountChallenge");
+
+const codecForChallengeCreateTransaction =
+ (): Codec<CreateTransactionChallenge> =>
+ buildCodecForObject<CreateTransactionChallenge>()
+ .property("operation", codecForConstString("create-transaction"))
+ .property("id", codecForString())
+ .property("location", codecForAppLocation())
+ .property("sent", codecForAbsoluteTime)
+ .property("info", codecOptional(codecForTanTransmission()))
+ .property("request", codecForAny())
+ .build("CreateTransactionChallenge");
+
+const codecForChallengeConfirmWithdrawal =
+ (): Codec<ConfirmWithdrawalChallenge> =>
+ buildCodecForObject<ConfirmWithdrawalChallenge>()
+ .property("operation", codecForConstString("confirm-withdrawal"))
+ .property("id", codecForString())
+ .property("location", codecForAppLocation())
+ .property("sent", codecForAbsoluteTime)
+ .property("info", codecOptional(codecForTanTransmission()))
+ .property("request", codecForString())
+ .build("ConfirmWithdrawalChallenge");
+
+const codecForAppLocation = codecForString as () => Codec<AppLocation>;
+
+const codecForChallengeCashout = (): Codec<CashoutChallenge> =>
+ buildCodecForObject<CashoutChallenge>()
+ .property("operation", codecForConstString("create-cashout"))
+ .property("id", codecForString())
+ .property("location", codecForAppLocation())
+ .property("sent", codecForAbsoluteTime)
+ .property("info", codecOptional(codecForTanTransmission()))
+ .property("request", codecForAny())
+ .build("CashoutChallenge");
+
+const codecForChallenge = (): Codec<ChallengeInProgess> =>
+ buildCodecForUnion<ChallengeInProgess>()
+ .discriminateOn("operation")
+ .alternative("confirm-withdrawal", codecForChallengeConfirmWithdrawal())
+ .alternative("create-cashout", codecForChallengeCashout())
+ .alternative("create-transaction", codecForChallengeCreateTransaction())
+ .alternative("delete-account", codecForChallengeDeleteAccount())
+ .alternative("update-account", codecForChallengeUpdateAccount())
+ .alternative("update-password", codecForChallengeUpdatePassword())
+ .build("ChallengeInProgess");
+
+interface BankState {
+ currentWithdrawalOperationId: string | undefined;
+ currentChallenge: ChallengeInProgess | undefined;
+}
+
+export const codecForBankState = (): Codec<BankState> =>
+ buildCodecForObject<BankState>()
+ .property("currentWithdrawalOperationId", codecOptional(codecForString()))
+ .property("currentChallenge", codecOptional(codecForChallenge()))
+ .build("BankState");
+
+const defaultBankState: BankState = {
+ currentWithdrawalOperationId: undefined,
+ currentChallenge: undefined,
+};
+
+const BANK_STATE_KEY = buildStorageKey("bank-app-state", codecForBankState());
+
+/**
+ * Client state saved in local storage.
+ *
+ * This information is saved in the client because
+ * the backend server session API is not enough.
+ *
+ * @returns tuple of [state, update(), reset()]
+ */
+export function useBankState(): [
+ Readonly<BankState>,
+ <T extends keyof BankState>(key: T, value: BankState[T]) => void,
+ () => void,
+] {
+ const { value, update } = useLocalStorage(BANK_STATE_KEY, defaultBankState);
+
+ function updateField<T extends keyof BankState>(k: T, v: BankState[T]) {
+ const newValue = { ...value, [k]: v };
+ update(newValue);
+ }
+ function reset() {
+ update(defaultBankState);
+ }
+ return [value, updateField, reset];
+}
diff --git a/packages/bank-ui/src/hooks/form.ts b/packages/bank-ui/src/hooks/form.ts
new file mode 100644
index 000000000..afa4912eb
--- /dev/null
+++ b/packages/bank-ui/src/hooks/form.ts
@@ -0,0 +1,115 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountJson, TranslatedString } from "@gnu-taler/taler-util";
+import { useState } from "preact/hooks";
+
+export type UIField = {
+ value: string | undefined;
+ onUpdate: (s: string) => void;
+ error: TranslatedString | undefined;
+};
+
+type FormHandler<T> = {
+ [k in keyof T]?: T[k] extends string
+ ? UIField
+ : T[k] extends AmountJson
+ ? UIField
+ : FormHandler<T[k]>;
+};
+
+export type FormValues<T> = {
+ [k in keyof T]: T[k] extends string
+ ? string | undefined
+ : T[k] extends AmountJson
+ ? string | undefined
+ : FormValues<T[k]>;
+};
+
+export type RecursivePartial<T> = {
+ [k in keyof T]?: T[k] extends string
+ ? string
+ : T[k] extends AmountJson
+ ? AmountJson
+ : RecursivePartial<T[k]>;
+};
+
+export type FormErrors<T> = {
+ [k in keyof T]?: T[k] extends string
+ ? TranslatedString
+ : T[k] extends AmountJson
+ ? TranslatedString
+ : FormErrors<T[k]>;
+};
+
+export type FormStatus<T> =
+ | {
+ status: "ok";
+ result: T;
+ errors: undefined;
+ }
+ | {
+ status: "fail";
+ result: RecursivePartial<T>;
+ errors: FormErrors<T>;
+ };
+
+function constructFormHandler<T>(
+ form: FormValues<T>,
+ updateForm: (d: FormValues<T>) => void,
+ errors: FormErrors<T> | undefined,
+): FormHandler<T> {
+ const keys = Object.keys(form) as Array<keyof T>;
+
+ const handler = keys.reduce((prev, fieldName) => {
+ const currentValue: unknown = form[fieldName];
+ const currentError: unknown = errors ? errors[fieldName] : undefined;
+ function updater(newValue: unknown) {
+ updateForm({ ...form, [fieldName]: newValue });
+ }
+ if (typeof currentValue === "object") {
+ // @ts-expect-error FIXME better typing
+ const group = constructFormHandler(currentValue, updater, currentError);
+ // @ts-expect-error FIXME better typing
+ prev[fieldName] = group;
+ return prev;
+ }
+ const field: UIField = {
+ // @ts-expect-error FIXME better typing
+ error: currentError,
+ // @ts-expect-error FIXME better typing
+ value: currentValue,
+ onUpdate: updater,
+ };
+ // @ts-expect-error FIXME better typing
+ prev[fieldName] = field;
+ return prev;
+ }, {} as FormHandler<T>);
+
+ return handler;
+}
+
+export function useFormState<T>(
+ defaultValue: FormValues<T>,
+ check: (f: FormValues<T>) => FormStatus<T>,
+): [FormHandler<T>, FormStatus<T>] {
+ const [form, updateForm] = useState<FormValues<T>>(defaultValue);
+
+ const status = check(form);
+ const handler = constructFormHandler(form, updateForm, status.errors);
+
+ return [handler, status];
+}
diff --git a/packages/bank-ui/src/hooks/preferences.ts b/packages/bank-ui/src/hooks/preferences.ts
new file mode 100644
index 000000000..bb3dcb153
--- /dev/null
+++ b/packages/bank-ui/src/hooks/preferences.ts
@@ -0,0 +1,111 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Codec,
+ TranslatedString,
+ buildCodecForObject,
+ codecForBoolean,
+ codecForNumber,
+} from "@gnu-taler/taler-util";
+import {
+ buildStorageKey,
+ useLocalStorage,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+
+interface Preferences {
+ showWithdrawalSuccess: boolean;
+ showDemoDescription: boolean;
+ showInstallWallet: boolean;
+ maxWithdrawalAmount: number;
+ fastWithdrawal: boolean;
+ showDebugInfo: boolean;
+}
+
+export const codecForPreferences = (): Codec<Preferences> =>
+ buildCodecForObject<Preferences>()
+ .property("showWithdrawalSuccess", codecForBoolean())
+ .property("showDemoDescription", codecForBoolean())
+ .property("showInstallWallet", codecForBoolean())
+ .property("fastWithdrawal", codecForBoolean())
+ .property("showDebugInfo", codecForBoolean())
+ .property("maxWithdrawalAmount", codecForNumber())
+ .build("Settings");
+
+const defaultPreferences: Preferences = {
+ showWithdrawalSuccess: true,
+ showDemoDescription: true,
+ showInstallWallet: true,
+ maxWithdrawalAmount: 25,
+ fastWithdrawal: false,
+ showDebugInfo: false,
+};
+
+const BANK_PREFERENCES_KEY = buildStorageKey(
+ "bank-preferences",
+ codecForPreferences(),
+);
+/**
+ * User preferences.
+ *
+ * @returns tuple of [state, update()]
+ */
+export function usePreferences(): [
+ Readonly<Preferences>,
+ <T extends keyof Preferences>(key: T, value: Preferences[T]) => void,
+] {
+ const { value, update } = useLocalStorage(
+ BANK_PREFERENCES_KEY,
+ defaultPreferences,
+ );
+
+ function updateField<T extends keyof Preferences>(k: T, v: Preferences[T]) {
+ const newValue = { ...value, [k]: v };
+ update(newValue);
+ }
+ return [value, updateField];
+}
+
+export function getAllBooleanPreferences(): Array<keyof Preferences> {
+ return [
+ "fastWithdrawal",
+ "showDebugInfo",
+ "showDemoDescription",
+ "showInstallWallet",
+ "showWithdrawalSuccess",
+ ];
+}
+
+export function getLabelForPreferences(
+ k: keyof Preferences,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): TranslatedString {
+ switch (k) {
+ case "maxWithdrawalAmount":
+ return i18n.str`Max withdrawal amount`;
+ case "showWithdrawalSuccess":
+ return i18n.str`Show withdrawal confirmation`;
+ case "showDemoDescription":
+ return i18n.str`Show demo description`;
+ case "showInstallWallet":
+ return i18n.str`Show install wallet first`;
+ case "fastWithdrawal":
+ return i18n.str`Use fast withdrawal form`;
+ case "showDebugInfo":
+ return i18n.str`Show debug info`;
+ }
+}
diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts
new file mode 100644
index 000000000..e0c861a0f
--- /dev/null
+++ b/packages/bank-ui/src/hooks/regional.ts
@@ -0,0 +1,507 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useSessionState } from "./session.js";
+
+import {
+ AbsoluteTime,
+ AccessToken,
+ AmountJson,
+ Amounts,
+ HttpStatusCode,
+ OperationOk,
+ TalerBankConversionResultByMethod,
+ TalerCoreBankErrorsByMethod,
+ TalerCoreBankResultByMethod,
+ TalerCorebankApi,
+ TalerError,
+ TalerHttpError,
+ opFixedSuccess,
+} from "@gnu-taler/taler-util";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useState } from "preact/hooks";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { buildPaginatedResult } from "./account.js";
+import { PAGINATED_LIST_REQUEST } from "../utils.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+const useSWR = _useSWR as unknown as SWRHook;
+
+export type TransferCalculation =
+ | {
+ debit: AmountJson;
+ credit: AmountJson;
+ beforeFee: AmountJson;
+ }
+ | "amount-is-too-small";
+type EstimatorFunction = (
+ amount: AmountJson,
+ fee: AmountJson,
+) => Promise<TransferCalculation>;
+
+type ConversionEstimators = {
+ estimateByCredit: EstimatorFunction;
+ estimateByDebit: EstimatorFunction;
+};
+
+export function revalidateConversionInfo() {
+ return mutate(
+ (key) =>
+ Array.isArray(key) && key[key.length - 1] === "getConversionInfoAPI",
+ );
+}
+export function useConversionInfo() {
+ const {
+ lib: { conversion },
+ config,
+ } = useBankCoreApiContext();
+
+ async function fetcher() {
+ return await conversion.getConfig();
+ }
+ const { data, error } = useSWR<
+ TalerBankConversionResultByMethod<"getConfig">,
+ TalerHttpError
+ >(!config.allow_conversion ? undefined : ["getConversionInfoAPI"], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+export function useCashinEstimator(): ConversionEstimators {
+ const {
+ lib: { conversion },
+ } = useBankCoreApiContext();
+ return {
+ estimateByCredit: async (fiatAmount, fee) => {
+ const resp = await conversion.getCashinRate({
+ credit: fiatAmount,
+ });
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case HttpStatusCode.Conflict: {
+ return "amount-is-too-small";
+ }
+ // this below can't happen
+ case HttpStatusCode.NotImplemented: //it should not be able to call this function
+ case HttpStatusCode.BadRequest: //we are using just one parameter
+ throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint);
+ }
+ }
+ const credit = Amounts.parseOrThrow(resp.body.amount_credit);
+ const debit = Amounts.parseOrThrow(resp.body.amount_debit);
+ const beforeFee = Amounts.sub(credit, fee).amount;
+
+ return {
+ debit,
+ beforeFee,
+ credit,
+ };
+ },
+ estimateByDebit: async (regionalAmount, fee) => {
+ const resp = await conversion.getCashinRate({
+ debit: regionalAmount,
+ });
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case HttpStatusCode.Conflict: {
+ return "amount-is-too-small";
+ }
+ // this below can't happen
+ case HttpStatusCode.NotImplemented: //it should not be able to call this function
+ case HttpStatusCode.BadRequest: //we are using just one parameter
+ throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint);
+ }
+ }
+ const credit = Amounts.parseOrThrow(resp.body.amount_credit);
+ const debit = Amounts.parseOrThrow(resp.body.amount_debit);
+ const beforeFee = Amounts.add(credit, fee).amount;
+
+ return {
+ debit,
+ beforeFee,
+ credit,
+ };
+ },
+ };
+}
+
+export function useCashoutEstimator(): ConversionEstimators {
+ const {
+ lib: { conversion },
+ } = useBankCoreApiContext();
+ return {
+ estimateByCredit: async (fiatAmount, fee) => {
+ const resp = await conversion.getCashoutRate({
+ credit: fiatAmount,
+ });
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case HttpStatusCode.Conflict: {
+ return "amount-is-too-small";
+ }
+ // this below can't happen
+ case HttpStatusCode.NotImplemented: //it should not be able to call this function
+ case HttpStatusCode.BadRequest: //we are using just one parameter
+ throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint);
+ }
+ }
+ const credit = Amounts.parseOrThrow(resp.body.amount_credit);
+ const debit = Amounts.parseOrThrow(resp.body.amount_debit);
+ const beforeFee = Amounts.sub(credit, fee).amount;
+
+ return {
+ debit,
+ beforeFee,
+ credit,
+ };
+ },
+ estimateByDebit: async (regionalAmount, fee) => {
+ const resp = await conversion.getCashoutRate({
+ debit: regionalAmount,
+ });
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case HttpStatusCode.Conflict: {
+ return "amount-is-too-small";
+ }
+ // this below can't happen
+ case HttpStatusCode.NotImplemented: //it should not be able to call this function
+ case HttpStatusCode.BadRequest: //we are using just one parameter
+ throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint);
+ }
+ }
+ const credit = Amounts.parseOrThrow(resp.body.amount_credit);
+ const debit = Amounts.parseOrThrow(resp.body.amount_debit);
+ const beforeFee = Amounts.add(credit, fee).amount;
+
+ return {
+ debit,
+ beforeFee,
+ credit,
+ };
+ },
+ };
+}
+
+/**
+ * @deprecated use useCashoutEstimator
+ */
+export function useEstimator(): ConversionEstimators {
+ return useCashoutEstimator();
+}
+
+export async function revalidateBusinessAccounts() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getAccounts",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useBusinessAccounts() {
+ const { state: credentials } = useSessionState();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ const [offset, setOffset] = useState<number | undefined>();
+
+ function fetcher([token, aid]: [AccessToken, number]) {
+ // FIXME: add account name filter
+ return api.getAccounts(
+ token,
+ {},
+ {
+ limit: PAGINATED_LIST_REQUEST,
+ offset: aid ? String(aid) : undefined,
+ order: "asc",
+ },
+ );
+ }
+
+ const { data, error } = useSWR<
+ TalerCoreBankResultByMethod<"getAccounts">,
+ TalerHttpError
+ >([token, offset ?? 0, "getAccounts"], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ });
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ //TODO: row_id should not be optional
+ return buildPaginatedResult(
+ data.body.accounts,
+ offset,
+ setOffset,
+ (d) => d.row_id ?? 0,
+ );
+}
+
+type CashoutWithId = TalerCorebankApi.CashoutStatusResponse & { id: number };
+function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId {
+ return c !== undefined;
+}
+export function revalidateOnePendingCashouts() {
+ return mutate(
+ (key) =>
+ Array.isArray(key) && key[key.length - 1] === "useOnePendingCashouts",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useOnePendingCashouts(account: string) {
+ const { state: credentials } = useSessionState();
+ const {
+ lib: { bank: api },
+ config,
+ } = useBankCoreApiContext();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+
+ async function fetcher([username, token]: [string, AccessToken]) {
+ const list = await api.getAccountCashouts({ username, token });
+ if (list.type !== "ok") {
+ return list;
+ }
+ const pendingCashout =
+ list.body.cashouts.length > 0 ? list.body.cashouts[0] : undefined;
+ if (!pendingCashout) return opFixedSuccess(undefined);
+ const cashoutInfo = await api.getCashoutById(
+ { username, token },
+ pendingCashout.cashout_id,
+ );
+ if (cashoutInfo.type !== "ok") {
+ return cashoutInfo;
+ }
+ return opFixedSuccess({
+ ...cashoutInfo.body,
+ id: pendingCashout.cashout_id,
+ });
+ }
+
+ const { data, error } = useSWR<
+ | OperationOk<CashoutWithId | undefined>
+ | TalerCoreBankErrorsByMethod<"getAccountCashouts">
+ | TalerCoreBankErrorsByMethod<"getCashoutById">,
+ TalerHttpError
+ >(
+ !config.allow_conversion
+ ? undefined
+ : [account, token, "useOnePendingCashouts"],
+ fetcher,
+ {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ },
+ );
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+export function revalidateCashouts() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "useCashouts",
+ );
+}
+export function useCashouts(account: string) {
+ const { state: credentials } = useSessionState();
+ const {
+ lib: { bank: api },
+ config,
+ } = useBankCoreApiContext();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+
+ async function fetcher([username, token]: [string, AccessToken]) {
+ const list = await api.getAccountCashouts({ username, token });
+ if (list.type !== "ok") {
+ return list;
+ }
+ const all: Array<CashoutWithId | undefined> = await Promise.all(
+ list.body.cashouts.map(async (c) => {
+ const r = await api.getCashoutById({ username, token }, c.cashout_id);
+ if (r.type === "fail") {
+ return undefined;
+ }
+ return { ...r.body, id: c.cashout_id };
+ }),
+ );
+ const cashouts = all.filter(notUndefined);
+ return { type: "ok" as const, body: { cashouts } };
+ }
+ const { data, error } = useSWR<
+ | OperationOk<{ cashouts: CashoutWithId[] }>
+ | TalerCoreBankErrorsByMethod<"getAccountCashouts">,
+ TalerHttpError
+ >(
+ !config.allow_conversion ? undefined : [account, token, "useCashouts"],
+ fetcher,
+ {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ },
+ );
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+export function revalidateCashoutDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getCashoutById",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useCashoutDetails(cashoutId: number | undefined) {
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ async function fetcher([username, token, id]: [string, AccessToken, number]) {
+ return api.getCashoutById({ username, token }, id);
+ }
+
+ const { data, error } = useSWR<
+ TalerCoreBankResultByMethod<"getCashoutById">,
+ TalerHttpError
+ >(
+ cashoutId === undefined
+ ? undefined
+ : [creds?.username, creds?.token, cashoutId, "getCashoutById"],
+ fetcher,
+ {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ },
+ );
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+export type MonitorMetrics = {
+ lastHour: TalerCoreBankResultByMethod<"getMonitor">;
+ lastDay: TalerCoreBankResultByMethod<"getMonitor">;
+ lastMonth: TalerCoreBankResultByMethod<"getMonitor">;
+};
+
+export type LastMonitor = {
+ current: TalerCoreBankResultByMethod<"getMonitor">;
+ previous: TalerCoreBankResultByMethod<"getMonitor">;
+};
+export function revalidateLastMonitorInfo() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "useLastMonitorInfo",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useLastMonitorInfo(
+ currentMoment: AbsoluteTime,
+ previousMoment: AbsoluteTime,
+ timeframe: TalerCorebankApi.MonitorTimeframeParam,
+) {
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+ const { state: credentials } = useSessionState();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+
+ async function fetcher([token, timeframe]: [
+ AccessToken,
+ TalerCorebankApi.MonitorTimeframeParam,
+ ]) {
+ const [current, previous] = await Promise.all([
+ api.getMonitor(token, { timeframe, date: currentMoment }),
+ api.getMonitor(token, { timeframe, date: previousMoment }),
+ ]);
+ return {
+ current,
+ previous,
+ };
+ }
+
+ const { data, error } = useSWR<LastMonitor, TalerHttpError>(
+ !token ? undefined : [token, timeframe, "useLastMonitorInfo"],
+ fetcher,
+ {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ },
+ );
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
diff --git a/packages/bank-ui/src/hooks/session.ts b/packages/bank-ui/src/hooks/session.ts
new file mode 100644
index 000000000..4520d0e4a
--- /dev/null
+++ b/packages/bank-ui/src/hooks/session.ts
@@ -0,0 +1,134 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AccessToken,
+ Codec,
+ buildCodecForObject,
+ buildCodecForUnion,
+ codecForBoolean,
+ codecForConstString,
+ codecForString,
+} from "@gnu-taler/taler-util";
+import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
+import { mutate } from "swr";
+
+/**
+ * Has the information to reach and
+ * authenticate at the bank's backend.
+ */
+export type SessionState = LoggedIn | LoggedOut | Expired;
+
+interface LoggedIn {
+ status: "loggedIn";
+ isUserAdministrator: boolean;
+ username: string;
+ token: AccessToken;
+}
+interface Expired {
+ status: "expired";
+ isUserAdministrator: boolean;
+ username: string;
+}
+interface LoggedOut {
+ status: "loggedOut";
+}
+
+export const codecForSessionStateLoggedIn = (): Codec<LoggedIn> =>
+ buildCodecForObject<LoggedIn>()
+ .property("status", codecForConstString("loggedIn"))
+ .property("username", codecForString())
+ .property("token", codecForString() as Codec<AccessToken>)
+ .property("isUserAdministrator", codecForBoolean())
+ .build("SessionState.LoggedIn");
+
+export const codecForSessionStateExpired = (): Codec<Expired> =>
+ buildCodecForObject<Expired>()
+ .property("status", codecForConstString("expired"))
+ .property("username", codecForString())
+ .property("isUserAdministrator", codecForBoolean())
+ .build("SessionState.Expired");
+
+export const codecForSessionStateLoggedOut = (): Codec<LoggedOut> =>
+ buildCodecForObject<LoggedOut>()
+ .property("status", codecForConstString("loggedOut"))
+ .build("SessionState.LoggedOut");
+
+export const codecForSessionState = (): Codec<SessionState> =>
+ buildCodecForUnion<SessionState>()
+ .discriminateOn("status")
+ .alternative("loggedIn", codecForSessionStateLoggedIn())
+ .alternative("loggedOut", codecForSessionStateLoggedOut())
+ .alternative("expired", codecForSessionStateExpired())
+ .build("SessionState");
+
+export const defaultState: SessionState = {
+ status: "loggedOut",
+};
+
+export interface SessionStateHandler {
+ state: SessionState;
+ logOut(): void;
+ expired(): void;
+ logIn(info: { username: string; token: AccessToken }): void;
+}
+
+const SESSION_STATE_KEY = buildStorageKey(
+ "bank-session",
+ codecForSessionState(),
+);
+
+/**
+ * Return getters and setters for
+ * login credentials and backend's
+ * base URL.
+ */
+export function useSessionState(): SessionStateHandler {
+ const { value: state, update } = useLocalStorage(
+ SESSION_STATE_KEY,
+ defaultState,
+ );
+
+ return {
+ state,
+ logOut() {
+ update(defaultState);
+ },
+ expired() {
+ if (state.status === "loggedOut") return;
+ const nextState: SessionState = {
+ status: "expired",
+ username: state.username,
+ isUserAdministrator: state.username === "admin",
+ };
+ update(nextState);
+ },
+ logIn(info) {
+ // admin is defined by the username
+ const nextState: SessionState = {
+ status: "loggedIn",
+ ...info,
+ isUserAdministrator: info.username === "admin",
+ };
+ update(nextState);
+ cleanAllCache();
+ },
+ };
+}
+
+function cleanAllCache(): void {
+ mutate(() => true, undefined, { revalidate: false });
+}
diff --git a/packages/bank-ui/src/i18n/bank.pot b/packages/bank-ui/src/i18n/bank.pot
new file mode 100644
index 000000000..1f11b8f10
--- /dev/null
+++ b/packages/bank-ui/src/i18n/bank.pot
@@ -0,0 +1,1740 @@
+# This file is part of GNU Taler
+# (C) 2022-2024 Taler Systems S.A.
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Bank\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+#: src/utils.ts:137
+#, c-format
+msgid "Operation failed, please report"
+msgstr ""
+
+#: src/utils.ts:156
+#, c-format
+msgid "Request timeout"
+msgstr ""
+
+#: src/utils.ts:165
+#, c-format
+msgid "Request throttled"
+msgstr ""
+
+#: src/utils.ts:174
+#, c-format
+msgid "Malformed response"
+msgstr ""
+
+#: src/utils.ts:183
+#, c-format
+msgid "Network error"
+msgstr ""
+
+#: src/utils.ts:192
+#, c-format
+msgid "Unexpected request error"
+msgstr ""
+
+#: src/utils.ts:201
+#, c-format
+msgid "Unexpected error"
+msgstr ""
+
+#: src/utils.ts:377
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/utils.ts:379
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/utils.ts:387
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/utils.ts:401
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/context/config.ts:136
+#, c-format
+msgid ""
+"the bank backend is not supported. supported version \"%1$s\", server version "
+"\"%2$s\""
+msgstr ""
+
+#: src/hooks/preferences.ts:55
+#, c-format
+msgid "Max withdrawal amount"
+msgstr ""
+
+#: src/hooks/preferences.ts:57
+#, c-format
+msgid "Show withdrawal confirmation"
+msgstr ""
+
+#: src/hooks/preferences.ts:59
+#, c-format
+msgid "Show demo description"
+msgstr ""
+
+#: src/hooks/preferences.ts:61
+#, c-format
+msgid "Show install wallet first"
+msgstr ""
+
+#: src/hooks/preferences.ts:63
+#, c-format
+msgid "Use fast withdrawal form"
+msgstr ""
+
+#: src/hooks/preferences.ts:65
+#, c-format
+msgid "Show debug info"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:90
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:92
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:98
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:100
+#, c-format
+msgid "should be greater than 0"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "balance is not enough"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:112
+#, c-format
+msgid "does not follow the pattern"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:114
+#, c-format
+msgid "only \"IBAN\" target are supported"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:116
+#, c-format
+msgid "use the \"amount\" parameter to specify the amount to be transferred"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:118
+#, c-format
+msgid "the amount is not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:120
+#, c-format
+msgid "use the \"message\" parameter to specify a reference text for the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:160
+#, c-format
+msgid "The request was invalid or the payto://-URI used unacceptable features."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:167
+#, c-format
+msgid "Not enough permission to complete the operation."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:174
+#, c-format
+msgid "The destination account \"%1$s\" was not found."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:181
+#, c-format
+msgid "The origin and the destination of the transfer can't be the same."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:188
+#, c-format
+msgid "Your balance is not enough."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:195
+#, c-format
+msgid "The origin account \"%1$s\" was not found."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:212
+#, c-format
+msgid "Wire transfer created!"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:270
+#, c-format
+msgid "Using a form"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:310
+#, c-format
+msgid "Import payto:// URI"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:335
+#, c-format
+msgid "Recipient"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:359
+#, c-format
+msgid "IBAN of the recipient's account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:369
+#, c-format
+msgid "Transfer subject"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:377
+#, c-format
+msgid "subject"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:390
+#, c-format
+msgid "some text to identify the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:400
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:415
+#, c-format
+msgid "amount to transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:425
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:436
+#, c-format
+msgid "uniform resource identifier of the target account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:437
+#, c-format
+msgid "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:457
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:471
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:71
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:75
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:104
+#, c-format
+msgid "Wrong credentials for \"%1$s\""
+msgstr ""
+
+#: src/pages/LoginForm.tsx:111
+#, c-format
+msgid "Account not found"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:142
+#, c-format
+msgid "Username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:156
+#, c-format
+msgid "username of the account"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:175
+#, c-format
+msgid "Password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:188
+#, c-format
+msgid "password of the account"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:223
+#, c-format
+msgid "Check"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:237
+#, c-format
+msgid "Log in"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:249
+#, c-format
+msgid "Register"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:52
+#, c-format
+msgid "Latest transactions"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:63
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:75
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:111
+#, c-format
+msgid "sent"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:112
+#, c-format
+msgid "received"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:127
+#, c-format
+msgid "invalid value"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "to"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "from"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:202
+#, c-format
+msgid "First page"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:209
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:86
+#, c-format
+msgid "Wire transfer completed!"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:93
+#, c-format
+msgid "The withdrawal has been aborted previously and can't be confirmed"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:100
+#, c-format
+msgid ""
+"The withdrawal operation can't be confirmed before a wallet accepted the "
+"transaction."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:107
+#, c-format
+msgid "The operation id is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:114
+#, c-format
+msgid "The operation was not found."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:121
+#, c-format
+msgid "Your balance is not enough for the operation."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:155
+#, c-format
+msgid "The reserve operation has been confirmed previously and can't be aborted"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:186
+#, c-format
+msgid "Confirm the withdrawal operation"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:203
+#, c-format
+msgid "Wire transfer details"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:217
+#, c-format
+msgid "Taler Exchange operator's account"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:228
+#, c-format
+msgid "Taler Exchange operator's name"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:317
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:342
+#, c-format
+msgid "Authentication required"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:352
+#, c-format
+msgid "This operation was created with other username"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:209
+#, c-format
+msgid ""
+"Unauthorized to make the operation, maybe the session has expired or the "
+"password changed."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:218
+#, c-format
+msgid "The operation was rejected due to insufficient funds."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:268
+#, c-format
+msgid "Withdrawal confirmed"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:272
+#, c-format
+msgid ""
+"The wire transfer to the Taler operator has been initiated. You will soon "
+"receive the requested amount in your Taler wallet."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:287
+#, c-format
+msgid "Do not show this again"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:319
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:399
+#, c-format
+msgid "On this device"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:404
+#, c-format
+msgid ""
+"If you are using a web browser on desktop you should access your wallet with the "
+"GNU Taler WebExtension now or click the link if your WebExtension have the "
+"\"Inject Taler support\" option enabled."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:417
+#, c-format
+msgid "Start"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:426
+#, c-format
+msgid "On a mobile phone"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:431
+#, c-format
+msgid "Scan the QR code with your mobile device."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:73
+#, c-format
+msgid "There is an operation already"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:75
+#, c-format
+msgid "Complete or cancel the operation in"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "this page"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:101
+#, c-format
+msgid "invalid"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:116
+#, c-format
+msgid "Server responded with an invalid withdraw URI"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:117
+#, c-format
+msgid "Withdraw URI: %1$s"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:132
+#, c-format
+msgid "The operation was rejected due to insufficient funds"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:253
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:282
+#, c-format
+msgid "Prepare your wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:285
+#, c-format
+msgid ""
+"After using your wallet you will need to confirm or cancel the operation on this "
+"site."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:295
+#, c-format
+msgid "You need a GNU Taler Wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:300
+#, c-format
+msgid "If you don't have one yet you can follow the instruction in"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:55
+#, c-format
+msgid "Send money"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:73
+#, c-format
+msgid "to a %1$s wallet"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:95
+#, c-format
+msgid "Withdraw digital money into your mobile wallet or browser extension"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:109
+#, c-format
+msgid "operation ready"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:129
+#, c-format
+msgid "to another bank account"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:149
+#, c-format
+msgid "Make a wire transfer to an account with known bank account number."
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:171
+#, c-format
+msgid "Transfer details"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:41
+#, c-format
+msgid "This is a demo bank"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:46
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would work. "
+"In addition to using your own bank account, you can also see the transaction "
+"history of some %1$s."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:53
+#, c-format
+msgid "This part of the demo shows how a bank that supports Taler directly would work."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:70
+#, c-format
+msgid "Pending account delete operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:72
+#, c-format
+msgid "Pending account update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:74
+#, c-format
+msgid "Pending password update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:76
+#, c-format
+msgid "Pending transaction operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:78
+#, c-format
+msgid "Pending withdrawal operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:80
+#, c-format
+msgid "Pending cashout operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:91
+#, c-format
+msgid "You can complete or cancel the operation in"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:64
+#, c-format
+msgid "Internal error, please report."
+msgstr ""
+
+#: src/pages/BankFrame.tsx:100
+#, c-format
+msgid "Preferences"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:184
+#, c-format
+msgid "Welcome, %1$s"
+msgstr ""
+
+#: src/pages/WireTransfer.tsx:79
+#, c-format
+msgid "Make a wire transfer"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:72
+#, c-format
+msgid "Accounts"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:75
+#, c-format
+msgid "A list of all business account in the bank."
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:86
+#, c-format
+msgid "Create account"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:106
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:110
+#, c-format
+msgid "Balance"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:112
+#, c-format
+msgid "Actions"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:151
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:170
+#, c-format
+msgid "change password"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:179
+#, c-format
+msgid "cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:189
+#, c-format
+msgid "remove"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:168
+#, c-format
+msgid "Cashout not implemented"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:184
+#, c-format
+msgid "Select a section"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:202
+#, c-format
+msgid "Last hour"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:208
+#, c-format
+msgid "Last day"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:216
+#, c-format
+msgid "Last month"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:222
+#, c-format
+msgid "Last year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:310
+#, c-format
+msgid "Last Year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:325
+#, c-format
+msgid "Trading volume on %1$s compared to %2$s"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:342
+#, c-format
+msgid "Cashin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:352
+#, c-format
+msgid "Cashout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:364
+#, c-format
+msgid "Payin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:374
+#, c-format
+msgid "Payout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:388
+#, c-format
+msgid "download stats as CSV"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:494
+#, c-format
+msgid "Decreased by"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:498
+#, c-format
+msgid "Increased by"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:89
+#, c-format
+msgid "Download bank stats"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:110
+#, c-format
+msgid "Include hour metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:143
+#, c-format
+msgid "Include day metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:173
+#, c-format
+msgid "Include month metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:206
+#, c-format
+msgid "Include year metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:239
+#, c-format
+msgid "Include table header"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:272
+#, c-format
+msgid "Add previous metric for compare"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:307
+#, c-format
+msgid "Fail on first error"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:364
+#, c-format
+msgid "Download"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:381
+#, c-format
+msgid "downloading... %1$s"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:399
+#, c-format
+msgid "Download completed"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:400
+#, c-format
+msgid "click here to save the file in your computer"
+msgstr ""
+
+#: src/pages/PublicHistoriesPage.tsx:78
+#, c-format
+msgid "History of public accounts"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:48
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:87
+#, c-format
+msgid "Missing name"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:91
+#, c-format
+msgid "Use letters and numbers only, and start with a lowercase letter"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:107
+#, c-format
+msgid "Passwords don't match"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:130
+#, c-format
+msgid "Server replied with invalid phone or email."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:137
+#, c-format
+msgid "Registration is disabled because the bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:144
+#, c-format
+msgid "No enough permission to create that account."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:151
+#, c-format
+msgid "That account id is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:158
+#, c-format
+msgid "That username is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:165
+#, c-format
+msgid "That username can't be used because is reserved."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:172
+#, c-format
+msgid "Only admin is allow to set debt limit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:179
+#, c-format
+msgid "No information for the selected authentication channel."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:186
+#, c-format
+msgid "Authentication channel is not supported."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:193
+#, c-format
+msgid "Only admin can create accounts with second factor authentication."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:233
+#, c-format
+msgid "Account registration"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:315
+#, c-format
+msgid "Repeat password"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:457
+#, c-format
+msgid "Create a random temporary user"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:110
+#, c-format
+msgid "If you have a Taler wallet installed in this device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:116
+#, c-format
+msgid ""
+"You will see the details of the operation in your wallet including the fees (if "
+"applies). If you still don't have one you can install it following instructions "
+"in"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:143
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:152
+#, c-format
+msgid "Or if you have the wallet in another device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:157
+#, c-format
+msgid "Scan the QR below to start the withdrawal."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:79
+#, c-format
+msgid "Operation aborted"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:82
+#, c-format
+msgid ""
+"The wire transfer to the Taler Exchange operator's account was aborted, your "
+"balance was not affected."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "You can close this page now or continue to the account page."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:147
+#, c-format
+msgid "Done"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:158
+#, c-format
+msgid "Operation canceled"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:173
+#, c-format
+msgid "The operation is marked as 'selected' but some step in the withdrawal failed"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:175
+#, c-format
+msgid "The account is selected but no withdrawal identification found."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:188
+#, c-format
+msgid ""
+"There is a withdrawal identification but no account has been selected or the "
+"selected account is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:202
+#, c-format
+msgid ""
+"No withdrawal ID found and no account has been selected or the selected account "
+"is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:259
+#, c-format
+msgid "Operation not found"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:263
+#, c-format
+msgid ""
+"This operation is not known by the server. The operation id is wrong or the "
+"server deleted the operation information before reaching here."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:278
+#, c-format
+msgid "Cotinue to dashboard"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:98
+#, c-format
+msgid "Cashout not found. It may be also mean that it was already aborted."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:136
+#, c-format
+msgid "Challenge not found."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:143
+#, c-format
+msgid "This user is not authorized to complete this challenge."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:150
+#, c-format
+msgid "Too many attempts, try another code."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:157
+#, c-format
+msgid "The confirmation code is wrong, try again."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:164
+#, c-format
+msgid "The operation expired."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:197
+#, c-format
+msgid "The operation failed."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:212
+#, c-format
+msgid "The operation needs another confirmation to complete."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:224
+#, c-format
+msgid "Account delete"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:226
+#, c-format
+msgid "Account update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:228
+#, c-format
+msgid "Password update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:230
+#, c-format
+msgid "Wire transfer"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:232
+#, c-format
+msgid "Withdrawal"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:248
+#, c-format
+msgid "Confirm the operation"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:271
+#, c-format
+msgid "Enter the confirmation code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:313
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:348
+#, c-format
+msgid "Send again"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:359
+#, c-format
+msgid "Send code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:369
+#, c-format
+msgid "Operation details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:529
+#, c-format
+msgid "Challenge details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:536
+#, c-format
+msgid "Sent at"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:551
+#, c-format
+msgid "To phone"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:553
+#, c-format
+msgid "To email"
+msgstr ""
+
+#: src/pages/WithdrawalOperationPage.tsx:49
+#, c-format
+msgid "The Withdrawal URI is not valid"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:100
+#, c-format
+msgid "Latest cashouts"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:111
+#, c-format
+msgid "Created"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:115
+#, c-format
+msgid "Total debit"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:119
+#, c-format
+msgid "Total credit"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:70
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:74
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:78
+#, c-format
+msgid "Credentials"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:82
+#, c-format
+msgid "Cashouts"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:95
+#, c-format
+msgid "Unable to create a cashout"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:96
+#, c-format
+msgid "The bank configuration does not support cashout operations."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:223
+#, c-format
+msgid "need to be higher due to fees"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:225
+#, c-format
+msgid "the total transfer at destination will be zero"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:250
+#, c-format
+msgid "Cashout created"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:272
+#, c-format
+msgid "Duplicated request detected, check if the operation succeeded or try again."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:279
+#, c-format
+msgid "The conversion rate was incorrectly applied"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:286
+#, c-format
+msgid "The account does not have sufficient funds"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:293
+#, c-format
+msgid "Cashouts are not supported"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:300
+#, c-format
+msgid "Missing cashout URI in the profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:307
+#, c-format
+msgid ""
+"Sending the confirmation message failed, retry later or contact the "
+"administrator."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:339
+#, c-format
+msgid "Conversion rate"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:360
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:374
+#, c-format
+msgid "To account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:381
+#, c-format
+msgid "No cashout account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:382
+#, c-format
+msgid "Before doing a cashout you need to complete your profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:440
+#, c-format
+msgid "Amount to send"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:441
+#, c-format
+msgid "Amount to receive"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:490
+#, c-format
+msgid "Total cost"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:505
+#, c-format
+msgid "Balance left"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:520
+#, c-format
+msgid "Before fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:533
+#, c-format
+msgid "Total cashout transfer"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:553
+#, c-format
+msgid "No cashout channel available"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:555
+#, c-format
+msgid ""
+"Before doing a cashout the server need to provide an second channel to confirm "
+"the operation"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:567
+#, c-format
+msgid "Second factor authentication"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:598
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:600
+#, c-format
+msgid "add a email in your profile to enable this option"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:646
+#, c-format
+msgid "SMS"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:648
+#, c-format
+msgid "add a phone number in your profile to enable this option"
+msgstr ""
+
+#: src/pages/account/CashoutListForAccount.tsx:52
+#, c-format
+msgid "Cashout for account %1$s"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:165
+#, c-format
+msgid "it doesn't have the pattern of an IBAN number"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:185
+#, c-format
+msgid "it doesn't have the pattern of an email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:190
+#, c-format
+msgid "should start with +"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:192
+#, c-format
+msgid "phone number can't have other than numbers"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:329
+#, c-format
+msgid "account identification in the bank"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:365
+#, c-format
+msgid "name of the person owner the account"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:374
+#, c-format
+msgid "Internal IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:377
+#, c-format
+msgid "if empty a random account number will be assigned"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:378
+#, c-format
+msgid "account identification for bank transfer"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:423
+#, c-format
+msgid "Phone"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:451
+#, c-format
+msgid "Cashout IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:452
+#, c-format
+msgid "account number where the money is going to be sent when doing cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:470
+#, c-format
+msgid "Max debt"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:494
+#, c-format
+msgid "how much is user able to transfer after zero balance"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:508
+#, c-format
+msgid "Is this a Taler Exchange?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:549
+#, c-format
+msgid "This server doesn't support second factor authentication."
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:560
+#, c-format
+msgid "Enable second factor authentication"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:596
+#, c-format
+msgid "Using email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:654
+#, c-format
+msgid "Using SMS"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:691
+#, c-format
+msgid "Is this account public?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:719
+#, c-format
+msgid "public accounts have their balance publicly accessible"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:100
+#, c-format
+msgid "Account updated"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:107
+#, c-format
+msgid "The rights to change the account are not sufficient"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:114
+#, c-format
+msgid "The username was not found"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:121
+#, c-format
+msgid "You can't change the legal name, please contact the your account administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:128
+#, c-format
+msgid "You can't change the debt limit, please contact the your account administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:135
+#, c-format
+msgid ""
+"You can't change the cashout address, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:177
+#, c-format
+msgid "Account \"%1$s\""
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:190
+#, c-format
+msgid "Change details"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:235
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:78
+#, c-format
+msgid "password doesn't match"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:95
+#, c-format
+msgid "Password changed"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:102
+#, c-format
+msgid "Not authorized to change the password, maybe the session is invalid."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:112
+#, c-format
+msgid ""
+"You need to provide the old password. If you don't have it contact your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:117
+#, c-format
+msgid "Your current password doesn't match, can't change to a new password."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:149
+#, c-format
+msgid "Update password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:167
+#, c-format
+msgid "New password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:195
+#, c-format
+msgid "Type it again"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:217
+#, c-format
+msgid "repeat the same password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:227
+#, c-format
+msgid "Current password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:248
+#, c-format
+msgid "your current password, for security"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:272
+#, c-format
+msgid "Change"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:74
+#, c-format
+msgid ""
+"Account created with password \"%1$s\". The user must change the password on the "
+"next login."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:83
+#, c-format
+msgid "Server replied that phone or email is invalid"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:90
+#, c-format
+msgid "The rights to perform the operation are not sufficient"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:97
+#, c-format
+msgid "Account username is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:104
+#, c-format
+msgid "Account id is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:111
+#, c-format
+msgid "Bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:118
+#, c-format
+msgid "Account username can't be used because is reserved"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:160
+#, c-format
+msgid "Can't create accounts"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:161
+#, c-format
+msgid "Only system admin can create accounts."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:183
+#, c-format
+msgid "New business account"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:209
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:94
+#, c-format
+msgid "Can't delete the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:95
+#, c-format
+msgid ""
+"The account can't be delete while still holding some balance. First make sure "
+"that the owner make a complete cashout."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:117
+#, c-format
+msgid "Account removed"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:124
+#, c-format
+msgid "No enough permission to delete the account."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:131
+#, c-format
+msgid "The username was not found."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:138
+#, c-format
+msgid "Can't delete a reserved username."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:145
+#, c-format
+msgid "Can't delete an account with balance different than zero."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:170
+#, c-format
+msgid "name doesn't match"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:180
+#, c-format
+msgid "You are going to remove the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:182
+#, c-format
+msgid "This step can't be undone."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:188
+#, c-format
+msgid "Deleting account \"%1$s\""
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:206
+#, c-format
+msgid "Verification"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:231
+#, c-format
+msgid "enter the account name that is going to be deleted"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:49
+#, c-format
+msgid "cashout id should be a number"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:65
+#, c-format
+msgid "This cashout not found. Maybe already aborted."
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:106
+#, c-format
+msgid "Cashout detail"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:139
+#, c-format
+msgid "Debited"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:154
+#, c-format
+msgid "Credited"
+msgstr ""
+
+#: src/Routing.tsx:140
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
+
diff --git a/packages/bank-ui/src/i18n/de.po b/packages/bank-ui/src/i18n/de.po
new file mode 100644
index 000000000..ccbbc8208
--- /dev/null
+++ b/packages/bank-ui/src/i18n/de.po
@@ -0,0 +1,1780 @@
+# This file is part of GNU Taler
+# (C) 2021 Taler Systems S.A.
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-03-21 21:39+0000\n"
+"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
+"Language-Team: German <https://weblate.taler.net/projects/gnu-taler/"
+"taler-bank-spa/de/>\n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/utils.ts:137
+#, c-format
+msgid "Operation failed, please report"
+msgstr "Vorgang abgebrochen, bitte Fehler berichten"
+
+#: src/utils.ts:156
+#, c-format
+msgid "Request timeout"
+msgstr "Zeitüberschreitung der Anforderung"
+
+#: src/utils.ts:165
+#, c-format
+msgid "Request throttled"
+msgstr "Anfrage verzögert sich"
+
+#: src/utils.ts:174
+#, c-format
+msgid "Malformed response"
+msgstr "Unstimmige Antwort"
+
+#: src/utils.ts:183
+#, c-format
+msgid "Network error"
+msgstr "Netzwerkfehler"
+
+#: src/utils.ts:192
+#, c-format
+msgid "Unexpected request error"
+msgstr "Unerwarteter Fehler bei der Anforderung"
+
+#: src/utils.ts:201
+#, c-format
+msgid "Unexpected error"
+msgstr "Unerwarteter Fehler"
+
+#: src/utils.ts:377
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr "IBAN-Nummern haben normalerweise mehr als 4 Ziffern"
+
+#: src/utils.ts:379
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr "IBAN-Nummern haben normalerweise weniger als 34 Ziffern"
+
+#: src/utils.ts:387
+#, c-format
+msgid "IBAN country code not found"
+msgstr "IBAN-Ländercode wurde nicht gefunden"
+
+#: src/utils.ts:401
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr "IBAN-Nummer ist ungültig, die Prüfsumme ist falsch"
+
+#: src/context/config.ts:136
+#, c-format
+msgid ""
+"the bank backend is not supported. supported version \"%1$s\", server "
+"version \"%2$s\""
+msgstr ""
+"Das Bank-Backend wird nicht unterstützt. Unterstützte Version \"%1$s\", "
+"Serverversion \"%2$s\""
+
+#: src/hooks/preferences.ts:55
+#, c-format
+msgid "Max withdrawal amount"
+msgstr "Höchste Abhebesumme"
+
+#: src/hooks/preferences.ts:57
+#, c-format
+msgid "Show withdrawal confirmation"
+msgstr ""
+
+#: src/hooks/preferences.ts:59
+#, c-format
+msgid "Show demo description"
+msgstr ""
+
+#: src/hooks/preferences.ts:61
+#, c-format
+msgid "Show install wallet first"
+msgstr ""
+
+#: src/hooks/preferences.ts:63
+#, c-format
+msgid "Use fast withdrawal form"
+msgstr ""
+
+#: src/hooks/preferences.ts:65
+#, c-format
+msgid "Show debug info"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:90
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:92
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:98
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:100
+#, c-format
+msgid "should be greater than 0"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "balance is not enough"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:112
+#, c-format
+msgid "does not follow the pattern"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:114
+#, c-format
+msgid "only \"IBAN\" target are supported"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:116
+#, c-format
+msgid "use the \"amount\" parameter to specify the amount to be transferred"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:118
+#, c-format
+msgid "the amount is not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:120
+#, c-format
+msgid ""
+"use the \"message\" parameter to specify a reference text for the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:160
+#, c-format
+msgid "The request was invalid or the payto://-URI used unacceptable features."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:167
+#, c-format
+msgid "Not enough permission to complete the operation."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:174
+#, c-format
+msgid "The destination account \"%1$s\" was not found."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:181
+#, c-format
+msgid "The origin and the destination of the transfer can't be the same."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:188
+#, c-format
+msgid "Your balance is not enough."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:195
+#, c-format
+msgid "The origin account \"%1$s\" was not found."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:212
+#, c-format
+msgid "Wire transfer created!"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:270
+#, c-format
+msgid "Using a form"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:310
+#, c-format
+msgid "Import payto:// URI"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:335
+#, c-format
+msgid "Recipient"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:359
+#, c-format
+msgid "IBAN of the recipient's account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:369
+#, c-format
+msgid "Transfer subject"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:377
+#, fuzzy, c-format
+msgid "subject"
+msgstr "Verwendungszweck"
+
+#: src/pages/PaytoWireTransferForm.tsx:390
+#, c-format
+msgid "some text to identify the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:400
+#, c-format
+msgid "Amount"
+msgstr "Betrag"
+
+#: src/pages/PaytoWireTransferForm.tsx:415
+#, fuzzy, c-format
+msgid "amount to transfer"
+msgstr "Betrag"
+
+#: src/pages/PaytoWireTransferForm.tsx:425
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:436
+#, c-format
+msgid "uniform resource identifier of the target account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:437
+#, c-format
+msgid "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:457
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:471
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:71
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:75
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:104
+#, c-format
+msgid "Wrong credentials for \"%1$s\""
+msgstr ""
+
+#: src/pages/LoginForm.tsx:111
+#, c-format
+msgid "Account not found"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:142
+#, c-format
+msgid "Username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:156
+#, c-format
+msgid "username of the account"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:175
+#, c-format
+msgid "Password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:188
+#, fuzzy, c-format
+msgid "password of the account"
+msgstr "Buchungen auf öffentlich sichtbaren Konten"
+
+#: src/pages/LoginForm.tsx:223
+#, c-format
+msgid "Check"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:237
+#, c-format
+msgid "Log in"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:249
+#, c-format
+msgid "Register"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:52
+#, c-format
+msgid "Latest transactions"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:63
+#, c-format
+msgid "Date"
+msgstr "Datum"
+
+#: src/components/Transactions/views.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr "Empfänger"
+
+#: src/components/Transactions/views.tsx:75
+#, c-format
+msgid "Subject"
+msgstr "Verwendungszweck"
+
+#: src/components/Transactions/views.tsx:111
+#, c-format
+msgid "sent"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:112
+#, c-format
+msgid "received"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:127
+#, c-format
+msgid "invalid value"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "to"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "from"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:202
+#, c-format
+msgid "First page"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:209
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:86
+#, c-format
+msgid "Wire transfer completed!"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:93
+#, c-format
+msgid "The withdrawal has been aborted previously and can't be confirmed"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:100
+#, c-format
+msgid ""
+"The withdrawal operation can't be confirmed before a wallet accepted the "
+"transaction."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:107
+#, c-format
+msgid "The operation id is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:114
+#, c-format
+msgid "The operation was not found."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:121
+#, c-format
+msgid "Your balance is not enough for the operation."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:155
+#, c-format
+msgid ""
+"The reserve operation has been confirmed previously and can't be aborted"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:186
+#, fuzzy, c-format
+msgid "Confirm the withdrawal operation"
+msgstr "Abhebung bestätigen"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:203
+#, c-format
+msgid "Wire transfer details"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:217
+#, c-format
+msgid "Taler Exchange operator's account"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:228
+#, c-format
+msgid "Taler Exchange operator's name"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:317
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:342
+#, c-format
+msgid "Authentication required"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:352
+#, c-format
+msgid "This operation was created with other username"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:209
+#, c-format
+msgid ""
+"Unauthorized to make the operation, maybe the session has expired or the "
+"password changed."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:218
+#, c-format
+msgid "The operation was rejected due to insufficient funds."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:268
+#, c-format
+msgid "Withdrawal confirmed"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:272
+#, c-format
+msgid ""
+"The wire transfer to the Taler operator has been initiated. You will soon "
+"receive the requested amount in your Taler wallet."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:287
+#, c-format
+msgid "Do not show this again"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:319
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:399
+#, c-format
+msgid "On this device"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:404
+#, c-format
+msgid ""
+"If you are using a web browser on desktop you should access your wallet with "
+"the GNU Taler WebExtension now or click the link if your WebExtension have "
+"the \"Inject Taler support\" option enabled."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:417
+#, c-format
+msgid "Start"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:426
+#, c-format
+msgid "On a mobile phone"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:431
+#, c-format
+msgid "Scan the QR code with your mobile device."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:73
+#, c-format
+msgid "There is an operation already"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:75
+#, fuzzy, c-format
+msgid "Complete or cancel the operation in"
+msgstr "Abhebung bestätigen"
+
+#: src/pages/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "this page"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:101
+#, c-format
+msgid "invalid"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:116
+#, c-format
+msgid "Server responded with an invalid withdraw URI"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:117
+#, c-format
+msgid "Withdraw URI: %1$s"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:132
+#, c-format
+msgid "The operation was rejected due to insufficient funds"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:253
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:282
+#, c-format
+msgid "Prepare your wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:285
+#, c-format
+msgid ""
+"After using your wallet you will need to confirm or cancel the operation on "
+"this site."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:295
+#, c-format
+msgid "You need a GNU Taler Wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:300
+#, c-format
+msgid "If you don't have one yet you can follow the instruction in"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:55
+#, c-format
+msgid "Send money"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:73
+#, c-format
+msgid "to a %1$s wallet"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:95
+#, c-format
+msgid "Withdraw digital money into your mobile wallet or browser extension"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:109
+#, c-format
+msgid "operation ready"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:129
+#, c-format
+msgid "to another bank account"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:149
+#, c-format
+msgid "Make a wire transfer to an account with known bank account number."
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:171
+#, c-format
+msgid "Transfer details"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:41
+#, c-format
+msgid "This is a demo bank"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:46
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work. In addition to using your own bank account, you can also see the "
+"transaction history of some %1$s."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:53
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:70
+#, c-format
+msgid "Pending account delete operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:72
+#, c-format
+msgid "Pending account update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:74
+#, c-format
+msgid "Pending password update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:76
+#, c-format
+msgid "Pending transaction operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:78
+#, c-format
+msgid "Pending withdrawal operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:80
+#, c-format
+msgid "Pending cashout operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:91
+#, c-format
+msgid "You can complete or cancel the operation in"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:64
+#, c-format
+msgid "Internal error, please report."
+msgstr ""
+
+#: src/pages/BankFrame.tsx:100
+#, c-format
+msgid "Preferences"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:184
+#, c-format
+msgid "Welcome, %1$s"
+msgstr ""
+
+#: src/pages/WireTransfer.tsx:79
+#, c-format
+msgid "Make a wire transfer"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:72
+#, fuzzy, c-format
+msgid "Accounts"
+msgstr "Betrag"
+
+#: src/pages/admin/AccountList.tsx:75
+#, c-format
+msgid "A list of all business account in the bank."
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:86
+#, c-format
+msgid "Create account"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:106
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:110
+#, c-format
+msgid "Balance"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:112
+#, c-format
+msgid "Actions"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:151
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:170
+#, c-format
+msgid "change password"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:179
+#, c-format
+msgid "cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:189
+#, c-format
+msgid "remove"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:168
+#, c-format
+msgid "Cashout not implemented"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:184
+#, c-format
+msgid "Select a section"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:202
+#, c-format
+msgid "Last hour"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:208
+#, c-format
+msgid "Last day"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:216
+#, c-format
+msgid "Last month"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:222
+#, c-format
+msgid "Last year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:310
+#, c-format
+msgid "Last Year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:325
+#, c-format
+msgid "Trading volume on %1$s compared to %2$s"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:342
+#, c-format
+msgid "Cashin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:352
+#, c-format
+msgid "Cashout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:364
+#, c-format
+msgid "Payin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:374
+#, c-format
+msgid "Payout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:388
+#, c-format
+msgid "download stats as CSV"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:494
+#, c-format
+msgid "Decreased by"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:498
+#, c-format
+msgid "Increased by"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:89
+#, c-format
+msgid "Download bank stats"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:110
+#, c-format
+msgid "Include hour metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:143
+#, c-format
+msgid "Include day metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:173
+#, c-format
+msgid "Include month metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:206
+#, c-format
+msgid "Include year metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:239
+#, c-format
+msgid "Include table header"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:272
+#, c-format
+msgid "Add previous metric for compare"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:307
+#, c-format
+msgid "Fail on first error"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:364
+#, c-format
+msgid "Download"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:381
+#, c-format
+msgid "downloading... %1$s"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:399
+#, c-format
+msgid "Download completed"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:400
+#, c-format
+msgid "click here to save the file in your computer"
+msgstr ""
+
+#: src/pages/PublicHistoriesPage.tsx:78
+#, c-format
+msgid "History of public accounts"
+msgstr "Buchungen auf öffentlich sichtbaren Konten"
+
+#: src/pages/RegistrationPage.tsx:48
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:87
+#, c-format
+msgid "Missing name"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:91
+#, c-format
+msgid "Use letters and numbers only, and start with a lowercase letter"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:107
+#, c-format
+msgid "Passwords don't match"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:130
+#, c-format
+msgid "Server replied with invalid phone or email."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:137
+#, c-format
+msgid "Registration is disabled because the bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:144
+#, c-format
+msgid "No enough permission to create that account."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:151
+#, c-format
+msgid "That account id is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:158
+#, c-format
+msgid "That username is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:165
+#, c-format
+msgid "That username can't be used because is reserved."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:172
+#, c-format
+msgid "Only admin is allow to set debt limit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:179
+#, c-format
+msgid "No information for the selected authentication channel."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:186
+#, c-format
+msgid "Authentication channel is not supported."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:193
+#, c-format
+msgid "Only admin can create accounts with second factor authentication."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:233
+#, c-format
+msgid "Account registration"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:315
+#, c-format
+msgid "Repeat password"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:457
+#, c-format
+msgid "Create a random temporary user"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:110
+#, c-format
+msgid "If you have a Taler wallet installed in this device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:116
+#, c-format
+msgid ""
+"You will see the details of the operation in your wallet including the fees "
+"(if applies). If you still don't have one you can install it following "
+"instructions in"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:143
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:152
+#, c-format
+msgid "Or if you have the wallet in another device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:157
+#, c-format
+msgid "Scan the QR below to start the withdrawal."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:79
+#, c-format
+msgid "Operation aborted"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:82
+#, c-format
+msgid ""
+"The wire transfer to the Taler Exchange operator's account was aborted, your "
+"balance was not affected."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "You can close this page now or continue to the account page."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:147
+#, c-format
+msgid "Done"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:158
+#, c-format
+msgid "Operation canceled"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:173
+#, c-format
+msgid ""
+"The operation is marked as 'selected' but some step in the withdrawal failed"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:175
+#, c-format
+msgid "The account is selected but no withdrawal identification found."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:188
+#, c-format
+msgid ""
+"There is a withdrawal identification but no account has been selected or the "
+"selected account is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:202
+#, c-format
+msgid ""
+"No withdrawal ID found and no account has been selected or the selected "
+"account is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:259
+#, c-format
+msgid "Operation not found"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:263
+#, c-format
+msgid ""
+"This operation is not known by the server. The operation id is wrong or the "
+"server deleted the operation information before reaching here."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:278
+#, c-format
+msgid "Cotinue to dashboard"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:98
+#, c-format
+msgid "Cashout not found. It may be also mean that it was already aborted."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:136
+#, c-format
+msgid "Challenge not found."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:143
+#, c-format
+msgid "This user is not authorized to complete this challenge."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:150
+#, c-format
+msgid "Too many attempts, try another code."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:157
+#, c-format
+msgid "The confirmation code is wrong, try again."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:164
+#, c-format
+msgid "The operation expired."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:197
+#, c-format
+msgid "The operation failed."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:212
+#, c-format
+msgid "The operation needs another confirmation to complete."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:224
+#, c-format
+msgid "Account delete"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:226
+#, c-format
+msgid "Account update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:228
+#, c-format
+msgid "Password update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:230
+#, c-format
+msgid "Wire transfer"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:232
+#, fuzzy, c-format
+msgid "Withdrawal"
+msgstr "Abhebung bestätigen"
+
+#: src/pages/SolveChallengePage.tsx:248
+#, fuzzy, c-format
+msgid "Confirm the operation"
+msgstr "Abhebung bestätigen"
+
+#: src/pages/SolveChallengePage.tsx:271
+#, c-format
+msgid "Enter the confirmation code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:313
+#, c-format
+msgid "Confirm"
+msgstr "Bestätigen"
+
+#: src/pages/SolveChallengePage.tsx:348
+#, c-format
+msgid "Send again"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:359
+#, c-format
+msgid "Send code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:369
+#, c-format
+msgid "Operation details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:529
+#, c-format
+msgid "Challenge details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:536
+#, c-format
+msgid "Sent at"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:551
+#, c-format
+msgid "To phone"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:553
+#, c-format
+msgid "To email"
+msgstr ""
+
+#: src/pages/WithdrawalOperationPage.tsx:49
+#, c-format
+msgid "The Withdrawal URI is not valid"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:100
+#, c-format
+msgid "Latest cashouts"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:111
+#, c-format
+msgid "Created"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:115
+#, c-format
+msgid "Total debit"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:119
+#, c-format
+msgid "Total credit"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:70
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:74
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:78
+#, c-format
+msgid "Credentials"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:82
+#, c-format
+msgid "Cashouts"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:95
+#, c-format
+msgid "Unable to create a cashout"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:96
+#, c-format
+msgid "The bank configuration does not support cashout operations."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:223
+#, c-format
+msgid "need to be higher due to fees"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:225
+#, c-format
+msgid "the total transfer at destination will be zero"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:250
+#, c-format
+msgid "Cashout created"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:272
+#, c-format
+msgid ""
+"Duplicated request detected, check if the operation succeeded or try again."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:279
+#, c-format
+msgid "The conversion rate was incorrectly applied"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:286
+#, c-format
+msgid "The account does not have sufficient funds"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:293
+#, c-format
+msgid "Cashouts are not supported"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:300
+#, c-format
+msgid "Missing cashout URI in the profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:307
+#, c-format
+msgid ""
+"Sending the confirmation message failed, retry later or contact the "
+"administrator."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:339
+#, c-format
+msgid "Conversion rate"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:360
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:374
+#, c-format
+msgid "To account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:381
+#, c-format
+msgid "No cashout account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:382
+#, c-format
+msgid "Before doing a cashout you need to complete your profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:440
+#, fuzzy, c-format
+msgid "Amount to send"
+msgstr "Betrag"
+
+#: src/pages/business/CreateCashout.tsx:441
+#, c-format
+msgid "Amount to receive"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:490
+#, c-format
+msgid "Total cost"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:505
+#, c-format
+msgid "Balance left"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:520
+#, c-format
+msgid "Before fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:533
+#, c-format
+msgid "Total cashout transfer"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:553
+#, c-format
+msgid "No cashout channel available"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:555
+#, c-format
+msgid ""
+"Before doing a cashout the server need to provide an second channel to "
+"confirm the operation"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:567
+#, c-format
+msgid "Second factor authentication"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:598
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:600
+#, c-format
+msgid "add a email in your profile to enable this option"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:646
+#, c-format
+msgid "SMS"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:648
+#, c-format
+msgid "add a phone number in your profile to enable this option"
+msgstr ""
+
+#: src/pages/account/CashoutListForAccount.tsx:52
+#, c-format
+msgid "Cashout for account %1$s"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:165
+#, c-format
+msgid "it doesn't have the pattern of an IBAN number"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:185
+#, c-format
+msgid "it doesn't have the pattern of an email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:190
+#, c-format
+msgid "should start with +"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:192
+#, c-format
+msgid "phone number can't have other than numbers"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:329
+#, c-format
+msgid "account identification in the bank"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:365
+#, c-format
+msgid "name of the person owner the account"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:374
+#, c-format
+msgid "Internal IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:377
+#, c-format
+msgid "if empty a random account number will be assigned"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:378
+#, c-format
+msgid "account identification for bank transfer"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:423
+#, c-format
+msgid "Phone"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:451
+#, c-format
+msgid "Cashout IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:452
+#, c-format
+msgid "account number where the money is going to be sent when doing cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:470
+#, c-format
+msgid "Max debt"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:494
+#, c-format
+msgid "how much is user able to transfer after zero balance"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:508
+#, c-format
+msgid "Is this a Taler Exchange?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:549
+#, c-format
+msgid "This server doesn't support second factor authentication."
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:560
+#, c-format
+msgid "Enable second factor authentication"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:596
+#, c-format
+msgid "Using email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:654
+#, c-format
+msgid "Using SMS"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:691
+#, c-format
+msgid "Is this account public?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:719
+#, c-format
+msgid "public accounts have their balance publicly accessible"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:100
+#, c-format
+msgid "Account updated"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:107
+#, c-format
+msgid "The rights to change the account are not sufficient"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:114
+#, c-format
+msgid "The username was not found"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:121
+#, c-format
+msgid ""
+"You can't change the legal name, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:128
+#, c-format
+msgid ""
+"You can't change the debt limit, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:135
+#, c-format
+msgid ""
+"You can't change the cashout address, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:177
+#, c-format
+msgid "Account \"%1$s\""
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:190
+#, c-format
+msgid "Change details"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:235
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:78
+#, c-format
+msgid "password doesn't match"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:95
+#, c-format
+msgid "Password changed"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:102
+#, c-format
+msgid "Not authorized to change the password, maybe the session is invalid."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:112
+#, c-format
+msgid ""
+"You need to provide the old password. If you don't have it contact your "
+"account administrator."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:117
+#, c-format
+msgid "Your current password doesn't match, can't change to a new password."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:149
+#, c-format
+msgid "Update password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:167
+#, c-format
+msgid "New password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:195
+#, c-format
+msgid "Type it again"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:217
+#, c-format
+msgid "repeat the same password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:227
+#, c-format
+msgid "Current password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:248
+#, c-format
+msgid "your current password, for security"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:272
+#, c-format
+msgid "Change"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:74
+#, c-format
+msgid ""
+"Account created with password \"%1$s\". The user must change the password on "
+"the next login."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:83
+#, c-format
+msgid "Server replied that phone or email is invalid"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:90
+#, c-format
+msgid "The rights to perform the operation are not sufficient"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:97
+#, c-format
+msgid "Account username is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:104
+#, c-format
+msgid "Account id is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:111
+#, c-format
+msgid "Bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:118
+#, c-format
+msgid "Account username can't be used because is reserved"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:160
+#, c-format
+msgid "Can't create accounts"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:161
+#, c-format
+msgid "Only system admin can create accounts."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:183
+#, c-format
+msgid "New business account"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:209
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:94
+#, c-format
+msgid "Can't delete the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:95
+#, c-format
+msgid ""
+"The account can't be delete while still holding some balance. First make "
+"sure that the owner make a complete cashout."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:117
+#, c-format
+msgid "Account removed"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:124
+#, c-format
+msgid "No enough permission to delete the account."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:131
+#, c-format
+msgid "The username was not found."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:138
+#, c-format
+msgid "Can't delete a reserved username."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:145
+#, c-format
+msgid "Can't delete an account with balance different than zero."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:170
+#, c-format
+msgid "name doesn't match"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:180
+#, c-format
+msgid "You are going to remove the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:182
+#, c-format
+msgid "This step can't be undone."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:188
+#, c-format
+msgid "Deleting account \"%1$s\""
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:206
+#, c-format
+msgid "Verification"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:231
+#, c-format
+msgid "enter the account name that is going to be deleted"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:49
+#, c-format
+msgid "cashout id should be a number"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:65
+#, c-format
+msgid "This cashout not found. Maybe already aborted."
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:106
+#, c-format
+msgid "Cashout detail"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:139
+#, c-format
+msgid "Debited"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:154
+#, c-format
+msgid "Credited"
+msgstr ""
+
+#: src/Routing.tsx:140
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
+
+#, fuzzy, c-format
+#~ msgid "Confirmed"
+#~ msgstr "Bestätigen"
+
+#, c-format
+#~ msgid "Logout"
+#~ msgstr "Abmelden"
+
+#, c-format
+#~ msgid "Skip to main content"
+#~ msgstr "Navigationsmenü überspringen"
+
+#, c-format
+#~ msgid "Taler logo"
+#~ msgstr "Taler-Logo"
+
+#, c-format
+#~ msgid "Please login!"
+#~ msgstr "Bitte melden Sie sich an!"
+
+#, c-format
+#~ msgid "payto address"
+#~ msgstr "payto-Adresse"
+
+#, c-format
+#~ msgid "Bank account balance"
+#~ msgstr "Kontostand"
diff --git a/packages/bank-ui/src/i18n/en.po b/packages/bank-ui/src/i18n/en.po
new file mode 100644
index 000000000..a9657bd32
--- /dev/null
+++ b/packages/bank-ui/src/i18n/en.po
@@ -0,0 +1,1784 @@
+# This file is part of GNU Taler
+# (C) 2021 Taler Systems S.A.
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2022-01-08 09:57+0100\n"
+"Last-Translator: <translate@taler.net>\n"
+"Language-Team: English\n"
+"Language: en\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: src/utils.ts:137
+#, c-format
+msgid "Operation failed, please report"
+msgstr ""
+
+#: src/utils.ts:156
+#, c-format
+msgid "Request timeout"
+msgstr ""
+
+#: src/utils.ts:165
+#, c-format
+msgid "Request throttled"
+msgstr ""
+
+#: src/utils.ts:174
+#, c-format
+msgid "Malformed response"
+msgstr ""
+
+#: src/utils.ts:183
+#, c-format
+msgid "Network error"
+msgstr ""
+
+#: src/utils.ts:192
+#, c-format
+msgid "Unexpected request error"
+msgstr ""
+
+#: src/utils.ts:201
+#, c-format
+msgid "Unexpected error"
+msgstr ""
+
+#: src/utils.ts:377
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/utils.ts:379
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/utils.ts:387
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/utils.ts:401
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/context/config.ts:136
+#, c-format
+msgid ""
+"the bank backend is not supported. supported version \"%1$s\", server "
+"version \"%2$s\""
+msgstr ""
+
+#: src/hooks/preferences.ts:55
+#, fuzzy, c-format
+msgid "Max withdrawal amount"
+msgstr ""
+
+#: src/hooks/preferences.ts:57
+#, c-format
+msgid "Show withdrawal confirmation"
+msgstr ""
+
+#: src/hooks/preferences.ts:59
+#, c-format
+msgid "Show demo description"
+msgstr ""
+
+#: src/hooks/preferences.ts:61
+#, c-format
+msgid "Show install wallet first"
+msgstr ""
+
+#: src/hooks/preferences.ts:63
+#, fuzzy, c-format
+msgid "Use fast withdrawal form"
+msgstr ""
+
+#: src/hooks/preferences.ts:65
+#, c-format
+msgid "Show debug info"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:90
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:92
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:98
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:100
+#, c-format
+msgid "should be greater than 0"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "balance is not enough"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:112
+#, c-format
+msgid "does not follow the pattern"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:114
+#, c-format
+msgid "only \"IBAN\" target are supported"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:116
+#, c-format
+msgid "use the \"amount\" parameter to specify the amount to be transferred"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:118
+#, c-format
+msgid "the amount is not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:120
+#, c-format
+msgid ""
+"use the \"message\" parameter to specify a reference text for the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:160
+#, c-format
+msgid "The request was invalid or the payto://-URI used unacceptable features."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:167
+#, c-format
+msgid "Not enough permission to complete the operation."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:174
+#, c-format
+msgid "The destination account \"%1$s\" was not found."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:181
+#, c-format
+msgid "The origin and the destination of the transfer can't be the same."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:188
+#, c-format
+msgid "Your balance is not enough."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:195
+#, c-format
+msgid "The origin account \"%1$s\" was not found."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:212
+#, c-format
+msgid "Wire transfer created!"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:270
+#, c-format
+msgid "Using a form"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:310
+#, c-format
+msgid "Import payto:// URI"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:335
+#, c-format
+msgid "Recipient"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:359
+#, c-format
+msgid "IBAN of the recipient's account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:369
+#, c-format
+msgid "Transfer subject"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:377
+#, c-format
+msgid "subject"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:390
+#, c-format
+msgid "some text to identify the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:400
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:415
+#, fuzzy, c-format
+msgid "amount to transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:425
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:436
+#, c-format
+msgid "uniform resource identifier of the target account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:437
+#, c-format
+msgid "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:457
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:471
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:71
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:75
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:104
+#, c-format
+msgid "Wrong credentials for \"%1$s\""
+msgstr ""
+
+#: src/pages/LoginForm.tsx:111
+#, c-format
+msgid "Account not found"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:142
+#, c-format
+msgid "Username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:156
+#, c-format
+msgid "username of the account"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:175
+#, c-format
+msgid "Password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:188
+#, c-format
+msgid "password of the account"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:223
+#, c-format
+msgid "Check"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:237
+#, c-format
+msgid "Log in"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:249
+#, c-format
+msgid "Register"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:52
+#, c-format
+msgid "Latest transactions"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:63
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:75
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:111
+#, c-format
+msgid "sent"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:112
+#, c-format
+msgid "received"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:127
+#, c-format
+msgid "invalid value"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "to"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "from"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:202
+#, c-format
+msgid "First page"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:209
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:86
+#, c-format
+msgid "Wire transfer completed!"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:93
+#, c-format
+msgid "The withdrawal has been aborted previously and can't be confirmed"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:100
+#, c-format
+msgid ""
+"The withdrawal operation can't be confirmed before a wallet accepted the "
+"transaction."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:107
+#, c-format
+msgid "The operation id is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:114
+#, c-format
+msgid "The operation was not found."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:121
+#, c-format
+msgid "Your balance is not enough for the operation."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:155
+#, c-format
+msgid ""
+"The reserve operation has been confirmed previously and can't be aborted"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:186
+#, fuzzy, c-format
+msgid "Confirm the withdrawal operation"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:203
+#, c-format
+msgid "Wire transfer details"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:217
+#, c-format
+msgid "Taler Exchange operator's account"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:228
+#, c-format
+msgid "Taler Exchange operator's name"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:317
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:342
+#, c-format
+msgid "Authentication required"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:352
+#, c-format
+msgid "This operation was created with other username"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:209
+#, c-format
+msgid ""
+"Unauthorized to make the operation, maybe the session has expired or the "
+"password changed."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:218
+#, c-format
+msgid "The operation was rejected due to insufficient funds."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:268
+#, c-format
+msgid "Withdrawal confirmed"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:272
+#, c-format
+msgid ""
+"The wire transfer to the Taler operator has been initiated. You will soon "
+"receive the requested amount in your Taler wallet."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:287
+#, c-format
+msgid "Do not show this again"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:319
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:399
+#, c-format
+msgid "On this device"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:404
+#, c-format
+msgid ""
+"If you are using a web browser on desktop you should access your wallet with "
+"the GNU Taler WebExtension now or click the link if your WebExtension have "
+"the \"Inject Taler support\" option enabled."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:417
+#, c-format
+msgid "Start"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:426
+#, c-format
+msgid "On a mobile phone"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:431
+#, c-format
+msgid "Scan the QR code with your mobile device."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:73
+#, c-format
+msgid "There is an operation already"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:75
+#, fuzzy, c-format
+msgid "Complete or cancel the operation in"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "this page"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:101
+#, c-format
+msgid "invalid"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:116
+#, c-format
+msgid "Server responded with an invalid withdraw URI"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:117
+#, fuzzy, c-format
+msgid "Withdraw URI: %1$s"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:132
+#, c-format
+msgid "The operation was rejected due to insufficient funds"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:253
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:282
+#, c-format
+msgid "Prepare your wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:285
+#, c-format
+msgid ""
+"After using your wallet you will need to confirm or cancel the operation on "
+"this site."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:295
+#, fuzzy, c-format
+msgid "You need a GNU Taler Wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:300
+#, c-format
+msgid "If you don't have one yet you can follow the instruction in"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:55
+#, c-format
+msgid "Send money"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:73
+#, c-format
+msgid "to a %1$s wallet"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:95
+#, c-format
+msgid "Withdraw digital money into your mobile wallet or browser extension"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:109
+#, c-format
+msgid "operation ready"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:129
+#, c-format
+msgid "to another bank account"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:149
+#, c-format
+msgid "Make a wire transfer to an account with known bank account number."
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:171
+#, fuzzy, c-format
+msgid "Transfer details"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:41
+#, c-format
+msgid "This is a demo bank"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:46
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work. In addition to using your own bank account, you can also see the "
+"transaction history of some %1$s."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:53
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:70
+#, c-format
+msgid "Pending account delete operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:72
+#, c-format
+msgid "Pending account update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:74
+#, c-format
+msgid "Pending password update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:76
+#, c-format
+msgid "Pending transaction operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:78
+#, c-format
+msgid "Pending withdrawal operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:80
+#, c-format
+msgid "Pending cashout operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:91
+#, c-format
+msgid "You can complete or cancel the operation in"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:64
+#, c-format
+msgid "Internal error, please report."
+msgstr ""
+
+#: src/pages/BankFrame.tsx:100
+#, c-format
+msgid "Preferences"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:184
+#, c-format
+msgid "Welcome, %1$s"
+msgstr ""
+
+#: src/pages/WireTransfer.tsx:79
+#, c-format
+msgid "Make a wire transfer"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:72
+#, c-format
+msgid "Accounts"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:75
+#, c-format
+msgid "A list of all business account in the bank."
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:86
+#, c-format
+msgid "Create account"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:106
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:110
+#, c-format
+msgid "Balance"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:112
+#, c-format
+msgid "Actions"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:151
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:170
+#, c-format
+msgid "change password"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:179
+#, c-format
+msgid "cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:189
+#, c-format
+msgid "remove"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:168
+#, c-format
+msgid "Cashout not implemented"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:184
+#, c-format
+msgid "Select a section"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:202
+#, c-format
+msgid "Last hour"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:208
+#, c-format
+msgid "Last day"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:216
+#, c-format
+msgid "Last month"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:222
+#, c-format
+msgid "Last year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:310
+#, c-format
+msgid "Last Year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:325
+#, c-format
+msgid "Trading volume on %1$s compared to %2$s"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:342
+#, c-format
+msgid "Cashin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:352
+#, c-format
+msgid "Cashout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:364
+#, c-format
+msgid "Payin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:374
+#, c-format
+msgid "Payout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:388
+#, c-format
+msgid "download stats as CSV"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:494
+#, c-format
+msgid "Decreased by"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:498
+#, c-format
+msgid "Increased by"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:89
+#, c-format
+msgid "Download bank stats"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:110
+#, c-format
+msgid "Include hour metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:143
+#, c-format
+msgid "Include day metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:173
+#, c-format
+msgid "Include month metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:206
+#, c-format
+msgid "Include year metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:239
+#, c-format
+msgid "Include table header"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:272
+#, c-format
+msgid "Add previous metric for compare"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:307
+#, c-format
+msgid "Fail on first error"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:364
+#, c-format
+msgid "Download"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:381
+#, c-format
+msgid "downloading... %1$s"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:399
+#, c-format
+msgid "Download completed"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:400
+#, c-format
+msgid "click here to save the file in your computer"
+msgstr ""
+
+#: src/pages/PublicHistoriesPage.tsx:78
+#, c-format
+msgid "History of public accounts"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:48
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:87
+#, c-format
+msgid "Missing name"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:91
+#, c-format
+msgid "Use letters and numbers only, and start with a lowercase letter"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:107
+#, c-format
+msgid "Passwords don't match"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:130
+#, c-format
+msgid "Server replied with invalid phone or email."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:137
+#, c-format
+msgid "Registration is disabled because the bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:144
+#, c-format
+msgid "No enough permission to create that account."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:151
+#, c-format
+msgid "That account id is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:158
+#, c-format
+msgid "That username is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:165
+#, c-format
+msgid "That username can't be used because is reserved."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:172
+#, c-format
+msgid "Only admin is allow to set debt limit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:179
+#, c-format
+msgid "No information for the selected authentication channel."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:186
+#, c-format
+msgid "Authentication channel is not supported."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:193
+#, c-format
+msgid "Only admin can create accounts with second factor authentication."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:233
+#, c-format
+msgid "Account registration"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:315
+#, c-format
+msgid "Repeat password"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:457
+#, c-format
+msgid "Create a random temporary user"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:110
+#, c-format
+msgid "If you have a Taler wallet installed in this device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:116
+#, c-format
+msgid ""
+"You will see the details of the operation in your wallet including the fees "
+"(if applies). If you still don't have one you can install it following "
+"instructions in"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:143
+#, fuzzy, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:152
+#, c-format
+msgid "Or if you have the wallet in another device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:157
+#, fuzzy, c-format
+msgid "Scan the QR below to start the withdrawal."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:79
+#, c-format
+msgid "Operation aborted"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:82
+#, c-format
+msgid ""
+"The wire transfer to the Taler Exchange operator's account was aborted, your "
+"balance was not affected."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "You can close this page now or continue to the account page."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:147
+#, c-format
+msgid "Done"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:158
+#, c-format
+msgid "Operation canceled"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:173
+#, c-format
+msgid ""
+"The operation is marked as 'selected' but some step in the withdrawal failed"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:175
+#, c-format
+msgid "The account is selected but no withdrawal identification found."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:188
+#, c-format
+msgid ""
+"There is a withdrawal identification but no account has been selected or the "
+"selected account is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:202
+#, c-format
+msgid ""
+"No withdrawal ID found and no account has been selected or the selected "
+"account is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:259
+#, c-format
+msgid "Operation not found"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:263
+#, c-format
+msgid ""
+"This operation is not known by the server. The operation id is wrong or the "
+"server deleted the operation information before reaching here."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:278
+#, c-format
+msgid "Cotinue to dashboard"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:98
+#, c-format
+msgid "Cashout not found. It may be also mean that it was already aborted."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:136
+#, c-format
+msgid "Challenge not found."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:143
+#, c-format
+msgid "This user is not authorized to complete this challenge."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:150
+#, c-format
+msgid "Too many attempts, try another code."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:157
+#, c-format
+msgid "The confirmation code is wrong, try again."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:164
+#, c-format
+msgid "The operation expired."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:197
+#, c-format
+msgid "The operation failed."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:212
+#, c-format
+msgid "The operation needs another confirmation to complete."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:224
+#, c-format
+msgid "Account delete"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:226
+#, c-format
+msgid "Account update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:228
+#, c-format
+msgid "Password update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:230
+#, c-format
+msgid "Wire transfer"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:232
+#, fuzzy, c-format
+msgid "Withdrawal"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:248
+#, fuzzy, c-format
+msgid "Confirm the operation"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:271
+#, c-format
+msgid "Enter the confirmation code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:313
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:348
+#, c-format
+msgid "Send again"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:359
+#, c-format
+msgid "Send code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:369
+#, c-format
+msgid "Operation details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:529
+#, c-format
+msgid "Challenge details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:536
+#, c-format
+msgid "Sent at"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:551
+#, c-format
+msgid "To phone"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:553
+#, c-format
+msgid "To email"
+msgstr ""
+
+#: src/pages/WithdrawalOperationPage.tsx:49
+#, c-format
+msgid "The Withdrawal URI is not valid"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:100
+#, c-format
+msgid "Latest cashouts"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:111
+#, c-format
+msgid "Created"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:115
+#, c-format
+msgid "Total debit"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:119
+#, c-format
+msgid "Total credit"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:70
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:74
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:78
+#, c-format
+msgid "Credentials"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:82
+#, c-format
+msgid "Cashouts"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:95
+#, c-format
+msgid "Unable to create a cashout"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:96
+#, c-format
+msgid "The bank configuration does not support cashout operations."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:223
+#, c-format
+msgid "need to be higher due to fees"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:225
+#, c-format
+msgid "the total transfer at destination will be zero"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:250
+#, c-format
+msgid "Cashout created"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:272
+#, c-format
+msgid ""
+"Duplicated request detected, check if the operation succeeded or try again."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:279
+#, c-format
+msgid "The conversion rate was incorrectly applied"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:286
+#, c-format
+msgid "The account does not have sufficient funds"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:293
+#, c-format
+msgid "Cashouts are not supported"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:300
+#, c-format
+msgid "Missing cashout URI in the profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:307
+#, c-format
+msgid ""
+"Sending the confirmation message failed, retry later or contact the "
+"administrator."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:339
+#, c-format
+msgid "Conversion rate"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:360
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:374
+#, c-format
+msgid "To account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:381
+#, c-format
+msgid "No cashout account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:382
+#, c-format
+msgid "Before doing a cashout you need to complete your profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:440
+#, fuzzy, c-format
+msgid "Amount to send"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:441
+#, fuzzy, c-format
+msgid "Amount to receive"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:490
+#, c-format
+msgid "Total cost"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:505
+#, c-format
+msgid "Balance left"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:520
+#, c-format
+msgid "Before fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:533
+#, c-format
+msgid "Total cashout transfer"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:553
+#, c-format
+msgid "No cashout channel available"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:555
+#, c-format
+msgid ""
+"Before doing a cashout the server need to provide an second channel to "
+"confirm the operation"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:567
+#, c-format
+msgid "Second factor authentication"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:598
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:600
+#, c-format
+msgid "add a email in your profile to enable this option"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:646
+#, c-format
+msgid "SMS"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:648
+#, c-format
+msgid "add a phone number in your profile to enable this option"
+msgstr ""
+
+#: src/pages/account/CashoutListForAccount.tsx:52
+#, c-format
+msgid "Cashout for account %1$s"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:165
+#, c-format
+msgid "it doesn't have the pattern of an IBAN number"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:185
+#, c-format
+msgid "it doesn't have the pattern of an email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:190
+#, c-format
+msgid "should start with +"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:192
+#, c-format
+msgid "phone number can't have other than numbers"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:329
+#, c-format
+msgid "account identification in the bank"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:365
+#, c-format
+msgid "name of the person owner the account"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:374
+#, c-format
+msgid "Internal IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:377
+#, c-format
+msgid "if empty a random account number will be assigned"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:378
+#, c-format
+msgid "account identification for bank transfer"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:423
+#, c-format
+msgid "Phone"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:451
+#, c-format
+msgid "Cashout IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:452
+#, c-format
+msgid "account number where the money is going to be sent when doing cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:470
+#, c-format
+msgid "Max debt"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:494
+#, c-format
+msgid "how much is user able to transfer after zero balance"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:508
+#, c-format
+msgid "Is this a Taler Exchange?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:549
+#, c-format
+msgid "This server doesn't support second factor authentication."
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:560
+#, c-format
+msgid "Enable second factor authentication"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:596
+#, c-format
+msgid "Using email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:654
+#, c-format
+msgid "Using SMS"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:691
+#, c-format
+msgid "Is this account public?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:719
+#, c-format
+msgid "public accounts have their balance publicly accessible"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:100
+#, c-format
+msgid "Account updated"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:107
+#, c-format
+msgid "The rights to change the account are not sufficient"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:114
+#, c-format
+msgid "The username was not found"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:121
+#, c-format
+msgid ""
+"You can't change the legal name, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:128
+#, c-format
+msgid ""
+"You can't change the debt limit, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:135
+#, c-format
+msgid ""
+"You can't change the cashout address, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:177
+#, c-format
+msgid "Account \"%1$s\""
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:190
+#, c-format
+msgid "Change details"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:235
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:78
+#, c-format
+msgid "password doesn't match"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:95
+#, c-format
+msgid "Password changed"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:102
+#, c-format
+msgid "Not authorized to change the password, maybe the session is invalid."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:112
+#, c-format
+msgid ""
+"You need to provide the old password. If you don't have it contact your "
+"account administrator."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:117
+#, c-format
+msgid "Your current password doesn't match, can't change to a new password."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:149
+#, c-format
+msgid "Update password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:167
+#, c-format
+msgid "New password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:195
+#, c-format
+msgid "Type it again"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:217
+#, c-format
+msgid "repeat the same password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:227
+#, c-format
+msgid "Current password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:248
+#, c-format
+msgid "your current password, for security"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:272
+#, c-format
+msgid "Change"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:74
+#, c-format
+msgid ""
+"Account created with password \"%1$s\". The user must change the password on "
+"the next login."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:83
+#, c-format
+msgid "Server replied that phone or email is invalid"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:90
+#, c-format
+msgid "The rights to perform the operation are not sufficient"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:97
+#, c-format
+msgid "Account username is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:104
+#, c-format
+msgid "Account id is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:111
+#, c-format
+msgid "Bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:118
+#, c-format
+msgid "Account username can't be used because is reserved"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:160
+#, c-format
+msgid "Can't create accounts"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:161
+#, c-format
+msgid "Only system admin can create accounts."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:183
+#, c-format
+msgid "New business account"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:209
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:94
+#, c-format
+msgid "Can't delete the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:95
+#, c-format
+msgid ""
+"The account can't be delete while still holding some balance. First make "
+"sure that the owner make a complete cashout."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:117
+#, c-format
+msgid "Account removed"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:124
+#, c-format
+msgid "No enough permission to delete the account."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:131
+#, c-format
+msgid "The username was not found."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:138
+#, c-format
+msgid "Can't delete a reserved username."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:145
+#, c-format
+msgid "Can't delete an account with balance different than zero."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:170
+#, c-format
+msgid "name doesn't match"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:180
+#, c-format
+msgid "You are going to remove the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:182
+#, c-format
+msgid "This step can't be undone."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:188
+#, c-format
+msgid "Deleting account \"%1$s\""
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:206
+#, c-format
+msgid "Verification"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:231
+#, c-format
+msgid "enter the account name that is going to be deleted"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:49
+#, c-format
+msgid "cashout id should be a number"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:65
+#, c-format
+msgid "This cashout not found. Maybe already aborted."
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:106
+#, c-format
+msgid "Cashout detail"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:139
+#, c-format
+msgid "Debited"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:154
+#, c-format
+msgid "Credited"
+msgstr ""
+
+#: src/Routing.tsx:140
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
+
+#, c-format
+#~ msgid "days"
+#~ msgstr ""
+
+#, c-format
+#~ msgid "hours"
+#~ msgstr ""
+
+#, c-format
+#~ msgid "minutes"
+#~ msgstr ""
+
+#, c-format
+#~ msgid "seconds"
+#~ msgstr ""
+
+#~ msgid "Go back"
+#~ msgstr ""
+
+#, fuzzy
+#~ msgid "Withdraw Money into a Taler wallet"
+#~ msgstr ""
+
+#~ msgid "Page has a problem: logged in but backend state is lost."
+#~ msgstr ""
+
+#, fuzzy
+#~ msgid "Welcome to the euFin bank!"
+#~ msgstr ""
+
+#~ msgid "Page has a problem:"
+#~ msgstr ""
+
+#~ msgid "Sign in"
+#~ msgstr ""
diff --git a/packages/bank-ui/src/i18n/es.po b/packages/bank-ui/src/i18n/es.po
new file mode 100644
index 000000000..fb69822c5
--- /dev/null
+++ b/packages/bank-ui/src/i18n/es.po
@@ -0,0 +1,2063 @@
+# This file is part of GNU Taler
+# (C) 2021 Taler Systems S.A.
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-02-13 14:40+0000\n"
+"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
+"Language-Team: Spanish <https://weblate.taler.net/projects/gnu-taler/"
+"taler-bank-spa/es/>\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/utils.ts:137
+#, c-format
+msgid "Operation failed, please report"
+msgstr "La operaicón falló, por favor reportelo"
+
+#: src/utils.ts:156
+#, c-format
+msgid "Request timeout"
+msgstr "La petición al servidor agoto su tiempo"
+
+#: src/utils.ts:165
+#, c-format
+msgid "Request throttled"
+msgstr "La petición al servidor interrumpida"
+
+#: src/utils.ts:174
+#, c-format
+msgid "Malformed response"
+msgstr "Respuesta malformada"
+
+#: src/utils.ts:183
+#, c-format
+msgid "Network error"
+msgstr "Error de conexión"
+
+#: src/utils.ts:192
+#, c-format
+msgid "Unexpected request error"
+msgstr "Error de pedido inesperado"
+
+#: src/utils.ts:201
+#, c-format
+msgid "Unexpected error"
+msgstr "Error inesperado"
+
+#: src/utils.ts:377
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr "Los números IBAN usualmente tienen mas de 4 digitos"
+
+#: src/utils.ts:379
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr "Los números IBAN usualmente tienen menos de 34 digitos"
+
+#: src/utils.ts:387
+#, c-format
+msgid "IBAN country code not found"
+msgstr "Código de pais de IBAN no encontrado"
+
+#: src/utils.ts:401
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr "El número IBAN no es válido, falló la verificación"
+
+#: src/context/config.ts:136
+#, c-format
+msgid ""
+"the bank backend is not supported. supported version \"%1$s\", server "
+"version \"%2$s\""
+msgstr ""
+"El servidor de bank no esta spoportado. Version soportada \"%1$s\", version "
+"del server \"%2$s\""
+
+#: src/hooks/preferences.ts:55
+#, c-format
+msgid "Max withdrawal amount"
+msgstr "Monto máximo de extracción"
+
+#: src/hooks/preferences.ts:57
+#, c-format
+msgid "Show withdrawal confirmation"
+msgstr "Mostrar confirmación de extracción"
+
+#: src/hooks/preferences.ts:59
+#, c-format
+msgid "Show demo description"
+msgstr "Mostrar descripción de demo"
+
+#: src/hooks/preferences.ts:61
+#, c-format
+msgid "Show install wallet first"
+msgstr "Mostrar instalar la billetera primero"
+
+#: src/hooks/preferences.ts:63
+#, c-format
+msgid "Use fast withdrawal form"
+msgstr "Usar formulario de extracción rápida"
+
+#: src/hooks/preferences.ts:65
+#, c-format
+msgid "Show debug info"
+msgstr "Mostrar información de depuración"
+
+#: src/pages/PaytoWireTransferForm.tsx:90
+#, c-format
+msgid "required"
+msgstr "requerido"
+
+#: src/pages/PaytoWireTransferForm.tsx:92
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr "IBAN debería tener letras mayúsculas y números"
+
+#: src/pages/PaytoWireTransferForm.tsx:98
+#, c-format
+msgid "not valid"
+msgstr "no válido"
+
+#: src/pages/PaytoWireTransferForm.tsx:100
+#, c-format
+msgid "should be greater than 0"
+msgstr "Debería ser mas grande que 0"
+
+#: src/pages/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "balance is not enough"
+msgstr "el saldo no es suficiente"
+
+#: src/pages/PaytoWireTransferForm.tsx:112
+#, c-format
+msgid "does not follow the pattern"
+msgstr "no tiene un patrón valido"
+
+#: src/pages/PaytoWireTransferForm.tsx:114
+#, c-format
+msgid "only \"IBAN\" target are supported"
+msgstr "solo cuentas \"IBAN\" son soportadas"
+
+#: src/pages/PaytoWireTransferForm.tsx:116
+#, c-format
+msgid "use the \"amount\" parameter to specify the amount to be transferred"
+msgstr "usa el parámetro \"amount\" para indicar el monto a ser transferido"
+
+#: src/pages/PaytoWireTransferForm.tsx:118
+#, c-format
+msgid "the amount is not valid"
+msgstr "el monto no es válido"
+
+#: src/pages/PaytoWireTransferForm.tsx:120
+#, c-format
+msgid ""
+"use the \"message\" parameter to specify a reference text for the transfer"
+msgstr ""
+"usa el parámetro \"message\" para indicar un texto de referencia en la "
+"transferencia"
+
+#: src/pages/PaytoWireTransferForm.tsx:160
+#, c-format
+msgid "The request was invalid or the payto://-URI used unacceptable features."
+msgstr ""
+"El pedido era inválido o el URI payto:// usado tiene características "
+"inaceptables."
+
+#: src/pages/PaytoWireTransferForm.tsx:167
+#, c-format
+msgid "Not enough permission to complete the operation."
+msgstr "Sin permisos suficientes para completar la operación."
+
+#: src/pages/PaytoWireTransferForm.tsx:174
+#, c-format
+msgid "The destination account \"%1$s\" was not found."
+msgstr "La cuenta de destino \"%1$s\" no fue encontrada."
+
+#: src/pages/PaytoWireTransferForm.tsx:181
+#, c-format
+msgid "The origin and the destination of the transfer can't be the same."
+msgstr "El origen y destino de la transferencia no puede ser la misma."
+
+#: src/pages/PaytoWireTransferForm.tsx:188
+#, c-format
+msgid "Your balance is not enough."
+msgstr "El saldo no es suficiente."
+
+#: src/pages/PaytoWireTransferForm.tsx:195
+#, c-format
+msgid "The origin account \"%1$s\" was not found."
+msgstr "La cuenta origen \"%1$s\" no fue encontrada."
+
+#: src/pages/PaytoWireTransferForm.tsx:212
+#, c-format
+msgid "Wire transfer created!"
+msgstr "Transferencia bancaria creada!"
+
+#: src/pages/PaytoWireTransferForm.tsx:270
+#, c-format
+msgid "Using a form"
+msgstr "Usando un formulario"
+
+#: src/pages/PaytoWireTransferForm.tsx:310
+#, c-format
+msgid "Import payto:// URI"
+msgstr "Importando un URI payto://"
+
+#: src/pages/PaytoWireTransferForm.tsx:335
+#, c-format
+msgid "Recipient"
+msgstr "Destinatario"
+
+#: src/pages/PaytoWireTransferForm.tsx:359
+#, c-format
+msgid "IBAN of the recipient's account"
+msgstr "Numero IBAN de la cuenta destinataria"
+
+#: src/pages/PaytoWireTransferForm.tsx:369
+#, c-format
+msgid "Transfer subject"
+msgstr "Asunto de transferencia"
+
+#: src/pages/PaytoWireTransferForm.tsx:377
+#, c-format
+msgid "subject"
+msgstr "asunto"
+
+#: src/pages/PaytoWireTransferForm.tsx:390
+#, c-format
+msgid "some text to identify the transfer"
+msgstr "algún texto para identificar la transferencia"
+
+#: src/pages/PaytoWireTransferForm.tsx:400
+#, c-format
+msgid "Amount"
+msgstr "Monto"
+
+#: src/pages/PaytoWireTransferForm.tsx:415
+#, c-format
+msgid "amount to transfer"
+msgstr "monto a transferir"
+
+#: src/pages/PaytoWireTransferForm.tsx:425
+#, c-format
+msgid "payto URI:"
+msgstr "payto URI:"
+
+#: src/pages/PaytoWireTransferForm.tsx:436
+#, c-format
+msgid "uniform resource identifier of the target account"
+msgstr "identificador de recurso uniforme de la cuenta destino"
+
+#: src/pages/PaytoWireTransferForm.tsx:437
+#, c-format
+msgid "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]"
+msgstr "payto://iban/[iban-destinatario]?message=[asunto]&amount=[%1$s:X.Y]"
+
+#: src/pages/PaytoWireTransferForm.tsx:457
+#, c-format
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: src/pages/PaytoWireTransferForm.tsx:471
+#, c-format
+msgid "Send"
+msgstr "Envíar"
+
+#: src/pages/LoginForm.tsx:71
+#, c-format
+msgid "Missing username"
+msgstr "Falta nombre de usuario"
+
+#: src/pages/LoginForm.tsx:75
+#, c-format
+msgid "Missing password"
+msgstr "Falta contraseña"
+
+#: src/pages/LoginForm.tsx:104
+#, c-format
+msgid "Wrong credentials for \"%1$s\""
+msgstr "Credenciales incorrectas para \"%1$s\""
+
+#: src/pages/LoginForm.tsx:111
+#, c-format
+msgid "Account not found"
+msgstr "Cuenta no encontrada"
+
+#: src/pages/LoginForm.tsx:142
+#, c-format
+msgid "Username"
+msgstr "Nombre de usuario"
+
+#: src/pages/LoginForm.tsx:156
+#, c-format
+msgid "username of the account"
+msgstr "nombre de usuario de la cuenta"
+
+#: src/pages/LoginForm.tsx:175
+#, c-format
+msgid "Password"
+msgstr "Contraseña"
+
+#: src/pages/LoginForm.tsx:188
+#, c-format
+msgid "password of the account"
+msgstr "contraseña de la cuenta"
+
+#: src/pages/LoginForm.tsx:223
+#, c-format
+msgid "Check"
+msgstr "Verificar"
+
+#: src/pages/LoginForm.tsx:237
+#, c-format
+msgid "Log in"
+msgstr "Acceso"
+
+#: src/pages/LoginForm.tsx:249
+#, c-format
+msgid "Register"
+msgstr "Registrarse"
+
+#: src/components/Transactions/views.tsx:52
+#, c-format
+msgid "Latest transactions"
+msgstr "Últimas transacciones"
+
+#: src/components/Transactions/views.tsx:63
+#, c-format
+msgid "Date"
+msgstr "Fecha"
+
+#: src/components/Transactions/views.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr "Contraparte"
+
+#: src/components/Transactions/views.tsx:75
+#, c-format
+msgid "Subject"
+msgstr "Asunto"
+
+#: src/components/Transactions/views.tsx:111
+#, c-format
+msgid "sent"
+msgstr "enviado"
+
+#: src/components/Transactions/views.tsx:112
+#, c-format
+msgid "received"
+msgstr "recibido"
+
+#: src/components/Transactions/views.tsx:127
+#, c-format
+msgid "invalid value"
+msgstr "valor inválido"
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "to"
+msgstr "hacia"
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "from"
+msgstr "desde"
+
+#: src/components/Transactions/views.tsx:202
+#, c-format
+msgid "First page"
+msgstr "Primera página"
+
+#: src/components/Transactions/views.tsx:209
+#, c-format
+msgid "Next"
+msgstr "Siguiente"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:86
+#, c-format
+msgid "Wire transfer completed!"
+msgstr "Transferencia bancaria completada!"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:93
+#, c-format
+msgid "The withdrawal has been aborted previously and can't be confirmed"
+msgstr "La extracción fue abortada anteriormente y no puede ser confirmada"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:100
+#, c-format
+msgid ""
+"The withdrawal operation can't be confirmed before a wallet accepted the "
+"transaction."
+msgstr ""
+"La operación de extracción no puede ser confirmada antes de que una "
+"billetera acepte la transaccion."
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:107
+#, c-format
+msgid "The operation id is invalid."
+msgstr "El id de operación es invalido."
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:114
+#, c-format
+msgid "The operation was not found."
+msgstr "La operación no se encontró."
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:121
+#, c-format
+msgid "Your balance is not enough for the operation."
+msgstr "El saldo no es suficiente para la operación."
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:155
+#, c-format
+msgid ""
+"The reserve operation has been confirmed previously and can't be aborted"
+msgstr ""
+"La operación en la reserva ya ha sido confirmada previamente y no puede ser "
+"abortada"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:186
+#, c-format
+msgid "Confirm the withdrawal operation"
+msgstr "Confirme la operación de extracción"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:203
+#, c-format
+msgid "Wire transfer details"
+msgstr "Detalle de transferencia bancaria"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:217
+#, c-format
+msgid "Taler Exchange operator's account"
+msgstr "Cuenta del operador del Taler Exchange"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:228
+#, c-format
+msgid "Taler Exchange operator's name"
+msgstr "Nombre del operador del Taler Exchange"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:317
+#, c-format
+msgid "Transfer"
+msgstr "Transferencia"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:342
+#, c-format
+msgid "Authentication required"
+msgstr "Autenticación requerida"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:352
+#, c-format
+msgid "This operation was created with other username"
+msgstr "Esta operación fue creada con otro usuario"
+
+#: src/pages/OperationState/views.tsx:209
+#, c-format
+msgid ""
+"Unauthorized to make the operation, maybe the session has expired or the "
+"password changed."
+msgstr ""
+"No autorizado para hacer la operación, quizá la sesión haya expirado or "
+"cambió la contraseña."
+
+#: src/pages/OperationState/views.tsx:218
+#, c-format
+msgid "The operation was rejected due to insufficient funds."
+msgstr "La operación fue rechazada debido a saldo insuficiente."
+
+#: src/pages/OperationState/views.tsx:268
+#, c-format
+msgid "Withdrawal confirmed"
+msgstr "La extracción fue confirmada"
+
+#: src/pages/OperationState/views.tsx:272
+#, c-format
+msgid ""
+"The wire transfer to the Taler operator has been initiated. You will soon "
+"receive the requested amount in your Taler wallet."
+msgstr ""
+"La transferencia bancaria al operador Taler fue iniciada. Pronto recibirás "
+"el monto pedido en tu billetera Taler."
+
+#: src/pages/OperationState/views.tsx:287
+#, c-format
+msgid "Do not show this again"
+msgstr "No mostrar otra vez"
+
+#: src/pages/OperationState/views.tsx:319
+#, c-format
+msgid "Close"
+msgstr "Cerrar"
+
+#: src/pages/OperationState/views.tsx:399
+#, c-format
+msgid "On this device"
+msgstr "En este dispositivo"
+
+#: src/pages/OperationState/views.tsx:404
+#, c-format
+msgid ""
+"If you are using a web browser on desktop you should access your wallet with "
+"the GNU Taler WebExtension now or click the link if your WebExtension have "
+"the \"Inject Taler support\" option enabled."
+msgstr ""
+"Si esta usando un explorador web de escritorio deberías acceder ahora a tu "
+"billletera con la GNU Taler WebExtension o hacer click en el link si tu "
+"extensión tiene la configuración \"Inyectar soporte para Taler\" habilitada."
+
+#: src/pages/OperationState/views.tsx:417
+#, c-format
+msgid "Start"
+msgstr "Comenzar"
+
+#: src/pages/OperationState/views.tsx:426
+#, c-format
+msgid "On a mobile phone"
+msgstr "En un dispotivo mobile"
+
+#: src/pages/OperationState/views.tsx:431
+#, c-format
+msgid "Scan the QR code with your mobile device."
+msgstr "Escanear el código QR con tu dispotivo móvil."
+
+#: src/pages/WalletWithdrawForm.tsx:73
+#, c-format
+msgid "There is an operation already"
+msgstr "Ya hay una operación"
+
+#: src/pages/WalletWithdrawForm.tsx:75
+#, c-format
+msgid "Complete or cancel the operation in"
+msgstr "Completa o cancela la operación en"
+
+#: src/pages/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "this page"
+msgstr "esta página"
+
+#: src/pages/WalletWithdrawForm.tsx:101
+#, c-format
+msgid "invalid"
+msgstr "inválido"
+
+#: src/pages/WalletWithdrawForm.tsx:116
+#, c-format
+msgid "Server responded with an invalid withdraw URI"
+msgstr "El servidor respondió con una URI de extracción inválida"
+
+#: src/pages/WalletWithdrawForm.tsx:117
+#, c-format
+msgid "Withdraw URI: %1$s"
+msgstr "URI de extracción: %1$s"
+
+#: src/pages/WalletWithdrawForm.tsx:132
+#, c-format
+msgid "The operation was rejected due to insufficient funds"
+msgstr "La operación fue rechazada debido a fundos insuficientes"
+
+#: src/pages/WalletWithdrawForm.tsx:253
+#, c-format
+msgid "Continue"
+msgstr "Continuar"
+
+#: src/pages/WalletWithdrawForm.tsx:282
+#, c-format
+msgid "Prepare your wallet"
+msgstr "Prepare su billetera"
+
+#: src/pages/WalletWithdrawForm.tsx:285
+#, c-format
+msgid ""
+"After using your wallet you will need to confirm or cancel the operation on "
+"this site."
+msgstr ""
+"Despues de usar tu billetera necesitarás confirmar o cancelar la operación "
+"en este sitio."
+
+#: src/pages/WalletWithdrawForm.tsx:295
+#, c-format
+msgid "You need a GNU Taler Wallet"
+msgstr "Necesitas una GNU Taler Wallet"
+
+#: src/pages/WalletWithdrawForm.tsx:300
+#, c-format
+msgid "If you don't have one yet you can follow the instruction in"
+msgstr "Si no tienes una todavía puedes seguir las instrucciones en"
+
+#: src/pages/PaymentOptions.tsx:55
+#, c-format
+msgid "Send money"
+msgstr "Enviar dinero"
+
+#: src/pages/PaymentOptions.tsx:73
+#, c-format
+msgid "to a %1$s wallet"
+msgstr "a una billetera %1$s"
+
+#: src/pages/PaymentOptions.tsx:95
+#, c-format
+msgid "Withdraw digital money into your mobile wallet or browser extension"
+msgstr "Extraer dinero digital a tu billetera móvil o extesión web"
+
+#: src/pages/PaymentOptions.tsx:109
+#, c-format
+msgid "operation ready"
+msgstr "operación lista"
+
+#: src/pages/PaymentOptions.tsx:129
+#, c-format
+msgid "to another bank account"
+msgstr "a otra cuenta bancaria"
+
+#: src/pages/PaymentOptions.tsx:149
+#, c-format
+msgid "Make a wire transfer to an account with known bank account number."
+msgstr ""
+"Hacer una transferencia bancaria a una cuenta con un número de cuenta "
+"conocido."
+
+#: src/pages/PaymentOptions.tsx:171
+#, c-format
+msgid "Transfer details"
+msgstr "Detalles de transferencia"
+
+#: src/pages/AccountPage/views.tsx:41
+#, c-format
+msgid "This is a demo bank"
+msgstr "Este es un banco de demostración"
+
+#: src/pages/AccountPage/views.tsx:46
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work. In addition to using your own bank account, you can also see the "
+"transaction history of some %1$s."
+msgstr ""
+"Esta parte de la demostración muestra cómo funciona un banco que soporta "
+"Taler directamente. Además de usar tu propia cuenta de banco, también podrás "
+"ver el historial de transacciones de algunas %1$s."
+
+#: src/pages/AccountPage/views.tsx:53
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work."
+msgstr ""
+"Esta parte de la demostración muetra como un banco que soporta Taler "
+"directamente funcionaría."
+
+#: src/pages/AccountPage/views.tsx:70
+#, c-format
+msgid "Pending account delete operation"
+msgstr "Operación pendiente de eliminación de cuenta"
+
+#: src/pages/AccountPage/views.tsx:72
+#, c-format
+msgid "Pending account update operation"
+msgstr "Operación pendiente de actualización de cuenta"
+
+#: src/pages/AccountPage/views.tsx:74
+#, c-format
+msgid "Pending password update operation"
+msgstr "Operación pendiente de actualización de password"
+
+#: src/pages/AccountPage/views.tsx:76
+#, c-format
+msgid "Pending transaction operation"
+msgstr "Operación pendiente de transacción"
+
+#: src/pages/AccountPage/views.tsx:78
+#, c-format
+msgid "Pending withdrawal operation"
+msgstr "Operación pendiente de extracción"
+
+#: src/pages/AccountPage/views.tsx:80
+#, c-format
+msgid "Pending cashout operation"
+msgstr "Operación pendiente de egreso"
+
+#: src/pages/AccountPage/views.tsx:91
+#, c-format
+msgid "You can complete or cancel the operation in"
+msgstr "Puedes completar o cancelar la operación en"
+
+#: src/pages/BankFrame.tsx:64
+#, c-format
+msgid "Internal error, please report."
+msgstr "Error interno, por favor reporte el error."
+
+#: src/pages/BankFrame.tsx:100
+#, c-format
+msgid "Preferences"
+msgstr "Preferencias"
+
+#: src/pages/BankFrame.tsx:184
+#, c-format
+msgid "Welcome, %1$s"
+msgstr "Bienvenido/a, %1$s"
+
+#: src/pages/WireTransfer.tsx:79
+#, c-format
+msgid "Make a wire transfer"
+msgstr "Hacer una transferencia bancaria"
+
+#: src/pages/admin/AccountList.tsx:72
+#, c-format
+msgid "Accounts"
+msgstr "Cuentas"
+
+#: src/pages/admin/AccountList.tsx:75
+#, c-format
+msgid "A list of all business account in the bank."
+msgstr "Una lista de todas las cuentas en el banco."
+
+#: src/pages/admin/AccountList.tsx:86
+#, c-format
+msgid "Create account"
+msgstr "Crear cuenta"
+
+#: src/pages/admin/AccountList.tsx:106
+#, c-format
+msgid "Name"
+msgstr "Nombre"
+
+#: src/pages/admin/AccountList.tsx:110
+#, c-format
+msgid "Balance"
+msgstr "Saldo"
+
+#: src/pages/admin/AccountList.tsx:112
+#, c-format
+msgid "Actions"
+msgstr "Acciones"
+
+#: src/pages/admin/AccountList.tsx:151
+#, c-format
+msgid "unknown"
+msgstr "desconocido"
+
+#: src/pages/admin/AccountList.tsx:170
+#, c-format
+msgid "change password"
+msgstr "cambiar contraseña"
+
+#: src/pages/admin/AccountList.tsx:179
+#, c-format
+msgid "cashouts"
+msgstr "egresos"
+
+#: src/pages/admin/AccountList.tsx:189
+#, c-format
+msgid "remove"
+msgstr "elimiar"
+
+#: src/pages/admin/AdminHome.tsx:168
+#, c-format
+msgid "Cashout not implemented"
+msgstr "Egreso no soportado"
+
+#: src/pages/admin/AdminHome.tsx:184
+#, c-format
+msgid "Select a section"
+msgstr "Seleccione una sección"
+
+#: src/pages/admin/AdminHome.tsx:202
+#, c-format
+msgid "Last hour"
+msgstr "Última hora"
+
+#: src/pages/admin/AdminHome.tsx:208
+#, c-format
+msgid "Last day"
+msgstr "Último día"
+
+#: src/pages/admin/AdminHome.tsx:216
+#, c-format
+msgid "Last month"
+msgstr "Último mes"
+
+#: src/pages/admin/AdminHome.tsx:222
+#, c-format
+msgid "Last year"
+msgstr "Último año"
+
+#: src/pages/admin/AdminHome.tsx:310
+#, c-format
+msgid "Last Year"
+msgstr "Último Año"
+
+#: src/pages/admin/AdminHome.tsx:325
+#, c-format
+msgid "Trading volume on %1$s compared to %2$s"
+msgstr "Vólumen de comercio en %1$s comparado con %2$s"
+
+#: src/pages/admin/AdminHome.tsx:342
+#, c-format
+msgid "Cashin"
+msgstr "Ingreso"
+
+#: src/pages/admin/AdminHome.tsx:352
+#, c-format
+msgid "Cashout"
+msgstr "Egreso"
+
+#: src/pages/admin/AdminHome.tsx:364
+#, c-format
+msgid "Payin"
+msgstr "Envios de dinero"
+
+#: src/pages/admin/AdminHome.tsx:374
+#, c-format
+msgid "Payout"
+msgstr "Recibos de dinero"
+
+#: src/pages/admin/AdminHome.tsx:388
+#, c-format
+msgid "download stats as CSV"
+msgstr "descargar estadísticas en CSV"
+
+#: src/pages/admin/AdminHome.tsx:494
+#, c-format
+msgid "Decreased by"
+msgstr "Descendiente por"
+
+#: src/pages/admin/AdminHome.tsx:498
+#, c-format
+msgid "Increased by"
+msgstr "Ascendente por"
+
+#: src/pages/DownloadStats.tsx:89
+#, c-format
+msgid "Download bank stats"
+msgstr "Descargar estadísticas del banco"
+
+#: src/pages/DownloadStats.tsx:110
+#, c-format
+msgid "Include hour metric"
+msgstr "Incluir métrica horaria"
+
+#: src/pages/DownloadStats.tsx:143
+#, c-format
+msgid "Include day metric"
+msgstr "Incluir métrica diaria"
+
+#: src/pages/DownloadStats.tsx:173
+#, c-format
+msgid "Include month metric"
+msgstr "Incluir métrica mensual"
+
+#: src/pages/DownloadStats.tsx:206
+#, c-format
+msgid "Include year metric"
+msgstr "Incluir métrica anual"
+
+#: src/pages/DownloadStats.tsx:239
+#, c-format
+msgid "Include table header"
+msgstr "Incluir encabezado de tabla"
+
+#: src/pages/DownloadStats.tsx:272
+#, c-format
+msgid "Add previous metric for compare"
+msgstr "Agregar métrica previa para comparar"
+
+#: src/pages/DownloadStats.tsx:307
+#, c-format
+msgid "Fail on first error"
+msgstr "Fallar en el primer error"
+
+#: src/pages/DownloadStats.tsx:364
+#, c-format
+msgid "Download"
+msgstr "Descargar"
+
+#: src/pages/DownloadStats.tsx:381
+#, c-format
+msgid "downloading... %1$s"
+msgstr "descargando... %1$s"
+
+#: src/pages/DownloadStats.tsx:399
+#, c-format
+msgid "Download completed"
+msgstr "Descarga completada"
+
+#: src/pages/DownloadStats.tsx:400
+#, c-format
+msgid "click here to save the file in your computer"
+msgstr "click aquí para guardar el archivo en su computadora"
+
+#: src/pages/PublicHistoriesPage.tsx:78
+#, c-format
+msgid "History of public accounts"
+msgstr "Historial de cuentas públicas"
+
+#: src/pages/RegistrationPage.tsx:48
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr "Actualmente, el banco no está aceptado nuevos registros!"
+
+#: src/pages/RegistrationPage.tsx:87
+#, c-format
+msgid "Missing name"
+msgstr "Falta nombre"
+
+#: src/pages/RegistrationPage.tsx:91
+#, c-format
+msgid "Use letters and numbers only, and start with a lowercase letter"
+msgstr "Solo use letras y números, y comience con una letra minúscula"
+
+#: src/pages/RegistrationPage.tsx:107
+#, c-format
+msgid "Passwords don't match"
+msgstr "La contraseña no coincide"
+
+#: src/pages/RegistrationPage.tsx:130
+#, c-format
+msgid "Server replied with invalid phone or email."
+msgstr "El servidor repondio con teléfono o dirección de correo inválido."
+
+#: src/pages/RegistrationPage.tsx:137
+#, c-format
+msgid "Registration is disabled because the bank ran out of bonus credit."
+msgstr ""
+"El registro está deshabilitado porque el banco se quedó sin crédito bonus."
+
+#: src/pages/RegistrationPage.tsx:144
+#, c-format
+msgid "No enough permission to create that account."
+msgstr "Sin permisos suficientes para crear esa cuenta."
+
+#: src/pages/RegistrationPage.tsx:151
+#, c-format
+msgid "That account id is already taken."
+msgstr "El identificador de cuenta ya está tomado."
+
+#: src/pages/RegistrationPage.tsx:158
+#, c-format
+msgid "That username is already taken."
+msgstr "El nombre de usuario ya está tomado."
+
+#: src/pages/RegistrationPage.tsx:165
+#, c-format
+msgid "That username can't be used because is reserved."
+msgstr "El nombre de usuario no puede ser usado porque esta reservado."
+
+#: src/pages/RegistrationPage.tsx:172
+#, c-format
+msgid "Only admin is allow to set debt limit."
+msgstr "Solo el administrador tiene permitido cambiar el límite de deuda."
+
+#: src/pages/RegistrationPage.tsx:179
+#, c-format
+msgid "No information for the selected authentication channel."
+msgstr "No hay información para el canal de autenticación seleccionado."
+
+#: src/pages/RegistrationPage.tsx:186
+#, c-format
+msgid "Authentication channel is not supported."
+msgstr "Canal de autenticación no esta soportado."
+
+#: src/pages/RegistrationPage.tsx:193
+#, c-format
+msgid "Only admin can create accounts with second factor authentication."
+msgstr ""
+"Solo el administrador puede crear cuentas con el segundo factor de "
+"autenticación."
+
+#: src/pages/RegistrationPage.tsx:233
+#, c-format
+msgid "Account registration"
+msgstr "Registro de cuenta"
+
+#: src/pages/RegistrationPage.tsx:315
+#, c-format
+msgid "Repeat password"
+msgstr "Repita la contraseña"
+
+#: src/pages/RegistrationPage.tsx:457
+#, c-format
+msgid "Create a random temporary user"
+msgstr "Crear un usuario aleatorio temporal"
+
+#: src/pages/QrCodeSection.tsx:110
+#, c-format
+msgid "If you have a Taler wallet installed in this device"
+msgstr "Si tienes una billetera Taler instalada en este dispositivo"
+
+#: src/pages/QrCodeSection.tsx:116
+#, c-format
+msgid ""
+"You will see the details of the operation in your wallet including the fees "
+"(if applies). If you still don't have one you can install it following "
+"instructions in"
+msgstr ""
+"Veras los detalles de la operación en tu billetera incluyendo comisiones (si "
+"aplicán). Si todavía no tienes una puedes instalarla siguiendo las "
+"instrucciones en"
+
+#: src/pages/QrCodeSection.tsx:143
+#, c-format
+msgid "Withdraw"
+msgstr "Retirar"
+
+#: src/pages/QrCodeSection.tsx:152
+#, c-format
+msgid "Or if you have the wallet in another device"
+msgstr "O si tienes la billetera en otro dispositivo"
+
+#: src/pages/QrCodeSection.tsx:157
+#, c-format
+msgid "Scan the QR below to start the withdrawal."
+msgstr "Escanea el QR debajo para comenzar la extracción."
+
+#: src/pages/WithdrawalQRCode.tsx:79
+#, c-format
+msgid "Operation aborted"
+msgstr "Operación abortada"
+
+#: src/pages/WithdrawalQRCode.tsx:82
+#, c-format
+msgid ""
+"The wire transfer to the Taler Exchange operator's account was aborted, your "
+"balance was not affected."
+msgstr ""
+"La transferencia bancaria a la cuenta del operador del Taler Exchange fue "
+"abortada, su saldo no fue afectado."
+
+#: src/pages/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "You can close this page now or continue to the account page."
+msgstr ""
+"Ya puedes cerrar esta pagina or continuar a la página de estado de cuenta."
+
+#: src/pages/WithdrawalQRCode.tsx:147
+#, c-format
+msgid "Done"
+msgstr "Listo"
+
+#: src/pages/WithdrawalQRCode.tsx:158
+#, c-format
+msgid "Operation canceled"
+msgstr "Operación cancelada"
+
+#: src/pages/WithdrawalQRCode.tsx:173
+#, c-format
+msgid ""
+"The operation is marked as 'selected' but some step in the withdrawal failed"
+msgstr ""
+"La operación está marcada como 'seleccionada' pero algunos pasos en la "
+"extracción fallaron"
+
+#: src/pages/WithdrawalQRCode.tsx:175
+#, c-format
+msgid "The account is selected but no withdrawal identification found."
+msgstr ""
+"La cuenta está seleccionada pero no se encontró el identificador de "
+"extracción."
+
+#: src/pages/WithdrawalQRCode.tsx:188
+#, c-format
+msgid ""
+"There is a withdrawal identification but no account has been selected or the "
+"selected account is invalid."
+msgstr ""
+"Hay un identificador de extracción pero la cuenta no ha sido seleccionada o "
+"la selccionada es inválida."
+
+#: src/pages/WithdrawalQRCode.tsx:202
+#, c-format
+msgid ""
+"No withdrawal ID found and no account has been selected or the selected "
+"account is invalid."
+msgstr ""
+"No hay un identificador de extracción y ninguna cuenta a sido seleccionada o "
+"la seleccionada es inválida."
+
+#: src/pages/WithdrawalQRCode.tsx:259
+#, c-format
+msgid "Operation not found"
+msgstr "Operación no encontrada"
+
+#: src/pages/WithdrawalQRCode.tsx:263
+#, c-format
+msgid ""
+"This operation is not known by the server. The operation id is wrong or the "
+"server deleted the operation information before reaching here."
+msgstr ""
+"Esta operación no es conocida por el servidor. El identificador de operación "
+"es incorrecto o el server borró la información de la operación antes de "
+"llegar hasta aquí."
+
+#: src/pages/WithdrawalQRCode.tsx:278
+#, c-format
+msgid "Cotinue to dashboard"
+msgstr "Continuar al panel"
+
+#: src/pages/SolveChallengePage.tsx:98
+#, c-format
+msgid "Cashout not found. It may be also mean that it was already aborted."
+msgstr "Egreso no econtrado. También puede significar que ya ha sido abortado."
+
+#: src/pages/SolveChallengePage.tsx:136
+#, c-format
+msgid "Challenge not found."
+msgstr "Desafío no encontrado."
+
+#: src/pages/SolveChallengePage.tsx:143
+#, c-format
+msgid "This user is not authorized to complete this challenge."
+msgstr "Este usuario no está autorizado para completar este desafío."
+
+#: src/pages/SolveChallengePage.tsx:150
+#, c-format
+msgid "Too many attempts, try another code."
+msgstr "Demasiados intentos, intente otro código."
+
+#: src/pages/SolveChallengePage.tsx:157
+#, c-format
+msgid "The confirmation code is wrong, try again."
+msgstr "El código de confirmación es erroneo, intente otra vez."
+
+#: src/pages/SolveChallengePage.tsx:164
+#, c-format
+msgid "The operation expired."
+msgstr "La operación expiró."
+
+#: src/pages/SolveChallengePage.tsx:197
+#, c-format
+msgid "The operation failed."
+msgstr "La operación falló."
+
+#: src/pages/SolveChallengePage.tsx:212
+#, c-format
+msgid "The operation needs another confirmation to complete."
+msgstr "La operación necesita otra confirmación para completar."
+
+#: src/pages/SolveChallengePage.tsx:224
+#, c-format
+msgid "Account delete"
+msgstr "Eliminación de cuenta"
+
+#: src/pages/SolveChallengePage.tsx:226
+#, c-format
+msgid "Account update"
+msgstr "Actualización de cuenta"
+
+#: src/pages/SolveChallengePage.tsx:228
+#, c-format
+msgid "Password update"
+msgstr "Actualización de contraseña"
+
+#: src/pages/SolveChallengePage.tsx:230
+#, c-format
+msgid "Wire transfer"
+msgstr "Transferencia bancaria"
+
+#: src/pages/SolveChallengePage.tsx:232
+#, c-format
+msgid "Withdrawal"
+msgstr "Extracción"
+
+#: src/pages/SolveChallengePage.tsx:248
+#, c-format
+msgid "Confirm the operation"
+msgstr "Confirmar la operación"
+
+#: src/pages/SolveChallengePage.tsx:271
+#, c-format
+msgid "Enter the confirmation code"
+msgstr "Ingresar el código de confirmación"
+
+#: src/pages/SolveChallengePage.tsx:313
+#, c-format
+msgid "Confirm"
+msgstr "Confirmar"
+
+#: src/pages/SolveChallengePage.tsx:348
+#, c-format
+msgid "Send again"
+msgstr "Enviar otra vez"
+
+#: src/pages/SolveChallengePage.tsx:359
+#, c-format
+msgid "Send code"
+msgstr "Enviar código"
+
+#: src/pages/SolveChallengePage.tsx:369
+#, c-format
+msgid "Operation details"
+msgstr "Detalles de operación"
+
+#: src/pages/SolveChallengePage.tsx:529
+#, c-format
+msgid "Challenge details"
+msgstr "Detalles del desafío"
+
+#: src/pages/SolveChallengePage.tsx:536
+#, c-format
+msgid "Sent at"
+msgstr "Enviado a"
+
+#: src/pages/SolveChallengePage.tsx:551
+#, c-format
+msgid "To phone"
+msgstr "Al teléfono"
+
+#: src/pages/SolveChallengePage.tsx:553
+#, c-format
+msgid "To email"
+msgstr "Al email"
+
+#: src/pages/WithdrawalOperationPage.tsx:49
+#, c-format
+msgid "The Withdrawal URI is not valid"
+msgstr "El URI de estracción no es válido"
+
+#: src/components/Cashouts/views.tsx:100
+#, c-format
+msgid "Latest cashouts"
+msgstr "Últimos egresos"
+
+#: src/components/Cashouts/views.tsx:111
+#, c-format
+msgid "Created"
+msgstr "Creado"
+
+#: src/components/Cashouts/views.tsx:115
+#, c-format
+msgid "Total debit"
+msgstr "Débito total"
+
+#: src/components/Cashouts/views.tsx:119
+#, c-format
+msgid "Total credit"
+msgstr "Crédito total"
+
+#: src/pages/ProfileNavigation.tsx:70
+#, c-format
+msgid "Details"
+msgstr "Detalles"
+
+#: src/pages/ProfileNavigation.tsx:74
+#, c-format
+msgid "Delete"
+msgstr "Borrar"
+
+#: src/pages/ProfileNavigation.tsx:78
+#, c-format
+msgid "Credentials"
+msgstr "Credenciales"
+
+#: src/pages/ProfileNavigation.tsx:82
+#, c-format
+msgid "Cashouts"
+msgstr "Egresos"
+
+#: src/pages/business/CreateCashout.tsx:95
+#, c-format
+msgid "Unable to create a cashout"
+msgstr "Imposible crear un egreso"
+
+#: src/pages/business/CreateCashout.tsx:96
+#, c-format
+msgid "The bank configuration does not support cashout operations."
+msgstr "La configuración del banco no soporta operaciones de egreso."
+
+#: src/pages/business/CreateCashout.tsx:223
+#, c-format
+msgid "need to be higher due to fees"
+msgstr "necesita ser mayor debido a las comisiones"
+
+#: src/pages/business/CreateCashout.tsx:225
+#, c-format
+msgid "the total transfer at destination will be zero"
+msgstr "el total de la transferencia en destino será cero"
+
+#: src/pages/business/CreateCashout.tsx:250
+#, c-format
+msgid "Cashout created"
+msgstr "Egreso creado"
+
+#: src/pages/business/CreateCashout.tsx:272
+#, c-format
+msgid ""
+"Duplicated request detected, check if the operation succeeded or try again."
+msgstr ""
+"Se detectó una petición duplicada, verifique si la operación tuvo éxito o "
+"intente otra vez."
+
+#: src/pages/business/CreateCashout.tsx:279
+#, c-format
+msgid "The conversion rate was incorrectly applied"
+msgstr "La tasa de conversión se aplicó incorrectamente"
+
+#: src/pages/business/CreateCashout.tsx:286
+#, c-format
+msgid "The account does not have sufficient funds"
+msgstr "La cuenta no tiene fondos suficientes"
+
+#: src/pages/business/CreateCashout.tsx:293
+#, c-format
+msgid "Cashouts are not supported"
+msgstr "Egresos no están soportados"
+
+#: src/pages/business/CreateCashout.tsx:300
+#, c-format
+msgid "Missing cashout URI in the profile"
+msgstr "Falta dirección de egreso en el perfíl"
+
+#: src/pages/business/CreateCashout.tsx:307
+#, c-format
+msgid ""
+"Sending the confirmation message failed, retry later or contact the "
+"administrator."
+msgstr ""
+"El envío del mensaje de confirmación falló, intente mas tarde o contacte al "
+"administrador."
+
+#: src/pages/business/CreateCashout.tsx:339
+#, c-format
+msgid "Conversion rate"
+msgstr "Tasa de conversión"
+
+#: src/pages/business/CreateCashout.tsx:360
+#, c-format
+msgid "Fee"
+msgstr "Comisión"
+
+#: src/pages/business/CreateCashout.tsx:374
+#, c-format
+msgid "To account"
+msgstr "Hacia cuenta"
+
+#: src/pages/business/CreateCashout.tsx:381
+#, c-format
+msgid "No cashout account"
+msgstr "No hay cuenta de egreso"
+
+#: src/pages/business/CreateCashout.tsx:382
+#, c-format
+msgid "Before doing a cashout you need to complete your profile"
+msgstr "Antes de hacer un egreso necesita completar su perfíl"
+
+#: src/pages/business/CreateCashout.tsx:440
+#, c-format
+msgid "Amount to send"
+msgstr "Monto a enviar"
+
+#: src/pages/business/CreateCashout.tsx:441
+#, c-format
+msgid "Amount to receive"
+msgstr "Monto a recibir"
+
+#: src/pages/business/CreateCashout.tsx:490
+#, c-format
+msgid "Total cost"
+msgstr "Costo total"
+
+#: src/pages/business/CreateCashout.tsx:505
+#, c-format
+msgid "Balance left"
+msgstr "Saldo remanente"
+
+#: src/pages/business/CreateCashout.tsx:520
+#, c-format
+msgid "Before fee"
+msgstr "Antes de comisión"
+
+#: src/pages/business/CreateCashout.tsx:533
+#, c-format
+msgid "Total cashout transfer"
+msgstr "Total de egreso"
+
+#: src/pages/business/CreateCashout.tsx:553
+#, c-format
+msgid "No cashout channel available"
+msgstr "No hay canal de egreso disponible"
+
+#: src/pages/business/CreateCashout.tsx:555
+#, c-format
+msgid ""
+"Before doing a cashout the server need to provide an second channel to "
+"confirm the operation"
+msgstr ""
+"Antes de hacer un egreso el servidor necesita proveer un segundo canal para "
+"confirmar la operación"
+
+#: src/pages/business/CreateCashout.tsx:567
+#, c-format
+msgid "Second factor authentication"
+msgstr "Segundo factor de autenticación"
+
+#: src/pages/business/CreateCashout.tsx:598
+#, c-format
+msgid "Email"
+msgstr "Correo eletrónico"
+
+#: src/pages/business/CreateCashout.tsx:600
+#, c-format
+msgid "add a email in your profile to enable this option"
+msgstr "agrege un correo en su perfíl para habilitar esta opción"
+
+#: src/pages/business/CreateCashout.tsx:646
+#, c-format
+msgid "SMS"
+msgstr "SMS"
+
+#: src/pages/business/CreateCashout.tsx:648
+#, c-format
+msgid "add a phone number in your profile to enable this option"
+msgstr "agregue un número de teléfono para habilitar esta opción"
+
+#: src/pages/account/CashoutListForAccount.tsx:52
+#, c-format
+msgid "Cashout for account %1$s"
+msgstr "Egreso para cuenta %1$s"
+
+#: src/pages/admin/AccountForm.tsx:165
+#, c-format
+msgid "it doesn't have the pattern of an IBAN number"
+msgstr "no tiene el patrón de un número IBAN"
+
+#: src/pages/admin/AccountForm.tsx:185
+#, c-format
+msgid "it doesn't have the pattern of an email"
+msgstr "no tiene el patrón de un correo electrónico"
+
+#: src/pages/admin/AccountForm.tsx:190
+#, c-format
+msgid "should start with +"
+msgstr "debería comenzar con un +"
+
+#: src/pages/admin/AccountForm.tsx:192
+#, c-format
+msgid "phone number can't have other than numbers"
+msgstr "número de teléfono no puede tener otra cosa que numeros"
+
+#: src/pages/admin/AccountForm.tsx:329
+#, c-format
+msgid "account identification in the bank"
+msgstr "identificador de cuenta en el banco"
+
+#: src/pages/admin/AccountForm.tsx:365
+#, c-format
+msgid "name of the person owner the account"
+msgstr "nombre de la persona dueña de la cuenta"
+
+#: src/pages/admin/AccountForm.tsx:374
+#, c-format
+msgid "Internal IBAN"
+msgstr "IBAN interno"
+
+#: src/pages/admin/AccountForm.tsx:377
+#, c-format
+msgid "if empty a random account number will be assigned"
+msgstr "si está vacío un número de cuenta aleatorio será asignado"
+
+#: src/pages/admin/AccountForm.tsx:378
+#, c-format
+msgid "account identification for bank transfer"
+msgstr "identificador de cuenta para transferencia bancaria"
+
+#: src/pages/admin/AccountForm.tsx:423
+#, c-format
+msgid "Phone"
+msgstr "Teléfono"
+
+#: src/pages/admin/AccountForm.tsx:451
+#, c-format
+msgid "Cashout IBAN"
+msgstr "IBAN de egreso"
+
+#: src/pages/admin/AccountForm.tsx:452
+#, c-format
+msgid "account number where the money is going to be sent when doing cashouts"
+msgstr ""
+"numero de cuenta donde el dinero será enviado cuando se ejecuten egresos"
+
+#: src/pages/admin/AccountForm.tsx:470
+#, c-format
+msgid "Max debt"
+msgstr "Máxima deuda"
+
+#: src/pages/admin/AccountForm.tsx:494
+#, c-format
+msgid "how much is user able to transfer after zero balance"
+msgstr "cuanto tiene habilitado a transferir despues de un saldo en cero"
+
+#: src/pages/admin/AccountForm.tsx:508
+#, c-format
+msgid "Is this a Taler Exchange?"
+msgstr "Es un Taler Exchange?"
+
+#: src/pages/admin/AccountForm.tsx:549
+#, c-format
+msgid "This server doesn't support second factor authentication."
+msgstr "Este servidor no tiene soporte para segundo factor de autenticación."
+
+#: src/pages/admin/AccountForm.tsx:560
+#, c-format
+msgid "Enable second factor authentication"
+msgstr "Hábilitar segundo factor de autenticación"
+
+#: src/pages/admin/AccountForm.tsx:596
+#, c-format
+msgid "Using email"
+msgstr "Usando correo eletrónico"
+
+#: src/pages/admin/AccountForm.tsx:654
+#, c-format
+msgid "Using SMS"
+msgstr "Usando SMS"
+
+#: src/pages/admin/AccountForm.tsx:691
+#, c-format
+msgid "Is this account public?"
+msgstr "Es una cuenta pública?"
+
+#: src/pages/admin/AccountForm.tsx:719
+#, c-format
+msgid "public accounts have their balance publicly accessible"
+msgstr "las cuentas públicas tienen su saldo accesible al público"
+
+#: src/pages/account/ShowAccountDetails.tsx:100
+#, c-format
+msgid "Account updated"
+msgstr "Cuenta actualizada"
+
+#: src/pages/account/ShowAccountDetails.tsx:107
+#, c-format
+msgid "The rights to change the account are not sufficient"
+msgstr "Los permisos para cambiar la cuenta no son suficientes"
+
+#: src/pages/account/ShowAccountDetails.tsx:114
+#, c-format
+msgid "The username was not found"
+msgstr "El nombre de usaurio no se encontró"
+
+#: src/pages/account/ShowAccountDetails.tsx:121
+#, c-format
+msgid ""
+"You can't change the legal name, please contact the your account "
+"administrator."
+msgstr ""
+"No puede cambiar el nombre legal, por favor contacte el administrador de la "
+"cuenta."
+
+#: src/pages/account/ShowAccountDetails.tsx:128
+#, c-format
+msgid ""
+"You can't change the debt limit, please contact the your account "
+"administrator."
+msgstr ""
+"No puede cambiar el límite de deuda, por favor contacte el administrador de "
+"la cuenta."
+
+#: src/pages/account/ShowAccountDetails.tsx:135
+#, c-format
+msgid ""
+"You can't change the cashout address, please contact the your account "
+"administrator."
+msgstr ""
+"No puede cambiar la dirección de egreso, por favor contacte al administrador "
+"de la cuenta."
+
+#: src/pages/account/ShowAccountDetails.tsx:177
+#, c-format
+msgid "Account \"%1$s\""
+msgstr "Cuenta \"%1$s\""
+
+#: src/pages/account/ShowAccountDetails.tsx:190
+#, c-format
+msgid "Change details"
+msgstr "Cambiar detalles"
+
+#: src/pages/account/ShowAccountDetails.tsx:235
+#, c-format
+msgid "Update"
+msgstr "Actualizar"
+
+#: src/pages/account/UpdateAccountPassword.tsx:78
+#, c-format
+msgid "password doesn't match"
+msgstr "la contraseña no coincide"
+
+#: src/pages/account/UpdateAccountPassword.tsx:95
+#, c-format
+msgid "Password changed"
+msgstr "La contraseña cambió"
+
+#: src/pages/account/UpdateAccountPassword.tsx:102
+#, c-format
+msgid "Not authorized to change the password, maybe the session is invalid."
+msgstr "No está autorizado a cambiar el password, quizá la sesión es invalida."
+
+#: src/pages/account/UpdateAccountPassword.tsx:112
+#, c-format
+msgid ""
+"You need to provide the old password. If you don't have it contact your "
+"account administrator."
+msgstr ""
+"Se necesita el password viejo para cambiar la contraseña. Si no lo tiene "
+"contacte a su administrador."
+
+#: src/pages/account/UpdateAccountPassword.tsx:117
+#, c-format
+msgid "Your current password doesn't match, can't change to a new password."
+msgstr ""
+"Su actual contraseña no coincide, no puede cambiar a una nueva contraseña."
+
+#: src/pages/account/UpdateAccountPassword.tsx:149
+#, c-format
+msgid "Update password"
+msgstr "Actualizar contraseña"
+
+#: src/pages/account/UpdateAccountPassword.tsx:167
+#, c-format
+msgid "New password"
+msgstr "Nueva contraseña"
+
+#: src/pages/account/UpdateAccountPassword.tsx:195
+#, c-format
+msgid "Type it again"
+msgstr "Escribalo otra vez"
+
+#: src/pages/account/UpdateAccountPassword.tsx:217
+#, c-format
+msgid "repeat the same password"
+msgstr "repita la misma contraseña"
+
+#: src/pages/account/UpdateAccountPassword.tsx:227
+#, c-format
+msgid "Current password"
+msgstr "Contraseña actual"
+
+#: src/pages/account/UpdateAccountPassword.tsx:248
+#, c-format
+msgid "your current password, for security"
+msgstr "su actual contraseña, por seguridad"
+
+#: src/pages/account/UpdateAccountPassword.tsx:272
+#, c-format
+msgid "Change"
+msgstr "Cambiar"
+
+#: src/pages/admin/CreateNewAccount.tsx:74
+#, c-format
+msgid ""
+"Account created with password \"%1$s\". The user must change the password on "
+"the next login."
+msgstr ""
+"Cuenta creada con contraseña \"%1$s\". El usuario debe cambiar la contraseña "
+"en el siguiente ingreso."
+
+#: src/pages/admin/CreateNewAccount.tsx:83
+#, c-format
+msgid "Server replied that phone or email is invalid"
+msgstr "El servidor respondió que el teléfono o correo eletrónico es invalido"
+
+#: src/pages/admin/CreateNewAccount.tsx:90
+#, c-format
+msgid "The rights to perform the operation are not sufficient"
+msgstr "Los permisos para ejecutar la operación no son suficientes"
+
+#: src/pages/admin/CreateNewAccount.tsx:97
+#, c-format
+msgid "Account username is already taken"
+msgstr "El nombre del usuario ya está tomado"
+
+#: src/pages/admin/CreateNewAccount.tsx:104
+#, c-format
+msgid "Account id is already taken"
+msgstr "El id de cuenta ya está tomado"
+
+#: src/pages/admin/CreateNewAccount.tsx:111
+#, c-format
+msgid "Bank ran out of bonus credit."
+msgstr "El banco no tiene mas crédito de bonus."
+
+#: src/pages/admin/CreateNewAccount.tsx:118
+#, c-format
+msgid "Account username can't be used because is reserved"
+msgstr ""
+"El nombre de usuario de la cuenta no puede userse porque está reservado"
+
+#: src/pages/admin/CreateNewAccount.tsx:160
+#, c-format
+msgid "Can't create accounts"
+msgstr "No puede crear cuentas"
+
+#: src/pages/admin/CreateNewAccount.tsx:161
+#, c-format
+msgid "Only system admin can create accounts."
+msgstr "Solo los administradores del sistema pueden crear cuentas."
+
+#: src/pages/admin/CreateNewAccount.tsx:183
+#, c-format
+msgid "New business account"
+msgstr "Nueva cuenta"
+
+#: src/pages/admin/CreateNewAccount.tsx:209
+#, c-format
+msgid "Create"
+msgstr "Crear"
+
+#: src/pages/admin/RemoveAccount.tsx:94
+#, c-format
+msgid "Can't delete the account"
+msgstr "No se puede eliminar la cuenta"
+
+#: src/pages/admin/RemoveAccount.tsx:95
+#, c-format
+msgid ""
+"The account can't be delete while still holding some balance. First make "
+"sure that the owner make a complete cashout."
+msgstr ""
+"La cuenta no puede ser eliminada mientras tiene saldo. Primero aseguresé que "
+"el dueño haga un egreso completo."
+
+#: src/pages/admin/RemoveAccount.tsx:117
+#, c-format
+msgid "Account removed"
+msgstr "Cuenta eliminada"
+
+#: src/pages/admin/RemoveAccount.tsx:124
+#, c-format
+msgid "No enough permission to delete the account."
+msgstr "No tiene permisos suficientes para eliminar la cuenta."
+
+#: src/pages/admin/RemoveAccount.tsx:131
+#, c-format
+msgid "The username was not found."
+msgstr "El nombr ede usuario no se encontró."
+
+#: src/pages/admin/RemoveAccount.tsx:138
+#, c-format
+msgid "Can't delete a reserved username."
+msgstr "No se puede eliminar un nombre de usuario reservado."
+
+#: src/pages/admin/RemoveAccount.tsx:145
+#, c-format
+msgid "Can't delete an account with balance different than zero."
+msgstr "No se puede eliminar una cuenta con saldo diferente a cero."
+
+#: src/pages/admin/RemoveAccount.tsx:170
+#, c-format
+msgid "name doesn't match"
+msgstr "el nombre no coincide"
+
+#: src/pages/admin/RemoveAccount.tsx:180
+#, c-format
+msgid "You are going to remove the account"
+msgstr "Está por eliminar la cuenta"
+
+#: src/pages/admin/RemoveAccount.tsx:182
+#, c-format
+msgid "This step can't be undone."
+msgstr "Este paso no puede ser deshecho."
+
+#: src/pages/admin/RemoveAccount.tsx:188
+#, c-format
+msgid "Deleting account \"%1$s\""
+msgstr "Borrando cuenta \"%1$s\""
+
+#: src/pages/admin/RemoveAccount.tsx:206
+#, c-format
+msgid "Verification"
+msgstr "Verificación"
+
+#: src/pages/admin/RemoveAccount.tsx:231
+#, c-format
+msgid "enter the account name that is going to be deleted"
+msgstr "ingrese el nombre de cuenta que será eliminado"
+
+#: src/pages/business/ShowCashoutDetails.tsx:49
+#, c-format
+msgid "cashout id should be a number"
+msgstr "debería ser un correo electrónico"
+
+#: src/pages/business/ShowCashoutDetails.tsx:65
+#, c-format
+msgid "This cashout not found. Maybe already aborted."
+msgstr "Este egreso no se encontró. Quizá fue abortado."
+
+#: src/pages/business/ShowCashoutDetails.tsx:106
+#, c-format
+msgid "Cashout detail"
+msgstr "Detalles de egreso"
+
+#: src/pages/business/ShowCashoutDetails.tsx:139
+#, c-format
+msgid "Debited"
+msgstr "Debitado"
+
+#: src/pages/business/ShowCashoutDetails.tsx:154
+#, c-format
+msgid "Credited"
+msgstr "Acreditado"
+
+#: src/Routing.tsx:140
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr "Bienvenido a %1$s!"
+
+#, c-format
+#~ msgid ""
+#~ "You can't change the contact data, please contact the your account "
+#~ "administrator."
+#~ msgstr ""
+#~ "No puede cambiar los datos de contacto, por favor contacte al "
+#~ "administrador de la cuenta."
+
+#, c-format
+#~ msgid "Account not found."
+#~ msgstr "Cuenta no encontrada."
+
+#, c-format
+#~ msgid "Confirmed"
+#~ msgstr "Confirmado"
+
+#, c-format
+#~ msgid "Status"
+#~ msgstr "Estado"
+
+#, c-format
+#~ msgid "never"
+#~ msgstr "nunca"
+
+#, c-format
+#~ msgid "Cashout was already confimed."
+#~ msgstr "Egreso ya fue confirmado."
+
+#, c-format
+#~ msgid "Cashout operation is not supported."
+#~ msgstr "Operación de egreso no soportada."
+
+#, c-format
+#~ msgid "The cashout operation is already aborted."
+#~ msgstr "La operación de egreso ya está abortada."
+
+#, c-format
+#~ msgid "Missing destination account."
+#~ msgstr "Falta cuenta destino."
+
+#, c-format
+#~ msgid "Too many failed attempts."
+#~ msgstr "Demasiados intentos fallidos."
+
+#, c-format
+#~ msgid "The code for this cashout is invalid."
+#~ msgstr "El código para este egreso es invalido."
+
+#, c-format
+#~ msgid "Abort"
+#~ msgstr "Abortar"
+
+#, fuzzy, c-format
+#~ msgid ""
+#~ "The Taler Exchange operator is selected but the Taler Exchange operator "
+#~ "account is missing or invalid."
+#~ msgstr ""
+#~ "El operador esta seleccionado pero la URI payto del operador falta o es "
+#~ "invalida."
+
+#, c-format
+#~ msgid "could not be parsed"
+#~ msgstr "inválido"
+
+#, c-format
+#~ msgid "Confirmation the operation using"
+#~ msgstr "Confirme la operación usando"
+
+#, c-format
+#~ msgid "Is public"
+#~ msgstr "es publica"
+
+#, c-format
+#~ msgid "Logout"
+#~ msgstr "Cierre de sesión"
+
+#, c-format
+#~ msgid "Skip to main content"
+#~ msgstr "Saltar el menú de navegación"
+
+#, c-format
+#~ msgid "Taler logo"
+#~ msgstr "Logo Taler"
+
+#, c-format
+#~ msgid "Please login!"
+#~ msgstr "Por favor inicia sesión!"
+
+#, c-format
+#~ msgid "Login"
+#~ msgstr "Iniciar sesión"
+
+#, c-format
+#~ msgid "Missing IBAN"
+#~ msgstr "Falta IBAN"
+
+#, c-format
+#~ msgid "Missing subject"
+#~ msgstr "Falta asunto"
+
+#, c-format
+#~ msgid "Receiver IBAN:"
+#~ msgstr "IBAN receptor:"
+
+#, c-format
+#~ msgid "Amount:"
+#~ msgstr "Monto:"
+
+#, c-format
+#~ msgid "Field(s) missing."
+#~ msgstr "Faltan campo(s)."
+
+#, c-format
+#~ msgid "Want to try the raw payto://-format?"
+#~ msgstr "Quieres probar el formato payto:// ?"
+
+#, c-format
+#~ msgid "Missing payto address"
+#~ msgstr "Falta direccion payto"
+
+#, c-format
+#~ msgid "Transfer money to account identified by payto:// URI:"
+#~ msgstr "Transferir dinero a la cuenta identificada por la URI payto://:"
+
+#, c-format
+#~ msgid "payto address"
+#~ msgstr "direccion payto"
+
+#, c-format
+#~ msgid "Could not create the wire transfer"
+#~ msgstr "No se pudo create la transferencia bancaria"
+
+#, c-format
+#~ msgid "Transfer creation gave response error"
+#~ msgstr "La creación de la transferencia dió una respuesta erronea"
+
+#, c-format
+#~ msgid "No credentials given."
+#~ msgstr "Se dieron las credenciales incorrectas."
+
+#, c-format
+#~ msgid "Withdrawal creation gave response error"
+#~ msgstr "La creación de retiro dió una respuesta errónea"
+
+#, c-format
+#~ msgid "Obtain digital cash"
+#~ msgstr "Obtener dinero digital"
+
+#, c-format
+#~ msgid "Click %1$s to open your Taler wallet!"
+#~ msgstr "Click %1$s para abrir una cartera Taler!"
+
+#, c-format
+#~ msgid "Authorize withdrawal by solving challenge"
+#~ msgstr "Autorizar retiro resolviendo una pregunta"
+
+#, c-format
+#~ msgid "What is"
+#~ msgstr "Cuanto es"
+
+#, c-format
+#~ msgid "Answer is wrong."
+#~ msgstr "La respuesta es incorrecta."
+
+#, c-format
+#~ msgid ""
+#~ "A this point, a %1$s bank would ask for an additional authentication "
+#~ "proof (PIN/TAN, one time password, ..), instead of a simple calculation."
+#~ msgstr ""
+#~ "En este punto, un banco %1$s preguntaría por una prueba adicional de "
+#~ "autenticación (PIN/TAN, password de un solo uso, ....), en vez de un "
+#~ "simple cálculo."
+
+#, c-format
+#~ msgid "Could not confirm the withdrawal"
+#~ msgstr "No se pudo confirmar la retirada"
+
+#, c-format
+#~ msgid "Withdrawal confirmation gave response error"
+#~ msgstr "La confirmación de retiro dió una respuesta errónea"
+
+#, c-format
+#~ msgid "Withdrawal aborted!"
+#~ msgstr "Este retiro fue cancelado!"
+
+#, c-format
+#~ msgid "withdrawal (%1$s) was never (correctly) created at the bank..."
+#~ msgstr "retiro (%1$s) nunca fue (correctamente) generado en el banco..."
+
+#, c-format
+#~ msgid "Username or account label '%1$s' not found. Won't login."
+#~ msgstr ""
+#~ "Nombre de usuario o etiqueta de cuenta '%1$s' no encontrada. No se "
+#~ "iniciará sesión."
+
+#, c-format
+#~ msgid "Account information could not be retrieved."
+#~ msgstr "La información de la cuenta no pudo ser accedida."
+
+#, c-format
+#~ msgid "Bank account balance"
+#~ msgstr "Balance de cuenta bancaria"
+
+#, c-format
+#~ msgid "Payments"
+#~ msgstr "Pagos"
+
+#, c-format
+#~ msgid "List of public accounts could not be retrieved."
+#~ msgstr "La lista de cuentas públicas no pudo ser accedida."
+
+#, c-format
+#~ msgid "Please register!"
+#~ msgstr "Por favor, registrese!"
+
+#, c-format
+#~ msgid "New registration gave response error"
+#~ msgstr "Nuevo registro dió una respuesta errónea"
+
+#, c-format
+#~ msgid "Bank menu"
+#~ msgstr "Menu del banco"
+
+#, c-format
+#~ msgid "Select option2"
+#~ msgstr "Seleccione opción 2"
+
+#, c-format
+#~ msgid "days"
+#~ msgstr "días"
+
+#, c-format
+#~ msgid "hours"
+#~ msgstr "horas"
+
+#, c-format
+#~ msgid "minutes"
+#~ msgstr "minutos"
+
+#, c-format
+#~ msgid "seconds"
+#~ msgstr "segundos"
+
+#~ msgid "this link"
+#~ msgstr "este link"
diff --git a/packages/bank-ui/src/i18n/fr.po b/packages/bank-ui/src/i18n/fr.po
new file mode 100644
index 000000000..b02cbe618
--- /dev/null
+++ b/packages/bank-ui/src/i18n/fr.po
@@ -0,0 +1,1752 @@
+# This file is part of GNU Taler
+# (C) 2022-2024 Taler Systems S.A.
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Bank\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: 2024-02-28 08:07+0000\n"
+"Last-Translator: d0p1 <contact@d0p1.eu>\n"
+"Language-Team: French <https://weblate.taler.net/projects/gnu-taler/"
+"taler-bank-spa/fr/>\n"
+"Language: fr\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n > 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/utils.ts:137
+#, c-format
+msgid "Operation failed, please report"
+msgstr "L'opération a échoué, veuillez le signaler"
+
+#: src/utils.ts:156
+#, c-format
+msgid "Request timeout"
+msgstr ""
+
+#: src/utils.ts:165
+#, c-format
+msgid "Request throttled"
+msgstr ""
+
+#: src/utils.ts:174
+#, c-format
+msgid "Malformed response"
+msgstr ""
+
+#: src/utils.ts:183
+#, c-format
+msgid "Network error"
+msgstr "Erreur réseau"
+
+#: src/utils.ts:192
+#, c-format
+msgid "Unexpected request error"
+msgstr ""
+
+#: src/utils.ts:201
+#, c-format
+msgid "Unexpected error"
+msgstr ""
+
+#: src/utils.ts:377
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/utils.ts:379
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/utils.ts:387
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/utils.ts:401
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/context/config.ts:136
+#, c-format
+msgid ""
+"the bank backend is not supported. supported version \"%1$s\", server "
+"version \"%2$s\""
+msgstr ""
+
+#: src/hooks/preferences.ts:55
+#, c-format
+msgid "Max withdrawal amount"
+msgstr ""
+
+#: src/hooks/preferences.ts:57
+#, c-format
+msgid "Show withdrawal confirmation"
+msgstr ""
+
+#: src/hooks/preferences.ts:59
+#, c-format
+msgid "Show demo description"
+msgstr ""
+
+#: src/hooks/preferences.ts:61
+#, c-format
+msgid "Show install wallet first"
+msgstr ""
+
+#: src/hooks/preferences.ts:63
+#, c-format
+msgid "Use fast withdrawal form"
+msgstr ""
+
+#: src/hooks/preferences.ts:65
+#, c-format
+msgid "Show debug info"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:90
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:92
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:98
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:100
+#, c-format
+msgid "should be greater than 0"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "balance is not enough"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:112
+#, c-format
+msgid "does not follow the pattern"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:114
+#, c-format
+msgid "only \"IBAN\" target are supported"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:116
+#, c-format
+msgid "use the \"amount\" parameter to specify the amount to be transferred"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:118
+#, c-format
+msgid "the amount is not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:120
+#, c-format
+msgid ""
+"use the \"message\" parameter to specify a reference text for the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:160
+#, c-format
+msgid "The request was invalid or the payto://-URI used unacceptable features."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:167
+#, c-format
+msgid "Not enough permission to complete the operation."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:174
+#, c-format
+msgid "The destination account \"%1$s\" was not found."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:181
+#, c-format
+msgid "The origin and the destination of the transfer can't be the same."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:188
+#, c-format
+msgid "Your balance is not enough."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:195
+#, c-format
+msgid "The origin account \"%1$s\" was not found."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:212
+#, c-format
+msgid "Wire transfer created!"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:270
+#, c-format
+msgid "Using a form"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:310
+#, c-format
+msgid "Import payto:// URI"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:335
+#, c-format
+msgid "Recipient"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:359
+#, c-format
+msgid "IBAN of the recipient's account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:369
+#, c-format
+msgid "Transfer subject"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:377
+#, c-format
+msgid "subject"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:390
+#, c-format
+msgid "some text to identify the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:400
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:415
+#, c-format
+msgid "amount to transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:425
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:436
+#, c-format
+msgid "uniform resource identifier of the target account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:437
+#, c-format
+msgid "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:457
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:471
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:71
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:75
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:104
+#, c-format
+msgid "Wrong credentials for \"%1$s\""
+msgstr ""
+
+#: src/pages/LoginForm.tsx:111
+#, c-format
+msgid "Account not found"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:142
+#, c-format
+msgid "Username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:156
+#, c-format
+msgid "username of the account"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:175
+#, c-format
+msgid "Password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:188
+#, c-format
+msgid "password of the account"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:223
+#, c-format
+msgid "Check"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:237
+#, c-format
+msgid "Log in"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:249
+#, c-format
+msgid "Register"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:52
+#, c-format
+msgid "Latest transactions"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:63
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:75
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:111
+#, c-format
+msgid "sent"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:112
+#, c-format
+msgid "received"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:127
+#, c-format
+msgid "invalid value"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "to"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "from"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:202
+#, c-format
+msgid "First page"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:209
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:86
+#, c-format
+msgid "Wire transfer completed!"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:93
+#, c-format
+msgid "The withdrawal has been aborted previously and can't be confirmed"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:100
+#, c-format
+msgid ""
+"The withdrawal operation can't be confirmed before a wallet accepted the "
+"transaction."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:107
+#, c-format
+msgid "The operation id is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:114
+#, c-format
+msgid "The operation was not found."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:121
+#, c-format
+msgid "Your balance is not enough for the operation."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:155
+#, c-format
+msgid ""
+"The reserve operation has been confirmed previously and can't be aborted"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:186
+#, c-format
+msgid "Confirm the withdrawal operation"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:203
+#, c-format
+msgid "Wire transfer details"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:217
+#, c-format
+msgid "Taler Exchange operator's account"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:228
+#, c-format
+msgid "Taler Exchange operator's name"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:317
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:342
+#, c-format
+msgid "Authentication required"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:352
+#, c-format
+msgid "This operation was created with other username"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:209
+#, c-format
+msgid ""
+"Unauthorized to make the operation, maybe the session has expired or the "
+"password changed."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:218
+#, c-format
+msgid "The operation was rejected due to insufficient funds."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:268
+#, c-format
+msgid "Withdrawal confirmed"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:272
+#, c-format
+msgid ""
+"The wire transfer to the Taler operator has been initiated. You will soon "
+"receive the requested amount in your Taler wallet."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:287
+#, c-format
+msgid "Do not show this again"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:319
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:399
+#, c-format
+msgid "On this device"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:404
+#, c-format
+msgid ""
+"If you are using a web browser on desktop you should access your wallet with "
+"the GNU Taler WebExtension now or click the link if your WebExtension have "
+"the \"Inject Taler support\" option enabled."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:417
+#, c-format
+msgid "Start"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:426
+#, c-format
+msgid "On a mobile phone"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:431
+#, c-format
+msgid "Scan the QR code with your mobile device."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:73
+#, c-format
+msgid "There is an operation already"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:75
+#, c-format
+msgid "Complete or cancel the operation in"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "this page"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:101
+#, c-format
+msgid "invalid"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:116
+#, c-format
+msgid "Server responded with an invalid withdraw URI"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:117
+#, c-format
+msgid "Withdraw URI: %1$s"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:132
+#, c-format
+msgid "The operation was rejected due to insufficient funds"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:253
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:282
+#, c-format
+msgid "Prepare your wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:285
+#, c-format
+msgid ""
+"After using your wallet you will need to confirm or cancel the operation on "
+"this site."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:295
+#, c-format
+msgid "You need a GNU Taler Wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:300
+#, c-format
+msgid "If you don't have one yet you can follow the instruction in"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:55
+#, c-format
+msgid "Send money"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:73
+#, c-format
+msgid "to a %1$s wallet"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:95
+#, c-format
+msgid "Withdraw digital money into your mobile wallet or browser extension"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:109
+#, c-format
+msgid "operation ready"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:129
+#, c-format
+msgid "to another bank account"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:149
+#, c-format
+msgid "Make a wire transfer to an account with known bank account number."
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:171
+#, c-format
+msgid "Transfer details"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:41
+#, c-format
+msgid "This is a demo bank"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:46
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work. In addition to using your own bank account, you can also see the "
+"transaction history of some %1$s."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:53
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:70
+#, c-format
+msgid "Pending account delete operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:72
+#, c-format
+msgid "Pending account update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:74
+#, c-format
+msgid "Pending password update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:76
+#, c-format
+msgid "Pending transaction operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:78
+#, c-format
+msgid "Pending withdrawal operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:80
+#, c-format
+msgid "Pending cashout operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:91
+#, c-format
+msgid "You can complete or cancel the operation in"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:64
+#, c-format
+msgid "Internal error, please report."
+msgstr ""
+
+#: src/pages/BankFrame.tsx:100
+#, c-format
+msgid "Preferences"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:184
+#, c-format
+msgid "Welcome, %1$s"
+msgstr ""
+
+#: src/pages/WireTransfer.tsx:79
+#, c-format
+msgid "Make a wire transfer"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:72
+#, c-format
+msgid "Accounts"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:75
+#, c-format
+msgid "A list of all business account in the bank."
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:86
+#, c-format
+msgid "Create account"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:106
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:110
+#, c-format
+msgid "Balance"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:112
+#, c-format
+msgid "Actions"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:151
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:170
+#, c-format
+msgid "change password"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:179
+#, c-format
+msgid "cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:189
+#, c-format
+msgid "remove"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:168
+#, c-format
+msgid "Cashout not implemented"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:184
+#, c-format
+msgid "Select a section"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:202
+#, c-format
+msgid "Last hour"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:208
+#, c-format
+msgid "Last day"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:216
+#, c-format
+msgid "Last month"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:222
+#, c-format
+msgid "Last year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:310
+#, c-format
+msgid "Last Year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:325
+#, c-format
+msgid "Trading volume on %1$s compared to %2$s"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:342
+#, c-format
+msgid "Cashin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:352
+#, c-format
+msgid "Cashout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:364
+#, c-format
+msgid "Payin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:374
+#, c-format
+msgid "Payout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:388
+#, c-format
+msgid "download stats as CSV"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:494
+#, c-format
+msgid "Decreased by"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:498
+#, c-format
+msgid "Increased by"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:89
+#, c-format
+msgid "Download bank stats"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:110
+#, c-format
+msgid "Include hour metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:143
+#, c-format
+msgid "Include day metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:173
+#, c-format
+msgid "Include month metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:206
+#, c-format
+msgid "Include year metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:239
+#, c-format
+msgid "Include table header"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:272
+#, c-format
+msgid "Add previous metric for compare"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:307
+#, c-format
+msgid "Fail on first error"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:364
+#, c-format
+msgid "Download"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:381
+#, c-format
+msgid "downloading... %1$s"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:399
+#, c-format
+msgid "Download completed"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:400
+#, c-format
+msgid "click here to save the file in your computer"
+msgstr ""
+
+#: src/pages/PublicHistoriesPage.tsx:78
+#, c-format
+msgid "History of public accounts"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:48
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:87
+#, c-format
+msgid "Missing name"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:91
+#, c-format
+msgid "Use letters and numbers only, and start with a lowercase letter"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:107
+#, c-format
+msgid "Passwords don't match"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:130
+#, c-format
+msgid "Server replied with invalid phone or email."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:137
+#, c-format
+msgid "Registration is disabled because the bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:144
+#, c-format
+msgid "No enough permission to create that account."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:151
+#, c-format
+msgid "That account id is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:158
+#, c-format
+msgid "That username is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:165
+#, c-format
+msgid "That username can't be used because is reserved."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:172
+#, c-format
+msgid "Only admin is allow to set debt limit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:179
+#, c-format
+msgid "No information for the selected authentication channel."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:186
+#, c-format
+msgid "Authentication channel is not supported."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:193
+#, c-format
+msgid "Only admin can create accounts with second factor authentication."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:233
+#, c-format
+msgid "Account registration"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:315
+#, c-format
+msgid "Repeat password"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:457
+#, c-format
+msgid "Create a random temporary user"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:110
+#, c-format
+msgid "If you have a Taler wallet installed in this device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:116
+#, c-format
+msgid ""
+"You will see the details of the operation in your wallet including the fees "
+"(if applies). If you still don't have one you can install it following "
+"instructions in"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:143
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:152
+#, c-format
+msgid "Or if you have the wallet in another device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:157
+#, c-format
+msgid "Scan the QR below to start the withdrawal."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:79
+#, c-format
+msgid "Operation aborted"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:82
+#, c-format
+msgid ""
+"The wire transfer to the Taler Exchange operator's account was aborted, your "
+"balance was not affected."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "You can close this page now or continue to the account page."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:147
+#, c-format
+msgid "Done"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:158
+#, c-format
+msgid "Operation canceled"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:173
+#, c-format
+msgid ""
+"The operation is marked as 'selected' but some step in the withdrawal failed"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:175
+#, c-format
+msgid "The account is selected but no withdrawal identification found."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:188
+#, c-format
+msgid ""
+"There is a withdrawal identification but no account has been selected or the "
+"selected account is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:202
+#, c-format
+msgid ""
+"No withdrawal ID found and no account has been selected or the selected "
+"account is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:259
+#, c-format
+msgid "Operation not found"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:263
+#, c-format
+msgid ""
+"This operation is not known by the server. The operation id is wrong or the "
+"server deleted the operation information before reaching here."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:278
+#, c-format
+msgid "Cotinue to dashboard"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:98
+#, c-format
+msgid "Cashout not found. It may be also mean that it was already aborted."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:136
+#, c-format
+msgid "Challenge not found."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:143
+#, c-format
+msgid "This user is not authorized to complete this challenge."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:150
+#, c-format
+msgid "Too many attempts, try another code."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:157
+#, c-format
+msgid "The confirmation code is wrong, try again."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:164
+#, c-format
+msgid "The operation expired."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:197
+#, c-format
+msgid "The operation failed."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:212
+#, c-format
+msgid "The operation needs another confirmation to complete."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:224
+#, c-format
+msgid "Account delete"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:226
+#, c-format
+msgid "Account update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:228
+#, c-format
+msgid "Password update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:230
+#, c-format
+msgid "Wire transfer"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:232
+#, c-format
+msgid "Withdrawal"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:248
+#, c-format
+msgid "Confirm the operation"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:271
+#, c-format
+msgid "Enter the confirmation code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:313
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:348
+#, c-format
+msgid "Send again"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:359
+#, c-format
+msgid "Send code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:369
+#, c-format
+msgid "Operation details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:529
+#, c-format
+msgid "Challenge details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:536
+#, c-format
+msgid "Sent at"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:551
+#, c-format
+msgid "To phone"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:553
+#, c-format
+msgid "To email"
+msgstr ""
+
+#: src/pages/WithdrawalOperationPage.tsx:49
+#, c-format
+msgid "The Withdrawal URI is not valid"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:100
+#, c-format
+msgid "Latest cashouts"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:111
+#, c-format
+msgid "Created"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:115
+#, c-format
+msgid "Total debit"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:119
+#, c-format
+msgid "Total credit"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:70
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:74
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:78
+#, c-format
+msgid "Credentials"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:82
+#, c-format
+msgid "Cashouts"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:95
+#, c-format
+msgid "Unable to create a cashout"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:96
+#, c-format
+msgid "The bank configuration does not support cashout operations."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:223
+#, c-format
+msgid "need to be higher due to fees"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:225
+#, c-format
+msgid "the total transfer at destination will be zero"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:250
+#, c-format
+msgid "Cashout created"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:272
+#, c-format
+msgid ""
+"Duplicated request detected, check if the operation succeeded or try again."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:279
+#, c-format
+msgid "The conversion rate was incorrectly applied"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:286
+#, c-format
+msgid "The account does not have sufficient funds"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:293
+#, c-format
+msgid "Cashouts are not supported"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:300
+#, c-format
+msgid "Missing cashout URI in the profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:307
+#, c-format
+msgid ""
+"Sending the confirmation message failed, retry later or contact the "
+"administrator."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:339
+#, c-format
+msgid "Conversion rate"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:360
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:374
+#, c-format
+msgid "To account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:381
+#, c-format
+msgid "No cashout account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:382
+#, c-format
+msgid "Before doing a cashout you need to complete your profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:440
+#, c-format
+msgid "Amount to send"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:441
+#, c-format
+msgid "Amount to receive"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:490
+#, c-format
+msgid "Total cost"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:505
+#, c-format
+msgid "Balance left"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:520
+#, c-format
+msgid "Before fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:533
+#, c-format
+msgid "Total cashout transfer"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:553
+#, c-format
+msgid "No cashout channel available"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:555
+#, c-format
+msgid ""
+"Before doing a cashout the server need to provide an second channel to "
+"confirm the operation"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:567
+#, c-format
+msgid "Second factor authentication"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:598
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:600
+#, c-format
+msgid "add a email in your profile to enable this option"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:646
+#, c-format
+msgid "SMS"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:648
+#, c-format
+msgid "add a phone number in your profile to enable this option"
+msgstr ""
+
+#: src/pages/account/CashoutListForAccount.tsx:52
+#, c-format
+msgid "Cashout for account %1$s"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:165
+#, c-format
+msgid "it doesn't have the pattern of an IBAN number"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:185
+#, c-format
+msgid "it doesn't have the pattern of an email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:190
+#, c-format
+msgid "should start with +"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:192
+#, c-format
+msgid "phone number can't have other than numbers"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:329
+#, c-format
+msgid "account identification in the bank"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:365
+#, c-format
+msgid "name of the person owner the account"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:374
+#, c-format
+msgid "Internal IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:377
+#, c-format
+msgid "if empty a random account number will be assigned"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:378
+#, c-format
+msgid "account identification for bank transfer"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:423
+#, c-format
+msgid "Phone"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:451
+#, c-format
+msgid "Cashout IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:452
+#, c-format
+msgid "account number where the money is going to be sent when doing cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:470
+#, c-format
+msgid "Max debt"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:494
+#, c-format
+msgid "how much is user able to transfer after zero balance"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:508
+#, c-format
+msgid "Is this a Taler Exchange?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:549
+#, c-format
+msgid "This server doesn't support second factor authentication."
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:560
+#, c-format
+msgid "Enable second factor authentication"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:596
+#, c-format
+msgid "Using email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:654
+#, c-format
+msgid "Using SMS"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:691
+#, c-format
+msgid "Is this account public?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:719
+#, c-format
+msgid "public accounts have their balance publicly accessible"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:100
+#, c-format
+msgid "Account updated"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:107
+#, c-format
+msgid "The rights to change the account are not sufficient"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:114
+#, c-format
+msgid "The username was not found"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:121
+#, c-format
+msgid ""
+"You can't change the legal name, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:128
+#, c-format
+msgid ""
+"You can't change the debt limit, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:135
+#, c-format
+msgid ""
+"You can't change the cashout address, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:177
+#, c-format
+msgid "Account \"%1$s\""
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:190
+#, c-format
+msgid "Change details"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:235
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:78
+#, c-format
+msgid "password doesn't match"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:95
+#, c-format
+msgid "Password changed"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:102
+#, c-format
+msgid "Not authorized to change the password, maybe the session is invalid."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:112
+#, c-format
+msgid ""
+"You need to provide the old password. If you don't have it contact your "
+"account administrator."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:117
+#, c-format
+msgid "Your current password doesn't match, can't change to a new password."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:149
+#, c-format
+msgid "Update password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:167
+#, c-format
+msgid "New password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:195
+#, c-format
+msgid "Type it again"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:217
+#, c-format
+msgid "repeat the same password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:227
+#, c-format
+msgid "Current password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:248
+#, c-format
+msgid "your current password, for security"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:272
+#, c-format
+msgid "Change"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:74
+#, c-format
+msgid ""
+"Account created with password \"%1$s\". The user must change the password on "
+"the next login."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:83
+#, c-format
+msgid "Server replied that phone or email is invalid"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:90
+#, c-format
+msgid "The rights to perform the operation are not sufficient"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:97
+#, c-format
+msgid "Account username is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:104
+#, c-format
+msgid "Account id is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:111
+#, c-format
+msgid "Bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:118
+#, c-format
+msgid "Account username can't be used because is reserved"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:160
+#, c-format
+msgid "Can't create accounts"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:161
+#, c-format
+msgid "Only system admin can create accounts."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:183
+#, c-format
+msgid "New business account"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:209
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:94
+#, c-format
+msgid "Can't delete the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:95
+#, c-format
+msgid ""
+"The account can't be delete while still holding some balance. First make "
+"sure that the owner make a complete cashout."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:117
+#, c-format
+msgid "Account removed"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:124
+#, c-format
+msgid "No enough permission to delete the account."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:131
+#, c-format
+msgid "The username was not found."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:138
+#, c-format
+msgid "Can't delete a reserved username."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:145
+#, c-format
+msgid "Can't delete an account with balance different than zero."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:170
+#, c-format
+msgid "name doesn't match"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:180
+#, c-format
+msgid "You are going to remove the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:182
+#, c-format
+msgid "This step can't be undone."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:188
+#, c-format
+msgid "Deleting account \"%1$s\""
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:206
+#, c-format
+msgid "Verification"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:231
+#, c-format
+msgid "enter the account name that is going to be deleted"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:49
+#, c-format
+msgid "cashout id should be a number"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:65
+#, c-format
+msgid "This cashout not found. Maybe already aborted."
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:106
+#, c-format
+msgid "Cashout detail"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:139
+#, c-format
+msgid "Debited"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:154
+#, c-format
+msgid "Credited"
+msgstr ""
+
+#: src/Routing.tsx:140
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
diff --git a/packages/bank-ui/src/i18n/it.po b/packages/bank-ui/src/i18n/it.po
new file mode 100644
index 000000000..7aeaca3a8
--- /dev/null
+++ b/packages/bank-ui/src/i18n/it.po
@@ -0,0 +1,1843 @@
+# This file is part of GNU Taler
+# (C) 2021 Taler Systems S.A.
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2023-08-15 07:28+0000\n"
+"Last-Translator: Krystian Baran <kiszkot@murena.io>\n"
+"Language-Team: Italian <https://weblate.taler.net/projects/gnu-taler/taler-"
+"bank-spa/it/>\n"
+"Language: it\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.13.1\n"
+
+#: src/utils.ts:137
+#, fuzzy, c-format
+msgid "Operation failed, please report"
+msgstr "Registrazione"
+
+#: src/utils.ts:156
+#, c-format
+msgid "Request timeout"
+msgstr ""
+
+#: src/utils.ts:165
+#, c-format
+msgid "Request throttled"
+msgstr ""
+
+#: src/utils.ts:174
+#, c-format
+msgid "Malformed response"
+msgstr ""
+
+#: src/utils.ts:183
+#, c-format
+msgid "Network error"
+msgstr ""
+
+#: src/utils.ts:192
+#, c-format
+msgid "Unexpected request error"
+msgstr ""
+
+#: src/utils.ts:201
+#, c-format
+msgid "Unexpected error"
+msgstr ""
+
+#: src/utils.ts:377
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/utils.ts:379
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/utils.ts:387
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/utils.ts:401
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/context/config.ts:136
+#, c-format
+msgid ""
+"the bank backend is not supported. supported version \"%1$s\", server "
+"version \"%2$s\""
+msgstr ""
+
+#: src/hooks/preferences.ts:55
+#, fuzzy, c-format
+msgid "Max withdrawal amount"
+msgstr "Questo ritiro è stato annullato!"
+
+#: src/hooks/preferences.ts:57
+#, fuzzy, c-format
+msgid "Show withdrawal confirmation"
+msgstr "Questo ritiro è stato annullato!"
+
+#: src/hooks/preferences.ts:59
+#, c-format
+msgid "Show demo description"
+msgstr ""
+
+#: src/hooks/preferences.ts:61
+#, c-format
+msgid "Show install wallet first"
+msgstr ""
+
+#: src/hooks/preferences.ts:63
+#, fuzzy, c-format
+msgid "Use fast withdrawal form"
+msgstr "Ritira contante"
+
+#: src/hooks/preferences.ts:65
+#, c-format
+msgid "Show debug info"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:90
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:92
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:98
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:100
+#, c-format
+msgid "should be greater than 0"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "balance is not enough"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:112
+#, c-format
+msgid "does not follow the pattern"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:114
+#, c-format
+msgid "only \"IBAN\" target are supported"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:116
+#, c-format
+msgid "use the \"amount\" parameter to specify the amount to be transferred"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:118
+#, c-format
+msgid "the amount is not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:120
+#, c-format
+msgid ""
+"use the \"message\" parameter to specify a reference text for the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:160
+#, c-format
+msgid "The request was invalid or the payto://-URI used unacceptable features."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:167
+#, fuzzy, c-format
+msgid "Not enough permission to complete the operation."
+msgstr "La banca sta creando l'operazione..."
+
+#: src/pages/PaytoWireTransferForm.tsx:174
+#, fuzzy, c-format
+msgid "The destination account \"%1$s\" was not found."
+msgstr "Lista conti pubblici non trovata."
+
+#: src/pages/PaytoWireTransferForm.tsx:181
+#, c-format
+msgid "The origin and the destination of the transfer can't be the same."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:188
+#, c-format
+msgid "Your balance is not enough."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:195
+#, fuzzy, c-format
+msgid "The origin account \"%1$s\" was not found."
+msgstr "Lista conti pubblici non trovata."
+
+#: src/pages/PaytoWireTransferForm.tsx:212
+#, fuzzy, c-format
+msgid "Wire transfer created!"
+msgstr "Bonifico"
+
+#: src/pages/PaytoWireTransferForm.tsx:270
+#, c-format
+msgid "Using a form"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:310
+#, c-format
+msgid "Import payto:// URI"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:335
+#, c-format
+msgid "Recipient"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:359
+#, c-format
+msgid "IBAN of the recipient's account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:369
+#, fuzzy, c-format
+msgid "Transfer subject"
+msgstr "Trasferisci fondi a un altro conto di questa banca:"
+
+#: src/pages/PaytoWireTransferForm.tsx:377
+#, fuzzy, c-format
+msgid "subject"
+msgstr "Soggetto"
+
+#: src/pages/PaytoWireTransferForm.tsx:390
+#, c-format
+msgid "some text to identify the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:400
+#, c-format
+msgid "Amount"
+msgstr "Importo"
+
+#: src/pages/PaytoWireTransferForm.tsx:415
+#, fuzzy, c-format
+msgid "amount to transfer"
+msgstr "Somma da ritirare"
+
+#: src/pages/PaytoWireTransferForm.tsx:425
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:436
+#, c-format
+msgid "uniform resource identifier of the target account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:437
+#, c-format
+msgid "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:457
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:471
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:71
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:75
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:104
+#, fuzzy, c-format
+msgid "Wrong credentials for \"%1$s\""
+msgstr "Credenziali invalide."
+
+#: src/pages/LoginForm.tsx:111
+#, c-format
+msgid "Account not found"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:142
+#, c-format
+msgid "Username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:156
+#, fuzzy, c-format
+msgid "username of the account"
+msgstr "Trasferisci fondi a un altro conto di questa banca:"
+
+#: src/pages/LoginForm.tsx:175
+#, c-format
+msgid "Password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:188
+#, fuzzy, c-format
+msgid "password of the account"
+msgstr "Storico dei conti pubblici"
+
+#: src/pages/LoginForm.tsx:223
+#, c-format
+msgid "Check"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:237
+#, c-format
+msgid "Log in"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:249
+#, c-format
+msgid "Register"
+msgstr "Registrati"
+
+#: src/components/Transactions/views.tsx:52
+#, fuzzy, c-format
+msgid "Latest transactions"
+msgstr "Ultime transazioni:"
+
+#: src/components/Transactions/views.tsx:63
+#, c-format
+msgid "Date"
+msgstr "Data"
+
+#: src/components/Transactions/views.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr "Controparte"
+
+#: src/components/Transactions/views.tsx:75
+#, c-format
+msgid "Subject"
+msgstr "Soggetto"
+
+#: src/components/Transactions/views.tsx:111
+#, c-format
+msgid "sent"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:112
+#, c-format
+msgid "received"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:127
+#, c-format
+msgid "invalid value"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "to"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "from"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:202
+#, c-format
+msgid "First page"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:209
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:86
+#, fuzzy, c-format
+msgid "Wire transfer completed!"
+msgstr "Bonifico"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:93
+#, c-format
+msgid "The withdrawal has been aborted previously and can't be confirmed"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:100
+#, c-format
+msgid ""
+"The withdrawal operation can't be confirmed before a wallet accepted the "
+"transaction."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:107
+#, c-format
+msgid "The operation id is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:114
+#, fuzzy, c-format
+msgid "The operation was not found."
+msgstr "Lista conti pubblici non trovata."
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:121
+#, c-format
+msgid "Your balance is not enough for the operation."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:155
+#, c-format
+msgid ""
+"The reserve operation has been confirmed previously and can't be aborted"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:186
+#, fuzzy, c-format
+msgid "Confirm the withdrawal operation"
+msgstr "Conferma il ritiro"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:203
+#, fuzzy, c-format
+msgid "Wire transfer details"
+msgstr "Bonifico"
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:217
+#, c-format
+msgid "Taler Exchange operator's account"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:228
+#, c-format
+msgid "Taler Exchange operator's name"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:317
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:342
+#, c-format
+msgid "Authentication required"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:352
+#, c-format
+msgid "This operation was created with other username"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:209
+#, c-format
+msgid ""
+"Unauthorized to make the operation, maybe the session has expired or the "
+"password changed."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:218
+#, c-format
+msgid "The operation was rejected due to insufficient funds."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:268
+#, fuzzy, c-format
+msgid "Withdrawal confirmed"
+msgstr "Questo ritiro è stato annullato!"
+
+#: src/pages/OperationState/views.tsx:272
+#, c-format
+msgid ""
+"The wire transfer to the Taler operator has been initiated. You will soon "
+"receive the requested amount in your Taler wallet."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:287
+#, c-format
+msgid "Do not show this again"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:319
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:399
+#, c-format
+msgid "On this device"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:404
+#, c-format
+msgid ""
+"If you are using a web browser on desktop you should access your wallet with "
+"the GNU Taler WebExtension now or click the link if your WebExtension have "
+"the \"Inject Taler support\" option enabled."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:417
+#, c-format
+msgid "Start"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:426
+#, c-format
+msgid "On a mobile phone"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:431
+#, fuzzy, c-format
+msgid "Scan the QR code with your mobile device."
+msgstr "Usa questo codice QR per ritirare contante nel tuo wallet:"
+
+#: src/pages/WalletWithdrawForm.tsx:73
+#, c-format
+msgid "There is an operation already"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:75
+#, fuzzy, c-format
+msgid "Complete or cancel the operation in"
+msgstr "Conferma il ritiro"
+
+#: src/pages/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "this page"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:101
+#, c-format
+msgid "invalid"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:116
+#, c-format
+msgid "Server responded with an invalid withdraw URI"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:117
+#, fuzzy, c-format
+msgid "Withdraw URI: %1$s"
+msgstr "Prelevare"
+
+#: src/pages/WalletWithdrawForm.tsx:132
+#, c-format
+msgid "The operation was rejected due to insufficient funds"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:253
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:282
+#, c-format
+msgid "Prepare your wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:285
+#, c-format
+msgid ""
+"After using your wallet you will need to confirm or cancel the operation on "
+"this site."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:295
+#, fuzzy, c-format
+msgid "You need a GNU Taler Wallet"
+msgstr "Ritira contante nel portafoglio Taler"
+
+#: src/pages/WalletWithdrawForm.tsx:300
+#, c-format
+msgid "If you don't have one yet you can follow the instruction in"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:55
+#, c-format
+msgid "Send money"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:73
+#, c-format
+msgid "to a %1$s wallet"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:95
+#, c-format
+msgid "Withdraw digital money into your mobile wallet or browser extension"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:109
+#, c-format
+msgid "operation ready"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:129
+#, fuzzy, c-format
+msgid "to another bank account"
+msgstr "Trasferisci fondi a un altro conto di questa banca:"
+
+#: src/pages/PaymentOptions.tsx:149
+#, c-format
+msgid "Make a wire transfer to an account with known bank account number."
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:171
+#, fuzzy, c-format
+msgid "Transfer details"
+msgstr "Effettua un bonifico"
+
+#: src/pages/AccountPage/views.tsx:41
+#, c-format
+msgid "This is a demo bank"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:46
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work. In addition to using your own bank account, you can also see the "
+"transaction history of some %1$s."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:53
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would "
+"work."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:70
+#, c-format
+msgid "Pending account delete operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:72
+#, c-format
+msgid "Pending account update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:74
+#, c-format
+msgid "Pending password update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:76
+#, c-format
+msgid "Pending transaction operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:78
+#, c-format
+msgid "Pending withdrawal operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:80
+#, c-format
+msgid "Pending cashout operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:91
+#, c-format
+msgid "You can complete or cancel the operation in"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:64
+#, fuzzy, c-format
+msgid "Internal error, please report."
+msgstr "Registrazione"
+
+#: src/pages/BankFrame.tsx:100
+#, c-format
+msgid "Preferences"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:184
+#, c-format
+msgid "Welcome, %1$s"
+msgstr ""
+
+#: src/pages/WireTransfer.tsx:79
+#, fuzzy, c-format
+msgid "Make a wire transfer"
+msgstr "Chiudi il bonifico"
+
+#: src/pages/admin/AccountList.tsx:72
+#, fuzzy, c-format
+msgid "Accounts"
+msgstr "Importo"
+
+#: src/pages/admin/AccountList.tsx:75
+#, c-format
+msgid "A list of all business account in the bank."
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:86
+#, c-format
+msgid "Create account"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:106
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:110
+#, c-format
+msgid "Balance"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:112
+#, c-format
+msgid "Actions"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:151
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:170
+#, c-format
+msgid "change password"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:179
+#, fuzzy, c-format
+msgid "cashouts"
+msgstr "Ultime transazioni:"
+
+#: src/pages/admin/AccountList.tsx:189
+#, c-format
+msgid "remove"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:168
+#, c-format
+msgid "Cashout not implemented"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:184
+#, c-format
+msgid "Select a section"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:202
+#, c-format
+msgid "Last hour"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:208
+#, c-format
+msgid "Last day"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:216
+#, c-format
+msgid "Last month"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:222
+#, c-format
+msgid "Last year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:310
+#, c-format
+msgid "Last Year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:325
+#, c-format
+msgid "Trading volume on %1$s compared to %2$s"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:342
+#, c-format
+msgid "Cashin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:352
+#, c-format
+msgid "Cashout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:364
+#, c-format
+msgid "Payin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:374
+#, c-format
+msgid "Payout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:388
+#, c-format
+msgid "download stats as CSV"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:494
+#, c-format
+msgid "Decreased by"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:498
+#, c-format
+msgid "Increased by"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:89
+#, c-format
+msgid "Download bank stats"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:110
+#, c-format
+msgid "Include hour metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:143
+#, c-format
+msgid "Include day metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:173
+#, c-format
+msgid "Include month metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:206
+#, c-format
+msgid "Include year metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:239
+#, c-format
+msgid "Include table header"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:272
+#, c-format
+msgid "Add previous metric for compare"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:307
+#, c-format
+msgid "Fail on first error"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:364
+#, c-format
+msgid "Download"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:381
+#, c-format
+msgid "downloading... %1$s"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:399
+#, c-format
+msgid "Download completed"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:400
+#, c-format
+msgid "click here to save the file in your computer"
+msgstr ""
+
+#: src/pages/PublicHistoriesPage.tsx:78
+#, c-format
+msgid "History of public accounts"
+msgstr "Storico dei conti pubblici"
+
+#: src/pages/RegistrationPage.tsx:48
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:87
+#, fuzzy, c-format
+msgid "Missing name"
+msgstr "indirizzo Payto"
+
+#: src/pages/RegistrationPage.tsx:91
+#, c-format
+msgid "Use letters and numbers only, and start with a lowercase letter"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:107
+#, c-format
+msgid "Passwords don't match"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:130
+#, c-format
+msgid "Server replied with invalid phone or email."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:137
+#, c-format
+msgid "Registration is disabled because the bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:144
+#, c-format
+msgid "No enough permission to create that account."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:151
+#, c-format
+msgid "That account id is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:158
+#, c-format
+msgid "That username is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:165
+#, c-format
+msgid "That username can't be used because is reserved."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:172
+#, c-format
+msgid "Only admin is allow to set debt limit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:179
+#, c-format
+msgid "No information for the selected authentication channel."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:186
+#, c-format
+msgid "Authentication channel is not supported."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:193
+#, c-format
+msgid "Only admin can create accounts with second factor authentication."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:233
+#, c-format
+msgid "Account registration"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:315
+#, c-format
+msgid "Repeat password"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:457
+#, c-format
+msgid "Create a random temporary user"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:110
+#, c-format
+msgid "If you have a Taler wallet installed in this device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:116
+#, c-format
+msgid ""
+"You will see the details of the operation in your wallet including the fees "
+"(if applies). If you still don't have one you can install it following "
+"instructions in"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:143
+#, c-format
+msgid "Withdraw"
+msgstr "Prelevare"
+
+#: src/pages/QrCodeSection.tsx:152
+#, c-format
+msgid "Or if you have the wallet in another device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:157
+#, fuzzy, c-format
+msgid "Scan the QR below to start the withdrawal."
+msgstr "Chiudi il ritiro Taler"
+
+#: src/pages/WithdrawalQRCode.tsx:79
+#, c-format
+msgid "Operation aborted"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:82
+#, c-format
+msgid ""
+"The wire transfer to the Taler Exchange operator's account was aborted, your "
+"balance was not affected."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "You can close this page now or continue to the account page."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:147
+#, c-format
+msgid "Done"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:158
+#, c-format
+msgid "Operation canceled"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:173
+#, c-format
+msgid ""
+"The operation is marked as 'selected' but some step in the withdrawal failed"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:175
+#, c-format
+msgid "The account is selected but no withdrawal identification found."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:188
+#, c-format
+msgid ""
+"There is a withdrawal identification but no account has been selected or the "
+"selected account is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:202
+#, c-format
+msgid ""
+"No withdrawal ID found and no account has been selected or the selected "
+"account is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:259
+#, c-format
+msgid "Operation not found"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:263
+#, c-format
+msgid ""
+"This operation is not known by the server. The operation id is wrong or the "
+"server deleted the operation information before reaching here."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:278
+#, c-format
+msgid "Cotinue to dashboard"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:98
+#, c-format
+msgid "Cashout not found. It may be also mean that it was already aborted."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:136
+#, c-format
+msgid "Challenge not found."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:143
+#, c-format
+msgid "This user is not authorized to complete this challenge."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:150
+#, c-format
+msgid "Too many attempts, try another code."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:157
+#, c-format
+msgid "The confirmation code is wrong, try again."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:164
+#, c-format
+msgid "The operation expired."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:197
+#, fuzzy, c-format
+msgid "The operation failed."
+msgstr "Questo ritiro è stato annullato!"
+
+#: src/pages/SolveChallengePage.tsx:212
+#, c-format
+msgid "The operation needs another confirmation to complete."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:224
+#, c-format
+msgid "Account delete"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:226
+#, c-format
+msgid "Account update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:228
+#, c-format
+msgid "Password update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:230
+#, fuzzy, c-format
+msgid "Wire transfer"
+msgstr "Bonifico"
+
+#: src/pages/SolveChallengePage.tsx:232
+#, fuzzy, c-format
+msgid "Withdrawal"
+msgstr "Prelevare"
+
+#: src/pages/SolveChallengePage.tsx:248
+#, fuzzy, c-format
+msgid "Confirm the operation"
+msgstr "Conferma il ritiro"
+
+#: src/pages/SolveChallengePage.tsx:271
+#, c-format
+msgid "Enter the confirmation code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:313
+#, c-format
+msgid "Confirm"
+msgstr "Conferma"
+
+#: src/pages/SolveChallengePage.tsx:348
+#, c-format
+msgid "Send again"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:359
+#, c-format
+msgid "Send code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:369
+#, c-format
+msgid "Operation details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:529
+#, c-format
+msgid "Challenge details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:536
+#, c-format
+msgid "Sent at"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:551
+#, c-format
+msgid "To phone"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:553
+#, c-format
+msgid "To email"
+msgstr ""
+
+#: src/pages/WithdrawalOperationPage.tsx:49
+#, fuzzy, c-format
+msgid "The Withdrawal URI is not valid"
+msgstr "Questo ritiro è stato annullato!"
+
+#: src/components/Cashouts/views.tsx:100
+#, fuzzy, c-format
+msgid "Latest cashouts"
+msgstr "Ultime transazioni:"
+
+#: src/components/Cashouts/views.tsx:111
+#, c-format
+msgid "Created"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:115
+#, c-format
+msgid "Total debit"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:119
+#, c-format
+msgid "Total credit"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:70
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:74
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:78
+#, fuzzy, c-format
+msgid "Credentials"
+msgstr "Credenziali invalide."
+
+#: src/pages/ProfileNavigation.tsx:82
+#, c-format
+msgid "Cashouts"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:95
+#, c-format
+msgid "Unable to create a cashout"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:96
+#, c-format
+msgid "The bank configuration does not support cashout operations."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:223
+#, c-format
+msgid "need to be higher due to fees"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:225
+#, c-format
+msgid "the total transfer at destination will be zero"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:250
+#, c-format
+msgid "Cashout created"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:272
+#, c-format
+msgid ""
+"Duplicated request detected, check if the operation succeeded or try again."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:279
+#, c-format
+msgid "The conversion rate was incorrectly applied"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:286
+#, c-format
+msgid "The account does not have sufficient funds"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:293
+#, c-format
+msgid "Cashouts are not supported"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:300
+#, c-format
+msgid "Missing cashout URI in the profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:307
+#, c-format
+msgid ""
+"Sending the confirmation message failed, retry later or contact the "
+"administrator."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:339
+#, c-format
+msgid "Conversion rate"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:360
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:374
+#, c-format
+msgid "To account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:381
+#, c-format
+msgid "No cashout account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:382
+#, c-format
+msgid "Before doing a cashout you need to complete your profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:440
+#, fuzzy, c-format
+msgid "Amount to send"
+msgstr "Somma da ritirare"
+
+#: src/pages/business/CreateCashout.tsx:441
+#, fuzzy, c-format
+msgid "Amount to receive"
+msgstr "Somma da ritirare"
+
+#: src/pages/business/CreateCashout.tsx:490
+#, c-format
+msgid "Total cost"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:505
+#, c-format
+msgid "Balance left"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:520
+#, c-format
+msgid "Before fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:533
+#, c-format
+msgid "Total cashout transfer"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:553
+#, c-format
+msgid "No cashout channel available"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:555
+#, c-format
+msgid ""
+"Before doing a cashout the server need to provide an second channel to "
+"confirm the operation"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:567
+#, c-format
+msgid "Second factor authentication"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:598
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:600
+#, c-format
+msgid "add a email in your profile to enable this option"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:646
+#, c-format
+msgid "SMS"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:648
+#, c-format
+msgid "add a phone number in your profile to enable this option"
+msgstr ""
+
+#: src/pages/account/CashoutListForAccount.tsx:52
+#, c-format
+msgid "Cashout for account %1$s"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:165
+#, c-format
+msgid "it doesn't have the pattern of an IBAN number"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:185
+#, c-format
+msgid "it doesn't have the pattern of an email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:190
+#, c-format
+msgid "should start with +"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:192
+#, c-format
+msgid "phone number can't have other than numbers"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:329
+#, c-format
+msgid "account identification in the bank"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:365
+#, c-format
+msgid "name of the person owner the account"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:374
+#, c-format
+msgid "Internal IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:377
+#, c-format
+msgid "if empty a random account number will be assigned"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:378
+#, c-format
+msgid "account identification for bank transfer"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:423
+#, c-format
+msgid "Phone"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:451
+#, c-format
+msgid "Cashout IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:452
+#, c-format
+msgid "account number where the money is going to be sent when doing cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:470
+#, c-format
+msgid "Max debt"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:494
+#, c-format
+msgid "how much is user able to transfer after zero balance"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:508
+#, c-format
+msgid "Is this a Taler Exchange?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:549
+#, c-format
+msgid "This server doesn't support second factor authentication."
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:560
+#, c-format
+msgid "Enable second factor authentication"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:596
+#, c-format
+msgid "Using email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:654
+#, c-format
+msgid "Using SMS"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:691
+#, c-format
+msgid "Is this account public?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:719
+#, c-format
+msgid "public accounts have their balance publicly accessible"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:100
+#, c-format
+msgid "Account updated"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:107
+#, c-format
+msgid "The rights to change the account are not sufficient"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:114
+#, c-format
+msgid "The username was not found"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:121
+#, c-format
+msgid ""
+"You can't change the legal name, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:128
+#, c-format
+msgid ""
+"You can't change the debt limit, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:135
+#, c-format
+msgid ""
+"You can't change the cashout address, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:177
+#, c-format
+msgid "Account \"%1$s\""
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:190
+#, c-format
+msgid "Change details"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:235
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:78
+#, c-format
+msgid "password doesn't match"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:95
+#, c-format
+msgid "Password changed"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:102
+#, c-format
+msgid "Not authorized to change the password, maybe the session is invalid."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:112
+#, c-format
+msgid ""
+"You need to provide the old password. If you don't have it contact your "
+"account administrator."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:117
+#, c-format
+msgid "Your current password doesn't match, can't change to a new password."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:149
+#, c-format
+msgid "Update password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:167
+#, c-format
+msgid "New password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:195
+#, c-format
+msgid "Type it again"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:217
+#, c-format
+msgid "repeat the same password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:227
+#, c-format
+msgid "Current password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:248
+#, c-format
+msgid "your current password, for security"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:272
+#, c-format
+msgid "Change"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:74
+#, c-format
+msgid ""
+"Account created with password \"%1$s\". The user must change the password on "
+"the next login."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:83
+#, c-format
+msgid "Server replied that phone or email is invalid"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:90
+#, c-format
+msgid "The rights to perform the operation are not sufficient"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:97
+#, c-format
+msgid "Account username is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:104
+#, c-format
+msgid "Account id is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:111
+#, c-format
+msgid "Bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:118
+#, c-format
+msgid "Account username can't be used because is reserved"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:160
+#, c-format
+msgid "Can't create accounts"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:161
+#, c-format
+msgid "Only system admin can create accounts."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:183
+#, c-format
+msgid "New business account"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:209
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:94
+#, c-format
+msgid "Can't delete the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:95
+#, c-format
+msgid ""
+"The account can't be delete while still holding some balance. First make "
+"sure that the owner make a complete cashout."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:117
+#, c-format
+msgid "Account removed"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:124
+#, c-format
+msgid "No enough permission to delete the account."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:131
+#, c-format
+msgid "The username was not found."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:138
+#, c-format
+msgid "Can't delete a reserved username."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:145
+#, c-format
+msgid "Can't delete an account with balance different than zero."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:170
+#, c-format
+msgid "name doesn't match"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:180
+#, c-format
+msgid "You are going to remove the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:182
+#, c-format
+msgid "This step can't be undone."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:188
+#, c-format
+msgid "Deleting account \"%1$s\""
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:206
+#, c-format
+msgid "Verification"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:231
+#, c-format
+msgid "enter the account name that is going to be deleted"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:49
+#, c-format
+msgid "cashout id should be a number"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:65
+#, c-format
+msgid "This cashout not found. Maybe already aborted."
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:106
+#, c-format
+msgid "Cashout detail"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:139
+#, c-format
+msgid "Debited"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:154
+#, c-format
+msgid "Credited"
+msgstr ""
+
+#: src/Routing.tsx:140
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
+
+#, fuzzy, c-format
+#~ msgid "Account not found."
+#~ msgstr "Lista conti pubblici non trovata."
+
+#, fuzzy, c-format
+#~ msgid "Confirmed"
+#~ msgstr "Conferma"
+
+#, c-format
+#~ msgid "Abort"
+#~ msgstr "Annulla"
+
+#, c-format
+#~ msgid "Skip to main content"
+#~ msgstr "Saltare il menu di navigazione"
+
+#, c-format
+#~ msgid "Please login!"
+#~ msgstr "Accedi!"
+
+#, c-format
+#~ msgid "Login"
+#~ msgstr "Accedi"
+
+#, fuzzy, c-format
+#~ msgid "Amount:"
+#~ msgstr "Somma"
+
+#, c-format
+#~ msgid "Want to try the raw payto://-format?"
+#~ msgstr "Prova il trasferimento tramite il formato Payto!"
+
+#, fuzzy, c-format
+#~ msgid "Transfer money to account identified by payto:// URI:"
+#~ msgstr "Trasferisci fondi a un altro conto di questa banca:"
+
+#, c-format
+#~ msgid "payto address"
+#~ msgstr "indirizzo Payto"
+
+#, fuzzy, c-format
+#~ msgid "No credentials given."
+#~ msgstr "Credenziali invalide."
+
+#, c-format
+#~ msgid "Username or account label '%1$s' not found. Won't login."
+#~ msgstr "L'utente '%1$s' non esiste. Login impossibile"
+
+#, c-format
+#~ msgid "Account information could not be retrieved."
+#~ msgstr "Impossibile ricevere le informazioni relative al conto."
+
+#, fuzzy, c-format
+#~ msgid "Bank account balance"
+#~ msgstr "Bilancio:"
+
+#, c-format
+#~ msgid "List of public accounts could not be retrieved."
+#~ msgstr "Lista conti pubblici non pervenuta."
+
+#, fuzzy, c-format
+#~ msgid "Please register!"
+#~ msgstr "Accedi!"
+
+#~ msgid "this link"
+#~ msgstr "questo link"
+
+#~ msgid "Clear"
+#~ msgstr "Cancella"
+
+#~ msgid "Demo Bank"
+#~ msgstr "Banca 'demo'"
+
+#~ msgid "Go back"
+#~ msgstr "Indietro"
+
+#~ msgid "Transfer money via the Payto system:"
+#~ msgstr "Effettua un bonifico tramite il sistema Payto:"
+
+#~ msgid "Withdraw Money into a Taler wallet"
+#~ msgstr "Ritira contante nel portafoglio Taler"
+
+#~ msgid "Register to the euFin bank!"
+#~ msgstr "Apri un conto in banca euFin!"
+
+#~ msgid "Page has a problem: logged in but backend state is lost."
+#~ msgstr ""
+#~ "Stato inconsistente: accesso utente effettuato ma stato con server perso."
+
+#, fuzzy
+#~ msgid "Welcome to the euFin bank!"
+#~ msgstr "Benvenuti in banca euFin!"
diff --git a/packages/bank-ui/src/i18n/poheader b/packages/bank-ui/src/i18n/poheader
new file mode 100644
index 000000000..d7a371934
--- /dev/null
+++ b/packages/bank-ui/src/i18n/poheader
@@ -0,0 +1,26 @@
+# This file is part of GNU Taler
+# (C) 2022-2024 Taler Systems S.A.
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Bank\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
diff --git a/packages/bank-ui/src/i18n/strings.ts b/packages/bank-ui/src/i18n/strings.ts
new file mode 100644
index 000000000..f55e5efbc
--- /dev/null
+++ b/packages/bank-ui/src/i18n/strings.ts
@@ -0,0 +1,2295 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+export interface StringsType {
+ // X-Domain or 'messages'
+ domain: string;
+ lang: string;
+ completeness: number;
+ plural_forms: string;
+ locale_data: {
+ messages: Record<string, unknown>;
+ };
+}
+export const strings: Record<string, StringsType> = {};
+
+strings["it"] = {
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "it",
+ },
+ "Operation failed, please report": ["Registrazione"],
+ "Request timeout": [""],
+ "Request throttled": [""],
+ "Malformed response": [""],
+ "Network error": [""],
+ "Unexpected request error": [""],
+ "Unexpected error": [""],
+ "IBAN numbers usually have more that 4 digits": [""],
+ "IBAN numbers usually have less that 34 digits": [""],
+ "IBAN country code not found": [""],
+ "IBAN number is not valid, checksum is wrong": [""],
+ "Max withdrawal amount": ["Questo ritiro è stato annullato!"],
+ "Show withdrawal confirmation": ["Questo ritiro è stato annullato!"],
+ "Show demo description": [""],
+ "Show install wallet first": [""],
+ "Use fast withdrawal form": ["Ritira contante"],
+ "Show debug info": [""],
+ "The reserve operation has been confirmed previously and can't be aborted":
+ [""],
+ "The operation id is invalid.": [""],
+ "The operation was not found.": ["Lista conti pubblici non trovata."],
+ "If you have a Taler wallet installed in this device": [""],
+ "You will see the details of the operation in your wallet including the fees (if applies). If you still don't have one you can install it following instructions in":
+ [""],
+ "this page": [""],
+ Withdraw: ["Prelevare"],
+ "Or if you have the wallet in another device": [""],
+ "Scan the QR below to start the withdrawal.": ["Chiudi il ritiro Taler"],
+ required: [""],
+ "IBAN should have just uppercased letters and numbers": [""],
+ "not valid": [""],
+ "should be greater than 0": [""],
+ "balance is not enough": [""],
+ "does not follow the pattern": [""],
+ 'only "IBAN" target are supported': [""],
+ 'use the "amount" parameter to specify the amount to be transferred': [
+ "",
+ ],
+ "the amount is not valid": [""],
+ 'use the "message" parameter to specify a reference text for the transfer':
+ [""],
+ "The request was invalid or the payto://-URI used unacceptable features.":
+ [""],
+ "Not enough permission to complete the operation.": [
+ "La banca sta creando l'operazione...",
+ ],
+ 'The destination account "%1$s" was not found.': [
+ "Lista conti pubblici non trovata.",
+ ],
+ "The origin and the destination of the transfer can't be the same.": [""],
+ "Your balance is not enough.": [""],
+ 'The origin account "%1$s" was not found.': [
+ "Lista conti pubblici non trovata.",
+ ],
+ "Using a form": [""],
+ "Import payto:// URI": [""],
+ Recipient: [""],
+ "IBAN of the recipient's account": [""],
+ "Transfer subject": [
+ "Trasferisci fondi a un altro conto di questa banca:",
+ ],
+ subject: ["Soggetto"],
+ "some text to identify the transfer": [""],
+ Amount: ["Importo"],
+ "amount to transfer": ["Somma da ritirare"],
+ "payto URI:": [""],
+ "uniform resource identifier of the target account": [""],
+ "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]": [""],
+ Cancel: [""],
+ Send: [""],
+ "Missing username": [""],
+ "Missing password": [""],
+ 'Wrong credentials for "%1$s"': ["Credenziali invalide."],
+ "Account not found": [""],
+ Username: [""],
+ "username of the account": [
+ "Trasferisci fondi a un altro conto di questa banca:",
+ ],
+ Password: [""],
+ "password of the account": ["Storico dei conti pubblici"],
+ Check: [""],
+ "Log in": [""],
+ Register: ["Registrati"],
+ "Wire transfer completed!": ["Bonifico"],
+ "The withdrawal has been aborted previously and can't be confirmed": [""],
+ "The withdrawal operation can't be confirmed before a wallet accepted the transaction.":
+ [""],
+ "Your balance is not enough for the operation.": [""],
+ "Confirm the withdrawal operation": ["Conferma il ritiro"],
+ "Wire transfer details": ["Bonifico"],
+ "Taler Exchange operator's account": [""],
+ "Taler Exchange operator's name": [""],
+ Transfer: [""],
+ "Authentication required": [""],
+ "This operation was created with other username": [""],
+ "Operation aborted": [""],
+ "The wire transfer to the Taler Exchange operator's account was aborted, your balance was not affected.":
+ [""],
+ "You can close this page now or continue to the account page.": [""],
+ Continue: [""],
+ "Withdrawal confirmed": ["Questo ritiro è stato annullato!"],
+ "The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet.":
+ [""],
+ Done: [""],
+ "Operation canceled": [""],
+ "The operation is marked as 'selected' but some step in the withdrawal failed":
+ [""],
+ "The account is selected but no withdrawal identification found.": [""],
+ "There is a withdrawal identification but no account has been selected or the selected account is invalid.":
+ [""],
+ "No withdrawal ID found and no account has been selected or the selected account is invalid.":
+ [""],
+ "Operation not found": [""],
+ "This operation is not known by the server. The operation id is wrong or the server deleted the operation information before reaching here.":
+ [""],
+ "Cotinue to dashboard": [""],
+ "The Withdrawal URI is not valid": ["Questo ritiro è stato annullato!"],
+ 'the bank backend is not supported. supported version "%1$s", server version "%2$s"':
+ [""],
+ "Internal error, please report.": ["Registrazione"],
+ Preferences: [""],
+ "Welcome, %1$s": [""],
+ "Latest transactions": ["Ultime transazioni:"],
+ Date: ["Data"],
+ Counterpart: ["Controparte"],
+ Subject: ["Soggetto"],
+ sent: [""],
+ received: [""],
+ "invalid value": [""],
+ to: [""],
+ from: [""],
+ "First page": [""],
+ Next: [""],
+ "History of public accounts": ["Storico dei conti pubblici"],
+ "Currently, the bank is not accepting new registrations!": [""],
+ "Missing name": ["indirizzo Payto"],
+ "Use letters and numbers only, and start with a lowercase letter": [""],
+ "Passwords don't match": [""],
+ "Server replied with invalid phone or email.": [""],
+ "Registration is disabled because the bank ran out of bonus credit.": [
+ "",
+ ],
+ "No enough permission to create that account.": [""],
+ "That account id is already taken.": [""],
+ "That username is already taken.": [""],
+ "That username can't be used because is reserved.": [""],
+ "Only admin is allow to set debt limit.": [""],
+ "No information for the selected authentication channel.": [""],
+ "Authentication channel is not supported.": [""],
+ "Only admin can create accounts with second factor authentication.": [""],
+ "Account registration": [""],
+ "Repeat password": [""],
+ Name: [""],
+ "Create a random temporary user": [""],
+ "Make a wire transfer": ["Chiudi il bonifico"],
+ "Wire transfer created!": ["Bonifico"],
+ Accounts: ["Importo"],
+ "A list of all business account in the bank.": [""],
+ "Create account": [""],
+ Balance: [""],
+ Actions: [""],
+ unknown: [""],
+ "change password": [""],
+ remove: [""],
+ "Select a section": [""],
+ "Last hour": [""],
+ "Last day": [""],
+ "Last month": [""],
+ "Last year": [""],
+ "Last Year": [""],
+ "Trading volume on %1$s compared to %2$s": [""],
+ Cashin: [""],
+ Cashout: [""],
+ Payin: [""],
+ Payout: [""],
+ "download stats as CSV": [""],
+ "Descreased by": [""],
+ "Increased by": [""],
+ "Unable to create a cashout": [""],
+ "The bank configuration does not support cashout operations.": [""],
+ invalid: [""],
+ "need to be higher due to fees": [""],
+ "the total transfer at destination will be zero": [""],
+ "Cashout created": [""],
+ "Duplicated request detected, check if the operation succeded or try again.":
+ [""],
+ "The conversion rate was incorrectly applied": [""],
+ "The account does not have sufficient funds": [""],
+ "Cashouts are not supported": [""],
+ "Missing cashout URI in the profile": [""],
+ "Sending the confirmation message failed, retry later or contact the administrator.":
+ [""],
+ "Convertion rate": [""],
+ Fee: [""],
+ "To account": [""],
+ "No cashout account": [""],
+ "Before doing a cashout you need to complete your profile": [""],
+ "Amount to send": ["Somma da ritirare"],
+ "Amount to receive": ["Somma da ritirare"],
+ "Total cost": [""],
+ "Balance left": [""],
+ "Before fee": [""],
+ "Total cashout transfer": [""],
+ "No cashout channel available": [""],
+ "Before doing a cashout the server need to provide an second channel to confirm the operation":
+ [""],
+ "Second factor authentication": [""],
+ Email: [""],
+ "add a email in your profile to enable this option": [""],
+ SMS: [""],
+ "add a phone number in your profile to enable this option": [""],
+ Details: [""],
+ Delete: [""],
+ Credentials: ["Credenziali invalide."],
+ Cashouts: [""],
+ "it doesnt have the pattern of an IBAN number": [""],
+ "it doesnt have the pattern of an email": [""],
+ "should start with +": [""],
+ "phone number can't have other than numbers": [""],
+ "account identification in the bank": [""],
+ "name of the person owner the account": [""],
+ "Internal IBAN": [""],
+ "if empty a random account number will be assigned": [""],
+ "account identification for bank transfer": [""],
+ Phone: [""],
+ "Cashout IBAN": [""],
+ "account number where the money is going to be sent when doing cashouts":
+ [""],
+ "Max debt": [""],
+ "how much is user able to transfer after zero balance": [""],
+ "Is this a Taler Exchange?": [""],
+ "This server doesn't support second factor authentication.": [""],
+ "Enable second factor authentication": [""],
+ "Using email": [""],
+ "Using SMS": [""],
+ "Is this account public?": [""],
+ "public accounts have their balance publicly accesible": [""],
+ "Account updated": [""],
+ "The rights to change the account are not sufficient": [""],
+ "The username was not found": [""],
+ "You can't change the legal name, please contact the your account administrator.":
+ [""],
+ "You can't change the debt limit, please contact the your account administrator.":
+ [""],
+ "You can't change the cashout address, please contact the your account administrator.":
+ [""],
+ "You can't change the contact data, please contact the your account administrator.":
+ [""],
+ 'Account "%1$s"': [""],
+ "Change details": [""],
+ Update: [""],
+ "password doesn't match": [""],
+ "Password changed": [""],
+ "Not authorized to change the password, maybe the session is invalid.": [
+ "",
+ ],
+ "You need to provide the old password. If you don't have it contact your account administrator.":
+ [""],
+ "Your current password doesn't match, can't change to a new password.": [
+ "",
+ ],
+ "Update password": [""],
+ "New password": [""],
+ "Type it again": [""],
+ "repeat the same password": [""],
+ "Current password": [""],
+ "your current password, for security": [""],
+ Change: [""],
+ "Can't delete the account": [""],
+ "The account can't be delete while still holding some balance. First make sure that the owner make a complete cashout.":
+ [""],
+ "Account removed": [""],
+ "No enough permission to delete the account.": [""],
+ "The username was not found.": [""],
+ "Can't delete a reserved username.": [""],
+ "Can't delete an account with balance different than zero.": [""],
+ "name doesn't match": [""],
+ "You are going to remove the account": [""],
+ "This step can't be undone.": [""],
+ 'Deleting account "%1$s"': [""],
+ Verification: [""],
+ "enter the account name that is going to be deleted": [""],
+ 'Account created with password "%1$s". The user must change the password on the next login.':
+ [""],
+ "Server replied that phone or email is invalid": [""],
+ "The rights to perform the operation are not sufficient": [""],
+ "Account username is already taken": [""],
+ "Account id is already taken": [""],
+ "Bank ran out of bonus credit.": [""],
+ "Account username can't be used because is reserved": [""],
+ "Can't create accounts": [""],
+ "Only system admin can create accounts.": [""],
+ "New business account": [""],
+ Create: [""],
+ "Cashout not supported.": [""],
+ "Account not found.": ["Lista conti pubblici non trovata."],
+ "Latest cashouts": ["Ultime transazioni:"],
+ Created: [""],
+ Confirmed: ["Conferma"],
+ "Total debit": [""],
+ "Total credit": [""],
+ Status: [""],
+ never: [""],
+ "Cashout for account %1$s": [""],
+ "This cashout not found. Maybe already aborted.": [""],
+ "Cashout not found. It may be also mean that it was already aborted.": [
+ "",
+ ],
+ "Cashout was already confimed.": [""],
+ "Cashout operation is not supported.": [""],
+ "The cashout operation is already aborted.": [""],
+ "Missing destination account.": [""],
+ "Too many failed attempts.": [""],
+ "The code for this cashout is invalid.": [""],
+ "Cashout detail": [""],
+ Debited: [""],
+ Credited: [""],
+ "Enter the confirmation code": [""],
+ Abort: ["Annulla"],
+ Confirm: ["Conferma"],
+ "Unauthorized to make the operation, maybe the session has expired or the password changed.":
+ [""],
+ "The operation was rejected due to insufficient funds.": [""],
+ "Do not show this again": [""],
+ Close: [""],
+ "On this device": [""],
+ 'If you are using a web browser on desktop you should access your wallet with the GNU Taler WebExtension now or click the link if your WebExtension have the "Inject Taler support" option enabled.':
+ [""],
+ Start: [""],
+ "On a mobile phone": [""],
+ "Scan the QR code with your mobile device.": [
+ "Usa questo codice QR per ritirare contante nel tuo wallet:",
+ ],
+ "There is an operation already": [""],
+ "Complete or cancel the operation in": ["Conferma il ritiro"],
+ "Server responded with an invalid withdraw URI": [""],
+ "Withdraw URI: %1$s": ["Prelevare"],
+ "The operation was rejected due to insufficient funds": [""],
+ "Prepare your wallet": [""],
+ "After using your wallet you will need to confirm or cancel the operation on this site.":
+ [""],
+ "You need a GNU Taler Wallet": ["Ritira contante nel portafoglio Taler"],
+ "If you don't have one yet you can follow the instruction in": [""],
+ "Send money": [""],
+ "to a %1$s wallet": [""],
+ "Withdraw digital money into your mobile wallet or browser extension": [
+ "",
+ ],
+ "operation ready": [""],
+ "to another bank account": [
+ "Trasferisci fondi a un altro conto di questa banca:",
+ ],
+ "Make a wire transfer to an account with known bank account number.": [
+ "",
+ ],
+ "Transfer details": ["Effettua un bonifico"],
+ "This is a demo bank": [""],
+ "This part of the demo shows how a bank that supports Taler directly would work. In addition to using your own bank account, you can also see the transaction history of some %1$s.":
+ [""],
+ "This part of the demo shows how a bank that supports Taler directly would work.":
+ [""],
+ "Pending account delete operation": [""],
+ "Pending account update operation": [""],
+ "Pending password update operation": [""],
+ "Pending transaction operation": [""],
+ "Pending withdrawal operation": [""],
+ "Pending cashout operation": [""],
+ "You can complete or cancel the operation in": [""],
+ "Download bank stats": [""],
+ "Include hour metric": [""],
+ "Include day metric": [""],
+ "Include month metric": [""],
+ "Include year metric": [""],
+ "Include table header": [""],
+ "Add previous metric for compare": [""],
+ "Fail on first error": [""],
+ Download: [""],
+ "downloading... %1$s": [""],
+ "Download completed": [""],
+ "click here to save the file in your computer": [""],
+ "Challenge not found.": [""],
+ "This user is not authorized to complete this challenge.": [""],
+ "Too many attemps, try another code.": [""],
+ "The confirmation code is wrong, try again.": [""],
+ "The operation expired.": [""],
+ "The operation failed.": ["Questo ritiro è stato annullato!"],
+ "The operation needs another confirmation to complete.": [""],
+ "Account delete": [""],
+ "Account update": [""],
+ "Password update": [""],
+ "Wire transfer": ["Bonifico"],
+ Withdrawal: ["Prelevare"],
+ "Confirm the operation": ["Conferma il ritiro"],
+ "Send again": [""],
+ "Send code": [""],
+ "Operation details": [""],
+ "Challenge details": [""],
+ "Sent at": [""],
+ "To phone": [""],
+ "To email": [""],
+ "Welcome to %1$s!": [""],
+ },
+ },
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "it",
+ completeness: 14,
+};
+
+strings["fr"] = {
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n > 1;",
+ lang: "fr",
+ },
+ "Operation failed, please report": [""],
+ "Request timeout": [""],
+ "Request throttled": [""],
+ "Malformed response": [""],
+ "Network error": [""],
+ "Unexpected request error": [""],
+ "Unexpected error": [""],
+ "IBAN numbers usually have more that 4 digits": [""],
+ "IBAN numbers usually have less that 34 digits": [""],
+ "IBAN country code not found": [""],
+ "IBAN number is not valid, checksum is wrong": [""],
+ "Max withdrawal amount": [""],
+ "Show withdrawal confirmation": [""],
+ "Show demo description": [""],
+ "Show install wallet first": [""],
+ "Use fast withdrawal form": [""],
+ "Show debug info": [""],
+ "The reserve operation has been confirmed previously and can't be aborted":
+ [""],
+ "The operation id is invalid.": [""],
+ "The operation was not found.": [""],
+ "If you have a Taler wallet installed in this device": [""],
+ "You will see the details of the operation in your wallet including the fees (if applies). If you still don't have one you can install it following instructions in":
+ [""],
+ "this page": [""],
+ Withdraw: [""],
+ "Or if you have the wallet in another device": [""],
+ "Scan the QR below to start the withdrawal.": [""],
+ required: [""],
+ "IBAN should have just uppercased letters and numbers": [""],
+ "not valid": [""],
+ "should be greater than 0": [""],
+ "balance is not enough": [""],
+ "does not follow the pattern": [""],
+ 'only "IBAN" target are supported': [""],
+ 'use the "amount" parameter to specify the amount to be transferred': [
+ "",
+ ],
+ "the amount is not valid": [""],
+ 'use the "message" parameter to specify a reference text for the transfer':
+ [""],
+ "The request was invalid or the payto://-URI used unacceptable features.":
+ [""],
+ "Not enough permission to complete the operation.": [""],
+ 'The destination account "%1$s" was not found.': [""],
+ "The origin and the destination of the transfer can't be the same.": [""],
+ "Your balance is not enough.": [""],
+ 'The origin account "%1$s" was not found.': [""],
+ "Using a form": [""],
+ "Import payto:// URI": [""],
+ Recipient: [""],
+ "IBAN of the recipient's account": [""],
+ "Transfer subject": [""],
+ subject: [""],
+ "some text to identify the transfer": [""],
+ Amount: [""],
+ "amount to transfer": [""],
+ "payto URI:": [""],
+ "uniform resource identifier of the target account": [""],
+ "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]": [""],
+ Cancel: [""],
+ Send: [""],
+ "Missing username": [""],
+ "Missing password": [""],
+ 'Wrong credentials for "%1$s"': [""],
+ "Account not found": [""],
+ Username: [""],
+ "username of the account": [""],
+ Password: [""],
+ "password of the account": [""],
+ Check: [""],
+ "Log in": [""],
+ Register: [""],
+ "Wire transfer completed!": [""],
+ "The withdrawal has been aborted previously and can't be confirmed": [""],
+ "The withdrawal operation can't be confirmed before a wallet accepted the transaction.":
+ [""],
+ "Your balance is not enough for the operation.": [""],
+ "Confirm the withdrawal operation": [""],
+ "Wire transfer details": [""],
+ "Taler Exchange operator's account": [""],
+ "Taler Exchange operator's name": [""],
+ Transfer: [""],
+ "Authentication required": [""],
+ "This operation was created with other username": [""],
+ "Operation aborted": [""],
+ "The wire transfer to the Taler Exchange operator's account was aborted, your balance was not affected.":
+ [""],
+ "You can close this page now or continue to the account page.": [""],
+ Continue: [""],
+ "Withdrawal confirmed": [""],
+ "The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet.":
+ [""],
+ Done: [""],
+ "Operation canceled": [""],
+ "The operation is marked as 'selected' but some step in the withdrawal failed":
+ [""],
+ "The account is selected but no withdrawal identification found.": [""],
+ "There is a withdrawal identification but no account has been selected or the selected account is invalid.":
+ [""],
+ "No withdrawal ID found and no account has been selected or the selected account is invalid.":
+ [""],
+ "Operation not found": [""],
+ "This operation is not known by the server. The operation id is wrong or the server deleted the operation information before reaching here.":
+ [""],
+ "Cotinue to dashboard": [""],
+ "The Withdrawal URI is not valid": [""],
+ 'the bank backend is not supported. supported version "%1$s", server version "%2$s"':
+ [""],
+ "Internal error, please report.": [""],
+ Preferences: [""],
+ "Welcome, %1$s": [""],
+ "Latest transactions": [""],
+ Date: [""],
+ Counterpart: [""],
+ Subject: [""],
+ sent: [""],
+ received: [""],
+ "invalid value": [""],
+ to: [""],
+ from: [""],
+ "First page": [""],
+ Next: [""],
+ "History of public accounts": [""],
+ "Currently, the bank is not accepting new registrations!": [""],
+ "Missing name": [""],
+ "Use letters and numbers only, and start with a lowercase letter": [""],
+ "Passwords don't match": [""],
+ "Server replied with invalid phone or email.": [""],
+ "Registration is disabled because the bank ran out of bonus credit.": [
+ "",
+ ],
+ "No enough permission to create that account.": [""],
+ "That account id is already taken.": [""],
+ "That username is already taken.": [""],
+ "That username can't be used because is reserved.": [""],
+ "Only admin is allow to set debt limit.": [""],
+ "No information for the selected authentication channel.": [""],
+ "Authentication channel is not supported.": [""],
+ "Only admin can create accounts with second factor authentication.": [""],
+ "Account registration": [""],
+ "Repeat password": [""],
+ Name: [""],
+ "Create a random temporary user": [""],
+ "Make a wire transfer": [""],
+ "Wire transfer created!": [""],
+ Accounts: [""],
+ "A list of all business account in the bank.": [""],
+ "Create account": [""],
+ Balance: [""],
+ Actions: [""],
+ unknown: [""],
+ "change password": [""],
+ remove: [""],
+ "Select a section": [""],
+ "Last hour": [""],
+ "Last day": [""],
+ "Last month": [""],
+ "Last year": [""],
+ "Last Year": [""],
+ "Trading volume on %1$s compared to %2$s": [""],
+ Cashin: [""],
+ Cashout: [""],
+ Payin: [""],
+ Payout: [""],
+ "download stats as CSV": [""],
+ "Descreased by": [""],
+ "Increased by": [""],
+ "Unable to create a cashout": [""],
+ "The bank configuration does not support cashout operations.": [""],
+ invalid: [""],
+ "need to be higher due to fees": [""],
+ "the total transfer at destination will be zero": [""],
+ "Cashout created": [""],
+ "Duplicated request detected, check if the operation succeded or try again.":
+ [""],
+ "The conversion rate was incorrectly applied": [""],
+ "The account does not have sufficient funds": [""],
+ "Cashouts are not supported": [""],
+ "Missing cashout URI in the profile": [""],
+ "Sending the confirmation message failed, retry later or contact the administrator.":
+ [""],
+ "Convertion rate": [""],
+ Fee: [""],
+ "To account": [""],
+ "No cashout account": [""],
+ "Before doing a cashout you need to complete your profile": [""],
+ "Amount to send": [""],
+ "Amount to receive": [""],
+ "Total cost": [""],
+ "Balance left": [""],
+ "Before fee": [""],
+ "Total cashout transfer": [""],
+ "No cashout channel available": [""],
+ "Before doing a cashout the server need to provide an second channel to confirm the operation":
+ [""],
+ "Second factor authentication": [""],
+ Email: [""],
+ "add a email in your profile to enable this option": [""],
+ SMS: [""],
+ "add a phone number in your profile to enable this option": [""],
+ Details: [""],
+ Delete: [""],
+ Credentials: [""],
+ Cashouts: [""],
+ "it doesnt have the pattern of an IBAN number": [""],
+ "it doesnt have the pattern of an email": [""],
+ "should start with +": [""],
+ "phone number can't have other than numbers": [""],
+ "account identification in the bank": [""],
+ "name of the person owner the account": [""],
+ "Internal IBAN": [""],
+ "if empty a random account number will be assigned": [""],
+ "account identification for bank transfer": [""],
+ Phone: [""],
+ "Cashout IBAN": [""],
+ "account number where the money is going to be sent when doing cashouts":
+ [""],
+ "Max debt": [""],
+ "how much is user able to transfer after zero balance": [""],
+ "Is this a Taler Exchange?": [""],
+ "This server doesn't support second factor authentication.": [""],
+ "Enable second factor authentication": [""],
+ "Using email": [""],
+ "Using SMS": [""],
+ "Is this account public?": [""],
+ "public accounts have their balance publicly accesible": [""],
+ "Account updated": [""],
+ "The rights to change the account are not sufficient": [""],
+ "The username was not found": [""],
+ "You can't change the legal name, please contact the your account administrator.":
+ [""],
+ "You can't change the debt limit, please contact the your account administrator.":
+ [""],
+ "You can't change the cashout address, please contact the your account administrator.":
+ [""],
+ "You can't change the contact data, please contact the your account administrator.":
+ [""],
+ 'Account "%1$s"': [""],
+ "Change details": [""],
+ Update: [""],
+ "password doesn't match": [""],
+ "Password changed": [""],
+ "Not authorized to change the password, maybe the session is invalid.": [
+ "",
+ ],
+ "You need to provide the old password. If you don't have it contact your account administrator.":
+ [""],
+ "Your current password doesn't match, can't change to a new password.": [
+ "",
+ ],
+ "Update password": [""],
+ "New password": [""],
+ "Type it again": [""],
+ "repeat the same password": [""],
+ "Current password": [""],
+ "your current password, for security": [""],
+ Change: [""],
+ "Can't delete the account": [""],
+ "The account can't be delete while still holding some balance. First make sure that the owner make a complete cashout.":
+ [""],
+ "Account removed": [""],
+ "No enough permission to delete the account.": [""],
+ "The username was not found.": [""],
+ "Can't delete a reserved username.": [""],
+ "Can't delete an account with balance different than zero.": [""],
+ "name doesn't match": [""],
+ "You are going to remove the account": [""],
+ "This step can't be undone.": [""],
+ 'Deleting account "%1$s"': [""],
+ Verification: [""],
+ "enter the account name that is going to be deleted": [""],
+ 'Account created with password "%1$s". The user must change the password on the next login.':
+ [""],
+ "Server replied that phone or email is invalid": [""],
+ "The rights to perform the operation are not sufficient": [""],
+ "Account username is already taken": [""],
+ "Account id is already taken": [""],
+ "Bank ran out of bonus credit.": [""],
+ "Account username can't be used because is reserved": [""],
+ "Can't create accounts": [""],
+ "Only system admin can create accounts.": [""],
+ "New business account": [""],
+ Create: [""],
+ "Cashout not supported.": [""],
+ "Account not found.": [""],
+ "Latest cashouts": [""],
+ Created: [""],
+ Confirmed: [""],
+ "Total debit": [""],
+ "Total credit": [""],
+ Status: [""],
+ never: [""],
+ "Cashout for account %1$s": [""],
+ "This cashout not found. Maybe already aborted.": [""],
+ "Cashout not found. It may be also mean that it was already aborted.": [
+ "",
+ ],
+ "Cashout was already confimed.": [""],
+ "Cashout operation is not supported.": [""],
+ "The cashout operation is already aborted.": [""],
+ "Missing destination account.": [""],
+ "Too many failed attempts.": [""],
+ "The code for this cashout is invalid.": [""],
+ "Cashout detail": [""],
+ Debited: [""],
+ Credited: [""],
+ "Enter the confirmation code": [""],
+ Abort: [""],
+ Confirm: [""],
+ "Unauthorized to make the operation, maybe the session has expired or the password changed.":
+ [""],
+ "The operation was rejected due to insufficient funds.": [""],
+ "Do not show this again": [""],
+ Close: [""],
+ "On this device": [""],
+ 'If you are using a web browser on desktop you should access your wallet with the GNU Taler WebExtension now or click the link if your WebExtension have the "Inject Taler support" option enabled.':
+ [""],
+ Start: [""],
+ "On a mobile phone": [""],
+ "Scan the QR code with your mobile device.": [""],
+ "There is an operation already": [""],
+ "Complete or cancel the operation in": [""],
+ "Server responded with an invalid withdraw URI": [""],
+ "Withdraw URI: %1$s": [""],
+ "The operation was rejected due to insufficient funds": [""],
+ "Prepare your wallet": [""],
+ "After using your wallet you will need to confirm or cancel the operation on this site.":
+ [""],
+ "You need a GNU Taler Wallet": [""],
+ "If you don't have one yet you can follow the instruction in": [""],
+ "Send money": [""],
+ "to a %1$s wallet": [""],
+ "Withdraw digital money into your mobile wallet or browser extension": [
+ "",
+ ],
+ "operation ready": [""],
+ "to another bank account": [""],
+ "Make a wire transfer to an account with known bank account number.": [
+ "",
+ ],
+ "Transfer details": [""],
+ "This is a demo bank": [""],
+ "This part of the demo shows how a bank that supports Taler directly would work. In addition to using your own bank account, you can also see the transaction history of some %1$s.":
+ [""],
+ "This part of the demo shows how a bank that supports Taler directly would work.":
+ [""],
+ "Pending account delete operation": [""],
+ "Pending account update operation": [""],
+ "Pending password update operation": [""],
+ "Pending transaction operation": [""],
+ "Pending withdrawal operation": [""],
+ "Pending cashout operation": [""],
+ "You can complete or cancel the operation in": [""],
+ "Download bank stats": [""],
+ "Include hour metric": [""],
+ "Include day metric": [""],
+ "Include month metric": [""],
+ "Include year metric": [""],
+ "Include table header": [""],
+ "Add previous metric for compare": [""],
+ "Fail on first error": [""],
+ Download: [""],
+ "downloading... %1$s": [""],
+ "Download completed": [""],
+ "click here to save the file in your computer": [""],
+ "Challenge not found.": [""],
+ "This user is not authorized to complete this challenge.": [""],
+ "Too many attemps, try another code.": [""],
+ "The confirmation code is wrong, try again.": [""],
+ "The operation expired.": [""],
+ "The operation failed.": [""],
+ "The operation needs another confirmation to complete.": [""],
+ "Account delete": [""],
+ "Account update": [""],
+ "Password update": [""],
+ "Wire transfer": [""],
+ Withdrawal: [""],
+ "Confirm the operation": [""],
+ "Send again": [""],
+ "Send code": [""],
+ "Operation details": [""],
+ "Challenge details": [""],
+ "Sent at": [""],
+ "To phone": [""],
+ "To email": [""],
+ "Welcome to %1$s!": [""],
+ },
+ },
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n > 1;",
+ lang: "fr",
+ completeness: 0,
+};
+
+strings["es"] = {
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "es",
+ },
+ "Operation failed, please report": [
+ "La operaicón falló, por favor reportelo",
+ ],
+ "Request timeout": ["La petición al servidor agoto su tiempo"],
+ "Request throttled": ["La petición al servidor interrumpida"],
+ "Malformed response": ["Respuesta malformada"],
+ "Network error": ["Error de conexión"],
+ "Unexpected request error": ["Error de pedido inesperado"],
+ "Unexpected error": ["Error inesperado"],
+ "IBAN numbers usually have more that 4 digits": [
+ "Los números IBAN usualmente tienen mas de 4 digitos",
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ "Los números IBAN usualmente tienen menos de 34 digitos",
+ ],
+ "IBAN country code not found": ["Código de pais de IBAN no encontrado"],
+ "IBAN number is not valid, checksum is wrong": [
+ "El número IBAN no es válido, falló la verificación",
+ ],
+ "Max withdrawal amount": ["Monto máximo de extracción"],
+ "Show withdrawal confirmation": ["Mostrar confirmación de extracción"],
+ "Show demo description": ["Mostrar descripción de demo"],
+ "Show install wallet first": ["Mostrar instalar la billetera primero"],
+ "Use fast withdrawal form": ["Usar formulario de extracción rápida"],
+ "Show debug info": ["Mostrar información de depuración"],
+ "The reserve operation has been confirmed previously and can't be aborted":
+ [
+ "La operación en la reserva ya ha sido confirmada previamente y no puede ser abortada",
+ ],
+ "The operation id is invalid.": ["El id de operación es invalido."],
+ "The operation was not found.": ["La operación no se encontró."],
+ "If you have a Taler wallet installed in this device": [
+ "Si tienes una billetera Taler instalada en este dispositivo",
+ ],
+ "You will see the details of the operation in your wallet including the fees (if applies). If you still don't have one you can install it following instructions in":
+ [
+ "Veras los detalles de la operación en tu billetera incluyendo comisiones (si aplicán). Si todavía no tienes una puedes instalarla siguiendo las instrucciones en",
+ ],
+ "this page": ["esta página"],
+ Withdraw: ["Retirar"],
+ "Or if you have the wallet in another device": [
+ "O si tienes la billetera en otro dispositivo",
+ ],
+ "Scan the QR below to start the withdrawal.": [
+ "Escanea el QR debajo para comenzar la extracción.",
+ ],
+ required: ["requerido"],
+ "IBAN should have just uppercased letters and numbers": [
+ "IBAN debería tener letras mayúsculas y números",
+ ],
+ "not valid": ["no válido"],
+ "should be greater than 0": ["Debería ser mas grande que 0"],
+ "balance is not enough": ["el saldo no es suficiente"],
+ "does not follow the pattern": ["no tiene un patrón valido"],
+ 'only "IBAN" target are supported': [
+ 'solo cuentas "IBAN" son soportadas',
+ ],
+ 'use the "amount" parameter to specify the amount to be transferred': [
+ 'usa el parámetro "amount" para indicar el monto a ser transferido',
+ ],
+ "the amount is not valid": ["el monto no es válido"],
+ 'use the "message" parameter to specify a reference text for the transfer':
+ [
+ 'usa el parámetro "message" para indicar un texto de referencia en la transferencia',
+ ],
+ "The request was invalid or the payto://-URI used unacceptable features.":
+ [
+ "El pedido era inválido o el URI payto:// usado tiene características inaceptables.",
+ ],
+ "Not enough permission to complete the operation.": [
+ "Sin permisos suficientes para completar la operación.",
+ ],
+ 'The destination account "%1$s" was not found.': [
+ 'La cuenta de destino "%1$s" no fue encontrada.',
+ ],
+ "The origin and the destination of the transfer can't be the same.": [
+ "El origen y destino de la transferencia no puede ser la misma.",
+ ],
+ "Your balance is not enough.": ["El saldo no es suficiente."],
+ 'The origin account "%1$s" was not found.': [
+ 'La cuenta origen "%1$s" no fue encontrada.',
+ ],
+ "Using a form": ["Usando un formulario"],
+ "Import payto:// URI": ["Importando un URI payto://"],
+ Recipient: ["Destinatario"],
+ "IBAN of the recipient's account": [
+ "Numero IBAN de la cuenta destinataria",
+ ],
+ "Transfer subject": ["Asunto de transferencia"],
+ subject: ["asunto"],
+ "some text to identify the transfer": [
+ "algún texto para identificar la transferencia",
+ ],
+ Amount: ["Monto"],
+ "amount to transfer": ["monto a transferir"],
+ "payto URI:": ["payto URI:"],
+ "uniform resource identifier of the target account": [
+ "identificador de recurso uniforme de la cuenta destino",
+ ],
+ "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]": [
+ "payto://iban/[iban-destinatario]?message=[asunto]&amount=[%1$s:X.Y]",
+ ],
+ Cancel: ["Cancelar"],
+ Send: ["Envíar"],
+ "Missing username": ["Falta nombre de usuario"],
+ "Missing password": ["Falta contraseña"],
+ 'Wrong credentials for "%1$s"': ['Credenciales incorrectas para "%1$s"'],
+ "Account not found": ["Cuenta no encontrada"],
+ Username: ["Nombre de usuario"],
+ "username of the account": ["nombre de usuario de la cuenta"],
+ Password: ["Contraseña"],
+ "password of the account": ["contraseña de la cuenta"],
+ Check: ["Verificar"],
+ "Log in": ["Acceso"],
+ Register: ["Registrarse"],
+ "Wire transfer completed!": ["Transferencia bancaria completada!"],
+ "The withdrawal has been aborted previously and can't be confirmed": [
+ "La extracción fue abortada anteriormente y no puede ser confirmada",
+ ],
+ "The withdrawal operation can't be confirmed before a wallet accepted the transaction.":
+ [
+ "La operación de extracción no puede ser confirmada antes de que una billetera acepte la transaccion.",
+ ],
+ "Your balance is not enough for the operation.": [
+ "El saldo no es suficiente para la operación.",
+ ],
+ "Confirm the withdrawal operation": [
+ "Confirme la operación de extracción",
+ ],
+ "Wire transfer details": ["Detalle de transferencia bancaria"],
+ "Taler Exchange operator's account": [
+ "Cuenta del operador del Taler Exchange",
+ ],
+ "Taler Exchange operator's name": [
+ "Nombre del operador del Taler Exchange",
+ ],
+ Transfer: ["Transferencia"],
+ "Authentication required": ["Autenticación requerida"],
+ "This operation was created with other username": [
+ "Esta operación fue creada con otro usuario",
+ ],
+ "Operation aborted": ["Operación abortada"],
+ "The wire transfer to the Taler Exchange operator's account was aborted, your balance was not affected.":
+ [
+ "La transferencia bancaria a la cuenta del operador del Taler Exchange fue abortada, su saldo no fue afectado.",
+ ],
+ "You can close this page now or continue to the account page.": [
+ "Ya puedes cerrar esta pagina or continuar a la página de estado de cuenta.",
+ ],
+ Continue: ["Continuar"],
+ "Withdrawal confirmed": ["La extracción fue confirmada"],
+ "The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet.":
+ [
+ "La transferencia bancaria al operador Taler fue iniciada. Pronto recibirás el monto pedido en tu billetera Taler.",
+ ],
+ Done: ["Listo"],
+ "Operation canceled": ["Operación cancelada"],
+ "The operation is marked as 'selected' but some step in the withdrawal failed":
+ [
+ "La operación está marcada como 'seleccionada' pero algunos pasos en la extracción fallaron",
+ ],
+ "The account is selected but no withdrawal identification found.": [
+ "La cuenta está seleccionada pero no se encontró el identificador de extracción.",
+ ],
+ "There is a withdrawal identification but no account has been selected or the selected account is invalid.":
+ [
+ "Hay un identificador de extracción pero la cuenta no ha sido seleccionada o la selccionada es inválida.",
+ ],
+ "No withdrawal ID found and no account has been selected or the selected account is invalid.":
+ [
+ "No hay un identificador de extracción y ninguna cuenta a sido seleccionada o la seleccionada es inválida.",
+ ],
+ "Operation not found": ["Operación no encontrada"],
+ "This operation is not known by the server. The operation id is wrong or the server deleted the operation information before reaching here.":
+ [
+ "Esta operación no es conocida por el servidor. El identificador de operación es incorrecto o el server borró la información de la operación antes de llegar hasta aquí.",
+ ],
+ "Cotinue to dashboard": ["Continuar al panel"],
+ "The Withdrawal URI is not valid": ["El URI de estracción no es válido"],
+ 'the bank backend is not supported. supported version "%1$s", server version "%2$s"':
+ [
+ 'El servidor de bank no esta spoportado. Version soportada "%1$s", version del server "%2$s"',
+ ],
+ "Internal error, please report.": [
+ "Error interno, por favor reporte el error.",
+ ],
+ Preferences: ["Preferencias"],
+ "Welcome, %1$s": ["Bienvenido/a, %1$s"],
+ "Latest transactions": ["Últimas transacciones"],
+ Date: ["Fecha"],
+ Counterpart: ["Contraparte"],
+ Subject: ["Asunto"],
+ sent: ["enviado"],
+ received: ["recibido"],
+ "invalid value": ["valor inválido"],
+ to: ["hacia"],
+ from: ["desde"],
+ "First page": ["Primera página"],
+ Next: ["Siguiente"],
+ "History of public accounts": ["Historial de cuentas públicas"],
+ "Currently, the bank is not accepting new registrations!": [
+ "Actualmente, el banco no está aceptado nuevos registros!",
+ ],
+ "Missing name": ["Falta nombre"],
+ "Use letters and numbers only, and start with a lowercase letter": [
+ "Solo use letras y números, y comience con una letra minúscula",
+ ],
+ "Passwords don't match": ["La contraseña no coincide"],
+ "Server replied with invalid phone or email.": [
+ "El servidor repondio con teléfono o dirección de correo inválido.",
+ ],
+ "Registration is disabled because the bank ran out of bonus credit.": [
+ "El registro está deshabilitado porque el banco se quedó sin crédito bonus.",
+ ],
+ "No enough permission to create that account.": [
+ "Sin permisos suficientes para crear esa cuenta.",
+ ],
+ "That account id is already taken.": [
+ "El identificador de cuenta ya está tomado.",
+ ],
+ "That username is already taken.": [
+ "El nombre de usuario ya está tomado.",
+ ],
+ "That username can't be used because is reserved.": [
+ "El nombre de usuario no puede ser usado porque esta reservado.",
+ ],
+ "Only admin is allow to set debt limit.": [
+ "Solo el administrador tiene permitido cambiar el límite de deuda.",
+ ],
+ "No information for the selected authentication channel.": [
+ "No hay información para el canal de autenticación seleccionado.",
+ ],
+ "Authentication channel is not supported.": [
+ "Canal de autenticación no esta soportado.",
+ ],
+ "Only admin can create accounts with second factor authentication.": [
+ "Solo el administrador puede crear cuentas con el segundo factor de autenticación.",
+ ],
+ "Account registration": ["Registro de cuenta"],
+ "Repeat password": ["Repita la contraseña"],
+ Name: ["Nombre"],
+ "Create a random temporary user": ["Crear un usuario aleatorio temporal"],
+ "Make a wire transfer": ["Hacer una transferencia bancaria"],
+ "Wire transfer created!": ["Transferencia bancaria creada!"],
+ Accounts: ["Cuentas"],
+ "A list of all business account in the bank.": [
+ "Una lista de todas las cuentas en el banco.",
+ ],
+ "Create account": ["Crear cuenta"],
+ Balance: ["Saldo"],
+ Actions: ["Acciones"],
+ unknown: ["desconocido"],
+ "change password": ["cambiar contraseña"],
+ remove: ["elimiar"],
+ "Select a section": ["Seleccione una sección"],
+ "Last hour": ["Última hora"],
+ "Last day": ["Último día"],
+ "Last month": ["Último mes"],
+ "Last year": ["Último año"],
+ "Last Year": ["Último Año"],
+ "Trading volume on %1$s compared to %2$s": [
+ "Vólumen de comercio en %1$s comparado con %2$s",
+ ],
+ Cashin: ["Ingreso"],
+ Cashout: ["Egreso"],
+ Payin: ["Envios de dinero"],
+ Payout: ["Recibos de dinero"],
+ "download stats as CSV": ["descargar estadísticas en CSV"],
+ "Descreased by": ["Descendiente por"],
+ "Increased by": ["Ascendente por"],
+ "Unable to create a cashout": ["Imposible crear un egreso"],
+ "The bank configuration does not support cashout operations.": [
+ "La configuración del banco no soporta operaciones de egreso.",
+ ],
+ invalid: ["inválido"],
+ "need to be higher due to fees": [
+ "necesita ser mayor debido a las comisiones",
+ ],
+ "the total transfer at destination will be zero": [
+ "el total de la transferencia en destino será cero",
+ ],
+ "Cashout created": ["Egreso creado"],
+ "Duplicated request detected, check if the operation succeded or try again.":
+ [
+ "Se detectó una petición duplicada, verifique si la operación tuvo éxito o intente otra vez.",
+ ],
+ "The conversion rate was incorrectly applied": [
+ "La tasa de conversión se aplicó incorrectamente",
+ ],
+ "The account does not have sufficient funds": [
+ "La cuenta no tiene fondos suficientes",
+ ],
+ "Cashouts are not supported": ["Egresos no están soportados"],
+ "Missing cashout URI in the profile": [
+ "Falta dirección de egreso en el perfíl",
+ ],
+ "Sending the confirmation message failed, retry later or contact the administrator.":
+ [
+ "El envío del mensaje de confirmación falló, intente mas tarde o contacte al administrador.",
+ ],
+ "Convertion rate": ["Tasa de conversión"],
+ Fee: ["Comisión"],
+ "To account": ["Hacia cuenta"],
+ "No cashout account": ["No hay cuenta de egreso"],
+ "Before doing a cashout you need to complete your profile": [
+ "Antes de hacer un egreso necesita completar su perfíl",
+ ],
+ "Amount to send": ["Monto a enviar"],
+ "Amount to receive": ["Monto a recibir"],
+ "Total cost": ["Costo total"],
+ "Balance left": ["Saldo remanente"],
+ "Before fee": ["Antes de comisión"],
+ "Total cashout transfer": ["Total de egreso"],
+ "No cashout channel available": ["No hay canal de egreso disponible"],
+ "Before doing a cashout the server need to provide an second channel to confirm the operation":
+ [
+ "Antes de hacer un egreso el servidor necesita proveer un segundo canal para confirmar la operación",
+ ],
+ "Second factor authentication": ["Segundo factor de autenticación"],
+ Email: ["Correo eletrónico"],
+ "add a email in your profile to enable this option": [
+ "agrege un correo en su perfíl para habilitar esta opción",
+ ],
+ SMS: ["SMS"],
+ "add a phone number in your profile to enable this option": [
+ "agregue un número de teléfono para habilitar esta opción",
+ ],
+ Details: ["Detalles"],
+ Delete: ["Borrar"],
+ Credentials: ["Credenciales"],
+ Cashouts: ["Egresos"],
+ "it doesnt have the pattern of an IBAN number": [
+ "no tiene el patrón de un número IBAN",
+ ],
+ "it doesnt have the pattern of an email": [
+ "no tiene el patrón de un correo electrónico",
+ ],
+ "should start with +": ["debería comenzar con un +"],
+ "phone number can't have other than numbers": [
+ "número de teléfono no puede tener otra cosa que numeros",
+ ],
+ "account identification in the bank": [
+ "identificador de cuenta en el banco",
+ ],
+ "name of the person owner the account": [
+ "nombre de la persona dueña de la cuenta",
+ ],
+ "Internal IBAN": ["IBAN interno"],
+ "if empty a random account number will be assigned": [
+ "si está vacío un número de cuenta aleatorio será asignado",
+ ],
+ "account identification for bank transfer": [
+ "identificador de cuenta para transferencia bancaria",
+ ],
+ Phone: ["Teléfono"],
+ "Cashout IBAN": ["IBAN de egreso"],
+ "account number where the money is going to be sent when doing cashouts":
+ [
+ "numero de cuenta donde el dinero será enviado cuando se ejecuten egresos",
+ ],
+ "Max debt": ["Máxima deuda"],
+ "how much is user able to transfer after zero balance": [
+ "cuanto tiene habilitado a transferir despues de un saldo en cero",
+ ],
+ "Is this a Taler Exchange?": ["Es un Taler Exchange?"],
+ "This server doesn't support second factor authentication.": [
+ "Este servidor no tiene soporte para segundo factor de autenticación.",
+ ],
+ "Enable second factor authentication": [
+ "Hábilitar segundo factor de autenticación",
+ ],
+ "Using email": ["Usando correo eletrónico"],
+ "Using SMS": ["Usando SMS"],
+ "Is this account public?": ["Es una cuenta pública?"],
+ "public accounts have their balance publicly accesible": [
+ "las cuentas públicas tienen su saldo accesible al público",
+ ],
+ "Account updated": ["Cuenta actualizada"],
+ "The rights to change the account are not sufficient": [
+ "Los permisos para cambiar la cuenta no son suficientes",
+ ],
+ "The username was not found": ["El nombre de usaurio no se encontró"],
+ "You can't change the legal name, please contact the your account administrator.":
+ [
+ "No puede cambiar el nombre legal, por favor contacte el administrador de la cuenta.",
+ ],
+ "You can't change the debt limit, please contact the your account administrator.":
+ [
+ "No puede cambiar el límite de deuda, por favor contacte el administrador de la cuenta.",
+ ],
+ "You can't change the cashout address, please contact the your account administrator.":
+ [
+ "No puede cambiar la dirección de egreso, por favor contacte al administrador de la cuenta.",
+ ],
+ "You can't change the contact data, please contact the your account administrator.":
+ [
+ "No puede cambiar los datos de contacto, por favor contacte al administrador de la cuenta.",
+ ],
+ 'Account "%1$s"': ['Cuenta "%1$s"'],
+ "Change details": ["Cambiar detalles"],
+ Update: ["Actualizar"],
+ "password doesn't match": ["la contraseña no coincide"],
+ "Password changed": ["La contraseña cambió"],
+ "Not authorized to change the password, maybe the session is invalid.": [
+ "No está autorizado a cambiar el password, quizá la sesión es invalida.",
+ ],
+ "You need to provide the old password. If you don't have it contact your account administrator.":
+ [
+ "Se necesita el password viejo para cambiar la contraseña. Si no lo tiene contacte a su administrador.",
+ ],
+ "Your current password doesn't match, can't change to a new password.": [
+ "Su actual contraseña no coincide, no puede cambiar a una nueva contraseña.",
+ ],
+ "Update password": ["Actualizar contraseña"],
+ "New password": ["Nueva contraseña"],
+ "Type it again": ["Escribalo otra vez"],
+ "repeat the same password": ["repita la misma contraseña"],
+ "Current password": ["Contraseña actual"],
+ "your current password, for security": [
+ "su actual contraseña, por seguridad",
+ ],
+ Change: ["Cambiar"],
+ "Can't delete the account": ["No se puede eliminar la cuenta"],
+ "The account can't be delete while still holding some balance. First make sure that the owner make a complete cashout.":
+ [
+ "La cuenta no puede ser eliminada mientras tiene saldo. Primero aseguresé que el dueño haga un egreso completo.",
+ ],
+ "Account removed": ["Cuenta eliminada"],
+ "No enough permission to delete the account.": [
+ "No tiene permisos suficientes para eliminar la cuenta.",
+ ],
+ "The username was not found.": ["El nombr ede usuario no se encontró."],
+ "Can't delete a reserved username.": [
+ "No se puede eliminar un nombre de usuario reservado.",
+ ],
+ "Can't delete an account with balance different than zero.": [
+ "No se puede eliminar una cuenta con saldo diferente a cero.",
+ ],
+ "name doesn't match": ["el nombre no coincide"],
+ "You are going to remove the account": ["Está por eliminar la cuenta"],
+ "This step can't be undone.": ["Este paso no puede ser deshecho."],
+ 'Deleting account "%1$s"': ['Borrando cuenta "%1$s"'],
+ Verification: ["Verificación"],
+ "enter the account name that is going to be deleted": [
+ "ingrese el nombre de cuenta que será eliminado",
+ ],
+ 'Account created with password "%1$s". The user must change the password on the next login.':
+ [
+ 'Cuenta creada con contraseña "%1$s". El usuario debe cambiar la contraseña en el siguiente ingreso.',
+ ],
+ "Server replied that phone or email is invalid": [
+ "El servidor respondió que el teléfono o correo eletrónico es invalido",
+ ],
+ "The rights to perform the operation are not sufficient": [
+ "Los permisos para ejecutar la operación no son suficientes",
+ ],
+ "Account username is already taken": [
+ "El nombre del usuario ya está tomado",
+ ],
+ "Account id is already taken": ["El id de cuenta ya está tomado"],
+ "Bank ran out of bonus credit.": [
+ "El banco no tiene mas crédito de bonus.",
+ ],
+ "Account username can't be used because is reserved": [
+ "El nombre de usuario de la cuenta no puede userse porque está reservado",
+ ],
+ "Can't create accounts": ["No puede crear cuentas"],
+ "Only system admin can create accounts.": [
+ "Solo los administradores del sistema pueden crear cuentas.",
+ ],
+ "New business account": ["Nueva cuenta"],
+ Create: ["Crear"],
+ "Cashout not supported.": ["Egreso no soportado."],
+ "Account not found.": ["Cuenta no encontrada."],
+ "Latest cashouts": ["Últimos egresos"],
+ Created: ["Creado"],
+ Confirmed: ["Confirmado"],
+ "Total debit": ["Débito total"],
+ "Total credit": ["Crédito total"],
+ Status: ["Estado"],
+ never: ["nunca"],
+ "Cashout for account %1$s": ["Egreso para cuenta %1$s"],
+ "This cashout not found. Maybe already aborted.": [
+ "Este egreso no se encontró. Quizá fue abortado.",
+ ],
+ "Cashout not found. It may be also mean that it was already aborted.": [
+ "Egreso no econtrado. También puede significar que ya ha sido abortado.",
+ ],
+ "Cashout was already confimed.": ["Egreso ya fue confirmado."],
+ "Cashout operation is not supported.": [
+ "Operación de egreso no soportada.",
+ ],
+ "The cashout operation is already aborted.": [
+ "La operación de egreso ya está abortada.",
+ ],
+ "Missing destination account.": ["Falta cuenta destino."],
+ "Too many failed attempts.": ["Demasiados intentos fallidos."],
+ "The code for this cashout is invalid.": [
+ "El código para este egreso es invalido.",
+ ],
+ "Cashout detail": ["Detalles de egreso"],
+ Debited: ["Debitado"],
+ Credited: ["Acreditado"],
+ "Enter the confirmation code": ["Ingresar el código de confirmación"],
+ Abort: ["Abortar"],
+ Confirm: ["Confirmar"],
+ "Unauthorized to make the operation, maybe the session has expired or the password changed.":
+ [
+ "No autorizado para hacer la operación, quizá la sesión haya expirado or cambió la contraseña.",
+ ],
+ "The operation was rejected due to insufficient funds.": [
+ "La operación fue rechazada debido a saldo insuficiente.",
+ ],
+ "Do not show this again": ["No mostrar otra vez"],
+ Close: ["Cerrar"],
+ "On this device": ["En este dispositivo"],
+ 'If you are using a web browser on desktop you should access your wallet with the GNU Taler WebExtension now or click the link if your WebExtension have the "Inject Taler support" option enabled.':
+ [
+ 'Si esta usando un explorador web de escritorio deberías acceder ahora a tu billletera con la GNU Taler WebExtension o hacer click en el link si tu extensión tiene la configuración "Inyectar soporte para Taler" habilitada.',
+ ],
+ Start: ["Comenzar"],
+ "On a mobile phone": ["En un dispotivo mobile"],
+ "Scan the QR code with your mobile device.": [
+ "Escanear el código QR con tu dispotivo móvil.",
+ ],
+ "There is an operation already": ["Ya hay una operación"],
+ "Complete or cancel the operation in": [
+ "Completa o cancela la operación en",
+ ],
+ "Server responded with an invalid withdraw URI": [
+ "El servidor respondió con una URI de extracción inválida",
+ ],
+ "Withdraw URI: %1$s": ["URI de extracción: %1$s"],
+ "The operation was rejected due to insufficient funds": [
+ "La operación fue rechazada debido a fundos insuficientes",
+ ],
+ "Prepare your wallet": ["Prepare su billetera"],
+ "After using your wallet you will need to confirm or cancel the operation on this site.":
+ [
+ "Despues de usar tu billetera necesitarás confirmar o cancelar la operación en este sitio.",
+ ],
+ "You need a GNU Taler Wallet": ["Necesitas una GNU Taler Wallet"],
+ "If you don't have one yet you can follow the instruction in": [
+ "Si no tienes una todavía puedes seguir las instrucciones en",
+ ],
+ "Send money": ["Enviar dinero"],
+ "to a %1$s wallet": ["a una billetera %1$s"],
+ "Withdraw digital money into your mobile wallet or browser extension": [
+ "Extraer dinero digital a tu billetera móvil o extesión web",
+ ],
+ "operation ready": ["operación lista"],
+ "to another bank account": ["a otra cuenta bancaria"],
+ "Make a wire transfer to an account with known bank account number.": [
+ "Hacer una transferencia bancaria a una cuenta con un número de cuenta conocido.",
+ ],
+ "Transfer details": ["Detalles de transferencia"],
+ "This is a demo bank": ["Este es un banco de demostración"],
+ "This part of the demo shows how a bank that supports Taler directly would work. In addition to using your own bank account, you can also see the transaction history of some %1$s.":
+ [
+ "Esta parte de la demostración muestra cómo funciona un banco que soporta Taler directamente. Además de usar tu propia cuenta de banco, también podrás ver el historial de transacciones de algunas %1$s.",
+ ],
+ "This part of the demo shows how a bank that supports Taler directly would work.":
+ [
+ "Esta parte de la demostración muetra como un banco que soporta Taler directamente funcionaría.",
+ ],
+ "Pending account delete operation": [
+ "Operación pendiente de eliminación de cuenta",
+ ],
+ "Pending account update operation": [
+ "Operación pendiente de actualización de cuenta",
+ ],
+ "Pending password update operation": [
+ "Operación pendiente de actualización de password",
+ ],
+ "Pending transaction operation": ["Operación pendiente de transacción"],
+ "Pending withdrawal operation": ["Operación pendiente de extracción"],
+ "Pending cashout operation": ["Operación pendiente de egreso"],
+ "You can complete or cancel the operation in": [
+ "Puedes completar o cancelar la operación en",
+ ],
+ "Download bank stats": ["Descargar estadísticas del banco"],
+ "Include hour metric": ["Incluir métrica horaria"],
+ "Include day metric": ["Incluir métrica diaria"],
+ "Include month metric": ["Incluir métrica mensual"],
+ "Include year metric": ["Incluir métrica anual"],
+ "Include table header": ["Incluir encabezado de tabla"],
+ "Add previous metric for compare": [
+ "Agregar métrica previa para comparar",
+ ],
+ "Fail on first error": ["Fallar en el primer error"],
+ Download: ["Descargar"],
+ "downloading... %1$s": ["descargando... %1$s"],
+ "Download completed": ["Descarga completada"],
+ "click here to save the file in your computer": [
+ "click aquí para guardar el archivo en su computadora",
+ ],
+ "Challenge not found.": ["Desafío no encontrado."],
+ "This user is not authorized to complete this challenge.": [
+ "Este usuario no está autorizado para completar este desafío.",
+ ],
+ "Too many attemps, try another code.": [
+ "Demasiados intentos, intente otro código.",
+ ],
+ "The confirmation code is wrong, try again.": [
+ "El código de confirmación es erroneo, intente otra vez.",
+ ],
+ "The operation expired.": ["La operación expiró."],
+ "The operation failed.": ["La operación falló."],
+ "The operation needs another confirmation to complete.": [
+ "La operación necesita otra confirmación para completar.",
+ ],
+ "Account delete": ["Eliminación de cuenta"],
+ "Account update": ["Actualización de cuenta"],
+ "Password update": ["Actualización de contraseña"],
+ "Wire transfer": ["Transferencia bancaria"],
+ Withdrawal: ["Extracción"],
+ "Confirm the operation": ["Confirmar la operación"],
+ "Send again": ["Enviar otra vez"],
+ "Send code": ["Enviar código"],
+ "Operation details": ["Detalles de operación"],
+ "Challenge details": ["Detalles del desafío"],
+ "Sent at": ["Enviado a"],
+ "To phone": ["Al teléfono"],
+ "To email": ["Al email"],
+ "Welcome to %1$s!": ["Bienvenido a %1$s!"],
+ },
+ },
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "es",
+ completeness: 100,
+};
+
+strings["en"] = {
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=(n != 1);",
+ lang: "en",
+ },
+ "Operation failed, please report": [""],
+ "Request timeout": [""],
+ "Request throttled": [""],
+ "Malformed response": [""],
+ "Network error": [""],
+ "Unexpected request error": [""],
+ "Unexpected error": [""],
+ "IBAN numbers usually have more that 4 digits": [""],
+ "IBAN numbers usually have less that 34 digits": [""],
+ "IBAN country code not found": [""],
+ "IBAN number is not valid, checksum is wrong": [""],
+ "Max withdrawal amount": [""],
+ "Show withdrawal confirmation": [""],
+ "Show demo description": [""],
+ "Show install wallet first": [""],
+ "Use fast withdrawal form": [""],
+ "Show debug info": [""],
+ "The reserve operation has been confirmed previously and can't be aborted":
+ [""],
+ "The operation id is invalid.": [""],
+ "The operation was not found.": [""],
+ "If you have a Taler wallet installed in this device": [""],
+ "You will see the details of the operation in your wallet including the fees (if applies). If you still don't have one you can install it following instructions in":
+ [""],
+ "this page": [""],
+ Withdraw: [""],
+ "Or if you have the wallet in another device": [""],
+ "Scan the QR below to start the withdrawal.": [""],
+ required: [""],
+ "IBAN should have just uppercased letters and numbers": [""],
+ "not valid": [""],
+ "should be greater than 0": [""],
+ "balance is not enough": [""],
+ "does not follow the pattern": [""],
+ 'only "IBAN" target are supported': [""],
+ 'use the "amount" parameter to specify the amount to be transferred': [
+ "",
+ ],
+ "the amount is not valid": [""],
+ 'use the "message" parameter to specify a reference text for the transfer':
+ [""],
+ "The request was invalid or the payto://-URI used unacceptable features.":
+ [""],
+ "Not enough permission to complete the operation.": [""],
+ 'The destination account "%1$s" was not found.': [""],
+ "The origin and the destination of the transfer can't be the same.": [""],
+ "Your balance is not enough.": [""],
+ 'The origin account "%1$s" was not found.': [""],
+ "Using a form": [""],
+ "Import payto:// URI": [""],
+ Recipient: [""],
+ "IBAN of the recipient's account": [""],
+ "Transfer subject": [""],
+ subject: [""],
+ "some text to identify the transfer": [""],
+ Amount: [""],
+ "amount to transfer": [""],
+ "payto URI:": [""],
+ "uniform resource identifier of the target account": [""],
+ "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]": [""],
+ Cancel: [""],
+ Send: [""],
+ "Missing username": [""],
+ "Missing password": [""],
+ 'Wrong credentials for "%1$s"': [""],
+ "Account not found": [""],
+ Username: [""],
+ "username of the account": [""],
+ Password: [""],
+ "password of the account": [""],
+ Check: [""],
+ "Log in": [""],
+ Register: [""],
+ "Wire transfer completed!": [""],
+ "The withdrawal has been aborted previously and can't be confirmed": [""],
+ "The withdrawal operation can't be confirmed before a wallet accepted the transaction.":
+ [""],
+ "Your balance is not enough for the operation.": [""],
+ "Confirm the withdrawal operation": [""],
+ "Wire transfer details": [""],
+ "Taler Exchange operator's account": [""],
+ "Taler Exchange operator's name": [""],
+ Transfer: [""],
+ "Authentication required": [""],
+ "This operation was created with other username": [""],
+ "Operation aborted": [""],
+ "The wire transfer to the Taler Exchange operator's account was aborted, your balance was not affected.":
+ [""],
+ "You can close this page now or continue to the account page.": [""],
+ Continue: [""],
+ "Withdrawal confirmed": [""],
+ "The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet.":
+ [""],
+ Done: [""],
+ "Operation canceled": [""],
+ "The operation is marked as 'selected' but some step in the withdrawal failed":
+ [""],
+ "The account is selected but no withdrawal identification found.": [""],
+ "There is a withdrawal identification but no account has been selected or the selected account is invalid.":
+ [""],
+ "No withdrawal ID found and no account has been selected or the selected account is invalid.":
+ [""],
+ "Operation not found": [""],
+ "This operation is not known by the server. The operation id is wrong or the server deleted the operation information before reaching here.":
+ [""],
+ "Cotinue to dashboard": [""],
+ "The Withdrawal URI is not valid": [""],
+ 'the bank backend is not supported. supported version "%1$s", server version "%2$s"':
+ [""],
+ "Internal error, please report.": [""],
+ Preferences: [""],
+ "Welcome, %1$s": [""],
+ "Latest transactions": [""],
+ Date: [""],
+ Counterpart: [""],
+ Subject: [""],
+ sent: [""],
+ received: [""],
+ "invalid value": [""],
+ to: [""],
+ from: [""],
+ "First page": [""],
+ Next: [""],
+ "History of public accounts": [""],
+ "Currently, the bank is not accepting new registrations!": [""],
+ "Missing name": [""],
+ "Use letters and numbers only, and start with a lowercase letter": [""],
+ "Passwords don't match": [""],
+ "Server replied with invalid phone or email.": [""],
+ "Registration is disabled because the bank ran out of bonus credit.": [
+ "",
+ ],
+ "No enough permission to create that account.": [""],
+ "That account id is already taken.": [""],
+ "That username is already taken.": [""],
+ "That username can't be used because is reserved.": [""],
+ "Only admin is allow to set debt limit.": [""],
+ "No information for the selected authentication channel.": [""],
+ "Authentication channel is not supported.": [""],
+ "Only admin can create accounts with second factor authentication.": [""],
+ "Account registration": [""],
+ "Repeat password": [""],
+ Name: [""],
+ "Create a random temporary user": [""],
+ "Make a wire transfer": [""],
+ "Wire transfer created!": [""],
+ Accounts: [""],
+ "A list of all business account in the bank.": [""],
+ "Create account": [""],
+ Balance: [""],
+ Actions: [""],
+ unknown: [""],
+ "change password": [""],
+ remove: [""],
+ "Select a section": [""],
+ "Last hour": [""],
+ "Last day": [""],
+ "Last month": [""],
+ "Last year": [""],
+ "Last Year": [""],
+ "Trading volume on %1$s compared to %2$s": [""],
+ Cashin: [""],
+ Cashout: [""],
+ Payin: [""],
+ Payout: [""],
+ "download stats as CSV": [""],
+ "Descreased by": [""],
+ "Increased by": [""],
+ "Unable to create a cashout": [""],
+ "The bank configuration does not support cashout operations.": [""],
+ invalid: [""],
+ "need to be higher due to fees": [""],
+ "the total transfer at destination will be zero": [""],
+ "Cashout created": [""],
+ "Duplicated request detected, check if the operation succeded or try again.":
+ [""],
+ "The conversion rate was incorrectly applied": [""],
+ "The account does not have sufficient funds": [""],
+ "Cashouts are not supported": [""],
+ "Missing cashout URI in the profile": [""],
+ "Sending the confirmation message failed, retry later or contact the administrator.":
+ [""],
+ "Convertion rate": [""],
+ Fee: [""],
+ "To account": [""],
+ "No cashout account": [""],
+ "Before doing a cashout you need to complete your profile": [""],
+ "Amount to send": [""],
+ "Amount to receive": [""],
+ "Total cost": [""],
+ "Balance left": [""],
+ "Before fee": [""],
+ "Total cashout transfer": [""],
+ "No cashout channel available": [""],
+ "Before doing a cashout the server need to provide an second channel to confirm the operation":
+ [""],
+ "Second factor authentication": [""],
+ Email: [""],
+ "add a email in your profile to enable this option": [""],
+ SMS: [""],
+ "add a phone number in your profile to enable this option": [""],
+ Details: [""],
+ Delete: [""],
+ Credentials: [""],
+ Cashouts: [""],
+ "it doesnt have the pattern of an IBAN number": [""],
+ "it doesnt have the pattern of an email": [""],
+ "should start with +": [""],
+ "phone number can't have other than numbers": [""],
+ "account identification in the bank": [""],
+ "name of the person owner the account": [""],
+ "Internal IBAN": [""],
+ "if empty a random account number will be assigned": [""],
+ "account identification for bank transfer": [""],
+ Phone: [""],
+ "Cashout IBAN": [""],
+ "account number where the money is going to be sent when doing cashouts":
+ [""],
+ "Max debt": [""],
+ "how much is user able to transfer after zero balance": [""],
+ "Is this a Taler Exchange?": [""],
+ "This server doesn't support second factor authentication.": [""],
+ "Enable second factor authentication": [""],
+ "Using email": [""],
+ "Using SMS": [""],
+ "Is this account public?": [""],
+ "public accounts have their balance publicly accesible": [""],
+ "Account updated": [""],
+ "The rights to change the account are not sufficient": [""],
+ "The username was not found": [""],
+ "You can't change the legal name, please contact the your account administrator.":
+ [""],
+ "You can't change the debt limit, please contact the your account administrator.":
+ [""],
+ "You can't change the cashout address, please contact the your account administrator.":
+ [""],
+ "You can't change the contact data, please contact the your account administrator.":
+ [""],
+ 'Account "%1$s"': [""],
+ "Change details": [""],
+ Update: [""],
+ "password doesn't match": [""],
+ "Password changed": [""],
+ "Not authorized to change the password, maybe the session is invalid.": [
+ "",
+ ],
+ "You need to provide the old password. If you don't have it contact your account administrator.":
+ [""],
+ "Your current password doesn't match, can't change to a new password.": [
+ "",
+ ],
+ "Update password": [""],
+ "New password": [""],
+ "Type it again": [""],
+ "repeat the same password": [""],
+ "Current password": [""],
+ "your current password, for security": [""],
+ Change: [""],
+ "Can't delete the account": [""],
+ "The account can't be delete while still holding some balance. First make sure that the owner make a complete cashout.":
+ [""],
+ "Account removed": [""],
+ "No enough permission to delete the account.": [""],
+ "The username was not found.": [""],
+ "Can't delete a reserved username.": [""],
+ "Can't delete an account with balance different than zero.": [""],
+ "name doesn't match": [""],
+ "You are going to remove the account": [""],
+ "This step can't be undone.": [""],
+ 'Deleting account "%1$s"': [""],
+ Verification: [""],
+ "enter the account name that is going to be deleted": [""],
+ 'Account created with password "%1$s". The user must change the password on the next login.':
+ [""],
+ "Server replied that phone or email is invalid": [""],
+ "The rights to perform the operation are not sufficient": [""],
+ "Account username is already taken": [""],
+ "Account id is already taken": [""],
+ "Bank ran out of bonus credit.": [""],
+ "Account username can't be used because is reserved": [""],
+ "Can't create accounts": [""],
+ "Only system admin can create accounts.": [""],
+ "New business account": [""],
+ Create: [""],
+ "Cashout not supported.": [""],
+ "Account not found.": [""],
+ "Latest cashouts": [""],
+ Created: [""],
+ Confirmed: [""],
+ "Total debit": [""],
+ "Total credit": [""],
+ Status: [""],
+ never: [""],
+ "Cashout for account %1$s": [""],
+ "This cashout not found. Maybe already aborted.": [""],
+ "Cashout not found. It may be also mean that it was already aborted.": [
+ "",
+ ],
+ "Cashout was already confimed.": [""],
+ "Cashout operation is not supported.": [""],
+ "The cashout operation is already aborted.": [""],
+ "Missing destination account.": [""],
+ "Too many failed attempts.": [""],
+ "The code for this cashout is invalid.": [""],
+ "Cashout detail": [""],
+ Debited: [""],
+ Credited: [""],
+ "Enter the confirmation code": [""],
+ Abort: [""],
+ Confirm: [""],
+ "Unauthorized to make the operation, maybe the session has expired or the password changed.":
+ [""],
+ "The operation was rejected due to insufficient funds.": [""],
+ "Do not show this again": [""],
+ Close: [""],
+ "On this device": [""],
+ 'If you are using a web browser on desktop you should access your wallet with the GNU Taler WebExtension now or click the link if your WebExtension have the "Inject Taler support" option enabled.':
+ [""],
+ Start: [""],
+ "On a mobile phone": [""],
+ "Scan the QR code with your mobile device.": [""],
+ "There is an operation already": [""],
+ "Complete or cancel the operation in": [""],
+ "Server responded with an invalid withdraw URI": [""],
+ "Withdraw URI: %1$s": [""],
+ "The operation was rejected due to insufficient funds": [""],
+ "Prepare your wallet": [""],
+ "After using your wallet you will need to confirm or cancel the operation on this site.":
+ [""],
+ "You need a GNU Taler Wallet": [""],
+ "If you don't have one yet you can follow the instruction in": [""],
+ "Send money": [""],
+ "to a %1$s wallet": [""],
+ "Withdraw digital money into your mobile wallet or browser extension": [
+ "",
+ ],
+ "operation ready": [""],
+ "to another bank account": [""],
+ "Make a wire transfer to an account with known bank account number.": [
+ "",
+ ],
+ "Transfer details": [""],
+ "This is a demo bank": [""],
+ "This part of the demo shows how a bank that supports Taler directly would work. In addition to using your own bank account, you can also see the transaction history of some %1$s.":
+ [""],
+ "This part of the demo shows how a bank that supports Taler directly would work.":
+ [""],
+ "Pending account delete operation": [""],
+ "Pending account update operation": [""],
+ "Pending password update operation": [""],
+ "Pending transaction operation": [""],
+ "Pending withdrawal operation": [""],
+ "Pending cashout operation": [""],
+ "You can complete or cancel the operation in": [""],
+ "Download bank stats": [""],
+ "Include hour metric": [""],
+ "Include day metric": [""],
+ "Include month metric": [""],
+ "Include year metric": [""],
+ "Include table header": [""],
+ "Add previous metric for compare": [""],
+ "Fail on first error": [""],
+ Download: [""],
+ "downloading... %1$s": [""],
+ "Download completed": [""],
+ "click here to save the file in your computer": [""],
+ "Challenge not found.": [""],
+ "This user is not authorized to complete this challenge.": [""],
+ "Too many attemps, try another code.": [""],
+ "The confirmation code is wrong, try again.": [""],
+ "The operation expired.": [""],
+ "The operation failed.": [""],
+ "The operation needs another confirmation to complete.": [""],
+ "Account delete": [""],
+ "Account update": [""],
+ "Password update": [""],
+ "Wire transfer": [""],
+ Withdrawal: [""],
+ "Confirm the operation": [""],
+ "Send again": [""],
+ "Send code": [""],
+ "Operation details": [""],
+ "Challenge details": [""],
+ "Sent at": [""],
+ "To phone": [""],
+ "To email": [""],
+ "Welcome to %1$s!": [""],
+ },
+ },
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=(n != 1);",
+ lang: "en",
+ completeness: 100,
+};
+
+strings["de"] = {
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "de",
+ },
+ "Operation failed, please report": [""],
+ "Request timeout": [""],
+ "Request throttled": [""],
+ "Malformed response": [""],
+ "Network error": [""],
+ "Unexpected request error": [""],
+ "Unexpected error": [""],
+ "IBAN numbers usually have more that 4 digits": [""],
+ "IBAN numbers usually have less that 34 digits": [""],
+ "IBAN country code not found": [""],
+ "IBAN number is not valid, checksum is wrong": [""],
+ "Max withdrawal amount": [""],
+ "Show withdrawal confirmation": [""],
+ "Show demo description": [""],
+ "Show install wallet first": [""],
+ "Use fast withdrawal form": [""],
+ "Show debug info": [""],
+ "The reserve operation has been confirmed previously and can't be aborted":
+ [""],
+ "The operation id is invalid.": [""],
+ "The operation was not found.": [""],
+ "If you have a Taler wallet installed in this device": [""],
+ "You will see the details of the operation in your wallet including the fees (if applies). If you still don't have one you can install it following instructions in":
+ [""],
+ "this page": [""],
+ Withdraw: [""],
+ "Or if you have the wallet in another device": [""],
+ "Scan the QR below to start the withdrawal.": [""],
+ required: [""],
+ "IBAN should have just uppercased letters and numbers": [""],
+ "not valid": [""],
+ "should be greater than 0": [""],
+ "balance is not enough": [""],
+ "does not follow the pattern": [""],
+ 'only "IBAN" target are supported': [""],
+ 'use the "amount" parameter to specify the amount to be transferred': [
+ "",
+ ],
+ "the amount is not valid": [""],
+ 'use the "message" parameter to specify a reference text for the transfer':
+ [""],
+ "The request was invalid or the payto://-URI used unacceptable features.":
+ [""],
+ "Not enough permission to complete the operation.": [""],
+ 'The destination account "%1$s" was not found.': [""],
+ "The origin and the destination of the transfer can't be the same.": [""],
+ "Your balance is not enough.": [""],
+ 'The origin account "%1$s" was not found.': [""],
+ "Using a form": [""],
+ "Import payto:// URI": [""],
+ Recipient: [""],
+ "IBAN of the recipient's account": [""],
+ "Transfer subject": [""],
+ subject: ["Verwendungszweck"],
+ "some text to identify the transfer": [""],
+ Amount: ["Betrag"],
+ "amount to transfer": ["Betrag"],
+ "payto URI:": [""],
+ "uniform resource identifier of the target account": [""],
+ "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]": [""],
+ Cancel: [""],
+ Send: [""],
+ "Missing username": [""],
+ "Missing password": [""],
+ 'Wrong credentials for "%1$s"': [""],
+ "Account not found": [""],
+ Username: [""],
+ "username of the account": [""],
+ Password: [""],
+ "password of the account": ["Buchungen auf öffentlich sichtbaren Konten"],
+ Check: [""],
+ "Log in": [""],
+ Register: [""],
+ "Wire transfer completed!": [""],
+ "The withdrawal has been aborted previously and can't be confirmed": [""],
+ "The withdrawal operation can't be confirmed before a wallet accepted the transaction.":
+ [""],
+ "Your balance is not enough for the operation.": [""],
+ "Confirm the withdrawal operation": ["Abhebung bestätigen"],
+ "Wire transfer details": [""],
+ "Taler Exchange operator's account": [""],
+ "Taler Exchange operator's name": [""],
+ Transfer: [""],
+ "Authentication required": [""],
+ "This operation was created with other username": [""],
+ "Operation aborted": [""],
+ "The wire transfer to the Taler Exchange operator's account was aborted, your balance was not affected.":
+ [""],
+ "You can close this page now or continue to the account page.": [""],
+ Continue: [""],
+ "Withdrawal confirmed": [""],
+ "The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet.":
+ [""],
+ Done: [""],
+ "Operation canceled": [""],
+ "The operation is marked as 'selected' but some step in the withdrawal failed":
+ [""],
+ "The account is selected but no withdrawal identification found.": [""],
+ "There is a withdrawal identification but no account has been selected or the selected account is invalid.":
+ [""],
+ "No withdrawal ID found and no account has been selected or the selected account is invalid.":
+ [""],
+ "Operation not found": [""],
+ "This operation is not known by the server. The operation id is wrong or the server deleted the operation information before reaching here.":
+ [""],
+ "Cotinue to dashboard": [""],
+ "The Withdrawal URI is not valid": [""],
+ 'the bank backend is not supported. supported version "%1$s", server version "%2$s"':
+ [""],
+ "Internal error, please report.": [""],
+ Preferences: [""],
+ "Welcome, %1$s": [""],
+ "Latest transactions": [""],
+ Date: ["Datum"],
+ Counterpart: ["Empfänger"],
+ Subject: ["Verwendungszweck"],
+ sent: [""],
+ received: [""],
+ "invalid value": [""],
+ to: [""],
+ from: [""],
+ "First page": [""],
+ Next: [""],
+ "History of public accounts": [
+ "Buchungen auf öffentlich sichtbaren Konten",
+ ],
+ "Currently, the bank is not accepting new registrations!": [""],
+ "Missing name": [""],
+ "Use letters and numbers only, and start with a lowercase letter": [""],
+ "Passwords don't match": [""],
+ "Server replied with invalid phone or email.": [""],
+ "Registration is disabled because the bank ran out of bonus credit.": [
+ "",
+ ],
+ "No enough permission to create that account.": [""],
+ "That account id is already taken.": [""],
+ "That username is already taken.": [""],
+ "That username can't be used because is reserved.": [""],
+ "Only admin is allow to set debt limit.": [""],
+ "No information for the selected authentication channel.": [""],
+ "Authentication channel is not supported.": [""],
+ "Only admin can create accounts with second factor authentication.": [""],
+ "Account registration": [""],
+ "Repeat password": [""],
+ Name: [""],
+ "Create a random temporary user": [""],
+ "Make a wire transfer": [""],
+ "Wire transfer created!": [""],
+ Accounts: ["Betrag"],
+ "A list of all business account in the bank.": [""],
+ "Create account": [""],
+ Balance: [""],
+ Actions: [""],
+ unknown: [""],
+ "change password": [""],
+ remove: [""],
+ "Select a section": [""],
+ "Last hour": [""],
+ "Last day": [""],
+ "Last month": [""],
+ "Last year": [""],
+ "Last Year": [""],
+ "Trading volume on %1$s compared to %2$s": [""],
+ Cashin: [""],
+ Cashout: [""],
+ Payin: [""],
+ Payout: [""],
+ "download stats as CSV": [""],
+ "Descreased by": [""],
+ "Increased by": [""],
+ "Unable to create a cashout": [""],
+ "The bank configuration does not support cashout operations.": [""],
+ invalid: [""],
+ "need to be higher due to fees": [""],
+ "the total transfer at destination will be zero": [""],
+ "Cashout created": [""],
+ "Duplicated request detected, check if the operation succeded or try again.":
+ [""],
+ "The conversion rate was incorrectly applied": [""],
+ "The account does not have sufficient funds": [""],
+ "Cashouts are not supported": [""],
+ "Missing cashout URI in the profile": [""],
+ "Sending the confirmation message failed, retry later or contact the administrator.":
+ [""],
+ "Convertion rate": [""],
+ Fee: [""],
+ "To account": [""],
+ "No cashout account": [""],
+ "Before doing a cashout you need to complete your profile": [""],
+ "Amount to send": ["Betrag"],
+ "Amount to receive": [""],
+ "Total cost": [""],
+ "Balance left": [""],
+ "Before fee": [""],
+ "Total cashout transfer": [""],
+ "No cashout channel available": [""],
+ "Before doing a cashout the server need to provide an second channel to confirm the operation":
+ [""],
+ "Second factor authentication": [""],
+ Email: [""],
+ "add a email in your profile to enable this option": [""],
+ SMS: [""],
+ "add a phone number in your profile to enable this option": [""],
+ Details: [""],
+ Delete: [""],
+ Credentials: [""],
+ Cashouts: [""],
+ "it doesnt have the pattern of an IBAN number": [""],
+ "it doesnt have the pattern of an email": [""],
+ "should start with +": [""],
+ "phone number can't have other than numbers": [""],
+ "account identification in the bank": [""],
+ "name of the person owner the account": [""],
+ "Internal IBAN": [""],
+ "if empty a random account number will be assigned": [""],
+ "account identification for bank transfer": [""],
+ Phone: [""],
+ "Cashout IBAN": [""],
+ "account number where the money is going to be sent when doing cashouts":
+ [""],
+ "Max debt": [""],
+ "how much is user able to transfer after zero balance": [""],
+ "Is this a Taler Exchange?": [""],
+ "This server doesn't support second factor authentication.": [""],
+ "Enable second factor authentication": [""],
+ "Using email": [""],
+ "Using SMS": [""],
+ "Is this account public?": [""],
+ "public accounts have their balance publicly accesible": [""],
+ "Account updated": [""],
+ "The rights to change the account are not sufficient": [""],
+ "The username was not found": [""],
+ "You can't change the legal name, please contact the your account administrator.":
+ [""],
+ "You can't change the debt limit, please contact the your account administrator.":
+ [""],
+ "You can't change the cashout address, please contact the your account administrator.":
+ [""],
+ "You can't change the contact data, please contact the your account administrator.":
+ [""],
+ 'Account "%1$s"': [""],
+ "Change details": [""],
+ Update: [""],
+ "password doesn't match": [""],
+ "Password changed": [""],
+ "Not authorized to change the password, maybe the session is invalid.": [
+ "",
+ ],
+ "You need to provide the old password. If you don't have it contact your account administrator.":
+ [""],
+ "Your current password doesn't match, can't change to a new password.": [
+ "",
+ ],
+ "Update password": [""],
+ "New password": [""],
+ "Type it again": [""],
+ "repeat the same password": [""],
+ "Current password": [""],
+ "your current password, for security": [""],
+ Change: [""],
+ "Can't delete the account": [""],
+ "The account can't be delete while still holding some balance. First make sure that the owner make a complete cashout.":
+ [""],
+ "Account removed": [""],
+ "No enough permission to delete the account.": [""],
+ "The username was not found.": [""],
+ "Can't delete a reserved username.": [""],
+ "Can't delete an account with balance different than zero.": [""],
+ "name doesn't match": [""],
+ "You are going to remove the account": [""],
+ "This step can't be undone.": [""],
+ 'Deleting account "%1$s"': [""],
+ Verification: [""],
+ "enter the account name that is going to be deleted": [""],
+ 'Account created with password "%1$s". The user must change the password on the next login.':
+ [""],
+ "Server replied that phone or email is invalid": [""],
+ "The rights to perform the operation are not sufficient": [""],
+ "Account username is already taken": [""],
+ "Account id is already taken": [""],
+ "Bank ran out of bonus credit.": [""],
+ "Account username can't be used because is reserved": [""],
+ "Can't create accounts": [""],
+ "Only system admin can create accounts.": [""],
+ "New business account": [""],
+ Create: [""],
+ "Cashout not supported.": [""],
+ "Account not found.": [""],
+ "Latest cashouts": [""],
+ Created: [""],
+ Confirmed: ["Bestätigen"],
+ "Total debit": [""],
+ "Total credit": [""],
+ Status: [""],
+ never: [""],
+ "Cashout for account %1$s": [""],
+ "This cashout not found. Maybe already aborted.": [""],
+ "Cashout not found. It may be also mean that it was already aborted.": [
+ "",
+ ],
+ "Cashout was already confimed.": [""],
+ "Cashout operation is not supported.": [""],
+ "The cashout operation is already aborted.": [""],
+ "Missing destination account.": [""],
+ "Too many failed attempts.": [""],
+ "The code for this cashout is invalid.": [""],
+ "Cashout detail": [""],
+ Debited: [""],
+ Credited: [""],
+ "Enter the confirmation code": [""],
+ Abort: [""],
+ Confirm: ["Bestätigen"],
+ "Unauthorized to make the operation, maybe the session has expired or the password changed.":
+ [""],
+ "The operation was rejected due to insufficient funds.": [""],
+ "Do not show this again": [""],
+ Close: [""],
+ "On this device": [""],
+ 'If you are using a web browser on desktop you should access your wallet with the GNU Taler WebExtension now or click the link if your WebExtension have the "Inject Taler support" option enabled.':
+ [""],
+ Start: [""],
+ "On a mobile phone": [""],
+ "Scan the QR code with your mobile device.": [""],
+ "There is an operation already": [""],
+ "Complete or cancel the operation in": ["Abhebung bestätigen"],
+ "Server responded with an invalid withdraw URI": [""],
+ "Withdraw URI: %1$s": [""],
+ "The operation was rejected due to insufficient funds": [""],
+ "Prepare your wallet": [""],
+ "After using your wallet you will need to confirm or cancel the operation on this site.":
+ [""],
+ "You need a GNU Taler Wallet": [""],
+ "If you don't have one yet you can follow the instruction in": [""],
+ "Send money": [""],
+ "to a %1$s wallet": [""],
+ "Withdraw digital money into your mobile wallet or browser extension": [
+ "",
+ ],
+ "operation ready": [""],
+ "to another bank account": [""],
+ "Make a wire transfer to an account with known bank account number.": [
+ "",
+ ],
+ "Transfer details": [""],
+ "This is a demo bank": [""],
+ "This part of the demo shows how a bank that supports Taler directly would work. In addition to using your own bank account, you can also see the transaction history of some %1$s.":
+ [""],
+ "This part of the demo shows how a bank that supports Taler directly would work.":
+ [""],
+ "Pending account delete operation": [""],
+ "Pending account update operation": [""],
+ "Pending password update operation": [""],
+ "Pending transaction operation": [""],
+ "Pending withdrawal operation": [""],
+ "Pending cashout operation": [""],
+ "You can complete or cancel the operation in": [""],
+ "Download bank stats": [""],
+ "Include hour metric": [""],
+ "Include day metric": [""],
+ "Include month metric": [""],
+ "Include year metric": [""],
+ "Include table header": [""],
+ "Add previous metric for compare": [""],
+ "Fail on first error": [""],
+ Download: [""],
+ "downloading... %1$s": [""],
+ "Download completed": [""],
+ "click here to save the file in your computer": [""],
+ "Challenge not found.": [""],
+ "This user is not authorized to complete this challenge.": [""],
+ "Too many attemps, try another code.": [""],
+ "The confirmation code is wrong, try again.": [""],
+ "The operation expired.": [""],
+ "The operation failed.": [""],
+ "The operation needs another confirmation to complete.": [""],
+ "Account delete": [""],
+ "Account update": [""],
+ "Password update": [""],
+ "Wire transfer": [""],
+ Withdrawal: ["Abhebung bestätigen"],
+ "Confirm the operation": ["Abhebung bestätigen"],
+ "Send again": [""],
+ "Send code": [""],
+ "Operation details": [""],
+ "Challenge details": [""],
+ "Sent at": [""],
+ "To phone": [""],
+ "To email": [""],
+ "Welcome to %1$s!": [""],
+ },
+ },
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "de",
+ completeness: 4,
+};
diff --git a/packages/bank-ui/src/i18n/uk.po b/packages/bank-ui/src/i18n/uk.po
new file mode 100644
index 000000000..a8b41e32f
--- /dev/null
+++ b/packages/bank-ui/src/i18n/uk.po
@@ -0,0 +1,1743 @@
+# This file is part of GNU Taler
+# (C) 2022-2024 Taler Systems S.A.
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Bank\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: 2024-03-07 07:04+0000\n"
+"Last-Translator: Tim Vutor <flukes.ostrich0p@icloud.com>\n"
+"Language-Team: Ukrainian <https://weblate.taler.net/projects/gnu-taler/"
+"taler-bank-spa/uk/>\n"
+"Language: uk\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/utils.ts:137
+#, c-format, fuzzy
+msgid "Operation failed, please report"
+msgstr "Помилка операції, повідомте"
+
+#: src/utils.ts:156
+#, c-format
+msgid "Request timeout"
+msgstr "Тайм-аут запиту"
+
+#: src/utils.ts:165
+#, c-format, fuzzy
+msgid "Request throttled"
+msgstr "Запит до сервера перервано"
+
+#: src/utils.ts:174
+#, c-format
+msgid "Malformed response"
+msgstr "Некоректна відповідь"
+
+#: src/utils.ts:183
+#, c-format
+msgid "Network error"
+msgstr "Мережева помилка"
+
+#: src/utils.ts:192
+#, c-format
+msgid "Unexpected request error"
+msgstr "Неочікувана помилка запиту"
+
+#: src/utils.ts:201
+#, c-format
+msgid "Unexpected error"
+msgstr "Неочікувана помилка"
+
+#: src/utils.ts:377
+#, c-format, fuzzy
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr "Номера IBAN зазвичай мають більше 4ьох цифр"
+
+#: src/utils.ts:379
+#, c-format, fuzzy
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr "Номера IBAN зазвичай мають менше 34ьох цифр"
+
+#: src/utils.ts:387
+#, c-format, fuzzy
+msgid "IBAN country code not found"
+msgstr "Код країни IBAN не знайдено"
+
+#: src/utils.ts:401
+#, c-format, fuzzy
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr "Номер IBAN невірний, контрольна сума не сходиться"
+
+#: src/context/config.ts:136
+#, c-format
+msgid ""
+"the bank backend is not supported. supported version \"%1$s\", server version "
+"\"%2$s\""
+msgstr ""
+
+#: src/hooks/preferences.ts:55
+#, c-format
+msgid "Max withdrawal amount"
+msgstr "Максимальна сумма для виведення"
+
+#: src/hooks/preferences.ts:57
+#, c-format
+msgid "Show withdrawal confirmation"
+msgstr "Показати підтвердження виводу"
+
+#: src/hooks/preferences.ts:59
+#, c-format
+msgid "Show demo description"
+msgstr "Показати демо опис"
+
+#: src/hooks/preferences.ts:61
+#, c-format
+msgid "Show install wallet first"
+msgstr ""
+
+#: src/hooks/preferences.ts:63
+#, c-format
+msgid "Use fast withdrawal form"
+msgstr ""
+
+#: src/hooks/preferences.ts:65
+#, c-format
+msgid "Show debug info"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:90
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:92
+#, c-format
+msgid "IBAN should have just uppercased letters and numbers"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:98
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:100
+#, c-format
+msgid "should be greater than 0"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:102
+#, c-format
+msgid "balance is not enough"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:112
+#, c-format
+msgid "does not follow the pattern"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:114
+#, c-format
+msgid "only \"IBAN\" target are supported"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:116
+#, c-format
+msgid "use the \"amount\" parameter to specify the amount to be transferred"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:118
+#, c-format
+msgid "the amount is not valid"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:120
+#, c-format
+msgid "use the \"message\" parameter to specify a reference text for the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:160
+#, c-format
+msgid "The request was invalid or the payto://-URI used unacceptable features."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:167
+#, c-format
+msgid "Not enough permission to complete the operation."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:174
+#, c-format
+msgid "The destination account \"%1$s\" was not found."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:181
+#, c-format
+msgid "The origin and the destination of the transfer can't be the same."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:188
+#, c-format
+msgid "Your balance is not enough."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:195
+#, c-format
+msgid "The origin account \"%1$s\" was not found."
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:212
+#, c-format
+msgid "Wire transfer created!"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:270
+#, c-format
+msgid "Using a form"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:310
+#, c-format
+msgid "Import payto:// URI"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:335
+#, c-format
+msgid "Recipient"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:359
+#, c-format
+msgid "IBAN of the recipient's account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:369
+#, c-format
+msgid "Transfer subject"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:377
+#, c-format
+msgid "subject"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:390
+#, c-format
+msgid "some text to identify the transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:400
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:415
+#, c-format
+msgid "amount to transfer"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:425
+#, c-format
+msgid "payto URI:"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:436
+#, c-format
+msgid "uniform resource identifier of the target account"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:437
+#, c-format
+msgid "payto://iban/[receiver-iban]?message=[subject]&amount=[%1$s:X.Y]"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:457
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/pages/PaytoWireTransferForm.tsx:471
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:71
+#, c-format
+msgid "Missing username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:75
+#, c-format
+msgid "Missing password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:104
+#, c-format
+msgid "Wrong credentials for \"%1$s\""
+msgstr ""
+
+#: src/pages/LoginForm.tsx:111
+#, c-format
+msgid "Account not found"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:142
+#, c-format
+msgid "Username"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:156
+#, c-format
+msgid "username of the account"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:175
+#, c-format
+msgid "Password"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:188
+#, c-format
+msgid "password of the account"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:223
+#, c-format
+msgid "Check"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:237
+#, c-format
+msgid "Log in"
+msgstr ""
+
+#: src/pages/LoginForm.tsx:249
+#, c-format
+msgid "Register"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:52
+#, c-format
+msgid "Latest transactions"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:63
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:71
+#, c-format
+msgid "Counterpart"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:75
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:111
+#, c-format
+msgid "sent"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:112
+#, c-format
+msgid "received"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:127
+#, c-format
+msgid "invalid value"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "to"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:136
+#, c-format
+msgid "from"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:202
+#, c-format
+msgid "First page"
+msgstr ""
+
+#: src/components/Transactions/views.tsx:209
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:86
+#, c-format
+msgid "Wire transfer completed!"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:93
+#, c-format
+msgid "The withdrawal has been aborted previously and can't be confirmed"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:100
+#, c-format
+msgid ""
+"The withdrawal operation can't be confirmed before a wallet accepted the "
+"transaction."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:107
+#, c-format
+msgid "The operation id is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:114
+#, c-format
+msgid "The operation was not found."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:121
+#, c-format
+msgid "Your balance is not enough for the operation."
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:155
+#, c-format
+msgid "The reserve operation has been confirmed previously and can't be aborted"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:186
+#, c-format
+msgid "Confirm the withdrawal operation"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:203
+#, c-format
+msgid "Wire transfer details"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:217
+#, c-format
+msgid "Taler Exchange operator's account"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:228
+#, c-format
+msgid "Taler Exchange operator's name"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:317
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:342
+#, c-format
+msgid "Authentication required"
+msgstr ""
+
+#: src/pages/WithdrawalConfirmationQuestion.tsx:352
+#, c-format
+msgid "This operation was created with other username"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:209
+#, c-format
+msgid ""
+"Unauthorized to make the operation, maybe the session has expired or the "
+"password changed."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:218
+#, c-format
+msgid "The operation was rejected due to insufficient funds."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:268
+#, c-format
+msgid "Withdrawal confirmed"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:272
+#, c-format
+msgid ""
+"The wire transfer to the Taler operator has been initiated. You will soon "
+"receive the requested amount in your Taler wallet."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:287
+#, c-format
+msgid "Do not show this again"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:319
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:399
+#, c-format
+msgid "On this device"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:404
+#, c-format
+msgid ""
+"If you are using a web browser on desktop you should access your wallet with the "
+"GNU Taler WebExtension now or click the link if your WebExtension have the "
+"\"Inject Taler support\" option enabled."
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:417
+#, c-format
+msgid "Start"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:426
+#, c-format
+msgid "On a mobile phone"
+msgstr ""
+
+#: src/pages/OperationState/views.tsx:431
+#, c-format
+msgid "Scan the QR code with your mobile device."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:73
+#, c-format
+msgid "There is an operation already"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:75
+#, c-format
+msgid "Complete or cancel the operation in"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:84
+#, c-format
+msgid "this page"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:101
+#, c-format
+msgid "invalid"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:116
+#, c-format
+msgid "Server responded with an invalid withdraw URI"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:117
+#, c-format
+msgid "Withdraw URI: %1$s"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:132
+#, c-format
+msgid "The operation was rejected due to insufficient funds"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:253
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:282
+#, c-format
+msgid "Prepare your wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:285
+#, c-format
+msgid ""
+"After using your wallet you will need to confirm or cancel the operation on this "
+"site."
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:295
+#, c-format
+msgid "You need a GNU Taler Wallet"
+msgstr ""
+
+#: src/pages/WalletWithdrawForm.tsx:300
+#, c-format
+msgid "If you don't have one yet you can follow the instruction in"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:55
+#, c-format
+msgid "Send money"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:73
+#, c-format
+msgid "to a %1$s wallet"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:95
+#, c-format
+msgid "Withdraw digital money into your mobile wallet or browser extension"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:109
+#, c-format
+msgid "operation ready"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:129
+#, c-format
+msgid "to another bank account"
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:149
+#, c-format
+msgid "Make a wire transfer to an account with known bank account number."
+msgstr ""
+
+#: src/pages/PaymentOptions.tsx:171
+#, c-format
+msgid "Transfer details"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:41
+#, c-format
+msgid "This is a demo bank"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:46
+#, c-format
+msgid ""
+"This part of the demo shows how a bank that supports Taler directly would work. "
+"In addition to using your own bank account, you can also see the transaction "
+"history of some %1$s."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:53
+#, c-format
+msgid "This part of the demo shows how a bank that supports Taler directly would work."
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:70
+#, c-format
+msgid "Pending account delete operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:72
+#, c-format
+msgid "Pending account update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:74
+#, c-format
+msgid "Pending password update operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:76
+#, c-format
+msgid "Pending transaction operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:78
+#, c-format
+msgid "Pending withdrawal operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:80
+#, c-format
+msgid "Pending cashout operation"
+msgstr ""
+
+#: src/pages/AccountPage/views.tsx:91
+#, c-format
+msgid "You can complete or cancel the operation in"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:64
+#, c-format
+msgid "Internal error, please report."
+msgstr ""
+
+#: src/pages/BankFrame.tsx:100
+#, c-format
+msgid "Preferences"
+msgstr ""
+
+#: src/pages/BankFrame.tsx:184
+#, c-format
+msgid "Welcome, %1$s"
+msgstr ""
+
+#: src/pages/WireTransfer.tsx:79
+#, c-format
+msgid "Make a wire transfer"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:72
+#, c-format
+msgid "Accounts"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:75
+#, c-format
+msgid "A list of all business account in the bank."
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:86
+#, c-format
+msgid "Create account"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:106
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:110
+#, c-format
+msgid "Balance"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:112
+#, c-format
+msgid "Actions"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:151
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:170
+#, c-format
+msgid "change password"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:179
+#, c-format
+msgid "cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountList.tsx:189
+#, c-format
+msgid "remove"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:168
+#, c-format
+msgid "Cashout not implemented"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:184
+#, c-format
+msgid "Select a section"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:202
+#, c-format
+msgid "Last hour"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:208
+#, c-format
+msgid "Last day"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:216
+#, c-format
+msgid "Last month"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:222
+#, c-format
+msgid "Last year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:310
+#, c-format
+msgid "Last Year"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:325
+#, c-format
+msgid "Trading volume on %1$s compared to %2$s"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:342
+#, c-format
+msgid "Cashin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:352
+#, c-format
+msgid "Cashout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:364
+#, c-format
+msgid "Payin"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:374
+#, c-format
+msgid "Payout"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:388
+#, c-format
+msgid "download stats as CSV"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:494
+#, c-format
+msgid "Decreased by"
+msgstr ""
+
+#: src/pages/admin/AdminHome.tsx:498
+#, c-format
+msgid "Increased by"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:89
+#, c-format
+msgid "Download bank stats"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:110
+#, c-format
+msgid "Include hour metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:143
+#, c-format
+msgid "Include day metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:173
+#, c-format
+msgid "Include month metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:206
+#, c-format
+msgid "Include year metric"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:239
+#, c-format
+msgid "Include table header"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:272
+#, c-format
+msgid "Add previous metric for compare"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:307
+#, c-format
+msgid "Fail on first error"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:364
+#, c-format
+msgid "Download"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:381
+#, c-format
+msgid "downloading... %1$s"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:399
+#, c-format
+msgid "Download completed"
+msgstr ""
+
+#: src/pages/DownloadStats.tsx:400
+#, c-format
+msgid "click here to save the file in your computer"
+msgstr ""
+
+#: src/pages/PublicHistoriesPage.tsx:78
+#, c-format
+msgid "History of public accounts"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:48
+#, c-format
+msgid "Currently, the bank is not accepting new registrations!"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:87
+#, c-format
+msgid "Missing name"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:91
+#, c-format
+msgid "Use letters and numbers only, and start with a lowercase letter"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:107
+#, c-format
+msgid "Passwords don't match"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:130
+#, c-format
+msgid "Server replied with invalid phone or email."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:137
+#, c-format
+msgid "Registration is disabled because the bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:144
+#, c-format
+msgid "No enough permission to create that account."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:151
+#, c-format
+msgid "That account id is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:158
+#, c-format
+msgid "That username is already taken."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:165
+#, c-format
+msgid "That username can't be used because is reserved."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:172
+#, c-format
+msgid "Only admin is allow to set debt limit."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:179
+#, c-format
+msgid "No information for the selected authentication channel."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:186
+#, c-format
+msgid "Authentication channel is not supported."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:193
+#, c-format
+msgid "Only admin can create accounts with second factor authentication."
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:233
+#, c-format
+msgid "Account registration"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:315
+#, c-format
+msgid "Repeat password"
+msgstr ""
+
+#: src/pages/RegistrationPage.tsx:457
+#, c-format
+msgid "Create a random temporary user"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:110
+#, c-format
+msgid "If you have a Taler wallet installed in this device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:116
+#, c-format
+msgid ""
+"You will see the details of the operation in your wallet including the fees (if "
+"applies). If you still don't have one you can install it following instructions "
+"in"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:143
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:152
+#, c-format
+msgid "Or if you have the wallet in another device"
+msgstr ""
+
+#: src/pages/QrCodeSection.tsx:157
+#, c-format
+msgid "Scan the QR below to start the withdrawal."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:79
+#, c-format
+msgid "Operation aborted"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:82
+#, c-format
+msgid ""
+"The wire transfer to the Taler Exchange operator's account was aborted, your "
+"balance was not affected."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:88
+#, c-format
+msgid "You can close this page now or continue to the account page."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:147
+#, c-format
+msgid "Done"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:158
+#, c-format
+msgid "Operation canceled"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:173
+#, c-format
+msgid "The operation is marked as 'selected' but some step in the withdrawal failed"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:175
+#, c-format
+msgid "The account is selected but no withdrawal identification found."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:188
+#, c-format
+msgid ""
+"There is a withdrawal identification but no account has been selected or the "
+"selected account is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:202
+#, c-format
+msgid ""
+"No withdrawal ID found and no account has been selected or the selected account "
+"is invalid."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:259
+#, c-format
+msgid "Operation not found"
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:263
+#, c-format
+msgid ""
+"This operation is not known by the server. The operation id is wrong or the "
+"server deleted the operation information before reaching here."
+msgstr ""
+
+#: src/pages/WithdrawalQRCode.tsx:278
+#, c-format
+msgid "Cotinue to dashboard"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:98
+#, c-format
+msgid "Cashout not found. It may be also mean that it was already aborted."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:136
+#, c-format
+msgid "Challenge not found."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:143
+#, c-format
+msgid "This user is not authorized to complete this challenge."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:150
+#, c-format
+msgid "Too many attempts, try another code."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:157
+#, c-format
+msgid "The confirmation code is wrong, try again."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:164
+#, c-format
+msgid "The operation expired."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:197
+#, c-format
+msgid "The operation failed."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:212
+#, c-format
+msgid "The operation needs another confirmation to complete."
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:224
+#, c-format
+msgid "Account delete"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:226
+#, c-format
+msgid "Account update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:228
+#, c-format
+msgid "Password update"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:230
+#, c-format
+msgid "Wire transfer"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:232
+#, c-format
+msgid "Withdrawal"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:248
+#, c-format
+msgid "Confirm the operation"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:271
+#, c-format
+msgid "Enter the confirmation code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:313
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:348
+#, c-format
+msgid "Send again"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:359
+#, c-format
+msgid "Send code"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:369
+#, c-format
+msgid "Operation details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:529
+#, c-format
+msgid "Challenge details"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:536
+#, c-format
+msgid "Sent at"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:551
+#, c-format
+msgid "To phone"
+msgstr ""
+
+#: src/pages/SolveChallengePage.tsx:553
+#, c-format
+msgid "To email"
+msgstr ""
+
+#: src/pages/WithdrawalOperationPage.tsx:49
+#, c-format
+msgid "The Withdrawal URI is not valid"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:100
+#, c-format
+msgid "Latest cashouts"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:111
+#, c-format
+msgid "Created"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:115
+#, c-format
+msgid "Total debit"
+msgstr ""
+
+#: src/components/Cashouts/views.tsx:119
+#, c-format
+msgid "Total credit"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:70
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:74
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:78
+#, c-format
+msgid "Credentials"
+msgstr ""
+
+#: src/pages/ProfileNavigation.tsx:82
+#, c-format
+msgid "Cashouts"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:95
+#, c-format
+msgid "Unable to create a cashout"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:96
+#, c-format
+msgid "The bank configuration does not support cashout operations."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:223
+#, c-format
+msgid "need to be higher due to fees"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:225
+#, c-format
+msgid "the total transfer at destination will be zero"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:250
+#, c-format
+msgid "Cashout created"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:272
+#, c-format
+msgid "Duplicated request detected, check if the operation succeeded or try again."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:279
+#, c-format
+msgid "The conversion rate was incorrectly applied"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:286
+#, c-format
+msgid "The account does not have sufficient funds"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:293
+#, c-format
+msgid "Cashouts are not supported"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:300
+#, c-format
+msgid "Missing cashout URI in the profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:307
+#, c-format
+msgid ""
+"Sending the confirmation message failed, retry later or contact the "
+"administrator."
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:339
+#, c-format
+msgid "Conversion rate"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:360
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:374
+#, c-format
+msgid "To account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:381
+#, c-format
+msgid "No cashout account"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:382
+#, c-format
+msgid "Before doing a cashout you need to complete your profile"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:440
+#, c-format
+msgid "Amount to send"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:441
+#, c-format
+msgid "Amount to receive"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:490
+#, c-format
+msgid "Total cost"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:505
+#, c-format
+msgid "Balance left"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:520
+#, c-format
+msgid "Before fee"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:533
+#, c-format
+msgid "Total cashout transfer"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:553
+#, c-format
+msgid "No cashout channel available"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:555
+#, c-format
+msgid ""
+"Before doing a cashout the server need to provide an second channel to confirm "
+"the operation"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:567
+#, c-format
+msgid "Second factor authentication"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:598
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:600
+#, c-format
+msgid "add a email in your profile to enable this option"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:646
+#, c-format
+msgid "SMS"
+msgstr ""
+
+#: src/pages/business/CreateCashout.tsx:648
+#, c-format
+msgid "add a phone number in your profile to enable this option"
+msgstr ""
+
+#: src/pages/account/CashoutListForAccount.tsx:52
+#, c-format
+msgid "Cashout for account %1$s"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:165
+#, c-format
+msgid "it doesn't have the pattern of an IBAN number"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:185
+#, c-format
+msgid "it doesn't have the pattern of an email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:190
+#, c-format
+msgid "should start with +"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:192
+#, c-format
+msgid "phone number can't have other than numbers"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:329
+#, c-format
+msgid "account identification in the bank"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:365
+#, c-format
+msgid "name of the person owner the account"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:374
+#, c-format
+msgid "Internal IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:377
+#, c-format
+msgid "if empty a random account number will be assigned"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:378
+#, c-format
+msgid "account identification for bank transfer"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:423
+#, c-format
+msgid "Phone"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:451
+#, c-format
+msgid "Cashout IBAN"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:452
+#, c-format
+msgid "account number where the money is going to be sent when doing cashouts"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:470
+#, c-format
+msgid "Max debt"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:494
+#, c-format
+msgid "how much is user able to transfer after zero balance"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:508
+#, c-format
+msgid "Is this a Taler Exchange?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:549
+#, c-format
+msgid "This server doesn't support second factor authentication."
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:560
+#, c-format
+msgid "Enable second factor authentication"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:596
+#, c-format
+msgid "Using email"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:654
+#, c-format
+msgid "Using SMS"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:691
+#, c-format
+msgid "Is this account public?"
+msgstr ""
+
+#: src/pages/admin/AccountForm.tsx:719
+#, c-format
+msgid "public accounts have their balance publicly accessible"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:100
+#, c-format
+msgid "Account updated"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:107
+#, c-format
+msgid "The rights to change the account are not sufficient"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:114
+#, c-format
+msgid "The username was not found"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:121
+#, c-format
+msgid "You can't change the legal name, please contact the your account administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:128
+#, c-format
+msgid "You can't change the debt limit, please contact the your account administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:135
+#, c-format
+msgid ""
+"You can't change the cashout address, please contact the your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:177
+#, c-format
+msgid "Account \"%1$s\""
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:190
+#, c-format
+msgid "Change details"
+msgstr ""
+
+#: src/pages/account/ShowAccountDetails.tsx:235
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:78
+#, c-format
+msgid "password doesn't match"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:95
+#, c-format
+msgid "Password changed"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:102
+#, c-format
+msgid "Not authorized to change the password, maybe the session is invalid."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:112
+#, c-format
+msgid ""
+"You need to provide the old password. If you don't have it contact your account "
+"administrator."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:117
+#, c-format
+msgid "Your current password doesn't match, can't change to a new password."
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:149
+#, c-format
+msgid "Update password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:167
+#, c-format
+msgid "New password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:195
+#, c-format
+msgid "Type it again"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:217
+#, c-format
+msgid "repeat the same password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:227
+#, c-format
+msgid "Current password"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:248
+#, c-format
+msgid "your current password, for security"
+msgstr ""
+
+#: src/pages/account/UpdateAccountPassword.tsx:272
+#, c-format
+msgid "Change"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:74
+#, c-format
+msgid ""
+"Account created with password \"%1$s\". The user must change the password on the "
+"next login."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:83
+#, c-format
+msgid "Server replied that phone or email is invalid"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:90
+#, c-format
+msgid "The rights to perform the operation are not sufficient"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:97
+#, c-format
+msgid "Account username is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:104
+#, c-format
+msgid "Account id is already taken"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:111
+#, c-format
+msgid "Bank ran out of bonus credit."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:118
+#, c-format
+msgid "Account username can't be used because is reserved"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:160
+#, c-format
+msgid "Can't create accounts"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:161
+#, c-format
+msgid "Only system admin can create accounts."
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:183
+#, c-format
+msgid "New business account"
+msgstr ""
+
+#: src/pages/admin/CreateNewAccount.tsx:209
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:94
+#, c-format
+msgid "Can't delete the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:95
+#, c-format
+msgid ""
+"The account can't be delete while still holding some balance. First make sure "
+"that the owner make a complete cashout."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:117
+#, c-format
+msgid "Account removed"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:124
+#, c-format
+msgid "No enough permission to delete the account."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:131
+#, c-format
+msgid "The username was not found."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:138
+#, c-format
+msgid "Can't delete a reserved username."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:145
+#, c-format
+msgid "Can't delete an account with balance different than zero."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:170
+#, c-format
+msgid "name doesn't match"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:180
+#, c-format
+msgid "You are going to remove the account"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:182
+#, c-format
+msgid "This step can't be undone."
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:188
+#, c-format
+msgid "Deleting account \"%1$s\""
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:206
+#, c-format
+msgid "Verification"
+msgstr ""
+
+#: src/pages/admin/RemoveAccount.tsx:231
+#, c-format
+msgid "enter the account name that is going to be deleted"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:49
+#, c-format
+msgid "cashout id should be a number"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:65
+#, c-format
+msgid "This cashout not found. Maybe already aborted."
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:106
+#, c-format
+msgid "Cashout detail"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:139
+#, c-format
+msgid "Debited"
+msgstr ""
+
+#: src/pages/business/ShowCashoutDetails.tsx:154
+#, c-format
+msgid "Credited"
+msgstr ""
+
+#: src/Routing.tsx:140
+#, c-format
+msgid "Welcome to %1$s!"
+msgstr ""
diff --git a/packages/bank-ui/src/index.html b/packages/bank-ui/src/index.html
new file mode 100644
index 000000000..0789ecf89
--- /dev/null
+++ b/packages/bank-ui/src/index.html
@@ -0,0 +1,41 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!doctype html>
+<html lang="en" class="h-full bg-gray-100">
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri,api" />
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link
+ rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
+ />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <title>Bank</title>
+ <!-- Entry point for the bank SPA. -->
+ <script type="module" src="index.js"></script>
+ <link rel="stylesheet" href="index.css" />
+ </head>
+
+ <body class="h-full">
+ <div id="app"></div>
+ </body>
+</html>
diff --git a/packages/bank-ui/src/index.tsx b/packages/bank-ui/src/index.tsx
new file mode 100644
index 000000000..f559288a3
--- /dev/null
+++ b/packages/bank-ui/src/index.tsx
@@ -0,0 +1,27 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { App } from "./app.js";
+import { h, render } from "preact";
+import "./scss/main.css";
+
+const app = document.getElementById("app");
+
+if (app) {
+ render(<App />, app);
+} else {
+ console.error("HTML element with id 'app' not found.");
+}
diff --git a/packages/bank-ui/src/manifest.json b/packages/bank-ui/src/manifest.json
new file mode 100644
index 000000000..8790b10c9
--- /dev/null
+++ b/packages/bank-ui/src/manifest.json
@@ -0,0 +1,21 @@
+{
+ "name": "taler-bank",
+ "short_name": "taler-bank",
+ "start_url": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#fff",
+ "theme_color": "#673ab8",
+ "icons": [
+ {
+ "src": "/assets/icons/android-chrome-192x192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "/assets/icons/android-chrome-512x512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ]
+}
diff --git a/packages/bank-ui/src/pages/AccountPage/index.ts b/packages/bank-ui/src/pages/AccountPage/index.ts
new file mode 100644
index 000000000..8a9471ef4
--- /dev/null
+++ b/packages/bank-ui/src/pages/AccountPage/index.ts
@@ -0,0 +1,135 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ TalerCorebankApi,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import { Loading, utils } from "@gnu-taler/web-util/browser";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { LoginForm } from "../LoginForm.js";
+import { useComponentState } from "./state.js";
+import { InvalidIbanView, ReadyView } from "./views.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export interface Props {
+ account: string;
+ onAuthorizationRequired: () => void;
+ onOperationCreated: (wopid: string) => void;
+ onClose: () => void;
+ tab: "charge-wallet" | "wire-transfer" | undefined;
+ routeClose: RouteDefinition;
+ routeCashout: RouteDefinition;
+ routeChargeWallet: RouteDefinition;
+ routeWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ routePublicAccounts: RouteDefinition;
+ routeCreateWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ routeOperationDetails: RouteDefinition<{ wopid: string }>;
+ routeSolveSecondFactor: RouteDefinition;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingError
+ | State.Ready
+ | State.InvalidIban
+ | State.UserNotFound;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingError {
+ status: "loading-error";
+ error: TalerError;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ account: string;
+ tab: "charge-wallet" | "wire-transfer" | undefined;
+ limit: AmountJson;
+ balance: AmountJson;
+ onAuthorizationRequired: () => void;
+ onOperationCreated: (wopid: string) => void;
+ onClose: () => void;
+ routeClose: RouteDefinition;
+ routeCashout: RouteDefinition;
+ routeChargeWallet: RouteDefinition;
+ routePublicAccounts: RouteDefinition;
+ routeWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ routeCreateWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ routeOperationDetails: RouteDefinition<{ wopid: string }>;
+ routeSolveSecondFactor: RouteDefinition;
+ }
+
+ export interface InvalidIban {
+ status: "invalid-iban";
+ error: TalerCorebankApi.AccountData;
+ }
+
+ export interface UserNotFound {
+ status: "login";
+ reason: "not-found" | "forbidden";
+ routeRegister?: RouteDefinition;
+ }
+}
+
+export interface Transaction {
+ negative: boolean;
+ counterpart: string;
+ when: AbsoluteTime;
+ amount: AmountJson | undefined;
+ subject: string;
+}
+
+const viewMapping: utils.StateViewMap<State> = {
+ loading: Loading,
+ login: LoginForm,
+ "invalid-iban": InvalidIbanView,
+ "loading-error": ErrorLoadingWithDebug,
+ ready: ReadyView,
+};
+
+export const AccountPage = utils.compose(
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/bank-ui/src/pages/AccountPage/state.ts b/packages/bank-ui/src/pages/AccountPage/state.ts
new file mode 100644
index 000000000..f8b91a2ce
--- /dev/null
+++ b/packages/bank-ui/src/pages/AccountPage/state.ts
@@ -0,0 +1,122 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import { useAccountDetails } from "../../hooks/account.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ account,
+ tab,
+ routeChargeWallet,
+ routeCreateWireTransfer,
+ routePublicAccounts,
+ routeSolveSecondFactor,
+ routeOperationDetails,
+ routeWireTransfer,
+ routeCashout,
+ onOperationCreated,
+ onClose,
+ routeClose,
+ onAuthorizationRequired,
+}: Props): State {
+ const result = useAccountDetails(account);
+
+ if (!result) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+
+ if (result instanceof TalerError) {
+ return {
+ status: "loading-error",
+ error: result,
+ };
+ }
+
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized:
+ return {
+ status: "login",
+ reason: "forbidden",
+ };
+ case HttpStatusCode.NotFound:
+ return {
+ status: "login",
+ reason: "not-found",
+ };
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ const { body: data } = result;
+
+ const balance = Amounts.parseOrThrow(data.balance.amount);
+
+ const debitThreshold = Amounts.parseOrThrow(data.debit_threshold);
+ const payto = parsePaytoUri(data.payto_uri);
+
+ if (
+ !payto ||
+ !payto.isKnown ||
+ (payto.targetType !== "iban" && payto.targetType !== "x-taler-bank")
+ ) {
+ return {
+ status: "invalid-iban",
+ error: data,
+ };
+ }
+
+ const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
+ const limit = balanceIsDebit
+ ? Amounts.sub(debitThreshold, balance).amount
+ : Amounts.add(balance, debitThreshold).amount;
+
+ const positiveBalance = balanceIsDebit
+ ? Amounts.zeroOfAmount(balance)
+ : balance;
+
+ return {
+ status: "ready",
+ onOperationCreated,
+ error: undefined,
+ tab,
+ routeCashout,
+ routeOperationDetails,
+ routeCreateWireTransfer,
+ routePublicAccounts,
+ routeSolveSecondFactor,
+ onAuthorizationRequired,
+ onClose,
+ routeClose,
+ routeChargeWallet,
+ routeWireTransfer,
+ account,
+ limit,
+ balance: positiveBalance,
+ };
+}
diff --git a/packages/bank-ui/src/pages/AccountPage/stories.tsx b/packages/bank-ui/src/pages/AccountPage/stories.tsx
new file mode 100644
index 000000000..fe09a4f89
--- /dev/null
+++ b/packages/bank-ui/src/pages/AccountPage/stories.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "account page",
+};
+
+export const Ready = tests.createExample(ReadyView, {});
diff --git a/packages/bank-ui/src/pages/AccountPage/test.ts b/packages/bank-ui/src/pages/AccountPage/test.ts
new file mode 100644
index 000000000..14c8be948
--- /dev/null
+++ b/packages/bank-ui/src/pages/AccountPage/test.ts
@@ -0,0 +1,31 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+// import * as tests from "@gnu-taler/web-util/testing";
+// import { SwrMockEnvironment } from "@gnu-taler/web-util/testing";
+// import { expect } from "chai";
+// import { CASHOUT_API_EXAMPLE } from "../../endpoints.js";
+// import { Props } from "./index.js";
+// import { useComponentState } from "./state.js";
+
+describe("Account states", () => {
+ it("should do some tests", async () => {});
+});
diff --git a/packages/bank-ui/src/pages/AccountPage/views.tsx b/packages/bank-ui/src/pages/AccountPage/views.tsx
new file mode 100644
index 000000000..42892f536
--- /dev/null
+++ b/packages/bank-ui/src/pages/AccountPage/views.tsx
@@ -0,0 +1,156 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { Transactions } from "../../components/Transactions/index.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { usePreferences } from "../../hooks/preferences.js";
+import { PaymentOptions } from "../PaymentOptions.js";
+import { State } from "./index.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export function InvalidIbanView({ error }: State.InvalidIban) {
+ return (
+ <div>Payto from server is not valid &quot;{error.payto_uri}&quot;</div>
+ );
+}
+
+const IS_PUBLIC_ACCOUNT_ENABLED = false;
+
+function ShowDemoInfo({
+ routePublicAccounts,
+}: {
+ routePublicAccounts: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = usePreferences();
+ if (!settings.showDemoDescription) return <Fragment />;
+ return (
+ <Attention
+ title={i18n.str`This is a demo bank`}
+ onClose={() => {
+ updateSettings("showDemoDescription", false);
+ }}
+ >
+ {IS_PUBLIC_ACCOUNT_ENABLED ? (
+ <i18n.Translate>
+ This part of the demo shows how a bank that supports Taler directly
+ would work. In addition to using your own bank account, you can also
+ see the transaction history of some{" "}
+ <a name="public account" href={routePublicAccounts.url({})}>
+ Public Accounts
+ </a>
+ .
+ </i18n.Translate>
+ ) : (
+ <i18n.Translate>
+ This part of the demo shows how a bank that supports Taler directly
+ would work.
+ </i18n.Translate>
+ )}
+ </Attention>
+ );
+}
+
+function ShowPedingOperation({
+ routeSolveSecondFactor,
+}: {
+ routeSolveSecondFactor: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [bankState, updateBankState] = useBankState();
+ if (!bankState.currentChallenge) return <Fragment />;
+ const title = ((op): TranslatedString => {
+ switch (op) {
+ case "delete-account":
+ return i18n.str`Pending account delete operation`;
+ case "update-account":
+ return i18n.str`Pending account update operation`;
+ case "update-password":
+ return i18n.str`Pending password update operation`;
+ case "create-transaction":
+ return i18n.str`Pending transaction operation`;
+ case "confirm-withdrawal":
+ return i18n.str`Pending withdrawal operation`;
+ case "create-cashout":
+ return i18n.str`Pending cashout operation`;
+ }
+ })(bankState.currentChallenge.operation);
+ return (
+ <Attention
+ title={title}
+ type="warning"
+ onClose={() => {
+ updateBankState("currentChallenge", undefined);
+ }}
+ >
+ <i18n.Translate>
+ You can complete or cancel the operation in
+ </i18n.Translate>{" "}
+ <a
+ class="font-semibold text-yellow-700 hover:text-yellow-600"
+ name="complete operation"
+ href={routeSolveSecondFactor.url({})}
+ >
+ <i18n.Translate>this page</i18n.Translate>
+ </a>
+ </Attention>
+ );
+}
+
+export function ReadyView({
+ tab,
+ account,
+ routeChargeWallet,
+ routeWireTransfer,
+ limit,
+ balance,
+ routeCashout,
+ routeCreateWireTransfer,
+ routePublicAccounts,
+ routeOperationDetails,
+ routeSolveSecondFactor,
+ onClose,
+ routeClose,
+ onOperationCreated,
+ onAuthorizationRequired,
+}: State.Ready): VNode {
+ return (
+ <Fragment>
+ <ShowPedingOperation routeSolveSecondFactor={routeSolveSecondFactor} />
+ <ShowDemoInfo routePublicAccounts={routePublicAccounts} />
+ <PaymentOptions
+ tab={tab}
+ routeOperationDetails={routeOperationDetails}
+ routeCashout={routeCashout}
+ routeChargeWallet={routeChargeWallet}
+ routeWireTransfer={routeWireTransfer}
+ limit={limit}
+ balance={balance}
+ routeClose={routeClose}
+ onClose={onClose}
+ onOperationCreated={onOperationCreated}
+ onAuthorizationRequired={onAuthorizationRequired}
+ />
+ <Transactions
+ account={account}
+ routeCreateWireTransfer={routeCreateWireTransfer}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/BankFrame.stories.tsx b/packages/bank-ui/src/pages/BankFrame.stories.tsx
new file mode 100644
index 000000000..c874ac4ca
--- /dev/null
+++ b/packages/bank-ui/src/pages/BankFrame.stories.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { BankFrame } from "./BankFrame.js";
+
+export default {
+ title: "bank frame",
+};
+
+export const Ready = tests.createExample(BankFrame, {});
diff --git a/packages/bank-ui/src/pages/BankFrame.tsx b/packages/bank-ui/src/pages/BankFrame.tsx
new file mode 100644
index 000000000..db757ee07
--- /dev/null
+++ b/packages/bank-ui/src/pages/BankFrame.tsx
@@ -0,0 +1,368 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ ObservabilityEventType,
+ TalerError,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Footer,
+ Header,
+ Loading,
+ RouteDefinition,
+ ToastBanner,
+ notifyError,
+ notifyException,
+ useBankCoreApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { useEffect, useErrorBoundary, useState } from "preact/hooks";
+import { privatePages } from "../Routing.js";
+import { useSettingsContext } from "../context/settings.js";
+import { useAccountDetails } from "../hooks/account.js";
+import { useBankState } from "../hooks/bank-state.js";
+import {
+ getAllBooleanPreferences,
+ getLabelForPreferences,
+ usePreferences,
+} from "../hooks/preferences.js";
+import { useSessionState } from "../hooks/session.js";
+import { RenderAmount } from "./PaytoWireTransferForm.js";
+
+const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
+const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
+
+export function BankFrame({
+ children,
+ account,
+ routeAccountDetails,
+}: {
+ account?: string;
+ routeAccountDetails?: RouteDefinition;
+ children: ComponentChildren;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const session = useSessionState();
+ const settings = useSettingsContext();
+ const [preferences, updatePreferences] = usePreferences();
+ const [, , resetBankState] = useBankState();
+
+ const [error, resetError] = useErrorBoundary();
+
+ useEffect(() => {
+ if (error) {
+ if (error instanceof Error) {
+ console.log("Internal error, please report", error);
+ notifyException(i18n.str`Internal error, please report.`, error);
+ } else {
+ console.log("Internal error, please report", error);
+ notifyError(
+ i18n.str`Internal error, please report.`,
+ String(error) as TranslatedString,
+ );
+ }
+ resetError();
+ }
+ }, [error]);
+
+ return (
+ <div
+ class="min-h-full flex flex-col m-0 bg-slate-200"
+ style="min-height: 100vh;"
+ >
+ <div class="bg-indigo-600 pb-32">
+ <Header
+ title="Bank"
+ iconLinkURL={settings.iconLinkURL ?? "#"}
+ profileURL={routeAccountDetails?.url({})}
+ notificationURL={
+ preferences.showDebugInfo
+ ? privatePages.notifications.url({})
+ : undefined
+ }
+ onLogout={
+ session.state.status !== "loggedIn"
+ ? undefined
+ : () => {
+ session.logOut();
+ resetBankState();
+ }
+ }
+ sites={
+ !settings.topNavSites ? [] : Object.entries(settings.topNavSites)
+ }
+ supportedLangs={["en", "es", "de"]}
+ >
+ <li>
+ <div class="text-xs font-semibold leading-6 text-gray-400">
+ <i18n.Translate>Preferences</i18n.Translate>
+ </div>
+ <ul role="list" class="space-y-4">
+ {getAllBooleanPreferences().map((set) => {
+ const isOn: boolean = !!preferences[set];
+ return (
+ <li key={set} class="pl-2">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ {getLabelForPreferences(set, i18n)}
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`${set} switch`}
+ data-enabled={isOn}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ updatePreferences(set, !isOn);
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={isOn}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ </li>
+ </Header>
+ </div>
+
+ <div class="fixed z-20 top-14 w-full">
+ <div class="mx-auto w-4/5">
+ <ToastBanner />
+ {/* <Attention type="success" title={"hola" as TranslatedString} onClose={() => { }} /> */}
+ </div>
+ </div>
+
+ <main class="-mt-32 flex-1">
+ {account && routeAccountDetails && (
+ <header class="py-6 bg-indigo-600">
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
+ <h1 class=" flex flex-wrap items-center justify-between sm:flex-nowrap">
+ <span class="text-2xl font-bold tracking-tight text-white">
+ <WelcomeAccount
+ account={account}
+ routeAccountDetails={routeAccountDetails}
+ />
+ </span>
+ <span class="text-2xl font-bold tracking-tight text-white">
+ <AccountBalance account={account} />
+ </span>
+ </h1>
+ </div>
+ </header>
+ )}
+
+ <div class="mx-auto max-w-7xl px-4 pb-4 sm:px-6 lg:px-8">
+ <div class="rounded-lg bg-white px-5 py-6 shadow sm:px-6">
+ {children}
+ </div>
+ </div>
+ </main>
+
+ <AppActivity />
+
+ <Footer
+ testingUrlKey="corebank-api-base-url"
+ GIT_HASH={GIT_HASH}
+ VERSION={VERSION}
+ />
+ </div>
+ );
+}
+
+function Wait({ class: clazz }: { class?: string }): VNode {
+ return (
+ <Fragment>
+ <style>{`
+ .animated-loader {
+ display: inline-block;
+ --b: 5px;
+ border-radius: 50%;
+ aspect-ratio: 1;
+ padding: 1px;
+ background: conic-gradient(#0000 10%,#4f46e5) content-box;
+ -webkit-mask:
+ repeating-conic-gradient(#0000 0deg,#000 1deg 20deg,#0000 21deg 36deg),
+ radial-gradient(farthest-side,#0000 calc(100% - var(--b) - 1px),#000 calc(100% - var(--b)));
+ -webkit-mask-composite: destination-in;
+ mask-composite: intersect;
+ animation:spinning-loader 1s infinite steps(10);
+ }
+ @keyframes spinning-loader {to{transform: rotate(1turn)}}
+ `}</style>
+ <div class={`animated-loader ${clazz}`} />
+ </Fragment>
+ );
+}
+
+function AppActivity(): VNode {
+ const [lastEvent, setLastEvent] = useState<{
+ url: string;
+ id: string;
+ when: AbsoluteTime;
+ }>();
+ const [status, setStatus] = useState<"ok" | "fail">();
+ const d = useBankCoreApiContext();
+ const onBackendActivity = !d ? undefined : d.onActivity;
+ const cancelRequest = !d ? undefined : d.cancelRequest;
+ const [pref] = usePreferences();
+ useEffect(() => {
+ // console.log("ASDASDS", onBackendActivity)
+ if (!pref.showDebugInfo) return;
+ if (!onBackendActivity) return;
+ return onBackendActivity((ev) => {
+ switch (ev.type) {
+ case ObservabilityEventType.HttpFetchStart: {
+ setLastEvent(ev);
+ setStatus(undefined);
+ return;
+ }
+ case ObservabilityEventType.HttpFetchFinishError: {
+ setStatus("fail");
+ return;
+ }
+ case ObservabilityEventType.HttpFetchFinishSuccess: {
+ setStatus("ok");
+ return;
+ }
+ /**
+ * all of this are ignored
+ */
+ case ObservabilityEventType.DbQueryStart:
+ case ObservabilityEventType.DbQueryFinishSuccess:
+ case ObservabilityEventType.DbQueryFinishError:
+ case ObservabilityEventType.RequestStart:
+ case ObservabilityEventType.RequestFinishSuccess:
+ case ObservabilityEventType.RequestFinishError:
+ case ObservabilityEventType.TaskStart:
+ case ObservabilityEventType.TaskStop:
+ case ObservabilityEventType.TaskReset:
+ case ObservabilityEventType.ShepherdTaskResult:
+ case ObservabilityEventType.DeclareTaskDependency:
+ case ObservabilityEventType.CryptoStart:
+ case ObservabilityEventType.CryptoFinishSuccess:
+ case ObservabilityEventType.CryptoFinishError:
+ case ObservabilityEventType.Message:
+ return;
+ default: {
+ assertUnreachable(ev);
+ }
+ }
+ });
+ });
+ if (!pref.showDebugInfo || !lastEvent) return <Fragment />;
+ return (
+ <div
+ data-status={status}
+ class="fixed z-20 bottom-0 w-full ease-in-out delay-1000 transition-transform data-[status=ok]:scale-y-0"
+ >
+ <div
+ data-status={status}
+ class="mx-auto w-4/5 center flex p-1 bg-gray-300 m-1 data-[status=fail]:bg-red-200 data-[status=ok]:bg-green-200 "
+ >
+ {!status ? <Wait class="w-6 h-6" /> : <div class="w-6 h-6" />}
+
+ <p class="ml-2 my-auto text-sm text-gray-500">{lastEvent.url}</p>
+ {!status ? (
+ <button
+ onClick={() => {
+ if (cancelRequest) cancelRequest(lastEvent.id);
+ }}
+ >
+ cancel
+ </button>
+ ) : undefined}
+ </div>
+ </div>
+ );
+}
+
+function WelcomeAccount({
+ account,
+ routeAccountDetails,
+}: {
+ account: string;
+ routeAccountDetails: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <div />;
+ }
+ if (result.type === "fail") {
+ return (
+ <a
+ name="account details"
+ href={routeAccountDetails.url({})}
+ class="underline underline-offset-2"
+ >
+ <i18n.Translate>Welcome</i18n.Translate>
+ </a>
+ );
+ }
+ return (
+ <a
+ name="account details"
+ href={routeAccountDetails.url({})}
+ class="underline underline-offset-2"
+ >
+ <i18n.Translate>
+ Welcome, <span class="whitespace-nowrap">{result.body.name}</span>
+ </i18n.Translate>
+ </a>
+ );
+}
+
+function AccountBalance({ account }: { account: string }): VNode {
+ const result = useAccountDetails(account);
+ const { config } = useBankCoreApiContext();
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <div />;
+ }
+ if (result.type === "fail") return <div />;
+
+ return (
+ <RenderAmount
+ value={Amounts.parseOrThrow(result.body.balance.amount)}
+ negative={result.body.balance.credit_debit_indicator === "debit"}
+ spec={config.currency_specification}
+ />
+ );
+}
diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx
new file mode 100644
index 000000000..2f967895c
--- /dev/null
+++ b/packages/bank-ui/src/pages/LoginForm.tsx
@@ -0,0 +1,230 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import {
+ Button,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ useLocalNotificationHandler,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../utils.js";
+import { doAutoFocus } from "./PaytoWireTransferForm.js";
+import { USERNAME_REGEX } from "./RegistrationPage.js";
+
+/**
+ * Collect and submit login data.
+ */
+export function LoginForm({
+ currentUser,
+ fixedUser,
+ routeRegister,
+}: {
+ fixedUser?: boolean;
+ currentUser?: string;
+ routeRegister?: RouteDefinition;
+}): VNode {
+ const session = useSessionState();
+
+ const sessionUser =
+ session.state.status !== "loggedOut" ? session.state.username : undefined;
+ const [username, setUsername] = useState<string | undefined>(
+ currentUser ?? sessionUser,
+ );
+ const [password, setPassword] = useState<string | undefined>();
+ const { i18n } = useTranslationContext();
+ const {
+ lib: { auth: authenticator },
+ } = useBankCoreApiContext();
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
+ const { config } = useBankCoreApiContext();
+
+ const ref = useRef<HTMLInputElement>(null);
+ useEffect(function focusInput() {
+ ref.current?.focus();
+ }, []);
+
+ const errors = undefinedIfEmpty({
+ username: !username
+ ? i18n.str`Missing username`
+ : !USERNAME_REGEX.test(username)
+ ? i18n.str`Use letters, numbers or any of these characters: - . _ ~`
+ : undefined,
+ password: !password ? i18n.str`Missing password` : undefined,
+ });
+
+ async function doLogout() {
+ session.logOut();
+ }
+
+ const loginHandler =
+ !username || !password
+ ? undefined
+ : withErrorHandler(
+ async () =>
+ authenticator(username).createAccessTokenBasic(username, password, {
+ scope: "readwrite",
+ duration: { d_us: "forever" },
+ refreshable: true,
+ }),
+ (result) => {
+ session.logIn({ username, token: result.body.access_token });
+ },
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Wrong credentials for "${username}"`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Account not found`;
+ }
+ },
+ );
+
+ return (
+ <div class="flex min-h-full flex-col justify-center ">
+ <LocalNotificationBanner notification={notification} />
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
+ <form
+ class="space-y-6"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ autoCapitalize="none"
+ autoCorrect="off"
+ >
+ <div>
+ <label
+ for="username"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ <i18n.Translate>Username</i18n.Translate>
+ </label>
+ <div class="mt-2">
+ <input
+ ref={doAutoFocus}
+ type="text"
+ name="username"
+ id="username"
+ class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={username ?? ""}
+ disabled={fixedUser}
+ enterkeyhint="next"
+ placeholder="identification"
+ autocomplete="username"
+ title={i18n.str`Username of the account`}
+ required
+ onInput={(e): void => {
+ setUsername(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.username}
+ isDirty={username !== undefined}
+ />
+ </div>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between">
+ <label
+ for="password"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ <i18n.Translate>Password</i18n.Translate>
+ </label>
+ </div>
+ <div class="mt-2">
+ <input
+ type="password"
+ name="password"
+ id="password"
+ autocomplete="current-password"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ enterkeyhint="send"
+ value={password ?? ""}
+ placeholder="Password"
+ title={i18n.str`Password of the account`}
+ required
+ onInput={(e): void => {
+ setPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </div>
+ </div>
+
+ {session.state.status !== "loggedOut" ? (
+ <div class="flex justify-between">
+ <button
+ type="submit"
+ name="cancel"
+ class="rounded-md bg-white-600 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
+ onClick={(e) => {
+ e.preventDefault();
+ doLogout();
+ }}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+
+ <Button
+ type="submit"
+ name="check"
+ class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!!errors}
+ handler={loginHandler}
+ >
+ <i18n.Translate>Check</i18n.Translate>
+ </Button>
+ </div>
+ ) : (
+ <div>
+ <Button
+ type="submit"
+ name="login"
+ class="flex w-full justify-center rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!!errors}
+ handler={loginHandler}
+ >
+ <i18n.Translate>Log in</i18n.Translate>
+ </Button>
+ </div>
+ )}
+ </form>
+
+ {config.allow_registrations && routeRegister && (
+ <a
+ name="register"
+ href={routeRegister.url({})}
+ class="flex justify-center border-t mt-4 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
+ >
+ <i18n.Translate>Register</i18n.Translate>
+ </a>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/OperationState/index.ts b/packages/bank-ui/src/pages/OperationState/index.ts
new file mode 100644
index 000000000..38f698a04
--- /dev/null
+++ b/packages/bank-ui/src/pages/OperationState/index.ts
@@ -0,0 +1,157 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ TalerCoreBankErrorsByMethod,
+ TalerError,
+ WithdrawUriResult,
+} from "@gnu-taler/taler-util";
+import { Loading, utils } from "@gnu-taler/web-util/browser";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useComponentState } from "./state.js";
+import {
+ AbortedView,
+ ConfirmedView,
+ FailedView,
+ InvalidPaytoView,
+ InvalidReserveView,
+ InvalidWithdrawalView,
+ NeedConfirmationView,
+ ReadyView,
+} from "./views.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export interface Props {
+ currency: string;
+ onAuthorizationRequired: () => void;
+ routeClose: RouteDefinition;
+ onAbort: () => void;
+ routeHere: RouteDefinition<{ wopid: string }>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingError
+ | State.Ready
+ | State.Failed
+ | State.Aborted
+ | State.Confirmed
+ | State.InvalidPayto
+ | State.InvalidWithdrawal
+ | State.InvalidReserve
+ | State.NeedConfirmation;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface Failed {
+ status: "failed";
+ error: TalerCoreBankErrorsByMethod<"createWithdrawal">;
+ }
+
+ export interface LoadingError {
+ status: "loading-error";
+ error: TalerError;
+ }
+
+ /**
+ * Need to open the wallet
+ */
+ export interface Ready {
+ status: "ready";
+ error: undefined;
+ uri: WithdrawUriResult;
+ onAbort: () => Promise<
+ TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined
+ >;
+ routeClose: RouteDefinition;
+ }
+
+ export interface InvalidPayto {
+ status: "invalid-payto";
+ error: undefined;
+ payto: string | undefined;
+ }
+ export interface InvalidWithdrawal {
+ status: "invalid-withdrawal";
+ error: undefined;
+ uri: string;
+ }
+ export interface InvalidReserve {
+ status: "invalid-reserve";
+ error: undefined;
+ reserve: string | undefined;
+ }
+ export interface NeedConfirmation {
+ status: "need-confirmation";
+ onAuthorizationRequired: () => void;
+ account: string;
+ routeHere: RouteDefinition<{ wopid: string }>;
+ onAbort:
+ | undefined
+ | (() => Promise<
+ TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined
+ >);
+ onConfirm:
+ | undefined
+ | (() => Promise<
+ TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined
+ >);
+ error: undefined;
+ id: string;
+ }
+ export interface Aborted {
+ status: "aborted";
+ error: undefined;
+ routeClose: RouteDefinition;
+ }
+ export interface Confirmed {
+ status: "confirmed";
+ error: undefined;
+ routeClose: RouteDefinition;
+ }
+}
+
+export interface Transaction {
+ negative: boolean;
+ counterpart: string;
+ when: AbsoluteTime;
+ amount: AmountJson | undefined;
+ subject: string;
+}
+
+const viewMapping: utils.StateViewMap<State> = {
+ loading: Loading,
+ failed: FailedView,
+ "invalid-payto": InvalidPaytoView,
+ "invalid-withdrawal": InvalidWithdrawalView,
+ "invalid-reserve": InvalidReserveView,
+ "need-confirmation": NeedConfirmationView,
+ aborted: AbortedView,
+ confirmed: ConfirmedView,
+ "loading-error": ErrorLoadingWithDebug,
+ ready: ReadyView,
+};
+
+export const OperationState = utils.compose(
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts
new file mode 100644
index 000000000..19c097d18
--- /dev/null
+++ b/packages/bank-ui/src/pages/OperationState/state.ts
@@ -0,0 +1,234 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ HttpStatusCode,
+ TalerCoreBankErrorsByMethod,
+ TalerError,
+ assertUnreachable,
+ parsePaytoUri,
+ parseWithdrawUri,
+ stringifyWithdrawUri,
+} from "@gnu-taler/taler-util";
+import { utils } from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { mutate } from "swr";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useWithdrawalDetails } from "../../hooks/account.js";
+import { useSessionState } from "../../hooks/session.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { usePreferences } from "../../hooks/preferences.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ currency,
+ routeClose,
+ onAbort,
+ routeHere,
+ onAuthorizationRequired,
+}: Props): utils.RecursiveState<State> {
+ const [settings] = usePreferences();
+ const [bankState, updateBankState] = useBankState();
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+ const {
+ lib: { bank },
+ } = useBankCoreApiContext();
+
+ const [failure, setFailure] = useState<
+ TalerCoreBankErrorsByMethod<"createWithdrawal"> | undefined
+ >();
+ const amount = settings.maxWithdrawalAmount;
+
+ async function doSilentStart() {
+ // FIXME: if amount is not enough use balance
+ const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`);
+ if (!creds) return;
+ const resp = await bank.createWithdrawal(creds, {
+ amount: Amounts.stringify(parsedAmount),
+ });
+ if (resp.type === "fail") {
+ setFailure(resp);
+ return;
+ }
+ updateBankState("currentWithdrawalOperationId", resp.body.withdrawal_id);
+ }
+
+ const withdrawalOperationId = bankState.currentWithdrawalOperationId;
+ useEffect(() => {
+ if (withdrawalOperationId === undefined) {
+ doSilentStart();
+ }
+ }, [settings.fastWithdrawal, amount]);
+
+ if (failure) {
+ return {
+ status: "failed",
+ error: failure,
+ };
+ }
+
+ if (!withdrawalOperationId) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+
+ const wid = withdrawalOperationId;
+
+ async function doAbort() {
+ if (!creds) return;
+ const resp = await bank.abortWithdrawalById(creds, wid);
+ if (resp.type === "ok") {
+ // updateBankState("currentWithdrawalOperationId", undefined)
+ onAbort();
+ } else {
+ return resp;
+ }
+ }
+
+ async function doConfirm(): Promise<
+ TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined
+ > {
+ if (!creds) return;
+ const resp = await bank.confirmWithdrawalById(creds, wid);
+ if (resp.type === "ok") {
+ mutate(() => true); //clean withdrawal state
+ } else {
+ return resp;
+ }
+ }
+
+ const uri = stringifyWithdrawUri({
+ bankIntegrationApiBaseUrl: bank.getIntegrationAPI().href,
+ withdrawalOperationId,
+ });
+ const parsedUri = parseWithdrawUri(uri);
+ if (!parsedUri) {
+ return {
+ status: "invalid-withdrawal",
+ error: undefined,
+ uri,
+ };
+ }
+
+ return (): utils.RecursiveState<State> => {
+ const result = useWithdrawalDetails(withdrawalOperationId);
+ const shouldCreateNewOperation = result && !(result instanceof TalerError);
+
+ useEffect(() => {
+ if (shouldCreateNewOperation) {
+ doSilentStart();
+ }
+ }, []);
+ if (!result) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (result instanceof TalerError) {
+ return {
+ status: "loading-error",
+ error: result,
+ };
+ }
+
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.NotFound: {
+ return {
+ status: "aborted",
+ error: undefined,
+ routeClose,
+ };
+ }
+ default:
+ assertUnreachable(result);
+ }
+ }
+
+ const { body: data } = result;
+ if (data.status === "aborted") {
+ return {
+ status: "aborted",
+ error: undefined,
+ routeClose,
+ };
+ }
+
+ if (data.status === "confirmed") {
+ if (!settings.showWithdrawalSuccess) {
+ updateBankState("currentWithdrawalOperationId", undefined);
+ // onClose()
+ }
+ return {
+ status: "confirmed",
+ error: undefined,
+ routeClose,
+ };
+ }
+
+ if (data.status === "pending") {
+ return {
+ status: "ready",
+ error: undefined,
+ uri: parsedUri,
+ routeClose,
+ onAbort: !creds
+ ? async () => {
+ onAbort();
+ return undefined;
+ }
+ : doAbort,
+ };
+ }
+
+ if (!data.selected_reserve_pub) {
+ return {
+ status: "invalid-reserve",
+ error: undefined,
+ reserve: data.selected_reserve_pub,
+ };
+ }
+
+ const account = !data.selected_exchange_account
+ ? undefined
+ : parsePaytoUri(data.selected_exchange_account);
+
+ if (!account) {
+ return {
+ status: "invalid-payto",
+ error: undefined,
+ payto: data.selected_exchange_account,
+ };
+ }
+
+ return {
+ status: "need-confirmation",
+ error: undefined,
+ routeHere,
+ onAuthorizationRequired,
+ account: data.username,
+ id: withdrawalOperationId,
+ onAbort: !creds ? undefined : doAbort,
+ onConfirm: !creds ? undefined : doConfirm,
+ };
+ };
+}
diff --git a/packages/bank-ui/src/pages/OperationState/stories.tsx b/packages/bank-ui/src/pages/OperationState/stories.tsx
new file mode 100644
index 000000000..82253b82c
--- /dev/null
+++ b/packages/bank-ui/src/pages/OperationState/stories.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "operation status page",
+};
+
+export const Ready = tests.createExample(ReadyView, {});
diff --git a/packages/bank-ui/src/pages/OperationState/test.ts b/packages/bank-ui/src/pages/OperationState/test.ts
new file mode 100644
index 000000000..d47cb64a2
--- /dev/null
+++ b/packages/bank-ui/src/pages/OperationState/test.ts
@@ -0,0 +1,31 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+// import * as tests from "@gnu-taler/web-util/testing";
+// import { SwrMockEnvironment } from "@gnu-taler/web-util/testing";
+// import { expect } from "chai";
+// import { CASHOUT_API_EXAMPLE } from "../../endpoints.js";
+// import { Props } from "./index.js";
+// import { useComponentState } from "./state.js";
+
+describe("Withdrawal operation states", () => {
+ it("should do some tests", async () => {});
+});
diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx
new file mode 100644
index 000000000..62308eca6
--- /dev/null
+++ b/packages/bank-ui/src/pages/OperationState/views.tsx
@@ -0,0 +1,447 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ stringifyWithdrawUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ LocalNotificationBanner,
+ notifyInfo,
+ useLocalNotification,
+ useTalerWalletIntegrationAPI,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect } from "preact/hooks";
+import { QR } from "../../components/QR.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { usePreferences } from "../../hooks/preferences.js";
+import { ShouldBeSameUser } from "../WithdrawalConfirmationQuestion.js";
+import { State } from "./index.js";
+
+export function InvalidPaytoView({ payto }: State.InvalidPayto) {
+ return <div>Payto from server is not valid &quot;{payto}&quot;</div>;
+}
+export function InvalidWithdrawalView({ uri }: State.InvalidWithdrawal) {
+ return <div>Withdrawal uri from server is not valid &quot;{uri}&quot;</div>;
+}
+export function InvalidReserveView({ reserve }: State.InvalidReserve) {
+ return <div>Reserve from server is not valid &quot;{reserve}&quot;</div>;
+}
+
+export function NeedConfirmationView({
+ onAbort: doAbort,
+ onConfirm: doConfirm,
+ routeHere,
+ account,
+ id,
+ onAuthorizationRequired,
+}: State.NeedConfirmation) {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreferences();
+ const [notification, notify, errorHandler] = useLocalNotification();
+ const [, updateBankState] = useBankState();
+
+ async function onCancel() {
+ errorHandler(async () => {
+ if (!doAbort) return;
+ const resp = await doAbort();
+ if (!resp) return;
+ switch (resp.case) {
+ case HttpStatusCode.Conflict:
+ return notify({
+ type: "error",
+ title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation id is invalid.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ default:
+ assertUnreachable(resp);
+ }
+ });
+ }
+
+ async function onConfirm() {
+ errorHandler(async () => {
+ if (!doConfirm) return;
+ const resp = await doConfirm();
+ if (!resp) {
+ if (!settings.showWithdrawalSuccess) {
+ notifyInfo(i18n.str`Wire transfer completed!`);
+ }
+ return;
+ }
+ switch (resp.case) {
+ case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT:
+ return notify({
+ type: "error",
+ title: i18n.str`The withdrawal has been aborted previously and can't be confirmed`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return notify({
+ type: "error",
+ title: i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation id is invalid.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Your balance is not enough.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "confirm-withdrawal",
+ id: String(resp.body.challenge_id),
+ sent: AbsoluteTime.never(),
+ location: routeHere.url({ wopid: id }),
+ request: id,
+ });
+ return onAuthorizationRequired();
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ });
+ }
+
+ return (
+ <div class="bg-white shadow sm:rounded-lg">
+ <LocalNotificationBanner notification={notification} />
+ <div class="px-4 py-5 sm:p-6">
+ <h3 class="text-base font-semibold text-gray-900">
+ <i18n.Translate>Confirm the withdrawal operation</i18n.Translate>
+ </h3>
+ <div class="mt-3 text-sm leading-6">
+ <ShouldBeSameUser username={account}>
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <button
+ type="button"
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={(e) => {
+ e.preventDefault();
+ onCancel();
+ }}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ type="submit"
+ name="transfer"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ onClick={(e) => {
+ e.preventDefault();
+ onConfirm();
+ }}
+ >
+ <i18n.Translate>Transfer</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </ShouldBeSameUser>
+ </div>
+ </div>
+ </div>
+ );
+}
+export function FailedView({ error }: State.Failed) {
+ const { i18n } = useTranslationContext();
+ switch (error.case) {
+ case HttpStatusCode.Unauthorized:
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`Unauthorized to make the operation, maybe the session has expired or the password changed.`}
+ >
+ <div class="mt-2 text-sm text-red-700">{error.detail.hint}</div>
+ </Attention>
+ );
+ case HttpStatusCode.Conflict:
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The operation was rejected due to insufficient funds.`}
+ >
+ <div class="mt-2 text-sm text-red-700">{error.detail.hint}</div>
+ </Attention>
+ );
+ case HttpStatusCode.NotFound:
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The operation was rejected due to insufficient funds.`}
+ >
+ <div class="mt-2 text-sm text-red-700">{error.detail.hint}</div>
+ </Attention>
+ );
+ default:
+ assertUnreachable(error);
+ }
+}
+
+export function AbortedView() {
+ return <div>aborted</div>;
+}
+
+export function ConfirmedView({ routeClose }: State.Confirmed) {
+ const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = usePreferences();
+ return (
+ <Fragment>
+ <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white p-4 text-left shadow-xl transition-all ">
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
+ <svg
+ class="h-6 w-6 text-green-600"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M4.5 12.75l6 6 9-13.5"
+ />
+ </svg>
+ </div>
+ <div class="mt-3 text-center sm:mt-5">
+ <h3
+ class="text-base font-semibold leading-6 text-gray-900"
+ id="modal-title"
+ >
+ <i18n.Translate>Withdrawal confirmed</i18n.Translate>
+ </h3>
+ <div class="mt-2">
+ <p class="text-sm text-gray-500">
+ <i18n.Translate>
+ The wire transfer to the Taler operator has been initiated. You
+ will soon receive the requested amount in your Taler wallet.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="mt-4">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Do not show this again</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name="toggle withdrawal"
+ data-enabled={!settings.showWithdrawalSuccess}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ updateSettings(
+ "showWithdrawalSuccess",
+ !settings.showWithdrawalSuccess,
+ );
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={!settings.showWithdrawalSuccess}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeClose.url({})}
+ type="button"
+ name="close"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </Fragment>
+ );
+}
+
+export function ReadyView({ uri, onAbort: doAbort }: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+ const walletInegrationApi = useTalerWalletIntegrationAPI();
+ const [notification, notify, errorHandler] = useLocalNotification();
+
+ const talerWithdrawUri = stringifyWithdrawUri(uri);
+ useEffect(() => {
+ walletInegrationApi.publishTalerAction(uri);
+ }, []);
+
+ async function onAbort() {
+ errorHandler(async () => {
+ const hasError = await doAbort();
+ if (!hasError) return;
+ switch (hasError.case) {
+ case HttpStatusCode.Conflict:
+ return notify({
+ type: "error",
+ title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`,
+ description: hasError.detail.hint as TranslatedString,
+ debug: hasError.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation id is invalid.`,
+ description: hasError.detail.hint as TranslatedString,
+ debug: hasError.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation was not found.`,
+ description: hasError.detail.hint as TranslatedString,
+ debug: hasError.detail,
+ when: AbsoluteTime.now(),
+ });
+ default:
+ assertUnreachable(hasError);
+ }
+ });
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="flex justify-end mt-4">
+ <button
+ type="button"
+ name="cancel"
+ class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
+ onClick={onAbort}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ </div>
+
+ <div class="bg-white shadow sm:rounded-lg mt-4">
+ <div class="p-4">
+ <h3 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>On this device</i18n.Translate>
+ </h3>
+ <div class="mt-2 sm:flex sm:items-start sm:justify-between">
+ <div class="max-w-xl text-sm text-gray-500">
+ <p>
+ <i18n.Translate>
+ If you are using a web browser on desktop you can also
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="mt-5 sm:ml-6 sm:mt-0 sm:flex sm:flex-shrink-0 sm:items-center">
+ <a
+ href={talerWithdrawUri}
+ name="start"
+ class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Start</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="bg-white shadow sm:rounded-lg mt-2">
+ <div class="p-4">
+ <h3 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>On a mobile phone</i18n.Translate>
+ </h3>
+ <div class="mt-2 sm:flex sm:items-start sm:justify-between">
+ <div class="max-w-xl text-sm text-gray-500">
+ <p>
+ <i18n.Translate>
+ Scan the QR code with your mobile device.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ <div class="mt-2 max-w-md ml-auto mr-auto">
+ <QR text={talerWithdrawUri} />
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/PaymentOptions.stories.tsx b/packages/bank-ui/src/pages/PaymentOptions.stories.tsx
new file mode 100644
index 000000000..78af886a8
--- /dev/null
+++ b/packages/bank-ui/src/pages/PaymentOptions.stories.tsx
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { PaymentOptions } from "./PaymentOptions.js";
+
+export default {
+ title: "PaymentOptions",
+};
+
+export const USD = tests.createExample(PaymentOptions, {
+ limit: {
+ currency: "USD",
+ fraction: 0,
+ value: 1,
+ },
+});
diff --git a/packages/bank-ui/src/pages/PaymentOptions.tsx b/packages/bank-ui/src/pages/PaymentOptions.tsx
new file mode 100644
index 000000000..386fe31bc
--- /dev/null
+++ b/packages/bank-ui/src/pages/PaymentOptions.tsx
@@ -0,0 +1,239 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountJson, TalerError } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect } from "preact/hooks";
+import { useWithdrawalDetails } from "../hooks/account.js";
+import { useBankState } from "../hooks/bank-state.js";
+import { useSessionState } from "../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
+import { WalletWithdrawForm } from "./WalletWithdrawForm.js";
+
+function ShowOperationPendingTag({
+ woid,
+ onOperationAlreadyCompleted,
+}: {
+ woid: string;
+ onOperationAlreadyCompleted?: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { state: credentials } = useSessionState();
+ const result = useWithdrawalDetails(woid);
+ const loading = !result;
+ const error =
+ !loading && (result instanceof TalerError || result.type === "fail");
+ const pending =
+ !loading &&
+ !error &&
+ (result.body.status === "pending" || result.body.status === "selected") &&
+ credentials.status === "loggedIn" &&
+ credentials.username === result.body.username;
+ useEffect(() => {
+ if (!loading && !pending && onOperationAlreadyCompleted) {
+ onOperationAlreadyCompleted();
+ }
+ }, [pending]);
+
+ if (error || !pending) {
+ return <Fragment />;
+ }
+
+ return (
+ <span class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 whitespace-pre">
+ <svg
+ class="h-1.5 w-1.5 fill-green-500"
+ viewBox="0 0 6 6"
+ aria-hidden="true"
+ >
+ <circle cx="3" cy="3" r="3" />
+ </svg>
+ <i18n.Translate>Operation ready</i18n.Translate>
+ </span>
+ );
+}
+
+/**
+ * Let the user choose a payment option,
+ * then specify the details trigger the action.
+ */
+export function PaymentOptions({
+ routeClose,
+ routeCashout,
+ routeChargeWallet,
+ routeWireTransfer,
+ tab,
+ limit,
+ balance,
+ onOperationCreated,
+ onClose,
+ routeOperationDetails,
+ onAuthorizationRequired,
+}: {
+ limit: AmountJson;
+ balance: AmountJson;
+ tab: "charge-wallet" | "wire-transfer" | undefined;
+ onAuthorizationRequired: () => void;
+ onOperationCreated: (wopid: string) => void;
+ onClose: () => void;
+
+ routeOperationDetails: RouteDefinition<{ wopid: string }>;
+ routeClose: RouteDefinition;
+ routeCashout: RouteDefinition;
+ routeChargeWallet: RouteDefinition;
+ routeWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [bankState, updateBankState] = useBankState();
+
+ return (
+ <div class="mt-4">
+ <fieldset>
+ <legend class="px-4 text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Send money</i18n.Translate>
+ </legend>
+
+ <div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
+ {/* <!-- Active: "border-indigo-600 ring-2 ring-indigo-600", Not Active: "border-gray-300" --> */}
+ <a name="charge wallet" href={routeChargeWallet.url({})}>
+ <label
+ class={
+ "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" +
+ (tab === "charge-wallet"
+ ? "border-indigo-600 ring-2 ring-indigo-600"
+ : "border-gray-300")
+ }
+ >
+ <div class="flex flex-col">
+ <span class="flex">
+ <div class="text-4xl mr-4 my-auto">&#x1F4B5;</div>
+ <span class="grow self-center text-lg text-gray-900 align-middle text-center">
+ <i18n.Translate>to a Taler wallet</i18n.Translate>
+ </span>
+ <svg
+ class="self-center flex-none h-5 w-5 text-indigo-600"
+ style={{
+ visibility:
+ tab === "charge-wallet" ? "visible" : "hidden",
+ }}
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </span>
+ <div class="mt-1 flex items-center text-sm text-gray-500">
+ <i18n.Translate>
+ Withdraw digital money into your mobile wallet or browser
+ extension
+ </i18n.Translate>
+ </div>
+ {!!bankState.currentWithdrawalOperationId && (
+ <ShowOperationPendingTag
+ woid={bankState.currentWithdrawalOperationId}
+ onOperationAlreadyCompleted={() => {
+ updateBankState(
+ "currentWithdrawalOperationId",
+ undefined,
+ );
+ }}
+ />
+ )}
+ </div>
+ </label>
+ </a>
+
+ <a name="wire transfer" href={routeWireTransfer.url({})}>
+ <label
+ class={
+ "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" +
+ (tab === "wire-transfer"
+ ? "border-indigo-600 ring-2 ring-indigo-600"
+ : "border-gray-300")
+ }
+ >
+ <div class="flex flex-col">
+ <span class="flex">
+ <div class="text-4xl mr-4 my-auto">&#x2194;</div>
+ <span class="grow self-center text-lg font-medium text-gray-900 align-middle text-center">
+ <i18n.Translate>to another bank account</i18n.Translate>
+ </span>
+ <svg
+ class="self-center flex-none h-5 w-5 text-indigo-600"
+ style={{
+ visibility:
+ tab === "wire-transfer" ? "visible" : "hidden",
+ }}
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </span>
+ <div class="mt-1 flex items-center text-sm text-gray-500">
+ <i18n.Translate>
+ Make a wire transfer to an account with known bank account
+ number.
+ </i18n.Translate>
+ </div>
+ </div>
+ </label>
+ </a>
+ </div>
+ {tab === "charge-wallet" && (
+ <WalletWithdrawForm
+ routeOperationDetails={routeOperationDetails}
+ focus
+ limit={limit}
+ balance={balance}
+ onAuthorizationRequired={onAuthorizationRequired}
+ onOperationCreated={onOperationCreated}
+ onOperationAborted={onClose}
+ routeCancel={routeClose}
+ />
+ )}
+ {tab === "wire-transfer" && (
+ <PaytoWireTransferForm
+ focus
+ routeHere={routeWireTransfer}
+ limit={limit}
+ balance={balance}
+ onAuthorizationRequired={onAuthorizationRequired}
+ onSuccess={onClose}
+ routeCashout={routeCashout}
+ routeCancel={routeClose}
+ />
+ )}
+ </fieldset>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx
new file mode 100644
index 000000000..61cfb5629
--- /dev/null
+++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
+
+export default {
+ title: "PaytoWireTransferForm",
+};
+
+export const USD = tests.createExample(PaytoWireTransferForm, {
+ limit: {
+ currency: "USD",
+ fraction: 0,
+ value: 1,
+ },
+});
diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
new file mode 100644
index 000000000..a3bb091c1
--- /dev/null
+++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
@@ -0,0 +1,1000 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ AmountString,
+ Amounts,
+ CurrencySpecification,
+ FRAC_SEPARATOR,
+ HttpStatusCode,
+ PaytoString,
+ PaytoUri,
+ TalerCorebankApi,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ buildPayto,
+ parsePaytoUri,
+ stringifyPaytoUri
+} from "@gnu-taler/taler-util";
+import {
+ InternationalizationAPI,
+ LocalNotificationBanner,
+ RouteDefinition,
+ ShowInputErrorLabel,
+ notifyInfo,
+ useBankCoreApiContext,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, Ref, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { mutate } from "swr";
+import { IdempotencyRetry } from "../../../taler-util/lib/http-client/utils.js";
+import { useBankState } from "../hooks/bank-state.js";
+import { useSessionState } from "../hooks/session.js";
+import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js";
+
+interface Props {
+ focus?: boolean;
+ withAccount?: string;
+ withSubject?: string;
+ withAmount?: string;
+ onSuccess: () => void;
+ onAuthorizationRequired: () => void;
+ routeCancel?: RouteDefinition;
+ routeCashout?: RouteDefinition;
+ routeHere: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ limit: AmountJson;
+ balance: AmountJson;
+}
+
+export function PaytoWireTransferForm({
+ focus,
+ withAccount,
+ withSubject,
+ withAmount,
+ onSuccess,
+ routeCancel,
+ routeCashout,
+ routeHere,
+ onAuthorizationRequired,
+ limit,
+}: Props): VNode {
+ const [inputType, setInputType] = useState<"form" | "payto" | "qr">("form");
+ const isRawPayto = inputType !== "form";
+
+ const { state: credentials } = useSessionState();
+ const {
+ lib: { bank: api },
+ config,
+ url,
+ } = useBankCoreApiContext();
+
+ const sendingToFixedAccount = withAccount !== undefined;
+
+ const [account, setAccount] = useState<string | undefined>(withAccount);
+ const [subject, setSubject] = useState<string | undefined>(withSubject);
+ const [amount, setAmount] = useState<string | undefined>(withAmount);
+ const [, updateBankState] = useBankState();
+
+ const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
+ undefined,
+ );
+ const { i18n } = useTranslationContext();
+
+ const trimmedAmountStr = amount?.trim();
+ const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`);
+ const [notification, notify, handleError] = useLocalNotification();
+
+ const paytoType =
+ config.wire_type === "X_TALER_BANK"
+ ? ("x-taler-bank" as const)
+ : ("iban" as const);
+
+ const errorsWire = undefinedIfEmpty({
+ account: !account
+ ? i18n.str`Required`
+ : paytoType === "iban"
+ ? validateIBAN(account, i18n)
+ : paytoType === "x-taler-bank"
+ ? validateTalerBank(account, i18n)
+ : undefined,
+ subject: !subject ? i18n.str`Required` : validateSubject(subject, i18n),
+ amount: !trimmedAmountStr
+ ? i18n.str`Required`
+ : !parsedAmount
+ ? i18n.str`Not valid`
+ : validateAmount(parsedAmount, limit, i18n),
+ });
+
+ const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
+
+ const errorsPayto = undefinedIfEmpty({
+ rawPaytoInput: !rawPaytoInput
+ ? i18n.str`Required`
+ : !parsed
+ ? i18n.str`Does not follow the pattern`
+ : validateRawPayto(parsed, limit, url.host, i18n, paytoType),
+ });
+
+ async function doSend() {
+ let payto_uri: PaytoString | undefined;
+ let sendingAmount: AmountString | undefined;
+
+ if (credentials.status !== "loggedIn") return;
+ let acName: string | undefined;
+ if (isRawPayto) {
+ const p = parsePaytoUri(rawPaytoInput!);
+ if (!p) return;
+ sendingAmount = p.params.amount as AmountString;
+ delete p.params.amount;
+ // if this payto is valid then it already have message
+ payto_uri = stringifyPaytoUri(p);
+ acName = !p.isKnown
+ ? undefined
+ : p.targetType === "iban"
+ ? p.iban
+ : p.targetType === "bitcoin"
+ ? p.address
+ : p.targetType === "x-taler-bank"
+ ? p.account
+ : assertUnreachable(p);
+ } else {
+ if (!account || !subject) return;
+ let payto;
+ acName = account;
+ switch (paytoType) {
+ case "x-taler-bank": {
+ payto = buildPayto("x-taler-bank", url.host, account);
+
+ break;
+ }
+ case "iban": {
+ payto = buildPayto("iban", account, undefined);
+ break;
+ }
+ default:
+ assertUnreachable(paytoType);
+ }
+
+ payto.params.message = encodeURIComponent(subject);
+ payto_uri = stringifyPaytoUri(payto);
+ sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString;
+ }
+ const puri = payto_uri;
+ const sAmount = sendingAmount;
+
+ await handleError(async function createTransactionHandleError() {
+ const request: TalerCorebankApi.CreateTransactionRequest = {
+ payto_uri: puri,
+ amount: sAmount,
+ };
+ const check = IdempotencyRetry.tryFiveTimes();
+ const resp = await api.createTransaction(
+ credentials,
+ request,
+ check,
+ );
+ mutate(() => true);
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The request was invalid or the payto://-URI used unacceptable features.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`Not enough permission to complete the operation.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_ADMIN_CREDITOR:
+ return notify({
+ type: "error",
+ title: i18n.str`Bank administrator can't be the transfer creditor.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNKNOWN_CREDITOR:
+ return notify({
+ type: "error",
+ title: i18n.str`The destination account "${
+ acName ?? puri
+ }" was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_SAME_ACCOUNT:
+ return notify({
+ type: "error",
+ title: i18n.str`The origin and the destination of the transfer can't be the same.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Your balance is not enough.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The origin account "${puri}" was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: {
+ return notify({
+ type: "error",
+ title: i18n.str`Tried to create the transaction ${check.maxTries} times with different UID but failed.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "create-transaction",
+ id: String(resp.body.challenge_id),
+ location: routeHere.url({
+ account: account ?? "",
+ amount,
+ subject,
+ }),
+ sent: AbsoluteTime.never(),
+ request,
+ });
+ return onAuthorizationRequired();
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ notifyInfo(i18n.str`Wire transfer created!`);
+ onSuccess();
+ setAmount(undefined);
+ setAccount(undefined);
+ setSubject(undefined);
+ rawPaytoInputSetter(undefined);
+ });
+ }
+
+ return (
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 my-4 md:grid-cols-3 bg-gray-100 px-4 pb-4 rounded-lg">
+ {/* <div class="">
+ <div class="px-2 grid grid-cols-1 gap-y-4 sm:gap-x-4">
+ <label
+ class={
+ "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" +
+ (!isRawPayto
+ ? "border-indigo-600 ring-2 ring-indigo-600"
+ : "border-gray-300")
+ }
+ >
+ <input
+ type="radio"
+ name="project-type"
+ value="Newsletter"
+ class="sr-only"
+ aria-labelledby="project-type-0-label"
+ aria-describedby="project-type-0-description-0 project-type-0-description-1"
+ onChange={() => {
+ setIsRawPayto(false);
+ }}
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Using a form</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
+
+ {sendingToFixedAccount ? undefined : (
+ <label
+ class={
+ "relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" +
+ (isRawPayto
+ ? "border-indigo-600 ring-2 ring-indigo-600"
+ : "border-gray-300")
+ }
+ >
+ <input
+ type="radio"
+ name="project-type"
+ value="Existing Customers"
+ class="sr-only"
+ aria-labelledby="project-type-1-label"
+ aria-describedby="project-type-1-description-0 project-type-1-description-1"
+ onChange={() => {
+
+ setIsRawPayto(true);
+ }}
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Import payto:// URI</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
+ )}
+ {routeCashout ? (
+ <a
+ name="do cashout"
+ href={routeCashout.url({})}
+ class="bg-white p-4 rounded-lg text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cashout</i18n.Translate>
+ </a>
+ ) : undefined}
+ </div>
+ </div> */}
+
+ <div>
+ <fieldset class="px-2 grid grid-cols-1 gap-y-4 sm:gap-x-4">
+ <legend class="sr-only">
+ <i18n.Translate>Input wire transfer detail</i18n.Translate>
+ </legend>
+ <div class="-space-y-px rounded-md ">
+ <label
+ data-checked={inputType === "form"}
+ class="group rounded-tl-md rounded-tr-md relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50"
+ >
+ <input
+ type="radio"
+ name="input-type"
+ onChange={() => {
+ if (parsed && parsed.isKnown) {
+ switch (parsed.targetType) {
+ case "iban": {
+ setAccount(parsed.iban);
+ break;
+ }
+ case "x-taler-bank": {
+ setAccount(parsed.account);
+ break;
+ }
+ case "bitcoin": {
+ break;
+ }
+ default: {
+ assertUnreachable(parsed);
+ }
+ }
+ const amountStr = !parsed.params
+ ? undefined
+ : parsed.params["amount"];
+ if (amountStr) {
+ const amount = Amounts.parse(amountStr);
+ if (amount) {
+ setAmount(Amounts.stringifyValue(amount));
+ }
+ }
+ const subject = parsed.params["message"];
+ if (subject) {
+ setSubject(subject);
+ }
+ }
+ setInputType("form");
+ }}
+ checked={inputType === "form"}
+ value="form"
+ class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600"
+ />
+ <span class="ml-3 flex flex-col">
+ {/* <!-- Checked: "text-indigo-900", Not Checked: "text-gray-900" --> */}
+ <span
+ data-checked={inputType === "form"}
+ class="block text-sm font-medium data-[checked=true]:text-indigo-900"
+ >
+ <i18n.Translate>Using a form</i18n.Translate>
+ </span>
+ </span>
+ </label>
+ {sendingToFixedAccount ? undefined : (
+ <Fragment>
+ <label
+ data-checked={inputType === "payto"}
+ class="relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50"
+ >
+ <input
+ type="radio"
+ name="input-type"
+ onChange={() => {
+ if (account) {
+ let payto;
+ switch (paytoType) {
+ case "x-taler-bank": {
+ payto = buildPayto(
+ "x-taler-bank",
+ url.host,
+ account,
+ );
+ if (parsedAmount) {
+ payto.params["amount"] =
+ Amounts.stringify(parsedAmount);
+ }
+ if (subject) {
+ payto.params["message"] = subject;
+ }
+ break;
+ }
+ case "iban": {
+ payto = buildPayto("iban", account, undefined);
+ if (parsedAmount) {
+ payto.params["amount"] =
+ Amounts.stringify(parsedAmount);
+ }
+ if (subject) {
+ payto.params["message"] = subject;
+ }
+ break;
+ }
+ default:
+ assertUnreachable(paytoType);
+ }
+ rawPaytoInputSetter(stringifyPaytoUri(payto));
+ }
+ setInputType("payto");
+ }}
+ checked={inputType === "payto"}
+ value="payto"
+ class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600"
+ />
+ <span class="ml-3 flex flex-col">
+ <span
+ data-checked={inputType === "payto"}
+ class="block font-medium data-[checked=true]:text-indigo-900"
+ >
+ payto:// URI
+ </span>
+ <span
+ data-checked={inputType === "payto"}
+ class="block text-sm text-gray-500 data-[checked=true]:text-indigo-600"
+ >
+ <i18n.Translate>
+ A special URI that indicate the transfer amount and
+ account target.
+ </i18n.Translate>
+ </span>
+ </span>
+ </label>
+ {
+ //FIXME: add QR support
+ false && (
+ <label
+ data-checked={inputType === "qr"}
+ class="rounded-bl-md rounded-br-md relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50"
+ >
+ <input
+ type="radio"
+ name="input-type"
+ onChange={() => {
+ setInputType("qr");
+ }}
+ checked={inputType === "qr"}
+ value="qr"
+ class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600"
+ />
+ <span class="ml-3 flex flex-col">
+ <span
+ data-checked={inputType === "qr"}
+ class="block font-medium data-[checked=true]:text-indigo-900"
+ >
+ <i18n.Translate>QR code</i18n.Translate>
+ </span>
+ <span
+ data-checked={inputType === "qr"}
+ class="block text-sm text-gray-500 data-[checked=true]:text-indigo-600"
+ >
+ <i18n.Translate>
+ If you have a camera in this device you can import a
+ payto:// URI from a QR code.
+ </i18n.Translate>
+ </span>
+ </span>
+ </label>
+ )
+ }
+ </Fragment>
+ )}
+ </div>
+ {routeCashout ? (
+ <a
+ name="do cashout"
+ href={routeCashout.url({})}
+ class="bg-white p-4 rounded-lg text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cashout</i18n.Translate>
+ </a>
+ ) : undefined}
+ </fieldset>
+ </div>
+
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md sm:rounded-xl md:col-span-2 w-fit mx-auto"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="p-4 sm:p-8">
+ {!isRawPayto ? (
+ <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ {(() => {
+ switch (paytoType) {
+ case "x-taler-bank": {
+ return (
+ <TextField
+ id="x-taler-bank"
+ required
+ label={i18n.str`Recipient`}
+ help={i18n.str`Id of the recipient's account`}
+ error={errorsWire?.account}
+ onChange={setAccount}
+ value={account}
+ placeholder={i18n.str`username`}
+ focus={focus}
+ disabled={sendingToFixedAccount}
+ />
+ );
+ }
+ case "iban": {
+ return (
+ <TextField
+ id="iban"
+ required
+ label={i18n.str`Recipient`}
+ help={i18n.str`IBAN of the recipient's account`}
+ placeholder={"CC0123456789" as TranslatedString}
+ error={errorsWire?.account}
+ onChange={(v) => setAccount(v.toUpperCase())}
+ value={account}
+ focus={focus}
+ disabled={sendingToFixedAccount}
+ />
+ );
+ }
+ default:
+ assertUnreachable(paytoType);
+ }
+ })()}
+
+ <div class="sm:col-span-5">
+ <label
+ for="subject"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ {i18n.str`Transfer subject`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="subject"
+ id="subject"
+ autocomplete="off"
+ placeholder={i18n.str`Subject`}
+ value={subject ?? ""}
+ required
+ onInput={(e): void => {
+ setSubject(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsWire?.subject}
+ isDirty={subject !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Some text to identify the transfer
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ for="amount"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ {i18n.str`Amount`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <InputAmount
+ name="amount"
+ left
+ currency={limit.currency}
+ value={trimmedAmountStr}
+ onChange={(d) => {
+ setAmount(d);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsWire?.amount}
+ isDirty={trimmedAmountStr !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Amount to transfer</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ ) : (
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6 w-full">
+ <div class="sm:col-span-6">
+ <label
+ for="address"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ {i18n.str`Payto URI:`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <textarea
+ ref={focus ? doAutoFocus : undefined}
+ name="address"
+ id="address"
+ type="textarea"
+ rows={5}
+ class="block overflow-hidden w-44 sm:w-96 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={rawPaytoInput ?? ""}
+ required
+ title={i18n.str`Uniform resource identifier of the target account`}
+ placeholder={((): TranslatedString => {
+ switch (paytoType) {
+ case "x-taler-bank":
+ return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limit.currency}:X.Y]`;
+ case "iban":
+ return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`;
+ }
+ })()}
+ onInput={(e): void => {
+ rawPaytoInputSetter(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsPayto?.rawPaytoInput}
+ isDirty={rawPaytoInput !== undefined}
+ />
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ {routeCancel ? (
+ <a
+ name="cancel"
+ href={routeCancel.url({})}
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ ) : (
+ <div />
+ )}
+ <button
+ type="submit"
+ name="send"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={isRawPayto ? !!errorsPayto : !!errorsWire}
+ onClick={(e) => {
+ e.preventDefault();
+ doSend();
+ }}
+ >
+ <i18n.Translate>Send</i18n.Translate>
+ </button>
+ </div>
+ <LocalNotificationBanner notification={notification} />
+ </form>
+ </div>
+ );
+}
+
+/**
+ * Show the element when the load ended
+ * @param element
+ */
+export function doAutoFocus(element: HTMLElement | null) {
+ if (element) {
+ setTimeout(() => {
+ element.focus({ preventScroll: true });
+ element.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "center",
+ });
+ }, 100);
+ }
+}
+
+export function InputAmount(
+ {
+ currency,
+ name,
+ value,
+ error,
+ left,
+ onChange,
+ }: {
+ error?: string;
+ currency: string;
+ name: string;
+ left?: boolean | undefined;
+ value: string | undefined;
+ onChange?: (s: string) => void;
+ },
+ ref: Ref<HTMLInputElement>,
+): VNode {
+ const { config } = useBankCoreApiContext();
+ return (
+ <div class="mt-2">
+ <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600">
+ <div class="pointer-events-none inset-y-0 flex items-center px-3">
+ <span class="text-gray-500 sm:text-sm">{currency}</span>
+ </div>
+ <input
+ type="number"
+ data-left={left}
+ class="disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"
+ placeholder="0.00"
+ aria-describedby="price-currency"
+ ref={ref}
+ name={name}
+ id={name}
+ autocomplete="off"
+ value={value ?? ""}
+ disabled={!onChange}
+ onInput={(e) => {
+ if (!onChange) return;
+ const l = e.currentTarget.value.length;
+ const sep_pos = e.currentTarget.value.indexOf(FRAC_SEPARATOR);
+ if (
+ sep_pos !== -1 &&
+ l - sep_pos - 1 >
+ config.currency_specification.num_fractional_input_digits
+ ) {
+ e.currentTarget.value = e.currentTarget.value.substring(
+ 0,
+ sep_pos +
+ config.currency_specification.num_fractional_input_digits +
+ 1,
+ );
+ }
+ onChange(e.currentTarget.value);
+ }}
+ />
+ </div>
+ <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
+ </div>
+ );
+}
+
+export function RenderAmount({
+ value,
+ spec,
+ negative,
+ withColor,
+ hideSmall,
+}: {
+ spec: CurrencySpecification;
+ value: AmountJson;
+ hideSmall?: boolean;
+ negative?: boolean;
+ withColor?: boolean;
+}): VNode {
+ const neg = !!negative; // convert to true or false
+
+ const { currency, normal, small } = Amounts.stringifyValueWithSpec(
+ value,
+ spec,
+ );
+
+ return (
+ <span
+ data-negative={withColor ? neg : undefined}
+ class="whitespace-nowrap data-[negative=false]:text-green-600 data-[negative=true]:text-red-600"
+ >
+ {negative ? "- " : undefined}
+ {currency} {normal}{" "}
+ {!hideSmall && small && <sup class="-ml-1">{small}</sup>}
+ </span>
+ );
+}
+
+function validateRawPayto(
+ parsed: PaytoUri,
+ limit: AmountJson,
+ host: string,
+ i18n: InternationalizationAPI,
+ type: "iban" | "x-taler-bank",
+): TranslatedString | undefined {
+ if (!parsed.isKnown) {
+ return i18n.str`The target type is unknown, use "${type}"`;
+ }
+ let result: TranslatedString | undefined;
+ switch (type) {
+ case "x-taler-bank": {
+ if (parsed.targetType !== "x-taler-bank") {
+ return i18n.str`Only "x-taler-bank" target are supported`;
+ }
+
+ if (parsed.host !== host) {
+ return i18n.str`Only this host is allowed. Use "${host}"`;
+ }
+
+ if (!parsed.account) {
+ return i18n.str`Missing account name`;
+ }
+ const result = validateTalerBank(parsed.account, i18n);
+ if (result) return result;
+ break;
+ }
+ case "iban": {
+ if (parsed.targetType !== "iban") {
+ return i18n.str`Only "IBAN" target are supported`;
+ }
+ const result = validateIBAN(parsed.iban, i18n);
+ if (result) return result;
+ break;
+ }
+ default:
+ assertUnreachable(type);
+ }
+ if (!parsed.params.amount) {
+ return i18n.str`Missing "amount" parameter to specify the amount to be transferred`;
+ }
+ const amount = Amounts.parse(parsed.params.amount);
+ if (!amount) {
+ return i18n.str`The "amount" parameter is not valid`;
+ }
+ result = validateAmount(amount, limit, i18n);
+ if (result) return result;
+
+ if (!parsed.params.message) {
+ return i18n.str`Missing the "message" parameter to specify a reference text for the transfer`;
+ }
+ const subject = parsed.params.message;
+ result = validateSubject(subject, i18n);
+ if (result) return result;
+
+ return undefined;
+}
+
+function validateAmount(
+ amount: AmountJson,
+ limit: AmountJson,
+ i18n: InternationalizationAPI,
+): TranslatedString | undefined {
+ if (amount.currency !== limit.currency) {
+ return i18n.str`The only currency allowed is "${limit.currency}"`;
+ }
+ if (Amounts.isZero(amount)) {
+ return i18n.str`Can't transfer zero amount`;
+ }
+ if (Amounts.cmp(limit, amount) === -1) {
+ return i18n.str`Balance is not enough`;
+ }
+ return undefined;
+}
+
+function validateSubject(
+ text: string,
+ i18n: InternationalizationAPI,
+): TranslatedString | undefined {
+ if (text.length < 2) {
+ return i18n.str`Use a longer subject`;
+ }
+ return undefined;
+}
+
+interface PaytoFieldProps {
+ id: string;
+ label: TranslatedString;
+ required?: boolean;
+ help?: TranslatedString;
+ placeholder?: TranslatedString;
+ error: string | undefined;
+ value: string | undefined;
+ rightIcons?: VNode;
+ onChange: (p: string) => void;
+ focus?: boolean;
+ disabled?: boolean;
+}
+
+function Wrapper({
+ withIcon,
+ children,
+}: {
+ withIcon: boolean;
+ children: ComponentChildren;
+}): VNode {
+ if (withIcon) {
+ return <div class="flex justify-between">{children}</div>;
+ }
+ return <Fragment>{children}</Fragment>;
+}
+
+export function TextField({
+ id,
+ label,
+ help,
+ focus,
+ disabled,
+ onChange,
+ placeholder,
+ rightIcons,
+ required,
+ value,
+ error,
+}: PaytoFieldProps): VNode {
+ return (
+ <div class="sm:col-span-5">
+ <label for={id} class="block text-sm font-medium leading-6 text-gray-900">
+ {label}
+ {required && <b style={{ color: "red" }}> *</b>}
+ </label>
+ <div class="mt-2">
+ <Wrapper withIcon={rightIcons !== undefined}>
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="text"
+ class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name={id}
+ id={id}
+ disabled={disabled}
+ value={value ?? ""}
+ placeholder={placeholder}
+ autocomplete="off"
+ required
+ onInput={(e): void => {
+ onChange(e.currentTarget.value);
+ }}
+ />
+ {rightIcons}
+ </Wrapper>
+ <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
+ </div>
+ {help && <p class="mt-2 text-sm text-gray-500">{help}</p>}
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/ProfileNavigation.tsx b/packages/bank-ui/src/pages/ProfileNavigation.tsx
new file mode 100644
index 000000000..3e81e307c
--- /dev/null
+++ b/packages/bank-ui/src/pages/ProfileNavigation.tsx
@@ -0,0 +1,202 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useNavigationContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export function ProfileNavigation({
+ current,
+ routeMyAccountCashout,
+ routeMyAccountDelete,
+ routeMyAccountDetails,
+ routeMyAccountPassword,
+ routeConversionConfig,
+}: {
+ current: "details" | "delete" | "credentials" | "cashouts" | "conversion";
+ routeMyAccountDetails: RouteDefinition;
+ routeMyAccountDelete: RouteDefinition;
+ routeMyAccountPassword: RouteDefinition;
+ routeMyAccountCashout: RouteDefinition;
+ routeConversionConfig: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+ const { state: credentials } = useSessionState();
+ const isAdminUser =
+ credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator;
+ const nonAdminUser = !isAdminUser;
+
+ const { navigateTo } = useNavigationContext();
+ return (
+ <div>
+ <div class="sm:hidden">
+ <label for="tabs" class="sr-only">
+ <i18n.Translate>Select a section</i18n.Translate>
+ </label>
+ <select
+ id="tabs"
+ name="tabs"
+ class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
+ onChange={(e) => {
+ const op = e.currentTarget.value as typeof current;
+ switch (op) {
+ case "details": {
+ navigateTo(routeMyAccountDetails.url({}));
+ return;
+ }
+ case "delete": {
+ navigateTo(routeMyAccountDelete.url({}));
+ return;
+ }
+ case "credentials": {
+ navigateTo(routeMyAccountPassword.url({}));
+ return;
+ }
+ case "cashouts": {
+ navigateTo(routeMyAccountCashout.url({}));
+ return;
+ }
+ case "conversion": {
+ navigateTo(routeConversionConfig.url({}));
+ return;
+ }
+ default:
+ assertUnreachable(op);
+ }
+ }}
+ >
+ <option value="details" selected={current == "details"}>
+ <i18n.Translate>Details</i18n.Translate>
+ </option>
+ {!config.allow_deletions ? undefined : (
+ <option value="delete" selected={current == "delete"}>
+ <i18n.Translate>Delete</i18n.Translate>
+ </option>
+ )}
+ <option value="credentials" selected={current == "credentials"}>
+ <i18n.Translate>Credentials</i18n.Translate>
+ </option>
+ {config.allow_conversion ? (
+ <Fragment>
+ <option value="cashouts" selected={current == "cashouts"}>
+ <i18n.Translate>Cashouts</i18n.Translate>
+ </option>
+ <option value="conversion" selected={current == "cashouts"}>
+ <i18n.Translate>Conversion</i18n.Translate>
+ </option>
+ </Fragment>
+ ) : undefined}
+ </select>
+ </div>
+ <div class="hidden sm:block">
+ <nav
+ class="isolate flex divide-x divide-gray-200 rounded-lg shadow"
+ aria-label="Tabs"
+ >
+ <a
+ name="my account details"
+ href={routeMyAccountDetails.url({})}
+ data-selected={current == "details"}
+ class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Details</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={current == "details"}
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </a>
+ {!config.allow_deletions ? undefined : (
+ <a
+ name="my account delete"
+ href={routeMyAccountDelete.url({})}
+ data-selected={current == "delete"}
+ aria-current="page"
+ class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Delete</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={current == "delete"}
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </a>
+ )}
+ <a
+ name="my account password"
+ href={routeMyAccountPassword.url({})}
+ data-selected={current == "credentials"}
+ aria-current="page"
+ class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Credentials</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={current == "credentials"}
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </a>
+ {config.allow_conversion && nonAdminUser ? (
+ <a
+ name="my account cashout"
+ href={routeMyAccountCashout.url({})}
+ data-selected={current == "cashouts"}
+ class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Cashouts</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={current == "cashouts"}
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </a>
+ ) : undefined}
+ {config.allow_conversion && isAdminUser ? (
+ <a
+ name="conversion config"
+ href={routeConversionConfig.url({})}
+ data-selected={current == "conversion"}
+ class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Conversion</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={current == "conversion"}
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </a>
+ ) : undefined}
+ </nav>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/PublicHistoriesPage.tsx b/packages/bank-ui/src/pages/PublicHistoriesPage.tsx
new file mode 100644
index 000000000..80ae28dde
--- /dev/null
+++ b/packages/bank-ui/src/pages/PublicHistoriesPage.tsx
@@ -0,0 +1,98 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TalerError } from "@gnu-taler/taler-util";
+import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Transactions } from "../components/Transactions/index.js";
+import { usePublicAccounts } from "../hooks/account.js";
+
+/**
+ * Show histories of public accounts.
+ */
+export function PublicHistoriesPage(): VNode {
+ const { i18n } = useTranslationContext();
+
+ // TODO: implemented filter by account name
+ const result = usePublicAccounts(undefined);
+ const firstAccount =
+ result && !(result instanceof TalerError) && result.body.length > 0
+ ? result.body[0].username
+ : undefined;
+
+ const [showAccount, setShowAccount] = useState(firstAccount);
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <Loading />;
+ }
+
+ const { body: accountList } = result;
+
+ const txs: Record<string, h.JSX.Element> = {};
+ const accountsBar = [];
+
+ // Ask story of all the public accounts.
+ for (const account of accountList) {
+ const isSelected = account.username == showAccount;
+ accountsBar.push(
+ <li
+ class={
+ isSelected
+ ? "pure-menu-selected pure-menu-item"
+ : "pure-menu-item pure-menu"
+ }
+ >
+ <a
+ href="#"
+ name={`show account ${account.username}`}
+ class="pure-menu-link"
+ onClick={() => setShowAccount(account.username)}
+ >
+ {account.username}
+ </a>
+ </li>,
+ );
+ txs[account.username] = (
+ <Transactions
+ account={account.username}
+ routeCreateWireTransfer={undefined}
+ />
+ );
+ }
+
+ return (
+ <Fragment>
+ <h1 class="nav">{i18n.str`History of public accounts`}</h1>
+ <section id="main">
+ <article>
+ <div class="pure-menu pure-menu-horizontal" name="accountMenu">
+ <ul class="pure-menu-list">{accountsBar}</ul>
+ {typeof showAccount !== "undefined" ? (
+ txs[showAccount]
+ ) : (
+ <p>No public transactions found.</p>
+ )}
+ <br />
+ </div>
+ </article>
+ </section>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/QrCodeSection.stories.tsx b/packages/bank-ui/src/pages/QrCodeSection.stories.tsx
new file mode 100644
index 000000000..d53d2e7b4
--- /dev/null
+++ b/packages/bank-ui/src/pages/QrCodeSection.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { QrCodeSection } from "./QrCodeSection.js";
+import { parseWithdrawUri } from "@gnu-taler/taler-util";
+
+export default {
+ title: "Qr Code Selection",
+};
+
+export const SimpleExample = tests.createExample(QrCodeSection, {
+ withdrawUri: parseWithdrawUri("taler://withdraw/bank.com/operationId"),
+});
diff --git a/packages/bank-ui/src/pages/QrCodeSection.tsx b/packages/bank-ui/src/pages/QrCodeSection.tsx
new file mode 100644
index 000000000..359d4c18f
--- /dev/null
+++ b/packages/bank-ui/src/pages/QrCodeSection.tsx
@@ -0,0 +1,152 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ HttpStatusCode,
+ stringifyWithdrawUri,
+ WithdrawUriResult,
+} from "@gnu-taler/taler-util";
+import {
+ Button,
+ LocalNotificationBanner,
+ useLocalNotificationHandler,
+ useTalerWalletIntegrationAPI,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useEffect } from "preact/hooks";
+import { QR } from "../components/QR.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../hooks/session.js";
+
+export function QrCodeSection({
+ withdrawUri,
+ onAborted,
+}: {
+ withdrawUri: WithdrawUriResult;
+ onAborted: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const walletInegrationApi = useTalerWalletIntegrationAPI();
+ const talerWithdrawUri = stringifyWithdrawUri(withdrawUri);
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+
+ useEffect(() => {
+ walletInegrationApi.publishTalerAction(withdrawUri);
+ }, []);
+
+ const [notification, handleError] = useLocalNotificationHandler();
+
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ const onAbortHandler = handleError(
+ async () => {
+ if (!creds) return undefined;
+ return api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId);
+ },
+ onAborted,
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`The operation id is invalid.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The operation was not found.`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`The reserve operation has been confirmed previously and can't be aborted`;
+ }
+ },
+ );
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+ <div class="bg-white shadow-xl sm:rounded-lg">
+ <div class="px-4 py-5 sm:p-6">
+ <h3 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>
+ If you have a Taler wallet installed in this device
+ </i18n.Translate>
+ </h3>
+ <div class="mt-4 mb-4 text-sm text-gray-500">
+ <p>
+ <i18n.Translate>
+ You will see the details of the operation in your wallet
+ including the fees (if applies). If you still don't have one you
+ can install it following instructions in
+ </i18n.Translate>{" "}
+ <a
+ class="font-semibold text-gray-500 hover:text-gray-400"
+ name="wallet page"
+ href="https://taler.net/en/wallet.html"
+ >
+ <i18n.Translate>this page</i18n.Translate>
+ </a>
+ .
+ </p>
+ </div>
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 pt-2 mt-2 ">
+ <Button
+ type="button"
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ handler={onAbortHandler}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ <a
+ href={talerWithdrawUri}
+ name="withdraw"
+ class="inline-flex items-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Withdraw</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ </div>
+
+ <div class="bg-white shadow-xl sm:rounded-lg mt-8">
+ <div class="px-4 py-5 sm:p-6">
+ <h3 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>
+ Or if you have the Taler wallet in another device
+ </i18n.Translate>
+ </h3>
+ <div class="mt-4 max-w-xl text-sm text-gray-500">
+ <i18n.Translate>
+ Scan the QR below to start the withdrawal.
+ </i18n.Translate>
+ </div>
+ <div class="mt-2 max-w-md ml-auto mr-auto">
+ <QR text={talerWithdrawUri} />
+ </div>
+ </div>
+ <div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <Button
+ type="button"
+ // class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ handler={onAbortHandler}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx
new file mode 100644
index 000000000..18f926e00
--- /dev/null
+++ b/packages/bank-ui/src/pages/RegistrationPage.tsx
@@ -0,0 +1,425 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AccessToken,
+ HttpStatusCode,
+ TalerErrorCode,
+} from "@gnu-taler/taler-util";
+import {
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSettingsContext } from "../context/settings.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../utils.js";
+import { getRandomPassword, getRandomUsername } from "./rnd.js";
+
+export function RegistrationPage({
+ onRegistrationSuccesful,
+ routeCancel,
+}: {
+ onRegistrationSuccesful: (user: string, password: string) => void;
+ routeCancel: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+ if (!config.allow_registrations) {
+ return (
+ <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p>
+ );
+ }
+ return (
+ <RegistrationForm
+ onRegistrationSuccesful={onRegistrationSuccesful}
+ routeCancel={routeCancel}
+ />
+ );
+}
+
+// eslint-disable-next-line no-useless-escape
+export const USERNAME_REGEX = /^[a-zA-Z0-9\-\.\_\~]*$/;
+export const PHONE_REGEX = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/;
+export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/;
+
+/**
+ * Collect and submit registration data.
+ */
+function RegistrationForm({
+ onRegistrationSuccesful,
+ routeCancel,
+}: {
+ onRegistrationSuccesful: (user: string, password: string) => void;
+ routeCancel: RouteDefinition;
+}): VNode {
+ const [username, setUsername] = useState<string | undefined>();
+ const [name, setName] = useState<string | undefined>();
+ const [password, setPassword] = useState<string | undefined>();
+ // const [phone, setPhone] = useState<string | undefined>();
+ // const [email, setEmail] = useState<string | undefined>();
+ const [repeatPassword, setRepeatPassword] = useState<string | undefined>();
+ const [notification, , handleError] = useLocalNotification();
+ const settings = useSettingsContext();
+
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+ // const { register } = useTestingAPI();
+ const { i18n } = useTranslationContext();
+
+ const errors = undefinedIfEmpty({
+ name: !name ? i18n.str`Missing name` : undefined,
+ username: !username
+ ? i18n.str`Missing username`
+ : !USERNAME_REGEX.test(username)
+ ? i18n.str`Use letters, numbers or any of these characters: - . _ ~`
+ : undefined,
+ // phone: !phone
+ // ? undefined
+ // : !PHONE_REGEX.test(phone)
+ // ? i18n.str`Use letters and numbers only, and start with a lowercase letter`
+ // : undefined,
+ // email: !email
+ // ? undefined
+ // : !EMAIL_REGEX.test(email)
+ // ? i18n.str`Use letters and numbers only, and start with a lowercase letter`
+ // : undefined,
+ password: !password ? i18n.str`Missing password` : undefined,
+ repeatPassword: !repeatPassword
+ ? i18n.str`Missing password`
+ : repeatPassword !== password
+ ? i18n.str`Passwords don't match`
+ : undefined,
+ });
+
+ async function doRegistrationAndLogin(
+ name: string,
+ username: string,
+ password: string,
+ onComplete: () => void,
+ ) {
+ await handleError(async (onError) => {
+ const resp = await api.createAccount(undefined, {
+ name,
+ username,
+ password,
+ });
+ if (resp.type === "ok") {
+ onComplete();
+ } else {
+ onError(resp, (_case) => {
+ switch (_case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`Server replied with invalid phone or email.`;
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`No enough permission to create that account.`;
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return i18n.str`Registration is disabled because the bank ran out of bonus credit.`;
+ case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
+ return i18n.str`That username can't be used because is reserved.`;
+ case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE:
+ return i18n.str`That username is already taken.`;
+ case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE:
+ return i18n.str`That account id is already taken.`;
+ case TalerErrorCode.BANK_MISSING_TAN_INFO:
+ return i18n.str`No information for the selected authentication channel.`;
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED:
+ return i18n.str`Authentication channel is not supported.`;
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
+ return i18n.str`Only admin is allow to set debt limit.`;
+ case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL:
+ return i18n.str`Only admin can create accounts with second factor authentication.`;
+ }
+ });
+ }
+ });
+ }
+
+ async function doRegistrationStep() {
+ if (!username || !password || !name) return;
+ await doRegistrationAndLogin(name, username, password, () => {
+ setUsername(undefined);
+ setPassword(undefined);
+ setRepeatPassword(undefined);
+ onRegistrationSuccesful(username, password);
+ });
+ }
+
+ async function doRandomRegistration() {
+ const user = getRandomUsername();
+
+ const password = settings.simplePasswordForRandomAccounts
+ ? "123"
+ : getRandomPassword();
+ const username = `_${user.first}-${user.second}_`;
+ const name = `${capitalizeFirstLetter(user.first)} ${capitalizeFirstLetter(
+ user.second,
+ )}`;
+ await doRegistrationAndLogin(name, username, password, () => {
+ onRegistrationSuccesful(username, password);
+ });
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="flex min-h-full flex-col justify-center">
+ <div class="sm:mx-auto sm:w-full sm:max-w-sm">
+ <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Account registration`}</h2>
+ </div>
+
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
+ <form
+ class="space-y-6"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ autoCapitalize="none"
+ autoCorrect="off"
+ >
+ <div>
+ <label
+ for="username"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ <i18n.Translate>Login username</i18n.Translate>
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <input
+ autoFocus
+ type="text"
+ name="username"
+ id="username"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={username ?? ""}
+ enterkeyhint="next"
+ placeholder="account identification to login"
+ autocomplete="username"
+ required
+ onInput={(e): void => {
+ setUsername(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.username}
+ isDirty={username !== undefined}
+ />
+ </div>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between">
+ <label
+ for="password"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ <i18n.Translate>Password</i18n.Translate>
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ </div>
+ <div class="mt-2">
+ <input
+ type="password"
+ name="password"
+ id="password"
+ autocomplete="current-password"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ enterkeyhint="send"
+ value={password ?? ""}
+ placeholder="Password"
+ required
+ onInput={(e): void => {
+ setPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </div>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between">
+ <label
+ for="register-repeat"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ <i18n.Translate>Repeat password</i18n.Translate>
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ </div>
+ <div class="mt-2">
+ <input
+ type="password"
+ name="register-repeat"
+ id="register-repeat"
+ autocomplete="current-password"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ enterkeyhint="send"
+ value={repeatPassword ?? ""}
+ placeholder="Same password"
+ required
+ onInput={(e): void => {
+ setRepeatPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.repeatPassword}
+ isDirty={repeatPassword !== undefined}
+ />
+ </div>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between">
+ <label
+ for="name"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ <i18n.Translate>Full name</i18n.Translate>
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ </div>
+ <div class="mt-2">
+ <input
+ autoFocus
+ type="text"
+ name="name"
+ id="name"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={name ?? ""}
+ enterkeyhint="next"
+ placeholder="John Doe"
+ autocomplete="name"
+ required
+ onInput={(e): void => {
+ setName(e.currentTarget.value);
+ }}
+ />
+ {/* <ShowInputErrorLabel
+ message={errors?.name}
+ isDirty={name !== undefined}
+ /> */}
+ </div>
+ </div>
+
+ {/* <div>
+ <label for="phone" class="block text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Phone</i18n.Translate>
+ </label>
+ <div class="mt-2">
+ <input
+ autoFocus
+ type="text"
+ name="phone"
+ id="phone"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={phone ?? ""}
+ enterkeyhint="next"
+ placeholder="your phone"
+ autocomplete="none"
+ onInput={(e): void => {
+ setPhone(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.phone}
+ isDirty={phone !== undefined}
+ />
+ </div>
+ </div>
+ <div>
+ <label for="email" class="block text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Email</i18n.Translate>
+ </label>
+ <div class="mt-2">
+ <input
+ autoFocus
+ type="text"
+ name="email"
+ id="email"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={email ?? ""}
+ enterkeyhint="next"
+ placeholder="your email"
+ autocomplete="email"
+ onInput={(e): void => {
+ setEmail(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.email}
+ isDirty={email !== undefined}
+ />
+ </div>
+ </div> */}
+
+ <div class="flex w-full justify-between">
+ <a
+ name="cancel"
+ href={routeCancel.url({})}
+ class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="register"
+ class=" rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!!errors}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ doRegistrationStep();
+ }}
+ >
+ <i18n.Translate>Register</i18n.Translate>
+ </button>
+ </div>
+ </form>
+
+ {settings.allowRandomAccountCreation && (
+ <p class="mt-10 text-center text-sm text-gray-500 border-t">
+ <button
+ type="submit"
+ name="create random"
+ class="flex mt-4 w-full justify-center rounded-md bg-green-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
+ onClick={(e) => {
+ e.preventDefault();
+ doRandomRegistration();
+ }}
+ >
+ <i18n.Translate>Create a random temporary user</i18n.Translate>
+ </button>
+ </p>
+ )}
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+
+function capitalizeFirstLetter(str: string) {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
diff --git a/packages/bank-ui/src/pages/ShowNotifications.tsx b/packages/bank-ui/src/pages/ShowNotifications.tsx
new file mode 100644
index 000000000..fe041fb19
--- /dev/null
+++ b/packages/bank-ui/src/pages/ShowNotifications.tsx
@@ -0,0 +1,55 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useNotifications } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { Time } from "../components/Time.js";
+
+export function ShowNotifications(): VNode {
+ const ns = useNotifications();
+ if (!ns.length) {
+ return <div>no notifications</div>;
+ }
+ return (
+ <div>
+ <p>Notifications</p>
+ <table>
+ <thead></thead>
+ <tbody>
+ {ns.map((n, idx) => {
+ return (
+ <tr key={idx}>
+ <td>
+ <Time
+ timestamp={n.message.when}
+ format="dd/MM/yyyy HH:mm:ss"
+ />
+ </td>
+ <td>{n.message.title}</td>
+ <td>
+ {n.message.type === "error"
+ ? n.message.description
+ : undefined}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {/* <ToastBanner all /> */}
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/SolveChallengePage.tsx b/packages/bank-ui/src/pages/SolveChallengePage.tsx
new file mode 100644
index 000000000..624890468
--- /dev/null
+++ b/packages/bank-ui/src/pages/SolveChallengePage.tsx
@@ -0,0 +1,793 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ Duration,
+ HttpStatusCode,
+ TalerCorebankApi,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ useLocalNotification,
+ useNavigationContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { Time } from "../components/Time.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useWithdrawalDetails } from "../hooks/account.js";
+import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js";
+import { useConversionInfo } from "../hooks/regional.js";
+import { useSessionState } from "../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../utils.js";
+import { RenderAmount } from "./PaytoWireTransferForm.js";
+import { OperationNotFound } from "./WithdrawalQRCode.js";
+import { IdempotencyRetry } from "../../../taler-util/lib/http-client/utils.js";
+
+const TAN_PREFIX = "T-";
+const TAN_REGEX = /^([Tt](-)?)?[0-9]*$/;
+export function SolveChallengePage({
+ onChallengeCompleted,
+ routeClose,
+}: {
+ onChallengeCompleted: () => void;
+ routeClose: RouteDefinition;
+}): VNode {
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+ const { i18n } = useTranslationContext();
+ const [bankState, updateBankState] = useBankState();
+ const [code, setCode] = useState<string | undefined>(undefined);
+ const [notification, notify, handleError] = useLocalNotification();
+ const { state } = useSessionState();
+ const creds = state.status !== "loggedIn" ? undefined : state;
+ const { navigateTo } = useNavigationContext();
+
+ if (!bankState.currentChallenge) {
+ return (
+ <div>
+ <span>no challenge to solve </span>
+ <a
+ href={routeClose.url({})}
+ name="close"
+ class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
+ >
+ <i18n.Translate>Continue</i18n.Translate>
+ </a>
+ </div>
+ );
+ }
+
+ const ch = bankState.currentChallenge;
+ const errors = undefinedIfEmpty({
+ code: !code
+ ? i18n.str`Required`
+ : !TAN_REGEX.test(code)
+ ? i18n.str`Confirmation codes are numerical, possibly beginning with 'T-.'`
+ : undefined,
+ });
+
+ async function startChallenge() {
+ if (!creds) return;
+ await handleError(async () => {
+ const resp = await api.sendChallenge(creds, ch.id);
+ if (resp.type === "ok") {
+ const newCh = structuredClone(ch);
+ newCh.sent = AbsoluteTime.now();
+ newCh.info = resp.body;
+ updateBankState("currentChallenge", newCh);
+ } else {
+ const newCh = structuredClone(ch);
+ newCh.sent = AbsoluteTime.now();
+ newCh.info = undefined;
+ updateBankState("currentChallenge", newCh);
+ switch (resp.case) {
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
+ return notify({
+ type: "error",
+ title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ async function completeChallenge() {
+ if (!creds || !code) return;
+ const tan = code.toUpperCase().startsWith(TAN_PREFIX)
+ ? code.substring(TAN_PREFIX.length)
+ : code;
+ await handleError(async () => {
+ {
+ const resp = await api.confirmChallenge(creds, ch.id, { tan });
+ if (resp.type === "fail") {
+ setCode("");
+ switch (resp.case) {
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`Challenge not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`This user is not authorized to complete this challenge.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.TooManyRequests:
+ return notify({
+ type: "error",
+ title: i18n.str`Too many attempts, try another code.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED:
+ return notify({
+ type: "error",
+ title: i18n.str`The confirmation code is wrong, try again.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation expired.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ }
+ {
+ const resp = await (async (ch: ChallengeInProgess) => {
+ switch (ch.operation) {
+ case "delete-account":
+ return await api.deleteAccount(creds, ch.id);
+ case "update-account":
+ return await api.updateAccount(creds, ch.request, ch.id);
+ case "update-password":
+ return await api.updatePassword(creds, ch.request, ch.id);
+ case "create-transaction":
+ return await api.createTransaction(creds, ch.request, undefined, ch.id);
+ case "confirm-withdrawal":
+ return await api.confirmWithdrawalById(creds, ch.request, ch.id);
+ case "create-cashout":
+ return await api.createCashout(creds, ch.request, ch.id);
+ default:
+ assertUnreachable(ch);
+ }
+ })(ch);
+
+ if (resp.type === "fail") {
+ if (resp.case !== HttpStatusCode.Accepted) {
+ return notify({
+ type: "error",
+ title: i18n.str`The operation failed.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ // another challenge required, save the request and the ID
+ // @ts-expect-error no need to check the type of request, since it will be the same as the previous request
+ updateBankState("currentChallenge", {
+ operation: ch.operation,
+ id: String(resp.body.challenge_id),
+ location: ch.location,
+ sent: AbsoluteTime.never(),
+ request: ch.request,
+ });
+ return notify({
+ type: "info",
+ title: i18n.str`The operation needs another confirmation to complete.`,
+ when: AbsoluteTime.now(),
+ });
+ }
+ updateBankState("currentChallenge", undefined);
+ return onChallengeCompleted();
+ }
+ });
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <span
+ class="text-sm text-black font-semibold leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Confirm the operation</i18n.Translate>
+ </span>
+ </h2>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ This operation is protected with second factor authentication. In
+ order to complete it we need to verify your identity using the
+ authentication channel you provided.
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
+ <ChallengeDetails
+ challenge={bankState.currentChallenge}
+ onStart={startChallenge}
+ onCancel={() => {
+ updateBankState("currentChallenge", undefined);
+ navigateTo(ch.location);
+ }}
+ />
+ {ch.info && (
+ <div class="mt-2">
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 py-4">
+ <label for="withdraw-amount">
+ <i18n.Translate>Enter the confirmation code</i18n.Translate>
+ </label>
+ <div class="mt-2">
+ <div class="relative rounded-md shadow-sm">
+ <input
+ type="text"
+ // class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ aria-describedby="answer"
+ autoFocus
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={code ?? ""}
+ required
+ onPaste={(e) => {
+ e.preventDefault();
+ const pasted = e.clipboardData?.getData("text/plain");
+ if (!pasted) return;
+ if (pasted.toUpperCase().startsWith(TAN_PREFIX)) {
+ const sub = pasted.substring(TAN_PREFIX.length);
+ setCode(sub);
+ return;
+ }
+ setCode(pasted);
+ }}
+ name="answer"
+ id="answer"
+ autocomplete="off"
+ onChange={(e): void => {
+ setCode(e.currentTarget.value);
+ }}
+ />
+ </div>
+ <ShowInputErrorLabel
+ message={errors?.code}
+ isDirty={code !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ {((ch: TalerCorebankApi.TanChannel): VNode => {
+ switch (ch) {
+ case TalerCorebankApi.TanChannel.SMS:
+ return (
+ <i18n.Translate>
+ You should have received a code in your phone.
+ </i18n.Translate>
+ );
+ case TalerCorebankApi.TanChannel.EMAIL:
+ return (
+ <i18n.Translate>
+ You should have received a code in your email.
+ </i18n.Translate>
+ );
+ default:
+ assertUnreachable(ch);
+ }
+ })(ch.info.tan_channel)}
+ </p>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ The confirmation code starts with "{TAN_PREFIX}" followed
+ by numbers.
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="flex items-center justify-between border-gray-900/10 px-4 py-4 ">
+ <div />
+ <button
+ type="submit"
+ name="confirm"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!!errors}
+ onClick={(e) => {
+ completeChallenge();
+ e.preventDefault();
+ }}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ )}
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+
+function ChallengeDetails({
+ challenge,
+ onStart,
+ onCancel,
+}: {
+ challenge: ChallengeInProgess;
+ onStart: () => void;
+ onCancel: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+
+ const firstTime = AbsoluteTime.isNever(challenge.sent);
+ useEffect(() => {
+ if (firstTime) {
+ onStart();
+ }
+ }, []);
+
+ const subtitle = ((op): TranslatedString => {
+ switch (op) {
+ case "delete-account":
+ return i18n.str`Removing account`;
+ case "update-account":
+ return i18n.str`Updating account values`;
+ case "update-password":
+ return i18n.str`Updating password`;
+ case "create-transaction":
+ return i18n.str`Making a wire transfer`;
+ case "confirm-withdrawal":
+ return i18n.str`Confirming withdrawal`;
+ case "create-cashout":
+ return i18n.str`Making a cashout`;
+ }
+ })(challenge.operation);
+
+ return (
+ <div class="px-4 mt-4 ">
+ <div class="w-full">
+ <div class="border-gray-100">
+ <h2 class="text-base font-semibold leading-10 text-gray-900">
+ <span class=" text-black font-semibold leading-6 ">
+ <i18n.Translate>Operation:</i18n.Translate>
+ </span>{" "}
+ &nbsp;
+ <span class=" text-black font-normal leading-6 ">{subtitle}</span>
+ </h2>
+ <dl class="divide-y divide-gray-100">
+ {((): VNode => {
+ switch (challenge.operation) {
+ case "delete-account":
+ return (
+ <Fragment>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Type</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <i18n.Translate>
+ Updating account settings
+ </i18n.Translate>
+ </dd>
+ </div>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Account</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request}
+ </dd>
+ </div>
+ </Fragment>
+ );
+ case "create-transaction": {
+ const payto = parsePaytoUri(challenge.request.payto_uri)!;
+ return (
+ <Fragment>
+ {challenge.request.amount && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Amount</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={Amounts.parseOrThrow(
+ challenge.request.amount,
+ )}
+ spec={config.currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ {payto.isKnown && payto.targetType === "iban" && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>To account</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {payto.iban}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+ }
+ case "confirm-withdrawal":
+ return <ShowWithdrawalDetails id={challenge.request} />;
+ case "create-cashout": {
+ return <ShowCashoutDetails request={challenge.request} />;
+ }
+ case "update-account": {
+ return (
+ <Fragment>
+ {challenge.request.cashout_payto_uri !== undefined && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Cashout account</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.cashout_payto_uri}
+ </dd>
+ </div>
+ )}
+ {challenge.request.contact_data?.email !== undefined && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Email</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.contact_data?.email}
+ </dd>
+ </div>
+ )}
+ {challenge.request.contact_data?.phone !== undefined && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Phone</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.contact_data?.phone}
+ </dd>
+ </div>
+ )}
+ {challenge.request.debit_threshold !== undefined && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Debit threshold</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={Amounts.parseOrThrow(
+ challenge.request.debit_threshold,
+ )}
+ spec={config.currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ {challenge.request.is_public !== undefined && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>
+ Is this account public?
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.is_public
+ ? i18n.str`Enable`
+ : i18n.str`Disable`}
+ </dd>
+ </div>
+ )}
+ {challenge.request.name !== undefined && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Name</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.name}
+ </dd>
+ </div>
+ )}
+ {challenge.request.tan_channel !== undefined && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>
+ Authentication channel
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.tan_channel ?? i18n.str`Remove`}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+ }
+ case "update-password": {
+ return (
+ <Fragment>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>New password</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.new_password}
+ </dd>
+ </div>
+ </Fragment>
+ );
+ }
+ default:
+ assertUnreachable(challenge);
+ }
+ })()}
+ </dl>
+ {challenge.info && (
+ <h2 class="text-base font-semibold leading-7 text-gray-900 mt-4">
+ <span
+ class="text-sm text-black font-semibold leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Challenge details</i18n.Translate>
+ </span>
+ </h2>
+ )}
+ <dl class="divide-y divide-gray-100">
+ {challenge.sent.t_ms !== "never" && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Sent at</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <Time
+ format="dd/MM/yyyy HH:mm:ss"
+ timestamp={challenge.sent}
+ relative={Duration.fromSpec({ days: 1 })}
+ />
+ </dd>
+ </div>
+ )}
+ {challenge.info && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ {((ch: TalerCorebankApi.TanChannel): VNode => {
+ switch (ch) {
+ case TalerCorebankApi.TanChannel.SMS:
+ return <i18n.Translate>To phone</i18n.Translate>;
+ case TalerCorebankApi.TanChannel.EMAIL:
+ return <i18n.Translate>To email</i18n.Translate>;
+ default:
+ assertUnreachable(ch);
+ }
+ })(challenge.info.tan_channel)}
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.info.tan_info}
+ </dd>
+ </div>
+ )}
+ </dl>
+ </div>
+ <div class="mt-6 mb-4 flex justify-between">
+ <button
+ type="button"
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={onCancel}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ {challenge.info ? (
+ <button
+ type="submit"
+ name="send again"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ onClick={(e) => {
+ onStart();
+ e.preventDefault();
+ }}
+ >
+ <i18n.Translate>Send again</i18n.Translate>
+ </button>
+ ) : (
+ <div> sending code ...</div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function ShowWithdrawalDetails({ id }: { id: string }): VNode {
+ const details = useWithdrawalDetails(id);
+ const { i18n } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+ if (!details) {
+ return <Loading />;
+ }
+ if (details instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={details} />;
+ }
+ if (details.type === "fail") {
+ switch (details.case) {
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.NotFound:
+ return <OperationNotFound routeClose={undefined} />;
+ default:
+ assertUnreachable(details);
+ }
+ }
+
+ return (
+ <Fragment>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={Amounts.parseOrThrow(details.body.amount)}
+ spec={config.currency_specification}
+ />
+ </dd>
+ </div>
+ {details.body.selected_reserve_pub !== undefined && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Withdraw id</i18n.Translate>
+ </dt>
+ <dd
+ class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"
+ title={details.body.selected_reserve_pub}
+ >
+ {details.body.selected_reserve_pub.substring(0, 16)}...
+ </dd>
+ </div>
+ )}
+ {details.body.selected_exchange_account !== undefined && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>To account</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.body.selected_exchange_account}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+}
+
+function ShowCashoutDetails({
+ request,
+}: {
+ request: TalerCorebankApi.CashoutRequest;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const info = useConversionInfo();
+ if (!info) {
+ return <Loading />;
+ }
+
+ if (info instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={info} />;
+ }
+ if (info.type === "fail") {
+ switch (info.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(info.case);
+ }
+ }
+
+ return (
+ <Fragment>
+ {request.subject !== undefined && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Subject</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {request.subject}
+ </dd>
+ </div>
+ )}
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Debit</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={Amounts.parseOrThrow(request.amount_credit)}
+ spec={info.body.regional_currency_specification}
+ />
+ </dd>
+ </div>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Credit</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={Amounts.parseOrThrow(request.amount_credit)}
+ spec={info.body.fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx
new file mode 100644
index 000000000..a9c652643
--- /dev/null
+++ b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx
@@ -0,0 +1,404 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ HttpStatusCode,
+ TranslatedString,
+ assertUnreachable,
+ parseWithdrawUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ LocalNotificationBanner,
+ notifyError,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { forwardRef } from "preact/compat";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../hooks/session.js";
+import { useBankState } from "../hooks/bank-state.js";
+import { usePreferences } from "../hooks/preferences.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../utils.js";
+import { OperationState } from "./OperationState/index.js";
+import {
+ InputAmount,
+ RenderAmount,
+ doAutoFocus,
+} from "./PaytoWireTransferForm.js";
+
+const RefAmount = forwardRef(InputAmount);
+
+function OldWithdrawalForm({
+ onOperationCreated,
+ limit,
+ balance,
+ routeCancel,
+ focus,
+ routeOperationDetails,
+}: {
+ limit: AmountJson;
+ balance: AmountJson;
+ focus?: boolean;
+ routeOperationDetails: RouteDefinition<{ wopid: string }>;
+ onOperationCreated: (wopid: string) => void;
+ routeCancel: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreferences();
+
+ // const walletInegrationApi = useTalerWalletIntegrationAPI()
+ // const { navigateTo } = useNavigationContext();
+
+ const [bankState, updateBankState] = useBankState();
+ const {
+ lib: { bank: api },
+ config,
+ } = useBankCoreApiContext();
+
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+
+ const [amountStr, setAmountStr] = useState<string | undefined>(
+ `${settings.maxWithdrawalAmount}`,
+ );
+ const [notification, notify, handleError] = useLocalNotification();
+
+ if (bankState.currentWithdrawalOperationId) {
+ // FIXME: doing the preventDefault is not optimal
+
+ // const suri = stringifyWithdrawUri({
+ // bankIntegrationApiBaseUrl: api.getIntegrationAPI().baseUrl,
+ // withdrawalOperationId: bankState.currentWithdrawalOperationId,
+ // });
+ // const uri = parseWithdrawUri(suri)!
+ const url = routeOperationDetails.url({
+ wopid: bankState.currentWithdrawalOperationId,
+ });
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`There is an operation already`}
+ onClose={() => {
+ updateBankState("currentWithdrawalOperationId", undefined);
+ }}
+ >
+ <span ref={focus ? doAutoFocus : undefined} />
+ <i18n.Translate>Complete the operation in</i18n.Translate>{" "}
+ <a
+ class="font-semibold text-yellow-700 hover:text-yellow-600"
+ name="complete operation"
+ href={url}
+ // onClick={(e) => {
+ // e.preventDefault()
+ // walletInegrationApi.publishTalerAction(uri, () => {
+ // navigateTo(url)
+ // })
+ // }}
+ >
+ <i18n.Translate>this page</i18n.Translate>
+ </a>
+ </Attention>
+ );
+ }
+
+ const trimmedAmountStr = amountStr?.trim();
+
+ const parsedAmount = trimmedAmountStr
+ ? Amounts.parse(`${limit.currency}:${trimmedAmountStr}`)
+ : undefined;
+
+ const errors = undefinedIfEmpty({
+ amount:
+ trimmedAmountStr == null
+ ? i18n.str`Required`
+ : !parsedAmount
+ ? i18n.str`Invalid`
+ : Amounts.cmp(limit, parsedAmount) === -1
+ ? i18n.str`Balance is not enough`
+ : undefined,
+ });
+
+ async function doStart() {
+ if (!parsedAmount || !creds) return;
+ await handleError(async () => {
+ const resp = await api.createWithdrawal(creds, {
+ amount: Amounts.stringify(parsedAmount),
+ });
+ if (resp.type === "ok") {
+ const uri = parseWithdrawUri(resp.body.taler_withdraw_uri);
+ if (!uri) {
+ return notifyError(
+ i18n.str`Server responded with an invalid withdraw URI`,
+ i18n.str`Withdraw URI: ${resp.body.taler_withdraw_uri}`,
+ );
+ } else {
+ updateBankState(
+ "currentWithdrawalOperationId",
+ uri.withdrawalOperationId,
+ );
+ onOperationCreated(uri.withdrawalOperationId);
+ }
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Conflict: {
+ notify({
+ type: "error",
+ title: i18n.str`The operation was rejected due to insufficient funds`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ break;
+ }
+ case HttpStatusCode.Unauthorized: {
+ notify({
+ type: "error",
+ title: i18n.str`The operation was rejected due to insufficient funds`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ break;
+ }
+ case HttpStatusCode.NotFound: {
+ notify({
+ type: "error",
+ title: i18n.str`Account not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ break;
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ return (
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2 mt-4"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="px-4 py-6 ">
+ <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label for="withdraw-amount">{i18n.str`Amount`}</label>
+ <RefAmount
+ currency={limit.currency}
+ value={amountStr}
+ name="withdraw-amount"
+ onChange={(v) => {
+ setAmountStr(v);
+ }}
+ error={errors?.amount}
+ ref={focus ? doAutoFocus : undefined}
+ />
+ </div>
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Current balance is{" "}
+ <RenderAmount
+ value={balance}
+ spec={config.currency_specification}
+ />
+ </i18n.Translate>
+ </p>
+ {Amounts.cmp(limit, balance) > 0 ? (
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Your account allows you to withdraw{" "}
+ <RenderAmount
+ value={limit}
+ spec={config.currency_specification}
+ />
+ </i18n.Translate>
+ </p>
+ ) : undefined}
+ <div class="mt-4">
+ <div class="sm:inline">
+ <button
+ type="button"
+ name="set 50"
+ class=" inline-flex px-6 py-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ setAmountStr("50.00");
+ }}
+ >
+ 50.00
+ </button>
+ <button
+ type="button"
+ name="set 25"
+ class=" -ml-px -mr-px inline-flex px-6 py-4 text-sm items-center rounded-r-md sm:rounded-none bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ setAmountStr("25.00");
+ }}
+ >
+ 25.00
+ </button>
+ </div>
+ <div class="mt-4 sm:inline">
+ <button
+ type="button"
+ name="set 10"
+ class=" -ml-px -mr-px inline-flex px-6 py-4 text-sm items-center rounded-l-md sm:rounded-none bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ setAmountStr("10.00");
+ }}
+ >
+ 10.00
+ </button>
+ <button
+ type="button"
+ name="set 5"
+ class=" inline-flex px-6 py-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ setAmountStr("5.00");
+ }}
+ >
+ 5.00
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeCancel.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="continue"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ // disabled={isRawPayto ? !!errorsPayto : !!errorsWire}
+ onClick={(e) => {
+ e.preventDefault();
+ doStart();
+ }}
+ >
+ <i18n.Translate>Continue</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ );
+}
+
+export function WalletWithdrawForm({
+ focus,
+ limit,
+ balance,
+ routeCancel,
+ onAuthorizationRequired,
+ onOperationCreated,
+ onOperationAborted,
+ routeOperationDetails,
+}: {
+ limit: AmountJson;
+ balance: AmountJson;
+ focus?: boolean;
+ routeOperationDetails: RouteDefinition<{ wopid: string }>;
+ onAuthorizationRequired: () => void;
+ onOperationCreated: (wopid: string) => void;
+ onOperationAborted: () => void;
+ routeCancel: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = usePreferences();
+
+ return (
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <i18n.Translate>Prepare your Taler wallet</i18n.Translate>
+ </h2>
+ <p class="mt-1 text-sm text-gray-500">
+ <i18n.Translate>
+ After using your wallet you will need to confirm or cancel the
+ operation on this site.
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="col-span-2">
+ {settings.showInstallWallet && (
+ <Attention
+ title={i18n.str`You need a Taler wallet`}
+ onClose={() => {
+ updateSettings("showInstallWallet", false);
+ }}
+ >
+ <i18n.Translate>
+ If you don't have one yet you can follow the instruction in
+ </i18n.Translate>{" "}
+ <a
+ target="_blank"
+ name="wallet page"
+ rel="noreferrer noopener"
+ class="font-semibold text-blue-700 hover:text-blue-600"
+ href="https://taler.net/en/wallet.html"
+ >
+ <i18n.Translate>this page</i18n.Translate>
+ </a>
+ </Attention>
+ )}
+
+ {!settings.fastWithdrawal ? (
+ <OldWithdrawalForm
+ focus={focus}
+ routeOperationDetails={routeOperationDetails}
+ limit={limit}
+ balance={balance}
+ routeCancel={routeCancel}
+ onOperationCreated={onOperationCreated}
+ />
+ ) : (
+ <OperationState
+ currency={limit.currency}
+ onAuthorizationRequired={onAuthorizationRequired}
+ routeClose={routeCancel}
+ routeHere={routeOperationDetails}
+ onAbort={onOperationAborted}
+ // route={routeCancel}
+ />
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/WireTransfer.tsx b/packages/bank-ui/src/pages/WireTransfer.tsx
new file mode 100644
index 000000000..f45390938
--- /dev/null
+++ b/packages/bank-ui/src/pages/WireTransfer.tsx
@@ -0,0 +1,119 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Loading,
+ notifyInfo,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { useAccountDetails } from "../hooks/account.js";
+import { useSessionState } from "../hooks/session.js";
+import { LoginForm } from "./LoginForm.js";
+import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export function WireTransfer({
+ toAccount,
+ withSubject,
+ withAmount,
+ onAuthorizationRequired,
+ routeCancel,
+ routeHere,
+ onSuccess,
+}: {
+ onSuccess?: () => void;
+ routeHere: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ toAccount?: string;
+ withSubject?: string;
+ withAmount?: string;
+ routeCancel?: RouteDefinition;
+ onAuthorizationRequired: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const r = useSessionState();
+ const account = r.state.status !== "loggedOut" ? r.state.username : "admin";
+ const result = useAccountDetails(account);
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized:
+ return <LoginForm currentUser={account} />;
+ case HttpStatusCode.NotFound:
+ return <LoginForm currentUser={account} />;
+ default:
+ assertUnreachable(result);
+ }
+ }
+ const { body: data } = result;
+
+ const balance = Amounts.parseOrThrow(data.balance.amount);
+ const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
+
+ const debitThreshold = Amounts.parseOrThrow(data.debit_threshold);
+
+ if (!balance) return <Fragment />;
+
+ const limit = balanceIsDebit
+ ? Amounts.sub(debitThreshold, balance).amount
+ : Amounts.add(balance, debitThreshold).amount;
+
+ const positiveBalance = balanceIsDebit
+ ? Amounts.zeroOfAmount(balance)
+ : balance;
+ return (
+ <div class="px-4 mt-8">
+ <div class="sm:flex sm:items-center mb-4">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Make a wire transfer</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+
+ <PaytoWireTransferForm
+ withAccount={toAccount}
+ withAmount={withAmount}
+ balance={positiveBalance}
+ withSubject={withSubject}
+ routeHere={routeHere}
+ limit={limit}
+ onAuthorizationRequired={onAuthorizationRequired}
+ onSuccess={() => {
+ notifyInfo(i18n.str`Wire transfer created!`);
+ if (onSuccess) onSuccess();
+ }}
+ routeCancel={routeCancel}
+ />
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
new file mode 100644
index 000000000..853dd7bae
--- /dev/null
+++ b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
@@ -0,0 +1,425 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ HttpStatusCode,
+ PaytoUri,
+ PaytoUriIBAN,
+ PaytoUriTalerBank,
+ TalerErrorCode,
+ TranslatedString,
+ WithdrawUriResult,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ LocalNotificationBanner,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { mutate } from "swr";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useBankState } from "../hooks/bank-state.js";
+import { usePreferences } from "../hooks/preferences.js";
+import { useSessionState } from "../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { LoginForm } from "./LoginForm.js";
+import { RenderAmount } from "./PaytoWireTransferForm.js";
+
+interface Props {
+ onAborted: () => void;
+ withdrawUri: WithdrawUriResult;
+ routeHere: RouteDefinition<{ wopid: string }>;
+ details: {
+ account: PaytoUri;
+ reserve: string;
+ username: string;
+ amount: AmountJson;
+ };
+ onAuthorizationRequired: () => void;
+}
+/**
+ * Additional authentication required to complete the operation.
+ * Not providing a back button, only abort.
+ */
+export function WithdrawalConfirmationQuestion({
+ onAborted,
+ details,
+ onAuthorizationRequired,
+ routeHere,
+ withdrawUri,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreferences();
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+ const [, updateBankState] = useBankState();
+
+ const [notification, notify, handleError] = useLocalNotification();
+
+ const {
+ config,
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ async function doTransfer() {
+ await handleError(async () => {
+ if (!creds) return;
+ const resp = await api.confirmWithdrawalById(
+ creds,
+ withdrawUri.withdrawalOperationId,
+ );
+ if (resp.type === "ok") {
+ mutate(() => true); // clean any info that we have
+ if (!settings.showWithdrawalSuccess) {
+ notifyInfo(i18n.str`Wire transfer completed!`);
+ }
+ } else {
+ switch (resp.case) {
+ case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT:
+ return notify({
+ type: "error",
+ title: i18n.str`The withdrawal has been aborted previously and can't be confirmed`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return notify({
+ type: "error",
+ title: i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation id is invalid.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Your balance is not enough for the operation.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "confirm-withdrawal",
+ id: String(resp.body.challenge_id),
+ location: routeHere.url({
+ wopid: withdrawUri.withdrawalOperationId,
+ }),
+ sent: AbsoluteTime.never(),
+ request: withdrawUri.withdrawalOperationId,
+ });
+ return onAuthorizationRequired();
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ async function doCancel() {
+ await handleError(async () => {
+ if (!creds) return;
+ const resp = await api.abortWithdrawalById(
+ creds,
+ withdrawUri.withdrawalOperationId,
+ );
+ if (resp.type === "ok") {
+ onAborted();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Conflict:
+ return notify({
+ type: "error",
+ title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation id is invalid.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ default: {
+ assertUnreachable(resp);
+ }
+ }
+ }
+ });
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="bg-white shadow sm:rounded-lg">
+ <div class="px-4 py-5 sm:p-6">
+ <h3 class="text-base font-semibold text-gray-900">
+ <i18n.Translate>Confirm the withdrawal operation</i18n.Translate>
+ </h3>
+ <div class="mt-3 text-sm leading-6">
+ <ShouldBeSameUser username={details.username}>
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-2 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 mt-4">
+ <div class="w-full">
+ <div class="px-4 sm:px-0 text-sm">
+ <p>
+ <i18n.Translate>Wire transfer details</i18n.Translate>
+ </p>
+ </div>
+ <div class="mt-6 border-t border-gray-100">
+ <dl class="divide-y divide-gray-100">
+ {((): VNode => {
+ if (!details.account.isKnown) {
+ return (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>
+ Payment provider's account
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.account.targetPath}
+ </dd>
+ </div>
+ );
+ }
+ switch (details.account.targetType) {
+ case "iban": {
+ const name =
+ details.account.params["receiver-name"];
+ return (
+ <Fragment>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>
+ Payment provider's account number
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.account.iban}
+ </dd>
+ </div>
+ {name && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>
+ Payment provider's name
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {name}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+ }
+ case "x-taler-bank": {
+ const name =
+ details.account.params["receiver-name"];
+ return (
+ <Fragment>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>
+ Payment provider's account bank
+ hostname
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.account.host}
+ </dd>
+ </div>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>
+ Payment provider's account id
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.account.account}
+ </dd>
+ </div>
+ {name && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>
+ Payment provider's name
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {name}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+ }
+ case "bitcoin": {
+ const name =
+ details.account.params["receiver-name"];
+ return (
+ <Fragment>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>
+ Payment provider's account address
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.account.address}
+ </dd>
+ </div>
+ {name && (
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>
+ Payment provider's name
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {name}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+ }
+ default: {
+ assertUnreachable(details.account);
+ }
+ }
+ })()}
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Amount</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={details.amount}
+ spec={config.currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+ </div>
+ </div>
+
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <button
+ type="button"
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={doCancel}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ type="submit"
+ name="transfer"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ onClick={(e) => {
+ e.preventDefault();
+ doTransfer();
+ }}
+ >
+ <i18n.Translate>Transfer</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ </ShouldBeSameUser>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+
+export function ShouldBeSameUser({
+ username,
+ children,
+}: {
+ username: string;
+ children: ComponentChildren;
+}): VNode {
+ const { state: credentials } = useSessionState();
+ const { i18n } = useTranslationContext();
+ if (credentials.status === "loggedOut") {
+ return (
+ <Fragment>
+ <Attention type="info" title={i18n.str`Authentication required`} />
+ <LoginForm currentUser={username} fixedUser />
+ </Fragment>
+ );
+ }
+ if (credentials.username !== username) {
+ return (
+ <Fragment>
+ <Attention
+ type="warning"
+ title={i18n.str`This operation was created with other username`}
+ />
+ <LoginForm currentUser={username} fixedUser />
+ </Fragment>
+ );
+ }
+ return <Fragment>{children}</Fragment>;
+}
diff --git a/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx b/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx
new file mode 100644
index 000000000..c0c55f14b
--- /dev/null
+++ b/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx
@@ -0,0 +1,73 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util";
+import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useBankState } from "../hooks/bank-state.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
+
+export function WithdrawalOperationPage({
+ operationId,
+ onAuthorizationRequired,
+ onOperationAborted,
+ routeClose,
+ routeWithdrawalDetails,
+}: {
+ onAuthorizationRequired: () => void;
+ operationId: string;
+ purpose: "after-creation" | "after-confirmation";
+ onOperationAborted: () => void;
+ routeClose: RouteDefinition;
+ routeWithdrawalDetails: RouteDefinition<{ wopid: string }>;
+}): VNode {
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+ const uri = stringifyWithdrawUri({
+ bankIntegrationApiBaseUrl: api.getIntegrationAPI().href,
+ withdrawalOperationId: operationId,
+ });
+ const parsedUri = parseWithdrawUri(uri);
+ const { i18n } = useTranslationContext();
+ const [, updateBankState] = useBankState();
+
+ if (!parsedUri) {
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The Withdrawal URI is not valid`}
+ >
+ {uri}
+ </Attention>
+ );
+ }
+
+ return (
+ <WithdrawalQRCode
+ withdrawUri={parsedUri}
+ routeWithdrawalDetails={routeWithdrawalDetails}
+ onAuthorizationRequired={onAuthorizationRequired}
+ onOperationAborted={() => {
+ updateBankState("currentWithdrawalOperationId", undefined);
+ onOperationAborted();
+ }}
+ routeClose={routeClose}
+ />
+ );
+}
diff --git a/packages/bank-ui/src/pages/WithdrawalQRCode.tsx b/packages/bank-ui/src/pages/WithdrawalQRCode.tsx
new file mode 100644
index 000000000..b61f0cc8f
--- /dev/null
+++ b/packages/bank-ui/src/pages/WithdrawalQRCode.tsx
@@ -0,0 +1,310 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ WithdrawUriResult,
+ assertUnreachable,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ notifyInfo,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { useWithdrawalDetails } from "../hooks/account.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { QrCodeSection } from "./QrCodeSection.js";
+import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js";
+
+interface Props {
+ withdrawUri: WithdrawUriResult;
+ onOperationAborted: () => void;
+ routeClose: RouteDefinition;
+ routeWithdrawalDetails: RouteDefinition<{ wopid: string }>;
+ onAuthorizationRequired: () => void;
+}
+/**
+ * Offer the QR code (and a clickable taler://-link) to
+ * permit the passing of exchange and reserve details to
+ * the bank. Poll the backend until such operation is done.
+ */
+export function WithdrawalQRCode({
+ withdrawUri,
+ onOperationAborted,
+ routeClose,
+ routeWithdrawalDetails,
+ onAuthorizationRequired,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId);
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.NotFound:
+ return <OperationNotFound routeClose={routeClose} />;
+ default:
+ assertUnreachable(result);
+ }
+ }
+
+ const { body: data } = result;
+
+ if (data.status === "aborted") {
+ return (
+ <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
+ <div>
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
+ <svg
+ class="h-5 w-5 text-yellow-400"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </div>
+ <div class="mt-3 text-center sm:mt-5">
+ <h3
+ class="text-base font-semibold leading-6 text-gray-900"
+ id="modal-title"
+ >
+ <i18n.Translate>Operation aborted</i18n.Translate>
+ </h3>
+ <div class="mt-2">
+ <p class="text-sm text-gray-500">
+ <i18n.Translate>
+ The wire transfer to the payment provider's account was
+ aborted from somewhere else, your balance was not affected.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeClose.url({})}
+ name="continue"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Continue</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ );
+ }
+
+ if (data.status === "confirmed") {
+ return (
+ <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
+ <div>
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
+ <svg
+ class="h-6 w-6 text-green-600"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M4.5 12.75l6 6 9-13.5"
+ />
+ </svg>
+ </div>
+ <div class="mt-3 text-center sm:mt-5">
+ <h3
+ class="text-base font-semibold leading-6 text-gray-900"
+ id="modal-title"
+ >
+ <i18n.Translate>Withdrawal confirmed</i18n.Translate>
+ </h3>
+ <div class="mt-2">
+ <p class="text-sm text-gray-500">
+ <i18n.Translate>
+ The wire transfer to the Taler operator has been initiated.
+ You will soon receive the requested amount in your Taler
+ wallet.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeClose.url({})}
+ name="done"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Done</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ );
+ }
+
+ if (data.status === "pending") {
+ return (
+ <QrCodeSection
+ withdrawUri={withdrawUri}
+ onAborted={() => {
+ notifyInfo(i18n.str`Operation canceled`);
+ onOperationAborted();
+ }}
+ />
+ );
+ }
+
+ const account = !data.selected_exchange_account
+ ? undefined
+ : parsePaytoUri(data.selected_exchange_account);
+
+ if (!data.selected_reserve_pub && account) {
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The operation is marked as 'selected' but some step in the withdrawal failed`}
+ >
+ <i18n.Translate>
+ The account is selected but no withdrawal identification found.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+
+ if (!account && data.selected_reserve_pub) {
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The operation is marked as 'selected' but some step in the withdrawal failed`}
+ >
+ <i18n.Translate>
+ There is a withdrawal identification but no account has been selected
+ or the selected account is invalid.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+
+ if (!account || !data.selected_reserve_pub) {
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The operation is marked as 'selected' but some step in the withdrawal failed`}
+ >
+ <i18n.Translate>
+ No withdrawal ID found and no account has been selected or the
+ selected account is invalid.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+
+ return (
+ <WithdrawalConfirmationQuestion
+ withdrawUri={withdrawUri}
+ routeHere={routeWithdrawalDetails}
+ details={{
+ username: data.username,
+ account,
+ reserve: data.selected_reserve_pub,
+ amount: Amounts.parseOrThrow(data.amount),
+ }}
+ onAuthorizationRequired={onAuthorizationRequired}
+ onAborted={() => {
+ notifyInfo(i18n.str`Operation canceled`);
+ onOperationAborted();
+ }}
+ />
+ );
+}
+
+export function OperationNotFound({
+ routeClose,
+}: {
+ routeClose: RouteDefinition | undefined;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
+ <div>
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 ">
+ <svg
+ class="h-6 w-6 text-red-600"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
+ />
+ </svg>
+ </div>
+
+ <div class="mt-3 text-center sm:mt-5">
+ <h3
+ class="text-base font-semibold leading-6 text-gray-900"
+ id="modal-title"
+ >
+ <i18n.Translate>Operation not found</i18n.Translate>
+ </h3>
+ <div class="mt-2">
+ <p class="text-sm text-gray-500">
+ <i18n.Translate>
+ This operation is not known by the server. The operation id is
+ wrong or the server deleted the operation information before
+ reaching here.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ {routeClose && (
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeClose.url({})}
+ name="continue to dashboard"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Cotinue to dashboard</i18n.Translate>
+ </a>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx
new file mode 100644
index 000000000..301978eaa
--- /dev/null
+++ b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx
@@ -0,0 +1,86 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { Cashouts } from "../../components/Cashouts/index.js";
+import { useSessionState } from "../../hooks/session.js";
+import { ProfileNavigation } from "../ProfileNavigation.js";
+import { CreateCashout } from "../regional/CreateCashout.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+interface Props {
+ account: string;
+ routeClose: RouteDefinition;
+ onAuthorizationRequired: () => void;
+ routeCashoutDetails: RouteDefinition<{ cid: string }>;
+ routeMyAccountDetails: RouteDefinition;
+ routeMyAccountDelete: RouteDefinition;
+ routeMyAccountPassword: RouteDefinition;
+ routeMyAccountCashout: RouteDefinition;
+ routeCreateCashout: RouteDefinition;
+ routeConversionConfig: RouteDefinition;
+}
+
+export function CashoutListForAccount({
+ account,
+ onAuthorizationRequired,
+ routeCreateCashout,
+ routeCashoutDetails,
+ routeMyAccountCashout,
+ routeMyAccountDelete,
+ routeMyAccountDetails,
+ routeConversionConfig,
+ routeMyAccountPassword,
+ routeClose,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const { state: credentials } = useSessionState();
+
+ const accountIsTheCurrentUser =
+ credentials.status === "loggedIn"
+ ? credentials.username === account
+ : false;
+
+ return (
+ <Fragment>
+ {accountIsTheCurrentUser ? (
+ <ProfileNavigation
+ current="cashouts"
+ routeMyAccountCashout={routeMyAccountCashout}
+ routeMyAccountDelete={routeMyAccountDelete}
+ routeMyAccountDetails={routeMyAccountDetails}
+ routeMyAccountPassword={routeMyAccountPassword}
+ routeConversionConfig={routeConversionConfig}
+ />
+ ) : (
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Cashout for account {account}</i18n.Translate>
+ </h1>
+ )}
+
+ <CreateCashout
+ focus
+ routeHere={routeCreateCashout}
+ routeClose={routeClose}
+ onAuthorizationRequired={onAuthorizationRequired}
+ account={account}
+ />
+
+ <Cashouts account={account} routeCashoutDetails={routeCashoutDetails} />
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx
new file mode 100644
index 000000000..69a186ca1
--- /dev/null
+++ b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx
@@ -0,0 +1,491 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ TalerCorebankApi,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ parsePaytoUri
+} from "@gnu-taler/taler-util";
+import {
+ CopyButton,
+ Loading,
+ LocalNotificationBanner,
+ RouteDefinition,
+ notifyInfo,
+ useBankCoreApiContext,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useAccountDetails } from "../../hooks/account.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { useSessionState } from "../../hooks/session.js";
+import { LoginForm } from "../LoginForm.js";
+import { ProfileNavigation } from "../ProfileNavigation.js";
+import { AccountForm } from "../admin/AccountForm.js";
+
+export function ShowAccountDetails({
+ account,
+ routeClose,
+ onUpdateSuccess,
+ onAuthorizationRequired,
+ routeMyAccountCashout,
+ routeMyAccountDelete,
+ routeMyAccountDetails,
+ routeHere,
+ routeMyAccountPassword,
+ routeConversionConfig,
+}: {
+ routeClose: RouteDefinition;
+ routeHere: RouteDefinition<{ account: string }>;
+ routeMyAccountDetails: RouteDefinition;
+ routeMyAccountDelete: RouteDefinition;
+ routeMyAccountPassword: RouteDefinition;
+ routeMyAccountCashout: RouteDefinition;
+ routeConversionConfig: RouteDefinition;
+ onUpdateSuccess: () => void;
+ onAuthorizationRequired: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+ const {
+ lib: { bank },
+ } = useBankCoreApiContext();
+ const accountIsTheCurrentUser =
+ credentials.status === "loggedIn"
+ ? credentials.username === account
+ : false;
+
+ const [submitAccount, setSubmitAccount] = useState<
+ TalerCorebankApi.AccountReconfiguration | undefined
+ >();
+ const [notification, notify, handleError] = useLocalNotification();
+ const [, updateBankState] = useBankState();
+
+ const result = useAccountDetails(account);
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.NotFound:
+ return <LoginForm currentUser={account} />;
+ default:
+ assertUnreachable(result);
+ }
+ }
+
+ async function doUpdate() {
+ if (!submitAccount || !creds) return;
+ await handleError(async () => {
+ const resp = await bank.updateAccount(
+ {
+ token: creds.token,
+ username: account,
+ },
+ submitAccount,
+ );
+
+ if (resp.type === "ok") {
+ notifyInfo(i18n.str`Account updated`);
+ onUpdateSuccess();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`The rights to change the account are not sufficient`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The username was not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME:
+ return notify({
+ type: "error",
+ title: i18n.str`You can't change the legal name, please contact the your account administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
+ return notify({
+ type: "error",
+ title: i18n.str`You can't change the debt limit, please contact the your account administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT:
+ return notify({
+ type: "error",
+ title: i18n.str`You can't change the cashout address, please contact the your account administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_MISSING_TAN_INFO:
+ return notify({
+ type: "error",
+ title: i18n.str`No information for the selected authentication channel.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "update-account",
+ id: String(resp.body.challenge_id),
+ location: routeHere.url({ account }),
+ sent: AbsoluteTime.never(),
+ request: submitAccount,
+ });
+ return onAuthorizationRequired();
+ }
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: {
+ return notify({
+ type: "error",
+ title: i18n.str`Authentication channel is not supported.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ const url = bank.getRevenueAPI(account);
+ url.username = account;
+ const baseURL = url.href;
+
+ const ac = parsePaytoUri(result.body.payto_uri);
+ const payto = !ac?.isKnown ? undefined : ac;
+ let accountLetter: string | undefined = undefined;
+ if (payto) {
+ switch (payto.targetType) {
+ case "iban": {
+ accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\niban=${payto.iban}\nreceiver-name=${result.body.name}\n`;
+ break;
+ }
+ case "x-taler-bank": {
+ accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\naccount=${payto.account}\nhost=${payto.host}\nreceiver-name=${result.body.name}\n`;
+ break;
+ }
+ case "bitcoin": {
+ accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\naddress=${payto.address}\nreceiver-name=${result.body.name}\n`;
+ break;
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} showDebug={true} />
+ {accountIsTheCurrentUser ? (
+ <ProfileNavigation
+ current="details"
+ routeMyAccountCashout={routeMyAccountCashout}
+ routeMyAccountDelete={routeMyAccountDelete}
+ routeConversionConfig={routeConversionConfig}
+ routeMyAccountDetails={routeMyAccountDetails}
+ routeMyAccountPassword={routeMyAccountPassword}
+ />
+ ) : (
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Account "{account}"</i18n.Translate>
+ </h1>
+ )}
+
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-semibold leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Change details</i18n.Translate>
+ </span>
+ </span>
+ </div>
+ </h2>
+ </div>
+
+ <AccountForm
+ focus={true}
+ username={account}
+ template={result.body}
+ purpose="update"
+ onChange={(a) => setSubmitAccount(a)}
+ >
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeClose.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="update"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!submitAccount}
+ onClick={doUpdate}
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+ </div>
+ </AccountForm>
+ </div>
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-semibold leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Merchant integration</i18n.Translate>
+ </span>
+ </span>
+ </div>
+ </h2>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Use this information to link your Taler Merchant Backoffice
+ account with the current bank account. You can start by copying
+ the values, then go to your merchant backoffice service provider,
+ login into your account and look for the "import" button in the
+ "bank account" section.
+ </i18n.Translate>
+ </p>
+ </div>
+
+ {payto !== undefined && (
+ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
+ <div class="px-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="account-type"
+ >
+ {i18n.str`Account type`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="account-type"
+ id="account-type"
+ disabled={true}
+ value={account}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Method to use for wire transfer.
+ </i18n.Translate>
+ </p>
+ </div>
+ {((payto) => {
+ switch (payto.targetType) {
+ case "iban": {
+ return (
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="iban"
+ >
+ {i18n.str`IBAN`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="iban"
+ id="iban"
+ disabled={true}
+ value={payto.iban}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ International Bank Account Number.
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+ }
+ case "x-taler-bank": {
+ return (
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="iban"
+ >
+ {i18n.str`IBAN`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="iban"
+ id="iban"
+ disabled={true}
+ value={payto.account}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ International Bank Account Number.
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+ }
+ case "bitcoin": {
+ return (
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="iban"
+ >
+ {i18n.str`Address`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="iban"
+ id="iban"
+ disabled={true}
+ value={"DE1231231231"}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ International Bank Account Number.
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+ }
+ }
+ })(payto)}
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="iban"
+ >
+ {i18n.str`Owner's name`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="iban"
+ id="iban"
+ disabled={true}
+ value={result.body.name}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Legal name of the person holding the account.
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="iban"
+ >
+ {i18n.str`Account info URL`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="iban"
+ id="iban"
+ disabled={true}
+ value={baseURL}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ From where the merchant can download information about
+ incoming wire transfers to this account.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeClose.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <CopyButton
+ getContent={() => accountLetter ?? ""}
+ class="flex text-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Copy</i18n.Translate>
+ </CopyButton>
+ </div>
+ </div>
+ )}
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx
new file mode 100644
index 000000000..2724fba11
--- /dev/null
+++ b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx
@@ -0,0 +1,319 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../../hooks/session.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../../utils.js";
+import { doAutoFocus } from "../PaytoWireTransferForm.js";
+import { ProfileNavigation } from "../ProfileNavigation.js";
+
+export function UpdateAccountPassword({
+ account: accountName,
+ routeClose,
+ onUpdateSuccess,
+ onAuthorizationRequired,
+ routeMyAccountCashout,
+ routeMyAccountDelete,
+ routeMyAccountDetails,
+ routeMyAccountPassword,
+ routeConversionConfig,
+ focus,
+ routeHere,
+}: {
+ routeClose: RouteDefinition;
+ routeHere: RouteDefinition<{ account: string }>;
+ routeMyAccountDetails: RouteDefinition;
+ routeMyAccountDelete: RouteDefinition;
+ routeMyAccountPassword: RouteDefinition;
+ routeMyAccountCashout: RouteDefinition;
+ routeConversionConfig: RouteDefinition;
+ focus?: boolean;
+ onAuthorizationRequired: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { state: credentials } = useSessionState();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ const [current, setCurrent] = useState<string | undefined>();
+ const [password, setPassword] = useState<string | undefined>();
+ const [repeat, setRepeat] = useState<string | undefined>();
+ const [, updateBankState] = useBankState();
+
+ const accountIsTheCurrentUser =
+ credentials.status === "loggedIn"
+ ? credentials.username === accountName
+ : false;
+
+ const errors = undefinedIfEmpty({
+ current: !accountIsTheCurrentUser
+ ? undefined
+ : !current
+ ? i18n.str`Required`
+ : undefined,
+ password: !password ? i18n.str`Required` : undefined,
+ repeat: !repeat
+ ? i18n.str`Required`
+ : password !== repeat
+ ? i18n.str`Repeated password doesn't match`
+ : undefined,
+ });
+ const [notification, notify, handleError] = useLocalNotification();
+
+ async function doChangePassword() {
+ if (!!errors || !password || !token) return;
+ await handleError(async () => {
+ const request = {
+ old_password: current,
+ new_password: password,
+ };
+ const resp = await api.updatePassword(
+ { username: accountName, token },
+ request,
+ );
+ if (resp.type === "ok") {
+ notifyInfo(i18n.str`Password changed`);
+ onUpdateSuccess();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`Not authorized to change the password, maybe the session is invalid.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`Account not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD:
+ return notify({
+ type: "error",
+ title: i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD:
+ return notify({
+ type: "error",
+ title: i18n.str`Your current password doesn't match, can't change to a new password.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "update-password",
+ id: String(resp.body.challenge_id),
+ location: routeHere.url({ account: accountName }),
+ sent: AbsoluteTime.never(),
+ request,
+ });
+ return onAuthorizationRequired();
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+ {accountIsTheCurrentUser ? (
+ <ProfileNavigation
+ current="credentials"
+ routeMyAccountCashout={routeMyAccountCashout}
+ routeMyAccountDelete={routeMyAccountDelete}
+ routeMyAccountDetails={routeMyAccountDetails}
+ routeMyAccountPassword={routeMyAccountPassword}
+ routeConversionConfig={routeConversionConfig}
+ />
+ ) : (
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Account "{accountName}"</i18n.Translate>
+ </h1>
+ )}
+
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <i18n.Translate>Update password</i18n.Translate>
+ </h2>
+ </div>
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ {accountIsTheCurrentUser ? (
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="password"
+ >
+ {i18n.str`Current password`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <input
+ type="password"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="current"
+ id="current-password"
+ data-error={!!errors?.current && current !== undefined}
+ value={current ?? ""}
+ onChange={(e) => {
+ setCurrent(e.currentTarget.value);
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.current}
+ isDirty={current !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Your current password, for security
+ </i18n.Translate>
+ </p>
+ </div>
+ ) : undefined}
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="password"
+ >
+ {i18n.str`New password`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="password"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="password"
+ id="password"
+ data-error={!!errors?.password && password !== undefined}
+ value={password ?? ""}
+ onChange={(e) => {
+ setPassword(e.currentTarget.value);
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </div>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="repeat"
+ >
+ {i18n.str`Type it again`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <input
+ type="password"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="repeat"
+ id="repeat"
+ data-error={!!errors?.repeat && repeat !== undefined}
+ value={repeat ?? ""}
+ onChange={(e) => {
+ setRepeat(e.currentTarget.value);
+ }}
+ // placeholder=""
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.repeat}
+ isDirty={repeat !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Repeat the same password</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeClose.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="change"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!!errors}
+ onClick={(e) => {
+ e.preventDefault();
+ doChangePassword();
+ }}
+ >
+ <i18n.Translate>Change</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx
new file mode 100644
index 000000000..c8195ddb0
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/AccountForm.tsx
@@ -0,0 +1,804 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AmountString,
+ Amounts,
+ PaytoString,
+ TalerCorebankApi,
+ assertUnreachable,
+ buildPayto,
+ parsePaytoUri,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ CopyButton,
+ ShowInputErrorLabel,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../../hooks/session.js";
+import {
+ ErrorMessageMappingFor,
+ TanChannel,
+ undefinedIfEmpty,
+ validateIBAN,
+ validateTalerBank,
+} from "../../utils.js";
+import {
+ InputAmount,
+ TextField,
+ doAutoFocus,
+} from "../PaytoWireTransferForm.js";
+import { getRandomPassword } from "../rnd.js";
+
+const EMAIL_REGEX =
+ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/;
+
+export type AccountFormData = {
+ debit_threshold?: string;
+ isExchange?: boolean;
+ isPublic?: boolean;
+ name?: string;
+ username?: string;
+ payto_uri?: string;
+ cashout_payto_uri?: string;
+ email?: string;
+ phone?: string;
+ tan_channel?: TanChannel | "remove";
+};
+
+type ChangeByPurposeType = {
+ create: (a: TalerCorebankApi.RegisterAccountRequest | undefined) => void;
+ update: (a: TalerCorebankApi.AccountReconfiguration | undefined) => void;
+ show: undefined;
+};
+/**
+ * FIXME:
+ * is_public is missing on PATCH
+ * account email/password should require 2FA
+ *
+ *
+ * @param param0
+ * @returns
+ */
+export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({
+ template,
+ username,
+ purpose,
+ onChange,
+ focus,
+ children,
+}: {
+ focus?: boolean;
+ children: ComponentChildren;
+ username?: string;
+ template: TalerCorebankApi.AccountData | undefined;
+ onChange: ChangeByPurposeType[PurposeType];
+ purpose: PurposeType;
+}): VNode {
+ const { config, url } = useBankCoreApiContext();
+ const { i18n } = useTranslationContext();
+ const { state: credentials } = useSessionState();
+ const [form, setForm] = useState<AccountFormData>({});
+
+ const [errors, setErrors] = useState<
+ ErrorMessageMappingFor<typeof defaultValue> | undefined
+ >(undefined);
+
+ const paytoType =
+ config.wire_type === "X_TALER_BANK"
+ ? ("x-taler-bank" as const)
+ : ("iban" as const);
+ const cashoutPaytoType: typeof paytoType = "iban" as const;
+
+ const defaultValue: AccountFormData = {
+ debit_threshold: Amounts.stringifyValue(
+ template?.debit_threshold ?? config.default_debit_threshold,
+ ),
+ isExchange: template?.is_taler_exchange,
+ isPublic: template?.is_public,
+ name: template?.name ?? "",
+ cashout_payto_uri:
+ getAccountId(cashoutPaytoType, template?.cashout_payto_uri) ??
+ ("" as PaytoString),
+ payto_uri:
+ getAccountId(paytoType, template?.payto_uri) ?? ("" as PaytoString),
+ email: template?.contact_data?.email ?? "",
+ phone: template?.contact_data?.phone ?? "",
+ username: username ?? "",
+ tan_channel: template?.tan_channel,
+ };
+
+ const userIsAdmin =
+ credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator;
+
+ const editableUsername = purpose === "create";
+ const editableName =
+ purpose === "create" ||
+ (purpose === "update" && (config.allow_edit_name || userIsAdmin));
+
+ const isCashoutEnabled = config.allow_conversion;
+ const editableCashout =
+ purpose === "create" ||
+ (purpose === "update" &&
+ (config.allow_edit_cashout_payto_uri || userIsAdmin));
+ const editableThreshold =
+ userIsAdmin && (purpose === "create" || purpose === "update");
+ const editableAccount = purpose === "create" && userIsAdmin;
+
+ function updateForm(newForm: typeof defaultValue): void {
+ const trimmedAmountStr = newForm.debit_threshold?.trim();
+ const parsedAmount = Amounts.parse(
+ `${config.currency}:${trimmedAmountStr}`,
+ );
+
+ const errors = undefinedIfEmpty<
+ ErrorMessageMappingFor<typeof defaultValue>
+ >({
+ cashout_payto_uri: !newForm.cashout_payto_uri
+ ? undefined
+ : !editableCashout
+ ? undefined
+ : !newForm.cashout_payto_uri
+ ? undefined
+ : cashoutPaytoType === "iban"
+ ? validateIBAN(newForm.cashout_payto_uri, i18n)
+ : cashoutPaytoType === "x-taler-bank"
+ ? validateTalerBank(newForm.cashout_payto_uri, i18n)
+ : undefined,
+
+ payto_uri: !newForm.payto_uri
+ ? undefined
+ : !editableAccount
+ ? undefined
+ : !newForm.payto_uri
+ ? undefined
+ : paytoType === "iban"
+ ? validateIBAN(newForm.payto_uri, i18n)
+ : paytoType === "x-taler-bank"
+ ? validateTalerBank(newForm.payto_uri, i18n)
+ : undefined,
+
+ email: !newForm.email
+ ? undefined
+ : !EMAIL_REGEX.test(newForm.email)
+ ? i18n.str`Doesn't have the pattern of an email`
+ : undefined,
+ phone: !newForm.phone
+ ? undefined
+ : !newForm.phone.startsWith("+") // FIXME: better phone number check
+ ? i18n.str`Should start with +`
+ : !REGEX_JUST_NUMBERS_REGEX.test(newForm.phone)
+ ? i18n.str`Phone number can't have other than numbers`
+ : undefined,
+ debit_threshold: !editableThreshold
+ ? undefined
+ : !trimmedAmountStr
+ ? undefined
+ : !parsedAmount
+ ? i18n.str`Not valid`
+ : undefined,
+ name: !editableName
+ ? undefined // disabled
+ : !newForm.name
+ ? i18n.str`Required`
+ : undefined,
+ username: !editableUsername
+ ? undefined
+ : !newForm.username
+ ? i18n.str`Required`
+ : undefined,
+ });
+ setErrors(errors);
+
+ setForm(newForm);
+ if (!onChange) return;
+
+ if (errors) {
+ onChange(undefined);
+ } else {
+ let cashout;
+ if (newForm.cashout_payto_uri)
+ switch (cashoutPaytoType) {
+ case "x-taler-bank": {
+ cashout = buildPayto(
+ "x-taler-bank",
+ url.host,
+ newForm.cashout_payto_uri,
+ );
+ break;
+ }
+ case "iban": {
+ cashout = buildPayto("iban", newForm.cashout_payto_uri, undefined);
+ break;
+ }
+ default:
+ assertUnreachable(cashoutPaytoType);
+ }
+ const cashoutURI = !cashout ? undefined : stringifyPaytoUri(cashout);
+ let internal;
+ if (newForm.payto_uri)
+ switch (paytoType) {
+ case "x-taler-bank": {
+ internal = buildPayto("x-taler-bank", url.host, newForm.payto_uri);
+ break;
+ }
+ case "iban": {
+ internal = buildPayto("iban", newForm.payto_uri, undefined);
+ break;
+ }
+ default:
+ assertUnreachable(paytoType);
+ }
+ const internalURI = !internal ? undefined : stringifyPaytoUri(internal);
+
+ const threshold = !parsedAmount
+ ? undefined
+ : Amounts.stringify(parsedAmount);
+
+ switch (purpose) {
+ case "create": {
+ // typescript doesn't correctly narrow a generic type
+ const callback = onChange as ChangeByPurposeType["create"];
+ const result: TalerCorebankApi.RegisterAccountRequest = {
+ name: newForm.name!,
+ password: getRandomPassword(),
+ username: newForm.username!,
+ contact_data: undefinedIfEmpty({
+ email: !newForm.email ? undefined : newForm.email,
+ phone: !newForm.phone ? undefined : newForm.phone,
+ }),
+ debit_threshold: threshold ?? config.default_debit_threshold,
+ cashout_payto_uri: cashoutURI,
+ payto_uri: internalURI,
+ is_public: newForm.isPublic,
+ is_taler_exchange: newForm.isExchange,
+ tan_channel:
+ newForm.tan_channel === "remove"
+ ? undefined
+ : newForm.tan_channel,
+ };
+ callback(result);
+ return;
+ }
+ case "update": {
+ // typescript doesn't correctly narrow a generic type
+ const callback = onChange as ChangeByPurposeType["update"];
+
+ const result: TalerCorebankApi.AccountReconfiguration = {
+ cashout_payto_uri: cashoutURI,
+ contact_data: undefinedIfEmpty({
+ email: !newForm.email ? undefined : newForm.email,
+ phone: !newForm.phone ? undefined : newForm.phone,
+ }),
+ debit_threshold: threshold,
+ is_public: newForm.isPublic,
+ name: newForm.name,
+ tan_channel:
+ newForm.tan_channel === "remove" ? null : newForm.tan_channel,
+ };
+ callback(result);
+ return;
+ }
+ case "show": {
+ return;
+ }
+ default: {
+ assertUnreachable(purpose);
+ }
+ }
+ }
+ }
+ return (
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="username"
+ >
+ {i18n.str`Login username`}
+ {editableUsername && <b style={{ color: "red" }}> *</b>}
+ </label>
+ <div class="mt-2">
+ <input
+ ref={focus && purpose === "create" ? doAutoFocus : undefined}
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="username"
+ id="username"
+ data-error={!!errors?.username && form.username !== undefined}
+ disabled={!editableUsername}
+ value={form.username ?? defaultValue.username}
+ onChange={(e) => {
+ form.username = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ // placeholder=""
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.username}
+ isDirty={form.username !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Account id for authentication</i18n.Translate>
+ </p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="name"
+ >
+ {i18n.str`Full name`}
+ {editableName && <b style={{ color: "red" }}> *</b>}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="name"
+ data-error={!!errors?.name && form.name !== undefined}
+ id="name"
+ disabled={!editableName}
+ value={form.name ?? defaultValue.name}
+ onChange={(e) => {
+ form.name = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ // placeholder=""
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.name}
+ isDirty={form.name !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Name of the account holder</i18n.Translate>
+ </p>
+ </div>
+
+ {purpose === "create" ? undefined : (
+ <TextField
+ id="internal-account"
+ label={i18n.str`Internal account`}
+ help={
+ purpose === "create"
+ ? i18n.str`If empty a random account id will be assigned`
+ : i18n.str`Share this id to receive bank transfers`
+ }
+ error={errors?.payto_uri}
+ onChange={(e) => {
+ form.payto_uri = e as PaytoString;
+ updateForm(structuredClone(form));
+ }}
+ rightIcons={
+ <CopyButton
+ class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 "
+ getContent={() =>
+ form.payto_uri ?? defaultValue.payto_uri ?? ""
+ }
+ />
+ }
+ value={(form.payto_uri ?? defaultValue.payto_uri) as PaytoString}
+ disabled={!editableAccount}
+ />
+ )}
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="email"
+ >
+ {i18n.str`Email`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="email"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="email"
+ id="email"
+ data-error={!!errors?.email && form.email !== undefined}
+ disabled={purpose === "show"}
+ value={form.email ?? defaultValue.email}
+ onChange={(e) => {
+ form.email = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.email}
+ isDirty={form.email !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ To be used when second factor authentication is enabled
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="phone"
+ >
+ {i18n.str`Phone`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="phone"
+ id="phone"
+ disabled={purpose === "show"}
+ value={form.phone ?? defaultValue.phone}
+ data-error={!!errors?.phone && form.phone !== undefined}
+ onChange={(e) => {
+ form.phone = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.phone}
+ isDirty={form.phone !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ To be used when second factor authentication is enabled
+ </i18n.Translate>
+ </p>
+ </div>
+
+ {isCashoutEnabled && (
+ <TextField
+ id="cashout-account"
+ label={i18n.str`Cashout account`}
+ help={i18n.str`External account number where the money is going to be sent when doing cashouts`}
+ error={errors?.cashout_payto_uri}
+ onChange={(e) => {
+ form.cashout_payto_uri = e as PaytoString;
+ updateForm(structuredClone(form));
+ }}
+ value={
+ (form.cashout_payto_uri ??
+ defaultValue.cashout_payto_uri) as PaytoString
+ }
+ disabled={!editableCashout}
+ />
+ )}
+
+ <div class="sm:col-span-5">
+ <label
+ for="debit"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Max debt`}</label>
+ <InputAmount
+ name="debit"
+ left
+ currency={config.currency}
+ value={form.debit_threshold ?? defaultValue.debit_threshold}
+ onChange={
+ !editableThreshold
+ ? undefined
+ : (e) => {
+ form.debit_threshold = e as AmountString;
+ updateForm(structuredClone(form));
+ }
+ }
+ />
+ <ShowInputErrorLabel
+ message={
+ errors?.debit_threshold
+ ? String(errors?.debit_threshold)
+ : undefined
+ }
+ isDirty={form.debit_threshold !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ How much the balance can go below zero.
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Is this account public?</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name="is public"
+ data-enabled={
+ form.isPublic ?? defaultValue.isPublic ? "true" : "false"
+ }
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ form.isPublic = !(form.isPublic ?? defaultValue.isPublic);
+ updateForm(structuredClone(form));
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={
+ form.isPublic ?? defaultValue.isPublic ? "true" : "false"
+ }
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Public accounts have their balance publicly accessible
+ </i18n.Translate>
+ </p>
+ </div>
+
+ {purpose !== "create" || !userIsAdmin ? undefined : (
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>
+ Is this account a payment provider?
+ </i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name="is exchange"
+ data-enabled={
+ form.isExchange ?? defaultValue.isExchange
+ ? "true"
+ : "false"
+ }
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ form.isExchange = !form.isExchange;
+ updateForm(structuredClone(form));
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={
+ form.isExchange ?? defaultValue.isExchange
+ ? "true"
+ : "false"
+ }
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ {children}
+ </form>
+ );
+}
+
+function getAccountId(
+ type: "iban" | "x-taler-bank",
+ s: PaytoString | undefined,
+): string | undefined {
+ if (s === undefined) return undefined;
+ const p = parsePaytoUri(s);
+ if (p === undefined) return undefined;
+ if (!p.isKnown) return "<unknown>";
+ if (type === "iban" && p.targetType === "iban") return p.iban;
+ if (type === "x-taler-bank" && p.targetType === "x-taler-bank")
+ return p.account;
+ return "<unsupported>";
+}
+
+{
+ /* <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="cashout"
+ >
+ {}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ ref={focus && purpose === "update" ? doAutoFocus : undefined}
+ data-error={!!errors?.cashout_payto_uri && form.cashout_payto_uri !== undefined}
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="cashout"
+ id="cashout"
+ disabled={purpose === "show"}
+ value={form.cashout_payto_uri ?? defaultValue.cashout_payto_uri}
+ onChange={(e) => {
+ form.cashout_payto_uri = e.currentTarget.value as PaytoString;
+ if (!form.cashout_payto_uri) {
+ form.cashout_payto_uri = undefined
+ }
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.cashout_payto_uri}
+ isDirty={form.cashout_payto_uri !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500" >
+ <i18n.Translate></i18n.Translate>
+ </p>
+ </div> */
+}
+
+// function PaytoField({
+// name,
+// label,
+// help,
+// type,
+// value,
+// disabled,
+// onChange,
+// error,
+// }: {
+// error: TranslatedString | undefined;
+// name: string;
+// label: TranslatedString;
+// help: TranslatedString;
+// onChange: (s: string) => void;
+// type: "iban" | "x-taler-bank" | "bitcoin";
+// disabled?: boolean;
+// value: string | undefined;
+// }): VNode {
+// if (type === "iban") {
+// return (
+// <div class="sm:col-span-5">
+// <label
+// class="block text-sm font-medium leading-6 text-gray-900"
+// for={name}
+// >
+// {label}
+// </label>
+// <div class="mt-2">
+// <div class="flex justify-between">
+// <input
+// type="text"
+// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+// name={name}
+// id={name}
+// disabled={disabled}
+// value={value ?? ""}
+// onChange={(e) => {
+// onChange(e.currentTarget.value);
+// }}
+// />
+// <CopyButton
+// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 "
+// getContent={() => value ?? ""}
+// />
+// </div>
+// <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
+// </div>
+// <p class="mt-2 text-sm text-gray-500">{help}</p>
+// </div>
+// );
+// }
+// if (type === "x-taler-bank") {
+// return (
+// <div class="sm:col-span-5">
+// <label
+// class="block text-sm font-medium leading-6 text-gray-900"
+// for={name}
+// >
+// {label}
+// </label>
+// <div class="mt-2">
+// <div class="flex justify-between">
+// <input
+// type="text"
+// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+// name={name}
+// id={name}
+// disabled={disabled}
+// value={value ?? ""}
+// onChange={(e) => {
+// onChange(e.currentTarget.value);
+// }}
+// />
+// <CopyButton
+// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 "
+// getContent={() => value ?? ""}
+// />
+// </div>
+// <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
+// </div>
+// <p class="mt-2 text-sm text-gray-500">
+// {help}
+// </p>
+// </div>
+// );
+// }
+// if (type === "bitcoin") {
+// return (
+// <div class="sm:col-span-5">
+// <label
+// class="block text-sm font-medium leading-6 text-gray-900"
+// for={name}
+// >
+// {label}
+// </label>
+// <div class="mt-2">
+// <div class="flex justify-between">
+// <input
+// type="text"
+// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+// name={name}
+// id={name}
+// disabled={disabled}
+// value={value ?? ""}
+// />
+// <CopyButton
+// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 "
+// getContent={() => value ?? ""}
+// />
+// <ShowInputErrorLabel
+// message={error}
+// isDirty={value !== undefined}
+// />
+// </div>
+// </div>
+// <p class="mt-2 text-sm text-gray-500">
+// {/* <i18n.Translate>bitcoin address</i18n.Translate> */}
+// {help}
+// </p>
+// </div>
+// );
+// }
+// assertUnreachable(type);
+// }
diff --git a/packages/bank-ui/src/pages/admin/AccountList.tsx b/packages/bank-ui/src/pages/admin/AccountList.tsx
new file mode 100644
index 000000000..6402c2bcd
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/AccountList.tsx
@@ -0,0 +1,234 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useBusinessAccounts } from "../../hooks/regional.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { RenderAmount } from "../PaytoWireTransferForm.js";
+
+interface Props {
+ routeCreate: RouteDefinition;
+
+ routeShowAccount: RouteDefinition<{ account: string }>;
+ routeRemoveAccount: RouteDefinition<{ account: string }>;
+ routeUpdatePasswordAccount: RouteDefinition<{ account: string }>;
+}
+
+export function AccountList({
+ routeCreate,
+ routeRemoveAccount,
+ routeShowAccount,
+ routeUpdatePasswordAccount,
+}: Props): VNode {
+ const result = useBusinessAccounts();
+ const { i18n } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized:
+ return <Fragment />;
+ default:
+ assertUnreachable(result.case);
+ }
+ }
+
+ const onGoStart = result.isFirstPage ? undefined : result.loadFirst;
+ const onGoNext = result.isLastPage ? undefined : result.loadNext;
+
+ const accounts = result.body;
+ return (
+ <Fragment>
+ <div class="px-4 sm:px-6 lg:px-8 mt-8">
+ <div class="sm:flex sm:items-center">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Accounts</i18n.Translate>
+ </h1>
+ </div>
+ <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
+ <a
+ href={routeCreate.url({})}
+ name="create account"
+ type="button"
+ class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Create account</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ <div class="mt-4 flow-root">
+ <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
+ <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
+ {!accounts.length ? (
+ <div>{/* FIXME: ADD empty list */}</div>
+ ) : (
+ <table class="min-w-full divide-y divide-gray-300">
+ <thead>
+ <tr>
+ <th
+ scope="col"
+ class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0"
+ >{i18n.str`Username`}</th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Name`}</th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Balance`}</th>
+ <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
+ <span class="sr-only">{i18n.str`Actions`}</span>
+ </th>
+ </tr>
+ </thead>
+ <tbody class="divide-y divide-gray-200">
+ {accounts.map((item, idx) => {
+ const balance = !item.balance
+ ? undefined
+ : Amounts.parse(item.balance.amount);
+ const noBalance = Amounts.isZero(item.balance.amount);
+ const balanceIsDebit =
+ item.balance &&
+ item.balance.credit_debit_indicator == "debit";
+
+ return (
+ <tr key={idx}>
+ <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
+ <a
+ name={`show account ${item.username}`}
+ href={routeShowAccount.url({
+ account: item.username,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ {item.username}
+ </a>
+ </td>
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
+ {item.name}
+ </td>
+ <td
+ data-negative={
+ noBalance
+ ? undefined
+ : balanceIsDebit
+ ? "true"
+ : "false"
+ }
+ class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 data-[negative=false]:text-green-600 data-[negative=true]:text-red-600 "
+ >
+ {!balance ? (
+ i18n.str`Unknown`
+ ) : (
+ <span class="amount">
+ <RenderAmount
+ value={balance}
+ negative={balanceIsDebit}
+ spec={config.currency_specification}
+ />
+ </span>
+ )}
+ </td>
+ <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
+ <a
+ name={`update password ${item.username}`}
+ href={routeUpdatePasswordAccount.url({
+ account: item.username,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ <i18n.Translate>Change password</i18n.Translate>
+ </a>
+ <br />
+ {/* {config.allow_conversion ?
+ <Fragment>
+
+ <a
+ name={`show cashout ${item.username}`}
+ href={routeShowCashoutsAccount.url({
+ account: item.username,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ <i18n.Translate>Cashouts</i18n.Translate>
+ </a>
+ <br />
+ </Fragment>
+ : undefined} */}
+ {noBalance ? (
+ <a
+ name={`remove account ${item.username}`}
+ href={routeRemoveAccount.url({
+ account: item.username,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ <i18n.Translate>Remove</i18n.Translate>
+ </a>
+ ) : undefined}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ )}
+ </div>
+ <nav
+ class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg"
+ aria-label="Pagination"
+ >
+ <div class="flex flex-1 justify-between sm:justify-end">
+ <button
+ name="first page"
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ disabled={!onGoStart}
+ onClick={onGoStart}
+ >
+ <i18n.Translate>First page</i18n.Translate>
+ </button>
+ <button
+ name="next page"
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ disabled={!onGoNext}
+ onClick={onGoNext}
+ >
+ <i18n.Translate>Next</i18n.Translate>
+ </button>
+ </div>
+ </nav>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx
new file mode 100644
index 000000000..acae09b40
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/AdminHome.tsx
@@ -0,0 +1,623 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Amounts,
+ CurrencySpecification,
+ HttpStatusCode,
+ TalerCorebankApi,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ RouteDefinition,
+ useBankCoreApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import {
+ format,
+ sub
+} from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { Transactions } from "../../components/Transactions/index.js";
+import { useConversionInfo, useLastMonitorInfo } from "../../hooks/regional.js";
+import { RenderAmount } from "../PaytoWireTransferForm.js";
+import { WireTransfer } from "../WireTransfer.js";
+import { AccountList } from "./AccountList.js";
+
+/**
+ * Query account information and show QR code if there is pending withdrawal
+ */
+interface Props {
+ routeCreate: RouteDefinition;
+ routeDownloadStats: RouteDefinition;
+ routeCreateWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+
+ routeShowAccount: RouteDefinition<{ account: string }>;
+ routeRemoveAccount: RouteDefinition<{ account: string }>;
+ routeUpdatePasswordAccount: RouteDefinition<{ account: string }>;
+ routeShowCashoutsAccount: RouteDefinition<{ account: string }>;
+ onAuthorizationRequired: () => void;
+}
+export function AdminHome({
+ routeCreate,
+ routeRemoveAccount,
+ routeShowAccount,
+ routeUpdatePasswordAccount,
+ routeDownloadStats,
+ routeCreateWireTransfer,
+ onAuthorizationRequired,
+}: Props): VNode {
+ return (
+ <Fragment>
+ <Metrics routeDownloadStats={routeDownloadStats} />
+ <WireTransfer
+ routeHere={routeCreateWireTransfer}
+ onAuthorizationRequired={onAuthorizationRequired}
+ />
+ <Transactions
+ account="admin"
+ routeCreateWireTransfer={routeCreateWireTransfer}
+ />
+ <AccountList
+ routeCreate={routeCreate}
+ routeRemoveAccount={routeRemoveAccount}
+ routeShowAccount={routeShowAccount}
+ routeUpdatePasswordAccount={routeUpdatePasswordAccount}
+ />
+ </Fragment>
+ );
+}
+
+function getDateForTimeframe(
+ date: AbsoluteTime,
+ timeframe: TalerCorebankApi.MonitorTimeframeParam,
+ locale: Locale,
+): string {
+ if (date.t_ms === "never") return "--";
+ switch (timeframe) {
+ case TalerCorebankApi.MonitorTimeframeParam.hour:
+ return `${format(date.t_ms, "HH", { locale })}hs`;
+ case TalerCorebankApi.MonitorTimeframeParam.day:
+ return format(date.t_ms, "EEEE", { locale });
+ case TalerCorebankApi.MonitorTimeframeParam.month:
+ return format(date.t_ms, "MMMM", { locale });
+ case TalerCorebankApi.MonitorTimeframeParam.year:
+ return format(date.t_ms, "yyyy", { locale });
+ case TalerCorebankApi.MonitorTimeframeParam.decade:
+ return format(date.t_ms, "yyyy", { locale });
+ }
+ assertUnreachable(timeframe);
+}
+
+export function getTimeframesForDate(
+ time: Date,
+ timeframe: TalerCorebankApi.MonitorTimeframeParam,
+): { current: AbsoluteTime; previous: AbsoluteTime } {
+ switch (timeframe) {
+ case TalerCorebankApi.MonitorTimeframeParam.hour:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { hours: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { hours: 2 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.day:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { days: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { days: 4 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.month:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { months: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { months: 2 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.year:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 2 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.decade:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 10 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 20 }).getTime(),
+ ),
+ };
+ default:
+ assertUnreachable(timeframe);
+ }
+}
+
+function Metrics({
+ routeDownloadStats,
+}: {
+ routeDownloadStats: RouteDefinition;
+}): VNode {
+ const { i18n, dateLocale } = useTranslationContext();
+ const [metricType, setMetricType] =
+ useState<TalerCorebankApi.MonitorTimeframeParam>(
+ TalerCorebankApi.MonitorTimeframeParam.hour,
+ );
+ const { config } = useBankCoreApiContext();
+ const respInfo = useConversionInfo();
+ const params = getTimeframesForDate(new Date(), metricType);
+
+ const resp = useLastMonitorInfo(params.current, params.previous, metricType);
+ if (!resp) return <Fragment />;
+ if (resp instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={resp} />;
+ }
+ if (!respInfo) return <Fragment />;
+ if (respInfo instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={respInfo} />;
+ }
+ if (respInfo.type === "fail") {
+ switch (respInfo.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default: {
+ assertUnreachable(respInfo.case);
+ }
+ }
+ }
+
+ if (resp.current.type !== "ok") {
+ switch (resp.current.case) {
+ case HttpStatusCode.BadRequest:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the current stats failed`}
+ >
+ <i18n.Translate>The request parameters are wrong</i18n.Translate>
+ </Attention>
+ );
+ case HttpStatusCode.Unauthorized:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the current stats failed`}
+ >
+ <i18n.Translate>The user is unauthorized</i18n.Translate>
+ </Attention>
+ );
+ default: {
+ assertUnreachable(resp.current);
+ }
+ }
+ }
+ if (resp.previous.type !== "ok") {
+ switch (resp.previous.case) {
+ case HttpStatusCode.BadRequest:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the previous stats failed`}
+ >
+ <i18n.Translate>The request parameters are wrong</i18n.Translate>
+ </Attention>
+ );
+ case HttpStatusCode.Unauthorized:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the previous stats failed`}
+ >
+ <i18n.Translate>The user is unauthorized</i18n.Translate>
+ </Attention>
+ );
+ default: {
+ assertUnreachable(resp.previous);
+ }
+ }
+ }
+ return (
+ <div class="px-4 mt-4">
+ <div class="sm:flex sm:items-center mb-4">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Transaction volume report</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+
+ <div class="sm:hidden">
+ <label for="tabs" class="sr-only">
+ <i18n.Translate>Select a section</i18n.Translate>
+ </label>
+ <select
+ id="tabs"
+ name="tabs"
+ class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
+ onChange={(e) => {
+ // const op = e.currentTarget.value as typeof metricType
+ setMetricType(
+ e.currentTarget
+ .value as unknown as TalerCorebankApi.MonitorTimeframeParam,
+ );
+ }}
+ >
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.hour}
+ selected={metricType == TalerCorebankApi.MonitorTimeframeParam.hour}
+ >
+ <i18n.Translate>Last hour</i18n.Translate>
+ </option>
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.day}
+ selected={metricType == TalerCorebankApi.MonitorTimeframeParam.day}
+ >
+ <i18n.Translate>Previous day</i18n.Translate>
+ </option>
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.month}
+ selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.month
+ }
+ >
+ <i18n.Translate>Last month</i18n.Translate>
+ </option>
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.year}
+ selected={metricType == TalerCorebankApi.MonitorTimeframeParam.year}
+ >
+ <i18n.Translate>Last year</i18n.Translate>
+ </option>
+ </select>
+ </div>
+ <div class="hidden sm:block">
+ {/* FIXME: This should be LINKS */}
+ <nav
+ class="isolate flex divide-x divide-gray-200 rounded-lg shadow"
+ aria-label="Tabs"
+ >
+ <button
+ type="button"
+ name="set last hour"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.hour);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.hour
+ }
+ class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Last hour</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.hour
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ <button
+ type="button"
+ name="set previous day"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.day);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.day
+ }
+ class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Previous day</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.day
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ <button
+ type="button"
+ name="set last month"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.month);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.month
+ }
+ class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Last month</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.month
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ <button
+ type="button"
+ name="set last year"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.year);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.year
+ }
+ class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Last Year</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.year
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ </nav>
+ </div>
+
+ <div class="w-full flex justify-between">
+ <h1 class="text-base text-gray-900 mt-5">
+ {i18n.str`Trading volume on ${getDateForTimeframe(
+ params.current,
+ metricType,
+ dateLocale,
+ )} compared to ${getDateForTimeframe(
+ params.previous,
+ metricType,
+ dateLocale,
+ )}`}
+ </h1>
+ </div>
+ <dl class="mt-5 grid grid-cols-1 md:grid-cols-2 divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x md:divide-y-0">
+ {resp.current.body.type !== "with-conversions" ||
+ resp.previous.body.type !== "with-conversions" ? undefined : (
+ <Fragment>
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Cashin</i18n.Translate>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from an external account to an account in this
+ bank.
+ </i18n.Translate>
+ </div>
+ </dt>
+ <MetricValue
+ current={resp.current.body.cashinFiatVolume}
+ previous={resp.previous.body.cashinFiatVolume}
+ spec={respInfo.body.fiat_currency_specification}
+ />
+ </div>
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Cashout</i18n.Translate>
+ </dt>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from an account in this bank to an external
+ account.
+ </i18n.Translate>
+ </div>
+ <MetricValue
+ current={resp.current.body.cashoutFiatVolume}
+ previous={resp.previous.body.cashoutFiatVolume}
+ spec={respInfo.body.fiat_currency_specification}
+ />
+ </div>
+ </Fragment>
+ )}
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Payin</i18n.Translate>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from an account to a Taler exchange.
+ </i18n.Translate>
+ </div>
+ </dt>
+ <MetricValue
+ current={resp.current.body.talerInVolume}
+ previous={resp.previous.body.talerInVolume}
+ spec={config.currency_specification}
+ />
+ </div>
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Payout</i18n.Translate>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from a Taler exchange to another account.
+ </i18n.Translate>
+ </div>
+ </dt>
+ <MetricValue
+ current={resp.current.body.talerOutVolume}
+ previous={resp.previous.body.talerOutVolume}
+ spec={config.currency_specification}
+ />
+ </div>
+ </dl>
+ <div class="flex justify-end mt-4">
+ <a
+ href={routeDownloadStats.url({})}
+ name="download stats"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Download stats as CSV</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ );
+}
+
+function MetricValue({
+ current,
+ previous,
+ spec,
+}: {
+ spec: CurrencySpecification;
+ current: AmountString | undefined;
+ previous: AmountString | undefined;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const cmp = current && previous ? Amounts.cmp(current, previous) : 0;
+ const cv = !current ? undefined : Amounts.stringifyValue(current);
+ const currAmount = !cv ? undefined : Number.parseFloat(cv);
+ const prevAmount = !previous
+ ? undefined
+ : Number.parseFloat(Amounts.stringifyValue(previous));
+
+ const rate =
+ !currAmount ||
+ Number.isNaN(currAmount) ||
+ !prevAmount ||
+ Number.isNaN(prevAmount)
+ ? 0
+ : cmp === -1
+ ? 1 - Math.round(currAmount) / Math.round(prevAmount)
+ : cmp === 1
+ ? Math.round(currAmount) / Math.round(prevAmount) - 1
+ : 0;
+
+ const negative = cmp === 0 ? undefined : cmp === -1;
+ const rateStr = `${(Math.abs(rate) * 100).toFixed(2)}%`;
+ return (
+ <Fragment>
+ <dd class="mt-1 block ">
+ <div class="flex justify-start text-2xl items-baseline font-semibold text-indigo-600">
+ {!current ? (
+ "-"
+ ) : (
+ <RenderAmount
+ value={Amounts.parseOrThrow(current)}
+ spec={spec}
+ hideSmall
+ />
+ )}
+ </div>
+ <div class="flex flex-col">
+ <div class="flex justify-end items-baseline text-2xl font-semibold text-indigo-600">
+ <small class="ml-2 text-sm font-medium text-gray-500">
+ <i18n.Translate>from</i18n.Translate>{" "}
+ {!previous ? (
+ "-"
+ ) : (
+ <RenderAmount
+ value={Amounts.parseOrThrow(previous)}
+ spec={spec}
+ hideSmall
+ />
+ )}
+ </small>
+ </div>
+ {!!rate && (
+ <span
+ data-negative={negative}
+ class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 text-green-800 data-[negative=true]:bg-red-100 px-2 py-1 text-xs font-medium data-[negative=true]:text-red-700 whitespace-pre"
+ >
+ {negative ? (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75"
+ />
+ </svg>
+ ) : (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 19.5v-15m0 0l-6.75 6.75M12 4.5l6.75 6.75"
+ />
+ </svg>
+ )}
+
+ {negative ? (
+ <span class="sr-only">
+ <i18n.Translate>Decreased by</i18n.Translate>
+ </span>
+ ) : (
+ <span class="sr-only">
+ <i18n.Translate>Increased by</i18n.Translate>
+ </span>
+ )}
+ {rateStr}
+ </span>
+ )}
+ </div>
+ </dd>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx
new file mode 100644
index 000000000..7d2d449b0
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx
@@ -0,0 +1,217 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ TalerCorebankApi,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ LocalNotificationBanner,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { AccountForm } from "./AccountForm.js";
+
+export function CreateNewAccount({
+ routeCancel,
+ onCreateSuccess,
+}: {
+ routeCancel: RouteDefinition;
+ onCreateSuccess: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { state: credentials } = useSessionState();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ const [submitAccount, setSubmitAccount] = useState<
+ TalerCorebankApi.RegisterAccountRequest | undefined
+ >();
+ const [notification, notify, handleError] = useLocalNotification();
+
+ async function doCreate() {
+ if (!submitAccount || !token) return;
+ await handleError(async () => {
+ const resp = await api.createAccount(token, submitAccount);
+ if (resp.type === "ok") {
+ notifyInfo(
+ i18n.str`Account created with password "${submitAccount.password}".`,
+ );
+ onCreateSuccess();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`Server replied that phone or email is invalid`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`The rights to perform the operation are not sufficient`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE:
+ return notify({
+ type: "error",
+ title: i18n.str`Account username is already taken`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE:
+ return notify({
+ type: "error",
+ title: i18n.str`Account id is already taken`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Bank ran out of bonus credit.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
+ return notify({
+ type: "error",
+ title: i18n.str`Account username can't be used because is reserved`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Only admin is allow to set debt limit.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_MISSING_TAN_INFO:
+ return notify({
+ type: "error",
+ title: i18n.str`No information for the selected authentication channel.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED:
+ return notify({
+ type: "error",
+ title: i18n.str`Authentication channel is not supported.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL:
+ return notify({
+ type: "error",
+ title: i18n.str`Only admin can create accounts with second factor authentication.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ if (!(credentials.status === "loggedIn" && credentials.isUserAdministrator)) {
+ return (
+ <Fragment>
+ <Attention type="warning" title={i18n.str`Can't create accounts`}>
+ <i18n.Translate>
+ Only system admin can create accounts.
+ </i18n.Translate>
+ </Attention>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeCancel.url({})}
+ name="close"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </Fragment>
+ );
+ }
+
+ return (
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <i18n.Translate>New bank account</i18n.Translate>
+ </h2>
+ </div>
+ <AccountForm
+ template={undefined}
+ purpose="create"
+ onChange={(a) => {
+ setSubmitAccount(a);
+ }}
+ >
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeCancel.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="create"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!submitAccount}
+ onClick={(e) => {
+ e.preventDefault();
+ doCreate();
+ }}
+ >
+ <i18n.Translate>Create</i18n.Translate>
+ </button>
+ </div>
+ </AccountForm>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/admin/DownloadStats.tsx b/packages/bank-ui/src/pages/admin/DownloadStats.tsx
new file mode 100644
index 000000000..8f6bb7c23
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/DownloadStats.tsx
@@ -0,0 +1,588 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AccessToken,
+ AmountString,
+ TalerCoreBankHttpClient,
+ TalerCorebankApi,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ LocalNotificationBanner,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { getTimeframesForDate } from "./AdminHome.js";
+
+interface Props {
+ routeCancel: RouteDefinition;
+}
+
+type Options = {
+ dayMetric: boolean;
+ hourMetric: boolean;
+ monthMetric: boolean;
+ yearMetric: boolean;
+ compareWithPrevious: boolean;
+ endOnFirstFail: boolean;
+ includeHeader: boolean;
+};
+
+/**
+ * Show histories of public accounts.
+ */
+export function DownloadStats({ routeCancel }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const { state: credentials } = useSessionState();
+ const creds =
+ credentials.status !== "loggedIn" || !credentials.isUserAdministrator
+ ? undefined
+ : credentials;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ const [options, setOptions] = useState<Options>({
+ compareWithPrevious: true,
+ dayMetric: true,
+ endOnFirstFail: false,
+ hourMetric: true,
+ includeHeader: true,
+ monthMetric: true,
+ yearMetric: true,
+ });
+ const [lastStep, setLastStep] = useState<{ step: number; total: number }>();
+ const [downloaded, setDownloaded] = useState<string>();
+ const referenceDates = [new Date()];
+ const [notification, , handleError] = useLocalNotification();
+
+ if (!creds) {
+ return <div>only admin can download stats</div>;
+ }
+
+ return (
+ <div>
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <i18n.Translate>Download bank stats</i18n.Translate>
+ </h2>
+ </div>
+
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Include hour metric</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`hour switch`}
+ data-enabled={options.hourMetric}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ hourMetric: !options.hourMetric,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.hourMetric}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Include day metric</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`day switch`}
+ data-enabled={!!options.dayMetric}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({ ...options, dayMetric: !options.dayMetric });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.dayMetric}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Include month metric</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`month switch`}
+ data-enabled={!!options.monthMetric}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ monthMetric: !options.monthMetric,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.monthMetric}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Include year metric</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`year switch`}
+ data-enabled={!!options.yearMetric}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ yearMetric: !options.yearMetric,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.yearMetric}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Include table header</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`header switch`}
+ data-enabled={!!options.includeHeader}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ includeHeader: !options.includeHeader,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.includeHeader}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>
+ Add previous metric for compare
+ </i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`compare switch`}
+ data-enabled={!!options.compareWithPrevious}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ compareWithPrevious: !options.compareWithPrevious,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.compareWithPrevious}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Fail on first error</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`fail switch`}
+ data-enabled={!!options.endOnFirstFail}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ endOnFirstFail: !options.endOnFirstFail,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.endOnFirstFail}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ name="cancel"
+ href={routeCancel.url({})}
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="download"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={lastStep !== undefined}
+ onClick={async () => {
+ setDownloaded(undefined);
+ await handleError(async () => {
+ const csv = await fetchAllStatus(
+ api,
+ creds.token,
+ options,
+ referenceDates,
+ (step, total) => {
+ setLastStep({ step, total });
+ },
+ );
+ setDownloaded(csv);
+ });
+ setLastStep(undefined);
+ }}
+ >
+ <i18n.Translate>Download</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ {!lastStep || lastStep.step === lastStep.total ? (
+ <div class="h-5 mb-5" />
+ ) : (
+ <div>
+ <div class="relative mb-5 h-5 rounded-full bg-gray-200">
+ <div
+ class="h-full animate-pulse rounded-full bg-blue-500"
+ style={{
+ width: `${Math.round((lastStep.step / lastStep.total) * 100)}%`,
+ }}
+ >
+ <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
+ <i18n.Translate>
+ downloading...{" "}
+ {Math.round((lastStep.step / lastStep.total) * 100)}
+ </i18n.Translate>
+ </span>
+ </div>
+ </div>
+ </div>
+ )}
+ {!downloaded ? (
+ <div class="h-5 mb-5" />
+ ) : (
+ <a
+ href={
+ "data:text/plain;charset=utf-8," + encodeURIComponent(downloaded)
+ }
+ name="save file"
+ download={"bank-stats.csv"}
+ >
+ <Attention title={i18n.str`Download completed`}>
+ <i18n.Translate>
+ Click here to save the file in your computer.
+ </i18n.Translate>
+ </Attention>
+ </a>
+ )}
+ </div>
+ );
+}
+
+async function fetchAllStatus(
+ api: TalerCoreBankHttpClient,
+ token: AccessToken,
+ options: Options,
+ references: Date[],
+ progress: (current: number, total: number) => void,
+): Promise<string> {
+ const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = [];
+ if (options.hourMetric) {
+ allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour);
+ }
+ if (options.dayMetric) {
+ allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day);
+ }
+ if (options.monthMetric) {
+ allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month);
+ }
+ if (options.yearMetric) {
+ allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year);
+ }
+
+ /**
+ * convert request into frames
+ */
+ const allFrames = allMetrics.flatMap((timeframe) =>
+ references.map((reference) => ({
+ reference,
+ timeframe,
+ moment: getTimeframesForDate(reference, timeframe),
+ })),
+ );
+ const total = allFrames.length;
+
+ /**
+ * call API for info
+ */
+ const allInfo = await allFrames.reduce(
+ async (prev, frame, index) => {
+ const accumulatedMap = await prev;
+ progress(index, total);
+ // await delay()
+ const previous = options.compareWithPrevious
+ ? await api.getMonitor(token, {
+ timeframe: frame.timeframe,
+ date: frame.moment.previous,
+ })
+ : undefined;
+
+ if (previous && previous.type === "fail" && options.endOnFirstFail) {
+ throw TalerError.fromUncheckedDetail(previous.detail);
+ }
+
+ const current = await api.getMonitor(token, {
+ timeframe: frame.timeframe,
+ date: frame.moment.current,
+ });
+
+ if (current.type === "fail" && options.endOnFirstFail) {
+ throw TalerError.fromUncheckedDetail(current.detail);
+ }
+
+ const metricName =
+ TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]];
+ accumulatedMap[metricName] = {
+ reference: frame.reference,
+ current: current.type !== "ok" ? undefined : current.body,
+ previous:
+ !previous || previous.type !== "ok" ? undefined : previous.body,
+ };
+ return accumulatedMap;
+ },
+ Promise.resolve({} as Record<string, Data>),
+ );
+ progress(total, total);
+
+ /**
+ * convert into table format
+ *
+ */
+ const table: Array<string[]> = [];
+ if (options.includeHeader) {
+ table.push([
+ "date",
+ "metric",
+ "reference",
+ "talerInCount",
+ "talerInVolume",
+ "talerOutCount",
+ "talerOutVolume",
+ "cashinCount",
+ "cashinFiatVolume",
+ "cashinRegionalVolume",
+ "cashoutCount",
+ "cashoutFiatVolume",
+ "cashoutRegionalVolume",
+ ]);
+ }
+ Object.entries(allInfo).forEach(([name, data]) => {
+ if (data.current) {
+ const row: TableRow = {
+ date: data.reference.getTime(),
+ metric: name,
+ reference: "current",
+ ...dataToRow(data.current),
+ };
+ table.push(Object.values(row) as string[]);
+ }
+
+ if (data.previous) {
+ const row: TableRow = {
+ date: data.reference.getTime(),
+ metric: name,
+ reference: "previous",
+ ...dataToRow(data.previous),
+ };
+ table.push(Object.values(row) as string[]);
+ }
+ });
+
+ const csv = table.reduce((acc, row) => {
+ return acc + row.join(",") + "\n";
+ }, "");
+
+ return csv;
+}
+
+type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference">;
+function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData {
+ return {
+ talerInCount: info.talerInCount,
+ talerInVolume: info.talerInVolume,
+ talerOutCount: info.talerOutCount,
+ talerOutVolume: info.talerOutVolume,
+ cashinCount: info.type === "no-conversions" ? undefined : info.cashinCount,
+ cashinFiatVolume:
+ info.type === "no-conversions" ? undefined : info.cashinFiatVolume,
+ cashinRegionalVolume:
+ info.type === "no-conversions" ? undefined : info.cashinRegionalVolume,
+ cashoutCount:
+ info.type === "no-conversions" ? undefined : info.cashoutCount,
+ cashoutFiatVolume:
+ info.type === "no-conversions" ? undefined : info.cashoutFiatVolume,
+ cashoutRegionalVolume:
+ info.type === "no-conversions" ? undefined : info.cashoutRegionalVolume,
+ };
+}
+
+type Data = {
+ reference: Date;
+ previous: TalerCorebankApi.MonitorResponse | undefined;
+ current: TalerCorebankApi.MonitorResponse | undefined;
+};
+type TableRow = {
+ date: number;
+ metric: string;
+ reference: "current" | "previous";
+ cashinCount?: number;
+ cashinRegionalVolume?: AmountString;
+ cashinFiatVolume?: AmountString;
+ cashoutCount?: number;
+ cashoutRegionalVolume?: AmountString;
+ cashoutFiatVolume?: AmountString;
+ talerInCount: number;
+ talerInVolume: AmountString;
+ talerOutCount: number;
+ talerOutVolume: AmountString;
+};
diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx
new file mode 100644
index 000000000..dbeebf719
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx
@@ -0,0 +1,273 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useAccountDetails } from "../../hooks/account.js";
+import { useSessionState } from "../../hooks/session.js";
+import { undefinedIfEmpty } from "../../utils.js";
+import { LoginForm } from "../LoginForm.js";
+import { doAutoFocus } from "../PaytoWireTransferForm.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export function RemoveAccount({
+ account,
+ routeCancel,
+ onUpdateSuccess,
+ onAuthorizationRequired,
+ focus,
+ routeHere,
+}: {
+ focus?: boolean;
+ routeHere: RouteDefinition<{ account: string }>;
+ onAuthorizationRequired: () => void;
+ routeCancel: RouteDefinition;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ const [accountName, setAccountName] = useState<string | undefined>();
+
+ const { state } = useSessionState();
+ const token = state.status !== "loggedIn" ? undefined : state.token;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+ const [notification, notify, handleError] = useLocalNotification();
+ const [, updateBankState] = useBankState();
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized:
+ return <LoginForm currentUser={account} />;
+ case HttpStatusCode.NotFound:
+ return <LoginForm currentUser={account} />;
+ default:
+ assertUnreachable(result);
+ }
+ }
+
+ const balance = Amounts.parse(result.body.balance.amount);
+ if (!balance) {
+ return <div>there was an error reading the balance</div>;
+ }
+ const isBalanceEmpty = Amounts.isZero(balance);
+ if (!isBalanceEmpty) {
+ return (
+ <Fragment>
+ <Attention type="warning" title={i18n.str`Can't delete the account`}>
+ <i18n.Translate>
+ The account can't be delete while still holding some balance. First
+ make sure that the owner make a complete cashout.
+ </i18n.Translate>
+ </Attention>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeCancel.url({})}
+ name="close"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </Fragment>
+ );
+ }
+
+ async function doRemove() {
+ if (!token) return;
+ await handleError(async () => {
+ const resp = await api.deleteAccount({ username: account, token });
+ if (resp.type === "ok") {
+ notifyInfo(i18n.str`Account removed`);
+ onUpdateSuccess();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`No enough permission to delete the account.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The username was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
+ return notify({
+ type: "error",
+ title: i18n.str`Can't delete a reserved username.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO:
+ return notify({
+ type: "error",
+ title: i18n.str`Can't delete an account with balance different than zero.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "delete-account",
+ id: String(resp.body.challenge_id),
+ sent: AbsoluteTime.never(),
+ location: routeHere.url({ account }),
+ request: account,
+ });
+ return onAuthorizationRequired();
+ }
+ default: {
+ assertUnreachable(resp);
+ }
+ }
+ }
+ });
+ }
+
+ const errors = undefinedIfEmpty({
+ accountName: !accountName
+ ? i18n.str`Required`
+ : account !== accountName
+ ? i18n.str`Name doesn't match`
+ : undefined,
+ });
+
+ return (
+ <div>
+ <LocalNotificationBanner notification={notification} />
+
+ <Attention
+ type="warning"
+ title={i18n.str`You are going to remove the account`}
+ >
+ <i18n.Translate>This step can't be undone.</i18n.Translate>
+ </Attention>
+
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <i18n.Translate>Deleting account "{account}"</i18n.Translate>
+ </h2>
+ </div>
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="password"
+ >
+ {i18n.str`Verification`}
+ </label>
+ <div class="mt-2">
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="text"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="password"
+ id="password"
+ data-error={
+ !!errors?.accountName && accountName !== undefined
+ }
+ value={accountName ?? ""}
+ onChange={(e) => {
+ setAccountName(e.currentTarget.value);
+ }}
+ placeholder={account}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.accountName}
+ isDirty={accountName !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Enter the account name that is going to be deleted
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeCancel.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="delete"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
+ disabled={!!errors}
+ onClick={(e) => {
+ e.preventDefault();
+ doRemove();
+ }}
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/index.stories.tsx b/packages/bank-ui/src/pages/index.stories.tsx
new file mode 100644
index 000000000..823def5d7
--- /dev/null
+++ b/packages/bank-ui/src/pages/index.stories.tsx
@@ -0,0 +1,20 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export * as qr from "./QrCodeSection.stories.js";
+export * as po from "./PaymentOptions.stories.js";
+export * as ptf from "./PaytoWireTransferForm.stories.js";
+export * as frame from "./BankFrame.stories.js";
diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx
new file mode 100644
index 000000000..485ef5490
--- /dev/null
+++ b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx
@@ -0,0 +1,1170 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ HttpStatusCode,
+ TalerBankConversionApi,
+ TalerError,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ InternationalizationAPI,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ useLocalNotification,
+ useTranslationContext,
+ utils,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../../hooks/session.js";
+import {
+ TransferCalculation,
+ useCashinEstimator,
+ useCashoutEstimator,
+ useConversionInfo,
+} from "../../hooks/regional.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../../utils.js";
+import { InputAmount, RenderAmount } from "../PaytoWireTransferForm.js";
+import { ProfileNavigation } from "../ProfileNavigation.js";
+import {
+ FormErrors,
+ FormStatus,
+ FormValues,
+ RecursivePartial,
+ UIField,
+ useFormState,
+} from "../../hooks/form.js";
+
+interface Props {
+ routeMyAccountDetails: RouteDefinition;
+ routeMyAccountDelete: RouteDefinition;
+ routeMyAccountPassword: RouteDefinition;
+ routeMyAccountCashout: RouteDefinition;
+ routeConversionConfig: RouteDefinition;
+ routeCancel: RouteDefinition;
+ onUpdateSuccess: () => void;
+}
+
+type FormType = {
+ amount: AmountJson;
+ conv: TalerBankConversionApi.ConversionRate;
+};
+
+function useComponentState({
+ routeCancel,
+ routeConversionConfig,
+ routeMyAccountCashout,
+ routeMyAccountDelete,
+ routeMyAccountDetails,
+ routeMyAccountPassword,
+}: Props): utils.RecursiveState<VNode> {
+ const { i18n } = useTranslationContext();
+
+ const result = useConversionInfo();
+ const info =
+ result && !(result instanceof TalerError) && result.type === "ok"
+ ? result.body
+ : undefined;
+
+ const { state: credentials } = useSessionState();
+ const creds =
+ credentials.status !== "loggedIn" || !credentials.isUserAdministrator
+ ? undefined
+ : credentials;
+
+ if (!info) {
+ return <i18n.Translate>loading...</i18n.Translate>;
+ }
+
+ if (!creds) {
+ return <i18n.Translate>only admin can setup conversion</i18n.Translate>;
+ }
+
+ return function afterComponentLoads() {
+ const { i18n } = useTranslationContext();
+
+ const {
+ lib: { conversion },
+ } = useBankCoreApiContext();
+
+ const [notification, notify, handleError] = useLocalNotification();
+
+ const initalState: FormValues<FormType> = {
+ amount: "100",
+ conv: {
+ cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1],
+ cashin_tiny_amount:
+ info.conversion_rate.cashin_tiny_amount.split(":")[1],
+ cashin_fee: info.conversion_rate.cashin_fee.split(":")[1],
+ cashin_ratio: info.conversion_rate.cashin_ratio,
+ cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode,
+ cashout_min_amount:
+ info.conversion_rate.cashout_min_amount.split(":")[1],
+ cashout_tiny_amount:
+ info.conversion_rate.cashout_tiny_amount.split(":")[1],
+ cashout_fee: info.conversion_rate.cashout_fee.split(":")[1],
+ cashout_ratio: info.conversion_rate.cashout_ratio,
+ cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode,
+ },
+ };
+
+ const [form, status] = useFormState<FormType>(
+ initalState,
+ createFormValidator(i18n, info.regional_currency, info.fiat_currency),
+ );
+
+ const { estimateByDebit: calculateCashoutFromDebit } =
+ useCashoutEstimator();
+
+ const { estimateByDebit: calculateCashinFromDebit } = useCashinEstimator();
+
+ const [calculationResult, setCalc] = useState<{
+ cashin: TransferCalculation;
+ cashout: TransferCalculation;
+ }>();
+
+ useEffect(() => {
+ async function doAsync() {
+ await handleError(async () => {
+ if (!info) return;
+ if (!form.amount?.value || form.amount.error) return;
+ const in_amount = Amounts.parseOrThrow(
+ `${info.fiat_currency}:${form.amount.value}`,
+ );
+ const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee);
+ const cashin = await calculateCashinFromDebit(in_amount, in_fee);
+
+ if (cashin === "amount-is-too-small") {
+ setCalc(undefined);
+ return;
+ }
+ // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`)
+ const out_fee = Amounts.parseOrThrow(
+ info.conversion_rate.cashout_fee,
+ );
+ const cashout = await calculateCashoutFromDebit(
+ cashin.credit,
+ out_fee,
+ );
+
+ setCalc({ cashin, cashout });
+ });
+ }
+ doAsync();
+ }, [
+ form.amount?.value,
+ form.conv?.cashin_fee?.value,
+ form.conv?.cashout_fee?.value,
+ ]);
+
+ const [section, setSection] = useState<"detail" | "cashout" | "cashin">(
+ "detail",
+ );
+ const cashinCalc =
+ calculationResult?.cashin === "amount-is-too-small"
+ ? undefined
+ : calculationResult?.cashin;
+ const cashoutCalc =
+ calculationResult?.cashout === "amount-is-too-small"
+ ? undefined
+ : calculationResult?.cashout;
+ async function doUpdate() {
+ if (!creds) return;
+ await handleError(async () => {
+ if (status.status === "fail") return;
+ const resp = await conversion.updateConversionRate(
+ creds.token,
+ status.result.conv,
+ );
+ if (resp.type === "ok") {
+ setSection("detail");
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Unauthorized: {
+ return notify({
+ type: "error",
+ title: i18n.str`Wrong credentials`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ case HttpStatusCode.NotImplemented: {
+ return notify({
+ type: "error",
+ title: i18n.str`Conversion is disabled`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio);
+ const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio);
+
+ const both_high = in_ratio > 1 && out_ratio > 1;
+ const both_low = in_ratio < 1 && out_ratio < 1;
+
+ return (
+ <div>
+ <ProfileNavigation
+ current="conversion"
+ routeMyAccountCashout={routeMyAccountCashout}
+ routeMyAccountDelete={routeMyAccountDelete}
+ routeMyAccountDetails={routeMyAccountDetails}
+ routeMyAccountPassword={routeMyAccountPassword}
+ routeConversionConfig={routeConversionConfig}
+ />
+
+ <LocalNotificationBanner notification={notification} />
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <i18n.Translate>Conversion</i18n.Translate>
+ </h2>
+ <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4">
+ <label
+ data-enabled={section === "detail"}
+ class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600"
+ >
+ <input
+ type="radio"
+ name="project-type"
+ value="Newsletter"
+ class="sr-only"
+ aria-labelledby="project-type-0-label"
+ aria-describedby="project-type-0-description-0 project-type-0-description-1"
+ onChange={() => {
+ setSection("detail");
+ }}
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Details</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
+
+ <label
+ data-enabled={section === "cashout"}
+ class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600"
+ >
+ <input
+ type="radio"
+ name="project-type"
+ value="Existing Customers"
+ class="sr-only"
+ aria-labelledby="project-type-1-label"
+ aria-describedby="project-type-1-description-0 project-type-1-description-1"
+ onChange={() => {
+ setSection("cashout");
+ }}
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Config cashout</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
+ <label
+ data-enabled={section === "cashin"}
+ class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600"
+ >
+ <input
+ type="radio"
+ name="project-type"
+ value="Existing Customers"
+ class="sr-only"
+ aria-labelledby="project-type-1-label"
+ aria-describedby="project-type-1-description-0 project-type-1-description-1"
+ onChange={() => {
+ setSection("cashin");
+ }}
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Config cashin</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
+ </div>
+ </div>
+
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ {section == "cashin" && (
+ <ConversionForm
+ id="cashin"
+ inputCurrency={info.fiat_currency}
+ outputCurrency={info.regional_currency}
+ fee={form?.conv?.cashin_fee}
+ minimum={form?.conv?.cashin_min_amount}
+ ratio={form?.conv?.cashin_ratio}
+ rounding={form?.conv?.cashin_rounding_mode}
+ tiny={form?.conv?.cashin_tiny_amount}
+ />
+ )}
+
+ {section == "cashout" && (
+ <Fragment>
+ <ConversionForm
+ id="cashout"
+ inputCurrency={info.regional_currency}
+ outputCurrency={info.fiat_currency}
+ fee={form?.conv?.cashout_fee}
+ minimum={form?.conv?.cashout_min_amount}
+ ratio={form?.conv?.cashout_ratio}
+ rounding={form?.conv?.cashout_rounding_mode}
+ tiny={form?.conv?.cashout_tiny_amount}
+ />
+ </Fragment>
+ )}
+
+ {section == "detail" && (
+ <Fragment>
+ <div class="px-6 pt-6">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Cashin ratio</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ {info.conversion_rate.cashin_ratio}
+ </dd>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Cashout ratio</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ {info.conversion_rate.cashout_ratio}
+ </dd>
+ </div>
+ </div>
+
+ {both_low || both_high ? (
+ <div class="p-4">
+ <Attention title={i18n.str`Bad ratios`} type="warning">
+ <i18n.Translate>
+ One of the ratios should be higher or equal than 1 an
+ the other should be lower or equal than 1.
+ </i18n.Translate>
+ </Attention>
+ </div>
+ ) : undefined}
+
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for="amount"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Initial amount`}</label>
+ <InputAmount
+ name="amount"
+ left
+ currency={info.fiat_currency}
+ value={form.amount?.value ?? ""}
+ onChange={form.amount?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={form.amount?.error}
+ isDirty={form.amount?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Use it to test how the conversion will affect the
+ amount.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {!cashoutCalc || !cashinCalc ? undefined : (
+ <div class="px-6 pt-6">
+ <div class="sm:col-span-5">
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>
+ Sending to this bank
+ </i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={cashinCalc.debit}
+ negative
+ withColor
+ spec={info.regional_currency_specification}
+ />
+ </dd>
+ </div>
+
+ {Amounts.isZero(cashinCalc.beforeFee) ? undefined : (
+ <div class="flex items-center justify-between afu ">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Converted</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={cashinCalc.beforeFee}
+ spec={info.fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ <div class="flex justify-between items-center border-t-2 afu pt-4">
+ <dt class="text-lg text-gray-900 font-medium">
+ <i18n.Translate>Cashin after fee</i18n.Translate>
+ </dt>
+ <dd class="text-lg text-gray-900 font-medium">
+ <RenderAmount
+ value={cashinCalc.credit}
+ withColor
+ spec={info.fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+
+ <div class="sm:col-span-5">
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>
+ Sending from this bank
+ </i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={cashoutCalc.debit}
+ negative
+ withColor
+ spec={info.fiat_currency_specification}
+ />
+ </dd>
+ </div>
+
+ {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : (
+ <div class="flex items-center justify-between afu">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Converted</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={cashoutCalc.beforeFee}
+ spec={info.regional_currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ <div class="flex justify-between items-center border-t-2 afu pt-4">
+ <dt class="text-lg text-gray-900 font-medium">
+ <i18n.Translate>Cashout after fee</i18n.Translate>
+ </dt>
+ <dd class="text-lg text-gray-900 font-medium">
+ <RenderAmount
+ value={cashoutCalc.credit}
+ withColor
+ spec={info.regional_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+
+ {cashoutCalc &&
+ status.status === "ok" &&
+ Amounts.cmp(status.result.amount, cashoutCalc.credit) <
+ 0 ? (
+ <div class="p-4">
+ <Attention
+ title={i18n.str`Bad configuration`}
+ type="warning"
+ >
+ <i18n.Translate>
+ This configuration allows users to cash out more of
+ what has been cashed in.
+ </i18n.Translate>
+ </Attention>
+ </div>
+ ) : undefined}
+ </div>
+ )}
+ </Fragment>
+ )}
+
+ <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4">
+ <a
+ name="cancel"
+ href={routeCancel.url({})}
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ {section == "cashin" || section == "cashout" ? (
+ <Fragment>
+ <button
+ type="submit"
+ name="update conversion"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ onClick={async () => {
+ doUpdate();
+ }}
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+ </Fragment>
+ ) : (
+ <div />
+ )}
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+ };
+}
+
+export const ConversionConfig = utils.recursive(useComponentState);
+
+/**
+ *
+ * @param i18n
+ * @param regional
+ * @param fiat
+ * @returns form validator
+ */
+function createFormValidator(
+ i18n: InternationalizationAPI,
+ regional: string,
+ fiat: string,
+) {
+ return function check(state: FormValues<FormType>): FormStatus<FormType> {
+ const cashin_min_amount = Amounts.parse(
+ `${fiat}:${state.conv.cashin_min_amount}`,
+ );
+ const cashin_tiny_amount = Amounts.parse(
+ `${regional}:${state.conv.cashin_tiny_amount}`,
+ );
+ const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`);
+
+ const cashout_min_amount = Amounts.parse(
+ `${regional}:${state.conv.cashout_min_amount}`,
+ );
+ const cashout_tiny_amount = Amounts.parse(
+ `${fiat}:${state.conv.cashout_tiny_amount}`,
+ );
+ const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`);
+
+ const am = Amounts.parse(`${fiat}:${state.amount}`);
+
+ const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? "");
+ const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? "");
+
+ const errors = undefinedIfEmpty<FormErrors<FormType>>({
+ conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({
+ cashin_min_amount: !state.conv.cashin_min_amount
+ ? i18n.str`required`
+ : !cashin_min_amount
+ ? i18n.str`invalid`
+ : undefined,
+ cashin_tiny_amount: !state.conv.cashin_tiny_amount
+ ? i18n.str`required`
+ : !cashin_tiny_amount
+ ? i18n.str`invalid`
+ : undefined,
+ cashin_fee: !state.conv.cashin_fee
+ ? i18n.str`required`
+ : !cashin_fee
+ ? i18n.str`invalid`
+ : undefined,
+
+ cashout_min_amount: !state.conv.cashout_min_amount
+ ? i18n.str`required`
+ : !cashout_min_amount
+ ? i18n.str`invalid`
+ : undefined,
+ cashout_tiny_amount: !state.conv.cashin_tiny_amount
+ ? i18n.str`required`
+ : !cashout_tiny_amount
+ ? i18n.str`invalid`
+ : undefined,
+ cashout_fee: !state.conv.cashin_fee
+ ? i18n.str`required`
+ : !cashout_fee
+ ? i18n.str`invalid`
+ : undefined,
+
+ cashin_rounding_mode: !state.conv.cashin_rounding_mode
+ ? i18n.str`required`
+ : undefined,
+ cashout_rounding_mode: !state.conv.cashout_rounding_mode
+ ? i18n.str`required`
+ : undefined,
+
+ cashin_ratio: !state.conv.cashin_ratio
+ ? i18n.str`required`
+ : Number.isNaN(cashin_ratio)
+ ? i18n.str`invalid`
+ : undefined,
+ cashout_ratio: !state.conv.cashout_ratio
+ ? i18n.str`required`
+ : Number.isNaN(cashout_ratio)
+ ? i18n.str`invalid`
+ : undefined,
+ }),
+
+ amount: !state.amount
+ ? i18n.str`required`
+ : !am
+ ? i18n.str`invalid`
+ : undefined,
+ });
+
+ const result: RecursivePartial<FormType> = {
+ amount: am,
+ conv: {
+ cashin_fee: !errors?.conv?.cashin_fee
+ ? Amounts.stringify(cashin_fee!)
+ : undefined,
+ cashin_min_amount: !errors?.conv?.cashin_min_amount
+ ? Amounts.stringify(cashin_min_amount!)
+ : undefined,
+ cashin_ratio: !errors?.conv?.cashin_ratio
+ ? String(cashin_ratio!)
+ : undefined,
+ cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode
+ ? state.conv.cashin_rounding_mode!
+ : undefined,
+ cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount
+ ? Amounts.stringify(cashin_tiny_amount!)
+ : undefined,
+ cashout_fee: !errors?.conv?.cashout_fee
+ ? Amounts.stringify(cashout_fee!)
+ : undefined,
+ cashout_min_amount: !errors?.conv?.cashout_min_amount
+ ? Amounts.stringify(cashout_min_amount!)
+ : undefined,
+ cashout_ratio: !errors?.conv?.cashout_ratio
+ ? String(cashout_ratio!)
+ : undefined,
+ cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode
+ ? state.conv.cashout_rounding_mode!
+ : undefined,
+ cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount
+ ? Amounts.stringify(cashout_tiny_amount!)
+ : undefined,
+ },
+ };
+ return errors === undefined
+ ? { status: "ok", result: result as FormType, errors }
+ : { status: "fail", result, errors };
+ };
+}
+
+function ConversionForm({
+ id,
+ inputCurrency,
+ outputCurrency,
+ fee,
+ minimum,
+ ratio,
+ rounding,
+ tiny,
+}: {
+ inputCurrency: string;
+ outputCurrency: string;
+ minimum: UIField | undefined;
+ tiny: UIField | undefined;
+ fee: UIField | undefined;
+ rounding: UIField | undefined;
+ ratio: UIField | undefined;
+ id: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for={`${id}_min_amount`}
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Minimum amount`}</label>
+ <InputAmount
+ name={`${id}_min_amount`}
+ left
+ currency={inputCurrency}
+ value={minimum?.value ?? ""}
+ onChange={minimum?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={minimum?.error}
+ isDirty={minimum?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Only cashout operation above this threshold will be allowed
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for={`${id}_ratio`}
+ >
+ {i18n.str`Ratio`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="number"
+ class="block rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="current"
+ id={`${id}_ratio`}
+ data-error={!!ratio?.error && ratio?.value !== undefined}
+ value={ratio?.value ?? ""}
+ onChange={(e) => {
+ ratio?.onUpdate(e.currentTarget.value);
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={ratio?.error}
+ isDirty={ratio?.value !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Conversion ratio between currencies</i18n.Translate>
+ </p>
+ </div>
+
+ <div class="px-6 pt-4">
+ <Attention title={i18n.str`Example conversion`}>
+ <i18n.Translate>
+ 1 {inputCurrency} will be converted into {ratio?.value}{" "}
+ {outputCurrency}
+ </i18n.Translate>
+ </Attention>
+ </div>
+
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for={`${id}_tiny_amount`}
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Rounding value`}</label>
+ <InputAmount
+ name={`${id}_tiny_amount`}
+ left
+ currency={outputCurrency}
+ value={tiny?.value ?? ""}
+ onChange={tiny?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={tiny?.error}
+ isDirty={tiny?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Smallest difference between two amounts after the ratio is
+ applied.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for={`${id}_channel`}
+ >
+ {i18n.str`Rounding mode`}
+ </label>
+ <div class="mt-2 max-w-xl text-sm text-gray-500">
+ <div class="px-4 mt-4 grid grid-cols-1 gap-y-6">
+ <label
+ onClick={(e) => {
+ e.preventDefault();
+ rounding?.onUpdate("zero");
+ }}
+ data-selected={rounding?.value === "zero"}
+ class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"
+ >
+ <input
+ type="radio"
+ name="channel"
+ value="Newsletter"
+ class="sr-only"
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900 ">
+ <i18n.Translate>Zero</i18n.Translate>
+ </span>
+ <i18n.Translate>
+ Amount will be round below to the largest possible value
+ smaller than the input.
+ </i18n.Translate>
+ </span>
+ </span>
+ <svg
+ data-selected={rounding?.value === "zero"}
+ class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </label>
+
+ <label
+ onClick={(e) => {
+ e.preventDefault();
+ rounding?.onUpdate("up");
+ }}
+ data-selected={rounding?.value === "up"}
+ class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"
+ >
+ <input
+ type="radio"
+ name="channel"
+ value="Existing Customers"
+ class="sr-only"
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900 ">
+ <i18n.Translate>Up</i18n.Translate>
+ </span>
+ <i18n.Translate>
+ Amount will be round up to the smallest possible value
+ larger than the input.
+ </i18n.Translate>
+ </span>
+ </span>
+ <svg
+ data-selected={rounding?.value === "up"}
+ class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </label>
+ <label
+ onClick={(e) => {
+ e.preventDefault();
+ rounding?.onUpdate("nearest");
+ }}
+ data-selected={rounding?.value === "nearest"}
+ class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"
+ >
+ <input
+ type="radio"
+ name="channel"
+ value="Existing Customers"
+ class="sr-only"
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900 ">
+ <i18n.Translate>Nearest</i18n.Translate>
+ </span>
+ <i18n.Translate>
+ Amount will be round to the closest possible value.
+ </i18n.Translate>
+ </span>
+ </span>
+ <svg
+ data-selected={rounding?.value === "nearest"}
+ class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </label>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-4">
+ <Attention title={i18n.str`Examples`}>
+ <section class="grid grid-cols-1 gap-y-3 text-gray-600">
+ <details class="group text-sm">
+ <summary class="flex cursor-pointer flex-row items-center justify-between ">
+ <i18n.Translate>
+ Rounding an amount of 1.24 with rounding value 0.1
+ </i18n.Translate>
+ <svg
+ class="h-6 w-6 rotate-0 transform group-open:rotate-180"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M19 9l-7 7-7-7"
+ ></path>
+ </svg>
+ </summary>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ Given the rounding value of 0.1 the possible values closest to
+ 1.24 are: 1.1, 1.2, 1.3, 1.4.
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "zero" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "nearest" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 mt-4">
+ <i18n.Translate>
+ With the "up" mode the value will be rounded to 1.3
+ </i18n.Translate>
+ </p>
+ </details>
+ <details class="group ">
+ <summary class="flex cursor-pointer flex-row items-center justify-between ">
+ <i18n.Translate>
+ Rounding an amount of 1.26 with rounding value 0.1
+ </i18n.Translate>
+ <svg
+ class="h-6 w-6 rotate-0 transform group-open:rotate-180"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M19 9l-7 7-7-7"
+ ></path>
+ </svg>
+ </summary>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ Given the rounding value of 0.1 the possible values closest to
+ 1.24 are: 1.1, 1.2, 1.3, 1.4.
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "zero" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "nearest" mode the value will be rounded to 1.3
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "up" mode the value will be rounded to 1.3
+ </i18n.Translate>
+ </p>
+ </details>
+ <details class="group ">
+ <summary class="flex cursor-pointer flex-row items-center justify-between ">
+ <i18n.Translate>
+ Rounding an amount of 1.24 with rounding value 0.3
+ </i18n.Translate>
+ <svg
+ class="h-6 w-6 rotate-0 transform group-open:rotate-180"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M19 9l-7 7-7-7"
+ ></path>
+ </svg>
+ </summary>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ Given the rounding value of 0.3 the possible values closest to
+ 1.24 are: 0.9, 1.2, 1.5, 1.8.
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "zero" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "nearest" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "up" mode the value will be rounded to 1.5
+ </i18n.Translate>
+ </p>
+ </details>
+ <details class="group ">
+ <summary class="flex cursor-pointer flex-row items-center justify-between ">
+ <i18n.Translate>
+ Rounding an amount of 1.26 with rounding value 0.3
+ </i18n.Translate>
+ <svg
+ class="h-6 w-6 rotate-0 transform group-open:rotate-180"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M19 9l-7 7-7-7"
+ ></path>
+ </svg>
+ </summary>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ Given the rounding value of 0.3 the possible values closest to
+ 1.24 are: 0.9, 1.2, 1.5, 1.8.
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "zero" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "nearest" mode the value will be rounded to 1.3
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "up" mode the value will be rounded to 1.3
+ </i18n.Translate>
+ </p>
+ </details>
+ </section>
+ </Attention>
+ </div>
+
+ <div class="px-6 pt-6">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ for={`${id}_fee`}
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Fee`}</label>
+ <InputAmount
+ name={`${id}_fee`}
+ left
+ currency={outputCurrency}
+ value={fee?.value ?? ""}
+ onChange={fee?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={fee?.error}
+ isDirty={fee?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Amount to be deducted before amount is credited.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx
new file mode 100644
index 000000000..8e54bbd4e
--- /dev/null
+++ b/packages/bank-ui/src/pages/regional/CreateCashout.tsx
@@ -0,0 +1,717 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ encodeCrock,
+ getRandomBytes,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useAccountDetails } from "../../hooks/account.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import {
+ TransferCalculation,
+ useCashoutEstimator,
+ useConversionInfo,
+} from "../../hooks/regional.js";
+import { useSessionState } from "../../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { TanChannel, undefinedIfEmpty } from "../../utils.js";
+import { LoginForm } from "../LoginForm.js";
+import {
+ InputAmount,
+ RenderAmount,
+ doAutoFocus,
+} from "../PaytoWireTransferForm.js";
+
+interface Props {
+ account: string;
+ focus?: boolean;
+ onAuthorizationRequired: () => void;
+ routeClose: RouteDefinition;
+ routeHere: RouteDefinition;
+}
+
+type FormType = {
+ isDebit: boolean;
+ amount: string;
+ subject: string;
+ channel: TanChannel;
+};
+type ErrorFrom<T> = {
+ [P in keyof T]+?: string;
+};
+
+export function CreateCashout({
+ account: accountName,
+ onAuthorizationRequired,
+ focus,
+ routeHere,
+ routeClose,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const resultAccount = useAccountDetails(accountName);
+ const {
+ estimateByCredit: calculateFromCredit,
+ estimateByDebit: calculateFromDebit,
+ } = useCashoutEstimator();
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+ const [, updateBankState] = useBankState();
+
+ const {
+ lib: { bank: api },
+ config,
+ hints,
+ } = useBankCoreApiContext();
+ const [form, setForm] = useState<Partial<FormType>>({ isDebit: true });
+ const [notification, notify, handleError] = useLocalNotification();
+ const info = useConversionInfo();
+
+ if (!config.allow_conversion) {
+ return (
+ <Fragment>
+ <Attention type="warning" title={i18n.str`Unable to create a cashout`}>
+ <i18n.Translate>
+ The bank configuration does not support cashout operations.
+ </i18n.Translate>
+ </Attention>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeClose.url({})}
+ name="close"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </Fragment>
+ );
+ }
+
+ if (!resultAccount) {
+ return <Loading />;
+ }
+ if (resultAccount instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={resultAccount} />;
+ }
+ if (resultAccount.type === "fail") {
+ switch (resultAccount.case) {
+ case HttpStatusCode.Unauthorized:
+ return <LoginForm currentUser={accountName} />;
+ case HttpStatusCode.NotFound:
+ return <LoginForm currentUser={accountName} />;
+ default:
+ assertUnreachable(resultAccount);
+ }
+ }
+ if (!info) {
+ return <Loading />;
+ }
+
+ if (info instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={info} />;
+ }
+ if (info.type === "fail") {
+ switch (info.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(info.case);
+ }
+ }
+
+ const conversionInfo = info.body.conversion_rate;
+ if (!conversionInfo) {
+ return (
+ <div>conversion enabled but server replied without conversion_rate</div>
+ );
+ }
+
+ const account = {
+ balance: Amounts.parseOrThrow(resultAccount.body.balance.amount),
+ balanceIsDebit:
+ resultAccount.body.balance.credit_debit_indicator == "debit",
+ debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold),
+ };
+
+ const {
+ fiat_currency,
+ regional_currency,
+ fiat_currency_specification,
+ regional_currency_specification,
+ } = info.body;
+ const regionalZero = Amounts.zeroOfCurrency(regional_currency);
+ const fiatZero = Amounts.zeroOfCurrency(fiat_currency);
+ const limit = account.balanceIsDebit
+ ? Amounts.sub(account.debitThreshold, account.balance).amount
+ : Amounts.add(account.balance, account.debitThreshold).amount;
+
+ const zeroCalc = {
+ debit: regionalZero,
+ credit: fiatZero,
+ beforeFee: fiatZero,
+ };
+ const [calculationResult, setCalculation] =
+ useState<TransferCalculation>(zeroCalc);
+ const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee);
+ const sellRate = conversionInfo.cashout_ratio;
+ /**
+ * can be in regional currency or fiat currency
+ * depending on the isDebit flag
+ */
+ const inputAmount = Amounts.parseOrThrow(
+ `${form.isDebit ? regional_currency : fiat_currency}:${
+ !form.amount ? "0" : form.amount
+ }`,
+ );
+
+ useEffect(() => {
+ async function doAsync() {
+ await handleError(async () => {
+ const higerThanMin = form.isDebit
+ ? Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1
+ : true;
+ const notZero = Amounts.isNonZero(inputAmount);
+ if (notZero && higerThanMin) {
+ const resp = await (form.isDebit
+ ? calculateFromDebit(inputAmount, sellFee)
+ : calculateFromCredit(inputAmount, sellFee));
+ setCalculation(resp);
+ } else {
+ setCalculation(zeroCalc);
+ }
+ });
+ }
+ doAsync();
+ }, [form.amount, form.isDebit]);
+
+ const calc =
+ calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult;
+
+ const balanceAfter = Amounts.sub(account.balance, calc.debit).amount;
+
+ function updateForm(newForm: typeof form): void {
+ setForm(newForm);
+ }
+ const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({
+ subject: !form.subject ? i18n.str`Required` : undefined,
+ amount: !form.amount
+ ? i18n.str`Required`
+ : !inputAmount
+ ? i18n.str`Invalid`
+ : Amounts.cmp(limit, calc.debit) === -1
+ ? i18n.str`Balance is not enough`
+ : form.isDebit &&
+ Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) < 1
+ ? i18n.str`Needs to be higher than ${
+ Amounts.stringifyValueWithSpec(
+ Amounts.parseOrThrow(conversionInfo.cashout_min_amount),
+ regional_currency_specification,
+ ).normal
+ }`
+ : calculationResult === "amount-is-too-small"
+ ? i18n.str`Amount needs to be higher`
+ : Amounts.isZero(calc.credit)
+ ? i18n.str`The total transfer at destination will be zero`
+ : undefined,
+ });
+ const trimmedAmountStr = form.amount?.trim();
+
+ async function createCashout() {
+ const request_uid = encodeCrock(getRandomBytes(32));
+ await handleError(async () => {
+ // new cashout api doesn't require channel
+ const validChannel =
+ config.supported_tan_channels.length === 0 || form.channel;
+
+ if (!creds || !form.subject || !validChannel) return;
+ const request = {
+ request_uid,
+ amount_credit: Amounts.stringify(calc.credit),
+ amount_debit: Amounts.stringify(calc.debit),
+ subject: form.subject,
+ tan_channel: form.channel,
+ };
+ const resp = await api.createCashout(creds, request);
+ if (resp.type === "ok") {
+ notifyInfo(i18n.str`Cashout created`);
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "create-cashout",
+ id: String(resp.body.challenge_id),
+ sent: AbsoluteTime.never(),
+ location: routeHere.url({}),
+ request,
+ });
+ return onAuthorizationRequired();
+ }
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`Account not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
+ return notify({
+ type: "error",
+ title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_BAD_CONVERSION:
+ return notify({
+ type: "error",
+ title: i18n.str`The conversion rate was incorrectly applied`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`The account does not have sufficient funds`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotImplemented:
+ return notify({
+ type: "error",
+ title: i18n.str`Cashout are disabled`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return notify({
+ type: "error",
+ title: i18n.str`Missing cashout URI in the profile`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
+ return notify({
+ type: "error",
+ title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ assertUnreachable(resp);
+ }
+ });
+ }
+ const cashoutDisabled =
+ config.supported_tan_channels.length < 1 ||
+ !resultAccount.body.cashout_payto_uri;
+
+ const cashoutAccount = !resultAccount.body.cashout_payto_uri
+ ? undefined
+ : parsePaytoUri(resultAccount.body.cashout_payto_uri);
+ const cashoutAccountName = !cashoutAccount
+ ? undefined
+ : cashoutAccount.targetPath;
+
+ const cashoutLegalName = !cashoutAccount
+ ? undefined
+ : cashoutAccount.params["receiver-name"];
+
+ return (
+ <div>
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <section class="mt-4 rounded-sm px-4 py-6 p-8 ">
+ <h2 id="summary-heading" class="font-medium text-lg">
+ <i18n.Translate>Cashout</i18n.Translate>
+ </h2>
+
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Conversion rate</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">{sellRate}</dd>
+ </div>
+
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Balance</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={account.balance}
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Fee</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={sellFee}
+ spec={fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ {cashoutAccountName && cashoutLegalName ? (
+ <Fragment>
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>To account</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">{cashoutAccountName}</dd>
+ </div>
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Legal name</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">{cashoutLegalName}</dd>
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ If this name doesn't match the account holder's name your
+ transaction may fail.
+ </i18n.Translate>
+ </p>
+ </Fragment>
+ ) : (
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <Attention type="warning" title={i18n.str`No cashout account`}>
+ <i18n.Translate>
+ Before doing a cashout you need to complete your profile
+ </i18n.Translate>
+ </Attention>
+ </div>
+ )}
+ </dl>
+ </section>
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ {/* subject */}
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="subject"
+ >
+ {i18n.str`Transfer subject`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="text"
+ class="block w-full rounded-md disabled:bg-gray-200 border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="subject"
+ id="subject"
+ disabled={cashoutDisabled}
+ data-error={!!errors?.subject && form.subject !== undefined}
+ value={form.subject ?? ""}
+ onChange={(e) => {
+ form.subject = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.subject}
+ isDirty={form.subject !== undefined}
+ />
+ </div>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="subject"
+ >
+ {i18n.str`Currency`}
+ </label>
+
+ <div class="mt-2">
+ <button
+ type="button"
+ name="set 50"
+ class=" inline-flex p-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ form.isDebit = true;
+ updateForm(structuredClone(form));
+ }}
+ >
+ {form.isDebit ? (
+ <svg
+ class="self-center flex-none h-5 w-5 text-indigo-600"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ ) : (
+ <svg
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-5 h-5"
+ >
+ <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
+ </svg>
+ )}
+
+ <i18n.Translate>Send {regional_currency}</i18n.Translate>
+ </button>
+ <button
+ type="button"
+ name="set 25"
+ class=" -ml-px -mr-px inline-flex p-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ form.isDebit = false;
+ updateForm(structuredClone(form));
+ }}
+ >
+ {!form.isDebit ? (
+ <svg
+ class="self-center flex-none h-5 w-5 text-indigo-600"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ ) : (
+ <svg
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-5 h-5"
+ >
+ <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
+ </svg>
+ )}
+
+ <i18n.Translate>Receive {fiat_currency}</i18n.Translate>
+ </button>
+ </div>
+ </div>
+
+ {/* amount */}
+ <div class="sm:col-span-5">
+ <div class="flex justify-between">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="amount"
+ >
+ {i18n.str`Amount`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ {/* <button
+ type="button"
+ data-enabled={form.isDebit}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ form.isDebit = !form.isDebit;
+ updateForm(structuredClone(form));
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={form.isDebit}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button> */}
+ </div>
+ <div class="mt-2">
+ <InputAmount
+ name="amount"
+ left
+ currency={form.isDebit ? regional_currency : fiat_currency}
+ value={trimmedAmountStr}
+ onChange={
+ cashoutDisabled
+ ? undefined
+ : (value) => {
+ form.amount = value;
+ updateForm(structuredClone(form));
+ }
+ }
+ />
+ <ShowInputErrorLabel
+ message={errors?.amount}
+ isDirty={form.amount !== undefined}
+ />
+ </div>
+ </div>
+
+ {Amounts.isZero(calc.credit) ? undefined : (
+ <div class="sm:col-span-5">
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Total cost</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={calc.debit}
+ negative
+ withColor
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Balance left</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={balanceAfter}
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+ {Amounts.isZero(sellFee) ||
+ Amounts.isZero(calc.beforeFee) ? undefined : (
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Before fee</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={calc.beforeFee}
+ spec={fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ <div class="flex justify-between items-center border-t-2 afu pt-4">
+ <dt class="text-lg text-gray-900 font-medium">
+ <i18n.Translate>Total cashout transfer</i18n.Translate>
+ </dt>
+ <dd class="text-lg text-gray-900 font-medium">
+ <RenderAmount
+ value={calc.credit}
+ withColor
+ spec={fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeClose.url({})}
+ name="cancel"
+ type="button"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="cashout"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!!errors}
+ onClick={(e) => {
+ e.preventDefault();
+ createCashout();
+ }}
+ >
+ <i18n.Translate>Cashout</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx
new file mode 100644
index 000000000..aba00ad7a
--- /dev/null
+++ b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx
@@ -0,0 +1,194 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { Time } from "../../components/Time.js";
+import { useCashoutDetails, useConversionInfo } from "../../hooks/regional.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { RenderAmount } from "../PaytoWireTransferForm.js";
+
+interface Props {
+ id: string;
+ routeClose: RouteDefinition;
+}
+export function ShowCashoutDetails({ id, routeClose }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const cid = Number.parseInt(id, 10);
+
+ const result = useCashoutDetails(Number.isNaN(cid) ? undefined : cid);
+ const info = useConversionInfo();
+
+ if (Number.isNaN(cid)) {
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`Cashout id should be a number`}
+ />
+ );
+ }
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`This cashout not found. Maybe already aborted.`}
+ ></Attention>
+ );
+ case HttpStatusCode.NotImplemented:
+ return (
+ <Attention type="warning" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ default:
+ assertUnreachable(result);
+ }
+ }
+ if (!info) {
+ return <Loading />;
+ }
+
+ if (info instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={info} />;
+ }
+ if (info.type === "fail") {
+ switch (info.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(info.case);
+ }
+ }
+
+ const { fiat_currency_specification, regional_currency_specification } =
+ info.body;
+
+ return (
+ <div>
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <section class="rounded-sm px-4">
+ <h2 id="summary-heading" class="font-medium text-lg">
+ <i18n.Translate>Cashout detail</i18n.Translate>
+ </h2>
+ <dl class="mt-8 space-y-4">
+ <div class="justify-between items-center flex">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Subject</i18n.Translate>
+ </dt>
+ <dd class="text-sm ">{result.body.subject}</dd>
+ </div>
+ </dl>
+ </section>
+ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
+ <div class="px-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <dl class="space-y-4">
+ {result.body.creation_time.t_s !== "never" ? (
+ <div class="justify-between items-center flex ">
+ <dt class=" text-gray-600">
+ <i18n.Translate>Created</i18n.Translate>
+ </dt>
+ <dd class="text-sm ">
+ <Time
+ format="dd/MM/yyyy HH:mm:ss"
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ result.body.creation_time,
+ )}
+ // relative={Duration.fromSpec({ days: 1 })}
+ />
+ </dd>
+ </div>
+ ) : undefined}
+
+ <div class="flex justify-between items-center border-t-2 afu pt-4">
+ <dt class="text-gray-600">
+ <i18n.Translate>Debited</i18n.Translate>
+ </dt>
+ <dd class=" font-medium">
+ <RenderAmount
+ value={Amounts.parseOrThrow(result.body.amount_debit)}
+ negative
+ withColor
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-gray-600">
+ <span>
+ <i18n.Translate>Credited</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm ">
+ <RenderAmount
+ value={Amounts.parseOrThrow(result.body.amount_credit)}
+ withColor
+ spec={fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <br />
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <a
+ href={routeClose.url({})}
+ name="close"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/rnd.ts b/packages/bank-ui/src/pages/rnd.ts
new file mode 100644
index 000000000..d66fb005b
--- /dev/null
+++ b/packages/bank-ui/src/pages/rnd.ts
@@ -0,0 +1,2907 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
+
+const noun = [
+ "people",
+ "history",
+ "way",
+ "art",
+ "world",
+ "information",
+ "map",
+ "two",
+ "family",
+ "government",
+ "health",
+ "system",
+ "computer",
+ "meat",
+ "year",
+ "thanks",
+ "music",
+ "person",
+ "reading",
+ "method",
+ "data",
+ "food",
+ "understanding",
+ "theory",
+ "law",
+ "bird",
+ "literature",
+ "problem",
+ "software",
+ "control",
+ "knowledge",
+ "power",
+ "ability",
+ "economics",
+ "love",
+ "internet",
+ "television",
+ "science",
+ "library",
+ "nature",
+ "fact",
+ "product",
+ "idea",
+ "temperature",
+ "investment",
+ "area",
+ "society",
+ "activity",
+ "story",
+ "industry",
+ "media",
+ "thing",
+ "oven",
+ "community",
+ "definition",
+ "safety",
+ "quality",
+ "development",
+ "language",
+ "management",
+ "player",
+ "variety",
+ "video",
+ "week",
+ "security",
+ "country",
+ "exam",
+ "movie",
+ "organization",
+ "equipment",
+ "physics",
+ "analysis",
+ "policy",
+ "series",
+ "thought",
+ "basis",
+ "boyfriend",
+ "direction",
+ "strategy",
+ "technology",
+ "army",
+ "camera",
+ "freedom",
+ "paper",
+ "environment",
+ "child",
+ "instance",
+ "month",
+ "truth",
+ "marketing",
+ "university",
+ "writing",
+ "article",
+ "department",
+ "difference",
+ "goal",
+ "news",
+ "audience",
+ "fishing",
+ "growth",
+ "income",
+ "marriage",
+ "user",
+ "combination",
+ "failure",
+ "meaning",
+ "medicine",
+ "philosophy",
+ "teacher",
+ "communication",
+ "night",
+ "chemistry",
+ "disease",
+ "disk",
+ "energy",
+ "nation",
+ "road",
+ "role",
+ "soup",
+ "advertising",
+ "location",
+ "success",
+ "addition",
+ "apartment",
+ "education",
+ "math",
+ "moment",
+ "painting",
+ "politics",
+ "attention",
+ "decision",
+ "event",
+ "property",
+ "shopping",
+ "student",
+ "wood",
+ "competition",
+ "distribution",
+ "entertainment",
+ "office",
+ "population",
+ "president",
+ "unit",
+ "category",
+ "cigarette",
+ "context",
+ "introduction",
+ "opportunity",
+ "performance",
+ "driver",
+ "flight",
+ "length",
+ "magazine",
+ "newspaper",
+ "relationship",
+ "teaching",
+ "cell",
+ "dealer",
+ "finding",
+ "lake",
+ "member",
+ "message",
+ "phone",
+ "scene",
+ "appearance",
+ "association",
+ "concept",
+ "customer",
+ "death",
+ "discussion",
+ "housing",
+ "inflation",
+ "insurance",
+ "mood",
+ "woman",
+ "advice",
+ "blood",
+ "effort",
+ "expression",
+ "importance",
+ "opinion",
+ "payment",
+ "reality",
+ "responsibility",
+ "situation",
+ "skill",
+ "statement",
+ "wealth",
+ "application",
+ "city",
+ "county",
+ "depth",
+ "estate",
+ "foundation",
+ "grandmother",
+ "heart",
+ "perspective",
+ "photo",
+ "recipe",
+ "studio",
+ "topic",
+ "collection",
+ "depression",
+ "imagination",
+ "passion",
+ "percentage",
+ "resource",
+ "setting",
+ "ad",
+ "agency",
+ "college",
+ "connection",
+ "criticism",
+ "debt",
+ "description",
+ "memory",
+ "patience",
+ "secretary",
+ "solution",
+ "administration",
+ "aspect",
+ "attitude",
+ "director",
+ "personality",
+ "psychology",
+ "recommendation",
+ "response",
+ "selection",
+ "storage",
+ "version",
+ "alcohol",
+ "argument",
+ "complaint",
+ "contract",
+ "emphasis",
+ "highway",
+ "loss",
+ "membership",
+ "possession",
+ "preparation",
+ "steak",
+ "union",
+ "agreement",
+ "cancer",
+ "currency",
+ "employment",
+ "engineering",
+ "entry",
+ "interaction",
+ "mixture",
+ "preference",
+ "region",
+ "republic",
+ "tradition",
+ "virus",
+ "actor",
+ "classroom",
+ "delivery",
+ "device",
+ "difficulty",
+ "drama",
+ "election",
+ "engine",
+ "football",
+ "guidance",
+ "hotel",
+ "owner",
+ "priority",
+ "protection",
+ "suggestion",
+ "tension",
+ "variation",
+ "anxiety",
+ "atmosphere",
+ "awareness",
+ "bath",
+ "bread",
+ "candidate",
+ "climate",
+ "comparison",
+ "confusion",
+ "construction",
+ "elevator",
+ "emotion",
+ "employee",
+ "employer",
+ "guest",
+ "height",
+ "leadership",
+ "mall",
+ "manager",
+ "operation",
+ "recording",
+ "sample",
+ "transportation",
+ "charity",
+ "cousin",
+ "disaster",
+ "editor",
+ "efficiency",
+ "excitement",
+ "extent",
+ "feedback",
+ "guitar",
+ "homework",
+ "leader",
+ "mom",
+ "outcome",
+ "permission",
+ "presentation",
+ "promotion",
+ "reflection",
+ "refrigerator",
+ "resolution",
+ "revenue",
+ "session",
+ "singer",
+ "tennis",
+ "basket",
+ "bonus",
+ "cabinet",
+ "childhood",
+ "church",
+ "clothes",
+ "coffee",
+ "dinner",
+ "drawing",
+ "hair",
+ "hearing",
+ "initiative",
+ "judgment",
+ "lab",
+ "measurement",
+ "mode",
+ "mud",
+ "orange",
+ "poetry",
+ "police",
+ "possibility",
+ "procedure",
+ "queen",
+ "ratio",
+ "relation",
+ "restaurant",
+ "satisfaction",
+ "sector",
+ "signature",
+ "significance",
+ "song",
+ "tooth",
+ "town",
+ "vehicle",
+ "volume",
+ "wife",
+ "accident",
+ "airport",
+ "appointment",
+ "arrival",
+ "assumption",
+ "baseball",
+ "chapter",
+ "committee",
+ "conversation",
+ "database",
+ "enthusiasm",
+ "error",
+ "explanation",
+ "farmer",
+ "gate",
+ "girl",
+ "hall",
+ "historian",
+ "hospital",
+ "injury",
+ "instruction",
+ "maintenance",
+ "manufacturer",
+ "meal",
+ "perception",
+ "pie",
+ "poem",
+ "presence",
+ "proposal",
+ "reception",
+ "replacement",
+ "revolution",
+ "river",
+ "son",
+ "speech",
+ "tea",
+ "village",
+ "warning",
+ "winner",
+ "worker",
+ "writer",
+ "assistance",
+ "breath",
+ "buyer",
+ "chest",
+ "chocolate",
+ "conclusion",
+ "contribution",
+ "cookie",
+ "courage",
+ "dad",
+ "desk",
+ "drawer",
+ "establishment",
+ "examination",
+ "garbage",
+ "grocery",
+ "honey",
+ "impression",
+ "improvement",
+ "independence",
+ "insect",
+ "inspection",
+ "inspector",
+ "king",
+ "ladder",
+ "menu",
+ "penalty",
+ "piano",
+ "potato",
+ "profession",
+ "professor",
+ "quantity",
+ "reaction",
+ "requirement",
+ "salad",
+ "sister",
+ "supermarket",
+ "tongue",
+ "weakness",
+ "wedding",
+ "affair",
+ "ambition",
+ "analyst",
+ "apple",
+ "assignment",
+ "assistant",
+ "bathroom",
+ "bedroom",
+ "beer",
+ "birthday",
+ "celebration",
+ "championship",
+ "cheek",
+ "client",
+ "consequence",
+ "departure",
+ "diamond",
+ "dirt",
+ "ear",
+ "fortune",
+ "friendship",
+ "funeral",
+ "gene",
+ "girlfriend",
+ "hat",
+ "indication",
+ "intention",
+ "lady",
+ "midnight",
+ "negotiation",
+ "obligation",
+ "passenger",
+ "pizza",
+ "platform",
+ "poet",
+ "pollution",
+ "recognition",
+ "reputation",
+ "shirt",
+ "sir",
+ "speaker",
+ "stranger",
+ "surgery",
+ "sympathy",
+ "tale",
+ "throat",
+ "trainer",
+ "uncle",
+ "youth",
+ "time",
+ "work",
+ "film",
+ "water",
+ "money",
+ "example",
+ "while",
+ "business",
+ "study",
+ "game",
+ "life",
+ "form",
+ "air",
+ "day",
+ "place",
+ "number",
+ "part",
+ "field",
+ "fish",
+ "back",
+ "process",
+ "heat",
+ "hand",
+ "experience",
+ "job",
+ "book",
+ "end",
+ "point",
+ "type",
+ "home",
+ "economy",
+ "value",
+ "body",
+ "market",
+ "guide",
+ "interest",
+ "state",
+ "radio",
+ "course",
+ "company",
+ "price",
+ "size",
+ "card",
+ "list",
+ "mind",
+ "trade",
+ "line",
+ "care",
+ "group",
+ "risk",
+ "word",
+ "fat",
+ "force",
+ "key",
+ "light",
+ "training",
+ "name",
+ "school",
+ "top",
+ "amount",
+ "level",
+ "order",
+ "practice",
+ "research",
+ "sense",
+ "service",
+ "piece",
+ "web",
+ "boss",
+ "sport",
+ "fun",
+ "house",
+ "page",
+ "term",
+ "test",
+ "answer",
+ "sound",
+ "focus",
+ "matter",
+ "kind",
+ "soil",
+ "board",
+ "oil",
+ "picture",
+ "access",
+ "garden",
+ "range",
+ "rate",
+ "reason",
+ "future",
+ "site",
+ "demand",
+ "exercise",
+ "image",
+ "case",
+ "cause",
+ "coast",
+ "action",
+ "age",
+ "bad",
+ "boat",
+ "record",
+ "result",
+ "section",
+ "building",
+ "mouse",
+ "cash",
+ "class",
+ "nothing",
+ "period",
+ "plan",
+ "store",
+ "tax",
+ "side",
+ "subject",
+ "space",
+ "rule",
+ "stock",
+ "weather",
+ "chance",
+ "figure",
+ "man",
+ "model",
+ "source",
+ "beginning",
+ "earth",
+ "program",
+ "chicken",
+ "design",
+ "feature",
+ "head",
+ "material",
+ "purpose",
+ "question",
+ "rock",
+ "salt",
+ "act",
+ "birth",
+ "car",
+ "dog",
+ "object",
+ "scale",
+ "sun",
+ "note",
+ "profit",
+ "rent",
+ "speed",
+ "style",
+ "war",
+ "bank",
+ "craft",
+ "half",
+ "inside",
+ "outside",
+ "standard",
+ "bus",
+ "exchange",
+ "eye",
+ "fire",
+ "position",
+ "pressure",
+ "stress",
+ "advantage",
+ "benefit",
+ "box",
+ "frame",
+ "issue",
+ "step",
+ "cycle",
+ "face",
+ "item",
+ "metal",
+ "paint",
+ "review",
+ "room",
+ "screen",
+ "structure",
+ "view",
+ "account",
+ "ball",
+ "discipline",
+ "medium",
+ "share",
+ "balance",
+ "bit",
+ "black",
+ "bottom",
+ "choice",
+ "gift",
+ "impact",
+ "machine",
+ "shape",
+ "tool",
+ "wind",
+ "address",
+ "average",
+ "career",
+ "culture",
+ "morning",
+ "pot",
+ "sign",
+ "table",
+ "task",
+ "condition",
+ "contact",
+ "credit",
+ "egg",
+ "hope",
+ "ice",
+ "network",
+ "north",
+ "square",
+ "attempt",
+ "date",
+ "effect",
+ "link",
+ "post",
+ "star",
+ "voice",
+ "capital",
+ "challenge",
+ "friend",
+ "self",
+ "shot",
+ "brush",
+ "couple",
+ "debate",
+ "exit",
+ "front",
+ "function",
+ "lack",
+ "living",
+ "plant",
+ "plastic",
+ "spot",
+ "summer",
+ "taste",
+ "theme",
+ "track",
+ "wing",
+ "brain",
+ "button",
+ "click",
+ "desire",
+ "foot",
+ "gas",
+ "influence",
+ "notice",
+ "rain",
+ "wall",
+ "base",
+ "damage",
+ "distance",
+ "feeling",
+ "pair",
+ "savings",
+ "staff",
+ "sugar",
+ "target",
+ "text",
+ "animal",
+ "author",
+ "budget",
+ "discount",
+ "file",
+ "ground",
+ "lesson",
+ "minute",
+ "officer",
+ "phase",
+ "reference",
+ "register",
+ "sky",
+ "stage",
+ "stick",
+ "title",
+ "trouble",
+ "bowl",
+ "bridge",
+ "campaign",
+ "character",
+ "club",
+ "edge",
+ "evidence",
+ "fan",
+ "letter",
+ "lock",
+ "maximum",
+ "novel",
+ "option",
+ "pack",
+ "park",
+ "plenty",
+ "quarter",
+ "skin",
+ "sort",
+ "weight",
+ "baby",
+ "background",
+ "carry",
+ "dish",
+ "factor",
+ "fruit",
+ "glass",
+ "joint",
+ "master",
+ "muscle",
+ "red",
+ "strength",
+ "traffic",
+ "trip",
+ "vegetable",
+ "appeal",
+ "chart",
+ "gear",
+ "ideal",
+ "kitchen",
+ "land",
+ "log",
+ "mother",
+ "net",
+ "party",
+ "principle",
+ "relative",
+ "sale",
+ "season",
+ "signal",
+ "spirit",
+ "street",
+ "tree",
+ "wave",
+ "belt",
+ "bench",
+ "commission",
+ "copy",
+ "drop",
+ "minimum",
+ "path",
+ "progress",
+ "project",
+ "sea",
+ "south",
+ "status",
+ "stuff",
+ "ticket",
+ "tour",
+ "angle",
+ "blue",
+ "breakfast",
+ "confidence",
+ "daughter",
+ "degree",
+ "doctor",
+ "dot",
+ "dream",
+ "duty",
+ "essay",
+ "father",
+ "fee",
+ "finance",
+ "hour",
+ "juice",
+ "limit",
+ "luck",
+ "milk",
+ "mouth",
+ "peace",
+ "pipe",
+ "seat",
+ "stable",
+ "storm",
+ "substance",
+ "team",
+ "trick",
+ "afternoon",
+ "bat",
+ "beach",
+ "blank",
+ "catch",
+ "chain",
+ "consideration",
+ "cream",
+ "crew",
+ "detail",
+ "gold",
+ "interview",
+ "kid",
+ "mark",
+ "match",
+ "mission",
+ "pain",
+ "pleasure",
+ "score",
+ "screw",
+ "sex",
+ "shop",
+ "shower",
+ "suit",
+ "tone",
+ "window",
+ "agent",
+ "band",
+ "block",
+ "bone",
+ "calendar",
+ "cap",
+ "coat",
+ "contest",
+ "corner",
+ "court",
+ "cup",
+ "district",
+ "door",
+ "east",
+ "finger",
+ "garage",
+ "guarantee",
+ "hole",
+ "hook",
+ "implement",
+ "layer",
+ "lecture",
+ "lie",
+ "manner",
+ "meeting",
+ "nose",
+ "parking",
+ "partner",
+ "profile",
+ "respect",
+ "rice",
+ "routine",
+ "schedule",
+ "swimming",
+ "telephone",
+ "tip",
+ "winter",
+ "airline",
+ "bag",
+ "battle",
+ "bed",
+ "bill",
+ "bother",
+ "cake",
+ "code",
+ "curve",
+ "designer",
+ "dimension",
+ "dress",
+ "ease",
+ "emergency",
+ "evening",
+ "extension",
+ "farm",
+ "fight",
+ "gap",
+ "grade",
+ "holiday",
+ "horror",
+ "horse",
+ "host",
+ "husband",
+ "loan",
+ "mistake",
+ "mountain",
+ "nail",
+ "noise",
+ "occasion",
+ "package",
+ "patient",
+ "pause",
+ "phrase",
+ "proof",
+ "race",
+ "relief",
+ "sand",
+ "sentence",
+ "shoulder",
+ "smoke",
+ "stomach",
+ "string",
+ "tourist",
+ "towel",
+ "vacation",
+ "west",
+ "wheel",
+ "wine",
+ "arm",
+ "aside",
+ "associate",
+ "bet",
+ "blow",
+ "border",
+ "branch",
+ "breast",
+ "brother",
+ "buddy",
+ "bunch",
+ "chip",
+ "coach",
+ "cross",
+ "document",
+ "draft",
+ "dust",
+ "expert",
+ "floor",
+ "god",
+ "golf",
+ "habit",
+ "iron",
+ "judge",
+ "knife",
+ "landscape",
+ "league",
+ "mail",
+ "mess",
+ "native",
+ "opening",
+ "parent",
+ "pattern",
+ "pin",
+ "pool",
+ "pound",
+ "request",
+ "salary",
+ "shame",
+ "shelter",
+ "shoe",
+ "silver",
+ "tackle",
+ "tank",
+ "trust",
+ "assist",
+ "bake",
+ "bar",
+ "bell",
+ "bike",
+ "blame",
+ "boy",
+ "brick",
+ "chair",
+ "closet",
+ "clue",
+ "collar",
+ "comment",
+ "conference",
+ "devil",
+ "diet",
+ "fear",
+ "fuel",
+ "glove",
+ "jacket",
+ "lunch",
+ "monitor",
+ "mortgage",
+ "nurse",
+ "pace",
+ "panic",
+ "peak",
+ "plane",
+ "reward",
+ "row",
+ "sandwich",
+ "shock",
+ "spite",
+ "spray",
+ "surprise",
+ "till",
+ "transition",
+ "weekend",
+ "welcome",
+ "yard",
+ "alarm",
+ "bend",
+ "bicycle",
+ "bite",
+ "blind",
+ "bottle",
+ "cable",
+ "candle",
+ "clerk",
+ "cloud",
+ "concert",
+ "counter",
+ "flower",
+ "grandfather",
+ "harm",
+ "knee",
+ "lawyer",
+ "leather",
+ "load",
+ "mirror",
+ "neck",
+ "pension",
+ "plate",
+ "purple",
+ "ruin",
+ "ship",
+ "skirt",
+ "slice",
+ "snow",
+ "specialist",
+ "stroke",
+ "switch",
+ "trash",
+ "tune",
+ "zone",
+ "anger",
+ "award",
+ "bid",
+ "bitter",
+ "boot",
+ "bug",
+ "camp",
+ "candy",
+ "carpet",
+ "cat",
+ "champion",
+ "channel",
+ "clock",
+ "comfort",
+ "cow",
+ "crack",
+ "engineer",
+ "entrance",
+ "fault",
+ "grass",
+ "guy",
+ "hell",
+ "highlight",
+ "incident",
+ "island",
+ "joke",
+ "jury",
+ "leg",
+ "lip",
+ "mate",
+ "motor",
+ "nerve",
+ "passage",
+ "pen",
+ "pride",
+ "priest",
+ "prize",
+ "promise",
+ "resident",
+ "resort",
+ "ring",
+ "roof",
+ "rope",
+ "sail",
+ "scheme",
+ "script",
+ "sock",
+ "station",
+ "toe",
+ "tower",
+ "truck",
+ "witness",
+ "a",
+ "you",
+ "it",
+ "can",
+ "will",
+ "if",
+ "one",
+ "many",
+ "most",
+ "other",
+ "use",
+ "make",
+ "good",
+ "look",
+ "help",
+ "go",
+ "great",
+ "being",
+ "few",
+ "might",
+ "still",
+ "public",
+ "read",
+ "keep",
+ "start",
+ "give",
+ "human",
+ "local",
+ "general",
+ "she",
+ "specific",
+ "long",
+ "play",
+ "feel",
+ "high",
+ "tonight",
+ "put",
+ "common",
+ "set",
+ "change",
+ "simple",
+ "past",
+ "big",
+ "possible",
+ "particular",
+ "today",
+ "major",
+ "personal",
+ "current",
+ "national",
+ "cut",
+ "natural",
+ "physical",
+ "show",
+ "try",
+ "check",
+ "second",
+ "call",
+ "move",
+ "pay",
+ "let",
+ "increase",
+ "single",
+ "individual",
+ "turn",
+ "ask",
+ "buy",
+ "guard",
+ "hold",
+ "main",
+ "offer",
+ "potential",
+ "professional",
+ "international",
+ "travel",
+ "cook",
+ "alternative",
+ "following",
+ "special",
+ "working",
+ "whole",
+ "dance",
+ "excuse",
+ "cold",
+ "commercial",
+ "low",
+ "purchase",
+ "deal",
+ "primary",
+ "worth",
+ "fall",
+ "necessary",
+ "positive",
+ "produce",
+ "search",
+ "present",
+ "spend",
+ "talk",
+ "creative",
+ "tell",
+ "cost",
+ "drive",
+ "green",
+ "support",
+ "glad",
+ "remove",
+ "return",
+ "run",
+ "complex",
+ "due",
+ "effective",
+ "middle",
+ "regular",
+ "reserve",
+ "independent",
+ "leave",
+ "original",
+ "reach",
+ "rest",
+ "serve",
+ "watch",
+ "beautiful",
+ "charge",
+ "active",
+ "break",
+ "negative",
+ "safe",
+ "stay",
+ "visit",
+ "visual",
+ "affect",
+ "cover",
+ "report",
+ "rise",
+ "walk",
+ "white",
+ "beyond",
+ "junior",
+ "pick",
+ "unique",
+ "anything",
+ "classic",
+ "final",
+ "lift",
+ "mix",
+ "private",
+ "stop",
+ "teach",
+ "western",
+ "concern",
+ "familiar",
+ "fly",
+ "official",
+ "broad",
+ "comfortable",
+ "gain",
+ "maybe",
+ "rich",
+ "save",
+ "stand",
+ "young",
+ "fail",
+ "heavy",
+ "hello",
+ "lead",
+ "listen",
+ "valuable",
+ "worry",
+ "handle",
+ "leading",
+ "meet",
+ "release",
+ "sell",
+ "finish",
+ "normal",
+ "press",
+ "ride",
+ "secret",
+ "spread",
+ "spring",
+ "tough",
+ "wait",
+ "brown",
+ "deep",
+ "display",
+ "flow",
+ "hit",
+ "objective",
+ "shoot",
+ "touch",
+ "cancel",
+ "chemical",
+ "cry",
+ "dump",
+ "extreme",
+ "push",
+ "conflict",
+ "eat",
+ "fill",
+ "formal",
+ "jump",
+ "kick",
+ "opposite",
+ "pass",
+ "pitch",
+ "remote",
+ "total",
+ "treat",
+ "vast",
+ "abuse",
+ "beat",
+ "burn",
+ "deposit",
+ "print",
+ "raise",
+ "sleep",
+ "somewhere",
+ "advance",
+ "anywhere",
+ "consist",
+ "dark",
+ "double",
+ "draw",
+ "equal",
+ "fix",
+ "hire",
+ "internal",
+ "join",
+ "kill",
+ "sensitive",
+ "tap",
+ "win",
+ "attack",
+ "claim",
+ "constant",
+ "drag",
+ "drink",
+ "guess",
+ "minor",
+ "pull",
+ "raw",
+ "soft",
+ "solid",
+ "wear",
+ "weird",
+ "wonder",
+ "annual",
+ "count",
+ "dead",
+ "doubt",
+ "feed",
+ "forever",
+ "impress",
+ "nobody",
+ "repeat",
+ "round",
+ "sing",
+ "slide",
+ "strip",
+ "whereas",
+ "wish",
+ "combine",
+ "command",
+ "dig",
+ "divide",
+ "equivalent",
+ "hang",
+ "hunt",
+ "initial",
+ "march",
+ "mention",
+ "smell",
+ "spiritual",
+ "survey",
+ "tie",
+ "adult",
+ "brief",
+ "crazy",
+ "escape",
+ "gather",
+ "hate",
+ "prior",
+ "repair",
+ "rough",
+ "sad",
+ "scratch",
+ "sick",
+ "strike",
+ "employ",
+ "external",
+ "hurt",
+ "illegal",
+ "laugh",
+ "lay",
+ "mobile",
+ "nasty",
+ "ordinary",
+ "respond",
+ "royal",
+ "senior",
+ "split",
+ "strain",
+ "struggle",
+ "swim",
+ "train",
+ "upper",
+ "wash",
+ "yellow",
+ "convert",
+ "crash",
+ "dependent",
+ "fold",
+ "funny",
+ "grab",
+ "hide",
+ "miss",
+ "permit",
+ "quote",
+ "recover",
+ "resolve",
+ "roll",
+ "sink",
+ "slip",
+ "spare",
+ "suspect",
+ "sweet",
+ "swing",
+ "twist",
+ "upstairs",
+ "usual",
+ "abroad",
+ "brave",
+ "calm",
+ "concentrate",
+ "estimate",
+ "grand",
+ "male",
+ "mine",
+ "prompt",
+ "quiet",
+ "refuse",
+ "regret",
+ "reveal",
+ "rush",
+ "shake",
+ "shift",
+ "shine",
+ "steal",
+ "suck",
+ "surround",
+ "anybody",
+ "bear",
+ "brilliant",
+ "dare",
+ "dear",
+ "delay",
+ "drunk",
+ "female",
+ "hurry",
+ "inevitable",
+ "invite",
+ "kiss",
+ "neat",
+ "pop",
+ "punch",
+ "quit",
+ "reply",
+ "representative",
+ "resist",
+ "rip",
+ "rub",
+ "silly",
+ "smile",
+ "spell",
+ "stretch",
+ "stupid",
+ "tear",
+ "temporary",
+ "tomorrow",
+ "wake",
+ "wrap",
+ "yesterday",
+];
+
+const adj = [
+ "abandoned",
+ "able",
+ "absolute",
+ "adorable",
+ "adventurous",
+ "academic",
+ "acceptable",
+ "acclaimed",
+ "accomplished",
+ "accurate",
+ "aching",
+ "acidic",
+ "acrobatic",
+ "active",
+ "actual",
+ "adept",
+ "admirable",
+ "admired",
+ "adolescent",
+ "adorable",
+ "adored",
+ "advanced",
+ "afraid",
+ "affectionate",
+ "aged",
+ "aggravating",
+ "aggressive",
+ "agile",
+ "agitated",
+ "agonizing",
+ "agreeable",
+ "ajar",
+ "alarmed",
+ "alarming",
+ "alert",
+ "alienated",
+ "alive",
+ "all",
+ "altruistic",
+ "amazing",
+ "ambitious",
+ "ample",
+ "amused",
+ "amusing",
+ "anchored",
+ "ancient",
+ "angelic",
+ "angry",
+ "anguished",
+ "animated",
+ "annual",
+ "another",
+ "antique",
+ "anxious",
+ "any",
+ "apprehensive",
+ "appropriate",
+ "apt",
+ "arctic",
+ "arid",
+ "aromatic",
+ "artistic",
+ "ashamed",
+ "assured",
+ "astonishing",
+ "athletic",
+ "attached",
+ "attentive",
+ "attractive",
+ "austere",
+ "authentic",
+ "authorized",
+ "automatic",
+ "avaricious",
+ "average",
+ "aware",
+ "awesome",
+ "awful",
+ "awkward",
+ "babyish",
+ "bad",
+ "back",
+ "baggy",
+ "bare",
+ "barren",
+ "basic",
+ "beautiful",
+ "belated",
+ "beloved",
+ "beneficial",
+ "better",
+ "best",
+ "bewitched",
+ "big",
+ "big-hearted",
+ "biodegradable",
+ "bite-sized",
+ "bitter",
+ "black",
+ "black-and-white",
+ "bland",
+ "blank",
+ "blaring",
+ "bleak",
+ "blind",
+ "blissful",
+ "blond",
+ "blue",
+ "blushing",
+ "bogus",
+ "boiling",
+ "bold",
+ "bony",
+ "boring",
+ "bossy",
+ "both",
+ "bouncy",
+ "bountiful",
+ "bowed",
+ "brave",
+ "breakable",
+ "brief",
+ "bright",
+ "brilliant",
+ "brisk",
+ "broken",
+ "bronze",
+ "brown",
+ "bruised",
+ "bubbly",
+ "bulky",
+ "bumpy",
+ "buoyant",
+ "burdensome",
+ "burly",
+ "bustling",
+ "busy",
+ "buttery",
+ "buzzing",
+ "calculating",
+ "calm",
+ "candid",
+ "canine",
+ "capital",
+ "carefree",
+ "careful",
+ "careless",
+ "caring",
+ "cautious",
+ "cavernous",
+ "celebrated",
+ "charming",
+ "cheap",
+ "cheerful",
+ "cheery",
+ "chief",
+ "chilly",
+ "chubby",
+ "circular",
+ "classic",
+ "clean",
+ "clear",
+ "clear-cut",
+ "clever",
+ "close",
+ "closed",
+ "cloudy",
+ "clueless",
+ "clumsy",
+ "cluttered",
+ "coarse",
+ "cold",
+ "colorful",
+ "colorless",
+ "colossal",
+ "comfortable",
+ "common",
+ "compassionate",
+ "competent",
+ "complete",
+ "complex",
+ "complicated",
+ "composed",
+ "concerned",
+ "concrete",
+ "confused",
+ "conscious",
+ "considerate",
+ "constant",
+ "content",
+ "conventional",
+ "cooked",
+ "cool",
+ "cooperative",
+ "coordinated",
+ "corny",
+ "corrupt",
+ "costly",
+ "courageous",
+ "courteous",
+ "crafty",
+ "crazy",
+ "creamy",
+ "creative",
+ "creepy",
+ "criminal",
+ "crisp",
+ "critical",
+ "crooked",
+ "crowded",
+ "cruel",
+ "crushing",
+ "cuddly",
+ "cultivated",
+ "cultured",
+ "cumbersome",
+ "curly",
+ "curvy",
+ "cute",
+ "cylindrical",
+ "damaged",
+ "damp",
+ "dangerous",
+ "dapper",
+ "daring",
+ "darling",
+ "dark",
+ "dazzling",
+ "dead",
+ "deadly",
+ "deafening",
+ "dear",
+ "dearest",
+ "decent",
+ "decimal",
+ "decisive",
+ "deep",
+ "defenseless",
+ "defensive",
+ "defiant",
+ "deficient",
+ "definite",
+ "definitive",
+ "delayed",
+ "delectable",
+ "delicious",
+ "delightful",
+ "delirious",
+ "demanding",
+ "dense",
+ "dental",
+ "dependable",
+ "dependent",
+ "descriptive",
+ "deserted",
+ "detailed",
+ "determined",
+ "devoted",
+ "different",
+ "difficult",
+ "digital",
+ "diligent",
+ "dim",
+ "dimpled",
+ "dimwitted",
+ "direct",
+ "disastrous",
+ "discrete",
+ "disfigured",
+ "disgusting",
+ "disloyal",
+ "dismal",
+ "distant",
+ "downright",
+ "dreary",
+ "dirty",
+ "disguised",
+ "dishonest",
+ "dismal",
+ "distant",
+ "distinct",
+ "distorted",
+ "dizzy",
+ "dopey",
+ "doting",
+ "double",
+ "downright",
+ "drab",
+ "drafty",
+ "dramatic",
+ "dreary",
+ "droopy",
+ "dry",
+ "dual",
+ "dull",
+ "dutiful",
+ "each",
+ "eager",
+ "earnest",
+ "early",
+ "easy",
+ "easy-going",
+ "ecstatic",
+ "edible",
+ "educated",
+ "elaborate",
+ "elastic",
+ "elated",
+ "elderly",
+ "electric",
+ "elegant",
+ "elementary",
+ "elliptical",
+ "embarrassed",
+ "embellished",
+ "eminent",
+ "emotional",
+ "empty",
+ "enchanted",
+ "enchanting",
+ "energetic",
+ "enlightened",
+ "enormous",
+ "enraged",
+ "entire",
+ "envious",
+ "equal",
+ "equatorial",
+ "essential",
+ "esteemed",
+ "ethical",
+ "euphoric",
+ "even",
+ "evergreen",
+ "everlasting",
+ "every",
+ "evil",
+ "exalted",
+ "excellent",
+ "exemplary",
+ "exhausted",
+ "excitable",
+ "excited",
+ "exciting",
+ "exotic",
+ "expensive",
+ "experienced",
+ "expert",
+ "extraneous",
+ "extroverted",
+ "extra-large",
+ "extra-small",
+ "fabulous",
+ "failing",
+ "faint",
+ "fair",
+ "faithful",
+ "fake",
+ "false",
+ "familiar",
+ "famous",
+ "fancy",
+ "fantastic",
+ "far",
+ "faraway",
+ "far-flung",
+ "far-off",
+ "fast",
+ "fat",
+ "fatal",
+ "fatherly",
+ "favorable",
+ "favorite",
+ "fearful",
+ "fearless",
+ "feisty",
+ "feline",
+ "female",
+ "feminine",
+ "few",
+ "fickle",
+ "filthy",
+ "fine",
+ "finished",
+ "firm",
+ "first",
+ "firsthand",
+ "fitting",
+ "fixed",
+ "flaky",
+ "flamboyant",
+ "flashy",
+ "flat",
+ "flawed",
+ "flawless",
+ "flickering",
+ "flimsy",
+ "flippant",
+ "flowery",
+ "fluffy",
+ "fluid",
+ "flustered",
+ "focused",
+ "fond",
+ "foolhardy",
+ "foolish",
+ "forceful",
+ "forked",
+ "formal",
+ "forsaken",
+ "forthright",
+ "fortunate",
+ "fragrant",
+ "frail",
+ "frank",
+ "frayed",
+ "free",
+ "French",
+ "fresh",
+ "frequent",
+ "friendly",
+ "frightened",
+ "frightening",
+ "frigid",
+ "frilly",
+ "frizzy",
+ "frivolous",
+ "front",
+ "frosty",
+ "frozen",
+ "frugal",
+ "fruitful",
+ "full",
+ "fumbling",
+ "functional",
+ "funny",
+ "fussy",
+ "fuzzy",
+ "gargantuan",
+ "gaseous",
+ "general",
+ "generous",
+ "gentle",
+ "genuine",
+ "giant",
+ "giddy",
+ "gigantic",
+ "gifted",
+ "giving",
+ "glamorous",
+ "glaring",
+ "glass",
+ "gleaming",
+ "gleeful",
+ "glistening",
+ "glittering",
+ "gloomy",
+ "glorious",
+ "glossy",
+ "glum",
+ "golden",
+ "good",
+ "good-natured",
+ "gorgeous",
+ "graceful",
+ "gracious",
+ "grand",
+ "grandiose",
+ "granular",
+ "grateful",
+ "grave",
+ "gray",
+ "great",
+ "greedy",
+ "green",
+ "gregarious",
+ "grim",
+ "grimy",
+ "gripping",
+ "grizzled",
+ "gross",
+ "grotesque",
+ "grouchy",
+ "grounded",
+ "growing",
+ "growling",
+ "grown",
+ "grubby",
+ "gruesome",
+ "grumpy",
+ "guilty",
+ "gullible",
+ "gummy",
+ "hairy",
+ "half",
+ "handmade",
+ "handsome",
+ "handy",
+ "happy",
+ "happy-go-lucky",
+ "hard",
+ "hard-to-find",
+ "harmful",
+ "harmless",
+ "harmonious",
+ "harsh",
+ "hasty",
+ "hateful",
+ "haunting",
+ "healthy",
+ "heartfelt",
+ "hearty",
+ "heavenly",
+ "heavy",
+ "hefty",
+ "helpful",
+ "helpless",
+ "hidden",
+ "hideous",
+ "high",
+ "high-level",
+ "hilarious",
+ "hoarse",
+ "hollow",
+ "homely",
+ "honest",
+ "honorable",
+ "honored",
+ "hopeful",
+ "horrible",
+ "hospitable",
+ "hot",
+ "huge",
+ "humble",
+ "humiliating",
+ "humming",
+ "humongous",
+ "hungry",
+ "hurtful",
+ "husky",
+ "icky",
+ "icy",
+ "ideal",
+ "idealistic",
+ "identical",
+ "idle",
+ "idiotic",
+ "idolized",
+ "ignorant",
+ "ill",
+ "illegal",
+ "ill-fated",
+ "ill-informed",
+ "illiterate",
+ "illustrious",
+ "imaginary",
+ "imaginative",
+ "immaculate",
+ "immaterial",
+ "immediate",
+ "immense",
+ "impassioned",
+ "impeccable",
+ "impartial",
+ "imperfect",
+ "imperturbable",
+ "impish",
+ "impolite",
+ "important",
+ "impossible",
+ "impractical",
+ "impressionable",
+ "impressive",
+ "improbable",
+ "impure",
+ "inborn",
+ "incomparable",
+ "incompatible",
+ "incomplete",
+ "inconsequential",
+ "incredible",
+ "indelible",
+ "inexperienced",
+ "indolent",
+ "infamous",
+ "infantile",
+ "infatuated",
+ "inferior",
+ "infinite",
+ "informal",
+ "innocent",
+ "insecure",
+ "insidious",
+ "insignificant",
+ "insistent",
+ "instructive",
+ "insubstantial",
+ "intelligent",
+ "intent",
+ "intentional",
+ "interesting",
+ "internal",
+ "international",
+ "intrepid",
+ "ironclad",
+ "irresponsible",
+ "irritating",
+ "itchy",
+ "jaded",
+ "jagged",
+ "jam-packed",
+ "jaunty",
+ "jealous",
+ "jittery",
+ "joint",
+ "jolly",
+ "jovial",
+ "joyful",
+ "joyous",
+ "jubilant",
+ "judicious",
+ "juicy",
+ "jumbo",
+ "junior",
+ "jumpy",
+ "juvenile",
+ "kaleidoscopic",
+ "keen",
+ "key",
+ "kind",
+ "kindhearted",
+ "kindly",
+ "klutzy",
+ "knobby",
+ "knotty",
+ "knowledgeable",
+ "knowing",
+ "known",
+ "kooky",
+ "kosher",
+ "lame",
+ "lanky",
+ "large",
+ "last",
+ "lasting",
+ "late",
+ "lavish",
+ "lawful",
+ "lazy",
+ "leading",
+ "lean",
+ "leafy",
+ "left",
+ "legal",
+ "legitimate",
+ "light",
+ "lighthearted",
+ "likable",
+ "likely",
+ "limited",
+ "limp",
+ "limping",
+ "linear",
+ "lined",
+ "liquid",
+ "little",
+ "live",
+ "lively",
+ "livid",
+ "loathsome",
+ "lone",
+ "lonely",
+ "long",
+ "long-term",
+ "loose",
+ "lopsided",
+ "lost",
+ "loud",
+ "lovable",
+ "lovely",
+ "loving",
+ "low",
+ "loyal",
+ "lucky",
+ "lumbering",
+ "luminous",
+ "lumpy",
+ "lustrous",
+ "luxurious",
+ "mad",
+ "made-up",
+ "magnificent",
+ "majestic",
+ "major",
+ "male",
+ "mammoth",
+ "married",
+ "marvelous",
+ "masculine",
+ "massive",
+ "mature",
+ "meager",
+ "mealy",
+ "mean",
+ "measly",
+ "meaty",
+ "medical",
+ "mediocre",
+ "medium",
+ "meek",
+ "mellow",
+ "melodic",
+ "memorable",
+ "menacing",
+ "merry",
+ "messy",
+ "metallic",
+ "mild",
+ "milky",
+ "mindless",
+ "miniature",
+ "minor",
+ "minty",
+ "miserable",
+ "miserly",
+ "misguided",
+ "misty",
+ "mixed",
+ "modern",
+ "modest",
+ "moist",
+ "monstrous",
+ "monthly",
+ "monumental",
+ "moral",
+ "mortified",
+ "motherly",
+ "motionless",
+ "mountainous",
+ "muddy",
+ "muffled",
+ "multicolored",
+ "mundane",
+ "murky",
+ "mushy",
+ "musty",
+ "muted",
+ "mysterious",
+ "naive",
+ "narrow",
+ "nasty",
+ "natural",
+ "naughty",
+ "nautical",
+ "near",
+ "neat",
+ "necessary",
+ "needy",
+ "negative",
+ "neglected",
+ "negligible",
+ "neighboring",
+ "nervous",
+ "new",
+ "next",
+ "nice",
+ "nifty",
+ "nimble",
+ "nippy",
+ "nocturnal",
+ "noisy",
+ "nonstop",
+ "normal",
+ "notable",
+ "noted",
+ "noteworthy",
+ "novel",
+ "noxious",
+ "numb",
+ "nutritious",
+ "nutty",
+ "obedient",
+ "obese",
+ "oblong",
+ "oily",
+ "oblong",
+ "obvious",
+ "occasional",
+ "odd",
+ "oddball",
+ "offbeat",
+ "offensive",
+ "official",
+ "old",
+ "old-fashioned",
+ "only",
+ "open",
+ "optimal",
+ "optimistic",
+ "opulent",
+ "orange",
+ "orderly",
+ "organic",
+ "ornate",
+ "ornery",
+ "ordinary",
+ "original",
+ "other",
+ "our",
+ "outlying",
+ "outgoing",
+ "outlandish",
+ "outrageous",
+ "outstanding",
+ "oval",
+ "overcooked",
+ "overdue",
+ "overjoyed",
+ "overlooked",
+ "palatable",
+ "pale",
+ "paltry",
+ "parallel",
+ "parched",
+ "partial",
+ "passionate",
+ "past",
+ "pastel",
+ "peaceful",
+ "peppery",
+ "perfect",
+ "perfumed",
+ "periodic",
+ "perky",
+ "personal",
+ "pertinent",
+ "pesky",
+ "pessimistic",
+ "petty",
+ "phony",
+ "physical",
+ "piercing",
+ "pink",
+ "pitiful",
+ "plain",
+ "plaintive",
+ "plastic",
+ "playful",
+ "pleasant",
+ "pleased",
+ "pleasing",
+ "plump",
+ "plush",
+ "polished",
+ "polite",
+ "political",
+ "pointed",
+ "pointless",
+ "poised",
+ "poor",
+ "popular",
+ "portly",
+ "posh",
+ "positive",
+ "possible",
+ "potable",
+ "powerful",
+ "powerless",
+ "practical",
+ "precious",
+ "present",
+ "prestigious",
+ "pretty",
+ "precious",
+ "previous",
+ "pricey",
+ "prickly",
+ "primary",
+ "prime",
+ "pristine",
+ "private",
+ "prize",
+ "probable",
+ "productive",
+ "profitable",
+ "profuse",
+ "proper",
+ "proud",
+ "prudent",
+ "punctual",
+ "pungent",
+ "puny",
+ "pure",
+ "purple",
+ "pushy",
+ "putrid",
+ "puzzled",
+ "puzzling",
+ "quaint",
+ "qualified",
+ "quarrelsome",
+ "quarterly",
+ "queasy",
+ "querulous",
+ "questionable",
+ "quick",
+ "quick-witted",
+ "quiet",
+ "quintessential",
+ "quirky",
+ "quixotic",
+ "quizzical",
+ "radiant",
+ "ragged",
+ "rapid",
+ "rare",
+ "rash",
+ "raw",
+ "recent",
+ "reckless",
+ "rectangular",
+ "ready",
+ "real",
+ "realistic",
+ "reasonable",
+ "red",
+ "reflecting",
+ "regal",
+ "regular",
+ "reliable",
+ "relieved",
+ "remarkable",
+ "remorseful",
+ "remote",
+ "repentant",
+ "required",
+ "respectful",
+ "responsible",
+ "repulsive",
+ "revolving",
+ "rewarding",
+ "rich",
+ "rigid",
+ "right",
+ "ringed",
+ "ripe",
+ "roasted",
+ "robust",
+ "rosy",
+ "rotating",
+ "rotten",
+ "rough",
+ "round",
+ "rowdy",
+ "royal",
+ "rubbery",
+ "rundown",
+ "ruddy",
+ "rude",
+ "runny",
+ "rural",
+ "rusty",
+ "sad",
+ "safe",
+ "salty",
+ "same",
+ "sandy",
+ "sane",
+ "sarcastic",
+ "sardonic",
+ "satisfied",
+ "scaly",
+ "scarce",
+ "scared",
+ "scary",
+ "scented",
+ "scholarly",
+ "scientific",
+ "scornful",
+ "scratchy",
+ "scrawny",
+ "second",
+ "secondary",
+ "second-hand",
+ "secret",
+ "self-assured",
+ "self-reliant",
+ "selfish",
+ "sentimental",
+ "separate",
+ "serene",
+ "serious",
+ "serpentine",
+ "several",
+ "severe",
+ "shabby",
+ "shadowy",
+ "shady",
+ "shallow",
+ "shameful",
+ "shameless",
+ "sharp",
+ "shimmering",
+ "shiny",
+ "shocked",
+ "shocking",
+ "shoddy",
+ "short",
+ "short-term",
+ "showy",
+ "shrill",
+ "shy",
+ "sick",
+ "silent",
+ "silky",
+ "silly",
+ "silver",
+ "similar",
+ "simple",
+ "simplistic",
+ "sinful",
+ "single",
+ "sizzling",
+ "skeletal",
+ "skinny",
+ "sleepy",
+ "slight",
+ "slim",
+ "slimy",
+ "slippery",
+ "slow",
+ "slushy",
+ "small",
+ "smart",
+ "smoggy",
+ "smooth",
+ "smug",
+ "snappy",
+ "snarling",
+ "sneaky",
+ "sniveling",
+ "snoopy",
+ "sociable",
+ "soft",
+ "soggy",
+ "solid",
+ "somber",
+ "some",
+ "spherical",
+ "sophisticated",
+ "sore",
+ "sorrowful",
+ "soulful",
+ "soupy",
+ "sour",
+ "Spanish",
+ "sparkling",
+ "sparse",
+ "specific",
+ "spectacular",
+ "speedy",
+ "spicy",
+ "spiffy",
+ "spirited",
+ "spiteful",
+ "splendid",
+ "spotless",
+ "spotted",
+ "spry",
+ "square",
+ "squeaky",
+ "squiggly",
+ "stable",
+ "staid",
+ "stained",
+ "stale",
+ "standard",
+ "starchy",
+ "stark",
+ "starry",
+ "steep",
+ "sticky",
+ "stiff",
+ "stimulating",
+ "stingy",
+ "stormy",
+ "straight",
+ "strange",
+ "steel",
+ "strict",
+ "strident",
+ "striking",
+ "striped",
+ "strong",
+ "studious",
+ "stunning",
+ "stupendous",
+ "stupid",
+ "sturdy",
+ "stylish",
+ "subdued",
+ "submissive",
+ "substantial",
+ "subtle",
+ "suburban",
+ "sudden",
+ "sugary",
+ "sunny",
+ "super",
+ "superb",
+ "superficial",
+ "superior",
+ "supportive",
+ "sure-footed",
+ "surprised",
+ "suspicious",
+ "svelte",
+ "sweaty",
+ "sweet",
+ "sweltering",
+ "swift",
+ "sympathetic",
+ "tall",
+ "talkative",
+ "tame",
+ "tan",
+ "tangible",
+ "tart",
+ "tasty",
+ "tattered",
+ "taut",
+ "tedious",
+ "teeming",
+ "tempting",
+ "tender",
+ "tense",
+ "tepid",
+ "terrible",
+ "terrific",
+ "testy",
+ "thankful",
+ "that",
+ "these",
+ "thick",
+ "thin",
+ "third",
+ "thirsty",
+ "this",
+ "thorough",
+ "thorny",
+ "those",
+ "thoughtful",
+ "threadbare",
+ "thrifty",
+ "thunderous",
+ "tidy",
+ "tight",
+ "timely",
+ "tinted",
+ "tiny",
+ "tired",
+ "torn",
+ "total",
+ "tough",
+ "traumatic",
+ "treasured",
+ "tremendous",
+ "tragic",
+ "trained",
+ "tremendous",
+ "triangular",
+ "tricky",
+ "trifling",
+ "trim",
+ "trivial",
+ "troubled",
+ "true",
+ "trusting",
+ "trustworthy",
+ "trusty",
+ "truthful",
+ "tubby",
+ "turbulent",
+ "twin",
+ "ugly",
+ "ultimate",
+ "unacceptable",
+ "unaware",
+ "uncomfortable",
+ "uncommon",
+ "unconscious",
+ "understated",
+ "unequaled",
+ "uneven",
+ "unfinished",
+ "unfit",
+ "unfolded",
+ "unfortunate",
+ "unhappy",
+ "unhealthy",
+ "uniform",
+ "unimportant",
+ "unique",
+ "united",
+ "unkempt",
+ "unknown",
+ "unlawful",
+ "unlined",
+ "unlucky",
+ "unnatural",
+ "unpleasant",
+ "unrealistic",
+ "unripe",
+ "unruly",
+ "unselfish",
+ "unsightly",
+ "unsteady",
+ "unsung",
+ "untidy",
+ "untimely",
+ "untried",
+ "untrue",
+ "unused",
+ "unusual",
+ "unwelcome",
+ "unwieldy",
+ "unwilling",
+ "unwitting",
+ "unwritten",
+ "upbeat",
+ "upright",
+ "upset",
+ "urban",
+ "usable",
+ "used",
+ "useful",
+ "useless",
+ "utilized",
+ "utter",
+ "vacant",
+ "vague",
+ "vain",
+ "valid",
+ "valuable",
+ "vapid",
+ "variable",
+ "vast",
+ "velvety",
+ "venerated",
+ "vengeful",
+ "verifiable",
+ "vibrant",
+ "vicious",
+ "victorious",
+ "vigilant",
+ "vigorous",
+ "villainous",
+ "violet",
+ "violent",
+ "virtual",
+ "virtuous",
+ "visible",
+ "vital",
+ "vivacious",
+ "vivid",
+ "voluminous",
+ "wan",
+ "warlike",
+ "warm",
+ "warmhearted",
+ "warped",
+ "wary",
+ "wasteful",
+ "watchful",
+ "waterlogged",
+ "watery",
+ "wavy",
+ "wealthy",
+ "weak",
+ "weary",
+ "webbed",
+ "weed",
+ "weekly",
+ "weepy",
+ "weighty",
+ "weird",
+ "welcome",
+ "well-documented",
+ "well-groomed",
+ "well-informed",
+ "well-lit",
+ "well-made",
+ "well-off",
+ "well-to-do",
+ "well-worn",
+ "wet",
+ "which",
+ "whimsical",
+ "whirlwind",
+ "whispered",
+ "white",
+ "whole",
+ "whopping",
+ "wicked",
+ "wide",
+ "wide-eyed",
+ "wiggly",
+ "wild",
+ "willing",
+ "wilted",
+ "winding",
+ "windy",
+ "winged",
+ "wiry",
+ "wise",
+ "witty",
+ "wobbly",
+ "woeful",
+ "wonderful",
+ "wooden",
+ "woozy",
+ "wordy",
+ "worldly",
+ "worn",
+ "worried",
+ "worrisome",
+ "worse",
+ "worst",
+ "worthless",
+ "worthwhile",
+ "worthy",
+ "wrathful",
+ "wretched",
+ "writhing",
+ "wrong",
+ "wry",
+ "yawning",
+ "yearly",
+ "yellow",
+ "yellowish",
+ "young",
+ "youthful",
+ "yummy",
+ "zany",
+ "zealous",
+ "zesty",
+ "zigzag",
+];
+
+export function getRandomUsername(): { first: string; second: string } {
+ const n = Math.floor(Math.random() * noun.length);
+ const a = Math.floor(Math.random() * adj.length);
+ return {
+ first: adj[a],
+ second: noun[n],
+ };
+}
+
+export function getRandomPassword(): string {
+ return encodeCrock(getRandomBytes(16));
+}
diff --git a/packages/bank-ui/src/route.ts b/packages/bank-ui/src/route.ts
new file mode 100644
index 000000000..11f13d140
--- /dev/null
+++ b/packages/bank-ui/src/route.ts
@@ -0,0 +1,139 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useNavigationContext } from "./context/navigation.js";
+
+declare const __location: unique symbol;
+/**
+ * special string that defined a location in the application
+ *
+ * this help to prevent wrong path
+ */
+export type AppLocation = string & {
+ [__location]: true;
+};
+export type EmptyObject = Record<string, never>;
+
+export function urlPattern<
+ T extends Record<string, string | undefined> = EmptyObject,
+>(pattern: RegExp, reverse: (p: T) => string): RouteDefinition<T> {
+ const url = reverse as (p: T) => AppLocation;
+ return {
+ pattern: new RegExp(pattern),
+ url,
+ };
+}
+
+/**
+ * defines a location in the app
+ *
+ * pattern: how a string will trigger this location
+ * url(): how a state serialize to a location
+ */
+
+export type ObjectOf<T> = Record<string, T> | EmptyObject;
+
+export type RouteDefinition<
+ T extends ObjectOf<string | undefined> = EmptyObject,
+> = {
+ pattern: RegExp;
+ url: (p: T) => AppLocation;
+};
+
+const nullRountDef = {
+ pattern: new RegExp(/.*/),
+ url: () => "" as AppLocation,
+};
+export function buildNullRoutDefinition<
+ T extends ObjectOf<string>,
+>(): RouteDefinition<T> {
+ return nullRountDef;
+}
+
+/**
+ * Search path in the pageList
+ * get the values from the path found
+ * add params from searchParams
+ *
+ * @param path
+ * @param params
+ */
+function findMatch<T extends ObjectOf<RouteDefinition>>(
+ pagesMap: T,
+ pageList: Array<keyof T>,
+ path: string,
+ params: Record<string, string>,
+): Location<T> | undefined {
+ for (let idx = 0; idx < pageList.length; idx++) {
+ const name = pageList[idx];
+ const found = pagesMap[name].pattern.exec(path);
+ if (found !== null) {
+ const values = {} as Record<string, unknown>;
+
+ Object.entries(params).forEach(([key, value]) => {
+ values[key] = value;
+ });
+
+ if (found.groups !== undefined) {
+ Object.entries(found.groups).forEach(([key, value]) => {
+ values[key] = value;
+ });
+ }
+
+ // @ts-expect-error values is a map string which is equivalent to the RouteParamsType
+ return { name, parent: pagesMap, values };
+ }
+ }
+ return undefined;
+}
+
+/**
+ * get the type of the params of a location
+ *
+ */
+type RouteParamsType<
+ RouteType,
+ Key extends keyof RouteType,
+> = RouteType[Key] extends RouteDefinition<infer ParamType> ? ParamType : never;
+
+/**
+ * Helps to create a map of a type with the key
+ */
+type MapKeyValue<Type> = {
+ [Key in keyof Type]: Key extends string
+ ? {
+ parent: Type;
+ name: Key;
+ values: RouteParamsType<Type, Key>;
+ }
+ : never;
+};
+
+/**
+ * create a enumeration of value of a mapped type
+ */
+type EnumerationOf<T> = T[keyof T];
+
+type Location<T> = EnumerationOf<MapKeyValue<T>>;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>(
+ pagesMap: T,
+): Location<T> | undefined {
+ const pageList = Object.keys(pagesMap as object) as Array<keyof T>;
+ const { path, params } = useNavigationContext();
+
+ return findMatch(pagesMap, pageList, path, params);
+}
diff --git a/packages/bank-ui/src/scss/main.css b/packages/bank-ui/src/scss/main.css
new file mode 100644
index 000000000..b5c61c956
--- /dev/null
+++ b/packages/bank-ui/src/scss/main.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/packages/bank-ui/src/settings.json b/packages/bank-ui/src/settings.json
new file mode 100644
index 000000000..df5fe75ce
--- /dev/null
+++ b/packages/bank-ui/src/settings.json
@@ -0,0 +1,11 @@
+{
+ "backendBaseURL": "http://bank.taler.test:1180/",
+ "simplePasswordForRandomAccounts": true,
+ "allowRandomAccountCreation": true,
+ "bankName": "Taler DEVELOPMENT Bank",
+ "topNavSites": {
+ "Exchange": "http://Exchnage.taler.test:1180/",
+ "Bank": "http://bank-ui.taler.test:1180/",
+ "Merchant": "http://merchant.taler.test:1180/"
+ }
+}
diff --git a/packages/bank-ui/src/settings.ts b/packages/bank-ui/src/settings.ts
new file mode 100644
index 000000000..968fe6248
--- /dev/null
+++ b/packages/bank-ui/src/settings.ts
@@ -0,0 +1,112 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Codec,
+ buildCodecForObject,
+ canonicalizeBaseUrl,
+ codecForBoolean,
+ codecForMap,
+ codecForString,
+ codecOptional,
+} from "@gnu-taler/taler-util";
+
+export interface BankUiSettings {
+ // Where libeufin backend is localted
+ // default: window.origin without "webui/"
+ backendBaseURL?: string;
+ // Shows a button "create random account" in the registration form
+ // Useful for testing
+ // default: false
+ allowRandomAccountCreation?: boolean;
+ // Create all random accounts with password "123"
+ // Useful for testing
+ // default: false
+ simplePasswordForRandomAccounts?: boolean;
+ // URL where the user is going to be redirected after
+ // clicking in Taler Logo
+ // default: home page
+ iconLinkURL?: string;
+ // Mapping for every link shown in the top navitation bar
+ // - key: link label, what the user will read
+ // - value: link target, where the user is going to be redirected
+ // default: empty list
+ topNavSites?: Record<string, string>;
+}
+
+/**
+ * Global settings for the bank UI.
+ */
+const defaultSettings: BankUiSettings = {
+ backendBaseURL: buildDefaultBackendBaseURL(),
+ iconLinkURL: undefined,
+ simplePasswordForRandomAccounts: false,
+ allowRandomAccountCreation: false,
+ topNavSites: {},
+};
+
+const codecForBankUISettings = (): Codec<BankUiSettings> =>
+ buildCodecForObject<BankUiSettings>()
+ .property("backendBaseURL", codecOptional(codecForString()))
+ .property("allowRandomAccountCreation", codecOptional(codecForBoolean()))
+ .property(
+ "simplePasswordForRandomAccounts",
+ codecOptional(codecForBoolean()),
+ )
+ .property("iconLinkURL", codecOptional(codecForString()))
+ .property("topNavSites", codecOptional(codecForMap(codecForString())))
+ .build("BankUiSettings");
+
+function removeUndefineField<T extends object>(obj: T): T {
+ const keys = Object.keys(obj) as Array<keyof T>;
+ return keys.reduce((prev, cur) => {
+ if (typeof prev[cur] === "undefined") {
+ delete prev[cur];
+ }
+ return prev;
+ }, obj);
+}
+
+export function fetchSettings(listener: (s: BankUiSettings) => void): void {
+ fetch("./settings.json")
+ .then((resp) => resp.json())
+ .then((json) => codecForBankUISettings().decode(json))
+ .then((result) =>
+ listener({
+ ...defaultSettings,
+ ...removeUndefineField(result),
+ }),
+ )
+ .catch((e) => {
+ console.log("failed to fetch settings", e);
+ listener(defaultSettings);
+ });
+}
+
+function buildDefaultBackendBaseURL(): string | undefined {
+ if (typeof window !== "undefined") {
+ const currentLocation = new URL(
+ window.location.pathname,
+ window.location.origin,
+ ).href;
+ /**
+ * By default, bank backend serves the html content
+ * from the /webui root.
+ */
+ return canonicalizeBaseUrl(currentLocation.replace("/webui", ""));
+ }
+ throw Error("No default URL");
+}
diff --git a/packages/bank-ui/src/stories.test.ts b/packages/bank-ui/src/stories.test.ts
new file mode 100644
index 000000000..921f9f9ea
--- /dev/null
+++ b/packages/bank-ui/src/stories.test.ts
@@ -0,0 +1,83 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import {
+ AmountString,
+ TalerCorebankApi,
+ setupI18n,
+} from "@gnu-taler/taler-util";
+import {
+ BankApiProviderTesting,
+ parseGroupImport,
+} from "@gnu-taler/web-util/browser";
+import * as tests from "@gnu-taler/web-util/testing";
+import * as components from "./components/index.examples.js";
+import * as pages from "./pages/index.stories.js";
+
+import { ComponentChildren, VNode, h as create } from "preact";
+// import { BankCoreApiProviderTesting } from "./context/config.js";
+
+setupI18n("en", { en: {} });
+
+describe("All the examples:", () => {
+ const cms = parseGroupImport({ pages, components });
+ cms.forEach((group) => {
+ describe(`Example for group "${group.title}:"`, () => {
+ group.list.forEach((component) => {
+ describe(`Component ${component.name}:`, () => {
+ component.examples.forEach((example) => {
+ it(`should render example: ${example.name}`, () => {
+ tests.renderUI(example.render, DefaultTestingContext);
+ });
+ });
+ });
+ });
+ });
+ });
+});
+
+function DefaultTestingContext(_props: { children: ComponentChildren }): VNode {
+ const cfg: TalerCorebankApi.Config = {
+ name: "libeufin-bank",
+ allow_deletions: true,
+ bank_name: "taler bank",
+ wire_type: "wire t",
+ supported_tan_channels: [],
+ allow_registrations: true,
+ allow_conversion: true,
+ allow_edit_cashout_payto_uri: false,
+ allow_edit_name: false,
+ currency: "ASR",
+ currency_specification: {
+ name: "ARS",
+ alt_unit_names: {},
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ },
+ default_debit_threshold: "ARS:10" as AmountString,
+ version: "1:0:0",
+ };
+ const ctx2 = create(BankApiProviderTesting, {
+ children: [],
+ value: cfg as any,
+ });
+ return ctx2;
+}
diff --git a/packages/bank-ui/src/stories.tsx b/packages/bank-ui/src/stories.tsx
new file mode 100644
index 000000000..8342a8434
--- /dev/null
+++ b/packages/bank-ui/src/stories.tsx
@@ -0,0 +1,41 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { strings } from "./i18n/strings.js";
+
+import * as pages from "./pages/index.stories.js";
+import * as components from "./components/index.examples.js";
+
+import { renderStories } from "@gnu-taler/web-util/browser";
+
+function main(): void {
+ renderStories(
+ { pages, components },
+ {
+ strings,
+ },
+ );
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", main);
+} else {
+ main();
+}
diff --git a/packages/bank-ui/src/utils.ts b/packages/bank-ui/src/utils.ts
new file mode 100644
index 000000000..2cc502416
--- /dev/null
+++ b/packages/bank-ui/src/utils.ts
@@ -0,0 +1,439 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountString,
+ PaytoString,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import {
+ ErrorNotification,
+ InternationalizationAPI,
+ notify,
+ notifyError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+
+/**
+ * Validate (the number part of) an amount. If needed,
+ * replace comma with a dot. Returns 'false' whenever
+ * the input is invalid, the valid amount otherwise.
+ */
+const amountRegex = /^[0-9]+(.[0-9]+)?$/;
+export function validateAmount(
+ maybeAmount: string | undefined,
+): string | undefined {
+ if (!maybeAmount || !amountRegex.test(maybeAmount)) {
+ return;
+ }
+ return maybeAmount;
+}
+
+/**
+ * Extract IBAN from a Payto URI.
+ */
+export function getIbanFromPayto(url: string): string {
+ const pathSplit = new URL(url).pathname.split("/");
+ let lastIndex = pathSplit.length - 1;
+ // Happens if the path ends with "/".
+ if (pathSplit[lastIndex] === "") lastIndex--;
+ const iban = pathSplit[lastIndex];
+ return iban;
+}
+
+export function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
+ return Object.keys(obj).some(
+ (k) => (obj as Record<string, T>)[k] !== undefined,
+ )
+ ? obj
+ : undefined;
+}
+
+export type PartialButDefined<T> = {
+ [P in keyof T]: T[P] | undefined;
+};
+
+/**
+ * every non-map field can be undefined
+ */
+export type WithIntermediate<Type> = {
+ [prop in keyof Type]: Type[prop] extends PaytoString
+ ? Type[prop] | undefined
+ : Type[prop] extends AmountString
+ ? Type[prop] | undefined
+ : Type[prop] extends TranslatedString
+ ? Type[prop] | undefined
+ : Type[prop] extends object
+ ? WithIntermediate<Type[prop]>
+ : Type[prop] | undefined;
+};
+export type RecursivePartial<Type> = {
+ [P in keyof Type]?: Type[P] extends (infer U)[]
+ ? RecursivePartial<U>[]
+ : Type[P] extends object
+ ? RecursivePartial<Type[P]>
+ : Type[P];
+};
+export type ErrorMessageMappingFor<Type> = {
+ [prop in keyof Type]+?: Exclude<Type[prop], undefined> extends PaytoString // enumerate known object
+ ? TranslatedString
+ : Exclude<Type[prop], undefined> extends AmountString
+ ? TranslatedString
+ : Exclude<Type[prop], undefined> extends TranslatedString
+ ? TranslatedString
+ : // arrays: every element
+ Exclude<Type[prop], undefined> extends (infer U)[]
+ ? ErrorMessageMappingFor<U>[]
+ : // map: every field
+ Exclude<Type[prop], undefined> extends object
+ ? ErrorMessageMappingFor<Type[prop]>
+ : TranslatedString;
+};
+
+export enum TanChannel {
+ SMS = "sms",
+ EMAIL = "email",
+}
+export enum CashoutStatus {
+ // The payment was initiated after a valid
+ // TAN was received by the bank.
+ CONFIRMED = "confirmed",
+
+ // The cashout was created and now waits
+ // for the TAN by the author.
+ PENDING = "pending",
+}
+
+
+export const PAGINATED_LIST_SIZE = 5;
+// when doing paginated request, ask for one more
+// and use it to know if there are more to request
+export const PAGINATED_LIST_REQUEST = PAGINATED_LIST_SIZE + 1;
+
+type Translator = ReturnType<typeof useTranslationContext>["i18n"];
+
+export async function withRuntimeErrorHandling<T>(
+ i18n: Translator,
+ cb: () => Promise<T>,
+): Promise<void> {
+ try {
+ await cb();
+ } catch (error: unknown) {
+ if (error instanceof TalerError) {
+ notify(buildRequestErrorMessage(i18n, error));
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString,
+ );
+ }
+ }
+}
+
+export function buildRequestErrorMessage(
+ i18n: Translator,
+ cause: TalerError,
+): ErrorNotification {
+ let result: ErrorNotification;
+ switch (cause.errorDetail.code) {
+ case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: {
+ result = {
+ type: "error",
+ title: i18n.str`Request timeout`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: {
+ result = {
+ type: "error",
+ title: i18n.str`Request throttled`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: {
+ result = {
+ type: "error",
+ title: i18n.str`Malformed response`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ case TalerErrorCode.WALLET_NETWORK_ERROR: {
+ result = {
+ type: "error",
+ title: i18n.str`Network error`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: {
+ result = {
+ type: "error",
+ title: i18n.str`Unexpected request error`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ default: {
+ result = {
+ type: "error",
+ title: i18n.str`Unexpected error`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ }
+ return result;
+}
+
+export const COUNTRY_TABLE = {
+ AE: "U.A.E.",
+ AF: "Afghanistan",
+ AL: "Albania",
+ AM: "Armenia",
+ AN: "Netherlands Antilles",
+ AR: "Argentina",
+ AT: "Austria",
+ AU: "Australia",
+ AZ: "Azerbaijan",
+ BA: "Bosnia and Herzegovina",
+ BD: "Bangladesh",
+ BE: "Belgium",
+ BG: "Bulgaria",
+ BH: "Bahrain",
+ BN: "Brunei Darussalam",
+ BO: "Bolivia",
+ BR: "Brazil",
+ BT: "Bhutan",
+ BY: "Belarus",
+ BZ: "Belize",
+ CA: "Canada",
+ CG: "Congo",
+ CH: "Switzerland",
+ CI: "Cote d'Ivoire",
+ CL: "Chile",
+ CM: "Cameroon",
+ CN: "People's Republic of China",
+ CO: "Colombia",
+ CR: "Costa Rica",
+ CS: "Serbia and Montenegro",
+ CZ: "Czech Republic",
+ DE: "Germany",
+ DK: "Denmark",
+ DO: "Dominican Republic",
+ DZ: "Algeria",
+ EC: "Ecuador",
+ EE: "Estonia",
+ EG: "Egypt",
+ ER: "Eritrea",
+ ES: "Spain",
+ ET: "Ethiopia",
+ FI: "Finland",
+ FO: "Faroe Islands",
+ FR: "France",
+ GB: "United Kingdom",
+ GD: "Caribbean",
+ GE: "Georgia",
+ GL: "Greenland",
+ GR: "Greece",
+ GT: "Guatemala",
+ HK: "Hong Kong",
+ // HK: "Hong Kong S.A.R.",
+ HN: "Honduras",
+ HR: "Croatia",
+ HT: "Haiti",
+ HU: "Hungary",
+ ID: "Indonesia",
+ IE: "Ireland",
+ IL: "Israel",
+ IN: "India",
+ IQ: "Iraq",
+ IR: "Iran",
+ IS: "Iceland",
+ IT: "Italy",
+ JM: "Jamaica",
+ JO: "Jordan",
+ JP: "Japan",
+ KE: "Kenya",
+ KG: "Kyrgyzstan",
+ KH: "Cambodia",
+ KR: "South Korea",
+ KW: "Kuwait",
+ KZ: "Kazakhstan",
+ LA: "Laos",
+ LB: "Lebanon",
+ LI: "Liechtenstein",
+ LK: "Sri Lanka",
+ LT: "Lithuania",
+ LU: "Luxembourg",
+ LV: "Latvia",
+ LY: "Libya",
+ MA: "Morocco",
+ MC: "Principality of Monaco",
+ MD: "Moldava",
+ // MD: "Moldova",
+ ME: "Montenegro",
+ MK: "Former Yugoslav Republic of Macedonia",
+ ML: "Mali",
+ MM: "Myanmar",
+ MN: "Mongolia",
+ MO: "Macau S.A.R.",
+ MT: "Malta",
+ MV: "Maldives",
+ MX: "Mexico",
+ MY: "Malaysia",
+ NG: "Nigeria",
+ NI: "Nicaragua",
+ NL: "Netherlands",
+ NO: "Norway",
+ NP: "Nepal",
+ NZ: "New Zealand",
+ OM: "Oman",
+ PA: "Panama",
+ PE: "Peru",
+ PH: "Philippines",
+ PK: "Islamic Republic of Pakistan",
+ PL: "Poland",
+ PR: "Puerto Rico",
+ PT: "Portugal",
+ PY: "Paraguay",
+ QA: "Qatar",
+ RE: "Reunion",
+ RO: "Romania",
+ RS: "Serbia",
+ RU: "Russia",
+ RW: "Rwanda",
+ SA: "Saudi Arabia",
+ SE: "Sweden",
+ SG: "Singapore",
+ SI: "Slovenia",
+ SK: "Slovak",
+ SN: "Senegal",
+ SO: "Somalia",
+ SR: "Suriname",
+ SV: "El Salvador",
+ SY: "Syria",
+ TH: "Thailand",
+ TJ: "Tajikistan",
+ TM: "Turkmenistan",
+ TN: "Tunisia",
+ TR: "Turkey",
+ TT: "Trinidad and Tobago",
+ TW: "Taiwan",
+ TZ: "Tanzania",
+ UA: "Ukraine",
+ US: "United States",
+ UY: "Uruguay",
+ VA: "Vatican",
+ VE: "Venezuela",
+ VN: "Viet Nam",
+ YE: "Yemen",
+ ZA: "South Africa",
+ ZW: "Zimbabwe",
+};
+
+/**
+ * An IBAN is validated by converting it into an integer and performing a
+ * basic mod-97 operation (as described in ISO 7064) on it.
+ * If the IBAN is valid, the remainder equals 1.
+ *
+ * The algorithm of IBAN validation is as follows:
+ * 1.- Check that the total IBAN length is correct as per the country. If not, the IBAN is invalid
+ * 2.- Move the four initial characters to the end of the string
+ * 3.- Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35
+ * 4.- Interpret the string as a decimal integer and compute the remainder of that number on division by 97
+ *
+ * If the remainder is 1, the check digit test is passed and the IBAN might be valid.
+ *
+ */
+const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
+export function validateIBAN(
+ account: string,
+ i18n: InternationalizationAPI,
+): TranslatedString | undefined {
+ if (!IBAN_REGEX.test(account)) {
+ return i18n.str`IBAN only have uppercased letters and numbers`;
+ }
+ // Check total length
+ if (account.length < 4) return i18n.str`IBAN numbers have more that 4 digits`;
+ if (account.length > 34)
+ return i18n.str`IBAN numbers have less that 34 digits`;
+
+ const A_code = "A".charCodeAt(0);
+ const Z_code = "Z".charCodeAt(0);
+ const IBAN = account.toUpperCase();
+ // check supported country
+ const code = IBAN.substring(0, 2);
+ const found = code in COUNTRY_TABLE;
+ if (!found) return i18n.str`IBAN country code not found`;
+
+ // 2.- Move the four initial characters to the end of the string
+ const step2 = IBAN.substring(4) + account.substring(0, 4);
+ const step3 = Array.from(step2)
+ .map((letter) => {
+ const code = letter.charCodeAt(0);
+ if (code < A_code || code > Z_code) return letter;
+ return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`;
+ })
+ .join("");
+
+ const checksum = calculate_iban_checksum(step3);
+ if (checksum !== 1)
+ return i18n.str`IBAN number is not valid, checksum is wrong`;
+ return undefined;
+}
+
+function calculate_iban_checksum(str: string): number {
+ const numberStr = str.substring(0, 5);
+ const rest = str.substring(5);
+ const number = parseInt(numberStr, 10);
+ const result = number % 97;
+ if (rest.length > 0) {
+ return calculate_iban_checksum(`${result}${rest}`);
+ }
+ return result;
+}
+
+const USERNAME_REGEX = /^[A-Za-z][A-Za-z0-9]*$/;
+
+export function validateTalerBank(
+ account: string,
+ i18n: InternationalizationAPI,
+): TranslatedString | undefined {
+ if (!USERNAME_REGEX.test(account)) {
+ return i18n.str`Account only have letters and numbers`;
+ }
+ return undefined;
+}
diff --git a/packages/bank-ui/tailwind.config.js b/packages/bank-ui/tailwind.config.js
new file mode 100644
index 000000000..ec51dfbb8
--- /dev/null
+++ b/packages/bank-ui/tailwind.config.js
@@ -0,0 +1,14 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: {
+ relative: true,
+ files: [
+ "./src/**/*.{html,tsx}",
+ "./node_modules/@gnu-taler/web-util/src/**/*.{html,tsx}"
+ ],
+ },
+ theme: {
+ extend: {},
+ },
+ plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
+};
diff --git a/packages/bank-ui/test.mjs b/packages/bank-ui/test.mjs
new file mode 100755
index 000000000..baaaaa3ef
--- /dev/null
+++ b/packages/bank-ui/test.mjs
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build } from "@gnu-taler/web-util/build";
+import { getFilesInDirectory } from "@gnu-taler/web-util/build";
+
+const allTestFiles = getFilesInDirectory("src", /.test.tsx?$/);
+
+await build({
+ type: "test",
+ source: {
+ js: allTestFiles.files,
+ assets: [{ base: "src", files: ["src/index.html"] }],
+
+ },
+ destination: "./dist/test",
+ css: "sass",
+});
diff --git a/packages/bank-ui/tsconfig.json b/packages/bank-ui/tsconfig.json
new file mode 100644
index 000000000..9826fac07
--- /dev/null
+++ b/packages/bank-ui/tsconfig.json
@@ -0,0 +1,46 @@
+{
+ "compilerOptions": {
+ /* Basic Options */
+ "target": "ES2020",
+ "module": "Node16",
+ "lib": ["DOM", "ES2020"],
+ "allowJs": true /* Allow javascript files to be compiled. */,
+ // "checkJs": true, /* Report errors in .js files. */
+ "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
+ "jsxFactory": "h",
+ "jsxFragmentFactory": "Fragment",
+ "noEmit": true /* Do not emit outputs. */,
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+ /* Strict Type-Checking Options */
+ "strict": true /* Enable all strict type-checking options. */,
+ "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
+ /* Additional Checks */
+ // "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+ /* Module Resolution Options */
+ "moduleResolution": "Node16" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
+ "esModuleInterop": true /* */,
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+ /* Source Map Options */
+ // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+ /* Advanced Options */
+ "skipLibCheck": true /* Skip type checking of declaration files. */
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/challenger-ui/.gitignore b/packages/challenger-ui/.gitignore
new file mode 100644
index 000000000..30cb2774c
--- /dev/null
+++ b/packages/challenger-ui/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+/build
+/*.log
+/demobank-ui-settings.js
diff --git a/packages/challenger-ui/Makefile b/packages/challenger-ui/Makefile
new file mode 100644
index 000000000..64f9f83d1
--- /dev/null
+++ b/packages/challenger-ui/Makefile
@@ -0,0 +1,36 @@
+# This Makefile has been placed in the public domain
+
+ifeq ($(TOPLEVEL), yes)
+ $(info top-level build)
+ -include ../../.config.mk
+ override DESTDIR := $(TOP_DESTDIR)
+else
+ $(info package-level build)
+ -include ../../.config.mk
+ -include .config.mk
+endif
+
+$(info prefix is $(prefix))
+
+.PHONY: all
+all:
+ @echo run \'make install\' to install
+
+spa_dir=$(DESTDIR)$(prefix)/share/taler/aml-backoffice-ui
+
+.PHONY: install-nodeps
+install-nodeps:
+ install -d $(spa_dir)
+ install ./dist/prod/* $(spa_dir)
+
+.PHONY: deps
+deps:
+ pnpm install --frozen-lockfile --filter @gnu-taler/aml-backoffice-ui...
+ pnpm run check
+ pnpm run build
+
+.PHONY: install
+install:
+ $(MAKE) deps
+ $(MAKE) install-nodeps
+
diff --git a/packages/challenger-ui/README.md b/packages/challenger-ui/README.md
new file mode 100644
index 000000000..ac589ace6
--- /dev/null
+++ b/packages/challenger-ui/README.md
@@ -0,0 +1,4 @@
+# Challenger Backoffice UI
+
+Web-based user interface for the GNU Challenger.
+
diff --git a/packages/challenger-ui/build.mjs b/packages/challenger-ui/build.mjs
new file mode 100755
index 000000000..95088628c
--- /dev/null
+++ b/packages/challenger-ui/build.mjs
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build } from "@gnu-taler/web-util/build";
+
+await build({
+ type: "production",
+ source: {
+ js: ["src/main.js"],
+ assets: [{
+ base: "src",
+ files: [
+ "src/attempts-exhausted.html",
+ "src/enter-address-form.html",
+ "src/enter-email-form.html",
+ "src/enter-file-access-form.html",
+ "src/enter-phone-form.html",
+ "src/enter-tan-form.html",
+ "src/internal-error.html",
+ "src/invalid-pin.html",
+ "src/invalid-request.html",
+ "src/validation-unknown.html",
+ ]
+ }],
+ },
+ destination: "./dist/prod",
+ css: "postcss",
+});
diff --git a/packages/challenger-ui/copyleft-header.js b/packages/challenger-ui/copyleft-header.js
new file mode 100644
index 000000000..2635717c5
--- /dev/null
+++ b/packages/challenger-ui/copyleft-header.js
@@ -0,0 +1,15 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
diff --git a/packages/challenger-ui/create_must.sh b/packages/challenger-ui/create_must.sh
new file mode 100755
index 000000000..a4d78b2cc
--- /dev/null
+++ b/packages/challenger-ui/create_must.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+# This file is in the public domain.
+
+# After the compilation succeeded
+# some changes needs to be made
+# in the html/js files to match the
+# what the service expects
+
+cd dist/prod
+
+for file in *.html; do
+
+ # 1. remove the js reference used for dev
+ sed /main.js/d -i $file
+
+ # 2. change the location of css since
+ #challenger backend wants them in the root path
+ sed 's/="main.css"/="..\/main.css"/' -i $file
+
+ # 3. rename the extension to must template
+ mv $file ${file:0:-5}.en.must
+done
+
+#delete unused files
+rm *.js *.map
diff --git a/packages/challenger-ui/dev.mjs b/packages/challenger-ui/dev.mjs
new file mode 100755
index 000000000..41f6b4210
--- /dev/null
+++ b/packages/challenger-ui/dev.mjs
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { serve } from "@gnu-taler/web-util/node";
+import { initializeDev } from "@gnu-taler/web-util/build";
+
+const build = initializeDev({
+ type: "development",
+ source: {
+ js: ["src/main.js"],
+ assets: [{
+ base: "src",
+ files: [
+ "src/attempts-exhausted.html",
+ "src/enter-address-form.html",
+ "src/enter-email-form.html",
+ "src/enter-file-access-form.html",
+ "src/enter-phone-form.html",
+ "src/enter-tan-form.html",
+ "src/internal-error.html",
+ "src/invalid-pin.html",
+ "src/invalid-request.html",
+ "src/validation-unknown.html",
+ ]
+ }],
+ },
+ destination: "./dist/dev",
+ css: "postcss",
+});
+
+await build();
+
+serve({
+ folder: "./dist/dev",
+ port: 8080,
+ source: "./src",
+ onSourceUpdate: build,
+});
diff --git a/packages/challenger-ui/package.json b/packages/challenger-ui/package.json
new file mode 100644
index 000000000..4f0428af3
--- /dev/null
+++ b/packages/challenger-ui/package.json
@@ -0,0 +1,48 @@
+{
+ "private": true,
+ "name": "@gnu-taler/challenger-ui",
+ "version": "0.10.6",
+ "author": "sebasjm",
+ "license": "AGPL-3.0-OR-LATER",
+ "description": "UI for GNU Challenger.",
+ "type": "module",
+ "scripts": {
+ "build": "./build.mjs && ./create_must.sh",
+ "check": "tsc",
+ "clean": "rm -rf dist lib",
+ "i18n:extract": "pogen extract",
+ "i18n:merge": "pogen merge",
+ "i18n:emit": "pogen emit",
+ "i18n": "pnpm i18n:extract && pnpm i18n:merge && pnpm i18n:emit",
+ "pretty": "prettier --write src"
+ },
+ "eslintConfig": {
+ "plugins": [
+ "header"
+ ],
+ "rules": {
+ "header/header": [
+ 2,
+ "copyleft-header.js"
+ ]
+ },
+ "extends": [
+ "prettier"
+ ]
+ },
+ "devDependencies": {
+ "@gnu-taler/pogen": "^0.0.5",
+ "@gnu-taler/web-util": "workspace:*",
+ "@tailwindcss/forms": "^0.5.3",
+ "@tailwindcss/typography": "^0.5.9",
+ "autoprefixer": "^10.4.14",
+ "esbuild": "^0.19.9",
+ "po2json": "^0.4.5",
+ "postcss": "^8.4.23",
+ "postcss-cli": "^10.1.0",
+ "tailwindcss": "^3.3.2"
+ },
+ "pogen": {
+ "domain": "aml-backoffice"
+ }
+}
diff --git a/packages/challenger-ui/postcss.config.js b/packages/challenger-ui/postcss.config.js
new file mode 100644
index 000000000..2e7af2b7f
--- /dev/null
+++ b/packages/challenger-ui/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/packages/challenger-ui/src/assets/home.svg b/packages/challenger-ui/src/assets/home.svg
new file mode 100644
index 000000000..35f340162
--- /dev/null
+++ b/packages/challenger-ui/src/assets/home.svg
@@ -0,0 +1,3 @@
+<svg class="h-6 w-6 shrink-0 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
+</svg> \ No newline at end of file
diff --git a/packages/challenger-ui/src/assets/logo-2021.svg b/packages/challenger-ui/src/assets/logo-2021.svg
new file mode 100644
index 000000000..8c5ff3e5b
--- /dev/null
+++ b/packages/challenger-ui/src/assets/logo-2021.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">
+ <g fill="#0042b3" fill-rule="evenodd" stroke-width=".3">
+ <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />
+ <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />
+ <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />
+ </g>
+ <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />
+</svg> \ No newline at end of file
diff --git a/packages/challenger-ui/src/assets/people.svg b/packages/challenger-ui/src/assets/people.svg
new file mode 100644
index 000000000..1dc878b81
--- /dev/null
+++ b/packages/challenger-ui/src/assets/people.svg
@@ -0,0 +1,3 @@
+<svg class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
+</svg> \ No newline at end of file
diff --git a/packages/challenger-ui/src/attempts-exhausted.html b/packages/challenger-ui/src/attempts-exhausted.html
new file mode 100644
index 000000000..c2468b98b
--- /dev/null
+++ b/packages/challenger-ui/src/attempts-exhausted.html
@@ -0,0 +1,88 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <link rel="stylesheet" href="main.css" />
+ <script type="module" src="main.js"></script>
+ <title>Attempts exhausted (#{{ec}})</title>
+</head>
+
+<body class="min-h-full flex flex-col">
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg"><a href="#"><img class="h-8 w-auto"
+ src="data:image/svg+xml,<?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?>%0A<svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 201 90&quot;>%0A <g fill=&quot;%230042b3&quot; fill-rule=&quot;evenodd&quot; stroke-width=&quot;.3&quot;>%0A <path d=&quot;M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z&quot; />%0A <path d=&quot;M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z&quot; />%0A <path d=&quot;M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z&quot; />%0A </g>%0A <path d=&quot;M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z&quot; />%0A</svg>"
+ alt="GNU Taler" style="height: 1.5rem; margin: 0.5rem;"></a></div><span
+ class="flex items-center text-white text-lg font-bold ml-4">Challenger</span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end">
+ </div>
+ </div>
+ </header>
+
+ <main class="flex-1">
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 items-center mt-4">
+ <div class="rounded-md bg-red-50 p-4 shadow-xl">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svg xmlns="http://www.w3.org/2000/svg" stroke="none" viewBox="0 0 24 24" fill="currentColor"
+ class="w-8 h-8 text-red-400">
+ <path fill-rule="evenodd"
+ d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" />
+ </svg>
+ </div>
+ <div class="ml-3">
+ <h3 class="text-sm font-medium text-red-800">
+ You have tried too many times
+ </h3>
+ <div class="mt-2 text-sm text-red-700">
+ <p>More attempts are not allowed</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </main>
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">Learn more about <a target="_blank" rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a></p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">Copyright © 2014—2023 Taler Systems SA. </p>
+ </div>
+ </footer>
+
+</body>
+
+</html> \ No newline at end of file
diff --git a/packages/challenger-ui/src/enter-address-form.html b/packages/challenger-ui/src/enter-address-form.html
new file mode 100644
index 000000000..76b4d2262
--- /dev/null
+++ b/packages/challenger-ui/src/enter-address-form.html
@@ -0,0 +1,133 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <link rel="stylesheet" href="main.css" />
+ <script type="module" src="main.js"></script>
+ <title>Enter contact details</title>
+</head>
+
+<body class="min-h-full flex flex-col">
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg"><a href="#"><img class="h-8 w-auto"
+ src="data:image/svg+xml,<?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?>%0A<svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 201 90&quot;>%0A <g fill=&quot;%230042b3&quot; fill-rule=&quot;evenodd&quot; stroke-width=&quot;.3&quot;>%0A <path d=&quot;M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z&quot; />%0A <path d=&quot;M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z&quot; />%0A <path d=&quot;M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z&quot; />%0A </g>%0A <path d=&quot;M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z&quot; />%0A</svg>"
+ alt="GNU Taler" style="height: 1.5rem; margin: 0.5rem;"></a></div><span
+ class="flex items-center text-white text-lg font-bold ml-4">Challenger</span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end">
+ </div>
+ </div>
+ </header>
+
+ <main class="flex-1">
+
+ <div class="isolate bg-white px-6 py-12">
+ <div class="mx-auto max-w-2xl text-center">
+ <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
+ Enter contact details
+ </h2>
+ <p class="mt-2 text-lg leading-8 text-gray-600">
+ You will receive a letter with a TAN code that must be provided on the next page.
+ </p>
+ </div>
+ <form action="/challenge/{{nonce}}" method="POST" class="mx-auto mt-16 max-w-xl sm:mt-20">
+ <div class="grid grid-cols-1 gap-x-8 gap-y-6">
+ <div class="sm:col-span-2">
+ <label for="address" class="block text-sm font-semibold leading-6 text-gray-900">
+ Street address
+ </label>
+ <div class="mt-2.5">
+ <textarea name="address" id="address" rows="3" autocomplete="shipping street-address"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"></textarea>
+
+ </div>
+ </div>
+
+ <div class="sm:col-span-2">
+ <label for="city" class="block text-sm font-semibold leading-6 text-gray-900">
+ City
+ </label>
+ <div class="mt-2.5">
+ <input type="text" name="city" id="city" maxlength="512" autocomplete="address-level2"
+ class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
+ </div>
+ </div>
+
+ <div class="sm:col-span-2">
+ <label for="postal-code" class="block text-sm font-semibold leading-6 text-gray-900">
+ Postal code
+ </label>
+ <div class="mt-2.5">
+ <input type="text" name="postal-code" id="postal-code" maxlength="512" autocomplete="postal-code"
+ class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
+ </div>
+ </div>
+
+ <div class="sm:col-span-2">
+ <label for="country" class="block text-sm font-semibold leading-6 text-gray-900">
+ Country
+ </label>
+ <div class="mt-2.5">
+ <input type="text" name="country" id="country" maxlength="512" autocomplete="country"
+ class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
+ </div>
+ </div>
+
+ <p class="mt-3 text-sm leading-6 text-gray-400">
+ You can change address another {{changes_left}} times.
+ </p>
+ </div>
+
+ <div class="mt-10">
+ <button type="submit"
+ class="block w-full rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
+ Send mail
+ </button>
+ </div>
+ </form>
+ </div>
+ </main>
+
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">Learn more about <a target="_blank" rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a></p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">Copyright © 2014—2023 Taler Systems SA. </p>
+ </div>
+ </footer>
+</body>
+
+</html>
diff --git a/packages/challenger-ui/src/enter-email-form.html b/packages/challenger-ui/src/enter-email-form.html
new file mode 100644
index 000000000..3b8720244
--- /dev/null
+++ b/packages/challenger-ui/src/enter-email-form.html
@@ -0,0 +1,127 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <link rel="stylesheet" href="main.css" />
+ <script type="module" src="main.js"></script>
+ <title>Enter contact details</title>
+</head>
+
+<body class="min-h-full flex flex-col">
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg"><a href="#"><img class="h-8 w-auto"
+ src="data:image/svg+xml,<?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?>%0A<svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 201 90&quot;>%0A <g fill=&quot;%230042b3&quot; fill-rule=&quot;evenodd&quot; stroke-width=&quot;.3&quot;>%0A <path d=&quot;M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z&quot; />%0A <path d=&quot;M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z&quot; />%0A <path d=&quot;M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z&quot; />%0A </g>%0A <path d=&quot;M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z&quot; />%0A</svg>"
+ alt="GNU Taler" style="height: 1.5rem; margin: 0.5rem;"></a></div><span
+ class="flex items-center text-white text-lg font-bold ml-4">Challenger</span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end">
+ </div>
+ </div>
+ </header>
+
+ <main class="flex-1">
+
+ <div class="isolate bg-white px-6 py-12">
+ <div class="mx-auto max-w-2xl text-center">
+ <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
+ Enter contact details
+ </h2>
+ <p class="mt-2 text-lg leading-8 text-gray-600">
+ You will receive an email with a TAN code that must be provided on the next page.
+ </p>
+ </div>
+ <form action="/challenge/{{nonce}}" method="POST" class="mx-auto mt-16 max-w-xl sm:mt-20">
+ <div class="grid grid-cols-1 gap-x-8 gap-y-6">
+ <div class="sm:col-span-2">
+ <label for="email" class="block text-sm font-semibold leading-6 text-gray-900">
+ Email
+ </label>
+ <div class="mt-2.5">
+ <input type="email" name="email" id="email" maxlength="512" autocomplete="email" value="{{last_address}}"
+ {{#fixed_address}}readonly{{/fixed_address}}
+ class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
+ </div>
+ </div>
+
+ <script>
+ function check() {
+ var email = document.getElementById('email');
+ var emailRepeat = document.getElementById('repeat-email');
+
+ if (email.value != emailRepeat.value) {
+ emailRepeat.setCustomValidity('The two email addresses must match.');
+ } else {
+ // input is valid -- reset the error message
+ emailRepeat.setCustomValidity('');
+ }
+ }
+ </script>
+
+ <div class="sm:col-span-2">
+ <label for="repeat-email" class="block text-sm font-semibold leading-6 text-gray-900">
+ Repeat email
+ </label>
+ <div class="mt-2.5">
+ <input oninput="check(this)" type="email" name="repeat-email" id="repeat-email" autocomplete="email"
+ class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
+ </div>
+ </div>
+
+ <p class="mt-3 text-sm leading-6 text-gray-400">
+ You can change your email address another {{changes_left}} times.
+ </p>
+ </div>
+
+ <div class="mt-10">
+ <button type="submit"
+ class="block w-full rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
+ Send email
+ </button>
+ </div>
+ </form>
+ </div>
+ </main>
+
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">Learn more about <a target="_blank" rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a></p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">Copyright © 2014—2023 Taler Systems SA. </p>
+ </div>
+ </footer>
+</body>
+
+</html>
diff --git a/packages/challenger-ui/src/enter-file-access-form.html b/packages/challenger-ui/src/enter-file-access-form.html
new file mode 100644
index 000000000..b79d1dada
--- /dev/null
+++ b/packages/challenger-ui/src/enter-file-access-form.html
@@ -0,0 +1,102 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <link rel="stylesheet" href="main.css" />
+ <script type="module" src="main.js"></script>
+ <title>Enter local file name</title>
+</head>
+
+<body class="min-h-full flex flex-col">
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg"><a href="#"><img class="h-8 w-auto"
+ src="data:image/svg+xml,<?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?>%0A<svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 201 90&quot;>%0A <g fill=&quot;%230042b3&quot; fill-rule=&quot;evenodd&quot; stroke-width=&quot;.3&quot;>%0A <path d=&quot;M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z&quot; />%0A <path d=&quot;M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z&quot; />%0A <path d=&quot;M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z&quot; />%0A </g>%0A <path d=&quot;M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z&quot; />%0A</svg>"
+ alt="GNU Taler" style="height: 1.5rem; margin: 0.5rem;"></a></div><span
+ class="flex items-center text-white text-lg font-bold ml-4">Challenger</span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end">
+ </div>
+ </div>
+ </header>
+
+ <main class="flex-1">
+
+ <div class="isolate bg-white px-6 py-12">
+ <div class="mx-auto max-w-2xl text-center">
+ <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
+ Enter file name
+ </h2>
+ <p class="mt-2 text-lg leading-8 text-gray-600">
+ The file will be overwritten with a code which need to be entered in the next page.
+ </p>
+ </div>
+ <form action="/challenge/{{nonce}}" method="POST" class="mx-auto mt-16 max-w-xl sm:mt-20">
+ <div class="grid grid-cols-1 gap-x-8 gap-y-6">
+ <div class="sm:col-span-2">
+ <label for="phone" class="block text-sm font-semibold leading-6 text-gray-900">
+ Phone number
+ </label>
+ <div class="mt-2.5">
+ <input type="filename" name="filename" id="filename" maxlength="200"
+ class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
+ </div>
+ </div>
+
+ <p class="mt-3 text-sm leading-6 text-gray-400">
+ You can change the filename another {{changes_left}} times.
+ </p>
+ </div>
+
+ <div class="mt-10">
+ <button type="submit"
+ class="block w-full rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
+ Write file
+ </button>
+ </div>
+ </form>
+ </div>
+ </main>
+
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">Learn more about <a target="_blank" rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a></p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">Copyright © 2014—2023 Taler Systems SA. </p>
+ </div>
+ </footer>
+</body>
+
+</html>
diff --git a/packages/challenger-ui/src/enter-phone-form.html b/packages/challenger-ui/src/enter-phone-form.html
new file mode 100644
index 000000000..ca06fb94e
--- /dev/null
+++ b/packages/challenger-ui/src/enter-phone-form.html
@@ -0,0 +1,126 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <link rel="stylesheet" href="main.css" />
+ <script type="module" src="main.js"></script>
+ <title>Enter contact details</title>
+</head>
+
+<body class="min-h-full flex flex-col">
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg"><a href="#"><img class="h-8 w-auto"
+ src="data:image/svg+xml,<?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?>%0A<svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 201 90&quot;>%0A <g fill=&quot;%230042b3&quot; fill-rule=&quot;evenodd&quot; stroke-width=&quot;.3&quot;>%0A <path d=&quot;M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z&quot; />%0A <path d=&quot;M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z&quot; />%0A <path d=&quot;M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z&quot; />%0A </g>%0A <path d=&quot;M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z&quot; />%0A</svg>"
+ alt="GNU Taler" style="height: 1.5rem; margin: 0.5rem;"></a></div><span
+ class="flex items-center text-white text-lg font-bold ml-4">Challenger</span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end">
+ </div>
+ </div>
+ </header>
+
+ <main class="flex-1">
+
+ <div class="isolate bg-white px-6 py-12">
+ <div class="mx-auto max-w-2xl text-center">
+ <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
+ Enter contact details
+ </h2>
+ <p class="mt-2 text-lg leading-8 text-gray-600">
+ You will receive an SMS with a TAN code that must be provided on the next page.
+ </p>
+ </div>
+ <form action="/challenge/{{nonce}}" method="POST" class="mx-auto mt-16 max-w-xl sm:mt-20">
+ <div class="grid grid-cols-1 gap-x-8 gap-y-6">
+ <div class="sm:col-span-2">
+ <label for="phone" class="block text-sm font-semibold leading-6 text-gray-900">
+ Phone number
+ </label>
+ <div class="mt-2.5">
+ <input type="phone" name="phone" id="phone" maxlength="20" autocomplete="tel"
+ class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
+ </div>
+ </div>
+
+ <script>
+ function check() {
+ var phone = document.getElementById('phone');
+ var phoneRepeat = document.getElementById('repeat-phone');
+
+ if (phone.value != phoneRepeat.value) {
+ phoneRepeat.setCustomValidity('The two phone numbers must match.');
+ } else {
+ // input is valid -- reset the error message
+ phoneRepeat.setCustomValidity('');
+ }
+ }
+ </script>
+
+ <div class="sm:col-span-2">
+ <label for="repeat-phone" class="block text-sm font-semibold leading-6 text-gray-900">
+ Repeat phone
+ </label>
+ <div class="mt-2.5">
+ <input oninput="check(this)" type="number" name="repeat-phone" id="repeat-phone" autocomplete="tel"
+ class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6">
+ </div>
+ </div>
+
+ <p class="mt-3 text-sm leading-6 text-gray-400">
+ You can change your phone number another {{changes_left}} times.
+ </p>
+ </div>
+
+ <div class="mt-10">
+ <button type="submit"
+ class="block w-full rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
+ Send SMS
+ </button>
+ </div>
+ </form>
+ </div>
+ </main>
+
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">Learn more about <a target="_blank" rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a></p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">Copyright © 2014—2023 Taler Systems SA. </p>
+ </div>
+ </footer>
+</body>
+
+</html>
diff --git a/packages/challenger-ui/src/enter-tan-form.html b/packages/challenger-ui/src/enter-tan-form.html
new file mode 100644
index 000000000..965f8e9d2
--- /dev/null
+++ b/packages/challenger-ui/src/enter-tan-form.html
@@ -0,0 +1,117 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <link rel="stylesheet" href="main.css" />
+ <script type="module" src="main.js"></script>
+ <title>Enter your TAN</title>
+</head>
+
+<body class="min-h-full flex flex-col">
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg"><a href="#"><img class="h-8 w-auto"
+ src="data:image/svg+xml,<?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?>%0A<svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 201 90&quot;>%0A <g fill=&quot;%230042b3&quot; fill-rule=&quot;evenodd&quot; stroke-width=&quot;.3&quot;>%0A <path d=&quot;M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z&quot; />%0A <path d=&quot;M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z&quot; />%0A <path d=&quot;M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z&quot; />%0A </g>%0A <path d=&quot;M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z&quot; />%0A</svg>"
+ alt="GNU Taler" style="height: 1.5rem; margin: 0.5rem;"></a></div><span
+ class="flex items-center text-white text-lg font-bold ml-4">Challenger</span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end">
+ </div>
+ </div>
+ </header>
+
+ <main class="flex-1">
+
+ <div class="isolate bg-white px-6 py-12">
+ <div class="mx-auto max-w-2xl text-center">
+ <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
+ Please enter the TAN you received to authenticate.
+ </h2>
+ <p class="mt-2 text-lg leading-8 text-gray-600">
+ <!-- {{#transmitted}}
+ A TAN was sent to your address &quot;{{address}}&quot;.
+ {{/transmitted}} -->
+ <!-- {{^transmitted}} -->
+ We recently already sent a TAN to your address &quot;{{address}}&quot;.
+ A new TAN will not be transmitted again before {{next_tx_time}}.
+ <!-- {{/transmitted}} -->
+ </p>
+ </div>
+
+
+ <form action="/solve/{{nonce}}" method="POST" class="mx-auto mt-16 max-w-xl sm:mt-20">
+ <div class="grid grid-cols-1 gap-x-8 gap-y-6">
+ <div class="sm:col-span-2">
+ <label for="pin" class="block text-sm font-semibold leading-6 text-gray-900">
+ TAN code
+ </label>
+ <div class="mt-2.5">
+ <div
+ class="flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600">
+ <span class="flex select-none items-center pl-3 text-gray-500 sm:text-sm">TAN:</span>
+ <input type="number" name="pin" id="pin" maxlength="64"
+ class="block flex-1 border-0 bg-transparent py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6"
+ placeholder="12345678">
+ </div>
+
+ </div>
+ </div>
+
+ <p class="mt-3 text-sm leading-6 text-gray-400">
+ You have {{attempts_left}} attempts left.
+ </p>
+ </div>
+
+ <div class="mt-10">
+ <button type="submit"
+ class="block w-full rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
+ Check
+ </button>
+ </div>
+ </form>
+
+ </div>
+ </main>
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">Learn more about <a target="_blank" rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a></p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">Copyright © 2014—2023 Taler Systems SA. </p>
+ </div>
+ </footer>
+
+</body>
+
+</html>
diff --git a/packages/challenger-ui/src/internal-error.html b/packages/challenger-ui/src/internal-error.html
new file mode 100644
index 000000000..521a2b69e
--- /dev/null
+++ b/packages/challenger-ui/src/internal-error.html
@@ -0,0 +1,89 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <link rel="stylesheet" href="main.css" />
+ <script type="module" src="main.js"></script>
+ <title>Internal server error (#{{ec}})</title>
+</head>
+
+<body class="min-h-full flex flex-col">
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg"><a href="#"><img class="h-8 w-auto"
+ src="data:image/svg+xml,<?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?>%0A<svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 201 90&quot;>%0A <g fill=&quot;%230042b3&quot; fill-rule=&quot;evenodd&quot; stroke-width=&quot;.3&quot;>%0A <path d=&quot;M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z&quot; />%0A <path d=&quot;M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z&quot; />%0A <path d=&quot;M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z&quot; />%0A </g>%0A <path d=&quot;M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z&quot; />%0A</svg>"
+ alt="GNU Taler" style="height: 1.5rem; margin: 0.5rem;"></a></div><span
+ class="flex items-center text-white text-lg font-bold ml-4">Challenger</span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end">
+ </div>
+ </div>
+ </header>
+
+ <main class="flex-1">
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 items-center mt-4">
+ <div class="rounded-md bg-red-50 p-4 shadow-xl">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svg xmlns="http://www.w3.org/2000/svg" stroke="none" viewBox="0 0 24 24" fill="currentColor"
+ class="w-8 h-8 text-red-400">
+ <path fill-rule="evenodd"
+ d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" />
+ </svg>
+ </div>
+ <div class="ml-3">
+ <h3 class="text-sm font-medium text-red-800">
+ Internal error
+ </h3>
+ <div class="mt-2 text-sm text-red-700">
+ <p>{{hint}} ({{detail}})</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ </main>
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">Learn more about <a target="_blank" rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a></p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">Copyright © 2014—2023 Taler Systems SA. </p>
+ </div>
+ </footer>
+
+</body>
+
+</html> \ No newline at end of file
diff --git a/packages/challenger-ui/src/invalid-pin.html b/packages/challenger-ui/src/invalid-pin.html
new file mode 100644
index 000000000..1229b8095
--- /dev/null
+++ b/packages/challenger-ui/src/invalid-pin.html
@@ -0,0 +1,87 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <link rel="stylesheet" href="main.css" />
+ <script type="module" src="main.js"></script>
+ <title>Invalid solution (#{{ec}})</title>
+</head>
+
+<body class="min-h-full flex flex-col">
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg"><a href="#"><img class="h-8 w-auto"
+ src="data:image/svg+xml,<?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?>%0A<svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 201 90&quot;>%0A <g fill=&quot;%230042b3&quot; fill-rule=&quot;evenodd&quot; stroke-width=&quot;.3&quot;>%0A <path d=&quot;M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z&quot; />%0A <path d=&quot;M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z&quot; />%0A <path d=&quot;M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z&quot; />%0A </g>%0A <path d=&quot;M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z&quot; />%0A</svg>"
+ alt="GNU Taler" style="height: 1.5rem; margin: 0.5rem;"></a></div><span
+ class="flex items-center text-white text-lg font-bold ml-4">Challenger</span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end">
+ </div>
+ </div>
+ </header>
+
+ <main class="flex-1">
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 items-center mt-4">
+ <div class="rounded-md bg-red-50 p-4 shadow-xl">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svg xmlns="http://www.w3.org/2000/svg" stroke="none" viewBox="0 0 24 24" fill="currentColor"
+ class="w-8 h-8 text-red-400">
+ <path fill-rule="evenodd"
+ d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" />
+ </svg>
+ </div>
+ <div class="ml-3">
+ <h3 class="text-sm font-medium text-red-800">
+ Invalid PIN
+ </h3>
+ <div class="mt-2 text-sm text-red-700">
+ <p>{{hint}}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </main>
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">Learn more about <a target="_blank" rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a></p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">Copyright © 2014—2023 Taler Systems SA. </p>
+ </div>
+ </footer>
+</body>
+
+</html> \ No newline at end of file
diff --git a/packages/challenger-ui/src/invalid-request.html b/packages/challenger-ui/src/invalid-request.html
new file mode 100644
index 000000000..89e6b125c
--- /dev/null
+++ b/packages/challenger-ui/src/invalid-request.html
@@ -0,0 +1,88 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <link rel="stylesheet" href="main.css" />
+ <script type="module" src="main.js"></script>
+ <title>Invalid request (#{{ec}})</title>
+</head>
+
+<body class="min-h-full flex flex-col">
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg"><a href="#"><img class="h-8 w-auto"
+ src="data:image/svg+xml,<?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?>%0A<svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 201 90&quot;>%0A <g fill=&quot;%230042b3&quot; fill-rule=&quot;evenodd&quot; stroke-width=&quot;.3&quot;>%0A <path d=&quot;M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z&quot; />%0A <path d=&quot;M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z&quot; />%0A <path d=&quot;M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z&quot; />%0A </g>%0A <path d=&quot;M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z&quot; />%0A</svg>"
+ alt="GNU Taler" style="height: 1.5rem; margin: 0.5rem;"></a></div><span
+ class="flex items-center text-white text-lg font-bold ml-4">Challenger</span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end">
+ </div>
+ </div>
+ </header>
+
+ <main class="flex-1">
+
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 items-center mt-4">
+ <div class="rounded-md bg-red-50 p-4 shadow-xl">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svg xmlns="http://www.w3.org/2000/svg" stroke="none" viewBox="0 0 24 24" fill="currentColor"
+ class="w-8 h-8 text-red-400">
+ <path fill-rule="evenodd"
+ d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" />
+ </svg>
+ </div>
+ <div class="ml-3">
+ <h3 class="text-sm font-medium text-red-800">
+ Request error
+ </h3>
+ <div class="mt-2 text-sm text-red-700">
+ <p>{{hint}}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </main>
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">Learn more about <a target="_blank" rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a></p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">Copyright © 2014—2023 Taler Systems SA. </p>
+ </div>
+ </footer>
+</body>
+
+</html> \ No newline at end of file
diff --git a/packages/challenger-ui/src/main.js b/packages/challenger-ui/src/main.js
new file mode 100644
index 000000000..3272bd0bb
--- /dev/null
+++ b/packages/challenger-ui/src/main.js
@@ -0,0 +1,2 @@
+// intentionally empty
+import "./scss/main.css"; \ No newline at end of file
diff --git a/packages/challenger-ui/src/scss/main.css b/packages/challenger-ui/src/scss/main.css
new file mode 100644
index 000000000..b5c61c956
--- /dev/null
+++ b/packages/challenger-ui/src/scss/main.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/packages/challenger-ui/src/validation-unknown.html b/packages/challenger-ui/src/validation-unknown.html
new file mode 100644
index 000000000..56c8d156c
--- /dev/null
+++ b/packages/challenger-ui/src/validation-unknown.html
@@ -0,0 +1,89 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021--2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html lang="en" class="h-full">
+
+<head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="taler-support" content="uri">
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <link rel="stylesheet" href="main.css" />
+ <script type="module" src="main.js"></script>
+ <title>Validation process unknown (#{{ec}})</title>
+</head>
+
+<body class="min-h-full flex flex-col">
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg"><a href="#"><img class="h-8 w-auto"
+ src="data:image/svg+xml,<?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot; standalone=&quot;no&quot;?>%0A<svg xmlns=&quot;http://www.w3.org/2000/svg&quot; viewBox=&quot;0 0 201 90&quot;>%0A <g fill=&quot;%230042b3&quot; fill-rule=&quot;evenodd&quot; stroke-width=&quot;.3&quot;>%0A <path d=&quot;M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z&quot; />%0A <path d=&quot;M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z&quot; />%0A <path d=&quot;M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z&quot; />%0A </g>%0A <path d=&quot;M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z&quot; />%0A</svg>"
+ alt="GNU Taler" style="height: 1.5rem; margin: 0.5rem;"></a></div><span
+ class="flex items-center text-white text-lg font-bold ml-4">Challenger</span>
+ </div>
+ <div class="block flex-1 ml-6 "></div>
+ <div class="flex justify-end">
+ </div>
+ </div>
+ </header>
+
+ <main class="flex-1">
+
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 items-center mt-4">
+ <div class="rounded-md bg-red-50 p-4 shadow-xl">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svg xmlns="http://www.w3.org/2000/svg" stroke="none" viewBox="0 0 24 24" fill="currentColor"
+ class="w-8 h-8 text-red-400">
+ <path fill-rule="evenodd"
+ d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" />
+ </svg>
+ </div>
+ <div class="ml-3">
+ <h3 class="text-sm font-medium text-red-800">
+ Validation error
+ </h3>
+ <div class="mt-2 text-sm text-red-700">
+ <p>{{hint}}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ </main>
+
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">Learn more about <a target="_blank" rel="noreferrer noopener"
+ class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a></p>
+ </div>
+ <div style="flex-grow: 1;"></div>
+ <p class="text-xs leading-5 text-gray-400">Copyright © 2014—2023 Taler Systems SA. </p>
+ </div>
+ </footer>
+</body>
+
+</html> \ No newline at end of file
diff --git a/packages/challenger-ui/tailwind.config.js b/packages/challenger-ui/tailwind.config.js
new file mode 100644
index 000000000..ec51dfbb8
--- /dev/null
+++ b/packages/challenger-ui/tailwind.config.js
@@ -0,0 +1,14 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: {
+ relative: true,
+ files: [
+ "./src/**/*.{html,tsx}",
+ "./node_modules/@gnu-taler/web-util/src/**/*.{html,tsx}"
+ ],
+ },
+ theme: {
+ extend: {},
+ },
+ plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
+};
diff --git a/packages/idb-bridge/.gitignore b/packages/idb-bridge/.gitignore
index 796b96d1c..4ea7691dc 100644
--- a/packages/idb-bridge/.gitignore
+++ b/packages/idb-bridge/.gitignore
@@ -1 +1,2 @@
/build
+mytestdb.sqlite3
diff --git a/packages/idb-bridge/package-lock.json b/packages/idb-bridge/package-lock.json
deleted file mode 100644
index 13fff38b6..000000000
--- a/packages/idb-bridge/package-lock.json
+++ /dev/null
@@ -1,2595 +0,0 @@
-{
- "name": "@gnu-taler/idb-bridge",
- "version": "0.0.16",
- "lockfileVersion": 1,
- "requires": true,
- "dependencies": {
- "@types/node": {
- "version": "14.14.22",
- "dev": true
- },
- "ava": {
- "version": "3.15.0",
- "dev": true,
- "requires": {
- "@concordance/react": "^2.0.0",
- "acorn": "^8.0.4",
- "acorn-walk": "^8.0.0",
- "ansi-styles": "^5.0.0",
- "arrgv": "^1.0.2",
- "arrify": "^2.0.1",
- "callsites": "^3.1.0",
- "chalk": "^4.1.0",
- "chokidar": "^3.4.3",
- "chunkd": "^2.0.1",
- "ci-info": "^2.0.0",
- "ci-parallel-vars": "^1.0.1",
- "clean-yaml-object": "^0.1.0",
- "cli-cursor": "^3.1.0",
- "cli-truncate": "^2.1.0",
- "code-excerpt": "^3.0.0",
- "common-path-prefix": "^3.0.0",
- "concordance": "^5.0.1",
- "convert-source-map": "^1.7.0",
- "currently-unhandled": "^0.4.1",
- "debug": "^4.3.1",
- "del": "^6.0.0",
- "emittery": "^0.8.0",
- "equal-length": "^1.0.0",
- "figures": "^3.2.0",
- "globby": "^11.0.1",
- "ignore-by-default": "^2.0.0",
- "import-local": "^3.0.2",
- "indent-string": "^4.0.0",
- "is-error": "^2.2.2",
- "is-plain-object": "^5.0.0",
- "is-promise": "^4.0.0",
- "lodash": "^4.17.20",
- "matcher": "^3.0.0",
- "md5-hex": "^3.0.1",
- "mem": "^8.0.0",
- "ms": "^2.1.3",
- "ora": "^5.2.0",
- "p-event": "^4.2.0",
- "p-map": "^4.0.0",
- "picomatch": "^2.2.2",
- "pkg-conf": "^3.1.0",
- "plur": "^4.0.0",
- "pretty-ms": "^7.0.1",
- "read-pkg": "^5.2.0",
- "resolve-cwd": "^3.0.0",
- "slash": "^3.0.0",
- "source-map-support": "^0.5.19",
- "stack-utils": "^2.0.3",
- "strip-ansi": "^6.0.0",
- "supertap": "^2.0.0",
- "temp-dir": "^2.0.0",
- "trim-off-newlines": "^1.0.1",
- "update-notifier": "^5.0.1",
- "write-file-atomic": "^3.0.3",
- "yargs": "^16.2.0"
- },
- "dependencies": {
- "@babel/code-frame": {
- "version": "7.12.13",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz",
- "integrity": "sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==",
- "dev": true,
- "requires": {
- "@babel/highlight": "^7.12.13"
- }
- },
- "@babel/helper-validator-identifier": {
- "version": "7.12.11",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz",
- "integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==",
- "dev": true
- },
- "@babel/highlight": {
- "version": "7.12.13",
- "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.12.13.tgz",
- "integrity": "sha512-kocDQvIbgMKlWxXe9fof3TQ+gkIPOUSEYhJjqUjvKMez3krV7vbzYCDq39Oj11UAVK7JqPVGQPlgE85dPNlQww==",
- "dev": true,
- "requires": {
- "@babel/helper-validator-identifier": "^7.12.11",
- "chalk": "^2.0.0",
- "js-tokens": "^4.0.0"
- },
- "dependencies": {
- "ansi-styles": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
- "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
- "dev": true,
- "requires": {
- "color-convert": "^1.9.0"
- }
- },
- "chalk": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
- "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
- "dev": true,
- "requires": {
- "ansi-styles": "^3.2.1",
- "escape-string-regexp": "^1.0.5",
- "supports-color": "^5.3.0"
- }
- },
- "color-convert": {
- "version": "1.9.3",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
- "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
- "dev": true,
- "requires": {
- "color-name": "1.1.3"
- }
- },
- "color-name": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
- "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
- "dev": true
- },
- "has-flag": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
- "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
- "dev": true
- },
- "supports-color": {
- "version": "5.5.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
- "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
- "dev": true,
- "requires": {
- "has-flag": "^3.0.0"
- }
- }
- }
- },
- "@concordance/react": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/@concordance/react/-/react-2.0.0.tgz",
- "integrity": "sha512-huLSkUuM2/P+U0uy2WwlKuixMsTODD8p4JVQBI4VKeopkiN0C7M3N9XYVawb4M+4spN5RrO/eLhk7KoQX6nsfA==",
- "dev": true,
- "requires": {
- "arrify": "^1.0.1"
- },
- "dependencies": {
- "arrify": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
- "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=",
- "dev": true
- }
- }
- },
- "@nodelib/fs.scandir": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz",
- "integrity": "sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==",
- "dev": true,
- "requires": {
- "@nodelib/fs.stat": "2.0.4",
- "run-parallel": "^1.1.9"
- }
- },
- "@nodelib/fs.stat": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz",
- "integrity": "sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==",
- "dev": true
- },
- "@nodelib/fs.walk": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz",
- "integrity": "sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==",
- "dev": true,
- "requires": {
- "@nodelib/fs.scandir": "2.1.4",
- "fastq": "^1.6.0"
- }
- },
- "@sindresorhus/is": {
- "version": "0.14.0",
- "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz",
- "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==",
- "dev": true
- },
- "@szmarczak/http-timer": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
- "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==",
- "dev": true,
- "requires": {
- "defer-to-connect": "^1.0.1"
- }
- },
- "@types/normalize-package-data": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
- "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==",
- "dev": true
- },
- "acorn": {
- "version": "8.0.5",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.0.5.tgz",
- "integrity": "sha512-v+DieK/HJkJOpFBETDJioequtc3PfxsWMaxIdIwujtF7FEV/MAyDQLlm6/zPvr7Mix07mLh6ccVwIsloceodlg==",
- "dev": true
- },
- "acorn-walk": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.0.2.tgz",
- "integrity": "sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==",
- "dev": true
- },
- "aggregate-error": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
- "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
- "dev": true,
- "requires": {
- "clean-stack": "^2.0.0",
- "indent-string": "^4.0.0"
- }
- },
- "ansi-align": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz",
- "integrity": "sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==",
- "dev": true,
- "requires": {
- "string-width": "^3.0.0"
- },
- "dependencies": {
- "ansi-regex": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
- "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
- "dev": true
- },
- "emoji-regex": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
- "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
- "dev": true
- },
- "is-fullwidth-code-point": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
- "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
- "dev": true
- },
- "string-width": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
- "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
- "dev": true,
- "requires": {
- "emoji-regex": "^7.0.1",
- "is-fullwidth-code-point": "^2.0.0",
- "strip-ansi": "^5.1.0"
- }
- },
- "strip-ansi": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
- "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
- "dev": true,
- "requires": {
- "ansi-regex": "^4.1.0"
- }
- }
- }
- },
- "ansi-regex": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
- "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
- "dev": true
- },
- "ansi-styles": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.1.0.tgz",
- "integrity": "sha512-osxifZo3ar56+e8tdYreU6p8FZGciBHo5O0JoDAxMUqZuyNUb+yHEwYtJZ+Z32R459jEgtwVf1u8D7qYwU0l6w==",
- "dev": true
- },
- "anymatch": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
- "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==",
- "dev": true,
- "requires": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- }
- },
- "argparse": {
- "version": "1.0.10",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
- "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
- "dev": true,
- "requires": {
- "sprintf-js": "~1.0.2"
- }
- },
- "array-find-index": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz",
- "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=",
- "dev": true
- },
- "array-union": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
- "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
- "dev": true
- },
- "arrgv": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/arrgv/-/arrgv-1.0.2.tgz",
- "integrity": "sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw==",
- "dev": true
- },
- "arrify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
- "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
- "dev": true
- },
- "astral-regex": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
- "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
- "dev": true
- },
- "balanced-match": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
- "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
- "dev": true
- },
- "base64-js": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
- "dev": true
- },
- "binary-extensions": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
- "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
- "dev": true
- },
- "bl": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
- "dev": true,
- "requires": {
- "buffer": "^5.5.0",
- "inherits": "^2.0.4",
- "readable-stream": "^3.4.0"
- }
- },
- "blueimp-md5": {
- "version": "2.18.0",
- "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.18.0.tgz",
- "integrity": "sha512-vE52okJvzsVWhcgUHOv+69OG3Mdg151xyn41aVQN/5W5S+S43qZhxECtYLAEHMSFWX6Mv5IZrzj3T5+JqXfj5Q==",
- "dev": true
- },
- "boxen": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.0.0.tgz",
- "integrity": "sha512-5bvsqw+hhgUi3oYGK0Vf4WpIkyemp60WBInn7+WNfoISzAqk/HX4L7WNROq38E6UR/y3YADpv6pEm4BfkeEAdA==",
- "dev": true,
- "requires": {
- "ansi-align": "^3.0.0",
- "camelcase": "^6.2.0",
- "chalk": "^4.1.0",
- "cli-boxes": "^2.2.1",
- "string-width": "^4.2.0",
- "type-fest": "^0.20.2",
- "widest-line": "^3.1.0",
- "wrap-ansi": "^7.0.0"
- },
- "dependencies": {
- "type-fest": {
- "version": "0.20.2",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
- "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
- "dev": true
- }
- }
- },
- "brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "requires": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "braces": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
- "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
- "dev": true,
- "requires": {
- "fill-range": "^7.0.1"
- }
- },
- "buffer": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "dev": true,
- "requires": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.1.13"
- }
- },
- "buffer-from": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
- "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
- "dev": true
- },
- "cacheable-request": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
- "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==",
- "dev": true,
- "requires": {
- "clone-response": "^1.0.2",
- "get-stream": "^5.1.0",
- "http-cache-semantics": "^4.0.0",
- "keyv": "^3.0.0",
- "lowercase-keys": "^2.0.0",
- "normalize-url": "^4.1.0",
- "responselike": "^1.0.2"
- },
- "dependencies": {
- "get-stream": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
- "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
- "dev": true,
- "requires": {
- "pump": "^3.0.0"
- }
- },
- "lowercase-keys": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
- "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
- "dev": true
- }
- }
- },
- "callsites": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
- "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
- "dev": true
- },
- "camelcase": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz",
- "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==",
- "dev": true
- },
- "chalk": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
- "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
- "dev": true,
- "requires": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
- "dependencies": {
- "ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "requires": {
- "color-convert": "^2.0.1"
- }
- }
- }
- },
- "chokidar": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz",
- "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==",
- "dev": true,
- "requires": {
- "anymatch": "~3.1.1",
- "braces": "~3.0.2",
- "fsevents": "~2.3.1",
- "glob-parent": "~5.1.0",
- "is-binary-path": "~2.1.0",
- "is-glob": "~4.0.1",
- "normalize-path": "~3.0.0",
- "readdirp": "~3.5.0"
- }
- },
- "chunkd": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/chunkd/-/chunkd-2.0.1.tgz",
- "integrity": "sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ==",
- "dev": true
- },
- "ci-info": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
- "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
- "dev": true
- },
- "ci-parallel-vars": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/ci-parallel-vars/-/ci-parallel-vars-1.0.1.tgz",
- "integrity": "sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==",
- "dev": true
- },
- "clean-stack": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
- "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
- "dev": true
- },
- "clean-yaml-object": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/clean-yaml-object/-/clean-yaml-object-0.1.0.tgz",
- "integrity": "sha1-Y/sRDcLOGoTcIfbZM0h20BCui2g=",
- "dev": true
- },
- "cli-boxes": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz",
- "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==",
- "dev": true
- },
- "cli-cursor": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
- "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
- "dev": true,
- "requires": {
- "restore-cursor": "^3.1.0"
- }
- },
- "cli-spinners": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.5.0.tgz",
- "integrity": "sha512-PC+AmIuK04E6aeSs/pUccSujsTzBhu4HzC2dL+CfJB/Jcc2qTRbEwZQDfIUpt2Xl8BodYBEq8w4fc0kU2I9DjQ==",
- "dev": true
- },
- "cli-truncate": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
- "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
- "dev": true,
- "requires": {
- "slice-ansi": "^3.0.0",
- "string-width": "^4.2.0"
- }
- },
- "cliui": {
- "version": "7.0.4",
- "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
- "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
- "dev": true,
- "requires": {
- "string-width": "^4.2.0",
- "strip-ansi": "^6.0.0",
- "wrap-ansi": "^7.0.0"
- }
- },
- "clone": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
- "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=",
- "dev": true
- },
- "clone-response": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
- "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=",
- "dev": true,
- "requires": {
- "mimic-response": "^1.0.0"
- }
- },
- "code-excerpt": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-3.0.0.tgz",
- "integrity": "sha512-VHNTVhd7KsLGOqfX3SyeO8RyYPMp1GJOg194VITk04WMYCv4plV68YWe6TJZxd9MhobjtpMRnVky01gqZsalaw==",
- "dev": true,
- "requires": {
- "convert-to-spaces": "^1.0.1"
- }
- },
- "color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
- "requires": {
- "color-name": "~1.1.4"
- }
- },
- "color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true
- },
- "common-path-prefix": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz",
- "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==",
- "dev": true
- },
- "concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
- "dev": true
- },
- "concordance": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/concordance/-/concordance-5.0.2.tgz",
- "integrity": "sha512-hC63FKdGM9tBcd4VQIa+LQjmrgorrnxESb8B3J21Qe/FzL0blBv0pb8iNyymt+bmsvGSUqO0uhPi2ZSLgLtLdg==",
- "dev": true,
- "requires": {
- "date-time": "^3.1.0",
- "esutils": "^2.0.3",
- "fast-diff": "^1.2.0",
- "js-string-escape": "^1.0.1",
- "lodash": "^4.17.15",
- "md5-hex": "^3.0.1",
- "semver": "^7.3.2",
- "well-known-symbols": "^2.0.0"
- }
- },
- "configstore": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz",
- "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==",
- "dev": true,
- "requires": {
- "dot-prop": "^5.2.0",
- "graceful-fs": "^4.1.2",
- "make-dir": "^3.0.0",
- "unique-string": "^2.0.0",
- "write-file-atomic": "^3.0.0",
- "xdg-basedir": "^4.0.0"
- }
- },
- "convert-source-map": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz",
- "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==",
- "dev": true,
- "requires": {
- "safe-buffer": "~5.1.1"
- }
- },
- "convert-to-spaces": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-1.0.2.tgz",
- "integrity": "sha1-fj5Iu+bZl7FBfdyihoIEtNPYVxU=",
- "dev": true
- },
- "crypto-random-string": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
- "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
- "dev": true
- },
- "currently-unhandled": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
- "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=",
- "dev": true,
- "requires": {
- "array-find-index": "^1.0.1"
- }
- },
- "date-time": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz",
- "integrity": "sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==",
- "dev": true,
- "requires": {
- "time-zone": "^1.0.0"
- }
- },
- "debug": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
- "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
- "dev": true,
- "requires": {
- "ms": "2.1.2"
- },
- "dependencies": {
- "ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
- "dev": true
- }
- }
- },
- "decompress-response": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
- "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=",
- "dev": true,
- "requires": {
- "mimic-response": "^1.0.0"
- }
- },
- "deep-extend": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
- "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
- "dev": true
- },
- "defaults": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
- "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=",
- "dev": true,
- "requires": {
- "clone": "^1.0.2"
- }
- },
- "defer-to-connect": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz",
- "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==",
- "dev": true
- },
- "del": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/del/-/del-6.0.0.tgz",
- "integrity": "sha512-1shh9DQ23L16oXSZKB2JxpL7iMy2E0S9d517ptA1P8iw0alkPtQcrKH7ru31rYtKwF499HkTu+DRzq3TCKDFRQ==",
- "dev": true,
- "requires": {
- "globby": "^11.0.1",
- "graceful-fs": "^4.2.4",
- "is-glob": "^4.0.1",
- "is-path-cwd": "^2.2.0",
- "is-path-inside": "^3.0.2",
- "p-map": "^4.0.0",
- "rimraf": "^3.0.2",
- "slash": "^3.0.0"
- }
- },
- "dir-glob": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
- "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
- "dev": true,
- "requires": {
- "path-type": "^4.0.0"
- }
- },
- "dot-prop": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
- "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==",
- "dev": true,
- "requires": {
- "is-obj": "^2.0.0"
- }
- },
- "duplexer3": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz",
- "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=",
- "dev": true
- },
- "emittery": {
- "version": "0.8.1",
- "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz",
- "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==",
- "dev": true
- },
- "emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true
- },
- "end-of-stream": {
- "version": "1.4.4",
- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
- "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
- "dev": true,
- "requires": {
- "once": "^1.4.0"
- }
- },
- "equal-length": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/equal-length/-/equal-length-1.0.1.tgz",
- "integrity": "sha1-IcoRLUirJLTh5//A5TOdMf38J0w=",
- "dev": true
- },
- "error-ex": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
- "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
- "dev": true,
- "requires": {
- "is-arrayish": "^0.2.1"
- }
- },
- "escalade": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
- "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
- "dev": true
- },
- "escape-goat": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz",
- "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==",
- "dev": true
- },
- "escape-string-regexp": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
- "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
- "dev": true
- },
- "esprima": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
- "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "dev": true
- },
- "esutils": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
- "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
- "dev": true
- },
- "fast-diff": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
- "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
- "dev": true
- },
- "fast-glob": {
- "version": "3.2.5",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz",
- "integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==",
- "dev": true,
- "requires": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.0",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.2",
- "picomatch": "^2.2.1"
- }
- },
- "fastq": {
- "version": "1.10.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.10.1.tgz",
- "integrity": "sha512-AWuv6Ery3pM+dY7LYS8YIaCiQvUaos9OB1RyNgaOWnaX+Tik7Onvcsf8x8c+YtDeT0maYLniBip2hox5KtEXXA==",
- "dev": true,
- "requires": {
- "reusify": "^1.0.4"
- }
- },
- "figures": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
- "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
- "dev": true,
- "requires": {
- "escape-string-regexp": "^1.0.5"
- }
- },
- "fill-range": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
- "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
- "dev": true,
- "requires": {
- "to-regex-range": "^5.0.1"
- }
- },
- "find-up": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
- "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
- "dev": true,
- "requires": {
- "locate-path": "^5.0.0",
- "path-exists": "^4.0.0"
- }
- },
- "fs.realpath": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
- "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
- "dev": true
- },
- "fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "optional": true
- },
- "function-bind": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
- "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
- "dev": true
- },
- "get-caller-file": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
- "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true
- },
- "get-stream": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
- "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
- "dev": true,
- "requires": {
- "pump": "^3.0.0"
- }
- },
- "glob": {
- "version": "7.1.6",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
- "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
- "dev": true,
- "requires": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.0.4",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- }
- },
- "glob-parent": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
- "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==",
- "dev": true,
- "requires": {
- "is-glob": "^4.0.1"
- }
- },
- "global-dirs": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz",
- "integrity": "sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==",
- "dev": true,
- "requires": {
- "ini": "2.0.0"
- }
- },
- "globby": {
- "version": "11.0.2",
- "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.2.tgz",
- "integrity": "sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og==",
- "dev": true,
- "requires": {
- "array-union": "^2.1.0",
- "dir-glob": "^3.0.1",
- "fast-glob": "^3.1.1",
- "ignore": "^5.1.4",
- "merge2": "^1.3.0",
- "slash": "^3.0.0"
- }
- },
- "got": {
- "version": "9.6.0",
- "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
- "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==",
- "dev": true,
- "requires": {
- "@sindresorhus/is": "^0.14.0",
- "@szmarczak/http-timer": "^1.1.2",
- "cacheable-request": "^6.0.0",
- "decompress-response": "^3.3.0",
- "duplexer3": "^0.1.4",
- "get-stream": "^4.1.0",
- "lowercase-keys": "^1.0.1",
- "mimic-response": "^1.0.1",
- "p-cancelable": "^1.0.0",
- "to-readable-stream": "^1.0.0",
- "url-parse-lax": "^3.0.0"
- }
- },
- "graceful-fs": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
- "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==",
- "dev": true
- },
- "has": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
- "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
- "dev": true,
- "requires": {
- "function-bind": "^1.1.1"
- }
- },
- "has-flag": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
- "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
- "dev": true
- },
- "has-yarn": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz",
- "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==",
- "dev": true
- },
- "hosted-git-info": {
- "version": "2.8.8",
- "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz",
- "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==",
- "dev": true
- },
- "http-cache-semantics": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
- "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==",
- "dev": true
- },
- "ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "dev": true
- },
- "ignore": {
- "version": "5.1.8",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
- "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==",
- "dev": true
- },
- "ignore-by-default": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-2.0.0.tgz",
- "integrity": "sha512-+mQSgMRiFD3L3AOxLYOCxjIq4OnAmo5CIuC+lj5ehCJcPtV++QacEV7FdpzvYxH6DaOySWzQU6RR0lPLy37ckA==",
- "dev": true
- },
- "import-lazy": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz",
- "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=",
- "dev": true
- },
- "import-local": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.0.2.tgz",
- "integrity": "sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA==",
- "dev": true,
- "requires": {
- "pkg-dir": "^4.2.0",
- "resolve-cwd": "^3.0.0"
- }
- },
- "imurmurhash": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
- "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
- "dev": true
- },
- "indent-string": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
- "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
- "dev": true
- },
- "inflight": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
- "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
- "dev": true,
- "requires": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
- "inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true
- },
- "ini": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz",
- "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==",
- "dev": true
- },
- "irregular-plurals": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.2.0.tgz",
- "integrity": "sha512-YqTdPLfwP7YFN0SsD3QUVCkm9ZG2VzOXv3DOrw5G5mkMbVwptTwVcFv7/C0vOpBmgTxAeTG19XpUs1E522LW9Q==",
- "dev": true
- },
- "is-arrayish": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
- "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=",
- "dev": true
- },
- "is-binary-path": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "dev": true,
- "requires": {
- "binary-extensions": "^2.0.0"
- }
- },
- "is-ci": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz",
- "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==",
- "dev": true,
- "requires": {
- "ci-info": "^2.0.0"
- }
- },
- "is-core-module": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz",
- "integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==",
- "dev": true,
- "requires": {
- "has": "^1.0.3"
- }
- },
- "is-error": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz",
- "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==",
- "dev": true
- },
- "is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
- "dev": true
- },
- "is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true
- },
- "is-glob": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
- "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
- "dev": true,
- "requires": {
- "is-extglob": "^2.1.1"
- }
- },
- "is-installed-globally": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz",
- "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==",
- "dev": true,
- "requires": {
- "global-dirs": "^3.0.0",
- "is-path-inside": "^3.0.2"
- }
- },
- "is-interactive": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
- "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
- "dev": true
- },
- "is-npm": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-5.0.0.tgz",
- "integrity": "sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA==",
- "dev": true
- },
- "is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true
- },
- "is-obj": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
- "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
- "dev": true
- },
- "is-path-cwd": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz",
- "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==",
- "dev": true
- },
- "is-path-inside": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz",
- "integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==",
- "dev": true
- },
- "is-plain-object": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
- "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
- "dev": true
- },
- "is-promise": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
- "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
- "dev": true
- },
- "is-typedarray": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
- "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
- "dev": true
- },
- "is-yarn-global": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz",
- "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==",
- "dev": true
- },
- "js-string-escape": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz",
- "integrity": "sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8=",
- "dev": true
- },
- "js-tokens": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true
- },
- "js-yaml": {
- "version": "3.14.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
- "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
- "dev": true,
- "requires": {
- "argparse": "^1.0.7",
- "esprima": "^4.0.0"
- }
- },
- "json-buffer": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz",
- "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=",
- "dev": true
- },
- "json-parse-better-errors": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
- "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
- "dev": true
- },
- "json-parse-even-better-errors": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
- "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
- "dev": true
- },
- "keyv": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz",
- "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==",
- "dev": true,
- "requires": {
- "json-buffer": "3.0.0"
- }
- },
- "latest-version": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz",
- "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==",
- "dev": true,
- "requires": {
- "package-json": "^6.3.0"
- }
- },
- "lines-and-columns": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz",
- "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=",
- "dev": true
- },
- "load-json-file": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-5.3.0.tgz",
- "integrity": "sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw==",
- "dev": true,
- "requires": {
- "graceful-fs": "^4.1.15",
- "parse-json": "^4.0.0",
- "pify": "^4.0.1",
- "strip-bom": "^3.0.0",
- "type-fest": "^0.3.0"
- }
- },
- "locate-path": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
- "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
- "dev": true,
- "requires": {
- "p-locate": "^4.1.0"
- }
- },
- "lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
- },
- "log-symbols": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz",
- "integrity": "sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==",
- "dev": true,
- "requires": {
- "chalk": "^4.0.0"
- }
- },
- "lowercase-keys": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
- "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==",
- "dev": true
- },
- "lru-cache": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
- "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "dev": true,
- "requires": {
- "yallist": "^4.0.0"
- }
- },
- "make-dir": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
- "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
- "dev": true,
- "requires": {
- "semver": "^6.0.0"
- },
- "dependencies": {
- "semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
- "dev": true
- }
- }
- },
- "map-age-cleaner": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz",
- "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==",
- "dev": true,
- "requires": {
- "p-defer": "^1.0.0"
- }
- },
- "matcher": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
- "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
- "dev": true,
- "requires": {
- "escape-string-regexp": "^4.0.0"
- },
- "dependencies": {
- "escape-string-regexp": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
- "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
- "dev": true
- }
- }
- },
- "md5-hex": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz",
- "integrity": "sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==",
- "dev": true,
- "requires": {
- "blueimp-md5": "^2.10.0"
- }
- },
- "mem": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/mem/-/mem-8.0.0.tgz",
- "integrity": "sha512-qrcJOe6uD+EW8Wrci1Vdiua/15Xw3n/QnaNXE7varnB6InxSk7nu3/i5jfy3S6kWxr8WYJ6R1o0afMUtvorTsA==",
- "dev": true,
- "requires": {
- "map-age-cleaner": "^0.1.3",
- "mimic-fn": "^3.1.0"
- },
- "dependencies": {
- "mimic-fn": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
- "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==",
- "dev": true
- }
- }
- },
- "merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true
- },
- "micromatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
- "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==",
- "dev": true,
- "requires": {
- "braces": "^3.0.1",
- "picomatch": "^2.0.5"
- }
- },
- "mimic-fn": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
- "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
- "dev": true
- },
- "mimic-response": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
- "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
- "dev": true
- },
- "minimatch": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
- "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
- "dev": true,
- "requires": {
- "brace-expansion": "^1.1.7"
- }
- },
- "minimist": {
- "version": "1.2.5",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
- "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
- "dev": true
- },
- "ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true
- },
- "normalize-package-data": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
- "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
- "dev": true,
- "requires": {
- "hosted-git-info": "^2.1.4",
- "resolve": "^1.10.0",
- "semver": "2 || 3 || 4 || 5",
- "validate-npm-package-license": "^3.0.1"
- },
- "dependencies": {
- "semver": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
- "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
- "dev": true
- }
- }
- },
- "normalize-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
- "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true
- },
- "normalize-url": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz",
- "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==",
- "dev": true
- },
- "once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
- "dev": true,
- "requires": {
- "wrappy": "1"
- }
- },
- "onetime": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
- "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
- "dev": true,
- "requires": {
- "mimic-fn": "^2.1.0"
- }
- },
- "ora": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz",
- "integrity": "sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==",
- "dev": true,
- "requires": {
- "bl": "^4.0.3",
- "chalk": "^4.1.0",
- "cli-cursor": "^3.1.0",
- "cli-spinners": "^2.5.0",
- "is-interactive": "^1.0.0",
- "log-symbols": "^4.0.0",
- "strip-ansi": "^6.0.0",
- "wcwidth": "^1.0.1"
- }
- },
- "p-cancelable": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz",
- "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==",
- "dev": true
- },
- "p-defer": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
- "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=",
- "dev": true
- },
- "p-event": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz",
- "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==",
- "dev": true,
- "requires": {
- "p-timeout": "^3.1.0"
- }
- },
- "p-finally": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
- "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=",
- "dev": true
- },
- "p-limit": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
- "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
- "dev": true,
- "requires": {
- "p-try": "^2.0.0"
- }
- },
- "p-locate": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
- "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
- "dev": true,
- "requires": {
- "p-limit": "^2.2.0"
- }
- },
- "p-map": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
- "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
- "dev": true,
- "requires": {
- "aggregate-error": "^3.0.0"
- }
- },
- "p-timeout": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
- "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
- "dev": true,
- "requires": {
- "p-finally": "^1.0.0"
- }
- },
- "p-try": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
- "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
- "dev": true
- },
- "package-json": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz",
- "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==",
- "dev": true,
- "requires": {
- "got": "^9.6.0",
- "registry-auth-token": "^4.0.0",
- "registry-url": "^5.0.0",
- "semver": "^6.2.0"
- },
- "dependencies": {
- "semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
- "dev": true
- }
- }
- },
- "parse-json": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
- "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
- "dev": true,
- "requires": {
- "error-ex": "^1.3.1",
- "json-parse-better-errors": "^1.0.1"
- }
- },
- "parse-ms": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz",
- "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==",
- "dev": true
- },
- "path-exists": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
- "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "dev": true
- },
- "path-is-absolute": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
- "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
- "dev": true
- },
- "path-parse": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
- "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
- "dev": true
- },
- "path-type": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
- "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
- "dev": true
- },
- "picomatch": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
- "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
- "dev": true
- },
- "pify": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
- "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
- "dev": true
- },
- "pkg-conf": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-3.1.0.tgz",
- "integrity": "sha512-m0OTbR/5VPNPqO1ph6Fqbj7Hv6QU7gR/tQW40ZqrL1rjgCU85W6C1bJn0BItuJqnR98PWzw7Z8hHeChD1WrgdQ==",
- "dev": true,
- "requires": {
- "find-up": "^3.0.0",
- "load-json-file": "^5.2.0"
- },
- "dependencies": {
- "find-up": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
- "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
- "dev": true,
- "requires": {
- "locate-path": "^3.0.0"
- }
- },
- "locate-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
- "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
- "dev": true,
- "requires": {
- "p-locate": "^3.0.0",
- "path-exists": "^3.0.0"
- }
- },
- "p-locate": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
- "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
- "dev": true,
- "requires": {
- "p-limit": "^2.0.0"
- }
- },
- "path-exists": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
- "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
- "dev": true
- }
- }
- },
- "pkg-dir": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
- "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
- "dev": true,
- "requires": {
- "find-up": "^4.0.0"
- }
- },
- "plur": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz",
- "integrity": "sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==",
- "dev": true,
- "requires": {
- "irregular-plurals": "^3.2.0"
- }
- },
- "prepend-http": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
- "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=",
- "dev": true
- },
- "pretty-ms": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz",
- "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==",
- "dev": true,
- "requires": {
- "parse-ms": "^2.1.0"
- }
- },
- "pump": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
- "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
- "dev": true,
- "requires": {
- "end-of-stream": "^1.1.0",
- "once": "^1.3.1"
- }
- },
- "pupa": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz",
- "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==",
- "dev": true,
- "requires": {
- "escape-goat": "^2.0.0"
- }
- },
- "queue-microtask": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.2.tgz",
- "integrity": "sha512-dB15eXv3p2jDlbOiNLyMabYg1/sXvppd8DP2J3EOCQ0AkuSXCW2tP7mnVouVLJKgUMY6yP0kcQDVpLCN13h4Xg==",
- "dev": true
- },
- "rc": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
- "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
- "dev": true,
- "requires": {
- "deep-extend": "^0.6.0",
- "ini": "~1.3.0",
- "minimist": "^1.2.0",
- "strip-json-comments": "~2.0.1"
- },
- "dependencies": {
- "ini": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
- "dev": true
- }
- }
- },
- "read-pkg": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
- "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
- "dev": true,
- "requires": {
- "@types/normalize-package-data": "^2.4.0",
- "normalize-package-data": "^2.5.0",
- "parse-json": "^5.0.0",
- "type-fest": "^0.6.0"
- },
- "dependencies": {
- "parse-json": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
- "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
- "dev": true,
- "requires": {
- "@babel/code-frame": "^7.0.0",
- "error-ex": "^1.3.1",
- "json-parse-even-better-errors": "^2.3.0",
- "lines-and-columns": "^1.1.6"
- }
- },
- "type-fest": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
- "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
- "dev": true
- }
- }
- },
- "readable-stream": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
- "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- }
- },
- "readdirp": {
- "version": "3.5.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz",
- "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==",
- "dev": true,
- "requires": {
- "picomatch": "^2.2.1"
- }
- },
- "registry-auth-token": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz",
- "integrity": "sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==",
- "dev": true,
- "requires": {
- "rc": "^1.2.8"
- }
- },
- "registry-url": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz",
- "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==",
- "dev": true,
- "requires": {
- "rc": "^1.2.8"
- }
- },
- "require-directory": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
- "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
- "dev": true
- },
- "resolve": {
- "version": "1.20.0",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
- "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
- "dev": true,
- "requires": {
- "is-core-module": "^2.2.0",
- "path-parse": "^1.0.6"
- }
- },
- "resolve-cwd": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
- "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==",
- "dev": true,
- "requires": {
- "resolve-from": "^5.0.0"
- }
- },
- "resolve-from": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
- "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
- "dev": true
- },
- "responselike": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz",
- "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=",
- "dev": true,
- "requires": {
- "lowercase-keys": "^1.0.0"
- }
- },
- "restore-cursor": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
- "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
- "dev": true,
- "requires": {
- "onetime": "^5.1.0",
- "signal-exit": "^3.0.2"
- }
- },
- "reusify": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
- "dev": true
- },
- "rimraf": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
- "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
- "dev": true,
- "requires": {
- "glob": "^7.1.3"
- }
- },
- "run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
- "requires": {
- "queue-microtask": "^1.2.2"
- }
- },
- "safe-buffer": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
- "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
- "dev": true
- },
- "semver": {
- "version": "7.3.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
- "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
- "dev": true,
- "requires": {
- "lru-cache": "^6.0.0"
- }
- },
- "semver-diff": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz",
- "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==",
- "dev": true,
- "requires": {
- "semver": "^6.3.0"
- },
- "dependencies": {
- "semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
- "dev": true
- }
- }
- },
- "serialize-error": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
- "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
- "dev": true,
- "requires": {
- "type-fest": "^0.13.1"
- },
- "dependencies": {
- "type-fest": {
- "version": "0.13.1",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
- "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
- "dev": true
- }
- }
- },
- "signal-exit": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
- "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
- "dev": true
- },
- "slash": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
- "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
- "dev": true
- },
- "slice-ansi": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
- "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
- "dev": true,
- "requires": {
- "ansi-styles": "^4.0.0",
- "astral-regex": "^2.0.0",
- "is-fullwidth-code-point": "^3.0.0"
- },
- "dependencies": {
- "ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "requires": {
- "color-convert": "^2.0.1"
- }
- }
- }
- },
- "source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true
- },
- "source-map-support": {
- "version": "0.5.19",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
- "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
- "dev": true,
- "requires": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- }
- },
- "spdx-correct": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
- "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
- "dev": true,
- "requires": {
- "spdx-expression-parse": "^3.0.0",
- "spdx-license-ids": "^3.0.0"
- }
- },
- "spdx-exceptions": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
- "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
- "dev": true
- },
- "spdx-expression-parse": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
- "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
- "dev": true,
- "requires": {
- "spdx-exceptions": "^2.1.0",
- "spdx-license-ids": "^3.0.0"
- }
- },
- "spdx-license-ids": {
- "version": "3.0.7",
- "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz",
- "integrity": "sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ==",
- "dev": true
- },
- "sprintf-js": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
- "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
- "dev": true
- },
- "stack-utils": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.3.tgz",
- "integrity": "sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw==",
- "dev": true,
- "requires": {
- "escape-string-regexp": "^2.0.0"
- },
- "dependencies": {
- "escape-string-regexp": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
- "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
- "dev": true
- }
- }
- },
- "string-width": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
- "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
- "dev": true,
- "requires": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.0"
- }
- },
- "string_decoder": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
- "dev": true,
- "requires": {
- "safe-buffer": "~5.2.0"
- },
- "dependencies": {
- "safe-buffer": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true
- }
- }
- },
- "strip-ansi": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
- "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
- "dev": true,
- "requires": {
- "ansi-regex": "^5.0.0"
- }
- },
- "strip-bom": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
- "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
- "dev": true
- },
- "strip-json-comments": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
- "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
- "dev": true
- },
- "supertap": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/supertap/-/supertap-2.0.0.tgz",
- "integrity": "sha512-jRzcXlCeDYvKoZGA5oRhYyR3jUIYu0enkSxtmAgHRlD7HwrovTpH4bDSi0py9FtuA8si9cW/fKommJHuaoDHJA==",
- "dev": true,
- "requires": {
- "arrify": "^2.0.1",
- "indent-string": "^4.0.0",
- "js-yaml": "^3.14.0",
- "serialize-error": "^7.0.1",
- "strip-ansi": "^6.0.0"
- }
- },
- "supports-color": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
- "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
- "dev": true,
- "requires": {
- "has-flag": "^4.0.0"
- }
- },
- "temp-dir": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
- "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==",
- "dev": true
- },
- "time-zone": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz",
- "integrity": "sha1-mcW/VZWJZq9tBtg73zgA3IL67F0=",
- "dev": true
- },
- "to-readable-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz",
- "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==",
- "dev": true
- },
- "to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "requires": {
- "is-number": "^7.0.0"
- }
- },
- "trim-off-newlines": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz",
- "integrity": "sha1-n5up2e+odkw4dpi8v+sshI8RrbM=",
- "dev": true
- },
- "type-fest": {
- "version": "0.3.1",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz",
- "integrity": "sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==",
- "dev": true
- },
- "typedarray-to-buffer": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
- "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
- "dev": true,
- "requires": {
- "is-typedarray": "^1.0.0"
- }
- },
- "unique-string": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
- "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==",
- "dev": true,
- "requires": {
- "crypto-random-string": "^2.0.0"
- }
- },
- "update-notifier": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-5.1.0.tgz",
- "integrity": "sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==",
- "dev": true,
- "requires": {
- "boxen": "^5.0.0",
- "chalk": "^4.1.0",
- "configstore": "^5.0.1",
- "has-yarn": "^2.1.0",
- "import-lazy": "^2.1.0",
- "is-ci": "^2.0.0",
- "is-installed-globally": "^0.4.0",
- "is-npm": "^5.0.0",
- "is-yarn-global": "^0.3.0",
- "latest-version": "^5.1.0",
- "pupa": "^2.1.1",
- "semver": "^7.3.4",
- "semver-diff": "^3.1.1",
- "xdg-basedir": "^4.0.0"
- }
- },
- "url-parse-lax": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
- "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=",
- "dev": true,
- "requires": {
- "prepend-http": "^2.0.0"
- }
- },
- "util-deprecate": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
- "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
- "dev": true
- },
- "validate-npm-package-license": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
- "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
- "dev": true,
- "requires": {
- "spdx-correct": "^3.0.0",
- "spdx-expression-parse": "^3.0.0"
- }
- },
- "wcwidth": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
- "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=",
- "dev": true,
- "requires": {
- "defaults": "^1.0.3"
- }
- },
- "well-known-symbols": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz",
- "integrity": "sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==",
- "dev": true
- },
- "widest-line": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz",
- "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==",
- "dev": true,
- "requires": {
- "string-width": "^4.0.0"
- }
- },
- "wrap-ansi": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dev": true,
- "requires": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "dependencies": {
- "ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "requires": {
- "color-convert": "^2.0.1"
- }
- }
- }
- },
- "wrappy": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
- "dev": true
- },
- "write-file-atomic": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
- "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
- "dev": true,
- "requires": {
- "imurmurhash": "^0.1.4",
- "is-typedarray": "^1.0.0",
- "signal-exit": "^3.0.2",
- "typedarray-to-buffer": "^3.1.5"
- }
- },
- "xdg-basedir": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz",
- "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==",
- "dev": true
- },
- "y18n": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz",
- "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==",
- "dev": true
- },
- "yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "dev": true
- },
- "yargs": {
- "version": "16.2.0",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
- "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
- "dev": true,
- "requires": {
- "cliui": "^7.0.2",
- "escalade": "^3.1.1",
- "get-caller-file": "^2.0.5",
- "require-directory": "^2.1.1",
- "string-width": "^4.2.0",
- "y18n": "^5.0.5",
- "yargs-parser": "^20.2.2"
- }
- },
- "yargs-parser": {
- "version": "20.2.6",
- "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.6.tgz",
- "integrity": "sha512-AP1+fQIWSM/sMiET8fyayjx/J+JmTPt2Mr0FkrgqB4todtfa53sOsrSAcIrJRD5XS20bKUwaDIuMkWKCEiQLKA==",
- "dev": true
- }
- }
- },
- "esm": {
- "version": "3.2.25",
- "dev": true
- },
- "prettier": {
- "version": "2.2.1",
- "dev": true
- },
- "rimraf": {
- "version": "3.0.2",
- "dev": true,
- "requires": {
- "glob": "^7.1.3"
- },
- "dependencies": {
- "balanced-match": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
- "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
- "dev": true
- },
- "brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "requires": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
- "dev": true
- },
- "fs.realpath": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
- "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
- "dev": true
- },
- "glob": {
- "version": "7.1.6",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
- "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
- "dev": true,
- "requires": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.0.4",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- }
- },
- "inflight": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
- "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
- "dev": true,
- "requires": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
- "inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true
- },
- "minimatch": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
- "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
- "dev": true,
- "requires": {
- "brace-expansion": "^1.1.7"
- }
- },
- "once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
- "dev": true,
- "requires": {
- "wrappy": "1"
- }
- },
- "path-is-absolute": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
- "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
- "dev": true
- },
- "wrappy": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
- "dev": true
- }
- }
- },
- "rollup": {
- "version": "2.37.1",
- "dev": true,
- "requires": {
- "fsevents": "~2.1.2"
- },
- "dependencies": {
- "fsevents": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
- "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
- "dev": true,
- "optional": true
- }
- }
- },
- "tslib": {
- "version": "2.1.0"
- },
- "typescript": {
- "version": "4.1.3",
- "dev": true
- },
- "typeson": {
- "version": "5.18.2"
- },
- "typeson-registry": {
- "version": "1.0.0-alpha.38",
- "requires": {
- "base64-arraybuffer-es6": "^0.6.0",
- "typeson": "^5.18.2",
- "whatwg-url": "^8.1.0"
- },
- "dependencies": {
- "base64-arraybuffer-es6": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.6.0.tgz",
- "integrity": "sha512-57nLqKj4ShsDwFJWJsM4sZx6u60WbCge35rWRSevUwqxDtRwwxiKAO800zD2upPv4CfdWjQp//wSLar35nDKvA=="
- },
- "lodash.sortby": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
- "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg="
- },
- "punycode": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
- "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
- },
- "tr46": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.0.2.tgz",
- "integrity": "sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==",
- "requires": {
- "punycode": "^2.1.1"
- }
- },
- "typeson": {
- "version": "5.18.2",
- "resolved": "https://registry.npmjs.org/typeson/-/typeson-5.18.2.tgz",
- "integrity": "sha512-Vetd+OGX05P4qHyHiSLdHZ5Z5GuQDrHHwSdjkqho9NSCYVSLSfRMjklD/unpHH8tXBR9Z/R05rwJSuMpMFrdsw=="
- },
- "webidl-conversions": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
- "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w=="
- },
- "whatwg-url": {
- "version": "8.4.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.4.0.tgz",
- "integrity": "sha512-vwTUFf6V4zhcPkWp/4CQPr1TW9Ml6SF4lVyaIMBdJw5i6qUUJ1QWM4Z6YYVkfka0OUIzVo/0aNtGVGk256IKWw==",
- "requires": {
- "lodash.sortby": "^4.7.0",
- "tr46": "^2.0.2",
- "webidl-conversions": "^6.1.0"
- }
- }
- }
- }
- }
-}
diff --git a/packages/idb-bridge/package.json b/packages/idb-bridge/package.json
index 52bc872da..15b3dbaba 100644
--- a/packages/idb-bridge/package.json
+++ b/packages/idb-bridge/package.json
@@ -1,38 +1,43 @@
{
"name": "@gnu-taler/idb-bridge",
- "version": "0.0.16",
+ "version": "0.10.6",
"description": "IndexedDB implementation that uses SQLite3 as storage",
"main": "./dist/idb-bridge.js",
"module": "./lib/index.js",
+ "type": "module",
"types": "./lib/index.d.ts",
"author": "Florian Dold",
"license": "AGPL-3.0-or-later",
"private": false,
"scripts": {
"test": "tsc && ava",
- "prepare": "tsc && rollup -c",
- "compile": "tsc && rollup -c",
- "clean": "rimraf dist lib tsconfig.tsbuildinfo",
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "compile": "tsc",
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo",
"pretty": "prettier --write src"
},
+ "exports": {
+ ".": {
+ "default": "./lib/index.js"
+ },
+ "./node-sqlite3-bindings": {
+ "default": "./lib/node-sqlite3-impl.js"
+ }
+ },
"devDependencies": {
- "@rollup/plugin-commonjs": "^17.1.0",
- "@rollup/plugin-json": "^4.1.0",
- "@rollup/plugin-node-resolve": "^11.2.0",
- "@types/node": "^14.14.22",
- "ava": "^3.15.0",
- "esm": "^3.2.25",
- "prettier": "^2.2.1",
- "rimraf": "^3.0.2",
- "rollup": "^2.37.1",
- "typescript": "^4.1.3"
+ "@types/better-sqlite3": "^7.6.8",
+ "@types/node": "^20.4.1",
+ "ava": "^6.0.1",
+ "prettier": "^3.1.1",
+ "typescript": "^5.3.3"
},
"dependencies": {
- "tslib": "^2.1.0"
+ "tslib": "^2.6.2"
},
"ava": {
- "require": [
- "esm"
- ]
+ "failFast": true
+ },
+ "optionalDependencies": {
+ "better-sqlite3": "9.4.0"
}
}
diff --git a/packages/idb-bridge/rollup.config.js b/packages/idb-bridge/rollup.config.js
deleted file mode 100644
index 76214f22d..000000000
--- a/packages/idb-bridge/rollup.config.js
+++ /dev/null
@@ -1,31 +0,0 @@
-// rollup.config.js
-import commonjs from "@rollup/plugin-commonjs";
-import nodeResolve from "@rollup/plugin-node-resolve";
-import json from "@rollup/plugin-json";
-import builtins from "builtin-modules";
-import pkg from "./package.json";
-
-export default {
- input: "lib/index.js",
- output: {
- file: pkg.main,
- format: "cjs",
- sourcemap: true
- },
- external: builtins,
- plugins: [
- nodeResolve({
- preferBuiltins: true,
- }),
-
- commonjs({
- include: [/node_modules/],
- extensions: [".js", ".ts"],
- ignoreGlobal: false,
- sourceMap: false,
- }),
-
- json(),
- ],
-}
-
diff --git a/packages/idb-bridge/src/MemoryBackend.test.ts b/packages/idb-bridge/src/MemoryBackend.test.ts
index 292f1b495..a851309ed 100644
--- a/packages/idb-bridge/src/MemoryBackend.test.ts
+++ b/packages/idb-bridge/src/MemoryBackend.test.ts
@@ -15,296 +15,9 @@
*/
import test from "ava";
-import {
- BridgeIDBCursorWithValue,
- BridgeIDBDatabase,
- BridgeIDBFactory,
- BridgeIDBKeyRange,
- BridgeIDBRequest,
- BridgeIDBTransaction,
-} from "./bridge-idb";
-import { MemoryBackend } from "./MemoryBackend";
-
-function promiseFromRequest(request: BridgeIDBRequest): Promise<any> {
- return new Promise((resolve, reject) => {
- request.onsuccess = () => {
- resolve(request.result);
- };
- request.onerror = () => {
- reject(request.error);
- };
- });
-}
-
-function promiseFromTransaction(
- transaction: BridgeIDBTransaction,
-): Promise<void> {
- return new Promise<void>((resolve, reject) => {
- transaction.oncomplete = () => {
- resolve();
- };
- transaction.onerror = () => {
- reject();
- };
- });
-}
-
-test("Spec: Example 1 Part 1", async (t) => {
- const backend = new MemoryBackend();
- const idb = new BridgeIDBFactory(backend);
-
- const request = idb.open("library");
- request.onupgradeneeded = () => {
- const db = request.result;
- const store = db.createObjectStore("books", { keyPath: "isbn" });
- const titleIndex = store.createIndex("by_title", "title", { unique: true });
- const authorIndex = store.createIndex("by_author", "author");
-
- // Populate with initial data.
- store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
- store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
- store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
- };
-
- await promiseFromRequest(request);
- t.pass();
-});
-
-test("Spec: Example 1 Part 2", async (t) => {
- const backend = new MemoryBackend();
- const idb = new BridgeIDBFactory(backend);
-
- const request = idb.open("library");
- request.onupgradeneeded = () => {
- const db = request.result;
- const store = db.createObjectStore("books", { keyPath: "isbn" });
- const titleIndex = store.createIndex("by_title", "title", { unique: true });
- const authorIndex = store.createIndex("by_author", "author");
- };
-
- const db: BridgeIDBDatabase = await promiseFromRequest(request);
-
- t.is(db.name, "library");
-
- const tx = db.transaction("books", "readwrite");
- tx.oncomplete = () => {
- console.log("oncomplete called");
- };
-
- const store = tx.objectStore("books");
-
- store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
- store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
- store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
-
- await promiseFromTransaction(tx);
-
- t.pass();
-});
-
-test("Spec: Example 1 Part 3", async (t) => {
- const backend = new MemoryBackend();
- const idb = new BridgeIDBFactory(backend);
-
- const request = idb.open("library");
- request.onupgradeneeded = () => {
- const db = request.result;
- const store = db.createObjectStore("books", { keyPath: "isbn" });
- const titleIndex = store.createIndex("by_title", "title", { unique: true });
- const authorIndex = store.createIndex("by_author", "author");
- };
-
- const db: BridgeIDBDatabase = await promiseFromRequest(request);
-
- t.is(db.name, "library");
-
- const tx = db.transaction("books", "readwrite");
-
- const store = tx.objectStore("books");
-
- store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
- store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
- store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
-
- await promiseFromTransaction(tx);
-
- const tx2 = db.transaction("books", "readonly");
- const store2 = tx2.objectStore("books");
- var index2 = store2.index("by_title");
- const request2 = index2.get("Bedrock Nights");
- const result2: any = await promiseFromRequest(request2);
-
- t.is(result2.author, "Barney");
-
- const tx3 = db.transaction(["books"], "readonly");
- const store3 = tx3.objectStore("books");
- const index3 = store3.index("by_author");
- const request3 = index3.openCursor(BridgeIDBKeyRange.only("Fred"));
-
- await promiseFromRequest(request3);
-
- let cursor: BridgeIDBCursorWithValue;
- cursor = request3.result as BridgeIDBCursorWithValue;
- t.is(cursor.value.author, "Fred");
- t.is(cursor.value.isbn, 123456);
-
- cursor.continue();
-
- await promiseFromRequest(request3);
-
- cursor = request3.result as BridgeIDBCursorWithValue;
- t.is(cursor.value.author, "Fred");
- t.is(cursor.value.isbn, 234567);
-
- await promiseFromTransaction(tx3);
-
- const tx4 = db.transaction("books", "readonly");
- const store4 = tx4.objectStore("books");
- const request4 = store4.openCursor();
-
- await promiseFromRequest(request4);
-
- cursor = request4.result;
- t.is(cursor.value.isbn, 123456);
-
- cursor.continue();
-
- await promiseFromRequest(request4);
-
- cursor = request4.result;
- t.is(cursor.value.isbn, 234567);
-
- cursor.continue();
-
- await promiseFromRequest(request4);
-
- cursor = request4.result;
- t.is(cursor.value.isbn, 345678);
-
- cursor.continue();
- await promiseFromRequest(request4);
-
- cursor = request4.result;
-
- t.is(cursor, null);
-
- const tx5 = db.transaction("books", "readonly");
- const store5 = tx5.objectStore("books");
- const index5 = store5.index("by_author");
-
- const request5 = index5.openCursor(null, "next");
-
- await promiseFromRequest(request5);
- cursor = request5.result;
- t.is(cursor.value.author, "Barney");
- cursor.continue();
-
- await promiseFromRequest(request5);
- cursor = request5.result;
- t.is(cursor.value.author, "Fred");
- cursor.continue();
-
- await promiseFromRequest(request5);
- cursor = request5.result;
- t.is(cursor.value.author, "Fred");
- cursor.continue();
-
- await promiseFromRequest(request5);
- cursor = request5.result;
- t.is(cursor, null);
-
- const request6 = index5.openCursor(null, "nextunique");
-
- await promiseFromRequest(request6);
- cursor = request6.result;
- t.is(cursor.value.author, "Barney");
- cursor.continue();
-
- await promiseFromRequest(request6);
- cursor = request6.result;
- t.is(cursor.value.author, "Fred");
- t.is(cursor.value.isbn, 123456);
- cursor.continue();
-
- await promiseFromRequest(request6);
- cursor = request6.result;
- t.is(cursor, null);
-
- const request7 = index5.openCursor(null, "prevunique");
- await promiseFromRequest(request7);
- cursor = request7.result;
- t.is(cursor.value.author, "Fred");
- t.is(cursor.value.isbn, 123456);
- cursor.continue();
-
- await promiseFromRequest(request7);
- cursor = request7.result;
- t.is(cursor.value.author, "Barney");
- cursor.continue();
-
- await promiseFromRequest(request7);
- cursor = request7.result;
- t.is(cursor, null);
-
- db.close();
-
- t.pass();
-});
-
-test("simple deletion", async (t) => {
- const backend = new MemoryBackend();
- const idb = new BridgeIDBFactory(backend);
-
- const request = idb.open("library");
- request.onupgradeneeded = () => {
- const db = request.result;
- const store = db.createObjectStore("books", { keyPath: "isbn" });
- const titleIndex = store.createIndex("by_title", "title", { unique: true });
- const authorIndex = store.createIndex("by_author", "author");
- };
-
- const db: BridgeIDBDatabase = await promiseFromRequest(request);
-
- t.is(db.name, "library");
-
- const tx = db.transaction("books", "readwrite");
- tx.oncomplete = () => {
- console.log("oncomplete called");
- };
-
- const store = tx.objectStore("books");
-
- store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
- store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
- store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
-
- await promiseFromTransaction(tx);
-
- const tx2 = db.transaction("books", "readwrite");
-
- const store2 = tx2.objectStore("books");
-
- const req1 = store2.get(234567);
- await promiseFromRequest(req1);
- t.is(req1.readyState, "done");
- t.is(req1.result.author, "Fred");
-
- store2.delete(123456);
-
- const req2 = store2.get(123456);
- await promiseFromRequest(req2);
- t.is(req2.readyState, "done");
- t.is(req2.result, undefined);
-
- const req3 = store2.get(234567);
- await promiseFromRequest(req3);
- t.is(req3.readyState, "done");
- t.is(req3.result.author, "Fred");
-
- await promiseFromTransaction(tx2);
-
- t.pass();
-});
+import { MemoryBackend } from "./MemoryBackend.js";
+import { BridgeIDBDatabase, BridgeIDBFactory } from "./bridge-idb.js";
+import { promiseFromRequest, promiseFromTransaction } from "./idbpromutil.js";
test("export", async (t) => {
const backend = new MemoryBackend();
diff --git a/packages/idb-bridge/src/MemoryBackend.ts b/packages/idb-bridge/src/MemoryBackend.ts
index 9233e8d88..526920a9f 100644
--- a/packages/idb-bridge/src/MemoryBackend.ts
+++ b/packages/idb-bridge/src/MemoryBackend.ts
@@ -14,43 +14,38 @@
permissions and limitations under the License.
*/
+import { AsyncCondition, TransactionLevel } from "./backend-common.js";
import {
Backend,
+ ConnectResult,
DatabaseConnection,
DatabaseTransaction,
- Schema,
- RecordStoreRequest,
- IndexProperties,
- RecordGetRequest,
+ IndexGetQuery,
+ IndexMeta,
+ ObjectStoreGetQuery,
+ ObjectStoreMeta,
RecordGetResponse,
+ RecordStoreRequest,
+ RecordStoreResponse,
ResultLevel,
StoreLevel,
- RecordStoreResponse,
-} from "./backend-interface";
+} from "./backend-interface.js";
+import { BridgeIDBKeyRange } from "./bridge-idb.js";
+import { IDBKeyRange, IDBTransactionMode, IDBValidKey } from "./idbtypes.js";
+import BTree, { ISortedMapF, ISortedSetF } from "./tree/b+tree.js";
+import { compareKeys } from "./util/cmp.js";
+import { ConstraintError, DataError } from "./util/errors.js";
+import { getIndexKeys } from "./util/getIndexKeys.js";
+import { StoreKeyResult, makeStoreKeyValue } from "./util/makeStoreKeyValue.js";
import {
structuredClone,
structuredEncapsulate,
structuredRevive,
-} from "./util/structuredClone";
-import { ConstraintError, DataError } from "./util/errors";
-import BTree, { ISortedMapF } from "./tree/b+tree";
-import { compareKeys } from "./util/cmp";
-import { StoreKeyResult, makeStoreKeyValue } from "./util/makeStoreKeyValue";
-import { getIndexKeys } from "./util/getIndexKeys";
-import { openPromise } from "./util/openPromise";
-import { IDBKeyRange, IDBTransactionMode, IDBValidKey } from "./idbtypes";
-import { BridgeIDBKeyRange } from "./bridge-idb";
+} from "./util/structuredClone.js";
type Key = IDBValidKey;
type Value = unknown;
-enum TransactionLevel {
- None = 0,
- Read = 1,
- Write = 2,
- VersionChange = 3,
-}
-
interface ObjectStore {
originalName: string;
modifiedName: string | undefined;
@@ -95,31 +90,39 @@ interface Database {
connectionCookies: string[];
}
-/** @public */
-export interface IndexDump {
- name: string;
- records: IndexRecord[];
-}
-
-/** @public */
export interface ObjectStoreDump {
name: string;
keyGenerator: number;
records: ObjectStoreRecord[];
- indexes: { [name: string]: IndexDump };
}
-/** @public */
export interface DatabaseDump {
schema: Schema;
objectStores: { [name: string]: ObjectStoreDump };
}
-/** @public */
export interface MemoryBackendDump {
databases: { [name: string]: DatabaseDump };
}
+export interface ObjectStoreProperties {
+ keyPath: string | string[] | null;
+ autoIncrement: boolean;
+ indexes: { [nameame: string]: IndexProperties };
+}
+
+export interface IndexProperties {
+ keyPath: string | string[];
+ multiEntry: boolean;
+ unique: boolean;
+}
+
+export interface Schema {
+ databaseName: string;
+ databaseVersion: number;
+ objectStores: { [name: string]: ObjectStoreProperties };
+}
+
interface ObjectStoreMapEntry {
store: ObjectStore;
indexMap: { [currentName: string]: Index };
@@ -140,7 +143,7 @@ interface Connection {
/** @public */
export interface IndexRecord {
indexKey: Key;
- primaryKeys: Key[];
+ primaryKeys: ISortedSetF<Key>;
}
/** @public */
@@ -149,27 +152,6 @@ export interface ObjectStoreRecord {
value: Value;
}
-class AsyncCondition {
- _waitPromise: Promise<void>;
- _resolveWaitPromise: () => void;
- constructor() {
- const op = openPromise<void>();
- this._waitPromise = op.promise;
- this._resolveWaitPromise = op.resolve;
- }
-
- wait(): Promise<void> {
- return this._waitPromise;
- }
-
- trigger(): void {
- this._resolveWaitPromise();
- const op = openPromise<void>();
- this._waitPromise = op.promise;
- this._resolveWaitPromise = op.resolve;
- }
-}
-
function nextStoreKey<T>(
forward: boolean,
data: ISortedMapF<Key, ObjectStoreRecord>,
@@ -185,6 +167,21 @@ function nextStoreKey<T>(
return res[1].primaryKey;
}
+function nextKey(
+ forward: boolean,
+ tree: ISortedSetF<IDBValidKey>,
+ key: IDBValidKey | undefined,
+): IDBValidKey | undefined {
+ if (key != null) {
+ return forward ? tree.nextHigherKey(key) : tree.nextLowerKey(key);
+ }
+ return forward ? tree.minKey() : tree.maxKey();
+}
+
+/**
+ * Return the key that is furthest in
+ * the direction indicated by the 'forward' flag.
+ */
function furthestKey(
forward: boolean,
key1: Key | undefined,
@@ -215,6 +212,17 @@ function furthestKey(
}
}
+export interface AccessStats {
+ primitiveStatements: number;
+ writeTransactions: number;
+ readTransactions: number;
+ writesPerStore: Record<string, number>;
+ readsPerStore: Record<string, number>;
+ readsPerIndex: Record<string, number>;
+ readItemsPerIndex: Record<string, number>;
+ readItemsPerStore: Record<string, number>;
+}
+
/**
* Primitive in-memory backend.
*
@@ -252,28 +260,39 @@ export class MemoryBackend implements Backend {
enableTracing: boolean = false;
+ trackStats: boolean = true;
+
+ accessStats: AccessStats = {
+ primitiveStatements: 0,
+ readTransactions: 0,
+ writeTransactions: 0,
+ readsPerStore: {},
+ readsPerIndex: {},
+ readItemsPerIndex: {},
+ readItemsPerStore: {},
+ writesPerStore: {},
+ };
+
/**
* Load the data in this IndexedDB backend from a dump in JSON format.
*
* Must be called before any connections to the database backend have
* been made.
*/
- importDump(data: any) {
- if (this.enableTracing) {
- console.log("importing dump (a)");
- }
+ importDump(dataJson: any) {
if (this.transactionIdCounter != 1 || this.connectionIdCounter != 1) {
throw Error(
"data must be imported before first transaction or connection",
);
}
+ // FIXME: validate!
+ const data = structuredRevive(dataJson) as MemoryBackendDump;
+
if (typeof data !== "object") {
throw Error("db dump corrupt");
}
- data = structuredRevive(data);
-
this.databases = {};
for (const dbName of Object.keys(data.databases)) {
@@ -285,29 +304,10 @@ export class MemoryBackend implements Backend {
for (const objectStoreName of Object.keys(
data.databases[dbName].objectStores,
)) {
- const dumpedObjectStore =
+ const storeSchema = schema.objectStores[objectStoreName];
+ const dumpedObjectStore: ObjectStoreDump =
data.databases[dbName].objectStores[objectStoreName];
- const indexes: { [name: string]: Index } = {};
- for (const indexName of Object.keys(dumpedObjectStore.indexes)) {
- const dumpedIndex = dumpedObjectStore.indexes[indexName];
- const pairs = dumpedIndex.records.map((r: any) => {
- return structuredClone([r.indexKey, r]);
- });
- const indexData: ISortedMapF<Key, IndexRecord> = new BTree(
- pairs,
- compareKeys,
- );
- const index: Index = {
- deleted: false,
- modifiedData: undefined,
- modifiedName: undefined,
- originalName: indexName,
- originalData: indexData,
- };
- indexes[indexName] = index;
- }
-
const pairs = dumpedObjectStore.records.map((r: any) => {
return structuredClone([r.primaryKey, r]);
});
@@ -323,10 +323,33 @@ export class MemoryBackend implements Backend {
originalData: objectStoreData,
originalName: objectStoreName,
originalKeyGenerator: dumpedObjectStore.keyGenerator,
- committedIndexes: indexes,
+ committedIndexes: {},
modifiedIndexes: {},
};
objectStores[objectStoreName] = objectStore;
+
+ for (const indexName in storeSchema.indexes) {
+ const indexSchema = storeSchema.indexes[indexName];
+ const newIndex: Index = {
+ deleted: false,
+ modifiedData: undefined,
+ modifiedName: undefined,
+ originalData: new BTree([], compareKeys),
+ originalName: indexName,
+ };
+ objectStore.committedIndexes[indexName] = newIndex;
+ objectStoreData.forEach((v, k) => {
+ try {
+ this.insertIntoIndex(newIndex, k, v.value, indexSchema);
+ } catch (e) {
+ if (e instanceof DataError) {
+ // We don't propagate this error here.
+ return;
+ }
+ throw e;
+ }
+ });
+ }
}
const db: Database = {
deleted: false,
@@ -340,9 +363,9 @@ export class MemoryBackend implements Backend {
}
}
- private makeObjectStoreMap(
- database: Database,
- ): { [currentName: string]: ObjectStoreMapEntry } {
+ private makeObjectStoreMap(database: Database): {
+ [currentName: string]: ObjectStoreMapEntry;
+ } {
let map: { [currentName: string]: ObjectStoreMapEntry } = {};
for (let objectStoreName in database.committedObjectStores) {
const store = database.committedObjectStores[objectStoreName];
@@ -368,16 +391,6 @@ export class MemoryBackend implements Backend {
const objectStores: { [name: string]: ObjectStoreDump } = {};
for (const objectStoreName of Object.keys(db.committedObjectStores)) {
const objectStore = db.committedObjectStores[objectStoreName];
-
- const indexes: { [name: string]: IndexDump } = {};
- for (const indexName of Object.keys(objectStore.committedIndexes)) {
- const index = objectStore.committedIndexes[indexName];
- const indexRecords: IndexRecord[] = [];
- index.originalData.forEach((v: IndexRecord) => {
- indexRecords.push(structuredClone(v));
- });
- indexes[indexName] = { name: indexName, records: indexRecords };
- }
const objectStoreRecords: ObjectStoreRecord[] = [];
objectStore.originalData.forEach((v: ObjectStoreRecord) => {
objectStoreRecords.push(structuredClone(v));
@@ -386,7 +399,6 @@ export class MemoryBackend implements Backend {
name: objectStoreName,
records: objectStoreRecords,
keyGenerator: objectStore.originalKeyGenerator,
- indexes: indexes,
};
}
const dbDump: DatabaseDump = {
@@ -432,7 +444,7 @@ export class MemoryBackend implements Backend {
delete this.databases[name];
}
- async connectDatabase(name: string): Promise<DatabaseConnection> {
+ async connectDatabase(name: string): Promise<ConnectResult> {
if (this.enableTracing) {
console.log(`TRACING: connectDatabase(${name})`);
}
@@ -471,7 +483,11 @@ export class MemoryBackend implements Backend {
this.connections[connectionCookie] = myConn;
- return { connectionCookie };
+ return {
+ conn: { connectionCookie },
+ version: database.committedSchema.databaseVersion,
+ objectStores: Object.keys(database.committedSchema.objectStores).sort(),
+ };
}
async beginTransaction(
@@ -507,6 +523,14 @@ export class MemoryBackend implements Backend {
throw Error("unsupported transaction mode");
}
+ if (this.trackStats) {
+ if (mode === "readonly") {
+ this.accessStats.readTransactions++;
+ } else if (mode === "readwrite") {
+ this.accessStats.writeTransactions++;
+ }
+ }
+
myDb.txRestrictObjectStores = [...objectStores];
this.connectionsByTransaction[transactionCookie] = myConn;
@@ -556,22 +580,16 @@ export class MemoryBackend implements Backend {
throw Error("connection not found - already closed?");
}
const myDb = this.databases[myConn.dbName];
- // FIXME: what if we're still in a transaction?
- myDb.connectionCookies = myDb.connectionCookies.filter(
- (x) => x != conn.connectionCookie,
- );
+ if (myDb) {
+ // FIXME: what if we're still in a transaction?
+ myDb.connectionCookies = myDb.connectionCookies.filter(
+ (x) => x != conn.connectionCookie,
+ );
+ }
delete this.connections[conn.connectionCookie];
this.disconnectCond.trigger();
}
- private requireConnection(dbConn: DatabaseConnection): Connection {
- const myConn = this.connections[dbConn.connectionCookie];
- if (!myConn) {
- throw Error(`unknown connection (${dbConn.connectionCookie})`);
- }
- return myConn;
- }
-
private requireConnectionFromTransaction(
btx: DatabaseTransaction,
): Connection {
@@ -582,36 +600,6 @@ export class MemoryBackend implements Backend {
return myConn;
}
- getSchema(dbConn: DatabaseConnection): Schema {
- if (this.enableTracing) {
- console.log(`TRACING: getSchema`);
- }
- const myConn = this.requireConnection(dbConn);
- const db = this.databases[myConn.dbName];
- if (!db) {
- throw Error("db not found");
- }
- return db.committedSchema;
- }
-
- getCurrentTransactionSchema(btx: DatabaseTransaction): Schema {
- const myConn = this.requireConnectionFromTransaction(btx);
- const db = this.databases[myConn.dbName];
- if (!db) {
- throw Error("db not found");
- }
- return myConn.modifiedSchema;
- }
-
- getInitialTransactionSchema(btx: DatabaseTransaction): Schema {
- const myConn = this.requireConnectionFromTransaction(btx);
- const db = this.databases[myConn.dbName];
- if (!db) {
- throw Error("db not found");
- }
- return db.committedSchema;
- }
-
renameIndex(
btx: DatabaseTransaction,
objectStoreName: string,
@@ -762,7 +750,7 @@ export class MemoryBackend implements Backend {
createObjectStore(
btx: DatabaseTransaction,
name: string,
- keyPath: string[] | null,
+ keyPath: string | string[] | null,
autoIncrement: boolean,
): void {
if (this.enableTracing) {
@@ -805,7 +793,7 @@ export class MemoryBackend implements Backend {
btx: DatabaseTransaction,
indexName: string,
objectStoreName: string,
- keyPath: string[],
+ keyPath: string | string[],
multiEntry: boolean,
unique: boolean,
): void {
@@ -1047,17 +1035,16 @@ export class MemoryBackend implements Backend {
indexProperties.multiEntry,
);
for (const indexKey of indexKeys) {
- const existingRecord = indexData.get(indexKey);
- if (!existingRecord) {
+ const existingIndexRecord = indexData.get(indexKey);
+ if (!existingIndexRecord) {
throw Error("db inconsistent: expected index entry missing");
}
- const newPrimaryKeys = existingRecord.primaryKeys.filter(
- (x) => compareKeys(x, primaryKey) !== 0,
- );
- if (newPrimaryKeys.length === 0) {
+ const newPrimaryKeys =
+ existingIndexRecord.primaryKeys.without(primaryKey);
+ if (newPrimaryKeys.size === 0) {
index.modifiedData = indexData.without(indexKey);
} else {
- const newIndexRecord = {
+ const newIndexRecord: IndexRecord = {
indexKey,
primaryKeys: newPrimaryKeys,
};
@@ -1066,12 +1053,12 @@ export class MemoryBackend implements Backend {
}
}
- async getRecords(
+ async getObjectStoreRecords(
btx: DatabaseTransaction,
- req: RecordGetRequest,
+ req: ObjectStoreGetQuery,
): Promise<RecordGetResponse> {
if (this.enableTracing) {
- console.log(`TRACING: getRecords`);
+ console.log(`TRACING: getObjectStoreRecords`);
console.log("query", req);
}
const myConn = this.requireConnectionFromTransaction(btx);
@@ -1098,7 +1085,7 @@ export class MemoryBackend implements Backend {
}
let range;
- if (req.range == null || req.range === undefined) {
+ if (req.range == null) {
range = new BridgeIDBKeyRange(undefined, undefined, true, true);
} else {
range = req.range;
@@ -1106,306 +1093,131 @@ export class MemoryBackend implements Backend {
if (typeof range !== "object") {
throw Error(
- "getRecords was given an invalid range (sanity check failed, not an object)",
+ "getObjectStoreRecords was given an invalid range (sanity check failed, not an object)",
);
}
if (!("lowerOpen" in range)) {
throw Error(
- "getRecords was given an invalid range (sanity check failed, lowerOpen missing)",
+ "getObjectStoreRecords was given an invalid range (sanity check failed, lowerOpen missing)",
);
}
- let numResults = 0;
- let indexKeys: Key[] = [];
- let primaryKeys: Key[] = [];
- let values: Value[] = [];
-
const forward: boolean =
req.direction === "next" || req.direction === "nextunique";
- const unique: boolean =
- req.direction === "prevunique" || req.direction === "nextunique";
const storeData =
objectStoreMapEntry.store.modifiedData ||
objectStoreMapEntry.store.originalData;
- const haveIndex = req.indexName !== undefined;
-
- if (haveIndex) {
- const index =
- myConn.objectStoreMap[req.objectStoreName].indexMap[req.indexName!];
- const indexData = index.modifiedData || index.originalData;
- let indexPos = req.lastIndexPosition;
-
- if (indexPos === undefined) {
- // First time we iterate! So start at the beginning (lower/upper)
- // of our allowed range.
- indexPos = forward ? range.lower : range.upper;
- }
-
- let primaryPos = req.lastObjectStorePosition;
-
- // We might have to advance the index key further!
- if (req.advanceIndexKey !== undefined) {
- const compareResult = compareKeys(req.advanceIndexKey, indexPos);
- if ((forward && compareResult > 0) || (!forward && compareResult > 0)) {
- indexPos = req.advanceIndexKey;
- } else if (compareResult == 0 && req.advancePrimaryKey !== undefined) {
- // index keys are the same, so advance the primary key
- if (primaryPos === undefined) {
- primaryPos = req.advancePrimaryKey;
- } else {
- const primCompareResult = compareKeys(
- req.advancePrimaryKey,
- primaryPos,
- );
- if (
- (forward && primCompareResult > 0) ||
- (!forward && primCompareResult < 0)
- ) {
- primaryPos = req.advancePrimaryKey;
- }
- }
- }
- }
-
- if (indexPos === undefined || indexPos === null) {
- indexPos = forward ? indexData.minKey() : indexData.maxKey();
- }
-
- if (indexPos === undefined) {
- throw Error("invariant violated");
- }
-
- let indexEntry: IndexRecord | undefined;
- indexEntry = indexData.get(indexPos);
- if (!indexEntry) {
- const res = forward
- ? indexData.nextHigherPair(indexPos)
- : indexData.nextLowerPair(indexPos);
- if (res) {
- indexEntry = res[1];
- indexPos = indexEntry.indexKey;
- }
- }
-
- if (unique) {
- while (1) {
- if (req.limit != 0 && numResults == req.limit) {
- break;
- }
- if (indexPos === undefined) {
- break;
- }
- if (!range.includes(indexPos)) {
- break;
- }
- if (indexEntry === undefined) {
- break;
- }
-
- if (
- req.lastIndexPosition === null ||
- req.lastIndexPosition === undefined ||
- compareKeys(indexEntry.indexKey, req.lastIndexPosition) !== 0
- ) {
- indexKeys.push(indexEntry.indexKey);
- primaryKeys.push(indexEntry.primaryKeys[0]);
- numResults++;
- }
-
- const res: any = forward
- ? indexData.nextHigherPair(indexPos)
- : indexData.nextLowerPair(indexPos);
- if (res) {
- indexPos = res[1].indexKey;
- indexEntry = res[1] as IndexRecord;
- } else {
- break;
- }
- }
- } else {
- let primkeySubPos = 0;
-
- // Sort out the case where the index key is the same, so we have
- // to get the prev/next primary key
- if (
- indexEntry !== undefined &&
- req.lastIndexPosition !== undefined &&
- compareKeys(indexEntry.indexKey, req.lastIndexPosition) === 0
- ) {
- let pos = forward ? 0 : indexEntry.primaryKeys.length - 1;
- this.enableTracing &&
- console.log(
- "number of primary keys",
- indexEntry.primaryKeys.length,
- );
- this.enableTracing && console.log("start pos is", pos);
- // Advance past the lastObjectStorePosition
- do {
- const cmpResult = compareKeys(
- req.lastObjectStorePosition,
- indexEntry.primaryKeys[pos],
- );
- this.enableTracing && console.log("cmp result is", cmpResult);
- if ((forward && cmpResult < 0) || (!forward && cmpResult > 0)) {
- break;
- }
- pos += forward ? 1 : -1;
- this.enableTracing && console.log("now pos is", pos);
- } while (pos >= 0 && pos < indexEntry.primaryKeys.length);
-
- // Make sure we're at least at advancedPrimaryPos
- while (
- primaryPos !== undefined &&
- pos >= 0 &&
- pos < indexEntry.primaryKeys.length
- ) {
- const cmpResult = compareKeys(
- primaryPos,
- indexEntry.primaryKeys[pos],
- );
- if ((forward && cmpResult <= 0) || (!forward && cmpResult >= 0)) {
- break;
- }
- pos += forward ? 1 : -1;
- }
- primkeySubPos = pos;
- } else if (indexEntry !== undefined) {
- primkeySubPos = forward ? 0 : indexEntry.primaryKeys.length - 1;
- }
-
- if (this.enableTracing) {
- console.log("subPos=", primkeySubPos);
- console.log("indexPos=", indexPos);
- }
+ const resp = getObjectStoreRecords({
+ forward,
+ storeData,
+ limit: req.limit,
+ range,
+ resultLevel: req.resultLevel,
+ advancePrimaryKey: req.advancePrimaryKey,
+ lastObjectStorePosition: req.lastObjectStorePosition,
+ });
+ if (this.trackStats) {
+ const k = `${req.objectStoreName}`;
+ this.accessStats.readsPerStore[k] =
+ (this.accessStats.readsPerStore[k] ?? 0) + 1;
+ this.accessStats.readItemsPerStore[k] =
+ (this.accessStats.readItemsPerStore[k] ?? 0) + resp.count;
+ }
+ if (this.enableTracing) {
+ console.log(`TRACING: getRecords got ${resp.count} results`);
+ }
+ return resp;
+ }
- while (1) {
- if (req.limit != 0 && numResults == req.limit) {
- break;
- }
- if (indexPos === undefined) {
- break;
- }
- if (!range.includes(indexPos)) {
- break;
- }
- if (indexEntry === undefined) {
- break;
- }
- if (
- primkeySubPos < 0 ||
- primkeySubPos >= indexEntry.primaryKeys.length
- ) {
- const res: any = forward
- ? indexData.nextHigherPair(indexPos)
- : indexData.nextLowerPair(indexPos);
- if (res) {
- indexPos = res[1].indexKey;
- indexEntry = res[1];
- primkeySubPos = forward ? 0 : indexEntry!.primaryKeys.length - 1;
- continue;
- } else {
- break;
- }
- }
- indexKeys.push(indexEntry.indexKey);
- primaryKeys.push(indexEntry.primaryKeys[primkeySubPos]);
- numResults++;
- primkeySubPos += forward ? 1 : -1;
- }
- }
+ async getIndexRecords(
+ btx: DatabaseTransaction,
+ req: IndexGetQuery,
+ ): Promise<RecordGetResponse> {
+ if (this.enableTracing) {
+ console.log(`TRACING: getIndexRecords`);
+ console.log("query", req);
+ }
+ const myConn = this.requireConnectionFromTransaction(btx);
+ const db = this.databases[myConn.dbName];
+ if (!db) {
+ throw Error("db not found");
+ }
+ if (db.txLevel < TransactionLevel.Read) {
+ throw Error("only allowed while running a transaction");
+ }
+ if (
+ db.txRestrictObjectStores &&
+ !db.txRestrictObjectStores.includes(req.objectStoreName)
+ ) {
+ throw Error(
+ `Not allowed to access store '${
+ req.objectStoreName
+ }', transaction is over ${JSON.stringify(db.txRestrictObjectStores)}`,
+ );
+ }
+ const objectStoreMapEntry = myConn.objectStoreMap[req.objectStoreName];
+ if (!objectStoreMapEntry) {
+ throw Error("object store not found");
+ }
- // Now we can collect the values based on the primary keys,
- // if requested.
- if (req.resultLevel === ResultLevel.Full) {
- for (let i = 0; i < numResults; i++) {
- const result = storeData.get(primaryKeys[i]);
- if (!result) {
- console.error("invariant violated during read");
- console.error("request was", req);
- throw Error("invariant violated during read");
- }
- values.push(result.value);
- }
- }
+ let range;
+ if (req.range == null || req.range === undefined) {
+ range = new BridgeIDBKeyRange(undefined, undefined, true, true);
} else {
- // only based on object store, no index involved, phew!
- let storePos = req.lastObjectStorePosition;
- if (storePos === undefined) {
- storePos = forward ? range.lower : range.upper;
- }
-
- if (req.advanceIndexKey !== undefined) {
- throw Error("unsupported request");
- }
-
- storePos = furthestKey(forward, req.advancePrimaryKey, storePos);
-
- if (storePos !== null && storePos !== undefined) {
- // Advance store position if we are either still at the last returned
- // store key, or if we are currently not on a key.
- const storeEntry = storeData.get(storePos);
- if (this.enableTracing) {
- console.log("store entry:", storeEntry);
- }
- if (
- !storeEntry ||
- (req.lastObjectStorePosition !== undefined &&
- compareKeys(req.lastObjectStorePosition, storePos) === 0)
- ) {
- storePos = storeData.nextHigherKey(storePos);
- }
- } else {
- storePos = forward ? storeData.minKey() : storeData.maxKey();
- if (this.enableTracing) {
- console.log("setting starting store pos to", storePos);
- }
- }
-
- while (1) {
- if (req.limit != 0 && numResults == req.limit) {
- break;
- }
- if (storePos === null || storePos === undefined) {
- break;
- }
- if (!range.includes(storePos)) {
- break;
- }
+ range = req.range;
+ }
- const res = storeData.get(storePos);
+ if (typeof range !== "object") {
+ throw Error(
+ "getRecords was given an invalid range (sanity check failed, not an object)",
+ );
+ }
- if (res === undefined) {
- break;
- }
+ if (!("lowerOpen" in range)) {
+ throw Error(
+ "getRecords was given an invalid range (sanity check failed, lowerOpen missing)",
+ );
+ }
- if (req.resultLevel >= ResultLevel.OnlyKeys) {
- primaryKeys.push(structuredClone(storePos));
- }
+ const forward: boolean =
+ req.direction === "next" || req.direction === "nextunique";
+ const unique: boolean =
+ req.direction === "prevunique" || req.direction === "nextunique";
- if (req.resultLevel >= ResultLevel.Full) {
- values.push(structuredClone(res.value));
- }
+ const storeData =
+ objectStoreMapEntry.store.modifiedData ||
+ objectStoreMapEntry.store.originalData;
- numResults++;
- storePos = nextStoreKey(forward, storeData, storePos);
- }
+ const index =
+ myConn.objectStoreMap[req.objectStoreName].indexMap[req.indexName!];
+ const indexData = index.modifiedData || index.originalData;
+ const resp = getIndexRecords({
+ forward,
+ indexData,
+ storeData,
+ limit: req.limit,
+ unique,
+ range,
+ resultLevel: req.resultLevel,
+ advanceIndexKey: req.advanceIndexKey,
+ advancePrimaryKey: req.advancePrimaryKey,
+ lastIndexPosition: req.lastIndexPosition,
+ lastObjectStorePosition: req.lastObjectStorePosition,
+ });
+ if (this.trackStats) {
+ const k = `${req.objectStoreName}.${req.indexName}`;
+ this.accessStats.readsPerIndex[k] =
+ (this.accessStats.readsPerIndex[k] ?? 0) + 1;
+ this.accessStats.readItemsPerIndex[k] =
+ (this.accessStats.readItemsPerIndex[k] ?? 0) + resp.count;
}
if (this.enableTracing) {
- console.log(`TRACING: getRecords got ${numResults} results`);
+ console.log(`TRACING: getIndexRecords got ${resp.count} results`);
}
- return {
- count: numResults,
- indexKeys:
- req.resultLevel >= ResultLevel.OnlyKeys && haveIndex
- ? indexKeys
- : undefined,
- primaryKeys:
- req.resultLevel >= ResultLevel.OnlyKeys ? primaryKeys : undefined,
- values: req.resultLevel >= ResultLevel.Full ? values : undefined,
- };
+ return resp;
}
async storeRecord(
@@ -1414,6 +1226,11 @@ export class MemoryBackend implements Backend {
): Promise<RecordStoreResponse> {
if (this.enableTracing) {
console.log(`TRACING: storeRecord`);
+ console.log(
+ `key ${storeReq.key}, record ${JSON.stringify(
+ structuredEncapsulate(storeReq.value),
+ )}`,
+ );
}
const myConn = this.requireConnectionFromTransaction(btx);
const db = this.databases[myConn.dbName];
@@ -1433,6 +1250,12 @@ export class MemoryBackend implements Backend {
}', transaction is over ${JSON.stringify(db.txRestrictObjectStores)}`,
);
}
+
+ if (this.trackStats) {
+ this.accessStats.writesPerStore[storeReq.objectStoreName] =
+ (this.accessStats.writesPerStore[storeReq.objectStoreName] ?? 0) + 1;
+ }
+
const schema = myConn.modifiedSchema;
const objectStoreMapEntry = myConn.objectStoreMap[storeReq.objectStoreName];
@@ -1474,13 +1297,13 @@ export class MemoryBackend implements Backend {
let storeKeyResult: StoreKeyResult;
try {
- storeKeyResult = makeStoreKeyValue(
- storeReq.value,
- storeReq.key,
- keygen,
- autoIncrement,
- keyPath,
- );
+ storeKeyResult = makeStoreKeyValue({
+ value: storeReq.value,
+ key: storeReq.key,
+ currentKeyGenerator: keygen,
+ autoIncrement: autoIncrement,
+ keyPath: keyPath,
+ });
} catch (e) {
if (e instanceof DataError) {
const kp = JSON.stringify(keyPath);
@@ -1509,7 +1332,9 @@ export class MemoryBackend implements Backend {
}
}
- const objectStoreRecord: ObjectStoreRecord = {
+ const oldStoreRecord = modifiedData.get(key);
+
+ const newObjectStoreRecord: ObjectStoreRecord = {
// FIXME: We should serialize the key here, not just clone it.
primaryKey: structuredClone(key),
value: structuredClone(value),
@@ -1517,7 +1342,7 @@ export class MemoryBackend implements Backend {
objectStoreMapEntry.store.modifiedData = modifiedData.with(
key,
- objectStoreRecord,
+ newObjectStoreRecord,
true,
);
@@ -1531,6 +1356,24 @@ export class MemoryBackend implements Backend {
}
const indexProperties =
schema.objectStores[storeReq.objectStoreName].indexes[indexName];
+
+ // Remove old index entry first!
+ if (oldStoreRecord) {
+ try {
+ this.deleteFromIndex(
+ index,
+ key,
+ oldStoreRecord.value,
+ indexProperties,
+ );
+ } catch (e) {
+ if (e instanceof DataError) {
+ // Do nothing
+ } else {
+ throw e;
+ }
+ }
+ }
try {
this.insertIntoIndex(index, key, value, indexProperties);
} catch (e) {
@@ -1585,28 +1428,27 @@ export class MemoryBackend implements Backend {
if (indexProperties.unique) {
throw new ConstraintError();
} else {
- const pred = (x: Key) => compareKeys(x, primaryKey) === 0;
- if (existingRecord.primaryKeys.findIndex(pred) === -1) {
- const newIndexRecord = {
- indexKey: indexKey,
- primaryKeys: [...existingRecord.primaryKeys, primaryKey].sort(
- compareKeys,
- ),
- };
- index.modifiedData = indexData.with(indexKey, newIndexRecord, true);
- }
+ const newIndexRecord: IndexRecord = {
+ indexKey: indexKey,
+ primaryKeys: existingRecord.primaryKeys.with(primaryKey),
+ };
+ index.modifiedData = indexData.with(indexKey, newIndexRecord, true);
}
} else {
+ const primaryKeys: ISortedSetF<IDBValidKey> = new BTree(
+ [[primaryKey, undefined]],
+ compareKeys,
+ );
const newIndexRecord: IndexRecord = {
indexKey: indexKey,
- primaryKeys: [primaryKey],
+ primaryKeys,
};
index.modifiedData = indexData.with(indexKey, newIndexRecord, true);
}
}
}
- async rollback(btx: DatabaseTransaction): Promise<void> {
+ rollback(btx: DatabaseTransaction): void {
if (this.enableTracing) {
console.log(`TRACING: rollback`);
}
@@ -1697,4 +1539,349 @@ export class MemoryBackend implements Backend {
await this.afterCommitCallback();
}
}
+
+ getObjectStoreMeta(
+ dbConn: DatabaseConnection,
+ objectStoreName: string,
+ ): ObjectStoreMeta | undefined {
+ const conn = this.connections[dbConn.connectionCookie];
+ if (!conn) {
+ throw Error("db connection not found");
+ }
+ let schema = conn.modifiedSchema;
+ if (!schema) {
+ throw Error();
+ }
+ const storeInfo = schema.objectStores[objectStoreName];
+ if (!storeInfo) {
+ return undefined;
+ }
+ return {
+ autoIncrement: storeInfo.autoIncrement,
+ indexSet: Object.keys(storeInfo.indexes).sort(),
+ keyPath: structuredClone(storeInfo.keyPath),
+ };
+ }
+
+ getIndexMeta(
+ dbConn: DatabaseConnection,
+ objectStoreName: string,
+ indexName: string,
+ ): IndexMeta | undefined {
+ const conn = this.connections[dbConn.connectionCookie];
+ if (!conn) {
+ throw Error("db connection not found");
+ }
+ let schema = conn.modifiedSchema;
+ if (!schema) {
+ throw Error();
+ }
+ const storeInfo = schema.objectStores[objectStoreName];
+ if (!storeInfo) {
+ return undefined;
+ }
+ const indexInfo = storeInfo.indexes[indexName];
+ if (!indexInfo) {
+ return;
+ }
+ return {
+ keyPath: structuredClone(indexInfo.keyPath),
+ multiEntry: indexInfo.multiEntry,
+ unique: indexInfo.unique,
+ };
+ }
+}
+
+function getIndexRecords(req: {
+ indexData: ISortedMapF<IDBValidKey, IndexRecord>;
+ storeData: ISortedMapF<IDBValidKey, ObjectStoreRecord>;
+ lastIndexPosition?: IDBValidKey;
+ forward: boolean;
+ unique: boolean;
+ range: IDBKeyRange;
+ lastObjectStorePosition?: IDBValidKey;
+ advancePrimaryKey?: IDBValidKey;
+ advanceIndexKey?: IDBValidKey;
+ limit: number;
+ resultLevel: ResultLevel;
+}): RecordGetResponse {
+ let numResults = 0;
+ const indexKeys: Key[] = [];
+ const primaryKeys: Key[] = [];
+ const values: Value[] = [];
+ const { unique, range, forward, indexData } = req;
+
+ function nextIndexEntry(prevPos: IDBValidKey): IndexRecord | undefined {
+ const res: [IDBValidKey, IndexRecord] | undefined = forward
+ ? indexData.nextHigherPair(prevPos)
+ : indexData.nextLowerPair(prevPos);
+ return res ? res[1] : undefined;
+ }
+
+ function packResult(): RecordGetResponse {
+ // Collect the values based on the primary keys,
+ // if requested.
+ if (req.resultLevel === ResultLevel.Full) {
+ for (let i = 0; i < numResults; i++) {
+ const result = req.storeData.get(primaryKeys[i]);
+ if (!result) {
+ console.error("invariant violated during read");
+ console.error("request was", req);
+ throw Error("invariant violated during read");
+ }
+ values.push(structuredClone(result.value));
+ }
+ }
+ return {
+ count: numResults,
+ indexKeys:
+ req.resultLevel >= ResultLevel.OnlyKeys ? indexKeys : undefined,
+ primaryKeys:
+ req.resultLevel >= ResultLevel.OnlyKeys ? primaryKeys : undefined,
+ values: req.resultLevel >= ResultLevel.Full ? values : undefined,
+ };
+ }
+
+ let firstIndexPos = req.lastIndexPosition;
+ {
+ const rangeStart = forward ? range.lower : range.upper;
+ const dataStart = forward ? indexData.minKey() : indexData.maxKey();
+ firstIndexPos = furthestKey(forward, firstIndexPos, rangeStart);
+ firstIndexPos = furthestKey(forward, firstIndexPos, dataStart);
+ }
+
+ if (firstIndexPos == null) {
+ return packResult();
+ }
+
+ let objectStorePos: IDBValidKey | undefined = undefined;
+ let indexEntry: IndexRecord | undefined = undefined;
+
+ // Now we align at indexPos and after objectStorePos
+
+ indexEntry = indexData.get(firstIndexPos);
+ if (!indexEntry) {
+ // We're not aligned to an index key, go to next index entry
+ indexEntry = nextIndexEntry(firstIndexPos);
+ if (!indexEntry) {
+ return packResult();
+ }
+ objectStorePos = nextKey(true, indexEntry.primaryKeys, undefined);
+ } else if (
+ req.lastIndexPosition != null &&
+ compareKeys(req.lastIndexPosition, indexEntry.indexKey) !== 0
+ ) {
+ // We're already past the desired lastIndexPosition, don't use
+ // lastObjectStorePosition.
+ objectStorePos = nextKey(true, indexEntry.primaryKeys, undefined);
+ } else {
+ objectStorePos = nextKey(
+ true,
+ indexEntry.primaryKeys,
+ req.lastObjectStorePosition,
+ );
+ }
+
+ // Now skip lower/upper bound of open ranges
+
+ if (
+ forward &&
+ range.lowerOpen &&
+ range.lower != null &&
+ compareKeys(range.lower, indexEntry.indexKey) === 0
+ ) {
+ indexEntry = nextIndexEntry(indexEntry.indexKey);
+ if (!indexEntry) {
+ return packResult();
+ }
+ objectStorePos = indexEntry.primaryKeys.minKey();
+ }
+
+ if (
+ !forward &&
+ range.upperOpen &&
+ range.upper != null &&
+ compareKeys(range.upper, indexEntry.indexKey) === 0
+ ) {
+ indexEntry = nextIndexEntry(indexEntry.indexKey);
+ if (!indexEntry) {
+ return packResult();
+ }
+ objectStorePos = indexEntry.primaryKeys.minKey();
+ }
+
+ // If requested, return only unique results
+
+ if (
+ unique &&
+ req.lastIndexPosition != null &&
+ compareKeys(indexEntry.indexKey, req.lastIndexPosition) === 0
+ ) {
+ indexEntry = nextIndexEntry(indexEntry.indexKey);
+ if (!indexEntry) {
+ return packResult();
+ }
+ objectStorePos = indexEntry.primaryKeys.minKey();
+ }
+
+ if (req.advanceIndexKey != null) {
+ const ik = furthestKey(forward, indexEntry.indexKey, req.advanceIndexKey)!;
+ indexEntry = indexData.get(ik);
+ if (!indexEntry) {
+ indexEntry = nextIndexEntry(ik);
+ }
+ if (!indexEntry) {
+ return packResult();
+ }
+ }
+
+ // Use advancePrimaryKey if necessary
+ if (
+ req.advanceIndexKey != null &&
+ req.advancePrimaryKey &&
+ compareKeys(indexEntry.indexKey, req.advanceIndexKey) == 0
+ ) {
+ if (
+ objectStorePos == null ||
+ compareKeys(req.advancePrimaryKey, objectStorePos) > 0
+ ) {
+ objectStorePos = nextKey(
+ true,
+ indexEntry.primaryKeys,
+ req.advancePrimaryKey,
+ );
+ }
+ }
+
+ while (1) {
+ if (req.limit != 0 && numResults == req.limit) {
+ break;
+ }
+ if (!range.includes(indexEntry.indexKey)) {
+ break;
+ }
+ if (indexEntry === undefined) {
+ break;
+ }
+ if (objectStorePos == null) {
+ // We don't have any more records with the current index key.
+ indexEntry = nextIndexEntry(indexEntry.indexKey);
+ if (!indexEntry) {
+ return packResult();
+ }
+ objectStorePos = indexEntry.primaryKeys.minKey();
+ continue;
+ }
+
+ indexKeys.push(structuredClone(indexEntry.indexKey));
+ primaryKeys.push(structuredClone(objectStorePos));
+ numResults++;
+ if (unique) {
+ objectStorePos = undefined;
+ } else {
+ objectStorePos = indexEntry.primaryKeys.nextHigherKey(objectStorePos);
+ }
+ }
+
+ return packResult();
+}
+
+function getObjectStoreRecords(req: {
+ storeData: ISortedMapF<IDBValidKey, ObjectStoreRecord>;
+ forward: boolean;
+ range: IDBKeyRange;
+ lastObjectStorePosition?: IDBValidKey;
+ advancePrimaryKey?: IDBValidKey;
+ limit: number;
+ resultLevel: ResultLevel;
+}): RecordGetResponse {
+ let numResults = 0;
+ const primaryKeys: Key[] = [];
+ const values: Value[] = [];
+ const { storeData, range, forward } = req;
+
+ function packResult(): RecordGetResponse {
+ return {
+ count: numResults,
+ indexKeys: undefined,
+ primaryKeys:
+ req.resultLevel >= ResultLevel.OnlyKeys ? primaryKeys : undefined,
+ values: req.resultLevel >= ResultLevel.Full ? values : undefined,
+ };
+ }
+
+ const rangeStart = forward ? range.lower : range.upper;
+ const dataStart = forward ? storeData.minKey() : storeData.maxKey();
+ let storePos = req.lastObjectStorePosition;
+ storePos = furthestKey(forward, storePos, dataStart);
+ storePos = furthestKey(forward, storePos, rangeStart);
+ storePos = furthestKey(forward, storePos, req.advancePrimaryKey);
+
+ if (storePos != null) {
+ // Advance store position if we are either still at the last returned
+ // store key, or if we are currently not on a key.
+ const storeEntry = storeData.get(storePos);
+ if (
+ !storeEntry ||
+ (req.lastObjectStorePosition != null &&
+ compareKeys(req.lastObjectStorePosition, storePos) === 0)
+ ) {
+ storePos = forward
+ ? storeData.nextHigherKey(storePos)
+ : storeData.nextLowerKey(storePos);
+ }
+ } else {
+ storePos = forward ? storeData.minKey() : storeData.maxKey();
+ }
+
+ if (
+ storePos != null &&
+ forward &&
+ range.lowerOpen &&
+ range.lower != null &&
+ compareKeys(range.lower, storePos) === 0
+ ) {
+ storePos = storeData.nextHigherKey(storePos);
+ }
+
+ if (
+ storePos != null &&
+ !forward &&
+ range.upperOpen &&
+ range.upper != null &&
+ compareKeys(range.upper, storePos) === 0
+ ) {
+ storePos = storeData.nextLowerKey(storePos);
+ }
+
+ while (1) {
+ if (req.limit != 0 && numResults == req.limit) {
+ break;
+ }
+ if (storePos === null || storePos === undefined) {
+ break;
+ }
+ if (!range.includes(storePos)) {
+ break;
+ }
+
+ const res = storeData.get(storePos);
+
+ if (res === undefined) {
+ break;
+ }
+
+ if (req.resultLevel >= ResultLevel.OnlyKeys) {
+ primaryKeys.push(structuredClone(storePos));
+ }
+
+ if (req.resultLevel >= ResultLevel.Full) {
+ values.push(structuredClone(res.value));
+ }
+
+ numResults++;
+ storePos = nextStoreKey(forward, storeData, storePos);
+ }
+
+ return packResult();
}
diff --git a/packages/idb-bridge/src/SqliteBackend.test.ts b/packages/idb-bridge/src/SqliteBackend.test.ts
new file mode 100644
index 000000000..612cb9d4b
--- /dev/null
+++ b/packages/idb-bridge/src/SqliteBackend.test.ts
@@ -0,0 +1,83 @@
+/*
+ Copyright 2019 Florian Dold
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ or implied. See the License for the specific language governing
+ permissions and limitations under the License.
+ */
+
+import test from "ava";
+import { createSqliteBackend } from "./SqliteBackend.js";
+import { ResultLevel, StoreLevel } from "./backend-interface.js";
+import { BridgeIDBKeyRange } from "./bridge-idb.js";
+import * as fs from "node:fs";
+import { createNodeSqlite3Impl } from "./node-sqlite3-impl.js";
+
+test("sqlite3 backend", async (t) => {
+ const filename = "mytestdb.sqlite3";
+ try {
+ fs.unlinkSync(filename);
+ } catch (e) {
+ // Do nothing.
+ }
+ try {
+ const sqlite3Impl = await createNodeSqlite3Impl();
+ const backend = await createSqliteBackend(sqlite3Impl, {
+ filename,
+ });
+ const dbConnRes = await backend.connectDatabase("mydb");
+ const dbConn = dbConnRes.conn;
+ const tx = await backend.enterVersionChange(dbConn, 1);
+ backend.createObjectStore(tx, "books", "isbn", true);
+ backend.createIndex(tx, "byName", "books", "name", false, false);
+ await backend.storeRecord(tx, {
+ objectStoreName: "books",
+ storeLevel: StoreLevel.AllowOverwrite,
+ value: { name: "foo" },
+ key: undefined,
+ });
+ const res = await backend.getObjectStoreRecords(tx, {
+ direction: "next",
+ limit: 1,
+ objectStoreName: "books",
+ resultLevel: ResultLevel.Full,
+ range: BridgeIDBKeyRange.only(1),
+ });
+ t.deepEqual(res.count, 1);
+ t.deepEqual(res.primaryKeys![0], 1);
+ t.deepEqual(res.values![0].name, "foo");
+
+ const indexRes = await backend.getIndexRecords(tx, {
+ direction: "next",
+ limit: 1,
+ objectStoreName: "books",
+ indexName: "byName",
+ resultLevel: ResultLevel.Full,
+ range: BridgeIDBKeyRange.only("foo"),
+ });
+
+ t.deepEqual(indexRes.count, 1);
+ t.deepEqual(indexRes.values![0].isbn, 1);
+ t.deepEqual(indexRes.values![0].name, "foo");
+
+ await backend.commit(tx);
+
+ const tx2 = await backend.beginTransaction(dbConn, ["books"], "readwrite");
+ await backend.commit(tx2);
+
+ await backend.close(dbConn);
+
+ t.pass();
+ } catch (e: any) {
+ console.log(e);
+ throw e;
+ }
+});
diff --git a/packages/idb-bridge/src/SqliteBackend.ts b/packages/idb-bridge/src/SqliteBackend.ts
new file mode 100644
index 000000000..26ed43b0f
--- /dev/null
+++ b/packages/idb-bridge/src/SqliteBackend.ts
@@ -0,0 +1,2329 @@
+/*
+ Copyright 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AsyncCondition } from "./backend-common.js";
+import {
+ Backend,
+ ConnectResult,
+ DatabaseConnection,
+ DatabaseTransaction,
+ IndexGetQuery,
+ IndexMeta,
+ ObjectStoreGetQuery,
+ ObjectStoreMeta,
+ RecordGetResponse,
+ RecordStoreRequest,
+ RecordStoreResponse,
+ ResultLevel,
+ StoreLevel,
+} from "./backend-interface.js";
+import { BridgeIDBDatabaseInfo, BridgeIDBKeyRange } from "./bridge-idb.js";
+import {
+ IDBKeyPath,
+ IDBKeyRange,
+ IDBTransactionMode,
+ IDBValidKey,
+} from "./idbtypes.js";
+import {
+ AccessStats,
+ structuredEncapsulate,
+ structuredRevive,
+} from "./index.js";
+import { ConstraintError, DataError } from "./util/errors.js";
+import { getIndexKeys } from "./util/getIndexKeys.js";
+import { deserializeKey, serializeKey } from "./util/key-storage.js";
+import { makeStoreKeyValue } from "./util/makeStoreKeyValue.js";
+import {
+ Sqlite3Database,
+ Sqlite3Interface,
+ Sqlite3Statement,
+} from "./sqlite3-interface.js";
+
+function assertDbInvariant(b: boolean): asserts b {
+ if (!b) {
+ throw Error("internal invariant failed");
+ }
+}
+
+const SqliteError = {
+ constraintPrimarykey: "SQLITE_CONSTRAINT_PRIMARYKEY",
+} as const;
+
+export type SqliteRowid = number | bigint;
+
+enum TransactionLevel {
+ None = 0,
+ Read = 1,
+ Write = 2,
+ VersionChange = 3,
+}
+
+interface ConnectionInfo {
+ // Database that the connection has
+ // connected to.
+ databaseName: string;
+}
+
+interface TransactionInfo {
+ connectionCookie: string;
+}
+
+interface ScopeIndexInfo {
+ indexId: SqliteRowid;
+ keyPath: IDBKeyPath | IDBKeyPath[];
+ multiEntry: boolean;
+ unique: boolean;
+}
+
+interface ScopeInfo {
+ /**
+ * Internal ID of the object store.
+ * Used for fast retrieval, since it's the
+ * primary key / rowid of the sqlite table.
+ */
+ objectStoreId: SqliteRowid;
+
+ indexMap: Map<string, ScopeIndexInfo>;
+}
+
+interface IndexIterPos {
+ objectPos: Uint8Array;
+ indexPos: Uint8Array;
+}
+
+export function serializeKeyPath(
+ keyPath: string | string[] | null,
+): string | null {
+ if (Array.isArray(keyPath)) {
+ return "," + keyPath.join(",");
+ }
+ return keyPath;
+}
+
+export function deserializeKeyPath(
+ dbKeyPath: string | null,
+): string | string[] | null {
+ if (dbKeyPath == null) {
+ return null;
+ }
+ if (dbKeyPath[0] === ",") {
+ const elems = dbKeyPath.split(",");
+ elems.splice(0, 1);
+ return elems;
+ } else {
+ return dbKeyPath;
+ }
+}
+
+interface Boundary {
+ key: Uint8Array;
+ inclusive: boolean;
+}
+
+function getRangeEndBoundary(
+ forward: boolean,
+ range: IDBKeyRange | undefined | null,
+): Boundary | undefined {
+ let endRangeKey: Uint8Array | undefined = undefined;
+ let endRangeInclusive: boolean = false;
+ if (range) {
+ if (forward && range.upper != null) {
+ endRangeKey = serializeKey(range.upper);
+ endRangeInclusive = !range.upperOpen;
+ } else if (!forward && range.lower != null) {
+ endRangeKey = serializeKey(range.lower);
+ endRangeInclusive = !range.lowerOpen;
+ }
+ }
+ if (endRangeKey) {
+ return {
+ inclusive: endRangeInclusive,
+ key: endRangeKey,
+ };
+ }
+ return undefined;
+}
+
+function isOutsideBoundary(
+ forward: boolean,
+ endRange: Boundary,
+ currentKey: Uint8Array,
+): boolean {
+ const cmp = compareSerializedKeys(currentKey, endRange.key);
+ if (forward && endRange.inclusive && cmp > 0) {
+ return true;
+ } else if (forward && !endRange.inclusive && cmp >= 0) {
+ return true;
+ } else if (!forward && endRange.inclusive && cmp < 0) {
+ return true;
+ } else if (!forward && !endRange.inclusive && cmp <= 0) {
+ return true;
+ }
+ return false;
+}
+
+function compareSerializedKeys(k1: Uint8Array, k2: Uint8Array): number {
+ // FIXME: Simplify!
+ let i = 0;
+ while (1) {
+ let x1 = i >= k1.length ? -1 : k1[i];
+ let x2 = i >= k2.length ? -1 : k2[i];
+ if (x1 < x2) {
+ return -1;
+ }
+ if (x1 > x2) {
+ return 1;
+ }
+ if (x1 < 0 && x2 < 0) {
+ return 0;
+ }
+ i++;
+ }
+ throw Error("not reached");
+}
+
+export function expectDbNumber(
+ resultRow: unknown,
+ name: string,
+): number | bigint {
+ assertDbInvariant(typeof resultRow === "object" && resultRow != null);
+ const res = (resultRow as any)[name];
+ if (typeof res !== "number") {
+ throw Error("unexpected type from database");
+ }
+ return res;
+}
+
+export function expectDbString(resultRow: unknown, name: string): string {
+ assertDbInvariant(typeof resultRow === "object" && resultRow != null);
+ const res = (resultRow as any)[name];
+ if (typeof res !== "string") {
+ throw Error("unexpected type from database");
+ }
+ return res;
+}
+
+export function expectDbStringOrNull(
+ resultRow: unknown,
+ name: string,
+): string | null {
+ assertDbInvariant(typeof resultRow === "object" && resultRow != null);
+ const res = (resultRow as any)[name];
+ if (res == null) {
+ return null;
+ }
+ if (typeof res !== "string") {
+ throw Error("unexpected type from database");
+ }
+ return res;
+}
+
+export class SqliteBackend implements Backend {
+ private connectionIdCounter = 1;
+ private transactionIdCounter = 1;
+
+ trackStats = false;
+
+ accessStats: AccessStats = {
+ primitiveStatements: 0, // Counted by the sqlite impl
+ readTransactions: 0,
+ writeTransactions: 0,
+ readsPerStore: {},
+ readsPerIndex: {},
+ readItemsPerIndex: {},
+ readItemsPerStore: {},
+ writesPerStore: {},
+ };
+
+ /**
+ * Condition that is triggered whenever a transaction finishes.
+ */
+ private transactionDoneCond: AsyncCondition = new AsyncCondition();
+
+ /**
+ * Is the connection blocked because either an open request
+ * or delete request is being processed?
+ */
+ private connectionBlocked: boolean = false;
+
+ private txLevel: TransactionLevel = TransactionLevel.None;
+
+ private txScope: Map<string, ScopeInfo> = new Map();
+
+ private connectionMap: Map<string, ConnectionInfo> = new Map();
+
+ private transactionMap: Map<string, TransactionInfo> = new Map();
+
+ private sqlPrepCache: Map<string, Sqlite3Statement> = new Map();
+
+ enableTracing: boolean = true;
+
+ constructor(
+ public sqliteImpl: Sqlite3Interface,
+ public db: Sqlite3Database,
+ ) {}
+
+ private _prep(sql: string): Sqlite3Statement {
+ const stmt = this.sqlPrepCache.get(sql);
+ if (stmt) {
+ return stmt;
+ }
+ const newStmt = this.db.prepare(sql);
+ this.sqlPrepCache.set(sql, newStmt);
+ return newStmt;
+ }
+
+ async getIndexRecords(
+ btx: DatabaseTransaction,
+ req: IndexGetQuery,
+ ): Promise<RecordGetResponse> {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction not found");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ if (this.txLevel < TransactionLevel.Read) {
+ throw Error("only allowed in read transaction");
+ }
+ const scopeInfo = this.txScope.get(req.objectStoreName);
+ if (!scopeInfo) {
+ throw Error("object store not in scope");
+ }
+ const indexInfo = scopeInfo.indexMap.get(req.indexName);
+ if (!indexInfo) {
+ throw Error("index not found");
+ }
+ if (req.advancePrimaryKey != null) {
+ if (req.advanceIndexKey == null) {
+ throw Error(
+ "invalid request (advancePrimaryKey without advanceIndexKey)",
+ );
+ }
+ }
+
+ if (this.enableTracing) {
+ console.log(
+ `querying index os=${req.objectStoreName}, idx=${req.indexName}, direction=${req.direction}`,
+ );
+ }
+
+ const forward: boolean =
+ req.direction === "next" || req.direction === "nextunique";
+
+ const queryUnique =
+ req.direction === "nextunique" || req.direction === "prevunique";
+
+ const indexId = indexInfo.indexId;
+ const indexUnique = indexInfo.unique;
+
+ let numResults = 0;
+ const encPrimaryKeys: Uint8Array[] = [];
+ const encIndexKeys: Uint8Array[] = [];
+ const indexKeys: IDBValidKey[] = [];
+ const primaryKeys: IDBValidKey[] = [];
+ const values: unknown[] = [];
+
+ const endRange = getRangeEndBoundary(forward, req.range);
+
+ const backendThis = this;
+
+ function packResult() {
+ if (req.resultLevel > ResultLevel.OnlyCount) {
+ for (let i = 0; i < encPrimaryKeys.length; i++) {
+ primaryKeys.push(deserializeKey(encPrimaryKeys[i]));
+ }
+ for (let i = 0; i < encIndexKeys.length; i++) {
+ indexKeys.push(deserializeKey(encIndexKeys[i]));
+ }
+ if (req.resultLevel === ResultLevel.Full) {
+ for (let i = 0; i < encPrimaryKeys.length; i++) {
+ const val = backendThis._getObjectValue(
+ scopeInfo!.objectStoreId,
+ encPrimaryKeys[i],
+ );
+ if (!val) {
+ throw Error("invariant failed: value not found");
+ }
+ values.push(structuredRevive(JSON.parse(val)));
+ }
+ }
+ }
+
+ if (backendThis.enableTracing) {
+ console.log(`index query returned ${numResults} results`);
+ console.log(`result prim keys:`, primaryKeys);
+ console.log(`result index keys:`, indexKeys);
+ }
+
+ if (backendThis.trackStats) {
+ const k = `${req.objectStoreName}.${req.indexName}`;
+ backendThis.accessStats.readsPerIndex[k] =
+ (backendThis.accessStats.readsPerIndex[k] ?? 0) + 1;
+ backendThis.accessStats.readItemsPerIndex[k] =
+ (backendThis.accessStats.readItemsPerIndex[k] ?? 0) + numResults;
+ }
+
+ return {
+ count: numResults,
+ indexKeys: indexKeys,
+ primaryKeys:
+ req.resultLevel >= ResultLevel.OnlyKeys ? primaryKeys : undefined,
+ values: req.resultLevel >= ResultLevel.Full ? values : undefined,
+ };
+ }
+
+ let currentPos = this._startIndex({
+ indexId,
+ indexUnique,
+ queryUnique,
+ forward,
+ });
+
+ if (!currentPos) {
+ return packResult();
+ }
+
+ if (this.enableTracing && currentPos) {
+ console.log(`starting iteration at:`);
+ console.log(`indexKey:`, deserializeKey(currentPos.indexPos));
+ console.log(`objectKey:`, deserializeKey(currentPos.objectPos));
+ }
+
+ if (req.advanceIndexKey) {
+ const advanceIndexKey = serializeKey(req.advanceIndexKey);
+ const advancePrimaryKey = req.advancePrimaryKey
+ ? serializeKey(req.advancePrimaryKey)
+ : undefined;
+ currentPos = this._continueIndex({
+ indexId,
+ indexUnique,
+ queryUnique,
+ inclusive: true,
+ currentPos,
+ forward,
+ targetIndexKey: advanceIndexKey,
+ targetObjectKey: advancePrimaryKey,
+ });
+ if (!currentPos) {
+ return packResult();
+ }
+ }
+
+ if (req.lastIndexPosition) {
+ if (this.enableTracing) {
+ console.log("index query: seeking past last index position");
+ console.log("lastObjectPosition", req.lastObjectStorePosition);
+ console.log("lastIndexPosition", req.lastIndexPosition);
+ }
+ const lastIndexPosition = serializeKey(req.lastIndexPosition);
+ const lastObjectPosition = req.lastObjectStorePosition
+ ? serializeKey(req.lastObjectStorePosition)
+ : undefined;
+ currentPos = this._continueIndex({
+ indexId,
+ indexUnique,
+ queryUnique,
+ inclusive: false,
+ currentPos,
+ forward,
+ targetIndexKey: lastIndexPosition,
+ targetObjectKey: lastObjectPosition,
+ });
+ if (!currentPos) {
+ return packResult();
+ }
+ }
+
+ if (this.enableTracing && currentPos) {
+ console.log(
+ "before range, current index pos",
+ deserializeKey(currentPos.indexPos),
+ );
+ console.log(
+ "... current object pos",
+ deserializeKey(currentPos.objectPos),
+ );
+ }
+
+ if (req.range != null) {
+ const targetKeyObj = forward ? req.range.lower : req.range.upper;
+ if (targetKeyObj != null) {
+ const targetKey = serializeKey(targetKeyObj);
+ const inclusive = forward ? !req.range.lowerOpen : !req.range.upperOpen;
+ currentPos = this._continueIndex({
+ indexId,
+ indexUnique,
+ queryUnique,
+ inclusive,
+ currentPos,
+ forward,
+ targetIndexKey: targetKey,
+ });
+ }
+ if (!currentPos) {
+ return packResult();
+ }
+ }
+
+ if (this.enableTracing && currentPos) {
+ console.log(
+ "after range, current pos",
+ deserializeKey(currentPos.indexPos),
+ );
+ console.log(
+ "after range, current obj pos",
+ deserializeKey(currentPos.objectPos),
+ );
+ }
+
+ while (1) {
+ if (req.limit != 0 && numResults == req.limit) {
+ break;
+ }
+ if (currentPos == null) {
+ break;
+ }
+ if (
+ endRange &&
+ isOutsideBoundary(forward, endRange, currentPos.indexPos)
+ ) {
+ break;
+ }
+
+ numResults++;
+
+ if (req.resultLevel > ResultLevel.OnlyCount) {
+ encPrimaryKeys.push(currentPos.objectPos);
+ encIndexKeys.push(currentPos.indexPos);
+ }
+
+ currentPos = backendThis._continueIndex({
+ indexId,
+ indexUnique,
+ forward,
+ inclusive: false,
+ currentPos: undefined,
+ queryUnique,
+ targetIndexKey: currentPos.indexPos,
+ targetObjectKey: currentPos.objectPos,
+ });
+ }
+
+ return packResult();
+ }
+
+ // Continue past targetIndexKey (and optionally targetObjectKey)
+ // in the direction specified by "forward".
+ // Do nothing if the current position is already past the
+ // target position.
+ _continueIndex(req: {
+ indexId: SqliteRowid;
+ indexUnique: boolean;
+ queryUnique: boolean;
+ forward: boolean;
+ inclusive: boolean;
+ currentPos: IndexIterPos | null | undefined;
+ targetIndexKey: Uint8Array;
+ targetObjectKey?: Uint8Array;
+ }): IndexIterPos | undefined {
+ const currentPos = req.currentPos;
+ const forward = req.forward;
+ const dir = forward ? 1 : -1;
+ if (currentPos) {
+ // Check that the target position after the current position.
+ // If not, we just stay at the current position.
+ const indexCmp = compareSerializedKeys(
+ currentPos.indexPos,
+ req.targetIndexKey,
+ );
+ if (dir * indexCmp > 0) {
+ return currentPos;
+ }
+ if (indexCmp === 0) {
+ if (req.targetObjectKey != null) {
+ const objectCmp = compareSerializedKeys(
+ currentPos.objectPos,
+ req.targetObjectKey,
+ );
+ if (req.inclusive && objectCmp === 0) {
+ return currentPos;
+ }
+ if (dir * objectCmp > 0) {
+ return currentPos;
+ }
+ } else if (req.inclusive) {
+ return currentPos;
+ }
+ }
+ }
+
+ let stmt: Sqlite3Statement;
+
+ if (req.indexUnique) {
+ if (req.forward) {
+ if (req.inclusive) {
+ stmt = this._prep(sqlUniqueIndexDataContinueForwardInclusive);
+ } else {
+ stmt = this._prep(sqlUniqueIndexDataContinueForwardStrict);
+ }
+ } else {
+ if (req.inclusive) {
+ stmt = this._prep(sqlUniqueIndexDataContinueBackwardInclusive);
+ } else {
+ stmt = this._prep(sqlUniqueIndexDataContinueBackwardStrict);
+ }
+ }
+ } else {
+ if (req.forward) {
+ if (req.queryUnique || req.targetObjectKey == null) {
+ if (req.inclusive) {
+ stmt = this._prep(sqlIndexDataContinueForwardInclusiveUnique);
+ } else {
+ stmt = this._prep(sqlIndexDataContinueForwardStrictUnique);
+ }
+ } else {
+ if (req.inclusive) {
+ stmt = this._prep(sqlIndexDataContinueForwardInclusive);
+ } else {
+ stmt = this._prep(sqlIndexDataContinueForwardStrict);
+ }
+ }
+ } else {
+ if (req.queryUnique || req.targetObjectKey == null) {
+ if (req.inclusive) {
+ stmt = this._prep(sqlIndexDataContinueBackwardInclusiveUnique);
+ } else {
+ stmt = this._prep(sqlIndexDataContinueBackwardStrictUnique);
+ }
+ } else {
+ if (req.inclusive) {
+ stmt = this._prep(sqlIndexDataContinueBackwardInclusive);
+ } else {
+ stmt = this._prep(sqlIndexDataContinueBackwardStrict);
+ }
+ }
+ }
+ }
+
+ const res = stmt.getFirst({
+ index_id: req.indexId,
+ index_key: req.targetIndexKey,
+ object_key: req.targetObjectKey,
+ });
+
+ if (res == null) {
+ return undefined;
+ }
+
+ assertDbInvariant(typeof res === "object");
+ assertDbInvariant("index_key" in res);
+ const indexKey = res.index_key;
+ if (indexKey == null) {
+ return undefined;
+ }
+ assertDbInvariant(indexKey instanceof Uint8Array);
+ assertDbInvariant("object_key" in res);
+ const objectKey = res.object_key;
+ if (objectKey == null) {
+ return undefined;
+ }
+ assertDbInvariant(objectKey instanceof Uint8Array);
+
+ return {
+ indexPos: indexKey,
+ objectPos: objectKey,
+ };
+ }
+
+ _startIndex(req: {
+ indexId: SqliteRowid;
+ indexUnique: boolean;
+ queryUnique: boolean;
+ forward: boolean;
+ }): IndexIterPos | undefined {
+ let stmt: Sqlite3Statement;
+ if (req.indexUnique) {
+ if (req.forward) {
+ stmt = this._prep(sqlUniqueIndexDataStartForward);
+ } else {
+ stmt = this._prep(sqlUniqueIndexDataStartBackward);
+ }
+ } else {
+ if (req.forward) {
+ stmt = this._prep(sqlIndexDataStartForward);
+ } else {
+ if (req.queryUnique) {
+ stmt = this._prep(sqlIndexDataStartBackwardUnique);
+ } else {
+ stmt = this._prep(sqlIndexDataStartBackward);
+ }
+ }
+ }
+
+ const res = stmt.getFirst({
+ index_id: req.indexId,
+ });
+
+ if (res == null) {
+ return undefined;
+ }
+
+ assertDbInvariant(typeof res === "object");
+ assertDbInvariant("index_key" in res);
+ const indexKey = res.index_key;
+ assertDbInvariant(indexKey instanceof Uint8Array);
+ assertDbInvariant("object_key" in res);
+ const objectKey = res.object_key;
+ assertDbInvariant(objectKey instanceof Uint8Array);
+
+ return {
+ indexPos: indexKey,
+ objectPos: objectKey,
+ };
+ }
+
+ async getObjectStoreRecords(
+ btx: DatabaseTransaction,
+ req: ObjectStoreGetQuery,
+ ): Promise<RecordGetResponse> {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction not found");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ if (this.txLevel < TransactionLevel.Read) {
+ throw Error("only allowed in read transaction");
+ }
+ const scopeInfo = this.txScope.get(req.objectStoreName);
+ if (!scopeInfo) {
+ throw Error(
+ `object store ${JSON.stringify(
+ req.objectStoreName,
+ )} not in transaction scope`,
+ );
+ }
+
+ const forward: boolean =
+ req.direction === "next" || req.direction === "nextunique";
+
+ let currentKey = this._startObjectKey(scopeInfo.objectStoreId, forward);
+
+ if (req.advancePrimaryKey != null) {
+ const targetKey = serializeKey(req.advancePrimaryKey);
+ currentKey = this._continueObjectKey({
+ objectStoreId: scopeInfo.objectStoreId,
+ forward,
+ inclusive: true,
+ currentKey,
+ targetKey,
+ });
+ }
+
+ if (req.lastObjectStorePosition != null) {
+ const targetKey = serializeKey(req.lastObjectStorePosition);
+ currentKey = this._continueObjectKey({
+ objectStoreId: scopeInfo.objectStoreId,
+ forward,
+ inclusive: false,
+ currentKey,
+ targetKey,
+ });
+ }
+
+ if (req.range != null) {
+ const targetKeyObj = forward ? req.range.lower : req.range.upper;
+ if (targetKeyObj != null) {
+ const targetKey = serializeKey(targetKeyObj);
+ const inclusive = forward ? !req.range.lowerOpen : !req.range.upperOpen;
+ currentKey = this._continueObjectKey({
+ objectStoreId: scopeInfo.objectStoreId,
+ forward,
+ inclusive,
+ currentKey,
+ targetKey,
+ });
+ }
+ }
+
+ const endRange = getRangeEndBoundary(forward, req.range);
+
+ let numResults = 0;
+ const encPrimaryKeys: Uint8Array[] = [];
+ const primaryKeys: IDBValidKey[] = [];
+ const values: unknown[] = [];
+
+ while (1) {
+ if (req.limit != 0 && numResults == req.limit) {
+ break;
+ }
+ if (currentKey == null) {
+ break;
+ }
+ if (endRange && isOutsideBoundary(forward, endRange, currentKey)) {
+ break;
+ }
+
+ numResults++;
+
+ if (req.resultLevel > ResultLevel.OnlyCount) {
+ encPrimaryKeys.push(currentKey);
+ }
+
+ currentKey = this._continueObjectKey({
+ objectStoreId: scopeInfo.objectStoreId,
+ forward,
+ inclusive: false,
+ currentKey: null,
+ targetKey: currentKey,
+ });
+ }
+
+ if (req.resultLevel > ResultLevel.OnlyCount) {
+ for (let i = 0; i < encPrimaryKeys.length; i++) {
+ primaryKeys.push(deserializeKey(encPrimaryKeys[i]));
+ }
+ if (req.resultLevel === ResultLevel.Full) {
+ for (let i = 0; i < encPrimaryKeys.length; i++) {
+ const val = this._getObjectValue(
+ scopeInfo.objectStoreId,
+ encPrimaryKeys[i],
+ );
+ if (!val) {
+ throw Error("invariant failed: value not found");
+ }
+ values.push(structuredRevive(JSON.parse(val)));
+ }
+ }
+ }
+
+ if (this.trackStats) {
+ const k = `${req.objectStoreName}`;
+ this.accessStats.readsPerStore[k] =
+ (this.accessStats.readsPerStore[k] ?? 0) + 1;
+ this.accessStats.readItemsPerStore[k] =
+ (this.accessStats.readItemsPerStore[k] ?? 0) + numResults;
+ }
+
+ return {
+ count: numResults,
+ indexKeys: undefined,
+ primaryKeys:
+ req.resultLevel >= ResultLevel.OnlyKeys ? primaryKeys : undefined,
+ values: req.resultLevel >= ResultLevel.Full ? values : undefined,
+ };
+ }
+
+ _startObjectKey(
+ objectStoreId: number | bigint,
+ forward: boolean,
+ ): Uint8Array | null {
+ let stmt: Sqlite3Statement;
+ if (forward) {
+ stmt = this._prep(sqlObjectDataStartForward);
+ } else {
+ stmt = this._prep(sqlObjectDataStartBackward);
+ }
+ const res = stmt.getFirst({
+ object_store_id: objectStoreId,
+ });
+ if (!res) {
+ return null;
+ }
+ assertDbInvariant(typeof res === "object");
+ assertDbInvariant("rkey" in res);
+ const rkey = res.rkey;
+ if (!rkey) {
+ return null;
+ }
+ assertDbInvariant(rkey instanceof Uint8Array);
+ return rkey;
+ }
+
+ // Result *must* be past targetKey in the direction
+ // specified by "forward".
+ _continueObjectKey(req: {
+ objectStoreId: number | bigint;
+ forward: boolean;
+ currentKey: Uint8Array | null;
+ targetKey: Uint8Array;
+ inclusive: boolean;
+ }): Uint8Array | null {
+ const { forward, currentKey, targetKey } = req;
+ const dir = forward ? 1 : -1;
+ if (currentKey) {
+ const objCmp = compareSerializedKeys(currentKey, targetKey);
+ if (objCmp === 0 && req.inclusive) {
+ return currentKey;
+ }
+ if (dir * objCmp > 0) {
+ return currentKey;
+ }
+ }
+
+ let stmt: Sqlite3Statement;
+
+ if (req.inclusive) {
+ if (req.forward) {
+ stmt = this._prep(sqlObjectDataContinueForwardInclusive);
+ } else {
+ stmt = this._prep(sqlObjectDataContinueBackwardInclusive);
+ }
+ } else {
+ if (req.forward) {
+ stmt = this._prep(sqlObjectDataContinueForward);
+ } else {
+ stmt = this._prep(sqlObjectDataContinueBackward);
+ }
+ }
+
+ const res = stmt.getFirst({
+ object_store_id: req.objectStoreId,
+ x: req.targetKey,
+ });
+
+ if (!res) {
+ return null;
+ }
+
+ assertDbInvariant(typeof res === "object");
+ assertDbInvariant("rkey" in res);
+ const rkey = res.rkey;
+ if (!rkey) {
+ return null;
+ }
+ assertDbInvariant(rkey instanceof Uint8Array);
+ return rkey;
+ }
+
+ _getObjectValue(
+ objectStoreId: number | bigint,
+ key: Uint8Array,
+ ): string | undefined {
+ const stmt = this._prep(sqlObjectDataValueFromKey);
+ const res = stmt.getFirst({
+ object_store_id: objectStoreId,
+ key: key,
+ });
+ if (!res) {
+ return undefined;
+ }
+ assertDbInvariant(typeof res === "object");
+ assertDbInvariant("value" in res);
+ assertDbInvariant(typeof res.value === "string");
+ return res.value;
+ }
+
+ getObjectStoreMeta(
+ dbConn: DatabaseConnection,
+ objectStoreName: string,
+ ): ObjectStoreMeta | undefined {
+ // FIXME: Use cached info from the connection for this!
+ const connInfo = this.connectionMap.get(dbConn.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ const objRes = this._prep(sqlGetObjectStoreMetaByName).getFirst({
+ name: objectStoreName,
+ database_name: connInfo.databaseName,
+ });
+ if (!objRes) {
+ throw Error("object store not found");
+ }
+ const objectStoreId = expectDbNumber(objRes, "id");
+ const keyPath = deserializeKeyPath(
+ expectDbStringOrNull(objRes, "key_path"),
+ );
+ const autoInc = expectDbNumber(objRes, "auto_increment");
+ const indexSet: string[] = [];
+ const indexRes = this._prep(sqlGetIndexesByObjectStoreId).getAll({
+ object_store_id: objectStoreId,
+ });
+ for (const idxInfo of indexRes) {
+ const indexName = expectDbString(idxInfo, "name");
+ indexSet.push(indexName);
+ }
+ return {
+ keyPath,
+ autoIncrement: autoInc != 0,
+ indexSet,
+ };
+ }
+
+ getIndexMeta(
+ dbConn: DatabaseConnection,
+ objectStoreName: string,
+ indexName: string,
+ ): IndexMeta | undefined {
+ // FIXME: Use cached info from the connection for this!
+ const connInfo = this.connectionMap.get(dbConn.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ const objRes = this._prep(sqlGetObjectStoreMetaByName).getFirst({
+ name: objectStoreName,
+ database_name: connInfo.databaseName,
+ });
+ if (!objRes) {
+ throw Error("object store not found");
+ }
+ const objectStoreId = expectDbNumber(objRes, "id");
+ const idxInfo = this._prep(sqlGetIndexByName).getFirst({
+ object_store_id: objectStoreId,
+ name: indexName,
+ });
+ if (!idxInfo) {
+ throw Error(
+ `index ${indexName} on object store ${objectStoreName} not found`,
+ );
+ }
+ const indexUnique = expectDbNumber(idxInfo, "unique_index");
+ const indexMultiEntry = expectDbNumber(idxInfo, "multientry");
+ const indexKeyPath = deserializeKeyPath(
+ expectDbString(idxInfo, "key_path"),
+ );
+ if (!indexKeyPath) {
+ throw Error("db inconsistent");
+ }
+ return {
+ keyPath: indexKeyPath,
+ multiEntry: indexMultiEntry != 0,
+ unique: indexUnique != 0,
+ };
+ }
+
+ async getDatabases(): Promise<BridgeIDBDatabaseInfo[]> {
+ const dbList = this._prep(sqlListDatabases).getAll();
+ let res: BridgeIDBDatabaseInfo[] = [];
+ for (const r of dbList) {
+ res.push({
+ name: (r as any).name,
+ version: (r as any).version,
+ });
+ }
+
+ return res;
+ }
+
+ private _loadObjectStoreNames(databaseName: string): string[] {
+ const objectStoreNames: string[] = [];
+ const storesRes = this._prep(sqlGetObjectStoresByDatabase).getAll({
+ database_name: databaseName,
+ });
+ for (const res of storesRes) {
+ assertDbInvariant(res != null && typeof res === "object");
+ assertDbInvariant("name" in res);
+ const storeName = res.name;
+ assertDbInvariant(typeof storeName === "string");
+ objectStoreNames.push(storeName);
+ }
+ return objectStoreNames;
+ }
+
+ async connectDatabase(databaseName: string): Promise<ConnectResult> {
+ const connectionId = this.connectionIdCounter++;
+ const connectionCookie = `connection-${connectionId}`;
+
+ // Wait until no transaction is active anymore.
+ while (1) {
+ if (this.txLevel == TransactionLevel.None) {
+ break;
+ }
+ await this.transactionDoneCond.wait();
+ }
+
+ this._prep(sqlBegin).run();
+ const versionRes = this._prep(sqlGetDatabaseVersion).getFirst({
+ name: databaseName,
+ });
+ let ver: number;
+ if (versionRes == undefined) {
+ this._prep(sqlCreateDatabase).run({ name: databaseName });
+ ver = 0;
+ } else {
+ const verNum = expectDbNumber(versionRes, "version");
+ assertDbInvariant(typeof verNum === "number");
+ ver = verNum;
+ }
+ const objectStoreNames: string[] = this._loadObjectStoreNames(databaseName);
+
+ this._prep(sqlCommit).run();
+
+ this.connectionMap.set(connectionCookie, {
+ databaseName: databaseName,
+ });
+
+ return {
+ conn: {
+ connectionCookie,
+ },
+ version: ver,
+ objectStores: objectStoreNames,
+ };
+ }
+
+ private _loadScopeInfo(connInfo: ConnectionInfo, storeName: string): void {
+ const objRes = this._prep(sqlGetObjectStoreMetaByName).getFirst({
+ name: storeName,
+ database_name: connInfo.databaseName,
+ });
+ if (!objRes) {
+ throw Error("object store not found");
+ }
+ const objectStoreId = expectDbNumber(objRes, "id");
+ const indexRes = this._prep(sqlGetIndexesByObjectStoreId).getAll({
+ object_store_id: objectStoreId,
+ });
+ if (!indexRes) {
+ throw Error("db inconsistent");
+ }
+ const indexMap = new Map<string, ScopeIndexInfo>();
+ for (const idxInfo of indexRes) {
+ const indexId = expectDbNumber(idxInfo, "id");
+ const indexName = expectDbString(idxInfo, "name");
+ const indexUnique = expectDbNumber(idxInfo, "unique_index");
+ const indexMultiEntry = expectDbNumber(idxInfo, "multientry");
+ const indexKeyPath = deserializeKeyPath(
+ expectDbString(idxInfo, "key_path"),
+ );
+ if (!indexKeyPath) {
+ throw Error("db inconsistent");
+ }
+ indexMap.set(indexName, {
+ indexId,
+ keyPath: indexKeyPath,
+ multiEntry: indexMultiEntry != 0,
+ unique: indexUnique != 0,
+ });
+ }
+ this.txScope.set(storeName, {
+ objectStoreId,
+ indexMap,
+ });
+ }
+
+ async beginTransaction(
+ conn: DatabaseConnection,
+ objectStores: string[],
+ mode: IDBTransactionMode,
+ ): Promise<DatabaseTransaction> {
+ const connInfo = this.connectionMap.get(conn.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ const transactionCookie = `tx-${this.transactionIdCounter++}`;
+
+ while (1) {
+ if (this.txLevel === TransactionLevel.None) {
+ break;
+ }
+ await this.transactionDoneCond.wait();
+ }
+
+ if (this.trackStats) {
+ if (mode === "readonly") {
+ this.accessStats.readTransactions++;
+ } else if (mode === "readwrite") {
+ this.accessStats.writeTransactions++;
+ }
+ }
+
+ this._prep(sqlBegin).run();
+ if (mode === "readonly") {
+ this.txLevel = TransactionLevel.Read;
+ } else if (mode === "readwrite") {
+ this.txLevel = TransactionLevel.Write;
+ }
+
+ this.transactionMap.set(transactionCookie, {
+ connectionCookie: conn.connectionCookie,
+ });
+
+ // FIXME: We should check this
+ // if (this.txScope.size != 0) {
+ // // Something didn't clean up!
+ // throw Error("scope not empty");
+ // }
+ this.txScope.clear();
+
+ // FIXME: Use cached info from connection?
+ for (const storeName of objectStores) {
+ this._loadScopeInfo(connInfo, storeName);
+ }
+
+ return {
+ transactionCookie,
+ };
+ }
+
+ async enterVersionChange(
+ conn: DatabaseConnection,
+ newVersion: number,
+ ): Promise<DatabaseTransaction> {
+ const connInfo = this.connectionMap.get(conn.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ if (this.enableTracing) {
+ console.log(
+ `entering version change transaction (conn ${conn.connectionCookie}), newVersion=${newVersion}`,
+ );
+ }
+ const transactionCookie = `tx-vc-${this.transactionIdCounter++}`;
+
+ while (1) {
+ if (this.txLevel === TransactionLevel.None) {
+ break;
+ }
+ await this.transactionDoneCond.wait();
+ }
+
+ // FIXME: We should check this
+ // if (this.txScope.size != 0) {
+ // // Something didn't clean up!
+ // throw Error("scope not empty");
+ // }
+ this.txScope.clear();
+
+ if (this.enableTracing) {
+ console.log(`version change transaction unblocked`);
+ }
+
+ this._prep(sqlBegin).run();
+ this.txLevel = TransactionLevel.VersionChange;
+
+ this.transactionMap.set(transactionCookie, {
+ connectionCookie: conn.connectionCookie,
+ });
+
+ this._prep(sqlUpdateDbVersion).run({
+ name: connInfo.databaseName,
+ version: newVersion,
+ });
+
+ const objectStoreNames = this._loadObjectStoreNames(connInfo.databaseName);
+
+ // FIXME: Use cached info from connection?
+ for (const storeName of objectStoreNames) {
+ this._loadScopeInfo(connInfo, storeName);
+ }
+
+ return {
+ transactionCookie,
+ };
+ }
+
+ async deleteDatabase(databaseName: string): Promise<void> {
+ // FIXME: Wait until connection queue is not blocked
+ // FIXME: To properly implement the spec semantics, maybe
+ // split delete into prepareDelete and executeDelete?
+
+ while (this.txLevel !== TransactionLevel.None) {
+ await this.transactionDoneCond.wait();
+ }
+
+ this._prep(sqlBegin).run();
+ const objectStoreNames = this._loadObjectStoreNames(databaseName);
+ for (const storeName of objectStoreNames) {
+ const objRes = this._prep(sqlGetObjectStoreMetaByName).getFirst({
+ name: storeName,
+ database_name: databaseName,
+ });
+ if (!objRes) {
+ throw Error("object store not found");
+ }
+ const objectStoreId = expectDbNumber(objRes, "id");
+ const indexRes = this._prep(sqlGetIndexesByObjectStoreId).getAll({
+ object_store_id: objectStoreId,
+ });
+ if (!indexRes) {
+ throw Error("db inconsistent");
+ }
+ const indexMap = new Map<string, ScopeIndexInfo>();
+ for (const idxInfo of indexRes) {
+ const indexId = expectDbNumber(idxInfo, "id");
+ const indexName = expectDbString(idxInfo, "name");
+ const indexUnique = expectDbNumber(idxInfo, "unique_index");
+ const indexMultiEntry = expectDbNumber(idxInfo, "multientry");
+ const indexKeyPath = deserializeKeyPath(
+ expectDbString(idxInfo, "key_path"),
+ );
+ if (!indexKeyPath) {
+ throw Error("db inconsistent");
+ }
+ indexMap.set(indexName, {
+ indexId,
+ keyPath: indexKeyPath,
+ multiEntry: indexMultiEntry != 0,
+ unique: indexUnique != 0,
+ });
+ }
+ this.txScope.set(storeName, {
+ objectStoreId,
+ indexMap,
+ });
+
+ for (const indexInfo of indexMap.values()) {
+ let stmt: Sqlite3Statement;
+ if (indexInfo.unique) {
+ stmt = this._prep(sqlIUniqueIndexDataDeleteAll);
+ } else {
+ stmt = this._prep(sqlIndexDataDeleteAll);
+ }
+ stmt.run({
+ index_id: indexInfo.indexId,
+ });
+ this._prep(sqlIndexDelete).run({
+ index_id: indexInfo.indexId,
+ });
+ }
+ this._prep(sqlObjectDataDeleteAll).run({
+ object_store_id: objectStoreId,
+ });
+ this._prep(sqlObjectStoreDelete).run({
+ object_store_id: objectStoreId,
+ });
+ }
+ this._prep(sqlDeleteDatabase).run({
+ name: databaseName,
+ });
+ this._prep(sqlCommit).run();
+ }
+
+ async close(db: DatabaseConnection): Promise<void> {
+ const connInfo = this.connectionMap.get(db.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ // FIXME: What if we're in a transaction? Does the backend interface allow this?
+ // if (this.txLevel !== TransactionLevel.None) {
+ // throw Error("can't close while in transaction");
+ // }
+ if (this.enableTracing) {
+ console.log(`closing connection ${db.connectionCookie}`);
+ }
+ this.connectionMap.delete(db.connectionCookie);
+ }
+
+ renameObjectStore(
+ btx: DatabaseTransaction,
+ oldName: string,
+ newName: string,
+ ): void {
+ if (this.enableTracing) {
+ console.log(`renaming object store '${oldName}' to '${newName}'`);
+ }
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction required");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("not connected");
+ }
+ // FIXME: Would be much nicer with numeric UID handles
+ const scopeInfo = this.txScope.get(oldName);
+ if (!scopeInfo) {
+ throw Error("object store not found");
+ }
+ this.txScope.delete(oldName);
+ this.txScope.set(newName, scopeInfo);
+ this._prep(sqlRenameObjectStore).run({
+ object_store_id: scopeInfo.objectStoreId,
+ name: newName,
+ });
+ }
+
+ renameIndex(
+ btx: DatabaseTransaction,
+ objectStoreName: string,
+ oldIndexName: string,
+ newIndexName: string,
+ ): void {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction required");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("not connected");
+ }
+ // FIXME: Would be much nicer with numeric UID handles
+ const scopeInfo = this.txScope.get(objectStoreName);
+ if (!scopeInfo) {
+ throw Error("object store not found");
+ }
+ const indexInfo = scopeInfo.indexMap.get(oldIndexName);
+ if (!indexInfo) {
+ throw Error("index not found");
+ }
+ // FIXME: Would also be much nicer with numeric UID handles
+ scopeInfo.indexMap.delete(oldIndexName);
+ scopeInfo.indexMap.set(newIndexName, indexInfo);
+ this._prep(sqlRenameIndex).run({
+ index_id: indexInfo.indexId,
+ name: newIndexName,
+ });
+ }
+
+ deleteObjectStore(btx: DatabaseTransaction, name: string): void {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction required");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("not connected");
+ }
+ // FIXME: Would be much nicer with numeric UID handles
+ const scopeInfo = this.txScope.get(name);
+ if (!scopeInfo) {
+ throw Error("object store not found");
+ }
+ for (const indexInfo of scopeInfo.indexMap.values()) {
+ let stmt: Sqlite3Statement;
+ if (indexInfo.unique) {
+ stmt = this._prep(sqlIUniqueIndexDataDeleteAll);
+ } else {
+ stmt = this._prep(sqlIndexDataDeleteAll);
+ }
+ stmt.run({
+ index_id: indexInfo.indexId,
+ });
+ this._prep(sqlIndexDelete).run({
+ index_id: indexInfo.indexId,
+ });
+ }
+ this._prep(sqlObjectDataDeleteAll).run({
+ object_store_id: scopeInfo.objectStoreId,
+ });
+ this._prep(sqlObjectStoreDelete).run({
+ object_store_id: scopeInfo.objectStoreId,
+ });
+ this.txScope.delete(name);
+ }
+
+ deleteIndex(
+ btx: DatabaseTransaction,
+ objectStoreName: string,
+ indexName: string,
+ ): void {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction required");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("not connected");
+ }
+ // FIXME: Would be much nicer with numeric UID handles
+ const scopeInfo = this.txScope.get(objectStoreName);
+ if (!scopeInfo) {
+ throw Error("object store not found");
+ }
+ const indexInfo = scopeInfo.indexMap.get(indexName);
+ if (!indexInfo) {
+ throw Error("index not found");
+ }
+ scopeInfo.indexMap.delete(indexName);
+ let stmt: Sqlite3Statement;
+ if (indexInfo.unique) {
+ stmt = this._prep(sqlIUniqueIndexDataDeleteAll);
+ } else {
+ stmt = this._prep(sqlIndexDataDeleteAll);
+ }
+ stmt.run({
+ index_id: indexInfo.indexId,
+ });
+ this._prep(sqlIndexDelete).run({
+ index_id: indexInfo.indexId,
+ });
+ }
+
+ async rollback(btx: DatabaseTransaction): Promise<void> {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction not found");
+ }
+ if (this.enableTracing) {
+ console.log(`rolling back transaction ${btx.transactionCookie}`);
+ }
+ if (this.txLevel === TransactionLevel.None) {
+ return;
+ }
+ this._prep(sqlRollback).run();
+ this.txLevel = TransactionLevel.None;
+ this.transactionMap.delete(btx.transactionCookie);
+ this.txScope.clear();
+ this.transactionDoneCond.trigger();
+ }
+
+ async commit(btx: DatabaseTransaction): Promise<void> {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction not found");
+ }
+ if (this.enableTracing) {
+ console.log(`committing transaction ${btx.transactionCookie}`);
+ }
+ if (this.txLevel === TransactionLevel.None) {
+ return;
+ }
+ this._prep(sqlCommit).run();
+ this.txLevel = TransactionLevel.None;
+ this.txScope.clear();
+ this.transactionMap.delete(btx.transactionCookie);
+ this.transactionDoneCond.trigger();
+ }
+
+ createObjectStore(
+ btx: DatabaseTransaction,
+ name: string,
+ keyPath: string | string[] | null,
+ autoIncrement: boolean,
+ ): void {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction not found");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ if (this.txLevel < TransactionLevel.VersionChange) {
+ throw Error("only allowed in versionchange transaction");
+ }
+ if (this.txScope.has(name)) {
+ throw Error("object store already exists");
+ }
+ let myKeyPath = serializeKeyPath(keyPath);
+ const runRes = this._prep(sqlCreateObjectStore).run({
+ name,
+ key_path: myKeyPath,
+ auto_increment: autoIncrement ? 1 : 0,
+ database_name: connInfo.databaseName,
+ });
+ this.txScope.set(name, {
+ objectStoreId: runRes.lastInsertRowid,
+ indexMap: new Map(),
+ });
+ }
+
+ createIndex(
+ btx: DatabaseTransaction,
+ indexName: string,
+ objectStoreName: string,
+ keyPath: string | string[],
+ multiEntry: boolean,
+ unique: boolean,
+ ): void {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction not found");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ if (this.txLevel < TransactionLevel.VersionChange) {
+ throw Error("only allowed in versionchange transaction");
+ }
+ const scopeInfo = this.txScope.get(objectStoreName);
+ if (!scopeInfo) {
+ throw Error("object store does not exist, can't create index");
+ }
+ if (scopeInfo.indexMap.has(indexName)) {
+ throw Error("index already exists");
+ }
+
+ if (this.enableTracing) {
+ console.log(`creating index "${indexName}"`);
+ }
+
+ const res = this._prep(sqlCreateIndex).run({
+ object_store_id: scopeInfo.objectStoreId,
+ name: indexName,
+ key_path: serializeKeyPath(keyPath),
+ unique: unique ? 1 : 0,
+ multientry: multiEntry ? 1 : 0,
+ });
+ const scopeIndexInfo: ScopeIndexInfo = {
+ indexId: res.lastInsertRowid,
+ keyPath,
+ multiEntry,
+ unique,
+ };
+ scopeInfo.indexMap.set(indexName, scopeIndexInfo);
+
+ // FIXME: We can't use an iterator here, as it's not allowed to
+ // execute a write statement while the iterator executes.
+ // Maybe do multiple selects instead of loading everything into memory?
+ const keyRowsRes = this._prep(sqlObjectDataGetAll).getAll({
+ object_store_id: scopeInfo.objectStoreId,
+ });
+
+ for (const keyRow of keyRowsRes) {
+ assertDbInvariant(typeof keyRow === "object" && keyRow != null);
+ assertDbInvariant("key" in keyRow);
+ assertDbInvariant("value" in keyRow);
+ assertDbInvariant(typeof keyRow.value === "string");
+ const key = keyRow.key;
+ const value = structuredRevive(JSON.parse(keyRow.value));
+ assertDbInvariant(key instanceof Uint8Array);
+ try {
+ this.insertIntoIndex(scopeIndexInfo, key, value);
+ } catch (e) {
+ // FIXME: Catch this in insertIntoIndex!
+ if (e instanceof DataError) {
+ // https://www.w3.org/TR/IndexedDB-2/#object-store-storage-operation
+ // Do nothing
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+
+ async deleteRecord(
+ btx: DatabaseTransaction,
+ objectStoreName: string,
+ range: BridgeIDBKeyRange,
+ ): Promise<void> {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction not found");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ if (this.txLevel < TransactionLevel.Write) {
+ throw Error("store operation only allowed while running a transaction");
+ }
+ const scopeInfo = this.txScope.get(objectStoreName);
+ if (!scopeInfo) {
+ throw Error(
+ `object store ${JSON.stringify(
+ objectStoreName,
+ )} not in transaction scope`,
+ );
+ }
+
+ // PERF: We delete keys one-by-one here.
+ // Instead, we could do it with a single
+ // delete query for the object data / index data.
+
+ let currKey: Uint8Array | null = null;
+
+ if (range.lower != null) {
+ const targetKey = serializeKey(range.lower);
+ currKey = this._continueObjectKey({
+ objectStoreId: scopeInfo.objectStoreId,
+ currentKey: null,
+ forward: true,
+ inclusive: true,
+ targetKey,
+ });
+ } else {
+ currKey = this._startObjectKey(scopeInfo.objectStoreId, true);
+ }
+
+ let upperBound: Uint8Array | undefined;
+ if (range.upper != null) {
+ upperBound = serializeKey(range.upper);
+ }
+
+ // loop invariant: (currKey is undefined) or (currKey is a valid key)
+ while (true) {
+ if (!currKey) {
+ break;
+ }
+
+ // FIXME: Check if we're past the range!
+ if (upperBound != null) {
+ const cmp = compareSerializedKeys(currKey, upperBound);
+ if (cmp > 0) {
+ break;
+ }
+ if (cmp == 0 && range.upperOpen) {
+ break;
+ }
+ }
+
+ // Now delete!
+
+ this._prep(sqlObjectDataDeleteKey).run({
+ object_store_id: scopeInfo.objectStoreId,
+ key: currKey,
+ });
+
+ for (const index of scopeInfo.indexMap.values()) {
+ let stmt: Sqlite3Statement;
+ if (index.unique) {
+ stmt = this._prep(sqlUniqueIndexDataDeleteKey);
+ } else {
+ stmt = this._prep(sqlIndexDataDeleteKey);
+ }
+ stmt.run({
+ index_id: index.indexId,
+ object_key: currKey,
+ });
+ }
+
+ currKey = this._continueObjectKey({
+ objectStoreId: scopeInfo.objectStoreId,
+ currentKey: null,
+ forward: true,
+ inclusive: false,
+ targetKey: currKey,
+ });
+ }
+ }
+
+ async storeRecord(
+ btx: DatabaseTransaction,
+ storeReq: RecordStoreRequest,
+ ): Promise<RecordStoreResponse> {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction not found");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ if (this.txLevel < TransactionLevel.Write) {
+ throw Error("store operation only allowed while running a transaction");
+ }
+ const scopeInfo = this.txScope.get(storeReq.objectStoreName);
+ if (!scopeInfo) {
+ throw Error(
+ `object store ${JSON.stringify(
+ storeReq.objectStoreName,
+ )} not in transaction scope`,
+ );
+ }
+ const metaRes = this._prep(sqlGetObjectStoreMetaById).getFirst({
+ id: scopeInfo.objectStoreId,
+ });
+ if (metaRes === undefined) {
+ throw Error(
+ `object store ${JSON.stringify(
+ storeReq.objectStoreName,
+ )} does not exist`,
+ );
+ }
+ assertDbInvariant(!!metaRes && typeof metaRes === "object");
+ assertDbInvariant("key_path" in metaRes);
+ assertDbInvariant("auto_increment" in metaRes);
+ const dbKeyPath = metaRes.key_path;
+ assertDbInvariant(dbKeyPath === null || typeof dbKeyPath === "string");
+ const keyPath = deserializeKeyPath(dbKeyPath);
+ const autoIncrement = metaRes.auto_increment;
+ assertDbInvariant(typeof autoIncrement === "number");
+
+ let key;
+ let value;
+ let updatedKeyGenerator: number | undefined;
+
+ if (storeReq.storeLevel === StoreLevel.UpdateExisting) {
+ if (storeReq.key == null) {
+ throw Error("invalid update request (key not given)");
+ }
+ key = storeReq.key;
+ value = storeReq.value;
+ } else {
+ if (keyPath != null && storeReq.key !== undefined) {
+ // If in-line keys are used, a key can't be explicitly specified.
+ throw new DataError();
+ }
+
+ const storeKeyResult = makeStoreKeyValue({
+ value: storeReq.value,
+ key: storeReq.key,
+ currentKeyGenerator: autoIncrement,
+ autoIncrement: autoIncrement != 0,
+ keyPath: keyPath,
+ });
+
+ if (autoIncrement != 0) {
+ updatedKeyGenerator = storeKeyResult.updatedKeyGenerator;
+ }
+
+ key = storeKeyResult.key;
+ value = storeKeyResult.value;
+ }
+
+ const serializedObjectKey = serializeKey(key);
+
+ const existingObj = this._getObjectValue(
+ scopeInfo.objectStoreId,
+ serializedObjectKey,
+ );
+
+ if (storeReq.storeLevel === StoreLevel.NoOverwrite) {
+ if (existingObj) {
+ throw new ConstraintError();
+ }
+ }
+
+ this._prep(sqlInsertObjectData).run({
+ object_store_id: scopeInfo.objectStoreId,
+ key: serializedObjectKey,
+ value: JSON.stringify(structuredEncapsulate(value)),
+ });
+
+ if (autoIncrement != 0) {
+ this._prep(sqlUpdateAutoIncrement).run({
+ object_store_id: scopeInfo.objectStoreId,
+ auto_increment: updatedKeyGenerator,
+ });
+ }
+
+ for (const [k, indexInfo] of scopeInfo.indexMap.entries()) {
+ if (existingObj) {
+ this.deleteFromIndex(
+ indexInfo.indexId,
+ indexInfo.unique,
+ serializedObjectKey,
+ );
+ }
+
+ try {
+ this.insertIntoIndex(indexInfo, serializedObjectKey, value);
+ } catch (e) {
+ // FIXME: handle this in insertIntoIndex!
+ if (e instanceof DataError) {
+ // We don't propagate this error here.
+ continue;
+ }
+ throw e;
+ }
+ }
+
+ if (this.trackStats) {
+ this.accessStats.writesPerStore[storeReq.objectStoreName] =
+ (this.accessStats.writesPerStore[storeReq.objectStoreName] ?? 0) + 1;
+ }
+
+ return {
+ key: key,
+ };
+ }
+
+ private deleteFromIndex(
+ indexId: SqliteRowid,
+ indexUnique: boolean,
+ objectKey: Uint8Array,
+ ): void {
+ let stmt: Sqlite3Statement;
+ if (indexUnique) {
+ stmt = this._prep(sqlUniqueIndexDataDeleteKey);
+ } else {
+ stmt = this._prep(sqlIndexDataDeleteKey);
+ }
+ stmt.run({
+ index_id: indexId,
+ object_key: objectKey,
+ });
+ }
+
+ private insertIntoIndex(
+ indexInfo: ScopeIndexInfo,
+ primaryKey: Uint8Array,
+ value: any,
+ ): void {
+ const indexKeys = getIndexKeys(
+ value,
+ indexInfo.keyPath,
+ indexInfo.multiEntry,
+ );
+ if (!indexKeys.length) {
+ return;
+ }
+
+ let stmt;
+ if (indexInfo.unique) {
+ stmt = this._prep(sqlInsertUniqueIndexData);
+ } else {
+ stmt = this._prep(sqlInsertIndexData);
+ }
+
+ for (const indexKey of indexKeys) {
+ // FIXME: Re-throw correct error for unique index violations
+ const serializedIndexKey = serializeKey(indexKey);
+ try {
+ stmt.run({
+ index_id: indexInfo.indexId,
+ object_key: primaryKey,
+ index_key: serializedIndexKey,
+ });
+ } catch (e: any) {
+ if (e.code === SqliteError.constraintPrimarykey) {
+ throw new ConstraintError();
+ }
+ throw e;
+ }
+ }
+ }
+
+ async clearObjectStore(
+ btx: DatabaseTransaction,
+ objectStoreName: string,
+ ): Promise<void> {
+ const txInfo = this.transactionMap.get(btx.transactionCookie);
+ if (!txInfo) {
+ throw Error("transaction not found");
+ }
+ const connInfo = this.connectionMap.get(txInfo.connectionCookie);
+ if (!connInfo) {
+ throw Error("connection not found");
+ }
+ if (this.txLevel < TransactionLevel.Write) {
+ throw Error("store operation only allowed while running a transaction");
+ }
+ const scopeInfo = this.txScope.get(objectStoreName);
+ if (!scopeInfo) {
+ throw Error(
+ `object store ${JSON.stringify(
+ objectStoreName,
+ )} not in transaction scope`,
+ );
+ }
+
+ this._prep(sqlClearObjectStore).run({
+ object_store_id: scopeInfo.objectStoreId,
+ });
+
+ for (const index of scopeInfo.indexMap.values()) {
+ let stmt: Sqlite3Statement;
+ if (index.unique) {
+ stmt = this._prep(sqlClearUniqueIndexData);
+ } else {
+ stmt = this._prep(sqlClearIndexData);
+ }
+ stmt.run({
+ index_id: index.indexId,
+ });
+ }
+ }
+}
+
+const schemaSql = `
+CREATE TABLE IF NOT EXISTS databases
+( name TEXT PRIMARY KEY
+, version INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS object_stores
+( id INTEGER PRIMARY KEY
+, database_name NOT NULL
+, name TEXT NOT NULL
+, key_path TEXT
+, auto_increment INTEGER NOT NULL DEFAULT 0
+, FOREIGN KEY (database_name)
+ REFERENCES databases(name)
+);
+
+CREATE TABLE IF NOT EXISTS indexes
+( id INTEGER PRIMARY KEY
+, object_store_id INTEGER NOT NULL
+, name TEXT NOT NULL
+, key_path TEXT NOT NULL
+, unique_index INTEGER NOT NULL
+, multientry INTEGER NOT NULL
+, FOREIGN KEY (object_store_id)
+ REFERENCES object_stores(id)
+);
+
+CREATE TABLE IF NOT EXISTS object_data
+( object_store_id INTEGER NOT NULL
+, key BLOB NOT NULL
+, value TEXT NOT NULL
+, PRIMARY KEY (object_store_id, key)
+);
+
+CREATE TABLE IF NOT EXISTS index_data
+( index_id INTEGER NOT NULL
+, index_key BLOB NOT NULL
+, object_key BLOB NOT NULL
+, PRIMARY KEY (index_id, index_key, object_key)
+, FOREIGN KEY (index_id)
+ REFERENCES indexes(id)
+);
+
+CREATE TABLE IF NOT EXISTS unique_index_data
+( index_id INTEGER NOT NULL
+, index_key BLOB NOT NULL
+, object_key BLOB NOT NULL
+, PRIMARY KEY (index_id, index_key)
+, FOREIGN KEY (index_id)
+ REFERENCES indexes(id)
+);
+`;
+
+const sqlClearObjectStore = `
+DELETE FROM object_data WHERE object_store_id=$object_store_id`;
+
+const sqlClearIndexData = `
+DELETE FROM index_data WHERE index_id=$index_id`;
+
+const sqlClearUniqueIndexData = `
+DELETE FROM unique_index_data WHERE index_id=$index_id`;
+
+const sqlListDatabases = `
+SELECT name, version FROM databases;
+`;
+
+const sqlGetDatabaseVersion = `
+SELECT version FROM databases WHERE name=$name;
+`;
+
+const sqlBegin = `BEGIN;`;
+const sqlCommit = `COMMIT;`;
+const sqlRollback = `ROLLBACK;`;
+
+const sqlCreateDatabase = `
+INSERT INTO databases (name, version) VALUES ($name, 1);
+`;
+
+const sqlDeleteDatabase = `
+DELETE FROM databases
+WHERE name=$name;
+`;
+
+const sqlCreateObjectStore = `
+INSERT INTO object_stores (name, database_name, key_path, auto_increment)
+ VALUES ($name, $database_name, $key_path, $auto_increment);
+`;
+
+const sqlObjectStoreDelete = `
+DELETE FROM object_stores
+WHERE id=$object_store_id;`;
+
+const sqlObjectDataDeleteAll = `
+DELETE FROM object_data
+WHERE object_store_id=$object_store_id`;
+
+const sqlIndexDelete = `
+DELETE FROM indexes
+WHERE id=$index_id;
+`;
+
+const sqlIndexDataDeleteAll = `
+DELETE FROM index_data
+WHERE index_id=$index_id;
+`;
+
+const sqlIUniqueIndexDataDeleteAll = `
+DELETE FROM unique_index_data
+WHERE index_id=$index_id;
+`;
+
+const sqlCreateIndex = `
+INSERT INTO indexes (object_store_id, name, key_path, unique_index, multientry)
+ VALUES ($object_store_id, $name, $key_path, $unique, $multientry);
+`;
+
+const sqlInsertIndexData = `
+INSERT INTO index_data (index_id, object_key, index_key)
+ VALUES ($index_id, $object_key, $index_key);`;
+
+const sqlInsertUniqueIndexData = `
+INSERT INTO unique_index_data (index_id, object_key, index_key)
+ VALUES ($index_id, $object_key, $index_key);`;
+
+const sqlUpdateDbVersion = `
+UPDATE databases
+ SET version=$version
+ WHERE name=$name;
+`;
+
+const sqlRenameObjectStore = `
+UPDATE object_stores
+ SET name=$name
+ WHERE id=$object_store_id`;
+
+const sqlRenameIndex = `
+UPDATE indexes
+ SET name=$name
+ WHERE index_id=$index_id`;
+
+const sqlGetObjectStoresByDatabase = `
+SELECT id, name, key_path, auto_increment
+FROM object_stores
+WHERE database_name=$database_name;
+`;
+
+const sqlGetObjectStoreMetaById = `
+SELECT key_path, auto_increment
+FROM object_stores
+WHERE id = $id;
+`;
+
+const sqlGetObjectStoreMetaByName = `
+SELECT id, key_path, auto_increment
+FROM object_stores
+WHERE database_name=$database_name AND name=$name;
+`;
+
+const sqlGetIndexesByObjectStoreId = `
+SELECT id, name, key_path, unique_index, multientry
+FROM indexes
+WHERE object_store_id=$object_store_id
+`;
+
+const sqlGetIndexByName = `
+SELECT id, key_path, unique_index, multientry
+FROM indexes
+WHERE object_store_id=$object_store_id
+ AND name=$name
+`;
+
+const sqlInsertObjectData = `
+INSERT OR REPLACE INTO object_data(object_store_id, key, value)
+ VALUES ($object_store_id, $key, $value);
+`;
+
+const sqlUpdateAutoIncrement = `
+UPDATE object_stores
+ SET auto_increment=$auto_increment
+ WHERE id=$object_store_id
+`;
+
+const sqlObjectDataValueFromKey = `
+SELECT value FROM object_data
+ WHERE object_store_id=$object_store_id
+ AND key=$key;
+`;
+
+const sqlObjectDataGetAll = `
+SELECT key, value FROM object_data
+ WHERE object_store_id=$object_store_id;`;
+
+const sqlObjectDataStartForward = `
+SELECT min(key) as rkey FROM object_data
+ WHERE object_store_id=$object_store_id;`;
+
+const sqlObjectDataStartBackward = `
+SELECT max(key) as rkey FROM object_data
+ WHERE object_store_id=$object_store_id;`;
+
+const sqlObjectDataContinueForward = `
+SELECT min(key) as rkey FROM object_data
+ WHERE object_store_id=$object_store_id
+ AND key > $x;`;
+
+const sqlObjectDataContinueBackward = `
+SELECT max(key) as rkey FROM object_data
+ WHERE object_store_id=$object_store_id
+ AND key < $x;`;
+
+const sqlObjectDataContinueForwardInclusive = `
+SELECT min(key) as rkey FROM object_data
+ WHERE object_store_id=$object_store_id
+ AND key >= $x;`;
+
+const sqlObjectDataContinueBackwardInclusive = `
+SELECT max(key) as rkey FROM object_data
+ WHERE object_store_id=$object_store_id
+ AND key <= $x;`;
+
+const sqlObjectDataDeleteKey = `
+DELETE FROM object_data
+ WHERE object_store_id=$object_store_id AND
+ key=$key`;
+
+const sqlIndexDataDeleteKey = `
+DELETE FROM index_data
+ WHERE index_id=$index_id AND
+ object_key=$object_key;
+`;
+
+const sqlUniqueIndexDataDeleteKey = `
+DELETE FROM unique_index_data
+ WHERE index_id=$index_id AND
+ object_key=$object_key;
+`;
+
+// "next" or "nextunique" on a non-unique index
+const sqlIndexDataStartForward = `
+SELECT index_key, object_key FROM index_data
+ WHERE index_id=$index_id
+ ORDER BY index_key, object_key
+ LIMIT 1;
+`;
+
+// start a "next" or "nextunique" on a unique index
+const sqlUniqueIndexDataStartForward = `
+SELECT index_key, object_key FROM unique_index_data
+ WHERE index_id=$index_id
+ ORDER BY index_key, object_key
+ LIMIT 1;
+`;
+
+// start a "prev" or "prevunique" on a unique index
+const sqlUniqueIndexDataStartBackward = `
+SELECT index_key, object_key FROM unique_index_data
+ WHERE index_id=$index_id
+ ORDER BY index_key DESC, object_key DESC
+ LIMIT 1
+`;
+
+// start a "prevunique" query on a non-unique index
+const sqlIndexDataStartBackwardUnique = `
+SELECT index_key, object_key FROM index_data
+ WHERE index_id=$index_id
+ ORDER BY index_key DESC, object_key ASC
+ LIMIT 1
+`;
+
+// start a "prev" query on a non-unique index
+const sqlIndexDataStartBackward = `
+SELECT index_key, object_key FROM index_data
+ WHERE index_id=$index_id
+ ORDER BY index_key DESC, object_key DESC
+ LIMIT 1
+`;
+
+// continue a "next" query, strictly go to a further key
+const sqlIndexDataContinueForwardStrict = `
+SELECT index_key, object_key FROM index_data
+ WHERE
+ index_id=$index_id AND
+ ((index_key = $index_key AND object_key > $object_key) OR
+ (index_key > $index_key))
+ ORDER BY index_key, object_key
+ LIMIT 1;
+`;
+
+// continue a "next" query, go to at least the specified key
+const sqlIndexDataContinueForwardInclusive = `
+SELECT index_key, object_key FROM index_data
+ WHERE
+ index_id=$index_id AND
+ ((index_key = $index_key AND object_key >= $object_key) OR
+ (index_key > $index_key))
+ ORDER BY index_key, object_key
+ LIMIT 1;
+`;
+
+// continue a "prev" query
+const sqlIndexDataContinueBackwardStrict = `
+SELECT index_key, object_key FROM index_data
+ WHERE
+ index_id=$index_id AND
+ ((index_key = $index_key AND object_key < $object_key) OR
+ (index_key < $index_key))
+ ORDER BY index_key DESC, object_key DESC
+ LIMIT 1;
+`;
+
+// continue a "prev" query
+const sqlIndexDataContinueBackwardInclusive = `
+SELECT index_key, object_key FROM index_data
+ WHERE
+ index_id=$index_id AND
+ ((index_key = $index_key AND object_key <= $object_key) OR
+ (index_key < $index_key))
+ ORDER BY index_key DESC, object_key DESC
+ LIMIT 1;
+`;
+
+// continue a "prevunique" query
+const sqlIndexDataContinueBackwardStrictUnique = `
+SELECT index_key, object_key FROM index_data
+ WHERE index_id=$index_id AND index_key < $index_key
+ ORDER BY index_key DESC, object_key ASC
+ LIMIT 1;
+`;
+
+// continue a "prevunique" query
+const sqlIndexDataContinueBackwardInclusiveUnique = `
+SELECT index_key, object_key FROM index_data
+ WHERE index_id=$index_id AND index_key <= $index_key
+ ORDER BY index_key DESC, object_key ASC
+ LIMIT 1;
+`;
+
+// continue a "next" query, no target object key
+const sqlIndexDataContinueForwardStrictUnique = `
+SELECT index_key, object_key FROM index_data
+ WHERE index_id=$index_id AND index_key > $index_key
+ ORDER BY index_key, object_key
+ LIMIT 1;
+`;
+
+// continue a "next" query, no target object key
+const sqlIndexDataContinueForwardInclusiveUnique = `
+SELECT index_key, object_key FROM index_data
+ WHERE index_id=$index_id AND index_key >= $index_key
+ ORDER BY index_key, object_key
+ LIMIT 1;
+`;
+
+// continue a "next" query, strictly go to a further key
+const sqlUniqueIndexDataContinueForwardStrict = `
+SELECT index_key, object_key FROM unique_index_data
+ WHERE index_id=$index_id AND index_key > $index_key
+ ORDER BY index_key, object_key
+ LIMIT 1;
+`;
+
+// continue a "next" query, go to at least the specified key
+const sqlUniqueIndexDataContinueForwardInclusive = `
+SELECT index_key, object_key FROM unique_index_data
+ WHERE index_id=$index_id AND index_key >= $index_key
+ ORDER BY index_key, object_key
+ LIMIT 1;
+`;
+
+// continue a "prev" query
+const sqlUniqueIndexDataContinueBackwardStrict = `
+SELECT index_key, object_key FROM unique_index_data
+ WHERE index_id=$index_id AND index_key < $index_key
+ ORDER BY index_key, object_key
+ LIMIT 1;
+`;
+
+// continue a "prev" query
+const sqlUniqueIndexDataContinueBackwardInclusive = `
+SELECT index_key, object_key FROM unique_index_data
+ WHERE index_id=$index_id AND index_key <= $index_key
+ ORDER BY index_key DESC, object_key DESC
+ LIMIT 1;
+`;
+
+export interface SqliteBackendOptions {
+ filename: string;
+}
+
+export async function createSqliteBackend(
+ sqliteImpl: Sqlite3Interface,
+ options: SqliteBackendOptions,
+): Promise<SqliteBackend> {
+ const db = sqliteImpl.open(options.filename);
+ db.exec("PRAGMA foreign_keys = ON;");
+ db.exec(schemaSql);
+ return new SqliteBackend(sqliteImpl, db);
+}
diff --git a/packages/idb-bridge/src/backend-common.ts b/packages/idb-bridge/src/backend-common.ts
new file mode 100644
index 000000000..d52071939
--- /dev/null
+++ b/packages/idb-bridge/src/backend-common.ts
@@ -0,0 +1,29 @@
+import { openPromise } from "./util/openPromise.js";
+
+export class AsyncCondition {
+ _waitPromise: Promise<void>;
+ _resolveWaitPromise: () => void;
+ constructor() {
+ const op = openPromise<void>();
+ this._waitPromise = op.promise;
+ this._resolveWaitPromise = op.resolve;
+ }
+
+ wait(): Promise<void> {
+ return this._waitPromise;
+ }
+
+ trigger(): void {
+ this._resolveWaitPromise();
+ const op = openPromise<void>();
+ this._waitPromise = op.promise;
+ this._resolveWaitPromise = op.resolve;
+ }
+}
+
+export enum TransactionLevel {
+ None = 0,
+ Read = 1,
+ Write = 2,
+ VersionChange = 3,
+}
diff --git a/packages/idb-bridge/src/backend-interface.ts b/packages/idb-bridge/src/backend-interface.ts
index ae43c9df7..690f92f54 100644
--- a/packages/idb-bridge/src/backend-interface.ts
+++ b/packages/idb-bridge/src/backend-interface.ts
@@ -14,73 +14,52 @@
permissions and limitations under the License.
*/
-import { BridgeIDBDatabaseInfo, BridgeIDBKeyRange } from "./bridge-idb";
+import { BridgeIDBDatabaseInfo, BridgeIDBKeyRange } from "./bridge-idb.js";
import {
IDBCursorDirection,
IDBTransactionMode,
IDBValidKey,
-} from "./idbtypes";
+} from "./idbtypes.js";
-/** @public */
-export interface ObjectStoreProperties {
- keyPath: string[] | null;
- autoIncrement: boolean;
- indexes: { [nameame: string]: IndexProperties };
-}
-
-/** @public */
-export interface IndexProperties {
- keyPath: string[];
- multiEntry: boolean;
- unique: boolean;
-}
-
-/** @public */
-export interface Schema {
- databaseName: string;
- databaseVersion: number;
- objectStores: { [name: string]: ObjectStoreProperties };
+export interface ConnectResult {
+ conn: DatabaseConnection;
+ version: number;
+ objectStores: string[];
}
-/** @public */
export interface DatabaseConnection {
connectionCookie: string;
}
-/** @public */
export interface DatabaseTransaction {
transactionCookie: string;
}
-/** @public */
export enum ResultLevel {
OnlyCount,
OnlyKeys,
Full,
}
-/** @public */
export enum StoreLevel {
NoOverwrite,
AllowOverwrite,
UpdateExisting,
}
-/** @public */
-export interface RecordGetRequest {
+
+export interface IndexGetQuery {
direction: IDBCursorDirection;
objectStoreName: string;
- indexName: string | undefined;
+ indexName: string;
/**
* The range of keys to return.
- * If indexName is defined, the range refers to the index keys.
- * Otherwise it refers to the object store keys.
+ * The range refers to the index keys.
*/
range: BridgeIDBKeyRange | undefined | null;
/**
* Last cursor position in terms of the index key.
- * Can only be specified if indexName is defined and
- * lastObjectStorePosition is defined.
+ * Can only be specified if lastObjectStorePosition is defined.
*
* Must either be undefined or within range.
*/
@@ -92,8 +71,6 @@ export interface RecordGetRequest {
/**
* If specified, the index key of the results must be
* greater or equal to advanceIndexKey.
- *
- * Only applicable if indexName is specified.
*/
advanceIndexKey?: IDBValidKey;
/**
@@ -103,13 +80,37 @@ export interface RecordGetRequest {
advancePrimaryKey?: IDBValidKey;
/**
* Maximum number of results to return.
- * If -1, return all available results
+ * If 0, return all available results
+ */
+ limit: number;
+ resultLevel: ResultLevel;
+}
+
+export interface ObjectStoreGetQuery {
+ direction: IDBCursorDirection;
+ objectStoreName: string;
+ /**
+ * The range of keys to return.
+ * Refers to the object store keys.
+ */
+ range: BridgeIDBKeyRange | undefined | null;
+ /**
+ * Last position in terms of the object store key.
+ */
+ lastObjectStorePosition?: IDBValidKey;
+ /**
+ * If specified, the primary key of the results must be greater
+ * or equal to advancePrimaryKey.
+ */
+ advancePrimaryKey?: IDBValidKey;
+ /**
+ * Maximum number of results to return.
+ * If 0, return all available results
*/
limit: number;
resultLevel: ResultLevel;
}
-/** @public */
export interface RecordGetResponse {
values: any[] | undefined;
indexKeys: IDBValidKey[] | undefined;
@@ -117,7 +118,6 @@ export interface RecordGetResponse {
count: number;
}
-/** @public */
export interface RecordStoreRequest {
objectStoreName: string;
value: any;
@@ -125,7 +125,6 @@ export interface RecordStoreRequest {
storeLevel: StoreLevel;
}
-/** @public */
export interface RecordStoreResponse {
/**
* Key that the record was stored under in the object store.
@@ -133,38 +132,79 @@ export interface RecordStoreResponse {
key: IDBValidKey;
}
-/** @public */
+export interface ObjectStoreMeta {
+ indexSet: string[];
+ keyPath: string | string[] | null;
+ autoIncrement: boolean;
+}
+
+export interface IndexMeta {
+ keyPath: string | string[];
+ multiEntry: boolean;
+ unique: boolean;
+}
+
+// FIXME: Instead of referring to an object store by name,
+// maybe refer to it via some internal, numeric ID?
+// This would simplify renaming.
export interface Backend {
getDatabases(): Promise<BridgeIDBDatabaseInfo[]>;
- connectDatabase(name: string): Promise<DatabaseConnection>;
+ connectDatabase(name: string): Promise<ConnectResult>;
beginTransaction(
- conn: DatabaseConnection,
+ dbConn: DatabaseConnection,
objectStores: string[],
mode: IDBTransactionMode,
): Promise<DatabaseTransaction>;
enterVersionChange(
- conn: DatabaseConnection,
+ dbConn: DatabaseConnection,
newVersion: number,
): Promise<DatabaseTransaction>;
deleteDatabase(name: string): Promise<void>;
- close(db: DatabaseConnection): Promise<void>;
+ close(dbConn: DatabaseConnection): Promise<void>;
- getSchema(db: DatabaseConnection): Schema;
+ // FIXME: Use this for connection
+ // prepareConnect() - acquires a lock, maybe enters a version change transaction?
+ // finishConnect() - after possible versionchange is done, allow others to connect
- getCurrentTransactionSchema(btx: DatabaseTransaction): Schema;
+ /**
+ * Get metadata for an object store.
+ *
+ * When dbConn is running a version change transaction,
+ * the current schema (and not the initial schema) is returned.
+ *
+ * Caller may mutate the result, a new object
+ * is returned on each call.
+ */
+ getObjectStoreMeta(
+ dbConn: DatabaseConnection,
+ objectStoreName: string,
+ ): ObjectStoreMeta | undefined;
- getInitialTransactionSchema(btx: DatabaseTransaction): Schema;
+ /**
+ * Get metadata for an index.
+ *
+ * When dbConn is running a version change transaction,
+ * the current schema (and not the initial schema) is returned.
+ *
+ * Caller may mutate the result, a new object
+ * is returned on each call.
+ */
+ getIndexMeta(
+ dbConn: DatabaseConnection,
+ objectStoreName: string,
+ indexName: string,
+ ): IndexMeta | undefined;
renameIndex(
btx: DatabaseTransaction,
objectStoreName: string,
- oldName: string,
- newName: string,
+ oldIndexName: string,
+ newIndexName: string,
): void;
deleteIndex(
@@ -173,8 +213,9 @@ export interface Backend {
indexName: string,
): void;
- rollback(btx: DatabaseTransaction): Promise<void>;
+ rollback(btx: DatabaseTransaction): void;
+ // FIXME: Should probably not be async
commit(btx: DatabaseTransaction): Promise<void>;
deleteObjectStore(btx: DatabaseTransaction, name: string): void;
@@ -207,9 +248,14 @@ export interface Backend {
range: BridgeIDBKeyRange,
): Promise<void>;
- getRecords(
+ getObjectStoreRecords(
+ btx: DatabaseTransaction,
+ req: ObjectStoreGetQuery,
+ ): Promise<RecordGetResponse>;
+
+ getIndexRecords(
btx: DatabaseTransaction,
- req: RecordGetRequest,
+ req: IndexGetQuery,
): Promise<RecordGetResponse>;
storeRecord(
diff --git a/packages/idb-bridge/src/backends.test.ts b/packages/idb-bridge/src/backends.test.ts
new file mode 100644
index 000000000..684358eac
--- /dev/null
+++ b/packages/idb-bridge/src/backends.test.ts
@@ -0,0 +1,740 @@
+/*
+ Copyright 2019 Florian Dold
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ or implied. See the License for the specific language governing
+ permissions and limitations under the License.
+ */
+
+/**
+ * Tests that are backend-generic.
+ * See testingdb.ts for the backend selection in test runs.
+ */
+
+/**
+ * Imports.
+ */
+import test from "ava";
+import {
+ BridgeIDBCursorWithValue,
+ BridgeIDBDatabase,
+ BridgeIDBFactory,
+ BridgeIDBKeyRange,
+ BridgeIDBTransaction,
+} from "./bridge-idb.js";
+import {
+ IDBCursorDirection,
+ IDBCursorWithValue,
+ IDBDatabase,
+ IDBKeyRange,
+ IDBRequest,
+ IDBValidKey,
+} from "./idbtypes.js";
+import { initTestIndexedDB, useTestIndexedDb } from "./testingdb.js";
+import { MemoryBackend } from "./MemoryBackend.js";
+import { promiseFromRequest, promiseFromTransaction } from "./idbpromutil.js";
+
+test.before("test DB initialization", initTestIndexedDB);
+
+test("Spec: Example 1 Part 1", async (t) => {
+ const idb = useTestIndexedDb();
+
+ const dbname = "library-" + new Date().getTime() + Math.random();
+
+ const request = idb.open(dbname);
+ request.onupgradeneeded = () => {
+ const db = request.result as BridgeIDBDatabase;
+ const store = db.createObjectStore("books", { keyPath: "isbn" });
+ const titleIndex = store.createIndex("by_title", "title", { unique: true });
+ const authorIndex = store.createIndex("by_author", "author");
+
+ // Populate with initial data.
+ store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
+ store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
+ store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
+ };
+
+ await promiseFromRequest(request);
+ t.pass();
+});
+
+test("Spec: Example 1 Part 2", async (t) => {
+ const idb = useTestIndexedDb();
+
+ const dbname = "library-" + new Date().getTime() + Math.random();
+
+ const request = idb.open(dbname);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ const store = db.createObjectStore("books", { keyPath: "isbn" });
+ const titleIndex = store.createIndex("by_title", "title", { unique: true });
+ const authorIndex = store.createIndex("by_author", "author");
+ };
+
+ const db: BridgeIDBDatabase = await promiseFromRequest(request);
+
+ t.is(db.name, dbname);
+
+ const tx = db.transaction("books", "readwrite");
+ tx.oncomplete = () => {
+ console.log("oncomplete called");
+ };
+
+ const store = tx.objectStore("books");
+
+ store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
+ store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
+ store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
+
+ await promiseFromTransaction(tx);
+
+ t.pass();
+});
+
+test("duplicate index insertion", async (t) => {
+ const idb = useTestIndexedDb();
+
+ const dbname = "library-" + new Date().getTime() + Math.random();
+
+ const request = idb.open(dbname);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ const store = db.createObjectStore("books", { keyPath: "isbn" });
+ const titleIndex = store.createIndex("by_title", "title", { unique: true });
+ const authorIndex = store.createIndex("by_author", "author");
+ };
+
+ const db: BridgeIDBDatabase = await promiseFromRequest(request);
+
+ t.is(db.name, dbname);
+
+ const tx = db.transaction("books", "readwrite");
+ tx.oncomplete = () => {
+ console.log("oncomplete called");
+ };
+
+ const store = tx.objectStore("books");
+
+ store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
+
+ // Change the index key, keep primary key (isbn) the same.
+ store.put({ title: "Water Buffaloes", author: "Bla", isbn: 234567 });
+ store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
+
+ store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
+
+ await promiseFromTransaction(tx);
+
+ const tx3 = db.transaction(["books"], "readonly");
+ const store3 = tx3.objectStore("books");
+ const index3 = store3.index("by_author");
+ const request3 = index3.openCursor();
+
+ const authorList: string[] = [];
+
+ await promiseFromRequest(request3);
+ while (request3.result != null) {
+ const cursor: IDBCursorWithValue = request3.result;
+ authorList.push(cursor.value.author);
+ cursor.continue();
+ await promiseFromRequest(request3);
+ }
+
+ t.deepEqual(authorList, ["Barney", "Fred", "Fred"]);
+
+ t.pass();
+});
+
+test("simple index iteration", async (t) => {
+ const idb = useTestIndexedDb();
+ const dbname = "library-" + new Date().getTime() + Math.random();
+ const request = idb.open(dbname);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ const store = db.createObjectStore("books", { keyPath: "isbn" });
+ const titleIndex = store.createIndex("by_title", "title", { unique: true });
+ const authorIndex = store.createIndex("by_author", "author");
+ };
+
+ const db: BridgeIDBDatabase = await promiseFromRequest(request);
+ const tx = db.transaction("books", "readwrite");
+ const store = tx.objectStore("books");
+
+ store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
+ store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
+ store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
+
+ await promiseFromTransaction(tx);
+
+ const tx3 = db.transaction(["books"], "readonly");
+ const store3 = tx3.objectStore("books");
+ const index3 = store3.index("by_author");
+ const request3 = index3.openCursor(BridgeIDBKeyRange.only("Fred"));
+
+ await promiseFromRequest(request3);
+
+ let cursor: BridgeIDBCursorWithValue | null;
+ cursor = request3.result as BridgeIDBCursorWithValue;
+ t.is(cursor.value.author, "Fred");
+ t.is(cursor.value.isbn, 123456);
+
+ cursor.continue();
+
+ await promiseFromRequest(request3);
+
+ t.is(cursor.value.author, "Fred");
+ t.is(cursor.value.isbn, 234567);
+
+ cursor.continue();
+
+ await promiseFromRequest(request3);
+
+ t.is(cursor.value, undefined);
+});
+
+test("Spec: Example 1 Part 3", async (t) => {
+ const idb = useTestIndexedDb();
+ const dbname = "library-" + new Date().getTime() + Math.random();
+ const request = idb.open(dbname);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ const store = db.createObjectStore("books", { keyPath: "isbn" });
+ const titleIndex = store.createIndex("by_title", "title", { unique: true });
+ const authorIndex = store.createIndex("by_author", "author");
+ };
+
+ const db: BridgeIDBDatabase = await promiseFromRequest(request);
+
+ t.is(db.name, dbname);
+
+ const tx = db.transaction("books", "readwrite");
+
+ const store = tx.objectStore("books");
+
+ store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
+ store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
+ store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
+
+ await promiseFromTransaction(tx);
+
+ const tx2 = db.transaction("books", "readonly");
+ const store2 = tx2.objectStore("books");
+ var index2 = store2.index("by_title");
+ const request2 = index2.get("Bedrock Nights");
+ const result2: any = await promiseFromRequest(request2);
+
+ t.is(result2.author, "Barney");
+
+ const tx3 = db.transaction(["books"], "readonly");
+ const store3 = tx3.objectStore("books");
+ const index3 = store3.index("by_author");
+ const request3 = index3.openCursor(BridgeIDBKeyRange.only("Fred"));
+
+ await promiseFromRequest(request3);
+
+ let cursor: BridgeIDBCursorWithValue | null;
+ cursor = request3.result as BridgeIDBCursorWithValue;
+ t.is(cursor.value.author, "Fred");
+ t.is(cursor.value.isbn, 123456);
+
+ cursor.continue();
+
+ await promiseFromRequest(request3);
+
+ cursor = request3.result as BridgeIDBCursorWithValue;
+ t.is(cursor.value.author, "Fred");
+ t.is(cursor.value.isbn, 234567);
+
+ await promiseFromTransaction(tx3);
+
+ const tx4 = db.transaction("books", "readonly");
+ const store4 = tx4.objectStore("books");
+ const request4 = store4.openCursor();
+
+ await promiseFromRequest(request4);
+
+ cursor = request4.result;
+ if (!cursor) {
+ throw new Error();
+ }
+ t.is(cursor.value.isbn, 123456);
+
+ cursor.continue();
+
+ await promiseFromRequest(request4);
+
+ cursor = request4.result;
+ if (!cursor) {
+ throw new Error();
+ }
+ t.is(cursor.value.isbn, 234567);
+
+ cursor.continue();
+
+ await promiseFromRequest(request4);
+
+ cursor = request4.result;
+ if (!cursor) {
+ throw new Error();
+ }
+ t.is(cursor.value.isbn, 345678);
+
+ cursor.continue();
+ await promiseFromRequest(request4);
+
+ cursor = request4.result;
+
+ t.is(cursor, null);
+
+ const tx5 = db.transaction("books", "readonly");
+ const store5 = tx5.objectStore("books");
+ const index5 = store5.index("by_author");
+
+ const request5 = index5.openCursor(null, "next");
+
+ await promiseFromRequest(request5);
+ cursor = request5.result;
+ if (!cursor) {
+ throw new Error();
+ }
+ t.is(cursor.value.author, "Barney");
+ cursor.continue();
+
+ await promiseFromRequest(request5);
+ cursor = request5.result;
+ if (!cursor) {
+ throw new Error();
+ }
+ t.is(cursor.value.author, "Fred");
+ cursor.continue();
+
+ await promiseFromRequest(request5);
+ cursor = request5.result;
+ if (!cursor) {
+ throw new Error();
+ }
+ t.is(cursor.value.author, "Fred");
+ cursor.continue();
+
+ await promiseFromRequest(request5);
+ cursor = request5.result;
+ t.is(cursor, null);
+
+ const request6 = index5.openCursor(null, "nextunique");
+
+ await promiseFromRequest(request6);
+ cursor = request6.result;
+ if (!cursor) {
+ throw new Error();
+ }
+ t.is(cursor.value.author, "Barney");
+ cursor.continue();
+
+ await promiseFromRequest(request6);
+ cursor = request6.result;
+ if (!cursor) {
+ throw new Error();
+ }
+ t.is(cursor.value.author, "Fred");
+ t.is(cursor.value.isbn, 123456);
+ cursor.continue();
+
+ await promiseFromRequest(request6);
+ cursor = request6.result;
+ t.is(cursor, null);
+
+ console.log("---------------------------");
+
+ const request7 = index5.openCursor(null, "prevunique");
+ await promiseFromRequest(request7);
+ cursor = request7.result;
+ if (!cursor) {
+ throw new Error();
+ }
+ t.is(cursor.value.author, "Fred");
+ t.is(cursor.value.isbn, 123456);
+ cursor.continue();
+
+ await promiseFromRequest(request7);
+ cursor = request7.result;
+ if (!cursor) {
+ throw new Error();
+ }
+ t.is(cursor.value.author, "Barney");
+ cursor.continue();
+
+ await promiseFromRequest(request7);
+ cursor = request7.result;
+ t.is(cursor, null);
+
+ db.close();
+
+ t.pass();
+});
+
+test("simple deletion", async (t) => {
+ const idb = useTestIndexedDb();
+ const dbname = "library-" + new Date().getTime() + Math.random();
+ const request = idb.open(dbname);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ const store = db.createObjectStore("books", { keyPath: "isbn" });
+ const titleIndex = store.createIndex("by_title", "title", { unique: true });
+ const authorIndex = store.createIndex("by_author", "author");
+ };
+
+ const db: BridgeIDBDatabase = await promiseFromRequest(request);
+ const tx = db.transaction("books", "readwrite");
+ tx.oncomplete = () => {
+ console.log("oncomplete called");
+ };
+
+ const store = tx.objectStore("books");
+
+ store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
+ store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
+ store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
+
+ await promiseFromTransaction(tx);
+
+ const tx2 = db.transaction("books", "readwrite");
+
+ const store2 = tx2.objectStore("books");
+
+ const req1 = store2.get(234567);
+ await promiseFromRequest(req1);
+ t.is(req1.readyState, "done");
+ t.is(req1.result.author, "Fred");
+
+ store2.delete(123456);
+
+ const req2 = store2.get(123456);
+ await promiseFromRequest(req2);
+ t.is(req2.readyState, "done");
+ t.is(req2.result, undefined);
+
+ const req3 = store2.get(234567);
+ await promiseFromRequest(req3);
+ t.is(req3.readyState, "done");
+ t.is(req3.result.author, "Fred");
+
+ await promiseFromTransaction(tx2);
+
+ t.pass();
+});
+
+test("export", async (t) => {
+ const backend = new MemoryBackend();
+ const idb = new BridgeIDBFactory(backend);
+ const dbname = "library-" + new Date().getTime() + Math.random();
+ const request = idb.open(dbname, 42);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ const store = db.createObjectStore("books", { keyPath: "isbn" });
+ const titleIndex = store.createIndex("by_title", "title", { unique: true });
+ const authorIndex = store.createIndex("by_author", "author");
+ };
+
+ const db: BridgeIDBDatabase = await promiseFromRequest(request);
+
+ const tx = db.transaction("books", "readwrite");
+ tx.oncomplete = () => {
+ console.log("oncomplete called");
+ };
+
+ const store = tx.objectStore("books");
+
+ store.put({ title: "Quarry Memories", author: "Fred", isbn: 123456 });
+ store.put({ title: "Water Buffaloes", author: "Fred", isbn: 234567 });
+ store.put({ title: "Bedrock Nights", author: "Barney", isbn: 345678 });
+
+ await promiseFromTransaction(tx);
+
+ const exportedData = backend.exportDump();
+ const backend2 = new MemoryBackend();
+ backend2.importDump(exportedData);
+ const exportedData2 = backend2.exportDump();
+
+ t.assert(
+ exportedData.databases[dbname].objectStores["books"].records.length ===
+ 3,
+ );
+ t.deepEqual(exportedData, exportedData2);
+
+ t.is(exportedData.databases[dbname].schema.databaseVersion, 42);
+ t.is(exportedData2.databases[dbname].schema.databaseVersion, 42);
+ t.pass();
+});
+
+test("update with non-existent index values", async (t) => {
+ const idb = useTestIndexedDb();
+ const dbname = "mydb-" + new Date().getTime() + Math.random();
+ const request = idb.open(dbname);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ const store = db.createObjectStore("bla", { keyPath: "x" });
+ store.createIndex("by_y", "y");
+ store.createIndex("by_z", "z");
+ };
+
+ const db: BridgeIDBDatabase = await promiseFromRequest(request);
+
+ t.is(db.name, dbname);
+
+ {
+ const tx = db.transaction("bla", "readwrite");
+ const store = tx.objectStore("bla");
+ store.put({ x: 0, y: "a", z: 42 });
+ const index = store.index("by_z");
+ const indRes = await promiseFromRequest(index.get(42));
+ t.is(indRes.x, 0);
+ const res = await promiseFromRequest(store.get(0));
+ t.is(res.z, 42);
+ await promiseFromTransaction(tx);
+ }
+
+ {
+ const tx = db.transaction("bla", "readwrite");
+ const store = tx.objectStore("bla");
+ store.put({ x: 0, y: "a" });
+ const res = await promiseFromRequest(store.get(0));
+ t.is(res.z, undefined);
+ await promiseFromTransaction(tx);
+ }
+
+ {
+ const tx = db.transaction("bla", "readwrite");
+ const store = tx.objectStore("bla");
+ const index = store.index("by_z");
+ {
+ const indRes = await promiseFromRequest(index.get(42));
+ t.is(indRes, undefined);
+ }
+ const res = await promiseFromRequest(store.get(0));
+ t.is(res.z, undefined);
+ await promiseFromTransaction(tx);
+ }
+
+ t.pass();
+});
+
+test("delete from unique index", async (t) => {
+ const idb = useTestIndexedDb();
+ const dbname = "mydb-" + new Date().getTime() + Math.random();
+ const request = idb.open(dbname);
+ request.onupgradeneeded = () => {
+ const db = request.result as IDBDatabase;
+ const store = db.createObjectStore("bla", { keyPath: "x" });
+ store.createIndex("by_yz", ["y", "z"], {
+ unique: true,
+ });
+ };
+
+ const db: BridgeIDBDatabase = await promiseFromRequest(request);
+
+ t.is(db.name, dbname);
+
+ {
+ const tx = db.transaction("bla", "readwrite");
+ const store = tx.objectStore("bla");
+ store.put({ x: 0, y: "a", z: 42 });
+ const index = store.index("by_yz");
+ const indRes = await promiseFromRequest(index.get(["a", 42]));
+ t.is(indRes.x, 0);
+ const res = await promiseFromRequest(store.get(0));
+ t.is(res.z, 42);
+ await promiseFromTransaction(tx);
+ }
+
+ {
+ const tx = db.transaction("bla", "readwrite");
+ const store = tx.objectStore("bla");
+ store.put({ x: 0, y: "a", z: 42, extra: 123 });
+ await promiseFromTransaction(tx);
+ }
+
+ t.pass();
+});
+
+test("range queries", async (t) => {
+ const idb = useTestIndexedDb();
+ const dbname = "mydb-" + new Date().getTime() + Math.random();
+ const request = idb.open(dbname);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ const store = db.createObjectStore("bla", { keyPath: "x" });
+ store.createIndex("by_y", "y");
+ store.createIndex("by_z", "z");
+ };
+
+ const db: BridgeIDBDatabase = await promiseFromRequest(request);
+ const tx = db.transaction("bla", "readwrite");
+ const store = tx.objectStore("bla");
+
+ store.put({ x: 0, y: "a" });
+ store.put({ x: 2, y: "a" });
+ store.put({ x: 4, y: "b" });
+ store.put({ x: 8, y: "b" });
+ store.put({ x: 10, y: "c" });
+ store.put({ x: 12, y: "c" });
+
+ await promiseFromTransaction(tx);
+
+ async function doCursorStoreQuery(
+ range: IDBKeyRange | IDBValidKey | undefined,
+ direction: IDBCursorDirection | undefined,
+ expected: any[],
+ ): Promise<void> {
+ const tx = db.transaction("bla", "readwrite");
+ const store = tx.objectStore("bla");
+ const vals: any[] = [];
+
+ const req = store.openCursor(range, direction);
+ while (1) {
+ await promiseFromRequest(req);
+ const cursor: IDBCursorWithValue = req.result;
+ if (!cursor) {
+ break;
+ }
+ cursor.continue();
+ vals.push(cursor.value);
+ }
+
+ await promiseFromTransaction(tx);
+
+ t.deepEqual(vals, expected);
+ }
+
+ async function doCursorIndexQuery(
+ range: IDBKeyRange | IDBValidKey | undefined,
+ direction: IDBCursorDirection | undefined,
+ expected: any[],
+ ): Promise<void> {
+ const tx = db.transaction("bla", "readwrite");
+ const store = tx.objectStore("bla");
+ const index = store.index("by_y");
+ const vals: any[] = [];
+
+ const req = index.openCursor(range, direction);
+ while (1) {
+ await promiseFromRequest(req);
+ const cursor: IDBCursorWithValue = req.result;
+ if (!cursor) {
+ break;
+ }
+ cursor.continue();
+ vals.push(cursor.value);
+ }
+
+ await promiseFromTransaction(tx);
+
+ t.deepEqual(vals, expected);
+ }
+
+ await doCursorStoreQuery(undefined, undefined, [
+ {
+ x: 0,
+ y: "a",
+ },
+ {
+ x: 2,
+ y: "a",
+ },
+ {
+ x: 4,
+ y: "b",
+ },
+ {
+ x: 8,
+ y: "b",
+ },
+ {
+ x: 10,
+ y: "c",
+ },
+ {
+ x: 12,
+ y: "c",
+ },
+ ]);
+
+ await doCursorStoreQuery(
+ BridgeIDBKeyRange.bound(0, 12, true, true),
+ undefined,
+ [
+ {
+ x: 2,
+ y: "a",
+ },
+ {
+ x: 4,
+ y: "b",
+ },
+ {
+ x: 8,
+ y: "b",
+ },
+ {
+ x: 10,
+ y: "c",
+ },
+ ],
+ );
+
+ await doCursorIndexQuery(
+ BridgeIDBKeyRange.bound("a", "c", true, true),
+ undefined,
+ [
+ {
+ x: 4,
+ y: "b",
+ },
+ {
+ x: 8,
+ y: "b",
+ },
+ ],
+ );
+
+ await doCursorIndexQuery(undefined, "nextunique", [
+ {
+ x: 0,
+ y: "a",
+ },
+ {
+ x: 4,
+ y: "b",
+ },
+ {
+ x: 10,
+ y: "c",
+ },
+ ]);
+
+ await doCursorIndexQuery(undefined, "prevunique", [
+ {
+ x: 10,
+ y: "c",
+ },
+ {
+ x: 4,
+ y: "b",
+ },
+ {
+ x: 0,
+ y: "a",
+ },
+ ]);
+
+ db.close();
+
+ t.pass();
+});
diff --git a/packages/idb-bridge/src/bench.ts b/packages/idb-bridge/src/bench.ts
new file mode 100644
index 000000000..d196bacb1
--- /dev/null
+++ b/packages/idb-bridge/src/bench.ts
@@ -0,0 +1,110 @@
+/*
+ Copyright 2024 Taler Systems S.A.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ or implied. See the License for the specific language governing
+ permissions and limitations under the License.
+ */
+
+import * as fs from "node:fs";
+import {
+ BridgeIDBDatabase,
+ BridgeIDBFactory,
+ BridgeIDBRequest,
+ BridgeIDBTransaction,
+ createSqliteBackend,
+} from "./index.js";
+import { createNodeSqlite3Impl } from "./node-sqlite3-impl.js";
+
+function openDb(idbFactory: BridgeIDBFactory): Promise<BridgeIDBDatabase> {
+ return new Promise((resolve, reject) => {
+ const openReq = idbFactory.open("mydb", 1);
+ openReq.addEventListener("success", () => {
+ const database = openReq.result;
+ resolve(database);
+ });
+ openReq.addEventListener("upgradeneeded", (event: any) => {
+ const database: BridgeIDBDatabase = event.target.result;
+ const transaction: BridgeIDBTransaction = event.target.transaction;
+ database.createObjectStore("books", {
+ keyPath: "isbn",
+ });
+ });
+ });
+}
+
+function requestToPromise(req: BridgeIDBRequest): Promise<any> {
+ //const stack = Error("Failed request was started here.");
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => {
+ resolve(req.result);
+ };
+ req.onerror = () => {
+ console.error("error in DB request", req.error);
+ reject(req.error);
+ //console.error("Request failed:", stack);
+ };
+ });
+}
+
+function transactionToPromise(tx: BridgeIDBTransaction): Promise<void> {
+ //const stack = Error("Failed request was started here.");
+ return new Promise((resolve, reject) => {
+ tx.addEventListener("complete", () => {
+ resolve();
+ });
+ tx.onerror = () => {
+ console.error("error in DB txn", tx.error);
+ reject(tx.error);
+ //console.error("Request failed:", stack);
+ };
+ });
+}
+
+const nTx = Number(process.argv[2]);
+const nInsert = Number(process.argv[3]);
+
+async function main() {
+ const filename = "mytestdb.sqlite3";
+ try {
+ fs.unlinkSync(filename);
+ } catch (e) {
+ // Do nothing.
+ }
+
+ console.log(`doing ${nTx} iterations of ${nInsert} items`);
+
+ const sqlite3Impl = await createNodeSqlite3Impl();
+ const backend = await createSqliteBackend(sqlite3Impl, {
+ filename,
+ });
+ backend.enableTracing = false;
+ const idbFactory = new BridgeIDBFactory(backend);
+ const db = await openDb(idbFactory);
+
+ for (let i = 0; i < nTx; i++) {
+ const tx = db.transaction(["books"], "readwrite");
+ const txProm = transactionToPromise(tx);
+ const books = tx.objectStore("books");
+ for (let j = 0; j < nInsert; j++) {
+ const addReq = books.add({
+ isbn: `${i}-${j}`,
+ name: `book-${i}-${j}`,
+ });
+ await requestToPromise(addReq);
+ }
+ await txProm;
+ }
+
+ console.log("done");
+}
+
+main();
diff --git a/packages/idb-bridge/src/bridge-idb.ts b/packages/idb-bridge/src/bridge-idb.ts
index f015d2a9f..afb3f4224 100644
--- a/packages/idb-bridge/src/bridge-idb.ts
+++ b/packages/idb-bridge/src/bridge-idb.ts
@@ -17,14 +17,18 @@
import {
Backend,
+ ConnectResult,
DatabaseConnection,
DatabaseTransaction,
- RecordGetRequest,
+ IndexGetQuery,
+ IndexMeta,
+ ObjectStoreGetQuery,
+ ObjectStoreMeta,
+ RecordGetResponse,
RecordStoreRequest,
ResultLevel,
- Schema,
StoreLevel,
-} from "./backend-interface";
+} from "./backend-interface.js";
import {
DOMException,
DOMStringList,
@@ -41,10 +45,10 @@ import {
IDBTransaction,
IDBTransactionMode,
IDBValidKey,
-} from "./idbtypes";
-import { canInjectKey } from "./util/canInjectKey";
-import { compareKeys } from "./util/cmp";
-import { enforceRange } from "./util/enforceRange";
+} from "./idbtypes.js";
+import { canInjectKey } from "./util/canInjectKey.js";
+import { compareKeys } from "./util/cmp.js";
+import { enforceRange } from "./util/enforceRange.js";
import {
AbortError,
ConstraintError,
@@ -56,29 +60,26 @@ import {
ReadOnlyError,
TransactionInactiveError,
VersionError,
-} from "./util/errors";
-import { FakeDOMStringList, fakeDOMStringList } from "./util/fakeDOMStringList";
-import FakeEvent from "./util/FakeEvent";
-import FakeEventTarget from "./util/FakeEventTarget";
-import { makeStoreKeyValue } from "./util/makeStoreKeyValue";
-import { normalizeKeyPath } from "./util/normalizeKeyPath";
-import { openPromise } from "./util/openPromise";
-import queueTask from "./util/queueTask";
-import { structuredClone } from "./util/structuredClone";
-import { validateKeyPath } from "./util/validateKeyPath";
-import { valueToKey } from "./util/valueToKey";
-
-/** @public */
+} from "./util/errors.js";
+import { fakeDOMStringList } from "./util/fakeDOMStringList.js";
+import FakeEvent from "./util/FakeEvent.js";
+import FakeEventTarget from "./util/FakeEventTarget.js";
+import { makeStoreKeyValue } from "./util/makeStoreKeyValue.js";
+import { normalizeKeyPath } from "./util/normalizeKeyPath.js";
+import { openPromise } from "./util/openPromise.js";
+import queueTask from "./util/queueTask.js";
+import { checkStructuredCloneOrThrow } from "./util/structuredClone.js";
+import { validateKeyPath } from "./util/validateKeyPath.js";
+import { valueToKey } from "./util/valueToKey.js";
+
export type CursorSource = BridgeIDBIndex | BridgeIDBObjectStore;
-/** @public */
export interface RequestObj {
operation: () => Promise<any>;
request?: BridgeIDBRequest | undefined;
source?: any;
}
-/** @public */
export interface BridgeIDBDatabaseInfo {
name: string;
version: number;
@@ -98,8 +99,6 @@ function simplifyRange(
/**
* http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#cursor
- *
- * @public
*/
export class BridgeIDBCursor implements IDBCursor {
_request: BridgeIDBRequest | undefined;
@@ -204,29 +203,56 @@ export class BridgeIDBCursor implements IDBCursor {
);
BridgeIDBFactory.enableTracing &&
console.log("cursor type ", this.toString());
- const isIndex = this._indexName !== undefined;
- const recordGetRequest: RecordGetRequest = {
- direction: this.direction,
- indexName: this._indexName,
- lastIndexPosition: this._indexPosition,
- lastObjectStorePosition: this._objectStorePosition,
- limit: 1,
- range: simplifyRange(this._range),
- objectStoreName: this._objectStoreName,
- advanceIndexKey: isIndex ? key : undefined,
- advancePrimaryKey: isIndex ? primaryKey : key,
- resultLevel: this._keyOnly ? ResultLevel.OnlyKeys : ResultLevel.Full,
- };
+ const indexName = this._indexName;
const { btx } = this.source._confirmStartedBackendTransaction();
- let response = await this._backend.getRecords(btx, recordGetRequest);
+ let response: RecordGetResponse;
+
+ if (indexName != null) {
+ const indexRecordGetRequest: IndexGetQuery = {
+ direction: this.direction,
+ indexName: indexName,
+ lastIndexPosition: this._indexPosition,
+ lastObjectStorePosition: this._objectStorePosition,
+ limit: 1,
+ range: simplifyRange(this._range),
+ objectStoreName: this._objectStoreName,
+ advanceIndexKey: key,
+ advancePrimaryKey: primaryKey,
+ resultLevel: this._keyOnly ? ResultLevel.OnlyKeys : ResultLevel.Full,
+ };
+ response = await this._backend.getIndexRecords(
+ btx,
+ indexRecordGetRequest,
+ );
+ } else {
+ if (primaryKey != null) {
+ // Only allowed for index cursors
+ throw new InvalidAccessError();
+ }
+ const objStoreGetRequest: ObjectStoreGetQuery = {
+ direction: this.direction,
+ lastObjectStorePosition: this._objectStorePosition,
+ limit: 1,
+ range: simplifyRange(this._range),
+ objectStoreName: this._objectStoreName,
+ advancePrimaryKey: key,
+ resultLevel: this._keyOnly ? ResultLevel.OnlyKeys : ResultLevel.Full,
+ };
+ response = await this._backend.getObjectStoreRecords(
+ btx,
+ objStoreGetRequest,
+ );
+ }
if (response.count === 0) {
if (BridgeIDBFactory.enableTracing) {
console.log("cursor is returning empty result");
}
this._gotValue = false;
+ this._key = undefined;
+ this._value = undefined;
return null;
}
@@ -234,11 +260,6 @@ export class BridgeIDBCursor implements IDBCursor {
throw Error("invariant failed");
}
- if (BridgeIDBFactory.enableTracing) {
- console.log("request is:", JSON.stringify(recordGetRequest));
- console.log("get response is:", JSON.stringify(response));
- }
-
if (this._indexName !== undefined) {
this._key = response.indexKeys![0];
} else {
@@ -303,7 +324,7 @@ export class BridgeIDBCursor implements IDBCursor {
try {
// Only called for the side effect of throwing an exception
- structuredClone(value);
+ checkStructuredCloneOrThrow(value);
} catch (e) {
throw new DataCloneError();
}
@@ -327,6 +348,7 @@ export class BridgeIDBCursor implements IDBCursor {
}
const { btx } = this.source._confirmStartedBackendTransaction();
await this._backend.storeRecord(btx, storeReq);
+ // FIXME: update the index position here!
};
return transaction._execRequestAsync({
operation,
@@ -546,7 +568,6 @@ const confirmActiveVersionchangeTransaction = (database: BridgeIDBDatabase) => {
};
// http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#database-interface
-/** @public */
export class BridgeIDBDatabase extends FakeEventTarget implements IDBDatabase {
_closePending = false;
_closed = false;
@@ -557,7 +578,16 @@ export class BridgeIDBDatabase extends FakeEventTarget implements IDBDatabase {
_backendConnection: DatabaseConnection;
_backend: Backend;
- _schema: Schema;
+ _name: string;
+
+ _initialVersion: number;
+
+ _version: number;
+
+ // "object store set" from the spec
+ _objectStoreSet: string[];
+
+ // _schema: Schema;
/**
* Name that can be set to identify the object store in logs.
@@ -565,17 +595,15 @@ export class BridgeIDBDatabase extends FakeEventTarget implements IDBDatabase {
_debugName: string | undefined = undefined;
get name(): string {
- return this._schema.databaseName;
+ return this._name;
}
get version(): number {
- return this._schema.databaseVersion;
+ return this._version;
}
get objectStoreNames(): DOMStringList {
- return fakeDOMStringList(
- Object.keys(this._schema.objectStores),
- ).sort() as DOMStringList;
+ return fakeDOMStringList([...this._objectStoreSet]).sort() as DOMStringList;
}
/**
@@ -602,13 +630,13 @@ export class BridgeIDBDatabase extends FakeEventTarget implements IDBDatabase {
}
}
- constructor(backend: Backend, backendConnection: DatabaseConnection) {
+ constructor(name: string, backend: Backend, connResult: ConnectResult) {
super();
-
- this._schema = backend.getSchema(backendConnection);
-
+ this._name = name;
+ this._version = this._initialVersion = connResult.version;
this._backend = backend;
- this._backendConnection = backendConnection;
+ this._backendConnection = connResult.conn;
+ this._objectStoreSet = connResult.objectStores;
}
// http://w3c.github.io/IndexedDB/#dom-idbdatabase-createobjectstore
@@ -641,7 +669,8 @@ export class BridgeIDBDatabase extends FakeEventTarget implements IDBDatabase {
validateKeyPath(keyPath);
}
- if (Object.keys(this._schema.objectStores).includes(name)) {
+ if (this._objectStoreSet.includes(name)) {
+ // Already exists
throw new ConstraintError();
}
@@ -656,7 +685,9 @@ export class BridgeIDBDatabase extends FakeEventTarget implements IDBDatabase {
autoIncrement,
);
- this._schema = this._backend.getCurrentTransactionSchema(backendTx);
+ transaction._scope.add(name);
+ this._objectStoreSet.push(name);
+ this._objectStoreSet.sort();
const newObjectStore = transaction.objectStore(name);
newObjectStore._justCreated = true;
@@ -678,6 +709,10 @@ export class BridgeIDBDatabase extends FakeEventTarget implements IDBDatabase {
os._deleted = true;
transaction._objectStoresCache.delete(name);
}
+ transaction._cachedObjectStoreNames = undefined;
+ transaction._scope.delete(name);
+ const nameIdx = this._objectStoreSet.indexOf(name);
+ this._objectStoreSet.splice(nameIdx, 1);
}
public _internalTransaction(
@@ -700,7 +735,9 @@ export class BridgeIDBDatabase extends FakeEventTarget implements IDBDatabase {
}
if (this._closePending) {
- throw new InvalidStateError();
+ throw new InvalidStateError(
+ `tried to start transaction on ${this._name}, but a close is pending`,
+ );
}
if (!Array.isArray(storeNames)) {
@@ -762,10 +799,8 @@ export class BridgeIDBDatabase extends FakeEventTarget implements IDBDatabase {
}
}
-/** @public */
export type DatabaseList = Array<{ name: string; version: number }>;
-/** @public */
export class BridgeIDBFactory {
public cmp = compareKeys;
private backend: Backend;
@@ -806,8 +841,10 @@ export class BridgeIDBFactory {
});
request.dispatchEvent(event2);
} catch (err: any) {
- request.error = new Error();
- request.error.name = err.name;
+ const myErr = new Error();
+ myErr.name = err.name;
+ myErr.message = err.message;
+ request.error = myErr;
request.readyState = "done";
const event = new FakeEvent("error", {
@@ -837,27 +874,26 @@ export class BridgeIDBFactory {
const request = new BridgeIDBOpenDBRequest();
queueTask(async () => {
- let dbconn: DatabaseConnection;
+ let dbConnRes: ConnectResult;
try {
if (BridgeIDBFactory.enableTracing) {
console.log("TRACE: connecting to database");
}
- dbconn = await this.backend.connectDatabase(name);
+ dbConnRes = await this.backend.connectDatabase(name);
if (BridgeIDBFactory.enableTracing) {
console.log("TRACE: connected!");
}
} catch (err: any) {
if (BridgeIDBFactory.enableTracing) {
console.log(
- "TRACE: caught exception while trying to connect with backend",
+ "TRACE: caught exception while trying to connect with backend:",
+ err,
);
}
request._finishWithError(err);
return;
}
-
- const schema = this.backend.getSchema(dbconn);
- const existingVersion = schema.databaseVersion;
+ const existingVersion = dbConnRes.version;
if (version === undefined) {
version = existingVersion !== 0 ? existingVersion : 1;
@@ -875,7 +911,7 @@ export class BridgeIDBFactory {
return;
}
- const db = new BridgeIDBDatabase(this.backend, dbconn);
+ const db = new BridgeIDBDatabase(name, this.backend, dbConnRes);
if (existingVersion == requestedVersion) {
request.result = db;
@@ -896,6 +932,9 @@ export class BridgeIDBFactory {
// http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#dfn-steps-for-running-a-versionchange-transaction
for (const otherConn of this.connections) {
+ if (otherConn._name != db._name) {
+ continue;
+ }
if (otherConn._closePending) {
continue;
}
@@ -925,17 +964,14 @@ export class BridgeIDBFactory {
}
const backendTransaction = await this.backend.enterVersionChange(
- dbconn,
+ dbConnRes.conn,
requestedVersion,
);
// We need to expose the new version number to the upgrade transaction.
- db._schema = this.backend.getCurrentTransactionSchema(
- backendTransaction,
- );
-
+ db._version = version;
const transaction = db._internalTransaction(
- [],
+ dbConnRes.objectStores,
"versionchange",
backendTransaction,
request,
@@ -961,7 +997,7 @@ export class BridgeIDBFactory {
await transaction._waitDone();
- // We re-use the same transaction (as per spec) here.
+ // We reuse the same transaction (as per spec) here.
transaction._active = true;
if (db._closed || db._closePending) {
@@ -1027,37 +1063,48 @@ export class BridgeIDBFactory {
}
// http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#idl-def-IDBIndex
-/** @public */
export class BridgeIDBIndex implements IDBIndex {
_objectStore: BridgeIDBObjectStore;
+ _indexMeta: IndexMeta;
+ _originalName: string | undefined = undefined;
+ _deleted: boolean = false;
+ _name: string;
+
+ /**
+ * Was this index newly created in the current transaction?
+ */
+ _justCreated: boolean = false;
get objectStore(): IDBObjectStore {
return this._objectStore;
}
- get _schema(): Schema {
- return this._objectStore._transaction._db._schema;
- }
-
get keyPath(): IDBKeyPath | IDBKeyPath[] {
- return this._schema.objectStores[this._objectStore.name].indexes[this._name]
- .keyPath;
+ return this._indexMeta.keyPath;
}
get multiEntry(): boolean {
- return this._schema.objectStores[this._objectStore.name].indexes[this._name]
- .multiEntry;
+ return this._indexMeta.multiEntry;
}
get unique(): boolean {
- return this._schema.objectStores[this._objectStore.name].indexes[this._name]
- .unique;
+ return this._indexMeta.multiEntry;
}
get _backend(): Backend {
return this._objectStore._backend;
}
+ constructor(
+ objectStore: BridgeIDBObjectStore,
+ name: string,
+ indexMeta: IndexMeta,
+ ) {
+ this._name = name;
+ this._objectStore = objectStore;
+ this._indexMeta = indexMeta;
+ }
+
_confirmStartedBackendTransaction(): { btx: DatabaseTransaction } {
return this._objectStore._confirmStartedBackendTransaction();
}
@@ -1066,20 +1113,6 @@ export class BridgeIDBIndex implements IDBIndex {
this._objectStore._confirmActiveTransaction();
}
- private _name: string;
-
- public _deleted: boolean = false;
-
- /**
- * Was this index newly created in the current transaction?
- */
- _justCreated: boolean = false;
-
- constructor(objectStore: BridgeIDBObjectStore, name: string) {
- this._name = name;
- this._objectStore = objectStore;
- }
-
get name() {
return this._name;
}
@@ -1104,19 +1137,39 @@ export class BridgeIDBIndex implements IDBIndex {
if (newName === oldName) {
return;
}
-
+ if (this._originalName != null) {
+ this._originalName = oldName;
+ }
this._backend.renameIndex(btx, this._objectStore.name, oldName, newName);
+ this._applyNameChange(oldName, newName);
+ if (this._objectStore._objectStoreMeta.indexSet.indexOf(name) >= 0) {
+ throw new Error("internal invariant violated");
+ }
+ }
- this._objectStore._transaction._db._schema = this._backend.getCurrentTransactionSchema(
- btx,
- );
-
- this._objectStore._indexesCache.delete(oldName);
- this._objectStore._indexesCache.set(newName, this);
+ _applyNameChange(oldName: string, newName: string) {
+ this._objectStore._indexHandlesCache.delete(oldName);
+ this._objectStore._indexHandlesCache.set(newName, this);
+ const indexSet = this._objectStore._objectStoreMeta.indexSet;
+ const indexIdx = indexSet.indexOf(oldName);
+ indexSet[indexIdx] = newName;
+ indexSet.sort();
this._name = newName;
+ }
- if (this._objectStore._indexNames.indexOf(name) >= 0) {
- throw new Error("internal invariant violated");
+ _applyDelete() {
+ this._objectStore._indexHandlesCache.delete(this._name);
+ const indexSet = this._objectStore._objectStoreMeta.indexSet;
+ const indexIdx = indexSet.indexOf(this._name);
+ indexSet.splice(indexIdx, 1);
+ }
+
+ _abort() {
+ if (this._originalName != null) {
+ this._applyNameChange(this._name, this._originalName);
+ }
+ if (this._justCreated) {
+ this._deleted = true;
}
}
@@ -1132,8 +1185,6 @@ export class BridgeIDBIndex implements IDBIndex {
);
}
- BridgeIDBFactory.enableTracing && console.log("opening cursor on", this);
-
this._confirmActiveTransaction();
range = simplifyRange(range);
@@ -1199,34 +1250,23 @@ export class BridgeIDBIndex implements IDBIndex {
}
private _confirmIndexExists() {
- const storeSchema = this._schema.objectStores[this._objectStore._name];
- if (!storeSchema) {
- throw new InvalidStateError(
- `no schema for object store '${this._objectStore._name}'`,
- );
- }
- if (!storeSchema.indexes[this._name]) {
- throw new InvalidStateError(
- `no schema for index '${this._name}' of object store '${this._objectStore._name}'`,
- );
- }
- }
-
- get(key: BridgeIDBKeyRange | IDBValidKey) {
if (this._deleted) {
throw new InvalidStateError();
}
if (this._objectStore._deleted) {
throw new InvalidStateError();
}
- this._confirmActiveTransaction();
+ }
+
+ get(key: BridgeIDBKeyRange | IDBValidKey) {
this._confirmIndexExists();
+ this._confirmActiveTransaction();
if (!(key instanceof BridgeIDBKeyRange)) {
key = BridgeIDBKeyRange._valueToKeyRange(key);
}
- const getReq: RecordGetRequest = {
+ const getReq: IndexGetQuery = {
direction: "next",
indexName: this._name,
limit: 1,
@@ -1237,7 +1277,7 @@ export class BridgeIDBIndex implements IDBIndex {
const operation = async () => {
const { btx } = this._confirmStartedBackendTransaction();
- const result = await this._backend.getRecords(btx, getReq);
+ const result = await this._backend.getIndexRecords(btx, getReq);
if (result.count == 0) {
return undefined;
}
@@ -1273,7 +1313,7 @@ export class BridgeIDBIndex implements IDBIndex {
count = -1;
}
- const getReq: RecordGetRequest = {
+ const getReq: IndexGetQuery = {
direction: "next",
indexName: this._name,
limit: count,
@@ -1284,7 +1324,7 @@ export class BridgeIDBIndex implements IDBIndex {
const operation = async () => {
const { btx } = this._confirmStartedBackendTransaction();
- const result = await this._backend.getRecords(btx, getReq);
+ const result = await this._backend.getIndexRecords(btx, getReq);
const values = result.values;
if (!values) {
throw Error("invariant violated");
@@ -1307,7 +1347,7 @@ export class BridgeIDBIndex implements IDBIndex {
key = BridgeIDBKeyRange._valueToKeyRange(key);
}
- const getReq: RecordGetRequest = {
+ const getReq: IndexGetQuery = {
direction: "next",
indexName: this._name,
limit: 1,
@@ -1318,7 +1358,7 @@ export class BridgeIDBIndex implements IDBIndex {
const operation = async () => {
const { btx } = this._confirmStartedBackendTransaction();
- const result = await this._backend.getRecords(btx, getReq);
+ const result = await this._backend.getIndexRecords(btx, getReq);
if (result.count == 0) {
return undefined;
}
@@ -1351,7 +1391,7 @@ export class BridgeIDBIndex implements IDBIndex {
count = -1;
}
- const getReq: RecordGetRequest = {
+ const getReq: IndexGetQuery = {
direction: "next",
indexName: this._name,
limit: count,
@@ -1362,7 +1402,7 @@ export class BridgeIDBIndex implements IDBIndex {
const operation = async () => {
const { btx } = this._confirmStartedBackendTransaction();
- const result = await this._backend.getRecords(btx, getReq);
+ const result = await this._backend.getIndexRecords(btx, getReq);
const primaryKeys = result.primaryKeys;
if (!primaryKeys) {
throw Error("invariant violated");
@@ -1388,7 +1428,7 @@ export class BridgeIDBIndex implements IDBIndex {
key = BridgeIDBKeyRange.only(valueToKey(key));
}
- const getReq: RecordGetRequest = {
+ const getReq: IndexGetQuery = {
direction: "next",
indexName: this._name,
limit: 1,
@@ -1399,7 +1439,7 @@ export class BridgeIDBIndex implements IDBIndex {
const operation = async () => {
const { btx } = this._confirmStartedBackendTransaction();
- const result = await this._backend.getRecords(btx, getReq);
+ const result = await this._backend.getIndexRecords(btx, getReq);
return result.count;
};
@@ -1415,7 +1455,6 @@ export class BridgeIDBIndex implements IDBIndex {
}
// http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#range-concept
-/** @public */
export class BridgeIDBKeyRange {
public static only(value: IDBValidKey) {
if (arguments.length === 0) {
@@ -1525,10 +1564,8 @@ export class BridgeIDBKeyRange {
}
// http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#object-store
-/** @public */
export class BridgeIDBObjectStore implements IDBObjectStore {
- _indexesCache: Map<string, BridgeIDBIndex> = new Map();
-
+ _indexHandlesCache: Map<string, BridgeIDBIndex> = new Map();
_transaction: BridgeIDBTransaction;
/**
@@ -1536,41 +1573,43 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
*/
_debugName: string | undefined = undefined;
+ // Was the object store (not the handle, but the underlying store)
+ // created in this upgrade transaction?
_justCreated: boolean = false;
+ _originalName: string | undefined = undefined;
+ _objectStoreMeta: ObjectStoreMeta;
+
get transaction(): IDBTransaction {
return this._transaction;
}
get autoIncrement(): boolean {
- return this._schema.objectStores[this._name].autoIncrement;
- }
-
- get _indexNames(): FakeDOMStringList {
- return fakeDOMStringList(
- Object.keys(this._schema.objectStores[this._name].indexes),
- ).sort();
+ return this._objectStoreMeta.autoIncrement;
}
get indexNames(): DOMStringList {
- return this._indexNames as DOMStringList;
+ return fakeDOMStringList([...this._objectStoreMeta.indexSet]);
}
get keyPath(): IDBKeyPath | IDBKeyPath[] {
- return this._schema.objectStores[this._name].keyPath!;
+ // Bug in th official type declarations. The spec
+ // allows returning null here.
+ return this._objectStoreMeta.keyPath!;
}
_name: string;
- get _schema(): Schema {
- return this._transaction._db._schema;
- }
-
_deleted: boolean = false;
- constructor(transaction: BridgeIDBTransaction, name: string) {
+ constructor(
+ transaction: BridgeIDBTransaction,
+ name: string,
+ objectStoreMeta: ObjectStoreMeta,
+ ) {
this._name = name;
this._transaction = transaction;
+ this._objectStoreMeta = objectStoreMeta;
}
get name() {
@@ -1620,27 +1659,56 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
let { btx } = this._confirmStartedBackendTransaction();
newName = String(newName);
-
const oldName = this._name;
-
if (newName === oldName) {
return;
}
-
+ if (this._originalName == null) {
+ this._originalName = this._name;
+ }
this._backend.renameObjectStore(btx, oldName, newName);
- this._transaction._db._schema = this._backend.getCurrentTransactionSchema(
- btx,
- );
+ this._applyNameChange(oldName, newName);
+ }
+ _applyNameChange(oldName: string, newName: string) {
+ this._transaction._scope.delete(oldName);
+ this._transaction._scope.add(newName);
// We don't modify scope, as the scope of the transaction
// doesn't matter if we're in an upgrade transaction.
this._transaction._objectStoresCache.delete(oldName);
this._transaction._objectStoresCache.set(newName, this);
this._transaction._cachedObjectStoreNames = undefined;
-
+ const objectStoreSet = this._transaction._db._objectStoreSet;
+ const oldIdx = objectStoreSet.indexOf(oldName);
+ objectStoreSet[oldIdx] = newName;
+ objectStoreSet.sort();
this._name = newName;
}
+ _applyDelete() {
+ this._deleted = true;
+ this._transaction._objectStoresCache.delete(this._name);
+ this._transaction._cachedObjectStoreNames = undefined;
+ const objectStoreSet = this._transaction._db._objectStoreSet;
+ const oldIdx = objectStoreSet.indexOf(this._name);
+ objectStoreSet.splice(oldIdx, 1);
+ }
+
+ /**
+ * Roll back changes to the handle after an abort.
+ */
+ _abort() {
+ if (this._originalName != null) {
+ this._applyNameChange(this._name, this._originalName);
+ }
+ if (this._justCreated) {
+ this._applyDelete();
+ }
+ }
+
+ /**
+ * "To add or put with handle, value, key, and no-overwrite flag, run these steps:"
+ */
public _store(value: any, key: IDBValidKey | undefined, overwrite: boolean) {
if (BridgeIDBFactory.enableTracing) {
console.log(
@@ -1648,6 +1716,12 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
);
}
+ if (this._deleted) {
+ throw new InvalidStateError(
+ "tried to call 'put' on a deleted object store",
+ );
+ }
+
if (!this._transaction._active) {
throw new TransactionInactiveError();
}
@@ -1656,14 +1730,21 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
throw new ReadOnlyError();
}
- const { keyPath, autoIncrement } = this._schema.objectStores[this._name];
+ const { keyPath, autoIncrement } = this._objectStoreMeta;
if (key !== null && key !== undefined) {
valueToKey(key);
}
// We only call this to synchronously verify the request.
- makeStoreKeyValue(value, key, 1, autoIncrement, keyPath);
+ // FIXME: The backend should do that!
+ makeStoreKeyValue({
+ value: value,
+ key: key,
+ currentKeyGenerator: 1,
+ autoIncrement,
+ keyPath,
+ });
const operation = async () => {
const { btx } = this._confirmStartedBackendTransaction();
@@ -1685,11 +1766,6 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
if (arguments.length === 0) {
throw new TypeError();
}
- if (this._deleted) {
- throw new InvalidStateError(
- "tried to call 'put' on a deleted object store",
- );
- }
return this._store(value, key, true);
}
@@ -1697,9 +1773,6 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
if (arguments.length === 0) {
throw new TypeError();
}
- if (!this._schema.objectStores[this._name]) {
- throw new InvalidStateError("object store does not exist");
- }
return this._store(value, key, false);
}
@@ -1768,10 +1841,8 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
}
}
- const recordRequest: RecordGetRequest = {
+ const recordRequest: ObjectStoreGetQuery = {
objectStoreName: this._name,
- indexName: undefined,
- lastIndexPosition: undefined,
lastObjectStorePosition: undefined,
direction: "next",
limit: 1,
@@ -1784,7 +1855,10 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
console.log("running get operation:", recordRequest);
}
const { btx } = this._confirmStartedBackendTransaction();
- const result = await this._backend.getRecords(btx, recordRequest);
+ const result = await this._backend.getObjectStoreRecords(
+ btx,
+ recordRequest,
+ );
if (BridgeIDBFactory.enableTracing) {
console.log("get operation result count:", result.count);
@@ -1834,10 +1908,8 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
let keyRange: BridgeIDBKeyRange | null = simplifyRange(query);
- const recordRequest: RecordGetRequest = {
+ const recordRequest: ObjectStoreGetQuery = {
objectStoreName: this._name,
- indexName: undefined,
- lastIndexPosition: undefined,
lastObjectStorePosition: undefined,
direction: "next",
limit: count,
@@ -1850,7 +1922,10 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
console.log("running getAll operation:", recordRequest);
}
const { btx } = this._confirmStartedBackendTransaction();
- const result = await this._backend.getRecords(btx, recordRequest);
+ const result = await this._backend.getObjectStoreRecords(
+ btx,
+ recordRequest,
+ );
if (BridgeIDBFactory.enableTracing) {
console.log("get operation result count:", result.count);
@@ -1888,10 +1963,8 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
let keyRange: BridgeIDBKeyRange | null = simplifyRange(query);
- const recordRequest: RecordGetRequest = {
+ const recordRequest: ObjectStoreGetQuery = {
objectStoreName: this._name,
- indexName: undefined,
- lastIndexPosition: undefined,
lastObjectStorePosition: undefined,
direction: "next",
limit: 1,
@@ -1904,7 +1977,10 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
console.log("running getKey operation:", recordRequest);
}
const { btx } = this._confirmStartedBackendTransaction();
- const result = await this._backend.getRecords(btx, recordRequest);
+ const result = await this._backend.getObjectStoreRecords(
+ btx,
+ recordRequest,
+ );
if (BridgeIDBFactory.enableTracing) {
console.log("getKey operation result count:", result.count);
@@ -1966,10 +2042,8 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
}
}
- const recordRequest: RecordGetRequest = {
+ const recordRequest: ObjectStoreGetQuery = {
objectStoreName: this._name,
- indexName: undefined,
- lastIndexPosition: undefined,
lastObjectStorePosition: undefined,
direction: "next",
limit: count,
@@ -1979,7 +2053,10 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
const operation = async () => {
const { btx } = this._confirmStartedBackendTransaction();
- const result = await this._backend.getRecords(btx, recordRequest);
+ const result = await this._backend.getObjectStoreRecords(
+ btx,
+ recordRequest,
+ );
const primaryKeys = result.primaryKeys;
if (!primaryKeys) {
@@ -2122,7 +2199,7 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
throw new InvalidStateError();
}
- if (this._indexNames.indexOf(indexName) >= 0) {
+ if (this._objectStoreMeta.indexSet.indexOf(indexName) >= 0) {
throw new ConstraintError();
}
@@ -2141,6 +2218,9 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
unique,
);
+ this._objectStoreMeta.indexSet.push(indexName);
+ this._objectStoreMeta.indexSet.sort();
+
const idx = this.index(indexName);
idx._justCreated = true;
return idx;
@@ -2155,13 +2235,20 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
if (this._transaction._finished) {
throw new InvalidStateError();
}
-
- const index = this._indexesCache.get(name);
+ const index = this._indexHandlesCache.get(name);
if (index !== undefined) {
return index;
}
- const newIndex = new BridgeIDBIndex(this, name);
- this._indexesCache.set(name, newIndex);
+ const indexMeta = this._backend.getIndexMeta(
+ this._backendConnection,
+ this._name,
+ name,
+ );
+ if (!indexMeta) {
+ throw new NotFoundError();
+ }
+ const newIndex = new BridgeIDBIndex(this, name, indexMeta);
+ this._indexHandlesCache.set(name, newIndex);
this._transaction._usedIndexes.push(newIndex);
return newIndex;
}
@@ -2181,12 +2268,15 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
const { btx } = this._confirmStartedBackendTransaction();
- const index = this._indexesCache.get(indexName);
+ const index = this._indexHandlesCache.get(indexName);
if (index !== undefined) {
index._deleted = true;
- this._indexesCache.delete(indexName);
+ this._indexHandlesCache.delete(indexName);
}
+ const indexIdx = this._objectStoreMeta.indexSet.indexOf(indexName);
+ this._objectStoreMeta.indexSet.splice(indexIdx, 1);
+
this._backend.deleteIndex(btx, this._name, indexName);
}
@@ -2199,11 +2289,9 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
key = BridgeIDBKeyRange.only(valueToKey(key));
}
- const recordGetRequest: RecordGetRequest = {
+ const recordGetRequest: ObjectStoreGetQuery = {
direction: "next",
- indexName: undefined,
- lastIndexPosition: undefined,
- limit: -1,
+ limit: 0,
objectStoreName: this._name,
lastObjectStorePosition: undefined,
range: key,
@@ -2212,7 +2300,10 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
const operation = async () => {
const { btx } = this._confirmStartedBackendTransaction();
- const result = await this._backend.getRecords(btx, recordGetRequest);
+ const result = await this._backend.getObjectStoreRecords(
+ btx,
+ recordGetRequest,
+ );
return result.count;
};
@@ -2224,7 +2315,6 @@ export class BridgeIDBObjectStore implements IDBObjectStore {
}
}
-/** @public */
export class BridgeIDBRequest extends FakeEventTarget implements IDBRequest {
_result: any = null;
_error: Error | null | undefined = null;
@@ -2234,11 +2324,8 @@ export class BridgeIDBRequest extends FakeEventTarget implements IDBRequest {
}
return this._source;
}
- _source:
- | BridgeIDBCursor
- | BridgeIDBIndex
- | BridgeIDBObjectStore
- | null = null;
+ _source: BridgeIDBCursor | BridgeIDBIndex | BridgeIDBObjectStore | null =
+ null;
transaction: BridgeIDBTransaction | null = null;
readyState: "done" | "pending" = "pending";
onsuccess: EventListener | null = null;
@@ -2298,10 +2385,10 @@ export class BridgeIDBRequest extends FakeEventTarget implements IDBRequest {
}
}
-/** @public */
export class BridgeIDBOpenDBRequest
extends BridgeIDBRequest
- implements IDBOpenDBRequest {
+ implements IDBOpenDBRequest
+{
public onupgradeneeded: EventListener | null = null;
public onblocked: EventListener | null = null;
@@ -2346,10 +2433,10 @@ function waitMacroQueue(): Promise<void> {
}
// http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#transaction
-/** @public */
export class BridgeIDBTransaction
extends FakeEventTarget
- implements IDBTransaction {
+ implements IDBTransaction
+{
_committed: boolean = false;
/**
* A transaction is active as long as new operations can be
@@ -2384,6 +2471,8 @@ export class BridgeIDBTransaction
return this._committed || this._aborted;
}
+ _counter = 0;
+
_openRequest: BridgeIDBOpenDBRequest | null = null;
_backendTransaction?: DatabaseTransaction;
@@ -2392,13 +2481,9 @@ export class BridgeIDBTransaction
get objectStoreNames(): DOMStringList {
if (!this._cachedObjectStoreNames) {
- if (this._openRequest) {
- this._cachedObjectStoreNames = this._db.objectStoreNames;
- } else {
- this._cachedObjectStoreNames = fakeDOMStringList(
- Array.from(this._scope).sort(),
- );
- }
+ this._cachedObjectStoreNames = fakeDOMStringList(
+ Array.from(this._scope).sort(),
+ );
}
return this._cachedObjectStoreNames;
}
@@ -2498,41 +2583,34 @@ export class BridgeIDBTransaction
}
}
+ // All steps before happened synchronously. Now
+ // we asynchronously roll back the backend transaction,
+ // if necessary/possible.
+
+ const maybeBtx = this._backendTransaction;
+ if (maybeBtx) {
+ this._backend.rollback(maybeBtx);
+ }
+
// "Any object stores and indexes which were created during the
// transaction are now considered deleted for the purposes of other
// algorithms."
if (this._db._upgradeTransaction) {
for (const os of this._usedObjectStores) {
- if (os._justCreated) {
- os._deleted = true;
- }
+ os._abort();
}
for (const ind of this._usedIndexes) {
- if (ind._justCreated) {
- ind._deleted = true;
- }
+ ind._abort();
}
}
+ this._db._version = this._db._initialVersion;
+
// ("abort a transaction", step 5.1)
if (this._openRequest) {
this._db._upgradeTransaction = null;
}
- // All steps before happened synchronously. Now
- // we asynchronously roll back the backend transaction,
- // if necessary/possible.
-
- const maybeBtx = this._backendTransaction;
- if (maybeBtx) {
- this._db._schema = this._backend.getInitialTransactionSchema(maybeBtx);
- // Only roll back if we actually executed the scheduled operations.
- await this._backend.rollback(maybeBtx);
- this._backendTransaction = undefined;
- } else {
- this._db._schema = this._backend.getSchema(this._db._backendConnection);
- }
-
queueTask(() => {
const event = new FakeEvent("abort", {
bubbles: true,
@@ -2562,22 +2640,29 @@ export class BridgeIDBTransaction
throw new TransactionInactiveError();
}
- if (!this._db._schema.objectStores[name]) {
+ if (!this._scope.has(name)) {
throw new NotFoundError();
}
- if (!this._db._upgradeTransaction) {
- if (!this._scope.has(name)) {
- throw new NotFoundError();
- }
- }
-
const objectStore = this._objectStoresCache.get(name);
if (objectStore !== undefined) {
return objectStore;
}
- const newObjectStore = new BridgeIDBObjectStore(this, name);
+ const objectStoreMeta = this._backend.getObjectStoreMeta(
+ this._db._backendConnection,
+ name,
+ );
+
+ if (!objectStoreMeta) {
+ throw new NotFoundError();
+ }
+
+ const newObjectStore = new BridgeIDBObjectStore(
+ this,
+ name,
+ objectStoreMeta,
+ );
this._objectStoresCache.set(name, newObjectStore);
this._usedObjectStores.push(newObjectStore);
return newObjectStore;
@@ -2657,13 +2742,20 @@ export class BridgeIDBTransaction
}
}
- await waitMacroQueue();
-
if (!request._source) {
// Special requests like indexes that just need to run some code,
// with error handling already built into operation
await operation();
} else {
+ this._counter++;
+ if (this._counter > 100) {
+ this._counter = 0;
+ // Give a chance for macro tasks to do something
+ // If we don't do this at all, we break WPT tests.
+ // If we always wait, performance is bad.
+ await waitMacroQueue();
+ }
+
let event;
try {
BridgeIDBFactory.enableTracing &&
diff --git a/packages/idb-bridge/src/idb-wpt-ported/abort-in-initial-upgradeneeded.test.ts b/packages/idb-bridge/src/idb-wpt-ported/abort-in-initial-upgradeneeded.test.ts
index 3b65a9033..14d4f7d6e 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/abort-in-initial-upgradeneeded.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/abort-in-initial-upgradeneeded.test.ts
@@ -1,5 +1,7 @@
import test from "ava";
-import { createdb } from "./wptsupport";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
test("WPT test abort-in-initial-upgradeneeded.htm", async (t) => {
await new Promise<void>((resolve, reject) => {
diff --git a/packages/idb-bridge/src/idb-wpt-ported/close-in-upgradeneeded.test.ts b/packages/idb-bridge/src/idb-wpt-ported/close-in-upgradeneeded.test.ts
index 96abe3918..1a730df0b 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/close-in-upgradeneeded.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/close-in-upgradeneeded.test.ts
@@ -1,44 +1,46 @@
import test from "ava";
-import { BridgeIDBCursor } from "..";
-import { BridgeIDBCursorWithValue } from "../bridge-idb";
-import { createdb } from "./wptsupport";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
-// When db.close is called in upgradeneeded, the db is cleaned up on refresh
-test.cb("WPT test close-in-upgradeneeded.htm", (t) => {
- var db: any;
- var open_rq = createdb(t);
- var sawTransactionComplete = false;
-
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- t.deepEqual(db.version, 1);
+test.before("test DB initialization", initTestIndexedDB);
- db.createObjectStore("os");
- db.close();
+// When db.close is called in upgradeneeded, the db is cleaned up on refresh
+test("WPT test close-in-upgradeneeded.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ var open_rq = createdb(t);
+ var sawTransactionComplete = false;
+
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ t.deepEqual(db.version, 1);
+
+ db.createObjectStore("os");
+ db.close();
+
+ e.target.transaction.oncomplete = function () {
+ sawTransactionComplete = true;
+ };
+ };
- e.target.transaction.oncomplete = function () {
- sawTransactionComplete = true;
+ open_rq.onerror = function (e: any) {
+ t.true(sawTransactionComplete, "saw transaction.complete");
+
+ t.deepEqual(e.target.error.name, "AbortError");
+ t.deepEqual(e.result, undefined);
+
+ t.true(!!db);
+ t.deepEqual(db.version, 1);
+ t.deepEqual(db.objectStoreNames.length, 1);
+ t.throws(
+ () => {
+ db.transaction("os");
+ },
+ {
+ name: "InvalidStateError",
+ },
+ );
+
+ resolve();
};
- };
-
- open_rq.onerror = function (e: any) {
- t.true(sawTransactionComplete, "saw transaction.complete");
-
- t.deepEqual(e.target.error.name, "AbortError");
- t.deepEqual(e.result, undefined);
-
- t.true(!!db);
- t.deepEqual(db.version, 1);
- t.deepEqual(db.objectStoreNames.length, 1);
- t.throws(
- () => {
- db.transaction("os");
- },
- {
- name: "InvalidStateError",
- },
- );
-
- t.end();
- };
+ });
});
diff --git a/packages/idb-bridge/src/idb-wpt-ported/cursor-overloads.test.ts b/packages/idb-bridge/src/idb-wpt-ported/cursor-overloads.test.ts
index c4bce8743..795d515ed 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/cursor-overloads.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/cursor-overloads.test.ts
@@ -1,117 +1,132 @@
import test from "ava";
-import { BridgeIDBCursor, BridgeIDBKeyRange } from "..";
-import { BridgeIDBCursorWithValue } from "../bridge-idb";
-import { IDBRequest } from "../idbtypes";
-import { createdb } from "./wptsupport";
+import { BridgeIDBKeyRange } from "../bridge-idb.js";
+import { IDBRequest } from "../idbtypes.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
const IDBKeyRange = BridgeIDBKeyRange;
// Validate the overloads of IDBObjectStore.openCursor(),
// IDBIndex.openCursor() and IDBIndex.openKeyCursor()
-test.cb("WPT test cursor-overloads.htm", (t) => {
- var db: any, store: any, index: any;
+test("WPT test cursor-overloads.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any, store: any, index: any;
- var request = createdb(t);
- request.onupgradeneeded = function (e: any) {
- db = request.result;
- store = db.createObjectStore("store");
- index = store.createIndex("index", "value");
- store.put({ value: 0 }, 0);
- const trans = request.transaction!;
- trans.oncomplete = verifyOverloads;
- };
+ var request = createdb(t);
+ request.onupgradeneeded = function (e: any) {
+ db = request.result;
+ store = db.createObjectStore("store");
+ index = store.createIndex("index", "value");
+ store.put({ value: 0 }, 0);
+ const trans = request.transaction!;
+ trans.oncomplete = verifyOverloads;
+ };
- async function verifyOverloads() {
- const trans = db.transaction("store");
- store = trans.objectStore("store");
- index = store.index("index");
+ async function verifyOverloads() {
+ const trans = db.transaction("store");
+ store = trans.objectStore("store");
+ index = store.index("index");
- await checkCursorDirection(store.openCursor(), "next");
- await checkCursorDirection(store.openCursor(0), "next");
- await checkCursorDirection(store.openCursor(0, "next"), "next");
- await checkCursorDirection(store.openCursor(0, "nextunique"), "nextunique");
- await checkCursorDirection(store.openCursor(0, "prev"), "prev");
- await checkCursorDirection(store.openCursor(0, "prevunique"), "prevunique");
+ await checkCursorDirection(store.openCursor(), "next");
+ await checkCursorDirection(store.openCursor(0), "next");
+ await checkCursorDirection(store.openCursor(0, "next"), "next");
+ await checkCursorDirection(
+ store.openCursor(0, "nextunique"),
+ "nextunique",
+ );
+ await checkCursorDirection(store.openCursor(0, "prev"), "prev");
+ await checkCursorDirection(
+ store.openCursor(0, "prevunique"),
+ "prevunique",
+ );
- await checkCursorDirection(store.openCursor(IDBKeyRange.only(0)), "next");
- await checkCursorDirection(
- store.openCursor(BridgeIDBKeyRange.only(0), "next"),
- "next",
- );
- await checkCursorDirection(
- store.openCursor(IDBKeyRange.only(0), "nextunique"),
- "nextunique",
- );
- await checkCursorDirection(
- store.openCursor(IDBKeyRange.only(0), "prev"),
- "prev",
- );
- await checkCursorDirection(
- store.openCursor(IDBKeyRange.only(0), "prevunique"),
- "prevunique",
- );
+ await checkCursorDirection(store.openCursor(IDBKeyRange.only(0)), "next");
+ await checkCursorDirection(
+ store.openCursor(BridgeIDBKeyRange.only(0), "next"),
+ "next",
+ );
+ await checkCursorDirection(
+ store.openCursor(IDBKeyRange.only(0), "nextunique"),
+ "nextunique",
+ );
+ await checkCursorDirection(
+ store.openCursor(IDBKeyRange.only(0), "prev"),
+ "prev",
+ );
+ await checkCursorDirection(
+ store.openCursor(IDBKeyRange.only(0), "prevunique"),
+ "prevunique",
+ );
- await checkCursorDirection(index.openCursor(), "next");
- await checkCursorDirection(index.openCursor(0), "next");
- await checkCursorDirection(index.openCursor(0, "next"), "next");
- await checkCursorDirection(index.openCursor(0, "nextunique"), "nextunique");
- await checkCursorDirection(index.openCursor(0, "prev"), "prev");
- await checkCursorDirection(index.openCursor(0, "prevunique"), "prevunique");
+ await checkCursorDirection(index.openCursor(), "next");
+ await checkCursorDirection(index.openCursor(0), "next");
+ await checkCursorDirection(index.openCursor(0, "next"), "next");
+ await checkCursorDirection(
+ index.openCursor(0, "nextunique"),
+ "nextunique",
+ );
+ await checkCursorDirection(index.openCursor(0, "prev"), "prev");
+ await checkCursorDirection(
+ index.openCursor(0, "prevunique"),
+ "prevunique",
+ );
- await checkCursorDirection(index.openCursor(IDBKeyRange.only(0)), "next");
- await checkCursorDirection(
- index.openCursor(IDBKeyRange.only(0), "next"),
- "next",
- );
- await checkCursorDirection(
- index.openCursor(IDBKeyRange.only(0), "nextunique"),
- "nextunique",
- );
- await checkCursorDirection(
- index.openCursor(IDBKeyRange.only(0), "prev"),
- "prev",
- );
- await checkCursorDirection(
- index.openCursor(IDBKeyRange.only(0), "prevunique"),
- "prevunique",
- );
+ await checkCursorDirection(index.openCursor(IDBKeyRange.only(0)), "next");
+ await checkCursorDirection(
+ index.openCursor(IDBKeyRange.only(0), "next"),
+ "next",
+ );
+ await checkCursorDirection(
+ index.openCursor(IDBKeyRange.only(0), "nextunique"),
+ "nextunique",
+ );
+ await checkCursorDirection(
+ index.openCursor(IDBKeyRange.only(0), "prev"),
+ "prev",
+ );
+ await checkCursorDirection(
+ index.openCursor(IDBKeyRange.only(0), "prevunique"),
+ "prevunique",
+ );
- await checkCursorDirection(index.openKeyCursor(), "next");
- await checkCursorDirection(index.openKeyCursor(0), "next");
- await checkCursorDirection(index.openKeyCursor(0, "next"), "next");
- await checkCursorDirection(
- index.openKeyCursor(0, "nextunique"),
- "nextunique",
- );
- await checkCursorDirection(index.openKeyCursor(0, "prev"), "prev");
- await checkCursorDirection(
- index.openKeyCursor(0, "prevunique"),
- "prevunique",
- );
+ await checkCursorDirection(index.openKeyCursor(), "next");
+ await checkCursorDirection(index.openKeyCursor(0), "next");
+ await checkCursorDirection(index.openKeyCursor(0, "next"), "next");
+ await checkCursorDirection(
+ index.openKeyCursor(0, "nextunique"),
+ "nextunique",
+ );
+ await checkCursorDirection(index.openKeyCursor(0, "prev"), "prev");
+ await checkCursorDirection(
+ index.openKeyCursor(0, "prevunique"),
+ "prevunique",
+ );
- await checkCursorDirection(
- index.openKeyCursor(IDBKeyRange.only(0)),
- "next",
- );
- await checkCursorDirection(
- index.openKeyCursor(IDBKeyRange.only(0), "next"),
- "next",
- );
- await checkCursorDirection(
- index.openKeyCursor(IDBKeyRange.only(0), "nextunique"),
- "nextunique",
- );
- await checkCursorDirection(
- index.openKeyCursor(IDBKeyRange.only(0), "prev"),
- "prev",
- );
- await checkCursorDirection(
- index.openKeyCursor(IDBKeyRange.only(0), "prevunique"),
- "prevunique",
- );
+ await checkCursorDirection(
+ index.openKeyCursor(IDBKeyRange.only(0)),
+ "next",
+ );
+ await checkCursorDirection(
+ index.openKeyCursor(IDBKeyRange.only(0), "next"),
+ "next",
+ );
+ await checkCursorDirection(
+ index.openKeyCursor(IDBKeyRange.only(0), "nextunique"),
+ "nextunique",
+ );
+ await checkCursorDirection(
+ index.openKeyCursor(IDBKeyRange.only(0), "prev"),
+ "prev",
+ );
+ await checkCursorDirection(
+ index.openKeyCursor(IDBKeyRange.only(0), "prevunique"),
+ "prevunique",
+ );
- t.end();
- }
+ resolve();
+ }
+ });
function checkCursorDirection(
request: IDBRequest,
diff --git a/packages/idb-bridge/src/idb-wpt-ported/event-dispatch-active-flag.test.ts b/packages/idb-bridge/src/idb-wpt-ported/event-dispatch-active-flag.test.ts
index b8151f465..1d895c712 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/event-dispatch-active-flag.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/event-dispatch-active-flag.test.ts
@@ -1,13 +1,15 @@
import test from "ava";
-import { BridgeIDBRequest } from "..";
+import { BridgeIDBRequest } from "../bridge-idb.js";
import {
- createdb,
indexeddb_test,
+ initTestIndexedDB,
is_transaction_active,
keep_alive,
-} from "./wptsupport";
+} from "./wptsupport.js";
-test("WPT test abort-in-initial-upgradeneeded.htm (subtest 1)", async (t) => {
+test.before("test DB initialization", initTestIndexedDB);
+
+test("WPT test event-dispatch-active-flag.html (subtest 1)", async (t) => {
// Transactions are active during success handlers
await indexeddb_test(
t,
@@ -55,7 +57,7 @@ test("WPT test abort-in-initial-upgradeneeded.htm (subtest 1)", async (t) => {
);
});
-test("WPT test abort-in-initial-upgradeneeded.htm (subtest 2)", async (t) => {
+test("WPT test event-dispatch-active-flag.html (subtest 2)", async (t) => {
// Transactions are active during success listeners
await indexeddb_test(
t,
@@ -101,7 +103,7 @@ test("WPT test abort-in-initial-upgradeneeded.htm (subtest 2)", async (t) => {
);
});
-test("WPT test abort-in-initial-upgradeneeded.htm (subtest 3)", async (t) => {
+test("WPT test event-dispatch-active-flag.html (subtest 3)", async (t) => {
// Transactions are active during error handlers
await indexeddb_test(
t,
@@ -150,7 +152,7 @@ test("WPT test abort-in-initial-upgradeneeded.htm (subtest 3)", async (t) => {
);
});
-test("WPT test abort-in-initial-upgradeneeded.htm (subtest 4)", async (t) => {
+test("WPT test event-dispatch-active-flag.html (subtest 4)", async (t) => {
// Transactions are active during error listeners
await indexeddb_test(
t,
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-advance-index.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-advance-index.test.ts
index fac047990..1bf5ca697 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-advance-index.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-advance-index.test.ts
@@ -1,8 +1,8 @@
import test from "ava";
-import { BridgeIDBCursor } from "..";
-import { BridgeIDBRequest } from "../bridge-idb";
-import { InvalidStateError } from "../util/errors";
-import { createdb } from "./wptsupport";
+import { BridgeIDBCursor,BridgeIDBRequest } from "../bridge-idb.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
test("WPT test idbcursor_advance_index.htm", async (t) => {
await new Promise<void>((resolve, reject) => {
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-continue-index.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-continue-index.test.ts
index 9b96a2e91..3cea3e86d 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-continue-index.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-continue-index.test.ts
@@ -1,385 +1,404 @@
import test from "ava";
-import { BridgeIDBCursor } from "..";
-import { BridgeIDBCursorWithValue } from "../bridge-idb";
-import { createdb } from "./wptsupport";
-
-test.cb("WPT test idbcursor_continue_index.htm", (t) => {
- var db: any;
- let count = 0;
- const records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- { pKey: "primaryKey_1-2", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db
- .transaction("test")
- .objectStore("test")
- .index("index")
- .openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- if (!cursor) {
- t.deepEqual(count, records.length, "cursor run count");
- t.end();
- return;
- }
+import { BridgeIDBCursor, BridgeIDBCursorWithValue } from "../bridge-idb.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+import { IDBDatabase } from "../idbtypes.js";
+
+test.before("test DB initialization", initTestIndexedDB);
- var record = cursor.value;
- t.deepEqual(record.pKey, records[count].pKey, "primary key");
- t.deepEqual(record.iKey, records[count].iKey, "index key");
+test("WPT test idbcursor_continue_index.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ let count = 0;
+ const records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ { pKey: "primaryKey_1-2", iKey: "indexKey_1" },
+ ];
- cursor.continue();
- count++;
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+
+ objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
};
- };
-});
-// IDBCursor.continue() - index - attempt to pass a key parameter that is not a valid key
-test.cb("WPT idbcursor-continue-index2.htm", (t) => {
- var db: any;
- let records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db
- .transaction("test")
- .objectStore("test")
- .index("index")
- .openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
-
- t.throws(
- () => {
- cursor.continue({ foo: "bar" });
- },
- { name: "DataError" },
- );
-
- t.true(cursor instanceof BridgeIDBCursorWithValue, "cursor");
-
- t.end();
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db
+ .transaction("test")
+ .objectStore("test")
+ .index("index")
+ .openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ if (!cursor) {
+ t.deepEqual(count, records.length, "cursor run count");
+ resolve();
+ return;
+ }
+
+ var record = cursor.value;
+ t.deepEqual(record.pKey, records[count].pKey, "primary key");
+ t.deepEqual(record.iKey, records[count].iKey, "index key");
+
+ cursor.continue();
+ count++;
+ };
};
- };
+ });
});
-// IDBCursor.continue() - index - attempt to iterate to the previous
-// record when the direction is set for the next record
-test.cb("WPT idbcursor-continue-index3.htm", (t) => {
- var db: any;
- const records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var count = 0;
- var cursor_rq = db
- .transaction("test")
- .objectStore("test")
- .index("index")
- .openCursor(undefined, "next"); // XXX: Fx has issue with "undefined"
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- if (!cursor) {
- t.deepEqual(count, 2, "ran number of times");
- t.end();
- return;
- }
+// IDBCursor.continue() - index - attempt to pass a key parameter that is not a valid key
+test("WPT idbcursor-continue-index2.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ let records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
- // First time checks key equal, second time checks key less than
- t.throws(
- () => {
- cursor.continue(records[0].iKey);
- },
- { name: "DataError" },
- );
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- cursor.continue();
+ objStore.createIndex("index", "iKey");
- count++;
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
};
- };
-});
-// IDBCursor.continue() - index - attempt to iterate to the next
-// record when the direction is set for the previous record
-test.cb("WPT idbcursor-continue-index4.htm", (t) => {
- var db: any;
- const records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- { pKey: "primaryKey_2", iKey: "indexKey_2" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var count = 0,
- cursor_rq = db
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db
.transaction("test")
.objectStore("test")
.index("index")
- .openCursor(undefined, "prev"); // XXX Fx issues w undefined
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result,
- record = cursor.value;
-
- switch (count) {
- case 0:
- t.deepEqual(record.pKey, records[2].pKey, "first pKey");
- t.deepEqual(record.iKey, records[2].iKey, "first iKey");
- cursor.continue();
- break;
-
- case 1:
- t.deepEqual(record.pKey, records[1].pKey, "second pKey");
- t.deepEqual(record.iKey, records[1].iKey, "second iKey");
- t.throws(
- () => {
- cursor.continue("indexKey_2");
- },
- { name: "DataError" },
- );
- t.end();
- break;
-
- default:
- t.fail("Unexpected count value: " + count);
- }
+ .openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+
+ t.throws(
+ () => {
+ cursor.continue({ foo: "bar" });
+ },
+ { name: "DataError" },
+ );
- count++;
+ t.true(cursor instanceof BridgeIDBCursorWithValue, "cursor");
+
+ resolve();
+ };
};
- };
+ });
});
-// IDBCursor.continue() - index - iterate using 'prevunique'
-test.cb("WPT idbcursor-continue-index5.htm", (t) => {
- var db: any;
- const records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- { pKey: "primaryKey_1-2", iKey: "indexKey_1" },
- { pKey: "primaryKey_2", iKey: "indexKey_2" },
- ];
- const expected = [
- { pKey: "primaryKey_2", iKey: "indexKey_2" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var count = 0,
- cursor_rq = db
- .transaction("test")
- .objectStore("test")
- .index("index")
- .openCursor(undefined, "prevunique");
+// IDBCursor.continue() - index - attempt to iterate to the previous
+// record when the direction is set for the next record
+test("WPT idbcursor-continue-index3.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ const records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
- cursor_rq.onsuccess = function (e: any) {
- if (!e.target.result) {
- t.deepEqual(count, expected.length, "count");
- t.end();
- return;
- }
- const cursor = e.target.result;
- const record = cursor.value;
- t.deepEqual(record.pKey, expected[count].pKey, "pKey #" + count);
- t.deepEqual(record.iKey, expected[count].iKey, "iKey #" + count);
-
- t.deepEqual(cursor.key, expected[count].iKey, "cursor.key #" + count);
- t.deepEqual(
- cursor.primaryKey,
- expected[count].pKey,
- "cursor.primaryKey #" + count,
- );
-
- count++;
- cursor.continue(expected[count] ? expected[count].iKey : undefined);
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+
+ objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
};
- };
-});
-// IDBCursor.continue() - index - iterate using nextunique
-test.cb("WPT idbcursor-continue-index6.htm", (t) => {
- var db: any;
- const records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- { pKey: "primaryKey_1-2", iKey: "indexKey_1" },
- { pKey: "primaryKey_2", iKey: "indexKey_2" },
- ];
- const expected = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- { pKey: "primaryKey_2", iKey: "indexKey_2" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var count = 0,
- cursor_rq = db
+ open_rq.onsuccess = function (e: any) {
+ var count = 0;
+ var cursor_rq = db
.transaction("test")
.objectStore("test")
.index("index")
- .openCursor(undefined, "nextunique");
+ .openCursor(undefined, "next"); // XXX: Fx has issue with "undefined"
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ if (!cursor) {
+ t.deepEqual(count, 2, "ran number of times");
+ resolve();
+ return;
+ }
+
+ // First time checks key equal, second time checks key less than
+ t.throws(
+ () => {
+ cursor.continue(records[0].iKey);
+ },
+ { name: "DataError" },
+ );
+
+ cursor.continue();
+
+ count++;
+ };
+ };
+ });
+});
- cursor_rq.onsuccess = function (e: any) {
- if (!e.target.result) {
- t.deepEqual(count, expected.length, "count");
- t.end();
- return;
- }
- var cursor = e.target.result,
- record = cursor.value;
+// IDBCursor.continue() - index - attempt to iterate to the next
+// record when the direction is set for the previous record
+test("WPT idbcursor-continue-index4.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ const records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ { pKey: "primaryKey_2", iKey: "indexKey_2" },
+ ];
- t.deepEqual(record.pKey, expected[count].pKey, "pKey #" + count);
- t.deepEqual(record.iKey, expected[count].iKey, "iKey #" + count);
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- t.deepEqual(cursor.key, expected[count].iKey, "cursor.key #" + count);
- t.deepEqual(
- cursor.primaryKey,
- expected[count].pKey,
- "cursor.primaryKey #" + count,
- );
+ objStore.createIndex("index", "iKey");
- count++;
- cursor.continue(expected[count] ? expected[count].iKey : undefined);
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
};
- };
+
+ open_rq.onsuccess = function (e: any) {
+ var count = 0,
+ cursor_rq = db
+ .transaction("test")
+ .objectStore("test")
+ .index("index")
+ .openCursor(undefined, "prev"); // XXX Fx issues w undefined
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result,
+ record = cursor.value;
+
+ switch (count) {
+ case 0:
+ t.deepEqual(record.pKey, records[2].pKey, "first pKey");
+ t.deepEqual(record.iKey, records[2].iKey, "first iKey");
+ cursor.continue();
+ break;
+
+ case 1:
+ t.deepEqual(record.pKey, records[1].pKey, "second pKey");
+ t.deepEqual(record.iKey, records[1].iKey, "second iKey");
+ t.throws(
+ () => {
+ cursor.continue("indexKey_2");
+ },
+ { name: "DataError" },
+ );
+ resolve();
+ break;
+
+ default:
+ t.fail("Unexpected count value: " + count);
+ }
+
+ count++;
+ };
+ };
+ });
});
-// IDBCursor.continue() - index - throw TransactionInactiveError
-test.cb("WPT idbcursor-continue-index7.htm", (t) => {
- var db: any,
- records = [
+// IDBCursor.continue() - index - iterate using 'prevunique'
+test("WPT idbcursor-continue-index5.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: IDBDatabase;
+ const records = [
{ pKey: "primaryKey_0", iKey: "indexKey_0" },
{ pKey: "primaryKey_1", iKey: "indexKey_1" },
+ { pKey: "primaryKey_1-2", iKey: "indexKey_1" },
+ { pKey: "primaryKey_2", iKey: "indexKey_2" },
+ ];
+ const expected = [
+ { pKey: "primaryKey_2", iKey: "indexKey_2" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
];
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (event: any) {
- db = event.target.result;
- var objStore = db.createObjectStore("store", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
- for (var i = 0; i < records.length; i++) {
- objStore.add(records[i]);
- }
- var rq = objStore.index("index").openCursor();
- rq.onsuccess = function (event: any) {
- var cursor = event.target.result;
- t.true(cursor instanceof BridgeIDBCursor);
-
- event.target.transaction.abort();
- t.throws(
- () => {
- cursor.continue();
- },
- { name: "TransactionInactiveError" },
- "Calling continue() should throws an exception TransactionInactiveError when the transaction is not active.",
- );
- t.end();
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+
+ objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var count = 0,
+ cursor_rq = db
+ .transaction("test")
+ .objectStore("test")
+ .index("index")
+ .openCursor(undefined, "prevunique");
+
+ cursor_rq.onsuccess = function (e: any) {
+ if (!e.target.result) {
+ t.deepEqual(count, expected.length, "count");
+ resolve();
+ return;
+ }
+ const cursor = e.target.result;
+ const record = cursor.value;
+ t.deepEqual(record.pKey, expected[count].pKey, "pKey #" + count);
+ t.deepEqual(record.iKey, expected[count].iKey, "iKey #" + count);
+
+ t.deepEqual(cursor.key, expected[count].iKey, "cursor.key #" + count);
+ t.deepEqual(
+ cursor.primaryKey,
+ expected[count].pKey,
+ "cursor.primaryKey #" + count,
+ );
+
+ count++;
+ cursor.continue(expected[count] ? expected[count].iKey : undefined);
+ };
};
- };
+ });
});
-// IDBCursor.continue() - index - throw InvalidStateError caused by object store been deleted
-test.cb("WPT idbcursor-continue-index8.htm", (t) => {
- var db: any,
- records = [
+// IDBCursor.continue() - index - iterate using nextunique
+test("WPT idbcursor-continue-index6.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ const records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ { pKey: "primaryKey_1-2", iKey: "indexKey_1" },
+ { pKey: "primaryKey_2", iKey: "indexKey_2" },
+ ];
+ const expected = [
{ pKey: "primaryKey_0", iKey: "indexKey_0" },
{ pKey: "primaryKey_1", iKey: "indexKey_1" },
+ { pKey: "primaryKey_2", iKey: "indexKey_2" },
];
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (event: any) {
- db = event.target.result;
- var objStore = db.createObjectStore("store", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
- for (var i = 0; i < records.length; i++) {
- objStore.add(records[i]);
- }
- var rq = objStore.index("index").openCursor();
- rq.onsuccess = function (event: any) {
- var cursor = event.target.result;
- t.true(cursor instanceof BridgeIDBCursor);
-
- db.deleteObjectStore("store");
-
- t.throws(
- () => {
- cursor.continue();
- },
- { name: "InvalidStateError" },
- "If the cursor's source or effective object store has been deleted, the implementation MUST throw a DOMException of type InvalidStateError",
- );
-
- t.end();
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+
+ objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var count = 0,
+ cursor_rq = db
+ .transaction("test")
+ .objectStore("test")
+ .index("index")
+ .openCursor(undefined, "nextunique");
+
+ cursor_rq.onsuccess = function (e: any) {
+ if (!e.target.result) {
+ t.deepEqual(count, expected.length, "count");
+ resolve();
+ return;
+ }
+ var cursor = e.target.result,
+ record = cursor.value;
+
+ t.deepEqual(record.pKey, expected[count].pKey, "pKey #" + count);
+ t.deepEqual(record.iKey, expected[count].iKey, "iKey #" + count);
+
+ t.deepEqual(cursor.key, expected[count].iKey, "cursor.key #" + count);
+ t.deepEqual(
+ cursor.primaryKey,
+ expected[count].pKey,
+ "cursor.primaryKey #" + count,
+ );
+
+ count++;
+ cursor.continue(expected[count] ? expected[count].iKey : undefined);
+ };
+ };
+ });
+});
+
+// IDBCursor.continue() - index - throw TransactionInactiveError
+test("WPT idbcursor-continue-index7.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (event: any) {
+ db = event.target.result;
+ var objStore = db.createObjectStore("store", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+ for (var i = 0; i < records.length; i++) {
+ objStore.add(records[i]);
+ }
+ var rq = objStore.index("index").openCursor();
+ rq.onsuccess = function (event: any) {
+ var cursor = event.target.result;
+ t.true(cursor instanceof BridgeIDBCursor);
+
+ event.target.transaction.abort();
+ t.throws(
+ () => {
+ cursor.continue();
+ },
+ { name: "TransactionInactiveError" },
+ "Calling continue() should throws an exception TransactionInactiveError when the transaction is not active.",
+ );
+ resolve();
+ return;
+ };
+ };
+ });
+});
+
+// IDBCursor.continue() - index - throw InvalidStateError caused by object store been deleted
+test("WPT idbcursor-continue-index8.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (event: any) {
+ db = event.target.result;
+ var objStore = db.createObjectStore("store", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+ for (var i = 0; i < records.length; i++) {
+ objStore.add(records[i]);
+ }
+ var rq = objStore.index("index").openCursor();
+ rq.onsuccess = function (event: any) {
+ var cursor = event.target.result;
+ t.true(cursor instanceof BridgeIDBCursor);
+
+ db.deleteObjectStore("store");
+
+ t.throws(
+ () => {
+ cursor.continue();
+ },
+ { name: "InvalidStateError" },
+ "If the cursor's source or effective object store has been deleted, the implementation MUST throw a DOMException of type InvalidStateError",
+ );
+
+ resolve();
+ };
};
- };
+ });
});
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-continue-objectstore.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-continue-objectstore.test.ts
index 4843b13ab..d8b6f2b31 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-continue-objectstore.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-continue-objectstore.test.ts
@@ -1,243 +1,266 @@
import test from "ava";
-import { BridgeIDBCursor } from "..";
-import { BridgeIDBCursorWithValue } from "../bridge-idb";
-import { IDBDatabase } from "../idbtypes";
-import { createdb } from "./wptsupport";
+import { BridgeIDBCursor } from "../bridge-idb.js";
+import { IDBDatabase } from "../idbtypes.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IDBCursor.continue() - object store - iterate to the next record
-test.cb("WPT test idbcursor_continue_objectstore.htm", (t) => {
- var db: any;
- let count = 0;
- const records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", {
- autoIncrement: true,
- keyPath: "pKey",
- });
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var store = db.transaction("test").objectStore("test");
-
- var cursor_rq = store.openCursor();
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- if (!cursor) {
- t.deepEqual(count, records.length, "cursor run count");
- t.end();
- }
-
- var record = cursor.value;
- t.deepEqual(record.pKey, records[count].pKey, "primary key");
-
- cursor.continue();
- count++;
+test("WPT test idbcursor_continue_objectstore.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ let count = 0;
+ const records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", {
+ autoIncrement: true,
+ keyPath: "pKey",
+ });
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var store = db.transaction("test").objectStore("test");
+
+ var cursor_rq = store.openCursor();
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ if (!cursor) {
+ t.deepEqual(count, records.length, "cursor run count");
+ resolve();
+ return;
+ }
+
+ var record = cursor.value;
+ t.deepEqual(record.pKey, records[count].pKey, "primary key");
+
+ cursor.continue();
+ count++;
+ };
};
- };
+ });
});
// IDBCursor.continue() - index - attempt to pass a
// key parameter that is not a valid key
-test.cb("WPT test idbcursor_continue_objectstore2.htm", (t) => {
- var db: any;
- const records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
+test("WPT test idbcursor_continue_objectstore2.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ const records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db.transaction("test").objectStore("test").openCursor();
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db.transaction("test").objectStore("test").openCursor();
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exists");
- t.throws(
- () => {
- cursor.continue({ foo: "42" });
- },
- { name: "DataError" },
- );
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exists");
+ t.throws(
+ () => {
+ cursor.continue({ foo: "42" });
+ },
+ { name: "DataError" },
+ );
- t.end();
+ resolve();
+ };
};
- };
+ });
});
// IDBCursor.continue() - object store - attempt to iterate to the
// previous record when the direction is set for the next record
-test.cb("WPT test idbcursor_continue_objectstore3.htm", (t) => {
- var db: IDBDatabase;
- const records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db
- .transaction("test")
- .objectStore("test")
- .openCursor(undefined, "next");
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
-
- t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
- t.throws(
- () => {
- cursor.continue(records[0].pKey);
- },
- {
- name: "DataError",
- },
- );
-
- t.end();
+test("WPT test idbcursor_continue_objectstore3.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: IDBDatabase;
+ const records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
};
- };
+
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db
+ .transaction("test")
+ .objectStore("test")
+ .openCursor(undefined, "next");
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
+ t.throws(
+ () => {
+ cursor.continue(records[0].pKey);
+ },
+ {
+ name: "DataError",
+ },
+ );
+
+ resolve();
+ };
+ };
+ });
});
// IDBCursor.continue() - object store - attempt to iterate to the
// next record when the direction is set for the previous record
-test.cb("WPT test idbcursor_continue_objectstore4.htm", (t) => {
- var db: any;
- const records = [
- { pKey: "primaryKey_0" },
- { pKey: "primaryKey_1" },
- { pKey: "primaryKey_2" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var count = 0,
- cursor_rq = db
- .transaction("test")
- .objectStore("test")
- .openCursor(null, "prev");
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
-
- t.true(cursor != null, "cursor exist");
-
- switch (count) {
- case 0:
- t.deepEqual(cursor.value.pKey, records[2].pKey, "first cursor pkey");
- cursor.continue(records[1].pKey);
- break;
-
- case 1:
- t.deepEqual(cursor.value.pKey, records[1].pKey, "second cursor pkey");
- t.throws(
- () => {
- console.log("**** continuing cursor");
- cursor.continue(records[2].pKey);
- console.log("**** this should not happen");
- },
- {
- name: "DataError",
- },
- );
- t.end();
- break;
-
- default:
- t.fail("Unexpected count value: " + count);
- }
-
- count++;
+test("WPT test idbcursor_continue_objectstore4.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ const records = [
+ { pKey: "primaryKey_0" },
+ { pKey: "primaryKey_1" },
+ { pKey: "primaryKey_2" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var count = 0,
+ cursor_rq = db
+ .transaction("test")
+ .objectStore("test")
+ .openCursor(null, "prev");
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+
+ t.true(cursor != null, "cursor exist");
+
+ switch (count) {
+ case 0:
+ t.deepEqual(
+ cursor.value.pKey,
+ records[2].pKey,
+ "first cursor pkey",
+ );
+ cursor.continue(records[1].pKey);
+ break;
+
+ case 1:
+ t.deepEqual(
+ cursor.value.pKey,
+ records[1].pKey,
+ "second cursor pkey",
+ );
+ t.throws(
+ () => {
+ console.log("**** continuing cursor");
+ cursor.continue(records[2].pKey);
+ console.log("**** this should not happen");
+ },
+ {
+ name: "DataError",
+ },
+ );
+ resolve();
+ return;
+
+ default:
+ t.fail("Unexpected count value: " + count);
+ }
+
+ count++;
+ };
};
- };
+ });
});
// IDBCursor.continue() - object store - throw TransactionInactiveError
-test.cb("WPT test idbcursor_continue_objectstore5.htm", (t) => {
- var db: any;
- const records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db.transaction("test").objectStore("test").openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exists");
-
- e.target.transaction.abort();
- t.throws(
- () => {
- cursor.continue();
- },
- {
- name: "TransactionInactiveError",
- },
- "Calling continue() should throw an exception TransactionInactiveError when the transaction is not active.",
- );
-
- t.end();
+test("WPT test idbcursor_continue_objectstore5.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ const records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db.transaction("test").objectStore("test").openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exists");
+
+ e.target.transaction.abort();
+ t.throws(
+ () => {
+ cursor.continue();
+ },
+ {
+ name: "TransactionInactiveError",
+ },
+ "Calling continue() should throw an exception TransactionInactiveError when the transaction is not active.",
+ );
+
+ resolve();
+ return;
+ };
};
- };
+ });
});
// IDBCursor.continue() - object store - throw InvalidStateError caused by object store been deleted
-test.cb("WPT test idbcursor_continue_objectstore6.htm", (t) => {
- var db: any;
- const records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
-
- var cursor_rq = objStore.openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exists");
-
- db.deleteObjectStore("test");
- t.throws(
- () => {
- cursor.continue();
- },
- {
- name: "InvalidStateError",
- },
- "If the cursor's source or effective object store has been deleted, the implementation MUST throw a DOMException of type InvalidStateError",
- );
-
- t.end();
+test("WPT test idbcursor_continue_objectstore6.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ const records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+
+ var cursor_rq = objStore.openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exists");
+
+ db.deleteObjectStore("test");
+ t.throws(
+ () => {
+ cursor.continue();
+ },
+ {
+ name: "InvalidStateError",
+ },
+ "If the cursor's source or effective object store has been deleted, the implementation MUST throw a DOMException of type InvalidStateError",
+ );
+
+ resolve();
+ };
};
- };
+ });
});
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-exception-order.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-exception-order.test.ts
index 604061acd..e159129da 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-exception-order.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-exception-order.test.ts
@@ -1,5 +1,7 @@
import test from "ava";
-import { createdb, indexeddb_test } from "./wptsupport";
+import { indexeddb_test, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
test("WPT idbcursor-delete-exception-order.htm", async (t) => {
// 'IDBCursor.delete exception order: TransactionInactiveError vs. ReadOnlyError'
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-index.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-index.test.ts
index 0b28a4d4d..d34c9c3f9 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-index.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-index.test.ts
@@ -1,204 +1,216 @@
import test from "ava";
-import { BridgeIDBCursor } from "..";
-import { IDBCursor } from "../idbtypes";
-import { createdb, indexeddb_test } from "./wptsupport";
+import { BridgeIDBCursor } from "../bridge-idb.js";
+import { IDBCursor } from "../idbtypes.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
-// IDBCursor.delete() - index - remove a record from the object store
-test.cb("WPT idbcursor-delete-index.htm", (t) => {
- var db: any;
- let count = 0,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
+test.before("test DB initialization", initTestIndexedDB);
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
+// IDBCursor.delete() - index - remove a record from the object store
+test("WPT idbcursor-delete-index.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ let count = 0,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
- open_rq.onsuccess = CursorDeleteRecord;
+ open_rq.onsuccess = CursorDeleteRecord;
- function CursorDeleteRecord(e: any) {
- var txn = db.transaction("test", "readwrite"),
- cursor_rq = txn.objectStore("test").index("index").openCursor();
+ function CursorDeleteRecord(e: any) {
+ var txn = db.transaction("test", "readwrite"),
+ cursor_rq = txn.objectStore("test").index("index").openCursor();
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
- cursor.delete();
- };
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
+ cursor.delete();
+ };
- txn.oncomplete = VerifyRecordWasDeleted;
- }
+ txn.oncomplete = VerifyRecordWasDeleted;
+ }
- function VerifyRecordWasDeleted(e: any) {
- var cursor_rq = db.transaction("test").objectStore("test").openCursor();
+ function VerifyRecordWasDeleted(e: any) {
+ var cursor_rq = db.transaction("test").objectStore("test").openCursor();
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
- if (!cursor) {
- t.deepEqual(count, 1, "count");
- t.end();
- return;
- }
+ if (!cursor) {
+ t.deepEqual(count, 1, "count");
+ resolve();
+ return;
+ }
- t.deepEqual(cursor.value.pKey, records[1].pKey);
- t.deepEqual(cursor.value.iKey, records[1].iKey);
- cursor.continue();
- count++;
- };
- }
+ t.deepEqual(cursor.value.pKey, records[1].pKey);
+ t.deepEqual(cursor.value.iKey, records[1].iKey);
+ cursor.continue();
+ count++;
+ };
+ }
+ });
});
// IDBCursor.delete() - object store - attempt to remove a record in a read-only transaction
-test.cb("WPT idbcursor-delete-index2.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db.transaction("test").objectStore("test").openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
-
- t.true(cursor != null, "cursor exist");
- t.throws(
- () => {
- cursor.delete();
- },
- {
- name: "ReadOnlyError",
- },
- );
- t.end();
- };
- };
-});
+test("WPT idbcursor-delete-index2.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
-// IDBCursor.delete() - index - attempt to remove a record in an inactive transaction
-test.cb("WPT idbcursor-delete-index3.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- var index = objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
- var cursor_rq = index.openCursor();
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- let myCursor: IDBCursor | undefined;
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
- myCursor = cursor;
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db.transaction("test").objectStore("test").openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+
+ t.true(cursor != null, "cursor exist");
+ t.throws(
+ () => {
+ cursor.delete();
+ },
+ {
+ name: "ReadOnlyError",
+ },
+ );
+ resolve();
+ };
};
+ });
+});
- e.target.transaction.oncomplete = function (e: any) {
- t.throws(
- () => {
- myCursor!.delete();
- },
- { name: "TransactionInactiveError" },
- );
- t.end();
+// IDBCursor.delete() - index - attempt to remove a record in an inactive transaction
+test("WPT idbcursor-delete-index3.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ var index = objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+
+ var cursor_rq = index.openCursor();
+
+ let myCursor: IDBCursor | undefined;
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
+ myCursor = cursor;
+ };
+
+ e.target.transaction.oncomplete = function (e: any) {
+ t.throws(
+ () => {
+ myCursor!.delete();
+ },
+ { name: "TransactionInactiveError" },
+ );
+ resolve();
+ };
};
- };
+ });
});
// IDBCursor.delete() - index - throw InvalidStateError caused by object store been deleted
-test.cb("WPT idbcursor-delete-index4.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (event: any) {
- db = event.target.result;
- var objStore = db.createObjectStore("store", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
- for (var i = 0; i < records.length; i++) {
- objStore.add(records[i]);
- }
- var rq = objStore.index("index").openCursor();
- rq.onsuccess = function (event: any) {
- var cursor = event.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
-
- db.deleteObjectStore("store");
- t.throws(
- function () {
- cursor.delete();
- },
- { name: "InvalidStateError" },
- "If the cursor's source or effective object store has been deleted, the implementation MUST throw a DOMException of type InvalidStateError",
- );
-
- t.end();
+test("WPT idbcursor-delete-index4.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (event: any) {
+ db = event.target.result;
+ var objStore = db.createObjectStore("store", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+ for (var i = 0; i < records.length; i++) {
+ objStore.add(records[i]);
+ }
+ var rq = objStore.index("index").openCursor();
+ rq.onsuccess = function (event: any) {
+ var cursor = event.target.result;
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
+
+ db.deleteObjectStore("store");
+ t.throws(
+ function () {
+ cursor.delete();
+ },
+ { name: "InvalidStateError" },
+ "If the cursor's source or effective object store has been deleted, the implementation MUST throw a DOMException of type InvalidStateError",
+ );
+
+ resolve();
+ };
};
- };
+ });
});
// IDBCursor.delete() - index - throw InvalidStateError when the cursor is being iterated
-test.cb("WPT idbcursor-delete-index5.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (event: any) {
- db = event.target.result;
- var objStore = db.createObjectStore("store", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
- for (var i = 0; i < records.length; i++) {
- objStore.add(records[i]);
- }
-
- var rq = objStore.index("index").openCursor();
- rq.onsuccess = function (event: any) {
- var cursor = event.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
-
- cursor.continue();
- t.throws(
- function () {
- cursor.delete();
- },
- { name: "InvalidStateError" },
- );
+test("WPT idbcursor-delete-index5.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (event: any) {
+ db = event.target.result;
+ var objStore = db.createObjectStore("store", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+ for (var i = 0; i < records.length; i++) {
+ objStore.add(records[i]);
+ }
- t.end();
+ var rq = objStore.index("index").openCursor();
+ rq.onsuccess = function (event: any) {
+ var cursor = event.target.result;
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
+
+ cursor.continue();
+ t.throws(
+ function () {
+ cursor.delete();
+ },
+ { name: "InvalidStateError" },
+ );
+
+ resolve();
+ };
};
- };
+ });
});
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-objectstore.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-objectstore.test.ts
index 7afe1e483..2b9993b19 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-objectstore.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-delete-objectstore.test.ts
@@ -1,194 +1,206 @@
import test from "ava";
-import { BridgeIDBCursor } from "..";
-import { IDBCursor } from "../idbtypes";
-import { createdb, indexeddb_test } from "./wptsupport";
+import { BridgeIDBCursor } from "../bridge-idb.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IDBCursor.delete() - object store - remove a record from the object store
-test.cb("WPT idbcursor-delete-objectstore.htm", (t) => {
- var db: any,
- count = 0,
- records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
+test("WPT idbcursor-delete-objectstore.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ count = 0,
+ records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
- open_rq.onsuccess = CursorDeleteRecord;
+ open_rq.onsuccess = CursorDeleteRecord;
- function CursorDeleteRecord(e: any) {
- var txn = db.transaction("test", "readwrite"),
- cursor_rq = txn.objectStore("test").openCursor();
+ function CursorDeleteRecord(e: any) {
+ var txn = db.transaction("test", "readwrite"),
+ cursor_rq = txn.objectStore("test").openCursor();
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
- t.true(cursor != null, "cursor exist");
- cursor.delete();
- };
+ t.true(cursor != null, "cursor exist");
+ cursor.delete();
+ };
- txn.oncomplete = VerifyRecordWasDeleted;
- }
+ txn.oncomplete = VerifyRecordWasDeleted;
+ }
- function VerifyRecordWasDeleted(e: any) {
- var cursor_rq = db.transaction("test").objectStore("test").openCursor();
+ function VerifyRecordWasDeleted(e: any) {
+ var cursor_rq = db.transaction("test").objectStore("test").openCursor();
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
- if (!cursor) {
- t.deepEqual(count, 1, "count");
- t.end();
- }
+ if (!cursor) {
+ t.deepEqual(count, 1, "count");
+ resolve();
+ return;
+ }
- t.deepEqual(cursor.value.pKey, records[1].pKey);
- count++;
- cursor.continue();
- };
- }
+ t.deepEqual(cursor.value.pKey, records[1].pKey);
+ count++;
+ cursor.continue();
+ };
+ }
+ });
});
// IDBCursor.delete() - object store - attempt to remove a record in a read-only transaction
-test.cb("WPT idbcursor-delete-objectstore2.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db.transaction("test").objectStore("test").openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
-
- t.true(cursor != null, "cursor exist");
- t.throws(
- function () {
- cursor.delete();
- },
- { name: "ReadOnlyError" },
- );
- t.end();
- };
- };
-});
-
-// IDBCursor.delete() - index - attempt to remove a record in an inactive transaction
-test.cb("WPT idbcursor-delete-objectstore3.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
+test("WPT idbcursor-delete-objectstore2.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- var cursor_rq = objStore.openCursor();
-
- const window: any = {};
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
- window.cursor = cursor;
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db.transaction("test").objectStore("test").openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+
+ t.true(cursor != null, "cursor exist");
+ t.throws(
+ function () {
+ cursor.delete();
+ },
+ { name: "ReadOnlyError" },
+ );
+ resolve();
+ };
};
+ });
+});
- e.target.transaction.oncomplete = function (e: any) {
- t.throws(
- function () {
- window.cursor.delete();
- },
- {
- name: "TransactionInactiveError",
- },
- );
- t.end();
+// IDBCursor.delete() - index - attempt to remove a record in an inactive transaction
+test("WPT idbcursor-delete-objectstore3.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+
+ var cursor_rq = objStore.openCursor();
+
+ const window: any = {};
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
+ window.cursor = cursor;
+ };
+
+ e.target.transaction.oncomplete = function (e: any) {
+ t.throws(
+ function () {
+ window.cursor.delete();
+ },
+ {
+ name: "TransactionInactiveError",
+ },
+ );
+ resolve();
+ };
};
- };
+ });
});
// IDBCursor.delete() - object store - throw InvalidStateError caused by object store been deleted
-test.cb("WPT idbcursor-delete-objectstore4.htm", (t) => {
- var db: any,
- records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (event: any) {
- db = event.target.result;
- var objStore = db.createObjectStore("store", { keyPath: "pKey" });
- for (var i = 0; i < records.length; i++) {
- objStore.add(records[i]);
- }
- var rq = objStore.openCursor();
- rq.onsuccess = function (event: any) {
- var cursor = event.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
-
- db.deleteObjectStore("store");
- t.throws(
- function () {
- cursor.delete();
- },
- { name: "InvalidStateError" },
- "If the cursor's source or effective object store has been deleted, the implementation MUST throw a DOMException of type InvalidStateError",
- );
-
- t.end();
+test("WPT idbcursor-delete-objectstore4.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (event: any) {
+ db = event.target.result;
+ var objStore = db.createObjectStore("store", { keyPath: "pKey" });
+ for (var i = 0; i < records.length; i++) {
+ objStore.add(records[i]);
+ }
+ var rq = objStore.openCursor();
+ rq.onsuccess = function (event: any) {
+ var cursor = event.target.result;
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
+
+ db.deleteObjectStore("store");
+ t.throws(
+ function () {
+ cursor.delete();
+ },
+ { name: "InvalidStateError" },
+ "If the cursor's source or effective object store has been deleted, the implementation MUST throw a DOMException of type InvalidStateError",
+ );
+
+ resolve();
+ };
};
- };
+ });
});
// IDBCursor.delete() - object store - throw InvalidStateError when the cursor is being iterated
-test.cb("WPT idbcursor-delete-objectstore5.htm", (t) => {
- var db: any,
- records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (event: any) {
- db = event.target.result;
- var objStore = db.createObjectStore("store", { keyPath: "pKey" });
- for (var i = 0; i < records.length; i++) {
- objStore.add(records[i]);
- }
- };
-
- open_rq.onsuccess = function (event: any) {
- var txn = db.transaction("store", "readwrite");
- var rq = txn.objectStore("store").openCursor();
- rq.onsuccess = function (event: any) {
- var cursor = event.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
-
- cursor.continue();
- t.throws(
- function () {
- cursor.delete();
- },
- {
- name: "InvalidStateError",
- },
- );
-
- t.end();
+test("WPT idbcursor-delete-objectstore5.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [{ pKey: "primaryKey_0" }, { pKey: "primaryKey_1" }];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (event: any) {
+ db = event.target.result;
+ var objStore = db.createObjectStore("store", { keyPath: "pKey" });
+ for (var i = 0; i < records.length; i++) {
+ objStore.add(records[i]);
+ }
+ };
+
+ open_rq.onsuccess = function (event: any) {
+ var txn = db.transaction("store", "readwrite");
+ var rq = txn.objectStore("store").openCursor();
+ rq.onsuccess = function (event: any) {
+ var cursor = event.target.result;
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
+
+ cursor.continue();
+ t.throws(
+ function () {
+ cursor.delete();
+ },
+ {
+ name: "InvalidStateError",
+ },
+ );
+
+ resolve();
+ };
};
- };
+ });
});
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-reused.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-reused.test.ts
index 3c2ee875d..e0e6c2bf8 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-reused.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-reused.test.ts
@@ -1,5 +1,7 @@
import test from "ava";
-import { createdb } from "./wptsupport";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
test("WPT idbcursor-reused.htm", async (t) => {
await new Promise<void>((resolve, reject) => {
@@ -24,7 +26,7 @@ test("WPT idbcursor-reused.htm", async (t) => {
case 0:
cursor = e.target.result;
- t.deepEqual(cursor.value, "data", "prequisite cursor.value");
+ t.deepEqual(cursor.value, "data", "prerequisite cursor.value");
cursor.custom_cursor_value = 1;
e.target.custom_request_value = 2;
@@ -32,7 +34,7 @@ test("WPT idbcursor-reused.htm", async (t) => {
break;
case 1:
- t.deepEqual(cursor.value, "data2", "prequisite cursor.value");
+ t.deepEqual(cursor.value, "data2", "prerequisite cursor.value");
t.deepEqual(cursor.custom_cursor_value, 1, "custom cursor value");
t.deepEqual(
e.target.custom_request_value,
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-update-index.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-update-index.test.ts
index 363ef4afa..8a878b35a 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbcursor-update-index.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbcursor-update-index.test.ts
@@ -1,343 +1,359 @@
import test from "ava";
-import { BridgeIDBCursor, BridgeIDBKeyRange } from "..";
-import { BridgeIDBCursorWithValue } from "../bridge-idb";
-import { IDBDatabase } from "../idbtypes";
+import { BridgeIDBCursor, BridgeIDBKeyRange } from "../bridge-idb.js";
import {
createDatabase,
createdb,
+ initTestIndexedDB,
promiseForRequest,
promiseForTransaction,
-} from "./wptsupport";
+} from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IDBCursor.update() - index - modify a record in the object store
-test.cb("WPT test idbcursor_update_index.htm", (t) => {
- var db: any,
- count = 0,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
+test("WPT test idbcursor_update_index.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- // XXX: Gecko doesn't like this
- //e.target.transaction.oncomplete = t.step_func(CursorUpdateRecord);
- };
+ // XXX: Gecko doesn't like this
+ //e.target.transaction.oncomplete = t.step_func(CursorUpdateRecord);
+ };
- open_rq.onsuccess = CursorUpdateRecord;
+ open_rq.onsuccess = CursorUpdateRecord;
- function CursorUpdateRecord(e: any) {
- var txn = db.transaction("test", "readwrite"),
- cursor_rq = txn.objectStore("test").index("index").openCursor();
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
+ function CursorUpdateRecord(e: any) {
+ var txn = db.transaction("test", "readwrite"),
+ cursor_rq = txn.objectStore("test").index("index").openCursor();
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
- cursor.value.iKey += "_updated";
- cursor.update(cursor.value);
- };
+ cursor.value.iKey += "_updated";
+ cursor.update(cursor.value);
+ };
- txn.oncomplete = VerifyRecordWasUpdated;
- }
+ txn.oncomplete = VerifyRecordWasUpdated;
+ }
- function VerifyRecordWasUpdated(e: any) {
- var cursor_rq = db.transaction("test").objectStore("test").openCursor();
+ function VerifyRecordWasUpdated(e: any) {
+ var cursor_rq = db.transaction("test").objectStore("test").openCursor();
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
- t.deepEqual(cursor.value.iKey, records[0].iKey + "_updated");
- t.end();
- };
- }
+ t.deepEqual(cursor.value.iKey, records[0].iKey + "_updated");
+ resolve();
+ };
+ }
+ });
});
// IDBCursor.update() - index - attempt to modify a record in a read-only transaction
-test.cb("WPT test idbcursor_update_index2.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db
- .transaction("test")
- .objectStore("test")
- .index("index")
- .openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- t.throws(
- function () {
- cursor.update(cursor.value);
- },
- { name: "ReadOnlyError" },
- );
- t.end();
+test("WPT test idbcursor_update_index2.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
};
- };
-});
-//IDBCursor.update() - index - attempt to modify a record in an inactive transaction
-test.cb("WPT test idbcursor_update_index3.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- var index = objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
-
- var cursor_rq = index.openCursor();
-
- const window: any = {};
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
- window.cursor = cursor;
- window.record = cursor.value;
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db
+ .transaction("test")
+ .objectStore("test")
+ .index("index")
+ .openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ t.throws(
+ function () {
+ cursor.update(cursor.value);
+ },
+ { name: "ReadOnlyError" },
+ );
+ resolve();
+ };
};
+ });
+});
- e.target.transaction.oncomplete = function (e: any) {
- t.throws(
- function () {
- window.cursor.update(window.record);
- },
- { name: "TransactionInactiveError" },
- );
- t.end();
+//IDBCursor.update() - index - attempt to modify a record in an inactive transaction
+test("WPT test idbcursor_update_index3.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ var index = objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+
+ var cursor_rq = index.openCursor();
+
+ const window: any = {};
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exist");
+ window.cursor = cursor;
+ window.record = cursor.value;
+ };
+
+ e.target.transaction.oncomplete = function (e: any) {
+ t.throws(
+ function () {
+ window.cursor.update(window.record);
+ },
+ { name: "TransactionInactiveError" },
+ );
+ resolve();
+ };
};
- };
+ });
});
// IDBCursor.update() - index - attempt to modify a record when object store been deleted
-test.cb("WPT test idbcursor_update_index4.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (event: any) {
- db = event.target.result;
- var objStore = db.createObjectStore("store", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
- for (var i = 0; i < records.length; i++) {
- objStore.add(records[i]);
- }
- var rq = objStore.index("index").openCursor();
- rq.onsuccess = function (event: any) {
- var cursor = event.target.result;
- t.true(cursor instanceof BridgeIDBCursor);
-
- db.deleteObjectStore("store");
- cursor.value.iKey += "_updated";
- t.throws(
- function () {
- cursor.update(cursor.value);
- },
- { name: "InvalidStateError" },
- "If the cursor's source or effective object store has been deleted, the implementation MUST throw a DOMException of type InvalidStateError",
- );
-
- t.end();
+test("WPT test idbcursor_update_index4.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (event: any) {
+ db = event.target.result;
+ var objStore = db.createObjectStore("store", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+ for (var i = 0; i < records.length; i++) {
+ objStore.add(records[i]);
+ }
+ var rq = objStore.index("index").openCursor();
+ rq.onsuccess = function (event: any) {
+ var cursor = event.target.result;
+ t.true(cursor instanceof BridgeIDBCursor);
+
+ db.deleteObjectStore("store");
+ cursor.value.iKey += "_updated";
+ t.throws(
+ function () {
+ cursor.update(cursor.value);
+ },
+ { name: "InvalidStateError" },
+ "If the cursor's source or effective object store has been deleted, the implementation MUST throw a DOMException of type InvalidStateError",
+ );
+
+ resolve();
+ };
};
- };
+ });
});
// IDBCursor.update() - index - throw DataCloneError
-test.cb("WPT test idbcursor_update_index5.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db
- .transaction("test", "readwrite")
- .objectStore("test")
- .index("index")
- .openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor);
-
- var record = cursor.value;
- // Original test uses different uncloneable value
- record.data = { foo: () => {} };
- t.throws(
- function () {
- cursor.update(record);
- },
- { name: "DataCloneError" },
- );
- t.end();
+test("WPT test idbcursor_update_index5.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db
+ .transaction("test", "readwrite")
+ .objectStore("test")
+ .index("index")
+ .openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ t.true(cursor instanceof BridgeIDBCursor);
+
+ var record = cursor.value;
+ // Original test uses different uncloneable value
+ record.data = { foo: () => {} };
+ t.throws(
+ function () {
+ cursor.update(record);
+ },
+ { name: "DataCloneError" },
+ );
+ resolve();
+ };
};
- };
+ });
});
// IDBCursor.update() - index - no argument
-test.cb("WPT test idbcursor_update_index6.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db
- .transaction("test")
- .objectStore("test")
- .index("index")
- .openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor);
-
- t.throws(
- function () {
- cursor.update();
- },
- {
- instanceOf: TypeError,
- },
- );
- t.end();
+test("WPT test idbcursor_update_index6.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db
+ .transaction("test")
+ .objectStore("test")
+ .index("index")
+ .openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ t.true(cursor instanceof BridgeIDBCursor);
+
+ t.throws(
+ function () {
+ cursor.update();
+ },
+ {
+ instanceOf: TypeError,
+ },
+ );
+ resolve();
+ };
};
- };
+ });
});
// IDBCursor.update() - index - throw DataError
-test.cb("WPT test idbcursor_update_index7.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var objStore = db.createObjectStore("test", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db
- .transaction("test", "readwrite")
- .objectStore("test")
- .index("index")
- .openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor);
-
- t.throws(
- function () {
- cursor.update(null);
- },
- { name: "DataError" },
- );
- t.end();
+test("WPT test idbcursor_update_index7.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+
+ var objStore = db.createObjectStore("test", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db
+ .transaction("test", "readwrite")
+ .objectStore("test")
+ .index("index")
+ .openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ t.true(cursor instanceof BridgeIDBCursor);
+
+ t.throws(
+ function () {
+ cursor.update(null);
+ },
+ { name: "DataError" },
+ );
+ resolve();
+ };
};
- };
+ });
});
// IDBCursor.update() - index - throw InvalidStateError when the cursor is being iterated
-test.cb("WPT test idbcursor_update_index8.htm", (t) => {
- var db: any,
- records = [
- { pKey: "primaryKey_0", iKey: "indexKey_0" },
- { pKey: "primaryKey_1", iKey: "indexKey_1" },
- ];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var objStore = db.createObjectStore("store", { keyPath: "pKey" });
- objStore.createIndex("index", "iKey");
-
- for (var i = 0; i < records.length; i++) objStore.add(records[i]);
- };
-
- open_rq.onsuccess = function (e: any) {
- var cursor_rq = db
- .transaction("store", "readwrite")
- .objectStore("store")
- .index("index")
- .openCursor();
-
- cursor_rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
- t.true(cursor instanceof BridgeIDBCursor, "cursor exists");
+test("WPT test idbcursor_update_index8.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ records = [
+ { pKey: "primaryKey_0", iKey: "indexKey_0" },
+ { pKey: "primaryKey_1", iKey: "indexKey_1" },
+ ];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+
+ var objStore = db.createObjectStore("store", { keyPath: "pKey" });
+ objStore.createIndex("index", "iKey");
+
+ for (var i = 0; i < records.length; i++) objStore.add(records[i]);
+ };
- cursor.continue();
- t.throws(
- function () {
- cursor.update({ pKey: "primaryKey_0", iKey: "indexKey_0_updated" });
- },
- {
- name: "InvalidStateError",
- },
- );
-
- t.end();
+ open_rq.onsuccess = function (e: any) {
+ var cursor_rq = db
+ .transaction("store", "readwrite")
+ .objectStore("store")
+ .index("index")
+ .openCursor();
+
+ cursor_rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+ t.true(cursor instanceof BridgeIDBCursor, "cursor exists");
+
+ cursor.continue();
+ t.throws(
+ function () {
+ cursor.update({ pKey: "primaryKey_0", iKey: "indexKey_0_updated" });
+ },
+ {
+ name: "InvalidStateError",
+ },
+ );
+
+ resolve();
+ };
};
- };
+ });
});
// Index cursor - indexed values updated during iteration
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbfactory-cmp.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbfactory-cmp.test.ts
index c7a25a46b..450bec7be 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbfactory-cmp.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbfactory-cmp.test.ts
@@ -1,8 +1,10 @@
import test from "ava";
-import { createdb, idbFactory } from "./wptsupport";
+import { initTestIndexedDB, useTestIndexedDb } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
test("WPT idbfactory-cmp*.html", async (t) => {
- const indexedDB = idbFactory;
+ const indexedDB = useTestIndexedDb();
var greater = indexedDB.cmp(2, 1);
var equal = indexedDB.cmp(2, 2);
var less = indexedDB.cmp(1, 2);
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbfactory-open.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbfactory-open.test.ts
index bba9c6e54..b8046fc1b 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbfactory-open.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbfactory-open.test.ts
@@ -1,7 +1,10 @@
import test from "ava";
-import { BridgeIDBVersionChangeEvent } from "../bridge-idb";
-import FakeEvent from "../util/FakeEvent";
-import { createdb, format_value, idbFactory } from "./wptsupport";
+import { BridgeIDBVersionChangeEvent } from "../bridge-idb.js";
+import FakeEvent from "../util/FakeEvent.js";
+import { createdb, format_value, initTestIndexedDB, useTestIndexedDb } from "./wptsupport.js";
+import { IDBDatabase } from "../idbtypes.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IDBFactory.open() - request has no source
test("WPT idbfactory-open.htm", async (t) => {
@@ -36,7 +39,7 @@ test("WPT idbfactory-open2.htm", async (t) => {
// IDBFactory.open() - no version opens current database
test("WPT idbfactory-open3.htm", async (t) => {
- const indexedDB = idbFactory;
+ const indexedDB = useTestIndexedDb();
await new Promise<void>((resolve, reject) => {
var open_rq = createdb(t, undefined, 13);
var did_upgrade = false;
@@ -61,7 +64,6 @@ test("WPT idbfactory-open3.htm", async (t) => {
// IDBFactory.open() - new database has default version
test("WPT idbfactory-open4.htm", async (t) => {
- const indexedDB = idbFactory;
await new Promise<void>((resolve, reject) => {
var open_rq = createdb(t, t.title + "-database_name");
@@ -78,7 +80,6 @@ test("WPT idbfactory-open4.htm", async (t) => {
// IDBFactory.open() - new database is empty
test("WPT idbfactory-open5.htm", async (t) => {
- const indexedDB = idbFactory;
await new Promise<void>((resolve, reject) => {
var open_rq = createdb(t, t.title + "-database_name");
@@ -97,7 +98,7 @@ test("WPT idbfactory-open5.htm", async (t) => {
// IDBFactory.open() - open database with a lower version than current
test("WPT idbfactory-open6.htm", async (t) => {
- const indexedDB = idbFactory;
+ const indexedDB = useTestIndexedDb();
await new Promise<void>((resolve, reject) => {
var open_rq = createdb(t, undefined, 13);
var open_rq2: any;
@@ -131,7 +132,7 @@ test("WPT idbfactory-open6.htm", async (t) => {
// IDBFactory.open() - open database with a higher version than current
test("WPT idbfactory-open7.htm", async (t) => {
- const indexedDB = idbFactory;
+ const indexedDB = useTestIndexedDb();
await new Promise<void>((resolve, reject) => {
var open_rq = createdb(t, undefined, 13);
var did_upgrade = false;
@@ -169,7 +170,7 @@ test("WPT idbfactory-open7.htm", async (t) => {
// IDBFactory.open() - error in version change transaction aborts open
test("WPT idbfactory-open8.htm", async (t) => {
- const indexedDB = idbFactory;
+ const indexedDB = useTestIndexedDb();
await new Promise<void>((resolve, reject) => {
var open_rq = createdb(t, undefined, 13);
var did_upgrade = false;
@@ -193,7 +194,7 @@ test("WPT idbfactory-open8.htm", async (t) => {
// IDBFactory.open() - errors in version argument
test("WPT idbfactory-open9.htm", async (t) => {
- const indexedDB = idbFactory;
+ const indexedDB = useTestIndexedDb();
function should_throw(val: any, name?: string) {
if (!name) {
name = typeof val == "object" && val ? "object" : format_value(val);
@@ -281,9 +282,9 @@ test("WPT idbfactory-open9.htm", async (t) => {
// IDBFactory.open() - error in version change transaction aborts open
test("WPT idbfactory-open10.htm", async (t) => {
- const indexedDB = idbFactory;
+ const indexedDB = useTestIndexedDb();
await new Promise<void>((resolve, reject) => {
- var db: any, db2: any;
+ var db: IDBDatabase, db2: IDBDatabase;
var open_rq = createdb(t, undefined, 9);
open_rq.onupgradeneeded = function (e: any) {
@@ -350,7 +351,7 @@ test("WPT idbfactory-open10.htm", async (t) => {
var open_rq3 = indexedDB.open(db.name);
open_rq3.onsuccess = function (e: any) {
- var db3 = e.target.result;
+ var db3: IDBDatabase = e.target.result;
t.true(
db3.objectStoreNames.contains("store"),
@@ -407,7 +408,7 @@ test("WPT idbfactory-open10.htm", async (t) => {
// IDBFactory.open() - second open's transaction is available to get objectStores
test("WPT idbfactory-open11.htm", async (t) => {
- const indexedDB = idbFactory;
+ const indexedDB = useTestIndexedDb();
await new Promise<void>((resolve, reject) => {
var db: any;
var count_done = 0;
@@ -472,8 +473,6 @@ test("WPT idbfactory-open11.htm", async (t) => {
// IDBFactory.open() - upgradeneeded gets VersionChangeEvent
test("WPT idbfactory-open12.htm", async (t) => {
- const indexedDB = idbFactory;
-
var db: any;
var open_rq = createdb(t, undefined, 9);
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbindex-get.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbindex-get.test.ts
index 751b4f983..ad8a57305 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbindex-get.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbindex-get.test.ts
@@ -1,7 +1,9 @@
import test from "ava";
-import { BridgeIDBKeyRange, BridgeIDBRequest } from "..";
-import { IDBDatabase } from "../idbtypes";
-import { createdb } from "./wptsupport";
+import { BridgeIDBKeyRange } from "../bridge-idb.js";
+import { IDBDatabase } from "../idbtypes.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IDBIndex.get() - returns the record
test("WPT idbindex_get.htm", async (t) => {
@@ -93,7 +95,7 @@ test("WPT idbindex_get3.htm", async (t) => {
// IDBIndex.get() - returns the record with the first key in the range
test("WPT idbindex_get4.htm", async (t) => {
await new Promise<void>((resolve, reject) => {
- var db: any;
+ var db: IDBDatabase;
var open_rq = createdb(t);
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbindex-openCursor.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbindex-openCursor.test.ts
index f4515b69e..5d61e68e5 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbindex-openCursor.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbindex-openCursor.test.ts
@@ -1,82 +1,87 @@
import test from "ava";
-import { BridgeIDBCursor } from "..";
-import { BridgeIDBCursorWithValue } from "../bridge-idb";
-import { IDBDatabase } from "../idbtypes";
-import { createdb } from "./wptsupport";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IDBIndex.openCursor() - throw InvalidStateError when the index is deleted
-test.cb("WPT test idbindex-openCursor.htm", (t) => {
- var db;
+test("WPT test idbindex-openCursor.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db;
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var store = db.createObjectStore("store", { keyPath: "key" });
- var index = store.createIndex("index", "indexedProperty");
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var store = db.createObjectStore("store", { keyPath: "key" });
+ var index = store.createIndex("index", "indexedProperty");
- store.add({ key: 1, indexedProperty: "data" });
- store.deleteIndex("index");
+ store.add({ key: 1, indexedProperty: "data" });
+ store.deleteIndex("index");
- t.throws(
- () => {
- index.openCursor();
- },
- { name: "InvalidStateError" },
- );
+ t.throws(
+ () => {
+ index.openCursor();
+ },
+ { name: "InvalidStateError" },
+ );
- t.end();
- };
+ resolve();
+ };
+ });
});
// IDBIndex.openCursor() - throw TransactionInactiveError on aborted transaction
-test.cb("WPT test idbindex-openCursor2.htm", (t) => {
- var db;
+test("WPT test idbindex-openCursor2.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db;
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var store = db.createObjectStore("store", { keyPath: "key" });
- var index = store.createIndex("index", "indexedProperty");
- store.add({ key: 1, indexedProperty: "data" });
- };
- open_rq.onsuccess = function (e: any) {
- db = e.target.result;
- var tx = db.transaction("store");
- var index = tx.objectStore("store").index("index");
- tx.abort();
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var store = db.createObjectStore("store", { keyPath: "key" });
+ var index = store.createIndex("index", "indexedProperty");
+ store.add({ key: 1, indexedProperty: "data" });
+ };
+ open_rq.onsuccess = function (e: any) {
+ db = e.target.result;
+ var tx = db.transaction("store");
+ var index = tx.objectStore("store").index("index");
+ tx.abort();
- t.throws(
- () => {
- index.openCursor();
- },
- { name: "TransactionInactiveError" },
- );
+ t.throws(
+ () => {
+ index.openCursor();
+ },
+ { name: "TransactionInactiveError" },
+ );
- t.end();
- };
+ resolve();
+ };
+ });
});
// IDBIndex.openCursor() - throw InvalidStateError on index deleted by aborted upgrade
-test.cb("WPT test idbindex-openCursor3.htm", (t) => {
- var db;
+test("WPT test idbindex-openCursor3.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db;
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var store = db.createObjectStore("store", { keyPath: "key" });
- var index = store.createIndex("index", "indexedProperty");
- store.add({ key: 1, indexedProperty: "data" });
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var store = db.createObjectStore("store", { keyPath: "key" });
+ var index = store.createIndex("index", "indexedProperty");
+ store.add({ key: 1, indexedProperty: "data" });
- e.target.transaction.abort();
+ e.target.transaction.abort();
- t.throws(
- () => {
- console.log("index before openCursor", index);
- index.openCursor();
- },
- { name: "InvalidStateError" },
- );
+ t.throws(
+ () => {
+ console.log("index before openCursor", index);
+ index.openCursor();
+ },
+ { name: "InvalidStateError" },
+ );
- t.end();
- };
+ resolve();
+ };
+ });
});
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-add-put-exception-order.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-add-put-exception-order.test.ts
index a3aead9db..60bf0cfb2 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-add-put-exception-order.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-add-put-exception-order.test.ts
@@ -1,8 +1,7 @@
import test, { ExecutionContext } from "ava";
-import { BridgeIDBCursor } from "..";
-import { BridgeIDBRequest } from "../bridge-idb";
-import { InvalidStateError } from "../util/errors";
-import { createdb, indexeddb_test } from "./wptsupport";
+import { indexeddb_test, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
async function t1(t: ExecutionContext, method: string): Promise<void> {
await indexeddb_test(
@@ -58,8 +57,6 @@ async function t2(t: ExecutionContext, method: string): Promise<void> {
done();
}, 0);
-
- console.log(`queued task for ${method}`);
},
"t2",
);
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-add.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-add.test.ts
index 02f05f468..4941c43d6 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-add.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-add.test.ts
@@ -1,7 +1,9 @@
import test from "ava";
-import { BridgeIDBRequest } from "..";
-import { IDBDatabase } from "../idbtypes";
-import { createdb } from "./wptsupport";
+import { BridgeIDBRequest } from "../bridge-idb.js";
+import { IDBDatabase } from "../idbtypes.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IDBObjectStore.add() - add with an inline key
test("WPT idbobjectstore_add.htm", async (t) => {
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-get.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-get.test.ts
index 0c9d30b7d..922c2bcf4 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-get.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-get.test.ts
@@ -1,159 +1,174 @@
import test from "ava";
-import { BridgeIDBKeyRange, BridgeIDBRequest } from "..";
-import { IDBDatabase } from "../idbtypes";
-import { createdb } from "./wptsupport";
+import { BridgeIDBKeyRange } from "../bridge-idb.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IDBObjectStore.get() - key is a number
-test.cb("WPT idbobjectstore_get.htm", (t) => {
- var db: any,
- record = { key: 3.14159265, property: "data" };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- db.createObjectStore("store", { keyPath: "key" }).add(record);
- };
-
- open_rq.onsuccess = function (e: any) {
- var rq = db.transaction("store").objectStore("store").get(record.key);
-
- rq.onsuccess = function (e: any) {
- t.deepEqual(e.target.result.key, record.key);
- t.deepEqual(e.target.result.property, record.property);
- t.end();
+test("WPT idbobjectstore_get.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { key: 3.14159265, property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ db.createObjectStore("store", { keyPath: "key" }).add(record);
};
- };
+
+ open_rq.onsuccess = function (e: any) {
+ var rq = db.transaction("store").objectStore("store").get(record.key);
+
+ rq.onsuccess = function (e: any) {
+ t.deepEqual(e.target.result.key, record.key);
+ t.deepEqual(e.target.result.property, record.property);
+ resolve();
+ };
+ };
+ });
});
// IDBObjectStore.get() - key is a string
-test.cb("WPT idbobjectstore_get2.htm", (t) => {
- var db: any,
- record = { key: "this is a key that's a string", property: "data" };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- db.createObjectStore("store", { keyPath: "key" }).add(record);
- };
-
- open_rq.onsuccess = function (e: any) {
- var rq = db.transaction("store").objectStore("store").get(record.key);
-
- rq.onsuccess = function (e: any) {
- t.deepEqual(e.target.result.key, record.key);
- t.deepEqual(e.target.result.property, record.property);
- t.end();
+test("WPT idbobjectstore_get2.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { key: "this is a key that's a string", property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ db.createObjectStore("store", { keyPath: "key" }).add(record);
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var rq = db.transaction("store").objectStore("store").get(record.key);
+
+ rq.onsuccess = function (e: any) {
+ t.deepEqual(e.target.result.key, record.key);
+ t.deepEqual(e.target.result.property, record.property);
+ resolve();
+ };
};
- };
+ });
});
// IDBObjectStore.get() - key is a date
-test.cb("WPT idbobjectstore_get3.htm", (t) => {
- var db: any;
- const record = { key: new Date(), property: "data" };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- db.createObjectStore("store", { keyPath: "key" }).add(record);
- };
-
- open_rq.onsuccess = function (e: any) {
- var rq = db.transaction("store").objectStore("store").get(record.key);
-
- rq.onsuccess = function (e: any) {
- t.deepEqual(e.target.result.key.valueOf(), record.key.valueOf());
- t.deepEqual(e.target.result.property, record.property);
- t.end();
+test("WPT idbobjectstore_get3.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ const record = { key: new Date(), property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ db.createObjectStore("store", { keyPath: "key" }).add(record);
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var rq = db.transaction("store").objectStore("store").get(record.key);
+
+ rq.onsuccess = function (e: any) {
+ t.deepEqual(e.target.result.key.valueOf(), record.key.valueOf());
+ t.deepEqual(e.target.result.property, record.property);
+ resolve();
+ };
};
- };
+ });
});
// IDBObjectStore.get() - attempt to retrieve a record that doesn't exist
-test.cb("WPT idbobjectstore_get4.htm", (t) => {
- var db: any;
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var rq = db.createObjectStore("store", { keyPath: "key" }).get(1);
- rq.onsuccess = function (e: any) {
- t.deepEqual(e.target.results, undefined);
- setTimeout(function () {
- t.end();
- }, 10);
+test("WPT idbobjectstore_get4.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var rq = db.createObjectStore("store", { keyPath: "key" }).get(1);
+ rq.onsuccess = function (e: any) {
+ t.deepEqual(e.target.results, undefined);
+ setTimeout(function () {
+ resolve();
+ }, 10);
+ };
};
- };
- open_rq.onsuccess = function () {};
+ open_rq.onsuccess = function () {};
+ });
});
// IDBObjectStore.get() - returns the record with the first key in the range
-test.cb("WPT idbobjectstore_get5.htm", (t) => {
- var db: any;
- var open_rq = createdb(t);
-
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var os = db.createObjectStore("store");
-
- for (var i = 0; i < 10; i++) os.add("data" + i, i);
- };
-
- open_rq.onsuccess = function (e: any) {
- db
- .transaction("store")
- .objectStore("store")
- .get(BridgeIDBKeyRange.bound(3, 6)).onsuccess = function (e: any) {
- t.deepEqual(e.target.result, "data3", "get(3-6)");
- t.end();
+test("WPT idbobjectstore_get5.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+ var open_rq = createdb(t);
+
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var os = db.createObjectStore("store");
+
+ for (var i = 0; i < 10; i++) os.add("data" + i, i);
};
- };
+
+ open_rq.onsuccess = function (e: any) {
+ db
+ .transaction("store")
+ .objectStore("store")
+ .get(BridgeIDBKeyRange.bound(3, 6)).onsuccess = function (e: any) {
+ t.deepEqual(e.target.result, "data3", "get(3-6)");
+ resolve();
+ };
+ };
+ });
});
// IDBObjectStore.get() - throw TransactionInactiveError on aborted transaction
-test.cb("WPT idbobjectstore_get6.htm", (t) => {
- var db: any;
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- db.createObjectStore("store", { keyPath: "key" });
- };
-
- open_rq.onsuccess = function (e: any) {
- var store = db.transaction("store").objectStore("store");
- store.transaction.abort();
- t.throws(
- function () {
- store.get(1);
- },
- { name: "TransactionInactiveError" },
- "throw TransactionInactiveError on aborted transaction.",
- );
- t.end();
- };
+test("WPT idbobjectstore_get6.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ db.createObjectStore("store", { keyPath: "key" });
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var store = db.transaction("store").objectStore("store");
+ store.transaction.abort();
+ t.throws(
+ function () {
+ store.get(1);
+ },
+ { name: "TransactionInactiveError" },
+ "throw TransactionInactiveError on aborted transaction.",
+ );
+ resolve();
+ };
+ });
});
// IDBObjectStore.get() - throw DataError when using invalid key
-test.cb("WPT idbobjectstore_get7.htm", (t) => {
- var db: any;
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- db.createObjectStore("store", { keyPath: "key" });
- };
-
- open_rq.onsuccess = function (e: any) {
- var store = db.transaction("store").objectStore("store");
- t.throws(
- function () {
- store.get(null);
- },
- { name: "DataError" },
- "throw DataError when using invalid key.",
- );
- t.end();
- };
+test("WPT idbobjectstore_get7.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ db.createObjectStore("store", { keyPath: "key" });
+ };
+
+ open_rq.onsuccess = function (e: any) {
+ var store = db.transaction("store").objectStore("store");
+ t.throws(
+ function () {
+ store.get(null);
+ },
+ { name: "DataError" },
+ "throw DataError when using invalid key.",
+ );
+ resolve();
+ };
+ });
});
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-put.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-put.test.ts
index 3ca1b8ecb..f051c57b6 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-put.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-put.test.ts
@@ -1,449 +1,485 @@
import test from "ava";
-import { BridgeIDBKeyRange, BridgeIDBRequest } from "..";
-import { IDBDatabase } from "../idbtypes";
-import { createdb } from "./wptsupport";
+import { BridgeIDBRequest } from "../bridge-idb.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IDBObjectStore.put() - put with an inline key
-test.cb("WPT idbobjectstore_put.htm", (t) => {
- var db: any,
- record = { key: 1, property: "data" };
+test("WPT idbobjectstore_put.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { key: 1, property: "data" };
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("store", { keyPath: "key" });
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("store", { keyPath: "key" });
- objStore.put(record);
- };
+ objStore.put(record);
+ };
- open_rq.onsuccess = function (e: any) {
- var rq = db.transaction("store").objectStore("store").get(record.key);
+ open_rq.onsuccess = function (e: any) {
+ var rq = db.transaction("store").objectStore("store").get(record.key);
- rq.onsuccess = function (e: any) {
- t.deepEqual(e.target.result.property, record.property);
- t.deepEqual(e.target.result.key, record.key);
- t.end();
+ rq.onsuccess = function (e: any) {
+ t.deepEqual(e.target.result.property, record.property);
+ t.deepEqual(e.target.result.key, record.key);
+ resolve();
+ };
};
- };
+ });
});
// IDBObjectStore.put() - put with an out-of-line key
-test.cb("WPT idbobjectstore_put2.htm", (t) => {
- var db: any,
- key = 1,
- record = { property: "data" };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("store");
-
- objStore.put(record, key);
- };
+test("WPT idbobjectstore_put2.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ key = 1,
+ record = { property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("store");
+
+ objStore.put(record, key);
+ };
- open_rq.onsuccess = function (e: any) {
- var rq = db.transaction("store").objectStore("store").get(key);
+ open_rq.onsuccess = function (e: any) {
+ var rq = db.transaction("store").objectStore("store").get(key);
- rq.onsuccess = function (e: any) {
- t.deepEqual(e.target.result.property, record.property);
+ rq.onsuccess = function (e: any) {
+ t.deepEqual(e.target.result.property, record.property);
- t.end();
+ resolve();
+ };
};
- };
+ });
});
// IDBObjectStore.put() - put with an out-of-line key
-test.cb("WPT idbobjectstore_put3.htm", (t) => {
- var db: any,
- success_event: any,
- record = { key: 1, property: "data" },
- record_put = { key: 1, property: "changed", more: ["stuff", 2] };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("store", { keyPath: "key" });
- objStore.put(record);
-
- var rq = objStore.put(record_put);
- rq.onerror = () => t.fail("error on put");
-
- rq.onsuccess = function (e: any) {
- success_event = true;
+test("WPT idbobjectstore_put3.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ success_event: any,
+ record = { key: 1, property: "data" },
+ record_put = { key: 1, property: "changed", more: ["stuff", 2] };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("store", { keyPath: "key" });
+ objStore.put(record);
+
+ var rq = objStore.put(record_put);
+ rq.onerror = () => t.fail("error on put");
+
+ rq.onsuccess = function (e: any) {
+ success_event = true;
+ };
};
- };
- open_rq.onsuccess = function (e: any) {
- t.true(success_event);
+ open_rq.onsuccess = function (e: any) {
+ t.true(success_event);
- var rq = db.transaction("store").objectStore("store").get(1);
+ var rq = db.transaction("store").objectStore("store").get(1);
- rq.onsuccess = function (e: any) {
- var rec = e.target.result;
+ rq.onsuccess = function (e: any) {
+ var rec = e.target.result;
- t.deepEqual(rec.key, record_put.key);
- t.deepEqual(rec.property, record_put.property);
- t.deepEqual(rec.more, record_put.more);
+ t.deepEqual(rec.key, record_put.key);
+ t.deepEqual(rec.property, record_put.property);
+ t.deepEqual(rec.more, record_put.more);
- t.end();
+ resolve();
+ };
};
- };
+ });
});
// IDBObjectStore.put() - put where an index has unique:true specified
-test.cb("WPT idbobjectstore_put4.htm", (t) => {
- var db: any,
- record = { key: 1, property: "data" };
+test("WPT idbobjectstore_put4.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { key: 1, property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("store", { autoIncrement: true });
+ objStore.createIndex("i1", "property", { unique: true });
+ objStore.put(record);
+
+ var rq = objStore.put(record);
+ rq.onsuccess = () =>
+ t.fail("success on putting duplicate indexed record");
+
+ rq.onerror = function (e: any) {
+ t.deepEqual(rq.error.name, "ConstraintError");
+ t.deepEqual(e.target.error.name, "ConstraintError");
+
+ t.deepEqual(e.type, "error");
+
+ e.preventDefault();
+ e.stopPropagation();
+ };
+ };
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("store", { autoIncrement: true });
- objStore.createIndex("i1", "property", { unique: true });
- objStore.put(record);
+ // Defer done, giving a spurious rq.onsuccess a chance to run
+ open_rq.onsuccess = function (e: any) {
+ resolve();
+ };
+ });
+});
- var rq = objStore.put(record);
- rq.onsuccess = () => t.fail("success on putting duplicate indexed record");
+// IDBObjectStore.put() - object store's key path is an object attribute
+test("WPT idbobjectstore_put5.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { test: { obj: { key: 1 } }, property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("store", { keyPath: "test.obj.key" });
+ objStore.put(record);
+ };
- rq.onerror = function (e: any) {
- t.deepEqual(rq.error.name, "ConstraintError");
- t.deepEqual(e.target.error.name, "ConstraintError");
+ open_rq.onsuccess = function (e: any) {
+ var rq = db
+ .transaction("store")
+ .objectStore("store")
+ .get(record.test.obj.key);
- t.deepEqual(e.type, "error");
+ rq.onsuccess = function (e: any) {
+ t.deepEqual(e.target.result.property, record.property);
- e.preventDefault();
- e.stopPropagation();
+ resolve();
+ };
};
- };
-
- // Defer done, giving a spurious rq.onsuccess a chance to run
- open_rq.onsuccess = function (e: any) {
- t.end();
- };
+ });
});
-// IDBObjectStore.put() - object store's key path is an object attribute
-test.cb("WPT idbobjectstore_put5.htm", (t) => {
- var db: any,
- record = { test: { obj: { key: 1 } }, property: "data" };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("store", { keyPath: "test.obj.key" });
- objStore.put(record);
- };
-
- open_rq.onsuccess = function (e: any) {
- var rq = db
- .transaction("store")
- .objectStore("store")
- .get(record.test.obj.key);
-
- rq.onsuccess = function (e: any) {
- t.deepEqual(e.target.result.property, record.property);
-
- t.end();
+// IDBObjectStore.put() - autoIncrement and inline keys
+test("WPT idbobjectstore_put6.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { property: "data" },
+ expected_keys = [1, 2, 3, 4];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("store", {
+ keyPath: "key",
+ autoIncrement: true,
+ });
+
+ objStore.put(record);
+ objStore.put(record);
+ objStore.put(record);
+ objStore.put(record);
};
- };
-});
-// IDBObjectStore.put() - autoIncrement and inline keys
-test.cb("WPT idbobjectstore_put6.htm", (t) => {
- var db: any,
- record = { property: "data" },
- expected_keys = [1, 2, 3, 4];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("store", {
- keyPath: "key",
- autoIncrement: true,
- });
-
- objStore.put(record);
- objStore.put(record);
- objStore.put(record);
- objStore.put(record);
- };
-
- open_rq.onsuccess = function (e: any) {
- var actual_keys: any[] = [],
- rq = db.transaction("store").objectStore("store").openCursor();
-
- rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
-
- if (cursor) {
- actual_keys.push(cursor.value.key);
- cursor.continue();
- } else {
- t.deepEqual(actual_keys, expected_keys);
- t.end();
- }
+ open_rq.onsuccess = function (e: any) {
+ var actual_keys: any[] = [],
+ rq = db.transaction("store").objectStore("store").openCursor();
+
+ rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+
+ if (cursor) {
+ actual_keys.push(cursor.value.key);
+ cursor.continue();
+ } else {
+ t.deepEqual(actual_keys, expected_keys);
+ resolve();
+ return;
+ }
+ };
};
- };
+ });
});
// IDBObjectStore.put() - autoIncrement and out-of-line keys
-test.cb("WPT idbobjectstore_put7.htm", (t) => {
- var db: any,
- record = { property: "data" },
- expected_keys = [1, 2, 3, 4];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("store", { autoIncrement: true });
-
- objStore.put(record);
- objStore.put(record);
- objStore.put(record);
- objStore.put(record);
- };
-
- open_rq.onsuccess = function (e) {
- var actual_keys: any[] = [],
- rq = db.transaction("store").objectStore("store").openCursor();
-
- rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
-
- if (cursor) {
- actual_keys.push(cursor.key);
- cursor.continue();
- } else {
- t.deepEqual(actual_keys, expected_keys);
- t.end();
- }
+test("WPT idbobjectstore_put7.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { property: "data" },
+ expected_keys = [1, 2, 3, 4];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("store", { autoIncrement: true });
+
+ objStore.put(record);
+ objStore.put(record);
+ objStore.put(record);
+ objStore.put(record);
+ };
+
+ open_rq.onsuccess = function (e) {
+ var actual_keys: any[] = [],
+ rq = db.transaction("store").objectStore("store").openCursor();
+
+ rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+
+ if (cursor) {
+ actual_keys.push(cursor.key);
+ cursor.continue();
+ } else {
+ t.deepEqual(actual_keys, expected_keys);
+ resolve();
+ }
+ };
};
- };
+ });
});
// IDBObjectStore.put() - object store has autoIncrement:true and the key path is an object attribute
-test.cb("WPT idbobjectstore_put8.htm", (t) => {
- var db: any,
- record = { property: "data" },
- expected_keys = [1, 2, 3, 4];
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
- var objStore = db.createObjectStore("store", {
- keyPath: "test.obj.key",
- autoIncrement: true,
- });
-
- objStore.put(record);
- objStore.put(record);
- objStore.put(record);
- objStore.put(record);
- };
-
- open_rq.onsuccess = function (e: any) {
- var actual_keys: any[] = [],
- rq = db.transaction("store").objectStore("store").openCursor();
-
- rq.onsuccess = function (e: any) {
- var cursor = e.target.result;
-
- if (cursor) {
- actual_keys.push(cursor.value.test.obj.key);
- cursor.continue();
- } else {
- t.deepEqual(actual_keys, expected_keys);
- t.end();
- }
+test("WPT idbobjectstore_put8.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { property: "data" },
+ expected_keys = [1, 2, 3, 4];
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+ var objStore = db.createObjectStore("store", {
+ keyPath: "test.obj.key",
+ autoIncrement: true,
+ });
+
+ objStore.put(record);
+ objStore.put(record);
+ objStore.put(record);
+ objStore.put(record);
};
- };
+
+ open_rq.onsuccess = function (e: any) {
+ var actual_keys: any[] = [],
+ rq = db.transaction("store").objectStore("store").openCursor();
+
+ rq.onsuccess = function (e: any) {
+ var cursor = e.target.result;
+
+ if (cursor) {
+ actual_keys.push(cursor.value.test.obj.key);
+ cursor.continue();
+ } else {
+ t.deepEqual(actual_keys, expected_keys);
+ resolve();
+ return;
+ }
+ };
+ };
+ });
});
//IDBObjectStore.put() - Attempt to put a record that does not meet the constraints of an object store's inline key requirements
-test.cb("WPT idbobjectstore_put9.htm", (t) => {
- var record = { key: 1, property: "data" };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- var rq,
- db = e.target.result,
- objStore = db.createObjectStore("store", { keyPath: "key" });
-
- t.throws(
- function () {
- rq = objStore.put(record, 1);
- },
- { name: "DataError" },
- );
-
- t.deepEqual(rq, undefined);
- t.end();
- };
+test("WPT idbobjectstore_put9.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var record = { key: 1, property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ var rq,
+ db = e.target.result,
+ objStore = db.createObjectStore("store", { keyPath: "key" });
+
+ t.throws(
+ function () {
+ rq = objStore.put(record, 1);
+ },
+ { name: "DataError" },
+ );
+
+ t.deepEqual(rq, undefined);
+ resolve();
+ };
+ });
});
//IDBObjectStore.put() - Attempt to call 'put' without an key parameter when the object store uses out-of-line keys
-test.cb("WPT idbobjectstore_put10.htm", (t) => {
- var db: any,
- record = { property: "data" };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var rq,
- objStore = db.createObjectStore("store", { keyPath: "key" });
-
- t.throws(
- function () {
- rq = objStore.put(record);
- },
- { name: "DataError" },
- );
-
- t.deepEqual(rq, undefined);
- t.end();
- };
+test("WPT idbobjectstore_put10.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+
+ var rq,
+ objStore = db.createObjectStore("store", { keyPath: "key" });
+
+ t.throws(
+ function () {
+ rq = objStore.put(record);
+ },
+ { name: "DataError" },
+ );
+
+ t.deepEqual(rq, undefined);
+ resolve();
+ };
+ });
});
// IDBObjectStore.put() - Attempt to put a record where the record's key does not meet the constraints of a valid key
-test.cb("WPT idbobjectstore_put11.htm", (t) => {
- var db: any,
- record = { key: { value: 1 }, property: "data" };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var rq,
- objStore = db.createObjectStore("store", { keyPath: "key" });
-
- t.throws(
- function () {
- rq = objStore.put(record);
- },
- { name: "DataError" },
- );
-
- t.deepEqual(rq, undefined);
- t.end();
- };
+test("WPT idbobjectstore_put11.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { key: { value: 1 }, property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+
+ var rq,
+ objStore = db.createObjectStore("store", { keyPath: "key" });
+
+ t.throws(
+ function () {
+ rq = objStore.put(record);
+ },
+ { name: "DataError" },
+ );
+
+ t.deepEqual(rq, undefined);
+ resolve();
+ };
+ });
});
// IDBObjectStore.put() - Attempt to put a record where the record's in-line key is not defined
-test.cb("WPT idbobjectstore_put12.htm", (t) => {
- var db: any,
- record = { property: "data" };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var rq,
- objStore = db.createObjectStore("store", { keyPath: "key" });
-
- t.throws(
- function () {
- rq = objStore.put(record);
- },
- { name: "DataError" },
- );
-
- t.deepEqual(rq, undefined);
- t.end();
- };
+test("WPT idbobjectstore_put12.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+
+ var rq,
+ objStore = db.createObjectStore("store", { keyPath: "key" });
+
+ t.throws(
+ function () {
+ rq = objStore.put(record);
+ },
+ { name: "DataError" },
+ );
+
+ t.deepEqual(rq, undefined);
+ resolve();
+ };
+ });
});
// IDBObjectStore.put() - Attempt to put a record where the out of line key provided does not meet the constraints of a valid key
-test.cb("WPT idbobjectstore_put13.htm", (t) => {
- var db: any,
- record = { property: "data" };
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
-
- var rq,
- objStore = db.createObjectStore("store");
-
- t.throws(
- function () {
- rq = objStore.put(record, { value: 1 });
- },
- {
- name: "DataError",
- },
- );
-
- t.deepEqual(rq, undefined);
- t.end();
- };
+test("WPT idbobjectstore_put13.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { property: "data" };
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
+
+ var rq,
+ objStore = db.createObjectStore("store");
+
+ t.throws(
+ function () {
+ rq = objStore.put(record, { value: 1 });
+ },
+ {
+ name: "DataError",
+ },
+ );
+
+ t.deepEqual(rq, undefined);
+ resolve();
+ };
+ });
});
// IDBObjectStore.put() - Put a record where a value being indexed does not meet the constraints of a valid key
-test.cb("WPT idbobjectstore_put14.htm", (t) => {
- var db: any,
- record = { key: 1, indexedProperty: { property: "data" } };
+test("WPT idbobjectstore_put14.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any,
+ record = { key: 1, indexedProperty: { property: "data" } };
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (e: any) {
- db = e.target.result;
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (e: any) {
+ db = e.target.result;
- var rq,
- objStore = db.createObjectStore("store", { keyPath: "key" });
+ var rq,
+ objStore = db.createObjectStore("store", { keyPath: "key" });
- objStore.createIndex("index", "indexedProperty");
+ objStore.createIndex("index", "indexedProperty");
- rq = objStore.put(record);
+ rq = objStore.put(record);
- t.true(rq instanceof BridgeIDBRequest);
- rq.onsuccess = function () {
- t.end();
+ t.true(rq instanceof BridgeIDBRequest);
+ rq.onsuccess = function () {
+ resolve();
+ };
};
- };
+ });
});
// IDBObjectStore.put() - If the transaction this IDBObjectStore belongs to has its mode set to readonly, throw ReadOnlyError
-test.cb("WPT idbobjectstore_put15.htm", (t) => {
- var db: any;
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (event: any) {
- db = event.target.result;
- db.createObjectStore("store", { keyPath: "pKey" });
- };
-
- open_rq.onsuccess = function (event: any) {
- var txn = db.transaction("store");
- var ostore = txn.objectStore("store");
- t.throws(
- function () {
- ostore.put({ pKey: "primaryKey_0" });
- },
- {
- name: "ReadOnlyError",
- },
- );
- t.end();
- };
+test("WPT idbobjectstore_put15.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any;
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (event: any) {
+ db = event.target.result;
+ db.createObjectStore("store", { keyPath: "pKey" });
+ };
+
+ open_rq.onsuccess = function (event: any) {
+ var txn = db.transaction("store");
+ var ostore = txn.objectStore("store");
+ t.throws(
+ function () {
+ ostore.put({ pKey: "primaryKey_0" });
+ },
+ {
+ name: "ReadOnlyError",
+ },
+ );
+ resolve();
+ };
+ });
});
// IDBObjectStore.put() - If the object store has been deleted, the implementation must throw a DOMException of type InvalidStateError
-test.cb("WPT idbobjectstore_put16.htm", (t) => {
- var db: any, ostore: any;
-
- var open_rq = createdb(t);
- open_rq.onupgradeneeded = function (event: any) {
- db = event.target.result;
- ostore = db.createObjectStore("store", { keyPath: "pKey" });
- db.deleteObjectStore("store");
- t.throws(
- function () {
- ostore.put({ pKey: "primaryKey_0" });
- },
- {
- name: "InvalidStateError",
- },
- );
- t.end();
- };
+test("WPT idbobjectstore_put16.htm", (t) => {
+ return new Promise((resolve, reject) => {
+ var db: any, ostore: any;
+
+ var open_rq = createdb(t);
+ open_rq.onupgradeneeded = function (event: any) {
+ db = event.target.result;
+ ostore = db.createObjectStore("store", { keyPath: "pKey" });
+ db.deleteObjectStore("store");
+ t.throws(
+ function () {
+ ostore.put({ pKey: "primaryKey_0" });
+ },
+ {
+ name: "InvalidStateError",
+ },
+ );
+ resolve();
+ };
+ });
});
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-rename-store.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-rename-store.test.ts
index b60e932b9..6f04552fa 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-rename-store.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbobjectstore-rename-store.test.ts
@@ -6,8 +6,11 @@ import {
createBooksStore,
createDatabase,
createNotBooksStore,
+ initTestIndexedDB,
migrateDatabase,
-} from "./wptsupport";
+} from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IndexedDB: object store renaming support
// IndexedDB object store rename in new transaction
diff --git a/packages/idb-bridge/src/idb-wpt-ported/idbtransaction-oncomplete.test.ts b/packages/idb-bridge/src/idb-wpt-ported/idbtransaction-oncomplete.test.ts
index 8f54fb7cb..f728cd487 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/idbtransaction-oncomplete.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/idbtransaction-oncomplete.test.ts
@@ -1,5 +1,7 @@
import test from "ava";
-import { createdb } from "./wptsupport";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// IDBTransaction - complete event
test("WPT idbtransaction-oncomplete.htm", async (t) => {
diff --git a/packages/idb-bridge/src/idb-wpt-ported/keypath.test.ts b/packages/idb-bridge/src/idb-wpt-ported/keypath.test.ts
index 20ec6f3fa..f15f93873 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/keypath.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/keypath.test.ts
@@ -1,5 +1,7 @@
import test from "ava";
-import { assert_key_equals, createdb } from "./wptsupport";
+import { assert_key_equals, createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
test("WPT test keypath.htm", async (t) => {
function keypath(
@@ -9,8 +11,6 @@ test("WPT test keypath.htm", async (t) => {
desc?: string,
) {
return new Promise<void>((resolve, reject) => {
- console.log("key path", keypath);
- console.log("checking", desc);
let db: any;
const store_name = "store-" + Date.now() + Math.random();
diff --git a/packages/idb-bridge/src/idb-wpt-ported/request-bubble-and-capture.test.ts b/packages/idb-bridge/src/idb-wpt-ported/request-bubble-and-capture.test.ts
index a7541a683..14c8f3be5 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/request-bubble-and-capture.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/request-bubble-and-capture.test.ts
@@ -1,7 +1,8 @@
import test from "ava";
-import { BridgeIDBRequest } from "..";
-import { EventTarget, IDBDatabase } from "../idbtypes";
-import { createdb } from "./wptsupport";
+import { EventTarget } from "../idbtypes.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// Bubbling and capturing of request events
test("WPT request_bubble-and-capture.htm", async (t) => {
diff --git a/packages/idb-bridge/src/idb-wpt-ported/transaction-requestqueue.test.ts b/packages/idb-bridge/src/idb-wpt-ported/transaction-requestqueue.test.ts
index 707bb5255..971330e3d 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/transaction-requestqueue.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/transaction-requestqueue.test.ts
@@ -1,5 +1,7 @@
import test from "ava";
-import { createdb } from "./wptsupport";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
+
+test.before("test DB initialization", initTestIndexedDB);
// Transactions have a request queue
test("transaction-requestqueue.htm", async (t) => {
@@ -72,7 +74,7 @@ test("transaction-requestqueue.htm", async (t) => {
"os2: 1",
"os2: 1",
"os1: 2",
- ],
+ ] as any,
"transaction keys",
);
@@ -93,7 +95,7 @@ test("transaction-requestqueue.htm", async (t) => {
"os3: 1",
"os1: 2",
"os4: 5",
- ],
+ ] as any,
"transaction 2 keys",
);
diff --git a/packages/idb-bridge/src/idb-wpt-ported/value.test.ts b/packages/idb-bridge/src/idb-wpt-ported/value.test.ts
index acae2fe63..95712e152 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/value.test.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/value.test.ts
@@ -1,47 +1,49 @@
import test from "ava";
-import { IDBVersionChangeEvent } from "../idbtypes";
-import { createdb } from "./wptsupport";
+import { IDBVersionChangeEvent } from "../idbtypes.js";
+import { createdb, initTestIndexedDB } from "./wptsupport.js";
-test.cb("WPT test value.htm, array", (t) => {
- const value = new Array();
- const _instanceof = Array;
+test.before("test DB initialization", initTestIndexedDB);
- t.plan(1);
+test("WPT test value.htm, array", (t) => {
+ return new Promise((resolve, reject) => {
+ const value = new Array();
+ const _instanceof = Array;
- createdb(t).onupgradeneeded = function (e: IDBVersionChangeEvent) {
- (e.target as any).result.createObjectStore("store").add(value, 1);
- (e.target as any).onsuccess = (e: any) => {
- console.log("in first onsuccess");
- e.target.result
- .transaction("store")
- .objectStore("store")
- .get(1).onsuccess = (e: any) => {
- t.assert(e.target.result instanceof _instanceof, "instanceof");
- t.end();
+ t.plan(1);
+
+ createdb(t).onupgradeneeded = function (e: IDBVersionChangeEvent) {
+ (e.target as any).result.createObjectStore("store").add(value, 1);
+ (e.target as any).onsuccess = (e: any) => {
+ e.target.result
+ .transaction("store")
+ .objectStore("store")
+ .get(1).onsuccess = (e: any) => {
+ t.assert(e.target.result instanceof _instanceof, "instanceof");
+ resolve();
+ };
};
};
- };
+ });
});
-test.cb("WPT test value.htm, date", (t) => {
- const value = new Date();
- const _instanceof = Date;
+test("WPT test value.htm, date", (t) => {
+ return new Promise((resolve, reject) => {
+ const value = new Date();
+ const _instanceof = Date;
- t.plan(1);
+ t.plan(1);
- createdb(t).onupgradeneeded = function (e: IDBVersionChangeEvent) {
- (e.target as any).result.createObjectStore("store").add(value, 1);
- (e.target as any).onsuccess = (e: any) => {
- console.log("in first onsuccess");
- e.target.result
- .transaction("store")
- .objectStore("store")
- .get(1).onsuccess = (e: any) => {
- console.log("target", e.target);
- console.log("result", e.target.result);
- t.assert(e.target.result instanceof _instanceof, "instanceof");
- t.end();
+ createdb(t).onupgradeneeded = function (e: IDBVersionChangeEvent) {
+ (e.target as any).result.createObjectStore("store").add(value, 1);
+ (e.target as any).onsuccess = (e: any) => {
+ e.target.result
+ .transaction("store")
+ .objectStore("store")
+ .get(1).onsuccess = (e: any) => {
+ t.assert(e.target.result instanceof _instanceof, "instanceof");
+ resolve();
+ };
};
};
- };
+ });
});
diff --git a/packages/idb-bridge/src/idb-wpt-ported/wptsupport.ts b/packages/idb-bridge/src/idb-wpt-ported/wptsupport.ts
index 38b44bbec..c648bf53f 100644
--- a/packages/idb-bridge/src/idb-wpt-ported/wptsupport.ts
+++ b/packages/idb-bridge/src/idb-wpt-ported/wptsupport.ts
@@ -1,5 +1,5 @@
-import test, { ExecutionContext } from "ava";
-import { BridgeIDBFactory, BridgeIDBRequest } from "..";
+import { ExecutionContext } from "ava";
+import { BridgeIDBRequest } from "../bridge-idb.js";
import {
IDBDatabase,
IDBIndex,
@@ -7,19 +7,11 @@ import {
IDBOpenDBRequest,
IDBRequest,
IDBTransaction,
- IDBTransactionMode,
-} from "../idbtypes";
-import { MemoryBackend } from "../MemoryBackend";
-import { compareKeys } from "../util/cmp";
-
-BridgeIDBFactory.enableTracing = true;
-const backend = new MemoryBackend();
-backend.enableTracing = true;
-export const idbFactory = new BridgeIDBFactory(backend);
-
-const self = {
- indexedDB: idbFactory,
-};
+} from "../idbtypes.js";
+import { initTestIndexedDB , useTestIndexedDb } from "../testingdb.js";
+import { compareKeys } from "../util/cmp.js";
+
+export { initTestIndexedDB, useTestIndexedDb } from "../testingdb.js"
export function createdb(
t: ExecutionContext<unknown>,
@@ -28,8 +20,8 @@ export function createdb(
): IDBOpenDBRequest {
var rq_open: IDBOpenDBRequest;
dbname = dbname ? dbname : "testdb-" + new Date().getTime() + Math.random();
- if (version) rq_open = self.indexedDB.open(dbname, version);
- else rq_open = self.indexedDB.open(dbname);
+ if (version) rq_open = useTestIndexedDb().open(dbname, version);
+ else rq_open = useTestIndexedDb().open(dbname);
return rq_open;
}
@@ -112,7 +104,7 @@ export async function migrateNamedDatabase(
migrationCallback: MigrationCallback,
): Promise<IDBDatabase> {
return new Promise<IDBDatabase>((resolve, reject) => {
- const request = self.indexedDB.open(databaseName, newVersion);
+ const request = useTestIndexedDb().open(databaseName, newVersion);
request.onupgradeneeded = (event: any) => {
const database = event.target.result;
const transaction = event.target.transaction;
@@ -176,7 +168,7 @@ export async function createDatabase(
setupCallback: MigrationCallback,
): Promise<IDBDatabase> {
const databaseName = makeDatabaseName(t.title);
- const request = self.indexedDB.deleteDatabase(databaseName);
+ const request = useTestIndexedDb().deleteDatabase(databaseName);
return migrateNamedDatabase(t, databaseName, 1, setupCallback);
}
@@ -464,9 +456,9 @@ export function indexeddb_test(
options = Object.assign({ upgrade_will_abort: false }, options);
const dbname =
"testdb-" + new Date().getTime() + Math.random() + (dbsuffix ?? "");
- var del = self.indexedDB.deleteDatabase(dbname);
+ var del = useTestIndexedDb().deleteDatabase(dbname);
del.onerror = () => t.fail("deleteDatabase should succeed");
- var open = self.indexedDB.open(dbname, 1);
+ var open = useTestIndexedDb().open(dbname, 1);
open.onupgradeneeded = function () {
var db = open.result;
t.teardown(function () {
@@ -475,7 +467,7 @@ export function indexeddb_test(
e.preventDefault();
};
db.close();
- self.indexedDB.deleteDatabase(db.name);
+ useTestIndexedDb().deleteDatabase(db.name);
});
var tx = open.transaction!;
upgrade_func(resolve, db, tx, open);
diff --git a/packages/idb-bridge/src/idbpromutil.ts b/packages/idb-bridge/src/idbpromutil.ts
new file mode 100644
index 000000000..e711db027
--- /dev/null
+++ b/packages/idb-bridge/src/idbpromutil.ts
@@ -0,0 +1,26 @@
+import { BridgeIDBTransaction } from "./bridge-idb.js";
+import { IDBRequest } from "./idbtypes.js";
+
+export function promiseFromRequest(request: IDBRequest): Promise<any> {
+ return new Promise((resolve, reject) => {
+ request.onsuccess = () => {
+ resolve(request.result);
+ };
+ request.onerror = () => {
+ reject(request.error);
+ };
+ });
+}
+
+export function promiseFromTransaction(
+ transaction: BridgeIDBTransaction,
+): Promise<void> {
+ return new Promise<void>((resolve, reject) => {
+ transaction.oncomplete = () => {
+ resolve();
+ };
+ transaction.onerror = () => {
+ reject();
+ };
+ });
+} \ No newline at end of file
diff --git a/packages/idb-bridge/src/idbtypes.ts b/packages/idb-bridge/src/idbtypes.ts
index fe9003d6d..9ee93e050 100644
--- a/packages/idb-bridge/src/idbtypes.ts
+++ b/packages/idb-bridge/src/idbtypes.ts
@@ -19,48 +19,27 @@ and limitations under the License.
* Instead of ambient types, we export type declarations.
*/
-/**
- * @public
- */
export type IDBKeyPath = string;
-/**
- * @public
- */
export interface EventListener {
(evt: Event): void;
}
-/**
- * @public
- */
export interface EventListenerObject {
handleEvent(evt: Event): void;
}
-/**
- * @public
- */
export interface EventListenerOptions {
capture?: boolean;
}
-/**
- * @public
- */
export interface AddEventListenerOptions extends EventListenerOptions {
once?: boolean;
passive?: boolean;
}
-/**
- * @public
- */
export type IDBTransactionMode = "readonly" | "readwrite" | "versionchange";
-/**
- * @public
- */
export type EventListenerOrEventListenerObject =
| EventListener
| EventListenerObject;
@@ -68,8 +47,6 @@ export type EventListenerOrEventListenerObject =
/**
* EventTarget is a DOM interface implemented by objects that can receive
* events and may have listeners for them.
- *
- * @public
*/
export interface EventTarget {
/**
@@ -820,7 +797,7 @@ export interface IDBTransaction extends EventTarget {
/**
* If the transaction was aborted, returns the error (a DOMException) providing the reason.
*/
- readonly error: DOMException;
+ readonly error: DOMException | null;
/**
* Returns the mode the transaction was created with ("readonly" or "readwrite"), or "versionchange" for an upgrade transaction.
*/
@@ -836,6 +813,7 @@ export interface IDBTransaction extends EventTarget {
* Aborts the transaction. All pending requests will fail with a "AbortError" DOMException and all changes made to the database will be reverted.
*/
abort(): void;
+ commit(): void;
/**
* Returns an IDBObjectStore in the transaction's scope.
*/
diff --git a/packages/idb-bridge/src/index.ts b/packages/idb-bridge/src/index.ts
index fa9edaea6..47ff80119 100644
--- a/packages/idb-bridge/src/index.ts
+++ b/packages/idb-bridge/src/index.ts
@@ -1,27 +1,13 @@
import {
+ Backend,
+ DatabaseConnection,
DatabaseTransaction,
RecordGetResponse,
- RecordGetRequest,
- Schema,
- Backend,
RecordStoreRequest,
RecordStoreResponse,
- DatabaseConnection,
- ObjectStoreProperties,
- StoreLevel,
ResultLevel,
- IndexProperties,
-} from "./backend-interface";
-import { Listener } from "./util/FakeEventTarget";
-import {
- DatabaseDump,
- ObjectStoreDump,
- IndexDump,
- IndexRecord,
- ObjectStoreRecord,
- MemoryBackendDump,
-} from "./MemoryBackend";
-import { Event } from "./idbtypes";
+ StoreLevel,
+} from "./backend-interface.js";
import {
BridgeIDBCursor,
BridgeIDBDatabase,
@@ -35,8 +21,24 @@ import {
BridgeIDBVersionChangeEvent,
DatabaseList,
RequestObj,
-} from "./bridge-idb";
+} from "./bridge-idb.js";
+import { Event } from "./idbtypes.js";
+import {
+ DatabaseDump,
+ IndexRecord,
+ MemoryBackendDump,
+ ObjectStoreDump,
+ ObjectStoreRecord,
+} from "./MemoryBackend.js";
+import { Listener } from "./util/FakeEventTarget.js";
+
+export * from "./SqliteBackend.js";
+export * from "./sqlite3-interface.js";
+export * from "./idbtypes.js";
+export { MemoryBackend } from "./MemoryBackend.js";
+export type { AccessStats } from "./MemoryBackend.js";
+export * from "./util/structuredClone.js";
export {
BridgeIDBCursor,
BridgeIDBDatabase,
@@ -52,29 +54,22 @@ export {
};
export type {
DatabaseTransaction,
- RecordGetRequest,
RecordGetResponse,
- Schema,
Backend,
DatabaseList,
RecordStoreRequest,
RecordStoreResponse,
DatabaseConnection,
- ObjectStoreProperties,
RequestObj,
DatabaseDump,
ObjectStoreDump,
- IndexDump,
IndexRecord,
ObjectStoreRecord,
- IndexProperties,
MemoryBackendDump,
Event,
Listener,
};
-export { MemoryBackend } from "./MemoryBackend";
-
// globalThis polyfill, see https://mathiasbynens.be/notes/globalthis
(function () {
if (typeof globalThis === "object") return;
@@ -91,6 +86,17 @@ export { MemoryBackend } from "./MemoryBackend";
})();
/**
+ * Global indexeddb objects, either from the native or bridge-idb
+ * implementation, depending on what is available in
+ * the global environment.
+ */
+export const GlobalIDB: {
+ KeyRange: typeof BridgeIDBKeyRange;
+} = {
+ KeyRange: (globalThis as any).IDBKeyRange ?? BridgeIDBKeyRange,
+};
+
+/**
* Populate the global name space such that the given IndexedDB factory is made
* available globally.
*
@@ -113,7 +119,3 @@ export function shimIndexedDB(factory: BridgeIDBFactory): void {
g.IDBTransaction = BridgeIDBTransaction;
g.IDBVersionChangeEvent = BridgeIDBVersionChangeEvent;
}
-
-export * from "./idbtypes";
-
-export * from "./util/structuredClone";
diff --git a/packages/idb-bridge/src/node-sqlite3-impl.ts b/packages/idb-bridge/src/node-sqlite3-impl.ts
new file mode 100644
index 000000000..fa38d298f
--- /dev/null
+++ b/packages/idb-bridge/src/node-sqlite3-impl.ts
@@ -0,0 +1,84 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+// @ts-ignore: optional dependency
+import type Database from "better-sqlite3";
+import {
+ ResultRow,
+ Sqlite3Interface,
+ Sqlite3Statement,
+} from "./sqlite3-interface.js";
+
+export async function createNodeSqlite3Impl(): Promise<Sqlite3Interface> {
+ // @ts-ignore: optional dependency
+ const bsq = (await import("better-sqlite3")).default;
+
+ return {
+ open(filename: string) {
+ const internalDbHandle = bsq(filename);
+ return {
+ internalDbHandle,
+ close() {
+ internalDbHandle.close();
+ },
+ prepare(stmtStr): Sqlite3Statement {
+ const stmtHandle = internalDbHandle.prepare(stmtStr);
+ return {
+ internalStatement: stmtHandle,
+ getAll(params): ResultRow[] {
+ let res: ResultRow[];
+ if (params === undefined) {
+ res = stmtHandle.all() as ResultRow[];
+ } else {
+ res = stmtHandle.all(params) as ResultRow[];
+ }
+ return res;
+ },
+ getFirst(params): ResultRow | undefined {
+ let res: ResultRow | undefined;
+ if (params === undefined) {
+ res = stmtHandle.get() as ResultRow | undefined;
+ } else {
+ res = stmtHandle.get(params) as ResultRow | undefined;
+ }
+ return res;
+ },
+ run(params) {
+ const myParams = [];
+ if (params !== undefined) {
+ myParams.push(params);
+ }
+ // The better-sqlite3 library doesn't like it we pass
+ // undefined directly.
+ let res: Database.RunResult;
+ if (params !== undefined) {
+ res = stmtHandle.run(params);
+ } else {
+ res = stmtHandle.run();
+ }
+ return {
+ lastInsertRowid: res.lastInsertRowid,
+ };
+ },
+ };
+ },
+ exec(sqlStr): void {
+ internalDbHandle.exec(sqlStr);
+ },
+ };
+ },
+ };
+}
diff --git a/packages/idb-bridge/src/sqlite3-interface.ts b/packages/idb-bridge/src/sqlite3-interface.ts
new file mode 100644
index 000000000..8668ef844
--- /dev/null
+++ b/packages/idb-bridge/src/sqlite3-interface.ts
@@ -0,0 +1,34 @@
+export type Sqlite3Database = {
+ internalDbHandle: any;
+ exec(sqlStr: string): void;
+ prepare(stmtStr: string): Sqlite3Statement;
+ close(): void;
+};
+export type Sqlite3Statement = {
+ internalStatement: any;
+
+ run(params?: BindParams): RunResult;
+ getAll(params?: BindParams): ResultRow[];
+ getFirst(params?: BindParams): ResultRow | undefined;
+};
+
+export interface RunResult {
+ lastInsertRowid: number | bigint;
+}
+
+export type Sqlite3Value = string | Uint8Array | number | null | bigint;
+
+export type BindParams = Record<string, Sqlite3Value | undefined>;
+export type ResultRow = Record<string, Sqlite3Value>;
+
+/**
+ * Common interface that multiple sqlite3 bindings
+ * (such as better-sqlite3 or qtart's sqlite3 bindings)
+ * can adapt to.
+ *
+ * This does not expose full sqlite3 functionality, but just enough
+ * to be used by our IndexedDB sqlite3 backend.
+ */
+export interface Sqlite3Interface {
+ open(filename: string): Sqlite3Database;
+}
diff --git a/packages/idb-bridge/src/testingdb.ts b/packages/idb-bridge/src/testingdb.ts
new file mode 100644
index 000000000..6c13979ca
--- /dev/null
+++ b/packages/idb-bridge/src/testingdb.ts
@@ -0,0 +1,43 @@
+/*
+ Copyright 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { createSqliteBackend } from "./SqliteBackend.js";
+import { BridgeIDBFactory } from "./bridge-idb.js";
+import { IDBFactory } from "./idbtypes.js";
+import { createNodeSqlite3Impl } from "./node-sqlite3-impl.js";
+
+let idbFactory: IDBFactory | undefined = undefined;
+
+export async function initTestIndexedDB(): Promise<void> {
+ // const backend = new MemoryBackend();
+ // backend.enableTracing = true;
+
+ const sqlite3Impl = await createNodeSqlite3Impl();
+
+ const backend = await createSqliteBackend(sqlite3Impl, {
+ filename: ":memory:",
+ });
+
+ idbFactory = new BridgeIDBFactory(backend);
+ backend.enableTracing = false;
+ BridgeIDBFactory.enableTracing = false;
+}
+
+export function useTestIndexedDb(): IDBFactory {
+ if (!idbFactory) {
+ throw Error("indexeddb factory not initialized");
+ }
+ return idbFactory;
+}
diff --git a/packages/idb-bridge/src/tree/b+tree.ts b/packages/idb-bridge/src/tree/b+tree.ts
index abe65e355..c51360d70 100644
--- a/packages/idb-bridge/src/tree/b+tree.ts
+++ b/packages/idb-bridge/src/tree/b+tree.ts
@@ -1,46 +1,22 @@
-/*
-Copyright (c) 2018 David Piepgrass
+// B+ tree by David Piepgrass. License: MIT
+import { ISortedMap, ISortedMapF } from "./interfaces.js";
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-SPDX-License-Identifier: MIT
-*/
-
-// Original repository: https://github.com/qwertie/btree-typescript
-
-import { ISortedMap, ISortedMapF } from "./interfaces";
export type {
- ISetSource,
- ISetSink,
- ISet,
- ISetF,
- ISortedSetSource,
- ISortedSet,
- ISortedSetF,
- IMapSource,
- IMapSink,
IMap,
IMapF,
- ISortedMapSource,
+ IMapSink,
+ IMapSource,
+ ISet,
+ ISetF,
+ ISetSink,
+ ISetSource,
ISortedMap,
ISortedMapF,
-} from "./interfaces";
+ ISortedMapSource,
+ ISortedSet,
+ ISortedSetF,
+ ISortedSetSource,
+} from "./interfaces.js";
export type EditRangeResult<V, R = number> = {
value?: V;
@@ -71,17 +47,108 @@ type index = number;
// - Objects can be used like arrays (e.g. have length property) but are slower
// - V8 source (NewElementsCapacity in src/objects.h): arrays grow by 50% + 16 elements
-/** Compares two numbers, strings, arrays of numbers/strings, Dates,
- * or objects that have a valueOf() method returning a number or string.
- * Optimized for numbers. Returns 1 if a>b, -1 if a<b, and 0 if a===b.
+/**
+ * Types that BTree supports by default
+ */
+export type DefaultComparable =
+ | number
+ | string
+ | Date
+ | boolean
+ | null
+ | undefined
+ | (number | string)[]
+ | {
+ valueOf: () =>
+ | number
+ | string
+ | Date
+ | boolean
+ | null
+ | undefined
+ | (number | string)[];
+ };
+
+/**
+ * Compares DefaultComparables to form a strict partial ordering.
+ *
+ * Handles +/-0 and NaN like Map: NaN is equal to NaN, and -0 is equal to +0.
+ *
+ * Arrays are compared using '<' and '>', which may cause unexpected equality:
+ * for example [1] will be considered equal to ['1'].
+ *
+ * Two objects with equal valueOf compare the same, but compare unequal to
+ * primitives that have the same value.
+ */
+export function defaultComparator(
+ a: DefaultComparable,
+ b: DefaultComparable,
+): number {
+ // Special case finite numbers first for performance.
+ // Note that the trick of using 'a - b' and checking for NaN to detect non-numbers
+ // does not work if the strings are numeric (ex: "5"). This would leading most
+ // comparison functions using that approach to fail to have transitivity.
+ if (Number.isFinite(a as any) && Number.isFinite(b as any)) {
+ return (a as number) - (b as number);
+ }
+
+ // The default < and > operators are not totally ordered. To allow types to be mixed
+ // in a single collection, compare types and order values of different types by type.
+ let ta = typeof a;
+ let tb = typeof b;
+ if (ta !== tb) {
+ return ta < tb ? -1 : 1;
+ }
+
+ if (ta === "object") {
+ // standardized JavaScript bug: null is not an object, but typeof says it is
+ if (a === null) return b === null ? 0 : -1;
+ else if (b === null) return 1;
+
+ a = a!.valueOf() as DefaultComparable;
+ b = b!.valueOf() as DefaultComparable;
+ ta = typeof a;
+ tb = typeof b;
+ // Deal with the two valueOf()s producing different types
+ if (ta !== tb) {
+ return ta < tb ? -1 : 1;
+ }
+ }
+
+ // a and b are now the same type, and will be a number, string or array
+ // (which we assume holds numbers or strings), or something unsupported.
+ if (a! < b!) return -1;
+ if (a! > b!) return 1;
+ if (a === b) return 0;
+
+ // Order NaN less than other numbers
+ if (Number.isNaN(a as any)) return Number.isNaN(b as any) ? 0 : -1;
+ else if (Number.isNaN(b as any)) return 1;
+ // This could be two objects (e.g. [7] and ['7']) that aren't ordered
+ return Array.isArray(a) ? 0 : Number.NaN;
+}
+
+/**
+ * Compares items using the < and > operators. This function is probably slightly
+ * faster than the defaultComparator for Dates and strings, but has not been benchmarked.
+ * Unlike defaultComparator, this comparator doesn't support mixed types correctly,
+ * i.e. use it with `BTree<string>` or `BTree<number>` but not `BTree<string|number>`.
+ *
+ * NaN is not supported.
+ *
+ * Note: null is treated like 0 when compared with numbers or Date, but in general
+ * null is not ordered with respect to strings (neither greater nor less), and
+ * undefined is not ordered with other types.
*/
-export function defaultComparator(a: any, b: any) {
- var c = a - b;
- if (c === c) return c; // a & b are number
- // General case (c is NaN): string / arrays / Date / incomparable things
- if (a) a = a.valueOf();
- if (b) b = b.valueOf();
- return a < b ? -1 : a > b ? 1 : a == b ? 0 : c;
+export function simpleComparator(a: string, b: string): number;
+export function simpleComparator(a: number | null, b: number | null): number;
+export function simpleComparator(a: Date | null, b: Date | null): number;
+export function simpleComparator(
+ a: (number | string)[],
+ b: (number | string)[],
+): number;
+export function simpleComparator(a: any, b: any): number {
+ return a > b ? 1 : a < b ? -1 : 0;
}
/**
@@ -149,16 +216,22 @@ export function defaultComparator(a: any, b: any) {
* @author David Piepgrass
*/
export default class BTree<K = any, V = any>
- implements ISortedMapF<K, V>, ISortedMap<K, V> {
+ implements ISortedMapF<K, V>, ISortedMap<K, V>
+{
private _root: BNode<K, V> = EmptyLeaf as BNode<K, V>;
_size: number = 0;
_maxNodeSize: number;
+
+ /**
+ * provides a total order over keys (and a strict partial order over the type K)
+ * @returns a negative value if a < b, 0 if a === b and a positive value if a > b
+ */
_compare: (a: K, b: K) => number;
/**
* Initializes an empty B+ tree.
* @param compare Custom function to compare pairs of elements in the tree.
- * This is not required for numbers, strings and arrays of numbers/strings.
+ * If not specified, defaultComparator will be used which is valid as long as K extends DefaultComparable.
* @param entries A set of key-value pairs to initialize the tree
* @param maxNodeSize Branching factor (maximum items or children per node)
* Must be in range 4..256. If undefined or <4 then default is used; if >256 then 256.
@@ -169,11 +242,13 @@ export default class BTree<K = any, V = any>
maxNodeSize?: number,
) {
this._maxNodeSize = maxNodeSize! >= 4 ? Math.min(maxNodeSize!, 256) : 32;
- this._compare = compare || defaultComparator;
+ this._compare =
+ compare || (defaultComparator as any as (a: K, b: K) => number);
if (entries) this.setPairs(entries);
}
- // ES6 Map<K,V> methods ///////////////////////////////////////////////////
+ /////////////////////////////////////////////////////////////////////////////
+ // ES6 Map<K,V> methods /////////////////////////////////////////////////////
/** Gets the number of key-value pairs in the tree. */
get size() {
@@ -292,7 +367,8 @@ export default class BTree<K = any, V = any>
return this.editRange(key, key, true, DeleteRange) !== 0;
}
- // Clone-mutators /////////////////////////////////////////////////////////
+ /////////////////////////////////////////////////////////////////////////////
+ // Clone-mutators ///////////////////////////////////////////////////////////
/** Returns a copy of the tree with the specified key set (the value is undefined). */
with(key: K): BTree<K, V | undefined>;
@@ -388,7 +464,7 @@ export default class BTree<K = any, V = any>
nu.editAll((k, v, i) => {
return (tmp.value = callback(v, k, i)), tmp as any;
});
- return (nu as any) as BTree<K, R>;
+ return nu as any as BTree<K, R>;
}
/** Performs a reduce operation like the `reduce` method of `Array`.
@@ -437,7 +513,8 @@ export default class BTree<K = any, V = any>
return p;
}
- // Iterator methods ///////////////////////////////////////////////////////
+ /////////////////////////////////////////////////////////////////////////////
+ // Iterator methods /////////////////////////////////////////////////////////
/** Returns an iterator that provides items in order (ascending order if
* the collection's comparator uses ascending order, as is the default.)
@@ -482,9 +559,9 @@ export default class BTree<K = any, V = any>
if (++nodeindex[level] < nodequeue[level].length) break;
}
for (; level > 0; level--) {
- nodequeue[level - 1] = (nodequeue[level][
- nodeindex[level]
- ] as BNodeInternal<K, V>).children;
+ nodequeue[level - 1] = (
+ nodequeue[level][nodeindex[level]] as BNodeInternal<K, V>
+ ).children;
nodeindex[level - 1] = 0;
}
leaf = nodequeue[0][nodeindex[0]];
@@ -500,7 +577,7 @@ export default class BTree<K = any, V = any>
/** Returns an iterator that provides items in reversed order.
* @param highestKey Key at which to start iterating, or undefined to
- * start at minKey(). If the specified key doesn't exist then iteration
+ * start at maxKey(). If the specified key doesn't exist then iteration
* starts at the next lower key (according to the comparator).
* @param reusedArray Optional array used repeatedly to store key-value
* pairs, to avoid creating a new array on every iteration.
@@ -512,13 +589,21 @@ export default class BTree<K = any, V = any>
reusedArray?: (K | V)[],
skipHighest?: boolean,
): IterableIterator<[K, V]> {
- if ((highestKey = highestKey || this.maxKey()) === undefined)
- return iterator<[K, V]>(); // collection is empty
+ if (highestKey === undefined) {
+ highestKey = this.maxKey();
+ skipHighest = undefined;
+ if (highestKey === undefined) return iterator<[K, V]>(); // collection is empty
+ }
var { nodequeue, nodeindex, leaf } =
this.findPath(highestKey) || this.findPath(this.maxKey())!;
check(!nodequeue[0] || leaf === nodequeue[0][nodeindex[0]], "wat!");
var i = leaf.indexOf(highestKey, 0, this._compare);
- if (!(skipHighest || this._compare(leaf.keys[i], highestKey) > 0)) i++;
+ if (
+ !skipHighest &&
+ i < leaf.keys.length &&
+ this._compare(leaf.keys[i], highestKey) <= 0
+ )
+ i++;
var state = reusedArray !== undefined ? 1 : 0;
return iterator<[K, V]>(() => {
@@ -546,9 +631,9 @@ export default class BTree<K = any, V = any>
if (--nodeindex[level] >= 0) break;
}
for (; level > 0; level--) {
- nodequeue[level - 1] = (nodequeue[level][
- nodeindex[level]
- ] as BNodeInternal<K, V>).children;
+ nodequeue[level - 1] = (
+ nodequeue[level][nodeindex[level]] as BNodeInternal<K, V>
+ ).children;
nodeindex[level - 1] = nodequeue[level - 1].length - 1;
}
leaf = nodequeue[0][nodeindex[0]];
@@ -596,6 +681,320 @@ export default class BTree<K = any, V = any>
return { nodequeue, nodeindex, leaf: nextnode };
}
+ /**
+ * Computes the differences between `this` and `other`.
+ * For efficiency, the diff is returned via invocations of supplied handlers.
+ * The computation is optimized for the case in which the two trees have large amounts
+ * of shared data (obtained by calling the `clone` or `with` APIs) and will avoid
+ * any iteration of shared state.
+ * The handlers can cause computation to early exit by returning {break: R}.
+ * Neither of the collections should be changed during the comparison process (in your callbacks), as this method assumes they will not be mutated.
+ * @param other The tree to compute a diff against.
+ * @param onlyThis Callback invoked for all keys only present in `this`.
+ * @param onlyOther Callback invoked for all keys only present in `other`.
+ * @param different Callback invoked for all keys with differing values.
+ */
+ diffAgainst<R>(
+ other: BTree<K, V>,
+ onlyThis?: (k: K, v: V) => { break?: R } | void,
+ onlyOther?: (k: K, v: V) => { break?: R } | void,
+ different?: (k: K, vThis: V, vOther: V) => { break?: R } | void,
+ ): R | undefined {
+ if (other._compare !== this._compare) {
+ throw new Error("Tree comparators are not the same.");
+ }
+
+ if (this.isEmpty || other.isEmpty) {
+ if (this.isEmpty && other.isEmpty) return undefined;
+ // If one tree is empty, everything will be an onlyThis/onlyOther.
+ if (this.isEmpty)
+ return onlyOther === undefined
+ ? undefined
+ : BTree.stepToEnd(BTree.makeDiffCursor(other), onlyOther);
+ return onlyThis === undefined
+ ? undefined
+ : BTree.stepToEnd(BTree.makeDiffCursor(this), onlyThis);
+ }
+
+ // Cursor-based diff algorithm is as follows:
+ // - Until neither cursor has navigated to the end of the tree, do the following:
+ // - If the `this` cursor is "behind" the `other` cursor (strictly <, via compare), advance it.
+ // - Otherwise, advance the `other` cursor.
+ // - Any time a cursor is stepped, perform the following:
+ // - If either cursor points to a key/value pair:
+ // - If thisCursor === otherCursor and the values differ, it is a Different.
+ // - If thisCursor > otherCursor and otherCursor is at a key/value pair, it is an OnlyOther.
+ // - If thisCursor < otherCursor and thisCursor is at a key/value pair, it is an OnlyThis as long as the most recent
+ // cursor step was *not* otherCursor advancing from a tie. The extra condition avoids erroneous OnlyOther calls
+ // that would occur due to otherCursor being the "leader".
+ // - Otherwise, if both cursors point to nodes, compare them. If they are equal by reference (shared), skip
+ // both cursors to the next node in the walk.
+ // - Once one cursor has finished stepping, any remaining steps (if any) are taken and key/value pairs are logged
+ // as OnlyOther (if otherCursor is stepping) or OnlyThis (if thisCursor is stepping).
+ // This algorithm gives the critical guarantee that all locations (both nodes and key/value pairs) in both trees that
+ // are identical by value (and possibly by reference) will be visited *at the same time* by the cursors.
+ // This removes the possibility of emitting incorrect diffs, as well as allowing for skipping shared nodes.
+ const { _compare } = this;
+ const thisCursor = BTree.makeDiffCursor(this);
+ const otherCursor = BTree.makeDiffCursor(other);
+ // It doesn't matter how thisSteppedLast is initialized.
+ // Step order is only used when either cursor is at a leaf, and cursors always start at a node.
+ let thisSuccess = true,
+ otherSuccess = true,
+ prevCursorOrder = BTree.compare(thisCursor, otherCursor, _compare);
+ while (thisSuccess && otherSuccess) {
+ const cursorOrder = BTree.compare(thisCursor, otherCursor, _compare);
+ const {
+ leaf: thisLeaf,
+ internalSpine: thisInternalSpine,
+ levelIndices: thisLevelIndices,
+ } = thisCursor;
+ const {
+ leaf: otherLeaf,
+ internalSpine: otherInternalSpine,
+ levelIndices: otherLevelIndices,
+ } = otherCursor;
+ if (thisLeaf || otherLeaf) {
+ // If the cursors were at the same location last step, then there is no work to be done.
+ if (prevCursorOrder !== 0) {
+ if (cursorOrder === 0) {
+ if (thisLeaf && otherLeaf && different) {
+ // Equal keys, check for modifications
+ const valThis =
+ thisLeaf.values[thisLevelIndices[thisLevelIndices.length - 1]];
+ const valOther =
+ otherLeaf.values[
+ otherLevelIndices[otherLevelIndices.length - 1]
+ ];
+ if (!Object.is(valThis, valOther)) {
+ const result = different(
+ thisCursor.currentKey,
+ valThis,
+ valOther,
+ );
+ if (result && result.break) return result.break;
+ }
+ }
+ } else if (cursorOrder > 0) {
+ // If this is the case, we know that either:
+ // 1. otherCursor stepped last from a starting position that trailed thisCursor, and is still behind, or
+ // 2. thisCursor stepped last and leapfrogged otherCursor
+ // Either of these cases is an "only other"
+ if (otherLeaf && onlyOther) {
+ const otherVal =
+ otherLeaf.values[
+ otherLevelIndices[otherLevelIndices.length - 1]
+ ];
+ const result = onlyOther(otherCursor.currentKey, otherVal);
+ if (result && result.break) return result.break;
+ }
+ } else if (onlyThis) {
+ if (thisLeaf && prevCursorOrder !== 0) {
+ const valThis =
+ thisLeaf.values[thisLevelIndices[thisLevelIndices.length - 1]];
+ const result = onlyThis(thisCursor.currentKey, valThis);
+ if (result && result.break) return result.break;
+ }
+ }
+ }
+ } else if (!thisLeaf && !otherLeaf && cursorOrder === 0) {
+ const lastThis = thisInternalSpine.length - 1;
+ const lastOther = otherInternalSpine.length - 1;
+ const nodeThis =
+ thisInternalSpine[lastThis][thisLevelIndices[lastThis]];
+ const nodeOther =
+ otherInternalSpine[lastOther][otherLevelIndices[lastOther]];
+ if (nodeOther === nodeThis) {
+ prevCursorOrder = 0;
+ thisSuccess = BTree.step(thisCursor, true);
+ otherSuccess = BTree.step(otherCursor, true);
+ continue;
+ }
+ }
+ prevCursorOrder = cursorOrder;
+ if (cursorOrder < 0) {
+ thisSuccess = BTree.step(thisCursor);
+ } else {
+ otherSuccess = BTree.step(otherCursor);
+ }
+ }
+
+ if (thisSuccess && onlyThis)
+ return BTree.finishCursorWalk(
+ thisCursor,
+ otherCursor,
+ _compare,
+ onlyThis,
+ );
+ if (otherSuccess && onlyOther)
+ return BTree.finishCursorWalk(
+ otherCursor,
+ thisCursor,
+ _compare,
+ onlyOther,
+ );
+ }
+
+ ///////////////////////////////////////////////////////////////////////////
+ // Helper methods for diffAgainst /////////////////////////////////////////
+
+ private static finishCursorWalk<K, V, R>(
+ cursor: DiffCursor<K, V>,
+ cursorFinished: DiffCursor<K, V>,
+ compareKeys: (a: K, b: K) => number,
+ callback: (k: K, v: V) => { break?: R } | void,
+ ): R | undefined {
+ const compared = BTree.compare(cursor, cursorFinished, compareKeys);
+ if (compared === 0) {
+ if (!BTree.step(cursor)) return undefined;
+ } else if (compared < 0) {
+ check(false, "cursor walk terminated early");
+ }
+ return BTree.stepToEnd(cursor, callback);
+ }
+
+ private static stepToEnd<K, V, R>(
+ cursor: DiffCursor<K, V>,
+ callback: (k: K, v: V) => { break?: R } | void,
+ ): R | undefined {
+ let canStep: boolean = true;
+ while (canStep) {
+ const { leaf, levelIndices, currentKey } = cursor;
+ if (leaf) {
+ const value = leaf.values[levelIndices[levelIndices.length - 1]];
+ const result = callback(currentKey, value);
+ if (result && result.break) return result.break;
+ }
+ canStep = BTree.step(cursor);
+ }
+ return undefined;
+ }
+
+ private static makeDiffCursor<K, V>(tree: BTree<K, V>): DiffCursor<K, V> {
+ const { _root, height } = tree;
+ return {
+ height: height,
+ internalSpine: [[_root]],
+ levelIndices: [0],
+ leaf: undefined,
+ currentKey: _root.maxKey(),
+ };
+ }
+
+ /**
+ * Advances the cursor to the next step in the walk of its tree.
+ * Cursors are walked backwards in sort order, as this allows them to leverage maxKey() in order to be compared in O(1).
+ * @param cursor The cursor to step
+ * @param stepToNode If true, the cursor will be advanced to the next node (skipping values)
+ * @returns true if the step was completed and false if the step would have caused the cursor to move beyond the end of the tree.
+ */
+ private static step<K, V>(
+ cursor: DiffCursor<K, V>,
+ stepToNode?: boolean,
+ ): boolean {
+ const { internalSpine, levelIndices, leaf } = cursor;
+ if (stepToNode === true || leaf) {
+ const levelsLength = levelIndices.length;
+ // Step to the next node only if:
+ // - We are explicitly directed to via stepToNode, or
+ // - There are no key/value pairs left to step to in this leaf
+ if (stepToNode === true || levelIndices[levelsLength - 1] === 0) {
+ const spineLength = internalSpine.length;
+ // Root is leaf
+ if (spineLength === 0) return false;
+ // Walk back up the tree until we find a new subtree to descend into
+ const nodeLevelIndex = spineLength - 1;
+ let levelIndexWalkBack = nodeLevelIndex;
+ while (levelIndexWalkBack >= 0) {
+ if (levelIndices[levelIndexWalkBack] > 0) {
+ if (levelIndexWalkBack < levelsLength - 1) {
+ // Remove leaf state from cursor
+ cursor.leaf = undefined;
+ levelIndices.pop();
+ }
+ // If we walked upwards past any internal node, slice them out
+ if (levelIndexWalkBack < nodeLevelIndex)
+ cursor.internalSpine = internalSpine.slice(
+ 0,
+ levelIndexWalkBack + 1,
+ );
+ // Move to new internal node
+ cursor.currentKey =
+ internalSpine[levelIndexWalkBack][
+ --levelIndices[levelIndexWalkBack]
+ ].maxKey();
+ return true;
+ }
+ levelIndexWalkBack--;
+ }
+ // Cursor is in the far left leaf of the tree, no more nodes to enumerate
+ return false;
+ } else {
+ // Move to new leaf value
+ const valueIndex = --levelIndices[levelsLength - 1];
+ cursor.currentKey = (leaf as unknown as BNode<K, V>).keys[valueIndex];
+ return true;
+ }
+ } else {
+ // Cursor does not point to a value in a leaf, so move downwards
+ const nextLevel = internalSpine.length;
+ const currentLevel = nextLevel - 1;
+ const node = internalSpine[currentLevel][levelIndices[currentLevel]];
+ if (node.isLeaf) {
+ // Entering into a leaf. Set the cursor to point at the last key/value pair.
+ cursor.leaf = node;
+ const valueIndex = (levelIndices[nextLevel] = node.values.length - 1);
+ cursor.currentKey = node.keys[valueIndex];
+ } else {
+ const children = (node as BNodeInternal<K, V>).children;
+ internalSpine[nextLevel] = children;
+ const childIndex = children.length - 1;
+ levelIndices[nextLevel] = childIndex;
+ cursor.currentKey = children[childIndex].maxKey();
+ }
+ return true;
+ }
+ }
+
+ /**
+ * Compares the two cursors. Returns a value indicating which cursor is ahead in a walk.
+ * Note that cursors are advanced in reverse sorting order.
+ */
+ private static compare<K, V>(
+ cursorA: DiffCursor<K, V>,
+ cursorB: DiffCursor<K, V>,
+ compareKeys: (a: K, b: K) => number,
+ ): number {
+ const {
+ height: heightA,
+ currentKey: currentKeyA,
+ levelIndices: levelIndicesA,
+ } = cursorA;
+ const {
+ height: heightB,
+ currentKey: currentKeyB,
+ levelIndices: levelIndicesB,
+ } = cursorB;
+ // Reverse the comparison order, as cursors are advanced in reverse sorting order
+ const keyComparison = compareKeys(currentKeyB, currentKeyA);
+ if (keyComparison !== 0) {
+ return keyComparison;
+ }
+
+ // Normalize depth values relative to the shortest tree.
+ // This ensures that concurrent cursor walks of trees of differing heights can reliably land on shared nodes at the same time.
+ // To accomplish this, a cursor that is on an internal node at depth D1 with maxKey X is considered "behind" a cursor on an
+ // internal node at depth D2 with maxKey Y, when D1 < D2. Thus, always walking the cursor that is "behind" will allow the cursor
+ // at shallower depth (but equal maxKey) to "catch up" and land on shared nodes.
+ const heightMin = heightA < heightB ? heightA : heightB;
+ const depthANormalized = levelIndicesA.length - (heightA - heightMin);
+ const depthBNormalized = levelIndicesB.length - (heightB - heightMin);
+ return depthANormalized - depthBNormalized;
+ }
+
+ // End of helper methods for diffAgainst //////////////////////////////////
+ ///////////////////////////////////////////////////////////////////////////
+
/** Returns a new iterator for iterating the keys of each pair in ascending order.
* @param firstKey: Minimum key to include in the output. */
keys(firstKey?: K): IterableIterator<K> {
@@ -618,7 +1017,8 @@ export default class BTree<K = any, V = any>
});
}
- // Additional methods /////////////////////////////////////////////////////
+ /////////////////////////////////////////////////////////////////////////////
+ // Additional methods ///////////////////////////////////////////////////////
/** Returns the maximum number of children/values before nodes will split. */
get maxNodeSize() {
@@ -714,30 +1114,90 @@ export default class BTree<K = any, V = any>
return this.set(key, value, false);
}
- /** Returns the next pair whose key is larger than the specified key (or undefined if there is none) */
- nextHigherPair(key: K): [K, V] | undefined {
- var it = this.entries(key, ReusedArray);
- var r = it.next();
- if (!r.done && this._compare(r.value[0], key) <= 0) r = it.next();
- return r.value;
+ /** Returns the next pair whose key is larger than the specified key (or undefined if there is none).
+ * If key === undefined, this function returns the lowest pair.
+ * @param key The key to search for.
+ * @param reusedArray Optional array used repeatedly to store key-value pairs, to
+ * avoid creating a new array on every iteration.
+ */
+ nextHigherPair(key: K | undefined, reusedArray?: [K, V]): [K, V] | undefined {
+ reusedArray = reusedArray || ([] as unknown as [K, V]);
+ if (key === undefined) {
+ return this._root.minPair(reusedArray);
+ }
+ return this._root.getPairOrNextHigher(
+ key,
+ this._compare,
+ false,
+ reusedArray,
+ );
+ }
+
+ /** Returns the next key larger than the specified key, or undefined if there is none.
+ * Also, nextHigherKey(undefined) returns the lowest key.
+ */
+ nextHigherKey(key: K | undefined): K | undefined {
+ var p = this.nextHigherPair(key, ReusedArray as [K, V]);
+ return p && p[0];
}
- /** Returns the next key larger than the specified key (or undefined if there is none) */
- nextHigherKey(key: K): K | undefined {
- var p = this.nextHigherPair(key);
- return p ? p[0] : p;
+ /** Returns the next pair whose key is smaller than the specified key (or undefined if there is none).
+ * If key === undefined, this function returns the highest pair.
+ * @param key The key to search for.
+ * @param reusedArray Optional array used repeatedly to store key-value pairs, to
+ * avoid creating a new array each time you call this method.
+ */
+ nextLowerPair(key: K | undefined, reusedArray?: [K, V]): [K, V] | undefined {
+ reusedArray = reusedArray || ([] as unknown as [K, V]);
+ if (key === undefined) {
+ return this._root.maxPair(reusedArray);
+ }
+ return this._root.getPairOrNextLower(
+ key,
+ this._compare,
+ false,
+ reusedArray,
+ );
}
- /** Returns the next pair whose key is smaller than the specified key (or undefined if there is none) */
- nextLowerPair(key: K): [K, V] | undefined {
- var it = this.entriesReversed(key, ReusedArray, true);
- return it.next().value;
+ /** Returns the next key smaller than the specified key, or undefined if there is none.
+ * Also, nextLowerKey(undefined) returns the highest key.
+ */
+ nextLowerKey(key: K | undefined): K | undefined {
+ var p = this.nextLowerPair(key, ReusedArray as [K, V]);
+ return p && p[0];
+ }
+
+ /** Returns the key-value pair associated with the supplied key if it exists
+ * or the pair associated with the next lower pair otherwise. If there is no
+ * next lower pair, undefined is returned.
+ * @param key The key to search for.
+ * @param reusedArray Optional array used repeatedly to store key-value pairs, to
+ * avoid creating a new array each time you call this method.
+ * */
+ getPairOrNextLower(key: K, reusedArray?: [K, V]): [K, V] | undefined {
+ return this._root.getPairOrNextLower(
+ key,
+ this._compare,
+ true,
+ reusedArray || ([] as unknown as [K, V]),
+ );
}
- /** Returns the next key smaller than the specified key (or undefined if there is none) */
- nextLowerKey(key: K): K | undefined {
- var p = this.nextLowerPair(key);
- return p ? p[0] : p;
+ /** Returns the key-value pair associated with the supplied key if it exists
+ * or the pair associated with the next lower pair otherwise. If there is no
+ * next lower pair, undefined is returned.
+ * @param key The key to search for.
+ * @param reusedArray Optional array used repeatedly to store key-value pairs, to
+ * avoid creating a new array each time you call this method.
+ * */
+ getPairOrNextHigher(key: K, reusedArray?: [K, V]): [K, V] | undefined {
+ return this._root.getPairOrNextHigher(
+ key,
+ this._compare,
+ true,
+ reusedArray || ([] as unknown as [K, V]),
+ );
}
/** Edits the value associated with a key in the tree, if it already exists.
@@ -887,7 +1347,7 @@ export default class BTree<K = any, V = any>
this._root = root =
root.keys.length === 0
? EmptyLeaf
- : ((root as any) as BNodeInternal<K, V>).children[0];
+ : (root as any as BNodeInternal<K, V>).children[0];
}
}
@@ -926,9 +1386,15 @@ export default class BTree<K = any, V = any>
/** Gets the height of the tree: the number of internal nodes between the
* BTree object and its leaf nodes (zero if there are no internal nodes). */
get height(): number {
- for (var node = this._root, h = -1; node != null; h++)
- node = (node as any).children;
- return h;
+ let node: BNode<K, V> | undefined = this._root;
+ let height = -1;
+ while (node) {
+ height++;
+ node = node.isLeaf
+ ? undefined
+ : (node as unknown as BNodeInternal<K, V>).children[0];
+ }
+ return height;
}
/** Makes the object read-only to ensure it is not accidentally modified.
@@ -941,14 +1407,18 @@ export default class BTree<K = any, V = any>
var t = this as any;
// Note: all other mutators ultimately call set() or editRange()
// so we don't need to override those others.
- t.clear = t.set = t.editRange = function () {
- throw new Error("Attempted to modify a frozen BTree");
- };
+ t.clear =
+ t.set =
+ t.editRange =
+ function () {
+ throw new Error("Attempted to modify a frozen BTree");
+ };
}
/** Ensures mutations are allowed, reversing the effect of freeze(). */
unfreeze() {
- // @ts-ignore
+ // @ts-ignore "The operand of a 'delete' operator must be optional."
+ // (wrong: delete does not affect the prototype.)
delete this.clear;
// @ts-ignore
delete this.set;
@@ -967,7 +1437,7 @@ export default class BTree<K = any, V = any>
* skips the most expensive test - whether all keys are sorted - but it
* does check that maxKey() of the children of internal nodes are sorted. */
checkValid() {
- var size = this._root.checkValid(0, this);
+ var size = this._root.checkValid(0, this, 0);
check(
size === this.size,
"size mismatch: counted ",
@@ -987,10 +1457,7 @@ if (Symbol && Symbol.iterator)
(BTree as any).prototype.add = BTree.prototype.set;
function iterator<T>(
- next: () => { done?: boolean; value?: T } = () => ({
- done: true,
- value: undefined,
- }),
+ next: () => IteratorResult<T> = () => ({ done: true, value: undefined }),
): IterableIterator<T> {
var result: any = { next };
if (Symbol && Symbol.iterator)
@@ -1016,6 +1483,7 @@ class BNode<K, V> {
this.isShared = undefined;
}
+ ///////////////////////////////////////////////////////////////////////////
// Shared methods /////////////////////////////////////////////////////////
maxKey() {
@@ -1025,7 +1493,6 @@ class BNode<K, V> {
// If key not found, returns i^failXor where i is the insertion index.
// Callers that don't care whether there was a match will set failXor=0.
indexOf(key: K, failXor: number, cmp: (a: K, b: K) => number): index {
- // TODO: benchmark multiple search strategies
const keys = this.keys;
var lo = 0,
hi = keys.length,
@@ -1094,12 +1561,28 @@ class BNode<K, V> {
return c === 0 ? i : i ^ failXor;*/
}
+ /////////////////////////////////////////////////////////////////////////////
// Leaf Node: misc //////////////////////////////////////////////////////////
- minKey() {
+ minKey(): K | undefined {
return this.keys[0];
}
+ minPair(reusedArray: [K, V]): [K, V] | undefined {
+ if (this.keys.length === 0) return undefined;
+ reusedArray[0] = this.keys[0];
+ reusedArray[1] = this.values[0];
+ return reusedArray;
+ }
+
+ maxPair(reusedArray: [K, V]): [K, V] | undefined {
+ if (this.keys.length === 0) return undefined;
+ const lastIndex = this.keys.length - 1;
+ reusedArray[0] = this.keys[lastIndex];
+ reusedArray[1] = this.values[lastIndex];
+ return reusedArray;
+ }
+
clone(): BNode<K, V> {
var v = this.values;
return new BNode<K, V>(
@@ -1117,7 +1600,40 @@ class BNode<K, V> {
return i < 0 ? defaultValue : this.values[i];
}
- checkValid(depth: number, tree: BTree<K, V>): number {
+ getPairOrNextLower(
+ key: K,
+ compare: (a: K, b: K) => number,
+ inclusive: boolean,
+ reusedArray: [K, V],
+ ): [K, V] | undefined {
+ var i = this.indexOf(key, -1, compare);
+ const indexOrLower = i < 0 ? ~i - 1 : inclusive ? i : i - 1;
+ if (indexOrLower >= 0) {
+ reusedArray[0] = this.keys[indexOrLower];
+ reusedArray[1] = this.values[indexOrLower];
+ return reusedArray;
+ }
+ return undefined;
+ }
+
+ getPairOrNextHigher(
+ key: K,
+ compare: (a: K, b: K) => number,
+ inclusive: boolean,
+ reusedArray: [K, V],
+ ): [K, V] | undefined {
+ var i = this.indexOf(key, -1, compare);
+ const indexOrLower = i < 0 ? ~i : inclusive ? i : i + 1;
+ const keys = this.keys;
+ if (indexOrLower < keys.length) {
+ reusedArray[0] = keys[indexOrLower];
+ reusedArray[1] = this.values[indexOrLower];
+ return reusedArray;
+ }
+ return undefined;
+ }
+
+ checkValid(depth: number, tree: BTree<K, V>, baseIndex: number): number {
var kL = this.keys.length,
vL = this.values.length;
check(
@@ -1127,16 +1643,25 @@ class BNode<K, V> {
"with lengths",
kL,
vL,
+ "and baseIndex",
+ baseIndex,
);
// Note: we don't check for "node too small" because sometimes a node
// can legitimately have size 1. This occurs if there is a batch
// deletion, leaving a node of size 1, and the siblings are full so
// it can't be merged with adjacent nodes. However, the parent will
// verify that the average node size is at least half of the maximum.
- check(depth == 0 || kL > 0, "empty leaf at depth", depth);
+ check(
+ depth == 0 || kL > 0,
+ "empty leaf at depth",
+ depth,
+ "and baseIndex",
+ baseIndex,
+ );
return kL;
}
+ /////////////////////////////////////////////////////////////////////////////
// Leaf Node: set & node splitting //////////////////////////////////////////
set(
@@ -1233,6 +1758,7 @@ class BNode<K, V> {
return new BNode<K, V>(keys, values);
}
+ /////////////////////////////////////////////////////////////////////////////
// Leaf Node: scanning & deletions //////////////////////////////////////////
forRange<R>(
@@ -1331,6 +1857,14 @@ class BNodeInternal<K, V> extends BNode<K, V> {
return this.children[0].minKey();
}
+ minPair(reusedArray: [K, V]): [K, V] | undefined {
+ return this.children[0].minPair(reusedArray);
+ }
+
+ maxPair(reusedArray: [K, V]): [K, V] | undefined {
+ return this.children[this.children.length - 1].maxPair(reusedArray);
+ }
+
get(key: K, defaultValue: V | undefined, tree: BTree<K, V>): V | undefined {
var i = this.indexOf(key, 0, tree._compare),
children = this.children;
@@ -1339,8 +1873,51 @@ class BNodeInternal<K, V> extends BNode<K, V> {
: undefined;
}
- checkValid(depth: number, tree: BTree<K, V>): number {
- var kL = this.keys.length,
+ getPairOrNextLower(
+ key: K,
+ compare: (a: K, b: K) => number,
+ inclusive: boolean,
+ reusedArray: [K, V],
+ ): [K, V] | undefined {
+ var i = this.indexOf(key, 0, compare),
+ children = this.children;
+ if (i >= children.length) return this.maxPair(reusedArray);
+ const result = children[i].getPairOrNextLower(
+ key,
+ compare,
+ inclusive,
+ reusedArray,
+ );
+ if (result === undefined && i > 0) {
+ return children[i - 1].maxPair(reusedArray);
+ }
+ return result;
+ }
+
+ getPairOrNextHigher(
+ key: K,
+ compare: (a: K, b: K) => number,
+ inclusive: boolean,
+ reusedArray: [K, V],
+ ): [K, V] | undefined {
+ var i = this.indexOf(key, 0, compare),
+ children = this.children,
+ length = children.length;
+ if (i >= length) return undefined;
+ const result = children[i].getPairOrNextHigher(
+ key,
+ compare,
+ inclusive,
+ reusedArray,
+ );
+ if (result === undefined && i < length - 1) {
+ return children[i + 1].minPair(reusedArray);
+ }
+ return result;
+ }
+
+ checkValid(depth: number, tree: BTree<K, V>, baseIndex: number): number {
+ let kL = this.keys.length,
cL = this.children.length;
check(
kL === cL,
@@ -1349,19 +1926,30 @@ class BNodeInternal<K, V> extends BNode<K, V> {
"lengths",
kL,
cL,
+ "baseIndex",
+ baseIndex,
);
- check(kL > 1, "internal node has length", kL, "at depth", depth);
- var size = 0,
+ check(
+ kL > 1 || depth > 0,
+ "internal node has length",
+ kL,
+ "at depth",
+ depth,
+ "baseIndex",
+ baseIndex,
+ );
+ let size = 0,
c = this.children,
k = this.keys,
childSize = 0;
for (var i = 0; i < cL; i++) {
- size += c[i].checkValid(depth + 1, tree);
+ size += c[i].checkValid(depth + 1, tree, baseIndex + size);
childSize += c[i].keys.length;
- check(size >= childSize, "wtf"); // no way this will ever fail
+ check(size >= childSize, "wtf", baseIndex); // no way this will ever fail
check(
i === 0 || c[i - 1].constructor === c[i].constructor,
- "type mismatch",
+ "type mismatch, baseIndex:",
+ baseIndex,
);
if (c[i].maxKey() != k[i])
check(
@@ -1374,6 +1962,8 @@ class BNodeInternal<K, V> extends BNode<K, V> {
c[i].maxKey(),
"at depth",
depth,
+ "baseIndex",
+ baseIndex,
);
if (!(i === 0 || tree._compare(k[i - 1], k[i]) < 0))
check(
@@ -1387,7 +1977,9 @@ class BNodeInternal<K, V> extends BNode<K, V> {
k[i],
);
}
- var toofew = childSize < (tree.maxNodeSize >> 1) * cL;
+ // 2020/08: BTree doesn't always avoid grossly undersized nodes,
+ // but AFAIK such nodes are pretty harmless, so accept them.
+ let toofew = childSize === 0; // childSize < (tree.maxNodeSize >> 1)*cL;
if (toofew || childSize > tree.maxNodeSize * cL)
check(
false,
@@ -1397,14 +1989,17 @@ class BNodeInternal<K, V> extends BNode<K, V> {
size,
") at depth",
depth,
- ", maxNodeSize:",
+ "maxNodeSize:",
tree.maxNodeSize,
"children.length:",
cL,
+ "baseIndex:",
+ baseIndex,
);
return size;
}
+ /////////////////////////////////////////////////////////////////////////////
// Internal Node: set & node splitting //////////////////////////////////////
set(
@@ -1497,8 +2092,12 @@ class BNodeInternal<K, V> extends BNode<K, V> {
this.children.unshift((lhs as BNodeInternal<K, V>).children.pop()!);
}
+ /////////////////////////////////////////////////////////////////////////////
// Internal Node: scanning & deletions //////////////////////////////////////
+ // Note: `count` is the next value of the third argument to `onFound`.
+ // A leaf node's `forRange` function returns a new value for this counter,
+ // unless the operation is to stop early.
forRange<R>(
low: K,
high: K,
@@ -1509,14 +2108,14 @@ class BNodeInternal<K, V> extends BNode<K, V> {
onFound?: (k: K, v: V, counter: number) => EditRangeResult<V, R> | void,
): EditRangeResult<V, R> | number {
var cmp = tree._compare;
+ var keys = this.keys,
+ children = this.children;
var iLow = this.indexOf(low, 0, cmp),
i = iLow;
var iHigh = Math.min(
high === low ? iLow : this.indexOf(high, 0, cmp),
- this.keys.length - 1,
+ keys.length - 1,
);
- var keys = this.keys,
- children = this.children;
if (!editMode) {
// Simple case
for (; i <= iHigh; i++) {
@@ -1545,6 +2144,8 @@ class BNodeInternal<K, V> extends BNode<K, V> {
count,
onFound,
);
+ // Note: if children[i] is empty then keys[i]=undefined.
+ // This is an invalid state, but it is fixed below.
keys[i] = children[i].maxKey();
if (typeof result !== "number") return result;
count = result;
@@ -1554,15 +2155,18 @@ class BNodeInternal<K, V> extends BNode<K, V> {
var half = tree._maxNodeSize >> 1;
if (iLow > 0) iLow--;
for (i = iHigh; i >= iLow; i--) {
- if (children[i].keys.length <= half)
- this.tryMerge(i, tree._maxNodeSize);
- }
- // Are we completely empty?
- if (children[0].keys.length === 0) {
- check(children.length === 1 && keys.length === 1, "emptiness bug");
- children.shift();
- keys.shift();
+ if (children[i].keys.length <= half) {
+ if (children[i].keys.length !== 0) {
+ this.tryMerge(i, tree._maxNodeSize);
+ } else {
+ // child is empty! delete it!
+ keys.splice(i, 1);
+ children.splice(i, 1);
+ }
+ }
}
+ if (children.length !== 0 && children[0].keys.length === 0)
+ check(false, "emptiness bug");
}
}
return count;
@@ -1592,7 +2196,7 @@ class BNodeInternal<K, V> extends BNode<K, V> {
this.keys.push.apply(this.keys, rhs.keys);
this.children.push.apply(
this.children,
- ((rhs as any) as BNodeInternal<K, V>).children,
+ (rhs as any as BNodeInternal<K, V>).children,
);
// If our children are themselves almost empty due to a mass-delete,
// they may need to be merged too (but only the oldLength-1 and its
@@ -1601,6 +2205,27 @@ class BNodeInternal<K, V> extends BNode<K, V> {
}
}
+/**
+ * A walkable pointer into a BTree for computing efficient diffs between trees with shared data.
+ * - A cursor points to either a key/value pair (KVP) or a node (which can be either a leaf or an internal node).
+ * As a consequence, a cursor cannot be created for an empty tree.
+ * - A cursor can be walked forwards using `step`. A cursor can be compared to another cursor to
+ * determine which is ahead in advancement.
+ * - A cursor is valid only for the tree it was created from, and only until the first edit made to
+ * that tree since the cursor's creation.
+ * - A cursor contains a key for the current location, which is the maxKey when the cursor points to a node
+ * and a key corresponding to a value when pointing to a leaf.
+ * - Leaf is only populated if the cursor points to a KVP. If this is the case, levelIndices.length === internalSpine.length + 1
+ * and levelIndices[levelIndices.length - 1] is the index of the value.
+ */
+type DiffCursor<K, V> = {
+ height: number;
+ internalSpine: BNode<K, V>[][];
+ levelIndices: number[];
+ leaf: BNode<K, V> | undefined;
+ currentKey: K;
+};
+
// Optimization: this array of `undefined`s is used instead of a normal
// array of values in nodes where `undefined` is the only value.
// Its length is extended to max node size on first use; since it can
@@ -1608,6 +2233,10 @@ class BNodeInternal<K, V> extends BNode<K, V> {
// increase, never decrease. Its type should be undefined[] but strangely
// TypeScript won't allow the comparison V[] === undefined[]. To prevent
// users from making this array too large, BTree has a maximum node size.
+//
+// FAQ: undefVals[i] is already undefined, so why increase the array size?
+// Reading outside the bounds of an array is relatively slow because it
+// has the side effect of scanning the prototype chain.
var undefVals: any[] = [];
const Delete = { delete: true },
@@ -1623,7 +2252,7 @@ const ReusedArray: any[] = []; // assumed thread-local
function check(fact: boolean, ...args: any[]) {
if (!fact) {
- args.unshift("B+ tree "); // at beginning of message
+ args.unshift("B+ tree"); // at beginning of message
throw new Error(args.join(" "));
}
}
diff --git a/packages/idb-bridge/src/tree/interfaces.ts b/packages/idb-bridge/src/tree/interfaces.ts
index ce8808d09..01013c038 100644
--- a/packages/idb-bridge/src/tree/interfaces.ts
+++ b/packages/idb-bridge/src/tree/interfaces.ts
@@ -1,28 +1,4 @@
-/*
-Copyright (c) 2018 David Piepgrass
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-SPDX-License-Identifier: MIT
-*/
-
-// Original repository: https://github.com/qwertie/btree-typescript
+// B+ tree by David Piepgrass. License: MIT
/** Read-only set interface (subinterface of IMapSource<K,any>).
* The word "set" usually means that each item in the collection is unique
@@ -350,6 +326,8 @@ export interface IMapF<K = any, V = any> extends IMapSource<K, V>, ISetF<K> {
export interface ISortedSetF<K = any> extends ISetF<K>, ISortedSetSource<K> {
// TypeScript requires this method of ISortedSetSource to be repeated
keys(firstKey?: K): IterableIterator<K>;
+ without(key: K): ISortedSetF<K>;
+ with(key: K): ISortedSetF<K>;
}
export interface ISortedMapF<K = any, V = any>
diff --git a/packages/idb-bridge/src/util/FakeDomEvent.ts b/packages/idb-bridge/src/util/FakeDomEvent.ts
new file mode 100644
index 000000000..b3ff298ec
--- /dev/null
+++ b/packages/idb-bridge/src/util/FakeDomEvent.ts
@@ -0,0 +1,103 @@
+/*
+ Copyright 2017 Jeremy Scheff
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ or implied. See the License for the specific language governing
+ permissions and limitations under the License.
+*/
+
+import FakeEventTarget from "./FakeEventTarget.js";
+import { Event, EventTarget } from "../idbtypes.js";
+
+/** @public */
+export type EventType =
+ | "abort"
+ | "blocked"
+ | "complete"
+ | "error"
+ | "success"
+ | "upgradeneeded"
+ | "versionchange";
+
+export class FakeDomEvent implements Event {
+ public eventPath: FakeEventTarget[] = [];
+ public type: EventType;
+
+ public readonly NONE = 0;
+ public readonly CAPTURING_PHASE = 1;
+ public readonly AT_TARGET = 2;
+ public readonly BUBBLING_PHASE = 3;
+
+ // Flags
+ public propagationStopped = false;
+ public immediatePropagationStopped = false;
+ public canceled = false;
+ public initialized = true;
+ public dispatched = false;
+
+ public target: FakeEventTarget | null = null;
+ public currentTarget: FakeEventTarget | null = null;
+
+ public eventPhase: 0 | 1 | 2 | 3 = 0;
+
+ public defaultPrevented = false;
+
+ public isTrusted = false;
+ public timeStamp = Date.now();
+
+ public bubbles: boolean;
+ public cancelable: boolean;
+
+ constructor(
+ type: EventType,
+ eventInitDict: { bubbles?: boolean; cancelable?: boolean } = {},
+ ) {
+ this.type = type;
+
+ this.bubbles =
+ eventInitDict.bubbles !== undefined ? eventInitDict.bubbles : false;
+ this.cancelable =
+ eventInitDict.cancelable !== undefined ? eventInitDict.cancelable : false;
+ }
+ cancelBubble: boolean = false;
+ composed: boolean = false;
+ returnValue: boolean = false;
+ get srcElement(): EventTarget | null {
+ return this.target;
+ }
+ composedPath(): EventTarget[] {
+ throw new Error("Method not implemented.");
+ }
+ initEvent(
+ type: string,
+ bubbles?: boolean | undefined,
+ cancelable?: boolean | undefined,
+ ): void {
+ throw new Error("Method not implemented.");
+ }
+
+ public preventDefault() {
+ if (this.cancelable) {
+ this.canceled = true;
+ }
+ }
+
+ public stopPropagation() {
+ this.propagationStopped = true;
+ }
+
+ public stopImmediatePropagation() {
+ this.propagationStopped = true;
+ this.immediatePropagationStopped = true;
+ }
+}
+
+export default FakeDomEvent;
diff --git a/packages/idb-bridge/src/util/FakeEvent.ts b/packages/idb-bridge/src/util/FakeEvent.ts
index c16a58fd3..e3ba864ed 100644
--- a/packages/idb-bridge/src/util/FakeEvent.ts
+++ b/packages/idb-bridge/src/util/FakeEvent.ts
@@ -14,8 +14,8 @@
permissions and limitations under the License.
*/
-import FakeEventTarget from "./FakeEventTarget";
-import { Event, EventTarget } from "../idbtypes";
+import FakeEventTarget from "./FakeEventTarget.js";
+import { Event, EventTarget } from "../idbtypes.js";
/** @public */
export type EventType =
diff --git a/packages/idb-bridge/src/util/FakeEventTarget.ts b/packages/idb-bridge/src/util/FakeEventTarget.ts
index 95489b4ac..839906a34 100644
--- a/packages/idb-bridge/src/util/FakeEventTarget.ts
+++ b/packages/idb-bridge/src/util/FakeEventTarget.ts
@@ -14,14 +14,14 @@
permissions and limitations under the License.
*/
-import { InvalidStateError } from "./errors";
-import FakeEvent, { EventType } from "./FakeEvent";
+import { InvalidStateError } from "./errors.js";
+import FakeEvent, { EventType } from "./FakeEvent.js";
import {
EventTarget,
Event,
EventListenerOrEventListenerObject,
EventListener,
-} from "../idbtypes";
+} from "../idbtypes.js";
type EventTypeProp =
| "onabort"
@@ -180,7 +180,7 @@ abstract class FakeEventTarget implements EventTarget {
fe.eventPath.reverse();
fe.eventPhase = event.BUBBLING_PHASE;
if (fe.eventPath.length === 0 && event.type === "error") {
- console.error("Unhandled error event: ", event.target);
+ console.error("Unhandled error event on target: ", event.target);
}
for (const obj of event.eventPath) {
if (!event.propagationStopped) {
diff --git a/packages/idb-bridge/src/util/canInjectKey.test.ts b/packages/idb-bridge/src/util/canInjectKey.test.ts
index b57dd1c9a..c73552ea6 100644
--- a/packages/idb-bridge/src/util/canInjectKey.test.ts
+++ b/packages/idb-bridge/src/util/canInjectKey.test.ts
@@ -16,7 +16,7 @@
*/
import test from "ava";
-import { canInjectKey } from "./canInjectKey";
+import { canInjectKey } from "./canInjectKey.js";
test("canInjectKey", (t) => {
t.false(canInjectKey("foo", null));
diff --git a/packages/idb-bridge/src/util/canInjectKey.ts b/packages/idb-bridge/src/util/canInjectKey.ts
index 903a9d3de..e2927b70f 100644
--- a/packages/idb-bridge/src/util/canInjectKey.ts
+++ b/packages/idb-bridge/src/util/canInjectKey.ts
@@ -15,7 +15,7 @@
permissions and limitations under the License.
*/
-import { IDBKeyPath } from "../idbtypes";
+import { IDBKeyPath } from "../idbtypes.js";
/**
* Check that a key could be injected into a value.
diff --git a/packages/idb-bridge/src/util/cmp.ts b/packages/idb-bridge/src/util/cmp.ts
index e7f26bf1a..19e1d01b4 100644
--- a/packages/idb-bridge/src/util/cmp.ts
+++ b/packages/idb-bridge/src/util/cmp.ts
@@ -14,8 +14,8 @@
permissions and limitations under the License.
*/
-import { DataError } from "./errors";
-import { valueToKey } from "./valueToKey";
+import { DataError } from "./errors.js";
+import { valueToKey } from "./valueToKey.js";
const getType = (x: any) => {
if (typeof x === "number") {
diff --git a/packages/idb-bridge/src/util/extractKey.ts b/packages/idb-bridge/src/util/extractKey.ts
index 09306ddec..2a4ec45b9 100644
--- a/packages/idb-bridge/src/util/extractKey.ts
+++ b/packages/idb-bridge/src/util/extractKey.ts
@@ -15,11 +15,15 @@
permissions and limitations under the License.
*/
-import { IDBKeyPath, IDBValidKey } from "../idbtypes";
-import { valueToKey } from "./valueToKey";
+import { IDBKeyPath, IDBValidKey } from "../idbtypes.js";
+import { valueToKey } from "./valueToKey.js";
// http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#dfn-steps-for-extracting-a-key-from-a-value-using-a-key-path
+/**
+ * Algorithm to "extract a key from a value using a key path".
+ */
export const extractKey = (keyPath: IDBKeyPath | IDBKeyPath[], value: any) => {
+ //console.log(`extracting key ${JSON.stringify(keyPath)} from ${JSON.stringify(value)}`);
if (Array.isArray(keyPath)) {
const result: IDBValidKey[] = [];
@@ -59,7 +63,7 @@ export const extractKey = (keyPath: IDBKeyPath | IDBKeyPath[], value: any) => {
remainingKeyPath = null;
}
- if (!object.hasOwnProperty(identifier)) {
+ if (object == null || !object.hasOwnProperty(identifier)) {
return;
}
diff --git a/packages/idb-bridge/src/util/fakeDOMStringList.ts b/packages/idb-bridge/src/util/fakeDOMStringList.ts
index 0549e1283..24f5c96f4 100644
--- a/packages/idb-bridge/src/util/fakeDOMStringList.ts
+++ b/packages/idb-bridge/src/util/fakeDOMStringList.ts
@@ -14,7 +14,6 @@
* permissions and limitations under the License.
*/
-import { DOMStringList } from "../idbtypes";
/** @public */
export interface FakeDOMStringList extends Array<string> {
@@ -22,7 +21,7 @@ export interface FakeDOMStringList extends Array<string> {
item: (i: number) => string | null;
}
-// Would be nicer to sublcass Array, but I'd have to sacrifice Node 4 support to do that.
+// Would be nicer to subclass Array, but I'd have to sacrifice Node 4 support to do that.
export const fakeDOMStringList = (arr: string[]): FakeDOMStringList => {
const arr2 = arr.slice();
diff --git a/packages/idb-bridge/src/util/getIndexKeys.test.ts b/packages/idb-bridge/src/util/getIndexKeys.test.ts
index 782b3da2f..1d477de1a 100644
--- a/packages/idb-bridge/src/util/getIndexKeys.test.ts
+++ b/packages/idb-bridge/src/util/getIndexKeys.test.ts
@@ -16,7 +16,7 @@
*/
import test from "ava";
-import { getIndexKeys } from "./getIndexKeys";
+import { getIndexKeys } from "./getIndexKeys.js";
test("basics", (t) => {
t.deepEqual(getIndexKeys({ foo: 42 }, "foo", false), [42]);
@@ -26,15 +26,13 @@ test("basics", (t) => {
t.deepEqual(getIndexKeys([1, 2, 3], "", false), [[1, 2, 3]]);
- t.throws(() => {
- getIndexKeys({ foo: 42 }, "foo.bar", false);
- });
+ t.deepEqual(getIndexKeys({ foo: 42 }, "foo.bar", false), []);
t.deepEqual(getIndexKeys({ foo: 42 }, "foo", true), [42]);
- t.deepEqual(getIndexKeys({ foo: 42, bar: 10 }, ["foo", "bar"], true), [
- 42,
- 10,
- ]);
+ t.deepEqual(
+ getIndexKeys({ foo: 42, bar: 10 }, ["foo", "bar"], true),
+ [42, 10],
+ );
t.deepEqual(getIndexKeys({ foo: 42, bar: 10 }, ["foo", "bar"], false), [
[42, 10],
]);
diff --git a/packages/idb-bridge/src/util/getIndexKeys.ts b/packages/idb-bridge/src/util/getIndexKeys.ts
index 8515d79ea..c2421f26e 100644
--- a/packages/idb-bridge/src/util/getIndexKeys.ts
+++ b/packages/idb-bridge/src/util/getIndexKeys.ts
@@ -15,9 +15,9 @@
permissions and limitations under the License.
*/
-import { IDBKeyPath, IDBValidKey } from "../idbtypes";
-import { extractKey } from "./extractKey";
-import { valueToKey } from "./valueToKey";
+import { IDBKeyPath, IDBValidKey } from "../idbtypes.js";
+import { extractKey } from "./extractKey.js";
+import { valueToKey } from "./valueToKey.js";
export function getIndexKeys(
value: any,
@@ -38,6 +38,9 @@ export function getIndexKeys(
return keys;
} else if (typeof keyPath === "string" || Array.isArray(keyPath)) {
let key = extractKey(keyPath, value);
+ if (key == null) {
+ return [];
+ }
return [valueToKey(key)];
} else {
throw Error(`unsupported key path: ${typeof keyPath}`);
diff --git a/packages/idb-bridge/src/util/key-storage.test.ts b/packages/idb-bridge/src/util/key-storage.test.ts
new file mode 100644
index 000000000..dc1e1827c
--- /dev/null
+++ b/packages/idb-bridge/src/util/key-storage.test.ts
@@ -0,0 +1,39 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import test, { ExecutionContext } from "ava";
+import { deserializeKey, serializeKey } from "./key-storage.js";
+import { IDBValidKey } from "../idbtypes.js";
+
+function checkKeySer(t: ExecutionContext, k: IDBValidKey): void {
+ const keyEnc = serializeKey(k);
+ const keyDec = deserializeKey(keyEnc);
+ t.deepEqual(k, keyDec);
+}
+
+test("basics", (t) => {
+ checkKeySer(t, "foo");
+ checkKeySer(t, "foo\0bar");
+ checkKeySer(t, "foo\u1000bar");
+ checkKeySer(t, "foo\u2000bar");
+ checkKeySer(t, "foo\u5000bar");
+ checkKeySer(t, "foo\uffffbar");
+ checkKeySer(t, 42);
+ checkKeySer(t, 255);
+ checkKeySer(t, 254);
+ checkKeySer(t, [1, 2, 3, 4]);
+ checkKeySer(t, [[[1], 3], [4]]);
+});
diff --git a/packages/idb-bridge/src/util/key-storage.ts b/packages/idb-bridge/src/util/key-storage.ts
new file mode 100644
index 000000000..b71548dd3
--- /dev/null
+++ b/packages/idb-bridge/src/util/key-storage.ts
@@ -0,0 +1,363 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/*
+Encoding rules (inspired by Firefox, but slightly simplified):
+
+Numbers: 0x10 n n n n n n n n
+Dates: 0x20 n n n n n n n n
+Strings: 0x30 s s s s ... 0
+Binaries: 0x40 s s s s ... 0
+Arrays: 0x50 i i i ... 0
+
+Numbers/dates are encoded as 64-bit IEEE 754 floats with the sign bit
+flipped, in order to make them sortable.
+*/
+
+/**
+ * Imports.
+ */
+import { IDBValidKey } from "../idbtypes.js";
+
+const tagNum = 0xa0;
+const tagDate = 0xb0;
+const tagString = 0xc0;
+const tagBinary = 0xc0;
+const tagArray = 0xe0;
+
+const oneByteOffset = 0x01;
+const twoByteOffset = 0x7f;
+const oneByteMax = 0x7e;
+const twoByteMax = 0x3fff + twoByteOffset;
+const twoByteMask = 0b1000_0000;
+const threeByteMask = 0b1100_0000;
+
+export function countEncSize(c: number): number {
+ if (c > twoByteMax) {
+ return 3;
+ }
+ if (c > oneByteMax) {
+ return 2;
+ }
+ return 1;
+}
+
+export function writeEnc(dv: DataView, offset: number, c: number): number {
+ if (c > twoByteMax) {
+ dv.setUint8(offset + 2, (c & 0xff) << 6);
+ dv.setUint8(offset + 1, (c >>> 2) & 0xff);
+ dv.setUint8(offset, threeByteMask | (c >>> 10));
+ return 3;
+ } else if (c > oneByteMax) {
+ c -= twoByteOffset;
+ dv.setUint8(offset + 1, c & 0xff);
+ dv.setUint8(offset, (c >>> 8) | twoByteMask);
+ return 2;
+ } else {
+ c += oneByteOffset;
+ dv.setUint8(offset, c);
+ return 1;
+ }
+}
+
+export function internalSerializeString(
+ dv: DataView,
+ offset: number,
+ key: string,
+): number {
+ dv.setUint8(offset, tagString);
+ let n = 1;
+ for (let i = 0; i < key.length; i++) {
+ let c = key.charCodeAt(i);
+ n += writeEnc(dv, offset + n, c);
+ }
+ // Null terminator
+ dv.setUint8(offset + n, 0);
+ n++;
+ return n;
+}
+
+export function countSerializeKey(key: IDBValidKey): number {
+ if (typeof key === "number") {
+ return 9;
+ }
+ if (key instanceof Date) {
+ return 9;
+ }
+ if (key instanceof ArrayBuffer) {
+ let len = 2;
+ const uv = new Uint8Array(key);
+ for (let i = 0; i < uv.length; i++) {
+ len += countEncSize(uv[i]);
+ }
+ return len;
+ }
+ if (ArrayBuffer.isView(key)) {
+ let len = 2;
+ const uv = new Uint8Array(key.buffer, key.byteOffset, key.byteLength);
+ for (let i = 0; i < uv.length; i++) {
+ len += countEncSize(uv[i]);
+ }
+ return len;
+ }
+ if (typeof key === "string") {
+ let len = 2;
+ for (let i = 0; i < key.length; i++) {
+ len += countEncSize(key.charCodeAt(i));
+ }
+ return len;
+ }
+ if (Array.isArray(key)) {
+ let len = 2;
+ for (let i = 0; i < key.length; i++) {
+ len += countSerializeKey(key[i]);
+ }
+ return len;
+ }
+ throw Error("unsupported type for key");
+}
+
+function internalSerializeNumeric(
+ dv: DataView,
+ offset: number,
+ tag: number,
+ val: number,
+): number {
+ dv.setUint8(offset, tagNum);
+ dv.setFloat64(offset + 1, val);
+ // Flip sign bit
+ let b = dv.getUint8(offset + 1);
+ b ^= 0x80;
+ dv.setUint8(offset + 1, b);
+ return 9;
+}
+
+function internalSerializeArray(
+ dv: DataView,
+ offset: number,
+ key: any[],
+): number {
+ dv.setUint8(offset, tagArray);
+ let n = 1;
+ for (let i = 0; i < key.length; i++) {
+ n += internalSerializeKey(key[i], dv, offset + n);
+ }
+ dv.setUint8(offset + n, 0);
+ n++;
+ return n;
+}
+
+function internalSerializeBinary(
+ dv: DataView,
+ offset: number,
+ key: Uint8Array,
+): number {
+ dv.setUint8(offset, tagBinary);
+ let n = 1;
+ for (let i = 0; i < key.length; i++) {
+ n += internalSerializeKey(key[i], dv, offset + n);
+ }
+ dv.setUint8(offset + n, 0);
+ n++;
+ return n;
+}
+
+function internalSerializeKey(
+ key: IDBValidKey,
+ dv: DataView,
+ offset: number,
+): number {
+ if (typeof key === "number") {
+ return internalSerializeNumeric(dv, offset, tagNum, key);
+ }
+ if (key instanceof Date) {
+ return internalSerializeNumeric(dv, offset, tagDate, key.getDate());
+ }
+ if (typeof key === "string") {
+ return internalSerializeString(dv, offset, key);
+ }
+ if (Array.isArray(key)) {
+ return internalSerializeArray(dv, offset, key);
+ }
+ if (key instanceof ArrayBuffer) {
+ return internalSerializeBinary(dv, offset, new Uint8Array(key));
+ }
+ if (ArrayBuffer.isView(key)) {
+ const uv = new Uint8Array(key.buffer, key.byteOffset, key.byteLength);
+ return internalSerializeBinary(dv, offset, uv);
+ }
+ throw Error("unsupported type for key");
+}
+
+export function serializeKey(key: IDBValidKey): Uint8Array {
+ const len = countSerializeKey(key);
+ let buf = new Uint8Array(len);
+ const outLen = internalSerializeKey(key, new DataView(buf.buffer), 0);
+ if (len != outLen) {
+ throw Error("internal invariant failed");
+ }
+ let numTrailingZeroes = 0;
+ for (let i = buf.length - 1; i >= 0 && buf[i] == 0; i--, numTrailingZeroes++);
+ if (numTrailingZeroes > 0) {
+ buf = buf.slice(0, buf.length - numTrailingZeroes);
+ }
+ return buf;
+}
+
+function internalReadString(dv: DataView, offset: number): [number, string] {
+ const chars: string[] = [];
+ while (offset < dv.byteLength) {
+ const v = dv.getUint8(offset);
+ if (v == 0) {
+ // Got end-of-string.
+ offset += 1;
+ break;
+ }
+ let c: number;
+ if ((v & threeByteMask) === threeByteMask) {
+ const b1 = v;
+ const b2 = dv.getUint8(offset + 1);
+ const b3 = dv.getUint8(offset + 2);
+ c = (b1 << 10) | (b2 << 2) | (b3 >> 6);
+ offset += 3;
+ } else if ((v & twoByteMask) === twoByteMask) {
+ const b1 = v & ~twoByteMask;
+ const b2 = dv.getUint8(offset + 1);
+ c = ((b1 << 8) | b2) + twoByteOffset;
+ offset += 2;
+ } else {
+ c = v - oneByteOffset;
+ offset += 1;
+ }
+ chars.push(String.fromCharCode(c));
+ }
+ return [offset, chars.join("")];
+}
+
+function internalReadBytes(dv: DataView, offset: number): [number, Uint8Array] {
+ let count = 0;
+ while (offset + count < dv.byteLength) {
+ const v = dv.getUint8(offset + count);
+ if (v === 0) {
+ break;
+ }
+ count++;
+ }
+ let writePos = 0;
+ const bytes = new Uint8Array(count);
+ while (offset < dv.byteLength) {
+ const v = dv.getUint8(offset);
+ if (v == 0) {
+ offset += 1;
+ break;
+ }
+ let c: number;
+ if ((v & threeByteMask) === threeByteMask) {
+ const b1 = v;
+ const b2 = dv.getUint8(offset + 1);
+ const b3 = dv.getUint8(offset + 2);
+ c = (b1 << 10) | (b2 << 2) | (b3 >> 6);
+ offset += 3;
+ } else if ((v & twoByteMask) === twoByteMask) {
+ const b1 = v & ~twoByteMask;
+ const b2 = dv.getUint8(offset + 1);
+ c = ((b1 << 8) | b2) + twoByteOffset;
+ offset += 2;
+ } else {
+ c = v - oneByteOffset;
+ offset += 1;
+ }
+ bytes[writePos] = c;
+ writePos++;
+ }
+ return [offset, bytes];
+}
+
+/**
+ * Same as DataView.getFloat64, but logically pad input
+ * with zeroes on the right if read offset would be out
+ * of bounds.
+ *
+ * This allows reading from buffers where zeros have been
+ * truncated.
+ */
+function getFloat64Trunc(dv: DataView, offset: number): number {
+ if (offset + 7 >= dv.byteLength) {
+ const buf = new Uint8Array(8);
+ for (let i = offset; i < dv.byteLength; i++) {
+ buf[i - offset] = dv.getUint8(i);
+ }
+ const dv2 = new DataView(buf.buffer);
+ return dv2.getFloat64(0);
+ } else {
+ return dv.getFloat64(offset);
+ }
+}
+
+function internalDeserializeKey(
+ dv: DataView,
+ offset: number,
+): [number, IDBValidKey] {
+ let tag = dv.getUint8(offset);
+ switch (tag) {
+ case tagNum: {
+ const num = -getFloat64Trunc(dv, offset + 1);
+ const newOffset = Math.min(offset + 9, dv.byteLength);
+ return [newOffset, num];
+ }
+ case tagDate: {
+ const num = -getFloat64Trunc(dv, offset + 1);
+ const newOffset = Math.min(offset + 9, dv.byteLength);
+ return [newOffset, new Date(num)];
+ }
+ case tagString: {
+ return internalReadString(dv, offset + 1);
+ }
+ case tagBinary: {
+ return internalReadBytes(dv, offset + 1);
+ }
+ case tagArray: {
+ const arr: any[] = [];
+ offset += 1;
+ while (offset < dv.byteLength) {
+ const innerTag = dv.getUint8(offset);
+ if (innerTag === 0) {
+ offset++;
+ break;
+ }
+ const [innerOff, innerVal] = internalDeserializeKey(dv, offset);
+ arr.push(innerVal);
+ offset = innerOff;
+ }
+ return [offset, arr];
+ }
+ default:
+ throw Error("invalid key (unrecognized tag)");
+ }
+}
+
+export function deserializeKey(encodedKey: Uint8Array): IDBValidKey {
+ const dv = new DataView(
+ encodedKey.buffer,
+ encodedKey.byteOffset,
+ encodedKey.byteLength,
+ );
+ let [off, res] = internalDeserializeKey(dv, 0);
+ if (off != encodedKey.byteLength) {
+ throw Error("internal invariant failed");
+ }
+ return res;
+}
diff --git a/packages/idb-bridge/src/util/makeStoreKeyValue.test.ts b/packages/idb-bridge/src/util/makeStoreKeyValue.test.ts
index df9748316..c1216fe97 100644
--- a/packages/idb-bridge/src/util/makeStoreKeyValue.test.ts
+++ b/packages/idb-bridge/src/util/makeStoreKeyValue.test.ts
@@ -15,60 +15,78 @@
*/
import test from "ava";
-import { makeStoreKeyValue } from "./makeStoreKeyValue";
+import { makeStoreKeyValue } from "./makeStoreKeyValue.js";
test("basics", (t) => {
let result;
- result = makeStoreKeyValue({ name: "Florian" }, undefined, 42, true, "id");
+ result = makeStoreKeyValue({
+ value: { name: "Florian" },
+ key: undefined,
+ currentKeyGenerator: 42,
+ autoIncrement: true,
+ keyPath: "id",
+ });
t.is(result.updatedKeyGenerator, 43);
t.is(result.key, 42);
t.is(result.value.name, "Florian");
t.is(result.value.id, 42);
- result = makeStoreKeyValue(
- { name: "Florian", id: 10 },
- undefined,
- 5,
- true,
- "id",
- );
+ result = makeStoreKeyValue({
+ value: { name: "Florian", id: 10 },
+ key: undefined,
+ currentKeyGenerator: 5,
+ autoIncrement: true,
+ keyPath: "id",
+ });
t.is(result.updatedKeyGenerator, 11);
t.is(result.key, 10);
t.is(result.value.name, "Florian");
t.is(result.value.id, 10);
- result = makeStoreKeyValue(
- { name: "Florian", id: 5 },
- undefined,
- 10,
- true,
- "id",
- );
+ result = makeStoreKeyValue({
+ value: { name: "Florian", id: 5 },
+ key: undefined,
+ currentKeyGenerator: 10,
+ autoIncrement: true,
+ keyPath: "id",
+ });
t.is(result.updatedKeyGenerator, 10);
t.is(result.key, 5);
t.is(result.value.name, "Florian");
t.is(result.value.id, 5);
- result = makeStoreKeyValue(
- { name: "Florian", id: "foo" },
- undefined,
- 10,
- true,
- "id",
- );
+ result = makeStoreKeyValue({
+ value: { name: "Florian", id: "foo" },
+ key: undefined,
+ currentKeyGenerator: 10,
+ autoIncrement: true,
+ keyPath: "id",
+ });
t.is(result.updatedKeyGenerator, 10);
t.is(result.key, "foo");
t.is(result.value.name, "Florian");
t.is(result.value.id, "foo");
- result = makeStoreKeyValue({ name: "Florian" }, "foo", 10, true, null);
+ result = makeStoreKeyValue({
+ value: { name: "Florian" },
+ key: "foo",
+ currentKeyGenerator: 10,
+ autoIncrement: true,
+ keyPath: null,
+ });
t.is(result.updatedKeyGenerator, 10);
t.is(result.key, "foo");
t.is(result.value.name, "Florian");
t.is(result.value.id, undefined);
- result = makeStoreKeyValue({ name: "Florian" }, undefined, 10, true, null);
+ result = makeStoreKeyValue({
+ value: { name: "Florian" },
+ key: undefined,
+ currentKeyGenerator: 10,
+ autoIncrement: true,
+ keyPath: null,
+ });
t.is(result.updatedKeyGenerator, 11);
t.is(result.key, 10);
t.is(result.value.name, "Florian");
diff --git a/packages/idb-bridge/src/util/makeStoreKeyValue.ts b/packages/idb-bridge/src/util/makeStoreKeyValue.ts
index c0fdb19a7..153cd9d81 100644
--- a/packages/idb-bridge/src/util/makeStoreKeyValue.ts
+++ b/packages/idb-bridge/src/util/makeStoreKeyValue.ts
@@ -14,11 +14,11 @@
permissions and limitations under the License.
*/
-import { extractKey } from "./extractKey";
-import { DataCloneError, DataError } from "./errors";
-import { valueToKey } from "./valueToKey";
-import { structuredClone } from "./structuredClone";
-import { IDBKeyPath, IDBValidKey } from "../idbtypes";
+import { extractKey } from "./extractKey.js";
+import { DataCloneError, DataError } from "./errors.js";
+import { valueToKey } from "./valueToKey.js";
+import { structuredClone } from "./structuredClone.js";
+import { IDBKeyPath, IDBValidKey } from "../idbtypes.js";
export interface StoreKeyResult {
updatedKeyGenerator: number;
@@ -75,19 +75,25 @@ function injectKey(
return newValue;
}
-export function makeStoreKeyValue(
- value: any,
- key: IDBValidKey | undefined,
- currentKeyGenerator: number,
- autoIncrement: boolean,
- keyPath: IDBKeyPath | IDBKeyPath[] | null,
-): StoreKeyResult {
+export interface MakeStoreKvRequest {
+ value: any;
+ key: IDBValidKey | undefined;
+ currentKeyGenerator: number;
+ autoIncrement: boolean;
+ keyPath: IDBKeyPath | IDBKeyPath[] | null;
+}
+
+export function makeStoreKeyValue(req: MakeStoreKvRequest): StoreKeyResult {
+ const { keyPath, currentKeyGenerator, autoIncrement } = req;
+ let { key, value } = req;
+
const haveKey = key !== null && key !== undefined;
const haveKeyPath = keyPath !== null && keyPath !== undefined;
// This models a decision table on (haveKey, haveKeyPath, autoIncrement)
try {
+ // FIXME: Perf: only do this if we need to inject something.
value = structuredClone(value);
} catch (e) {
throw new DataCloneError();
diff --git a/packages/idb-bridge/src/util/normalizeKeyPath.ts b/packages/idb-bridge/src/util/normalizeKeyPath.ts
index 4e194b2d1..b79f54fd1 100644
--- a/packages/idb-bridge/src/util/normalizeKeyPath.ts
+++ b/packages/idb-bridge/src/util/normalizeKeyPath.ts
@@ -15,7 +15,7 @@
permissions and limitations under the License.
*/
-import { IDBKeyPath } from "../idbtypes";
+import { IDBKeyPath } from "../idbtypes.js";
export function normalizeKeyPath(
keyPath: IDBKeyPath | IDBKeyPath[],
diff --git a/packages/idb-bridge/src/util/queueTask.ts b/packages/idb-bridge/src/util/queueTask.ts
index 297602c67..f8a6e799f 100644
--- a/packages/idb-bridge/src/util/queueTask.ts
+++ b/packages/idb-bridge/src/util/queueTask.ts
@@ -14,6 +14,11 @@
permissions and limitations under the License.
*/
+/**
+ * Queue a task to be executed *after* the microtask
+ * queue has been processed, but *before* subsequent setTimeout / setImmediate
+ * tasks.
+ */
export function queueTask(fn: () => void) {
let called = false;
const callFirst = () => {
diff --git a/packages/idb-bridge/src/util/structuredClone.test.ts b/packages/idb-bridge/src/util/structuredClone.test.ts
index 352c2c30b..e13d4117f 100644
--- a/packages/idb-bridge/src/util/structuredClone.test.ts
+++ b/packages/idb-bridge/src/util/structuredClone.test.ts
@@ -15,7 +15,11 @@
*/
import test, { ExecutionContext } from "ava";
-import { structuredClone } from "./structuredClone";
+import {
+ structuredClone,
+ structuredEncapsulate,
+ structuredRevive,
+} from "./structuredClone.js";
function checkClone(t: ExecutionContext, x: any): void {
t.deepEqual(structuredClone(x), x);
@@ -46,9 +50,71 @@ test("structured clone", (t) => {
});
});
-test("structured clone (cycles)", (t) => {
+test("structured clone (array cycles)", (t) => {
const obj1: any[] = [1, 2];
obj1.push(obj1);
const obj1Clone = structuredClone(obj1);
t.is(obj1Clone, obj1Clone[2]);
});
+
+test("structured clone (object cycles)", (t) => {
+ const obj1: any = { a: 1, b: 2 };
+ obj1.c = obj1;
+ const obj1Clone = structuredClone(obj1);
+ t.is(obj1Clone, obj1Clone.c);
+});
+
+test("encapsulate", (t) => {
+ t.deepEqual(structuredEncapsulate(42), 42);
+ t.deepEqual(structuredEncapsulate(true), true);
+ t.deepEqual(structuredEncapsulate(false), false);
+ t.deepEqual(structuredEncapsulate(null), null);
+
+ t.deepEqual(structuredEncapsulate(undefined), { $: "undef" });
+ t.deepEqual(structuredEncapsulate(42n), { $: "bigint", val: "42" });
+
+ t.deepEqual(structuredEncapsulate(new Date(42)), { $: "date", val: 42 });
+
+ t.deepEqual(structuredEncapsulate({ x: 42 }), { x: 42 });
+
+ t.deepEqual(structuredEncapsulate({ $: "bla", x: 42 }), {
+ $: "obj",
+ val: { $: "bla", x: 42 },
+ });
+
+ const x = { foo: 42, bar: {} } as any;
+ x.bar.baz = x;
+
+ t.deepEqual(structuredEncapsulate(x), {
+ foo: 42,
+ bar: {
+ baz: { $: "ref", d: 2, p: [] },
+ },
+ });
+});
+
+test("revive", (t) => {
+ t.deepEqual(structuredRevive(42), 42);
+ t.deepEqual(structuredRevive([1, 2, 3]), [1, 2, 3]);
+ t.deepEqual(structuredRevive(true), true);
+ t.deepEqual(structuredRevive(false), false);
+ t.deepEqual(structuredRevive(null), null);
+ t.deepEqual(structuredRevive({ $: "undef" }), undefined);
+ t.deepEqual(structuredRevive({ x: { $: "undef" } }), { x: undefined });
+
+ t.deepEqual(structuredRevive({ $: "date", val: 42}), new Date(42));
+
+ {
+ const x = { foo: 42, bar: {} } as any;
+ x.bar.baz = x;
+
+ const r = {
+ foo: 42,
+ bar: {
+ baz: { $: "ref", d: 2, p: [] },
+ },
+ };
+
+ t.deepEqual(structuredRevive(r), x);
+ }
+});
diff --git a/packages/idb-bridge/src/util/structuredClone.ts b/packages/idb-bridge/src/util/structuredClone.ts
index 181e9ca0e..2f857c6c5 100644
--- a/packages/idb-bridge/src/util/structuredClone.ts
+++ b/packages/idb-bridge/src/util/structuredClone.ts
@@ -14,6 +14,30 @@
permissions and limitations under the License.
*/
+/**
+ * Encoding (new, compositional version):
+ *
+ * Encapsulate object that itself might contain a "$" field:
+ * { $: "obj", val: ... }
+ * (Outer level only:) Wrap other values into object
+ * { $: "lit", val: ... }
+ * Circular reference:
+ * { $: "ref" l: uplevel, p: path }
+ * Date:
+ * { $: "date", val: datestr }
+ * Bigint:
+ * { $: "bigint", val: bigintstr }
+ * Array with special (non-number) attributes:
+ * { $: "array", val: arrayobj }
+ * Undefined field
+ * { $: "undef" }
+ */
+
+/**
+ * Imports.
+ */
+import { DataCloneError } from "./errors.js";
+
const { toString: toStr } = {};
const hasOwn = {}.hasOwnProperty;
const getProto = Object.getPrototypeOf;
@@ -71,28 +95,183 @@ function isUserObject(val: any): boolean {
return hasConstructorOf(val, Object) || isUserObject(proto);
}
-function isRegExp(val: any): boolean {
- return toStringTag(val) === "RegExp";
+function copyBuffer(cur: any) {
+ if (cur instanceof Buffer) {
+ return Buffer.from(cur);
+ }
+
+ return new cur.constructor(cur.buffer.slice(), cur.byteOffset, cur.length);
+}
+
+function checkCloneableOrThrow(x: any) {
+ if (x == null) return;
+ if (typeof x !== "object" && typeof x !== "function") return;
+ if (x instanceof Date) return;
+ if (Array.isArray(x)) return;
+ if (x instanceof Map) return;
+ if (x instanceof Set) return;
+ if (isUserObject(x)) return;
+ if (isPlainObject(x)) return;
+ throw new DataCloneError();
+}
+
+export function mkDeepClone() {
+ const refs = [] as any;
+ const refsNew = [] as any;
+
+ return clone;
+
+ function cloneArray(a: any) {
+ var keys = Object.keys(a);
+ var a2 = new Array(keys.length);
+ refs.push(a);
+ refsNew.push(a2);
+ for (var i = 0; i < keys.length; i++) {
+ var k = keys[i] as any;
+ var cur = a[k];
+ checkCloneableOrThrow(cur);
+ if (typeof cur !== "object" || cur === null) {
+ a2[k] = cur;
+ } else if (cur instanceof Date) {
+ a2[k] = new Date(cur);
+ } else if (ArrayBuffer.isView(cur)) {
+ a2[k] = copyBuffer(cur);
+ } else {
+ var index = refs.indexOf(cur);
+ if (index !== -1) {
+ a2[k] = refsNew[index];
+ } else {
+ a2[k] = clone(cur);
+ }
+ }
+ }
+ refs.pop();
+ refsNew.pop();
+ return a2;
+ }
+
+ function clone(o: any) {
+ checkCloneableOrThrow(o);
+ if (typeof o !== "object" || o === null) return o;
+ if (o instanceof Date) return new Date(o);
+ if (Array.isArray(o)) return cloneArray(o);
+ if (o instanceof Map) return new Map(cloneArray(Array.from(o)));
+ if (o instanceof Set) return new Set(cloneArray(Array.from(o)));
+ var o2 = {} as any;
+ refs.push(o);
+ refsNew.push(o2);
+ for (var k in o) {
+ if (Object.hasOwnProperty.call(o, k) === false) continue;
+ var cur = o[k] as any;
+ checkCloneableOrThrow(cur);
+ if (typeof cur !== "object" || cur === null) {
+ o2[k] = cur;
+ } else if (cur instanceof Date) {
+ o2[k] = new Date(cur);
+ } else if (cur instanceof Map) {
+ o2[k] = new Map(cloneArray(Array.from(cur)));
+ } else if (cur instanceof Set) {
+ o2[k] = new Set(cloneArray(Array.from(cur)));
+ } else if (ArrayBuffer.isView(cur)) {
+ o2[k] = copyBuffer(cur);
+ } else {
+ var i = refs.indexOf(cur);
+ if (i !== -1) {
+ o2[k] = refsNew[i];
+ } else {
+ o2[k] = clone(cur);
+ }
+ }
+ }
+ refs.pop();
+ refsNew.pop();
+ return o2;
+ }
+}
+
+/**
+ * Check if an object is deeply cloneable.
+ * Only called for the side-effect of throwing an exception.
+ */
+export function mkDeepCloneCheckOnly() {
+ const refs = [] as any;
+
+ return clone;
+
+ function cloneArray(a: any) {
+ var keys = Object.keys(a);
+ refs.push(a);
+ for (var i = 0; i < keys.length; i++) {
+ var k = keys[i] as any;
+ var cur = a[k];
+ checkCloneableOrThrow(cur);
+ if (typeof cur !== "object" || cur === null) {
+ // do nothing
+ } else if (cur instanceof Date) {
+ // do nothing
+ } else if (ArrayBuffer.isView(cur)) {
+ // do nothing
+ } else {
+ var index = refs.indexOf(cur);
+ if (index !== -1) {
+ // do nothing
+ } else {
+ clone(cur);
+ }
+ }
+ }
+ refs.pop();
+ }
+
+ function clone(o: any) {
+ checkCloneableOrThrow(o);
+ if (typeof o !== "object" || o === null) return o;
+ if (o instanceof Date) return;
+ if (Array.isArray(o)) return cloneArray(o);
+ if (o instanceof Map) return cloneArray(Array.from(o));
+ if (o instanceof Set) return cloneArray(Array.from(o));
+ refs.push(o);
+ for (var k in o) {
+ if (Object.hasOwnProperty.call(o, k) === false) continue;
+ var cur = o[k] as any;
+ checkCloneableOrThrow(cur);
+ if (typeof cur !== "object" || cur === null) {
+ // do nothing
+ } else if (cur instanceof Date) {
+ // do nothing
+ } else if (cur instanceof Map) {
+ cloneArray(Array.from(cur));
+ } else if (cur instanceof Set) {
+ cloneArray(Array.from(cur));
+ } else if (ArrayBuffer.isView(cur)) {
+ // do nothing
+ } else {
+ var i = refs.indexOf(cur);
+ if (i !== -1) {
+ // do nothing
+ } else {
+ clone(cur);
+ }
+ }
+ }
+ refs.pop();
+ }
}
function internalEncapsulate(
val: any,
- outRoot: any,
path: string[],
memo: Map<any, string[]>,
- types: Array<[string[], string]>,
): any {
const memoPath = memo.get(val);
if (memoPath) {
- types.push([path, "ref"]);
- return memoPath;
+ return { $: "ref", d: path.length, p: memoPath };
}
if (val === null) {
return null;
}
if (val === undefined) {
- types.push([path, "undef"]);
- return 0;
+ return { $: "undef" };
}
if (Array.isArray(val)) {
memo.set(val, path);
@@ -105,31 +284,33 @@ function internalEncapsulate(
break;
}
}
- if (special) {
- types.push([path, "array"]);
- }
for (const x in val) {
const p = [...path, x];
- outArr[x] = internalEncapsulate(val[x], outRoot, p, memo, types);
+ outArr[x] = internalEncapsulate(val[x], p, memo);
+ }
+ if (special) {
+ return { $: "array", val: outArr };
+ } else {
+ return outArr;
}
- return outArr;
}
if (val instanceof Date) {
- types.push([path, "date"]);
- return val.getTime();
+ return { $: "date", val: val.getTime() };
}
if (isUserObject(val) || isPlainObject(val)) {
memo.set(val, path);
const outObj: any = {};
for (const x in val) {
const p = [...path, x];
- outObj[x] = internalEncapsulate(val[x], outRoot, p, memo, types);
+ outObj[x] = internalEncapsulate(val[x], p, memo);
+ }
+ if ("$" in outObj) {
+ return { $: "obj", val: outObj };
}
return outObj;
}
if (typeof val === "bigint") {
- types.push([path, "bigint"]);
- return val.toString();
+ return { $: "bigint", val: val.toString() };
}
if (typeof val === "boolean") {
return val;
@@ -143,122 +324,126 @@ function internalEncapsulate(
throw Error();
}
-/**
- * Encapsulate a cloneable value into a plain JSON object.
- */
-export function structuredEncapsulate(val: any): any {
- const outRoot = {};
- const types: Array<[string[], string]> = [];
- let res;
- res = internalEncapsulate(val, outRoot, [], new Map(), types);
- if (res === null) {
- return res;
- }
- // We need to further encapsulate the outer layer
- if (
- Array.isArray(res) ||
- typeof res !== "object" ||
- "$" in res ||
- "$types" in res
- ) {
- res = { $: res };
- }
- if (types.length > 0) {
- res["$types"] = types;
- }
- return res;
+function derefPath(
+ root: any,
+ p1: Array<string | number>,
+ n: number,
+ p2: Array<string | number>,
+): any {
+ let v = root;
+ for (let i = 0; i < n; i++) {
+ v = v[p1[i]];
+ }
+ for (let i = 0; i < p2.length; i++) {
+ v = v[p2[i]];
+ }
+ return v;
}
-export function internalStructuredRevive(val: any): any {
- val = JSON.parse(JSON.stringify(val));
- if (val === null) {
- return null;
+function internalReviveArray(sval: any, root: any, path: string[]): any {
+ const newArr: any[] = [];
+ if (root === undefined) {
+ root = newArr;
}
- if (typeof val === "number") {
- return val;
+ for (let i = 0; i < sval.length; i++) {
+ const p = [...path, String(i)];
+ newArr.push(internalStructuredRevive(sval[i], root, p));
}
- if (typeof val === "string") {
- return val;
+ return newArr;
+}
+
+function internalReviveObject(sval: any, root: any, path: string[]): any {
+ const newObj = {} as any;
+ if (root === undefined) {
+ root = newObj;
}
- if (typeof val === "boolean") {
- return val;
+ for (const key of Object.keys(sval)) {
+ const p = [...path, key];
+ newObj[key] = internalStructuredRevive(sval[key], root, p);
}
- if (!isPlainObject(val)) {
- throw Error();
- }
- let types = val.$types ?? [];
- delete val.$types;
- let outRoot: any;
- if ("$" in val) {
- outRoot = val.$;
- } else {
- outRoot = val;
- }
- function mutatePath(path: string[], f: (x: any) => any): void {
- if (path.length == 0) {
- outRoot = f(outRoot);
- return;
- }
- let obj = outRoot;
- for (let i = 0; i < path.length - 1; i++) {
- const n = path[i];
- if (!(n in obj)) {
- obj[n] = {};
- }
- obj = obj[n];
- }
- const last = path[path.length - 1];
- obj[last] = f(obj[last]);
+ return newObj;
+}
+
+function internalStructuredRevive(sval: any, root: any, path: string[]): any {
+ if (typeof sval === "string") {
+ return sval;
}
- function lookupPath(path: string[]): any {
- let obj = outRoot;
- for (const n of path) {
- obj = obj[n];
- }
- return obj;
+ if (typeof sval === "number") {
+ return sval;
}
- for (const [path, type] of types) {
- switch (type) {
- case "bigint": {
- mutatePath(path, (x) => BigInt(x));
- break;
- }
- case "array": {
- mutatePath(path, (x) => {
- const newArr: any = [];
- for (const k in x) {
- newArr[k] = x[k];
- }
- return newArr;
- });
- break;
- }
- case "date": {
- mutatePath(path, (x) => new Date(x));
- break;
- }
- case "undef": {
- mutatePath(path, (x) => undefined);
- break;
- }
- case "ref": {
- mutatePath(path, (x) => lookupPath(x));
- break;
+ if (typeof sval === "boolean") {
+ return sval;
+ }
+ if (sval === null) {
+ return null;
+ }
+ if (Array.isArray(sval)) {
+ return internalReviveArray(sval, root, path);
+ }
+
+ if (isUserObject(sval) || isPlainObject(sval)) {
+ if ("$" in sval) {
+ const dollar = sval.$;
+ switch (dollar) {
+ case "undef":
+ return undefined;
+ case "bigint":
+ return BigInt((sval as any).val);
+ case "date":
+ return new Date((sval as any).val);
+ case "obj": {
+ return internalReviveObject((sval as any).val, root, path);
+ }
+ case "array":
+ return internalReviveArray((sval as any).val, root, path);
+ case "ref": {
+ const level = (sval as any).l;
+ const p2 = (sval as any).p;
+ return derefPath(root, path, path.length - level, p2);
+ }
+ default:
+ throw Error();
}
- default:
- throw Error(`type '${type}' not implemented`);
+ } else {
+ return internalReviveObject(sval, root, path);
}
}
- return outRoot;
+
+ throw Error();
+}
+
+/**
+ * Encapsulate a cloneable value into a plain JSON value.
+ */
+export function structuredEncapsulate(val: any): any {
+ return internalEncapsulate(val, [], new Map());
}
-export function structuredRevive(val: any): any {
- return internalStructuredRevive(val);
+export function structuredRevive(sval: any): any {
+ return internalStructuredRevive(sval, undefined, []);
}
/**
* Structured clone for IndexedDB.
*/
export function structuredClone(val: any): any {
- return structuredRevive(structuredEncapsulate(val));
+ // @ts-ignore
+ if (globalThis._tart?.structuredClone) {
+ // @ts-ignore
+ return globalThis._tart?.structuredClone(val);
+ }
+ return mkDeepClone()(val);
+}
+
+/**
+ * Structured clone for IndexedDB.
+ */
+export function checkStructuredCloneOrThrow(val: any): void {
+ // @ts-ignore
+ if (globalThis._tart?.structuredClone) {
+ // @ts-ignore
+ globalThis._tart?.structuredClone(val);
+ return;
+ }
+ mkDeepCloneCheckOnly()(val);
}
diff --git a/packages/idb-bridge/src/util/validateKeyPath.ts b/packages/idb-bridge/src/util/validateKeyPath.ts
index 3bbe653b6..1c614fcca 100644
--- a/packages/idb-bridge/src/util/validateKeyPath.ts
+++ b/packages/idb-bridge/src/util/validateKeyPath.ts
@@ -14,7 +14,7 @@
permissions and limitations under the License.
*/
-import { IDBKeyPath } from "../idbtypes";
+import { IDBKeyPath } from "../idbtypes.js";
// http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#dfn-valid-key-path
export const validateKeyPath = (
@@ -46,7 +46,7 @@ export const validateKeyPath = (
if (myKeyPath.length >= 1 && validIdentifierRegex.test(myKeyPath)) {
return;
}
- } catch (err) {
+ } catch (err: any) {
throw new SyntaxError(err.message);
}
if (myKeyPath.indexOf(" ") >= 0) {
diff --git a/packages/idb-bridge/src/util/valueToKey.ts b/packages/idb-bridge/src/util/valueToKey.ts
index c65604df1..0cd824689 100644
--- a/packages/idb-bridge/src/util/valueToKey.ts
+++ b/packages/idb-bridge/src/util/valueToKey.ts
@@ -14,10 +14,14 @@
permissions and limitations under the License.
*/
-import { IDBValidKey } from "..";
-import { DataError } from "./errors";
+import { IDBValidKey } from "../idbtypes.js";
+import { DataError } from "./errors.js";
-// https://www.w3.org/TR/IndexedDB-2/#convert-a-value-to-a-key
+/**
+ * Algorithm to "convert a value to a key".
+ *
+ * https://www.w3.org/TR/IndexedDB/#convert-value-to-key
+ */
export function valueToKey(
input: any,
seen?: Set<object>,
diff --git a/packages/idb-bridge/tsconfig.json b/packages/idb-bridge/tsconfig.json
index 4f730e1c5..44a27284b 100644
--- a/packages/idb-bridge/tsconfig.json
+++ b/packages/idb-bridge/tsconfig.json
@@ -1,24 +1,24 @@
{
- "compilerOptions": {
- "composite": true,
- "lib": ["es6"],
- "module": "ESNext",
- "moduleResolution": "node",
- "target": "ES6",
- "allowJs": true,
- "noImplicitAny": true,
- "outDir": "lib",
- "declaration": true,
- "declarationMap": true,
- "noEmitOnError": true,
- "strict": true,
- "incremental": true,
- "sourceMap": true,
- "rootDir": "./src",
- "esModuleInterop": true,
- "importHelpers": true,
- "isolatedModules": true,
- "typeRoots": ["./node_modules/@types"]
- },
- "include": ["src/**/*"]
+ "compilerOptions": {
+ "composite": true,
+ "lib": ["ES2020"],
+ "module": "Node16",
+ "moduleResolution": "Node16",
+ "target": "ES2020",
+ "allowJs": true,
+ "noImplicitAny": true,
+ "outDir": "lib",
+ "declaration": true,
+ "declarationMap": true,
+ "noEmitOnError": true,
+ "strict": true,
+ "incremental": true,
+ "sourceMap": true,
+ "rootDir": "./src",
+ "esModuleInterop": true,
+ "importHelpers": true,
+ "isolatedModules": true,
+ "typeRoots": ["./node_modules/@types"]
+ },
+ "include": ["src/**/*"]
}
diff --git a/packages/merchant-backend-ui/.gitignore b/packages/merchant-backend-ui/.gitignore
new file mode 100644
index 000000000..a6ee22df2
--- /dev/null
+++ b/packages/merchant-backend-ui/.gitignore
@@ -0,0 +1,9 @@
+/build
+/size-plugin.json
+/storybook-static
+/docs
+/single
+/coverage
+/dist
+/.rollup.cache
+/.linaria-cache
diff --git a/packages/merchant-backend-ui/README.md b/packages/merchant-backend-ui/README.md
new file mode 100644
index 000000000..7f9bcf5dc
--- /dev/null
+++ b/packages/merchant-backend-ui/README.md
@@ -0,0 +1,26 @@
+Merchant Backend pages
+
+# Description
+
+This project generate 5 templates for the merchant backend:
+
+- OfferRefund
+- RequestPayment
+- ShowOrderDetails
+
+This pages are to be serve from the merchant-backend service and will be queried for browser that may or may not have JavaScript enabled, so we are going to do server side rendering.
+The merchant-backend service is currently supporting mustache library for server side rendering.
+We also want the be able to create a more interactive design if the browser have JavaScript enabled, so the pages will be serve with all the information in the HTML but also in JavaScript.
+
+In this scenario, we are using jsx to build the template of the page that will be build-time rendered into the mustache template. This template can the be deployed into a merchant-backend that will complete the information before send it to the browser.
+
+# Building
+
+The building process can be executed with `pnpm build`
+
+# Testing
+
+This project is using a JavaScript implementation of mustache that can be executed with the command `pnpm render-examples`.
+This script will take the pages previously built in `dist/pages` directory and the examples definition in the `src/pages/[exampleName].examples.ts` files and render a to-be-sent-to-the-user page like the merchant would do.
+This examples will be saved invidivualy into directory `dist/examples` and should be opened with your testing browser.
+Testing should be done with JavaScript enabled and JavaScript disabled, both should look ok.
diff --git a/packages/merchant-backend-ui/babel.config-linaria.json b/packages/merchant-backend-ui/babel.config-linaria.json
new file mode 100644
index 000000000..6192b62fe
--- /dev/null
+++ b/packages/merchant-backend-ui/babel.config-linaria.json
@@ -0,0 +1,27 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+/*
+ * Linaria need pre-process typscript files into javascript before running.
+ * We choose to use the default preact-cli config.
+ * This file should be used from @linaria/rollup plugin only
+ */
+{
+ "plugins": ["./trim-extension.cjs"],
+}
diff --git a/packages/merchant-backend-ui/build.mjs b/packages/merchant-backend-ui/build.mjs
new file mode 100755
index 000000000..e72113dc5
--- /dev/null
+++ b/packages/merchant-backend-ui/build.mjs
@@ -0,0 +1,190 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import esbuild from "esbuild";
+import path from "path";
+import fs from "fs";
+import linaria from '@linaria/esbuild'
+
+// eslint-disable-next-line no-undef
+const BASE = process.cwd();
+
+const preact = path.join(
+ BASE,
+ "node_modules",
+ "preact",
+ "compat",
+ "dist",
+ "compat.module.js",
+);
+
+const preactCompatPlugin = {
+ name: "preact-compat",
+ setup(build) {
+ build.onResolve({ filter: /^(react-dom|react)$/ }, (args) => {
+ //console.log("onresolve", JSON.stringify(args, undefined, 2));
+ return {
+ path: preact,
+ };
+ });
+ },
+};
+
+const pages = ["OfferRefund", "RequestPayment", "ShowOrderDetails"]
+const entryPoints = pages.map(p => `src/pages/${p}.tsx`);
+
+let GIT_ROOT = BASE;
+while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") {
+ GIT_ROOT = path.join(GIT_ROOT, "../");
+}
+if (GIT_ROOT === "/") {
+ // eslint-disable-next-line no-undef
+ console.log("not found");
+ // eslint-disable-next-line no-undef
+ process.exit(1);
+}
+const GIT_HASH = GIT_ROOT === "/" ? undefined : git_hash();
+
+let _package = JSON.parse(fs.readFileSync(path.join(BASE, "package.json")));
+
+function git_hash() {
+ const rev = fs
+ .readFileSync(path.join(GIT_ROOT, ".git", "HEAD"))
+ .toString()
+ .trim()
+ .split(/.*[: ]/)
+ .slice(-1)[0];
+ if (rev.indexOf("/") === -1) {
+ return rev;
+ } else {
+ return fs.readFileSync(path.join(GIT_ROOT, ".git", rev)).toString().trim();
+ }
+}
+function toCamelCaseName(name) {
+ return name
+ .replace(/^[A-Z]/, letter => `${letter.toLowerCase()}`) //first letter lowercase
+ .replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`) //snake case
+ .concat(".en.html"); //extension
+}
+
+function templatePlugin(options) {
+ return {
+ name: "template-backend",
+ setup(build) {
+ build.onEnd(() => {
+ for (const pageName of options.pages) {
+ const css = fs.readFileSync(path.join(build.initialOptions.outdir, `${pageName}.css`), "utf8").toString()
+ const js = fs.readFileSync(path.join(build.initialOptions.outdir, `${pageName}.js`), "utf8").toString()
+ const location = path.join(build.initialOptions.outdir, toCamelCaseName(pageName))
+ const render = new Function(`${js}; return page.buildTimeRendering();`)()
+ const html = `
+ <!doctype html>
+ <html>
+ <head>
+ ${render.head}
+ <style>${css}</style>
+ </head>
+ <script id="built_time_data">
+ </script>
+ <body>
+ ${render.body}
+ <script>${js}</script>
+ <script>page.mount()</script>
+ </body>
+ </html>`
+ fs.writeFileSync(location, html);
+ }
+ });
+ },
+ };
+}
+
+export const buildConfig = {
+ entryPoints: [...entryPoints],
+ bundle: true,
+ outdir: "dist/pages",
+ /*
+ * Doing a minified version will replace templatestring to common strings
+ * This app is building mustache template with placeholders that will be replaced
+ * with string in runtime by the merchant-backend
+ *
+ * To the date, merchant backend is replacing with multiline string so
+ * doing minified version will brake at runtime
+ * */
+ minify: false,
+ loader: {
+ ".svg": "file",
+ ".png": "dataurl",
+ ".jpeg": "dataurl",
+ '.ttf': 'file',
+ '.woff': 'file',
+ '.woff2': 'file',
+ '.eot': 'file',
+ },
+ target: ["es2020"],
+ format: "iife",
+ platform: "browser",
+ sourcemap: false,
+ globalName: "page",
+ jsxFactory: "h",
+ jsxFragment: "Fragment",
+ define: {
+ __VERSION__: `"${_package.version}"`,
+ __GIT_HASH__: `"${GIT_HASH}"`,
+ },
+ plugins: [
+ linaria.default({
+ babelOptions: {
+ babelrc: false,
+ configFile: './babel.config-linaria.json',
+ },
+ sourceMap: true,
+ }),
+ preactCompatPlugin,
+ templatePlugin({ pages })
+ ],
+};
+
+await esbuild.build(buildConfig)
+
+export const testingConfig = {
+ entryPoints: ["src/render-examples.ts"],
+ bundle: true,
+ outdir: "dist/test",
+ minify: false,
+ loader: {
+ ".svg": "file",
+ ".png": "dataurl",
+ ".jpeg": "dataurl",
+ '.ttf': 'file',
+ '.woff': 'file',
+ '.woff2': 'file',
+ '.eot': 'file',
+ },
+ target: ["es2020"],
+ format: "iife",
+ platform: "node",
+ sourcemap: true,
+ define: {
+ __VERSION__: `"${_package.version}"`,
+ __GIT_HASH__: `"${GIT_HASH}"`,
+ },
+ plugins: [
+ ],
+};
+
+await esbuild.build(testingConfig)
diff --git a/packages/merchant-backend-ui/contrib/po2ts b/packages/merchant-backend-ui/contrib/po2ts
new file mode 100755
index 000000000..a135da61b
--- /dev/null
+++ b/packages/merchant-backend-ui/contrib/po2ts
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Convert a <lang>.po file into a JavaScript / TypeScript expression.
+ */
+
+const po2json = require("po2json");
+
+const filename = process.argv[2];
+
+if (!filename) {
+ console.error("error: missing filename");
+ process.exit(1);
+}
+
+const m = filename.match(/([a-zA-Z0-9-_]+).po/);
+
+if (!m) {
+ console.error("error: unexpected filename (expected <lang>.po)");
+ process.exit(1);
+}
+
+const lang = m[1];
+const pojson = po2json.parseFileSync(filename, { format: "jed1.x", fuzzy: true });
+const s =
+ "strings['" + lang + "'] = " + JSON.stringify(pojson, null, " ") + ";\n";
+console.log(s);
diff --git a/packages/merchant-backend-ui/copyleft-header.js b/packages/merchant-backend-ui/copyleft-header.js
new file mode 100644
index 000000000..0794cb839
--- /dev/null
+++ b/packages/merchant-backend-ui/copyleft-header.js
@@ -0,0 +1,15 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
diff --git a/packages/merchant-backend-ui/package.json b/packages/merchant-backend-ui/package.json
new file mode 100644
index 000000000..9f32746f4
--- /dev/null
+++ b/packages/merchant-backend-ui/package.json
@@ -0,0 +1,69 @@
+{
+ "private": true,
+ "name": "@gnu-taler/merchant-backend-ui",
+ "version": "0.10.6",
+ "license": "AGPL-3.0-or-later",
+ "scripts": {
+ "compile": "tsc && ./build.mjs",
+ "build": "pnpm compile",
+ "render-examples": "node dist/test/render-examples.js dist/pages dist/examples",
+ "lint-check": "eslint '{src,tests}/**/*.{js,jsx,ts,tsx}'",
+ "lint-fix": "eslint --fix '{src,tests}/**/*.{js,jsx,ts,tsx}'",
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo",
+ "serve-dist": "sirv --port ${PORT:=8080} --cors --single dist"
+ },
+ "engines": {
+ "node": ">=12",
+ "pnpm": ">=5"
+ },
+ "eslintConfig": {
+ "parser": "@typescript-eslint/parser",
+ "extends": [
+ "preact",
+ "plugin:@typescript-eslint/recommended"
+ ],
+ "plugins": [
+ "header"
+ ],
+ "rules": {
+ "header/header": [
+ 2,
+ "copyleft-header.js"
+ ]
+ },
+ "ignorePatterns": [
+ "build/"
+ ]
+ },
+ "dependencies": {
+ "date-fns": "^2.21.1",
+ "preact": "10.11.3",
+ "qrcode-generator": "^1.4.4"
+ },
+ "devDependencies": {
+ "@babel/core": "7.18.9",
+ "@gnu-taler/pogen": "^0.0.5",
+ "@linaria/babel-preset": "3.0.0-beta.22",
+ "@linaria/core": "3.0.0-beta.22",
+ "@linaria/react": "3.0.0-beta.22",
+ "@linaria/shaker": "3.0.0-beta.22",
+ "@linaria/webpack-loader": "3.0.0-beta.22",
+ "@types/mocha": "^8.2.2",
+ "@types/mustache": "^4.1.2",
+ "@types/node": "^20.11.13",
+ "@typescript-eslint/eslint-plugin": "^4.22.0",
+ "@typescript-eslint/parser": "^4.22.0",
+ "babel-loader": "^8.2.2",
+ "base64-inline-loader": "^1.1.1",
+ "eslint": "^7.25.0",
+ "eslint-config-preact": "^1.1.4",
+ "eslint-plugin-header": "^3.1.1",
+ "mustache": "^4.2.0",
+ "po2json": "^0.4.5",
+ "preact-render-to-string": "^5.1.19",
+ "sirv-cli": "^1.0.11",
+ "ts-node": "^10.9.1",
+ "tslib": "2.6.2",
+ "typescript": "5.3.3"
+ }
+}
diff --git a/packages/merchant-backend-ui/src/assets/empty.png b/packages/merchant-backend-ui/src/assets/empty.png
new file mode 100644
index 000000000..5120d3138
--- /dev/null
+++ b/packages/merchant-backend-ui/src/assets/empty.png
Binary files differ
diff --git a/packages/merchant-backend-ui/src/assets/icons/android-chrome-192x192.png b/packages/merchant-backend-ui/src/assets/icons/android-chrome-192x192.png
new file mode 100644
index 000000000..93ebe2e2c
--- /dev/null
+++ b/packages/merchant-backend-ui/src/assets/icons/android-chrome-192x192.png
Binary files differ
diff --git a/packages/merchant-backend-ui/src/assets/icons/android-chrome-512x512.png b/packages/merchant-backend-ui/src/assets/icons/android-chrome-512x512.png
new file mode 100644
index 000000000..52d1623ea
--- /dev/null
+++ b/packages/merchant-backend-ui/src/assets/icons/android-chrome-512x512.png
Binary files differ
diff --git a/packages/merchant-backend-ui/src/assets/icons/apple-touch-icon.png b/packages/merchant-backend-ui/src/assets/icons/apple-touch-icon.png
new file mode 100644
index 000000000..254e4bb4d
--- /dev/null
+++ b/packages/merchant-backend-ui/src/assets/icons/apple-touch-icon.png
Binary files differ
diff --git a/packages/merchant-backend-ui/src/assets/icons/favicon-16x16.png b/packages/merchant-backend-ui/src/assets/icons/favicon-16x16.png
new file mode 100644
index 000000000..e81177dcb
--- /dev/null
+++ b/packages/merchant-backend-ui/src/assets/icons/favicon-16x16.png
Binary files differ
diff --git a/packages/merchant-backend-ui/src/assets/icons/favicon-32x32.png b/packages/merchant-backend-ui/src/assets/icons/favicon-32x32.png
new file mode 100644
index 000000000..40e9b5b47
--- /dev/null
+++ b/packages/merchant-backend-ui/src/assets/icons/favicon-32x32.png
Binary files differ
diff --git a/packages/merchant-backend-ui/src/assets/icons/languageicon.svg b/packages/merchant-backend-ui/src/assets/icons/languageicon.svg
new file mode 100644
index 000000000..22d58da65
--- /dev/null
+++ b/packages/merchant-backend-ui/src/assets/icons/languageicon.svg
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 2411.2 2794" style="enable-background:new 0 0 2411.2 2794;" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#FFFFFF;}
+ .st1{fill-rule:evenodd;clip-rule:evenodd;}
+ .st2{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
+</style>
+<g id="Layer_2">
+</g>
+<g id="Layer_x5F_1_x5F_1">
+ <g>
+ <polygon points="1204.6,359.2 271.8,30 271.8,2060.1 1204.6,1758.3 "/>
+ <polygon class="st0" points="1182.2,358.1 2150.6,29 2150.6,2059 1182.2,1757.3 "/>
+ <polygon class="st0" points="30,2415.4 1182.2,2031.4 1182.2,357.9 30,742 "/>
+ <polygon points="1707.2,2440.7 1870.5,2709.4 1956.6,2459.8 "/>
+ <g>
+ <path d="M421.7,934.8c-6.1-6,8,49.1,27.6,68.9c34.8,35.1,61.9,39.6,76.4,40.2c32,1.3,71.5-8,94.9-17.8
+ c22.7-9.7,62.4-30,77.5-59.6c3.2-6.3,11.9-17,6.4-43.2c-4.2-20.2-17-27.3-32.7-26.2c-15.7,1.1-63.2,13.7-86.1,20.8
+ c-23,7-70.3,21.4-90.9,25.8C474.3,948.2,429,941.7,421.7,934.8z"/>
+ <path d="M1003.1,1593.7c-9.1-3.3-196.9-81.1-223.6-93.9c-21.8-10.5-75.2-33.1-100.4-43.3c70.8-109.2,115.5-191.6,121.5-204.1
+ c11-23,86-169.6,87.7-178.7c1.7-9.1,3.8-42.9,2.2-51c-1.7-8.2-29.1,7.6-66.4,20.2c-37.4,12.6-108.4,58.8-135.8,64.6
+ c-27.5,5.7-115.5,39.1-160.5,54c-45,14.9-130.2,40.9-165.2,50.4c-35.1,9.5-65.7,10.2-85.3,16.2c0,0,2.6,27.5,7.8,35.7
+ c5.2,8.2,23.7,28.4,45.3,34.1c21.6,5.7,57.3,3.4,73.6-0.3c16.3-3.8,44.4-17.5,48.2-23.6c3.8-6.1-2-24.9,4.5-30.6
+ c6.5-5.6,92.2-25.7,124.6-35.4c32.4-10,156.3-52.6,173.1-50.5c-5.3,17.7-105,215.1-137.1,274c-32.1,58.9-218.6,318-258.3,363.6
+ c-30.1,34.7-103.2,123.5-128.5,143.6c6.4,1.8,51.6-2.1,59.9-7.2c51.3-31.6,136.9-138.1,164.4-170.5
+ c81.9-96,153.8-196.8,210.8-283.4h0.1c11.1,4.6,100.9,77.8,124.4,94c23.4,16.2,115.9,67.8,136,76.4c20,8.7,97.1,44.2,100.3,32.2
+ C1029.4,1668,1012.2,1597.1,1003.1,1593.7z"/>
+ </g>
+ <path class="st1" d="M569,2572c18,11,35,20,54,29c38,19,81,39,122,54c56,21,112,38,168,51c31,7,65,13,98,18c3,0,92,11,110,11h90
+ c35-3,68-5,103-10c28-4,59-9,89-16c22-5,45-10,67-17c21-6,45-14,68-22c15-5,31-12,47-18c13-6,29-13,44-19c18-8,39-19,59-29
+ c16-8,34-18,51-28c13-7,43-30,59-30c18,0,30,16,30,30c0,29-39,38-57,51c-19,13-42,23-62,34c-40,21-81,39-120,54
+ c-51,19-107,37-157,49c-19,4-38,9-57,12c-10,2-114,18-143,18h-132c-35-3-72-7-107-12c-31-5-64-11-95-18c-24-5-50-12-73-19
+ c-40-11-79-25-117-40c-69-26-141-60-209-105c-12-8-13-16-13-25c0-15,11-29,29-29C531,2546,563,2569,569,2572z"/>
+ <path class="st1" d="M1151,2009L61,2372V764l1090-363V2009z M1212,354v1680c-1,5-3,10-7,15c-2,3-6,7-9,8c-25,10-1151,388-1166,388
+ c-12,0-23-8-29-21c0-1-1-2-1-4V739c2-5,3-12,7-16c8-11,22-13,31-16c17-6,1126-378,1142-378C1190,329,1212,336,1212,354z"/>
+ <path class="st1" d="M2120,2017l-907-282V380l907-308V2017z M2181,32v2023c-1,23-17,33-32,33c-13,0-107-32-123-37
+ c-126-39-253-78-378-117c-28-9-57-18-84-27c-24-7-50-15-74-23c-107-33-216-66-323-102c-4-1-14-15-14-18V351c2-5,4-11,9-15
+ c8-9,351-123,486-168c36-13,487-168,501-168C2167,0,2181,13,2181,32z"/>
+ <polygon points="2411.2,2440.7 1199.5,2054.5 1204.6,373.2 2411.2,757.2 "/>
+ <g>
+ <path class="st2" d="M1800.3,1124.6L1681.4,1412l218.6,66.3L1800.3,1124.6z M1729,853.2l156.1,47.3l284.4,1025l-160.3-48.7
+ l-57.6-210.4L1620.2,1566l-71.3,171.4l-160.4-48.7L1729,853.2z"/>
+ </g>
+ </g>
+</g>
+</svg>
diff --git a/packages/merchant-backend-ui/src/assets/icons/mstile-150x150.png b/packages/merchant-backend-ui/src/assets/icons/mstile-150x150.png
new file mode 100644
index 000000000..9cfb889be
--- /dev/null
+++ b/packages/merchant-backend-ui/src/assets/icons/mstile-150x150.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/tests/__mocks__/setupTests.ts b/packages/merchant-backend-ui/src/components/Footer.tsx
index bae5c128f..278e4a543 100644
--- a/packages/taler-wallet-webextension/tests/__mocks__/setupTests.ts
+++ b/packages/merchant-backend-ui/src/components/Footer.tsx
@@ -14,21 +14,22 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
+import { h, VNode } from "preact";
+import { FooterBar } from "../styled/index.js";
-import 'regenerator-runtime/runtime'
-import { configure } from 'enzyme';
-import Adapter from 'enzyme-adapter-preact-pure';
-
-configure({
- adapter: new Adapter()
-});
-
-// Polyfill for encoding which isn't present globally in jsdom
-import { TextEncoder, TextDecoder } from 'util'
-global.TextEncoder = TextEncoder;
-global.TextDecoder = TextDecoder;
-(global as any).chrome = {}; \ No newline at end of file
+export function Footer(): VNode {
+ return (
+ <FooterBar>
+ <p>
+ <a href="https://taler.net/">
+ Learn more about GNU Taler on our website.
+ </a>
+ <p>Copyright &copy; 2014&mdash;2021 Taler Systems SA</p>
+ </p>
+ </FooterBar>
+ );
+}
diff --git a/packages/merchant-backend-ui/src/components/QR.tsx b/packages/merchant-backend-ui/src/components/QR.tsx
new file mode 100644
index 000000000..425a94961
--- /dev/null
+++ b/packages/merchant-backend-ui/src/components/QR.tsx
@@ -0,0 +1,54 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+import { useEffect, useRef } from "preact/hooks";
+import qrcode from "qrcode-generator";
+
+export function createSVG(text: string): string {
+ const qr = qrcode(0, "L");
+ qr.addData(text);
+ qr.make();
+ return qr.createSvgTag({
+ scalable: true,
+ margin: 0,
+ });
+}
+
+export function QR({ text }: { text: string }): VNode {
+ const divRef = useRef<HTMLDivElement>(null);
+ useEffect(() => {
+ if (divRef.current) {
+ divRef.current.innerHTML = createSVG(text);
+ }
+ });
+
+ return (
+ <div
+ style={{
+ width: "100%",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ }}
+ >
+ <div
+ style={{ width: "50%", minWidth: 200, maxWidth: 300 }}
+ ref={divRef}
+ />
+ </div>
+ );
+}
diff --git a/packages/merchant-backend-ui/src/css/pure-min.css b/packages/merchant-backend-ui/src/css/pure-min.css
new file mode 100644
index 000000000..77217b520
--- /dev/null
+++ b/packages/merchant-backend-ui/src/css/pure-min.css
@@ -0,0 +1,973 @@
+/*!
+ Pure v2.0.3
+ Copyright 2013 Yahoo!
+ Licensed under the BSD License.
+ https://github.com/pure-cs s/pure/blob/master/LICENSE.md
+*/
+/*!
+ normalize.cs s v | MIT License | git.io/normalize
+ Copyright (c) Nicolas Gallagher and Jonathan Neal
+*/
+/*! normalize.cs s v8.0.1 | MIT License | github.com/necolas/normalize.cs s */
+
+.talerbar {
+ text-align: center;
+}
+
+html {
+ line-height: 1.15;
+ -webkit-text-size-adjust: 100%;
+}
+body {
+ margin: 0;
+}
+main {
+ display: block;
+}
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+hr {
+ -webkit-box-sizing: content-box;
+ box-sizing: content-box;
+ height: 0;
+ overflow: visible;
+}
+pre {
+ font-family: monospace, monospace;
+ font-size: 1em;
+}
+a {
+ background-color: transparent;
+}
+abbr[title] {
+ border-bottom: none;
+ text-decoration: underline;
+ -webkit-text-decoration: underline dotted;
+ text-decoration: underline dotted;
+}
+b,
+strong {
+ font-weight: bolder;
+}
+code,
+kbd,
+samp {
+ font-family: monospace, monospace;
+ font-size: 1em;
+}
+small {
+ font-size: 80%;
+}
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+sub {
+ bottom: -0.25em;
+}
+sup {
+ top: -0.5em;
+}
+img {
+ border-style: none;
+}
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ font-size: 100%;
+ line-height: 1.15;
+ margin: 0;
+}
+button,
+input {
+ overflow: visible;
+}
+button,
+select {
+ text-transform: none;
+}
+[type="button"],
+[type="reset"],
+[type="submit"],
+button {
+ -webkit-appearance: button;
+}
+[type="button"]::-moz-focus-inner,
+[type="reset"]::-moz-focus-inner,
+[type="submit"]::-moz-focus-inner,
+button::-moz-focus-inner {
+ border-style: none;
+ padding: 0;
+}
+[type="button"]:-moz-focusring,
+[type="reset"]:-moz-focusring,
+[type="submit"]:-moz-focusring,
+button:-moz-focusring {
+ outline: 1px dotted ButtonText;
+}
+fieldset {
+ padding: 0.35em 0.75em 0.625em;
+}
+legend {
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+ color: inherit;
+ display: table;
+ max-width: 100%;
+ padding: 0;
+ white-space: normal;
+}
+progress {
+ vertical-align: baseline;
+}
+textarea {
+ overflow: auto;
+}
+[type="checkbox"],
+[type="radio"] {
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+ padding: 0;
+}
+[type="number"]::-webkit-inner-spin-button,
+[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+[type="search"] {
+ -webkit-appearance: textfield;
+ outline-offset: -2px;
+}
+[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ font: inherit;
+}
+details {
+ display: block;
+}
+summary {
+ display: list-item;
+}
+template {
+ display: none;
+}
+[hidden] {
+ display: none;
+}
+html {
+ font-family: sans-serif;
+}
+.hidden,
+[hidden] {
+ display: none !important;
+}
+.pure-img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+}
+.pure-g {
+ letter-spacing: -0.31em;
+ text-rendering: optimizespeed;
+ font-family: FreeSans, Arimo, "Droid Sans", Helvetica, Arial, sans-serif;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-orient: horizontal;
+ -webkit-box-direction: normal;
+ -ms-flex-flow: row wrap;
+ flex-flow: row wrap;
+ -ms-flex-line-pack: start;
+ align-content: flex-start;
+}
+@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
+ table .pure-g {
+ display: block;
+ }
+}
+.opera-only :-o-prefocus,
+.pure-g {
+ word-spacing: -0.43em;
+}
+.pure-u {
+ display: inline-block;
+ letter-spacing: normal;
+ word-spacing: normal;
+ vertical-align: top;
+ text-rendering: auto;
+}
+.pure-g [class*="pure-u"] {
+ font-family: sans-serif;
+}
+.pure-u-1,
+.pure-u-1-1,
+.pure-u-1-12,
+.pure-u-1-2,
+.pure-u-1-24,
+.pure-u-1-3,
+.pure-u-1-4,
+.pure-u-1-5,
+.pure-u-1-6,
+.pure-u-1-8,
+.pure-u-10-24,
+.pure-u-11-12,
+.pure-u-11-24,
+.pure-u-12-24,
+.pure-u-13-24,
+.pure-u-14-24,
+.pure-u-15-24,
+.pure-u-16-24,
+.pure-u-17-24,
+.pure-u-18-24,
+.pure-u-19-24,
+.pure-u-2-24,
+.pure-u-2-3,
+.pure-u-2-5,
+.pure-u-20-24,
+.pure-u-21-24,
+.pure-u-22-24,
+.pure-u-23-24,
+.pure-u-24-24,
+.pure-u-3-24,
+.pure-u-3-4,
+.pure-u-3-5,
+.pure-u-3-8,
+.pure-u-4-24,
+.pure-u-4-5,
+.pure-u-5-12,
+.pure-u-5-24,
+.pure-u-5-5,
+.pure-u-5-6,
+.pure-u-5-8,
+.pure-u-6-24,
+.pure-u-7-12,
+.pure-u-7-24,
+.pure-u-7-8,
+.pure-u-8-24,
+.pure-u-9-24 {
+ display: inline-block;
+ letter-spacing: normal;
+ word-spacing: normal;
+ vertical-align: top;
+ text-rendering: auto;
+}
+.pure-u-1-24 {
+ width: 4.1667%;
+}
+.pure-u-1-12,
+.pure-u-2-24 {
+ width: 8.3333%;
+}
+.pure-u-1-8,
+.pure-u-3-24 {
+ width: 12.5%;
+}
+.pure-u-1-6,
+.pure-u-4-24 {
+ width: 16.6667%;
+}
+.pure-u-1-5 {
+ width: 20%;
+}
+.pure-u-5-24 {
+ width: 20.8333%;
+}
+.pure-u-1-4,
+.pure-u-6-24 {
+ width: 25%;
+}
+.pure-u-7-24 {
+ width: 29.1667%;
+}
+.pure-u-1-3,
+.pure-u-8-24 {
+ width: 33.3333%;
+}
+.pure-u-3-8,
+.pure-u-9-24 {
+ width: 37.5%;
+}
+.pure-u-2-5 {
+ width: 40%;
+}
+.pure-u-10-24,
+.pure-u-5-12 {
+ width: 41.6667%;
+}
+.pure-u-11-24 {
+ width: 45.8333%;
+}
+.pure-u-1-2,
+.pure-u-12-24 {
+ width: 50%;
+}
+.pure-u-13-24 {
+ width: 54.1667%;
+}
+.pure-u-14-24,
+.pure-u-7-12 {
+ width: 58.3333%;
+}
+.pure-u-3-5 {
+ width: 60%;
+}
+.pure-u-15-24,
+.pure-u-5-8 {
+ width: 62.5%;
+}
+.pure-u-16-24,
+.pure-u-2-3 {
+ width: 66.6667%;
+}
+.pure-u-17-24 {
+ width: 70.8333%;
+}
+.pure-u-18-24,
+.pure-u-3-4 {
+ width: 75%;
+}
+.pure-u-19-24 {
+ width: 79.1667%;
+}
+.pure-u-4-5 {
+ width: 80%;
+}
+.pure-u-20-24,
+.pure-u-5-6 {
+ width: 83.3333%;
+}
+.pure-u-21-24,
+.pure-u-7-8 {
+ width: 87.5%;
+}
+.pure-u-11-12,
+.pure-u-22-24 {
+ width: 91.6667%;
+}
+.pure-u-23-24 {
+ width: 95.8333%;
+}
+.pure-u-1,
+.pure-u-1-1,
+.pure-u-24-24,
+.pure-u-5-5 {
+ width: 100%;
+}
+.pure-button {
+ display: inline-block;
+ line-height: normal;
+ white-space: nowrap;
+ vertical-align: middle;
+ text-align: center;
+ cursor: pointer;
+ -webkit-user-drag: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+.pure-button::-moz-focus-inner {
+ padding: 0;
+ border: 0;
+}
+.pure-button-group {
+ letter-spacing: -0.31em;
+ text-rendering: optimizespeed;
+}
+.opera-only :-o-prefocus,
+.pure-button-group {
+ word-spacing: -0.43em;
+}
+.pure-button-group .pure-button {
+ letter-spacing: normal;
+ word-spacing: normal;
+ vertical-align: top;
+ text-rendering: auto;
+}
+.pure-button {
+ font-family: inherit;
+ font-size: 100%;
+ padding: 0.5em 1em;
+ color: rgba(0, 0, 0, 0.8);
+ border: none transparent;
+ background-color: #e6e6e6;
+ text-decoration: none;
+ border-radius: 2px;
+}
+.pure-button-hover,
+.pure-button:focus,
+.pure-button:hover {
+ background-image: -webkit-gradient(
+ linear,
+ left top,
+ left bottom,
+ from(transparent),
+ color-stop(40%, rgba(0, 0, 0, 0.05)),
+ to(rgba(0, 0, 0, 0.1))
+ );
+ background-image: linear-gradient(
+ transparent,
+ rgba(0, 0, 0, 0.05) 40%,
+ rgba(0, 0, 0, 0.1)
+ );
+}
+.pure-button:focus {
+ outline: 0;
+}
+.pure-button-active,
+.pure-button:active {
+ -webkit-box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15) inset,
+ 0 0 6px rgba(0, 0, 0, 0.2) inset;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15) inset,
+ 0 0 6px rgba(0, 0, 0, 0.2) inset;
+ border-color: #000;
+}
+.pure-button-disabled,
+.pure-button-disabled:active,
+.pure-button-disabled:focus,
+.pure-button-disabled:hover,
+.pure-button[disabled] {
+ border: none;
+ background-image: none;
+ opacity: 0.4;
+ cursor: not-allowed;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ pointer-events: none;
+}
+.pure-button-hidden {
+ display: none;
+}
+.pure-button-primary,
+.pure-button-selected,
+a.pure-button-primary,
+a.pure-button-selected {
+ background-color: #0078e7;
+ color: #fff;
+}
+.pure-button-group .pure-button {
+ margin: 0;
+ border-radius: 0;
+ border-right: 1px solid rgba(0, 0, 0, 0.2);
+}
+.pure-button-group .pure-button:first-child {
+ border-top-left-radius: 2px;
+ border-bottom-left-radius: 2px;
+}
+.pure-button-group .pure-button:last-child {
+ border-top-right-radius: 2px;
+ border-bottom-right-radius: 2px;
+ border-right: none;
+}
+.pure-form input[type="color"],
+.pure-form input[type="date"],
+.pure-form input[type="datetime-local"],
+.pure-form input[type="datetime"],
+.pure-form input[type="email"],
+.pure-form input[type="month"],
+.pure-form input[type="number"],
+.pure-form input[type="password"],
+.pure-form input[type="search"],
+.pure-form input[type="tel"],
+.pure-form input[type="text"],
+.pure-form input[type="time"],
+.pure-form input[type="url"],
+.pure-form input[type="week"],
+.pure-form select,
+.pure-form textarea {
+ padding: 0.5em 0.6em;
+ display: inline-block;
+ border: 1px solid #ccc;
+ -webkit-box-shadow: inset 0 1px 3px #ddd;
+ box-shadow: inset 0 1px 3px #ddd;
+ border-radius: 4px;
+ vertical-align: middle;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+.pure-form input:not([type]) {
+ padding: 0.5em 0.6em;
+ display: inline-block;
+ border: 1px solid #ccc;
+ -webkit-box-shadow: inset 0 1px 3px #ddd;
+ box-shadow: inset 0 1px 3px #ddd;
+ border-radius: 4px;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+.pure-form input[type="color"] {
+ padding: 0.2em 0.5em;
+}
+.pure-form input[type="color"]:focus,
+.pure-form input[type="date"]:focus,
+.pure-form input[type="datetime-local"]:focus,
+.pure-form input[type="datetime"]:focus,
+.pure-form input[type="email"]:focus,
+.pure-form input[type="month"]:focus,
+.pure-form input[type="number"]:focus,
+.pure-form input[type="password"]:focus,
+.pure-form input[type="search"]:focus,
+.pure-form input[type="tel"]:focus,
+.pure-form input[type="text"]:focus,
+.pure-form input[type="time"]:focus,
+.pure-form input[type="url"]:focus,
+.pure-form input[type="week"]:focus,
+.pure-form select:focus,
+.pure-form textarea:focus {
+ outline: 0;
+ border-color: #129fea;
+}
+.pure-form input:not([type]):focus {
+ outline: 0;
+ border-color: #129fea;
+}
+.pure-form input[type="checkbox"]:focus,
+.pure-form input[type="file"]:focus,
+.pure-form input[type="radio"]:focus {
+ outline: thin solid #129fea;
+ outline: 1px auto #129fea;
+}
+.pure-form .pure-checkbox,
+.pure-form .pure-radio {
+ margin: 0.5em 0;
+ display: block;
+}
+.pure-form input[type="color"][disabled],
+.pure-form input[type="date"][disabled],
+.pure-form input[type="datetime-local"][disabled],
+.pure-form input[type="datetime"][disabled],
+.pure-form input[type="email"][disabled],
+.pure-form input[type="month"][disabled],
+.pure-form input[type="number"][disabled],
+.pure-form input[type="password"][disabled],
+.pure-form input[type="search"][disabled],
+.pure-form input[type="tel"][disabled],
+.pure-form input[type="text"][disabled],
+.pure-form input[type="time"][disabled],
+.pure-form input[type="url"][disabled],
+.pure-form input[type="week"][disabled],
+.pure-form select[disabled],
+.pure-form textarea[disabled] {
+ cursor: not-allowed;
+ background-color: #eaeded;
+ color: #cad2d3;
+}
+.pure-form input:not([type])[disabled] {
+ cursor: not-allowed;
+ background-color: #eaeded;
+ color: #cad2d3;
+}
+.pure-form input[readonly],
+.pure-form select[readonly],
+.pure-form textarea[readonly] {
+ background-color: #eee;
+ color: #777;
+ border-color: #ccc;
+}
+.pure-form input:focus:invalid,
+.pure-form select:focus:invalid,
+.pure-form textarea:focus:invalid {
+ color: #b94a48;
+ border-color: #e9322d;
+}
+.pure-form input[type="checkbox"]:focus:invalid:focus,
+.pure-form input[type="file"]:focus:invalid:focus,
+.pure-form input[type="radio"]:focus:invalid:focus {
+ outline-color: #e9322d;
+}
+.pure-form select {
+ height: 2.25em;
+ border: 1px solid #ccc;
+ background-color: #fff;
+}
+.pure-form select[multiple] {
+ height: auto;
+}
+.pure-form label {
+ margin: 0.5em 0 0.2em;
+}
+.pure-form fieldset {
+ margin: 0;
+ padding: 0.35em 0 0.75em;
+ border: 0;
+}
+.pure-form legend {
+ display: block;
+ width: 100%;
+ padding: 0.3em 0;
+ margin-bottom: 0.3em;
+ color: #333;
+ border-bottom: 1px solid #e5e5e5;
+}
+.pure-form-stacked input[type="color"],
+.pure-form-stacked input[type="date"],
+.pure-form-stacked input[type="datetime-local"],
+.pure-form-stacked input[type="datetime"],
+.pure-form-stacked input[type="email"],
+.pure-form-stacked input[type="file"],
+.pure-form-stacked input[type="month"],
+.pure-form-stacked input[type="number"],
+.pure-form-stacked input[type="password"],
+.pure-form-stacked input[type="search"],
+.pure-form-stacked input[type="tel"],
+.pure-form-stacked input[type="text"],
+.pure-form-stacked input[type="time"],
+.pure-form-stacked input[type="url"],
+.pure-form-stacked input[type="week"],
+.pure-form-stacked label,
+.pure-form-stacked select,
+.pure-form-stacked textarea {
+ display: block;
+ margin: 0.25em 0;
+}
+.pure-form-stacked input:not([type]) {
+ display: block;
+ margin: 0.25em 0;
+}
+.pure-form-aligned input,
+.pure-form-aligned select,
+.pure-form-aligned textarea,
+.pure-form-message-inline {
+ display: inline-block;
+ vertical-align: middle;
+}
+.pure-form-aligned textarea {
+ vertical-align: top;
+}
+.pure-form-aligned .pure-control-group {
+ margin-bottom: 0.5em;
+}
+.pure-form-aligned .pure-control-group label {
+ text-align: right;
+ display: inline-block;
+ vertical-align: middle;
+ width: 10em;
+ margin: 0 1em 0 0;
+}
+.pure-form-aligned .pure-controls {
+ margin: 1.5em 0 0 11em;
+}
+.pure-form .pure-input-rounded,
+.pure-form input.pure-input-rounded {
+ border-radius: 2em;
+ padding: 0.5em 1em;
+}
+.pure-form .pure-group fieldset {
+ margin-bottom: 10px;
+}
+.pure-form .pure-group input,
+.pure-form .pure-group textarea {
+ display: block;
+ padding: 10px;
+ margin: 0 0 -1px;
+ border-radius: 0;
+ position: relative;
+ top: -1px;
+}
+.pure-form .pure-group input:focus,
+.pure-form .pure-group textarea:focus {
+ z-index: 3;
+}
+.pure-form .pure-group input:first-child,
+.pure-form .pure-group textarea:first-child {
+ top: 1px;
+ border-radius: 4px 4px 0 0;
+ margin: 0;
+}
+.pure-form .pure-group input:first-child:last-child,
+.pure-form .pure-group textarea:first-child:last-child {
+ top: 1px;
+ border-radius: 4px;
+ margin: 0;
+}
+.pure-form .pure-group input:last-child,
+.pure-form .pure-group textarea:last-child {
+ top: -2px;
+ border-radius: 0 0 4px 4px;
+ margin: 0;
+}
+.pure-form .pure-group button {
+ margin: 0.35em 0;
+}
+.pure-form .pure-input-1 {
+ width: 100%;
+}
+.pure-form .pure-input-3-4 {
+ width: 75%;
+}
+.pure-form .pure-input-2-3 {
+ width: 66%;
+}
+.pure-form .pure-input-1-2 {
+ width: 50%;
+}
+.pure-form .pure-input-1-3 {
+ width: 33%;
+}
+.pure-form .pure-input-1-4 {
+ width: 25%;
+}
+.pure-form-message-inline {
+ display: inline-block;
+ padding-left: 0.3em;
+ color: #666;
+ vertical-align: middle;
+ font-size: 0.875em;
+}
+.pure-form-message {
+ display: block;
+ color: #666;
+ font-size: 0.875em;
+}
+@media only screen and (max-width: 480px) {
+ .pure-form button[type="submit"] {
+ margin: 0.7em 0 0;
+ }
+ .pure-form input:not([type]),
+ .pure-form input[type="color"],
+ .pure-form input[type="date"],
+ .pure-form input[type="datetime-local"],
+ .pure-form input[type="datetime"],
+ .pure-form input[type="email"],
+ .pure-form input[type="month"],
+ .pure-form input[type="number"],
+ .pure-form input[type="password"],
+ .pure-form input[type="search"],
+ .pure-form input[type="tel"],
+ .pure-form input[type="text"],
+ .pure-form input[type="time"],
+ .pure-form input[type="url"],
+ .pure-form input[type="week"],
+ .pure-form label {
+ margin-bottom: 0.3em;
+ display: block;
+ }
+ .pure-group input:not([type]),
+ .pure-group input[type="color"],
+ .pure-group input[type="date"],
+ .pure-group input[type="datetime-local"],
+ .pure-group input[type="datetime"],
+ .pure-group input[type="email"],
+ .pure-group input[type="month"],
+ .pure-group input[type="number"],
+ .pure-group input[type="password"],
+ .pure-group input[type="search"],
+ .pure-group input[type="tel"],
+ .pure-group input[type="text"],
+ .pure-group input[type="time"],
+ .pure-group input[type="url"],
+ .pure-group input[type="week"] {
+ margin-bottom: 0;
+ }
+ .pure-form-aligned .pure-control-group label {
+ margin-bottom: 0.3em;
+ text-align: left;
+ display: block;
+ width: 100%;
+ }
+ .pure-form-aligned .pure-controls {
+ margin: 1.5em 0 0 0;
+ }
+ .pure-form-message,
+ .pure-form-message-inline {
+ display: block;
+ font-size: 0.75em;
+ padding: 0.2em 0 0.8em;
+ }
+}
+.pure-menu {
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+.pure-menu-fixed {
+ position: fixed;
+ left: 0;
+ top: 0;
+ z-index: 3;
+}
+.pure-menu-item,
+.pure-menu-list {
+ position: relative;
+}
+.pure-menu-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.pure-menu-item {
+ padding: 0;
+ margin: 0;
+ height: 100%;
+}
+.pure-menu-heading,
+.pure-menu-link {
+ display: block;
+ text-decoration: none;
+ white-space: nowrap;
+}
+.pure-menu-horizontal {
+ width: 100%;
+ white-space: nowrap;
+}
+.pure-menu-horizontal .pure-menu-list {
+ display: inline-block;
+}
+.pure-menu-horizontal .pure-menu-heading,
+.pure-menu-horizontal .pure-menu-item,
+.pure-menu-horizontal .pure-menu-separator {
+ display: inline-block;
+ vertical-align: middle;
+}
+.pure-menu-item .pure-menu-item {
+ display: block;
+}
+.pure-menu-children {
+ display: none;
+ position: absolute;
+ left: 100%;
+ top: 0;
+ margin: 0;
+ padding: 0;
+ z-index: 3;
+}
+.pure-menu-horizontal .pure-menu-children {
+ left: 0;
+ top: auto;
+ width: inherit;
+}
+.pure-menu-active > .pure-menu-children,
+.pure-menu-allow-hover:hover > .pure-menu-children {
+ display: block;
+ position: absolute;
+}
+.pure-menu-has-children > .pure-menu-link:after {
+ padding-left: 0.5em;
+ content: "\25B8";
+ font-size: small;
+}
+.pure-menu-horizontal .pure-menu-has-children > .pure-menu-link:after {
+ content: "\25BE";
+}
+.pure-menu-scrollable {
+ overflow-y: scroll;
+ overflow-x: hidden;
+}
+.pure-menu-scrollable .pure-menu-list {
+ display: block;
+}
+.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list {
+ display: inline-block;
+}
+.pure-menu-horizontal.pure-menu-scrollable {
+ white-space: nowrap;
+ overflow-y: hidden;
+ overflow-x: auto;
+ padding: 0.5em 0;
+}
+.pure-menu-horizontal .pure-menu-children .pure-menu-separator,
+.pure-menu-separator {
+ background-color: #ccc;
+ height: 1px;
+ margin: 0.3em 0;
+}
+.pure-menu-horizontal .pure-menu-separator {
+ width: 1px;
+ height: 1.3em;
+ margin: 0 0.3em;
+}
+.pure-menu-horizontal .pure-menu-children .pure-menu-separator {
+ display: block;
+ width: auto;
+}
+.pure-menu-heading {
+ text-transform: uppercase;
+ color: #565d64;
+}
+.pure-menu-link {
+ color: #777;
+}
+.pure-menu-children {
+ background-color: #fff;
+}
+.pure-menu-disabled,
+.pure-menu-heading,
+.pure-menu-link {
+ padding: 0.5em 1em;
+}
+.pure-menu-disabled {
+ opacity: 0.5;
+}
+.pure-menu-disabled .pure-menu-link:hover {
+ background-color: transparent;
+}
+.pure-menu-active > .pure-menu-link,
+.pure-menu-link:focus,
+.pure-menu-link:hover {
+ background-color: #eee;
+}
+.pure-menu-selected > .pure-menu-link,
+.pure-menu-selected > .pure-menu-link:visited {
+ color: #000;
+}
+.pure-table {
+ border-collapse: collapse;
+ border-spacing: 0;
+ empty-cells: show;
+ border: 1px solid #cbcbcb;
+}
+.pure-table caption {
+ color: #000;
+ font: italic 85%/1 arial, sans-serif;
+ padding: 1em 0;
+ text-align: center;
+}
+.pure-table td,
+.pure-table th {
+ border-left: 1px solid #cbcbcb;
+ border-width: 0 0 0 1px;
+ font-size: inherit;
+ margin: 0;
+ overflow: visible;
+ padding: 0.5em 1em;
+}
+.pure-table thead {
+ background-color: #e0e0e0;
+ color: #000;
+ text-align: left;
+ vertical-align: bottom;
+}
+.pure-table td {
+ background-color: transparent;
+}
+.pure-table-odd td {
+ background-color: #f2f2f2;
+}
+.pure-table-striped tr:nth-child(2n-1) td {
+ background-color: #f2f2f2;
+}
+.pure-table-bordered td {
+ border-bottom: 1px solid #cbcbcb;
+}
+.pure-table-bordered tbody > tr:last-child > td {
+ border-bottom-width: 0;
+}
+.pure-table-horizontal td,
+.pure-table-horizontal th {
+ border-width: 0 0 1px 0;
+ border-bottom: 1px solid #cbcbcb;
+}
+.pure-table-horizontal tbody > tr:last-child > td {
+ border-bottom-width: 0;
+}
diff --git a/packages/merchant-backend-ui/src/css/style.css b/packages/merchant-backend-ui/src/css/style.css
new file mode 100644
index 000000000..f24dbaa87
--- /dev/null
+++ b/packages/merchant-backend-ui/src/css/style.css
@@ -0,0 +1,61 @@
+/*!
+ Pure v2.0.3
+ Copyright 2013 Yahoo!
+ Licensed under the BSD License.
+ https://github.com/pure-ss/pure/blob/master/LICENSE.md
+*/
+/*!
+ normalize.cs v | MIT License | git.io/normalize
+ Copyright (c) Nicolas Gallagher and Jonathan Neal
+*/
+/*! normalize.ss v8.0.1 | MIT License | github.com/necolas/normalize.cs */
+
+.talerbar {
+ text-align: center;
+}
+.tt {
+ font-family: "Lucida Console", Monaco, monospace;
+}
+.content {
+ overflow-x: auto;
+ padding-left: 15%;
+ padding-right: 15%;
+}
+.qr {
+ margin: auto;
+ text-align: center;
+}
+.qrtext {
+ width: max-content;
+ margin: auto;
+ transition: font-size 0.2s;
+ font-family: "Lucida Console", Monaco, monospace;
+ font-size: 0.5em;
+}
+.qrtext:hover {
+ font-size: 1em;
+}
+.talerbar {
+ margin: 0;
+ bottom: 0;
+ background-color: #033;
+ color: white;
+ width: 100%;
+ padding: 1em;
+ overflow: auto;
+}
+body {
+ overflow-y: scroll;
+}
+@media (min-width: 500px) {
+ .content {
+ padding-bottom: 2em;
+ overflow-y: auto;
+ }
+}
+#main a:link,
+#main a:visited,
+#main a:hover,
+#main a:active {
+ color: black;
+} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/hooks/useLang.ts b/packages/merchant-backend-ui/src/custom.d.ts
index 70b9614f6..d2705003b 100644
--- a/packages/taler-wallet-webextension/src/hooks/useLang.ts
+++ b/packages/merchant-backend-ui/src/custom.d.ts
@@ -13,11 +13,28 @@
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+declare module '*.po' {
+ const content: any;
+ export default content;
+}
+declare module 'jed' {
+ const x: any;
+ export = x;
+}
+declare module "*.jpeg" {
+ const content: any;
+ export default content;
+}
+declare module "*.png" {
+ const content: any;
+ export default content;
+}
+declare module '*.svg' {
+ const content: any;
+ export default content;
+}
-import { useNotNullLocalStorage } from './useLocalStorage';
-
-export function useLang(initial?: string): [string, (s:string) => void] {
- const browserLang: string | undefined = typeof window !== "undefined" ? navigator.language || (navigator as any).userLanguage : undefined;
- const defaultLang = (browserLang || initial || 'en').substring(0, 2)
- return useNotNullLocalStorage('lang-preference', defaultLang)
+declare module '*.scss' {
+ const content: Record<string, string>;
+ export default content;
}
diff --git a/packages/merchant-backend-ui/src/declaration.d.ts b/packages/merchant-backend-ui/src/declaration.d.ts
new file mode 100644
index 000000000..bb71f61cd
--- /dev/null
+++ b/packages/merchant-backend-ui/src/declaration.d.ts
@@ -0,0 +1,1387 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+
+
+type HashCode = string;
+type EddsaPublicKey = string;
+type EddsaSignature = string;
+type WireTransferIdentifierRawP = string;
+type RelativeTime = Duration;
+type ImageDataUrl = string;
+
+export interface WithId {
+ id: string
+}
+
+interface Timestamp {
+ // Milliseconds since epoch, or the special
+ // value "forever" to represent an event that will
+ // never happen.
+ t_s: number | "never";
+}
+interface Duration {
+ // Duration in milliseconds or "forever"
+ // to represent an infinite duration.
+ d_us: number | "forever";
+}
+
+interface WithId {
+ id: string;
+}
+
+type Amount = string;
+type UUID = string;
+type Integer = number;
+type TalerProtocolTimestamp = {
+ t_s: number | "never"
+}
+export namespace ExchangeBackend {
+ interface WireResponse {
+
+ // Master public key of the exchange, must match the key returned in /keys.
+ master_public_key: EddsaPublicKey;
+
+ // Array of wire accounts operated by the exchange for
+ // incoming wire transfers.
+ accounts: WireAccount[];
+
+ // Object mapping names of wire methods (i.e. "sepa" or "x-taler-bank")
+ // to wire fees.
+ fees: { method: AggregateTransferFee };
+ }
+ interface WireAccount {
+ // payto:// URI identifying the account and wire method
+ payto_uri: string;
+
+ // Signature using the exchange's offline key
+ // with purpose TALER_SIGNATURE_MASTER_WIRE_DETAILS.
+ master_sig: EddsaSignature;
+ }
+ interface AggregateTransferFee {
+ // Per transfer wire transfer fee.
+ wire_fee: Amount;
+
+ // Per transfer closing fee.
+ closing_fee: Amount;
+
+ // What date (inclusive) does this fee go into effect?
+ // The different fees must cover the full time period in which
+ // any of the denomination keys are valid without overlap.
+ start_date: TalerProtocolTimestamp;
+
+ // What date (exclusive) does this fee stop going into effect?
+ // The different fees must cover the full time period in which
+ // any of the denomination keys are valid without overlap.
+ end_date: TalerProtocolTimestamp;
+
+ // Signature of TALER_MasterWireFeePS with
+ // purpose TALER_SIGNATURE_MASTER_WIRE_FEES.
+ sig: EddsaSignature;
+ }
+
+}
+export namespace MerchantBackend {
+ interface ErrorDetail {
+
+ // Numeric error code unique to the condition.
+ // The other arguments are specific to the error value reported here.
+ code: number;
+
+ // Human-readable description of the error, i.e. "missing parameter", "commitment violation", ...
+ // Should give a human-readable hint about the error's nature. Optional, may change without notice!
+ hint?: string;
+
+ // Optional detail about the specific input value that failed. May change without notice!
+ detail?: string;
+
+ // Name of the parameter that was bogus (if applicable).
+ parameter?: string;
+
+ // Path to the argument that was bogus (if applicable).
+ path?: string;
+
+ // Offset of the argument that was bogus (if applicable).
+ offset?: string;
+
+ // Index of the argument that was bogus (if applicable).
+ index?: string;
+
+ // Name of the object that was bogus (if applicable).
+ object?: string;
+
+ // Name of the currency than was problematic (if applicable).
+ currency?: string;
+
+ // Expected type (if applicable).
+ type_expected?: string;
+
+ // Type that was provided instead (if applicable).
+ type_actual?: string;
+ }
+
+
+ // Delivery location, loosely modeled as a subset of
+ // ISO20022's PostalAddress25.
+ interface Tax {
+ // the name of the tax
+ name: string;
+
+ // amount paid in tax
+ tax: Amount;
+ }
+
+ interface Auditor {
+ // official name
+ name: string;
+
+ // Auditor's public key
+ auditor_pub: EddsaPublicKey;
+
+ // Base URL of the auditor
+ url: string;
+ }
+ interface Exchange {
+ // the exchange's base URL
+ url: string;
+
+ // master public key of the exchange
+ master_pub: EddsaPublicKey;
+ }
+
+ interface Product {
+ // merchant-internal identifier for the product.
+ product_id?: string;
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n?: { [lang_tag: string]: string };
+
+ // The number of units of the product to deliver to the customer.
+ quantity: Integer;
+
+ // The unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit: string;
+
+ // The price of the product; this is the total price for quantity times unit of this product.
+ price: Amount;
+
+ // An optional base64-encoded product image
+ image: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for this product. Can be empty.
+ taxes: Tax[];
+
+ // time indicating when this product should be delivered
+ delivery_date?: Timestamp;
+ }
+ interface Merchant {
+ // label for a location with the business address of the merchant
+ address: Location;
+
+ // the merchant's legal name of business
+ name: string;
+
+ // label for a location that denotes the jurisdiction for disputes.
+ // Some of the typical fields for a location (such as a street address) may be absent.
+ jurisdiction: Location;
+ }
+
+ interface VersionResponse {
+ // libtool-style representation of the Merchant protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // Name of the protocol.
+ name: "taler-merchant";
+
+ // Currency supported by this backend.
+ currency: string;
+
+ }
+ interface Location {
+ // Nation with its own government.
+ country?: string;
+
+ // Identifies a subdivision of a country such as state, region, county.
+ country_subdivision?: string;
+
+ // Identifies a subdivision within a country sub-division.
+ district?: string;
+
+ // Name of a built-up area, with defined boundaries, and a local government.
+ town?: string;
+
+ // Specific location name within the town.
+ town_location?: string;
+
+ // Identifier consisting of a group of letters and/or numbers that
+ // is added to a postal address to assist the sorting of mail.
+ post_code?: string;
+
+ // Name of a street or thoroughfare.
+ street?: string;
+
+ // Name of the building or house.
+ building_name?: string;
+
+ // Number that identifies the position of a building on a street.
+ building_number?: string;
+
+ // Free-form address lines, should not exceed 7 elements.
+ address_lines?: string[];
+ }
+ namespace Instances {
+
+ //POST /private/instances/$INSTANCE/auth
+ interface InstanceAuthConfigurationMessage {
+ // Type of authentication.
+ // "external": The mechant backend does not do
+ // any authentication checks. Instead an API
+ // gateway must do the authentication.
+ // "token": The merchant checks an auth token.
+ // See "token" for details.
+ method: "external" | "token";
+
+ // For method "external", this field is mandatory.
+ // The token MUST begin with the string "secret-token:".
+ // After the auth token has been set (with method "token"),
+ // the value must be provided in a "Authorization: Bearer $token"
+ // header.
+ token?: string;
+
+ }
+ //POST /private/instances
+ interface InstanceConfigurationMessage {
+ // The URI where the wallet will send coins. A merchant may have
+ // multiple accounts, thus this is an array. Note that by
+ // removing URIs from this list the respective account is set to
+ // inactive and thus unavailable for new contracts, but preserved
+ // in the database as existing offers and contracts may still refer
+ // to it.
+ payto_uris: string[];
+
+ // Name of the merchant instance to create (will become $INSTANCE).
+ id: string;
+
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // "Authentication" header required to authorize management access the instance.
+ // Optional, if not given authentication will be disabled for
+ // this instance (hopefully authentication checks are still
+ // done by some reverse proxy).
+ auth: InstanceAuthConfigurationMessage;
+
+ // The merchant's physical address (to be put into contracts).
+ address: Location;
+
+ // The jurisdiction under which the merchant conducts its business
+ // (to be put into contracts).
+ jurisdiction: Location;
+
+ // Maximum wire fee this instance is willing to pay.
+ // Can be overridden by the frontend on a per-order basis.
+ default_max_wire_fee: Amount;
+
+ // Default factor for wire fee amortization calculations.
+ // Can be overridden by the frontend on a per-order basis.
+ default_wire_fee_amortization: Integer;
+
+ // Maximum deposit fee (sum over all coins) this instance is willing to pay.
+ // Can be overridden by the frontend on a per-order basis.
+ default_max_deposit_fee: Amount;
+
+ // If the frontend does NOT specify an execution date, how long should
+ // we tell the exchange to wait to aggregate transactions before
+ // executing the wire transfer? This delay is added to the current
+ // time when we generate the advisory execution time for the exchange.
+ default_wire_transfer_delay: RelativeTime;
+
+ // If the frontend does NOT specify a payment deadline, how long should
+ // offers we make be valid by default?
+ default_pay_delay: RelativeTime;
+
+ }
+
+ // PATCH /private/instances/$INSTANCE
+ interface InstanceReconfigurationMessage {
+ // The URI where the wallet will send coins. A merchant may have
+ // multiple accounts, thus this is an array. Note that by
+ // removing URIs from this list
+ payto_uris: string[];
+
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // The merchant's physical address (to be put into contracts).
+ address: Location;
+
+ // The jurisdiction under which the merchant conducts its business
+ // (to be put into contracts).
+ jurisdiction: Location;
+
+ // Maximum wire fee this instance is willing to pay.
+ // Can be overridden by the frontend on a per-order basis.
+ default_max_wire_fee: Amount;
+
+ // Default factor for wire fee amortization calculations.
+ // Can be overridden by the frontend on a per-order basis.
+ default_wire_fee_amortization: Integer;
+
+ // Maximum deposit fee (sum over all coins) this instance is willing to pay.
+ // Can be overridden by the frontend on a per-order basis.
+ default_max_deposit_fee: Amount;
+
+ // If the frontend does NOT specify an execution date, how long should
+ // we tell the exchange to wait to aggregate transactions before
+ // executing the wire transfer? This delay is added to the current
+ // time when we generate the advisory execution time for the exchange.
+ default_wire_transfer_delay: RelativeTime;
+
+ // If the frontend does NOT specify a payment deadline, how long should
+ // offers we make be valid by default?
+ default_pay_delay: RelativeTime;
+
+ }
+
+ // GET /private/instances
+ interface InstancesResponse {
+ // List of instances that are present in the backend (see Instance)
+ instances: Instance[];
+ }
+
+ interface Instance {
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ deleted?: boolean;
+
+ // Merchant instance this response is about ($INSTANCE)
+ id: string;
+
+ // Public key of the merchant/instance, in Crockford Base32 encoding.
+ merchant_pub: EddsaPublicKey;
+
+ // List of the payment targets supported by this instance. Clients can
+ // specify the desired payment target in /order requests. Note that
+ // front-ends do not have to support wallets selecting payment targets.
+ payment_targets: string[];
+
+ }
+
+ //GET /private/instances/$INSTANCE
+ interface QueryInstancesResponse {
+ // The URI where the wallet will send coins. A merchant may have
+ // multiple accounts, thus this is an array.
+ accounts: MerchantAccount[];
+
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // Public key of the merchant/instance, in Crockford Base32 encoding.
+ merchant_pub: EddsaPublicKey;
+
+ // The merchant's physical address (to be put into contracts).
+ address: Location;
+
+ // The jurisdiction under which the merchant conducts its business
+ // (to be put into contracts).
+ jurisdiction: Location;
+
+ // Maximum wire fee this instance is willing to pay.
+ // Can be overridden by the frontend on a per-order basis.
+ default_max_wire_fee: Amount;
+
+ // Default factor for wire fee amortization calculations.
+ // Can be overridden by the frontend on a per-order basis.
+ default_wire_fee_amortization: Integer;
+
+ // Maximum deposit fee (sum over all coins) this instance is willing to pay.
+ // Can be overridden by the frontend on a per-order basis.
+ default_max_deposit_fee: Amount;
+
+ // If the frontend does NOT specify an execution date, how long should
+ // we tell the exchange to wait to aggregate transactions before
+ // executing the wire transfer? This delay is added to the current
+ // time when we generate the advisory execution time for the exchange.
+ default_wire_transfer_delay: RelativeTime;
+
+ // If the frontend does NOT specify a payment deadline, how long should
+ // offers we make be valid by default?
+ default_pay_delay: RelativeTime;
+
+ // Authentication configuration.
+ // Does not contain the token when token auth is configured.
+ auth: {
+ method: "external" | "token";
+ };
+ }
+
+ interface MerchantAccount {
+
+ // payto:// URI of the account.
+ payto_uri: string;
+
+ // Hash over the wire details (including over the salt)
+ h_wire: HashCode;
+
+ // salt used to compute h_wire
+ salt: HashCode;
+
+ // true if this account is active,
+ // false if it is historic.
+ active: boolean;
+ }
+
+ // DELETE /private/instances/$INSTANCE
+
+
+ }
+
+ namespace Products {
+ // POST /private/products
+ interface ProductAddDetail {
+
+ // product ID to use.
+ product_id: string;
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n: { [lang_tag: string]: string };
+
+ // unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: Amount;
+
+ // An optional base64-encoded product image
+ image: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for one unit of this product
+ taxes: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Identifies where the product is in stock.
+ address: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ }
+ // PATCH /private/products/$PRODUCT_ID
+ interface ProductPatchDetail {
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n: { [lang_tag: string]: string };
+
+ // unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: Amount;
+
+ // An optional base64-encoded product image
+ image: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for one unit of this product
+ taxes: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Number of units of the product that were lost (spoiled, stolen, etc.)
+ total_lost: Integer;
+
+ // Identifies where the product is in stock.
+ address: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ }
+
+ // GET /private/products
+ interface InventorySummaryResponse {
+ // List of products that are present in the inventory
+ products: InventoryEntry[];
+ }
+ interface InventoryEntry {
+ // Product identifier, as found in the product.
+ product_id: string;
+
+ }
+
+ // GET /private/products/$PRODUCT_ID
+ interface ProductDetail {
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n: { [lang_tag: string]: string };
+
+ // unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: Amount;
+
+ // An optional base64-encoded product image
+ image: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for one unit of this product
+ taxes: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Number of units of the product that have already been sold.
+ total_sold: Integer;
+
+ // Number of units of the product that were lost (spoiled, stolen, etc.)
+ total_lost: Integer;
+
+ // Identifies where the product is in stock.
+ address: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ }
+
+ // POST /private/products/$PRODUCT_ID/lock
+ interface LockRequest {
+
+ // UUID that identifies the frontend performing the lock
+ // It is suggested that clients use a timeflake for this,
+ // see https://github.com/anthonynsimon/timeflake
+ lock_uuid: UUID;
+
+ // How long does the frontend intend to hold the lock
+ duration: RelativeTime;
+
+ // How many units should be locked?
+ quantity: Integer;
+
+ }
+
+ // DELETE /private/products/$PRODUCT_ID
+
+ }
+
+ namespace Orders {
+
+ type MerchantOrderStatusResponse = CheckPaymentPaidResponse |
+ CheckPaymentClaimedResponse |
+ CheckPaymentUnpaidResponse;
+ interface CheckPaymentPaidResponse {
+ // The customer paid for this contract.
+ order_status: "paid";
+
+ // Was the payment refunded (even partially)?
+ refunded: boolean;
+
+ // True if there are any approved refunds that the wallet has
+ // not yet obtained.
+ refund_pending: boolean;
+
+ // Did the exchange wire us the funds?
+ wired: boolean;
+
+ // Total amount the exchange deposited into our bank account
+ // for this contract, excluding fees.
+ deposit_total: Amount;
+
+ // Numeric error code indicating errors the exchange
+ // encountered tracking the wire transfer for this purchase (before
+ // we even got to specific coin issues).
+ // 0 if there were no issues.
+ exchange_ec: number;
+
+ // HTTP status code returned by the exchange when we asked for
+ // information to track the wire transfer for this purchase.
+ // 0 if there were no issues.
+ exchange_hc: number;
+
+ // Total amount that was refunded, 0 if refunded is false.
+ refund_amount: Amount;
+
+ // Contract terms.
+ contract_terms: ContractTerms;
+
+ // The wire transfer status from the exchange for this order if
+ // available, otherwise empty array.
+ wire_details: TransactionWireTransfer[];
+
+ // Reports about trouble obtaining wire transfer details,
+ // empty array if no trouble were encountered.
+ wire_reports: TransactionWireReport[];
+
+ // The refund details for this order. One entry per
+ // refunded coin; empty array if there are no refunds.
+ refund_details: RefundDetails[];
+
+ // Status URL, can be used as a redirect target for the browser
+ // to show the order QR code / trigger the wallet.
+ order_status_url: string;
+ }
+ interface CheckPaymentClaimedResponse {
+ // A wallet claimed the order, but did not yet pay for the contract.
+ order_status: "claimed";
+
+ // Contract terms.
+ contract_terms: ContractTerms;
+
+ }
+ interface CheckPaymentUnpaidResponse {
+ // The order was neither claimed nor paid.
+ order_status: "unpaid";
+
+ // when was the order created
+ creation_time: Timestamp;
+
+ // Order summary text.
+ summary: string;
+
+ // Total amount of the order (to be paid by the customer).
+ total_amount: Amount;
+
+ // URI that the wallet must process to complete the payment.
+ taler_pay_uri: string;
+
+ // Alternative order ID which was paid for already in the same session.
+ // Only given if the same product was purchased before in the same session.
+ already_paid_order_id?: string;
+
+ // Fulfillment URL of an already paid order. Only given if under this
+ // session an already paid order with a fulfillment URL exists.
+ already_paid_fulfillment_url?: string;
+
+ // Status URL, can be used as a redirect target for the browser
+ // to show the order QR code / trigger the wallet.
+ order_status_url: string;
+
+ // We do we NOT return the contract terms here because they may not
+ // exist in case the wallet did not yet claim them.
+ }
+ interface RefundDetails {
+ // Reason given for the refund.
+ reason: string;
+
+ // When was the refund approved.
+ timestamp: Timestamp;
+
+ // Total amount that was refunded (minus a refund fee).
+ amount: Amount;
+ }
+ interface TransactionWireTransfer {
+ // Responsible exchange.
+ exchange_url: string;
+
+ // 32-byte wire transfer identifier.
+ wtid: Base32;
+
+ // Execution time of the wire transfer.
+ execution_time: Timestamp;
+
+ // Total amount that has been wire transferred
+ // to the merchant.
+ amount: Amount;
+
+ // Was this transfer confirmed by the merchant via the
+ // POST /transfers API, or is it merely claimed by the exchange?
+ confirmed: boolean;
+ }
+ interface TransactionWireReport {
+ // Numerical error code.
+ code: number;
+
+ // Human-readable error description.
+ hint: string;
+
+ // Numerical error code from the exchange.
+ exchange_ec: number;
+
+ // HTTP status code received from the exchange.
+ exchange_hc: number;
+
+ // Public key of the coin for which we got the exchange error.
+ coin_pub: CoinPublicKey;
+ }
+
+ interface OrderHistory {
+ // timestamp-sorted array of all orders matching the query.
+ // The order of the sorting depends on the sign of delta.
+ orders: OrderHistoryEntry[];
+ }
+ interface OrderHistoryEntry {
+
+ // order ID of the transaction related to this entry.
+ order_id: string;
+
+ // row ID of the order in the database
+ row_id: number;
+
+ // when the order was created
+ timestamp: Timestamp;
+
+ // the amount of money the order is for
+ amount: Amount;
+
+ // the summary of the order
+ summary: string;
+
+ // whether some part of the order is refundable,
+ // that is the refund deadline has not yet expired
+ // and the total amount refunded so far is below
+ // the value of the original transaction.
+ refundable: boolean;
+
+ // whether the order has been paid or not
+ paid: boolean;
+ }
+
+ interface PostOrderRequest {
+ // The order must at least contain the minimal
+ // order detail, but can override all
+ order: Order;
+
+ // if set, the backend will then set the refund deadline to the current
+ // time plus the specified delay. If it's not set, refunds will not be
+ // possible.
+ refund_delay?: RelativeTime;
+
+ // specifies the payment target preferred by the client. Can be used
+ // to select among the various (active) wire methods supported by the instance.
+ payment_target?: string;
+
+ // specifies that some products are to be included in the
+ // order from the inventory. For these inventory management
+ // is performed (so the products must be in stock) and
+ // details are completed from the product data of the backend.
+ inventory_products?: MinimalInventoryProduct[];
+
+ // Specifies a lock identifier that was used to
+ // lock a product in the inventory. Only useful if
+ // manage_inventory is set. Used in case a frontend
+ // reserved quantities of the individual products while
+ // the shopping card was being built. Multiple UUIDs can
+ // be used in case different UUIDs were used for different
+ // products (i.e. in case the user started with multiple
+ // shopping sessions that were combined during checkout).
+ lock_uuids?: UUID[];
+
+ // Should a token for claiming the order be generated?
+ // False can make sense if the ORDER_ID is sufficiently
+ // high entropy to prevent adversarial claims (like it is
+ // if the backend auto-generates one). Default is 'true'.
+ create_token?: boolean;
+
+ }
+ type Order = MinimalOrderDetail | ContractTerms;
+
+ interface MinimalOrderDetail {
+ // Amount to be paid by the customer
+ amount: Amount;
+
+ // Short summary of the order
+ summary: string;
+
+ // URL that will show that the order was successful after
+ // it has been paid for. Optional. When POSTing to the
+ // merchant, the placeholder "${ORDER_ID}" will be
+ // replaced with the actual order ID (useful if the
+ // order ID is generated server-side and needs to be
+ // in the URL).
+ fulfillment_url?: string;
+ }
+
+ interface MinimalInventoryProduct {
+ // Which product is requested (here mandatory!)
+ product_id: string;
+
+ // How many units of the product are requested
+ quantity: Integer;
+ }
+ interface PostOrderResponse {
+ // Order ID of the response that was just created
+ order_id: string;
+
+ // Token that authorizes the wallet to claim the order.
+ // Provided only if "create_token" was set to 'true'
+ // in the request.
+ token?: ClaimToken;
+ }
+ interface OutOfStockResponse {
+
+ // Product ID of an out-of-stock item
+ product_id: string;
+
+ // Requested quantity
+ requested_quantity: Integer;
+
+ // Available quantity (must be below requested_quanitity)
+ available_quantity: Integer;
+
+ // When do we expect the product to be again in stock?
+ // Optional, not given if unknown.
+ restock_expected?: Timestamp;
+ }
+
+ interface ForgetRequest {
+
+ // Array of valid JSON paths to forgettable fields in the order's
+ // contract terms.
+ fields: string[];
+ }
+ interface RefundRequest {
+ // Amount to be refunded
+ refund: Amount;
+
+ // Human-readable refund justification
+ reason: string;
+ }
+ interface MerchantRefundResponse {
+
+ // URL (handled by the backend) that the wallet should access to
+ // trigger refund processing.
+ // taler://refund/...
+ taler_refund_uri: string;
+
+ // Contract hash that a client may need to authenticate an
+ // HTTP request to obtain the above URI in a wallet-friendly way.
+ h_contract: HashCode;
+ }
+
+ }
+
+ namespace Tips {
+
+ // GET /private/reserves
+ interface TippingReserveStatus {
+ // Array of all known reserves (possibly empty!)
+ reserves: ReserveStatusEntry[];
+ }
+ interface ReserveStatusEntry {
+ // Public key of the reserve
+ reserve_pub: EddsaPublicKey;
+
+ // Timestamp when it was established
+ creation_time: Timestamp;
+
+ // Timestamp when it expires
+ expiration_time: Timestamp;
+
+ // Initial amount as per reserve creation call
+ merchant_initial_amount: Amount;
+
+ // Initial amount as per exchange, 0 if exchange did
+ // not confirm reserve creation yet.
+ exchange_initial_amount: Amount;
+
+ // Amount picked up so far.
+ pickup_amount: Amount;
+
+ // Amount approved for tips that exceeds the pickup_amount.
+ committed_amount: Amount;
+
+ // Is this reserve active (false if it was deleted but not purged)
+ active: boolean;
+ }
+
+ interface ReserveCreateRequest {
+ // Amount that the merchant promises to put into the reserve
+ initial_balance: Amount;
+
+ // Exchange the merchant intends to use for tipping
+ exchange_url: string;
+
+ // Desired wire method, for example "iban" or "x-taler-bank"
+ wire_method: string;
+ }
+ interface ReserveCreateConfirmation {
+ // Public key identifying the reserve
+ reserve_pub: EddsaPublicKey;
+
+ // Wire account of the exchange where to transfer the funds
+ payto_uri: string;
+ }
+ interface TipCreateRequest {
+ // Amount that the customer should be tipped
+ amount: Amount;
+
+ // Justification for giving the tip
+ justification: string;
+
+ // URL that the user should be directed to after tipping,
+ // will be included in the tip_token.
+ next_url: string;
+ }
+ interface TipCreateConfirmation {
+ // Unique tip identifier for the tip that was created.
+ tip_id: HashCode;
+
+ // taler://tip URI for the tip
+ taler_tip_uri: string;
+
+ // URL that will directly trigger processing
+ // the tip when the browser is redirected to it
+ tip_status_url: string;
+
+ // when does the tip expire
+ tip_expiration: Timestamp;
+ }
+
+ interface ReserveDetail {
+ // Timestamp when it was established.
+ creation_time: Timestamp;
+
+ // Timestamp when it expires.
+ expiration_time: Timestamp;
+
+ // Initial amount as per reserve creation call.
+ merchant_initial_amount: Amount;
+
+ // Initial amount as per exchange, 0 if exchange did
+ // not confirm reserve creation yet.
+ exchange_initial_amount: Amount;
+
+ // Amount picked up so far.
+ pickup_amount: Amount;
+
+ // Amount approved for tips that exceeds the pickup_amount.
+ committed_amount: Amount;
+
+ // Array of all tips created by this reserves (possibly empty!).
+ // Only present if asked for explicitly.
+ tips?: TipStatusEntry[];
+
+ // Is this reserve active (false if it was deleted but not purged)?
+ active: boolean;
+
+ // URI to use to fill the reserve, can be NULL
+ // if the reserve is inactive or was already filled
+ payto_uri: string;
+
+ // URL of the exchange hosting the reserve,
+ // NULL if the reserve is inactive
+ exchange_url: string;
+
+ }
+
+ interface TipStatusEntry {
+
+ // Unique identifier for the tip.
+ tip_id: HashCode;
+
+ // Total amount of the tip that can be withdrawn.
+ total_amount: Amount;
+
+ // Human-readable reason for why the tip was granted.
+ reason: string;
+ }
+
+ interface TipDetails {
+ // Amount that we authorized for this tip.
+ total_authorized: Amount;
+
+ // Amount that was picked up by the user already.
+ total_picked_up: Amount;
+
+ // Human-readable reason given when authorizing the tip.
+ reason: string;
+
+ // Timestamp indicating when the tip is set to expire (may be in the past).
+ expiration: TalerProtocolTimestamp;
+
+ // Reserve public key from which the tip is funded.
+ reserve_pub: EddsaPublicKey;
+
+ // Array showing the pickup operations of the wallet (possibly empty!).
+ // Only present if asked for explicitly.
+ pickups?: PickupDetail[];
+ }
+ interface PickupDetail {
+ // Unique identifier for the pickup operation.
+ pickup_id: HashCode;
+
+ // Number of planchets involved.
+ num_planchets: Integer;
+
+ // Total amount requested for this pickup_id.
+ requested_amount: Amount;
+ }
+
+ }
+
+ namespace Transfers {
+
+ interface TransferList {
+ // list of all the transfers that fit the filter that we know
+ transfers: TransferDetails[];
+ }
+ interface TransferDetails {
+ // how much was wired to the merchant (minus fees)
+ credit_amount: Amount;
+
+ // raw wire transfer identifier identifying the wire transfer (a base32-encoded value)
+ wtid: string;
+
+ // target account that received the wire transfer
+ payto_uri: string;
+
+ // base URL of the exchange that made the wire transfer
+ exchange_url: string;
+
+ // Serial number identifying the transfer in the merchant backend.
+ // Used for filgering via offset.
+ transfer_serial_id: number;
+
+ // Time of the execution of the wire transfer by the exchange, according to the exchange
+ // Only provided if we did get an answer from the exchange.
+ execution_time?: Timestamp;
+
+ // True if we checked the exchange's answer and are happy with it.
+ // False if we have an answer and are unhappy, missing if we
+ // do not have an answer from the exchange.
+ verified?: boolean;
+
+ // True if the merchant uses the POST /transfers API to confirm
+ // that this wire transfer took place (and it is thus not
+ // something merely claimed by the exchange).
+ confirmed?: boolean;
+ }
+
+ interface TransferInformation {
+ // how much was wired to the merchant (minus fees)
+ credit_amount: Amount;
+
+ // raw wire transfer identifier identifying the wire transfer (a base32-encoded value)
+ wtid: WireTransferIdentifierRawP;
+
+ // target account that received the wire transfer
+ payto_uri: string;
+
+ // base URL of the exchange that made the wire transfer
+ exchange_url: string;
+ }
+ interface MerchantTrackTransferResponse {
+ // Total amount transferred
+ total: Amount;
+
+ // Applicable wire fee that was charged
+ wire_fee: Amount;
+
+ // Time of the execution of the wire transfer by the exchange, according to the exchange
+ execution_time: Timestamp;
+
+ // details about the deposits
+ deposits_sums: MerchantTrackTransferDetail[];
+ }
+ interface MerchantTrackTransferDetail {
+ // Business activity associated with the wire transferred amount
+ // deposit_value.
+ order_id: string;
+
+ // The total amount the exchange paid back for order_id.
+ deposit_value: Amount;
+
+ // applicable fees for the deposit
+ deposit_fee: Amount;
+ }
+
+ type ExchangeConflictDetails = WireFeeConflictDetails | TrackTransferConflictDetails
+ // Note: this is not the full 'proof' of missbehavior, as
+ // the bogus message from the exchange with a signature
+ // over the 'different' wire fee is missing.
+ //
+ // This information is NOT provided by the current implementation,
+ // because this would be quite expensive to generate and is
+ // hardly needed _here_. Once we add automated reports for
+ // the Taler auditor, we need to generate this data anyway
+ // and should probably return it here as well.
+ interface WireFeeConflictDetails {
+ // Numerical error code:
+ code: "TALER_EC_MERCHANT_PRIVATE_POST_TRANSFERS_BAD_WIRE_FEE";
+
+ // Text describing the issue for humans.
+ hint: string;
+
+
+ // Wire fee (wrongly) charged by the exchange, breaking the
+ // contract affirmed by the exchange_sig.
+ wire_fee: Amount;
+
+ // Timestamp of the wire transfer
+ execution_time: Timestamp;
+
+ // The expected wire fee (as signed by the exchange)
+ expected_wire_fee: Amount;
+
+ // Expected closing fee (needed to verify signature)
+ expected_closing_fee: Amount;
+
+ // Start date of the expected fee structure
+ start_date: Timestamp;
+
+ // End date of the expected fee structure
+ end_date: Timestamp;
+
+ // Signature of the exchange affirming the expected fee structure
+ master_sig: EddsaSignature;
+
+ // Master public key of the exchange
+ master_pub: EddsaPublicKey;
+ }
+ interface TrackTransferConflictDetails {
+ // Numerical error code
+ code: "TALER_EC_MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_REPORTS";
+
+ // Text describing the issue for humans.
+ hint: string;
+
+ // Offset in the exchange_transfer where the
+ // exchange's response fails to match the exchange_deposit_proof.
+ conflict_offset: number;
+
+ // The response from the exchange which tells us when the
+ // coin was returned to us, except that it does not match
+ // the expected value of the coin.
+ //
+ // This field is NOT provided by the current implementation,
+ // because this would be quite expensive to generate and is
+ // hardly needed _here_. Once we add automated reports for
+ // the Taler auditor, we need to generate this data anyway
+ // and should probably return it here as well.
+ // exchange_transfer?: TrackTransferResponse;
+
+ // Public key of the exchange used to sign the response to
+ // our deposit request.
+ deposit_exchange_pub: EddsaPublicKey;
+
+ // Signature of the exchange signing the (conflicting) response.
+ // Signs over a struct TALER_DepositConfirmationPS.
+ deposit_exchange_sig: EddsaSignature;
+
+ // Hash of the merchant's bank account the wire transfer went to
+ h_wire: HashCode;
+
+ // Hash of the contract terms with the conflicting deposit.
+ h_contract_terms: HashCode;
+
+ // At what time the exchange received the deposit. Needed
+ // to verify the \exchange_sig\.
+ deposit_timestamp: Timestamp;
+
+ // At what time the refund possibility expired (needed to verify exchange_sig).
+ refund_deadline: Timestamp;
+
+ // Public key of the coin for which we have conflicting information.
+ coin_pub: EddsaPublicKey;
+
+ // Amount the exchange counted the coin for in the transfer.
+ amount_with_fee: Amount;
+
+ // Expected value of the coin.
+ coin_value: Amount;
+
+ // Expected deposit fee of the coin.
+ coin_fee: Amount;
+
+ // Expected deposit fee of the coin.
+ deposit_fee: Amount;
+
+ }
+
+ // interface TrackTransferProof {
+ // // signature from the exchange made with purpose
+ // // TALER_SIGNATURE_EXCHANGE_CONFIRM_WIRE_DEPOSIT
+ // exchange_sig: EddsaSignature;
+
+ // // public EdDSA key of the exchange that was used to generate the signature.
+ // // Should match one of the exchange's signing keys from /keys. Again given
+ // // explicitly as the client might otherwise be confused by clock skew as to
+ // // which signing key was used.
+ // exchange_pub: EddsaSignature;
+
+ // // hash of the wire details (identical for all deposits)
+ // // Needed to check the exchange_sig
+ // h_wire: HashCode;
+ // }
+
+ }
+
+
+ interface ContractTerms {
+ // Human-readable description of the whole purchase
+ summary: string;
+
+ // Map from IETF BCP 47 language tags to localized summaries
+ summary_i18n?: { [lang_tag: string]: string };
+
+ // Unique, free-form identifier for the proposal.
+ // Must be unique within a merchant instance.
+ // For merchants that do not store proposals in their DB
+ // before the customer paid for them, the order_id can be used
+ // by the frontend to restore a proposal from the information
+ // encoded in it (such as a short product identifier and timestamp).
+ order_id: string;
+
+ // Total price for the transaction.
+ // The exchange will subtract deposit fees from that amount
+ // before transferring it to the merchant.
+ amount: Amount;
+
+ // The URL for this purchase. Every time is is visited, the merchant
+ // will send back to the customer the same proposal. Clearly, this URL
+ // can be bookmarked and shared by users.
+ fulfillment_url?: string;
+ fulfillment_message?: string;
+
+ // Maximum total deposit fee accepted by the merchant for this contract
+ max_fee: Amount;
+
+ // Maximum wire fee accepted by the merchant (customer share to be
+ // divided by the 'wire_fee_amortization' factor, and further reduced
+ // if deposit fees are below 'max_fee'). Default if missing is zero.
+ max_wire_fee: Amount;
+
+ // Over how many customer transactions does the merchant expect to
+ // amortize wire fees on average? If the exchange's wire fee is
+ // above 'max_wire_fee', the difference is divided by this number
+ // to compute the expected customer's contribution to the wire fee.
+ // The customer's contribution may further be reduced by the difference
+ // between the 'max_fee' and the sum of the actual deposit fees.
+ // Optional, default value if missing is 1. 0 and negative values are
+ // invalid and also interpreted as 1.
+ wire_fee_amortization: number;
+
+ // List of products that are part of the purchase (see Product).
+ products: Product[];
+
+ // Time when this contract was generated
+ timestamp: TalerProtocolTimestamp;
+
+ // After this deadline has passed, no refunds will be accepted.
+ refund_deadline: TalerProtocolTimestamp;
+
+ // After this deadline, the merchant won't accept payments for the contact
+ pay_deadline: TalerProtocolTimestamp;
+
+ // Transfer deadline for the exchange. Must be in the
+ // deposit permissions of coins used to pay for this order.
+ wire_transfer_deadline: TalerProtocolTimestamp;
+
+ // Merchant's public key used to sign this proposal; this information
+ // is typically added by the backend Note that this can be an ephemeral key.
+ merchant_pub: EddsaPublicKey;
+
+ // Base URL of the (public!) merchant backend API.
+ // Must be an absolute URL that ends with a slash.
+ merchant_base_url: string;
+
+ // More info about the merchant, see below
+ merchant: Merchant;
+
+ // The hash of the merchant instance's wire details.
+ h_wire: HashCode;
+
+ // Wire transfer method identifier for the wire method associated with h_wire.
+ // The wallet may only select exchanges via a matching auditor if the
+ // exchange also supports this wire method.
+ // The wire transfer fees must be added based on this wire transfer method.
+ wire_method: string;
+
+ // Any exchanges audited by these auditors are accepted by the merchant.
+ auditors: Auditor[];
+
+ // Exchanges that the merchant accepts even if it does not accept any auditors that audit them.
+ exchanges: Exchange[];
+
+ // Delivery location for (all!) products.
+ delivery_location?: Location;
+
+ // Time indicating when the order should be delivered.
+ // May be overwritten by individual products.
+ delivery_date?: TalerProtocolTimestamp;
+
+ // Nonce generated by the wallet and echoed by the merchant
+ // in this field when the proposal is generated.
+ nonce: string;
+
+ // Specifies for how long the wallet should try to get an
+ // automatic refund for the purchase. If this field is
+ // present, the wallet should wait for a few seconds after
+ // the purchase and then automatically attempt to obtain
+ // a refund. The wallet should probe until "delay"
+ // after the payment was successful (i.e. via long polling
+ // or via explicit requests with exponential back-off).
+ //
+ // In particular, if the wallet is offline
+ // at that time, it MUST repeat the request until it gets
+ // one response from the merchant after the delay has expired.
+ // If the refund is granted, the wallet MUST automatically
+ // recover the payment. This is used in case a merchant
+ // knows that it might be unable to satisfy the contract and
+ // desires for the wallet to attempt to get the refund without any
+ // customer interaction. Note that it is NOT an error if the
+ // merchant does not grant a refund.
+ auto_refund?: RelativeTime;
+
+ // Extra data that is only interpreted by the merchant frontend.
+ // Useful when the merchant needs to store extra information on a
+ // contract without storing it separately in their database.
+ extra?: any;
+ }
+
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.stories.tsx b/packages/merchant-backend-ui/src/pages/OfferRefund.stories.tsx
index ca524f4e2..92694f867 100644
--- a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.stories.tsx
+++ b/packages/merchant-backend-ui/src/pages/OfferRefund.stories.tsx
@@ -19,22 +19,27 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { createExample } from '../test-utils';
-import { ReserveCreated as TestedComponent } from './ReserveCreated';
+import { h, VNode, FunctionalComponent } from 'preact';
+import { createSVG } from '../components/QR';
+import { OfferRefund as TestedComponent } from './OfferRefund';
+
export default {
- title: 'wallet/manual withdraw/reserve created',
+ title: 'OfferRefund',
component: TestedComponent,
argTypes: {
- }
+ },
};
+function createExample<Props>(Component: FunctionalComponent<Props>, props: Partial<Props>) {
+ const r = (args: any) => <Component {...args} />
+ r.args = props
+ return r
+}
-export const InitialState = createExample(TestedComponent, {
- reservePub: 'ASLKDJQWLKEJASLKDJSADLKASJDLKSADJ',
- paytos: [
- 'payto://x-taler-bank/bank.taler:5882/exchangeminator?amount=COL%3A1&message=Taler+Withdrawal+A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG',
- 'payto://x-taler-bank/international-bank.com/myaccount?amount=COL%3A1&message=Taler+Withdrawal+TYQTE7VA4M9GZQ4TR06YBNGA05AJGMFNSK4Q62NXR2FKNDB1J4EX',
- ]
-});
+const REFUND_URI_EXAMPLE = 'taler://pay/backend.demo.taler.net/instances/blog/2021.249-022NW2KG88QGA/def537eb-00c2-4a8b-8a17-0be034d118d3?c=2Y4N4PMST7KYAPS83428GTPCD4'
+export const Example = createExample(TestedComponent, {
+ refundURI: REFUND_URI_EXAMPLE,
+ qr_code: createSVG(REFUND_URI_EXAMPLE)
+});
diff --git a/packages/merchant-backend-ui/src/pages/OfferRefund.tsx b/packages/merchant-backend-ui/src/pages/OfferRefund.tsx
new file mode 100644
index 000000000..b1cf63572
--- /dev/null
+++ b/packages/merchant-backend-ui/src/pages/OfferRefund.tsx
@@ -0,0 +1,158 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+import { Fragment, h, render, VNode } from 'preact';
+import { render as renderToString } from 'preact-render-to-string';
+import { useEffect } from 'preact/hooks';
+import { Footer } from '../components/Footer';
+import { QR } from '../components/QR';
+import "../css/pure-min.css";
+import "../css/style.css";
+import { Page, QRPlaceholder, WalletLink } from '../styled';
+
+/**
+ * This page creates a refund offer QR code
+ *
+ * It will build into a mustache html template for server side rendering
+ *
+ * server side rendering params:
+ * - order_status_url
+ * - taler_refund_qrcode_svg
+ * - taler_refund_uri
+ *
+ * request params:
+ * - refund_uri
+ * - order_status_url
+ */
+
+interface Props {
+ refundURI?: string;
+ order_status_url?: string;
+ qr_code?: string;
+}
+
+function Head({ order_summary }: { order_summary?: string }): VNode {
+ return <Fragment>
+ <meta charSet="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <meta name="taler-support" content="uri" />
+ <meta name="taler-uri" content="{{ taler_refund_uri }}"></meta>
+ <noscript>
+ <meta http-equiv="refresh" content="1" />
+ </noscript>
+ <title>Refund available for {order_summary ? order_summary : `{{ order_summary }}`}</title>
+ </Fragment>
+}
+
+export function OfferRefund({ refundURI, qr_code, order_status_url }: Props): VNode {
+ useEffect(() => {
+ const longpollDelayMs = 60 * 1000;
+ const delayMs = 500;
+ let checkUrl: URL;
+ try {
+ checkUrl = new URL(order_status_url ? order_status_url : "{{& order_status_url }}");
+ } catch (e) {
+ return;
+ }
+ checkUrl.searchParams.set("await_refund_obtained", "yes");
+ checkUrl.searchParams.set("timeout_ms", longpollDelayMs.toString());
+ function check() {
+ let retried = false;
+ function retryOnce() {
+ if (!retried) {
+ retried = true;
+ check();
+ }
+ }
+ const req = new XMLHttpRequest();
+ req.onreadystatechange = function () {
+ if (req.readyState === XMLHttpRequest.DONE) {
+ if (req.status === 200) {
+ try {
+ const resp = JSON.parse(req.responseText);
+ if (!resp.refund_pending) {
+ window.location.reload();
+ }
+ } catch (e) {
+ console.error("could not parse response:", e);
+ }
+ }
+ setTimeout(retryOnce, delayMs);
+ }
+ };
+ req.onerror = function () {
+ setTimeout(retryOnce, delayMs);
+ }
+ req.open("GET", checkUrl.href);
+ req.send();
+ }
+
+ setTimeout(check, delayMs);
+ })
+ return <Page>
+ <section>
+ <h1>Collect Taler refund</h1>
+ <p>
+ Scan this QR code with your Taler mobile wallet:
+ </p>
+ <QRPlaceholder dangerouslySetInnerHTML={{ __html: qr_code ? qr_code : `{{{ taler_refund_qrcode_svg }}}` }} />
+ <p>
+ <WalletLink href={refundURI ? refundURI : `{{ taler_refund_uri }}`}>
+ Or open your Taler wallet
+ </WalletLink>
+ </p>
+ <p>
+ <a href="https://wallet.taler.net/">Don't have a Taler wallet yet? Install it!</a>
+ </p>
+ </section>
+ <Footer />
+ </Page>
+}
+
+export function mount(): void {
+ try {
+ const fromLocation = new URL(window.location.href).searchParams
+ const os = fromLocation.get('order_summary') || undefined;
+ if (os) {
+ render(<Head order_summary={os} />, document.head);
+ }
+
+ const uri = fromLocation.get('refund_uri') || undefined;
+ const osu = fromLocation.get('order_status_url') || undefined;
+ const qr_code = uri ? renderToString(<QR text={uri} />) : undefined;
+
+ render(<OfferRefund
+ refundURI={uri} order_status_url={osu}
+ qr_code={qr_code}
+ />, document.body);
+ } catch (e) {
+ console.error("got error", e);
+ if (e instanceof Error) {
+ document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`;
+ }
+ }
+}
+
+export function buildTimeRendering(): { head: string, body: string } {
+ return {
+ head: renderToString(<Head />),
+ body: renderToString(<OfferRefund />)
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx b/packages/merchant-backend-ui/src/pages/RequestPayment.stories.tsx
index de1f67b96..5d6d79adf 100644
--- a/packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx
+++ b/packages/merchant-backend-ui/src/pages/RequestPayment.stories.tsx
@@ -19,34 +19,27 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { createExample } from '../test-utils';
-import { ConfirmProviderView as TestedComponent } from './ProviderAddPage';
+import { FunctionalComponent, h } from 'preact';
+import { createSVG } from '../components/QR';
+import { RequestPayment as TestedComponent } from './RequestPayment';
+
export default {
- title: 'popup/backup/confirm',
+ title: 'RequestPayment',
component: TestedComponent,
argTypes: {
- onRetry: { action: 'onRetry' },
- onDelete: { action: 'onDelete' },
- onBack: { action: 'onBack' },
- }
+ },
};
+function createExample<Props>(Component: FunctionalComponent<Props>, props: Partial<Props>) {
+ const r = (args: any) => <Component {...args} />
+ r.args = props
+ return r
+}
-export const DemoService = createExample(TestedComponent, {
- url: 'https://sync.demo.taler.net/',
- provider: {
- annual_fee: 'KUDOS:0.1',
- storage_limit_in_megabytes: 20,
- supported_protocol_version: '1'
- }
-});
+const PAYTO_URI_EXAMPLE = 'taler+http://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0'
-export const FreeService = createExample(TestedComponent, {
- url: 'https://sync.taler:9667/',
- provider: {
- annual_fee: 'ARS:0',
- storage_limit_in_megabytes: 20,
- supported_protocol_version: '1'
- }
+export const Example = createExample(TestedComponent, {
+ payURI: 'taler+http://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0',
+ qr_code: createSVG(PAYTO_URI_EXAMPLE)
});
diff --git a/packages/merchant-backend-ui/src/pages/RequestPayment.tsx b/packages/merchant-backend-ui/src/pages/RequestPayment.tsx
new file mode 100644
index 000000000..513438ba2
--- /dev/null
+++ b/packages/merchant-backend-ui/src/pages/RequestPayment.tsx
@@ -0,0 +1,203 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { Fragment, h, render, VNode } from "preact";
+import { render as renderToString } from "preact-render-to-string";
+import { useEffect } from "preact/hooks";
+import { Footer } from "../components/Footer";
+import "../css/pure-min.css";
+import "../css/style.css";
+import { QR } from "../components/QR";
+import { Page, QRPlaceholder, WalletLink } from "../styled";
+
+/**
+ * This page creates a payment request QR code
+ *
+ * It will build into a mustache html template for server side rendering
+ *
+ * server side rendering params:
+ * - order_status_url
+ * - taler_pay_qrcode_svg
+ * - taler_pay_uri
+ * - order_summary
+ *
+ * request params:
+ * - pay_uri
+ * - order_summary
+ * - order_status_url
+ */
+
+interface Props {
+ payURI?: string;
+ order_status_url?: string;
+ qr_code?: string;
+}
+
+function Head({ order_summary }: { order_summary?: string }): VNode {
+ return (
+ <Fragment>
+ <meta charSet="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <meta name="taler-support" content="uri" />
+ <meta name="taler-uri" content="{{ taler_pay_uri }}"></meta>
+ <noscript>
+ <meta http-equiv="refresh" content="1" />
+ </noscript>
+ <title>
+ Payment requested for{" "}
+ {order_summary ? order_summary : `{{ order_summary }}`}
+ </title>
+ </Fragment>
+ );
+}
+
+export function RequestPayment({
+ payURI,
+ qr_code,
+ order_status_url,
+}: Props): VNode {
+ useEffect(() => {
+ const longpollDelayMs = 60 * 1000;
+ let checkUrl: URL;
+ try {
+ checkUrl = new URL(
+ order_status_url ? order_status_url : "{{& order_status_url }}"
+ );
+ } catch (e) {
+ return;
+ }
+ checkUrl.searchParams.set("timeout_ms", longpollDelayMs.toString());
+ const delayMs = 500;
+ function check() {
+ let retried = false;
+ function retryOnce() {
+ if (!retried) {
+ retried = true;
+ check();
+ }
+ }
+ const req = new XMLHttpRequest();
+ req.onreadystatechange = function () {
+ if (req.readyState === XMLHttpRequest.DONE) {
+ if (req.status === 200) {
+ try {
+ const resp = JSON.parse(req.responseText);
+ if (resp.fulfillment_url) {
+ window.location.replace(resp.fulfillment_url);
+ } else {
+ window.location.reload()
+ }
+ } catch (e) {
+ console.error("could not parse response:", e);
+ }
+ }
+ if (req.status === 202) {
+ try {
+ const resp = JSON.parse(req.responseText);
+ if (resp.fulfillment_url) {
+ window.location.replace(resp.fulfillment_url);
+ } else {
+ window.location.reload()
+ }
+ } catch (e) {
+ console.error("could not parse response:", e);
+ }
+ }
+ if (req.status === 402) {
+ try {
+ const resp = JSON.parse(req.responseText);
+ if (resp.already_paid_order_id && resp.fulfillment_url) {
+ window.location.replace(resp.fulfillment_url);
+ }
+ } catch (e) {
+ console.error("could not parse response:", e);
+ }
+ }
+ setTimeout(retryOnce, delayMs);
+ }
+ };
+ req.onerror = function () {
+ setTimeout(retryOnce, delayMs);
+ };
+ req.ontimeout = function () {
+ setTimeout(retryOnce, delayMs);
+ };
+ req.timeout = longpollDelayMs;
+ req.open("GET", checkUrl.href);
+ req.send();
+ }
+ setTimeout(check, delayMs);
+ });
+ return (
+ <Page>
+ <section>
+ <h1>Pay with Taler</h1>
+ <p>Scan this QR code with your mobile wallet:</p>
+ <QRPlaceholder
+ dangerouslySetInnerHTML={{
+ __html: qr_code ? qr_code : `{{{ taler_pay_qrcode_svg }}}`,
+ }}
+ />
+ <p>
+ <WalletLink href={payURI ? payURI : `{{ taler_pay_uri }}`}>
+ Or open your Taler wallet
+ </WalletLink>
+ </p>
+ <p>
+ <a href="https://wallet.taler.net/">
+ Don't have a Taler wallet yet? Install it!
+ </a>
+ </p>
+ </section>
+ <Footer />
+ </Page>
+ );
+}
+
+export function mount(): void {
+ try {
+ const fromLocation = new URL(window.location.href).searchParams;
+ const os = fromLocation.get("order_summary") || undefined;
+ if (os) {
+ render(<Head order_summary={os} />, document.head);
+ }
+
+ const uri = fromLocation.get("pay_uri") || undefined;
+ const osu = fromLocation.get("order_status_url") || undefined;
+ const qr_code = uri ? renderToString(<QR text={uri} />) : undefined;
+
+ render(
+ <RequestPayment payURI={uri} order_status_url={osu} qr_code={qr_code} />,
+ document.body
+ );
+ } catch (e) {
+ console.error("got error", e);
+ if (e instanceof Error) {
+ document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`;
+ }
+ }
+}
+
+export function buildTimeRendering(): { head: string; body: string } {
+ return {
+ head: renderToString(<Head />),
+ body: renderToString(<RequestPayment />),
+ };
+}
diff --git a/packages/merchant-backend-ui/src/pages/ShowOrderDetails.examples.ts b/packages/merchant-backend-ui/src/pages/ShowOrderDetails.examples.ts
new file mode 100644
index 000000000..86992c9e1
--- /dev/null
+++ b/packages/merchant-backend-ui/src/pages/ShowOrderDetails.examples.ts
@@ -0,0 +1,253 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+
+import { MerchantBackend } from '../declaration';
+import { Props } from './ShowOrderDetails';
+
+
+const defaultContractTerms: MerchantBackend.ContractTerms = {
+ order_id: 'XRS8876388373',
+ amount: 'USD:10',
+ summary: 'this is a short summary',
+ pay_deadline: {
+ t_s: Math.round(new Date().getTime() / 1000) + 6 * 24 * 60 * 60
+ },
+ merchant: {
+ name: 'the merchant (inc)',
+ address: {
+ country_subdivision: 'Buenos Aires',
+ town: 'CABA',
+ country: 'Argentina'
+ },
+ jurisdiction: {
+ country_subdivision: 'Cordoba',
+ town: 'Capital',
+ country: 'Argentina'
+ },
+ },
+ max_fee: 'USD:0.1',
+ max_wire_fee: 'USD:0.2',
+ wire_fee_amortization: 1,
+ products: [],
+ timestamp: {
+ t_s: Math.round(new Date().getTime() / 1000)
+ },
+ auditors: [],
+ exchanges: [],
+ h_wire: '',
+ merchant_base_url: 'http://merchant.base.url/',
+ merchant_pub: 'QWEASDQWEASD',
+ nonce: 'NONCE',
+ refund_deadline: {
+ t_s: Math.round(new Date().getTime() / 1000) + 6 * 24 * 60 * 60
+ },
+ wire_method: 'x-taler-bank',
+ wire_transfer_deadline: {
+ t_s: Math.round(new Date().getTime() / 1000) + 3 * 24 * 60 * 60
+ },
+};
+
+const inSixDays = Math.round(new Date().getTime() / 1000) + 6 * 24 * 60 * 60
+const in10Minutes = Math.round(new Date().getTime() / 1000) + 10 * 60
+const in15Minutes = Math.round(new Date().getTime() / 1000) + 15 * 60
+const in20Minutes = Math.round(new Date().getTime() / 1000) + 20 * 60
+
+export const exampleData: { [name: string]: Props } = {
+ Simplest: {
+ order_summary: 'here goes the order summary',
+ contract_terms: defaultContractTerms,
+ },
+ WithRefundAmount: {
+ order_summary: 'here goes the order summary',
+ refund_amount: 'USD:10',
+ contract_terms: defaultContractTerms,
+ },
+ WithDeliveryDate: {
+ order_summary: 'here goes the order summary',
+ contract_terms: {
+ ...defaultContractTerms,
+ delivery_date: {
+ t_s: inSixDays
+ },
+ },
+ },
+ WithDeliveryLocation: {
+ order_summary: 'here goes the order summary',
+ contract_terms: {
+ ...defaultContractTerms,
+ delivery_location: {
+ address_lines: ['addr line 1', 'addr line 2', 'addr line 3', 'addr line 4', 'addr line 5', 'addr line 6', 'addr line 7'],
+ building_name: 'building-name',
+ building_number: 'building-number',
+ country: 'country',
+ country_subdivision: 'country sub',
+ district: 'district',
+ post_code: 'post-code',
+ street: 'street',
+ town: 'town',
+ town_location: 'town loc',
+ },
+ },
+ },
+ WithDeliveryLocationAndDate: {
+ order_summary: 'here goes the order summary',
+ contract_terms: {
+ ...defaultContractTerms,
+ delivery_location: {
+ address_lines: ['addr1', 'addr2', 'addr3', 'addr4', 'addr5', 'addr6', 'addr7'],
+ building_name: 'building-name',
+ building_number: 'building-number',
+ country: 'country',
+ country_subdivision: 'country sub',
+ district: 'district',
+ post_code: 'post-code',
+ street: 'street',
+ town: 'town',
+ town_location: 'town loc',
+ },
+ delivery_date: {
+ t_s: inSixDays
+ },
+ },
+ },
+ WithThreeProducts: {
+ order_summary: 'here goes the order summary',
+ contract_terms: {
+ ...defaultContractTerms,
+ products: [{
+ description: 'description of the first product',
+ price: '5:USD',
+ quantity: 1,
+ delivery_date: { t_s: in10Minutes },
+ product_id: '12333',
+ }, {
+ description: 'another description',
+ price: '10:USD',
+ quantity: 5,
+ unit: 't-shirt',
+ }, {
+ description: 'one last description',
+ price: '10:USD',
+ quantity: 5
+ }]
+ } as MerchantBackend.ContractTerms
+ },
+ WithProductWithTaxes: {
+ order_summary: 'here goes the order summary',
+ contract_terms: {
+ ...defaultContractTerms,
+ products: [{
+ description: 'description of the first product',
+ price: '5:USD',
+ quantity: 1,
+ unit: 'beer',
+ delivery_date: { t_s: in10Minutes },
+ product_id: '456',
+ taxes: [{
+ name: 'VAT', tax: 'USD:1'
+ }],
+ }, {
+ description: 'one last description',
+ price: '10:USD',
+ quantity: 5,
+ product_id: '123',
+ unit: 'beer',
+ taxes: [{
+ name: 'VAT', tax: 'USD:1'
+ }],
+ }]
+ } as MerchantBackend.ContractTerms
+ },
+ WithExchangeList: {
+ order_summary: 'here goes the order summary',
+ contract_terms: {
+ ...defaultContractTerms,
+ exchanges: [{
+ master_pub: 'ABCDEFGHIJKLMNO',
+ url: 'http://exchange0.taler.net'
+ }, {
+ master_pub: 'AAAAAAAAAAAAAAA',
+ url: 'http://exchange1.taler.net'
+ }, {
+ master_pub: 'BBBBBBBBBBBBBBB',
+ url: 'http://exchange2.taler.net'
+ }]
+ },
+ },
+ WithAuditorList: {
+ order_summary: 'here goes the order summary',
+ contract_terms: {
+ ...defaultContractTerms,
+ auditors: [{
+ auditor_pub: 'ABCDEFGHIJKLMNO',
+ name: 'the USD auditor',
+ url: 'http://auditor-usd.taler.net'
+ }, {
+ auditor_pub: 'OPQRSTUVWXYZABCD',
+ name: 'the EUR auditor',
+ url: 'http://auditor-eur.taler.net'
+ }]
+ },
+ },
+ WithAutoRefund: {
+ order_summary: 'here goes the order summary',
+ contract_terms: {
+ ...defaultContractTerms,
+ auto_refund: {
+ d_us: 1000 * 60 * 60 * 26 + 1000 * 60 * 30
+ }
+ },
+ },
+ WithFulfillmentURL: {
+ order_summary: 'this is the order with fulfillmentURL',
+ contract_terms: {
+ ...defaultContractTerms,
+ fulfillment_url: "https://demo.taler.net",
+ fulfillment_message: "Congratulations! You just purchased an valuable item!"
+ },
+ },
+ WithFulfillmentMessage: {
+ order_summary: 'this is the order with fulfillment message',
+ contract_terms: {
+ ...defaultContractTerms,
+ fulfillment_message: "Congratulations! You just purchased an valuable item!"
+ },
+ },
+ WithoutWireTransferDeadline: {
+ order_summary: 'this is the order without transfer deadline',
+ contract_terms: {
+ ...defaultContractTerms,
+ // @ts-ignore
+ wire_transfer_deadline: undefined,
+ },
+ },
+ ZeroFee: {
+ order_summary: 'example with zero fee',
+ contract_terms: {
+ ...defaultContractTerms,
+ // @ts-ignore
+ max_fee: undefined,
+ // @ts-ignore
+ max_wire_fee: undefined,
+ fulfillment_message: "Congratulations! You just purchased an valuable item!"
+ },
+ },
+}
diff --git a/packages/merchant-backend-ui/src/pages/ShowOrderDetails.stories.tsx b/packages/merchant-backend-ui/src/pages/ShowOrderDetails.stories.tsx
new file mode 100644
index 000000000..6a902cc9e
--- /dev/null
+++ b/packages/merchant-backend-ui/src/pages/ShowOrderDetails.stories.tsx
@@ -0,0 +1,49 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+
+import { FunctionalComponent, h } from 'preact';
+import { ShowOrderDetails as TestedComponent } from './ShowOrderDetails';
+import { exampleData } from './ShowOrderDetails.examples';
+
+export default {
+ title: 'ShowOrderDetails',
+ component: TestedComponent,
+ argTypes: {
+ },
+ excludeStories: /.*Data$/,
+};
+
+function createExample<Props>(Component: FunctionalComponent<Props>, props: Partial<Props>) {
+ const r = (args: any) => <Component {...args} />
+ r.args = props
+ return r
+}
+
+export const Simplest = createExample(TestedComponent, exampleData.Simplest);
+export const WithRefundAmount = createExample(TestedComponent, exampleData.WithRefundAmount);
+export const WithDeliveryDate = createExample(TestedComponent, exampleData.WithDeliveryDate);
+export const WithDeliveryLocation = createExample(TestedComponent, exampleData.WithDeliveryLocation);
+export const WithDeliveryLocationAndDate = createExample(TestedComponent, exampleData.WithDeliveryLocationAndDate);
+export const WithThreeProducts = createExample(TestedComponent, exampleData.WithThreeProducts);
+export const WithAuditorList = createExample(TestedComponent, exampleData.WithAuditorList);
+export const WithExchangeList = createExample(TestedComponent, exampleData.WithExchangeList);
+export const WithAutoRefund = createExample(TestedComponent, exampleData.WithAutoRefund);
+export const WithProductWithTaxes = createExample(TestedComponent, exampleData.WithProductWithTaxes);
diff --git a/packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx b/packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx
new file mode 100644
index 000000000..7d11eb21d
--- /dev/null
+++ b/packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx
@@ -0,0 +1,566 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { format, formatDuration } from "date-fns";
+import { intervalToDuration } from "date-fns/esm";
+import { Fragment, h, render, VNode } from "preact";
+import { render as renderToString } from "preact-render-to-string";
+import { Footer } from "../components/Footer";
+import "../css/pure-min.css";
+import "../css/style.css";
+import { MerchantBackend } from "../declaration";
+import { Page, InfoBox, TableExpanded, TableSimple } from "../styled";
+import { TIME_DATE_FORMAT } from "../utils";
+
+/**
+ * This page creates a payment request QR code
+ *
+ * It will build into a mustache html template for server side rendering
+ *
+ * server side rendering params:
+ * - order_summary
+ * - contract_terms
+ * - refund_amount
+ *
+ * request params:
+ * - refund_amount
+ * - contract_terms
+ * - order_summary
+ */
+
+export interface Props {
+ btr?: boolean; // build time rendering flag
+ order_summary?: string;
+ refund_amount?: string;
+ contract_terms?: MerchantBackend.ContractTerms;
+}
+
+function Head({ order_summary }: { order_summary?: string }): VNode {
+ return (
+ <Fragment>
+ <meta charSet="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <meta name="taler-support" content="uri" />
+ <noscript>
+ <meta http-equiv="refresh" content="1" />
+ </noscript>
+ <title>
+ Status of your order for{" "}
+ {order_summary ? order_summary : `{{ order_summary }}`}
+ </title>
+ <script>{`
+ var contractTermsStr = '{{{contract_terms_json}}}';
+ `}</script>
+ </Fragment>
+ );
+}
+
+function Location({
+ templateName,
+ location,
+ btr,
+}: {
+ templateName: string;
+ location: MerchantBackend.Location | undefined;
+ btr?: boolean;
+}) {
+ //FIXME: mustache strings will be constructed in a way that ends in the final output of the html but is not present in the
+ // javascript code, otherwise when mustache render engine run over the html it will also replace string in the javascript code
+ // that is made to run when the browser has javascript enable leading into undefined behavior.
+ // that's why in the next fields we are using concatenations to build the mustache placeholder.
+ return (
+ <Fragment>
+ {btr && `{{` + `#${templateName}.building_name}}`}
+ <dd>
+ {location?.building_name ||
+ (btr && `{{ ${templateName}.building_name }}`)}{" "}
+ {location?.building_number ||
+ (btr && `{{ ${templateName}.building_number }}`)}
+ </dd>
+ {btr && `{{` + `/${templateName}.building_name}}`}
+
+ {btr && `{{` + `#${templateName}.country}}`}
+ <dd>
+ {location?.country || (btr && `{{ ${templateName}.country }}`)}{" "}
+ {location?.country_subdivision ||
+ (btr && `{{ ${templateName}.country_subdivision }}`)}
+ </dd>
+ {btr && `{{` + `/${templateName}.country}}`}
+
+ {btr && `{{` + `#${templateName}.district}}`}
+ <dd>{location?.district || (btr && `{{ ${templateName}.district }}`)}</dd>
+ {btr && `{{` + `/${templateName}.district}}`}
+
+ {btr && `{{` + `#${templateName}.post_code}}`}
+ <dd>
+ {location?.post_code || (btr && `{{ ${templateName}.post_code }}`)}
+ </dd>
+ {btr && `{{` + `/${templateName}.post_code}}`}
+
+ {btr && `{{` + `#${templateName}.street}}`}
+ <dd>{location?.street || (btr && `{{ ${templateName}.street }}`)}</dd>
+ {btr && `{{` + `/${templateName}.street}}`}
+
+ {btr && `{{` + `#${templateName}.town}}`}
+ <dd>{location?.town || (btr && `{{ ${templateName}.town }}`)}</dd>
+ {btr && `{{` + `/${templateName}.town}}`}
+
+ {btr && `{{` + `#${templateName}.town_location}}`}
+ <dd>
+ {location?.town_location ||
+ (btr && `{{ ${templateName}.town_location }}`)}
+ </dd>
+ {btr && `{{` + `/${templateName}.town_location}}`}
+ </Fragment>
+ );
+}
+
+export function ShowOrderDetails({
+ order_summary,
+ refund_amount,
+ contract_terms,
+ btr,
+}: Props): VNode {
+ const productList = btr
+ ? [{} as MerchantBackend.Product]
+ : contract_terms?.products || [];
+ const auditorsList = btr
+ ? [{} as MerchantBackend.Auditor]
+ : contract_terms?.auditors || [];
+ const exchangesList = btr
+ ? [{} as MerchantBackend.Exchange]
+ : contract_terms?.exchanges || [];
+ const hasDeliveryInfo =
+ btr ||
+ !!contract_terms?.delivery_date ||
+ !!contract_terms?.delivery_location;
+
+ return (
+ <Page>
+ <header>
+ <h1>
+ Details of order{" "}
+ {contract_terms?.order_id || `{{ contract_terms.order_id }}`}
+ </h1>
+ </header>
+
+ <section>
+ {btr && `{{#refund_amount}}`}
+ {(btr || refund_amount) && (
+ <section>
+ <InfoBox>
+ <b>Refunded:</b> The merchant refunded you{" "}
+ <b>{refund_amount || `{{ refund_amount }}`}</b>.
+ </InfoBox>
+ </section>
+ )}
+ {btr && `{{/refund_amount}}`}
+
+ {btr && `{{#contract_terms.fulfillment_message}}`}
+ {(btr || contract_terms?.fulfillment_message) && (
+ <section>
+ <InfoBox>
+ <b>{contract_terms?.fulfillment_message || `{{ contract_terms.fulfillment_message }}`}</b>.
+ </InfoBox>
+ </section>
+ )}
+ {btr && `{{/contract_terms.fulfillment_message}}`}
+
+ <section>
+ <TableExpanded>
+ <dt>Order summary:</dt>
+ <dd>{contract_terms?.summary || `{{ contract_terms.summary }}`}</dd>
+ {btr && `{{#contract_terms.fulfillment_url}}`}
+ <dt>Fulfillment URL:</dt>
+ <dd><a href={contract_terms?.fulfillment_url || `{{ contract_terms.fulfillment_url }}`}>{contract_terms?.fulfillment_url || `{{ contract_terms.fulfillment_url }}`}</a></dd>
+ {btr && `{{/contract_terms.fulfillment_url}}`}
+ <dt>Amount paid:</dt>
+ <dd>{contract_terms?.amount || `{{ contract_terms.amount }}`}</dd>
+ <dt>Order date:</dt>
+ <dd>
+ {contract_terms?.timestamp
+ ? contract_terms?.timestamp.t_s != "never"
+ ? format(
+ contract_terms?.timestamp.t_s * 1000,
+ TIME_DATE_FORMAT,
+ )
+ : "never"
+ : `{{ contract_terms.timestamp_str }}`}{" "}
+ </dd>
+ <dt>Merchant name:</dt>
+ <dd>
+ {contract_terms?.merchant.name ||
+ `{{ contract_terms.merchant.name }}`}
+ </dd>
+ </TableExpanded>
+ </section>
+
+ {btr && `{{#contract_terms.hasProducts}}`}
+ {!productList.length ? null : (
+ <section>
+ <h2>Products purchased</h2>
+ <TableSimple>
+ {btr && "{{" + "#contract_terms.products" + "}}"}
+ {productList.map((p, i) => {
+ const taxList = btr
+ ? [{} as MerchantBackend.Tax]
+ : p.taxes || [];
+
+ return (
+ <Fragment key={i}>
+ <p>{p.description || `{{description}}`}</p>
+ <dl>
+ <dt>Quantity:</dt>
+ <dd>{p.quantity || `{{quantity}}`}</dd>
+
+ <dt>Price:</dt>
+ <dd>{p.price || `{{price}}`}</dd>
+
+ {btr && `{{#hasTaxes}}`}
+ {!taxList.length ? null : (
+ <Fragment>
+ {btr && "{{" + "#taxes" + "}}"}
+ {taxList.map((t, i) => {
+ return (
+ <Fragment key={i}>
+ <dt>{t.name || `{{name}}`}</dt>
+ <dd>{t.tax || `{{tax}}`}</dd>
+ </Fragment>
+ );
+ })}
+ {btr && "{{" + "/taxes" + "}}"}
+ </Fragment>
+ )}
+ {btr && `{{/hasTaxes}}`}
+
+ {btr && `{{#delivery_date}}`}
+ {(btr || p.delivery_date) && (
+ <Fragment>
+ <dt>Delivered on:</dt>
+ <dd>
+ {p.delivery_date
+ ? p.delivery_date.t_s != "never"
+ ? format(
+ p.delivery_date.t_s,
+ TIME_DATE_FORMAT,
+ )
+ : "never"
+ : `{{ delivery_date_str }}`}{" "}
+ </dd>
+ </Fragment>
+ )}
+ {btr && `{{/delivery_date}}`}
+
+ {btr && `{{#unit}}`}
+ {(btr || p.unit) && (
+ <Fragment>
+ <dt>Product unit:</dt>
+ <dd>{p.unit || `{{.}}`}</dd>
+ </Fragment>
+ )}
+ {btr && `{{/unit}}`}
+
+ {btr && `{{#product_id}}`}
+ {(btr || p.product_id) && (
+ <Fragment>
+ <dt>Product ID:</dt>
+ <dd>{p.product_id || `{{.}}`}</dd>
+ </Fragment>
+ )}
+ {btr && `{{/product_id}}`}
+ </dl>
+ </Fragment>
+ );
+ })}
+ {btr && "{{" + "/contract_terms.products" + "}}"}
+ </TableSimple>
+ </section>
+ )}
+ {btr && `{{/contract_terms.hasProducts}}`}
+
+ {btr && `{{#contract_terms.has_delivery_info}}`}
+ {!hasDeliveryInfo ? null : (
+ <section>
+ <h2>Delivery information</h2>
+ <TableExpanded>
+ {btr && `{{#contract_terms.delivery_date}}`}
+ {(btr || contract_terms?.delivery_date) && (
+ <Fragment>
+ <dt>Delivery date:</dt>
+ <dd>
+ {contract_terms?.delivery_date
+ ? contract_terms?.delivery_date.t_s != "never"
+ ? format(
+ contract_terms?.delivery_date.t_s,
+ TIME_DATE_FORMAT,
+ )
+ : "never"
+ : `{{ contract_terms.delivery_date_str }}`}{" "}
+ </dd>
+ </Fragment>
+ )}
+ {btr && `{{/contract_terms.delivery_date}}`}
+
+ {btr && `{{#contract_terms.delivery_location}}`}
+ {(btr || contract_terms?.delivery_location) && (
+ <Fragment>
+ <dt>Delivery address:</dt>
+ <Location
+ btr={btr}
+ location={contract_terms?.delivery_location}
+ templateName="contract_terms.delivery_location"
+ />
+ </Fragment>
+ )}
+ {btr && `{{/contract_terms.delivery_location}}`}
+ </TableExpanded>
+ </section>
+ )}
+ {btr && `{{/contract_terms.has_delivery_info}}`}
+
+ <section>
+ <h2>Full payment information</h2>
+ <TableExpanded>
+ <dt>Exchange transfer deadline:</dt>
+ {btr && `{{` + `#contract_terms.wire_transfer_deadline_str}}`}
+ <dd>
+ {contract_terms?.wire_transfer_deadline
+ ? contract_terms?.wire_transfer_deadline.t_s != "never"
+ ? format(
+ contract_terms?.wire_transfer_deadline.t_s * 1000,
+ TIME_DATE_FORMAT,
+ )
+ : "never"
+ : `{{ contract_terms.wire_transfer_deadline_str }}`}{" "}
+ </dd>
+ {btr && `{{` + `/contract_terms.wire_transfer_deadline_str}}`}
+
+ {btr && `{{` + `^contract_terms.wire_transfer_deadline_str}}`}
+ <dd>
+ Wire transfer settled.
+ </dd>
+ {btr && `{{` + `/contract_terms.wire_transfer_deadline_str}}`}
+
+ {btr && `{{` + `#contract_terms.max_fee}}`}
+ <dt>Maximum deposit fee:</dt>
+ <dd>{contract_terms?.max_fee || `{{ contract_terms.max_fee }}`}</dd>
+ {btr && `{{` + `/contract_terms.max_fee}}`}
+
+ {btr && `{{` + `#contract_terms.max_wire_fee}}`}
+ <dt>Maximum wire fee:</dt>
+ <dd>
+ {contract_terms?.max_wire_fee ||
+ `{{ contract_terms.max_wire_fee }}`}
+ </dd>
+ {btr && `{{` + `/contract_terms.max_wire_fee}}`}
+
+ {btr && `{{` + `#contract_terms.wire_fee_amortization}}`}
+ <dt>Wire fee amortization:</dt>
+ <dd>
+ {contract_terms?.wire_fee_amortization ||
+ `{{ contract_terms.wire_fee_amortization }}`}{" "}
+ transactions
+ </dd>
+ {btr && `{{` + `/contract_terms.wire_fee_amortization}}`}
+ </TableExpanded>
+ </section>
+
+ <section>
+ <h2>Refund information</h2>
+ <TableExpanded>
+ <dt>Refund deadline:</dt>
+ <dd>
+ {contract_terms?.refund_deadline
+ ? contract_terms?.refund_deadline.t_s != "never"
+ ? format(
+ contract_terms?.refund_deadline.t_s * 1000,
+ TIME_DATE_FORMAT,
+ )
+ : "never"
+ : `{{ contract_terms.refund_deadline_str }}`}{" "}
+ </dd>
+
+ {btr && `{{#contract_terms.auto_refund}}`}
+ {(btr || contract_terms?.auto_refund) && (
+ <Fragment>
+ <dt>Attempt autorefund for:</dt>
+ <dd>
+ {contract_terms?.auto_refund
+ ? contract_terms?.auto_refund.d_us != "forever"
+ ? formatDuration(
+ intervalToDuration({
+ start: 0,
+ end: contract_terms?.auto_refund.d_us,
+ }),
+ )
+ : "forever"
+ : `{{ contract_terms.auto_refund_str }}`}{" "}
+ </dd>
+ </Fragment>
+ )}
+ {btr && `{{/contract_terms.auto_refund}}`}
+ </TableExpanded>
+ </section>
+
+ <section>
+ <h2>Additional order details</h2>
+ <TableExpanded>
+ <dt>Public reorder URL:</dt>
+ <dd> -- not defined yet -- </dd>
+ {btr && `{{#contract_terms.fulfillment_url}}`}
+ {(btr || contract_terms?.fulfillment_url) && (
+ <Fragment>
+ <dt>Fulfillment URL:</dt>
+ <dd>
+ {contract_terms?.fulfillment_url ||
+ (btr && `{{ contract_terms.fulfillment_url }}`)}
+ </dd>
+ </Fragment>
+ )}
+ {btr && `{{/contract_terms.fulfillment_url}}`}
+ {/* <dt>Fulfillment message:</dt>
+ <dd> -- not defined yet -- </dd> */}
+ </TableExpanded>
+ </section>
+
+ <section>
+ <h2>Full merchant information</h2>
+ <TableExpanded>
+ <dt>Merchant name:</dt>
+ <dd>
+ {contract_terms?.merchant.name ||
+ `{{ contract_terms.merchant.name }}`}
+ </dd>
+ <dt>Merchant address:</dt>
+ <Location
+ btr={btr}
+ location={contract_terms?.merchant.address}
+ templateName="contract_terms.merchant.address"
+ />
+ <dt>Merchant's jurisdiction:</dt>
+ <Location
+ btr={btr}
+ location={contract_terms?.merchant.jurisdiction}
+ templateName="contract_terms.merchant.jurisdiction"
+ />
+ <dt>Merchant URI:</dt>
+ <dd>
+ {contract_terms?.merchant_base_url ||
+ `{{ contract_terms.merchant_base_url }}`}
+ </dd>
+ <dt>Merchant's public key:</dt>
+ <dd>
+ {contract_terms?.merchant_pub ||
+ `{{ contract_terms.merchant_pub }}`}
+ </dd>
+ {/* <dt>Merchant's hash:</dt>
+ <dd> -- not defined yet -- </dd> */}
+ </TableExpanded>
+ </section>
+
+ {btr && `{{#contract_terms.hasAuditors}}`}
+ {!auditorsList.length ? null : (
+ <section>
+ <h2>Auditors accepted by the merchant</h2>
+ <TableExpanded>
+ {btr && "{{" + "#contract_terms.auditors" + "}}"}
+ {auditorsList.map((p, i) => {
+ return (
+ <Fragment key={i}>
+ <p>{p.name || `{{name}}`}</p>
+ <dt>Auditor's public key:</dt>
+ <dd>{p.auditor_pub || `{{auditor_pub}}`}</dd>
+ <dt>Auditor's URL:</dt>
+ <dd>{p.url || `{{url}}`}</dd>
+ </Fragment>
+ );
+ })}
+ {btr && "{{" + "/contract_terms.auditors" + "}}"}
+ </TableExpanded>
+ </section>
+ )}
+ {btr && `{{/contract_terms.hasAuditors}}`}
+
+ {btr && `{{#contract_terms.hasExchanges}}`}
+ {!exchangesList.length ? null : (
+ <section>
+ <h2>Exchanges accepted by the merchant</h2>
+ <TableExpanded>
+ {btr && "{{" + "#contract_terms.exchanges" + "}}"}
+ {exchangesList.map((p, i) => {
+ return (
+ <Fragment key={i}>
+ <dt>Exchange's URL:</dt>
+ <dd>{p.url || `{{url}}`}</dd>
+ <dt>Public key:</dt>
+ <dd>{p.master_pub || `{{master_pub}}`}</dd>
+ </Fragment>
+ );
+ })}
+ {btr && "{{" + "/contract_terms.exchanges" + "}}"}
+ </TableExpanded>
+ </section>
+ )}
+ {btr && `{{/contract_terms.hasExchanges}}`}
+ </section>
+
+ <Footer />
+ </Page>
+ );
+}
+
+export function mount(): void {
+ try {
+ const fromLocation = new URL(window.location.href).searchParams;
+ const os = fromLocation.get("order_summary") || undefined;
+ if (os) {
+ render(<Head order_summary={os} />, document.head);
+ }
+
+ const ra = fromLocation.get("refund_amount") || undefined;
+ const ct = fromLocation.get("contract_terms") || undefined;
+
+ let contractTerms: MerchantBackend.ContractTerms | undefined;
+ try {
+ contractTerms = JSON.parse((window as any).contractTermsStr);
+ } catch { }
+
+ render(
+ <ShowOrderDetails
+ contract_terms={contractTerms}
+ order_summary={os}
+ refund_amount={ra}
+ />,
+ document.body,
+ );
+ } catch (e) {
+ console.error("got error", e);
+ if (e instanceof Error) {
+ document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`;
+ }
+ }
+}
+
+export function buildTimeRendering(): { head: string; body: string } {
+ return {
+ head: renderToString(<Head />),
+ body: renderToString(<ShowOrderDetails btr />),
+ };
+}
diff --git a/packages/merchant-backend-ui/src/render-examples.ts b/packages/merchant-backend-ui/src/render-examples.ts
new file mode 100644
index 000000000..957e06a58
--- /dev/null
+++ b/packages/merchant-backend-ui/src/render-examples.ts
@@ -0,0 +1,112 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import fs from "fs";
+import mustache from "mustache";
+import { createDateToStringFunction, createDurationToStringFunction, createNonEmptyFunction } from "./utils.js"
+import { exampleData as ShowOrderDetailsExamples } from "./pages/ShowOrderDetails.examples.js";
+/**
+ * This script will emulate what the merchant backend will do when being requested
+ *
+ */
+
+const templateDirectory = process.argv[2];
+const destDirectory = process.argv[3];
+
+if (!templateDirectory || !destDirectory) {
+ console.log("usage: render-mustache <source-directory> <dest-directory>");
+ process.exit(1);
+}
+
+if (!fs.existsSync(destDirectory)) {
+ fs.mkdirSync(destDirectory);
+}
+
+function fromCamelCaseName(name: string) {
+ const result = name
+ .replace(/^[a-z]/, (letter) => `${letter.toUpperCase()}`) //first letter lowercase
+ .replace(/_[a-z]/g, (letter) => `${letter[1].toUpperCase()}`); //snake case
+ return result;
+}
+/**
+ * Load all the html files
+ */
+const templateFiles = fs.readdirSync(templateDirectory).filter((f) => /.html/.test(f));
+const exampleByTemplate: Record<string, any> = {
+ "show_order_details.en.html": ShowOrderDetailsExamples
+}
+
+templateFiles.forEach((templateFile) => {
+ const html = fs.readFileSync(`${templateDirectory}/${templateFile}`, "utf8");
+
+ const templateFileWithoutExt = templateFile.replace(".en.html", "");
+ // const exampleFileName = `src/pages/${fromCamelCaseName(testName)}.examples`;
+ // if (!fs.existsSync(`./${exampleFileName}.ts`)) {
+ // console.log(`- skipping ${testName}: no examples found`);
+ // return;
+ // }
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ // const pepe = `./${exampleFileName}.ts`
+ // const { exampleData } = require(pepe);
+
+ const exampleData = exampleByTemplate[templateFile]
+ if (!exampleData) {
+ console.log(`- skipping ${templateFile}: no examples found`);
+ return;
+ }
+ const exampleNames = Object.keys(exampleData)
+ console.log(`+ rendering ${templateFile}: ${exampleNames.length} examples`);
+ exampleNames.forEach((exampleName) => {
+ const example = exampleData[exampleName];
+
+ //enhance the example with more information
+ example.contract_terms_json = () => JSON.stringify(example.contract_terms);
+
+ example.contract_terms.timestamp_str = createDateToStringFunction(example.contract_terms.timestamp)
+
+ example.contract_terms.hasProducts = createNonEmptyFunction(example.contract_terms.products)
+ example.contract_terms.hasAuditors = createNonEmptyFunction(example.contract_terms.auditors)
+ example.contract_terms.hasExchanges = createNonEmptyFunction(example.contract_terms.exchanges)
+
+ example.contract_terms.products.forEach((p: any) => {
+ p.delivery_date_str = createDateToStringFunction(p.delivery_date)
+ p.hasTaxes = createNonEmptyFunction(p.taxes)
+ });
+
+ example.contract_terms.has_delivery_info = () =>
+ example.contract_terms.delivery_date ||
+ example.contract_terms.delivery_location;
+
+ example.contract_terms.delivery_date_str = createDateToStringFunction(example.contract_terms.delivery_date)
+ example.contract_terms.pay_deadline_str = createDateToStringFunction(example.contract_terms.pay_deadline)
+ example.contract_terms.wire_transfer_deadline_str = createDateToStringFunction(example.contract_terms.wire_transfer_deadline)
+
+ example.contract_terms.refund_deadline_str = createDateToStringFunction(example.contract_terms.refund_deadline)
+ example.contract_terms.auto_refund_str = createDurationToStringFunction(example.contract_terms.auto_refund)
+
+ const output = mustache.render(html, example);
+
+ fs.writeFileSync(
+ `${destDirectory}/${templateFileWithoutExt}.${exampleName}.html`,
+ output,
+ );
+ });
+});
diff --git a/packages/merchant-backend-ui/src/styled/index.tsx b/packages/merchant-backend-ui/src/styled/index.tsx
new file mode 100644
index 000000000..55803b9cd
--- /dev/null
+++ b/packages/merchant-backend-ui/src/styled/index.tsx
@@ -0,0 +1,178 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+import { styled } from '@linaria/react'
+
+export const QRPlaceholder = styled.div`
+ margin: auto;
+ text-align: center;
+ width: 340px;
+`
+
+export const FooterBar = styled.footer`
+ text-align: center;
+ background-color: #033;
+ color: white;
+ padding: 1em;
+ overflow: auto;
+
+ & > p > a:link,
+ & > p > a:visited,
+ & > p > a:hover,
+ & > p > a:active {
+ color: white;
+ }
+`
+
+export const Page = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-height: 100vh;
+ align-items: center;
+
+ a:link,
+ a:visited,
+ a:hover,
+ a:active {
+ color: black;
+ }
+
+ section {
+ text-align: center;
+ width: 600px;
+ /* margin: auto; */
+ /* margin-top: 0px; */
+ margin-bottom: auto;
+ /* overflow: auto; */
+ }
+ section:not(:first-of-type) {
+ margin-top: 2em;
+ }
+ & > header {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ text-align: center;
+ }
+ & > footer {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-around;
+ width: 100%;
+ margin-bottom: 0px;
+ }
+`
+export const Center = styled.div`
+ display: flex;
+ justify-content: center;
+`
+
+export const WalletLink = styled.a<{ upperCased?: boolean }>`
+ display: inline-block;
+ zoom: 1;
+ line-height: normal;
+ white-space: nowrap;
+ vertical-align: middle;
+ text-align: center;
+ cursor: pointer;
+ user-select: none;
+ box-sizing: border-box;
+ text-transform: ${({ upperCased }) => upperCased ? 'uppercase' : 'none'};
+
+ font-family: inherit;
+ font-size: 100%;
+ padding: 0.5em 1em;
+ color: #444; /* rgba not supported (IE 8) */
+ color: rgba(0, 0, 0, 0.8); /* rgba supported */
+ border: 1px solid #999; /*IE 6/7/8*/
+ border: none rgba(0, 0, 0, 0); /*IE9 + everything else*/
+ background-color: '#e6e6e6';
+ text-decoration: none;
+ border-radius: 2px;
+
+ :focus {
+ outline: 0;
+ }
+
+ &:disabled {
+ border: none;
+ background-image: none;
+ /* csslint ignore:start */
+ filter: alpha(opacity=40);
+ /* csslint ignore:end */
+ opacity: 0.4;
+ cursor: not-allowed;
+ box-shadow: none;
+ pointer-events: none;
+ }
+
+ :hover {
+ filter: alpha(opacity=90);
+ background-image: linear-gradient(
+ transparent,
+ rgba(0, 0, 0, 0.05) 40%,
+ rgba(0, 0, 0, 0.1)
+ );
+ }
+
+ background-color: #e6e6e6;
+ border-radius: 4px;
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15) inset,
+ 0 0 6px rgba(0, 0, 0, 0.2) inset;
+ border-color: #000;
+`;
+
+export const InfoBox = styled.div`
+ border-radius: 0.25em;
+ flex-direction: column;
+ /* margin: 0.5em; */
+ padding: 1em;
+ /* width: 100%; */
+ border:solid 1px #b8daff;
+ background-color:#cce5ff;
+ color:#004085;
+`
+
+export const TableExpanded = styled.dl`
+ text-align: left;
+ dt {
+ font-weight: bold;
+ margin-top: 1em;
+ }
+ dd {
+ margin-inline-start: 0px;
+ }
+`
+
+export const TableSimple = styled.dl`
+ text-align: left;
+ dt {
+ font-weight: bold;
+ display: inline-block;
+ width:30%;
+ }
+ dd {
+ margin-inline-start: 0px;
+ display: inline-block;
+ width:70%;
+ }
+` \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/popup/Popup.stories.tsx b/packages/merchant-backend-ui/src/utils.ts
index cd443e9d4..0a420aa22 100644
--- a/packages/taler-wallet-webextension/src/popup/Popup.stories.tsx
+++ b/packages/merchant-backend-ui/src/utils.ts
@@ -14,31 +14,28 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createExample } from '../test-utils';
-import { NavBar as TestedComponent } from '../NavigationBar';
-
-export default {
- title: 'popup/header',
- // component: TestedComponent,
- argTypes: {
- onRetry: { action: 'onRetry' },
- onDelete: { action: 'onDelete' },
- onBack: { action: 'onBack' },
+import { format, formatDuration, intervalToDuration } from "date-fns";
+
+export const TIME_DATE_FORMAT = "dd MMM yyyy HH:mm:ss"
+
+export function createDateToStringFunction(date: any) {
+ return () => {
+ if (!date) return "";
+ return format(date.t_s * 1000, TIME_DATE_FORMAT);
}
-};
+}
+export function createDurationToStringFunction(duration: any) {
+ return () => {
+ if (!duration) return "";
+ return formatDuration(intervalToDuration({ start: 0, end: duration.d_us }));
+ }
+}
-export const OnBalance = createExample(TestedComponent, {
- devMode:false,
- path:'/balance'
-});
+export function createNonEmptyFunction(list: any) {
+ return () => {
+ if (!list) return false;
+ return list.length > 0;
+ }
+}
-export const OnHistoryWithDevMode = createExample(TestedComponent, {
- devMode:true,
- path:'/history'
-});
diff --git a/packages/merchant-backend-ui/trim-extension.cjs b/packages/merchant-backend-ui/trim-extension.cjs
new file mode 100644
index 000000000..00e8f9f01
--- /dev/null
+++ b/packages/merchant-backend-ui/trim-extension.cjs
@@ -0,0 +1,23 @@
+// Simple plugin to trim extensions from the filename of relative import statements.
+// Required to get linaria to work with `moduleResulution: "Node16"` imports.
+// @author Florian Dold
+module.exports = function({ types: t }) {
+ return {
+ name: "trim-extension",
+ visitor: {
+ ImportDeclaration: (x) => {
+ const src = x.node.source;
+ if (src.value.startsWith(".")) {
+ if (src.value.endsWith(".js")) {
+ const newVal = src.value.replace(/[.]js$/, "")
+ x.node.source = t.stringLiteral(newVal);
+ }
+ }
+ if (src.value.endsWith(".jsx")) {
+ const newVal = src.value.replace(/[.]jsx$/, "")
+ x.node.source = t.stringLiteral(newVal);
+ }
+ },
+ }
+ };
+}
diff --git a/packages/merchant-backend-ui/tsconfig.json b/packages/merchant-backend-ui/tsconfig.json
new file mode 100644
index 000000000..d9cd57c4e
--- /dev/null
+++ b/packages/merchant-backend-ui/tsconfig.json
@@ -0,0 +1,61 @@
+{
+ "compilerOptions": {
+ /* Basic Options */
+ "target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */
+ "module": "ESNext", /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
+ // "lib": [], /* Specify library files to be included in the compilation: */
+ "allowJs": true, /* Allow javascript files to be compiled. */
+ // "checkJs": true, /* Report errors in .js files. */
+ "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
+ "jsxFactory": "h", /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */
+ "jsxFragmentFactory": "Fragment", // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#custom-jsx-factories
+ // "declaration": true, /* Generates corresponding '.d.ts' file. */
+ // "sourceMap": true, /* Generates corresponding '.map' file. */
+ // "outFile": "./", /* Concatenate and emit output to single file. */
+ // "outDir": "./", /* Redirect output structure to the directory. */
+ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+ // "removeComments": true, /* Do not emit comments to output. */
+ "noEmit": true, /* Do not emit outputs. */
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+
+ /* Strict Type-Checking Options */
+ "strict": true, /* Enable all strict type-checking options. */
+ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* Enable strict null checks. */
+ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
+
+ /* Additional Checks */
+ // "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+
+ /* Module Resolution Options */
+ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+ "esModuleInterop": true, /* */
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+
+ /* Source Map Options */
+ // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+
+ /* Advanced Options */
+ "skipLibCheck": true /* Skip type checking of declaration files. */
+ },
+ "include": ["src/**/*", "tests/**/*"]
+}
diff --git a/packages/merchant-backoffice-ui/.eslintrc.cjs b/packages/merchant-backoffice-ui/.eslintrc.cjs
new file mode 100644
index 000000000..05618b499
--- /dev/null
+++ b/packages/merchant-backoffice-ui/.eslintrc.cjs
@@ -0,0 +1,28 @@
+module.exports = {
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react/recommended',
+ ],
+ parser: '@typescript-eslint/parser',
+ plugins: ['@typescript-eslint', 'header'],
+ root: true,
+ rules: {
+ "react/no-unknown-property": 0,
+ "react/no-unescaped-entities": 0,
+ "@typescript-eslint/no-namespace": 0,
+ "@typescript-eslint/no-unused-vars": [2,{argsIgnorePattern:"^_"}],
+ "header/header": [2,"copyleft-header.js"]
+ },
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ jsx: true,
+ },
+ settings: {
+ react: {
+ version: "18",
+ pragma: "h",
+ }
+ },
+};
diff --git a/packages/merchant-backoffice-ui/.gitignore b/packages/merchant-backoffice-ui/.gitignore
new file mode 100644
index 000000000..df149101c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/.gitignore
@@ -0,0 +1,6 @@
+/build
+/size-plugin.json
+/storybook-static
+/docs
+/single
+/coverage
diff --git a/packages/merchant-backoffice-ui/DESIGN.md b/packages/merchant-backoffice-ui/DESIGN.md
new file mode 100644
index 000000000..d6252ccdc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/DESIGN.md
@@ -0,0 +1,195 @@
+# Page internal routing
+
+* The SPA is loaded from the BACKOFFICE_URL
+
+* The view to be rendered is decided by the URL fragment
+
+* Query parameters that may affect routing
+
+ - instance: use from the default instance to mimic another instance management
+
+* The user must provide BACKEND_URL or BACKOFFICE_URL will use as default
+
+* Token for querying the backend will be saved in localStorage under
+ backend-token-${name}
+
+# HTTP queries to the backend
+
+HTTP queries will have 4 states:
+
+* loading: request did not end yet. data and error are undefined
+
+* ok: data has information, http response status == 200
+
+* clientError: http response status is between 400 and 499
+
+ - notfound: http status 404
+
+ - unauthorized: http status 401
+
+* serverError: http response status is grater than 500
+
+There are categories of queries:
+
+ * sync: getting information for the page rendering
+
+ * async: performing an CRUD operation
+
+## Loading the page information (sync)
+
+In this scenario, a failed request will make the app flow to break.
+
+When receiving an not found error a generic not found page will be shown. If the
+BACKEND_URL points to a default instance it should send the user to create the
+instance.
+
+When receiving an unauthorized error, the user should be prompted with a login form.
+
+When receiving an another error (400 < http status < 600), the login form should
+be shown with an error message using the hint from the backend.
+
+On other unexpected error (like network error), the login form should be shown
+with an error message.
+
+## CRUD operation (async)
+
+In this scenario, a failed request does not break the flow but a message will be
+prompted.
+
+# Forms
+
+All the input components should be placed in the folder `src/components/from`.
+
+The core concepts are:
+
+ * <FormProvider<T> /> places instead of <form /> it should be mapped to an
+ object of type T
+
+ * <Input /> an others: defines UI, create <input /> DOM controls and access the
+ form with useField()
+
+To use it you will need a state somewhere with the object holding all the form
+information.
+
+```
+const [state, setState] = useState({ name: '', age: 11 })
+```
+
+Optionally an error object an be built with the error messages
+
+```
+const errors = {
+ field1: undefined,
+ field2: 'should be greater than 18',
+}
+```
+
+These 3 elements are used to setup the FormProvider
+
+```
+<FormProvider errors={errors} object={state} valueHandler={setState}>
+...inputs
+</FormProvider>
+```
+
+Inputs should handle UI rendering and use `useField(name)` to get:
+
+ * error: the field has been modified and the value is not correct
+ * required: the field need to be corrected
+ * value: the current value of the object
+ * initial: original value before change
+ * onChange: function to update the current field
+
+Also, every input must be ready to receive these properties
+
+ * name: property of the form object being manipulated
+ * label: how the name of the property will be shown in the UI
+ * placeholder: optional, inplace text when there is no value yet
+ * readonly: default to false, will prevent change the value
+ * help: optional, example text below the input text to help the user
+ * tooltip: optional, will add a (i) with a popup to describe the field
+
+
+# Custom Hooks
+
+All the general purpose hooks should be placed in folder `src/hooks` and tests
+under `tests/hooks`. Starts with the `use` word.
+
+# Contexts
+
+All the contexts should be placed in the folder `src/context` as a function.
+Should expose provider as a component `<XxxContextProvider />` and consumer as a
+hook function `useXxxContext()` (where XXX is the name)
+
+# Components
+
+Type of components:
+
+ * main entry point: src/index.tsx, mostly initialization
+
+ * routing: in the `src` folder, deciding who is going to take the work. That's
+ when the page is loading but also create navigation handlers
+
+ * pages: in the `paths` folder, setup page information (like querying the
+ backend for the list of things), handlers for CRUD events, delegated routing
+ to parent and UI to children.
+
+Some other guidelines:
+
+ * Hooks over classes are preferred
+
+ * Components that are ready to be reused on any place should be in
+ `src/components` folder
+
+ * Since one of the build targets is a single bundle with all the pages, we are
+ avoiding route based code splitting
+ https://github.com/preactjs/preact-cli#route-based-code-splitting
+
+
+# Testing
+
+Every components should have examples using storybook (xxx.stories.tsx). There
+is an automated test that check that every example can be rendered so we make
+sure that we do not add a regression.
+
+Every hook should have examples under `tests/hooks` with common usage trying to
+follow this structure:
+
+ * (Given) set some context of the initial condition
+
+ * (When) some action to be tested. May be the initialization of a hook or an
+ action associated with it
+
+ * (Then) a particular set of observable consequences should be expected
+
+# Accessibility
+
+Pages and components should be built with accessibility in mind.
+
+https://github.com/nickcolley/jest-axe
+https://orkhanhuseyn.medium.com/accessibility-testing-in-react-with-jest-axe-e08c2a3f3289
+http://accesibilidadweb.dlsi.ua.es/?menu=jaws
+https://webaim.org/projects/screenreadersurvey8/#intro
+https://www.gov.uk/service-manual/technology/testing-with-assistive-technologies#how-to-test
+https://es.reactjs.org/docs/accessibility.html
+
+# Internationalization
+
+Every non translated message should be written in English and wrapped into:
+
+ * i18n function from useTranslator() hook
+ * <Translate /> component
+
+Makefile has a i18n that will parse source files and update the po template.
+When *.po are updated, running the i18n target will create the strings.ts that
+the application will use in runtime.
+
+# Documentation Conventions
+
+* labels
+ * begin w/ a capital letter
+ * acronyms (e.g., "URL") are upper case
+* tooltips
+ * begin w/ a lower case letter
+ * do not end w/ punctuation (period)
+ * avoid leading article ("a", "an", "the")
diff --git a/packages/merchant-backoffice-ui/Makefile b/packages/merchant-backoffice-ui/Makefile
new file mode 100644
index 000000000..7175ef723
--- /dev/null
+++ b/packages/merchant-backoffice-ui/Makefile
@@ -0,0 +1,35 @@
+# This Makefile has been placed in the public domain
+
+ifeq ($(TOPLEVEL), yes)
+ $(info top-level build)
+ -include ../../.config.mk
+ override DESTDIR := $(TOP_DESTDIR)
+else
+ $(info package-level build)
+ -include ../../.config.mk
+ -include .config.mk
+endif
+
+$(info prefix is $(prefix))
+
+.PHONY: all
+all:
+ @echo run \'make install\' to install
+
+spa_dir=$(DESTDIR)$(prefix)/share/taler/merchant-backoffice
+
+.PHONY: deps
+deps:
+ pnpm install --frozen-lockfile --filter @gnu-taler/merchant-backoffice...
+ pnpm run build
+
+.PHONY: install-nodeps
+install-nodeps:
+ (cd dist/prod && find . -type f -exec install -D "{}" "$(spa_dir)/{}" \;)
+
+
+.PHONY: install
+install:
+ $(MAKE) deps
+ $(MAKE) install-nodeps
+
diff --git a/packages/merchant-backoffice-ui/README.md b/packages/merchant-backoffice-ui/README.md
new file mode 100644
index 000000000..34bb98b67
--- /dev/null
+++ b/packages/merchant-backoffice-ui/README.md
@@ -0,0 +1,64 @@
+## Merchant Admin Frontend
+
+Merchant Admin Frontend is a Single Page Application (SPA) that connects with a running Merchant Backend and lets you manage instances, orders, products and tipping.
+
+## System requirements
+
+- Node: v16.15.0
+- pnpm: 7.14.2
+- make
+
+## Compiling from source
+
+Run `pnpm install --frozen-lockfile --filter @gnu-taler/merchant-backoffice...` to install all the nodejs dependencies.
+
+Then the command `pnpm build` create the distribution in the `dist` folder.
+
+By default the installation prefix will be `/usr/local/share/taler/merchant-backoffice/` but it can be overridden by `--prefix` in the configuration process:
+
+```shell
+./configure --prefix=/another/directory
+```
+
+To install run `make install`
+
+## Running develop
+
+To run a development server run:
+
+```shell
+./dev.mjs
+```
+
+This should start a watch process that will reload the server every time that a file is saved.
+
+The application need to connect to a merchant-backend properly configured to run.
+
+## Building for deploy
+
+To build and deploy the SPA in your local server run the install script:
+
+```shell
+make install
+```
+
+## Runtime dependencies
+
+* preact: Fast 3kB alternative to React with the same modern API
+
+* preact-router: URL component router for Preact
+
+* SWR: React Hooks library for data fetching (stale-while-revalidate)
+
+* Yup: schema builder for value parsing and validation (to be deprecated)
+
+* Date-fns: library for manipulating javascript date
+
+* qrcode-generator: simplest qr implementation based on JIS X 0510:1999
+
+* @gnu-taler/taler-util: types and tooling
+
+* history: manage the history stack, navigate, and persist state between sessions
+
+* jed: gettext like library for internationalization
+
diff --git a/packages/merchant-backoffice-ui/build.mjs b/packages/merchant-backoffice-ui/build.mjs
new file mode 100755
index 000000000..b6d6e5127
--- /dev/null
+++ b/packages/merchant-backoffice-ui/build.mjs
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build } from "@gnu-taler/web-util/build";
+
+await build({
+ type: "production",
+ source: {
+ js: ["src/index.tsx"],
+ assets: [{base:"src",files:["src/index.html"]}],
+ },
+ destination: "./dist/prod",
+ css: "sass",
+});
diff --git a/packages/merchant-backoffice-ui/contrib/po2ts b/packages/merchant-backoffice-ui/contrib/po2ts
new file mode 100755
index 000000000..a135da61b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/contrib/po2ts
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Convert a <lang>.po file into a JavaScript / TypeScript expression.
+ */
+
+const po2json = require("po2json");
+
+const filename = process.argv[2];
+
+if (!filename) {
+ console.error("error: missing filename");
+ process.exit(1);
+}
+
+const m = filename.match(/([a-zA-Z0-9-_]+).po/);
+
+if (!m) {
+ console.error("error: unexpected filename (expected <lang>.po)");
+ process.exit(1);
+}
+
+const lang = m[1];
+const pojson = po2json.parseFileSync(filename, { format: "jed1.x", fuzzy: true });
+const s =
+ "strings['" + lang + "'] = " + JSON.stringify(pojson, null, " ") + ";\n";
+console.log(s);
diff --git a/packages/merchant-backoffice-ui/copyleft-header.js b/packages/merchant-backoffice-ui/copyleft-header.js
new file mode 100644
index 000000000..2589fdc92
--- /dev/null
+++ b/packages/merchant-backoffice-ui/copyleft-header.js
@@ -0,0 +1,15 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
diff --git a/packages/merchant-backoffice-ui/dev.mjs b/packages/merchant-backoffice-ui/dev.mjs
new file mode 100755
index 000000000..6cc99add0
--- /dev/null
+++ b/packages/merchant-backoffice-ui/dev.mjs
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { serve } from "@gnu-taler/web-util/node";
+import { initializeDev } from "@gnu-taler/web-util/build";
+
+const devEntryPoints = ["src/stories.tsx", "src/index.tsx"];
+
+const build = initializeDev({
+ type: "development",
+ source: {
+ js: devEntryPoints,
+ assets: [{base:"src",files:["src/index.html"]}],
+ },
+ css: "sass",
+ destination: "./dist/dev",
+});
+
+await build();
+
+serve({
+ folder: "./dist/dev",
+ port: 8080,
+ source: "./src",
+ onSourceUpdate: build,
+});
diff --git a/packages/merchant-backoffice-ui/package.json b/packages/merchant-backoffice-ui/package.json
new file mode 100644
index 000000000..bc56c3b77
--- /dev/null
+++ b/packages/merchant-backoffice-ui/package.json
@@ -0,0 +1,70 @@
+{
+ "private": true,
+ "name": "@gnu-taler/merchant-backoffice-ui",
+ "version": "0.10.6",
+ "license": "AGPL-3.0-or-later",
+ "type": "module",
+ "scripts": {
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
+ "build": "./build.mjs",
+ "check": "tsc",
+ "compile": "tsc && ./build.mjs",
+ "dev": "preact watch --port ${PORT:=8080} --no-sw --no-esm",
+ "test": "./test.mjs && mocha --require source-map-support/register 'dist/**/*.test.js' 'dist/**/test.js'",
+ "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
+ "i18n:extract": "pogen extract",
+ "i18n:merge": "pogen merge",
+ "i18n:emit": "pogen emit",
+ "i18n": "pnpm i18n:extract && pnpm i18n:merge && pnpm i18n:emit",
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "pretty": "prettier --write src"
+ },
+ "dependencies": {
+ "@gnu-taler/taler-util": "workspace:*",
+ "@gnu-taler/web-util": "workspace:*",
+ "date-fns": "2.29.3",
+ "history": "4.10.1",
+ "jed": "1.1.1",
+ "preact": "10.11.3",
+ "preact-router": "3.2.1",
+ "qrcode-generator": "1.4.4",
+ "swr": "2.2.2",
+ "yup": "^0.32.9"
+ },
+ "devDependencies": {
+ "eslint": "^8.56.0",
+ "@typescript-eslint/eslint-plugin": "^6.19.0",
+ "@typescript-eslint/parser": "^6.19.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-react": "^7.33.2",
+ "@creativebulma/bulma-tooltip": "^1.2.0",
+ "@gnu-taler/pogen": "^0.0.5",
+ "@types/chai": "^4.3.0",
+ "@types/history": "^4.7.8",
+ "@types/mocha": "^8.2.3",
+ "@types/node": "^18.11.17",
+ "base64-inline-loader": "^1.1.1",
+ "bulma": "^0.9.2",
+ "bulma-checkbox": "^1.1.1",
+ "bulma-radio": "^1.1.1",
+ "bulma-responsive-tables": "^1.2.3",
+ "bulma-switch-control": "^1.1.1",
+ "bulma-timeline": "^3.0.4",
+ "bulma-upload-control": "^1.2.0",
+ "chai": "^4.3.6",
+ "dotenv": "^8.2.0",
+ "html-webpack-inline-chunk-plugin": "^1.1.1",
+ "html-webpack-inline-source-plugin": "0.0.10",
+ "html-webpack-skip-assets-plugin": "^1.0.1",
+ "inline-chunk-html-plugin": "^1.1.1",
+ "mocha": "^9.2.0",
+ "preact-render-to-string": "^5.2.6",
+ "sass": "1.56.1",
+ "source-map-support": "^0.5.21",
+ "typedoc": "^0.25.4",
+ "typescript": "5.3.3"
+ },
+ "pogen": {
+ "domain": "taler-merchant-backoffice"
+ }
+}
diff --git a/packages/merchant-backoffice-ui/preact.config.js b/packages/merchant-backoffice-ui/preact.config.js
new file mode 100644
index 000000000..b20017a0c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/preact.config.js
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+
+import { DefinePlugin } from 'webpack';
+
+import pack from './package.json';
+import * as cp from 'child_process';
+
+const commitHash = cp.execSync('git rev-parse --short HEAD').toString();
+
+export default {
+ webpack(config, env, helpers) {
+ // ensure that process.env will not be undefined on runtime
+ config.node.process = 'mock'
+
+ // add __VERSION__ to be use in the html
+ config.plugins.push(
+ new DefinePlugin({
+ 'process.env.__VERSION__': JSON.stringify(env.isProd ? pack.version : `dev-${commitHash}`) ,
+ }),
+ );
+
+ // suddenly getting out of memory error from build process, error below [1]
+ // FIXME: remove preact-cli, use rollup
+ let { index } = helpers.getPluginsByName(config, 'WebpackFixStyleOnlyEntriesPlugin')[0]
+ config.plugins.splice(index, 1)
+ }
+}
+
+
+
+/* [1] from this error decided to remove plugin 'webpack-fix-style-only-entries
+ leaving this error for future reference
+
+
+<--- Last few GCs --->
+
+[32479:0x2e01870] 19969 ms: Mark-sweep 1869.4 (1950.2) -> 1443.1 (1504.1) MB, 497.5 / 0.0 ms (average mu = 0.631, current mu = 0.455) allocation failure scavenge might not succeed
+[32479:0x2e01870] 21907 ms: Mark-sweep 2016.9 (2077.9) -> 1628.6 (1681.4) MB, 1596.0 / 0.0 ms (average mu = 0.354, current mu = 0.176) allocation failure scavenge might not succeed
+
+<--- JS stacktrace --->
+
+==== JS stack trace =========================================
+
+ 0: ExitFrame [pc: 0x13cf099]
+Security context: 0x2f4ca66c08d1 <JSObject>
+ 1: /* anonymous * / [0x35d05555b4b9] [...path/merchant-backoffice/node_modules/.pnpm/webpack-fix-style-only-entries@0.5.2/node_modules/webpack-fix-style-only-entries/index.js:~80] [pc=0x2145e699d1a4](this=0x1149465410e9 <GlobalObject Object map = 0xff481b5b5f9>,0x047e52e36a49 <Dependency map = 0x1ed1fe41cd19>)
+ 2: arguments adaptor frame: 3...
+
+FATAL ERROR: invalid array length Allocation failed - JavaScript heap out of memory
+
+*/ \ No newline at end of file
diff --git a/packages/merchant-backoffice-ui/preact.single-config.js b/packages/merchant-backoffice-ui/preact.single-config.js
new file mode 100644
index 000000000..d3640a5a6
--- /dev/null
+++ b/packages/merchant-backoffice-ui/preact.single-config.js
@@ -0,0 +1,62 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+
+import defaultConfig from './preact.config'
+
+export default {
+ webpack(config, env, helpers, options) {
+ defaultConfig.webpack(config, env, helpers, options)
+
+ //1. check no file is under /routers or /component/{routers,async} to prevent async components
+ // https://github.com/preactjs/preact-cli#route-based-code-splitting
+
+ //2. remove devtools to prevent sourcemaps
+ config.devtool = false
+
+ //3. change assetLoader to load assets inline
+ const loaders = helpers.getLoaders(config)
+ const assetsLoader = loaders.find(lo => lo.rule.test.test('something.woff'))
+ if (assetsLoader) {
+ assetsLoader.rule.use = 'base64-inline-loader'
+ assetsLoader.rule.loader = undefined
+ }
+
+ //4. remove critters
+ //critters remove the css bundle from htmlWebpackPlugin.files.css
+ //for now, pushing all the content into the html is enough
+ const crittersWrapper = helpers.getPluginsByName(config, 'Critters')
+ if (crittersWrapper && crittersWrapper.length > 0) {
+ const [{ index }] = crittersWrapper
+ config.plugins.splice(index, 1)
+ }
+
+ //5. remove favicon from src/assets
+
+ //6. remove performance hints since we now that this is going to be big
+ if (config.performance) {
+ config.performance.hints = false
+ }
+
+ //7. template.html should have a favicon and add js/css content
+
+ //last, after building remove the mysterious link to stylesheet with remove-link-stylesheet.sh
+ }
+}
diff --git a/packages/merchant-backoffice-ui/remove-link-stylesheet.sh b/packages/merchant-backoffice-ui/remove-link-stylesheet.sh
new file mode 100644
index 000000000..fdf8f241c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/remove-link-stylesheet.sh
@@ -0,0 +1,8 @@
+# This script has been placed in the public domain.
+
+FILE=$(ls single/bundle.*.css)
+BUNDLE=${FILE#single}
+grep -q '<link href="'$BUNDLE'" rel="stylesheet">' single/index.html || { echo bundle $BUNDLE not found in index.html; exit 1; }
+echo -n Removing link from index.html ...
+sed 's_<link href="'$BUNDLE'" rel="stylesheet">__' -i single/index.html
+echo done
diff --git a/packages/merchant-backoffice-ui/src/AdminRoutes.tsx b/packages/merchant-backoffice-ui/src/AdminRoutes.tsx
new file mode 100644
index 000000000..b186f1408
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/AdminRoutes.tsx
@@ -0,0 +1,52 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { Router, route, Route } from "preact-router";
+import InstanceCreatePage from "./paths/admin/create/index.js";
+import InstanceListPage from "./paths/admin/list/index.js";
+import { InstancePaths } from "./Routing.js";
+
+export enum AdminPaths {
+ list_instances = "/instances",
+ new_instance = "/instance/new",
+}
+
+export function AdminRoutes(): VNode {
+ return (
+ <Router>
+ <Route
+ path={AdminPaths.list_instances}
+ component={InstanceListPage}
+ onCreate={() => {
+ route(AdminPaths.new_instance);
+ }}
+ onUpdate={(id: string): void => {
+ route(`/instance/${id}/update`);
+ }}
+ />
+
+ <Route
+ path={AdminPaths.new_instance}
+ component={InstanceCreatePage}
+ onBack={() => route(AdminPaths.list_instances)}
+ onConfirm={() => {
+ route(InstancePaths.bank_list);
+ }}
+
+ />
+ </Router>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/Application.tsx b/packages/merchant-backoffice-ui/src/Application.tsx
new file mode 100644
index 000000000..097e98567
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/Application.tsx
@@ -0,0 +1,364 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ CacheEvictor,
+ TalerMerchantApi,
+ TalerMerchantInstanceCacheEviction,
+ TalerMerchantManagementCacheEviction,
+ assertUnreachable,
+ canonicalizeBaseUrl,
+} from "@gnu-taler/taler-util";
+import {
+ BrowserHashNavigationProvider,
+ ConfigResultFail,
+ MerchantApiProvider,
+ TalerWalletIntegrationBrowserProvider,
+ TranslationProvider,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { SWRConfig } from "swr";
+import { Routing } from "./Routing.js";
+import { Loading } from "./components/exception/loading.js";
+import { NotificationCard } from "./components/menu/index.js";
+import { SettingsProvider } from "./context/settings.js";
+import {
+ revalidateBankAccountDetails,
+ revalidateInstanceBankAccounts,
+} from "./hooks/bank.js";
+import {
+ revalidateBackendInstances,
+ revalidateInstanceDetails,
+ revalidateManagedInstanceDetails,
+} from "./hooks/instance.js";
+import {
+ revalidateInstanceOtpDevices,
+ revalidateOtpDeviceDetails,
+} from "./hooks/otp.js";
+import {
+ revalidateInstanceProducts,
+ revalidateProductDetails,
+} from "./hooks/product.js";
+import {
+ revalidateInstanceTemplates,
+ revalidateTemplateDetails,
+} from "./hooks/templates.js";
+import { revalidateInstanceTransfers } from "./hooks/transfer.js";
+import {
+ revalidateInstanceWebhooks,
+ revalidateWebhookDetails,
+} from "./hooks/webhooks.js";
+import { strings } from "./i18n/strings.js";
+import {
+ MerchantUiSettings,
+ buildDefaultBackendBaseURL,
+ fetchSettings,
+} from "./settings.js";
+import {
+ revalidateInstanceOrders,
+ revalidateOrderDetails,
+} from "./hooks/order.js";
+import { SessionContextProvider } from "./context/session.js";
+const WITH_LOCAL_STORAGE_CACHE = false;
+
+export function Application(): VNode {
+ const [settings, setSettings] = useState<MerchantUiSettings>();
+ useEffect(() => {
+ fetchSettings(setSettings);
+ }, []);
+ if (!settings) return <Loading />;
+
+ const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL);
+ return (
+ <SettingsProvider value={settings}>
+ <TranslationProvider
+ source={strings}
+ completeness={{
+ es: strings["es"].completeness,
+ de: strings["de"].completeness,
+ }}
+ >
+ <MerchantApiProvider
+ baseUrl={new URL("./", baseUrl)}
+ frameOnError={OnConfigError}
+ evictors={{
+ management: swrCacheEvictor,
+ }}
+ >
+ <SessionContextProvider>
+ <SWRConfig
+ value={{
+ provider: WITH_LOCAL_STORAGE_CACHE
+ ? localStorageProvider
+ : undefined,
+ // normally, do not revalidate
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ revalidateIfStale: false,
+ revalidateOnMount: undefined,
+ focusThrottleInterval: undefined,
+
+ // normally, do not refresh
+ refreshInterval: undefined,
+ dedupingInterval: 2000,
+ refreshWhenHidden: false,
+ refreshWhenOffline: false,
+
+ // ignore errors
+ shouldRetryOnError: false,
+ errorRetryCount: 0,
+ errorRetryInterval: undefined,
+
+ // do not go to loading again if already has data
+ keepPreviousData: true,
+ }}
+ >
+ <TalerWalletIntegrationBrowserProvider>
+ <BrowserHashNavigationProvider>
+ <Routing />
+ </BrowserHashNavigationProvider>
+ </TalerWalletIntegrationBrowserProvider>
+ </SWRConfig>
+ </SessionContextProvider>
+ </MerchantApiProvider>
+ </TranslationProvider>
+ </SettingsProvider>
+ );
+}
+
+function getInitialBackendBaseURL(
+ backendFromSettings: string | undefined,
+): string {
+ /**
+ * For testing purpose
+ */
+ const overrideUrl =
+ typeof localStorage !== "undefined"
+ ? localStorage.getItem("merchant-base-url")
+ : undefined;
+ let result: string;
+
+ if (overrideUrl) {
+ // testing/development path
+ result = overrideUrl;
+ } else {
+ // normal path
+ if (!backendFromSettings) {
+ console.error(
+ "ERROR: backendBaseURL was overridden by a setting file and missing. Setting value to 'window.origin'",
+ );
+ result = buildDefaultBackendBaseURL();
+ } else {
+ result = backendFromSettings;
+ }
+ }
+ try {
+ return canonicalizeBaseUrl(result);
+ } catch (e) {
+ // fall back
+ return canonicalizeBaseUrl(window.origin);
+ }
+}
+
+function localStorageProvider(): Map<unknown, unknown> {
+ const map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]"));
+
+ window.addEventListener("beforeunload", () => {
+ const appCache = JSON.stringify(Array.from(map.entries()));
+ localStorage.setItem("app-cache", appCache);
+ });
+ return map;
+}
+
+function OnConfigError({
+ state,
+}: {
+ state: ConfigResultFail<TalerMerchantApi.VersionResponse> | undefined;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ if (!state) {
+ return (
+ <i18n.Translate>checking compatibility with server...</i18n.Translate>
+ );
+ }
+ switch (state.type) {
+ case "error": {
+ return (
+ <NotificationCard
+ notification={{
+ message: i18n.str`Contacting the server failed`,
+ description: state.error.message,
+ details: JSON.stringify(state.error.errorDetail, undefined, 2),
+ type: "ERROR",
+ }}
+ />
+ );
+ }
+ case "incompatible": {
+ return (
+ <NotificationCard
+ notification={{
+ message: i18n.str`The server version is not supported`,
+ description: i18n.str`Supported version "${state.supported}", server version "${state.result.version}".`,
+ type: "WARN",
+ }}
+ />
+ );
+ }
+ default:
+ assertUnreachable(state);
+ }
+}
+
+const swrCacheEvictor = new (class
+ implements
+ CacheEvictor<
+ TalerMerchantManagementCacheEviction | TalerMerchantInstanceCacheEviction
+ >
+{
+ async notifySuccess(
+ op:
+ | TalerMerchantManagementCacheEviction
+ | TalerMerchantInstanceCacheEviction,
+ ) {
+ switch (op) {
+ case TalerMerchantManagementCacheEviction.CREATE_INSTANCE: {
+ await Promise.all([revalidateBackendInstances()]);
+ return;
+ }
+ case TalerMerchantManagementCacheEviction.UPDATE_INSTANCE: {
+ await Promise.all([revalidateManagedInstanceDetails()]);
+ return;
+ }
+ case TalerMerchantManagementCacheEviction.DELETE_INSTANCE: {
+ await Promise.all([revalidateBackendInstances()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.UPDATE_CURRENT_INSTANCE: {
+ await Promise.all([revalidateInstanceDetails()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.DELETE_CURRENT_INSTANCE: {
+ await Promise.all([revalidateInstanceDetails()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.CREATE_BANK_ACCOUNT: {
+ await Promise.all([revalidateInstanceBankAccounts()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.UPDATE_BANK_ACCOUNT: {
+ await Promise.all([revalidateBankAccountDetails()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.DELETE_BANK_ACCOUNT: {
+ await Promise.all([revalidateInstanceBankAccounts()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.CREATE_PRODUCT: {
+ await Promise.all([revalidateInstanceProducts()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.UPDATE_PRODUCT: {
+ await Promise.all([revalidateProductDetails()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.DELETE_PRODUCT: {
+ await Promise.all([revalidateInstanceProducts()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.CREATE_TRANSFER: {
+ await Promise.all([revalidateInstanceTransfers()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.DELETE_TRANSFER: {
+ await Promise.all([revalidateInstanceTransfers()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.CREATE_DEVICE: {
+ await Promise.all([revalidateInstanceOtpDevices()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.UPDATE_DEVICE: {
+ await Promise.all([revalidateOtpDeviceDetails()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.DELETE_DEVICE: {
+ await Promise.all([revalidateInstanceOtpDevices()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.CREATE_TEMPLATE: {
+ await Promise.all([revalidateInstanceTemplates()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.UPDATE_TEMPLATE: {
+ await Promise.all([revalidateTemplateDetails()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.DELETE_TEMPLATE: {
+ await Promise.all([revalidateInstanceTemplates()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.CREATE_WEBHOOK: {
+ await Promise.all([revalidateInstanceWebhooks()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.UPDATE_WEBHOOK: {
+ await Promise.all([revalidateWebhookDetails()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.DELETE_WEBHOOK: {
+ await Promise.all([revalidateInstanceWebhooks()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.CREATE_ORDER: {
+ await Promise.all([revalidateInstanceOrders()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.UPDATE_ORDER: {
+ await Promise.all([revalidateOrderDetails()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.DELETE_ORDER: {
+ await Promise.all([revalidateInstanceOrders()]);
+ return;
+ }
+ case TalerMerchantInstanceCacheEviction.LAST:
+ // case TalerMerchantInstanceCacheEviction.CREATE_TOKENFAMILY:{
+ // await Promise.all([
+ // reva
+ // ])
+ // return
+ // }
+ // case TalerMerchantInstanceCacheEviction.UPDATE_TOKENFAMILY:{
+ // await Promise.all([
+ // ])
+ // return
+ // }
+ // case TalerMerchantInstanceCacheEviction.DELETE_TOKENFAMILY:{
+ // await Promise.all([
+ // ])
+ // return
+ // }
+ }
+ }
+})();
diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx
new file mode 100644
index 000000000..665137415
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/Routing.tsx
@@ -0,0 +1,754 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AbsoluteTime,
+ TalerError,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { urlPattern, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { createHashHistory } from "history";
+import { Fragment, VNode, h } from "preact";
+import { Route, Router, route } from "preact-router";
+import { useEffect, useErrorBoundary, useState } from "preact/hooks";
+import { Loading } from "./components/exception/loading.js";
+import {
+ Menu,
+ NotConnectedAppMenu,
+ NotificationCard,
+} from "./components/menu/index.js";
+import { useSessionContext } from "./context/session.js";
+import { useInstanceBankAccounts } from "./hooks/bank.js";
+import { useInstanceKYCDetails } from "./hooks/instance.js";
+import { usePreference } from "./hooks/preference.js";
+import InstanceCreatePage from "./paths/admin/create/index.js";
+import InstanceListPage from "./paths/admin/list/index.js";
+import BankAccountCreatePage from "./paths/instance/accounts/create/index.js";
+import BankAccountListPage from "./paths/instance/accounts/list/index.js";
+import BankAccountUpdatePage from "./paths/instance/accounts/update/index.js";
+import ListKYCPage from "./paths/instance/kyc/list/index.js";
+import OrderCreatePage from "./paths/instance/orders/create/index.js";
+import OrderDetailsPage from "./paths/instance/orders/details/index.js";
+import OrderListPage from "./paths/instance/orders/list/index.js";
+import ValidatorCreatePage from "./paths/instance/otp_devices/create/index.js";
+import ValidatorListPage from "./paths/instance/otp_devices/list/index.js";
+import ValidatorUpdatePage from "./paths/instance/otp_devices/update/index.js";
+import ProductCreatePage from "./paths/instance/products/create/index.js";
+import ProductListPage from "./paths/instance/products/list/index.js";
+import ProductUpdatePage from "./paths/instance/products/update/index.js";
+import TemplateCreatePage from "./paths/instance/templates/create/index.js";
+import TemplateListPage from "./paths/instance/templates/list/index.js";
+import TemplateQrPage from "./paths/instance/templates/qr/index.js";
+import TemplateUpdatePage from "./paths/instance/templates/update/index.js";
+import TemplateUsePage from "./paths/instance/templates/use/index.js";
+import TokenPage from "./paths/instance/token/index.js";
+import TransferCreatePage from "./paths/instance/transfers/create/index.js";
+import TransferListPage from "./paths/instance/transfers/list/index.js";
+import InstanceUpdatePage, {
+ AdminUpdate as InstanceAdminUpdatePage,
+ Props as InstanceUpdatePageProps,
+} from "./paths/instance/update/index.js";
+import WebhookCreatePage from "./paths/instance/webhooks/create/index.js";
+import WebhookListPage from "./paths/instance/webhooks/list/index.js";
+import WebhookUpdatePage from "./paths/instance/webhooks/update/index.js";
+import { LoginPage } from "./paths/login/index.js";
+import { NotFoundPage } from "./paths/notfound/index.js";
+import { Settings } from "./paths/settings/index.js";
+import { Notification } from "./utils/types.js";
+
+export enum InstancePaths {
+ error = "/error",
+ settings = "/settings",
+ token = "/token",
+
+ bank_list = "/bank",
+ bank_update = "/bank/:bid/update",
+ bank_new = "/bank/new",
+
+ inventory_list = "/inventory",
+ inventory_update = "/inventory/:pid/update",
+ inventory_new = "/inventory/new",
+
+ order_list = "/orders",
+ order_new = "/order/new",
+ order_details = "/order/:oid/details",
+
+ reserves_list = "/reserves",
+ reserves_details = "/reserves/:rid/details",
+ reserves_new = "/reserves/new",
+
+ kyc = "/kyc",
+
+ transfers_list = "/transfers",
+ transfers_new = "/transfer/new",
+
+ templates_list = "/templates",
+ templates_update = "/templates/:tid/update",
+ templates_new = "/templates/new",
+ templates_use = "/templates/:tid/use",
+ templates_qr = "/templates/:tid/qr",
+
+ webhooks_list = "/webhooks",
+ webhooks_update = "/webhooks/:tid/update",
+ webhooks_new = "/webhooks/new",
+
+ otp_devices_list = "/otp-devices",
+ otp_devices_update = "/otp-devices/:vid/update",
+ otp_devices_new = "/otp-devices/new",
+
+ interface = "/interface",
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-function
+// const noop = () => { };
+
+export enum AdminPaths {
+ list_instances = "/instances",
+ new_instance = "/instance/new",
+ update_instance = "/instance/:id/update",
+}
+
+export interface Props {}
+
+export const privatePages = {
+ home: urlPattern(/\/home/, () => "#/home"),
+ go: urlPattern(/\/home/, () => "#/home"),
+};
+export const publicPages = {
+ home: urlPattern(/\/home/, () => "#/home"),
+ go: urlPattern(/\/home/, () => "#/home"),
+};
+
+const history = createHashHistory();
+export function Routing(_p: Props): VNode {
+ // const { i18n } = useTranslationContext();
+ const { state } = useSessionContext();
+
+ type GlobalNotifState =
+ | (Notification & { to: string | undefined })
+ | undefined;
+ const [globalNotification, setGlobalNotification] =
+ useState<GlobalNotifState>(undefined);
+
+ const [error] = useErrorBoundary();
+ const [preference] = usePreference();
+
+ const now = AbsoluteTime.now();
+
+ const instance = useInstanceBankAccounts();
+ const accounts =
+ !instance || instance instanceof TalerError || instance.type === "fail"
+ ? undefined
+ : instance.body;
+ const shouldWarnAboutMissingBankAccounts =
+ !state.isAdmin &&
+ accounts !== undefined &&
+ accounts.accounts.length < 1 &&
+ (AbsoluteTime.isNever(preference.hideMissingAccountUntil) ||
+ AbsoluteTime.cmp(now, preference.hideMissingAccountUntil) > 1);
+
+ const shouldLogin = state.status === "loggedOut";
+
+ // function ServerErrorRedirectTo(to: InstancePaths | AdminPaths) {
+ // return function ServerErrorRedirectToImpl(
+ // error: HttpError<TalerErrorDetail>,
+ // ) {
+ // if (error.type === ErrorType.TIMEOUT) {
+ // setGlobalNotification({
+ // message: i18n.str`The request to the backend take too long and was cancelled`,
+ // description: i18n.str`Diagnostic from ${error.info.url} is "${error.message}"`,
+ // type: "ERROR",
+ // to,
+ // });
+ // } else {
+ // setGlobalNotification({
+ // message: i18n.str`The backend reported a problem: HTTP status #${error.status}`,
+ // description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`,
+ // details:
+ // error.type === ErrorType.CLIENT || error.type === ErrorType.SERVER
+ // ? error.payload.hint
+ // : undefined,
+ // type: "ERROR",
+ // to,
+ // });
+ // }
+ // return <Redirect to={to} />;
+ // };
+ // }
+
+ // const LoginPageAccessDeniend = onUnauthorized
+ // const LoginPageAccessDenied = () => {
+ // return (
+ // <Fragment>
+ // <NotificationCard
+ // notification={{
+ // message: i18n.str`Access denied`,
+ // description: i18n.str`Session expired or password changed.`,
+ // type: "ERROR",
+ // }}
+ // />
+ // <LoginPage />
+ // </Fragment>
+ // );
+ // };
+
+ // function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<unknown>) {
+ // return function IfAdminCreateDefaultOrImpl(props?: T) {
+ // if (state.isAdmin && state.instance === DEFAULT_ADMIN_USERNAME) {
+ // return (
+ // <Fragment>
+ // <NotificationCard
+ // notification={{
+ // message: i18n.str`No 'default' instance configured yet.`,
+ // description: i18n.str`Create a 'default' instance to begin using the merchant backoffice.`,
+ // type: "INFO",
+ // }}
+ // />
+ // <InstanceCreatePage
+ // forceId={DEFAULT_ADMIN_USERNAME}
+ // onConfirm={() => {
+ // route(InstancePaths.bank_list);
+ // }}
+ // />
+ // </Fragment>
+ // );
+ // }
+ // if (props) {
+ // return <Next {...props} />;
+ // }
+ // return <Next />;
+ // };
+ // }
+
+ if (shouldLogin) {
+ return (
+ <Fragment>
+ <NotConnectedAppMenu title="Welcome!" />
+ <LoginPage />
+ </Fragment>
+ );
+ }
+
+ if (shouldWarnAboutMissingBankAccounts) {
+ return (
+ <Fragment>
+ <Menu />
+ <BankAccountBanner />
+ <BankAccountCreatePage
+ onConfirm={() => {
+ route(InstancePaths.bank_list);
+ }}
+ />
+ </Fragment>
+ );
+ }
+
+ return (
+ <Fragment>
+ <Menu />
+ <KycBanner />
+ <NotificationCard notification={globalNotification} />
+ {error && (
+ <NotificationCard
+ notification={{
+ message: "Internal error, please repot",
+ type: "ERROR",
+ description: (
+ <pre>
+ {
+ (error instanceof Error
+ ? error.stack
+ : String(error)) as TranslatedString
+ }
+ </pre>
+ ),
+ }}
+ />
+ )}
+
+ <Router
+ history={history}
+ onChange={(e) => {
+ const movingOutFromNotification =
+ globalNotification && e.url !== globalNotification.to;
+ if (movingOutFromNotification) {
+ setGlobalNotification(undefined);
+ }
+ }}
+ >
+ <Route path="/" component={Redirect} to={InstancePaths.order_list} />
+ {/**
+ * Admin pages
+ */}
+ {state.isAdmin && (
+ <Route
+ path={AdminPaths.list_instances}
+ component={InstanceListPage}
+ onCreate={() => {
+ route(AdminPaths.new_instance);
+ }}
+ onUpdate={(id: string): void => {
+ route(`/instance/${id}/update`);
+ }}
+ />
+ )}
+ {state.isAdmin && (
+ <Route
+ path={AdminPaths.new_instance}
+ component={InstanceCreatePage}
+ onBack={() => route(AdminPaths.list_instances)}
+ onConfirm={() => {
+ route(AdminPaths.list_instances);
+ }}
+ />
+ )}
+ {state.isAdmin && (
+ <Route
+ path={AdminPaths.update_instance}
+ component={AdminInstanceUpdatePage}
+ onBack={() => route(AdminPaths.list_instances)}
+ onConfirm={() => {
+ route(AdminPaths.list_instances);
+ }}
+ />
+ )}
+ {/**
+ * Update instance page
+ */}
+ <Route
+ path={InstancePaths.settings}
+ component={InstanceUpdatePage}
+ onBack={() => {
+ route(`/`);
+ }}
+ onConfirm={() => {
+ route(`/`);
+ }}
+ />
+ {/**
+ * Update instance page
+ */}
+ <Route
+ path={InstancePaths.token}
+ component={TokenPage}
+ onChange={() => {
+ route(`/`);
+ }}
+ onCancel={() => {
+ route(InstancePaths.order_list);
+ }}
+ />
+ {/**
+ * Inventory pages
+ */}
+ <Route
+ path={InstancePaths.inventory_list}
+ component={ProductListPage}
+ onCreate={() => {
+ route(InstancePaths.inventory_new);
+ }}
+ onSelect={(id: string) => {
+ route(InstancePaths.inventory_update.replace(":pid", id));
+ }}
+ />
+ <Route
+ path={InstancePaths.inventory_update}
+ component={ProductUpdatePage}
+ onConfirm={() => {
+ route(InstancePaths.inventory_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.inventory_list);
+ }}
+ />
+ <Route
+ path={InstancePaths.inventory_new}
+ component={ProductCreatePage}
+ onConfirm={() => {
+ route(InstancePaths.inventory_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.inventory_list);
+ }}
+ />
+ {/**
+ * Bank pages
+ */}
+ <Route
+ path={InstancePaths.bank_list}
+ component={BankAccountListPage}
+ onCreate={() => {
+ route(InstancePaths.bank_new);
+ }}
+ onSelect={(id: string) => {
+ route(InstancePaths.bank_update.replace(":bid", id));
+ }}
+ />
+ <Route
+ path={InstancePaths.bank_update}
+ component={BankAccountUpdatePage}
+ onConfirm={() => {
+ route(InstancePaths.bank_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.bank_list);
+ }}
+ />
+ <Route
+ path={InstancePaths.bank_new}
+ component={BankAccountCreatePage}
+ onConfirm={() => {
+ route(InstancePaths.bank_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.bank_list);
+ }}
+ />
+ {/**
+ * Order pages
+ */}
+ <Route
+ path={InstancePaths.order_list}
+ component={OrderListPage}
+ onCreate={() => {
+ route(InstancePaths.order_new);
+ }}
+ onSelect={(id: string) => {
+ route(InstancePaths.order_details.replace(":oid", id));
+ }}
+ />
+ <Route
+ path={InstancePaths.order_details}
+ component={OrderDetailsPage}
+ onBack={() => {
+ route(InstancePaths.order_list);
+ }}
+ />
+ <Route
+ path={InstancePaths.order_new}
+ component={OrderCreatePage}
+ onConfirm={(orderId: string) => {
+ route(InstancePaths.order_details.replace(":oid", orderId));
+ }}
+ onBack={() => {
+ route(InstancePaths.order_list);
+ }}
+ />
+ {/**
+ * Transfer pages
+ */}
+ <Route
+ path={InstancePaths.transfers_list}
+ component={TransferListPage}
+ onCreate={() => {
+ route(InstancePaths.transfers_new);
+ }}
+ />
+ <Route
+ path={InstancePaths.transfers_new}
+ component={TransferCreatePage}
+ onConfirm={() => {
+ route(InstancePaths.transfers_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.transfers_list);
+ }}
+ />
+ {/**
+ * Webhooks pages
+ */}
+ <Route
+ path={InstancePaths.webhooks_list}
+ component={WebhookListPage}
+ onCreate={() => {
+ route(InstancePaths.webhooks_new);
+ }}
+ onSelect={(id: string) => {
+ route(InstancePaths.webhooks_update.replace(":tid", id));
+ }}
+ />
+ <Route
+ path={InstancePaths.webhooks_update}
+ component={WebhookUpdatePage}
+ onConfirm={() => {
+ route(InstancePaths.webhooks_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.webhooks_list);
+ }}
+ />
+ <Route
+ path={InstancePaths.webhooks_new}
+ component={WebhookCreatePage}
+ onConfirm={() => {
+ route(InstancePaths.webhooks_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.webhooks_list);
+ }}
+ />
+ {/**
+ * Validator pages
+ */}
+ <Route
+ path={InstancePaths.otp_devices_list}
+ component={ValidatorListPage}
+ onCreate={() => {
+ route(InstancePaths.otp_devices_new);
+ }}
+ onSelect={(id: string) => {
+ route(InstancePaths.otp_devices_update.replace(":vid", id));
+ }}
+ />
+ <Route
+ path={InstancePaths.otp_devices_update}
+ component={ValidatorUpdatePage}
+ onConfirm={() => {
+ route(InstancePaths.otp_devices_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.otp_devices_list);
+ }}
+ />
+ <Route
+ path={InstancePaths.otp_devices_new}
+ component={ValidatorCreatePage}
+ onConfirm={() => {
+ route(InstancePaths.otp_devices_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.otp_devices_list);
+ }}
+ />
+ {/**
+ * Templates pages
+ */}
+ <Route
+ path={InstancePaths.templates_list}
+ component={TemplateListPage}
+ onCreate={() => {
+ route(InstancePaths.templates_new);
+ }}
+ onNewOrder={(id: string) => {
+ route(InstancePaths.templates_use.replace(":tid", id));
+ }}
+ onQR={(id: string) => {
+ route(InstancePaths.templates_qr.replace(":tid", id));
+ }}
+ onSelect={(id: string) => {
+ route(InstancePaths.templates_update.replace(":tid", id));
+ }}
+ />
+ <Route
+ path={InstancePaths.templates_update}
+ component={TemplateUpdatePage}
+ onConfirm={() => {
+ route(InstancePaths.templates_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.templates_list);
+ }}
+ />
+ <Route
+ path={InstancePaths.templates_new}
+ component={TemplateCreatePage}
+ onConfirm={() => {
+ route(InstancePaths.templates_list);
+ }}
+ onBack={() => {
+ route(InstancePaths.templates_list);
+ }}
+ />
+ <Route
+ path={InstancePaths.templates_use}
+ component={TemplateUsePage}
+ onOrderCreated={(id: string) => {
+ route(InstancePaths.order_details.replace(":oid", id));
+ }}
+ onBack={() => {
+ route(InstancePaths.templates_list);
+ }}
+ />
+ <Route
+ path={InstancePaths.templates_qr}
+ component={TemplateQrPage}
+ onBack={() => {
+ route(InstancePaths.templates_list);
+ }}
+ />
+
+ <Route path={InstancePaths.kyc} component={ListKYCPage} />
+ <Route path={InstancePaths.interface} component={Settings} />
+ {/**
+ * Example pages
+ */}
+ <Route path="/loading" component={Loading} />
+ <Route default component={NotFoundPage} />
+ </Router>
+ </Fragment>
+ );
+}
+
+export function Redirect({ to }: { to: string }): null {
+ useEffect(() => {
+ route(to, true);
+ });
+ return null;
+}
+
+function AdminInstanceUpdatePage({
+ id,
+ ...rest
+}: { id: string } & InstanceUpdatePageProps): VNode {
+ // const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <InstanceAdminUpdatePage
+ {...rest}
+ instanceId={id}
+ // onLoadError={(error: HttpError<TalerErrorDetail>) => {
+ // const notif =
+ // error.type === ErrorType.TIMEOUT
+ // ? {
+ // message: i18n.str`The request to the backend take too long and was cancelled`,
+ // description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`,
+ // type: "ERROR" as const,
+ // }
+ // : {
+ // message: i18n.str`The backend reported a problem: HTTP status #${error.status}`,
+ // description: i18n.str`Diagnostic from ${error.info.url} is '${error.message}'`,
+ // details:
+ // error.type === ErrorType.CLIENT ||
+ // error.type === ErrorType.SERVER
+ // ? error.payload.hint
+ // : undefined,
+ // type: "ERROR" as const,
+ // };
+ // return (
+ // <Fragment>
+ // <NotificationCard notification={notif} />
+ // <LoginPage />
+ // </Fragment>
+ // );
+ // }}
+ // onUnauthorized={() => {
+ // return (
+ // <Fragment>
+ // <NotificationCard
+ // notification={{
+ // message: i18n.str`Access denied`,
+ // description: i18n.str`The access token provided is invalid`,
+ // type: "ERROR",
+ // }}
+ // />
+ // <LoginPage />
+ // </Fragment>
+ // );
+ // }}
+ />
+ </Fragment>
+ );
+}
+
+function BankAccountBanner(): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [, updatePref] = usePreference();
+ const now = AbsoluteTime.now();
+ const oneDay = { d_ms: 1000 * 60 * 60 * 24 };
+ const tomorrow = AbsoluteTime.addDuration(now, oneDay);
+
+ return (
+ <NotificationCard
+ notification={{
+ type: "INFO",
+ message: i18n.str`You need to associate a bank account to receive revenue.`,
+ description: (
+ <div>
+ <p>
+ <i18n.Translate>
+ Without this the merchant backend will refuse to create new
+ orders.
+ </i18n.Translate>
+ </p>
+ <div class="buttons is-right">
+ <button
+ class="button"
+ onClick={() => updatePref("hideMissingAccountUntil", tomorrow)}
+ >
+ <i18n.Translate>Hide for today</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ ),
+ }}
+ />
+ );
+}
+
+function KycBanner(): VNode {
+ const kycStatus = useInstanceKYCDetails();
+ const { i18n } = useTranslationContext();
+ // const today = format(new Date(), dateFormatForSettings(settings));
+ const [prefs, updatePref] = usePreference();
+
+ const now = AbsoluteTime.now();
+
+ const needsToBeShown =
+ kycStatus !== undefined &&
+ !(kycStatus instanceof TalerError) &&
+ kycStatus.type === "ok" &&
+ !!kycStatus.body;
+
+ const hidden = AbsoluteTime.cmp(now, prefs.hideKycUntil) < 1;
+ if (hidden || !needsToBeShown) return <Fragment />;
+
+ const oneDay = { d_ms: 1000 * 60 * 60 * 24 };
+ const tomorrow = AbsoluteTime.addDuration(now, oneDay);
+
+ return (
+ <NotificationCard
+ notification={{
+ type: "WARN",
+ message: "KYC verification needed",
+ description: (
+ <div>
+ <p>
+ <i18n.Translate>
+ Some transfer are on hold until a KYC process is completed. Go
+ to the KYC section in the left panel for more information
+ </i18n.Translate>
+ </p>
+ <div class="buttons is-right">
+ <button
+ class="button"
+ onClick={() => updatePref("hideKycUntil", tomorrow)}
+ >
+ <i18n.Translate>Hide for today</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ ),
+ }}
+ />
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/assets/empty.png b/packages/merchant-backoffice-ui/src/assets/empty.png
new file mode 100644
index 000000000..5120d3138
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/assets/empty.png
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/assets/icons/android-chrome-192x192.png b/packages/merchant-backoffice-ui/src/assets/icons/android-chrome-192x192.png
new file mode 100644
index 000000000..93ebe2e2c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/assets/icons/android-chrome-192x192.png
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/assets/icons/android-chrome-512x512.png b/packages/merchant-backoffice-ui/src/assets/icons/android-chrome-512x512.png
new file mode 100644
index 000000000..52d1623ea
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/assets/icons/android-chrome-512x512.png
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/assets/icons/apple-touch-icon.png b/packages/merchant-backoffice-ui/src/assets/icons/apple-touch-icon.png
new file mode 100644
index 000000000..254e4bb4d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/assets/icons/apple-touch-icon.png
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/assets/icons/favicon-16x16.png b/packages/merchant-backoffice-ui/src/assets/icons/favicon-16x16.png
new file mode 100644
index 000000000..e81177dcb
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/assets/icons/favicon-16x16.png
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/assets/icons/favicon-32x32.png b/packages/merchant-backoffice-ui/src/assets/icons/favicon-32x32.png
new file mode 100644
index 000000000..40e9b5b47
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/assets/icons/favicon-32x32.png
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/assets/icons/languageicon.svg b/packages/merchant-backoffice-ui/src/assets/icons/languageicon.svg
new file mode 100644
index 000000000..22d58da65
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/assets/icons/languageicon.svg
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 2411.2 2794" style="enable-background:new 0 0 2411.2 2794;" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#FFFFFF;}
+ .st1{fill-rule:evenodd;clip-rule:evenodd;}
+ .st2{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
+</style>
+<g id="Layer_2">
+</g>
+<g id="Layer_x5F_1_x5F_1">
+ <g>
+ <polygon points="1204.6,359.2 271.8,30 271.8,2060.1 1204.6,1758.3 "/>
+ <polygon class="st0" points="1182.2,358.1 2150.6,29 2150.6,2059 1182.2,1757.3 "/>
+ <polygon class="st0" points="30,2415.4 1182.2,2031.4 1182.2,357.9 30,742 "/>
+ <polygon points="1707.2,2440.7 1870.5,2709.4 1956.6,2459.8 "/>
+ <g>
+ <path d="M421.7,934.8c-6.1-6,8,49.1,27.6,68.9c34.8,35.1,61.9,39.6,76.4,40.2c32,1.3,71.5-8,94.9-17.8
+ c22.7-9.7,62.4-30,77.5-59.6c3.2-6.3,11.9-17,6.4-43.2c-4.2-20.2-17-27.3-32.7-26.2c-15.7,1.1-63.2,13.7-86.1,20.8
+ c-23,7-70.3,21.4-90.9,25.8C474.3,948.2,429,941.7,421.7,934.8z"/>
+ <path d="M1003.1,1593.7c-9.1-3.3-196.9-81.1-223.6-93.9c-21.8-10.5-75.2-33.1-100.4-43.3c70.8-109.2,115.5-191.6,121.5-204.1
+ c11-23,86-169.6,87.7-178.7c1.7-9.1,3.8-42.9,2.2-51c-1.7-8.2-29.1,7.6-66.4,20.2c-37.4,12.6-108.4,58.8-135.8,64.6
+ c-27.5,5.7-115.5,39.1-160.5,54c-45,14.9-130.2,40.9-165.2,50.4c-35.1,9.5-65.7,10.2-85.3,16.2c0,0,2.6,27.5,7.8,35.7
+ c5.2,8.2,23.7,28.4,45.3,34.1c21.6,5.7,57.3,3.4,73.6-0.3c16.3-3.8,44.4-17.5,48.2-23.6c3.8-6.1-2-24.9,4.5-30.6
+ c6.5-5.6,92.2-25.7,124.6-35.4c32.4-10,156.3-52.6,173.1-50.5c-5.3,17.7-105,215.1-137.1,274c-32.1,58.9-218.6,318-258.3,363.6
+ c-30.1,34.7-103.2,123.5-128.5,143.6c6.4,1.8,51.6-2.1,59.9-7.2c51.3-31.6,136.9-138.1,164.4-170.5
+ c81.9-96,153.8-196.8,210.8-283.4h0.1c11.1,4.6,100.9,77.8,124.4,94c23.4,16.2,115.9,67.8,136,76.4c20,8.7,97.1,44.2,100.3,32.2
+ C1029.4,1668,1012.2,1597.1,1003.1,1593.7z"/>
+ </g>
+ <path class="st1" d="M569,2572c18,11,35,20,54,29c38,19,81,39,122,54c56,21,112,38,168,51c31,7,65,13,98,18c3,0,92,11,110,11h90
+ c35-3,68-5,103-10c28-4,59-9,89-16c22-5,45-10,67-17c21-6,45-14,68-22c15-5,31-12,47-18c13-6,29-13,44-19c18-8,39-19,59-29
+ c16-8,34-18,51-28c13-7,43-30,59-30c18,0,30,16,30,30c0,29-39,38-57,51c-19,13-42,23-62,34c-40,21-81,39-120,54
+ c-51,19-107,37-157,49c-19,4-38,9-57,12c-10,2-114,18-143,18h-132c-35-3-72-7-107-12c-31-5-64-11-95-18c-24-5-50-12-73-19
+ c-40-11-79-25-117-40c-69-26-141-60-209-105c-12-8-13-16-13-25c0-15,11-29,29-29C531,2546,563,2569,569,2572z"/>
+ <path class="st1" d="M1151,2009L61,2372V764l1090-363V2009z M1212,354v1680c-1,5-3,10-7,15c-2,3-6,7-9,8c-25,10-1151,388-1166,388
+ c-12,0-23-8-29-21c0-1-1-2-1-4V739c2-5,3-12,7-16c8-11,22-13,31-16c17-6,1126-378,1142-378C1190,329,1212,336,1212,354z"/>
+ <path class="st1" d="M2120,2017l-907-282V380l907-308V2017z M2181,32v2023c-1,23-17,33-32,33c-13,0-107-32-123-37
+ c-126-39-253-78-378-117c-28-9-57-18-84-27c-24-7-50-15-74-23c-107-33-216-66-323-102c-4-1-14-15-14-18V351c2-5,4-11,9-15
+ c8-9,351-123,486-168c36-13,487-168,501-168C2167,0,2181,13,2181,32z"/>
+ <polygon points="2411.2,2440.7 1199.5,2054.5 1204.6,373.2 2411.2,757.2 "/>
+ <g>
+ <path class="st2" d="M1800.3,1124.6L1681.4,1412l218.6,66.3L1800.3,1124.6z M1729,853.2l156.1,47.3l284.4,1025l-160.3-48.7
+ l-57.6-210.4L1620.2,1566l-71.3,171.4l-160.4-48.7L1729,853.2z"/>
+ </g>
+ </g>
+</g>
+</svg>
diff --git a/packages/merchant-backoffice-ui/src/assets/icons/mstile-150x150.png b/packages/merchant-backoffice-ui/src/assets/icons/mstile-150x150.png
new file mode 100644
index 000000000..9cfb889be
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/assets/icons/mstile-150x150.png
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/assets/logo-2021.svg b/packages/merchant-backoffice-ui/src/assets/logo-2021.svg
new file mode 100644
index 000000000..8c5ff3e5b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/assets/logo-2021.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">
+ <g fill="#0042b3" fill-rule="evenodd" stroke-width=".3">
+ <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />
+ <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />
+ <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />
+ </g>
+ <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />
+</svg> \ No newline at end of file
diff --git a/packages/merchant-backoffice-ui/src/assets/logo.jpeg b/packages/merchant-backoffice-ui/src/assets/logo.jpeg
new file mode 100644
index 000000000..489832f7c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/assets/logo.jpeg
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/components/ErrorLoadingMerchant.tsx b/packages/merchant-backoffice-ui/src/components/ErrorLoadingMerchant.tsx
new file mode 100644
index 000000000..b1d1cac66
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/ErrorLoadingMerchant.tsx
@@ -0,0 +1,146 @@
+/*
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TalerError, TalerErrorCode, assertUnreachable } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { NotificationCard } from "./menu/index.js";
+
+/**
+ * equivalent to ErrorLoading for merchant-backoffice which uses notification-card
+ * @param param0
+ * @returns
+ */
+export function ErrorLoadingMerchant({ error, showDetail }: { error: TalerError, showDetail?: boolean }): VNode {
+ const { i18n } = useTranslationContext()
+ switch (error.errorDetail.code) {
+ //////////////////
+ // Every error that can be produce in a Http Request
+ //////////////////
+ case TalerErrorCode.GENERIC_TIMEOUT: {
+ if (error.hasErrorCode(TalerErrorCode.GENERIC_TIMEOUT)) {
+ const { requestMethod, requestUrl, timeoutMs } = error.errorDetail
+ return <NotificationCard
+ notification={{
+ type: "ERROR",
+ message: i18n.str`The request reached a timeout, check your connection.`,
+ description: error.message,
+ details: JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)
+ }} />
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: {
+ if (error.hasErrorCode(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR)) {
+ const { requestMethod, requestUrl, timeoutMs } = error.errorDetail
+ return <NotificationCard
+ notification={{
+ type: "ERROR",
+ message: i18n.str`The request was cancelled.`,
+ description: error.message,
+ details: JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)
+ }} />
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT)) {
+ const { requestMethod, requestUrl, timeoutMs } = error.errorDetail
+ return <NotificationCard
+ notification={{
+ type: "ERROR",
+ message: i18n.str`The request reached a timeout, check your connection.`,
+ description: error.message,
+ details: JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)
+ }} />
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED)) {
+ const { requestMethod, requestUrl, throttleStats } = error.errorDetail
+ return <NotificationCard
+ notification={{
+ type: "ERROR",
+ message: i18n.str`A lot of request were made to the same server and this action was throttled.`,
+ description: error.message,
+ details: JSON.stringify({ requestMethod, requestUrl, throttleStats }, undefined, 2)
+ }} />
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE)) {
+ const { requestMethod, requestUrl, httpStatusCode, validationError } = error.errorDetail
+ return <NotificationCard
+ notification={{
+ type: "ERROR",
+ message: i18n.str`The response of the request is malformed.`,
+ description: error.message,
+ details: JSON.stringify({ requestMethod, requestUrl, httpStatusCode, validationError }, undefined, 2)
+ }} />
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_NETWORK_ERROR: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_NETWORK_ERROR)) {
+ const { requestMethod, requestUrl } = error.errorDetail
+ return <NotificationCard
+ notification={{
+ type: "ERROR",
+ message: i18n.str`Could not complete the request due to a network problem.`,
+ description: error.message,
+ details: JSON.stringify({ requestMethod, requestUrl }, undefined, 2)
+ }} />
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR)) {
+ const { requestMethod, requestUrl, httpStatusCode, errorResponse } = error.errorDetail
+ return <NotificationCard
+ notification={{
+ type: "ERROR",
+ message: i18n.str`Unexpected request error.`,
+ description: error.message,
+ details: JSON.stringify({ requestMethod, requestUrl, httpStatusCode, errorResponse }, undefined, 2)
+ }} />
+ }
+ assertUnreachable(1 as never)
+ }
+ //////////////////
+ // Every other error
+ //////////////////
+ // case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: {
+ // return <Attention type="danger" title={i18n.str``}>
+ // </Attention>
+ // }
+ //////////////////
+ // Default message for unhandled case
+ //////////////////
+ default: {
+ return <NotificationCard
+ notification={{
+ type: "ERROR",
+ message: i18n.str`Unexpected error.`,
+ description: error.message,
+ details: JSON.stringify(error.errorDetail, undefined, 2)
+ }} />
+ }
+ }
+}
+
diff --git a/packages/merchant-backoffice-ui/src/components/exception/AsyncButton.tsx b/packages/merchant-backoffice-ui/src/components/exception/AsyncButton.tsx
new file mode 100644
index 000000000..58c10e7d7
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/exception/AsyncButton.tsx
@@ -0,0 +1,55 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ComponentChildren, h } from "preact";
+import { LoadingModal } from "../modal/index.js";
+import { useAsync } from "../../hooks/async.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+
+type Props = {
+ children: ComponentChildren;
+ disabled: boolean;
+ onClick?: () => Promise<void>;
+ [rest: string]: any;
+};
+
+export function AsyncButton({ onClick, disabled, children, ...rest }: Props) {
+ const { isSlow, isLoading, request, cancel } = useAsync(onClick);
+ const { i18n } = useTranslationContext();
+ if (isSlow) {
+ return <LoadingModal onCancel={cancel} />;
+ }
+ if (isLoading) {
+ return (
+ <button class="button">
+ <i18n.Translate>Loading...</i18n.Translate>
+ </button>
+ );
+ }
+
+ return (
+ <span {...rest}>
+ <button class="button is-success" onClick={request} disabled={disabled}>
+ {children}
+ </button>
+ </span>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/exception/QR.tsx b/packages/merchant-backoffice-ui/src/components/exception/QR.tsx
new file mode 100644
index 000000000..246ce0229
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/exception/QR.tsx
@@ -0,0 +1,49 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+import { useEffect, useRef } from "preact/hooks";
+import qrcode from "qrcode-generator";
+
+export function QR({ text }: { text: string }): VNode {
+ const divRef = useRef<HTMLDivElement>(null);
+ useEffect(() => {
+ const qr = qrcode(0, "L");
+ qr.addData(text);
+ qr.make();
+ if (divRef.current) {
+ divRef.current.innerHTML = qr.createSvgTag({
+ scalable: true,
+ });
+ }
+ });
+
+ return (
+ <div
+ style={{
+ width: "100%",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ }}
+ >
+ <div
+ style={{ width: "50%", minWidth: 200, maxWidth: 300 }}
+ ref={divRef}
+ />
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/exception/loading.tsx b/packages/merchant-backoffice-ui/src/components/exception/loading.tsx
new file mode 100644
index 000000000..5c249f79d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/exception/loading.tsx
@@ -0,0 +1,48 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+
+export function Loading(): VNode {
+ return (
+ <div
+ class="columns is-centered is-vcentered"
+ style={{
+ height: "calc(100% - 3rem)",
+ position: "absolute",
+ width: "100%",
+ }}
+ >
+ <Spinner />
+ </div>
+ );
+}
+
+export function Spinner(): VNode {
+ return (
+ <div class="lds-ring">
+ <div />
+ <div />
+ <div />
+ <div />
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx b/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx
new file mode 100644
index 000000000..a5f3c1d2f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx
@@ -0,0 +1,109 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext, useMemo } from "preact/hooks";
+
+type Updater<S> = (value: (prevState: S) => S) => void;
+
+export interface Props<T> {
+ object?: Partial<T>;
+ errors?: FormErrors<T>;
+ name?: string;
+ valueHandler: Updater<Partial<T>> | null;
+ children: ComponentChildren;
+}
+
+const noUpdater: Updater<Partial<unknown>> = () => (s: unknown) => s;
+
+export function FormProvider<T>({
+ object = {},
+ errors = {},
+ name = "",
+ valueHandler,
+ children,
+}: Props<T>): VNode {
+ const initialObject = useMemo(() => object, []);
+ const value = useMemo<FormType<T>>(
+ () => ({
+ errors,
+ object,
+ initialObject,
+ valueHandler: valueHandler ? valueHandler : noUpdater,
+ name,
+ toStr: {},
+ fromStr: {},
+ }),
+ [errors, object, valueHandler],
+ );
+
+ return (
+ <FormContext.Provider value={value}>
+ <form
+ class="field"
+ onSubmit={(e) => {
+ e.preventDefault();
+ // if (valueHandler) valueHandler(object);
+ }}
+ >
+ {children}
+ </form>
+ </FormContext.Provider>
+ );
+}
+
+export interface FormType<T> {
+ object: Partial<T>;
+ initialObject: Partial<T>;
+ errors: FormErrors<T>;
+ toStr: FormtoStr<T>;
+ name: string;
+ fromStr: FormfromStr<T>;
+ valueHandler: Updater<Partial<T>>;
+}
+
+const FormContext = createContext<FormType<unknown>>(null!);
+
+/**
+ * FIXME:
+ * USE MEMORY EVENTS INSTEAD OF CONTEXT
+ * @deprecated
+ */
+
+export function useFormContext<T>() {
+ return useContext<FormType<T>>(FormContext);
+}
+
+export type FormErrors<T> = {
+ [P in keyof T]?: string | FormErrors<T[P]>;
+};
+
+export type FormtoStr<T> = {
+ [P in keyof T]?: (f?: T[P]) => string;
+};
+
+export type FormfromStr<T> = {
+ [P in keyof T]?: (f: string) => T[P];
+};
+
+export type FormUpdater<T> = {
+ [P in keyof T]?: (f: keyof T) => (v: T[P]) => void;
+};
diff --git a/packages/merchant-backoffice-ui/src/components/form/Input.tsx b/packages/merchant-backoffice-ui/src/components/form/Input.tsx
new file mode 100644
index 000000000..899061c35
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/Input.tsx
@@ -0,0 +1,116 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useField, InputProps } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ inputType?: "text" | "number" | "multiline" | "password";
+ expand?: boolean;
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+ inputExtra?: any;
+ side?: ComponentChildren;
+ children?: ComponentChildren;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+const TextInput = ({ inputType, error, ...rest }: any) =>
+ inputType === "multiline" ? (
+ <textarea
+ {...rest}
+ class={error ? "textarea is-danger" : "textarea"}
+ rows="3"
+ />
+ ) : (
+ <input
+ {...rest}
+ class={error ? "input is-danger" : "input"}
+ type={inputType}
+ />
+ );
+
+export function Input<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ expand,
+ help,
+ children,
+ inputType,
+ inputExtra,
+ side,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p
+ class={
+ expand
+ ? "control is-expanded has-icons-right"
+ : "control has-icons-right"
+ }
+ >
+ <TextInput
+ error={error}
+ {...inputExtra}
+ inputType={inputType}
+ placeholder={placeholder}
+ readonly={readonly}
+ disabled={readonly}
+ name={String(name)}
+ value={toStr(value)}
+ onChange={(e: h.JSX.TargetedEvent<HTMLInputElement>): void =>
+ onChange(fromStr(e.currentTarget.value))
+ }
+ />
+ {help}
+ {children}
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ {side}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx b/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx
new file mode 100644
index 000000000..b0b9eaefc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx
@@ -0,0 +1,139 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ isValid?: (e: any) => boolean;
+ addonBefore?: string;
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputArray<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ addonBefore,
+ isValid = () => true,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error: formError, value, onChange, required } = useField<T>(name);
+ const [localError, setLocalError] = useState<string | null>(null);
+
+ const error = localError || formError;
+
+ const array: any[] = (value ? value! : []) as any;
+ const [currentValue, setCurrentValue] = useState("");
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <div class="field has-addons">
+ {addonBefore && (
+ <div class="control">
+ <a class="button is-static">{addonBefore}</a>
+ </div>
+ )}
+ <p class="control is-expanded has-icons-right">
+ <input
+ class={error ? "input is-danger" : "input"}
+ type="text"
+ placeholder={placeholder}
+ readonly={readonly}
+ disabled={readonly}
+ name={String(name)}
+ value={currentValue}
+ onChange={(e): void => setCurrentValue(e.currentTarget.value)}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ <p class="control">
+ <button
+ class="button is-info has-tooltip-left"
+ disabled={!currentValue}
+ onClick={(): void => {
+ const v = fromStr(currentValue);
+ if (!isValid(v)) {
+ setLocalError(
+ i18n.str`The value ${v} is invalid for a payment url`,
+ );
+ return;
+ }
+ setLocalError(null);
+ onChange([v, ...array] as any);
+ setCurrentValue("");
+ }}
+ data-tooltip={i18n.str`add element to the list`}
+ >
+ <i18n.Translate>add</i18n.Translate>
+ </button>
+ </p>
+ </div>
+ {help}
+ {error && <p class="help is-danger"> {error} </p>}
+ {array.map((v, i) => (
+ <div key={i} class="tags has-addons mt-3 mb-0">
+ <span
+ class="tag is-medium is-info mb-0"
+ style={{ maxWidth: "90%" }}
+ >
+ {v}
+ </span>
+ <a
+ class="tag is-medium is-danger is-delete mb-0"
+ onClick={() => {
+ onChange(array.filter((f) => f !== v) as any);
+ setCurrentValue(toStr(v));
+ }}
+ />
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx b/packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx
new file mode 100644
index 000000000..bdb2feb6b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx
@@ -0,0 +1,91 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ name: T;
+ readonly?: boolean;
+ expand?: boolean;
+ threeState?: boolean;
+ toBoolean?: (v?: any) => boolean | undefined;
+ fromBoolean?: (s: boolean | undefined) => any;
+}
+
+const defaultToBoolean = (f?: any): boolean | undefined => f || "";
+const defaultFromBoolean = (v: boolean | undefined): any => v as any;
+
+export function InputBoolean<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ threeState,
+ expand,
+ fromBoolean = defaultFromBoolean,
+ toBoolean = defaultToBoolean,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange } = useField<T>(name);
+
+ const onCheckboxClick = (): void => {
+ const c = toBoolean(value);
+ if (c === false && threeState) return onChange(undefined as any);
+ return onChange(fromBoolean(!c));
+ };
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class={expand ? "control is-expanded" : "control"}>
+ <label class="b-checkbox checkbox">
+ <input
+ type="checkbox"
+ class={toBoolean(value) === undefined ? "is-indeterminate" : ""}
+ checked={toBoolean(value)}
+ placeholder={placeholder}
+ readonly={readonly}
+ name={String(name)}
+ disabled={readonly}
+ onChange={onCheckboxClick}
+ />
+ <span class="check" />
+ </label>
+ {help}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx
new file mode 100644
index 000000000..11396b88e
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useMerchantApiContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, h, VNode } from "preact";
+import { InputWithAddon } from "./InputWithAddon.js";
+import { InputProps } from "./useField.js";
+import { AmountString } from "@gnu-taler/taler-util";
+import { useSessionContext } from "../../context/session.js";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ addonAfter?: ComponentChildren;
+ children?: ComponentChildren;
+ side?: ComponentChildren;
+}
+
+export function InputCurrency<T>({
+ name,
+ readonly,
+ label,
+ placeholder,
+ help,
+ tooltip,
+ expand,
+ addonAfter,
+ children,
+ side,
+}: Props<keyof T>): VNode {
+ const { config } = useSessionContext();
+ return (
+ <InputWithAddon<T>
+ name={name}
+ readonly={readonly}
+ addonBefore={config.currency}
+ side={side}
+ label={label}
+ placeholder={placeholder}
+ help={help}
+ tooltip={tooltip}
+ addonAfter={addonAfter}
+ inputType="number"
+ expand={expand}
+ toStr={(v?: AmountString) => v?.split(":")[1] || ""}
+ fromStr={(v: string) => (!v ? undefined : `${config.currency}:${v}`)}
+ inputExtra={{ min: 0 }}
+ >
+ {children}
+ </InputWithAddon>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx b/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx
new file mode 100644
index 000000000..812505f6a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx
@@ -0,0 +1,164 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { ComponentChildren, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { DatePicker } from "../picker/DatePicker.js";
+import { InputProps, useField } from "./useField.js";
+import { dateFormatForSettings, usePreference } from "../../hooks/preference.js";
+
+export interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ //FIXME: create separated components InputDate and InputTimestamp
+ withTimestampSupport?: boolean;
+ side?: ComponentChildren;
+}
+
+export function InputDate<T>({
+ name,
+ readonly,
+ label,
+ placeholder,
+ help,
+ tooltip,
+ expand,
+ withTimestampSupport,
+ side,
+}: Props<keyof T>): VNode {
+ const [opened, setOpened] = useState(false);
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreference()
+
+ const { error, required, value, onChange } = useField<T>(name);
+
+ let strValue = "";
+ if (!value) {
+ strValue = withTimestampSupport ? "unknown" : "";
+ } else if (value instanceof Date) {
+ strValue = format(value, dateFormatForSettings(settings));
+ } else if (value.t_s) {
+ strValue =
+ value.t_s === "never"
+ ? withTimestampSupport
+ ? "never"
+ : ""
+ : format(new Date(value.t_s * 1000), dateFormatForSettings(settings));
+ }
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <div class="field has-addons">
+ <p
+ class={
+ expand
+ ? "control is-expanded has-icons-right"
+ : "control has-icons-right"
+ }
+ >
+ <input
+ class="input"
+ type="text"
+ readonly
+ value={strValue}
+ placeholder={placeholder}
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {help}
+ </p>
+ <div
+ class="control"
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ >
+ <a class="button is-static">
+ <span class="icon">
+ <i class="mdi mdi-calendar" />
+ </span>
+ </a>
+ </div>
+ </div>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+
+ {!readonly && (
+ <span
+ data-tooltip={
+ withTimestampSupport
+ ? i18n.str`change value to unknown date`
+ : i18n.str`change value to empty`
+ }
+ >
+ <button
+ class="button is-info mr-3"
+ onClick={() => onChange(undefined as any)}
+ >
+ <i18n.Translate>clear</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {withTimestampSupport && (
+ <span data-tooltip={i18n.str`change value to never`}>
+ <button
+ class="button is-info"
+ onClick={() => onChange({ t_s: "never" } as any)}
+ >
+ <i18n.Translate>never</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {side}
+ </div>
+ <DatePicker
+ opened={opened}
+ closeFunction={() => setOpened(false)}
+ dateReceiver={(d) => {
+ if (withTimestampSupport) {
+ onChange({ t_s: d.getTime() / 1000 } as any);
+ } else {
+ onChange(d as any);
+ }
+ }}
+ />
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx b/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx
new file mode 100644
index 000000000..ad3cb0e32
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx
@@ -0,0 +1,189 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { formatDuration, intervalToDuration } from "date-fns";
+import { ComponentChildren, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { SimpleModal } from "../modal/index.js";
+import { DurationPicker } from "../picker/DurationPicker.js";
+import { InputProps, useField } from "./useField.js";
+import { Duration } from "@gnu-taler/taler-util";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ readonly?: boolean;
+ withForever?: boolean;
+ side?: ComponentChildren;
+ withoutClear?: boolean;
+}
+
+export function InputDuration<T>({
+ name,
+ expand,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ readonly,
+ withForever,
+ withoutClear,
+ side,
+}: Props<keyof T>): VNode {
+ const [opened, setOpened] = useState(false);
+ const { i18n } = useTranslationContext();
+
+ const { error, required, value: anyValue, onChange } = useField<T>(name);
+ let strValue = "";
+ const value: Duration = anyValue
+ if (!value) {
+ strValue = "";
+ } else if (value.d_ms === "forever") {
+ strValue = i18n.str`forever`;
+ } else {
+ if (value.d_ms === undefined) {
+ throw Error(`assertion error: duration should have a d_ms but got '${JSON.stringify(value)}'`)
+ }
+ strValue = formatDuration(
+ intervalToDuration({ start: 0, end: value.d_ms }),
+ {
+ locale: {
+ formatDistance: (name, value) => {
+ switch (name) {
+ case "xMonths":
+ return i18n.str`${value}M`;
+ case "xYears":
+ return i18n.str`${value}Y`;
+ case "xDays":
+ return i18n.str`${value}d`;
+ case "xHours":
+ return i18n.str`${value}h`;
+ case "xMinutes":
+ return i18n.str`${value}min`;
+ case "xSeconds":
+ return i18n.str`${value}sec`;
+ }
+ },
+ localize: {
+ day: () => "s",
+ month: () => "m",
+ ordinalNumber: () => "th",
+ dayPeriod: () => "p",
+ quarter: () => "w",
+ era: () => "e",
+ },
+ },
+ },
+ );
+ }
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal is-flex-grow-3">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+
+ <div class="is-flex-grow-3">
+ <div class="field-body ">
+ <div class="field">
+ <div class="field has-addons">
+ <p class={expand ? "control is-expanded " : "control "}>
+ <input
+ class="input"
+ type="text"
+ readonly
+ value={strValue}
+ placeholder={placeholder}
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ <div
+ class="control"
+ onClick={() => {
+ if (!readonly) setOpened(true);
+ }}
+ >
+ <a class="button is-static">
+ <span class="icon">
+ <i class="mdi mdi-clock" />
+ </span>
+ </a>
+ </div>
+ </div>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ {withForever && (
+ <span data-tooltip={i18n.str`change value to never`}>
+ <button
+ class="button is-info mr-3"
+ onClick={() => onChange({ d_ms: "forever" } as any)}
+ >
+ <i18n.Translate>forever</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {!readonly && !withoutClear && (
+ <span data-tooltip={i18n.str`change value to empty`}>
+ <button
+ class="button is-info "
+ onClick={() => onChange(undefined as any)}
+ >
+ <i18n.Translate>clear</i18n.Translate>
+ </button>
+ </span>
+ )}
+ {side}
+ </div>
+ <span>
+ {help}
+ </span>
+ </div>
+
+
+ {opened && (
+ <SimpleModal onCancel={() => setOpened(false)}>
+ <DurationPicker
+ days
+ hours
+ minutes
+ value={!value || value.d_ms === "forever" ? 0 : value.d_ms}
+ onChange={(v) => {
+ onChange({ d_ms: v } as any);
+ }}
+ />
+ </SimpleModal>
+ )}
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx b/packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx
new file mode 100644
index 000000000..92b9e8b16
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx
@@ -0,0 +1,86 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useGroupField } from "./useGroupField.js";
+
+export interface Props<T> {
+ name: T;
+ children: ComponentChildren;
+ label: ComponentChildren;
+ tooltip?: ComponentChildren;
+ alternative?: ComponentChildren;
+ fixed?: boolean;
+ initialActive?: boolean;
+}
+
+export function InputGroup<T>({
+ name,
+ label,
+ children,
+ tooltip,
+ alternative,
+ fixed,
+ initialActive,
+}: Props<keyof T>): VNode {
+ const [active, setActive] = useState(initialActive || fixed);
+ const group = useGroupField<T>(name);
+
+ return (
+ <div class="card">
+ <header class="card-header">
+ <p class="card-header-title">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ {group?.hasError && (
+ <span class="icon has-text-danger" data-tooltip={tooltip}>
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ </p>
+ {!fixed && (
+ <button
+ class="card-header-icon"
+ aria-label="more options"
+ onClick={(): void => setActive(!active)}
+ >
+ <span class="icon">
+ {active ? (
+ <i class="mdi mdi-arrow-up" />
+ ) : (
+ <i class="mdi mdi-arrow-down" />
+ )}
+ </span>
+ </button>
+ )}
+ </header>
+ {active ? (
+ <div class="card-content">{children}</div>
+ ) : alternative ? (
+ <div class="card-content">{alternative}</div>
+ ) : undefined}
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputImage.tsx b/packages/merchant-backoffice-ui/src/components/form/InputImage.tsx
new file mode 100644
index 000000000..d284b476f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputImage.tsx
@@ -0,0 +1,122 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, h, VNode } from "preact";
+import { useRef, useState } from "preact/hooks";
+import { MAX_IMAGE_SIZE as MAX_IMAGE_UPLOAD_SIZE } from "../../utils/constants.js";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ addonAfter?: ComponentChildren;
+ children?: ComponentChildren;
+}
+
+export function InputImage<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ children,
+ expand,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange } = useField<T>(name);
+
+ const image = useRef<HTMLInputElement>(null);
+ const { i18n } = useTranslationContext();
+ const [sizeError, setSizeError] = useState(false);
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class={expand ? "control is-expanded" : "control"}>
+ {value && (
+ <img
+ src={value}
+ style={{ width: 200, height: 200 }}
+ onClick={() => image.current?.click()}
+ />
+ )}
+ <input
+ ref={image}
+ style={{ display: "none" }}
+ type="file"
+ name={String(name)}
+ placeholder={placeholder}
+ readonly={readonly}
+ onChange={(e) => {
+ const f: FileList | null = e.currentTarget.files;
+ if (!f || f.length != 1) {
+ return onChange(undefined!);
+ }
+ if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) {
+ setSizeError(true);
+ return onChange(undefined!);
+ }
+ setSizeError(false);
+ return f[0].arrayBuffer().then((b) => {
+ const b64 = window.btoa(
+ new Uint8Array(b).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ ),
+ );
+ return onChange(`data:${f[0].type};base64,${b64}` as any);
+ });
+ }}
+ />
+ {help}
+ {children}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ {sizeError && (
+ <p class="help is-danger">
+ <i18n.Translate>Image should be smaller than 1 MB</i18n.Translate>
+ </p>
+ )}
+ {!value && (
+ <button class="button" onClick={() => image.current?.click()}>
+ <i18n.Translate>Add</i18n.Translate>
+ </button>
+ )}
+ {value && (
+ <button class="button" onClick={() => onChange(undefined!)}>
+ <i18n.Translate>Remove</i18n.Translate>
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx b/packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx
new file mode 100644
index 000000000..d4b13d555
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx
@@ -0,0 +1,53 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { Fragment, h } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Input } from "./Input.js";
+
+export function InputLocation({ name }: { name: string }) {
+ const { i18n } = useTranslationContext();
+ return (
+ <>
+ <Input name={`${name}.country`} label={i18n.str`Country`} />
+ <Input
+ name={`${name}.address_lines`}
+ inputType="multiline"
+ label={i18n.str`Address`}
+ toStr={(v: string[] | undefined) => (!v ? "" : v.join("\n"))}
+ fromStr={(v: string) => v.split("\n")}
+ />
+ <Input
+ name={`${name}.building_number`}
+ label={i18n.str`Building number`}
+ />
+ <Input name={`${name}.building_name`} label={i18n.str`Building name`} />
+ <Input name={`${name}.street`} label={i18n.str`Street`} />
+ <Input name={`${name}.post_code`} label={i18n.str`Post code`} />
+ <Input name={`${name}.town_location`} label={i18n.str`Town location`} />
+ <Input name={`${name}.town`} label={i18n.str`Town`} />
+ <Input name={`${name}.district`} label={i18n.str`District`} />
+ <Input
+ name={`${name}.country_subdivision`}
+ label={i18n.str`Country subdivision`}
+ />
+ </>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx b/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx
new file mode 100644
index 000000000..38444b85d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h } from "preact";
+import { InputWithAddon } from "./InputWithAddon.js";
+import { InputProps } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ side?: ComponentChildren;
+ children?: ComponentChildren;
+}
+
+export function InputNumber<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ expand,
+ children,
+ side,
+}: Props<keyof T>) {
+ return (
+ <InputWithAddon<T>
+ name={name}
+ readonly={readonly}
+ fromStr={(v) => (!v ? undefined : parseInt(v, 10))}
+ toStr={(v) => `${v}`}
+ inputType="number"
+ expand={expand}
+ label={label}
+ placeholder={placeholder}
+ help={help}
+ tooltip={tooltip}
+ inputExtra={{ min: 0 }}
+ side={side}
+ >
+ {children}
+ </InputWithAddon>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx
new file mode 100644
index 000000000..fcecd8932
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx
@@ -0,0 +1,52 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputArray } from "./InputArray.js";
+import { PAYTO_REGEX } from "../../utils/constants.js";
+import { InputProps } from "./useField.js";
+
+export type Props<T> = InputProps<T>;
+
+const PAYTO_START_REGEX = /^payto:\/\//;
+
+export function InputPayto<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+}: Props<keyof T>): VNode {
+ return (
+ <InputArray<T>
+ name={name}
+ readonly={readonly}
+ addonBefore="payto://"
+ label={label}
+ placeholder={placeholder}
+ help={help}
+ tooltip={tooltip}
+ isValid={(v) => v && PAYTO_REGEX.test(v)}
+ toStr={(v?: string) => (!v ? "" : v.replace(PAYTO_START_REGEX, ""))}
+ fromStr={(v: string) => `payto://${v}`}
+ />
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx
new file mode 100644
index 000000000..cc5326bbe
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx
@@ -0,0 +1,47 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h } from "preact";
+import * as tests from "@gnu-taler/web-util/testing";
+import { InputPaytoForm } from "./InputPaytoForm.js";
+import { FormProvider } from "./FormProvider.js";
+import { useState } from "preact/hooks";
+
+export default {
+ title: "Components/Form/PayTo",
+ component: InputPaytoForm,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+export const Example = tests.createExample(() => {
+ const initial = {
+ accounts: [],
+ };
+ const [form, updateForm] = useState<Partial<typeof initial>>(initial);
+ return (
+ <FormProvider valueHandler={updateForm} object={form}>
+ <InputPaytoForm name="accounts" label="Accounts:" />
+ </FormProvider>
+ );
+}, {});
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
new file mode 100644
index 000000000..a0c15c77c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx
@@ -0,0 +1,447 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import {
+ parsePaytoUri,
+ PaytoUriGeneric,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { COUNTRY_TABLE } from "../../utils/constants.js";
+import { undefinedIfEmpty } from "../../utils/table.js";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { Input } from "./Input.js";
+import { InputGroup } from "./InputGroup.js";
+import { InputSelector } from "./InputSelector.js";
+import { InputProps, useField } from "./useField.js";
+import { useEffect, useState } from "preact/hooks";
+
+export interface Props<T> extends InputProps<T> {
+ isValid?: (e: any) => boolean;
+}
+
+// type Entity = PaytoUriGeneric
+// https://datatracker.ietf.org/doc/html/rfc8905
+type Entity = {
+ // iban, bitcoin, x-taler-bank. it defined the format
+ target: string;
+ // path1 if the first field to be used
+ path1?: string;
+ // path2 if the second field to be used, optional
+ path2?: string;
+ // params of the payto uri
+ params: {
+ "receiver-name"?: string;
+ sender?: string;
+ message?: string;
+ amount?: string;
+ instruction?: string;
+ [name: string]: string | undefined;
+ };
+};
+
+function isEthereumAddress(address: string) {
+ if (!/^(0x)?[0-9a-f]{40}$/i.test(address)) {
+ return false;
+ } else if (
+ /^(0x|0X)?[0-9a-f]{40}$/.test(address) ||
+ /^(0x|0X)?[0-9A-F]{40}$/.test(address)
+ ) {
+ return true;
+ }
+ return checkAddressChecksum(address);
+}
+
+function checkAddressChecksum(address: string) {
+ //TODO implement ethereum checksum
+ return true;
+}
+
+function validateBitcoin_path1(
+ addr: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ try {
+ const valid = /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/.test(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid bitcoin address.`;
+}
+
+function validateEthereum_path1(
+ addr: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ try {
+ const valid = isEthereumAddress(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid Ethereum address.`;
+}
+
+/**
+ * validates
+ * bank.com/
+ * bank.com
+ * bank.com/path
+ * bank.com/path/subpath/
+ */
+const DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+(\/[a-zA-Z0-9-.]+)*\/?$/
+
+function validateTalerBank_path1(
+ addr: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ console.log(addr, DOMAIN_REGEX.test(addr))
+ try {
+ const valid = DOMAIN_REGEX.test(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid host.`;
+}
+
+/**
+ * An IBAN is validated by converting it into an integer and performing a
+ * basic mod-97 operation (as described in ISO 7064) on it.
+ * If the IBAN is valid, the remainder equals 1.
+ *
+ * The algorithm of IBAN validation is as follows:
+ * 1.- Check that the total IBAN length is correct as per the country. If not, the IBAN is invalid
+ * 2.- Move the four initial characters to the end of the string
+ * 3.- Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35
+ * 4.- Interpret the string as a decimal integer and compute the remainder of that number on division by 97
+ *
+ * If the remainder is 1, the check digit test is passed and the IBAN might be valid.
+ *
+ */
+function validateIBAN_path1(
+ iban: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): string | undefined {
+ // Check total length
+ if (iban.length < 4)
+ return i18n.str`IBAN numbers usually have more that 4 digits`;
+ if (iban.length > 34)
+ return i18n.str`IBAN numbers usually have less that 34 digits`;
+
+ const A_code = "A".charCodeAt(0);
+ const Z_code = "Z".charCodeAt(0);
+ const IBAN = iban.toUpperCase();
+ // check supported country
+ const code = IBAN.substr(0, 2);
+ const found = code in COUNTRY_TABLE;
+ if (!found) return i18n.str`IBAN country code not found`;
+
+ // 2.- Move the four initial characters to the end of the string
+ const step2 = IBAN.substr(4) + iban.substr(0, 4);
+ const step3 = Array.from(step2)
+ .map((letter) => {
+ const code = letter.charCodeAt(0);
+ if (code < A_code || code > Z_code) return letter;
+ return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`;
+ })
+ .join("");
+
+ function calculate_iban_checksum(str: string): number {
+ const numberStr = str.substr(0, 5);
+ const rest = str.substr(5);
+ const number = parseInt(numberStr, 10);
+ const result = number % 97;
+ if (rest.length > 0) {
+ return calculate_iban_checksum(`${result}${rest}`);
+ }
+ return result;
+ }
+
+ const checksum = calculate_iban_checksum(step3);
+ if (checksum !== 1)
+ return i18n.str`IBAN number is not valid, checksum is wrong`;
+ return undefined;
+}
+
+// const targets = ['ach', 'bic', 'iban', 'upi', 'bitcoin', 'ilp', 'void', 'x-taler-bank']
+const targets = [
+ "Choose one...",
+ "iban",
+ "x-taler-bank",
+ "bitcoin",
+ "ethereum",
+];
+const noTargetValue = targets[0];
+const defaultTarget: Entity = {
+ target: noTargetValue,
+ params: {},
+};
+
+export function InputPaytoForm<T>({
+ name,
+ readonly,
+ label,
+ tooltip,
+}: Props<keyof T>): VNode {
+ const { value: initialValueStr, onChange } = useField<T>(name);
+
+ const initialPayto = parsePaytoUri(initialValueStr ?? "");
+ const paths = !initialPayto ? [] : initialPayto.targetPath.split("/");
+ const initialPath1 = paths.length >= 1 ? paths[0] : undefined;
+ const initialPath2 = paths.length >= 2 ? paths[1] : undefined;
+ const initial: Entity =
+ initialPayto === undefined
+ ? defaultTarget
+ : {
+ target: initialPayto.targetType,
+ params: initialPayto.params,
+ path1: initialPath1,
+ path2: initialPath2,
+ };
+ const [value, setValue] = useState<Partial<Entity>>(initial);
+
+ const { i18n } = useTranslationContext();
+
+ const errors: FormErrors<Entity> = {
+ target: value.target === noTargetValue ? i18n.str`required` : undefined,
+ path1: !value.path1
+ ? i18n.str`required`
+ : value.target === "iban"
+ ? validateIBAN_path1(value.path1, i18n)
+ : value.target === "bitcoin"
+ ? validateBitcoin_path1(value.path1, i18n)
+ : value.target === "ethereum"
+ ? validateEthereum_path1(value.path1, i18n)
+ : value.target === "x-taler-bank"
+ ? validateTalerBank_path1(value.path1, i18n)
+ : undefined,
+ path2:
+ value.target === "x-taler-bank"
+ ? !value.path2
+ ? i18n.str`required`
+ : undefined
+ : undefined,
+ params: undefinedIfEmpty({
+ "receiver-name": !value.params?.["receiver-name"]
+ ? i18n.str`required`
+ : undefined,
+ }),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const path1WithSlash = value.path1 && !value.path1.endsWith("/") ? value.path1 + "/" : value.path1
+ const str =
+ hasErrors || !value.target
+ ? undefined
+ : stringifyPaytoUri({
+ targetType: value.target,
+ targetPath: value.path2
+ ? `${path1WithSlash}${value.path2}`
+ : value.path1 ?? "",
+ params: value.params ?? ({} as any),
+ isKnown: false,
+ });
+ useEffect(() => {
+ onChange(str as any);
+ }, [str]);
+
+ // const submit = useCallback((): void => {
+ // // const accounts: TalerMerchantApi.AccountAddDetails[] = paytos;
+ // // const alreadyExists =
+ // // accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1;
+ // // if (!alreadyExists) {
+ // const newValue: TalerMerchantApi.AccountAddDetails = {
+ // payto_uri: paytoURL,
+ // };
+ // if (value.auth) {
+ // if (value.auth.url) {
+ // newValue.credit_facade_url = value.auth.url;
+ // }
+ // if (value.auth.type === "none") {
+ // newValue.credit_facade_credentials = {
+ // type: "none",
+ // };
+ // }
+ // if (value.auth.type === "basic") {
+ // newValue.credit_facade_credentials = {
+ // type: "basic",
+ // username: value.auth.username ?? "",
+ // password: value.auth.password ?? "",
+ // };
+ // }
+ // }
+ // onChange(newValue as any);
+ // // }
+ // // valueHandler(defaultTarget);
+ // }, [value]);
+
+ //FIXME: translating plural singular
+ return (
+ <InputGroup name="payto" label={label} fixed tooltip={tooltip}>
+ <FormProvider<Entity>
+ name="tax"
+ errors={errors}
+ object={value}
+ valueHandler={setValue}
+ >
+ <InputSelector<Entity>
+ name="target"
+ label={i18n.str`Account type`}
+ tooltip={i18n.str`Method to use for wire transfer`}
+ values={targets}
+ readonly={readonly}
+ toStr={(v) => (v === noTargetValue ? i18n.str`Choose one...` : v)}
+ />
+
+ {value.target === "ach" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n.str`Routing`}
+ readonly={readonly}
+ tooltip={i18n.str`Routing number.`}
+ />
+ <Input<Entity>
+ name="path2"
+ label={i18n.str`Account`}
+ readonly={readonly}
+ tooltip={i18n.str`Account number.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "bic" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n.str`Code`}
+ readonly={readonly}
+ tooltip={i18n.str`Business Identifier Code.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "iban" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ label={i18n.str`IBAN`}
+ tooltip={i18n.str`International Bank Account Number.`}
+ readonly={readonly}
+ placeholder="DE1231231231"
+ inputExtra={{ style: { textTransform: "uppercase" } }}
+ />
+ </Fragment>
+ )}
+ {value.target === "upi" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Account`}
+ tooltip={i18n.str`Unified Payment Interface.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "bitcoin" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Bitcoin protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "ethereum" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Ethereum protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "ilp" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Interledger protocol.`}
+ />
+ </Fragment>
+ )}
+ {value.target === "void" && <Fragment />}
+ {value.target === "x-taler-bank" && (
+ <Fragment>
+ <Input<Entity>
+ name="path1"
+ readonly={readonly}
+ label={i18n.str`Host`}
+ fromStr={(v) => {
+ if (v.startsWith("http")) {
+ try {
+ const url = new URL(v);
+ return url.host + url.pathname;
+ } catch {
+ return v;
+ }
+ }
+ return v;
+ }}
+ tooltip={i18n.str`Bank host.`}
+ help={<Fragment>
+ <div><i18n.Translate>Without scheme and may include subpath:</i18n.Translate></div>
+ <div>bank.com/</div>
+ <div>bank.com/path/subpath/</div>
+ </Fragment>}
+ />
+ <Input<Entity>
+ name="path2"
+ readonly={readonly}
+ label={i18n.str`Account`}
+ tooltip={i18n.str`Bank account.`}
+ />
+ </Fragment>
+ )}
+
+ {/**
+ * Show additional fields apart from the payto
+ */}
+ {value.target !== noTargetValue && (
+ <Fragment>
+ <Input
+ name="params.receiver-name"
+ readonly={readonly}
+ label={i18n.str`Owner's name`}
+ tooltip={i18n.str`Legal name of the person holding the account.`}
+ />
+ </Fragment>
+ )}
+ </FormProvider>
+ </InputGroup>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx
new file mode 100644
index 000000000..9956a6427
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx
@@ -0,0 +1,204 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import emptyImage from "../../assets/empty.png";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { InputWithAddon } from "./InputWithAddon.js";
+import { TranslatedString } from "@gnu-taler/taler-util";
+
+type Entity = {
+ id: string,
+ description: string;
+ image?: string;
+ extra?: string;
+};
+
+export interface Props<T extends Entity> {
+ selected?: T;
+ onChange: (p?: T) => void;
+ label: TranslatedString;
+ list: T[];
+ withImage?: boolean;
+}
+
+interface Search {
+ name: string;
+}
+
+export function InputSearchOnList<T extends Entity>({
+ selected,
+ onChange,
+ label,
+ list,
+ withImage,
+}: Props<T>): VNode {
+ const [nameForm, setNameForm] = useState<Partial<Search>>({
+ name: "",
+ });
+
+ const errors: FormErrors<Search> = {
+ name: undefined,
+ };
+ const { i18n } = useTranslationContext();
+
+ if (selected) {
+ return (
+ <article class="media">
+ {withImage &&
+ <figure class="media-left">
+ <p class="image is-128x128">
+ <img src={selected.image ? selected.image : emptyImage} />
+ </p>
+ </figure>
+ }
+ <div class="media-content">
+ <div class="content">
+ <p class="media-meta">
+ <i18n.Translate>ID</i18n.Translate>: <b>{selected.id}</b>
+ </p>
+ <p>
+ <i18n.Translate>Description</i18n.Translate>:{" "}
+ {selected.description}
+ </p>
+ <div class="buttons is-right mt-5">
+ <button
+ class="button is-info"
+ onClick={() => onChange(undefined)}
+ >
+ clear
+ </button>
+ </div>
+ </div>
+ </div>
+ </article>
+ );
+ }
+
+ return (
+ <FormProvider<Search>
+ errors={errors}
+ object={nameForm}
+ valueHandler={setNameForm}
+ >
+ <InputWithAddon<Search>
+ name="name"
+ label={label}
+ tooltip={i18n.str`enter description or id`}
+ addonAfter={
+ <span class="icon">
+ <i class="mdi mdi-magnify" />
+ </span>
+ }
+ >
+ <div>
+ <DropdownList
+ name={nameForm.name}
+ list={list}
+ onSelect={(p) => {
+ setNameForm({ name: "" });
+ onChange(p);
+ }}
+ withImage={!!withImage}
+ />
+ </div>
+ </InputWithAddon>
+ </FormProvider>
+ );
+}
+
+interface DropdownListProps<T extends Entity> {
+ name?: string;
+ onSelect: (p: T) => void;
+ list: T[];
+ withImage: boolean;
+}
+
+function DropdownList<T extends Entity>({ name, onSelect, list, withImage }: DropdownListProps<T>) {
+ const { i18n } = useTranslationContext();
+ if (!name) {
+ /* FIXME
+ this BR is added to occupy the space that will be added when the
+ dropdown appears
+ */
+ return (
+ <div>
+ <br />
+ </div>
+ );
+ }
+ const filtered = list.filter(
+ (p) => p.id.includes(name) || p.description.includes(name),
+ );
+
+ return (
+ <div class="dropdown is-active">
+ <div
+ class="dropdown-menu"
+ id="dropdown-menu"
+ role="menu"
+ style={{ minWidth: "20rem" }}
+ >
+ <div class="dropdown-content">
+ {!filtered.length ? (
+ <div class="dropdown-item">
+ <i18n.Translate>
+ no match found with that description or id
+ </i18n.Translate>
+ </div>
+ ) : (
+ filtered.map((p) => (
+ <div
+ key={p.id}
+ class="dropdown-item"
+ onClick={() => onSelect(p)}
+ style={{ cursor: "pointer" }}
+ >
+ <article class="media">
+ {withImage &&
+ <div class="media-left">
+ <div class="image" style={{ minWidth: 64 }}>
+ <img
+ src={p.image ? p.image : emptyImage}
+ style={{ width: 64, height: 64 }}
+ />
+ </div>
+ </div>
+ }
+ <div class="media-content">
+ <div class="content">
+ <p>
+ <strong>{p.id}</strong> {p.extra !== undefined ? <small>{p.extra}</small> : undefined}
+ <br />
+ {p.description}
+ </p>
+ </div>
+ </div>
+ </article>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx
new file mode 100644
index 000000000..4de84d984
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider } from "./FormProvider.js";
+import { InputSecured } from "./InputSecured.js";
+
+export default {
+ title: "Components/Form/InputSecured",
+ component: InputSecured,
+};
+
+type T = { auth_token: string | null };
+
+export const InitialValueEmpty = (): VNode => {
+ const [state, setState] = useState<Partial<T>>({ auth_token: "" });
+ return (
+ <FormProvider<T> object={state} errors={{}} valueHandler={setState}>
+ Initial value: ''
+ <InputSecured<T> name="auth_token" label="Access token" />
+ </FormProvider>
+ );
+};
+
+export const InitialValueToken = (): VNode => {
+ const [state, setState] = useState<Partial<T>>({ auth_token: "token" });
+ return (
+ <FormProvider<T> object={state} errors={{}} valueHandler={setState}>
+ <InputSecured<T> name="auth_token" label="Access token" />
+ </FormProvider>
+ );
+};
+
+export const InitialValueNull = (): VNode => {
+ const [state, setState] = useState<Partial<T>>({ auth_token: null });
+ return (
+ <FormProvider<T> object={state} errors={{}} valueHandler={setState}>
+ Initial value: ''
+ <InputSecured<T> name="auth_token" label="Access token" />
+ </FormProvider>
+ );
+};
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx
new file mode 100644
index 000000000..4a35ad96c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx
@@ -0,0 +1,186 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { InputProps, useField } from "./useField.js";
+
+export type Props<T> = InputProps<T>;
+
+const TokenStatus = ({ prev, post }: any) => {
+ const { i18n } = useTranslationContext();
+ if (
+ (prev === undefined || prev === null) &&
+ (post === undefined || post === null)
+ )
+ return null;
+ return prev === post ? null : post === null ? (
+ <span class="tag is-danger is-align-self-center ml-2">
+ <i18n.Translate>Deleting</i18n.Translate>
+ </span>
+ ) : (
+ <span class="tag is-warning is-align-self-center ml-2">
+ <i18n.Translate>Changing</i18n.Translate>
+ </span>
+ );
+};
+
+export function InputSecured<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+}: Props<keyof T>): VNode {
+ const { error, value, initial, onChange, toStr, fromStr } = useField<T>(name);
+
+ const [active, setActive] = useState(false);
+ const [newValue, setNuewValue] = useState("");
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ {!active ? (
+ <Fragment>
+ <div class="field has-addons">
+ <button
+ class="button"
+ onClick={(): void => {
+ setActive(!active);
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-reset" />
+ </div>
+ <span>
+ <i18n.Translate>Manage access token</i18n.Translate>
+ </span>
+ </button>
+ <TokenStatus prev={initial} post={value} />
+ </div>
+ </Fragment>
+ ) : (
+ <Fragment>
+ <div class="field has-addons">
+ <div class="control">
+ <a class="button is-static">secret-token:</a>
+ </div>
+ <div class="control is-expanded">
+ <input
+ class="input"
+ type="text"
+ placeholder={placeholder}
+ readonly={readonly || !active}
+ disabled={readonly || !active}
+ name={String(name)}
+ value={newValue}
+ onInput={(e): void => {
+ setNuewValue(e.currentTarget.value);
+ }}
+ />
+ {help}
+ </div>
+ <div class="control">
+ <button
+ class="button is-info"
+ disabled={fromStr(newValue) === value}
+ onClick={(): void => {
+ onChange(fromStr(newValue));
+ setActive(!active);
+ setNuewValue("");
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-outline" />
+ </div>
+ <span>
+ <i18n.Translate>Update</i18n.Translate>
+ </span>
+ </button>
+ </div>
+ </div>
+ </Fragment>
+ )}
+ {error ? <p class="help is-danger">{error}</p> : null}
+ </div>
+ </div>
+ {active && (
+ <div class="field is-horizontal">
+ <div class="field-body is-flex-grow-3">
+ <div class="level" style={{ width: "100%" }}>
+ <div class="level-right is-flex-grow-1">
+ <div class="level-item">
+ <button
+ class="button is-danger"
+ disabled={null === value || undefined === value}
+ onClick={(): void => {
+ onChange(null!);
+ setActive(!active);
+ setNuewValue("");
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-open-variant" />
+ </div>
+ <span>
+ <i18n.Translate>Remove</i18n.Translate>
+ </span>
+ </button>
+ </div>
+ <div class="level-item">
+ <button
+ class="button "
+ onClick={(): void => {
+ onChange(initial!);
+ setActive(!active);
+ setNuewValue("");
+ }}
+ >
+ <div class="icon is-left">
+ <i class="mdi mdi-lock-open-variant" />
+ </div>
+ <span>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx
new file mode 100644
index 000000000..f567f7247
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx
@@ -0,0 +1,94 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ values: any[];
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputSelector<T>({
+ name,
+ readonly,
+ expand,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ values,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field has-icons-right">
+ <p class={expand ? "control is-expanded select" : "control select "}>
+ <select
+ class={error ? "select is-danger" : "select"}
+ name={String(name)}
+ disabled={readonly}
+ readonly={readonly}
+ onChange={(e) => {
+ onChange(fromStr(e.currentTarget.value));
+ }}
+ >
+ {placeholder && <option>{placeholder}</option>}
+ {values.map((v, i) => {
+ return (
+ <option key={i} value={v} selected={value === v}>
+ {toStr(v)}
+ </option>
+ );
+ })}
+ </select>
+
+ {help}
+ </p>
+ {required && (
+ <span class="icon has-text-danger is-right" style={{height: "2.5em"}}>
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx b/packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx
new file mode 100644
index 000000000..d7cf04553
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx
@@ -0,0 +1,162 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { addDays } from "date-fns";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider } from "./FormProvider.js";
+import { InputStock, Stock } from "./InputStock.js";
+
+export default {
+ title: "Components/Form/InputStock",
+ component: InputStock,
+};
+
+type T = { stock?: Stock };
+
+export const CreateStockEmpty = () => {
+ const [state, setState] = useState<Partial<T>>({});
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const CreateStockUnknownRestock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 10,
+ lost: 0,
+ sold: 0,
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const CreateStockNoRestock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 10,
+ lost: 0,
+ sold: 0,
+ nextRestock: { t_s: "never" },
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const CreateStockWithRestock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 15,
+ lost: 0,
+ sold: 0,
+ nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 },
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const UpdatingProductWithManagedStock = () => {
+ const [state, setState] = useState<Partial<T>>({
+ stock: {
+ current: 100,
+ lost: 0,
+ sold: 0,
+ nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 },
+ },
+ });
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" alreadyExist />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
+
+export const UpdatingProductWithInfiniteStock = () => {
+ const [state, setState] = useState<Partial<T>>({});
+ return (
+ <FormProvider<T>
+ name="product"
+ object={state}
+ errors={{}}
+ valueHandler={setState}
+ >
+ <InputStock<T> name="stock" label="Stock" alreadyExist />
+ <div>
+ <pre>{JSON.stringify(state, undefined, 2)}</pre>
+ </div>
+ </FormProvider>
+ );
+};
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx b/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx
new file mode 100644
index 000000000..8104d1f9f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx
@@ -0,0 +1,223 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { TalerMerchantApi, TalerProtocolTimestamp } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h } from "preact";
+import { useLayoutEffect, useState } from "preact/hooks";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { InputDate } from "./InputDate.js";
+import { InputGroup } from "./InputGroup.js";
+import { InputLocation } from "./InputLocation.js";
+import { InputNumber } from "./InputNumber.js";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ alreadyExist?: boolean;
+}
+
+type Entity = Stock;
+
+export interface Stock {
+ current: number;
+ lost: number;
+ sold: number;
+ address?: TalerMerchantApi.Location;
+ nextRestock?: TalerProtocolTimestamp;
+}
+
+interface StockDelta {
+ incoming: number;
+ lost: number;
+}
+
+export function InputStock<T>({
+ name,
+ tooltip,
+ label,
+ alreadyExist,
+}: Props<keyof T>) {
+ const { error, value, onChange } = useField<T>(name);
+
+ const [errors, setErrors] = useState<FormErrors<Entity>>({});
+
+ const [formValue, valueHandler] = useState<Partial<Entity>>(value);
+ const [addedStock, setAddedStock] = useState<StockDelta>({
+ incoming: 0,
+ lost: 0,
+ });
+ const { i18n } = useTranslationContext();
+
+ useLayoutEffect(() => {
+ if (!formValue) {
+ onChange(undefined as any);
+ } else {
+ onChange({
+ ...formValue,
+ current: (formValue?.current || 0) + addedStock.incoming,
+ lost: (formValue?.lost || 0) + addedStock.lost,
+ } as any);
+ }
+ }, [formValue, addedStock]);
+
+ if (!formValue) {
+ return (
+ <Fragment>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field has-addons">
+ {!alreadyExist ? (
+ <button
+ class="button"
+ data-tooltip={i18n.str`click here to configure the stock of the product, leave it as is and the backend will not control stock`}
+ onClick={(): void => {
+ valueHandler({
+ current: 0,
+ lost: 0,
+ sold: 0,
+ } as Stock as any);
+ }}
+ >
+ <span>
+ <i18n.Translate>Manage stock</i18n.Translate>
+ </span>
+ </button>
+ ) : (
+ <button
+ class="button"
+ data-tooltip={i18n.str`this product has been configured without stock control`}
+ disabled
+ >
+ <span>
+ <i18n.Translate>Infinite</i18n.Translate>
+ </span>
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+ }
+
+ const currentStock =
+ (formValue.current || 0) - (formValue.lost || 0) - (formValue.sold || 0);
+
+ const stockAddedErrors: FormErrors<typeof addedStock> = {
+ lost:
+ currentStock + addedStock.incoming < addedStock.lost
+ ? i18n.str`lost cannot be greater than current and incoming (max ${currentStock + addedStock.incoming
+ })`
+ : undefined,
+ };
+
+ // const stockUpdateDescription = stockAddedErrors.lost ? '' : (
+ // !!addedStock.incoming || !!addedStock.lost ?
+ // i18n.str`current stock will change from ${currentStock} to ${currentStock + addedStock.incoming - addedStock.lost}` :
+ // i18n.str`current stock will stay at ${currentStock}`
+ // )
+
+ return (
+ <Fragment>
+ <div class="card">
+ <header class="card-header">
+ <p class="card-header-title">
+ {label}
+ {tooltip && (
+ <span class="icon" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </p>
+ </header>
+ <div class="card-content">
+ <FormProvider<Entity>
+ name="stock"
+ errors={errors}
+ object={formValue}
+ valueHandler={valueHandler}
+ >
+ {alreadyExist ? (
+ <Fragment>
+ <FormProvider
+ name="added"
+ errors={stockAddedErrors}
+ object={addedStock}
+ valueHandler={setAddedStock as any}
+ >
+ <InputNumber name="incoming" label={i18n.str`Incoming`} />
+ <InputNumber name="lost" label={i18n.str`Lost`} />
+ </FormProvider>
+
+ {/* <div class="field is-horizontal">
+ <div class="field-label is-normal" />
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ {stockUpdateDescription}
+ </div>
+ </div>
+ </div> */}
+ </Fragment>
+ ) : (
+ <InputNumber<Entity>
+ name="current"
+ label={i18n.str`Current`}
+ side={
+ <button
+ class="button is-danger"
+ data-tooltip={i18n.str`remove stock control for this product`}
+ onClick={(): void => {
+ valueHandler(undefined as any);
+ }}
+ >
+ <span>
+ <i18n.Translate>without stock</i18n.Translate>
+ </span>
+ </button>
+ }
+ />
+ )}
+
+ <InputDate<Entity>
+ name="nextRestock"
+ label={i18n.str`Next restock`}
+ withTimestampSupport
+ />
+
+ <InputGroup<Entity> name="address" label={i18n.str`Warehouse address`}>
+ <InputLocation name="address" />
+ </InputGroup>
+ </FormProvider>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+// (
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputTab.tsx b/packages/merchant-backoffice-ui/src/components/form/InputTab.tsx
new file mode 100644
index 000000000..1cd88d31a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputTab.tsx
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ readonly?: boolean;
+ expand?: boolean;
+ values: any[];
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputTab<T>({
+ name,
+ readonly,
+ expand,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ values,
+ fromStr = defaultFromString,
+ toStr = defaultToString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field has-icons-right">
+ <p class={expand ? "control is-expanded " : "control "}>
+ <div class="tabs is-toggle is-fullwidth is-small">
+ <ul>
+ {values.map((v, i) => {
+ return (
+ <li key={i} class={value === v ? "is-active" : ""}
+ onClick={(e) => { onChange(v) }}
+ >
+ <a style={{ cursor: "initial" }}>
+ <span>{toStr(v)}</span>
+ </a>
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ {help}
+ </p>
+ {required && (
+ <span class="icon has-text-danger is-right" style={{ height: "2.5em" }}>
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx b/packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx
new file mode 100644
index 000000000..4392c7659
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx
@@ -0,0 +1,147 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useCallback, useState } from "preact/hooks";
+import * as yup from "yup";
+import { TaxSchema as schema } from "../../schemas/index.js";
+import { FormErrors, FormProvider } from "./FormProvider.js";
+import { Input } from "./Input.js";
+import { InputGroup } from "./InputGroup.js";
+import { InputProps, useField } from "./useField.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+export interface Props<T> extends InputProps<T> {
+ isValid?: (e: any) => boolean;
+}
+
+type Entity = TalerMerchantApi.Tax;
+export function InputTaxes<T>({
+ name,
+ readonly,
+ label,
+}: Props<keyof T>): VNode {
+ const { value: taxes, onChange } = useField<T>(name);
+
+ const [value, valueHandler] = useState<Partial<Entity>>({});
+ // const [errors, setErrors] = useState<FormErrors<Entity>>({})
+
+ let errors: FormErrors<Entity> = {};
+
+ try {
+ schema.validateSync(value, { abortEarly: false });
+ } catch (err) {
+ if (err instanceof yup.ValidationError) {
+ const yupErrors = err.inner as yup.ValidationError[];
+ errors = yupErrors.reduce(
+ (prev, cur) =>
+ !cur.path ? prev : { ...prev, [cur.path]: cur.message },
+ {},
+ );
+ }
+ }
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submit = useCallback((): void => {
+ onChange([value as any, ...taxes] as any);
+ valueHandler({});
+ }, [value]);
+
+ const { i18n } = useTranslationContext();
+
+ //FIXME: translating plural singular
+ return (
+ <InputGroup
+ name="tax"
+ label={label}
+ alternative={
+ taxes.length > 0 && (
+ <p>This product has {taxes.length} applicable taxes configured.</p>
+ )
+ }
+ >
+ <FormProvider<Entity>
+ name="tax"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <div class="field is-horizontal">
+ <div class="field-label is-normal" />
+ <div class="field-body" style={{ display: "block" }}>
+ {taxes.map((v: any, i: number) => (
+ <div
+ key={i}
+ class="tags has-addons mt-3 mb-0 mr-3"
+ style={{ flexWrap: "nowrap" }}
+ >
+ <span
+ class="tag is-medium is-info mb-0"
+ style={{ maxWidth: "90%" }}
+ >
+ <b>{v.tax}</b>: {v.name}
+ </span>
+ <a
+ class="tag is-medium is-danger is-delete mb-0"
+ onClick={() => {
+ onChange(taxes.filter((f: any) => f !== v) as any);
+ valueHandler(v);
+ }}
+ />
+ </div>
+ ))}
+ {!taxes.length && i18n.str`No taxes configured for this product.`}
+ </div>
+ </div>
+
+ <Input<Entity>
+ name="tax"
+ label={i18n.str`Amount`}
+ tooltip={i18n.str`Taxes can be in currencies that differ from the main currency used by the merchant.`}
+ >
+ <i18n.Translate>
+ Enter currency and value separated with a colon, e.g.
+ &quot;USD:2.3&quot;.
+ </i18n.Translate>
+ </Input>
+
+ <Input<Entity>
+ name="name"
+ label={i18n.str`Description`}
+ tooltip={i18n.str`Legal name of the tax, e.g. VAT or import duties.`}
+ />
+
+ <div class="buttons is-right mt-5">
+ <button
+ class="button is-info"
+ data-tooltip={i18n.str`add tax to the tax list`}
+ disabled={hasErrors}
+ onClick={submit}
+ >
+ <i18n.Translate>Add</i18n.Translate>
+ </button>
+ </div>
+ </FormProvider>
+ </InputGroup>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx b/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx
new file mode 100644
index 000000000..8c935f33b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputToggle.tsx
@@ -0,0 +1,91 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ name: T;
+ readonly?: boolean;
+ expand?: boolean;
+ threeState?: boolean;
+ toBoolean?: (v?: any) => boolean | undefined;
+ fromBoolean?: (s: boolean | undefined) => any;
+}
+
+const defaultToBoolean = (f?: any): boolean | undefined => f || "";
+const defaultFromBoolean = (v: boolean | undefined): any => v as any;
+
+export function InputToggle<T>({
+ name,
+ readonly,
+ placeholder,
+ tooltip,
+ label,
+ help,
+ threeState,
+ expand,
+ fromBoolean = defaultFromBoolean,
+ toBoolean = defaultToBoolean,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange } = useField<T>(name);
+
+ const onCheckboxClick = (): void => {
+ const c = toBoolean(value);
+ if (c === false && threeState) return onChange(undefined as any);
+ return onChange(fromBoolean(!c));
+ };
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label" >
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class={expand ? "control is-expanded" : "control"}>
+ <label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}>
+ <input
+ type="checkbox"
+ class={toBoolean(value) === undefined ? "is-indeterminate" : "toggle-checkbox"}
+ checked={toBoolean(value)}
+ placeholder={placeholder}
+ readonly={readonly}
+ name={String(name)}
+ disabled={readonly}
+ onChange={onCheckboxClick}
+ />
+ <div class={`toggle-switch ${readonly ? "disabled" : ""}`} style={{ cursor: readonly ? "default" : undefined }}></div>
+ </label>
+ {help}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx b/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx
new file mode 100644
index 000000000..b8cd4c2d2
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx
@@ -0,0 +1,116 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { InputProps, useField } from "./useField.js";
+
+export interface Props<T> extends InputProps<T> {
+ expand?: boolean;
+ inputType?: "text" | "number" | "password";
+ addonBefore?: ComponentChildren;
+ addonAfter?: ComponentChildren;
+ addonAfterAction?: () => void;
+ toStr?: (v?: any) => string;
+ fromStr?: (s: string) => any;
+ inputExtra?: any;
+ children?: ComponentChildren;
+ side?: ComponentChildren;
+}
+
+const defaultToString = (f?: any): string => f || "";
+const defaultFromString = (v: string): any => v as any;
+
+export function InputWithAddon<T>({
+ name,
+ readonly,
+ addonBefore,
+ children,
+ expand,
+ label,
+ placeholder,
+ help,
+ tooltip,
+ inputType,
+ inputExtra,
+ side,
+ addonAfter,
+ addonAfterAction,
+ toStr = defaultToString,
+ fromStr = defaultFromString,
+}: Props<keyof T>): VNode {
+ const { error, value, onChange, required } = useField<T>(name);
+
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <div class="field has-addons">
+ {addonBefore && (
+ <div class="control">
+ <a class="button is-static">{addonBefore}</a>
+ </div>
+ )}
+ <p
+ class={`control${expand ? " is-expanded" : ""}${required ? " has-icons-right" : ""
+ }`}
+ >
+ <input
+ {...(inputExtra || {})}
+ class={error ? "input is-danger" : "input"}
+ type={inputType}
+ placeholder={placeholder}
+ readonly={readonly}
+ disabled={readonly}
+ name={String(name)}
+ value={toStr(value)}
+ onChange={(e): void => onChange(fromStr(e.currentTarget.value))}
+ />
+ {required && (
+ <span class="icon has-text-danger is-right">
+ <i class="mdi mdi-alert" />
+ </span>
+ )}
+ {children}
+ </p>
+ {addonAfter && (
+ <div class="control" onClick={addonAfterAction} style={{ cursor: addonAfterAction ? "pointer" : undefined }}>
+ <a class="button is-static">{addonAfter}</a>
+ </div>
+ )}
+ </div>
+ {error && <p class="help is-danger">{error}</p>}
+ <span class="has-text-grey">{help}</span>
+ </div>
+ {expand ? <div>{side}</div> : side}
+ </div>
+
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/JumpToElementById.tsx b/packages/merchant-backoffice-ui/src/components/form/JumpToElementById.tsx
new file mode 100644
index 000000000..f5f9d5b4f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/JumpToElementById.tsx
@@ -0,0 +1,63 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+
+export function JumpToElementById({ testIfExist, onSelect, placeholder, description }: { placeholder: TranslatedString, description: TranslatedString, testIfExist: (id: string) => Promise<boolean>, onSelect: (id: string) => void }): VNode {
+ const { i18n } = useTranslationContext()
+
+ const [error, setError] = useState<string | undefined>(
+ undefined,
+ );
+
+ const [id, setId] = useState<string>()
+ async function check(currentId: string | undefined): Promise<void> {
+ if (!currentId) {
+ setError(i18n.str`missing id`);
+ return;
+ }
+ try {
+ const exi = await testIfExist(currentId);
+ if (exi) {
+ onSelect(currentId);
+ setError(undefined);
+ } else {
+ setError(i18n.str`not found`);
+ }
+ } catch {
+ setError(i18n.str`not found`);
+ }
+ }
+
+ return <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <div class="field has-addons">
+ <div class="control">
+ <input
+ class={error ? "input is-danger" : "input"}
+ type="text"
+ value={id ?? ""}
+ onChange={(e) => setId(e.currentTarget.value)}
+ placeholder={placeholder}
+ />
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ <span
+ class="has-tooltip-bottom"
+ data-tooltip={description}
+ >
+ <button
+ class="button"
+ onClick={(e) => check(id)}
+ >
+ <span class="icon">
+ <i class="mdi mdi-arrow-right" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/TextField.tsx b/packages/merchant-backoffice-ui/src/components/form/TextField.tsx
new file mode 100644
index 000000000..8f897c2d8
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/TextField.tsx
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useField, InputProps } from "./useField.js";
+
+interface Props<T> extends InputProps<T> {
+ inputType?: "text" | "number" | "multiline" | "password";
+ expand?: boolean;
+ side?: ComponentChildren;
+ children: ComponentChildren;
+}
+
+export function TextField<T>({
+ name,
+ tooltip,
+ label,
+ expand,
+ help,
+ children,
+ side,
+}: Props<keyof T>): VNode {
+ const { error } = useField<T>(name);
+ return (
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ {label}
+ {tooltip && (
+ <span class="icon has-tooltip-right" data-tooltip={tooltip}>
+ <i class="mdi mdi-information" />
+ </span>
+ )}
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p
+ class={
+ expand
+ ? "control is-expanded has-icons-right"
+ : "control has-icons-right"
+ }
+ >
+ {children}
+ {help}
+ </p>
+ {error && <p class="help is-danger">{error}</p>}
+ </div>
+ {side}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/useField.tsx b/packages/merchant-backoffice-ui/src/components/form/useField.tsx
new file mode 100644
index 000000000..49bba4984
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/useField.tsx
@@ -0,0 +1,92 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ComponentChildren, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useFormContext } from "./FormProvider.js";
+
+interface Use<V> {
+ error?: string;
+ required: boolean;
+ value: any;
+ initial: any;
+ onChange: (v: V) => void;
+ toStr: (f: V | undefined) => string;
+ fromStr: (v: string) => V;
+}
+
+export function useField<T>(name: keyof T): Use<T[typeof name]> {
+ const { errors, object, initialObject, toStr, fromStr, valueHandler } =
+ useFormContext<T>();
+ type P = typeof name;
+ type V = T[P];
+ const [isDirty, setDirty] = useState(false);
+ const updateField =
+ (field: P) =>
+ (value: V): void => {
+ setDirty(true);
+ return valueHandler((prev) => {
+ return setValueDeeper(prev, String(field).split("."), value);
+ });
+ };
+
+ const defaultToString = (f?: V): string => String(!f ? "" : f);
+ const defaultFromString = (v: string): V => v as any;
+ const value = readField(object, String(name));
+ const initial = readField(initialObject, String(name));
+ const hasError = readField(errors, String(name));
+ return {
+ error: isDirty ? hasError : undefined,
+ required: !isDirty && hasError,
+ value,
+ initial,
+ onChange: updateField(name) as any,
+ toStr: toStr[name] ? toStr[name]! : defaultToString,
+ fromStr: fromStr[name] ? fromStr[name]! : defaultFromString,
+ };
+}
+/**
+ * read the field of an object an support accessing it using '.'
+ *
+ * @param object
+ * @param name
+ * @returns
+ */
+const readField = (object: any, name: string) => {
+ return name
+ .split(".")
+ .reduce((prev, current) => prev && prev[current], object);
+};
+
+const setValueDeeper = (object: any, names: string[], value: any): any => {
+ if (names.length === 0) return value;
+ const [head, ...rest] = names;
+ return { ...object, [head]: setValueDeeper(object[head] || {}, rest, value) };
+};
+
+export interface InputProps<T> {
+ name: T;
+ label: ComponentChildren;
+ placeholder?: string;
+ tooltip?: ComponentChildren;
+ readonly?: boolean;
+ help?: ComponentChildren;
+}
diff --git a/packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx b/packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx
new file mode 100644
index 000000000..4fbfc4a75
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx
@@ -0,0 +1,41 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useFormContext } from "./FormProvider.js";
+
+interface Use {
+ hasError?: boolean;
+}
+
+export function useGroupField<T>(name: keyof T): Use {
+ const f = useFormContext<T>();
+ if (!f) return {};
+
+ return {
+ hasError: readField(f.errors, String(name)),
+ };
+}
+
+const readField = (object: any, name: string) => {
+ return name
+ .split(".")
+ .reduce((prev, current) => prev && prev[current], object);
+};
diff --git a/packages/merchant-backoffice-ui/src/components/index.stories.ts b/packages/merchant-backoffice-ui/src/components/index.stories.ts
new file mode 100644
index 000000000..f96defc09
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/index.stories.ts
@@ -0,0 +1,17 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export * as payto from "./form/InputPaytoForm.stories.js";
diff --git a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx
new file mode 100644
index 000000000..864d09f48
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx
@@ -0,0 +1,134 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useSessionContext } from "../../context/session.js";
+import { Entity } from "../../paths/admin/create/CreatePage.js";
+import { Input } from "../form/Input.js";
+import { InputDuration } from "../form/InputDuration.js";
+import { InputGroup } from "../form/InputGroup.js";
+import { InputImage } from "../form/InputImage.js";
+import { InputLocation } from "../form/InputLocation.js";
+import { InputSelector } from "../form/InputSelector.js";
+import { InputToggle } from "../form/InputToggle.js";
+import { InputWithAddon } from "../form/InputWithAddon.js";
+import { TextField } from "../form/TextField.js";
+
+export function DefaultInstanceFormFields({
+ readonlyId,
+ showId,
+}: {
+ readonlyId?: boolean;
+ showId: boolean;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { state } = useSessionContext();
+ return (
+ <Fragment>
+ {showId && (
+ <InputWithAddon<Entity>
+ name="id"
+ addonBefore={new URL("instances/", state.backendUrl.href).href}
+ readonly={readonlyId}
+ label={i18n.str`Identifier`}
+ tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`}
+ />
+ )}
+
+ <Input<Entity>
+ name="name"
+ label={i18n.str`Business name`}
+ tooltip={i18n.str`Legal name of the business represented by this instance.`}
+ />
+
+ <TextField name="asdasd" label="">
+ <i18n.Translate>
+ Choose individual if you don't have or are not required to have legal business permission.
+ </i18n.Translate>
+ </TextField>
+
+ <InputSelector<Entity>
+ name="user_type"
+ label={i18n.str`Selling as`}
+ tooltip={i18n.str`Different type of account can have different rules and requirements.`}
+ values={["business", "individual"]}
+ toStr={(d: string) => {
+ return d.toUpperCase();
+ }}
+ />
+
+ <Input<Entity>
+ name="email"
+ label={i18n.str`Email`}
+ tooltip={i18n.str`Contact email`}
+ />
+
+ <Input<Entity>
+ name="website"
+ label={i18n.str`Website URL`}
+ tooltip={i18n.str`URL.`}
+ />
+
+ <InputImage<Entity>
+ name="logo"
+ label={i18n.str`Logo`}
+ tooltip={i18n.str`Logo image.`}
+ />
+
+ <InputGroup
+ name="address"
+ label={i18n.str`Address`}
+ tooltip={i18n.str`Physical location of the merchant.`}
+ >
+ <InputLocation name="address" />
+ </InputGroup>
+
+ <InputGroup
+ name="jurisdiction"
+ label={i18n.str`Jurisdiction`}
+ tooltip={i18n.str`Jurisdiction for legal disputes with the merchant.`}
+ >
+ <InputLocation name="jurisdiction" />
+ </InputGroup>
+
+ <InputToggle<Entity>
+ name="use_stefan"
+ label={i18n.str`Pay transaction fee`}
+ tooltip={i18n.str`Assume the cost of the transaction of let the user pay for it.`}
+ />
+
+ <InputDuration<Entity>
+ name="default_pay_delay"
+ label={i18n.str`Default payment delay`}
+ withForever
+ tooltip={i18n.str`Time customers have to pay an order before the offer expires by default.`}
+ />
+
+ <InputDuration<Entity>
+ name="default_wire_transfer_delay"
+ label={i18n.str`Default wire transfer delay`}
+ tooltip={i18n.str`Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.`}
+ withForever
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/menu/LangSelector.tsx b/packages/merchant-backoffice-ui/src/components/menu/LangSelector.tsx
new file mode 100644
index 000000000..a6cd8014d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/menu/LangSelector.tsx
@@ -0,0 +1,92 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import langIcon from "../../assets/icons/languageicon.svg";
+import { strings as messages } from "../../i18n/strings.js";
+
+type LangsNames = {
+ [P in keyof typeof messages]: string;
+};
+
+const names: LangsNames = {
+ es: "Español [es]",
+ en: "English [en]",
+ fr: "Français [fr]",
+ de: "Deutsch [de]",
+ sv: "Svenska [sv]",
+ it: "Italiano [it]",
+};
+
+function getLangName(s: keyof LangsNames | string) {
+ if (names[s]) return names[s];
+ return s;
+}
+
+export function LangSelector(): VNode {
+ const [updatingLang, setUpdatingLang] = useState(false);
+ const { lang, changeLanguage } = useTranslationContext();
+
+ return (
+ <div class="dropdown is-active ">
+ <div class="dropdown-trigger">
+ <button
+ class="button has-tooltip-left"
+ data-tooltip="change language selection"
+ aria-haspopup="true"
+ aria-controls="dropdown-menu"
+ onClick={() => setUpdatingLang(!updatingLang)}
+ >
+ <div class="icon is-small is-left">
+ <img src={langIcon} />
+ </div>
+ <span>{getLangName(lang)}</span>
+ <div class="icon is-right">
+ <i class="mdi mdi-chevron-down" />
+ </div>
+ </button>
+ </div>
+ {updatingLang && (
+ <div class="dropdown-menu" id="dropdown-menu" role="menu">
+ <div class="dropdown-content">
+ {Object.keys(messages)
+ .filter((l) => l !== lang)
+ .map((l) => (
+ <a
+ key={l}
+ class="dropdown-item"
+ value={l}
+ onClick={() => {
+ changeLanguage(l);
+ setUpdatingLang(false);
+ }}
+ >
+ {getLangName(l)}
+ </a>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/menu/NavigationBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/NavigationBar.tsx
new file mode 100644
index 000000000..d81410bdf
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/menu/NavigationBar.tsx
@@ -0,0 +1,72 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import logo from "../../assets/logo-2021.svg";
+
+interface Props {
+ onMobileMenu: () => void;
+ title: string;
+}
+
+export function NavigationBar({ onMobileMenu, title }: Props): VNode {
+ return (
+ <nav
+ class="navbar is-fixed-top"
+ role="navigation"
+ aria-label="main navigation"
+ >
+ <div class="navbar-brand">
+ <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}>
+ {title}
+ </span>
+
+ <a
+ role="button"
+ class="navbar-burger"
+ aria-label="menu"
+ aria-expanded="false"
+ onClick={(e) => {
+ onMobileMenu();
+ e.stopPropagation();
+ }}
+ >
+ <span aria-hidden="true" />
+ <span aria-hidden="true" />
+ <span aria-hidden="true" />
+ </a>
+ </div>
+
+ <div class="navbar-menu ">
+ <a
+ class="navbar-start is-justify-content-center is-flex-grow-1"
+ href="https://taler.net"
+ >
+ <img src={logo} style={{ height: 35, margin: 10 }} />
+ </a>
+ <div class="navbar-end">
+ <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}>
+ </div>
+ </div>
+ </div>
+ </nav>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx
new file mode 100644
index 000000000..2090704d9
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx
@@ -0,0 +1,268 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerError } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useSessionContext } from "../../context/session.js";
+import { useInstanceKYCDetails } from "../../hooks/instance.js";
+import { LangSelector } from "./LangSelector.js";
+
+// const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
+const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
+
+interface Props {
+ mobile?: boolean;
+}
+
+export function Sidebar({ mobile }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { state, logOut, config } = useSessionContext();
+ const kycStatus = useInstanceKYCDetails();
+
+ const needKYC =
+ kycStatus !== undefined &&
+ !(kycStatus instanceof TalerError) &&
+ kycStatus.type === "ok" &&
+ !!kycStatus.body;
+ const isLoggedIn = state.status === "loggedIn";
+ const hasToken = isLoggedIn && state.token !== undefined;
+
+ return (
+ <aside
+ class="aside is-placed-left is-expanded"
+ style={{ overflowY: "scroll" }}
+ >
+ {mobile && (
+ <div
+ class="footer"
+ onClick={(e) => {
+ return e.stopImmediatePropagation();
+ }}
+ >
+ <LangSelector />
+ </div>
+ )}
+ <div class="aside-tools">
+ <div class="aside-tools-label">
+ <div>
+ <b>Taler</b> Backoffice
+ </div>
+ <div
+ class="is-size-7 has-text-right"
+ style={{ lineHeight: 0, marginTop: -10 }}
+ >
+ {VERSION} ({config.version})
+ </div>
+ </div>
+ </div>
+ <div class="menu is-menu-main">
+ {isLoggedIn ? (
+ <Fragment>
+ <ul class="menu-list">
+ <li>
+ <a href={"/orders"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-cash-register" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Orders</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/inventory"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-shopping" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Inventory</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/transfers"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-arrow-left-right" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Transfers</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/templates"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Templates</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ {needKYC && (
+ <li>
+ <a href={"/kyc"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-account-check" />
+ </span>
+ <span class="menu-item-label">KYC Status</span>
+ </a>
+ </li>
+ )}
+ </ul>
+ <p class="menu-label">
+ <i18n.Translate>Configuration</i18n.Translate>
+ </p>
+ <ul class="menu-list">
+ <li>
+ <a href={"/bank"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-bank" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Bank account</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/otp-devices"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-lock" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>OTP Devices</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/webhooks"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Webhooks</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/settings"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-square-edit-outline" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Settings</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/token"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-security" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Access token</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ </ul>
+ </Fragment>
+ ) : undefined}
+ <p class="menu-label">
+ <i18n.Translate>Connection</i18n.Translate>
+ </p>
+ <ul class="menu-list">
+ <li>
+ <a class="has-icon is-state-info is-hoverable" href="/interface">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Interface</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <div>
+ <span style={{ width: "3rem" }} class="icon">
+ <i class="mdi mdi-web" />
+ </span>
+ <span class="menu-item-label">{state.backendUrl.hostname}</span>
+ </div>
+ </li>
+ <li>
+ <div>
+ <span style={{ width: "3rem" }} class="icon">
+ ID
+ </span>
+ <span class="menu-item-label">{state.instance}</span>
+ </div>
+ </li>
+ {state.isAdmin && (
+ <Fragment>
+ <p class="menu-label">
+ <i18n.Translate>Instances</i18n.Translate>
+ </p>
+ <li>
+ <a href={"/instance/new"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-plus" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>New</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a href={"/instances"} class="has-icon">
+ <span class="icon">
+ <i class="mdi mdi-format-list-bulleted" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>List</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ </Fragment>
+ )}
+ {hasToken ? (
+ <li>
+ <a
+ class="has-icon is-state-info is-hoverable"
+ onClick={(e): void => {
+ logOut();
+ e.preventDefault();
+ }}
+ >
+ <span class="icon">
+ <i class="mdi mdi-logout default" />
+ </span>
+ <span class="menu-item-label">
+ <i18n.Translate>Log out</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ ) : undefined}
+ </ul>
+ </div>
+ </aside>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/menu/index.tsx b/packages/merchant-backoffice-ui/src/components/menu/index.tsx
new file mode 100644
index 000000000..a35c07ace
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/menu/index.tsx
@@ -0,0 +1,243 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { AdminPaths } from "../../AdminRoutes.js";
+import { InstancePaths } from "../../Routing.js";
+import { Notification } from "../../utils/types.js";
+import { NavigationBar } from "./NavigationBar.js";
+import { Sidebar } from "./SideBar.js";
+import { useSessionContext } from "../../context/session.js";
+import { useNavigationContext } from "@gnu-taler/web-util/browser";
+
+function getInstanceTitle(path: string, id: string): string {
+ switch (path) {
+ case InstancePaths.settings:
+ return `${id}: Settings`;
+ case InstancePaths.order_list:
+ return `${id}: Orders`;
+ case InstancePaths.order_new:
+ return `${id}: New order`;
+ case InstancePaths.inventory_list:
+ return `${id}: Inventory`;
+ case InstancePaths.inventory_new:
+ return `${id}: New product`;
+ case InstancePaths.inventory_update:
+ return `${id}: Update product`;
+ case InstancePaths.reserves_new:
+ return `${id}: New reserve`;
+ case InstancePaths.reserves_list:
+ return `${id}: Reserves`;
+ case InstancePaths.transfers_list:
+ return `${id}: Transfers`;
+ case InstancePaths.transfers_new:
+ return `${id}: New transfer`;
+ case InstancePaths.webhooks_list:
+ return `${id}: Webhooks`;
+ case InstancePaths.webhooks_new:
+ return `${id}: New webhook`;
+ case InstancePaths.webhooks_update:
+ return `${id}: Update webhook`;
+ case InstancePaths.otp_devices_list:
+ return `${id}: otp devices`;
+ case InstancePaths.otp_devices_new:
+ return `${id}: New otp devices`;
+ case InstancePaths.otp_devices_update:
+ return `${id}: Update otp devices`;
+ case InstancePaths.templates_new:
+ return `${id}: New template`;
+ case InstancePaths.templates_update:
+ return `${id}: Update template`;
+ case InstancePaths.templates_list:
+ return `${id}: Templates`;
+ case InstancePaths.templates_use:
+ return `${id}: Use template`;
+ case InstancePaths.interface:
+ return `${id}: Interface`;
+ default:
+ return "";
+ }
+}
+
+function getAdminTitle(path: string, instance: string) {
+ if (path === AdminPaths.new_instance) return `New instance`;
+ if (path === AdminPaths.list_instances) return `Instances`;
+ return getInstanceTitle(path, instance);
+}
+
+interface MenuProps {}
+
+function WithTitle({
+ title,
+ children,
+}: {
+ title: string;
+ children: ComponentChildren;
+}): VNode {
+ useEffect(() => {
+ document.title = `Taler Backoffice: ${title}`;
+ }, [title]);
+ return <Fragment>{children}</Fragment>;
+}
+
+export function Menu(_p: MenuProps): VNode {
+ const [mobileOpen, setMobileOpen] = useState(false);
+
+ const { state, deImpersonate } = useSessionContext();
+ const { path } = useNavigationContext();
+
+ const titleWithSubtitle = !state.isAdmin
+ ? getInstanceTitle(path, state.instance)
+ : getAdminTitle(path, state.instance);
+
+ const isLoggedIn = state.status === "loggedIn";
+
+ return (
+ <WithTitle title={titleWithSubtitle}>
+ <div
+ class={mobileOpen ? "has-aside-mobile-expanded" : ""}
+ onClick={() => setMobileOpen(false)}
+ >
+ <NavigationBar
+ onMobileMenu={() => setMobileOpen(!mobileOpen)}
+ title={titleWithSubtitle}
+ />
+
+ {isLoggedIn && <Sidebar mobile={mobileOpen} />}
+
+ {state.status !== "loggedOut" && state.impersonated && (
+ <nav
+ class="level"
+ style={{
+ zIndex: 100,
+ position: "fixed",
+ width: "50%",
+ marginLeft: "20%",
+ }}
+ >
+ <div class="level-item has-text-centered has-background-warning">
+ <p class="is-size-5">
+ You are viewing the instance <b>&quot;{state.instance}&quot;</b>
+ .{" "}
+ <a
+ href="#/instances"
+ onClick={() => {
+ deImpersonate();
+ }}
+ >
+ go back
+ </a>
+ </p>
+ </div>
+ </nav>
+ )}
+ </div>
+ </WithTitle>
+ );
+}
+
+interface NotYetReadyAppMenuProps {
+ title: string;
+ onShowSettings: () => void;
+ onLogout?: () => void;
+ isPasswordOk: boolean;
+}
+
+interface NotifProps {
+ notification?: Notification;
+}
+export function NotificationCard({
+ notification: n,
+}: NotifProps): VNode | null {
+ if (!n) return null;
+ return (
+ <div class="notification">
+ <div class="columns is-vcentered">
+ <div class="column is-12">
+ <article
+ class={
+ n.type === "ERROR"
+ ? "message is-danger"
+ : n.type === "WARN"
+ ? "message is-warning"
+ : "message is-info"
+ }
+ >
+ <div class="message-header">
+ <p>{n.message}</p>
+ </div>
+ {n.description && (
+ <div class="message-body">
+ <div>{n.description}</div>
+ {n.details && <pre>{n.details}</pre>}
+ </div>
+ )}
+ </article>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+interface NotConnectedAppMenuProps {
+ title: string;
+}
+export function NotConnectedAppMenu({
+ title,
+}: NotConnectedAppMenuProps): VNode {
+ const [mobileOpen, setMobileOpen] = useState(false);
+
+ useEffect(() => {
+ document.title = `Taler Backoffice: ${title}`;
+ }, [title]);
+
+ return (
+ <div
+ class={mobileOpen ? "has-aside-mobile-expanded" : ""}
+ onClick={() => setMobileOpen(false)}
+ >
+ <NavigationBar
+ onMobileMenu={() => setMobileOpen(!mobileOpen)}
+ title={title}
+ />
+ </div>
+ );
+}
+
+export function NotYetReadyAppMenu({ title }: NotYetReadyAppMenuProps): VNode {
+ const [mobileOpen, setMobileOpen] = useState(false);
+ const { state } = useSessionContext();
+
+ useEffect(() => {
+ document.title = `Taler Backoffice: ${title}`;
+ }, [title]);
+
+ const isLoggedIn = state.status === "loggedIn";
+
+ return (
+ <div
+ class={mobileOpen ? "has-aside-mobile-expanded" : ""}
+ onClick={() => setMobileOpen(false)}
+ >
+ <NavigationBar
+ onMobileMenu={() => setMobileOpen(!mobileOpen)}
+ title={title}
+ />
+ {isLoggedIn && <Sidebar mobile={mobileOpen} />}
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx
new file mode 100644
index 000000000..1335d0f77
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/modal/index.tsx
@@ -0,0 +1,496 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js";
+import { Spinner } from "../exception/loading.js";
+import { FormProvider } from "../form/FormProvider.js";
+import { Input } from "../form/Input.js";
+import { useSessionContext } from "../../context/session.js";
+
+interface Props {
+ active?: boolean;
+ description?: string;
+ onCancel?: () => void;
+ onConfirm?: () => void;
+ label?: string;
+ children?: ComponentChildren;
+ danger?: boolean;
+ disabled?: boolean;
+}
+
+export function ConfirmModal({
+ active,
+ description,
+ onCancel,
+ onConfirm,
+ children,
+ danger,
+ disabled,
+ label = "Confirm",
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class={active ? "modal is-active" : "modal"}>
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card" style={{ maxWidth: 700 }}>
+ <header class="modal-card-head">
+ {!description ? null : (
+ <p class="modal-card-title">
+ <b>{description}</b>
+ </p>
+ )}
+ <button class="delete " aria-label="close" onClick={onCancel} />
+ </header>
+ <section class="modal-card-body">{children}</section>
+ <footer class="modal-card-foot">
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ {onConfirm ? (
+ <Fragment>
+ <button class="button " onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+
+ <button
+ class={danger ? "button is-danger " : "button is-info "}
+ disabled={disabled}
+ onClick={onConfirm}
+ >
+ <i18n.Translate>{label}</i18n.Translate>
+ </button>
+ </Fragment>
+ ) : (
+ <button class="button " onClick={onCancel}>
+ <i18n.Translate>Close</i18n.Translate>
+ </button>
+ )}
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
+
+export function ContinueModal({
+ active,
+ description,
+ onCancel,
+ onConfirm,
+ children,
+ disabled,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class={active ? "modal is-active" : "modal"}>
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card">
+ <header class="modal-card-head has-background-success">
+ {!description ? null : <p class="modal-card-title">{description}</p>}
+ <button class="delete " aria-label="close" onClick={onCancel} />
+ </header>
+ <section class="modal-card-body">{children}</section>
+ <footer class="modal-card-foot">
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ <button
+ class="button is-success "
+ disabled={disabled}
+ onClick={onConfirm}
+ >
+ <i18n.Translate>Continue</i18n.Translate>
+ </button>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
+
+export function SimpleModal({ onCancel, children }: any): VNode {
+ return (
+ <div class="modal is-active">
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card">
+ <section class="modal-card-body is-main-section">{children}</section>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
+
+export function ClearConfirmModal({
+ description,
+ onCancel,
+ onClear,
+ onConfirm,
+ children,
+}: Props & { onClear?: () => void }): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="modal is-active">
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card">
+ <header class="modal-card-head">
+ {!description ? null : <p class="modal-card-title">{description}</p>}
+ <button class="delete " aria-label="close" onClick={onCancel} />
+ </header>
+ <section class="modal-card-body is-main-section">{children}</section>
+ <footer class="modal-card-foot">
+ {onClear && (
+ <button
+ class="button is-danger"
+ onClick={onClear}
+ disabled={onClear === undefined}
+ >
+ <i18n.Translate>Clear</i18n.Translate>
+ </button>
+ )}
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ <button class="button " onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ class="button is-info"
+ onClick={onConfirm}
+ disabled={onConfirm === undefined}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
+
+interface DeleteModalProps {
+ element: { id: string; name: string };
+ onCancel: () => void;
+ onConfirm: (id: string) => void;
+}
+
+export function DeleteModal({
+ element,
+ onCancel,
+ onConfirm,
+}: DeleteModalProps): VNode {
+ return (
+ <ConfirmModal
+ label={`Delete instance`}
+ description={`Delete the instance "${element.name}"`}
+ danger
+ active
+ onCancel={onCancel}
+ onConfirm={() => onConfirm(element.id)}
+ >
+ <p>
+ If you delete the instance named <b>&quot;{element.name}&quot;</b> (ID:{" "}
+ <b>{element.id}</b>), the merchant will no longer be able to process
+ orders or refunds
+ </p>
+ <p>
+ This action deletes the instance private key, but preserves all
+ transaction data. You can still access that data after deleting the
+ instance.
+ </p>
+ <p class="warning">
+ Deleting an instance <b>cannot be undone</b>.
+ </p>
+ </ConfirmModal>
+ );
+}
+
+export function PurgeModal({
+ element,
+ onCancel,
+ onConfirm,
+}: DeleteModalProps): VNode {
+ return (
+ <ConfirmModal
+ label={`Purge the instance`}
+ description={`Purge the instance "${element.name}"`}
+ danger
+ active
+ onCancel={onCancel}
+ onConfirm={() => onConfirm(element.id)}
+ >
+ <p>
+ If you purge the instance named <b>&quot;{element.name}&quot;</b> (ID:{" "}
+ <b>{element.id}</b>), you will also delete all it&apos;s transaction
+ data.
+ </p>
+ <p>
+ The instance will disappear from your list, and you will no longer be
+ able to access it&apos;s data.
+ </p>
+ <p class="warning">
+ Purging an instance <b>cannot be undone</b>.
+ </p>
+ </ConfirmModal>
+ );
+}
+
+interface UpdateTokenModalProps {
+ oldToken?: string;
+ onCancel: () => void;
+ onConfirm: (value: string) => void;
+ onClear: () => void;
+}
+
+//FIXME: merge UpdateTokenModal with SetTokenNewInstanceModal
+export function UpdateTokenModal({
+ onCancel,
+ onClear,
+ onConfirm,
+ oldToken,
+}: UpdateTokenModalProps): VNode {
+ type State = { old_token: string; new_token: string; repeat_token: string };
+ const [form, setValue] = useState<Partial<State>>({
+ old_token: "",
+ new_token: "",
+ repeat_token: "",
+ });
+ const { i18n } = useTranslationContext();
+
+ const hasInputTheCorrectOldToken = oldToken && oldToken !== form.old_token;
+ const errors = {
+ old_token: hasInputTheCorrectOldToken
+ ? i18n.str`is not the same as the current access token`
+ : undefined,
+ new_token: !form.new_token
+ ? i18n.str`cannot be empty`
+ : form.new_token === form.old_token
+ ? i18n.str`cannot be the same as the old token`
+ : undefined,
+ repeat_token:
+ form.new_token !== form.repeat_token
+ ? i18n.str`is not the same`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const { state } = useSessionContext();
+
+ const text = i18n.str`You are updating the access token from instance with id ${state.instance}`;
+
+ return (
+ <ClearConfirmModal
+ description={text}
+ onCancel={onCancel}
+ onConfirm={!hasErrors ? () => onConfirm(form.new_token!) : undefined}
+ onClear={!hasInputTheCorrectOldToken && oldToken ? onClear : undefined}
+ >
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider errors={errors} object={form} valueHandler={setValue}>
+ {oldToken && (
+ <Input<State>
+ name="old_token"
+ label={i18n.str`Old access token`}
+ tooltip={i18n.str`access token currently in use`}
+ inputType="password"
+ />
+ )}
+ <Input<State>
+ name="new_token"
+ label={i18n.str`New access token`}
+ tooltip={i18n.str`next access token to be used`}
+ inputType="password"
+ />
+ <Input<State>
+ name="repeat_token"
+ label={i18n.str`Repeat access token`}
+ tooltip={i18n.str`confirm the same access token`}
+ inputType="password"
+ />
+ </FormProvider>
+ <p>
+ <i18n.Translate>
+ Clearing the access token will mean public access to the instance
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="column" />
+ </div>
+ </ClearConfirmModal>
+ );
+}
+
+export function SetTokenNewInstanceModal({
+ onCancel,
+ onClear,
+ onConfirm,
+}: UpdateTokenModalProps): VNode {
+ type State = { old_token: string; new_token: string; repeat_token: string };
+ const [form, setValue] = useState<Partial<State>>({
+ new_token: "",
+ repeat_token: "",
+ });
+ const { i18n } = useTranslationContext();
+
+ const errors = {
+ new_token: !form.new_token
+ ? i18n.str`cannot be empty`
+ : form.new_token === form.old_token
+ ? i18n.str`cannot be the same as the old access token`
+ : undefined,
+ repeat_token:
+ form.new_token !== form.repeat_token
+ ? i18n.str`is not the same`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ return (
+ <div class="modal is-active">
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card">
+ <header class="modal-card-head">
+ <p class="modal-card-title">{i18n.str`You are setting the access token for the new instance`}</p>
+ <button class="delete " aria-label="close" onClick={onCancel} />
+ </header>
+ <section class="modal-card-body is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ errors={errors}
+ object={form}
+ valueHandler={setValue}
+ >
+ <Input<State>
+ name="new_token"
+ label={i18n.str`New access token`}
+ tooltip={i18n.str`next access token to be used`}
+ inputType="password"
+ />
+ <Input<State>
+ name="repeat_token"
+ label={i18n.str`Repeat access token`}
+ tooltip={i18n.str`confirm the same access token`}
+ inputType="password"
+ />
+ </FormProvider>
+ <p>
+ <i18n.Translate>
+ With external authorization method no check will be done by
+ the merchant backend
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ <footer class="modal-card-foot">
+ {onClear && (
+ <button
+ class="button is-danger"
+ onClick={onClear}
+ disabled={onClear === undefined}
+ >
+ <i18n.Translate>Set external authorization</i18n.Translate>
+ </button>
+ )}
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ <button class="button " onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ class="button is-info"
+ onClick={() => onConfirm(form.new_token!)}
+ disabled={hasErrors}
+ >
+ <i18n.Translate>Set access token</i18n.Translate>
+ </button>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
+
+export function LoadingModal({ onCancel }: { onCancel: () => void }): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="modal is-active">
+ <div class="modal-background " onClick={onCancel} />
+ <div class="modal-card">
+ <header class="modal-card-head">
+ <p class="modal-card-title">
+ <i18n.Translate>Operation in progress...</i18n.Translate>
+ </p>
+ </header>
+ <section class="modal-card-body">
+ <div class="columns">
+ <div class="column" />
+ <Spinner />
+ <div class="column" />
+ </div>
+ <p>{i18n.str`The operation will be automatically canceled after ${DEFAULT_REQUEST_TIMEOUT} seconds`}</p>
+ </section>
+ <footer class="modal-card-foot">
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ <button class="button " onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={onCancel}
+ />
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx
new file mode 100644
index 000000000..5cd8a237b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx
@@ -0,0 +1,57 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+
+interface Props {
+ onCreateAnother?: () => void;
+ onConfirm: () => void;
+ children: ComponentChildren;
+}
+
+export function CreatedSuccessfully({
+ children,
+ onConfirm,
+ onCreateAnother,
+}: Props): VNode {
+ return (
+ <div class="columns is-fullwidth is-vcentered mt-3">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <div class="card">
+ <header class="card-header has-background-success">
+ <p class="card-header-title has-text-white-ter">Success.</p>
+ </header>
+ <div class="card-content">{children}</div>
+ </div>
+ <div class="buttons is-right">
+ {onCreateAnother && (
+ <button class="button is-info" onClick={onCreateAnother}>
+ Create another
+ </button>
+ )}
+ <button class="button is-info" onClick={onConfirm}>
+ Continue
+ </button>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/notifications/Notifications.stories.tsx b/packages/merchant-backoffice-ui/src/components/notifications/Notifications.stories.tsx
new file mode 100644
index 000000000..d75c5ced2
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/notifications/Notifications.stories.tsx
@@ -0,0 +1,62 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h } from "preact";
+import { Notifications } from "./index.js";
+
+export default {
+ title: "Components/Notification",
+ component: Notifications,
+ argTypes: {
+ removeNotification: { action: "removeNotification" },
+ },
+};
+
+export const Info = (a: any) => <Notifications {...a} />;
+Info.args = {
+ notifications: [
+ {
+ message: "Title",
+ description: "Some large description",
+ type: "INFO",
+ },
+ ],
+};
+export const Warn = (a: any) => <Notifications {...a} />;
+Warn.args = {
+ notifications: [
+ {
+ message: "Title",
+ description: "Some large description",
+ type: "WARN",
+ },
+ ],
+};
+export const Error = (a: any) => <Notifications {...a} />;
+Error.args = {
+ notifications: [
+ {
+ message: "Title",
+ description: "Some large description",
+ type: "ERROR",
+ },
+ ],
+};
diff --git a/packages/merchant-backoffice-ui/src/components/notifications/index.tsx b/packages/merchant-backoffice-ui/src/components/notifications/index.tsx
new file mode 100644
index 000000000..0c4e0d761
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/notifications/index.tsx
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { MessageType, Notification } from "../../utils/types.js";
+
+interface Props {
+ notifications: Notification[];
+ removeNotification?: (n: Notification) => void;
+}
+
+function messageStyle(type: MessageType): string {
+ switch (type) {
+ case "INFO":
+ return "message is-info";
+ case "WARN":
+ return "message is-warning";
+ case "ERROR":
+ return "message is-danger";
+ case "SUCCESS":
+ return "message is-success";
+ default:
+ return "message";
+ }
+}
+
+export function Notifications({
+ notifications,
+ removeNotification,
+}: Props): VNode {
+ return (
+ <div class="toast">
+ {notifications.map((n, i) => (
+ <article key={i} class={messageStyle(n.type)}>
+ <div class="message-header">
+ <p>{n.message}</p>
+ <button
+ class="delete"
+ onClick={() => removeNotification && removeNotification(n)}
+ />
+ </div>
+ {n.description && <div class="message-body">{n.description}</div>}
+ </article>
+ ))}
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/picker/DatePicker.tsx b/packages/merchant-backoffice-ui/src/components/picker/DatePicker.tsx
new file mode 100644
index 000000000..6dc1fadd6
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/picker/DatePicker.tsx
@@ -0,0 +1,349 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Component, h } from "preact";
+
+interface Props {
+ closeFunction?: () => void;
+ dateReceiver?: (d: Date) => void;
+ opened?: boolean;
+}
+interface State {
+ displayedMonth: number;
+ displayedYear: number;
+ selectYearMode: boolean;
+ currentDate: Date;
+}
+
+// inspired by https://codepen.io/m4r1vs/pen/MOOxyE
+export class DatePicker extends Component<Props, State> {
+ closeDatePicker() {
+ this.props.closeFunction && this.props.closeFunction(); // Function gets passed by parent
+ }
+
+ /**
+ * Gets fired when a day gets clicked.
+ * @param {object} e The event thrown by the <span /> element clicked
+ */
+ dayClicked(e: any) {
+ const element = e.target; // the actual element clicked
+
+ if (element.innerHTML === "") return false; // don't continue if <span /> empty
+
+ // get date from clicked element (gets attached when rendered)
+ const date = new Date(element.getAttribute("data-value"));
+
+ // update the state
+ this.setState({ currentDate: date });
+ this.passDateToParent(date);
+ }
+
+ /**
+ * returns days in month as array
+ * @param {number} month the month to display
+ * @param {number} year the year to display
+ */
+ getDaysByMonth(month: number, year: number) {
+ const calendar = [];
+
+ // const date = new Date(year, month, 1); // month to display
+
+ const firstDay = new Date(year, month, 1).getDay(); // first weekday of month
+ const lastDate = new Date(year, month + 1, 0).getDate(); // last date of month
+
+ let day: number | null = 0;
+
+ // the calendar is 7*6 fields big, so 42 loops
+ for (let i = 0; i < 42; i++) {
+ if (i >= firstDay && day !== null) day = day + 1;
+ if (day !== null && day > lastDate) day = null;
+
+ // append the calendar Array
+ calendar.push({
+ day: day === 0 || day === null ? null : day, // null or number
+ date: day === 0 || day === null ? null : new Date(year, month, day), // null or Date()
+ today:
+ day === now.getDate() &&
+ month === now.getMonth() &&
+ year === now.getFullYear(), // boolean
+ });
+ }
+
+ return calendar;
+ }
+
+ /**
+ * Display previous month by updating state
+ */
+ displayPrevMonth() {
+ if (this.state.displayedMonth <= 0) {
+ this.setState({
+ displayedMonth: 11,
+ displayedYear: this.state.displayedYear - 1,
+ });
+ } else {
+ this.setState({
+ displayedMonth: this.state.displayedMonth - 1,
+ });
+ }
+ }
+
+ /**
+ * Display next month by updating state
+ */
+ displayNextMonth() {
+ if (this.state.displayedMonth >= 11) {
+ this.setState({
+ displayedMonth: 0,
+ displayedYear: this.state.displayedYear + 1,
+ });
+ } else {
+ this.setState({
+ displayedMonth: this.state.displayedMonth + 1,
+ });
+ }
+ }
+
+ /**
+ * Display the selected month (gets fired when clicking on the date string)
+ */
+ displaySelectedMonth() {
+ if (this.state.selectYearMode) {
+ this.toggleYearSelector();
+ } else {
+ if (!this.state.currentDate) return false;
+ this.setState({
+ displayedMonth: this.state.currentDate.getMonth(),
+ displayedYear: this.state.currentDate.getFullYear(),
+ });
+ }
+ }
+
+ toggleYearSelector() {
+ this.setState({ selectYearMode: !this.state.selectYearMode });
+ }
+
+ changeDisplayedYear(e: any) {
+ const element = e.target;
+ this.toggleYearSelector();
+ this.setState({
+ displayedYear: parseInt(element.innerHTML, 10),
+ displayedMonth: 0,
+ });
+ }
+
+ /**
+ * Pass the selected date to parent when 'OK' is clicked
+ */
+ passSavedDateDateToParent() {
+ this.passDateToParent(this.state.currentDate);
+ }
+ passDateToParent(date: Date) {
+ if (typeof this.props.dateReceiver === "function")
+ this.props.dateReceiver(date);
+ this.closeDatePicker();
+ }
+
+ componentDidUpdate() {
+ if (this.state.selectYearMode) {
+ document.getElementsByClassName("selected")[0].scrollIntoView(); // works in every browser incl. IE, replace with scrollIntoViewIfNeeded when browsers support it
+ }
+ }
+
+ constructor() {
+ super();
+
+ this.closeDatePicker = this.closeDatePicker.bind(this);
+ this.dayClicked = this.dayClicked.bind(this);
+ this.displayNextMonth = this.displayNextMonth.bind(this);
+ this.displayPrevMonth = this.displayPrevMonth.bind(this);
+ this.getDaysByMonth = this.getDaysByMonth.bind(this);
+ this.changeDisplayedYear = this.changeDisplayedYear.bind(this);
+ this.passDateToParent = this.passDateToParent.bind(this);
+ this.toggleYearSelector = this.toggleYearSelector.bind(this);
+ this.displaySelectedMonth = this.displaySelectedMonth.bind(this);
+
+ this.state = {
+ currentDate: now,
+ displayedMonth: now.getMonth(),
+ displayedYear: now.getFullYear(),
+ selectYearMode: false,
+ };
+ }
+
+ render() {
+ const { currentDate, displayedMonth, displayedYear, selectYearMode } =
+ this.state;
+
+ return (
+ <div>
+ <div class={`datePicker ${this.props.opened && "datePicker--opened"}`}>
+ <div class="datePicker--titles">
+ <h3
+ style={{
+ color: selectYearMode
+ ? "rgba(255,255,255,.87)"
+ : "rgba(255,255,255,.57)",
+ }}
+ onClick={this.toggleYearSelector}
+ >
+ {currentDate.getFullYear()}
+ </h3>
+ <h2
+ style={{
+ color: !selectYearMode
+ ? "rgba(255,255,255,.87)"
+ : "rgba(255,255,255,.57)",
+ }}
+ onClick={this.displaySelectedMonth}
+ >
+ {dayArr[currentDate.getDay()]},{" "}
+ {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()}
+ </h2>
+ </div>
+
+ {!selectYearMode && (
+ <nav>
+ <span onClick={this.displayPrevMonth} class="icon">
+ <i
+ style={{ transform: "rotate(180deg)" }}
+ class="mdi mdi-forward"
+ />
+ </span>
+ <h4>
+ {monthArrShortFull[displayedMonth]} {displayedYear}
+ </h4>
+ <span onClick={this.displayNextMonth} class="icon">
+ <i class="mdi mdi-forward" />
+ </span>
+ </nav>
+ )}
+
+ <div class="datePicker--scroll">
+ {!selectYearMode && (
+ <div class="datePicker--calendar">
+ <div class="datePicker--dayNames">
+ {["S", "M", "T", "W", "T", "F", "S"].map((day, i) => (
+ <span key={i}>{day}</span>
+ ))}
+ </div>
+
+ <div onClick={this.dayClicked} class="datePicker--days">
+ {/*
+ Loop through the calendar object returned by getDaysByMonth().
+ */}
+
+ {this.getDaysByMonth(
+ this.state.displayedMonth,
+ this.state.displayedYear,
+ ).map((day) => {
+ let selected = false;
+
+ if (currentDate && day.date)
+ selected =
+ currentDate.toLocaleDateString() ===
+ day.date.toLocaleDateString();
+
+ return (
+ <span
+ key={day.day}
+ class={
+ (day.today ? "datePicker--today " : "") +
+ (selected ? "datePicker--selected" : "")
+ }
+ disabled={!day.date}
+ data-value={day.date}
+ >
+ {day.day}
+ </span>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {selectYearMode && (
+ <div class="datePicker--selectYear">
+ {yearArr.map((year) => (
+ <span
+ key={year}
+ class={year === displayedYear ? "selected" : ""}
+ onClick={this.changeDisplayedYear}
+ >
+ {year}
+ </span>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div
+ class="datePicker--background"
+ onClick={this.closeDatePicker}
+ style={{
+ display: this.props.opened ? "block" : "none",
+ }}
+ />
+ </div>
+ );
+ }
+}
+
+const monthArrShortFull = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+];
+
+const monthArrShort = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+];
+
+const dayArr = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+const now = new Date();
+
+const yearArr: number[] = [];
+
+for (let i = 2010; i <= now.getFullYear() + 10; i++) {
+ yearArr.push(i);
+}
diff --git a/packages/merchant-backoffice-ui/src/components/picker/DurationPicker.stories.tsx b/packages/merchant-backoffice-ui/src/components/picker/DurationPicker.stories.tsx
new file mode 100644
index 000000000..b95ab054c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/picker/DurationPicker.stories.tsx
@@ -0,0 +1,55 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, FunctionalComponent } from "preact";
+import { useState } from "preact/hooks";
+import { DurationPicker as TestedComponent } from "./DurationPicker.js";
+
+export default {
+ title: "Components/Picker/Duration",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ goBack: { action: "goBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ days: true,
+ minutes: true,
+ hours: true,
+ seconds: true,
+ value: 10000000,
+});
+
+export const WithState = () => {
+ const [v, s] = useState<number>(1000000);
+ return <TestedComponent value={v} onChange={s} days minutes hours seconds />;
+};
diff --git a/packages/merchant-backoffice-ui/src/components/picker/DurationPicker.tsx b/packages/merchant-backoffice-ui/src/components/picker/DurationPicker.tsx
new file mode 100644
index 000000000..7c1c172ac
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/picker/DurationPicker.tsx
@@ -0,0 +1,211 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import "../../scss/DurationPicker.scss";
+
+export interface Props {
+ hours?: boolean;
+ minutes?: boolean;
+ seconds?: boolean;
+ days?: boolean;
+ onChange: (value: number) => void;
+ value: number;
+}
+
+// inspiration taken from https://github.com/flurmbo/react-duration-picker
+export function DurationPicker({
+ days,
+ hours,
+ minutes,
+ seconds,
+ onChange,
+ value,
+}: Props): VNode {
+ const ss = 1000;
+ const ms = ss * 60;
+ const hs = ms * 60;
+ const ds = hs * 24;
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="rdp-picker">
+ {days && (
+ <DurationColumn
+ unit={i18n.str`days`}
+ max={99}
+ value={Math.floor(value / ds)}
+ onDecrease={value >= ds ? () => onChange(value - ds) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + ds) : undefined}
+ onChange={(diff) => onChange(value + diff * ds)}
+ />
+ )}
+ {hours && (
+ <DurationColumn
+ unit={i18n.str`hours`}
+ max={23}
+ min={1}
+ value={Math.floor(value / hs) % 24}
+ onDecrease={value >= hs ? () => onChange(value - hs) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + hs) : undefined}
+ onChange={(diff) => onChange(value + diff * hs)}
+ />
+ )}
+ {minutes && (
+ <DurationColumn
+ unit={i18n.str`minutes`}
+ max={59}
+ min={1}
+ value={Math.floor(value / ms) % 60}
+ onDecrease={value >= ms ? () => onChange(value - ms) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + ms) : undefined}
+ onChange={(diff) => onChange(value + diff * ms)}
+ />
+ )}
+ {seconds && (
+ <DurationColumn
+ unit={i18n.str`seconds`}
+ max={59}
+ value={Math.floor(value / ss) % 60}
+ onDecrease={value >= ss ? () => onChange(value - ss) : undefined}
+ onIncrease={value < 99 * ds ? () => onChange(value + ss) : undefined}
+ onChange={(diff) => onChange(value + diff * ss)}
+ />
+ )}
+ </div>
+ );
+}
+
+interface ColProps {
+ unit: string;
+ min?: number;
+ max: number;
+ value: number;
+ onIncrease?: () => void;
+ onDecrease?: () => void;
+ onChange?: (diff: number) => void;
+}
+
+function InputNumber({
+ initial,
+ onChange,
+}: {
+ initial: number;
+ onChange: (n: number) => void;
+}) {
+ const [value, handler] = useState<{ v: string }>({
+ v: toTwoDigitString(initial),
+ });
+
+ return (
+ <input
+ value={value.v}
+ onBlur={(e) => onChange(parseInt(value.v, 10))}
+ onInput={(e) => {
+ e.preventDefault();
+ const n = Number.parseInt(e.currentTarget.value, 10);
+ if (isNaN(n)) return handler({ v: toTwoDigitString(initial) });
+ return handler({ v: toTwoDigitString(n) });
+ }}
+ style={{
+ width: 50,
+ border: "none",
+ fontSize: "inherit",
+ background: "inherit",
+ }}
+ />
+ );
+}
+
+function DurationColumn({
+ unit,
+ min = 0,
+ max,
+ value,
+ onIncrease,
+ onDecrease,
+ onChange,
+}: ColProps): VNode {
+ const cellHeight = 35;
+ return (
+ <div class="rdp-column-container">
+ <div class="rdp-masked-div">
+ <hr class="rdp-reticule" style={{ top: cellHeight * 2 - 1 }} />
+ <hr class="rdp-reticule" style={{ top: cellHeight * 3 - 1 }} />
+
+ <div class="rdp-column" style={{ top: 0 }}>
+ <div class="rdp-cell" key={value - 2}>
+ {onDecrease && (
+ <button
+ style={{ width: "100%", textAlign: "center", margin: 5 }}
+ onClick={onDecrease}
+ >
+ <span class="icon">
+ <i class="mdi mdi-chevron-up" />
+ </span>
+ </button>
+ )}
+ </div>
+ <div class="rdp-cell" key={value - 1}>
+ {value > min ? toTwoDigitString(value - 1) : ""}
+ </div>
+ <div class="rdp-cell rdp-center" key={value}>
+ {onChange ? (
+ <InputNumber
+ initial={value}
+ onChange={(n) => onChange(n - value)}
+ />
+ ) : (
+ toTwoDigitString(value)
+ )}
+ <div>{unit}</div>
+ </div>
+
+ <div class="rdp-cell" key={value + 1}>
+ {value < max ? toTwoDigitString(value + 1) : ""}
+ </div>
+
+ <div class="rdp-cell" key={value + 2}>
+ {onIncrease && (
+ <button
+ style={{ width: "100%", textAlign: "center", margin: 5 }}
+ onClick={onIncrease}
+ >
+ <span class="icon">
+ <i class="mdi mdi-chevron-down" />
+ </span>
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function toTwoDigitString(n: number) {
+ if (n < 10) {
+ return `0${n}`;
+ }
+ return `${n}`;
+}
diff --git a/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx
new file mode 100644
index 000000000..fcc97f96a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx
@@ -0,0 +1,62 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { InventoryProductForm as TestedComponent } from "./InventoryProductForm.js";
+
+export default {
+ title: "Components/Product/Add",
+ component: TestedComponent,
+ argTypes: {
+ onAddProduct: { action: "onAddProduct" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const WithASimpleList = createExample(TestedComponent, {
+ inventory: [
+ {
+ id: "this id",
+ description: "this is the description",
+ } as any,
+ ],
+});
+
+export const WithAProductSelected = createExample(TestedComponent, {
+ inventory: [],
+ currentProducts: {
+ thisid: {
+ quantity: 1,
+ product: {
+ id: "asd",
+ description: "asdsadsad",
+ } as any,
+ },
+ },
+});
diff --git a/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx
new file mode 100644
index 000000000..52ac2a1fe
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx
@@ -0,0 +1,127 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { ProductMap } from "../../paths/instance/orders/create/CreatePage.js";
+import { FormErrors, FormProvider } from "../form/FormProvider.js";
+import { InputNumber } from "../form/InputNumber.js";
+import { InputSearchOnList } from "../form/InputSearchOnList.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+type Form = {
+ product: TalerMerchantApi.ProductDetail & WithId;
+ quantity: number;
+};
+
+interface Props {
+ currentProducts: ProductMap;
+ onAddProduct: (
+ product: TalerMerchantApi.ProductDetail & WithId,
+ quantity: number,
+ ) => void;
+ inventory: (TalerMerchantApi.ProductDetail & WithId)[];
+}
+
+export function InventoryProductForm({
+ currentProducts,
+ onAddProduct,
+ inventory,
+}: Props): VNode {
+ const initialState = { quantity: 1 };
+ const [state, setState] = useState<Partial<Form>>(initialState);
+ const [errors, setErrors] = useState<FormErrors<Form>>({});
+
+ const { i18n } = useTranslationContext();
+
+ const productWithInfiniteStock =
+ state.product && state.product.total_stock === -1;
+
+ const submit = (): void => {
+ if (!state.product) {
+ setErrors({
+ product: i18n.str`You must enter a valid product identifier.`,
+ });
+ return;
+ }
+ if (productWithInfiniteStock) {
+ onAddProduct(state.product, 1);
+ } else {
+ if (!state.quantity || state.quantity <= 0) {
+ setErrors({ quantity: i18n.str`Quantity must be greater than 0!` });
+ return;
+ }
+ const currentStock =
+ state.product.total_stock -
+ state.product.total_lost -
+ state.product.total_sold;
+ const p = currentProducts[state.product.id];
+ if (p) {
+ if (state.quantity + p.quantity > currentStock) {
+ const left = currentStock - p.quantity;
+ setErrors({
+ quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`,
+ });
+ return;
+ }
+ onAddProduct(state.product, state.quantity + p.quantity);
+ } else {
+ if (state.quantity > currentStock) {
+ const left = currentStock;
+ setErrors({
+ quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`,
+ });
+ return;
+ }
+ onAddProduct(state.product, state.quantity);
+ }
+ }
+
+ setState(initialState);
+ };
+
+ return (
+ <FormProvider<Form> errors={errors} object={state} valueHandler={setState}>
+ <InputSearchOnList
+ label={i18n.str`Search product`}
+ selected={state.product}
+ onChange={(p) => setState((v) => ({ ...v, product: p }))}
+ list={inventory}
+ withImage
+ />
+ {state.product && (
+ <div class="columns mt-5">
+ <div class="column is-two-thirds">
+ {!productWithInfiniteStock && (
+ <InputNumber<Form>
+ name="quantity"
+ label={i18n.str`Quantity`}
+ tooltip={i18n.str`how many products will be added`}
+ />
+ )}
+ </div>
+ <div class="column">
+ <div class="buttons is-right">
+ <button class="button is-success" onClick={submit}>
+ <i18n.Translate>Add from inventory</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+ </FormProvider>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/product/NonInventoryProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/NonInventoryProductForm.tsx
new file mode 100644
index 000000000..a127999fc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/product/NonInventoryProductForm.tsx
@@ -0,0 +1,215 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { AmountString, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useCallback, useEffect, useState } from "preact/hooks";
+import * as yup from "yup";
+import { useListener } from "../../hooks/listener.js";
+import { NonInventoryProductSchema as schema } from "../../schemas/index.js";
+import { FormErrors, FormProvider } from "../form/FormProvider.js";
+import { Input } from "../form/Input.js";
+import { InputCurrency } from "../form/InputCurrency.js";
+import { InputImage } from "../form/InputImage.js";
+import { InputNumber } from "../form/InputNumber.js";
+import { InputTaxes } from "../form/InputTaxes.js";
+
+type Entity = TalerMerchantApi.Product;
+
+interface Props {
+ onAddProduct: (p: Entity) => Promise<void>;
+ productToEdit?: Entity;
+}
+export function NonInventoryProductFrom({
+ productToEdit,
+ onAddProduct,
+}: Props): VNode {
+ const [showCreateProduct, setShowCreateProduct] = useState(false);
+
+ const isEditing = !!productToEdit;
+
+ useEffect(() => {
+ setShowCreateProduct(isEditing);
+ }, [isEditing]);
+
+ const [submitForm, addFormSubmitter] = useListener<
+ Partial<TalerMerchantApi.Product> | undefined
+ >((result) => {
+ if (result) {
+ setShowCreateProduct(false);
+ return onAddProduct({
+ quantity: result.quantity || 0,
+ taxes: result.taxes || [],
+ description: result.description || "",
+ image: result.image || "",
+ price: (result.price || "") as AmountString,
+ unit: result.unit || "",
+ });
+ }
+ return Promise.resolve();
+ });
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <div class="buttons">
+ <button
+ class="button is-success"
+ data-tooltip={i18n.str`describe and add a product that is not in the inventory list`}
+ onClick={() => setShowCreateProduct(true)}
+ >
+ <i18n.Translate>Add custom product</i18n.Translate>
+ </button>
+ </div>
+ {showCreateProduct && (
+ <div class="modal is-active">
+ <div
+ class="modal-background "
+ onClick={() => setShowCreateProduct(false)}
+ />
+ <div class="modal-card">
+ <header class="modal-card-head">
+ <p class="modal-card-title">{i18n.str`Complete information of the product`}</p>
+ <button
+ class="delete "
+ aria-label="close"
+ onClick={() => setShowCreateProduct(false)}
+ />
+ </header>
+ <section class="modal-card-body">
+ <ProductForm
+ initial={productToEdit}
+ onSubscribe={addFormSubmitter}
+ />
+ </section>
+ <footer class="modal-card-foot">
+ <div class="buttons is-right" style={{ width: "100%" }}>
+ <button
+ class="button "
+ onClick={() => setShowCreateProduct(false)}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ class="button is-info "
+ disabled={!submitForm}
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ </footer>
+ </div>
+ <button
+ class="modal-close is-large "
+ aria-label="close"
+ onClick={() => setShowCreateProduct(false)}
+ />
+ </div>
+ )}
+ </Fragment>
+ );
+}
+
+interface ProductProps {
+ onSubscribe: (c?: () => Entity | undefined) => void;
+ initial?: Partial<Entity>;
+}
+
+interface NonInventoryProduct {
+ quantity: number;
+ description: string;
+ unit: string;
+ price: string;
+ image: string;
+ taxes: TalerMerchantApi.Tax[];
+}
+
+export function ProductForm({ onSubscribe, initial }: ProductProps): VNode {
+ const [value, valueHandler] = useState<Partial<NonInventoryProduct>>({
+ taxes: [],
+ ...initial,
+ });
+ let errors: FormErrors<NonInventoryProduct> = {};
+ try {
+ schema.validateSync(value, { abortEarly: false });
+ } catch (err) {
+ if (err instanceof yup.ValidationError) {
+ const yupErrors = err.inner as yup.ValidationError[];
+ errors = yupErrors.reduce(
+ (prev, cur) =>
+ !cur.path ? prev : { ...prev, [cur.path]: cur.message },
+ {},
+ );
+ }
+ }
+
+ const submit = useCallback((): Entity | undefined => {
+ return value as TalerMerchantApi.Product;
+ }, [value]);
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ useEffect(() => {
+ onSubscribe(hasErrors ? undefined : submit);
+ }, [submit, hasErrors]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <FormProvider<NonInventoryProduct>
+ name="product"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <InputImage<NonInventoryProduct>
+ name="image"
+ label={i18n.str`Image`}
+ tooltip={i18n.str`photo of the product`}
+ />
+ <Input<NonInventoryProduct>
+ name="description"
+ inputType="multiline"
+ label={i18n.str`Description`}
+ tooltip={i18n.str`full product description`}
+ />
+ <Input<NonInventoryProduct>
+ name="unit"
+ label={i18n.str`Unit`}
+ tooltip={i18n.str`name of the product unit`}
+ />
+ <InputCurrency<NonInventoryProduct>
+ name="price"
+ label={i18n.str`Price`}
+ tooltip={i18n.str`amount in the current currency`}
+ />
+
+ <InputNumber<NonInventoryProduct>
+ name="quantity"
+ label={i18n.str`Quantity`}
+ tooltip={i18n.str`how many products will be added`}
+ />
+
+ <InputTaxes<NonInventoryProduct> name="taxes" label={i18n.str`Taxes`} />
+ </FormProvider>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx
new file mode 100644
index 000000000..c6d687b85
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx
@@ -0,0 +1,177 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountString, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h } from "preact";
+import { useCallback, useEffect, useState } from "preact/hooks";
+import * as yup from "yup";
+import { useSessionContext } from "../../context/session.js";
+import {
+ ProductCreateSchema as createSchema,
+ ProductUpdateSchema as updateSchema,
+} from "../../schemas/index.js";
+import { FormErrors, FormProvider } from "../form/FormProvider.js";
+import { Input } from "../form/Input.js";
+import { InputCurrency } from "../form/InputCurrency.js";
+import { InputImage } from "../form/InputImage.js";
+import { InputNumber } from "../form/InputNumber.js";
+import { InputStock, Stock } from "../form/InputStock.js";
+import { InputTaxes } from "../form/InputTaxes.js";
+import { InputWithAddon } from "../form/InputWithAddon.js";
+
+type Entity = TalerMerchantApi.ProductDetail & { product_id: string };
+
+interface Props {
+ onSubscribe: (c?: () => Entity | undefined) => void;
+ initial?: Partial<Entity>;
+ alreadyExist?: boolean;
+}
+
+export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
+ const [value, valueHandler] = useState<Partial<Entity & { stock: Stock }>>({
+ address: {},
+ description_i18n: {},
+ taxes: [],
+ next_restock: { t_s: "never" },
+ price: ":0" as AmountString,
+ ...initial,
+ stock:
+ !initial || initial.total_stock === -1
+ ? undefined
+ : {
+ current: initial.total_stock || 0,
+ lost: initial.total_lost || 0,
+ sold: initial.total_sold || 0,
+ address: initial.address,
+ nextRestock: initial.next_restock,
+ },
+ });
+ let errors: FormErrors<Entity> = {};
+
+ try {
+ (alreadyExist ? updateSchema : createSchema).validateSync(value, {
+ abortEarly: false,
+ });
+ } catch (err) {
+ if (err instanceof yup.ValidationError) {
+ const yupErrors = err.inner as yup.ValidationError[];
+ errors = yupErrors.reduce(
+ (prev, cur) =>
+ !cur.path ? prev : { ...prev, [cur.path]: cur.message },
+ {},
+ );
+ }
+ }
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as Record<string, unknown>)[k] !== undefined,
+ );
+
+ const submit = useCallback((): Entity | undefined => {
+ const stock = value.stock;
+
+ if (!stock) {
+ value.total_stock = -1;
+ } else {
+ value.total_stock = stock.current;
+ value.total_lost = stock.lost;
+ value.next_restock =
+ stock.nextRestock instanceof Date
+ ? { t_s: stock.nextRestock.getTime() / 1000 }
+ : stock.nextRestock;
+ value.address = stock.address;
+ }
+ delete value.stock;
+
+ if (typeof value.minimum_age !== "undefined" && value.minimum_age < 1) {
+ delete value.minimum_age;
+ }
+
+ return value as TalerMerchantApi.ProductDetail & {
+ product_id: string;
+ };
+ }, [value]);
+
+ useEffect(() => {
+ onSubscribe(hasErrors ? undefined : submit);
+ }, [submit, hasErrors]);
+
+ const { i18n } = useTranslationContext();
+ const { state } = useSessionContext();
+ return (
+ <div>
+ <FormProvider<Entity>
+ name="product"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ {alreadyExist ? undefined : (
+ <InputWithAddon<Entity>
+ name="product_id"
+ addonBefore={new URL("product/", state.backendUrl.href).href}
+ label={i18n.str`ID`}
+ tooltip={i18n.str`product identification to use in URLs (for internal use only)`}
+ />
+ )}
+ <InputImage<Entity>
+ name="image"
+ label={i18n.str`Image`}
+ tooltip={i18n.str`illustration of the product for customers`}
+ />
+ <Input<Entity>
+ name="description"
+ inputType="multiline"
+ label={i18n.str`Description`}
+ tooltip={i18n.str`product description for customers`}
+ />
+ <InputNumber<Entity>
+ name="minimum_age"
+ label={i18n.str`Age restriction`}
+ tooltip={i18n.str`is this product restricted for customer below certain age?`}
+ help={i18n.str`minimum age of the buyer`}
+ />
+ <Input<Entity>
+ name="unit"
+ label={i18n.str`Unit name`}
+ tooltip={i18n.str`unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers`}
+ help={i18n.str`exajmple: kg, items or liters`}
+ />
+ <InputCurrency<Entity>
+ name="price"
+ label={i18n.str`Price per unit`}
+ tooltip={i18n.str`sale price for customers, including taxes, for above units of the product`}
+ />
+ <InputStock
+ name="stock"
+ label={i18n.str`Stock`}
+ alreadyExist={alreadyExist}
+ tooltip={i18n.str`inventory for products with finite supply (for internal use only)`}
+ />
+ <InputTaxes<Entity>
+ name="taxes"
+ label={i18n.str`Taxes`}
+ tooltip={i18n.str`taxes included in the product price, exposed to customers`}
+ />
+ </FormProvider>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductList.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductList.tsx
new file mode 100644
index 000000000..4fff66fd7
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/components/product/ProductList.tsx
@@ -0,0 +1,105 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { Amounts, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import emptyImage from "../../assets/empty.png";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+
+interface Props {
+ list: TalerMerchantApi.Product[];
+ actions?: {
+ name: string;
+ tooltip: string;
+ handler: (d: TalerMerchantApi.Product, index: number) => void;
+ }[];
+}
+export function ProductList({ list, actions = [] }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>image</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>description</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>quantity</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>unit price</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>total price</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {list.map((entry, index) => {
+ const unitPrice = !entry.price ? "0" : entry.price;
+ const totalPrice = !entry.price
+ ? "0"
+ : Amounts.stringify(
+ Amounts.mult(
+ Amounts.parseOrThrow(entry.price),
+ entry.quantity ?? 0
+ ).amount,
+ );
+
+ return (
+ <tr key={index}>
+ <td>
+ <img
+ style={{ height: 32, width: 32 }}
+ src={entry.image ? entry.image : emptyImage}
+ />
+ </td>
+ <td>{entry.description}</td>
+ <td>
+ {entry.quantity === 0
+ ? "--"
+ : `${entry.quantity} ${entry.unit}`}
+ </td>
+ <td>{unitPrice}</td>
+ <td>{totalPrice}</td>
+ <td class="is-actions-cell right-sticky">
+ {actions.map((a, i) => {
+ return (
+ <div key={i} class="buttons is-right">
+ <button
+ class="button is-small is-danger has-tooltip-left"
+ data-tooltip={a.tooltip}
+ type="button"
+ onClick={() => a.handler(entry, index)}
+ >
+ {a.name}
+ </button>
+ </div>
+ );
+ })}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/context/session.ts b/packages/merchant-backoffice-ui/src/context/session.ts
new file mode 100644
index 000000000..fb1b7b374
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/context/session.ts
@@ -0,0 +1,246 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AccessToken,
+ Codec,
+ TalerMerchantApi,
+ buildCodecForObject,
+ codecForString,
+ codecForURL,
+ codecOptional,
+} from "@gnu-taler/taler-util";
+import {
+ buildStorageKey,
+ useLocalStorage,
+ useMerchantApiContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, VNode, createContext, h } from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { mutate } from "swr";
+import { MerchantLib } from "../../../web-util/lib/context/activity.js";
+
+/**
+ * Has the information to reach and
+ * authenticate at the bank's backend.
+ */
+export type SessionState = LoggedIn | LoggedOut;
+
+interface LoggedIn {
+ status: "loggedIn";
+
+ // is this instance admin? usually "default" name
+ isAdmin: boolean;
+
+ // url where all the request will be made
+ // usually this is from where the SPA was loaded
+ // unless it's using impersonate feature
+ backendUrl: URL;
+
+ // instance name
+ instance: string;
+
+ // session is not the same from where it was loaded
+ impersonated: boolean;
+
+ //instane access token
+ token: AccessToken | undefined;
+}
+
+interface LoggedOut {
+ status: "loggedOut";
+ backendUrl: URL;
+ instance: string;
+ isAdmin: boolean;
+ token: AccessToken | undefined;
+}
+
+interface SavedSession {
+ backendUrl: URL;
+ token: AccessToken | undefined;
+ prevToken: AccessToken | undefined;
+}
+
+export const codecForSessionState = (): Codec<SavedSession> =>
+ buildCodecForObject<SavedSession>()
+ .property("backendUrl", codecForURL())
+ .property("token", codecOptional(codecForString() as Codec<AccessToken>))
+ .property(
+ "prevToken",
+ codecOptional(codecForString() as Codec<AccessToken>),
+ )
+ .build("SavedSession");
+
+function inferInstanceName(url: URL) {
+ const match = INSTANCE_ID_LOOKUP.exec(url.href);
+ return !match || !match[1] ? DEFAULT_ADMIN_USERNAME : match[1];
+}
+
+export const defaultState = (url: URL): SavedSession => {
+ return {
+ backendUrl: url,
+ token: undefined,
+ prevToken: undefined,
+ };
+};
+
+export interface SessionStateHandler {
+ lib: MerchantLib;
+ config: TalerMerchantApi.VersionResponse;
+
+ state: SessionState;
+ /**
+ * from every state to logout state
+ */
+ logOut(): void;
+ /**
+ * from impersonate to loggedIn
+ */
+ deImpersonate(): void;
+ /**
+ * from any to loggedIn
+ * @param info
+ */
+ logIn(token: AccessToken | undefined): void;
+ /**
+ * from loggedIn to impersonate
+ * @param info
+ */
+ impersonate(baseUrl: URL): void;
+}
+
+const SESSION_STATE_KEY = buildStorageKey(
+ "merchant-session",
+ codecForSessionState(),
+);
+
+export const DEFAULT_ADMIN_USERNAME = "default";
+
+export const INSTANCE_ID_LOOKUP = /\/instances\/([^/]*)\/?$/;
+
+export function cleanAllCache(): void {
+ mutate(() => true, undefined, { revalidate: false });
+}
+
+const Context = createContext<SessionStateHandler>(undefined!);
+
+export const useSessionContext = (): SessionStateHandler => useContext(Context);
+
+export const SessionContextProvider = ({
+ children,
+ // value,
+}: {
+ // value: MerchantUiSettings;
+ children: ComponentChildren;
+}): VNode => {
+ const {
+ lib: rootLib,
+ config: rootConfig,
+ url: merchantUrl,
+ } = useMerchantApiContext();
+ const [status, setStatus] = useState<"loggedIn" | "loggedOut">("loggedIn");
+ const [currentConfig, setCurrentConfig] =
+ useState<TalerMerchantApi.VersionResponse>();
+ const { value: state, update } = useLocalStorage(
+ SESSION_STATE_KEY,
+ defaultState(merchantUrl),
+ );
+
+ const currentInstance = inferInstanceName(state.backendUrl);
+
+ let lib: MerchantLib;
+ let config: TalerMerchantApi.VersionResponse;
+ const doingImpersonation = state.backendUrl.href !== merchantUrl.href;
+ if (doingImpersonation) {
+ /**
+ * FIXME: can't impersonate other than local instances
+ */
+ lib = rootLib.subInstanceApi(inferInstanceName(state.backendUrl));
+
+ config = currentConfig ?? rootConfig;
+ } else {
+ lib = rootLib;
+ config = rootConfig;
+ }
+
+ useEffect(() => {
+ // FIXME: handle what happen if the subinstance /config
+ // fails
+ if (!doingImpersonation) return;
+ lib.instance.getConfig().then((resp) => {
+ if (resp.type === "ok") {
+ setCurrentConfig(resp.body);
+ }
+ });
+ }, [state.backendUrl.href]);
+
+ const value: SessionStateHandler = {
+ state: {
+ backendUrl: state.backendUrl,
+ token: state.token,
+ impersonated: doingImpersonation,
+ instance: currentInstance,
+ isAdmin: currentInstance === DEFAULT_ADMIN_USERNAME,
+ status: status,
+ },
+ lib,
+ config,
+ logOut() {
+ setStatus("loggedOut");
+ update({
+ backendUrl: merchantUrl,
+ token: undefined,
+ prevToken: undefined,
+ });
+ cleanAllCache();
+ },
+ deImpersonate() {
+ cleanAllCache();
+ update({
+ backendUrl: merchantUrl,
+ token: state.prevToken,
+ prevToken: undefined,
+ });
+ setStatus("loggedIn");
+ },
+ impersonate(baseUrl) {
+ /**
+ * FIXME: can't impersonate other than local instances
+ */
+ update({
+ backendUrl: baseUrl,
+ token: undefined,
+ prevToken: state.token,
+ });
+ setStatus("loggedIn");
+ cleanAllCache();
+ },
+ logIn(token) {
+ cleanAllCache();
+ setStatus("loggedIn");
+ update({
+ backendUrl: state.backendUrl,
+ token: token,
+ prevToken: state.prevToken,
+ });
+ },
+ };
+
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/merchant-backoffice-ui/src/context/settings.ts b/packages/merchant-backoffice-ui/src/context/settings.ts
new file mode 100644
index 000000000..8bd1506d6
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/context/settings.ts
@@ -0,0 +1,44 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+import { MerchantUiSettings } from "../settings.js";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type Type = MerchantUiSettings;
+
+const initial: MerchantUiSettings = {};
+const Context = createContext<Type>(initial);
+
+export const useSettingsContext = (): Type => useContext(Context);
+
+export const SettingsProvider = ({
+ children,
+ value,
+}: {
+ value: MerchantUiSettings;
+ children: ComponentChildren;
+}): VNode => {
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/merchant-backoffice-ui/src/custom.d.ts b/packages/merchant-backoffice-ui/src/custom.d.ts
new file mode 100644
index 000000000..34522a2dd
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/custom.d.ts
@@ -0,0 +1,42 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+declare module "*.po" {
+ const content: any;
+ export default content;
+}
+declare module "jed" {
+ const x: any;
+ export = x;
+}
+declare module "*.jpeg" {
+ const content: any;
+ export default content;
+}
+declare module "*.png" {
+ const content: any;
+ export default content;
+}
+declare module "*.svg" {
+ const content: any;
+ export default content;
+}
+
+declare module "*.scss" {
+ const content: Record<string, string>;
+ export default content;
+}
+declare const __VERSION__: string;
+declare const __GIT_HASH__: string;
diff --git a/packages/anastasis-webui/.storybook/.babelrc b/packages/merchant-backoffice-ui/src/declaration.d.ts
index 610b6f339..1baf80ba6 100644
--- a/packages/anastasis-webui/.storybook/.babelrc
+++ b/packages/merchant-backoffice-ui/src/declaration.d.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2021-2024 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,12 +14,11 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- /**
+/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
-{
- "presets": [
- "preact-cli/babel"
- ]
-} \ No newline at end of file
+
+interface WithId {
+ id: string;
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/async.ts b/packages/merchant-backoffice-ui/src/hooks/async.ts
new file mode 100644
index 000000000..212ef2211
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/async.ts
@@ -0,0 +1,77 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { useState } from "preact/hooks";
+
+export interface Options {
+ slowTolerance: number;
+}
+
+export interface AsyncOperationApi<T> {
+ request: (...a: any) => void;
+ cancel: () => void;
+ data: T | undefined;
+ isSlow: boolean;
+ isLoading: boolean;
+ error: string | undefined;
+}
+
+export function useAsync<T>(
+ fn?: (...args: any) => Promise<T>,
+ { slowTolerance: tooLong }: Options = { slowTolerance: 1000 },
+): AsyncOperationApi<T> {
+ const [data, setData] = useState<T | undefined>(undefined);
+ const [isLoading, setLoading] = useState<boolean>(false);
+ const [error, setError] = useState<any>(undefined);
+ const [isSlow, setSlow] = useState(false);
+
+ const request = async (...args: any) => {
+ if (!fn) return;
+ setLoading(true);
+
+ const handler = setTimeout(() => {
+ setSlow(true);
+ }, tooLong);
+
+ try {
+ const result = await fn(...args);
+ setData(result);
+ } catch (error) {
+ setError(error);
+ }
+ setLoading(false);
+ setSlow(false);
+ clearTimeout(handler);
+ };
+
+ function cancel(): void {
+ setLoading(false);
+ setSlow(false);
+ }
+
+ return {
+ request,
+ cancel,
+ data,
+ isSlow,
+ isLoading,
+ error,
+ };
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/bank.ts b/packages/merchant-backoffice-ui/src/hooks/bank.ts
new file mode 100644
index 000000000..8857ad839
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/bank.ts
@@ -0,0 +1,86 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ useMerchantApiContext
+} from "@gnu-taler/web-util/browser";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useSessionContext } from "../context/session.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface InstanceBankAccountFilter {
+}
+
+export function revalidateInstanceBankAccounts() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "listBankAccounts",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceBankAccounts() {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ // const [offset, setOffset] = useState<string | undefined>();
+
+ async function fetcher([token, _bid]: [AccessToken, string]) {
+ return await instance.listBankAccounts(token, {
+ // limit: PAGINATED_LIST_REQUEST,
+ // offset: bid,
+ // order: "dec",
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"listBankAccounts">,
+ TalerHttpError
+ >([session.token, "offset", "listBankAccounts"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ // return buildPaginatedResult(data.body.accounts, offset, setOffset, (d) => d.h_wire)
+ return data;
+}
+
+export function revalidateBankAccountDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getBankAccountDetails",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useBankAccountDetails(h_wire: string) {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ async function fetcher([token, wireId]: [AccessToken, string]) {
+ return await instance.getBankAccountDetails(token, wireId);
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getBankAccountDetails">,
+ TalerHttpError
+ >([session.token, h_wire, "getBankAccountDetails"], fetcher);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.test.ts b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts
new file mode 100644
index 000000000..f409592b0
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/instance.test.ts
@@ -0,0 +1,741 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useMerchantApiContext } from "@gnu-taler/web-util/browser";
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import {
+ useBackendInstances,
+ useInstanceDetails,
+} from "./instance.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_CREATE_INSTANCE,
+ API_DELETE_INSTANCE,
+ API_GET_CURRENT_INSTANCE,
+ API_LIST_INSTANCES,
+ API_UPDATE_CURRENT_INSTANCE,
+ API_UPDATE_CURRENT_INSTANCE_AUTH,
+ API_UPDATE_INSTANCE_BY_ID,
+} from "./urls.js";
+
+describe("instance api interaction with details", () => {
+ it("should evict cache when updating an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ } as TalerMerchantApi.QueryInstancesResponse,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ // const api = useInstanceAPI();
+ const { lib: api } = useMerchantApiContext()
+ const query = useInstanceDetails();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // name: "instance_name",
+ // });
+ env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE, {
+ request: {
+ name: "other_name",
+ } as TalerMerchantApi.InstanceReconfigurationMessage,
+ });
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "other_name",
+ } as TalerMerchantApi.QueryInstancesResponse,
+ });
+ api.instance.updateCurrentInstance(undefined, {
+ name: "other_name",
+ } as TalerMerchantApi.InstanceReconfigurationMessage);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // name: "other_name",
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when setting the instance's token", async () => {
+ const env = new ApiMockEnvironment();
+
+ // env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ // response: {
+ // name: "instance_name",
+ // auth: {
+ // method: "token",
+ // // token: "not-secret",
+ // },
+ // } as TalerMerchantApi.QueryInstancesResponse,
+ // });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const { lib: api } = useMerchantApiContext()
+ const query = useInstanceDetails();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // name: "instance_name",
+ // auth: {
+ // method: "token",
+ // },
+ // });
+ env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, {
+ request: {
+ method: "token",
+ token: "secret",
+ } as TalerMerchantApi.InstanceAuthConfigurationMessage,
+ });
+ // env.addRequestExpectation(API_NEW_LOGIN, {
+ // auth: "secret",
+ // request: {
+ // scope: "write",
+ // duration: {
+ // "d_us": "forever",
+ // },
+ // refreshable: true,
+ // },
+ // });
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "secret",
+ },
+ } as TalerMerchantApi.QueryInstancesResponse,
+ });
+ // api.setNewAccessToken(undefined, "secret" as AccessToken);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // name: "instance_name",
+ // auth: {
+ // method: "token",
+ // // token: "secret",
+ // },
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when clearing the instance's token", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "token",
+ // token: "not-secret",
+ },
+ } as TalerMerchantApi.QueryInstancesResponse,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const { lib: api } = useMerchantApiContext()
+ const query = useInstanceDetails();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // name: "instance_name",
+ // auth: {
+ // method: "token",
+ // // token: "not-secret",
+ // },
+ // });
+ env.addRequestExpectation(API_UPDATE_CURRENT_INSTANCE_AUTH, {
+ request: {
+ method: "external",
+ } as TalerMerchantApi.InstanceAuthConfigurationMessage,
+ });
+ env.addRequestExpectation(API_GET_CURRENT_INSTANCE, {
+ response: {
+ name: "instance_name",
+ auth: {
+ method: "external",
+ },
+ } as TalerMerchantApi.QueryInstancesResponse,
+ });
+
+ api.instance.updateCurrentInstanceAuthentication(undefined, {
+ method: "external"
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // name: "instance_name",
+ // auth: {
+ // method: "external",
+ // },
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ // const { result, waitForNextUpdate } = renderHook(
+ // () => {
+ // const api = useInstanceAPI();
+ // const query = useInstanceDetails();
+
+ // return { query, api };
+ // },
+ // { wrapper: TestingContext }
+ // );
+
+ // expect(result.current).not.undefined;
+ // if (!result.current) {
+ // return;
+ // }
+ // expect(result.current.query.loading).true;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+
+ // expect(result.current?.query.ok).true;
+ // if (!result.current?.query.ok) return;
+
+ // expect(result.current.query.data).equals({
+ // name: 'instance_name',
+ // auth: {
+ // method: 'token',
+ // token: 'not-secret',
+ // }
+ // });
+
+ // act(async () => {
+ // await result.current?.api.clearToken();
+ // });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+ // expect(result.current.query.ok).true;
+
+ // expect(result.current.query.data).equals({
+ // name: 'instance_name',
+ // auth: {
+ // method: 'external',
+ // }
+ // });
+ });
+});
+
+describe("instance admin api interaction with listing", () => {
+ it("should evict cache when creating a new instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ name: "instance_name",
+ } as TalerMerchantApi.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const { lib: api } = useMerchantApiContext()
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // instances: [
+ // {
+ // name: "instance_name",
+ // },
+ // ],
+ // });
+
+ env.addRequestExpectation(API_CREATE_INSTANCE, {
+ request: {
+ name: "other_name",
+ } as TalerMerchantApi.InstanceConfigurationMessage,
+ });
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ name: "instance_name",
+ } as TalerMerchantApi.Instance,
+ {
+ name: "other_name",
+ } as TalerMerchantApi.Instance,
+ ],
+ },
+ });
+
+ api.instance.createInstance(undefined, {
+ name: "other_name",
+ } as TalerMerchantApi.InstanceConfigurationMessage)
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // instances: [
+ // {
+ // name: "instance_name",
+ // },
+ // {
+ // name: "other_name",
+ // },
+ // ],
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as TalerMerchantApi.Instance,
+ {
+ id: "the_id",
+ name: "second_instance",
+ } as TalerMerchantApi.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const { lib: api } = useMerchantApiContext()
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // instances: [
+ // {
+ // id: "default",
+ // name: "instance_name",
+ // },
+ // {
+ // id: "the_id",
+ // name: "second_instance",
+ // },
+ // ],
+ // });
+
+ env.addRequestExpectation(API_DELETE_INSTANCE("the_id"), {});
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as TalerMerchantApi.Instance,
+ ],
+ },
+ });
+
+ api.instance.deleteInstance(undefined, "the_id");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // instances: [
+ // {
+ // id: "default",
+ // name: "instance_name",
+ // },
+ // ],
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // const { result, waitForNextUpdate } = renderHook(
+ // () => {
+ // const api = useAdminAPI();
+ // const query = useBackendInstances();
+
+ // return { query, api };
+ // },
+ // { wrapper: TestingContext }
+ // );
+
+ // expect(result.current).not.undefined;
+ // if (!result.current) {
+ // return;
+ // }
+ // expect(result.current.query.loading).true;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+
+ // expect(result.current?.query.ok).true;
+ // if (!result.current?.query.ok) return;
+
+ // expect(result.current.query.data).equals({
+ // instances: [{
+ // id: 'default',
+ // name: 'instance_name'
+ // }, {
+ // id: 'the_id',
+ // name: 'second_instance'
+ // }]
+ // });
+
+ // env.addRequestExpectation(API_DELETE_INSTANCE('the_id'), {});
+
+ // act(async () => {
+ // await result.current?.api.deleteInstance('the_id');
+ // });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // env.addRequestExpectation(API_LIST_INSTANCES, {
+ // response: {
+ // instances: [{
+ // id: 'default',
+ // name: 'instance_name'
+ // } as TalerMerchantApi.Instance]
+ // },
+ // });
+
+ // expect(result.current.query.loading).false;
+
+ // await waitForNextUpdate({ timeout: 1 });
+
+ // expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+
+ // expect(result.current.query.loading).false;
+ // expect(result.current.query.ok).true;
+
+ // expect(result.current.query.data).equals({
+ // instances: [{
+ // id: 'default',
+ // name: 'instance_name'
+ // }]
+ // });
+ });
+
+ it("should evict cache when deleting (purge) an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as TalerMerchantApi.Instance,
+ {
+ id: "the_id",
+ name: "second_instance",
+ } as TalerMerchantApi.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const { lib: api } = useMerchantApiContext()
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // instances: [
+ // {
+ // id: "default",
+ // name: "instance_name",
+ // },
+ // {
+ // id: "the_id",
+ // name: "second_instance",
+ // },
+ // ],
+ // });
+
+ env.addRequestExpectation(API_DELETE_INSTANCE("the_id"), {
+ qparam: {
+ purge: "YES",
+ },
+ });
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "default",
+ name: "instance_name",
+ } as TalerMerchantApi.Instance,
+ ],
+ },
+ });
+
+ api.instance.deleteInstance(undefined, "the_id", { purge: true })
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // instances: [
+ // {
+ // id: "default",
+ // name: "instance_name",
+ // },
+ // ],
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("instance management api interaction with listing", () => {
+ it("should evict cache when updating an instance", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "managed",
+ name: "instance_name",
+ } as TalerMerchantApi.Instance,
+ ],
+ },
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const { lib: api } = useMerchantApiContext()
+ const query = useBackendInstances();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // instances: [
+ // {
+ // id: "managed",
+ // name: "instance_name",
+ // },
+ // ],
+ // });
+
+ env.addRequestExpectation(API_UPDATE_INSTANCE_BY_ID("managed"), {
+ request: {
+ name: "other_name",
+ } as TalerMerchantApi.InstanceReconfigurationMessage,
+ });
+ env.addRequestExpectation(API_LIST_INSTANCES, {
+ response: {
+ instances: [
+ {
+ id: "managed",
+ name: "other_name",
+ } as TalerMerchantApi.Instance,
+ ],
+ },
+ });
+
+ api.instance.updateCurrentInstance(undefined, {
+ name: "other_name",
+ } as TalerMerchantApi.InstanceConfigurationMessage);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // instances: [
+ // {
+ // id: "managed",
+ // name: "other_name",
+ // },
+ // ],
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts
new file mode 100644
index 000000000..f5f8893cd
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/instance.ts
@@ -0,0 +1,124 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useSessionContext } from "../context/session.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+
+export function revalidateInstanceDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getCurrentInstanceDetails",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceDetails() {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ async function fetcher([token]: [AccessToken]) {
+ return await instance.getCurrentInstanceDetails(token);
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getCurrentInstanceDetails">,
+ TalerHttpError
+ >([session.token, "getCurrentInstanceDetails"], fetcher);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+export function revalidateInstanceKYCDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getCurrentIntanceKycStatus",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceKYCDetails() {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ async function fetcher([token]: [AccessToken]) {
+ return await instance.getCurrentIntanceKycStatus(token, {});
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getCurrentIntanceKycStatus">,
+ TalerHttpError
+ >([session.token, "getCurrentIntanceKycStatus"], fetcher);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+
+
+}
+
+export function revalidateManagedInstanceDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getInstanceDetails",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useManagedInstanceDetails(instanceId: string) {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ async function fetcher([token, instanceId]: [AccessToken, string]) {
+ return await instance.getInstanceDetails(token, instanceId);
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getInstanceDetails">,
+ TalerHttpError
+ >([session.token, instanceId, "getInstanceDetails"], fetcher);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+export function revalidateBackendInstances() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "listInstances",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useBackendInstances() {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ async function fetcher([token]: [AccessToken]) {
+ return await instance.listInstances(token);
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"listInstances">,
+ TalerHttpError
+ >([session.token, "listInstances"], fetcher);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/listener.ts b/packages/merchant-backoffice-ui/src/hooks/listener.ts
new file mode 100644
index 000000000..f59794fd4
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/listener.ts
@@ -0,0 +1,85 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useState } from "preact/hooks";
+
+/**
+ * This component is used when a component wants one child to have a trigger for
+ * an action (a button) and other child have the action implemented (like
+ * gathering information with a form). The difference with other approaches is
+ * that in this case the parent component is not holding the state.
+ *
+ * It will return a subscriber and activator.
+ *
+ * The activator may be undefined, if it is undefined it is indicating that the
+ * subscriber is not ready to be called.
+ *
+ * The subscriber will receive a function (the listener) that will be call when the
+ * activator runs. The listener must return the collected information.
+ *
+ * As a result, when the activator is triggered by a child component, the
+ * @action function is called receives the information from the listener defined by other
+ * child component
+ *
+ * @param action from <T> to <R>
+ * @returns activator and subscriber, undefined activator means that there is not subscriber
+ */
+
+export function useListener<T, R = any>(
+ action: (r: T) => Promise<R>,
+): [undefined | (() => Promise<R>), (listener?: () => T) => void] {
+ type RunnerHandler = { toBeRan?: () => Promise<R> };
+ const [state, setState] = useState<RunnerHandler>({});
+
+ /**
+ * subscriber will receive a method that will be call when the activator runs
+ *
+ * @param listener function to be run when the activator runs
+ */
+ const subscriber = (listener?: () => T) => {
+ if (listener) {
+ setState({
+ toBeRan: () => {
+ const whatWeGetFromTheListener = listener();
+ return action(whatWeGetFromTheListener);
+ },
+ });
+ } else {
+ setState({
+ toBeRan: undefined,
+ });
+ }
+ };
+
+ /**
+ * activator will call runner if there is someone subscribed
+ */
+ const activator = state.toBeRan
+ ? async () => {
+ if (state.toBeRan) {
+ return state.toBeRan();
+ }
+ return Promise.reject();
+ }
+ : undefined;
+
+ return [activator, subscriber];
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/notifications.ts b/packages/merchant-backoffice-ui/src/hooks/notifications.ts
new file mode 100644
index 000000000..137ef5333
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/notifications.ts
@@ -0,0 +1,56 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useState } from "preact/hooks";
+import { Notification } from "../utils/types.js";
+
+interface Result {
+ notifications: Notification[];
+ pushNotification: (n: Notification) => void;
+ removeNotification: (n: Notification) => void;
+}
+
+type NotificationWithDate = Notification & { since: Date };
+
+export function useNotifications(
+ initial: Notification[] = [],
+ timeout = 3000,
+): Result {
+ const [notifications, setNotifications] = useState<NotificationWithDate[]>(
+ initial.map((i) => ({ ...i, since: new Date() })),
+ );
+
+ const pushNotification = (n: Notification): void => {
+ const entry = { ...n, since: new Date() };
+ setNotifications((ns) => [...ns, entry]);
+ if (n.type !== "ERROR")
+ setTimeout(() => {
+ setNotifications((ns) => ns.filter((x) => x.since !== entry.since));
+ }, timeout);
+ };
+
+ const removeNotification = (notif: Notification) => {
+ setNotifications((ns: NotificationWithDate[]) =>
+ ns.filter((n) => n !== notif),
+ );
+ };
+ return { notifications, pushNotification, removeNotification };
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/order.test.ts b/packages/merchant-backoffice-ui/src/hooks/order.test.ts
new file mode 100644
index 000000000..9c1eaccbb
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/order.test.ts
@@ -0,0 +1,581 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AbsoluteTime, AmountString, TalerMerchantApi } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { useInstanceOrders, useOrderDetails } from "./order.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_CREATE_ORDER,
+ API_DELETE_ORDER,
+ API_FORGET_ORDER_BY_ID,
+ API_GET_ORDER_BY_ID,
+ API_LIST_ORDERS,
+ API_REFUND_ORDER_BY_ID,
+} from "./urls.js";
+import { useMerchantApiContext } from "@gnu-taler/web-util/browser";
+
+describe("order api interaction with listing", () => {
+ it("should evict cache when creating an order", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "1" }, { order_id: "2" } as TalerMerchantApi.OrderHistoryEntry],
+ },
+ });
+
+ const newDate = (_d: string | undefined) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceOrders({ paid: true }, newDate);
+ const { lib: api } = useMerchantApiContext()
+ return { query, api };
+ },
+ {},
+ [
+ ({ query }) => {
+ expect(query).undefined;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // orders: [{ order_id: "1" }, { order_id: "2" }],
+ // });
+
+ env.addRequestExpectation(API_CREATE_ORDER, {
+ request: {
+ order: { amount: "ARS:12" as AmountString, summary: "pay me" },
+ lock_uuids: []
+ },
+ response: { order_id: "3" },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "1" }, { order_id: "2" } as any, { order_id: "3" } as any],
+ },
+ });
+
+ api.instance.createOrder(undefined, {
+ order: { amount: "ARS:12" as AmountString, summary: "pay me" },
+ })
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // orders: [{ order_id: "1" }, { order_id: "2" }, { order_id: "3" }],
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when doing a refund", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{
+ order_id: "1",
+ amount: "EUR:12",
+ refundable: true,
+ } as TalerMerchantApi.OrderHistoryEntry]
+ },
+ });
+
+ const newDate = (_d: string | undefined) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceOrders({ paid: true }, newDate);
+ const { lib: api } = useMerchantApiContext()
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // orders: [
+ // {
+ // order_id: "1",
+ // amount: "EUR:12",
+ // refundable: true,
+ // },
+ // ],
+ // });
+ env.addRequestExpectation(API_REFUND_ORDER_BY_ID("1"), {
+ request: {
+ reason: "double pay",
+ refund: "EUR:1",
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [
+ { order_id: "1", amount: "EUR:12", refundable: false } as any,
+ ]
+ },
+ });
+
+ api.instance.addRefund(undefined, "1", {
+ reason: "double pay",
+ refund: "EUR:1" as AmountString,
+ })
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // orders: [
+ // {
+ // order_id: "1",
+ // amount: "EUR:12",
+ // refundable: false,
+ // },
+ // ],
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting an order", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "1" }, { order_id: "2" } as TalerMerchantApi.OrderHistoryEntry],
+ },
+ });
+
+ const newDate = (_d: string | undefined) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceOrders({ paid: true }, newDate);
+ const { lib: api } = useMerchantApiContext()
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // orders: [{ order_id: "1" }, { order_id: "2" }],
+ // });
+
+ env.addRequestExpectation(API_DELETE_ORDER("1"), {});
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, paid: "yes" },
+ response: {
+ orders: [{ order_id: "2" } as any],
+ },
+ });
+
+ api.instance.deleteOrder(undefined, "1")
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // orders: [{ order_id: "2" }],
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("order api interaction with details", () => {
+ it("should evict cache when doing a refund", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ // qparam: { delta: 0, paid: "yes" },
+ response: {
+ summary: "description",
+ refund_amount: "EUR:0",
+ } as unknown as TalerMerchantApi.CheckPaymentPaidResponse,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useOrderDetails("1");
+ const { lib: api } = useMerchantApiContext()
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // summary: "description",
+ // refund_amount: "EUR:0",
+ // });
+ env.addRequestExpectation(API_REFUND_ORDER_BY_ID("1"), {
+ request: {
+ reason: "double pay",
+ refund: "EUR:1",
+ },
+ });
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ response: {
+ summary: "description",
+ refund_amount: "EUR:1",
+ } as unknown as TalerMerchantApi.CheckPaymentPaidResponse,
+ });
+
+ api.instance.addRefund(undefined, "1", {
+ reason: "double pay",
+ refund: "EUR:1" as AmountString,
+ })
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // summary: "description",
+ // refund_amount: "EUR:1",
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when doing a forget", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ // qparam: { delta: 0, paid: "yes" },
+ response: {
+ summary: "description",
+ refund_amount: "EUR:0",
+ } as unknown as TalerMerchantApi.CheckPaymentPaidResponse,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useOrderDetails("1");
+ const { lib: api } = useMerchantApiContext()
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // summary: "description",
+ // refund_amount: "EUR:0",
+ // });
+ env.addRequestExpectation(API_FORGET_ORDER_BY_ID("1"), {
+ request: {
+ fields: ["$.summary"],
+ },
+ });
+
+ env.addRequestExpectation(API_GET_ORDER_BY_ID("1"), {
+ response: {
+ summary: undefined,
+ } as unknown as TalerMerchantApi.CheckPaymentPaidResponse,
+ });
+
+ api.instance.forgetOrder(undefined, "1", {
+ fields: ["$.summary"],
+ })
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // summary: undefined,
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("order listing pagination", () => {
+ it("should not load more if has reach the end", async () => {
+ const env = new ApiMockEnvironment();
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: 20, wired: "yes", date_s: 12 },
+ response: {
+ orders: [{ order_id: "1" } as any],
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, wired: "yes", date_s: 13 },
+ response: {
+ orders: [{ order_id: "2" } as any],
+ },
+ });
+
+ const newDate = (_d: string | undefined) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const date = new Date(12000);
+ const query = useInstanceOrders({ wired: true, date: AbsoluteTime.fromMilliseconds(date.getTime()) }, newDate);
+ const { lib: api } = useMerchantApiContext()
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // orders: [{ order_id: "1" }, { order_id: "2" }],
+ // });
+ // expect(query.isReachingEnd).true;
+ // expect(query.isReachingStart).true;
+
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should load more if result brings more that PAGINATED_LIST_REQUEST", async () => {
+ const env = new ApiMockEnvironment();
+
+ const ordersFrom0to20 = Array.from({ length: 20 }).map((e, i) => ({
+ order_id: String(i),
+ }));
+ const ordersFrom20to40 = Array.from({ length: 20 }).map((e, i) => ({
+ order_id: String(i + 20),
+ }));
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: 20, wired: "yes", date_s: 12 },
+ response: {
+ orders: ordersFrom0to20,
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -20, wired: "yes", date_s: 13 },
+ response: {
+ orders: ordersFrom20to40,
+ },
+ });
+
+ const newDate = (_d: string | undefined) => {
+ //console.log("new date", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const date = new Date(12000);
+ const query = useInstanceOrders({ wired: true, date: AbsoluteTime.fromMilliseconds(date.getTime()) }, newDate);
+ const { lib: api } = useMerchantApiContext()
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // orders: [...ordersFrom20to0, ...ordersFrom20to40],
+ // });
+ // expect(query.isReachingEnd).false;
+ // expect(query.isReachingStart).false;
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: -40, wired: "yes", date_s: 13 },
+ response: {
+ orders: [...ordersFrom20to40, { order_id: "41" }],
+ },
+ });
+
+ // query.loadMore();
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // orders: [
+ // ...ordersFrom20to0,
+ // ...ordersFrom20to40,
+ // { order_id: "41" },
+ // ],
+ // });
+
+ env.addRequestExpectation(API_LIST_ORDERS, {
+ qparam: { delta: 40, wired: "yes", date_s: 12 },
+ response: {
+ orders: [...ordersFrom0to20, { order_id: "-1" }],
+ },
+ });
+
+ // query.loadMorePrev();
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // orders: [
+ // { order_id: "-1" },
+ // ...ordersFrom20to0,
+ // ...ordersFrom20to40,
+ // { order_id: "41" },
+ // ],
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/merchant-backoffice-ui/src/hooks/order.ts b/packages/merchant-backoffice-ui/src/hooks/order.ts
new file mode 100644
index 000000000..d0513dc40
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/order.ts
@@ -0,0 +1,98 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { AbsoluteTime, AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useSessionContext } from "../context/session.js";
+import { buildPaginatedResult } from "./webhooks.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+
+
+export function revalidateOrderDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getOrderDetails",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useOrderDetails(oderId: string) {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ async function fetcher([dId, token]: [string, AccessToken]) {
+ return await instance.getOrderDetails(token, dId);
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getOrderDetails">,
+ TalerHttpError
+ >([oderId, session.token, "getOrderDetails"], fetcher);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+
+export interface InstanceOrderFilter {
+ paid?: boolean;
+ refunded?: boolean;
+ wired?: boolean;
+ date?: AbsoluteTime;
+ position?: string;
+}
+
+export function revalidateInstanceOrders() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "listOrders",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceOrders(
+ args?: InstanceOrderFilter,
+ updatePosition: (d: string | undefined) => void = () => { },
+) {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ // const [offset, setOffset] = useState<string | undefined>(args?.position);
+
+ async function fetcher([token, o, p, r, w, d]: [AccessToken, string, boolean, boolean, boolean, AbsoluteTime]) {
+ return await instance.listOrders(token, {
+ limit: PAGINATED_LIST_REQUEST,
+ offset: o,
+ order: "dec",
+ paid: p,
+ refunded: r,
+ wired: w,
+ date: d,
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"listOrders">,
+ TalerHttpError
+ >([session.token, args?.position, args?.paid, args?.refunded, args?.wired, args?.date, "listOrders"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return buildPaginatedResult(data.body.orders, args?.position, updatePosition, (d) => String(d.row_id))
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/otp.ts b/packages/merchant-backoffice-ui/src/hooks/otp.ts
new file mode 100644
index 000000000..41ed89f70
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/otp.ts
@@ -0,0 +1,80 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useSessionContext } from "../context/session.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export function revalidateInstanceOtpDevices() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "listOtpDevices",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceOtpDevices() {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ // const [offset, setOffset] = useState<string | undefined>();
+
+ async function fetcher([token, _bid]: [AccessToken, string]) {
+ return await instance.listOtpDevices(token, {
+ // limit: PAGINATED_LIST_REQUEST,
+ // offset: bid,
+ // order: "dec",
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"listOtpDevices">,
+ TalerHttpError
+ >([session.token, "offset", "listOtpDevices"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ // return buildPaginatedResult(data.body.otp_devices, offset, setOffset, (d) => d.otp_device_id)
+ return data;
+}
+
+export function revalidateOtpDeviceDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getOtpDeviceDetails",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useOtpDeviceDetails(deviceId: string) {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ async function fetcher([dId, token]: [string, AccessToken]) {
+ return await instance.getOtpDeviceDetails(token, dId);
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getOtpDeviceDetails">,
+ TalerHttpError
+ >([deviceId, session.token, "getOtpDeviceDetails"], fetcher);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/preference.ts b/packages/merchant-backoffice-ui/src/hooks/preference.ts
new file mode 100644
index 000000000..a21d2921c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/preference.ts
@@ -0,0 +1,89 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Codec,
+ buildCodecForObject,
+ codecForAbsoluteTime,
+ codecForBoolean,
+ codecForConstString,
+ codecForEither,
+} from "@gnu-taler/taler-util";
+import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
+
+export interface Preferences {
+ advanceOrderMode: boolean;
+ hideKycUntil: AbsoluteTime;
+ hideMissingAccountUntil: AbsoluteTime;
+ dateFormat: "ymd" | "dmy" | "mdy";
+}
+
+const defaultSettings: Preferences = {
+ advanceOrderMode: false,
+ hideKycUntil: AbsoluteTime.never(),
+ hideMissingAccountUntil: AbsoluteTime.never(),
+ dateFormat: "ymd",
+};
+
+export const codecForPreferences = (): Codec<Preferences> =>
+ buildCodecForObject<Preferences>()
+ .property("advanceOrderMode", codecForBoolean())
+ .property("hideKycUntil", codecForAbsoluteTime)
+ .property("hideMissingAccountUntil", codecForAbsoluteTime)
+ .property(
+ "dateFormat",
+ codecForEither(
+ codecForConstString("ymd"),
+ codecForConstString("dmy"),
+ codecForConstString("mdy"),
+ ),
+ )
+ .build("Preferences");
+
+const PREFERENCES_KEY = buildStorageKey(
+ "merchant-preferences",
+ codecForPreferences(),
+);
+
+export function usePreference(): [
+ Readonly<Preferences>,
+ <T extends keyof Preferences>(key: T, value: Preferences[T]) => void,
+ (s: Preferences) => void,
+] {
+ const { value, update } = useLocalStorage(PREFERENCES_KEY, defaultSettings);
+ function updateField<T extends keyof Preferences>(k: T, v: Preferences[T]) {
+ const newValue = { ...value, [k]: v };
+ update(newValue);
+ }
+
+ return [value, updateField, update];
+}
+
+export function dateFormatForSettings(s: Preferences): string {
+ switch (s.dateFormat) {
+ case "ymd":
+ return "yyyy/MM/dd";
+ case "dmy":
+ return "dd/MM/yyyy";
+ case "mdy":
+ return "MM/dd/yyyy";
+ }
+}
+
+export function datetimeFormatForSettings(s: Preferences): string {
+ return dateFormatForSettings(s) + " HH:mm:ss";
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/product.test.ts b/packages/merchant-backoffice-ui/src/hooks/product.test.ts
new file mode 100644
index 000000000..39281241c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/product.test.ts
@@ -0,0 +1,362 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import {
+ useInstanceProducts,
+ useProductDetails,
+} from "./product.js";
+import { ApiMockEnvironment } from "./testing.js";
+import {
+ API_CREATE_PRODUCT,
+ API_DELETE_PRODUCT,
+ API_GET_PRODUCT_BY_ID,
+ API_LIST_PRODUCTS,
+ API_UPDATE_PRODUCT_BY_ID,
+} from "./urls.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useMerchantApiContext } from "@gnu-taler/web-util/browser";
+
+describe("product api interaction with listing", () => {
+ it("should evict cache when creating a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234", product_serial: 1 }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: { price: "ARS:12" } as TalerMerchantApi.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceProducts();
+ const { lib: api } = useMerchantApiContext();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]);
+
+ env.addRequestExpectation(API_CREATE_PRODUCT, {
+ request: {
+ price: "ARS:23",
+ } as TalerMerchantApi.ProductAddDetail,
+ });
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234", product_serial: 1 }, { product_id: "2345", product_serial: 2 }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:12",
+ } as TalerMerchantApi.ProductDetail,
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:12",
+ } as TalerMerchantApi.ProductDetail,
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("2345"), {
+ response: {
+ price: "ARS:23",
+ } as TalerMerchantApi.ProductDetail,
+ });
+
+ api.instance.addProduct(undefined, {
+ price: "ARS:23",
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals([
+ // {
+ // id: "1234",
+ // price: "ARS:12",
+ // },
+ // {
+ // id: "2345",
+ // price: "ARS:23",
+ // },
+ // ]);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when updating a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234", product_serial: 1 }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: { price: "ARS:12" } as TalerMerchantApi.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceProducts();
+ const { lib: api } = useMerchantApiContext();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]);
+
+ env.addRequestExpectation(API_UPDATE_PRODUCT_BY_ID("1234"), {
+ request: {
+ price: "ARS:13",
+ } as TalerMerchantApi.ProductPatchDetail,
+ });
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234", product_serial: 1 }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:13",
+ } as TalerMerchantApi.ProductDetail,
+ });
+
+ api.instance.updateProduct(undefined, "1234", {
+ price: "ARS:13",
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals([
+ // {
+ // id: "1234",
+ // price: "ARS:13",
+ // },
+ // ]);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+
+ it("should evict cache when deleting a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234", product_serial: 1 }, { product_id: "2345", product_serial: 2 }],
+ },
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: { price: "ARS:12" } as TalerMerchantApi.ProductDetail,
+ });
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("2345"), {
+ response: { price: "ARS:23" } as TalerMerchantApi.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceProducts();
+ const { lib: api } = useMerchantApiContext();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals([
+ // { id: "1234", price: "ARS:12" },
+ // { id: "2345", price: "ARS:23" },
+ // ]);
+
+ env.addRequestExpectation(API_DELETE_PRODUCT("2345"), {});
+
+ env.addRequestExpectation(API_LIST_PRODUCTS, {
+ response: {
+ products: [{ product_id: "1234", product_serial: 1 }],
+ },
+ });
+
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("1234"), {
+ response: {
+ price: "ARS:12",
+ } as TalerMerchantApi.ProductDetail,
+ });
+ api.instance.deleteProduct(undefined, "2345");
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals([{ id: "1234", price: "ARS:12" }]);
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("product api interaction with details", () => {
+ it("should evict cache when updating a product", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("12"), {
+ response: {
+ description: "this is a description",
+ } as TalerMerchantApi.ProductDetail,
+ });
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useProductDetails("12");
+ const { lib: api } = useMerchantApiContext();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // description: "this is a description",
+ // });
+
+ env.addRequestExpectation(API_UPDATE_PRODUCT_BY_ID("12"), {
+ request: {
+ description: "other description",
+ } as TalerMerchantApi.ProductPatchDetail,
+ });
+
+ env.addRequestExpectation(API_GET_PRODUCT_BY_ID("12"), {
+ response: {
+ description: "other description",
+ } as TalerMerchantApi.ProductDetail,
+ });
+
+ api.instance.updateProduct(undefined, "12", {
+ description: "other description",
+ } as any);
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).false;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // description: "other description",
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/merchant-backoffice-ui/src/hooks/product.ts b/packages/merchant-backoffice-ui/src/hooks/product.ts
new file mode 100644
index 000000000..defda5552
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/product.ts
@@ -0,0 +1,103 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { AccessToken, OperationOk, TalerHttpError, TalerMerchantApi, TalerMerchantManagementErrorsByMethod, TalerMerchantManagementResultByMethod, opFixedSuccess } from "@gnu-taler/taler-util";
+import { useState } from "preact/hooks";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useSessionContext } from "../context/session.js";
+import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
+import { buildPaginatedResult } from "./webhooks.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+type ProductWithId = TalerMerchantApi.ProductDetail & { id: string, serial: number };
+function notUndefined(c: ProductWithId | undefined): c is ProductWithId {
+ return c !== undefined;
+}
+
+export function revalidateInstanceProducts() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "listProductsWithId",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceProducts() {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ const [offset, setOffset] = useState<number | undefined>();
+
+ async function fetcher([token, bid]: [AccessToken, number]) {
+ const list = await instance.listProducts(token, {
+ limit: PAGINATED_LIST_REQUEST,
+ offset: bid === undefined ? undefined: String(bid),
+ order: "dec",
+ });
+ if (list.type !== "ok") {
+ return list;
+ }
+ const all: Array<ProductWithId | undefined> = await Promise.all(
+ list.body.products.map(async (c) => {
+ const r = await instance.getProductDetails(token, c.product_id);
+ if (r.type === "fail") {
+ return undefined;
+ }
+ return { ...r.body, id: c.product_id, serial: c.product_serial };
+ }),
+ );
+ const products = all.filter(notUndefined);
+
+ return opFixedSuccess({ products });
+ }
+
+ const { data, error } = useSWR<
+ OperationOk<{ products: ProductWithId[] }> |
+ TalerMerchantManagementErrorsByMethod<"listProducts">,
+ TalerHttpError
+ >([session.token, offset, "listProductsWithId"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return buildPaginatedResult(data.body.products, offset, setOffset, (d) => d.serial)
+}
+
+export function revalidateProductDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getProductDetails",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useProductDetails(productId: string) {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ async function fetcher([pid, token]: [string, AccessToken]) {
+ return await instance.getProductDetails(token, pid);
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getProductDetails">,
+ TalerHttpError
+ >([productId, session.token, "getProductDetails"], fetcher);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/templates.ts b/packages/merchant-backoffice-ui/src/hooks/templates.ts
new file mode 100644
index 000000000..12d99f3fc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/templates.ts
@@ -0,0 +1,87 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useState } from "preact/hooks";
+import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useSessionContext } from "../context/session.js";
+import { buildPaginatedResult } from "./webhooks.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+
+export interface InstanceTemplateFilter {
+}
+
+export function revalidateInstanceTemplates() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "listTemplates",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceTemplates() {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ const [offset, setOffset] = useState<string | undefined>();
+
+ async function fetcher([token, bid]: [AccessToken, string]) {
+ return await instance.listTemplates(token, {
+ limit: PAGINATED_LIST_REQUEST,
+ offset: bid,
+ order: "dec",
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"listTemplates">,
+ TalerHttpError
+ >([session.token, offset, "listTemplates"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return buildPaginatedResult(data.body.templates, offset, setOffset, (d) => d.template_id)
+
+}
+
+export function revalidateTemplateDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getTemplateDetails",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useTemplateDetails(templateId: string) {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ async function fetcher([tid, token]: [string, AccessToken]) {
+ return await instance.getTemplateDetails(token, tid);
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getTemplateDetails">,
+ TalerHttpError
+ >([templateId, session.token, "getTemplateDetails"], fetcher);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/testing.tsx b/packages/merchant-backoffice-ui/src/hooks/testing.tsx
new file mode 100644
index 000000000..fc78f6c58
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/testing.tsx
@@ -0,0 +1,192 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { MockEnvironment } from "@gnu-taler/web-util/testing";
+import { ComponentChildren, FunctionalComponent, h, VNode } from "preact";
+import { HttpRequestLibrary, HttpRequestOptions, HttpResponse } from "@gnu-taler/taler-util/http";
+import { SWRConfig } from "swr";
+import { ApiContextProvider } from "@gnu-taler/web-util/browser";
+import { TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient } from "@gnu-taler/taler-util";
+
+interface RequestOptions {
+ method?: "GET" | "POST" | "HEAD",
+ params?: any,
+ token?: string | undefined,
+ data?: any,
+}
+interface HttpResponseOk<T> {
+ ok: true,
+ data: T,
+ loading: boolean,
+ clientError: boolean,
+ serverError: boolean,
+ info: any,
+}
+
+export class ApiMockEnvironment extends MockEnvironment {
+ constructor(debug = false) {
+ super(debug);
+ }
+
+ mockApiIfNeeded(): void {
+ null; // do nothing
+ }
+
+ public buildTestingContext(): FunctionalComponent<{
+ children: ComponentChildren;
+ }> {
+ const __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE =
+ this.saveRequestAndGetMockedResponse.bind(this);
+
+ return function TestingContext({
+ children,
+ }: {
+ children: ComponentChildren;
+ }): VNode {
+
+ async function request<T>(
+ base: string,
+ path: string,
+ options: RequestOptions = {},
+ ): Promise<HttpResponseOk<T>> {
+ const _url = new URL(`${base}${path}`);
+ // Object.entries(options.params ?? {}).forEach(([key, value]) => {
+ // _url.searchParams.set(key, String(value));
+ // });
+
+ const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE(
+ {
+ method: options.method ?? "GET",
+ url: _url.href,
+ },
+ {
+ qparam: options.params,
+ auth: options.token,
+ request: options.data,
+ },
+ );
+ const status = mocked.expectedQuery?.query.code ?? 200;
+ const requestPayload = mocked.expectedQuery?.params?.request;
+ const responsePayload = mocked.expectedQuery?.params?.response;
+
+ return {
+ ok: true,
+ data: responsePayload as T,
+ loading: false,
+ clientError: false,
+ serverError: false,
+ info: {
+ hasToken: !!options.token,
+ status,
+ url: _url.href,
+ payload: options.data,
+ options: {},
+ },
+ };
+ }
+ const SC: any = SWRConfig;
+
+ const mockHttpClient = new class implements HttpRequestLibrary {
+ async fetch(url: string, options?: HttpRequestOptions | undefined): Promise<HttpResponse> {
+ const _url = new URL(url);
+ const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE(
+ {
+ method: options?.method ?? "GET",
+ url: _url.href,
+ },
+ {
+ qparam: _url.searchParams,
+ auth: options as any,
+ request: options?.body as any,
+ },
+ );
+ const status = mocked.expectedQuery?.query.code ?? 200;
+ const requestPayload = mocked.expectedQuery?.params?.request;
+ const responsePayload = mocked.expectedQuery?.params?.response;
+
+ // FIXME: complete this implementation to mock any query
+ const resp: HttpResponse = {
+ requestUrl: _url.href,
+ status: status,
+ headers: {} as any,
+ requestMethod: options?.method ?? "GET",
+ json: async () => responsePayload,
+ text: async () => responsePayload as any as string,
+ bytes: async () => responsePayload as ArrayBuffer,
+ };
+ return resp
+ }
+ get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
+ return this.fetch(url, {
+ method: "GET",
+ ...opt,
+ });
+ }
+
+ postJson(
+ url: string,
+ body: any,
+ opt?: HttpRequestOptions,
+ ): Promise<HttpResponse> {
+ return this.fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ ...opt,
+ });
+ }
+
+ }
+ const bankCore = new TalerCoreBankHttpClient("http://localhost", mockHttpClient)
+ const bankIntegration = new TalerBankIntegrationHttpClient(bankCore.getIntegrationAPI().href, mockHttpClient)
+ const bankRevenue = new TalerRevenueHttpClient(bankCore.getRevenueAPI("a").href, mockHttpClient)
+ const bankWire = new TalerWireGatewayHttpClient(bankCore.getWireGatewayAPI("b").href, "b", mockHttpClient)
+
+ return (
+ // <BackendContextProvider defaultUrl="http://backend">
+ // <InstanceContextProvider
+ // value={{
+ // token: undefined,
+ // id: "default",
+ // admin: true,
+ // changeToken: () => null,
+ // }}
+ // >
+ <ApiContextProvider value={{ request : undefined as any, bankCore, bankIntegration, bankRevenue, bankWire }}>
+ <SC
+ value={{
+ loadingTimeout: 0,
+ dedupingInterval: 0,
+ shouldRetryOnError: false,
+ errorRetryInterval: 0,
+ errorRetryCount: 0,
+ provider: () => new Map(),
+ }}
+ >
+ {children}
+ </SC>
+ </ApiContextProvider>
+ // </InstanceContextProvider>
+ // </BackendContextProvider>
+ );
+ };
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/transfer.test.ts b/packages/merchant-backoffice-ui/src/hooks/transfer.test.ts
new file mode 100644
index 000000000..7daaf5049
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/transfer.test.ts
@@ -0,0 +1,260 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AmountString,
+ PaytoString,
+ TalerMerchantApi,
+} from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { ApiMockEnvironment } from "./testing.js";
+import { useInstanceTransfers } from "./transfer.js";
+import { API_INFORM_TRANSFERS, API_LIST_TRANSFERS } from "./urls.js";
+import { useMerchantApiContext } from "@gnu-taler/web-util/browser";
+
+describe("transfer api interaction with listing", () => {
+ it("should evict cache when informing a transfer", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20 },
+ response: {
+ transfers: [{ wtid: "2" } as TalerMerchantApi.TransferDetails],
+ },
+ });
+
+ const moveCursor = (d: string | undefined) => {
+ console.log("new position", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceTransfers({}, moveCursor);
+ const { lib: api } = useMerchantApiContext();
+ return { query, api };
+ },
+ {},
+ [
+ ({ query, api }) => {
+ // expect(query.loading).true;
+ },
+
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // transfers: [{ wtid: "2" }],
+ // });
+
+ env.addRequestExpectation(API_INFORM_TRANSFERS, {
+ request: {
+ wtid: "3",
+ credit_amount: "EUR:1",
+ exchange_url: "exchange.url",
+ payto_uri: "payto://",
+ },
+ response: { total: "" } as any,
+ });
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20 },
+ response: {
+ transfers: [{ wtid: "3" } as any, { wtid: "2" } as any],
+ },
+ });
+
+ api.instance.informWireTransfer(undefined, {
+ wtid: "3",
+ credit_amount: "EUR:1" as AmountString,
+ exchange_url: "exchange.url",
+ payto_uri: "payto://" as PaytoString,
+ });
+ },
+ ({ query, api }) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+
+ // expect(query.data).deep.equals({
+ // transfers: [{ wtid: "3" }, { wtid: "2" }],
+ // });
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ });
+});
+
+describe("transfer listing pagination", () => {
+ it("should not load more if has reach the end", async () => {
+ const env = new ApiMockEnvironment();
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20, payto_uri: "payto://" },
+ response: {
+ transfers: [{ wtid: "2" }, { wtid: "1" } as any],
+ },
+ });
+
+ const moveCursor = (d: string | undefined) => {
+ console.log("new position", d);
+ };
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceTransfers(
+ { payto_uri: "payto://" },
+ moveCursor,
+ );
+ return { query };
+ },
+ {},
+ [
+ (query) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(query.loading).true;
+ },
+ (query) => {
+ // expect(query.loading).undefined;
+ // expect(query.ok).true;
+ // if (!query.ok) return;
+ // expect(query.data).deep.equals({
+ // transfers: [{ wtid: "2" }, { wtid: "1" }],
+ // });
+ // expect(query.isReachingEnd).true;
+ // expect(query.isReachingStart).true;
+
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ });
+
+ it("should load more if result brings more that PAGINATED_LIST_REQUEST", async () => {
+ const env = new ApiMockEnvironment();
+
+ const transfersFrom0to20 = Array.from({ length: 20 }).map((e, i) => ({
+ wtid: String(i),
+ }));
+ const transfersFrom20to40 = Array.from({ length: 20 }).map((e, i) => ({
+ wtid: String(i + 20),
+ }));
+ // const transfersFrom20to0 = [...transfersFrom0to20].reverse();
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: 20, payto_uri: "payto://", offset: "1" },
+ response: {
+ transfers: transfersFrom0to20,
+ },
+ });
+
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -20, payto_uri: "payto://", offset: "1" },
+ response: {
+ transfers: transfersFrom20to40,
+ },
+ });
+
+ const moveCursor = (d: string | undefined) => {
+ console.log("new position", d);
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const query = useInstanceTransfers(
+ { payto_uri: "payto://", position: "1" },
+ moveCursor,
+ );
+ return { query };
+ },
+ {},
+ [
+ (result) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(result.loading).true;
+ },
+ (result) => {
+ // expect(result.loading).undefined;
+ // expect(result.ok).true;
+ // if (!result.ok) return;
+ // expect(result.data).deep.equals({
+ // transfers: [...transfersFrom20to0, ...transfersFrom20to40],
+ // });
+ // expect(result.isReachingEnd).false;
+ // expect(result.isReachingStart).false;
+
+ //query more
+ env.addRequestExpectation(API_LIST_TRANSFERS, {
+ qparam: { limit: -40, payto_uri: "payto://", offset: "1" },
+ response: {
+ transfers: [...transfersFrom20to40, { wtid: "41" }],
+ },
+ });
+ // result.loadMore();
+ },
+ (result) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(result.loading).true;
+ },
+ (result) => {
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
+ result: "ok",
+ });
+ // expect(result.loading).undefined;
+ // expect(result.ok).true;
+ // if (!result.ok) return;
+ // expect(result.data).deep.equals({
+ // transfers: [
+ // ...transfersFrom20to0,
+ // ...transfersFrom20to40,
+ // { wtid: "41" },
+ // ],
+ // });
+ // expect(result.isReachingEnd).true;
+ // expect(result.isReachingStart).false;
+ },
+ ],
+ env.buildTestingContext(),
+ );
+
+ expect(env.assertJustExpectedRequestWereMade()).deep.eq({ result: "ok" });
+ expect(hookBehavior).deep.eq({ result: "ok" });
+ });
+});
diff --git a/packages/merchant-backoffice-ui/src/hooks/transfer.ts b/packages/merchant-backoffice-ui/src/hooks/transfer.ts
new file mode 100644
index 000000000..6f77369c2
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/transfer.ts
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useSessionContext } from "../context/session.js";
+import { buildPaginatedResult } from "./webhooks.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface InstanceTransferFilter {
+ payto_uri?: string;
+ verified?: boolean;
+ position?: string;
+}
+
+export function revalidateInstanceTransfers() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "listWireTransfers",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceTransfers(
+ args?: InstanceTransferFilter,
+ updatePosition: (id: string | undefined) => void = (() => { }),
+) {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ // const [offset, setOffset] = useState<string | undefined>(args?.position);
+
+ async function fetcher([token, o, p, v]: [AccessToken, string, string, boolean]) {
+ return await instance.listWireTransfers(token, {
+ paytoURI: p,
+ verified: v,
+ limit: PAGINATED_LIST_REQUEST,
+ offset: o,
+ order: "dec",
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"listWireTransfers">,
+ TalerHttpError
+ >([session.token, args?.position, args?.payto_uri, args?.verified, "listWireTransfers"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return buildPaginatedResult(data.body.transfers, args?.position, updatePosition, (d) => String(d.transfer_serial_id))
+
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/urls.ts b/packages/merchant-backoffice-ui/src/hooks/urls.ts
new file mode 100644
index 000000000..95e1c04f3
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/urls.ts
@@ -0,0 +1,235 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { Query } from "@gnu-taler/web-util/testing";
+
+////////////////////
+// ORDER
+////////////////////
+
+export const API_CREATE_ORDER: Query<
+ TalerMerchantApi.PostOrderRequest,
+ TalerMerchantApi.PostOrderResponse
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/orders",
+};
+
+export const API_GET_ORDER_BY_ID = (
+ id: string,
+): Query<unknown, TalerMerchantApi.MerchantOrderStatusResponse> => ({
+ method: "GET",
+ url: `http://backend/instances/default/private/orders/${id}`,
+});
+
+export const API_LIST_ORDERS: Query<
+ unknown,
+ TalerMerchantApi.OrderHistory
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/orders",
+};
+
+export const API_REFUND_ORDER_BY_ID = (
+ id: string,
+): Query<
+ TalerMerchantApi.RefundRequest,
+ TalerMerchantApi.MerchantRefundResponse
+> => ({
+ method: "POST",
+ url: `http://backend/instances/default/private/orders/${id}/refund`,
+});
+
+export const API_FORGET_ORDER_BY_ID = (
+ id: string,
+): Query<TalerMerchantApi.ForgetRequest, unknown> => ({
+ method: "PATCH",
+ url: `http://backend/instances/default/private/orders/${id}/forget`,
+});
+
+export const API_DELETE_ORDER = (
+ id: string,
+): Query<TalerMerchantApi.ForgetRequest, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/instances/default/private/orders/${id}`,
+});
+
+////////////////////
+// TRANSFER
+////////////////////
+
+export const API_LIST_TRANSFERS: Query<
+ unknown,
+ TalerMerchantApi.TransferList
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/transfers",
+};
+
+export const API_INFORM_TRANSFERS: Query<
+ TalerMerchantApi.TransferInformation,
+ {}
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/transfers",
+};
+
+////////////////////
+// PRODUCT
+////////////////////
+
+export const API_CREATE_PRODUCT: Query<
+ TalerMerchantApi.ProductAddDetail,
+ unknown
+> = {
+ method: "POST",
+ url: "http://backend/instances/default/private/products",
+};
+
+export const API_LIST_PRODUCTS: Query<
+ unknown,
+ TalerMerchantApi.InventorySummaryResponse
+> = {
+ method: "GET",
+ url: "http://backend/instances/default/private/products",
+};
+
+export const API_GET_PRODUCT_BY_ID = (
+ id: string,
+): Query<unknown, TalerMerchantApi.ProductDetail> => ({
+ method: "GET",
+ url: `http://backend/instances/default/private/products/${id}`,
+});
+
+export const API_UPDATE_PRODUCT_BY_ID = (
+ id: string,
+): Query<
+ TalerMerchantApi.ProductPatchDetail,
+ TalerMerchantApi.InventorySummaryResponse
+> => ({
+ method: "PATCH",
+ url: `http://backend/instances/default/private/products/${id}`,
+});
+
+export const API_DELETE_PRODUCT = (id: string): Query<unknown, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/instances/default/private/products/${id}`,
+});
+
+////////////////////
+// INSTANCE ADMIN
+////////////////////
+
+export const API_CREATE_INSTANCE: Query<
+ TalerMerchantApi.InstanceConfigurationMessage,
+ unknown
+> = {
+ method: "POST",
+ url: "http://backend/management/instances",
+};
+
+export const API_GET_INSTANCE_BY_ID = (
+ id: string,
+): Query<unknown, TalerMerchantApi.QueryInstancesResponse> => ({
+ method: "GET",
+ url: `http://backend/management/instances/${id}`,
+});
+
+export const API_GET_INSTANCE_KYC_BY_ID = (
+ id: string,
+): Query<unknown, TalerMerchantApi.AccountKycRedirects> => ({
+ method: "GET",
+ url: `http://backend/management/instances/${id}/kyc`,
+});
+
+export const API_LIST_INSTANCES: Query<
+ unknown,
+ TalerMerchantApi.InstancesResponse
+> = {
+ method: "GET",
+ url: "http://backend/management/instances",
+};
+
+export const API_UPDATE_INSTANCE_BY_ID = (
+ id: string,
+): Query<
+ TalerMerchantApi.InstanceReconfigurationMessage,
+ unknown
+> => ({
+ method: "PATCH",
+ url: `http://backend/management/instances/${id}`,
+});
+
+export const API_UPDATE_INSTANCE_AUTH_BY_ID = (
+ id: string,
+): Query<
+ TalerMerchantApi.InstanceAuthConfigurationMessage,
+ unknown
+> => ({
+ method: "POST",
+ url: `http://backend/management/instances/${id}/auth`,
+});
+
+export const API_DELETE_INSTANCE = (id: string): Query<unknown, unknown> => ({
+ method: "DELETE",
+ url: `http://backend/management/instances/${id}`,
+});
+
+////////////////////
+// INSTANCE
+////////////////////
+
+export const API_GET_CURRENT_INSTANCE: Query<
+ unknown,
+ TalerMerchantApi.QueryInstancesResponse
+> = {
+ method: "GET",
+ url: `http://backend/instances/default/private/`,
+};
+
+export const API_GET_CURRENT_INSTANCE_KYC: Query<
+ unknown,
+ TalerMerchantApi.AccountKycRedirects
+> = {
+ method: "GET",
+ url: `http://backend/instances/default/private/kyc`,
+};
+
+export const API_UPDATE_CURRENT_INSTANCE: Query<
+ TalerMerchantApi.InstanceReconfigurationMessage,
+ unknown
+> = {
+ method: "PATCH",
+ url: `http://backend/instances/default/private/`,
+};
+
+export const API_UPDATE_CURRENT_INSTANCE_AUTH: Query<
+ TalerMerchantApi.InstanceAuthConfigurationMessage,
+ unknown
+> = {
+ method: "POST",
+ url: `http://backend/instances/default/private/auth`,
+};
+
+export const API_DELETE_CURRENT_INSTANCE: Query<unknown, unknown> = {
+ method: "DELETE",
+ url: `http://backend/instances/default/private`,
+};
diff --git a/packages/merchant-backoffice-ui/src/hooks/webhooks.ts b/packages/merchant-backoffice-ui/src/hooks/webhooks.ts
new file mode 100644
index 000000000..fe37162aa
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/webhooks.ts
@@ -0,0 +1,118 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { PAGINATED_LIST_REQUEST } from "../utils/constants.js";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import { AccessToken, OperationOk, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useSessionContext } from "../context/session.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export interface InstanceWebhookFilter {
+}
+
+export function revalidateInstanceWebhooks() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "listWebhooks",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useInstanceWebhooks() {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ // const [offset, setOffset] = useState<string | undefined>();
+
+ async function fetcher([token, _bid]: [AccessToken, string]) {
+ return await instance.listWebhooks(token, {
+ // limit: PAGINATED_LIST_REQUEST,
+ // offset: bid,
+ // order: "dec",
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"listWebhooks">,
+ TalerHttpError
+ >([session.token, "offset", "listWebhooks"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ // return buildPaginatedResult(data.body.webhooks, offset, setOffset, (d) => d.webhook_id)
+ return data;
+}
+
+type PaginatedResult<T> = OperationOk<T> & {
+ isLastPage: boolean;
+ isFirstPage: boolean;
+ loadNext(): void;
+ loadFirst(): void;
+}
+
+//TODO: consider sending this to web-util
+export function buildPaginatedResult<R, OffId>(data: R[], offset: OffId | undefined, setOffset: (o: OffId | undefined) => void, getId: (r: R) => OffId): PaginatedResult<R[]> {
+
+ const isLastPage = data.length < PAGINATED_LIST_REQUEST;
+ const isFirstPage = offset === undefined;
+
+ const result = structuredClone(data);
+ if (result.length == PAGINATED_LIST_REQUEST) {
+ result.pop();
+ }
+ return {
+ type: "ok",
+ body: result,
+ isLastPage,
+ isFirstPage,
+ loadNext: () => {
+ if (!result.length) return;
+ const id = getId(result[result.length - 1])
+ setOffset(id);
+ },
+ loadFirst: () => {
+ setOffset(undefined);
+ },
+ };
+}
+
+
+export function revalidateWebhookDetails() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getWebhookDetails",
+ undefined,
+ { revalidate: true },
+ );
+}
+export function useWebhookDetails(webhookId: string) {
+ const { state: session } = useSessionContext();
+ const { lib: { instance } } = useSessionContext();
+
+ async function fetcher([hookId, token]: [string, AccessToken]) {
+ return await instance.getWebhookDetails(token, hookId);
+ }
+
+ const { data, error } = useSWR<
+ TalerMerchantManagementResultByMethod<"getWebhookDetails">,
+ TalerHttpError
+ >([webhookId, session.token, "getWebhookDetails"], fetcher);
+
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
diff --git a/packages/merchant-backoffice-ui/src/i18n/de.po b/packages/merchant-backoffice-ui/src/i18n/de.po
new file mode 100644
index 000000000..f34d5dd20
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/i18n/de.po
@@ -0,0 +1,2742 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-03-21 21:39+0000\n"
+"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
+"Language-Team: German <https://weblate.taler.net/projects/gnu-taler/"
+"merchant-backoffice/de/>\n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr "Bestätigen"
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr "Betrag"
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr "Datum"
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr "Zurück"
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr "Rückerstattet"
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr "Verwendungszweck"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr "IBAN-Nummern haben normalerweise mehr als 4 Ziffern"
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr "IBAN-Nummern haben normalerweise weniger als 34 Ziffern"
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr "IBAN-Ländercode wurde nicht gefunden"
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr "IBAN-Nummer ist ungültig, die Prüfsumme ist falsch"
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
diff --git a/packages/merchant-backoffice-ui/src/i18n/en.po b/packages/merchant-backoffice-ui/src/i18n/en.po
new file mode 100644
index 000000000..d8d0bae29
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/i18n/en.po
@@ -0,0 +1,2741 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
diff --git a/packages/merchant-backoffice-ui/src/i18n/es.po b/packages/merchant-backoffice-ui/src/i18n/es.po
new file mode 100644
index 000000000..2c4bc64a7
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/i18n/es.po
@@ -0,0 +1,2854 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-02-13 14:40+0000\n"
+"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
+"Language-Team: Spanish <https://weblate.taler.net/projects/gnu-taler/"
+"merchant-backoffice/es/>\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr "%1$s"
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr "Cerrar"
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr "Continuar"
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr "Limpiar"
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr "Confirmar"
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr "no es el mismo que el token de acceso actual"
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr "no puede ser vacío"
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr "no puede ser igual al viejo token"
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr "no son iguales"
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr "Está actualizando el token de acceso para la instancia con id %1$s"
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr "Viejo token de acceso"
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr "acceder al token en uso actualmente"
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr "Nuevo token de acceso"
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr "siguiente token de acceso a usar"
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr "Repetir token de acceso"
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr "confirmar el mismo token de acceso"
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr "Limpiar el token de acceso significa acceso público a la instancia"
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr "no puede ser igual al anterior token de acceso"
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr "Está estableciendo el token de acceso para la nueva instancia"
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+"Con el método de autorización externa no se hará ninguna revisión por el "
+"backend del comerciante"
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr "Establecer autorización externa"
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr "Establecer token de acceso"
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr "Operación en progreso..."
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr "La operación será automáticamente cancelada luego de %1$s segundos"
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr "Instancias"
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr "Borrar"
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr "agregar nueva instancia"
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr "ID"
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr "Nombre"
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr "Editar"
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr "Purgar"
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr "Todavía no hay instancias, agregue más presionando el signo +"
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr "Solo mostrar instancias activas"
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr "Activo"
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr "Mostrar solo instancias eliminadas"
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr "Eliminado"
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr "Mostrar todas las instancias"
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr "Todo"
+
+#: src/paths/admin/list/index.tsx:101
+#, fuzzy, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr "La instancia '%1$s' (ID: %2$s) fue eliminada"
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr "Fallo al eliminar instancia"
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr "Instance '%1$s' (ID: %2$s) ha sido deshabilitada"
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr "Fallo al purgar la instancia"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr "Verificación KYC pendiente"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr "Expirado"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr "Exchange"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr "Cuenta objetivo"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr "URL de KYC"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr "Código"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr "Estado http"
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr "¡No hay verificación kyc pendiente!"
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr "cambiar valor a fecha desconocida"
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr "cambiar valor a vacío"
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr "limpiar"
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr "cambiar valor a nunca"
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr "nunca"
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr "País"
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr "Dirección"
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr "Número de edificio"
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr "Nombre de edificio"
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr "Calle"
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr "Código postal"
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr "Ubicación de ciudad"
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr "Ciudad"
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr "Distrito"
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr "Subdivisión de país"
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr "Id de producto"
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr "Descripcion"
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, fuzzy, c-format
+msgid "Product"
+msgstr "Productos"
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr "buscar productos por su descripción o ID"
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr "no se encontraron productos con esa descripción"
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr "Debe ingresar un identificador de producto válido."
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr "¡Cantidad debe ser mayor que 0!"
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, fuzzy, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+"Esta cantidad excede las existencias restantes. Actualmente, solo quedan "
+"%1$s unidades sin reservar en las existencias."
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr "Cantidad"
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr "cuántos productos serán agregados"
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr "Agregar del inventario"
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr "La imagen debe ser mas chica que 1 MB"
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr "Agregar"
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr "Eliminar"
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr "Ningun impuesto configurado para este producto."
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr "Monto"
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+"Impuestos pueden estar en divisas que difieren de la principal divisa usada "
+"por el comerciante."
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+"Ingrese divisa y valor separado por dos puntos, e.g. &quot;USD:2.3&quot;."
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr "Nombre legal del impuesto, e.g. IVA o arancel."
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr "agregar impuesto a la lista de impuestos"
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr "describa y agregue un producto que no está en la lista de inventarios"
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr "Agregue un producto personalizado"
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr "Complete información del producto"
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr "Imagen"
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr "foto del producto"
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr "descripción completa del producto"
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr "Unidad"
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr "nombre de la unidad del producto"
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr "Precio"
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr "monto de la divisa actual"
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr "Impuestos"
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr "imagen"
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr "descripción"
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr "cantidad"
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr "precio unitario"
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr "precio total"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr "requerido"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr "no válido"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr "debe ser mayor que 0"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr "no es un json válido"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr "deberían ser en el futuro"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr "plazo de reembolso no puede ser antes que el plazo de pago"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+"el plazo de la transferencia bancaria no puede ser antes que el plazo de "
+"reembolso"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+"el plazo de la transferencia bancaria no puede ser antes que el plazo de pago"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr "debería tener un plazo de reembolso"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr "reembolso automático no puede ser después qu el plazo de reembolso"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr "Manejar productos en orden"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr "Manejar lista de productos en la orden."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr "Remover este producto de la orden."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr "Precio total"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr "precio total de producto agregado"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr "Monto a ser pagado por el cliente"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr "Precio de la orden"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr "Precio final de la orden"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr "Resumen"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr "Título de la orden a ser mostrado al cliente"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr "Envío y cumplimiento"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr "Fecha de entrega"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr "Plazo para la entrega física asegurado por el comerciante."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr "Ubicación"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr "dirección a donde los productos serán entregados"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr "URL de cumplimiento"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr "URL al cual el usuario será redirigido luego de pago exitoso."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr "Opciones de pago de Taler"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr "Sobreescribir pagos por omisión de Taler para esta orden"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, fuzzy, c-format
+msgid "Payment deadline"
+msgstr "Plazo de pago"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+"Plazo límite para que el cliente pague por la oferta antes de que expire. "
+"Productos del inventario serán reservados hasta este plazo límite."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr "Plazo de reembolso"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+"Tiempo hasta el cual la orden puede ser reembolsada por el comerciante."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr "Plazo de la transferencia"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr "Plazo para que el exchange haga la transferencia."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, fuzzy, c-format
+msgid "Auto-refund deadline"
+msgstr "Plazo de reembolso automático"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+"Tiempo hasta el cual la billetera será automáticamente revisada por "
+"reembolsos win interación por parte del usuario."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr "Máxima tarifa de depósito"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+"Máxima tarifa de depósito que el comerciante esta dispuesto a cubir para "
+"esta orden. Mayores tarifas de depósito deben ser cubiertas completamente "
+"por el consumidor."
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr "Máxima tarifa de transferencia"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr "Amortización de comisión de transferencia"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, fuzzy, c-format
+msgid "Create token"
+msgstr "Administrar token"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, fuzzy, c-format
+msgid "Minimum age required"
+msgstr "Login necesario"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, fuzzy, c-format
+msgid "Additional information"
+msgstr "Información extra"
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr "días"
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr "horas"
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr "minutos"
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr "segundos"
+
+#: src/components/form/InputDuration.tsx:53
+#, fuzzy, c-format
+msgid "forever"
+msgstr "nunca"
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr "Órdenes"
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, fuzzy, c-format
+msgid "create order"
+msgstr "creado"
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr "cargar nuevas ordenes"
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr "Fecha"
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr "Devolución"
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr "copiar url"
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr "cargar viejas ordenes"
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr "¡No se encontraron órdenes que emparejen su búsqueda!"
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr "duplicado"
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr "formato inválido"
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr "este monto excede el monto reembolsable"
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr "fecha"
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr "monto"
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr "razón"
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr "monto a ser reembolsado"
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr "Máximo reembolzable:"
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr "Razón"
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr "Elija uno..."
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr "pedido por el consumidor"
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr "otro"
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr "por qué esta orden está siendo reembolsada"
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr "más información para dar contexto"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr "Términos de contrato"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr "descripción legible de toda la compra"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr "precio total de la transacción"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr "URL para esta compra"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr "Máxima comisión"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr "Impuesto de transferencia máximo"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr "Creado en"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, fuzzy, c-format
+msgid "Auto-refund delay"
+msgstr "Plazo de reembolso automático"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, fuzzy, c-format
+msgid "Extra info"
+msgstr "Información extra"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr "Orden"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr "reclamado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, fuzzy, c-format
+msgid "claimed at"
+msgstr "reclamado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr "Cronología"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr "Detalles de pago"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr "Estado de orden"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr "Lista de producto"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr "pagados"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr "transferido"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr "reembolzado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, fuzzy, c-format
+msgid "refund order"
+msgstr "reembolzado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, fuzzy, c-format
+msgid "not refundable"
+msgstr "Máximo reembolzable:"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr "reembolzar"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr "Monto reembolzado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, fuzzy, c-format
+msgid "Refund taken"
+msgstr "Reembolzado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, fuzzy, c-format
+msgid "Status URL"
+msgstr "URL de estado de orden"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, fuzzy, c-format
+msgid "Refund URI"
+msgstr "Devolución"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr "impago"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr "pagar en"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr "creado"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr "URL de estado de orden"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, fuzzy, c-format
+msgid "Payment URI"
+msgstr "URI de pago"
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+"Estado de orden desconocido. Esto es un error, por favor contacte a su "
+"administrador."
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr "reembolzo creado satisfactoriamente"
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, fuzzy, c-format
+msgid "order id"
+msgstr "ir a id de orden"
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr "Pagado"
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, fuzzy, c-format
+msgid "only show orders with refunds"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr "Reembolsado"
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr "No transferido"
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, fuzzy, c-format
+msgid "Enter an order id"
+msgstr "ir a id de orden"
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, fuzzy, c-format
+msgid "order not found"
+msgstr "Servidor no encontrado"
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, fuzzy, c-format
+msgid "could not get the order to refund"
+msgstr "No se pudo create el reembolso"
+
+#: src/components/exception/AsyncButton.tsx:43
+#, fuzzy, c-format
+msgid "Loading..."
+msgstr "Cargando..."
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr "Administrar stock"
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr "Inifinito"
+
+#: src/components/form/InputStock.tsx:136
+#, fuzzy, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr "la pérdida no puede ser mayor al stock actual + entrante (max %1$s )"
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr "Ingresando"
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr "Perdido"
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr "Actual"
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr "sin stock"
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr "Próximo reabastecimiento"
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr "Dirección de entrega"
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr "Existencias"
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr "no se pudo crear el producto"
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr "Productos"
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr "Venta"
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr "Ganancia"
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr "Vendido"
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr "Gratis"
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, fuzzy, c-format
+msgid "go to product update page"
+msgstr "producto actualizado correctamente"
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr "Actualizar"
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, fuzzy, c-format
+msgid "new price for the product"
+msgstr "no se pudo actualizar el producto"
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, fuzzy, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr "No hay propinas todavía, agregar mas presionando el signo +"
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr "producto actualizado correctamente"
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr "no se pudo actualizar el producto"
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr "producto fue eliminado correctamente"
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr "no se pudo eliminar el producto"
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, fuzzy, c-format
+msgid "Product id:"
+msgstr "Id de producto"
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, fuzzy, c-format
+msgid "it should be greater than 0"
+msgstr "Debe ser mayor a 0"
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, fuzzy, c-format
+msgid "Initial balance"
+msgstr "Instancia"
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr "URL del Exchange"
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr "Siguiente"
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, fuzzy, c-format
+msgid "method to use for wire transfer"
+msgstr "no se pudo informar la transferencia"
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, fuzzy, c-format
+msgid "could not create reserve"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr "Válido hasta"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, fuzzy, c-format
+msgid "Created balance"
+msgstr "creado"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, fuzzy, c-format
+msgid "Exchange balance"
+msgstr "Monto inicial"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, fuzzy, c-format
+msgid "Committed"
+msgstr "Monto confirmado"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr "Dirección de cuenta"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr "Asunto"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr "Propinas"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, fuzzy, c-format
+msgid "Authorized"
+msgstr "Token de autorización"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, fuzzy, c-format
+msgid "Expiration"
+msgstr "Información extra"
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, fuzzy, c-format
+msgid "amount of tip"
+msgstr "monto"
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, fuzzy, c-format
+msgid "Justification"
+msgstr "Jurisdicción"
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, fuzzy, c-format
+msgid "Reserves not yet funded"
+msgstr "Servidor no encontrado"
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, fuzzy, c-format
+msgid "add new reserve"
+msgstr "cargar nuevas transferencias"
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, fuzzy, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr "No hay transferencias todavía, agregar mas presionando el signo +"
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, fuzzy, c-format
+msgid "Expected Balance"
+msgstr "Ejecutado en"
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, fuzzy, c-format
+msgid "could not create the tip"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, fuzzy, c-format
+msgid "should not be empty"
+msgstr "no puede ser vacío"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, fuzzy, c-format
+msgid "should be greater that 0"
+msgstr "Debe ser mayor a 0"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, fuzzy, c-format
+msgid "can't be empty"
+msgstr "no puede ser vacío"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, fuzzy, c-format
+msgid "Fixed summary"
+msgstr "Estado de orden"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, fuzzy, c-format
+msgid "Fixed price"
+msgstr "precio unitario"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr "Edad mínima"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, fuzzy, c-format
+msgid "Payment timeout"
+msgstr "Opciones de pago"
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, fuzzy, c-format
+msgid "could not inform template"
+msgstr "no se pudo informar la transferencia"
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, fuzzy, c-format
+msgid "Amount is required"
+msgstr "Login necesario"
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, fuzzy, c-format
+msgid "New order for template"
+msgstr "cargar viejas transferencias"
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, fuzzy, c-format
+msgid "Order summary"
+msgstr "Estado de orden"
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, fuzzy, c-format
+msgid "could not create order from template"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, fuzzy, c-format
+msgid "Fixed amount"
+msgstr "Monto reembolzado"
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, fuzzy, c-format
+msgid "Default amount"
+msgstr "Monto reembolzado"
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, fuzzy, c-format
+msgid "Default summary"
+msgstr "Estado de orden"
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, fuzzy, c-format
+msgid "load newer templates"
+msgstr "cargar nuevas transferencias"
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, fuzzy, c-format
+msgid "create qr code for the template"
+msgstr "No se pudo create el reembolso"
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, fuzzy, c-format
+msgid "load older templates"
+msgstr "cargar viejas transferencias"
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, fuzzy, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr "No hay propinas todavía, agregar mas presionando el signo +"
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, fuzzy, c-format
+msgid "template delete successfully"
+msgstr "producto fue eliminado correctamente"
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, fuzzy, c-format
+msgid "could not delete the template"
+msgstr "no se pudo eliminar el producto"
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, fuzzy, c-format
+msgid "could not update template"
+msgstr "no se pudo actualizar el producto"
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, fuzzy, c-format
+msgid "should be one of '%1$s'"
+msgstr "deberían ser iguales"
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr "URL"
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, fuzzy, c-format
+msgid "load newer webhooks"
+msgstr "cargar nuevas ordenes"
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, fuzzy, c-format
+msgid "load older webhooks"
+msgstr "cargar viejas ordenes"
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, fuzzy, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr "No hay propinas todavía, agregar mas presionando el signo +"
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, fuzzy, c-format
+msgid "webhook delete successfully"
+msgstr "producto fue eliminado correctamente"
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, fuzzy, c-format
+msgid "could not delete the webhook"
+msgstr "no se pudo eliminar el producto"
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, fuzzy, c-format
+msgid "check the id, does not look valid"
+msgstr "verificar el id, no parece válido"
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr "debería tener 52 caracteres, actualmente %1$s"
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr "La URL no tiene el formato correcto"
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, fuzzy, c-format
+msgid "Wire transfer ID"
+msgstr "Id de transferencia"
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr "no se pudo informar la transferencia"
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr "Transferencias"
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, fuzzy, c-format
+msgid "add new transfer"
+msgstr "cargar nuevas transferencias"
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr "cargar nuevas transferencias"
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr "Crédito"
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr "Confirmado"
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr "Verificado"
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr "Ejecutado en"
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr "si"
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr "no"
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr "desconocido"
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr "eliminar transferencia seleccionada de la base de datos"
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr "cargue más transferencia luego de la última"
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr "cargar viejas transferencias"
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr "No hay transferencias todavía, agregar mas presionando el signo +"
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, fuzzy, c-format
+msgid "filter by account address"
+msgstr "Dirección de cuenta"
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, fuzzy, c-format
+msgid "Unverified"
+msgstr "Verificado"
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, fuzzy, c-format
+msgid "is not a number"
+msgstr "Número de edificio"
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr "debe ser 1 o mayor"
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr "máximo 7 líneas"
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr "cambiar configuración de autorización"
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr "Necesita completar campos marcados y escoger un método de autorización"
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr "Esta no es una dirección de bitcoin válida."
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr "Esta no es una dirección de Ethereum válida."
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr "Los números IBAN usualmente tienen mas de 4 digitos"
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr "Los números IBAN usualmente tienen menos de 34 digitos"
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr "Código de pais de IBAN no encontrado"
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr "El número IBAN no es válido, falló la verificación"
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr "Tipo objetivo"
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr "Método a usar para la transferencia"
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr "Enrutamiento"
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr "Número de enrutamiento."
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr "Cuenta"
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, fuzzy, c-format
+msgid "Account number."
+msgstr "Dirección de cuenta"
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr "Interfaz de pago unificado."
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, fuzzy, c-format
+msgid "Business name"
+msgstr "Nombre de edificio"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr "Correo eletrónico"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr "URL de sitio web"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr "Cuenta bancaria"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr "Impuesto máximo de deposito por omisión"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr "Impuesto máximo de transferencia por omisión"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr "Amortización de impuesto de transferencia por omisión"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr "Jurisdicción"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr "Jurisdicción para disputas legales con el comerciante."
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, fuzzy, c-format
+msgid "Default payment delay"
+msgstr "Retrazo de pago por omisión"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr "Retrazo de transferencia por omisión"
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr "ID de instancia"
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, fuzzy, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+"Limpiar el token de autorización significa acceso público a la instancia"
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr "Administrar token de acceso"
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr "Fallo al crear la instancia"
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr "Login necesario"
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, fuzzy, c-format
+msgid "Access Token"
+msgstr "Acceso denegado"
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, fuzzy, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr "Servidir reporto un problema: HTTP status #%1$s"
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr "Acceso denegado"
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, fuzzy, c-format
+msgid "No 'default' instance configured yet."
+msgstr "Sin instancia default"
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr "Instancia"
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr "Configuración"
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr "Conexión"
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr "Nuevo"
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr "Lista"
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr "Salir"
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr "Verifica que el token sea valido"
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr "No se pudo acceder al servidor."
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr "No se pudo inferir el id de la instancia con la url %1$s"
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr "Servidor no encontrado"
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr "Recibimos el mensaje %1$s desde %2$s"
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr "Error inesperado"
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr "El valor %1$s es invalido para una URL de pago"
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr "agregar elemento a la lista"
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr "Agregar"
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr "Borrando"
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr "Cambiando"
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr "ID de pedido"
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr "URL de pago"
+
+#, c-format
+#~ msgid "Couldn't access the server"
+#~ msgstr "No se pudo aceder al servidor"
+
+#, c-format
+#~ msgid "HTTP status #%1$s: Server reported a problem"
+#~ msgstr "HTTP status #%1$s: Servidor reporto un problema"
+
+#, c-format
+#~ msgid "Got message: \"%1$s\" from: %2$s"
+#~ msgstr "Recibimos el mensaje: %1$s desde %2$s"
+
+#, c-format
+#~ msgid ""
+#~ "in order to use merchant backoffice, you should create the default "
+#~ "instance"
+#~ msgstr ""
+#~ "para usar el merchant backoffice, debería crear la instancia default"
+
+#, c-format
+#~ msgid "Got message: %1$s from: %2$s"
+#~ msgstr "Recibimos el mensaje %1$s desde %2$s"
+
+#, c-format
+#~ msgid ""
+#~ "Please enter your auth token. Token should have \"secret-token:\" and "
+#~ "start with Bearer or ApiKey"
+#~ msgstr ""
+#~ "Por favor ingrese su token de autorización. El token debe tener \"secret-"
+#~ "token\" y comenzar con Bearer o ApiKey"
+
+#, c-format
+#~ msgid "pick a date"
+#~ msgstr "elegir una fecha"
+
+#, c-format
+#~ msgid "no results"
+#~ msgstr "Sin resultados"
+
+#, c-format
+#~ msgid "current stock will change from %1$s to %2$s"
+#~ msgstr "stock actual cambiará desde %1$s a %2$s"
+
+#, c-format
+#~ msgid "current stock will stay at %1$s"
+#~ msgstr "stock actual seguirá en %1$s"
+
+#, c-format
+#~ msgid "this product has no taxes"
+#~ msgstr "este producto no tiene impuestos"
+
+#, c-format
+#~ msgid "Inventory products"
+#~ msgstr "Productos de inventario"
+
+#, c-format
+#~ msgid "Total tax"
+#~ msgstr "Impuesto total"
+
+#, c-format
+#~ msgid "Net"
+#~ msgstr "Neto"
+
+#, c-format
+#~ msgid "select a product first"
+#~ msgstr "seleccione un producto primero"
+
+#, c-format
+#~ msgid ""
+#~ "cannot be greater than current stock and quantity previously added. max: "
+#~ "%1$s"
+#~ msgstr ""
+#~ "no puede ser mayor al stock actual y la cantidad previamente agregada. "
+#~ "máximo: %1$s"
+
+#, c-format
+#~ msgid "cannot be greater than current stock %1$s"
+#~ msgstr "no puede ser mayor al stock actual %1$s"
+
+#, c-format
+#~ msgid "Deposit total"
+#~ msgstr "Total depositado"
+
+#, c-format
+#~ msgid "Merchant initial amount"
+#~ msgstr "Monto inicial"
+
+#, c-format
+#~ msgid "Account Address"
+#~ msgstr "Dirección de cuenta"
diff --git a/packages/merchant-backoffice-ui/src/i18n/fr.po b/packages/merchant-backoffice-ui/src/i18n/fr.po
new file mode 100644
index 000000000..4da5c5b59
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/i18n/fr.po
@@ -0,0 +1,2742 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-02-28 08:07+0000\n"
+"Last-Translator: d0p1 <contact@d0p1.eu>\n"
+"Language-Team: French <https://weblate.taler.net/projects/gnu-taler/"
+"merchant-backoffice/fr/>\n"
+"Language: fr\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n!=1);\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr "Annuler"
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr "Fermer"
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr "Continuer"
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr "Confirmer"
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr "ne peux pas être vide"
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
diff --git a/packages/merchant-backoffice-ui/src/i18n/it.po b/packages/merchant-backoffice-ui/src/i18n/it.po
new file mode 100644
index 000000000..4055af10e
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/i18n/it.po
@@ -0,0 +1,2742 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2023-08-16 12:43+0000\n"
+"Last-Translator: Krystian Baran <kiszkot@murena.io>\n"
+"Language-Team: Italian <https://weblate.taler.net/projects/gnu-taler/"
+"merchant-backoffice/it/>\n"
+"Language: it\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.13.1\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr "Importo"
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr "Data"
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr "Indietro"
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr "Rimborsato"
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr "Soggetto"
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr "Impostazioni"
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
diff --git a/packages/merchant-backoffice-ui/src/i18n/poheader b/packages/merchant-backoffice-ui/src/i18n/poheader
new file mode 100644
index 000000000..7ddcf49b8
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/i18n/poheader
@@ -0,0 +1,27 @@
+# This file is part of GNU Taler
+# (C) 2021-2023 Taler Systems S.A.
+
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
diff --git a/packages/merchant-backoffice-ui/src/i18n/strings-prelude b/packages/merchant-backoffice-ui/src/i18n/strings-prelude
new file mode 100644
index 000000000..6c68662de
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/i18n/strings-prelude
@@ -0,0 +1,19 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/*eslint quote-props: ["error", "consistent"]*/
+export const strings: {[s: string]: any} = {};
+
diff --git a/packages/merchant-backoffice-ui/src/i18n/strings.ts b/packages/merchant-backoffice-ui/src/i18n/strings.ts
new file mode 100644
index 000000000..65dc41358
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/i18n/strings.ts
@@ -0,0 +1,9655 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/*eslint quote-props: ["error", "consistent"]*/
+export const strings: {[s: string]: any} = {};
+
+strings['de'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=(n != 1);",
+ "lang": ""
+ },
+ "Cancel": [
+ ""
+ ],
+ "%1$s": [
+ ""
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ ""
+ ],
+ "Clear": [
+ ""
+ ],
+ "Confirm": [
+ ""
+ ],
+ "is not the same as the current access token": [
+ ""
+ ],
+ "cannot be empty": [
+ ""
+ ],
+ "cannot be the same as the old token": [
+ ""
+ ],
+ "is not the same": [
+ ""
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ ""
+ ],
+ "Old access token": [
+ ""
+ ],
+ "access token currently in use": [
+ ""
+ ],
+ "New access token": [
+ ""
+ ],
+ "next access token to be used": [
+ ""
+ ],
+ "Repeat access token": [
+ ""
+ ],
+ "confirm the same access token": [
+ ""
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ ""
+ ],
+ "cannot be the same as the old access token": [
+ ""
+ ],
+ "You are setting the access token for the new instance": [
+ ""
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ ""
+ ],
+ "Set external authorization": [
+ ""
+ ],
+ "Set access token": [
+ ""
+ ],
+ "Operation in progress...": [
+ ""
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ ""
+ ],
+ "Instances": [
+ ""
+ ],
+ "Delete": [
+ ""
+ ],
+ "add new instance": [
+ ""
+ ],
+ "ID": [
+ ""
+ ],
+ "Name": [
+ ""
+ ],
+ "Edit": [
+ ""
+ ],
+ "Purge": [
+ ""
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ ""
+ ],
+ "Only show active instances": [
+ ""
+ ],
+ "Active": [
+ ""
+ ],
+ "Only show deleted instances": [
+ ""
+ ],
+ "Deleted": [
+ ""
+ ],
+ "Show all instances": [
+ ""
+ ],
+ "All": [
+ ""
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ ""
+ ],
+ "Failed to delete instance": [
+ ""
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ ""
+ ],
+ "Failed to purge instance": [
+ ""
+ ],
+ "Pending KYC verification": [
+ ""
+ ],
+ "Timed out": [
+ ""
+ ],
+ "Exchange": [
+ ""
+ ],
+ "Target account": [
+ ""
+ ],
+ "KYC URL": [
+ ""
+ ],
+ "Code": [
+ ""
+ ],
+ "Http Status": [
+ ""
+ ],
+ "No pending kyc verification!": [
+ ""
+ ],
+ "change value to unknown date": [
+ ""
+ ],
+ "change value to empty": [
+ ""
+ ],
+ "clear": [
+ ""
+ ],
+ "change value to never": [
+ ""
+ ],
+ "never": [
+ ""
+ ],
+ "Country": [
+ ""
+ ],
+ "Address": [
+ ""
+ ],
+ "Building number": [
+ ""
+ ],
+ "Building name": [
+ ""
+ ],
+ "Street": [
+ ""
+ ],
+ "Post code": [
+ ""
+ ],
+ "Town location": [
+ ""
+ ],
+ "Town": [
+ ""
+ ],
+ "District": [
+ ""
+ ],
+ "Country subdivision": [
+ ""
+ ],
+ "Product id": [
+ ""
+ ],
+ "Description": [
+ ""
+ ],
+ "Product": [
+ ""
+ ],
+ "search products by it's description or id": [
+ ""
+ ],
+ "no products found with that description": [
+ ""
+ ],
+ "You must enter a valid product identifier.": [
+ ""
+ ],
+ "Quantity must be greater than 0!": [
+ ""
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ ""
+ ],
+ "Quantity": [
+ ""
+ ],
+ "how many products will be added": [
+ ""
+ ],
+ "Add from inventory": [
+ ""
+ ],
+ "Image should be smaller than 1 MB": [
+ ""
+ ],
+ "Add": [
+ ""
+ ],
+ "Remove": [
+ ""
+ ],
+ "No taxes configured for this product.": [
+ ""
+ ],
+ "Amount": [
+ ""
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ ""
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ ""
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ ""
+ ],
+ "add tax to the tax list": [
+ ""
+ ],
+ "describe and add a product that is not in the inventory list": [
+ ""
+ ],
+ "Add custom product": [
+ ""
+ ],
+ "Complete information of the product": [
+ ""
+ ],
+ "Image": [
+ ""
+ ],
+ "photo of the product": [
+ ""
+ ],
+ "full product description": [
+ ""
+ ],
+ "Unit": [
+ ""
+ ],
+ "name of the product unit": [
+ ""
+ ],
+ "Price": [
+ ""
+ ],
+ "amount in the current currency": [
+ ""
+ ],
+ "Taxes": [
+ ""
+ ],
+ "image": [
+ ""
+ ],
+ "description": [
+ ""
+ ],
+ "quantity": [
+ ""
+ ],
+ "unit price": [
+ ""
+ ],
+ "total price": [
+ ""
+ ],
+ "required": [
+ ""
+ ],
+ "not valid": [
+ ""
+ ],
+ "must be greater than 0": [
+ ""
+ ],
+ "not a valid json": [
+ ""
+ ],
+ "should be in the future": [
+ ""
+ ],
+ "refund deadline cannot be before pay deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ ""
+ ],
+ "should have a refund deadline": [
+ ""
+ ],
+ "auto refund cannot be after refund deadline": [
+ ""
+ ],
+ "Manage products in order": [
+ ""
+ ],
+ "Manage list of products in the order.": [
+ ""
+ ],
+ "Remove this product from the order.": [
+ ""
+ ],
+ "Total price": [
+ ""
+ ],
+ "total product price added up": [
+ ""
+ ],
+ "Amount to be paid by the customer": [
+ ""
+ ],
+ "Order price": [
+ ""
+ ],
+ "final order price": [
+ ""
+ ],
+ "Summary": [
+ ""
+ ],
+ "Title of the order to be shown to the customer": [
+ ""
+ ],
+ "Shipping and Fulfillment": [
+ ""
+ ],
+ "Delivery date": [
+ ""
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ ""
+ ],
+ "Location": [
+ ""
+ ],
+ "address where the products will be delivered": [
+ ""
+ ],
+ "Fulfillment URL": [
+ ""
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ ""
+ ],
+ "Taler payment options": [
+ ""
+ ],
+ "Override default Taler payment settings for this order": [
+ ""
+ ],
+ "Payment deadline": [
+ ""
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ ""
+ ],
+ "Refund deadline": [
+ ""
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ ""
+ ],
+ "Wire transfer deadline": [
+ ""
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ ""
+ ],
+ "Auto-refund deadline": [
+ ""
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ ""
+ ],
+ "Maximum deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ ""
+ ],
+ "Maximum wire fee": [
+ ""
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ ""
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ ""
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ ""
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ ""
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ ""
+ ],
+ "hours": [
+ ""
+ ],
+ "minutes": [
+ ""
+ ],
+ "seconds": [
+ ""
+ ],
+ "forever": [
+ ""
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ ""
+ ],
+ "create order": [
+ ""
+ ],
+ "load newer orders": [
+ ""
+ ],
+ "Date": [
+ ""
+ ],
+ "Refund": [
+ ""
+ ],
+ "copy url": [
+ ""
+ ],
+ "load older orders": [
+ ""
+ ],
+ "No orders have been found matching your query!": [
+ ""
+ ],
+ "duplicated": [
+ ""
+ ],
+ "invalid format": [
+ ""
+ ],
+ "this value exceed the refundable amount": [
+ ""
+ ],
+ "date": [
+ ""
+ ],
+ "amount": [
+ ""
+ ],
+ "reason": [
+ ""
+ ],
+ "amount to be refunded": [
+ ""
+ ],
+ "Max refundable:": [
+ ""
+ ],
+ "Reason": [
+ ""
+ ],
+ "Choose one...": [
+ ""
+ ],
+ "requested by the customer": [
+ ""
+ ],
+ "other": [
+ ""
+ ],
+ "why this order is being refunded": [
+ ""
+ ],
+ "more information to give context": [
+ ""
+ ],
+ "Contract Terms": [
+ ""
+ ],
+ "human-readable description of the whole purchase": [
+ ""
+ ],
+ "total price for the transaction": [
+ ""
+ ],
+ "URL for this purchase": [
+ ""
+ ],
+ "Max fee": [
+ ""
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ ""
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ ""
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ ""
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ ""
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ ""
+ ],
+ "claimed": [
+ ""
+ ],
+ "claimed at": [
+ ""
+ ],
+ "Timeline": [
+ ""
+ ],
+ "Payment details": [
+ ""
+ ],
+ "Order status": [
+ ""
+ ],
+ "Product list": [
+ ""
+ ],
+ "paid": [
+ ""
+ ],
+ "wired": [
+ ""
+ ],
+ "refunded": [
+ ""
+ ],
+ "refund order": [
+ ""
+ ],
+ "not refundable": [
+ ""
+ ],
+ "refund": [
+ ""
+ ],
+ "Refunded amount": [
+ ""
+ ],
+ "Refund taken": [
+ ""
+ ],
+ "Status URL": [
+ ""
+ ],
+ "Refund URI": [
+ ""
+ ],
+ "unpaid": [
+ ""
+ ],
+ "pay at": [
+ ""
+ ],
+ "created at": [
+ ""
+ ],
+ "Order status URL": [
+ ""
+ ],
+ "Payment URI": [
+ ""
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ ""
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ ""
+ ],
+ "could not create the refund": [
+ ""
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ ""
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ ""
+ ],
+ "only show orders with refunds": [
+ ""
+ ],
+ "Refunded": [
+ ""
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ ""
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ ""
+ ],
+ "order not found": [
+ ""
+ ],
+ "could not get the order to refund": [
+ ""
+ ],
+ "Loading...": [
+ ""
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ ""
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ ""
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ ""
+ ],
+ "Incoming": [
+ ""
+ ],
+ "Lost": [
+ ""
+ ],
+ "Current": [
+ ""
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ ""
+ ],
+ "Next restock": [
+ ""
+ ],
+ "Delivery address": [
+ ""
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ ""
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ ""
+ ],
+ "Products": [
+ ""
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ ""
+ ],
+ "Profit": [
+ ""
+ ],
+ "Sold": [
+ ""
+ ],
+ "free": [
+ ""
+ ],
+ "go to product update page": [
+ ""
+ ],
+ "Update": [
+ ""
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ ""
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ ""
+ ],
+ "product updated successfully": [
+ ""
+ ],
+ "could not update the product": [
+ ""
+ ],
+ "product delete successfully": [
+ ""
+ ],
+ "could not delete the product": [
+ ""
+ ],
+ "Product id:": [
+ ""
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ ""
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ ""
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ ""
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ ""
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ ""
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ ""
+ ],
+ "Valid until": [
+ ""
+ ],
+ "Created balance": [
+ ""
+ ],
+ "Exchange balance": [
+ ""
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ ""
+ ],
+ "Account address": [
+ ""
+ ],
+ "Subject": [
+ ""
+ ],
+ "Tips": [
+ ""
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ ""
+ ],
+ "Expiration": [
+ ""
+ ],
+ "amount of tip": [
+ ""
+ ],
+ "Justification": [
+ ""
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ ""
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ ""
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ ""
+ ],
+ "Expected Balance": [
+ ""
+ ],
+ "could not create the tip": [
+ ""
+ ],
+ "should not be empty": [
+ ""
+ ],
+ "should be greater that 0": [
+ ""
+ ],
+ "can't be empty": [
+ ""
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ ""
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ ""
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ ""
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ ""
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ ""
+ ],
+ "Amount is required": [
+ ""
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ ""
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ ""
+ ],
+ "could not create order from template": [
+ ""
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ ""
+ ],
+ "Default amount": [
+ ""
+ ],
+ "Default summary": [
+ ""
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ ""
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ ""
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ ""
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ ""
+ ],
+ "template delete successfully": [
+ ""
+ ],
+ "could not delete the template": [
+ ""
+ ],
+ "could not update template": [
+ ""
+ ],
+ "should be one of '%1$s'": [
+ ""
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ ""
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ ""
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ ""
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ ""
+ ],
+ "webhook delete successfully": [
+ ""
+ ],
+ "could not delete the webhook": [
+ ""
+ ],
+ "check the id, does not look valid": [
+ ""
+ ],
+ "should have 52 characters, current %1$s": [
+ ""
+ ],
+ "URL doesn't have the right format": [
+ ""
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ ""
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ ""
+ ],
+ "Transfers": [
+ ""
+ ],
+ "add new transfer": [
+ ""
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ ""
+ ],
+ "Credit": [
+ ""
+ ],
+ "Confirmed": [
+ ""
+ ],
+ "Verified": [
+ ""
+ ],
+ "Executed at": [
+ ""
+ ],
+ "yes": [
+ ""
+ ],
+ "no": [
+ ""
+ ],
+ "unknown": [
+ ""
+ ],
+ "delete selected transfer from the database": [
+ ""
+ ],
+ "load more transfer after the last one": [
+ ""
+ ],
+ "load older transfers": [
+ ""
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ ""
+ ],
+ "filter by account address": [
+ ""
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ ""
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ ""
+ ],
+ "must be 1 or greater": [
+ ""
+ ],
+ "max 7 lines": [
+ ""
+ ],
+ "change authorization configuration": [
+ ""
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ ""
+ ],
+ "This is not a valid bitcoin address.": [
+ ""
+ ],
+ "This is not a valid Ethereum address.": [
+ ""
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ ""
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ ""
+ ],
+ "IBAN country code not found": [
+ ""
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ ""
+ ],
+ "Target type": [
+ ""
+ ],
+ "Method to use for wire transfer": [
+ ""
+ ],
+ "Routing": [
+ ""
+ ],
+ "Routing number.": [
+ ""
+ ],
+ "Account": [
+ ""
+ ],
+ "Account number.": [
+ ""
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ ""
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ ""
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ ""
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ ""
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ ""
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ ""
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ ""
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ ""
+ ],
+ "Default payment delay": [
+ ""
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ ""
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ ""
+ ],
+ "Change the authorization method use for this instance.": [
+ ""
+ ],
+ "Manage access token": [
+ ""
+ ],
+ "Failed to create instance": [
+ ""
+ ],
+ "Login required": [
+ ""
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ ""
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ ""
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ ""
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ ""
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ ""
+ ],
+ "Settings": [
+ ""
+ ],
+ "Connection": [
+ ""
+ ],
+ "New": [
+ ""
+ ],
+ "List": [
+ ""
+ ],
+ "Log out": [
+ ""
+ ],
+ "Check your token is valid": [
+ ""
+ ],
+ "Couldn't access the server.": [
+ ""
+ ],
+ "Could not infer instance id from url %1$s": [
+ ""
+ ],
+ "Server not found": [
+ ""
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ ""
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ ""
+ ],
+ "The value %1$s is invalid for a payment url": [
+ ""
+ ],
+ "add element to the list": [
+ ""
+ ],
+ "add": [
+ ""
+ ],
+ "Deleting": [
+ ""
+ ],
+ "Changing": [
+ ""
+ ],
+ "Order ID": [
+ ""
+ ],
+ "Payment URL": [
+ ""
+ ]
+ }
+ }
+};
+
+strings['en'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=(n != 1);",
+ "lang": ""
+ },
+ "Cancel": [
+ ""
+ ],
+ "%1$s": [
+ ""
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ ""
+ ],
+ "Clear": [
+ ""
+ ],
+ "Confirm": [
+ ""
+ ],
+ "is not the same as the current access token": [
+ ""
+ ],
+ "cannot be empty": [
+ ""
+ ],
+ "cannot be the same as the old token": [
+ ""
+ ],
+ "is not the same": [
+ ""
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ ""
+ ],
+ "Old access token": [
+ ""
+ ],
+ "access token currently in use": [
+ ""
+ ],
+ "New access token": [
+ ""
+ ],
+ "next access token to be used": [
+ ""
+ ],
+ "Repeat access token": [
+ ""
+ ],
+ "confirm the same access token": [
+ ""
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ ""
+ ],
+ "cannot be the same as the old access token": [
+ ""
+ ],
+ "You are setting the access token for the new instance": [
+ ""
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ ""
+ ],
+ "Set external authorization": [
+ ""
+ ],
+ "Set access token": [
+ ""
+ ],
+ "Operation in progress...": [
+ ""
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ ""
+ ],
+ "Instances": [
+ ""
+ ],
+ "Delete": [
+ ""
+ ],
+ "add new instance": [
+ ""
+ ],
+ "ID": [
+ ""
+ ],
+ "Name": [
+ ""
+ ],
+ "Edit": [
+ ""
+ ],
+ "Purge": [
+ ""
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ ""
+ ],
+ "Only show active instances": [
+ ""
+ ],
+ "Active": [
+ ""
+ ],
+ "Only show deleted instances": [
+ ""
+ ],
+ "Deleted": [
+ ""
+ ],
+ "Show all instances": [
+ ""
+ ],
+ "All": [
+ ""
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ ""
+ ],
+ "Failed to delete instance": [
+ ""
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ ""
+ ],
+ "Failed to purge instance": [
+ ""
+ ],
+ "Pending KYC verification": [
+ ""
+ ],
+ "Timed out": [
+ ""
+ ],
+ "Exchange": [
+ ""
+ ],
+ "Target account": [
+ ""
+ ],
+ "KYC URL": [
+ ""
+ ],
+ "Code": [
+ ""
+ ],
+ "Http Status": [
+ ""
+ ],
+ "No pending kyc verification!": [
+ ""
+ ],
+ "change value to unknown date": [
+ ""
+ ],
+ "change value to empty": [
+ ""
+ ],
+ "clear": [
+ ""
+ ],
+ "change value to never": [
+ ""
+ ],
+ "never": [
+ ""
+ ],
+ "Country": [
+ ""
+ ],
+ "Address": [
+ ""
+ ],
+ "Building number": [
+ ""
+ ],
+ "Building name": [
+ ""
+ ],
+ "Street": [
+ ""
+ ],
+ "Post code": [
+ ""
+ ],
+ "Town location": [
+ ""
+ ],
+ "Town": [
+ ""
+ ],
+ "District": [
+ ""
+ ],
+ "Country subdivision": [
+ ""
+ ],
+ "Product id": [
+ ""
+ ],
+ "Description": [
+ ""
+ ],
+ "Product": [
+ ""
+ ],
+ "search products by it's description or id": [
+ ""
+ ],
+ "no products found with that description": [
+ ""
+ ],
+ "You must enter a valid product identifier.": [
+ ""
+ ],
+ "Quantity must be greater than 0!": [
+ ""
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ ""
+ ],
+ "Quantity": [
+ ""
+ ],
+ "how many products will be added": [
+ ""
+ ],
+ "Add from inventory": [
+ ""
+ ],
+ "Image should be smaller than 1 MB": [
+ ""
+ ],
+ "Add": [
+ ""
+ ],
+ "Remove": [
+ ""
+ ],
+ "No taxes configured for this product.": [
+ ""
+ ],
+ "Amount": [
+ ""
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ ""
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ ""
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ ""
+ ],
+ "add tax to the tax list": [
+ ""
+ ],
+ "describe and add a product that is not in the inventory list": [
+ ""
+ ],
+ "Add custom product": [
+ ""
+ ],
+ "Complete information of the product": [
+ ""
+ ],
+ "Image": [
+ ""
+ ],
+ "photo of the product": [
+ ""
+ ],
+ "full product description": [
+ ""
+ ],
+ "Unit": [
+ ""
+ ],
+ "name of the product unit": [
+ ""
+ ],
+ "Price": [
+ ""
+ ],
+ "amount in the current currency": [
+ ""
+ ],
+ "Taxes": [
+ ""
+ ],
+ "image": [
+ ""
+ ],
+ "description": [
+ ""
+ ],
+ "quantity": [
+ ""
+ ],
+ "unit price": [
+ ""
+ ],
+ "total price": [
+ ""
+ ],
+ "required": [
+ ""
+ ],
+ "not valid": [
+ ""
+ ],
+ "must be greater than 0": [
+ ""
+ ],
+ "not a valid json": [
+ ""
+ ],
+ "should be in the future": [
+ ""
+ ],
+ "refund deadline cannot be before pay deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ ""
+ ],
+ "should have a refund deadline": [
+ ""
+ ],
+ "auto refund cannot be after refund deadline": [
+ ""
+ ],
+ "Manage products in order": [
+ ""
+ ],
+ "Manage list of products in the order.": [
+ ""
+ ],
+ "Remove this product from the order.": [
+ ""
+ ],
+ "Total price": [
+ ""
+ ],
+ "total product price added up": [
+ ""
+ ],
+ "Amount to be paid by the customer": [
+ ""
+ ],
+ "Order price": [
+ ""
+ ],
+ "final order price": [
+ ""
+ ],
+ "Summary": [
+ ""
+ ],
+ "Title of the order to be shown to the customer": [
+ ""
+ ],
+ "Shipping and Fulfillment": [
+ ""
+ ],
+ "Delivery date": [
+ ""
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ ""
+ ],
+ "Location": [
+ ""
+ ],
+ "address where the products will be delivered": [
+ ""
+ ],
+ "Fulfillment URL": [
+ ""
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ ""
+ ],
+ "Taler payment options": [
+ ""
+ ],
+ "Override default Taler payment settings for this order": [
+ ""
+ ],
+ "Payment deadline": [
+ ""
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ ""
+ ],
+ "Refund deadline": [
+ ""
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ ""
+ ],
+ "Wire transfer deadline": [
+ ""
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ ""
+ ],
+ "Auto-refund deadline": [
+ ""
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ ""
+ ],
+ "Maximum deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ ""
+ ],
+ "Maximum wire fee": [
+ ""
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ ""
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ ""
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ ""
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ ""
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ ""
+ ],
+ "hours": [
+ ""
+ ],
+ "minutes": [
+ ""
+ ],
+ "seconds": [
+ ""
+ ],
+ "forever": [
+ ""
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ ""
+ ],
+ "create order": [
+ ""
+ ],
+ "load newer orders": [
+ ""
+ ],
+ "Date": [
+ ""
+ ],
+ "Refund": [
+ ""
+ ],
+ "copy url": [
+ ""
+ ],
+ "load older orders": [
+ ""
+ ],
+ "No orders have been found matching your query!": [
+ ""
+ ],
+ "duplicated": [
+ ""
+ ],
+ "invalid format": [
+ ""
+ ],
+ "this value exceed the refundable amount": [
+ ""
+ ],
+ "date": [
+ ""
+ ],
+ "amount": [
+ ""
+ ],
+ "reason": [
+ ""
+ ],
+ "amount to be refunded": [
+ ""
+ ],
+ "Max refundable:": [
+ ""
+ ],
+ "Reason": [
+ ""
+ ],
+ "Choose one...": [
+ ""
+ ],
+ "requested by the customer": [
+ ""
+ ],
+ "other": [
+ ""
+ ],
+ "why this order is being refunded": [
+ ""
+ ],
+ "more information to give context": [
+ ""
+ ],
+ "Contract Terms": [
+ ""
+ ],
+ "human-readable description of the whole purchase": [
+ ""
+ ],
+ "total price for the transaction": [
+ ""
+ ],
+ "URL for this purchase": [
+ ""
+ ],
+ "Max fee": [
+ ""
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ ""
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ ""
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ ""
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ ""
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ ""
+ ],
+ "claimed": [
+ ""
+ ],
+ "claimed at": [
+ ""
+ ],
+ "Timeline": [
+ ""
+ ],
+ "Payment details": [
+ ""
+ ],
+ "Order status": [
+ ""
+ ],
+ "Product list": [
+ ""
+ ],
+ "paid": [
+ ""
+ ],
+ "wired": [
+ ""
+ ],
+ "refunded": [
+ ""
+ ],
+ "refund order": [
+ ""
+ ],
+ "not refundable": [
+ ""
+ ],
+ "refund": [
+ ""
+ ],
+ "Refunded amount": [
+ ""
+ ],
+ "Refund taken": [
+ ""
+ ],
+ "Status URL": [
+ ""
+ ],
+ "Refund URI": [
+ ""
+ ],
+ "unpaid": [
+ ""
+ ],
+ "pay at": [
+ ""
+ ],
+ "created at": [
+ ""
+ ],
+ "Order status URL": [
+ ""
+ ],
+ "Payment URI": [
+ ""
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ ""
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ ""
+ ],
+ "could not create the refund": [
+ ""
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ ""
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ ""
+ ],
+ "only show orders with refunds": [
+ ""
+ ],
+ "Refunded": [
+ ""
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ ""
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ ""
+ ],
+ "order not found": [
+ ""
+ ],
+ "could not get the order to refund": [
+ ""
+ ],
+ "Loading...": [
+ ""
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ ""
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ ""
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ ""
+ ],
+ "Incoming": [
+ ""
+ ],
+ "Lost": [
+ ""
+ ],
+ "Current": [
+ ""
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ ""
+ ],
+ "Next restock": [
+ ""
+ ],
+ "Delivery address": [
+ ""
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ ""
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ ""
+ ],
+ "Products": [
+ ""
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ ""
+ ],
+ "Profit": [
+ ""
+ ],
+ "Sold": [
+ ""
+ ],
+ "free": [
+ ""
+ ],
+ "go to product update page": [
+ ""
+ ],
+ "Update": [
+ ""
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ ""
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ ""
+ ],
+ "product updated successfully": [
+ ""
+ ],
+ "could not update the product": [
+ ""
+ ],
+ "product delete successfully": [
+ ""
+ ],
+ "could not delete the product": [
+ ""
+ ],
+ "Product id:": [
+ ""
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ ""
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ ""
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ ""
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ ""
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ ""
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ ""
+ ],
+ "Valid until": [
+ ""
+ ],
+ "Created balance": [
+ ""
+ ],
+ "Exchange balance": [
+ ""
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ ""
+ ],
+ "Account address": [
+ ""
+ ],
+ "Subject": [
+ ""
+ ],
+ "Tips": [
+ ""
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ ""
+ ],
+ "Expiration": [
+ ""
+ ],
+ "amount of tip": [
+ ""
+ ],
+ "Justification": [
+ ""
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ ""
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ ""
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ ""
+ ],
+ "Expected Balance": [
+ ""
+ ],
+ "could not create the tip": [
+ ""
+ ],
+ "should not be empty": [
+ ""
+ ],
+ "should be greater that 0": [
+ ""
+ ],
+ "can't be empty": [
+ ""
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ ""
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ ""
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ ""
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ ""
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ ""
+ ],
+ "Amount is required": [
+ ""
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ ""
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ ""
+ ],
+ "could not create order from template": [
+ ""
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ ""
+ ],
+ "Default amount": [
+ ""
+ ],
+ "Default summary": [
+ ""
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ ""
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ ""
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ ""
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ ""
+ ],
+ "template delete successfully": [
+ ""
+ ],
+ "could not delete the template": [
+ ""
+ ],
+ "could not update template": [
+ ""
+ ],
+ "should be one of '%1$s'": [
+ ""
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ ""
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ ""
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ ""
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ ""
+ ],
+ "webhook delete successfully": [
+ ""
+ ],
+ "could not delete the webhook": [
+ ""
+ ],
+ "check the id, does not look valid": [
+ ""
+ ],
+ "should have 52 characters, current %1$s": [
+ ""
+ ],
+ "URL doesn't have the right format": [
+ ""
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ ""
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ ""
+ ],
+ "Transfers": [
+ ""
+ ],
+ "add new transfer": [
+ ""
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ ""
+ ],
+ "Credit": [
+ ""
+ ],
+ "Confirmed": [
+ ""
+ ],
+ "Verified": [
+ ""
+ ],
+ "Executed at": [
+ ""
+ ],
+ "yes": [
+ ""
+ ],
+ "no": [
+ ""
+ ],
+ "unknown": [
+ ""
+ ],
+ "delete selected transfer from the database": [
+ ""
+ ],
+ "load more transfer after the last one": [
+ ""
+ ],
+ "load older transfers": [
+ ""
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ ""
+ ],
+ "filter by account address": [
+ ""
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ ""
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ ""
+ ],
+ "must be 1 or greater": [
+ ""
+ ],
+ "max 7 lines": [
+ ""
+ ],
+ "change authorization configuration": [
+ ""
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ ""
+ ],
+ "This is not a valid bitcoin address.": [
+ ""
+ ],
+ "This is not a valid Ethereum address.": [
+ ""
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ ""
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ ""
+ ],
+ "IBAN country code not found": [
+ ""
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ ""
+ ],
+ "Target type": [
+ ""
+ ],
+ "Method to use for wire transfer": [
+ ""
+ ],
+ "Routing": [
+ ""
+ ],
+ "Routing number.": [
+ ""
+ ],
+ "Account": [
+ ""
+ ],
+ "Account number.": [
+ ""
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ ""
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ ""
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ ""
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ ""
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ ""
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ ""
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ ""
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ ""
+ ],
+ "Default payment delay": [
+ ""
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ ""
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ ""
+ ],
+ "Change the authorization method use for this instance.": [
+ ""
+ ],
+ "Manage access token": [
+ ""
+ ],
+ "Failed to create instance": [
+ ""
+ ],
+ "Login required": [
+ ""
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ ""
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ ""
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ ""
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ ""
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ ""
+ ],
+ "Settings": [
+ ""
+ ],
+ "Connection": [
+ ""
+ ],
+ "New": [
+ ""
+ ],
+ "List": [
+ ""
+ ],
+ "Log out": [
+ ""
+ ],
+ "Check your token is valid": [
+ ""
+ ],
+ "Couldn't access the server.": [
+ ""
+ ],
+ "Could not infer instance id from url %1$s": [
+ ""
+ ],
+ "Server not found": [
+ ""
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ ""
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ ""
+ ],
+ "The value %1$s is invalid for a payment url": [
+ ""
+ ],
+ "add element to the list": [
+ ""
+ ],
+ "add": [
+ ""
+ ],
+ "Deleting": [
+ ""
+ ],
+ "Changing": [
+ ""
+ ],
+ "Order ID": [
+ ""
+ ],
+ "Payment URL": [
+ ""
+ ]
+ }
+ }
+};
+
+strings['es'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=n != 1;",
+ "lang": "es"
+ },
+ "Cancel": [
+ "Cancelar"
+ ],
+ "%1$s": [
+ "%1$s"
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ "Continuar"
+ ],
+ "Clear": [
+ "Limpiar"
+ ],
+ "Confirm": [
+ "Confirmar"
+ ],
+ "is not the same as the current access token": [
+ "no es el mismo que el token de acceso actual"
+ ],
+ "cannot be empty": [
+ "no puede ser vacío"
+ ],
+ "cannot be the same as the old token": [
+ "no puede ser igual al viejo token"
+ ],
+ "is not the same": [
+ "no son iguales"
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ "Está actualizando el token de acceso para la instancia con id %1$s"
+ ],
+ "Old access token": [
+ "Viejo token de acceso"
+ ],
+ "access token currently in use": [
+ "acceder al token en uso actualmente"
+ ],
+ "New access token": [
+ "Nuevo token de acceso"
+ ],
+ "next access token to be used": [
+ "siguiente token de acceso a usar"
+ ],
+ "Repeat access token": [
+ "Repetir token de acceso"
+ ],
+ "confirm the same access token": [
+ "confirmar el mismo token de acceso"
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ "Limpiar el token de acceso significa acceso público a la instancia"
+ ],
+ "cannot be the same as the old access token": [
+ "no puede ser igual al anterior token de acceso"
+ ],
+ "You are setting the access token for the new instance": [
+ "Está estableciendo el token de acceso para la nueva instancia"
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ "Con el método de autorización externa no se hará ninguna revisión por el backend del comerciante"
+ ],
+ "Set external authorization": [
+ "Establecer autorización externa"
+ ],
+ "Set access token": [
+ "Establecer token de acceso"
+ ],
+ "Operation in progress...": [
+ "Operación en progreso..."
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ "La operación será automáticamente cancelada luego de %1$s segundos"
+ ],
+ "Instances": [
+ "Instancias"
+ ],
+ "Delete": [
+ "Eliminar"
+ ],
+ "add new instance": [
+ "agregar nueva instancia"
+ ],
+ "ID": [
+ "ID"
+ ],
+ "Name": [
+ "Nombre"
+ ],
+ "Edit": [
+ "Editar"
+ ],
+ "Purge": [
+ "Purgar"
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ "Todavía no hay instancias, agregue más presionando el signo +"
+ ],
+ "Only show active instances": [
+ "Solo mostrar instancias activas"
+ ],
+ "Active": [
+ "Activo"
+ ],
+ "Only show deleted instances": [
+ "Mostrar solo instancias eliminadas"
+ ],
+ "Deleted": [
+ "Eliminado"
+ ],
+ "Show all instances": [
+ "Mostrar todas las instancias"
+ ],
+ "All": [
+ "Todo"
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ "La instancia '%1$s' (ID: %2$s) fue eliminada"
+ ],
+ "Failed to delete instance": [
+ "Fallo al eliminar instancia"
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ "Instance '%1$s' (ID: %2$s) ha sido deshabilitada"
+ ],
+ "Failed to purge instance": [
+ "Fallo al purgar la instancia"
+ ],
+ "Pending KYC verification": [
+ "Verificación KYC pendiente"
+ ],
+ "Timed out": [
+ "Expirado"
+ ],
+ "Exchange": [
+ "Exchange"
+ ],
+ "Target account": [
+ "Cuenta objetivo"
+ ],
+ "KYC URL": [
+ "URL de KYC"
+ ],
+ "Code": [
+ "Código"
+ ],
+ "Http Status": [
+ "Estado http"
+ ],
+ "No pending kyc verification!": [
+ "¡No hay verificación kyc pendiente!"
+ ],
+ "change value to unknown date": [
+ "cambiar valor a fecha desconocida"
+ ],
+ "change value to empty": [
+ "cambiar valor a vacío"
+ ],
+ "clear": [
+ "limpiar"
+ ],
+ "change value to never": [
+ "cambiar valor a nunca"
+ ],
+ "never": [
+ "nunca"
+ ],
+ "Country": [
+ "País"
+ ],
+ "Address": [
+ "Dirección"
+ ],
+ "Building number": [
+ "Número de edificio"
+ ],
+ "Building name": [
+ "Nombre de edificio"
+ ],
+ "Street": [
+ "Calle"
+ ],
+ "Post code": [
+ "Código postal"
+ ],
+ "Town location": [
+ "Ubicación de ciudad"
+ ],
+ "Town": [
+ "Ciudad"
+ ],
+ "District": [
+ "Distrito"
+ ],
+ "Country subdivision": [
+ "Subdivisión de país"
+ ],
+ "Product id": [
+ "Id de producto"
+ ],
+ "Description": [
+ "Descripcion"
+ ],
+ "Product": [
+ "Productos"
+ ],
+ "search products by it's description or id": [
+ "buscar productos por su descripción o ID"
+ ],
+ "no products found with that description": [
+ "no se encontraron productos con esa descripción"
+ ],
+ "You must enter a valid product identifier.": [
+ "Debe ingresar un identificador de producto válido."
+ ],
+ "Quantity must be greater than 0!": [
+ "¡Cantidad debe ser mayor que 0!"
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ "Esta cantidad excede las existencias restantes. Actualmente, solo quedan %1$s unidades sin reservar en las existencias."
+ ],
+ "Quantity": [
+ "Cantidad"
+ ],
+ "how many products will be added": [
+ "cuántos productos serán agregados"
+ ],
+ "Add from inventory": [
+ "Agregar del inventario"
+ ],
+ "Image should be smaller than 1 MB": [
+ "La imagen debe ser mas chica que 1 MB"
+ ],
+ "Add": [
+ "Agregar"
+ ],
+ "Remove": [
+ "Eliminar"
+ ],
+ "No taxes configured for this product.": [
+ "Ningun impuesto configurado para este producto."
+ ],
+ "Amount": [
+ "Monto"
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ "Impuestos pueden estar en divisas que difieren de la principal divisa usada por el comerciante."
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ "Ingrese divisa y valor separado por dos puntos, e.g. &quot;USD:2.3&quot;."
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ "Nombre legal del impuesto, e.g. IVA o arancel."
+ ],
+ "add tax to the tax list": [
+ "agregar impuesto a la lista de impuestos"
+ ],
+ "describe and add a product that is not in the inventory list": [
+ "describa y agregue un producto que no está en la lista de inventarios"
+ ],
+ "Add custom product": [
+ "Agregue un producto personalizado"
+ ],
+ "Complete information of the product": [
+ "Complete información del producto"
+ ],
+ "Image": [
+ "Imagen"
+ ],
+ "photo of the product": [
+ "foto del producto"
+ ],
+ "full product description": [
+ "descripción completa del producto"
+ ],
+ "Unit": [
+ "Unidad"
+ ],
+ "name of the product unit": [
+ "nombre de la unidad del producto"
+ ],
+ "Price": [
+ "Precio"
+ ],
+ "amount in the current currency": [
+ "monto de la divisa actual"
+ ],
+ "Taxes": [
+ "Impuestos"
+ ],
+ "image": [
+ "imagen"
+ ],
+ "description": [
+ "descripción"
+ ],
+ "quantity": [
+ "cantidad"
+ ],
+ "unit price": [
+ "precio unitario"
+ ],
+ "total price": [
+ "precio total"
+ ],
+ "required": [
+ "requerido"
+ ],
+ "not valid": [
+ "no es un json válido"
+ ],
+ "must be greater than 0": [
+ "debe ser mayor que 0"
+ ],
+ "not a valid json": [
+ "no es un json válido"
+ ],
+ "should be in the future": [
+ "deberían ser en el futuro"
+ ],
+ "refund deadline cannot be before pay deadline": [
+ "plazo de reembolso no puede ser antes que el plazo de pago"
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ "el plazo de la transferencia bancaria no puede ser antes que el plazo de reembolso"
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ "el plazo de la transferencia bancaria no puede ser antes que el plazo de pago"
+ ],
+ "should have a refund deadline": [
+ "debería tener un plazo de reembolso"
+ ],
+ "auto refund cannot be after refund deadline": [
+ "reembolso automático no puede ser después qu el plazo de reembolso"
+ ],
+ "Manage products in order": [
+ "Manejar productos en orden"
+ ],
+ "Manage list of products in the order.": [
+ "Manejar lista de productos en la orden."
+ ],
+ "Remove this product from the order.": [
+ "Remover este producto de la orden."
+ ],
+ "Total price": [
+ "Precio total"
+ ],
+ "total product price added up": [
+ "precio total de producto agregado"
+ ],
+ "Amount to be paid by the customer": [
+ "Monto a ser pagado por el cliente"
+ ],
+ "Order price": [
+ "Precio de la orden"
+ ],
+ "final order price": [
+ "Precio final de la orden"
+ ],
+ "Summary": [
+ "Resumen"
+ ],
+ "Title of the order to be shown to the customer": [
+ "Título de la orden a ser mostrado al cliente"
+ ],
+ "Shipping and Fulfillment": [
+ "Envío y cumplimiento"
+ ],
+ "Delivery date": [
+ "Fecha de entrega"
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ "Plazo para la entrega física asegurado por el comerciante."
+ ],
+ "Location": [
+ "Ubicación"
+ ],
+ "address where the products will be delivered": [
+ "dirección a donde los productos serán entregados"
+ ],
+ "Fulfillment URL": [
+ "URL de cumplimiento"
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ "URL al cual el usuario será redirigido luego de pago exitoso."
+ ],
+ "Taler payment options": [
+ "Opciones de pago de Taler"
+ ],
+ "Override default Taler payment settings for this order": [
+ "Sobreescribir pagos por omisión de Taler para esta orden"
+ ],
+ "Payment deadline": [
+ "Plazo de pago"
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ "Plazo límite para que el cliente pague por la oferta antes de que expire. Productos del inventario serán reservados hasta este plazo límite."
+ ],
+ "Refund deadline": [
+ "Plazo de reembolso"
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ "Tiempo hasta el cual la orden puede ser reembolsada por el comerciante."
+ ],
+ "Wire transfer deadline": [
+ "Plazo de la transferencia"
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ "Plazo para que el exchange haga la transferencia."
+ ],
+ "Auto-refund deadline": [
+ "Plazo de reembolso automático"
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ "Tiempo hasta el cual la billetera será automáticamente revisada por reembolsos win interación por parte del usuario."
+ ],
+ "Maximum deposit fee": [
+ "Máxima tarifa de depósito"
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ "Máxima tarifa de depósito que el comerciante esta dispuesto a cubir para esta orden. Mayores tarifas de depósito deben ser cubiertas completamente por el consumidor."
+ ],
+ "Maximum wire fee": [
+ "Máxima tarifa de transferencia"
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ "Amortización de comisión de transferencia"
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ "Administrar token"
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ "Login necesario"
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ "Información extra"
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ "días"
+ ],
+ "hours": [
+ "horas"
+ ],
+ "minutes": [
+ "minutos"
+ ],
+ "seconds": [
+ "segundos"
+ ],
+ "forever": [
+ "nunca"
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ "Órdenes"
+ ],
+ "create order": [
+ "creado"
+ ],
+ "load newer orders": [
+ "cargar nuevas ordenes"
+ ],
+ "Date": [
+ "Fecha"
+ ],
+ "Refund": [
+ "Devolución"
+ ],
+ "copy url": [
+ "copiar url"
+ ],
+ "load older orders": [
+ "cargar viejas ordenes"
+ ],
+ "No orders have been found matching your query!": [
+ "¡No se encontraron órdenes que emparejen su búsqueda!"
+ ],
+ "duplicated": [
+ "duplicado"
+ ],
+ "invalid format": [
+ "formato inválido"
+ ],
+ "this value exceed the refundable amount": [
+ "este monto excede el monto reembolsable"
+ ],
+ "date": [
+ "fecha"
+ ],
+ "amount": [
+ "monto"
+ ],
+ "reason": [
+ "razón"
+ ],
+ "amount to be refunded": [
+ "monto a ser reembolsado"
+ ],
+ "Max refundable:": [
+ "Máximo reembolzable:"
+ ],
+ "Reason": [
+ "Razón"
+ ],
+ "Choose one...": [
+ "Elija uno..."
+ ],
+ "requested by the customer": [
+ "pedido por el consumidor"
+ ],
+ "other": [
+ "otro"
+ ],
+ "why this order is being refunded": [
+ "por qué esta orden está siendo reembolsada"
+ ],
+ "more information to give context": [
+ "más información para dar contexto"
+ ],
+ "Contract Terms": [
+ "Términos de contrato"
+ ],
+ "human-readable description of the whole purchase": [
+ "descripción legible de toda la compra"
+ ],
+ "total price for the transaction": [
+ "precio total de la transacción"
+ ],
+ "URL for this purchase": [
+ "URL para esta compra"
+ ],
+ "Max fee": [
+ "Máxima comisión"
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ "Impuesto de transferencia máximo"
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ "Creado en"
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ "Plazo de reembolso automático"
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ "Información extra"
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ "Orden"
+ ],
+ "claimed": [
+ "reclamado"
+ ],
+ "claimed at": [
+ "reclamado"
+ ],
+ "Timeline": [
+ "Cronología"
+ ],
+ "Payment details": [
+ "Detalles de pago"
+ ],
+ "Order status": [
+ "Estado de orden"
+ ],
+ "Product list": [
+ "Lista de producto"
+ ],
+ "paid": [
+ "pagados"
+ ],
+ "wired": [
+ "transferido"
+ ],
+ "refunded": [
+ "reembolzado"
+ ],
+ "refund order": [
+ "reembolzado"
+ ],
+ "not refundable": [
+ "Máximo reembolzable:"
+ ],
+ "refund": [
+ "reembolzar"
+ ],
+ "Refunded amount": [
+ "Monto reembolzado"
+ ],
+ "Refund taken": [
+ "Reembolzado"
+ ],
+ "Status URL": [
+ "URL de estado de orden"
+ ],
+ "Refund URI": [
+ "Devolución"
+ ],
+ "unpaid": [
+ "impago"
+ ],
+ "pay at": [
+ "pagar en"
+ ],
+ "created at": [
+ "creado"
+ ],
+ "Order status URL": [
+ "URL de estado de orden"
+ ],
+ "Payment URI": [
+ "URI de pago"
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ "Estado de orden desconocido. Esto es un error, por favor contacte a su administrador."
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ "reembolzo creado satisfactoriamente"
+ ],
+ "could not create the refund": [
+ "No se pudo create el reembolso"
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ "ir a id de orden"
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ "Pagado"
+ ],
+ "only show orders with refunds": [
+ "No se pudo create el reembolso"
+ ],
+ "Refunded": [
+ "Reembolzado"
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ "No transferido"
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ "ir a id de orden"
+ ],
+ "order not found": [
+ "Servidor no encontrado"
+ ],
+ "could not get the order to refund": [
+ "No se pudo create el reembolso"
+ ],
+ "Loading...": [
+ "Cargando..."
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ "Administrar stock"
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ "Inifinito"
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ "la pérdida no puede ser mayor al stock actual + entrante (max %1$s )"
+ ],
+ "Incoming": [
+ "Ingresando"
+ ],
+ "Lost": [
+ "Perdido"
+ ],
+ "Current": [
+ "Actual"
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ "sin stock"
+ ],
+ "Next restock": [
+ "Próximo reabastecimiento"
+ ],
+ "Delivery address": [
+ "Dirección de entrega"
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ "Existencias"
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ "no se pudo crear el producto"
+ ],
+ "Products": [
+ "Productos"
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ "Venta"
+ ],
+ "Profit": [
+ "Ganancia"
+ ],
+ "Sold": [
+ "Vendido"
+ ],
+ "free": [
+ "Gratis"
+ ],
+ "go to product update page": [
+ "producto actualizado correctamente"
+ ],
+ "Update": [
+ "Actualizar"
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ "no se pudo actualizar el producto"
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ "No hay propinas todavía, agregar mas presionando el signo +"
+ ],
+ "product updated successfully": [
+ "producto actualizado correctamente"
+ ],
+ "could not update the product": [
+ "no se pudo actualizar el producto"
+ ],
+ "product delete successfully": [
+ "producto fue eliminado correctamente"
+ ],
+ "could not delete the product": [
+ "no se pudo eliminar el producto"
+ ],
+ "Product id:": [
+ "Id de producto"
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ "Debe ser mayor a 0"
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ "Instancia"
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ "URL del Exchange"
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ "Siguiente"
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ "no se pudo informar la transferencia"
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ "No se pudo create el reembolso"
+ ],
+ "Valid until": [
+ "Válido hasta"
+ ],
+ "Created balance": [
+ "creado"
+ ],
+ "Exchange balance": [
+ "Monto inicial"
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ "Monto confirmado"
+ ],
+ "Account address": [
+ "Dirección de cuenta"
+ ],
+ "Subject": [
+ "Asunto"
+ ],
+ "Tips": [
+ "Propinas"
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ "Token de autorización"
+ ],
+ "Expiration": [
+ "Información extra"
+ ],
+ "amount of tip": [
+ "monto"
+ ],
+ "Justification": [
+ "Jurisdicción"
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ "Servidor no encontrado"
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ "cargar nuevas transferencias"
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ "No hay transferencias todavía, agregar mas presionando el signo +"
+ ],
+ "Expected Balance": [
+ "Ejecutado en"
+ ],
+ "could not create the tip": [
+ "No se pudo create el reembolso"
+ ],
+ "should not be empty": [
+ "no puede ser vacío"
+ ],
+ "should be greater that 0": [
+ "Debe ser mayor a 0"
+ ],
+ "can't be empty": [
+ "no puede ser vacío"
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ "Estado de orden"
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ "precio unitario"
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ "Edad mínima"
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ "Opciones de pago"
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ "no se pudo informar la transferencia"
+ ],
+ "Amount is required": [
+ "Login necesario"
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ "cargar viejas transferencias"
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ "Estado de orden"
+ ],
+ "could not create order from template": [
+ "No se pudo create el reembolso"
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ "Monto reembolzado"
+ ],
+ "Default amount": [
+ "Monto reembolzado"
+ ],
+ "Default summary": [
+ "Estado de orden"
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ "cargar nuevas transferencias"
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ "No se pudo create el reembolso"
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ "cargar viejas transferencias"
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ "No hay propinas todavía, agregar mas presionando el signo +"
+ ],
+ "template delete successfully": [
+ "producto fue eliminado correctamente"
+ ],
+ "could not delete the template": [
+ "no se pudo eliminar el producto"
+ ],
+ "could not update template": [
+ "no se pudo actualizar el producto"
+ ],
+ "should be one of '%1$s'": [
+ "deberían ser iguales"
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ "URL"
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ "cargar nuevas ordenes"
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ "cargar viejas ordenes"
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ "No hay propinas todavía, agregar mas presionando el signo +"
+ ],
+ "webhook delete successfully": [
+ "producto fue eliminado correctamente"
+ ],
+ "could not delete the webhook": [
+ "no se pudo eliminar el producto"
+ ],
+ "check the id, does not look valid": [
+ "verificar el id, no parece válido"
+ ],
+ "should have 52 characters, current %1$s": [
+ "debería tener 52 caracteres, actualmente %1$s"
+ ],
+ "URL doesn't have the right format": [
+ "La URL no tiene el formato correcto"
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ "Id de transferencia"
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ "no se pudo informar la transferencia"
+ ],
+ "Transfers": [
+ "Transferencias"
+ ],
+ "add new transfer": [
+ "cargar nuevas transferencias"
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ "cargar nuevas transferencias"
+ ],
+ "Credit": [
+ "Crédito"
+ ],
+ "Confirmed": [
+ "Confirmado"
+ ],
+ "Verified": [
+ "Verificado"
+ ],
+ "Executed at": [
+ "Ejecutado en"
+ ],
+ "yes": [
+ "si"
+ ],
+ "no": [
+ "no"
+ ],
+ "unknown": [
+ "desconocido"
+ ],
+ "delete selected transfer from the database": [
+ "eliminar transferencia seleccionada de la base de datos"
+ ],
+ "load more transfer after the last one": [
+ "cargue más transferencia luego de la última"
+ ],
+ "load older transfers": [
+ "cargar viejas transferencias"
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ "No hay transferencias todavía, agregar mas presionando el signo +"
+ ],
+ "filter by account address": [
+ "Dirección de cuenta"
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ "Verificado"
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ "Número de edificio"
+ ],
+ "must be 1 or greater": [
+ "debe ser 1 o mayor"
+ ],
+ "max 7 lines": [
+ "máximo 7 líneas"
+ ],
+ "change authorization configuration": [
+ "cambiar configuración de autorización"
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ "Necesita completar campos marcados y escoger un método de autorización"
+ ],
+ "This is not a valid bitcoin address.": [
+ "Esta no es una dirección de bitcoin válida."
+ ],
+ "This is not a valid Ethereum address.": [
+ "Esta no es una dirección de Ethereum válida."
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ "Números IBAN usualmente tienen más de 4 dígitos"
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ "Número IBAN usualmente tienen menos de 34 dígitos"
+ ],
+ "IBAN country code not found": [
+ "Código IBAN de país no encontrado"
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ "Número IBAN no es válido, la suma de verificación es incorrecta"
+ ],
+ "Target type": [
+ "Tipo objetivo"
+ ],
+ "Method to use for wire transfer": [
+ "Método a usar para la transferencia"
+ ],
+ "Routing": [
+ "Enrutamiento"
+ ],
+ "Routing number.": [
+ "Número de enrutamiento."
+ ],
+ "Account": [
+ "Cuenta"
+ ],
+ "Account number.": [
+ "Dirección de cuenta"
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ "Interfaz de pago unificado."
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ "Nombre de edificio"
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ "URL de sitio web"
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ "Cuenta bancaria"
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ "Impuesto máximo de deposito por omisión"
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ "Impuesto máximo de transferencia por omisión"
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ "Amortización de impuesto de transferencia por omisión"
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ "Jurisdicción"
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ "Jurisdicción para disputas legales con el comerciante."
+ ],
+ "Default payment delay": [
+ "Retrazo de pago por omisión"
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ "Retrazo de transferencia por omisión"
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ "ID de instancia"
+ ],
+ "Change the authorization method use for this instance.": [
+ "Limpiar el token de autorización significa acceso público a la instancia"
+ ],
+ "Manage access token": [
+ "Administrar token de acceso"
+ ],
+ "Failed to create instance": [
+ "Fallo al crear la instancia"
+ ],
+ "Login required": [
+ "Login necesario"
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ "Acceso denegado"
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ "Servidir reporto un problema: HTTP status #%1$s"
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ "Acceso denegado"
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ "Sin instancia default"
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ "Instancia"
+ ],
+ "Settings": [
+ "Configuración"
+ ],
+ "Connection": [
+ "Conexión"
+ ],
+ "New": [
+ "Nuevo"
+ ],
+ "List": [
+ "Lista"
+ ],
+ "Log out": [
+ "Salir"
+ ],
+ "Check your token is valid": [
+ "Verifica que el token sea valido"
+ ],
+ "Couldn't access the server.": [
+ "No se pudo acceder al servidor."
+ ],
+ "Could not infer instance id from url %1$s": [
+ "No se pudo inferir el id de la instancia con la url %1$s"
+ ],
+ "Server not found": [
+ "Servidor no encontrado"
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ "Recibimos el mensaje %1$s desde %2$s"
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ "Error inesperado"
+ ],
+ "The value %1$s is invalid for a payment url": [
+ "El valor %1$s es invalido para una URL de pago"
+ ],
+ "add element to the list": [
+ "agregar elemento a la lista"
+ ],
+ "add": [
+ "Agregar"
+ ],
+ "Deleting": [
+ "Borrando"
+ ],
+ "Changing": [
+ "Cambiando"
+ ],
+ "Order ID": [
+ "ID de pedido"
+ ],
+ "Payment URL": [
+ "URL de pago"
+ ]
+ }
+ }
+};
+
+strings['fr'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=(n != 1);",
+ "lang": ""
+ },
+ "Cancel": [
+ ""
+ ],
+ "%1$s": [
+ ""
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ ""
+ ],
+ "Clear": [
+ ""
+ ],
+ "Confirm": [
+ ""
+ ],
+ "is not the same as the current access token": [
+ ""
+ ],
+ "cannot be empty": [
+ ""
+ ],
+ "cannot be the same as the old token": [
+ ""
+ ],
+ "is not the same": [
+ ""
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ ""
+ ],
+ "Old access token": [
+ ""
+ ],
+ "access token currently in use": [
+ ""
+ ],
+ "New access token": [
+ ""
+ ],
+ "next access token to be used": [
+ ""
+ ],
+ "Repeat access token": [
+ ""
+ ],
+ "confirm the same access token": [
+ ""
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ ""
+ ],
+ "cannot be the same as the old access token": [
+ ""
+ ],
+ "You are setting the access token for the new instance": [
+ ""
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ ""
+ ],
+ "Set external authorization": [
+ ""
+ ],
+ "Set access token": [
+ ""
+ ],
+ "Operation in progress...": [
+ ""
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ ""
+ ],
+ "Instances": [
+ ""
+ ],
+ "Delete": [
+ ""
+ ],
+ "add new instance": [
+ ""
+ ],
+ "ID": [
+ ""
+ ],
+ "Name": [
+ ""
+ ],
+ "Edit": [
+ ""
+ ],
+ "Purge": [
+ ""
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ ""
+ ],
+ "Only show active instances": [
+ ""
+ ],
+ "Active": [
+ ""
+ ],
+ "Only show deleted instances": [
+ ""
+ ],
+ "Deleted": [
+ ""
+ ],
+ "Show all instances": [
+ ""
+ ],
+ "All": [
+ ""
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ ""
+ ],
+ "Failed to delete instance": [
+ ""
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ ""
+ ],
+ "Failed to purge instance": [
+ ""
+ ],
+ "Pending KYC verification": [
+ ""
+ ],
+ "Timed out": [
+ ""
+ ],
+ "Exchange": [
+ ""
+ ],
+ "Target account": [
+ ""
+ ],
+ "KYC URL": [
+ ""
+ ],
+ "Code": [
+ ""
+ ],
+ "Http Status": [
+ ""
+ ],
+ "No pending kyc verification!": [
+ ""
+ ],
+ "change value to unknown date": [
+ ""
+ ],
+ "change value to empty": [
+ ""
+ ],
+ "clear": [
+ ""
+ ],
+ "change value to never": [
+ ""
+ ],
+ "never": [
+ ""
+ ],
+ "Country": [
+ ""
+ ],
+ "Address": [
+ ""
+ ],
+ "Building number": [
+ ""
+ ],
+ "Building name": [
+ ""
+ ],
+ "Street": [
+ ""
+ ],
+ "Post code": [
+ ""
+ ],
+ "Town location": [
+ ""
+ ],
+ "Town": [
+ ""
+ ],
+ "District": [
+ ""
+ ],
+ "Country subdivision": [
+ ""
+ ],
+ "Product id": [
+ ""
+ ],
+ "Description": [
+ ""
+ ],
+ "Product": [
+ ""
+ ],
+ "search products by it's description or id": [
+ ""
+ ],
+ "no products found with that description": [
+ ""
+ ],
+ "You must enter a valid product identifier.": [
+ ""
+ ],
+ "Quantity must be greater than 0!": [
+ ""
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ ""
+ ],
+ "Quantity": [
+ ""
+ ],
+ "how many products will be added": [
+ ""
+ ],
+ "Add from inventory": [
+ ""
+ ],
+ "Image should be smaller than 1 MB": [
+ ""
+ ],
+ "Add": [
+ ""
+ ],
+ "Remove": [
+ ""
+ ],
+ "No taxes configured for this product.": [
+ ""
+ ],
+ "Amount": [
+ ""
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ ""
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ ""
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ ""
+ ],
+ "add tax to the tax list": [
+ ""
+ ],
+ "describe and add a product that is not in the inventory list": [
+ ""
+ ],
+ "Add custom product": [
+ ""
+ ],
+ "Complete information of the product": [
+ ""
+ ],
+ "Image": [
+ ""
+ ],
+ "photo of the product": [
+ ""
+ ],
+ "full product description": [
+ ""
+ ],
+ "Unit": [
+ ""
+ ],
+ "name of the product unit": [
+ ""
+ ],
+ "Price": [
+ ""
+ ],
+ "amount in the current currency": [
+ ""
+ ],
+ "Taxes": [
+ ""
+ ],
+ "image": [
+ ""
+ ],
+ "description": [
+ ""
+ ],
+ "quantity": [
+ ""
+ ],
+ "unit price": [
+ ""
+ ],
+ "total price": [
+ ""
+ ],
+ "required": [
+ ""
+ ],
+ "not valid": [
+ ""
+ ],
+ "must be greater than 0": [
+ ""
+ ],
+ "not a valid json": [
+ ""
+ ],
+ "should be in the future": [
+ ""
+ ],
+ "refund deadline cannot be before pay deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ ""
+ ],
+ "should have a refund deadline": [
+ ""
+ ],
+ "auto refund cannot be after refund deadline": [
+ ""
+ ],
+ "Manage products in order": [
+ ""
+ ],
+ "Manage list of products in the order.": [
+ ""
+ ],
+ "Remove this product from the order.": [
+ ""
+ ],
+ "Total price": [
+ ""
+ ],
+ "total product price added up": [
+ ""
+ ],
+ "Amount to be paid by the customer": [
+ ""
+ ],
+ "Order price": [
+ ""
+ ],
+ "final order price": [
+ ""
+ ],
+ "Summary": [
+ ""
+ ],
+ "Title of the order to be shown to the customer": [
+ ""
+ ],
+ "Shipping and Fulfillment": [
+ ""
+ ],
+ "Delivery date": [
+ ""
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ ""
+ ],
+ "Location": [
+ ""
+ ],
+ "address where the products will be delivered": [
+ ""
+ ],
+ "Fulfillment URL": [
+ ""
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ ""
+ ],
+ "Taler payment options": [
+ ""
+ ],
+ "Override default Taler payment settings for this order": [
+ ""
+ ],
+ "Payment deadline": [
+ ""
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ ""
+ ],
+ "Refund deadline": [
+ ""
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ ""
+ ],
+ "Wire transfer deadline": [
+ ""
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ ""
+ ],
+ "Auto-refund deadline": [
+ ""
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ ""
+ ],
+ "Maximum deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ ""
+ ],
+ "Maximum wire fee": [
+ ""
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ ""
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ ""
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ ""
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ ""
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ ""
+ ],
+ "hours": [
+ ""
+ ],
+ "minutes": [
+ ""
+ ],
+ "seconds": [
+ ""
+ ],
+ "forever": [
+ ""
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ ""
+ ],
+ "create order": [
+ ""
+ ],
+ "load newer orders": [
+ ""
+ ],
+ "Date": [
+ ""
+ ],
+ "Refund": [
+ ""
+ ],
+ "copy url": [
+ ""
+ ],
+ "load older orders": [
+ ""
+ ],
+ "No orders have been found matching your query!": [
+ ""
+ ],
+ "duplicated": [
+ ""
+ ],
+ "invalid format": [
+ ""
+ ],
+ "this value exceed the refundable amount": [
+ ""
+ ],
+ "date": [
+ ""
+ ],
+ "amount": [
+ ""
+ ],
+ "reason": [
+ ""
+ ],
+ "amount to be refunded": [
+ ""
+ ],
+ "Max refundable:": [
+ ""
+ ],
+ "Reason": [
+ ""
+ ],
+ "Choose one...": [
+ ""
+ ],
+ "requested by the customer": [
+ ""
+ ],
+ "other": [
+ ""
+ ],
+ "why this order is being refunded": [
+ ""
+ ],
+ "more information to give context": [
+ ""
+ ],
+ "Contract Terms": [
+ ""
+ ],
+ "human-readable description of the whole purchase": [
+ ""
+ ],
+ "total price for the transaction": [
+ ""
+ ],
+ "URL for this purchase": [
+ ""
+ ],
+ "Max fee": [
+ ""
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ ""
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ ""
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ ""
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ ""
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ ""
+ ],
+ "claimed": [
+ ""
+ ],
+ "claimed at": [
+ ""
+ ],
+ "Timeline": [
+ ""
+ ],
+ "Payment details": [
+ ""
+ ],
+ "Order status": [
+ ""
+ ],
+ "Product list": [
+ ""
+ ],
+ "paid": [
+ ""
+ ],
+ "wired": [
+ ""
+ ],
+ "refunded": [
+ ""
+ ],
+ "refund order": [
+ ""
+ ],
+ "not refundable": [
+ ""
+ ],
+ "refund": [
+ ""
+ ],
+ "Refunded amount": [
+ ""
+ ],
+ "Refund taken": [
+ ""
+ ],
+ "Status URL": [
+ ""
+ ],
+ "Refund URI": [
+ ""
+ ],
+ "unpaid": [
+ ""
+ ],
+ "pay at": [
+ ""
+ ],
+ "created at": [
+ ""
+ ],
+ "Order status URL": [
+ ""
+ ],
+ "Payment URI": [
+ ""
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ ""
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ ""
+ ],
+ "could not create the refund": [
+ ""
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ ""
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ ""
+ ],
+ "only show orders with refunds": [
+ ""
+ ],
+ "Refunded": [
+ ""
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ ""
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ ""
+ ],
+ "order not found": [
+ ""
+ ],
+ "could not get the order to refund": [
+ ""
+ ],
+ "Loading...": [
+ ""
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ ""
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ ""
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ ""
+ ],
+ "Incoming": [
+ ""
+ ],
+ "Lost": [
+ ""
+ ],
+ "Current": [
+ ""
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ ""
+ ],
+ "Next restock": [
+ ""
+ ],
+ "Delivery address": [
+ ""
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ ""
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ ""
+ ],
+ "Products": [
+ ""
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ ""
+ ],
+ "Profit": [
+ ""
+ ],
+ "Sold": [
+ ""
+ ],
+ "free": [
+ ""
+ ],
+ "go to product update page": [
+ ""
+ ],
+ "Update": [
+ ""
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ ""
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ ""
+ ],
+ "product updated successfully": [
+ ""
+ ],
+ "could not update the product": [
+ ""
+ ],
+ "product delete successfully": [
+ ""
+ ],
+ "could not delete the product": [
+ ""
+ ],
+ "Product id:": [
+ ""
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ ""
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ ""
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ ""
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ ""
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ ""
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ ""
+ ],
+ "Valid until": [
+ ""
+ ],
+ "Created balance": [
+ ""
+ ],
+ "Exchange balance": [
+ ""
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ ""
+ ],
+ "Account address": [
+ ""
+ ],
+ "Subject": [
+ ""
+ ],
+ "Tips": [
+ ""
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ ""
+ ],
+ "Expiration": [
+ ""
+ ],
+ "amount of tip": [
+ ""
+ ],
+ "Justification": [
+ ""
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ ""
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ ""
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ ""
+ ],
+ "Expected Balance": [
+ ""
+ ],
+ "could not create the tip": [
+ ""
+ ],
+ "should not be empty": [
+ ""
+ ],
+ "should be greater that 0": [
+ ""
+ ],
+ "can't be empty": [
+ ""
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ ""
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ ""
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ ""
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ ""
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ ""
+ ],
+ "Amount is required": [
+ ""
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ ""
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ ""
+ ],
+ "could not create order from template": [
+ ""
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ ""
+ ],
+ "Default amount": [
+ ""
+ ],
+ "Default summary": [
+ ""
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ ""
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ ""
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ ""
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ ""
+ ],
+ "template delete successfully": [
+ ""
+ ],
+ "could not delete the template": [
+ ""
+ ],
+ "could not update template": [
+ ""
+ ],
+ "should be one of '%1$s'": [
+ ""
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ ""
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ ""
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ ""
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ ""
+ ],
+ "webhook delete successfully": [
+ ""
+ ],
+ "could not delete the webhook": [
+ ""
+ ],
+ "check the id, does not look valid": [
+ ""
+ ],
+ "should have 52 characters, current %1$s": [
+ ""
+ ],
+ "URL doesn't have the right format": [
+ ""
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ ""
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ ""
+ ],
+ "Transfers": [
+ ""
+ ],
+ "add new transfer": [
+ ""
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ ""
+ ],
+ "Credit": [
+ ""
+ ],
+ "Confirmed": [
+ ""
+ ],
+ "Verified": [
+ ""
+ ],
+ "Executed at": [
+ ""
+ ],
+ "yes": [
+ ""
+ ],
+ "no": [
+ ""
+ ],
+ "unknown": [
+ ""
+ ],
+ "delete selected transfer from the database": [
+ ""
+ ],
+ "load more transfer after the last one": [
+ ""
+ ],
+ "load older transfers": [
+ ""
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ ""
+ ],
+ "filter by account address": [
+ ""
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ ""
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ ""
+ ],
+ "must be 1 or greater": [
+ ""
+ ],
+ "max 7 lines": [
+ ""
+ ],
+ "change authorization configuration": [
+ ""
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ ""
+ ],
+ "This is not a valid bitcoin address.": [
+ ""
+ ],
+ "This is not a valid Ethereum address.": [
+ ""
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ ""
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ ""
+ ],
+ "IBAN country code not found": [
+ ""
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ ""
+ ],
+ "Target type": [
+ ""
+ ],
+ "Method to use for wire transfer": [
+ ""
+ ],
+ "Routing": [
+ ""
+ ],
+ "Routing number.": [
+ ""
+ ],
+ "Account": [
+ ""
+ ],
+ "Account number.": [
+ ""
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ ""
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ ""
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ ""
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ ""
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ ""
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ ""
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ ""
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ ""
+ ],
+ "Default payment delay": [
+ ""
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ ""
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ ""
+ ],
+ "Change the authorization method use for this instance.": [
+ ""
+ ],
+ "Manage access token": [
+ ""
+ ],
+ "Failed to create instance": [
+ ""
+ ],
+ "Login required": [
+ ""
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ ""
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ ""
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ ""
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ ""
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ ""
+ ],
+ "Settings": [
+ ""
+ ],
+ "Connection": [
+ ""
+ ],
+ "New": [
+ ""
+ ],
+ "List": [
+ ""
+ ],
+ "Log out": [
+ ""
+ ],
+ "Check your token is valid": [
+ ""
+ ],
+ "Couldn't access the server.": [
+ ""
+ ],
+ "Could not infer instance id from url %1$s": [
+ ""
+ ],
+ "Server not found": [
+ ""
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ ""
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ ""
+ ],
+ "The value %1$s is invalid for a payment url": [
+ ""
+ ],
+ "add element to the list": [
+ ""
+ ],
+ "add": [
+ ""
+ ],
+ "Deleting": [
+ ""
+ ],
+ "Changing": [
+ ""
+ ],
+ "Order ID": [
+ ""
+ ],
+ "Payment URL": [
+ ""
+ ]
+ }
+ }
+};
+
+strings['it'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=(n != 1);",
+ "lang": ""
+ },
+ "Cancel": [
+ ""
+ ],
+ "%1$s": [
+ ""
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ ""
+ ],
+ "Clear": [
+ ""
+ ],
+ "Confirm": [
+ ""
+ ],
+ "is not the same as the current access token": [
+ ""
+ ],
+ "cannot be empty": [
+ ""
+ ],
+ "cannot be the same as the old token": [
+ ""
+ ],
+ "is not the same": [
+ ""
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ ""
+ ],
+ "Old access token": [
+ ""
+ ],
+ "access token currently in use": [
+ ""
+ ],
+ "New access token": [
+ ""
+ ],
+ "next access token to be used": [
+ ""
+ ],
+ "Repeat access token": [
+ ""
+ ],
+ "confirm the same access token": [
+ ""
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ ""
+ ],
+ "cannot be the same as the old access token": [
+ ""
+ ],
+ "You are setting the access token for the new instance": [
+ ""
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ ""
+ ],
+ "Set external authorization": [
+ ""
+ ],
+ "Set access token": [
+ ""
+ ],
+ "Operation in progress...": [
+ ""
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ ""
+ ],
+ "Instances": [
+ ""
+ ],
+ "Delete": [
+ ""
+ ],
+ "add new instance": [
+ ""
+ ],
+ "ID": [
+ ""
+ ],
+ "Name": [
+ ""
+ ],
+ "Edit": [
+ ""
+ ],
+ "Purge": [
+ ""
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ ""
+ ],
+ "Only show active instances": [
+ ""
+ ],
+ "Active": [
+ ""
+ ],
+ "Only show deleted instances": [
+ ""
+ ],
+ "Deleted": [
+ ""
+ ],
+ "Show all instances": [
+ ""
+ ],
+ "All": [
+ ""
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ ""
+ ],
+ "Failed to delete instance": [
+ ""
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ ""
+ ],
+ "Failed to purge instance": [
+ ""
+ ],
+ "Pending KYC verification": [
+ ""
+ ],
+ "Timed out": [
+ ""
+ ],
+ "Exchange": [
+ ""
+ ],
+ "Target account": [
+ ""
+ ],
+ "KYC URL": [
+ ""
+ ],
+ "Code": [
+ ""
+ ],
+ "Http Status": [
+ ""
+ ],
+ "No pending kyc verification!": [
+ ""
+ ],
+ "change value to unknown date": [
+ ""
+ ],
+ "change value to empty": [
+ ""
+ ],
+ "clear": [
+ ""
+ ],
+ "change value to never": [
+ ""
+ ],
+ "never": [
+ ""
+ ],
+ "Country": [
+ ""
+ ],
+ "Address": [
+ ""
+ ],
+ "Building number": [
+ ""
+ ],
+ "Building name": [
+ ""
+ ],
+ "Street": [
+ ""
+ ],
+ "Post code": [
+ ""
+ ],
+ "Town location": [
+ ""
+ ],
+ "Town": [
+ ""
+ ],
+ "District": [
+ ""
+ ],
+ "Country subdivision": [
+ ""
+ ],
+ "Product id": [
+ ""
+ ],
+ "Description": [
+ ""
+ ],
+ "Product": [
+ ""
+ ],
+ "search products by it's description or id": [
+ ""
+ ],
+ "no products found with that description": [
+ ""
+ ],
+ "You must enter a valid product identifier.": [
+ ""
+ ],
+ "Quantity must be greater than 0!": [
+ ""
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ ""
+ ],
+ "Quantity": [
+ ""
+ ],
+ "how many products will be added": [
+ ""
+ ],
+ "Add from inventory": [
+ ""
+ ],
+ "Image should be smaller than 1 MB": [
+ ""
+ ],
+ "Add": [
+ ""
+ ],
+ "Remove": [
+ ""
+ ],
+ "No taxes configured for this product.": [
+ ""
+ ],
+ "Amount": [
+ ""
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ ""
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ ""
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ ""
+ ],
+ "add tax to the tax list": [
+ ""
+ ],
+ "describe and add a product that is not in the inventory list": [
+ ""
+ ],
+ "Add custom product": [
+ ""
+ ],
+ "Complete information of the product": [
+ ""
+ ],
+ "Image": [
+ ""
+ ],
+ "photo of the product": [
+ ""
+ ],
+ "full product description": [
+ ""
+ ],
+ "Unit": [
+ ""
+ ],
+ "name of the product unit": [
+ ""
+ ],
+ "Price": [
+ ""
+ ],
+ "amount in the current currency": [
+ ""
+ ],
+ "Taxes": [
+ ""
+ ],
+ "image": [
+ ""
+ ],
+ "description": [
+ ""
+ ],
+ "quantity": [
+ ""
+ ],
+ "unit price": [
+ ""
+ ],
+ "total price": [
+ ""
+ ],
+ "required": [
+ ""
+ ],
+ "not valid": [
+ ""
+ ],
+ "must be greater than 0": [
+ ""
+ ],
+ "not a valid json": [
+ ""
+ ],
+ "should be in the future": [
+ ""
+ ],
+ "refund deadline cannot be before pay deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ ""
+ ],
+ "should have a refund deadline": [
+ ""
+ ],
+ "auto refund cannot be after refund deadline": [
+ ""
+ ],
+ "Manage products in order": [
+ ""
+ ],
+ "Manage list of products in the order.": [
+ ""
+ ],
+ "Remove this product from the order.": [
+ ""
+ ],
+ "Total price": [
+ ""
+ ],
+ "total product price added up": [
+ ""
+ ],
+ "Amount to be paid by the customer": [
+ ""
+ ],
+ "Order price": [
+ ""
+ ],
+ "final order price": [
+ ""
+ ],
+ "Summary": [
+ ""
+ ],
+ "Title of the order to be shown to the customer": [
+ ""
+ ],
+ "Shipping and Fulfillment": [
+ ""
+ ],
+ "Delivery date": [
+ ""
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ ""
+ ],
+ "Location": [
+ ""
+ ],
+ "address where the products will be delivered": [
+ ""
+ ],
+ "Fulfillment URL": [
+ ""
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ ""
+ ],
+ "Taler payment options": [
+ ""
+ ],
+ "Override default Taler payment settings for this order": [
+ ""
+ ],
+ "Payment deadline": [
+ ""
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ ""
+ ],
+ "Refund deadline": [
+ ""
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ ""
+ ],
+ "Wire transfer deadline": [
+ ""
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ ""
+ ],
+ "Auto-refund deadline": [
+ ""
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ ""
+ ],
+ "Maximum deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ ""
+ ],
+ "Maximum wire fee": [
+ ""
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ ""
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ ""
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ ""
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ ""
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ ""
+ ],
+ "hours": [
+ ""
+ ],
+ "minutes": [
+ ""
+ ],
+ "seconds": [
+ ""
+ ],
+ "forever": [
+ ""
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ ""
+ ],
+ "create order": [
+ ""
+ ],
+ "load newer orders": [
+ ""
+ ],
+ "Date": [
+ ""
+ ],
+ "Refund": [
+ ""
+ ],
+ "copy url": [
+ ""
+ ],
+ "load older orders": [
+ ""
+ ],
+ "No orders have been found matching your query!": [
+ ""
+ ],
+ "duplicated": [
+ ""
+ ],
+ "invalid format": [
+ ""
+ ],
+ "this value exceed the refundable amount": [
+ ""
+ ],
+ "date": [
+ ""
+ ],
+ "amount": [
+ ""
+ ],
+ "reason": [
+ ""
+ ],
+ "amount to be refunded": [
+ ""
+ ],
+ "Max refundable:": [
+ ""
+ ],
+ "Reason": [
+ ""
+ ],
+ "Choose one...": [
+ ""
+ ],
+ "requested by the customer": [
+ ""
+ ],
+ "other": [
+ ""
+ ],
+ "why this order is being refunded": [
+ ""
+ ],
+ "more information to give context": [
+ ""
+ ],
+ "Contract Terms": [
+ ""
+ ],
+ "human-readable description of the whole purchase": [
+ ""
+ ],
+ "total price for the transaction": [
+ ""
+ ],
+ "URL for this purchase": [
+ ""
+ ],
+ "Max fee": [
+ ""
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ ""
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ ""
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ ""
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ ""
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ ""
+ ],
+ "claimed": [
+ ""
+ ],
+ "claimed at": [
+ ""
+ ],
+ "Timeline": [
+ ""
+ ],
+ "Payment details": [
+ ""
+ ],
+ "Order status": [
+ ""
+ ],
+ "Product list": [
+ ""
+ ],
+ "paid": [
+ ""
+ ],
+ "wired": [
+ ""
+ ],
+ "refunded": [
+ ""
+ ],
+ "refund order": [
+ ""
+ ],
+ "not refundable": [
+ ""
+ ],
+ "refund": [
+ ""
+ ],
+ "Refunded amount": [
+ ""
+ ],
+ "Refund taken": [
+ ""
+ ],
+ "Status URL": [
+ ""
+ ],
+ "Refund URI": [
+ ""
+ ],
+ "unpaid": [
+ ""
+ ],
+ "pay at": [
+ ""
+ ],
+ "created at": [
+ ""
+ ],
+ "Order status URL": [
+ ""
+ ],
+ "Payment URI": [
+ ""
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ ""
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ ""
+ ],
+ "could not create the refund": [
+ ""
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ ""
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ ""
+ ],
+ "only show orders with refunds": [
+ ""
+ ],
+ "Refunded": [
+ ""
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ ""
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ ""
+ ],
+ "order not found": [
+ ""
+ ],
+ "could not get the order to refund": [
+ ""
+ ],
+ "Loading...": [
+ ""
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ ""
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ ""
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ ""
+ ],
+ "Incoming": [
+ ""
+ ],
+ "Lost": [
+ ""
+ ],
+ "Current": [
+ ""
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ ""
+ ],
+ "Next restock": [
+ ""
+ ],
+ "Delivery address": [
+ ""
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ ""
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ ""
+ ],
+ "Products": [
+ ""
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ ""
+ ],
+ "Profit": [
+ ""
+ ],
+ "Sold": [
+ ""
+ ],
+ "free": [
+ ""
+ ],
+ "go to product update page": [
+ ""
+ ],
+ "Update": [
+ ""
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ ""
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ ""
+ ],
+ "product updated successfully": [
+ ""
+ ],
+ "could not update the product": [
+ ""
+ ],
+ "product delete successfully": [
+ ""
+ ],
+ "could not delete the product": [
+ ""
+ ],
+ "Product id:": [
+ ""
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ ""
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ ""
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ ""
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ ""
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ ""
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ ""
+ ],
+ "Valid until": [
+ ""
+ ],
+ "Created balance": [
+ ""
+ ],
+ "Exchange balance": [
+ ""
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ ""
+ ],
+ "Account address": [
+ ""
+ ],
+ "Subject": [
+ ""
+ ],
+ "Tips": [
+ ""
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ ""
+ ],
+ "Expiration": [
+ ""
+ ],
+ "amount of tip": [
+ ""
+ ],
+ "Justification": [
+ ""
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ ""
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ ""
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ ""
+ ],
+ "Expected Balance": [
+ ""
+ ],
+ "could not create the tip": [
+ ""
+ ],
+ "should not be empty": [
+ ""
+ ],
+ "should be greater that 0": [
+ ""
+ ],
+ "can't be empty": [
+ ""
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ ""
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ ""
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ ""
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ ""
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ ""
+ ],
+ "Amount is required": [
+ ""
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ ""
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ ""
+ ],
+ "could not create order from template": [
+ ""
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ ""
+ ],
+ "Default amount": [
+ ""
+ ],
+ "Default summary": [
+ ""
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ ""
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ ""
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ ""
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ ""
+ ],
+ "template delete successfully": [
+ ""
+ ],
+ "could not delete the template": [
+ ""
+ ],
+ "could not update template": [
+ ""
+ ],
+ "should be one of '%1$s'": [
+ ""
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ ""
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ ""
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ ""
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ ""
+ ],
+ "webhook delete successfully": [
+ ""
+ ],
+ "could not delete the webhook": [
+ ""
+ ],
+ "check the id, does not look valid": [
+ ""
+ ],
+ "should have 52 characters, current %1$s": [
+ ""
+ ],
+ "URL doesn't have the right format": [
+ ""
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ ""
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ ""
+ ],
+ "Transfers": [
+ ""
+ ],
+ "add new transfer": [
+ ""
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ ""
+ ],
+ "Credit": [
+ ""
+ ],
+ "Confirmed": [
+ ""
+ ],
+ "Verified": [
+ ""
+ ],
+ "Executed at": [
+ ""
+ ],
+ "yes": [
+ ""
+ ],
+ "no": [
+ ""
+ ],
+ "unknown": [
+ ""
+ ],
+ "delete selected transfer from the database": [
+ ""
+ ],
+ "load more transfer after the last one": [
+ ""
+ ],
+ "load older transfers": [
+ ""
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ ""
+ ],
+ "filter by account address": [
+ ""
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ ""
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ ""
+ ],
+ "must be 1 or greater": [
+ ""
+ ],
+ "max 7 lines": [
+ ""
+ ],
+ "change authorization configuration": [
+ ""
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ ""
+ ],
+ "This is not a valid bitcoin address.": [
+ ""
+ ],
+ "This is not a valid Ethereum address.": [
+ ""
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ ""
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ ""
+ ],
+ "IBAN country code not found": [
+ ""
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ ""
+ ],
+ "Target type": [
+ ""
+ ],
+ "Method to use for wire transfer": [
+ ""
+ ],
+ "Routing": [
+ ""
+ ],
+ "Routing number.": [
+ ""
+ ],
+ "Account": [
+ ""
+ ],
+ "Account number.": [
+ ""
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ ""
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ ""
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ ""
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ ""
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ ""
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ ""
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ ""
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ ""
+ ],
+ "Default payment delay": [
+ ""
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ ""
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ ""
+ ],
+ "Change the authorization method use for this instance.": [
+ ""
+ ],
+ "Manage access token": [
+ ""
+ ],
+ "Failed to create instance": [
+ ""
+ ],
+ "Login required": [
+ ""
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ ""
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ ""
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ ""
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ ""
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ ""
+ ],
+ "Settings": [
+ ""
+ ],
+ "Connection": [
+ ""
+ ],
+ "New": [
+ ""
+ ],
+ "List": [
+ ""
+ ],
+ "Log out": [
+ ""
+ ],
+ "Check your token is valid": [
+ ""
+ ],
+ "Couldn't access the server.": [
+ ""
+ ],
+ "Could not infer instance id from url %1$s": [
+ ""
+ ],
+ "Server not found": [
+ ""
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ ""
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ ""
+ ],
+ "The value %1$s is invalid for a payment url": [
+ ""
+ ],
+ "add element to the list": [
+ ""
+ ],
+ "add": [
+ ""
+ ],
+ "Deleting": [
+ ""
+ ],
+ "Changing": [
+ ""
+ ],
+ "Order ID": [
+ ""
+ ],
+ "Payment URL": [
+ ""
+ ]
+ }
+ }
+};
+
+strings['sv'] = {
+ "domain": "messages",
+ "locale_data": {
+ "messages": {
+ "": {
+ "domain": "messages",
+ "plural_forms": "nplurals=2; plural=(n != 1);",
+ "lang": ""
+ },
+ "Cancel": [
+ ""
+ ],
+ "%1$s": [
+ ""
+ ],
+ "Close": [
+ ""
+ ],
+ "Continue": [
+ ""
+ ],
+ "Clear": [
+ ""
+ ],
+ "Confirm": [
+ ""
+ ],
+ "is not the same as the current access token": [
+ ""
+ ],
+ "cannot be empty": [
+ ""
+ ],
+ "cannot be the same as the old token": [
+ ""
+ ],
+ "is not the same": [
+ ""
+ ],
+ "You are updating the access token from instance with id %1$s": [
+ ""
+ ],
+ "Old access token": [
+ ""
+ ],
+ "access token currently in use": [
+ ""
+ ],
+ "New access token": [
+ ""
+ ],
+ "next access token to be used": [
+ ""
+ ],
+ "Repeat access token": [
+ ""
+ ],
+ "confirm the same access token": [
+ ""
+ ],
+ "Clearing the access token will mean public access to the instance": [
+ ""
+ ],
+ "cannot be the same as the old access token": [
+ ""
+ ],
+ "You are setting the access token for the new instance": [
+ ""
+ ],
+ "With external authorization method no check will be done by the merchant backend": [
+ ""
+ ],
+ "Set external authorization": [
+ ""
+ ],
+ "Set access token": [
+ ""
+ ],
+ "Operation in progress...": [
+ ""
+ ],
+ "The operation will be automatically canceled after %1$s seconds": [
+ ""
+ ],
+ "Instances": [
+ ""
+ ],
+ "Delete": [
+ ""
+ ],
+ "add new instance": [
+ ""
+ ],
+ "ID": [
+ ""
+ ],
+ "Name": [
+ ""
+ ],
+ "Edit": [
+ ""
+ ],
+ "Purge": [
+ ""
+ ],
+ "There is no instances yet, add more pressing the + sign": [
+ ""
+ ],
+ "Only show active instances": [
+ ""
+ ],
+ "Active": [
+ ""
+ ],
+ "Only show deleted instances": [
+ ""
+ ],
+ "Deleted": [
+ ""
+ ],
+ "Show all instances": [
+ ""
+ ],
+ "All": [
+ ""
+ ],
+ "Instance \"%1$s\" (ID: %2$s) has been deleted": [
+ ""
+ ],
+ "Failed to delete instance": [
+ ""
+ ],
+ "Instance '%1$s' (ID: %2$s) has been disabled": [
+ ""
+ ],
+ "Failed to purge instance": [
+ ""
+ ],
+ "Pending KYC verification": [
+ ""
+ ],
+ "Timed out": [
+ ""
+ ],
+ "Exchange": [
+ ""
+ ],
+ "Target account": [
+ ""
+ ],
+ "KYC URL": [
+ ""
+ ],
+ "Code": [
+ ""
+ ],
+ "Http Status": [
+ ""
+ ],
+ "No pending kyc verification!": [
+ ""
+ ],
+ "change value to unknown date": [
+ ""
+ ],
+ "change value to empty": [
+ ""
+ ],
+ "clear": [
+ ""
+ ],
+ "change value to never": [
+ ""
+ ],
+ "never": [
+ ""
+ ],
+ "Country": [
+ ""
+ ],
+ "Address": [
+ ""
+ ],
+ "Building number": [
+ ""
+ ],
+ "Building name": [
+ ""
+ ],
+ "Street": [
+ ""
+ ],
+ "Post code": [
+ ""
+ ],
+ "Town location": [
+ ""
+ ],
+ "Town": [
+ ""
+ ],
+ "District": [
+ ""
+ ],
+ "Country subdivision": [
+ ""
+ ],
+ "Product id": [
+ ""
+ ],
+ "Description": [
+ ""
+ ],
+ "Product": [
+ ""
+ ],
+ "search products by it's description or id": [
+ ""
+ ],
+ "no products found with that description": [
+ ""
+ ],
+ "You must enter a valid product identifier.": [
+ ""
+ ],
+ "Quantity must be greater than 0!": [
+ ""
+ ],
+ "This quantity exceeds remaining stock. Currently, only %1$s units remain unreserved in stock.": [
+ ""
+ ],
+ "Quantity": [
+ ""
+ ],
+ "how many products will be added": [
+ ""
+ ],
+ "Add from inventory": [
+ ""
+ ],
+ "Image should be smaller than 1 MB": [
+ ""
+ ],
+ "Add": [
+ ""
+ ],
+ "Remove": [
+ ""
+ ],
+ "No taxes configured for this product.": [
+ ""
+ ],
+ "Amount": [
+ ""
+ ],
+ "Taxes can be in currencies that differ from the main currency used by the merchant.": [
+ ""
+ ],
+ "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;.": [
+ ""
+ ],
+ "Legal name of the tax, e.g. VAT or import duties.": [
+ ""
+ ],
+ "add tax to the tax list": [
+ ""
+ ],
+ "describe and add a product that is not in the inventory list": [
+ ""
+ ],
+ "Add custom product": [
+ ""
+ ],
+ "Complete information of the product": [
+ ""
+ ],
+ "Image": [
+ ""
+ ],
+ "photo of the product": [
+ ""
+ ],
+ "full product description": [
+ ""
+ ],
+ "Unit": [
+ ""
+ ],
+ "name of the product unit": [
+ ""
+ ],
+ "Price": [
+ ""
+ ],
+ "amount in the current currency": [
+ ""
+ ],
+ "Taxes": [
+ ""
+ ],
+ "image": [
+ ""
+ ],
+ "description": [
+ ""
+ ],
+ "quantity": [
+ ""
+ ],
+ "unit price": [
+ ""
+ ],
+ "total price": [
+ ""
+ ],
+ "required": [
+ ""
+ ],
+ "not valid": [
+ ""
+ ],
+ "must be greater than 0": [
+ ""
+ ],
+ "not a valid json": [
+ ""
+ ],
+ "should be in the future": [
+ ""
+ ],
+ "refund deadline cannot be before pay deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before refund deadline": [
+ ""
+ ],
+ "wire transfer deadline cannot be before pay deadline": [
+ ""
+ ],
+ "should have a refund deadline": [
+ ""
+ ],
+ "auto refund cannot be after refund deadline": [
+ ""
+ ],
+ "Manage products in order": [
+ ""
+ ],
+ "Manage list of products in the order.": [
+ ""
+ ],
+ "Remove this product from the order.": [
+ ""
+ ],
+ "Total price": [
+ ""
+ ],
+ "total product price added up": [
+ ""
+ ],
+ "Amount to be paid by the customer": [
+ ""
+ ],
+ "Order price": [
+ ""
+ ],
+ "final order price": [
+ ""
+ ],
+ "Summary": [
+ ""
+ ],
+ "Title of the order to be shown to the customer": [
+ ""
+ ],
+ "Shipping and Fulfillment": [
+ ""
+ ],
+ "Delivery date": [
+ ""
+ ],
+ "Deadline for physical delivery assured by the merchant.": [
+ ""
+ ],
+ "Location": [
+ ""
+ ],
+ "address where the products will be delivered": [
+ ""
+ ],
+ "Fulfillment URL": [
+ ""
+ ],
+ "URL to which the user will be redirected after successful payment.": [
+ ""
+ ],
+ "Taler payment options": [
+ ""
+ ],
+ "Override default Taler payment settings for this order": [
+ ""
+ ],
+ "Payment deadline": [
+ ""
+ ],
+ "Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.": [
+ ""
+ ],
+ "Refund deadline": [
+ ""
+ ],
+ "Time until which the order can be refunded by the merchant.": [
+ ""
+ ],
+ "Wire transfer deadline": [
+ ""
+ ],
+ "Deadline for the exchange to make the wire transfer.": [
+ ""
+ ],
+ "Auto-refund deadline": [
+ ""
+ ],
+ "Time until which the wallet will automatically check for refunds without user interaction.": [
+ ""
+ ],
+ "Maximum deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.": [
+ ""
+ ],
+ "Maximum wire fee": [
+ ""
+ ],
+ "Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.": [
+ ""
+ ],
+ "Wire fee amortization": [
+ ""
+ ],
+ "Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.": [
+ ""
+ ],
+ "Create token": [
+ ""
+ ],
+ "Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.": [
+ ""
+ ],
+ "Minimum age required": [
+ ""
+ ],
+ "Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products": [
+ ""
+ ],
+ "Min age defined by the producs is %1$s": [
+ ""
+ ],
+ "Additional information": [
+ ""
+ ],
+ "Custom information to be included in the contract for this order.": [
+ ""
+ ],
+ "You must enter a value in JavaScript Object Notation (JSON).": [
+ ""
+ ],
+ "days": [
+ ""
+ ],
+ "hours": [
+ ""
+ ],
+ "minutes": [
+ ""
+ ],
+ "seconds": [
+ ""
+ ],
+ "forever": [
+ ""
+ ],
+ "%1$sM": [
+ ""
+ ],
+ "%1$sY": [
+ ""
+ ],
+ "%1$sd": [
+ ""
+ ],
+ "%1$sh": [
+ ""
+ ],
+ "%1$smin": [
+ ""
+ ],
+ "%1$ssec": [
+ ""
+ ],
+ "Orders": [
+ ""
+ ],
+ "create order": [
+ ""
+ ],
+ "load newer orders": [
+ ""
+ ],
+ "Date": [
+ ""
+ ],
+ "Refund": [
+ ""
+ ],
+ "copy url": [
+ ""
+ ],
+ "load older orders": [
+ ""
+ ],
+ "No orders have been found matching your query!": [
+ ""
+ ],
+ "duplicated": [
+ ""
+ ],
+ "invalid format": [
+ ""
+ ],
+ "this value exceed the refundable amount": [
+ ""
+ ],
+ "date": [
+ ""
+ ],
+ "amount": [
+ ""
+ ],
+ "reason": [
+ ""
+ ],
+ "amount to be refunded": [
+ ""
+ ],
+ "Max refundable:": [
+ ""
+ ],
+ "Reason": [
+ ""
+ ],
+ "Choose one...": [
+ ""
+ ],
+ "requested by the customer": [
+ ""
+ ],
+ "other": [
+ ""
+ ],
+ "why this order is being refunded": [
+ ""
+ ],
+ "more information to give context": [
+ ""
+ ],
+ "Contract Terms": [
+ ""
+ ],
+ "human-readable description of the whole purchase": [
+ ""
+ ],
+ "total price for the transaction": [
+ ""
+ ],
+ "URL for this purchase": [
+ ""
+ ],
+ "Max fee": [
+ ""
+ ],
+ "maximum total deposit fee accepted by the merchant for this contract": [
+ ""
+ ],
+ "Max wire fee": [
+ ""
+ ],
+ "maximum wire fee accepted by the merchant": [
+ ""
+ ],
+ "over how many customer transactions does the merchant expect to amortize wire fees on average": [
+ ""
+ ],
+ "Created at": [
+ ""
+ ],
+ "time when this contract was generated": [
+ ""
+ ],
+ "after this deadline has passed no refunds will be accepted": [
+ ""
+ ],
+ "after this deadline, the merchant won't accept payments for the contract": [
+ ""
+ ],
+ "transfer deadline for the exchange": [
+ ""
+ ],
+ "time indicating when the order should be delivered": [
+ ""
+ ],
+ "where the order will be delivered": [
+ ""
+ ],
+ "Auto-refund delay": [
+ ""
+ ],
+ "how long the wallet should try to get an automatic refund for the purchase": [
+ ""
+ ],
+ "Extra info": [
+ ""
+ ],
+ "extra data that is only interpreted by the merchant frontend": [
+ ""
+ ],
+ "Order": [
+ ""
+ ],
+ "claimed": [
+ ""
+ ],
+ "claimed at": [
+ ""
+ ],
+ "Timeline": [
+ ""
+ ],
+ "Payment details": [
+ ""
+ ],
+ "Order status": [
+ ""
+ ],
+ "Product list": [
+ ""
+ ],
+ "paid": [
+ ""
+ ],
+ "wired": [
+ ""
+ ],
+ "refunded": [
+ ""
+ ],
+ "refund order": [
+ ""
+ ],
+ "not refundable": [
+ ""
+ ],
+ "refund": [
+ ""
+ ],
+ "Refunded amount": [
+ ""
+ ],
+ "Refund taken": [
+ ""
+ ],
+ "Status URL": [
+ ""
+ ],
+ "Refund URI": [
+ ""
+ ],
+ "unpaid": [
+ ""
+ ],
+ "pay at": [
+ ""
+ ],
+ "created at": [
+ ""
+ ],
+ "Order status URL": [
+ ""
+ ],
+ "Payment URI": [
+ ""
+ ],
+ "Unknown order status. This is an error, please contact the administrator.": [
+ ""
+ ],
+ "Back": [
+ ""
+ ],
+ "refund created successfully": [
+ ""
+ ],
+ "could not create the refund": [
+ ""
+ ],
+ "select date to show nearby orders": [
+ ""
+ ],
+ "order id": [
+ ""
+ ],
+ "jump to order with the given order ID": [
+ ""
+ ],
+ "remove all filters": [
+ ""
+ ],
+ "only show paid orders": [
+ ""
+ ],
+ "Paid": [
+ ""
+ ],
+ "only show orders with refunds": [
+ ""
+ ],
+ "Refunded": [
+ ""
+ ],
+ "only show orders where customers paid, but wire payments from payment provider are still pending": [
+ ""
+ ],
+ "Not wired": [
+ ""
+ ],
+ "clear date filter": [
+ ""
+ ],
+ "date (YYYY/MM/DD)": [
+ ""
+ ],
+ "Enter an order id": [
+ ""
+ ],
+ "order not found": [
+ ""
+ ],
+ "could not get the order to refund": [
+ ""
+ ],
+ "Loading...": [
+ ""
+ ],
+ "click here to configure the stock of the product, leave it as is and the backend will not control stock": [
+ ""
+ ],
+ "Manage stock": [
+ ""
+ ],
+ "this product has been configured without stock control": [
+ ""
+ ],
+ "Infinite": [
+ ""
+ ],
+ "lost cannot be greater than current and incoming (max %1$s)": [
+ ""
+ ],
+ "Incoming": [
+ ""
+ ],
+ "Lost": [
+ ""
+ ],
+ "Current": [
+ ""
+ ],
+ "remove stock control for this product": [
+ ""
+ ],
+ "without stock": [
+ ""
+ ],
+ "Next restock": [
+ ""
+ ],
+ "Delivery address": [
+ ""
+ ],
+ "product identification to use in URLs (for internal use only)": [
+ ""
+ ],
+ "illustration of the product for customers": [
+ ""
+ ],
+ "product description for customers": [
+ ""
+ ],
+ "Age restricted": [
+ ""
+ ],
+ "is this product restricted for customer below certain age?": [
+ ""
+ ],
+ "unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers": [
+ ""
+ ],
+ "sale price for customers, including taxes, for above units of the product": [
+ ""
+ ],
+ "Stock": [
+ ""
+ ],
+ "product inventory for products with finite supply (for internal use only)": [
+ ""
+ ],
+ "taxes included in the product price, exposed to customers": [
+ ""
+ ],
+ "Need to complete marked fields": [
+ ""
+ ],
+ "could not create product": [
+ ""
+ ],
+ "Products": [
+ ""
+ ],
+ "add product to inventory": [
+ ""
+ ],
+ "Sell": [
+ ""
+ ],
+ "Profit": [
+ ""
+ ],
+ "Sold": [
+ ""
+ ],
+ "free": [
+ ""
+ ],
+ "go to product update page": [
+ ""
+ ],
+ "Update": [
+ ""
+ ],
+ "remove this product from the database": [
+ ""
+ ],
+ "update the product with new price": [
+ ""
+ ],
+ "update product with new price": [
+ ""
+ ],
+ "add more elements to the inventory": [
+ ""
+ ],
+ "report elements lost in the inventory": [
+ ""
+ ],
+ "new price for the product": [
+ ""
+ ],
+ "the are value with errors": [
+ ""
+ ],
+ "update product with new stock and price": [
+ ""
+ ],
+ "There is no products yet, add more pressing the + sign": [
+ ""
+ ],
+ "product updated successfully": [
+ ""
+ ],
+ "could not update the product": [
+ ""
+ ],
+ "product delete successfully": [
+ ""
+ ],
+ "could not delete the product": [
+ ""
+ ],
+ "Product id:": [
+ ""
+ ],
+ "To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.": [
+ ""
+ ],
+ "If your system supports RFC 8905, you can do this by opening this URI:": [
+ ""
+ ],
+ "it should be greater than 0": [
+ ""
+ ],
+ "must be a valid URL": [
+ ""
+ ],
+ "Initial balance": [
+ ""
+ ],
+ "balance prior to deposit": [
+ ""
+ ],
+ "Exchange URL": [
+ ""
+ ],
+ "URL of exchange": [
+ ""
+ ],
+ "Next": [
+ ""
+ ],
+ "Wire method": [
+ ""
+ ],
+ "method to use for wire transfer": [
+ ""
+ ],
+ "Select one wire method": [
+ ""
+ ],
+ "could not create reserve": [
+ ""
+ ],
+ "Valid until": [
+ ""
+ ],
+ "Created balance": [
+ ""
+ ],
+ "Exchange balance": [
+ ""
+ ],
+ "Picked up": [
+ ""
+ ],
+ "Committed": [
+ ""
+ ],
+ "Account address": [
+ ""
+ ],
+ "Subject": [
+ ""
+ ],
+ "Tips": [
+ ""
+ ],
+ "No tips has been authorized from this reserve": [
+ ""
+ ],
+ "Authorized": [
+ ""
+ ],
+ "Expiration": [
+ ""
+ ],
+ "amount of tip": [
+ ""
+ ],
+ "Justification": [
+ ""
+ ],
+ "reason for the tip": [
+ ""
+ ],
+ "URL after tip": [
+ ""
+ ],
+ "URL to visit after tip payment": [
+ ""
+ ],
+ "Reserves not yet funded": [
+ ""
+ ],
+ "Reserves ready": [
+ ""
+ ],
+ "add new reserve": [
+ ""
+ ],
+ "Expires at": [
+ ""
+ ],
+ "Initial": [
+ ""
+ ],
+ "delete selected reserve from the database": [
+ ""
+ ],
+ "authorize new tip from selected reserve": [
+ ""
+ ],
+ "There is no ready reserves yet, add more pressing the + sign or fund them": [
+ ""
+ ],
+ "Expected Balance": [
+ ""
+ ],
+ "could not create the tip": [
+ ""
+ ],
+ "should not be empty": [
+ ""
+ ],
+ "should be greater that 0": [
+ ""
+ ],
+ "can't be empty": [
+ ""
+ ],
+ "to short": [
+ ""
+ ],
+ "just letters and numbers from 2 to 7": [
+ ""
+ ],
+ "size of the key should be 32": [
+ ""
+ ],
+ "Identifier": [
+ ""
+ ],
+ "Name of the template in URLs.": [
+ ""
+ ],
+ "Describe what this template stands for": [
+ ""
+ ],
+ "Fixed summary": [
+ ""
+ ],
+ "If specified, this template will create order with the same summary": [
+ ""
+ ],
+ "Fixed price": [
+ ""
+ ],
+ "If specified, this template will create order with the same price": [
+ ""
+ ],
+ "Minimum age": [
+ ""
+ ],
+ "Is this contract restricted to some age?": [
+ ""
+ ],
+ "Payment timeout": [
+ ""
+ ],
+ "How much time has the customer to complete the payment once the order was created.": [
+ ""
+ ],
+ "Verification algorithm": [
+ ""
+ ],
+ "Algorithm to use to verify transaction in offline mode": [
+ ""
+ ],
+ "Point-of-sale key": [
+ ""
+ ],
+ "Useful to validate the purchase": [
+ ""
+ ],
+ "generate random secret key": [
+ ""
+ ],
+ "random": [
+ ""
+ ],
+ "show secret key": [
+ ""
+ ],
+ "hide secret key": [
+ ""
+ ],
+ "hide": [
+ ""
+ ],
+ "show": [
+ ""
+ ],
+ "could not inform template": [
+ ""
+ ],
+ "Amount is required": [
+ ""
+ ],
+ "Order summary is required": [
+ ""
+ ],
+ "New order for template": [
+ ""
+ ],
+ "Amount of the order": [
+ ""
+ ],
+ "Order summary": [
+ ""
+ ],
+ "could not create order from template": [
+ ""
+ ],
+ "Here you can specify a default value for fields that are not fixed. Default values can be edited by the customer before the payment.": [
+ ""
+ ],
+ "Fixed amount": [
+ ""
+ ],
+ "Default amount": [
+ ""
+ ],
+ "Default summary": [
+ ""
+ ],
+ "Print": [
+ ""
+ ],
+ "Setup TOTP": [
+ ""
+ ],
+ "Templates": [
+ ""
+ ],
+ "add new templates": [
+ ""
+ ],
+ "load more templates before the first one": [
+ ""
+ ],
+ "load newer templates": [
+ ""
+ ],
+ "delete selected templates from the database": [
+ ""
+ ],
+ "use template to create new order": [
+ ""
+ ],
+ "create qr code for the template": [
+ ""
+ ],
+ "load more templates after the last one": [
+ ""
+ ],
+ "load older templates": [
+ ""
+ ],
+ "There is no templates yet, add more pressing the + sign": [
+ ""
+ ],
+ "template delete successfully": [
+ ""
+ ],
+ "could not delete the template": [
+ ""
+ ],
+ "could not update template": [
+ ""
+ ],
+ "should be one of '%1$s'": [
+ ""
+ ],
+ "Webhook ID to use": [
+ ""
+ ],
+ "Event": [
+ ""
+ ],
+ "The event of the webhook: why the webhook is used": [
+ ""
+ ],
+ "Method": [
+ ""
+ ],
+ "Method used by the webhook": [
+ ""
+ ],
+ "URL": [
+ ""
+ ],
+ "URL of the webhook where the customer will be redirected": [
+ ""
+ ],
+ "Header": [
+ ""
+ ],
+ "Header template of the webhook": [
+ ""
+ ],
+ "Body": [
+ ""
+ ],
+ "Body template by the webhook": [
+ ""
+ ],
+ "Webhooks": [
+ ""
+ ],
+ "add new webhooks": [
+ ""
+ ],
+ "load more webhooks before the first one": [
+ ""
+ ],
+ "load newer webhooks": [
+ ""
+ ],
+ "Event type": [
+ ""
+ ],
+ "delete selected webhook from the database": [
+ ""
+ ],
+ "load more webhooks after the last one": [
+ ""
+ ],
+ "load older webhooks": [
+ ""
+ ],
+ "There is no webhooks yet, add more pressing the + sign": [
+ ""
+ ],
+ "webhook delete successfully": [
+ ""
+ ],
+ "could not delete the webhook": [
+ ""
+ ],
+ "check the id, does not look valid": [
+ ""
+ ],
+ "should have 52 characters, current %1$s": [
+ ""
+ ],
+ "URL doesn't have the right format": [
+ ""
+ ],
+ "Credited bank account": [
+ ""
+ ],
+ "Select one account": [
+ ""
+ ],
+ "Bank account of the merchant where the payment was received": [
+ ""
+ ],
+ "Wire transfer ID": [
+ ""
+ ],
+ "unique identifier of the wire transfer used by the exchange, must be 52 characters long": [
+ ""
+ ],
+ "Base URL of the exchange that made the transfer, should have been in the wire transfer subject": [
+ ""
+ ],
+ "Amount credited": [
+ ""
+ ],
+ "Actual amount that was wired to the merchant's bank account": [
+ ""
+ ],
+ "could not inform transfer": [
+ ""
+ ],
+ "Transfers": [
+ ""
+ ],
+ "add new transfer": [
+ ""
+ ],
+ "load more transfers before the first one": [
+ ""
+ ],
+ "load newer transfers": [
+ ""
+ ],
+ "Credit": [
+ ""
+ ],
+ "Confirmed": [
+ ""
+ ],
+ "Verified": [
+ ""
+ ],
+ "Executed at": [
+ ""
+ ],
+ "yes": [
+ ""
+ ],
+ "no": [
+ ""
+ ],
+ "unknown": [
+ ""
+ ],
+ "delete selected transfer from the database": [
+ ""
+ ],
+ "load more transfer after the last one": [
+ ""
+ ],
+ "load older transfers": [
+ ""
+ ],
+ "There is no transfer yet, add more pressing the + sign": [
+ ""
+ ],
+ "filter by account address": [
+ ""
+ ],
+ "only show wire transfers confirmed by the merchant": [
+ ""
+ ],
+ "only show wire transfers claimed by the exchange": [
+ ""
+ ],
+ "Unverified": [
+ ""
+ ],
+ "is not valid": [
+ ""
+ ],
+ "is not a number": [
+ ""
+ ],
+ "must be 1 or greater": [
+ ""
+ ],
+ "max 7 lines": [
+ ""
+ ],
+ "change authorization configuration": [
+ ""
+ ],
+ "Need to complete marked fields and choose authorization method": [
+ ""
+ ],
+ "This is not a valid bitcoin address.": [
+ ""
+ ],
+ "This is not a valid Ethereum address.": [
+ ""
+ ],
+ "IBAN numbers usually have more that 4 digits": [
+ ""
+ ],
+ "IBAN numbers usually have less that 34 digits": [
+ ""
+ ],
+ "IBAN country code not found": [
+ ""
+ ],
+ "IBAN number is not valid, checksum is wrong": [
+ ""
+ ],
+ "Target type": [
+ ""
+ ],
+ "Method to use for wire transfer": [
+ ""
+ ],
+ "Routing": [
+ ""
+ ],
+ "Routing number.": [
+ ""
+ ],
+ "Account": [
+ ""
+ ],
+ "Account number.": [
+ ""
+ ],
+ "Business Identifier Code.": [
+ ""
+ ],
+ "Bank Account Number.": [
+ ""
+ ],
+ "Unified Payment Interface.": [
+ ""
+ ],
+ "Bitcoin protocol.": [
+ ""
+ ],
+ "Ethereum protocol.": [
+ ""
+ ],
+ "Interledger protocol.": [
+ ""
+ ],
+ "Host": [
+ ""
+ ],
+ "Bank host.": [
+ ""
+ ],
+ "Bank account.": [
+ ""
+ ],
+ "Bank account owner's name.": [
+ ""
+ ],
+ "No accounts yet.": [
+ ""
+ ],
+ "Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.": [
+ ""
+ ],
+ "Business name": [
+ ""
+ ],
+ "Legal name of the business represented by this instance.": [
+ ""
+ ],
+ "Email": [
+ ""
+ ],
+ "Contact email": [
+ ""
+ ],
+ "Website URL": [
+ ""
+ ],
+ "URL.": [
+ ""
+ ],
+ "Logo": [
+ ""
+ ],
+ "Logo image.": [
+ ""
+ ],
+ "Bank account": [
+ ""
+ ],
+ "URI specifying bank account for crediting revenue.": [
+ ""
+ ],
+ "Default max deposit fee": [
+ ""
+ ],
+ "Maximum deposit fees this merchant is willing to pay per order by default.": [
+ ""
+ ],
+ "Default max wire fee": [
+ ""
+ ],
+ "Maximum wire fees this merchant is willing to pay per wire transfer by default.": [
+ ""
+ ],
+ "Default wire fee amortization": [
+ ""
+ ],
+ "Number of orders excess wire transfer fees will be divided by to compute per order surcharge.": [
+ ""
+ ],
+ "Physical location of the merchant.": [
+ ""
+ ],
+ "Jurisdiction": [
+ ""
+ ],
+ "Jurisdiction for legal disputes with the merchant.": [
+ ""
+ ],
+ "Default payment delay": [
+ ""
+ ],
+ "Time customers have to pay an order before the offer expires by default.": [
+ ""
+ ],
+ "Default wire transfer delay": [
+ ""
+ ],
+ "Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.": [
+ ""
+ ],
+ "Instance id": [
+ ""
+ ],
+ "Change the authorization method use for this instance.": [
+ ""
+ ],
+ "Manage access token": [
+ ""
+ ],
+ "Failed to create instance": [
+ ""
+ ],
+ "Login required": [
+ ""
+ ],
+ "Please enter your access token.": [
+ ""
+ ],
+ "Access Token": [
+ ""
+ ],
+ "The request to the backend take too long and was cancelled": [
+ ""
+ ],
+ "Diagnostic from %1$s is \"%2$s\"": [
+ ""
+ ],
+ "The backend reported a problem: HTTP status #%1$s": [
+ ""
+ ],
+ "Diagnostic from %1$s is '%2$s'": [
+ ""
+ ],
+ "Access denied": [
+ ""
+ ],
+ "The access token provided is invalid.": [
+ ""
+ ],
+ "No 'default' instance configured yet.": [
+ ""
+ ],
+ "Create a 'default' instance to begin using the merchant backoffice.": [
+ ""
+ ],
+ "The access token provided is invalid": [
+ ""
+ ],
+ "Hide for today": [
+ ""
+ ],
+ "Instance": [
+ ""
+ ],
+ "Settings": [
+ ""
+ ],
+ "Connection": [
+ ""
+ ],
+ "New": [
+ ""
+ ],
+ "List": [
+ ""
+ ],
+ "Log out": [
+ ""
+ ],
+ "Check your token is valid": [
+ ""
+ ],
+ "Couldn't access the server.": [
+ ""
+ ],
+ "Could not infer instance id from url %1$s": [
+ ""
+ ],
+ "Server not found": [
+ ""
+ ],
+ "Server response with an error code": [
+ ""
+ ],
+ "Got message %1$s from %2$s": [
+ ""
+ ],
+ "Response from server is unreadable, http status: %1$s": [
+ ""
+ ],
+ "Unexpected Error": [
+ ""
+ ],
+ "The value %1$s is invalid for a payment url": [
+ ""
+ ],
+ "add element to the list": [
+ ""
+ ],
+ "add": [
+ ""
+ ],
+ "Deleting": [
+ ""
+ ],
+ "Changing": [
+ ""
+ ],
+ "Order ID": [
+ ""
+ ],
+ "Payment URL": [
+ ""
+ ]
+ }
+ }
+};
+
diff --git a/packages/merchant-backoffice-ui/src/i18n/sv.po b/packages/merchant-backoffice-ui/src/i18n/sv.po
new file mode 100644
index 000000000..d8d0bae29
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/i18n/sv.po
@@ -0,0 +1,2741 @@
+# This file is part of TALER
+# (C) 2016 GNUnet e.V.
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid ""
+"With external authorization method no check will be done by the merchant "
+"backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid ""
+"Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without "
+"user interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to "
+"determine the share of excess wire fees to be paid explicitly by the "
+"consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with "
+"enough entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this "
+"contract. If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize "
+"wire fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid ""
+"after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid ""
+"how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid ""
+"Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment "
+"provider are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the "
+"backend will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 "
+"items, 5 meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid ""
+"sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid ""
+"product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to "
+"the indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid ""
+"There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the "
+"wire transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it "
+"is used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid ""
+"Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid ""
+"Maximum wire fees this merchant is willing to pay per wire transfer by "
+"default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid ""
+"Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
diff --git a/packages/merchant-backoffice-ui/src/i18n/taler-merchant-backoffice.pot b/packages/merchant-backoffice-ui/src/i18n/taler-merchant-backoffice.pot
new file mode 100644
index 000000000..5ef56ca05
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/i18n/taler-merchant-backoffice.pot
@@ -0,0 +1,2726 @@
+# This file is part of GNU Taler
+# (C) 2021-2023 Taler Systems S.A.
+
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: taler@gnu.org\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+#: src/components/modal/index.tsx:71
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/components/modal/index.tsx:79
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:84
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/components/modal/index.tsx:124
+#, c-format
+msgid "Continue"
+msgstr ""
+
+#: src/components/modal/index.tsx:178
+#, c-format
+msgid "Clear"
+msgstr ""
+
+#: src/components/modal/index.tsx:190
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/components/modal/index.tsx:296
+#, c-format
+msgid "is not the same as the current access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:299
+#, c-format
+msgid "cannot be empty"
+msgstr ""
+
+#: src/components/modal/index.tsx:301
+#, c-format
+msgid "cannot be the same as the old token"
+msgstr ""
+
+#: src/components/modal/index.tsx:305
+#, c-format
+msgid "is not the same"
+msgstr ""
+
+#: src/components/modal/index.tsx:315
+#, c-format
+msgid "You are updating the access token from instance with id %1$s"
+msgstr ""
+
+#: src/components/modal/index.tsx:331
+#, c-format
+msgid "Old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:332
+#, c-format
+msgid "access token currently in use"
+msgstr ""
+
+#: src/components/modal/index.tsx:338
+#, c-format
+msgid "New access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:339
+#, c-format
+msgid "next access token to be used"
+msgstr ""
+
+#: src/components/modal/index.tsx:344
+#, c-format
+msgid "Repeat access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:345
+#, c-format
+msgid "confirm the same access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:350
+#, c-format
+msgid "Clearing the access token will mean public access to the instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:377
+#, c-format
+msgid "cannot be the same as the old access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:394
+#, c-format
+msgid "You are setting the access token for the new instance"
+msgstr ""
+
+#: src/components/modal/index.tsx:420
+#, c-format
+msgid "With external authorization method no check will be done by the merchant backend"
+msgstr ""
+
+#: src/components/modal/index.tsx:436
+#, c-format
+msgid "Set external authorization"
+msgstr ""
+
+#: src/components/modal/index.tsx:448
+#, c-format
+msgid "Set access token"
+msgstr ""
+
+#: src/components/modal/index.tsx:470
+#, c-format
+msgid "Operation in progress..."
+msgstr ""
+
+#: src/components/modal/index.tsx:479
+#, c-format
+msgid "The operation will be automatically canceled after %1$s seconds"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:80
+#, c-format
+msgid "Instances"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:93
+#, c-format
+msgid "Delete"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:99
+#, c-format
+msgid "add new instance"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:178
+#, c-format
+msgid "ID"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:181
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:220
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:237
+#, c-format
+msgid "Purge"
+msgstr ""
+
+#: src/paths/admin/list/TableActive.tsx:261
+#, c-format
+msgid "There is no instances yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:68
+#, c-format
+msgid "Only show active instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:71
+#, c-format
+msgid "Active"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:78
+#, c-format
+msgid "Only show deleted instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:81
+#, c-format
+msgid "Deleted"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:88
+#, c-format
+msgid "Show all instances"
+msgstr ""
+
+#: src/paths/admin/list/View.tsx:91
+#, c-format
+msgid "All"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:101
+#, c-format
+msgid "Instance \"%1$s\" (ID: %2$s) has been deleted"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:106
+#, c-format
+msgid "Failed to delete instance"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:124
+#, c-format
+msgid "Instance '%1$s' (ID: %2$s) has been disabled"
+msgstr ""
+
+#: src/paths/admin/list/index.tsx:129
+#, c-format
+msgid "Failed to purge instance"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:41
+#, c-format
+msgid "Pending KYC verification"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:66
+#, c-format
+msgid "Timed out"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:103
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:106
+#, c-format
+msgid "Target account"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:109
+#, c-format
+msgid "KYC URL"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:144
+#, c-format
+msgid "Code"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:147
+#, c-format
+msgid "Http Status"
+msgstr ""
+
+#: src/paths/instance/kyc/list/ListPage.tsx:177
+#, c-format
+msgid "No pending kyc verification!"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:123
+#, c-format
+msgid "change value to unknown date"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:124
+#, c-format
+msgid "change value to empty"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:131
+#, c-format
+msgid "clear"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:136
+#, c-format
+msgid "change value to never"
+msgstr ""
+
+#: src/components/form/InputDate.tsx:141
+#, c-format
+msgid "never"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:29
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:33
+#, c-format
+msgid "Address"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:39
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:41
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:42
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:43
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:44
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:45
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:46
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/components/form/InputLocation.tsx:49
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:66
+#, c-format
+msgid "Product id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:69
+#, c-format
+msgid "Description"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:94
+#, c-format
+msgid "Product"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:95
+#, c-format
+msgid "search products by it's description or id"
+msgstr ""
+
+#: src/components/form/InputSearchProduct.tsx:151
+#, c-format
+msgid "no products found with that description"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:56
+#, c-format
+msgid "You must enter a valid product identifier."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:64
+#, c-format
+msgid "Quantity must be greater than 0!"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:76
+#, c-format
+msgid ""
+"This quantity exceeds remaining stock. Currently, only %1$s units remain "
+"unreserved in stock."
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:109
+#, c-format
+msgid "Quantity"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:110
+#, c-format
+msgid "how many products will be added"
+msgstr ""
+
+#: src/components/product/InventoryProductForm.tsx:117
+#, c-format
+msgid "Add from inventory"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:105
+#, c-format
+msgid "Image should be smaller than 1 MB"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:110
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/components/form/InputImage.tsx:115
+#, c-format
+msgid "Remove"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:113
+#, c-format
+msgid "No taxes configured for this product."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:119
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:120
+#, c-format
+msgid ""
+"Taxes can be in currencies that differ from the main currency used by the "
+"merchant."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:122
+#, c-format
+msgid "Enter currency and value separated with a colon, e.g. &quot;USD:2.3&quot;."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:131
+#, c-format
+msgid "Legal name of the tax, e.g. VAT or import duties."
+msgstr ""
+
+#: src/components/form/InputTaxes.tsx:137
+#, c-format
+msgid "add tax to the tax list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:72
+#, c-format
+msgid "describe and add a product that is not in the inventory list"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:75
+#, c-format
+msgid "Add custom product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:86
+#, c-format
+msgid "Complete information of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:185
+#, c-format
+msgid "Image"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:186
+#, c-format
+msgid "photo of the product"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:192
+#, c-format
+msgid "full product description"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:196
+#, c-format
+msgid "Unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:197
+#, c-format
+msgid "name of the product unit"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:201
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:202
+#, c-format
+msgid "amount in the current currency"
+msgstr ""
+
+#: src/components/product/NonInventoryProductForm.tsx:211
+#, c-format
+msgid "Taxes"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:38
+#, c-format
+msgid "image"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:41
+#, c-format
+msgid "description"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:44
+#, c-format
+msgid "quantity"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:47
+#, c-format
+msgid "unit price"
+msgstr ""
+
+#: src/components/product/ProductList.tsx:50
+#, c-format
+msgid "total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:153
+#, c-format
+msgid "required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:157
+#, c-format
+msgid "not valid"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:159
+#, c-format
+msgid "must be greater than 0"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:164
+#, c-format
+msgid "not a valid json"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:170
+#, c-format
+msgid "should be in the future"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:173
+#, c-format
+msgid "refund deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:179
+#, c-format
+msgid "wire transfer deadline cannot be before refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:190
+#, c-format
+msgid "wire transfer deadline cannot be before pay deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:197
+#, c-format
+msgid "should have a refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:202
+#, c-format
+msgid "auto refund cannot be after refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:360
+#, c-format
+msgid "Manage products in order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:369
+#, c-format
+msgid "Manage list of products in the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:391
+#, c-format
+msgid "Remove this product from the order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:415
+#, c-format
+msgid "Total price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:417
+#, c-format
+msgid "total product price added up"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:430
+#, c-format
+msgid "Amount to be paid by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:436
+#, c-format
+msgid "Order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:437
+#, c-format
+msgid "final order price"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:444
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:445
+#, c-format
+msgid "Title of the order to be shown to the customer"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:450
+#, c-format
+msgid "Shipping and Fulfillment"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:455
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:456
+#, c-format
+msgid "Deadline for physical delivery assured by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:461
+#, c-format
+msgid "Location"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:462
+#, c-format
+msgid "address where the products will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:469
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:470
+#, c-format
+msgid "URL to which the user will be redirected after successful payment."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:476
+#, c-format
+msgid "Taler payment options"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:477
+#, c-format
+msgid "Override default Taler payment settings for this order"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:481
+#, c-format
+msgid "Payment deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:482
+#, c-format
+msgid ""
+"Deadline for the customer to pay for the offer before it expires. Inventory "
+"products will be reserved until this deadline."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:486
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:487
+#, c-format
+msgid "Time until which the order can be refunded by the merchant."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:491
+#, c-format
+msgid "Wire transfer deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:492
+#, c-format
+msgid "Deadline for the exchange to make the wire transfer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:496
+#, c-format
+msgid "Auto-refund deadline"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:497
+#, c-format
+msgid ""
+"Time until which the wallet will automatically check for refunds without user "
+"interaction."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:502
+#, c-format
+msgid "Maximum deposit fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:503
+#, c-format
+msgid ""
+"Maximum deposit fees the merchant is willing to cover for this order. Higher "
+"deposit fees must be covered in full by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:507
+#, c-format
+msgid "Maximum wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:508
+#, c-format
+msgid ""
+"Maximum aggregate wire fees the merchant is willing to cover for this order. "
+"Wire fees exceeding this amount are to be covered by the customers."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:512
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:513
+#, c-format
+msgid ""
+"Factor by which wire fees exceeding the above threshold are divided to determine "
+"the share of excess wire fees to be paid explicitly by the consumer."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:517
+#, c-format
+msgid "Create token"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:518
+#, c-format
+msgid ""
+"Uncheck this option if the merchant backend generated an order ID with enough "
+"entropy to prevent adversarial claims."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:522
+#, c-format
+msgid "Minimum age required"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:523
+#, c-format
+msgid ""
+"Any value greater than 0 will limit the coins able be used to pay this contract. "
+"If empty the age restriction will be defined by the products"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:526
+#, c-format
+msgid "Min age defined by the producs is %1$s"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:534
+#, c-format
+msgid "Additional information"
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:535
+#, c-format
+msgid "Custom information to be included in the contract for this order."
+msgstr ""
+
+#: src/paths/instance/orders/create/CreatePage.tsx:541
+#, c-format
+msgid "You must enter a value in JavaScript Object Notation (JSON)."
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:55
+#, c-format
+msgid "days"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:65
+#, c-format
+msgid "hours"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:76
+#, c-format
+msgid "minutes"
+msgstr ""
+
+#: src/components/picker/DurationPicker.tsx:87
+#, c-format
+msgid "seconds"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:53
+#, c-format
+msgid "forever"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:62
+#, c-format
+msgid "%1$sM"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:64
+#, c-format
+msgid "%1$sY"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:66
+#, c-format
+msgid "%1$sd"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:68
+#, c-format
+msgid "%1$sh"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:70
+#, c-format
+msgid "%1$smin"
+msgstr ""
+
+#: src/components/form/InputDuration.tsx:72
+#, c-format
+msgid "%1$ssec"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:75
+#, c-format
+msgid "Orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:81
+#, c-format
+msgid "create order"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:147
+#, c-format
+msgid "load newer orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:154
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:200
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:209
+#, c-format
+msgid "copy url"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:225
+#, c-format
+msgid "load older orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:242
+#, c-format
+msgid "No orders have been found matching your query!"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:288
+#, c-format
+msgid "duplicated"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:299
+#, c-format
+msgid "invalid format"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:301
+#, c-format
+msgid "this value exceed the refundable amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:346
+#, c-format
+msgid "date"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:349
+#, c-format
+msgid "amount"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:352
+#, c-format
+msgid "reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:389
+#, c-format
+msgid "amount to be refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:391
+#, c-format
+msgid "Max refundable:"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:396
+#, c-format
+msgid "Reason"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:397
+#, c-format
+msgid "Choose one..."
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:399
+#, c-format
+msgid "requested by the customer"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:400
+#, c-format
+msgid "other"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:403
+#, c-format
+msgid "why this order is being refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/Table.tsx:409
+#, c-format
+msgid "more information to give context"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:62
+#, c-format
+msgid "Contract Terms"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:68
+#, c-format
+msgid "human-readable description of the whole purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:74
+#, c-format
+msgid "total price for the transaction"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:81
+#, c-format
+msgid "URL for this purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:87
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:88
+#, c-format
+msgid "maximum total deposit fee accepted by the merchant for this contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:93
+#, c-format
+msgid "Max wire fee"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:94
+#, c-format
+msgid "maximum wire fee accepted by the merchant"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:100
+#, c-format
+msgid ""
+"over how many customer transactions does the merchant expect to amortize wire "
+"fees on average"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:105
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:106
+#, c-format
+msgid "time when this contract was generated"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:112
+#, c-format
+msgid "after this deadline has passed no refunds will be accepted"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:118
+#, c-format
+msgid "after this deadline, the merchant won't accept payments for the contract"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:124
+#, c-format
+msgid "transfer deadline for the exchange"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:130
+#, c-format
+msgid "time indicating when the order should be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:136
+#, c-format
+msgid "where the order will be delivered"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:144
+#, c-format
+msgid "Auto-refund delay"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:145
+#, c-format
+msgid "how long the wallet should try to get an automatic refund for the purchase"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:150
+#, c-format
+msgid "Extra info"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:151
+#, c-format
+msgid "extra data that is only interpreted by the merchant frontend"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:219
+#, c-format
+msgid "Order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:221
+#, c-format
+msgid "claimed"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:247
+#, c-format
+msgid "claimed at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:265
+#, c-format
+msgid "Timeline"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:271
+#, c-format
+msgid "Payment details"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:291
+#, c-format
+msgid "Order status"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:301
+#, c-format
+msgid "Product list"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:451
+#, c-format
+msgid "paid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:455
+#, c-format
+msgid "wired"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:460
+#, c-format
+msgid "refunded"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:480
+#, c-format
+msgid "refund order"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:481
+#, c-format
+msgid "not refundable"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:489
+#, c-format
+msgid "refund"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:553
+#, c-format
+msgid "Refunded amount"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:560
+#, c-format
+msgid "Refund taken"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:570
+#, c-format
+msgid "Status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:583
+#, c-format
+msgid "Refund URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:636
+#, c-format
+msgid "unpaid"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:654
+#, c-format
+msgid "pay at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:666
+#, c-format
+msgid "created at"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:707
+#, c-format
+msgid "Order status URL"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:711
+#, c-format
+msgid "Payment URI"
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:740
+#, c-format
+msgid "Unknown order status. This is an error, please contact the administrator."
+msgstr ""
+
+#: src/paths/instance/orders/details/DetailPage.tsx:767
+#, c-format
+msgid "Back"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:79
+#, c-format
+msgid "refund created successfully"
+msgstr ""
+
+#: src/paths/instance/orders/details/index.tsx:85
+#, c-format
+msgid "could not create the refund"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:78
+#, c-format
+msgid "select date to show nearby orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:94
+#, c-format
+msgid "order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:100
+#, c-format
+msgid "jump to order with the given order ID"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:122
+#, c-format
+msgid "remove all filters"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:132
+#, c-format
+msgid "only show paid orders"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:135
+#, c-format
+msgid "Paid"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:142
+#, c-format
+msgid "only show orders with refunds"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:145
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:152
+#, c-format
+msgid ""
+"only show orders where customers paid, but wire payments from payment provider "
+"are still pending"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:155
+#, c-format
+msgid "Not wired"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:170
+#, c-format
+msgid "clear date filter"
+msgstr ""
+
+#: src/paths/instance/orders/list/ListPage.tsx:184
+#, c-format
+msgid "date (YYYY/MM/DD)"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:103
+#, c-format
+msgid "Enter an order id"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:111
+#, c-format
+msgid "order not found"
+msgstr ""
+
+#: src/paths/instance/orders/list/index.tsx:178
+#, c-format
+msgid "could not get the order to refund"
+msgstr ""
+
+#: src/components/exception/AsyncButton.tsx:43
+#, c-format
+msgid "Loading..."
+msgstr ""
+
+#: src/components/form/InputStock.tsx:99
+#, c-format
+msgid ""
+"click here to configure the stock of the product, leave it as is and the backend "
+"will not control stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:109
+#, c-format
+msgid "Manage stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:115
+#, c-format
+msgid "this product has been configured without stock control"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:119
+#, c-format
+msgid "Infinite"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:136
+#, c-format
+msgid "lost cannot be greater than current and incoming (max %1$s)"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:176
+#, c-format
+msgid "Incoming"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:177
+#, c-format
+msgid "Lost"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:192
+#, c-format
+msgid "Current"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:196
+#, c-format
+msgid "remove stock control for this product"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:202
+#, c-format
+msgid "without stock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:211
+#, c-format
+msgid "Next restock"
+msgstr ""
+
+#: src/components/form/InputStock.tsx:217
+#, c-format
+msgid "Delivery address"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:133
+#, c-format
+msgid "product identification to use in URLs (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:139
+#, c-format
+msgid "illustration of the product for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:145
+#, c-format
+msgid "product description for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:149
+#, c-format
+msgid "Age restricted"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:150
+#, c-format
+msgid "is this product restricted for customer below certain age?"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:155
+#, c-format
+msgid ""
+"unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 "
+"meters) for customers"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:160
+#, c-format
+msgid "sale price for customers, including taxes, for above units of the product"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:164
+#, c-format
+msgid "Stock"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:166
+#, c-format
+msgid "product inventory for products with finite supply (for internal use only)"
+msgstr ""
+
+#: src/components/product/ProductForm.tsx:171
+#, c-format
+msgid "taxes included in the product price, exposed to customers"
+msgstr ""
+
+#: src/paths/instance/products/create/CreatePage.tsx:66
+#, c-format
+msgid "Need to complete marked fields"
+msgstr ""
+
+#: src/paths/instance/products/create/index.tsx:51
+#, c-format
+msgid "could not create product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:68
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:73
+#, c-format
+msgid "add product to inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:137
+#, c-format
+msgid "Sell"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:143
+#, c-format
+msgid "Profit"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:149
+#, c-format
+msgid "Sold"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:210
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:248
+#, c-format
+msgid "go to product update page"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:255
+#, c-format
+msgid "Update"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:260
+#, c-format
+msgid "remove this product from the database"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:331
+#, c-format
+msgid "update the product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:341
+#, c-format
+msgid "update product with new price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:399
+#, c-format
+msgid "add more elements to the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:404
+#, c-format
+msgid "report elements lost in the inventory"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:409
+#, c-format
+msgid "new price for the product"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:421
+#, c-format
+msgid "the are value with errors"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:422
+#, c-format
+msgid "update product with new stock and price"
+msgstr ""
+
+#: src/paths/instance/products/list/Table.tsx:463
+#, c-format
+msgid "There is no products yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:86
+#, c-format
+msgid "product updated successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:92
+#, c-format
+msgid "could not update the product"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:103
+#, c-format
+msgid "product delete successfully"
+msgstr ""
+
+#: src/paths/instance/products/list/index.tsx:109
+#, c-format
+msgid "could not delete the product"
+msgstr ""
+
+#: src/paths/instance/products/update/UpdatePage.tsx:56
+#, c-format
+msgid "Product id:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:95
+#, c-format
+msgid ""
+"To complete the setup of the reserve, you must now initiate a wire transfer "
+"using the given wire transfer subject and crediting the specified amount to the "
+"indicated account of the exchange."
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatedSuccessfully.tsx:102
+#, c-format
+msgid "If your system supports RFC 8905, you can do this by opening this URI:"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:83
+#, c-format
+msgid "it should be greater than 0"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:88
+#, c-format
+msgid "must be a valid URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:107
+#, c-format
+msgid "Initial balance"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:108
+#, c-format
+msgid "balance prior to deposit"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:112
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:113
+#, c-format
+msgid "URL of exchange"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:148
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:186
+#, c-format
+msgid "Wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:187
+#, c-format
+msgid "method to use for wire transfer"
+msgstr ""
+
+#: src/paths/instance/reserves/create/CreatePage.tsx:189
+#, c-format
+msgid "Select one wire method"
+msgstr ""
+
+#: src/paths/instance/reserves/create/index.tsx:62
+#, c-format
+msgid "could not create reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:77
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:82
+#, c-format
+msgid "Created balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:99
+#, c-format
+msgid "Exchange balance"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:104
+#, c-format
+msgid "Picked up"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:109
+#, c-format
+msgid "Committed"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:116
+#, c-format
+msgid "Account address"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:119
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:130
+#, c-format
+msgid "Tips"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:193
+#, c-format
+msgid "No tips has been authorized from this reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:213
+#, c-format
+msgid "Authorized"
+msgstr ""
+
+#: src/paths/instance/reserves/details/DetailPage.tsx:222
+#, c-format
+msgid "Expiration"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:108
+#, c-format
+msgid "amount of tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:112
+#, c-format
+msgid "Justification"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:114
+#, c-format
+msgid "reason for the tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:118
+#, c-format
+msgid "URL after tip"
+msgstr ""
+
+#: src/paths/instance/reserves/list/AutorizeTipModal.tsx:119
+#, c-format
+msgid "URL to visit after tip payment"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:65
+#, c-format
+msgid "Reserves not yet funded"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:89
+#, c-format
+msgid "Reserves ready"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:95
+#, c-format
+msgid "add new reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:143
+#, c-format
+msgid "Expires at"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:146
+#, c-format
+msgid "Initial"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:202
+#, c-format
+msgid "delete selected reserve from the database"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:210
+#, c-format
+msgid "authorize new tip from selected reserve"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:237
+#, c-format
+msgid "There is no ready reserves yet, add more pressing the + sign or fund them"
+msgstr ""
+
+#: src/paths/instance/reserves/list/Table.tsx:264
+#, c-format
+msgid "Expected Balance"
+msgstr ""
+
+#: src/paths/instance/reserves/list/index.tsx:110
+#, c-format
+msgid "could not create the tip"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:77
+#, c-format
+msgid "should not be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:93
+#, c-format
+msgid "should be greater that 0"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:96
+#, c-format
+msgid "can't be empty"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:100
+#, c-format
+msgid "to short"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:108
+#, c-format
+msgid "just letters and numbers from 2 to 7"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:110
+#, c-format
+msgid "size of the key should be 32"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:137
+#, c-format
+msgid "Identifier"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:138
+#, c-format
+msgid "Name of the template in URLs."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:144
+#, c-format
+msgid "Describe what this template stands for"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:149
+#, c-format
+msgid "Fixed summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:150
+#, c-format
+msgid "If specified, this template will create order with the same summary"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:154
+#, c-format
+msgid "Fixed price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:155
+#, c-format
+msgid "If specified, this template will create order with the same price"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:159
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:161
+#, c-format
+msgid "Is this contract restricted to some age?"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:165
+#, c-format
+msgid "Payment timeout"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:167
+#, c-format
+msgid ""
+"How much time has the customer to complete the payment once the order was "
+"created."
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:171
+#, c-format
+msgid "Verification algorithm"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:172
+#, c-format
+msgid "Algorithm to use to verify transaction in offline mode"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:180
+#, c-format
+msgid "Point-of-sale key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:182
+#, c-format
+msgid "Useful to validate the purchase"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:196
+#, c-format
+msgid "generate random secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:203
+#, c-format
+msgid "random"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:208
+#, c-format
+msgid "show secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:209
+#, c-format
+msgid "hide secret key"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:216
+#, c-format
+msgid "hide"
+msgstr ""
+
+#: src/paths/instance/templates/create/CreatePage.tsx:218
+#, c-format
+msgid "show"
+msgstr ""
+
+#: src/paths/instance/templates/create/index.tsx:52
+#, c-format
+msgid "could not inform template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:54
+#, c-format
+msgid "Amount is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:58
+#, c-format
+msgid "Order summary is required"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:86
+#, c-format
+msgid "New order for template"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:108
+#, c-format
+msgid "Amount of the order"
+msgstr ""
+
+#: src/paths/instance/templates/use/UsePage.tsx:113
+#, c-format
+msgid "Order summary"
+msgstr ""
+
+#: src/paths/instance/templates/use/index.tsx:92
+#, c-format
+msgid "could not create order from template"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:131
+#, c-format
+msgid ""
+"Here you can specify a default value for fields that are not fixed. Default "
+"values can be edited by the customer before the payment."
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:148
+#, c-format
+msgid "Fixed amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:149
+#, c-format
+msgid "Default amount"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:161
+#, c-format
+msgid "Default summary"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:177
+#, c-format
+msgid "Print"
+msgstr ""
+
+#: src/paths/instance/templates/qr/QrPage.tsx:184
+#, c-format
+msgid "Setup TOTP"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:65
+#, c-format
+msgid "Templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:70
+#, c-format
+msgid "add new templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:142
+#, c-format
+msgid "load more templates before the first one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:146
+#, c-format
+msgid "load newer templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:181
+#, c-format
+msgid "delete selected templates from the database"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:188
+#, c-format
+msgid "use template to create new order"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:195
+#, c-format
+msgid "create qr code for the template"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:210
+#, c-format
+msgid "load more templates after the last one"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:214
+#, c-format
+msgid "load older templates"
+msgstr ""
+
+#: src/paths/instance/templates/list/Table.tsx:231
+#, c-format
+msgid "There is no templates yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:104
+#, c-format
+msgid "template delete successfully"
+msgstr ""
+
+#: src/paths/instance/templates/list/index.tsx:110
+#, c-format
+msgid "could not delete the template"
+msgstr ""
+
+#: src/paths/instance/templates/update/index.tsx:90
+#, c-format
+msgid "could not update template"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:57
+#, c-format
+msgid "should be one of '%1$s'"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:85
+#, c-format
+msgid "Webhook ID to use"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:89
+#, c-format
+msgid "Event"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:90
+#, c-format
+msgid "The event of the webhook: why the webhook is used"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:94
+#, c-format
+msgid "Method"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:95
+#, c-format
+msgid "Method used by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:99
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:100
+#, c-format
+msgid "URL of the webhook where the customer will be redirected"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:104
+#, c-format
+msgid "Header"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:106
+#, c-format
+msgid "Header template of the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:111
+#, c-format
+msgid "Body"
+msgstr ""
+
+#: src/paths/instance/webhooks/create/CreatePage.tsx:112
+#, c-format
+msgid "Body template by the webhook"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:61
+#, c-format
+msgid "Webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:66
+#, c-format
+msgid "add new webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:137
+#, c-format
+msgid "load more webhooks before the first one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:141
+#, c-format
+msgid "load newer webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:151
+#, c-format
+msgid "Event type"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:176
+#, c-format
+msgid "delete selected webhook from the database"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:198
+#, c-format
+msgid "load more webhooks after the last one"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:202
+#, c-format
+msgid "load older webhooks"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/Table.tsx:219
+#, c-format
+msgid "There is no webhooks yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:94
+#, c-format
+msgid "webhook delete successfully"
+msgstr ""
+
+#: src/paths/instance/webhooks/list/index.tsx:100
+#, c-format
+msgid "could not delete the webhook"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:63
+#, c-format
+msgid "check the id, does not look valid"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:65
+#, c-format
+msgid "should have 52 characters, current %1$s"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:72
+#, c-format
+msgid "URL doesn't have the right format"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:98
+#, c-format
+msgid "Credited bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:100
+#, c-format
+msgid "Select one account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:101
+#, c-format
+msgid "Bank account of the merchant where the payment was received"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:105
+#, c-format
+msgid "Wire transfer ID"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:107
+#, c-format
+msgid ""
+"unique identifier of the wire transfer used by the exchange, must be 52 "
+"characters long"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:112
+#, c-format
+msgid ""
+"Base URL of the exchange that made the transfer, should have been in the wire "
+"transfer subject"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:117
+#, c-format
+msgid "Amount credited"
+msgstr ""
+
+#: src/paths/instance/transfers/create/CreatePage.tsx:118
+#, c-format
+msgid "Actual amount that was wired to the merchant's bank account"
+msgstr ""
+
+#: src/paths/instance/transfers/create/index.tsx:58
+#, c-format
+msgid "could not inform transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:61
+#, c-format
+msgid "Transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:66
+#, c-format
+msgid "add new transfer"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:129
+#, c-format
+msgid "load more transfers before the first one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:133
+#, c-format
+msgid "load newer transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:143
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:152
+#, c-format
+msgid "Confirmed"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:155
+#, c-format
+msgid "Verified"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:158
+#, c-format
+msgid "Executed at"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "yes"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:171
+#, c-format
+msgid "no"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:181
+#, c-format
+msgid "unknown"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:187
+#, c-format
+msgid "delete selected transfer from the database"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:202
+#, c-format
+msgid "load more transfer after the last one"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:206
+#, c-format
+msgid "load older transfers"
+msgstr ""
+
+#: src/paths/instance/transfers/list/Table.tsx:223
+#, c-format
+msgid "There is no transfer yet, add more pressing the + sign"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:79
+#, c-format
+msgid "filter by account address"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:100
+#, c-format
+msgid "only show wire transfers confirmed by the merchant"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:110
+#, c-format
+msgid "only show wire transfers claimed by the exchange"
+msgstr ""
+
+#: src/paths/instance/transfers/list/ListPage.tsx:113
+#, c-format
+msgid "Unverified"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:69
+#, c-format
+msgid "is not valid"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:94
+#, c-format
+msgid "is not a number"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:96
+#, c-format
+msgid "must be 1 or greater"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:107
+#, c-format
+msgid "max 7 lines"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:178
+#, c-format
+msgid "change authorization configuration"
+msgstr ""
+
+#: src/paths/admin/create/CreatePage.tsx:217
+#, c-format
+msgid "Need to complete marked fields and choose authorization method"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:82
+#, c-format
+msgid "This is not a valid bitcoin address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:95
+#, c-format
+msgid "This is not a valid Ethereum address."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:118
+#, c-format
+msgid "IBAN numbers usually have more that 4 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:120
+#, c-format
+msgid "IBAN numbers usually have less that 34 digits"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:128
+#, c-format
+msgid "IBAN country code not found"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:153
+#, c-format
+msgid "IBAN number is not valid, checksum is wrong"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:248
+#, c-format
+msgid "Target type"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:249
+#, c-format
+msgid "Method to use for wire transfer"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:258
+#, c-format
+msgid "Routing"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:259
+#, c-format
+msgid "Routing number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:263
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:264
+#, c-format
+msgid "Account number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:273
+#, c-format
+msgid "Business Identifier Code."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:282
+#, c-format
+msgid "Bank Account Number."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:292
+#, c-format
+msgid "Unified Payment Interface."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:301
+#, c-format
+msgid "Bitcoin protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:310
+#, c-format
+msgid "Ethereum protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:319
+#, c-format
+msgid "Interledger protocol."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:328
+#, c-format
+msgid "Host"
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:329
+#, c-format
+msgid "Bank host."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:334
+#, c-format
+msgid "Bank account."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:343
+#, c-format
+msgid "Bank account owner's name."
+msgstr ""
+
+#: src/components/form/InputPaytoForm.tsx:370
+#, c-format
+msgid "No accounts yet."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:52
+#, c-format
+msgid ""
+"Name of the instance in URLs. The 'default' instance is special in that it is "
+"used to administer other instances."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:58
+#, c-format
+msgid "Business name"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:59
+#, c-format
+msgid "Legal name of the business represented by this instance."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:64
+#, c-format
+msgid "Email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:65
+#, c-format
+msgid "Contact email"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:70
+#, c-format
+msgid "Website URL"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:71
+#, c-format
+msgid "URL."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:76
+#, c-format
+msgid "Logo"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:77
+#, c-format
+msgid "Logo image."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:82
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:83
+#, c-format
+msgid "URI specifying bank account for crediting revenue."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:88
+#, c-format
+msgid "Default max deposit fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:89
+#, c-format
+msgid "Maximum deposit fees this merchant is willing to pay per order by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:94
+#, c-format
+msgid "Default max wire fee"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:95
+#, c-format
+msgid "Maximum wire fees this merchant is willing to pay per wire transfer by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:100
+#, c-format
+msgid "Default wire fee amortization"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:101
+#, c-format
+msgid ""
+"Number of orders excess wire transfer fees will be divided by to compute per "
+"order surcharge."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:107
+#, c-format
+msgid "Physical location of the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:114
+#, c-format
+msgid "Jurisdiction"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:115
+#, c-format
+msgid "Jurisdiction for legal disputes with the merchant."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:122
+#, c-format
+msgid "Default payment delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:124
+#, c-format
+msgid "Time customers have to pay an order before the offer expires by default."
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:129
+#, c-format
+msgid "Default wire transfer delay"
+msgstr ""
+
+#: src/components/instance/DefaultInstanceFormFields.tsx:130
+#, c-format
+msgid ""
+"Maximum time an exchange is allowed to delay wiring funds to the merchant, "
+"enabling it to aggregate smaller payments into larger wire transfers and "
+"reducing wire fees."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:164
+#, c-format
+msgid "Instance id"
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:173
+#, c-format
+msgid "Change the authorization method use for this instance."
+msgstr ""
+
+#: src/paths/instance/update/UpdatePage.tsx:182
+#, c-format
+msgid "Manage access token"
+msgstr ""
+
+#: src/paths/instance/update/index.tsx:112
+#, c-format
+msgid "Failed to create instance"
+msgstr ""
+
+#: src/components/exception/login.tsx:74
+#, c-format
+msgid "Login required"
+msgstr ""
+
+#: src/components/exception/login.tsx:80
+#, c-format
+msgid "Please enter your access token."
+msgstr ""
+
+#: src/components/exception/login.tsx:108
+#, c-format
+msgid "Access Token"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:171
+#, c-format
+msgid "The request to the backend take too long and was cancelled"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:172
+#, c-format
+msgid "Diagnostic from %1$s is \"%2$s\""
+msgstr ""
+
+#: src/InstanceRoutes.tsx:178
+#, c-format
+msgid "The backend reported a problem: HTTP status #%1$s"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:179
+#, c-format
+msgid "Diagnostic from %1$s is '%2$s'"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:196
+#, c-format
+msgid "Access denied"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:197
+#, c-format
+msgid "The access token provided is invalid."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:212
+#, c-format
+msgid "No 'default' instance configured yet."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:213
+#, c-format
+msgid "Create a 'default' instance to begin using the merchant backoffice."
+msgstr ""
+
+#: src/InstanceRoutes.tsx:630
+#, c-format
+msgid "The access token provided is invalid"
+msgstr ""
+
+#: src/InstanceRoutes.tsx:664
+#, c-format
+msgid "Hide for today"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:82
+#, c-format
+msgid "Instance"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:91
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:167
+#, c-format
+msgid "Connection"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:209
+#, c-format
+msgid "New"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:219
+#, c-format
+msgid "List"
+msgstr ""
+
+#: src/components/menu/SideBar.tsx:234
+#, c-format
+msgid "Log out"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:71
+#, c-format
+msgid "Check your token is valid"
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:90
+#, c-format
+msgid "Couldn't access the server."
+msgstr ""
+
+#: src/ApplicationReadyRoutes.tsx:91
+#, c-format
+msgid "Could not infer instance id from url %1$s"
+msgstr ""
+
+#: src/Application.tsx:104
+#, c-format
+msgid "Server not found"
+msgstr ""
+
+#: src/Application.tsx:118
+#, c-format
+msgid "Server response with an error code"
+msgstr ""
+
+#: src/Application.tsx:120
+#, c-format
+msgid "Got message %1$s from %2$s"
+msgstr ""
+
+#: src/Application.tsx:131
+#, c-format
+msgid "Response from server is unreadable, http status: %1$s"
+msgstr ""
+
+#: src/Application.tsx:144
+#, c-format
+msgid "Unexpected Error"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:101
+#, c-format
+msgid "The value %1$s is invalid for a payment url"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:110
+#, c-format
+msgid "add element to the list"
+msgstr ""
+
+#: src/components/form/InputArray.tsx:112
+#, c-format
+msgid "add"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:37
+#, c-format
+msgid "Deleting"
+msgstr ""
+
+#: src/components/form/InputSecured.tsx:41
+#, c-format
+msgid "Changing"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:87
+#, c-format
+msgid "Order ID"
+msgstr ""
+
+#: src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx:101
+#, c-format
+msgid "Payment URL"
+msgstr ""
+
diff --git a/packages/merchant-backoffice-ui/src/index.html b/packages/merchant-backoffice-ui/src/index.html
new file mode 100644
index 000000000..b005f967d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/index.html
@@ -0,0 +1,45 @@
+<!--
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Sebastian Javier Marchano
+-->
+<!DOCTYPE html>
+<html
+ lang="en"
+ class="has-aside-left has-aside-mobile-transition has-navbar-fixed-top has-aside-expanded"
+>
+ <head>
+ <meta charset="utf-8" />
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+
+ <link
+ rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
+ />
+ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
+ <title>Merchant Backoffice</title>
+ <!-- Optional customization script. -->
+ <script src="merchant-backoffice-ui-settings.js"></script>
+ <!-- Entry point for the SPA. -->
+ <script type="module" src="index.js"></script>
+ <link rel="stylesheet" href="index.css" />
+ </head>
+ <body>
+ <div id="app"></div>
+ </body>
+</html>
diff --git a/packages/merchant-backoffice-ui/src/index.tsx b/packages/merchant-backoffice-ui/src/index.tsx
new file mode 100644
index 000000000..46a3223bb
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/index.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Application } from "./Application.js";
+
+import { h, render } from "preact";
+import "./scss/main.scss";
+
+const app = document.getElementById("app");
+
+if (app) {
+ render(<Application />, app);
+} else {
+ console.error("HTML element with id 'app' not found.");
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/Create.stories.tsx
new file mode 100644
index 000000000..54d947e14
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/admin/create/Create.stories.tsx
@@ -0,0 +1,76 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { MerchantApiProviderTesting } from "@gnu-taler/web-util/browser";
+import { FunctionalComponent, h } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Instance/Create",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ goBack: { action: "goBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => (
+ <MerchantApiProviderTesting
+ value={{
+ cancelRequest: () => {},
+ changeBackend: () => {},
+ config: {
+ currency: "ARS",
+ version: "1",
+ currencies: {
+ "ASD": {
+ name: "testkudos",
+ alt_unit_names: {},
+ num_fractional_input_digits: 1,
+ num_fractional_normal_digits: 1,
+ num_fractional_trailing_zero_digits: 1,
+ }
+ },
+ exchanges: [],
+ name: "taler-merchant"
+ },
+ hints: [],
+ lib: {} as any,
+ onActivity: (() => {}) as any,
+ url: new URL("asdasd"),
+ }}
+ >
+ <Component {...args} />
+ </MerchantApiProviderTesting>
+ );
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {});
+// export const Example = (a: any): VNode => <CreatePage {...a} />;
+// Example.args = {
+// isLoading: false
+// }
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx
new file mode 100644
index 000000000..4a5ab440b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx
@@ -0,0 +1,273 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ Duration,
+ TalerMerchantApi,
+ createAccessToken,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../components/form/FormProvider.js";
+import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js";
+import { SetTokenNewInstanceModal } from "../../../components/modal/index.js";
+import { INSTANCE_ID_REGEX } from "../../../utils/constants.js";
+import { undefinedIfEmpty } from "../../../utils/table.js";
+
+export type Entity = Omit<
+ Omit<TalerMerchantApi.InstanceConfigurationMessage, "default_pay_delay">,
+ "default_wire_transfer_delay"
+> & {
+ auth_token?: string;
+ default_pay_delay: Duration;
+ default_wire_transfer_delay: Duration;
+};
+
+interface Props {
+ onCreate: (d: TalerMerchantApi.InstanceConfigurationMessage) => Promise<void>;
+ onBack?: () => void;
+ forceId?: string;
+}
+
+function with_defaults(id?: string): Partial<Entity> {
+ return {
+ id,
+ // accounts: [],
+ user_type: "business",
+ use_stefan: true,
+ default_pay_delay: { d_ms: 2 * 60 * 60 * 1000 }, // two hours
+ default_wire_transfer_delay: { d_ms: 2 * 60 * 60 * 24 * 1000 }, // two days
+ };
+}
+
+export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
+ const [value, valueHandler] = useState(with_defaults(forceId));
+ const [isTokenSet, updateIsTokenSet] = useState<boolean>(false);
+ const [isTokenDialogActive, updateIsTokenDialogActive] =
+ useState<boolean>(false);
+
+ const { i18n } = useTranslationContext();
+
+ const errors: FormErrors<Entity> = {
+ id: !value.id
+ ? i18n.str`required`
+ : !INSTANCE_ID_REGEX.test(value.id)
+ ? i18n.str`is not valid`
+ : undefined,
+ name: !value.name ? i18n.str`required` : undefined,
+
+ user_type: !value.user_type
+ ? i18n.str`required`
+ : value.user_type !== "business" && value.user_type !== "individual"
+ ? i18n.str`should be business or individual`
+ : undefined,
+ // accounts:
+ // !value.accounts || !value.accounts.length
+ // ? i18n.str`required`
+ // : undefinedIfEmpty(
+ // value.accounts.map((p) => {
+ // return !PAYTO_REGEX.test(p.payto_uri)
+ // ? i18n.str`is not valid`
+ // : undefined;
+ // }),
+ // ),
+ default_pay_delay: !value.default_pay_delay
+ ? i18n.str`required`
+ : !!value.default_wire_transfer_delay &&
+ value.default_wire_transfer_delay.d_ms !== "forever" &&
+ value.default_pay_delay.d_ms !== "forever" &&
+ value.default_pay_delay.d_ms > value.default_wire_transfer_delay.d_ms
+ ? i18n.str`pay delay can't be greater than wire transfer delay`
+ : undefined,
+ default_wire_transfer_delay: !value.default_wire_transfer_delay
+ ? i18n.str`required`
+ : undefined,
+ address: undefinedIfEmpty({
+ address_lines:
+ value.address?.address_lines && value.address?.address_lines.length > 7
+ ? i18n.str`max 7 lines`
+ : undefined,
+ }),
+ jurisdiction: undefinedIfEmpty({
+ address_lines:
+ value.address?.address_lines && value.address?.address_lines.length > 7
+ ? i18n.str`max 7 lines`
+ : undefined,
+ }),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as Record<string, unknown>)[k] !== undefined,
+ );
+
+ const submit = (): Promise<void> => {
+ // use conversion instead of this
+ const newValue = structuredClone(value);
+
+ const newToken = newValue.auth_token;
+ newValue.auth_token = undefined;
+ newValue.auth =
+ newToken === null || newToken === undefined
+ ? { method: "external" }
+ : { method: "token", token: createAccessToken(newToken) };
+ if (!newValue.address) newValue.address = {};
+ if (!newValue.jurisdiction) newValue.jurisdiction = {};
+ // remove above use conversion
+ // schema.validateSync(value, { abortEarly: false })
+ newValue.default_pay_delay = Duration.toTalerProtocolDuration(
+ newValue.default_pay_delay!,
+ ) as any;
+ newValue.default_wire_transfer_delay = Duration.toTalerProtocolDuration(
+ newValue.default_wire_transfer_delay!,
+ ) as any;
+ // delete value.default_pay_delay;
+ // delete value.default_wire_transfer_delay;
+
+ return onCreate(
+ newValue as any as TalerMerchantApi.InstanceConfigurationMessage,
+ );
+ };
+
+ function updateToken(token: string | null) {
+ valueHandler((old) => ({
+ ...old,
+ auth_token: token === null ? undefined : token,
+ }));
+ }
+
+ return (
+ <div>
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ {isTokenDialogActive && (
+ <SetTokenNewInstanceModal
+ onCancel={() => {
+ updateIsTokenDialogActive(false);
+ updateIsTokenSet(false);
+ }}
+ onClear={() => {
+ updateToken(null);
+ updateIsTokenDialogActive(false);
+ updateIsTokenSet(true);
+ }}
+ onConfirm={(newToken) => {
+ updateToken(newToken);
+ updateIsTokenDialogActive(false);
+ updateIsTokenSet(true);
+ }}
+ />
+ )}
+ </div>
+ <div class="column" />
+ </div>
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider<Entity>
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <DefaultInstanceFormFields readonlyId={!!forceId} showId={true} />
+ </FormProvider>
+
+ <div class="level">
+ <div class="level-item has-text-centered">
+ <h1 class="title">
+ <button
+ class={
+ !isTokenSet
+ ? "button is-danger has-tooltip-bottom"
+ : !value.auth_token
+ ? "button has-tooltip-bottom"
+ : "button is-info has-tooltip-bottom"
+ }
+ data-tooltip={i18n.str`change authorization configuration`}
+ onClick={() => updateIsTokenDialogActive(true)}
+ >
+ <div class="icon is-centered">
+ <i class="mdi mdi-lock-reset" />
+ </div>
+ <span>
+ <i18n.Translate>Set access token</i18n.Translate>
+ </span>
+ </button>
+ </h1>
+ </div>
+ </div>
+ <div class="level">
+ <div class="level-item has-text-centered">
+ {!isTokenSet ? (
+ <p class="is-size-6">
+ <i18n.Translate>
+ Access token is not yet configured. This instance can't be
+ created.
+ </i18n.Translate>
+ </p>
+ ) : value.auth_token === undefined ? (
+ <p class="is-size-6">
+ <i18n.Translate>
+ No access token. Authorization must be handled externally.
+ </i18n.Translate>
+ </p>
+ ) : (
+ <p class="is-size-6">
+ <i18n.Translate>
+ Access token is set. Authorization is handled by the
+ merchant backend.
+ </i18n.Translate>
+ </p>
+ )}
+ </div>
+ </div>
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ onClick={submit}
+ disabled={hasErrors || !isTokenSet}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields and choose authorization method`
+ : "confirm operation"
+ }
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx
new file mode 100644
index 000000000..939f9b06a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/admin/create/InstanceCreatedSuccessfully.tsx
@@ -0,0 +1,74 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { h, VNode } from "preact";
+import { CreatedSuccessfully } from "../../../components/notifications/CreatedSuccessfully.js";
+import { Entity } from "./index.js";
+
+export function InstanceCreatedSuccessfully({
+ entity,
+ onConfirm,
+}: {
+ entity: Entity;
+ onConfirm: () => void;
+}): VNode {
+ return (
+ <CreatedSuccessfully onConfirm={onConfirm}>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">ID</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input" readonly value={entity.id} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Business Name</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input" readonly value={entity.name} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Access token</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ {entity.auth.method === "external" && "external"}
+ {entity.auth.method === "token" && (
+ <input class="input" readonly value={entity.auth.token} />
+ )}
+ </p>
+ </div>
+ </div>
+ </div>
+ </CreatedSuccessfully>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx
new file mode 100644
index 000000000..b00cfbe7d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../components/menu/index.js";
+import { useSessionContext } from "../../../context/session.js";
+import { Notification } from "../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ forceId?: string;
+}
+export type Entity = TalerMerchantApi.InstanceConfigurationMessage;
+
+export default function Create({ onBack, onConfirm, forceId }: Props): VNode {
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const { lib } = useSessionContext();
+ const { state, logIn } = useSessionContext();
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <CreatePage
+ onBack={onBack}
+ forceId={forceId}
+ onCreate={async (
+ d: TalerMerchantApi.InstanceConfigurationMessage,
+ ) => {
+ if (state.status !== "loggedIn") return;
+ try {
+ await lib.instance.createInstance(state.token, d);
+ if (d.auth.token) {
+ //if auth has been updated, request a new access token
+ const result = await lib.authenticate.createAccessTokenBearer(
+ d.auth.token,
+ {
+ scope: "write",
+ duration: {
+ d_us: "forever",
+ },
+ refreshable: true,
+ },
+ );
+ if (result.type === "ok") {
+ const { token } = result.body;
+ logIn(token);
+ }
+ }
+ onConfirm();
+ } catch (ex) {
+ if (ex instanceof Error) {
+ setNotif({
+ message: i18n.str`Failed to create instance`,
+ type: "ERROR",
+ description: ex.message,
+ });
+ } else {
+ console.error(ex);
+ }
+ }
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/stories.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/stories.tsx
new file mode 100644
index 000000000..d4258058b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/admin/create/stories.tsx
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+import { MerchantApiProviderTesting } from "@gnu-taler/web-util/browser";
+
+export default {
+ title: "Pages/Instance/Create",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ goBack: { action: "goBack" },
+ },
+};
+
+function createExample<Props>(
+ Internal: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const component = (args: any) => (
+ <MerchantApiProviderTesting
+ value={{
+ cancelRequest: () => {},
+ changeBackend: () => {},
+ config: {
+ currency: "ARS",
+ version: "1",
+ currencies: {
+ "ASD": {
+ name: "testkudos",
+ alt_unit_names: {},
+ num_fractional_input_digits: 1,
+ num_fractional_normal_digits: 1,
+ num_fractional_trailing_zero_digits: 1,
+ }
+ },
+ exchanges: [],
+ name: "taler-merchant"
+ },
+ hints: [],
+ lib: {} as any,
+ onActivity: (() => {}) as any,
+ url: new URL("asdasd"),
+ }}
+ >
+ <Internal {...(props as any)} />
+ </MerchantApiProviderTesting>
+ );
+ return { component, props };
+}
+
+export const Example = createExample(TestedComponent, {});
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/index.stories.ts b/packages/merchant-backoffice-ui/src/paths/admin/index.stories.ts
new file mode 100644
index 000000000..3c16c0e57
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/admin/index.stories.ts
@@ -0,0 +1,18 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+// export * as list from "./list/stories.js";
+export * as create from "./create/stories.js";
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx
new file mode 100644
index 000000000..cff3c5a02
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/admin/list/TableActive.tsx
@@ -0,0 +1,290 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { StateUpdater, useEffect, useState } from "preact/hooks";
+import { useSessionContext } from "../../../context/session.js";
+
+interface Props {
+ instances: TalerMerchantApi.Instance[];
+ onUpdate: (id: string) => void;
+ onDelete: (id: TalerMerchantApi.Instance) => void;
+ onPurge: (id: TalerMerchantApi.Instance) => void;
+ onCreate: () => void;
+ selected?: boolean;
+}
+
+export function CardTable({
+ instances,
+ onCreate,
+ onUpdate,
+ onPurge,
+ onDelete,
+ selected,
+}: Props): VNode {
+ const [actionQueue, actionQueueHandler] = useState<Actions[]>([]);
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ useEffect(() => {
+ if (
+ actionQueue.length > 0 &&
+ !selected &&
+ actionQueue[0].type == "DELETE"
+ ) {
+ onDelete(actionQueue[0].element);
+ actionQueueHandler(actionQueue.slice(1));
+ }
+ }, [actionQueue, selected, onDelete]);
+
+ useEffect(() => {
+ if (
+ actionQueue.length > 0 &&
+ !selected &&
+ actionQueue[0].type == "UPDATE"
+ ) {
+ onUpdate(actionQueue[0].element.id);
+ actionQueueHandler(actionQueue.slice(1));
+ }
+ }, [actionQueue, selected, onUpdate]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-desktop-mac" />
+ </span>
+ <i18n.Translate>Instances</i18n.Translate>
+ </p>
+
+ <div class="card-header-icon" aria-label="more options">
+ <button
+ class={rowSelection.length > 0 ? "button is-danger" : "is-hidden"}
+ type="button"
+ onClick={(): void =>
+ actionQueueHandler(
+ buildActions(instances, rowSelection, "DELETE"),
+ )
+ }
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </button>
+ </div>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new instance`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {instances.length > 0 ? (
+ <Table
+ instances={instances}
+ onPurge={onPurge}
+ onUpdate={onUpdate}
+ onDelete={onDelete}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: TalerMerchantApi.Instance[];
+ onUpdate: (id: string) => void;
+ onDelete: (id: TalerMerchantApi.Instance) => void;
+ onPurge: (id: TalerMerchantApi.Instance) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+}
+
+function toggleSelected<T>(id: T): (prev: T[]) => T[] {
+ return (prev: T[]): T[] =>
+ prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id);
+}
+
+function Table({
+ rowSelection,
+ rowSelectionHandler,
+ instances,
+ onUpdate,
+ onDelete,
+ onPurge,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const { lib } = useSessionContext();
+ const { impersonate } = useSessionContext();
+ return (
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th class="is-checkbox-cell">
+ <label class="b-checkbox checkbox">
+ <input
+ type="checkbox"
+ checked={rowSelection.length === instances.length}
+ onClick={(): void =>
+ rowSelectionHandler(
+ rowSelection.length === instances.length
+ ? []
+ : instances.map((i) => i.id),
+ )
+ }
+ />
+ <span class="check" />
+ </label>
+ </th>
+ <th>
+ <i18n.Translate>ID</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Name</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.id}>
+ <td class="is-checkbox-cell">
+ <label class="b-checkbox checkbox">
+ <input
+ type="checkbox"
+ checked={rowSelection.indexOf(i.id) != -1}
+ onClick={(): void =>
+ rowSelectionHandler(toggleSelected(i.id))
+ }
+ />
+ <span class="check" />
+ </label>
+ </td>
+ <td>
+ <a
+ href={`#/orders`}
+ onClick={async (_e) => {
+ // e.preventDefault();
+ const newInstanceApi = lib.subInstanceApi(i.id);
+ //not checking /config since this comes from instance list
+ impersonate(new URL(newInstanceApi.instance.baseUrl));
+ }}
+ >
+ {i.id}
+ </a>
+ </td>
+ <td>{i.name}</td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-small is-success jb-modal"
+ type="button"
+ onClick={(): void => onUpdate(i.id)}
+ >
+ <i18n.Translate>Edit</i18n.Translate>
+ </button>
+ {!i.deleted && (
+ <button
+ class="button is-small is-danger jb-modal is-outlined"
+ type="button"
+ onClick={(): void => onDelete(i)}
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </button>
+ )}
+ {i.deleted && (
+ <button
+ class="button is-small is-danger jb-modal"
+ type="button"
+ onClick={(): void => onPurge(i)}
+ >
+ <i18n.Translate>Purge</i18n.Translate>
+ </button>
+ )}
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-magnify mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no instances yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
+
+interface Actions {
+ element: TalerMerchantApi.Instance;
+ type: "DELETE" | "UPDATE";
+}
+
+function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
+ return value !== null && value !== undefined;
+}
+
+function buildActions(
+ instances: TalerMerchantApi.Instance[],
+ selected: string[],
+ action: "DELETE",
+): Actions[] {
+ return selected
+ .map((id) => instances.find((i) => i.id === id))
+ .filter(notEmpty)
+ .map((id) => ({ element: id, type: action }));
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/View.stories.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/View.stories.tsx
new file mode 100644
index 000000000..c4c0996f6
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/admin/list/View.stories.tsx
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h } from "preact";
+import { View } from "./View.js";
+
+export default {
+ title: "Pages/Instance/List",
+ component: View,
+ argTypes: {
+ onSelect: { action: "onSelect" },
+ },
+};
+
+export const Empty = (a: any) => <View {...a} />;
+Empty.args = {
+ instances: [],
+};
+
+export const WithDefaultInstance = (a: any) => <View {...a} />;
+WithDefaultInstance.args = {
+ instances: [
+ {
+ id: "default",
+ name: "the default instance",
+ merchant_pub: "abcdef",
+ payment_targets: [],
+ },
+ ],
+};
+
+export const WithFiveInstance = (a: any) => <View {...a} />;
+WithFiveInstance.args = {
+ instances: [
+ {
+ id: "first",
+ name: "the first instance",
+ merchant_pub: "abcdefgh",
+ payment_targets: ["asd"],
+ },
+ {
+ id: "second",
+ name: "the second instance",
+ merchant_pub: "zxczxcz",
+ payment_targets: ["asd"],
+ },
+ {
+ id: "third",
+ name: "the third instance",
+ merchant_pub: "QWEQWEWQE",
+ payment_targets: ["asd"],
+ },
+ {
+ id: "other",
+ name: "the other instance",
+ merchant_pub: "FHJHGJGHJ",
+ payment_targets: ["asd"],
+ },
+ {
+ id: "another",
+ name: "the another instance",
+ merchant_pub: "abcd3423423efgh",
+ payment_targets: ["asd"],
+ },
+ {
+ id: "last",
+ name: "last instance",
+ merchant_pub: "zxcvvbnm",
+ payment_targets: ["pay-to", "asd"],
+ },
+ ],
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/View.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/View.tsx
new file mode 100644
index 000000000..940d14334
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/admin/list/View.tsx
@@ -0,0 +1,107 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { CardTable as CardTableActive } from "./TableActive.js";
+
+interface Props {
+ instances: TalerMerchantApi.Instance[];
+ onCreate: () => void;
+ onUpdate: (id: string) => void;
+ onDelete: (id: TalerMerchantApi.Instance) => void;
+ onPurge: (id: TalerMerchantApi.Instance) => void;
+ selected?: boolean;
+}
+
+export function View({
+ instances,
+ onCreate,
+ onDelete,
+ onPurge,
+ onUpdate,
+ selected,
+}: Props): VNode {
+ const [show, setShow] = useState<"active" | "deleted" | null>("active");
+ const showIsActive = show === "active" ? "is-active" : "";
+ const showIsDeleted = show === "deleted" ? "is-active" : "";
+ const showAll = show === null ? "is-active" : "";
+ const { i18n } = useTranslationContext();
+
+ const showingInstances = showIsDeleted
+ ? instances.filter((i) => i.deleted)
+ : showIsActive
+ ? instances.filter((i) => !i.deleted)
+ : instances;
+
+ return (
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column is-two-thirds">
+ <div class="tabs" style={{ overflow: "inherit" }}>
+ <ul>
+ <li class={showIsActive}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`Only show active instances`}
+ >
+ <a onClick={() => setShow("active")}>
+ <i18n.Translate>Active</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={showIsDeleted}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`Only show deleted instances`}
+ >
+ <a onClick={() => setShow("deleted")}>
+ <i18n.Translate>Deleted</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={showAll}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`Show all instances`}
+ >
+ <a onClick={() => setShow(null)}>
+ <i18n.Translate>All</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ <CardTableActive
+ instances={showingInstances}
+ onDelete={onDelete}
+ onPurge={onPurge}
+ onUpdate={onUpdate}
+ selected={selected}
+ onCreate={onCreate}
+ />
+ </section>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx
new file mode 100644
index 000000000..5b492e45c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx
@@ -0,0 +1,138 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../components/exception/loading.js";
+import { NotificationCard } from "../../../components/menu/index.js";
+import { DeleteModal, PurgeModal } from "../../../components/modal/index.js";
+import { useSessionContext } from "../../../context/session.js";
+import { useBackendInstances } from "../../../hooks/instance.js";
+import { Notification } from "../../../utils/types.js";
+import { LoginPage } from "../../login/index.js";
+import { View } from "./View.js";
+
+interface Props {
+ onCreate: () => void;
+ onUpdate: (id: string) => void;
+ instances: TalerMerchantApi.Instance[];
+}
+
+export default function Instances({
+ onCreate,
+ onUpdate,
+}: Props): VNode {
+ const result = useBackendInstances();
+ const [deleting, setDeleting] =
+ useState<TalerMerchantApi.Instance | null>(null);
+ const [purging, setPurging] =
+ useState<TalerMerchantApi.Instance | null>(null);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+
+ if (!result) return <Loading />
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />
+ }
+ if (result.type === "fail") {
+ switch(result.case) {
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result.case)
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <View
+ instances={result.body.instances}
+ onDelete={setDeleting}
+ onCreate={onCreate}
+ onPurge={setPurging}
+ onUpdate={onUpdate}
+ selected={!!deleting}
+ />
+ {deleting && (
+ <DeleteModal
+ element={deleting}
+ onCancel={() => setDeleting(null)}
+ onConfirm={async (): Promise<void> => {
+ if (state.status !== "loggedIn") {
+ return;
+ }
+ try {
+ await lib.instance.deleteInstance(state.token, deleting.id);
+ // pushNotification({message: 'delete_success', type: 'SUCCESS' })
+ setNotif({
+ message: i18n.str`Instance "${deleting.name}" (ID: ${deleting.id}) has been deleted`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`Failed to delete instance`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ // pushNotification({message: 'delete_error', type: 'ERROR' })
+ }
+ setDeleting(null);
+ }}
+ />
+ )}
+ {purging && (
+ <PurgeModal
+ element={purging}
+ onCancel={() => setPurging(null)}
+ onConfirm={async (): Promise<void> => {
+ if (state.status !== "loggedIn") {
+ return;
+ }
+ try {
+ await lib.instance.deleteInstance(state.token, purging.id, { purge: true });
+ setNotif({
+ message: i18n.str`Instance '${purging.name}' (ID: ${purging.id}) has been disabled`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`Failed to purge instance`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+ setPurging(null);
+ }}
+ />
+ )}
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx
new file mode 100644
index 000000000..50cd801d8
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Accounts/Create",
+ component: TestedComponent,
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx
new file mode 100644
index 000000000..d05375b6c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx
@@ -0,0 +1,197 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { undefinedIfEmpty } from "../../../../utils/table.js";
+import { safeConvertURL } from "../update/UpdatePage.js";
+
+type Entity = TalerMerchantApi.AccountAddDetails & { repeatPassword: string };
+
+interface Props {
+ onCreate: (d: TalerMerchantApi.AccountAddDetails) => Promise<void>;
+ onBack?: () => void;
+}
+
+const accountAuthType = ["none", "basic"];
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>({});
+ const facadeURL = safeConvertURL(state.credit_facade_url);
+ const errors: FormErrors<Entity> = {
+ payto_uri: !state.payto_uri ? i18n.str`required` : undefined,
+
+ credit_facade_credentials: !state.credit_facade_credentials
+ ? undefined
+ : undefinedIfEmpty({
+ username:
+ state.credit_facade_credentials.type === "basic" &&
+ !state.credit_facade_credentials.username
+ ? i18n.str`required`
+ : undefined,
+ password:
+ state.credit_facade_credentials.type === "basic" &&
+ !state.credit_facade_credentials.password
+ ? i18n.str`required`
+ : undefined,
+ }),
+ credit_facade_url: !state.credit_facade_url
+ ? undefined
+ : !facadeURL
+ ? i18n.str`Invalid url`
+ : !facadeURL.href.endsWith("/")
+ ? i18n.str`URL should end with a '/'`
+ : facadeURL.searchParams.size > 0
+ ? i18n.str`URL should not contain params`
+ : facadeURL.hash
+ ? i18n.str`URL should not hash param`
+ : undefined,
+ repeatPassword: !state.credit_facade_credentials
+ ? undefined
+ : state.credit_facade_credentials.type === "basic" &&
+ (!state.credit_facade_credentials.password ||
+ state.credit_facade_credentials.password !== state.repeatPassword)
+ ? i18n.str`is not the same`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as Record<string, unknown>)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ const credit_facade_url = !state.credit_facade_url
+ ? undefined
+ : facadeURL?.href;
+ const credit_facade_credentials:
+ | TalerMerchantApi.FacadeCredentials
+ | undefined =
+ credit_facade_url == undefined
+ ? undefined
+ : state.credit_facade_credentials?.type === "basic"
+ ? {
+ type: "basic",
+ password: state.credit_facade_credentials.password,
+ username: state.credit_facade_credentials.username,
+ }
+ : {
+ type: "none",
+ };
+
+ return onCreate({
+ payto_uri: state.payto_uri!,
+ credit_facade_credentials,
+ credit_facade_url,
+ });
+ };
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputPaytoForm<Entity>
+ name="payto_uri"
+ label={i18n.str`Account`}
+ />
+ <Input<Entity>
+ name="credit_facade_url"
+ label={i18n.str`Account info URL`}
+ help="https://bank.com"
+ expand
+ tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`}
+ />
+ <InputSelector
+ name="credit_facade_credentials.type"
+ label={i18n.str`Auth type`}
+ tooltip={i18n.str`Choose the authentication type for the account info URL`}
+ values={accountAuthType}
+ toStr={(str) => {
+ if (str === "none") return "Without authentication";
+ return "Username and password";
+ }}
+ />
+ {state.credit_facade_credentials?.type === "basic" ? (
+ <Fragment>
+ <Input
+ name="credit_facade_credentials.username"
+ label={i18n.str`Username`}
+ tooltip={i18n.str`Username to access the account information.`}
+ />
+ <Input
+ name="credit_facade_credentials.password"
+ inputType="password"
+ label={i18n.str`Password`}
+ tooltip={i18n.str`Password to access the account information.`}
+ />
+ <Input
+ name="repeatPassword"
+ inputType="password"
+ label={i18n.str`Repeat password`}
+ />
+ </Fragment>
+ ) : undefined}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx
new file mode 100644
index 000000000..9bab33f6f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx
@@ -0,0 +1,236 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ FacadeCredentials,
+ HttpStatusCode,
+ OperationFail,
+ OperationOk,
+ TalerError,
+ TalerMerchantApi,
+ TalerRevenueHttpClient,
+ assertUnreachable,
+ opFixedSuccess,
+} from "@gnu-taler/taler-util";
+import {
+ BrowserFetchHttpLib,
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+
+export type Entity = TalerMerchantApi.AccountAddDetails;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+
+export default function CreateValidator({ onConfirm, onBack }: Props): VNode {
+ const { lib: api } = useSessionContext();
+ const { state } = useSessionContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ onCreate={async (request: Entity) => {
+ const revenueAPI = !request.credit_facade_url
+ ? undefined
+ : new URL("./", request.credit_facade_url);
+
+ if (revenueAPI) {
+ const resp = await testRevenueAPI(
+ revenueAPI,
+ request.credit_facade_credentials,
+ );
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case TestRevenueErrorType.NO_CONFIG: {
+ setNotif({
+ message: i18n.str`Could not create account`,
+ type: "ERROR",
+ description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`,
+ });
+ return;
+ }
+ case TestRevenueErrorType.CLIENT_BAD_REQUEST: {
+ setNotif({
+ message: i18n.str`Could not create account`,
+ type: "ERROR",
+ description: i18n.str`Server replied with "bad request".`,
+ });
+ return;
+ }
+ case TestRevenueErrorType.UNAUTHORIZED: {
+ setNotif({
+ message: i18n.str`Could not create account`,
+ type: "ERROR",
+ description: i18n.str`Unauthorized, try with another credentials.`,
+ });
+ return;
+ }
+ case TestRevenueErrorType.NOT_FOUND: {
+ setNotif({
+ message: i18n.str`Could not create account`,
+ type: "ERROR",
+ description: i18n.str`Check facade URL, server replied with "not found".`,
+ });
+ return;
+ }
+ case TestRevenueErrorType.GENERIC_ERROR: {
+ setNotif({
+ message: i18n.str`Could not create account`,
+ type: "ERROR",
+ description: resp.detail.hint,
+ });
+ return;
+ }
+ default: {
+ assertUnreachable(resp.case);
+ }
+ }
+ }
+ }
+
+ return api.instance
+ .addBankAccount(state.token, request)
+ .then(() => {
+ onConfirm();
+ })
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create account`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
+
+export enum TestRevenueErrorType {
+ NO_CONFIG,
+ CLIENT_BAD_REQUEST,
+ UNAUTHORIZED,
+ NOT_FOUND,
+ GENERIC_ERROR,
+}
+
+export async function testRevenueAPI(
+ revenueAPI: URL,
+ creds: FacadeCredentials | undefined,
+): Promise<OperationOk<void> | OperationFail<TestRevenueErrorType>> {
+ const api = new TalerRevenueHttpClient(
+ revenueAPI.href,
+ new BrowserFetchHttpLib(),
+ );
+ const auth =
+ creds === undefined
+ ? undefined
+ : creds.type === "none"
+ ? undefined
+ : creds.type === "basic"
+ ? {
+ username: creds.username,
+ password: creds.password,
+ }
+ : undefined;
+
+ try {
+ const config = await api.getConfig(auth);
+
+ if (config.type === "fail") {
+ switch (config.case) {
+ case HttpStatusCode.Unauthorized: {
+ return {
+ type: "fail",
+ case: TestRevenueErrorType.UNAUTHORIZED,
+ detail: {
+ code: 1,
+ },
+ };
+ }
+ case HttpStatusCode.NotFound: {
+ return {
+ type: "fail",
+ case: TestRevenueErrorType.NO_CONFIG,
+ detail: {
+ code: 1,
+ },
+ };
+ }
+ }
+ }
+
+ const history = await api.getHistory(auth);
+
+ if (history.type === "fail") {
+ switch (history.case) {
+ case HttpStatusCode.BadRequest: {
+ return {
+ type: "fail",
+ case: TestRevenueErrorType.CLIENT_BAD_REQUEST,
+ detail: {
+ code: 1,
+ },
+ };
+ }
+ case HttpStatusCode.Unauthorized: {
+ return {
+ type: "fail",
+ case: TestRevenueErrorType.UNAUTHORIZED,
+ detail: {
+ code: 1,
+ },
+ };
+ }
+ case HttpStatusCode.NotFound: {
+ return {
+ type: "fail",
+ case: TestRevenueErrorType.NOT_FOUND,
+ detail: {
+ code: 1,
+ },
+ };
+ }
+ }
+ }
+ } catch (err) {
+ if (err instanceof TalerError) {
+ return {
+ type: "fail",
+ case: TestRevenueErrorType.GENERIC_ERROR,
+ detail: err.errorDetail,
+ };
+ }
+ }
+
+ return opFixedSuccess(undefined);
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx
new file mode 100644
index 000000000..18e762642
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/Accounts/List",
+ component: TestedComponent,
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx
new file mode 100644
index 000000000..4ee68cd80
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { CardTable } from "./Table.js";
+
+export interface Props {
+ devices: TalerMerchantApi.BankAccountSummaryEntry[];
+ // onLoadMoreBefore?: () => void;
+ // onLoadMoreAfter?: () => void;
+ onCreate: () => void;
+ onDelete: (e: TalerMerchantApi.BankAccountSummaryEntry) => void;
+ onSelect: (e: TalerMerchantApi.BankAccountSummaryEntry) => void;
+}
+
+export function ListPage({
+ devices,
+ onCreate,
+ onDelete,
+ onSelect,
+ // onLoadMoreBefore,
+ // onLoadMoreAfter,
+}: Props): VNode {
+
+ return (
+ <section class="section is-main-section">
+ <CardTable
+ accounts={devices.map((o) => ({
+ ...o,
+ id: String(o.h_wire),
+ }))}
+ onCreate={onCreate}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ // onLoadMoreBefore={onLoadMoreBefore}
+ // hasMoreBefore={!onLoadMoreBefore}
+ // onLoadMoreAfter={onLoadMoreAfter}
+ // hasMoreAfter={!onLoadMoreAfter}
+ />
+ </section>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx
new file mode 100644
index 000000000..efe484402
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx
@@ -0,0 +1,359 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { parsePaytoUri, PaytoType, PaytoUri, PaytoUriBitcoin, PaytoUriIBAN, PaytoUriTalerBank, PaytoUriUnknown, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+
+type Entity = TalerMerchantApi.BankAccountSummaryEntry;
+
+interface Props {
+ accounts: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ onCreate: () => void;
+}
+
+export function CardTable({
+ accounts,
+ onCreate,
+ onDelete,
+ onSelect,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <i18n.Translate>Bank accounts</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new accounts`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {accounts.length > 0 ? (
+ <Table
+ accounts={accounts}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ accounts: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+}
+
+function Table({
+ accounts,
+ onDelete,
+ onSelect,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const emptyList: Record<PaytoType | "unknown", { parsed: PaytoUri, acc: Entity }[]> = { "bitcoin": [], "x-taler-bank": [], "iban": [], "unknown": [], }
+ const accountsByType = accounts.reduce((prev, acc) => {
+ const parsed = parsePaytoUri(acc.payto_uri)
+ if (!parsed) return prev //skip
+ if (parsed.targetType !== "bitcoin" && parsed.targetType !== "x-taler-bank" && parsed.targetType !== "iban") {
+ prev["unknown"].push({ parsed, acc })
+ } else {
+ prev[parsed.targetType].push({ parsed, acc })
+ }
+ return prev
+ }, emptyList)
+
+ const bitcoinAccounts = accountsByType["bitcoin"]
+ const talerbankAccounts = accountsByType["x-taler-bank"]
+ const ibanAccounts = accountsByType["iban"]
+ const unkownAccounts = accountsByType["unknown"]
+
+
+ return (
+ <Fragment>
+
+ {bitcoinAccounts.length > 0 && <div class="table-container">
+ <p class="card-header-title"><i18n.Translate>Bitcoin type accounts</i18n.Translate></p>
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Address</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Sewgit 1</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Sewgit 2</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {bitcoinAccounts.map(({ parsed, acc }, idx) => {
+ const ac = parsed as PaytoUriBitcoin
+ return (
+ <tr key={idx}>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.targetPath}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.segwitAddrs[0]}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.segwitAddrs[1]}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected accounts from the database`}
+ onClick={() => onDelete(acc)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>}
+
+
+
+ {talerbankAccounts.length > 0 && <div class="table-container">
+ <p class="card-header-title"><i18n.Translate>Taler type accounts</i18n.Translate></p>
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Host</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Account name</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {talerbankAccounts.map(({ parsed, acc }, idx) => {
+ const ac = parsed as PaytoUriTalerBank
+ return (
+ <tr key={idx}>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.host}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.account}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected accounts from the database`}
+ onClick={() => onDelete(acc)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>}
+
+ {ibanAccounts.length > 0 && <div class="table-container">
+ <p class="card-header-title"><i18n.Translate>IBAN type accounts</i18n.Translate></p>
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Account name</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>IBAN</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>BIC</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {ibanAccounts.map(({ parsed, acc }, idx) => {
+ const ac = parsed as PaytoUriIBAN
+ return (
+ <tr key={idx}>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.params["receiver-name"]}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.iban}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.bic ?? ""}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected accounts from the database`}
+ onClick={() => onDelete(acc)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>}
+
+ {unkownAccounts.length > 0 && <div class="table-container">
+ <p class="card-header-title"><i18n.Translate>Other type accounts</i18n.Translate></p>
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Type</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Path</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {unkownAccounts.map(({ parsed, acc }, idx) => {
+ const ac = parsed as PaytoUriUnknown
+ return (
+ <tr key={idx}>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.targetType}
+ </td>
+ <td
+ onClick={(): void => onSelect(acc)}
+ style={{ cursor: "pointer" }}
+ >
+ {ac.targetPath}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected accounts from the database`}
+ onClick={() => onDelete(acc)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>}
+ </Fragment>
+
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-magnify mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no accounts yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx
new file mode 100644
index 000000000..1eda7382d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx
@@ -0,0 +1,111 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useInstanceBankAccounts } from "../../../../hooks/bank.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { ListPage } from "./ListPage.js";
+
+interface Props {
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+}
+
+export default function ListOtpDevices({
+ onCreate,
+ onSelect,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { lib: api } = useSessionContext();
+ const { state } = useSessionContext();
+ const result = useInstanceBankAccounts();
+
+ if (!result) return <Loading />
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />
+ }
+ if (result.type === "fail") {
+ switch(result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result)
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ {result.body.accounts.length < 1 &&
+ <NotificationCard notification={{
+ type: "WARN",
+ message: i18n.str`You need to associate a bank account to receive revenue.`,
+ description: i18n.str`Without this the merchant backend will refuse to create new orders.`
+ }} />
+ }
+ <ListPage
+ devices={result.body.accounts}
+ // onLoadMoreBefore={
+ // result.isFirstPage ? undefined: result.loadFirst
+ // }
+ // onLoadMoreAfter={result.isLastPage ? undefined : result.loadNext}
+ onCreate={onCreate}
+ onSelect={(e) => {
+ onSelect(e.h_wire);
+ }}
+ onDelete={(e: TalerMerchantApi.BankAccountSummaryEntry) => {
+ return api.instance.deleteBankAccount(state.token, e.h_wire)
+ .then(() =>
+ setNotif({
+ message: i18n.str`bank account delete successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not delete the bank account`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ )
+ }
+ }
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx
new file mode 100644
index 000000000..06ea9d07a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/OtpDevices/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx
new file mode 100644
index 000000000..1a8e9bdc1
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx
@@ -0,0 +1,232 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { undefinedIfEmpty } from "../../../../utils/table.js";
+
+type Entity = TalerMerchantApi.BankAccountEntry & WithId;
+
+const accountAuthType = ["unedit", "none", "basic"];
+interface Props {
+ onUpdate: (d: TalerMerchantApi.AccountPatchDetails) => Promise<void>;
+ onBack?: () => void;
+ account: Entity;
+}
+
+export function UpdatePage({ account, onUpdate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] =
+ useState<Partial<TalerMerchantApi.AccountPatchDetails>>(account);
+
+ // @ts-expect-error "unedit" is fine since is part of the accountAuthType values
+ if (state.credit_facade_credentials?.type === "unedit") {
+ // we use this to set creds to undefined but server don't get this type
+ state.credit_facade_credentials = undefined;
+ }
+
+ const facadeURL = safeConvertURL(state.credit_facade_url);
+
+ const errors: FormErrors<TalerMerchantApi.AccountPatchDetails> = {
+ credit_facade_url: !state.credit_facade_url
+ ? undefined
+ : !facadeURL
+ ? i18n.str`Invalid url`
+ : !facadeURL.href.endsWith("/")
+ ? i18n.str`URL should end with a '/'`
+ : facadeURL.searchParams.size > 0
+ ? i18n.str`URL should not contain params`
+ : facadeURL.hash
+ ? i18n.str`URL should not hash param`
+ : undefined,
+ credit_facade_credentials: undefinedIfEmpty({
+ username:
+ state.credit_facade_credentials?.type !== "basic"
+ ? undefined
+ : !state.credit_facade_credentials.username
+ ? i18n.str`required`
+ : undefined,
+
+ password:
+ state.credit_facade_credentials?.type !== "basic"
+ ? undefined
+ : !state.credit_facade_credentials.password
+ ? i18n.str`required`
+ : undefined,
+
+ repeatPassword:
+ state.credit_facade_credentials?.type !== "basic"
+ ? undefined
+ : !(state.credit_facade_credentials as any).repeatPassword
+ ? i18n.str`required`
+ : (state.credit_facade_credentials as any).repeatPassword !==
+ state.credit_facade_credentials.password
+ ? i18n.str`doesn't match`
+ : undefined,
+ }),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ const credit_facade_url = !state.credit_facade_url
+ ? undefined
+ : facadeURL?.href;
+
+ const credit_facade_credentials:
+ | TalerMerchantApi.FacadeCredentials
+ | undefined =
+ credit_facade_url == undefined ||
+ state.credit_facade_credentials === undefined
+ ? undefined
+ : state.credit_facade_credentials.type === "basic"
+ ? {
+ type: "basic",
+ password: state.credit_facade_credentials.password,
+ username: state.credit_facade_credentials.username,
+ }
+ : {
+ type: "none",
+ };
+
+ return onUpdate({ credit_facade_credentials, credit_facade_url });
+ };
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ Account: <b>{account.id.substring(0, 8)}...</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputPaytoForm<Entity>
+ name="payto_uri"
+ label={i18n.str`Account`}
+ readonly
+ />
+ <Input<Entity>
+ name="credit_facade_url"
+ label={i18n.str`Account info URL`}
+ help="https://bank.com"
+ expand
+ tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`}
+ />
+ <InputSelector
+ name="credit_facade_credentials.type"
+ label={i18n.str`Auth type`}
+ tooltip={i18n.str`Choose the authentication type for the account info URL`}
+ values={accountAuthType}
+ toStr={(str) => {
+ if (str === "none") return "Without authentication";
+ if (str === "basic") return "With authentication";
+ return "Do not change";
+ }}
+ />
+ {state.credit_facade_credentials?.type === "basic" ? (
+ <Fragment>
+ <Input
+ name="credit_facade_credentials.username"
+ label={i18n.str`Username`}
+ tooltip={i18n.str`Username to access the account information.`}
+ />
+ <Input
+ name="credit_facade_credentials.password"
+ inputType="password"
+ label={i18n.str`Password`}
+ tooltip={i18n.str`Password to access the account information.`}
+ />
+ <Input
+ name="credit_facade_credentials.repeatPassword"
+ inputType="password"
+ label={i18n.str`Repeat password`}
+ />
+ </Fragment>
+ ) : undefined}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ </div>
+ </section>
+ </section>
+ </div>
+ );
+}
+
+//TODO: move to utils
+export function safeConvertURL(s?: string): URL | undefined {
+ if (!s) return undefined;
+ try {
+ return new URL(s);
+ } catch (e) {
+ return undefined;
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx
new file mode 100644
index 000000000..70942fd55
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx
@@ -0,0 +1,153 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useBankAccountDetails } from "../../../../hooks/bank.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { TestRevenueErrorType, testRevenueAPI } from "../create/index.js";
+import { UpdatePage } from "./UpdatePage.js";
+
+export type Entity = TalerMerchantApi.AccountPatchDetails & WithId;
+
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ bid: string;
+}
+export default function UpdateValidator({
+ bid,
+ onConfirm,
+ onBack,
+}: Props): VNode {
+ const { lib: api } = useSessionContext();
+ const { state } = useSessionContext();
+ const result = useBankAccountDetails(bid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ account={{ ...result.body, id: bid }}
+ onBack={onBack}
+ onUpdate={async (request) => {
+ const revenueAPI = !request.credit_facade_url
+ ? undefined
+ : new URL("./", request.credit_facade_url);
+
+ if (revenueAPI) {
+ const resp = await testRevenueAPI(
+ revenueAPI,
+ request.credit_facade_credentials,
+ );
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case TestRevenueErrorType.NO_CONFIG: {
+ setNotif({
+ message: i18n.str`Could not create account`,
+ type: "ERROR",
+ description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`,
+ });
+ return;
+ }
+ case TestRevenueErrorType.CLIENT_BAD_REQUEST: {
+ setNotif({
+ message: i18n.str`Could not create account`,
+ type: "ERROR",
+ description: i18n.str`Server replied with "bad request".`,
+ });
+ return;
+ }
+ case TestRevenueErrorType.UNAUTHORIZED: {
+ setNotif({
+ message: i18n.str`Could not create account`,
+ type: "ERROR",
+ description: i18n.str`Unauthorized, try with another credentials.`,
+ });
+ return;
+ }
+ case TestRevenueErrorType.NOT_FOUND: {
+ setNotif({
+ message: i18n.str`Could not create account`,
+ type: "ERROR",
+ description: i18n.str`Check facade URL, server replied with "not found".`,
+ });
+ return;
+ }
+ case TestRevenueErrorType.GENERIC_ERROR: {
+ setNotif({
+ message: i18n.str`Could not create account`,
+ type: "ERROR",
+ description: resp.detail.hint,
+ });
+ return;
+ }
+ default: {
+ assertUnreachable(resp.case)
+ }
+ }
+ }
+ }
+ return api.instance.updateBankAccount(state.token, bid, request)
+ .then(onConfirm)
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not update account`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx
new file mode 100644
index 000000000..3168c7cc4
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx
@@ -0,0 +1,83 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider } from "../../../components/form/FormProvider.js";
+import { Input } from "../../../components/form/Input.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+type Entity = TalerMerchantApi.InstanceReconfigurationMessage;
+interface Props {
+ onUpdate: () => void;
+ onDelete: () => void;
+ selected: TalerMerchantApi.QueryInstancesResponse;
+}
+
+function convert(
+ from: TalerMerchantApi.QueryInstancesResponse,
+): Entity {
+ const defaults = {
+ default_wire_fee_amortization: 1,
+ use_stefan: true,
+ default_pay_delay: { d_us: 1000 * 60 * 60 * 1000 }, //one hour
+ default_wire_transfer_delay: { d_us: 1000 * 60 * 60 * 2 * 1000 }, //two hours
+ };
+ return { ...defaults, ...from };
+}
+
+export function DetailPage({ selected }: Props): VNode {
+ const [value, valueHandler] = useState<Partial<Entity>>(convert(selected));
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <h1 class="title">Here goes the instance description</h1>
+ </div>
+ </div>
+ <div class="level-right" style="display: none;">
+ <div class="level-item" />
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-6">
+ <FormProvider<Entity> object={value} valueHandler={valueHandler}>
+ <Input<Entity> name="name" readonly label={i18n.str`Name`} />
+ </FormProvider>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx
new file mode 100644
index 000000000..e1a7f87f0
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/details/index.tsx
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { HttpStatusCode, TalerError, assertUnreachable } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../components/exception/loading.js";
+import { DeleteModal } from "../../../components/modal/index.js";
+import { useSessionContext } from "../../../context/session.js";
+import { useInstanceDetails } from "../../../hooks/instance.js";
+import { LoginPage } from "../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../notfound/index.js";
+import { DetailPage } from "./DetailPage.js";
+
+interface Props {
+ onUpdate: () => void;
+ onDelete: () => void;
+}
+
+export default function Detail({
+ onUpdate,
+ onDelete,
+}: Props): VNode {
+ const { state } = useSessionContext();
+ const result = useInstanceDetails();
+ const [deleting, setDeleting] = useState<boolean>(false);
+
+ // const { deleteInstance } = useInstanceAPI();
+ const { lib } = useSessionContext();
+
+ if (!result) return <Loading />
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />
+ }
+ if (result.type === "fail") {
+ switch(result.case) {
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ default: {
+ assertUnreachable(result)
+ }
+ }
+ }
+
+
+ return (
+ <Fragment>
+ <DetailPage
+ selected={result.body}
+ onUpdate={onUpdate}
+ onDelete={() => setDeleting(true)}
+ />
+ {deleting && (
+ <DeleteModal
+ element={{ name: result.body.name, id: state.instance }}
+ onCancel={() => setDeleting(false)}
+ onConfirm={async (): Promise<void> => {
+ if (state.status !== "loggedIn") {
+ return
+ }
+ try {
+ await lib.instance.deleteCurrentInstance(state.token);
+ onDelete();
+ } catch (error) {
+ //FIXME: show message error
+ }
+ setDeleting(false);
+ }}
+ />
+ )}
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx
new file mode 100644
index 000000000..42cb1cb02
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx
@@ -0,0 +1,87 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { MerchantApiProviderTesting } from "@gnu-taler/web-util/browser";
+import { FunctionalComponent, h } from "preact";
+import { DetailPage as TestedComponent } from "./DetailPage.js";
+
+export default {
+ title: "Pages/Instance/Detail",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Internal: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const component = (args: any) => (
+ <MerchantApiProviderTesting
+ value={{
+ cancelRequest: () => { },
+ changeBackend: () => { },
+ config: {
+ currency: "ARS",
+ version: "1",
+ currencies: {
+ "ASD": {
+ name: "testkudos",
+ alt_unit_names: {},
+ num_fractional_input_digits: 1,
+ num_fractional_normal_digits: 1,
+ num_fractional_trailing_zero_digits: 1,
+ }
+ },
+ exchanges: [],
+ name: "taler-merchant"
+ },
+ hints: [],
+ lib: {} as any,
+ onActivity: (() => { }) as any,
+ url: new URL("asdasd"),
+ }}
+ >
+ <Internal {...(props as any)} />
+ </MerchantApiProviderTesting>
+ );
+ return { component, props };
+}
+
+export const Example = createExample(TestedComponent, {
+ selected: {
+ name: "name",
+ auth: { method: "external" },
+ address: {},
+ user_type: "business",
+ jurisdiction: {},
+ use_stefan: true,
+ default_pay_delay: {
+ d_us: 1000 * 1000, //one second
+ },
+ default_wire_transfer_delay: {
+ d_us: 1000 * 1000, //one second
+ },
+ merchant_pub: "ASDWQEKASJDKSADJ",
+ },
+});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/index.stories.ts b/packages/merchant-backoffice-ui/src/paths/instance/index.stories.ts
new file mode 100644
index 000000000..8f06937df
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/index.stories.ts
@@ -0,0 +1,18 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export * as details from "./details/stories.js";
+export * as kycList from "./kyc/list/ListPage.stories.js";
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx
new file mode 100644
index 000000000..046636b4b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx
@@ -0,0 +1,57 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { PaytoString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/KYC/List",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+export const Example = tests.createExample(TestedComponent, {
+ status: {
+ timeout_kycs: [],
+ pending_kycs: [
+ {
+ aml_status: 0,
+ exchange_url: "http://exchange.taler",
+ payto_uri: "payto://iban/de123123123" as PaytoString,
+ kyc_url: "http://exchange.taler/kyc",
+ },
+ {
+ aml_status: 1,
+ exchange_url: "http://exchange.taler",
+ payto_uri: "payto://iban/de123123123" as PaytoString,
+ },
+ {
+ aml_status: 2,
+ exchange_url: "http://exchange.taler",
+ payto_uri: "payto://iban/de123123123" as PaytoString,
+ },
+ ],
+ },
+});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx
new file mode 100644
index 000000000..3eeed1d7b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx
@@ -0,0 +1,208 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+
+export interface Props {
+ status: TalerMerchantApi.AccountKycRedirects;
+}
+
+export function ListPage({ status }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <section class="section is-main-section">
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-clock" />
+ </span>
+ <i18n.Translate>Pending KYC verification</i18n.Translate>
+ </p>
+
+ <div class="card-header-icon" aria-label="more options" />
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {status.pending_kycs.length > 0 ? (
+ <PendingTable entries={status.pending_kycs} />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {status.timeout_kycs.length > 0 ? (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-clock" />
+ </span>
+ <i18n.Translate>Timed out</i18n.Translate>
+ </p>
+
+ <div class="card-header-icon" aria-label="more options" />
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {status.timeout_kycs.length > 0 ? (
+ <TimedOutTable entries={status.timeout_kycs} />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ ) : undefined}
+ </section>
+ );
+}
+interface PendingTableProps {
+ entries: TalerMerchantApi.MerchantAccountKycRedirect[];
+}
+
+interface TimedOutTableProps {
+ entries: TalerMerchantApi.ExchangeKycTimeout[];
+}
+
+function PendingTable({ entries }: PendingTableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ <table class="table is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Exchange</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Target account</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Reason</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {entries.map((e, i) => {
+ if (e.kyc_url === undefined) {
+ // blocked by AML
+ return (
+ <tr key={i}>
+ <td>{e.exchange_url}</td>
+ <td>{e.payto_uri}</td>
+ <td>
+ {e.aml_status === 1 ? (
+ <i18n.Translate>
+ There is an anti-money laundering process pending to
+ complete.
+ </i18n.Translate>
+ ) : (
+ <i18n.Translate>
+ The account is frozen due to the anti-money laundering
+ rules. Contact the exchange service provider for further
+ instructions.
+ </i18n.Translate>
+ )}
+ </td>
+ </tr>
+ );
+ } else {
+ // blocked by KYC
+ return (
+ <tr key={i}>
+ <td>{e.exchange_url}</td>
+ <td>{e.payto_uri}</td>
+ <td>
+ <a href={e.kyc_url} target="_black" rel="noreferrer">
+ <i18n.Translate>
+ Pending KYC process, click here to complete
+ </i18n.Translate>
+ </a>
+ </td>
+ </tr>
+ );
+ }
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+function TimedOutTable({ entries }: TimedOutTableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ <table class="table is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Exchange</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Code</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Http Status</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {entries.map((e, i) => {
+ return (
+ <tr key={i}>
+ <td>{e.exchange_url}</td>
+ <td>{e.exchange_code}</td>
+ <td>{e.exchange_http_status}</td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-emoticon-happy mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>No pending kyc verification!</i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx
new file mode 100644
index 000000000..ed0e1220f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx
@@ -0,0 +1,76 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, assertUnreachable } from "@gnu-taler/taler-util";
+import { VNode, h } from "preact";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { useInstanceKYCDetails } from "../../../../hooks/instance.js";
+import { ListPage } from "./ListPage.js";
+
+interface Props {
+}
+
+export default function ListKYC(_p: Props): VNode {
+ const result = useInstanceKYCDetails();
+ if (!result) return <Loading />
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />
+ }
+ /**
+ * This component just render known kyc requirements.
+ * If query fail then is safe to hide errors.
+ */
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.GatewayTimeout: {
+ return <div />
+ }
+ case HttpStatusCode.BadGateway: {
+ const status = result.body;
+
+ if (!status) {
+ return <div>no kyc required</div>;
+ }
+ return <ListPage status={status} />;
+
+ }
+ case HttpStatusCode.ServiceUnavailable: {
+ return <div />
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <div />
+ }
+ case HttpStatusCode.NotFound: {
+ return <div />;
+ }
+ default: {
+ assertUnreachable(result)
+ }
+ }
+ }
+ const status = result.body;
+
+ if (!status) {
+ return <div>no kyc required</div>;
+ }
+ return <ListPage status={status} />;
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx
new file mode 100644
index 000000000..fc814b68f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Order/Create",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ goBack: { action: "goBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ instanceConfig: {
+ default_pay_delay: {
+ d_us: 1000 * 1000 * 60 * 60, //one hour
+ },
+ default_wire_transfer_delay: {
+ d_us: 1000 * 1000 * 60 * 60, //one hour
+ },
+ use_stefan: true,
+ },
+ instanceInventory: [
+ {
+ id: "t-shirt-1",
+ description: "a m size t-shirt",
+ price: "TESTKUDOS:1",
+ total_stock: -1,
+ },
+ {
+ id: "t-shirt-2",
+ price: "TESTKUDOS:1",
+ description: "a xl size t-shirt",
+ } as any,
+ {
+ id: "t-shirt-3",
+ price: "TESTKUDOS:1",
+ description: "a s size t-shirt",
+ } as any,
+ ],
+});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx
new file mode 100644
index 000000000..7be3d23f6
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx
@@ -0,0 +1,794 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AbsoluteTime,
+ AmountString,
+ Amounts,
+ Duration,
+ TalerMerchantApi,
+ TalerProtocolDuration,
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { format, isFuture } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputDate } from "../../../../components/form/InputDate.js";
+import { InputDuration } from "../../../../components/form/InputDuration.js";
+import { InputGroup } from "../../../../components/form/InputGroup.js";
+import { InputLocation } from "../../../../components/form/InputLocation.js";
+import { InputNumber } from "../../../../components/form/InputNumber.js";
+import { InputToggle } from "../../../../components/form/InputToggle.js";
+import { InventoryProductForm } from "../../../../components/product/InventoryProductForm.js";
+import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm.js";
+import { ProductList } from "../../../../components/product/ProductList.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { usePreference } from "../../../../hooks/preference.js";
+import { rate } from "../../../../utils/amount.js";
+import { undefinedIfEmpty } from "../../../../utils/table.js";
+
+interface Props {
+ onCreate: (d: TalerMerchantApi.PostOrderRequest) => void;
+ onBack?: () => void;
+ instanceConfig: InstanceConfig;
+ instanceInventory: (TalerMerchantApi.ProductDetail & WithId)[];
+}
+interface InstanceConfig {
+ use_stefan: boolean;
+ default_pay_delay: TalerProtocolDuration;
+ default_wire_transfer_delay: TalerProtocolDuration;
+}
+
+function with_defaults(
+ config: InstanceConfig,
+ _currency: string,
+): Partial<Entity> {
+ const defaultPayDeadline = Duration.fromTalerProtocolDuration(
+ config.default_pay_delay,
+ );
+ const defaultWireDeadline = Duration.fromTalerProtocolDuration(
+ config.default_wire_transfer_delay,
+ );
+
+ return {
+ inventoryProducts: {},
+ products: [],
+ pricing: {},
+ payments: {
+ max_fee: undefined,
+ createToken: true,
+ pay_deadline: defaultPayDeadline,
+ refund_deadline: defaultPayDeadline,
+ wire_transfer_deadline: defaultWireDeadline,
+ },
+ shipping: {},
+ extra: {},
+ };
+}
+
+interface ProductAndQuantity {
+ product: TalerMerchantApi.ProductDetail & WithId;
+ quantity: number;
+}
+export interface ProductMap {
+ [id: string]: ProductAndQuantity;
+}
+
+interface Pricing {
+ products_price: string;
+ order_price: string;
+ summary: string;
+}
+interface Shipping {
+ delivery_date?: Date;
+ delivery_location?: TalerMerchantApi.Location;
+ fullfilment_url?: string;
+}
+interface Payments {
+ refund_deadline: Duration;
+ pay_deadline: Duration;
+ wire_transfer_deadline: Duration;
+ auto_refund_deadline: Duration;
+ max_fee?: string;
+ createToken: boolean;
+ minimum_age?: number;
+}
+interface Entity {
+ inventoryProducts: ProductMap;
+ products: TalerMerchantApi.Product[];
+ pricing: Partial<Pricing>;
+ payments: Partial<Payments>;
+ shipping: Partial<Shipping>;
+ extra: Record<string, string>;
+}
+
+export function CreatePage({
+ onCreate,
+ onBack,
+ instanceConfig,
+ instanceInventory,
+}: Props): VNode {
+ const { config } = useSessionContext();
+ const instance_default = with_defaults(instanceConfig, config.currency);
+ const [value, valueHandler] = useState(instance_default);
+ const zero = Amounts.zeroOfCurrency(config.currency);
+ const [settings, updateSettings] = usePreference();
+ const inventoryList = Object.values(value.inventoryProducts || {});
+ const productList = Object.values(value.products || {});
+
+ const { i18n } = useTranslationContext();
+
+ const parsedPrice = !value.pricing?.order_price
+ ? undefined
+ : Amounts.parse(value.pricing.order_price);
+
+ const errors: FormErrors<Entity> = {
+ pricing: undefinedIfEmpty({
+ summary: !value.pricing?.summary ? i18n.str`required` : undefined,
+ order_price: !value.pricing?.order_price
+ ? i18n.str`required`
+ : !parsedPrice
+ ? i18n.str`not valid`
+ : Amounts.isZero(parsedPrice)
+ ? i18n.str`must be greater than 0`
+ : undefined,
+ }),
+ payments: undefinedIfEmpty({
+ refund_deadline: !value.payments?.refund_deadline
+ ? undefined
+ : value.payments.pay_deadline &&
+ Duration.cmp(
+ value.payments.refund_deadline,
+ value.payments.pay_deadline,
+ ) === -1
+ ? i18n.str`refund deadline cannot be before pay deadline`
+ : value.payments.wire_transfer_deadline &&
+ Duration.cmp(
+ value.payments.wire_transfer_deadline,
+ value.payments.refund_deadline,
+ ) === -1
+ ? i18n.str`wire transfer deadline cannot be before refund deadline`
+ : undefined,
+ pay_deadline: !value.payments?.pay_deadline
+ ? i18n.str`required`
+ : value.payments.wire_transfer_deadline &&
+ Duration.cmp(
+ value.payments.wire_transfer_deadline,
+ value.payments.pay_deadline,
+ ) === -1
+ ? i18n.str`wire transfer deadline cannot be before pay deadline`
+ : undefined,
+ wire_transfer_deadline: !value.payments?.wire_transfer_deadline
+ ? i18n.str`required`
+ : undefined,
+ auto_refund_deadline: !value.payments?.auto_refund_deadline
+ ? undefined
+ : !value.payments?.refund_deadline
+ ? i18n.str`should have a refund deadline`
+ : Duration.cmp(
+ value.payments.refund_deadline,
+ value.payments.auto_refund_deadline,
+ ) == -1
+ ? i18n.str`auto refund cannot be after refund deadline`
+ : undefined,
+ }),
+ shipping: undefinedIfEmpty({
+ delivery_date: !value.shipping?.delivery_date
+ ? undefined
+ : !isFuture(value.shipping.delivery_date)
+ ? i18n.str`should be in the future`
+ : undefined,
+ }),
+ };
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submit = (): void => {
+ const order = value as any; //schema.cast(value);
+ if (!value.payments) return;
+ if (!value.shipping) return;
+
+ const request: TalerMerchantApi.PostOrderRequest = {
+ order: {
+ amount: order.pricing.order_price,
+ summary: order.pricing.summary,
+ products: productList,
+ extra: undefinedIfEmpty(value.extra),
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ value.payments.pay_deadline!,
+ ),
+ ),
+ wire_transfer_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ value.payments.wire_transfer_deadline!,
+ ),
+ ),
+ refund_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ value.payments.refund_deadline!,
+ ),
+ ),
+ auto_refund: value.payments.auto_refund_deadline
+ ? Duration.toTalerProtocolDuration(
+ value.payments.auto_refund_deadline,
+ )
+ : undefined,
+ max_fee: value.payments.max_fee as AmountString,
+ delivery_date: value.shipping.delivery_date
+ ? { t_s: value.shipping.delivery_date.getTime() / 1000 }
+ : undefined,
+ delivery_location: value.shipping.delivery_location,
+ fulfillment_url: value.shipping.fullfilment_url,
+ minimum_age: value.payments.minimum_age,
+ },
+ inventory_products: inventoryList.map((p) => ({
+ product_id: p.product.id,
+ quantity: p.quantity,
+ })),
+ create_token: value.payments.createToken,
+ };
+
+ onCreate(request);
+ };
+
+ const addProductToTheInventoryList = (
+ product: TalerMerchantApi.ProductDetail & WithId,
+ quantity: number,
+ ) => {
+ valueHandler((v) => {
+ const inventoryProducts = { ...v.inventoryProducts };
+ inventoryProducts[product.id] = { product, quantity };
+ return { ...v, inventoryProducts };
+ });
+ };
+
+ const removeProductFromTheInventoryList = (id: string) => {
+ valueHandler((v) => {
+ const inventoryProducts = { ...v.inventoryProducts };
+ delete inventoryProducts[id];
+ return { ...v, inventoryProducts };
+ });
+ };
+
+ const addNewProduct = async (product: TalerMerchantApi.Product) => {
+ return valueHandler((v) => {
+ const products = v.products ? [...v.products, product] : [];
+ return { ...v, products };
+ });
+ };
+
+ const removeFromNewProduct = (index: number) => {
+ valueHandler((v) => {
+ const products = v.products ? [...v.products] : [];
+ products.splice(index, 1);
+ return { ...v, products };
+ });
+ };
+
+ const [editingProduct, setEditingProduct] = useState<
+ TalerMerchantApi.Product | undefined
+ >(undefined);
+
+ const totalPriceInventory = inventoryList.reduce((prev, cur) => {
+ const p = Amounts.parseOrThrow(cur.product.price);
+ return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount;
+ }, zero);
+
+ const totalPriceProducts = productList.reduce((prev, cur) => {
+ if (!cur.price) return zero;
+ const p = Amounts.parseOrThrow(cur.price);
+ return Amounts.add(prev, Amounts.mult(p, cur.quantity ?? 0).amount).amount;
+ }, zero);
+
+ const hasProducts = inventoryList.length > 0 || productList.length > 0;
+ const totalPrice = Amounts.add(totalPriceInventory, totalPriceProducts);
+
+ const totalAsString = Amounts.stringify(totalPrice.amount);
+ const allProducts = productList.concat(inventoryList.map(asProduct));
+
+ const [newField, setNewField] = useState("");
+
+ useEffect(() => {
+ valueHandler((v) => {
+ return {
+ ...v,
+ pricing: {
+ ...v.pricing,
+ products_price: hasProducts ? totalAsString : undefined,
+ order_price: hasProducts ? totalAsString : undefined,
+ },
+ };
+ });
+ }, [hasProducts, totalAsString]);
+
+ const discountOrRise = rate(
+ parsedPrice ?? Amounts.zeroOfCurrency(config.currency),
+ totalPrice.amount,
+ );
+
+ const minAgeByProducts = inventoryList.reduce(
+ (cur, prev) =>
+ !prev.product.minimum_age || cur > prev.product.minimum_age ? cur : prev.product.minimum_age,
+ 0,
+ );
+
+ // if there is no default pay deadline
+ const noDefault_payDeadline =
+ !instance_default.payments || !instance_default.payments.pay_deadline;
+ // and there is no default wire deadline
+ const noDefault_wireDeadline =
+ !instance_default.payments ||
+ !instance_default.payments.wire_transfer_deadline;
+ // user required to set the taler options
+ const requiresSomeTalerOptions =
+ noDefault_payDeadline || noDefault_wireDeadline;
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="tabs is-toggle is-fullwidth is-small">
+ <ul>
+ <li
+ class={!settings.advanceOrderMode ? "is-active" : ""}
+ onClick={() => {
+ updateSettings("advanceOrderMode", false);
+ }}
+ >
+ <a>
+ <span>
+ <i18n.Translate>Simple</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ <li
+ class={settings.advanceOrderMode ? "is-active" : ""}
+ onClick={() => {
+ updateSettings("advanceOrderMode", true);
+ }}
+ >
+ <a>
+ <span>
+ <i18n.Translate>Advanced</i18n.Translate>
+ </span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ {/* // FIXME: translating plural singular */}
+ <InputGroup
+ name="inventory_products"
+ label={i18n.str`Manage products in order`}
+ alternative={
+ allProducts.length > 0 && (
+ <p>
+ {allProducts.length} products with a total price of{" "}
+ {totalAsString}.
+ </p>
+ )
+ }
+ tooltip={i18n.str`Manage list of products in the order.`}
+ >
+ <InventoryProductForm
+ currentProducts={value.inventoryProducts || {}}
+ onAddProduct={addProductToTheInventoryList}
+ inventory={instanceInventory}
+ />
+
+ {settings.advanceOrderMode && (
+ <NonInventoryProductFrom
+ productToEdit={editingProduct}
+ onAddProduct={(p) => {
+ setEditingProduct(undefined);
+ return addNewProduct(p);
+ }}
+ />
+ )}
+
+ {allProducts.length > 0 && (
+ <ProductList
+ list={allProducts}
+ actions={[
+ {
+ name: i18n.str`Remove`,
+ tooltip: i18n.str`Remove this product from the order.`,
+ handler: (e, index) => {
+ if (e.product_id) {
+ removeProductFromTheInventoryList(e.product_id);
+ } else {
+ removeFromNewProduct(index);
+ setEditingProduct(e);
+ }
+ },
+ },
+ ]}
+ />
+ )}
+ </InputGroup>
+
+ <FormProvider<Entity>
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler as any}
+ >
+ {hasProducts ? (
+ <Fragment>
+ <InputCurrency
+ name="pricing.products_price"
+ label={i18n.str`Total price`}
+ readonly
+ tooltip={i18n.str`total product price added up`}
+ />
+ <InputCurrency
+ name="pricing.order_price"
+ label={i18n.str`Total price`}
+ addonAfter={
+ discountOrRise > 0 &&
+ (discountOrRise < 1
+ ? `discount of %${Math.round(
+ (1 - discountOrRise) * 100,
+ )}`
+ : `rise of %${Math.round((discountOrRise - 1) * 100)}`)
+ }
+ tooltip={i18n.str`Amount to be paid by the customer`}
+ />
+ </Fragment>
+ ) : (
+ <InputCurrency
+ name="pricing.order_price"
+ label={i18n.str`Order price`}
+ tooltip={i18n.str`final order price`}
+ />
+ )}
+
+ <Input
+ name="pricing.summary"
+ inputType="multiline"
+ label={i18n.str`Summary`}
+ tooltip={i18n.str`Title of the order to be shown to the customer`}
+ />
+
+ {settings.advanceOrderMode && (
+ <InputGroup
+ name="shipping"
+ label={i18n.str`Shipping and Fulfillment`}
+ initialActive
+ >
+ <InputDate
+ name="shipping.delivery_date"
+ label={i18n.str`Delivery date`}
+ tooltip={i18n.str`Deadline for physical delivery assured by the merchant.`}
+ />
+ {value.shipping?.delivery_date && (
+ <InputGroup
+ name="shipping.delivery_location"
+ label={i18n.str`Location`}
+ tooltip={i18n.str`address where the products will be delivered`}
+ >
+ <InputLocation name="shipping.delivery_location" />
+ </InputGroup>
+ )}
+ <Input
+ name="shipping.fullfilment_url"
+ label={i18n.str`Fulfillment URL`}
+ tooltip={i18n.str`URL to which the user will be redirected after successful payment.`}
+ />
+ </InputGroup>
+ )}
+
+ {(settings.advanceOrderMode || requiresSomeTalerOptions) && (
+ <InputGroup
+ name="payments"
+ label={i18n.str`Taler payment options`}
+ tooltip={i18n.str`Override default Taler payment settings for this order`}
+ >
+ {(settings.advanceOrderMode || noDefault_payDeadline) && (
+ <InputDuration
+ name="payments.pay_deadline"
+ label={i18n.str`Payment time`}
+ help={
+ <DeadlineHelp duration={value.payments?.pay_deadline} />
+ }
+ withForever
+ withoutClear
+ tooltip={i18n.str`Time for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline. Time start to run after the order is created.`}
+ side={
+ <span>
+ <button
+ class="button"
+ onClick={() => {
+ const c = {
+ ...value,
+ payments: {
+ ...(value.payments ?? {}),
+ pay_deadline:
+ instance_default.payments?.pay_deadline,
+ },
+ };
+ valueHandler(c);
+ }}
+ >
+ <i18n.Translate>default</i18n.Translate>
+ </button>
+ </span>
+ }
+ />
+ )}
+ {settings.advanceOrderMode && (
+ <InputDuration
+ name="payments.refund_deadline"
+ label={i18n.str`Refund time`}
+ help={
+ <DeadlineHelp
+ duration={value.payments?.refund_deadline}
+ />
+ }
+ withForever
+ withoutClear
+ tooltip={i18n.str`Time while the order can be refunded by the merchant. Time starts after the order is created.`}
+ side={
+ <span>
+ <button
+ class="button"
+ onClick={() => {
+ valueHandler({
+ ...value,
+ payments: {
+ ...(value.payments ?? {}),
+ refund_deadline:
+ instance_default.payments?.refund_deadline,
+ },
+ });
+ }}
+ >
+ <i18n.Translate>default</i18n.Translate>
+ </button>
+ </span>
+ }
+ />
+ )}
+ {(settings.advanceOrderMode || noDefault_wireDeadline) && (
+ <InputDuration
+ name="payments.wire_transfer_deadline"
+ label={i18n.str`Wire transfer time`}
+ help={
+ <DeadlineHelp
+ duration={value.payments?.wire_transfer_deadline}
+ />
+ }
+ withoutClear
+ withForever
+ tooltip={i18n.str`Time for the exchange to make the wire transfer. Time starts after the order is created.`}
+ side={
+ <span>
+ <button
+ class="button"
+ onClick={() => {
+ valueHandler({
+ ...value,
+ payments: {
+ ...(value.payments ?? {}),
+ wire_transfer_deadline:
+ instance_default.payments
+ ?.wire_transfer_deadline,
+ },
+ });
+ }}
+ >
+ <i18n.Translate>default</i18n.Translate>
+ </button>
+ </span>
+ }
+ />
+ )}
+ {settings.advanceOrderMode && (
+ <InputDuration
+ name="payments.auto_refund_deadline"
+ label={i18n.str`Auto-refund time`}
+ help={
+ <DeadlineHelp
+ duration={value.payments?.auto_refund_deadline}
+ />
+ }
+ tooltip={i18n.str`Time until which the wallet will automatically check for refunds without user interaction.`}
+ withForever
+ />
+ )}
+
+ {settings.advanceOrderMode && (
+ <InputCurrency
+ name="payments.max_fee"
+ label={i18n.str`Maximum fee`}
+ tooltip={i18n.str`Maximum fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`}
+ />
+ )}
+ {settings.advanceOrderMode && (
+ <InputToggle
+ name="payments.createToken"
+ label={i18n.str`Create token`}
+ tooltip={i18n.str`If the order ID is easy to guess the token will prevent user to steal orders from others.`}
+ />
+ )}
+ {settings.advanceOrderMode && (
+ <InputNumber
+ name="payments.minimum_age"
+ label={i18n.str`Minimum age required`}
+ tooltip={i18n.str`Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products`}
+ help={
+ minAgeByProducts > 0
+ ? i18n.str`Min age defined by the producs is ${minAgeByProducts}`
+ : i18n.str`No product with age restriction in this order`
+ }
+ />
+ )}
+ </InputGroup>
+ )}
+
+ {settings.advanceOrderMode && (
+ <InputGroup
+ name="extra"
+ label={i18n.str`Additional information`}
+ tooltip={i18n.str`Custom information to be included in the contract for this order.`}
+ >
+ {Object.keys(value.extra ?? {}).map((key, idx) => {
+ return (
+ <Input
+ name={`extra.${key}`}
+ key={String(idx)}
+ inputType="multiline"
+ label={key}
+ tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`}
+ side={
+ <button
+ class="button"
+ onClick={(e) => {
+ if (
+ value.extra &&
+ value.extra[key] !== undefined
+ ) {
+ delete value.extra[key];
+ }
+ valueHandler({
+ ...value,
+ });
+ e.preventDefault();
+ }}
+ >
+ remove
+ </button>
+ }
+ />
+ );
+ })}
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Custom field name</i18n.Translate>
+ <span
+ class="icon has-tooltip-right"
+ data-tooltip={"new extra field"}
+ >
+ <i class="mdi mdi-information" />
+ </span>
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ class="input "
+ value={newField}
+ onChange={(e) => setNewField(e.currentTarget.value)}
+ />
+ </p>
+ </div>
+ </div>
+ <button
+ class="button"
+ onClick={(e) => {
+ setNewField("");
+ valueHandler({
+ ...value,
+ extra: {
+ ...(value.extra ?? {}),
+ [newField]: "",
+ },
+ });
+ e.preventDefault();
+ }}
+ >
+ add
+ </button>
+ </div>
+ </InputGroup>
+ )}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <button
+ class="button is-success"
+ onClick={submit}
+ disabled={hasErrors}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
+
+function asProduct(p: ProductAndQuantity): TalerMerchantApi.Product {
+ return {
+ product_id: p.product.id,
+ image: p.product.image,
+ price: p.product.price,
+ unit: p.product.unit,
+ quantity: p.quantity,
+ description: p.product.description,
+ taxes: p.product.taxes,
+ };
+}
+
+function DeadlineHelp({ duration }: { duration?: Duration }): VNode {
+ const { i18n } = useTranslationContext();
+ const [now, setNow] = useState(AbsoluteTime.now());
+ useEffect(() => {
+ const iid = setInterval(() => {
+ setNow(AbsoluteTime.now());
+ }, 60 * 1000);
+ return () => {
+ clearInterval(iid);
+ };
+ });
+ if (!duration) return <i18n.Translate>Disabled</i18n.Translate>;
+ const when = AbsoluteTime.addDuration(now, duration);
+ if (when.t_ms === "never")
+ return <i18n.Translate>No deadline</i18n.Translate>;
+ return (
+ <i18n.Translate>
+ Deadline at {format(when.t_ms, "dd/MM/yy HH:mm")}
+ </i18n.Translate>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx
new file mode 100644
index 000000000..32f3f05c7
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx
@@ -0,0 +1,141 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { HttpStatusCode, TalerError, assertUnreachable } from "@gnu-taler/taler-util";
+import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { CreatedSuccessfully } from "../../../../components/notifications/CreatedSuccessfully.js";
+import { useOrderDetails } from "../../../../hooks/order.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { Entity } from "./index.js";
+import { LoginPage } from "../../../login/index.js";
+
+interface Props {
+ entity: Entity;
+ onConfirm: () => void;
+ onCreateAnother?: () => void;
+}
+
+export function OrderCreatedSuccessfully({
+ entity,
+ onConfirm,
+ onCreateAnother,
+}: Props): VNode {
+ const result = useOrderDetails(entity.response.order_id)
+ const { i18n } = useTranslationContext();
+
+ if (!result) return <Loading />
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />
+ }
+ if (result.type === "fail") {
+ switch(result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />
+ }
+ case HttpStatusCode.BadGateway: {
+ return <div>Failed to obtain a response from the exchange</div>;
+ }
+ case HttpStatusCode.GatewayTimeout: {
+ return (
+ <div>The merchant's interaction with the exchange took too long</div>
+ );
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result)
+ }
+ }
+ }
+
+ const url = result.body.order_status === "unpaid" ?
+ result.body.taler_pay_uri :
+ result.body.contract_terms.fulfillment_url
+
+ return (
+ <CreatedSuccessfully
+ onConfirm={onConfirm}
+ onCreateAnother={onCreateAnother}
+ >
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Amount</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ class="input"
+ readonly
+ value={entity.request.order.amount}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Summary</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ class="input"
+ readonly
+ value={entity.request.order.summary}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Order ID</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input" readonly value={entity.response.order_id} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Payment URL</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input" readonly value={url} />
+ </p>
+ </div>
+ </div>
+ </div>
+ </CreatedSuccessfully>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx
new file mode 100644
index 000000000..861114014
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx
@@ -0,0 +1,120 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useInstanceDetails } from "../../../../hooks/instance.js";
+import { useInstanceProducts } from "../../../../hooks/product.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { CreatePage } from "./CreatePage.js";
+
+export type Entity = {
+ request: TalerMerchantApi.PostOrderRequest;
+ response: TalerMerchantApi.PostOrderResponse;
+};
+interface Props {
+ onBack?: () => void;
+ onConfirm: (id: string) => void;
+}
+export default function OrderCreate({
+ onConfirm,
+ onBack,
+}: Props): VNode {
+ const { lib } = useSessionContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { state } = useSessionContext();
+ const detailsResult = useInstanceDetails();
+ const inventoryResult = useInstanceProducts();
+
+ if (!detailsResult) return <Loading />
+ if (detailsResult instanceof TalerError) {
+ return <ErrorLoadingMerchant error={detailsResult} />
+ }
+ if (detailsResult.type === "fail") {
+ switch (detailsResult.case) {
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ default: {
+ assertUnreachable(detailsResult);
+ }
+ }
+ }
+ if (!inventoryResult) return <Loading />
+ if (inventoryResult instanceof TalerError) {
+ return <ErrorLoadingMerchant error={inventoryResult} />
+ }
+ if (inventoryResult.type === "fail") {
+ switch (inventoryResult.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(inventoryResult);
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: TalerMerchantApi.PostOrderRequest) => {
+ lib.instance.createOrder(state.token, request)
+ .then((r) => {
+ if (r.type === "ok") {
+ return onConfirm(r.body.order_id)
+ } else {
+ setNotif({
+ message: "could not create order",
+ type: "ERROR",
+ });
+ }
+ })
+ .catch((error) => {
+ setNotif({
+ message: "could not create order",
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ instanceConfig={detailsResult.body}
+ instanceInventory={inventoryResult.body}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx
new file mode 100644
index 000000000..7d4877db9
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx
@@ -0,0 +1,134 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountString, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { addDays } from "date-fns";
+import { FunctionalComponent, h } from "preact";
+import { DetailPage as TestedComponent } from "./DetailPage.js";
+
+export default {
+ title: "Pages/Order/Detail",
+ component: TestedComponent,
+ argTypes: {
+ onRefund: { action: "onRefund" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+const defaultContractTerm: TalerMerchantApi.ContractTerms = {
+ amount: "TESTKUDOS:10" as AmountString,
+ timestamp: {
+ t_s: new Date().getTime() / 1000,
+ },
+ exchanges: [],
+ max_fee: "TESTKUDOS:1" as AmountString,
+ merchant: {} as any,
+ merchant_base_url: "http://merchant.url/",
+ order_id: "2021.165-03GDFC26Y1NNG",
+ products: [],
+ summary: "text summary",
+ wire_transfer_deadline: {
+ t_s: "never",
+ },
+ refund_deadline: { t_s: "never" },
+ merchant_pub: "ASDASDASDSd",
+ nonce: "QWEQWEQWE",
+ pay_deadline: {
+ t_s: "never",
+ },
+ wire_method: "x-taler-bank",
+ h_wire: "asd",
+};
+
+// contract_terms: defaultContracTerm,
+export const Claimed = createExample(TestedComponent, {
+ id: "2021.165-03GDFC26Y1NNG",
+ selected: {
+ order_status: "claimed",
+ contract_terms: defaultContractTerm,
+ },
+});
+
+export const PaidNotRefundable = createExample(TestedComponent, {
+ id: "2021.165-03GDFC26Y1NNG",
+ selected: {
+ order_status: "paid",
+ contract_terms: defaultContractTerm,
+ refunded: false,
+ deposit_total: "TESTKUDOS:10" as AmountString,
+ exchange_code: 0,
+ order_status_url: "http://merchant.backend/status",
+ exchange_http_status: 0,
+ refund_amount: "TESTKUDOS:0" as AmountString,
+ refund_details: [],
+ refund_pending: false,
+ wire_details: [],
+ wired: false,
+ wire_reports: [],
+ },
+});
+
+export const PaidRefundable = createExample(TestedComponent, {
+ id: "2021.165-03GDFC26Y1NNG",
+ selected: {
+ order_status: "paid",
+ contract_terms: {
+ ...defaultContractTerm,
+ refund_deadline: {
+ t_s: addDays(new Date(), 2).getTime() / 1000,
+ },
+ },
+ refunded: false,
+ deposit_total: "TESTKUDOS:10" as AmountString,
+ exchange_code: 0,
+ order_status_url: "http://merchant.backend/status",
+ exchange_http_status: 0,
+ refund_amount: "TESTKUDOS:0" as AmountString,
+ refund_details: [],
+ wire_reports: [],
+ refund_pending: false,
+ wire_details: [],
+ wired: false,
+ },
+});
+
+export const Unpaid = createExample(TestedComponent, {
+ id: "2021.165-03GDFC26Y1NNG",
+ selected: {
+ order_status: "unpaid",
+ order_status_url: "http://merchant.backend/status",
+ creation_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ summary: "text summary",
+ taler_pay_uri: "pay uri",
+ total_amount: "TESTKUDOS:10" as AmountString,
+ },
+});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
new file mode 100644
index 000000000..498ea83e3
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
@@ -0,0 +1,780 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AmountJson,
+ Amounts,
+ TalerMerchantApi,
+ stringifyRefundUri,
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { format, formatDistance } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider } from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputDate } from "../../../../components/form/InputDate.js";
+import { InputDuration } from "../../../../components/form/InputDuration.js";
+import { InputGroup } from "../../../../components/form/InputGroup.js";
+import { InputLocation } from "../../../../components/form/InputLocation.js";
+import { TextField } from "../../../../components/form/TextField.js";
+import { ProductList } from "../../../../components/product/ProductList.js";
+import { useSessionContext } from "../../../../context/session.js";
+import {
+ datetimeFormatForSettings,
+ usePreference,
+} from "../../../../hooks/preference.js";
+import { mergeRefunds } from "../../../../utils/amount.js";
+import { RefundModal } from "../list/Table.js";
+import { Event, Timeline } from "./Timeline.js";
+
+type Entity = TalerMerchantApi.MerchantOrderStatusResponse;
+type CT = TalerMerchantApi.ContractTerms;
+
+interface Props {
+ onBack: () => void;
+ selected: Entity;
+ id: string;
+ onRefund: (id: string, value: TalerMerchantApi.RefundRequest) => void;
+}
+
+type Paid = TalerMerchantApi.CheckPaymentPaidResponse & {
+ refund_taken: string;
+};
+type Unpaid = TalerMerchantApi.CheckPaymentUnpaidResponse;
+type Claimed = TalerMerchantApi.CheckPaymentClaimedResponse;
+
+function ContractTerms({ value }: { value: CT }) {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <InputGroup name="contract_terms" label={i18n.str`Contract Terms`}>
+ <FormProvider<CT> object={value} valueHandler={null}>
+ <Input<CT>
+ readonly
+ name="summary"
+ label={i18n.str`Summary`}
+ tooltip={i18n.str`human-readable description of the whole purchase`}
+ />
+ <InputCurrency<CT>
+ readonly
+ name="amount"
+ label={i18n.str`Amount`}
+ tooltip={i18n.str`total price for the transaction`}
+ />
+ {value.fulfillment_url && (
+ <Input<CT>
+ readonly
+ name="fulfillment_url"
+ label={i18n.str`Fulfillment URL`}
+ tooltip={i18n.str`URL for this purchase`}
+ />
+ )}
+ <Input<CT>
+ readonly
+ name="max_fee"
+ label={i18n.str`Max fee`}
+ tooltip={i18n.str`maximum total deposit fee accepted by the merchant for this contract`}
+ />
+ <InputDate<CT>
+ readonly
+ name="timestamp"
+ label={i18n.str`Created at`}
+ tooltip={i18n.str`time when this contract was generated`}
+ />
+ <InputDate<CT>
+ readonly
+ name="refund_deadline"
+ label={i18n.str`Refund deadline`}
+ tooltip={i18n.str`after this deadline has passed no refunds will be accepted`}
+ />
+ <InputDate<CT>
+ readonly
+ name="pay_deadline"
+ label={i18n.str`Payment deadline`}
+ tooltip={i18n.str`after this deadline, the merchant won't accept payments for the contract`}
+ />
+ <InputDate<CT>
+ readonly
+ name="wire_transfer_deadline"
+ label={i18n.str`Wire transfer deadline`}
+ tooltip={i18n.str`transfer deadline for the exchange`}
+ />
+ <InputDate<CT>
+ readonly
+ name="delivery_date"
+ label={i18n.str`Delivery date`}
+ tooltip={i18n.str`time indicating when the order should be delivered`}
+ />
+ {value.delivery_date && (
+ <InputGroup
+ name="delivery_location"
+ label={i18n.str`Location`}
+ tooltip={i18n.str`where the order will be delivered`}
+ >
+ <InputLocation name="payments.delivery_location" />
+ </InputGroup>
+ )}
+ <InputDuration<CT>
+ readonly
+ name="auto_refund"
+ label={i18n.str`Auto-refund delay`}
+ tooltip={i18n.str`how long the wallet should try to get an automatic refund for the purchase`}
+ />
+ <Input<CT>
+ readonly
+ name="extra"
+ label={i18n.str`Extra info`}
+ tooltip={i18n.str`extra data that is only interpreted by the merchant frontend`}
+ />
+ </FormProvider>
+ </InputGroup>
+ );
+}
+
+function ClaimedPage({
+ id,
+ order,
+}: {
+ id: string;
+ order: TalerMerchantApi.CheckPaymentClaimedResponse;
+}) {
+ const now = new Date();
+ const refundable =
+ order.contract_terms.refund_deadline.t_s !== "never" &&
+ now.getTime() < order.contract_terms.refund_deadline.t_s * 1000;
+ const events: Event[] = [];
+ if (order.contract_terms.timestamp.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.timestamp.t_s * 1000),
+ description: "order created",
+ type: "start",
+ });
+ }
+ if (order.contract_terms.pay_deadline.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.pay_deadline.t_s * 1000),
+ description: "pay deadline",
+ type: "deadline",
+ });
+ }
+ if (order.contract_terms.refund_deadline.t_s !== "never" && refundable) {
+ events.push({
+ when: new Date(order.contract_terms.refund_deadline.t_s * 1000),
+ description: "refund deadline",
+ type: "deadline",
+ });
+ }
+ // if (order.contract_terms.wire_transfer_deadline.t_s !== "never") {
+ // events.push({
+ // when: new Date(order.contract_terms.wire_transfer_deadline.t_s * 1000),
+ // description: "wire deadline",
+ // type: "deadline",
+ // });
+ // }
+ if (
+ order.contract_terms.delivery_date &&
+ order.contract_terms.delivery_date.t_s !== "never"
+ ) {
+ events.push({
+ when: new Date(order.contract_terms.delivery_date?.t_s * 1000),
+ description: "delivery",
+ type: "delivery",
+ });
+ }
+
+ const [value, valueHandler] = useState<Partial<Claimed>>(order);
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreference();
+
+ return (
+ <div>
+ <section class="section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-10">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <i18n.Translate>Order</i18n.Translate> #{id}
+ <div class="tag is-info ml-4">
+ <i18n.Translate>claimed</i18n.Translate>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <h1 class="title">{order.contract_terms.amount}</h1>
+ </div>
+ </div>
+ </div>
+
+ <div class="level">
+ <div class="level-left" style={{ maxWidth: "100%" }}>
+ <div class="level-item" style={{ maxWidth: "100%" }}>
+ <div
+ class="content"
+ style={{
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ }}
+ >
+ <p>
+ <b>
+ <i18n.Translate>claimed at</i18n.Translate>:
+ </b>{" "}
+ {order.contract_terms.timestamp.t_s === "never"
+ ? "never"
+ : format(
+ new Date(
+ order.contract_terms.timestamp.t_s * 1000,
+ ),
+ datetimeFormatForSettings(settings),
+ )}
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="columns">
+ <div class="column is-4">
+ <div class="title">
+ <i18n.Translate>Timeline</i18n.Translate>
+ </div>
+ <Timeline events={events} />
+ </div>
+ <div class="column is-8">
+ <div class="title">
+ <i18n.Translate>Payment details</i18n.Translate>
+ </div>
+ <FormProvider<Claimed>
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <Input
+ name="contract_terms.summary"
+ readonly
+ inputType="multiline"
+ label={i18n.str`Summary`}
+ />
+ <InputCurrency
+ name="contract_terms.amount"
+ readonly
+ label={i18n.str`Amount`}
+ />
+ <Input<Claimed>
+ name="order_status"
+ readonly
+ label={i18n.str`Order status`}
+ />
+ </FormProvider>
+ </div>
+ </div>
+ </section>
+
+ {order.contract_terms.products.length ? (
+ <Fragment>
+ <div class="title">
+ <i18n.Translate>Product list</i18n.Translate>
+ </div>
+ <ProductList list={order.contract_terms.products} />
+ </Fragment>
+ ) : undefined}
+
+ {value.contract_terms && (
+ <ContractTerms value={value.contract_terms} />
+ )}
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
+function PaidPage({
+ id,
+ order,
+ onRefund,
+}: {
+ id: string;
+ order: TalerMerchantApi.CheckPaymentPaidResponse;
+ onRefund: (id: string) => void;
+}) {
+ const now = new Date();
+ const refundable =
+ order.contract_terms.refund_deadline.t_s !== "never" &&
+ now.getTime() < order.contract_terms.refund_deadline.t_s * 1000;
+
+ const events: Event[] = [];
+ if (order.contract_terms.refund_deadline.t_s !== "never" && refundable) {
+ events.push({
+ when: new Date(order.contract_terms.refund_deadline.t_s * 1000),
+ description: "refund deadline",
+ type: "deadline",
+ });
+ }
+ if (order.contract_terms.wire_transfer_deadline.t_s !== "never") {
+ events.push({
+ when: new Date(order.contract_terms.wire_transfer_deadline.t_s * 1000),
+ description: "wire deadline",
+ type: "deadline",
+ });
+ }
+ if (
+ order.contract_terms.delivery_date &&
+ order.contract_terms.delivery_date.t_s !== "never"
+ ) {
+ if (order.contract_terms.delivery_date)
+ events.push({
+ when: new Date(order.contract_terms.delivery_date?.t_s * 1000),
+ description: "delivery",
+ type: "delivery",
+ });
+ }
+ order.refund_details.reduce(mergeRefunds, []).forEach((e) => {
+ if (e.timestamp.t_s !== "never") {
+ events.push({
+ when: new Date(e.timestamp.t_s * 1000),
+ description: `refund: ${e.amount}: ${e.reason}`,
+ type: e.pending ? "refund" : "refund-taken",
+ });
+ }
+ });
+ const ra = !order.refunded ? undefined : Amounts.parse(order.refund_amount);
+ const am = Amounts.parseOrThrow(order.contract_terms.amount);
+ if (ra && Amounts.cmp(ra, am) === 1) {
+ if (order.wire_details && order.wire_details.length) {
+ if (order.wire_details.length > 1) {
+ let last: TalerMerchantApi.TransactionWireTransfer | null = null;
+ let first: TalerMerchantApi.TransactionWireTransfer | null = null;
+ let total: AmountJson | null = null;
+
+ order.wire_details.forEach((w) => {
+ if (last === null || last.execution_time.t_s < w.execution_time.t_s) {
+ last = w;
+ }
+ if (
+ first === null ||
+ first.execution_time.t_s > w.execution_time.t_s
+ ) {
+ first = w;
+ }
+ total =
+ total === null
+ ? Amounts.parseOrThrow(w.amount)
+ : Amounts.add(total, Amounts.parseOrThrow(w.amount)).amount;
+ });
+ const last_time = last!.execution_time.t_s;
+ if (last_time !== "never") {
+ events.push({
+ when: new Date(last_time * 1000),
+ description: `wired ${Amounts.stringify(total!)}`,
+ type: "wired-range",
+ });
+ }
+ const first_time = first!.execution_time.t_s;
+ if (first_time !== "never") {
+ events.push({
+ when: new Date(first_time * 1000),
+ description: `wire transfer started...`,
+ type: "wired-range",
+ });
+ }
+ } else {
+ order.wire_details.forEach((e) => {
+ if (e.execution_time.t_s !== "never") {
+ events.push({
+ when: new Date(e.execution_time.t_s * 1000),
+ description: `wired ${e.amount}`,
+ type: "wired",
+ });
+ }
+ });
+ }
+ }
+ }
+
+ const nextEvent = events.find((e) => {
+ return e.when.getTime() > now.getTime();
+ });
+
+ const [value, valueHandler] = useState<Partial<Paid>>(order);
+ const { state } = useSessionContext();
+
+ const refundurl = stringifyRefundUri({
+ merchantBaseUrl: state.backendUrl.href,
+ orderId: order.contract_terms.order_id,
+ });
+ const { i18n } = useTranslationContext();
+
+ const amount = Amounts.parseOrThrow(order.contract_terms.amount);
+ const refund_taken = order.refund_details.reduce((prev, cur) => {
+ if (cur.pending) return prev;
+ return Amounts.add(prev, Amounts.parseOrThrow(cur.amount)).amount;
+ }, Amounts.zeroOfCurrency(amount.currency));
+ value.refund_taken = Amounts.stringify(refund_taken);
+
+ return (
+ <div>
+ <section class="section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-10">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <i18n.Translate>Order</i18n.Translate> #{id}
+ <div class="tag is-success ml-4">
+ <i18n.Translate>paid</i18n.Translate>
+ </div>
+ {order.wired ? (
+ <div class="tag is-success ml-4">
+ <i18n.Translate>wired</i18n.Translate>
+ </div>
+ ) : null}
+ {order.refunded ? (
+ <div class="tag is-danger ml-4">
+ <i18n.Translate>refunded</i18n.Translate>
+ </div>
+ ) : null}
+ </div>
+ </div>
+ </div>
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <h1 class="title">{order.contract_terms.amount}</h1>
+ </div>
+ </div>
+ <div class="level-right">
+ <div class="level-item">
+ <h1 class="title">
+ <div class="buttons">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={
+ refundable
+ ? i18n.str`refund order`
+ : i18n.str`not refundable`
+ }
+ >
+ <button
+ class="button is-danger"
+ disabled={!refundable}
+ onClick={() => onRefund(id)}
+ >
+ <i18n.Translate>refund</i18n.Translate>
+ </button>
+ </span>
+ </div>
+ </h1>
+ </div>
+ </div>
+ </div>
+
+ <div class="level">
+ <div class="level-left" style={{ maxWidth: "100%" }}>
+ <div class="level-item" style={{ maxWidth: "100%" }}>
+ <div
+ class="content"
+ style={{
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ }}
+ >
+ {nextEvent && (
+ <p>
+ <i18n.Translate>Next event in </i18n.Translate>{" "}
+ {formatDistance(
+ nextEvent.when,
+ new Date(),
+ // "yyyy/MM/dd HH:mm:ss",
+ )}
+ </p>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section class="section">
+ <div class="columns">
+ <div class="column is-4">
+ <div class="title">
+ <i18n.Translate>Timeline</i18n.Translate>
+ </div>
+ <Timeline events={events} />
+ </div>
+ <div class="column is-8">
+ <div class="title">
+ <i18n.Translate>Payment details</i18n.Translate>
+ </div>
+ <FormProvider<Paid>
+ object={value}
+ valueHandler={valueHandler}
+ >
+ {/* <InputCurrency<Paid> name="deposit_total" readonly label={i18n.str`Deposit total`} /> */}
+ {order.refunded && (
+ <InputCurrency<Paid>
+ name="refund_amount"
+ readonly
+ label={i18n.str`Refunded amount`}
+ />
+ )}
+ {order.refunded && (
+ <InputCurrency<Paid>
+ name="refund_taken"
+ readonly
+ label={i18n.str`Refund taken`}
+ />
+ )}
+ <Input<Paid>
+ name="order_status"
+ readonly
+ label={i18n.str`Order status`}
+ />
+ <TextField<Paid>
+ name="order_status_url"
+ label={i18n.str`Status URL`}
+ >
+ <a
+ target="_blank"
+ rel="noreferrer"
+ href={order.order_status_url}
+ >
+ {order.order_status_url}
+ </a>
+ </TextField>
+ {order.refunded && (
+ <TextField<Paid>
+ name="order_status_url"
+ label={i18n.str`Refund URI`}
+ >
+ <a target="_blank" rel="noreferrer" href={refundurl}>
+ {refundurl}
+ </a>
+ </TextField>
+ )}
+ </FormProvider>
+ </div>
+ </div>
+ </section>
+
+ {order.contract_terms.products.length ? (
+ <Fragment>
+ <div class="title">
+ <i18n.Translate>Product list</i18n.Translate>
+ </div>
+ <ProductList list={order.contract_terms.products} />
+ </Fragment>
+ ) : undefined}
+
+ {value.contract_terms && (
+ <ContractTerms value={value.contract_terms} />
+ )}
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
+
+function UnpaidPage({
+ id,
+ order,
+}: {
+ id: string;
+ order: TalerMerchantApi.CheckPaymentUnpaidResponse;
+}) {
+ const [value, valueHandler] = useState<Partial<Unpaid>>(order);
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreference();
+ return (
+ <div>
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <h1 class="title">
+ <i18n.Translate>Order</i18n.Translate> #{id}
+ </h1>
+ </div>
+ <div class="tag is-dark">
+ <i18n.Translate>unpaid</i18n.Translate>
+ </div>
+ </div>
+ </div>
+
+ <div class="level">
+ <div class="level-left" style={{ maxWidth: "100%" }}>
+ <div class="level-item" style={{ maxWidth: "100%" }}>
+ <div
+ class="content"
+ style={{
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ }}
+ >
+ <p>
+ <b>
+ <i18n.Translate>pay at</i18n.Translate>:
+ </b>{" "}
+ <a
+ href={order.order_status_url}
+ rel="nofollow"
+ target="new"
+ >
+ {order.order_status_url}
+ </a>
+ </p>
+ <p>
+ <b>
+ <i18n.Translate>created at</i18n.Translate>:
+ </b>{" "}
+ {order.creation_time.t_s === "never"
+ ? "never"
+ : format(
+ new Date(order.creation_time.t_s * 1000),
+ datetimeFormatForSettings(settings),
+ )}
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider<Unpaid> object={value} valueHandler={valueHandler}>
+ <Input<Unpaid>
+ readonly
+ name="summary"
+ label={i18n.str`Summary`}
+ tooltip={i18n.str`human-readable description of the whole purchase`}
+ />
+ <InputCurrency<Unpaid>
+ readonly
+ name="total_amount"
+ label={i18n.str`Amount`}
+ tooltip={i18n.str`total price for the transaction`}
+ />
+ <Input<Unpaid>
+ name="order_status"
+ readonly
+ label={i18n.str`Order status`}
+ />
+ <Input<Unpaid>
+ name="order_status_url"
+ readonly
+ label={i18n.str`Order status URL`}
+ />
+ <TextField<Unpaid>
+ name="taler_pay_uri"
+ label={i18n.str`Payment URI`}
+ >
+ <a target="_blank" rel="noreferrer" href={value.taler_pay_uri}>
+ {value.taler_pay_uri}
+ </a>
+ </TextField>
+ </FormProvider>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
+
+export function DetailPage({ id, selected, onRefund, onBack }: Props): VNode {
+ const [showRefund, setShowRefund] = useState<string | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const DetailByStatus = function () {
+ switch (selected.order_status) {
+ case "claimed":
+ return <ClaimedPage id={id} order={selected} />;
+ case "paid":
+ return <PaidPage id={id} order={selected} onRefund={setShowRefund} />;
+ case "unpaid":
+ return <UnpaidPage id={id} order={selected} />;
+ default:
+ return (
+ <div>
+ <i18n.Translate>
+ Unknown order status. This is an error, please contact the
+ administrator.
+ </i18n.Translate>
+ </div>
+ );
+ }
+ };
+
+ return (
+ <Fragment>
+ {DetailByStatus()}
+ {showRefund && (
+ <RefundModal
+ order={selected}
+ onCancel={() => setShowRefund(undefined)}
+ onConfirm={(value) => {
+ onRefund(showRefund, value);
+ setShowRefund(undefined);
+ }}
+ />
+ )}
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <div class="buttons is-right mt-5">
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Back</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx
new file mode 100644
index 000000000..2d62e2252
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx
@@ -0,0 +1,129 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { format } from "date-fns";
+import { h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { datetimeFormatForSettings, usePreference } from "../../../../hooks/preference.js";
+
+interface Props {
+ events: Event[];
+}
+
+export function Timeline({ events: e }: Props) {
+ const events = [...e];
+ events.push({
+ when: new Date(),
+ description: "now",
+ type: "now",
+ });
+
+ events.sort((a, b) => a.when.getTime() - b.when.getTime());
+ const [settings] = usePreference();
+ const [state, setState] = useState(events);
+ useEffect(() => {
+ const handle = setTimeout(() => {
+ const eventsWithoutNow = state.filter((e) => e.type !== "now");
+ eventsWithoutNow.push({
+ when: new Date(),
+ description: "now",
+ type: "now",
+ });
+ setState(eventsWithoutNow);
+ }, 1000);
+ return () => {
+ clearTimeout(handle);
+ };
+ });
+ return (
+ <div class="timeline">
+ {events.map((e, i) => {
+ return (
+ <div key={i} class="timeline-item">
+ {(() => {
+ switch (e.type) {
+ case "deadline":
+ return (
+ <div class="timeline-marker is-icon ">
+ <i class="mdi mdi-flag" />
+ </div>
+ );
+ case "delivery":
+ return (
+ <div class="timeline-marker is-icon ">
+ <i class="mdi mdi-delivery" />
+ </div>
+ );
+ case "start":
+ return (
+ <div class="timeline-marker is-icon">
+ <i class="mdi mdi-flag " />
+ </div>
+ );
+ case "wired":
+ return (
+ <div class="timeline-marker is-icon is-success">
+ <i class="mdi mdi-cash" />
+ </div>
+ );
+ case "wired-range":
+ return (
+ <div class="timeline-marker is-icon is-success">
+ <i class="mdi mdi-cash" />
+ </div>
+ );
+ case "refund":
+ return (
+ <div class="timeline-marker is-icon is-danger">
+ <i class="mdi mdi-cash" />
+ </div>
+ );
+ case "refund-taken":
+ return (
+ <div class="timeline-marker is-icon is-success">
+ <i class="mdi mdi-cash" />
+ </div>
+ );
+ case "now":
+ return (
+ <div class="timeline-marker is-icon is-info">
+ <i class="mdi mdi-clock" />
+ </div>
+ );
+ }
+ })()}
+ <div class="timeline-content">
+ {e.description !== "now" && <p class="heading">{format(e.when, datetimeFormatForSettings(settings))}</p>}
+ <p>{e.description}</p>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ );
+}
+export interface Event {
+ when: Date;
+ description: string;
+ type:
+ | "start"
+ | "refund"
+ | "refund-taken"
+ | "wired"
+ | "wired-range"
+ | "deadline"
+ | "delivery"
+ | "now";
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx
new file mode 100644
index 000000000..b28e59b29
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx
@@ -0,0 +1,106 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useOrderDetails } from "../../../../hooks/order.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { DetailPage } from "./DetailPage.js";
+
+export interface Props {
+ oid: string;
+ onBack: () => void;
+}
+
+export default function Update({ oid, onBack }: Props): VNode {
+ const result = useOrderDetails(oid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { lib: api } = useSessionContext();
+ const { state } = useSessionContext();
+
+ const { i18n } = useTranslationContext();
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.BadGateway: {
+ return <div>Failed to obtain a response from the exchange</div>;
+ }
+ case HttpStatusCode.GatewayTimeout: {
+ return (
+ <div>The merchant's interaction with the exchange took too long</div>
+ );
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <DetailPage
+ onBack={onBack}
+ id={oid}
+ onRefund={(id, value) => {
+ if (state.status !== "loggedIn") {
+ return;
+ }
+ api.instance
+ .addRefund(state.token, id, value)
+ .then(() =>
+ setNotif({
+ message: i18n.str`refund created successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not create the refund`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ );
+ }}
+ selected={result.body}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx
new file mode 100644
index 000000000..5c9969689
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx
@@ -0,0 +1,108 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+import { AmountString } from "@gnu-taler/taler-util";
+
+export default {
+ title: "Pages/Order/List",
+ component: TestedComponent,
+ argTypes: {
+ onShowAll: { action: "onShowAll" },
+ onShowPaid: { action: "onShowPaid" },
+ onShowRefunded: { action: "onShowRefunded" },
+ onShowNotWired: { action: "onShowNotWired" },
+ onCopyURL: { action: "onCopyURL" },
+ onSelectDate: { action: "onSelectDate" },
+ onLoadMoreBefore: { action: "onLoadMoreBefore" },
+ onLoadMoreAfter: { action: "onLoadMoreAfter" },
+ onSelectOrder: { action: "onSelectOrder" },
+ onRefundOrder: { action: "onRefundOrder" },
+ onSearchOrderById: { action: "onSearchOrderById" },
+ onCreate: { action: "onCreate" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ orders: [
+ {
+ id: "123",
+ amount: "TESTKUDOS:10" as AmountString,
+ paid: false,
+ refundable: true,
+ row_id: 1,
+ summary: "summary",
+ timestamp: {
+ t_s: new Date().getTime() / 1000,
+ },
+ order_id: "123",
+ },
+ {
+ id: "234",
+ amount: "TESTKUDOS:12" as AmountString,
+ paid: true,
+ refundable: true,
+ row_id: 2,
+ summary:
+ "summary with long text, very very long text that someone want to add as a description of the order",
+ timestamp: {
+ t_s: new Date().getTime() / 1000,
+ },
+ order_id: "234",
+ },
+ {
+ id: "456",
+ amount: "TESTKUDOS:1" as AmountString,
+ paid: false,
+ refundable: false,
+ row_id: 3,
+ summary:
+ "summary with long text, very very long text that someone want to add as a description of the order",
+ timestamp: {
+ t_s: new Date().getTime() / 1000,
+ },
+ order_id: "456",
+ },
+ {
+ id: "234",
+ amount: "TESTKUDOS:12" as AmountString,
+ paid: false,
+ refundable: false,
+ row_id: 4,
+ summary:
+ "summary with long text, very very long text that someone want to add as a description of the order",
+ timestamp: {
+ t_s: new Date().getTime() / 1000,
+ },
+ order_id: "234",
+ },
+ ],
+});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx
new file mode 100644
index 000000000..408bc0c0a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx
@@ -0,0 +1,222 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AbsoluteTime, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { DatePicker } from "../../../../components/picker/DatePicker.js";
+import { dateFormatForSettings, usePreference } from "../../../../hooks/preference.js";
+import { CardTable } from "./Table.js";
+
+export interface ListPageProps {
+ onShowAll: () => void;
+ onShowNotPaid: () => void;
+ onShowPaid: () => void;
+ onShowRefunded: () => void;
+ onShowNotWired: () => void;
+ onShowWired: () => void;
+ onCopyURL: (id: string) => void;
+ isAllActive: string;
+ isPaidActive: string;
+ isNotPaidActive: string;
+ isRefundedActive: string;
+ isNotWiredActive: string;
+ isWiredActive: string;
+
+ jumpToDate?: AbsoluteTime;
+ onSelectDate: (date?: AbsoluteTime) => void;
+
+ orders: (TalerMerchantApi.OrderHistoryEntry & WithId)[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+
+ onSelectOrder: (o: TalerMerchantApi.OrderHistoryEntry & WithId) => void;
+ onRefundOrder: (o: TalerMerchantApi.OrderHistoryEntry & WithId) => void;
+ onCreate: () => void;
+}
+
+export function ListPage({
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+ orders,
+ isAllActive,
+ onSelectOrder,
+ onRefundOrder,
+ jumpToDate,
+ onCopyURL,
+ onShowAll,
+ onShowPaid,
+ onShowNotPaid,
+ onShowRefunded,
+ onShowNotWired,
+ onShowWired,
+ onSelectDate,
+ isPaidActive,
+ isRefundedActive,
+ isNotWiredActive,
+ onCreate,
+ isNotPaidActive,
+ isWiredActive,
+}: ListPageProps): VNode {
+ const { i18n } = useTranslationContext();
+ const dateTooltip = i18n.str`select date to show nearby orders`;
+ const [pickDate, setPickDate] = useState(false);
+ const [settings] = usePreference();
+
+ return (
+ <Fragment>
+ <div class="columns">
+ <div class="column is-two-thirds">
+ <div class="tabs" style={{ overflow: "inherit" }}>
+ <ul>
+ <li class={isNotPaidActive}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`only show paid orders`}
+ >
+ <a onClick={onShowNotPaid}>
+ <i18n.Translate>New</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isPaidActive}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`only show paid orders`}
+ >
+ <a onClick={onShowPaid}>
+ <i18n.Translate>Paid</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isRefundedActive}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`only show orders with refunds`}
+ >
+ <a onClick={onShowRefunded}>
+ <i18n.Translate>Refunded</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isNotWiredActive}>
+ <div
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`only show orders where customers paid, but wire payments from payment provider are still pending`}
+ >
+ <a onClick={onShowNotWired}>
+ <i18n.Translate>Not wired</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isWiredActive}>
+ <div
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`only show orders where customers paid, but wire payments from payment provider are still pending`}
+ >
+ <a onClick={onShowWired}>
+ <i18n.Translate>Completed</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isAllActive}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`remove all filters`}
+ >
+ <a onClick={onShowAll}>
+ <i18n.Translate>All</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ <div class="column ">
+ <div class="buttons is-right">
+ <div class="field has-addons">
+ {jumpToDate && (
+ <div class="control">
+ <a class="button is-fullwidth" onClick={() => onSelectDate(undefined)}>
+ <span
+ class="icon"
+ data-tooltip={i18n.str`clear date filter`}
+ >
+ <i class="mdi mdi-close" />
+ </span>
+ </a>
+ </div>
+ )}
+ <div class="control">
+ <span class="has-tooltip-top" data-tooltip={dateTooltip}>
+ <input
+ class="input"
+ type="text"
+ readonly
+ value={!jumpToDate || jumpToDate.t_ms === "never" ? "" : format(jumpToDate.t_ms, dateFormatForSettings(settings))}
+ placeholder={i18n.str`date (${dateFormatForSettings(settings)})`}
+ onClick={() => {
+ setPickDate(true);
+ }}
+ />
+ </span>
+ </div>
+ <div class="control">
+ <span class="has-tooltip-left" data-tooltip={dateTooltip}>
+ <a
+ class="button is-fullwidth"
+ onClick={() => {
+ setPickDate(true);
+ }}
+ >
+ <span class="icon">
+ <i class="mdi mdi-calendar" />
+ </span>
+ </a>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DatePicker
+ opened={pickDate}
+ closeFunction={() => setPickDate(false)}
+ dateReceiver={(d) => {
+ onSelectDate(AbsoluteTime.fromMilliseconds(d.getTime()))
+ }}
+ />
+
+ <CardTable
+ orders={orders}
+ onCreate={onCreate}
+ onCopyURL={onCopyURL}
+ onSelect={onSelectOrder}
+ onRefund={onRefundOrder}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx
new file mode 100644
index 000000000..5ece34409
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx
@@ -0,0 +1,407 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Amounts, TalerMerchantApi } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { VNode, h } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputGroup } from "../../../../components/form/InputGroup.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { ConfirmModal } from "../../../../components/modal/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import {
+ datetimeFormatForSettings,
+ usePreference,
+} from "../../../../hooks/preference.js";
+import { mergeRefunds } from "../../../../utils/amount.js";
+
+type Entity = TalerMerchantApi.OrderHistoryEntry & WithId;
+interface Props {
+ orders: Entity[];
+ onRefund: (value: Entity) => void;
+ onCopyURL: (id: string) => void;
+ onCreate: () => void;
+ onSelect: (order: Entity) => void;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ orders,
+ onCreate,
+ onRefund,
+ onCopyURL,
+ onSelect,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-cash-register" />
+ </span>
+ <i18n.Translate>Orders</i18n.Translate>
+ </p>
+
+ <div class="card-header-icon" aria-label="more options" />
+
+ <div class="card-header-icon" aria-label="more options">
+ <span class="has-tooltip-left" data-tooltip={i18n.str`create order`}>
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {orders.length > 0 ? (
+ <Table
+ instances={orders}
+ onSelect={onSelect}
+ onRefund={onRefund}
+ onCopyURL={(o) => onCopyURL(o.id)}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: Entity[];
+ onRefund: (id: Entity) => void;
+ onCopyURL: (id: Entity) => void;
+ onSelect: (id: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+function Table({
+ instances,
+ onSelect,
+ onRefund,
+ onCopyURL,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreference();
+ return (
+ <div class="table-container">
+ {onLoadMoreBefore && (
+ <button class="button is-fullwidth" onClick={onLoadMoreBefore}>
+ <i18n.Translate>load first page</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th style={{ minWidth: 100 }}>
+ <i18n.Translate>Date</i18n.Translate>
+ </th>
+ <th style={{ minWidth: 100 }}>
+ <i18n.Translate>Amount</i18n.Translate>
+ </th>
+ <th style={{ minWidth: 400 }}>
+ <i18n.Translate>Summary</i18n.Translate>
+ </th>
+ <th style={{ minWidth: 50 }} />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.id}>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.timestamp.t_s === "never"
+ ? "never"
+ : format(
+ new Date(i.timestamp.t_s * 1000),
+ datetimeFormatForSettings(settings),
+ )}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.amount}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.summary}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ {i.refundable && (
+ <button
+ class="button is-small is-danger jb-modal"
+ type="button"
+ onClick={(): void => onRefund(i)}
+ >
+ <i18n.Translate>Refund</i18n.Translate>
+ </button>
+ )}
+ {!i.paid && (
+ <button
+ class="button is-small is-info jb-modal"
+ type="button"
+ onClick={(): void => onCopyURL(i)}
+ >
+ <i18n.Translate>copy url</i18n.Translate>
+ </button>
+ )}
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {onLoadMoreAfter && (
+ <button class="button is-fullwidth"
+ data-tooltip={i18n.str`load more orders after the last one`}
+ onClick={onLoadMoreAfter}>
+ <i18n.Translate>load next page</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-magnify mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ No orders have been found matching your query!
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
+
+interface RefundModalProps {
+ onCancel: () => void;
+ onConfirm: (value: TalerMerchantApi.RefundRequest) => void;
+ order: TalerMerchantApi.MerchantOrderStatusResponse;
+}
+
+export function RefundModal({
+ order,
+ onCancel,
+ onConfirm,
+}: RefundModalProps): VNode {
+ type State = { mainReason?: string; description?: string; refund?: string };
+ const [form, setValue] = useState<State>({});
+ const [settings] = usePreference();
+ const { i18n } = useTranslationContext();
+ // const [errors, setErrors] = useState<FormErrors<State>>({});
+
+ const refunds = (
+ order.order_status === "paid" ? order.refund_details : []
+ ).reduce(mergeRefunds, []);
+
+ const { config } = useSessionContext();
+ const totalRefunded = refunds
+ .map((r) => r.amount)
+ .reduce(
+ (p, c) => Amounts.add(p, Amounts.parseOrThrow(c)).amount,
+ Amounts.zeroOfCurrency(config.currency),
+ );
+ const orderPrice =
+ order.order_status === "paid"
+ ? Amounts.parseOrThrow(order.contract_terms.amount)
+ : undefined;
+ const totalRefundable = !orderPrice
+ ? Amounts.zeroOfCurrency(totalRefunded.currency)
+ : refunds.length
+ ? Amounts.sub(orderPrice, totalRefunded).amount
+ : orderPrice;
+
+ const isRefundable = Amounts.isNonZero(totalRefundable);
+ const duplicatedText = i18n.str`duplicated`;
+
+ const errors: FormErrors<State> = {
+ mainReason: !form.mainReason ? i18n.str`required` : undefined,
+ description:
+ !form.description && form.mainReason !== duplicatedText
+ ? i18n.str`required`
+ : undefined,
+ refund: !form.refund
+ ? i18n.str`required`
+ : !Amounts.parse(form.refund)
+ ? i18n.str`invalid format`
+ : Amounts.cmp(totalRefundable, Amounts.parse(form.refund)!) === -1
+ ? i18n.str`this value exceed the refundable amount`
+ : undefined,
+ };
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as Record<string, unknown>)[k] !== undefined,
+ );
+
+ const validateAndConfirm = () => {
+ try {
+ if (!form.refund) return;
+ onConfirm({
+ refund: Amounts.stringify(
+ Amounts.add(Amounts.parse(form.refund)!, totalRefunded).amount,
+ ),
+ reason:
+ form.description === undefined
+ ? form.mainReason || ""
+ : `${form.mainReason}: ${form.description}`,
+ });
+ } catch (err) {
+ console.log(err);
+ }
+ };
+
+ //FIXME: parameters in the translation
+ return (
+ <ConfirmModal
+ description="refund"
+ danger
+ active
+ disabled={!isRefundable || hasErrors}
+ onCancel={onCancel}
+ onConfirm={validateAndConfirm}
+ >
+ {refunds.length > 0 && (
+ <div class="columns">
+ <div class="column is-12">
+ <InputGroup
+ name="asd"
+ label={`${Amounts.stringify(totalRefunded)} was already refunded`}
+ >
+ <table class="table is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>date</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>amount</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>reason</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {refunds.map((r) => {
+ return (
+ <tr key={r.timestamp.t_s}>
+ <td>
+ {r.timestamp.t_s === "never"
+ ? "never"
+ : format(
+ new Date(r.timestamp.t_s * 1000),
+ datetimeFormatForSettings(settings),
+ )}
+ </td>
+ <td>{r.amount}</td>
+ <td>{r.reason}</td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </InputGroup>
+ </div>
+ </div>
+ )}
+
+ {isRefundable && (
+ <FormProvider<State>
+ errors={errors}
+ object={form}
+ valueHandler={(d) => setValue(d)}
+ >
+ <InputCurrency<State>
+ name="refund"
+ label={i18n.str`Refund`}
+ tooltip={i18n.str`amount to be refunded`}
+ >
+ <i18n.Translate>Max refundable:</i18n.Translate>{" "}
+ {Amounts.stringify(totalRefundable)}
+ </InputCurrency>
+ <InputSelector
+ name="mainReason"
+ label={i18n.str`Reason`}
+ values={[
+ i18n.str`Choose one...`,
+ duplicatedText,
+ i18n.str`requested by the customer`,
+ i18n.str`other`,
+ ]}
+ tooltip={i18n.str`why this order is being refunded`}
+ />
+ {form.mainReason && form.mainReason !== duplicatedText ? (
+ <Input<State>
+ label={i18n.str`Description`}
+ name="description"
+ tooltip={i18n.str`more information to give context`}
+ />
+ ) : undefined}
+ </FormProvider>
+ )}
+ </ConfirmModal>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx
new file mode 100644
index 000000000..8a1f85b1c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx
@@ -0,0 +1,230 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ TalerError,
+ TalerMerchantApi,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { JumpToElementById } from "../../../../components/form/JumpToElementById.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import {
+ InstanceOrderFilter,
+ useInstanceOrders,
+ useOrderDetails,
+} from "../../../../hooks/order.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { ListPage } from "./ListPage.js";
+import { RefundModal } from "./Table.js";
+
+interface Props {
+ onSelect: (id: string) => void;
+ onCreate: () => void;
+}
+
+export default function OrderList({ onCreate, onSelect }: Props): VNode {
+ const [filter, setFilter] = useState<InstanceOrderFilter>({ paid: false });
+ const [orderToBeRefunded, setOrderToBeRefunded] = useState<
+ TalerMerchantApi.OrderHistoryEntry | undefined
+ >(undefined);
+
+ const setNewDate = (date?: AbsoluteTime): void =>
+ setFilter((prev) => ({ ...prev, date }));
+
+ const result = useInstanceOrders(filter, (d) =>
+ setFilter({ ...filter, position: d }),
+ );
+ const { lib } = useSessionContext();
+
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+ const { state } = useSessionContext();
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch(result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result)
+ }
+ }
+ }
+
+ const isNotPaidActive = filter.paid === false ? "is-active" : "";
+ const isPaidActive =
+ filter.paid === true && filter.wired === undefined ? "is-active" : "";
+ const isRefundedActive = filter.refunded === true ? "is-active" : "";
+ const isNotWiredActive =
+ filter.wired === false && filter.paid === true ? "is-active" : "";
+ const isWiredActive = filter.wired === true ? "is-active" : "";
+ const isAllActive =
+ filter.paid === undefined &&
+ filter.refunded === undefined &&
+ filter.wired === undefined
+ ? "is-active"
+ : "";
+
+ return (
+ <section class="section is-main-section">
+ <NotificationCard notification={notif} />
+
+ <JumpToElementById
+ testIfExist={async (order) => {
+ const resp = await lib.instance.getOrderDetails(state.token, order);
+ return resp.type === "ok";
+ }}
+ onSelect={onSelect}
+ description={i18n.str`jump to order with the given product ID`}
+ placeholder={i18n.str`order id`}
+ />
+
+ <ListPage
+ orders={result.body.map((o) => ({ ...o, id: o.order_id }))}
+ onLoadMoreBefore={result.isFirstPage ? undefined : result.loadFirst}
+ onLoadMoreAfter={result.isLastPage ? undefined : result.loadNext}
+ onSelectOrder={(order) => onSelect(order.id)}
+ onRefundOrder={(value) => setOrderToBeRefunded(value)}
+ isAllActive={isAllActive}
+ isNotWiredActive={isNotWiredActive}
+ isWiredActive={isWiredActive}
+ isPaidActive={isPaidActive}
+ isNotPaidActive={isNotPaidActive}
+ isRefundedActive={isRefundedActive}
+ jumpToDate={filter.date}
+ onSelectDate={setNewDate}
+ onCopyURL={async (id) => {
+ const resp = await lib.instance.getOrderDetails(state.token, id);
+ if (resp.type === "ok") {
+ if (resp.body.order_status === "unpaid") {
+ copyToClipboard(resp.body.taler_pay_uri);
+ } else {
+ if (resp.body.contract_terms.fulfillment_url) {
+ copyToClipboard(resp.body.contract_terms.fulfillment_url);
+ }
+ }
+ copyToClipboard(resp.body.order_status);
+ }
+ }}
+ onCreate={onCreate}
+ onShowAll={() => setFilter({})}
+ onShowNotPaid={() => setFilter({ paid: false })}
+ onShowPaid={() => setFilter({ paid: true })}
+ onShowRefunded={() => setFilter({ refunded: true })}
+ onShowNotWired={() => setFilter({ wired: false, paid: true })}
+ onShowWired={() => setFilter({ wired: true })}
+ />
+
+ {orderToBeRefunded && (
+ <RefundModalForTable
+ id={orderToBeRefunded.order_id}
+ onCancel={() => setOrderToBeRefunded(undefined)}
+ onConfirm={(value) => {
+ lib.instance
+ .addRefund(state.token, orderToBeRefunded.order_id, value)
+ .then(() =>
+ setNotif({
+ message: i18n.str`refund created successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not create the refund`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ )
+ .then(() => setOrderToBeRefunded(undefined));
+ }}
+ />
+ )}
+ </section>
+ );
+}
+
+interface RefundProps {
+ id: string;
+ onCancel: () => void;
+ onConfirm: (m: TalerMerchantApi.RefundRequest) => void;
+}
+
+function RefundModalForTable({ id, onConfirm, onCancel }: RefundProps): VNode {
+ const result = useOrderDetails(id);
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.BadGateway: {
+ return <div>Failed to obtain a response from the exchange</div>;
+ }
+ case HttpStatusCode.GatewayTimeout: {
+ return (
+ <div>The merchant's interaction with the exchange took too long</div>
+ );
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ return (
+ <RefundModal
+ order={result.body}
+ onCancel={onCancel}
+ onConfirm={onConfirm}
+ />
+ );
+}
+
+async function copyToClipboard(text: string): Promise<void> {
+ return navigator.clipboard.writeText(text);
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx
new file mode 100644
index 000000000..36b31ebe8
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/Create.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/OtpDevices/Create",
+ component: TestedComponent,
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx
new file mode 100644
index 000000000..d5522c2d4
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx
@@ -0,0 +1,181 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ TalerMerchantApi,
+ isRfc3548Base32Charset,
+ randomRfc3548Base32Key,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
+
+type Entity = TalerMerchantApi.OtpDeviceAddDetails;
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+const algorithms = [0, 1, 2];
+const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"];
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>({});
+
+ const [showKey, setShowKey] = useState(false);
+
+ const errors: FormErrors<Entity> = {
+ otp_device_id: !state.otp_device_id
+ ? i18n.str`required`
+ : !/[a-zA-Z0-9]*/.test(state.otp_device_id)
+ ? i18n.str`no valid. only characters and numbers`
+ : undefined,
+ otp_algorithm: !state.otp_algorithm ? i18n.str`required` : undefined,
+ otp_key: !state.otp_key
+ ? i18n.str`required`
+ : !isRfc3548Base32Charset(state.otp_key)
+ ? i18n.str`just letters and numbers from 2 to 7`
+ : state.otp_key.length !== 32
+ ? i18n.str`size of the key should be 32`
+ : undefined,
+ otp_device_description: !state.otp_device_description
+ ? i18n.str`required`
+ : !/[a-zA-Z0-9]*/.test(state.otp_device_description)
+ ? i18n.str`no valid. only characters and numbers`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onCreate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <Input<Entity>
+ name="otp_device_id"
+ label={i18n.str`ID`}
+ tooltip={i18n.str`Internal id on the system`}
+ />
+ <Input<Entity>
+ name="otp_device_description"
+ label={i18n.str`Descripiton`}
+ tooltip={i18n.str`Useful to identify the device physically`}
+ />
+ <InputSelector<Entity>
+ name="otp_algorithm"
+ label={i18n.str`Verification algorithm`}
+ tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`}
+ values={algorithms}
+ toStr={(v) => algorithmsNames[v]}
+ fromStr={(v) => Number(v)}
+ />
+ {state.otp_algorithm ? (
+ <Fragment>
+ <InputWithAddon<Entity>
+ expand
+ name="otp_key"
+ label={i18n.str`Device key`}
+ inputType={showKey ? "text" : "password"}
+ help="Be sure to be very hard to guess or use the random generator"
+ tooltip={i18n.str`Your device need to have exactly the same value`}
+ fromStr={(v) => v.toUpperCase()}
+ addonAfterAction={() => {
+ setShowKey(!showKey);
+ }}
+ addonAfter={
+ <span class="icon">
+ {showKey ? (
+ <i class="mdi mdi-eye" />
+ ) : (
+ <i class="mdi mdi-eye-off" />
+ )}
+ </span>
+ }
+ side={
+ <button
+ data-tooltip={i18n.str`generate random secret key`}
+ class="button is-info mr-3"
+ onClick={(e) => {
+ setState((s) => ({
+ ...s,
+ otp_key: randomRfc3548Base32Key(),
+ }));
+ e.preventDefault();
+ }}
+ >
+ <i18n.Translate>random</i18n.Translate>
+ </button>
+ }
+ />
+ </Fragment>
+ ) : undefined}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx
new file mode 100644
index 000000000..7723bec81
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx
@@ -0,0 +1,98 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { QR } from "../../../../components/exception/QR.js";
+import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
+import { useSessionContext } from "../../../../context/session.js";
+
+type Entity = TalerMerchantApi.OtpDeviceAddDetails;
+
+interface Props {
+ entity: Entity;
+ onConfirm: () => void;
+}
+
+export function CreatedSuccessfully({
+ entity,
+ onConfirm,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { state } = useSessionContext();
+ const issuer = state.backendUrl.href;
+ const qrText = `otpauth://totp/${state.instance}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`;
+ const qrTextSafe = `otpauth://totp/${state.instance}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`;
+
+ return (
+ <Template onConfirm={onConfirm} >
+ <p class="is-size-5">
+ <i18n.Translate>
+ You can scan the next QR code with your device or save the key before continuing.
+ </i18n.Translate>
+ </p>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">ID</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ readonly
+ class="input"
+ value={entity.otp_device_id}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label"><i18n.Translate>Description</i18n.Translate></label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input
+ class="input"
+ readonly
+ value={entity.otp_device_description}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ <QR
+ text={qrText}
+ />
+ <div
+ style={{
+ color: "grey",
+ fontSize: "small",
+ width: 200,
+ textAlign: "center",
+ margin: "auto",
+ wordBreak: "break-all",
+ }}
+ >
+ {qrTextSafe}
+ </div>
+ </Template>
+ );
+}
+
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx
new file mode 100644
index 000000000..8ab0e1f26
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/index.tsx
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+import { CreatedSuccessfully } from "./CreatedSuccessfully.js";
+
+export type Entity = TalerMerchantApi.OtpDeviceAddDetails;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+
+export default function CreateValidator({ onConfirm, onBack }: Props): VNode {
+ const { lib: api } = useSessionContext();
+ const { state } = useSessionContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const [created, setCreated] = useState<TalerMerchantApi.OtpDeviceAddDetails | null>(null)
+
+ if (created) {
+ return <CreatedSuccessfully entity={created} onConfirm={onConfirm} />
+ }
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: Entity) => {
+ return api.instance.addOtpDevice(state.token, request)
+ .then((d) => {
+ setCreated(request)
+ })
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create device`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx
new file mode 100644
index 000000000..49032c80e
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/List.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/OtpDevices/List",
+ component: TestedComponent,
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx
new file mode 100644
index 000000000..8ca0a9c58
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/ListPage.tsx
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { CardTable } from "./Table.js";
+
+export interface Props {
+ devices: TalerMerchantApi.OtpDeviceEntry[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+ onCreate: () => void;
+ onDelete: (e: TalerMerchantApi.OtpDeviceEntry) => void;
+ onSelect: (e: TalerMerchantApi.OtpDeviceEntry) => void;
+}
+
+export function ListPage({
+ devices,
+ onCreate,
+ onDelete,
+ onSelect,
+ onLoadMoreBefore,
+ onLoadMoreAfter,
+}: Props): VNode {
+
+ return (
+ <section class="section is-main-section">
+ <CardTable
+ devices={devices.map((o) => ({
+ ...o,
+ id: String(o.otp_device_id),
+ }))}
+ onCreate={onCreate}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ onLoadMoreBefore={onLoadMoreBefore}
+ onLoadMoreAfter={onLoadMoreAfter}
+ />
+ </section>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx
new file mode 100644
index 000000000..afe3c98e2
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx
@@ -0,0 +1,196 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+
+type Entity = TalerMerchantApi.OtpDeviceEntry;
+
+interface Props {
+ devices: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ onCreate: () => void;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ devices,
+ onCreate,
+ onDelete,
+ onSelect,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <i18n.Translate>OTP Devices</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new devices`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {devices.length > 0 ? (
+ <Table
+ instances={devices}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+function Table({
+ instances,
+ onLoadMoreAfter,
+ onDelete,
+ onSelect,
+ onLoadMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ {onLoadMoreBefore && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more devices before the first one`}
+ onClick={onLoadMoreBefore}
+ >
+ <i18n.Translate>load first page</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>ID</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Description</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.otp_device_id}>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.otp_device_id}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.device_description}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected devices from the database`}
+ onClick={() => onDelete(i)}
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {onLoadMoreAfter && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more devices after the last one`}
+ onClick={onLoadMoreAfter}
+ >
+ <i18n.Translate>load next page</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-magnify mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no devices yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx
new file mode 100644
index 000000000..b6a077863
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/index.tsx
@@ -0,0 +1,106 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ HttpStatusCode,
+ TalerError,
+ TalerMerchantApi,
+ assertUnreachable
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useInstanceOtpDevices } from "../../../../hooks/otp.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { ListPage } from "./ListPage.js";
+
+interface Props {
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+}
+
+export default function ListOtpDevices({ onCreate, onSelect }: Props): VNode {
+ // const [position, setPosition] = useState<string | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+ const result = useInstanceOtpDevices();
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <ListPage
+ devices={result.body.otp_devices}
+ onLoadMoreBefore={undefined} //result.isFirstPage ? undefined : result.loadFirst}
+ onLoadMoreAfter={undefined} //result.isLastPage ? undefined : result.loadNext}
+ onCreate={onCreate}
+ onSelect={(e) => {
+ onSelect(e.otp_device_id);
+ }}
+ onDelete={(e: TalerMerchantApi.OtpDeviceEntry) => {
+ return lib.instance
+ .deleteOtpDevice(state.token, e.otp_device_id)
+ .then(() =>
+ setNotif({
+ message: i18n.str`validator delete successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not delete the validator`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ );
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx
new file mode 100644
index 000000000..06ea9d07a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/Update.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/OtpDevices/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx
new file mode 100644
index 000000000..35d67cbc6
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/UpdatePage.tsx
@@ -0,0 +1,185 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { randomRfc3548Base32Key, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
+
+type Entity = TalerMerchantApi.OtpDevicePatchDetails & WithId;
+
+interface Props {
+ onUpdate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+ device: Entity;
+}
+const algorithms = [0, 1, 2];
+const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"];
+export function UpdatePage({ device, onUpdate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>(device);
+ const [showKey, setShowKey] = useState(false);
+
+ const errors: FormErrors<Entity> = {};
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onUpdate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ Device: <b>{device.id}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <Input<Entity>
+ name="otp_device_description"
+ label={i18n.str`Description`}
+ tooltip={i18n.str`Useful to identify the device physically`}
+ />
+ <InputSelector<Entity>
+ name="otp_algorithm"
+ label={i18n.str`Verification algorithm`}
+ tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`}
+ values={algorithms}
+ toStr={(v) => algorithmsNames[v]}
+ fromStr={(v) => Number(v)}
+ />
+ {state.otp_algorithm && state.otp_algorithm > 0 ? (
+ <Fragment>
+ <InputWithAddon<Entity>
+ name="otp_key"
+ label={i18n.str`Device key`}
+ readonly={state.otp_key === undefined}
+ inputType={showKey ? "text" : "password"}
+ help={
+ state.otp_key === undefined
+ ? "Not modified"
+ : "Be sure to be very hard to guess or use the random generator"
+ }
+ tooltip={i18n.str`Your device need to have exactly the same value`}
+ fromStr={(v) => v.toUpperCase()}
+ addonAfterAction={() => {
+ setShowKey(!showKey);
+ }}
+ addonAfter={
+ <span
+ class="icon"
+ onClick={() => {
+ setShowKey(!showKey);
+ }}
+ >
+ {showKey ? (
+ <i class="mdi mdi-eye" />
+ ) : (
+ <i class="mdi mdi-eye-off" />
+ )}
+ </span>
+ }
+ side={
+ state.otp_key === undefined ? (
+ <button
+ onClick={(e) => {
+ setState((s) => ({ ...s, otp_key: "" }));
+ }}
+ class="button"
+ >
+ change key
+ </button>
+ ) : (
+ <button
+ data-tooltip={i18n.str`generate random secret key`}
+ class="button is-info mr-3"
+ onClick={(e) => {
+ setState((s) => ({
+ ...s,
+ otp_key: randomRfc3548Base32Key(),
+ }));
+ }}
+ >
+ <i18n.Translate>random</i18n.Translate>
+ </button>
+ )
+ }
+ />
+ </Fragment>
+ ) : undefined}{" "}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ </div>
+ </section>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx
new file mode 100644
index 000000000..99edb95c3
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/update/index.tsx
@@ -0,0 +1,147 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ HttpStatusCode,
+ TalerError,
+ TalerMerchantApi,
+ assertUnreachable
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useOtpDeviceDetails } from "../../../../hooks/otp.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { CreatedSuccessfully } from "../create/CreatedSuccessfully.js";
+import { UpdatePage } from "./UpdatePage.js";
+
+export type Entity = TalerMerchantApi.OtpDevicePatchDetails & WithId;
+
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ vid: string;
+}
+export default function UpdateValidator({
+ vid,
+ onConfirm,
+ onBack,
+}: Props): VNode {
+ const result = useOtpDeviceDetails(vid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const [keyUpdated, setKeyUpdated] =
+ useState<TalerMerchantApi.OtpDeviceAddDetails | null>(null);
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+
+ const { i18n } = useTranslationContext();
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ if (keyUpdated) {
+ return <CreatedSuccessfully entity={keyUpdated} onConfirm={onConfirm} />;
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ device={{
+ id: vid,
+ otp_algorithm: result.body.otp_algorithm,
+ otp_device_description: result.body.device_description,
+ otp_key: "",
+ otp_ctr: result.body.otp_ctr,
+ }}
+ onBack={onBack}
+ onUpdate={async (newInfo) => {
+ return lib.instance
+ .updateOtpDevice(state.token, vid, newInfo)
+ .then((d) => {
+ if (d.type === "ok") {
+ if (newInfo.otp_key) {
+ setKeyUpdated({
+ otp_algorithm: newInfo.otp_algorithm,
+ otp_device_description: newInfo.otp_device_description,
+ otp_device_id: newInfo.id,
+ otp_key: newInfo.otp_key,
+ otp_ctr: newInfo.otp_ctr,
+ });
+ } else {
+ onConfirm();
+ }
+ } else {
+ switch(d.case) {
+ case HttpStatusCode.NotFound: {
+ setNotif({
+ message: i18n.str`Could not update template`,
+ type: "ERROR",
+ description: i18n.str`Template id is unknown`,
+ });
+ break;
+ }
+ case HttpStatusCode.Conflict: {
+ setNotif({
+ message: i18n.str`Could not update template`,
+ type: "ERROR",
+ description: i18n.str`The provided information is inconsistent with the current state of the template`,
+ });
+ break;
+ }
+ }
+ }
+ })
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not update template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/create/Create.stories.tsx
new file mode 100644
index 000000000..22bbfe28a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/create/Create.stories.tsx
@@ -0,0 +1,43 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Product/Create",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx
new file mode 100644
index 000000000..64b174f64
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx
@@ -0,0 +1,80 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import { ProductForm } from "../../../../components/product/ProductForm.js";
+import { useListener } from "../../../../hooks/listener.js";
+
+type Entity = TalerMerchantApi.ProductAddDetail & {
+ product_id: string;
+};
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const [submitForm, addFormSubmitter] = useListener<Entity | undefined>(
+ (result) => {
+ if (result) return onCreate(result);
+ return Promise.reject();
+ },
+ );
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <ProductForm onSubscribe={addFormSubmitter} />
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ onClick={submitForm}
+ data-tooltip={
+ !submitForm
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ disabled={!submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx
new file mode 100644
index 000000000..2b6ebed45
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatedSuccessfully.tsx
@@ -0,0 +1,72 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
+import { Entity } from "./index.js";
+import emptyImage from "../../assets/empty.png";
+
+interface Props {
+ entity: Entity;
+ onConfirm: () => void;
+ onCreateAnother?: () => void;
+}
+
+export function CreatedSuccessfully({
+ entity,
+ onConfirm,
+ onCreateAnother,
+}: Props): VNode {
+ return (
+ <Template onConfirm={onConfirm} onCreateAnother={onCreateAnother}>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Image</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <img src={entity.image} style={{ width: 200, height: 200 }} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Description</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <textarea class="input" readonly value={entity.description} />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">Price</label>
+ </div>
+ <div class="field-body is-flex-grow-3">
+ <div class="field">
+ <p class="control">
+ <input class="input" readonly value={entity.price} />
+ </p>
+ </div>
+ </div>
+ </div>
+ </Template>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx
new file mode 100644
index 000000000..9de5cae78
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+
+export type Entity = TalerMerchantApi.ProductAddDetail;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+export default function CreateProduct({ onConfirm, onBack }: Props): VNode {
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: TalerMerchantApi.ProductAddDetail) => {
+ return lib.instance.addProduct(state.token, request)
+ .then(() => onConfirm())
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create product`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/List.stories.tsx
new file mode 100644
index 000000000..580a92cdc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/list/List.stories.tsx
@@ -0,0 +1,62 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountString } from "@gnu-taler/taler-util";
+import { FunctionalComponent, h } from "preact";
+import { CardTable as TestedComponent } from "./Table.js";
+
+export default {
+ title: "Pages/Product/List",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ onSelect: { action: "onSelect" },
+ onDelete: { action: "onDelete" },
+ onUpdate: { action: "onUpdate" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ instances: [
+ {
+ id: "orderid",
+ description: "description1",
+ description_i18n: {} as any,
+ image: "",
+ price: "TESTKUDOS:10" as AmountString,
+ taxes: [],
+ total_lost: 10,
+ total_sold: 5,
+ total_stock: 15,
+ unit: "bar",
+ address: {},
+ },
+ ],
+});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx
new file mode 100644
index 000000000..9d5701fa7
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx
@@ -0,0 +1,511 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountString, Amounts, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import emptyImage from "../../../../assets/empty.png";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputNumber } from "../../../../components/form/InputNumber.js";
+import { dateFormatForSettings, usePreference } from "../../../../hooks/preference.js";
+
+type Entity = TalerMerchantApi.ProductDetail & WithId;
+
+interface Props {
+ instances: Entity[];
+ onDelete: (id: Entity) => void;
+ onSelect: (product: Entity) => void;
+ onUpdate: (
+ id: string,
+ data: TalerMerchantApi.ProductPatchDetail,
+ ) => Promise<void>;
+ onCreate: () => void;
+ selected?: boolean;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ instances,
+ onCreate,
+ onSelect,
+ onUpdate,
+ onDelete,
+ onLoadMoreAfter,
+ onLoadMoreBefore
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string | undefined>(
+ undefined,
+ );
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-shopping" />
+ </span>
+ <i18n.Translate>Inventory</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add product to inventory`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {instances.length > 0 ? (
+ <Table
+ instances={instances}
+ onSelect={onSelect}
+ onDelete={onDelete}
+ onUpdate={onUpdate}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string | undefined;
+ instances: Entity[];
+ onSelect: (id: Entity) => void;
+ onUpdate: (
+ id: string,
+ data: TalerMerchantApi.ProductPatchDetail,
+ ) => Promise<void>;
+ onDelete: (id: Entity) => void;
+ rowSelectionHandler: StateUpdater<string | undefined>;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+function Table({
+ rowSelection,
+ rowSelectionHandler,
+ instances,
+ onSelect,
+ onUpdate,
+ onDelete,
+ onLoadMoreAfter,
+ onLoadMoreBefore
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreference();
+ return (
+ <div class="table-container">
+ {onLoadMoreBefore && (
+ <button class="button is-fullwidth" onClick={onLoadMoreBefore}>
+ <i18n.Translate>load first page</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Image</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Description</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Price per unit</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Taxes</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Sales</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Stock</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Sold</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ const restStockInfo = !i.next_restock
+ ? ""
+ : i.next_restock.t_s === "never"
+ ? "never"
+ : `restock at ${format(
+ new Date(i.next_restock.t_s * 1000),
+ dateFormatForSettings(settings),
+ )}`;
+ let stockInfo: ComponentChildren = "";
+ if (i.total_stock < 0) {
+ stockInfo = "infinite";
+ } else {
+ const totalStock = i.total_stock - i.total_lost - i.total_sold;
+ stockInfo = (
+ <label title={restStockInfo}>
+ {totalStock} {i.unit}
+ </label>
+ );
+ }
+
+ const isFree = Amounts.isZero(Amounts.parseOrThrow(i.price));
+
+ return (
+ <Fragment key={i.id}>
+ <tr key="info">
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ <img
+ src={i.image ? i.image : emptyImage}
+ style={{
+ border: "solid black 1px",
+ maxHeight: "2em",
+ width: "auto",
+ height: "auto",
+ }}
+ />
+ </td>
+ <td
+ class="has-tooltip-right"
+ data-tooltip={i.description}
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ {i.description.length > 30 ? i.description.substring(0, 30) + "..." : i.description}
+ </td>
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ {isFree ? i18n.str`free` : `${i.price} / ${i.unit}`}
+ </td>
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ {sum(i.taxes)}
+ </td>
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ {difference(i.price, sum(i.taxes))}
+ </td>
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ {stockInfo}
+ </td>
+ <td
+ onClick={() =>
+ rowSelection !== i.id && rowSelectionHandler(i.id)
+ }
+ style={{ cursor: "pointer" }}
+ >
+ <span style={{ "whiteSpace": "nowrap" }}>
+
+ {i.total_sold} {i.unit}
+ </span>
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <span
+ class="has-tooltip-bottom"
+ data-tooltip={i18n.str`go to product update page`}
+ >
+ <button
+ class="button is-small is-success "
+ type="button"
+ onClick={(): void => onSelect(i)}
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+ </span>
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`remove this product from the database`}
+ >
+ <button
+ class="button is-small is-danger"
+ type="button"
+ onClick={(): void => onDelete(i)}
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </button>
+ </span>
+ </div>
+ </td>
+ </tr>
+ {rowSelection === i.id && (
+ <tr key="form">
+ <td colSpan={10}>
+ <FastProductUpdateForm
+ product={i}
+ onUpdate={(prod) =>
+ onUpdate(i.id, prod).then(() =>
+ rowSelectionHandler(undefined),
+ )
+ }
+ onCancel={() => rowSelectionHandler(undefined)}
+ />
+ </td>
+ </tr>
+ )}
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+ {onLoadMoreAfter && (
+ <button class="button is-fullwidth"
+ data-tooltip={i18n.str`load more products after the last one`}
+ onClick={onLoadMoreAfter}>
+ <i18n.Translate>load next page</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+interface FastProductUpdateFormProps {
+ product: Entity;
+ onUpdate: (
+ data: TalerMerchantApi.ProductPatchDetail,
+ ) => Promise<void>;
+ onCancel: () => void;
+}
+interface FastProductUpdate {
+ incoming: number;
+ lost: number;
+ price: string;
+}
+interface UpdatePrice {
+ price: string;
+}
+
+function FastProductWithInfiniteStockUpdateForm({
+ product,
+ onUpdate,
+ onCancel,
+}: FastProductUpdateFormProps) {
+ const [value, valueHandler] = useState<UpdatePrice>({ price: product.price });
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <FormProvider<FastProductUpdate>
+ name="added"
+ object={value}
+ valueHandler={valueHandler as any}
+ >
+ <InputCurrency<FastProductUpdate>
+ name="price"
+ label={i18n.str`Price`}
+ tooltip={i18n.str`update the product with new price`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-expanded">
+
+ <div class="buttons is-right mt-5">
+ <button class="button" onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`update product with new price`}
+ >
+ <button
+ class="button is-info"
+ onClick={() =>
+ onUpdate({
+ ...product,
+ price: value.price as AmountString,
+ })
+ }
+ >
+ <i18n.Translate>Confirm update</i18n.Translate>
+ </button>
+ </span>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+
+function FastProductWithManagedStockUpdateForm({
+ product,
+ onUpdate,
+ onCancel,
+}: FastProductUpdateFormProps) {
+ const [value, valueHandler] = useState<FastProductUpdate>({
+ incoming: 0,
+ lost: 0,
+ price: product.price,
+ });
+
+ const currentStock =
+ product.total_stock - product.total_sold - product.total_lost;
+
+ const errors: FormErrors<FastProductUpdate> = {
+ lost:
+ currentStock + value.incoming < value.lost
+ ? `lost cannot be greater that current + incoming (max ${currentStock + value.incoming
+ })`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as Record<string,unknown>)[k] !== undefined,
+ );
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <FormProvider<FastProductUpdate>
+ name="added"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler as any}
+ >
+ <InputNumber<FastProductUpdate>
+ name="incoming"
+ label={i18n.str`Incoming`}
+ tooltip={i18n.str`add more elements to the inventory`}
+ />
+ <InputNumber<FastProductUpdate>
+ name="lost"
+ label={i18n.str`Lost`}
+ tooltip={i18n.str`report elements lost in the inventory`}
+ />
+ <InputCurrency<FastProductUpdate>
+ name="price"
+ label={i18n.str`Price`}
+ tooltip={i18n.str`new price for the product`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ <button class="button" onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <span
+ class="has-tooltip-left"
+ data-tooltip={
+ hasErrors
+ ? i18n.str`the are value with errors`
+ : i18n.str`update product with new stock and price`
+ }
+ >
+ <button
+ class="button is-info"
+ disabled={hasErrors}
+ onClick={() =>
+ onUpdate({
+ ...product,
+ total_stock: product.total_stock + value.incoming,
+ total_lost: product.total_lost + value.lost,
+ price: value.price as AmountString,
+ })
+ }
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </span>
+ </div>
+ </Fragment>
+ );
+}
+
+function FastProductUpdateForm(props: FastProductUpdateFormProps) {
+ return props.product.total_stock === -1 ? (
+ <FastProductWithInfiniteStockUpdateForm {...props} />
+ ) : (
+ <FastProductWithManagedStockUpdateForm {...props} />
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-magnify mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no products yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
+
+function difference(price: string, tax: number) {
+ if (!tax) return price;
+ const ps = price.split(":");
+ const p = parseInt(ps[1], 10);
+ ps[1] = `${p - tax}`;
+ return ps.join(":");
+}
+function sum(taxes: TalerMerchantApi.Tax[]) {
+ return taxes.reduce((p, c) => p + parseInt(c.tax.split(":")[1], 10), 0);
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx
new file mode 100644
index 000000000..6ad0d4598
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx
@@ -0,0 +1,153 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { JumpToElementById } from "../../../../components/form/JumpToElementById.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { ConfirmModal } from "../../../../components/modal/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import {
+ useInstanceProducts
+} from "../../../../hooks/product.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { CardTable } from "./Table.js";
+
+interface Props {
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+}
+export default function ProductList({
+ onCreate,
+ onSelect,
+}: Props): VNode {
+ const result = useInstanceProducts();
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+ const [deleting, setDeleting] =
+ useState<TalerMerchantApi.ProductDetail & WithId | null>(null);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ return (
+ <section class="section is-main-section">
+ <NotificationCard notification={notif} />
+
+ <JumpToElementById
+ testIfExist={async (id) => {
+ const resp = await lib.instance.getProductDetails(state.token, id);
+ return resp.type === "ok";
+ }}
+ onSelect={onSelect}
+ description={i18n.str`jump to product with the given product ID`}
+ placeholder={i18n.str`product id`}
+ />
+
+ <CardTable
+ instances={result.body}
+ onLoadMoreBefore={result.isFirstPage ? undefined : result.loadFirst}
+ onLoadMoreAfter={result.isLastPage ? undefined : result.loadNext}
+ onCreate={onCreate}
+ onUpdate={async (id, prod) => {
+ try {
+ await lib.instance.updateProduct(state.token, id, prod);
+ setNotif({
+ message: i18n.str`product updated successfully`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`could not update the product`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+ return
+ }}
+ onSelect={(product) => onSelect(product.id)}
+ onDelete={(prod: TalerMerchantApi.ProductDetail & WithId) =>
+ setDeleting(prod)
+ }
+ />
+
+ {deleting && (
+ <ConfirmModal
+ label={`Delete product`}
+ description={`Delete the product "${deleting.description}"`}
+ danger
+ active
+ onCancel={() => setDeleting(null)}
+ onConfirm={async (): Promise<void> => {
+ try {
+ await lib.instance.deleteProduct(state.token, deleting.id);
+ setNotif({
+ message: i18n.str`Product "${deleting.description}" (ID: ${deleting.id}) has been deleted`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`Failed to delete product`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+ setDeleting(null);
+ }}
+ >
+ <p>
+ If you delete the product named <b>&quot;{deleting.description}&quot;</b> (ID:{" "}
+ <b>{deleting.id}</b>), the stock and related information will be lost
+ </p>
+ <p class="warning">
+ Deleting an product <b>cannot be undone</b>.
+ </p>
+ </ConfirmModal>
+ )}
+ </section>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx
new file mode 100644
index 000000000..7aa93b186
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/update/Update.stories.tsx
@@ -0,0 +1,74 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountString } from "@gnu-taler/taler-util";
+import { FunctionalComponent, h } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/Product/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const WithManagedStock = createExample(TestedComponent, {
+ product: {
+ product_id: "20102-ASDAS-QWE",
+ description: "description1",
+ description_i18n: {} as any,
+ image: "",
+ price: "TESTKUDOS:10" as AmountString,
+ taxes: [],
+ total_lost: 10,
+ total_sold: 5,
+ total_stock: 15,
+ unit: "bar",
+ address: {},
+ },
+});
+
+export const WithInfiniteStock = createExample(TestedComponent, {
+ product: {
+ product_id: "20102-ASDAS-QWE",
+ description: "description1",
+ description_i18n: {} as any,
+ image: "",
+ price: "TESTKUDOS:10" as AmountString,
+ taxes: [],
+ total_lost: 10,
+ total_sold: 5,
+ total_stock: -1,
+ unit: "bar",
+ address: {},
+ },
+});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx
new file mode 100644
index 000000000..5395ae40f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx
@@ -0,0 +1,99 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import { ProductForm } from "../../../../components/product/ProductForm.js";
+import { useListener } from "../../../../hooks/listener.js";
+
+type Entity = TalerMerchantApi.ProductDetail & { product_id: string };
+
+interface Props {
+ onUpdate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+ product: Entity;
+}
+
+export function UpdatePage({ product, onUpdate, onBack }: Props): VNode {
+ const [submitForm, addFormSubmitter] = useListener<Entity | undefined>(
+ (result) => {
+ if (result) return onUpdate(result);
+ return Promise.resolve();
+ },
+ );
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ <i18n.Translate>Product id:</i18n.Translate>
+ <b>{product.product_id}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <ProductForm
+ initial={product}
+ onSubscribe={addFormSubmitter}
+ alreadyExist
+ />
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ onClick={submitForm}
+ data-tooltip={
+ !submitForm
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ disabled={!submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx
new file mode 100644
index 000000000..5e3e58d80
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx
@@ -0,0 +1,94 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useProductDetails } from "../../../../hooks/product.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { UpdatePage } from "./UpdatePage.js";
+
+export type Entity = TalerMerchantApi.ProductAddDetail;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ pid: string;
+}
+export default function UpdateProduct({
+ pid,
+ onConfirm,
+ onBack,
+}: Props): VNode {
+ const result = useProductDetails(pid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+
+ const { i18n } = useTranslationContext();
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ product={{ ...result.body, product_id: pid }}
+ onBack={onBack}
+ onUpdate={(data) => {
+ return lib.instance.updateProduct(state.token, pid, data)
+ .then(onConfirm)
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create product`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx
new file mode 100644
index 000000000..53025f153
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Templates/Create",
+ component: TestedComponent,
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx
new file mode 100644
index 000000000..78d7c83ac
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx
@@ -0,0 +1,292 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AmountString,
+ Amounts,
+ Duration,
+ TalerError,
+ TalerMerchantApi,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputDuration } from "../../../../components/form/InputDuration.js";
+import { InputNumber } from "../../../../components/form/InputNumber.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { InputToggle } from "../../../../components/form/InputToggle.js";
+import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
+import { TextField } from "../../../../components/form/TextField.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useInstanceOtpDevices } from "../../../../hooks/otp.js";
+
+// type Entity = TalerMerchantApi.TemplateAddDetails & { type: Steps };
+type Entity = {
+ id?: string;
+ description?: string;
+ otpId?: string;
+ summary?: string;
+ amount?: AmountString;
+ minimum_age?: number;
+ pay_duration?: Duration;
+ summary_editable?: boolean;
+ amount_editable?: boolean;
+ currency_editable?: boolean;
+};
+
+interface Props {
+ onCreate: (d: TalerMerchantApi.TemplateAddDetails) => Promise<void>;
+ onBack?: () => void;
+}
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { config } = useSessionContext();
+ const {state:session} = useSessionContext();
+ const devices = useInstanceOtpDevices();
+
+ const [state, setState] = useState<Partial<Entity>>({
+ minimum_age: 0,
+ pay_duration: {
+ d_ms: 1000 * 60 * 30, //30 min
+ },
+ });
+
+ function updateState(up: (s: Partial<Entity>) => Partial<Entity>) {
+ setState((old) => {
+ const newState = up(old);
+ if (!newState.amount_editable) {
+ newState.currency_editable = false;
+ }
+ return newState;
+ });
+ }
+
+ const parsedPrice = !state.amount ? undefined : Amounts.parse(state.amount);
+
+ const errors: FormErrors<Entity> = {
+ id: !state.id
+ ? i18n.str`should not be empty`
+ : !/[a-zA-Z0-9]*/.test(state.id)
+ ? i18n.str`no valid. only characters and numbers`
+ : undefined,
+ description: !state.description ? i18n.str`should not be empty` : undefined,
+ amount: !state.amount
+ ? undefined
+ : !parsedPrice
+ ? i18n.str`not valid`
+ : Amounts.isZero(parsedPrice)
+ ? i18n.str`must be greater than 0`
+ : undefined,
+ minimum_age:
+ state.minimum_age && state.minimum_age < 0
+ ? i18n.str`should be greater that 0`
+ : undefined,
+ pay_duration: !state.pay_duration
+ ? i18n.str`can't be empty`
+ : state.pay_duration.d_ms === "forever"
+ ? undefined
+ : state.pay_duration.d_ms < 1000 //less than one second
+ ? i18n.str`to short`
+ : undefined,
+ };
+
+ const cList = Object.values(config.currencies).map((d) => d.name);
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as Record<string, unknown>)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onCreate({
+ template_id: state.id!,
+ template_description: state.description!,
+ template_contract: {
+ minimum_age: state.minimum_age!,
+ pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!),
+ amount: state.amount_editable ? undefined : state.amount,
+ summary: state.summary_editable ? undefined : state.summary,
+ currency:
+ cList.length > 1 && state.currency_editable
+ ? undefined
+ : config.currency,
+ },
+ editable_defaults: {
+ amount: !state.amount_editable ? undefined : state.amount,
+ summary: !state.summary_editable ? undefined : state.summary,
+ currency:
+ cList.length === 1 || !state.currency_editable
+ ? undefined
+ : config.currency,
+ },
+ otp_id: state.otpId!,
+ });
+ };
+ const deviceList =
+ !devices || devices instanceof TalerError || devices.type === "fail"
+ ? []
+ : devices.body.otp_devices;
+ const deviceMap = deviceList.reduce(
+ (prev, cur) => {
+ prev[cur.otp_device_id] = cur.device_description as TranslatedString;
+ return prev;
+ },
+ {} as Record<string, TranslatedString>,
+ );
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={updateState}
+ errors={errors}
+ >
+ <InputWithAddon<Entity>
+ name="id"
+ help={
+ new URL(`templates/${state.id ?? ""}`, session.backendUrl.href).href
+ }
+ label={i18n.str`Identifier`}
+ tooltip={i18n.str`Name of the template in URLs.`}
+ />
+ <Input<Entity>
+ name="description"
+ label={i18n.str`Description`}
+ help=""
+ tooltip={i18n.str`Describe what this template stands for`}
+ />
+
+ <Input<Entity>
+ name="summary"
+ inputType="multiline"
+ label={i18n.str`Summary`}
+ tooltip={i18n.str`If specified, this template will create order with the same summary`}
+ />
+ <InputToggle<Entity>
+ name="summary_editable"
+ label={i18n.str`Summary is editable`}
+ tooltip={i18n.str`Allow the user to change the summary.`}
+ />
+
+ <InputCurrency<Entity>
+ name="amount"
+ label={i18n.str`Amount`}
+ tooltip={i18n.str`If specified, this template will create order with the same price`}
+ />
+ <InputToggle<Entity>
+ name="amount_editable"
+ label={i18n.str`Amount is editable`}
+ tooltip={i18n.str`Allow the user to select the amount to pay.`}
+ />
+ {cList.length > 1 && (
+ <Fragment>
+ <InputToggle<Entity>
+ name="currency_editable"
+ readonly={!state.amount_editable}
+ label={i18n.str`Currency is editable`}
+ tooltip={i18n.str`Allow the user to change currency.`}
+ />
+ <TextField name="sc" label={i18n.str`Supported currencies`}>
+ <i18n.Translate>supported currencies: {cList.join(", ")}</i18n.Translate>
+ </TextField>
+ </Fragment>
+ )}
+ <InputNumber<Entity>
+ name="minimum_age"
+ label={i18n.str`Minimum age`}
+ help=""
+ tooltip={i18n.str`Is this contract restricted to some age?`}
+ />
+ <InputDuration<Entity>
+ name="pay_duration"
+ label={i18n.str`Payment timeout`}
+ help=""
+ tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`}
+ />
+ {!deviceList.length ? (
+ <TextField
+ name="otpId"
+ label={i18n.str`OTP device`}
+ tooltip={i18n.str`Use to verify transaction while offline.`}
+ >
+ <i18n.Translate>No OTP device.</i18n.Translate>&nbsp;
+ <a href="/otp-devices/new">
+ <i18n.Translate>Add one first</i18n.Translate>
+ </a>
+ </TextField>
+ ) : (
+ <InputSelector<Entity>
+ name="otpId"
+ label={i18n.str`OTP device`}
+ values={[
+ undefined,
+ ...deviceList.map((e) => e.otp_device_id),
+ ]}
+ toStr={(v?: string) => {
+ if (!v) {
+ return i18n.str`No device`;
+ }
+ return deviceMap[v];
+ }}
+ tooltip={i18n.str`Use to verify transaction in offline mode.`}
+ />
+ )}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx
new file mode 100644
index 000000000..499c7c859
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx
@@ -0,0 +1,62 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+
+export type Entity = TalerMerchantApi.TransferInformation;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+
+export default function CreateTransfer({ onConfirm, onBack }: Props): VNode {
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: TalerMerchantApi.TemplateAddDetails) => {
+ return lib.instance.addTemplate(state.token, request)
+ .then(() => onConfirm())
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not inform template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx
new file mode 100644
index 000000000..707324d40
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/Templates/List",
+ component: TestedComponent,
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx
new file mode 100644
index 000000000..66d8a2f7e
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx
@@ -0,0 +1,63 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { CardTable } from "./Table.js";
+
+export interface Props {
+ templates: TalerMerchantApi.TemplateEntry[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+ onCreate: () => void;
+ onDelete: (e: TalerMerchantApi.TemplateEntry) => void;
+ onSelect: (e: TalerMerchantApi.TemplateEntry) => void;
+ onNewOrder: (e: TalerMerchantApi.TemplateEntry) => void;
+ onQR: (e: TalerMerchantApi.TemplateEntry) => void;
+}
+
+export function ListPage({
+ templates,
+ onCreate,
+ onDelete,
+ onSelect,
+ onNewOrder,
+ onQR,
+ onLoadMoreBefore,
+ onLoadMoreAfter,
+}: Props): VNode {
+
+ return (
+ <CardTable
+ templates={templates.map((o) => ({
+ ...o,
+ id: String(o.template_id),
+ }))}
+ onQR={onQR}
+ onCreate={onCreate}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ onNewOrder={onNewOrder}
+ onLoadMoreBefore={onLoadMoreBefore}
+ onLoadMoreAfter={onLoadMoreAfter}
+ />
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx
new file mode 100644
index 000000000..082e622e3
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx
@@ -0,0 +1,220 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+
+type Entity = TalerMerchantApi.TemplateEntry;
+
+interface Props {
+ templates: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ onNewOrder: (e: Entity) => void;
+ onQR: (e: Entity) => void;
+ onCreate: () => void;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ templates,
+ onCreate,
+ onDelete,
+ onSelect,
+ onQR,
+ onNewOrder,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <i18n.Translate>Templates</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new templates`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {templates.length > 0 ? (
+ <Table
+ instances={templates}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ onNewOrder={onNewOrder}
+ onQR={onQR}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: Entity[];
+ onDelete: (e: Entity) => void;
+ onNewOrder: (e: Entity) => void;
+ onQR: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+function Table({
+ instances,
+ onLoadMoreAfter,
+ onDelete,
+ onNewOrder,
+ onQR,
+ onSelect,
+ onLoadMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ {onLoadMoreBefore && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more templates before the first one`}
+ onClick={onLoadMoreBefore}
+ >
+ <i18n.Translate>load first page</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>ID</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Description</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.template_id}>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.template_id}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.template_description}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected templates from the database`}
+ onClick={() => onDelete(i)}
+ >
+ Delete
+ </button>
+ <button
+ class="button is-info is-small has-tooltip-left"
+ data-tooltip={i18n.str`use template to create new order`}
+ onClick={() => onNewOrder(i)}
+ >
+ Use template
+ </button>
+ <button
+ class="button is-info is-small has-tooltip-left"
+ data-tooltip={i18n.str`create qr code for the template`}
+ onClick={() => onQR(i)}
+ >
+ QR
+ </button>
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {onLoadMoreAfter && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more templates after the last one`}
+ onClick={onLoadMoreAfter}
+ >
+ <i18n.Translate>load next page</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-magnify mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no templates yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx
new file mode 100644
index 000000000..9e59609c7
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx
@@ -0,0 +1,152 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { JumpToElementById } from "../../../../components/form/JumpToElementById.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { ConfirmModal } from "../../../../components/modal/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import {
+ useInstanceTemplates
+} from "../../../../hooks/templates.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { ListPage } from "./ListPage.js";
+
+interface Props {
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+ onNewOrder: (id: string) => void;
+ onQR: (id: string) => void;
+}
+
+export default function ListTemplates({
+ onCreate,
+ onQR,
+ onSelect,
+ onNewOrder,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { lib } = useSessionContext();
+ const result = useInstanceTemplates();
+ const [deleting, setDeleting] =
+ useState<TalerMerchantApi.TemplateEntry | null>(null);
+ const { state } = useSessionContext();
+
+ if (!result) return <Loading />
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />
+ }
+ if (result.type === "fail") {
+ switch(result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result)
+ }
+ }
+ }
+
+ return (
+ <section class="section is-main-section">
+ <NotificationCard notification={notif} />
+
+ <JumpToElementById
+ testIfExist={async (id) => {
+ const resp = await lib.instance.getTemplateDetails(state.token, id)
+ return resp.type === "ok"
+ }}
+ onSelect={onSelect}
+ description={i18n.str`jump to template with the given template ID`}
+ placeholder={i18n.str`template id`}
+ />
+
+ <ListPage
+ templates={result.body}
+ onLoadMoreBefore={
+ result.isFirstPage ? undefined: result.loadFirst
+ }
+ onLoadMoreAfter={result.isLastPage ? undefined : result.loadNext}
+ onCreate={onCreate}
+ onSelect={(e) => {
+ onSelect(e.template_id);
+ }}
+ onNewOrder={(e) => {
+ onNewOrder(e.template_id);
+ }}
+ onQR={(e) => {
+ onQR(e.template_id);
+ }}
+ onDelete={(e: TalerMerchantApi.TemplateEntry) => {
+ setDeleting(e)
+ }
+ }
+ />
+
+ {deleting && (
+ <ConfirmModal
+ label={`Delete template`}
+ description={`Delete the template "${deleting.template_description}"`}
+ danger
+ active
+ onCancel={() => setDeleting(null)}
+ onConfirm={async (): Promise<void> => {
+ try {
+ await lib.instance.deleteTemplate(state.token, deleting.template_id);
+ setNotif({
+ message: i18n.str`Template "${deleting.template_description}" (ID: ${deleting.template_id}) has been deleted`,
+ type: "SUCCESS",
+ });
+ } catch (error) {
+ setNotif({
+ message: i18n.str`Failed to delete template`,
+ type: "ERROR",
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
+ setDeleting(null);
+ }}
+ >
+ <p>
+ If you delete the template <b>&quot;{deleting.template_description}&quot;</b> (ID:{" "}
+ <b>{deleting.template_id}</b>) you may loose information
+ </p>
+ <p class="warning">
+ Deleting an template <b>cannot be undone</b>.
+ </p>
+ </ConfirmModal>
+ )}
+ </section>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/Qr.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/Qr.stories.tsx
new file mode 100644
index 000000000..c0059c7bc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/Qr.stories.tsx
@@ -0,0 +1,27 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { QrPage as TestedComponent } from "./QrPage.js";
+
+export default {
+ title: "Pages/Templates/QR",
+ component: TestedComponent,
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx
new file mode 100644
index 000000000..7322ca169
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx
@@ -0,0 +1,154 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ TalerMerchantApi,
+ stringifyPayTemplateUri
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { QR } from "../../../../components/exception/QR.js";
+import { useSessionContext } from "../../../../context/session.js";
+
+// type Entity = TalerMerchantApi.UsingTemplateDetails;
+
+interface Props {
+ contract: TalerMerchantApi.TemplateContractDetails;
+ id: string;
+ onBack?: () => void;
+}
+
+export function QrPage({ id: templateId, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { state } = useSessionContext();
+
+ // const [state, setState] = useState<Partial<Entity>>({
+ // amount: contract.amount,
+ // summary: contract.summary,
+ // });
+
+ // const errors: FormErrors<Entity> = {};
+
+ // const fixedAmount = !!contract.amount;
+ // const fixedSummary = !!contract.summary;
+
+ // const templateParams: Record<string, string> = {};
+ // if (!fixedAmount) {
+ // if (state.amount) {
+ // templateParams.amount = state.amount;
+ // } else {
+ // templateParams.amount = config.currency;
+ // }
+ // }
+
+ // if (!fixedSummary) {
+ // templateParams.summary = state.summary ?? "";
+ // }
+
+ const merchantBaseUrl = state.backendUrl.href;
+
+ const payTemplateUri = stringifyPayTemplateUri({
+ merchantBaseUrl,
+ templateId,
+ templateParams: {},
+ });
+
+ return (
+ <div>
+ <section id="printThis">
+ <QR text={payTemplateUri} />
+ <pre style={{ textAlign: "center" }}>
+ <a href={payTemplateUri}>{payTemplateUri}</a>
+ </pre>
+ </section>
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ {/* <p class="is-size-5 mt-5 mb-5">
+ <i18n.Translate>
+ Here you can specify a default value for fields that are not
+ fixed. Default values can be edited by the customer before the
+ payment.
+ </i18n.Translate>
+ </p> */}
+
+ <p></p>
+ {/* <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputCurrency<Entity>
+ name="amount"
+ label={i18n.str`Amount`}
+ readonly
+ tooltip={i18n.str`Amount of the order`}
+ />
+ <Input<Entity>
+ name="summary"
+ inputType="multiline"
+ readonly
+ label={i18n.str`Summary`}
+ tooltip={i18n.str`Title of the order to be shown to the customer`}
+ />
+ </FormProvider> */}
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <button
+ class="button is-info"
+ onClick={() => saveAsPDF(templateId)}
+ >
+ <i18n.Translate>Print</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
+
+function saveAsPDF(name: string): void {
+ const printWindow = window.open("", "", "height=400,width=800");
+ if (!printWindow) return;
+ const divContents = document.getElementById("printThis");
+ if (!divContents) return;
+ printWindow.document.write(
+ `<html><head><title>Order template for ${name}</title><style>`,
+ );
+ printWindow.document.write("</style></head><body>&nbsp;</body></html>");
+ printWindow.document.close();
+ printWindow.document.body.appendChild(divContents.cloneNode(true));
+ printWindow.addEventListener("load", () => {
+ printWindow.print();
+ // printWindow.close();
+ });
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx
new file mode 100644
index 000000000..ed809c7b3
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx
@@ -0,0 +1,66 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import { VNode, h } from "preact";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import {
+ useTemplateDetails
+} from "../../../../hooks/templates.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { QrPage } from "./QrPage.js";
+import { LoginPage } from "../../../login/index.js";
+
+export type Entity = TalerMerchantApi.TransferInformation;
+interface Props {
+ onBack?: () => void;
+ tid: string;
+}
+
+export default function TemplateQrPage({
+ tid,
+ onBack,
+}: Props): VNode {
+ const result = useTemplateDetails(tid);
+ if (!result) return <Loading />
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />
+ }
+ if (result.type === "fail") {
+ switch(result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result)
+ }
+ }
+ }
+
+
+ return (
+ <QrPage contract={result.body.template_contract} id={tid} onBack={onBack} />
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx
new file mode 100644
index 000000000..303d17b72
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/Templates/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx
new file mode 100644
index 000000000..eedb77f28
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx
@@ -0,0 +1,305 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AmountString,
+ Amounts,
+ Duration,
+ TalerError,
+ TalerMerchantApi,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputDuration } from "../../../../components/form/InputDuration.js";
+import { InputNumber } from "../../../../components/form/InputNumber.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { InputToggle } from "../../../../components/form/InputToggle.js";
+import { TextField } from "../../../../components/form/TextField.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useInstanceOtpDevices } from "../../../../hooks/otp.js";
+
+type Entity = {
+ description?: string;
+ otpId?: string | null;
+ summary?: string;
+ amount?: AmountString;
+ minimum_age?: number;
+ pay_duration?: Duration;
+ summary_editable?: boolean;
+ amount_editable?: boolean;
+ currency_editable?: boolean;
+};
+
+interface Props {
+ onUpdate: (d: TalerMerchantApi.TemplatePatchDetails) => Promise<void>;
+ onBack?: () => void;
+ template: TalerMerchantApi.TemplateDetails & WithId;
+}
+
+export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { config } = useSessionContext();
+ const {state:session} = useSessionContext();
+
+ const [state, setState] = useState<Partial<Entity>>({
+ description: template.template_description,
+ minimum_age: template.template_contract.minimum_age,
+ otpId: template.otp_id,
+ pay_duration: template.template_contract.pay_duration
+ ? Duration.fromTalerProtocolDuration(
+ template.template_contract.pay_duration,
+ )
+ : undefined,
+ summary:
+ template.editable_defaults?.summary ?? template.template_contract.summary,
+ amount:
+ template.editable_defaults?.amount ??
+ (template.template_contract.amount as AmountString | undefined),
+ currency_editable: !!template.editable_defaults?.currency,
+ summary_editable: !!template.editable_defaults?.summary,
+ amount_editable: !!template.editable_defaults?.amount,
+ });
+
+ function updateState(up: (s: Partial<Entity>) => Partial<Entity>) {
+ setState((old) => {
+ const newState = up(old);
+ if (!newState.amount_editable) {
+ newState.currency_editable = false;
+ }
+ return newState;
+ });
+ }
+
+ const devices = useInstanceOtpDevices();
+ const deviceList =
+ !devices || devices instanceof TalerError || devices.type === "fail"
+ ? []
+ : devices.body.otp_devices;
+ const deviceMap = deviceList.reduce(
+ (prev, cur) => {
+ prev[cur.otp_device_id] = cur.device_description as TranslatedString;
+ return prev;
+ },
+ {} as Record<string, TranslatedString>,
+ );
+
+ const parsedPrice = !state.amount ? undefined : Amounts.parse(state.amount);
+
+ const errors: FormErrors<Entity> = {
+ description: !state.description ? i18n.str`should not be empty` : undefined,
+ amount: !state.amount
+ ? undefined
+ : !parsedPrice
+ ? i18n.str`not valid`
+ : Amounts.isZero(parsedPrice)
+ ? i18n.str`must be greater than 0`
+ : undefined,
+ minimum_age:
+ state.minimum_age && state.minimum_age < 0
+ ? i18n.str`should be greater that 0`
+ : undefined,
+ pay_duration: !state.pay_duration
+ ? i18n.str`can't be empty`
+ : state.pay_duration.d_ms === "forever"
+ ? undefined
+ : state.pay_duration.d_ms < 1000 // less than one second
+ ? i18n.str`to short`
+ : undefined,
+ };
+
+ const cList = Object.values(config.currencies).map((d) => d.name);
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as Record<string, unknown>)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onUpdate({
+ template_description: state.description!,
+ template_contract: {
+ minimum_age: state.minimum_age!,
+ pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!),
+ amount: state.amount_editable ? undefined : state.amount,
+ summary: state.summary_editable ? undefined : state.summary,
+ currency:
+ cList.length > 1 && state.currency_editable
+ ? undefined
+ : config.currency,
+ },
+ editable_defaults: {
+ amount: !state.amount_editable ? undefined : state.amount,
+ summary: !state.summary_editable ? undefined : state.summary,
+ currency:
+ cList.length === 1 || !state.currency_editable
+ ? undefined
+ : config.currency,
+ },
+ otp_id: state.otpId!,
+ });
+ };
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ {new URL(`templates/${template.id}`, session.backendUrl.href).href}
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={updateState}
+ errors={errors}
+ >
+ <Input<Entity>
+ name="description"
+ label={i18n.str`Description`}
+ help=""
+ tooltip={i18n.str`Describe what this template stands for`}
+ />
+ <Input<Entity>
+ name="summary"
+ inputType="multiline"
+ label={i18n.str`Summary`}
+ tooltip={i18n.str`If specified, this template will create order with the same summary`}
+ />
+ <InputToggle<Entity>
+ name="summary_editable"
+ label={i18n.str`Summary is editable`}
+ tooltip={i18n.str`Allow the user to change the summary.`}
+ />
+ <InputCurrency<Entity>
+ name="amount"
+ label={i18n.str`Amount`}
+ tooltip={i18n.str`If specified, this template will create order with the same price`}
+ />
+ <InputToggle<Entity>
+ name="amount_editable"
+ label={i18n.str`Amount is editable`}
+ tooltip={i18n.str`Allow the user to select the amount to pay.`}
+ />
+ {cList.length > 1 && (
+ <Fragment>
+ <InputToggle<Entity>
+ name="currency_editable"
+ readonly={!state.amount_editable}
+ label={i18n.str`Currency is editable`}
+ tooltip={i18n.str`Allow the user to change currency.`}
+ />
+ <TextField name="sc" label={i18n.str`Supported currencies`}>
+ <i18n.Translate>
+ supported currencies: {cList.join(", ")}
+ </i18n.Translate>
+ </TextField>
+ </Fragment>
+ )}
+ <InputNumber<Entity>
+ name="minimum_age"
+ label={i18n.str`Minimum age`}
+ help=""
+ tooltip={i18n.str`Is this contract restricted to some age?`}
+ />
+ <InputDuration<Entity>
+ name="pay_duration"
+ label={i18n.str`Payment timeout`}
+ help=""
+ tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`}
+ />
+ {!deviceList.length ? (
+ <TextField
+ name="otpId"
+ label={i18n.str`OTP device`}
+ tooltip={i18n.str`Use to verify transaction while offline.`}
+ >
+ <i18n.Translate>No OTP device.</i18n.Translate>&nbsp;
+ <a href="/otp-devices/new">
+ <i18n.Translate>Add one first</i18n.Translate>
+ </a>
+ </TextField>
+ ) : (
+ <InputSelector<Entity>
+ name="otpId"
+ label={i18n.str`OTP device`}
+ values={[
+ undefined,
+ ...deviceList.map((e) => e.otp_device_id),
+ ]}
+ toStr={(v?: string) => {
+ if (!v) {
+ return i18n.str`No device`;
+ }
+ return deviceMap[v];
+ }}
+ tooltip={i18n.str`Use to verify transaction in offline mode.`}
+ />
+ )}
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ </div>
+ </section>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx
new file mode 100644
index 000000000..6185bd2a9
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx
@@ -0,0 +1,97 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import {
+ useTemplateDetails,
+} from "../../../../hooks/templates.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { UpdatePage } from "./UpdatePage.js";
+
+export type Entity = TalerMerchantApi.TemplatePatchDetails & WithId;
+
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ tid: string;
+}
+export default function UpdateTemplate({
+ tid,
+ onConfirm,
+ onBack,
+}: Props): VNode {
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+ const result = useTemplateDetails(tid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (!result) return <Loading />
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />
+ }
+ if (result.type === "fail") {
+ switch(result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result)
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ template={{...result.body, id: tid}}
+ onBack={onBack}
+ onUpdate={(data) => {
+ return lib.instance.updateTemplate(state.token, tid, data)
+ .then(onConfirm)
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not update template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx
new file mode 100644
index 000000000..d91888b97
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/Use.stories.tsx
@@ -0,0 +1,27 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { UsePage as TestedComponent } from "./UsePage.js";
+
+export default {
+ title: "Pages/Templates/Create",
+ component: TestedComponent,
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx
new file mode 100644
index 000000000..360c9d373
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx
@@ -0,0 +1,144 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+
+type Entity = TalerMerchantApi.TemplateContractDetails;
+
+interface Props {
+ id: string;
+ template: TalerMerchantApi.TemplateDetails;
+ onCreateOrder: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+export function UsePage({ id, template, onCreateOrder, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>({
+ currency: template.editable_defaults?.currency ?? template.template_contract.currency,
+ amount: template.editable_defaults?.amount ?? template.template_contract.amount,
+ summary: template.editable_defaults?.summary ?? template.template_contract.summary,
+ });
+
+ const errors: FormErrors<Entity> = {
+ amount:
+ !state.amount
+ ? i18n.str`Amount is required`
+ : undefined,
+ summary:
+ !state.summary
+ ? i18n.str`Order summary is required`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ if (template.template_contract.amount) {
+ delete state.amount;
+ }
+ if (template.template_contract.summary) {
+ delete state.summary;
+ }
+ return onCreateOrder(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ <i18n.Translate>New order for template</i18n.Translate>:{" "}
+ <b>{id}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ </section>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputCurrency<Entity>
+ name="amount"
+ label={i18n.str`Amount`}
+ readonly={!!template.template_contract.amount}
+ tooltip={i18n.str`Amount of the order`}
+ />
+ <Input<Entity>
+ name="summary"
+ inputType="multiline"
+ label={i18n.str`Order summary`}
+ readonly={!!template.template_contract.summary}
+ tooltip={i18n.str`Title of the order to be shown to the customer`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx
new file mode 100644
index 000000000..00cb2b827
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx
@@ -0,0 +1,108 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import {
+ useTemplateDetails
+} from "../../../../hooks/templates.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { UsePage } from "./UsePage.js";
+import { useSessionContext } from "../../../../context/session.js";
+
+export type Entity = TalerMerchantApi.TransferInformation;
+interface Props {
+ onBack?: () => void;
+ onOrderCreated: (id: string) => void;
+ tid: string;
+}
+
+export default function TemplateUsePage({
+ tid,
+ onOrderCreated,
+ onBack,
+}: Props): VNode {
+ const { lib } = useSessionContext();
+ const result = useTemplateDetails(tid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ if (!result) return <Loading />
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />
+ }
+ if (result.type === "fail") {
+ switch(result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result)
+ }
+ }
+ }
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <UsePage
+ template={result.body}
+ id={tid}
+ onBack={onBack}
+ onCreateOrder={(
+ request: TalerMerchantApi.UsingTemplateDetails,
+ ) => {
+
+ return lib.instance.useTemplateCreateOrder(tid, request)
+ .then((res) => {
+ if (res.type === "ok") {
+ onOrderCreated(res.body.order_id)
+ } else {
+ setNotif({
+ message: i18n.str`could not create order from template`,
+ type: "ERROR",
+ });
+ }
+ })
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not create order from template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx
new file mode 100644
index 000000000..f75ee89b8
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx
@@ -0,0 +1,188 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/exception/AsyncButton.js";
+import { FormProvider } from "../../../components/form/FormProvider.js";
+import { Input } from "../../../components/form/Input.js";
+import { NotificationCard } from "../../../components/menu/index.js";
+import { useSessionContext } from "../../../context/session.js";
+import { AccessToken, createAccessToken } from "@gnu-taler/taler-util";
+
+interface Props {
+ hasToken: boolean | undefined;
+ onClearToken: (c: AccessToken | undefined) => void;
+ onNewToken: (c: AccessToken | undefined, s: AccessToken) => void;
+ onBack?: () => void;
+}
+
+export function DetailPage({
+ hasToken,
+ onBack,
+ onNewToken,
+ onClearToken,
+}: Props): VNode {
+ type State = { old_token: string; new_token: string; repeat_token: string };
+ const [form, setValue] = useState<Partial<State>>({
+ old_token: "",
+ new_token: "",
+ repeat_token: "",
+ });
+ const { i18n } = useTranslationContext();
+
+ const errors = {
+ old_token:
+ hasToken && !form.old_token
+ ? i18n.str`you need your access token to perform the operation`
+ : undefined,
+ new_token: !form.new_token
+ ? i18n.str`cannot be empty`
+ : form.new_token === form.old_token
+ ? i18n.str`cannot be the same as the old token`
+ : undefined,
+ repeat_token:
+ form.new_token !== form.repeat_token
+ ? i18n.str`is not the same`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as Record<string, unknown>)[k] !== undefined,
+ );
+
+ const { state } = useSessionContext();
+
+ const text = i18n.str`You are updating the access token from instance with id "${state.instance}"`;
+
+ async function submitForm() {
+ if (hasErrors) return;
+ const oldToken =
+ form.old_token !== undefined && hasToken
+ ? createAccessToken(form.old_token)
+ : undefined;
+ const newToken = createAccessToken(form.new_token!);
+ onNewToken(oldToken, newToken);
+ }
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">{text}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ {!hasToken && (
+ <NotificationCard
+ notification={{
+ message: i18n.str`This instance doesn't have authentication token.`,
+ description: i18n.str`You can leave it empty if there is another layer of security.`,
+ type: "WARN",
+ }}
+ />
+ )}
+
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider errors={errors} object={form} valueHandler={setValue}>
+ <Fragment>
+ {hasToken && (
+ <Fragment>
+ <Input<State>
+ name="old_token"
+ label={i18n.str`Current access token`}
+ tooltip={i18n.str`access token currently in use`}
+ inputType="password"
+ />
+ <p>
+ <i18n.Translate>
+ Clearing the access token will mean public access to the
+ instance.
+ </i18n.Translate>
+ </p>
+ <div class="buttons is-right mt-5">
+ <button
+ class="button"
+ onClick={() => {
+ if (hasToken) {
+ onClearToken(form.old_token ? createAccessToken(form.old_token) : undefined);
+ } else {
+ onClearToken(undefined);
+ }
+ }}
+ >
+ <i18n.Translate>Clear token</i18n.Translate>
+ </button>
+ </div>
+ </Fragment>
+ )}
+
+ <Input<State>
+ name="new_token"
+ label={i18n.str`New access token`}
+ tooltip={i18n.str`next access token to be used`}
+ inputType="password"
+ />
+ <Input<State>
+ name="repeat_token"
+ label={i18n.str`Repeat access token`}
+ tooltip={i18n.str`confirm the same access token`}
+ inputType="password"
+ />
+ </Fragment>
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <a class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ )}
+ <AsyncButton
+ type="submit"
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm change</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </FormProvider>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx
new file mode 100644
index 000000000..c23e5be17
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx
@@ -0,0 +1,152 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../components/exception/loading.js";
+import { NotificationCard } from "../../../components/menu/index.js";
+import { useSessionContext } from "../../../context/session.js";
+import { useInstanceDetails } from "../../../hooks/instance.js";
+import { Notification } from "../../../utils/types.js";
+import { LoginPage } from "../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../notfound/index.js";
+import { DetailPage } from "./DetailPage.js";
+
+interface Props {
+ onChange: () => void;
+ onCancel: () => void;
+}
+
+export default function Token({ onChange, onCancel }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const { lib } = useSessionContext();
+ const { logIn } = useSessionContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const result = useInstanceDetails();
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />;
+ }
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ const hasToken = result.body.auth.method === "token";
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <DetailPage
+ onBack={onCancel}
+ hasToken={hasToken}
+ onClearToken={async (currentToken): Promise<void> => {
+ try {
+ const resp = await lib.instance.updateCurrentInstanceAuthentication(
+ currentToken,
+ {
+ method: "external",
+ },
+ );
+ if (resp.type === "ok") {
+ onChange();
+ } else {
+ return setNotif({
+ message: i18n.str`Failed to clear token`,
+ type: "ERROR",
+ description: resp.detail.hint,
+ });
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ return setNotif({
+ message: i18n.str`Failed to clear token`,
+ type: "ERROR",
+ description: error.message,
+ });
+ }
+ }
+ }}
+ onNewToken={async (currentToken, newToken): Promise<void> => {
+ try {
+ {
+ const resp =
+ await lib.instance.updateCurrentInstanceAuthentication(
+ currentToken,
+ {
+ token: newToken,
+ method: "token",
+ },
+ );
+ if (resp.type === "fail") {
+ return setNotif({
+ message: i18n.str`Failed to set new token`,
+ type: "ERROR",
+ description: resp.detail.hint,
+ });
+ }
+ }
+ const resp = await lib.authenticate.createAccessTokenBearer(
+ newToken,
+ {
+ scope: "write",
+ duration: {
+ d_us: "forever",
+ },
+ refreshable: true,
+ },
+ );
+ if (resp.type === "ok") {
+ logIn(resp.body.token);
+ return onChange();
+ } else {
+ return setNotif({
+ message: i18n.str`Failed to set new token`,
+ type: "ERROR",
+ });
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ return setNotif({
+ message: i18n.str`Failed to set new token`,
+ type: "ERROR",
+ description: error.message,
+ });
+ }
+ }
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/stories.tsx
new file mode 100644
index 000000000..581828657
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/token/stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { DetailPage as TestedComponent } from "./DetailPage.js";
+
+export default {
+ title: "Pages/Token",
+ component: TestedComponent,
+};
+
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx
new file mode 100644
index 000000000..ca38defc3
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/Create.stories.tsx
@@ -0,0 +1,45 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Transfer/Create",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ accounts: ["payto://x-taler-bank/account1", "payto://x-taler-bank/account2"],
+});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx
new file mode 100644
index 000000000..91aabe58e
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/CreatePage.tsx
@@ -0,0 +1,144 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountString, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputCurrency } from "../../../../components/form/InputCurrency.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import {
+ CROCKFORD_BASE32_REGEX,
+ URL_REGEX,
+} from "../../../../utils/constants.js";
+
+type Entity = TalerMerchantApi.TransferInformation;
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+ accounts: string[];
+}
+
+export function CreatePage({ accounts, onCreate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>({
+ wtid: "",
+ // payto_uri: ,
+ // exchange_url: 'http://exchange.taler:8081/',
+ credit_amount: `` as AmountString,
+ });
+
+ const errors: FormErrors<Entity> = {
+ wtid: !state.wtid
+ ? i18n.str`cannot be empty`
+ : !CROCKFORD_BASE32_REGEX.test(state.wtid)
+ ? i18n.str`check the id, does not look valid`
+ : state.wtid.length !== 52
+ ? i18n.str`should have 52 characters, current ${state.wtid.length}`
+ : undefined,
+ payto_uri: !state.payto_uri ? i18n.str`cannot be empty` : undefined,
+ credit_amount: !state.credit_amount ? i18n.str`cannot be empty` : undefined,
+ exchange_url: !state.exchange_url
+ ? i18n.str`cannot be empty`
+ : !URL_REGEX.test(state.exchange_url)
+ ? i18n.str`URL doesn't have the right format`
+ : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onCreate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <InputSelector
+ name="payto_uri"
+ label={i18n.str`Credited bank account`}
+ values={accounts}
+ placeholder={i18n.str`Select one account`}
+ tooltip={i18n.str`Bank account of the merchant where the payment was received`}
+ />
+ <Input<Entity>
+ name="wtid"
+ label={i18n.str`Wire transfer ID`}
+ help=""
+ tooltip={i18n.str`unique identifier of the wire transfer used by the exchange, must be 52 characters long`}
+ />
+ <Input<Entity>
+ name="exchange_url"
+ label={i18n.str`Exchange URL`}
+ tooltip={i18n.str`Base URL of the exchange that made the transfer, should have been in the wire transfer subject`}
+ help="http://exchange.taler:8081/"
+ />
+ <InputCurrency<Entity>
+ name="credit_amount"
+ label={i18n.str`Amount credited`}
+ tooltip={i18n.str`Actual amount that was wired to the merchant's bank account`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx
new file mode 100644
index 000000000..428476337
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx
@@ -0,0 +1,72 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerError, TalerMerchantApi } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useInstanceBankAccounts } from "../../../../hooks/bank.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+
+export type Entity = TalerMerchantApi.TransferInformation;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+
+export default function CreateTransfer({ onConfirm, onBack }: Props): VNode {
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const instance = useInstanceBankAccounts();
+ const accounts =
+ !instance || instance instanceof TalerError || instance.type === "fail"
+ ? []
+ : instance.body.accounts.map((a) => a.payto_uri);
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ accounts={accounts}
+ onCreate={(request: TalerMerchantApi.TransferInformation) => {
+ return lib.instance
+ .informWireTransfer(state.token, request)
+ .then(() => onConfirm())
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not inform transfer`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx
new file mode 100644
index 000000000..def03fe27
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/List.stories.tsx
@@ -0,0 +1,94 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountString, PaytoString } from "@gnu-taler/taler-util";
+import { FunctionalComponent, h } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/Transfer/List",
+ component: TestedComponent,
+ argTypes: {
+ onCreate: { action: "onCreate" },
+ onDelete: { action: "onDelete" },
+ onLoadMoreBefore: { action: "onLoadMoreBefore" },
+ onLoadMoreAfter: { action: "onLoadMoreAfter" },
+ onShowAll: { action: "onShowAll" },
+ onShowVerified: { action: "onShowVerified" },
+ onShowUnverified: { action: "onShowUnverified" },
+ onChangePayTo: { action: "onChangePayTo" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ transfers: [
+ {
+ exchange_url: "http://exchange.url/",
+ credit_amount: "TESTKUDOS:10" as AmountString,
+ payto_uri: "payto//x-taler-bank/bank:8080/account" as PaytoString,
+ transfer_serial_id: 123123123,
+ wtid: "!@KJELQKWEJ!L@K#!J@",
+ confirmed: true,
+ execution_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ verified: false,
+ },
+ {
+ exchange_url: "http://exchange.url/",
+ credit_amount: "TESTKUDOS:10" as AmountString,
+ payto_uri: "payto//x-taler-bank/bank:8080/account" as PaytoString,
+ transfer_serial_id: 123123123,
+ wtid: "!@KJELQKWEJ!L@K#!J@",
+ confirmed: true,
+ execution_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ verified: false,
+ },
+ {
+ exchange_url: "http://exchange.url/",
+ credit_amount: "TESTKUDOS:10" as AmountString,
+ payto_uri: "payto//x-taler-bank/bank:8080/account" as PaytoString,
+ transfer_serial_id: 123123123,
+ wtid: "!@KJELQKWEJ!L@K#!J@",
+ confirmed: true,
+ execution_time: {
+ t_s: new Date().getTime() / 1000,
+ },
+ verified: false,
+ },
+ ],
+ accounts: ["payto://x-taler-bank/bank/some_account"],
+});
+export const Empty = createExample(TestedComponent, {
+ transfers: [],
+ accounts: [],
+});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx
new file mode 100644
index 000000000..22ad0b8d8
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/ListPage.tsx
@@ -0,0 +1,137 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { FormProvider } from "../../../../components/form/FormProvider.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { CardTable } from "./Table.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+export interface Props {
+ transfers: TalerMerchantApi.TransferDetails[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+ onShowAll: () => void;
+ onShowVerified: () => void;
+ onShowUnverified: () => void;
+ isVerifiedTransfers?: boolean;
+ isNonVerifiedTransfers?: boolean;
+ isAllTransfers?: boolean;
+ accounts: string[];
+ onChangePayTo: (p?: string) => void;
+ payTo?: string;
+ onCreate: () => void;
+ onDelete: () => void;
+}
+
+export function ListPage({
+ payTo,
+ onChangePayTo,
+ transfers,
+ onCreate,
+ onDelete,
+ accounts,
+ onLoadMoreBefore,
+ onLoadMoreAfter,
+ isAllTransfers,
+ isNonVerifiedTransfers,
+ isVerifiedTransfers,
+ onShowAll,
+ onShowUnverified,
+ onShowVerified,
+}: Props): VNode {
+ const form = { payto_uri: payTo };
+
+ const { i18n } = useTranslationContext();
+ return (
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-10">
+ <FormProvider
+ object={form}
+ valueHandler={(updater) => onChangePayTo(updater(form).payto_uri)}
+ >
+ <InputSelector
+ name="payto_uri"
+ label={i18n.str`Account URI`}
+ values={accounts}
+ fromStr={(d) => {
+ const idx = accounts.indexOf(d)
+ if (idx === -1) return undefined;
+ return d
+ }}
+ placeholder={i18n.str`Select one account`}
+ tooltip={i18n.str`filter by account address`}
+ />
+ </FormProvider>
+ </div>
+ <div class="column" />
+ </div>
+ <div class="tabs">
+ <ul>
+ <li class={isAllTransfers ? "is-active" : ""}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`remove all filters`}
+ >
+ <a onClick={onShowAll}>
+ <i18n.Translate>All</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isVerifiedTransfers ? "is-active" : ""}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`only show wire transfers confirmed by the merchant`}
+ >
+ <a onClick={onShowVerified}>
+ <i18n.Translate>Verified</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={isNonVerifiedTransfers ? "is-active" : ""}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`only show wire transfers claimed by the exchange`}
+ >
+ <a onClick={onShowUnverified}>
+ <i18n.Translate>Unverified</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ </ul>
+ </div>
+ <CardTable
+ transfers={transfers.map((o) => ({
+ ...o,
+ id: String(o.transfer_serial_id),
+ }))}
+ accounts={accounts}
+ onCreate={onCreate}
+ onDelete={onDelete}
+ onLoadMoreBefore={onLoadMoreBefore}
+ onLoadMoreAfter={onLoadMoreAfter}
+ />
+ </section>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx
new file mode 100644
index 000000000..b9235c669
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx
@@ -0,0 +1,214 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+import { datetimeFormatForSettings, usePreference } from "../../../../hooks/preference.js";
+
+type Entity = TalerMerchantApi.TransferDetails & WithId;
+
+interface Props {
+ transfers: Entity[];
+ onDelete: (id: Entity) => void;
+ onCreate: () => void;
+ accounts: string[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ transfers,
+ onCreate,
+ onDelete,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-arrow-left-right" />
+ </span>
+ <i18n.Translate>Transfers</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new transfer`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {transfers.length > 0 ? (
+ <Table
+ instances={transfers}
+ onDelete={onDelete}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: Entity[];
+ onDelete: (id: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+function Table({
+ instances,
+ onLoadMoreAfter,
+ onDelete,
+ onLoadMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreference();
+ return (
+ <div class="table-container">
+ {onLoadMoreBefore && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more transfers before the first one`}
+ onClick={onLoadMoreBefore}
+ >
+ <i18n.Translate>load first page</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>ID</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Credit</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Address</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Exchange URL</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Confirmed</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Verified</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Executed at</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.id}>
+ <td>{i.id}</td>
+ <td>{i.credit_amount}</td>
+ <td>{i.payto_uri}</td>
+ <td>{i.exchange_url}</td>
+ <td>{i.confirmed ? i18n.str`yes` : i18n.str`no`}</td>
+ <td>{i.verified ? i18n.str`yes` : i18n.str`no`}</td>
+ <td>
+ {i.execution_time
+ ? i.execution_time.t_s == "never"
+ ? i18n.str`never`
+ : format(
+ i.execution_time.t_s * 1000,
+ datetimeFormatForSettings(settings),
+ )
+ : i18n.str`unknown`}
+ </td>
+ <td>
+ {i.verified === undefined ? (
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected transfer from the database`}
+ onClick={() => onDelete(i)}
+ >
+ Delete
+ </button>
+ ) : undefined}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {onLoadMoreAfter && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more transfers after the last one`}
+ onClick={onLoadMoreAfter}
+ >
+ <i18n.Translate>load next page</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-magnify mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no transfer yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx
new file mode 100644
index 000000000..8b4d1f3cb
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx
@@ -0,0 +1,112 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, assertUnreachable } from "@gnu-taler/taler-util";
+import { VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { useInstanceBankAccounts } from "../../../../hooks/bank.js";
+import { useInstanceTransfers } from "../../../../hooks/transfer.js";
+import { LoginPage } from "../../../login/index.js";
+import { ListPage } from "./ListPage.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+
+interface Props {
+ onCreate: () => void;
+}
+interface Form {
+ verified?: boolean;
+ payto_uri?: string;
+}
+
+export default function ListTransfer({
+ onCreate,
+}: Props): VNode {
+ const setFilter = (s?: boolean) => setForm({ ...form, verified: s });
+
+ const [position, setPosition] = useState<string | undefined>(undefined);
+
+ const instance = useInstanceBankAccounts();
+ const accounts = !instance || (instance instanceof TalerError) || instance.type === "fail"
+ ? []
+ : instance.body.accounts.map((a) => a.payto_uri);
+ const [form, setForm] = useState<Form>({ payto_uri: "" });
+
+ const shoulUseDefaultAccount = accounts.length === 1
+ useEffect(() => {
+ if (shoulUseDefaultAccount) {
+ setForm({...form, payto_uri: accounts[0]})
+ }
+ }, [shoulUseDefaultAccount])
+
+ const isVerifiedTransfers = form.verified === true;
+ const isNonVerifiedTransfers = form.verified === false;
+ const isAllTransfers = form.verified === undefined;
+
+ const result = useInstanceTransfers(
+ {
+ position,
+ payto_uri: form.payto_uri === "" ? undefined : form.payto_uri,
+ verified: form.verified,
+ },
+ (id) => setPosition(id),
+ );
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ return (
+ <ListPage
+ accounts={accounts}
+ transfers={result.body}
+ onLoadMoreBefore={result.isFirstPage ? undefined: result.loadFirst }
+ onLoadMoreAfter={result.isLastPage ? undefined : result.loadNext}
+ onCreate={onCreate}
+ onDelete={() => {
+ null;
+ }}
+ onShowAll={() => setFilter(undefined)}
+ onShowUnverified={() => setFilter(false)}
+ onShowVerified={() => setFilter(true)}
+ isAllTransfers={isAllTransfers}
+ isVerifiedTransfers={isVerifiedTransfers}
+ isNonVerifiedTransfers={isNonVerifiedTransfers}
+ payTo={form.payto_uri}
+ onChangePayTo={(p) => setForm((v) => ({ ...v, payto_uri: p }))}
+ />
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/update/index.tsx
new file mode 100644
index 000000000..719f99209
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/update/index.tsx
@@ -0,0 +1,26 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+
+export default function UpdateTransfer(): VNode {
+ return <div>order transfer page</div>;
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx
new file mode 100644
index 000000000..5bd12e4e9
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/Instance/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
+
+function createExample<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props>,
+) {
+ const r = (args: any) => <Component {...args} />;
+ r.args = props;
+ return r;
+}
+
+export const Example = createExample(TestedComponent, {
+ selected: {
+ name: "name",
+ auth: { method: "external" },
+ address: {},
+ user_type: "business",
+ use_stefan: true,
+ jurisdiction: {},
+ default_pay_delay: {
+ d_us: 1000 * 1000, //one second
+ },
+ default_wire_transfer_delay: {
+ d_us: 1000 * 1000, //one second
+ },
+ merchant_pub: "ASDWQEKASJDKSADJ",
+ },
+});
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx
new file mode 100644
index 000000000..cde58967f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx
@@ -0,0 +1,175 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Duration, TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../components/form/FormProvider.js";
+import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js";
+import { useSessionContext } from "../../../context/session.js";
+import { undefinedIfEmpty } from "../../../utils/table.js";
+
+export type Entity = Omit<Omit<TalerMerchantApi.InstanceReconfigurationMessage, "default_pay_delay">, "default_wire_transfer_delay"> & {
+ default_pay_delay: Duration,
+ default_wire_transfer_delay: Duration,
+};
+
+//TalerMerchantApi.InstanceAuthConfigurationMessage
+interface Props {
+ onUpdate: (d: TalerMerchantApi.InstanceReconfigurationMessage) => void;
+ selected: TalerMerchantApi.QueryInstancesResponse;
+ isLoading: boolean;
+ onBack: () => void;
+}
+
+function convert(
+ from: TalerMerchantApi.QueryInstancesResponse,
+): Entity {
+ const { default_pay_delay, default_wire_transfer_delay, ...rest } = from;
+
+ const defaults = {
+ use_stefan: false,
+ default_pay_delay: Duration.fromTalerProtocolDuration(default_pay_delay),
+ default_wire_transfer_delay: Duration.fromTalerProtocolDuration(default_wire_transfer_delay),
+ };
+ return { ...defaults, ...rest };
+}
+
+export function UpdatePage({
+ onUpdate,
+ selected,
+ onBack,
+}: Props): VNode {
+ const { state } = useSessionContext();
+
+ const [value, valueHandler] = useState<Partial<Entity>>(convert(selected));
+
+ const { i18n } = useTranslationContext();
+
+ const errors: FormErrors<Entity> = {
+ name: !value.name ? i18n.str`required` : undefined,
+ user_type: !value.user_type
+ ? i18n.str`required`
+ : value.user_type !== "business" && value.user_type !== "individual"
+ ? i18n.str`should be business or individual`
+ : undefined,
+ default_pay_delay: !value.default_pay_delay
+ ? i18n.str`required`
+ : !!value.default_wire_transfer_delay &&
+ value.default_wire_transfer_delay.d_ms !== "forever" &&
+ value.default_pay_delay.d_ms !== "forever" &&
+ value.default_pay_delay.d_ms > value.default_wire_transfer_delay.d_ms ?
+ i18n.str`pay delay can't be greater than wire transfer delay` : undefined,
+ default_wire_transfer_delay: !value.default_wire_transfer_delay
+ ? i18n.str`required`
+ : undefined,
+ address: undefinedIfEmpty({
+ address_lines:
+ value.address?.address_lines && value.address?.address_lines.length > 7
+ ? i18n.str`max 7 lines`
+ : undefined,
+ }),
+ jurisdiction: undefinedIfEmpty({
+ address_lines:
+ value.address?.address_lines && value.address?.address_lines.length > 7
+ ? i18n.str`max 7 lines`
+ : undefined,
+ }),
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submit = async (): Promise<void> => {
+ const { default_pay_delay, default_wire_transfer_delay, ...rest } = value as Required<Entity>;
+ const result: TalerMerchantApi.InstanceReconfigurationMessage = {
+ default_pay_delay: Duration.toTalerProtocolDuration(default_pay_delay),
+ default_wire_transfer_delay: Duration.toTalerProtocolDuration(default_wire_transfer_delay),
+ ...rest,
+ }
+ await onUpdate(result);
+ };
+ // const [active, setActive] = useState(false);
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ <i18n.Translate>Instance id</i18n.Translate>: <b>{state.instance}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <hr />
+
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider<Entity>
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <DefaultInstanceFormFields showId={false} />
+ </FormProvider>
+
+ <div class="buttons is-right mt-4">
+ <button
+ class="button"
+ onClick={onBack}
+ data-tooltip="cancel operation"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+
+ <AsyncButton
+ onClick={submit}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ disabled={hasErrors}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx
new file mode 100644
index 000000000..9da7f7efb
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx
@@ -0,0 +1,117 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { HttpStatusCode, TalerError, TalerMerchantApi, TalerMerchantInstanceHttpClient, TalerMerchantManagementResultByMethod, assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../components/exception/loading.js";
+import { NotificationCard } from "../../../components/menu/index.js";
+import { useSessionContext } from "../../../context/session.js";
+import {
+ useInstanceDetails,
+ useManagedInstanceDetails,
+} from "../../../hooks/instance.js";
+import { Notification } from "../../../utils/types.js";
+import { LoginPage } from "../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../notfound/index.js";
+import { UpdatePage } from "./UpdatePage.js";
+
+export interface Props {
+ onBack: () => void;
+ onConfirm: () => void;
+
+ // onUnauthorized: () => VNode;
+ // onNotFound: () => VNode;
+ // onLoadError: (e: HttpError<TalerErrorDetail>) => VNode;
+ // onUpdateError: (e: HttpError<TalerErrorDetail>) => void;
+}
+
+export default function Update(props: Props): VNode {
+ const { lib } = useSessionContext();
+ const updateInstance = lib.instance.updateCurrentInstance.bind(lib.instance)
+ const result = useInstanceDetails();
+ return CommonUpdate(props, result, updateInstance,);
+}
+
+export function AdminUpdate(props: Props & { instanceId: string }): VNode {
+ const { lib } = useSessionContext();
+ const t = lib.subInstanceApi(props.instanceId).instance;
+ const updateInstance = t.updateCurrentInstance.bind(t)
+ const result = useManagedInstanceDetails(props.instanceId);
+ return CommonUpdate(props, result, updateInstance,);
+}
+
+
+function CommonUpdate(
+ {
+ onBack,
+ onConfirm,
+ }: Props,
+ result: TalerMerchantManagementResultByMethod<"getInstanceDetails"> | TalerError | undefined,
+ updateInstance: typeof TalerMerchantInstanceHttpClient.prototype.updateCurrentInstance,
+): VNode {
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const { state } = useSessionContext();
+
+ if (!result) return <Loading />
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />
+ }
+ if (result.type === "fail") {
+ switch(result.case) {
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ default: {
+ assertUnreachable(result)
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ onBack={onBack}
+ isLoading={false}
+ selected={result.body}
+ onUpdate={(
+ d: TalerMerchantApi.InstanceReconfigurationMessage,
+ ): Promise<void> => {
+ if (state.status !== "loggedIn") {
+ return Promise.resolve();
+ }
+ return updateInstance(state.token, d)
+ .then(onConfirm)
+ .catch((error: Error) =>
+ setNotif({
+ message: i18n.str`Failed to update instance`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ );
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx
new file mode 100644
index 000000000..2e2a58b33
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/Create.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { CreatePage as TestedComponent } from "./CreatePage.js";
+
+export default {
+ title: "Pages/Webhooks/Create",
+ component: TestedComponent,
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx
new file mode 100644
index 000000000..8792aabea
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/CreatePage.tsx
@@ -0,0 +1,179 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+type Entity = TalerMerchantApi.WebhookAddDetails;
+
+interface Props {
+ onCreate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+}
+
+const validMethod = ["GET", "POST", "PUT", "PATCH", "HEAD"];
+
+export function CreatePage({ onCreate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>({});
+
+ const errors: FormErrors<Entity> = {
+ webhook_id: !state.webhook_id ? i18n.str`required` : undefined,
+ event_type: !state.event_type ? i18n.str`required`
+ : state.event_type !== "pay" && state.event_type !== "refund" ? i18n.str`it should be "pay" or "refund"`
+ : undefined,
+ http_method: !state.http_method
+ ? i18n.str`required`
+ : !validMethod.includes(state.http_method)
+ ? i18n.str`should be one of '${validMethod.join(", ")}'`
+ : undefined,
+ url: !state.url ? i18n.str`required` : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onCreate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <Input<Entity>
+ name="webhook_id"
+ label={i18n.str`ID`}
+ tooltip={i18n.str`Webhook ID to use`}
+ />
+ <InputSelector
+ name="event_type"
+ label={i18n.str`Event`}
+ values={[
+ i18n.str`Choose one...`,
+ i18n.str`pay`,
+ i18n.str`refund`,
+ ]}
+ tooltip={i18n.str`The event of the webhook: why the webhook is used`}
+ />
+ <InputSelector
+ name="http_method"
+ label={i18n.str`Method`}
+ values={[
+ i18n.str`Choose one...`,
+ i18n.str`GET`,
+ i18n.str`POST`,
+ i18n.str`PUT`,
+ i18n.str`PATCH`,
+ i18n.str`HEAD`,
+ ]}
+ tooltip={i18n.str`Method used by the webhook`}
+ />
+
+ <Input<Entity>
+ name="url"
+ label={i18n.str`URL`}
+ tooltip={i18n.str`URL of the webhook where the customer will be redirected`}
+ />
+
+ <p>
+ The text below support <a target="_blank" rel="noreferrer" href="https://mustache.github.io/mustache.5.html">mustache</a> template engine. Any string
+ between <pre style={{ display: "inline", padding: 0 }}>&#123;&#123;</pre> and <pre style={{ display: "inline", padding: 0 }}>&#125;&#125;</pre> will
+ be replaced with replaced with the value of the corresponding variable.
+ </p>
+ <p>
+ For example <pre style={{ display: "inline", padding: 0 }}>&#123;&#123;contract_terms.amount&#125;&#125;</pre> will be replaced
+ with the the order's price
+ </p>
+ <p>
+ The short list of variables are:
+ </p>
+ <div class="menu">
+
+ <ul class="menu-list" style={{ listStyleType: "disc", marginLeft: 20 }}>
+ <li><b>contract_terms.summary:</b> order's description </li>
+ <li><b>contract_terms.amount:</b> order's price </li>
+ <li><b>order_id:</b> order's unique identification </li>
+ {state.event_type === "refund" && <Fragment>
+ <li><b>refund_amout:</b> the amount that was being refunded</li>
+ <li><b>reason:</b> the reason entered by the merchant staff for granting the refund</li>
+ <li><b>timestamp:</b> time of the refund in nanoseconds since 1970</li>
+ </Fragment>}
+ </ul>
+ </div>
+ {/* <Input<Entity>
+ name="header_template"
+ label={i18n.str`Http header`}
+ inputType="multiline"
+ tooltip={i18n.str`Header template of the webhook`}
+ /> */}
+ <Input<Entity>
+ name="body_template"
+ inputType="multiline"
+ label={i18n.str`Http body`}
+ tooltip={i18n.str`Body template by the webhook`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/index.tsx
new file mode 100644
index 000000000..70f246ff1
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/create/index.tsx
@@ -0,0 +1,62 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { Notification } from "../../../../utils/types.js";
+import { CreatePage } from "./CreatePage.js";
+
+export type Entity = TalerMerchantApi.WebhookAddDetails;
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+}
+
+export default function CreateWebhook({ onConfirm, onBack }: Props): VNode {
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+
+ return (
+ <>
+ <NotificationCard notification={notif} />
+ <CreatePage
+ onBack={onBack}
+ onCreate={(request: TalerMerchantApi.WebhookAddDetails) => {
+ return lib.instance.addWebhook(state.token, request)
+ .then(() => onConfirm())
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not inform template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx
new file mode 100644
index 000000000..707324d40
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/List.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { FunctionalComponent, h } from "preact";
+import { ListPage as TestedComponent } from "./ListPage.js";
+
+export default {
+ title: "Pages/Templates/List",
+ component: TestedComponent,
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx
new file mode 100644
index 000000000..3f1feb8e9
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/ListPage.tsx
@@ -0,0 +1,62 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { CardTable } from "./Table.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+export interface Props {
+ webhooks: TalerMerchantApi.WebhookEntry[];
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+ onCreate: () => void;
+ onDelete: (e: TalerMerchantApi.WebhookEntry) => void;
+ onSelect: (e: TalerMerchantApi.WebhookEntry) => void;
+}
+
+export function ListPage({
+ webhooks,
+ onCreate,
+ onDelete,
+ onSelect,
+ onLoadMoreBefore,
+ onLoadMoreAfter,
+}: Props): VNode {
+ const form = { payto_uri: "" };
+
+ const { i18n } = useTranslationContext();
+ return (
+ <section class="section is-main-section">
+ <CardTable
+ webhooks={webhooks.map((o) => ({
+ ...o,
+ id: String(o.webhook_id),
+ }))}
+ onCreate={onCreate}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ onLoadMoreBefore={onLoadMoreBefore}
+ onLoadMoreAfter={onLoadMoreAfter}
+ />
+ </section>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx
new file mode 100644
index 000000000..919285e78
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx
@@ -0,0 +1,203 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { StateUpdater, useState } from "preact/hooks";
+
+type Entity = TalerMerchantApi.WebhookEntry;
+
+interface Props {
+ webhooks: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ onCreate: () => void;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+export function CardTable({
+ webhooks,
+ onCreate,
+ onDelete,
+ onSelect,
+ onLoadMoreAfter,
+ onLoadMoreBefore,
+}: Props): VNode {
+ const [rowSelection, rowSelectionHandler] = useState<string[]>([]);
+
+ const { i18n } = useTranslationContext();
+
+ return (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-newspaper" />
+ </span>
+ <i18n.Translate>Webhooks</i18n.Translate>
+ </p>
+ <div class="card-header-icon" aria-label="more options">
+ <span
+ class="has-tooltip-left"
+ data-tooltip={i18n.str`add new webhooks`}
+ >
+ <button class="button is-info" type="button" onClick={onCreate}>
+ <span class="icon is-small">
+ <i class="mdi mdi-plus mdi-36px" />
+ </span>
+ </button>
+ </span>
+ </div>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ {webhooks.length > 0 ? (
+ <Table
+ instances={webhooks}
+ onDelete={onDelete}
+ onSelect={onSelect}
+ rowSelection={rowSelection}
+ rowSelectionHandler={rowSelectionHandler}
+ onLoadMoreAfter={onLoadMoreAfter}
+ onLoadMoreBefore={onLoadMoreBefore}
+ />
+ ) : (
+ <EmptyTable />
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+interface TableProps {
+ rowSelection: string[];
+ instances: Entity[];
+ onDelete: (e: Entity) => void;
+ onSelect: (e: Entity) => void;
+ rowSelectionHandler: StateUpdater<string[]>;
+ onLoadMoreBefore?: () => void;
+ onLoadMoreAfter?: () => void;
+}
+
+function Table({
+ instances,
+ onLoadMoreAfter,
+ onDelete,
+ onSelect,
+ onLoadMoreBefore,
+}: TableProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="table-container">
+ {onLoadMoreBefore && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more webhooks before the first one`}
+ onClick={onLoadMoreBefore}
+ >
+ <i18n.Translate>load first page</i18n.Translate>
+ </button>
+ )}
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>ID</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Event type</i18n.Translate>
+ </th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {instances.map((i) => {
+ return (
+ <tr key={i.webhook_id}>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.webhook_id}
+ </td>
+ <td
+ onClick={(): void => onSelect(i)}
+ style={{ cursor: "pointer" }}
+ >
+ {i.event_type}
+ </td>
+ <td class="is-actions-cell right-sticky">
+ <div class="buttons is-right">
+ <button
+ class="button is-danger is-small has-tooltip-left"
+ data-tooltip={i18n.str`delete selected webhook from the database`}
+ onClick={() => onDelete(i)}
+ >
+ Delete
+ </button>
+ {/* <button
+ class="button is-info is-small has-tooltip-left"
+ data-tooltip={i18n.str`test webhook`}
+ onClick={() => onNewOrder(i)}
+ >
+ Test
+ </button> */}
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {onLoadMoreAfter && (
+ <button
+ class="button is-fullwidth"
+ data-tooltip={i18n.str`load more webhooks after the last one`}
+ onClick={onLoadMoreAfter}
+ >
+ <i18n.Translate>load next page</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+}
+
+function EmptyTable(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="content has-text-grey has-text-centered">
+ <p>
+ <span class="icon is-large">
+ <i class="mdi mdi-magnify mdi-48px" />
+ </span>
+ </p>
+ <p>
+ <i18n.Translate>
+ There is no webhooks yet, add more pressing the + sign
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/index.tsx
new file mode 100644
index 000000000..789b8d73b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/index.tsx
@@ -0,0 +1,105 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ HttpStatusCode,
+ TalerError,
+ TalerMerchantApi,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import { useInstanceWebhooks } from "../../../../hooks/webhooks.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { ListPage } from "./ListPage.js";
+
+interface Props {
+ onCreate: () => void;
+ onSelect: (id: string) => void;
+}
+
+export default function ListWebhooks({ onCreate, onSelect }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+ const result = useInstanceWebhooks();
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+
+ <ListPage
+ webhooks={result.body.webhooks}
+ onLoadMoreBefore={undefined} //result.isFirstPage ? undefined : result.loadFirst}
+ onLoadMoreAfter={undefined} //result.isLastPage ? undefined : result.loadNext}
+ onCreate={onCreate}
+ onSelect={(e) => {
+ onSelect(e.webhook_id);
+ }}
+ onDelete={(e: TalerMerchantApi.WebhookEntry) => {
+ return lib.instance
+ .deleteWebhook(state.token, e.webhook_id)
+ .then(() =>
+ setNotif({
+ message: i18n.str`webhook delete successfully`,
+ type: "SUCCESS",
+ }),
+ )
+ .catch((error) =>
+ setNotif({
+ message: i18n.str`could not delete the webhook`,
+ type: "ERROR",
+ description: error.message,
+ }),
+ );
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx
new file mode 100644
index 000000000..303d17b72
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/Update.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode, FunctionalComponent } from "preact";
+import { UpdatePage as TestedComponent } from "./UpdatePage.js";
+
+export default {
+ title: "Pages/Templates/Update",
+ component: TestedComponent,
+ argTypes: {
+ onUpdate: { action: "onUpdate" },
+ onBack: { action: "onBack" },
+ },
+};
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx
new file mode 100644
index 000000000..6aca62582
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/UpdatePage.tsx
@@ -0,0 +1,145 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../../../components/form/FormProvider.js";
+import { Input } from "../../../../components/form/Input.js";
+import { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+type Entity = TalerMerchantApi.WebhookPatchDetails & WithId;
+
+interface Props {
+ onUpdate: (d: Entity) => Promise<void>;
+ onBack?: () => void;
+ webhook: Entity;
+}
+const validMethod = ["GET", "POST", "PUT", "PATCH", "HEAD"];
+
+export function UpdatePage({ webhook, onUpdate, onBack }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [state, setState] = useState<Partial<Entity>>(webhook);
+
+ const errors: FormErrors<Entity> = {
+ event_type: !state.event_type ? i18n.str`required` : undefined,
+ http_method: !state.http_method
+ ? i18n.str`required`
+ : !validMethod.includes(state.http_method)
+ ? i18n.str`should be one of '${validMethod.join(", ")}'`
+ : undefined,
+ url: !state.url ? i18n.str`required` : undefined,
+ };
+
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined,
+ );
+
+ const submitForm = () => {
+ if (hasErrors) return Promise.reject();
+ return onUpdate(state as any);
+ };
+
+ return (
+ <div>
+ <section class="section">
+ <section class="hero is-hero-bar">
+ <div class="hero-body">
+ <div class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <span class="is-size-4">
+ Webhook: <b>{webhook.id}</b>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <hr />
+
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column is-four-fifths">
+ <FormProvider
+ object={state}
+ valueHandler={setState}
+ errors={errors}
+ >
+ <Input<Entity>
+ name="event_type"
+ label={i18n.str`Event`}
+ tooltip={i18n.str`The event of the webhook: why the webhook is used`}
+ />
+ <Input<Entity>
+ name="http_method"
+ label={i18n.str`Method`}
+ tooltip={i18n.str`Method used by the webhook`}
+ />
+ <Input<Entity>
+ name="url"
+ label={i18n.str`URL`}
+ tooltip={i18n.str`URL of the webhook where the customer will be redirected`}
+ />
+ <Input<Entity>
+ name="header_template"
+ label={i18n.str`Header`}
+ inputType="multiline"
+ tooltip={i18n.str`Header template of the webhook`}
+ />
+ <Input<Entity>
+ name="body_template"
+ inputType="multiline"
+ label={i18n.str`Body`}
+ tooltip={i18n.str`Body template by the webhook`}
+ />
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ )}
+ <AsyncButton
+ disabled={hasErrors}
+ data-tooltip={
+ hasErrors
+ ? i18n.str`Need to complete marked fields`
+ : "confirm operation"
+ }
+ onClick={submitForm}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </div>
+ </div>
+ </div>
+ </section>
+ </section>
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx
new file mode 100644
index 000000000..5b2ba7bb9
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/update/index.tsx
@@ -0,0 +1,97 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { Loading } from "../../../../components/exception/loading.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { useSessionContext } from "../../../../context/session.js";
+import {
+ useWebhookDetails,
+} from "../../../../hooks/webhooks.js";
+import { Notification } from "../../../../utils/types.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { UpdatePage } from "./UpdatePage.js";
+
+export type Entity = TalerMerchantApi.WebhookPatchDetails & WithId;
+
+interface Props {
+ onBack?: () => void;
+ onConfirm: () => void;
+ tid: string;
+}
+export default function UpdateWebhook({
+ tid,
+ onConfirm,
+ onBack,
+}: Props): VNode {
+ const { lib } = useSessionContext();
+ const { state } = useSessionContext();
+ const result = useWebhookDetails(tid);
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+
+ const { i18n } = useTranslationContext();
+
+ if (!result) return <Loading />;
+ if (result instanceof TalerError) {
+ return <ErrorLoadingMerchant error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound: {
+ return <NotFoundPageOrAdminCreate />;
+ }
+ case HttpStatusCode.Unauthorized: {
+ return <LoginPage />
+ }
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <UpdatePage
+ webhook={{ ...result.body, id: tid }}
+ onBack={onBack}
+ onUpdate={(data) => {
+ return lib.instance.updateWebhook(state.token, tid, data)
+ .then(onConfirm)
+ .catch((error) => {
+ setNotif({
+ message: i18n.str`could not update template`,
+ type: "ERROR",
+ description: error.message,
+ });
+ });
+ }}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx
new file mode 100644
index 000000000..272c40b55
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/login/index.tsx
@@ -0,0 +1,174 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { HttpStatusCode, createAccessToken } from "@gnu-taler/taler-util";
+import {
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { NotificationCard } from "../../components/menu/index.js";
+import { useSessionContext } from "../../context/session.js";
+import { Notification } from "../../utils/types.js";
+
+interface Props {}
+
+const tokenRequest = {
+ scope: "write",
+ duration: {
+ d_us: "forever" as const,
+ },
+ refreshable: true,
+};
+
+export function LoginPage(_p: Props): VNode {
+ const [token, setToken] = useState("");
+ const [notif, setNotif] = useState<Notification | undefined>(undefined);
+ const { state, logIn } = useSessionContext();
+ const { lib } = useSessionContext();
+
+ const { i18n } = useTranslationContext();
+
+ async function doLoginImpl() {
+ const result = await lib.authenticate.createAccessTokenBearer(
+ createAccessToken(token),
+ tokenRequest,
+ );
+ if (result.type === "ok") {
+ const { token } = result.body;
+ logIn(token);
+ return;
+ } else {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized: {
+ setNotif({
+ message: "Your password is incorrect",
+ type: "ERROR",
+ });
+ return;
+ }
+ case HttpStatusCode.NotFound: {
+ setNotif({
+ message: "Your instance not found",
+ type: "ERROR",
+ });
+ return;
+ }
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <NotificationCard notification={notif} />
+ <div class="columns is-centered" style={{ margin: "auto" }}>
+ <div class="column is-two-thirds ">
+ <div class="modal-card" style={{ width: "100%", margin: 0 }}>
+ <header
+ class="modal-card-head"
+ style={{ border: "1px solid", borderBottom: 0 }}
+ >
+ <p class="modal-card-title">{i18n.str`Login required`}</p>
+ </header>
+ <section
+ class="modal-card-body"
+ style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
+ >
+ <i18n.Translate>
+ Please enter your access token for <b>"{state.instance}"</b>.
+ </i18n.Translate>
+
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Access Token</i18n.Translate>
+ </label>
+ </div>
+ <div class="field-body">
+ <div class="field">
+ <p class="control is-expanded">
+ <input
+ class="input"
+ type="password"
+ placeholder={"current access token"}
+ name="token"
+ onKeyPress={(e) =>
+ e.keyCode === 13 ? doLoginImpl() : null
+ }
+ value={token}
+ onInput={(e): void => setToken(e?.currentTarget.value)}
+ />
+ </p>
+ </div>
+ </div>
+ </div>
+ </section>
+ <footer
+ class="modal-card-foot "
+ style={{
+ justifyContent: "space-between",
+ border: "1px solid",
+ borderTop: 0,
+ }}
+ >
+ <div />
+ <AsyncButton type="is-info" onClick={doLoginImpl}>
+ <i18n.Translate>Confirm</i18n.Translate>
+ </AsyncButton>
+ </footer>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+
+function AsyncButton({
+ onClick,
+ disabled,
+ type = "",
+ children,
+}: {
+ type?: string;
+ disabled?: boolean;
+ onClick: () => Promise<void>;
+ children: ComponentChildren;
+}): VNode {
+ const [running, setRunning] = useState(false);
+ return (
+ <button
+ class={"button " + type}
+ disabled={disabled || running}
+ onClick={() => {
+ setRunning(true);
+ onClick()
+ .then(() => {
+ setRunning(false);
+ })
+ .catch(() => {
+ setRunning(false);
+ });
+ }}
+ >
+ {children}
+ </button>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/notfound/index.tsx b/packages/merchant-backoffice-ui/src/paths/notfound/index.tsx
new file mode 100644
index 000000000..4d348c02b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/notfound/index.tsx
@@ -0,0 +1,72 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { Link, route } from "preact-router";
+import { NotificationCard } from "../../components/menu/index.js";
+import {
+ cleanAllCache,
+ DEFAULT_ADMIN_USERNAME,
+ useSessionContext,
+} from "../../context/session.js";
+import InstanceCreatePage from "../../paths/admin/create/index.js";
+import { InstancePaths } from "../../Routing.js";
+
+export function NotFoundPage(): VNode {
+ return (
+ <div>
+ <p>That page doesn&apos;t exist.</p>
+ <Link href="/">
+ <h4>Back to Home</h4>
+ </Link>
+ </div>
+ );
+}
+
+export function NotFoundPageOrAdminCreate(): VNode {
+ const { state } = useSessionContext();
+ const { i18n } = useTranslationContext();
+ if (state.isAdmin && state.instance === DEFAULT_ADMIN_USERNAME) {
+ return (
+ <Fragment>
+ <NotificationCard
+ notification={{
+ message: i18n.str`No 'default' instance configured yet.`,
+ description: i18n.str`Create a 'default' instance to begin using the merchant backoffice.`,
+ type: "INFO",
+ }}
+ />
+ <InstanceCreatePage
+ forceId={DEFAULT_ADMIN_USERNAME}
+ onConfirm={() => {
+ // we need to clear everything since we take some
+ // 404 as "default instance don't exist"
+ cleanAllCache()
+ route(InstancePaths.bank_list);
+ }}
+ />
+ </Fragment>
+ );
+ }
+
+ return <NotFoundPage />
+}
diff --git a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx
new file mode 100644
index 000000000..0c4b9dd1a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx
@@ -0,0 +1,141 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useLang, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../components/form/FormProvider.js";
+import { InputSelector } from "../../components/form/InputSelector.js";
+import { InputToggle } from "../../components/form/InputToggle.js";
+import { LangSelector } from "../../components/menu/LangSelector.js";
+import { Preferences, usePreference } from "../../hooks/preference.js";
+import { AbsoluteTime } from "@gnu-taler/taler-util";
+
+function getBrowserLang(): string | undefined {
+ if (typeof window === "undefined") return undefined;
+ if (window.navigator.languages) return window.navigator.languages[0];
+ if (window.navigator.language) return window.navigator.language;
+ return undefined;
+}
+
+export function Settings({ onClose }: { onClose?: () => void }): VNode {
+ const { i18n } = useTranslationContext();
+ const borwserLang = getBrowserLang();
+ const { update } = useLang(undefined, {});
+
+ const [value, , updateValue] = usePreference();
+ const errors: FormErrors<Preferences> = {};
+
+ function valueHandler(s: (d: Partial<Preferences>) => Partial<Preferences>): void {
+ const next = s(value);
+ const v: Preferences = {
+ advanceOrderMode: next.advanceOrderMode ?? false,
+ hideMissingAccountUntil: next.hideMissingAccountUntil ?? AbsoluteTime.never(),
+ hideKycUntil: next.hideKycUntil ?? AbsoluteTime.never(),
+ dateFormat: next.dateFormat ?? "ymd",
+ };
+ updateValue(v);
+ }
+
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ <div>
+ <FormProvider<Preferences>
+ name="settings"
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler}
+ >
+ <div class="field is-horizontal">
+ <div class="field-label is-normal">
+ <label class="label">
+ <i18n.Translate>Language</i18n.Translate>
+ <span
+ class="icon has-tooltip-right"
+ data-tooltip={
+ "Force language setting instance of taking the browser"
+ }
+ >
+ <i class="mdi mdi-information" />
+ </span>
+ </label>
+ </div>
+ <div class="field field-body has-addons is-flex-grow-3">
+ <LangSelector />
+ &nbsp;
+ {borwserLang !== undefined && (
+ <button
+ data-tooltip={i18n.str`generate random secret key`}
+ class="button is-info mr-2"
+ onClick={(e) => {
+ update(borwserLang.substring(0, 2));
+ e.preventDefault()
+ }}
+ >
+ <i18n.Translate>Set default</i18n.Translate>
+ </button>
+ )}
+ </div>
+ </div>
+ <InputToggle<Preferences>
+ label={i18n.str`Advance order creation`}
+ tooltip={i18n.str`Shows more options in the order creation form`}
+ name="advanceOrderMode"
+ />
+ <InputSelector<Preferences>
+ name="dateFormat"
+ label={i18n.str`Date format`}
+ expand={true}
+ help={
+ value.dateFormat === "dmy"
+ ? "31/12/2001"
+ : value.dateFormat === "mdy"
+ ? "12/31/2001"
+ : value.dateFormat === "ymd"
+ ? "2001/12/31"
+ : ""
+ }
+ toStr={(e) => {
+ if (e === "ymd") return "year month day";
+ if (e === "mdy") return "month day year";
+ if (e === "dmy") return "day month year";
+ return "choose one";
+ }}
+ values={["ymd", "mdy", "dmy"]}
+ tooltip={i18n.str`how the date is going to be displayed`}
+ />
+ </FormProvider>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ {onClose && (
+ <section class="section is-main-section">
+ <button class="button" onClick={onClose}>
+ <i18n.Translate>Close</i18n.Translate>
+ </button>
+ </section>
+ )}
+ </div>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/schemas/index.ts b/packages/merchant-backoffice-ui/src/schemas/index.ts
new file mode 100644
index 000000000..693894ae0
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/schemas/index.ts
@@ -0,0 +1,224 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import { isAfter, isFuture } from "date-fns";
+import * as yup from "yup";
+import { PAYTO_REGEX } from "../utils/constants.js";
+
+yup.setLocale({
+ mixed: {
+ default: "field_invalid",
+ },
+ number: {
+ min: ({ min }: any) => ({ key: "field_too_short", values: { min } }),
+ max: ({ max }: any) => ({ key: "field_too_big", values: { max } }),
+ },
+});
+
+function listOfPayToUrisAreValid(values?: (string | undefined)[]): boolean {
+ return !!values && values.every((v) => v && PAYTO_REGEX.test(v));
+}
+
+function currencyWithAmountIsValid(value?: string): boolean {
+ return !!value && Amounts.parse(value) !== undefined;
+}
+function currencyGreaterThan0(value?: string) {
+ if (value) {
+ try {
+ const [, amount] = value.split(":");
+ const intAmount = parseInt(amount, 10);
+ return intAmount > 0;
+ } catch {
+ return false;
+ }
+ }
+ return true;
+}
+
+export const InstanceSchema = yup.object().shape({
+ id: yup.string().required().meta({ type: "url" }),
+ name: yup.string().required(),
+ auth: yup.object().shape({
+ method: yup.string().matches(/^(external|token)$/),
+ token: yup.string().optional().nullable(),
+ }),
+ payto_uris: yup
+ .array()
+ .of(yup.string())
+ .min(1)
+ .meta({ type: "array" })
+ .test("payto", "{path} is not valid", listOfPayToUrisAreValid),
+ default_max_deposit_fee: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid)
+ .meta({ type: "amount" }),
+ default_max_wire_fee: yup
+ .string()
+ .required()
+ .test("amount", "{path} is not valid", currencyWithAmountIsValid)
+ .meta({ type: "amount" }),
+ default_wire_fee_amortization: yup.number().required(),
+ address: yup
+ .object()
+ .shape({
+ country: yup.string().optional(),
+ address_lines: yup.array().of(yup.string()).max(7).optional(),
+ building_number: yup.string().optional(),
+ building_name: yup.string().optional(),
+ street: yup.string().optional(),
+ post_code: yup.string().optional(),
+ town_location: yup.string().optional(),
+ town: yup.string(),
+ district: yup.string().optional(),
+ country_subdivision: yup.string().optional(),
+ })
+ .meta({ type: "group" }),
+ jurisdiction: yup
+ .object()
+ .shape({
+ country: yup.string().optional(),
+ address_lines: yup.array().of(yup.string()).max(7).optional(),
+ building_number: yup.string().optional(),
+ building_name: yup.string().optional(),
+ street: yup.string().optional(),
+ post_code: yup.string().optional(),
+ town_location: yup.string().optional(),
+ town: yup.string(),
+ district: yup.string().optional(),
+ country_subdivision: yup.string().optional(),
+ })
+ .meta({ type: "group" }),
+ // default_pay_delay: yup.object()
+ // .shape({ d_us: yup.number() })
+ // .required()
+ // .meta({ type: 'duration' }),
+ // .transform(numberToDuration),
+ default_wire_transfer_delay: yup
+ .object()
+ .shape({ d_us: yup.number() })
+ .required()
+ .meta({ type: "duration" }),
+ // .transform(numberToDuration),
+});
+
+export const InstanceUpdateSchema = InstanceSchema.clone().omit(["id"]);
+export const InstanceCreateSchema = InstanceSchema.clone();
+
+export const OrderCreateSchema = yup.object().shape({
+ pricing: yup
+ .object()
+ .required()
+ .shape({
+ summary: yup.string().ensure().required(),
+ order_price: yup
+ .string()
+ .ensure()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid)
+ .test(
+ "amount_positive",
+ "the amount should be greater than 0",
+ currencyGreaterThan0,
+ ),
+ }),
+ // extra: yup.object().test("extra", "is not a JSON format", stringIsValidJSON),
+ payments: yup
+ .object()
+ .required()
+ .shape({
+ refund_deadline: yup
+ .date()
+ .test("future", "should be in the future", (d) =>
+ d ? isFuture(d) : true,
+ ),
+ pay_deadline: yup
+ .date()
+ .test("future", "should be in the future", (d) =>
+ d ? isFuture(d) : true,
+ ),
+ auto_refund_deadline: yup
+ .date()
+ .test("future", "should be in the future", (d) =>
+ d ? isFuture(d) : true,
+ ),
+ delivery_date: yup
+ .date()
+ .test("future", "should be in the future", (d) =>
+ d ? isFuture(d) : true,
+ ),
+ })
+ .test("payment", "dates", (d) => {
+ if (
+ d.pay_deadline &&
+ d.refund_deadline &&
+ isAfter(d.refund_deadline, d.pay_deadline)
+ ) {
+ return new yup.ValidationError(
+ "pay deadline should be greater than refund",
+ "asd",
+ "payments.pay_deadline",
+ );
+ }
+ return true;
+ }),
+});
+
+export const ProductCreateSchema = yup.object().shape({
+ product_id: yup.string().ensure().required(),
+ description: yup.string().required(),
+ unit: yup.string().ensure().required(),
+ price: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid),
+ stock: yup.object({}).optional(),
+ minimum_age: yup.number().optional().min(0),
+});
+
+export const ProductUpdateSchema = yup.object().shape({
+ description: yup.string().required(),
+ price: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid),
+ stock: yup.object({}).optional(),
+ minimum_age: yup.number().optional().min(0),
+});
+
+export const TaxSchema = yup.object().shape({
+ name: yup.string().required().ensure(),
+ tax: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid),
+});
+
+export const NonInventoryProductSchema = yup.object().shape({
+ quantity: yup.number().required().positive(),
+ description: yup.string().required(),
+ unit: yup.string().ensure().required(),
+ price: yup
+ .string()
+ .required()
+ .test("amount", "the amount is not valid", currencyWithAmountIsValid),
+});
diff --git a/packages/merchant-backoffice-ui/src/scss/DurationPicker.scss b/packages/merchant-backoffice-ui/src/scss/DurationPicker.scss
new file mode 100644
index 000000000..aa75b9916
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/DurationPicker.scss
@@ -0,0 +1,70 @@
+.rdp-picker {
+ display: flex;
+ height: 175px;
+}
+
+@media (max-width: 400px) {
+ .rdp-picker {
+ width: 250px;
+ }
+}
+
+.rdp-masked-div {
+ overflow: hidden;
+ height: 175px;
+ position: relative;
+}
+
+.rdp-column-container {
+ flex-grow: 1;
+ display: inline-block;
+}
+
+.rdp-column {
+ position: absolute;
+ z-index: 0;
+ width: 100%;
+}
+
+.rdp-reticule {
+ border: 0;
+ border-top: 2px solid rgba(109, 202, 236, 1);
+ height: 2px;
+ position: absolute;
+ width: 80%;
+ margin: 0;
+ z-index: 100;
+ left: 50%;
+ -webkit-transform: translateX(-50%);
+ transform: translateX(-50%);
+}
+
+.rdp-text-overlay {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 35px;
+ font-size: 20px;
+ left: 50%;
+ -webkit-transform: translateX(-50%);
+ transform: translateX(-50%);
+}
+
+.rdp-cell div {
+ font-size: 17px;
+ color: gray;
+ font-style: italic;
+}
+
+.rdp-cell {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 35px;
+ font-size: 18px;
+}
+
+.rdp-center {
+ font-size: 25px;
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_aside.scss b/packages/merchant-backoffice-ui/src/scss/_aside.scss
new file mode 100644
index 000000000..b7b59516b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_aside.scss
@@ -0,0 +1,181 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+@include desktop {
+ html {
+ &.has-aside-left {
+ &.has-aside-expanded {
+ nav.navbar,
+ body {
+ padding-left: $aside-width;
+ }
+ }
+ aside.is-placed-left {
+ display: block;
+ }
+ }
+ }
+
+ aside.aside.is-expanded {
+ width: $aside-width;
+
+ .menu-list {
+ @include icon-with-update-mark($aside-icon-width);
+
+ span.menu-item-label {
+ display: inline-block;
+ }
+
+ li.is-active {
+ ul {
+ display: block;
+ }
+ }
+ }
+ }
+}
+
+aside.aside {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 40;
+ height: 100vh;
+ padding: 0;
+ box-shadow: $aside-box-shadow;
+ background: $aside-background-color;
+
+ .aside-tools {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ background-color: $aside-tools-background-color;
+ color: $aside-tools-color;
+ line-height: $navbar-height;
+ height: $navbar-height;
+ padding-left: $default-padding * 0.5;
+ flex: 1;
+
+ .icon {
+ margin-right: $default-padding * 0.5;
+ }
+ }
+
+ .menu-list {
+ li {
+ a {
+ &.has-dropdown-icon {
+ position: relative;
+ padding-right: $aside-icon-width;
+
+ .dropdown-icon {
+ position: absolute;
+ top: $size-base * 0.5;
+ right: 0;
+ }
+ }
+ }
+ ul {
+ display: none;
+ border-left: 0;
+ background-color: darken($base-color, 2.5%);
+ padding-left: 0;
+ margin: 0 0 $default-padding * 0.5;
+
+ li {
+ a {
+ padding: $default-padding * 0.5 0 $default-padding * 0.5
+ $default-padding * 0.5;
+ font-size: $aside-submenu-font-size;
+
+ &.has-icon {
+ padding-left: 0;
+ }
+ &.is-active {
+ &:not(:hover) {
+ background: transparent;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .menu-label {
+ padding: 0 $default-padding * 0.5;
+ margin-top: $default-padding * 0.5;
+ margin-bottom: $default-padding * 0.5;
+ }
+}
+
+@include touch {
+ nav.navbar {
+ @include transition(margin-left);
+ }
+ aside.aside {
+ @include transition(left);
+ }
+ html.has-aside-mobile-transition {
+ body {
+ overflow-x: hidden;
+ }
+ body,
+ nav.navbar {
+ width: 100vw;
+ }
+ aside.aside {
+ width: $aside-mobile-width;
+ display: block;
+ left: $aside-mobile-width * -1;
+
+ .image {
+ img {
+ max-width: $aside-mobile-width * 0.33;
+ }
+ }
+
+ .menu-list {
+ li.is-active {
+ ul {
+ display: block;
+ }
+ }
+ a {
+ @include icon-with-update-mark($aside-icon-width);
+
+ span.menu-item-label {
+ display: inline-block;
+ }
+ }
+ }
+ }
+ }
+ div.has-aside-mobile-expanded {
+ nav.navbar {
+ margin-left: $aside-mobile-width;
+ }
+ aside.aside {
+ left: 0;
+ }
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_card.scss b/packages/merchant-backoffice-ui/src/scss/_card.scss
new file mode 100644
index 000000000..a4118400f
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_card.scss
@@ -0,0 +1,69 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+.card:not(:last-child) {
+ margin-bottom: $default-padding;
+}
+
+.card {
+ border-radius: $radius-large;
+ border: $card-border;
+
+ &.has-table {
+ .card-content {
+ padding: 0;
+ }
+ .b-table {
+ border-radius: $radius-large;
+ overflow: hidden;
+ }
+ }
+
+ &.is-card-widget {
+ .card-content {
+ padding: $default-padding * 0.5;
+ }
+ }
+
+ .card-header {
+ border-bottom: 1px solid $base-color-light;
+ }
+
+ .card-content {
+ hr {
+ margin-left: $card-content-padding * -1;
+ margin-right: $card-content-padding * -1;
+ }
+ }
+
+ .is-widget-icon {
+ .icon {
+ width: 5rem;
+ height: 5rem;
+ }
+ }
+
+ .is-widget-label {
+ .subtitle {
+ color: $grey;
+ }
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_custom-calendar.scss b/packages/merchant-backoffice-ui/src/scss/_custom-calendar.scss
new file mode 100644
index 000000000..62414a00a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_custom-calendar.scss
@@ -0,0 +1,259 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+:root {
+ --primary-color: #3298dc;
+
+ --primary-text-color-dark: rgba(0, 0, 0, 0.87);
+ --secondary-text-color-dark: rgba(0, 0, 0, 0.57);
+ --disabled-text-color-dark: rgba(0, 0, 0, 0.13);
+
+ --primary-text-color-light: rgba(255, 255, 255, 0.87);
+ --secondary-text-color-light: rgba(255, 255, 255, 0.57);
+ --disabled-text-color-light: rgba(255, 255, 255, 0.13);
+
+ --font-stack: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif;
+
+ --primary-card-color: #fff;
+ --primary-background-color: #f2f2f2;
+
+ --box-shadow-lvl-1: 0 1px 3px rgba(0, 0, 0, 0.12),
+ 0 1px 2px rgba(0, 0, 0, 0.24);
+ --box-shadow-lvl-2: 0 3px 6px rgba(0, 0, 0, 0.16),
+ 0 3px 6px rgba(0, 0, 0, 0.23);
+ --box-shadow-lvl-3: 0 10px 20px rgba(0, 0, 0, 0.19),
+ 0 6px 6px rgba(0, 0, 0, 0.23);
+ --box-shadow-lvl-4: 0 14px 28px rgba(0, 0, 0, 0.25),
+ 0 10px 10px rgba(0, 0, 0, 0.22);
+}
+
+.datePicker {
+ text-align: left;
+ background: var(--primary-card-color);
+ border-radius: 3px;
+ z-index: 200;
+ position: fixed;
+ height: auto;
+ max-height: 90vh;
+ width: 90vw;
+ max-width: 448px;
+ transform-origin: top left;
+ transition: transform 0.22s ease-in-out, opacity 0.22s ease-in-out;
+ top: 50%;
+ left: 50%;
+ opacity: 0;
+ transform: scale(0) translate(-50%, -50%);
+ user-select: none;
+
+ &.datePicker--opened {
+ opacity: 1;
+ transform: scale(1) translate(-50%, -50%);
+ }
+
+ .datePicker--titles {
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+ padding: 24px;
+ height: 100px;
+ background: var(--primary-color);
+
+ h2,
+ h3 {
+ cursor: pointer;
+ color: #fff;
+ line-height: 1;
+ padding: 0;
+ margin: 0;
+ font-size: 32px;
+ }
+
+ h3 {
+ color: rgba(255, 255, 255, 0.57);
+ font-size: 18px;
+ padding-bottom: 2px;
+ }
+ }
+
+ nav {
+ padding: 20px;
+ height: 56px;
+
+ h4 {
+ width: calc(100% - 60px);
+ text-align: center;
+ display: inline-block;
+ padding: 0;
+ font-size: 14px;
+ line-height: 24px;
+ margin: 0;
+ position: relative;
+ top: -9px;
+ color: var(--primary-text-color);
+ }
+
+ i {
+ cursor: pointer;
+ color: var(--secondary-text-color);
+ font-size: 26px;
+ user-select: none;
+ border-radius: 50%;
+
+ &:hover {
+ background: var(--disabled-text-color-dark);
+ }
+ }
+ }
+
+ .datePicker--scroll {
+ overflow-y: auto;
+ max-height: calc(90vh - 56px - 100px);
+ }
+
+ .datePicker--calendar {
+ padding: 0 20px;
+
+ .datePicker--dayNames {
+ width: 100%;
+ display: grid;
+ text-align: center;
+
+ // there's probably a better way to do this, but wanted to try out CSS grid
+ grid-template-columns:
+ calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7)
+ calc(100% / 7) calc(100% / 7) calc(100% / 7);
+
+ span {
+ color: var(--secondary-text-color-dark);
+ font-size: 14px;
+ line-height: 42px;
+ display: inline-grid;
+ }
+ }
+
+ .datePicker--days {
+ width: 100%;
+ display: grid;
+ text-align: center;
+ grid-template-columns:
+ calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7)
+ calc(100% / 7) calc(100% / 7) calc(100% / 7);
+
+ span {
+ color: var(--primary-text-color-dark);
+ line-height: 42px;
+ font-size: 14px;
+ display: inline-grid;
+ transition: color 0.22s;
+ height: 42px;
+ position: relative;
+ cursor: pointer;
+ user-select: none;
+ border-radius: 50%;
+
+ &::before {
+ content: "";
+ position: absolute;
+ z-index: -1;
+ height: 42px;
+ width: 42px;
+ left: calc(50% - 21px);
+ background: var(--primary-color);
+ border-radius: 50%;
+ transition: transform 0.22s, opacity 0.22s;
+ transform: scale(0);
+ opacity: 0;
+ }
+
+ &[disabled="true"] {
+ cursor: unset;
+ }
+
+ &.datePicker--today {
+ font-weight: 700;
+ }
+
+ &.datePicker--selected {
+ color: rgba(255, 255, 255, 0.87);
+
+ &:before {
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+ }
+ }
+ }
+
+ .datePicker--selectYear {
+ padding: 0 20px;
+ display: block;
+ width: 100%;
+ text-align: center;
+ max-height: 362px;
+
+ span {
+ display: block;
+ width: 100%;
+ font-size: 24px;
+ margin: 20px auto;
+ cursor: pointer;
+
+ &.selected {
+ font-size: 42px;
+ color: var(--primary-color);
+ }
+ }
+ }
+
+ div.datePicker--actions {
+ width: 100%;
+ padding: 8px;
+ text-align: right;
+
+ button {
+ margin-bottom: 0;
+ font-size: 15px;
+ cursor: pointer;
+ color: var(--primary-text-color);
+ border: none;
+ margin-left: 8px;
+ min-width: 64px;
+ line-height: 36px;
+ background-color: transparent;
+ appearance: none;
+ padding: 0 16px;
+ border-radius: 3px;
+ transition: background-color 0.13s;
+
+ &:hover,
+ &:focus {
+ outline: none;
+ background-color: var(--disabled-text-color-dark);
+ }
+ }
+ }
+}
+
+.datePicker--background {
+ z-index: 199;
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.52);
+ animation: fadeIn 0.22s forwards;
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_footer.scss b/packages/merchant-backoffice-ui/src/scss/_footer.scss
new file mode 100644
index 000000000..7e90c40cc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_footer.scss
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+footer.footer {
+ .logo {
+ img {
+ width: auto;
+ height: $footer-logo-height;
+ }
+ }
+}
+
+@include mobile {
+ .footer-copyright {
+ text-align: center;
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_form.scss b/packages/merchant-backoffice-ui/src/scss/_form.scss
new file mode 100644
index 000000000..126d3d0cc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_form.scss
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+.field {
+ &.has-check {
+ .field-body {
+ margin-top: $default-padding * 0.125;
+ }
+ }
+ .control {
+ .mdi-24px.mdi-set,
+ .mdi-24px.mdi:before {
+ font-size: inherit;
+ }
+ }
+}
+.upload {
+ .upload-draggable {
+ display: block;
+ }
+}
+
+.input,
+.textarea,
+select {
+ box-shadow: none;
+
+ &:focus,
+ &:active {
+ box-shadow: none !important;
+ }
+}
+
+.switch input[type="checkbox"] + .check:before {
+ box-shadow: none;
+}
+
+.switch,
+.b-checkbox.checkbox {
+ input[type="checkbox"] {
+ &:focus + .check,
+ &:focus:checked + .check {
+ box-shadow: none !important;
+ }
+ }
+}
+
+.b-checkbox.checkbox input[type="checkbox"],
+.b-radio.radio input[type="radio"] {
+ & + .check {
+ border: $checkbox-border;
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_hero-bar.scss b/packages/merchant-backoffice-ui/src/scss/_hero-bar.scss
new file mode 100644
index 000000000..cb3f438e9
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_hero-bar.scss
@@ -0,0 +1,55 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+section.hero.is-hero-bar {
+ background-color: $hero-bar-background;
+ border-bottom: $light-border;
+
+ .hero-body {
+ padding: $default-padding;
+
+ .level-item {
+ &.is-hero-avatar-item {
+ margin-right: $default-padding;
+ }
+
+ > div > .level {
+ margin-bottom: $default-padding * 0.5;
+ }
+
+ .subtitle + p {
+ margin-top: $default-padding * 0.5;
+ }
+ }
+
+ .button {
+ &.is-hero-button {
+ background-color: rgba($white, 0.5);
+ font-weight: 300;
+ @include transition(background-color);
+
+ &:hover {
+ background-color: $white;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_loading.scss b/packages/merchant-backoffice-ui/src/scss/_loading.scss
new file mode 100644
index 000000000..32f64f276
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_loading.scss
@@ -0,0 +1,51 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+.lds-ring {
+ display: inline-block;
+ position: relative;
+ width: 80px;
+ height: 80px;
+}
+.lds-ring div {
+ box-sizing: border-box;
+ display: block;
+ position: absolute;
+ width: 64px;
+ height: 64px;
+ margin: 8px;
+ border: 8px solid black;
+ border-radius: 50%;
+ animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
+ border-color: black transparent transparent transparent;
+}
+.lds-ring div:nth-child(1) {
+ animation-delay: -0.45s;
+}
+.lds-ring div:nth-child(2) {
+ animation-delay: -0.3s;
+}
+.lds-ring div:nth-child(3) {
+ animation-delay: -0.15s;
+}
+@keyframes lds-ring {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_main-section.scss b/packages/merchant-backoffice-ui/src/scss/_main-section.scss
new file mode 100644
index 000000000..444af5235
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_main-section.scss
@@ -0,0 +1,24 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+section.section.is-main-section {
+ padding-top: $default-padding;
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_misc.scss b/packages/merchant-backoffice-ui/src/scss/_misc.scss
new file mode 100644
index 000000000..a0dbc64fc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_misc.scss
@@ -0,0 +1,50 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+.is-user-avatar {
+ &.has-max-width {
+ max-width: $size-base * 7;
+ }
+
+ &.is-aligned-center {
+ margin: 0 auto;
+ }
+
+ img {
+ margin: 0 auto;
+ border-radius: $radius-rounded;
+ }
+}
+
+.icon.has-update-mark {
+ position: relative;
+
+ &:after {
+ content: "";
+ width: $icon-update-mark-size;
+ height: $icon-update-mark-size;
+ position: absolute;
+ top: 1px;
+ right: 1px;
+ background-color: $icon-update-mark-color;
+ border-radius: $radius-rounded;
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_mixins.scss b/packages/merchant-backoffice-ui/src/scss/_mixins.scss
new file mode 100644
index 000000000..f119ec68a
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_mixins.scss
@@ -0,0 +1,34 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+@mixin transition($t) {
+ transition: $t 250ms ease-in-out 50ms;
+}
+
+@mixin icon-with-update-mark($icon-base-width) {
+ .icon {
+ width: $icon-base-width;
+
+ &.has-update-mark:after {
+ right: calc($icon-base-width / 2) - 0.85;
+ }
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_modal.scss b/packages/merchant-backoffice-ui/src/scss/_modal.scss
new file mode 100644
index 000000000..d2565e7c7
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_modal.scss
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+.modal-card {
+ width: $modal-card-width;
+}
+
+.modal-card-foot {
+ background-color: $modal-card-foot-background-color;
+}
+
+@include mobile {
+ .modal .animation-content .modal-card {
+ width: $modal-card-width-mobile;
+ margin: 0 auto;
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_nav-bar.scss b/packages/merchant-backoffice-ui/src/scss/_nav-bar.scss
new file mode 100644
index 000000000..4c0e2f5cc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_nav-bar.scss
@@ -0,0 +1,144 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+nav.navbar {
+ box-shadow: $navbar-box-shadow;
+
+ .navbar-item {
+ &.has-user-avatar {
+ .is-user-avatar {
+ margin-right: $default-padding * 0.5;
+ display: inline-flex;
+ width: $navbar-avatar-size;
+ height: $navbar-avatar-size;
+ }
+ }
+
+ &.has-divider {
+ border-right: $navbar-divider-border;
+ }
+
+ &.no-left-space {
+ padding-left: 0;
+ }
+
+ &.has-dropdown {
+ padding-right: 0;
+ padding-left: 0;
+
+ .navbar-link {
+ padding-right: $navbar-item-h-padding;
+ padding-left: $navbar-item-h-padding;
+ }
+ }
+
+ &.has-control {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ .control {
+ .input {
+ color: $navbar-input-color;
+ border: 0;
+ box-shadow: none;
+ background: transparent;
+
+ &::placeholder {
+ color: $navbar-input-placeholder-color;
+ }
+ }
+ }
+ }
+}
+
+@include touch {
+ nav.navbar {
+ display: flex;
+ padding-right: 0;
+
+ .navbar-brand {
+ flex: 1;
+
+ &.is-right {
+ flex: none;
+ }
+ }
+
+ .navbar-item {
+ &.no-left-space-touch {
+ padding-left: 0;
+ }
+ }
+
+ .navbar-menu {
+ position: absolute;
+ width: 100vw;
+ padding-top: 0;
+ top: $navbar-height;
+ left: 0;
+
+ .navbar-item {
+ .icon:first-child {
+ margin-right: $default-padding * 0.5;
+ }
+
+ &.has-dropdown {
+ > .navbar-link {
+ background-color: $white-ter;
+ .icon:last-child {
+ display: none;
+ }
+ }
+ }
+
+ &.has-user-avatar {
+ > .navbar-link {
+ display: flex;
+ align-items: center;
+ padding-top: $default-padding * 0.5;
+ padding-bottom: $default-padding * 0.5;
+ }
+ }
+ }
+ }
+ }
+}
+
+@include desktop {
+ nav.navbar {
+ .navbar-item {
+ padding-right: $navbar-item-h-padding;
+ padding-left: $navbar-item-h-padding;
+
+ &:not(.is-desktop-icon-only) {
+ .icon:first-child {
+ margin-right: $default-padding * 0.5;
+ }
+ }
+ &.is-desktop-icon-only {
+ span:not(.icon) {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_table.scss b/packages/merchant-backoffice-ui/src/scss/_table.scss
new file mode 100644
index 000000000..6c7765a74
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_table.scss
@@ -0,0 +1,179 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+table.table {
+ thead {
+ th {
+ border-bottom-width: 1px;
+ }
+ }
+
+ td,
+ th {
+ &.checkbox-cell {
+ .b-checkbox.checkbox:not(.button) {
+ margin-right: 0;
+ width: 20px;
+
+ .control-label {
+ display: none;
+ padding: 0;
+ }
+ }
+ }
+ }
+
+ td {
+ .image {
+ margin: 0 auto;
+ width: $table-avatar-size;
+ height: $table-avatar-size;
+ }
+
+ &.is-progress-col {
+ min-width: 5rem;
+ vertical-align: middle;
+ }
+ }
+}
+
+.b-table {
+ .table {
+ border: 0;
+ border-radius: 0;
+ }
+
+ /* This stylizes buefy's pagination */
+ .table-wrapper {
+ margin-bottom: 0;
+ }
+
+ .table-wrapper + .level {
+ padding: $notification-padding;
+ padding-left: $card-content-padding;
+ padding-right: $card-content-padding;
+ margin: 0;
+ border-top: $base-color-light;
+ background: $notification-background-color;
+
+ .pagination-link {
+ background: $button-background-color;
+ color: $button-color;
+ border-color: $button-border-color;
+
+ &.is-current {
+ border-color: $button-active-border-color;
+ }
+ }
+
+ .pagination-previous,
+ .pagination-next,
+ .pagination-link {
+ border-color: $button-border-color;
+ color: $base-color;
+
+ &[disabled] {
+ background-color: transparent;
+ }
+ }
+ }
+}
+
+@include mobile {
+ .card {
+ &.has-table {
+ .b-table {
+ .table-wrapper + .level {
+ .level-left + .level-right {
+ margin-top: 0;
+ }
+ }
+ }
+ }
+ &.has-mobile-sort-spaced {
+ .b-table {
+ .field.table-mobile-sort {
+ padding-top: $default-padding * 0.5;
+ }
+ }
+ }
+ }
+ .b-table {
+ .field.table-mobile-sort {
+ padding: 0 $default-padding * 0.5;
+ }
+
+ .table-wrapper.has-mobile-cards {
+ tr {
+ box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1);
+ margin-bottom: 3px !important;
+ }
+ td {
+ &.is-progress-col {
+ span,
+ progress {
+ display: flex;
+ width: 45%;
+ align-items: center;
+ align-self: center;
+ }
+ }
+
+ &.checkbox-cell,
+ &.is-image-cell {
+ border-bottom: 0 !important;
+ }
+
+ &.checkbox-cell,
+ &.is-actions-cell {
+ &:before {
+ display: none;
+ }
+ }
+
+ &.has-no-head-mobile {
+ &:before {
+ display: none;
+ }
+
+ span {
+ display: block;
+ width: 100%;
+ }
+
+ &.is-progress-col {
+ progress {
+ width: 100%;
+ }
+ }
+
+ &.is-image-cell {
+ .image {
+ width: $table-avatar-size-mobile;
+ height: auto;
+ margin: 0 auto $default-padding * 0.25;
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_theme-default.scss b/packages/merchant-backoffice-ui/src/scss/_theme-default.scss
new file mode 100644
index 000000000..f34497bde
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_theme-default.scss
@@ -0,0 +1,136 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+/* We'll need some initial vars to use here */
+@import "node_modules/bulma/sass/utilities/initial-variables";
+
+/* Base: Size */
+$size-base: 1rem;
+$default-padding: $size-base * 1.5;
+
+/* Default font */
+$family-sans-serif: "Nunito", sans-serif;
+
+/* Base color */
+$base-color: #2e323a;
+$base-color-light: rgba(24, 28, 33, 0.06);
+
+/* General overrides */
+$primary: $turquoise;
+$body-background-color: #f8f8f8;
+$link: $blue;
+$link-visited: $purple;
+$light-border: 1px solid $base-color-light;
+$hr-height: 1px;
+
+/* NavBar: specifics */
+$navbar-input-color: $grey-darker;
+$navbar-input-placeholder-color: $grey-lighter;
+$navbar-box-shadow: 0 1px 0 rgba(24, 28, 33, 0.04);
+$navbar-divider-border: 1px solid rgba($grey-lighter, 0.25);
+$navbar-item-h-padding: $default-padding * 0.75;
+$navbar-avatar-size: 1.75rem;
+
+/* Aside: Bulma override */
+$menu-item-radius: 0;
+$menu-list-link-padding: $size-base * 0.5 0;
+$menu-label-color: lighten($base-color, 25%);
+$menu-item-color: lighten($base-color, 30%);
+$menu-item-hover-color: $white;
+$menu-item-hover-background-color: darken($base-color, 3.5%);
+$menu-item-active-color: $white;
+$menu-item-active-background-color: darken($base-color, 2.5%);
+
+/* Aside: specifics */
+$aside-width: $size-base * 14;
+$aside-mobile-width: $size-base * 15;
+$aside-icon-width: $size-base * 3;
+$aside-submenu-font-size: $size-base * 0.95;
+$aside-box-shadow: none;
+$aside-background-color: $base-color;
+$aside-tools-background-color: darken($aside-background-color, 10%);
+$aside-tools-color: $white;
+
+/* Title Bar: specifics */
+$title-bar-color: $grey;
+$title-bar-active-color: $black-ter;
+
+/* Hero Bar: specifics */
+$hero-bar-background: $white;
+
+/* Card: Bulma override */
+$card-shadow: none;
+$card-header-shadow: none;
+
+/* Card: specifics */
+$card-border: 1px solid $base-color-light;
+$card-header-border-bottom-color: $base-color-light;
+
+/* Table: Bulma override */
+$table-cell-border: 1px solid $white-bis;
+
+/* Table: specifics */
+$table-avatar-size: $size-base * 1.5;
+$table-avatar-size-mobile: 25vw;
+
+/* Form */
+$checkbox-border: 1px solid $base-color;
+
+/* Modal card: Bulma override */
+$modal-card-head-background-color: $white-ter;
+$modal-card-title-size: $size-base;
+$modal-card-body-padding: $default-padding 20px;
+$modal-card-head-border-bottom: 1px solid $white-ter;
+$modal-card-foot-border-top: 0;
+
+/* Modal card: specifics */
+$modal-card-width: 80vw;
+$modal-card-width-mobile: 90vw;
+$modal-card-foot-background-color: $white-ter;
+
+/* Notification: Bulma override */
+$notification-padding: $default-padding * 0.75 $default-padding;
+
+/* Footer: Bulma override */
+$footer-background-color: $white;
+$footer-padding: $default-padding * 0.33 $default-padding;
+
+/* Footer: specifics */
+$footer-logo-height: $size-base * 2;
+
+/* Progress: Bulma override */
+$progress-bar-background-color: $grey-lighter;
+
+/* Icon: specifics */
+$icon-update-mark-size: $size-base * 0.5;
+$icon-update-mark-color: $yellow;
+
+$input-disabled-border-color: $grey-lighter;
+$table-row-hover-background-color: hsl(0, 0%, 80%);
+
+.menu-list {
+ div {
+ border-radius: $menu-item-radius;
+ color: $menu-item-color;
+ display: block;
+ padding: $menu-list-link-padding;
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_tiles.scss b/packages/merchant-backoffice-ui/src/scss/_tiles.scss
new file mode 100644
index 000000000..75bc6b94e
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_tiles.scss
@@ -0,0 +1,24 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+.is-tiles-wrapper {
+ margin-bottom: $default-padding;
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/_title-bar.scss b/packages/merchant-backoffice-ui/src/scss/_title-bar.scss
new file mode 100644
index 000000000..5de384a32
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/_title-bar.scss
@@ -0,0 +1,50 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+section.section.is-title-bar {
+ padding: $default-padding;
+ border-bottom: $light-border;
+
+ ul {
+ li {
+ display: inline-block;
+ padding: 0 $default-padding * 0.5 0 0;
+ font-size: $default-padding;
+ color: $title-bar-color;
+
+ &:after {
+ display: inline-block;
+ content: "/";
+ padding-left: $default-padding * 0.5;
+ }
+
+ &:last-child {
+ padding-right: 0;
+ font-weight: 900;
+ color: $title-bar-active-color;
+
+ &:after {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/fonts/XRXV3I6Li01BKofINeaE.ttf b/packages/merchant-backoffice-ui/src/scss/fonts/XRXV3I6Li01BKofINeaE.ttf
new file mode 100644
index 000000000..7665ee336
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/fonts/XRXV3I6Li01BKofINeaE.ttf
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/scss/fonts/nunito.css b/packages/merchant-backoffice-ui/src/scss/fonts/nunito.css
new file mode 100644
index 000000000..591fc3da2
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/fonts/nunito.css
@@ -0,0 +1,22 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+@font-face {
+ font-family: "Nunito";
+ font-style: normal;
+ font-weight: 400;
+ src: url(./XRXV3I6Li01BKofINeaE.ttf) format("truetype");
+}
diff --git a/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eot b/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eot
new file mode 100644
index 000000000..ab6b25ded
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.eot
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttf b/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttf
new file mode 100644
index 000000000..824be10fa
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.ttf
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff b/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff
new file mode 100644
index 000000000..7e087c1de
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2 b/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2
new file mode 100644
index 000000000..b5caa4ddc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/icons/fonts/materialdesignicons-webfont-4.9.95.woff2
Binary files differ
diff --git a/packages/merchant-backoffice-ui/src/scss/icons/materialdesignicons-4.9.95.min.css b/packages/merchant-backoffice-ui/src/scss/icons/materialdesignicons-4.9.95.min.css
new file mode 100644
index 000000000..2b8a2b244
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/icons/materialdesignicons-4.9.95.min.css
@@ -0,0 +1,15109 @@
+@font-face {
+ font-family: "Material Design Icons";
+ src: url("./fonts/materialdesignicons-webfont-4.9.95.eot");
+ src: url("./fonts/materialdesignicons-webfont-4.9.95.woff2") format("woff2"),
+ url("./fonts/materialdesignicons-webfont-4.9.95.woff") format("woff"),
+ url("./fonts/materialdesignicons-webfont-4.9.95.ttf") format("truetype");
+ font-weight: normal;
+ font-style: normal;
+}
+.mdi:before,
+.mdi-set {
+ display: inline-block;
+ font: normal normal normal 24px/1 "Material Design Icons";
+ font-size: inherit;
+ text-rendering: auto;
+ line-height: inherit;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+.mdi-ab-testing::before {
+ content: "\F001C";
+}
+.mdi-abjad-arabic::before {
+ content: "\F0353";
+}
+.mdi-abjad-hebrew::before {
+ content: "\F0354";
+}
+.mdi-abugida-devanagari::before {
+ content: "\F0355";
+}
+.mdi-abugida-thai::before {
+ content: "\F0356";
+}
+.mdi-access-point::before {
+ content: "\F002";
+}
+.mdi-access-point-network::before {
+ content: "\F003";
+}
+.mdi-access-point-network-off::before {
+ content: "\FBBD";
+}
+.mdi-account::before {
+ content: "\F004";
+}
+.mdi-account-alert::before {
+ content: "\F005";
+}
+.mdi-account-alert-outline::before {
+ content: "\FB2C";
+}
+.mdi-account-arrow-left::before {
+ content: "\FB2D";
+}
+.mdi-account-arrow-left-outline::before {
+ content: "\FB2E";
+}
+.mdi-account-arrow-right::before {
+ content: "\FB2F";
+}
+.mdi-account-arrow-right-outline::before {
+ content: "\FB30";
+}
+.mdi-account-badge::before {
+ content: "\FD83";
+}
+.mdi-account-badge-alert::before {
+ content: "\FD84";
+}
+.mdi-account-badge-alert-outline::before {
+ content: "\FD85";
+}
+.mdi-account-badge-horizontal::before {
+ content: "\FDF0";
+}
+.mdi-account-badge-horizontal-outline::before {
+ content: "\FDF1";
+}
+.mdi-account-badge-outline::before {
+ content: "\FD86";
+}
+.mdi-account-box::before {
+ content: "\F006";
+}
+.mdi-account-box-multiple::before {
+ content: "\F933";
+}
+.mdi-account-box-multiple-outline::before {
+ content: "\F002C";
+}
+.mdi-account-box-outline::before {
+ content: "\F007";
+}
+.mdi-account-cancel::before {
+ content: "\F030A";
+}
+.mdi-account-cancel-outline::before {
+ content: "\F030B";
+}
+.mdi-account-card-details::before {
+ content: "\F5D2";
+}
+.mdi-account-card-details-outline::before {
+ content: "\FD87";
+}
+.mdi-account-cash::before {
+ content: "\F00C2";
+}
+.mdi-account-cash-outline::before {
+ content: "\F00C3";
+}
+.mdi-account-check::before {
+ content: "\F008";
+}
+.mdi-account-check-outline::before {
+ content: "\FBBE";
+}
+.mdi-account-child::before {
+ content: "\FA88";
+}
+.mdi-account-child-circle::before {
+ content: "\FA89";
+}
+.mdi-account-child-outline::before {
+ content: "\F00F3";
+}
+.mdi-account-circle::before {
+ content: "\F009";
+}
+.mdi-account-circle-outline::before {
+ content: "\FB31";
+}
+.mdi-account-clock::before {
+ content: "\FB32";
+}
+.mdi-account-clock-outline::before {
+ content: "\FB33";
+}
+.mdi-account-cog::before {
+ content: "\F039B";
+}
+.mdi-account-cog-outline::before {
+ content: "\F039C";
+}
+.mdi-account-convert::before {
+ content: "\F00A";
+}
+.mdi-account-convert-outline::before {
+ content: "\F032C";
+}
+.mdi-account-details::before {
+ content: "\F631";
+}
+.mdi-account-details-outline::before {
+ content: "\F039D";
+}
+.mdi-account-edit::before {
+ content: "\F6BB";
+}
+.mdi-account-edit-outline::before {
+ content: "\F001D";
+}
+.mdi-account-group::before {
+ content: "\F848";
+}
+.mdi-account-group-outline::before {
+ content: "\FB34";
+}
+.mdi-account-heart::before {
+ content: "\F898";
+}
+.mdi-account-heart-outline::before {
+ content: "\FBBF";
+}
+.mdi-account-key::before {
+ content: "\F00B";
+}
+.mdi-account-key-outline::before {
+ content: "\FBC0";
+}
+.mdi-account-lock::before {
+ content: "\F0189";
+}
+.mdi-account-lock-outline::before {
+ content: "\F018A";
+}
+.mdi-account-minus::before {
+ content: "\F00D";
+}
+.mdi-account-minus-outline::before {
+ content: "\FAEB";
+}
+.mdi-account-multiple::before {
+ content: "\F00E";
+}
+.mdi-account-multiple-check::before {
+ content: "\F8C4";
+}
+.mdi-account-multiple-check-outline::before {
+ content: "\F0229";
+}
+.mdi-account-multiple-minus::before {
+ content: "\F5D3";
+}
+.mdi-account-multiple-minus-outline::before {
+ content: "\FBC1";
+}
+.mdi-account-multiple-outline::before {
+ content: "\F00F";
+}
+.mdi-account-multiple-plus::before {
+ content: "\F010";
+}
+.mdi-account-multiple-plus-outline::before {
+ content: "\F7FF";
+}
+.mdi-account-multiple-remove::before {
+ content: "\F0235";
+}
+.mdi-account-multiple-remove-outline::before {
+ content: "\F0236";
+}
+.mdi-account-network::before {
+ content: "\F011";
+}
+.mdi-account-network-outline::before {
+ content: "\FBC2";
+}
+.mdi-account-off::before {
+ content: "\F012";
+}
+.mdi-account-off-outline::before {
+ content: "\FBC3";
+}
+.mdi-account-outline::before {
+ content: "\F013";
+}
+.mdi-account-plus::before {
+ content: "\F014";
+}
+.mdi-account-plus-outline::before {
+ content: "\F800";
+}
+.mdi-account-question::before {
+ content: "\FB35";
+}
+.mdi-account-question-outline::before {
+ content: "\FB36";
+}
+.mdi-account-remove::before {
+ content: "\F015";
+}
+.mdi-account-remove-outline::before {
+ content: "\FAEC";
+}
+.mdi-account-search::before {
+ content: "\F016";
+}
+.mdi-account-search-outline::before {
+ content: "\F934";
+}
+.mdi-account-settings::before {
+ content: "\F630";
+}
+.mdi-account-settings-outline::before {
+ content: "\F00F4";
+}
+.mdi-account-star::before {
+ content: "\F017";
+}
+.mdi-account-star-outline::before {
+ content: "\FBC4";
+}
+.mdi-account-supervisor::before {
+ content: "\FA8A";
+}
+.mdi-account-supervisor-circle::before {
+ content: "\FA8B";
+}
+.mdi-account-supervisor-outline::before {
+ content: "\F0158";
+}
+.mdi-account-switch::before {
+ content: "\F019";
+}
+.mdi-account-tie::before {
+ content: "\FCBF";
+}
+.mdi-account-tie-outline::before {
+ content: "\F00F5";
+}
+.mdi-account-tie-voice::before {
+ content: "\F0333";
+}
+.mdi-account-tie-voice-off::before {
+ content: "\F0335";
+}
+.mdi-account-tie-voice-off-outline::before {
+ content: "\F0336";
+}
+.mdi-account-tie-voice-outline::before {
+ content: "\F0334";
+}
+.mdi-accusoft::before {
+ content: "\F849";
+}
+.mdi-adjust::before {
+ content: "\F01A";
+}
+.mdi-adobe::before {
+ content: "\F935";
+}
+.mdi-adobe-acrobat::before {
+ content: "\FFBD";
+}
+.mdi-air-conditioner::before {
+ content: "\F01B";
+}
+.mdi-air-filter::before {
+ content: "\FD1F";
+}
+.mdi-air-horn::before {
+ content: "\FD88";
+}
+.mdi-air-humidifier::before {
+ content: "\F00C4";
+}
+.mdi-air-purifier::before {
+ content: "\FD20";
+}
+.mdi-airbag::before {
+ content: "\FBC5";
+}
+.mdi-airballoon::before {
+ content: "\F01C";
+}
+.mdi-airballoon-outline::before {
+ content: "\F002D";
+}
+.mdi-airplane::before {
+ content: "\F01D";
+}
+.mdi-airplane-landing::before {
+ content: "\F5D4";
+}
+.mdi-airplane-off::before {
+ content: "\F01E";
+}
+.mdi-airplane-takeoff::before {
+ content: "\F5D5";
+}
+.mdi-airplay::before {
+ content: "\F01F";
+}
+.mdi-airport::before {
+ content: "\F84A";
+}
+.mdi-alarm::before {
+ content: "\F020";
+}
+.mdi-alarm-bell::before {
+ content: "\F78D";
+}
+.mdi-alarm-check::before {
+ content: "\F021";
+}
+.mdi-alarm-light::before {
+ content: "\F78E";
+}
+.mdi-alarm-light-outline::before {
+ content: "\FBC6";
+}
+.mdi-alarm-multiple::before {
+ content: "\F022";
+}
+.mdi-alarm-note::before {
+ content: "\FE8E";
+}
+.mdi-alarm-note-off::before {
+ content: "\FE8F";
+}
+.mdi-alarm-off::before {
+ content: "\F023";
+}
+.mdi-alarm-plus::before {
+ content: "\F024";
+}
+.mdi-alarm-snooze::before {
+ content: "\F68D";
+}
+.mdi-album::before {
+ content: "\F025";
+}
+.mdi-alert::before {
+ content: "\F026";
+}
+.mdi-alert-box::before {
+ content: "\F027";
+}
+.mdi-alert-box-outline::before {
+ content: "\FCC0";
+}
+.mdi-alert-circle::before {
+ content: "\F028";
+}
+.mdi-alert-circle-check::before {
+ content: "\F0218";
+}
+.mdi-alert-circle-check-outline::before {
+ content: "\F0219";
+}
+.mdi-alert-circle-outline::before {
+ content: "\F5D6";
+}
+.mdi-alert-decagram::before {
+ content: "\F6BC";
+}
+.mdi-alert-decagram-outline::before {
+ content: "\FCC1";
+}
+.mdi-alert-octagon::before {
+ content: "\F029";
+}
+.mdi-alert-octagon-outline::before {
+ content: "\FCC2";
+}
+.mdi-alert-octagram::before {
+ content: "\F766";
+}
+.mdi-alert-octagram-outline::before {
+ content: "\FCC3";
+}
+.mdi-alert-outline::before {
+ content: "\F02A";
+}
+.mdi-alert-rhombus::before {
+ content: "\F01F9";
+}
+.mdi-alert-rhombus-outline::before {
+ content: "\F01FA";
+}
+.mdi-alien::before {
+ content: "\F899";
+}
+.mdi-alien-outline::before {
+ content: "\F00F6";
+}
+.mdi-align-horizontal-center::before {
+ content: "\F01EE";
+}
+.mdi-align-horizontal-left::before {
+ content: "\F01ED";
+}
+.mdi-align-horizontal-right::before {
+ content: "\F01EF";
+}
+.mdi-align-vertical-bottom::before {
+ content: "\F01F0";
+}
+.mdi-align-vertical-center::before {
+ content: "\F01F1";
+}
+.mdi-align-vertical-top::before {
+ content: "\F01F2";
+}
+.mdi-all-inclusive::before {
+ content: "\F6BD";
+}
+.mdi-allergy::before {
+ content: "\F0283";
+}
+.mdi-alpha::before {
+ content: "\F02B";
+}
+.mdi-alpha-a::before {
+ content: "\41";
+}
+.mdi-alpha-a-box::before {
+ content: "\FAED";
+}
+.mdi-alpha-a-box-outline::before {
+ content: "\FBC7";
+}
+.mdi-alpha-a-circle::before {
+ content: "\FBC8";
+}
+.mdi-alpha-a-circle-outline::before {
+ content: "\FBC9";
+}
+.mdi-alpha-b::before {
+ content: "\42";
+}
+.mdi-alpha-b-box::before {
+ content: "\FAEE";
+}
+.mdi-alpha-b-box-outline::before {
+ content: "\FBCA";
+}
+.mdi-alpha-b-circle::before {
+ content: "\FBCB";
+}
+.mdi-alpha-b-circle-outline::before {
+ content: "\FBCC";
+}
+.mdi-alpha-c::before {
+ content: "\43";
+}
+.mdi-alpha-c-box::before {
+ content: "\FAEF";
+}
+.mdi-alpha-c-box-outline::before {
+ content: "\FBCD";
+}
+.mdi-alpha-c-circle::before {
+ content: "\FBCE";
+}
+.mdi-alpha-c-circle-outline::before {
+ content: "\FBCF";
+}
+.mdi-alpha-d::before {
+ content: "\44";
+}
+.mdi-alpha-d-box::before {
+ content: "\FAF0";
+}
+.mdi-alpha-d-box-outline::before {
+ content: "\FBD0";
+}
+.mdi-alpha-d-circle::before {
+ content: "\FBD1";
+}
+.mdi-alpha-d-circle-outline::before {
+ content: "\FBD2";
+}
+.mdi-alpha-e::before {
+ content: "\45";
+}
+.mdi-alpha-e-box::before {
+ content: "\FAF1";
+}
+.mdi-alpha-e-box-outline::before {
+ content: "\FBD3";
+}
+.mdi-alpha-e-circle::before {
+ content: "\FBD4";
+}
+.mdi-alpha-e-circle-outline::before {
+ content: "\FBD5";
+}
+.mdi-alpha-f::before {
+ content: "\46";
+}
+.mdi-alpha-f-box::before {
+ content: "\FAF2";
+}
+.mdi-alpha-f-box-outline::before {
+ content: "\FBD6";
+}
+.mdi-alpha-f-circle::before {
+ content: "\FBD7";
+}
+.mdi-alpha-f-circle-outline::before {
+ content: "\FBD8";
+}
+.mdi-alpha-g::before {
+ content: "\47";
+}
+.mdi-alpha-g-box::before {
+ content: "\FAF3";
+}
+.mdi-alpha-g-box-outline::before {
+ content: "\FBD9";
+}
+.mdi-alpha-g-circle::before {
+ content: "\FBDA";
+}
+.mdi-alpha-g-circle-outline::before {
+ content: "\FBDB";
+}
+.mdi-alpha-h::before {
+ content: "\48";
+}
+.mdi-alpha-h-box::before {
+ content: "\FAF4";
+}
+.mdi-alpha-h-box-outline::before {
+ content: "\FBDC";
+}
+.mdi-alpha-h-circle::before {
+ content: "\FBDD";
+}
+.mdi-alpha-h-circle-outline::before {
+ content: "\FBDE";
+}
+.mdi-alpha-i::before {
+ content: "\49";
+}
+.mdi-alpha-i-box::before {
+ content: "\FAF5";
+}
+.mdi-alpha-i-box-outline::before {
+ content: "\FBDF";
+}
+.mdi-alpha-i-circle::before {
+ content: "\FBE0";
+}
+.mdi-alpha-i-circle-outline::before {
+ content: "\FBE1";
+}
+.mdi-alpha-j::before {
+ content: "\4A";
+}
+.mdi-alpha-j-box::before {
+ content: "\FAF6";
+}
+.mdi-alpha-j-box-outline::before {
+ content: "\FBE2";
+}
+.mdi-alpha-j-circle::before {
+ content: "\FBE3";
+}
+.mdi-alpha-j-circle-outline::before {
+ content: "\FBE4";
+}
+.mdi-alpha-k::before {
+ content: "\4B";
+}
+.mdi-alpha-k-box::before {
+ content: "\FAF7";
+}
+.mdi-alpha-k-box-outline::before {
+ content: "\FBE5";
+}
+.mdi-alpha-k-circle::before {
+ content: "\FBE6";
+}
+.mdi-alpha-k-circle-outline::before {
+ content: "\FBE7";
+}
+.mdi-alpha-l::before {
+ content: "\4C";
+}
+.mdi-alpha-l-box::before {
+ content: "\FAF8";
+}
+.mdi-alpha-l-box-outline::before {
+ content: "\FBE8";
+}
+.mdi-alpha-l-circle::before {
+ content: "\FBE9";
+}
+.mdi-alpha-l-circle-outline::before {
+ content: "\FBEA";
+}
+.mdi-alpha-m::before {
+ content: "\4D";
+}
+.mdi-alpha-m-box::before {
+ content: "\FAF9";
+}
+.mdi-alpha-m-box-outline::before {
+ content: "\FBEB";
+}
+.mdi-alpha-m-circle::before {
+ content: "\FBEC";
+}
+.mdi-alpha-m-circle-outline::before {
+ content: "\FBED";
+}
+.mdi-alpha-n::before {
+ content: "\4E";
+}
+.mdi-alpha-n-box::before {
+ content: "\FAFA";
+}
+.mdi-alpha-n-box-outline::before {
+ content: "\FBEE";
+}
+.mdi-alpha-n-circle::before {
+ content: "\FBEF";
+}
+.mdi-alpha-n-circle-outline::before {
+ content: "\FBF0";
+}
+.mdi-alpha-o::before {
+ content: "\4F";
+}
+.mdi-alpha-o-box::before {
+ content: "\FAFB";
+}
+.mdi-alpha-o-box-outline::before {
+ content: "\FBF1";
+}
+.mdi-alpha-o-circle::before {
+ content: "\FBF2";
+}
+.mdi-alpha-o-circle-outline::before {
+ content: "\FBF3";
+}
+.mdi-alpha-p::before {
+ content: "\50";
+}
+.mdi-alpha-p-box::before {
+ content: "\FAFC";
+}
+.mdi-alpha-p-box-outline::before {
+ content: "\FBF4";
+}
+.mdi-alpha-p-circle::before {
+ content: "\FBF5";
+}
+.mdi-alpha-p-circle-outline::before {
+ content: "\FBF6";
+}
+.mdi-alpha-q::before {
+ content: "\51";
+}
+.mdi-alpha-q-box::before {
+ content: "\FAFD";
+}
+.mdi-alpha-q-box-outline::before {
+ content: "\FBF7";
+}
+.mdi-alpha-q-circle::before {
+ content: "\FBF8";
+}
+.mdi-alpha-q-circle-outline::before {
+ content: "\FBF9";
+}
+.mdi-alpha-r::before {
+ content: "\52";
+}
+.mdi-alpha-r-box::before {
+ content: "\FAFE";
+}
+.mdi-alpha-r-box-outline::before {
+ content: "\FBFA";
+}
+.mdi-alpha-r-circle::before {
+ content: "\FBFB";
+}
+.mdi-alpha-r-circle-outline::before {
+ content: "\FBFC";
+}
+.mdi-alpha-s::before {
+ content: "\53";
+}
+.mdi-alpha-s-box::before {
+ content: "\FAFF";
+}
+.mdi-alpha-s-box-outline::before {
+ content: "\FBFD";
+}
+.mdi-alpha-s-circle::before {
+ content: "\FBFE";
+}
+.mdi-alpha-s-circle-outline::before {
+ content: "\FBFF";
+}
+.mdi-alpha-t::before {
+ content: "\54";
+}
+.mdi-alpha-t-box::before {
+ content: "\FB00";
+}
+.mdi-alpha-t-box-outline::before {
+ content: "\FC00";
+}
+.mdi-alpha-t-circle::before {
+ content: "\FC01";
+}
+.mdi-alpha-t-circle-outline::before {
+ content: "\FC02";
+}
+.mdi-alpha-u::before {
+ content: "\55";
+}
+.mdi-alpha-u-box::before {
+ content: "\FB01";
+}
+.mdi-alpha-u-box-outline::before {
+ content: "\FC03";
+}
+.mdi-alpha-u-circle::before {
+ content: "\FC04";
+}
+.mdi-alpha-u-circle-outline::before {
+ content: "\FC05";
+}
+.mdi-alpha-v::before {
+ content: "\56";
+}
+.mdi-alpha-v-box::before {
+ content: "\FB02";
+}
+.mdi-alpha-v-box-outline::before {
+ content: "\FC06";
+}
+.mdi-alpha-v-circle::before {
+ content: "\FC07";
+}
+.mdi-alpha-v-circle-outline::before {
+ content: "\FC08";
+}
+.mdi-alpha-w::before {
+ content: "\57";
+}
+.mdi-alpha-w-box::before {
+ content: "\FB03";
+}
+.mdi-alpha-w-box-outline::before {
+ content: "\FC09";
+}
+.mdi-alpha-w-circle::before {
+ content: "\FC0A";
+}
+.mdi-alpha-w-circle-outline::before {
+ content: "\FC0B";
+}
+.mdi-alpha-x::before {
+ content: "\58";
+}
+.mdi-alpha-x-box::before {
+ content: "\FB04";
+}
+.mdi-alpha-x-box-outline::before {
+ content: "\FC0C";
+}
+.mdi-alpha-x-circle::before {
+ content: "\FC0D";
+}
+.mdi-alpha-x-circle-outline::before {
+ content: "\FC0E";
+}
+.mdi-alpha-y::before {
+ content: "\59";
+}
+.mdi-alpha-y-box::before {
+ content: "\FB05";
+}
+.mdi-alpha-y-box-outline::before {
+ content: "\FC0F";
+}
+.mdi-alpha-y-circle::before {
+ content: "\FC10";
+}
+.mdi-alpha-y-circle-outline::before {
+ content: "\FC11";
+}
+.mdi-alpha-z::before {
+ content: "\5A";
+}
+.mdi-alpha-z-box::before {
+ content: "\FB06";
+}
+.mdi-alpha-z-box-outline::before {
+ content: "\FC12";
+}
+.mdi-alpha-z-circle::before {
+ content: "\FC13";
+}
+.mdi-alpha-z-circle-outline::before {
+ content: "\FC14";
+}
+.mdi-alphabet-aurebesh::before {
+ content: "\F0357";
+}
+.mdi-alphabet-cyrillic::before {
+ content: "\F0358";
+}
+.mdi-alphabet-greek::before {
+ content: "\F0359";
+}
+.mdi-alphabet-latin::before {
+ content: "\F035A";
+}
+.mdi-alphabet-piqad::before {
+ content: "\F035B";
+}
+.mdi-alphabet-tengwar::before {
+ content: "\F0362";
+}
+.mdi-alphabetical::before {
+ content: "\F02C";
+}
+.mdi-alphabetical-off::before {
+ content: "\F002E";
+}
+.mdi-alphabetical-variant::before {
+ content: "\F002F";
+}
+.mdi-alphabetical-variant-off::before {
+ content: "\F0030";
+}
+.mdi-altimeter::before {
+ content: "\F5D7";
+}
+.mdi-amazon::before {
+ content: "\F02D";
+}
+.mdi-amazon-alexa::before {
+ content: "\F8C5";
+}
+.mdi-amazon-drive::before {
+ content: "\F02E";
+}
+.mdi-ambulance::before {
+ content: "\F02F";
+}
+.mdi-ammunition::before {
+ content: "\FCC4";
+}
+.mdi-ampersand::before {
+ content: "\FA8C";
+}
+.mdi-amplifier::before {
+ content: "\F030";
+}
+.mdi-amplifier-off::before {
+ content: "\F01E0";
+}
+.mdi-anchor::before {
+ content: "\F031";
+}
+.mdi-android::before {
+ content: "\F032";
+}
+.mdi-android-auto::before {
+ content: "\FA8D";
+}
+.mdi-android-debug-bridge::before {
+ content: "\F033";
+}
+.mdi-android-head::before {
+ content: "\F78F";
+}
+.mdi-android-messages::before {
+ content: "\FD21";
+}
+.mdi-android-studio::before {
+ content: "\F034";
+}
+.mdi-angle-acute::before {
+ content: "\F936";
+}
+.mdi-angle-obtuse::before {
+ content: "\F937";
+}
+.mdi-angle-right::before {
+ content: "\F938";
+}
+.mdi-angular::before {
+ content: "\F6B1";
+}
+.mdi-angularjs::before {
+ content: "\F6BE";
+}
+.mdi-animation::before {
+ content: "\F5D8";
+}
+.mdi-animation-outline::before {
+ content: "\FA8E";
+}
+.mdi-animation-play::before {
+ content: "\F939";
+}
+.mdi-animation-play-outline::before {
+ content: "\FA8F";
+}
+.mdi-ansible::before {
+ content: "\F00C5";
+}
+.mdi-antenna::before {
+ content: "\F0144";
+}
+.mdi-anvil::before {
+ content: "\F89A";
+}
+.mdi-apache-kafka::before {
+ content: "\F0031";
+}
+.mdi-api::before {
+ content: "\F00C6";
+}
+.mdi-api-off::before {
+ content: "\F0282";
+}
+.mdi-apple::before {
+ content: "\F035";
+}
+.mdi-apple-finder::before {
+ content: "\F036";
+}
+.mdi-apple-icloud::before {
+ content: "\F038";
+}
+.mdi-apple-ios::before {
+ content: "\F037";
+}
+.mdi-apple-keyboard-caps::before {
+ content: "\F632";
+}
+.mdi-apple-keyboard-command::before {
+ content: "\F633";
+}
+.mdi-apple-keyboard-control::before {
+ content: "\F634";
+}
+.mdi-apple-keyboard-option::before {
+ content: "\F635";
+}
+.mdi-apple-keyboard-shift::before {
+ content: "\F636";
+}
+.mdi-apple-safari::before {
+ content: "\F039";
+}
+.mdi-application::before {
+ content: "\F614";
+}
+.mdi-application-export::before {
+ content: "\FD89";
+}
+.mdi-application-import::before {
+ content: "\FD8A";
+}
+.mdi-approximately-equal::before {
+ content: "\FFBE";
+}
+.mdi-approximately-equal-box::before {
+ content: "\FFBF";
+}
+.mdi-apps::before {
+ content: "\F03B";
+}
+.mdi-apps-box::before {
+ content: "\FD22";
+}
+.mdi-arch::before {
+ content: "\F8C6";
+}
+.mdi-archive::before {
+ content: "\F03C";
+}
+.mdi-archive-arrow-down::before {
+ content: "\F0284";
+}
+.mdi-archive-arrow-down-outline::before {
+ content: "\F0285";
+}
+.mdi-archive-arrow-up::before {
+ content: "\F0286";
+}
+.mdi-archive-arrow-up-outline::before {
+ content: "\F0287";
+}
+.mdi-archive-outline::before {
+ content: "\F0239";
+}
+.mdi-arm-flex::before {
+ content: "\F008F";
+}
+.mdi-arm-flex-outline::before {
+ content: "\F0090";
+}
+.mdi-arrange-bring-forward::before {
+ content: "\F03D";
+}
+.mdi-arrange-bring-to-front::before {
+ content: "\F03E";
+}
+.mdi-arrange-send-backward::before {
+ content: "\F03F";
+}
+.mdi-arrange-send-to-back::before {
+ content: "\F040";
+}
+.mdi-arrow-all::before {
+ content: "\F041";
+}
+.mdi-arrow-bottom-left::before {
+ content: "\F042";
+}
+.mdi-arrow-bottom-left-bold-outline::before {
+ content: "\F9B6";
+}
+.mdi-arrow-bottom-left-thick::before {
+ content: "\F9B7";
+}
+.mdi-arrow-bottom-right::before {
+ content: "\F043";
+}
+.mdi-arrow-bottom-right-bold-outline::before {
+ content: "\F9B8";
+}
+.mdi-arrow-bottom-right-thick::before {
+ content: "\F9B9";
+}
+.mdi-arrow-collapse::before {
+ content: "\F615";
+}
+.mdi-arrow-collapse-all::before {
+ content: "\F044";
+}
+.mdi-arrow-collapse-down::before {
+ content: "\F791";
+}
+.mdi-arrow-collapse-horizontal::before {
+ content: "\F84B";
+}
+.mdi-arrow-collapse-left::before {
+ content: "\F792";
+}
+.mdi-arrow-collapse-right::before {
+ content: "\F793";
+}
+.mdi-arrow-collapse-up::before {
+ content: "\F794";
+}
+.mdi-arrow-collapse-vertical::before {
+ content: "\F84C";
+}
+.mdi-arrow-decision::before {
+ content: "\F9BA";
+}
+.mdi-arrow-decision-auto::before {
+ content: "\F9BB";
+}
+.mdi-arrow-decision-auto-outline::before {
+ content: "\F9BC";
+}
+.mdi-arrow-decision-outline::before {
+ content: "\F9BD";
+}
+.mdi-arrow-down::before {
+ content: "\F045";
+}
+.mdi-arrow-down-bold::before {
+ content: "\F72D";
+}
+.mdi-arrow-down-bold-box::before {
+ content: "\F72E";
+}
+.mdi-arrow-down-bold-box-outline::before {
+ content: "\F72F";
+}
+.mdi-arrow-down-bold-circle::before {
+ content: "\F047";
+}
+.mdi-arrow-down-bold-circle-outline::before {
+ content: "\F048";
+}
+.mdi-arrow-down-bold-hexagon-outline::before {
+ content: "\F049";
+}
+.mdi-arrow-down-bold-outline::before {
+ content: "\F9BE";
+}
+.mdi-arrow-down-box::before {
+ content: "\F6BF";
+}
+.mdi-arrow-down-circle::before {
+ content: "\FCB7";
+}
+.mdi-arrow-down-circle-outline::before {
+ content: "\FCB8";
+}
+.mdi-arrow-down-drop-circle::before {
+ content: "\F04A";
+}
+.mdi-arrow-down-drop-circle-outline::before {
+ content: "\F04B";
+}
+.mdi-arrow-down-thick::before {
+ content: "\F046";
+}
+.mdi-arrow-expand::before {
+ content: "\F616";
+}
+.mdi-arrow-expand-all::before {
+ content: "\F04C";
+}
+.mdi-arrow-expand-down::before {
+ content: "\F795";
+}
+.mdi-arrow-expand-horizontal::before {
+ content: "\F84D";
+}
+.mdi-arrow-expand-left::before {
+ content: "\F796";
+}
+.mdi-arrow-expand-right::before {
+ content: "\F797";
+}
+.mdi-arrow-expand-up::before {
+ content: "\F798";
+}
+.mdi-arrow-expand-vertical::before {
+ content: "\F84E";
+}
+.mdi-arrow-horizontal-lock::before {
+ content: "\F0186";
+}
+.mdi-arrow-left::before {
+ content: "\F04D";
+}
+.mdi-arrow-left-bold::before {
+ content: "\F730";
+}
+.mdi-arrow-left-bold-box::before {
+ content: "\F731";
+}
+.mdi-arrow-left-bold-box-outline::before {
+ content: "\F732";
+}
+.mdi-arrow-left-bold-circle::before {
+ content: "\F04F";
+}
+.mdi-arrow-left-bold-circle-outline::before {
+ content: "\F050";
+}
+.mdi-arrow-left-bold-hexagon-outline::before {
+ content: "\F051";
+}
+.mdi-arrow-left-bold-outline::before {
+ content: "\F9BF";
+}
+.mdi-arrow-left-box::before {
+ content: "\F6C0";
+}
+.mdi-arrow-left-circle::before {
+ content: "\FCB9";
+}
+.mdi-arrow-left-circle-outline::before {
+ content: "\FCBA";
+}
+.mdi-arrow-left-drop-circle::before {
+ content: "\F052";
+}
+.mdi-arrow-left-drop-circle-outline::before {
+ content: "\F053";
+}
+.mdi-arrow-left-right::before {
+ content: "\FE90";
+}
+.mdi-arrow-left-right-bold::before {
+ content: "\FE91";
+}
+.mdi-arrow-left-right-bold-outline::before {
+ content: "\F9C0";
+}
+.mdi-arrow-left-thick::before {
+ content: "\F04E";
+}
+.mdi-arrow-right::before {
+ content: "\F054";
+}
+.mdi-arrow-right-bold::before {
+ content: "\F733";
+}
+.mdi-arrow-right-bold-box::before {
+ content: "\F734";
+}
+.mdi-arrow-right-bold-box-outline::before {
+ content: "\F735";
+}
+.mdi-arrow-right-bold-circle::before {
+ content: "\F056";
+}
+.mdi-arrow-right-bold-circle-outline::before {
+ content: "\F057";
+}
+.mdi-arrow-right-bold-hexagon-outline::before {
+ content: "\F058";
+}
+.mdi-arrow-right-bold-outline::before {
+ content: "\F9C1";
+}
+.mdi-arrow-right-box::before {
+ content: "\F6C1";
+}
+.mdi-arrow-right-circle::before {
+ content: "\FCBB";
+}
+.mdi-arrow-right-circle-outline::before {
+ content: "\FCBC";
+}
+.mdi-arrow-right-drop-circle::before {
+ content: "\F059";
+}
+.mdi-arrow-right-drop-circle-outline::before {
+ content: "\F05A";
+}
+.mdi-arrow-right-thick::before {
+ content: "\F055";
+}
+.mdi-arrow-split-horizontal::before {
+ content: "\F93A";
+}
+.mdi-arrow-split-vertical::before {
+ content: "\F93B";
+}
+.mdi-arrow-top-left::before {
+ content: "\F05B";
+}
+.mdi-arrow-top-left-bold-outline::before {
+ content: "\F9C2";
+}
+.mdi-arrow-top-left-bottom-right::before {
+ content: "\FE92";
+}
+.mdi-arrow-top-left-bottom-right-bold::before {
+ content: "\FE93";
+}
+.mdi-arrow-top-left-thick::before {
+ content: "\F9C3";
+}
+.mdi-arrow-top-right::before {
+ content: "\F05C";
+}
+.mdi-arrow-top-right-bold-outline::before {
+ content: "\F9C4";
+}
+.mdi-arrow-top-right-bottom-left::before {
+ content: "\FE94";
+}
+.mdi-arrow-top-right-bottom-left-bold::before {
+ content: "\FE95";
+}
+.mdi-arrow-top-right-thick::before {
+ content: "\F9C5";
+}
+.mdi-arrow-up::before {
+ content: "\F05D";
+}
+.mdi-arrow-up-bold::before {
+ content: "\F736";
+}
+.mdi-arrow-up-bold-box::before {
+ content: "\F737";
+}
+.mdi-arrow-up-bold-box-outline::before {
+ content: "\F738";
+}
+.mdi-arrow-up-bold-circle::before {
+ content: "\F05F";
+}
+.mdi-arrow-up-bold-circle-outline::before {
+ content: "\F060";
+}
+.mdi-arrow-up-bold-hexagon-outline::before {
+ content: "\F061";
+}
+.mdi-arrow-up-bold-outline::before {
+ content: "\F9C6";
+}
+.mdi-arrow-up-box::before {
+ content: "\F6C2";
+}
+.mdi-arrow-up-circle::before {
+ content: "\FCBD";
+}
+.mdi-arrow-up-circle-outline::before {
+ content: "\FCBE";
+}
+.mdi-arrow-up-down::before {
+ content: "\FE96";
+}
+.mdi-arrow-up-down-bold::before {
+ content: "\FE97";
+}
+.mdi-arrow-up-down-bold-outline::before {
+ content: "\F9C7";
+}
+.mdi-arrow-up-drop-circle::before {
+ content: "\F062";
+}
+.mdi-arrow-up-drop-circle-outline::before {
+ content: "\F063";
+}
+.mdi-arrow-up-thick::before {
+ content: "\F05E";
+}
+.mdi-arrow-vertical-lock::before {
+ content: "\F0187";
+}
+.mdi-artist::before {
+ content: "\F802";
+}
+.mdi-artist-outline::before {
+ content: "\FCC5";
+}
+.mdi-artstation::before {
+ content: "\FB37";
+}
+.mdi-aspect-ratio::before {
+ content: "\FA23";
+}
+.mdi-assistant::before {
+ content: "\F064";
+}
+.mdi-asterisk::before {
+ content: "\F6C3";
+}
+.mdi-at::before {
+ content: "\F065";
+}
+.mdi-atlassian::before {
+ content: "\F803";
+}
+.mdi-atm::before {
+ content: "\FD23";
+}
+.mdi-atom::before {
+ content: "\F767";
+}
+.mdi-atom-variant::before {
+ content: "\FE98";
+}
+.mdi-attachment::before {
+ content: "\F066";
+}
+.mdi-audio-video::before {
+ content: "\F93C";
+}
+.mdi-audio-video-off::before {
+ content: "\F01E1";
+}
+.mdi-audiobook::before {
+ content: "\F067";
+}
+.mdi-augmented-reality::before {
+ content: "\F84F";
+}
+.mdi-auto-download::before {
+ content: "\F03A9";
+}
+.mdi-auto-fix::before {
+ content: "\F068";
+}
+.mdi-auto-upload::before {
+ content: "\F069";
+}
+.mdi-autorenew::before {
+ content: "\F06A";
+}
+.mdi-av-timer::before {
+ content: "\F06B";
+}
+.mdi-aws::before {
+ content: "\FDF2";
+}
+.mdi-axe::before {
+ content: "\F8C7";
+}
+.mdi-axis::before {
+ content: "\FD24";
+}
+.mdi-axis-arrow::before {
+ content: "\FD25";
+}
+.mdi-axis-arrow-lock::before {
+ content: "\FD26";
+}
+.mdi-axis-lock::before {
+ content: "\FD27";
+}
+.mdi-axis-x-arrow::before {
+ content: "\FD28";
+}
+.mdi-axis-x-arrow-lock::before {
+ content: "\FD29";
+}
+.mdi-axis-x-rotate-clockwise::before {
+ content: "\FD2A";
+}
+.mdi-axis-x-rotate-counterclockwise::before {
+ content: "\FD2B";
+}
+.mdi-axis-x-y-arrow-lock::before {
+ content: "\FD2C";
+}
+.mdi-axis-y-arrow::before {
+ content: "\FD2D";
+}
+.mdi-axis-y-arrow-lock::before {
+ content: "\FD2E";
+}
+.mdi-axis-y-rotate-clockwise::before {
+ content: "\FD2F";
+}
+.mdi-axis-y-rotate-counterclockwise::before {
+ content: "\FD30";
+}
+.mdi-axis-z-arrow::before {
+ content: "\FD31";
+}
+.mdi-axis-z-arrow-lock::before {
+ content: "\FD32";
+}
+.mdi-axis-z-rotate-clockwise::before {
+ content: "\FD33";
+}
+.mdi-axis-z-rotate-counterclockwise::before {
+ content: "\FD34";
+}
+.mdi-azure::before {
+ content: "\F804";
+}
+.mdi-azure-devops::before {
+ content: "\F0091";
+}
+.mdi-babel::before {
+ content: "\FA24";
+}
+.mdi-baby::before {
+ content: "\F06C";
+}
+.mdi-baby-bottle::before {
+ content: "\FF56";
+}
+.mdi-baby-bottle-outline::before {
+ content: "\FF57";
+}
+.mdi-baby-carriage::before {
+ content: "\F68E";
+}
+.mdi-baby-carriage-off::before {
+ content: "\FFC0";
+}
+.mdi-baby-face::before {
+ content: "\FE99";
+}
+.mdi-baby-face-outline::before {
+ content: "\FE9A";
+}
+.mdi-backburger::before {
+ content: "\F06D";
+}
+.mdi-backspace::before {
+ content: "\F06E";
+}
+.mdi-backspace-outline::before {
+ content: "\FB38";
+}
+.mdi-backspace-reverse::before {
+ content: "\FE9B";
+}
+.mdi-backspace-reverse-outline::before {
+ content: "\FE9C";
+}
+.mdi-backup-restore::before {
+ content: "\F06F";
+}
+.mdi-bacteria::before {
+ content: "\FEF2";
+}
+.mdi-bacteria-outline::before {
+ content: "\FEF3";
+}
+.mdi-badminton::before {
+ content: "\F850";
+}
+.mdi-bag-carry-on::before {
+ content: "\FF58";
+}
+.mdi-bag-carry-on-check::before {
+ content: "\FD41";
+}
+.mdi-bag-carry-on-off::before {
+ content: "\FF59";
+}
+.mdi-bag-checked::before {
+ content: "\FF5A";
+}
+.mdi-bag-personal::before {
+ content: "\FDF3";
+}
+.mdi-bag-personal-off::before {
+ content: "\FDF4";
+}
+.mdi-bag-personal-off-outline::before {
+ content: "\FDF5";
+}
+.mdi-bag-personal-outline::before {
+ content: "\FDF6";
+}
+.mdi-baguette::before {
+ content: "\FF5B";
+}
+.mdi-balloon::before {
+ content: "\FA25";
+}
+.mdi-ballot::before {
+ content: "\F9C8";
+}
+.mdi-ballot-outline::before {
+ content: "\F9C9";
+}
+.mdi-ballot-recount::before {
+ content: "\FC15";
+}
+.mdi-ballot-recount-outline::before {
+ content: "\FC16";
+}
+.mdi-bandage::before {
+ content: "\FD8B";
+}
+.mdi-bandcamp::before {
+ content: "\F674";
+}
+.mdi-bank::before {
+ content: "\F070";
+}
+.mdi-bank-minus::before {
+ content: "\FD8C";
+}
+.mdi-bank-outline::before {
+ content: "\FE9D";
+}
+.mdi-bank-plus::before {
+ content: "\FD8D";
+}
+.mdi-bank-remove::before {
+ content: "\FD8E";
+}
+.mdi-bank-transfer::before {
+ content: "\FA26";
+}
+.mdi-bank-transfer-in::before {
+ content: "\FA27";
+}
+.mdi-bank-transfer-out::before {
+ content: "\FA28";
+}
+.mdi-barcode::before {
+ content: "\F071";
+}
+.mdi-barcode-off::before {
+ content: "\F0261";
+}
+.mdi-barcode-scan::before {
+ content: "\F072";
+}
+.mdi-barley::before {
+ content: "\F073";
+}
+.mdi-barley-off::before {
+ content: "\FB39";
+}
+.mdi-barn::before {
+ content: "\FB3A";
+}
+.mdi-barrel::before {
+ content: "\F074";
+}
+.mdi-baseball::before {
+ content: "\F851";
+}
+.mdi-baseball-bat::before {
+ content: "\F852";
+}
+.mdi-basecamp::before {
+ content: "\F075";
+}
+.mdi-bash::before {
+ content: "\F01AE";
+}
+.mdi-basket::before {
+ content: "\F076";
+}
+.mdi-basket-fill::before {
+ content: "\F077";
+}
+.mdi-basket-outline::before {
+ content: "\F01AC";
+}
+.mdi-basket-unfill::before {
+ content: "\F078";
+}
+.mdi-basketball::before {
+ content: "\F805";
+}
+.mdi-basketball-hoop::before {
+ content: "\FC17";
+}
+.mdi-basketball-hoop-outline::before {
+ content: "\FC18";
+}
+.mdi-bat::before {
+ content: "\FB3B";
+}
+.mdi-battery::before {
+ content: "\F079";
+}
+.mdi-battery-10::before {
+ content: "\F07A";
+}
+.mdi-battery-10-bluetooth::before {
+ content: "\F93D";
+}
+.mdi-battery-20::before {
+ content: "\F07B";
+}
+.mdi-battery-20-bluetooth::before {
+ content: "\F93E";
+}
+.mdi-battery-30::before {
+ content: "\F07C";
+}
+.mdi-battery-30-bluetooth::before {
+ content: "\F93F";
+}
+.mdi-battery-40::before {
+ content: "\F07D";
+}
+.mdi-battery-40-bluetooth::before {
+ content: "\F940";
+}
+.mdi-battery-50::before {
+ content: "\F07E";
+}
+.mdi-battery-50-bluetooth::before {
+ content: "\F941";
+}
+.mdi-battery-60::before {
+ content: "\F07F";
+}
+.mdi-battery-60-bluetooth::before {
+ content: "\F942";
+}
+.mdi-battery-70::before {
+ content: "\F080";
+}
+.mdi-battery-70-bluetooth::before {
+ content: "\F943";
+}
+.mdi-battery-80::before {
+ content: "\F081";
+}
+.mdi-battery-80-bluetooth::before {
+ content: "\F944";
+}
+.mdi-battery-90::before {
+ content: "\F082";
+}
+.mdi-battery-90-bluetooth::before {
+ content: "\F945";
+}
+.mdi-battery-alert::before {
+ content: "\F083";
+}
+.mdi-battery-alert-bluetooth::before {
+ content: "\F946";
+}
+.mdi-battery-alert-variant::before {
+ content: "\F00F7";
+}
+.mdi-battery-alert-variant-outline::before {
+ content: "\F00F8";
+}
+.mdi-battery-bluetooth::before {
+ content: "\F947";
+}
+.mdi-battery-bluetooth-variant::before {
+ content: "\F948";
+}
+.mdi-battery-charging::before {
+ content: "\F084";
+}
+.mdi-battery-charging-10::before {
+ content: "\F89B";
+}
+.mdi-battery-charging-100::before {
+ content: "\F085";
+}
+.mdi-battery-charging-20::before {
+ content: "\F086";
+}
+.mdi-battery-charging-30::before {
+ content: "\F087";
+}
+.mdi-battery-charging-40::before {
+ content: "\F088";
+}
+.mdi-battery-charging-50::before {
+ content: "\F89C";
+}
+.mdi-battery-charging-60::before {
+ content: "\F089";
+}
+.mdi-battery-charging-70::before {
+ content: "\F89D";
+}
+.mdi-battery-charging-80::before {
+ content: "\F08A";
+}
+.mdi-battery-charging-90::before {
+ content: "\F08B";
+}
+.mdi-battery-charging-high::before {
+ content: "\F02D1";
+}
+.mdi-battery-charging-low::before {
+ content: "\F02CF";
+}
+.mdi-battery-charging-medium::before {
+ content: "\F02D0";
+}
+.mdi-battery-charging-outline::before {
+ content: "\F89E";
+}
+.mdi-battery-charging-wireless::before {
+ content: "\F806";
+}
+.mdi-battery-charging-wireless-10::before {
+ content: "\F807";
+}
+.mdi-battery-charging-wireless-20::before {
+ content: "\F808";
+}
+.mdi-battery-charging-wireless-30::before {
+ content: "\F809";
+}
+.mdi-battery-charging-wireless-40::before {
+ content: "\F80A";
+}
+.mdi-battery-charging-wireless-50::before {
+ content: "\F80B";
+}
+.mdi-battery-charging-wireless-60::before {
+ content: "\F80C";
+}
+.mdi-battery-charging-wireless-70::before {
+ content: "\F80D";
+}
+.mdi-battery-charging-wireless-80::before {
+ content: "\F80E";
+}
+.mdi-battery-charging-wireless-90::before {
+ content: "\F80F";
+}
+.mdi-battery-charging-wireless-alert::before {
+ content: "\F810";
+}
+.mdi-battery-charging-wireless-outline::before {
+ content: "\F811";
+}
+.mdi-battery-heart::before {
+ content: "\F023A";
+}
+.mdi-battery-heart-outline::before {
+ content: "\F023B";
+}
+.mdi-battery-heart-variant::before {
+ content: "\F023C";
+}
+.mdi-battery-high::before {
+ content: "\F02CE";
+}
+.mdi-battery-low::before {
+ content: "\F02CC";
+}
+.mdi-battery-medium::before {
+ content: "\F02CD";
+}
+.mdi-battery-minus::before {
+ content: "\F08C";
+}
+.mdi-battery-negative::before {
+ content: "\F08D";
+}
+.mdi-battery-off::before {
+ content: "\F0288";
+}
+.mdi-battery-off-outline::before {
+ content: "\F0289";
+}
+.mdi-battery-outline::before {
+ content: "\F08E";
+}
+.mdi-battery-plus::before {
+ content: "\F08F";
+}
+.mdi-battery-positive::before {
+ content: "\F090";
+}
+.mdi-battery-unknown::before {
+ content: "\F091";
+}
+.mdi-battery-unknown-bluetooth::before {
+ content: "\F949";
+}
+.mdi-battlenet::before {
+ content: "\FB3C";
+}
+.mdi-beach::before {
+ content: "\F092";
+}
+.mdi-beaker::before {
+ content: "\FCC6";
+}
+.mdi-beaker-alert::before {
+ content: "\F0254";
+}
+.mdi-beaker-alert-outline::before {
+ content: "\F0255";
+}
+.mdi-beaker-check::before {
+ content: "\F0256";
+}
+.mdi-beaker-check-outline::before {
+ content: "\F0257";
+}
+.mdi-beaker-minus::before {
+ content: "\F0258";
+}
+.mdi-beaker-minus-outline::before {
+ content: "\F0259";
+}
+.mdi-beaker-outline::before {
+ content: "\F68F";
+}
+.mdi-beaker-plus::before {
+ content: "\F025A";
+}
+.mdi-beaker-plus-outline::before {
+ content: "\F025B";
+}
+.mdi-beaker-question::before {
+ content: "\F025C";
+}
+.mdi-beaker-question-outline::before {
+ content: "\F025D";
+}
+.mdi-beaker-remove::before {
+ content: "\F025E";
+}
+.mdi-beaker-remove-outline::before {
+ content: "\F025F";
+}
+.mdi-beats::before {
+ content: "\F097";
+}
+.mdi-bed-double::before {
+ content: "\F0092";
+}
+.mdi-bed-double-outline::before {
+ content: "\F0093";
+}
+.mdi-bed-empty::before {
+ content: "\F89F";
+}
+.mdi-bed-king::before {
+ content: "\F0094";
+}
+.mdi-bed-king-outline::before {
+ content: "\F0095";
+}
+.mdi-bed-queen::before {
+ content: "\F0096";
+}
+.mdi-bed-queen-outline::before {
+ content: "\F0097";
+}
+.mdi-bed-single::before {
+ content: "\F0098";
+}
+.mdi-bed-single-outline::before {
+ content: "\F0099";
+}
+.mdi-bee::before {
+ content: "\FFC1";
+}
+.mdi-bee-flower::before {
+ content: "\FFC2";
+}
+.mdi-beehive-outline::before {
+ content: "\F00F9";
+}
+.mdi-beer::before {
+ content: "\F098";
+}
+.mdi-beer-outline::before {
+ content: "\F0337";
+}
+.mdi-behance::before {
+ content: "\F099";
+}
+.mdi-bell::before {
+ content: "\F09A";
+}
+.mdi-bell-alert::before {
+ content: "\FD35";
+}
+.mdi-bell-alert-outline::before {
+ content: "\FE9E";
+}
+.mdi-bell-check::before {
+ content: "\F0210";
+}
+.mdi-bell-check-outline::before {
+ content: "\F0211";
+}
+.mdi-bell-circle::before {
+ content: "\FD36";
+}
+.mdi-bell-circle-outline::before {
+ content: "\FD37";
+}
+.mdi-bell-off::before {
+ content: "\F09B";
+}
+.mdi-bell-off-outline::before {
+ content: "\FA90";
+}
+.mdi-bell-outline::before {
+ content: "\F09C";
+}
+.mdi-bell-plus::before {
+ content: "\F09D";
+}
+.mdi-bell-plus-outline::before {
+ content: "\FA91";
+}
+.mdi-bell-ring::before {
+ content: "\F09E";
+}
+.mdi-bell-ring-outline::before {
+ content: "\F09F";
+}
+.mdi-bell-sleep::before {
+ content: "\F0A0";
+}
+.mdi-bell-sleep-outline::before {
+ content: "\FA92";
+}
+.mdi-beta::before {
+ content: "\F0A1";
+}
+.mdi-betamax::before {
+ content: "\F9CA";
+}
+.mdi-biathlon::before {
+ content: "\FDF7";
+}
+.mdi-bible::before {
+ content: "\F0A2";
+}
+.mdi-bicycle::before {
+ content: "\F00C7";
+}
+.mdi-bicycle-basket::before {
+ content: "\F0260";
+}
+.mdi-bike::before {
+ content: "\F0A3";
+}
+.mdi-bike-fast::before {
+ content: "\F014A";
+}
+.mdi-billboard::before {
+ content: "\F0032";
+}
+.mdi-billiards::before {
+ content: "\FB3D";
+}
+.mdi-billiards-rack::before {
+ content: "\FB3E";
+}
+.mdi-bing::before {
+ content: "\F0A4";
+}
+.mdi-binoculars::before {
+ content: "\F0A5";
+}
+.mdi-bio::before {
+ content: "\F0A6";
+}
+.mdi-biohazard::before {
+ content: "\F0A7";
+}
+.mdi-bitbucket::before {
+ content: "\F0A8";
+}
+.mdi-bitcoin::before {
+ content: "\F812";
+}
+.mdi-black-mesa::before {
+ content: "\F0A9";
+}
+.mdi-blackberry::before {
+ content: "\F0AA";
+}
+.mdi-blender::before {
+ content: "\FCC7";
+}
+.mdi-blender-software::before {
+ content: "\F0AB";
+}
+.mdi-blinds::before {
+ content: "\F0AC";
+}
+.mdi-blinds-open::before {
+ content: "\F0033";
+}
+.mdi-block-helper::before {
+ content: "\F0AD";
+}
+.mdi-blogger::before {
+ content: "\F0AE";
+}
+.mdi-blood-bag::before {
+ content: "\FCC8";
+}
+.mdi-bluetooth::before {
+ content: "\F0AF";
+}
+.mdi-bluetooth-audio::before {
+ content: "\F0B0";
+}
+.mdi-bluetooth-connect::before {
+ content: "\F0B1";
+}
+.mdi-bluetooth-off::before {
+ content: "\F0B2";
+}
+.mdi-bluetooth-settings::before {
+ content: "\F0B3";
+}
+.mdi-bluetooth-transfer::before {
+ content: "\F0B4";
+}
+.mdi-blur::before {
+ content: "\F0B5";
+}
+.mdi-blur-linear::before {
+ content: "\F0B6";
+}
+.mdi-blur-off::before {
+ content: "\F0B7";
+}
+.mdi-blur-radial::before {
+ content: "\F0B8";
+}
+.mdi-bolnisi-cross::before {
+ content: "\FCC9";
+}
+.mdi-bolt::before {
+ content: "\FD8F";
+}
+.mdi-bomb::before {
+ content: "\F690";
+}
+.mdi-bomb-off::before {
+ content: "\F6C4";
+}
+.mdi-bone::before {
+ content: "\F0B9";
+}
+.mdi-book::before {
+ content: "\F0BA";
+}
+.mdi-book-information-variant::before {
+ content: "\F009A";
+}
+.mdi-book-lock::before {
+ content: "\F799";
+}
+.mdi-book-lock-open::before {
+ content: "\F79A";
+}
+.mdi-book-minus::before {
+ content: "\F5D9";
+}
+.mdi-book-minus-multiple::before {
+ content: "\FA93";
+}
+.mdi-book-multiple::before {
+ content: "\F0BB";
+}
+.mdi-book-open::before {
+ content: "\F0BD";
+}
+.mdi-book-open-outline::before {
+ content: "\FB3F";
+}
+.mdi-book-open-page-variant::before {
+ content: "\F5DA";
+}
+.mdi-book-open-variant::before {
+ content: "\F0BE";
+}
+.mdi-book-outline::before {
+ content: "\FB40";
+}
+.mdi-book-play::before {
+ content: "\FE9F";
+}
+.mdi-book-play-outline::before {
+ content: "\FEA0";
+}
+.mdi-book-plus::before {
+ content: "\F5DB";
+}
+.mdi-book-plus-multiple::before {
+ content: "\FA94";
+}
+.mdi-book-remove::before {
+ content: "\FA96";
+}
+.mdi-book-remove-multiple::before {
+ content: "\FA95";
+}
+.mdi-book-search::before {
+ content: "\FEA1";
+}
+.mdi-book-search-outline::before {
+ content: "\FEA2";
+}
+.mdi-book-variant::before {
+ content: "\F0BF";
+}
+.mdi-book-variant-multiple::before {
+ content: "\F0BC";
+}
+.mdi-bookmark::before {
+ content: "\F0C0";
+}
+.mdi-bookmark-check::before {
+ content: "\F0C1";
+}
+.mdi-bookmark-check-outline::before {
+ content: "\F03A6";
+}
+.mdi-bookmark-minus::before {
+ content: "\F9CB";
+}
+.mdi-bookmark-minus-outline::before {
+ content: "\F9CC";
+}
+.mdi-bookmark-multiple::before {
+ content: "\FDF8";
+}
+.mdi-bookmark-multiple-outline::before {
+ content: "\FDF9";
+}
+.mdi-bookmark-music::before {
+ content: "\F0C2";
+}
+.mdi-bookmark-music-outline::before {
+ content: "\F03A4";
+}
+.mdi-bookmark-off::before {
+ content: "\F9CD";
+}
+.mdi-bookmark-off-outline::before {
+ content: "\F9CE";
+}
+.mdi-bookmark-outline::before {
+ content: "\F0C3";
+}
+.mdi-bookmark-plus::before {
+ content: "\F0C5";
+}
+.mdi-bookmark-plus-outline::before {
+ content: "\F0C4";
+}
+.mdi-bookmark-remove::before {
+ content: "\F0C6";
+}
+.mdi-bookmark-remove-outline::before {
+ content: "\F03A5";
+}
+.mdi-bookshelf::before {
+ content: "\F028A";
+}
+.mdi-boom-gate::before {
+ content: "\FEA3";
+}
+.mdi-boom-gate-alert::before {
+ content: "\FEA4";
+}
+.mdi-boom-gate-alert-outline::before {
+ content: "\FEA5";
+}
+.mdi-boom-gate-down::before {
+ content: "\FEA6";
+}
+.mdi-boom-gate-down-outline::before {
+ content: "\FEA7";
+}
+.mdi-boom-gate-outline::before {
+ content: "\FEA8";
+}
+.mdi-boom-gate-up::before {
+ content: "\FEA9";
+}
+.mdi-boom-gate-up-outline::before {
+ content: "\FEAA";
+}
+.mdi-boombox::before {
+ content: "\F5DC";
+}
+.mdi-boomerang::before {
+ content: "\F00FA";
+}
+.mdi-bootstrap::before {
+ content: "\F6C5";
+}
+.mdi-border-all::before {
+ content: "\F0C7";
+}
+.mdi-border-all-variant::before {
+ content: "\F8A0";
+}
+.mdi-border-bottom::before {
+ content: "\F0C8";
+}
+.mdi-border-bottom-variant::before {
+ content: "\F8A1";
+}
+.mdi-border-color::before {
+ content: "\F0C9";
+}
+.mdi-border-horizontal::before {
+ content: "\F0CA";
+}
+.mdi-border-inside::before {
+ content: "\F0CB";
+}
+.mdi-border-left::before {
+ content: "\F0CC";
+}
+.mdi-border-left-variant::before {
+ content: "\F8A2";
+}
+.mdi-border-none::before {
+ content: "\F0CD";
+}
+.mdi-border-none-variant::before {
+ content: "\F8A3";
+}
+.mdi-border-outside::before {
+ content: "\F0CE";
+}
+.mdi-border-right::before {
+ content: "\F0CF";
+}
+.mdi-border-right-variant::before {
+ content: "\F8A4";
+}
+.mdi-border-style::before {
+ content: "\F0D0";
+}
+.mdi-border-top::before {
+ content: "\F0D1";
+}
+.mdi-border-top-variant::before {
+ content: "\F8A5";
+}
+.mdi-border-vertical::before {
+ content: "\F0D2";
+}
+.mdi-bottle-soda::before {
+ content: "\F009B";
+}
+.mdi-bottle-soda-classic::before {
+ content: "\F009C";
+}
+.mdi-bottle-soda-classic-outline::before {
+ content: "\F038E";
+}
+.mdi-bottle-soda-outline::before {
+ content: "\F009D";
+}
+.mdi-bottle-tonic::before {
+ content: "\F0159";
+}
+.mdi-bottle-tonic-outline::before {
+ content: "\F015A";
+}
+.mdi-bottle-tonic-plus::before {
+ content: "\F015B";
+}
+.mdi-bottle-tonic-plus-outline::before {
+ content: "\F015C";
+}
+.mdi-bottle-tonic-skull::before {
+ content: "\F015D";
+}
+.mdi-bottle-tonic-skull-outline::before {
+ content: "\F015E";
+}
+.mdi-bottle-wine::before {
+ content: "\F853";
+}
+.mdi-bottle-wine-outline::before {
+ content: "\F033B";
+}
+.mdi-bow-tie::before {
+ content: "\F677";
+}
+.mdi-bowl::before {
+ content: "\F617";
+}
+.mdi-bowling::before {
+ content: "\F0D3";
+}
+.mdi-box::before {
+ content: "\F0D4";
+}
+.mdi-box-cutter::before {
+ content: "\F0D5";
+}
+.mdi-box-shadow::before {
+ content: "\F637";
+}
+.mdi-boxing-glove::before {
+ content: "\FB41";
+}
+.mdi-braille::before {
+ content: "\F9CF";
+}
+.mdi-brain::before {
+ content: "\F9D0";
+}
+.mdi-bread-slice::before {
+ content: "\FCCA";
+}
+.mdi-bread-slice-outline::before {
+ content: "\FCCB";
+}
+.mdi-bridge::before {
+ content: "\F618";
+}
+.mdi-briefcase::before {
+ content: "\F0D6";
+}
+.mdi-briefcase-account::before {
+ content: "\FCCC";
+}
+.mdi-briefcase-account-outline::before {
+ content: "\FCCD";
+}
+.mdi-briefcase-check::before {
+ content: "\F0D7";
+}
+.mdi-briefcase-check-outline::before {
+ content: "\F0349";
+}
+.mdi-briefcase-clock::before {
+ content: "\F00FB";
+}
+.mdi-briefcase-clock-outline::before {
+ content: "\F00FC";
+}
+.mdi-briefcase-download::before {
+ content: "\F0D8";
+}
+.mdi-briefcase-download-outline::before {
+ content: "\FC19";
+}
+.mdi-briefcase-edit::before {
+ content: "\FA97";
+}
+.mdi-briefcase-edit-outline::before {
+ content: "\FC1A";
+}
+.mdi-briefcase-minus::before {
+ content: "\FA29";
+}
+.mdi-briefcase-minus-outline::before {
+ content: "\FC1B";
+}
+.mdi-briefcase-outline::before {
+ content: "\F813";
+}
+.mdi-briefcase-plus::before {
+ content: "\FA2A";
+}
+.mdi-briefcase-plus-outline::before {
+ content: "\FC1C";
+}
+.mdi-briefcase-remove::before {
+ content: "\FA2B";
+}
+.mdi-briefcase-remove-outline::before {
+ content: "\FC1D";
+}
+.mdi-briefcase-search::before {
+ content: "\FA2C";
+}
+.mdi-briefcase-search-outline::before {
+ content: "\FC1E";
+}
+.mdi-briefcase-upload::before {
+ content: "\F0D9";
+}
+.mdi-briefcase-upload-outline::before {
+ content: "\FC1F";
+}
+.mdi-brightness-1::before {
+ content: "\F0DA";
+}
+.mdi-brightness-2::before {
+ content: "\F0DB";
+}
+.mdi-brightness-3::before {
+ content: "\F0DC";
+}
+.mdi-brightness-4::before {
+ content: "\F0DD";
+}
+.mdi-brightness-5::before {
+ content: "\F0DE";
+}
+.mdi-brightness-6::before {
+ content: "\F0DF";
+}
+.mdi-brightness-7::before {
+ content: "\F0E0";
+}
+.mdi-brightness-auto::before {
+ content: "\F0E1";
+}
+.mdi-brightness-percent::before {
+ content: "\FCCE";
+}
+.mdi-broom::before {
+ content: "\F0E2";
+}
+.mdi-brush::before {
+ content: "\F0E3";
+}
+.mdi-buddhism::before {
+ content: "\F94A";
+}
+.mdi-buffer::before {
+ content: "\F619";
+}
+.mdi-bug::before {
+ content: "\F0E4";
+}
+.mdi-bug-check::before {
+ content: "\FA2D";
+}
+.mdi-bug-check-outline::before {
+ content: "\FA2E";
+}
+.mdi-bug-outline::before {
+ content: "\FA2F";
+}
+.mdi-bugle::before {
+ content: "\FD90";
+}
+.mdi-bulldozer::before {
+ content: "\FB07";
+}
+.mdi-bullet::before {
+ content: "\FCCF";
+}
+.mdi-bulletin-board::before {
+ content: "\F0E5";
+}
+.mdi-bullhorn::before {
+ content: "\F0E6";
+}
+.mdi-bullhorn-outline::before {
+ content: "\FB08";
+}
+.mdi-bullseye::before {
+ content: "\F5DD";
+}
+.mdi-bullseye-arrow::before {
+ content: "\F8C8";
+}
+.mdi-bulma::before {
+ content: "\F0312";
+}
+.mdi-bunk-bed::before {
+ content: "\F032D";
+}
+.mdi-bus::before {
+ content: "\F0E7";
+}
+.mdi-bus-alert::before {
+ content: "\FA98";
+}
+.mdi-bus-articulated-end::before {
+ content: "\F79B";
+}
+.mdi-bus-articulated-front::before {
+ content: "\F79C";
+}
+.mdi-bus-clock::before {
+ content: "\F8C9";
+}
+.mdi-bus-double-decker::before {
+ content: "\F79D";
+}
+.mdi-bus-marker::before {
+ content: "\F023D";
+}
+.mdi-bus-multiple::before {
+ content: "\FF5C";
+}
+.mdi-bus-school::before {
+ content: "\F79E";
+}
+.mdi-bus-side::before {
+ content: "\F79F";
+}
+.mdi-bus-stop::before {
+ content: "\F0034";
+}
+.mdi-bus-stop-covered::before {
+ content: "\F0035";
+}
+.mdi-bus-stop-uncovered::before {
+ content: "\F0036";
+}
+.mdi-cached::before {
+ content: "\F0E8";
+}
+.mdi-cactus::before {
+ content: "\FD91";
+}
+.mdi-cake::before {
+ content: "\F0E9";
+}
+.mdi-cake-layered::before {
+ content: "\F0EA";
+}
+.mdi-cake-variant::before {
+ content: "\F0EB";
+}
+.mdi-calculator::before {
+ content: "\F0EC";
+}
+.mdi-calculator-variant::before {
+ content: "\FA99";
+}
+.mdi-calendar::before {
+ content: "\F0ED";
+}
+.mdi-calendar-account::before {
+ content: "\FEF4";
+}
+.mdi-calendar-account-outline::before {
+ content: "\FEF5";
+}
+.mdi-calendar-alert::before {
+ content: "\FA30";
+}
+.mdi-calendar-arrow-left::before {
+ content: "\F015F";
+}
+.mdi-calendar-arrow-right::before {
+ content: "\F0160";
+}
+.mdi-calendar-blank::before {
+ content: "\F0EE";
+}
+.mdi-calendar-blank-multiple::before {
+ content: "\F009E";
+}
+.mdi-calendar-blank-outline::before {
+ content: "\FB42";
+}
+.mdi-calendar-check::before {
+ content: "\F0EF";
+}
+.mdi-calendar-check-outline::before {
+ content: "\FC20";
+}
+.mdi-calendar-clock::before {
+ content: "\F0F0";
+}
+.mdi-calendar-edit::before {
+ content: "\F8A6";
+}
+.mdi-calendar-export::before {
+ content: "\FB09";
+}
+.mdi-calendar-heart::before {
+ content: "\F9D1";
+}
+.mdi-calendar-import::before {
+ content: "\FB0A";
+}
+.mdi-calendar-minus::before {
+ content: "\FD38";
+}
+.mdi-calendar-month::before {
+ content: "\FDFA";
+}
+.mdi-calendar-month-outline::before {
+ content: "\FDFB";
+}
+.mdi-calendar-multiple::before {
+ content: "\F0F1";
+}
+.mdi-calendar-multiple-check::before {
+ content: "\F0F2";
+}
+.mdi-calendar-multiselect::before {
+ content: "\FA31";
+}
+.mdi-calendar-outline::before {
+ content: "\FB43";
+}
+.mdi-calendar-plus::before {
+ content: "\F0F3";
+}
+.mdi-calendar-question::before {
+ content: "\F691";
+}
+.mdi-calendar-range::before {
+ content: "\F678";
+}
+.mdi-calendar-range-outline::before {
+ content: "\FB44";
+}
+.mdi-calendar-remove::before {
+ content: "\F0F4";
+}
+.mdi-calendar-remove-outline::before {
+ content: "\FC21";
+}
+.mdi-calendar-repeat::before {
+ content: "\FEAB";
+}
+.mdi-calendar-repeat-outline::before {
+ content: "\FEAC";
+}
+.mdi-calendar-search::before {
+ content: "\F94B";
+}
+.mdi-calendar-star::before {
+ content: "\F9D2";
+}
+.mdi-calendar-text::before {
+ content: "\F0F5";
+}
+.mdi-calendar-text-outline::before {
+ content: "\FC22";
+}
+.mdi-calendar-today::before {
+ content: "\F0F6";
+}
+.mdi-calendar-week::before {
+ content: "\FA32";
+}
+.mdi-calendar-week-begin::before {
+ content: "\FA33";
+}
+.mdi-calendar-weekend::before {
+ content: "\FEF6";
+}
+.mdi-calendar-weekend-outline::before {
+ content: "\FEF7";
+}
+.mdi-call-made::before {
+ content: "\F0F7";
+}
+.mdi-call-merge::before {
+ content: "\F0F8";
+}
+.mdi-call-missed::before {
+ content: "\F0F9";
+}
+.mdi-call-received::before {
+ content: "\F0FA";
+}
+.mdi-call-split::before {
+ content: "\F0FB";
+}
+.mdi-camcorder::before {
+ content: "\F0FC";
+}
+.mdi-camcorder-box::before {
+ content: "\F0FD";
+}
+.mdi-camcorder-box-off::before {
+ content: "\F0FE";
+}
+.mdi-camcorder-off::before {
+ content: "\F0FF";
+}
+.mdi-camera::before {
+ content: "\F100";
+}
+.mdi-camera-account::before {
+ content: "\F8CA";
+}
+.mdi-camera-burst::before {
+ content: "\F692";
+}
+.mdi-camera-control::before {
+ content: "\FB45";
+}
+.mdi-camera-enhance::before {
+ content: "\F101";
+}
+.mdi-camera-enhance-outline::before {
+ content: "\FB46";
+}
+.mdi-camera-front::before {
+ content: "\F102";
+}
+.mdi-camera-front-variant::before {
+ content: "\F103";
+}
+.mdi-camera-gopro::before {
+ content: "\F7A0";
+}
+.mdi-camera-image::before {
+ content: "\F8CB";
+}
+.mdi-camera-iris::before {
+ content: "\F104";
+}
+.mdi-camera-metering-center::before {
+ content: "\F7A1";
+}
+.mdi-camera-metering-matrix::before {
+ content: "\F7A2";
+}
+.mdi-camera-metering-partial::before {
+ content: "\F7A3";
+}
+.mdi-camera-metering-spot::before {
+ content: "\F7A4";
+}
+.mdi-camera-off::before {
+ content: "\F5DF";
+}
+.mdi-camera-outline::before {
+ content: "\FD39";
+}
+.mdi-camera-party-mode::before {
+ content: "\F105";
+}
+.mdi-camera-plus::before {
+ content: "\FEF8";
+}
+.mdi-camera-plus-outline::before {
+ content: "\FEF9";
+}
+.mdi-camera-rear::before {
+ content: "\F106";
+}
+.mdi-camera-rear-variant::before {
+ content: "\F107";
+}
+.mdi-camera-retake::before {
+ content: "\FDFC";
+}
+.mdi-camera-retake-outline::before {
+ content: "\FDFD";
+}
+.mdi-camera-switch::before {
+ content: "\F108";
+}
+.mdi-camera-timer::before {
+ content: "\F109";
+}
+.mdi-camera-wireless::before {
+ content: "\FD92";
+}
+.mdi-camera-wireless-outline::before {
+ content: "\FD93";
+}
+.mdi-campfire::before {
+ content: "\FEFA";
+}
+.mdi-cancel::before {
+ content: "\F739";
+}
+.mdi-candle::before {
+ content: "\F5E2";
+}
+.mdi-candycane::before {
+ content: "\F10A";
+}
+.mdi-cannabis::before {
+ content: "\F7A5";
+}
+.mdi-caps-lock::before {
+ content: "\FA9A";
+}
+.mdi-car::before {
+ content: "\F10B";
+}
+.mdi-car-2-plus::before {
+ content: "\F0037";
+}
+.mdi-car-3-plus::before {
+ content: "\F0038";
+}
+.mdi-car-back::before {
+ content: "\FDFE";
+}
+.mdi-car-battery::before {
+ content: "\F10C";
+}
+.mdi-car-brake-abs::before {
+ content: "\FC23";
+}
+.mdi-car-brake-alert::before {
+ content: "\FC24";
+}
+.mdi-car-brake-hold::before {
+ content: "\FD3A";
+}
+.mdi-car-brake-parking::before {
+ content: "\FD3B";
+}
+.mdi-car-brake-retarder::before {
+ content: "\F0039";
+}
+.mdi-car-child-seat::before {
+ content: "\FFC3";
+}
+.mdi-car-clutch::before {
+ content: "\F003A";
+}
+.mdi-car-connected::before {
+ content: "\F10D";
+}
+.mdi-car-convertible::before {
+ content: "\F7A6";
+}
+.mdi-car-coolant-level::before {
+ content: "\F003B";
+}
+.mdi-car-cruise-control::before {
+ content: "\FD3C";
+}
+.mdi-car-defrost-front::before {
+ content: "\FD3D";
+}
+.mdi-car-defrost-rear::before {
+ content: "\FD3E";
+}
+.mdi-car-door::before {
+ content: "\FB47";
+}
+.mdi-car-door-lock::before {
+ content: "\F00C8";
+}
+.mdi-car-electric::before {
+ content: "\FB48";
+}
+.mdi-car-esp::before {
+ content: "\FC25";
+}
+.mdi-car-estate::before {
+ content: "\F7A7";
+}
+.mdi-car-hatchback::before {
+ content: "\F7A8";
+}
+.mdi-car-info::before {
+ content: "\F01E9";
+}
+.mdi-car-key::before {
+ content: "\FB49";
+}
+.mdi-car-light-dimmed::before {
+ content: "\FC26";
+}
+.mdi-car-light-fog::before {
+ content: "\FC27";
+}
+.mdi-car-light-high::before {
+ content: "\FC28";
+}
+.mdi-car-limousine::before {
+ content: "\F8CC";
+}
+.mdi-car-multiple::before {
+ content: "\FB4A";
+}
+.mdi-car-off::before {
+ content: "\FDFF";
+}
+.mdi-car-parking-lights::before {
+ content: "\FD3F";
+}
+.mdi-car-pickup::before {
+ content: "\F7A9";
+}
+.mdi-car-seat::before {
+ content: "\FFC4";
+}
+.mdi-car-seat-cooler::before {
+ content: "\FFC5";
+}
+.mdi-car-seat-heater::before {
+ content: "\FFC6";
+}
+.mdi-car-shift-pattern::before {
+ content: "\FF5D";
+}
+.mdi-car-side::before {
+ content: "\F7AA";
+}
+.mdi-car-sports::before {
+ content: "\F7AB";
+}
+.mdi-car-tire-alert::before {
+ content: "\FC29";
+}
+.mdi-car-traction-control::before {
+ content: "\FD40";
+}
+.mdi-car-turbocharger::before {
+ content: "\F003C";
+}
+.mdi-car-wash::before {
+ content: "\F10E";
+}
+.mdi-car-windshield::before {
+ content: "\F003D";
+}
+.mdi-car-windshield-outline::before {
+ content: "\F003E";
+}
+.mdi-caravan::before {
+ content: "\F7AC";
+}
+.mdi-card::before {
+ content: "\FB4B";
+}
+.mdi-card-bulleted::before {
+ content: "\FB4C";
+}
+.mdi-card-bulleted-off::before {
+ content: "\FB4D";
+}
+.mdi-card-bulleted-off-outline::before {
+ content: "\FB4E";
+}
+.mdi-card-bulleted-outline::before {
+ content: "\FB4F";
+}
+.mdi-card-bulleted-settings::before {
+ content: "\FB50";
+}
+.mdi-card-bulleted-settings-outline::before {
+ content: "\FB51";
+}
+.mdi-card-outline::before {
+ content: "\FB52";
+}
+.mdi-card-plus::before {
+ content: "\F022A";
+}
+.mdi-card-plus-outline::before {
+ content: "\F022B";
+}
+.mdi-card-search::before {
+ content: "\F009F";
+}
+.mdi-card-search-outline::before {
+ content: "\F00A0";
+}
+.mdi-card-text::before {
+ content: "\FB53";
+}
+.mdi-card-text-outline::before {
+ content: "\FB54";
+}
+.mdi-cards::before {
+ content: "\F638";
+}
+.mdi-cards-club::before {
+ content: "\F8CD";
+}
+.mdi-cards-diamond::before {
+ content: "\F8CE";
+}
+.mdi-cards-diamond-outline::before {
+ content: "\F003F";
+}
+.mdi-cards-heart::before {
+ content: "\F8CF";
+}
+.mdi-cards-outline::before {
+ content: "\F639";
+}
+.mdi-cards-playing-outline::before {
+ content: "\F63A";
+}
+.mdi-cards-spade::before {
+ content: "\F8D0";
+}
+.mdi-cards-variant::before {
+ content: "\F6C6";
+}
+.mdi-carrot::before {
+ content: "\F10F";
+}
+.mdi-cart::before {
+ content: "\F110";
+}
+.mdi-cart-arrow-down::before {
+ content: "\FD42";
+}
+.mdi-cart-arrow-right::before {
+ content: "\FC2A";
+}
+.mdi-cart-arrow-up::before {
+ content: "\FD43";
+}
+.mdi-cart-minus::before {
+ content: "\FD44";
+}
+.mdi-cart-off::before {
+ content: "\F66B";
+}
+.mdi-cart-outline::before {
+ content: "\F111";
+}
+.mdi-cart-plus::before {
+ content: "\F112";
+}
+.mdi-cart-remove::before {
+ content: "\FD45";
+}
+.mdi-case-sensitive-alt::before {
+ content: "\F113";
+}
+.mdi-cash::before {
+ content: "\F114";
+}
+.mdi-cash-100::before {
+ content: "\F115";
+}
+.mdi-cash-marker::before {
+ content: "\FD94";
+}
+.mdi-cash-minus::before {
+ content: "\F028B";
+}
+.mdi-cash-multiple::before {
+ content: "\F116";
+}
+.mdi-cash-plus::before {
+ content: "\F028C";
+}
+.mdi-cash-refund::before {
+ content: "\FA9B";
+}
+.mdi-cash-register::before {
+ content: "\FCD0";
+}
+.mdi-cash-remove::before {
+ content: "\F028D";
+}
+.mdi-cash-usd::before {
+ content: "\F01A1";
+}
+.mdi-cash-usd-outline::before {
+ content: "\F117";
+}
+.mdi-cassette::before {
+ content: "\F9D3";
+}
+.mdi-cast::before {
+ content: "\F118";
+}
+.mdi-cast-audio::before {
+ content: "\F0040";
+}
+.mdi-cast-connected::before {
+ content: "\F119";
+}
+.mdi-cast-education::before {
+ content: "\FE6D";
+}
+.mdi-cast-off::before {
+ content: "\F789";
+}
+.mdi-castle::before {
+ content: "\F11A";
+}
+.mdi-cat::before {
+ content: "\F11B";
+}
+.mdi-cctv::before {
+ content: "\F7AD";
+}
+.mdi-ceiling-light::before {
+ content: "\F768";
+}
+.mdi-cellphone::before {
+ content: "\F11C";
+}
+.mdi-cellphone-android::before {
+ content: "\F11D";
+}
+.mdi-cellphone-arrow-down::before {
+ content: "\F9D4";
+}
+.mdi-cellphone-basic::before {
+ content: "\F11E";
+}
+.mdi-cellphone-dock::before {
+ content: "\F11F";
+}
+.mdi-cellphone-erase::before {
+ content: "\F94C";
+}
+.mdi-cellphone-information::before {
+ content: "\FF5E";
+}
+.mdi-cellphone-iphone::before {
+ content: "\F120";
+}
+.mdi-cellphone-key::before {
+ content: "\F94D";
+}
+.mdi-cellphone-link::before {
+ content: "\F121";
+}
+.mdi-cellphone-link-off::before {
+ content: "\F122";
+}
+.mdi-cellphone-lock::before {
+ content: "\F94E";
+}
+.mdi-cellphone-message::before {
+ content: "\F8D2";
+}
+.mdi-cellphone-message-off::before {
+ content: "\F00FD";
+}
+.mdi-cellphone-nfc::before {
+ content: "\FEAD";
+}
+.mdi-cellphone-nfc-off::before {
+ content: "\F0303";
+}
+.mdi-cellphone-off::before {
+ content: "\F94F";
+}
+.mdi-cellphone-play::before {
+ content: "\F0041";
+}
+.mdi-cellphone-screenshot::before {
+ content: "\FA34";
+}
+.mdi-cellphone-settings::before {
+ content: "\F123";
+}
+.mdi-cellphone-settings-variant::before {
+ content: "\F950";
+}
+.mdi-cellphone-sound::before {
+ content: "\F951";
+}
+.mdi-cellphone-text::before {
+ content: "\F8D1";
+}
+.mdi-cellphone-wireless::before {
+ content: "\F814";
+}
+.mdi-celtic-cross::before {
+ content: "\FCD1";
+}
+.mdi-centos::before {
+ content: "\F0145";
+}
+.mdi-certificate::before {
+ content: "\F124";
+}
+.mdi-certificate-outline::before {
+ content: "\F01B3";
+}
+.mdi-chair-rolling::before {
+ content: "\FFBA";
+}
+.mdi-chair-school::before {
+ content: "\F125";
+}
+.mdi-charity::before {
+ content: "\FC2B";
+}
+.mdi-chart-arc::before {
+ content: "\F126";
+}
+.mdi-chart-areaspline::before {
+ content: "\F127";
+}
+.mdi-chart-areaspline-variant::before {
+ content: "\FEAE";
+}
+.mdi-chart-bar::before {
+ content: "\F128";
+}
+.mdi-chart-bar-stacked::before {
+ content: "\F769";
+}
+.mdi-chart-bell-curve::before {
+ content: "\FC2C";
+}
+.mdi-chart-bell-curve-cumulative::before {
+ content: "\FFC7";
+}
+.mdi-chart-bubble::before {
+ content: "\F5E3";
+}
+.mdi-chart-donut::before {
+ content: "\F7AE";
+}
+.mdi-chart-donut-variant::before {
+ content: "\F7AF";
+}
+.mdi-chart-gantt::before {
+ content: "\F66C";
+}
+.mdi-chart-histogram::before {
+ content: "\F129";
+}
+.mdi-chart-line::before {
+ content: "\F12A";
+}
+.mdi-chart-line-stacked::before {
+ content: "\F76A";
+}
+.mdi-chart-line-variant::before {
+ content: "\F7B0";
+}
+.mdi-chart-multiline::before {
+ content: "\F8D3";
+}
+.mdi-chart-multiple::before {
+ content: "\F023E";
+}
+.mdi-chart-pie::before {
+ content: "\F12B";
+}
+.mdi-chart-ppf::before {
+ content: "\F03AB";
+}
+.mdi-chart-scatter-plot::before {
+ content: "\FEAF";
+}
+.mdi-chart-scatter-plot-hexbin::before {
+ content: "\F66D";
+}
+.mdi-chart-snakey::before {
+ content: "\F020A";
+}
+.mdi-chart-snakey-variant::before {
+ content: "\F020B";
+}
+.mdi-chart-timeline::before {
+ content: "\F66E";
+}
+.mdi-chart-timeline-variant::before {
+ content: "\FEB0";
+}
+.mdi-chart-tree::before {
+ content: "\FEB1";
+}
+.mdi-chat::before {
+ content: "\FB55";
+}
+.mdi-chat-alert::before {
+ content: "\FB56";
+}
+.mdi-chat-alert-outline::before {
+ content: "\F02F4";
+}
+.mdi-chat-outline::before {
+ content: "\FEFB";
+}
+.mdi-chat-processing::before {
+ content: "\FB57";
+}
+.mdi-chat-processing-outline::before {
+ content: "\F02F5";
+}
+.mdi-chat-sleep::before {
+ content: "\F02FC";
+}
+.mdi-chat-sleep-outline::before {
+ content: "\F02FD";
+}
+.mdi-check::before {
+ content: "\F12C";
+}
+.mdi-check-all::before {
+ content: "\F12D";
+}
+.mdi-check-bold::before {
+ content: "\FE6E";
+}
+.mdi-check-box-multiple-outline::before {
+ content: "\FC2D";
+}
+.mdi-check-box-outline::before {
+ content: "\FC2E";
+}
+.mdi-check-circle::before {
+ content: "\F5E0";
+}
+.mdi-check-circle-outline::before {
+ content: "\F5E1";
+}
+.mdi-check-decagram::before {
+ content: "\F790";
+}
+.mdi-check-network::before {
+ content: "\FC2F";
+}
+.mdi-check-network-outline::before {
+ content: "\FC30";
+}
+.mdi-check-outline::before {
+ content: "\F854";
+}
+.mdi-check-underline::before {
+ content: "\FE70";
+}
+.mdi-check-underline-circle::before {
+ content: "\FE71";
+}
+.mdi-check-underline-circle-outline::before {
+ content: "\FE72";
+}
+.mdi-checkbook::before {
+ content: "\FA9C";
+}
+.mdi-checkbox-blank::before {
+ content: "\F12E";
+}
+.mdi-checkbox-blank-circle::before {
+ content: "\F12F";
+}
+.mdi-checkbox-blank-circle-outline::before {
+ content: "\F130";
+}
+.mdi-checkbox-blank-off::before {
+ content: "\F0317";
+}
+.mdi-checkbox-blank-off-outline::before {
+ content: "\F0318";
+}
+.mdi-checkbox-blank-outline::before {
+ content: "\F131";
+}
+.mdi-checkbox-intermediate::before {
+ content: "\F855";
+}
+.mdi-checkbox-marked::before {
+ content: "\F132";
+}
+.mdi-checkbox-marked-circle::before {
+ content: "\F133";
+}
+.mdi-checkbox-marked-circle-outline::before {
+ content: "\F134";
+}
+.mdi-checkbox-marked-outline::before {
+ content: "\F135";
+}
+.mdi-checkbox-multiple-blank::before {
+ content: "\F136";
+}
+.mdi-checkbox-multiple-blank-circle::before {
+ content: "\F63B";
+}
+.mdi-checkbox-multiple-blank-circle-outline::before {
+ content: "\F63C";
+}
+.mdi-checkbox-multiple-blank-outline::before {
+ content: "\F137";
+}
+.mdi-checkbox-multiple-marked::before {
+ content: "\F138";
+}
+.mdi-checkbox-multiple-marked-circle::before {
+ content: "\F63D";
+}
+.mdi-checkbox-multiple-marked-circle-outline::before {
+ content: "\F63E";
+}
+.mdi-checkbox-multiple-marked-outline::before {
+ content: "\F139";
+}
+.mdi-checkerboard::before {
+ content: "\F13A";
+}
+.mdi-checkerboard-minus::before {
+ content: "\F022D";
+}
+.mdi-checkerboard-plus::before {
+ content: "\F022C";
+}
+.mdi-checkerboard-remove::before {
+ content: "\F022E";
+}
+.mdi-cheese::before {
+ content: "\F02E4";
+}
+.mdi-chef-hat::before {
+ content: "\FB58";
+}
+.mdi-chemical-weapon::before {
+ content: "\F13B";
+}
+.mdi-chess-bishop::before {
+ content: "\F85B";
+}
+.mdi-chess-king::before {
+ content: "\F856";
+}
+.mdi-chess-knight::before {
+ content: "\F857";
+}
+.mdi-chess-pawn::before {
+ content: "\F858";
+}
+.mdi-chess-queen::before {
+ content: "\F859";
+}
+.mdi-chess-rook::before {
+ content: "\F85A";
+}
+.mdi-chevron-double-down::before {
+ content: "\F13C";
+}
+.mdi-chevron-double-left::before {
+ content: "\F13D";
+}
+.mdi-chevron-double-right::before {
+ content: "\F13E";
+}
+.mdi-chevron-double-up::before {
+ content: "\F13F";
+}
+.mdi-chevron-down::before {
+ content: "\F140";
+}
+.mdi-chevron-down-box::before {
+ content: "\F9D5";
+}
+.mdi-chevron-down-box-outline::before {
+ content: "\F9D6";
+}
+.mdi-chevron-down-circle::before {
+ content: "\FB0B";
+}
+.mdi-chevron-down-circle-outline::before {
+ content: "\FB0C";
+}
+.mdi-chevron-left::before {
+ content: "\F141";
+}
+.mdi-chevron-left-box::before {
+ content: "\F9D7";
+}
+.mdi-chevron-left-box-outline::before {
+ content: "\F9D8";
+}
+.mdi-chevron-left-circle::before {
+ content: "\FB0D";
+}
+.mdi-chevron-left-circle-outline::before {
+ content: "\FB0E";
+}
+.mdi-chevron-right::before {
+ content: "\F142";
+}
+.mdi-chevron-right-box::before {
+ content: "\F9D9";
+}
+.mdi-chevron-right-box-outline::before {
+ content: "\F9DA";
+}
+.mdi-chevron-right-circle::before {
+ content: "\FB0F";
+}
+.mdi-chevron-right-circle-outline::before {
+ content: "\FB10";
+}
+.mdi-chevron-triple-down::before {
+ content: "\FD95";
+}
+.mdi-chevron-triple-left::before {
+ content: "\FD96";
+}
+.mdi-chevron-triple-right::before {
+ content: "\FD97";
+}
+.mdi-chevron-triple-up::before {
+ content: "\FD98";
+}
+.mdi-chevron-up::before {
+ content: "\F143";
+}
+.mdi-chevron-up-box::before {
+ content: "\F9DB";
+}
+.mdi-chevron-up-box-outline::before {
+ content: "\F9DC";
+}
+.mdi-chevron-up-circle::before {
+ content: "\FB11";
+}
+.mdi-chevron-up-circle-outline::before {
+ content: "\FB12";
+}
+.mdi-chili-hot::before {
+ content: "\F7B1";
+}
+.mdi-chili-medium::before {
+ content: "\F7B2";
+}
+.mdi-chili-mild::before {
+ content: "\F7B3";
+}
+.mdi-chip::before {
+ content: "\F61A";
+}
+.mdi-christianity::before {
+ content: "\F952";
+}
+.mdi-christianity-outline::before {
+ content: "\FCD2";
+}
+.mdi-church::before {
+ content: "\F144";
+}
+.mdi-cigar::before {
+ content: "\F01B4";
+}
+.mdi-circle::before {
+ content: "\F764";
+}
+.mdi-circle-double::before {
+ content: "\FEB2";
+}
+.mdi-circle-edit-outline::before {
+ content: "\F8D4";
+}
+.mdi-circle-expand::before {
+ content: "\FEB3";
+}
+.mdi-circle-medium::before {
+ content: "\F9DD";
+}
+.mdi-circle-off-outline::before {
+ content: "\F00FE";
+}
+.mdi-circle-outline::before {
+ content: "\F765";
+}
+.mdi-circle-slice-1::before {
+ content: "\FA9D";
+}
+.mdi-circle-slice-2::before {
+ content: "\FA9E";
+}
+.mdi-circle-slice-3::before {
+ content: "\FA9F";
+}
+.mdi-circle-slice-4::before {
+ content: "\FAA0";
+}
+.mdi-circle-slice-5::before {
+ content: "\FAA1";
+}
+.mdi-circle-slice-6::before {
+ content: "\FAA2";
+}
+.mdi-circle-slice-7::before {
+ content: "\FAA3";
+}
+.mdi-circle-slice-8::before {
+ content: "\FAA4";
+}
+.mdi-circle-small::before {
+ content: "\F9DE";
+}
+.mdi-circular-saw::before {
+ content: "\FE73";
+}
+.mdi-cisco-webex::before {
+ content: "\F145";
+}
+.mdi-city::before {
+ content: "\F146";
+}
+.mdi-city-variant::before {
+ content: "\FA35";
+}
+.mdi-city-variant-outline::before {
+ content: "\FA36";
+}
+.mdi-clipboard::before {
+ content: "\F147";
+}
+.mdi-clipboard-account::before {
+ content: "\F148";
+}
+.mdi-clipboard-account-outline::before {
+ content: "\FC31";
+}
+.mdi-clipboard-alert::before {
+ content: "\F149";
+}
+.mdi-clipboard-alert-outline::before {
+ content: "\FCD3";
+}
+.mdi-clipboard-arrow-down::before {
+ content: "\F14A";
+}
+.mdi-clipboard-arrow-down-outline::before {
+ content: "\FC32";
+}
+.mdi-clipboard-arrow-left::before {
+ content: "\F14B";
+}
+.mdi-clipboard-arrow-left-outline::before {
+ content: "\FCD4";
+}
+.mdi-clipboard-arrow-right::before {
+ content: "\FCD5";
+}
+.mdi-clipboard-arrow-right-outline::before {
+ content: "\FCD6";
+}
+.mdi-clipboard-arrow-up::before {
+ content: "\FC33";
+}
+.mdi-clipboard-arrow-up-outline::before {
+ content: "\FC34";
+}
+.mdi-clipboard-check::before {
+ content: "\F14C";
+}
+.mdi-clipboard-check-multiple::before {
+ content: "\F028E";
+}
+.mdi-clipboard-check-multiple-outline::before {
+ content: "\F028F";
+}
+.mdi-clipboard-check-outline::before {
+ content: "\F8A7";
+}
+.mdi-clipboard-file::before {
+ content: "\F0290";
+}
+.mdi-clipboard-file-outline::before {
+ content: "\F0291";
+}
+.mdi-clipboard-flow::before {
+ content: "\F6C7";
+}
+.mdi-clipboard-flow-outline::before {
+ content: "\F0142";
+}
+.mdi-clipboard-list::before {
+ content: "\F00FF";
+}
+.mdi-clipboard-list-outline::before {
+ content: "\F0100";
+}
+.mdi-clipboard-multiple::before {
+ content: "\F0292";
+}
+.mdi-clipboard-multiple-outline::before {
+ content: "\F0293";
+}
+.mdi-clipboard-outline::before {
+ content: "\F14D";
+}
+.mdi-clipboard-play::before {
+ content: "\FC35";
+}
+.mdi-clipboard-play-multiple::before {
+ content: "\F0294";
+}
+.mdi-clipboard-play-multiple-outline::before {
+ content: "\F0295";
+}
+.mdi-clipboard-play-outline::before {
+ content: "\FC36";
+}
+.mdi-clipboard-plus::before {
+ content: "\F750";
+}
+.mdi-clipboard-plus-outline::before {
+ content: "\F034A";
+}
+.mdi-clipboard-pulse::before {
+ content: "\F85C";
+}
+.mdi-clipboard-pulse-outline::before {
+ content: "\F85D";
+}
+.mdi-clipboard-text::before {
+ content: "\F14E";
+}
+.mdi-clipboard-text-multiple::before {
+ content: "\F0296";
+}
+.mdi-clipboard-text-multiple-outline::before {
+ content: "\F0297";
+}
+.mdi-clipboard-text-outline::before {
+ content: "\FA37";
+}
+.mdi-clipboard-text-play::before {
+ content: "\FC37";
+}
+.mdi-clipboard-text-play-outline::before {
+ content: "\FC38";
+}
+.mdi-clippy::before {
+ content: "\F14F";
+}
+.mdi-clock::before {
+ content: "\F953";
+}
+.mdi-clock-alert::before {
+ content: "\F954";
+}
+.mdi-clock-alert-outline::before {
+ content: "\F5CE";
+}
+.mdi-clock-check::before {
+ content: "\FFC8";
+}
+.mdi-clock-check-outline::before {
+ content: "\FFC9";
+}
+.mdi-clock-digital::before {
+ content: "\FEB4";
+}
+.mdi-clock-end::before {
+ content: "\F151";
+}
+.mdi-clock-fast::before {
+ content: "\F152";
+}
+.mdi-clock-in::before {
+ content: "\F153";
+}
+.mdi-clock-out::before {
+ content: "\F154";
+}
+.mdi-clock-outline::before {
+ content: "\F150";
+}
+.mdi-clock-start::before {
+ content: "\F155";
+}
+.mdi-close::before {
+ content: "\F156";
+}
+.mdi-close-box::before {
+ content: "\F157";
+}
+.mdi-close-box-multiple::before {
+ content: "\FC39";
+}
+.mdi-close-box-multiple-outline::before {
+ content: "\FC3A";
+}
+.mdi-close-box-outline::before {
+ content: "\F158";
+}
+.mdi-close-circle::before {
+ content: "\F159";
+}
+.mdi-close-circle-outline::before {
+ content: "\F15A";
+}
+.mdi-close-network::before {
+ content: "\F15B";
+}
+.mdi-close-network-outline::before {
+ content: "\FC3B";
+}
+.mdi-close-octagon::before {
+ content: "\F15C";
+}
+.mdi-close-octagon-outline::before {
+ content: "\F15D";
+}
+.mdi-close-outline::before {
+ content: "\F6C8";
+}
+.mdi-closed-caption::before {
+ content: "\F15E";
+}
+.mdi-closed-caption-outline::before {
+ content: "\FD99";
+}
+.mdi-cloud::before {
+ content: "\F15F";
+}
+.mdi-cloud-alert::before {
+ content: "\F9DF";
+}
+.mdi-cloud-braces::before {
+ content: "\F7B4";
+}
+.mdi-cloud-check::before {
+ content: "\F160";
+}
+.mdi-cloud-check-outline::before {
+ content: "\F02F7";
+}
+.mdi-cloud-circle::before {
+ content: "\F161";
+}
+.mdi-cloud-download::before {
+ content: "\F162";
+}
+.mdi-cloud-download-outline::before {
+ content: "\FB59";
+}
+.mdi-cloud-lock::before {
+ content: "\F021C";
+}
+.mdi-cloud-lock-outline::before {
+ content: "\F021D";
+}
+.mdi-cloud-off-outline::before {
+ content: "\F164";
+}
+.mdi-cloud-outline::before {
+ content: "\F163";
+}
+.mdi-cloud-print::before {
+ content: "\F165";
+}
+.mdi-cloud-print-outline::before {
+ content: "\F166";
+}
+.mdi-cloud-question::before {
+ content: "\FA38";
+}
+.mdi-cloud-search::before {
+ content: "\F955";
+}
+.mdi-cloud-search-outline::before {
+ content: "\F956";
+}
+.mdi-cloud-sync::before {
+ content: "\F63F";
+}
+.mdi-cloud-sync-outline::before {
+ content: "\F0301";
+}
+.mdi-cloud-tags::before {
+ content: "\F7B5";
+}
+.mdi-cloud-upload::before {
+ content: "\F167";
+}
+.mdi-cloud-upload-outline::before {
+ content: "\FB5A";
+}
+.mdi-clover::before {
+ content: "\F815";
+}
+.mdi-coach-lamp::before {
+ content: "\F0042";
+}
+.mdi-coat-rack::before {
+ content: "\F00C9";
+}
+.mdi-code-array::before {
+ content: "\F168";
+}
+.mdi-code-braces::before {
+ content: "\F169";
+}
+.mdi-code-braces-box::before {
+ content: "\F0101";
+}
+.mdi-code-brackets::before {
+ content: "\F16A";
+}
+.mdi-code-equal::before {
+ content: "\F16B";
+}
+.mdi-code-greater-than::before {
+ content: "\F16C";
+}
+.mdi-code-greater-than-or-equal::before {
+ content: "\F16D";
+}
+.mdi-code-less-than::before {
+ content: "\F16E";
+}
+.mdi-code-less-than-or-equal::before {
+ content: "\F16F";
+}
+.mdi-code-not-equal::before {
+ content: "\F170";
+}
+.mdi-code-not-equal-variant::before {
+ content: "\F171";
+}
+.mdi-code-parentheses::before {
+ content: "\F172";
+}
+.mdi-code-parentheses-box::before {
+ content: "\F0102";
+}
+.mdi-code-string::before {
+ content: "\F173";
+}
+.mdi-code-tags::before {
+ content: "\F174";
+}
+.mdi-code-tags-check::before {
+ content: "\F693";
+}
+.mdi-codepen::before {
+ content: "\F175";
+}
+.mdi-coffee::before {
+ content: "\F176";
+}
+.mdi-coffee-maker::before {
+ content: "\F00CA";
+}
+.mdi-coffee-off::before {
+ content: "\FFCA";
+}
+.mdi-coffee-off-outline::before {
+ content: "\FFCB";
+}
+.mdi-coffee-outline::before {
+ content: "\F6C9";
+}
+.mdi-coffee-to-go::before {
+ content: "\F177";
+}
+.mdi-coffee-to-go-outline::before {
+ content: "\F0339";
+}
+.mdi-coffin::before {
+ content: "\FB5B";
+}
+.mdi-cog-clockwise::before {
+ content: "\F0208";
+}
+.mdi-cog-counterclockwise::before {
+ content: "\F0209";
+}
+.mdi-cogs::before {
+ content: "\F8D5";
+}
+.mdi-coin::before {
+ content: "\F0196";
+}
+.mdi-coin-outline::before {
+ content: "\F178";
+}
+.mdi-coins::before {
+ content: "\F694";
+}
+.mdi-collage::before {
+ content: "\F640";
+}
+.mdi-collapse-all::before {
+ content: "\FAA5";
+}
+.mdi-collapse-all-outline::before {
+ content: "\FAA6";
+}
+.mdi-color-helper::before {
+ content: "\F179";
+}
+.mdi-comma::before {
+ content: "\FE74";
+}
+.mdi-comma-box::before {
+ content: "\FE75";
+}
+.mdi-comma-box-outline::before {
+ content: "\FE76";
+}
+.mdi-comma-circle::before {
+ content: "\FE77";
+}
+.mdi-comma-circle-outline::before {
+ content: "\FE78";
+}
+.mdi-comment::before {
+ content: "\F17A";
+}
+.mdi-comment-account::before {
+ content: "\F17B";
+}
+.mdi-comment-account-outline::before {
+ content: "\F17C";
+}
+.mdi-comment-alert::before {
+ content: "\F17D";
+}
+.mdi-comment-alert-outline::before {
+ content: "\F17E";
+}
+.mdi-comment-arrow-left::before {
+ content: "\F9E0";
+}
+.mdi-comment-arrow-left-outline::before {
+ content: "\F9E1";
+}
+.mdi-comment-arrow-right::before {
+ content: "\F9E2";
+}
+.mdi-comment-arrow-right-outline::before {
+ content: "\F9E3";
+}
+.mdi-comment-check::before {
+ content: "\F17F";
+}
+.mdi-comment-check-outline::before {
+ content: "\F180";
+}
+.mdi-comment-edit::before {
+ content: "\F01EA";
+}
+.mdi-comment-edit-outline::before {
+ content: "\F02EF";
+}
+.mdi-comment-eye::before {
+ content: "\FA39";
+}
+.mdi-comment-eye-outline::before {
+ content: "\FA3A";
+}
+.mdi-comment-multiple::before {
+ content: "\F85E";
+}
+.mdi-comment-multiple-outline::before {
+ content: "\F181";
+}
+.mdi-comment-outline::before {
+ content: "\F182";
+}
+.mdi-comment-plus::before {
+ content: "\F9E4";
+}
+.mdi-comment-plus-outline::before {
+ content: "\F183";
+}
+.mdi-comment-processing::before {
+ content: "\F184";
+}
+.mdi-comment-processing-outline::before {
+ content: "\F185";
+}
+.mdi-comment-question::before {
+ content: "\F816";
+}
+.mdi-comment-question-outline::before {
+ content: "\F186";
+}
+.mdi-comment-quote::before {
+ content: "\F0043";
+}
+.mdi-comment-quote-outline::before {
+ content: "\F0044";
+}
+.mdi-comment-remove::before {
+ content: "\F5DE";
+}
+.mdi-comment-remove-outline::before {
+ content: "\F187";
+}
+.mdi-comment-search::before {
+ content: "\FA3B";
+}
+.mdi-comment-search-outline::before {
+ content: "\FA3C";
+}
+.mdi-comment-text::before {
+ content: "\F188";
+}
+.mdi-comment-text-multiple::before {
+ content: "\F85F";
+}
+.mdi-comment-text-multiple-outline::before {
+ content: "\F860";
+}
+.mdi-comment-text-outline::before {
+ content: "\F189";
+}
+.mdi-compare::before {
+ content: "\F18A";
+}
+.mdi-compass::before {
+ content: "\F18B";
+}
+.mdi-compass-off::before {
+ content: "\FB5C";
+}
+.mdi-compass-off-outline::before {
+ content: "\FB5D";
+}
+.mdi-compass-outline::before {
+ content: "\F18C";
+}
+.mdi-compass-rose::before {
+ content: "\F03AD";
+}
+.mdi-concourse-ci::before {
+ content: "\F00CB";
+}
+.mdi-console::before {
+ content: "\F18D";
+}
+.mdi-console-line::before {
+ content: "\F7B6";
+}
+.mdi-console-network::before {
+ content: "\F8A8";
+}
+.mdi-console-network-outline::before {
+ content: "\FC3C";
+}
+.mdi-consolidate::before {
+ content: "\F0103";
+}
+.mdi-contact-mail::before {
+ content: "\F18E";
+}
+.mdi-contact-mail-outline::before {
+ content: "\FEB5";
+}
+.mdi-contact-phone::before {
+ content: "\FEB6";
+}
+.mdi-contact-phone-outline::before {
+ content: "\FEB7";
+}
+.mdi-contactless-payment::before {
+ content: "\FD46";
+}
+.mdi-contacts::before {
+ content: "\F6CA";
+}
+.mdi-contain::before {
+ content: "\FA3D";
+}
+.mdi-contain-end::before {
+ content: "\FA3E";
+}
+.mdi-contain-start::before {
+ content: "\FA3F";
+}
+.mdi-content-copy::before {
+ content: "\F18F";
+}
+.mdi-content-cut::before {
+ content: "\F190";
+}
+.mdi-content-duplicate::before {
+ content: "\F191";
+}
+.mdi-content-paste::before {
+ content: "\F192";
+}
+.mdi-content-save::before {
+ content: "\F193";
+}
+.mdi-content-save-alert::before {
+ content: "\FF5F";
+}
+.mdi-content-save-alert-outline::before {
+ content: "\FF60";
+}
+.mdi-content-save-all::before {
+ content: "\F194";
+}
+.mdi-content-save-all-outline::before {
+ content: "\FF61";
+}
+.mdi-content-save-edit::before {
+ content: "\FCD7";
+}
+.mdi-content-save-edit-outline::before {
+ content: "\FCD8";
+}
+.mdi-content-save-move::before {
+ content: "\FE79";
+}
+.mdi-content-save-move-outline::before {
+ content: "\FE7A";
+}
+.mdi-content-save-outline::before {
+ content: "\F817";
+}
+.mdi-content-save-settings::before {
+ content: "\F61B";
+}
+.mdi-content-save-settings-outline::before {
+ content: "\FB13";
+}
+.mdi-contrast::before {
+ content: "\F195";
+}
+.mdi-contrast-box::before {
+ content: "\F196";
+}
+.mdi-contrast-circle::before {
+ content: "\F197";
+}
+.mdi-controller-classic::before {
+ content: "\FB5E";
+}
+.mdi-controller-classic-outline::before {
+ content: "\FB5F";
+}
+.mdi-cookie::before {
+ content: "\F198";
+}
+.mdi-coolant-temperature::before {
+ content: "\F3C8";
+}
+.mdi-copyright::before {
+ content: "\F5E6";
+}
+.mdi-cordova::before {
+ content: "\F957";
+}
+.mdi-corn::before {
+ content: "\F7B7";
+}
+.mdi-counter::before {
+ content: "\F199";
+}
+.mdi-cow::before {
+ content: "\F19A";
+}
+.mdi-cowboy::before {
+ content: "\FEB8";
+}
+.mdi-cpu-32-bit::before {
+ content: "\FEFC";
+}
+.mdi-cpu-64-bit::before {
+ content: "\FEFD";
+}
+.mdi-crane::before {
+ content: "\F861";
+}
+.mdi-creation::before {
+ content: "\F1C9";
+}
+.mdi-creative-commons::before {
+ content: "\FD47";
+}
+.mdi-credit-card::before {
+ content: "\F0010";
+}
+.mdi-credit-card-clock::before {
+ content: "\FEFE";
+}
+.mdi-credit-card-clock-outline::before {
+ content: "\FFBC";
+}
+.mdi-credit-card-marker::before {
+ content: "\F6A7";
+}
+.mdi-credit-card-marker-outline::before {
+ content: "\FD9A";
+}
+.mdi-credit-card-minus::before {
+ content: "\FFCC";
+}
+.mdi-credit-card-minus-outline::before {
+ content: "\FFCD";
+}
+.mdi-credit-card-multiple::before {
+ content: "\F0011";
+}
+.mdi-credit-card-multiple-outline::before {
+ content: "\F19C";
+}
+.mdi-credit-card-off::before {
+ content: "\F0012";
+}
+.mdi-credit-card-off-outline::before {
+ content: "\F5E4";
+}
+.mdi-credit-card-outline::before {
+ content: "\F19B";
+}
+.mdi-credit-card-plus::before {
+ content: "\F0013";
+}
+.mdi-credit-card-plus-outline::before {
+ content: "\F675";
+}
+.mdi-credit-card-refund::before {
+ content: "\F0014";
+}
+.mdi-credit-card-refund-outline::before {
+ content: "\FAA7";
+}
+.mdi-credit-card-remove::before {
+ content: "\FFCE";
+}
+.mdi-credit-card-remove-outline::before {
+ content: "\FFCF";
+}
+.mdi-credit-card-scan::before {
+ content: "\F0015";
+}
+.mdi-credit-card-scan-outline::before {
+ content: "\F19D";
+}
+.mdi-credit-card-settings::before {
+ content: "\F0016";
+}
+.mdi-credit-card-settings-outline::before {
+ content: "\F8D6";
+}
+.mdi-credit-card-wireless::before {
+ content: "\F801";
+}
+.mdi-credit-card-wireless-outline::before {
+ content: "\FD48";
+}
+.mdi-cricket::before {
+ content: "\FD49";
+}
+.mdi-crop::before {
+ content: "\F19E";
+}
+.mdi-crop-free::before {
+ content: "\F19F";
+}
+.mdi-crop-landscape::before {
+ content: "\F1A0";
+}
+.mdi-crop-portrait::before {
+ content: "\F1A1";
+}
+.mdi-crop-rotate::before {
+ content: "\F695";
+}
+.mdi-crop-square::before {
+ content: "\F1A2";
+}
+.mdi-crosshairs::before {
+ content: "\F1A3";
+}
+.mdi-crosshairs-gps::before {
+ content: "\F1A4";
+}
+.mdi-crosshairs-off::before {
+ content: "\FF62";
+}
+.mdi-crosshairs-question::before {
+ content: "\F0161";
+}
+.mdi-crown::before {
+ content: "\F1A5";
+}
+.mdi-crown-outline::before {
+ content: "\F01FB";
+}
+.mdi-cryengine::before {
+ content: "\F958";
+}
+.mdi-crystal-ball::before {
+ content: "\FB14";
+}
+.mdi-cube::before {
+ content: "\F1A6";
+}
+.mdi-cube-outline::before {
+ content: "\F1A7";
+}
+.mdi-cube-scan::before {
+ content: "\FB60";
+}
+.mdi-cube-send::before {
+ content: "\F1A8";
+}
+.mdi-cube-unfolded::before {
+ content: "\F1A9";
+}
+.mdi-cup::before {
+ content: "\F1AA";
+}
+.mdi-cup-off::before {
+ content: "\F5E5";
+}
+.mdi-cup-off-outline::before {
+ content: "\F03A8";
+}
+.mdi-cup-outline::before {
+ content: "\F033A";
+}
+.mdi-cup-water::before {
+ content: "\F1AB";
+}
+.mdi-cupboard::before {
+ content: "\FF63";
+}
+.mdi-cupboard-outline::before {
+ content: "\FF64";
+}
+.mdi-cupcake::before {
+ content: "\F959";
+}
+.mdi-curling::before {
+ content: "\F862";
+}
+.mdi-currency-bdt::before {
+ content: "\F863";
+}
+.mdi-currency-brl::before {
+ content: "\FB61";
+}
+.mdi-currency-btc::before {
+ content: "\F1AC";
+}
+.mdi-currency-cny::before {
+ content: "\F7B9";
+}
+.mdi-currency-eth::before {
+ content: "\F7BA";
+}
+.mdi-currency-eur::before {
+ content: "\F1AD";
+}
+.mdi-currency-eur-off::before {
+ content: "\F0340";
+}
+.mdi-currency-gbp::before {
+ content: "\F1AE";
+}
+.mdi-currency-ils::before {
+ content: "\FC3D";
+}
+.mdi-currency-inr::before {
+ content: "\F1AF";
+}
+.mdi-currency-jpy::before {
+ content: "\F7BB";
+}
+.mdi-currency-krw::before {
+ content: "\F7BC";
+}
+.mdi-currency-kzt::before {
+ content: "\F864";
+}
+.mdi-currency-ngn::before {
+ content: "\F1B0";
+}
+.mdi-currency-php::before {
+ content: "\F9E5";
+}
+.mdi-currency-rial::before {
+ content: "\FEB9";
+}
+.mdi-currency-rub::before {
+ content: "\F1B1";
+}
+.mdi-currency-sign::before {
+ content: "\F7BD";
+}
+.mdi-currency-try::before {
+ content: "\F1B2";
+}
+.mdi-currency-twd::before {
+ content: "\F7BE";
+}
+.mdi-currency-usd::before {
+ content: "\F1B3";
+}
+.mdi-currency-usd-off::before {
+ content: "\F679";
+}
+.mdi-current-ac::before {
+ content: "\F95A";
+}
+.mdi-current-dc::before {
+ content: "\F95B";
+}
+.mdi-cursor-default::before {
+ content: "\F1B4";
+}
+.mdi-cursor-default-click::before {
+ content: "\FCD9";
+}
+.mdi-cursor-default-click-outline::before {
+ content: "\FCDA";
+}
+.mdi-cursor-default-gesture::before {
+ content: "\F0152";
+}
+.mdi-cursor-default-gesture-outline::before {
+ content: "\F0153";
+}
+.mdi-cursor-default-outline::before {
+ content: "\F1B5";
+}
+.mdi-cursor-move::before {
+ content: "\F1B6";
+}
+.mdi-cursor-pointer::before {
+ content: "\F1B7";
+}
+.mdi-cursor-text::before {
+ content: "\F5E7";
+}
+.mdi-database::before {
+ content: "\F1B8";
+}
+.mdi-database-check::before {
+ content: "\FAA8";
+}
+.mdi-database-edit::before {
+ content: "\FB62";
+}
+.mdi-database-export::before {
+ content: "\F95D";
+}
+.mdi-database-import::before {
+ content: "\F95C";
+}
+.mdi-database-lock::before {
+ content: "\FAA9";
+}
+.mdi-database-marker::before {
+ content: "\F0321";
+}
+.mdi-database-minus::before {
+ content: "\F1B9";
+}
+.mdi-database-plus::before {
+ content: "\F1BA";
+}
+.mdi-database-refresh::before {
+ content: "\FCDB";
+}
+.mdi-database-remove::before {
+ content: "\FCDC";
+}
+.mdi-database-search::before {
+ content: "\F865";
+}
+.mdi-database-settings::before {
+ content: "\FCDD";
+}
+.mdi-death-star::before {
+ content: "\F8D7";
+}
+.mdi-death-star-variant::before {
+ content: "\F8D8";
+}
+.mdi-deathly-hallows::before {
+ content: "\FB63";
+}
+.mdi-debian::before {
+ content: "\F8D9";
+}
+.mdi-debug-step-into::before {
+ content: "\F1BB";
+}
+.mdi-debug-step-out::before {
+ content: "\F1BC";
+}
+.mdi-debug-step-over::before {
+ content: "\F1BD";
+}
+.mdi-decagram::before {
+ content: "\F76B";
+}
+.mdi-decagram-outline::before {
+ content: "\F76C";
+}
+.mdi-decimal::before {
+ content: "\F00CC";
+}
+.mdi-decimal-comma::before {
+ content: "\F00CD";
+}
+.mdi-decimal-comma-decrease::before {
+ content: "\F00CE";
+}
+.mdi-decimal-comma-increase::before {
+ content: "\F00CF";
+}
+.mdi-decimal-decrease::before {
+ content: "\F1BE";
+}
+.mdi-decimal-increase::before {
+ content: "\F1BF";
+}
+.mdi-delete::before {
+ content: "\F1C0";
+}
+.mdi-delete-alert::before {
+ content: "\F00D0";
+}
+.mdi-delete-alert-outline::before {
+ content: "\F00D1";
+}
+.mdi-delete-circle::before {
+ content: "\F682";
+}
+.mdi-delete-circle-outline::before {
+ content: "\FB64";
+}
+.mdi-delete-empty::before {
+ content: "\F6CB";
+}
+.mdi-delete-empty-outline::before {
+ content: "\FEBA";
+}
+.mdi-delete-forever::before {
+ content: "\F5E8";
+}
+.mdi-delete-forever-outline::before {
+ content: "\FB65";
+}
+.mdi-delete-off::before {
+ content: "\F00D2";
+}
+.mdi-delete-off-outline::before {
+ content: "\F00D3";
+}
+.mdi-delete-outline::before {
+ content: "\F9E6";
+}
+.mdi-delete-restore::before {
+ content: "\F818";
+}
+.mdi-delete-sweep::before {
+ content: "\F5E9";
+}
+.mdi-delete-sweep-outline::before {
+ content: "\FC3E";
+}
+.mdi-delete-variant::before {
+ content: "\F1C1";
+}
+.mdi-delta::before {
+ content: "\F1C2";
+}
+.mdi-desk::before {
+ content: "\F0264";
+}
+.mdi-desk-lamp::before {
+ content: "\F95E";
+}
+.mdi-deskphone::before {
+ content: "\F1C3";
+}
+.mdi-desktop-classic::before {
+ content: "\F7BF";
+}
+.mdi-desktop-mac::before {
+ content: "\F1C4";
+}
+.mdi-desktop-mac-dashboard::before {
+ content: "\F9E7";
+}
+.mdi-desktop-tower::before {
+ content: "\F1C5";
+}
+.mdi-desktop-tower-monitor::before {
+ content: "\FAAA";
+}
+.mdi-details::before {
+ content: "\F1C6";
+}
+.mdi-dev-to::before {
+ content: "\FD4A";
+}
+.mdi-developer-board::before {
+ content: "\F696";
+}
+.mdi-deviantart::before {
+ content: "\F1C7";
+}
+.mdi-devices::before {
+ content: "\FFD0";
+}
+.mdi-diabetes::before {
+ content: "\F0151";
+}
+.mdi-dialpad::before {
+ content: "\F61C";
+}
+.mdi-diameter::before {
+ content: "\FC3F";
+}
+.mdi-diameter-outline::before {
+ content: "\FC40";
+}
+.mdi-diameter-variant::before {
+ content: "\FC41";
+}
+.mdi-diamond::before {
+ content: "\FB66";
+}
+.mdi-diamond-outline::before {
+ content: "\FB67";
+}
+.mdi-diamond-stone::before {
+ content: "\F1C8";
+}
+.mdi-dice-1::before {
+ content: "\F1CA";
+}
+.mdi-dice-1-outline::before {
+ content: "\F0175";
+}
+.mdi-dice-2::before {
+ content: "\F1CB";
+}
+.mdi-dice-2-outline::before {
+ content: "\F0176";
+}
+.mdi-dice-3::before {
+ content: "\F1CC";
+}
+.mdi-dice-3-outline::before {
+ content: "\F0177";
+}
+.mdi-dice-4::before {
+ content: "\F1CD";
+}
+.mdi-dice-4-outline::before {
+ content: "\F0178";
+}
+.mdi-dice-5::before {
+ content: "\F1CE";
+}
+.mdi-dice-5-outline::before {
+ content: "\F0179";
+}
+.mdi-dice-6::before {
+ content: "\F1CF";
+}
+.mdi-dice-6-outline::before {
+ content: "\F017A";
+}
+.mdi-dice-d10::before {
+ content: "\F017E";
+}
+.mdi-dice-d10-outline::before {
+ content: "\F76E";
+}
+.mdi-dice-d12::before {
+ content: "\F017F";
+}
+.mdi-dice-d12-outline::before {
+ content: "\F866";
+}
+.mdi-dice-d20::before {
+ content: "\F0180";
+}
+.mdi-dice-d20-outline::before {
+ content: "\F5EA";
+}
+.mdi-dice-d4::before {
+ content: "\F017B";
+}
+.mdi-dice-d4-outline::before {
+ content: "\F5EB";
+}
+.mdi-dice-d6::before {
+ content: "\F017C";
+}
+.mdi-dice-d6-outline::before {
+ content: "\F5EC";
+}
+.mdi-dice-d8::before {
+ content: "\F017D";
+}
+.mdi-dice-d8-outline::before {
+ content: "\F5ED";
+}
+.mdi-dice-multiple::before {
+ content: "\F76D";
+}
+.mdi-dice-multiple-outline::before {
+ content: "\F0181";
+}
+.mdi-dictionary::before {
+ content: "\F61D";
+}
+.mdi-digital-ocean::before {
+ content: "\F0262";
+}
+.mdi-dip-switch::before {
+ content: "\F7C0";
+}
+.mdi-directions::before {
+ content: "\F1D0";
+}
+.mdi-directions-fork::before {
+ content: "\F641";
+}
+.mdi-disc::before {
+ content: "\F5EE";
+}
+.mdi-disc-alert::before {
+ content: "\F1D1";
+}
+.mdi-disc-player::before {
+ content: "\F95F";
+}
+.mdi-discord::before {
+ content: "\F66F";
+}
+.mdi-dishwasher::before {
+ content: "\FAAB";
+}
+.mdi-dishwasher-alert::before {
+ content: "\F01E3";
+}
+.mdi-dishwasher-off::before {
+ content: "\F01E4";
+}
+.mdi-disqus::before {
+ content: "\F1D2";
+}
+.mdi-disqus-outline::before {
+ content: "\F1D3";
+}
+.mdi-distribute-horizontal-center::before {
+ content: "\F01F4";
+}
+.mdi-distribute-horizontal-left::before {
+ content: "\F01F3";
+}
+.mdi-distribute-horizontal-right::before {
+ content: "\F01F5";
+}
+.mdi-distribute-vertical-bottom::before {
+ content: "\F01F6";
+}
+.mdi-distribute-vertical-center::before {
+ content: "\F01F7";
+}
+.mdi-distribute-vertical-top::before {
+ content: "\F01F8";
+}
+.mdi-diving-flippers::before {
+ content: "\FD9B";
+}
+.mdi-diving-helmet::before {
+ content: "\FD9C";
+}
+.mdi-diving-scuba::before {
+ content: "\FD9D";
+}
+.mdi-diving-scuba-flag::before {
+ content: "\FD9E";
+}
+.mdi-diving-scuba-tank::before {
+ content: "\FD9F";
+}
+.mdi-diving-scuba-tank-multiple::before {
+ content: "\FDA0";
+}
+.mdi-diving-snorkel::before {
+ content: "\FDA1";
+}
+.mdi-division::before {
+ content: "\F1D4";
+}
+.mdi-division-box::before {
+ content: "\F1D5";
+}
+.mdi-dlna::before {
+ content: "\FA40";
+}
+.mdi-dna::before {
+ content: "\F683";
+}
+.mdi-dns::before {
+ content: "\F1D6";
+}
+.mdi-dns-outline::before {
+ content: "\FB68";
+}
+.mdi-do-not-disturb::before {
+ content: "\F697";
+}
+.mdi-do-not-disturb-off::before {
+ content: "\F698";
+}
+.mdi-dock-bottom::before {
+ content: "\F00D4";
+}
+.mdi-dock-left::before {
+ content: "\F00D5";
+}
+.mdi-dock-right::before {
+ content: "\F00D6";
+}
+.mdi-dock-window::before {
+ content: "\F00D7";
+}
+.mdi-docker::before {
+ content: "\F867";
+}
+.mdi-doctor::before {
+ content: "\FA41";
+}
+.mdi-dog::before {
+ content: "\FA42";
+}
+.mdi-dog-service::before {
+ content: "\FAAC";
+}
+.mdi-dog-side::before {
+ content: "\FA43";
+}
+.mdi-dolby::before {
+ content: "\F6B2";
+}
+.mdi-dolly::before {
+ content: "\FEBB";
+}
+.mdi-domain::before {
+ content: "\F1D7";
+}
+.mdi-domain-off::before {
+ content: "\FD4B";
+}
+.mdi-domain-plus::before {
+ content: "\F00D8";
+}
+.mdi-domain-remove::before {
+ content: "\F00D9";
+}
+.mdi-domino-mask::before {
+ content: "\F0045";
+}
+.mdi-donkey::before {
+ content: "\F7C1";
+}
+.mdi-door::before {
+ content: "\F819";
+}
+.mdi-door-closed::before {
+ content: "\F81A";
+}
+.mdi-door-closed-lock::before {
+ content: "\F00DA";
+}
+.mdi-door-open::before {
+ content: "\F81B";
+}
+.mdi-doorbell::before {
+ content: "\F0311";
+}
+.mdi-doorbell-video::before {
+ content: "\F868";
+}
+.mdi-dot-net::before {
+ content: "\FAAD";
+}
+.mdi-dots-horizontal::before {
+ content: "\F1D8";
+}
+.mdi-dots-horizontal-circle::before {
+ content: "\F7C2";
+}
+.mdi-dots-horizontal-circle-outline::before {
+ content: "\FB69";
+}
+.mdi-dots-vertical::before {
+ content: "\F1D9";
+}
+.mdi-dots-vertical-circle::before {
+ content: "\F7C3";
+}
+.mdi-dots-vertical-circle-outline::before {
+ content: "\FB6A";
+}
+.mdi-douban::before {
+ content: "\F699";
+}
+.mdi-download::before {
+ content: "\F1DA";
+}
+.mdi-download-lock::before {
+ content: "\F034B";
+}
+.mdi-download-lock-outline::before {
+ content: "\F034C";
+}
+.mdi-download-multiple::before {
+ content: "\F9E8";
+}
+.mdi-download-network::before {
+ content: "\F6F3";
+}
+.mdi-download-network-outline::before {
+ content: "\FC42";
+}
+.mdi-download-off::before {
+ content: "\F00DB";
+}
+.mdi-download-off-outline::before {
+ content: "\F00DC";
+}
+.mdi-download-outline::before {
+ content: "\FB6B";
+}
+.mdi-drag::before {
+ content: "\F1DB";
+}
+.mdi-drag-horizontal::before {
+ content: "\F1DC";
+}
+.mdi-drag-horizontal-variant::before {
+ content: "\F031B";
+}
+.mdi-drag-variant::before {
+ content: "\FB6C";
+}
+.mdi-drag-vertical::before {
+ content: "\F1DD";
+}
+.mdi-drag-vertical-variant::before {
+ content: "\F031C";
+}
+.mdi-drama-masks::before {
+ content: "\FCDE";
+}
+.mdi-draw::before {
+ content: "\FF66";
+}
+.mdi-drawing::before {
+ content: "\F1DE";
+}
+.mdi-drawing-box::before {
+ content: "\F1DF";
+}
+.mdi-dresser::before {
+ content: "\FF67";
+}
+.mdi-dresser-outline::before {
+ content: "\FF68";
+}
+.mdi-dribbble::before {
+ content: "\F1E0";
+}
+.mdi-dribbble-box::before {
+ content: "\F1E1";
+}
+.mdi-drone::before {
+ content: "\F1E2";
+}
+.mdi-dropbox::before {
+ content: "\F1E3";
+}
+.mdi-drupal::before {
+ content: "\F1E4";
+}
+.mdi-duck::before {
+ content: "\F1E5";
+}
+.mdi-dumbbell::before {
+ content: "\F1E6";
+}
+.mdi-dump-truck::before {
+ content: "\FC43";
+}
+.mdi-ear-hearing::before {
+ content: "\F7C4";
+}
+.mdi-ear-hearing-off::before {
+ content: "\FA44";
+}
+.mdi-earth::before {
+ content: "\F1E7";
+}
+.mdi-earth-arrow-right::before {
+ content: "\F033C";
+}
+.mdi-earth-box::before {
+ content: "\F6CC";
+}
+.mdi-earth-box-off::before {
+ content: "\F6CD";
+}
+.mdi-earth-off::before {
+ content: "\F1E8";
+}
+.mdi-edge::before {
+ content: "\F1E9";
+}
+.mdi-edge-legacy::before {
+ content: "\F027B";
+}
+.mdi-egg::before {
+ content: "\FAAE";
+}
+.mdi-egg-easter::before {
+ content: "\FAAF";
+}
+.mdi-eight-track::before {
+ content: "\F9E9";
+}
+.mdi-eject::before {
+ content: "\F1EA";
+}
+.mdi-eject-outline::before {
+ content: "\FB6D";
+}
+.mdi-electric-switch::before {
+ content: "\FEBC";
+}
+.mdi-electric-switch-closed::before {
+ content: "\F0104";
+}
+.mdi-electron-framework::before {
+ content: "\F0046";
+}
+.mdi-elephant::before {
+ content: "\F7C5";
+}
+.mdi-elevation-decline::before {
+ content: "\F1EB";
+}
+.mdi-elevation-rise::before {
+ content: "\F1EC";
+}
+.mdi-elevator::before {
+ content: "\F1ED";
+}
+.mdi-elevator-down::before {
+ content: "\F02ED";
+}
+.mdi-elevator-passenger::before {
+ content: "\F03AC";
+}
+.mdi-elevator-up::before {
+ content: "\F02EC";
+}
+.mdi-ellipse::before {
+ content: "\FEBD";
+}
+.mdi-ellipse-outline::before {
+ content: "\FEBE";
+}
+.mdi-email::before {
+ content: "\F1EE";
+}
+.mdi-email-alert::before {
+ content: "\F6CE";
+}
+.mdi-email-alert-outline::before {
+ content: "\FD1E";
+}
+.mdi-email-box::before {
+ content: "\FCDF";
+}
+.mdi-email-check::before {
+ content: "\FAB0";
+}
+.mdi-email-check-outline::before {
+ content: "\FAB1";
+}
+.mdi-email-edit::before {
+ content: "\FF00";
+}
+.mdi-email-edit-outline::before {
+ content: "\FF01";
+}
+.mdi-email-lock::before {
+ content: "\F1F1";
+}
+.mdi-email-mark-as-unread::before {
+ content: "\FB6E";
+}
+.mdi-email-minus::before {
+ content: "\FF02";
+}
+.mdi-email-minus-outline::before {
+ content: "\FF03";
+}
+.mdi-email-multiple::before {
+ content: "\FF04";
+}
+.mdi-email-multiple-outline::before {
+ content: "\FF05";
+}
+.mdi-email-newsletter::before {
+ content: "\FFD1";
+}
+.mdi-email-open::before {
+ content: "\F1EF";
+}
+.mdi-email-open-multiple::before {
+ content: "\FF06";
+}
+.mdi-email-open-multiple-outline::before {
+ content: "\FF07";
+}
+.mdi-email-open-outline::before {
+ content: "\F5EF";
+}
+.mdi-email-outline::before {
+ content: "\F1F0";
+}
+.mdi-email-plus::before {
+ content: "\F9EA";
+}
+.mdi-email-plus-outline::before {
+ content: "\F9EB";
+}
+.mdi-email-receive::before {
+ content: "\F0105";
+}
+.mdi-email-receive-outline::before {
+ content: "\F0106";
+}
+.mdi-email-search::before {
+ content: "\F960";
+}
+.mdi-email-search-outline::before {
+ content: "\F961";
+}
+.mdi-email-send::before {
+ content: "\F0107";
+}
+.mdi-email-send-outline::before {
+ content: "\F0108";
+}
+.mdi-email-sync::before {
+ content: "\F02F2";
+}
+.mdi-email-sync-outline::before {
+ content: "\F02F3";
+}
+.mdi-email-variant::before {
+ content: "\F5F0";
+}
+.mdi-ember::before {
+ content: "\FB15";
+}
+.mdi-emby::before {
+ content: "\F6B3";
+}
+.mdi-emoticon::before {
+ content: "\FC44";
+}
+.mdi-emoticon-angry::before {
+ content: "\FC45";
+}
+.mdi-emoticon-angry-outline::before {
+ content: "\FC46";
+}
+.mdi-emoticon-confused::before {
+ content: "\F0109";
+}
+.mdi-emoticon-confused-outline::before {
+ content: "\F010A";
+}
+.mdi-emoticon-cool::before {
+ content: "\FC47";
+}
+.mdi-emoticon-cool-outline::before {
+ content: "\F1F3";
+}
+.mdi-emoticon-cry::before {
+ content: "\FC48";
+}
+.mdi-emoticon-cry-outline::before {
+ content: "\FC49";
+}
+.mdi-emoticon-dead::before {
+ content: "\FC4A";
+}
+.mdi-emoticon-dead-outline::before {
+ content: "\F69A";
+}
+.mdi-emoticon-devil::before {
+ content: "\FC4B";
+}
+.mdi-emoticon-devil-outline::before {
+ content: "\F1F4";
+}
+.mdi-emoticon-excited::before {
+ content: "\FC4C";
+}
+.mdi-emoticon-excited-outline::before {
+ content: "\F69B";
+}
+.mdi-emoticon-frown::before {
+ content: "\FF69";
+}
+.mdi-emoticon-frown-outline::before {
+ content: "\FF6A";
+}
+.mdi-emoticon-happy::before {
+ content: "\FC4D";
+}
+.mdi-emoticon-happy-outline::before {
+ content: "\F1F5";
+}
+.mdi-emoticon-kiss::before {
+ content: "\FC4E";
+}
+.mdi-emoticon-kiss-outline::before {
+ content: "\FC4F";
+}
+.mdi-emoticon-lol::before {
+ content: "\F023F";
+}
+.mdi-emoticon-lol-outline::before {
+ content: "\F0240";
+}
+.mdi-emoticon-neutral::before {
+ content: "\FC50";
+}
+.mdi-emoticon-neutral-outline::before {
+ content: "\F1F6";
+}
+.mdi-emoticon-outline::before {
+ content: "\F1F2";
+}
+.mdi-emoticon-poop::before {
+ content: "\F1F7";
+}
+.mdi-emoticon-poop-outline::before {
+ content: "\FC51";
+}
+.mdi-emoticon-sad::before {
+ content: "\FC52";
+}
+.mdi-emoticon-sad-outline::before {
+ content: "\F1F8";
+}
+.mdi-emoticon-tongue::before {
+ content: "\F1F9";
+}
+.mdi-emoticon-tongue-outline::before {
+ content: "\FC53";
+}
+.mdi-emoticon-wink::before {
+ content: "\FC54";
+}
+.mdi-emoticon-wink-outline::before {
+ content: "\FC55";
+}
+.mdi-engine::before {
+ content: "\F1FA";
+}
+.mdi-engine-off::before {
+ content: "\FA45";
+}
+.mdi-engine-off-outline::before {
+ content: "\FA46";
+}
+.mdi-engine-outline::before {
+ content: "\F1FB";
+}
+.mdi-epsilon::before {
+ content: "\F010B";
+}
+.mdi-equal::before {
+ content: "\F1FC";
+}
+.mdi-equal-box::before {
+ content: "\F1FD";
+}
+.mdi-equalizer::before {
+ content: "\FEBF";
+}
+.mdi-equalizer-outline::before {
+ content: "\FEC0";
+}
+.mdi-eraser::before {
+ content: "\F1FE";
+}
+.mdi-eraser-variant::before {
+ content: "\F642";
+}
+.mdi-escalator::before {
+ content: "\F1FF";
+}
+.mdi-escalator-down::before {
+ content: "\F02EB";
+}
+.mdi-escalator-up::before {
+ content: "\F02EA";
+}
+.mdi-eslint::before {
+ content: "\FC56";
+}
+.mdi-et::before {
+ content: "\FAB2";
+}
+.mdi-ethereum::before {
+ content: "\F869";
+}
+.mdi-ethernet::before {
+ content: "\F200";
+}
+.mdi-ethernet-cable::before {
+ content: "\F201";
+}
+.mdi-ethernet-cable-off::before {
+ content: "\F202";
+}
+.mdi-etsy::before {
+ content: "\F203";
+}
+.mdi-ev-station::before {
+ content: "\F5F1";
+}
+.mdi-eventbrite::before {
+ content: "\F7C6";
+}
+.mdi-evernote::before {
+ content: "\F204";
+}
+.mdi-excavator::before {
+ content: "\F0047";
+}
+.mdi-exclamation::before {
+ content: "\F205";
+}
+.mdi-exclamation-thick::before {
+ content: "\F0263";
+}
+.mdi-exit-run::before {
+ content: "\FA47";
+}
+.mdi-exit-to-app::before {
+ content: "\F206";
+}
+.mdi-expand-all::before {
+ content: "\FAB3";
+}
+.mdi-expand-all-outline::before {
+ content: "\FAB4";
+}
+.mdi-expansion-card::before {
+ content: "\F8AD";
+}
+.mdi-expansion-card-variant::before {
+ content: "\FFD2";
+}
+.mdi-exponent::before {
+ content: "\F962";
+}
+.mdi-exponent-box::before {
+ content: "\F963";
+}
+.mdi-export::before {
+ content: "\F207";
+}
+.mdi-export-variant::before {
+ content: "\FB6F";
+}
+.mdi-eye::before {
+ content: "\F208";
+}
+.mdi-eye-check::before {
+ content: "\FCE0";
+}
+.mdi-eye-check-outline::before {
+ content: "\FCE1";
+}
+.mdi-eye-circle::before {
+ content: "\FB70";
+}
+.mdi-eye-circle-outline::before {
+ content: "\FB71";
+}
+.mdi-eye-minus::before {
+ content: "\F0048";
+}
+.mdi-eye-minus-outline::before {
+ content: "\F0049";
+}
+.mdi-eye-off::before {
+ content: "\F209";
+}
+.mdi-eye-off-outline::before {
+ content: "\F6D0";
+}
+.mdi-eye-outline::before {
+ content: "\F6CF";
+}
+.mdi-eye-plus::before {
+ content: "\F86A";
+}
+.mdi-eye-plus-outline::before {
+ content: "\F86B";
+}
+.mdi-eye-settings::before {
+ content: "\F86C";
+}
+.mdi-eye-settings-outline::before {
+ content: "\F86D";
+}
+.mdi-eyedropper::before {
+ content: "\F20A";
+}
+.mdi-eyedropper-variant::before {
+ content: "\F20B";
+}
+.mdi-face::before {
+ content: "\F643";
+}
+.mdi-face-agent::before {
+ content: "\FD4C";
+}
+.mdi-face-outline::before {
+ content: "\FB72";
+}
+.mdi-face-profile::before {
+ content: "\F644";
+}
+.mdi-face-profile-woman::before {
+ content: "\F00A1";
+}
+.mdi-face-recognition::before {
+ content: "\FC57";
+}
+.mdi-face-woman::before {
+ content: "\F00A2";
+}
+.mdi-face-woman-outline::before {
+ content: "\F00A3";
+}
+.mdi-facebook::before {
+ content: "\F20C";
+}
+.mdi-facebook-box::before {
+ content: "\F20D";
+}
+.mdi-facebook-messenger::before {
+ content: "\F20E";
+}
+.mdi-facebook-workplace::before {
+ content: "\FB16";
+}
+.mdi-factory::before {
+ content: "\F20F";
+}
+.mdi-fan::before {
+ content: "\F210";
+}
+.mdi-fan-off::before {
+ content: "\F81C";
+}
+.mdi-fast-forward::before {
+ content: "\F211";
+}
+.mdi-fast-forward-10::before {
+ content: "\FD4D";
+}
+.mdi-fast-forward-30::before {
+ content: "\FCE2";
+}
+.mdi-fast-forward-5::before {
+ content: "\F0223";
+}
+.mdi-fast-forward-outline::before {
+ content: "\F6D1";
+}
+.mdi-fax::before {
+ content: "\F212";
+}
+.mdi-feather::before {
+ content: "\F6D2";
+}
+.mdi-feature-search::before {
+ content: "\FA48";
+}
+.mdi-feature-search-outline::before {
+ content: "\FA49";
+}
+.mdi-fedora::before {
+ content: "\F8DA";
+}
+.mdi-ferris-wheel::before {
+ content: "\FEC1";
+}
+.mdi-ferry::before {
+ content: "\F213";
+}
+.mdi-file::before {
+ content: "\F214";
+}
+.mdi-file-account::before {
+ content: "\F73A";
+}
+.mdi-file-account-outline::before {
+ content: "\F004A";
+}
+.mdi-file-alert::before {
+ content: "\FA4A";
+}
+.mdi-file-alert-outline::before {
+ content: "\FA4B";
+}
+.mdi-file-cabinet::before {
+ content: "\FAB5";
+}
+.mdi-file-cad::before {
+ content: "\FF08";
+}
+.mdi-file-cad-box::before {
+ content: "\FF09";
+}
+.mdi-file-cancel::before {
+ content: "\FDA2";
+}
+.mdi-file-cancel-outline::before {
+ content: "\FDA3";
+}
+.mdi-file-certificate::before {
+ content: "\F01B1";
+}
+.mdi-file-certificate-outline::before {
+ content: "\F01B2";
+}
+.mdi-file-chart::before {
+ content: "\F215";
+}
+.mdi-file-chart-outline::before {
+ content: "\F004B";
+}
+.mdi-file-check::before {
+ content: "\F216";
+}
+.mdi-file-check-outline::before {
+ content: "\FE7B";
+}
+.mdi-file-clock::before {
+ content: "\F030C";
+}
+.mdi-file-clock-outline::before {
+ content: "\F030D";
+}
+.mdi-file-cloud::before {
+ content: "\F217";
+}
+.mdi-file-cloud-outline::before {
+ content: "\F004C";
+}
+.mdi-file-code::before {
+ content: "\F22E";
+}
+.mdi-file-code-outline::before {
+ content: "\F004D";
+}
+.mdi-file-compare::before {
+ content: "\F8A9";
+}
+.mdi-file-delimited::before {
+ content: "\F218";
+}
+.mdi-file-delimited-outline::before {
+ content: "\FEC2";
+}
+.mdi-file-document::before {
+ content: "\F219";
+}
+.mdi-file-document-box::before {
+ content: "\F21A";
+}
+.mdi-file-document-box-check::before {
+ content: "\FEC3";
+}
+.mdi-file-document-box-check-outline::before {
+ content: "\FEC4";
+}
+.mdi-file-document-box-minus::before {
+ content: "\FEC5";
+}
+.mdi-file-document-box-minus-outline::before {
+ content: "\FEC6";
+}
+.mdi-file-document-box-multiple::before {
+ content: "\FAB6";
+}
+.mdi-file-document-box-multiple-outline::before {
+ content: "\FAB7";
+}
+.mdi-file-document-box-outline::before {
+ content: "\F9EC";
+}
+.mdi-file-document-box-plus::before {
+ content: "\FEC7";
+}
+.mdi-file-document-box-plus-outline::before {
+ content: "\FEC8";
+}
+.mdi-file-document-box-remove::before {
+ content: "\FEC9";
+}
+.mdi-file-document-box-remove-outline::before {
+ content: "\FECA";
+}
+.mdi-file-document-box-search::before {
+ content: "\FECB";
+}
+.mdi-file-document-box-search-outline::before {
+ content: "\FECC";
+}
+.mdi-file-document-edit::before {
+ content: "\FDA4";
+}
+.mdi-file-document-edit-outline::before {
+ content: "\FDA5";
+}
+.mdi-file-document-outline::before {
+ content: "\F9ED";
+}
+.mdi-file-download::before {
+ content: "\F964";
+}
+.mdi-file-download-outline::before {
+ content: "\F965";
+}
+.mdi-file-edit::before {
+ content: "\F0212";
+}
+.mdi-file-edit-outline::before {
+ content: "\F0213";
+}
+.mdi-file-excel::before {
+ content: "\F21B";
+}
+.mdi-file-excel-box::before {
+ content: "\F21C";
+}
+.mdi-file-excel-box-outline::before {
+ content: "\F004E";
+}
+.mdi-file-excel-outline::before {
+ content: "\F004F";
+}
+.mdi-file-export::before {
+ content: "\F21D";
+}
+.mdi-file-export-outline::before {
+ content: "\F0050";
+}
+.mdi-file-eye::before {
+ content: "\FDA6";
+}
+.mdi-file-eye-outline::before {
+ content: "\FDA7";
+}
+.mdi-file-find::before {
+ content: "\F21E";
+}
+.mdi-file-find-outline::before {
+ content: "\FB73";
+}
+.mdi-file-hidden::before {
+ content: "\F613";
+}
+.mdi-file-image::before {
+ content: "\F21F";
+}
+.mdi-file-image-outline::before {
+ content: "\FECD";
+}
+.mdi-file-import::before {
+ content: "\F220";
+}
+.mdi-file-import-outline::before {
+ content: "\F0051";
+}
+.mdi-file-key::before {
+ content: "\F01AF";
+}
+.mdi-file-key-outline::before {
+ content: "\F01B0";
+}
+.mdi-file-link::before {
+ content: "\F01A2";
+}
+.mdi-file-link-outline::before {
+ content: "\F01A3";
+}
+.mdi-file-lock::before {
+ content: "\F221";
+}
+.mdi-file-lock-outline::before {
+ content: "\F0052";
+}
+.mdi-file-move::before {
+ content: "\FAB8";
+}
+.mdi-file-move-outline::before {
+ content: "\F0053";
+}
+.mdi-file-multiple::before {
+ content: "\F222";
+}
+.mdi-file-multiple-outline::before {
+ content: "\F0054";
+}
+.mdi-file-music::before {
+ content: "\F223";
+}
+.mdi-file-music-outline::before {
+ content: "\FE7C";
+}
+.mdi-file-outline::before {
+ content: "\F224";
+}
+.mdi-file-pdf::before {
+ content: "\F225";
+}
+.mdi-file-pdf-box::before {
+ content: "\F226";
+}
+.mdi-file-pdf-box-outline::before {
+ content: "\FFD3";
+}
+.mdi-file-pdf-outline::before {
+ content: "\FE7D";
+}
+.mdi-file-percent::before {
+ content: "\F81D";
+}
+.mdi-file-percent-outline::before {
+ content: "\F0055";
+}
+.mdi-file-phone::before {
+ content: "\F01A4";
+}
+.mdi-file-phone-outline::before {
+ content: "\F01A5";
+}
+.mdi-file-plus::before {
+ content: "\F751";
+}
+.mdi-file-plus-outline::before {
+ content: "\FF0A";
+}
+.mdi-file-powerpoint::before {
+ content: "\F227";
+}
+.mdi-file-powerpoint-box::before {
+ content: "\F228";
+}
+.mdi-file-powerpoint-box-outline::before {
+ content: "\F0056";
+}
+.mdi-file-powerpoint-outline::before {
+ content: "\F0057";
+}
+.mdi-file-presentation-box::before {
+ content: "\F229";
+}
+.mdi-file-question::before {
+ content: "\F86E";
+}
+.mdi-file-question-outline::before {
+ content: "\F0058";
+}
+.mdi-file-remove::before {
+ content: "\FB74";
+}
+.mdi-file-remove-outline::before {
+ content: "\F0059";
+}
+.mdi-file-replace::before {
+ content: "\FB17";
+}
+.mdi-file-replace-outline::before {
+ content: "\FB18";
+}
+.mdi-file-restore::before {
+ content: "\F670";
+}
+.mdi-file-restore-outline::before {
+ content: "\F005A";
+}
+.mdi-file-search::before {
+ content: "\FC58";
+}
+.mdi-file-search-outline::before {
+ content: "\FC59";
+}
+.mdi-file-send::before {
+ content: "\F22A";
+}
+.mdi-file-send-outline::before {
+ content: "\F005B";
+}
+.mdi-file-settings::before {
+ content: "\F00A4";
+}
+.mdi-file-settings-outline::before {
+ content: "\F00A5";
+}
+.mdi-file-settings-variant::before {
+ content: "\F00A6";
+}
+.mdi-file-settings-variant-outline::before {
+ content: "\F00A7";
+}
+.mdi-file-star::before {
+ content: "\F005C";
+}
+.mdi-file-star-outline::before {
+ content: "\F005D";
+}
+.mdi-file-swap::before {
+ content: "\FFD4";
+}
+.mdi-file-swap-outline::before {
+ content: "\FFD5";
+}
+.mdi-file-sync::before {
+ content: "\F0241";
+}
+.mdi-file-sync-outline::before {
+ content: "\F0242";
+}
+.mdi-file-table::before {
+ content: "\FC5A";
+}
+.mdi-file-table-box::before {
+ content: "\F010C";
+}
+.mdi-file-table-box-multiple::before {
+ content: "\F010D";
+}
+.mdi-file-table-box-multiple-outline::before {
+ content: "\F010E";
+}
+.mdi-file-table-box-outline::before {
+ content: "\F010F";
+}
+.mdi-file-table-outline::before {
+ content: "\FC5B";
+}
+.mdi-file-tree::before {
+ content: "\F645";
+}
+.mdi-file-undo::before {
+ content: "\F8DB";
+}
+.mdi-file-undo-outline::before {
+ content: "\F005E";
+}
+.mdi-file-upload::before {
+ content: "\FA4C";
+}
+.mdi-file-upload-outline::before {
+ content: "\FA4D";
+}
+.mdi-file-video::before {
+ content: "\F22B";
+}
+.mdi-file-video-outline::before {
+ content: "\FE10";
+}
+.mdi-file-word::before {
+ content: "\F22C";
+}
+.mdi-file-word-box::before {
+ content: "\F22D";
+}
+.mdi-file-word-box-outline::before {
+ content: "\F005F";
+}
+.mdi-file-word-outline::before {
+ content: "\F0060";
+}
+.mdi-film::before {
+ content: "\F22F";
+}
+.mdi-filmstrip::before {
+ content: "\F230";
+}
+.mdi-filmstrip-off::before {
+ content: "\F231";
+}
+.mdi-filter::before {
+ content: "\F232";
+}
+.mdi-filter-menu::before {
+ content: "\F0110";
+}
+.mdi-filter-menu-outline::before {
+ content: "\F0111";
+}
+.mdi-filter-minus::before {
+ content: "\FF0B";
+}
+.mdi-filter-minus-outline::before {
+ content: "\FF0C";
+}
+.mdi-filter-outline::before {
+ content: "\F233";
+}
+.mdi-filter-plus::before {
+ content: "\FF0D";
+}
+.mdi-filter-plus-outline::before {
+ content: "\FF0E";
+}
+.mdi-filter-remove::before {
+ content: "\F234";
+}
+.mdi-filter-remove-outline::before {
+ content: "\F235";
+}
+.mdi-filter-variant::before {
+ content: "\F236";
+}
+.mdi-filter-variant-minus::before {
+ content: "\F013D";
+}
+.mdi-filter-variant-plus::before {
+ content: "\F013E";
+}
+.mdi-filter-variant-remove::before {
+ content: "\F0061";
+}
+.mdi-finance::before {
+ content: "\F81E";
+}
+.mdi-find-replace::before {
+ content: "\F6D3";
+}
+.mdi-fingerprint::before {
+ content: "\F237";
+}
+.mdi-fingerprint-off::before {
+ content: "\FECE";
+}
+.mdi-fire::before {
+ content: "\F238";
+}
+.mdi-fire-extinguisher::before {
+ content: "\FF0F";
+}
+.mdi-fire-hydrant::before {
+ content: "\F0162";
+}
+.mdi-fire-hydrant-alert::before {
+ content: "\F0163";
+}
+.mdi-fire-hydrant-off::before {
+ content: "\F0164";
+}
+.mdi-fire-truck::before {
+ content: "\F8AA";
+}
+.mdi-firebase::before {
+ content: "\F966";
+}
+.mdi-firefox::before {
+ content: "\F239";
+}
+.mdi-fireplace::before {
+ content: "\FE11";
+}
+.mdi-fireplace-off::before {
+ content: "\FE12";
+}
+.mdi-firework::before {
+ content: "\FE13";
+}
+.mdi-fish::before {
+ content: "\F23A";
+}
+.mdi-fishbowl::before {
+ content: "\FF10";
+}
+.mdi-fishbowl-outline::before {
+ content: "\FF11";
+}
+.mdi-fit-to-page::before {
+ content: "\FF12";
+}
+.mdi-fit-to-page-outline::before {
+ content: "\FF13";
+}
+.mdi-flag::before {
+ content: "\F23B";
+}
+.mdi-flag-checkered::before {
+ content: "\F23C";
+}
+.mdi-flag-minus::before {
+ content: "\FB75";
+}
+.mdi-flag-minus-outline::before {
+ content: "\F00DD";
+}
+.mdi-flag-outline::before {
+ content: "\F23D";
+}
+.mdi-flag-plus::before {
+ content: "\FB76";
+}
+.mdi-flag-plus-outline::before {
+ content: "\F00DE";
+}
+.mdi-flag-remove::before {
+ content: "\FB77";
+}
+.mdi-flag-remove-outline::before {
+ content: "\F00DF";
+}
+.mdi-flag-triangle::before {
+ content: "\F23F";
+}
+.mdi-flag-variant::before {
+ content: "\F240";
+}
+.mdi-flag-variant-outline::before {
+ content: "\F23E";
+}
+.mdi-flare::before {
+ content: "\FD4E";
+}
+.mdi-flash::before {
+ content: "\F241";
+}
+.mdi-flash-alert::before {
+ content: "\FF14";
+}
+.mdi-flash-alert-outline::before {
+ content: "\FF15";
+}
+.mdi-flash-auto::before {
+ content: "\F242";
+}
+.mdi-flash-circle::before {
+ content: "\F81F";
+}
+.mdi-flash-off::before {
+ content: "\F243";
+}
+.mdi-flash-outline::before {
+ content: "\F6D4";
+}
+.mdi-flash-red-eye::before {
+ content: "\F67A";
+}
+.mdi-flashlight::before {
+ content: "\F244";
+}
+.mdi-flashlight-off::before {
+ content: "\F245";
+}
+.mdi-flask::before {
+ content: "\F093";
+}
+.mdi-flask-empty::before {
+ content: "\F094";
+}
+.mdi-flask-empty-minus::before {
+ content: "\F0265";
+}
+.mdi-flask-empty-minus-outline::before {
+ content: "\F0266";
+}
+.mdi-flask-empty-outline::before {
+ content: "\F095";
+}
+.mdi-flask-empty-plus::before {
+ content: "\F0267";
+}
+.mdi-flask-empty-plus-outline::before {
+ content: "\F0268";
+}
+.mdi-flask-empty-remove::before {
+ content: "\F0269";
+}
+.mdi-flask-empty-remove-outline::before {
+ content: "\F026A";
+}
+.mdi-flask-minus::before {
+ content: "\F026B";
+}
+.mdi-flask-minus-outline::before {
+ content: "\F026C";
+}
+.mdi-flask-outline::before {
+ content: "\F096";
+}
+.mdi-flask-plus::before {
+ content: "\F026D";
+}
+.mdi-flask-plus-outline::before {
+ content: "\F026E";
+}
+.mdi-flask-remove::before {
+ content: "\F026F";
+}
+.mdi-flask-remove-outline::before {
+ content: "\F0270";
+}
+.mdi-flask-round-bottom::before {
+ content: "\F0276";
+}
+.mdi-flask-round-bottom-empty::before {
+ content: "\F0277";
+}
+.mdi-flask-round-bottom-empty-outline::before {
+ content: "\F0278";
+}
+.mdi-flask-round-bottom-outline::before {
+ content: "\F0279";
+}
+.mdi-flattr::before {
+ content: "\F246";
+}
+.mdi-fleur-de-lis::before {
+ content: "\F032E";
+}
+.mdi-flickr::before {
+ content: "\FCE3";
+}
+.mdi-flip-horizontal::before {
+ content: "\F0112";
+}
+.mdi-flip-to-back::before {
+ content: "\F247";
+}
+.mdi-flip-to-front::before {
+ content: "\F248";
+}
+.mdi-flip-vertical::before {
+ content: "\F0113";
+}
+.mdi-floor-lamp::before {
+ content: "\F8DC";
+}
+.mdi-floor-lamp-dual::before {
+ content: "\F0062";
+}
+.mdi-floor-lamp-variant::before {
+ content: "\F0063";
+}
+.mdi-floor-plan::before {
+ content: "\F820";
+}
+.mdi-floppy::before {
+ content: "\F249";
+}
+.mdi-floppy-variant::before {
+ content: "\F9EE";
+}
+.mdi-flower::before {
+ content: "\F24A";
+}
+.mdi-flower-outline::before {
+ content: "\F9EF";
+}
+.mdi-flower-poppy::before {
+ content: "\FCE4";
+}
+.mdi-flower-tulip::before {
+ content: "\F9F0";
+}
+.mdi-flower-tulip-outline::before {
+ content: "\F9F1";
+}
+.mdi-focus-auto::before {
+ content: "\FF6B";
+}
+.mdi-focus-field::before {
+ content: "\FF6C";
+}
+.mdi-focus-field-horizontal::before {
+ content: "\FF6D";
+}
+.mdi-focus-field-vertical::before {
+ content: "\FF6E";
+}
+.mdi-folder::before {
+ content: "\F24B";
+}
+.mdi-folder-account::before {
+ content: "\F24C";
+}
+.mdi-folder-account-outline::before {
+ content: "\FB78";
+}
+.mdi-folder-alert::before {
+ content: "\FDA8";
+}
+.mdi-folder-alert-outline::before {
+ content: "\FDA9";
+}
+.mdi-folder-clock::before {
+ content: "\FAB9";
+}
+.mdi-folder-clock-outline::before {
+ content: "\FABA";
+}
+.mdi-folder-download::before {
+ content: "\F24D";
+}
+.mdi-folder-download-outline::before {
+ content: "\F0114";
+}
+.mdi-folder-edit::before {
+ content: "\F8DD";
+}
+.mdi-folder-edit-outline::before {
+ content: "\FDAA";
+}
+.mdi-folder-google-drive::before {
+ content: "\F24E";
+}
+.mdi-folder-heart::before {
+ content: "\F0115";
+}
+.mdi-folder-heart-outline::before {
+ content: "\F0116";
+}
+.mdi-folder-home::before {
+ content: "\F00E0";
+}
+.mdi-folder-home-outline::before {
+ content: "\F00E1";
+}
+.mdi-folder-image::before {
+ content: "\F24F";
+}
+.mdi-folder-information::before {
+ content: "\F00E2";
+}
+.mdi-folder-information-outline::before {
+ content: "\F00E3";
+}
+.mdi-folder-key::before {
+ content: "\F8AB";
+}
+.mdi-folder-key-network::before {
+ content: "\F8AC";
+}
+.mdi-folder-key-network-outline::before {
+ content: "\FC5C";
+}
+.mdi-folder-key-outline::before {
+ content: "\F0117";
+}
+.mdi-folder-lock::before {
+ content: "\F250";
+}
+.mdi-folder-lock-open::before {
+ content: "\F251";
+}
+.mdi-folder-marker::before {
+ content: "\F0298";
+}
+.mdi-folder-marker-outline::before {
+ content: "\F0299";
+}
+.mdi-folder-move::before {
+ content: "\F252";
+}
+.mdi-folder-move-outline::before {
+ content: "\F0271";
+}
+.mdi-folder-multiple::before {
+ content: "\F253";
+}
+.mdi-folder-multiple-image::before {
+ content: "\F254";
+}
+.mdi-folder-multiple-outline::before {
+ content: "\F255";
+}
+.mdi-folder-music::before {
+ content: "\F0384";
+}
+.mdi-folder-music-outline::before {
+ content: "\F0385";
+}
+.mdi-folder-network::before {
+ content: "\F86F";
+}
+.mdi-folder-network-outline::before {
+ content: "\FC5D";
+}
+.mdi-folder-open::before {
+ content: "\F76F";
+}
+.mdi-folder-open-outline::before {
+ content: "\FDAB";
+}
+.mdi-folder-outline::before {
+ content: "\F256";
+}
+.mdi-folder-plus::before {
+ content: "\F257";
+}
+.mdi-folder-plus-outline::before {
+ content: "\FB79";
+}
+.mdi-folder-pound::before {
+ content: "\FCE5";
+}
+.mdi-folder-pound-outline::before {
+ content: "\FCE6";
+}
+.mdi-folder-remove::before {
+ content: "\F258";
+}
+.mdi-folder-remove-outline::before {
+ content: "\FB7A";
+}
+.mdi-folder-search::before {
+ content: "\F967";
+}
+.mdi-folder-search-outline::before {
+ content: "\F968";
+}
+.mdi-folder-settings::before {
+ content: "\F00A8";
+}
+.mdi-folder-settings-outline::before {
+ content: "\F00A9";
+}
+.mdi-folder-settings-variant::before {
+ content: "\F00AA";
+}
+.mdi-folder-settings-variant-outline::before {
+ content: "\F00AB";
+}
+.mdi-folder-star::before {
+ content: "\F69C";
+}
+.mdi-folder-star-outline::before {
+ content: "\FB7B";
+}
+.mdi-folder-swap::before {
+ content: "\FFD6";
+}
+.mdi-folder-swap-outline::before {
+ content: "\FFD7";
+}
+.mdi-folder-sync::before {
+ content: "\FCE7";
+}
+.mdi-folder-sync-outline::before {
+ content: "\FCE8";
+}
+.mdi-folder-table::before {
+ content: "\F030E";
+}
+.mdi-folder-table-outline::before {
+ content: "\F030F";
+}
+.mdi-folder-text::before {
+ content: "\FC5E";
+}
+.mdi-folder-text-outline::before {
+ content: "\FC5F";
+}
+.mdi-folder-upload::before {
+ content: "\F259";
+}
+.mdi-folder-upload-outline::before {
+ content: "\F0118";
+}
+.mdi-folder-zip::before {
+ content: "\F6EA";
+}
+.mdi-folder-zip-outline::before {
+ content: "\F7B8";
+}
+.mdi-font-awesome::before {
+ content: "\F03A";
+}
+.mdi-food::before {
+ content: "\F25A";
+}
+.mdi-food-apple::before {
+ content: "\F25B";
+}
+.mdi-food-apple-outline::before {
+ content: "\FC60";
+}
+.mdi-food-croissant::before {
+ content: "\F7C7";
+}
+.mdi-food-fork-drink::before {
+ content: "\F5F2";
+}
+.mdi-food-off::before {
+ content: "\F5F3";
+}
+.mdi-food-variant::before {
+ content: "\F25C";
+}
+.mdi-foot-print::before {
+ content: "\FF6F";
+}
+.mdi-football::before {
+ content: "\F25D";
+}
+.mdi-football-australian::before {
+ content: "\F25E";
+}
+.mdi-football-helmet::before {
+ content: "\F25F";
+}
+.mdi-forklift::before {
+ content: "\F7C8";
+}
+.mdi-format-align-bottom::before {
+ content: "\F752";
+}
+.mdi-format-align-center::before {
+ content: "\F260";
+}
+.mdi-format-align-justify::before {
+ content: "\F261";
+}
+.mdi-format-align-left::before {
+ content: "\F262";
+}
+.mdi-format-align-middle::before {
+ content: "\F753";
+}
+.mdi-format-align-right::before {
+ content: "\F263";
+}
+.mdi-format-align-top::before {
+ content: "\F754";
+}
+.mdi-format-annotation-minus::before {
+ content: "\FABB";
+}
+.mdi-format-annotation-plus::before {
+ content: "\F646";
+}
+.mdi-format-bold::before {
+ content: "\F264";
+}
+.mdi-format-clear::before {
+ content: "\F265";
+}
+.mdi-format-color-fill::before {
+ content: "\F266";
+}
+.mdi-format-color-highlight::before {
+ content: "\FE14";
+}
+.mdi-format-color-marker-cancel::before {
+ content: "\F033E";
+}
+.mdi-format-color-text::before {
+ content: "\F69D";
+}
+.mdi-format-columns::before {
+ content: "\F8DE";
+}
+.mdi-format-float-center::before {
+ content: "\F267";
+}
+.mdi-format-float-left::before {
+ content: "\F268";
+}
+.mdi-format-float-none::before {
+ content: "\F269";
+}
+.mdi-format-float-right::before {
+ content: "\F26A";
+}
+.mdi-format-font::before {
+ content: "\F6D5";
+}
+.mdi-format-font-size-decrease::before {
+ content: "\F9F2";
+}
+.mdi-format-font-size-increase::before {
+ content: "\F9F3";
+}
+.mdi-format-header-1::before {
+ content: "\F26B";
+}
+.mdi-format-header-2::before {
+ content: "\F26C";
+}
+.mdi-format-header-3::before {
+ content: "\F26D";
+}
+.mdi-format-header-4::before {
+ content: "\F26E";
+}
+.mdi-format-header-5::before {
+ content: "\F26F";
+}
+.mdi-format-header-6::before {
+ content: "\F270";
+}
+.mdi-format-header-decrease::before {
+ content: "\F271";
+}
+.mdi-format-header-equal::before {
+ content: "\F272";
+}
+.mdi-format-header-increase::before {
+ content: "\F273";
+}
+.mdi-format-header-pound::before {
+ content: "\F274";
+}
+.mdi-format-horizontal-align-center::before {
+ content: "\F61E";
+}
+.mdi-format-horizontal-align-left::before {
+ content: "\F61F";
+}
+.mdi-format-horizontal-align-right::before {
+ content: "\F620";
+}
+.mdi-format-indent-decrease::before {
+ content: "\F275";
+}
+.mdi-format-indent-increase::before {
+ content: "\F276";
+}
+.mdi-format-italic::before {
+ content: "\F277";
+}
+.mdi-format-letter-case::before {
+ content: "\FB19";
+}
+.mdi-format-letter-case-lower::before {
+ content: "\FB1A";
+}
+.mdi-format-letter-case-upper::before {
+ content: "\FB1B";
+}
+.mdi-format-letter-ends-with::before {
+ content: "\FFD8";
+}
+.mdi-format-letter-matches::before {
+ content: "\FFD9";
+}
+.mdi-format-letter-starts-with::before {
+ content: "\FFDA";
+}
+.mdi-format-line-spacing::before {
+ content: "\F278";
+}
+.mdi-format-line-style::before {
+ content: "\F5C8";
+}
+.mdi-format-line-weight::before {
+ content: "\F5C9";
+}
+.mdi-format-list-bulleted::before {
+ content: "\F279";
+}
+.mdi-format-list-bulleted-square::before {
+ content: "\FDAC";
+}
+.mdi-format-list-bulleted-triangle::before {
+ content: "\FECF";
+}
+.mdi-format-list-bulleted-type::before {
+ content: "\F27A";
+}
+.mdi-format-list-checkbox::before {
+ content: "\F969";
+}
+.mdi-format-list-checks::before {
+ content: "\F755";
+}
+.mdi-format-list-numbered::before {
+ content: "\F27B";
+}
+.mdi-format-list-numbered-rtl::before {
+ content: "\FCE9";
+}
+.mdi-format-list-text::before {
+ content: "\F029A";
+}
+.mdi-format-overline::before {
+ content: "\FED0";
+}
+.mdi-format-page-break::before {
+ content: "\F6D6";
+}
+.mdi-format-paint::before {
+ content: "\F27C";
+}
+.mdi-format-paragraph::before {
+ content: "\F27D";
+}
+.mdi-format-pilcrow::before {
+ content: "\F6D7";
+}
+.mdi-format-quote-close::before {
+ content: "\F27E";
+}
+.mdi-format-quote-close-outline::before {
+ content: "\F01D3";
+}
+.mdi-format-quote-open::before {
+ content: "\F756";
+}
+.mdi-format-quote-open-outline::before {
+ content: "\F01D2";
+}
+.mdi-format-rotate-90::before {
+ content: "\F6A9";
+}
+.mdi-format-section::before {
+ content: "\F69E";
+}
+.mdi-format-size::before {
+ content: "\F27F";
+}
+.mdi-format-strikethrough::before {
+ content: "\F280";
+}
+.mdi-format-strikethrough-variant::before {
+ content: "\F281";
+}
+.mdi-format-subscript::before {
+ content: "\F282";
+}
+.mdi-format-superscript::before {
+ content: "\F283";
+}
+.mdi-format-text::before {
+ content: "\F284";
+}
+.mdi-format-text-rotation-angle-down::before {
+ content: "\FFDB";
+}
+.mdi-format-text-rotation-angle-up::before {
+ content: "\FFDC";
+}
+.mdi-format-text-rotation-down::before {
+ content: "\FD4F";
+}
+.mdi-format-text-rotation-down-vertical::before {
+ content: "\FFDD";
+}
+.mdi-format-text-rotation-none::before {
+ content: "\FD50";
+}
+.mdi-format-text-rotation-up::before {
+ content: "\FFDE";
+}
+.mdi-format-text-rotation-vertical::before {
+ content: "\FFDF";
+}
+.mdi-format-text-variant::before {
+ content: "\FE15";
+}
+.mdi-format-text-wrapping-clip::before {
+ content: "\FCEA";
+}
+.mdi-format-text-wrapping-overflow::before {
+ content: "\FCEB";
+}
+.mdi-format-text-wrapping-wrap::before {
+ content: "\FCEC";
+}
+.mdi-format-textbox::before {
+ content: "\FCED";
+}
+.mdi-format-textdirection-l-to-r::before {
+ content: "\F285";
+}
+.mdi-format-textdirection-r-to-l::before {
+ content: "\F286";
+}
+.mdi-format-title::before {
+ content: "\F5F4";
+}
+.mdi-format-underline::before {
+ content: "\F287";
+}
+.mdi-format-vertical-align-bottom::before {
+ content: "\F621";
+}
+.mdi-format-vertical-align-center::before {
+ content: "\F622";
+}
+.mdi-format-vertical-align-top::before {
+ content: "\F623";
+}
+.mdi-format-wrap-inline::before {
+ content: "\F288";
+}
+.mdi-format-wrap-square::before {
+ content: "\F289";
+}
+.mdi-format-wrap-tight::before {
+ content: "\F28A";
+}
+.mdi-format-wrap-top-bottom::before {
+ content: "\F28B";
+}
+.mdi-forum::before {
+ content: "\F28C";
+}
+.mdi-forum-outline::before {
+ content: "\F821";
+}
+.mdi-forward::before {
+ content: "\F28D";
+}
+.mdi-forwardburger::before {
+ content: "\FD51";
+}
+.mdi-fountain::before {
+ content: "\F96A";
+}
+.mdi-fountain-pen::before {
+ content: "\FCEE";
+}
+.mdi-fountain-pen-tip::before {
+ content: "\FCEF";
+}
+.mdi-foursquare::before {
+ content: "\F28E";
+}
+.mdi-freebsd::before {
+ content: "\F8DF";
+}
+.mdi-frequently-asked-questions::before {
+ content: "\FED1";
+}
+.mdi-fridge::before {
+ content: "\F290";
+}
+.mdi-fridge-alert::before {
+ content: "\F01DC";
+}
+.mdi-fridge-alert-outline::before {
+ content: "\F01DD";
+}
+.mdi-fridge-bottom::before {
+ content: "\F292";
+}
+.mdi-fridge-off::before {
+ content: "\F01DA";
+}
+.mdi-fridge-off-outline::before {
+ content: "\F01DB";
+}
+.mdi-fridge-outline::before {
+ content: "\F28F";
+}
+.mdi-fridge-top::before {
+ content: "\F291";
+}
+.mdi-fruit-cherries::before {
+ content: "\F0064";
+}
+.mdi-fruit-citrus::before {
+ content: "\F0065";
+}
+.mdi-fruit-grapes::before {
+ content: "\F0066";
+}
+.mdi-fruit-grapes-outline::before {
+ content: "\F0067";
+}
+.mdi-fruit-pineapple::before {
+ content: "\F0068";
+}
+.mdi-fruit-watermelon::before {
+ content: "\F0069";
+}
+.mdi-fuel::before {
+ content: "\F7C9";
+}
+.mdi-fullscreen::before {
+ content: "\F293";
+}
+.mdi-fullscreen-exit::before {
+ content: "\F294";
+}
+.mdi-function::before {
+ content: "\F295";
+}
+.mdi-function-variant::before {
+ content: "\F870";
+}
+.mdi-furigana-horizontal::before {
+ content: "\F00AC";
+}
+.mdi-furigana-vertical::before {
+ content: "\F00AD";
+}
+.mdi-fuse::before {
+ content: "\FC61";
+}
+.mdi-fuse-blade::before {
+ content: "\FC62";
+}
+.mdi-gamepad::before {
+ content: "\F296";
+}
+.mdi-gamepad-circle::before {
+ content: "\FE16";
+}
+.mdi-gamepad-circle-down::before {
+ content: "\FE17";
+}
+.mdi-gamepad-circle-left::before {
+ content: "\FE18";
+}
+.mdi-gamepad-circle-outline::before {
+ content: "\FE19";
+}
+.mdi-gamepad-circle-right::before {
+ content: "\FE1A";
+}
+.mdi-gamepad-circle-up::before {
+ content: "\FE1B";
+}
+.mdi-gamepad-down::before {
+ content: "\FE1C";
+}
+.mdi-gamepad-left::before {
+ content: "\FE1D";
+}
+.mdi-gamepad-right::before {
+ content: "\FE1E";
+}
+.mdi-gamepad-round::before {
+ content: "\FE1F";
+}
+.mdi-gamepad-round-down::before {
+ content: "\FE7E";
+}
+.mdi-gamepad-round-left::before {
+ content: "\FE7F";
+}
+.mdi-gamepad-round-outline::before {
+ content: "\FE80";
+}
+.mdi-gamepad-round-right::before {
+ content: "\FE81";
+}
+.mdi-gamepad-round-up::before {
+ content: "\FE82";
+}
+.mdi-gamepad-square::before {
+ content: "\FED2";
+}
+.mdi-gamepad-square-outline::before {
+ content: "\FED3";
+}
+.mdi-gamepad-up::before {
+ content: "\FE83";
+}
+.mdi-gamepad-variant::before {
+ content: "\F297";
+}
+.mdi-gamepad-variant-outline::before {
+ content: "\FED4";
+}
+.mdi-gamma::before {
+ content: "\F0119";
+}
+.mdi-gantry-crane::before {
+ content: "\FDAD";
+}
+.mdi-garage::before {
+ content: "\F6D8";
+}
+.mdi-garage-alert::before {
+ content: "\F871";
+}
+.mdi-garage-alert-variant::before {
+ content: "\F0300";
+}
+.mdi-garage-open::before {
+ content: "\F6D9";
+}
+.mdi-garage-open-variant::before {
+ content: "\F02FF";
+}
+.mdi-garage-variant::before {
+ content: "\F02FE";
+}
+.mdi-gas-cylinder::before {
+ content: "\F647";
+}
+.mdi-gas-station::before {
+ content: "\F298";
+}
+.mdi-gas-station-outline::before {
+ content: "\FED5";
+}
+.mdi-gate::before {
+ content: "\F299";
+}
+.mdi-gate-and::before {
+ content: "\F8E0";
+}
+.mdi-gate-arrow-right::before {
+ content: "\F0194";
+}
+.mdi-gate-nand::before {
+ content: "\F8E1";
+}
+.mdi-gate-nor::before {
+ content: "\F8E2";
+}
+.mdi-gate-not::before {
+ content: "\F8E3";
+}
+.mdi-gate-open::before {
+ content: "\F0195";
+}
+.mdi-gate-or::before {
+ content: "\F8E4";
+}
+.mdi-gate-xnor::before {
+ content: "\F8E5";
+}
+.mdi-gate-xor::before {
+ content: "\F8E6";
+}
+.mdi-gatsby::before {
+ content: "\FE84";
+}
+.mdi-gauge::before {
+ content: "\F29A";
+}
+.mdi-gauge-empty::before {
+ content: "\F872";
+}
+.mdi-gauge-full::before {
+ content: "\F873";
+}
+.mdi-gauge-low::before {
+ content: "\F874";
+}
+.mdi-gavel::before {
+ content: "\F29B";
+}
+.mdi-gender-female::before {
+ content: "\F29C";
+}
+.mdi-gender-male::before {
+ content: "\F29D";
+}
+.mdi-gender-male-female::before {
+ content: "\F29E";
+}
+.mdi-gender-male-female-variant::before {
+ content: "\F016A";
+}
+.mdi-gender-non-binary::before {
+ content: "\F016B";
+}
+.mdi-gender-transgender::before {
+ content: "\F29F";
+}
+.mdi-gentoo::before {
+ content: "\F8E7";
+}
+.mdi-gesture::before {
+ content: "\F7CA";
+}
+.mdi-gesture-double-tap::before {
+ content: "\F73B";
+}
+.mdi-gesture-pinch::before {
+ content: "\FABC";
+}
+.mdi-gesture-spread::before {
+ content: "\FABD";
+}
+.mdi-gesture-swipe::before {
+ content: "\FD52";
+}
+.mdi-gesture-swipe-down::before {
+ content: "\F73C";
+}
+.mdi-gesture-swipe-horizontal::before {
+ content: "\FABE";
+}
+.mdi-gesture-swipe-left::before {
+ content: "\F73D";
+}
+.mdi-gesture-swipe-right::before {
+ content: "\F73E";
+}
+.mdi-gesture-swipe-up::before {
+ content: "\F73F";
+}
+.mdi-gesture-swipe-vertical::before {
+ content: "\FABF";
+}
+.mdi-gesture-tap::before {
+ content: "\F740";
+}
+.mdi-gesture-tap-box::before {
+ content: "\F02D4";
+}
+.mdi-gesture-tap-button::before {
+ content: "\F02D3";
+}
+.mdi-gesture-tap-hold::before {
+ content: "\FD53";
+}
+.mdi-gesture-two-double-tap::before {
+ content: "\F741";
+}
+.mdi-gesture-two-tap::before {
+ content: "\F742";
+}
+.mdi-ghost::before {
+ content: "\F2A0";
+}
+.mdi-ghost-off::before {
+ content: "\F9F4";
+}
+.mdi-gif::before {
+ content: "\FD54";
+}
+.mdi-gift::before {
+ content: "\FE85";
+}
+.mdi-gift-outline::before {
+ content: "\F2A1";
+}
+.mdi-git::before {
+ content: "\F2A2";
+}
+.mdi-github-box::before {
+ content: "\F2A3";
+}
+.mdi-github-circle::before {
+ content: "\F2A4";
+}
+.mdi-github-face::before {
+ content: "\F6DA";
+}
+.mdi-gitlab::before {
+ content: "\FB7C";
+}
+.mdi-glass-cocktail::before {
+ content: "\F356";
+}
+.mdi-glass-flute::before {
+ content: "\F2A5";
+}
+.mdi-glass-mug::before {
+ content: "\F2A6";
+}
+.mdi-glass-mug-variant::before {
+ content: "\F0141";
+}
+.mdi-glass-pint-outline::before {
+ content: "\F0338";
+}
+.mdi-glass-stange::before {
+ content: "\F2A7";
+}
+.mdi-glass-tulip::before {
+ content: "\F2A8";
+}
+.mdi-glass-wine::before {
+ content: "\F875";
+}
+.mdi-glassdoor::before {
+ content: "\F2A9";
+}
+.mdi-glasses::before {
+ content: "\F2AA";
+}
+.mdi-globe-light::before {
+ content: "\F0302";
+}
+.mdi-globe-model::before {
+ content: "\F8E8";
+}
+.mdi-gmail::before {
+ content: "\F2AB";
+}
+.mdi-gnome::before {
+ content: "\F2AC";
+}
+.mdi-go-kart::before {
+ content: "\FD55";
+}
+.mdi-go-kart-track::before {
+ content: "\FD56";
+}
+.mdi-gog::before {
+ content: "\FB7D";
+}
+.mdi-gold::before {
+ content: "\F027A";
+}
+.mdi-golf::before {
+ content: "\F822";
+}
+.mdi-golf-cart::before {
+ content: "\F01CF";
+}
+.mdi-golf-tee::before {
+ content: "\F00AE";
+}
+.mdi-gondola::before {
+ content: "\F685";
+}
+.mdi-goodreads::before {
+ content: "\FD57";
+}
+.mdi-google::before {
+ content: "\F2AD";
+}
+.mdi-google-adwords::before {
+ content: "\FC63";
+}
+.mdi-google-analytics::before {
+ content: "\F7CB";
+}
+.mdi-google-assistant::before {
+ content: "\F7CC";
+}
+.mdi-google-cardboard::before {
+ content: "\F2AE";
+}
+.mdi-google-chrome::before {
+ content: "\F2AF";
+}
+.mdi-google-circles::before {
+ content: "\F2B0";
+}
+.mdi-google-circles-communities::before {
+ content: "\F2B1";
+}
+.mdi-google-circles-extended::before {
+ content: "\F2B2";
+}
+.mdi-google-circles-group::before {
+ content: "\F2B3";
+}
+.mdi-google-classroom::before {
+ content: "\F2C0";
+}
+.mdi-google-cloud::before {
+ content: "\F0221";
+}
+.mdi-google-controller::before {
+ content: "\F2B4";
+}
+.mdi-google-controller-off::before {
+ content: "\F2B5";
+}
+.mdi-google-downasaur::before {
+ content: "\F038D";
+}
+.mdi-google-drive::before {
+ content: "\F2B6";
+}
+.mdi-google-earth::before {
+ content: "\F2B7";
+}
+.mdi-google-fit::before {
+ content: "\F96B";
+}
+.mdi-google-glass::before {
+ content: "\F2B8";
+}
+.mdi-google-hangouts::before {
+ content: "\F2C9";
+}
+.mdi-google-home::before {
+ content: "\F823";
+}
+.mdi-google-keep::before {
+ content: "\F6DB";
+}
+.mdi-google-lens::before {
+ content: "\F9F5";
+}
+.mdi-google-maps::before {
+ content: "\F5F5";
+}
+.mdi-google-my-business::before {
+ content: "\F006A";
+}
+.mdi-google-nearby::before {
+ content: "\F2B9";
+}
+.mdi-google-pages::before {
+ content: "\F2BA";
+}
+.mdi-google-photos::before {
+ content: "\F6DC";
+}
+.mdi-google-physical-web::before {
+ content: "\F2BB";
+}
+.mdi-google-play::before {
+ content: "\F2BC";
+}
+.mdi-google-plus::before {
+ content: "\F2BD";
+}
+.mdi-google-plus-box::before {
+ content: "\F2BE";
+}
+.mdi-google-podcast::before {
+ content: "\FED6";
+}
+.mdi-google-spreadsheet::before {
+ content: "\F9F6";
+}
+.mdi-google-street-view::before {
+ content: "\FC64";
+}
+.mdi-google-translate::before {
+ content: "\F2BF";
+}
+.mdi-gradient::before {
+ content: "\F69F";
+}
+.mdi-grain::before {
+ content: "\FD58";
+}
+.mdi-graph::before {
+ content: "\F006B";
+}
+.mdi-graph-outline::before {
+ content: "\F006C";
+}
+.mdi-graphql::before {
+ content: "\F876";
+}
+.mdi-grave-stone::before {
+ content: "\FB7E";
+}
+.mdi-grease-pencil::before {
+ content: "\F648";
+}
+.mdi-greater-than::before {
+ content: "\F96C";
+}
+.mdi-greater-than-or-equal::before {
+ content: "\F96D";
+}
+.mdi-grid::before {
+ content: "\F2C1";
+}
+.mdi-grid-large::before {
+ content: "\F757";
+}
+.mdi-grid-off::before {
+ content: "\F2C2";
+}
+.mdi-grill::before {
+ content: "\FE86";
+}
+.mdi-grill-outline::before {
+ content: "\F01B5";
+}
+.mdi-group::before {
+ content: "\F2C3";
+}
+.mdi-guitar-acoustic::before {
+ content: "\F770";
+}
+.mdi-guitar-electric::before {
+ content: "\F2C4";
+}
+.mdi-guitar-pick::before {
+ content: "\F2C5";
+}
+.mdi-guitar-pick-outline::before {
+ content: "\F2C6";
+}
+.mdi-guy-fawkes-mask::before {
+ content: "\F824";
+}
+.mdi-hackernews::before {
+ content: "\F624";
+}
+.mdi-hail::before {
+ content: "\FAC0";
+}
+.mdi-hair-dryer::before {
+ content: "\F011A";
+}
+.mdi-hair-dryer-outline::before {
+ content: "\F011B";
+}
+.mdi-halloween::before {
+ content: "\FB7F";
+}
+.mdi-hamburger::before {
+ content: "\F684";
+}
+.mdi-hammer::before {
+ content: "\F8E9";
+}
+.mdi-hammer-screwdriver::before {
+ content: "\F034D";
+}
+.mdi-hammer-wrench::before {
+ content: "\F034E";
+}
+.mdi-hand::before {
+ content: "\FA4E";
+}
+.mdi-hand-heart::before {
+ content: "\F011C";
+}
+.mdi-hand-left::before {
+ content: "\FE87";
+}
+.mdi-hand-okay::before {
+ content: "\FA4F";
+}
+.mdi-hand-peace::before {
+ content: "\FA50";
+}
+.mdi-hand-peace-variant::before {
+ content: "\FA51";
+}
+.mdi-hand-pointing-down::before {
+ content: "\FA52";
+}
+.mdi-hand-pointing-left::before {
+ content: "\FA53";
+}
+.mdi-hand-pointing-right::before {
+ content: "\F2C7";
+}
+.mdi-hand-pointing-up::before {
+ content: "\FA54";
+}
+.mdi-hand-right::before {
+ content: "\FE88";
+}
+.mdi-hand-saw::before {
+ content: "\FE89";
+}
+.mdi-handball::before {
+ content: "\FF70";
+}
+.mdi-handcuffs::before {
+ content: "\F0169";
+}
+.mdi-handshake::before {
+ content: "\F0243";
+}
+.mdi-hanger::before {
+ content: "\F2C8";
+}
+.mdi-hard-hat::before {
+ content: "\F96E";
+}
+.mdi-harddisk::before {
+ content: "\F2CA";
+}
+.mdi-harddisk-plus::before {
+ content: "\F006D";
+}
+.mdi-harddisk-remove::before {
+ content: "\F006E";
+}
+.mdi-hat-fedora::before {
+ content: "\FB80";
+}
+.mdi-hazard-lights::before {
+ content: "\FC65";
+}
+.mdi-hdr::before {
+ content: "\FD59";
+}
+.mdi-hdr-off::before {
+ content: "\FD5A";
+}
+.mdi-head::before {
+ content: "\F0389";
+}
+.mdi-head-alert::before {
+ content: "\F0363";
+}
+.mdi-head-alert-outline::before {
+ content: "\F0364";
+}
+.mdi-head-check::before {
+ content: "\F0365";
+}
+.mdi-head-check-outline::before {
+ content: "\F0366";
+}
+.mdi-head-cog::before {
+ content: "\F0367";
+}
+.mdi-head-cog-outline::before {
+ content: "\F0368";
+}
+.mdi-head-dots-horizontal::before {
+ content: "\F0369";
+}
+.mdi-head-dots-horizontal-outline::before {
+ content: "\F036A";
+}
+.mdi-head-flash::before {
+ content: "\F036B";
+}
+.mdi-head-flash-outline::before {
+ content: "\F036C";
+}
+.mdi-head-heart::before {
+ content: "\F036D";
+}
+.mdi-head-heart-outline::before {
+ content: "\F036E";
+}
+.mdi-head-lightbulb::before {
+ content: "\F036F";
+}
+.mdi-head-lightbulb-outline::before {
+ content: "\F0370";
+}
+.mdi-head-minus::before {
+ content: "\F0371";
+}
+.mdi-head-minus-outline::before {
+ content: "\F0372";
+}
+.mdi-head-outline::before {
+ content: "\F038A";
+}
+.mdi-head-plus::before {
+ content: "\F0373";
+}
+.mdi-head-plus-outline::before {
+ content: "\F0374";
+}
+.mdi-head-question::before {
+ content: "\F0375";
+}
+.mdi-head-question-outline::before {
+ content: "\F0376";
+}
+.mdi-head-remove::before {
+ content: "\F0377";
+}
+.mdi-head-remove-outline::before {
+ content: "\F0378";
+}
+.mdi-head-snowflake::before {
+ content: "\F0379";
+}
+.mdi-head-snowflake-outline::before {
+ content: "\F037A";
+}
+.mdi-head-sync::before {
+ content: "\F037B";
+}
+.mdi-head-sync-outline::before {
+ content: "\F037C";
+}
+.mdi-headphones::before {
+ content: "\F2CB";
+}
+.mdi-headphones-bluetooth::before {
+ content: "\F96F";
+}
+.mdi-headphones-box::before {
+ content: "\F2CC";
+}
+.mdi-headphones-off::before {
+ content: "\F7CD";
+}
+.mdi-headphones-settings::before {
+ content: "\F2CD";
+}
+.mdi-headset::before {
+ content: "\F2CE";
+}
+.mdi-headset-dock::before {
+ content: "\F2CF";
+}
+.mdi-headset-off::before {
+ content: "\F2D0";
+}
+.mdi-heart::before {
+ content: "\F2D1";
+}
+.mdi-heart-box::before {
+ content: "\F2D2";
+}
+.mdi-heart-box-outline::before {
+ content: "\F2D3";
+}
+.mdi-heart-broken::before {
+ content: "\F2D4";
+}
+.mdi-heart-broken-outline::before {
+ content: "\FCF0";
+}
+.mdi-heart-circle::before {
+ content: "\F970";
+}
+.mdi-heart-circle-outline::before {
+ content: "\F971";
+}
+.mdi-heart-flash::before {
+ content: "\FF16";
+}
+.mdi-heart-half::before {
+ content: "\F6DE";
+}
+.mdi-heart-half-full::before {
+ content: "\F6DD";
+}
+.mdi-heart-half-outline::before {
+ content: "\F6DF";
+}
+.mdi-heart-multiple::before {
+ content: "\FA55";
+}
+.mdi-heart-multiple-outline::before {
+ content: "\FA56";
+}
+.mdi-heart-off::before {
+ content: "\F758";
+}
+.mdi-heart-outline::before {
+ content: "\F2D5";
+}
+.mdi-heart-pulse::before {
+ content: "\F5F6";
+}
+.mdi-helicopter::before {
+ content: "\FAC1";
+}
+.mdi-help::before {
+ content: "\F2D6";
+}
+.mdi-help-box::before {
+ content: "\F78A";
+}
+.mdi-help-circle::before {
+ content: "\F2D7";
+}
+.mdi-help-circle-outline::before {
+ content: "\F625";
+}
+.mdi-help-network::before {
+ content: "\F6F4";
+}
+.mdi-help-network-outline::before {
+ content: "\FC66";
+}
+.mdi-help-rhombus::before {
+ content: "\FB81";
+}
+.mdi-help-rhombus-outline::before {
+ content: "\FB82";
+}
+.mdi-hexadecimal::before {
+ content: "\F02D2";
+}
+.mdi-hexagon::before {
+ content: "\F2D8";
+}
+.mdi-hexagon-multiple::before {
+ content: "\F6E0";
+}
+.mdi-hexagon-multiple-outline::before {
+ content: "\F011D";
+}
+.mdi-hexagon-outline::before {
+ content: "\F2D9";
+}
+.mdi-hexagon-slice-1::before {
+ content: "\FAC2";
+}
+.mdi-hexagon-slice-2::before {
+ content: "\FAC3";
+}
+.mdi-hexagon-slice-3::before {
+ content: "\FAC4";
+}
+.mdi-hexagon-slice-4::before {
+ content: "\FAC5";
+}
+.mdi-hexagon-slice-5::before {
+ content: "\FAC6";
+}
+.mdi-hexagon-slice-6::before {
+ content: "\FAC7";
+}
+.mdi-hexagram::before {
+ content: "\FAC8";
+}
+.mdi-hexagram-outline::before {
+ content: "\FAC9";
+}
+.mdi-high-definition::before {
+ content: "\F7CE";
+}
+.mdi-high-definition-box::before {
+ content: "\F877";
+}
+.mdi-highway::before {
+ content: "\F5F7";
+}
+.mdi-hiking::before {
+ content: "\FD5B";
+}
+.mdi-hinduism::before {
+ content: "\F972";
+}
+.mdi-history::before {
+ content: "\F2DA";
+}
+.mdi-hockey-puck::before {
+ content: "\F878";
+}
+.mdi-hockey-sticks::before {
+ content: "\F879";
+}
+.mdi-hololens::before {
+ content: "\F2DB";
+}
+.mdi-home::before {
+ content: "\F2DC";
+}
+.mdi-home-account::before {
+ content: "\F825";
+}
+.mdi-home-alert::before {
+ content: "\F87A";
+}
+.mdi-home-analytics::before {
+ content: "\FED7";
+}
+.mdi-home-assistant::before {
+ content: "\F7CF";
+}
+.mdi-home-automation::before {
+ content: "\F7D0";
+}
+.mdi-home-circle::before {
+ content: "\F7D1";
+}
+.mdi-home-circle-outline::before {
+ content: "\F006F";
+}
+.mdi-home-city::before {
+ content: "\FCF1";
+}
+.mdi-home-city-outline::before {
+ content: "\FCF2";
+}
+.mdi-home-currency-usd::before {
+ content: "\F8AE";
+}
+.mdi-home-edit::before {
+ content: "\F0184";
+}
+.mdi-home-edit-outline::before {
+ content: "\F0185";
+}
+.mdi-home-export-outline::before {
+ content: "\FFB8";
+}
+.mdi-home-flood::before {
+ content: "\FF17";
+}
+.mdi-home-floor-0::before {
+ content: "\FDAE";
+}
+.mdi-home-floor-1::before {
+ content: "\FD5C";
+}
+.mdi-home-floor-2::before {
+ content: "\FD5D";
+}
+.mdi-home-floor-3::before {
+ content: "\FD5E";
+}
+.mdi-home-floor-a::before {
+ content: "\FD5F";
+}
+.mdi-home-floor-b::before {
+ content: "\FD60";
+}
+.mdi-home-floor-g::before {
+ content: "\FD61";
+}
+.mdi-home-floor-l::before {
+ content: "\FD62";
+}
+.mdi-home-floor-negative-1::before {
+ content: "\FDAF";
+}
+.mdi-home-group::before {
+ content: "\FDB0";
+}
+.mdi-home-heart::before {
+ content: "\F826";
+}
+.mdi-home-import-outline::before {
+ content: "\FFB9";
+}
+.mdi-home-lightbulb::before {
+ content: "\F027C";
+}
+.mdi-home-lightbulb-outline::before {
+ content: "\F027D";
+}
+.mdi-home-lock::before {
+ content: "\F8EA";
+}
+.mdi-home-lock-open::before {
+ content: "\F8EB";
+}
+.mdi-home-map-marker::before {
+ content: "\F5F8";
+}
+.mdi-home-minus::before {
+ content: "\F973";
+}
+.mdi-home-modern::before {
+ content: "\F2DD";
+}
+.mdi-home-outline::before {
+ content: "\F6A0";
+}
+.mdi-home-plus::before {
+ content: "\F974";
+}
+.mdi-home-remove::before {
+ content: "\F0272";
+}
+.mdi-home-roof::before {
+ content: "\F0156";
+}
+.mdi-home-thermometer::before {
+ content: "\FF71";
+}
+.mdi-home-thermometer-outline::before {
+ content: "\FF72";
+}
+.mdi-home-variant::before {
+ content: "\F2DE";
+}
+.mdi-home-variant-outline::before {
+ content: "\FB83";
+}
+.mdi-hook::before {
+ content: "\F6E1";
+}
+.mdi-hook-off::before {
+ content: "\F6E2";
+}
+.mdi-hops::before {
+ content: "\F2DF";
+}
+.mdi-horizontal-rotate-clockwise::before {
+ content: "\F011E";
+}
+.mdi-horizontal-rotate-counterclockwise::before {
+ content: "\F011F";
+}
+.mdi-horseshoe::before {
+ content: "\FA57";
+}
+.mdi-hospital::before {
+ content: "\F0017";
+}
+.mdi-hospital-box::before {
+ content: "\F2E0";
+}
+.mdi-hospital-box-outline::before {
+ content: "\F0018";
+}
+.mdi-hospital-building::before {
+ content: "\F2E1";
+}
+.mdi-hospital-marker::before {
+ content: "\F2E2";
+}
+.mdi-hot-tub::before {
+ content: "\F827";
+}
+.mdi-hotel::before {
+ content: "\F2E3";
+}
+.mdi-houzz::before {
+ content: "\F2E4";
+}
+.mdi-houzz-box::before {
+ content: "\F2E5";
+}
+.mdi-hubspot::before {
+ content: "\FCF3";
+}
+.mdi-hulu::before {
+ content: "\F828";
+}
+.mdi-human::before {
+ content: "\F2E6";
+}
+.mdi-human-child::before {
+ content: "\F2E7";
+}
+.mdi-human-female::before {
+ content: "\F649";
+}
+.mdi-human-female-boy::before {
+ content: "\FA58";
+}
+.mdi-human-female-female::before {
+ content: "\FA59";
+}
+.mdi-human-female-girl::before {
+ content: "\FA5A";
+}
+.mdi-human-greeting::before {
+ content: "\F64A";
+}
+.mdi-human-handsdown::before {
+ content: "\F64B";
+}
+.mdi-human-handsup::before {
+ content: "\F64C";
+}
+.mdi-human-male::before {
+ content: "\F64D";
+}
+.mdi-human-male-boy::before {
+ content: "\FA5B";
+}
+.mdi-human-male-female::before {
+ content: "\F2E8";
+}
+.mdi-human-male-girl::before {
+ content: "\FA5C";
+}
+.mdi-human-male-height::before {
+ content: "\FF18";
+}
+.mdi-human-male-height-variant::before {
+ content: "\FF19";
+}
+.mdi-human-male-male::before {
+ content: "\FA5D";
+}
+.mdi-human-pregnant::before {
+ content: "\F5CF";
+}
+.mdi-humble-bundle::before {
+ content: "\F743";
+}
+.mdi-hvac::before {
+ content: "\F037D";
+}
+.mdi-hydraulic-oil-level::before {
+ content: "\F034F";
+}
+.mdi-hydraulic-oil-temperature::before {
+ content: "\F0350";
+}
+.mdi-hydro-power::before {
+ content: "\F0310";
+}
+.mdi-ice-cream::before {
+ content: "\F829";
+}
+.mdi-ice-pop::before {
+ content: "\FF1A";
+}
+.mdi-id-card::before {
+ content: "\FFE0";
+}
+.mdi-identifier::before {
+ content: "\FF1B";
+}
+.mdi-ideogram-cjk::before {
+ content: "\F035C";
+}
+.mdi-ideogram-cjk-variant::before {
+ content: "\F035D";
+}
+.mdi-iframe::before {
+ content: "\FC67";
+}
+.mdi-iframe-array::before {
+ content: "\F0120";
+}
+.mdi-iframe-array-outline::before {
+ content: "\F0121";
+}
+.mdi-iframe-braces::before {
+ content: "\F0122";
+}
+.mdi-iframe-braces-outline::before {
+ content: "\F0123";
+}
+.mdi-iframe-outline::before {
+ content: "\FC68";
+}
+.mdi-iframe-parentheses::before {
+ content: "\F0124";
+}
+.mdi-iframe-parentheses-outline::before {
+ content: "\F0125";
+}
+.mdi-iframe-variable::before {
+ content: "\F0126";
+}
+.mdi-iframe-variable-outline::before {
+ content: "\F0127";
+}
+.mdi-image::before {
+ content: "\F2E9";
+}
+.mdi-image-album::before {
+ content: "\F2EA";
+}
+.mdi-image-area::before {
+ content: "\F2EB";
+}
+.mdi-image-area-close::before {
+ content: "\F2EC";
+}
+.mdi-image-auto-adjust::before {
+ content: "\FFE1";
+}
+.mdi-image-broken::before {
+ content: "\F2ED";
+}
+.mdi-image-broken-variant::before {
+ content: "\F2EE";
+}
+.mdi-image-edit::before {
+ content: "\F020E";
+}
+.mdi-image-edit-outline::before {
+ content: "\F020F";
+}
+.mdi-image-filter::before {
+ content: "\F2EF";
+}
+.mdi-image-filter-black-white::before {
+ content: "\F2F0";
+}
+.mdi-image-filter-center-focus::before {
+ content: "\F2F1";
+}
+.mdi-image-filter-center-focus-strong::before {
+ content: "\FF1C";
+}
+.mdi-image-filter-center-focus-strong-outline::before {
+ content: "\FF1D";
+}
+.mdi-image-filter-center-focus-weak::before {
+ content: "\F2F2";
+}
+.mdi-image-filter-drama::before {
+ content: "\F2F3";
+}
+.mdi-image-filter-frames::before {
+ content: "\F2F4";
+}
+.mdi-image-filter-hdr::before {
+ content: "\F2F5";
+}
+.mdi-image-filter-none::before {
+ content: "\F2F6";
+}
+.mdi-image-filter-tilt-shift::before {
+ content: "\F2F7";
+}
+.mdi-image-filter-vintage::before {
+ content: "\F2F8";
+}
+.mdi-image-frame::before {
+ content: "\FE8A";
+}
+.mdi-image-move::before {
+ content: "\F9F7";
+}
+.mdi-image-multiple::before {
+ content: "\F2F9";
+}
+.mdi-image-off::before {
+ content: "\F82A";
+}
+.mdi-image-off-outline::before {
+ content: "\F01FC";
+}
+.mdi-image-outline::before {
+ content: "\F975";
+}
+.mdi-image-plus::before {
+ content: "\F87B";
+}
+.mdi-image-search::before {
+ content: "\F976";
+}
+.mdi-image-search-outline::before {
+ content: "\F977";
+}
+.mdi-image-size-select-actual::before {
+ content: "\FC69";
+}
+.mdi-image-size-select-large::before {
+ content: "\FC6A";
+}
+.mdi-image-size-select-small::before {
+ content: "\FC6B";
+}
+.mdi-import::before {
+ content: "\F2FA";
+}
+.mdi-inbox::before {
+ content: "\F686";
+}
+.mdi-inbox-arrow-down::before {
+ content: "\F2FB";
+}
+.mdi-inbox-arrow-down-outline::before {
+ content: "\F029B";
+}
+.mdi-inbox-arrow-up::before {
+ content: "\F3D1";
+}
+.mdi-inbox-arrow-up-outline::before {
+ content: "\F029C";
+}
+.mdi-inbox-full::before {
+ content: "\F029D";
+}
+.mdi-inbox-full-outline::before {
+ content: "\F029E";
+}
+.mdi-inbox-multiple::before {
+ content: "\F8AF";
+}
+.mdi-inbox-multiple-outline::before {
+ content: "\FB84";
+}
+.mdi-inbox-outline::before {
+ content: "\F029F";
+}
+.mdi-incognito::before {
+ content: "\F5F9";
+}
+.mdi-infinity::before {
+ content: "\F6E3";
+}
+.mdi-information::before {
+ content: "\F2FC";
+}
+.mdi-information-outline::before {
+ content: "\F2FD";
+}
+.mdi-information-variant::before {
+ content: "\F64E";
+}
+.mdi-instagram::before {
+ content: "\F2FE";
+}
+.mdi-instapaper::before {
+ content: "\F2FF";
+}
+.mdi-instrument-triangle::before {
+ content: "\F0070";
+}
+.mdi-internet-explorer::before {
+ content: "\F300";
+}
+.mdi-invert-colors::before {
+ content: "\F301";
+}
+.mdi-invert-colors-off::before {
+ content: "\FE8B";
+}
+.mdi-iobroker::before {
+ content: "\F0313";
+}
+.mdi-ip::before {
+ content: "\FA5E";
+}
+.mdi-ip-network::before {
+ content: "\FA5F";
+}
+.mdi-ip-network-outline::before {
+ content: "\FC6C";
+}
+.mdi-ipod::before {
+ content: "\FC6D";
+}
+.mdi-islam::before {
+ content: "\F978";
+}
+.mdi-island::before {
+ content: "\F0071";
+}
+.mdi-itunes::before {
+ content: "\F676";
+}
+.mdi-iv-bag::before {
+ content: "\F00E4";
+}
+.mdi-jabber::before {
+ content: "\FDB1";
+}
+.mdi-jeepney::before {
+ content: "\F302";
+}
+.mdi-jellyfish::before {
+ content: "\FF1E";
+}
+.mdi-jellyfish-outline::before {
+ content: "\FF1F";
+}
+.mdi-jira::before {
+ content: "\F303";
+}
+.mdi-jquery::before {
+ content: "\F87C";
+}
+.mdi-jsfiddle::before {
+ content: "\F304";
+}
+.mdi-json::before {
+ content: "\F626";
+}
+.mdi-judaism::before {
+ content: "\F979";
+}
+.mdi-jump-rope::before {
+ content: "\F032A";
+}
+.mdi-kabaddi::before {
+ content: "\FD63";
+}
+.mdi-karate::before {
+ content: "\F82B";
+}
+.mdi-keg::before {
+ content: "\F305";
+}
+.mdi-kettle::before {
+ content: "\F5FA";
+}
+.mdi-kettle-alert::before {
+ content: "\F0342";
+}
+.mdi-kettle-alert-outline::before {
+ content: "\F0343";
+}
+.mdi-kettle-off::before {
+ content: "\F0346";
+}
+.mdi-kettle-off-outline::before {
+ content: "\F0347";
+}
+.mdi-kettle-outline::before {
+ content: "\FF73";
+}
+.mdi-kettle-steam::before {
+ content: "\F0344";
+}
+.mdi-kettle-steam-outline::before {
+ content: "\F0345";
+}
+.mdi-kettlebell::before {
+ content: "\F032B";
+}
+.mdi-key::before {
+ content: "\F306";
+}
+.mdi-key-arrow-right::before {
+ content: "\F033D";
+}
+.mdi-key-change::before {
+ content: "\F307";
+}
+.mdi-key-link::before {
+ content: "\F01CA";
+}
+.mdi-key-minus::before {
+ content: "\F308";
+}
+.mdi-key-outline::before {
+ content: "\FDB2";
+}
+.mdi-key-plus::before {
+ content: "\F309";
+}
+.mdi-key-remove::before {
+ content: "\F30A";
+}
+.mdi-key-star::before {
+ content: "\F01C9";
+}
+.mdi-key-variant::before {
+ content: "\F30B";
+}
+.mdi-key-wireless::before {
+ content: "\FFE2";
+}
+.mdi-keyboard::before {
+ content: "\F30C";
+}
+.mdi-keyboard-backspace::before {
+ content: "\F30D";
+}
+.mdi-keyboard-caps::before {
+ content: "\F30E";
+}
+.mdi-keyboard-close::before {
+ content: "\F30F";
+}
+.mdi-keyboard-esc::before {
+ content: "\F02E2";
+}
+.mdi-keyboard-f1::before {
+ content: "\F02D6";
+}
+.mdi-keyboard-f10::before {
+ content: "\F02DF";
+}
+.mdi-keyboard-f11::before {
+ content: "\F02E0";
+}
+.mdi-keyboard-f12::before {
+ content: "\F02E1";
+}
+.mdi-keyboard-f2::before {
+ content: "\F02D7";
+}
+.mdi-keyboard-f3::before {
+ content: "\F02D8";
+}
+.mdi-keyboard-f4::before {
+ content: "\F02D9";
+}
+.mdi-keyboard-f5::before {
+ content: "\F02DA";
+}
+.mdi-keyboard-f6::before {
+ content: "\F02DB";
+}
+.mdi-keyboard-f7::before {
+ content: "\F02DC";
+}
+.mdi-keyboard-f8::before {
+ content: "\F02DD";
+}
+.mdi-keyboard-f9::before {
+ content: "\F02DE";
+}
+.mdi-keyboard-off::before {
+ content: "\F310";
+}
+.mdi-keyboard-off-outline::before {
+ content: "\FE8C";
+}
+.mdi-keyboard-outline::before {
+ content: "\F97A";
+}
+.mdi-keyboard-return::before {
+ content: "\F311";
+}
+.mdi-keyboard-settings::before {
+ content: "\F9F8";
+}
+.mdi-keyboard-settings-outline::before {
+ content: "\F9F9";
+}
+.mdi-keyboard-space::before {
+ content: "\F0072";
+}
+.mdi-keyboard-tab::before {
+ content: "\F312";
+}
+.mdi-keyboard-variant::before {
+ content: "\F313";
+}
+.mdi-khanda::before {
+ content: "\F0128";
+}
+.mdi-kickstarter::before {
+ content: "\F744";
+}
+.mdi-klingon::before {
+ content: "\F0386";
+}
+.mdi-knife::before {
+ content: "\F9FA";
+}
+.mdi-knife-military::before {
+ content: "\F9FB";
+}
+.mdi-kodi::before {
+ content: "\F314";
+}
+.mdi-kotlin::before {
+ content: "\F0244";
+}
+.mdi-kubernetes::before {
+ content: "\F0129";
+}
+.mdi-label::before {
+ content: "\F315";
+}
+.mdi-label-multiple::before {
+ content: "\F03A0";
+}
+.mdi-label-multiple-outline::before {
+ content: "\F03A1";
+}
+.mdi-label-off::before {
+ content: "\FACA";
+}
+.mdi-label-off-outline::before {
+ content: "\FACB";
+}
+.mdi-label-outline::before {
+ content: "\F316";
+}
+.mdi-label-percent::before {
+ content: "\F0315";
+}
+.mdi-label-percent-outline::before {
+ content: "\F0316";
+}
+.mdi-label-variant::before {
+ content: "\FACC";
+}
+.mdi-label-variant-outline::before {
+ content: "\FACD";
+}
+.mdi-ladybug::before {
+ content: "\F82C";
+}
+.mdi-lambda::before {
+ content: "\F627";
+}
+.mdi-lamp::before {
+ content: "\F6B4";
+}
+.mdi-lan::before {
+ content: "\F317";
+}
+.mdi-lan-check::before {
+ content: "\F02D5";
+}
+.mdi-lan-connect::before {
+ content: "\F318";
+}
+.mdi-lan-disconnect::before {
+ content: "\F319";
+}
+.mdi-lan-pending::before {
+ content: "\F31A";
+}
+.mdi-language-c::before {
+ content: "\F671";
+}
+.mdi-language-cpp::before {
+ content: "\F672";
+}
+.mdi-language-csharp::before {
+ content: "\F31B";
+}
+.mdi-language-css3::before {
+ content: "\F31C";
+}
+.mdi-language-fortran::before {
+ content: "\F0245";
+}
+.mdi-language-go::before {
+ content: "\F7D2";
+}
+.mdi-language-haskell::before {
+ content: "\FC6E";
+}
+.mdi-language-html5::before {
+ content: "\F31D";
+}
+.mdi-language-java::before {
+ content: "\FB1C";
+}
+.mdi-language-javascript::before {
+ content: "\F31E";
+}
+.mdi-language-lua::before {
+ content: "\F8B0";
+}
+.mdi-language-php::before {
+ content: "\F31F";
+}
+.mdi-language-python::before {
+ content: "\F320";
+}
+.mdi-language-python-text::before {
+ content: "\F321";
+}
+.mdi-language-r::before {
+ content: "\F7D3";
+}
+.mdi-language-ruby-on-rails::before {
+ content: "\FACE";
+}
+.mdi-language-swift::before {
+ content: "\F6E4";
+}
+.mdi-language-typescript::before {
+ content: "\F6E5";
+}
+.mdi-laptop::before {
+ content: "\F322";
+}
+.mdi-laptop-chromebook::before {
+ content: "\F323";
+}
+.mdi-laptop-mac::before {
+ content: "\F324";
+}
+.mdi-laptop-off::before {
+ content: "\F6E6";
+}
+.mdi-laptop-windows::before {
+ content: "\F325";
+}
+.mdi-laravel::before {
+ content: "\FACF";
+}
+.mdi-lasso::before {
+ content: "\FF20";
+}
+.mdi-lastfm::before {
+ content: "\F326";
+}
+.mdi-lastpass::before {
+ content: "\F446";
+}
+.mdi-latitude::before {
+ content: "\FF74";
+}
+.mdi-launch::before {
+ content: "\F327";
+}
+.mdi-lava-lamp::before {
+ content: "\F7D4";
+}
+.mdi-layers::before {
+ content: "\F328";
+}
+.mdi-layers-minus::before {
+ content: "\FE8D";
+}
+.mdi-layers-off::before {
+ content: "\F329";
+}
+.mdi-layers-off-outline::before {
+ content: "\F9FC";
+}
+.mdi-layers-outline::before {
+ content: "\F9FD";
+}
+.mdi-layers-plus::before {
+ content: "\FE30";
+}
+.mdi-layers-remove::before {
+ content: "\FE31";
+}
+.mdi-layers-search::before {
+ content: "\F0231";
+}
+.mdi-layers-search-outline::before {
+ content: "\F0232";
+}
+.mdi-layers-triple::before {
+ content: "\FF75";
+}
+.mdi-layers-triple-outline::before {
+ content: "\FF76";
+}
+.mdi-lead-pencil::before {
+ content: "\F64F";
+}
+.mdi-leaf::before {
+ content: "\F32A";
+}
+.mdi-leaf-maple::before {
+ content: "\FC6F";
+}
+.mdi-leaf-maple-off::before {
+ content: "\F0305";
+}
+.mdi-leaf-off::before {
+ content: "\F0304";
+}
+.mdi-leak::before {
+ content: "\FDB3";
+}
+.mdi-leak-off::before {
+ content: "\FDB4";
+}
+.mdi-led-off::before {
+ content: "\F32B";
+}
+.mdi-led-on::before {
+ content: "\F32C";
+}
+.mdi-led-outline::before {
+ content: "\F32D";
+}
+.mdi-led-strip::before {
+ content: "\F7D5";
+}
+.mdi-led-strip-variant::before {
+ content: "\F0073";
+}
+.mdi-led-variant-off::before {
+ content: "\F32E";
+}
+.mdi-led-variant-on::before {
+ content: "\F32F";
+}
+.mdi-led-variant-outline::before {
+ content: "\F330";
+}
+.mdi-leek::before {
+ content: "\F01A8";
+}
+.mdi-less-than::before {
+ content: "\F97B";
+}
+.mdi-less-than-or-equal::before {
+ content: "\F97C";
+}
+.mdi-library::before {
+ content: "\F331";
+}
+.mdi-library-books::before {
+ content: "\F332";
+}
+.mdi-library-movie::before {
+ content: "\FCF4";
+}
+.mdi-library-music::before {
+ content: "\F333";
+}
+.mdi-library-music-outline::before {
+ content: "\FF21";
+}
+.mdi-library-shelves::before {
+ content: "\FB85";
+}
+.mdi-library-video::before {
+ content: "\FCF5";
+}
+.mdi-license::before {
+ content: "\FFE3";
+}
+.mdi-lifebuoy::before {
+ content: "\F87D";
+}
+.mdi-light-switch::before {
+ content: "\F97D";
+}
+.mdi-lightbulb::before {
+ content: "\F335";
+}
+.mdi-lightbulb-cfl::before {
+ content: "\F0233";
+}
+.mdi-lightbulb-cfl-off::before {
+ content: "\F0234";
+}
+.mdi-lightbulb-cfl-spiral::before {
+ content: "\F02A0";
+}
+.mdi-lightbulb-cfl-spiral-off::before {
+ content: "\F02EE";
+}
+.mdi-lightbulb-group::before {
+ content: "\F027E";
+}
+.mdi-lightbulb-group-off::before {
+ content: "\F02F8";
+}
+.mdi-lightbulb-group-off-outline::before {
+ content: "\F02F9";
+}
+.mdi-lightbulb-group-outline::before {
+ content: "\F027F";
+}
+.mdi-lightbulb-multiple::before {
+ content: "\F0280";
+}
+.mdi-lightbulb-multiple-off::before {
+ content: "\F02FA";
+}
+.mdi-lightbulb-multiple-off-outline::before {
+ content: "\F02FB";
+}
+.mdi-lightbulb-multiple-outline::before {
+ content: "\F0281";
+}
+.mdi-lightbulb-off::before {
+ content: "\FE32";
+}
+.mdi-lightbulb-off-outline::before {
+ content: "\FE33";
+}
+.mdi-lightbulb-on::before {
+ content: "\F6E7";
+}
+.mdi-lightbulb-on-outline::before {
+ content: "\F6E8";
+}
+.mdi-lightbulb-outline::before {
+ content: "\F336";
+}
+.mdi-lighthouse::before {
+ content: "\F9FE";
+}
+.mdi-lighthouse-on::before {
+ content: "\F9FF";
+}
+.mdi-link::before {
+ content: "\F337";
+}
+.mdi-link-box::before {
+ content: "\FCF6";
+}
+.mdi-link-box-outline::before {
+ content: "\FCF7";
+}
+.mdi-link-box-variant::before {
+ content: "\FCF8";
+}
+.mdi-link-box-variant-outline::before {
+ content: "\FCF9";
+}
+.mdi-link-lock::before {
+ content: "\F00E5";
+}
+.mdi-link-off::before {
+ content: "\F338";
+}
+.mdi-link-plus::before {
+ content: "\FC70";
+}
+.mdi-link-variant::before {
+ content: "\F339";
+}
+.mdi-link-variant-minus::before {
+ content: "\F012A";
+}
+.mdi-link-variant-off::before {
+ content: "\F33A";
+}
+.mdi-link-variant-plus::before {
+ content: "\F012B";
+}
+.mdi-link-variant-remove::before {
+ content: "\F012C";
+}
+.mdi-linkedin::before {
+ content: "\F33B";
+}
+.mdi-linkedin-box::before {
+ content: "\F33C";
+}
+.mdi-linux::before {
+ content: "\F33D";
+}
+.mdi-linux-mint::before {
+ content: "\F8EC";
+}
+.mdi-litecoin::before {
+ content: "\FA60";
+}
+.mdi-loading::before {
+ content: "\F771";
+}
+.mdi-location-enter::before {
+ content: "\FFE4";
+}
+.mdi-location-exit::before {
+ content: "\FFE5";
+}
+.mdi-lock::before {
+ content: "\F33E";
+}
+.mdi-lock-alert::before {
+ content: "\F8ED";
+}
+.mdi-lock-clock::before {
+ content: "\F97E";
+}
+.mdi-lock-open::before {
+ content: "\F33F";
+}
+.mdi-lock-open-outline::before {
+ content: "\F340";
+}
+.mdi-lock-open-variant::before {
+ content: "\FFE6";
+}
+.mdi-lock-open-variant-outline::before {
+ content: "\FFE7";
+}
+.mdi-lock-outline::before {
+ content: "\F341";
+}
+.mdi-lock-pattern::before {
+ content: "\F6E9";
+}
+.mdi-lock-plus::before {
+ content: "\F5FB";
+}
+.mdi-lock-question::before {
+ content: "\F8EE";
+}
+.mdi-lock-reset::before {
+ content: "\F772";
+}
+.mdi-lock-smart::before {
+ content: "\F8B1";
+}
+.mdi-locker::before {
+ content: "\F7D6";
+}
+.mdi-locker-multiple::before {
+ content: "\F7D7";
+}
+.mdi-login::before {
+ content: "\F342";
+}
+.mdi-login-variant::before {
+ content: "\F5FC";
+}
+.mdi-logout::before {
+ content: "\F343";
+}
+.mdi-logout-variant::before {
+ content: "\F5FD";
+}
+.mdi-longitude::before {
+ content: "\FF77";
+}
+.mdi-looks::before {
+ content: "\F344";
+}
+.mdi-loupe::before {
+ content: "\F345";
+}
+.mdi-lumx::before {
+ content: "\F346";
+}
+.mdi-lungs::before {
+ content: "\F00AF";
+}
+.mdi-lyft::before {
+ content: "\FB1D";
+}
+.mdi-magnet::before {
+ content: "\F347";
+}
+.mdi-magnet-on::before {
+ content: "\F348";
+}
+.mdi-magnify::before {
+ content: "\F349";
+}
+.mdi-magnify-close::before {
+ content: "\F97F";
+}
+.mdi-magnify-minus::before {
+ content: "\F34A";
+}
+.mdi-magnify-minus-cursor::before {
+ content: "\FA61";
+}
+.mdi-magnify-minus-outline::before {
+ content: "\F6EB";
+}
+.mdi-magnify-plus::before {
+ content: "\F34B";
+}
+.mdi-magnify-plus-cursor::before {
+ content: "\FA62";
+}
+.mdi-magnify-plus-outline::before {
+ content: "\F6EC";
+}
+.mdi-magnify-remove-cursor::before {
+ content: "\F0237";
+}
+.mdi-magnify-remove-outline::before {
+ content: "\F0238";
+}
+.mdi-magnify-scan::before {
+ content: "\F02A1";
+}
+.mdi-mail::before {
+ content: "\FED8";
+}
+.mdi-mail-ru::before {
+ content: "\F34C";
+}
+.mdi-mailbox::before {
+ content: "\F6ED";
+}
+.mdi-mailbox-open::before {
+ content: "\FD64";
+}
+.mdi-mailbox-open-outline::before {
+ content: "\FD65";
+}
+.mdi-mailbox-open-up::before {
+ content: "\FD66";
+}
+.mdi-mailbox-open-up-outline::before {
+ content: "\FD67";
+}
+.mdi-mailbox-outline::before {
+ content: "\FD68";
+}
+.mdi-mailbox-up::before {
+ content: "\FD69";
+}
+.mdi-mailbox-up-outline::before {
+ content: "\FD6A";
+}
+.mdi-map::before {
+ content: "\F34D";
+}
+.mdi-map-check::before {
+ content: "\FED9";
+}
+.mdi-map-check-outline::before {
+ content: "\FEDA";
+}
+.mdi-map-clock::before {
+ content: "\FCFA";
+}
+.mdi-map-clock-outline::before {
+ content: "\FCFB";
+}
+.mdi-map-legend::before {
+ content: "\FA00";
+}
+.mdi-map-marker::before {
+ content: "\F34E";
+}
+.mdi-map-marker-alert::before {
+ content: "\FF22";
+}
+.mdi-map-marker-alert-outline::before {
+ content: "\FF23";
+}
+.mdi-map-marker-check::before {
+ content: "\FC71";
+}
+.mdi-map-marker-check-outline::before {
+ content: "\F0326";
+}
+.mdi-map-marker-circle::before {
+ content: "\F34F";
+}
+.mdi-map-marker-distance::before {
+ content: "\F8EF";
+}
+.mdi-map-marker-down::before {
+ content: "\F012D";
+}
+.mdi-map-marker-left::before {
+ content: "\F0306";
+}
+.mdi-map-marker-left-outline::before {
+ content: "\F0308";
+}
+.mdi-map-marker-minus::before {
+ content: "\F650";
+}
+.mdi-map-marker-minus-outline::before {
+ content: "\F0324";
+}
+.mdi-map-marker-multiple::before {
+ content: "\F350";
+}
+.mdi-map-marker-multiple-outline::before {
+ content: "\F02A2";
+}
+.mdi-map-marker-off::before {
+ content: "\F351";
+}
+.mdi-map-marker-off-outline::before {
+ content: "\F0328";
+}
+.mdi-map-marker-outline::before {
+ content: "\F7D8";
+}
+.mdi-map-marker-path::before {
+ content: "\FCFC";
+}
+.mdi-map-marker-plus::before {
+ content: "\F651";
+}
+.mdi-map-marker-plus-outline::before {
+ content: "\F0323";
+}
+.mdi-map-marker-question::before {
+ content: "\FF24";
+}
+.mdi-map-marker-question-outline::before {
+ content: "\FF25";
+}
+.mdi-map-marker-radius::before {
+ content: "\F352";
+}
+.mdi-map-marker-radius-outline::before {
+ content: "\F0327";
+}
+.mdi-map-marker-remove::before {
+ content: "\FF26";
+}
+.mdi-map-marker-remove-outline::before {
+ content: "\F0325";
+}
+.mdi-map-marker-remove-variant::before {
+ content: "\FF27";
+}
+.mdi-map-marker-right::before {
+ content: "\F0307";
+}
+.mdi-map-marker-right-outline::before {
+ content: "\F0309";
+}
+.mdi-map-marker-up::before {
+ content: "\F012E";
+}
+.mdi-map-minus::before {
+ content: "\F980";
+}
+.mdi-map-outline::before {
+ content: "\F981";
+}
+.mdi-map-plus::before {
+ content: "\F982";
+}
+.mdi-map-search::before {
+ content: "\F983";
+}
+.mdi-map-search-outline::before {
+ content: "\F984";
+}
+.mdi-mapbox::before {
+ content: "\FB86";
+}
+.mdi-margin::before {
+ content: "\F353";
+}
+.mdi-markdown::before {
+ content: "\F354";
+}
+.mdi-markdown-outline::before {
+ content: "\FF78";
+}
+.mdi-marker::before {
+ content: "\F652";
+}
+.mdi-marker-cancel::before {
+ content: "\FDB5";
+}
+.mdi-marker-check::before {
+ content: "\F355";
+}
+.mdi-mastodon::before {
+ content: "\FAD0";
+}
+.mdi-mastodon-variant::before {
+ content: "\FAD1";
+}
+.mdi-material-design::before {
+ content: "\F985";
+}
+.mdi-material-ui::before {
+ content: "\F357";
+}
+.mdi-math-compass::before {
+ content: "\F358";
+}
+.mdi-math-cos::before {
+ content: "\FC72";
+}
+.mdi-math-integral::before {
+ content: "\FFE8";
+}
+.mdi-math-integral-box::before {
+ content: "\FFE9";
+}
+.mdi-math-log::before {
+ content: "\F00B0";
+}
+.mdi-math-norm::before {
+ content: "\FFEA";
+}
+.mdi-math-norm-box::before {
+ content: "\FFEB";
+}
+.mdi-math-sin::before {
+ content: "\FC73";
+}
+.mdi-math-tan::before {
+ content: "\FC74";
+}
+.mdi-matrix::before {
+ content: "\F628";
+}
+.mdi-medal::before {
+ content: "\F986";
+}
+.mdi-medal-outline::before {
+ content: "\F0351";
+}
+.mdi-medical-bag::before {
+ content: "\F6EE";
+}
+.mdi-meditation::before {
+ content: "\F01A6";
+}
+.mdi-medium::before {
+ content: "\F35A";
+}
+.mdi-meetup::before {
+ content: "\FAD2";
+}
+.mdi-memory::before {
+ content: "\F35B";
+}
+.mdi-menu::before {
+ content: "\F35C";
+}
+.mdi-menu-down::before {
+ content: "\F35D";
+}
+.mdi-menu-down-outline::before {
+ content: "\F6B5";
+}
+.mdi-menu-left::before {
+ content: "\F35E";
+}
+.mdi-menu-left-outline::before {
+ content: "\FA01";
+}
+.mdi-menu-open::before {
+ content: "\FB87";
+}
+.mdi-menu-right::before {
+ content: "\F35F";
+}
+.mdi-menu-right-outline::before {
+ content: "\FA02";
+}
+.mdi-menu-swap::before {
+ content: "\FA63";
+}
+.mdi-menu-swap-outline::before {
+ content: "\FA64";
+}
+.mdi-menu-up::before {
+ content: "\F360";
+}
+.mdi-menu-up-outline::before {
+ content: "\F6B6";
+}
+.mdi-merge::before {
+ content: "\FF79";
+}
+.mdi-message::before {
+ content: "\F361";
+}
+.mdi-message-alert::before {
+ content: "\F362";
+}
+.mdi-message-alert-outline::before {
+ content: "\FA03";
+}
+.mdi-message-arrow-left::before {
+ content: "\F031D";
+}
+.mdi-message-arrow-left-outline::before {
+ content: "\F031E";
+}
+.mdi-message-arrow-right::before {
+ content: "\F031F";
+}
+.mdi-message-arrow-right-outline::before {
+ content: "\F0320";
+}
+.mdi-message-bulleted::before {
+ content: "\F6A1";
+}
+.mdi-message-bulleted-off::before {
+ content: "\F6A2";
+}
+.mdi-message-draw::before {
+ content: "\F363";
+}
+.mdi-message-image::before {
+ content: "\F364";
+}
+.mdi-message-image-outline::before {
+ content: "\F0197";
+}
+.mdi-message-lock::before {
+ content: "\FFEC";
+}
+.mdi-message-lock-outline::before {
+ content: "\F0198";
+}
+.mdi-message-minus::before {
+ content: "\F0199";
+}
+.mdi-message-minus-outline::before {
+ content: "\F019A";
+}
+.mdi-message-outline::before {
+ content: "\F365";
+}
+.mdi-message-plus::before {
+ content: "\F653";
+}
+.mdi-message-plus-outline::before {
+ content: "\F00E6";
+}
+.mdi-message-processing::before {
+ content: "\F366";
+}
+.mdi-message-processing-outline::before {
+ content: "\F019B";
+}
+.mdi-message-reply::before {
+ content: "\F367";
+}
+.mdi-message-reply-text::before {
+ content: "\F368";
+}
+.mdi-message-settings::before {
+ content: "\F6EF";
+}
+.mdi-message-settings-outline::before {
+ content: "\F019C";
+}
+.mdi-message-settings-variant::before {
+ content: "\F6F0";
+}
+.mdi-message-settings-variant-outline::before {
+ content: "\F019D";
+}
+.mdi-message-text::before {
+ content: "\F369";
+}
+.mdi-message-text-clock::before {
+ content: "\F019E";
+}
+.mdi-message-text-clock-outline::before {
+ content: "\F019F";
+}
+.mdi-message-text-lock::before {
+ content: "\FFED";
+}
+.mdi-message-text-lock-outline::before {
+ content: "\F01A0";
+}
+.mdi-message-text-outline::before {
+ content: "\F36A";
+}
+.mdi-message-video::before {
+ content: "\F36B";
+}
+.mdi-meteor::before {
+ content: "\F629";
+}
+.mdi-metronome::before {
+ content: "\F7D9";
+}
+.mdi-metronome-tick::before {
+ content: "\F7DA";
+}
+.mdi-micro-sd::before {
+ content: "\F7DB";
+}
+.mdi-microphone::before {
+ content: "\F36C";
+}
+.mdi-microphone-minus::before {
+ content: "\F8B2";
+}
+.mdi-microphone-off::before {
+ content: "\F36D";
+}
+.mdi-microphone-outline::before {
+ content: "\F36E";
+}
+.mdi-microphone-plus::before {
+ content: "\F8B3";
+}
+.mdi-microphone-settings::before {
+ content: "\F36F";
+}
+.mdi-microphone-variant::before {
+ content: "\F370";
+}
+.mdi-microphone-variant-off::before {
+ content: "\F371";
+}
+.mdi-microscope::before {
+ content: "\F654";
+}
+.mdi-microsoft::before {
+ content: "\F372";
+}
+.mdi-microsoft-dynamics::before {
+ content: "\F987";
+}
+.mdi-microwave::before {
+ content: "\FC75";
+}
+.mdi-middleware::before {
+ content: "\FF7A";
+}
+.mdi-middleware-outline::before {
+ content: "\FF7B";
+}
+.mdi-midi::before {
+ content: "\F8F0";
+}
+.mdi-midi-port::before {
+ content: "\F8F1";
+}
+.mdi-mine::before {
+ content: "\FDB6";
+}
+.mdi-minecraft::before {
+ content: "\F373";
+}
+.mdi-mini-sd::before {
+ content: "\FA04";
+}
+.mdi-minidisc::before {
+ content: "\FA05";
+}
+.mdi-minus::before {
+ content: "\F374";
+}
+.mdi-minus-box::before {
+ content: "\F375";
+}
+.mdi-minus-box-multiple::before {
+ content: "\F016C";
+}
+.mdi-minus-box-multiple-outline::before {
+ content: "\F016D";
+}
+.mdi-minus-box-outline::before {
+ content: "\F6F1";
+}
+.mdi-minus-circle::before {
+ content: "\F376";
+}
+.mdi-minus-circle-outline::before {
+ content: "\F377";
+}
+.mdi-minus-network::before {
+ content: "\F378";
+}
+.mdi-minus-network-outline::before {
+ content: "\FC76";
+}
+.mdi-mirror::before {
+ content: "\F0228";
+}
+.mdi-mixcloud::before {
+ content: "\F62A";
+}
+.mdi-mixed-martial-arts::before {
+ content: "\FD6B";
+}
+.mdi-mixed-reality::before {
+ content: "\F87E";
+}
+.mdi-mixer::before {
+ content: "\F7DC";
+}
+.mdi-molecule::before {
+ content: "\FB88";
+}
+.mdi-monitor::before {
+ content: "\F379";
+}
+.mdi-monitor-cellphone::before {
+ content: "\F988";
+}
+.mdi-monitor-cellphone-star::before {
+ content: "\F989";
+}
+.mdi-monitor-clean::before {
+ content: "\F012F";
+}
+.mdi-monitor-dashboard::before {
+ content: "\FA06";
+}
+.mdi-monitor-edit::before {
+ content: "\F02F1";
+}
+.mdi-monitor-lock::before {
+ content: "\FDB7";
+}
+.mdi-monitor-multiple::before {
+ content: "\F37A";
+}
+.mdi-monitor-off::before {
+ content: "\FD6C";
+}
+.mdi-monitor-screenshot::before {
+ content: "\FE34";
+}
+.mdi-monitor-speaker::before {
+ content: "\FF7C";
+}
+.mdi-monitor-speaker-off::before {
+ content: "\FF7D";
+}
+.mdi-monitor-star::before {
+ content: "\FDB8";
+}
+.mdi-moon-first-quarter::before {
+ content: "\FF7E";
+}
+.mdi-moon-full::before {
+ content: "\FF7F";
+}
+.mdi-moon-last-quarter::before {
+ content: "\FF80";
+}
+.mdi-moon-new::before {
+ content: "\FF81";
+}
+.mdi-moon-waning-crescent::before {
+ content: "\FF82";
+}
+.mdi-moon-waning-gibbous::before {
+ content: "\FF83";
+}
+.mdi-moon-waxing-crescent::before {
+ content: "\FF84";
+}
+.mdi-moon-waxing-gibbous::before {
+ content: "\FF85";
+}
+.mdi-moped::before {
+ content: "\F00B1";
+}
+.mdi-more::before {
+ content: "\F37B";
+}
+.mdi-mother-heart::before {
+ content: "\F033F";
+}
+.mdi-mother-nurse::before {
+ content: "\FCFD";
+}
+.mdi-motion-sensor::before {
+ content: "\FD6D";
+}
+.mdi-motorbike::before {
+ content: "\F37C";
+}
+.mdi-mouse::before {
+ content: "\F37D";
+}
+.mdi-mouse-bluetooth::before {
+ content: "\F98A";
+}
+.mdi-mouse-off::before {
+ content: "\F37E";
+}
+.mdi-mouse-variant::before {
+ content: "\F37F";
+}
+.mdi-mouse-variant-off::before {
+ content: "\F380";
+}
+.mdi-move-resize::before {
+ content: "\F655";
+}
+.mdi-move-resize-variant::before {
+ content: "\F656";
+}
+.mdi-movie::before {
+ content: "\F381";
+}
+.mdi-movie-edit::before {
+ content: "\F014D";
+}
+.mdi-movie-edit-outline::before {
+ content: "\F014E";
+}
+.mdi-movie-filter::before {
+ content: "\F014F";
+}
+.mdi-movie-filter-outline::before {
+ content: "\F0150";
+}
+.mdi-movie-open::before {
+ content: "\FFEE";
+}
+.mdi-movie-open-outline::before {
+ content: "\FFEF";
+}
+.mdi-movie-outline::before {
+ content: "\FDB9";
+}
+.mdi-movie-roll::before {
+ content: "\F7DD";
+}
+.mdi-movie-search::before {
+ content: "\F01FD";
+}
+.mdi-movie-search-outline::before {
+ content: "\F01FE";
+}
+.mdi-muffin::before {
+ content: "\F98B";
+}
+.mdi-multiplication::before {
+ content: "\F382";
+}
+.mdi-multiplication-box::before {
+ content: "\F383";
+}
+.mdi-mushroom::before {
+ content: "\F7DE";
+}
+.mdi-mushroom-outline::before {
+ content: "\F7DF";
+}
+.mdi-music::before {
+ content: "\F759";
+}
+.mdi-music-accidental-double-flat::before {
+ content: "\FF86";
+}
+.mdi-music-accidental-double-sharp::before {
+ content: "\FF87";
+}
+.mdi-music-accidental-flat::before {
+ content: "\FF88";
+}
+.mdi-music-accidental-natural::before {
+ content: "\FF89";
+}
+.mdi-music-accidental-sharp::before {
+ content: "\FF8A";
+}
+.mdi-music-box::before {
+ content: "\F384";
+}
+.mdi-music-box-outline::before {
+ content: "\F385";
+}
+.mdi-music-circle::before {
+ content: "\F386";
+}
+.mdi-music-circle-outline::before {
+ content: "\FAD3";
+}
+.mdi-music-clef-alto::before {
+ content: "\FF8B";
+}
+.mdi-music-clef-bass::before {
+ content: "\FF8C";
+}
+.mdi-music-clef-treble::before {
+ content: "\FF8D";
+}
+.mdi-music-note::before {
+ content: "\F387";
+}
+.mdi-music-note-bluetooth::before {
+ content: "\F5FE";
+}
+.mdi-music-note-bluetooth-off::before {
+ content: "\F5FF";
+}
+.mdi-music-note-eighth::before {
+ content: "\F388";
+}
+.mdi-music-note-eighth-dotted::before {
+ content: "\FF8E";
+}
+.mdi-music-note-half::before {
+ content: "\F389";
+}
+.mdi-music-note-half-dotted::before {
+ content: "\FF8F";
+}
+.mdi-music-note-off::before {
+ content: "\F38A";
+}
+.mdi-music-note-off-outline::before {
+ content: "\FF90";
+}
+.mdi-music-note-outline::before {
+ content: "\FF91";
+}
+.mdi-music-note-plus::before {
+ content: "\FDBA";
+}
+.mdi-music-note-quarter::before {
+ content: "\F38B";
+}
+.mdi-music-note-quarter-dotted::before {
+ content: "\FF92";
+}
+.mdi-music-note-sixteenth::before {
+ content: "\F38C";
+}
+.mdi-music-note-sixteenth-dotted::before {
+ content: "\FF93";
+}
+.mdi-music-note-whole::before {
+ content: "\F38D";
+}
+.mdi-music-note-whole-dotted::before {
+ content: "\FF94";
+}
+.mdi-music-off::before {
+ content: "\F75A";
+}
+.mdi-music-rest-eighth::before {
+ content: "\FF95";
+}
+.mdi-music-rest-half::before {
+ content: "\FF96";
+}
+.mdi-music-rest-quarter::before {
+ content: "\FF97";
+}
+.mdi-music-rest-sixteenth::before {
+ content: "\FF98";
+}
+.mdi-music-rest-whole::before {
+ content: "\FF99";
+}
+.mdi-nail::before {
+ content: "\FDBB";
+}
+.mdi-nas::before {
+ content: "\F8F2";
+}
+.mdi-nativescript::before {
+ content: "\F87F";
+}
+.mdi-nature::before {
+ content: "\F38E";
+}
+.mdi-nature-people::before {
+ content: "\F38F";
+}
+.mdi-navigation::before {
+ content: "\F390";
+}
+.mdi-near-me::before {
+ content: "\F5CD";
+}
+.mdi-necklace::before {
+ content: "\FF28";
+}
+.mdi-needle::before {
+ content: "\F391";
+}
+.mdi-netflix::before {
+ content: "\F745";
+}
+.mdi-network::before {
+ content: "\F6F2";
+}
+.mdi-network-off::before {
+ content: "\FC77";
+}
+.mdi-network-off-outline::before {
+ content: "\FC78";
+}
+.mdi-network-outline::before {
+ content: "\FC79";
+}
+.mdi-network-router::before {
+ content: "\F00B2";
+}
+.mdi-network-strength-1::before {
+ content: "\F8F3";
+}
+.mdi-network-strength-1-alert::before {
+ content: "\F8F4";
+}
+.mdi-network-strength-2::before {
+ content: "\F8F5";
+}
+.mdi-network-strength-2-alert::before {
+ content: "\F8F6";
+}
+.mdi-network-strength-3::before {
+ content: "\F8F7";
+}
+.mdi-network-strength-3-alert::before {
+ content: "\F8F8";
+}
+.mdi-network-strength-4::before {
+ content: "\F8F9";
+}
+.mdi-network-strength-4-alert::before {
+ content: "\F8FA";
+}
+.mdi-network-strength-off::before {
+ content: "\F8FB";
+}
+.mdi-network-strength-off-outline::before {
+ content: "\F8FC";
+}
+.mdi-network-strength-outline::before {
+ content: "\F8FD";
+}
+.mdi-new-box::before {
+ content: "\F394";
+}
+.mdi-newspaper::before {
+ content: "\F395";
+}
+.mdi-newspaper-minus::before {
+ content: "\FF29";
+}
+.mdi-newspaper-plus::before {
+ content: "\FF2A";
+}
+.mdi-newspaper-variant::before {
+ content: "\F0023";
+}
+.mdi-newspaper-variant-multiple::before {
+ content: "\F0024";
+}
+.mdi-newspaper-variant-multiple-outline::before {
+ content: "\F0025";
+}
+.mdi-newspaper-variant-outline::before {
+ content: "\F0026";
+}
+.mdi-nfc::before {
+ content: "\F396";
+}
+.mdi-nfc-off::before {
+ content: "\FE35";
+}
+.mdi-nfc-search-variant::before {
+ content: "\FE36";
+}
+.mdi-nfc-tap::before {
+ content: "\F397";
+}
+.mdi-nfc-variant::before {
+ content: "\F398";
+}
+.mdi-nfc-variant-off::before {
+ content: "\FE37";
+}
+.mdi-ninja::before {
+ content: "\F773";
+}
+.mdi-nintendo-switch::before {
+ content: "\F7E0";
+}
+.mdi-nix::before {
+ content: "\F0130";
+}
+.mdi-nodejs::before {
+ content: "\F399";
+}
+.mdi-noodles::before {
+ content: "\F01A9";
+}
+.mdi-not-equal::before {
+ content: "\F98C";
+}
+.mdi-not-equal-variant::before {
+ content: "\F98D";
+}
+.mdi-note::before {
+ content: "\F39A";
+}
+.mdi-note-multiple::before {
+ content: "\F6B7";
+}
+.mdi-note-multiple-outline::before {
+ content: "\F6B8";
+}
+.mdi-note-outline::before {
+ content: "\F39B";
+}
+.mdi-note-plus::before {
+ content: "\F39C";
+}
+.mdi-note-plus-outline::before {
+ content: "\F39D";
+}
+.mdi-note-text::before {
+ content: "\F39E";
+}
+.mdi-note-text-outline::before {
+ content: "\F0202";
+}
+.mdi-notebook::before {
+ content: "\F82D";
+}
+.mdi-notebook-multiple::before {
+ content: "\FE38";
+}
+.mdi-notebook-outline::before {
+ content: "\FEDC";
+}
+.mdi-notification-clear-all::before {
+ content: "\F39F";
+}
+.mdi-npm::before {
+ content: "\F6F6";
+}
+.mdi-npm-variant::before {
+ content: "\F98E";
+}
+.mdi-npm-variant-outline::before {
+ content: "\F98F";
+}
+.mdi-nuke::before {
+ content: "\F6A3";
+}
+.mdi-null::before {
+ content: "\F7E1";
+}
+.mdi-numeric::before {
+ content: "\F3A0";
+}
+.mdi-numeric-0::before {
+ content: "\30";
+}
+.mdi-numeric-0-box::before {
+ content: "\F3A1";
+}
+.mdi-numeric-0-box-multiple::before {
+ content: "\FF2B";
+}
+.mdi-numeric-0-box-multiple-outline::before {
+ content: "\F3A2";
+}
+.mdi-numeric-0-box-outline::before {
+ content: "\F3A3";
+}
+.mdi-numeric-0-circle::before {
+ content: "\FC7A";
+}
+.mdi-numeric-0-circle-outline::before {
+ content: "\FC7B";
+}
+.mdi-numeric-1::before {
+ content: "\31";
+}
+.mdi-numeric-1-box::before {
+ content: "\F3A4";
+}
+.mdi-numeric-1-box-multiple::before {
+ content: "\FF2C";
+}
+.mdi-numeric-1-box-multiple-outline::before {
+ content: "\F3A5";
+}
+.mdi-numeric-1-box-outline::before {
+ content: "\F3A6";
+}
+.mdi-numeric-1-circle::before {
+ content: "\FC7C";
+}
+.mdi-numeric-1-circle-outline::before {
+ content: "\FC7D";
+}
+.mdi-numeric-10::before {
+ content: "\F000A";
+}
+.mdi-numeric-10-box::before {
+ content: "\FF9A";
+}
+.mdi-numeric-10-box-multiple::before {
+ content: "\F000B";
+}
+.mdi-numeric-10-box-multiple-outline::before {
+ content: "\F000C";
+}
+.mdi-numeric-10-box-outline::before {
+ content: "\FF9B";
+}
+.mdi-numeric-10-circle::before {
+ content: "\F000D";
+}
+.mdi-numeric-10-circle-outline::before {
+ content: "\F000E";
+}
+.mdi-numeric-2::before {
+ content: "\32";
+}
+.mdi-numeric-2-box::before {
+ content: "\F3A7";
+}
+.mdi-numeric-2-box-multiple::before {
+ content: "\FF2D";
+}
+.mdi-numeric-2-box-multiple-outline::before {
+ content: "\F3A8";
+}
+.mdi-numeric-2-box-outline::before {
+ content: "\F3A9";
+}
+.mdi-numeric-2-circle::before {
+ content: "\FC7E";
+}
+.mdi-numeric-2-circle-outline::before {
+ content: "\FC7F";
+}
+.mdi-numeric-3::before {
+ content: "\33";
+}
+.mdi-numeric-3-box::before {
+ content: "\F3AA";
+}
+.mdi-numeric-3-box-multiple::before {
+ content: "\FF2E";
+}
+.mdi-numeric-3-box-multiple-outline::before {
+ content: "\F3AB";
+}
+.mdi-numeric-3-box-outline::before {
+ content: "\F3AC";
+}
+.mdi-numeric-3-circle::before {
+ content: "\FC80";
+}
+.mdi-numeric-3-circle-outline::before {
+ content: "\FC81";
+}
+.mdi-numeric-4::before {
+ content: "\34";
+}
+.mdi-numeric-4-box::before {
+ content: "\F3AD";
+}
+.mdi-numeric-4-box-multiple::before {
+ content: "\FF2F";
+}
+.mdi-numeric-4-box-multiple-outline::before {
+ content: "\F3AE";
+}
+.mdi-numeric-4-box-outline::before {
+ content: "\F3AF";
+}
+.mdi-numeric-4-circle::before {
+ content: "\FC82";
+}
+.mdi-numeric-4-circle-outline::before {
+ content: "\FC83";
+}
+.mdi-numeric-5::before {
+ content: "\35";
+}
+.mdi-numeric-5-box::before {
+ content: "\F3B0";
+}
+.mdi-numeric-5-box-multiple::before {
+ content: "\FF30";
+}
+.mdi-numeric-5-box-multiple-outline::before {
+ content: "\F3B1";
+}
+.mdi-numeric-5-box-outline::before {
+ content: "\F3B2";
+}
+.mdi-numeric-5-circle::before {
+ content: "\FC84";
+}
+.mdi-numeric-5-circle-outline::before {
+ content: "\FC85";
+}
+.mdi-numeric-6::before {
+ content: "\36";
+}
+.mdi-numeric-6-box::before {
+ content: "\F3B3";
+}
+.mdi-numeric-6-box-multiple::before {
+ content: "\FF31";
+}
+.mdi-numeric-6-box-multiple-outline::before {
+ content: "\F3B4";
+}
+.mdi-numeric-6-box-outline::before {
+ content: "\F3B5";
+}
+.mdi-numeric-6-circle::before {
+ content: "\FC86";
+}
+.mdi-numeric-6-circle-outline::before {
+ content: "\FC87";
+}
+.mdi-numeric-7::before {
+ content: "\37";
+}
+.mdi-numeric-7-box::before {
+ content: "\F3B6";
+}
+.mdi-numeric-7-box-multiple::before {
+ content: "\FF32";
+}
+.mdi-numeric-7-box-multiple-outline::before {
+ content: "\F3B7";
+}
+.mdi-numeric-7-box-outline::before {
+ content: "\F3B8";
+}
+.mdi-numeric-7-circle::before {
+ content: "\FC88";
+}
+.mdi-numeric-7-circle-outline::before {
+ content: "\FC89";
+}
+.mdi-numeric-8::before {
+ content: "\38";
+}
+.mdi-numeric-8-box::before {
+ content: "\F3B9";
+}
+.mdi-numeric-8-box-multiple::before {
+ content: "\FF33";
+}
+.mdi-numeric-8-box-multiple-outline::before {
+ content: "\F3BA";
+}
+.mdi-numeric-8-box-outline::before {
+ content: "\F3BB";
+}
+.mdi-numeric-8-circle::before {
+ content: "\FC8A";
+}
+.mdi-numeric-8-circle-outline::before {
+ content: "\FC8B";
+}
+.mdi-numeric-9::before {
+ content: "\39";
+}
+.mdi-numeric-9-box::before {
+ content: "\F3BC";
+}
+.mdi-numeric-9-box-multiple::before {
+ content: "\FF34";
+}
+.mdi-numeric-9-box-multiple-outline::before {
+ content: "\F3BD";
+}
+.mdi-numeric-9-box-outline::before {
+ content: "\F3BE";
+}
+.mdi-numeric-9-circle::before {
+ content: "\FC8C";
+}
+.mdi-numeric-9-circle-outline::before {
+ content: "\FC8D";
+}
+.mdi-numeric-9-plus::before {
+ content: "\F000F";
+}
+.mdi-numeric-9-plus-box::before {
+ content: "\F3BF";
+}
+.mdi-numeric-9-plus-box-multiple::before {
+ content: "\FF35";
+}
+.mdi-numeric-9-plus-box-multiple-outline::before {
+ content: "\F3C0";
+}
+.mdi-numeric-9-plus-box-outline::before {
+ content: "\F3C1";
+}
+.mdi-numeric-9-plus-circle::before {
+ content: "\FC8E";
+}
+.mdi-numeric-9-plus-circle-outline::before {
+ content: "\FC8F";
+}
+.mdi-numeric-negative-1::before {
+ content: "\F0074";
+}
+.mdi-nut::before {
+ content: "\F6F7";
+}
+.mdi-nutrition::before {
+ content: "\F3C2";
+}
+.mdi-nuxt::before {
+ content: "\F0131";
+}
+.mdi-oar::before {
+ content: "\F67B";
+}
+.mdi-ocarina::before {
+ content: "\FDBC";
+}
+.mdi-oci::before {
+ content: "\F0314";
+}
+.mdi-ocr::before {
+ content: "\F0165";
+}
+.mdi-octagon::before {
+ content: "\F3C3";
+}
+.mdi-octagon-outline::before {
+ content: "\F3C4";
+}
+.mdi-octagram::before {
+ content: "\F6F8";
+}
+.mdi-octagram-outline::before {
+ content: "\F774";
+}
+.mdi-odnoklassniki::before {
+ content: "\F3C5";
+}
+.mdi-offer::before {
+ content: "\F0246";
+}
+.mdi-office::before {
+ content: "\F3C6";
+}
+.mdi-office-building::before {
+ content: "\F990";
+}
+.mdi-oil::before {
+ content: "\F3C7";
+}
+.mdi-oil-lamp::before {
+ content: "\FF36";
+}
+.mdi-oil-level::before {
+ content: "\F0075";
+}
+.mdi-oil-temperature::before {
+ content: "\F0019";
+}
+.mdi-omega::before {
+ content: "\F3C9";
+}
+.mdi-one-up::before {
+ content: "\FB89";
+}
+.mdi-onedrive::before {
+ content: "\F3CA";
+}
+.mdi-onenote::before {
+ content: "\F746";
+}
+.mdi-onepassword::before {
+ content: "\F880";
+}
+.mdi-opacity::before {
+ content: "\F5CC";
+}
+.mdi-open-in-app::before {
+ content: "\F3CB";
+}
+.mdi-open-in-new::before {
+ content: "\F3CC";
+}
+.mdi-open-source-initiative::before {
+ content: "\FB8A";
+}
+.mdi-openid::before {
+ content: "\F3CD";
+}
+.mdi-opera::before {
+ content: "\F3CE";
+}
+.mdi-orbit::before {
+ content: "\F018";
+}
+.mdi-origin::before {
+ content: "\FB2B";
+}
+.mdi-ornament::before {
+ content: "\F3CF";
+}
+.mdi-ornament-variant::before {
+ content: "\F3D0";
+}
+.mdi-outdoor-lamp::before {
+ content: "\F0076";
+}
+.mdi-outlook::before {
+ content: "\FCFE";
+}
+.mdi-overscan::before {
+ content: "\F0027";
+}
+.mdi-owl::before {
+ content: "\F3D2";
+}
+.mdi-pac-man::before {
+ content: "\FB8B";
+}
+.mdi-package::before {
+ content: "\F3D3";
+}
+.mdi-package-down::before {
+ content: "\F3D4";
+}
+.mdi-package-up::before {
+ content: "\F3D5";
+}
+.mdi-package-variant::before {
+ content: "\F3D6";
+}
+.mdi-package-variant-closed::before {
+ content: "\F3D7";
+}
+.mdi-page-first::before {
+ content: "\F600";
+}
+.mdi-page-last::before {
+ content: "\F601";
+}
+.mdi-page-layout-body::before {
+ content: "\F6F9";
+}
+.mdi-page-layout-footer::before {
+ content: "\F6FA";
+}
+.mdi-page-layout-header::before {
+ content: "\F6FB";
+}
+.mdi-page-layout-header-footer::before {
+ content: "\FF9C";
+}
+.mdi-page-layout-sidebar-left::before {
+ content: "\F6FC";
+}
+.mdi-page-layout-sidebar-right::before {
+ content: "\F6FD";
+}
+.mdi-page-next::before {
+ content: "\FB8C";
+}
+.mdi-page-next-outline::before {
+ content: "\FB8D";
+}
+.mdi-page-previous::before {
+ content: "\FB8E";
+}
+.mdi-page-previous-outline::before {
+ content: "\FB8F";
+}
+.mdi-palette::before {
+ content: "\F3D8";
+}
+.mdi-palette-advanced::before {
+ content: "\F3D9";
+}
+.mdi-palette-outline::before {
+ content: "\FE6C";
+}
+.mdi-palette-swatch::before {
+ content: "\F8B4";
+}
+.mdi-palette-swatch-outline::before {
+ content: "\F0387";
+}
+.mdi-palm-tree::before {
+ content: "\F0077";
+}
+.mdi-pan::before {
+ content: "\FB90";
+}
+.mdi-pan-bottom-left::before {
+ content: "\FB91";
+}
+.mdi-pan-bottom-right::before {
+ content: "\FB92";
+}
+.mdi-pan-down::before {
+ content: "\FB93";
+}
+.mdi-pan-horizontal::before {
+ content: "\FB94";
+}
+.mdi-pan-left::before {
+ content: "\FB95";
+}
+.mdi-pan-right::before {
+ content: "\FB96";
+}
+.mdi-pan-top-left::before {
+ content: "\FB97";
+}
+.mdi-pan-top-right::before {
+ content: "\FB98";
+}
+.mdi-pan-up::before {
+ content: "\FB99";
+}
+.mdi-pan-vertical::before {
+ content: "\FB9A";
+}
+.mdi-panda::before {
+ content: "\F3DA";
+}
+.mdi-pandora::before {
+ content: "\F3DB";
+}
+.mdi-panorama::before {
+ content: "\F3DC";
+}
+.mdi-panorama-fisheye::before {
+ content: "\F3DD";
+}
+.mdi-panorama-horizontal::before {
+ content: "\F3DE";
+}
+.mdi-panorama-vertical::before {
+ content: "\F3DF";
+}
+.mdi-panorama-wide-angle::before {
+ content: "\F3E0";
+}
+.mdi-paper-cut-vertical::before {
+ content: "\F3E1";
+}
+.mdi-paper-roll::before {
+ content: "\F0182";
+}
+.mdi-paper-roll-outline::before {
+ content: "\F0183";
+}
+.mdi-paperclip::before {
+ content: "\F3E2";
+}
+.mdi-parachute::before {
+ content: "\FC90";
+}
+.mdi-parachute-outline::before {
+ content: "\FC91";
+}
+.mdi-parking::before {
+ content: "\F3E3";
+}
+.mdi-party-popper::before {
+ content: "\F0078";
+}
+.mdi-passport::before {
+ content: "\F7E2";
+}
+.mdi-passport-biometric::before {
+ content: "\FDBD";
+}
+.mdi-pasta::before {
+ content: "\F018B";
+}
+.mdi-patio-heater::before {
+ content: "\FF9D";
+}
+.mdi-patreon::before {
+ content: "\F881";
+}
+.mdi-pause::before {
+ content: "\F3E4";
+}
+.mdi-pause-circle::before {
+ content: "\F3E5";
+}
+.mdi-pause-circle-outline::before {
+ content: "\F3E6";
+}
+.mdi-pause-octagon::before {
+ content: "\F3E7";
+}
+.mdi-pause-octagon-outline::before {
+ content: "\F3E8";
+}
+.mdi-paw::before {
+ content: "\F3E9";
+}
+.mdi-paw-off::before {
+ content: "\F657";
+}
+.mdi-paypal::before {
+ content: "\F882";
+}
+.mdi-pdf-box::before {
+ content: "\FE39";
+}
+.mdi-peace::before {
+ content: "\F883";
+}
+.mdi-peanut::before {
+ content: "\F001E";
+}
+.mdi-peanut-off::before {
+ content: "\F001F";
+}
+.mdi-peanut-off-outline::before {
+ content: "\F0021";
+}
+.mdi-peanut-outline::before {
+ content: "\F0020";
+}
+.mdi-pen::before {
+ content: "\F3EA";
+}
+.mdi-pen-lock::before {
+ content: "\FDBE";
+}
+.mdi-pen-minus::before {
+ content: "\FDBF";
+}
+.mdi-pen-off::before {
+ content: "\FDC0";
+}
+.mdi-pen-plus::before {
+ content: "\FDC1";
+}
+.mdi-pen-remove::before {
+ content: "\FDC2";
+}
+.mdi-pencil::before {
+ content: "\F3EB";
+}
+.mdi-pencil-box::before {
+ content: "\F3EC";
+}
+.mdi-pencil-box-multiple::before {
+ content: "\F016F";
+}
+.mdi-pencil-box-multiple-outline::before {
+ content: "\F0170";
+}
+.mdi-pencil-box-outline::before {
+ content: "\F3ED";
+}
+.mdi-pencil-circle::before {
+ content: "\F6FE";
+}
+.mdi-pencil-circle-outline::before {
+ content: "\F775";
+}
+.mdi-pencil-lock::before {
+ content: "\F3EE";
+}
+.mdi-pencil-lock-outline::before {
+ content: "\FDC3";
+}
+.mdi-pencil-minus::before {
+ content: "\FDC4";
+}
+.mdi-pencil-minus-outline::before {
+ content: "\FDC5";
+}
+.mdi-pencil-off::before {
+ content: "\F3EF";
+}
+.mdi-pencil-off-outline::before {
+ content: "\FDC6";
+}
+.mdi-pencil-outline::before {
+ content: "\FC92";
+}
+.mdi-pencil-plus::before {
+ content: "\FDC7";
+}
+.mdi-pencil-plus-outline::before {
+ content: "\FDC8";
+}
+.mdi-pencil-remove::before {
+ content: "\FDC9";
+}
+.mdi-pencil-remove-outline::before {
+ content: "\FDCA";
+}
+.mdi-pencil-ruler::before {
+ content: "\F037E";
+}
+.mdi-penguin::before {
+ content: "\FEDD";
+}
+.mdi-pentagon::before {
+ content: "\F6FF";
+}
+.mdi-pentagon-outline::before {
+ content: "\F700";
+}
+.mdi-percent::before {
+ content: "\F3F0";
+}
+.mdi-percent-outline::before {
+ content: "\F02A3";
+}
+.mdi-periodic-table::before {
+ content: "\F8B5";
+}
+.mdi-periodic-table-co::before {
+ content: "\F0329";
+}
+.mdi-periodic-table-co2::before {
+ content: "\F7E3";
+}
+.mdi-periscope::before {
+ content: "\F747";
+}
+.mdi-perspective-less::before {
+ content: "\FCFF";
+}
+.mdi-perspective-more::before {
+ content: "\FD00";
+}
+.mdi-pharmacy::before {
+ content: "\F3F1";
+}
+.mdi-phone::before {
+ content: "\F3F2";
+}
+.mdi-phone-alert::before {
+ content: "\FF37";
+}
+.mdi-phone-alert-outline::before {
+ content: "\F01B9";
+}
+.mdi-phone-bluetooth::before {
+ content: "\F3F3";
+}
+.mdi-phone-bluetooth-outline::before {
+ content: "\F01BA";
+}
+.mdi-phone-cancel::before {
+ content: "\F00E7";
+}
+.mdi-phone-cancel-outline::before {
+ content: "\F01BB";
+}
+.mdi-phone-check::before {
+ content: "\F01D4";
+}
+.mdi-phone-check-outline::before {
+ content: "\F01D5";
+}
+.mdi-phone-classic::before {
+ content: "\F602";
+}
+.mdi-phone-classic-off::before {
+ content: "\F02A4";
+}
+.mdi-phone-forward::before {
+ content: "\F3F4";
+}
+.mdi-phone-forward-outline::before {
+ content: "\F01BC";
+}
+.mdi-phone-hangup::before {
+ content: "\F3F5";
+}
+.mdi-phone-hangup-outline::before {
+ content: "\F01BD";
+}
+.mdi-phone-in-talk::before {
+ content: "\F3F6";
+}
+.mdi-phone-in-talk-outline::before {
+ content: "\F01AD";
+}
+.mdi-phone-incoming::before {
+ content: "\F3F7";
+}
+.mdi-phone-incoming-outline::before {
+ content: "\F01BE";
+}
+.mdi-phone-lock::before {
+ content: "\F3F8";
+}
+.mdi-phone-lock-outline::before {
+ content: "\F01BF";
+}
+.mdi-phone-log::before {
+ content: "\F3F9";
+}
+.mdi-phone-log-outline::before {
+ content: "\F01C0";
+}
+.mdi-phone-message::before {
+ content: "\F01C1";
+}
+.mdi-phone-message-outline::before {
+ content: "\F01C2";
+}
+.mdi-phone-minus::before {
+ content: "\F658";
+}
+.mdi-phone-minus-outline::before {
+ content: "\F01C3";
+}
+.mdi-phone-missed::before {
+ content: "\F3FA";
+}
+.mdi-phone-missed-outline::before {
+ content: "\F01D0";
+}
+.mdi-phone-off::before {
+ content: "\FDCB";
+}
+.mdi-phone-off-outline::before {
+ content: "\F01D1";
+}
+.mdi-phone-outgoing::before {
+ content: "\F3FB";
+}
+.mdi-phone-outgoing-outline::before {
+ content: "\F01C4";
+}
+.mdi-phone-outline::before {
+ content: "\FDCC";
+}
+.mdi-phone-paused::before {
+ content: "\F3FC";
+}
+.mdi-phone-paused-outline::before {
+ content: "\F01C5";
+}
+.mdi-phone-plus::before {
+ content: "\F659";
+}
+.mdi-phone-plus-outline::before {
+ content: "\F01C6";
+}
+.mdi-phone-return::before {
+ content: "\F82E";
+}
+.mdi-phone-return-outline::before {
+ content: "\F01C7";
+}
+.mdi-phone-ring::before {
+ content: "\F01D6";
+}
+.mdi-phone-ring-outline::before {
+ content: "\F01D7";
+}
+.mdi-phone-rotate-landscape::before {
+ content: "\F884";
+}
+.mdi-phone-rotate-portrait::before {
+ content: "\F885";
+}
+.mdi-phone-settings::before {
+ content: "\F3FD";
+}
+.mdi-phone-settings-outline::before {
+ content: "\F01C8";
+}
+.mdi-phone-voip::before {
+ content: "\F3FE";
+}
+.mdi-pi::before {
+ content: "\F3FF";
+}
+.mdi-pi-box::before {
+ content: "\F400";
+}
+.mdi-pi-hole::before {
+ content: "\FDCD";
+}
+.mdi-piano::before {
+ content: "\F67C";
+}
+.mdi-pickaxe::before {
+ content: "\F8B6";
+}
+.mdi-picture-in-picture-bottom-right::before {
+ content: "\FE3A";
+}
+.mdi-picture-in-picture-bottom-right-outline::before {
+ content: "\FE3B";
+}
+.mdi-picture-in-picture-top-right::before {
+ content: "\FE3C";
+}
+.mdi-picture-in-picture-top-right-outline::before {
+ content: "\FE3D";
+}
+.mdi-pier::before {
+ content: "\F886";
+}
+.mdi-pier-crane::before {
+ content: "\F887";
+}
+.mdi-pig::before {
+ content: "\F401";
+}
+.mdi-pig-variant::before {
+ content: "\F0028";
+}
+.mdi-piggy-bank::before {
+ content: "\F0029";
+}
+.mdi-pill::before {
+ content: "\F402";
+}
+.mdi-pillar::before {
+ content: "\F701";
+}
+.mdi-pin::before {
+ content: "\F403";
+}
+.mdi-pin-off::before {
+ content: "\F404";
+}
+.mdi-pin-off-outline::before {
+ content: "\F92F";
+}
+.mdi-pin-outline::before {
+ content: "\F930";
+}
+.mdi-pine-tree::before {
+ content: "\F405";
+}
+.mdi-pine-tree-box::before {
+ content: "\F406";
+}
+.mdi-pinterest::before {
+ content: "\F407";
+}
+.mdi-pinterest-box::before {
+ content: "\F408";
+}
+.mdi-pinwheel::before {
+ content: "\FAD4";
+}
+.mdi-pinwheel-outline::before {
+ content: "\FAD5";
+}
+.mdi-pipe::before {
+ content: "\F7E4";
+}
+.mdi-pipe-disconnected::before {
+ content: "\F7E5";
+}
+.mdi-pipe-leak::before {
+ content: "\F888";
+}
+.mdi-pipe-wrench::before {
+ content: "\F037F";
+}
+.mdi-pirate::before {
+ content: "\FA07";
+}
+.mdi-pistol::before {
+ content: "\F702";
+}
+.mdi-piston::before {
+ content: "\F889";
+}
+.mdi-pizza::before {
+ content: "\F409";
+}
+.mdi-play::before {
+ content: "\F40A";
+}
+.mdi-play-box::before {
+ content: "\F02A5";
+}
+.mdi-play-box-outline::before {
+ content: "\F40B";
+}
+.mdi-play-circle::before {
+ content: "\F40C";
+}
+.mdi-play-circle-outline::before {
+ content: "\F40D";
+}
+.mdi-play-network::before {
+ content: "\F88A";
+}
+.mdi-play-network-outline::before {
+ content: "\FC93";
+}
+.mdi-play-outline::before {
+ content: "\FF38";
+}
+.mdi-play-pause::before {
+ content: "\F40E";
+}
+.mdi-play-protected-content::before {
+ content: "\F40F";
+}
+.mdi-play-speed::before {
+ content: "\F8FE";
+}
+.mdi-playlist-check::before {
+ content: "\F5C7";
+}
+.mdi-playlist-edit::before {
+ content: "\F8FF";
+}
+.mdi-playlist-minus::before {
+ content: "\F410";
+}
+.mdi-playlist-music::before {
+ content: "\FC94";
+}
+.mdi-playlist-music-outline::before {
+ content: "\FC95";
+}
+.mdi-playlist-play::before {
+ content: "\F411";
+}
+.mdi-playlist-plus::before {
+ content: "\F412";
+}
+.mdi-playlist-remove::before {
+ content: "\F413";
+}
+.mdi-playlist-star::before {
+ content: "\FDCE";
+}
+.mdi-playstation::before {
+ content: "\F414";
+}
+.mdi-plex::before {
+ content: "\F6B9";
+}
+.mdi-plus::before {
+ content: "\F415";
+}
+.mdi-plus-box::before {
+ content: "\F416";
+}
+.mdi-plus-box-multiple::before {
+ content: "\F334";
+}
+.mdi-plus-box-multiple-outline::before {
+ content: "\F016E";
+}
+.mdi-plus-box-outline::before {
+ content: "\F703";
+}
+.mdi-plus-circle::before {
+ content: "\F417";
+}
+.mdi-plus-circle-multiple-outline::before {
+ content: "\F418";
+}
+.mdi-plus-circle-outline::before {
+ content: "\F419";
+}
+.mdi-plus-minus::before {
+ content: "\F991";
+}
+.mdi-plus-minus-box::before {
+ content: "\F992";
+}
+.mdi-plus-network::before {
+ content: "\F41A";
+}
+.mdi-plus-network-outline::before {
+ content: "\FC96";
+}
+.mdi-plus-one::before {
+ content: "\F41B";
+}
+.mdi-plus-outline::before {
+ content: "\F704";
+}
+.mdi-plus-thick::before {
+ content: "\F0217";
+}
+.mdi-pocket::before {
+ content: "\F41C";
+}
+.mdi-podcast::before {
+ content: "\F993";
+}
+.mdi-podium::before {
+ content: "\FD01";
+}
+.mdi-podium-bronze::before {
+ content: "\FD02";
+}
+.mdi-podium-gold::before {
+ content: "\FD03";
+}
+.mdi-podium-silver::before {
+ content: "\FD04";
+}
+.mdi-point-of-sale::before {
+ content: "\FD6E";
+}
+.mdi-pokeball::before {
+ content: "\F41D";
+}
+.mdi-pokemon-go::before {
+ content: "\FA08";
+}
+.mdi-poker-chip::before {
+ content: "\F82F";
+}
+.mdi-polaroid::before {
+ content: "\F41E";
+}
+.mdi-police-badge::before {
+ content: "\F0192";
+}
+.mdi-police-badge-outline::before {
+ content: "\F0193";
+}
+.mdi-poll::before {
+ content: "\F41F";
+}
+.mdi-poll-box::before {
+ content: "\F420";
+}
+.mdi-poll-box-outline::before {
+ content: "\F02A6";
+}
+.mdi-polymer::before {
+ content: "\F421";
+}
+.mdi-pool::before {
+ content: "\F606";
+}
+.mdi-popcorn::before {
+ content: "\F422";
+}
+.mdi-post::before {
+ content: "\F002A";
+}
+.mdi-post-outline::before {
+ content: "\F002B";
+}
+.mdi-postage-stamp::before {
+ content: "\FC97";
+}
+.mdi-pot::before {
+ content: "\F65A";
+}
+.mdi-pot-mix::before {
+ content: "\F65B";
+}
+.mdi-pound::before {
+ content: "\F423";
+}
+.mdi-pound-box::before {
+ content: "\F424";
+}
+.mdi-pound-box-outline::before {
+ content: "\F01AA";
+}
+.mdi-power::before {
+ content: "\F425";
+}
+.mdi-power-cycle::before {
+ content: "\F900";
+}
+.mdi-power-off::before {
+ content: "\F901";
+}
+.mdi-power-on::before {
+ content: "\F902";
+}
+.mdi-power-plug::before {
+ content: "\F6A4";
+}
+.mdi-power-plug-off::before {
+ content: "\F6A5";
+}
+.mdi-power-settings::before {
+ content: "\F426";
+}
+.mdi-power-sleep::before {
+ content: "\F903";
+}
+.mdi-power-socket::before {
+ content: "\F427";
+}
+.mdi-power-socket-au::before {
+ content: "\F904";
+}
+.mdi-power-socket-de::before {
+ content: "\F0132";
+}
+.mdi-power-socket-eu::before {
+ content: "\F7E6";
+}
+.mdi-power-socket-fr::before {
+ content: "\F0133";
+}
+.mdi-power-socket-jp::before {
+ content: "\F0134";
+}
+.mdi-power-socket-uk::before {
+ content: "\F7E7";
+}
+.mdi-power-socket-us::before {
+ content: "\F7E8";
+}
+.mdi-power-standby::before {
+ content: "\F905";
+}
+.mdi-powershell::before {
+ content: "\FA09";
+}
+.mdi-prescription::before {
+ content: "\F705";
+}
+.mdi-presentation::before {
+ content: "\F428";
+}
+.mdi-presentation-play::before {
+ content: "\F429";
+}
+.mdi-printer::before {
+ content: "\F42A";
+}
+.mdi-printer-3d::before {
+ content: "\F42B";
+}
+.mdi-printer-3d-nozzle::before {
+ content: "\FE3E";
+}
+.mdi-printer-3d-nozzle-alert::before {
+ content: "\F01EB";
+}
+.mdi-printer-3d-nozzle-alert-outline::before {
+ content: "\F01EC";
+}
+.mdi-printer-3d-nozzle-outline::before {
+ content: "\FE3F";
+}
+.mdi-printer-alert::before {
+ content: "\F42C";
+}
+.mdi-printer-check::before {
+ content: "\F0171";
+}
+.mdi-printer-off::before {
+ content: "\FE40";
+}
+.mdi-printer-pos::before {
+ content: "\F0079";
+}
+.mdi-printer-settings::before {
+ content: "\F706";
+}
+.mdi-printer-wireless::before {
+ content: "\FA0A";
+}
+.mdi-priority-high::before {
+ content: "\F603";
+}
+.mdi-priority-low::before {
+ content: "\F604";
+}
+.mdi-professional-hexagon::before {
+ content: "\F42D";
+}
+.mdi-progress-alert::before {
+ content: "\FC98";
+}
+.mdi-progress-check::before {
+ content: "\F994";
+}
+.mdi-progress-clock::before {
+ content: "\F995";
+}
+.mdi-progress-close::before {
+ content: "\F0135";
+}
+.mdi-progress-download::before {
+ content: "\F996";
+}
+.mdi-progress-upload::before {
+ content: "\F997";
+}
+.mdi-progress-wrench::before {
+ content: "\FC99";
+}
+.mdi-projector::before {
+ content: "\F42E";
+}
+.mdi-projector-screen::before {
+ content: "\F42F";
+}
+.mdi-propane-tank::before {
+ content: "\F0382";
+}
+.mdi-propane-tank-outline::before {
+ content: "\F0383";
+}
+.mdi-protocol::before {
+ content: "\FFF9";
+}
+.mdi-publish::before {
+ content: "\F6A6";
+}
+.mdi-pulse::before {
+ content: "\F430";
+}
+.mdi-pumpkin::before {
+ content: "\FB9B";
+}
+.mdi-purse::before {
+ content: "\FF39";
+}
+.mdi-purse-outline::before {
+ content: "\FF3A";
+}
+.mdi-puzzle::before {
+ content: "\F431";
+}
+.mdi-puzzle-outline::before {
+ content: "\FA65";
+}
+.mdi-qi::before {
+ content: "\F998";
+}
+.mdi-qqchat::before {
+ content: "\F605";
+}
+.mdi-qrcode::before {
+ content: "\F432";
+}
+.mdi-qrcode-edit::before {
+ content: "\F8B7";
+}
+.mdi-qrcode-minus::before {
+ content: "\F01B7";
+}
+.mdi-qrcode-plus::before {
+ content: "\F01B6";
+}
+.mdi-qrcode-remove::before {
+ content: "\F01B8";
+}
+.mdi-qrcode-scan::before {
+ content: "\F433";
+}
+.mdi-quadcopter::before {
+ content: "\F434";
+}
+.mdi-quality-high::before {
+ content: "\F435";
+}
+.mdi-quality-low::before {
+ content: "\FA0B";
+}
+.mdi-quality-medium::before {
+ content: "\FA0C";
+}
+.mdi-quicktime::before {
+ content: "\F436";
+}
+.mdi-quora::before {
+ content: "\FD05";
+}
+.mdi-rabbit::before {
+ content: "\F906";
+}
+.mdi-racing-helmet::before {
+ content: "\FD6F";
+}
+.mdi-racquetball::before {
+ content: "\FD70";
+}
+.mdi-radar::before {
+ content: "\F437";
+}
+.mdi-radiator::before {
+ content: "\F438";
+}
+.mdi-radiator-disabled::before {
+ content: "\FAD6";
+}
+.mdi-radiator-off::before {
+ content: "\FAD7";
+}
+.mdi-radio::before {
+ content: "\F439";
+}
+.mdi-radio-am::before {
+ content: "\FC9A";
+}
+.mdi-radio-fm::before {
+ content: "\FC9B";
+}
+.mdi-radio-handheld::before {
+ content: "\F43A";
+}
+.mdi-radio-off::before {
+ content: "\F0247";
+}
+.mdi-radio-tower::before {
+ content: "\F43B";
+}
+.mdi-radioactive::before {
+ content: "\F43C";
+}
+.mdi-radioactive-off::before {
+ content: "\FEDE";
+}
+.mdi-radiobox-blank::before {
+ content: "\F43D";
+}
+.mdi-radiobox-marked::before {
+ content: "\F43E";
+}
+.mdi-radius::before {
+ content: "\FC9C";
+}
+.mdi-radius-outline::before {
+ content: "\FC9D";
+}
+.mdi-railroad-light::before {
+ content: "\FF3B";
+}
+.mdi-raspberry-pi::before {
+ content: "\F43F";
+}
+.mdi-ray-end::before {
+ content: "\F440";
+}
+.mdi-ray-end-arrow::before {
+ content: "\F441";
+}
+.mdi-ray-start::before {
+ content: "\F442";
+}
+.mdi-ray-start-arrow::before {
+ content: "\F443";
+}
+.mdi-ray-start-end::before {
+ content: "\F444";
+}
+.mdi-ray-vertex::before {
+ content: "\F445";
+}
+.mdi-react::before {
+ content: "\F707";
+}
+.mdi-read::before {
+ content: "\F447";
+}
+.mdi-receipt::before {
+ content: "\F449";
+}
+.mdi-record::before {
+ content: "\F44A";
+}
+.mdi-record-circle::before {
+ content: "\FEDF";
+}
+.mdi-record-circle-outline::before {
+ content: "\FEE0";
+}
+.mdi-record-player::before {
+ content: "\F999";
+}
+.mdi-record-rec::before {
+ content: "\F44B";
+}
+.mdi-rectangle::before {
+ content: "\FE41";
+}
+.mdi-rectangle-outline::before {
+ content: "\FE42";
+}
+.mdi-recycle::before {
+ content: "\F44C";
+}
+.mdi-reddit::before {
+ content: "\F44D";
+}
+.mdi-redhat::before {
+ content: "\F0146";
+}
+.mdi-redo::before {
+ content: "\F44E";
+}
+.mdi-redo-variant::before {
+ content: "\F44F";
+}
+.mdi-reflect-horizontal::before {
+ content: "\FA0D";
+}
+.mdi-reflect-vertical::before {
+ content: "\FA0E";
+}
+.mdi-refresh::before {
+ content: "\F450";
+}
+.mdi-refresh-circle::before {
+ content: "\F03A2";
+}
+.mdi-regex::before {
+ content: "\F451";
+}
+.mdi-registered-trademark::before {
+ content: "\FA66";
+}
+.mdi-relative-scale::before {
+ content: "\F452";
+}
+.mdi-reload::before {
+ content: "\F453";
+}
+.mdi-reload-alert::before {
+ content: "\F0136";
+}
+.mdi-reminder::before {
+ content: "\F88B";
+}
+.mdi-remote::before {
+ content: "\F454";
+}
+.mdi-remote-desktop::before {
+ content: "\F8B8";
+}
+.mdi-remote-off::before {
+ content: "\FEE1";
+}
+.mdi-remote-tv::before {
+ content: "\FEE2";
+}
+.mdi-remote-tv-off::before {
+ content: "\FEE3";
+}
+.mdi-rename-box::before {
+ content: "\F455";
+}
+.mdi-reorder-horizontal::before {
+ content: "\F687";
+}
+.mdi-reorder-vertical::before {
+ content: "\F688";
+}
+.mdi-repeat::before {
+ content: "\F456";
+}
+.mdi-repeat-off::before {
+ content: "\F457";
+}
+.mdi-repeat-once::before {
+ content: "\F458";
+}
+.mdi-replay::before {
+ content: "\F459";
+}
+.mdi-reply::before {
+ content: "\F45A";
+}
+.mdi-reply-all::before {
+ content: "\F45B";
+}
+.mdi-reply-all-outline::before {
+ content: "\FF3C";
+}
+.mdi-reply-circle::before {
+ content: "\F01D9";
+}
+.mdi-reply-outline::before {
+ content: "\FF3D";
+}
+.mdi-reproduction::before {
+ content: "\F45C";
+}
+.mdi-resistor::before {
+ content: "\FB1F";
+}
+.mdi-resistor-nodes::before {
+ content: "\FB20";
+}
+.mdi-resize::before {
+ content: "\FA67";
+}
+.mdi-resize-bottom-right::before {
+ content: "\F45D";
+}
+.mdi-responsive::before {
+ content: "\F45E";
+}
+.mdi-restart::before {
+ content: "\F708";
+}
+.mdi-restart-alert::before {
+ content: "\F0137";
+}
+.mdi-restart-off::before {
+ content: "\FD71";
+}
+.mdi-restore::before {
+ content: "\F99A";
+}
+.mdi-restore-alert::before {
+ content: "\F0138";
+}
+.mdi-rewind::before {
+ content: "\F45F";
+}
+.mdi-rewind-10::before {
+ content: "\FD06";
+}
+.mdi-rewind-30::before {
+ content: "\FD72";
+}
+.mdi-rewind-5::before {
+ content: "\F0224";
+}
+.mdi-rewind-outline::before {
+ content: "\F709";
+}
+.mdi-rhombus::before {
+ content: "\F70A";
+}
+.mdi-rhombus-medium::before {
+ content: "\FA0F";
+}
+.mdi-rhombus-outline::before {
+ content: "\F70B";
+}
+.mdi-rhombus-split::before {
+ content: "\FA10";
+}
+.mdi-ribbon::before {
+ content: "\F460";
+}
+.mdi-rice::before {
+ content: "\F7E9";
+}
+.mdi-ring::before {
+ content: "\F7EA";
+}
+.mdi-rivet::before {
+ content: "\FE43";
+}
+.mdi-road::before {
+ content: "\F461";
+}
+.mdi-road-variant::before {
+ content: "\F462";
+}
+.mdi-robber::before {
+ content: "\F007A";
+}
+.mdi-robot::before {
+ content: "\F6A8";
+}
+.mdi-robot-industrial::before {
+ content: "\FB21";
+}
+.mdi-robot-mower::before {
+ content: "\F0222";
+}
+.mdi-robot-mower-outline::before {
+ content: "\F021E";
+}
+.mdi-robot-vacuum::before {
+ content: "\F70C";
+}
+.mdi-robot-vacuum-variant::before {
+ content: "\F907";
+}
+.mdi-rocket::before {
+ content: "\F463";
+}
+.mdi-rodent::before {
+ content: "\F0352";
+}
+.mdi-roller-skate::before {
+ content: "\FD07";
+}
+.mdi-rollerblade::before {
+ content: "\FD08";
+}
+.mdi-rollupjs::before {
+ content: "\FB9C";
+}
+.mdi-roman-numeral-1::before {
+ content: "\F00B3";
+}
+.mdi-roman-numeral-10::before {
+ content: "\F00BC";
+}
+.mdi-roman-numeral-2::before {
+ content: "\F00B4";
+}
+.mdi-roman-numeral-3::before {
+ content: "\F00B5";
+}
+.mdi-roman-numeral-4::before {
+ content: "\F00B6";
+}
+.mdi-roman-numeral-5::before {
+ content: "\F00B7";
+}
+.mdi-roman-numeral-6::before {
+ content: "\F00B8";
+}
+.mdi-roman-numeral-7::before {
+ content: "\F00B9";
+}
+.mdi-roman-numeral-8::before {
+ content: "\F00BA";
+}
+.mdi-roman-numeral-9::before {
+ content: "\F00BB";
+}
+.mdi-room-service::before {
+ content: "\F88C";
+}
+.mdi-room-service-outline::before {
+ content: "\FD73";
+}
+.mdi-rotate-3d::before {
+ content: "\FEE4";
+}
+.mdi-rotate-3d-variant::before {
+ content: "\F464";
+}
+.mdi-rotate-left::before {
+ content: "\F465";
+}
+.mdi-rotate-left-variant::before {
+ content: "\F466";
+}
+.mdi-rotate-orbit::before {
+ content: "\FD74";
+}
+.mdi-rotate-right::before {
+ content: "\F467";
+}
+.mdi-rotate-right-variant::before {
+ content: "\F468";
+}
+.mdi-rounded-corner::before {
+ content: "\F607";
+}
+.mdi-router::before {
+ content: "\F020D";
+}
+.mdi-router-wireless::before {
+ content: "\F469";
+}
+.mdi-router-wireless-settings::before {
+ content: "\FA68";
+}
+.mdi-routes::before {
+ content: "\F46A";
+}
+.mdi-routes-clock::before {
+ content: "\F007B";
+}
+.mdi-rowing::before {
+ content: "\F608";
+}
+.mdi-rss::before {
+ content: "\F46B";
+}
+.mdi-rss-box::before {
+ content: "\F46C";
+}
+.mdi-rss-off::before {
+ content: "\FF3E";
+}
+.mdi-ruby::before {
+ content: "\FD09";
+}
+.mdi-rugby::before {
+ content: "\FD75";
+}
+.mdi-ruler::before {
+ content: "\F46D";
+}
+.mdi-ruler-square::before {
+ content: "\FC9E";
+}
+.mdi-ruler-square-compass::before {
+ content: "\FEDB";
+}
+.mdi-run::before {
+ content: "\F70D";
+}
+.mdi-run-fast::before {
+ content: "\F46E";
+}
+.mdi-rv-truck::before {
+ content: "\F01FF";
+}
+.mdi-sack::before {
+ content: "\FD0A";
+}
+.mdi-sack-percent::before {
+ content: "\FD0B";
+}
+.mdi-safe::before {
+ content: "\FA69";
+}
+.mdi-safe-square::before {
+ content: "\F02A7";
+}
+.mdi-safe-square-outline::before {
+ content: "\F02A8";
+}
+.mdi-safety-goggles::before {
+ content: "\FD0C";
+}
+.mdi-sailing::before {
+ content: "\FEE5";
+}
+.mdi-sale::before {
+ content: "\F46F";
+}
+.mdi-salesforce::before {
+ content: "\F88D";
+}
+.mdi-sass::before {
+ content: "\F7EB";
+}
+.mdi-satellite::before {
+ content: "\F470";
+}
+.mdi-satellite-uplink::before {
+ content: "\F908";
+}
+.mdi-satellite-variant::before {
+ content: "\F471";
+}
+.mdi-sausage::before {
+ content: "\F8B9";
+}
+.mdi-saw-blade::before {
+ content: "\FE44";
+}
+.mdi-saxophone::before {
+ content: "\F609";
+}
+.mdi-scale::before {
+ content: "\F472";
+}
+.mdi-scale-balance::before {
+ content: "\F5D1";
+}
+.mdi-scale-bathroom::before {
+ content: "\F473";
+}
+.mdi-scale-off::before {
+ content: "\F007C";
+}
+.mdi-scanner::before {
+ content: "\F6AA";
+}
+.mdi-scanner-off::before {
+ content: "\F909";
+}
+.mdi-scatter-plot::before {
+ content: "\FEE6";
+}
+.mdi-scatter-plot-outline::before {
+ content: "\FEE7";
+}
+.mdi-school::before {
+ content: "\F474";
+}
+.mdi-school-outline::before {
+ content: "\F01AB";
+}
+.mdi-scissors-cutting::before {
+ content: "\FA6A";
+}
+.mdi-scooter::before {
+ content: "\F0214";
+}
+.mdi-scoreboard::before {
+ content: "\F02A9";
+}
+.mdi-scoreboard-outline::before {
+ content: "\F02AA";
+}
+.mdi-screen-rotation::before {
+ content: "\F475";
+}
+.mdi-screen-rotation-lock::before {
+ content: "\F476";
+}
+.mdi-screw-flat-top::before {
+ content: "\FDCF";
+}
+.mdi-screw-lag::before {
+ content: "\FE54";
+}
+.mdi-screw-machine-flat-top::before {
+ content: "\FE55";
+}
+.mdi-screw-machine-round-top::before {
+ content: "\FE56";
+}
+.mdi-screw-round-top::before {
+ content: "\FE57";
+}
+.mdi-screwdriver::before {
+ content: "\F477";
+}
+.mdi-script::before {
+ content: "\FB9D";
+}
+.mdi-script-outline::before {
+ content: "\F478";
+}
+.mdi-script-text::before {
+ content: "\FB9E";
+}
+.mdi-script-text-outline::before {
+ content: "\FB9F";
+}
+.mdi-sd::before {
+ content: "\F479";
+}
+.mdi-seal::before {
+ content: "\F47A";
+}
+.mdi-seal-variant::before {
+ content: "\FFFA";
+}
+.mdi-search-web::before {
+ content: "\F70E";
+}
+.mdi-seat::before {
+ content: "\FC9F";
+}
+.mdi-seat-flat::before {
+ content: "\F47B";
+}
+.mdi-seat-flat-angled::before {
+ content: "\F47C";
+}
+.mdi-seat-individual-suite::before {
+ content: "\F47D";
+}
+.mdi-seat-legroom-extra::before {
+ content: "\F47E";
+}
+.mdi-seat-legroom-normal::before {
+ content: "\F47F";
+}
+.mdi-seat-legroom-reduced::before {
+ content: "\F480";
+}
+.mdi-seat-outline::before {
+ content: "\FCA0";
+}
+.mdi-seat-passenger::before {
+ content: "\F0274";
+}
+.mdi-seat-recline-extra::before {
+ content: "\F481";
+}
+.mdi-seat-recline-normal::before {
+ content: "\F482";
+}
+.mdi-seatbelt::before {
+ content: "\FCA1";
+}
+.mdi-security::before {
+ content: "\F483";
+}
+.mdi-security-network::before {
+ content: "\F484";
+}
+.mdi-seed::before {
+ content: "\FE45";
+}
+.mdi-seed-outline::before {
+ content: "\FE46";
+}
+.mdi-segment::before {
+ content: "\FEE8";
+}
+.mdi-select::before {
+ content: "\F485";
+}
+.mdi-select-all::before {
+ content: "\F486";
+}
+.mdi-select-color::before {
+ content: "\FD0D";
+}
+.mdi-select-compare::before {
+ content: "\FAD8";
+}
+.mdi-select-drag::before {
+ content: "\FA6B";
+}
+.mdi-select-group::before {
+ content: "\FF9F";
+}
+.mdi-select-inverse::before {
+ content: "\F487";
+}
+.mdi-select-marker::before {
+ content: "\F02AB";
+}
+.mdi-select-multiple::before {
+ content: "\F02AC";
+}
+.mdi-select-multiple-marker::before {
+ content: "\F02AD";
+}
+.mdi-select-off::before {
+ content: "\F488";
+}
+.mdi-select-place::before {
+ content: "\FFFB";
+}
+.mdi-select-search::before {
+ content: "\F022F";
+}
+.mdi-selection::before {
+ content: "\F489";
+}
+.mdi-selection-drag::before {
+ content: "\FA6C";
+}
+.mdi-selection-ellipse::before {
+ content: "\FD0E";
+}
+.mdi-selection-ellipse-arrow-inside::before {
+ content: "\FF3F";
+}
+.mdi-selection-marker::before {
+ content: "\F02AE";
+}
+.mdi-selection-multiple-marker::before {
+ content: "\F02AF";
+}
+.mdi-selection-mutliple::before {
+ content: "\F02B0";
+}
+.mdi-selection-off::before {
+ content: "\F776";
+}
+.mdi-selection-search::before {
+ content: "\F0230";
+}
+.mdi-semantic-web::before {
+ content: "\F0341";
+}
+.mdi-send::before {
+ content: "\F48A";
+}
+.mdi-send-check::before {
+ content: "\F018C";
+}
+.mdi-send-check-outline::before {
+ content: "\F018D";
+}
+.mdi-send-circle::before {
+ content: "\FE58";
+}
+.mdi-send-circle-outline::before {
+ content: "\FE59";
+}
+.mdi-send-clock::before {
+ content: "\F018E";
+}
+.mdi-send-clock-outline::before {
+ content: "\F018F";
+}
+.mdi-send-lock::before {
+ content: "\F7EC";
+}
+.mdi-send-lock-outline::before {
+ content: "\F0191";
+}
+.mdi-send-outline::before {
+ content: "\F0190";
+}
+.mdi-serial-port::before {
+ content: "\F65C";
+}
+.mdi-server::before {
+ content: "\F48B";
+}
+.mdi-server-minus::before {
+ content: "\F48C";
+}
+.mdi-server-network::before {
+ content: "\F48D";
+}
+.mdi-server-network-off::before {
+ content: "\F48E";
+}
+.mdi-server-off::before {
+ content: "\F48F";
+}
+.mdi-server-plus::before {
+ content: "\F490";
+}
+.mdi-server-remove::before {
+ content: "\F491";
+}
+.mdi-server-security::before {
+ content: "\F492";
+}
+.mdi-set-all::before {
+ content: "\F777";
+}
+.mdi-set-center::before {
+ content: "\F778";
+}
+.mdi-set-center-right::before {
+ content: "\F779";
+}
+.mdi-set-left::before {
+ content: "\F77A";
+}
+.mdi-set-left-center::before {
+ content: "\F77B";
+}
+.mdi-set-left-right::before {
+ content: "\F77C";
+}
+.mdi-set-none::before {
+ content: "\F77D";
+}
+.mdi-set-right::before {
+ content: "\F77E";
+}
+.mdi-set-top-box::before {
+ content: "\F99E";
+}
+.mdi-settings::before {
+ content: "\F493";
+}
+.mdi-settings-box::before {
+ content: "\F494";
+}
+.mdi-settings-helper::before {
+ content: "\FA6D";
+}
+.mdi-settings-outline::before {
+ content: "\F8BA";
+}
+.mdi-settings-transfer::before {
+ content: "\F007D";
+}
+.mdi-settings-transfer-outline::before {
+ content: "\F007E";
+}
+.mdi-shaker::before {
+ content: "\F0139";
+}
+.mdi-shaker-outline::before {
+ content: "\F013A";
+}
+.mdi-shape::before {
+ content: "\F830";
+}
+.mdi-shape-circle-plus::before {
+ content: "\F65D";
+}
+.mdi-shape-outline::before {
+ content: "\F831";
+}
+.mdi-shape-oval-plus::before {
+ content: "\F0225";
+}
+.mdi-shape-plus::before {
+ content: "\F495";
+}
+.mdi-shape-polygon-plus::before {
+ content: "\F65E";
+}
+.mdi-shape-rectangle-plus::before {
+ content: "\F65F";
+}
+.mdi-shape-square-plus::before {
+ content: "\F660";
+}
+.mdi-share::before {
+ content: "\F496";
+}
+.mdi-share-all::before {
+ content: "\F021F";
+}
+.mdi-share-all-outline::before {
+ content: "\F0220";
+}
+.mdi-share-circle::before {
+ content: "\F01D8";
+}
+.mdi-share-off::before {
+ content: "\FF40";
+}
+.mdi-share-off-outline::before {
+ content: "\FF41";
+}
+.mdi-share-outline::before {
+ content: "\F931";
+}
+.mdi-share-variant::before {
+ content: "\F497";
+}
+.mdi-sheep::before {
+ content: "\FCA2";
+}
+.mdi-shield::before {
+ content: "\F498";
+}
+.mdi-shield-account::before {
+ content: "\F88E";
+}
+.mdi-shield-account-outline::before {
+ content: "\FA11";
+}
+.mdi-shield-airplane::before {
+ content: "\F6BA";
+}
+.mdi-shield-airplane-outline::before {
+ content: "\FCA3";
+}
+.mdi-shield-alert::before {
+ content: "\FEE9";
+}
+.mdi-shield-alert-outline::before {
+ content: "\FEEA";
+}
+.mdi-shield-car::before {
+ content: "\FFA0";
+}
+.mdi-shield-check::before {
+ content: "\F565";
+}
+.mdi-shield-check-outline::before {
+ content: "\FCA4";
+}
+.mdi-shield-cross::before {
+ content: "\FCA5";
+}
+.mdi-shield-cross-outline::before {
+ content: "\FCA6";
+}
+.mdi-shield-edit::before {
+ content: "\F01CB";
+}
+.mdi-shield-edit-outline::before {
+ content: "\F01CC";
+}
+.mdi-shield-half::before {
+ content: "\F038B";
+}
+.mdi-shield-half-full::before {
+ content: "\F77F";
+}
+.mdi-shield-home::before {
+ content: "\F689";
+}
+.mdi-shield-home-outline::before {
+ content: "\FCA7";
+}
+.mdi-shield-key::before {
+ content: "\FBA0";
+}
+.mdi-shield-key-outline::before {
+ content: "\FBA1";
+}
+.mdi-shield-link-variant::before {
+ content: "\FD0F";
+}
+.mdi-shield-link-variant-outline::before {
+ content: "\FD10";
+}
+.mdi-shield-lock::before {
+ content: "\F99C";
+}
+.mdi-shield-lock-outline::before {
+ content: "\FCA8";
+}
+.mdi-shield-off::before {
+ content: "\F99D";
+}
+.mdi-shield-off-outline::before {
+ content: "\F99B";
+}
+.mdi-shield-outline::before {
+ content: "\F499";
+}
+.mdi-shield-plus::before {
+ content: "\FAD9";
+}
+.mdi-shield-plus-outline::before {
+ content: "\FADA";
+}
+.mdi-shield-refresh::before {
+ content: "\F01CD";
+}
+.mdi-shield-refresh-outline::before {
+ content: "\F01CE";
+}
+.mdi-shield-remove::before {
+ content: "\FADB";
+}
+.mdi-shield-remove-outline::before {
+ content: "\FADC";
+}
+.mdi-shield-search::before {
+ content: "\FD76";
+}
+.mdi-shield-star::before {
+ content: "\F0166";
+}
+.mdi-shield-star-outline::before {
+ content: "\F0167";
+}
+.mdi-shield-sun::before {
+ content: "\F007F";
+}
+.mdi-shield-sun-outline::before {
+ content: "\F0080";
+}
+.mdi-ship-wheel::before {
+ content: "\F832";
+}
+.mdi-shoe-formal::before {
+ content: "\FB22";
+}
+.mdi-shoe-heel::before {
+ content: "\FB23";
+}
+.mdi-shoe-print::before {
+ content: "\FE5A";
+}
+.mdi-shopify::before {
+ content: "\FADD";
+}
+.mdi-shopping::before {
+ content: "\F49A";
+}
+.mdi-shopping-music::before {
+ content: "\F49B";
+}
+.mdi-shopping-outline::before {
+ content: "\F0200";
+}
+.mdi-shopping-search::before {
+ content: "\FFA1";
+}
+.mdi-shovel::before {
+ content: "\F70F";
+}
+.mdi-shovel-off::before {
+ content: "\F710";
+}
+.mdi-shower::before {
+ content: "\F99F";
+}
+.mdi-shower-head::before {
+ content: "\F9A0";
+}
+.mdi-shredder::before {
+ content: "\F49C";
+}
+.mdi-shuffle::before {
+ content: "\F49D";
+}
+.mdi-shuffle-disabled::before {
+ content: "\F49E";
+}
+.mdi-shuffle-variant::before {
+ content: "\F49F";
+}
+.mdi-shuriken::before {
+ content: "\F03AA";
+}
+.mdi-sigma::before {
+ content: "\F4A0";
+}
+.mdi-sigma-lower::before {
+ content: "\F62B";
+}
+.mdi-sign-caution::before {
+ content: "\F4A1";
+}
+.mdi-sign-direction::before {
+ content: "\F780";
+}
+.mdi-sign-direction-minus::before {
+ content: "\F0022";
+}
+.mdi-sign-direction-plus::before {
+ content: "\FFFD";
+}
+.mdi-sign-direction-remove::before {
+ content: "\FFFE";
+}
+.mdi-sign-real-estate::before {
+ content: "\F0143";
+}
+.mdi-sign-text::before {
+ content: "\F781";
+}
+.mdi-signal::before {
+ content: "\F4A2";
+}
+.mdi-signal-2g::before {
+ content: "\F711";
+}
+.mdi-signal-3g::before {
+ content: "\F712";
+}
+.mdi-signal-4g::before {
+ content: "\F713";
+}
+.mdi-signal-5g::before {
+ content: "\FA6E";
+}
+.mdi-signal-cellular-1::before {
+ content: "\F8BB";
+}
+.mdi-signal-cellular-2::before {
+ content: "\F8BC";
+}
+.mdi-signal-cellular-3::before {
+ content: "\F8BD";
+}
+.mdi-signal-cellular-outline::before {
+ content: "\F8BE";
+}
+.mdi-signal-distance-variant::before {
+ content: "\FE47";
+}
+.mdi-signal-hspa::before {
+ content: "\F714";
+}
+.mdi-signal-hspa-plus::before {
+ content: "\F715";
+}
+.mdi-signal-off::before {
+ content: "\F782";
+}
+.mdi-signal-variant::before {
+ content: "\F60A";
+}
+.mdi-signature::before {
+ content: "\FE5B";
+}
+.mdi-signature-freehand::before {
+ content: "\FE5C";
+}
+.mdi-signature-image::before {
+ content: "\FE5D";
+}
+.mdi-signature-text::before {
+ content: "\FE5E";
+}
+.mdi-silo::before {
+ content: "\FB24";
+}
+.mdi-silverware::before {
+ content: "\F4A3";
+}
+.mdi-silverware-clean::before {
+ content: "\FFFF";
+}
+.mdi-silverware-fork::before {
+ content: "\F4A4";
+}
+.mdi-silverware-fork-knife::before {
+ content: "\FA6F";
+}
+.mdi-silverware-spoon::before {
+ content: "\F4A5";
+}
+.mdi-silverware-variant::before {
+ content: "\F4A6";
+}
+.mdi-sim::before {
+ content: "\F4A7";
+}
+.mdi-sim-alert::before {
+ content: "\F4A8";
+}
+.mdi-sim-off::before {
+ content: "\F4A9";
+}
+.mdi-simple-icons::before {
+ content: "\F0348";
+}
+.mdi-sina-weibo::before {
+ content: "\FADE";
+}
+.mdi-sitemap::before {
+ content: "\F4AA";
+}
+.mdi-skate::before {
+ content: "\FD11";
+}
+.mdi-skew-less::before {
+ content: "\FD12";
+}
+.mdi-skew-more::before {
+ content: "\FD13";
+}
+.mdi-ski::before {
+ content: "\F032F";
+}
+.mdi-ski-cross-country::before {
+ content: "\F0330";
+}
+.mdi-ski-water::before {
+ content: "\F0331";
+}
+.mdi-skip-backward::before {
+ content: "\F4AB";
+}
+.mdi-skip-backward-outline::before {
+ content: "\FF42";
+}
+.mdi-skip-forward::before {
+ content: "\F4AC";
+}
+.mdi-skip-forward-outline::before {
+ content: "\FF43";
+}
+.mdi-skip-next::before {
+ content: "\F4AD";
+}
+.mdi-skip-next-circle::before {
+ content: "\F661";
+}
+.mdi-skip-next-circle-outline::before {
+ content: "\F662";
+}
+.mdi-skip-next-outline::before {
+ content: "\FF44";
+}
+.mdi-skip-previous::before {
+ content: "\F4AE";
+}
+.mdi-skip-previous-circle::before {
+ content: "\F663";
+}
+.mdi-skip-previous-circle-outline::before {
+ content: "\F664";
+}
+.mdi-skip-previous-outline::before {
+ content: "\FF45";
+}
+.mdi-skull::before {
+ content: "\F68B";
+}
+.mdi-skull-crossbones::before {
+ content: "\FBA2";
+}
+.mdi-skull-crossbones-outline::before {
+ content: "\FBA3";
+}
+.mdi-skull-outline::before {
+ content: "\FBA4";
+}
+.mdi-skype::before {
+ content: "\F4AF";
+}
+.mdi-skype-business::before {
+ content: "\F4B0";
+}
+.mdi-slack::before {
+ content: "\F4B1";
+}
+.mdi-slackware::before {
+ content: "\F90A";
+}
+.mdi-slash-forward::before {
+ content: "\F0000";
+}
+.mdi-slash-forward-box::before {
+ content: "\F0001";
+}
+.mdi-sleep::before {
+ content: "\F4B2";
+}
+.mdi-sleep-off::before {
+ content: "\F4B3";
+}
+.mdi-slope-downhill::before {
+ content: "\FE5F";
+}
+.mdi-slope-uphill::before {
+ content: "\FE60";
+}
+.mdi-slot-machine::before {
+ content: "\F013F";
+}
+.mdi-slot-machine-outline::before {
+ content: "\F0140";
+}
+.mdi-smart-card::before {
+ content: "\F00E8";
+}
+.mdi-smart-card-outline::before {
+ content: "\F00E9";
+}
+.mdi-smart-card-reader::before {
+ content: "\F00EA";
+}
+.mdi-smart-card-reader-outline::before {
+ content: "\F00EB";
+}
+.mdi-smog::before {
+ content: "\FA70";
+}
+.mdi-smoke-detector::before {
+ content: "\F392";
+}
+.mdi-smoking::before {
+ content: "\F4B4";
+}
+.mdi-smoking-off::before {
+ content: "\F4B5";
+}
+.mdi-snapchat::before {
+ content: "\F4B6";
+}
+.mdi-snowboard::before {
+ content: "\F0332";
+}
+.mdi-snowflake::before {
+ content: "\F716";
+}
+.mdi-snowflake-alert::before {
+ content: "\FF46";
+}
+.mdi-snowflake-melt::before {
+ content: "\F02F6";
+}
+.mdi-snowflake-variant::before {
+ content: "\FF47";
+}
+.mdi-snowman::before {
+ content: "\F4B7";
+}
+.mdi-soccer::before {
+ content: "\F4B8";
+}
+.mdi-soccer-field::before {
+ content: "\F833";
+}
+.mdi-sofa::before {
+ content: "\F4B9";
+}
+.mdi-solar-panel::before {
+ content: "\FD77";
+}
+.mdi-solar-panel-large::before {
+ content: "\FD78";
+}
+.mdi-solar-power::before {
+ content: "\FA71";
+}
+.mdi-soldering-iron::before {
+ content: "\F00BD";
+}
+.mdi-solid::before {
+ content: "\F68C";
+}
+.mdi-sort::before {
+ content: "\F4BA";
+}
+.mdi-sort-alphabetical::before {
+ content: "\F4BB";
+}
+.mdi-sort-alphabetical-ascending::before {
+ content: "\F0173";
+}
+.mdi-sort-alphabetical-descending::before {
+ content: "\F0174";
+}
+.mdi-sort-ascending::before {
+ content: "\F4BC";
+}
+.mdi-sort-descending::before {
+ content: "\F4BD";
+}
+.mdi-sort-numeric::before {
+ content: "\F4BE";
+}
+.mdi-sort-variant::before {
+ content: "\F4BF";
+}
+.mdi-sort-variant-lock::before {
+ content: "\FCA9";
+}
+.mdi-sort-variant-lock-open::before {
+ content: "\FCAA";
+}
+.mdi-sort-variant-remove::before {
+ content: "\F0172";
+}
+.mdi-soundcloud::before {
+ content: "\F4C0";
+}
+.mdi-source-branch::before {
+ content: "\F62C";
+}
+.mdi-source-commit::before {
+ content: "\F717";
+}
+.mdi-source-commit-end::before {
+ content: "\F718";
+}
+.mdi-source-commit-end-local::before {
+ content: "\F719";
+}
+.mdi-source-commit-local::before {
+ content: "\F71A";
+}
+.mdi-source-commit-next-local::before {
+ content: "\F71B";
+}
+.mdi-source-commit-start::before {
+ content: "\F71C";
+}
+.mdi-source-commit-start-next-local::before {
+ content: "\F71D";
+}
+.mdi-source-fork::before {
+ content: "\F4C1";
+}
+.mdi-source-merge::before {
+ content: "\F62D";
+}
+.mdi-source-pull::before {
+ content: "\F4C2";
+}
+.mdi-source-repository::before {
+ content: "\FCAB";
+}
+.mdi-source-repository-multiple::before {
+ content: "\FCAC";
+}
+.mdi-soy-sauce::before {
+ content: "\F7ED";
+}
+.mdi-spa::before {
+ content: "\FCAD";
+}
+.mdi-spa-outline::before {
+ content: "\FCAE";
+}
+.mdi-space-invaders::before {
+ content: "\FBA5";
+}
+.mdi-space-station::before {
+ content: "\F03AE";
+}
+.mdi-spade::before {
+ content: "\FE48";
+}
+.mdi-speaker::before {
+ content: "\F4C3";
+}
+.mdi-speaker-bluetooth::before {
+ content: "\F9A1";
+}
+.mdi-speaker-multiple::before {
+ content: "\FD14";
+}
+.mdi-speaker-off::before {
+ content: "\F4C4";
+}
+.mdi-speaker-wireless::before {
+ content: "\F71E";
+}
+.mdi-speedometer::before {
+ content: "\F4C5";
+}
+.mdi-speedometer-medium::before {
+ content: "\FFA2";
+}
+.mdi-speedometer-slow::before {
+ content: "\FFA3";
+}
+.mdi-spellcheck::before {
+ content: "\F4C6";
+}
+.mdi-spider::before {
+ content: "\F0215";
+}
+.mdi-spider-thread::before {
+ content: "\F0216";
+}
+.mdi-spider-web::before {
+ content: "\FBA6";
+}
+.mdi-spotify::before {
+ content: "\F4C7";
+}
+.mdi-spotlight::before {
+ content: "\F4C8";
+}
+.mdi-spotlight-beam::before {
+ content: "\F4C9";
+}
+.mdi-spray::before {
+ content: "\F665";
+}
+.mdi-spray-bottle::before {
+ content: "\FADF";
+}
+.mdi-sprinkler::before {
+ content: "\F0081";
+}
+.mdi-sprinkler-variant::before {
+ content: "\F0082";
+}
+.mdi-sprout::before {
+ content: "\FE49";
+}
+.mdi-sprout-outline::before {
+ content: "\FE4A";
+}
+.mdi-square::before {
+ content: "\F763";
+}
+.mdi-square-edit-outline::before {
+ content: "\F90B";
+}
+.mdi-square-inc::before {
+ content: "\F4CA";
+}
+.mdi-square-inc-cash::before {
+ content: "\F4CB";
+}
+.mdi-square-medium::before {
+ content: "\FA12";
+}
+.mdi-square-medium-outline::before {
+ content: "\FA13";
+}
+.mdi-square-off::before {
+ content: "\F0319";
+}
+.mdi-square-off-outline::before {
+ content: "\F031A";
+}
+.mdi-square-outline::before {
+ content: "\F762";
+}
+.mdi-square-root::before {
+ content: "\F783";
+}
+.mdi-square-root-box::before {
+ content: "\F9A2";
+}
+.mdi-square-small::before {
+ content: "\FA14";
+}
+.mdi-squeegee::before {
+ content: "\FAE0";
+}
+.mdi-ssh::before {
+ content: "\F8BF";
+}
+.mdi-stack-exchange::before {
+ content: "\F60B";
+}
+.mdi-stack-overflow::before {
+ content: "\F4CC";
+}
+.mdi-stackpath::before {
+ content: "\F359";
+}
+.mdi-stadium::before {
+ content: "\F001A";
+}
+.mdi-stadium-variant::before {
+ content: "\F71F";
+}
+.mdi-stairs::before {
+ content: "\F4CD";
+}
+.mdi-stairs-down::before {
+ content: "\F02E9";
+}
+.mdi-stairs-up::before {
+ content: "\F02E8";
+}
+.mdi-stamper::before {
+ content: "\FD15";
+}
+.mdi-standard-definition::before {
+ content: "\F7EE";
+}
+.mdi-star::before {
+ content: "\F4CE";
+}
+.mdi-star-box::before {
+ content: "\FA72";
+}
+.mdi-star-box-multiple::before {
+ content: "\F02B1";
+}
+.mdi-star-box-multiple-outline::before {
+ content: "\F02B2";
+}
+.mdi-star-box-outline::before {
+ content: "\FA73";
+}
+.mdi-star-circle::before {
+ content: "\F4CF";
+}
+.mdi-star-circle-outline::before {
+ content: "\F9A3";
+}
+.mdi-star-face::before {
+ content: "\F9A4";
+}
+.mdi-star-four-points::before {
+ content: "\FAE1";
+}
+.mdi-star-four-points-outline::before {
+ content: "\FAE2";
+}
+.mdi-star-half::before {
+ content: "\F4D0";
+}
+.mdi-star-off::before {
+ content: "\F4D1";
+}
+.mdi-star-outline::before {
+ content: "\F4D2";
+}
+.mdi-star-three-points::before {
+ content: "\FAE3";
+}
+.mdi-star-three-points-outline::before {
+ content: "\FAE4";
+}
+.mdi-state-machine::before {
+ content: "\F021A";
+}
+.mdi-steam::before {
+ content: "\F4D3";
+}
+.mdi-steam-box::before {
+ content: "\F90C";
+}
+.mdi-steering::before {
+ content: "\F4D4";
+}
+.mdi-steering-off::before {
+ content: "\F90D";
+}
+.mdi-step-backward::before {
+ content: "\F4D5";
+}
+.mdi-step-backward-2::before {
+ content: "\F4D6";
+}
+.mdi-step-forward::before {
+ content: "\F4D7";
+}
+.mdi-step-forward-2::before {
+ content: "\F4D8";
+}
+.mdi-stethoscope::before {
+ content: "\F4D9";
+}
+.mdi-sticker::before {
+ content: "\F038F";
+}
+.mdi-sticker-alert::before {
+ content: "\F0390";
+}
+.mdi-sticker-alert-outline::before {
+ content: "\F0391";
+}
+.mdi-sticker-check::before {
+ content: "\F0392";
+}
+.mdi-sticker-check-outline::before {
+ content: "\F0393";
+}
+.mdi-sticker-circle-outline::before {
+ content: "\F5D0";
+}
+.mdi-sticker-emoji::before {
+ content: "\F784";
+}
+.mdi-sticker-minus::before {
+ content: "\F0394";
+}
+.mdi-sticker-minus-outline::before {
+ content: "\F0395";
+}
+.mdi-sticker-outline::before {
+ content: "\F0396";
+}
+.mdi-sticker-plus::before {
+ content: "\F0397";
+}
+.mdi-sticker-plus-outline::before {
+ content: "\F0398";
+}
+.mdi-sticker-remove::before {
+ content: "\F0399";
+}
+.mdi-sticker-remove-outline::before {
+ content: "\F039A";
+}
+.mdi-stocking::before {
+ content: "\F4DA";
+}
+.mdi-stomach::before {
+ content: "\F00BE";
+}
+.mdi-stop::before {
+ content: "\F4DB";
+}
+.mdi-stop-circle::before {
+ content: "\F666";
+}
+.mdi-stop-circle-outline::before {
+ content: "\F667";
+}
+.mdi-store::before {
+ content: "\F4DC";
+}
+.mdi-store-24-hour::before {
+ content: "\F4DD";
+}
+.mdi-store-outline::before {
+ content: "\F038C";
+}
+.mdi-storefront::before {
+ content: "\F00EC";
+}
+.mdi-stove::before {
+ content: "\F4DE";
+}
+.mdi-strategy::before {
+ content: "\F0201";
+}
+.mdi-strava::before {
+ content: "\FB25";
+}
+.mdi-stretch-to-page::before {
+ content: "\FF48";
+}
+.mdi-stretch-to-page-outline::before {
+ content: "\FF49";
+}
+.mdi-string-lights::before {
+ content: "\F02E5";
+}
+.mdi-string-lights-off::before {
+ content: "\F02E6";
+}
+.mdi-subdirectory-arrow-left::before {
+ content: "\F60C";
+}
+.mdi-subdirectory-arrow-right::before {
+ content: "\F60D";
+}
+.mdi-subtitles::before {
+ content: "\FA15";
+}
+.mdi-subtitles-outline::before {
+ content: "\FA16";
+}
+.mdi-subway::before {
+ content: "\F6AB";
+}
+.mdi-subway-alert-variant::before {
+ content: "\FD79";
+}
+.mdi-subway-variant::before {
+ content: "\F4DF";
+}
+.mdi-summit::before {
+ content: "\F785";
+}
+.mdi-sunglasses::before {
+ content: "\F4E0";
+}
+.mdi-surround-sound::before {
+ content: "\F5C5";
+}
+.mdi-surround-sound-2-0::before {
+ content: "\F7EF";
+}
+.mdi-surround-sound-3-1::before {
+ content: "\F7F0";
+}
+.mdi-surround-sound-5-1::before {
+ content: "\F7F1";
+}
+.mdi-surround-sound-7-1::before {
+ content: "\F7F2";
+}
+.mdi-svg::before {
+ content: "\F720";
+}
+.mdi-swap-horizontal::before {
+ content: "\F4E1";
+}
+.mdi-swap-horizontal-bold::before {
+ content: "\FBA9";
+}
+.mdi-swap-horizontal-circle::before {
+ content: "\F0002";
+}
+.mdi-swap-horizontal-circle-outline::before {
+ content: "\F0003";
+}
+.mdi-swap-horizontal-variant::before {
+ content: "\F8C0";
+}
+.mdi-swap-vertical::before {
+ content: "\F4E2";
+}
+.mdi-swap-vertical-bold::before {
+ content: "\FBAA";
+}
+.mdi-swap-vertical-circle::before {
+ content: "\F0004";
+}
+.mdi-swap-vertical-circle-outline::before {
+ content: "\F0005";
+}
+.mdi-swap-vertical-variant::before {
+ content: "\F8C1";
+}
+.mdi-swim::before {
+ content: "\F4E3";
+}
+.mdi-switch::before {
+ content: "\F4E4";
+}
+.mdi-sword::before {
+ content: "\F4E5";
+}
+.mdi-sword-cross::before {
+ content: "\F786";
+}
+.mdi-syllabary-hangul::before {
+ content: "\F035E";
+}
+.mdi-syllabary-hiragana::before {
+ content: "\F035F";
+}
+.mdi-syllabary-katakana::before {
+ content: "\F0360";
+}
+.mdi-syllabary-katakana-half-width::before {
+ content: "\F0361";
+}
+.mdi-symfony::before {
+ content: "\FAE5";
+}
+.mdi-sync::before {
+ content: "\F4E6";
+}
+.mdi-sync-alert::before {
+ content: "\F4E7";
+}
+.mdi-sync-circle::before {
+ content: "\F03A3";
+}
+.mdi-sync-off::before {
+ content: "\F4E8";
+}
+.mdi-tab::before {
+ content: "\F4E9";
+}
+.mdi-tab-minus::before {
+ content: "\FB26";
+}
+.mdi-tab-plus::before {
+ content: "\F75B";
+}
+.mdi-tab-remove::before {
+ content: "\FB27";
+}
+.mdi-tab-unselected::before {
+ content: "\F4EA";
+}
+.mdi-table::before {
+ content: "\F4EB";
+}
+.mdi-table-border::before {
+ content: "\FA17";
+}
+.mdi-table-chair::before {
+ content: "\F0083";
+}
+.mdi-table-column::before {
+ content: "\F834";
+}
+.mdi-table-column-plus-after::before {
+ content: "\F4EC";
+}
+.mdi-table-column-plus-before::before {
+ content: "\F4ED";
+}
+.mdi-table-column-remove::before {
+ content: "\F4EE";
+}
+.mdi-table-column-width::before {
+ content: "\F4EF";
+}
+.mdi-table-edit::before {
+ content: "\F4F0";
+}
+.mdi-table-eye::before {
+ content: "\F00BF";
+}
+.mdi-table-headers-eye::before {
+ content: "\F0248";
+}
+.mdi-table-headers-eye-off::before {
+ content: "\F0249";
+}
+.mdi-table-large::before {
+ content: "\F4F1";
+}
+.mdi-table-large-plus::before {
+ content: "\FFA4";
+}
+.mdi-table-large-remove::before {
+ content: "\FFA5";
+}
+.mdi-table-merge-cells::before {
+ content: "\F9A5";
+}
+.mdi-table-of-contents::before {
+ content: "\F835";
+}
+.mdi-table-plus::before {
+ content: "\FA74";
+}
+.mdi-table-remove::before {
+ content: "\FA75";
+}
+.mdi-table-row::before {
+ content: "\F836";
+}
+.mdi-table-row-height::before {
+ content: "\F4F2";
+}
+.mdi-table-row-plus-after::before {
+ content: "\F4F3";
+}
+.mdi-table-row-plus-before::before {
+ content: "\F4F4";
+}
+.mdi-table-row-remove::before {
+ content: "\F4F5";
+}
+.mdi-table-search::before {
+ content: "\F90E";
+}
+.mdi-table-settings::before {
+ content: "\F837";
+}
+.mdi-table-tennis::before {
+ content: "\FE4B";
+}
+.mdi-tablet::before {
+ content: "\F4F6";
+}
+.mdi-tablet-android::before {
+ content: "\F4F7";
+}
+.mdi-tablet-cellphone::before {
+ content: "\F9A6";
+}
+.mdi-tablet-dashboard::before {
+ content: "\FEEB";
+}
+.mdi-tablet-ipad::before {
+ content: "\F4F8";
+}
+.mdi-taco::before {
+ content: "\F761";
+}
+.mdi-tag::before {
+ content: "\F4F9";
+}
+.mdi-tag-faces::before {
+ content: "\F4FA";
+}
+.mdi-tag-heart::before {
+ content: "\F68A";
+}
+.mdi-tag-heart-outline::before {
+ content: "\FBAB";
+}
+.mdi-tag-minus::before {
+ content: "\F90F";
+}
+.mdi-tag-minus-outline::before {
+ content: "\F024A";
+}
+.mdi-tag-multiple::before {
+ content: "\F4FB";
+}
+.mdi-tag-multiple-outline::before {
+ content: "\F0322";
+}
+.mdi-tag-off::before {
+ content: "\F024B";
+}
+.mdi-tag-off-outline::before {
+ content: "\F024C";
+}
+.mdi-tag-outline::before {
+ content: "\F4FC";
+}
+.mdi-tag-plus::before {
+ content: "\F721";
+}
+.mdi-tag-plus-outline::before {
+ content: "\F024D";
+}
+.mdi-tag-remove::before {
+ content: "\F722";
+}
+.mdi-tag-remove-outline::before {
+ content: "\F024E";
+}
+.mdi-tag-text::before {
+ content: "\F024F";
+}
+.mdi-tag-text-outline::before {
+ content: "\F4FD";
+}
+.mdi-tank::before {
+ content: "\FD16";
+}
+.mdi-tanker-truck::before {
+ content: "\F0006";
+}
+.mdi-tape-measure::before {
+ content: "\FB28";
+}
+.mdi-target::before {
+ content: "\F4FE";
+}
+.mdi-target-account::before {
+ content: "\FBAC";
+}
+.mdi-target-variant::before {
+ content: "\FA76";
+}
+.mdi-taxi::before {
+ content: "\F4FF";
+}
+.mdi-tea::before {
+ content: "\FD7A";
+}
+.mdi-tea-outline::before {
+ content: "\FD7B";
+}
+.mdi-teach::before {
+ content: "\F88F";
+}
+.mdi-teamviewer::before {
+ content: "\F500";
+}
+.mdi-telegram::before {
+ content: "\F501";
+}
+.mdi-telescope::before {
+ content: "\FB29";
+}
+.mdi-television::before {
+ content: "\F502";
+}
+.mdi-television-ambient-light::before {
+ content: "\F0381";
+}
+.mdi-television-box::before {
+ content: "\F838";
+}
+.mdi-television-classic::before {
+ content: "\F7F3";
+}
+.mdi-television-classic-off::before {
+ content: "\F839";
+}
+.mdi-television-clean::before {
+ content: "\F013B";
+}
+.mdi-television-guide::before {
+ content: "\F503";
+}
+.mdi-television-off::before {
+ content: "\F83A";
+}
+.mdi-television-pause::before {
+ content: "\FFA6";
+}
+.mdi-television-play::before {
+ content: "\FEEC";
+}
+.mdi-television-stop::before {
+ content: "\FFA7";
+}
+.mdi-temperature-celsius::before {
+ content: "\F504";
+}
+.mdi-temperature-fahrenheit::before {
+ content: "\F505";
+}
+.mdi-temperature-kelvin::before {
+ content: "\F506";
+}
+.mdi-tennis::before {
+ content: "\FD7C";
+}
+.mdi-tennis-ball::before {
+ content: "\F507";
+}
+.mdi-tent::before {
+ content: "\F508";
+}
+.mdi-terraform::before {
+ content: "\F0084";
+}
+.mdi-terrain::before {
+ content: "\F509";
+}
+.mdi-test-tube::before {
+ content: "\F668";
+}
+.mdi-test-tube-empty::before {
+ content: "\F910";
+}
+.mdi-test-tube-off::before {
+ content: "\F911";
+}
+.mdi-text::before {
+ content: "\F9A7";
+}
+.mdi-text-recognition::before {
+ content: "\F0168";
+}
+.mdi-text-shadow::before {
+ content: "\F669";
+}
+.mdi-text-short::before {
+ content: "\F9A8";
+}
+.mdi-text-subject::before {
+ content: "\F9A9";
+}
+.mdi-text-to-speech::before {
+ content: "\F50A";
+}
+.mdi-text-to-speech-off::before {
+ content: "\F50B";
+}
+.mdi-textarea::before {
+ content: "\F00C0";
+}
+.mdi-textbox::before {
+ content: "\F60E";
+}
+.mdi-textbox-lock::before {
+ content: "\F0388";
+}
+.mdi-textbox-password::before {
+ content: "\F7F4";
+}
+.mdi-texture::before {
+ content: "\F50C";
+}
+.mdi-texture-box::before {
+ content: "\F0007";
+}
+.mdi-theater::before {
+ content: "\F50D";
+}
+.mdi-theme-light-dark::before {
+ content: "\F50E";
+}
+.mdi-thermometer::before {
+ content: "\F50F";
+}
+.mdi-thermometer-alert::before {
+ content: "\FE61";
+}
+.mdi-thermometer-chevron-down::before {
+ content: "\FE62";
+}
+.mdi-thermometer-chevron-up::before {
+ content: "\FE63";
+}
+.mdi-thermometer-high::before {
+ content: "\F00ED";
+}
+.mdi-thermometer-lines::before {
+ content: "\F510";
+}
+.mdi-thermometer-low::before {
+ content: "\F00EE";
+}
+.mdi-thermometer-minus::before {
+ content: "\FE64";
+}
+.mdi-thermometer-plus::before {
+ content: "\FE65";
+}
+.mdi-thermostat::before {
+ content: "\F393";
+}
+.mdi-thermostat-box::before {
+ content: "\F890";
+}
+.mdi-thought-bubble::before {
+ content: "\F7F5";
+}
+.mdi-thought-bubble-outline::before {
+ content: "\F7F6";
+}
+.mdi-thumb-down::before {
+ content: "\F511";
+}
+.mdi-thumb-down-outline::before {
+ content: "\F512";
+}
+.mdi-thumb-up::before {
+ content: "\F513";
+}
+.mdi-thumb-up-outline::before {
+ content: "\F514";
+}
+.mdi-thumbs-up-down::before {
+ content: "\F515";
+}
+.mdi-ticket::before {
+ content: "\F516";
+}
+.mdi-ticket-account::before {
+ content: "\F517";
+}
+.mdi-ticket-confirmation::before {
+ content: "\F518";
+}
+.mdi-ticket-outline::before {
+ content: "\F912";
+}
+.mdi-ticket-percent::before {
+ content: "\F723";
+}
+.mdi-tie::before {
+ content: "\F519";
+}
+.mdi-tilde::before {
+ content: "\F724";
+}
+.mdi-timelapse::before {
+ content: "\F51A";
+}
+.mdi-timeline::before {
+ content: "\FBAD";
+}
+.mdi-timeline-alert::before {
+ content: "\FFB2";
+}
+.mdi-timeline-alert-outline::before {
+ content: "\FFB5";
+}
+.mdi-timeline-clock::before {
+ content: "\F0226";
+}
+.mdi-timeline-clock-outline::before {
+ content: "\F0227";
+}
+.mdi-timeline-help::before {
+ content: "\FFB6";
+}
+.mdi-timeline-help-outline::before {
+ content: "\FFB7";
+}
+.mdi-timeline-outline::before {
+ content: "\FBAE";
+}
+.mdi-timeline-plus::before {
+ content: "\FFB3";
+}
+.mdi-timeline-plus-outline::before {
+ content: "\FFB4";
+}
+.mdi-timeline-text::before {
+ content: "\FBAF";
+}
+.mdi-timeline-text-outline::before {
+ content: "\FBB0";
+}
+.mdi-timer::before {
+ content: "\F51B";
+}
+.mdi-timer-10::before {
+ content: "\F51C";
+}
+.mdi-timer-3::before {
+ content: "\F51D";
+}
+.mdi-timer-off::before {
+ content: "\F51E";
+}
+.mdi-timer-sand::before {
+ content: "\F51F";
+}
+.mdi-timer-sand-empty::before {
+ content: "\F6AC";
+}
+.mdi-timer-sand-full::before {
+ content: "\F78B";
+}
+.mdi-timetable::before {
+ content: "\F520";
+}
+.mdi-toaster::before {
+ content: "\F0085";
+}
+.mdi-toaster-off::before {
+ content: "\F01E2";
+}
+.mdi-toaster-oven::before {
+ content: "\FCAF";
+}
+.mdi-toggle-switch::before {
+ content: "\F521";
+}
+.mdi-toggle-switch-off::before {
+ content: "\F522";
+}
+.mdi-toggle-switch-off-outline::before {
+ content: "\FA18";
+}
+.mdi-toggle-switch-outline::before {
+ content: "\FA19";
+}
+.mdi-toilet::before {
+ content: "\F9AA";
+}
+.mdi-toolbox::before {
+ content: "\F9AB";
+}
+.mdi-toolbox-outline::before {
+ content: "\F9AC";
+}
+.mdi-tools::before {
+ content: "\F0086";
+}
+.mdi-tooltip::before {
+ content: "\F523";
+}
+.mdi-tooltip-account::before {
+ content: "\F00C";
+}
+.mdi-tooltip-edit::before {
+ content: "\F524";
+}
+.mdi-tooltip-edit-outline::before {
+ content: "\F02F0";
+}
+.mdi-tooltip-image::before {
+ content: "\F525";
+}
+.mdi-tooltip-image-outline::before {
+ content: "\FBB1";
+}
+.mdi-tooltip-outline::before {
+ content: "\F526";
+}
+.mdi-tooltip-plus::before {
+ content: "\FBB2";
+}
+.mdi-tooltip-plus-outline::before {
+ content: "\F527";
+}
+.mdi-tooltip-text::before {
+ content: "\F528";
+}
+.mdi-tooltip-text-outline::before {
+ content: "\FBB3";
+}
+.mdi-tooth::before {
+ content: "\F8C2";
+}
+.mdi-tooth-outline::before {
+ content: "\F529";
+}
+.mdi-toothbrush::before {
+ content: "\F0154";
+}
+.mdi-toothbrush-electric::before {
+ content: "\F0157";
+}
+.mdi-toothbrush-paste::before {
+ content: "\F0155";
+}
+.mdi-tor::before {
+ content: "\F52A";
+}
+.mdi-tortoise::before {
+ content: "\FD17";
+}
+.mdi-toslink::before {
+ content: "\F02E3";
+}
+.mdi-tournament::before {
+ content: "\F9AD";
+}
+.mdi-tower-beach::before {
+ content: "\F680";
+}
+.mdi-tower-fire::before {
+ content: "\F681";
+}
+.mdi-towing::before {
+ content: "\F83B";
+}
+.mdi-toy-brick::before {
+ content: "\F02B3";
+}
+.mdi-toy-brick-marker::before {
+ content: "\F02B4";
+}
+.mdi-toy-brick-marker-outline::before {
+ content: "\F02B5";
+}
+.mdi-toy-brick-minus::before {
+ content: "\F02B6";
+}
+.mdi-toy-brick-minus-outline::before {
+ content: "\F02B7";
+}
+.mdi-toy-brick-outline::before {
+ content: "\F02B8";
+}
+.mdi-toy-brick-plus::before {
+ content: "\F02B9";
+}
+.mdi-toy-brick-plus-outline::before {
+ content: "\F02BA";
+}
+.mdi-toy-brick-remove::before {
+ content: "\F02BB";
+}
+.mdi-toy-brick-remove-outline::before {
+ content: "\F02BC";
+}
+.mdi-toy-brick-search::before {
+ content: "\F02BD";
+}
+.mdi-toy-brick-search-outline::before {
+ content: "\F02BE";
+}
+.mdi-track-light::before {
+ content: "\F913";
+}
+.mdi-trackpad::before {
+ content: "\F7F7";
+}
+.mdi-trackpad-lock::before {
+ content: "\F932";
+}
+.mdi-tractor::before {
+ content: "\F891";
+}
+.mdi-trademark::before {
+ content: "\FA77";
+}
+.mdi-traffic-cone::before {
+ content: "\F03A7";
+}
+.mdi-traffic-light::before {
+ content: "\F52B";
+}
+.mdi-train::before {
+ content: "\F52C";
+}
+.mdi-train-car::before {
+ content: "\FBB4";
+}
+.mdi-train-variant::before {
+ content: "\F8C3";
+}
+.mdi-tram::before {
+ content: "\F52D";
+}
+.mdi-tram-side::before {
+ content: "\F0008";
+}
+.mdi-transcribe::before {
+ content: "\F52E";
+}
+.mdi-transcribe-close::before {
+ content: "\F52F";
+}
+.mdi-transfer::before {
+ content: "\F0087";
+}
+.mdi-transfer-down::before {
+ content: "\FD7D";
+}
+.mdi-transfer-left::before {
+ content: "\FD7E";
+}
+.mdi-transfer-right::before {
+ content: "\F530";
+}
+.mdi-transfer-up::before {
+ content: "\FD7F";
+}
+.mdi-transit-connection::before {
+ content: "\FD18";
+}
+.mdi-transit-connection-variant::before {
+ content: "\FD19";
+}
+.mdi-transit-detour::before {
+ content: "\FFA8";
+}
+.mdi-transit-transfer::before {
+ content: "\F6AD";
+}
+.mdi-transition::before {
+ content: "\F914";
+}
+.mdi-transition-masked::before {
+ content: "\F915";
+}
+.mdi-translate::before {
+ content: "\F5CA";
+}
+.mdi-translate-off::before {
+ content: "\FE66";
+}
+.mdi-transmission-tower::before {
+ content: "\FD1A";
+}
+.mdi-trash-can::before {
+ content: "\FA78";
+}
+.mdi-trash-can-outline::before {
+ content: "\FA79";
+}
+.mdi-tray::before {
+ content: "\F02BF";
+}
+.mdi-tray-alert::before {
+ content: "\F02C0";
+}
+.mdi-tray-full::before {
+ content: "\F02C1";
+}
+.mdi-tray-minus::before {
+ content: "\F02C2";
+}
+.mdi-tray-plus::before {
+ content: "\F02C3";
+}
+.mdi-tray-remove::before {
+ content: "\F02C4";
+}
+.mdi-treasure-chest::before {
+ content: "\F725";
+}
+.mdi-tree::before {
+ content: "\F531";
+}
+.mdi-tree-outline::before {
+ content: "\FE4C";
+}
+.mdi-trello::before {
+ content: "\F532";
+}
+.mdi-trending-down::before {
+ content: "\F533";
+}
+.mdi-trending-neutral::before {
+ content: "\F534";
+}
+.mdi-trending-up::before {
+ content: "\F535";
+}
+.mdi-triangle::before {
+ content: "\F536";
+}
+.mdi-triangle-outline::before {
+ content: "\F537";
+}
+.mdi-triforce::before {
+ content: "\FBB5";
+}
+.mdi-trophy::before {
+ content: "\F538";
+}
+.mdi-trophy-award::before {
+ content: "\F539";
+}
+.mdi-trophy-broken::before {
+ content: "\FD80";
+}
+.mdi-trophy-outline::before {
+ content: "\F53A";
+}
+.mdi-trophy-variant::before {
+ content: "\F53B";
+}
+.mdi-trophy-variant-outline::before {
+ content: "\F53C";
+}
+.mdi-truck::before {
+ content: "\F53D";
+}
+.mdi-truck-check::before {
+ content: "\FCB0";
+}
+.mdi-truck-check-outline::before {
+ content: "\F02C5";
+}
+.mdi-truck-delivery::before {
+ content: "\F53E";
+}
+.mdi-truck-delivery-outline::before {
+ content: "\F02C6";
+}
+.mdi-truck-fast::before {
+ content: "\F787";
+}
+.mdi-truck-fast-outline::before {
+ content: "\F02C7";
+}
+.mdi-truck-outline::before {
+ content: "\F02C8";
+}
+.mdi-truck-trailer::before {
+ content: "\F726";
+}
+.mdi-trumpet::before {
+ content: "\F00C1";
+}
+.mdi-tshirt-crew::before {
+ content: "\FA7A";
+}
+.mdi-tshirt-crew-outline::before {
+ content: "\F53F";
+}
+.mdi-tshirt-v::before {
+ content: "\FA7B";
+}
+.mdi-tshirt-v-outline::before {
+ content: "\F540";
+}
+.mdi-tumble-dryer::before {
+ content: "\F916";
+}
+.mdi-tumble-dryer-alert::before {
+ content: "\F01E5";
+}
+.mdi-tumble-dryer-off::before {
+ content: "\F01E6";
+}
+.mdi-tumblr::before {
+ content: "\F541";
+}
+.mdi-tumblr-box::before {
+ content: "\F917";
+}
+.mdi-tumblr-reblog::before {
+ content: "\F542";
+}
+.mdi-tune::before {
+ content: "\F62E";
+}
+.mdi-tune-vertical::before {
+ content: "\F66A";
+}
+.mdi-turnstile::before {
+ content: "\FCB1";
+}
+.mdi-turnstile-outline::before {
+ content: "\FCB2";
+}
+.mdi-turtle::before {
+ content: "\FCB3";
+}
+.mdi-twitch::before {
+ content: "\F543";
+}
+.mdi-twitter::before {
+ content: "\F544";
+}
+.mdi-twitter-box::before {
+ content: "\F545";
+}
+.mdi-twitter-circle::before {
+ content: "\F546";
+}
+.mdi-twitter-retweet::before {
+ content: "\F547";
+}
+.mdi-two-factor-authentication::before {
+ content: "\F9AE";
+}
+.mdi-typewriter::before {
+ content: "\FF4A";
+}
+.mdi-uber::before {
+ content: "\F748";
+}
+.mdi-ubisoft::before {
+ content: "\FBB6";
+}
+.mdi-ubuntu::before {
+ content: "\F548";
+}
+.mdi-ufo::before {
+ content: "\F00EF";
+}
+.mdi-ufo-outline::before {
+ content: "\F00F0";
+}
+.mdi-ultra-high-definition::before {
+ content: "\F7F8";
+}
+.mdi-umbraco::before {
+ content: "\F549";
+}
+.mdi-umbrella::before {
+ content: "\F54A";
+}
+.mdi-umbrella-closed::before {
+ content: "\F9AF";
+}
+.mdi-umbrella-outline::before {
+ content: "\F54B";
+}
+.mdi-undo::before {
+ content: "\F54C";
+}
+.mdi-undo-variant::before {
+ content: "\F54D";
+}
+.mdi-unfold-less-horizontal::before {
+ content: "\F54E";
+}
+.mdi-unfold-less-vertical::before {
+ content: "\F75F";
+}
+.mdi-unfold-more-horizontal::before {
+ content: "\F54F";
+}
+.mdi-unfold-more-vertical::before {
+ content: "\F760";
+}
+.mdi-ungroup::before {
+ content: "\F550";
+}
+.mdi-unicode::before {
+ content: "\FEED";
+}
+.mdi-unity::before {
+ content: "\F6AE";
+}
+.mdi-unreal::before {
+ content: "\F9B0";
+}
+.mdi-untappd::before {
+ content: "\F551";
+}
+.mdi-update::before {
+ content: "\F6AF";
+}
+.mdi-upload::before {
+ content: "\F552";
+}
+.mdi-upload-lock::before {
+ content: "\F039E";
+}
+.mdi-upload-lock-outline::before {
+ content: "\F039F";
+}
+.mdi-upload-multiple::before {
+ content: "\F83C";
+}
+.mdi-upload-network::before {
+ content: "\F6F5";
+}
+.mdi-upload-network-outline::before {
+ content: "\FCB4";
+}
+.mdi-upload-off::before {
+ content: "\F00F1";
+}
+.mdi-upload-off-outline::before {
+ content: "\F00F2";
+}
+.mdi-upload-outline::before {
+ content: "\FE67";
+}
+.mdi-usb::before {
+ content: "\F553";
+}
+.mdi-usb-flash-drive::before {
+ content: "\F02C9";
+}
+.mdi-usb-flash-drive-outline::before {
+ content: "\F02CA";
+}
+.mdi-usb-port::before {
+ content: "\F021B";
+}
+.mdi-valve::before {
+ content: "\F0088";
+}
+.mdi-valve-closed::before {
+ content: "\F0089";
+}
+.mdi-valve-open::before {
+ content: "\F008A";
+}
+.mdi-van-passenger::before {
+ content: "\F7F9";
+}
+.mdi-van-utility::before {
+ content: "\F7FA";
+}
+.mdi-vanish::before {
+ content: "\F7FB";
+}
+.mdi-vanity-light::before {
+ content: "\F020C";
+}
+.mdi-variable::before {
+ content: "\FAE6";
+}
+.mdi-variable-box::before {
+ content: "\F013C";
+}
+.mdi-vector-arrange-above::before {
+ content: "\F554";
+}
+.mdi-vector-arrange-below::before {
+ content: "\F555";
+}
+.mdi-vector-bezier::before {
+ content: "\FAE7";
+}
+.mdi-vector-circle::before {
+ content: "\F556";
+}
+.mdi-vector-circle-variant::before {
+ content: "\F557";
+}
+.mdi-vector-combine::before {
+ content: "\F558";
+}
+.mdi-vector-curve::before {
+ content: "\F559";
+}
+.mdi-vector-difference::before {
+ content: "\F55A";
+}
+.mdi-vector-difference-ab::before {
+ content: "\F55B";
+}
+.mdi-vector-difference-ba::before {
+ content: "\F55C";
+}
+.mdi-vector-ellipse::before {
+ content: "\F892";
+}
+.mdi-vector-intersection::before {
+ content: "\F55D";
+}
+.mdi-vector-line::before {
+ content: "\F55E";
+}
+.mdi-vector-link::before {
+ content: "\F0009";
+}
+.mdi-vector-point::before {
+ content: "\F55F";
+}
+.mdi-vector-polygon::before {
+ content: "\F560";
+}
+.mdi-vector-polyline::before {
+ content: "\F561";
+}
+.mdi-vector-polyline-edit::before {
+ content: "\F0250";
+}
+.mdi-vector-polyline-minus::before {
+ content: "\F0251";
+}
+.mdi-vector-polyline-plus::before {
+ content: "\F0252";
+}
+.mdi-vector-polyline-remove::before {
+ content: "\F0253";
+}
+.mdi-vector-radius::before {
+ content: "\F749";
+}
+.mdi-vector-rectangle::before {
+ content: "\F5C6";
+}
+.mdi-vector-selection::before {
+ content: "\F562";
+}
+.mdi-vector-square::before {
+ content: "\F001";
+}
+.mdi-vector-triangle::before {
+ content: "\F563";
+}
+.mdi-vector-union::before {
+ content: "\F564";
+}
+.mdi-venmo::before {
+ content: "\F578";
+}
+.mdi-vhs::before {
+ content: "\FA1A";
+}
+.mdi-vibrate::before {
+ content: "\F566";
+}
+.mdi-vibrate-off::before {
+ content: "\FCB5";
+}
+.mdi-video::before {
+ content: "\F567";
+}
+.mdi-video-3d::before {
+ content: "\F7FC";
+}
+.mdi-video-3d-variant::before {
+ content: "\FEEE";
+}
+.mdi-video-4k-box::before {
+ content: "\F83D";
+}
+.mdi-video-account::before {
+ content: "\F918";
+}
+.mdi-video-check::before {
+ content: "\F008B";
+}
+.mdi-video-check-outline::before {
+ content: "\F008C";
+}
+.mdi-video-image::before {
+ content: "\F919";
+}
+.mdi-video-input-antenna::before {
+ content: "\F83E";
+}
+.mdi-video-input-component::before {
+ content: "\F83F";
+}
+.mdi-video-input-hdmi::before {
+ content: "\F840";
+}
+.mdi-video-input-scart::before {
+ content: "\FFA9";
+}
+.mdi-video-input-svideo::before {
+ content: "\F841";
+}
+.mdi-video-minus::before {
+ content: "\F9B1";
+}
+.mdi-video-off::before {
+ content: "\F568";
+}
+.mdi-video-off-outline::before {
+ content: "\FBB7";
+}
+.mdi-video-outline::before {
+ content: "\FBB8";
+}
+.mdi-video-plus::before {
+ content: "\F9B2";
+}
+.mdi-video-stabilization::before {
+ content: "\F91A";
+}
+.mdi-video-switch::before {
+ content: "\F569";
+}
+.mdi-video-vintage::before {
+ content: "\FA1B";
+}
+.mdi-video-wireless::before {
+ content: "\FEEF";
+}
+.mdi-video-wireless-outline::before {
+ content: "\FEF0";
+}
+.mdi-view-agenda::before {
+ content: "\F56A";
+}
+.mdi-view-agenda-outline::before {
+ content: "\F0203";
+}
+.mdi-view-array::before {
+ content: "\F56B";
+}
+.mdi-view-carousel::before {
+ content: "\F56C";
+}
+.mdi-view-column::before {
+ content: "\F56D";
+}
+.mdi-view-comfy::before {
+ content: "\FE4D";
+}
+.mdi-view-compact::before {
+ content: "\FE4E";
+}
+.mdi-view-compact-outline::before {
+ content: "\FE4F";
+}
+.mdi-view-dashboard::before {
+ content: "\F56E";
+}
+.mdi-view-dashboard-outline::before {
+ content: "\FA1C";
+}
+.mdi-view-dashboard-variant::before {
+ content: "\F842";
+}
+.mdi-view-day::before {
+ content: "\F56F";
+}
+.mdi-view-grid::before {
+ content: "\F570";
+}
+.mdi-view-grid-outline::before {
+ content: "\F0204";
+}
+.mdi-view-grid-plus::before {
+ content: "\FFAA";
+}
+.mdi-view-grid-plus-outline::before {
+ content: "\F0205";
+}
+.mdi-view-headline::before {
+ content: "\F571";
+}
+.mdi-view-list::before {
+ content: "\F572";
+}
+.mdi-view-module::before {
+ content: "\F573";
+}
+.mdi-view-parallel::before {
+ content: "\F727";
+}
+.mdi-view-quilt::before {
+ content: "\F574";
+}
+.mdi-view-sequential::before {
+ content: "\F728";
+}
+.mdi-view-split-horizontal::before {
+ content: "\FBA7";
+}
+.mdi-view-split-vertical::before {
+ content: "\FBA8";
+}
+.mdi-view-stream::before {
+ content: "\F575";
+}
+.mdi-view-week::before {
+ content: "\F576";
+}
+.mdi-vimeo::before {
+ content: "\F577";
+}
+.mdi-violin::before {
+ content: "\F60F";
+}
+.mdi-virtual-reality::before {
+ content: "\F893";
+}
+.mdi-visual-studio::before {
+ content: "\F610";
+}
+.mdi-visual-studio-code::before {
+ content: "\FA1D";
+}
+.mdi-vk::before {
+ content: "\F579";
+}
+.mdi-vk-box::before {
+ content: "\F57A";
+}
+.mdi-vk-circle::before {
+ content: "\F57B";
+}
+.mdi-vlc::before {
+ content: "\F57C";
+}
+.mdi-voice::before {
+ content: "\F5CB";
+}
+.mdi-voice-off::before {
+ content: "\FEF1";
+}
+.mdi-voicemail::before {
+ content: "\F57D";
+}
+.mdi-volleyball::before {
+ content: "\F9B3";
+}
+.mdi-volume-high::before {
+ content: "\F57E";
+}
+.mdi-volume-low::before {
+ content: "\F57F";
+}
+.mdi-volume-medium::before {
+ content: "\F580";
+}
+.mdi-volume-minus::before {
+ content: "\F75D";
+}
+.mdi-volume-mute::before {
+ content: "\F75E";
+}
+.mdi-volume-off::before {
+ content: "\F581";
+}
+.mdi-volume-plus::before {
+ content: "\F75C";
+}
+.mdi-volume-source::before {
+ content: "\F014B";
+}
+.mdi-volume-variant-off::before {
+ content: "\FE68";
+}
+.mdi-volume-vibrate::before {
+ content: "\F014C";
+}
+.mdi-vote::before {
+ content: "\FA1E";
+}
+.mdi-vote-outline::before {
+ content: "\FA1F";
+}
+.mdi-vpn::before {
+ content: "\F582";
+}
+.mdi-vuejs::before {
+ content: "\F843";
+}
+.mdi-vuetify::before {
+ content: "\FE50";
+}
+.mdi-walk::before {
+ content: "\F583";
+}
+.mdi-wall::before {
+ content: "\F7FD";
+}
+.mdi-wall-sconce::before {
+ content: "\F91B";
+}
+.mdi-wall-sconce-flat::before {
+ content: "\F91C";
+}
+.mdi-wall-sconce-variant::before {
+ content: "\F91D";
+}
+.mdi-wallet::before {
+ content: "\F584";
+}
+.mdi-wallet-giftcard::before {
+ content: "\F585";
+}
+.mdi-wallet-membership::before {
+ content: "\F586";
+}
+.mdi-wallet-outline::before {
+ content: "\FBB9";
+}
+.mdi-wallet-plus::before {
+ content: "\FFAB";
+}
+.mdi-wallet-plus-outline::before {
+ content: "\FFAC";
+}
+.mdi-wallet-travel::before {
+ content: "\F587";
+}
+.mdi-wallpaper::before {
+ content: "\FE69";
+}
+.mdi-wan::before {
+ content: "\F588";
+}
+.mdi-wardrobe::before {
+ content: "\FFAD";
+}
+.mdi-wardrobe-outline::before {
+ content: "\FFAE";
+}
+.mdi-warehouse::before {
+ content: "\FFBB";
+}
+.mdi-washing-machine::before {
+ content: "\F729";
+}
+.mdi-washing-machine-alert::before {
+ content: "\F01E7";
+}
+.mdi-washing-machine-off::before {
+ content: "\F01E8";
+}
+.mdi-watch::before {
+ content: "\F589";
+}
+.mdi-watch-export::before {
+ content: "\F58A";
+}
+.mdi-watch-export-variant::before {
+ content: "\F894";
+}
+.mdi-watch-import::before {
+ content: "\F58B";
+}
+.mdi-watch-import-variant::before {
+ content: "\F895";
+}
+.mdi-watch-variant::before {
+ content: "\F896";
+}
+.mdi-watch-vibrate::before {
+ content: "\F6B0";
+}
+.mdi-watch-vibrate-off::before {
+ content: "\FCB6";
+}
+.mdi-water::before {
+ content: "\F58C";
+}
+.mdi-water-boiler::before {
+ content: "\FFAF";
+}
+.mdi-water-boiler-alert::before {
+ content: "\F01DE";
+}
+.mdi-water-boiler-off::before {
+ content: "\F01DF";
+}
+.mdi-water-off::before {
+ content: "\F58D";
+}
+.mdi-water-outline::before {
+ content: "\FE6A";
+}
+.mdi-water-percent::before {
+ content: "\F58E";
+}
+.mdi-water-polo::before {
+ content: "\F02CB";
+}
+.mdi-water-pump::before {
+ content: "\F58F";
+}
+.mdi-water-pump-off::before {
+ content: "\FFB0";
+}
+.mdi-water-well::before {
+ content: "\F008D";
+}
+.mdi-water-well-outline::before {
+ content: "\F008E";
+}
+.mdi-watermark::before {
+ content: "\F612";
+}
+.mdi-wave::before {
+ content: "\FF4B";
+}
+.mdi-waves::before {
+ content: "\F78C";
+}
+.mdi-waze::before {
+ content: "\FBBA";
+}
+.mdi-weather-cloudy::before {
+ content: "\F590";
+}
+.mdi-weather-cloudy-alert::before {
+ content: "\FF4C";
+}
+.mdi-weather-cloudy-arrow-right::before {
+ content: "\FE51";
+}
+.mdi-weather-fog::before {
+ content: "\F591";
+}
+.mdi-weather-hail::before {
+ content: "\F592";
+}
+.mdi-weather-hazy::before {
+ content: "\FF4D";
+}
+.mdi-weather-hurricane::before {
+ content: "\F897";
+}
+.mdi-weather-lightning::before {
+ content: "\F593";
+}
+.mdi-weather-lightning-rainy::before {
+ content: "\F67D";
+}
+.mdi-weather-night::before {
+ content: "\F594";
+}
+.mdi-weather-night-partly-cloudy::before {
+ content: "\FF4E";
+}
+.mdi-weather-partly-cloudy::before {
+ content: "\F595";
+}
+.mdi-weather-partly-lightning::before {
+ content: "\FF4F";
+}
+.mdi-weather-partly-rainy::before {
+ content: "\FF50";
+}
+.mdi-weather-partly-snowy::before {
+ content: "\FF51";
+}
+.mdi-weather-partly-snowy-rainy::before {
+ content: "\FF52";
+}
+.mdi-weather-pouring::before {
+ content: "\F596";
+}
+.mdi-weather-rainy::before {
+ content: "\F597";
+}
+.mdi-weather-snowy::before {
+ content: "\F598";
+}
+.mdi-weather-snowy-heavy::before {
+ content: "\FF53";
+}
+.mdi-weather-snowy-rainy::before {
+ content: "\F67E";
+}
+.mdi-weather-sunny::before {
+ content: "\F599";
+}
+.mdi-weather-sunny-alert::before {
+ content: "\FF54";
+}
+.mdi-weather-sunset::before {
+ content: "\F59A";
+}
+.mdi-weather-sunset-down::before {
+ content: "\F59B";
+}
+.mdi-weather-sunset-up::before {
+ content: "\F59C";
+}
+.mdi-weather-tornado::before {
+ content: "\FF55";
+}
+.mdi-weather-windy::before {
+ content: "\F59D";
+}
+.mdi-weather-windy-variant::before {
+ content: "\F59E";
+}
+.mdi-web::before {
+ content: "\F59F";
+}
+.mdi-web-box::before {
+ content: "\FFB1";
+}
+.mdi-web-clock::before {
+ content: "\F0275";
+}
+.mdi-webcam::before {
+ content: "\F5A0";
+}
+.mdi-webhook::before {
+ content: "\F62F";
+}
+.mdi-webpack::before {
+ content: "\F72A";
+}
+.mdi-webrtc::before {
+ content: "\F0273";
+}
+.mdi-wechat::before {
+ content: "\F611";
+}
+.mdi-weight::before {
+ content: "\F5A1";
+}
+.mdi-weight-gram::before {
+ content: "\FD1B";
+}
+.mdi-weight-kilogram::before {
+ content: "\F5A2";
+}
+.mdi-weight-lifter::before {
+ content: "\F0188";
+}
+.mdi-weight-pound::before {
+ content: "\F9B4";
+}
+.mdi-whatsapp::before {
+ content: "\F5A3";
+}
+.mdi-wheelchair-accessibility::before {
+ content: "\F5A4";
+}
+.mdi-whistle::before {
+ content: "\F9B5";
+}
+.mdi-whistle-outline::before {
+ content: "\F02E7";
+}
+.mdi-white-balance-auto::before {
+ content: "\F5A5";
+}
+.mdi-white-balance-incandescent::before {
+ content: "\F5A6";
+}
+.mdi-white-balance-iridescent::before {
+ content: "\F5A7";
+}
+.mdi-white-balance-sunny::before {
+ content: "\F5A8";
+}
+.mdi-widgets::before {
+ content: "\F72B";
+}
+.mdi-widgets-outline::before {
+ content: "\F0380";
+}
+.mdi-wifi::before {
+ content: "\F5A9";
+}
+.mdi-wifi-off::before {
+ content: "\F5AA";
+}
+.mdi-wifi-star::before {
+ content: "\FE6B";
+}
+.mdi-wifi-strength-1::before {
+ content: "\F91E";
+}
+.mdi-wifi-strength-1-alert::before {
+ content: "\F91F";
+}
+.mdi-wifi-strength-1-lock::before {
+ content: "\F920";
+}
+.mdi-wifi-strength-2::before {
+ content: "\F921";
+}
+.mdi-wifi-strength-2-alert::before {
+ content: "\F922";
+}
+.mdi-wifi-strength-2-lock::before {
+ content: "\F923";
+}
+.mdi-wifi-strength-3::before {
+ content: "\F924";
+}
+.mdi-wifi-strength-3-alert::before {
+ content: "\F925";
+}
+.mdi-wifi-strength-3-lock::before {
+ content: "\F926";
+}
+.mdi-wifi-strength-4::before {
+ content: "\F927";
+}
+.mdi-wifi-strength-4-alert::before {
+ content: "\F928";
+}
+.mdi-wifi-strength-4-lock::before {
+ content: "\F929";
+}
+.mdi-wifi-strength-alert-outline::before {
+ content: "\F92A";
+}
+.mdi-wifi-strength-lock-outline::before {
+ content: "\F92B";
+}
+.mdi-wifi-strength-off::before {
+ content: "\F92C";
+}
+.mdi-wifi-strength-off-outline::before {
+ content: "\F92D";
+}
+.mdi-wifi-strength-outline::before {
+ content: "\F92E";
+}
+.mdi-wii::before {
+ content: "\F5AB";
+}
+.mdi-wiiu::before {
+ content: "\F72C";
+}
+.mdi-wikipedia::before {
+ content: "\F5AC";
+}
+.mdi-wind-turbine::before {
+ content: "\FD81";
+}
+.mdi-window-close::before {
+ content: "\F5AD";
+}
+.mdi-window-closed::before {
+ content: "\F5AE";
+}
+.mdi-window-closed-variant::before {
+ content: "\F0206";
+}
+.mdi-window-maximize::before {
+ content: "\F5AF";
+}
+.mdi-window-minimize::before {
+ content: "\F5B0";
+}
+.mdi-window-open::before {
+ content: "\F5B1";
+}
+.mdi-window-open-variant::before {
+ content: "\F0207";
+}
+.mdi-window-restore::before {
+ content: "\F5B2";
+}
+.mdi-window-shutter::before {
+ content: "\F0147";
+}
+.mdi-window-shutter-alert::before {
+ content: "\F0148";
+}
+.mdi-window-shutter-open::before {
+ content: "\F0149";
+}
+.mdi-windows::before {
+ content: "\F5B3";
+}
+.mdi-windows-classic::before {
+ content: "\FA20";
+}
+.mdi-wiper::before {
+ content: "\FAE8";
+}
+.mdi-wiper-wash::before {
+ content: "\FD82";
+}
+.mdi-wordpress::before {
+ content: "\F5B4";
+}
+.mdi-worker::before {
+ content: "\F5B5";
+}
+.mdi-wrap::before {
+ content: "\F5B6";
+}
+.mdi-wrap-disabled::before {
+ content: "\FBBB";
+}
+.mdi-wrench::before {
+ content: "\F5B7";
+}
+.mdi-wrench-outline::before {
+ content: "\FBBC";
+}
+.mdi-wunderlist::before {
+ content: "\F5B8";
+}
+.mdi-xamarin::before {
+ content: "\F844";
+}
+.mdi-xamarin-outline::before {
+ content: "\F845";
+}
+.mdi-xaml::before {
+ content: "\F673";
+}
+.mdi-xbox::before {
+ content: "\F5B9";
+}
+.mdi-xbox-controller::before {
+ content: "\F5BA";
+}
+.mdi-xbox-controller-battery-alert::before {
+ content: "\F74A";
+}
+.mdi-xbox-controller-battery-charging::before {
+ content: "\FA21";
+}
+.mdi-xbox-controller-battery-empty::before {
+ content: "\F74B";
+}
+.mdi-xbox-controller-battery-full::before {
+ content: "\F74C";
+}
+.mdi-xbox-controller-battery-low::before {
+ content: "\F74D";
+}
+.mdi-xbox-controller-battery-medium::before {
+ content: "\F74E";
+}
+.mdi-xbox-controller-battery-unknown::before {
+ content: "\F74F";
+}
+.mdi-xbox-controller-menu::before {
+ content: "\FE52";
+}
+.mdi-xbox-controller-off::before {
+ content: "\F5BB";
+}
+.mdi-xbox-controller-view::before {
+ content: "\FE53";
+}
+.mdi-xda::before {
+ content: "\F5BC";
+}
+.mdi-xing::before {
+ content: "\F5BD";
+}
+.mdi-xing-box::before {
+ content: "\F5BE";
+}
+.mdi-xing-circle::before {
+ content: "\F5BF";
+}
+.mdi-xml::before {
+ content: "\F5C0";
+}
+.mdi-xmpp::before {
+ content: "\F7FE";
+}
+.mdi-yahoo::before {
+ content: "\FB2A";
+}
+.mdi-yammer::before {
+ content: "\F788";
+}
+.mdi-yeast::before {
+ content: "\F5C1";
+}
+.mdi-yelp::before {
+ content: "\F5C2";
+}
+.mdi-yin-yang::before {
+ content: "\F67F";
+}
+.mdi-yoga::before {
+ content: "\F01A7";
+}
+.mdi-youtube::before {
+ content: "\F5C3";
+}
+.mdi-youtube-creator-studio::before {
+ content: "\F846";
+}
+.mdi-youtube-gaming::before {
+ content: "\F847";
+}
+.mdi-youtube-subscription::before {
+ content: "\FD1C";
+}
+.mdi-youtube-tv::before {
+ content: "\F448";
+}
+.mdi-z-wave::before {
+ content: "\FAE9";
+}
+.mdi-zend::before {
+ content: "\FAEA";
+}
+.mdi-zigbee::before {
+ content: "\FD1D";
+}
+.mdi-zip-box::before {
+ content: "\F5C4";
+}
+.mdi-zip-box-outline::before {
+ content: "\F001B";
+}
+.mdi-zip-disk::before {
+ content: "\FA22";
+}
+.mdi-zodiac-aquarius::before {
+ content: "\FA7C";
+}
+.mdi-zodiac-aries::before {
+ content: "\FA7D";
+}
+.mdi-zodiac-cancer::before {
+ content: "\FA7E";
+}
+.mdi-zodiac-capricorn::before {
+ content: "\FA7F";
+}
+.mdi-zodiac-gemini::before {
+ content: "\FA80";
+}
+.mdi-zodiac-leo::before {
+ content: "\FA81";
+}
+.mdi-zodiac-libra::before {
+ content: "\FA82";
+}
+.mdi-zodiac-pisces::before {
+ content: "\FA83";
+}
+.mdi-zodiac-sagittarius::before {
+ content: "\FA84";
+}
+.mdi-zodiac-scorpio::before {
+ content: "\FA85";
+}
+.mdi-zodiac-taurus::before {
+ content: "\FA86";
+}
+.mdi-zodiac-virgo::before {
+ content: "\FA87";
+}
+.mdi-blank::before {
+ content: "\F68C";
+ visibility: hidden;
+}
+.mdi-18px.mdi-set,
+.mdi-18px.mdi:before {
+ font-size: 18px;
+}
+.mdi-24px.mdi-set,
+.mdi-24px.mdi:before {
+ font-size: 24px;
+}
+.mdi-36px.mdi-set,
+.mdi-36px.mdi:before {
+ font-size: 36px;
+}
+.mdi-48px.mdi-set,
+.mdi-48px.mdi:before {
+ font-size: 48px;
+}
+.mdi-dark:before {
+ color: rgba(0, 0, 0, 0.54);
+}
+.mdi-dark.mdi-inactive:before {
+ color: rgba(0, 0, 0, 0.26);
+}
+.mdi-light:before {
+ color: #fff;
+}
+.mdi-light.mdi-inactive:before {
+ color: rgba(255, 255, 255, 0.3);
+}
+.mdi-rotate-45:before {
+ -webkit-transform: rotate(45deg);
+ -ms-transform: rotate(45deg);
+ transform: rotate(45deg);
+}
+.mdi-rotate-90:before {
+ -webkit-transform: rotate(90deg);
+ -ms-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+.mdi-rotate-135:before {
+ -webkit-transform: rotate(135deg);
+ -ms-transform: rotate(135deg);
+ transform: rotate(135deg);
+}
+.mdi-rotate-180:before {
+ -webkit-transform: rotate(180deg);
+ -ms-transform: rotate(180deg);
+ transform: rotate(180deg);
+}
+.mdi-rotate-225:before {
+ -webkit-transform: rotate(225deg);
+ -ms-transform: rotate(225deg);
+ transform: rotate(225deg);
+}
+.mdi-rotate-270:before {
+ -webkit-transform: rotate(270deg);
+ -ms-transform: rotate(270deg);
+ transform: rotate(270deg);
+}
+.mdi-rotate-315:before {
+ -webkit-transform: rotate(315deg);
+ -ms-transform: rotate(315deg);
+ transform: rotate(315deg);
+}
+.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1);
+ transform: scaleX(-1);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+}
+.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1);
+ transform: scaleY(-1);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+}
+.mdi-spin:before {
+ -webkit-animation: mdi-spin 2s infinite linear;
+ animation: mdi-spin 2s infinite linear;
+}
+@-webkit-keyframes mdi-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
+@keyframes mdi-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
+
+/*# sourceMappingURL=materialdesignicons.css.map */
diff --git a/packages/merchant-backoffice-ui/src/scss/libs/_all.scss b/packages/merchant-backoffice-ui/src/scss/libs/_all.scss
new file mode 100644
index 000000000..ab8030a13
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/libs/_all.scss
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+@import "node_modules/bulma-radio/bulma-radio";
+// @import "node_modules/bulma-responsive-tables/bulma-responsive-tables";
+@import "node_modules/bulma-checkbox/bulma-checkbox";
+@import "node_modules/bulma-switch-control/bulma-switch-control";
+@import "node_modules/bulma-upload-control/bulma-upload-control";
+
+/* Bulma */
+@import "node_modules/bulma/bulma";
diff --git a/packages/merchant-backoffice-ui/src/scss/main.scss b/packages/merchant-backoffice-ui/src/scss/main.scss
new file mode 100644
index 000000000..4a46472f9
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/main.scss
@@ -0,0 +1,195 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+/* Theme style (colors & sizes) */
+@import "theme-default";
+
+/* Core Libs & Lib configs */
+@import "libs/all";
+
+/* Mixins */
+@import "mixins";
+
+/* Theme components */
+@import "nav-bar";
+@import "aside";
+@import "title-bar";
+@import "hero-bar";
+@import "card";
+@import "table";
+@import "tiles";
+@import "form";
+@import "main-section";
+@import "modal";
+@import "footer";
+@import "misc";
+@import "custom-calendar";
+@import "loading";
+
+@import "fonts/nunito.css";
+@import "icons/materialdesignicons-4.9.95.min.css";
+
+$tooltip-color: red;
+
+@import "../../node_modules/@creativebulma/bulma-tooltip/dist/bulma-tooltip.min.css";
+@import "../../node_modules/bulma-timeline/dist/css/bulma-timeline.min.css";
+
+@import "toggle";
+
+.notification {
+ background-color: transparent;
+}
+
+.timeline .timeline-item .timeline-content {
+ padding-top: 0;
+}
+
+.timeline .timeline-item:last-child::before {
+ display: none;
+}
+
+.timeline .timeline-item .timeline-marker {
+ top: 0;
+}
+
+.toast {
+ position: absolute;
+ width: 60%;
+ margin-left: 10%;
+ margin-right: 10%;
+ z-index: 999;
+
+ display: flex;
+ flex-direction: column;
+ padding: 15px;
+ text-align: center;
+ pointer-events: none;
+}
+
+.toast>.message {
+ white-space: pre-wrap;
+ opacity: 80%;
+}
+
+div {
+ &.is-loading {
+ position: relative;
+ pointer-events: none;
+ opacity: 0.5;
+
+ &:after {
+ // @include loader;
+ position: absolute;
+ top: calc(50% - 2.5em);
+ left: calc(50% - 2.5em);
+ width: 5em;
+ height: 5em;
+ border-width: 0.25em;
+ }
+ }
+}
+
+input[type="checkbox"]:indeterminate+.check {
+ background: red !important;
+}
+
+.right-sticky {
+ position: sticky;
+ right: 0px;
+ background-color: $white;
+}
+
+.right-sticky .buttons {
+ flex-wrap: nowrap;
+}
+
+.table.is-striped tbody tr:not(.is-selected):nth-child(even) .right-sticky {
+ background-color: #fafafa;
+}
+
+tr:hover .right-sticky {
+ background-color: hsl(0, 0%, 80%);
+}
+
+.table.is-striped tbody tr:nth-child(even):hover .right-sticky {
+ background-color: hsl(0, 0%, 95%);
+}
+
+.content-full-size {
+ height: calc(100% - 3rem);
+ position: absolute;
+ width: calc(100% - 14rem);
+ display: flex;
+}
+
+.content-full-size .column .card {
+ min-width: 200px;
+}
+
+@include touch {
+ .content-full-size {
+ height: 100%;
+ position: absolute;
+ width: 100%;
+ }
+}
+
+.column.is-half {
+ flex: none;
+ width: 50%;
+}
+
+input:read-only {
+ cursor: initial;
+}
+
+[data-tooltip]:before {
+ max-width: 15rem;
+ width: max-content;
+ text-align: left;
+ transition: opacity 0.1s linear 1s;
+ // transform: inherit !important;
+ white-space: pre-wrap !important;
+ font-weight: normal;
+ // position: relative;
+}
+
+.icon[data-tooltip]:before {
+ transition: none;
+ z-index: 5;
+}
+
+span[data-tooltip] {
+ border-bottom: none;
+}
+
+div[data-tooltip]::before {
+ position: absolute;
+}
+
+.modal-card-body>p {
+ padding: 1em;
+}
+
+.modal-card-body>p.warning {
+ background-color: #fffbdd;
+ border: solid 1px #f2e9bf;
+} \ No newline at end of file
diff --git a/packages/merchant-backoffice-ui/src/scss/toggle.scss b/packages/merchant-backoffice-ui/src/scss/toggle.scss
new file mode 100644
index 000000000..6c7346eb3
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/scss/toggle.scss
@@ -0,0 +1,67 @@
+$green: #56c080;
+
+.toggle {
+ cursor: pointer;
+ display: inline-block;
+}
+
+.toggle-switch {
+ display: inline-block;
+ background: #ccc;
+ border-radius: 16px;
+ width: 58px;
+ height: 32px;
+ position: relative;
+ vertical-align: middle;
+ transition: background 0.25s;
+
+ &:before,
+ &:after {
+ content: "";
+ }
+
+ &:before {
+ display: block;
+ background: linear-gradient(to bottom, #fff 0%, #eee 100%);
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
+ width: 24px;
+ height: 24px;
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ transition: left 0.25s;
+ }
+
+ .toggle:hover &:before {
+ background: linear-gradient(to bottom, #fff 0%, #fff 100%);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
+ }
+
+ &.disabled:before {
+ background: linear-gradient(to bottom, #ccc 0%, #bbb 100%);
+ }
+
+ .toggle:hover &.disabled:before {
+ background: linear-gradient(to bottom, #ccc 0%, #bbb 100%);
+ }
+
+ .toggle-checkbox:checked+& {
+ background: $green;
+
+ &:before {
+ left: 30px;
+ }
+ }
+}
+
+.toggle-checkbox {
+ position: absolute;
+ visibility: hidden;
+}
+
+.toggle-label {
+ margin-left: 5px;
+ position: relative;
+ top: 2px;
+} \ No newline at end of file
diff --git a/packages/merchant-backoffice-ui/src/settings.ts b/packages/merchant-backoffice-ui/src/settings.ts
new file mode 100644
index 000000000..0e377bec2
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/settings.ts
@@ -0,0 +1,84 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Codec,
+ buildCodecForObject,
+ canonicalizeBaseUrl,
+ codecForString,
+ codecOptional
+} from "@gnu-taler/taler-util";
+
+export interface MerchantUiSettings {
+ // Where merchant backend is localted
+ // default: window.origin without "webui/"
+ backendBaseURL?: string;
+}
+
+/**
+ * Global settings for the bank UI.
+ */
+const defaultSettings: MerchantUiSettings = {
+ backendBaseURL: buildDefaultBackendBaseURL(),
+};
+
+const codecForBankUISettings = (): Codec<MerchantUiSettings> =>
+ buildCodecForObject<MerchantUiSettings>()
+ .property("backendBaseURL", codecOptional(codecForString()))
+ .build("MerchantUiSettings");
+
+function removeUndefineField<T extends object>(obj: T): T {
+ const keys = Object.keys(obj) as Array<keyof T>;
+ return keys.reduce((prev, cur) => {
+ if (typeof prev[cur] === "undefined") {
+ delete prev[cur];
+ }
+ return prev;
+ }, obj);
+}
+
+export function fetchSettings(listener: (s: MerchantUiSettings) => void): void {
+ fetch("./settings.json")
+ .then((resp) => resp.json())
+ .then((json) => codecForBankUISettings().decode(json))
+ .then((result) =>
+ listener({
+ ...defaultSettings,
+ ...removeUndefineField(result),
+ }),
+ )
+ .catch((e) => {
+ console.log("failed to fetch settings", e);
+ listener(defaultSettings);
+ });
+}
+
+export function buildDefaultBackendBaseURL(): string {
+ if (typeof window !== "undefined") {
+ const currentLocation = new URL(
+ window.location.pathname,
+ window.location.origin,
+ ).href;
+ /**
+ * By default, merchant backend serves the html content
+ * from the /webui root. This should cover most of the
+ * cases and the rootPath will be the merchant backend
+ * URL where the instances are
+ */
+ return canonicalizeBaseUrl(currentLocation.replace("/webui", ""));
+ }
+ throw Error("No default URL")
+}
diff --git a/packages/merchant-backoffice-ui/src/stories.test.ts b/packages/merchant-backoffice-ui/src/stories.test.ts
new file mode 100644
index 000000000..6ce88b916
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/stories.test.ts
@@ -0,0 +1,44 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { setupI18n } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { parseGroupImport } from "@gnu-taler/web-util/browser";
+import * as admin from "./paths/admin/index.stories.js";
+import * as instance from "./paths/instance/index.stories.js";
+
+setupI18n("en", { en: {} });
+
+describe("All the examples:", () => {
+ const cms = parseGroupImport({ admin, instance });
+ cms.forEach((group) => {
+ describe(`Example for group: ${group.title}`, () => {
+ group.list.forEach((component) => {
+ describe(`Component: ${component.name}`, () => {
+ component.examples.forEach((example) => {
+ it(`should render example: ${example.name}`, () => {
+ tests.renderUI(example.render);
+ });
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/packages/merchant-backoffice-ui/src/stories.tsx b/packages/merchant-backoffice-ui/src/stories.tsx
new file mode 100644
index 000000000..8bb06b8cb
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/stories.tsx
@@ -0,0 +1,48 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { strings } from "./i18n/strings.js";
+
+import * as admin from "./paths/admin/index.stories.js";
+import * as instance from "./paths/instance/index.stories.js";
+import * as components from "./components/index.stories.js";
+
+import { renderStories } from "@gnu-taler/web-util/browser";
+
+import "./scss/main.scss";
+
+function SortStories(a: any, b: any): number {
+ return (a?.order ?? 0) - (b?.order ?? 0);
+}
+
+function main(): void {
+ renderStories(
+ { admin, instance, components },
+ {
+ strings,
+ },
+ );
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", main);
+} else {
+ main();
+}
diff --git a/packages/merchant-backoffice-ui/src/sw.js b/packages/merchant-backoffice-ui/src/sw.js
new file mode 100644
index 000000000..bf52db6fa
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/sw.js
@@ -0,0 +1,25 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+// import { getFiles, setupPrecaching, setupRouting } from 'preact-cli/sw/';
+
+// setupRouting();
+// setupPrecaching(getFiles());
diff --git a/packages/merchant-backoffice-ui/src/utils/amount.ts b/packages/merchant-backoffice-ui/src/utils/amount.ts
new file mode 100644
index 000000000..c94101b4b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/utils/amount.ts
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ amountFractionalBase,
+ AmountJson,
+ Amounts,
+ TalerMerchantApi,
+} from "@gnu-taler/taler-util";
+
+/**
+ * merge refund with the same description and a difference less than one minute
+ * @param prev list of refunds that will hold the merged refunds
+ * @param cur new refund to add to the list
+ * @returns list with the new refund, may be merged with the last
+ */
+export function mergeRefunds(
+ prev: TalerMerchantApi.RefundDetails[],
+ cur: TalerMerchantApi.RefundDetails,
+): TalerMerchantApi.RefundDetails[] {
+ let tail;
+
+ if (
+ prev.length === 0 || //empty list
+ cur.timestamp.t_s === "never" || //current does not have timestamp
+ (tail = prev[prev.length - 1]).timestamp.t_s === "never" || // last does not have timestamp
+ cur.reason !== tail.reason || //different reason
+ cur.pending !== tail.pending || //different pending state
+ Math.abs(cur.timestamp.t_s - tail.timestamp.t_s) > 1000 * 60
+ ) {
+ //more than 1 minute difference
+
+ //can't merge refunds, they are different or to distant in time
+ prev.push(cur);
+ return prev;
+ }
+
+ const a = Amounts.parseOrThrow(tail.amount);
+ const b = Amounts.parseOrThrow(cur.amount);
+ const r = Amounts.add(a, b).amount;
+
+ prev[prev.length - 1] = {
+ ...tail,
+ amount: Amounts.stringify(r),
+ };
+
+ return prev;
+}
+
+export function rate(a: AmountJson, b: AmountJson): number {
+ const af = toFloat(a);
+ const bf = toFloat(b);
+ if (bf === 0) return 0;
+ return af / bf;
+}
+
+function toFloat(amount: AmountJson): number {
+ return amount.value + amount.fraction / amountFractionalBase;
+}
diff --git a/packages/merchant-backoffice-ui/src/utils/constants.ts b/packages/merchant-backoffice-ui/src/utils/constants.ts
new file mode 100644
index 000000000..6b4d8eade
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/utils/constants.ts
@@ -0,0 +1,194 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+//https://tools.ietf.org/html/rfc8905
+export const PAYTO_REGEX =
+ /^payto:\/\/[a-zA-Z][a-zA-Z0-9-.]+(\/[a-zA-Z0-9\-\.\~\(\)@_%:!$&'*+,;=]*)*\??((amount|receiver-name|sender-name|instruction|message)=[a-zA-Z0-9\-\.\~\(\)@_%:!$'*+,;=]*&?)*$/;
+export const PAYTO_WIRE_METHOD_LOOKUP =
+ /payto:\/\/([a-zA-Z][a-zA-Z0-9-.]+)\/.*/;
+
+export const AMOUNT_REGEX = /^[a-zA-Z][a-zA-Z]{1,11}:[0-9][0-9,]*\.?[0-9,]*$/;
+
+export const AMOUNT_ZERO_REGEX = /^[a-zA-Z][a-zA-Z]*:0$/;
+
+export const CROCKFORD_BASE32_REGEX =
+ /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]+[*~$=U]*$/;
+
+export const URL_REGEX =
+ /^((https?:)(\/\/\/?)([\w]*(?::[\w]*)?@)?([\d\w\.-]+)(?::(\d+))?)\/$/;
+
+export const PAGINATED_LIST_SIZE = 5;
+// when doing paginated request, ask for one more
+// and use it to know if there are more to request
+export const PAGINATED_LIST_REQUEST = PAGINATED_LIST_SIZE + 1;
+
+// how much we will wait for all request, in seconds
+export const DEFAULT_REQUEST_TIMEOUT = 10;
+
+export const MAX_IMAGE_SIZE = 1024 * 1024;
+
+export const INSTANCE_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.@-]+$/;
+
+export const COUNTRY_TABLE = {
+ AE: "U.A.E.",
+ AF: "Afghanistan",
+ AL: "Albania",
+ AM: "Armenia",
+ AN: "Netherlands Antilles",
+ AR: "Argentina",
+ AT: "Austria",
+ AU: "Australia",
+ AZ: "Azerbaijan",
+ BA: "Bosnia and Herzegovina",
+ BD: "Bangladesh",
+ BE: "Belgium",
+ BG: "Bulgaria",
+ BH: "Bahrain",
+ BN: "Brunei Darussalam",
+ BO: "Bolivia",
+ BR: "Brazil",
+ BT: "Bhutan",
+ BY: "Belarus",
+ BZ: "Belize",
+ CA: "Canada",
+ CG: "Congo",
+ CH: "Switzerland",
+ CI: "Cote d'Ivoire",
+ CL: "Chile",
+ CM: "Cameroon",
+ CN: "People's Republic of China",
+ CO: "Colombia",
+ CR: "Costa Rica",
+ CS: "Serbia and Montenegro",
+ CZ: "Czech Republic",
+ DE: "Germany",
+ DK: "Denmark",
+ DO: "Dominican Republic",
+ DZ: "Algeria",
+ EC: "Ecuador",
+ EE: "Estonia",
+ EG: "Egypt",
+ ER: "Eritrea",
+ ES: "Spain",
+ ET: "Ethiopia",
+ FI: "Finland",
+ FO: "Faroe Islands",
+ FR: "France",
+ GB: "United Kingdom",
+ GD: "Caribbean",
+ GE: "Georgia",
+ GL: "Greenland",
+ GR: "Greece",
+ GT: "Guatemala",
+ HK: "Hong Kong",
+ // HK: "Hong Kong S.A.R.",
+ HN: "Honduras",
+ HR: "Croatia",
+ HT: "Haiti",
+ HU: "Hungary",
+ ID: "Indonesia",
+ IE: "Ireland",
+ IL: "Israel",
+ IN: "India",
+ IQ: "Iraq",
+ IR: "Iran",
+ IS: "Iceland",
+ IT: "Italy",
+ JM: "Jamaica",
+ JO: "Jordan",
+ JP: "Japan",
+ KE: "Kenya",
+ KG: "Kyrgyzstan",
+ KH: "Cambodia",
+ KR: "South Korea",
+ KW: "Kuwait",
+ KZ: "Kazakhstan",
+ LA: "Laos",
+ LB: "Lebanon",
+ LI: "Liechtenstein",
+ LK: "Sri Lanka",
+ LT: "Lithuania",
+ LU: "Luxembourg",
+ LV: "Latvia",
+ LY: "Libya",
+ MA: "Morocco",
+ MC: "Principality of Monaco",
+ MD: "Moldava",
+ // MD: "Moldova",
+ ME: "Montenegro",
+ MK: "Former Yugoslav Republic of Macedonia",
+ ML: "Mali",
+ MM: "Myanmar",
+ MN: "Mongolia",
+ MO: "Macau S.A.R.",
+ MT: "Malta",
+ MV: "Maldives",
+ MX: "Mexico",
+ MY: "Malaysia",
+ NG: "Nigeria",
+ NI: "Nicaragua",
+ NL: "Netherlands",
+ NO: "Norway",
+ NP: "Nepal",
+ NZ: "New Zealand",
+ OM: "Oman",
+ PA: "Panama",
+ PE: "Peru",
+ PH: "Philippines",
+ PK: "Islamic Republic of Pakistan",
+ PL: "Poland",
+ PR: "Puerto Rico",
+ PT: "Portugal",
+ PY: "Paraguay",
+ QA: "Qatar",
+ RE: "Reunion",
+ RO: "Romania",
+ RS: "Serbia",
+ RU: "Russia",
+ RW: "Rwanda",
+ SA: "Saudi Arabia",
+ SE: "Sweden",
+ SG: "Singapore",
+ SI: "Slovenia",
+ SK: "Slovak",
+ SN: "Senegal",
+ SO: "Somalia",
+ SR: "Suriname",
+ SV: "El Salvador",
+ SY: "Syria",
+ TH: "Thailand",
+ TJ: "Tajikistan",
+ TM: "Turkmenistan",
+ TN: "Tunisia",
+ TR: "Turkey",
+ TT: "Trinidad and Tobago",
+ TW: "Taiwan",
+ TZ: "Tanzania",
+ UA: "Ukraine",
+ US: "United States",
+ UY: "Uruguay",
+ VA: "Vatican",
+ VE: "Venezuela",
+ VN: "Viet Nam",
+ YE: "Yemen",
+ ZA: "South Africa",
+ ZW: "Zimbabwe",
+};
diff --git a/packages/merchant-backoffice-ui/src/utils/regex.test.ts b/packages/merchant-backoffice-ui/src/utils/regex.test.ts
new file mode 100644
index 000000000..78f2ef5ae
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/utils/regex.test.ts
@@ -0,0 +1,88 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+import { AMOUNT_REGEX, PAYTO_REGEX } from "./constants.js";
+
+describe("payto uri format", () => {
+ const valids = [
+ "payto://iban/DE75512108001245126199?amount=EUR:200.0&message=hello",
+ "payto://ach/122000661/1234",
+ "payto://upi/alice@example.com?receiver-name=Alice&amount=INR:200",
+ "payto://void/?amount=EUR:10.5",
+ "payto://ilp/g.acme.bob",
+ ];
+
+ it("should be valid", () => {
+ valids.forEach((v) => expect(v).match(PAYTO_REGEX));
+ });
+
+ const invalids = [
+ // has two question marks
+ "payto://iban/DE75?512108001245126199?amount=EUR:200.0&message=hello",
+ // has a space
+ "payto://ach /122000661/1234",
+ // has a space
+ "payto://upi/alice@ example.com?receiver-name=Alice&amount=INR:200",
+ // invalid field name (mount instead of amount)
+ "payto://void/?mount=EUR:10.5",
+ // payto:// is incomplete
+ "payto: //ilp/g.acme.bob",
+ ];
+
+ it("should not be valid", () => {
+ invalids.forEach((v) => expect(v).not.match(PAYTO_REGEX));
+ });
+});
+
+describe("amount format", () => {
+ const valids = [
+ "ARS:10",
+ "COL:10.2",
+ "UY:1,000.2",
+ "ARS:10.123,123",
+ "ARS:1,000,000",
+ "ARSCOL:10",
+ "LONGESTCURR:1,000,000.123,123",
+ ];
+
+
+ it("should be valid", () => {
+ valids.forEach((v) => expect(v).match(AMOUNT_REGEX));
+ });
+
+ const invalids = [
+ //no currency name
+ ":10",
+ //use . instead of ,
+ "ARS:1.000.000",
+ //currency name with numbers
+ "1ARS:10",
+ //currency name with numbers
+ "AR5:10",
+ //missing value
+ "USD:",
+ ];
+
+ it("should not be valid", () => {
+ invalids.forEach((v) => expect(v).not.match(AMOUNT_REGEX));
+ });
+});
diff --git a/packages/merchant-backoffice-ui/src/utils/table.ts b/packages/merchant-backoffice-ui/src/utils/table.ts
new file mode 100644
index 000000000..982b68e5e
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/utils/table.ts
@@ -0,0 +1,56 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export interface Actions<T extends WithId> {
+ element: T;
+ type: "DELETE" | "UPDATE";
+}
+
+function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
+ return value !== null && value !== undefined;
+}
+
+export function buildActions<T extends WithId>(
+ instances: T[],
+ selected: string[],
+ action: "DELETE",
+): Actions<T>[] {
+ return selected
+ .map((id) => instances.find((i) => i.id === id))
+ .filter(notEmpty)
+ .map((id) => ({ element: id, type: action }));
+}
+
+/**
+ * For any object or array, return the same object if is not empty.
+ * not empty:
+ * - for arrays: at least one element not undefined
+ * - for objects: at least one property not undefined
+ * @param obj
+ * @returns
+ */
+export function undefinedIfEmpty<
+ T extends Record<string, unknown> | Array<unknown>,
+>(obj: T | undefined): T | undefined {
+ if (obj === undefined) return undefined;
+ return Object.values(obj).some((v) => v !== undefined) ? obj : undefined;
+}
diff --git a/packages/merchant-backoffice-ui/src/utils/types.ts b/packages/merchant-backoffice-ui/src/utils/types.ts
new file mode 100644
index 000000000..9ce6da4d1
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/utils/types.ts
@@ -0,0 +1,31 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { VNode } from "preact";
+
+export interface KeyValue {
+ [key: string]: string;
+}
+
+export interface Notification {
+ message: string;
+ description?: string | VNode;
+ details?: string | VNode | string;
+ type: MessageType;
+}
+
+export type ValueOrFunction<T> = T | ((p: T) => T);
+export type MessageType = "INFO" | "WARN" | "ERROR" | "SUCCESS";
diff --git a/packages/merchant-backoffice-ui/test.mjs b/packages/merchant-backoffice-ui/test.mjs
new file mode 100755
index 000000000..be76348e5
--- /dev/null
+++ b/packages/merchant-backoffice-ui/test.mjs
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build } from "@gnu-taler/web-util/build";
+import { getFilesInDirectory } from "@gnu-taler/web-util/build";
+
+const allTestFiles = getFilesInDirectory("src", /.test.tsx?$/);
+
+await build({
+ type: "test",
+ source: {
+ js: allTestFiles.files,
+ assets: [{base:"src",files:["src/index.html"]}],
+ },
+ destination: "./dist/test",
+ css: "sass",
+});
diff --git a/packages/merchant-backoffice-ui/tsconfig.json b/packages/merchant-backoffice-ui/tsconfig.json
new file mode 100644
index 000000000..396f1e9e7
--- /dev/null
+++ b/packages/merchant-backoffice-ui/tsconfig.json
@@ -0,0 +1,58 @@
+{
+ "compilerOptions": {
+ /* Basic Options */
+ "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */,
+ "module": "Node16" /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
+ "lib": [
+ "es2020",
+ "dom"
+ ] /* Specify library files to be included in the compilation: */,
+ // "allowJs": true, /* Allow javascript files to be compiled. */
+ // "checkJs": true, /* Report errors in .js files. */
+ "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
+ "jsxFactory": "h" /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */,
+ "jsxFragmentFactory": "Fragment", // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#custom-jsx-factories
+ // "declaration": true, /* Generates corresponding '.d.ts' file. */
+ // "sourceMap": true, /* Generates corresponding '.map' file. */
+ // "outFile": "./", /* Concatenate and emit output to single file. */
+ // "outDir": "./", /* Redirect output structure to the directory. */
+ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+ // "removeComments": true, /* Do not emit comments to output. */
+ "noEmit": true /* Do not emit outputs. */,
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+ /* Strict Type-Checking Options */
+ "strict": true /* Enable all strict type-checking options. */,
+ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* Enable strict null checks. */
+ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
+ /* Additional Checks */
+ // "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+ /* Module Resolution Options */
+ "moduleResolution": "node16" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
+ "esModuleInterop": true /* */,
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+ /* Source Map Options */
+ // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+ /* Advanced Options */
+ "skipLibCheck": true /* Skip type checking of declaration files. */
+ },
+ "include": ["src/**/*", "tests/**/*"]
+}
diff --git a/packages/pogen/bin/pogen b/packages/pogen/bin/pogen
index a7ef879d8..3b17d1df7 100755
--- a/packages/pogen/bin/pogen
+++ b/packages/pogen/bin/pogen
@@ -1,2 +1,2 @@
-#!/usr/bin/env node
+#!/usr/bin/env -S node --trace-deprecation
require('../lib/pogen.js').main();
diff --git a/packages/pogen/example/proj1/package.json b/packages/pogen/example/proj1/package.json
index 954139ecf..97adf0f3a 100644
--- a/packages/pogen/example/proj1/package.json
+++ b/packages/pogen/example/proj1/package.json
@@ -7,5 +7,8 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
- "license": "ISC"
+ "license": "ISC",
+ "pogen": {
+ "domain": "test"
+ }
}
diff --git a/packages/pogen/example/messages.po b/packages/pogen/example/proj1/src/i18n/test.pot
index 1addae3f2..8bc5ef236 100644
--- a/packages/pogen/example/messages.po
+++ b/packages/pogen/example/proj1/src/i18n/test.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2016-01-27 01:51+0100\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,68 +17,78 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-#: example/test.ts:3
-#, csharp-format
+#: src/test.ts:4
+#, c-format
msgid "Hello1, World"
msgstr ""
-#: example/test.ts:4
-#, csharp-format
-msgid "Hello2, World"
+#: src/test.ts:8
+#, c-format
+msgid "Hello, quoted \"world\""
msgstr ""
-#: example/test.ts:5
-#, csharp-format
-msgid "Hello3, World"
-msgstr ""
-
-#. This is a comment and should be included
-#: example/test.ts:9
-#, csharp-format
-msgid "Hello4, World"
-msgstr ""
-
-#: example/test.ts:12
-#, csharp-format
+#: src/test.ts:15
+#, c-format
msgid "Hello5, World"
msgstr ""
-#: example/test.ts:13
-#, csharp-format
-msgid "Hello6,{0} World"
+#: src/test.ts:16
+#, c-format
+msgid "Hello6,%1$s World"
msgstr ""
#. This one has a multi line comment.
#. It has multiple lines, and a trailing empty line.
#.
-#: example/test.ts:20
-#, csharp-format
-msgid "Hello7,{0} World{1}"
+#: src/test.ts:23
+#, c-format
+msgid "Hello7,%1$s World%2$s"
msgstr ""
-#: example/test.ts:21
-#, csharp-format
-msgid "{0}Hello8,{1} World{2}"
+#: src/test.ts:23
+#, c-format
+msgid "one %1$s"
+msgid_plural "many %1$s"
+msgstr[0] ""
+msgstr[1] ""
+
+#: src/test.ts:26
+#, c-format
+msgid "one bla %1$s"
+msgid_plural "many bla %1$s"
+msgstr[0] ""
+msgstr[1] ""
+
+#: src/test.ts:31
+#, c-format
+msgid "I have %1$s apple"
+msgid_plural "I have %1$s apples"
+msgstr[0] ""
+msgstr[1] ""
+
+#: src/test.ts:35
+#, c-format
+msgid "%1$sHello8,%2$s World%3$s"
msgstr ""
#.
#. This one has a multi line comment.
#. It has multiple lines, and a leading empty line.
-#: example/test.ts:28
-#, csharp-format
+#: src/test.ts:42
+#, c-format
msgid "Hello9,\" '\" World"
msgstr ""
-#: example/test.ts:32
-#, csharp-format
+#: src/test.ts:46
+#, c-format
msgid ""
"Hello10\n"
" ,\" '\" Wo\n"
" rld"
msgstr ""
-#: example/test.ts:37
-#, csharp-format
+#: src/test.ts:51
+#, c-format
msgid ""
"Hello11 this is a long long string\n"
"it will go over multiple lines and in the pofile\n"
@@ -86,8 +96,8 @@ msgid ""
msgstr ""
#. This is a single line comment
-#: example/test.ts:42
-#, csharp-format
+#: src/test.ts:56
+#, c-format
msgid ""
"Hello12 this is a long long string it will go over multiple lines and in the "
"pofile it should be wrapped and stuff. asdf asdf asdf asdf asdf asdf asdf asdf "
@@ -95,13 +105,26 @@ msgid ""
"asdf"
msgstr ""
-#: example/test.ts:42
-#, csharp-format
+#. First occurrence
+#: src/test.ts:65
+#, c-format
msgid "This message appears twice"
msgstr ""
-#: example/test.ts:45
-#, csharp-format
-msgid "This message appears twice"
+#: src/test2.tsx:1
+#, c-format
+msgid "foo %1$s foo baz"
+msgstr ""
+
+#: src/test2.tsx:5
+#, c-format
+msgid "singular form second line"
+msgid_plural "plural form"
+msgstr[0] ""
+msgstr[1] ""
+
+#: src/test2.tsx:17
+#, c-format
+msgid "\"foo\""
msgstr ""
diff --git a/packages/pogen/example/test.ts b/packages/pogen/example/proj1/src/test.ts
index 6577fa314..2e9b4cbdd 100644
--- a/packages/pogen/example/test.ts
+++ b/packages/pogen/example/proj1/src/test.ts
@@ -1,9 +1,12 @@
declare var i18n: any;
+
console.log(i18n`Hello1, World`);
console.log(i18n.foo()`Hello2, World`);
console.log(i18n.foo()`Hello3, World`);
+console.log(i18n`Hello, quoted "world"`);
+
/* This is a comment and should be included */
console.log(i18n().foo()`Hello4, World`);
diff --git a/packages/pogen/example/test2.tsx b/packages/pogen/example/proj1/src/test2.tsx
index 4133f86fb..1c1ab49f2 100644
--- a/packages/pogen/example/test2.tsx
+++ b/packages/pogen/example/proj1/src/test2.tsx
@@ -13,3 +13,5 @@ let y = (
</i18n.TranslatePlural>
</i18n.TranslateSwitch>
);
+
+let z = <i18n.Translate>"foo"</i18n.Translate>;
diff --git a/packages/pogen/example/proj1/tsconfig.json b/packages/pogen/example/proj1/tsconfig.json
index 30cb65e1d..36ef053db 100644
--- a/packages/pogen/example/proj1/tsconfig.json
+++ b/packages/pogen/example/proj1/tsconfig.json
@@ -4,7 +4,7 @@
"composite": true,
"declaration": true,
"declarationMap": false,
- "target": "ES6",
+ "target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"sourceMap": true,
diff --git a/packages/pogen/package.json b/packages/pogen/package.json
index 3d744f736..4cc94b107 100644
--- a/packages/pogen/package.json
+++ b/packages/pogen/package.json
@@ -1,19 +1,21 @@
{
"name": "@gnu-taler/pogen",
- "version": "0.0.5",
+ "version": "0.10.6",
"bin": {
"pogen": "bin/pogen"
},
"author": "Florian Dold",
"license": "GPL-2.0+",
"scripts": {
- "prepare": "tsc",
+ "clean": "rm -rf lib",
"compile": "tsc"
},
"devDependencies": {
- "typescript": "^4.1.3"
+ "po2json": "^0.4.5",
+ "typescript": "^5.3.3"
},
"dependencies": {
- "@types/node": "^14.14.22"
+ "@types/node": "^18.11.17",
+ "glob": "^10.3.10"
}
}
diff --git a/packages/pogen/po2.js b/packages/pogen/po2.js
deleted file mode 100644
index 532a1522f..000000000
--- a/packages/pogen/po2.js
+++ /dev/null
@@ -1,32 +0,0 @@
-const ts = require("typescript");
-
-const configPath = ts.findConfigFile(
- /*searchPath*/ "./",
- ts.sys.fileExists,
- "tsconfig.json"
- );
-if (!configPath) {
- throw new Error("Could not find a valid 'tsconfig.json'.");
-}
-
-console.log(configPath);
-
-const cmdline = ts.getParsedCommandLineOfConfigFile(configPath, {}, {
- fileExists: ts.sys.fileExists,
- getCurrentDirectory: ts.sys.getCurrentDirectory,
- onUnRecoverableConfigFileDiagnostic: (e) => console.log(e),
- readDirectory: ts.sys.readDirectory,
- readFile: ts.sys.readFile,
- useCaseSensitiveFileNames: true,
-})
-
-console.log(cmdline);
-
-const prog = ts.createProgram({
- options: cmdline.options,
- rootNames: cmdline.fileNames,
-});
-
-const allFiles = prog.getSourceFiles();
-
-console.log(allFiles.map(x => x.path)); \ No newline at end of file
diff --git a/packages/pogen/dumpTree.ts b/packages/pogen/src/dumpTree.ts
index af25caf32..af25caf32 100644
--- a/packages/pogen/dumpTree.ts
+++ b/packages/pogen/src/dumpTree.ts
diff --git a/packages/pogen/src/po2ts.ts b/packages/pogen/src/po2ts.ts
new file mode 100644
index 000000000..0b5b1384d
--- /dev/null
+++ b/packages/pogen/src/po2ts.ts
@@ -0,0 +1,146 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Convert a <lang>.po file into a JavaScript / TypeScript expression.
+ */
+
+// @ts-ignore
+import * as po2jsonLib from "po2json";
+import * as fs from "fs";
+import glob = require("glob");
+
+//types defined by the po2json library
+type Header = {
+ domain: string;
+ lang: string;
+ 'plural_forms': string;
+};
+
+type MessagesType = Record<string, undefined | Array<string>> & { "": Header }
+interface pojsonType {
+ // X-Domain or 'messages'
+ domain: string;
+ locale_data: {
+ messages: MessagesType
+ }
+}
+// ----------- end pf po2json
+
+interface StringsType {
+ // X-Domain or 'messages'
+ domain: string;
+ lang: string;
+ completeness: number,
+ 'plural_forms': string;
+ locale_data: {
+ messages: Record<string, undefined | Array<string>>
+ }
+}
+
+// This prelude match the types above
+const TYPES_FOR_STRING_PRELUDE = `
+export interface StringsType {
+ domain: string;
+ lang: string;
+ completeness: number;
+ 'plural_forms': string;
+ locale_data: {
+ messages: Record<string, unknown>;
+ };
+};
+`;
+
+const DEFAULT_STRING_PRELUDE = `${TYPES_FOR_STRING_PRELUDE}export const strings: Record<string,StringsType> = {};\n\n`
+
+
+export function po2ts(): void {
+ const files = glob.sync("src/i18n/*.po");
+
+ if (files.length === 0) {
+ console.error("no .po files found in src/i18n/");
+ process.exit(1);
+ }
+
+ console.log(files);
+
+ let prelude: string;
+ try {
+ prelude = fs.readFileSync("src/i18n/strings-prelude", "utf-8")
+ } catch (e) {
+ prelude = DEFAULT_STRING_PRELUDE
+ }
+
+ const chunks = [prelude];
+
+ for (const filename of files) {
+ const m = filename.match(/([a-zA-Z0-9-_]+).po/);
+
+ if (!m) {
+ console.error("error: unexpected filename (expected <lang>.po)");
+ process.exit(1);
+ }
+
+ const lang = m[1];
+ const poAsJson: pojsonType = po2jsonLib.parseFileSync(filename, {
+ format: "jed1.x",
+ fuzzy: true,
+ });
+ const header = poAsJson.locale_data.messages[""]
+ const total = calculateTotalTranslations(poAsJson.locale_data.messages)
+ const completeness =
+ header.lang === "en"
+ ? 100 // 'en' is always complete
+ : Math.floor(total.translations * 100 / total.keys);
+
+ const strings: StringsType = {
+ locale_data: poAsJson.locale_data,
+ domain: poAsJson.domain,
+ plural_forms: header.plural_forms,
+ lang: header.lang,
+ completeness,
+ }
+ const value = JSON.stringify(strings, undefined, 2)
+ const s = `strings['${lang}'] = ${value};\n\n`
+ chunks.push(s);
+ }
+
+ const tsContents = chunks.join("");
+
+ fs.writeFileSync("src/i18n/strings.ts", tsContents);
+}
+
+function calculateTotalTranslations(msgs: MessagesType): { keys: number, translations: number } {
+ const kv = Object.entries(msgs)
+ const [keys, translations] = kv.reduce(([total, withTranslation], translation) => {
+ if (!translation || translation.length !== 2 || !translation[1]) {
+ //current key is empty
+ return [total, withTranslation]
+ }
+ const v = translation[1]
+ if (!Array.isArray(v)) {
+ // this is not a translation
+ return [total, withTranslation]
+ }
+ if (!v.length || !v[0].length) {
+ //translation is missing
+ return [total + 1, withTranslation]
+ }
+ //current key has a translation
+ return [total + 1, withTranslation + 1]
+ }, [0, 0])
+ return { keys, translations }
+} \ No newline at end of file
diff --git a/packages/pogen/src/pogen.ts b/packages/pogen/src/pogen.ts
new file mode 100644
index 000000000..72b7c81d7
--- /dev/null
+++ b/packages/pogen/src/pogen.ts
@@ -0,0 +1,48 @@
+import { potextract } from "./potextract.js";
+import * as child_process from "child_process";
+import * as fs from "fs";
+import glob = require("glob");
+import { po2ts } from "./po2ts.js";
+
+function usage(): never {
+ console.log("usage: pogen <extract|merge|emit>");
+ process.exit(1);
+}
+
+export function main() {
+ const subcommand = process.argv[2];
+ if (process.argv.includes("--help") || !subcommand) {
+ usage();
+ }
+ switch (subcommand) {
+ case "extract":
+ potextract();
+ break;
+ case "merge": {
+ const packageJson = JSON.parse(
+ fs.readFileSync("./package.json", { encoding: "utf-8" }),
+ );
+
+ const poDomain = packageJson.pogen?.domain;
+ if (!poDomain) {
+ console.error("missing 'pogen.domain' field in package.json");
+ process.exit(1);
+ }
+ const files = glob.sync("src/i18n/*.po");
+ console.log(files);
+ for (const f of files) {
+ console.log(`merging ${f}`);
+ child_process.execSync(
+ `msgmerge -o '${f}' '${f}' 'src/i18n/${poDomain}.pot'`,
+ );
+ }
+ break;
+ }
+ case "emit":
+ po2ts();
+ break;
+ default:
+ console.error(`unknown subcommand '${subcommand}'`);
+ usage();
+ }
+}
diff --git a/packages/pogen/pogen.ts b/packages/pogen/src/potextract.ts
index 23ac389f4..3e9a95ded 100644
--- a/packages/pogen/pogen.ts
+++ b/packages/pogen/src/potextract.ts
@@ -1,43 +1,59 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2019-2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Generate .po file from list of source files.
- *
- * Note that duplicate message IDs are NOT merged, to get the same output as
- * you would from xgettext, just run msguniq.
- *
- * @author Florian Dold
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Imports.
*/
-import { readFileSync } from "fs";
import * as ts from "typescript";
+import * as fs from "fs";
+import * as path from "path"
+
+const DEFAULT_PO_HEADER = `# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\\n"
+"Report-Msgid-Bugs-To: \\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
+"Language-Team: LANGUAGE <LL@li.org>\\n"
+"Language: \\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"\n\n`
+
function wordwrap(str: string, width: number = 80): string[] {
var regex = ".{1," + width + "}(\\s|$)|\\S+(\\s|$)";
return str.match(RegExp(regex, "g"));
}
-export function processFile(sourceFile: ts.SourceFile) {
- processNode(sourceFile);
+function processFile(
+ sourceFile: ts.SourceFile,
+ outChunks: string[],
+ knownMessageIds: Set<string>,
+) {
let lastTokLine = 0;
let preLastTokLine = 0;
+ processNode(sourceFile);
function getTemplate(node: ts.Node): string {
switch (node.kind) {
@@ -138,7 +154,7 @@ export function processFile(sourceFile: ts.SourceFile) {
path,
line: lc.line,
comment: getComment(tte),
- template: getTemplate(tte.template).replace(/"/g, '\\"'),
+ template: getTemplate(tte.template),
};
return res;
}
@@ -146,26 +162,29 @@ export function processFile(sourceFile: ts.SourceFile) {
function formatMsgComment(line: number, comment?: string) {
if (comment) {
for (let cl of comment.split("\n")) {
- console.log(`#. ${cl}`);
+ outChunks.push(`#. ${cl}\n`);
}
}
- console.log(`#: ${sourceFile.fileName}:${line + 1}`);
- console.log(`#, c-format`);
+ const fn = path.posix.relative(process.cwd(), sourceFile.fileName);
+ outChunks.push(`#: ${fn}:${line + 1}\n`);
+ outChunks.push(`#, c-format\n`);
}
function formatMsgLine(head: string, msg: string) {
// Do escaping, wrap break at newlines
+ console.log("head", JSON.stringify(head));
+ console.log("msg", JSON.stringify(msg));
let parts = msg
.match(/(.*\n|.+$)/g)
- .map((x) => x.replace(/\n/g, "\\n"))
+ .map((x) => x.replace(/\n/g, "\\n").replace(/"/g, '\\"'))
.map((p) => wordwrap(p))
.reduce((a, b) => a.concat(b));
if (parts.length == 1) {
- console.log(`${head} "${parts[0]}"`);
+ outChunks.push(`${head} "${parts[0]}"\n`);
} else {
- console.log(`${head} ""`);
+ outChunks.push(`${head} ""\n`);
for (let p of parts) {
- console.log(`"${p}"`);
+ outChunks.push(`"${p}"\n`);
}
}
}
@@ -197,7 +216,7 @@ export function processFile(sourceFile: ts.SourceFile) {
}
}
- function trim(s) {
+ function trim(s: string) {
return s.replace(/^[ \n\t]*/, "").replace(/[ \n\t]*$/, "");
}
@@ -208,7 +227,7 @@ export function processFile(sourceFile: ts.SourceFile) {
switch (childNode.kind) {
case ts.SyntaxKind.JsxText: {
let e = childNode as ts.JsxText;
- let s = e.getFullText();
+ let s = e.text;
let t = s.split("\n").map(trim).join(" ");
if (s[0] === " ") {
t = " " + t;
@@ -220,6 +239,7 @@ export function processFile(sourceFile: ts.SourceFile) {
}
case ts.SyntaxKind.JsxOpeningElement:
break;
+ case ts.SyntaxKind.JsxSelfClosingElement:
case ts.SyntaxKind.JsxElement:
fragments.push(`%${holeNum[0]++}$s`);
break;
@@ -231,16 +251,13 @@ export function processFile(sourceFile: ts.SourceFile) {
case ts.SyntaxKind.JsxClosingElement:
break;
default:
+ console.log("unhandled node type: ", childNode.kind)
let lc = ts.getLineAndCharacterOfPosition(
childNode.getSourceFile(),
childNode.getStart(),
);
console.error(
- `unrecognized syntax in JSX Element ${
- ts.SyntaxKind[childNode.kind]
- } (${childNode.getSourceFile().fileName}:${lc.line + 1}:${
- lc.character + 1
- }`,
+ `unrecognized syntax in JSX Element ${ts.SyntaxKind[childNode.kind]} (${childNode.getSourceFile().fileName}:${lc.line + 1}:${lc.character + 1}`,
);
break;
}
@@ -293,10 +310,13 @@ export function processFile(sourceFile: ts.SourceFile) {
let content = getJsxContent(node);
let { line } = ts.getLineAndCharacterOfPosition(sourceFile, node.pos);
let comment = getComment(node);
- formatMsgComment(line, comment);
- formatMsgLine("msgid", content);
- console.log(`msgstr ""`);
- console.log();
+ if (!knownMessageIds.has(content)) {
+ knownMessageIds.add(content);
+ formatMsgComment(line, comment);
+ formatMsgLine("msgid", content);
+ outChunks.push(`msgstr ""\n`);
+ outChunks.push("\n");
+ }
return;
}
if (arrayEq(path, ["i18n", "TranslateSwitch"])) {
@@ -313,11 +333,14 @@ export function processFile(sourceFile: ts.SourceFile) {
console.error("plural form missing");
process.exit(1);
}
- formatMsgLine("msgid", singularForm);
- formatMsgLine("msgid_plural", pluralForm);
- console.log(`msgstr[0] ""`);
- console.log(`msgstr[1] ""`);
- console.log();
+ if (!knownMessageIds.has(singularForm)) {
+ knownMessageIds.add(singularForm);
+ formatMsgLine("msgid", singularForm);
+ formatMsgLine("msgid_plural", pluralForm);
+ outChunks.push(`msgstr[0] ""\n`);
+ outChunks.push(`msgstr[1] ""\n`);
+ outChunks.push(`\n`);
+ }
return;
}
break;
@@ -342,29 +365,35 @@ export function processFile(sourceFile: ts.SourceFile) {
<ts.TaggedTemplateExpression>ce.arguments[1],
);
let comment = getComment(ce);
-
- formatMsgComment(line, comment);
- formatMsgLine("msgid", t1.template);
- formatMsgLine("msgid_plural", t2.template);
- console.log(`msgstr[0] ""`);
- console.log(`msgstr[1] ""`);
- console.log();
+ const msgid = t1.template;
+ if (!knownMessageIds.has(msgid)) {
+ knownMessageIds.add(msgid);
+ formatMsgComment(line, comment);
+ formatMsgLine("msgid", t1.template);
+ formatMsgLine("msgid_plural", t2.template);
+ outChunks.push(`msgstr[0] ""\n`);
+ outChunks.push(`msgstr[1] ""\n`);
+ outChunks.push("\n");
+ }
// Important: no processing for child i18n expressions here
return;
}
case ts.SyntaxKind.TaggedTemplateExpression: {
let tte = <ts.TaggedTemplateExpression>node;
- let { comment, template, line, path } = processTaggedTemplateExpression(
- tte,
- );
+ let { comment, template, line, path } =
+ processTaggedTemplateExpression(tte);
if (path[0] != "i18n") {
break;
}
- formatMsgComment(line, comment);
- formatMsgLine("msgid", template);
- console.log(`msgstr ""`);
- console.log();
+ const msgid = template;
+ if (!knownMessageIds.has(msgid)) {
+ knownMessageIds.add(msgid);
+ formatMsgComment(line, comment);
+ formatMsgLine("msgid", template);
+ outChunks.push(`msgstr ""\n`);
+ outChunks.push("\n");
+ }
break;
}
}
@@ -373,7 +402,7 @@ export function processFile(sourceFile: ts.SourceFile) {
}
}
-export function main() {
+export function potextract() {
const configPath = ts.findConfigFile(
/*searchPath*/ "./",
ts.sys.fileExists,
@@ -396,41 +425,47 @@ export function main() {
},
);
- const fileNames = cmdline.fileNames;
+ const prog = ts.createProgram({
+ options: cmdline.options,
+ rootNames: cmdline.fileNames,
+ });
- fileNames.sort();
+ const allFiles = prog.getSourceFiles();
- const outChunks: string[] = [];
+ const ownFiles = allFiles.filter(
+ (x) =>
+ !x.isDeclarationFile &&
+ !prog.isSourceFileFromExternalLibrary(x) &&
+ !prog.isSourceFileDefaultLibrary(x),
+ );
- outChunks.push(`# SOME DESCRIPTIVE TITLE.
-# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
-# This file is distributed under the same license as the PACKAGE package.
-# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
-#
-#, fuzzy
-msgid ""
-msgstr ""
-"Project-Id-Version: PACKAGE VERSION\\n"
-"Report-Msgid-Bugs-To: \\n"
-"POT-Creation-Date: 2016-11-23 00:00+0100\\n"
-"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
-"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
-"Language-Team: LANGUAGE <LL@li.org>\\n"
-"Language: \\n"
-"MIME-Version: 1.0\\n"
-"Content-Type: text/plain; charset=UTF-8\\n"
-"Content-Transfer-Encoding: 8bit\\n"`);
-
- fileNames.forEach((fileName) => {
- let sourceFile = ts.createSourceFile(
- fileName,
- readFileSync(fileName).toString(),
- ts.ScriptTarget.ES2016,
- /*setParentNodes */ true,
- );
- processFile(sourceFile);
- });
+ let header: string
+ try {
+ header = fs.readFileSync("src/i18n/poheader", "utf-8")
+ } catch (e) {
+ header = DEFAULT_PO_HEADER
+ }
+
+ const chunks = [header];
+
+ const knownMessageIds = new Set<string>();
- const out = outChunks.join("");
- console.log(out);
+ for (const f of ownFiles) {
+ processFile(f, chunks, knownMessageIds);
+ }
+
+ const pot = chunks.join("");
+
+ //console.log(pot);
+
+ const packageJson = JSON.parse(
+ fs.readFileSync("./package.json", { encoding: "utf-8" }),
+ );
+
+ const poDomain = packageJson.pogen?.domain;
+ if (!poDomain) {
+ console.error("missing 'pogen.domain' field in package.json");
+ process.exit(1);
+ }
+ fs.writeFileSync(`./src/i18n/${poDomain}.pot`, pot);
}
diff --git a/packages/pogen/tsconfig.json b/packages/pogen/tsconfig.json
index d61e5595a..482ce6fe8 100644
--- a/packages/pogen/tsconfig.json
+++ b/packages/pogen/tsconfig.json
@@ -1,13 +1,14 @@
{
- "compilerOptions": {
- "module": "commonjs",
- "target": "es5",
- "noImplicitAny": false,
- "sourceMap": false,
- "outDir": "lib",
- "incremental": true
- },
- "files": [
- "pogen.ts"
- ]
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "ES2020",
+ "noImplicitAny": false,
+ "outDir": "lib",
+ "incremental": true,
+ "moduleResolution": "node",
+ "sourceMap": true,
+ "lib": ["ES2020"],
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"]
}
diff --git a/packages/taler-harness/Makefile b/packages/taler-harness/Makefile
new file mode 100644
index 000000000..e9663aa61
--- /dev/null
+++ b/packages/taler-harness/Makefile
@@ -0,0 +1,47 @@
+# This Makefile has been placed in the public domain.
+
+ifeq ($(TOPLEVEL), yes)
+ $(info top-level build)
+ -include ../../.config.mk
+ override DESTDIR := $(TOP_DESTDIR)
+else
+ $(info package-level build)
+ -include ../../.config.mk
+ -include .config.mk
+endif
+
+$(info prefix is $(prefix))
+
+all:
+ @echo use 'make install' to build and install taler-harness
+
+ifndef prefix
+.PHONY: warn-noprefix install
+warn-noprefix:
+ @echo "no prefix configured, did you run ./configure?"
+install: warn-noprefix
+else
+BINDIR = $(prefix)/bin
+LIBDIR = $(prefix)/lib/taler-harness
+NODEDIR = $(LIBDIR)/node_modules/taler-harness
+.PHONY: install deps install-nodeps
+install-nodeps:
+ ./build.mjs
+ install -d $(DESTDIR)$(BINDIR)
+ install -d $(DESTDIR)$(NODEDIR)
+ install -d $(DESTDIR)$(NODEDIR)/bin
+ install -d $(DESTDIR)$(NODEDIR)/dist
+ install ./dist/taler-harness-bundled.cjs $(DESTDIR)$(NODEDIR)/dist/
+ install ./dist/taler-harness-bundled.cjs.map $(DESTDIR)$(NODEDIR)/dist/
+ install ./bin/taler-harness.mjs $(DESTDIR)$(NODEDIR)/bin/
+ ln -sf ../lib/taler-harness/node_modules/taler-harness/bin/taler-harness.mjs $(DESTDIR)$(BINDIR)/taler-harness
+deps:
+ pnpm install --frozen-lockfile --filter @gnu-taler/taler-harness...
+install:
+ $(MAKE) deps
+ $(MAKE) install-nodeps
+endif
+
+.PHONY: deb
+deb:
+ dpkg-buildpackage -rfakeroot -b -uc -us
diff --git a/packages/taler-harness/README.md b/packages/taler-harness/README.md
new file mode 100644
index 000000000..f88024297
--- /dev/null
+++ b/packages/taler-harness/README.md
@@ -0,0 +1,13 @@
+# taler-harness
+
+This package implements the `taler-harness` CLI tool. It contains integration
+tests for GNU Taler and GNU anastasis, as well as various helpers for managing
+deployments of GNU Taler.
+
+## Debugging
+
+To get more actionable stack traces, enable source maps for node:
+
+```
+export NODE_OPTIONS=--enable-source-maps
+```
diff --git a/packages/taler-wallet-webextension/src/cta/return-coins.tsx b/packages/taler-harness/bin/taler-harness.mjs
index 43d73b5fe..f8deebedb 100644..100755
--- a/packages/taler-wallet-webextension/src/cta/return-coins.tsx
+++ b/packages/taler-harness/bin/taler-harness.mjs
@@ -1,6 +1,7 @@
+#!/usr/bin/env node
/*
- This file is part of TALER
- (C) 2017 Inria
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems SA
TALER is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,17 +15,5 @@
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { JSX } from "preact/jsx-runtime";
-import { h } from 'preact';
-/**
- * Return coins to own bank account.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-export function createReturnCoinsPage(): JSX.Element {
- return <span>Not implemented yet.</span>;
-}
+import { main } from '../dist/taler-harness-bundled.cjs';
+main();
diff --git a/packages/taler-harness/build.mjs b/packages/taler-harness/build.mjs
new file mode 100755
index 000000000..ef2a2b111
--- /dev/null
+++ b/packages/taler-harness/build.mjs
@@ -0,0 +1,76 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import esbuild from "esbuild";
+import fs from "fs";
+import path from "path";
+
+const BASE = process.cwd();
+
+let GIT_ROOT = BASE;
+while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") {
+ GIT_ROOT = path.join(GIT_ROOT, "../");
+}
+if (GIT_ROOT === "/") {
+ console.log("not found");
+ process.exit(1);
+}
+const GIT_HASH = GIT_ROOT === "/" ? undefined : git_hash();
+
+let _package = JSON.parse(fs.readFileSync(path.join(BASE, "package.json")));
+
+function git_hash() {
+ const rev = fs
+ .readFileSync(path.join(GIT_ROOT, ".git", "HEAD"))
+ .toString()
+ .trim()
+ .split(/.*[: ]/)
+ .slice(-1)[0];
+ if (rev.indexOf("/") === -1) {
+ return rev;
+ } else {
+ return fs
+ .readFileSync(path.join(GIT_ROOT, ".git", rev))
+ .toString()
+ .trim();
+ }
+}
+
+// Still commonjs, because axios doesn't work properly under mjs
+export const buildConfig = {
+ entryPoints: ["src/index.ts"],
+ outfile: "dist/taler-harness-bundled.cjs",
+ bundle: true,
+ minify: false,
+ target: ["es2020"],
+ format: "cjs",
+ platform: "node",
+ sourcemap: true,
+ inject: ["src/import-meta-url.js"],
+ define: {
+ __VERSION__: `"${_package.version}"`,
+ __GIT_HASH__: `"${GIT_HASH}"`,
+ "walletCoreBuildInfo.implementationSemver": `"${_package.version}"`,
+ "walletCoreBuildInfo.implementationGitHash": `"${GIT_HASH}"`,
+ "import.meta.url": "import_meta_url",
+ },
+};
+
+esbuild.build(buildConfig).catch((e) => {
+ console.log(e);
+ process.exit(1);
+});
diff --git a/debian/README b/packages/taler-harness/debian/README
index 2b4f83aa2..fb451a71d 100644
--- a/debian/README
+++ b/packages/taler-harness/debian/README
@@ -1,7 +1,8 @@
-For the moment, building the debian package needs
-a preliminary manual step to compile the taler-wallet-cli
+For the moment, building the Debian package needs
+a preliminary manual step to install the taler-harness
Node.JS package. In the future, this will either be invoked
by DH, or added as packaging instructions to Debian.
-$ pnpm run compile
+$ ./configure --prefix=/usr
+$ make install
$ dpkg-buildpackage -rfakeroot -b -uc -us
diff --git a/packages/taler-harness/debian/changelog b/packages/taler-harness/debian/changelog
new file mode 100644
index 000000000..0862b1aa3
--- /dev/null
+++ b/packages/taler-harness/debian/changelog
@@ -0,0 +1,51 @@
+taler-harness (0.10.6) unstable; urgency=low
+
+ * Release 0.10.6
+
+ -- Florian Dold <dold@taler.net> Wed, 10 Apr 2024 15:19:33 +0200
+
+taler-harness (0.10.5) unstable; urgency=low
+
+ * Release 0.10.5
+
+ -- Florian Dold <dold@taler.net> Tue, 09 Apr 2024 16:13:58 +0200
+
+taler-harness (0.10.4) unstable; urgency=low
+
+ * Release 0.10.4
+
+ -- Florian Dold <dold@taler.net> Tue, 09 Apr 2024 14:39:44 +0200
+
+taler-harness (0.9.4a) unstable; urgency=low
+
+ * Release v0.9.4a.
+
+ -- Florian Dold <dold@taler.net> Thu, 07 Mar 2024 23:43:05 +0100
+
+taler-harness (0.9.3-1) unstable; urgency=low
+
+ * Misc bugfixes.
+
+ -- Christian Grothoff <grothoff@gnu.org> Tue, 13 Dec 2023 18:53:15 -0700
+
+taler-harness (0.9.3) unstable; urgency=low
+
+ * First release for GNU Taler v0.9.3.
+
+ -- Christian Grothoff <grothoff@gnu.org> Wed, 29 Nov 2023 08:53:15 -0800
+
+taler-harness (0.9.2-2) unstable; urgency=low
+
+ * Release with various Debian package fixes.
+
+ -- Christian Grothoff <grothoff@gnu.org> Sat, 3 Mar 2023 23:47:15 -0300
+
+taler-harness (0.9.2) unstable; urgency=low
+
+ * Official 0.9.2 release.
+
+ -- Christian Grothoff <grothoff@gnu.org> Sat, 25 Feb 2023 12:47:15 -0300
+
+Local variables:
+mode: debian-changelog
+End:
diff --git a/debian/control b/packages/taler-harness/debian/control
index d44a2e3ee..c57b01202 100644
--- a/debian/control
+++ b/packages/taler-harness/debian/control
@@ -1,4 +1,4 @@
-Source: taler-wallet-cli
+Source: taler-harness
Section: networking
Priority: optional
Maintainer: Taler Systems SA <deb@taler.net>
@@ -8,7 +8,7 @@ Standards-Version: 4.1.0
Vcs-Git: https://git.taler.net/wallet-core.git
Homepage: https://taler.net/
-Package: taler-wallet-cli
+Package: taler-harness
Architecture: all
Depends: nodejs,
${misc:Depends}
diff --git a/packages/taler-harness/debian/rules b/packages/taler-harness/debian/rules
new file mode 100755
index 000000000..0a8d6408c
--- /dev/null
+++ b/packages/taler-harness/debian/rules
@@ -0,0 +1,19 @@
+#!/usr/bin/make -f
+
+%:
+ dh ${@}
+
+# Override because our configure doesn't like extra arguments.
+override_dh_auto_configure:
+ ./configure --prefix=/usr
+
+override_dh_builddeb:
+ dh_builddeb -- -Zgzip
+
+# Override this step because it's very slow and likely
+# unnecessary for us.
+override_dh_strip_nondeterminism:
+ true
+
+get-orig-source:
+ uscan --force-download --rename
diff --git a/packages/taler-harness/package.json b/packages/taler-harness/package.json
new file mode 100644
index 000000000..4b08cab89
--- /dev/null
+++ b/packages/taler-harness/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@gnu-taler/taler-harness",
+ "version": "0.10.6",
+ "description": "",
+ "engines": {
+ "node": ">=0.12.0"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git://git.taler.net/wallet-core.git"
+ },
+ "author": "Florian Dold",
+ "license": "GPL-3.0",
+ "bin": {
+ "taler-harness": "./bin/taler-harness.mjs"
+ },
+ "type": "module",
+ "scripts": {
+ "compile": "tsc && ./build.mjs",
+ "check": "tsc",
+ "test": "tsc",
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "clean": "rm -rf lib dist tsconfig.tsbuildinfo",
+ "pretty": "prettier --write src"
+ },
+ "files": [
+ "AUTHORS",
+ "README",
+ "COPYING",
+ "bin/",
+ "dist/node",
+ "src/"
+ ],
+ "devDependencies": {
+ "@types/node": "^18.11.17",
+ "esbuild": "^0.19.9",
+ "prettier": "^3.1.1",
+ "typescript": "^5.3.3"
+ },
+ "dependencies": {
+ "@gnu-taler/taler-util": "workspace:*",
+ "@gnu-taler/taler-wallet-core": "workspace:*",
+ "tslib": "^2.6.2"
+ }
+}
diff --git a/packages/taler-harness/src/bench1.ts b/packages/taler-harness/src/bench1.ts
new file mode 100644
index 000000000..428114e0e
--- /dev/null
+++ b/packages/taler-harness/src/bench1.ts
@@ -0,0 +1,190 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ buildCodecForObject,
+ codecForBoolean,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ j2s,
+ Logger,
+} from "@gnu-taler/taler-util";
+import {
+ AccessStats,
+ applyRunConfigDefaults,
+ createNativeWalletHost2,
+ Wallet,
+ WalletApiOperation,
+} from "@gnu-taler/taler-wallet-core";
+import { harnessHttpLib } from "./harness/harness.js";
+
+/**
+ * Entry point for the benchmark.
+ *
+ * The benchmark runs against an existing Taler deployment and does not
+ * set up its own services.
+ */
+export async function runBench1(configJson: any): Promise<void> {
+ const logger = new Logger("Bench1");
+
+ // Validate the configuration file for this benchmark.
+ const b1conf = codecForBench1Config().decode(configJson);
+
+ const numIter = b1conf.iterations ?? 1;
+ const numDeposits = b1conf.deposits ?? 5;
+ const restartWallet = b1conf.restartAfter ?? 20;
+
+ const withdrawOnly = b1conf.withdrawOnly ?? false;
+ const withdrawAmount = (numDeposits + 1) * 10;
+
+ logger.info(
+ `Starting Benchmark iterations=${numIter} deposits=${numDeposits}`,
+ );
+
+ const trustExchange = !!process.env["TALER_WALLET_INSECURE_TRUST_EXCHANGE"];
+ if (trustExchange) {
+ logger.info("trusting exchange (not validating signatures)");
+ } else {
+ logger.info("not trusting exchange (validating signatures)");
+ }
+
+ let wallet = {} as Wallet;
+ let getDbStats: () => AccessStats;
+
+ for (let i = 0; i < numIter; i++) {
+ // Create a new wallet in each iteration
+ // otherwise the TPS go down
+ // my assumption is that the in-memory db file gets too large
+ if (i % restartWallet == 0) {
+ if (Object.keys(wallet).length !== 0) {
+ wallet.stop();
+ console.log("wallet DB stats", j2s(getDbStats!()));
+ }
+
+ const res = await createNativeWalletHost2({
+ // No persistent DB storage.
+ persistentStoragePath: undefined,
+ httpLib: harnessHttpLib,
+ });
+ wallet = res.wallet;
+ getDbStats = res.getDbStats;
+ await wallet.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ insecureTrustExchange: trustExchange,
+ },
+ features: {},
+ },
+ });
+ }
+
+ logger.trace(`Starting withdrawal amount=${withdrawAmount}`);
+ let start = Date.now();
+
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: (b1conf.currency + ":" + withdrawAmount) as AmountString,
+ corebankApiBaseUrl: b1conf.bank,
+ exchangeBaseUrl: b1conf.exchange,
+ });
+
+ await wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+
+ logger.info(
+ `Finished withdrawal amount=${withdrawAmount} time=${Date.now() - start}`,
+ );
+
+ if (!withdrawOnly) {
+ for (let i = 0; i < numDeposits; i++) {
+ logger.trace(`Starting deposit amount=10`);
+ start = Date.now();
+
+ await wallet.client.call(WalletApiOperation.CreateDepositGroup, {
+ amount: (b1conf.currency + ":10") as AmountString,
+ depositPaytoUri: b1conf.payto,
+ });
+
+ await wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+
+ logger.info(`Finished deposit amount=10 time=${Date.now() - start}`);
+ }
+ }
+ }
+
+ wallet.stop();
+ console.log("wallet DB stats", j2s(getDbStats!()));
+}
+
+/**
+ * Format of the configuration file passed to the benchmark
+ */
+interface Bench1Config {
+ /**
+ * Base URL of the bank.
+ */
+ bank: string;
+
+ /**
+ * Payto url for deposits.
+ */
+ payto: string;
+
+ /**
+ * Base URL of the exchange.
+ */
+ exchange: string;
+
+ /**
+ * How many withdraw/deposit iterations should be made?
+ * Defaults to 1.
+ */
+ iterations?: number;
+
+ currency: string;
+
+ deposits?: number;
+
+ /**
+ * How any iterations run until the wallet db gets purged
+ * Defaults to 20.
+ */
+ restartAfter?: number;
+
+ withdrawOnly?: boolean;
+}
+
+/**
+ * Schema validation codec for Bench1Config.
+ */
+const codecForBench1Config = () =>
+ buildCodecForObject<Bench1Config>()
+ .property("bank", codecForString())
+ .property("payto", codecForString())
+ .property("exchange", codecForString())
+ .property("iterations", codecOptional(codecForNumber()))
+ .property("deposits", codecOptional(codecForNumber()))
+ .property("currency", codecForString())
+ .property("restartAfter", codecOptional(codecForNumber()))
+ .property("withdrawOnly", codecOptional(codecForBoolean()))
+ .build("Bench1Config");
diff --git a/packages/taler-harness/src/bench2.ts b/packages/taler-harness/src/bench2.ts
new file mode 100644
index 000000000..90924caec
--- /dev/null
+++ b/packages/taler-harness/src/bench2.ts
@@ -0,0 +1,186 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ buildCodecForObject,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ Logger,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import {
+ applyRunConfigDefaults,
+ CryptoDispatcher,
+ SynchronousCryptoWorkerFactoryPlain,
+ Wallet,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ checkReserve,
+ createTestingReserve,
+ depositCoin,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ refreshCoin,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core/dbless";
+
+/**
+ * Entry point for the benchmark.
+ *
+ * The benchmark runs against an existing Taler deployment and does not
+ * set up its own services.
+ */
+export async function runBench2(configJson: any): Promise<void> {
+ const logger = new Logger("Bench2");
+
+ // Validate the configuration file for this benchmark.
+ const benchConf = codecForBench2Config().decode(configJson);
+ const curr = benchConf.currency;
+ const cryptoDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptoDisp.cryptoApi;
+
+ const http = createPlatformHttpLib({
+ enableThrottling: false,
+ });
+
+ const numIter = benchConf.iterations ?? 1;
+ const numDeposits = benchConf.deposits ?? 5;
+
+ const reserveAmount = (numDeposits + 1) * 10;
+
+ const defaultConfig = applyRunConfigDefaults();
+
+ for (let i = 0; i < numIter; i++) {
+ const exchangeInfo = await downloadExchangeInfo(benchConf.exchange, http);
+
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+
+ console.log("creating fakebank reserve");
+
+ await createTestingReserve({
+ amount: `${curr}:${reserveAmount}`,
+ exchangeInfo,
+ corebankApiBaseUrl: benchConf.bank,
+ http,
+ reservePub: reserveKeyPair.pub,
+ });
+
+ console.log("waiting for reserve");
+
+ await checkReserve(http, benchConf.exchange, reserveKeyPair.pub);
+
+ console.log("reserve found");
+
+ const d1 = findDenomOrThrow(exchangeInfo, `${curr}:8` as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ });
+
+ for (let j = 0; j < numDeposits; j++) {
+ console.log("withdrawing coin");
+ const coin = await withdrawCoin({
+ http,
+ cryptoApi,
+ reserveKeyPair: {
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ },
+ denom: d1,
+ exchangeBaseUrl: benchConf.exchange,
+ });
+
+ console.log("depositing coin");
+
+ await depositCoin({
+ amount: `${curr}:4` as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: benchConf.exchange,
+ http,
+ depositPayto: benchConf.payto,
+ });
+
+ const refreshDenoms = [
+ findDenomOrThrow(exchangeInfo, `${curr}:1` as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ }),
+ findDenomOrThrow(exchangeInfo, `${curr}:1` as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ }),
+ ];
+
+ console.log("refreshing coin");
+
+ await refreshCoin({
+ oldCoin: coin,
+ cryptoApi,
+ http,
+ newDenoms: refreshDenoms,
+ });
+
+ console.log("refresh done");
+ }
+ }
+}
+
+/**
+ * Format of the configuration file passed to the benchmark
+ */
+interface Bench2Config {
+ /**
+ * Base URL of the bank.
+ */
+ bank: string;
+
+ /**
+ * Payto url for deposits.
+ */
+ payto: string;
+
+ /**
+ * Base URL of the exchange.
+ */
+ exchange: string;
+
+ /**
+ * How many withdraw/deposit iterations should be made?
+ * Defaults to 1.
+ */
+ iterations?: number;
+
+ currency: string;
+
+ deposits?: number;
+}
+
+/**
+ * Schema validation codec for Bench1Config.
+ */
+const codecForBench2Config = () =>
+ buildCodecForObject<Bench2Config>()
+ .property("bank", codecForString())
+ .property("payto", codecForString())
+ .property("exchange", codecForString())
+ .property("iterations", codecOptional(codecForNumber()))
+ .property("deposits", codecOptional(codecForNumber()))
+ .property("currency", codecForString())
+ .build("Bench2Config");
diff --git a/packages/taler-harness/src/bench3.ts b/packages/taler-harness/src/bench3.ts
new file mode 100644
index 000000000..f138dff68
--- /dev/null
+++ b/packages/taler-harness/src/bench3.ts
@@ -0,0 +1,211 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ buildCodecForObject,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ j2s,
+ Logger,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import {
+ AccessStats,
+ createNativeWalletHost2,
+ Wallet,
+ WalletApiOperation,
+} from "@gnu-taler/taler-wallet-core";
+import benchMerchantIDGenerator from "./benchMerchantIDGenerator.js";
+
+/**
+ * Entry point for the benchmark.
+ *
+ * The benchmark runs against an existing Taler deployment and does not
+ * set up its own services.
+ */
+export async function runBench3(configJson: any): Promise<void> {
+ const logger = new Logger("Bench3");
+
+ // Validate the configuration file for this benchmark.
+ const b3conf = codecForBench3Config().decode(configJson);
+
+ if (!b3conf.paytoTemplate.includes("${id")) {
+ throw new Error("Payto template url must contain '${id}' placeholder");
+ }
+
+ const myHttpLib = createPlatformHttpLib({
+ enableThrottling: false,
+ });
+
+ const numIter = b3conf.iterations ?? 1;
+ const numDeposits = b3conf.deposits ?? 5;
+ const restartWallet = b3conf.restartAfter ?? 20;
+
+ const withdrawAmount = (numDeposits + 1) * 10;
+
+ const IDGenerator = benchMerchantIDGenerator(
+ b3conf.randomAlg,
+ b3conf.numMerchants ?? 100,
+ );
+
+ logger.info(
+ `Starting Benchmark iterations=${numIter} deposits=${numDeposits} with ${b3conf.randomAlg} merchant selection`,
+ );
+
+ const trustExchange = !!process.env["TALER_WALLET_INSECURE_TRUST_EXCHANGE"];
+ if (trustExchange) {
+ logger.info("trusting exchange (not validating signatures)");
+ } else {
+ logger.info("not trusting exchange (validating signatures)");
+ }
+ let wallet = {} as Wallet;
+ let getDbStats: () => AccessStats;
+
+ for (let i = 0; i < numIter; i++) {
+ // Create a new wallet in each iteration
+ // otherwise the TPS go down
+ // my assumption is that the in-memory db file gets too large
+ if (i % restartWallet == 0) {
+ if (Object.keys(wallet).length !== 0) {
+ wallet.stop();
+ console.log("wallet DB stats", j2s(getDbStats!()));
+ }
+
+ const res = await createNativeWalletHost2({
+ // No persistent DB storage.
+ persistentStoragePath: undefined,
+ httpLib: myHttpLib,
+ });
+ wallet = res.wallet;
+ getDbStats = res.getDbStats;
+ await wallet.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ features: {},
+ testing: {
+ insecureTrustExchange: trustExchange,
+ },
+ },
+ });
+ }
+
+ logger.trace(`Starting withdrawal amount=${withdrawAmount}`);
+ let start = Date.now();
+
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: (b3conf.currency + ":" + withdrawAmount) as AmountString,
+ corebankApiBaseUrl: b3conf.bank,
+ exchangeBaseUrl: b3conf.exchange,
+ });
+
+ await wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+
+ logger.info(
+ `Finished withdrawal amount=${withdrawAmount} time=${Date.now() - start}`,
+ );
+
+ for (let i = 0; i < numDeposits; i++) {
+ logger.trace(`Starting deposit amount=10`);
+ start = Date.now();
+
+ let merchID = IDGenerator.getRandomMerchantID();
+ let payto = b3conf.paytoTemplate.replace("${id}", merchID.toString());
+
+ await wallet.client.call(WalletApiOperation.CreateDepositGroup, {
+ amount: (b3conf.currency + ":10") as AmountString,
+ depositPaytoUri: payto,
+ });
+
+ await wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+
+ logger.info(`Finished deposit amount=10 time=${Date.now() - start}`);
+ }
+ }
+
+ wallet.stop();
+ console.log("wallet DB stats", j2s(getDbStats!()));
+}
+
+/**
+ * Format of the configuration file passed to the benchmark
+ */
+interface Bench3Config {
+ /**
+ * Base URL of the bank.
+ */
+ bank: string;
+
+ /**
+ * Payto url template for deposits, must contain '${id}' for replacements.
+ */
+ paytoTemplate: string;
+
+ /**
+ * Base URL of the exchange.
+ */
+ exchange: string;
+
+ /**
+ * How many withdraw/deposit iterations should be made?
+ * Defaults to 1.
+ */
+ iterations?: number;
+
+ currency: string;
+
+ deposits?: number;
+
+ /**
+ * How any iterations run until the wallet db gets purged
+ * Defaults to 20.
+ */
+ restartAfter?: number;
+
+ /**
+ * Number of merchants to select from randomly
+ */
+ numMerchants?: number;
+
+ /**
+ * Which random generator to use.
+ * Possible values: 'zipf', 'rand'
+ */
+ randomAlg: string;
+}
+
+/**
+ * Schema validation codec for Bench1Config.
+ */
+const codecForBench3Config = () =>
+ buildCodecForObject<Bench3Config>()
+ .property("bank", codecForString())
+ .property("paytoTemplate", codecForString())
+ .property("numMerchants", codecOptional(codecForNumber()))
+ .property("randomAlg", codecForString())
+ .property("exchange", codecForString())
+ .property("iterations", codecOptional(codecForNumber()))
+ .property("deposits", codecOptional(codecForNumber()))
+ .property("currency", codecForString())
+ .property("restartAfter", codecOptional(codecForNumber()))
+ .build("Bench1Config");
diff --git a/packages/taler-harness/src/benchMerchantIDGenerator.ts b/packages/taler-harness/src/benchMerchantIDGenerator.ts
new file mode 100644
index 000000000..89b26dc81
--- /dev/null
+++ b/packages/taler-harness/src/benchMerchantIDGenerator.ts
@@ -0,0 +1,84 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author: Boss Marco
+ */
+
+const getRandomInt = function (max: number) {
+ return Math.floor(Math.random() * max);
+};
+
+abstract class BenchMerchantIDGenerator {
+ abstract getRandomMerchantID(): number;
+}
+
+class ZipfGenerator extends BenchMerchantIDGenerator {
+ weights: number[];
+ total_weight: number;
+
+ constructor(numMerchants: number) {
+ super();
+ this.weights = new Array<number>(numMerchants);
+ for (var i = 0; i < this.weights.length; i++) {
+ /* we use integers (floor), make sure we have big enough values
+ * by multiplying with
+ * numMerchants again */
+ this.weights[i] = Math.floor((numMerchants / (i + 1)) * numMerchants);
+ }
+ this.total_weight = this.weights.reduce((p, n) => p + n);
+ }
+
+ getRandomMerchantID(): number {
+ let random = getRandomInt(this.total_weight);
+ let current = 0;
+
+ for (var i = 0; i < this.weights.length; i++) {
+ current += this.weights[i];
+ if (random <= current) {
+ return i + 1;
+ }
+ }
+
+ /* should never come here */
+ return getRandomInt(this.weights.length);
+ }
+}
+
+class RandomGenerator extends BenchMerchantIDGenerator {
+ max: number;
+
+ constructor(numMerchants: number) {
+ super();
+ this.max = numMerchants;
+ }
+
+ getRandomMerchantID() {
+ return getRandomInt(this.max);
+ }
+}
+
+export default function (
+ type: string,
+ maxID: number,
+): BenchMerchantIDGenerator {
+ switch (type) {
+ case "zipf":
+ return new ZipfGenerator(maxID);
+ case "rand":
+ return new RandomGenerator(maxID);
+ default:
+ throw new Error("Valid types are 'zipf' and 'rand'");
+ }
+}
diff --git a/packages/taler-harness/src/env-full.ts b/packages/taler-harness/src/env-full.ts
new file mode 100644
index 000000000..bb2cb8c47
--- /dev/null
+++ b/packages/taler-harness/src/env-full.ts
@@ -0,0 +1,101 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { Duration, j2s, URL } from "@gnu-taler/taler-util";
+import { CoinConfig, defaultCoinConfig } from "./harness/denomStructures.js";
+import {
+ GlobalTestState,
+ setupDb,
+ ExchangeService,
+ FakebankService,
+ MerchantService,
+ generateRandomPayto,
+} from "./harness/harness.js";
+
+/**
+ * Entry point for the full Taler test environment.
+ */
+export async function runEnvFull(t: GlobalTestState): Promise<void> {
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ console.log("exchange bank account", j2s(exchangeBankAccount));
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ console.log("setup done!");
+}
diff --git a/packages/taler-wallet-cli/src/env1.ts b/packages/taler-harness/src/env1.ts
index eb7da352c..aec0b7b8f 100644
--- a/packages/taler-wallet-cli/src/env1.ts
+++ b/packages/taler-harness/src/env1.ts
@@ -22,8 +22,8 @@ import { CoinConfig, defaultCoinConfig } from "./harness/denomStructures.js";
import {
GlobalTestState,
setupDb,
- FakeBankService,
ExchangeService,
+ FakebankService,
} from "./harness/harness.js";
/**
@@ -35,9 +35,11 @@ import {
export async function runEnv1(t: GlobalTestState): Promise<void> {
const db = await setupDb(t);
- const bank = await FakeBankService.create(t, {
+ const bank = await FakebankService.create(t, {
currency: "TESTKUDOS",
httpPort: 8082,
+ allowRegistrations: true,
+ database: db.connStr,
});
const exchange = ExchangeService.create(t, {
diff --git a/packages/taler-wallet-cli/src/harness/denomStructures.ts b/packages/taler-harness/src/harness/denomStructures.ts
index 5ab9aca00..2d5e719b0 100644
--- a/packages/taler-wallet-cli/src/harness/denomStructures.ts
+++ b/packages/taler-harness/src/harness/denomStructures.ts
@@ -14,7 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-export interface CoinConfig {
+export interface CoinCoinfigCommon {
name: string;
value: string;
durationWithdraw: string;
@@ -24,10 +24,25 @@ export interface CoinConfig {
feeDeposit: string;
feeRefresh: string;
feeRefund: string;
+ ageRestricted?: boolean;
+}
+
+export interface CoinConfigRsa extends CoinCoinfigCommon {
+ cipher: "RSA";
rsaKeySize: number;
}
-const coinCommon = {
+/**
+ * Clause Schnorr coin config.
+ */
+export interface CoinConfigCs extends CoinCoinfigCommon {
+ cipher: "CS";
+}
+
+export type CoinConfig = CoinConfigRsa | CoinConfigCs;
+
+const coinRsaCommon = {
+ cipher: "RSA" as const,
durationLegal: "3 years",
durationSpend: "2 years",
durationWithdraw: "7 days",
@@ -35,7 +50,7 @@ const coinCommon = {
};
export const coin_ct1 = (curr: string): CoinConfig => ({
- ...coinCommon,
+ ...coinRsaCommon,
name: `${curr}_ct1`,
value: `${curr}:0.01`,
feeDeposit: `${curr}:0.00`,
@@ -45,7 +60,7 @@ export const coin_ct1 = (curr: string): CoinConfig => ({
});
export const coin_ct10 = (curr: string): CoinConfig => ({
- ...coinCommon,
+ ...coinRsaCommon,
name: `${curr}_ct10`,
value: `${curr}:0.10`,
feeDeposit: `${curr}:0.01`,
@@ -55,7 +70,7 @@ export const coin_ct10 = (curr: string): CoinConfig => ({
});
export const coin_u1 = (curr: string): CoinConfig => ({
- ...coinCommon,
+ ...coinRsaCommon,
name: `${curr}_u1`,
value: `${curr}:1`,
feeDeposit: `${curr}:0.02`,
@@ -65,7 +80,7 @@ export const coin_u1 = (curr: string): CoinConfig => ({
});
export const coin_u2 = (curr: string): CoinConfig => ({
- ...coinCommon,
+ ...coinRsaCommon,
name: `${curr}_u2`,
value: `${curr}:2`,
feeDeposit: `${curr}:0.02`,
@@ -75,7 +90,7 @@ export const coin_u2 = (curr: string): CoinConfig => ({
});
export const coin_u4 = (curr: string): CoinConfig => ({
- ...coinCommon,
+ ...coinRsaCommon,
name: `${curr}_u4`,
value: `${curr}:4`,
feeDeposit: `${curr}:0.02`,
@@ -85,7 +100,7 @@ export const coin_u4 = (curr: string): CoinConfig => ({
});
export const coin_u8 = (curr: string): CoinConfig => ({
- ...coinCommon,
+ ...coinRsaCommon,
name: `${curr}_u8`,
value: `${curr}:8`,
feeDeposit: `${curr}:0.16`,
@@ -95,7 +110,7 @@ export const coin_u8 = (curr: string): CoinConfig => ({
});
const coin_u10 = (curr: string): CoinConfig => ({
- ...coinCommon,
+ ...coinRsaCommon,
name: `${curr}_u10`,
value: `${curr}:10`,
feeDeposit: `${curr}:0.2`,
@@ -114,16 +129,6 @@ export const defaultCoinConfig = [
coin_u10,
];
-const coinCheapCommon = (curr: string) => ({
- durationLegal: "3 years",
- durationSpend: "2 years",
- durationWithdraw: "7 days",
- rsaKeySize: 1024,
- feeRefresh: `${curr}:0.2`,
- feeRefund: `${curr}:0.2`,
- feeWithdraw: `${curr}:0.2`,
-});
-
export function makeNoFeeCoinConfig(curr: string): CoinConfig[] {
const cc: CoinConfig[] = [];
@@ -131,9 +136,10 @@ export function makeNoFeeCoinConfig(curr: string): CoinConfig[] {
const ct = 2 ** i;
const unit = Math.floor(ct / 100);
- const cent = ct % 100;
+ const cent = `${ct % 100}`.padStart(2, "0");
cc.push({
+ cipher: "RSA",
durationLegal: "3 years",
durationSpend: "2 years",
durationWithdraw: "7 days",
diff --git a/packages/taler-wallet-cli/src/harness/faultInjection.ts b/packages/taler-harness/src/harness/faultInjection.ts
index 4c3d0c123..f4d7fc4b9 100644
--- a/packages/taler-wallet-cli/src/harness/faultInjection.ts
+++ b/packages/taler-harness/src/harness/faultInjection.ts
@@ -47,6 +47,10 @@ export interface FaultInjectionRequestContext {
requestHeaders: Record<string, string | string[] | undefined>;
requestBody?: Buffer;
dropRequest: boolean;
+ // These are only used when the request is dropped
+ substituteResponseBody?: Buffer;
+ substituteResponseStatusCode?: number;
+ substituteResponseHeaders?: Record<string, string | string[] | undefined>;
}
export interface FaultInjectionResponseContext {
@@ -101,7 +105,18 @@ export class FaultProxy {
}
if (faultReqContext.dropRequest) {
- res.destroy();
+ if (faultReqContext.substituteResponseStatusCode) {
+ const statusCode = faultReqContext.substituteResponseStatusCode;
+ res.writeHead(
+ statusCode,
+ http.STATUS_CODES[statusCode],
+ faultReqContext.substituteResponseHeaders,
+ );
+ res.write(faultReqContext.substituteResponseBody);
+ res.end();
+ } else {
+ res.destroy();
+ }
return;
}
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts
new file mode 100644
index 000000000..68c0744fc
--- /dev/null
+++ b/packages/taler-harness/src/harness/harness.ts
@@ -0,0 +1,2255 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Test harness for various GNU Taler components.
+ * Also provides a fault-injection proxy.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports
+ */
+import {
+ AccountAddDetails,
+ AccountRestriction,
+ AmountJson,
+ Amounts,
+ Configuration,
+ CoreApiResponse,
+ Duration,
+ EddsaKeyPair,
+ Logger,
+ MerchantInstanceConfig,
+ PartialMerchantInstanceConfig,
+ TalerCorebankApiClient,
+ TalerError,
+ WalletNotification,
+ createEddsaKeyPair,
+ eddsaGetPublic,
+ encodeCrock,
+ hash,
+ j2s,
+ openPromise,
+ parsePaytoUri,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ createPlatformHttpLib,
+ expectSuccessResponseOrThrow,
+} from "@gnu-taler/taler-util/http";
+import {
+ WalletCoreApiClient,
+ WalletCoreRequestType,
+ WalletCoreResponseType,
+ WalletOperations,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ RemoteWallet,
+ WalletNotificationWaiter,
+ createRemoteWallet,
+ getClientFromRemoteWallet,
+ makeNotificationWaiter,
+} from "@gnu-taler/taler-wallet-core/remote";
+import { deepStrictEqual } from "assert";
+import { ChildProcess, spawn } from "child_process";
+import * as fs from "fs";
+import * as http from "http";
+import * as net from "node:net";
+import * as path from "path";
+import * as readline from "readline";
+import { CoinConfig } from "./denomStructures.js";
+
+const logger = new Logger("harness.ts");
+
+export async function delayMs(ms: number): Promise<void> {
+ return new Promise((resolve, reject) => {
+ setTimeout(() => resolve(), ms);
+ });
+}
+
+export interface WithAuthorization {
+ Authorization?: string;
+}
+
+interface WaitResult {
+ code: number | null;
+ signal: NodeJS.Signals | null;
+}
+
+class CommandError extends Error {
+ constructor(
+ public message: string,
+ public logName: string,
+ public command: string,
+ public args: string[],
+ public env: Env,
+ public code: number | null,
+ ) {
+ super(message);
+ }
+}
+interface Env {
+ [index: string]: string | undefined;
+}
+/**
+ * Run a shell command, return stdout.
+ */
+export async function sh(
+ t: GlobalTestState,
+ logName: string,
+ command: string,
+ env: Env = process.env,
+): Promise<string> {
+ logger.trace(`running command ${command}`);
+ return new Promise((resolve, reject) => {
+ const stdoutChunks: Buffer[] = [];
+ const proc = spawn(command, {
+ stdio: ["inherit", "pipe", "pipe"],
+ shell: true,
+ env,
+ });
+ proc.stdout.on("data", (x) => {
+ if (x instanceof Buffer) {
+ stdoutChunks.push(x);
+ } else {
+ throw Error("unexpected data chunk type");
+ }
+ });
+ const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
+ const stderrLog = fs.createWriteStream(stderrLogFileName, {
+ flags: "a",
+ });
+ proc.stderr.pipe(stderrLog);
+ proc.on("exit", (code, signal) => {
+ logger.info(`child process ${logName} exited (${code} / ${signal})`);
+ if (code != 0) {
+ reject(
+ new CommandError(
+ `Unexpected exit code ${code}`,
+ logName,
+ command,
+ [],
+ env,
+ code,
+ ),
+ );
+ return;
+ }
+ const b = Buffer.concat(stdoutChunks).toString("utf-8");
+ resolve(b);
+ });
+ proc.on("error", (err) => {
+ reject(
+ new CommandError(
+ "Child process had error:" + err.message,
+ logName,
+ command,
+ [],
+ env,
+ null,
+ ),
+ );
+ });
+ });
+}
+
+function shellescape(args: string[]) {
+ const ret = args.map((s) => {
+ if (/[^A-Za-z0-9_\/:=-]/.test(s)) {
+ s = "'" + s.replace(/'/g, "'\\''") + "'";
+ s = s.replace(/^(?:'')+/g, "").replace(/\\'''/g, "\\'");
+ }
+ return s;
+ });
+ return ret.join(" ");
+}
+
+/**
+ * Run a shell command, return stdout.
+ *
+ * Log stderr to a log file.
+ */
+export async function runCommand(
+ t: GlobalTestState,
+ logName: string,
+ command: string,
+ args: string[],
+ env: { [index: string]: string | undefined } = process.env,
+): Promise<string> {
+ logger.info(`running command ${shellescape([command, ...args])}`);
+
+ return new Promise((resolve, reject) => {
+ const stdoutChunks: Buffer[] = [];
+ const proc = spawn(command, args, {
+ stdio: ["inherit", "pipe", "pipe"],
+ shell: false,
+ env: env,
+ });
+ proc.stdout.on("data", (x) => {
+ if (x instanceof Buffer) {
+ stdoutChunks.push(x);
+ } else {
+ throw Error("unexpected data chunk type");
+ }
+ });
+ const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
+ const stderrLog = fs.createWriteStream(stderrLogFileName, {
+ flags: "a",
+ });
+ proc.stderr.pipe(stderrLog);
+ proc.on("exit", (code, signal) => {
+ logger.trace(`child process exited (${code} / ${signal})`);
+ if (code != 0) {
+ reject(
+ new CommandError(
+ `Unexpected exit code ${code}`,
+ logName,
+ command,
+ [],
+ env,
+ code,
+ ),
+ );
+ return;
+ }
+ const b = Buffer.concat(stdoutChunks).toString("utf-8");
+ resolve(b);
+ });
+ proc.on("error", (err) => {
+ reject(
+ new CommandError(
+ "Child process had error:" + err.message,
+ logName,
+ command,
+ [],
+ env,
+ null,
+ ),
+ );
+ });
+ });
+}
+
+export class ProcessWrapper {
+ private waitPromise: Promise<WaitResult>;
+ constructor(public proc: ChildProcess) {
+ this.waitPromise = new Promise((resolve, reject) => {
+ proc.on("exit", (code, signal) => {
+ resolve({ code, signal });
+ });
+ proc.on("error", (err) => {
+ reject(err);
+ });
+ });
+ }
+
+ wait(): Promise<WaitResult> {
+ return this.waitPromise;
+ }
+}
+
+export class GlobalTestParams {
+ testDir: string;
+}
+
+export class GlobalTestState {
+ testDir: string;
+ procs: ProcessWrapper[];
+ servers: http.Server[];
+ inShutdown: boolean = false;
+ constructor(params: GlobalTestParams) {
+ this.testDir = params.testDir;
+ this.procs = [];
+ this.servers = [];
+ }
+
+ async assertThrowsTalerErrorAsync(
+ block: () => Promise<void>,
+ ): Promise<TalerError> {
+ try {
+ await block();
+ } catch (e) {
+ if (e instanceof TalerError) {
+ return e;
+ }
+ throw Error(`expected TalerError to be thrown, but got ${e}`);
+ }
+ throw Error(
+ `expected TalerError to be thrown, but block finished without throwing`,
+ );
+ }
+
+ async assertThrowsAsync(block: () => Promise<void>): Promise<any> {
+ try {
+ await block();
+ } catch (e) {
+ return e;
+ }
+ throw Error(
+ `expected exception to be thrown, but block finished without throwing`,
+ );
+ }
+
+ assertTrue(b: boolean): asserts b {
+ if (!b) {
+ throw Error("test assertion failed");
+ }
+ }
+
+ assertDeepEqual<T>(actual: any, expected: T): asserts actual is T {
+ deepStrictEqual(actual, expected);
+ }
+
+ assertAmountEquals(
+ amtActual: string | AmountJson,
+ amtExpected: string | AmountJson,
+ ): void {
+ if (Amounts.cmp(amtActual, amtExpected) != 0) {
+ throw Error(
+ `test assertion failed: expected ${Amounts.stringify(
+ amtExpected,
+ )} but got ${Amounts.stringify(amtActual)}`,
+ );
+ }
+ }
+
+ assertAmountLeq(a: string | AmountJson, b: string | AmountJson): void {
+ if (Amounts.cmp(a, b) > 0) {
+ throw Error(
+ `test assertion failed: expected ${Amounts.stringify(
+ a,
+ )} to be less or equal (leq) than ${Amounts.stringify(b)}`,
+ );
+ }
+ }
+
+ shutdownSync(): void {
+ for (const s of this.servers) {
+ s.close();
+ s.removeAllListeners();
+ }
+ for (const p of this.procs) {
+ if (p.proc.exitCode == null) {
+ p.proc.kill("SIGTERM");
+ }
+ }
+ }
+
+ spawnService(
+ command: string,
+ args: string[],
+ logName: string,
+ env: { [index: string]: string | undefined } = process.env,
+ ): ProcessWrapper {
+ logger.info(
+ `spawning process (${logName}): ${shellescape([command, ...args])}`,
+ );
+ const proc = spawn(command, args, {
+ stdio: ["inherit", "pipe", "pipe"],
+ env: env,
+ });
+ logger.trace(`spawned process (${logName}) with pid ${proc.pid}`);
+ proc.on("error", (err) => {
+ logger.warn(`could not start process (${command})`, err);
+ });
+ proc.on("exit", (code, signal) => {
+ if (code == 0 && signal == null) {
+ logger.trace(`process ${logName} exited with success`);
+ } else {
+ logger.warn(`process ${logName} exited ${j2s({ code, signal })}`);
+ }
+ });
+ const stderrLogFileName = this.testDir + `/${logName}-stderr.log`;
+ const stderrLog = fs.createWriteStream(stderrLogFileName, {
+ flags: "a",
+ });
+ proc.stderr.pipe(stderrLog);
+ const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`;
+ const stdoutLog = fs.createWriteStream(stdoutLogFileName, {
+ flags: "a",
+ });
+ proc.stdout.pipe(stdoutLog);
+ const procWrap = new ProcessWrapper(proc);
+ this.procs.push(procWrap);
+ return procWrap;
+ }
+
+ async shutdown(): Promise<void> {
+ if (this.inShutdown) {
+ return;
+ }
+ if (shouldLingerInTest()) {
+ logger.trace("refusing to shut down, lingering was requested");
+ return;
+ }
+ this.inShutdown = true;
+ logger.trace("shutting down");
+ for (const s of this.servers) {
+ s.close();
+ s.removeAllListeners();
+ }
+ for (const p of this.procs) {
+ if (p.proc.exitCode == null) {
+ logger.trace(`killing process ${p.proc.pid}`);
+ p.proc.kill("SIGTERM");
+ await p.wait();
+ }
+ }
+ }
+
+ /**
+ * Log that the test arrived a certain step.
+ *
+ * The step name should be unique across the whole
+ */
+ logStep(stepName: string): void {
+ // Now we just log, later we may report the steps that were done
+ // to easily see where the test hangs.
+ console.info(`STEP: ${stepName}`);
+ }
+}
+
+export function shouldLingerInTest(): boolean {
+ return !!process.env["TALER_TEST_LINGER"];
+}
+
+export interface TalerConfigSection {
+ options: Record<string, string | undefined>;
+}
+
+export interface TalerConfig {
+ sections: Record<string, TalerConfigSection>;
+}
+
+export interface DbInfo {
+ /**
+ * Postgres connection string.
+ */
+ connStr: string;
+
+ dbname: string;
+}
+
+export interface SetupDbOpts {
+ nameSuffix?: string;
+}
+
+export async function setupDb(
+ t: GlobalTestState,
+ opts: SetupDbOpts = {},
+): Promise<DbInfo> {
+ let dbname: string;
+ if (!opts.nameSuffix) {
+ dbname = "taler-integrationtest";
+ } else {
+ dbname = `taler-integrationtest-${opts.nameSuffix}`;
+ }
+ try {
+ await runCommand(t, "dropdb", "dropdb", [dbname]);
+ } catch (e: any) {
+ logger.warn(`dropdb failed: ${e.toString()}`);
+ }
+ await runCommand(t, "createdb", "createdb", [dbname]);
+ return {
+ connStr: `postgres:///${dbname}`,
+ dbname,
+ };
+}
+
+/**
+ * Make sure that the taler-integrationtest-shared database exists.
+ * Don't delete it if it already exists.
+ */
+export async function setupSharedDb(t: GlobalTestState): Promise<DbInfo> {
+ const dbname = "taler-integrationtest-shared";
+ const databases = await runCommand(t, "list-dbs", "psql", ["-Aqtl"]);
+ if (databases.indexOf("taler-integrationtest-shared") < 0) {
+ await runCommand(t, "createdb", "createdb", [dbname]);
+ }
+ return {
+ connStr: `postgres:///${dbname}`,
+ dbname,
+ };
+}
+
+export interface BankConfig {
+ currency: string;
+ httpPort: number;
+ database: string;
+ allowRegistrations: boolean;
+ maxDebt?: string;
+ overrideTestDir?: string;
+}
+
+export interface FakeBankConfig {
+ currency: string;
+ httpPort: number;
+}
+
+/**
+ * @param name additional component name, needed when launching multiple instances of the same component
+ */
+function setTalerPaths(config: Configuration, home: string, name?: string) {
+ config.setString("paths", "taler_home", home);
+ // We need to make sure that the path of taler_runtime_dir isn't too long,
+ // as it contains unix domain sockets (108 character limit).
+ const extraName = name != null ? `${name}-` : "";
+ const runDir = fs.mkdtempSync(`/tmp/taler-test-${extraName}`);
+ config.setString("paths", "taler_runtime_dir", runDir);
+ config.setString(
+ "paths",
+ "taler_data_home",
+ "$TALER_HOME/.local/share/taler/",
+ );
+ config.setString("paths", "taler_config_home", "$TALER_HOME/.config/taler/");
+ config.setString("paths", "taler_cache_home", "$TALER_HOME/.config/taler/");
+}
+
+function setCoin(config: Configuration, c: CoinConfig) {
+ const s = `coin_${c.name}`;
+ config.setString(s, "value", c.value);
+ config.setString(s, "duration_withdraw", c.durationWithdraw);
+ config.setString(s, "duration_spend", c.durationSpend);
+ config.setString(s, "duration_legal", c.durationLegal);
+ config.setString(s, "fee_deposit", c.feeDeposit);
+ config.setString(s, "fee_withdraw", c.feeWithdraw);
+ config.setString(s, "fee_refresh", c.feeRefresh);
+ config.setString(s, "fee_refund", c.feeRefund);
+ if (c.ageRestricted) {
+ config.setString(s, "age_restricted", "yes");
+ }
+ if (c.cipher === "RSA") {
+ config.setString(s, "rsa_keysize", `${c.rsaKeySize}`);
+ config.setString(s, "cipher", "RSA");
+ } else if (c.cipher === "CS") {
+ config.setString(s, "cipher", "CS");
+ } else {
+ throw new Error();
+ }
+}
+
+function backoffStart(): number {
+ return 10;
+}
+
+function backoffIncrement(n: number): number {
+ return Math.min(Math.floor(n * 1.5), 1000);
+}
+
+/**
+ * Send an HTTP request until it succeeds or the process dies.
+ */
+export async function pingProc(
+ proc: ProcessWrapper | undefined,
+ url: string,
+ serviceName: string,
+): Promise<void> {
+ if (!proc || proc.proc.exitCode !== null) {
+ throw Error(`service process ${serviceName} not started, can't ping`);
+ }
+ let nextDelay = backoffStart();
+ while (true) {
+ try {
+ logger.trace(`pinging ${serviceName} at ${url}`);
+ const resp = await harnessHttpLib.fetch(url);
+ if (resp.status !== 200) {
+ throw Error("non-200 status code");
+ }
+ logger.trace(`service ${serviceName} available`);
+ return;
+ } catch (e: any) {
+ logger.warn(`service ${serviceName} not ready:`, e.toString());
+ logger.info(`waiting ${nextDelay}ms on ${serviceName}`);
+ await delayMs(nextDelay);
+ nextDelay = backoffIncrement(nextDelay);
+ }
+ if (!proc || proc.proc.exitCode != null || proc.proc.signalCode != null) {
+ throw Error(`service process ${serviceName} stopped unexpectedly`);
+ }
+ }
+}
+
+class BankServiceBase {
+ proc: ProcessWrapper | undefined;
+
+ protected constructor(
+ protected globalTestState: GlobalTestState,
+ protected bankConfig: BankConfig,
+ protected configFile: string,
+ ) {}
+}
+
+export interface HarnessExchangeBankAccount {
+ accountName: string;
+ accountPassword: string;
+ accountPaytoUri: string;
+ wireGatewayApiBaseUrl: string;
+
+ conversionUrl?: string;
+
+ debitRestrictions?: AccountRestriction[];
+ creditRestrictions?: AccountRestriction[];
+
+ /**
+ * If set, the harness will not automatically configure the wire fee for this account.
+ */
+ skipWireFeeCreation?: boolean;
+}
+
+/**
+ * Implementation of the bank service using the "taler-fakebank-run" tool.
+ */
+export class FakebankService
+ extends BankServiceBase
+ implements BankServiceHandle
+{
+ proc: ProcessWrapper | undefined;
+
+ http = createPlatformHttpLib({ enableThrottling: false });
+
+ // We store "created" accounts during setup and
+ // register them after startup.
+ private accounts: {
+ accountName: string;
+ accountPassword: string;
+ }[] = [];
+
+ /**
+ * Create a new fakebank service handle.
+ *
+ * First generates the configuration for the fakebank and
+ * then creates a fakebank handle, but doesn't start the fakebank
+ * service yet.
+ */
+ static async create(
+ gc: GlobalTestState,
+ bc: BankConfig,
+ ): Promise<FakebankService> {
+ const config = new Configuration();
+ const testDir = bc.overrideTestDir ?? gc.testDir;
+ setTalerPaths(config, testDir + "/talerhome");
+ config.setString("taler", "currency", bc.currency);
+ config.setString("bank", "http_port", `${bc.httpPort}`);
+ config.setString("bank", "serve", "http");
+ config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
+ config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`);
+ config.setString("bank", "ram_limit", `${1024}`);
+ const cfgFilename = testDir + "/bank.conf";
+ config.write(cfgFilename, { excludeDefaults: true });
+
+ return new FakebankService(gc, bc, cfgFilename);
+ }
+
+ static fromExistingConfig(
+ gc: GlobalTestState,
+ opts: { overridePath?: string },
+ ): FakebankService {
+ const testDir = opts.overridePath ?? gc.testDir;
+ const cfgFilename = testDir + `/bank.conf`;
+ const config = Configuration.load(cfgFilename);
+ const bc: BankConfig = {
+ allowRegistrations:
+ config.getYesNo("bank", "allow_registrations").orUndefined() ?? true,
+ currency: config.getString("taler", "currency").required(),
+ database: "none",
+ httpPort: config.getNumber("bank", "http_port").required(),
+ maxDebt: config.getString("bank", "max_debt").required(),
+ };
+ return new FakebankService(gc, bc, cfgFilename);
+ }
+
+ setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) {
+ if (!!this.proc) {
+ throw Error("Can't set suggested exchange while bank is running.");
+ }
+ const config = Configuration.load(this.configFile);
+ config.setString("bank", "suggested_exchange", e.baseUrl);
+ config.write(this.configFile, { excludeDefaults: true });
+ }
+
+ get baseUrl(): string {
+ return `http://localhost:${this.bankConfig.httpPort}/`;
+ }
+
+ get corebankApiBaseUrl(): string {
+ return this.baseUrl;
+ }
+
+ // FIXME: Why do we have this function at all?
+ // We now have a unified corebank API, we should just use that
+ // to create bank accounts, also for the exchange.
+ async createExchangeAccount(
+ accountName: string,
+ password: string,
+ ): Promise<HarnessExchangeBankAccount> {
+ this.accounts.push({
+ accountName,
+ accountPassword: password,
+ });
+ return {
+ accountName: accountName,
+ accountPassword: password,
+ accountPaytoUri: generateRandomPayto(accountName),
+ wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/accounts/${accountName}/taler-wire-gateway/`,
+ };
+ }
+
+ get port() {
+ return this.bankConfig.httpPort;
+ }
+
+ async start(): Promise<void> {
+ logger.info("starting fakebank");
+ if (this.proc) {
+ logger.info("fakebank already running, not starting again");
+ return;
+ }
+ this.proc = this.globalTestState.spawnService(
+ "taler-fakebank-run",
+ [
+ "-c",
+ this.configFile,
+ "--signup-bonus",
+ `${this.bankConfig.currency}:100`,
+ ],
+ "bank",
+ );
+ await this.pingUntilAvailable();
+ const bankClient = new TalerCorebankApiClient(this.corebankApiBaseUrl);
+ for (const acc of this.accounts) {
+ await bankClient.registerAccount(acc.accountName, acc.accountPassword);
+ }
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.bankConfig.httpPort}/config`;
+ await pingProc(this.proc, url, "bank");
+ }
+}
+
+/**
+ * Implementation of the bank service using the libeufin-bank implementation.
+ */
+export class LibeufinBankService
+ extends BankServiceBase
+ implements BankServiceHandle
+{
+ proc: ProcessWrapper | undefined;
+
+ http = createPlatformHttpLib({ enableThrottling: false });
+
+ // We store "created" accounts during setup and
+ // register them after startup.
+ private accounts: {
+ accountName: string;
+ accountPassword: string;
+ }[] = [];
+
+ /**
+ * Create a new fakebank service handle.
+ *
+ * First generates the configuration for the fakebank and
+ * then creates a fakebank handle, but doesn't start the fakebank
+ * service yet.
+ */
+ static async create(
+ gc: GlobalTestState,
+ bc: BankConfig,
+ ): Promise<LibeufinBankService> {
+ const config = new Configuration();
+ const testDir = bc.overrideTestDir ?? gc.testDir;
+ setTalerPaths(config, testDir + "/talerhome");
+ config.setString("libeufin-bankdb-postgres", "config", bc.database);
+ config.setString("libeufin-bank", "currency", bc.currency);
+ config.setString("libeufin-bank", "port", `${bc.httpPort}`);
+ config.setString("libeufin-bank", "serve", "tcp");
+ config.setString(
+ "libeufin-bank",
+ "DEFAULT_DEBT_LIMIT",
+ `${bc.currency}:100`,
+ );
+ config.setString(
+ "libeufin-bank",
+ "registration_bonus",
+ `${bc.currency}:100`,
+ );
+ const cfgFilename = testDir + "/bank.conf";
+ config.write(cfgFilename, { excludeDefaults: true });
+
+ return new LibeufinBankService(gc, bc, cfgFilename);
+ }
+
+ static fromExistingConfig(
+ gc: GlobalTestState,
+ opts: { overridePath?: string },
+ ): FakebankService {
+ const testDir = opts.overridePath ?? gc.testDir;
+ const cfgFilename = testDir + `/bank.conf`;
+ const config = Configuration.load(cfgFilename);
+ const bc: BankConfig = {
+ allowRegistrations:
+ config.getYesNo("libeufin-bank", "allow_registrations").orUndefined() ??
+ true,
+ currency: config.getString("libeufin-bank", "currency").required(),
+ database: config
+ .getString("libeufin-bankdb-postgres", "config")
+ .required(),
+ httpPort: config.getNumber("libeufin-bank", "port").required(),
+ maxDebt: config
+ .getString("libeufin-bank", "DEFAULT_DEBT_LIMIT")
+ .required(),
+ };
+ return new FakebankService(gc, bc, cfgFilename);
+ }
+
+ setSuggestedExchange(e: ExchangeServiceInterface) {
+ if (!!this.proc) {
+ throw Error("Can't set suggested exchange while bank is running.");
+ }
+ const config = Configuration.load(this.configFile);
+ config.setString(
+ "libeufin-bank",
+ "suggested_withdrawal_exchange",
+ e.baseUrl,
+ );
+ config.write(this.configFile, { excludeDefaults: true });
+ }
+
+ get baseUrl(): string {
+ return `http://localhost:${this.bankConfig.httpPort}/`;
+ }
+
+ get corebankApiBaseUrl(): string {
+ return this.baseUrl;
+ }
+
+ get port() {
+ return this.bankConfig.httpPort;
+ }
+
+ async start(): Promise<void> {
+ logger.info("starting libeufin-bank");
+ if (this.proc) {
+ logger.info("libeufin-bank already running, not starting again");
+ return;
+ }
+
+ await sh(
+ this.globalTestState,
+ "libeufin-bank-dbinit",
+ `libeufin-bank dbinit -r -c "${this.configFile}"`,
+ );
+
+ await sh(
+ this.globalTestState,
+ "libeufin-bank-passwd",
+ `libeufin-bank passwd -c "${this.configFile}" admin adminpw`,
+ );
+
+ await sh(
+ this.globalTestState,
+ "libeufin-bank-edit-account",
+ `libeufin-bank edit-account -c "${this.configFile}" admin --debit_threshold=${this.bankConfig.currency}:1000`,
+ );
+
+ this.proc = this.globalTestState.spawnService(
+ "libeufin-bank",
+ ["serve", "-c", this.configFile],
+ "libeufin-bank-httpd",
+ );
+ await this.pingUntilAvailable();
+ const bankClient = new TalerCorebankApiClient(this.corebankApiBaseUrl);
+ for (const acc of this.accounts) {
+ await bankClient.registerAccount(acc.accountName, acc.accountPassword);
+ }
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.bankConfig.httpPort}/config`;
+ await pingProc(this.proc, url, "libeufin-bank");
+ }
+}
+
+// Use libeufin bank instead of pybank.
+const useLibeufinBank = false;
+
+export interface BankServiceHandle {
+ readonly corebankApiBaseUrl: string;
+ readonly http: HttpRequestLibrary;
+}
+
+export type BankService = BankServiceHandle;
+export const BankService = FakebankService;
+
+export interface ExchangeConfig {
+ name: string;
+ currency: string;
+ roundUnit?: string;
+ httpPort: number;
+ database: string;
+ overrideTestDir?: string;
+ overrideWireFee?: string;
+}
+
+export interface ExchangeServiceInterface {
+ readonly baseUrl: string;
+ readonly port: number;
+ readonly name: string;
+ readonly masterPub: string;
+}
+
+export class ExchangeService implements ExchangeServiceInterface {
+ static fromExistingConfig(
+ gc: GlobalTestState,
+ exchangeName: string,
+ opts: { overridePath?: string },
+ ) {
+ const testDir = opts.overridePath ?? gc.testDir;
+ const cfgFilename = testDir + `/exchange-${exchangeName}.conf`;
+ const config = Configuration.load(cfgFilename);
+ const ec: ExchangeConfig = {
+ currency: config.getString("taler", "currency").required(),
+ database: config.getString("exchangedb-postgres", "config").required(),
+ httpPort: config.getNumber("exchange", "port").required(),
+ name: exchangeName,
+ roundUnit: config.getString("taler", "currency_round_unit").required(),
+ };
+ const privFile = config
+ .getPath("exchange-offline", "master_priv_file")
+ .required();
+ const eddsaPriv = fs.readFileSync(privFile);
+ const keyPair: EddsaKeyPair = {
+ eddsaPriv,
+ eddsaPub: eddsaGetPublic(eddsaPriv),
+ };
+ return new ExchangeService(gc, ec, cfgFilename, keyPair);
+ }
+
+ private currentTimetravelOffsetMs: number | undefined;
+
+ private exchangeBankAccounts: HarnessExchangeBankAccount[] = [];
+
+ setTimetravel(tMs: number | undefined): void {
+ if (this.isRunning()) {
+ throw Error("can't set time travel while the exchange is running");
+ }
+ this.currentTimetravelOffsetMs = tMs;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravelOffsetMs != null) {
+ // Convert to microseconds
+ return `--timetravel=+${this.currentTimetravelOffsetMs * 1000}`;
+ }
+ return undefined;
+ }
+
+ /**
+ * Return an empty array if no time travel is set,
+ * and an array with the time travel command line argument
+ * otherwise.
+ */
+ private get timetravelArgArr(): string[] {
+ const tta = this.timetravelArg;
+ if (tta) {
+ return [tta];
+ }
+ return [];
+ }
+
+ async runWirewatchOnce() {
+ if (useLibeufinBank) {
+ // Not even 2 seconds showed to be enough!
+ await waitMs(4000);
+ }
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-wirewatch-once`,
+ "taler-exchange-wirewatch",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ async runAggregatorOnceWithTimetravel(opts: {
+ timetravelMicroseconds: number;
+ }) {
+ let timetravelArgArr = [];
+ timetravelArgArr.push(`--timetravel=${opts.timetravelMicroseconds}`);
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-aggregator-once`,
+ "taler-exchange-aggregator",
+ [...timetravelArgArr, "-c", this.configFilename, "-t", "-y", "-LINFO"],
+ );
+ }
+
+ async runAggregatorOnce() {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-aggregator-once`,
+ "taler-exchange-aggregator",
+ [
+ ...this.timetravelArgArr,
+ "-c",
+ this.configFilename,
+ "-t",
+ "-y",
+ "-LINFO",
+ ],
+ );
+ }
+
+ async runTransferOnce() {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-transfer-once`,
+ "taler-exchange-transfer",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ async runTransferOnceWithTimetravel(opts: {
+ timetravelMicroseconds: number;
+ }) {
+ let timetravelArgArr = [];
+ timetravelArgArr.push(`--timetravel=${opts.timetravelMicroseconds}`);
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-transfer-once`,
+ "taler-exchange-transfer",
+ [...timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ /**
+ * Run the taler-exchange-expire command once in test mode.
+ */
+ async runExpireOnce() {
+ await runCommand(
+ this.globalState,
+ `exchange-${this.name}-expire-once`,
+ "taler-exchange-expire",
+ [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
+ );
+ }
+
+ changeConfig(f: (config: Configuration) => void) {
+ const config = Configuration.load(this.configFilename);
+ f(config);
+ config.write(this.configFilename, { excludeDefaults: true });
+ }
+
+ static create(gc: GlobalTestState, e: ExchangeConfig) {
+ const testDir = e.overrideTestDir ?? gc.testDir;
+ const config = new Configuration();
+ setTalerPaths(config, `${testDir}/talerhome-exchange-${e.name}`, e.name);
+ config.setString("taler", "currency", e.currency);
+ // Required by the exchange but not really used yet.
+ config.setString("exchange", "aml_threshold", `${e.currency}:1000000`);
+ config.setString(
+ "taler",
+ "currency_round_unit",
+ e.roundUnit ?? `${e.currency}:0.01`,
+ );
+ // Set to a high value to not break existing test cases where the merchant
+ // would cover all fees.
+ config.setString("exchange", "STEFAN_ABS", `${e.currency}:1`);
+ config.setString("exchange", "STEFAN_LOG", `${e.currency}:1`);
+ config.setString(
+ "exchange",
+ "revocation_dir",
+ "${TALER_DATA_HOME}/exchange/revocations",
+ );
+ config.setString("exchange", "max_keys_caching", "forever");
+ config.setString("exchange", "db", "postgres");
+ config.setString(
+ "exchange-offline",
+ "master_priv_file",
+ "${TALER_DATA_HOME}/exchange/offline-keys/master.priv",
+ );
+ config.setString("exchange", "base_url", `http://localhost:${e.httpPort}/`);
+ config.setString("exchange", "serve", "tcp");
+ config.setString("exchange", "port", `${e.httpPort}`);
+
+ config.setString("exchangedb-postgres", "config", e.database);
+
+ config.setString("taler-exchange-secmod-eddsa", "lookahead_sign", "20 s");
+ config.setString("taler-exchange-secmod-rsa", "lookahead_sign", "20 s");
+
+ // FIXME: Remove once the exchange default config properly ships this.
+ config.setString("exchange", "EXPIRE_IDLE_SLEEP_INTERVAL", "1 s");
+
+ const exchangeMasterKey = createEddsaKeyPair();
+
+ config.setString(
+ "exchange",
+ "master_public_key",
+ encodeCrock(exchangeMasterKey.eddsaPub),
+ );
+
+ const masterPrivFile = config
+ .getPath("exchange-offline", "master_priv_file")
+ .required();
+
+ fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true });
+
+ if (fs.existsSync(masterPrivFile)) {
+ throw new Error(
+ "master priv file already exists, can't create new exchange config",
+ );
+ }
+
+ fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv));
+
+ const cfgFilename = testDir + `/exchange-${e.name}.conf`;
+ config.write(cfgFilename, { excludeDefaults: true });
+ return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey);
+ }
+
+ addOfferedCoins(offeredCoins: ((curr: string) => CoinConfig)[]) {
+ const config = Configuration.load(this.configFilename);
+ offeredCoins.forEach((cc) =>
+ setCoin(config, cc(this.exchangeConfig.currency)),
+ );
+ config.write(this.configFilename, { excludeDefaults: true });
+ }
+
+ addCoinConfigList(ccs: CoinConfig[]) {
+ const config = Configuration.load(this.configFilename);
+ ccs.forEach((cc) => setCoin(config, cc));
+ config.write(this.configFilename, { excludeDefaults: true });
+ }
+
+ enableAgeRestrictions(maskStr: string) {
+ const config = Configuration.load(this.configFilename);
+ config.setString("exchange-extension-age_restriction", "enabled", "yes");
+ config.setString(
+ "exchange-extension-age_restriction",
+ "age_groups",
+ maskStr,
+ );
+ config.write(this.configFilename, { excludeDefaults: true });
+ }
+
+ get masterPub() {
+ return encodeCrock(this.keyPair.eddsaPub);
+ }
+
+ get port() {
+ return this.exchangeConfig.httpPort;
+ }
+
+ /**
+ * Run a function that modifies the existing exchange configuration.
+ * The modified exchange configuration will then be written to the
+ * file system.
+ */
+ async modifyConfig(
+ f: (config: Configuration) => Promise<void>,
+ ): Promise<void> {
+ const config = Configuration.load(this.configFilename);
+ await f(config);
+ config.write(this.configFilename, { excludeDefaults: true });
+ }
+
+ async addBankAccount(
+ localName: string,
+ exchangeBankAccount: HarnessExchangeBankAccount,
+ ): Promise<void> {
+ this.exchangeBankAccounts.push(exchangeBankAccount);
+ const config = Configuration.load(this.configFilename);
+ config.setString(
+ `exchange-account-${localName}`,
+ "wire_response",
+ `\${TALER_DATA_HOME}/exchange/account-${localName}.json`,
+ );
+ config.setString(
+ `exchange-account-${localName}`,
+ "payto_uri",
+ exchangeBankAccount.accountPaytoUri,
+ );
+ config.setString(`exchange-account-${localName}`, "enable_credit", "yes");
+ config.setString(`exchange-account-${localName}`, "enable_debit", "yes");
+ config.setString(
+ `exchange-accountcredentials-${localName}`,
+ "wire_gateway_url",
+ exchangeBankAccount.wireGatewayApiBaseUrl,
+ );
+ config.setString(
+ `exchange-accountcredentials-${localName}`,
+ "wire_gateway_auth_method",
+ "basic",
+ );
+ config.setString(
+ `exchange-accountcredentials-${localName}`,
+ "username",
+ exchangeBankAccount.accountName,
+ );
+ config.setString(
+ `exchange-accountcredentials-${localName}`,
+ "password",
+ exchangeBankAccount.accountPassword,
+ );
+ config.write(this.configFilename, { excludeDefaults: true });
+ }
+
+ exchangeHttpProc: ProcessWrapper | undefined;
+ exchangeWirewatchProc: ProcessWrapper | undefined;
+
+ exchangeTransferProc: ProcessWrapper | undefined;
+ exchangeAggregatorProc: ProcessWrapper | undefined;
+
+ helperCryptoRsaProc: ProcessWrapper | undefined;
+ helperCryptoEddsaProc: ProcessWrapper | undefined;
+ helperCryptoCsProc: ProcessWrapper | undefined;
+
+ constructor(
+ private globalState: GlobalTestState,
+ private exchangeConfig: ExchangeConfig,
+ private configFilename: string,
+ private keyPair: EddsaKeyPair,
+ ) {}
+
+ get name() {
+ return this.exchangeConfig.name;
+ }
+
+ get baseUrl() {
+ return `http://localhost:${this.exchangeConfig.httpPort}/`;
+ }
+
+ isRunning(): boolean {
+ return !!this.exchangeWirewatchProc || !!this.exchangeHttpProc;
+ }
+
+ /**
+ * Stop the wirewatch service (which runs by default).
+ *
+ * Useful for some tests.
+ */
+ async stopWirewatch(): Promise<void> {
+ const wirewatch = this.exchangeWirewatchProc;
+ if (wirewatch) {
+ wirewatch.proc.kill("SIGTERM");
+ await wirewatch.wait();
+ this.exchangeWirewatchProc = undefined;
+ }
+ }
+
+ async stopAggregator(): Promise<void> {
+ const agg = this.exchangeAggregatorProc;
+ if (agg) {
+ agg.proc.kill("SIGTERM");
+ await agg.wait();
+ this.exchangeAggregatorProc = undefined;
+ }
+ }
+
+ async startWirewatch(): Promise<void> {
+ const wirewatch = this.exchangeWirewatchProc;
+ if (wirewatch) {
+ logger.warn("wirewatch already running");
+ } else {
+ this.internalCreateWirewatchProc();
+ }
+ }
+
+ async stop(): Promise<void> {
+ const wirewatch = this.exchangeWirewatchProc;
+ if (wirewatch) {
+ wirewatch.proc.kill("SIGTERM");
+ await wirewatch.wait();
+ this.exchangeWirewatchProc = undefined;
+ }
+ const aggregatorProc = this.exchangeAggregatorProc;
+ if (aggregatorProc) {
+ aggregatorProc.proc.kill("SIGTERM");
+ await aggregatorProc.wait();
+ this.exchangeAggregatorProc = undefined;
+ }
+ const transferProc = this.exchangeTransferProc;
+ if (transferProc) {
+ transferProc.proc.kill("SIGTERM");
+ await transferProc.wait();
+ this.exchangeTransferProc = undefined;
+ }
+ const httpd = this.exchangeHttpProc;
+ if (httpd) {
+ httpd.proc.kill("SIGTERM");
+ await httpd.wait();
+ this.exchangeHttpProc = undefined;
+ }
+ const cryptoRsa = this.helperCryptoRsaProc;
+ if (cryptoRsa) {
+ cryptoRsa.proc.kill("SIGTERM");
+ await cryptoRsa.wait();
+ this.helperCryptoRsaProc = undefined;
+ }
+ const cryptoEddsa = this.helperCryptoEddsaProc;
+ if (cryptoEddsa) {
+ cryptoEddsa.proc.kill("SIGTERM");
+ await cryptoEddsa.wait();
+ this.helperCryptoRsaProc = undefined;
+ }
+ const cryptoCs = this.helperCryptoCsProc;
+ if (cryptoCs) {
+ cryptoCs.proc.kill("SIGTERM");
+ await cryptoCs.wait();
+ this.helperCryptoCsProc = undefined;
+ }
+ }
+
+ /**
+ * Update keys signing the keys generated by the security module
+ * with the offline signing key.
+ */
+ async keyup(): Promise<void> {
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ ["-c", this.configFilename, "download", "sign", "upload"],
+ );
+
+ const accountTargetTypes: Set<string> = new Set();
+
+ for (const acct of this.exchangeBankAccounts) {
+ const paytoUri = acct.accountPaytoUri;
+ const p = parsePaytoUri(paytoUri);
+ if (!p) {
+ throw Error(`invalid payto uri in exchange config: ${paytoUri}`);
+ }
+ const optArgs: string[] = [];
+ if (acct.conversionUrl != null) {
+ optArgs.push("conversion-url", acct.conversionUrl);
+ }
+
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ "enable-account",
+ paytoUri,
+ ...optArgs,
+ "upload",
+ ],
+ );
+
+ const accTargetType = p.targetType;
+
+ const covered = accountTargetTypes.has(p.targetType);
+ if (!covered && !acct.skipWireFeeCreation) {
+ const year = new Date().getFullYear();
+
+ for (let i = year; i < year + 5; i++) {
+ const wireFee =
+ this.exchangeConfig.overrideWireFee ??
+ `${this.exchangeConfig.currency}:0.01`;
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ "wire-fee",
+ // Year
+ `${i}`,
+ // Wire method
+ accTargetType,
+ // Wire fee
+ wireFee,
+ // Closing fee
+ `${this.exchangeConfig.currency}:0.01`,
+ "upload",
+ ],
+ );
+ accountTargetTypes.add(accTargetType);
+ }
+ }
+ }
+
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ "global-fee",
+ // year
+ "now",
+ // history fee
+ `${this.exchangeConfig.currency}:0.01`,
+ // account fee
+ `${this.exchangeConfig.currency}:0.01`,
+ // purse fee
+ `${this.exchangeConfig.currency}:0.00`,
+ // purse timeout
+ "1h",
+ // history expiration
+ "1year",
+ // free purses per account
+ "5",
+ "upload",
+ ],
+ );
+ }
+
+ async revokeDenomination(denomPubHash: string) {
+ if (!this.isRunning()) {
+ throw Error("exchange must be running when revoking denominations");
+ }
+ await runCommand(
+ this.globalState,
+ "exchange-offline",
+ "taler-exchange-offline",
+ [
+ "-c",
+ this.configFilename,
+ "revoke-denomination",
+ denomPubHash,
+ "upload",
+ ],
+ );
+ }
+
+ async purgeSecmodKeys(): Promise<void> {
+ const cfg = Configuration.load(this.configFilename);
+ const rsaKeydir = cfg
+ .getPath("taler-exchange-secmod-rsa", "KEY_DIR")
+ .required();
+ const eddsaKeydir = cfg
+ .getPath("taler-exchange-secmod-eddsa", "KEY_DIR")
+ .required();
+ // Be *VERY* careful when changing this, or you will accidentally delete user data.
+ await sh(this.globalState, "rm-secmod-keys", `rm -rf ${rsaKeydir}/COIN_*`);
+ await sh(this.globalState, "rm-secmod-keys", `rm ${eddsaKeydir}/*`);
+ }
+
+ async purgeDatabase(): Promise<void> {
+ await sh(
+ this.globalState,
+ "exchange-dbinit",
+ `taler-exchange-dbinit -r -c "${this.configFilename}"`,
+ );
+ }
+
+ private internalCreateWirewatchProc() {
+ this.exchangeWirewatchProc = this.globalState.spawnService(
+ "taler-exchange-wirewatch",
+ [
+ "-c",
+ this.configFilename,
+ "--longpoll-timeout=5s",
+ ...this.timetravelArgArr,
+ ],
+ `exchange-wirewatch-${this.name}`,
+ );
+ }
+
+ private internalCreateAggregatorProc() {
+ this.exchangeAggregatorProc = this.globalState.spawnService(
+ "taler-exchange-aggregator",
+ ["-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-aggregator-${this.name}`,
+ );
+ }
+
+ private internalCreateTransferProc() {
+ this.exchangeTransferProc = this.globalState.spawnService(
+ "taler-exchange-transfer",
+ ["-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-transfer-${this.name}`,
+ );
+ }
+
+ async dbinit() {
+ await sh(
+ this.globalState,
+ "exchange-dbinit",
+ `taler-exchange-dbinit -c "${this.configFilename}"`,
+ );
+ }
+
+ async start(
+ opts: { skipDbinit?: boolean; skipKeyup?: boolean } = {},
+ ): Promise<void> {
+ if (this.isRunning()) {
+ throw Error("exchange is already running");
+ }
+
+ const skipDbinit = opts.skipDbinit ?? false;
+
+ if (!skipDbinit) {
+ await this.dbinit();
+ }
+
+ this.helperCryptoEddsaProc = this.globalState.spawnService(
+ "taler-exchange-secmod-eddsa",
+ ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
+ `exchange-crypto-eddsa-${this.name}`,
+ );
+
+ this.helperCryptoCsProc = this.globalState.spawnService(
+ "taler-exchange-secmod-cs",
+ ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
+ `exchange-crypto-cs-${this.name}`,
+ );
+
+ this.helperCryptoRsaProc = this.globalState.spawnService(
+ "taler-exchange-secmod-rsa",
+ ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
+ `exchange-crypto-rsa-${this.name}`,
+ );
+
+ this.internalCreateWirewatchProc();
+ this.internalCreateTransferProc();
+ this.internalCreateAggregatorProc();
+
+ this.exchangeHttpProc = this.globalState.spawnService(
+ "taler-exchange-httpd",
+ ["-LINFO", "-c", this.configFilename, ...this.timetravelArgArr],
+ `exchange-httpd-${this.name}`,
+ );
+
+ await this.pingUntilAvailable();
+
+ const skipKeyup = opts.skipKeyup ?? false;
+
+ if (!skipKeyup) {
+ await this.keyup();
+ } else {
+ logger.info("skipping keyup");
+ }
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ // We request /management/keys, since /keys can block
+ // when we didn't do the key setup yet.
+ const url = `http://localhost:${this.exchangeConfig.httpPort}/management/keys`;
+ await pingProc(this.exchangeHttpProc, url, `exchange (${this.name})`);
+ }
+}
+
+export interface MerchantConfig {
+ name: string;
+ currency: string;
+ httpPort: number;
+ database: string;
+ overrideTestDir?: string;
+}
+
+export interface MerchantServiceInterface {
+ makeInstanceBaseUrl(instanceName?: string): string;
+ readonly port: number;
+ readonly name: string;
+}
+
+/**
+ * Default HTTP client handle for the integration test harness.
+ */
+export const harnessHttpLib = createPlatformHttpLib({
+ enableThrottling: false,
+});
+
+export class MerchantService implements MerchantServiceInterface {
+ static fromExistingConfig(
+ gc: GlobalTestState,
+ name: string,
+ opts: { overridePath?: string },
+ ) {
+ const testDir = opts.overridePath ?? gc.testDir;
+ const cfgFilename = testDir + `/merchant-${name}.conf`;
+ const config = Configuration.load(cfgFilename);
+ const mc: MerchantConfig = {
+ currency: config.getString("taler", "currency").required(),
+ database: config.getString("merchantdb-postgres", "config").required(),
+ httpPort: config.getNumber("merchant", "port").required(),
+ name,
+ };
+ return new MerchantService(gc, mc, cfgFilename);
+ }
+
+ proc: ProcessWrapper | undefined;
+
+ constructor(
+ private globalState: GlobalTestState,
+ private merchantConfig: MerchantConfig,
+ private configFilename: string,
+ ) {}
+
+ private currentTimetravelOffsetMs: number | undefined;
+
+ private isRunning(): boolean {
+ return !!this.proc;
+ }
+
+ setTimetravel(t: number | undefined): void {
+ if (this.isRunning()) {
+ throw Error("can't set time travel while the exchange is running");
+ }
+ this.currentTimetravelOffsetMs = t;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravelOffsetMs != null) {
+ // Convert to microseconds
+ return `--timetravel=+${this.currentTimetravelOffsetMs * 1000}`;
+ }
+ return undefined;
+ }
+
+ /**
+ * Return an empty array if no time travel is set,
+ * and an array with the time travel command line argument
+ * otherwise.
+ */
+ private get timetravelArgArr(): string[] {
+ const tta = this.timetravelArg;
+ if (tta) {
+ return [tta];
+ }
+ return [];
+ }
+
+ get port(): number {
+ return this.merchantConfig.httpPort;
+ }
+
+ get name(): string {
+ return this.merchantConfig.name;
+ }
+
+ async stop(): Promise<void> {
+ const httpd = this.proc;
+ if (httpd) {
+ httpd.proc.kill("SIGTERM");
+ await httpd.wait();
+ this.proc = undefined;
+ }
+ }
+
+ async dbinit() {
+ await runCommand(
+ this.globalState,
+ "merchant-dbinit",
+ "taler-merchant-dbinit",
+ ["-c", this.configFilename],
+ );
+ }
+
+ /**
+ * Start the merchant,
+ */
+ async start(opts: { skipDbinit?: boolean } = {}): Promise<void> {
+ const skipSetup = opts.skipDbinit ?? false;
+
+ if (!skipSetup) {
+ await this.dbinit();
+ }
+
+ this.proc = this.globalState.spawnService(
+ "taler-merchant-httpd",
+ [
+ "taler-merchant-httpd",
+ "-LDEBUG",
+ "-c",
+ this.configFilename,
+ ...this.timetravelArgArr,
+ ],
+ `merchant-${this.merchantConfig.name}`,
+ );
+ }
+
+ static async create(
+ gc: GlobalTestState,
+ mc: MerchantConfig,
+ ): Promise<MerchantService> {
+ const testDir = mc.overrideTestDir ?? gc.testDir;
+ const config = new Configuration();
+ config.setString("taler", "currency", mc.currency);
+
+ const cfgFilename = testDir + `/merchant-${mc.name}.conf`;
+ setTalerPaths(config, testDir + "/talerhome");
+ config.setString("merchant", "serve", "tcp");
+ config.setString("merchant", "port", `${mc.httpPort}`);
+ config.setString(
+ "merchant",
+ "keyfile",
+ "${TALER_DATA_HOME}/merchant/merchant.priv",
+ );
+ config.setString("merchantdb-postgres", "config", mc.database);
+ // Do not contact demo.taler.net exchange in tests
+ config.setString("merchant-exchange-kudos", "disabled", "yes");
+ config.write(cfgFilename, { excludeDefaults: true });
+
+ return new MerchantService(gc, mc, cfgFilename);
+ }
+
+ addExchange(e: ExchangeServiceInterface): void {
+ const config = Configuration.load(this.configFilename);
+ config.setString(
+ `merchant-exchange-${e.name}`,
+ "exchange_base_url",
+ e.baseUrl,
+ );
+ config.setString(
+ `merchant-exchange-${e.name}`,
+ "currency",
+ this.merchantConfig.currency,
+ );
+ config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub);
+ config.write(this.configFilename, { excludeDefaults: true });
+ }
+
+ async addDefaultInstance(): Promise<void> {
+ return await this.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+ }
+
+ /**
+ * Add an instance together with a wire account.
+ */
+ async addInstanceWithWireAccount(
+ instanceConfig: PartialMerchantInstanceConfig,
+ ): Promise<void> {
+ if (!this.proc) {
+ throw Error("merchant must be running to add instance");
+ }
+ logger.info(`adding instance '${instanceConfig.id}'`);
+ const url = `http://localhost:${this.merchantConfig.httpPort}/management/instances`;
+ const auth = instanceConfig.auth ?? { method: "external" };
+
+ const body: MerchantInstanceConfig = {
+ auth,
+ id: instanceConfig.id,
+ name: instanceConfig.name,
+ address: instanceConfig.address ?? {},
+ jurisdiction: instanceConfig.jurisdiction ?? {},
+ // FIXME: In some tests, we might want to make this configurable
+ use_stefan: true,
+ default_wire_transfer_delay:
+ instanceConfig.defaultWireTransferDelay ??
+ Duration.toTalerProtocolDuration(
+ Duration.fromSpec({
+ days: 1,
+ }),
+ ),
+ default_pay_delay:
+ instanceConfig.defaultPayDelay ??
+ Duration.toTalerProtocolDuration(Duration.getForever()),
+ };
+ const resp = await harnessHttpLib.fetch(url, { method: "POST", body });
+ await expectSuccessResponseOrThrow(resp);
+
+ const accountCreateUrl = `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceConfig.id}/private/accounts`;
+ for (const paytoUri of instanceConfig.paytoUris) {
+ const accountReq: AccountAddDetails = {
+ payto_uri: paytoUri,
+ };
+ const acctResp = await harnessHttpLib.fetch(accountCreateUrl, {
+ method: "POST",
+ body: accountReq,
+ });
+ await expectSuccessResponseOrThrow(acctResp);
+ }
+ }
+
+ makeInstanceBaseUrl(instanceName?: string): string {
+ if (instanceName === undefined || instanceName === "default") {
+ return `http://localhost:${this.merchantConfig.httpPort}/`;
+ } else {
+ return `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/`;
+ }
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ const url = `http://localhost:${this.merchantConfig.httpPort}/config`;
+ await pingProc(this.proc, url, `merchant (${this.merchantConfig.name})`);
+ }
+}
+
+type TestStatus = "pass" | "fail" | "skip";
+
+export interface TestRunResult {
+ /**
+ * Name of the test.
+ */
+ name: string;
+
+ /**
+ * How long did the test run?
+ */
+ timeSec: number;
+
+ status: TestStatus;
+
+ reason?: string;
+}
+
+export async function runTestWithState(
+ gc: GlobalTestState,
+ testMain: (t: GlobalTestState) => Promise<void>,
+ testName: string,
+ linger: boolean = false,
+): Promise<TestRunResult> {
+ const startMs = new Date().getTime();
+
+ const p = openPromise();
+ let status: TestStatus;
+
+ const handleSignal = (s: string) => {
+ logger.warn(
+ `**** received fatal process event (${s}), terminating test ${testName}`,
+ );
+ gc.shutdownSync();
+ process.exit(1);
+ };
+
+ process.on("SIGINT", handleSignal);
+ process.on("SIGTERM", handleSignal);
+
+ process.on("unhandledRejection", (reason: unknown, promise: any) => {
+ logger.warn(
+ `**** received unhandled rejection (${reason}), terminating test ${testName}`,
+ );
+ logger.warn(`reason type: ${typeof reason}`);
+ gc.shutdownSync();
+ process.exit(1);
+ });
+ process.on("uncaughtException", (error, origin) => {
+ logger.warn(
+ `**** received uncaught exception (${error}), terminating test ${testName}`,
+ );
+ console.warn("stack", error.stack);
+ gc.shutdownSync();
+ process.exit(1);
+ });
+
+ try {
+ logger.info("running test in directory", gc.testDir);
+ await Promise.race([testMain(gc), p.promise]);
+ logger.info("completed test in directory", gc.testDir);
+ status = "pass";
+ if (linger) {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ terminal: true,
+ });
+ await new Promise<void>((resolve, reject) => {
+ rl.question("Press enter to shut down test.", () => {
+ logger.error("Requested shutdown");
+ resolve();
+ });
+ });
+ rl.close();
+ }
+ } catch (e) {
+ if (e instanceof CommandError) {
+ console.error("FATAL: test failed for", e.logName);
+ const errorLog = fs.readFileSync(
+ path.join(gc.testDir, `${e.logName}-stderr.log`),
+ );
+ console.error(`${e.message}: "${e.command}"`);
+ console.error(errorLog.toString());
+ console.error(e);
+ } else if (e instanceof TalerError) {
+ console.error(
+ "FATAL: test failed",
+ e.message,
+ `error detail: ${j2s(e.errorDetail)}`,
+ );
+ console.error(e.stack);
+ } else {
+ console.error("FATAL: test failed with exception", e);
+ }
+ status = "fail";
+ } finally {
+ await gc.shutdown();
+ }
+ const afterMs = new Date().getTime();
+ return {
+ name: testName,
+ timeSec: (afterMs - startMs) / 1000,
+ status,
+ };
+}
+
+function shellWrap(s: string) {
+ return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'";
+}
+
+export interface WalletCliOpts {
+ cryptoWorkerType?: "sync" | "node-worker-thread";
+}
+
+function tryUnixConnect(socketPath: string): Promise<void> {
+ return new Promise((resolve, reject) => {
+ const client = net.createConnection(socketPath);
+ client.on("error", (e) => {
+ reject(e);
+ });
+ client.on("connect", () => {
+ client.end();
+ resolve();
+ });
+ });
+}
+
+export interface WalletServiceOptions {
+ useInMemoryDb?: boolean;
+ /**
+ * Use a particular DB path instead of the default one in the
+ * test environment.
+ */
+ overrideDbPath?: string;
+ name: string;
+}
+
+/**
+ * A wallet service that listens on a unix domain socket for commands.
+ */
+export class WalletService {
+ walletProc: ProcessWrapper | undefined;
+
+ private internalDbPath: string;
+
+ constructor(
+ private globalState: GlobalTestState,
+ private opts: WalletServiceOptions,
+ ) {
+ if (this.opts.overrideDbPath) {
+ this.internalDbPath = this.opts.overrideDbPath;
+ } else {
+ if (this.opts.useInMemoryDb) {
+ this.internalDbPath = ":memory:";
+ } else {
+ this.internalDbPath = path.join(
+ this.globalState.testDir,
+ `walletdb-${this.opts.name}.sqlite3`,
+ );
+ }
+ }
+ }
+
+ get socketPath() {
+ const unixPath = path.join(
+ this.globalState.testDir,
+ `${this.opts.name}.sock`,
+ );
+ return unixPath;
+ }
+
+ get dbPath() {
+ return this.internalDbPath;
+ }
+
+ async stop(): Promise<void> {
+ if (this.walletProc) {
+ this.walletProc.proc.kill("SIGTERM");
+ await this.walletProc.wait();
+ }
+ }
+
+ async start(): Promise<void> {
+ const unixPath = this.socketPath;
+ this.walletProc = this.globalState.spawnService(
+ "taler-wallet-cli",
+ [
+ "--wallet-db",
+ this.dbPath,
+ "-LTRACE", // FIXME: Make this configurable?
+ "--no-throttle", // FIXME: Optionally do throttling for some tests?
+ "advanced",
+ "serve",
+ "--unix-path",
+ unixPath,
+ "--no-init",
+ ],
+ `wallet-${this.opts.name}`,
+ );
+ logger.info(
+ `hint: connect to wallet using taler-wallet-cli --wallet-connection=${unixPath}`,
+ );
+ }
+
+ async pingUntilAvailable(): Promise<void> {
+ let nextDelay = backoffStart();
+ while (1) {
+ try {
+ await tryUnixConnect(this.socketPath);
+ } catch (e) {
+ logger.info(`wallet connection attempt failed: ${e}`);
+ logger.info(`waiting on wallet for ${nextDelay}ms`);
+ await delayMs(nextDelay);
+ nextDelay = backoffIncrement(nextDelay);
+ continue;
+ }
+ logger.info("connection to wallet-core succeeded");
+ break;
+ }
+ }
+}
+
+export interface WalletClientArgs {
+ name?: string;
+ unixPath: string;
+ onNotification?(n: WalletNotification): void;
+}
+
+export type CancelFn = () => void;
+export type NotificationHandler = (n: WalletNotification) => void;
+
+/**
+ * Convenience wrapper around a (remote) wallet handle.
+ */
+export class WalletClient {
+ remoteWallet: RemoteWallet | undefined = undefined;
+ private waiter: WalletNotificationWaiter = makeNotificationWaiter();
+ notificationHandlers: NotificationHandler[] = [];
+
+ addNotificationListener(f: NotificationHandler): CancelFn {
+ this.notificationHandlers.push(f);
+ return () => {
+ const idx = this.notificationHandlers.indexOf(f);
+ if (idx >= 0) {
+ this.notificationHandlers.splice(idx, 1);
+ }
+ };
+ }
+
+ async call<Op extends keyof WalletOperations>(
+ operation: Op,
+ payload: WalletCoreRequestType<Op>,
+ ): Promise<WalletCoreResponseType<Op>> {
+ if (!this.remoteWallet) {
+ throw Error("wallet not connected");
+ }
+ const client = getClientFromRemoteWallet(this.remoteWallet);
+ return client.call(operation, payload);
+ }
+
+ constructor(private args: WalletClientArgs) {}
+
+ async connect(): Promise<void> {
+ const waiter = this.waiter;
+ const walletClient = this;
+ const w = await createRemoteWallet({
+ name: this.args.name,
+ socketFilename: this.args.unixPath,
+ notificationHandler(n) {
+ if (walletClient.args.onNotification) {
+ walletClient.args.onNotification(n);
+ }
+ waiter.notify(n);
+ for (const h of walletClient.notificationHandlers) {
+ h(n);
+ }
+ },
+ });
+ this.remoteWallet = w;
+
+ this.waiter.waitForNotificationCond;
+ }
+
+ get client() {
+ if (!this.remoteWallet) {
+ throw Error("wallet not connected");
+ }
+ return getClientFromRemoteWallet(this.remoteWallet);
+ }
+
+ waitForNotificationCond<T>(
+ cond: (n: WalletNotification) => T | undefined | false,
+ ): Promise<T> {
+ return this.waiter.waitForNotificationCond(cond);
+ }
+}
+
+export class WalletCli {
+ private currentTimetravel: Duration | undefined;
+ private _client: WalletCoreApiClient;
+
+ setTimetravel(d: Duration | undefined) {
+ this.currentTimetravel = d;
+ }
+
+ private get timetravelArg(): string | undefined {
+ if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
+ // Convert to microseconds
+ return `--timetravel=${this.currentTimetravel.d_ms * 1000}`;
+ }
+ return undefined;
+ }
+
+ constructor(
+ private globalTestState: GlobalTestState,
+ private name: string = "default",
+ cliOpts: WalletCliOpts = {},
+ ) {
+ const self = this;
+ this._client = {
+ async call(op: any, payload: any): Promise<any> {
+ logger.info(
+ `calling wallet with timetravel arg ${j2s(self.timetravelArg)}`,
+ );
+ const cryptoWorkerArg = cliOpts.cryptoWorkerType
+ ? `--crypto-worker=${cliOpts.cryptoWorkerType}`
+ : "";
+ const logName = `wallet-${self.name}`;
+ const command = `taler-wallet-cli ${
+ self.timetravelArg ?? ""
+ } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${
+ self.dbfile
+ }' api '${op}' ${shellWrap(JSON.stringify(payload))}`;
+ const resp = await sh(self.globalTestState, logName, command);
+ logger.info("--- wallet core response ---");
+ logger.info(resp);
+ logger.info("--- end of response ---");
+ let ar: CoreApiResponse;
+ try {
+ ar = JSON.parse(resp);
+ } catch (e) {
+ throw new CommandError(
+ "wallet CLI did not return a proper JSON response",
+ logName,
+ command,
+ [],
+ {},
+ null,
+ );
+ }
+ if (ar.type === "error") {
+ throw TalerError.fromUncheckedDetail(ar.error);
+ }
+ return ar.result;
+ },
+ };
+ }
+
+ get dbfile(): string {
+ return this.globalTestState.testDir + `/walletdb-${this.name}.json`;
+ }
+
+ deleteDatabase() {
+ fs.unlinkSync(this.dbfile);
+ }
+
+ private get timetravelArgArr(): string[] {
+ const tta = this.timetravelArg;
+ if (tta) {
+ return [tta];
+ }
+ return [];
+ }
+
+ get client(): WalletCoreApiClient {
+ return this._client;
+ }
+
+ async runUntilDone(args: {} = {}): Promise<void> {
+ await runCommand(
+ this.globalTestState,
+ `wallet-${this.name}`,
+ "taler-wallet-cli",
+ [
+ "--no-throttle",
+ ...this.timetravelArgArr,
+ "-LTRACE",
+ "--skip-defaults",
+ "--wallet-db",
+ this.dbfile,
+ "run-until-done",
+ ],
+ );
+ }
+
+ async runPending(): Promise<void> {
+ await runCommand(
+ this.globalTestState,
+ `wallet-${this.name}`,
+ "taler-wallet-cli",
+ [
+ "--no-throttle",
+ "--skip-defaults",
+ "-LTRACE",
+ ...this.timetravelArgArr,
+ "--wallet-db",
+ this.dbfile,
+ "advanced",
+ "run-pending",
+ ],
+ );
+ }
+}
+
+export function generateRandomTestIban(salt: string | null = null): string {
+ function getBban(salt: string | null): string {
+ if (!salt) return Math.random().toString().substring(2, 6);
+ let hashed = hash(stringToBytes(salt));
+ let ret = "";
+ for (let i = 0; i < hashed.length; i++) {
+ ret += hashed[i].toString();
+ }
+ return ret.substring(0, 4);
+ }
+
+ let cc_no_check = "131400"; // == DE00
+ let bban = getBban(salt);
+ let check_digits = (
+ 98 -
+ (Number.parseInt(`${bban}${cc_no_check}`) % 97)
+ ).toString();
+ if (check_digits.length == 1) {
+ check_digits = `0${check_digits}`;
+ }
+ return `DE${check_digits}${bban}`;
+}
+
+export function getWireMethodForTest(): string {
+ if (useLibeufinBank) return "iban";
+ return "x-taler-bank";
+}
+
+/**
+ * Generate a payto address, whose authority depends
+ * on whether the banking is served by euFin or Pybank.
+ */
+export function generateRandomPayto(label: string): string {
+ if (useLibeufinBank)
+ return `payto://iban/SANDBOXX/${generateRandomTestIban(
+ label,
+ )}?receiver-name=${label}`;
+ return `payto://x-taler-bank/localhost/${label}?receiver-name=${label}`;
+}
+
+function waitMs(ms: number): Promise<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/packages/taler-harness/src/harness/helpers.ts b/packages/taler-harness/src/harness/helpers.ts
new file mode 100644
index 000000000..ea9047d0b
--- /dev/null
+++ b/packages/taler-harness/src/harness/helpers.ts
@@ -0,0 +1,711 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Helpers to create typical test environments.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports
+ */
+import {
+ AmountString,
+ ConfirmPayResultType,
+ Duration,
+ Logger,
+ MerchantApiClient,
+ MerchantContractTerms,
+ NotificationType,
+ PartialWalletRunConfig,
+ PreparePayResultType,
+ TalerCorebankApiClient,
+ TransactionMajorState,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "./denomStructures.js";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectedMerchantService,
+} from "./faultInjection.js";
+import {
+ BankService,
+ DbInfo,
+ ExchangeService,
+ ExchangeServiceInterface,
+ FakebankService,
+ GlobalTestState,
+ HarnessExchangeBankAccount,
+ MerchantService,
+ MerchantServiceInterface,
+ WalletCli,
+ WalletClient,
+ WalletService,
+ WithAuthorization,
+ generateRandomPayto,
+ setupDb,
+ setupSharedDb,
+} from "./harness.js";
+
+import * as fs from "fs";
+
+const logger = new Logger("helpers.ts");
+
+/**
+ * @deprecated
+ */
+export interface SimpleTestEnvironment {
+ commonDb: DbInfo;
+ bank: BankService;
+ exchange: ExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ merchant: MerchantService;
+ wallet: WalletCli;
+}
+
+/**
+ * Improved version of the simple test environment,
+ * with the daemonized wallet.
+ */
+export interface SimpleTestEnvironmentNg {
+ commonDb: DbInfo;
+ bank: BankService;
+ exchange: ExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ merchant: MerchantService;
+ walletClient: WalletClient;
+ walletService: WalletService;
+}
+
+export interface EnvOptions {
+ /**
+ * If provided, enable age restrictions with the specified age mask string.
+ */
+ ageMaskSpec?: string;
+
+ mixedAgeRestriction?: boolean;
+
+ additionalExchangeConfig?(e: ExchangeService): void;
+ additionalMerchantConfig?(m: MerchantService): void;
+ additionalBankConfig?(b: BankService): void;
+}
+
+export function getSharedTestDir(): string {
+ return `/tmp/taler-harness@${process.env.USER}`;
+}
+
+export async function useSharedTestkudosEnvironment(t: GlobalTestState) {
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+
+ const sharedDir = getSharedTestDir();
+
+ fs.mkdirSync(sharedDir, { recursive: true });
+
+ const db = await setupSharedDb(t);
+
+ let bank: FakebankService;
+
+ const prevSetupDone = fs.existsSync(sharedDir + "/setup-done");
+
+ logger.info(`previous setup done: ${prevSetupDone}`);
+
+ // Wallet has longer startup-time and no dependencies,
+ // so we start it rather early.
+ const walletStartProm = createWalletDaemonWithClient(t, { name: "wallet" });
+
+ if (fs.existsSync(sharedDir + "/bank.conf")) {
+ logger.info("reusing existing bank");
+ bank = BankService.fromExistingConfig(t, {
+ overridePath: sharedDir,
+ });
+ } else {
+ logger.info("creating new bank config");
+ bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ overrideTestDir: sharedDir,
+ });
+ }
+
+ logger.info("setting up exchange");
+
+ const exchangeName = "testexchange-1";
+ const exchangeConfigFilename = sharedDir + `/exchange-${exchangeName}.conf`;
+
+ logger.info(`exchange config filename: ${exchangeConfigFilename}`);
+
+ let exchange: ExchangeService;
+
+ if (fs.existsSync(exchangeConfigFilename)) {
+ logger.info("reusing existing exchange config");
+ exchange = ExchangeService.fromExistingConfig(t, exchangeName, {
+ overridePath: sharedDir,
+ });
+ } else {
+ logger.info("creating new exchange config");
+ exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ overrideTestDir: sharedDir,
+ });
+ }
+
+ logger.info("setting up merchant");
+
+ let merchant: MerchantService;
+ const merchantName = "testmerchant-1";
+ const merchantConfigFilename = sharedDir + `/merchant-${merchantName}}`;
+
+ if (fs.existsSync(merchantConfigFilename)) {
+ merchant = MerchantService.fromExistingConfig(t, merchantName, {
+ overridePath: sharedDir,
+ });
+ } else {
+ merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ overrideTestDir: sharedDir,
+ });
+ }
+
+ logger.info("creating bank account for exchange");
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+
+ logger.info("creating exchange bank account");
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ exchange.addCoinConfigList(coinConfig);
+
+ merchant.addExchange(exchange);
+
+ logger.info("basic setup done, starting services");
+
+ if (!prevSetupDone) {
+ // Must be done sequentially, due to a concurrency
+ // issue in the *-dbinit tools.
+ await exchange.dbinit();
+ await merchant.dbinit();
+ }
+
+ const bankStart = async () => {
+ await bank.start();
+ await bank.pingUntilAvailable();
+ };
+
+ const exchangeStart = async () => {
+ await exchange.start({
+ skipDbinit: true,
+ skipKeyup: prevSetupDone,
+ });
+ await exchange.pingUntilAvailable();
+ };
+
+ const merchStart = async () => {
+ await merchant.start({
+ skipDbinit: true,
+ });
+ await merchant.pingUntilAvailable();
+
+ if (!prevSetupDone) {
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+ }
+ };
+
+ await bankStart();
+
+ const res = await Promise.all([
+ exchangeStart(),
+ merchStart(),
+ undefined,
+ walletStartProm,
+ ]);
+
+ const walletClient = res[3].walletClient;
+ const walletService = res[3].walletService;
+
+ fs.writeFileSync(sharedDir + "/setup-done", "OK");
+
+ logger.info("setup done!");
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ walletService,
+ bank,
+ exchangeBankAccount,
+ };
+}
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ *
+ * V2 uses a daemonized wallet instead of the CLI wallet.
+ */
+export async function createSimpleTestkudosEnvironmentV2(
+ t: GlobalTestState,
+ coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ opts: EnvOptions = {},
+): Promise<SimpleTestEnvironmentNg> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ if (opts.additionalBankConfig) {
+ opts.additionalBankConfig(bank);
+ }
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const ageMaskSpec = opts.ageMaskSpec;
+
+ if (ageMaskSpec) {
+ exchange.enableAgeRestrictions(ageMaskSpec);
+ // Enable age restriction for all coins.
+ exchange.addCoinConfigList(
+ coinConfig.map((x) => ({
+ ...x,
+ name: `${x.name}-age`,
+ ageRestricted: true,
+ })),
+ );
+ // For mixed age restrictions, we also offer coins without age restrictions
+ if (opts.mixedAgeRestriction) {
+ exchange.addCoinConfigList(
+ coinConfig.map((x) => ({ ...x, ageRestricted: false })),
+ );
+ }
+ } else {
+ exchange.addCoinConfigList(coinConfig);
+ }
+
+ if (opts.additionalExchangeConfig) {
+ opts.additionalExchangeConfig(exchange);
+ }
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ if (opts.additionalMerchantConfig) {
+ opts.additionalMerchantConfig(merchant);
+ }
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet", persistent: true },
+ );
+
+ console.log("setup done!");
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ walletService,
+ bank,
+ exchangeBankAccount,
+ };
+}
+
+export interface CreateWalletArgs {
+ handleNotification?(wn: WalletNotification): void;
+ name: string;
+ persistent?: boolean;
+ overrideDbPath?: string;
+ config?: PartialWalletRunConfig;
+}
+
+export async function createWalletDaemonWithClient(
+ t: GlobalTestState,
+ args: CreateWalletArgs,
+): Promise<{ walletClient: WalletClient; walletService: WalletService }> {
+ const walletService = new WalletService(t, {
+ name: args.name,
+ useInMemoryDb: !args.persistent,
+ overrideDbPath: args.overrideDbPath,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const observabilityEventFile = t.testDir + `/wallet-${args.name}-notifs.log`;
+
+ const onNotif = (notif: WalletNotification) => {
+ if (observabilityEventFile) {
+ fs.appendFileSync(
+ observabilityEventFile,
+ new Date().toISOString() + " " + JSON.stringify(notif) + "\n",
+ );
+ }
+ if (args.handleNotification) {
+ args.handleNotification(notif);
+ }
+ };
+
+ const walletClient = new WalletClient({
+ name: args.name,
+ unixPath: walletService.socketPath,
+ onNotification: onNotif,
+ });
+ await walletClient.connect();
+ const defaultRunConfig = {
+ testing: {
+ skipDefaults: true,
+ emitObservabilityEvents: !!process.env["TALER_TEST_OBSERVABILITY"],
+ },
+ } satisfies PartialWalletRunConfig;
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: args.config ?? defaultRunConfig,
+ });
+
+ return { walletClient, walletService };
+}
+
+export interface FaultyMerchantTestEnvironment {
+ commonDb: DbInfo;
+ bank: BankService;
+ exchange: ExchangeService;
+ faultyExchange: FaultInjectedExchangeService;
+ exchangeBankAccount: HarnessExchangeBankAccount;
+ merchant: MerchantService;
+ faultyMerchant: FaultInjectedMerchantService;
+ walletClient: WalletClient;
+}
+
+/**
+ * Run a test case with a simple TESTKUDOS Taler environment, consisting
+ * of one exchange, one bank and one merchant.
+ */
+export async function createFaultInjectedMerchantTestkudosEnvironment(
+ t: GlobalTestState,
+): Promise<FaultyMerchantTestEnvironment> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const faultyMerchant = new FaultInjectedMerchantService(t, merchant, 9083);
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 9081);
+
+ // Base URL must contain port that the proxy is listening on.
+ await exchange.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", "http://localhost:9081/");
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(
+ faultyExchange,
+ exchangeBankAccount.accountPaytoUri,
+ );
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(faultyExchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ bank,
+ exchangeBankAccount,
+ faultyMerchant,
+ faultyExchange,
+ };
+}
+
+export interface WithdrawViaBankResult {
+ withdrawalFinishedCond: Promise<true>;
+}
+
+/**
+ * Withdraw via a bank with the testing API enabled.
+ * Uses the new notification-based mechanism to wait for the
+ * operation to finish.
+ */
+export async function withdrawViaBankV2(
+ t: GlobalTestState,
+ p: {
+ walletClient: WalletClient;
+ bank: BankService;
+ exchange: ExchangeServiceInterface;
+ amount: AmountString | string;
+ restrictAge?: number;
+ },
+): Promise<WithdrawViaBankResult> {
+ const { walletClient: wallet, bank, exchange, amount } = p;
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl);
+
+ const user = await bankClient.createRandomBankUser();
+ const wop = await bankClient.createWithdrawalOperation(user.username, amount);
+
+ // Hand it to the wallet
+
+ await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ restrictAge: p.restrictAge,
+ });
+
+ // Withdraw (AKA select)
+
+ const acceptRes = await wallet.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ restrictAge: p.restrictAge,
+ },
+ );
+
+ const withdrawalFinishedCond = wallet.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId === acceptRes.transactionId,
+ );
+
+ // Confirm it
+
+ await bankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ return {
+ withdrawalFinishedCond,
+ };
+}
+
+export async function applyTimeTravelV2(
+ timetravelOffsetMs: number,
+ s: {
+ exchange?: ExchangeService;
+ merchant?: MerchantService;
+ walletClient?: WalletClient;
+ },
+): Promise<void> {
+ if (s.exchange) {
+ await s.exchange.stop();
+ s.exchange.setTimetravel(timetravelOffsetMs);
+ await s.exchange.start();
+ await s.exchange.pingUntilAvailable();
+ }
+
+ if (s.merchant) {
+ await s.merchant.stop();
+ s.merchant.setTimetravel(timetravelOffsetMs);
+ await s.merchant.start();
+ await s.merchant.pingUntilAvailable();
+ }
+
+ if (s.walletClient) {
+ await s.walletClient.call(WalletApiOperation.TestingSetTimetravel, {
+ offsetMs: timetravelOffsetMs,
+ });
+ }
+}
+
+/**
+ * Make a simple payment and check that it succeeded.
+ */
+export async function makeTestPaymentV2(
+ t: GlobalTestState,
+ args: {
+ merchant: MerchantServiceInterface;
+ walletClient: WalletClient;
+ order: Partial<MerchantContractTerms>;
+ instance?: string;
+ },
+ auth: WithAuthorization = {},
+): Promise<void> {
+ // Set up order.
+
+ const { walletClient, merchant, instance } = args;
+
+ const merchantClient = new MerchantApiClient(
+ merchant.makeInstanceBaseUrl(instance),
+ );
+
+ const orderResp = await merchantClient.createOrder({
+ order: args.order,
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ instance,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+}
diff --git a/packages/taler-wallet-cli/src/harness/sync.ts b/packages/taler-harness/src/harness/sync.ts
index 16be89eff..64c9acaef 100644
--- a/packages/taler-wallet-cli/src/harness/sync.ts
+++ b/packages/taler-harness/src/harness/sync.ts
@@ -26,8 +26,9 @@ import {
ProcessWrapper,
} from "../harness/harness.js";
import { Configuration } from "@gnu-taler/taler-util";
+import * as child_process from "child_process";
-const exec = util.promisify(require("child_process").exec);
+const exec = util.promisify(child_process.exec);
export interface SyncConfig {
/**
diff --git a/packages/taler-harness/src/import-meta-url.js b/packages/taler-harness/src/import-meta-url.js
new file mode 100644
index 000000000..c0e657160
--- /dev/null
+++ b/packages/taler-harness/src/import-meta-url.js
@@ -0,0 +1,2 @@
+// Helper to make 'import.meta.url' available in esbuild-bundled code as well.
+export const import_meta_url = require("url").pathToFileURL(__filename);
diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts
new file mode 100644
index 000000000..0f282e123
--- /dev/null
+++ b/packages/taler-harness/src/index.ts
@@ -0,0 +1,1288 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AccessToken,
+ AmountString,
+ Amounts,
+ BalancesResponse,
+ Configuration,
+ Duration,
+ HttpStatusCode,
+ Logger,
+ PaytoString,
+ TalerAuthenticationHttpClient,
+ TalerBankConversionHttpClient,
+ TalerCoreBankHttpClient,
+ TalerErrorCode,
+ TalerMerchantInstanceHttpClient,
+ TalerMerchantManagementHttpClient,
+ TransactionsResponse,
+ createAccessToken,
+ decodeCrock,
+ encodeCrock,
+ generateIban,
+ j2s,
+ randomBytes,
+ rsaBlind,
+ setGlobalLogLevelFromString,
+ setPrintHttpRequestAsCurl,
+ stringifyPayTemplateUri,
+} from "@gnu-taler/taler-util";
+import { clk } from "@gnu-taler/taler-util/clk";
+import {
+ HttpResponse,
+ createPlatformHttpLib,
+} from "@gnu-taler/taler-util/http";
+import {
+ CryptoDispatcher,
+ SynchronousCryptoWorkerFactoryPlain,
+ WalletApiOperation,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ downloadExchangeInfo,
+ topupReserveWithBank,
+} from "@gnu-taler/taler-wallet-core/dbless";
+import { deepStrictEqual } from "assert";
+import fs from "fs";
+import os from "os";
+import path from "path";
+import { runBench1 } from "./bench1.js";
+import { runBench2 } from "./bench2.js";
+import { runBench3 } from "./bench3.js";
+import { runEnvFull } from "./env-full.js";
+import { runEnv1 } from "./env1.js";
+import {
+ GlobalTestState,
+ WalletClient,
+ delayMs,
+ runTestWithState,
+} from "./harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+} from "./harness/helpers.js";
+import { getTestInfo, runTests } from "./integrationtests/testrunner.js";
+import { lintExchangeDeployment } from "./lint.js";
+import { randomUUID } from "crypto";
+
+const logger = new Logger("taler-harness:index.ts");
+
+process.on("unhandledRejection", (error: any) => {
+ logger.error("unhandledRejection", error.message);
+ logger.error("stack", error.stack);
+ process.exit(2);
+});
+
+declare const __VERSION__: string;
+declare const __GIT_HASH__: string;
+function printVersion(): void {
+ console.log(`${__VERSION__} ${__GIT_HASH__}`);
+ process.exit(0);
+}
+
+export const testingCli = clk
+ .program("testing", {
+ help: "Command line interface for the GNU Taler test/deployment harness.",
+ })
+ .maybeOption("log", ["-L", "--log"], clk.STRING, {
+ help: "configure log level (NONE, ..., TRACE)",
+ onPresentHandler: (x) => {
+ setGlobalLogLevelFromString(x);
+ },
+ })
+ .flag("version", ["-v", "--version"], {
+ onPresentHandler: printVersion,
+ })
+ .flag("verbose", ["-V", "--verbose"], {
+ help: "Enable verbose output.",
+ });
+
+const advancedCli = testingCli.subcommand("advancedArgs", "advanced", {
+ help: "Subcommands for advanced operations (only use if you know what you're doing!).",
+});
+
+advancedCli
+ .subcommand("decode", "decode", {
+ help: "Decode base32-crockford.",
+ })
+ .action((args) => {
+ const enc = fs.readFileSync(0, "utf8");
+ console.log(decodeCrock(enc.trim()));
+ });
+
+advancedCli
+ .subcommand("bench1", "bench1", {
+ help: "Run the 'bench1' benchmark",
+ })
+ .requiredOption("configJson", ["--config-json"], clk.STRING)
+ .action(async (args) => {
+ let config: any;
+ try {
+ config = JSON.parse(args.bench1.configJson);
+ } catch (e) {
+ console.log("Could not parse config JSON");
+ }
+ await runBench1(config);
+ });
+
+advancedCli
+ .subcommand("bench2", "bench2", {
+ help: "Run the 'bench2' benchmark",
+ })
+ .requiredOption("configJson", ["--config-json"], clk.STRING)
+ .action(async (args) => {
+ let config: any;
+ try {
+ config = JSON.parse(args.bench2.configJson);
+ } catch (e) {
+ console.log("Could not parse config JSON");
+ }
+ await runBench2(config);
+ });
+
+advancedCli
+ .subcommand("bench3", "bench3", {
+ help: "Run the 'bench3' benchmark",
+ })
+ .requiredOption("configJson", ["--config-json"], clk.STRING)
+ .action(async (args) => {
+ let config: any;
+ try {
+ config = JSON.parse(args.bench3.configJson);
+ } catch (e) {
+ console.log("Could not parse config JSON");
+ }
+ await runBench3(config);
+ });
+
+advancedCli
+ .subcommand("envFull", "env-full", {
+ help: "Run a test environment for bench1",
+ })
+ .action(async (args) => {
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-env-full-"));
+ const testState = new GlobalTestState({
+ testDir,
+ });
+ await runTestWithState(testState, runEnvFull, "env-full", true);
+ });
+
+advancedCli
+ .subcommand("env1", "env1", {
+ help: "Run a test environment for bench1",
+ })
+ .action(async (args) => {
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-env1-"));
+ const testState = new GlobalTestState({
+ testDir,
+ });
+ await runTestWithState(testState, runEnv1, "env1", true);
+ });
+
+async function doDbChecks(
+ t: GlobalTestState,
+ walletClient: WalletClient,
+ indir: string,
+): Promise<void> {
+ // Check that balance didn't break
+ const balPath = `${indir}/wallet-balances.json`;
+ const expectedBal: BalancesResponse = JSON.parse(
+ fs.readFileSync(balPath, { encoding: "utf8" }),
+ ) as BalancesResponse;
+ const actualBal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertDeepEqual(actualBal.balances.length, expectedBal.balances.length);
+
+ // Check that transactions didn't break
+ const txnPath = `${indir}/wallet-transactions.json`;
+ const expectedTxn: TransactionsResponse = JSON.parse(
+ fs.readFileSync(txnPath, { encoding: "utf8" }),
+ ) as TransactionsResponse;
+ const actualTxn = await walletClient.call(
+ WalletApiOperation.GetTransactions,
+ { includeRefreshes: true },
+ );
+ t.assertDeepEqual(
+ actualTxn.transactions.length,
+ expectedTxn.transactions.length,
+ );
+}
+
+advancedCli
+ .subcommand("walletDbcheck", "wallet-dbcheck", {
+ help: "Check a wallet database (used for migration testing).",
+ })
+ .requiredArgument("indir", clk.STRING)
+ .action(async (args) => {
+ const indir = args.walletDbcheck.indir;
+ if (!fs.existsSync(indir)) {
+ throw Error("directory to be checked does not exist");
+ }
+
+ const testRootDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-dbchk-"));
+ const t: GlobalTestState = new GlobalTestState({
+ testDir: testRootDir,
+ });
+ const origWalletDbPath = `${indir}/wallet-db.sqlite3`;
+ const testWalletDbPath = `${testRootDir}/wallet-testdb.sqlite3`;
+ fs.cpSync(origWalletDbPath, testWalletDbPath);
+ if (!fs.existsSync(origWalletDbPath)) {
+ throw new Error("wallet db to be checked does not exist");
+ }
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet-loaded", overrideDbPath: testWalletDbPath },
+ );
+
+ await walletService.pingUntilAvailable();
+
+ // Do DB checks with the DB we loaded.
+ await doDbChecks(t, walletClient, indir);
+
+ const {
+ walletClient: freshWalletClient,
+ walletService: freshWalletService,
+ } = await createWalletDaemonWithClient(t, {
+ name: "wallet-fresh",
+ persistent: false,
+ });
+
+ await freshWalletService.pingUntilAvailable();
+
+ // Check that we can still import the backup JSON.
+
+ const backupPath = `${indir}/wallet-backup.json`;
+ const backupData = JSON.parse(
+ fs.readFileSync(backupPath, { encoding: "utf8" }),
+ );
+ await freshWalletClient.call(WalletApiOperation.ImportDb, {
+ dump: backupData,
+ });
+
+ // Repeat same checks with wallet that we restored from backup
+ // instead of from the DB file.
+ await doDbChecks(t, freshWalletClient, indir);
+
+ await t.shutdown();
+ });
+
+advancedCli
+ .subcommand("walletDbgen", "wallet-dbgen", {
+ help: "Generate a wallet test database (to be used for migration testing).",
+ })
+ .requiredArgument("outdir", clk.STRING)
+ .action(async (args) => {
+ const outdir = args.walletDbgen.outdir;
+ if (fs.existsSync(outdir)) {
+ throw new Error("outdir already exists, please delete first");
+ }
+ fs.mkdirSync(outdir, {
+ recursive: true,
+ });
+
+ const testRootDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-dbgen-"));
+ console.log(`generating data in ${testRootDir}`);
+ const t = new GlobalTestState({
+ testDir: testRootDir,
+ });
+ const { walletClient, walletService, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+ await walletClient.call(WalletApiOperation.RunIntegrationTestV2, {
+ amountToSpend: "TESTKUDOS:5" as AmountString,
+ amountToWithdraw: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ const transactionsJson = await walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {
+ includeRefreshes: true,
+ },
+ );
+
+ const balancesJson = await walletClient.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+
+ const backupJson = await walletClient.call(WalletApiOperation.ExportDb, {});
+
+ const versionJson = await walletClient.call(
+ WalletApiOperation.GetVersion,
+ {},
+ );
+
+ await walletService.stop();
+
+ await t.shutdown();
+
+ console.log(`generated data in ${testRootDir}`);
+
+ fs.copyFileSync(walletService.dbPath, `${outdir}/wallet-db.sqlite3`);
+ fs.writeFileSync(
+ `${outdir}/wallet-transactions.json`,
+ j2s(transactionsJson),
+ );
+ fs.writeFileSync(`${outdir}/wallet-balances.json`, j2s(balancesJson));
+ fs.writeFileSync(`${outdir}/wallet-backup.json`, j2s(backupJson));
+ fs.writeFileSync(`${outdir}/wallet-version.json`, j2s(versionJson));
+ fs.writeFileSync(
+ `${outdir}/meta.json`,
+ j2s({
+ timestamp: new Date(),
+ }),
+ );
+ });
+
+const configCli = testingCli.subcommand("configArgs", "config", {
+ help: "Subcommands for handling the Taler configuration.",
+});
+
+configCli.subcommand("show", "show").action(async (args) => {
+ const config = Configuration.load();
+ const cfgStr = config.stringify({
+ diagnostics: true,
+ });
+ console.log(cfgStr);
+});
+
+configCli
+ .subcommand("get", "get")
+ .requiredArgument("section", clk.STRING)
+ .requiredArgument("option", clk.STRING)
+ .flag("file", ["-f"])
+ .action(async (args) => {
+ const config = Configuration.load();
+ let res;
+ if (args.get.file) {
+ res = config.getPath(args.get.section, args.get.option);
+ } else {
+ res = config.getString(args.get.section, args.get.option);
+ }
+ if (res.isDefined()) {
+ console.log(res.required());
+ } else {
+ console.warn("not found");
+ process.exit(1);
+ }
+ });
+
+const deploymentCli = testingCli.subcommand("deploymentArgs", "deployment", {
+ help: "Subcommands for handling GNU Taler deployments.",
+});
+
+deploymentCli
+ .subcommand("testTalerdotnetDemo", "test-demodottalerdotnet")
+ .action(async (args) => {
+ const http = createPlatformHttpLib();
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+ const exchangeBaseUrl = "https://exchange.demo.taler.net/";
+ const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
+ await topupReserveWithBank({
+ amount: "KUDOS:10" as AmountString,
+ corebankApiBaseUrl: "https://bank.demo.taler.net/",
+ exchangeInfo,
+ http,
+ reservePub: reserveKeyPair.pub,
+ });
+ let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl);
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ console.log("requesting", reserveUrl.href);
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+ const reserveStatusResp = await longpollReq;
+ console.log("reserve status", reserveStatusResp.status);
+ });
+
+deploymentCli
+ .subcommand("testDemoTestdotdalerdotnet", "test-testdottalerdotnet")
+ .action(async (args) => {
+ const http = createPlatformHttpLib();
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+ const exchangeBaseUrl = "https://exchange.test.taler.net/";
+ const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
+ await topupReserveWithBank({
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: "https://bank.test.taler.net/",
+ exchangeInfo,
+ http,
+ reservePub: reserveKeyPair.pub,
+ });
+ let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl);
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ console.log("requesting", reserveUrl.href);
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+ const reserveStatusResp = await longpollReq;
+ console.log("reserve status", reserveStatusResp.status);
+ });
+
+deploymentCli
+ .subcommand("testLocalhostDemo", "test-demo-localhost")
+ .action(async (args) => {
+ // Run checks against the "env-full" demo deployment on localhost
+ const http = createPlatformHttpLib();
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+ const exchangeBaseUrl = "http://localhost:8081/";
+ const exchangeInfo = await downloadExchangeInfo(exchangeBaseUrl, http);
+ await topupReserveWithBank({
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/",
+ exchangeInfo,
+ http,
+ reservePub: reserveKeyPair.pub,
+ });
+ let reserveUrl = new URL(`reserves/${reserveKeyPair.pub}`, exchangeBaseUrl);
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ console.log("requesting", reserveUrl.href);
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+ const reserveStatusResp = await longpollReq;
+ console.log("reserve status", reserveStatusResp.status);
+ });
+
+deploymentCli
+ .subcommand("lintExchange", "lint-exchange", {
+ help: "Run checks on the exchange deployment.",
+ })
+ .flag("cont", ["--continue"], {
+ help: "Continue after errors if possible",
+ })
+ .flag("debug", ["--debug"], {
+ help: "Output extra debug info",
+ })
+ .action(async (args) => {
+ await lintExchangeDeployment(
+ args.lintExchange.debug,
+ args.lintExchange.cont,
+ );
+ });
+
+deploymentCli
+ .subcommand("waitService", "wait-taler-service", {
+ help: "Wait for the config endpoint of a Taler-style service to be available",
+ })
+ .requiredArgument("serviceName", clk.STRING)
+ .requiredArgument("serviceConfigUrl", clk.STRING)
+ .action(async (args) => {
+ const serviceName = args.waitService.serviceName;
+ const serviceUrl = args.waitService.serviceConfigUrl;
+ console.log(
+ `Waiting for service ${serviceName} to be ready at ${serviceUrl}`,
+ );
+ const httpLib = createPlatformHttpLib();
+ while (1) {
+ console.log(`Fetching ${serviceUrl}`);
+ let resp: HttpResponse;
+ try {
+ resp = await httpLib.fetch(serviceUrl);
+ } catch (e) {
+ console.log(
+ `Got network error for service ${serviceName} at ${serviceUrl}`,
+ );
+ await delayMs(1000);
+ continue;
+ }
+ if (resp.status != 200) {
+ console.log(
+ `Got unexpected status ${resp.status} for service at ${serviceUrl}`,
+ );
+ await delayMs(1000);
+ continue;
+ }
+ let respJson: any;
+ try {
+ respJson = await resp.json();
+ } catch (e) {
+ console.log(
+ `Got json error for service ${serviceName} at ${serviceUrl}`,
+ );
+ await delayMs(1000);
+ continue;
+ }
+ const recServiceName = respJson.name;
+ console.log(`Got name ${recServiceName}`);
+ if (recServiceName != serviceName) {
+ console.log(`A different service is still running at ${serviceUrl}`);
+ await delayMs(1000);
+ continue;
+ }
+ console.log(`service ${serviceName} at ${serviceUrl} is now available`);
+ return;
+ }
+ });
+
+deploymentCli
+ .subcommand("waitEndpoint", "wait-endpoint", {
+ help: "Wait for an endpoint to return an HTTP 200 Ok status with JSON body",
+ })
+ .requiredArgument("serviceEndpoint", clk.STRING)
+ .action(async (args) => {
+ const serviceUrl = args.waitEndpoint.serviceEndpoint;
+ console.log(`Waiting for endpoint ${serviceUrl} to be ready`);
+ const httpLib = createPlatformHttpLib();
+ while (1) {
+ console.log(`Fetching ${serviceUrl}`);
+ let resp: HttpResponse;
+ try {
+ resp = await httpLib.fetch(serviceUrl);
+ } catch (e) {
+ console.log(`Got network error for service at ${serviceUrl}`);
+ await delayMs(1000);
+ continue;
+ }
+ if (resp.status != 200) {
+ console.log(
+ `Got unexpected status ${resp.status} for service at ${serviceUrl}`,
+ );
+ await delayMs(1000);
+ continue;
+ }
+ let respJson: any;
+ try {
+ respJson = await resp.json();
+ } catch (e) {
+ console.log(`Got json error for service at ${serviceUrl}`);
+ await delayMs(1000);
+ continue;
+ }
+ return;
+ }
+ });
+
+deploymentCli
+ .subcommand("genIban", "gen-iban", {
+ help: "Generate a random IBAN.",
+ })
+ .requiredArgument("countryCode", clk.STRING)
+ .requiredArgument("length", clk.INT)
+ .action(async (args) => {
+ console.log(generateIban(args.genIban.countryCode, args.genIban.length));
+ });
+
+deploymentCli
+ .subcommand("provisionBankMerchant", "provision-bank-and-merchant", {
+ help: "Provision a bank account, merchant instance and link them together.",
+ })
+ .requiredArgument("merchantApiBaseUrl", clk.STRING, {
+ help: "URL location of the merchant backend",
+ })
+ .requiredArgument("corebankApiBaseUrl", clk.STRING, {
+ help: "URL location of the libeufin bank backend",
+ })
+ .requiredOption(
+ "merchantToken",
+ ["--merchant-management-token"],
+ clk.STRING,
+ {
+ help: "access token of the default instance in the merchant backend",
+ },
+ )
+ .maybeOption("bankToken", ["--bank-admin-token"], clk.STRING, {
+ help: "libeufin bank admin's token if the account creation is restricted",
+ })
+ .maybeOption("bankPassword", ["--bank-admin-password"], clk.STRING, {
+ help: "libeufin bank admin's password if the account creation is restricted, it will override --bank-admin-token",
+ })
+ .requiredOption("name", ["--legal-name"], clk.STRING, {
+ help: "legal name of the merchant",
+ })
+ .maybeOption("email", ["--email"], clk.STRING, {
+ help: "email contact of the merchant",
+ })
+ .maybeOption("phone", ["--phone"], clk.STRING, {
+ help: "phone contact of the merchant",
+ })
+ .requiredOption("id", ["--id"], clk.STRING, {
+ help: "login id for the bank account and instance id of the merchant backend",
+ })
+ .flag("template", ["--create-template"], {
+ help: "use this flag to create a default template for the merchant with fixed summary",
+ })
+ .requiredOption("password", ["--password"], clk.STRING, {
+ help: "password of the accounts in libeufin bank and merchant backend",
+ })
+ .flag("randomPassword", ["--set-random-password"], {
+ help: "if everything worked ok, change the password of the accounts at the end",
+ })
+ .action(async (args) => {
+ const managementToken = createAccessToken(
+ args.provisionBankMerchant.merchantToken,
+ );
+ const bankAdminPassword = args.provisionBankMerchant.bankPassword;
+ const bankAdminTokenArg = args.provisionBankMerchant.bankToken
+ ? createAccessToken(args.provisionBankMerchant.bankToken)
+ : undefined;
+ const id = args.provisionBankMerchant.id;
+ const name = args.provisionBankMerchant.name;
+ const email = args.provisionBankMerchant.email;
+ const phone = args.provisionBankMerchant.phone;
+ const password = args.provisionBankMerchant.password;
+
+ const httpLib = createPlatformHttpLib({});
+ const merchantManager = new TalerMerchantManagementHttpClient(
+ args.provisionBankMerchant.merchantApiBaseUrl,
+ httpLib,
+ );
+ const bank = new TalerCoreBankHttpClient(
+ args.provisionBankMerchant.corebankApiBaseUrl,
+ httpLib,
+ );
+ const instanceURL = merchantManager.getSubInstanceAPI(id).href;
+ const merchantInstance = new TalerMerchantInstanceHttpClient(
+ instanceURL,
+ httpLib,
+ );
+ const conv = new TalerBankConversionHttpClient(
+ bank.getConversionInfoAPI().href,
+ httpLib,
+ );
+ const bankAuth = new TalerAuthenticationHttpClient(
+ bank.getAuthenticationAPI(id).href,
+ httpLib,
+ );
+
+ const bc = await bank.getConfig();
+ if (bc.type === "fail") {
+ logger.error(`couldn't get bank config. ${bc.detail.hint}`);
+ return;
+ }
+ if (!bank.isCompatible(bc.body.version)) {
+ logger.error(
+ `bank server version is not compatible: ${bc.body.version}, client version: ${bank.PROTOCOL_VERSION}`,
+ );
+ return;
+ }
+ const mc = await merchantManager.getConfig();
+ if (mc.type === "fail") {
+ logger.error(`couldn't get merchant config. ${mc.detail.hint}`);
+ return;
+ }
+ if (!merchantManager.isCompatible(mc.body.version)) {
+ logger.error(
+ `merchant server version is not compatible: ${mc.body.version}, client version: ${merchantManager.PROTOCOL_VERSION}`,
+ );
+ return;
+ }
+
+ let bankAdminToken: AccessToken | undefined;
+ if (bankAdminPassword) {
+ const adminAuth = new TalerAuthenticationHttpClient(
+ bank.getAuthenticationAPI("admin").href,
+ httpLib,
+ );
+
+ const resp = await adminAuth.createAccessTokenBasic(
+ "admin",
+ bankAdminPassword,
+ {
+ scope: "write",
+ duration: {
+ d_us: 1000 * 1000 * 10, //10 secs
+ },
+ refreshable: false,
+ },
+ );
+ if (resp.type === "fail") {
+ logger.error(`could not get bank admin token from password.`);
+ return;
+ }
+ bankAdminToken = resp.body.access_token;
+ } else {
+ bankAdminToken = bankAdminTokenArg;
+ }
+
+ /**
+ * create bank account
+ */
+ let accountPayto: PaytoString;
+ {
+ const resp = await bank.createAccount(bankAdminToken, {
+ name: name,
+ password: password,
+ username: id,
+ contact_data:
+ email || phone
+ ? {
+ email: email,
+ phone: phone,
+ }
+ : undefined,
+ });
+
+ if (resp.type === "fail") {
+ logger.error(
+ `unable to provision bank account, HTTP response status ${resp.case}`,
+ );
+ process.exit(2);
+ }
+ logger.info(`account ${id} successfully provisioned`);
+ accountPayto = resp.body.internal_payto_uri;
+ }
+
+ /**
+ * create merchant account
+ */
+ {
+ const resp = await merchantManager.createInstance(managementToken, {
+ address: {},
+ auth: {
+ method: "token",
+ token: createAccessToken(password),
+ },
+ default_pay_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ default_wire_transfer_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ id: id,
+ jurisdiction: {},
+ name: name,
+ use_stefan: true,
+ });
+
+ if (resp.type === "ok") {
+ logger.info(`instance ${id} created successfully`);
+ } else if (resp.case === HttpStatusCode.Conflict) {
+ logger.info(`instance ${id} already exists`);
+ } else {
+ logger.error(
+ `unable to create instance ${id}, HTTP status ${resp.case}`,
+ );
+ process.exit(2);
+ }
+ }
+
+ let wireAccount: string;
+ /**
+ * link bank account and merchant
+ */
+ {
+ const resp = await merchantInstance.addBankAccount(
+ createAccessToken(password),
+ {
+ payto_uri: accountPayto,
+ credit_facade_url: bank.getRevenueAPI(id).href,
+ credit_facade_credentials: {
+ type: "basic",
+ username: id,
+ password: password,
+ },
+ },
+ );
+ if (resp.type === "fail") {
+ console.error(
+ `unable to configure bank account for instance ${id}, status ${resp.case}`,
+ );
+ console.error(j2s(resp.detail));
+ process.exit(2);
+ }
+ wireAccount = resp.body.h_wire;
+ }
+
+ logger.info(`successfully configured bank account for ${id}`);
+
+ let templateURI;
+ /**
+ * create template
+ */
+ if (args.provisionBankMerchant.template) {
+ let currency = bc.body.currency;
+ if (bc.body.allow_conversion) {
+ const cc = await conv.getConfig();
+ if (cc.type === "ok") {
+ currency = cc.body.fiat_currency;
+ } else {
+ console.error(`could not get fiat currency status ${cc.case}`);
+ console.error(j2s(cc.detail));
+ }
+ } else {
+ console.log(`conversion is disabled, using bank currency`);
+ }
+
+ {
+ const resp = await merchantInstance.addTemplate(
+ createAccessToken(password),
+ {
+ template_id: "default",
+ template_description: "First template",
+ template_contract: {
+ pay_duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ minimum_age: 0,
+ currency,
+ summary: "Pay me!",
+ },
+ },
+ );
+ if (resp.type === "fail") {
+ console.error(
+ `unable to create template for insntaince ${id}, status ${resp.case}`,
+ );
+ console.error(j2s(resp.detail));
+ process.exit(2);
+ }
+ }
+
+ logger.info(`template default successfully created`);
+ templateURI = stringifyPayTemplateUri({
+ merchantBaseUrl: instanceURL,
+ templateId: "default",
+ templateParams: {
+ amount: currency,
+ },
+ });
+ }
+
+ let finalPassword = password;
+ if (args.provisionBankMerchant.randomPassword) {
+ const prevPassword = password;
+ const randomPassword = encodeCrock(randomBytes(16));
+ logger.info("random password: ", randomPassword);
+ let token: AccessToken;
+ {
+ const resp = await bankAuth.createAccessTokenBasic(id, prevPassword, {
+ scope: "readwrite",
+ duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ refreshable: false,
+ });
+ if (resp.type === "fail") {
+ console.error(
+ `unable to login into bank accountfor user ${id}, status ${resp.case}`,
+ );
+ console.error(j2s(resp.detail));
+ process.exit(2);
+ }
+ token = resp.body.access_token;
+ }
+
+ {
+ const resp = await bank.updatePassword(
+ { username: id, token },
+ {
+ old_password: prevPassword,
+ new_password: randomPassword,
+ },
+ );
+ if (resp.type === "fail") {
+ console.error(
+ `unable to change bank password for user ${id}, status ${resp.case}`,
+ );
+ if (resp.case !== HttpStatusCode.Accepted) {
+ console.error(j2s(resp.detail));
+ } else {
+ console.error("2FA required");
+ }
+ process.exit(2);
+ }
+ }
+
+ {
+ const resp = await merchantInstance.updateCurrentInstanceAuthentication(
+ createAccessToken(prevPassword),
+ {
+ method: "token",
+ token: createAccessToken(randomPassword),
+ },
+ );
+ if (resp.type === "fail") {
+ console.error(
+ `unable to change merchant password for instance ${id}, status ${resp.case}`,
+ );
+ console.error(j2s(resp.detail));
+ process.exit(2);
+ }
+ }
+
+ {
+ const resp = await merchantInstance.updateBankAccount(
+ createAccessToken(randomPassword),
+ wireAccount,
+ {
+ credit_facade_url: bank.getRevenueAPI(id).href,
+ credit_facade_credentials: {
+ type: "basic",
+ username: id,
+ password: randomPassword,
+ },
+ },
+ );
+ if (resp.type != "ok") {
+ console.error(
+ `unable to update bank account for instance ${id}, status ${resp.case}`,
+ );
+ console.error(j2s(resp.detail));
+ process.exit(2);
+ }
+ }
+ finalPassword = randomPassword;
+ }
+ logger.info(`successfully configured bank account for ${id}`);
+
+ /**
+ * show result
+ */
+ console.log(
+ JSON.stringify(
+ {
+ bankUser: id,
+ bankURL: args.provisionBankMerchant.corebankApiBaseUrl,
+ merchantURL: instanceURL,
+ templateURI,
+ password: finalPassword,
+ },
+ undefined,
+ 2,
+ ),
+ );
+ });
+
+deploymentCli
+ .subcommand("provisionMerchantInstance", "provision-merchant-instance", {
+ help: "Provision a merchant backend instance.",
+ })
+ .requiredArgument("merchantApiBaseUrl", clk.STRING)
+ .requiredOption("managementToken", ["--management-token"], clk.STRING)
+ .requiredOption("instanceToken", ["--instance-token"], clk.STRING)
+ .requiredOption("name", ["--name"], clk.STRING)
+ .requiredOption("id", ["--id"], clk.STRING)
+ .requiredOption("payto", ["--payto"], clk.STRING)
+ .maybeOption("bankURL", ["--bankURL"], clk.STRING)
+ .maybeOption("bankUser", ["--bankUser"], clk.STRING)
+ .maybeOption("bankPassword", ["--bankPassword"], clk.STRING)
+ .action(async (args) => {
+ const httpLib = createPlatformHttpLib({});
+ const baseUrl = args.provisionMerchantInstance.merchantApiBaseUrl;
+ const api = new TalerMerchantManagementHttpClient(baseUrl, httpLib);
+ const managementToken = createAccessToken(
+ args.provisionMerchantInstance.managementToken,
+ );
+ const instanceToken = createAccessToken(
+ args.provisionMerchantInstance.instanceToken,
+ );
+ const instanceId = args.provisionMerchantInstance.id;
+ const instancceName = args.provisionMerchantInstance.name;
+ const bankURL = args.provisionMerchantInstance.bankURL;
+ const bankUser = args.provisionMerchantInstance.bankUser;
+ const bankPassword = args.provisionMerchantInstance.bankPassword;
+ const accountPayto = args.provisionMerchantInstance.payto as PaytoString;
+
+ const createResp = await api.createInstance(managementToken, {
+ address: {},
+ auth: {
+ method: "token",
+ token: instanceToken,
+ },
+ default_pay_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ default_wire_transfer_delay: { d_us: 1 },
+ id: instanceId,
+ jurisdiction: {},
+ name: instancceName,
+ use_stefan: true,
+ });
+
+ if (createResp.type === "ok") {
+ logger.info(`instance ${instanceId} created successfully`);
+ } else if (createResp.case === HttpStatusCode.Conflict) {
+ logger.info(`instance ${instanceId} already exists`);
+ } else {
+ logger.error(
+ `unable to create instance ${instanceId}, HTTP status ${createResp.case}`,
+ );
+ process.exit(2);
+ }
+
+ const createAccountResp = await api.addBankAccount(instanceToken, {
+ payto_uri: accountPayto,
+ credit_facade_url: bankURL,
+ credit_facade_credentials:
+ bankUser && bankPassword
+ ? {
+ type: "basic",
+ username: bankUser,
+ password: bankPassword,
+ }
+ : undefined,
+ });
+ if (createAccountResp.type != "ok") {
+ console.error(
+ `unable to configure bank account for instance ${instanceId}, status ${createAccountResp.case}`,
+ );
+ console.error(j2s(createAccountResp.detail));
+ process.exit(2);
+ }
+ logger.info(`successfully configured bank account for ${instanceId}`);
+ });
+
+deploymentCli
+ .subcommand("provisionBankAccount", "provision-bank-account", {
+ help: "Provision a corebank account.",
+ })
+ .requiredArgument("corebankApiBaseUrl", clk.STRING)
+ .flag("exchange", ["--exchange"])
+ .flag("public", ["--public"])
+ .requiredOption("login", ["--login"], clk.STRING)
+ .requiredOption("name", ["--name"], clk.STRING)
+ .requiredOption("password", ["--password"], clk.STRING)
+ .maybeOption("internalPayto", ["--payto"], clk.STRING)
+ .action(async (args) => {
+ const httpLib = createPlatformHttpLib();
+ const baseUrl = args.provisionBankAccount.corebankApiBaseUrl;
+ const api = new TalerCoreBankHttpClient(baseUrl, httpLib);
+
+ const accountLogin = args.provisionBankAccount.login;
+ const resp = await api.createAccount(undefined, {
+ name: args.provisionBankAccount.name,
+ password: args.provisionBankAccount.password,
+ username: accountLogin,
+ is_public: !!args.provisionBankAccount.public,
+ is_taler_exchange: !!args.provisionBankAccount.exchange,
+ payto_uri: args.provisionBankAccount.internalPayto as PaytoString,
+ });
+
+ if (resp.type === "ok") {
+ logger.info(`account ${accountLogin} successfully provisioned`);
+ return;
+ }
+ logger.error(
+ `unable to provision bank account, HTTP response status ${resp.case}`,
+ );
+ process.exit(2);
+ });
+
+deploymentCli
+ .subcommand("coincfg", "gen-coin-config", {
+ help: "Generate a coin/denomination configuration for the exchange.",
+ })
+ .requiredOption("minAmount", ["--min-amount"], clk.STRING, {
+ help: "Smallest denomination",
+ })
+ .requiredOption("maxAmount", ["--max-amount"], clk.STRING, {
+ help: "Largest denomination",
+ })
+ .flag("noFees", ["--no-fees"])
+ .action(async (args) => {
+ let out = "";
+
+ const stamp = Math.floor(new Date().getTime() / 1000);
+
+ const min = Amounts.parseOrThrow(args.coincfg.minAmount);
+ const max = Amounts.parseOrThrow(args.coincfg.maxAmount);
+ if (min.currency != max.currency) {
+ console.error("currency mismatch");
+ process.exit(1);
+ }
+ const currency = min.currency;
+ let x = min;
+ let n = 1;
+
+ out += "# Coin configuration for the exchange.\n";
+ out += '# Should be placed in "/etc/taler/conf.d/exchange-coins.conf".\n';
+ out += "\n";
+
+ while (Amounts.cmp(x, max) < 0) {
+ out += `[COIN-${currency}-n${n}-t${stamp}]\n`;
+ out += `VALUE = ${Amounts.stringify(x)}\n`;
+ out += `DURATION_WITHDRAW = 7 days\n`;
+ out += `DURATION_SPEND = 2 years\n`;
+ out += `DURATION_LEGAL = 6 years\n`;
+ out += `FEE_WITHDRAW = ${currency}:0\n`;
+ if (args.coincfg.noFees) {
+ out += `FEE_DEPOSIT = ${currency}:0\n`;
+ } else {
+ out += `FEE_DEPOSIT = ${Amounts.stringify(min)}\n`;
+ }
+ out += `FEE_REFRESH = ${currency}:0\n`;
+ out += `FEE_REFUND = ${currency}:0\n`;
+ out += `RSA_KEYSIZE = 2048\n`;
+ out += `CIPHER = RSA\n`;
+ out += "\n";
+ x = Amounts.add(x, x).amount;
+ n++;
+ }
+
+ console.log(out);
+ });
+
+const deploymentConfigCli = deploymentCli.subcommand("configArgs", "config", {
+ help: "Subcommands the Taler configuration.",
+});
+
+deploymentConfigCli
+ .subcommand("show", "show")
+ .flag("diagnostics", ["-d", "--diagnostics"])
+ .maybeArgument("cfgfile", clk.STRING, {})
+ .action(async (args) => {
+ const cfg = Configuration.load(args.show.cfgfile);
+ console.log(
+ cfg.stringify({
+ diagnostics: args.show.diagnostics,
+ }),
+ );
+ });
+
+testingCli.subcommand("logtest", "logtest").action(async (args) => {
+ logger.trace("This is a trace message.");
+ logger.info("This is an info message.");
+ logger.warn("This is an warning message.");
+ logger.error("This is an error message.");
+});
+
+testingCli
+ .subcommand("listIntegrationtests", "list-integrationtests")
+ .action(async (args) => {
+ for (const t of getTestInfo()) {
+ let s = t.name;
+ if (t.suites.length > 0) {
+ s += ` (suites: ${t.suites.join(",")})`;
+ }
+ if (t.experimental) {
+ s += ` [experimental]`;
+ }
+ console.log(s);
+ }
+ });
+
+testingCli
+ .subcommand("runIntegrationtests", "run-integrationtests")
+ .maybeArgument("pattern", clk.STRING, {
+ help: "Glob pattern to select which tests to run",
+ })
+ .maybeOption("suites", ["--suites"], clk.STRING, {
+ help: "Only run selected suites (comma-separated list)",
+ })
+ .flag("dryRun", ["--dry"], {
+ help: "Only print tests that will be selected to run.",
+ })
+ .flag("experimental", ["--experimental"], {
+ help: "Include tests marked as experimental",
+ })
+ .flag("failFast", ["--fail-fast"], {
+ help: "Exit after the first error",
+ })
+ .flag("waitOnFail", ["--wait-on-fail"], {
+ help: "Exit after the first error",
+ })
+ .flag("quiet", ["--quiet"], {
+ help: "Produce less output.",
+ })
+ .flag("noTimeout", ["--no-timeout"], {
+ help: "Do not time out tests.",
+ })
+ .action(async (args) => {
+ await runTests({
+ includePattern: args.runIntegrationtests.pattern,
+ failFast: args.runIntegrationtests.failFast,
+ waitOnFail: args.runIntegrationtests.waitOnFail,
+ suiteSpec: args.runIntegrationtests.suites,
+ dryRun: args.runIntegrationtests.dryRun,
+ verbosity: args.runIntegrationtests.quiet ? 0 : 1,
+ includeExperimental: args.runIntegrationtests.experimental ?? false,
+ noTimeout: args.runIntegrationtests.noTimeout,
+ });
+ });
+
+async function read(stream: NodeJS.ReadStream) {
+ const chunks = [];
+ for await (const chunk of stream) chunks.push(chunk);
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+testingCli.subcommand("tvgcheck", "tvgcheck").action(async (args) => {
+ const data = await read(process.stdin);
+
+ const lines = data.match(/[^\r\n]+/g);
+
+ if (!lines) {
+ throw Error("can't split lines");
+ }
+
+ const vals: Record<string, string> = {};
+
+ let inBlindSigningSection = false;
+
+ for (const line of lines) {
+ if (line === "blind signing:") {
+ inBlindSigningSection = true;
+ continue;
+ }
+ if (line[0] !== " ") {
+ inBlindSigningSection = false;
+ continue;
+ }
+ if (inBlindSigningSection) {
+ const m = line.match(/ (\w+) (\w+)/);
+ if (!m) {
+ console.log("bad format");
+ process.exit(2);
+ }
+ vals[m[1]] = m[2];
+ }
+ }
+
+ console.log(vals);
+
+ const req = (k: string) => {
+ if (!vals[k]) {
+ throw Error(`no value for ${k}`);
+ }
+ return decodeCrock(vals[k]);
+ };
+
+ const myBm = rsaBlind(
+ req("message_hash"),
+ req("blinding_key_secret"),
+ req("rsa_public_key"),
+ );
+
+ deepStrictEqual(req("blinded_message"), myBm);
+
+ console.log("check passed!");
+});
+
+export function main() {
+ testingCli.run();
+}
diff --git a/packages/taler-harness/src/integrationtests/test-age-restrictions-deposit.ts b/packages/taler-harness/src/integrationtests/test-age-restrictions-deposit.ts
new file mode 100644
index 000000000..d36ba0e61
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-deposit.ts
@@ -0,0 +1,114 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, generateRandomPayto } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsDepositTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange } =
+ await createSimpleTestkudosEnvironmentV2(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ },
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const withdrawalResult = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawalResult.withdrawalFinishedCond;
+
+ const dgIdResp = await walletClient.client.call(
+ WalletApiOperation.GenerateDepositGroupTxId,
+ {},
+ );
+
+ const depositTxId = dgIdResp.transactionId;
+
+ const depositTrack = walletClient.waitForNotificationCond(
+ (n) =>
+ n.type == NotificationType.TransactionStateTransition &&
+ n.transactionId == depositTxId &&
+ n.newTxState.major == TransactionMajorState.Pending &&
+ n.newTxState.minor == TransactionMinorState.Track,
+ );
+
+ const depositDone = walletClient.waitForNotificationCond(
+ (n) =>
+ n.type == NotificationType.TransactionStateTransition &&
+ n.transactionId == depositTxId &&
+ n.newTxState.major == TransactionMajorState.Done,
+ );
+
+ const depositGroupResult = await walletClient.client.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:10" as AmountString,
+ depositPaytoUri: generateRandomPayto("foo"),
+ transactionId: depositTxId,
+ },
+ );
+
+ t.assertDeepEqual(depositGroupResult.transactionId, depositTxId);
+
+ await depositTrack;
+
+ await exchange.runAggregatorOnceWithTimetravel({
+ timetravelMicroseconds: 1000 * 1000 * 60 * 60 * 3,
+ });
+
+ await depositDone;
+
+ const transactions = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log("transactions", JSON.stringify(transactions, undefined, 2));
+ t.assertDeepEqual(transactions.transactions[0].type, "withdrawal");
+ t.assertDeepEqual(transactions.transactions[1].type, "deposit");
+ // The raw amount is what ends up on the bank account, which includes
+ // deposit and wire fees.
+ t.assertDeepEqual(transactions.transactions[1].amountRaw, "TESTKUDOS:9.79");
+}
+
+runAgeRestrictionsDepositTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-age-restrictions-merchant.ts b/packages/taler-harness/src/integrationtests/test-age-restrictions-merchant.ts
new file mode 100644
index 000000000..bba571328
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-merchant.ts
@@ -0,0 +1,174 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AmountString, MerchantApiClient } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsMerchantTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ walletClient: walletClientOne,
+ bank,
+ exchange,
+ merchant,
+ exchangeBankAccount,
+ } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ },
+ );
+
+ const { walletClient: walletClientTwo } = await createWalletDaemonWithClient(
+ t,
+ {
+ name: "w2",
+ },
+ );
+
+ const { walletClient: walletClientThree } =
+ await createWalletDaemonWithClient(t, {
+ name: "w3",
+ });
+
+ {
+ const { walletClient: walletClientZero } =
+ await createWalletDaemonWithClient(t, {
+ name: "w0",
+ });
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient: walletClientZero,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ restrictAge: 13,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPaymentV2(t, {
+ walletClient: walletClientZero,
+ merchant,
+ order,
+ });
+ await walletClientZero.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+
+ {
+ const walletClient = walletClientOne;
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ restrictAge: 13,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+
+ {
+ const walletClient = walletClientTwo;
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ restrictAge: 13,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+
+ {
+ const walletClient = walletClientThree;
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+}
+
+runAgeRestrictionsMerchantTest.suites = ["wallet"];
+runAgeRestrictionsMerchantTest.timeoutMs = 120 * 1000;
diff --git a/packages/taler-harness/src/integrationtests/test-age-restrictions-mixed-merchant.ts b/packages/taler-harness/src/integrationtests/test-age-restrictions-mixed-merchant.ts
new file mode 100644
index 000000000..244de1972
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-mixed-merchant.ts
@@ -0,0 +1,131 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+import { AmountString } from "@gnu-taler/taler-util";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsMixedMerchantTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const {
+ walletClient: walletOne,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ mixedAgeRestriction: true,
+ },
+ );
+
+ const { walletClient: walletTwo } = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ });
+
+ const { walletClient: walletThree } = await createWalletDaemonWithClient(t, {
+ name: "w3",
+ });
+
+ {
+ const walletClient = walletOne;
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ restrictAge: 13,
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5" as AmountString,
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+
+ {
+ const wres = await withdrawViaBankV2(t, {
+ walletClient: walletTwo,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ restrictAge: 13,
+ });
+
+
+ await wres.withdrawalFinishedCond;
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5" as AmountString,
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient: walletTwo, merchant, order });
+ await walletTwo.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ }
+
+ {
+ const wres = await withdrawViaBankV2(t, {
+ walletClient: walletThree,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20" as AmountString,
+ });
+
+
+ await wres.withdrawalFinishedCond;
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ minimum_age: 9,
+ };
+
+ await makeTestPaymentV2(t, { walletClient: walletThree, merchant, order });
+ await walletThree.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ }
+}
+
+runAgeRestrictionsMixedMerchantTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-age-restrictions-peer.ts b/packages/taler-harness/src/integrationtests/test-age-restrictions-peer.ts
new file mode 100644
index 000000000..aea59b706
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-age-restrictions-peer.ts
@@ -0,0 +1,135 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ NotificationType,
+ TalerUriAction,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runAgeRestrictionsPeerTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ {
+ ageMaskSpec: "8:10:12:14:16:18:21",
+ },
+ );
+
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ persistent: true,
+ });
+
+ const wallet1 = w1.walletClient;
+ const wallet2 = w2.walletClient;
+
+ {
+ const withdrawalRes = await withdrawViaBankV2(t, {
+ walletClient: wallet1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ restrictAge: 13,
+ });
+
+ await withdrawalRes.withdrawalFinishedCond;
+
+ const purse_expiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const initResp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "Hello, World",
+ amount: "TESTKUDOS:1" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ const peerPushReadyCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready &&
+ x.transactionId === initResp.transactionId,
+ );
+
+ await peerPushReadyCond;
+
+ const txDetails = await wallet1.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: initResp.transactionId,
+ },
+ );
+ t.assertDeepEqual(txDetails.type, TransactionType.PeerPushDebit);
+ t.assertTrue(!!txDetails.talerUri);
+
+ const checkResp = await wallet2.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ {
+ talerUri: txDetails.talerUri,
+ },
+ );
+
+ await wallet2.call(WalletApiOperation.ConfirmPeerPushCredit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ const peerPullCreditDoneCond = wallet2.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId === checkResp.transactionId,
+ );
+
+ await peerPullCreditDoneCond;
+ }
+}
+
+runAgeRestrictionsPeerTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts b/packages/taler-harness/src/integrationtests/test-bank-api.ts
index 0f8af05e5..9c5b06397 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts
+++ b/packages/taler-harness/src/integrationtests/test-bank-api.ts
@@ -18,18 +18,21 @@
* Imports.
*/
import {
- GlobalTestState,
- WalletCli,
- ExchangeService,
- setupDb,
+ TalerCorebankApiClient,
+ CreditDebitIndicator,
+ WireGatewayApiClient,
+ createEddsaKeyPair,
+ encodeCrock,
+} from "@gnu-taler/taler-util";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
BankService,
+ ExchangeService,
+ GlobalTestState,
MerchantService,
- BankApi,
- BankAccessApi,
- CreditDebitIndicator,
+ generateRandomPayto,
+ setupDb,
} from "../harness/harness.js";
-import { createEddsaKeyPair, encodeCrock } from "@gnu-taler/taler-util";
-import { defaultCoinConfig } from "../harness/denomStructures";
/**
* Run test for basic, bank-integrated withdrawal.
@@ -40,10 +43,10 @@ export async function runBankApiTest(t: GlobalTestState) {
const db = await setupDb(t);
const bank = await BankService.create(t, {
- allowRegistrations: true,
currency: "TESTKUDOS",
- database: db.connStr,
httpPort: 8082,
+ database: db.connStr,
+ allowRegistrations: true,
});
const exchange = ExchangeService.create(t, {
@@ -61,7 +64,7 @@ export async function runBankApiTest(t: GlobalTestState) {
});
const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
+ "myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
@@ -81,35 +84,34 @@ export async function runBankApiTest(t: GlobalTestState) {
await merchant.start();
await merchant.pingUntilAvailable();
- await merchant.addDefaultInstance();
- await merchant.addInstance({
- id: "minst1",
- name: "minst1",
- paytoUris: ["payto://x-taler-bank/minst1"],
- });
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
});
console.log("setup done!");
- const wallet = new WalletCli(t);
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl);
- const bankUser = await BankApi.registerAccount(bank, "user1", "pw1");
+ const bankUser = await bankClient.registerAccount("user1", "pw1");
// Make sure that registering twice results in a 409 Conflict
{
- const e = await t.assertThrowsAsync(async () => {
- await BankApi.registerAccount(bank, "user1", "pw1");
+ const e = await t.assertThrowsTalerErrorAsync(async () => {
+ await bankClient.registerAccount("user1", "pw2");
});
- t.assertAxiosError(e);
- t.assertTrue(e.response?.status === 409);
+ t.assertTrue(e.errorDetail.httpStatusCode === 409);
}
- let balResp = await BankAccessApi.getAccountBalance(bank, bankUser);
+ let balResp = await bankClient.getAccountBalance(bankUser.username);
console.log(balResp);
@@ -121,16 +123,27 @@ export async function runBankApiTest(t: GlobalTestState) {
const res = createEddsaKeyPair();
- await BankApi.adminAddIncoming(bank, {
+ const wireGatewayApiClient = new WireGatewayApiClient(
+ exchangeBankAccount.wireGatewayApiBaseUrl,
+ {
+ auth: {
+ username: exchangeBankAccount.accountName,
+ password: exchangeBankAccount.accountPassword,
+ },
+ },
+ );
+
+ await wireGatewayApiClient.adminAddIncoming({
amount: "TESTKUDOS:115",
debitAccountPayto: bankUser.accountPaytoUri,
- exchangeBankAccount: exchangeBankAccount,
reservePub: encodeCrock(res.eddsaPub),
});
- balResp = await BankAccessApi.getAccountBalance(bank, bankUser);
+ balResp = await bankClient.getAccountBalance(bankUser.username);
t.assertAmountEquals(balResp.balance.amount, "TESTKUDOS:15");
t.assertTrue(
balResp.balance.credit_debit_indicator === CreditDebitIndicator.Debit,
);
}
+
+runBankApiTest.suites = ["fakebank"] \ No newline at end of file
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-claim-loop.ts b/packages/taler-harness/src/integrationtests/test-claim-loop.ts
index a509e3b19..a424e0101 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-claim-loop.ts
+++ b/packages/taler-harness/src/integrationtests/test-claim-loop.ts
@@ -17,10 +17,14 @@
/**
* Imports.
*/
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
-import { URL } from "url";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { URL } from "url";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+import { MerchantApiClient } from "@gnu-taler/taler-util";
/**
* Run test for the merchant's order lifecycle.
@@ -31,17 +35,20 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
export async function runClaimLoopTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ await withdrawViaBankV2(t, {
+ walletClient,
bank,
exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ amount: "TESTKUDOS:20",
+ });
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
// Set up order.
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -50,30 +57,26 @@ export async function runClaimLoopTest(t: GlobalTestState) {
});
// Query private order status before claiming it.
- let orderStatusBefore = await MerchantPrivateApi.queryPrivateOrderStatus(
- merchant,
- {
- orderId: orderResp.order_id,
- },
- );
+ let orderStatusBefore = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
t.assertTrue(orderStatusBefore.order_status === "unpaid");
let statusUrlBefore = new URL(orderStatusBefore.order_status_url);
// Make wallet claim the unpaid order.
t.assertTrue(orderStatusBefore.order_status === "unpaid");
const talerPayUri = orderStatusBefore.taler_pay_uri;
- await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ await walletClient.call(WalletApiOperation.PreparePayForUri, {
talerPayUri,
});
// Query private order status after claiming it.
- let orderStatusAfter = await MerchantPrivateApi.queryPrivateOrderStatus(
- merchant,
- {
- orderId: orderResp.order_id,
- },
- );
+ let orderStatusAfter = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
t.assertTrue(orderStatusAfter.order_status === "claimed");
await t.shutdown();
}
+
+runClaimLoopTest.suites = ["merchant"]; \ No newline at end of file
diff --git a/packages/taler-harness/src/integrationtests/test-clause-schnorr.ts b/packages/taler-harness/src/integrationtests/test-clause-schnorr.ts
new file mode 100644
index 000000000..a5ad382a7
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-clause-schnorr.ts
@@ -0,0 +1,103 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runClauseSchnorrTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => {
+ return {
+ ...x("TESTKUDOS"),
+ cipher: "CS",
+ };
+ });
+
+ // We need to have at least one RSA denom configured
+ coinConfig.push({
+ cipher: "RSA",
+ rsaKeySize: 1024,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:42",
+ value: "TESTKUDOS:0.0001",
+ feeWithdraw: "TESTKUDOS:42",
+ feeRefresh: "TESTKUDOS:42",
+ feeRefund: "TESTKUDOS:42",
+ name: "rsa_dummy",
+ });
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t, coinConfig);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Test JSON normalization of contract terms: Does the wallet
+ // agree with the merchant?
+ const order2 = {
+ summary: "Testing “unicode” characters",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order: order2 });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Test JSON normalization of contract terms: Does the wallet
+ // agree with the merchant?
+ const order3 = {
+ summary: "Testing\nNewlines\rAnd\tStuff\nHere\b",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order: order3 });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runClauseSchnorrTest.suites = ["experimental-wallet"];
+runClauseSchnorrTest.experimental = true;
diff --git a/packages/taler-harness/src/integrationtests/test-currency-scope.ts b/packages/taler-harness/src/integrationtests/test-currency-scope.ts
new file mode 100644
index 000000000..e07a8f47b
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-currency-scope.ts
@@ -0,0 +1,192 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { Duration, j2s } from "@gnu-taler/taler-util";
+import { Wallet, WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runCurrencyScopeTest(t: GlobalTestState) {
+ // Set up test environment
+ const dbDefault = await setupDb(t);
+
+ const dbExchangeTwo = await setupDb(t, {
+ nameSuffix: "exchange2",
+ });
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: dbDefault.connStr,
+ httpPort: 8082,
+ });
+
+ const exchangeOne = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: dbDefault.connStr,
+ });
+
+ const exchangeTwo = ExchangeService.create(t, {
+ name: "testexchange-2",
+ currency: "TESTKUDOS",
+ httpPort: 8281,
+ database: dbExchangeTwo.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: dbDefault.connStr,
+ });
+
+ const exchangeOneBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ await exchangeOne.addBankAccount("1", exchangeOneBankAccount);
+
+ const exchangeTwoBankAccount = await bank.createExchangeAccount(
+ "myexchange2",
+ "x",
+ );
+ await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount);
+
+ bank.setSuggestedExchange(
+ exchangeOne,
+ exchangeOneBankAccount.accountPaytoUri,
+ );
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ // Set up the first exchange
+
+ exchangeOne.addOfferedCoins(defaultCoinConfig);
+ await exchangeOne.start();
+ await exchangeOne.pingUntilAvailable();
+
+ // Set up the second exchange
+
+ exchangeTwo.addOfferedCoins(defaultCoinConfig);
+ await exchangeTwo.start();
+ await exchangeTwo.pingUntilAvailable();
+
+ // Start and configure merchant
+
+ merchant.addExchange(exchangeOne);
+ merchant.addExchange(exchangeTwo);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wallet",
+ });
+
+ console.log("setup done!");
+
+ // Withdraw digital cash into the wallet.
+
+ const w1 = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: exchangeOne,
+ amount: "TESTKUDOS:6",
+ });
+
+ const w2 = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: exchangeTwo,
+ amount: "TESTKUDOS:6",
+ });
+
+ await w1.withdrawalFinishedCond;
+ await w2.withdrawalFinishedCond;
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(bal));
+
+ // Separate balances, exchange-scope.
+ t.assertDeepEqual(bal.balances.length, 2);
+
+ await walletClient.call(WalletApiOperation.AddGlobalCurrencyExchange, {
+ currency: "TESTKUDOS",
+ exchangeBaseUrl: exchangeOne.baseUrl,
+ exchangeMasterPub: exchangeOne.masterPub,
+ });
+
+ await walletClient.call(WalletApiOperation.AddGlobalCurrencyExchange, {
+ currency: "TESTKUDOS",
+ exchangeBaseUrl: exchangeTwo.baseUrl,
+ exchangeMasterPub: exchangeTwo.masterPub,
+ });
+
+ const ex = walletClient.call(
+ WalletApiOperation.ListGlobalCurrencyExchanges,
+ {},
+ );
+ console.log("global currency exchanges:");
+ console.log(j2s(ex));
+
+ const bal2 = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(bal2));
+
+ // Global currencies are merged
+ t.assertDeepEqual(bal2.balances.length, 1);
+}
+
+runCurrencyScopeTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-denom-lost.ts b/packages/taler-harness/src/integrationtests/test-denom-lost.ts
new file mode 100644
index 000000000..307ae352a
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-denom-lost.ts
@@ -0,0 +1,81 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for refreshe after a payment.
+ */
+export async function runDenomLostTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const dsBefore = await walletClient.call(
+ WalletApiOperation.TestingGetDenomStats,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+
+ t.assertDeepEqual(dsBefore.numLost, 0);
+ t.assertDeepEqual(dsBefore.numOffered, dsBefore.numKnown);
+
+ await exchange.stop();
+
+ await exchange.purgeSecmodKeys();
+
+ await exchange.start();
+
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+
+ const dsAfter = await walletClient.call(
+ WalletApiOperation.TestingGetDenomStats,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+
+ // All previous denominations were lost
+ t.assertDeepEqual(dsBefore.numOffered, dsAfter.numLost);
+ // But we have new ones!
+ t.assertTrue(dsAfter.numKnown > dsBefore.numKnown);
+}
+
+runDenomLostTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts b/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts
new file mode 100644
index 000000000..6a82d586c
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-denom-unoffered.ts
@@ -0,0 +1,163 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerErrorCode,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+export async function runDenomUnofferedTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Make the exchange forget the denomination.
+ // Effectively we completely reset the exchange,
+ // but keep the exchange master public key.
+
+ await merchant.stop();
+
+ await exchange.stop();
+ await exchange.purgeDatabase();
+ await exchange.purgeSecmodKeys();
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const orderResp = await merchantClient.createOrder({
+ order: order,
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ const confirmResp = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ const tx = await walletClient.call(WalletApiOperation.GetTransactionById, {
+ transactionId: confirmResp.transactionId,
+ });
+
+ t.assertTrue(tx.error != null);
+ t.assertTrue(
+ tx.error.code === TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
+ );
+
+ const merchantErrorCode = (tx.error as any).requestError.errorResponse.code;
+
+ t.assertDeepEqual(
+ merchantErrorCode,
+ TalerErrorCode.MERCHANT_GENERIC_EXCHANGE_UNEXPECTED_STATUS,
+ );
+
+ const exchangeErrorCode = (tx.error as any).requestError.errorResponse
+ .exchange_ec;
+
+ t.assertDeepEqual(
+ exchangeErrorCode,
+ TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN,
+ );
+
+ // Depending on whether the merchant has seen the new denominations or not,
+ // the error code might be different here.
+ // t.assertDeepEqual(
+ // merchantErrorCode,
+ // TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND,
+ // );
+
+ // Force updating the exchange entry so that the wallet knows about the new denominations.
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+
+ await walletClient.call(WalletApiOperation.DeleteTransaction, {
+ transactionId: confirmResp.transactionId,
+ });
+
+ // Now withdrawal should work again.
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txs = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ includeRefreshes: true,
+ });
+ console.log(JSON.stringify(txs, undefined, 2));
+
+ t.assertDeepEqual(txs.transactions[0].type, TransactionType.Withdrawal);
+ t.assertDeepEqual(txs.transactions[1].type, TransactionType.Refresh);
+ t.assertDeepEqual(txs.transactions[2].type, TransactionType.DenomLoss);
+ t.assertDeepEqual(txs.transactions[3].type, TransactionType.Withdrawal);
+}
+
+runDenomUnofferedTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-deposit.ts b/packages/taler-harness/src/integrationtests/test-deposit.ts
new file mode 100644
index 000000000..74b318226
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-deposit.ts
@@ -0,0 +1,122 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, generateRandomPayto } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runDepositTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const withdrawalResult = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawalResult.withdrawalFinishedCond;
+
+ const dgIdResp = await walletClient.client.call(
+ WalletApiOperation.GenerateDepositGroupTxId,
+ {},
+ );
+
+ const depositTxId = dgIdResp.transactionId;
+
+ const depositTrack = walletClient.waitForNotificationCond(
+ (n) =>
+ n.type == NotificationType.TransactionStateTransition &&
+ n.transactionId == depositTxId &&
+ n.newTxState.major == TransactionMajorState.Pending &&
+ n.newTxState.minor == TransactionMinorState.Track,
+ );
+
+ const depositDone = walletClient.waitForNotificationCond(
+ (n) =>
+ n.type == NotificationType.TransactionStateTransition &&
+ n.transactionId == depositTxId &&
+ n.newTxState.major == TransactionMajorState.Done,
+ );
+
+ const depositGroupResult = await walletClient.client.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:10" as AmountString,
+ depositPaytoUri: generateRandomPayto("foo"),
+ transactionId: depositTxId,
+ },
+ );
+
+ t.assertDeepEqual(depositGroupResult.transactionId, depositTxId);
+
+ const balDuring = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(`balances during deposit: ${j2s(balDuring)}`);
+ t.assertAmountEquals(balDuring.balances[0].pendingOutgoing, "TESTKUDOS:10");
+
+ await depositTrack;
+
+ t.logStep("before-aggregator");
+
+ await exchange.runAggregatorOnceWithTimetravel({
+ timetravelMicroseconds: 1000 * 1000 * 60 * 60 * 3,
+ });
+
+ await exchange.runTransferOnceWithTimetravel({
+ timetravelMicroseconds: 1000 * 1000 * 60 * 60 * 3,
+ });
+
+ await depositDone;
+
+ const transactions = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log("transactions", JSON.stringify(transactions, undefined, 2));
+ t.assertDeepEqual(transactions.transactions[0].type, "withdrawal");
+ t.assertDeepEqual(transactions.transactions[1].type, "deposit");
+ // The raw amount is what ends up on the bank account, which includes
+ // deposit and wire fees.
+ t.assertDeepEqual(transactions.transactions[1].amountRaw, "TESTKUDOS:9.79");
+
+ const balAfter = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(`balances after deposit: ${j2s(balAfter)}`);
+ t.assertAmountEquals(balAfter.balances[0].pendingOutgoing, "TESTKUDOS:0");
+}
+
+runDepositTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-deposit.ts b/packages/taler-harness/src/integrationtests/test-exchange-deposit.ts
new file mode 100644
index 000000000..47a17a1f2
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-exchange-deposit.ts
@@ -0,0 +1,159 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import {
+ CryptoDispatcher,
+ SynchronousCryptoWorkerFactoryPlain,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ checkReserve,
+ depositCoin,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ topupReserveWithBank,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core/dbless";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runExchangeDepositTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t);
+
+ const http = createPlatformHttpLib({
+ enableThrottling: false,
+ });
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+
+ try {
+ // Withdraw digital cash into the wallet.
+
+ const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http);
+
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+
+ await topupReserveWithBank({
+ http,
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeInfo,
+ reservePub: reserveKeyPair.pub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await checkReserve(http, exchange.baseUrl, reserveKeyPair.pub);
+
+ const d1 = findDenomOrThrow(
+ exchangeInfo,
+ "TESTKUDOS:8" as AmountString,
+ {},
+ );
+
+ const coin = await withdrawCoin({
+ http,
+ cryptoApi,
+ reserveKeyPair: {
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ },
+ denom: d1,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const wireSalt = encodeCrock(getRandomBytes(16));
+ const merchantPub = encodeCrock(getRandomBytes(32));
+ const contractTermsHash = encodeCrock(getRandomBytes(64));
+
+ await depositCoin({
+ contractTermsHash,
+ merchantPub,
+ wireSalt,
+ amount: "TESTKUDOS:4" as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+
+ // Idempotency
+ await depositCoin({
+ contractTermsHash,
+ merchantPub,
+ wireSalt,
+ amount: "TESTKUDOS:4" as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+
+ try {
+ // Non-idempotent request with different amount
+ await depositCoin({
+ contractTermsHash,
+ merchantPub,
+ wireSalt,
+ amount: "TESTKUDOS:3.5" as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+ } catch (e) {
+ if (e instanceof TalerError && e.errorDetail.code === 7005) {
+ if (e.errorDetail.httpStatusCode === 409) {
+ console.log("got expected error response from exchange");
+ console.log(e);
+ console.log(j2s(e.errorDetail));
+ } else {
+ console.log("did not expect deposit error from exchange");
+ throw e;
+ }
+ } else {
+ throw e;
+ }
+ }
+ } catch (e) {
+ if (e instanceof TalerError) {
+ console.log(e);
+ console.log(j2s(e.errorDetail));
+ } else {
+ console.log(e);
+ }
+ throw e;
+ }
+}
+
+runExchangeDepositTest.suites = ["exchange"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts b/packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts
index 8a5d563ce..d3bd022ae 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts
+++ b/packages/taler-harness/src/integrationtests/test-exchange-management-fault.ts
@@ -18,31 +18,34 @@
* Imports.
*/
import {
- GlobalTestState,
- WalletCli,
- setupDb,
- BankService,
- ExchangeService,
- MerchantService,
- BankApi,
- BankAccessApi,
-} from "../harness/harness.js";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import {
- ExchangesListRespose,
- URL,
+ ExchangesListResponse,
+ TalerCorebankApiClient,
TalerErrorCode,
+ URL,
+ j2s,
} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
import {
FaultInjectedExchangeService,
FaultInjectionResponseContext,
-} from "../harness/faultInjection";
-import { defaultCoinConfig } from "../harness/denomStructures";
+} from "../harness/faultInjection.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ WalletCli,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
/**
- * Test if the wallet handles outdated exchange versions correct.y
+ * Test if the wallet handles outdated exchange versions correctly.
*/
-export async function runExchangeManagementTest(t: GlobalTestState) {
+export async function runExchangeManagementFaultTest(
+ t: GlobalTestState,
+): Promise<void> {
// Set up test environment
const db = await setupDb(t);
@@ -69,12 +72,16 @@ export async function runExchangeManagementTest(t: GlobalTestState) {
});
const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
+ "myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
+ // Base URL must contain port that the proxy is listening on.
+ await exchange.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", "http://localhost:8091/");
+ });
bank.setSuggestedExchange(
faultyExchange,
@@ -95,16 +102,16 @@ export async function runExchangeManagementTest(t: GlobalTestState) {
await merchant.start();
await merchant.pingUntilAvailable();
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
});
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
- paytoUris: ["payto://x-taler-bank/minst1"],
+ paytoUris: [generateRandomPayto("minst1")],
});
console.log("setup done!");
@@ -118,12 +125,13 @@ export async function runExchangeManagementTest(t: GlobalTestState) {
const wallet = new WalletCli(t);
- let exchangesList: ExchangesListRespose;
+ let exchangesList: ExchangesListResponse;
exchangesList = await wallet.client.call(
WalletApiOperation.ListExchanges,
{},
);
+ console.log("exchanges list:", j2s(exchangesList));
t.assertTrue(exchangesList.exchanges.length === 0);
// Try before fault is injected
@@ -137,9 +145,7 @@ export async function runExchangeManagementTest(t: GlobalTestState) {
);
t.assertTrue(exchangesList.exchanges.length === 1);
- await wallet.client.call(WalletApiOperation.ListExchanges, {
- exchangeBaseUrl: faultyExchange.baseUrl,
- });
+ await wallet.client.call(WalletApiOperation.ListExchanges, {});
console.log("listing exchanges");
@@ -178,24 +184,30 @@ export async function runExchangeManagementTest(t: GlobalTestState) {
},
});
- const err1 = await t.assertThrowsOperationErrorAsync(async () => {
+ const err1 = await t.assertThrowsTalerErrorAsync(async () => {
await wallet.client.call(WalletApiOperation.AddExchange, {
exchangeBaseUrl: faultyExchange.baseUrl,
});
});
+ console.log("got error", err1);
+
// Response is malformed, since it didn't even contain a version code
// in a format the wallet can understand.
t.assertTrue(
- err1.operationError.code ===
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ err1.errorDetail.code === TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
);
exchangesList = await wallet.client.call(
WalletApiOperation.ListExchanges,
{},
);
- t.assertTrue(exchangesList.exchanges.length === 0);
+ console.log("exchanges list", j2s(exchangesList));
+ t.assertTrue(exchangesList.exchanges.length === 1);
+ t.assertTrue(
+ exchangesList.exchanges[0].lastUpdateErrorInfo?.error.code ===
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ );
/*
* =========================================================================
@@ -220,22 +232,23 @@ export async function runExchangeManagementTest(t: GlobalTestState) {
},
});
- const err2 = await t.assertThrowsOperationErrorAsync(async () => {
+ const err2 = await t.assertThrowsTalerErrorAsync(async () => {
await wallet.client.call(WalletApiOperation.AddExchange, {
exchangeBaseUrl: faultyExchange.baseUrl,
});
});
- t.assertTrue(
- err2.operationError.code ===
- TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
- );
+ t.assertTrue(err2.hasErrorCode(TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE));
exchangesList = await wallet.client.call(
WalletApiOperation.ListExchanges,
{},
);
- t.assertTrue(exchangesList.exchanges.length === 0);
+ t.assertTrue(exchangesList.exchanges.length === 1);
+ t.assertTrue(
+ exchangesList.exchanges[0].lastUpdateErrorInfo?.error.code ===
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ );
/*
* =========================================================================
@@ -249,10 +262,11 @@ export async function runExchangeManagementTest(t: GlobalTestState) {
// Create withdrawal operation
- const user = await BankApi.createRandomBankUser(bank);
- const wop = await BankAccessApi.createWithdrawalOperation(
- bank,
- user,
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl);
+
+ const user = await bankClient.createRandomBankUser();
+ const wop = await bankClient.createWithdrawalOperation(
+ user.username,
"TESTKUDOS:10",
);
@@ -269,4 +283,4 @@ export async function runExchangeManagementTest(t: GlobalTestState) {
t.assertTrue(wd.possibleExchanges.length === 0);
}
-runExchangeManagementTest.suites = ["exchange"];
+runExchangeManagementFaultTest.suites = ["wallet", "exchange"];
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-management.ts b/packages/taler-harness/src/integrationtests/test-exchange-management.ts
new file mode 100644
index 000000000..9d3c1d867
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-exchange-management.ts
@@ -0,0 +1,82 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+/**
+ * Test if the wallet handles outdated exchange versions correctly.
+ */
+export async function runExchangeManagementTest(
+ t: GlobalTestState,
+): Promise<void> {
+ // Set up test environment
+
+ const { walletClient, exchange } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Since the default exchanges can change, we start the wallet in tests
+ // with no built-in defaults. Thus the list of exchanges is empty here.
+ const exchangesListResult = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesListResult.exchanges.length, 0);
+
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+
+ const exchangesListResult2 = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesListResult2.exchanges.length, 1);
+
+ await walletClient.call(WalletApiOperation.DeleteExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const exchangesListResult3 = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesListResult3.exchanges.length, 0);
+
+ // Check for regression: Can we re-add a deleted exchange?
+
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchange.baseUrl,
+ force: true,
+ });
+
+ const exchangesListResult4 = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesListResult4.exchanges.length, 1);
+}
+
+runExchangeManagementTest.suites = ["wallet", "exchange"];
diff --git a/packages/taler-harness/src/integrationtests/test-exchange-purse.ts b/packages/taler-harness/src/integrationtests/test-exchange-purse.ts
new file mode 100644
index 000000000..6666e2d0b
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-exchange-purse.ts
@@ -0,0 +1,224 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ ContractTermsUtil,
+ decodeCrock,
+ Duration,
+ encodeCrock,
+ getRandomBytes,
+ hash,
+ j2s,
+ PeerContractTerms,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import {
+ CryptoDispatcher,
+ EncryptContractRequest,
+ SpendCoinDetails,
+ SynchronousCryptoWorkerFactoryPlain,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ checkReserve,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ topupReserveWithBank,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core/dbless";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+/**
+ * Test the exchange's purse API.
+ */
+export async function runExchangePurseTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t);
+
+ const http = harnessHttpLib;
+ const cryptoDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptoDisp.cryptoApi;
+
+ try {
+ // Withdraw digital cash into the wallet.
+
+ const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http);
+
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+
+ let reserveUrl = new URL(
+ `reserves/${reserveKeyPair.pub}`,
+ exchange.baseUrl,
+ );
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+
+ await topupReserveWithBank({
+ amount: "TESTKUDOS:10" as AmountString,
+ http,
+ reservePub: reserveKeyPair.pub,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeInfo,
+ });
+
+ console.log("waiting for longpoll request");
+ const resp = await longpollReq;
+ console.log(`got response, status ${resp.status}`);
+
+ console.log(exchangeInfo);
+
+ await checkReserve(http, exchange.baseUrl, reserveKeyPair.pub);
+
+ const d1 = findDenomOrThrow(
+ exchangeInfo,
+ "TESTKUDOS:8" as AmountString,
+ {},
+ );
+
+ const coin = await withdrawCoin({
+ http,
+ cryptoApi,
+ reserveKeyPair: {
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ },
+ denom: d1,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const amount = "TESTKUDOS:5" as AmountString;
+
+ const contractTerms: PeerContractTerms = {
+ amount,
+ summary: "Hello",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ ),
+ };
+
+ const mergeReservePair = await cryptoApi.createEddsaKeypair({});
+ const pursePair = await cryptoApi.createEddsaKeypair({});
+ const mergePair = await cryptoApi.createEddsaKeypair({});
+ const contractPair = await cryptoApi.createEddsaKeypair({});
+ const contractEncNonce = encodeCrock(getRandomBytes(24));
+
+ const pursePub = pursePair.pub;
+
+ const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ const purseSigResp = await cryptoApi.signPurseCreation({
+ hContractTerms,
+ mergePub: mergePair.pub,
+ minAge: 0,
+ purseAmount: amount,
+ purseExpiration: contractTerms.purse_expiration,
+ pursePriv: pursePair.priv,
+ });
+
+ const coinSpend: SpendCoinDetails = {
+ ageCommitmentProof: undefined,
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contribution: amount,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ };
+
+ const depositSigsResp = await cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: exchange.baseUrl,
+ pursePub: pursePair.pub,
+ coins: [coinSpend],
+ });
+
+ const encryptContractRequest: EncryptContractRequest = {
+ contractTerms: contractTerms,
+ mergePriv: mergePair.priv,
+ pursePriv: pursePair.priv,
+ pursePub: pursePair.pub,
+ contractPriv: contractPair.priv,
+ contractPub: contractPair.pub,
+ nonce: contractEncNonce,
+ };
+
+ const econtractResp = await cryptoApi.encryptContractForMerge(
+ encryptContractRequest,
+ );
+
+ const econtractHash = encodeCrock(
+ hash(decodeCrock(econtractResp.econtract.econtract)),
+ );
+
+ const createPurseUrl = new URL(
+ `purses/${pursePair.pub}/create`,
+ exchange.baseUrl,
+ );
+
+ const reqBody = {
+ amount: amount,
+ merge_pub: mergePair.pub,
+ purse_sig: purseSigResp.sig,
+ h_contract_terms: hContractTerms,
+ purse_expiration: contractTerms.purse_expiration,
+ deposits: depositSigsResp.deposits,
+ min_age: 0,
+ econtract: econtractResp.econtract,
+ };
+
+ const httpResp = await http.fetch(createPurseUrl.href, {
+ method: "POST",
+ body: reqBody,
+ });
+
+ const respBody = await httpResp.json();
+
+ console.log("status", httpResp.status);
+
+ console.log(j2s(respBody));
+
+ const mergeUrl = new URL(`purses/${pursePub}/merge`, exchange.baseUrl);
+ mergeUrl.searchParams.set("timeout_ms", "300");
+ const statusResp = await http.fetch(mergeUrl.href, {});
+
+ const statusRespBody = await statusResp.json();
+
+ console.log(j2s(statusRespBody));
+
+ t.assertTrue(statusRespBody.merge_timestamp === undefined);
+ } catch (e) {
+ if (e instanceof TalerError) {
+ console.log(e);
+ console.log(j2s(e.errorDetail));
+ } else {
+ console.log(e);
+ }
+ throw e;
+ }
+}
+
+runExchangePurseTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-exchange-timetravel.ts b/packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts
index 56684f70a..714a7f879 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-exchange-timetravel.ts
+++ b/packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts
@@ -18,60 +18,82 @@
* Imports.
*/
import {
+ AbsoluteTime,
codecForExchangeKeysJson,
- ConfirmPayResultType,
+ DenominationPubKey,
+ DenomKeyType,
Duration,
- durationFromSpec,
- PreparePayResultType,
- stringifyTimestamp,
+ ExchangeKeysJson,
+ Logger,
} from "@gnu-taler/taler-util";
import {
- NodeHttpLib,
- PendingOperationsResponse,
+ createPlatformHttpLib,
readSuccessResponseJsonOrThrow,
- WalletApiOperation,
-} from "@gnu-taler/taler-wallet-core";
-import { makeNoFeeCoinConfig } from "../harness/denomStructures";
+} from "@gnu-taler/taler-util/http";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
import {
BankService,
ExchangeService,
+ generateRandomPayto,
GlobalTestState,
- MerchantPrivateApi,
MerchantService,
setupDb,
- WalletCli,
} from "../harness/harness.js";
-import { startWithdrawViaBank, withdrawViaBank } from "../harness/helpers.js";
-
-async function applyTimeTravel(
- timetravelDuration: Duration,
- s: {
- exchange?: ExchangeService;
- merchant?: MerchantService;
- wallet?: WalletCli;
- },
-): Promise<void> {
- if (s.exchange) {
- await s.exchange.stop();
- s.exchange.setTimetravel(timetravelDuration);
- await s.exchange.start();
- await s.exchange.pingUntilAvailable();
- }
+import {
+ applyTimeTravelV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
- if (s.merchant) {
- await s.merchant.stop();
- s.merchant.setTimetravel(timetravelDuration);
- await s.merchant.start();
- await s.merchant.pingUntilAvailable();
- }
+const logger = new Logger("test-exchange-timetravel.ts");
- if (s.wallet) {
- console.log("setting wallet time travel to", timetravelDuration);
- s.wallet.setTimetravel(timetravelDuration);
+interface DenomInfo {
+ denomPub: DenominationPubKey;
+ expireDeposit: string;
+}
+
+function getDenomInfoFromKeys(ek: ExchangeKeysJson): DenomInfo[] {
+ const denomInfos: DenomInfo[] = [];
+ for (const denomGroup of ek.denominations) {
+ switch (denomGroup.cipher) {
+ case "RSA":
+ case "RSA+age_restricted": {
+ let ageMask = 0;
+ if (denomGroup.cipher === "RSA+age_restricted") {
+ ageMask = denomGroup.age_mask;
+ }
+ for (const denomIn of denomGroup.denoms) {
+ const denomPub: DenominationPubKey = {
+ age_mask: ageMask,
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: denomIn.rsa_pub,
+ };
+ denomInfos.push({
+ denomPub,
+ expireDeposit: AbsoluteTime.stringify(
+ AbsoluteTime.fromProtocolTimestamp(denomIn.stamp_expire_deposit),
+ ),
+ });
+ }
+ break;
+ }
+ case "CS+age_restricted":
+ case "CS":
+ logger.warn("Clause-Schnorr denominations not supported");
+ continue;
+ default:
+ logger.warn(
+ `denomination type ${(denomGroup as any).cipher} not supported`,
+ );
+ continue;
+ }
}
+ return denomInfos;
}
-const http = new NodeHttpLib();
+const http = createPlatformHttpLib({
+ enableThrottling: false,
+});
/**
* Basic time travel test.
@@ -103,7 +125,7 @@ export async function runExchangeTimetravelTest(t: GlobalTestState) {
});
const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
+ "myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
@@ -124,27 +146,35 @@ export async function runExchangeTimetravelTest(t: GlobalTestState) {
await merchant.start();
await merchant.pingUntilAvailable();
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
});
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
- paytoUris: ["payto://x-taler-bank/minst1"],
+ paytoUris: [generateRandomPayto("minst1")],
});
console.log("setup done!");
- const wallet = new WalletCli(t);
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
+ await wres.withdrawalFinishedCond;
- const keysResp1 = await http.get(exchange.baseUrl + "keys");
+ const keysResp1 = await http.fetch(exchange.baseUrl + "keys");
const keys1 = await readSuccessResponseJsonOrThrow(
keysResp1,
codecForExchangeKeysJson(),
@@ -157,13 +187,16 @@ export async function runExchangeTimetravelTest(t: GlobalTestState) {
// Travel into the future, the deposit expiration is two years
// into the future.
console.log("applying first time travel");
- await applyTimeTravel(durationFromSpec({ days: 400 }), {
- wallet,
- exchange,
- merchant,
- });
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ days: 400 })),
+ {
+ walletClient,
+ exchange,
+ merchant,
+ },
+ );
- const keysResp2 = await http.get(exchange.baseUrl + "keys");
+ const keysResp2 = await http.fetch(exchange.baseUrl + "keys");
const keys2 = await readSuccessResponseJsonOrThrow(
keysResp2,
codecForExchangeKeysJson(),
@@ -173,40 +206,51 @@ export async function runExchangeTimetravelTest(t: GlobalTestState) {
JSON.stringify(keys2, undefined, 2),
);
- const denomPubs1 = keys1.denoms.map((x) => {
- return {
- denomPub: x.denom_pub,
- expireDeposit: stringifyTimestamp(x.stamp_expire_deposit),
- };
- });
+ const denomPubs1 = getDenomInfoFromKeys(keys1);
+ const denomPubs2 = getDenomInfoFromKeys(keys2);
- const denomPubs2 = keys2.denoms.map((x) => {
- return {
- denomPub: x.denom_pub,
- expireDeposit: stringifyTimestamp(x.stamp_expire_deposit),
- };
- });
const dps2 = new Set(denomPubs2.map((x) => x.denomPub));
console.log("=== KEYS RESPONSE 1 ===");
- console.log("list issue date", stringifyTimestamp(keys1.list_issue_date));
- console.log("num denoms", keys1.denoms.length)
+ console.log(
+ "list issue date",
+ AbsoluteTime.stringify(
+ AbsoluteTime.fromProtocolTimestamp(keys1.list_issue_date),
+ ),
+ );
+ console.log("num denoms", denomPubs1.length);
console.log("denoms", JSON.stringify(denomPubs1, undefined, 2));
console.log("=== KEYS RESPONSE 2 ===");
- console.log("list issue date", stringifyTimestamp(keys2.list_issue_date));
- console.log("num denoms", keys2.denoms.length)
+ console.log(
+ "list issue date",
+ AbsoluteTime.stringify(
+ AbsoluteTime.fromProtocolTimestamp(keys2.list_issue_date),
+ ),
+ );
+ console.log("num denoms", denomPubs2.length);
console.log("denoms", JSON.stringify(denomPubs2, undefined, 2));
for (const da of denomPubs1) {
- if (!dps2.has(da.denomPub)) {
+ let found = false;
+ for (const db of denomPubs2) {
+ const d1 = da.denomPub;
+ const d2 = db.denomPub;
+ if (DenominationPubKey.cmp(d1, d2) === 0) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
console.log("=== ERROR ===");
- console.log(`denomination with public key ${da.denomPub} is not present in new /keys response`);
console.log(
- `the new /keys response was issued ${stringifyTimestamp(
- keys2.list_issue_date,
+ `denomination with public key ${da.denomPub} is not present in new /keys response`,
+ );
+ console.log(
+ `the new /keys response was issued ${AbsoluteTime.stringify(
+ AbsoluteTime.fromProtocolTimestamp(keys2.list_issue_date),
)}`,
);
console.log(
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts b/packages/taler-harness/src/integrationtests/test-fee-regression.ts
index 025e12226..f164606c4 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts
+++ b/packages/taler-harness/src/integrationtests/test-fee-regression.ts
@@ -19,17 +19,18 @@
*/
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import {
- GlobalTestState,
BankService,
ExchangeService,
+ GlobalTestState,
MerchantService,
+ generateRandomPayto,
setupDb,
- WalletCli,
} from "../harness/harness.js";
import {
- withdrawViaBank,
- makeTestPayment,
- SimpleTestEnvironment,
+ SimpleTestEnvironmentNg,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
} from "../harness/helpers.js";
/**
@@ -38,7 +39,7 @@ import {
*/
export async function createMyTestkudosEnvironment(
t: GlobalTestState,
-): Promise<SimpleTestEnvironment> {
+): Promise<SimpleTestEnvironmentNg> {
const db = await setupDb(t);
const bank = await BankService.create(t, {
@@ -63,7 +64,7 @@ export async function createMyTestkudosEnvironment(
});
const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
+ "myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
@@ -75,6 +76,7 @@ export async function createMyTestkudosEnvironment(
await bank.pingUntilAvailable();
const coinCommon = {
+ cipher: "RSA" as const,
durationLegal: "3 years",
durationSpend: "2 years",
durationWithdraw: "7 days",
@@ -137,21 +139,27 @@ export async function createMyTestkudosEnvironment(
await merchant.pingUntilAvailable();
await merchant.addDefaultInstance();
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
- paytoUris: ["payto://x-taler-bank/minst1"],
+ paytoUris: [generateRandomPayto("minst1")],
});
console.log("setup done!");
- const wallet = new WalletCli(t);
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ {
+ name: "w1",
+ },
+ );
return {
commonDb: db,
exchange,
merchant,
- wallet,
+ walletClient,
+ walletService,
bank,
exchangeBankAccount,
};
@@ -163,23 +171,21 @@ export async function createMyTestkudosEnvironment(
export async function runFeeRegressionTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createMyTestkudosEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createMyTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, {
- wallet,
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
bank,
exchange,
amount: "TESTKUDOS:1.92",
});
- const coins = await wallet.client.call(WalletApiOperation.DumpCoins, {});
+ await wres.withdrawalFinishedCond;
+
+ const coins = await walletClient.call(WalletApiOperation.DumpCoins, {});
// Make sure we really withdraw one 0.64 and one 1.28 coin.
t.assertTrue(coins.coins.length === 2);
@@ -190,11 +196,13 @@ export async function runFeeRegressionTest(t: GlobalTestState) {
fulfillment_url: "taler://fulfillment-success/thx",
};
- await makeTestPayment(t, { wallet, merchant, order });
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
- const txs = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ const txs = await walletClient.call(WalletApiOperation.GetTransactions, {});
t.assertAmountEquals(txs.transactions[1].amountEffective, "TESTKUDOS:1.30");
console.log(txs);
}
+
+runFeeRegressionTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-forced-selection.ts b/packages/taler-harness/src/integrationtests/test-forced-selection.ts
new file mode 100644
index 000000000..839ddd927
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-forced-selection.ts
@@ -0,0 +1,86 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AmountString, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+/**
+ * Run test for forced denom/coin selection.
+ */
+export async function runForcedSelectionTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ await walletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ await walletClient.call(WalletApiOperation.WithdrawTestBalance, {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ forcedDenomSel: {
+ denoms: [
+ {
+ value: "TESTKUDOS:2" as AmountString,
+ count: 3,
+ },
+ ],
+ },
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {});
+ console.log(coinDump);
+ t.assertDeepEqual(coinDump.coins.length, 3);
+
+ const payResp = await walletClient.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:3" as AmountString,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ summary: "bla",
+ forcedCoinSel: {
+ coins: [
+ {
+ value: "TESTKUDOS:2" as AmountString,
+ contribution: "TESTKUDOS:1" as AmountString,
+ },
+ {
+ value: "TESTKUDOS:2" as AmountString,
+ contribution: "TESTKUDOS:1" as AmountString,
+ },
+ {
+ value: "TESTKUDOS:2" as AmountString,
+ contribution: "TESTKUDOS:1" as AmountString,
+ },
+ ],
+ },
+ });
+
+ console.log(j2s(payResp));
+
+ // Without forced selection, we would only use 2 coins.
+ t.assertDeepEqual(payResp.numCoins, 3);
+}
+
+runForcedSelectionTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-kyc.ts b/packages/taler-harness/src/integrationtests/test-kyc.ts
new file mode 100644
index 000000000..a9ef654fd
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-kyc.ts
@@ -0,0 +1,422 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ Duration,
+ Logger,
+ NotificationType,
+ TalerCorebankApiClient,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import * as http from "node:http";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ WalletClient,
+ WalletService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import { EnvOptions, SimpleTestEnvironmentNg } from "../harness/helpers.js";
+
+const logger = new Logger("test-kyc.ts");
+
+export async function createKycTestkudosEnvironment(
+ t: GlobalTestState,
+ coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
+ opts: EnvOptions = {},
+): Promise<SimpleTestEnvironmentNg> {
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const ageMaskSpec = opts.ageMaskSpec;
+
+ if (ageMaskSpec) {
+ exchange.enableAgeRestrictions(ageMaskSpec);
+ // Enable age restriction for all coins.
+ exchange.addCoinConfigList(
+ coinConfig.map((x) => ({
+ ...x,
+ name: `${x.name}-age`,
+ ageRestricted: true,
+ })),
+ );
+ // For mixed age restrictions, we also offer coins without age restrictions
+ if (opts.mixedAgeRestriction) {
+ exchange.addCoinConfigList(
+ coinConfig.map((x) => ({ ...x, ageRestricted: false })),
+ );
+ }
+ } else {
+ exchange.addCoinConfigList(coinConfig);
+ }
+
+ await exchange.modifyConfig(async (config) => {
+ const myprov = "kyc-provider-myprov";
+ config.setString(myprov, "cost", "0");
+ config.setString(myprov, "logic", "oauth2");
+ config.setString(myprov, "provided_checks", "dummy1");
+ config.setString(myprov, "user_type", "individual");
+ config.setString(myprov, "kyc_oauth2_validity", "forever");
+ config.setString(
+ myprov,
+ "kyc_oauth2_token_url",
+ "http://localhost:6666/oauth/v2/token",
+ );
+ config.setString(
+ myprov,
+ "kyc_oauth2_authorize_url",
+ "http://localhost:6666/oauth/v2/login",
+ );
+ config.setString(
+ myprov,
+ "kyc_oauth2_info_url",
+ "http://localhost:6666/oauth/v2/info",
+ );
+ config.setString(
+ myprov,
+ "kyc_oauth2_converter_helper",
+ "taler-exchange-kyc-oauth2-test-converter.sh",
+ );
+ config.setString(myprov, "kyc_oauth2_client_id", "taler-exchange");
+ config.setString(myprov, "kyc_oauth2_client_secret", "exchange-secret");
+ config.setString(myprov, "kyc_oauth2_post_url", "https://taler.net");
+
+ config.setString(
+ "kyc-legitimization-withdraw1",
+ "operation_type",
+ "withdraw",
+ );
+ config.setString(
+ "kyc-legitimization-withdraw1",
+ "required_checks",
+ "dummy1",
+ );
+ config.setString("kyc-legitimization-withdraw1", "timeframe", "1d");
+ config.setString(
+ "kyc-legitimization-withdraw1",
+ "threshold",
+ "TESTKUDOS:5",
+ );
+ });
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ },
+ },
+ });
+
+ console.log("setup done!");
+
+ return {
+ commonDb: db,
+ exchange,
+ merchant,
+ walletClient,
+ walletService,
+ bank,
+ exchangeBankAccount,
+ };
+}
+
+interface TestfakeKycService {
+ stop: () => void;
+}
+
+function splitInTwoAt(s: string, separator: string): [string, string] {
+ const idx = s.indexOf(separator);
+ if (idx === -1) {
+ return [s, ""];
+ }
+ return [s.slice(0, idx), s.slice(idx + 1)];
+}
+
+/**
+ * Testfake for the kyc service that the exchange talks to.
+ */
+async function runTestfakeKycService(): Promise<TestfakeKycService> {
+ const server = http.createServer((req, res) => {
+ const requestUrl = req.url!;
+ logger.info(`kyc: got ${req.method} request, ${requestUrl}`);
+
+ const [path, query] = splitInTwoAt(requestUrl, "?");
+
+ const qp = new URLSearchParams(query);
+
+ if (path === "/oauth/v2/login") {
+ // Usually this would render some HTML page for the user to log in,
+ // but we return JSON here.
+ const redirUriUnparsed = qp.get("redirect_uri");
+ if (!redirUriUnparsed) {
+ throw Error("missing redirect_url");
+ }
+ const state = qp.get("state");
+ if (!state) {
+ throw Error("missing state");
+ }
+ const redirUri = new URL(redirUriUnparsed);
+ redirUri.searchParams.set("code", "code_is_ok");
+ redirUri.searchParams.set("state", state);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ redirect_uri: redirUri.href,
+ }),
+ );
+ } else if (path === "/oauth/v2/token") {
+ let reqBody = "";
+ req.on("data", (x) => {
+ reqBody += x;
+ });
+
+ req.on("end", () => {
+ logger.info("login request body:", reqBody);
+
+ res.writeHead(200, { "Content-Type": "application/json" });
+ // Normally, the access_token would also include which user we're trying
+ // to get info about, but we (for now) skip it in this test.
+ res.end(
+ JSON.stringify({
+ access_token: "exchange_access_token",
+ token_type: "Bearer",
+ }),
+ );
+ });
+ } else if (path === "/oauth/v2/info") {
+ logger.info("authorization header:", req.headers.authorization);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ status: "success",
+ data: {
+ id: "foobar",
+ },
+ }),
+ );
+ } else {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ code: 1, message: "bad request" }));
+ }
+ });
+ await new Promise<void>((resolve, reject) => {
+ server.listen(6666, () => resolve());
+ });
+ return {
+ stop() {
+ server.close();
+ },
+ };
+}
+
+export async function runKycTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createKycTestkudosEnvironment(t);
+
+ const kycServer = await runTestfakeKycService();
+
+ // Withdraw digital cash into the wallet.
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl);
+
+ const amount = "TESTKUDOS:20";
+ const user = await bankClient.createRandomBankUser();
+ const wop = await bankClient.createWithdrawalOperation(user.username, amount);
+
+ // Hand it to the wallet
+
+ await walletClient.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ // Withdraw
+
+ const acceptResp = await walletClient.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ const withdrawalTxId = acceptResp.transactionId;
+
+ // Confirm it
+
+ await bankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ const kycNotificationCond = walletClient.waitForNotificationCond((x) => {
+ if (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === withdrawalTxId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.KycRequired
+ ) {
+ return x;
+ }
+ return false;
+ });
+
+ const withdrawalDoneCond = walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === withdrawalTxId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ const kycNotif = await kycNotificationCond;
+
+ logger.info("got kyc notification:", j2s(kycNotif));
+
+ const txState = await walletClient.client.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: withdrawalTxId,
+ },
+ );
+
+ t.assertDeepEqual(txState.type, TransactionType.Withdrawal);
+
+ const kycUrl = txState.kycUrl;
+
+ t.assertTrue(!!kycUrl);
+
+ logger.info(`kyc URL is ${kycUrl}`);
+
+ // We now simulate the user interacting with the KYC service,
+ // which would usually done in the browser.
+
+ const httpLib = createPlatformHttpLib({
+ enableThrottling: false,
+ });
+ const kycServerResp = await httpLib.fetch(kycUrl);
+ const kycLoginResp = await kycServerResp.json();
+ logger.info(`kyc server resp: ${j2s(kycLoginResp)}`);
+ const kycProofUrl = kycLoginResp.redirect_uri;
+ // We need to "visit" the KYC proof URL at least once to trigger the exchange
+ // asking for the KYC status.
+ const proofHttpResp = await httpLib.fetch(kycProofUrl);
+ logger.info(`proof resp status ${proofHttpResp.status}`);
+ logger.info(`resp headers ${j2s(proofHttpResp.headers.toJSON())}`);
+ if (
+ !(proofHttpResp.status >= 200 && proofHttpResp.status <= 299) &&
+ proofHttpResp.status !== 303
+ ) {
+ logger.error("kyc proof failed");
+ logger.info(await proofHttpResp.text());
+ t.assertTrue(false);
+ }
+
+ // Now that KYC is done, withdrawal should finally succeed.
+
+ await withdrawalDoneCond;
+
+ kycServer.stop();
+}
+
+runKycTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-libeufin-bank.ts b/packages/taler-harness/src/integrationtests/test-libeufin-bank.ts
new file mode 100644
index 000000000..3900779e2
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-libeufin-bank.ts
@@ -0,0 +1,231 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ CreditDebitIndicator,
+ Logger,
+ TalerCorebankApiClient,
+ TransactionMajorState,
+ TransactionMinorState,
+ WireGatewayApiClient,
+ createEddsaKeyPair,
+ encodeCrock,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ GlobalTestState,
+ LibeufinBankService,
+ MerchantService,
+ generateRandomPayto,
+ generateRandomTestIban,
+ setupDb,
+} from "../harness/harness.js";
+import { createWalletDaemonWithClient } from "../harness/helpers.js";
+
+const logger = new Logger("test-libeufin-bank.ts");
+
+/**
+ * Run test for the basic functionality of libeufin-bank.
+ */
+export async function runLibeufinBankTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await LibeufinBankService.create(t, {
+ currency: "TESTKUDOS",
+ httpPort: 8082,
+ database: db.connStr,
+ allowRegistrations: true,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeIban = generateRandomTestIban();
+ const exchangeBankUsername = "exchange";
+ const exchangeBankPw = "mypw";
+ const exchangePlainPayto = `payto://iban/${exchangeIban}`;
+ const exchangeExtendedPayto = `payto://iban/${exchangeIban}?receiver-name=Exchange`;
+ const wireGatewayApiBaseUrl = new URL(
+ "accounts/exchange/taler-wire-gateway/",
+ bank.baseUrl,
+ ).href;
+
+ logger.info("creating bank account for the exchange");
+
+ exchange.addBankAccount("1", {
+ wireGatewayApiBaseUrl,
+ accountName: exchangeBankUsername,
+ accountPassword: exchangeBankPw,
+ accountPaytoUri: exchangeExtendedPayto,
+ });
+
+ bank.setSuggestedExchange(exchange);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wallet",
+ });
+
+ console.log("setup done!");
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ });
+
+ // register exchange bank account
+ await bankClient.registerAccountExtended({
+ name: "Exchange",
+ password: exchangeBankPw,
+ username: exchangeBankUsername,
+ is_taler_exchange: true,
+ payto_uri: exchangePlainPayto,
+ });
+
+ const bankUser = await bankClient.registerAccount("user1", "pw1");
+ bankClient.setAuth({
+ username: "user1",
+ password: "pw1",
+ });
+
+ // Make sure that registering twice results in a 409 Conflict
+ // {
+ // const e = await t.assertThrowsTalerErrorAsync(async () => {
+ // await bankClient.registerAccount("user1", "pw2");
+ // });
+ // t.assertTrue(e.errorDetail.httpStatusCode === 409);
+ // }
+
+ let balResp = await bankClient.getAccountBalance(bankUser.username);
+
+ console.log(balResp);
+
+ // Check that we got the sign-up bonus.
+ t.assertAmountEquals(balResp.balance.amount, "TESTKUDOS:100");
+ t.assertTrue(
+ balResp.balance.credit_debit_indicator === CreditDebitIndicator.Credit,
+ );
+
+ const res = createEddsaKeyPair();
+
+ // Not a normal client, but one with admin credentials,
+ // as /add-incoming is testing functionality only allowed by the admin.
+ const wireGatewayApiAdminClient = new WireGatewayApiClient(
+ wireGatewayApiBaseUrl,
+ {
+ auth: {
+ username: "admin",
+ password: "adminpw",
+ },
+ },
+ );
+
+ await wireGatewayApiAdminClient.adminAddIncoming({
+ amount: "TESTKUDOS:115",
+ debitAccountPayto: bankUser.accountPaytoUri,
+ reservePub: encodeCrock(res.eddsaPub),
+ });
+
+ balResp = await bankClient.getAccountBalance(bankUser.username);
+ t.assertAmountEquals(balResp.balance.amount, "TESTKUDOS:15");
+ t.assertTrue(
+ balResp.balance.credit_debit_indicator === CreditDebitIndicator.Debit,
+ );
+
+ const wop = await bankClient.createWithdrawalOperation(
+ bankUser.username,
+ "TESTKUDOS:10",
+ );
+
+ const r1 = await walletClient.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ console.log(j2s(r1));
+
+ const r2 = await walletClient.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: r2.transactionId,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ },
+ });
+
+ await bankClient.confirmWithdrawalOperation(bankUser.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runLibeufinBankTest.suites = ["fakebank"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-exchange-confusion.ts b/packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts
index 8e8f966b9..35e3267b1 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-exchange-confusion.ts
+++ b/packages/taler-harness/src/integrationtests/test-merchant-exchange-confusion.ts
@@ -18,33 +18,32 @@
* Imports.
*/
import {
+ codecForMerchantOrderStatusUnpaid,
+ ConfirmPayResultType,
+ MerchantApiClient,
+ PreparePayResultType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { URL } from "url";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectedMerchantService,
+} from "../harness/faultInjection.js";
+import {
BankService,
ExchangeService,
+ generateRandomPayto,
GlobalTestState,
- MerchantPrivateApi,
+ harnessHttpLib,
MerchantService,
setupDb,
- WalletCli,
} from "../harness/harness.js";
import {
- withdrawViaBank,
- createFaultInjectedMerchantTestkudosEnvironment,
+ createWalletDaemonWithClient,
FaultyMerchantTestEnvironment,
+ withdrawViaBankV2,
} from "../harness/helpers.js";
-import {
- PreparePayResultType,
- codecForMerchantOrderStatusUnpaid,
- ConfirmPayResultType,
-} from "@gnu-taler/taler-util";
-import axios from "axios";
-import {
- FaultInjectedExchangeService,
- FaultInjectedMerchantService,
- FaultInjectionRequestContext,
-} from "../harness/faultInjection";
-import { defaultCoinConfig } from "../harness/denomStructures";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { URL } from "url";
/**
* Run a test case with a simple TESTKUDOS Taler environment, consisting
@@ -79,8 +78,13 @@ export async function createConfusedMerchantTestkudosEnvironment(
const faultyMerchant = new FaultInjectedMerchantService(t, merchant, 9083);
const faultyExchange = new FaultInjectedExchangeService(t, exchange, 9081);
+ // Base URL must contain port that the proxy is listening on.
+ await exchange.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", "http://localhost:9081/");
+ });
+
const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
+ "myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
@@ -105,27 +109,29 @@ export async function createConfusedMerchantTestkudosEnvironment(
await merchant.start();
await merchant.pingUntilAvailable();
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
});
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
- paytoUris: ["payto://x-taler-bank/minst1"],
+ paytoUris: [generateRandomPayto("minst1")],
});
console.log("setup done!");
- const wallet = new WalletCli(t);
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
return {
commonDb: db,
exchange,
merchant,
- wallet,
+ walletClient,
bank,
exchangeBankAccount,
faultyMerchant,
@@ -140,22 +146,20 @@ export async function createConfusedMerchantTestkudosEnvironment(
export async function runMerchantExchangeConfusionTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- faultyExchange,
- faultyMerchant,
- } = await createConfusedMerchantTestkudosEnvironment(t);
+ const { walletClient, bank, faultyExchange, faultyMerchant } =
+ await createConfusedMerchantTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, {
- wallet,
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
bank,
exchange: faultyExchange,
amount: "TESTKUDOS:20",
});
+ await wres.withdrawalFinishedCond;
+
/**
* =========================================================================
* Create an order and let the wallet pay under a session ID
@@ -167,7 +171,9 @@ export async function runMerchantExchangeConfusionTest(t: GlobalTestState) {
const merchant = faultyMerchant;
- let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ let orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -175,7 +181,7 @@ export async function runMerchantExchangeConfusionTest(t: GlobalTestState) {
},
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
sessionId: "mysession-one",
});
@@ -185,9 +191,7 @@ export async function runMerchantExchangeConfusionTest(t: GlobalTestState) {
t.assertTrue(orderStatus.already_paid_order_id === undefined);
let publicOrderStatusUrl = orderStatus.order_status_url;
- let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
- validateStatus: () => true,
- });
+ let publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
if (publicOrderStatusResp.status != 402) {
throw Error(
@@ -196,12 +200,12 @@ export async function runMerchantExchangeConfusionTest(t: GlobalTestState) {
}
let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ await publicOrderStatusResp.json(),
);
console.log(pubUnpaidStatus);
- let preparePayResp = await wallet.client.call(
+ let preparePayResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: pubUnpaidStatus.taler_pay_uri,
@@ -213,13 +217,14 @@ export async function runMerchantExchangeConfusionTest(t: GlobalTestState) {
const proposalId = preparePayResp.proposalId;
const orderUrlWithHash = new URL(publicOrderStatusUrl);
- orderUrlWithHash.searchParams.set("h_contract", preparePayResp.contractTermsHash);
+ orderUrlWithHash.searchParams.set(
+ "h_contract",
+ preparePayResp.contractTermsHash,
+ );
console.log("requesting", orderUrlWithHash.href);
- publicOrderStatusResp = await axios.get(orderUrlWithHash.href, {
- validateStatus: () => true,
- });
+ publicOrderStatusResp = await harnessHttpLib.fetch(orderUrlWithHash.href);
if (publicOrderStatusResp.status != 402) {
throw Error(
@@ -228,15 +233,12 @@ export async function runMerchantExchangeConfusionTest(t: GlobalTestState) {
}
pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ await publicOrderStatusResp.json(),
);
- const confirmPayRes = await wallet.client.call(
- WalletApiOperation.ConfirmPay,
- {
- proposalId: proposalId,
- },
- );
+ const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: proposalId,
+ });
t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-delete.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts
index 589c79120..c0c9353e4 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-delete.ts
+++ b/packages/taler-harness/src/integrationtests/test-merchant-instances-delete.ts
@@ -17,13 +17,13 @@
/**
* Imports.
*/
-import { URL } from "@gnu-taler/taler-util";
-import axios from "axios";
+import { MerchantApiClient, TalerError, URL } from "@gnu-taler/taler-util";
import {
ExchangeService,
GlobalTestState,
- MerchantApiClient,
MerchantService,
+ generateRandomPayto,
+ harnessHttpLib,
setupDb,
} from "../harness/harness.js";
@@ -59,39 +59,45 @@ export async function runMerchantInstancesDeleteTest(t: GlobalTestState) {
const baseUrl = merchant.makeInstanceBaseUrl();
{
- const r = await axios.get(new URL("config", baseUrl).href);
- console.log(r.data);
- t.assertDeepEqual(r.data.currency, "TESTKUDOS");
+ const r = await harnessHttpLib.fetch(new URL("config", baseUrl).href);
+ const data = await r.json();
+ console.log(data);
+ t.assertDeepEqual(data.currency, "TESTKUDOS");
}
// Instances should initially be empty
{
- const r = await axios.get(new URL("management/instances", baseUrl).href);
- t.assertDeepEqual(r.data.instances, []);
+ const r = await harnessHttpLib.fetch(
+ new URL("management/instances", baseUrl).href,
+ );
+ const data = await r.json();
+ t.assertDeepEqual(data.instances, []);
}
// Add an instance, no auth!
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
auth: {
method: "external",
},
});
// Add an instance, no auth!
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "myinst",
name: "Second Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
auth: {
method: "external",
},
});
let merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
- method: "external",
+ auth: {
+ method: "external",
+ },
});
await merchantClient.changeAuth({
@@ -100,8 +106,10 @@ export async function runMerchantInstancesDeleteTest(t: GlobalTestState) {
});
merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
- method: "token",
- token: "secret-token:foobar",
+ auth: {
+ method: "token",
+ token: "secret-token:foobar",
+ },
});
// Check that deleting an instance checks the auth
@@ -110,8 +118,10 @@ export async function runMerchantInstancesDeleteTest(t: GlobalTestState) {
const unauthMerchantClient = new MerchantApiClient(
merchant.makeInstanceBaseUrl(),
{
- method: "token",
- token: "secret-token:invalid",
+ auth: {
+ method: "token",
+ token: "secret-token:invalid",
+ },
},
);
@@ -119,8 +129,8 @@ export async function runMerchantInstancesDeleteTest(t: GlobalTestState) {
await unauthMerchantClient.deleteInstance("myinst");
});
console.log("Got expected exception", exc);
- t.assertAxiosError(exc);
- t.assertDeepEqual(exc.response?.status, 401);
+ t.assertTrue(exc instanceof TalerError);
+ t.assertDeepEqual(exc.errorDetail.httpStatusCode, 401);
}
}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-urls.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts
index fc5e7305a..b631ea1a4 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-urls.ts
+++ b/packages/taler-harness/src/integrationtests/test-merchant-instances-urls.ts
@@ -17,12 +17,12 @@
/**
* Imports.
*/
-import axios from "axios";
+import { Duration, MerchantApiClient } from "@gnu-taler/taler-util";
import {
ExchangeService,
GlobalTestState,
- MerchantApiClient,
MerchantService,
+ harnessHttpLib,
setupDb,
} from "../harness/harness.js";
@@ -30,8 +30,6 @@ import {
* Do basic checks on instance management and authentication.
*/
export async function runMerchantInstancesUrlsTest(t: GlobalTestState) {
- // Set up test environment
-
const db = await setupDb(t);
const exchange = ExchangeService.create(t, {
@@ -56,22 +54,25 @@ export async function runMerchantInstancesUrlsTest(t: GlobalTestState) {
const clientForDefault = new MerchantApiClient(
merchant.makeInstanceBaseUrl(),
{
- method: "token",
- token: "secret-token:i-am-default",
+ auth: {
+ method: "token",
+ token: "secret-token:i-am-default",
+ },
},
);
await clientForDefault.createInstance({
id: "default",
address: {},
- default_max_deposit_fee: "TESTKUDOS:1",
- default_max_wire_fee: "TESTKUDOS:1",
- default_pay_delay: { d_ms: 60000 },
- default_wire_fee_amortization: 1,
- default_wire_transfer_delay: { d_ms: 60000 },
+ use_stefan: true,
+ default_pay_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
+ default_wire_transfer_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
jurisdiction: {},
name: "My Default Instance",
- payto_uris: ["payto://x-taler-bank/foo/bar"],
auth: {
method: "token",
token: "secret-token:i-am-default",
@@ -81,14 +82,15 @@ export async function runMerchantInstancesUrlsTest(t: GlobalTestState) {
await clientForDefault.createInstance({
id: "myinst",
address: {},
- default_max_deposit_fee: "TESTKUDOS:1",
- default_max_wire_fee: "TESTKUDOS:1",
- default_pay_delay: { d_ms: 60000 },
- default_wire_fee_amortization: 1,
- default_wire_transfer_delay: { d_ms: 60000 },
+ default_pay_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
+ use_stefan: true,
+ default_wire_transfer_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ seconds: 60 }),
+ ),
jurisdiction: {},
name: "My Second Instance",
- payto_uris: ["payto://x-taler-bank/foo/bar"],
auth: {
method: "token",
token: "secret-token:i-am-myinst",
@@ -96,11 +98,10 @@ export async function runMerchantInstancesUrlsTest(t: GlobalTestState) {
});
async function check(url: string, token: string, expectedStatus: number) {
- const resp = await axios.get(url, {
+ const resp = await harnessHttpLib.fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
- validateStatus: () => true,
});
console.log(
`checking ${url}, expected ${expectedStatus}, got ${resp.status}`,
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances.ts
index 46af87922..188451e15 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances.ts
+++ b/packages/taler-harness/src/integrationtests/test-merchant-instances.ts
@@ -17,13 +17,13 @@
/**
* Imports.
*/
-import { URL } from "@gnu-taler/taler-util";
-import axios from "axios";
+import { MerchantApiClient, URL } from "@gnu-taler/taler-util";
import {
ExchangeService,
GlobalTestState,
- MerchantApiClient,
MerchantService,
+ generateRandomPayto,
+ harnessHttpLib,
setupDb,
} from "../harness/harness.js";
@@ -59,39 +59,55 @@ export async function runMerchantInstancesTest(t: GlobalTestState) {
const baseUrl = merchant.makeInstanceBaseUrl();
{
- const r = await axios.get(new URL("config", baseUrl).href);
- console.log(r.data);
- t.assertDeepEqual(r.data.currency, "TESTKUDOS");
+ const r = await harnessHttpLib.fetch(new URL("config", baseUrl).href);
+ const data = await r.json();
+ console.log(data);
+ t.assertDeepEqual(data.currency, "TESTKUDOS");
}
// Instances should initially be empty
{
- const r = await axios.get(new URL("management/instances", baseUrl).href);
- t.assertDeepEqual(r.data.instances, []);
+ const r = await harnessHttpLib.fetch(
+ new URL("management/instances", baseUrl).href,
+ );
+ const data = await r.json();
+ t.assertDeepEqual(data.instances, []);
}
// Add an instance, no auth!
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
+ auth: {
+ method: "external",
+ },
+ });
+
+ // Add it again, should be idempotent
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
auth: {
method: "external",
},
});
// Add an instance, no auth!
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "myinst",
name: "Second Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
auth: {
method: "external",
},
});
let merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
- method: "external",
+ auth: {
+ method: "external",
+ },
});
{
@@ -102,11 +118,14 @@ export async function runMerchantInstancesTest(t: GlobalTestState) {
// Check that a "malformed" bearer Authorization header gets ignored
{
const url = merchant.makeInstanceBaseUrl();
- const resp = await axios.get(new URL("management/instances", url).href, {
- headers: {
- Authorization: "foo bar-baz",
+ const resp = await harnessHttpLib.fetch(
+ new URL("management/instances", url).href,
+ {
+ headers: {
+ Authorization: "foo bar-baz",
+ },
},
- });
+ );
t.assertDeepEqual(resp.status, 200);
}
@@ -128,13 +147,13 @@ export async function runMerchantInstancesTest(t: GlobalTestState) {
});
console.log(exc);
-
- t.assertAxiosError(exc);
- t.assertTrue(exc.response?.status === 401);
+ t.assertTrue(exc.errorDetail.httpStatusCode === 401);
merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl(), {
- method: "token",
- token: "secret-token:foobar",
+ auth: {
+ method: "token",
+ token: "secret-token:foobar",
+ },
});
// With the new client auth settings, request should work again.
@@ -143,12 +162,15 @@ export async function runMerchantInstancesTest(t: GlobalTestState) {
// Now, try some variations.
{
const url = merchant.makeInstanceBaseUrl();
- const resp = await axios.get(new URL("management/instances", url).href, {
- headers: {
- // Note the spaces
- Authorization: "Bearer secret-token:foobar",
+ const resp = await harnessHttpLib.fetch(
+ new URL("management/instances", url).href,
+ {
+ headers: {
+ // Note the spaces
+ Authorization: "Bearer secret-token:foobar",
+ },
},
- });
+ );
t.assertDeepEqual(resp.status, 200);
}
@@ -166,7 +188,9 @@ export async function runMerchantInstancesTest(t: GlobalTestState) {
const unauthMerchantClient = new MerchantApiClient(
merchant.makeInstanceBaseUrl(),
{
- method: "external",
+ auth: {
+ method: "external",
+ },
},
);
@@ -174,8 +198,7 @@ export async function runMerchantInstancesTest(t: GlobalTestState) {
await unauthMerchantClient.deleteInstance("myinst");
});
console.log(exc);
- t.assertAxiosError(exc);
- t.assertDeepEqual(exc.response?.status, 401);
+ t.assertTrue(exc.errorDetail.httpStatusCode === 401);
}
}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-longpolling.ts b/packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts
index 556d9074e..bd63a8445 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-longpolling.ts
+++ b/packages/taler-harness/src/integrationtests/test-merchant-longpolling.ts
@@ -17,33 +17,40 @@
/**
* Imports.
*/
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
import {
- PreparePayResultType,
- codecForMerchantOrderStatusUnpaid,
ConfirmPayResultType,
+ MerchantApiClient,
+ PreparePayResultType,
URL,
+ codecForMerchantOrderStatusUnpaid,
} from "@gnu-taler/taler-util";
-import axios from "axios";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
/**
* Run test for basic, bank-integrated withdrawal.
*/
export async function runMerchantLongpollingTest(t: GlobalTestState) {
// Set up test environment
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
- const {
- wallet,
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
bank,
exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ amount: "TESTKUDOS:20",
+ });
- // Withdraw digital cash into the wallet.
+ await wres.withdrawalFinishedCond;
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
/**
* =========================================================================
@@ -54,7 +61,7 @@ export async function runMerchantLongpollingTest(t: GlobalTestState) {
* =========================================================================
*/
- let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ let orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -63,7 +70,7 @@ export async function runMerchantLongpollingTest(t: GlobalTestState) {
create_token: false,
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
sessionId: "mysession-one",
});
@@ -76,9 +83,9 @@ export async function runMerchantLongpollingTest(t: GlobalTestState) {
// First, request order status without longpolling
{
console.log("requesting", publicOrderStatusUrl.href);
- let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
+ let publicOrderStatusResp = await harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
if (publicOrderStatusResp.status != 402) {
throw Error(
@@ -91,9 +98,9 @@ export async function runMerchantLongpollingTest(t: GlobalTestState) {
publicOrderStatusUrl.searchParams.set("timeout_ms", "500");
console.log("requesting", publicOrderStatusUrl.href);
- let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
+ let publicOrderStatusResp = await harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
if (publicOrderStatusResp.status != 402) {
throw Error(
@@ -102,7 +109,7 @@ export async function runMerchantLongpollingTest(t: GlobalTestState) {
}
let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ await publicOrderStatusResp.json(),
);
console.log(pubUnpaidStatus);
@@ -113,7 +120,7 @@ export async function runMerchantLongpollingTest(t: GlobalTestState) {
* =========================================================================
*/
- let preparePayResp = await wallet.client.call(
+ let preparePayResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: pubUnpaidStatus.taler_pay_uri,
@@ -128,9 +135,9 @@ export async function runMerchantLongpollingTest(t: GlobalTestState) {
preparePayResp.contractTermsHash,
);
- let publicOrderStatusPromise = axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
+ let publicOrderStatusPromise = harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
@@ -145,15 +152,12 @@ export async function runMerchantLongpollingTest(t: GlobalTestState) {
}
pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ await publicOrderStatusResp.json(),
);
- const confirmPayRes = await wallet.client.call(
- WalletApiOperation.ConfirmPay,
- {
- proposalId: proposalId,
- },
- );
+ const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: proposalId,
+ });
t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-refund-api.ts b/packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts
index 466b1efbd..7ee4c977b 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-refund-api.ts
+++ b/packages/taler-harness/src/integrationtests/test-merchant-refund-api.ts
@@ -18,44 +18,51 @@
* Imports.
*/
import {
+ Duration,
+ MerchantApiClient,
+ PreparePayResultType,
+ URL,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ BankServiceHandle,
+ ExchangeServiceInterface,
GlobalTestState,
- MerchantPrivateApi,
- BankServiceInterface,
MerchantServiceInterface,
- WalletCli,
- ExchangeServiceInterface,
+ WalletClient,
+ harnessHttpLib,
} from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
import {
- URL,
- durationFromSpec,
- PreparePayResultType,
-} from "@gnu-taler/taler-util";
-import axios from "axios";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
async function testRefundApiWithFulfillmentUrl(
t: GlobalTestState,
env: {
merchant: MerchantServiceInterface;
- bank: BankServiceInterface;
- wallet: WalletCli;
+ bank: BankServiceHandle;
+ walletClient: WalletClient;
exchange: ExchangeServiceInterface;
},
): Promise<void> {
- const { wallet, bank, exchange, merchant } = env;
+ const { walletClient, merchant } = env;
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
// Set up order.
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
fulfillment_url: "https://example.com/fulfillment",
},
- refund_delay: durationFromSpec({ minutes: 5 }),
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 5 }),
+ ),
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -66,7 +73,7 @@ async function testRefundApiWithFulfillmentUrl(
// Make wallet pay for the order
- let preparePayResult = await wallet.client.call(
+ let preparePayResult = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri,
@@ -77,19 +84,19 @@ async function testRefundApiWithFulfillmentUrl(
preparePayResult.status === PreparePayResultType.PaymentPossible,
);
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
proposalId: preparePayResult.proposalId,
});
// Check if payment was successful.
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
t.assertTrue(orderStatus.order_status === "paid");
- preparePayResult = await wallet.client.call(
+ preparePayResult = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri,
@@ -100,14 +107,14 @@ async function testRefundApiWithFulfillmentUrl(
preparePayResult.status === PreparePayResultType.AlreadyConfirmed,
);
- await MerchantPrivateApi.giveRefund(merchant, {
+ await merchantClient.giveRefund({
amount: "TESTKUDOS:5",
instance: "default",
justification: "foo",
orderId: orderResp.order_id,
});
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -127,23 +134,21 @@ async function testRefundApiWithFulfillmentUrl(
preparePayResult.contractTermsHash,
);
- let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
- console.log(publicOrderStatusResp.data);
+ let publicOrderStatusResp = await harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
+ const respData = await publicOrderStatusResp.json();
t.assertTrue(publicOrderStatusResp.status === 200);
- t.assertAmountEquals(publicOrderStatusResp.data.refund_amount, "TESTKUDOS:5");
+ t.assertAmountEquals(respData.refund_amount, "TESTKUDOS:5");
publicOrderStatusUrl = new URL(
`orders/${orderId}`,
merchant.makeInstanceBaseUrl(),
);
console.log(`requesting order status via '${publicOrderStatusUrl.href}'`);
- publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl.href);
console.log(publicOrderStatusResp.status);
- console.log(publicOrderStatusResp.data);
+ console.log(await publicOrderStatusResp.json());
// We didn't give any authentication, so we should get a fulfillment URL back
t.assertTrue(publicOrderStatusResp.status === 403);
}
@@ -152,24 +157,28 @@ async function testRefundApiWithFulfillmentMessage(
t: GlobalTestState,
env: {
merchant: MerchantServiceInterface;
- bank: BankServiceInterface;
- wallet: WalletCli;
+ bank: BankServiceHandle;
+ walletClient: WalletClient;
exchange: ExchangeServiceInterface;
},
): Promise<void> {
- const { wallet, bank, exchange, merchant } = env;
+ const { walletClient, merchant } = env;
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
// Set up order.
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
fulfillment_message: "Thank you for buying foobar",
},
- refund_delay: durationFromSpec({ minutes: 5 }),
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 5 }),
+ ),
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -180,7 +189,7 @@ async function testRefundApiWithFulfillmentMessage(
// Make wallet pay for the order
- let preparePayResult = await wallet.client.call(
+ let preparePayResult = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri,
@@ -191,19 +200,19 @@ async function testRefundApiWithFulfillmentMessage(
preparePayResult.status === PreparePayResultType.PaymentPossible,
);
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
proposalId: preparePayResult.proposalId,
});
// Check if payment was successful.
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
t.assertTrue(orderStatus.order_status === "paid");
- preparePayResult = await wallet.client.call(
+ preparePayResult = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri,
@@ -214,14 +223,14 @@ async function testRefundApiWithFulfillmentMessage(
preparePayResult.status === PreparePayResultType.AlreadyConfirmed,
);
- await MerchantPrivateApi.giveRefund(merchant, {
+ await merchantClient.giveRefund({
amount: "TESTKUDOS:5",
instance: "default",
justification: "foo",
orderId: orderResp.order_id,
});
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -241,22 +250,22 @@ async function testRefundApiWithFulfillmentMessage(
preparePayResult.contractTermsHash,
);
- let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
- console.log(publicOrderStatusResp.data);
+ let publicOrderStatusResp = await harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
+ let respData = await publicOrderStatusResp.json();
+ console.log(respData);
t.assertTrue(publicOrderStatusResp.status === 200);
- t.assertAmountEquals(publicOrderStatusResp.data.refund_amount, "TESTKUDOS:5");
+ t.assertAmountEquals(respData.refund_amount, "TESTKUDOS:5");
publicOrderStatusUrl = new URL(
`orders/${orderId}`,
merchant.makeInstanceBaseUrl(),
);
- publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
- console.log(publicOrderStatusResp.data);
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl.href);
+ respData = await publicOrderStatusResp.json();
+ console.log(respData);
// We didn't give any authentication, so we should get a fulfillment URL back
t.assertTrue(publicOrderStatusResp.status === 403);
}
@@ -267,26 +276,28 @@ async function testRefundApiWithFulfillmentMessage(
export async function runMerchantRefundApiTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
await testRefundApiWithFulfillmentUrl(t, {
- wallet,
+ walletClient,
bank,
exchange,
merchant,
});
await testRefundApiWithFulfillmentMessage(t, {
- wallet,
+ walletClient,
bank,
exchange,
merchant,
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-spec-public-orders.ts b/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts
index 70edaaf0c..8e664dfa9 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-spec-public-orders.ts
+++ b/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts
@@ -19,27 +19,26 @@
*/
import {
ConfirmPayResultType,
+ MerchantApiClient,
PreparePayResultType,
URL,
encodeCrock,
getRandomBytes,
} from "@gnu-taler/taler-util";
-import { NodeHttpLib, WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import {
BankService,
ExchangeService,
GlobalTestState,
- MerchantPrivateApi,
MerchantService,
- WalletCli,
+ harnessHttpLib,
} from "../harness/harness.js";
import {
- createSimpleTestkudosEnvironment,
- withdrawViaBank,
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
} from "../harness/helpers.js";
-const httpLib = new NodeHttpLib();
-
interface Context {
merchant: MerchantService;
merchantBaseUrl: string;
@@ -47,16 +46,27 @@ interface Context {
exchange: ExchangeService;
}
+const httpLib = harnessHttpLib;
+
async function testWithClaimToken(
t: GlobalTestState,
c: Context,
): Promise<void> {
- const wallet = new WalletCli(t, "withclaimtoken");
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wct",
+ });
const { bank, exchange } = c;
const { merchant, merchantBaseUrl } = c;
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
const sessionId = "mysession";
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -71,7 +81,7 @@ async function testWithClaimToken(
let talerPayUri: string;
{
- const httpResp = await httpLib.get(
+ const httpResp = await httpLib.fetch(
new URL(`orders/${orderId}`, merchantBaseUrl).href,
);
const r = await httpResp.json();
@@ -82,7 +92,7 @@ async function testWithClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
url.searchParams.set("token", claimToken);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
t.assertDeepEqual(httpResp.status, 402);
console.log(r);
@@ -93,7 +103,7 @@ async function testWithClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
url.searchParams.set("token", claimToken);
- const httpResp = await httpLib.get(url.href, {
+ const httpResp = await httpLib.fetch(url.href, {
headers: {
Accept: "text/html",
},
@@ -103,7 +113,7 @@ async function testWithClaimToken(
console.log(r);
}
- const preparePayResp = await wallet.client.call(
+ const preparePayResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri,
@@ -119,7 +129,7 @@ async function testWithClaimToken(
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
const hcWrong = encodeCrock(getRandomBytes(64));
url.searchParams.set("h_contract", hcWrong);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 403);
@@ -130,7 +140,7 @@ async function testWithClaimToken(
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
const ctWrong = encodeCrock(getRandomBytes(16));
url.searchParams.set("token", ctWrong);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 403);
@@ -140,7 +150,7 @@ async function testWithClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
url.searchParams.set("token", claimToken);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 402);
@@ -150,7 +160,7 @@ async function testWithClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
url.searchParams.set("h_contract", contractTermsHash);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 402);
@@ -159,25 +169,22 @@ async function testWithClaimToken(
// claimed, unpaid, access without credentials
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 202);
}
- const confirmPayRes = await wallet.client.call(
- WalletApiOperation.ConfirmPay,
- {
- proposalId: proposalId,
- },
- );
+ const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: proposalId,
+ });
t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
// paid, access without credentials
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 202);
@@ -188,7 +195,7 @@ async function testWithClaimToken(
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
const hcWrong = encodeCrock(getRandomBytes(64));
url.searchParams.set("h_contract", hcWrong);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 403);
@@ -199,7 +206,7 @@ async function testWithClaimToken(
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
const ctWrong = encodeCrock(getRandomBytes(16));
url.searchParams.set("token", ctWrong);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 403);
@@ -209,7 +216,7 @@ async function testWithClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
url.searchParams.set("h_contract", contractTermsHash);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 200);
@@ -219,7 +226,7 @@ async function testWithClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
url.searchParams.set("token", claimToken);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 200);
@@ -231,13 +238,13 @@ async function testWithClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
url.searchParams.set("token", claimToken);
- const httpResp = await httpLib.get(url.href, {
+ const httpResp = await httpLib.fetch(url.href, {
headers: { Accept: "text/html" },
});
t.assertDeepEqual(httpResp.status, 200);
}
- const confirmPayRes2 = await wallet.client.call(
+ const confirmPayRes2 = await walletClient.call(
WalletApiOperation.ConfirmPay,
{
proposalId: proposalId,
@@ -248,18 +255,14 @@ async function testWithClaimToken(
t.assertTrue(confirmPayRes2.type === ConfirmPayResultType.Done);
// Create another order with identical fulfillment URL to test the "already paid" flow
- const alreadyPaidOrderResp = await MerchantPrivateApi.createOrder(
- merchant,
- "default",
- {
- order: {
- summary: "Buy me!",
- amount: "TESTKUDOS:5",
- fulfillment_url: "https://example.com/article42",
- public_reorder_url: "https://example.com/article42-share",
- },
+ const alreadyPaidOrderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
},
- );
+ });
const apOrderId = alreadyPaidOrderResp.order_id;
const apToken = alreadyPaidOrderResp.token;
@@ -268,7 +271,7 @@ async function testWithClaimToken(
{
const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
url.searchParams.set("token", apToken);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 402);
@@ -279,7 +282,7 @@ async function testWithClaimToken(
const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
url.searchParams.set("token", apToken);
url.searchParams.set("session_id", sessionId);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 402);
@@ -292,9 +295,13 @@ async function testWithClaimToken(
const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
url.searchParams.set("token", apToken);
url.searchParams.set("session_id", sessionId);
- const httpResp = await httpLib.get(url.href, {
+ const httpResp = await httpLib.fetch(url.href, {
headers: { Accept: "text/html" },
+ redirect: "manual",
});
+ console.log(
+ `requesting GET ${url.href}, expected 302 got ${httpResp.status}`,
+ );
t.assertDeepEqual(httpResp.status, 302);
const location = httpResp.headers.get("Location");
console.log("location header:", location);
@@ -306,12 +313,21 @@ async function testWithoutClaimToken(
t: GlobalTestState,
c: Context,
): Promise<void> {
- const wallet = new WalletCli(t, "withoutct");
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wnoct",
+ });
const sessionId = "mysession2";
const { bank, exchange } = c;
const { merchant, merchantBaseUrl } = c;
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -325,7 +341,7 @@ async function testWithoutClaimToken(
let talerPayUri: string;
{
- const httpResp = await httpLib.get(
+ const httpResp = await httpLib.fetch(
new URL(`orders/${orderId}`, merchantBaseUrl).href,
);
const r = await httpResp.json();
@@ -335,7 +351,7 @@ async function testWithoutClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
t.assertDeepEqual(httpResp.status, 402);
console.log(r);
@@ -345,7 +361,7 @@ async function testWithoutClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
- const httpResp = await httpLib.get(url.href, {
+ const httpResp = await httpLib.fetch(url.href, {
headers: {
Accept: "text/html",
},
@@ -355,7 +371,7 @@ async function testWithoutClaimToken(
console.log(r);
}
- const preparePayResp = await wallet.client.call(
+ const preparePayResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri,
@@ -373,7 +389,7 @@ async function testWithoutClaimToken(
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
const hcWrong = encodeCrock(getRandomBytes(64));
url.searchParams.set("h_contract", hcWrong);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 403);
@@ -384,7 +400,7 @@ async function testWithoutClaimToken(
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
const ctWrong = encodeCrock(getRandomBytes(16));
url.searchParams.set("token", ctWrong);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 403);
@@ -393,7 +409,7 @@ async function testWithoutClaimToken(
// claimed, unpaid, no claim token
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 402);
@@ -403,7 +419,7 @@ async function testWithoutClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
url.searchParams.set("h_contract", contractTermsHash);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 402);
@@ -412,7 +428,7 @@ async function testWithoutClaimToken(
// claimed, unpaid, access without credentials
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
// No credentials, but the order doesn't require a claim token.
@@ -421,19 +437,16 @@ async function testWithoutClaimToken(
t.assertDeepEqual(httpResp.status, 402);
}
- const confirmPayRes = await wallet.client.call(
- WalletApiOperation.ConfirmPay,
- {
- proposalId: proposalId,
- },
- );
+ const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: proposalId,
+ });
t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
// paid, access without credentials
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 200);
@@ -444,7 +457,7 @@ async function testWithoutClaimToken(
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
const hcWrong = encodeCrock(getRandomBytes(64));
url.searchParams.set("h_contract", hcWrong);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 403);
@@ -455,7 +468,7 @@ async function testWithoutClaimToken(
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
const ctWrong = encodeCrock(getRandomBytes(16));
url.searchParams.set("token", ctWrong);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 403);
@@ -465,7 +478,7 @@ async function testWithoutClaimToken(
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
url.searchParams.set("h_contract", contractTermsHash);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 200);
@@ -474,7 +487,7 @@ async function testWithoutClaimToken(
// paid, JSON
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 200);
@@ -485,13 +498,13 @@ async function testWithoutClaimToken(
// paid, HTML
{
const url = new URL(`orders/${orderId}`, merchantBaseUrl);
- const httpResp = await httpLib.get(url.href, {
+ const httpResp = await httpLib.fetch(url.href, {
headers: { Accept: "text/html" },
});
t.assertDeepEqual(httpResp.status, 200);
}
- const confirmPayRes2 = await wallet.client.call(
+ const confirmPayRes2 = await walletClient.call(
WalletApiOperation.ConfirmPay,
{
proposalId: proposalId,
@@ -502,18 +515,14 @@ async function testWithoutClaimToken(
t.assertTrue(confirmPayRes2.type === ConfirmPayResultType.Done);
// Create another order with identical fulfillment URL to test the "already paid" flow
- const alreadyPaidOrderResp = await MerchantPrivateApi.createOrder(
- merchant,
- "default",
- {
- order: {
- summary: "Buy me!",
- amount: "TESTKUDOS:5",
- fulfillment_url: "https://example.com/article42",
- public_reorder_url: "https://example.com/article42-share",
- },
+ const alreadyPaidOrderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
},
- );
+ });
const apOrderId = alreadyPaidOrderResp.order_id;
const apToken = alreadyPaidOrderResp.token;
@@ -522,7 +531,7 @@ async function testWithoutClaimToken(
{
const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
url.searchParams.set("token", apToken);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 402);
@@ -533,7 +542,7 @@ async function testWithoutClaimToken(
const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
url.searchParams.set("token", apToken);
url.searchParams.set("session_id", sessionId);
- const httpResp = await httpLib.get(url.href);
+ const httpResp = await httpLib.fetch(url.href);
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(httpResp.status, 402);
@@ -546,8 +555,9 @@ async function testWithoutClaimToken(
const url = new URL(`orders/${apOrderId}`, merchantBaseUrl);
url.searchParams.set("token", apToken);
url.searchParams.set("session_id", sessionId);
- const httpResp = await httpLib.get(url.href, {
+ const httpResp = await httpLib.fetch(url.href, {
headers: { Accept: "text/html" },
+ redirect: "manual",
});
t.assertDeepEqual(httpResp.status, 302);
const location = httpResp.headers.get("Location");
@@ -563,22 +573,23 @@ async function testWithoutClaimToken(
* specification of the endpoint.
*/
export async function runMerchantSpecPublicOrdersTest(t: GlobalTestState) {
- const { bank, exchange, merchant } = await createSimpleTestkudosEnvironment(
- t,
- );
+ const { bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
// Base URL for the default instance.
const merchantBaseUrl = merchant.makeInstanceBaseUrl();
{
- const httpResp = await httpLib.get(new URL("config", merchantBaseUrl).href);
+ const httpResp = await httpLib.fetch(
+ new URL("config", merchantBaseUrl).href,
+ );
const r = await httpResp.json();
console.log(r);
t.assertDeepEqual(r.currency, "TESTKUDOS");
}
{
- const httpResp = await httpLib.get(
+ const httpResp = await httpLib.fetch(
new URL("orders/foo", merchantBaseUrl).href,
);
const r = await httpResp.json();
@@ -588,7 +599,7 @@ export async function runMerchantSpecPublicOrdersTest(t: GlobalTestState) {
}
{
- const httpResp = await httpLib.get(
+ const httpResp = await httpLib.fetch(
new URL("orders/foo", merchantBaseUrl).href,
{
headers: {
diff --git a/packages/taler-harness/src/integrationtests/test-multiexchange.ts b/packages/taler-harness/src/integrationtests/test-multiexchange.ts
new file mode 100644
index 000000000..e27bccc46
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-multiexchange.ts
@@ -0,0 +1,172 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { Duration } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runMultiExchangeTest(t: GlobalTestState) {
+ // Set up test environment
+ const dbDefault = await setupDb(t);
+
+ const dbExchangeTwo = await setupDb(t, {
+ nameSuffix: "exchange2",
+ });
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: dbDefault.connStr,
+ httpPort: 8082,
+ });
+
+ const exchangeOne = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: dbDefault.connStr,
+ });
+
+ const exchangeTwo = ExchangeService.create(t, {
+ name: "testexchange-2",
+ currency: "TESTKUDOS",
+ httpPort: 8281,
+ database: dbExchangeTwo.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: dbDefault.connStr,
+ });
+
+ const exchangeOneBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ await exchangeOne.addBankAccount("1", exchangeOneBankAccount);
+
+ const exchangeTwoBankAccount = await bank.createExchangeAccount(
+ "myexchange2",
+ "x",
+ );
+ await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount);
+
+ bank.setSuggestedExchange(
+ exchangeOne,
+ exchangeOneBankAccount.accountPaytoUri,
+ );
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ // Set up the first exchange
+
+ exchangeOne.addOfferedCoins(defaultCoinConfig);
+ await exchangeOne.start();
+ await exchangeOne.pingUntilAvailable();
+
+ // Set up the second exchange
+
+ exchangeTwo.addOfferedCoins(defaultCoinConfig);
+ await exchangeTwo.start();
+ await exchangeTwo.pingUntilAvailable();
+
+ // Start and configure merchant
+
+ merchant.addExchange(exchangeOne);
+ merchant.addExchange(exchangeTwo);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet" },
+ );
+
+ console.log("setup done!");
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: exchangeOne,
+ amount: "TESTKUDOS:6",
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: exchangeTwo,
+ amount: "TESTKUDOS:6",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:10",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ console.log("making test payment");
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runMultiExchangeTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-otp.ts b/packages/taler-harness/src/integrationtests/test-otp.ts
new file mode 100644
index 000000000..d0aeba095
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-otp.ts
@@ -0,0 +1,119 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ ConfirmPayResultType,
+ Duration,
+ MerchantApiClient,
+ PreparePayResultType,
+ TransactionType,
+ j2s,
+ narrowOpSuccessOrThrow,
+ randomRfc3548Base32Key,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runOtpTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+ const createOtpRes = await merchantClient.createOtpDevice({
+ otp_algorithm: 1,
+ otp_device_description: "Hello",
+ otp_device_id: "mydevice",
+ otp_key: randomRfc3548Base32Key(),
+ });
+ narrowOpSuccessOrThrow("createOtpDevice", createOtpRes);
+
+ const createTemplateRes = await merchantClient.createTemplate({
+ template_description: "my template",
+ template_id: "tpl1",
+ otp_id: "mydevice",
+ template_contract: {
+ summary: "test",
+ amount: "TESTKUDOS:1",
+ minimum_age: 0,
+ pay_duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ },
+ });
+ narrowOpSuccessOrThrow("createTemplate", createTemplateRes);
+
+ const getTemplateResp = await merchantClient.getTemplate("tpl1");
+ narrowOpSuccessOrThrow("getTemplate", getTemplateResp);
+
+ console.log(`template: ${j2s(getTemplateResp.body)}`);
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForTemplate,
+ {
+ talerPayTemplateUri: `taler+http://pay-template/localhost:${merchant.port}/tpl1`,
+ templateParams: {},
+ },
+ );
+
+ console.log(preparePayResult);
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ // Pay for it
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ const transaction = await walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: preparePayResult.transactionId,
+ },
+ );
+
+ console.log(j2s(transaction));
+
+ t.assertTrue(transaction.type === TransactionType.Payment);
+ t.assertTrue(transaction.posConfirmation != null);
+ t.assertTrue(transaction.posConfirmation.length > 10);
+}
+
+runOtpTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-pay-paid.ts b/packages/taler-harness/src/integrationtests/test-pay-paid.ts
index 2d291ddd3..3d93f6e29 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-pay-paid.ts
+++ b/packages/taler-harness/src/integrationtests/test-pay-paid.ts
@@ -17,20 +17,20 @@
/**
* Imports.
*/
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
import {
- withdrawViaBank,
- createFaultInjectedMerchantTestkudosEnvironment,
-} from "../harness/helpers.js";
-import {
- PreparePayResultType,
- codecForMerchantOrderStatusUnpaid,
ConfirmPayResultType,
+ MerchantApiClient,
+ PreparePayResultType,
URL,
+ codecForMerchantOrderStatusUnpaid,
} from "@gnu-taler/taler-util";
-import axios from "axios";
-import { FaultInjectionRequestContext } from "../harness/faultInjection";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { FaultInjectionRequestContext } from "../harness/faultInjection.js";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import {
+ createFaultInjectedMerchantTestkudosEnvironment,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
/**
* Run test for the wallets repurchase detection mechanism
@@ -43,22 +43,20 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
export async function runPayPaidTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- faultyExchange,
- faultyMerchant,
- } = await createFaultInjectedMerchantTestkudosEnvironment(t);
+ const { walletClient, bank, faultyExchange, faultyMerchant } =
+ await createFaultInjectedMerchantTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, {
- wallet,
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
bank,
exchange: faultyExchange,
amount: "TESTKUDOS:20",
});
+ await wres.withdrawalFinishedCond;
+
/**
* =========================================================================
* Create an order and let the wallet pay under a session ID
@@ -70,7 +68,9 @@ export async function runPayPaidTest(t: GlobalTestState) {
const merchant = faultyMerchant;
- let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ let orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -79,7 +79,7 @@ export async function runPayPaidTest(t: GlobalTestState) {
},
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
sessionId: "mysession-one",
});
@@ -89,9 +89,7 @@ export async function runPayPaidTest(t: GlobalTestState) {
t.assertTrue(orderStatus.already_paid_order_id === undefined);
let publicOrderStatusUrl = orderStatus.order_status_url;
- let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
- validateStatus: () => true,
- });
+ let publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
if (publicOrderStatusResp.status != 402) {
throw Error(
@@ -100,12 +98,12 @@ export async function runPayPaidTest(t: GlobalTestState) {
}
let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ publicOrderStatusResp.json(),
);
console.log(pubUnpaidStatus);
- let preparePayResp = await wallet.client.call(
+ let preparePayResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: pubUnpaidStatus.taler_pay_uri,
@@ -116,9 +114,7 @@ export async function runPayPaidTest(t: GlobalTestState) {
const proposalId = preparePayResp.proposalId;
- publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
- validateStatus: () => true,
- });
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
if (publicOrderStatusResp.status != 402) {
throw Error(
@@ -127,26 +123,21 @@ export async function runPayPaidTest(t: GlobalTestState) {
}
pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ publicOrderStatusResp.json(),
);
- const confirmPayRes = await wallet.client.call(
- WalletApiOperation.ConfirmPay,
- {
- proposalId: proposalId,
- },
- );
+ const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: proposalId,
+ });
t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
- publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
- validateStatus: () => true,
- });
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
- console.log(publicOrderStatusResp.data);
+ console.log(publicOrderStatusResp.json());
if (publicOrderStatusResp.status != 200) {
- console.log(publicOrderStatusResp.data);
+ console.log(publicOrderStatusResp.json());
throw Error(
`expected status 200 (after paying), but got ${publicOrderStatusResp.status}`,
);
@@ -158,7 +149,7 @@ export async function runPayPaidTest(t: GlobalTestState) {
* =========================================================================
*/
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
sessionId: "mysession-two",
});
@@ -185,7 +176,7 @@ export async function runPayPaidTest(t: GlobalTestState) {
},
});
- let orderRespTwo = await MerchantPrivateApi.createOrder(merchant, "default", {
+ let orderRespTwo = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -194,20 +185,17 @@ export async function runPayPaidTest(t: GlobalTestState) {
},
});
- let orderStatusTwo = await MerchantPrivateApi.queryPrivateOrderStatus(
- merchant,
- {
- orderId: orderRespTwo.order_id,
- sessionId: "mysession-two",
- },
- );
+ let orderStatusTwo = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderRespTwo.order_id,
+ sessionId: "mysession-two",
+ });
t.assertTrue(orderStatusTwo.order_status === "unpaid");
// Pay with new taler://pay URI, which should
// have the new session ID!
// Wallet should now automatically re-play payment.
- preparePayResp = await wallet.client.call(
+ preparePayResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: orderStatusTwo.taler_pay_uri,
diff --git a/packages/taler-harness/src/integrationtests/test-payment-abort.ts b/packages/taler-harness/src/integrationtests/test-payment-abort.ts
new file mode 100644
index 000000000..ca8384411
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-abort.ts
@@ -0,0 +1,164 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ ConfirmPayResultType,
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerErrorCode,
+ TalerErrorDetail,
+ URL,
+ codecForMerchantOrderStatusUnpaid,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { FaultInjectionRequestContext } from "../harness/faultInjection.js";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import {
+ createFaultInjectedMerchantTestkudosEnvironment,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+export async function runPaymentAbortTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, faultyMerchant, faultyExchange } =
+ await createFaultInjectedMerchantTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const merchantClient = new MerchantApiClient(
+ faultyMerchant.makeInstanceBaseUrl(),
+ );
+
+ let orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "https://example.com/article42",
+ public_reorder_url: "https://example.com/article42-share",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ sessionId: "mysession-one",
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ t.assertTrue(orderStatus.already_paid_order_id === undefined);
+ let publicOrderStatusUrl = orderStatus.order_status_url;
+
+ let publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ console.log(pubUnpaidStatus);
+
+ let preparePayResp = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: pubUnpaidStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
+
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
+
+ if (publicOrderStatusResp.status != 402) {
+ throw Error(
+ `expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
+ );
+ }
+
+ pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
+ publicOrderStatusResp.json(),
+ );
+
+ faultyMerchant.faultProxy.addFault({
+ async modifyRequest(ctx: FaultInjectionRequestContext) {
+ const url = new URL(ctx.requestUrl);
+ if (!url.pathname.endsWith("/pay")) {
+ return;
+ }
+ ctx.dropRequest = true;
+ const err: TalerErrorDetail = {
+ code: TalerErrorCode.GENERIC_CONFIGURATION_INVALID,
+ hint: "something went wrong",
+ };
+ ctx.substituteResponseStatusCode = 404;
+ ctx.substituteResponseBody = Buffer.from(JSON.stringify(err));
+ console.log("injecting pay fault");
+ },
+ });
+
+ const confirmPayResp = await walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ transactionId: preparePayResp.transactionId,
+ },
+ );
+
+ // Can't have succeeded yet, but network error results in "pending" state.
+ t.assertDeepEqual(confirmPayResp.type, ConfirmPayResultType.Pending);
+
+ const txns = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ });
+ console.log(j2s(txns));
+
+ await walletClient.call(WalletApiOperation.AbortTransaction, {
+ transactionId: txns.transactions[1].transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txns2 = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ });
+ console.log(j2s(txns2));
+
+ const txTypes = txns2.transactions.map((x) => x.type);
+ console.log(txTypes);
+ t.assertDeepEqual(txTypes, ["withdrawal", "payment", "refund"]);
+
+ // FIXME: also check extended transaction list for refresh.
+ // FIXME: also check balance
+}
+
+runPaymentAbortTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts b/packages/taler-harness/src/integrationtests/test-payment-claim.ts
index ba3bd8e0a..3595a1750 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-claim.ts
+++ b/packages/taler-harness/src/integrationtests/test-payment-claim.ts
@@ -17,34 +17,46 @@
/**
* Imports.
*/
-import { GlobalTestState, MerchantPrivateApi, WalletCli } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
-import { PreparePayResultType } from "@gnu-taler/taler-util";
-import { TalerErrorCode } from "@gnu-taler/taler-util";
+import {
+ MerchantApiClient,
+ PreparePayResultType,
+ TalerErrorCode,
+} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
/**
- * Run test for basic, bank-integrated withdrawal.
+ * Run test where a wallet tries to claim an already claimed order.
*/
export async function runPaymentClaimTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
- const walletTwo = new WalletCli(t, "two");
+ const w2 = await createWalletDaemonWithClient(t, { name: "w2" });
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
// Set up order.
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -52,7 +64,7 @@ export async function runPaymentClaimTest(t: GlobalTestState) {
},
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -62,7 +74,7 @@ export async function runPaymentClaimTest(t: GlobalTestState) {
// Make wallet pay for the order
- const preparePayResult = await wallet.client.call(
+ const preparePayResult = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri,
@@ -73,35 +85,35 @@ export async function runPaymentClaimTest(t: GlobalTestState) {
preparePayResult.status === PreparePayResultType.PaymentPossible,
);
- t.assertThrowsOperationErrorAsync(async () => {
- await walletTwo.client.call(WalletApiOperation.PreparePayForUri, {
+ const errOne = t.assertThrowsTalerErrorAsync(async () => {
+ await w2.walletClient.call(WalletApiOperation.PreparePayForUri, {
talerPayUri,
});
});
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
- proposalId: preparePayResult.proposalId,
+ console.log(errOne);
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
});
// Check if payment was successful.
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
t.assertTrue(orderStatus.order_status === "paid");
- walletTwo.deleteDatabase();
+ await w2.walletClient.call(WalletApiOperation.ClearDb, {});
- const err = await t.assertThrowsOperationErrorAsync(async () => {
- await walletTwo.client.call(WalletApiOperation.PreparePayForUri, {
+ const err = await t.assertThrowsTalerErrorAsync(async () => {
+ await w2.walletClient.call(WalletApiOperation.PreparePayForUri, {
talerPayUri,
});
});
- t.assertTrue(
- err.operationError.code === TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
- );
+ t.assertTrue(err.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED));
await t.shutdown();
}
diff --git a/packages/taler-harness/src/integrationtests/test-payment-deleted.ts b/packages/taler-harness/src/integrationtests/test-payment-deleted.ts
new file mode 100644
index 000000000..1d25848fd
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-deleted.ts
@@ -0,0 +1,104 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ ConfirmPayResultType,
+ MerchantApiClient,
+ PreparePayResultType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Test behavior when an order is deleted while the wallet is paying for it.
+ */
+export async function runPaymentDeletedTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // First, make a "free" payment when we don't even have
+ // any money in the
+
+ // Withdraw digital cash into the wallet.
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Hello",
+ amount: "TESTKUDOS:2",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ await merchantClient.deleteOrder({
+ orderId: orderResp.order_id,
+ force: true,
+ });
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Pending);
+
+ await walletClient.call(WalletApiOperation.AbortTransaction, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(bal));
+}
+
+runPaymentDeletedTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment-expired.ts b/packages/taler-harness/src/integrationtests/test-payment-expired.ts
new file mode 100644
index 000000000..a837b18fa
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-expired.ts
@@ -0,0 +1,132 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ ConfirmPayResultType,
+ Duration,
+ MerchantApiClient,
+ MerchantContractTerms,
+ PreparePayResultType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ applyTimeTravelV2,
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run a test for the following scenario:
+ *
+ * - Wallet claims an order
+ * - Merchant goes down
+ * - Wallet tried to pay, but it fails as the merchant is unavailable
+ * - The order expires
+ * - The merchant goes back up again
+ * - Instead of trying to get an abort-refund, the wallet notices that
+ * the order is expired, puts the transaction into "failed",
+ * refreshes allocated coins and thus raises the balance again.
+ */
+export async function runPaymentExpiredTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Order that can only be paid within five minutes.
+ const order: Partial<MerchantContractTerms> = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ minutes: 5 }),
+ ),
+ ),
+ };
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const orderResp = await merchantClient.createOrder({
+ order,
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.status,
+ PreparePayResultType.PaymentPossible,
+ );
+
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ hours: 1 })),
+ { walletClient, exchange, merchant },
+ );
+
+ const confirmPayResult = await walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ { transactionId: preparePayResult.transactionId },
+ );
+ console.log("confirm pay result:");
+ console.log(j2s(confirmPayResult));
+ t.assertDeepEqual(confirmPayResult.type, ConfirmPayResultType.Pending);
+ await walletClient.call(WalletApiOperation.AbortTransaction, {
+ transactionId: preparePayResult.transactionId,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txns = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ includeRefreshes: true,
+ });
+ console.log(j2s(txns));
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(bal));
+
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:18.93");
+}
+
+runPaymentExpiredTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts b/packages/taler-harness/src/integrationtests/test-payment-fault.ts
index 2be01d919..cadcc9056 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts
+++ b/packages/taler-harness/src/integrationtests/test-payment-fault.ts
@@ -21,25 +21,26 @@
/**
* Imports.
*/
+import { ConfirmPayResultType, MerchantApiClient } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ FaultInjectedExchangeService,
+ FaultInjectionRequestContext,
+ FaultInjectionResponseContext,
+} from "../harness/faultInjection.js";
import {
+ BankService,
+ ExchangeService,
GlobalTestState,
MerchantService,
- ExchangeService,
+ generateRandomPayto,
setupDb,
- BankService,
- WalletCli,
- MerchantPrivateApi,
- BankApi,
- BankAccessApi,
} from "../harness/harness.js";
import {
- FaultInjectedExchangeService,
- FaultInjectionRequestContext,
- FaultInjectionResponseContext,
-} from "../harness/faultInjection";
-import { CoreApiResponse } from "@gnu-taler/taler-util";
-import { defaultCoinConfig } from "../harness/denomStructures";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
/**
* Run test for basic, bank-integrated withdrawal.
@@ -64,11 +65,20 @@ export async function runPaymentFaultTest(t: GlobalTestState) {
});
const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
+ "myexchange",
"x",
);
- bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+ const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
+ // Base URL must contain port that the proxy is listening on.
+ await exchange.modifyConfig(async (config) => {
+ config.setString("exchange", "base_url", "http://localhost:8091/");
+ });
+
+ bank.setSuggestedExchange(
+ faultyExchange,
+ exchangeBankAccount.accountPaytoUri,
+ );
await bank.start();
@@ -80,8 +90,6 @@ export async function runPaymentFaultTest(t: GlobalTestState) {
await exchange.start();
await exchange.pingUntilAvailable();
- const faultyExchange = new FaultInjectedExchangeService(t, exchange, 8091);
-
// Print all requests to the exchange
faultyExchange.faultProxy.addFault({
async modifyRequest(ctx: FaultInjectionRequestContext) {
@@ -104,52 +112,34 @@ export async function runPaymentFaultTest(t: GlobalTestState) {
await merchant.start();
await merchant.pingUntilAvailable();
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
});
- console.log("setup done!");
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
- const wallet = new WalletCli(t);
-
- // Create withdrawal operation
-
- const user = await BankApi.createRandomBankUser(bank);
- const wop = await BankAccessApi.createWithdrawalOperation(
- bank,
- user,
- "TESTKUDOS:20",
- );
-
- // Hand it to the wallet
+ console.log("setup done!");
- await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
- talerWithdrawUri: wop.taler_withdraw_uri,
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
});
- await wallet.runPending();
-
- // Confirm it
-
- await BankApi.confirmWithdrawalOperation(bank, user, wop);
+ await walletClient.call(WalletApiOperation.GetBalances, {});
- // Withdraw
-
- await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, {
- exchangeBaseUrl: faultyExchange.baseUrl,
- talerWithdrawUri: wop.taler_withdraw_uri,
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
});
- await wallet.runUntilDone();
- // Check balance
-
- await wallet.client.call(WalletApiOperation.GetBalances, {});
+ await wres.withdrawalFinishedCond;
// Set up order.
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -157,7 +147,7 @@ export async function runPaymentFaultTest(t: GlobalTestState) {
},
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -165,24 +155,22 @@ export async function runPaymentFaultTest(t: GlobalTestState) {
// Make wallet pay for the order
- let apiResp: CoreApiResponse;
-
- const prepResp = await wallet.client.call(
+ const prepResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: orderStatus.taler_pay_uri,
},
);
- const proposalId = prepResp.proposalId;
-
- await wallet.runPending();
-
// Drop 3 responses from the exchange.
let faultCount = 0;
faultyExchange.faultProxy.addFault({
async modifyResponse(ctx: FaultInjectionResponseContext) {
- if (!ctx.request.requestUrl.endsWith("/deposit")) {
+ console.log(`in modifyResponse for ${ctx.request.requestUrl}`);
+ if (
+ !ctx.request.requestUrl.endsWith("/deposit") &&
+ !ctx.request.requestUrl.endsWith("/batch-deposit")
+ ) {
return;
}
if (faultCount < 3) {
@@ -197,16 +185,20 @@ export async function runPaymentFaultTest(t: GlobalTestState) {
// confirmPay won't work, as the exchange is unreachable
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
- // FIXME: should be validated, don't cast!
- proposalId: proposalId,
- });
+ const confirmPayResp = await walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ transactionId: prepResp.transactionId,
+ },
+ );
+
+ t.assertDeepEqual(confirmPayResp.type, ConfirmPayResultType.Pending);
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
// Check if payment was successful.
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-forgettable.ts b/packages/taler-harness/src/integrationtests/test-payment-forgettable.ts
index 3bdd6bef3..21d76397d 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-forgettable.ts
+++ b/packages/taler-harness/src/integrationtests/test-payment-forgettable.ts
@@ -17,11 +17,12 @@
/**
* Imports.
*/
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { GlobalTestState } from "../harness/harness.js";
import {
- createSimpleTestkudosEnvironment,
- withdrawViaBank,
- makeTestPayment,
+ createSimpleTestkudosEnvironmentV2,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
} from "../harness/helpers.js";
/**
@@ -30,16 +31,19 @@ import {
export async function runPaymentForgettableTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
{
const order = {
@@ -54,7 +58,7 @@ export async function runPaymentForgettableTest(t: GlobalTestState) {
},
};
- await makeTestPayment(t, { wallet, merchant, order });
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
}
console.log("testing with forgettable field without hash");
@@ -72,10 +76,10 @@ export async function runPaymentForgettableTest(t: GlobalTestState) {
},
};
- await makeTestPayment(t, { wallet, merchant, order });
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
}
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
}
runPaymentForgettableTest.suites = ["wallet", "merchant"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts b/packages/taler-harness/src/integrationtests/test-payment-idempotency.ts
index 9378465a0..65fd3a562 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-idempotency.ts
+++ b/packages/taler-harness/src/integrationtests/test-payment-idempotency.ts
@@ -17,10 +17,13 @@
/**
* Imports.
*/
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
-import { PreparePayResultType } from "@gnu-taler/taler-util";
+import { MerchantApiClient, PreparePayResultType } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
/**
* Test the wallet-core payment API, especially that repeated operations
@@ -29,20 +32,25 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
export async function runPaymentIdempotencyTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
bank,
exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ amount: "TESTKUDOS:20",
+ });
- // Withdraw digital cash into the wallet.
+ await wres.withdrawalFinishedCond;
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
// Set up order.
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -50,7 +58,7 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) {
},
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -60,14 +68,14 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) {
// Make wallet pay for the order
- const preparePayResult = await wallet.client.call(
+ const preparePayResult = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: orderStatus.taler_pay_uri,
},
);
- const preparePayResultRep = await wallet.client.call(
+ const preparePayResultRep = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: orderStatus.taler_pay_uri,
@@ -83,26 +91,34 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) {
const proposalId = preparePayResult.proposalId;
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
- // FIXME: should be validated, don't cast!
- proposalId: proposalId,
- });
+ const confirmPayResult = await walletClient.call(
+ WalletApiOperation.ConfirmPay,
+ {
+ proposalId: proposalId,
+ },
+ );
+
+ console.log("confirm pay result", confirmPayResult);
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
// Check if payment was successful.
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
t.assertTrue(orderStatus.order_status === "paid");
- const preparePayResultAfter = await wallet.client.call(
+ const preparePayResultAfter = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri,
},
);
+ console.log("result after:", preparePayResultAfter);
+
t.assertTrue(
preparePayResultAfter.status === PreparePayResultType.AlreadyConfirmed,
);
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts b/packages/taler-harness/src/integrationtests/test-payment-multiple.ts
index 754c3a0e8..0caa3c3e7 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts
+++ b/packages/taler-harness/src/integrationtests/test-payment-multiple.ts
@@ -17,22 +17,23 @@
/**
* Imports.
*/
+import { MerchantApiClient } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { coin_ct10, coin_u1 } from "../harness/denomStructures.js";
import {
- GlobalTestState,
- setupDb,
BankService,
ExchangeService,
+ GlobalTestState,
MerchantService,
- WalletCli,
- MerchantPrivateApi,
+ generateRandomPayto,
+ setupDb,
} from "../harness/harness.js";
-import { withdrawViaBank } from "../harness/helpers.js";
-import { coin_ct10, coin_u1 } from "../harness/denomStructures";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
-async function setupTest(
- t: GlobalTestState,
-): Promise<{
+async function setupTest(t: GlobalTestState): Promise<{
merchant: MerchantService;
exchange: ExchangeService;
bank: BankService;
@@ -54,7 +55,7 @@ async function setupTest(
});
const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
+ "myexchange",
"x",
);
@@ -83,16 +84,16 @@ async function setupTest(
await merchant.start();
await merchant.pingUntilAvailable();
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
});
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
- paytoUris: ["payto://x-taler-bank/minst1"],
+ paytoUris: [generateRandomPayto("minst1")],
});
console.log("setup done!");
@@ -114,15 +115,26 @@ export async function runPaymentMultipleTest(t: GlobalTestState) {
const { merchant, bank, exchange } = await setupTest(t);
- const wallet = new WalletCli(t);
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:100" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:100",
+ });
+
+ await wres.withdrawalFinishedCond;
// Set up order.
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:80",
@@ -130,7 +142,7 @@ export async function runPaymentMultipleTest(t: GlobalTestState) {
},
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -138,18 +150,17 @@ export async function runPaymentMultipleTest(t: GlobalTestState) {
// Make wallet pay for the order
- const r1 = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, {
talerPayUri: orderStatus.taler_pay_uri,
});
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
- // FIXME: should be validated, don't cast!
- proposalId: r1.proposalId,
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: r1.transactionId,
});
// Check if payment was successful.
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
diff --git a/packages/taler-harness/src/integrationtests/test-payment-share.ts b/packages/taler-harness/src/integrationtests/test-payment-share.ts
new file mode 100644
index 000000000..141faa81e
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-share.ts
@@ -0,0 +1,308 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ ConfirmPayResultType,
+ MerchantApiClient,
+ NotificationType,
+ PreparePayResultType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPaymentShareTest(t: GlobalTestState) {
+ // Set up test environment
+ const {
+ walletClient: firstWallet,
+ bank,
+ exchange,
+ merchant,
+ } = await createSimpleTestkudosEnvironmentV2(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Withdraw digital cash into the wallet.
+ await withdrawViaBankV2(t, {
+ walletClient: firstWallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await firstWallet.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const { walletClient: secondWallet } = await createWalletDaemonWithClient(t, {
+ name: "wallet2",
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient: secondWallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await secondWallet.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ {
+ const first = await firstWallet.call(WalletApiOperation.GetBalances, {});
+ const second = await secondWallet.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals(first.balances[0].available, "TESTKUDOS:19.53");
+ t.assertAmountEquals(second.balances[0].available, "TESTKUDOS:19.53");
+ }
+
+ t.logStep("setup-done");
+
+ // create two orders to pay
+ async function createOrder(amount: string) {
+ const order = {
+ summary: "Buy me!",
+ amount,
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ const args = { order };
+
+ const orderResp = await merchantClient.createOrder({
+ order: args.order,
+ });
+
+ const orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+ return { id: orderResp.order_id, uri: orderStatus.taler_pay_uri };
+ }
+
+ t.logStep("orders-created");
+
+ /**
+ * Case 1:
+ * - Claim with first wallet and pay in the second wallet.
+ * - First wallet should be notified.
+ */
+ {
+ const order = await createOrder("TESTKUDOS:5");
+ // Claim the order with the first wallet
+ const claimFirstWallet = await firstWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: order.uri },
+ );
+
+ t.assertTrue(
+ claimFirstWallet.status === PreparePayResultType.PaymentPossible,
+ );
+
+ t.logStep("w1-payment-possible");
+
+ // share order from the first wallet
+ const { privatePayUri } = await firstWallet.call(
+ WalletApiOperation.SharePayment,
+ {
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ orderId: order.id,
+ },
+ );
+
+ t.logStep("w1-payment-shared");
+
+ // claim from the second wallet
+ const claimSecondWallet = await secondWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: privatePayUri },
+ );
+
+ t.assertTrue(
+ claimSecondWallet.status === PreparePayResultType.PaymentPossible,
+ );
+
+ t.logStep("w2-claimed");
+
+ // pay from the second wallet
+ const r2 = await secondWallet.call(WalletApiOperation.ConfirmPay, {
+ transactionId: claimSecondWallet.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ t.logStep("w2-confirmed");
+
+ // Wait for refresh to settle before we do checks
+ await secondWallet.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ t.logStep("w2-refresh-settled");
+
+ {
+ const first = await firstWallet.call(WalletApiOperation.GetBalances, {});
+ const second = await secondWallet.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ t.assertAmountEquals(first.balances[0].available, "TESTKUDOS:19.53");
+ t.assertAmountEquals(second.balances[0].available, "TESTKUDOS:14.23");
+ }
+
+ t.logStep("wait-for-payment");
+ // firstWallet.waitForNotificationCond(n =>
+ // n.type === NotificationType.TransactionStateTransition &&
+ // n.transactionId === claimFirstWallet.transactionId
+ // )
+ // Claim the order with the first wallet
+ const claimFirstWalletAgain = await firstWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: order.uri },
+ );
+
+ t.assertTrue(
+ claimFirstWalletAgain.status === PreparePayResultType.AlreadyConfirmed,
+ );
+ t.assertTrue( claimFirstWalletAgain.paid );
+
+ t.logStep("w1-prepared-again");
+
+ const r1 = await firstWallet.call(WalletApiOperation.ConfirmPay, {
+ transactionId: claimFirstWallet.transactionId,
+ });
+
+ //t.assertTrue(r1.type === ConfirmPayResultType.Pending);
+
+ t.logStep("w1-confirmed-shared");
+
+ await firstWallet.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ await secondWallet.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ /**
+ * only the second wallet balance was affected
+ */
+ {
+ const first = await firstWallet.call(WalletApiOperation.GetBalances, {});
+ const second = await secondWallet.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ t.assertAmountEquals(first.balances[0].available, "TESTKUDOS:19.53");
+ t.assertAmountEquals(second.balances[0].available, "TESTKUDOS:14.23");
+ }
+ }
+
+ t.logStep("first-case-done");
+
+ /**
+ * Case 2:
+ * - Claim with first wallet and share with the second wallet
+ * - Pay with the first wallet, second wallet should be notified
+ */
+ {
+ const order = await createOrder("TESTKUDOS:3");
+ // Claim the order with the first wallet
+ const claimFirstWallet = await firstWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: order.uri },
+ );
+
+ t.assertTrue(
+ claimFirstWallet.status === PreparePayResultType.PaymentPossible,
+ );
+
+ t.logStep("case2-w1-claimed");
+
+ // share order from the first wallet
+ const { privatePayUri } = await firstWallet.call(
+ WalletApiOperation.SharePayment,
+ {
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ orderId: order.id,
+ },
+ );
+
+ t.logStep("case2-w1-shared");
+
+ // claim from the second wallet
+ const claimSecondWallet = await secondWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: privatePayUri },
+ );
+
+ t.logStep("case2-w2-prepared");
+
+ t.assertTrue(
+ claimSecondWallet.status === PreparePayResultType.PaymentPossible,
+ );
+
+ // pay from the first wallet
+ const r2 = await firstWallet.call(WalletApiOperation.ConfirmPay, {
+ transactionId: claimFirstWallet.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ // Wait for refreshes to settle before doing checks
+ await firstWallet.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ /**
+ * only the first wallet balance was affected
+ */
+ const bal1 = await firstWallet.call(WalletApiOperation.GetBalances, {});
+ const bal2 = await secondWallet.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals(bal1.balances[0].available, "TESTKUDOS:16.18");
+ t.assertAmountEquals(bal2.balances[0].available, "TESTKUDOS:14.23");
+
+ t.logStep("wait-for-payment");
+ // secondWallet.waitForNotificationCond(n =>
+ // n.type === NotificationType.TransactionStateTransition &&
+ // n.transactionId === claimSecondWallet.transactionId
+ // )
+
+ // Claim the order with the first wallet
+ const claimSecondWalletAgain = await secondWallet.call(
+ WalletApiOperation.PreparePayForUri,
+ { talerPayUri: order.uri },
+ );
+
+ t.assertTrue(
+ claimSecondWalletAgain.status === PreparePayResultType.AlreadyConfirmed,
+ );
+ t.assertTrue(
+ claimSecondWalletAgain.paid,
+ );
+
+ }
+
+ t.logStep("second-case-done");
+}
+
+runPaymentShareTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-payment-template.ts b/packages/taler-harness/src/integrationtests/test-payment-template.ts
new file mode 100644
index 000000000..c3ab5ffc8
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment-template.ts
@@ -0,0 +1,105 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ ConfirmPayResultType,
+ Duration,
+ MerchantApiClient,
+ PreparePayResultType,
+ narrowOpSuccessOrThrow,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Test for taler://payment-template/ URIs
+ */
+export async function runPaymentTemplateTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const createTemplateRes = await merchantClient.createTemplate({
+ template_id: "template1",
+ template_description: "my test template",
+ template_contract: {
+ minimum_age: 0,
+ pay_duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({
+ minutes: 2,
+ }),
+ ),
+ summary: "hello, I'm a summary",
+ },
+ });
+ narrowOpSuccessOrThrow("createTemplate", createTemplateRes);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+ await wres.withdrawalFinishedCond;
+
+ // Request a template payment
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForTemplate,
+ {
+ talerPayTemplateUri: `taler+http://pay-template/localhost:${merchant.port}/template1?amount=TESTKUDOS:1`,
+ templateParams: {},
+ },
+ );
+
+ console.log(preparePayResult);
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ // Pay for it
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertTrue(r2.type === ConfirmPayResultType.Done);
+
+ // Check if payment was successful.
+
+ const orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: preparePayResult.contractTerms.order_id,
+ instance: "default",
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runPaymentTemplateTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-transient.ts b/packages/taler-harness/src/integrationtests/test-payment-transient.ts
index 75d44d495..1911b5e92 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-transient.ts
+++ b/packages/taler-harness/src/integrationtests/test-payment-transient.ts
@@ -17,25 +17,22 @@
/**
* Imports.
*/
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
import {
- withdrawViaBank,
- createFaultInjectedMerchantTestkudosEnvironment,
-} from "../harness/helpers.js";
-import axios from "axios";
-import {
- FaultInjectionRequestContext,
- FaultInjectionResponseContext,
-} from "../harness/faultInjection";
-import {
- codecForMerchantOrderStatusUnpaid,
ConfirmPayResultType,
+ MerchantApiClient,
PreparePayResultType,
TalerErrorCode,
- TalerErrorDetails,
+ TalerErrorDetail,
URL,
+ codecForMerchantOrderStatusUnpaid,
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { FaultInjectionResponseContext } from "../harness/faultInjection.js";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import {
+ createFaultInjectedMerchantTestkudosEnvironment,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
/**
* Run test for a payment where the merchant has a transient
@@ -44,20 +41,25 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
export async function runPaymentTransientTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- faultyMerchant,
- } = await createFaultInjectedMerchantTestkudosEnvironment(t);
+ const { walletClient, bank, faultyMerchant, faultyExchange } =
+ await createFaultInjectedMerchantTestkudosEnvironment(t);
+
+ const merchantClient = new MerchantApiClient(
+ faultyMerchant.makeInstanceBaseUrl(),
+ );
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: faultyExchange,
+ amount: "TESTKUDOS:20",
+ });
- const merchant = faultyMerchant;
+ await wres.withdrawalFinishedCond;
- let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ let orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -66,7 +68,7 @@ export async function runPaymentTransientTest(t: GlobalTestState) {
},
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
sessionId: "mysession-one",
});
@@ -76,25 +78,21 @@ export async function runPaymentTransientTest(t: GlobalTestState) {
t.assertTrue(orderStatus.already_paid_order_id === undefined);
let publicOrderStatusUrl = orderStatus.order_status_url;
- let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
- validateStatus: () => true,
- });
+ let publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
if (publicOrderStatusResp.status != 402) {
-
-
throw Error(
`expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
);
}
let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ publicOrderStatusResp.json(),
);
console.log(pubUnpaidStatus);
- let preparePayResp = await wallet.client.call(
+ let preparePayResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: pubUnpaidStatus.taler_pay_uri,
@@ -105,9 +103,7 @@ export async function runPaymentTransientTest(t: GlobalTestState) {
const proposalId = preparePayResp.proposalId;
- publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
- validateStatus: () => true,
- });
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
if (publicOrderStatusResp.status != 402) {
throw Error(
@@ -116,7 +112,7 @@ export async function runPaymentTransientTest(t: GlobalTestState) {
}
pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ publicOrderStatusResp.json(),
);
let faultInjected = false;
@@ -135,18 +131,16 @@ export async function runPaymentTransientTest(t: GlobalTestState) {
}
faultInjected = true;
console.log("injecting pay fault");
- const err: TalerErrorDetails = {
+ const err: TalerErrorDetail = {
code: TalerErrorCode.GENERIC_DB_COMMIT_FAILED,
- details: {},
- hint: "huh",
- message: "something went wrong",
+ hint: "something went wrong",
};
ctx.responseBody = Buffer.from(JSON.stringify(err));
ctx.statusCode = 500;
},
});
- const confirmPayResp = await wallet.client.call(
+ const confirmPayResp = await walletClient.call(
WalletApiOperation.ConfirmPay,
{
proposalId,
@@ -158,7 +152,7 @@ export async function runPaymentTransientTest(t: GlobalTestState) {
t.assertTrue(confirmPayResp.type === ConfirmPayResultType.Pending);
t.assertTrue(faultInjected);
- const confirmPayRespTwo = await wallet.client.call(
+ const confirmPayRespTwo = await walletClient.call(
WalletApiOperation.ConfirmPay,
{
proposalId,
@@ -170,14 +164,12 @@ export async function runPaymentTransientTest(t: GlobalTestState) {
// Now ask the merchant if paid
console.log("requesting", publicOrderStatusUrl);
- publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
- validateStatus: () => true,
- });
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl);
- console.log(publicOrderStatusResp.data);
+ console.log(publicOrderStatusResp.json());
if (publicOrderStatusResp.status != 200) {
- console.log(publicOrderStatusResp.data);
+ console.log(publicOrderStatusResp.json());
throw Error(
`expected status 200 (after paying), but got ${publicOrderStatusResp.status}`,
);
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-zero.ts b/packages/taler-harness/src/integrationtests/test-payment-zero.ts
index c38b8b382..7423751a5 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-zero.ts
+++ b/packages/taler-harness/src/integrationtests/test-payment-zero.ts
@@ -20,10 +20,11 @@
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { GlobalTestState } from "../harness/harness.js";
import {
- createSimpleTestkudosEnvironment,
- withdrawViaBank,
- makeTestPayment,
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+ makeTestPaymentV2,
} from "../harness/helpers.js";
+import { TransactionMajorState } from "@gnu-taler/taler-util";
/**
* Run test for a payment for a "free" order with
@@ -32,23 +33,19 @@ import {
export async function runPaymentZeroTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
// First, make a "free" payment when we don't even have
// any money in the
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ await withdrawViaBankV2(t, { walletClient, bank, exchange, amount: "TESTKUDOS:20" });
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
- await makeTestPayment(t, {
- wallet,
+ await makeTestPaymentV2(t, {
+ walletClient,
merchant,
order: {
summary: "I am free!",
@@ -57,15 +54,15 @@ export async function runPaymentZeroTest(t: GlobalTestState) {
},
});
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
- const transactions = await wallet.client.call(
+ const transactions = await walletClient.call(
WalletApiOperation.GetTransactions,
{},
);
for (const tr of transactions.transactions) {
- t.assertDeepEqual(tr.pending, false);
+ t.assertDeepEqual(tr.txState.major, TransactionMajorState.Done);
}
}
diff --git a/packages/taler-harness/src/integrationtests/test-payment.ts b/packages/taler-harness/src/integrationtests/test-payment.ts
new file mode 100644
index 000000000..9d1ce0e22
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-payment.ts
@@ -0,0 +1,82 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+ makeTestPaymentV2,
+} from "../harness/helpers.js";
+import { j2s } from "@gnu-taler/taler-util";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPaymentTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV2(t, { walletClient, bank, exchange, amount: "TESTKUDOS:20" });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Test JSON normalization of contract terms: Does the wallet
+ // agree with the merchant?
+ const order2 = {
+ summary: "Testing “unicode” characters: 😁😱😇🥺🫦",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order: order2 });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Test JSON normalization of contract terms: Does the wallet
+ // agree with the merchant?
+ const order3 = {
+ summary: "Testing\nNewlines\rAnd\tStuff\nHere\b",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order: order3 });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(`balance after 3 payments: ${j2s(bal)}`);
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:3.8");
+ t.assertAmountEquals(bal.balances[0].pendingIncoming, "TESTKUDOS:0");
+ t.assertAmountEquals(bal.balances[0].pendingOutgoing, "TESTKUDOS:0");
+}
+
+runPaymentTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-paywall-flow.ts b/packages/taler-harness/src/integrationtests/test-paywall-flow.ts
index a8e3b3e95..247ec9cad 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-paywall-flow.ts
+++ b/packages/taler-harness/src/integrationtests/test-paywall-flow.ts
@@ -17,16 +17,19 @@
/**
* Imports.
*/
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
import {
- PreparePayResultType,
- codecForMerchantOrderStatusUnpaid,
ConfirmPayResultType,
+ MerchantApiClient,
+ PreparePayResultType,
URL,
+ codecForMerchantOrderStatusUnpaid,
} from "@gnu-taler/taler-util";
-import axios from "axios";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
/**
* Run test for basic, bank-integrated withdrawal.
@@ -34,16 +37,21 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
export async function runPaywallFlowTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
/**
* =========================================================================
@@ -54,7 +62,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
* =========================================================================
*/
- let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ let orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -65,7 +73,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
const firstOrderId = orderResp.order_id;
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
sessionId: "mysession-one",
});
@@ -77,9 +85,9 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
t.assertTrue(orderStatus.already_paid_order_id === undefined);
let publicOrderStatusUrl = new URL(orderStatus.order_status_url);
- let publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
+ let publicOrderStatusResp = await harnessHttpLib.fetch(
+ publicOrderStatusUrl.href,
+ );
if (publicOrderStatusResp.status != 402) {
throw Error(
@@ -88,12 +96,12 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
}
let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ publicOrderStatusResp.json(),
);
console.log(pubUnpaidStatus);
- let preparePayResp = await wallet.client.call(
+ let preparePayResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: pubUnpaidStatus.taler_pay_uri,
@@ -105,10 +113,8 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
const proposalId = preparePayResp.proposalId;
console.log("requesting", publicOrderStatusUrl.href);
- publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
- console.log("response body", publicOrderStatusResp.data);
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl.href);
+ console.log("response body", publicOrderStatusResp.json());
if (publicOrderStatusResp.status != 402) {
throw Error(
`expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
@@ -116,26 +122,20 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
}
pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ publicOrderStatusResp.json(),
);
- const confirmPayRes = await wallet.client.call(
- WalletApiOperation.ConfirmPay,
- {
- proposalId: proposalId,
- },
- );
+ const confirmPayRes = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ proposalId: proposalId,
+ });
t.assertTrue(confirmPayRes.type === ConfirmPayResultType.Done);
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl.href);
- publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
-
- console.log(publicOrderStatusResp.data);
+ console.log(publicOrderStatusResp.json());
if (publicOrderStatusResp.status != 200) {
- console.log(publicOrderStatusResp.data);
+ console.log(publicOrderStatusResp.json());
throw Error(
`expected status 200 (after paying), but got ${publicOrderStatusResp.status}`,
);
@@ -147,7 +147,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
* =========================================================================
*/
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
sessionId: "mysession-two",
});
@@ -158,7 +158,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
// Pay with new taler://pay URI, which should
// have the new session ID!
// Wallet should now automatically re-play payment.
- preparePayResp = await wallet.client.call(
+ preparePayResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: talerPayUriOne,
@@ -174,7 +174,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
* =========================================================================
*/
- orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
@@ -186,7 +186,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
const secondOrderId = orderResp.order_id;
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: secondOrderId,
sessionId: "mysession-three",
});
@@ -199,7 +199,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
// Here the re-purchase detection should kick in,
// and the wallet should re-pay for the old order
// under the new session ID (mysession-three).
- preparePayResp = await wallet.client.call(
+ preparePayResp = await walletClient.call(
WalletApiOperation.PreparePayForUri,
{
talerPayUri: orderStatus.taler_pay_uri,
@@ -211,7 +211,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
// The first order should now be paid under "mysession-three",
// as the wallet did re-purchase detection
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: firstOrderId,
sessionId: "mysession-three",
});
@@ -220,7 +220,7 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
// Check that with a completely new session ID, the status would NOT
// be paid.
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: firstOrderId,
sessionId: "mysession-four",
});
@@ -232,19 +232,17 @@ export async function runPaywallFlowTest(t: GlobalTestState) {
console.log("requesting public status", publicOrderStatusUrl);
// Ask the order status of the claimed-but-unpaid order
- publicOrderStatusResp = await axios.get(publicOrderStatusUrl.href, {
- validateStatus: () => true,
- });
+ publicOrderStatusResp = await harnessHttpLib.fetch(publicOrderStatusUrl.href);
if (publicOrderStatusResp.status != 402) {
throw Error(`expected status 402, but got ${publicOrderStatusResp.status}`);
}
pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
- publicOrderStatusResp.data,
+ publicOrderStatusResp.json(),
);
- console.log(publicOrderStatusResp.data);
+ console.log(publicOrderStatusResp.json());
t.assertTrue(pubUnpaidStatus.already_paid_order_id === firstOrderId);
}
diff --git a/packages/taler-harness/src/integrationtests/test-peer-repair.ts b/packages/taler-harness/src/integrationtests/test-peer-repair.ts
new file mode 100644
index 000000000..8bc7ed0a1
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-repair.ts
@@ -0,0 +1,213 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import * as fs from "node:fs";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+export async function runPeerRepairTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t);
+
+ let allW1Notifications: WalletNotification[] = [];
+ let allW2Notifications: WalletNotification[] = [];
+
+ let w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ handleNotification(wn) {
+ allW2Notifications.push(wn);
+ },
+ });
+
+ // Withdraw digital cash into the wallet.
+ let wallet1 = w1.walletClient;
+ const wallet2 = w2.walletClient;
+
+ const withdrawalDoneCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId.startsWith("txn:withdrawal:"),
+ );
+
+ await withdrawViaBankV2(t, {
+ walletClient: wallet1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:5",
+ });
+
+ await withdrawalDoneCond;
+ const w1DbPath = w1.walletService.dbPath;
+ const w1DbCopyPath = w1.walletService.dbPath + ".copy";
+ fs.copyFileSync(w1DbPath, w1DbCopyPath);
+
+ const purse_expiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const resp1 = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:3" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ const peerPushDebitReady1Cond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp1.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPushDebitReady1Cond;
+
+ const txDetails = await wallet1.call(WalletApiOperation.GetTransactionById, {
+ transactionId: resp1.transactionId,
+ });
+ t.assertDeepEqual(txDetails.type, TransactionType.PeerPushDebit);
+ t.assertTrue(!!txDetails.talerUri);
+
+ const resp2 = await wallet2.client.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ {
+ talerUri: txDetails.talerUri,
+ },
+ );
+
+ const peerPushCreditDone1Cond = wallet2.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ const peerPushDebitDone1Cond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp1.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ await wallet2.client.call(WalletApiOperation.ConfirmPeerPushCredit, {
+ transactionId: resp2.transactionId,
+ });
+
+ await peerPushCreditDone1Cond;
+ await peerPushDebitDone1Cond;
+
+ w1.walletClient.remoteWallet?.close();
+ await w1.walletService.stop();
+
+ fs.copyFileSync(w1DbCopyPath, w1DbPath);
+
+ console.log(`copied back to ${w1DbPath}`);
+
+ w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ wallet1 = w1.walletClient;
+
+ console.log("attempting peer-push-debit, should fail.");
+
+ const initResp2 = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:3" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ const peerPushDebitFailingCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === initResp2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.errorInfo != null,
+ );
+
+ console.log(`waiting for error on ${initResp2.transactionId}`);
+
+ await peerPushDebitFailingCond;
+
+ console.log("reached error");
+
+ // Now withdraw so we have enough coins to re-select
+
+ const withdraw2Res = await withdrawViaBankV2(t, {
+ walletClient: wallet1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:5",
+ });
+
+ await withdraw2Res.withdrawalFinishedCond;
+
+ const peerPushDebitReady2Cond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === initResp2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPushDebitReady2Cond;
+}
+
+runPeerRepairTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts
new file mode 100644
index 000000000..e1565f295
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-to-peer-pull.ts
@@ -0,0 +1,285 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ j2s,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ BankServiceHandle,
+ ExchangeService,
+ GlobalTestState,
+ WalletClient,
+} from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runPeerToPeerPullTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t);
+
+ let allW1Notifications: WalletNotification[] = [];
+ let allW2Notifications: WalletNotification[] = [];
+
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ persistent: true,
+ handleNotification(wn) {
+ allW2Notifications.push(wn);
+ },
+ });
+
+ // Withdraw digital cash into the wallet.
+ const wallet1 = w1.walletClient;
+ const wallet2 = w2.walletClient;
+
+ await checkNormalPeerPull(t, bank, exchange, wallet1, wallet2);
+
+ console.log(`w1 notifications: ${j2s(allW1Notifications)}`);
+
+ // Check that we don't have an excessive number of notifications.
+ t.assertTrue(allW1Notifications.length <= 60);
+
+ await checkAbortedPeerPull(t, bank, exchange, wallet1, wallet2);
+}
+
+async function checkNormalPeerPull(
+ t: GlobalTestState,
+ bank: BankServiceHandle,
+ exchange: ExchangeService,
+ wallet1: WalletClient,
+ wallet2: WalletClient,
+): Promise<void> {
+ const withdrawRes = await withdrawViaBankV2(t, {
+ walletClient: wallet2,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawRes.withdrawalFinishedCond;
+
+ const purseExpiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const resp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+
+ const peerPullCreditReadyCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPullCreditReadyCond;
+
+ const creditTx = await wallet1.call(WalletApiOperation.GetTransactionById, {
+ transactionId: resp.transactionId,
+ });
+
+ t.assertDeepEqual(creditTx.type, TransactionType.PeerPullCredit);
+ t.assertTrue(!!creditTx.talerUri);
+
+ const checkResp = await wallet2.client.call(
+ WalletApiOperation.PreparePeerPullDebit,
+ {
+ talerUri: creditTx.talerUri,
+ },
+ );
+
+ console.log(`checkResp: ${j2s(checkResp)}`);
+
+ const peerPullCreditDoneCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ const peerPullDebitDoneCond = wallet2.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === checkResp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+
+ await wallet2.client.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ await peerPullCreditDoneCond;
+ await peerPullDebitDoneCond;
+
+ const txn1 = await wallet1.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ const txn2 = await wallet2.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log(`txn1: ${j2s(txn1)}`);
+ console.log(`txn2: ${j2s(txn2)}`);
+}
+
+async function checkAbortedPeerPull(
+ t: GlobalTestState,
+ bank: BankServiceHandle,
+ exchange: ExchangeService,
+ wallet1: WalletClient,
+ wallet2: WalletClient,
+): Promise<void> {
+ const withdrawRes = await withdrawViaBankV2(t, {
+ walletClient: wallet2,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawRes.withdrawalFinishedCond;
+
+ const purseExpiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const resp = await wallet1.client.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+
+ const peerPullCreditReadyCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPullCreditReadyCond;
+
+ const creditTx = await wallet1.call(WalletApiOperation.GetTransactionById, {
+ transactionId: resp.transactionId,
+ });
+
+ t.assertDeepEqual(creditTx.type, TransactionType.PeerPullCredit);
+ t.assertTrue(!!creditTx.talerUri);
+
+ const checkResp = await wallet2.client.call(
+ WalletApiOperation.PreparePeerPullDebit,
+ {
+ talerUri: creditTx.talerUri,
+ },
+ );
+
+ console.log(`checkResp: ${j2s(checkResp)}`);
+
+ const peerPullCreditAbortedCond = wallet1.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === resp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Aborted,
+ );
+
+ const peerPullDebitAbortedCond = wallet2.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === checkResp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Aborted,
+ );
+
+ await wallet1.call(WalletApiOperation.AbortTransaction, {
+ transactionId: resp.transactionId,
+ });
+
+ await wallet2.client.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ console.log(`waiting for ${resp.transactionId} to go to state aborted`);
+ console.log("checkpoint: before-aborted-wait");
+ await peerPullCreditAbortedCond;
+ console.log("checkpoint: after-credit-aborted-wait");
+ await peerPullDebitAbortedCond;
+ console.log("checkpoint: after-debit-aborted-wait");
+ console.log("checkpoint: after-aborted-wait");
+
+ const txn1 = await wallet1.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ const txn2 = await wallet2.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log(`txn1: ${j2s(txn1)}`);
+ console.log(`txn2: ${j2s(txn2)}`);
+}
+
+runPeerToPeerPullTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-peer-to-peer-push.ts b/packages/taler-harness/src/integrationtests/test-peer-to-peer-push.ts
new file mode 100644
index 000000000..21e0d384a
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-peer-to-peer-push.ts
@@ -0,0 +1,264 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WalletNotification,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run a test for basic peer-push payments.
+ */
+export async function runPeerToPeerPushTest(t: GlobalTestState) {
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t);
+
+ let allW1Notifications: WalletNotification[] = [];
+ let allW2Notifications: WalletNotification[] = [];
+
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ handleNotification(wn) {
+ allW1Notifications.push(wn);
+ },
+ });
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ handleNotification(wn) {
+ allW2Notifications.push(wn);
+ },
+ });
+
+ // Withdraw digital cash into the wallet.
+
+ const withdrawRes = await withdrawViaBankV2(t, {
+ walletClient: w1.walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await withdrawRes.withdrawalFinishedCond;
+
+ const purse_expiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const checkResp0 = await w1.walletClient.call(
+ WalletApiOperation.CheckPeerPushDebit,
+ {
+ amount: "TESTKUDOS:5" as AmountString,
+ },
+ );
+
+ t.assertAmountEquals(checkResp0.amountEffective, "TESTKUDOS:5.49");
+
+ {
+ const resp = await w1.walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "Hello World 😁😇",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ console.log(resp);
+ }
+
+ {
+ const bal = await w1.walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals(bal.balances[0].pendingOutgoing, "TESTKUDOS:5.49");
+ }
+
+ await w1.walletClient.call(WalletApiOperation.TestingWaitRefreshesFinal, {});
+
+ const resp = await w1.walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "Hello World 🥺",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ console.log(resp);
+
+ const peerPushReadyCond = w1.walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready &&
+ x.transactionId === resp.transactionId,
+ );
+
+ await peerPushReadyCond;
+
+ const txDetails = await w1.walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: resp.transactionId,
+ },
+ );
+ t.assertDeepEqual(txDetails.type, TransactionType.PeerPushDebit);
+ t.assertTrue(!!txDetails.talerUri);
+
+ const checkResp = await w2.walletClient.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ {
+ talerUri: txDetails.talerUri,
+ },
+ );
+
+ console.log(checkResp);
+
+ const acceptResp = await w2.walletClient.call(
+ WalletApiOperation.ConfirmPeerPushCredit,
+ {
+ transactionId: checkResp.transactionId,
+ },
+ );
+
+ console.log(acceptResp);
+
+ const txn1 = await w1.walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ const txn2 = await w2.walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+
+ console.log(`txn1: ${j2s(txn1)}`);
+ console.log(`txn2: ${j2s(txn2)}`);
+
+ // We expect insufficient balance here!
+ const ex1 = await t.assertThrowsTalerErrorAsync(async () => {
+ await w1.walletClient.call(WalletApiOperation.InitiatePeerPushDebit, {
+ partialContractTerms: {
+ summary: "(this will fail)",
+ amount: "TESTKUDOS:15" as AmountString,
+ purse_expiration,
+ },
+ });
+ });
+
+ console.log("got expected exception detail", j2s(ex1.errorDetail));
+
+ const initiateResp2 = await w1.walletClient.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "second tx, will expire",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration,
+ },
+ },
+ );
+
+ const peerPushReadyCond2 = w1.walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready &&
+ x.transactionId === initiateResp2.transactionId,
+ );
+
+ await peerPushReadyCond2;
+
+ const txDetails3 = await w1.walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: initiateResp2.transactionId,
+ },
+ );
+ t.assertDeepEqual(txDetails3.type, TransactionType.PeerPushDebit);
+ t.assertTrue(!!txDetails3.talerUri);
+
+ await w2.walletClient.call(WalletApiOperation.PreparePeerPushCredit, {
+ talerUri: txDetails3.talerUri,
+ });
+
+ const timetravelOffsetMs = Duration.toMilliseconds(
+ Duration.fromSpec({ days: 5 }),
+ );
+
+ console.log("stopping exchange to apply time-travel");
+
+ await exchange.stop();
+ exchange.setTimetravel(timetravelOffsetMs);
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ console.log("running expire");
+ await exchange.runExpireOnce();
+ console.log("done running expire");
+
+ console.log("purse should now be expired");
+
+ await w1.walletClient.call(WalletApiOperation.TestingSetTimetravel, {
+ offsetMs: timetravelOffsetMs,
+ });
+
+ await w2.walletClient.call(WalletApiOperation.TestingSetTimetravel, {
+ offsetMs: timetravelOffsetMs,
+ });
+
+ await w1.walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ await w2.walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ const txDetails2 = await w1.walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: initiateResp2.transactionId,
+ },
+ );
+
+ console.log(`tx details 2: ${j2s(txDetails2)}`);
+}
+
+runPeerToPeerPushTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-refund-auto.ts b/packages/taler-harness/src/integrationtests/test-refund-auto.ts
index 230fc942d..2a2e26ea4 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-refund-auto.ts
+++ b/packages/taler-harness/src/integrationtests/test-refund-auto.ts
@@ -17,10 +17,13 @@
/**
* Imports.
*/
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
-import { durationFromSpec } from "@gnu-taler/taler-util";
+import { Duration, MerchantApiClient } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
/**
* Run test for basic, bank-integrated withdrawal.
@@ -28,31 +31,38 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
export async function runRefundAutoTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
// Set up order.
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
fulfillment_url: "taler://fulfillment-success/thx",
auto_refund: {
- d_ms: 3000,
+ d_us: 3000 * 1000,
},
},
- refund_delay: durationFromSpec({ minutes: 5 }),
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 5 }),
+ ),
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -60,24 +70,23 @@ export async function runRefundAutoTest(t: GlobalTestState) {
// Make wallet pay for the order
- const r1 = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, {
talerPayUri: orderStatus.taler_pay_uri,
});
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
- // FIXME: should be validated, don't cast!
- proposalId: r1.proposalId,
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: r1.transactionId,
});
// Check if payment was successful.
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
t.assertTrue(orderStatus.order_status === "paid");
- const ref = await MerchantPrivateApi.giveRefund(merchant, {
+ const ref = await merchantClient.giveRefund({
amount: "TESTKUDOS:5",
instance: "default",
justification: "foo",
@@ -87,9 +96,9 @@ export async function runRefundAutoTest(t: GlobalTestState) {
console.log(ref);
// The wallet should now automatically pick up the refund.
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
- const transactions = await wallet.client.call(
+ const transactions = await walletClient.call(
WalletApiOperation.GetTransactions,
{},
);
diff --git a/packages/taler-harness/src/integrationtests/test-refund-gone.ts b/packages/taler-harness/src/integrationtests/test-refund-gone.ts
new file mode 100644
index 000000000..8a661868f
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-refund-gone.ts
@@ -0,0 +1,139 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ Duration,
+ MerchantApiClient,
+ TransactionMajorState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ applyTimeTravelV2,
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Test wallet behavior when a refund expires before the wallet
+ * can claim it.
+ */
+export async function runRefundGoneTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Set up order.
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({
+ minutes: 10,
+ }),
+ ),
+ ),
+ },
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ const r2 = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: r1.transactionId,
+ });
+
+ // Check if payment was successful.
+
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "paid");
+
+ console.log(orderStatus);
+
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ hours: 1 })),
+ { exchange, merchant, walletClient: walletClient },
+ );
+ await exchange.stopAggregator();
+ await exchange.runAggregatorOnce();
+
+ const ref = await merchantClient.giveRefund({
+ amount: "TESTKUDOS:5",
+ instance: "default",
+ justification: "foo",
+ orderId: orderResp.order_id,
+ });
+
+ console.log(ref);
+
+ await walletClient.call(WalletApiOperation.StartRefundQuery, {
+ transactionId: r1.transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ let r = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(JSON.stringify(r, undefined, 2));
+
+ const r3 = await walletClient.call(WalletApiOperation.GetTransactions, {});
+ console.log(JSON.stringify(r3, undefined, 2));
+
+ const refundTx = r3.transactions[2];
+
+ t.assertDeepEqual(refundTx.type, TransactionType.Refund);
+ t.assertDeepEqual(refundTx.txState.major, TransactionMajorState.Failed);
+}
+
+runRefundGoneTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-refund-incremental.ts b/packages/taler-harness/src/integrationtests/test-refund-incremental.ts
index 47c2293e2..8a5d23315 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-refund-incremental.ts
+++ b/packages/taler-harness/src/integrationtests/test-refund-incremental.ts
@@ -17,14 +17,18 @@
/**
* Imports.
*/
-import { GlobalTestState, delayMs, MerchantPrivateApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
import {
- TransactionType,
Amounts,
- durationFromSpec,
+ Duration,
+ MerchantApiClient,
+ TransactionType,
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, delayMs } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
/**
* Run test for basic, bank-integrated withdrawal.
@@ -32,29 +36,36 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
export async function runRefundIncrementalTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
// Set up order.
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:10",
fulfillment_url: "taler://fulfillment-success/thx",
},
- refund_delay: durationFromSpec({ minutes: 5 }),
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 5 }),
+ ),
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -62,23 +73,23 @@ export async function runRefundIncrementalTest(t: GlobalTestState) {
// Make wallet pay for the order
- const r1 = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, {
talerPayUri: orderStatus.taler_pay_uri,
});
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
proposalId: r1.proposalId,
});
// Check if payment was successful.
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
t.assertTrue(orderStatus.order_status === "paid");
- let ref = await MerchantPrivateApi.giveRefund(merchant, {
+ let ref = await merchantClient.giveRefund({
amount: "TESTKUDOS:2.5",
instance: "default",
justification: "foo",
@@ -88,14 +99,15 @@ export async function runRefundIncrementalTest(t: GlobalTestState) {
console.log("first refund increase response", ref);
{
- let wr = await wallet.client.call(WalletApiOperation.ApplyRefund, {
- talerRefundUri: ref.talerRefundUri,
+ let wr = await walletClient.call(WalletApiOperation.StartRefundQuery, {
+ transactionId: r1.transactionId,
});
- console.log(wr);
- const txs = await wallet.client.call(
- WalletApiOperation.GetTransactions,
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
{},
);
+ console.log(wr);
+ const txs = await walletClient.call(WalletApiOperation.GetTransactions, {});
console.log(
"transactions after applying first refund:",
JSON.stringify(txs, undefined, 2),
@@ -106,7 +118,7 @@ export async function runRefundIncrementalTest(t: GlobalTestState) {
// refund will be grouped with the previous one.
await delayMs(1200);
- ref = await MerchantPrivateApi.giveRefund(merchant, {
+ ref = await merchantClient.giveRefund({
amount: "TESTKUDOS:5",
instance: "default",
justification: "bar",
@@ -119,7 +131,7 @@ export async function runRefundIncrementalTest(t: GlobalTestState) {
// refund will be grouped with the previous one.
await delayMs(1200);
- ref = await MerchantPrivateApi.giveRefund(merchant, {
+ ref = await merchantClient.giveRefund({
amount: "TESTKUDOS:10",
instance: "default",
justification: "bar",
@@ -129,13 +141,17 @@ export async function runRefundIncrementalTest(t: GlobalTestState) {
console.log("third refund increase response", ref);
{
- let wr = await wallet.client.call(WalletApiOperation.ApplyRefund, {
- talerRefundUri: ref.talerRefundUri,
+ let wr = await walletClient.call(WalletApiOperation.StartRefundQuery, {
+ transactionId: r1.transactionId,
});
+ await walletClient.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
console.log(wr);
}
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -145,26 +161,17 @@ export async function runRefundIncrementalTest(t: GlobalTestState) {
console.log(JSON.stringify(orderStatus, undefined, 2));
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
- const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
console.log(JSON.stringify(bal, undefined, 2));
{
- const txs = await wallet.client.call(
- WalletApiOperation.GetTransactions,
- {},
- );
+ const txs = await walletClient.call(WalletApiOperation.GetTransactions, {});
console.log(JSON.stringify(txs, undefined, 2));
const txTypes = txs.transactions.map((x) => x.type);
- t.assertDeepEqual(txTypes, [
- "withdrawal",
- "payment",
- "refund",
- "refund",
- "refund",
- ]);
+ t.assertDeepEqual(txTypes, ["withdrawal", "payment", "refund", "refund"]);
for (const tx of txs.transactions) {
if (tx.type !== TransactionType.Refund) {
@@ -187,7 +194,7 @@ export async function runRefundIncrementalTest(t: GlobalTestState) {
.map((x) => x.amountEffective),
).amount;
- t.assertAmountEquals("TESTKUDOS:8.33", effective);
+ t.assertAmountEquals("TESTKUDOS:8.59", effective);
}
await t.shutdown();
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-refund.ts b/packages/taler-harness/src/integrationtests/test-refund.ts
index f11771922..999a9b621 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-refund.ts
+++ b/packages/taler-harness/src/integrationtests/test-refund.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
+ (C) 2020-2024 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -17,40 +17,57 @@
/**
* Imports.
*/
-import { durationFromSpec } from "@gnu-taler/taler-util";
+import {
+ Duration,
+ j2s,
+ MerchantApiClient,
+ NotificationType,
+ TransactionMajorState,
+ TransactionType,
+} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
-/**
- * Run test for basic, bank-integrated withdrawal.
- */
export async function runRefundTest(t: GlobalTestState) {
// Set up test environment
const {
- wallet,
+ walletClient: wallet,
bank,
exchange,
merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ } = await createSimpleTestkudosEnvironmentV2(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ const withdrawalRes = await withdrawViaBankV2(t, {
+ walletClient: wallet,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
- // Set up order.
+ await withdrawalRes.withdrawalFinishedCond;
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ // Set up order.
+ const orderResp = await merchantClient.createOrder({
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
fulfillment_url: "taler://fulfillment-success/thx",
},
- refund_delay: durationFromSpec({ minutes: 5 }),
+ refund_delay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 5 }),
+ ),
});
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
@@ -63,18 +80,28 @@ export async function runRefundTest(t: GlobalTestState) {
});
await wallet.client.call(WalletApiOperation.ConfirmPay, {
- proposalId: r1.proposalId,
+ transactionId: r1.transactionId,
});
// Check if payment was successful.
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
+ orderStatus = await merchantClient.queryPrivateOrderStatus({
orderId: orderResp.order_id,
});
t.assertTrue(orderStatus.order_status === "paid");
- const ref = await MerchantPrivateApi.giveRefund(merchant, {
+ {
+ const tx = await wallet.client.call(WalletApiOperation.GetTransactionById, {
+ transactionId: r1.transactionId,
+ });
+
+ t.assertTrue(
+ tx.type === TransactionType.Payment && tx.refundPending === undefined,
+ );
+ }
+
+ const ref = await merchantClient.giveRefund({
amount: "TESTKUDOS:5",
instance: "default",
justification: "foo",
@@ -83,23 +110,40 @@ export async function runRefundTest(t: GlobalTestState) {
console.log(ref);
- let r = await wallet.client.call(WalletApiOperation.ApplyRefund, {
- talerRefundUri: ref.talerRefundUri,
- });
- console.log(r);
-
- await wallet.runUntilDone();
+ {
+ const refundFinishedCond = wallet.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === r1.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done,
+ );
+ await wallet.client.call(WalletApiOperation.StartRefundQuery, {
+ transactionId: r1.transactionId,
+ });
+ await refundFinishedCond;
+ }
{
const r2 = await wallet.client.call(WalletApiOperation.GetBalances, {});
console.log(JSON.stringify(r2, undefined, 2));
}
+
{
const r2 = await wallet.client.call(WalletApiOperation.GetTransactions, {});
console.log(JSON.stringify(r2, undefined, 2));
}
- await t.shutdown();
+ {
+ const tx = await wallet.client.call(WalletApiOperation.GetTransactionById, {
+ transactionId: r1.transactionId,
+ });
+
+ console.log(j2s(tx));
+
+ t.assertTrue(
+ tx.type === TransactionType.Payment && tx.refundPending === undefined,
+ );
+ }
}
runRefundTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts b/packages/taler-harness/src/integrationtests/test-revocation.ts
index 276c532b5..ac118e4eb 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts
+++ b/packages/taler-harness/src/integrationtests/test-revocation.ts
@@ -18,29 +18,32 @@
* Imports.
*/
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { CoinConfig } from "../harness/denomStructures";
+import { CoinConfig } from "../harness/denomStructures.js";
import {
- GlobalTestState,
+ BankService,
ExchangeService,
+ GlobalTestState,
MerchantService,
WalletCli,
- setupDb,
- BankService,
+ WalletClient,
delayMs,
+ generateRandomPayto,
+ setupDb,
} from "../harness/harness.js";
import {
- withdrawViaBank,
- makeTestPayment,
- SimpleTestEnvironment,
+ SimpleTestEnvironmentNg,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
} from "../harness/helpers.js";
async function revokeAllWalletCoins(req: {
- wallet: WalletCli;
+ walletClient: WalletClient;
exchange: ExchangeService;
merchant: MerchantService;
}): Promise<void> {
- const { wallet, exchange, merchant } = req;
- const coinDump = await wallet.client.call(WalletApiOperation.DumpCoins, {});
+ const { walletClient, exchange, merchant } = req;
+ const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {});
console.log(coinDump);
const usedDenomHashes = new Set<string>();
for (const coin of coinDump.coins) {
@@ -59,7 +62,7 @@ async function revokeAllWalletCoins(req: {
async function createTestEnvironment(
t: GlobalTestState,
-): Promise<SimpleTestEnvironment> {
+): Promise<SimpleTestEnvironmentNg> {
const db = await setupDb(t);
const bank = await BankService.create(t, {
@@ -84,7 +87,7 @@ async function createTestEnvironment(
});
const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
+ "myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
@@ -96,6 +99,7 @@ async function createTestEnvironment(
await bank.pingUntilAvailable();
const coin_u1: CoinConfig = {
+ cipher: "RSA" as const,
durationLegal: "3 years",
durationSpend: "2 years",
durationWithdraw: "7 days",
@@ -118,27 +122,35 @@ async function createTestEnvironment(
await merchant.start();
await merchant.pingUntilAvailable();
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
});
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
- paytoUris: ["payto://x-taler-bank/minst1"],
+ paytoUris: [generateRandomPayto("minst1")],
});
console.log("setup done!");
const wallet = new WalletCli(t);
+ const { walletService, walletClient } = await createWalletDaemonWithClient(
+ t,
+ {
+ name: "default",
+ },
+ );
+
return {
commonDb: db,
exchange,
merchant,
- wallet,
+ walletClient,
+ walletService,
bank,
exchangeBankAccount,
};
@@ -150,24 +162,29 @@ async function createTestEnvironment(
export async function runRevocationTest(t: GlobalTestState) {
// Set up test environment
- const { wallet, bank, exchange, merchant } = await createTestEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createTestEnvironment(t);
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
+ await wres.withdrawalFinishedCond;
console.log("revoking first time");
- await revokeAllWalletCoins({ wallet, exchange, merchant });
+ await revokeAllWalletCoins({ walletClient, exchange, merchant });
// FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565
// is implemented.
- await wallet.client.call(WalletApiOperation.AddExchange, {
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
exchangeBaseUrl: exchange.baseUrl,
- forceUpdate: true,
});
- await wallet.runUntilDone();
- await wallet.runUntilDone();
- const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
console.log("wallet balance", bal);
const order = {
@@ -176,37 +193,42 @@ export async function runRevocationTest(t: GlobalTestState) {
fulfillment_url: "taler://fulfillment-success/thx",
};
- await makeTestPayment(t, { wallet, merchant, order });
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+
+ await walletClient.call(WalletApiOperation.ClearDb, {});
- wallet.deleteDatabase();
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
- const coinDump = await wallet.client.call(WalletApiOperation.DumpCoins, {});
+ const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {});
console.log(coinDump);
const coinPubList = coinDump.coins.map((x) => x.coin_pub);
- await wallet.client.call(WalletApiOperation.ForceRefresh, {
- coinPubList,
+ await walletClient.call(WalletApiOperation.ForceRefresh, {
+ refreshCoinSpecs: coinPubList.map((x) => ({ coinPub: x })),
});
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
console.log("revoking second time");
- await revokeAllWalletCoins({ wallet, exchange, merchant });
+ await revokeAllWalletCoins({ walletClient, exchange, merchant });
// FIXME: this shouldn't be necessary once https://bugs.taler.net/n/6565
// is implemented.
- await wallet.client.call(WalletApiOperation.AddExchange, {
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
exchangeBaseUrl: exchange.baseUrl,
- forceUpdate: true,
});
- await wallet.runUntilDone();
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
{
- const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
console.log("wallet balance", bal);
}
- await makeTestPayment(t, { wallet, merchant, order });
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
}
runRevocationTest.timeoutMs = 120000;
runRevocationTest.suites = ["wallet"];
+runRevocationTest.experimental = true;
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment.ts b/packages/taler-harness/src/integrationtests/test-simple-payment.ts
index 967d491be..58ab61435 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment.ts
+++ b/packages/taler-harness/src/integrationtests/test-simple-payment.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
+ (C) 2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -17,29 +17,33 @@
/**
* Imports.
*/
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { GlobalTestState } from "../harness/harness.js";
import {
- createSimpleTestkudosEnvironment,
- withdrawViaBank,
- makeTestPayment,
+ withdrawViaBankV2,
+ makeTestPaymentV2,
+ useSharedTestkudosEnvironment,
} from "../harness/helpers.js";
/**
* Run test for basic, bank-integrated withdrawal and payment.
*/
-export async function runPaymentTest(t: GlobalTestState) {
+export async function runSimplePaymentTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await useSharedTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
const order = {
summary: "Buy me!",
@@ -47,9 +51,8 @@ export async function runPaymentTest(t: GlobalTestState) {
fulfillment_url: "taler://fulfillment-success/thx",
};
- await makeTestPayment(t, { wallet, merchant, order });
-
- await wallet.runUntilDone();
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
}
-runPaymentTest.suites = ["wallet"];
+runSimplePaymentTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-stored-backups.ts b/packages/taler-harness/src/integrationtests/test-stored-backups.ts
new file mode 100644
index 000000000..a3a5e6ca3
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-stored-backups.ts
@@ -0,0 +1,112 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ withdrawViaBankV2,
+ makeTestPaymentV2,
+ useSharedTestkudosEnvironment,
+} from "../harness/helpers.js";
+
+/**
+ * Test stored backup wallet-core API.
+ */
+export async function runStoredBackupsTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await useSharedTestkudosEnvironment(t);
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres;
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const sb1Resp = await walletClient.call(
+ WalletApiOperation.CreateStoredBackup,
+ {},
+ );
+ const sbList = await walletClient.call(
+ WalletApiOperation.ListStoredBackups,
+ {},
+ );
+ t.assertTrue(sbList.storedBackups.length === 1);
+ t.assertTrue(sbList.storedBackups[0].name === sb1Resp.name);
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txn1 = await walletClient.call(WalletApiOperation.GetTransactions, {});
+ t.assertDeepEqual(txn1.transactions.length, 2);
+
+ // Recover from the stored backup now.
+
+ const sb2Resp = await walletClient.call(
+ WalletApiOperation.CreateStoredBackup,
+ {},
+ );
+
+ console.log("recovering backup");
+
+ await walletClient.call(WalletApiOperation.RecoverStoredBackup, {
+ name: sb1Resp.name,
+ });
+
+ console.log("first recovery done");
+
+ // Recovery went well, now we can delete the backup
+ // of the old database we stored before importing.
+ {
+ const sbl1 = await walletClient.call(
+ WalletApiOperation.ListStoredBackups,
+ {},
+ );
+ t.assertTrue(sbl1.storedBackups.length === 2);
+
+ await walletClient.call(WalletApiOperation.DeleteStoredBackup, {
+ name: sb1Resp.name,
+ });
+ const sbl2 = await walletClient.call(
+ WalletApiOperation.ListStoredBackups,
+ {},
+ );
+ t.assertTrue(sbl2.storedBackups.length === 1);
+ }
+
+ const txn2 = await walletClient.call(WalletApiOperation.GetTransactions, {});
+ // We only have the withdrawal after restoring
+ t.assertDeepEqual(txn2.transactions.length, 1);
+}
+
+runStoredBackupsTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts
index e20d8bdad..e144683cb 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts
+++ b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts
@@ -20,52 +20,25 @@
import {
ConfirmPayResultType,
Duration,
- durationFromSpec,
+ MerchantApiClient,
+ NotificationType,
PreparePayResultType,
} from "@gnu-taler/taler-util";
-import {
- PendingOperationsResponse,
- WalletApiOperation,
-} from "@gnu-taler/taler-wallet-core";
-import { makeNoFeeCoinConfig } from "../harness/denomStructures";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
import {
BankService,
ExchangeService,
GlobalTestState,
- MerchantPrivateApi,
MerchantService,
+ generateRandomPayto,
setupDb,
- WalletCli,
} from "../harness/harness.js";
-import { startWithdrawViaBank, withdrawViaBank } from "../harness/helpers.js";
-
-async function applyTimeTravel(
- timetravelDuration: Duration,
- s: {
- exchange?: ExchangeService;
- merchant?: MerchantService;
- wallet?: WalletCli;
- },
-): Promise<void> {
- if (s.exchange) {
- await s.exchange.stop();
- s.exchange.setTimetravel(timetravelDuration);
- await s.exchange.start();
- await s.exchange.pingUntilAvailable();
- }
-
- if (s.merchant) {
- await s.merchant.stop();
- s.merchant.setTimetravel(timetravelDuration);
- await s.merchant.start();
- await s.merchant.pingUntilAvailable();
- }
-
- if (s.wallet) {
- console.log("setting wallet time travel to", timetravelDuration);
- s.wallet.setTimetravel(timetravelDuration);
- }
-}
+import {
+ applyTimeTravelV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
/**
* Basic time travel test.
@@ -97,7 +70,7 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) {
});
const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
+ "myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
@@ -118,68 +91,95 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) {
await merchant.start();
await merchant.pingUntilAvailable();
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
});
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "minst1",
name: "minst1",
- paytoUris: ["payto://x-taler-bank/minst1"],
+ paytoUris: [generateRandomPayto("minst1")],
});
console.log("setup done!");
- const wallet = new WalletCli(t);
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ });
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
-
- // Travel into the future, the deposit expiration is two years
- // into the future.
- console.log("applying first time travel");
- await applyTimeTravel(durationFromSpec({ days: 400 }), {
- wallet,
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
exchange,
- merchant,
+ amount: "TESTKUDOS:15",
});
+ await wres.withdrawalFinishedCond;
- await wallet.runUntilDone();
+ const exchangeUpdated1Cond = walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.ExchangeStateTransition &&
+ x.exchangeBaseUrl === exchange.baseUrl,
+ );
- let p: PendingOperationsResponse;
- p = await wallet.client.call(WalletApiOperation.GetPendingOperations, {});
+ // Travel into the future, the deposit expiration is two years
+ // into the future.
+ console.log("applying first time travel");
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ days: 400 })),
+ {
+ walletClient,
+ exchange,
+ merchant,
+ },
+ );
- console.log("pending operations after first time travel");
- console.log(JSON.stringify(p, undefined, 2));
+ // The time travel should cause exchanges to update.
+ await exchangeUpdated1Cond;
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
- await startWithdrawViaBank(t, {
- wallet,
+ const wres2 = await withdrawViaBankV2(t, {
+ walletClient,
bank,
exchange,
amount: "TESTKUDOS:20",
});
+ await wres2.withdrawalFinishedCond;
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const exchangeUpdated2Cond = walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.ExchangeStateTransition &&
+ x.exchangeBaseUrl === exchange.baseUrl,
+ );
// Travel into the future, the deposit expiration is two years
// into the future.
console.log("applying second time travel");
- await applyTimeTravel(durationFromSpec({ years: 2, months: 6 }), {
- wallet,
- exchange,
- merchant,
- });
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ years: 2, months: 6 })),
+ {
+ walletClient,
+ exchange,
+ merchant,
+ },
+ );
+
+ // The time travel should cause exchanges to update.
+ await exchangeUpdated2Cond;
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
// At this point, the original coins should've been refreshed.
// It would be too late to refresh them now, as we're past
// the two year deposit expiration.
- await wallet.runUntilDone();
-
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
+ const orderResp = await merchantClient.createOrder({
order: {
fulfillment_url: "http://example.com",
summary: "foo",
@@ -187,17 +187,14 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) {
},
});
- const orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
- merchant,
- {
- orderId: orderResp.order_id,
- instance: "default",
- },
- );
+ const orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ instance: "default",
+ });
t.assertTrue(orderStatus.order_status === "unpaid");
- const r = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
+ const r = await walletClient.call(WalletApiOperation.PreparePayForUri, {
talerPayUri: orderStatus.taler_pay_uri,
});
@@ -205,8 +202,8 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) {
t.assertTrue(r.status === PreparePayResultType.PaymentPossible);
- const cpr = await wallet.client.call(WalletApiOperation.ConfirmPay, {
- proposalId: r.proposalId,
+ const cpr = await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: r.transactionId,
});
t.assertTrue(cpr.type === ConfirmPayResultType.Done);
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-timetravel-withdraw.ts b/packages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts
index 2ff857057..9cd0beb42 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-timetravel-withdraw.ts
+++ b/packages/taler-harness/src/integrationtests/test-timetravel-withdraw.ts
@@ -17,14 +17,18 @@
/**
* Imports.
*/
+import {
+ Duration,
+ TransactionMajorState,
+ TransactionType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { GlobalTestState } from "../harness/harness.js";
import {
- createSimpleTestkudosEnvironment,
- withdrawViaBank,
- startWithdrawViaBank,
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
} from "../harness/helpers.js";
-import { Duration, TransactionType } from "@gnu-taler/taler-util";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
/**
* Basic time travel test.
@@ -32,16 +36,18 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
export async function runTimetravelWithdrawTest(t: GlobalTestState) {
// Set up test environment
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
// Withdraw digital cash into the wallet.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" });
+ const wres1 = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
+ await wres1.withdrawalFinishedCond;
// Travel 400 days into the future,
// as the deposit expiration is two years
@@ -51,46 +57,53 @@ export async function runTimetravelWithdrawTest(t: GlobalTestState) {
};
await exchange.stop();
- exchange.setTimetravel(timetravelDuration);
+ exchange.setTimetravel(Duration.toMilliseconds(timetravelDuration));
await exchange.start();
await exchange.pingUntilAvailable();
await exchange.keyup();
await merchant.stop();
- merchant.setTimetravel(timetravelDuration);
+ merchant.setTimetravel(Duration.toMilliseconds(timetravelDuration));
await merchant.start();
await merchant.pingUntilAvailable();
+ console.log("starting withdrawal via bank");
+
// This should fail, as the wallet didn't time travel yet.
- await startWithdrawViaBank(t, {
- wallet,
+ const wres2 = await withdrawViaBankV2(t, {
+ walletClient,
bank,
exchange,
amount: "TESTKUDOS:20",
});
+ console.log("starting withdrawal done");
+
// Check that transactions are correct for the failed withdrawal
{
- await wallet.runUntilDone({ maxRetries: 5 });
- const transactions = await wallet.client.call(
+ const transactions = await walletClient.call(
WalletApiOperation.GetTransactions,
- {},
+ {
+ sort: "stable-ascending",
+ },
);
- console.log(transactions);
+ console.log(j2s(transactions));
const types = transactions.transactions.map((x) => x.type);
t.assertDeepEqual(types, ["withdrawal", "withdrawal"]);
const wtrans = transactions.transactions[1];
t.assertTrue(wtrans.type === TransactionType.Withdrawal);
- t.assertTrue(wtrans.pending);
+ t.assertTrue(wtrans.txState.major === TransactionMajorState.Pending);
}
// Now we also let the wallet time travel
- wallet.setTimetravel(timetravelDuration);
+ walletClient.call(WalletApiOperation.TestingSetTimetravel, {
+ offsetMs: Duration.toMilliseconds(timetravelDuration),
+ });
- // This doesn't work yet, see https://bugs.taler.net/n/6585
+ // The wallet should do denomination re-selection and succeed
- // await wallet.runUntilDone({ maxRetries: 5 });
+ await wres2.withdrawalFinishedCond;
}
runTimetravelWithdrawTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-tos-format.ts b/packages/taler-harness/src/integrationtests/test-tos-format.ts
new file mode 100644
index 000000000..e6087af9d
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-tos-format.ts
@@ -0,0 +1,101 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+} from "../harness/helpers.js";
+import * as fs from "fs";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runTermOfServiceFormatTest(t: GlobalTestState) {
+ // Set up test environment
+ const tosDir = t.testDir + `/tos/`;
+ const langs = ["es", "en", "de"]
+
+ langs.forEach(l => {
+ const langDir = tosDir + l + "/"
+ fs.mkdirSync(langDir, { recursive: true });
+ fs.writeFileSync(langDir + "v1.txt", "text content");
+ fs.writeFileSync(langDir + "v1.md", "markdown content");
+ fs.writeFileSync(langDir + "v1.html", "html content");
+ });
+
+ const { walletClient, exchange, } =
+ await createSimpleTestkudosEnvironmentV2(t, undefined, {
+ additionalExchangeConfig: (ex) => {
+ ex.changeConfig((cfg) => {
+ cfg.setString("exchange", "terms_etag", "v1")
+ cfg.setString("exchange", "terms_dir", tosDir)
+ })
+ }
+ });
+
+
+ {
+ const tos = await walletClient.client.call(WalletApiOperation.GetExchangeTos, {
+ exchangeBaseUrl: exchange.baseUrl,
+ })
+
+ t.assertDeepEqual(tos.content, "text content");
+ }
+
+ {
+ const tos = await walletClient.client.call(WalletApiOperation.GetExchangeTos, {
+ exchangeBaseUrl: exchange.baseUrl,
+ acceptedFormat: ["text/html"]
+ })
+
+ t.assertDeepEqual(tos.content, "html content");
+ }
+
+ {
+ const tos = await walletClient.client.call(WalletApiOperation.GetExchangeTos, {
+ exchangeBaseUrl: exchange.baseUrl,
+ acceptedFormat: ["text/markdown"]
+ })
+
+ t.assertDeepEqual(tos.content, "markdown content");
+ }
+
+ {
+ const tos = await walletClient.client.call(WalletApiOperation.GetExchangeTos, {
+ exchangeBaseUrl: exchange.baseUrl,
+ acceptedFormat: ["text/markdown", "text/html"]
+ })
+
+ // prefer markdown since its the first one in the list
+ t.assertDeepEqual(tos.content, "markdown content");
+ }
+
+ {
+ const tos = await walletClient.client.call(WalletApiOperation.GetExchangeTos, {
+ exchangeBaseUrl: exchange.baseUrl,
+ acceptedFormat: ["text/pdf", "text/html"]
+ })
+
+ // there is no pdf so fallback in html
+ t.assertDeepEqual(tos.content, "html content");
+ }
+}
+
+runTermOfServiceFormatTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-backup-basic.ts b/packages/taler-harness/src/integrationtests/test-wallet-backup-basic.ts
new file mode 100644
index 000000000..cb4a50a2b
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-backup-basic.ts
@@ -0,0 +1,189 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+import { SyncService } from "../harness/sync.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWalletBackupBasicTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { commonDb, merchant, walletClient, bank, exchange } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ const sync = await SyncService.create(t, {
+ currency: "TESTKUDOS",
+ annualFee: "TESTKUDOS:0.5",
+ database: commonDb.connStr,
+ fulfillmentUrl: "taler://fulfillment-success",
+ httpPort: 8089,
+ name: "sync1",
+ paymentBackendUrl: merchant.makeInstanceBaseUrl(),
+ uploadLimitMb: 10,
+ });
+
+ await sync.start();
+ await sync.pingUntilAvailable();
+
+ await walletClient.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: sync.baseUrl,
+ activate: false,
+ name: sync.baseUrl,
+ });
+
+ {
+ const bi = await walletClient.call(WalletApiOperation.GetBackupInfo, {});
+ t.assertDeepEqual(bi.providers[0].active, false);
+ }
+
+ await walletClient.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: sync.baseUrl,
+ activate: true,
+ name: sync.baseUrl,
+ });
+
+ {
+ const bi = await walletClient.call(WalletApiOperation.GetBackupInfo, {});
+ t.assertDeepEqual(bi.providers[0].active, true);
+ }
+
+ await walletClient.call(WalletApiOperation.RunBackupCycle, {});
+
+ {
+ const bi = await walletClient.call(WalletApiOperation.GetBackupInfo, {});
+ console.log(bi);
+ t.assertDeepEqual(
+ bi.providers[0].paymentStatus.type,
+ "insufficient-balance",
+ );
+ }
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:10",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ await walletClient.call(WalletApiOperation.RunBackupCycle, {});
+
+ {
+ const bi = await walletClient.call(WalletApiOperation.GetBackupInfo, {});
+ console.log(bi);
+ }
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:5",
+ });
+
+ await walletClient.call(WalletApiOperation.RunBackupCycle, {});
+
+ {
+ const bi = await walletClient.call(WalletApiOperation.GetBackupInfo, {});
+ console.log(bi);
+ }
+
+ const backupRecovery = await walletClient.call(
+ WalletApiOperation.ExportBackupRecovery,
+ {},
+ );
+
+ const txs = await walletClient.call(WalletApiOperation.GetTransactions, {});
+ console.log(`backed up transactions ${j2s(txs)}`);
+
+ const { walletClient: walletClient2 } = await createWalletDaemonWithClient(
+ t,
+ {
+ name: "w2",
+ },
+ );
+
+ // Check that the second wallet is a fresh wallet.
+ {
+ const bal = await walletClient2.call(WalletApiOperation.GetBalances, {});
+ t.assertTrue(bal.balances.length === 0);
+ }
+
+ await walletClient2.call(WalletApiOperation.ImportBackupRecovery, {
+ recovery: backupRecovery,
+ });
+
+ await walletClient2.call(WalletApiOperation.RunBackupCycle, {});
+
+ // Check that now the old balance is available!
+ {
+ const bal = await walletClient2.call(WalletApiOperation.GetBalances, {});
+ t.assertTrue(bal.balances.length === 1);
+ console.log(bal);
+ }
+
+ // Now do some basic checks that the restored wallet is still functional
+ {
+ const txs = await walletClient2.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(`restored transactions ${j2s(txs)}`);
+ const bal1 = await walletClient2.call(WalletApiOperation.GetBalances, {});
+
+ t.assertAmountEquals(bal1.balances[0].available, "TESTKUDOS:14.1");
+
+ await withdrawViaBankV2(t, {
+ walletClient: walletClient2,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:10",
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await walletClient2.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+
+ const txs2 = await walletClient2.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(`tx after withdraw after restore ${j2s(txs2)}`);
+
+ const bal2 = await walletClient2.call(WalletApiOperation.GetBalances, {});
+
+ t.assertAmountEquals(bal2.balances[0].available, "TESTKUDOS:23.82");
+ }
+}
+
+runWalletBackupBasicTest.suites = ["wallet", "wallet-backup"];
+// See https://bugs.taler.net/n/7598
+runWalletBackupBasicTest.experimental = true;
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-backup-doublespend.ts b/packages/taler-harness/src/integrationtests/test-wallet-backup-doublespend.ts
new file mode 100644
index 000000000..c761c4fb0
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-backup-doublespend.ts
@@ -0,0 +1,183 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { MerchantApiClient, PreparePayResultType } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+import { SyncService } from "../harness/sync.js";
+
+export async function runWalletBackupDoublespendTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { commonDb, merchant, walletClient, bank, exchange } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const sync = await SyncService.create(t, {
+ currency: "TESTKUDOS",
+ annualFee: "TESTKUDOS:0.5",
+ database: commonDb.connStr,
+ fulfillmentUrl: "taler://fulfillment-success",
+ httpPort: 8089,
+ name: "sync1",
+ paymentBackendUrl: merchant.makeInstanceBaseUrl(),
+ uploadLimitMb: 10,
+ });
+
+ await sync.start();
+ await sync.pingUntilAvailable();
+
+ await walletClient.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: sync.baseUrl,
+ activate: true,
+ name: sync.baseUrl,
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:10",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ await walletClient.call(WalletApiOperation.RunBackupCycle, {});
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+ await walletClient.call(WalletApiOperation.RunBackupCycle, {});
+
+ const backupRecovery = await walletClient.call(
+ WalletApiOperation.ExportBackupRecovery,
+ {},
+ );
+
+ const { walletClient: walletClientTwo } = await createWalletDaemonWithClient(
+ t,
+ { name: "default" },
+ );
+
+ await walletClientTwo.call(WalletApiOperation.ImportBackupRecovery, {
+ recovery: backupRecovery,
+ });
+
+ await walletClientTwo.call(WalletApiOperation.RunBackupCycle, {});
+
+ console.log(
+ "wallet1 balance before spend:",
+ await walletClient.call(WalletApiOperation.GetBalances, {}),
+ );
+
+ await makeTestPaymentV2(t, {
+ merchant,
+ walletClient,
+ order: {
+ summary: "foo",
+ amount: "TESTKUDOS:7",
+ },
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ console.log(
+ "wallet1 balance after spend:",
+ await walletClient.call(WalletApiOperation.GetBalances, {}),
+ );
+
+ {
+ console.log(
+ "wallet2 balance:",
+ await walletClientTwo.call(WalletApiOperation.GetBalances, {}),
+ );
+ }
+
+ // Now we double-spend with the second wallet
+
+ {
+ const instance = "default";
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ amount: "TESTKUDOS:8",
+ summary: "bla",
+ fulfillment_url: "taler://fulfillment-success",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ {
+ console.log(
+ "wallet2 balance before preparePay:",
+ await walletClientTwo.call(WalletApiOperation.GetBalances, {}),
+ );
+ }
+
+ const preparePayResult = await walletClientTwo.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.status,
+ PreparePayResultType.PaymentPossible,
+ );
+
+ const res = await walletClientTwo.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ console.log(res);
+
+ // FIXME: wait for a notification that indicates insufficient funds!
+
+ await withdrawViaBankV2(t, {
+ walletClient: walletClientTwo,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:50",
+ });
+
+ const bal = await walletClientTwo.call(WalletApiOperation.GetBalances, {});
+ console.log("bal", bal);
+
+ await walletClientTwo.call(
+ WalletApiOperation.TestingWaitTransactionsFinal,
+ {},
+ );
+ }
+}
+
+runWalletBackupDoublespendTest.suites = ["wallet", "wallet-backup"];
+// See https://bugs.taler.net/n/7598
+runWalletBackupDoublespendTest.experimental = true;
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-balance-notifications.ts b/packages/taler-harness/src/integrationtests/test-wallet-balance-notifications.ts
new file mode 100644
index 000000000..290ef7e2d
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-balance-notifications.ts
@@ -0,0 +1,111 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ NotificationType,
+ TalerCorebankApiClient,
+ TransactionMajorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+/**
+ * Test behavior when an order is deleted while the wallet is paying for it.
+ */
+export async function runWalletBalanceNotificationsTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant, walletService } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ const amount = "TESTKUDOS:20";
+
+ const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl);
+
+ const user = await bankClient.createRandomBankUser();
+ const wop = await bankClient.createWithdrawalOperation(user.username, amount);
+
+ // Hand it to the wallet
+
+ await walletClient.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+
+ // Withdraw (AKA select)
+
+ const balanceChangedNotif1 = walletClient.waitForNotificationCond(
+ (x) => x.type === NotificationType.BalanceChange,
+ );
+
+ const acceptRes = await walletClient.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ t.logStep("wait-balance-notif-1");
+ await balanceChangedNotif1;
+ t.logStep("done-wait-balance-notif-1");
+
+ const withdrawalFinishedCond = walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId === acceptRes.transactionId,
+ );
+
+ // Confirm it
+
+ await bankClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ await withdrawalFinishedCond;
+
+ // Second withdrawal!
+ {
+ const wop2 = await bankClient.createWithdrawalOperation(
+ user.username,
+ "TESTKUDOS:5",
+ );
+
+ await walletClient.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ talerWithdrawUri: wop2.taler_withdraw_uri,
+ });
+
+ const acceptRes = await walletClient.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop2.taler_withdraw_uri,
+ },
+ );
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:19.53");
+ t.assertAmountEquals(bal.balances[0].pendingIncoming, "TESTKUDOS:4.85");
+
+ await walletService.stop();
+ }
+}
+
+runWalletBalanceNotificationsTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-balance-zero.ts b/packages/taler-harness/src/integrationtests/test-wallet-balance-zero.ts
new file mode 100644
index 000000000..7d65b60cf
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-balance-zero.ts
@@ -0,0 +1,64 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Related bugs:
+ * https://bugs.taler.net/n/8323
+ */
+export async function runWalletBalanceZeroTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfig = makeNoFeeCoinConfig("TESTKUDOS");
+ console.log(`coin config ${j2s(coinConfig)}`);
+ const { merchant, walletClient, exchange, bank } =
+ await createSimpleTestkudosEnvironmentV2(t, coinConfig);
+
+ const wres = await withdrawViaBankV2(t, {
+ amount: "TESTKUDOS:10",
+ bank,
+ exchange,
+ walletClient,
+ });
+ await wres.withdrawalFinishedCond;
+
+ await makeTestPaymentV2(t, {
+ walletClient,
+ merchant,
+ order: {
+ summary: "Hello, World!",
+ amount: "TESTKUDOS:10",
+ },
+ });
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(`${j2s(bal)}`);
+ t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:0");
+}
+
+runWalletBalanceZeroTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-balance.ts b/packages/taler-harness/src/integrationtests/test-wallet-balance.ts
new file mode 100644
index 000000000..eb7359781
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-balance.ts
@@ -0,0 +1,129 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ Amounts,
+ Duration,
+ MerchantApiClient,
+ MerchantContractTerms,
+ PreparePayResultType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Test wallet:
+ * - balance error messages
+ * - different types of insufficient balance.
+ *
+ * Related bugs:
+ * https://bugs.taler.net/n/7299
+ */
+export async function runWalletBalanceTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { merchant, walletClient, exchange, bank } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ await merchant.addInstanceWithWireAccount({
+ id: "myinst",
+ name: "My Instance",
+ paytoUris: ["payto://void/foo"],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const merchantClient = new MerchantApiClient(
+ merchant.makeInstanceBaseUrl("myinst"),
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ console.log("withdrawal finished");
+
+ const order: Partial<MerchantContractTerms> = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ console.log("creating order");
+
+ const orderResp = await merchantClient.createOrder({
+ order,
+ });
+
+ console.log("created order with merchant");
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ console.log("queried order at merchant");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.InsufficientBalance,
+ );
+
+ t.assertDeepEqual(
+ preparePayResult.status,
+ PreparePayResultType.InsufficientBalance,
+ );
+
+ t.assertTrue(
+ Amounts.isNonZero(
+ preparePayResult.balanceDetails.balanceReceiverAcceptable,
+ ),
+ );
+
+ t.assertTrue(
+ Amounts.isZero(preparePayResult.balanceDetails.balanceReceiverDepositable),
+ );
+
+ console.log("waiting for transactions to finalize");
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletBalanceTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts
new file mode 100644
index 000000000..69b721789
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts
@@ -0,0 +1,150 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState, generateRandomPayto } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+/**
+ * Run test for refreshe after a payment.
+ */
+export async function runWalletBlockedDepositTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+ ];
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t, coinConfigList);
+
+ // Withdraw digital cash into the wallet.
+
+ const { walletClient: w1 } = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient: w1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Prevent the wallet from doing refreshes by injecting a 5xx
+ // status for all refresh requests.
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/start-block-refresh",
+ });
+
+ await makeTestPaymentV2(t, {
+ merchant,
+ walletClient: w1,
+ order: {
+ summary: "test",
+ amount: "TESTKUDOS:2",
+ },
+ });
+
+ const userPayto = generateRandomPayto("foo");
+
+ const bal = await w1.call(WalletApiOperation.GetBalances, {});
+ console.log(`balance: ${j2s(bal)}`);
+
+ const balDet = await w1.call(WalletApiOperation.GetBalanceDetail, {
+ currency: "TESTKUDOS",
+ });
+ console.log(`balance details: ${j2s(balDet)}`);
+
+ const depositCheckResp = await w1.call(WalletApiOperation.PrepareDeposit, {
+ amount: "TESTKUDOS:18" as AmountString,
+ depositPaytoUri: userPayto,
+ });
+
+ console.log(`check resp: ${j2s(depositCheckResp)}`);
+
+ const depositCreateResp = await w1.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:18" as AmountString,
+ depositPaytoUri: userPayto,
+ },
+ );
+
+ console.log(`create resp: ${j2s(depositCreateResp)}`);
+
+ const depositTrackCond = w1.waitForNotificationCond((n) => {
+ return (
+ n.type === NotificationType.TransactionStateTransition &&
+ n.transactionId === depositCreateResp.transactionId &&
+ n.newTxState.major === TransactionMajorState.Pending &&
+ n.newTxState.minor === TransactionMinorState.Track
+ );
+ });
+
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/stop-block-refresh",
+ });
+
+ await depositTrackCond;
+}
+
+runWalletBlockedDepositTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts
new file mode 100644
index 000000000..15167d133
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-merchant.ts
@@ -0,0 +1,142 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ MerchantApiClient,
+ PreparePayResultType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+/**
+ * Run test for paying a merchant with balance locked behind a pending refresh.
+ */
+export async function runWalletBlockedPayMerchantTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+ ];
+
+ const { bank, exchange, merchant } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ coinConfigList,
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const { walletClient: w1 } = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient: w1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Prevent the wallet from doing refreshes by injecting a 5xx
+ // status for all refresh requests.
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/start-block-refresh",
+ });
+
+ // Do a payment that causes a refresh.
+ await makeTestPaymentV2(t, {
+ merchant,
+ walletClient: w1,
+ order: {
+ summary: "test",
+ amount: "TESTKUDOS:2",
+ },
+ });
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const orderResp = await merchantClient.createOrder({
+ order: {
+ summary: "My Payment",
+ amount: "TESTKUDOS:18",
+ },
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await w1.call(WalletApiOperation.PreparePayForUri, {
+ talerPayUri: orderStatus.taler_pay_uri,
+ });
+
+ console.log(`prepare pay result: ${j2s(preparePayResult)}`);
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/stop-block-refresh",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletBlockedPayMerchantTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts
new file mode 100644
index 000000000..36a6fea05
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-pull.ts
@@ -0,0 +1,177 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+/**
+ * Run test for a peer push payment with balance locked behind a pending refresh.
+ */
+export async function runWalletBlockedPayPeerPullTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+ ];
+
+ const { bank, exchange, merchant } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ coinConfigList,
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const { walletClient: w1 } = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ const { walletClient: w2 } = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ persistent: true,
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient: w1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Prevent the wallet from doing refreshes by injecting a 5xx
+ // status for all refresh requests.
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/start-block-refresh",
+ });
+
+ // Do a payment that causes a refresh.
+ await makeTestPaymentV2(t, {
+ merchant,
+ walletClient: w1,
+ order: {
+ summary: "test",
+ amount: "TESTKUDOS:2",
+ },
+ });
+
+ await w2.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const pullCreditReadyCond = w2.waitForNotificationCond((n) => {
+ return (
+ n.type === NotificationType.TransactionStateTransition &&
+ n.transactionId.startsWith("txn:peer-pull-credit:") &&
+ n.newTxState.major === TransactionMajorState.Pending &&
+ n.newTxState.minor === TransactionMinorState.Ready
+ );
+ });
+
+ const initResp = await w2.call(WalletApiOperation.InitiatePeerPullCredit, {
+ partialContractTerms: {
+ summary: "hi!",
+ amount: "TESTKUDOS:18" as AmountString,
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ });
+
+ await pullCreditReadyCond;
+
+ const initTx = await w2.call(WalletApiOperation.GetTransactionById, {
+ transactionId: initResp.transactionId,
+ });
+
+ t.assertDeepEqual(initTx.type, TransactionType.PeerPullCredit);
+ t.assertTrue(!!initTx.talerUri);
+
+ const checkResp = await w1.call(WalletApiOperation.PreparePeerPullDebit, {
+ talerUri: initTx.talerUri,
+ });
+
+ console.log(`check resp ${j2s(checkResp)}`);
+
+ const confirmResp = await w1.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ console.log(`confirm resp ${j2s(confirmResp)}`);
+
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/stop-block-refresh",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletBlockedPayPeerPullTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-push.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-push.ts
new file mode 100644
index 000000000..7427f2b07
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-pay-peer-push.ts
@@ -0,0 +1,149 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ createWalletDaemonWithClient,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+/**
+ * Run test for a peer push payment with balance locked behind a pending refresh.
+ */
+export async function runWalletBlockedPayPeerPushTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+ ];
+
+ const { bank, exchange, merchant } = await createSimpleTestkudosEnvironmentV2(
+ t,
+ coinConfigList,
+ );
+
+ // Withdraw digital cash into the wallet.
+
+ const { walletClient: w1 } = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ persistent: true,
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ await withdrawViaBankV2(t, {
+ walletClient: w1,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await w1.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Prevent the wallet from doing refreshes by injecting a 5xx
+ // status for all refresh requests.
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/start-block-refresh",
+ });
+
+ // Do a payment that causes a refresh.
+ await makeTestPaymentV2(t, {
+ merchant,
+ walletClient: w1,
+ order: {
+ summary: "test",
+ amount: "TESTKUDOS:2",
+ },
+ });
+
+ const checkResp = await w1.call(WalletApiOperation.CheckPeerPushDebit, {
+ amount: "TESTKUDOS:18" as AmountString,
+ });
+
+ console.log(`check resp ${j2s(checkResp)}`);
+
+ const readyCond = w1.waitForNotificationCond((n) => {
+ return (
+ n.type === NotificationType.TransactionStateTransition &&
+ n.transactionId.startsWith("txn:peer-push-debit:") &&
+ n.newTxState.major === TransactionMajorState.Pending &&
+ n.newTxState.minor === TransactionMinorState.Ready
+ );
+ });
+
+ const confirmResp = await w1.call(WalletApiOperation.InitiatePeerPushDebit, {
+ partialContractTerms: {
+ summary: "hi!",
+ amount: "TESTKUDOS:18" as AmountString,
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ });
+
+ console.log(`confirm resp ${j2s(confirmResp)}`);
+
+ await w1.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/stop-block-refresh",
+ });
+
+ await readyCond;
+}
+
+runWalletBlockedPayPeerPushTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts b/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts
new file mode 100644
index 000000000..4f015799f
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-cli-termination.ts
@@ -0,0 +1,101 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AmountString } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ WalletCli,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+
+/**
+ * Test that run-until-done of taler-wallet-cli terminates.
+ */
+export async function runWalletCliTerminationTest(t: GlobalTestState) {
+ const db = await setupDb(t);
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ const wallet = new WalletCli(t, "wallet");
+
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:20" as AmountString,
+ });
+
+ await wallet.runUntilDone();
+}
+
+runWalletCliTerminationTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-config.ts b/packages/taler-harness/src/integrationtests/test-wallet-config.ts
new file mode 100644
index 000000000..461574031
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-config.ts
@@ -0,0 +1,67 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createWalletDaemonWithClient } from "../harness/helpers.js";
+
+export async function runWalletConfigTest(t: GlobalTestState) {
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ config: {
+ builtin: {
+ exchanges: [],
+ },
+ },
+ });
+
+ const exchangesResp1 = await w1.walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesResp1.exchanges.length, 0);
+
+ const w2 = await createWalletDaemonWithClient(t, {
+ name: "w2",
+ config: {
+ builtin: {
+ exchanges: [
+ {
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ currencyHint: "KUDOS",
+ },
+ {
+ exchangeBaseUrl: "https://exchange.test.taler.net/",
+ currencyHint: "TESTKUDOS",
+ },
+ ],
+ },
+ },
+ });
+
+ const exchangesResp2 = await w2.walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesResp2.exchanges.length, 2);
+}
+
+runWalletConfigTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-cryptoworker.ts b/packages/taler-harness/src/integrationtests/test-wallet-cryptoworker.ts
new file mode 100644
index 000000000..6c2006636
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-cryptoworker.ts
@@ -0,0 +1,42 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ WalletApiOperation,
+} from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, WalletCli } from "../harness/harness.js";
+
+/**
+ * Run test for the different crypto workers.
+ */
+export async function runWalletCryptoWorkerTest(t: GlobalTestState) {
+ const wallet1 = new WalletCli(t, "w1", {
+ cryptoWorkerType: "sync",
+ });
+
+ await wallet1.client.call(WalletApiOperation.TestCrypto, {});
+
+ const wallet2 = new WalletCli(t, "w2", {
+ cryptoWorkerType: "node-worker-thread",
+ });
+
+ await wallet2.client.call(WalletApiOperation.TestCrypto, {});
+}
+
+runWalletCryptoWorkerTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-dbless.ts b/packages/taler-harness/src/integrationtests/test-wallet-dbless.ts
new file mode 100644
index 000000000..a089d99b5
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-dbless.ts
@@ -0,0 +1,160 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import {
+ applyRunConfigDefaults,
+ CryptoDispatcher,
+ SynchronousCryptoWorkerFactoryPlain,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ checkReserve,
+ depositCoin,
+ downloadExchangeInfo,
+ findDenomOrThrow,
+ refreshCoin,
+ topupReserveWithBank,
+ withdrawCoin,
+} from "@gnu-taler/taler-wallet-core/dbless";
+import { GlobalTestState, harnessHttpLib } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal and payment.
+ */
+export async function runWalletDblessTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t);
+
+ const http = harnessHttpLib;
+ const cryptiDisp = new CryptoDispatcher(
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const cryptoApi = cryptiDisp.cryptoApi;
+
+ try {
+ // Withdraw digital cash into the wallet.
+
+ const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http);
+
+ const reserveKeyPair = await cryptoApi.createEddsaKeypair({});
+
+ let reserveUrl = new URL(
+ `reserves/${reserveKeyPair.pub}`,
+ exchange.baseUrl,
+ );
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+ const longpollReq = http.fetch(reserveUrl.href, {
+ method: "GET",
+ });
+
+ await topupReserveWithBank({
+ amount: "TESTKUDOS:10" as AmountString,
+ http,
+ reservePub: reserveKeyPair.pub,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeInfo,
+ });
+
+ console.log("waiting for longpoll request");
+ const resp = await longpollReq;
+ console.log(`got response, status ${resp.status}`);
+
+ console.log(exchangeInfo);
+
+ await checkReserve(http, exchange.baseUrl, reserveKeyPair.pub);
+
+ const defaultConfig = applyRunConfigDefaults();
+
+ const d1 = findDenomOrThrow(exchangeInfo, "TESTKUDOS:8" as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ });
+
+ const coin = await withdrawCoin({
+ http,
+ cryptoApi,
+ reserveKeyPair: {
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ },
+ denom: d1,
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const wireSalt = encodeCrock(getRandomBytes(16));
+ const merchantPub = encodeCrock(getRandomBytes(32));
+ const contractTermsHash = encodeCrock(getRandomBytes(64));
+
+ await depositCoin({
+ contractTermsHash,
+ merchantPub,
+ wireSalt,
+ amount: "TESTKUDOS:4" as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+
+ // Idempotency
+ await depositCoin({
+ contractTermsHash,
+ merchantPub,
+ wireSalt,
+ amount: "TESTKUDOS:4" as AmountString,
+ coin: coin,
+ cryptoApi,
+ exchangeBaseUrl: exchange.baseUrl,
+ http,
+ });
+
+ const refreshDenoms = [
+ findDenomOrThrow(exchangeInfo, "TESTKUDOS:1" as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ }),
+ findDenomOrThrow(exchangeInfo, "TESTKUDOS:1" as AmountString, {
+ denomselAllowLate: defaultConfig.testing.denomselAllowLate,
+ }),
+ ];
+
+ await refreshCoin({
+ oldCoin: coin,
+ cryptoApi,
+ http,
+ newDenoms: refreshDenoms,
+ });
+ } catch (e) {
+ if (e instanceof TalerError) {
+ console.log(e);
+ console.log(j2s(e.errorDetail));
+ } else {
+ console.log(e);
+ }
+ throw e;
+ }
+}
+
+runWalletDblessTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-dd48.ts b/packages/taler-harness/src/integrationtests/test-wallet-dd48.ts
new file mode 100644
index 000000000..3341b6a53
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-dd48.ts
@@ -0,0 +1,183 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ ExchangeEntryStatus,
+ NotificationType,
+ TalerError,
+ TalerErrorCode,
+ WalletNotification,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ WalletClient,
+ WalletService,
+ setupDb,
+} from "../harness/harness.js";
+import { withdrawViaBankV2 } from "../harness/helpers.js";
+
+/**
+ * Test for DD48 notifications.
+ */
+export async function runWalletDd48Test(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const allNotifications: WalletNotification[] = [];
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ allNotifications.push(n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ },
+ },
+ });
+
+ await walletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ {
+ const exchangeEntry = await walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+
+ t.assertDeepEqual(
+ exchangeEntry.exchangeEntryStatus,
+ ExchangeEntryStatus.Ephemeral,
+ );
+
+ const resources = await walletClient.call(
+ WalletApiOperation.GetExchangeResources,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+ t.assertDeepEqual(resources.hasResources, false);
+ }
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ amount: "TESTKUDOS:20",
+ bank,
+ exchange,
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ const exchangeEntry = await walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ },
+ );
+
+ t.assertDeepEqual(
+ exchangeEntry.exchangeEntryStatus,
+ ExchangeEntryStatus.Used,
+ );
+
+ t.assertTrue(
+ !!allNotifications.find(
+ (x) =>
+ x.type === NotificationType.ExchangeStateTransition &&
+ x.oldExchangeState == null &&
+ x.newExchangeState.exchangeEntryStatus ===
+ ExchangeEntryStatus.Ephemeral,
+ ),
+ );
+
+ console.log(j2s(allNotifications));
+
+ const delErr = await t.assertThrowsAsync(async () => {
+ await walletClient.call(WalletApiOperation.DeleteExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+ });
+
+ t.assertTrue(delErr instanceof TalerError);
+ t.assertDeepEqual(
+ delErr.errorDetail.code,
+ TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
+ );
+
+ await walletClient.call(WalletApiOperation.DeleteExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ purge: true,
+ });
+}
+
+runWalletDd48Test.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts b/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts
new file mode 100644
index 000000000..4ce8cde4c
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts
@@ -0,0 +1,154 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { Duration, Logger, NotificationType, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { makeNoFeeCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ applyTimeTravelV2,
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const logger = new Logger("test-exchange-timetravel.ts");
+
+/**
+ * Test how the wallet handles an expired denomination.
+ */
+export async function runWalletDenomExpireTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addCoinConfigList(makeNoFeeCoinConfig("TESTKUDOS"));
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ console.log("merchant started, configuring instances");
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ });
+
+ console.log("setup done!");
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "default",
+ });
+
+ // Withdraw digital cash into the wallet.
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:15",
+ });
+ await wres.withdrawalFinishedCond;
+
+ const denomLossCond = walletClient.waitForNotificationCond((n) => {
+ return (
+ n.type === NotificationType.TransactionStateTransition &&
+ n.transactionId.startsWith("txn:denom-loss:")
+ );
+ });
+
+ // Travel into the future, the deposit expiration is two years
+ // into the future.
+ console.log("applying first time travel");
+ await applyTimeTravelV2(
+ Duration.toMilliseconds(Duration.fromSpec({ days: 800 })),
+ {
+ walletClient,
+ exchange,
+ merchant,
+ },
+ );
+
+ t.logStep("before-wait-denom-loss");
+
+ // Should be detected automatically, as exchange entry is surely outdated.
+ await denomLossCond;
+
+ const bal = await walletClient.call(WalletApiOperation.GetBalances, {});
+ console.log(`balances: ${j2s(bal)}`);
+
+ const txns = await walletClient.call(WalletApiOperation.GetTransactions, {
+ sort: "stable-ascending",
+ includeRefreshes: true,
+ });
+ console.log(`transactions: ${j2s(txns)}`);
+}
+
+runWalletDenomExpireTest.suites = ["exchange"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-dev-experiments.ts b/packages/taler-harness/src/integrationtests/test-wallet-dev-experiments.ts
new file mode 100644
index 000000000..1f1187d80
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-dev-experiments.ts
@@ -0,0 +1,48 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createWalletDaemonWithClient } from "../harness/helpers.js";
+
+export async function runWalletDevExperimentsTest(t: GlobalTestState) {
+ const w1 = await createWalletDaemonWithClient(t, {
+ name: "w1",
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ });
+
+ await w1.walletClient.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/insert-pending-refresh",
+ });
+
+ const txnResp = await w1.walletClient.call(
+ WalletApiOperation.GetTransactions,
+ {
+ includeRefreshes: true,
+ },
+ );
+
+ t.assertTrue(txnResp.transactions.length > 0);
+}
+
+runWalletDevExperimentsTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts
new file mode 100644
index 000000000..3251750da
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts
@@ -0,0 +1,165 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ ExchangeUpdateStatus,
+ NotificationType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Test how the wallet reacts when an exchange unexpectedly updates
+ * properties like the master public key.
+ */
+export async function runWalletExchangeUpdateTest(
+ t: GlobalTestState,
+): Promise<void> {
+ // Set up test environment
+
+ const db = await setupDb(t);
+ const db2 = await setupDb(t, {
+ nameSuffix: "two",
+ });
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchangeOne = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ // Danger: The second exchange has the same port!
+ // That's because we want it to have the same base URL,
+ // and we'll only start on of them at a time.
+ const exchangeTwo = ExchangeService.create(t, {
+ name: "testexchange-2",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db2.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+
+ await exchangeOne.addBankAccount("1", exchangeBankAccount);
+ await exchangeTwo.addBankAccount("1", exchangeBankAccount);
+
+ // Same anyway.
+ bank.setSuggestedExchange(exchangeOne, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ exchangeOne.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS")));
+ exchangeTwo.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS")));
+
+ // Only start first exchange.
+ await exchangeOne.start();
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "wallet",
+ persistent: true,
+ });
+
+ // Since the default exchanges can change, we start the wallet in tests
+ // with no built-in defaults. Thus the list of exchanges is empty here.
+ const exchangesListResult = await walletClient.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+
+ t.assertDeepEqual(exchangesListResult.exchanges.length, 0);
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange: exchangeOne,
+ amount: "TESTKUDOS:10",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ await exchangeOne.stop();
+
+ console.log("starting second exchange");
+ await exchangeTwo.start();
+
+ console.log("updating exchange entry");
+
+ await t.assertThrowsAsync(async () => {
+ await walletClient.call(WalletApiOperation.UpdateExchangeEntry, {
+ exchangeBaseUrl: exchangeOne.baseUrl,
+ force: true,
+ });
+ });
+
+ const exchangeEntry = await walletClient.call(
+ WalletApiOperation.GetExchangeEntryByUrl,
+ {
+ exchangeBaseUrl: exchangeOne.baseUrl,
+ },
+ );
+
+ console.log(`exchange entry: ${j2s(exchangeEntry)}`);
+
+ await t.assertThrowsAsync(async () => {
+ await walletClient.call(WalletApiOperation.GetWithdrawalDetailsForAmount, {
+ amount: "TESTKUDOS:10" as AmountString,
+ exchangeBaseUrl: exchangeOne.baseUrl,
+ });
+ });
+
+ const exchangeAvailableCond = walletClient.waitForNotificationCond((n) => {
+ console.log(`got notif ${j2s(n)}`);
+ return (
+ n.type === NotificationType.ExchangeStateTransition &&
+ n.newExchangeState.exchangeUpdateStatus === ExchangeUpdateStatus.Ready
+ );
+ });
+
+ await exchangeTwo.stop();
+
+ console.log("starting first exchange");
+ await exchangeOne.start();
+
+ await exchangeAvailableCond;
+}
+
+runWalletExchangeUpdateTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-gendb.ts b/packages/taler-harness/src/integrationtests/test-wallet-gendb.ts
new file mode 100644
index 000000000..9e3b60899
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-gendb.ts
@@ -0,0 +1,111 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+ makeTestPaymentV2,
+} from "../harness/helpers.js";
+import {
+ AbsoluteTime,
+ AmountString,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ j2s,
+} from "@gnu-taler/taler-util";
+
+/**
+ * Test that creates various transactions and exports the resulting
+ * database. Used to generate a database export file for DB compatibility
+ * testing.
+ */
+export async function runWalletGenDbTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:50",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:10",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const purseExpiration = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 2 }),
+ ),
+ );
+
+ const peerPullIniResp = await walletClient.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "Hello World",
+ amount: "TESTKUDOS:5" as AmountString,
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+
+ const peerPullCreditReadyCond = walletClient.waitForNotificationCond(
+ (x) =>
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === peerPullIniResp.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.Ready,
+ );
+
+ await peerPullCreditReadyCond;
+
+ const checkResp = await walletClient.call(
+ WalletApiOperation.PreparePeerPullDebit,
+ {
+ talerUri: peerPullIniResp.talerUri,
+ },
+ );
+
+ await walletClient.call(WalletApiOperation.ConfirmPeerPullDebit, {
+ transactionId: checkResp.transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletGenDbTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts b/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts
new file mode 100644
index 000000000..ac1244446
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts
@@ -0,0 +1,166 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ Duration,
+ PaymentInsufficientBalanceDetails,
+ TalerErrorCode,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ MerchantService,
+ WalletClient,
+ WalletService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import { withdrawViaBankV2 } from "../harness/helpers.js";
+
+export async function runWalletInsufficientBalanceTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchangeBankAccount.skipWireFeeCreation = true;
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const allNotifications: WalletNotification[] = [];
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ allNotifications.push(n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ },
+ },
+ });
+
+ const wres = await withdrawViaBankV2(t, {
+ amount: "TESTKUDOS:10",
+ bank,
+ exchange,
+ walletClient,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const exc = await t.assertThrowsTalerErrorAsync(async () => {
+ await walletClient.call(WalletApiOperation.PrepareDeposit, {
+ amount: "TESTKUDOS:5" as AmountString,
+ depositPaytoUri: "payto://x-taler-bank/localhost/foobar",
+ });
+ });
+ t.assertDeepEqual(
+ exc.errorDetail.code,
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ );
+ const insufficientBalanceDetails: PaymentInsufficientBalanceDetails =
+ exc.errorDetail.insufficientBalanceDetails;
+
+ t.assertAmountEquals(
+ insufficientBalanceDetails.balanceAvailable,
+ "TESTKUDOS:9.72",
+ );
+ t.assertAmountEquals(
+ insufficientBalanceDetails.balanceExchangeDepositable,
+ "TESTKUDOS:0",
+ );
+}
+
+runWalletInsufficientBalanceTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-notifications.ts b/packages/taler-harness/src/integrationtests/test-wallet-notifications.ts
new file mode 100644
index 000000000..28b73a9f9
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-notifications.ts
@@ -0,0 +1,176 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ TalerCorebankApiClient,
+ Duration,
+ NotificationType,
+ TransactionMajorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ MerchantService,
+ WalletClient,
+ WalletService,
+ generateRandomTestIban,
+ setupDb,
+} from "../harness/harness.js";
+
+/**
+ * Test for wallet-core notifications.
+ */
+export async function runWalletNotificationsTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ // Fakebank uses x-taler-bank, but merchant is configured to only accept sepa!
+ const label = "mymerchant";
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [
+ `payto://iban/SANDBOXX/${generateRandomTestIban(label)}?receiver-name=${label}`,
+ ],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ console.log("setup done!");
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ }
+ }
+ });
+
+ const bankAccessApiClient = new TalerCorebankApiClient(
+ bank.corebankApiBaseUrl,
+ );
+ const user = await bankAccessApiClient.createRandomBankUser();
+ bankAccessApiClient.setAuth(user);
+ const wop = await bankAccessApiClient.createWithdrawalOperation(
+ user.username,
+ "TESTKUDOS:20",
+ );
+
+ // Hand it to the wallet
+
+ await walletClient.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ // Withdraw (AKA select)
+
+ const acceptRes = await walletClient.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ const withdrawalFinishedReceivedPromise =
+ walletClient.waitForNotificationCond((x) => {
+ return (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.newTxState.major === TransactionMajorState.Done &&
+ x.transactionId === acceptRes.transactionId
+ );
+ });
+
+ // Confirm it
+
+ await bankAccessApiClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ await withdrawalFinishedReceivedPromise;
+}
+
+runWalletNotificationsTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-observability.ts b/packages/taler-harness/src/integrationtests/test-wallet-observability.ts
new file mode 100644
index 000000000..5dff8670e
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-observability.ts
@@ -0,0 +1,119 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { NotificationType, WalletNotification } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ ExchangeService,
+ FakebankService,
+ GlobalTestState,
+ WalletClient,
+ WalletService,
+ setupDb,
+} from "../harness/harness.js";
+import { withdrawViaBankV2 } from "../harness/helpers.js";
+
+export async function runWalletObservabilityTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ const walletService = new WalletService(t, {
+ name: "wallet",
+ useInMemoryDb: true,
+ });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const allNotifications: WalletNotification[] = [];
+
+ const walletClient = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ onNotification(n) {
+ console.log("got notification", n);
+ allNotifications.push(n);
+ },
+ });
+ await walletClient.connect();
+ await walletClient.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ testing: {
+ skipDefaults: true,
+ emitObservabilityEvents: true,
+ },
+ },
+ });
+
+ const wres = await withdrawViaBankV2(t, {
+ amount: "TESTKUDOS:10",
+ bank,
+ exchange,
+ walletClient,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const requestObsEvents = allNotifications.filter(
+ (x) => x.type === NotificationType.RequestObservabilityEvent,
+ );
+ const taskObsEvents = allNotifications.filter(
+ (x) => x.type === NotificationType.TaskObservabilityEvent,
+ );
+
+ console.log(`req events: ${requestObsEvents.length}`);
+ console.log(`task events: ${taskObsEvents.length}`);
+
+ t.assertTrue(requestObsEvents.length > 30);
+ t.assertTrue(taskObsEvents.length > 30);
+}
+
+runWalletObservabilityTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts b/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts
new file mode 100644
index 000000000..0f1efd35e
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-refresh-errors.ts
@@ -0,0 +1,107 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AmountString } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import { GlobalTestState } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+const coinCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ feeDeposit: "TESTKUDOS:0",
+ feeRefresh: "TESTKUDOS:0",
+ feeRefund: "TESTKUDOS:0",
+ feeWithdraw: "TESTKUDOS:0",
+ rsaKeySize: 1024,
+};
+
+/**
+ * Run test for refreshe after a payment.
+ */
+export async function runWalletRefreshErrorsTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const coinConfigList: CoinConfig[] = [
+ {
+ ...coinCommon,
+ name: "n1",
+ value: "TESTKUDOS:1",
+ },
+ {
+ ...coinCommon,
+ name: "n5",
+ value: "TESTKUDOS:5",
+ },
+ ];
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t, coinConfigList);
+
+ const wres = await withdrawViaBankV2(t, {
+ amount: "TESTKUDOS:5",
+ bank,
+ exchange,
+ walletClient,
+ });
+ await wres.withdrawalFinishedCond;
+
+ const backupResp = await walletClient.call(
+ WalletApiOperation.CreateStoredBackup,
+ {},
+ );
+
+ const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {});
+
+ t.assertDeepEqual(coinDump.coins.length, 1);
+
+ await walletClient.call(WalletApiOperation.ForceRefresh, {
+ refreshCoinSpecs: [
+ {
+ coinPub: coinDump.coins[0].coin_pub,
+ amount: "TESTKUDOS:3" as AmountString,
+ },
+ ],
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ await walletClient.call(WalletApiOperation.RecoverStoredBackup, {
+ name: backupResp.name,
+ });
+
+ await walletClient.call(WalletApiOperation.ForceRefresh, {
+ refreshCoinSpecs: [
+ {
+ coinPub: coinDump.coins[0].coin_pub,
+ amount: "TESTKUDOS:3" as AmountString,
+ },
+ ],
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+}
+
+runWalletRefreshErrorsTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh.ts b/packages/taler-harness/src/integrationtests/test-wallet-refresh.ts
new file mode 100644
index 000000000..f1c544a4e
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-refresh.ts
@@ -0,0 +1,200 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ NotificationType,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import {
+ WalletApiOperation,
+ parseTransactionIdentifier,
+} from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState, generateRandomPayto } from "../harness/harness.js";
+import {
+ createSimpleTestkudosEnvironmentV2,
+ makeTestPaymentV2,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Run test for refreshe after a payment.
+ */
+export async function runWalletRefreshTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, merchant } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ };
+
+ await makeTestPaymentV2(t, { walletClient, merchant, order });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const txns = await walletClient.call(WalletApiOperation.GetTransactions, {
+ includeRefreshes: true,
+ });
+
+ console.log(j2s(txns));
+
+ t.assertDeepEqual(txns.transactions.length, 3);
+
+ const refreshListTx = txns.transactions.find(
+ (x) => x.type === TransactionType.Refresh,
+ );
+
+ t.assertTrue(!!refreshListTx);
+
+ const refreshTx = await walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: refreshListTx.transactionId,
+ },
+ );
+
+ t.assertDeepEqual(refreshTx.type, TransactionType.Refresh);
+
+ // Now we test a pending refresh operation.
+ {
+ await exchange.stop();
+
+ const refreshCreatedCond = walletClient.waitForNotificationCond((x) => {
+ if (
+ x.type === NotificationType.TransactionStateTransition &&
+ parseTransactionIdentifier(x.transactionId)?.tag ===
+ TransactionType.Refresh
+ ) {
+ return true;
+ }
+ return false;
+ });
+
+ const refreshDoneCond = walletClient.waitForNotificationCond((x) => {
+ if (
+ x.type === NotificationType.TransactionStateTransition &&
+ parseTransactionIdentifier(x.transactionId)?.tag ===
+ TransactionType.Refresh &&
+ x.newTxState.major === TransactionMajorState.Done
+ ) {
+ return true;
+ }
+ return false;
+ });
+
+ const depositGroupResult = await walletClient.client.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:10.5" as AmountString,
+ depositPaytoUri: generateRandomPayto("foo"),
+ },
+ );
+
+ await refreshCreatedCond;
+
+ // Here, the refresh operation should be in a pending state.
+
+ const bal1 = await walletClient.call(WalletApiOperation.GetBalances, {});
+
+ await exchange.start();
+
+ await refreshDoneCond;
+
+ const bal2 = await walletClient.call(WalletApiOperation.GetBalances, {});
+
+ // The refresh operation completing should not change the available balance,
+ // as we're accounting pending refreshes towards the available (but not material!) balance.
+ t.assertAmountEquals(
+ bal1.balances[0].available,
+ bal2.balances[0].available,
+ );
+ }
+
+ const wres = await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await wres.withdrawalFinishedCond;
+
+ // Test failing a refresh transaction
+ {
+ await exchange.stop();
+
+ let refreshTransactionId: TransactionIdStr | undefined = undefined;
+
+ const refreshCreatedCond = walletClient.waitForNotificationCond((x) => {
+ if (
+ x.type === NotificationType.TransactionStateTransition &&
+ parseTransactionIdentifier(x.transactionId)?.tag ===
+ TransactionType.Refresh
+ ) {
+ refreshTransactionId = x.transactionId as TransactionIdStr;
+ return true;
+ }
+ return false;
+ });
+
+ const depositGroupResult = await walletClient.client.call(
+ WalletApiOperation.CreateDepositGroup,
+ {
+ amount: "TESTKUDOS:10.5" as AmountString,
+ depositPaytoUri: generateRandomPayto("foo"),
+ },
+ );
+
+ await refreshCreatedCond;
+
+ t.assertTrue(!!refreshTransactionId);
+
+ await walletClient.call(WalletApiOperation.FailTransaction, {
+ transactionId: refreshTransactionId,
+ });
+
+ const txn = await walletClient.call(WalletApiOperation.GetTransactionById, {
+ transactionId: refreshTransactionId,
+ });
+
+ t.assertDeepEqual(txn.type, TransactionType.Refresh);
+ t.assertDeepEqual(txn.txState.major, TransactionMajorState.Failed);
+
+ t.assertTrue(!!refreshTransactionId);
+ }
+}
+
+runWalletRefreshTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-wirefees.ts b/packages/taler-harness/src/integrationtests/test-wallet-wirefees.ts
new file mode 100644
index 000000000..1bf9bd659
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-wallet-wirefees.ts
@@ -0,0 +1,184 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ Duration,
+ MerchantApiClient,
+ MerchantContractTerms,
+ PreparePayResultType,
+ TransactionMajorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import {
+ createWalletDaemonWithClient,
+ withdrawViaBankV2,
+} from "../harness/helpers.js";
+
+/**
+ * Test payment where the exchange charges wire fees.
+ */
+export async function runWalletWirefeesTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ // Ridiculously high wire fees!
+ overrideWireFee: "TESTKUDOS:5",
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet", persistent: true },
+ );
+
+ console.log("setup done!");
+
+ // Withdraw digital cash into the wallet.
+
+ await withdrawViaBankV2(t, {
+ walletClient,
+ bank,
+ exchange,
+ amount: "TESTKUDOS:20",
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const order = {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:1",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ //max_wire_fee: "TESTKUDOS:0.1",
+ max_fee: "TESTKUDOS:0.1",
+ } satisfies Partial<MerchantContractTerms>;
+
+ const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl());
+
+ const orderResp = await merchantClient.createOrder({
+ order,
+ });
+
+ let orderStatus = await merchantClient.queryPrivateOrderStatus({
+ orderId: orderResp.order_id,
+ });
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ // Make wallet pay for the order
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ t.assertTrue(
+ preparePayResult.status === PreparePayResultType.PaymentPossible,
+ );
+
+ console.log(`amountEffective: ${preparePayResult.amountEffective}`);
+
+ t.assertAmountEquals(preparePayResult.amountEffective, "TESTKUDOS:6.4");
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ const payTxn = await walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: preparePayResult.transactionId,
+ },
+ );
+
+ t.assertTrue(payTxn.txState.major === TransactionMajorState.Done);
+}
+
+runWalletWirefeesTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts b/packages/taler-harness/src/integrationtests/test-wallettesting.ts
index c21a7279b..932284d62 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts
+++ b/packages/taler-harness/src/integrationtests/test-wallettesting.ts
@@ -22,7 +22,7 @@
/**
* Imports.
*/
-import { Amounts } from "@gnu-taler/taler-util";
+import { AmountString, Amounts, CoinStatus } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
import {
@@ -31,9 +31,12 @@ import {
GlobalTestState,
MerchantService,
setupDb,
- WalletCli,
+ generateRandomPayto,
} from "../harness/harness.js";
-import { SimpleTestEnvironment } from "../harness/helpers.js";
+import {
+ SimpleTestEnvironmentNg,
+ createWalletDaemonWithClient,
+} from "../harness/helpers.js";
const merchantAuthToken = "secret-token:sandbox";
@@ -44,7 +47,7 @@ const merchantAuthToken = "secret-token:sandbox";
export async function createMyEnvironment(
t: GlobalTestState,
coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
-): Promise<SimpleTestEnvironment> {
+): Promise<SimpleTestEnvironmentNg> {
const db = await setupDb(t);
const bank = await BankService.create(t, {
@@ -69,7 +72,7 @@ export async function createMyEnvironment(
});
const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
+ "myexchange",
"x",
);
exchange.addBankAccount("1", exchangeBankAccount);
@@ -90,21 +93,27 @@ export async function createMyEnvironment(
await merchant.start();
await merchant.pingUntilAvailable();
- await merchant.addInstance({
+ await merchant.addInstanceWithWireAccount({
id: "default",
name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
+ paytoUris: [generateRandomPayto("merchant-default")],
});
console.log("setup done!");
- const wallet = new WalletCli(t);
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ {
+ name: "w1",
+ },
+ );
return {
commonDb: db,
exchange,
merchant,
- wallet,
+ walletClient,
+ walletService,
bank,
exchangeBankAccount,
};
@@ -114,18 +123,19 @@ export async function createMyEnvironment(
* Run test for basic, bank-integrated withdrawal.
*/
export async function runWallettestingTest(t: GlobalTestState) {
- const { wallet, bank, exchange, merchant } = await createMyEnvironment(t);
+ const { walletClient, bank, exchange, merchant } =
+ await createMyEnvironment(t);
- await wallet.client.call(WalletApiOperation.RunIntegrationTest, {
- amountToSpend: "TESTKUDOS:5",
- amountToWithdraw: "TESTKUDOS:10",
- bankBaseUrl: bank.baseUrl,
+ await walletClient.call(WalletApiOperation.RunIntegrationTest, {
+ amountToSpend: "TESTKUDOS:5" as AmountString,
+ amountToWithdraw: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
exchangeBaseUrl: exchange.baseUrl,
merchantAuthToken: merchantAuthToken,
merchantBaseUrl: merchant.makeInstanceBaseUrl(),
});
- let txns = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ let txns = await walletClient.call(WalletApiOperation.GetTransactions, {});
console.log(JSON.stringify(txns, undefined, 2));
let txTypes = txns.transactions.map((x) => x.type);
@@ -138,49 +148,52 @@ export async function runWallettestingTest(t: GlobalTestState) {
"payment",
]);
- wallet.deleteDatabase();
+ await walletClient.call(WalletApiOperation.ClearDb, {});
- await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
- amount: "TESTKUDOS:10",
- bankBaseUrl: bank.baseUrl,
+ await walletClient.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
exchangeBaseUrl: exchange.baseUrl,
});
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
- await wallet.client.call(WalletApiOperation.TestPay, {
- amount: "TESTKUDOS:5",
+ await walletClient.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:5" as AmountString,
merchantAuthToken: merchantAuthToken,
merchantBaseUrl: merchant.makeInstanceBaseUrl(),
summary: "foo",
});
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
- txns = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ txns = await walletClient.call(WalletApiOperation.GetTransactions, {});
console.log(JSON.stringify(txns, undefined, 2));
txTypes = txns.transactions.map((x) => x.type);
t.assertDeepEqual(txTypes, ["withdrawal", "payment"]);
- wallet.deleteDatabase();
+ await walletClient.call(WalletApiOperation.ClearDb, {});
- await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
- amount: "TESTKUDOS:10",
- bankBaseUrl: bank.baseUrl,
+ await walletClient.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
exchangeBaseUrl: exchange.baseUrl,
});
- await wallet.runUntilDone();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
- const coinDump = await wallet.client.call(WalletApiOperation.DumpCoins, {});
+ const coinDump = await walletClient.call(WalletApiOperation.DumpCoins, {});
console.log("coin dump:", JSON.stringify(coinDump, undefined, 2));
let susp: string | undefined;
{
for (const c of coinDump.coins) {
- if (0 === Amounts.cmp(c.remaining_value, "TESTKUDOS:8")) {
+ if (
+ c.coin_status === CoinStatus.Fresh &&
+ 0 === Amounts.cmp(c.denom_value, "TESTKUDOS:8")
+ ) {
susp = c.coin_pub;
}
}
@@ -190,7 +203,7 @@ export async function runWallettestingTest(t: GlobalTestState) {
console.log("suspending coin");
- await wallet.client.call(WalletApiOperation.SetCoinSuspended, {
+ await walletClient.call(WalletApiOperation.SetCoinSuspended, {
coinPub: susp,
suspended: true,
});
@@ -198,8 +211,8 @@ export async function runWallettestingTest(t: GlobalTestState) {
// This should fail, as we've suspended a coin that we need
// to pay.
await t.assertThrowsAsync(async () => {
- await wallet.client.call(WalletApiOperation.TestPay, {
- amount: "TESTKUDOS:5",
+ await walletClient.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:5" as AmountString,
merchantAuthToken: merchantAuthToken,
merchantBaseUrl: merchant.makeInstanceBaseUrl(),
summary: "foo",
@@ -208,19 +221,27 @@ export async function runWallettestingTest(t: GlobalTestState) {
console.log("unsuspending coin");
- await wallet.client.call(WalletApiOperation.SetCoinSuspended, {
+ await walletClient.call(WalletApiOperation.SetCoinSuspended, {
coinPub: susp,
suspended: false,
});
- await wallet.client.call(WalletApiOperation.TestPay, {
- amount: "TESTKUDOS:5",
+ await walletClient.call(WalletApiOperation.TestPay, {
+ amount: "TESTKUDOS:5" as AmountString,
merchantAuthToken: merchantAuthToken,
merchantBaseUrl: merchant.makeInstanceBaseUrl(),
summary: "foo",
});
- await t.shutdown();
+ await walletClient.call(WalletApiOperation.ClearDb, {});
+ await walletClient.call(WalletApiOperation.RunIntegrationTestV2, {
+ amountToSpend: "TESTKUDOS:5" as AmountString,
+ amountToWithdraw: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ merchantAuthToken: merchantAuthToken,
+ merchantBaseUrl: merchant.makeInstanceBaseUrl(),
+ });
}
runWallettestingTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-abort-bank.ts
index fe719ea62..39389e3c6 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-abort-bank.ts
@@ -17,10 +17,10 @@
/**
* Imports.
*/
-import { TalerErrorCode } from "@gnu-taler/taler-util";
+import { TalerCorebankApiClient, TalerErrorCode } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { GlobalTestState, BankApi, BankAccessApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
/**
* Run test for basic, bank-integrated withdrawal.
@@ -28,42 +28,49 @@ import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
export async function runWithdrawalAbortBankTest(t: GlobalTestState) {
// Set up test environment
- const { wallet, bank, exchange } = await createSimpleTestkudosEnvironment(t);
+ const { walletClient, bank, exchange } =
+ await createSimpleTestkudosEnvironmentV2(t);
// Create a withdrawal operation
- const user = await BankApi.createRandomBankUser(bank);
- const wop = await BankAccessApi.createWithdrawalOperation(
- bank,
- user,
+ const bankAccessApiClient = new TalerCorebankApiClient(
+ bank.corebankApiBaseUrl,
+ );
+ const user = await bankAccessApiClient.createRandomBankUser();
+ bankAccessApiClient.setAuth(user);
+ const wop = await bankAccessApiClient.createWithdrawalOperation(
+ user.username,
"TESTKUDOS:10",
);
// Hand it to the wallet
- await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
+ await walletClient.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
talerWithdrawUri: wop.taler_withdraw_uri,
});
- await wallet.runPending();
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
- // Confirm it
+ // Abort it
- await BankApi.abortWithdrawalOperation(bank, user, wop);
+ await bankAccessApiClient.abortWithdrawalOperation(wop);
// Withdraw
- const e = await t.assertThrowsOperationErrorAsync(async () => {
- await wallet.client.call(
- WalletApiOperation.AcceptBankIntegratedWithdrawal,
- {
- exchangeBaseUrl: exchange.baseUrl,
- talerWithdrawUri: wop.taler_withdraw_uri,
- },
- );
+ // Difference:
+ // -> with euFin, the wallet selects
+ // -> with PyBank, the wallet stops _before_
+ //
+ // WHY ?!
+ //
+ const e = await t.assertThrowsTalerErrorAsync(async () => {
+ await walletClient.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
});
t.assertDeepEqual(
- e.operationError.code,
+ e.errorDetail.code,
TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
);
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts
new file mode 100644
index 000000000..76dec50d3
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts
@@ -0,0 +1,192 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ TalerCorebankApiClient,
+ j2s,
+ NotificationType,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ WithdrawalType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Create a withdrawal operation
+
+ const corebankApiClient = new TalerCorebankApiClient(
+ bank.corebankApiBaseUrl,
+ );
+ const user = await corebankApiClient.createRandomBankUser();
+ corebankApiClient.setAuth(user);
+ const wop = await corebankApiClient.createWithdrawalOperation(
+ user.username,
+ "TESTKUDOS:10",
+ );
+
+ // Hand it to the wallet
+
+ const r1 = await walletClient.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ // Withdraw
+
+ const r2 = await walletClient.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ const withdrawalBankConfirmedCond = walletClient.waitForNotificationCond(
+ (x) => {
+ return (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === r2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.ExchangeWaitReserve
+ );
+ },
+ );
+
+ const withdrawalFinishedCond = walletClient.waitForNotificationCond((x) => {
+ return (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === r2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Done
+ );
+ });
+
+ const withdrawalReserveReadyCond = walletClient.waitForNotificationCond(
+ (x) => {
+ return (
+ x.type === NotificationType.TransactionStateTransition &&
+ x.transactionId === r2.transactionId &&
+ x.newTxState.major === TransactionMajorState.Pending &&
+ x.newTxState.minor === TransactionMinorState.WithdrawCoins
+ );
+ },
+ );
+
+ // Do it twice to check idempotency
+ const r3 = await walletClient.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ await exchange.stopWirewatch();
+
+ // Check status before withdrawal is confirmed by bank.
+ {
+ const txn = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log("transactions before confirmation:", j2s(txn));
+ const tx0 = txn.transactions[0];
+ t.assertTrue(tx0.type === TransactionType.Withdrawal);
+ t.assertTrue(
+ tx0.withdrawalDetails.type === WithdrawalType.TalerBankIntegrationApi,
+ );
+ t.assertTrue(tx0.withdrawalDetails.confirmed === false);
+ t.assertTrue(tx0.withdrawalDetails.reserveIsReady === false);
+ }
+
+ // Confirm it
+
+ await corebankApiClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+
+ await withdrawalBankConfirmedCond;
+
+ // Check status after withdrawal is confirmed by bank,
+ // but before funds are wired to the exchange.
+ {
+ const txn = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log("transactions after confirmation:", j2s(txn));
+ const tx0 = txn.transactions[0];
+ t.assertTrue(tx0.type === TransactionType.Withdrawal);
+ t.assertTrue(
+ tx0.withdrawalDetails.type === WithdrawalType.TalerBankIntegrationApi,
+ );
+ t.assertTrue(tx0.withdrawalDetails.confirmed === true);
+ t.assertTrue(tx0.withdrawalDetails.reserveIsReady === false);
+ }
+
+ await exchange.startWirewatch();
+
+ await withdrawalReserveReadyCond;
+
+ // Check status after funds were wired.
+ {
+ const txn = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log("transactions after reserve ready:", j2s(txn));
+ const tx0 = txn.transactions[0];
+ t.assertTrue(tx0.type === TransactionType.Withdrawal);
+ t.assertTrue(
+ tx0.withdrawalDetails.type === WithdrawalType.TalerBankIntegrationApi,
+ );
+ t.assertTrue(tx0.withdrawalDetails.confirmed === true);
+ t.assertTrue(tx0.withdrawalDetails.reserveIsReady === true);
+ }
+
+ await withdrawalFinishedCond;
+
+ // Check balance
+
+ const balResp = await walletClient.client.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+
+ const txn = await walletClient.client.call(
+ WalletApiOperation.GetTransactions,
+ {},
+ );
+ console.log(`transactions: ${j2s(txn)}`);
+}
+
+runWithdrawalBankIntegratedTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts
new file mode 100644
index 000000000..8351e5251
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts
@@ -0,0 +1,304 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Amounts,
+ Duration,
+ Logger,
+ TalerBankConversionApi,
+ TalerCorebankApiClient,
+ TransactionType,
+ WireGatewayApiClient,
+ WithdrawalType,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import * as http from "node:http";
+import { defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ MerchantService,
+ generateRandomPayto,
+ setupDb,
+} from "../harness/harness.js";
+import { createWalletDaemonWithClient } from "../harness/helpers.js";
+
+const logger = new Logger("test-withdrawal-conversion.ts");
+
+interface TestfakeConversionService {
+ stop: () => void;
+}
+
+function splitInTwoAt(s: string, separator: string): [string, string] {
+ const idx = s.indexOf(separator);
+ if (idx === -1) {
+ return [s, ""];
+ }
+ return [s.slice(0, idx), s.slice(idx + 1)];
+}
+
+/**
+ * Testfake for the kyc service that the exchange talks to.
+ */
+async function runTestfakeConversionService(): Promise<TestfakeConversionService> {
+ const server = http.createServer((req, res) => {
+ const requestUrl = req.url!;
+ logger.info(`kyc: got ${req.method} request, ${requestUrl}`);
+
+ const [path, query] = splitInTwoAt(requestUrl, "?");
+
+ const qp = new URLSearchParams(query);
+
+ if (path === "/config") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ version: "0:0:0",
+ name: "taler-conversion-info",
+ regional_currency: "FOO",
+ fiat_currency: "BAR",
+ regional_currency_specification: {
+ alt_unit_names: {},
+ name: "FOO",
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ },
+ fiat_currency_specification: {
+ alt_unit_names: {},
+ name: "BAR",
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ },
+ conversion_rate: {
+ cashin_fee: "A:1" as AmountString,
+ cashin_min_amount: "A:0.1" as AmountString,
+ cashin_ratio: "1",
+ cashin_rounding_mode: "zero",
+ cashin_tiny_amount: "A:1" as AmountString,
+ cashout_fee: "A:1" as AmountString,
+ cashout_min_amount: "A:0.1" as AmountString,
+ cashout_ratio: "1",
+ cashout_rounding_mode: "zero",
+ cashout_tiny_amount: "A:1" as AmountString,
+ }
+ } satisfies TalerBankConversionApi.IntegrationConfig),
+ );
+ } else if (path === "/cashin-rate") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ amount_debit: "FOO:123",
+ amount_credit: "BAR:123",
+ }),
+ );
+ } else {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ code: 1, message: "bad request" }));
+ }
+ });
+ await new Promise<void>((resolve, reject) => {
+ server.listen(8071, () => resolve());
+ });
+ return {
+ stop() {
+ server.close();
+ },
+ };
+}
+
+/**
+ * Test for currency conversion during manual withdrawal.
+ */
+export async function runWithdrawalConversionTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const merchant = await MerchantService.create(t, {
+ name: "testmerchant-1",
+ currency: "TESTKUDOS",
+ httpPort: 8083,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ exchangeBankAccount.conversionUrl = "http://localhost:8071/";
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ exchange.addOfferedCoins(defaultCoinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ merchant.addExchange(exchange);
+ await merchant.start();
+ await merchant.pingUntilAvailable();
+
+ await merchant.addInstanceWithWireAccount({
+ id: "default",
+ name: "Default Instance",
+ paytoUris: [generateRandomPayto("merchant-default")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ await merchant.addInstanceWithWireAccount({
+ id: "minst1",
+ name: "minst1",
+ paytoUris: [generateRandomPayto("minst1")],
+ defaultWireTransferDelay: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ minutes: 1 }),
+ ),
+ });
+
+ const { walletClient, walletService } = await createWalletDaemonWithClient(
+ t,
+ { name: "wallet" },
+ );
+
+ await runTestfakeConversionService();
+
+ // Create a withdrawal operation
+
+ const bankAccessApiClient = new TalerCorebankApiClient(
+ bank.corebankApiBaseUrl,
+ );
+
+ const user = await bankAccessApiClient.createRandomBankUser();
+
+ await walletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const infoRes = await walletClient.call(
+ WalletApiOperation.GetWithdrawalDetailsForAmount,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:20" as AmountString,
+ },
+ );
+
+ console.log(`withdrawal details: ${j2s(infoRes)}`);
+
+ const checkTransferAmount = infoRes.withdrawalAccountsList[0].transferAmount;
+ t.assertTrue(checkTransferAmount != null);
+ t.assertAmountEquals(checkTransferAmount, "FOO:123");
+
+ const tStart = AbsoluteTime.now();
+
+ logger.info("starting AcceptManualWithdrawal request");
+ // We expect this to return immediately.
+
+ const wres = await walletClient.call(
+ WalletApiOperation.AcceptManualWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10" as AmountString,
+ },
+ );
+
+ logger.info("AcceptManualWithdrawal finished");
+ logger.info(`result: ${j2s(wres)}`);
+
+ const acceptedTransferAmount = wres.withdrawalAccountsList[0].transferAmount;
+ t.assertTrue(acceptedTransferAmount != null);
+
+ t.assertAmountEquals(acceptedTransferAmount, "FOO:123");
+
+ const txInfo = await walletClient.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: wres.transactionId,
+ },
+ );
+
+ t.assertDeepEqual(txInfo.type, TransactionType.Withdrawal);
+ t.assertDeepEqual(
+ txInfo.withdrawalDetails.type,
+ WithdrawalType.ManualTransfer,
+ );
+ t.assertTrue(!!txInfo.withdrawalDetails.exchangeCreditAccountDetails);
+ t.assertDeepEqual(
+ txInfo.withdrawalDetails.exchangeCreditAccountDetails[0].transferAmount,
+ "FOO:123",
+ );
+
+ // Check that the request did not go into long-polling.
+ const duration = AbsoluteTime.difference(tStart, AbsoluteTime.now());
+ if (typeof duration.d_ms !== "number" || duration.d_ms > 5 * 1000) {
+ throw Error("withdrawal took too long (longpolling issue)");
+ }
+
+ const reservePub: string = wres.reservePub;
+
+ const wireGatewayApiClient = new WireGatewayApiClient(
+ exchangeBankAccount.wireGatewayApiBaseUrl,
+ {
+ auth: {
+ username: exchangeBankAccount.accountName,
+ password: exchangeBankAccount.accountPassword,
+ },
+ },
+ );
+
+ await wireGatewayApiClient.adminAddIncoming({
+ amount: "TESTKUDOS:10",
+ debitAccountPayto: user.accountPaytoUri,
+ reservePub: reservePub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Check balance
+
+ const balResp = await walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+}
+
+runWithdrawalConversionTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-fakebank.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts
index 97beba1bf..1dc955649 100644
--- a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-fakebank.ts
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts
@@ -17,30 +17,31 @@
/**
* Imports.
*/
+import { AmountString, URL } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
import {
+ ExchangeService,
+ FakebankService,
GlobalTestState,
- BankApi,
WalletCli,
setupDb,
- ExchangeService,
- FakeBankService,
} from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
-import { URL } from "@gnu-taler/taler-util";
/**
* Run test for basic, bank-integrated withdrawal.
*/
-export async function runTestWithdrawalFakebankTest(t: GlobalTestState) {
+export async function runWithdrawalFakebankTest(t: GlobalTestState) {
// Set up test environment
const db = await setupDb(t);
- const bank = await FakeBankService.create(t, {
+ const bank = await FakebankService.create(t, {
currency: "TESTKUDOS",
httpPort: 8082,
+ allowRegistrations: true,
+ // Not used by fakebank
+ database: db.connStr,
});
const exchange = ExchangeService.create(t, {
@@ -53,10 +54,16 @@ export async function runTestWithdrawalFakebankTest(t: GlobalTestState) {
exchange.addBankAccount("1", {
accountName: "exchange",
accountPassword: "x",
- wireGatewayApiBaseUrl: new URL("/exchange/", bank.baseUrl).href,
- accountPaytoUri: "payto://x-taler-bank/localhost/exchange",
+ wireGatewayApiBaseUrl: new URL(
+ "/accounts/exchange/taler-wire-gateway/",
+ bank.baseUrl,
+ ).href,
+ accountPaytoUri:
+ "payto://x-taler-bank/localhost/exchange?receiver-name=Exchange",
});
+ await bank.createExchangeAccount("exchange", "x");
+
await bank.start();
await bank.pingUntilAvailable();
@@ -75,10 +82,10 @@ export async function runTestWithdrawalFakebankTest(t: GlobalTestState) {
exchangeBaseUrl: exchange.baseUrl,
});
- await wallet.client.call(WalletApiOperation.WithdrawFakebank, {
- exchange: exchange.baseUrl,
- amount: "TESTKUDOS:10",
- bank: bank.baseUrl,
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ corebankApiBaseUrl: bank.corebankApiBaseUrl,
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10" as AmountString,
});
await exchange.runWirewatchOnce();
@@ -89,8 +96,6 @@ export async function runTestWithdrawalFakebankTest(t: GlobalTestState) {
const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
-
- await t.shutdown();
}
-runTestWithdrawalFakebankTest.suites = ["wallet"];
+runWithdrawalFakebankTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts
new file mode 100644
index 000000000..f702376e1
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts
@@ -0,0 +1,171 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { TalerCorebankApiClient, j2s } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig } from "../harness/denomStructures.js";
+import {
+ BankService,
+ ExchangeService,
+ GlobalTestState,
+ WalletCli,
+ setupDb,
+} from "../harness/harness.js";
+
+const coinRsaCommon = {
+ cipher: "RSA" as const,
+ durationLegal: "3 years",
+ durationSpend: "2 years",
+ durationWithdraw: "7 days",
+ rsaKeySize: 1024,
+};
+
+const coin_u1 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_u1`,
+ value: `${curr}:1`,
+ feeDeposit: `${curr}:0`,
+ feeRefresh: `${curr}:0`,
+ feeRefund: `${curr}:0`,
+ feeWithdraw: `${curr}:1`,
+});
+
+const coin_u5 = (curr: string): CoinConfig => ({
+ ...coinRsaCommon,
+ name: `${curr}_u5`,
+ value: `${curr}:5`,
+ feeDeposit: `${curr}:0`,
+ feeRefresh: `${curr}:0`,
+ feeRefund: `${curr}:0`,
+ feeWithdraw: `${curr}:1`,
+});
+
+export const weirdCoinConfig = [coin_u1, coin_u5];
+
+/**
+ * Test withdrawal with a weird denomination structure to
+ * make sure fees are computed as expected.
+ */
+export async function runWithdrawalFeesTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await BankService.create(t, {
+ allowRegistrations: true,
+ currency: "TESTKUDOS",
+ database: db.connStr,
+ httpPort: 8082,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ const exchangeBankAccount = await bank.createExchangeAccount(
+ "myexchange",
+ "x",
+ );
+ await exchange.addBankAccount("1", exchangeBankAccount);
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = weirdCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ console.log("setup done!");
+
+ const wallet = new WalletCli(t);
+
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const amount = "TESTKUDOS:7.5";
+
+ const bankAccessApiClient = new TalerCorebankApiClient(
+ bank.corebankApiBaseUrl,
+ );
+ const user = await bankAccessApiClient.createRandomBankUser();
+ bankAccessApiClient.setAuth(user);
+ const wop = await bankAccessApiClient.createWithdrawalOperation(
+ user.username,
+ amount,
+ );
+
+ // Hand it to the wallet
+
+ const details = await wallet.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ },
+ );
+
+ console.log(j2s(details));
+
+ const amountDetails = await wallet.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForAmount,
+ {
+ amount: details.amount,
+ exchangeBaseUrl: details.possibleExchanges[0].exchangeBaseUrl,
+ },
+ );
+
+ console.log(j2s(amountDetails));
+
+ t.assertAmountEquals(amountDetails.amountEffective, "TESTKUDOS:5");
+ t.assertAmountEquals(amountDetails.amountRaw, "TESTKUDOS:7.5");
+
+ await wallet.runPending();
+
+ // Withdraw (AKA select)
+
+ await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, {
+ exchangeBaseUrl: exchange.baseUrl,
+ talerWithdrawUri: wop.taler_withdraw_uri,
+ });
+
+ // Confirm it
+
+ await bankAccessApiClient.confirmWithdrawalOperation(user.username, {
+ withdrawalOperationId: wop.withdrawal_id,
+ });
+ await wallet.runUntilDone();
+
+ // Check balance
+
+ const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log(j2s(balResp));
+
+ t.assertAmountEquals(balResp.balances[0].available, "TESTKUDOS:5");
+
+ const txns = await wallet.client.call(WalletApiOperation.GetTransactions, {});
+ console.log(j2s(txns));
+}
+
+runWithdrawalFeesTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts
new file mode 100644
index 000000000..b483b8706
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts
@@ -0,0 +1,120 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ GlobalTestState,
+ setupDb,
+ ExchangeService,
+ FakebankService,
+ WalletService,
+ WalletClient,
+} from "../harness/harness.js";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js";
+import {
+ AmountString,
+ NotificationType,
+ TransactionMajorState,
+ URL,
+} from "@gnu-taler/taler-util";
+
+/**
+ * Withdraw a high amount. Mostly intended as a perf test.
+ *
+ * It is useful to see whether the wallet stays responsive while doing a huge withdrawal.
+ * (This is not automatic yet. Use taler-wallet-cli to connect to the daemon and make requests to check.)
+ */
+export async function runWithdrawalHugeTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const db = await setupDb(t);
+
+ const bank = await FakebankService.create(t, {
+ currency: "TESTKUDOS",
+ httpPort: 8082,
+ allowRegistrations: true,
+ // Not used by fakebank
+ database: db.connStr,
+ });
+
+ const exchange = ExchangeService.create(t, {
+ name: "testexchange-1",
+ currency: "TESTKUDOS",
+ httpPort: 8081,
+ database: db.connStr,
+ });
+
+ exchange.addBankAccount("1", {
+ accountName: "exchange",
+ accountPassword: "x",
+ wireGatewayApiBaseUrl: new URL("/exchange/", bank.baseUrl).href,
+ accountPaytoUri: "payto://x-taler-bank/localhost/exchange",
+ });
+
+ await bank.start();
+
+ await bank.pingUntilAvailable();
+
+ const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS"));
+ exchange.addCoinConfigList(coinConfig);
+
+ await exchange.start();
+ await exchange.pingUntilAvailable();
+
+ console.log("setup done!");
+
+ const walletService = new WalletService(t, { name: "w1" });
+ await walletService.start();
+ await walletService.pingUntilAvailable();
+
+ const wallet = new WalletClient({
+ name: "wallet",
+ unixPath: walletService.socketPath,
+ });
+ await wallet.connect();
+
+ const withdrawalFinishedCond = wallet.waitForNotificationCond(
+ (wn) =>
+ wn.type === NotificationType.TransactionStateTransition &&
+ wn.transactionId.startsWith("txn:withdrawal:") &&
+ wn.newTxState.major === TransactionMajorState.Done,
+ );
+
+ await wallet.client.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ // Results in about 1K coins withdrawn
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10000" as AmountString,
+ corebankApiBaseUrl: bank.baseUrl,
+ });
+
+ await withdrawalFinishedCond;
+
+ // Check balance
+
+ const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
+ console.log(balResp);
+}
+
+runWithdrawalHugeTest.suites = ["wallet-perf"];
+// FIXME: Should not be "experimental" but "slow" or something similar.
+runWithdrawalHugeTest.experimental = true;
diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts
new file mode 100644
index 000000000..8ab029acc
--- /dev/null
+++ b/packages/taler-harness/src/integrationtests/test-withdrawal-manual.ts
@@ -0,0 +1,107 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ TalerCorebankApiClient,
+ Logger,
+ WireGatewayApiClient,
+ j2s,
+ AmountString,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { GlobalTestState } from "../harness/harness.js";
+import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js";
+
+const logger = new Logger("test-withdrawal-manual.ts");
+
+/**
+ * Run test for basic, bank-integrated withdrawal.
+ */
+export async function runWithdrawalManualTest(t: GlobalTestState) {
+ // Set up test environment
+
+ const { walletClient, bank, exchange, exchangeBankAccount } =
+ await createSimpleTestkudosEnvironmentV2(t);
+
+ // Create a withdrawal operation
+
+ const bankAccessApiClient = new TalerCorebankApiClient(
+ bank.corebankApiBaseUrl,
+ );
+
+ const user = await bankAccessApiClient.createRandomBankUser();
+
+ await walletClient.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchange.baseUrl,
+ });
+
+ const tStart = AbsoluteTime.now();
+
+ logger.info("starting AcceptManualWithdrawal request");
+ // We expect this to return immediately.
+
+ const wres = await walletClient.call(
+ WalletApiOperation.AcceptManualWithdrawal,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ amount: "TESTKUDOS:10" as AmountString,
+ },
+ );
+
+ logger.info("AcceptManualWithdrawal finished");
+ logger.info(`result: ${j2s(wres)}`);
+
+ // Check that the request did not go into long-polling.
+ const duration = AbsoluteTime.difference(tStart, AbsoluteTime.now());
+ if (typeof duration.d_ms !== "number" || duration.d_ms > 5 * 1000) {
+ throw Error("withdrawal took too long (longpolling issue)");
+ }
+
+ const reservePub: string = wres.reservePub;
+
+ const wireGatewayApiClient = new WireGatewayApiClient(
+ exchangeBankAccount.wireGatewayApiBaseUrl,
+ {
+ auth: {
+ username: exchangeBankAccount.accountName,
+ password: exchangeBankAccount.accountPassword,
+ },
+ },
+ );
+
+ await wireGatewayApiClient.adminAddIncoming({
+ amount: "TESTKUDOS:10",
+ debitAccountPayto: user.accountPaytoUri,
+ reservePub: reservePub,
+ });
+
+ await exchange.runWirewatchOnce();
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {});
+
+ // Check balance
+
+ const balResp = await walletClient.call(WalletApiOperation.GetBalances, {});
+ t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
+
+ await t.shutdown();
+}
+
+runWithdrawalManualTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
index d985ed67f..54c211c6b 100644
--- a/packages/taler-wallet-cli/src/integrationtests/testrunner.ts
+++ b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -14,84 +14,114 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import {
- minimatch
-} from "@gnu-taler/taler-util";
+import { CancellationToken, Logger, minimatch } from "@gnu-taler/taler-util";
+import * as child_process from "child_process";
+import { spawnSync } from "child_process";
+import * as fs from "fs";
+import * as os from "os";
+import * as path from "path";
+import url from "url";
import {
GlobalTestState,
+ TestRunResult,
runTestWithState,
shouldLingerInTest,
- TestRunResult,
} from "../harness/harness.js";
-import { runPaymentTest } from "./test-payment";
-import { runPaymentDemoTest } from "./test-payment-on-demo";
-import * as fs from "fs";
-import * as path from "path";
-import * as os from "os";
-import * as child_process from "child_process";
-import { runBankApiTest } from "./test-bank-api";
-import { runClaimLoopTest } from "./test-claim-loop";
-import { runExchangeManagementTest } from "./test-exchange-management";
-import { runFeeRegressionTest } from "./test-fee-regression";
-import { runMerchantLongpollingTest } from "./test-merchant-longpolling";
-import { runMerchantRefundApiTest } from "./test-merchant-refund-api";
-import { runPayAbortTest } from "./test-pay-abort";
-import { runPayPaidTest } from "./test-pay-paid";
-import { runPaymentClaimTest } from "./test-payment-claim";
-import { runPaymentFaultTest } from "./test-payment-fault";
-import { runPaymentIdempotencyTest } from "./test-payment-idempotency";
-import { runPaymentMultipleTest } from "./test-payment-multiple";
-import { runPaymentTransientTest } from "./test-payment-transient";
-import { runPaywallFlowTest } from "./test-paywall-flow";
-import { runRefundAutoTest } from "./test-refund-auto";
-import { runRefundGoneTest } from "./test-refund-gone";
-import { runRefundIncrementalTest } from "./test-refund-incremental";
-import { runRefundTest } from "./test-refund";
-import { runRevocationTest } from "./test-revocation";
-import { runTimetravelAutorefreshTest } from "./test-timetravel-autorefresh";
-import { runTimetravelWithdrawTest } from "./test-timetravel-withdraw";
-import { runTippingTest } from "./test-tipping";
-import { runWallettestingTest } from "./test-wallettesting";
-import { runTestWithdrawalManualTest } from "./test-withdrawal-manual";
-import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank";
-import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated";
-import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion";
-import { runLibeufinBasicTest } from "./test-libeufin-basic";
-import { runLibeufinC5xTest } from "./test-libeufin-c5x";
-import { runLibeufinNexusBalanceTest } from "./test-libeufin-nexus-balance";
-import { runLibeufinBadGatewayTest } from "./test-libeufin-bad-gateway";
-import { runLibeufinKeyrotationTest } from "./test-libeufin-keyrotation";
-import { runLibeufinRefundTest } from "./test-libeufin-refund";
-import { runLibeufinRefundMultipleUsersTest } from "./test-libeufin-refund-multiple-users";
-import { runLibeufinTutorialTest } from "./test-libeufin-tutorial";
-import { runLibeufinApiPermissionsTest } from "./test-libeufin-api-permissions";
-import { runLibeufinApiFacadeTest } from "./test-libeufin-api-facade";
-import { runLibeufinApiFacadeBadRequestTest } from "./test-libeufin-api-facade-bad-request";
-import { runLibeufinAnastasisFacadeTest } from "./test-libeufin-facade-anastasis";
-import { runLibeufinApiSchedulingTest } from "./test-libeufin-api-scheduling";
-import { runLibeufinApiBankconnectionTest } from "./test-libeufin-api-bankconnection";
-import { runLibeufinApiUsersTest } from "./test-libeufin-api-users";
-import { runLibeufinApiBankaccountTest } from "./test-libeufin-api-bankaccount";
-import { runLibeufinApiSandboxTransactionsTest } from "./test-libeufin-api-sandbox-transactions";
-import { runLibeufinApiSandboxCamtTest } from "./test-libeufin-api-sandbox-camt";
-import { runLibeufinSandboxWireTransferCliTest } from "./test-libeufin-sandbox-wire-transfer-cli";
-import { runDepositTest } from "./test-deposit";
-import CancellationToken from "cancellationtoken";
-import { runMerchantInstancesTest } from "./test-merchant-instances";
-import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls";
-import { runWalletBackupBasicTest } from "./test-wallet-backup-basic";
-import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete";
-import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend";
+import { getSharedTestDir } from "../harness/helpers.js";
+import { runAgeRestrictionsDepositTest } from "./test-age-restrictions-deposit.js";
+import { runAgeRestrictionsMerchantTest } from "./test-age-restrictions-merchant.js";
+import { runAgeRestrictionsMixedMerchantTest } from "./test-age-restrictions-mixed-merchant.js";
+import { runAgeRestrictionsPeerTest } from "./test-age-restrictions-peer.js";
+import { runBankApiTest } from "./test-bank-api.js";
+import { runClaimLoopTest } from "./test-claim-loop.js";
+import { runClauseSchnorrTest } from "./test-clause-schnorr.js";
+import { runCurrencyScopeTest } from "./test-currency-scope.js";
+import { runDenomLostTest } from "./test-denom-lost.js";
+import { runDenomUnofferedTest } from "./test-denom-unoffered.js";
+import { runDepositTest } from "./test-deposit.js";
+import { runExchangeDepositTest } from "./test-exchange-deposit.js";
+import { runExchangeManagementFaultTest } from "./test-exchange-management-fault.js";
+import { runExchangeManagementTest } from "./test-exchange-management.js";
+import { runExchangePurseTest } from "./test-exchange-purse.js";
+import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js";
+import { runFeeRegressionTest } from "./test-fee-regression.js";
+import { runForcedSelectionTest } from "./test-forced-selection.js";
+import { runKycTest } from "./test-kyc.js";
+import { runLibeufinBankTest } from "./test-libeufin-bank.js";
+import { runMerchantExchangeConfusionTest } from "./test-merchant-exchange-confusion.js";
+import { runMerchantInstancesDeleteTest } from "./test-merchant-instances-delete.js";
+import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls.js";
+import { runMerchantInstancesTest } from "./test-merchant-instances.js";
+import { runMerchantLongpollingTest } from "./test-merchant-longpolling.js";
+import { runMerchantRefundApiTest } from "./test-merchant-refund-api.js";
+import { runMerchantSpecPublicOrdersTest } from "./test-merchant-spec-public-orders.js";
+import { runMultiExchangeTest } from "./test-multiexchange.js";
+import { runOtpTest } from "./test-otp.js";
+import { runPayPaidTest } from "./test-pay-paid.js";
+import { runPaymentAbortTest } from "./test-payment-abort.js";
+import { runPaymentClaimTest } from "./test-payment-claim.js";
+import { runPaymentDeletedTest } from "./test-payment-deleted.js";
+import { runPaymentExpiredTest } from "./test-payment-expired.js";
+import { runPaymentFaultTest } from "./test-payment-fault.js";
import { runPaymentForgettableTest } from "./test-payment-forgettable.js";
+import { runPaymentIdempotencyTest } from "./test-payment-idempotency.js";
+import { runPaymentMultipleTest } from "./test-payment-multiple.js";
+import { runPaymentShareTest } from "./test-payment-share.js";
+import { runPaymentTemplateTest } from "./test-payment-template.js";
+import { runPaymentTransientTest } from "./test-payment-transient.js";
import { runPaymentZeroTest } from "./test-payment-zero.js";
-import { runMerchantSpecPublicOrdersTest } from "./test-merchant-spec-public-orders.js";
-import { runExchangeTimetravelTest } from "./test-exchange-timetravel.js";
-import { runDenomUnofferedTest } from "./test-denom-unoffered.js";
-import { runTestWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js";
+import { runPaymentTest } from "./test-payment.js";
+import { runPaywallFlowTest } from "./test-paywall-flow.js";
+import { runPeerRepairTest } from "./test-peer-repair.js";
+import { runPeerToPeerPullTest } from "./test-peer-to-peer-pull.js";
+import { runPeerToPeerPushTest } from "./test-peer-to-peer-push.js";
+import { runRefundAutoTest } from "./test-refund-auto.js";
+import { runRefundGoneTest } from "./test-refund-gone.js";
+import { runRefundIncrementalTest } from "./test-refund-incremental.js";
+import { runRefundTest } from "./test-refund.js";
+import { runRevocationTest } from "./test-revocation.js";
+import { runSimplePaymentTest } from "./test-simple-payment.js";
+import { runStoredBackupsTest } from "./test-stored-backups.js";
+import { runTimetravelAutorefreshTest } from "./test-timetravel-autorefresh.js";
+import { runTimetravelWithdrawTest } from "./test-timetravel-withdraw.js";
+import { runTermOfServiceFormatTest } from "./test-tos-format.js";
+import { runWalletBackupBasicTest } from "./test-wallet-backup-basic.js";
+import { runWalletBackupDoublespendTest } from "./test-wallet-backup-doublespend.js";
+import { runWalletBalanceNotificationsTest } from "./test-wallet-balance-notifications.js";
+import { runWalletBalanceZeroTest } from "./test-wallet-balance-zero.js";
+import { runWalletBalanceTest } from "./test-wallet-balance.js";
+import { runWalletBlockedDepositTest } from "./test-wallet-blocked-deposit.js";
+import { runWalletBlockedPayMerchantTest } from "./test-wallet-blocked-pay-merchant.js";
+import { runWalletBlockedPayPeerPullTest } from "./test-wallet-blocked-pay-peer-pull.js";
+import { runWalletBlockedPayPeerPushTest } from "./test-wallet-blocked-pay-peer-push.js";
+import { runWalletCliTerminationTest } from "./test-wallet-cli-termination.js";
+import { runWalletConfigTest } from "./test-wallet-config.js";
+import { runWalletCryptoWorkerTest } from "./test-wallet-cryptoworker.js";
+import { runWalletDblessTest } from "./test-wallet-dbless.js";
+import { runWalletDd48Test } from "./test-wallet-dd48.js";
+import { runWalletDenomExpireTest } from "./test-wallet-denom-expire.js";
+import { runWalletDevExperimentsTest } from "./test-wallet-dev-experiments.js";
+import { runWalletExchangeUpdateTest } from "./test-wallet-exchange-update.js";
+import { runWalletGenDbTest } from "./test-wallet-gendb.js";
+import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js";
+import { runWalletNotificationsTest } from "./test-wallet-notifications.js";
+import { runWalletObservabilityTest } from "./test-wallet-observability.js";
+import { runWalletRefreshErrorsTest } from "./test-wallet-refresh-errors.js";
+import { runWalletRefreshTest } from "./test-wallet-refresh.js";
+import { runWalletWirefeesTest } from "./test-wallet-wirefees.js";
+import { runWallettestingTest } from "./test-wallettesting.js";
+import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank.js";
+import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated.js";
+import { runWithdrawalConversionTest } from "./test-withdrawal-conversion.js";
+import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js";
+import { runWithdrawalFeesTest } from "./test-withdrawal-fees.js";
+import { runWithdrawalHugeTest } from "./test-withdrawal-huge.js";
+import { runWithdrawalManualTest } from "./test-withdrawal-manual.js";
/**
* Test runner.
*/
+const logger = new Logger("testrunner.ts");
/**
* Spec for one test.
@@ -99,84 +129,118 @@ import { runTestWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js";
interface TestMainFunction {
(t: GlobalTestState): Promise<void>;
timeoutMs?: number;
- excludeByDefault?: boolean;
+ experimental?: boolean;
suites?: string[];
}
const allTests: TestMainFunction[] = [
+ runAgeRestrictionsMerchantTest,
+ runAgeRestrictionsMixedMerchantTest,
+ runAgeRestrictionsPeerTest,
+ runAgeRestrictionsDepositTest,
runBankApiTest,
runClaimLoopTest,
- runDepositTest,
+ runClauseSchnorrTest,
runDenomUnofferedTest,
- runExchangeManagementTest,
+ runDepositTest,
+ runSimplePaymentTest,
+ runExchangeManagementFaultTest,
runExchangeTimetravelTest,
runFeeRegressionTest,
- runLibeufinBasicTest,
- runLibeufinKeyrotationTest,
- runLibeufinTutorialTest,
- runLibeufinRefundTest,
- runLibeufinC5xTest,
- runLibeufinNexusBalanceTest,
- runLibeufinBadGatewayTest,
- runLibeufinRefundMultipleUsersTest,
- runLibeufinApiPermissionsTest,
- runLibeufinApiFacadeTest,
- runLibeufinApiFacadeBadRequestTest,
- runLibeufinAnastasisFacadeTest,
- runLibeufinApiSchedulingTest,
- runLibeufinApiUsersTest,
- runLibeufinApiBankaccountTest,
- runLibeufinApiBankconnectionTest,
- runLibeufinApiSandboxTransactionsTest,
- runLibeufinApiSandboxCamtTest,
- runLibeufinSandboxWireTransferCliTest,
+ runForcedSelectionTest,
+ runKycTest,
+ runExchangePurseTest,
+ runExchangeDepositTest,
runMerchantExchangeConfusionTest,
- runMerchantInstancesTest,
runMerchantInstancesDeleteTest,
+ runMerchantInstancesTest,
runMerchantInstancesUrlsTest,
runMerchantLongpollingTest,
- runMerchantSpecPublicOrdersTest,
runMerchantRefundApiTest,
- runPayAbortTest,
+ runMerchantSpecPublicOrdersTest,
runPaymentClaimTest,
runPaymentFaultTest,
runPaymentForgettableTest,
runPaymentIdempotencyTest,
runPaymentMultipleTest,
runPaymentTest,
- runPaymentDemoTest,
+ runPaymentShareTest,
+ runPaymentTemplateTest,
+ runPaymentAbortTest,
runPaymentTransientTest,
runPaymentZeroTest,
runPayPaidTest,
+ runPeerRepairTest,
+ runMultiExchangeTest,
+ runWalletBalanceTest,
runPaywallFlowTest,
+ runPeerToPeerPullTest,
+ runPeerToPeerPushTest,
runRefundAutoTest,
runRefundGoneTest,
runRefundIncrementalTest,
runRefundTest,
runRevocationTest,
- runTestWithdrawalManualTest,
- runTestWithdrawalFakebankTest,
+ runWithdrawalManualTest,
runTimetravelAutorefreshTest,
runTimetravelWithdrawTest,
- runTippingTest,
runWalletBackupBasicTest,
runWalletBackupDoublespendTest,
+ runWalletNotificationsTest,
+ runWalletCryptoWorkerTest,
+ runWalletDblessTest,
runWallettestingTest,
runWithdrawalAbortBankTest,
+ // runWithdrawalNotifyBeforeTxTest,
runWithdrawalBankIntegratedTest,
+ runWithdrawalFakebankTest,
+ runWithdrawalFeesTest,
+ runWithdrawalConversionTest,
+ runWithdrawalHugeTest,
+ runTermOfServiceFormatTest,
+ runStoredBackupsTest,
+ runPaymentExpiredTest,
+ runWalletGenDbTest,
+ runLibeufinBankTest,
+ runPaymentDeletedTest,
+ runWalletDd48Test,
+ runCurrencyScopeTest,
+ runWalletRefreshTest,
+ runWalletCliTerminationTest,
+ runOtpTest,
+ runWalletBalanceNotificationsTest,
+ runExchangeManagementTest,
+ runWalletConfigTest,
+ runWalletObservabilityTest,
+ runWalletDevExperimentsTest,
+ runWalletBalanceZeroTest,
+ runWalletInsufficientBalanceTest,
+ runWalletWirefeesTest,
+ runDenomLostTest,
+ runWalletDenomExpireTest,
+ runWalletBlockedDepositTest,
+ runWalletBlockedPayMerchantTest,
+ runWalletBlockedPayPeerPushTest,
+ runWalletBlockedPayPeerPullTest,
+ runWalletExchangeUpdateTest,
+ runWalletRefreshErrorsTest,
];
export interface TestRunSpec {
includePattern?: string;
suiteSpec?: string;
dryRun?: boolean;
+ failFast?: boolean;
+ waitOnFail?: boolean;
+ includeExperimental: boolean;
+ noTimeout: boolean;
verbosity: number;
}
export interface TestInfo {
name: string;
suites: string[];
- excludeByDefault: boolean;
+ experimental: boolean;
}
function updateCurrentSymlink(testDir: string): void {
@@ -214,12 +278,42 @@ interface RunTestChildInstruction {
testRootDir: string;
}
+function purgeSharedTestEnvironment() {
+ const rmRes = spawnSync("rm", ["-rf", `${getSharedTestDir()}`]);
+ if (rmRes.status != 0) {
+ logger.warn("can't delete shared test directory");
+ }
+ const psqlRes = spawnSync("psql", ["-Aqtl"], {
+ encoding: "utf-8",
+ });
+ if (psqlRes.status != 0) {
+ logger.warn("could not list available postgres databases");
+ return;
+ }
+ if (psqlRes.output[1]!!.indexOf("taler-integrationtest-shared") >= 0) {
+ const dropRes = spawnSync("dropdb", ["taler-integrationtest-shared"], {
+ encoding: "utf-8",
+ });
+ if (dropRes.status != 0) {
+ logger.warn("could not drop taler-integrationtest-shared database");
+ return;
+ }
+ }
+}
+
export async function runTests(spec: TestRunSpec) {
+ if (!process.env.TALER_HARNESS_KEEP) {
+ logger.info("purging shared test environment");
+ purgeSharedTestEnvironment();
+ } else {
+ logger.info("keeping shared test environment");
+ }
+
const testRootDir = fs.mkdtempSync(
path.join(os.tmpdir(), "taler-integrationtests-"),
);
updateCurrentSymlink(testRootDir);
- console.log("testsuite root directory: ", testRootDir);
+ console.log(`testsuite root directory: ${testRootDir}`);
const testResults: TestRunResult[] = [];
@@ -250,16 +344,16 @@ export async function runTests(spec: TestRunSpec) {
continue;
}
+ if (testCase.experimental && !spec.includeExperimental) {
+ continue;
+ }
+
if (suites) {
const ts = new Set(testCase.suites ?? []);
const intersection = new Set([...suites].filter((x) => ts.has(x)));
if (intersection.size === 0) {
continue;
}
- } else {
- if (testCase.excludeByDefault) {
- continue;
- }
}
if (spec.dryRun) {
@@ -272,7 +366,9 @@ export async function runTests(spec: TestRunSpec) {
testRootDir,
};
- currentChild = child_process.fork(__filename, ["__TWCLI_TESTWORKER"], {
+ const myFilename = url.fileURLToPath(import.meta.url);
+
+ currentChild = child_process.fork(myFilename, ["__TWCLI_TESTWORKER"], {
env: {
TWCLI_RUN_TEST_INSTRUCTION: JSON.stringify(testInstr),
...process.env,
@@ -294,12 +390,27 @@ export async function runTests(spec: TestRunSpec) {
currentChild.stdout?.pipe(harnessLogStream);
currentChild.stderr?.pipe(harnessLogStream);
- const defaultTimeout = 60000;
- const testTimeoutMs = testCase.timeoutMs ?? defaultTimeout;
+ // Default timeout when the test doesn't override it.
+ let defaultTimeout = 60000;
+ const overrideDefaultTimeout = process.env.TALER_TEST_TIMEOUT;
+ if (overrideDefaultTimeout) {
+ defaultTimeout = Number.parseInt(overrideDefaultTimeout, 10) * 1000;
+ }
- console.log(`running ${testName} with timeout ${testTimeoutMs}ms`);
+ // Set the timeout to at least be the default timeout.
+ const testTimeoutMs = testCase.timeoutMs
+ ? Math.max(testCase.timeoutMs, defaultTimeout)
+ : defaultTimeout;
- const { token } = CancellationToken.timeout(testTimeoutMs);
+ if (spec.noTimeout) {
+ console.log(`running ${testName}, no timeout`);
+ } else {
+ console.log(`running ${testName} with timeout ${testTimeoutMs}ms`);
+ }
+
+ const token = spec.noTimeout
+ ? CancellationToken.CONTINUE
+ : CancellationToken.timeout(testTimeoutMs).token;
const resultPromise: Promise<TestRunResult> = new Promise(
(resolve, reject) => {
@@ -314,7 +425,7 @@ export async function runTests(spec: TestRunSpec) {
if (token.isCancelled) {
return;
}
- console.log(`process exited code=${code} signal=${signal}`);
+ logger.info(`process exited code=${code} signal=${signal}`);
if (signal) {
reject(new Error(`test worker exited with signal ${signal}`));
} else if (code != 0) {
@@ -342,6 +453,10 @@ export async function runTests(spec: TestRunSpec) {
try {
result = await token.racePromise(resultPromise);
+ if (result.status === "fail" && spec.failFast) {
+ logger.error("test failed and failing fast, exit!");
+ throw Error("exit on fail fast");
+ }
} catch (e: any) {
console.error(`test ${testName} timed out`);
if (token.isCancelled) {
@@ -414,7 +529,7 @@ export function getTestInfo(): TestInfo[] {
return allTests.map((x) => ({
name: getTestName(x),
suites: x.suites ?? [],
- excludeByDefault: x.excludeByDefault ?? false,
+ experimental: x.experimental ?? false,
}));
}
@@ -425,19 +540,12 @@ if (runTestInstrStr && process.argv.includes("__TWCLI_TESTWORKER")) {
const { testRootDir, testName } = JSON.parse(
runTestInstrStr,
) as RunTestChildInstruction;
- console.log(`running test ${testName} in worker process`);
process.on("disconnect", () => {
- console.log("got disconnect from parent");
+ logger.trace("got disconnect from parent");
process.exit(3);
});
- try {
- require("source-map-support").install();
- } catch (e) {
- // Do nothing.
- }
-
const runTest = async () => {
let testMain: TestMainFunction | undefined;
for (const t of allTests) {
@@ -448,35 +556,36 @@ if (runTestInstrStr && process.argv.includes("__TWCLI_TESTWORKER")) {
}
if (!process.send) {
- console.error("can't communicate with parent");
+ logger.error("can't communicate with parent");
process.exit(2);
}
if (!testMain) {
- console.log(`test ${testName} not found`);
+ logger.info(`test ${testName} not found`);
process.exit(2);
}
const testDir = path.join(testRootDir, testName);
- console.log(`running test ${testName}`);
+ logger.info(`running test ${testName}`);
const gc = new GlobalTestState({
testDir,
});
const testResult = await runTestWithState(gc, testMain, testName);
+ logger.info(`done test ${testName}: ${testResult.status}`);
process.send(testResult);
};
runTest()
.then(() => {
- console.log(`test ${testName} finished in worker`);
+ logger.trace(`test ${testName} finished in worker`);
if (shouldLingerInTest()) {
- console.log("lingering ...");
+ logger.trace("lingering ...");
return;
}
process.exit(0);
})
.catch((e) => {
- console.log(e);
+ logger.error(e);
process.exit(1);
});
}
diff --git a/packages/taler-wallet-cli/src/lint.ts b/packages/taler-harness/src/lint.ts
index 2b888ccf4..a45e6db9d 100644
--- a/packages/taler-wallet-cli/src/lint.ts
+++ b/packages/taler-harness/src/lint.ts
@@ -37,13 +37,13 @@ import {
Configuration,
decodeCrock,
} from "@gnu-taler/taler-util";
-import {
- NodeHttpLib,
- readSuccessResponseJsonOrThrow,
-} from "@gnu-taler/taler-wallet-core";
import { URL } from "url";
import { spawn } from "child_process";
import { delayMs } from "./harness/harness.js";
+import {
+ createPlatformHttpLib,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
interface BasicConf {
mainCurrency: string;
@@ -53,7 +53,9 @@ interface PubkeyConf {
masterPublicKey: string;
}
-const httpLib = new NodeHttpLib();
+const httpLib = createPlatformHttpLib({
+ enableThrottling: false,
+});
interface ShellResult {
stdout: string;
@@ -133,13 +135,13 @@ function checkBasicConf(context: LintContext): BasicConf {
const currencyEntry = cfg.getString("taler", "currency");
let mainCurrency: string | undefined;
- if (!currencyEntry.value) {
+ if (!currencyEntry.isDefined()) {
context.numErr++;
console.log("error: currency not defined in section TALER option CURRENCY");
console.log("Aborting further checks.");
process.exit(1);
} else {
- mainCurrency = currencyEntry.value.toUpperCase();
+ mainCurrency = currencyEntry.required().toUpperCase();
}
if (mainCurrency === "KUDOS") {
@@ -404,7 +406,7 @@ export async function checkExchangeHttpd(
{
const mgmtUrl = new URL("management/keys", baseUrl);
- const resp = await httpLib.get(mgmtUrl.href);
+ const resp = await httpLib.fetch(mgmtUrl.href);
const futureKeys = await readSuccessResponseJsonOrThrow(
resp,
@@ -428,7 +430,7 @@ export async function checkExchangeHttpd(
{
const keysUrl = new URL("keys", baseUrl);
- const resp = await Promise.race([httpLib.get(keysUrl.href), delayMs(2000)]);
+ const resp = await Promise.race([httpLib.fetch(keysUrl.href), delayMs(2000)]);
if (!resp) {
context.numErr++;
@@ -464,7 +466,7 @@ export async function checkExchangeHttpd(
{
const keysUrl = new URL("wire", baseUrl);
- const resp = await Promise.race([httpLib.get(keysUrl.href), delayMs(2000)]);
+ const resp = await Promise.race([httpLib.fetch(keysUrl.href), delayMs(2000)]);
if (!resp) {
context.numErr++;
@@ -495,7 +497,7 @@ export async function lintExchangeDeployment(
verbose: boolean,
cont: boolean,
): Promise<void> {
- if (process.getuid() != 0) {
+ if (process.getuid!() != 0) {
console.log(
"warning: the exchange deployment linter is designed to be run as root",
);
diff --git a/packages/taler-harness/src/sandcastle-config.ts b/packages/taler-harness/src/sandcastle-config.ts
new file mode 100644
index 000000000..a7f7233ac
--- /dev/null
+++ b/packages/taler-harness/src/sandcastle-config.ts
@@ -0,0 +1,10 @@
+// Work in progress.
+// TS-based schema for the sandcastle configuration.
+
+export interface SandcastleConfig {
+ currency: string;
+ merchant: {
+ apiKey: string;
+ baseUrl: string;
+ };
+}
diff --git a/packages/taler-harness/tsconfig.json b/packages/taler-harness/tsconfig.json
new file mode 100644
index 000000000..0453aaff0
--- /dev/null
+++ b/packages/taler-harness/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compileOnSave": true,
+ "compilerOptions": {
+ "composite": true,
+ "target": "ES2020",
+ "module": "Node16",
+ "moduleResolution": "Node16",
+ "sourceMap": true,
+ "lib": ["ES2020"],
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "strict": true,
+ "strictPropertyInitialization": false,
+ "outDir": "lib",
+ "noImplicitAny": true,
+ "noImplicitThis": true,
+ "incremental": true,
+ "esModuleInterop": true,
+ "importHelpers": true,
+ "rootDir": "src",
+ "baseUrl": "./src",
+ "typeRoots": ["./node_modules/@types"]
+ },
+ "include": ["src/**/*"],
+ "references": [
+ {
+ "path": "../taler-wallet-core/"
+ },
+ {
+ "path": "../taler-util/"
+ }
+ ]
+}
diff --git a/packages/taler-util/.eslintrc.cjs b/packages/taler-util/.eslintrc.cjs
new file mode 100644
index 000000000..05618b499
--- /dev/null
+++ b/packages/taler-util/.eslintrc.cjs
@@ -0,0 +1,28 @@
+module.exports = {
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react/recommended',
+ ],
+ parser: '@typescript-eslint/parser',
+ plugins: ['@typescript-eslint', 'header'],
+ root: true,
+ rules: {
+ "react/no-unknown-property": 0,
+ "react/no-unescaped-entities": 0,
+ "@typescript-eslint/no-namespace": 0,
+ "@typescript-eslint/no-unused-vars": [2,{argsIgnorePattern:"^_"}],
+ "header/header": [2,"copyleft-header.js"]
+ },
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ jsx: true,
+ },
+ settings: {
+ react: {
+ version: "18",
+ pragma: "h",
+ }
+ },
+};
diff --git a/packages/taler-util/Makefile b/packages/taler-util/Makefile
new file mode 100644
index 000000000..def16c823
--- /dev/null
+++ b/packages/taler-util/Makefile
@@ -0,0 +1,42 @@
+# This Makefile has been placed in the public domain.
+
+ifeq ($(TOPLEVEL), yes)
+ $(info top-level build)
+ -include ../../.config.mk
+ override DESTDIR := $(TOP_DESTDIR)
+else
+ $(info package-level build)
+ -include ../../.config.mk
+ -include .config.mk
+endif
+
+$(info prefix is $(prefix))
+
+all:
+ @echo use 'make install' to build and install taler-util
+
+ifndef prefix
+.PHONY: warn-noprefix install
+warn-noprefix:
+ @echo "no prefix configured, did you run ./configure?"
+install: warn-noprefix
+else
+LIBDIR = $(prefix)/share/taler-js/taler-util
+NODE_DEPS = $(shell jq "(.dependencies|keys|map(\"node_modules/\" + .)|join(\" \"))" package.json -r)
+.PHONY: install install-nodeps deps
+install-nodeps:
+ pnpm compile
+ @echo installing taler-util to $(DESTDIR)$(prefix)
+ install -d $(DESTDIR)$(LIBDIR)/lib/globbing
+ install lib/*.* $(DESTDIR)$(LIBDIR)/lib
+ install lib/globbing/*.* $(DESTDIR)$(LIBDIR)/lib/globbing
+ install package.json $(DESTDIR)$(LIBDIR)
+ tar hcf - $(NODE_DEPS) | (cd $(DESTDIR)$(LIBDIR); tar xf -)
+
+deps:
+ pnpm install --frozen-lockfile --filter @gnu-taler/taler-util...
+ pnpm run --filter @gnu-taler/taler-util... compile
+install:
+ $(MAKE) deps
+ $(MAKE) install-nodeps
+endif
diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json
index 3d92c7610..5f192762a 100644
--- a/packages/taler-util/package.json
+++ b/packages/taler-util/package.json
@@ -1,52 +1,89 @@
{
"name": "@gnu-taler/taler-util",
- "version": "0.8.3",
+ "version": "0.10.6",
"description": "Generic helper functionality for GNU Taler",
- "exports": {
- ".": "./lib/index.node.js"
- },
- "module": "./lib/index.node.js",
- "main": "./lib/index.node.js",
- "browser": {
- "./lib/index.node.js": "./lib/index.browser.js"
- },
"type": "module",
"types": "./lib/index.node.d.ts",
- "typesVersions": {
- "*": {
- "lib/index.node.d.ts": [
- "lib/index.node.d.ts"
- ],
- "src/*": [],
- "*": []
- }
- },
"author": "Florian Dold",
"license": "AGPL-3.0-or-later",
"private": false,
+ "exports": {
+ ".": {
+ "node": "./lib/index.node.js",
+ "browser": "./lib/index.browser.js",
+ "qtart": "./lib/index.qtart.js",
+ "default": "./lib/index.js"
+ },
+ "./twrpc": {
+ "default": "./lib/twrpc.js"
+ },
+ "./compat": {
+ "types": "./lib/compat.node.js",
+ "node": "./lib/compat.node.js",
+ "qtart": "./lib/compat.qtart.js",
+ "default": "./lib/not-implemented.js"
+ },
+ "./clk": {
+ "default": "./lib/clk.js"
+ },
+ "./http": {
+ "default": "./lib/http.js"
+ },
+ "./qtart": {
+ "types": "./lib/qtart.js",
+ "qtart": "./lib/qtart.js",
+ "default": "./lib/not-implemented.js"
+ }
+ },
+ "imports": {
+ "#twrpc-impl": {
+ "node": "./lib/twrpc-impl.node.js",
+ "qtart": "./lib/twrpc-impl.qtart.js"
+ },
+ "#compat-impl": {
+ "node": "./lib/compat.node.js",
+ "qtart": "./lib/compat.qtart.js",
+ "type": "./lib/compat.d.ts"
+ },
+ "#http-impl": {
+ "type": "./lib/http-impl.node.js",
+ "node": "./lib/http-impl.node.js",
+ "qtart": "./lib/http-impl.qtart.js",
+ "default": "./lib/http-impl.missing.js"
+ },
+ "#argon2-impl": {
+ "node": "./lib/argon2-impl.node.js",
+ "default": "./lib/argon2-impl.missing.js"
+ }
+ },
"scripts": {
- "prepare": "tsc",
"compile": "tsc",
"test": "tsc && ava",
- "clean": "rimraf dist lib tsconfig.tsbuildinfo",
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo",
+ "typedoc": "typedoc --out dist/typedoc ./src/",
"pretty": "prettier --write src"
},
"devDependencies": {
- "@types/node": "^14.14.22",
- "ava": "^3.15.0",
- "esbuild": "^0.9.2",
- "prettier": "^2.2.1",
- "rimraf": "^3.0.2",
- "typescript": "^4.2.3"
+ "eslint": "^8.56.0",
+ "@typescript-eslint/eslint-plugin": "^6.19.0",
+ "@typescript-eslint/parser": "^6.19.0",
+ "@types/follow-redirects": "^1.14.4",
+ "@types/node": "^18.11.17",
+ "ava": "^6.0.1",
+ "esbuild": "^0.19.9",
+ "typescript": "^5.3.3"
},
"dependencies": {
- "big-integer": "^1.6.48",
+ "big-integer": "^1.6.52",
+ "fflate": "^0.8.1",
+ "follow-redirects": "^1.15.5",
+ "hash-wasm": "^4.11.0",
"jed": "^1.1.1",
- "tslib": "^2.1.0"
+ "tslib": "^2.6.2"
},
"ava": {
"files": [
- "lib/*test*"
+ "lib/**/*test.js"
]
}
}
diff --git a/packages/taler-util/src/CancellationToken.ts b/packages/taler-util/src/CancellationToken.ts
new file mode 100644
index 000000000..3aa576d77
--- /dev/null
+++ b/packages/taler-util/src/CancellationToken.ts
@@ -0,0 +1,285 @@
+/*
+MIT License
+
+Copyright (c) 2017 Conrad Reuter
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+const NOOP = () => {};
+
+/**
+ * A token that can be passed around to inform consumers of the token that a
+ * certain operation has been cancelled.
+ */
+class CancellationToken {
+ private _reason: any;
+ private _callbacks?: Set<(reason?: any) => void> = new Set();
+
+ /**
+ * A cancellation token that is already cancelled.
+ */
+ public static readonly CANCELLED: CancellationToken = new CancellationToken(
+ true,
+ true,
+ );
+
+ /**
+ * A cancellation token that is never cancelled.
+ */
+ public static readonly CONTINUE: CancellationToken = new CancellationToken(
+ false,
+ false,
+ );
+
+ /**
+ * Whether the token has been cancelled.
+ */
+ public get isCancelled(): boolean {
+ return this._isCancelled;
+ }
+
+ /**
+ * Whether the token can be cancelled.
+ */
+ public get canBeCancelled(): boolean {
+ return this._canBeCancelled;
+ }
+
+ /**
+ * Why this token has been cancelled.
+ */
+ public get reason(): any {
+ if (this.isCancelled) {
+ return this._reason;
+ } else {
+ throw new Error("This token is not cancelled.");
+ }
+ }
+
+ /**
+ * Make a promise that resolves when the async operation resolves,
+ * or rejects when the operation is rejected or this token is cancelled.
+ */
+ public racePromise<T>(asyncOperation: Promise<T>): Promise<T> {
+ if (!this.canBeCancelled) {
+ return asyncOperation;
+ }
+ return new Promise<T>((resolve, reject) => {
+ // we could use Promise.finally here as soon as it's implemented in the major browsers
+ const unregister = this.onCancelled((reason) =>
+ reject(new CancellationToken.CancellationError(reason)),
+ );
+ asyncOperation.then(
+ (value) => {
+ resolve(value);
+ unregister();
+ },
+ (err) => {
+ reject(err);
+ unregister();
+ },
+ );
+ });
+ }
+
+ /**
+ * Throw a {CancellationToken.CancellationError} if this token is cancelled.
+ */
+ public throwIfCancelled(): void {
+ if (this._isCancelled) {
+ throw new CancellationToken.CancellationError(this._reason);
+ }
+ }
+
+ /**
+ * Invoke the callback when this token is cancelled.
+ * If this token is already cancelled, the callback is invoked immediately.
+ * Returns a function that unregisters the cancellation callback.
+ */
+ public onCancelled(cb: (reason?: any) => void): () => void {
+ if (!this.canBeCancelled) {
+ return NOOP;
+ }
+ if (this.isCancelled) {
+ cb(this.reason);
+ return NOOP;
+ }
+
+ /* istanbul ignore next */
+ this._callbacks?.add(cb);
+ return () => this._callbacks?.delete(cb);
+ }
+
+ private constructor(
+ /**
+ * Whether the token is already cancelled.
+ */
+ private _isCancelled: boolean,
+ /**
+ * Whether the token can be cancelled.
+ */
+ private _canBeCancelled: boolean,
+ ) {}
+
+ /**
+ * Create a {CancellationTokenSource}.
+ */
+ public static create(): CancellationToken.Source {
+ const token = new CancellationToken(false, true);
+
+ const cancel = (reason?: any) => {
+ if (token._isCancelled) return;
+ token._isCancelled = true;
+ token._reason = reason;
+ token._callbacks?.forEach((cb) => cb(reason));
+ dispose();
+ };
+
+ const dispose = () => {
+ token._canBeCancelled = token.isCancelled;
+ delete token._callbacks; // release memory
+ };
+
+ return { token, cancel, dispose };
+ }
+
+ /**
+ * Create a {CancellationTokenSource}.
+ * The token will be cancelled automatically after the specified timeout in milliseconds.
+ */
+ public static timeout(ms: number): CancellationToken.Source {
+ const {
+ token,
+ cancel: originalCancel,
+ dispose: originalDispose,
+ } = CancellationToken.create();
+
+ let timer: NodeJS.Timeout | null;
+ timer = setTimeout(() => originalCancel(CancellationToken.timeout), ms);
+ const disposeTimer = () => {
+ if (timer == null) return;
+ clearTimeout(timer);
+ timer = null;
+ };
+
+ const cancel = (reason?: any) => {
+ disposeTimer();
+ originalCancel(reason);
+ };
+
+ /* istanbul ignore next */
+ const dispose = () => {
+ disposeTimer();
+ originalDispose();
+ };
+
+ return { token, cancel, dispose };
+ }
+
+ /**
+ * Create a {CancellationToken} that is cancelled when all of the given tokens are cancelled.
+ *
+ * This is like {Promise<T>.all} for {CancellationToken}s.
+ */
+ public static all(...tokens: CancellationToken[]): CancellationToken {
+ // If *any* of the tokens cannot be cancelled, then the token we return can never be.
+ if (tokens.some((token) => !token.canBeCancelled)) {
+ return CancellationToken.CONTINUE;
+ }
+
+ const combined = CancellationToken.create();
+ let countdown = tokens.length;
+ const handleNextTokenCancelled = () => {
+ if (--countdown === 0) {
+ const reasons = tokens.map((token) => token._reason);
+ combined.cancel(reasons);
+ }
+ };
+ tokens.forEach((token) => token.onCancelled(handleNextTokenCancelled));
+ return combined.token;
+ }
+
+ /**
+ * Create a {CancellationToken} that is cancelled when at least one of the given tokens is cancelled.
+ *
+ * This is like {Promise<T>.race} for {CancellationToken}s.
+ */
+ public static race(...tokens: CancellationToken[]): CancellationToken {
+ // If *any* of the tokens is already cancelled, immediately return that token.
+ for (const token of tokens) {
+ if (token._isCancelled) {
+ return token;
+ }
+ }
+
+ const combined = CancellationToken.create();
+ let unregistrations: (() => void)[];
+ const handleAnyTokenCancelled = (reason?: any) => {
+ unregistrations.forEach((unregister) => unregister()); // release memory
+ combined.cancel(reason);
+ };
+ unregistrations = tokens.map((token) =>
+ token.onCancelled(handleAnyTokenCancelled),
+ );
+ return combined.token;
+ }
+}
+
+/* istanbul ignore next */
+namespace CancellationToken {
+ /**
+ * Provides a {CancellationToken}, along with some methods to operate on it.
+ */
+ export interface Source {
+ /**
+ * The token provided by this source.
+ */
+ token: CancellationToken;
+
+ /**
+ * Cancel the provided token with the given reason.
+ * Do nothing if the provided token cannot be cancelled or is already cancelled.
+ */
+ cancel(reason?: any): void;
+
+ /**
+ * Dispose of the token and this source and release memory.
+ */
+ dispose(): void;
+ }
+
+ /**
+ * The error that is thrown when a {CancellationToken} has been cancelled and a
+ * consumer of the token calls {CancellationToken.throwIfCancelled} on it.
+ */
+ export class CancellationError extends Error {
+ public constructor(
+ /**
+ * The reason why the token was cancelled.
+ */
+ public readonly reason: any,
+ ) {
+ super("Operation cancelled");
+ Object.setPrototypeOf(this, CancellationError.prototype);
+ }
+ }
+}
+
+export { CancellationToken };
diff --git a/packages/taler-util/src/MerchantApiClient.ts b/packages/taler-util/src/MerchantApiClient.ts
new file mode 100644
index 000000000..c27f1d582
--- /dev/null
+++ b/packages/taler-util/src/MerchantApiClient.ts
@@ -0,0 +1,380 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { codecForAny } from "./codec.js";
+import {
+ TalerMerchantApi,
+ codecForMerchantConfig,
+ codecForMerchantOrderPrivateStatusResponse,
+} from "./http-client/types.js";
+import { HttpStatusCode } from "./http-status-codes.js";
+import {
+ createPlatformHttpLib,
+ expectSuccessResponseOrThrow,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "./http.js";
+import { FacadeCredentials } from "./libeufin-api-types.js";
+import { LibtoolVersion } from "./libtool-version.js";
+import { Logger } from "./logging.js";
+import {
+ MerchantInstancesResponse,
+ MerchantPostOrderRequest,
+ MerchantPostOrderResponse,
+ MerchantTemplateAddDetails,
+ codecForMerchantPostOrderResponse,
+} from "./merchant-api-types.js";
+import {
+ FailCasesByMethod,
+ OperationFail,
+ OperationOk,
+ ResultByMethod,
+ opEmptySuccess,
+ opKnownHttpFailure,
+ opSuccessFromHttp,
+ opUnknownFailure,
+} from "./operation.js";
+import { AmountString } from "./taler-types.js";
+import { TalerProtocolDuration } from "./time.js";
+
+const logger = new Logger("MerchantApiClient.ts");
+
+// FIXME: Explain!
+export type TalerMerchantResultByMethod<prop extends keyof MerchantApiClient> =
+ ResultByMethod<MerchantApiClient, prop>;
+
+// FIXME: Explain!
+export type TalerMerchantErrorsByMethod<prop extends keyof MerchantApiClient> =
+ FailCasesByMethod<MerchantApiClient, prop>;
+
+export interface MerchantAuthConfiguration {
+ method: "external" | "token";
+ token?: string;
+}
+
+// FIXME: Why do we need this? Describe / fix!
+export interface PartialMerchantInstanceConfig {
+ auth?: MerchantAuthConfiguration;
+ id: string;
+ name: string;
+ paytoUris: string[];
+ address?: unknown;
+ jurisdiction?: unknown;
+ defaultWireTransferDelay?: TalerProtocolDuration;
+ defaultPayDelay?: TalerProtocolDuration;
+}
+
+export interface CreateMerchantTippingReserveRequest {
+ // Amount that the merchant promises to put into the reserve
+ initial_balance: AmountString;
+
+ // Exchange the merchant intends to use for tipping
+ exchange_url: string;
+
+ // Desired wire method, for example "iban" or "x-taler-bank"
+ wire_method: string;
+}
+
+export interface DeleteTippingReserveArgs {
+ reservePub: string;
+ purge?: boolean;
+}
+
+interface MerchantBankAccount {
+ // The payto:// URI where the wallet will send coins.
+ payto_uri: string;
+
+ // Optional base URL for a facade where the
+ // merchant backend can see incoming wire
+ // transfers to reconcile its accounting
+ // with that of the exchange. Used by
+ // taler-merchant-wirewatch.
+ credit_facade_url?: string;
+
+ // Credentials for accessing the credit facade.
+ credit_facade_credentials?: FacadeCredentials;
+}
+
+export interface MerchantInstanceConfig {
+ auth: MerchantAuthConfiguration;
+ id: string;
+ name: string;
+ address: unknown;
+ jurisdiction: unknown;
+ use_stefan: boolean;
+ default_wire_transfer_delay: TalerProtocolDuration;
+ default_pay_delay: TalerProtocolDuration;
+}
+
+export interface PrivateOrderStatusQuery {
+ instance?: string;
+ orderId: string;
+ sessionId?: string;
+}
+
+export interface OtpDeviceAddDetails {
+ // Device ID to use.
+ otp_device_id: string;
+
+ // Human-readable description for the device.
+ otp_device_description: string;
+
+ // A base64-encoded key
+ otp_key: string;
+
+ // Algorithm for computing the POS confirmation.
+ otp_algorithm: number;
+
+ // Counter for counter-based OTP devices.
+ otp_ctr?: number;
+}
+
+/**
+ * Client for the GNU Taler merchant backend.
+ */
+export class MerchantApiClient {
+ /**
+ * Base URL for the particular instance that this merchant API client
+ * is for.
+ */
+ private baseUrl: string;
+
+ readonly auth: MerchantAuthConfiguration;
+
+ public readonly PROTOCOL_VERSION = "6:0:2";
+
+ constructor(
+ baseUrl: string,
+ options: { auth?: MerchantAuthConfiguration } = {},
+ ) {
+ this.baseUrl = baseUrl;
+
+ this.auth = options?.auth ?? {
+ method: "external",
+ };
+ }
+
+ httpClient = createPlatformHttpLib();
+
+ async changeAuth(auth: MerchantAuthConfiguration): Promise<void> {
+ const url = new URL("private/auth", this.baseUrl);
+ const res = await this.httpClient.fetch(url.href, {
+ method: "POST",
+ body: auth,
+ headers: this.makeAuthHeader(),
+ });
+ await expectSuccessResponseOrThrow(res);
+ }
+
+ async getPrivateInstanceInfo(): Promise<any> {
+ const url = new URL("private", this.baseUrl);
+ const resp = await this.httpClient.fetch(url.href, {
+ method: "GET",
+ headers: this.makeAuthHeader(),
+ });
+ return await resp.json();
+ }
+
+ async deleteInstance(instanceId: string) {
+ const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
+ const resp = await this.httpClient.fetch(url.href, {
+ method: "DELETE",
+ headers: this.makeAuthHeader(),
+ });
+ await expectSuccessResponseOrThrow(resp);
+ }
+
+ async createInstance(req: MerchantInstanceConfig): Promise<void> {
+ const url = new URL("management/instances", this.baseUrl);
+ await this.httpClient.fetch(url.href, {
+ method: "POST",
+ body: req,
+ headers: this.makeAuthHeader(),
+ });
+ }
+
+ async getInstances(): Promise<MerchantInstancesResponse> {
+ const url = new URL("management/instances", this.baseUrl);
+ const resp = await this.httpClient.fetch(url.href, {
+ headers: this.makeAuthHeader(),
+ });
+ return readSuccessResponseJsonOrThrow(resp, codecForAny());
+ }
+
+ async getInstanceFullDetails(instanceId: string): Promise<any> {
+ const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
+ try {
+ const resp = await this.httpClient.fetch(url.href, {
+ headers: this.makeAuthHeader(),
+ });
+ return resp.json();
+ } catch (e) {
+ throw e;
+ }
+ }
+
+ async createOrder(
+ req: MerchantPostOrderRequest,
+ ): Promise<MerchantPostOrderResponse> {
+ let url = new URL("private/orders", this.baseUrl);
+ const resp = await this.httpClient.fetch(url.href, {
+ method: "POST",
+ body: req,
+ headers: this.makeAuthHeader(),
+ });
+ return readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantPostOrderResponse(),
+ );
+ }
+
+ async deleteOrder(req: { orderId: string; force?: boolean }): Promise<void> {
+ let url = new URL(`private/orders/${req.orderId}`, this.baseUrl);
+ if (req.force) {
+ url.searchParams.set("force", "yes");
+ }
+ const resp = await this.httpClient.fetch(url.href, {
+ method: "DELETE",
+ body: req,
+ headers: this.makeAuthHeader(),
+ });
+ if (resp.status !== 204) {
+ throw Error(`failed to delete order (status ${resp.status})`);
+ }
+ }
+
+ async queryPrivateOrderStatus(
+ query: PrivateOrderStatusQuery,
+ ): Promise<TalerMerchantApi.MerchantOrderStatusResponse> {
+ const reqUrl = new URL(`private/orders/${query.orderId}`, this.baseUrl);
+ if (query.sessionId) {
+ reqUrl.searchParams.set("session_id", query.sessionId);
+ }
+ const resp = await this.httpClient.fetch(reqUrl.href, {
+ headers: this.makeAuthHeader(),
+ });
+ return readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderPrivateStatusResponse(),
+ );
+ }
+
+ async giveRefund(r: {
+ instance: string;
+ orderId: string;
+ amount: string;
+ justification: string;
+ }): Promise<{ talerRefundUri: string }> {
+ const reqUrl = new URL(`private/orders/${r.orderId}/refund`, this.baseUrl);
+ const resp = await this.httpClient.fetch(reqUrl.href, {
+ method: "POST",
+ body: {
+ refund: r.amount,
+ reason: r.justification,
+ },
+ });
+ const respBody = await resp.json();
+ return {
+ talerRefundUri: respBody.taler_refund_uri,
+ };
+ }
+
+ async createTemplate(req: MerchantTemplateAddDetails) {
+ let url = new URL("private/templates", this.baseUrl);
+ const resp = await this.httpClient.fetch(url.href, {
+ method: "POST",
+ body: req,
+ headers: this.makeAuthHeader(),
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ async getTemplate(templateId: string) {
+ let url = new URL(`private/templates/${templateId}`, this.baseUrl);
+ const resp = await this.httpClient.fetch(url.href, {
+ method: "GET",
+ headers: this.makeAuthHeader(),
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForAny());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ isCompatible(version: string): boolean {
+ const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version);
+ return compare?.compatible ?? false;
+ }
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get--config
+ *
+ */
+ async getConfig(): Promise<OperationOk<TalerMerchantApi.VersionResponse>> {
+ const url = new URL(`config`, this.baseUrl);
+ const resp = await this.httpClient.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForMerchantConfig());
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ async createOtpDevice(
+ req: OtpDeviceAddDetails,
+ ): Promise<OperationOk<void> | OperationFail<HttpStatusCode.NotFound>> {
+ let url = new URL("private/otp-devices", this.baseUrl);
+ const resp = await this.httpClient.fetch(url.href, {
+ method: "POST",
+ body: req,
+ headers: this.makeAuthHeader(),
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ private makeAuthHeader(): Record<string, string> {
+ switch (this.auth.method) {
+ case "external":
+ return {};
+ case "token":
+ return {
+ Authorization: `Bearer ${this.auth.token}`,
+ };
+ }
+ }
+}
diff --git a/packages/taler-wallet-core/src/util/RequestThrottler.ts b/packages/taler-util/src/RequestThrottler.ts
index d79afe47a..2f59612de 100644
--- a/packages/taler-wallet-core/src/util/RequestThrottler.ts
+++ b/packages/taler-util/src/RequestThrottler.ts
@@ -14,20 +14,12 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-/**
- * Implementation of token bucket throttling.
- */
+import { Logger } from "./logging.js";
+import { AbsoluteTime } from "./time.js";
/**
- * Imports.
+ * Implementation of token bucket throttling.
*/
-import {
- getTimestampNow,
- timestampDifference,
- timestampCmp,
- Logger,
- URL,
-} from "@gnu-taler/taler-util";
const logger = new Logger("RequestThrottler.ts");
@@ -53,16 +45,16 @@ class OriginState {
tokensSecond: number = MAX_PER_SECOND;
tokensMinute: number = MAX_PER_MINUTE;
tokensHour: number = MAX_PER_HOUR;
- private lastUpdate = getTimestampNow();
+ private lastUpdate = AbsoluteTime.now();
private refill(): void {
- const now = getTimestampNow();
- if (timestampCmp(now, this.lastUpdate) < 0) {
+ const now = AbsoluteTime.now();
+ if (AbsoluteTime.cmp(now, this.lastUpdate) < 0) {
// Did the system time change?
this.lastUpdate = now;
return;
}
- const d = timestampDifference(now, this.lastUpdate);
+ const d = AbsoluteTime.difference(now, this.lastUpdate);
if (d.d_ms === "forever") {
throw Error("assertion failed");
}
diff --git a/packages/taler-util/src/ReserveStatus.ts b/packages/taler-util/src/ReserveStatus.ts
index b17207f24..3a30755ce 100644
--- a/packages/taler-util/src/ReserveStatus.ts
+++ b/packages/taler-util/src/ReserveStatus.ts
@@ -21,17 +21,9 @@
/**
* Imports.
*/
-import {
- codecForString,
- buildCodecForObject,
- codecForList,
- Codec,
-} from "./codec.js";
-import { AmountString } from "./talerTypes.js";
-import {
- ReserveTransaction,
- codecForReserveTransaction,
-} from "./ReserveTransaction.js";
+import { codecForAmountString } from "./amounts.js";
+import { Codec, buildCodecForObject } from "./codec.js";
+import { AmountString } from "./taler-types.js";
/**
* Status of a reserve.
@@ -43,15 +35,9 @@ export interface ReserveStatus {
* Balance left in the reserve.
*/
balance: AmountString;
-
- /**
- * Transaction history for the reserve.
- */
- history: ReserveTransaction[];
}
export const codecForReserveStatus = (): Codec<ReserveStatus> =>
buildCodecForObject<ReserveStatus>()
- .property("balance", codecForString())
- .property("history", codecForList(codecForReserveTransaction()))
+ .property("balance", codecForAmountString())
.build("ReserveStatus");
diff --git a/packages/taler-util/src/ReserveTransaction.ts b/packages/taler-util/src/ReserveTransaction.ts
index b282ef189..7a3c69d07 100644
--- a/packages/taler-util/src/ReserveTransaction.ts
+++ b/packages/taler-util/src/ReserveTransaction.ts
@@ -23,6 +23,7 @@
/**
* Imports.
*/
+import { codecForAmountString } from "./amounts.js";
import {
codecForString,
buildCodecForObject,
@@ -37,8 +38,12 @@ import {
EddsaSignatureString,
EddsaPublicKeyString,
CoinPublicKeyString,
-} from "./talerTypes";
-import { Timestamp, codecForTimestamp } from "./time.js";
+} from "./taler-types.js";
+import {
+ AbsoluteTime,
+ codecForTimestamp,
+ TalerProtocolTimestamp,
+} from "./time.js";
export enum ReserveTransactionType {
Withdraw = "WITHDRAW",
@@ -98,7 +103,7 @@ export interface ReserveCreditTransaction {
/**
* Timestamp of the incoming wire transfer.
*/
- timestamp: Timestamp;
+ timestamp: TalerProtocolTimestamp;
}
export interface ReserveClosingTransaction {
@@ -139,7 +144,7 @@ export interface ReserveClosingTransaction {
/**
* Time when the reserve was closed.
*/
- timestamp: Timestamp;
+ timestamp: TalerProtocolTimestamp;
}
export interface ReserveRecoupTransaction {
@@ -165,7 +170,7 @@ export interface ReserveRecoupTransaction {
/**
* Time when the funds were paid back into the reserve.
*/
- timestamp: Timestamp;
+ timestamp: TalerProtocolTimestamp;
/**
* Public key of the coin that was paid back.
@@ -182,46 +187,50 @@ export type ReserveTransaction =
| ReserveClosingTransaction
| ReserveRecoupTransaction;
-export const codecForReserveWithdrawTransaction = (): Codec<ReserveWithdrawTransaction> =>
- buildCodecForObject<ReserveWithdrawTransaction>()
- .property("amount", codecForString())
- .property("h_coin_envelope", codecForString())
- .property("h_denom_pub", codecForString())
- .property("reserve_sig", codecForString())
- .property("type", codecForConstString(ReserveTransactionType.Withdraw))
- .property("withdraw_fee", codecForString())
- .build("ReserveWithdrawTransaction");
-
-export const codecForReserveCreditTransaction = (): Codec<ReserveCreditTransaction> =>
- buildCodecForObject<ReserveCreditTransaction>()
- .property("amount", codecForString())
- .property("sender_account_url", codecForString())
- .property("timestamp", codecForTimestamp)
- .property("wire_reference", codecForNumber())
- .property("type", codecForConstString(ReserveTransactionType.Credit))
- .build("ReserveCreditTransaction");
-
-export const codecForReserveClosingTransaction = (): Codec<ReserveClosingTransaction> =>
- buildCodecForObject<ReserveClosingTransaction>()
- .property("amount", codecForString())
- .property("closing_fee", codecForString())
- .property("exchange_pub", codecForString())
- .property("exchange_sig", codecForString())
- .property("h_wire", codecForString())
- .property("timestamp", codecForTimestamp)
- .property("type", codecForConstString(ReserveTransactionType.Closing))
- .property("wtid", codecForString())
- .build("ReserveClosingTransaction");
-
-export const codecForReserveRecoupTransaction = (): Codec<ReserveRecoupTransaction> =>
- buildCodecForObject<ReserveRecoupTransaction>()
- .property("amount", codecForString())
- .property("coin_pub", codecForString())
- .property("exchange_pub", codecForString())
- .property("exchange_sig", codecForString())
- .property("timestamp", codecForTimestamp)
- .property("type", codecForConstString(ReserveTransactionType.Recoup))
- .build("ReserveRecoupTransaction");
+export const codecForReserveWithdrawTransaction =
+ (): Codec<ReserveWithdrawTransaction> =>
+ buildCodecForObject<ReserveWithdrawTransaction>()
+ .property("amount", codecForAmountString())
+ .property("h_coin_envelope", codecForString())
+ .property("h_denom_pub", codecForString())
+ .property("reserve_sig", codecForString())
+ .property("type", codecForConstString(ReserveTransactionType.Withdraw))
+ .property("withdraw_fee", codecForAmountString())
+ .build("ReserveWithdrawTransaction");
+
+export const codecForReserveCreditTransaction =
+ (): Codec<ReserveCreditTransaction> =>
+ buildCodecForObject<ReserveCreditTransaction>()
+ .property("amount", codecForAmountString())
+ .property("sender_account_url", codecForString())
+ .property("timestamp", codecForTimestamp)
+ .property("wire_reference", codecForNumber())
+ .property("type", codecForConstString(ReserveTransactionType.Credit))
+ .build("ReserveCreditTransaction");
+
+export const codecForReserveClosingTransaction =
+ (): Codec<ReserveClosingTransaction> =>
+ buildCodecForObject<ReserveClosingTransaction>()
+ .property("amount", codecForAmountString())
+ .property("closing_fee", codecForAmountString())
+ .property("exchange_pub", codecForString())
+ .property("exchange_sig", codecForString())
+ .property("h_wire", codecForString())
+ .property("timestamp", codecForTimestamp)
+ .property("type", codecForConstString(ReserveTransactionType.Closing))
+ .property("wtid", codecForString())
+ .build("ReserveClosingTransaction");
+
+export const codecForReserveRecoupTransaction =
+ (): Codec<ReserveRecoupTransaction> =>
+ buildCodecForObject<ReserveRecoupTransaction>()
+ .property("amount", codecForAmountString())
+ .property("coin_pub", codecForString())
+ .property("exchange_pub", codecForString())
+ .property("exchange_sig", codecForString())
+ .property("timestamp", codecForTimestamp)
+ .property("type", codecForConstString(ReserveTransactionType.Recoup))
+ .build("ReserveRecoupTransaction");
export const codecForReserveTransaction = (): Codec<ReserveTransaction> =>
buildCodecForUnion<ReserveTransaction>()
diff --git a/packages/taler-util/src/TaskThrottler.ts b/packages/taler-util/src/TaskThrottler.ts
new file mode 100644
index 000000000..e4fb82171
--- /dev/null
+++ b/packages/taler-util/src/TaskThrottler.ts
@@ -0,0 +1,160 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Logger } from "./logging.js";
+import { AbsoluteTime, Duration } from "./time.js";
+
+/**
+ * Implementation of token bucket throttling.
+ */
+
+/**
+ * Logger.
+ */
+const logger = new Logger("OperationThrottler.ts");
+
+/**
+ * Maximum request per second, per origin.
+ */
+const MAX_PER_SECOND = 100;
+
+/**
+ * Maximum request per minute, per origin.
+ */
+const MAX_PER_MINUTE = 500;
+
+/**
+ * Maximum request per hour, per origin.
+ */
+const MAX_PER_HOUR = 2000;
+
+/**
+ * Throttling state for one task.
+ */
+class TaskState {
+ tokensSecond: number = MAX_PER_SECOND;
+ tokensMinute: number = MAX_PER_MINUTE;
+ tokensHour: number = MAX_PER_HOUR;
+ lastUpdate = AbsoluteTime.now();
+
+ private refill(): void {
+ const now = AbsoluteTime.now();
+ if (AbsoluteTime.cmp(now, this.lastUpdate) < 0) {
+ // Did the system time change?
+ this.lastUpdate = now;
+ return;
+ }
+ const d = AbsoluteTime.difference(now, this.lastUpdate);
+ if (d.d_ms === "forever") {
+ throw Error("assertion failed");
+ }
+ this.tokensSecond = Math.min(
+ MAX_PER_SECOND,
+ this.tokensSecond + d.d_ms / 1000,
+ );
+ this.tokensMinute = Math.min(
+ MAX_PER_MINUTE,
+ this.tokensMinute + d.d_ms / 1000 / 60,
+ );
+ this.tokensHour = Math.min(
+ MAX_PER_HOUR,
+ this.tokensHour + d.d_ms / 1000 / 60 / 60,
+ );
+ this.lastUpdate = now;
+ }
+
+ /**
+ * Return true if the request for this origin should be throttled.
+ * Otherwise, take a token out of the respective buckets.
+ */
+ applyThrottle(): boolean {
+ this.refill();
+ if (this.tokensSecond < 1) {
+ logger.warn("request throttled (per second limit exceeded)");
+ return true;
+ }
+ if (this.tokensMinute < 1) {
+ logger.warn("request throttled (per minute limit exceeded)");
+ return true;
+ }
+ if (this.tokensHour < 1) {
+ logger.warn("request throttled (per hour limit exceeded)");
+ return true;
+ }
+ this.tokensSecond--;
+ this.tokensMinute--;
+ this.tokensHour--;
+ return false;
+ }
+}
+
+/**
+ * Request throttler, used as a "last layer of defense" when some
+ * other part of the re-try logic is broken and we're sending too
+ * many requests to the same exchange/bank/merchant.
+ */
+export class TaskThrottler {
+ private perTaskInfo: { [taskId: string]: TaskState } = {};
+
+ /**
+ * Get the throttling state for an origin, or
+ * initialize if no state is associated with the
+ * origin yet.
+ */
+ private getState(origin: string): TaskState {
+ const s = this.perTaskInfo[origin];
+ if (s) {
+ return s;
+ }
+ const ns = (this.perTaskInfo[origin] = new TaskState());
+ return ns;
+ }
+
+ /**
+ * Apply throttling to a request.
+ *
+ * @returns whether the request should be throttled.
+ */
+ applyThrottle(taskId: string): boolean {
+ for (let [k, v] of Object.entries(this.perTaskInfo)) {
+ // Remove throttled tasks that haven't seen an update in more than one hour.
+ if (
+ Duration.cmp(
+ AbsoluteTime.difference(v.lastUpdate, AbsoluteTime.now()),
+ Duration.fromSpec({ hours: 1 }),
+ ) > 1
+ ) {
+ delete this.perTaskInfo[k];
+ }
+ }
+ return this.getState(taskId).applyThrottle();
+ }
+
+ /**
+ * Get the throttle statistics for a particular URL.
+ */
+ getThrottleStats(taskId: string): Record<string, unknown> {
+ const state = this.getState(taskId);
+ return {
+ tokensHour: state.tokensHour,
+ tokensMinute: state.tokensMinute,
+ tokensSecond: state.tokensSecond,
+ maxTokensHour: MAX_PER_HOUR,
+ maxTokensMinute: MAX_PER_MINUTE,
+ maxTokensSecond: MAX_PER_SECOND,
+ };
+ }
+}
diff --git a/packages/taler-util/src/amounts.test.ts b/packages/taler-util/src/amounts.test.ts
index 064023e2d..449a6319a 100644
--- a/packages/taler-util/src/amounts.test.ts
+++ b/packages/taler-util/src/amounts.test.ts
@@ -17,6 +17,7 @@
import test from "ava";
import { Amounts, AmountJson, amountMaxValue } from "./amounts.js";
+import { AmountString } from "./taler-types.js";
const jAmt = (
value: number,
@@ -120,21 +121,71 @@ test("amount parsing", (t) => {
});
test("amount stringification", (t) => {
- t.is(Amounts.stringify(jAmt(0, 0, "TESTKUDOS")), "TESTKUDOS:0");
- t.is(Amounts.stringify(jAmt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94");
- t.is(Amounts.stringify(jAmt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1");
- t.is(Amounts.stringify(jAmt(0, 1, "TESTKUDOS")), "TESTKUDOS:0.00000001");
- t.is(Amounts.stringify(jAmt(5, 0, "TESTKUDOS")), "TESTKUDOS:5");
+ t.is(
+ Amounts.stringify(jAmt(0, 0, "TESTKUDOS")),
+ "TESTKUDOS:0" as AmountString,
+ );
+ t.is(
+ Amounts.stringify(jAmt(4, 94000000, "TESTKUDOS")),
+ "TESTKUDOS:4.94" as AmountString,
+ );
+ t.is(
+ Amounts.stringify(jAmt(0, 10000000, "TESTKUDOS")),
+ "TESTKUDOS:0.1" as AmountString,
+ );
+ t.is(
+ Amounts.stringify(jAmt(0, 1, "TESTKUDOS")),
+ "TESTKUDOS:0.00000001" as AmountString,
+ );
+ t.is(
+ Amounts.stringify(jAmt(5, 0, "TESTKUDOS")),
+ "TESTKUDOS:5" as AmountString,
+ );
// denormalized
- t.is(Amounts.stringify(jAmt(1, 100000000, "TESTKUDOS")), "TESTKUDOS:2");
+ t.is(
+ Amounts.stringify(jAmt(1, 100000000, "TESTKUDOS")),
+ "TESTKUDOS:2" as AmountString,
+ );
t.pass();
});
test("amount multiplication", (t) => {
- t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 0).amount), "EUR:0");
- t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 1).amount), "EUR:1.11");
- t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 2).amount), "EUR:2.22");
- t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 3).amount), "EUR:3.33");
- t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 4).amount), "EUR:4.44");
- t.is(Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 5).amount), "EUR:5.55");
+ t.is(
+ Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 0).amount),
+ "EUR:0" as AmountString,
+ );
+ t.is(
+ Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 1).amount),
+ "EUR:1.11" as AmountString,
+ );
+ t.is(
+ Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 2).amount),
+ "EUR:2.22" as AmountString,
+ );
+ t.is(
+ Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 3).amount),
+ "EUR:3.33" as AmountString,
+ );
+ t.is(
+ Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 4).amount),
+ "EUR:4.44" as AmountString,
+ );
+ t.is(
+ Amounts.stringify(Amounts.mult(sAmt("EUR:1.11"), 5).amount),
+ "EUR:5.55" as AmountString,
+ );
+});
+
+test("amount division", (t) => {
+ t.is(Amounts.divmod("EUR:5", "EUR:1").quotient, 5);
+ t.is(
+ Amounts.stringify(Amounts.divmod("EUR:5", "EUR:1").remainder),
+ "EUR:0" as AmountString,
+ );
+
+ t.is(Amounts.divmod("EUR:5", "EUR:2").quotient, 2);
+ t.is(
+ Amounts.stringify(Amounts.divmod("EUR:5", "EUR:2").remainder),
+ "EUR:1" as AmountString,
+ );
});
diff --git a/packages/taler-util/src/amounts.ts b/packages/taler-util/src/amounts.ts
index 5a8c7f06f..82a3d3b68 100644
--- a/packages/taler-util/src/amounts.ts
+++ b/packages/taler-util/src/amounts.ts
@@ -22,12 +22,16 @@
* Imports.
*/
import {
+ Codec,
+ Context,
+ DecodingError,
buildCodecForObject,
- codecForString,
codecForNumber,
- Codec,
+ codecForString,
+ renderContext,
} from "./codec.js";
-import { AmountString } from "./talerTypes.js";
+import { CurrencySpecification } from "./index.js";
+import { AmountString } from "./taler-types.js";
/**
* Number of fractional units that one value unit represents.
@@ -47,6 +51,11 @@ export const amountFractionalLength = 8;
export const amountMaxValue = 2 ** 52;
/**
+ * Separator character between integer and fractional
+ */
+export const FRAC_SEPARATOR = ".";
+
+/**
* Non-negative financial amount. Fractional values are expressed as multiples
* of 1e-8.
*/
@@ -67,6 +76,48 @@ export interface AmountJson {
readonly currency: string;
}
+/**
+ * Immutable amount.
+ */
+export class Amount {
+ static from(a: AmountLike): Amount {
+ return new Amount(Amounts.parseOrThrow(a), 0);
+ }
+
+ static zeroOfCurrency(currency: string): Amount {
+ return new Amount(Amounts.zeroOfCurrency(currency), 0);
+ }
+
+ add(...a: AmountLike[]): Amount {
+ if (this.saturated) {
+ return this;
+ }
+ const r = Amounts.add(this.val, ...a);
+ return new Amount(r.amount, r.saturated ? 1 : 0);
+ }
+
+ mult(n: number): Amount {
+ if (this.saturated) {
+ return this;
+ }
+ const r = Amounts.mult(this, n);
+ return new Amount(r.amount, r.saturated ? 1 : 0);
+ }
+
+ toJson(): AmountJson {
+ return { ...this.val };
+ }
+
+ toString(): AmountString {
+ return Amounts.stringify(this.val);
+ }
+
+ private constructor(
+ private val: AmountJson,
+ private saturated: number,
+ ) {}
+}
+
export const codecForAmountJson = (): Codec<AmountJson> =>
buildCodecForObject<AmountJson>()
.property("currency", codecForString())
@@ -74,7 +125,23 @@ export const codecForAmountJson = (): Codec<AmountJson> =>
.property("fraction", codecForNumber())
.build("AmountJson");
-export const codecForAmountString = (): Codec<AmountString> => codecForString();
+export function codecForAmountString(): Codec<AmountString> {
+ return {
+ decode(x: any, c?: Context): AmountString {
+ if (typeof x !== "string") {
+ throw new DecodingError(
+ `expected string at ${renderContext(c)} but got ${typeof x}`,
+ );
+ }
+ if (Amounts.parse(x) === undefined) {
+ throw new DecodingError(
+ `invalid amount at ${renderContext(c)} got "${x}"`,
+ );
+ }
+ return x as AmountString;
+ },
+ };
+}
/**
* Result of a possibly overflowing operation.
@@ -93,7 +160,12 @@ export interface Result {
/**
* Type for things that are treated like amounts.
*/
-export type AmountLike = AmountString | AmountJson;
+export type AmountLike = string | AmountString | AmountJson | Amount;
+
+export interface DivmodResult {
+ quotient: number;
+ remainder: AmountJson;
+}
/**
* Helper class for dealing with amounts.
@@ -103,10 +175,24 @@ export class Amounts {
throw Error("not instantiable");
}
+ static currencyOf(amount: AmountLike) {
+ const amt = Amounts.parseOrThrow(amount);
+ return amt.currency;
+ }
+
+ static zeroOfAmount(amount: AmountLike): AmountJson {
+ const amt = Amounts.parseOrThrow(amount);
+ return {
+ currency: amt.currency,
+ fraction: 0,
+ value: 0,
+ };
+ }
+
/**
* Get an amount that represents zero units of a currency.
*/
- static getZero(currency: string): AmountJson {
+ static zeroOfCurrency(currency: string): AmountJson {
return {
currency,
fraction: 0,
@@ -118,9 +204,37 @@ export class Amounts {
if (typeof amt === "string") {
return Amounts.parseOrThrow(amt);
}
+ if (amt instanceof Amount) {
+ return amt.toJson();
+ }
return amt;
}
+ static divmod(a1: AmountLike, a2: AmountLike): DivmodResult {
+ const am1 = Amounts.jsonifyAmount(a1);
+ const am2 = Amounts.jsonifyAmount(a2);
+ if (am1.currency != am2.currency) {
+ throw Error(`incompatible currency (${am1.currency} vs${am2.currency})`);
+ }
+
+ const x1 =
+ BigInt(am1.value) * BigInt(amountFractionalBase) + BigInt(am1.fraction);
+ const x2 =
+ BigInt(am2.value) * BigInt(amountFractionalBase) + BigInt(am2.fraction);
+
+ const quotient = x1 / x2;
+ const remainderScaled = x1 % x2;
+
+ return {
+ quotient: Number(quotient),
+ remainder: {
+ currency: am1.currency,
+ value: Number(remainderScaled / BigInt(amountFractionalBase)),
+ fraction: Number(remainderScaled % BigInt(amountFractionalBase)),
+ },
+ };
+ }
+
static sum(amounts: AmountLike[]): Result {
if (amounts.length <= 0) {
throw Error("can't sum zero amounts");
@@ -129,6 +243,17 @@ export class Amounts {
return Amounts.add(jsonAmounts[0], ...jsonAmounts.slice(1));
}
+ static sumOrZero(currency: string, amounts: AmountLike[]): Result {
+ if (amounts.length <= 0) {
+ return {
+ amount: Amounts.zeroOfCurrency(currency),
+ saturated: false,
+ };
+ }
+ const jsonAmounts = amounts.map((x) => Amounts.jsonifyAmount(x));
+ return Amounts.add(jsonAmounts[0], ...jsonAmounts.slice(1));
+ }
+
/**
* Add two amounts. Return the result and whether
* the addition overflowed. The overflow is always handled
@@ -136,9 +261,11 @@ export class Amounts {
*
* Throws when currencies don't match.
*/
- static add(first: AmountJson, ...rest: AmountJson[]): Result {
- const currency = first.currency;
- let value = first.value + Math.floor(first.fraction / amountFractionalBase);
+ static add(first: AmountLike, ...rest: AmountLike[]): Result {
+ const firstJ = Amounts.jsonifyAmount(first);
+ const currency = firstJ.currency;
+ let value =
+ firstJ.value + Math.floor(firstJ.fraction / amountFractionalBase);
if (value > amountMaxValue) {
return {
amount: {
@@ -149,17 +276,18 @@ export class Amounts {
saturated: true,
};
}
- let fraction = first.fraction % amountFractionalBase;
+ let fraction = firstJ.fraction % amountFractionalBase;
for (const x of rest) {
- if (x.currency !== currency) {
- throw Error(`Mismatched currency: ${x.currency} and ${currency}`);
+ const xJ = Amounts.jsonifyAmount(x);
+ if (xJ.currency.toUpperCase() !== currency.toUpperCase()) {
+ throw Error(`Mismatched currency: ${xJ.currency} and ${currency}`);
}
value =
value +
- x.value +
- Math.floor((fraction + x.fraction) / amountFractionalBase);
- fraction = Math.floor((fraction + x.fraction) % amountFractionalBase);
+ xJ.value +
+ Math.floor((fraction + xJ.fraction) / amountFractionalBase);
+ fraction = Math.floor((fraction + xJ.fraction) % amountFractionalBase);
if (value > amountMaxValue) {
return {
amount: {
@@ -181,16 +309,18 @@ export class Amounts {
*
* Throws when currencies don't match.
*/
- static sub(a: AmountJson, ...rest: AmountJson[]): Result {
- const currency = a.currency;
- let value = a.value;
- let fraction = a.fraction;
+ static sub(a: AmountLike, ...rest: AmountLike[]): Result {
+ const aJ = Amounts.jsonifyAmount(a);
+ const currency = aJ.currency;
+ let value = aJ.value;
+ let fraction = aJ.fraction;
for (const b of rest) {
- if (b.currency !== currency) {
- throw Error(`Mismatched currency: ${b.currency} and ${currency}`);
+ const bJ = Amounts.jsonifyAmount(b);
+ if (bJ.currency.toUpperCase() !== aJ.currency.toUpperCase()) {
+ throw Error(`Mismatched currency: ${bJ.currency} and ${currency}`);
}
- if (fraction < b.fraction) {
+ if (fraction < bJ.fraction) {
if (value < 1) {
return {
amount: { currency, value: 0, fraction: 0 },
@@ -200,12 +330,12 @@ export class Amounts {
value--;
fraction += amountFractionalBase;
}
- console.assert(fraction >= b.fraction);
- fraction -= b.fraction;
- if (value < b.value) {
+ console.assert(fraction >= bJ.fraction);
+ fraction -= bJ.fraction;
+ if (value < bJ.value) {
return { amount: { currency, value: 0, fraction: 0 }, saturated: true };
}
- value -= b.value;
+ value -= bJ.value;
}
return { amount: { currency, value, fraction }, saturated: false };
@@ -273,7 +403,8 @@ export class Amounts {
/**
* Check if an amount is non-zero.
*/
- static isNonZero(a: AmountJson): boolean {
+ static isNonZero(a: AmountLike): boolean {
+ a = Amounts.jsonifyAmount(a);
return a.value > 0 || a.fraction > 0;
}
@@ -283,14 +414,24 @@ export class Amounts {
}
/**
+ * Check whether a string is a valid currency for a Taler amount.
+ */
+ static isCurrency(s: string): boolean {
+ return /^[a-zA-Z]{1,11}$/.test(s);
+ }
+
+ /**
* Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct.
+ *
+ * Currency name size limit is 11 of ASCII letters
+ * Fraction size limit is 8
*/
static parse(s: string): AmountJson | undefined {
- const res = s.match(/^([a-zA-Z0-9_*-]+):([0-9]+)([.][0-9]+)?$/);
+ const res = s.match(/^([a-zA-Z]{1,11}):([0-9]+)([.][0-9]{1,8})?$/);
if (!res) {
return undefined;
}
- const tail = res[3] || ".0";
+ const tail = res[3] || FRAC_SEPARATOR + "0";
if (tail.length > amountFractionalLength + 1) {
return undefined;
}
@@ -299,7 +440,7 @@ export class Amounts {
return undefined;
}
return {
- currency: res[1],
+ currency: res[1].toUpperCase(),
fraction: Math.round(amountFractionalBase * Number.parseFloat(tail)),
value,
};
@@ -309,26 +450,30 @@ export class Amounts {
* Parse amount in standard string form (like 'EUR:20.5'),
* throw if the input is not a valid amount.
*/
- static parseOrThrow(s: string): AmountJson {
- const res = Amounts.parse(s);
- if (!res) {
- throw Error(`Can't parse amount: "${s}"`);
+ static parseOrThrow(s: AmountLike): AmountJson {
+ if (s instanceof Amount) {
+ return s.toJson();
+ }
+ if (typeof s === "object") {
+ if (typeof s.currency !== "string") {
+ throw Error("invalid amount object");
+ }
+ if (typeof s.value !== "number") {
+ throw Error("invalid amount object");
+ }
+ if (typeof s.fraction !== "number") {
+ throw Error("invalid amount object");
+ }
+ return { currency: s.currency, value: s.value, fraction: s.fraction };
+ } else if (typeof s === "string") {
+ const res = Amounts.parse(s);
+ if (!res) {
+ throw Error(`Can't parse amount: "${s}"`);
+ }
+ return res;
+ } else {
+ throw Error("invalid amount (illegal type)");
}
- return res;
- }
-
- /**
- * Convert a float to a Taler amount.
- * Loss of precision possible.
- */
- static fromFloat(floatVal: number, currency: string): AmountJson {
- return {
- currency,
- fraction: Math.floor(
- (floatVal - Math.floor(floatVal)) * amountFractionalBase,
- ),
- value: Math.floor(floatVal),
- };
}
static min(a: AmountLike, b: AmountLike): AmountJson {
@@ -349,18 +494,22 @@ export class Amounts {
}
}
- static mult(a: AmountJson, n: number): Result {
+ static mult(a: AmountLike, n: number): Result {
+ a = this.jsonifyAmount(a);
if (!Number.isInteger(n)) {
- throw Error("amount can only be multipied by an integer");
+ throw Error("amount can only be multiplied by an integer");
}
if (n < 0) {
throw Error("amount can only be multiplied by a positive integer");
}
if (n == 0) {
- return { amount: Amounts.getZero(a.currency), saturated: false };
+ return {
+ amount: Amounts.zeroOfCurrency(a.currency),
+ saturated: false,
+ };
}
let x = a;
- let acc = Amounts.getZero(a.currency);
+ let acc = Amounts.zeroOfCurrency(a.currency);
while (n > 1) {
if (n % 2 == 0) {
n = n / 2;
@@ -400,20 +549,31 @@ export class Amounts {
* Convert to standard human-readable string representation that's
* also used in JSON formats.
*/
- static stringify(a: AmountLike): string {
+ static stringify(a: AmountLike): AmountString {
a = Amounts.jsonifyAmount(a);
- const s = this.stringifyValue(a)
+ const s = this.stringifyValue(a);
- return `${a.currency}:${s}`;
+ return `${a.currency}:${s}` as AmountString;
}
- static stringifyValue(a: AmountJson, minFractional: number = 0): string {
- const av = a.value + Math.floor(a.fraction / amountFractionalBase);
- const af = a.fraction % amountFractionalBase;
+ static amountHasSameCurrency(a1: AmountLike, a2: AmountLike): boolean {
+ const x1 = this.jsonifyAmount(a1);
+ const x2 = this.jsonifyAmount(a2);
+ return x1.currency.toUpperCase() === x2.currency.toUpperCase();
+ }
+
+ static isSameCurrency(curr1: string, curr2: string): boolean {
+ return curr1.toLowerCase() === curr2.toLowerCase();
+ }
+
+ static stringifyValue(a: AmountLike, minFractional = 0): string {
+ const aJ = Amounts.jsonifyAmount(a);
+ const av = aJ.value + Math.floor(aJ.fraction / amountFractionalBase);
+ const af = aJ.fraction % amountFractionalBase;
let s = av.toString();
- if (af) {
- s = s + ".";
+ if (af || minFractional) {
+ s = s + FRAC_SEPARATOR;
let n = af;
for (let i = 0; i < amountFractionalLength; i++) {
if (!n && i >= minFractional) {
@@ -423,6 +583,102 @@ export class Amounts {
n = (n * 10) % amountFractionalBase;
}
}
- return s
+
+ return s;
+ }
+
+ /**
+ * Number of fractional digits needed to fully represent the amount
+ * @param a amount
+ * @returns
+ */
+ static maxFractionalDigits(a: AmountJson): number {
+ if (a.fraction === 0) return 0;
+ if (a.fraction < 0) {
+ console.error("amount fraction can not be negative", a);
+ return 0;
+ }
+ let i = 0;
+ let check = true;
+ let rest = a.fraction;
+ while (rest > 0 && check) {
+ check = rest % 10 === 0;
+ rest = rest / 10;
+ i++;
+ }
+ return amountFractionalLength - i + 1;
+ }
+
+ static stringifyValueWithSpec(
+ value: AmountJson,
+ spec: CurrencySpecification,
+ ): { currency: string; normal: string; small?: string } {
+ const strValue = Amounts.stringifyValue(value);
+ const pos = strValue.indexOf(FRAC_SEPARATOR);
+ const originalPosition = pos < 0 ? strValue.length : pos;
+
+ let currency = value.currency;
+ const names = Object.keys(spec.alt_unit_names);
+ let FRAC_POS_NEW_POSITION = originalPosition;
+ //find symbol
+ //FIXME: this should be based on a cache to speed up
+ if (names.length > 0) {
+ let unitIndex: string = "0"; //default entry by DD51
+ names.forEach((index) => {
+ const i = Number.parseInt(index, 10);
+ if (Number.isNaN(i)) return; //skip
+ if (originalPosition - i <= 0) return; //too big
+ if (originalPosition - i < FRAC_POS_NEW_POSITION) {
+ FRAC_POS_NEW_POSITION = originalPosition - i;
+ unitIndex = index;
+ }
+ });
+ currency = spec.alt_unit_names[unitIndex];
+ }
+
+ if (originalPosition === FRAC_POS_NEW_POSITION) {
+ const { normal, small } = splitNormalAndSmall(
+ strValue,
+ originalPosition,
+ spec,
+ );
+ return { currency, normal, small };
+ }
+
+ const intPart = strValue.substring(0, originalPosition);
+ const fracPArt = strValue.substring(originalPosition + 1);
+ //indexSize is always smaller than originalPosition
+ const newValue =
+ intPart.substring(0, FRAC_POS_NEW_POSITION) +
+ FRAC_SEPARATOR +
+ intPart.substring(FRAC_POS_NEW_POSITION) +
+ fracPArt;
+ const { normal, small } = splitNormalAndSmall(
+ newValue,
+ FRAC_POS_NEW_POSITION,
+ spec,
+ );
+ return { currency, normal, small };
+ }
+}
+
+function splitNormalAndSmall(
+ decimal: string,
+ fracSeparatorIndex: number,
+ spec: CurrencySpecification,
+): { normal: string; small?: string } {
+ let normal: string;
+ let small: string | undefined;
+ if (
+ decimal.length - fracSeparatorIndex - 1 >
+ spec.num_fractional_normal_digits
+ ) {
+ const limit = fracSeparatorIndex + spec.num_fractional_normal_digits + 1;
+ normal = decimal.substring(0, limit);
+ small = decimal.substring(limit);
+ } else {
+ normal = decimal;
+ small = undefined;
}
+ return { normal, small };
}
diff --git a/packages/taler-util/src/argon2-impl.missing.ts b/packages/taler-util/src/argon2-impl.missing.ts
new file mode 100644
index 000000000..2e175bc75
--- /dev/null
+++ b/packages/taler-util/src/argon2-impl.missing.ts
@@ -0,0 +1,9 @@
+export async function HashArgon2idImpl(
+ password: Uint8Array,
+ salt: Uint8Array,
+ iterations: number,
+ memorySize: number,
+ hashLength: number,
+): Promise<Uint8Array> {
+ throw new Error("Method not implemented.");
+}
diff --git a/packages/taler-util/src/argon2-impl.node.ts b/packages/taler-util/src/argon2-impl.node.ts
new file mode 100644
index 000000000..d1a36c4fe
--- /dev/null
+++ b/packages/taler-util/src/argon2-impl.node.ts
@@ -0,0 +1,19 @@
+import { argon2id } from "hash-wasm";
+
+export async function HashArgon2idImpl(
+ password: Uint8Array,
+ salt: Uint8Array,
+ iterations: number,
+ memorySize: number,
+ hashLength: number,
+): Promise<Uint8Array> {
+ return await argon2id({
+ password: password,
+ salt: salt,
+ iterations: iterations,
+ memorySize: memorySize,
+ hashLength: hashLength,
+ parallelism: 1,
+ outputType: "binary",
+ });
+}
diff --git a/packages/taler-util/src/argon2.ts b/packages/taler-util/src/argon2.ts
new file mode 100644
index 000000000..aebfb6962
--- /dev/null
+++ b/packages/taler-util/src/argon2.ts
@@ -0,0 +1,17 @@
+import * as impl from "#argon2-impl";
+
+export async function hashArgon2id(
+ password: Uint8Array,
+ salt: Uint8Array,
+ iterations: number,
+ memorySize: number,
+ hashLength: number,
+): Promise<Uint8Array> {
+ return await impl.HashArgon2idImpl(
+ password,
+ salt,
+ iterations,
+ memorySize,
+ hashLength,
+ );
+}
diff --git a/packages/taler-util/src/backup-types.ts b/packages/taler-util/src/backup-types.ts
new file mode 100644
index 000000000..8c38b70a6
--- /dev/null
+++ b/packages/taler-util/src/backup-types.ts
@@ -0,0 +1,42 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountString } from "./taler-types.js";
+
+export interface BackupRecovery {
+ walletRootPriv: string;
+ providers: {
+ name: string;
+ url: string;
+ }[];
+}
+
+export class BackupBackupProviderTerms {
+ /**
+ * Last known supported protocol version.
+ */
+ supported_protocol_version: string;
+
+ /**
+ * Last known annual fee.
+ */
+ annual_fee: AmountString;
+
+ /**
+ * Last known storage limit.
+ */
+ storage_limit_in_megabytes: number;
+}
diff --git a/packages/taler-util/src/backupTypes.ts b/packages/taler-util/src/backupTypes.ts
deleted file mode 100644
index 70e52e63b..000000000
--- a/packages/taler-util/src/backupTypes.ts
+++ /dev/null
@@ -1,1280 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Type declarations for the backup content format.
- *
- * Contains some redundancy with the other type declarations,
- * as the backup schema must remain very stable and should be self-contained.
- *
- * Future:
- * 1. Ghost spends (coin unexpectedly spent by a wallet with shared data)
- * 2. Ghost withdrawals (reserve unexpectedly emptied by another wallet with shared data)
- * 3. Track losses through re-denomination of payments/refreshes
- * 4. (Feature:) Payments to own bank account and P2P-payments need to be backed up
- * 5. Track last/next update time, so on restore we need to do less work
- * 6. Currency render preferences?
- *
- * Questions:
- * 1. What happens when two backups are merged that have
- * the same coin in different refresh groups?
- * => Both are added, one will eventually fail
- * 2. Should we make more information forgettable? I.e. is
- * the coin selection still relevant for a purchase after the coins
- * are legally expired?
- * => Yes, still needs to be implemented
- * 3. What about re-denominations / re-selection of payment coins?
- * Is it enough to store a clock value for the selection?
- * => Coin derivation should also consider denom pub hash
- *
- * General considerations / decisions:
- * 1. Information about previously occurring errors and
- * retries is never backed up.
- * 2. The ToS text of an exchange is never backed up.
- * 3. Derived information is never backed up (hashed values, public keys
- * when we know the private key).
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import { Duration, Timestamp } from "./time.js";
-
-/**
- * Type alias for strings that are to be treated like amounts.
- */
-type BackupAmountString = string;
-
-/**
- * A human-recognizable identifier here that is
- * reasonable unique and assigned the first time the wallet is
- * started/installed, such as:
- *
- * `${wallet-implementation} ${os} ${hostname} (${short-uid})`
- * => e.g. "GNU Taler Android iceking ABC123"
- */
-type DeviceIdString = string;
-
-/**
- * Contract terms JSON.
- */
-type RawContractTerms = any;
-
-/**
- * Unique identifier for an operation, used to either (a) reference
- * the operation in a tombstone (b) disambiguate conflicting writes.
- */
-type OperationUid = string;
-
-/**
- * Content of the backup.
- *
- * The contents of the wallet must be serialized in a deterministic
- * way across implementations, so that the normalized backup content
- * JSON is identical when the wallet's content is identical.
- */
-export interface WalletBackupContentV1 {
- /**
- * Magic constant to identify that this is a backup content JSON.
- */
- schema_id: "gnu-taler-wallet-backup-content";
-
- /**
- * Version of the schema.
- */
- schema_version: 1;
-
- /**
- * Root public key of the wallet. This field is present as
- * a sanity check if the backup content JSON is loaded from file.
- */
- wallet_root_pub: string;
-
- /**
- * Current device identifier that "owns" the backup.
- *
- * This identifier allows one wallet to notice when another
- * wallet is "alive" and connected to the same sync provider.
- */
- current_device_id: DeviceIdString;
-
- /**
- * Timestamp of the backup.
- *
- * This timestamp should only be advanced if the content
- * of the backup changes.
- */
- timestamp: Timestamp;
-
- /**
- * Per-exchange data sorted by exchange master public key.
- *
- * Sorted by the exchange public key.
- */
- exchanges: BackupExchange[];
-
- exchange_details: BackupExchangeDetails[];
-
- /**
- * Grouped refresh sessions.
- *
- * Sorted by the refresh group ID.
- */
- refresh_groups: BackupRefreshGroup[];
-
- /**
- * Tips.
- *
- * Sorted by the wallet tip ID.
- */
- tips: BackupTip[];
-
- /**
- * Proposals from merchants. The proposal may
- * be deleted as soon as it has been accepted (and thus
- * turned into a purchase).
- *
- * Sorted by the proposal ID.
- */
- proposals: BackupProposal[];
-
- /**
- * Accepted purchases.
- *
- * Sorted by the proposal ID.
- */
- purchases: BackupPurchase[];
-
- /**
- * All backup providers. Backup providers
- * in this list should be considered "active".
- *
- * Sorted by the provider base URL.
- */
- backup_providers: BackupBackupProvider[];
-
- /**
- * Recoup groups.
- */
- recoup_groups: BackupRecoupGroup[];
-
- /**
- * Trusted auditors, either for official (3 letter) or local (4-12 letter)
- * currencies.
- *
- * Auditors are sorted by their canonicalized base URL.
- */
- trusted_auditors: { [currency: string]: BackupTrustAuditor[] };
-
- /**
- * Trusted exchange. Only applicable for local currencies (4-12 letter currency code).
- *
- * Exchanges are sorted by their canonicalized base URL.
- */
- trusted_exchanges: { [currency: string]: BackupTrustExchange[] };
-
- /**
- * Interning table for forgettable values of contract terms.
- *
- * Used to reduce storage space, as many forgettable items (product image,
- * addresses, etc.) might be shared among many contract terms.
- */
- intern_table: { [hash: string]: any };
-
- /**
- * Permanent error reports.
- */
- error_reports: BackupErrorReport[];
-
- /**
- * Deletion tombstones. Lexically sorted.
- */
- tombstones: Tombstone[];
-}
-
-/**
- * Tombstone in the format "<type>:<key>"
- */
-export type Tombstone = string;
-
-/**
- * Detailed error report.
- *
- * For auditor-relevant reports with attached cryptographic proof,
- * the error report also should contain the submission status to
- * the auditor(s).
- */
-interface BackupErrorReport {
- // FIXME: specify!
-}
-
-/**
- * Trust declaration for an auditor.
- *
- * The trust applies based on the public key of
- * the auditor, irrespective of what base URL the exchange
- * is referencing.
- */
-export interface BackupTrustAuditor {
- /**
- * Base URL of the auditor.
- */
- auditor_base_url: string;
-
- /**
- * Public key of the auditor.
- */
- auditor_pub: string;
-
- /**
- * UIDs for the operation of adding this auditor
- * as a trusted auditor.
- */
- uids: OperationUid;
-}
-
-/**
- * Trust declaration for an exchange.
- *
- * The trust only applies for the combination of base URL
- * and public key. If the master public key changes while the base
- * URL stays the same, the exchange has to be re-added by a wallet update
- * or by the user.
- */
-export interface BackupTrustExchange {
- /**
- * Canonicalized exchange base URL.
- */
- exchange_base_url: string;
-
- /**
- * Master public key of the exchange.
- */
- exchange_master_pub: string;
-
- /**
- * UIDs for the operation of adding this exchange
- * as trusted.
- */
- uids: OperationUid;
-}
-
-export class BackupBackupProviderTerms {
- /**
- * Last known supported protocol version.
- */
- supported_protocol_version: string;
-
- /**
- * Last known annual fee.
- */
- annual_fee: BackupAmountString;
-
- /**
- * Last known storage limit.
- */
- storage_limit_in_megabytes: number;
-}
-
-/**
- * Backup information about one backup storage provider.
- */
-export class BackupBackupProvider {
- /**
- * Canonicalized base URL of the provider.
- */
- base_url: string;
-
- /**
- * Last known terms. Might be unavailable in some situations, such
- * as directly after restoring form a backup recovery document.
- */
- terms?: BackupBackupProviderTerms;
-
- /**
- * Proposal IDs for payments to this provider.
- */
- pay_proposal_ids: string[];
-
- /**
- * UIDs for adding this backup provider.
- */
- uids: OperationUid[];
-}
-
-/**
- * Status of recoup operations that were grouped together.
- *
- * The remaining amount of the corresponding coins must be set to
- * zero when the recoup group is created/imported.
- */
-export interface BackupRecoupGroup {
- /**
- * Unique identifier for the recoup group record.
- */
- recoup_group_id: string;
-
- /**
- * Timestamp when the recoup was started.
- */
- timestamp_created: Timestamp;
-
- timestamp_finish?: Timestamp;
- finish_clock?: Timestamp;
- finish_is_failure?: boolean;
-
- /**
- * Information about each coin being recouped.
- */
- coins: {
- coin_pub: string;
- recoup_finished: boolean;
- old_amount: BackupAmountString;
- }[];
-}
-
-/**
- * Types of coin sources.
- */
-export enum BackupCoinSourceType {
- Withdraw = "withdraw",
- Refresh = "refresh",
- Tip = "tip",
-}
-
-/**
- * Metadata about a coin obtained via withdrawing.
- */
-export interface BackupWithdrawCoinSource {
- type: BackupCoinSourceType.Withdraw;
-
- /**
- * Can be the empty string for orphaned coins.
- */
- withdrawal_group_id: string;
-
- /**
- * Index of the coin in the withdrawal session.
- */
- coin_index: number;
-
- /**
- * Reserve public key for the reserve we got this coin from.
- */
- reserve_pub: string;
-}
-
-/**
- * Metadata about a coin obtained from refreshing.
- *
- * FIXME: Currently does not link to the refreshGroupId because
- * the wallet DB doesn't do this. Not really necessary,
- * but would be more consistent.
- */
-export interface BackupRefreshCoinSource {
- type: BackupCoinSourceType.Refresh;
-
- /**
- * Public key of the coin that was refreshed into this coin.
- */
- old_coin_pub: string;
-}
-
-/**
- * Metadata about a coin obtained from a tip.
- */
-export interface BackupTipCoinSource {
- type: BackupCoinSourceType.Tip;
-
- /**
- * Wallet's identifier for the tip that this coin
- * originates from.
- */
- wallet_tip_id: string;
-
- /**
- * Index in the tip planchets of the tip.
- */
- coin_index: number;
-}
-
-/**
- * Metadata about a coin depending on the origin.
- */
-export type BackupCoinSource =
- | BackupWithdrawCoinSource
- | BackupRefreshCoinSource
- | BackupTipCoinSource;
-
-/**
- * Backup information about a coin.
- *
- * (Always part of a BackupExchange/BackupDenom)
- */
-export interface BackupCoin {
- /**
- * Where did the coin come from? Used for recouping coins.
- */
- coin_source: BackupCoinSource;
-
- /**
- * Private key to authorize operations on the coin.
- */
- coin_priv: string;
-
- /**
- * Unblinded signature by the exchange.
- */
- denom_sig: string;
-
- /**
- * Amount that's left on the coin.
- */
- current_amount: BackupAmountString;
-
- /**
- * Blinding key used when withdrawing the coin.
- * Potentionally used again during payback.
- */
- blinding_key: string;
-
- /**
- * Does the wallet think that the coin is still fresh?
- *
- * Note that even if a fresh coin is imported, it should still
- * be refreshed in most situations.
- */
- fresh: boolean;
-}
-
-/**
- * Status of a tip we got from a merchant.
- */
-export interface BackupTip {
- /**
- * Tip ID chosen by the wallet.
- */
- wallet_tip_id: string;
-
- /**
- * The merchant's identifier for this tip.
- */
- merchant_tip_id: string;
-
- /**
- * Secret seed used for the tipping planchets.
- */
- secret_seed: string;
-
- /**
- * Has the user accepted the tip? Only after the tip has been accepted coins
- * withdrawn from the tip may be used.
- */
- timestamp_accepted: Timestamp | undefined;
-
- /**
- * When was the tip first scanned by the wallet?
- */
- timestamp_created: Timestamp;
-
- timestamp_finished?: Timestamp;
- finish_is_failure?: boolean;
-
- /**
- * The tipped amount.
- */
- tip_amount_raw: BackupAmountString;
-
- /**
- * Timestamp, the tip can't be picked up anymore after this deadline.
- */
- timestamp_expiration: Timestamp;
-
- /**
- * The exchange that will sign our coins, chosen by the merchant.
- */
- exchange_base_url: string;
-
- /**
- * Base URL of the merchant that is giving us the tip.
- */
- merchant_base_url: string;
-
- /**
- * Selected denominations. Determines the effective tip amount.
- */
- selected_denoms: BackupDenomSel;
-
- /**
- * UID for the denomination selection.
- * Used to disambiguate when merging.
- */
- selected_denoms_uid: OperationUid;
-}
-
-/**
- * Reasons for why a coin is being refreshed.
- */
-export enum BackupRefreshReason {
- Manual = "manual",
- Pay = "pay",
- Refund = "refund",
- AbortPay = "abort-pay",
- Recoup = "recoup",
- BackupRestored = "backup-restored",
- Scheduled = "scheduled",
-}
-
-/**
- * Information about one refresh session, always part
- * of a refresh group.
- *
- * (Public key of the old coin is stored in the refresh group.)
- */
-export interface BackupRefreshSession {
- /**
- * Hashed denominations of the newly requested coins.
- */
- new_denoms: BackupDenomSel;
-
- /**
- * Seed used to derive the planchets and
- * transfer private keys for this refresh session.
- */
- session_secret_seed: string;
-
- /**
- * The no-reveal-index after we've done the melting.
- */
- noreveal_index?: number;
-}
-
-/**
- * Refresh session for one coin inside a refresh group.
- */
-export interface BackupRefreshOldCoin {
- /**
- * Public key of the old coin,
- */
- coin_pub: string;
-
- /**
- * Requested amount to refresh. Must be subtracted from the coin's remaining
- * amount as soon as the coin is added to the refresh group.
- */
- input_amount: BackupAmountString;
-
- /**
- * Estimated output (may change if it takes a long time to create the
- * actual session).
- */
- estimated_output_amount: BackupAmountString;
-
- /**
- * Did the refresh session finish (or was it unnecessary/impossible to create
- * one)
- */
- finished: boolean;
-
- /**
- * Refresh session (if created) or undefined it not created yet.
- */
- refresh_session: BackupRefreshSession | undefined;
-}
-
-/**
- * Information about one refresh group.
- *
- * May span more than one exchange, but typically doesn't
- */
-export interface BackupRefreshGroup {
- refresh_group_id: string;
-
- reason: BackupRefreshReason;
-
- /**
- * Details per old coin.
- */
- old_coins: BackupRefreshOldCoin[];
-
- timestamp_created: Timestamp;
-
- timestamp_finish?: Timestamp;
- finish_is_failure?: boolean;
-}
-
-/**
- * Backup information for a withdrawal group.
- *
- * Always part of a BackupReserve.
- */
-export interface BackupWithdrawalGroup {
- withdrawal_group_id: string;
-
- /**
- * Secret seed to derive the planchets.
- */
- secret_seed: string;
-
- /**
- * When was the withdrawal operation started started?
- * Timestamp in milliseconds.
- */
- timestamp_created: Timestamp;
-
- timestamp_finish?: Timestamp;
- finish_is_failure?: boolean;
-
- /**
- * Amount including fees (i.e. the amount subtracted from the
- * reserve to withdraw all coins in this withdrawal session).
- *
- * Note that this *includes* the amount remaining in the reserve
- * that is too small to be withdrawn, and thus can't be derived
- * from selectedDenoms.
- */
- raw_withdrawal_amount: BackupAmountString;
-
- /**
- * Multiset of denominations selected for withdrawal.
- */
- selected_denoms: BackupDenomSel;
-
- selected_denoms_id: OperationUid;
-}
-
-export enum BackupRefundState {
- Failed = "failed",
- Applied = "applied",
- Pending = "pending",
-}
-
-/**
- * Common information about a refund.
- */
-export interface BackupRefundItemCommon {
- /**
- * Execution time as claimed by the merchant
- */
- execution_time: Timestamp;
-
- /**
- * Time when the wallet became aware of the refund.
- */
- obtained_time: Timestamp;
-
- /**
- * Amount refunded for the coin.
- */
- refund_amount: BackupAmountString;
-
- /**
- * Coin being refunded.
- */
- coin_pub: string;
-
- /**
- * The refund transaction ID for the refund.
- */
- rtransaction_id: number;
-
- /**
- * Upper bound on the refresh cost incurred by
- * applying this refund.
- *
- * Might be lower in practice when two refunds on the same
- * coin are refreshed in the same refresh operation.
- *
- * Used to display fees, and stored since it's expensive to recompute
- * accurately.
- */
- total_refresh_cost_bound: BackupAmountString;
-}
-
-/**
- * Failed refund, either because the merchant did
- * something wrong or it expired.
- */
-export interface BackupRefundFailedItem extends BackupRefundItemCommon {
- type: BackupRefundState.Failed;
-}
-
-export interface BackupRefundPendingItem extends BackupRefundItemCommon {
- type: BackupRefundState.Pending;
-}
-
-export interface BackupRefundAppliedItem extends BackupRefundItemCommon {
- type: BackupRefundState.Applied;
-}
-
-/**
- * State of one refund from the merchant, maintained by the wallet.
- */
-export type BackupRefundItem =
- | BackupRefundFailedItem
- | BackupRefundPendingItem
- | BackupRefundAppliedItem;
-
-export interface BackupPurchase {
- /**
- * Proposal ID for this purchase. Uniquely identifies the
- * purchase and the proposal.
- */
- proposal_id: string;
-
- /**
- * Contract terms we got from the merchant.
- */
- contract_terms_raw: RawContractTerms;
-
- /**
- * Signature on the contract terms.
- */
- merchant_sig: string;
-
- /**
- * Private key for the nonce. Might eventually be used
- * to prove ownership of the contract.
- */
- nonce_priv: string;
-
- pay_coins: {
- /**
- * Public keys of the coins that were selected.
- */
- coin_pub: string;
-
- /**
- * Amount that each coin contributes.
- */
- contribution: BackupAmountString;
- }[];
-
- /**
- * Unique ID to disambiguate pay coin selection on merge.
- */
- pay_coins_uid: OperationUid;
-
- /**
- * Total cost initially shown to the user.
- *
- * This includes the amount taken by the merchant, fees (wire/deposit) contributed
- * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
- * of coins that are too small to spend.
- *
- * Note that in rare situations, this cost might not be accurate (e.g.
- * when the payment or refresh gets re-denominated).
- * We might show adjustments to this later, but currently we don't do so.
- */
- total_pay_cost: BackupAmountString;
-
- /**
- * Timestamp of the first time that sending a payment to the merchant
- * for this purchase was successful.
- */
- timestamp_first_successful_pay: Timestamp | undefined;
-
- /**
- * Signature by the merchant confirming the payment.
- */
- merchant_pay_sig: string | undefined;
-
- /**
- * When was the purchase made?
- * Refers to the time that the user accepted.
- */
- timestamp_accept: Timestamp;
-
- /**
- * Pending refunds for the purchase. A refund is pending
- * when the merchant reports a transient error from the exchange.
- */
- refunds: BackupRefundItem[];
-
- /**
- * Abort status of the payment.
- */
- abort_status?: "abort-refund" | "abort-finished";
-
- /**
- * Continue querying the refund status until this deadline has expired.
- */
- auto_refund_deadline: Timestamp | undefined;
-}
-
-/**
- * Info about one denomination in the backup.
- *
- * Note that the wallet only backs up validated denominations.
- */
-export interface BackupDenomination {
- /**
- * Value of one coin of the denomination.
- */
- value: BackupAmountString;
-
- /**
- * The denomination public key.
- */
- denom_pub: string;
-
- /**
- * Fee for withdrawing.
- */
- fee_withdraw: BackupAmountString;
-
- /**
- * Fee for depositing.
- */
- fee_deposit: BackupAmountString;
-
- /**
- * Fee for refreshing.
- */
- fee_refresh: BackupAmountString;
-
- /**
- * Fee for refunding.
- */
- fee_refund: BackupAmountString;
-
- /**
- * Validity start date of the denomination.
- */
- stamp_start: Timestamp;
-
- /**
- * Date after which the currency can't be withdrawn anymore.
- */
- stamp_expire_withdraw: Timestamp;
-
- /**
- * Date after the denomination officially doesn't exist anymore.
- */
- stamp_expire_legal: Timestamp;
-
- /**
- * Data after which coins of this denomination can't be deposited anymore.
- */
- stamp_expire_deposit: Timestamp;
-
- /**
- * Signature by the exchange's master key over the denomination
- * information.
- */
- master_sig: string;
-
- /**
- * Was this denomination still offered by the exchange the last time
- * we checked?
- * Only false when the exchange redacts a previously published denomination.
- */
- is_offered: boolean;
-
- /**
- * Did the exchange revoke the denomination?
- * When this field is set to true in the database, the same transaction
- * should also mark all affected coins as revoked.
- */
- is_revoked: boolean;
-
- /**
- * Coins of this denomination.
- */
- coins: BackupCoin[];
-
- /**
- * The list issue date of the exchange "/keys" response
- * that this denomination was last seen in.
- */
- list_issue_date: Timestamp;
-}
-
-/**
- * Denomination selection.
- */
-export type BackupDenomSel = {
- denom_pub_hash: string;
- count: number;
-}[];
-
-export interface BackupReserve {
- /**
- * The reserve private key.
- */
- reserve_priv: string;
-
- /**
- * Time when the reserve was created.
- */
- timestamp_created: Timestamp;
-
- /**
- * Timestamp of the last observed activity.
- *
- * Used to compute when to give up querying the exchange.
- */
- timestamp_last_activity: Timestamp;
-
- /**
- * Timestamp of when the reserve closed.
- *
- * Note that the last activity can be after the closing time
- * due to recouping.
- */
- timestamp_closed?: Timestamp;
-
- /**
- * Wire information (as payto URI) for the bank account that
- * transferred funds for this reserve.
- */
- sender_wire?: string;
-
- /**
- * Amount that was sent by the user to fund the reserve.
- */
- instructed_amount: BackupAmountString;
-
- /**
- * Extra state for when this is a withdrawal involving
- * a Taler-integrated bank.
- */
- bank_info?: {
- /**
- * Status URL that the wallet will use to query the status
- * of the Taler withdrawal operation on the bank's side.
- */
- status_url: string;
-
- /**
- * URL that the user should be instructed to navigate to
- * in order to confirm the transfer (or show instructions/help
- * on how to do that at a PoS terminal).
- */
- confirm_url?: string;
-
- /**
- * Exchange payto URI that the bank will use to fund the reserve.
- */
- exchange_payto_uri: string;
-
- /**
- * Time when the information about this reserve was posted to the bank.
- */
- timestamp_reserve_info_posted: Timestamp | undefined;
-
- /**
- * Time when the reserve was confirmed by the bank.
- *
- * Set to undefined if not confirmed yet.
- */
- timestamp_bank_confirmed: Timestamp | undefined;
- };
-
- /**
- * Pre-allocated withdrawal group ID that will be
- * used for the first withdrawal.
- *
- * (Already created so it can be referenced in the transactions list
- * before it really exists, as there'll be an entry for the withdrawal
- * even before the withdrawal group really has been created).
- */
- initial_withdrawal_group_id: string;
-
- /**
- * Denominations selected for the initial withdrawal.
- * Stored here to show costs before withdrawal has begun.
- */
- initial_selected_denoms: BackupDenomSel;
-
- /**
- * Groups of withdrawal operations for this reserve. Typically just one.
- */
- withdrawal_groups: BackupWithdrawalGroup[];
-}
-
-/**
- * Wire fee for one wire payment target type as stored in the
- * wallet's database.
- *
- * (Flattened to a list to make the declaration simpler).
- */
-export interface BackupExchangeWireFee {
- wire_type: string;
-
- /**
- * Fee for wire transfers.
- */
- wire_fee: string;
-
- /**
- * Fees to close and refund a reserve.
- */
- closing_fee: string;
-
- /**
- * Start date of the fee.
- */
- start_stamp: Timestamp;
-
- /**
- * End date of the fee.
- */
- end_stamp: Timestamp;
-
- /**
- * Signature made by the exchange master key.
- */
- sig: string;
-}
-
-/**
- * Structure of one exchange signing key in the /keys response.
- */
-export class BackupExchangeSignKey {
- stamp_start: Timestamp;
- stamp_expire: Timestamp;
- stamp_end: Timestamp;
- key: string;
- master_sig: string;
-}
-
-/**
- * Signature by the auditor that a particular denomination key is audited.
- */
-export class BackupAuditorDenomSig {
- /**
- * Denomination public key's hash.
- */
- denom_pub_h: string;
-
- /**
- * The signature.
- */
- auditor_sig: string;
-}
-
-/**
- * Auditor information as given by the exchange in /keys.
- */
-export class BackupExchangeAuditor {
- /**
- * Auditor's public key.
- */
- auditor_pub: string;
-
- /**
- * Base URL of the auditor.
- */
- auditor_url: string;
-
- /**
- * List of signatures for denominations by the auditor.
- */
- denomination_keys: BackupAuditorDenomSig[];
-}
-
-/**
- * Backup information for an exchange. Serves effectively
- * as a pointer to the exchange details identified by
- * the base URL, master public key and currency.
- */
-export interface BackupExchange {
- base_url: string;
-
- master_public_key: string;
-
- currency: string;
-
- /**
- * Time when the pointer to the exchange details
- * was last updated.
- *
- * Used to facilitate automatic merging.
- */
- update_clock: Timestamp;
-}
-
-/**
- * Backup information about an exchange's details.
- *
- * Note that one base URL can have multiple exchange
- * details. The BackupExchange stores a pointer
- * to the current exchange details.
- */
-export interface BackupExchangeDetails {
- /**
- * Canonicalized base url of the exchange.
- */
- base_url: string;
-
- /**
- * Master public key of the exchange.
- */
- master_public_key: string;
-
- /**
- * Auditors (partially) auditing the exchange.
- */
- auditors: BackupExchangeAuditor[];
-
- /**
- * Currency that the exchange offers.
- */
- currency: string;
-
- /**
- * Denominations offered by the exchange.
- */
- denominations: BackupDenomination[];
-
- /**
- * Reserves at the exchange.
- */
- reserves: BackupReserve[];
-
- /**
- * Last observed protocol version.
- */
- protocol_version: string;
-
- /**
- * Closing delay of reserves.
- */
- reserve_closing_delay: Duration;
-
- /**
- * Signing keys we got from the exchange, can also contain
- * older signing keys that are not returned by /keys anymore.
- */
- signing_keys: BackupExchangeSignKey[];
-
- wire_fees: BackupExchangeWireFee[];
-
- /**
- * Bank accounts offered by the exchange;
- */
- accounts: {
- payto_uri: string;
- master_sig: string;
- }[];
-
- /**
- * ETag for last terms of service download.
- */
- tos_accepted_etag: string | undefined;
-
- /**
- * Timestamp when the ToS has been accepted.
- */
- tos_accepted_timestamp: Timestamp | undefined;
-}
-
-export enum BackupProposalStatus {
- /**
- * Proposed (and either downloaded or not,
- * depending on whether contract terms are present),
- * but the user needs to accept/reject it.
- */
- Proposed = "proposed",
- /**
- * The user has rejected the proposal.
- */
- Refused = "refused",
- /**
- * Downloading or processing the proposal has failed permanently.
- *
- * FIXME: Should this be modeled as a "misbehavior report" instead?
- */
- PermanentlyFailed = "permanently-failed",
- /**
- * Downloaded proposal was detected as a re-purchase.
- */
- Repurchase = "repurchase",
-}
-
-/**
- * Proposal by a merchant.
- */
-export interface BackupProposal {
- /**
- * Base URL of the merchant that proposed the purchase.
- */
- merchant_base_url: string;
-
- /**
- * Downloaded data from the merchant.
- */
- contract_terms_raw?: RawContractTerms;
-
- /**
- * Signature on the contract terms.
- *
- * Must be present if contract_terms_raw is present.
- */
- merchant_sig?: string;
-
- /**
- * Unique ID when the order is stored in the wallet DB.
- */
- proposal_id: string;
-
- /**
- * Merchant-assigned order ID of the proposal.
- */
- order_id: string;
-
- /**
- * Timestamp of when the record
- * was created.
- */
- timestamp: Timestamp;
-
- /**
- * Private key for the nonce.
- */
- nonce_priv: string;
-
- /**
- * Claim token initially given by the merchant.
- */
- claim_token: string | undefined;
-
- /**
- * Status of the proposal.
- */
- proposal_status: BackupProposalStatus;
-
- /**
- * Proposal that this one got "redirected" to as part of
- * the repurchase detection.
- */
- repurchase_proposal_id: string | undefined;
-
- /**
- * Session ID we got when downloading the contract.
- */
- download_session_id?: string;
-}
-
-export interface BackupRecovery {
- walletRootPriv: string;
- providers: {
- url: string;
- }[];
-}
diff --git a/packages/taler-util/src/bank-api-client.ts b/packages/taler-util/src/bank-api-client.ts
new file mode 100644
index 000000000..51359129d
--- /dev/null
+++ b/packages/taler-util/src/bank-api-client.ts
@@ -0,0 +1,440 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Client for the Taler (demo-)bank.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountString,
+ base64FromArrayBuffer,
+ buildCodecForObject,
+ Codec,
+ codecForAny,
+ codecForString,
+ encodeCrock,
+ getRandomBytes,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ opEmptySuccess,
+ opKnownHttpFailure,
+ opUnknownFailure,
+ stringToBytes,
+ TalerError,
+ TalerErrorCode,
+} from "@gnu-taler/taler-util";
+import {
+ checkSuccessResponseOrThrow,
+ createPlatformHttpLib,
+ HttpRequestLibrary,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+
+const logger = new Logger("bank-api-client.ts");
+
+export enum CreditDebitIndicator {
+ Credit = "credit",
+ Debit = "debit",
+}
+
+export interface BankAccountBalanceResponse {
+ balance: {
+ amount: AmountString;
+ credit_debit_indicator: CreditDebitIndicator;
+ };
+}
+
+export interface BankUser {
+ username: string;
+ password: string;
+ accountPaytoUri: string;
+}
+
+export interface WithdrawalOperationInfo {
+ withdrawal_id: string;
+ taler_withdraw_uri: string;
+}
+
+/**
+ * Helper function to generate the "Authorization" HTTP header.
+ */
+function makeBasicAuthHeader(username: string, password: string): string {
+ const auth = `${username}:${password}`;
+ const authEncoded: string = base64FromArrayBuffer(stringToBytes(auth));
+ return `Basic ${authEncoded}`;
+}
+
+const codecForWithdrawalOperationInfo = (): Codec<WithdrawalOperationInfo> =>
+ buildCodecForObject<WithdrawalOperationInfo>()
+ .property("withdrawal_id", codecForString())
+ .property("taler_withdraw_uri", codecForString())
+ .build("WithdrawalOperationInfo");
+
+export interface BankAccessApiClientArgs {
+ auth?: { username: string; password: string };
+ httpClient?: HttpRequestLibrary;
+}
+
+export interface BankAccessApiCreateTransactionRequest {
+ amount: AmountString;
+ paytoUri: string;
+}
+
+export class WireGatewayApiClientArgs {
+ auth?: {
+ username: string;
+ password: string;
+ };
+ httpClient?: HttpRequestLibrary;
+}
+
+/**
+ * This API look like it belongs to harness
+ * but it will be nice to have in utils to be used by others
+ */
+export class WireGatewayApiClient {
+ httpLib;
+
+ constructor(
+ private baseUrl: string,
+ private args: WireGatewayApiClientArgs = {},
+ ) {
+ this.httpLib = args.httpClient ?? createPlatformHttpLib();
+ }
+
+ private makeAuthHeader(): Record<string, string> {
+ const auth = this.args.auth;
+ if (auth) {
+ return {
+ Authorization: makeBasicAuthHeader(auth.username, auth.password),
+ };
+ }
+ return {};
+ }
+
+ async adminAddIncoming(params: {
+ amount: string;
+ reservePub: string;
+ debitAccountPayto: string;
+ }): Promise<void> {
+ let url = new URL(`admin/add-incoming`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body: {
+ amount: params.amount,
+ reserve_pub: params.reservePub,
+ debit_account: params.debitAccountPayto,
+ },
+ headers: this.makeAuthHeader(),
+ });
+ logger.info(`add-incoming response status: ${resp.status}`);
+ await checkSuccessResponseOrThrow(resp);
+ }
+}
+
+export interface ChallengeContactData {
+ // E-Mail address
+ email?: string;
+
+ // Phone number.
+ phone?: string;
+}
+
+export interface AccountBalance {
+ amount: AmountString;
+ credit_debit_indicator: "credit" | "debit";
+}
+
+export interface RegisterAccountRequest {
+ // Username
+ username: string;
+
+ // Password.
+ password: string;
+
+ // Legal name of the account owner
+ name: string;
+
+ // Defaults to false.
+ is_public?: boolean;
+
+ // Is this a taler exchange account?
+ // If true:
+ // - incoming transactions to the account that do not
+ // have a valid reserve public key are automatically
+ // - the account provides the taler-wire-gateway-api endpoints
+ // Defaults to false.
+ is_taler_exchange?: boolean;
+
+ // Addresses where to send the TAN for transactions.
+ // Currently only used for cashouts.
+ // If missing, cashouts will fail.
+ // In the future, might be used for other transactions
+ // as well.
+ challenge_contact_data?: ChallengeContactData;
+
+ // 'payto' address pointing a bank account
+ // external to the libeufin-bank.
+ // Payments will be sent to this bank account
+ // when the user wants to convert the local currency
+ // back to fiat currency outside libeufin-bank.
+ cashout_payto_uri?: string;
+
+ // Internal payto URI of this bank account.
+ // Used mostly for testing.
+ payto_uri?: string;
+}
+
+export interface AccountData {
+ // Legal name of the account owner.
+ name: string;
+
+ // Available balance on the account.
+ balance: AccountBalance;
+
+ // payto://-URI of the account.
+ payto_uri: string;
+
+ // Number indicating the max debit allowed for the requesting user.
+ debit_threshold: AmountString;
+
+ contact_data?: ChallengeContactData;
+
+ // 'payto' address pointing the bank account
+ // where to send cashouts. This field is optional
+ // because not all the accounts are required to participate
+ // in the merchants' circuit. One example is the exchange:
+ // that never cashouts. Registering these accounts can
+ // be done via the access API.
+ cashout_payto_uri?: string;
+}
+
+export interface ConfirmWithdrawalArgs {
+ withdrawalOperationId: string;
+}
+
+/**
+ * Client for the Taler corebank API.
+ */
+export class TalerCorebankApiClient {
+ httpLib: HttpRequestLibrary;
+
+ constructor(
+ private baseUrl: string,
+ private args: BankAccessApiClientArgs = {},
+ ) {
+ this.httpLib = args.httpClient ?? createPlatformHttpLib();
+ }
+
+ setAuth(auth: { username: string; password: string }) {
+ this.args.auth = auth;
+ }
+
+ private makeAuthHeader(): Record<string, string> {
+ if (!this.args.auth) {
+ return {};
+ }
+ const authHeaderValue = makeBasicAuthHeader(
+ this.args.auth.username,
+ this.args.auth.password,
+ );
+ return {
+ Authorization: authHeaderValue,
+ };
+ }
+
+ async getAccountBalance(
+ username: string,
+ ): Promise<BankAccountBalanceResponse> {
+ const url = new URL(`accounts/${username}`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ headers: this.makeAuthHeader(),
+ });
+ return readSuccessResponseJsonOrThrow(resp, codecForAny());
+ }
+
+ async getTransactions(username: string): Promise<void> {
+ const reqUrl = new URL(`accounts/${username}/transactions`, this.baseUrl);
+ const resp = await this.httpLib.fetch(reqUrl.href, {
+ method: "GET",
+ headers: {
+ ...this.makeAuthHeader(),
+ },
+ });
+
+ const res = await readSuccessResponseJsonOrThrow(resp, codecForAny());
+ logger.info(`result: ${j2s(res)}`);
+ }
+
+ async createTransaction(
+ username: string,
+ req: BankAccessApiCreateTransactionRequest,
+ ): Promise<any> {
+ const reqUrl = new URL(`accounts/${username}/transactions`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(reqUrl.href, {
+ method: "POST",
+ body: req,
+ headers: this.makeAuthHeader(),
+ });
+
+ return await readSuccessResponseJsonOrThrow(resp, codecForAny());
+ }
+
+ async registerAccountExtended(req: RegisterAccountRequest): Promise<void> {
+ const url = new URL("accounts", this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body: req,
+ headers: this.makeAuthHeader(),
+ });
+
+ if (
+ resp.status !== 200 &&
+ resp.status !== 201 &&
+ resp.status !== 202 &&
+ resp.status !== 204
+ ) {
+ logger.error(`unexpected status ${resp.status} from POST ${url.href}`);
+ logger.error(`${j2s(await resp.json())}`);
+ throw TalerError.fromDetail(
+ TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR,
+ {
+ httpStatusCode: resp.status,
+ },
+ );
+ }
+ }
+
+ /**
+ * Register a new account and return information about it.
+ *
+ * This is a helper, as it does both the registration and the
+ * account info query.
+ */
+ async registerAccount(username: string, password: string): Promise<BankUser> {
+ const url = new URL("accounts", this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body: {
+ username,
+ password,
+ name: username,
+ },
+ headers: this.makeAuthHeader(),
+ });
+ if (
+ resp.status !== 200 &&
+ resp.status !== 201 &&
+ resp.status !== 202 &&
+ resp.status !== 204
+ ) {
+ logger.error(`unexpected status ${resp.status} from POST ${url.href}`);
+ logger.error(`${j2s(await resp.json())}`);
+ throw TalerError.fromDetail(
+ TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR,
+ {
+ httpStatusCode: resp.status,
+ },
+ );
+ }
+ // FIXME: Corebank should directly return this info!
+ const infoUrl = new URL(`accounts/${username}`, this.baseUrl);
+ const infoResp = await this.httpLib.fetch(infoUrl.href, {
+ headers: {
+ Authorization: makeBasicAuthHeader(username, password),
+ },
+ });
+ // FIXME: Validate!
+ const acctInfo: AccountData = await readSuccessResponseJsonOrThrow(
+ infoResp,
+ codecForAny(),
+ );
+ return {
+ password,
+ username,
+ accountPaytoUri: acctInfo.payto_uri,
+ };
+ }
+
+ async createRandomBankUser(): Promise<BankUser> {
+ const username = "user-" + encodeCrock(getRandomBytes(10)).toLowerCase();
+ const password = "pw-" + encodeCrock(getRandomBytes(10)).toLowerCase();
+ return await this.registerAccount(username, password);
+ }
+
+ async createWithdrawalOperation(
+ user: string,
+ amount: string,
+ ): Promise<WithdrawalOperationInfo> {
+ const url = new URL(`accounts/${user}/withdrawals`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body: {
+ amount,
+ },
+ headers: this.makeAuthHeader(),
+ });
+ return readSuccessResponseJsonOrThrow(
+ resp,
+ codecForWithdrawalOperationInfo(),
+ );
+ }
+
+ async confirmWithdrawalOperation(
+ username: string,
+ wopi: ConfirmWithdrawalArgs,
+ ) {
+ const url = new URL(
+ `accounts/${username}/withdrawals/${wopi.withdrawalOperationId}/confirm`,
+ this.baseUrl,
+ );
+ logger.info(`confirming withdrawal operation via ${url.href}`);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body: {},
+ headers: this.makeAuthHeader(),
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ async abortWithdrawalOperation(wopi: WithdrawalOperationInfo): Promise<void> {
+ const url = new URL(
+ `withdrawals/${wopi.withdrawal_id}/abort`,
+ this.baseUrl,
+ );
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body: {},
+ headers: this.makeAuthHeader(),
+ });
+ await readSuccessResponseJsonOrThrow(resp, codecForAny());
+ }
+}
diff --git a/packages/taler-util/src/base64.ts b/packages/taler-util/src/base64.ts
new file mode 100644
index 000000000..5d39ee581
--- /dev/null
+++ b/packages/taler-util/src/base64.ts
@@ -0,0 +1,64 @@
+// Converts an ArrayBuffer directly to base64, without any intermediate 'convert to string then
+// use window.btoa' step. According to my tests, this appears to be a faster approach:
+// http://jsperf.com/encoding-xhr-image-data/5
+
+/*
+MIT LICENSE
+Copyright 2011 Jon Leighton
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+
+export function base64FromArrayBuffer(arrayBuffer: ArrayBuffer): string {
+ var base64 = "";
+ var encodings =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+ var bytes = new Uint8Array(arrayBuffer);
+ var byteLength = bytes.byteLength;
+ var byteRemainder = byteLength % 3;
+ var mainLength = byteLength - byteRemainder;
+
+ var a, b, c, d;
+ var chunk;
+
+ // Main loop deals with bytes in chunks of 3
+ for (var i = 0; i < mainLength; i = i + 3) {
+ // Combine the three bytes into a single integer
+ chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
+
+ // Use bitmasks to extract 6-bit segments from the triplet
+ a = (chunk & 16515072) >> 18; // 16515072 = (2^6 - 1) << 18
+ b = (chunk & 258048) >> 12; // 258048 = (2^6 - 1) << 12
+ c = (chunk & 4032) >> 6; // 4032 = (2^6 - 1) << 6
+ d = chunk & 63; // 63 = 2^6 - 1
+
+ // Convert the raw binary segments to the appropriate ASCII encoding
+ base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d];
+ }
+
+ // Deal with the remaining bytes and padding
+ if (byteRemainder == 1) {
+ chunk = bytes[mainLength];
+
+ a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2
+
+ // Set the 4 least significant bits to zero
+ b = (chunk & 3) << 4; // 3 = 2^2 - 1
+
+ base64 += encodings[a] + encodings[b] + "==";
+ } else if (byteRemainder == 2) {
+ chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
+
+ a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10
+ b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4
+
+ // Set the 2 least significant bits to zero
+ c = (chunk & 15) << 2; // 15 = 2^4 - 1
+
+ base64 += encodings[a] + encodings[b] + encodings[c] + "=";
+ }
+
+ return base64;
+}
diff --git a/packages/taler-util/src/bech32.ts b/packages/taler-util/src/bech32.ts
new file mode 100644
index 000000000..e48e9ac3e
--- /dev/null
+++ b/packages/taler-util/src/bech32.ts
@@ -0,0 +1,131 @@
+// Copyright (c) 2017, 2021 Pieter Wuille
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+var CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+var GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
+
+const encodings: any = {
+ BECH32: "bech32",
+ BECH32M: "bech32m",
+};
+
+export default {
+ decode: decode,
+ encode: encode,
+ encodings: encodings,
+};
+
+function getEncodingConst(enc: any) {
+ if (enc == encodings.BECH32) {
+ return 1;
+ } else if (enc == encodings.BECH32M) {
+ return 0x2bc830a3;
+ } else {
+ throw new Error("unknown encoding");
+ }
+}
+
+function polymod(values: any) {
+ var chk = 1;
+ for (var p = 0; p < values.length; ++p) {
+ var top = chk >> 25;
+ chk = ((chk & 0x1ffffff) << 5) ^ values[p];
+ for (var i = 0; i < 5; ++i) {
+ if ((top >> i) & 1) {
+ chk ^= GENERATOR[i];
+ }
+ }
+ }
+ return chk;
+}
+
+function hrpExpand(hrp: any) {
+ var ret = [];
+ var p;
+ for (p = 0; p < hrp.length; ++p) {
+ ret.push(hrp.charCodeAt(p) >> 5);
+ }
+ ret.push(0);
+ for (p = 0; p < hrp.length; ++p) {
+ ret.push(hrp.charCodeAt(p) & 31);
+ }
+ return ret;
+}
+
+function verifyChecksum(hrp: any, data: any, enc: any) {
+ return polymod(hrpExpand(hrp).concat(data)) === getEncodingConst(enc);
+}
+
+function createChecksum(hrp: any, data: any, enc: any) {
+ var values = hrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0]);
+ var mod = polymod(values) ^ getEncodingConst(enc);
+ var ret = [];
+ for (var p = 0; p < 6; ++p) {
+ ret.push((mod >> (5 * (5 - p))) & 31);
+ }
+ return ret;
+}
+
+function encode(hrp: any, data: any, enc: any): string {
+ var combined = data.concat(createChecksum(hrp, data, enc));
+ var ret = hrp + "1";
+ for (var p = 0; p < combined.length; ++p) {
+ ret += CHARSET.charAt(combined[p]);
+ }
+ return ret;
+}
+
+function decode(bechString: any, enc: any) {
+ var p;
+ var has_lower = false;
+ var has_upper = false;
+ for (p = 0; p < bechString.length; ++p) {
+ if (bechString.charCodeAt(p) < 33 || bechString.charCodeAt(p) > 126) {
+ return null;
+ }
+ if (bechString.charCodeAt(p) >= 97 && bechString.charCodeAt(p) <= 122) {
+ has_lower = true;
+ }
+ if (bechString.charCodeAt(p) >= 65 && bechString.charCodeAt(p) <= 90) {
+ has_upper = true;
+ }
+ }
+ if (has_lower && has_upper) {
+ return null;
+ }
+ bechString = bechString.toLowerCase();
+ var pos = bechString.lastIndexOf("1");
+ if (pos < 1 || pos + 7 > bechString.length || bechString.length > 90) {
+ return null;
+ }
+ var hrp = bechString.substring(0, pos);
+ var data = [];
+ for (p = pos + 1; p < bechString.length; ++p) {
+ var d = CHARSET.indexOf(bechString.charAt(p));
+ if (d === -1) {
+ return null;
+ }
+ data.push(d);
+ }
+ if (!verifyChecksum(hrp, data, enc)) {
+ return null;
+ }
+ return { hrp: hrp, data: data.slice(0, data.length - 6) };
+}
diff --git a/packages/taler-util/src/bitcoin.test.ts b/packages/taler-util/src/bitcoin.test.ts
new file mode 100644
index 000000000..fe8de89c3
--- /dev/null
+++ b/packages/taler-util/src/bitcoin.test.ts
@@ -0,0 +1,108 @@
+/*
+ This file is part of GNU Taler
+ (C) 2018-2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Type-safe codecs for converting from/to JSON.
+ */
+
+import test from "ava";
+import { generateFakeSegwitAddress } from "./bitcoin.js";
+
+test("generate testnet", (t) => {
+ const [addr1, addr2] = generateFakeSegwitAddress(
+ "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG",
+ "tb1qhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+
+ t.assert(addr1 === "tb1qtfwqwaj6tsrhdtvuyhflr6nklm8ldqxpf0lfjw");
+ t.assert(addr2 === "tb1qmfwqwa5vr5vdac6wr20ts76aewakzpmns40yuf");
+});
+
+test("generate mainnet", (t) => {
+ const [addr1, addr2] = generateFakeSegwitAddress(
+ "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG",
+ "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
+ );
+ //bc
+ t.assert(addr1 === "bc1qtfwqwaj6tsrhdtvuyhflr6nklm8ldqxprfy6fa");
+ t.assert(addr2 === "bc1qmfwqwa5vr5vdac6wr20ts76aewakzpmn6n5h86");
+});
+
+test("generate Regtest", (t) => {
+ const [addr1, addr2] = generateFakeSegwitAddress(
+ "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG",
+ "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+
+ t.assert(addr1 === "bcrt1qtfwqwaj6tsrhdtvuyhflr6nklm8ldqxptxxy98");
+ t.assert(addr2 === "bcrt1qmfwqwa5vr5vdac6wr20ts76aewakzpmnjukftq");
+});
+
+test("unknown net", (t) => {
+ t.throws(() => {
+ generateFakeSegwitAddress(
+ "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG",
+ "abqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+ });
+});
+
+test("invalid or no reserve", (t) => {
+ let result = undefined;
+ // empty
+ result = generateFakeSegwitAddress(
+ "",
+ "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+ t.deepEqual(result, []);
+ // small
+ result = generateFakeSegwitAddress(
+ "s",
+ "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+ t.deepEqual(result, []);
+ result = generateFakeSegwitAddress(
+ "asdsad",
+ "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+ t.deepEqual(result, []);
+ result = generateFakeSegwitAddress(
+ "asdasdasdasdasdasd",
+ "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+ t.deepEqual(result, []);
+ result = generateFakeSegwitAddress(
+ "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XS",
+ "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+ t.deepEqual(result, []);
+ result = generateFakeSegwitAddress(
+ "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSSSS",
+ "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+ t.deepEqual(result, []);
+ // no reserve
+ result = generateFakeSegwitAddress(
+ undefined,
+ "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+ t.deepEqual(result, []);
+ result = generateFakeSegwitAddress(
+ "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XS-",
+ "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v",
+ );
+ t.deepEqual(result, []);
+});
diff --git a/packages/taler-util/src/bitcoin.ts b/packages/taler-util/src/bitcoin.ts
new file mode 100644
index 000000000..37b7ae6b9
--- /dev/null
+++ b/packages/taler-util/src/bitcoin.ts
@@ -0,0 +1,87 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author sebasjm
+ */
+
+/**
+ * Imports.
+ */
+import { AmountJson, Amounts } from "./amounts.js";
+import { decodeCrock } from "./taler-crypto.js";
+import * as segwit from "./segwit_addr.js";
+
+function buf2hex(buffer: Uint8Array) {
+ // buffer is an ArrayBuffer
+ return [...new Uint8Array(buffer)]
+ .map((x) => x.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+const hext2buf = (hexString: string) =>
+ new Uint8Array(hexString.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)));
+
+export function generateFakeSegwitAddress(
+ reservePub: string | undefined,
+ addr: string,
+): string[] {
+ if (!reservePub) return [];
+ let pub;
+ try {
+ pub = decodeCrock(reservePub);
+ } catch {
+ // pub = new Uint8Array(0)
+ }
+ if (!pub || pub.length !== 32) return [];
+
+ const first_rnd = new Uint8Array(4);
+ first_rnd.set(pub.subarray(0, 4));
+ const second_rnd = new Uint8Array(4);
+ second_rnd.set(pub.subarray(0, 4));
+
+ first_rnd[0] = first_rnd[0] & 0b0111_1111;
+ second_rnd[0] = second_rnd[0] | 0b1000_0000;
+
+ const first_part = new Uint8Array(first_rnd.length + pub.length / 2);
+ first_part.set(first_rnd, 0);
+ first_part.set(pub.subarray(0, 16), 4);
+
+ const second_part = new Uint8Array(first_rnd.length + pub.length / 2);
+ second_part.set(second_rnd, 0);
+ second_part.set(pub.subarray(16, 32), 4);
+
+ const prefix =
+ addr[0] === "t" && addr[1] == "b"
+ ? "tb"
+ : addr[0] === "b" && addr[1] == "c" && addr[2] === "r" && addr[3] == "t"
+ ? "bcrt"
+ : addr[0] === "b" && addr[1] == "c"
+ ? "bc"
+ : undefined;
+ if (prefix === undefined) throw new Error("unknown bitcoin net");
+
+ const addr1 = segwit.default.encode(prefix, 0, first_part);
+ const addr2 = segwit.default.encode(prefix, 0, second_part);
+
+ return [addr1, addr2];
+}
+
+// https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.cpp
+export function segwitMinAmount(currency: string): AmountJson {
+ return Amounts.parseOrThrow(`${currency}:0.00000294`);
+}
diff --git a/packages/taler-util/src/clk.test.ts b/packages/taler-util/src/clk.test.ts
new file mode 100644
index 000000000..9077f07de
--- /dev/null
+++ b/packages/taler-util/src/clk.test.ts
@@ -0,0 +1,39 @@
+/*
+ This file is part of GNU Taler
+ (C) 2018-2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Type-safe codecs for converting from/to JSON.
+ */
+
+import test from "ava";
+import { clk } from "./clk.js";
+
+test("bla", (t) => {
+ const prog = clk.program("foo", {
+ help: "Hello",
+ });
+
+ let success = false;
+
+ prog.maybeOption("opt1", ["-o", "--opt1"], clk.INT).action((args) => {
+ success = true;
+ t.deepEqual(args.foo.opt1, 42);
+ });
+
+ prog.run(["bla", "-o", "42"]);
+
+ t.true(success);
+});
diff --git a/packages/taler-util/src/clk.ts b/packages/taler-util/src/clk.ts
new file mode 100644
index 000000000..60969af69
--- /dev/null
+++ b/packages/taler-util/src/clk.ts
@@ -0,0 +1,633 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ processExit,
+ processArgv,
+ readlinePrompt,
+ pathBasename,
+} from "#compat-impl";
+import { AmountString } from "./taler-types.js";
+
+export namespace clk {
+ class Converter<T> {}
+
+ export const INT = new Converter<number>();
+ export const STRING: Converter<string> = new Converter<string>();
+ export const AMOUNT: Converter<AmountString> = new Converter<AmountString>();
+
+ export interface OptionArgs<T> {
+ help?: string;
+ default?: T;
+ onPresentHandler?: (v: T) => void;
+ }
+
+ export interface ArgumentArgs<T> {
+ metavar?: string;
+ help?: string;
+ default?: T;
+ }
+
+ export interface SubcommandArgs {
+ help?: string;
+ }
+
+ export interface FlagArgs {
+ help?: string;
+ }
+
+ export interface ProgramArgs {
+ help?: string;
+ }
+
+ interface ArgumentDef {
+ name: string;
+ conv: Converter<any>;
+ args: ArgumentArgs<any>;
+ required: boolean;
+ }
+
+ interface SubcommandDef {
+ commandGroup: CommandGroup<any, any>;
+ name: string;
+ args: SubcommandArgs;
+ }
+
+ type ActionFn<TG> = (x: TG) => void;
+
+ type SubRecord<S extends keyof any, N extends keyof any, V> = {
+ [Y in S]: { [X in N]: V };
+ };
+
+ interface OptionDef {
+ name: string;
+ flagspec: string[];
+ /**
+ * Converter, only present for options, not for flags.
+ */
+ conv?: Converter<any>;
+ args: OptionArgs<any>;
+ isFlag: boolean;
+ required: boolean;
+ }
+
+ function splitOpt(opt: string): { key: string; value?: string } {
+ const idx = opt.indexOf("=");
+ if (idx == -1) {
+ return { key: opt };
+ }
+ return { key: opt.substring(0, idx), value: opt.substring(idx + 1) };
+ }
+
+ function formatListing(key: string, value?: string): string {
+ const res = " " + key;
+ if (!value) {
+ return res;
+ }
+ if (res.length >= 25) {
+ return res + "\n" + " " + value;
+ } else {
+ return res.padEnd(24) + " " + value;
+ }
+ }
+
+ export class CommandGroup<GN extends keyof any, TG> {
+ private shortOptions: { [name: string]: OptionDef } = {};
+ private longOptions: { [name: string]: OptionDef } = {};
+ private subcommandMap: { [name: string]: SubcommandDef } = {};
+ private subcommands: SubcommandDef[] = [];
+ private options: OptionDef[] = [];
+ private arguments: ArgumentDef[] = [];
+
+ private myAction?: ActionFn<TG>;
+
+ constructor(
+ private argKey: string,
+ private name: string | null,
+ private scArgs: SubcommandArgs,
+ ) {}
+
+ action(f: ActionFn<TG>): void {
+ if (this.myAction) {
+ throw Error("only one action supported per command");
+ }
+ this.myAction = f;
+ }
+
+ requiredOption<N extends keyof any, V>(
+ name: N,
+ flagspec: string[],
+ conv: Converter<V>,
+ args: OptionArgs<V> = {},
+ ): CommandGroup<GN, TG & SubRecord<GN, N, V>> {
+ const def: OptionDef = {
+ args: args,
+ conv: conv,
+ flagspec: flagspec,
+ isFlag: false,
+ required: true,
+ name: name as string,
+ };
+ this.options.push(def);
+ for (const flag of flagspec) {
+ if (flag.startsWith("--")) {
+ const flagname = flag.substring(2);
+ this.longOptions[flagname] = def;
+ } else if (flag.startsWith("-")) {
+ const flagname = flag.substring(1);
+ this.shortOptions[flagname] = def;
+ } else {
+ throw Error("option must start with '-' or '--'");
+ }
+ }
+ return this as any;
+ }
+
+ maybeOption<N extends keyof any, V>(
+ name: N,
+ flagspec: string[],
+ conv: Converter<V>,
+ args: OptionArgs<V> = {},
+ ): CommandGroup<GN, TG & SubRecord<GN, N, V | undefined>> {
+ const def: OptionDef = {
+ args: args,
+ conv: conv,
+ flagspec: flagspec,
+ isFlag: false,
+ required: false,
+ name: name as string,
+ };
+ this.options.push(def);
+ for (const flag of flagspec) {
+ if (flag.startsWith("--")) {
+ const flagname = flag.substring(2);
+ this.longOptions[flagname] = def;
+ } else if (flag.startsWith("-")) {
+ const flagname = flag.substring(1);
+ this.shortOptions[flagname] = def;
+ } else {
+ throw Error("option must start with '-' or '--'");
+ }
+ }
+ return this as any;
+ }
+
+ requiredArgument<N extends keyof any, V>(
+ name: N,
+ conv: Converter<V>,
+ args: ArgumentArgs<V> = {},
+ ): CommandGroup<GN, TG & SubRecord<GN, N, V>> {
+ const argDef: ArgumentDef = {
+ args: args,
+ conv: conv,
+ name: name as string,
+ required: true,
+ };
+ this.arguments.push(argDef);
+ return this as any;
+ }
+
+ maybeArgument<N extends keyof any, V>(
+ name: N,
+ conv: Converter<V>,
+ args: ArgumentArgs<V> = {},
+ ): CommandGroup<GN, TG & SubRecord<GN, N, V | undefined>> {
+ const argDef: ArgumentDef = {
+ args: args,
+ conv: conv,
+ name: name as string,
+ required: false,
+ };
+ this.arguments.push(argDef);
+ return this as any;
+ }
+
+ flag<N extends string, V>(
+ name: N,
+ flagspec: string[],
+ args: OptionArgs<V> = {},
+ ): CommandGroup<GN, TG & SubRecord<GN, N, boolean>> {
+ const def: OptionDef = {
+ args: args,
+ flagspec: flagspec,
+ isFlag: true,
+ required: false,
+ name: name as string,
+ };
+ this.options.push(def);
+ for (const flag of flagspec) {
+ if (flag.startsWith("--")) {
+ const flagname = flag.substring(2);
+ this.longOptions[flagname] = def;
+ } else if (flag.startsWith("-")) {
+ const flagname = flag.substring(1);
+ this.shortOptions[flagname] = def;
+ } else {
+ throw Error("option must start with '-' or '--'");
+ }
+ }
+ return this as any;
+ }
+
+ subcommand<GN extends keyof any>(
+ argKey: GN,
+ name: string,
+ args: SubcommandArgs = {},
+ ): CommandGroup<GN, TG> {
+ const cg = new CommandGroup<GN, {}>(argKey as string, name, args);
+ const def: SubcommandDef = {
+ commandGroup: cg,
+ name: name as string,
+ args: args,
+ };
+ cg.flag("help", ["-h", "--help"], {
+ help: "Show this message and exit.",
+ });
+ this.subcommandMap[name as string] = def;
+ this.subcommands.push(def);
+ this.subcommands = this.subcommands.sort((x1, x2) => {
+ const a = x1.name;
+ const b = x2.name;
+ if (a === b) {
+ return 0;
+ } else if (a < b) {
+ return -1;
+ } else {
+ return 1;
+ }
+ });
+ return cg as any;
+ }
+
+ printHelp(progName: string, parents: CommandGroup<any, any>[]): void {
+ let usageSpec = "";
+ for (const p of parents) {
+ usageSpec += (p.name ?? progName) + " ";
+ if (p.arguments.length >= 1) {
+ usageSpec += "<ARGS...> ";
+ }
+ }
+ usageSpec += (this.name ?? progName) + " ";
+ if (this.subcommands.length != 0) {
+ usageSpec += "COMMAND ";
+ }
+ for (const a of this.arguments) {
+ const argName = a.args.metavar ?? a.name;
+ usageSpec += `<${argName}> `;
+ }
+ usageSpec = usageSpec.trimRight();
+ console.log(`Usage: ${usageSpec}`);
+ if (this.scArgs.help) {
+ console.log();
+ console.log(this.scArgs.help);
+ }
+ if (this.options.length != 0) {
+ console.log();
+ console.log("Options:");
+ for (const opt of this.options) {
+ let optSpec = opt.flagspec.join(", ");
+ if (!opt.isFlag) {
+ optSpec = optSpec + "=VALUE";
+ }
+ console.log(formatListing(optSpec, opt.args.help));
+ }
+ }
+
+ if (this.subcommands.length != 0) {
+ console.log();
+ console.log("Commands:");
+ for (const subcmd of this.subcommands) {
+ console.log(formatListing(subcmd.name, subcmd.args.help));
+ }
+ }
+ }
+
+ /**
+ * Run the (sub-)command with the given command line parameters.
+ */
+ run(
+ progname: string,
+ parents: CommandGroup<any, any>[],
+ unparsedArgs: string[],
+ parsedArgs: any,
+ ): void {
+ let posArgIndex = 0;
+ let argsTerminated = false;
+ let i;
+ let foundSubcommand: CommandGroup<any, any> | undefined = undefined;
+ const myArgs: any = (parsedArgs[this.argKey] = {});
+ const foundOptions: { [name: string]: boolean } = {};
+ const currentName = this.name ?? progname;
+ const storeOption = (def: OptionDef, value: string) => {
+ foundOptions[def.name] = true;
+ if (def.conv === INT) {
+ myArgs[def.name] = Number.parseInt(value);
+ } else if (def.conv == null || def.conv === STRING) {
+ myArgs[def.name] = value;
+ } else if (def.conv == null || def.conv === AMOUNT) {
+ myArgs[def.name] = value;
+ } else {
+ throw Error("unknown converter");
+ }
+ };
+ const storeFlag = (def: OptionDef, value: boolean) => {
+ foundOptions[def.name] = true;
+ myArgs[def.name] = value;
+ };
+ for (i = 0; i < unparsedArgs.length; i++) {
+ const argVal = unparsedArgs[i];
+ if (argsTerminated == false) {
+ if (argVal === "--") {
+ argsTerminated = true;
+ continue;
+ }
+ if (argVal.startsWith("--")) {
+ const opt = argVal.substring(2);
+ const r = splitOpt(opt);
+ const d = this.longOptions[r.key];
+ if (!d) {
+ console.error(
+ `error: unknown option '--${r.key}' for ${currentName}`,
+ );
+ processExit(-1);
+ throw Error("not reached");
+ }
+ if (d.isFlag) {
+ if (r.value !== undefined) {
+ console.error(`error: flag '--${r.key}' does not take a value`);
+ processExit(-1);
+ throw Error("not reached");
+ }
+ storeFlag(d, true);
+ } else {
+ if (r.value === undefined) {
+ if (i === unparsedArgs.length - 1) {
+ console.error(`error: option '--${r.key}' needs an argument`);
+ processExit(-1);
+ throw Error("not reached");
+ }
+ storeOption(d, unparsedArgs[i + 1]);
+ i++;
+ } else {
+ storeOption(d, r.value);
+ }
+ }
+ continue;
+ }
+ if (argVal.startsWith("-") && argVal != "-") {
+ const optShort = argVal.substring(1);
+ for (let si = 0; si < optShort.length; si++) {
+ const chr = optShort[si];
+ const opt = this.shortOptions[chr];
+ if (!opt) {
+ console.error(`error: option '-${chr}' not known`);
+ processExit(-1);
+ }
+ if (opt.isFlag) {
+ storeFlag(opt, true);
+ } else {
+ if (si == optShort.length - 1) {
+ if (i === unparsedArgs.length - 1) {
+ console.error(`error: option '-${chr}' needs an argument`);
+ processExit(-1);
+ throw Error("not reached");
+ } else {
+ storeOption(opt, unparsedArgs[i + 1]);
+ i++;
+ }
+ } else {
+ storeOption(opt, optShort.substring(si + 1));
+ }
+ break;
+ }
+ }
+ continue;
+ }
+ }
+ if (this.subcommands.length != 0) {
+ const subcmd = this.subcommandMap[argVal];
+ if (!subcmd) {
+ console.error(`error: unknown command '${argVal}'`);
+ processExit(-1);
+ throw Error("not reached");
+ }
+ foundSubcommand = subcmd.commandGroup;
+ break;
+ } else {
+ const d = this.arguments[posArgIndex];
+ if (!d) {
+ console.error(`error: too many arguments for ${currentName}`);
+ processExit(-1);
+ throw Error("not reached");
+ }
+ myArgs[d.name] = unparsedArgs[i];
+ posArgIndex++;
+ }
+ }
+
+ if (parsedArgs[this.argKey].help) {
+ this.printHelp(progname, parents);
+ processExit(0);
+ throw Error("not reached");
+ }
+
+ for (let i = posArgIndex; i < this.arguments.length; i++) {
+ const d = this.arguments[i];
+ if (d.required) {
+ if (d.args.default !== undefined) {
+ myArgs[d.name] = d.args.default;
+ } else {
+ console.error(
+ `error: missing positional argument '${d.name}' for ${currentName}`,
+ );
+ processExit(-1);
+ throw Error("not reached");
+ }
+ }
+ }
+
+ for (const option of this.options) {
+ if (option.isFlag == false && option.required == true) {
+ if (!foundOptions[option.name]) {
+ if (option.args.default !== undefined) {
+ myArgs[option.name] = option.args.default;
+ } else {
+ const name = option.flagspec.join(",");
+ console.error(`error: missing option '${name}'`);
+ processExit(-1);
+ throw Error("not reached");
+ }
+ }
+ }
+ }
+
+ for (const option of this.options) {
+ const ph = option.args.onPresentHandler;
+ if (ph && foundOptions[option.name]) {
+ ph(myArgs[option.name]);
+ }
+ }
+
+ if (foundSubcommand) {
+ foundSubcommand.run(
+ progname,
+ Array.prototype.concat(parents, [this]),
+ unparsedArgs.slice(i + 1),
+ parsedArgs,
+ );
+ } else if (this.myAction) {
+ let r;
+ try {
+ r = this.myAction(parsedArgs);
+ } catch (e) {
+ console.error(`An error occurred while running ${currentName}`);
+ console.error(e);
+ processExit(1);
+ }
+ Promise.resolve(r).catch((e) => {
+ console.error(`An error occurred while running ${currentName}`);
+ console.error(e);
+ processExit(1);
+ });
+ } else {
+ this.printHelp(progname, parents);
+ processExit(-1);
+ throw Error("not reached");
+ }
+ }
+ }
+
+ export class Program<PN extends keyof any, T> {
+ private mainCommand: CommandGroup<any, any>;
+
+ constructor(argKey: string, args: ProgramArgs = {}) {
+ this.mainCommand = new CommandGroup<any, any>(argKey, null, {
+ help: args.help,
+ });
+ this.mainCommand.flag("help", ["-h", "--help"], {
+ help: "Show this message and exit.",
+ });
+ }
+
+ run(cmdlineArgs?: string[]): void {
+ let args: string[];
+ if (cmdlineArgs) {
+ args = cmdlineArgs;
+ } else {
+ args = processArgv().slice(1);
+ }
+ if (args.length < 1) {
+ console.error(
+ "Error while parsing command line arguments: not enough arguments",
+ );
+ processExit(-1);
+ }
+ const progname = pathBasename(args[0]);
+ const rest = args.slice(1);
+
+ this.mainCommand.run(progname, [], rest, {});
+ }
+
+ subcommand<GN extends keyof any>(
+ argKey: GN,
+ name: string,
+ args: SubcommandArgs = {},
+ ): CommandGroup<GN, T> {
+ const cmd = this.mainCommand.subcommand(argKey, name as string, args);
+ return cmd as any;
+ }
+
+ requiredOption<N extends keyof any, V>(
+ name: N,
+ flagspec: string[],
+ conv: Converter<V>,
+ args: OptionArgs<V> = {},
+ ): Program<PN, T & SubRecord<PN, N, V>> {
+ this.mainCommand.requiredOption(name, flagspec, conv, args);
+ return this as any;
+ }
+
+ maybeOption<N extends keyof any, V>(
+ name: N,
+ flagspec: string[],
+ conv: Converter<V>,
+ args: OptionArgs<V> = {},
+ ): Program<PN, T & SubRecord<PN, N, V | undefined>> {
+ this.mainCommand.maybeOption(name, flagspec, conv, args);
+ return this as any;
+ }
+
+ /**
+ * Add a flag (option without value) to the program.
+ */
+ flag<N extends string>(
+ name: N,
+ flagspec: string[],
+ args: OptionArgs<boolean> = {},
+ ): Program<PN, T & SubRecord<PN, N, boolean>> {
+ this.mainCommand.flag(name, flagspec, args);
+ return this as any;
+ }
+
+ /**
+ * Add a required positional argument to the program.
+ */
+ requiredArgument<N extends keyof any, V>(
+ name: N,
+ conv: Converter<V>,
+ args: ArgumentArgs<V> = {},
+ ): Program<PN, T & SubRecord<PN, N, V>> {
+ this.mainCommand.requiredArgument(name, conv, args);
+ return this as any;
+ }
+
+ /**
+ * Add an optional argument to the program.
+ */
+ maybeArgument<N extends keyof any, V>(
+ name: N,
+ conv: Converter<V>,
+ args: ArgumentArgs<V> = {},
+ ): Program<PN, T & SubRecord<PN, N, V | undefined>> {
+ this.mainCommand.maybeArgument(name, conv, args);
+ return this as any;
+ }
+
+ action(f: ActionFn<T>): void {
+ this.mainCommand.action(f);
+ }
+ }
+
+ export type GetArgType<T> = T extends Program<any, infer AT>
+ ? AT
+ : T extends CommandGroup<any, infer AT>
+ ? AT
+ : any;
+
+ export function program<PN extends keyof any>(
+ argKey: PN,
+ args: ProgramArgs = {},
+ ): Program<PN, {}> {
+ return new Program(argKey as string, args);
+ }
+
+ export function prompt(question: string): Promise<string> {
+ return readlinePrompt(question);
+ }
+}
diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts
index 8605ff335..678c3f092 100644
--- a/packages/taler-util/src/codec.ts
+++ b/packages/taler-util/src/codec.ts
@@ -14,12 +14,17 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import { j2s } from "./helpers.js";
+import { Logger } from "./logging.js";
+
/**
* Type-safe codecs for converting from/to JSON.
*/
/* eslint-disable @typescript-eslint/ban-types */
+const logger = new Logger("codec.ts");
+
/**
* Error thrown when decoding fails.
*/
@@ -134,7 +139,7 @@ class UnionCodecBuilder<
TargetType,
TagPropertyLabel extends keyof TargetType,
CommonBaseType,
- PartialTargetType
+ PartialTargetType,
> {
private alternatives = new Map<any, Alternative>();
@@ -186,7 +191,7 @@ class UnionCodecBuilder<
throw new DecodingError(
`expected tag for ${objectDisplayName} at ${renderContext(
c,
- )}.${discriminator}`,
+ )}.${String(discriminator)}`,
);
}
const alt = alternatives.get(d);
@@ -194,7 +199,7 @@ class UnionCodecBuilder<
throw new DecodingError(
`unknown tag for ${objectDisplayName} ${d} at ${renderContext(
c,
- )}.${discriminator}`,
+ )}.${String(discriminator)}`,
);
}
const altDecoded = alt.codec.decode(x);
@@ -322,6 +327,74 @@ export function codecForString(): Codec<string> {
}
/**
+ * Return a codec for a value that must be a string.
+ */
+export function codecForStringURL(shouldEndWithSlash?: boolean): Codec<string> {
+ return {
+ decode(x: any, c?: Context): string {
+ if (typeof x !== "string") {
+ throw new DecodingError(
+ `expected string at ${renderContext(c)} but got ${typeof x}`,
+ );
+ }
+ if (shouldEndWithSlash && !x.endsWith("/")) {
+ throw new DecodingError(
+ `expected URL string that ends with slash at ${renderContext(
+ c,
+ )} but got ${x}`,
+ );
+ }
+ try {
+ const url = new URL(x);
+ return x;
+ } catch (e) {
+ if (e instanceof Error) {
+ throw new DecodingError(e.message);
+ } else {
+ throw new DecodingError(
+ `expected an URL string at ${renderContext(c)} but got "${x}"`,
+ );
+ }
+ }
+ },
+ };
+}
+
+/**
+ * Return a codec for a value that must be a string.
+ */
+export function codecForURL(shouldEndWithSlash?: boolean): Codec<URL> {
+ return {
+ decode(x: any, c?: Context): URL {
+ if (typeof x !== "string") {
+ throw new DecodingError(
+ `expected string at ${renderContext(c)} but got ${typeof x}`,
+ );
+ }
+ if (shouldEndWithSlash && !x.endsWith("/")) {
+ throw new DecodingError(
+ `expected URL string that ends with slash at ${renderContext(
+ c,
+ )} but got ${x}`,
+ );
+ }
+ try {
+ const url = new URL(x);
+ return url;
+ } catch (e) {
+ if (e instanceof Error) {
+ throw new DecodingError(e.message);
+ } else {
+ throw new DecodingError(
+ `expected an URL string at ${renderContext(c)} but got "${x}"`,
+ );
+ }
+ }
+ },
+ };
+}
+
+/**
* Codec that allows any value.
*/
export function codecForAny(): Codec<any> {
@@ -417,3 +490,29 @@ export function codecOptional<V>(innerCodec: Codec<V>): Codec<V | undefined> {
},
};
}
+
+export type CodecType<T> = T extends Codec<infer X> ? X : any;
+
+export function codecForEither<T extends Array<Codec<unknown>>>(
+ ...alts: [...T]
+): Codec<CodecType<T[number]>> {
+ return {
+ decode(x: any, c?: Context): any {
+ for (const alt of alts) {
+ try {
+ return alt.decode(x, c);
+ } catch (e) {
+ continue;
+ }
+ }
+ if (logger.shouldLogTrace()) {
+ logger.trace(`offending value: ${j2s(x)}`);
+ }
+ throw new DecodingError(
+ `No alternative matched at at ${renderContext(c)}`,
+ );
+ },
+ };
+}
+
+const x = codecForEither(codecForString(), codecForNumber());
diff --git a/packages/taler-util/src/compat.d.ts b/packages/taler-util/src/compat.d.ts
new file mode 100644
index 000000000..d7ccf19f0
--- /dev/null
+++ b/packages/taler-util/src/compat.d.ts
@@ -0,0 +1,23 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export function processExit(status: number): never;
+export function processArgv(): string[];
+export function readlinePrompt(prompt: string): Promise<string>;
+export function pathBasename(s: string): string;
+export function setUnhandledRejectionHandler(h: (e: any) => void): void;
+export function getenv(name: string): string | undefined;
+export function readFile(fileName: string): string;
diff --git a/packages/taler-util/src/compat.node.ts b/packages/taler-util/src/compat.node.ts
new file mode 100644
index 000000000..2f78c9346
--- /dev/null
+++ b/packages/taler-util/src/compat.node.ts
@@ -0,0 +1,64 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import process from "node:process";
+import readline from "node:readline";
+import path from "node:path";
+import os from "node:os";
+import fs from "node:fs";
+
+export function processExit(status: number): never {
+ process.exit(status);
+}
+
+export function processArgv(): string[] {
+ return [...process.argv];
+}
+
+export function readlinePrompt(prompt: string): Promise<string> {
+ const stdinReadline = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+ return new Promise<string>((resolve, reject) => {
+ stdinReadline.question(prompt, (res) => {
+ resolve(res);
+ stdinReadline.close();
+ });
+ });
+}
+
+export function pathBasename(p: string): string {
+ return path.basename(p);
+}
+
+export function pathHomedir(): string {
+ return os.homedir();
+}
+
+export function setUnhandledRejectionHandler(h: (e: any) => void): void {
+ process.on("unhandledRejection", (e) => {
+ h(e);
+ });
+}
+
+export function getenv(name: string): string | undefined {
+ return process.env[name];
+}
+
+export function readFile(fileName: string): string {
+ return fs.readFileSync(fileName, "utf-8");
+}
diff --git a/packages/taler-util/src/compat.qtart.ts b/packages/taler-util/src/compat.qtart.ts
new file mode 100644
index 000000000..7d11cf375
--- /dev/null
+++ b/packages/taler-util/src/compat.qtart.ts
@@ -0,0 +1,57 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+// qtart "std" library
+// @ts-ignore
+import * as std from "std";
+
+export function processExit(status: number): never {
+ std.exit(status);
+ throw Error("not reached");
+}
+
+export function processArgv(): string[] {
+ // @ts-ignore
+ return ["qtart", ...globalThis.scriptArgs];
+}
+
+export function readlinePrompt(prompt: string): Promise<string> {
+ throw new Error("realinePrompt not yet supported in qtart");
+}
+
+export function pathBasename(p: string): string {
+ const slashIndex = p.lastIndexOf("/");
+ if (slashIndex < 0) {
+ return p;
+ }
+ return p.substring(0, slashIndex);
+}
+
+export function pathHomedir(): string {
+ return std.getenv("HOME");
+}
+
+export function setUnhandledRejectionHandler(h: (e: any) => void): void {
+ // not supported
+}
+
+export function getenv(name: string): string | undefined {
+ return std.getenv(name);
+}
+
+export function readFile(fileName: string): string {
+ throw new Error("readFile not yet supported in qtart");
+}
diff --git a/packages/taler-wallet-core/src/util/contractTerms.test.ts b/packages/taler-util/src/contract-terms.test.ts
index 74cae4ca7..fc0920501 100644
--- a/packages/taler-wallet-core/src/util/contractTerms.test.ts
+++ b/packages/taler-util/src/contract-terms.test.ts
@@ -18,7 +18,12 @@
* Imports.
*/
import test from "ava";
-import { ContractTermsUtil } from "./contractTerms.js";
+import { initNodePrng } from "./prng-node.js";
+import { ContractTermsUtil } from "./contract-terms.js";
+
+// Since we import nacl-fast directly (and not via index.node.ts), we need to
+// init the PRNG manually.
+initNodePrng();
test("contract terms canon hashing", (t) => {
const cReq = {
diff --git a/packages/taler-wallet-core/src/util/contractTerms.ts b/packages/taler-util/src/contract-terms.ts
index b064079e9..b906a1d7f 100644
--- a/packages/taler-wallet-core/src/util/contractTerms.ts
+++ b/packages/taler-util/src/contract-terms.ts
@@ -14,37 +14,21 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { canonicalJson, Logger } from "@gnu-taler/taler-util";
-import { kdf } from "@gnu-taler/taler-util";
+import { canonicalJson } from "./helpers.js";
+import { Logger } from "./logging.js";
import {
decodeCrock,
encodeCrock,
getRandomBytes,
hash,
+ kdf,
stringToBytes,
-} from "@gnu-taler/taler-util";
+} from "./taler-crypto.js";
const logger = new Logger("contractTerms.ts");
export namespace ContractTermsUtil {
- export type PathPredicate = (path: string[]) => boolean;
-
- /**
- * Scrub all forgettable members from an object.
- */
- export function scrub(anyJson: any): any {
- return forgetAllImpl(anyJson, [], () => true);
- }
-
- /**
- * Recursively forget all forgettable members of an object,
- * where the path matches a predicate.
- */
- export function forgetAll(anyJson: any, pred: PathPredicate): any {
- return forgetAllImpl(anyJson, [], pred);
- }
-
- function forgetAllImpl(
+ export function forgetAllImpl(
anyJson: any,
path: string[],
pred: PathPredicate,
@@ -88,6 +72,23 @@ export namespace ContractTermsUtil {
return dup;
}
+ export type PathPredicate = (path: string[]) => boolean;
+
+ /**
+ * Scrub all forgettable members from an object.
+ */
+ export function scrub(anyJson: any): any {
+ return forgetAllImpl(anyJson, [], () => true);
+ }
+
+ /**
+ * Recursively forget all forgettable members of an object,
+ * where the path matches a predicate.
+ */
+ export function forgetAll(anyJson: any, pred: PathPredicate): any {
+ return forgetAllImpl(anyJson, [], pred);
+ }
+
/**
* Generate a salt for all members marked as forgettable,
* but which don't have an actual salt yet.
@@ -225,7 +226,6 @@ export namespace ContractTermsUtil {
const cleaned = scrub(contractTerms);
const canon = canonicalJson(cleaned) + "\0";
const bytes = stringToBytes(canon);
- logger.info(`contract terms before hashing: ${encodeCrock(bytes)}`);
return encodeCrock(hash(bytes));
}
}
diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts
new file mode 100644
index 000000000..4dea7e1b6
--- /dev/null
+++ b/packages/taler-util/src/errors.ts
@@ -0,0 +1,325 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2020 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Classes and helpers for error handling specific to wallet operations.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ CancellationToken,
+ PaymentInsufficientBalanceDetails,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+
+type empty = Record<string, never>;
+
+export interface DetailsMap {
+ [TalerErrorCode.WALLET_PENDING_OPERATION_FAILED]: {
+ innerError: TalerErrorDetail;
+ transactionId?: string;
+ };
+ [TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT]: {
+ exchangeBaseUrl: string;
+ };
+ [TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE]: {
+ exchangeProtocolVersion: string;
+ walletProtocolVersion: string;
+ };
+ [TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK]: empty;
+ [TalerErrorCode.WALLET_REWARD_COIN_SIGNATURE_INVALID]: empty;
+ [TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED]: {
+ orderId: string;
+ claimUrl: string;
+ };
+ [TalerErrorCode.WALLET_ORDER_ALREADY_PAID]: {
+ orderId: string;
+ fulfillmentUrl: string;
+ };
+ [TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED]: empty;
+ [TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID]: {
+ merchantPub: string;
+ orderId: string;
+ };
+ [TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH]: {
+ baseUrlForDownload: string;
+ baseUrlFromContractTerms: string;
+ };
+ [TalerErrorCode.WALLET_INVALID_TALER_PAY_URI]: {
+ talerPayUri: string;
+ };
+ [TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR]: {
+ requestUrl: string;
+ requestMethod: string;
+ httpStatusCode: number;
+ errorResponse?: any;
+ };
+ [TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION]: {
+ stack?: string;
+ };
+ [TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE]: {
+ bankProtocolVersion: string;
+ walletProtocolVersion: string;
+ };
+ [TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN]: {
+ operation: string;
+ };
+ [TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED]: {
+ requestUrl: string;
+ requestMethod: string;
+ throttleStats: Record<string, unknown>;
+ };
+ [TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT]: {
+ requestUrl: string;
+ requestMethod: string;
+ timeoutMs: number;
+ };
+ [TalerErrorCode.GENERIC_TIMEOUT]: {
+ requestUrl: string;
+ requestMethod: string;
+ timeoutMs: number;
+ };
+ [TalerErrorCode.WALLET_NETWORK_ERROR]: {
+ requestUrl: string;
+ requestMethod: string;
+ };
+ [TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE]: {
+ requestUrl: string;
+ requestMethod: string;
+ httpStatusCode: number;
+ validationError?: string;
+ /**
+ * Content type of the response, usually only specified if not the
+ * expected content type.
+ */
+ contentType?: string;
+ };
+ [TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR]: {
+ operation: string;
+ error: string;
+ detail: TalerErrorDetail | undefined;
+ };
+ [TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID]: empty;
+ [TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE]: {
+ numErrors: number;
+ errorsPerCoin: Record<number, TalerErrorDetail>;
+ };
+ [TalerErrorCode.WALLET_CORE_NOT_AVAILABLE]: {
+ lastError?: TalerErrorDetail;
+ };
+ [TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR]: {
+ httpStatusCode: number;
+ };
+ [TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR]: {
+ requestError: TalerErrorDetail;
+ };
+ [TalerErrorCode.WALLET_CRYPTO_WORKER_ERROR]: {
+ innerError: TalerErrorDetail;
+ };
+ [TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST]: {
+ detail: string;
+ };
+ [TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED]: {
+ kycUrl: string;
+ };
+ [TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE]: {
+ insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
+ };
+ [TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE]: {
+ insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
+ };
+ [TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE]: {
+ numErrors: number;
+ /**
+ * Errors, can be truncated.
+ */
+ errors: TalerErrorDetail[];
+ };
+ [TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH]: {
+ urlWallet: string;
+ urlExchange: string;
+ };
+ [TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE]: {
+ exchangeBaseUrl: string;
+ innerError: TalerErrorDetail | undefined;
+ };
+ [TalerErrorCode.WALLET_DB_UNAVAILABLE]: {
+ innerError: TalerErrorDetail | undefined;
+ };
+}
+
+type ErrBody<Y> = Y extends keyof DetailsMap ? DetailsMap[Y] : empty;
+
+export function makeErrorDetail<C extends TalerErrorCode>(
+ code: C,
+ detail: ErrBody<C>,
+ hint?: string,
+): TalerErrorDetail {
+ if (!hint && !(detail as any).hint) {
+ hint = getDefaultHint(code);
+ }
+ const when = AbsoluteTime.now();
+ return { code, when, hint, ...detail };
+}
+
+export function makePendingOperationFailedError(
+ innerError: TalerErrorDetail,
+ tag: TransactionType,
+ uid: string,
+): TalerError {
+ return TalerError.fromDetail(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED, {
+ innerError,
+ transactionId: `${tag}:${uid}`,
+ });
+}
+
+export function summarizeTalerErrorDetail(ed: TalerErrorDetail): string {
+ const errName = TalerErrorCode[ed.code] ?? "<unknown>";
+ return `Error (${ed.code}/${errName})`;
+}
+
+function getDefaultHint(code: number): string {
+ const errName = TalerErrorCode[code];
+ if (errName) {
+ return `Error (${errName})`;
+ } else {
+ return `Error (<unknown>)`;
+ }
+}
+
+export class TalerProtocolViolationError extends Error {
+ constructor(hint?: string) {
+ let msg: string;
+ if (hint) {
+ msg = `Taler protocol violation error (${hint})`;
+ } else {
+ msg = `Taler protocol violation error`;
+ }
+ super(msg);
+ Object.setPrototypeOf(this, TalerProtocolViolationError.prototype);
+ }
+}
+
+// compute a subset of TalerError, just for http request
+type HttpErrors =
+ | TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT
+ | TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED
+ | TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE
+ | TalerErrorCode.WALLET_NETWORK_ERROR
+ | TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR;
+
+type TalerHttpErrorsDetails = {
+ [code in HttpErrors]: TalerError<DetailsMap[code]>;
+};
+
+export type TalerHttpError =
+ TalerHttpErrorsDetails[keyof TalerHttpErrorsDetails];
+
+export class TalerError<T = any> extends Error {
+ errorDetail: TalerErrorDetail & T;
+ cause: Error | undefined;
+ private constructor(d: TalerErrorDetail & T, cause?: Error) {
+ super(d.hint ?? `Error (code ${d.code})`);
+ this.errorDetail = d;
+ this.cause = cause;
+ Object.setPrototypeOf(this, TalerError.prototype);
+ }
+
+ static fromDetail<C extends TalerErrorCode>(
+ code: C,
+ detail: ErrBody<C>,
+ hint?: string,
+ cause?: Error,
+ ): TalerError {
+ if (!hint) {
+ hint = getDefaultHint(code);
+ }
+ const when = AbsoluteTime.now();
+ return new TalerError<unknown>({ code, when, hint, ...detail }, cause);
+ }
+
+ static fromUncheckedDetail(d: TalerErrorDetail, c?: Error): TalerError {
+ return new TalerError<unknown>({ ...d }, c);
+ }
+
+ static fromException(e: any): TalerError {
+ const errDetail = getErrorDetailFromException(e);
+ return new TalerError(errDetail, e);
+ }
+
+ hasErrorCode<C extends keyof DetailsMap>(
+ code: C,
+ ): this is TalerError<DetailsMap[C]> {
+ return this.errorDetail.code === code;
+ }
+
+ toString(): string {
+ return `TalerError: ${JSON.stringify(this.errorDetail)}`;
+ }
+}
+
+/**
+ * Convert an exception (or anything that was thrown) into
+ * a TalerErrorDetail object.
+ */
+export function getErrorDetailFromException(e: any): TalerErrorDetail {
+ if (e instanceof TalerError) {
+ return e.errorDetail;
+ }
+ if (e instanceof CancellationToken.CancellationError) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED,
+ {},
+ );
+ return err;
+ }
+ if (e instanceof Error) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ {
+ stack: e.stack,
+ },
+ `unexpected exception (message: ${e.message})`,
+ );
+ return err;
+ }
+ // Something was thrown that is not even an exception!
+ // Try to stringify it.
+ let excString: string;
+ try {
+ excString = e.toString();
+ } catch (e) {
+ // Something went horribly wrong.
+ excString = "can't stringify exception";
+ }
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ {},
+ `unexpected exception (not an exception, ${excString})`,
+ );
+ return err;
+}
+
+export function assertUnreachable(x: never): never {
+ throw new Error("Didn't expect to get here");
+}
diff --git a/packages/taler-util/src/fnutils.ts b/packages/taler-util/src/fnutils.ts
index 85fac6680..ff309f694 100644
--- a/packages/taler-util/src/fnutils.ts
+++ b/packages/taler-util/src/fnutils.ts
@@ -35,4 +35,4 @@ export namespace fnutil {
}
return false;
}
-} \ No newline at end of file
+}
diff --git a/packages/taler-util/src/globbing/minimatch.ts b/packages/taler-util/src/globbing/minimatch.ts
index b23de4e92..30391ba26 100644
--- a/packages/taler-util/src/globbing/minimatch.ts
+++ b/packages/taler-util/src/globbing/minimatch.ts
@@ -341,9 +341,9 @@ export class Minimatch {
pattern.charAt(0) === "."
? "" // anything
: // not (start or / followed by . or .. followed by / or end)
- options.dot
- ? "(?!(?:^|\\/)\\.{1,2}(?:$|\\/))"
- : "(?!\\.)";
+ options.dot
+ ? "(?!(?:^|\\/)\\.{1,2}(?:$|\\/))"
+ : "(?!\\.)";
var self = this;
function clearStateChar() {
@@ -874,8 +874,8 @@ export class Minimatch {
var twoStar = options.noglobstar
? star
: options.dot
- ? twoStarDot
- : twoStarNoDot;
+ ? twoStarDot
+ : twoStarNoDot;
var flags = options.nocase ? "i" : "";
var re = (set as any)
@@ -885,8 +885,8 @@ export class Minimatch {
return p === GLOBSTAR
? twoStar
: typeof p === "string"
- ? regExpEscape(p)
- : (p as any)._src;
+ ? regExpEscape(p)
+ : (p as any)._src;
})
.join("\\/");
})
diff --git a/packages/taler-util/src/helpers.ts b/packages/taler-util/src/helpers.ts
index 089602c9d..d4c3c86b5 100644
--- a/packages/taler-util/src/helpers.ts
+++ b/packages/taler-util/src/helpers.ts
@@ -63,10 +63,7 @@ export function canonicalJson(obj: any): string {
// Check for cycles, etc.
obj = JSON.parse(JSON.stringify(obj));
if (typeof obj === "string") {
- const s = JSON.stringify(obj);
- return s.replace(/[\u007F-\uFFFF]/g, function (chr) {
- return "\\u" + ("0000" + chr.charCodeAt(0).toString(16)).substr(-4);
- });
+ return JSON.stringify(obj);
}
if (typeof obj === "number" || typeof obj === "boolean" || obj === null) {
return JSON.stringify(obj);
@@ -94,7 +91,7 @@ export function canonicalJson(obj: any): string {
/**
* Lexically compare two strings.
*/
-export function strcmp(s1: string, s2: string): number {
+export function strcmp(s1: string, s2: string): -1 | 0 | 1 {
if (s1 < s2) {
return -1;
}
@@ -113,15 +110,30 @@ export function j2s(x: any): string {
/**
* Use this to filter null or undefined from an array in a type-safe fashion
- *
+ *
* example:
* const array: Array<T | undefined> = [undefined, null]
* const filtered: Array<T> = array.filter(notEmpty)
- *
- * @param value
- * @returns
+ *
+ * @param value
+ * @returns
*/
export function notEmpty<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
+/**
+ * Safe function to stringify errors.
+ */
+export function stringifyError(x: any): string {
+ if (typeof x === "undefined") {
+ return "<thrown undefined>";
+ }
+ if (x === null) {
+ return `<thrown null>`;
+ }
+ if (typeof x === "object") {
+ return x.toString();
+ }
+ return `<thrown ${typeof x}>`;
+}
diff --git a/packages/taler-util/src/http-client/README.md b/packages/taler-util/src/http-client/README.md
new file mode 100644
index 000000000..33d1a8645
--- /dev/null
+++ b/packages/taler-util/src/http-client/README.md
@@ -0,0 +1,19 @@
+## HTTP Cclients
+
+This folder contain class or function specifically designed to facilitate HTTP client
+interactions with a the core systems.
+
+These API defines:
+
+1. **API Communication**: Handle communication with the component API,
+ abstracting away the details of HTTP requests and responses.
+ This includes making GET, POST, PUT, and DELETE requests to the servers.
+2. **Data Formatting**: Responsible for formatting requests to the API in a
+ way that's expected by the servers (JSON) and parsing the responses back
+ into formats usable by the client.
+3. **Authentication and Security**: Handling authentication with the server API,
+ which could involve sending API keys, client credentials, or managing tokens.
+ It might also implement security features to ensure data integrity and confidentiality during transit.
+4. **Error Handling**: Providing robust error handling and retry mechanisms
+ for failed HTTP requests, including logging and potentially user notifications for critical failures.
+5. **Data Validation**: Before sending requests, it could validate the data to ensure it meets the API's expected format, types, and value ranges, reducing the likelihood of errors and improving system reliability.
diff --git a/packages/taler-util/src/http-client/authentication.ts b/packages/taler-util/src/http-client/authentication.ts
new file mode 100644
index 000000000..8897a2fa0
--- /dev/null
+++ b/packages/taler-util/src/http-client/authentication.ts
@@ -0,0 +1,137 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { HttpStatusCode } from "../http-status-codes.js";
+import {
+ HttpRequestLibrary,
+ createPlatformHttpLib,
+ makeBasicAuthHeader,
+ readTalerErrorResponse,
+} from "../http.js";
+import { LibtoolVersion } from "../libtool-version.js";
+import {
+ opEmptySuccess,
+ opKnownHttpFailure,
+ opSuccessFromHttp,
+ opUnknownFailure,
+} from "../operation.js";
+import {
+ AccessToken,
+ TalerAuthentication,
+ codecForTokenSuccessResponse,
+ codecForTokenSuccessResponseMerchant,
+} from "./types.js";
+import { makeBearerTokenAuthHeader } from "./utils.js";
+
+export class TalerAuthenticationHttpClient {
+ public readonly PROTOCOL_VERSION = "0:0:0";
+
+ httpLib: HttpRequestLibrary;
+
+ constructor(
+ readonly baseUrl: string,
+ httpClient?: HttpRequestLibrary,
+ ) {
+ this.httpLib = httpClient ?? createPlatformHttpLib();
+ }
+
+ isCompatible(version: string): boolean {
+ const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version);
+ return compare?.compatible ?? false;
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-token
+ *
+ * @returns
+ */
+ async createAccessTokenBasic(
+ username: string,
+ password: string,
+ body: TalerAuthentication.TokenRequest,
+ ) {
+ const url = new URL(`token`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBasicAuthHeader(username, password),
+ },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForTokenSuccessResponse());
+ //FIXME: missing in docs
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ *
+ * @returns
+ */
+ async createAccessTokenBearer(
+ token: AccessToken,
+ body: TalerAuthentication.TokenRequest,
+ ) {
+ const url = new URL(`token`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(token),
+ },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForTokenSuccessResponseMerchant());
+ //FIXME: missing in docs
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ async deleteAccessToken(token: AccessToken) {
+ const url = new URL(`token`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(token),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opEmptySuccess(resp);
+ //FIXME: missing in docs
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+}
diff --git a/packages/taler-util/src/http-client/bank-conversion.ts b/packages/taler-util/src/http-client/bank-conversion.ts
new file mode 100644
index 000000000..cb14d8b34
--- /dev/null
+++ b/packages/taler-util/src/http-client/bank-conversion.ts
@@ -0,0 +1,223 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { AmountJson, Amounts } from "../amounts.js";
+import { HttpRequestLibrary, readTalerErrorResponse } from "../http-common.js";
+import { HttpStatusCode } from "../http-status-codes.js";
+import { createPlatformHttpLib } from "../http.js";
+import { LibtoolVersion } from "../libtool-version.js";
+import {
+ FailCasesByMethod,
+ ResultByMethod,
+ opEmptySuccess,
+ opKnownHttpFailure,
+ opSuccessFromHttp,
+ opUnknownFailure,
+} from "../operation.js";
+import { TalerErrorCode } from "../taler-error-codes.js";
+import { codecForTalerErrorDetail } from "../wallet-types.js";
+import {
+ AccessToken,
+ TalerBankConversionApi,
+ codecForCashinConversionResponse,
+ codecForCashoutConversionResponse,
+ codecForConversionBankConfig,
+} from "./types.js";
+import {
+ CacheEvictor,
+ makeBearerTokenAuthHeader,
+ nullEvictor,
+} from "./utils.js";
+
+export type TalerBankConversionResultByMethod<
+ prop extends keyof TalerBankConversionHttpClient,
+> = ResultByMethod<TalerBankConversionHttpClient, prop>;
+export type TalerBankConversionErrorsByMethod<
+ prop extends keyof TalerBankConversionHttpClient,
+> = FailCasesByMethod<TalerBankConversionHttpClient, prop>;
+
+export enum TalerBankConversionCacheEviction {
+ UPDATE_RATE,
+}
+
+/**
+ * The API is used by the wallets.
+ */
+export class TalerBankConversionHttpClient {
+ public readonly PROTOCOL_VERSION = "0:0:0";
+
+ httpLib: HttpRequestLibrary;
+ cacheEvictor: CacheEvictor<TalerBankConversionCacheEviction>;
+
+ constructor(
+ readonly baseUrl: string,
+ httpClient?: HttpRequestLibrary,
+ cacheEvictor?: CacheEvictor<TalerBankConversionCacheEviction>,
+ ) {
+ this.httpLib = httpClient ?? createPlatformHttpLib();
+ this.cacheEvictor = cacheEvictor ?? nullEvictor;
+ }
+
+ isCompatible(version: string): boolean {
+ const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version);
+ return compare?.compatible ?? false;
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-conversion-info.html#get--config
+ *
+ */
+ async getConfig() {
+ const url = new URL(`config`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForConversionBankConfig());
+ case HttpStatusCode.NotImplemented:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-conversion-info.html#get--cashin-rate
+ *
+ */
+ async getCashinRate(conversion: { debit?: AmountJson; credit?: AmountJson }) {
+ const url = new URL(`cashin-rate`, this.baseUrl);
+ if (conversion.debit) {
+ url.searchParams.set("amount_debit", Amounts.stringify(conversion.debit));
+ }
+ if (conversion.credit) {
+ url.searchParams.set(
+ "amount_credit",
+ Amounts.stringify(conversion.credit),
+ );
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForCashinConversionResponse());
+ case HttpStatusCode.BadRequest: {
+ const body = await resp.json();
+ const details = codecForTalerErrorDetail().decode(body);
+ switch (details.code) {
+ case TalerErrorCode.GENERIC_PARAMETER_MISSING:
+ return opKnownHttpFailure(resp.status, resp);
+ case TalerErrorCode.GENERIC_PARAMETER_MALFORMED:
+ return opKnownHttpFailure(resp.status, resp);
+ case TalerErrorCode.GENERIC_CURRENCY_MISMATCH:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, body);
+ }
+ }
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotImplemented:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-conversion-info.html#get--cashout-rate
+ *
+ */
+ async getCashoutRate(conversion: {
+ debit?: AmountJson;
+ credit?: AmountJson;
+ }) {
+ const url = new URL(`cashout-rate`, this.baseUrl);
+ if (conversion.debit) {
+ url.searchParams.set("amount_debit", Amounts.stringify(conversion.debit));
+ }
+ if (conversion.credit) {
+ url.searchParams.set(
+ "amount_credit",
+ Amounts.stringify(conversion.credit),
+ );
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForCashoutConversionResponse());
+ case HttpStatusCode.BadRequest: {
+ const body = await resp.json();
+ const details = codecForTalerErrorDetail().decode(body);
+ switch (details.code) {
+ case TalerErrorCode.GENERIC_PARAMETER_MISSING:
+ return opKnownHttpFailure(resp.status, resp);
+ case TalerErrorCode.GENERIC_PARAMETER_MALFORMED:
+ return opKnownHttpFailure(resp.status, resp);
+ case TalerErrorCode.GENERIC_CURRENCY_MISMATCH:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, body);
+ }
+ }
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotImplemented:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-conversion-info.html#post--conversion-rate
+ *
+ */
+ async updateConversionRate(
+ auth: AccessToken,
+ body: TalerBankConversionApi.ConversionRate,
+ ) {
+ const url = new URL(`conversion-rate`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth),
+ },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerBankConversionCacheEviction.UPDATE_RATE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotImplemented:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+}
diff --git a/packages/taler-util/src/http-client/bank-core.ts b/packages/taler-util/src/http-client/bank-core.ts
new file mode 100644
index 000000000..be37560cd
--- /dev/null
+++ b/packages/taler-util/src/http-client/bank-core.ts
@@ -0,0 +1,1033 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ LibtoolVersion,
+ LongPollParams,
+ OperationAlternative,
+ OperationFail,
+ OperationOk,
+ TalerErrorCode,
+ codecForChallenge,
+ codecForTalerErrorDetail,
+ codecForTanTransmission,
+ opKnownAlternativeFailure,
+ opKnownHttpFailure,
+ opKnownTalerFailure,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ createPlatformHttpLib,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import {
+ FailCasesByMethod,
+ ResultByMethod,
+ opEmptySuccess,
+ opFixedSuccess,
+ opSuccessFromHttp,
+ opUnknownFailure,
+} from "../operation.js";
+import {
+ AccessToken,
+ PaginationParams,
+ TalerCorebankApi,
+ UserAndToken,
+ WithdrawalOperationStatus,
+ codecForAccountData,
+ codecForBankAccountCreateWithdrawalResponse,
+ codecForBankAccountTransactionInfo,
+ codecForBankAccountTransactionsResponse,
+ codecForCashoutPending,
+ codecForCashoutStatusResponse,
+ codecForCashouts,
+ codecForCoreBankConfig,
+ codecForCreateTransactionResponse,
+ codecForGlobalCashouts,
+ codecForListBankAccountsResponse,
+ codecForMonitorResponse,
+ codecForPublicAccountsResponse,
+ codecForRegisterAccountResponse,
+ codecForWithdrawalPublicInfo,
+} from "./types.js";
+import {
+ CacheEvictor,
+ IdempotencyRetry,
+ addLongPollingParam,
+ addPaginationParams,
+ makeBearerTokenAuthHeader,
+ nullEvictor,
+} from "./utils.js";
+
+export type TalerCoreBankResultByMethod<
+ prop extends keyof TalerCoreBankHttpClient,
+> = ResultByMethod<TalerCoreBankHttpClient, prop>;
+export type TalerCoreBankErrorsByMethod<
+ prop extends keyof TalerCoreBankHttpClient,
+> = FailCasesByMethod<TalerCoreBankHttpClient, prop>;
+
+export enum TalerCoreBankCacheEviction {
+ DELETE_ACCOUNT,
+ CREATE_ACCOUNT,
+ UPDATE_ACCOUNT,
+ UPDATE_PASSWORD,
+ CREATE_TRANSACTION,
+ CONFIRM_WITHDRAWAL,
+ ABORT_WITHDRAWAL,
+ CREATE_WITHDRAWAL,
+ CREATE_CASHOUT,
+}
+/**
+ * Protocol version spoken with the core bank.
+ *
+ * Endpoint must be ordered in the same way that in the docs
+ * Response code (http and taler) must have the same order that in the docs
+ * That way is easier to see changes
+ *
+ * Uses libtool's current:revision:age versioning.
+ */
+export class TalerCoreBankHttpClient {
+ public readonly PROTOCOL_VERSION = "4:0:0";
+
+ httpLib: HttpRequestLibrary;
+ cacheEvictor: CacheEvictor<TalerCoreBankCacheEviction>;
+ constructor(
+ readonly baseUrl: string,
+ httpClient?: HttpRequestLibrary,
+ cacheEvictor?: CacheEvictor<TalerCoreBankCacheEviction>,
+ ) {
+ this.httpLib = httpClient ?? createPlatformHttpLib();
+ this.cacheEvictor = cacheEvictor ?? nullEvictor;
+ }
+
+ isCompatible(version: string): boolean {
+ const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version);
+ return compare?.compatible ?? false;
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#config
+ *
+ */
+ async getConfig() {
+ const url = new URL(`config`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForCoreBankConfig());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // ACCOUNTS
+ //
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts
+ *
+ */
+ async createAccount(
+ auth: AccessToken | undefined,
+ body: TalerCorebankApi.RegisterAccountRequest,
+ ) {
+ const url = new URL(`accounts`, this.baseUrl);
+ const headers: Record<string, string> = {};
+ if (auth) {
+ headers.Authorization = makeBearerTokenAuthHeader(auth);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers: headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ await this.cacheEvictor.notifySuccess(
+ TalerCoreBankCacheEviction.CREATE_ACCOUNT,
+ );
+ return opSuccessFromHttp(resp, codecForRegisterAccountResponse());
+ }
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_MISSING_TAN_INFO:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+ /**
+ * https://docs.taler.net/core/api-corebank.html#delete--accounts-$USERNAME
+ *
+ */
+ async deleteAccount(auth: UserAndToken, cid?: string) {
+ const url = new URL(`accounts/${auth.username}`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ "X-Challenge-Id": cid,
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Accepted:
+ return opKnownAlternativeFailure(
+ resp,
+ resp.status,
+ codecForChallenge(),
+ );
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#patch--accounts-$USERNAME
+ *
+ */
+ async updateAccount(
+ auth: UserAndToken,
+ body: TalerCorebankApi.AccountReconfiguration,
+ cid?: string,
+ ) {
+ const url = new URL(`accounts/${auth.username}`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ body,
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ "X-Challenge-Id": cid,
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Accepted:
+ return opKnownAlternativeFailure(
+ resp,
+ resp.status,
+ codecForChallenge(),
+ );
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_MISSING_TAN_INFO:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#patch--accounts-$USERNAME-auth
+ *
+ */
+ async updatePassword(
+ auth: UserAndToken,
+ body: TalerCorebankApi.AccountPasswordChange,
+ cid?: string,
+ ) {
+ const url = new URL(`accounts/${auth.username}/auth`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ body,
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ "X-Challenge-Id": cid,
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Accepted:
+ return opKnownAlternativeFailure(
+ resp,
+ resp.status,
+ codecForChallenge(),
+ );
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#get--public-accounts
+ *
+ */
+ async getPublicAccounts(
+ filter: { account?: string } = {},
+ pagination?: PaginationParams,
+ ) {
+ const url = new URL(`public-accounts`, this.baseUrl);
+ addPaginationParams(url, pagination);
+ if (filter.account !== undefined) {
+ url.searchParams.set("filter_name", filter.account);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForPublicAccountsResponse());
+ case HttpStatusCode.NoContent:
+ return opFixedSuccess({ public_accounts: [] });
+ case HttpStatusCode.NotFound:
+ return opFixedSuccess({ public_accounts: [] });
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#get--accounts
+ *
+ */
+ async getAccounts(
+ auth: AccessToken,
+ filter: { account?: string } = {},
+ pagination?: PaginationParams,
+ ) {
+ const url = new URL(`accounts`, this.baseUrl);
+ addPaginationParams(url, pagination);
+ if (filter.account !== undefined) {
+ url.searchParams.set("filter_name", filter.account);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForListBankAccountsResponse());
+ case HttpStatusCode.NoContent:
+ return opFixedSuccess({ accounts: [] });
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#get--accounts-$USERNAME
+ *
+ */
+ async getAccount(auth: UserAndToken) {
+ const url = new URL(`accounts/${auth.username}`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForAccountData());
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // TRANSACTIONS
+ //
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#get--accounts-$USERNAME-transactions
+ *
+ */
+ async getTransactions(
+ auth: UserAndToken,
+ params?: PaginationParams & LongPollParams,
+ ) {
+ const url = new URL(`accounts/${auth.username}/transactions`, this.baseUrl);
+ addPaginationParams(url, params);
+ addLongPollingParam(url, params);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(
+ resp,
+ codecForBankAccountTransactionsResponse(),
+ );
+ case HttpStatusCode.NoContent:
+ return opFixedSuccess({ transactions: [] });
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#get--accounts-$USERNAME-transactions-$TRANSACTION_ID
+ *
+ */
+ async getTransactionById(auth: UserAndToken, txid: number) {
+ const url = new URL(
+ `accounts/${auth.username}/transactions/${String(txid)}`,
+ this.baseUrl,
+ );
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForBankAccountTransactionInfo());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-transactions
+ *
+ */
+ async createTransaction(
+ auth: UserAndToken,
+ body: TalerCorebankApi.CreateTransactionRequest,
+ idempotencyCheck: IdempotencyRetry | undefined,
+ cid?: string,
+ ): Promise<
+ //manually definition all return types because of recursion
+ | OperationOk<TalerCorebankApi.CreateTransactionResponse>
+ | OperationAlternative<HttpStatusCode.Accepted, TalerCorebankApi.Challenge>
+ | OperationFail<HttpStatusCode.NotFound>
+ | OperationFail<HttpStatusCode.BadRequest>
+ | OperationFail<HttpStatusCode.Unauthorized>
+ | OperationFail<TalerErrorCode.BANK_UNALLOWED_DEBIT>
+ | OperationFail<TalerErrorCode.BANK_ADMIN_CREDITOR>
+ | OperationFail<TalerErrorCode.BANK_SAME_ACCOUNT>
+ | OperationFail<TalerErrorCode.BANK_UNKNOWN_CREDITOR>
+ | OperationFail<TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED>
+ > {
+ const url = new URL(`accounts/${auth.username}/transactions`, this.baseUrl);
+ if (idempotencyCheck) {
+ body.request_uid = idempotencyCheck.uid
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ "X-Challenge-Id": cid,
+ },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForCreateTransactionResponse());
+ case HttpStatusCode.Accepted:
+ return opKnownAlternativeFailure(
+ resp,
+ resp.status,
+ codecForChallenge(),
+ );
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.BANK_ADMIN_CREDITOR:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_SAME_ACCOUNT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_UNKNOWN_CREDITOR:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
+ if (!idempotencyCheck) {
+ return opKnownTalerFailure(details.code, details);
+ }
+ const nextRetry = idempotencyCheck.next();
+ return this.createTransaction(auth, body, nextRetry, cid);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // WITHDRAWALS
+ //
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-withdrawals
+ *
+ */
+ async createWithdrawal(
+ auth: UserAndToken,
+ body: TalerCorebankApi.BankAccountCreateWithdrawalRequest,
+ ) {
+ const url = new URL(`accounts/${auth.username}/withdrawals`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(
+ resp,
+ codecForBankAccountCreateWithdrawalResponse(),
+ );
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ //FIXME: missing in docs
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-withdrawals-$WITHDRAWAL_ID-confirm
+ *
+ */
+ async confirmWithdrawalById(auth: UserAndToken, wid: string, cid?: string) {
+ const url = new URL(
+ `accounts/${auth.username}/withdrawals/${wid}/confirm`,
+ this.baseUrl,
+ );
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ "X-Challenge-Id": cid,
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Accepted:
+ return opKnownAlternativeFailure(
+ resp,
+ resp.status,
+ codecForChallenge(),
+ );
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ //FIXME: missing in docs
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-withdrawals-$WITHDRAWAL_ID-abort
+ *
+ */
+ async abortWithdrawalById(auth: UserAndToken, wid: string) {
+ const url = new URL(
+ `accounts/${auth.username}/withdrawals/${wid}/abort`,
+ this.baseUrl,
+ );
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ //FIXME: missing in docs
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#get--withdrawals-$WITHDRAWAL_ID
+ *
+ */
+ async getWithdrawalById(
+ wid: string,
+ params?: {
+ old_state?: WithdrawalOperationStatus;
+ } & LongPollParams,
+ ) {
+ const url = new URL(`withdrawals/${wid}`, this.baseUrl);
+ addLongPollingParam(url, params);
+ if (params) {
+ url.searchParams.set(
+ "old_state",
+ !params.old_state ? "pending" : params.old_state,
+ );
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForWithdrawalPublicInfo());
+ //FIXME: missing in docs
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // CASHOUTS
+ //
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-cashouts
+ *
+ */
+ async createCashout(
+ auth: UserAndToken,
+ body: TalerCorebankApi.CashoutRequest,
+ cid?: string,
+ ) {
+ const url = new URL(`accounts/${auth.username}/cashouts`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ "X-Challenge-Id": cid,
+ },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForCashoutPending());
+ case HttpStatusCode.Accepted:
+ return opKnownAlternativeFailure(
+ resp,
+ resp.status,
+ codecForChallenge(),
+ );
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_BAD_CONVERSION:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ case HttpStatusCode.BadGateway: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ case HttpStatusCode.NotImplemented:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#get--accounts-$USERNAME-cashouts-$CASHOUT_ID
+ *
+ */
+ async getCashoutById(auth: UserAndToken, cid: number) {
+ const url = new URL(
+ `accounts/${auth.username}/cashouts/${cid}`,
+ this.baseUrl,
+ );
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForCashoutStatusResponse());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotImplemented:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#get--accounts-$USERNAME-cashouts
+ *
+ */
+ async getAccountCashouts(auth: UserAndToken, pagination?: PaginationParams) {
+ const url = new URL(`accounts/${auth.username}/cashouts`, this.baseUrl);
+ addPaginationParams(url, pagination);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForCashouts());
+ case HttpStatusCode.NoContent:
+ return opFixedSuccess({ cashouts: [] });
+ case HttpStatusCode.NotImplemented:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#get--cashouts
+ *
+ */
+ async getGlobalCashouts(auth: AccessToken, pagination?: PaginationParams) {
+ const url = new URL(`cashouts`, this.baseUrl);
+ addPaginationParams(url, pagination);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForGlobalCashouts());
+ case HttpStatusCode.NoContent:
+ return opFixedSuccess({ cashouts: [] });
+ case HttpStatusCode.NotImplemented:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // 2FA
+ //
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-challenge-$CHALLENGE_ID
+ *
+ */
+ async sendChallenge(auth: UserAndToken, cid: string) {
+ const url = new URL(
+ `accounts/${auth.username}/challenge/${cid}`,
+ this.baseUrl,
+ );
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForTanTransmission());
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.BadGateway: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-challenge-$CHALLENGE_ID-confirm
+ *
+ */
+ async confirmChallenge(
+ auth: UserAndToken,
+ cid: string,
+ body: TalerCorebankApi.ChallengeSolve,
+ ) {
+ const url = new URL(
+ `accounts/${auth.username}/challenge/${cid}/confirm`,
+ this.baseUrl,
+ );
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth.token),
+ },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ case HttpStatusCode.TooManyRequests:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // MONITOR
+ //
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#get--monitor
+ *
+ */
+ async getMonitor(
+ auth: AccessToken,
+ params: {
+ timeframe?: TalerCorebankApi.MonitorTimeframeParam;
+ date?: AbsoluteTime;
+ } = {},
+ ) {
+ const url = new URL(`monitor`, this.baseUrl);
+ if (params.timeframe) {
+ url.searchParams.set(
+ "timeframe",
+ TalerCorebankApi.MonitorTimeframeParam[params.timeframe],
+ );
+ }
+ if (params.date) {
+ const { t_s: seconds } = AbsoluteTime.toProtocolTimestamp(params.date);
+ if (seconds !== "never") {
+ url.searchParams.set("date_s", String(seconds));
+ }
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBearerTokenAuthHeader(auth),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForMonitorResponse());
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // Others API
+ //
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#taler-bank-integration-api
+ *
+ */
+ getIntegrationAPI(): URL {
+ return new URL(`taler-integration/`, this.baseUrl);
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#taler-bank-integration-api
+ *
+ */
+ getWireGatewayAPI(username: string): URL {
+ return new URL(`accounts/${username}/taler-wire-gateway/`, this.baseUrl);
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#taler-bank-integration-api
+ *
+ */
+ getRevenueAPI(username: string): URL {
+ return new URL(`accounts/${username}/taler-revenue/`, this.baseUrl);
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-token
+ *
+ */
+ getAuthenticationAPI(username: string): URL {
+ return new URL(`accounts/${username}/`, this.baseUrl);
+ }
+
+ /**
+ * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-token
+ *
+ */
+ getConversionInfoAPI(): URL {
+ return new URL(`conversion-info/`, this.baseUrl);
+ }
+}
diff --git a/packages/taler-util/src/http-client/bank-integration.ts b/packages/taler-util/src/http-client/bank-integration.ts
new file mode 100644
index 000000000..75e6a627a
--- /dev/null
+++ b/packages/taler-util/src/http-client/bank-integration.ts
@@ -0,0 +1,179 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { HttpRequestLibrary, readTalerErrorResponse } from "../http-common.js";
+import { HttpStatusCode } from "../http-status-codes.js";
+import { createPlatformHttpLib } from "../http.js";
+import { LibtoolVersion } from "../libtool-version.js";
+import {
+ FailCasesByMethod,
+ ResultByMethod,
+ opEmptySuccess,
+ opKnownHttpFailure,
+ opKnownTalerFailure,
+ opSuccessFromHttp,
+ opUnknownFailure,
+} from "../operation.js";
+import { TalerErrorCode } from "../taler-error-codes.js";
+import { codecForTalerErrorDetail } from "../wallet-types.js";
+import {
+ LongPollParams,
+ TalerBankIntegrationApi,
+ WithdrawalOperationStatus,
+ codecForBankWithdrawalOperationPostResponse,
+ codecForBankWithdrawalOperationStatus,
+ codecForIntegrationBankConfig,
+} from "./types.js";
+import { addLongPollingParam } from "./utils.js";
+
+export type TalerBankIntegrationResultByMethod<
+ prop extends keyof TalerBankIntegrationHttpClient,
+> = ResultByMethod<TalerBankIntegrationHttpClient, prop>;
+export type TalerBankIntegrationErrorsByMethod<
+ prop extends keyof TalerBankIntegrationHttpClient,
+> = FailCasesByMethod<TalerBankIntegrationHttpClient, prop>;
+
+/**
+ * The API is used by the wallets.
+ */
+export class TalerBankIntegrationHttpClient {
+ public readonly PROTOCOL_VERSION = "2:0:2";
+
+ httpLib: HttpRequestLibrary;
+
+ constructor(
+ readonly baseUrl: string,
+ httpClient?: HttpRequestLibrary,
+ ) {
+ this.httpLib = httpClient ?? createPlatformHttpLib();
+ }
+
+ isCompatible(version: string): boolean {
+ const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version);
+ return compare?.compatible ?? false;
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-integration.html#get--config
+ *
+ */
+ async getConfig() {
+ const url = new URL(`config`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForIntegrationBankConfig());
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-integration.html#get--withdrawal-operation-$WITHDRAWAL_ID
+ *
+ */
+ async getWithdrawalOperationById(
+ woid: string,
+ params?: {
+ old_state?: WithdrawalOperationStatus;
+ } & LongPollParams,
+ ) {
+ const url = new URL(`withdrawal-operation/${woid}`, this.baseUrl);
+ addLongPollingParam(url, params);
+ if (params) {
+ url.searchParams.set(
+ "old_state",
+ !params.old_state ? "pending" : params.old_state,
+ );
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForBankWithdrawalOperationStatus());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-integration.html#post-$BANK_API_BASE_URL-withdrawal-operation-$wopid
+ *
+ */
+ async completeWithdrawalOperationById(
+ woid: string,
+ body: TalerBankIntegrationApi.BankWithdrawalOperationPostRequest,
+ ) {
+ const url = new URL(`withdrawal-operation/${woid}`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(
+ resp,
+ codecForBankWithdrawalOperationPostResponse(),
+ );
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict: {
+ const body = await readTalerErrorResponse(resp);
+ const details = codecForTalerErrorDetail().decode(body);
+ switch (details.code) {
+ case TalerErrorCode.BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_UNKNOWN_ACCOUNT:
+ return opKnownTalerFailure(details.code, details);
+ case TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE:
+ return opKnownTalerFailure(details.code, details);
+ default:
+ return opUnknownFailure(resp, details);
+ }
+ }
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-integration.html#post-$BANK_API_BASE_URL-withdrawal-operation-$wopid
+ *
+ */
+ async abortWithdrawalOperationById(woid: string) {
+ const url = new URL(`withdrawal-operation/${woid}/abort`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+}
diff --git a/packages/taler-util/src/http-client/bank-revenue.ts b/packages/taler-util/src/http-client/bank-revenue.ts
new file mode 100644
index 000000000..34afe7d86
--- /dev/null
+++ b/packages/taler-util/src/http-client/bank-revenue.ts
@@ -0,0 +1,130 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ HttpRequestLibrary,
+ makeBasicAuthHeader,
+ readTalerErrorResponse,
+} from "../http-common.js";
+import { HttpStatusCode } from "../http-status-codes.js";
+import { createPlatformHttpLib } from "../http.js";
+import { LibtoolVersion } from "../libtool-version.js";
+import {
+ FailCasesByMethod,
+ ResultByMethod,
+ opKnownHttpFailure,
+ opSuccessFromHttp,
+ opUnknownFailure,
+} from "../operation.js";
+import {
+ LongPollParams,
+ PaginationParams,
+ codecForRevenueConfig,
+ codecForRevenueIncomingHistory,
+} from "./types.js";
+import { addLongPollingParam, addPaginationParams } from "./utils.js";
+
+export type TalerBankRevenueResultByMethod<
+ prop extends keyof TalerRevenueHttpClient,
+> = ResultByMethod<TalerRevenueHttpClient, prop>;
+export type TalerBankRevenueErrorsByMethod<
+ prop extends keyof TalerRevenueHttpClient,
+> = FailCasesByMethod<TalerRevenueHttpClient, prop>;
+
+type UsernameAndPassword = {
+ username: string;
+ password: string;
+};
+/**
+ * The API is used by the merchant (or other parties) to query
+ * for incoming transactions to their account.
+ */
+export class TalerRevenueHttpClient {
+ httpLib: HttpRequestLibrary;
+
+ constructor(
+ readonly baseUrl: string,
+ httpClient?: HttpRequestLibrary,
+ ) {
+ this.httpLib = httpClient ?? createPlatformHttpLib();
+ }
+
+ public readonly PROTOCOL_VERSION = "0:0:0";
+
+ isCompatible(version: string): boolean {
+ const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version);
+ return compare?.compatible ?? false;
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-revenue.html#get--config
+ *
+ */
+ async getConfig(auth?: UsernameAndPassword) {
+ const url = new URL(`config`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: auth
+ ? makeBasicAuthHeader(auth.username, auth.password)
+ : undefined,
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForRevenueConfig());
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+ /**
+ * https://docs.taler.net/core/api-bank-revenue.html#get--history
+ *
+ * @returns
+ */
+ async getHistory(
+ auth?: UsernameAndPassword,
+ params?: PaginationParams & LongPollParams,
+ ) {
+ const url = new URL(`history`, this.baseUrl);
+ addPaginationParams(url, params);
+ addLongPollingParam(url, params);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: auth
+ ? makeBasicAuthHeader(auth.username, auth.password)
+ : undefined,
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForRevenueIncomingHistory());
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+}
diff --git a/packages/taler-util/src/http-client/bank-wire.ts b/packages/taler-util/src/http-client/bank-wire.ts
new file mode 100644
index 000000000..a8c976a80
--- /dev/null
+++ b/packages/taler-util/src/http-client/bank-wire.ts
@@ -0,0 +1,226 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { HttpRequestLibrary, makeBasicAuthHeader, readTalerErrorResponse } from "../http-common.js";
+import { HttpStatusCode } from "../http-status-codes.js";
+import { createPlatformHttpLib } from "../http.js";
+import {
+ FailCasesByMethod,
+ ResultByMethod,
+ opFixedSuccess,
+ opKnownHttpFailure,
+ opSuccessFromHttp,
+ opUnknownFailure,
+} from "../operation.js";
+import {
+ LongPollParams,
+ PaginationParams,
+ TalerWireGatewayApi,
+ codecForAddIncomingResponse,
+ codecForIncomingHistory,
+ codecForOutgoingHistory,
+ codecForTransferResponse,
+} from "./types.js";
+import { addLongPollingParam, addPaginationParams } from "./utils.js";
+
+export type TalerWireGatewayResultByMethod<
+ prop extends keyof TalerWireGatewayHttpClient,
+> = ResultByMethod<TalerWireGatewayHttpClient, prop>;
+export type TalerWireGatewayErrorsByMethod<
+ prop extends keyof TalerWireGatewayHttpClient,
+> = FailCasesByMethod<TalerWireGatewayHttpClient, prop>;
+
+/**
+ * The API is used by the exchange to trigger transactions and query
+ * incoming transactions, as well as by the auditor to query incoming
+ * and outgoing transactions.
+ *
+ * https://docs.taler.net/core/api-bank-wire.html
+ */
+export class TalerWireGatewayHttpClient {
+ httpLib: HttpRequestLibrary;
+
+ constructor(
+ readonly baseUrl: string,
+ readonly username: string,
+ httpClient?: HttpRequestLibrary,
+ ) {
+ this.httpLib = httpClient ?? createPlatformHttpLib();
+ }
+ // public readonly PROTOCOL_VERSION = "4:0:0";
+ // isCompatible(version: string): boolean {
+ // const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version)
+ // return compare?.compatible ?? false
+ // }
+
+ // /**
+ // * https://docs.taler.net/core/api-corebank.html#config
+ // *
+ // */
+ // async getConfig() {
+ // const url = new URL(`config`, this.baseUrl);
+ // const resp = await this.httpLib.fetch(url.href, {
+ // method: "GET"
+ // });
+ // switch (resp.status) {
+ // case HttpStatusCode.Ok: return opSuccess(resp, codecForCoreBankConfig())
+ // default: return opUnknownFailure(resp, await readTalerErrorResponse(resp))
+ // }
+ // }
+
+ /**
+ * https://docs.taler.net/core/api-bank-wire.html#post--transfer
+ *
+ */
+ async transfer(auth: string, body: TalerWireGatewayApi.TransferRequest) {
+ const url = new URL(`transfer`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBasicAuthHeader(this.username, auth),
+ },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForTransferResponse());
+ //FIXME: show more details in docs
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ //FIXME: show more details in docs
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-wire.html#get--history-incoming
+ *
+ */
+ async getHistoryIncoming(
+ auth: string,
+ params?: PaginationParams & LongPollParams,
+ ) {
+ const url = new URL(`history/incoming`, this.baseUrl);
+ addPaginationParams(url, params);
+ addLongPollingParam(url, params);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBasicAuthHeader(this.username, auth),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForIncomingHistory());
+ //FIXME: account should not be returned or make it optional
+ case HttpStatusCode.NoContent:
+ return opFixedSuccess({
+ incoming_transactions: [],
+ credit_account: undefined,
+ });
+ //FIXME: show more details in docs
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ //FIXME: show more details in docs
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-wire.html#get--history-outgoing
+ *
+ */
+ async getHistoryOutgoing(
+ auth: string,
+ params?: PaginationParams & LongPollParams,
+ ) {
+ const url = new URL(`history/outgoing`, this.baseUrl);
+ addPaginationParams(url, params);
+ addLongPollingParam(url, params);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ Authorization: makeBasicAuthHeader(this.username, auth),
+ },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForOutgoingHistory());
+ //FIXME: account should not be returned or make it optional
+ case HttpStatusCode.NoContent:
+ return opFixedSuccess({
+ outgoing_transactions: [],
+ debit_account: undefined,
+ });
+ //FIXME: show more details in docs
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ //FIXME: show more details in docs
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-bank-wire.html#post--admin-add-incoming
+ *
+ */
+ async addIncoming(
+ auth: string,
+ body: TalerWireGatewayApi.AddIncomingRequest,
+ ) {
+ const url = new URL(`admin/add-incoming`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: {
+ Authorization: makeBasicAuthHeader(this.username, auth),
+ },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForAddIncomingResponse());
+ //FIXME: show more details in docs
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ //FIXME: show more details in docs
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+}
diff --git a/packages/taler-util/src/http-client/exchange.ts b/packages/taler-util/src/http-client/exchange.ts
new file mode 100644
index 000000000..ea7f44cf9
--- /dev/null
+++ b/packages/taler-util/src/http-client/exchange.ts
@@ -0,0 +1,242 @@
+import { HttpRequestLibrary, readTalerErrorResponse } from "../http-common.js";
+import { HttpStatusCode } from "../http-status-codes.js";
+import { createPlatformHttpLib } from "../http.js";
+import { LibtoolVersion } from "../libtool-version.js";
+import { hash } from "../nacl-fast.js";
+import {
+ FailCasesByMethod,
+ ResultByMethod,
+ opEmptySuccess,
+ opFixedSuccess,
+ opKnownHttpFailure,
+ opSuccessFromHttp,
+ opUnknownFailure,
+} from "../operation.js";
+import {
+ TalerSignaturePurpose,
+ amountToBuffer,
+ bufferForUint32,
+ buildSigPS,
+ decodeCrock,
+ eddsaSign,
+ encodeCrock,
+ stringToBytes,
+ timestampRoundedToBuffer,
+} from "../taler-crypto.js";
+import {
+ OfficerAccount,
+ PaginationParams,
+ SigningKey,
+ TalerExchangeApi,
+ codecForAmlDecisionDetails,
+ codecForAmlRecords,
+ codecForExchangeConfig,
+ codecForExchangeKeys,
+} from "./types.js";
+import { addPaginationParams } from "./utils.js";
+
+export type TalerExchangeResultByMethod<
+ prop extends keyof TalerExchangeHttpClient,
+> = ResultByMethod<TalerExchangeHttpClient, prop>;
+export type TalerExchangeErrorsByMethod<
+ prop extends keyof TalerExchangeHttpClient,
+> = FailCasesByMethod<TalerExchangeHttpClient, prop>;
+
+/**
+ */
+export class TalerExchangeHttpClient {
+ httpLib: HttpRequestLibrary;
+ public readonly PROTOCOL_VERSION = "18:0:1";
+
+ constructor(
+ readonly baseUrl: string,
+ httpClient?: HttpRequestLibrary,
+ ) {
+ this.httpLib = httpClient ?? createPlatformHttpLib();
+ }
+
+ isCompatible(version: string): boolean {
+ const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version);
+ return compare?.compatible ?? false;
+ }
+ /**
+ * https://docs.taler.net/core/api-exchange.html#get--config
+ *
+ */
+ async getConfig() {
+ const url = new URL(`config`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForExchangeConfig());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get--config
+ *
+ * PARTIALLY IMPLEMENTED!!
+ */
+ async getKeys() {
+ const url = new URL(`keys`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForExchangeKeys());
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ // TERMS
+
+ //
+ // AML operations
+ //
+
+ /**
+ * https://docs.taler.net/core/api-exchange.html#get--aml-$OFFICER_PUB-decisions-$STATE
+ *
+ */
+ async getDecisionsByState(
+ auth: OfficerAccount,
+ state: TalerExchangeApi.AmlState,
+ pagination?: PaginationParams,
+ ) {
+ const url = new URL(
+ `aml/${auth.id}/decisions/${TalerExchangeApi.AmlState[state]}`,
+ this.baseUrl,
+ );
+ addPaginationParams(url, pagination);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ "Taler-AML-Officer-Signature": buildQuerySignature(auth.signingKey),
+ },
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForAmlRecords());
+ case HttpStatusCode.NoContent:
+ return opFixedSuccess({ records: [] });
+ //this should be unauthorized
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-exchange.html#get--aml-$OFFICER_PUB-decision-$H_PAYTO
+ *
+ */
+ async getDecisionDetails(auth: OfficerAccount, account: string) {
+ const url = new URL(`aml/${auth.id}/decision/${account}`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {
+ "Taler-AML-Officer-Signature": buildQuerySignature(auth.signingKey),
+ },
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForAmlDecisionDetails());
+ case HttpStatusCode.NoContent:
+ return opFixedSuccess({ aml_history: [], kyc_attributes: [] });
+ //this should be unauthorized
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-exchange.html#post--aml-$OFFICER_PUB-decision
+ *
+ */
+ async addDecisionDetails(
+ auth: OfficerAccount,
+ decision: Omit<TalerExchangeApi.AmlDecision, "officer_sig">,
+ ) {
+ const url = new URL(`aml/${auth.id}/decision`, this.baseUrl);
+
+ const body = buildDecisionSignature(auth.signingKey, decision);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ //FIXME: this should be unauthorized
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ //FIXME: this two need to be split by error code
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+}
+
+function buildQuerySignature(key: SigningKey): string {
+ const sigBlob = buildSigPS(
+ TalerSignaturePurpose.TALER_SIGNATURE_AML_QUERY,
+ ).build();
+
+ return encodeCrock(eddsaSign(sigBlob, key));
+}
+
+function buildDecisionSignature(
+ key: SigningKey,
+ decision: Omit<TalerExchangeApi.AmlDecision, "officer_sig">,
+): TalerExchangeApi.AmlDecision {
+ const zero = new Uint8Array(new ArrayBuffer(64));
+
+ const sigBlob = buildSigPS(TalerSignaturePurpose.TALER_SIGNATURE_AML_DECISION)
+ //TODO: new need the null terminator, also in the exchange
+ .put(hash(stringToBytes(decision.justification))) //check null
+ .put(timestampRoundedToBuffer(decision.decision_time))
+ .put(amountToBuffer(decision.new_threshold))
+ .put(decodeCrock(decision.h_payto))
+ .put(zero) //kyc_requirement
+ .put(bufferForUint32(decision.new_state))
+ .build();
+
+ const officer_sig = encodeCrock(eddsaSign(sigBlob, key));
+ return {
+ ...decision,
+ officer_sig,
+ };
+}
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
new file mode 100644
index 000000000..d682dcfa0
--- /dev/null
+++ b/packages/taler-util/src/http-client/merchant.ts
@@ -0,0 +1,2343 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AccessToken,
+ FailCasesByMethod,
+ HttpStatusCode,
+ LibtoolVersion,
+ PaginationParams,
+ ResultByMethod,
+ TalerMerchantApi,
+ codecForAbortResponse,
+ codecForAccountAddResponse,
+ codecForAccountKycRedirects,
+ codecForAccountsSummaryResponse,
+ codecForBankAccountEntry,
+ codecForClaimResponse,
+ codecForInstancesResponse,
+ codecForInventorySummaryResponse,
+ codecForMerchantConfig,
+ codecForMerchantOrderPrivateStatusResponse,
+ codecForMerchantRefundResponse,
+ codecForOrderHistory,
+ codecForOtpDeviceDetails,
+ codecForOtpDeviceSummaryResponse,
+ codecForOutOfStockResponse,
+ codecForPaidRefundStatusResponse,
+ codecForPaymentResponse,
+ codecForPostOrderResponse,
+ codecForProductDetail,
+ codecForQueryInstancesResponse,
+ codecForStatusGoto,
+ codecForStatusPaid,
+ codecForStatusStatusUnpaid,
+ codecForTansferList,
+ codecForTemplateDetails,
+ codecForTemplateSummaryResponse,
+ codecForTokenFamiliesList,
+ codecForTokenFamilyDetails,
+ codecForWalletRefundResponse,
+ codecForWalletTemplateDetails,
+ codecForWebhookDetails,
+ codecForWebhookSummaryResponse,
+ opEmptySuccess,
+ opKnownAlternativeFailure,
+ opKnownHttpFailure,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ HttpResponse,
+ createPlatformHttpLib,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { opSuccessFromHttp, opUnknownFailure } from "../operation.js";
+import {
+ CacheEvictor,
+ addMerchantPaginationParams,
+ makeBearerTokenAuthHeader,
+ nullEvictor,
+} from "./utils.js";
+
+export type TalerMerchantInstanceResultByMethod<
+ prop extends keyof TalerMerchantInstanceHttpClient,
+> = ResultByMethod<TalerMerchantInstanceHttpClient, prop>;
+export type TalerMerchantInstanceErrorsByMethod<
+ prop extends keyof TalerMerchantInstanceHttpClient,
+> = FailCasesByMethod<TalerMerchantInstanceHttpClient, prop>;
+
+export enum TalerMerchantInstanceCacheEviction {
+ CREATE_ORDER,
+ UPDATE_ORDER,
+ DELETE_ORDER,
+ UPDATE_CURRENT_INSTANCE,
+ DELETE_CURRENT_INSTANCE,
+ CREATE_BANK_ACCOUNT,
+ UPDATE_BANK_ACCOUNT,
+ DELETE_BANK_ACCOUNT,
+ CREATE_PRODUCT,
+ UPDATE_PRODUCT,
+ DELETE_PRODUCT,
+ CREATE_TRANSFER,
+ DELETE_TRANSFER,
+ CREATE_DEVICE,
+ UPDATE_DEVICE,
+ DELETE_DEVICE,
+ CREATE_TEMPLATE,
+ UPDATE_TEMPLATE,
+ DELETE_TEMPLATE,
+ CREATE_WEBHOOK,
+ UPDATE_WEBHOOK,
+ DELETE_WEBHOOK,
+ CREATE_TOKENFAMILY,
+ UPDATE_TOKENFAMILY,
+ DELETE_TOKENFAMILY,
+ LAST,
+}
+export enum TalerMerchantManagementCacheEviction {
+ CREATE_INSTANCE = TalerMerchantInstanceCacheEviction.LAST + 1,
+ UPDATE_INSTANCE,
+ DELETE_INSTANCE,
+}
+/**
+ * Protocol version spoken with the core bank.
+ *
+ * Endpoint must be ordered in the same way that in the docs
+ * Response code (http and taler) must have the same order that in the docs
+ * That way is easier to see changes
+ *
+ * Uses libtool's current:revision:age versioning.
+ */
+export class TalerMerchantInstanceHttpClient {
+ public readonly PROTOCOL_VERSION = "10:0:6";
+
+ readonly httpLib: HttpRequestLibrary;
+ readonly cacheEvictor: CacheEvictor<TalerMerchantInstanceCacheEviction>;
+
+ constructor(
+ readonly baseUrl: string,
+ httpClient?: HttpRequestLibrary,
+ cacheEvictor?: CacheEvictor<TalerMerchantInstanceCacheEviction>,
+ ) {
+ this.httpLib = httpClient ?? createPlatformHttpLib();
+ this.cacheEvictor = cacheEvictor ?? nullEvictor;
+ }
+
+ isCompatible(version: string): boolean {
+ const compare = LibtoolVersion.compare(this.PROTOCOL_VERSION, version);
+ return compare?.compatible ?? false;
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get--config
+ *
+ */
+ async getConfig() {
+ const url = new URL(`config`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForMerchantConfig());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // Wallet API
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-orders-$ORDER_ID-claim
+ */
+ async claimOrder(orderId: string, body: TalerMerchantApi.ClaimRequest) {
+ const url = new URL(`orders/${orderId}/claim`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_ORDER,
+ );
+ return opSuccessFromHttp(resp, codecForClaimResponse());
+ }
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-orders-$ORDER_ID-pay
+ */
+ async makePayment(orderId: string, body: TalerMerchantApi.PayRequest) {
+ const url = new URL(`orders/${orderId}/pay`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_ORDER,
+ );
+ return opSuccessFromHttp(resp, codecForPaymentResponse());
+ }
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.PaymentRequired:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.RequestTimeout:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Gone:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.PreconditionFailed:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.BadGateway:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.GatewayTimeout:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-orders-$ORDER_ID
+ */
+
+ async getPaymentStatus(
+ orderId: string,
+ params: TalerMerchantApi.PaymentStatusRequestParams = {},
+ ) {
+ const url = new URL(`orders/${orderId}`, this.baseUrl);
+
+ if (params.allowRefundedForRepurchase !== undefined) {
+ url.searchParams.set(
+ "allow_refunded_for_repurchase",
+ params.allowRefundedForRepurchase ? "YES" : "NO",
+ );
+ }
+ if (params.awaitRefundObtained !== undefined) {
+ url.searchParams.set(
+ "await_refund_obtained",
+ params.allowRefundedForRepurchase ? "YES" : "NO",
+ );
+ }
+ if (params.claimToken !== undefined) {
+ url.searchParams.set("token", params.claimToken);
+ }
+ if (params.contractTermHash !== undefined) {
+ url.searchParams.set("h_contract", params.contractTermHash);
+ }
+ if (params.refund !== undefined) {
+ url.searchParams.set("refund", params.refund);
+ }
+ if (params.sessionId !== undefined) {
+ url.searchParams.set("session_id", params.sessionId);
+ }
+ if (params.timeout !== undefined) {
+ url.searchParams.set("timeout_ms", String(params.timeout));
+ }
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ // body,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForStatusPaid());
+ case HttpStatusCode.Accepted:
+ return opSuccessFromHttp(resp, codecForStatusGoto());
+ // case HttpStatusCode.Found: not possible since content is not HTML
+ case HttpStatusCode.PaymentRequired:
+ return opSuccessFromHttp(resp, codecForStatusStatusUnpaid());
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotAcceptable:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#demonstrating-payment
+ */
+ async demostratePayment(orderId: string, body: TalerMerchantApi.PaidRequest) {
+ const url = new URL(`orders/${orderId}/paid`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_ORDER,
+ );
+ return opSuccessFromHttp(resp, codecForPaidRefundStatusResponse());
+ }
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#aborting-incomplete-payments
+ */
+ async abortIncompletePayment(
+ orderId: string,
+ body: TalerMerchantApi.AbortRequest,
+ ) {
+ const url = new URL(`orders/${orderId}/abort`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_ORDER,
+ );
+ return opSuccessFromHttp(resp, codecForAbortResponse());
+ }
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#obtaining-refunds
+ */
+ async obtainRefund(
+ orderId: string,
+ body: TalerMerchantApi.WalletRefundRequest,
+ ) {
+ const url = new URL(`orders/${orderId}/refund`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_ORDER,
+ );
+ return opSuccessFromHttp(resp, codecForWalletRefundResponse());
+ }
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // Management
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-auth
+ */
+ async updateCurrentInstanceAuthentication(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.InstanceAuthConfigurationMessage,
+ ) {
+ const url = new URL(`private/auth`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: // FIXME: missing in docs
+ return opEmptySuccess(resp);
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#patch-[-instances-$INSTANCE]-private
+ */
+ async updateCurrentInstance(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.InstanceReconfigurationMessage,
+ ) {
+ const url = new URL(`private`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ body,
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_CURRENT_INSTANCE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private
+ *
+ */
+ async getCurrentInstanceDetails(token: AccessToken) {
+ const url = new URL(`private`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForQueryInstancesResponse());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private
+ */
+ async deleteCurrentInstance(
+ token: AccessToken | undefined,
+ params: { purge?: boolean } = {},
+ ) {
+ const url = new URL(`private`, this.baseUrl);
+
+ if (params.purge !== undefined) {
+ url.searchParams.set("purge", params.purge ? "YES" : "NO");
+ }
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.DELETE_CURRENT_INSTANCE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get--instances-$INSTANCE-private-kyc
+ */
+ async getCurrentIntanceKycStatus(
+ token: AccessToken | undefined,
+ params: TalerMerchantApi.GetKycStatusRequestParams = {},
+ ) {
+ const url = new URL(`private/kyc`, this.baseUrl);
+
+ if (params.wireHash) {
+ url.searchParams.set("h_wire", params.wireHash);
+ }
+ if (params.exchangeURL) {
+ url.searchParams.set("exchange_url", params.exchangeURL);
+ }
+ if (params.timeout) {
+ url.searchParams.set("timeout_ms", String(params.timeout));
+ }
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Accepted:
+ return opSuccessFromHttp(resp, codecForAccountKycRedirects());
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.BadGateway:
+ return opKnownAlternativeFailure(
+ resp,
+ resp.status,
+ codecForAccountKycRedirects(),
+ );
+ case HttpStatusCode.ServiceUnavailable:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.GatewayTimeout:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // Bank Accounts
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-accounts
+ */
+ async addBankAccount(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.AccountAddDetails,
+ ) {
+ const url = new URL(`private/accounts`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.CREATE_BANK_ACCOUNT,
+ );
+ return opSuccessFromHttp(resp, codecForAccountAddResponse());
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#patch-[-instances-$INSTANCE]-private-accounts-$H_WIRE
+ */
+ async updateBankAccount(
+ token: AccessToken | undefined,
+ wireAccount: string,
+ body: TalerMerchantApi.AccountPatchDetails,
+ ) {
+ const url = new URL(`private/accounts/${wireAccount}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ body,
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_BANK_ACCOUNT,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-accounts
+ */
+ async listBankAccounts(token: AccessToken, params?: PaginationParams) {
+ const url = new URL(`private/accounts`, this.baseUrl);
+
+ // addMerchantPaginationParams(url, params);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForAccountsSummaryResponse());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-accounts-$H_WIRE
+ */
+ async getBankAccountDetails(
+ token: AccessToken | undefined,
+ wireAccount: string,
+ ) {
+ const url = new URL(`private/accounts/${wireAccount}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForBankAccountEntry());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-accounts-$H_WIRE
+ */
+ async deleteBankAccount(token: AccessToken | undefined, wireAccount: string) {
+ const url = new URL(`private/accounts/${wireAccount}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.DELETE_BANK_ACCOUNT,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // Inventory Management
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-products
+ */
+ async addProduct(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.ProductAddDetail,
+ ) {
+ const url = new URL(`private/products`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.CREATE_PRODUCT,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#patch-[-instances-$INSTANCE]-private-products-$PRODUCT_ID
+ */
+ async updateProduct(
+ token: AccessToken | undefined,
+ productId: string,
+ body: TalerMerchantApi.ProductPatchDetail,
+ ) {
+ const url = new URL(`private/products/${productId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_PRODUCT,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-products
+ */
+ async listProducts(
+ token: AccessToken | undefined,
+ params?: PaginationParams,
+ ) {
+ const url = new URL(`private/products`, this.baseUrl);
+
+ addMerchantPaginationParams(url, params);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForInventorySummaryResponse());
+ case HttpStatusCode.Unauthorized: // FIXME: not in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-products-$PRODUCT_ID
+ */
+ async getProductDetails(token: AccessToken | undefined, productId: string) {
+ const url = new URL(`private/products/${productId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForProductDetail());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#reserving-inventory
+ */
+ async lockProduct(
+ token: AccessToken | undefined,
+ productId: string,
+ body: TalerMerchantApi.LockRequest,
+ ) {
+ const url = new URL(`private/products/${productId}/lock`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_PRODUCT,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Gone:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#removing-products-from-inventory
+ */
+ async deleteProduct(token: AccessToken | undefined, productId: string) {
+ const url = new URL(`private/products/${productId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.DELETE_PRODUCT,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // Payment processing
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-orders
+ */
+ async createOrder(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.PostOrderRequest,
+ ) {
+ const url = new URL(`private/orders`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+ return this.procesOrderCreationResponse(resp);
+ }
+
+ private async procesOrderCreationResponse(resp: HttpResponse) {
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.CREATE_ORDER,
+ );
+ return opSuccessFromHttp(resp, codecForPostOrderResponse());
+ }
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Gone:
+ return opKnownAlternativeFailure(
+ resp,
+ resp.status,
+ codecForOutOfStockResponse(),
+ );
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#inspecting-orders
+ */
+ async listOrders(
+ token: AccessToken | undefined,
+ params: TalerMerchantApi.ListOrdersRequestParams = {},
+ ) {
+ const url = new URL(`private/orders`, this.baseUrl);
+
+ if (params.date) {
+ url.searchParams.set("date_s", String(params.date));
+ }
+ if (params.fulfillmentUrl) {
+ url.searchParams.set("fulfillment_url", params.fulfillmentUrl);
+ }
+ if (params.paid !== undefined) {
+ url.searchParams.set("paid", params.paid ? "YES" : "NO");
+ }
+ if (params.refunded !== undefined) {
+ url.searchParams.set("refunded", params.refunded ? "YES" : "NO");
+ }
+ if (params.sessionId) {
+ url.searchParams.set("session_id", params.sessionId);
+ }
+ if (params.timeout) {
+ url.searchParams.set("timeout", String(params.timeout));
+ }
+ if (params.wired !== undefined) {
+ url.searchParams.set("wired", params.wired ? "YES" : "NO");
+ }
+ addMerchantPaginationParams(url, params);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForOrderHistory());
+ case HttpStatusCode.NotFound: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-orders-$ORDER_ID
+ */
+ async getOrderDetails(
+ token: AccessToken | undefined,
+ orderId: string,
+ params: TalerMerchantApi.GetOrderRequestParams = {},
+ ) {
+ const url = new URL(`private/orders/${orderId}`, this.baseUrl);
+
+ if (params.allowRefundedForRepurchase !== undefined) {
+ url.searchParams.set(
+ "allow_refunded_for_repurchase",
+ params.allowRefundedForRepurchase ? "YES" : "NO",
+ );
+ }
+ if (params.sessionId) {
+ url.searchParams.set("session_id", params.sessionId);
+ }
+ if (params.timeout) {
+ url.searchParams.set("timeout_ms", String(params.timeout));
+ }
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(
+ resp,
+ codecForMerchantOrderPrivateStatusResponse(),
+ );
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.BadGateway:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.GatewayTimeout:
+ return opKnownAlternativeFailure(
+ resp,
+ resp.status,
+ codecForOutOfStockResponse(),
+ );
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#private-order-data-cleanup
+ */
+ async forgetOrder(
+ token: AccessToken | undefined,
+ orderId: string,
+ body: TalerMerchantApi.ForgetRequest,
+ ) {
+ const url = new URL(`private/orders/${orderId}/forget`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_ORDER,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.BadRequest:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-orders-$ORDER_ID
+ */
+ async deleteOrder(token: AccessToken | undefined, orderId: string) {
+ const url = new URL(`private/orders/${orderId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.DELETE_ORDER,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // Refunds
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-orders-$ORDER_ID-refund
+ */
+ async addRefund(
+ token: AccessToken | undefined,
+ orderId: string,
+ body: TalerMerchantApi.RefundRequest,
+ ) {
+ const url = new URL(`private/orders/${orderId}/refund`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_ORDER,
+ );
+ return opSuccessFromHttp(resp, codecForMerchantRefundResponse());
+ }
+ case HttpStatusCode.Forbidden:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Gone:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // Wire Transfer
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-transfers
+ */
+ async informWireTransfer(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.TransferInformation,
+ ) {
+ const url = new URL(`private/transfers`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.CREATE_TRANSFER,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-transfers
+ */
+ async listWireTransfers(
+ token: AccessToken | undefined,
+ params: TalerMerchantApi.ListWireTransferRequestParams = {},
+ ) {
+ const url = new URL(`private/transfers`, this.baseUrl);
+
+ if (params.after) {
+ url.searchParams.set("after", String(params.after));
+ }
+ if (params.before) {
+ url.searchParams.set("before", String(params.before));
+ }
+ if (params.paytoURI) {
+ url.searchParams.set("payto_uri", params.paytoURI);
+ }
+ if (params.verified !== undefined) {
+ url.searchParams.set("verified", params.verified ? "YES" : "NO");
+ }
+ addMerchantPaginationParams(url, params);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForTansferList());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-transfers-$TID
+ */
+ async deleteWireTransfer(token: AccessToken | undefined, transferId: string) {
+ const url = new URL(`private/transfers/${transferId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.DELETE_TRANSFER,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // OTP Devices
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-otp-devices
+ */
+ async addOtpDevice(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.OtpDeviceAddDetails,
+ ) {
+ const url = new URL(`private/otp-devices`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.CREATE_DEVICE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#patch-[-instances-$INSTANCE]-private-otp-devices-$DEVICE_ID
+ */
+ async updateOtpDevice(
+ token: AccessToken | undefined,
+ deviceId: string,
+ body: TalerMerchantApi.OtpDevicePatchDetails,
+ ) {
+ const url = new URL(`private/otp-devices/${deviceId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ body,
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_DEVICE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-otp-devices
+ */
+ async listOtpDevices(
+ token: AccessToken | undefined,
+ params?: PaginationParams,
+ ) {
+ const url = new URL(`private/otp-devices`, this.baseUrl);
+
+ addMerchantPaginationParams(url, params);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForOtpDeviceSummaryResponse());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-otp-devices-$DEVICE_ID
+ */
+ async getOtpDeviceDetails(
+ token: AccessToken | undefined,
+ deviceId: string,
+ params: TalerMerchantApi.GetOtpDeviceRequestParams = {},
+ ) {
+ const url = new URL(`private/otp-devices/${deviceId}`, this.baseUrl);
+
+ if (params.faketime) {
+ url.searchParams.set("faketime", String(params.faketime));
+ }
+ if (params.price) {
+ url.searchParams.set("price", params.price);
+ }
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForOtpDeviceDetails());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-otp-devices-$DEVICE_ID
+ */
+ async deleteOtpDevice(token: AccessToken | undefined, deviceId: string) {
+ const url = new URL(`private/otp-devices/${deviceId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.DELETE_DEVICE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // Templates
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-templates
+ */
+ async addTemplate(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.TemplateAddDetails,
+ ) {
+ const url = new URL(`private/templates`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.CREATE_TEMPLATE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#patch-[-instances-$INSTANCE]-private-templates-$TEMPLATE_ID
+ */
+ async updateTemplate(
+ token: AccessToken | undefined,
+ templateId: string,
+ body: TalerMerchantApi.TemplatePatchDetails,
+ ) {
+ const url = new URL(`private/templates/${templateId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ body,
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_TEMPLATE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#inspecting-template
+ */
+ async listTemplates(
+ token: AccessToken | undefined,
+ params?: PaginationParams,
+ ) {
+ const url = new URL(`private/templates`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForTemplateSummaryResponse());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-templates-$TEMPLATE_ID
+ */
+ async getTemplateDetails(token: AccessToken | undefined, templateId: string) {
+ const url = new URL(`private/templates/${templateId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForTemplateDetails());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-templates-$TEMPLATE_ID
+ */
+ async deleteTemplate(token: AccessToken | undefined, templateId: string) {
+ const url = new URL(`private/templates/${templateId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.DELETE_TEMPLATE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-templates-$TEMPLATE_ID
+ */
+ async useTemplateGetInfo(templateId: string) {
+ const url = new URL(`templates/${templateId}`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForWalletTemplateDetails());
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCES]-templates-$TEMPLATE_ID
+ */
+ async useTemplateCreateOrder(
+ templateId: string,
+ body: TalerMerchantApi.UsingTemplateDetails,
+ ) {
+ const url = new URL(`templates/${templateId}`, this.baseUrl);
+
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ });
+
+ return this.procesOrderCreationResponse(resp);
+ }
+
+ //
+ // Webhooks
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCES]-private-webhooks
+ */
+ async addWebhook(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.WebhookAddDetails,
+ ) {
+ const url = new URL(`private/webhooks`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.CREATE_WEBHOOK,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#patch-[-instances-$INSTANCES]-private-webhooks-$WEBHOOK_ID
+ */
+ async updateWebhook(
+ token: AccessToken | undefined,
+ webhookId: string,
+ body: TalerMerchantApi.WebhookPatchDetails,
+ ) {
+ const url = new URL(`private/webhooks/${webhookId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_WEBHOOK,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCES]-private-webhooks
+ */
+ async listWebhooks(
+ token: AccessToken | undefined,
+ params?: PaginationParams,
+ ) {
+ const url = new URL(`private/webhooks`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForWebhookSummaryResponse());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCES]-private-webhooks-$WEBHOOK_ID
+ */
+ async getWebhookDetails(token: AccessToken | undefined, webhookId: string) {
+ const url = new URL(`private/webhooks/${webhookId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent:
+ return opSuccessFromHttp(resp, codecForWebhookDetails());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCES]-private-webhooks-$WEBHOOK_ID
+ */
+ async deleteWebhook(token: AccessToken | undefined, webhookId: string) {
+ const url = new URL(`private/webhooks/${webhookId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.DELETE_WEBHOOK,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ //
+ // token families
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCES]-private-tokenfamilies
+ */
+ async createTokenFamily(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.TokenFamilyCreateRequest,
+ ) {
+ const url = new URL(`private/tokenfamilies`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.CREATE_TOKENFAMILY,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#patch-[-instances-$INSTANCES]-private-tokenfamilies-$TOKEN_FAMILY_SLUG
+ */
+ async updateTokenFamily(
+ token: AccessToken | undefined,
+ tokenSlug: string,
+ body: TalerMerchantApi.TokenFamilyUpdateRequest,
+ ) {
+ const url = new URL(`private/tokenfamilies/${tokenSlug}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_TOKENFAMILY,
+ );
+ return opSuccessFromHttp(resp, codecForTokenFamilyDetails());
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCES]-private-tokenfamilies
+ */
+ async listTokenFamilies(
+ token: AccessToken | undefined,
+ params?: PaginationParams,
+ ) {
+ const url = new URL(`private/tokenfamilies`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForTokenFamiliesList());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCES]-private-tokenfamilies-$TOKEN_FAMILY_SLUG
+ */
+ async getTokenFamilyDetails(
+ token: AccessToken | undefined,
+ tokenSlug: string,
+ ) {
+ const url = new URL(`private/tokenfamilies/${tokenSlug}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForTokenFamilyDetails());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCES]-private-tokenfamilies-$TOKEN_FAMILY_SLUG
+ */
+ async deleteTokenFamily(token: AccessToken | undefined, tokenSlug: string) {
+ const url = new URL(`private/tokenfamilies/${tokenSlug}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.DELETE_TOKENFAMILY,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * Get the auth api against the current instance
+ *
+ * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-token
+ * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-token
+ */
+ getAuthenticationAPI(): URL {
+ return new URL(`private/`, this.baseUrl);
+ }
+}
+
+export type TalerMerchantManagementResultByMethod<
+ prop extends keyof TalerMerchantManagementHttpClient,
+> = ResultByMethod<TalerMerchantManagementHttpClient, prop>;
+export type TalerMerchantManagementErrorsByMethod<
+ prop extends keyof TalerMerchantManagementHttpClient,
+> = FailCasesByMethod<TalerMerchantManagementHttpClient, prop>;
+
+export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttpClient {
+ readonly cacheManagementEvictor: CacheEvictor<
+ TalerMerchantInstanceCacheEviction | TalerMerchantManagementCacheEviction
+ >;
+ constructor(
+ readonly baseUrl: string,
+ httpClient?: HttpRequestLibrary,
+ // cacheManagementEvictor?: CacheEvictor<TalerMerchantManagementCacheEviction>,
+ cacheEvictor?: CacheEvictor<
+ TalerMerchantInstanceCacheEviction | TalerMerchantManagementCacheEviction
+ >,
+ ) {
+ super(baseUrl, httpClient, cacheEvictor);
+ this.cacheManagementEvictor = cacheEvictor ?? nullEvictor;
+ }
+
+ getSubInstanceAPI(instanceId: string) {
+ return new URL(`instances/${instanceId}/`, this.baseUrl);
+ }
+
+ //
+ // Instance Management
+ //
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post--management-instances
+ */
+ async createInstance(
+ token: AccessToken | undefined,
+ body: TalerMerchantApi.InstanceConfigurationMessage,
+ ) {
+ const url = new URL(`management/instances`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheManagementEvictor.notifySuccess(
+ TalerMerchantManagementCacheEviction.CREATE_INSTANCE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#post--management-instances-$INSTANCE-auth
+ */
+ async updateInstanceAuthentication(
+ token: AccessToken | undefined,
+ instanceId: string,
+ body: TalerMerchantApi.InstanceAuthConfigurationMessage,
+ ) {
+ const url = new URL(
+ `management/instances/${instanceId}/auth`,
+ this.baseUrl,
+ );
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ body,
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#patch--management-instances-$INSTANCE
+ */
+ async updateInstance(
+ token: AccessToken | undefined,
+ instanceId: string,
+ body: TalerMerchantApi.InstanceReconfigurationMessage,
+ ) {
+ const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ body,
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheManagementEvictor.notifySuccess(
+ TalerMerchantManagementCacheEviction.UPDATE_INSTANCE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get--management-instances
+ */
+ async listInstances(
+ token: AccessToken | undefined,
+ params?: PaginationParams,
+ ) {
+ const url = new URL(`management/instances`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForInstancesResponse());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get--management-instances-$INSTANCE
+ *
+ */
+ async getInstanceDetails(token: AccessToken | undefined, instanceId: string) {
+ const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForQueryInstancesResponse());
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#delete--management-instances-$INSTANCE
+ */
+ async deleteInstance(
+ token: AccessToken | undefined,
+ instanceId: string,
+ params: { purge?: boolean } = {},
+ ) {
+ const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
+
+ if (params.purge !== undefined) {
+ url.searchParams.set("purge", params.purge ? "YES" : "NO");
+ }
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent: {
+ this.cacheManagementEvictor.notifySuccess(
+ TalerMerchantManagementCacheEviction.DELETE_INSTANCE,
+ );
+ return opEmptySuccess(resp);
+ }
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+
+ /**
+ * https://docs.taler.net/core/api-merchant.html#get--management-instances-$INSTANCE-kyc
+ */
+ async getIntanceKycStatus(
+ token: AccessToken | undefined,
+ instanceId: string,
+ params: TalerMerchantApi.GetKycStatusRequestParams,
+ ) {
+ const url = new URL(`management/instances/${instanceId}/kyc`, this.baseUrl);
+
+ if (params.wireHash) {
+ url.searchParams.set("h_wire", params.wireHash);
+ }
+ if (params.exchangeURL) {
+ url.searchParams.set("exchange_url", params.exchangeURL);
+ }
+ if (params.timeout) {
+ url.searchParams.set("timeout_ms", String(params.timeout));
+ }
+
+ const headers: Record<string, string> = {};
+ if (token) {
+ headers.Authorization = makeBearerTokenAuthHeader(token);
+ }
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Accepted:
+ return opSuccessFromHttp(resp, codecForAccountKycRedirects());
+ case HttpStatusCode.NoContent:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.NotFound:
+ return opEmptySuccess(resp);
+ case HttpStatusCode.Unauthorized: // FIXME: missing in docs
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.BadGateway:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.ServiceUnavailable:
+ return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Conflict:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownFailure(resp, await readTalerErrorResponse(resp));
+ }
+ }
+}
diff --git a/packages/taler-util/src/http-client/officer-account.ts b/packages/taler-util/src/http-client/officer-account.ts
new file mode 100644
index 000000000..2c1426be2
--- /dev/null
+++ b/packages/taler-util/src/http-client/officer-account.ts
@@ -0,0 +1,105 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ EncryptionNonce,
+ LockedAccount,
+ OfficerAccount,
+ OfficerId,
+ SigningKey,
+ createEddsaKeyPair,
+ decodeCrock,
+ decryptWithDerivedKey,
+ eddsaGetPublic,
+ encodeCrock,
+ encryptWithDerivedKey,
+ getRandomBytesF,
+ kdf,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+
+/**
+ * Restore previous session and unlock account with password
+ *
+ * @param salt string from which crypto params will be derived
+ * @param key secured private key
+ * @param password password for the private key
+ * @returns
+ */
+export async function unlockOfficerAccount(
+ account: LockedAccount,
+ password: string,
+): Promise<OfficerAccount> {
+ const rawKey = decodeCrock(account);
+ const rawPassword = stringToBytes(password);
+
+ const signingKey = (await decryptWithDerivedKey(
+ rawKey,
+ rawPassword,
+ password,
+ ).catch((e: Error) => {
+ throw new UnwrapKeyError(e.message);
+ })) as SigningKey;
+
+ const publicKey = eddsaGetPublic(signingKey);
+
+ const accountId = encodeCrock(publicKey) as OfficerId;
+
+ return { id: accountId, signingKey };
+}
+
+/**
+ * Create new account (secured private key)
+ * secured with the given password
+ *
+ * @param sessionId
+ * @param password
+ * @returns
+ */
+export async function createNewOfficerAccount(
+ password: string,
+ extraNonce: EncryptionNonce,
+): Promise<OfficerAccount & { safe: LockedAccount }> {
+ const { eddsaPriv, eddsaPub } = createEddsaKeyPair();
+
+ const key = stringToBytes(password);
+
+ const localRnd = getRandomBytesF(24);
+ const mergedRnd: EncryptionNonce = extraNonce
+ ? kdf(24, stringToBytes("aml-officer"), extraNonce, localRnd)
+ : localRnd;
+
+ const protectedPrivKey = await encryptWithDerivedKey(
+ mergedRnd,
+ key,
+ eddsaPriv,
+ password,
+ );
+
+ const signingKey = eddsaPriv as SigningKey;
+ const accountId = encodeCrock(eddsaPub) as OfficerId;
+ const safe = encodeCrock(protectedPrivKey) as LockedAccount;
+
+ return { id: accountId, signingKey, safe };
+}
+
+export class UnwrapKeyError extends Error {
+ public cause: string;
+ constructor(cause: string) {
+ super(`Recovering private key failed on: ${cause}`);
+ this.cause = cause;
+ }
+}
diff --git a/packages/taler-util/src/http-client/types.ts b/packages/taler-util/src/http-client/types.ts
new file mode 100644
index 000000000..94eafb329
--- /dev/null
+++ b/packages/taler-util/src/http-client/types.ts
@@ -0,0 +1,5225 @@
+import { deprecate } from "util";
+import { codecForAmountString } from "../amounts.js";
+import {
+ Codec,
+ buildCodecForObject,
+ buildCodecForUnion,
+ codecForAny,
+ codecForBoolean,
+ codecForConstNumber,
+ codecForConstString,
+ codecForEither,
+ codecForList,
+ codecForMap,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+} from "../codec.js";
+import { PaytoString, codecForPaytoString } from "../payto.js";
+import {
+ AmountString,
+ codecForInternationalizedString,
+ codecForLocation,
+} from "../taler-types.js";
+import { TalerUriString, codecForTalerUriString } from "../taleruri.js";
+import {
+ AbsoluteTime,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ codecForAbsoluteTime,
+ codecForDuration,
+ codecForTimestamp,
+} from "../time.js";
+
+export type UserAndPassword = {
+ username: string;
+ password: string;
+};
+
+export type UserAndToken = {
+ username: string;
+ token: AccessToken;
+};
+
+declare const opaque_OfficerAccount: unique symbol;
+export type LockedAccount = string & { [opaque_OfficerAccount]: true };
+
+declare const opaque_OfficerId: unique symbol;
+export type OfficerId = string & { [opaque_OfficerId]: true };
+
+declare const opaque_OfficerSigningKey: unique symbol;
+export type SigningKey = Uint8Array & { [opaque_OfficerSigningKey]: true };
+
+export interface OfficerAccount {
+ id: OfficerId;
+ signingKey: SigningKey;
+}
+
+export type PaginationParams = {
+ /**
+ * row identifier as the starting point of the query
+ */
+ offset?: string;
+ /**
+ * max number of element in the result response
+ * always greater than 0
+ */
+ limit?: number;
+ /**
+ * order
+ */
+ order?: "asc" | "dec";
+};
+
+export type LongPollParams = {
+ /**
+ * milliseconds the server should wait for at least one result to be shown
+ */
+ timeoutMs?: number;
+};
+///
+/// HASH
+///
+
+// 64-byte hash code.
+type HashCode = string;
+
+type PaytoHash = string;
+
+type AmlOfficerPublicKeyP = string;
+
+// 32-byte hash code.
+type ShortHashCode = string;
+
+// 16-byte salt.
+type WireSalt = string;
+
+type SHA256HashCode = ShortHashCode;
+
+type SHA512HashCode = HashCode;
+
+// 32-byte nonce value, must only be used once.
+type CSNonce = string;
+
+// 32-byte nonce value, must only be used once.
+type RefreshMasterSeed = string;
+
+// 32-byte value representing a point on Curve25519.
+type Cs25519Point = string;
+
+// 32-byte value representing a scalar multiplier
+// for scalar operations on points on Curve25519.
+type Cs25519Scalar = string;
+
+///
+/// KEYS
+///
+
+// 16-byte access token used to authorize access.
+type ClaimToken = string;
+
+// EdDSA and ECDHE public keys always point on Curve25519
+// and represented using the standard 256 bits Ed25519 compact format,
+// converted to Crockford Base32.
+type EddsaPublicKey = string;
+
+// EdDSA and ECDHE public keys always point on Curve25519
+// and represented using the standard 256 bits Ed25519 compact format,
+// converted to Crockford Base32.
+type EddsaPrivateKey = string;
+
+// Edx25519 public keys are points on Curve25519 and represented using the
+// standard 256 bits Ed25519 compact format converted to Crockford
+// Base32.
+type Edx25519PublicKey = string;
+
+// Edx25519 private keys are always points on Curve25519
+// and represented using the standard 256 bits Ed25519 compact format,
+// converted to Crockford Base32.
+type Edx25519PrivateKey = string;
+
+// EdDSA and ECDHE public keys always point on Curve25519
+// and represented using the standard 256 bits Ed25519 compact format,
+// converted to Crockford Base32.
+type EcdhePublicKey = string;
+
+// Point on Curve25519 represented using the standard 256 bits Ed25519 compact format,
+// converted to Crockford Base32.
+type CsRPublic = string;
+
+// EdDSA and ECDHE public keys always point on Curve25519
+// and represented using the standard 256 bits Ed25519 compact format,
+// converted to Crockford Base32.
+type EcdhePrivateKey = string;
+
+type CoinPublicKey = EddsaPublicKey;
+
+// RSA public key converted to Crockford Base32.
+type RsaPublicKey = string;
+
+type Integer = number;
+
+type WireTransferIdentifierRawP = string;
+// Subset of numbers: Integers in the
+// inclusive range 0 .. (2^53 - 1).
+type SafeUint64 = number;
+
+// The string must be a data URL according to RFC 2397
+// with explicit mediatype and base64 parameters.
+//
+// data:<mediatype>;base64,<data>
+//
+// Supported mediatypes are image/jpeg and image/png.
+// Invalid strings will be rejected by the wallet.
+type ImageDataUrl = string;
+
+type WadId = string;
+
+type Timestamp = TalerProtocolTimestamp;
+
+type RelativeTime = TalerProtocolDuration;
+
+export interface LoginToken {
+ token: AccessToken;
+ expiration: Timestamp;
+}
+
+declare const __ac_token: unique symbol;
+/**
+ * Use `createAccessToken(string)` function to build one.
+ */
+export type AccessToken = string & {
+ [__ac_token]: true;
+};
+
+/**
+ * Create a rfc8959 access token.
+ * Adds secret-token: prefix if there is none.
+ *
+ * @param token
+ * @returns
+ */
+export function createAccessToken(token: string): AccessToken {
+ return (token.startsWith("secret-token:") ? token : `secret-token:${token}`) as AccessToken
+}
+
+declare const __officer_signature: unique symbol;
+export type OfficerSignature = string & {
+ [__officer_signature]: true;
+};
+
+export namespace TalerAuthentication {
+ export interface TokenRequest {
+ // Service-defined scope for the token.
+ // Typical scopes would be "readonly" or "readwrite".
+ scope: string;
+
+ // Server may impose its own upper bound
+ // on the token validity duration
+ duration?: RelativeTime;
+
+ // Is the token refreshable into a new token during its
+ // validity?
+ // Refreshable tokens effectively provide indefinite
+ // access if they are refreshed in time.
+ refreshable?: boolean;
+ }
+
+ export interface TokenSuccessResponse {
+ // Expiration determined by the server.
+ // Can be based on the token_duration
+ // from the request, but ultimately the
+ // server decides the expiration.
+ expiration: Timestamp;
+
+ // Opque access token.
+ access_token: AccessToken;
+ }
+ export interface TokenSuccessResponseMerchant {
+ // Expiration determined by the server.
+ // Can be based on the token_duration
+ // from the request, but ultimately the
+ // server decides the expiration.
+ expiration: Timestamp;
+
+ // Opque access token.
+ token: AccessToken;
+ }
+}
+
+// DD51 https://docs.taler.net/design-documents/051-fractional-digits.html
+export interface CurrencySpecification {
+ // Name of the currency.
+ name: string;
+
+ // how many digits the user may enter after the decimal_separator
+ num_fractional_input_digits: Integer;
+
+ // Number of fractional digits to render in normal font and size.
+ num_fractional_normal_digits: Integer;
+
+ // Number of fractional digits to render always, if needed by
+ // padding with zeros.
+ num_fractional_trailing_zero_digits: Integer;
+
+ // map of powers of 10 to alternative currency names / symbols, must
+ // always have an entry under "0" that defines the base name,
+ // e.g. "0 => €" or "3 => k€". For BTC, would be "0 => BTC, -3 => mBTC".
+ // Communicates the currency symbol to be used.
+ alt_unit_names: { [log10: string]: string };
+}
+
+//FIXME: implement this codec
+export const codecForAccessToken = codecForString as () => Codec<AccessToken>;
+export const codecForTokenSuccessResponse =
+ (): Codec<TalerAuthentication.TokenSuccessResponse> =>
+ buildCodecForObject<TalerAuthentication.TokenSuccessResponse>()
+ .property("access_token", codecForAccessToken())
+ .property("expiration", codecForTimestamp)
+ .build("TalerAuthentication.TokenSuccessResponse");
+
+export const codecForTokenSuccessResponseMerchant =
+ (): Codec<TalerAuthentication.TokenSuccessResponseMerchant> =>
+ buildCodecForObject<TalerAuthentication.TokenSuccessResponseMerchant>()
+ .property("token", codecForAccessToken())
+ .property("expiration", codecForTimestamp)
+ .build("TalerAuthentication.TokenSuccessResponseMerchant");
+
+export const codecForCurrencySpecificiation =
+ (): Codec<CurrencySpecification> =>
+ buildCodecForObject<CurrencySpecification>()
+ .property("name", codecForString())
+ .property("num_fractional_input_digits", codecForNumber())
+ .property("num_fractional_normal_digits", codecForNumber())
+ .property("num_fractional_trailing_zero_digits", codecForNumber())
+ .property("alt_unit_names", codecForMap(codecForString()))
+ .build("CurrencySpecification");
+
+export const codecForIntegrationBankConfig =
+ (): Codec<TalerCorebankApi.IntegrationConfig> =>
+ buildCodecForObject<TalerCorebankApi.IntegrationConfig>()
+ .property("name", codecForConstString("taler-bank-integration"))
+ .property("version", codecForString())
+ .property("currency", codecForString())
+ .property("currency_specification", codecForCurrencySpecificiation())
+ .build("TalerCorebankApi.IntegrationConfig");
+
+export const codecForCoreBankConfig = (): Codec<TalerCorebankApi.Config> =>
+ buildCodecForObject<TalerCorebankApi.Config>()
+ .property("name", codecForConstString("libeufin-bank"))
+ .property("version", codecForString())
+ .property("bank_name", codecForString())
+ .property("allow_conversion", codecForBoolean())
+ .property("allow_registrations", codecForBoolean())
+ .property("allow_deletions", codecForBoolean())
+ .property("allow_edit_name", codecForBoolean())
+ .property("allow_edit_cashout_payto_uri", codecForBoolean())
+ .property("default_debit_threshold", codecForAmountString())
+ .property("currency", codecForString())
+ .property("currency_specification", codecForCurrencySpecificiation())
+ .property(
+ "supported_tan_channels",
+ codecForList(
+ codecForEither(
+ codecForConstString(TalerCorebankApi.TanChannel.SMS),
+ codecForConstString(TalerCorebankApi.TanChannel.EMAIL),
+ ),
+ ),
+ )
+ .property("wire_type", codecForString())
+ .build("TalerCorebankApi.Config");
+
+//FIXME: implement this codec
+export const codecForURN = codecForString;
+
+export const codecForExchangeConfigInfo =
+ (): Codec<TalerMerchantApi.ExchangeConfigInfo> =>
+ buildCodecForObject<TalerMerchantApi.ExchangeConfigInfo>()
+ .property("base_url", codecForString())
+ .property("currency", codecForString())
+ .property("master_pub", codecForString())
+ .build("TalerMerchantApi.ExchangeConfigInfo");
+
+export const codecForMerchantConfig =
+ (): Codec<TalerMerchantApi.VersionResponse> =>
+ buildCodecForObject<TalerMerchantApi.VersionResponse>()
+ .property("name", codecForConstString("taler-merchant"))
+ .property("currency", codecForString())
+ .property("version", codecForString())
+ .property("currencies", codecForMap(codecForCurrencySpecificiation()))
+ .property("exchanges", codecForList(codecForExchangeConfigInfo()))
+ .build("TalerMerchantApi.VersionResponse");
+
+export const codecForClaimResponse =
+ (): Codec<TalerMerchantApi.ClaimResponse> =>
+ buildCodecForObject<TalerMerchantApi.ClaimResponse>()
+ .property("contract_terms", codecForContractTerms())
+ .property("sig", codecForString())
+ .build("TalerMerchantApi.ClaimResponse");
+
+export const codecForPaymentResponse =
+ (): Codec<TalerMerchantApi.PaymentResponse> =>
+ buildCodecForObject<TalerMerchantApi.PaymentResponse>()
+ .property("pos_confirmation", codecOptional(codecForString()))
+ .property("sig", codecForString())
+ .build("TalerMerchantApi.PaymentResponse");
+
+export const codecForStatusPaid = (): Codec<TalerMerchantApi.StatusPaid> =>
+ buildCodecForObject<TalerMerchantApi.StatusPaid>()
+ .property("refund_amount", codecForAmountString())
+ .property("refund_pending", codecForBoolean())
+ .property("refund_taken", codecForAmountString())
+ .property("refunded", codecForBoolean())
+ .property("type", codecForConstString("paid"))
+ .build("TalerMerchantApi.StatusPaid");
+
+export const codecForStatusGoto =
+ (): Codec<TalerMerchantApi.StatusGotoResponse> =>
+ buildCodecForObject<TalerMerchantApi.StatusGotoResponse>()
+ .property("public_reorder_url", codecForURL())
+ .property("type", codecForConstString("goto"))
+ .build("TalerMerchantApi.StatusGotoResponse");
+
+export const codecForStatusStatusUnpaid =
+ (): Codec<TalerMerchantApi.StatusUnpaidResponse> =>
+ buildCodecForObject<TalerMerchantApi.StatusUnpaidResponse>()
+ .property("type", codecForConstString("unpaid"))
+ .property("already_paid_order_id", codecOptional(codecForString()))
+ .property("fulfillment_url", codecOptional(codecForString()))
+ .property("taler_pay_uri", codecForTalerUriString())
+ .build("TalerMerchantApi.PaymentResponse");
+
+export const codecForPaidRefundStatusResponse =
+ (): Codec<TalerMerchantApi.PaidRefundStatusResponse> =>
+ buildCodecForObject<TalerMerchantApi.PaidRefundStatusResponse>()
+ .property("pos_confirmation", codecOptional(codecForString()))
+ .property("refunded", codecForBoolean())
+ .build("TalerMerchantApi.PaidRefundStatusResponse");
+
+export const codecForMerchantAbortPayRefundSuccessStatus =
+ (): Codec<TalerMerchantApi.MerchantAbortPayRefundSuccessStatus> =>
+ buildCodecForObject<TalerMerchantApi.MerchantAbortPayRefundSuccessStatus>()
+ .property("exchange_pub", codecForString())
+ .property("exchange_sig", codecForString())
+ .property("exchange_status", codecForConstNumber(200))
+ .property("type", codecForConstString("success"))
+ .build("TalerMerchantApi.MerchantAbortPayRefundSuccessStatus");
+
+export const codecForMerchantAbortPayRefundFailureStatus =
+ (): Codec<TalerMerchantApi.MerchantAbortPayRefundFailureStatus> =>
+ buildCodecForObject<TalerMerchantApi.MerchantAbortPayRefundFailureStatus>()
+ .property("exchange_code", codecForNumber())
+ .property("exchange_reply", codecForAny())
+ .property("exchange_status", codecForNumber())
+ .property("type", codecForConstString("failure"))
+ .build("TalerMerchantApi.MerchantAbortPayRefundFailureStatus");
+
+export const codecForMerchantAbortPayRefundStatus =
+ (): Codec<TalerMerchantApi.MerchantAbortPayRefundStatus> =>
+ buildCodecForUnion<TalerMerchantApi.MerchantAbortPayRefundStatus>()
+ .discriminateOn("type")
+ .alternative("success", codecForMerchantAbortPayRefundSuccessStatus())
+ .alternative("failure", codecForMerchantAbortPayRefundFailureStatus())
+ .build("TalerMerchantApi.MerchantAbortPayRefundStatus");
+
+export const codecForAbortResponse =
+ (): Codec<TalerMerchantApi.AbortResponse> =>
+ buildCodecForObject<TalerMerchantApi.AbortResponse>()
+ .property("refunds", codecForList(codecForMerchantAbortPayRefundStatus()))
+ .build("TalerMerchantApi.AbortResponse");
+
+export const codecForWalletRefundResponse =
+ (): Codec<TalerMerchantApi.WalletRefundResponse> =>
+ buildCodecForObject<TalerMerchantApi.WalletRefundResponse>()
+ .property("merchant_pub", codecForString())
+ .property("refund_amount", codecForAmountString())
+ .property("refunds", codecForList(codecForMerchantCoinRefundStatus()))
+ .build("TalerMerchantApi.AbortResponse");
+
+export const codecForMerchantCoinRefundSuccessStatus =
+ (): Codec<TalerMerchantApi.MerchantCoinRefundSuccessStatus> =>
+ buildCodecForObject<TalerMerchantApi.MerchantCoinRefundSuccessStatus>()
+ .property("type", codecForConstString("success"))
+ .property("coin_pub", codecForString())
+ .property("exchange_status", codecForConstNumber(200))
+ .property("exchange_sig", codecForString())
+ .property("rtransaction_id", codecForNumber())
+ .property("refund_amount", codecForAmountString())
+ .property("exchange_pub", codecForString())
+ .property("execution_time", codecForTimestamp)
+ .build("TalerMerchantApi.MerchantCoinRefundSuccessStatus");
+
+export const codecForMerchantCoinRefundFailureStatus =
+ (): Codec<TalerMerchantApi.MerchantCoinRefundFailureStatus> =>
+ buildCodecForObject<TalerMerchantApi.MerchantCoinRefundFailureStatus>()
+ .property("type", codecForConstString("failure"))
+ .property("coin_pub", codecForString())
+ .property("exchange_status", codecForNumber())
+ .property("rtransaction_id", codecForNumber())
+ .property("refund_amount", codecForAmountString())
+ .property("exchange_code", codecOptional(codecForNumber()))
+ .property("exchange_reply", codecOptional(codecForAny()))
+ .property("execution_time", codecForTimestamp)
+ .build("TalerMerchantApi.MerchantCoinRefundFailureStatus");
+
+export const codecForMerchantCoinRefundStatus =
+ (): Codec<TalerMerchantApi.MerchantCoinRefundStatus> =>
+ buildCodecForUnion<TalerMerchantApi.MerchantCoinRefundStatus>()
+ .discriminateOn("type")
+ .alternative("success", codecForMerchantCoinRefundSuccessStatus())
+ .alternative("failure", codecForMerchantCoinRefundFailureStatus())
+ .build("TalerMerchantApi.MerchantCoinRefundStatus");
+
+export const codecForQueryInstancesResponse =
+ (): Codec<TalerMerchantApi.QueryInstancesResponse> =>
+ buildCodecForObject<TalerMerchantApi.QueryInstancesResponse>()
+ .property("name", codecForString())
+ .property("user_type", codecForString())
+ .property("email", codecOptional(codecForString()))
+ .property("website", codecOptional(codecForString()))
+ .property("logo", codecOptional(codecForString()))
+ .property("merchant_pub", codecForString())
+ .property("address", codecForLocation())
+ .property("jurisdiction", codecForLocation())
+ .property("use_stefan", codecForBoolean())
+ .property("default_wire_transfer_delay", codecForDuration)
+ .property("default_pay_delay", codecForDuration)
+ .property(
+ "auth",
+ buildCodecForObject<{
+ method: "external" | "token";
+ }>()
+ .property(
+ "method",
+ codecForEither(
+ codecForConstString("token"),
+ codecForConstString("external"),
+ ),
+ )
+ .build("TalerMerchantApi.QueryInstancesResponse.auth"),
+ )
+ .build("TalerMerchantApi.QueryInstancesResponse");
+
+export const codecForAccountKycRedirects =
+ (): Codec<TalerMerchantApi.AccountKycRedirects> =>
+ buildCodecForObject<TalerMerchantApi.AccountKycRedirects>()
+ .property(
+ "pending_kycs",
+ codecForList(codecForMerchantAccountKycRedirect()),
+ )
+ .property("timeout_kycs", codecForList(codecForExchangeKycTimeout()))
+
+ .build("TalerMerchantApi.AccountKycRedirects");
+
+export const codecForMerchantAccountKycRedirect =
+ (): Codec<TalerMerchantApi.MerchantAccountKycRedirect> =>
+ buildCodecForObject<TalerMerchantApi.MerchantAccountKycRedirect>()
+ .property("kyc_url", codecForURL())
+ .property("aml_status", codecForNumber())
+ .property("exchange_url", codecForURL())
+ .property("payto_uri", codecForPaytoString())
+ .build("TalerMerchantApi.MerchantAccountKycRedirect");
+
+export const codecForExchangeKycTimeout =
+ (): Codec<TalerMerchantApi.ExchangeKycTimeout> =>
+ buildCodecForObject<TalerMerchantApi.ExchangeKycTimeout>()
+ .property("exchange_url", codecForURL())
+ .property("exchange_code", codecForNumber())
+ .property("exchange_http_status", codecForNumber())
+ .build("TalerMerchantApi.ExchangeKycTimeout");
+
+export const codecForAccountAddResponse =
+ (): Codec<TalerMerchantApi.AccountAddResponse> =>
+ buildCodecForObject<TalerMerchantApi.AccountAddResponse>()
+ .property("h_wire", codecForString())
+ .property("salt", codecForString())
+ .build("TalerMerchantApi.AccountAddResponse");
+
+export const codecForAccountsSummaryResponse =
+ (): Codec<TalerMerchantApi.AccountsSummaryResponse> =>
+ buildCodecForObject<TalerMerchantApi.AccountsSummaryResponse>()
+ .property("accounts", codecForList(codecForBankAccountSummaryEntry()))
+ .build("TalerMerchantApi.AccountsSummaryResponse");
+
+export const codecForBankAccountSummaryEntry =
+ (): Codec<TalerMerchantApi.BankAccountSummaryEntry> =>
+ buildCodecForObject<TalerMerchantApi.BankAccountSummaryEntry>()
+ .property("payto_uri", codecForPaytoString())
+ .property("h_wire", codecForString())
+ .build("TalerMerchantApi.BankAccountSummaryEntry");
+
+export const codecForBankAccountEntry =
+ (): Codec<TalerMerchantApi.BankAccountEntry> =>
+ buildCodecForObject<TalerMerchantApi.BankAccountEntry>()
+ .property("payto_uri", codecForPaytoString())
+ .property("h_wire", codecForString())
+ .property("salt", codecForString())
+ .property("credit_facade_url", codecOptional(codecForURL()))
+ .property("active", codecOptional(codecForBoolean()))
+ .build("TalerMerchantApi.BankAccountEntry");
+
+export const codecForInventorySummaryResponse =
+ (): Codec<TalerMerchantApi.InventorySummaryResponse> =>
+ buildCodecForObject<TalerMerchantApi.InventorySummaryResponse>()
+ .property("products", codecForList(codecForInventoryEntry()))
+ .build("TalerMerchantApi.InventorySummaryResponse");
+
+export const codecForInventoryEntry =
+ (): Codec<TalerMerchantApi.InventoryEntry> =>
+ buildCodecForObject<TalerMerchantApi.InventoryEntry>()
+ .property("product_id", codecForString())
+ .property("product_serial", codecForNumber())
+ .build("TalerMerchantApi.InventoryEntry");
+
+export const codecForProductDetail =
+ (): Codec<TalerMerchantApi.ProductDetail> =>
+ buildCodecForObject<TalerMerchantApi.ProductDetail>()
+ .property("description", codecForString())
+ .property("description_i18n", codecForInternationalizedString())
+ .property("unit", codecForString())
+ .property("price", codecForAmountString())
+ .property("image", codecForString())
+ .property("taxes", codecForList(codecForTax()))
+ .property("address", codecForLocation())
+ .property("next_restock", codecForTimestamp)
+ .property("total_stock", codecForNumber())
+ .property("total_sold", codecForNumber())
+ .property("total_lost", codecForNumber())
+ .property("minimum_age", codecOptional(codecForNumber()))
+ .build("TalerMerchantApi.ProductDetail");
+
+export const codecForTax = (): Codec<TalerMerchantApi.Tax> =>
+ buildCodecForObject<TalerMerchantApi.Tax>()
+ .property("name", codecForString())
+ .property("tax", codecForAmountString())
+ .build("TalerMerchantApi.Tax");
+
+export const codecForPostOrderResponse =
+ (): Codec<TalerMerchantApi.PostOrderResponse> =>
+ buildCodecForObject<TalerMerchantApi.PostOrderResponse>()
+ .property("order_id", codecForString())
+ .property("token", codecOptional(codecForString()))
+ .build("TalerMerchantApi.PostOrderResponse");
+
+export const codecForOutOfStockResponse =
+ (): Codec<TalerMerchantApi.OutOfStockResponse> =>
+ buildCodecForObject<TalerMerchantApi.OutOfStockResponse>()
+ .property("product_id", codecForString())
+ .property("available_quantity", codecForNumber())
+ .property("requested_quantity", codecForNumber())
+ .property("restock_expected", codecForTimestamp)
+ .build("TalerMerchantApi.OutOfStockResponse");
+
+export const codecForOrderHistory = (): Codec<TalerMerchantApi.OrderHistory> =>
+ buildCodecForObject<TalerMerchantApi.OrderHistory>()
+ .property("orders", codecForList(codecForOrderHistoryEntry()))
+ .build("TalerMerchantApi.OrderHistory");
+
+export const codecForOrderHistoryEntry =
+ (): Codec<TalerMerchantApi.OrderHistoryEntry> =>
+ buildCodecForObject<TalerMerchantApi.OrderHistoryEntry>()
+ .property("order_id", codecForString())
+ .property("row_id", codecForNumber())
+ .property("timestamp", codecForTimestamp)
+ .property("amount", codecForAmountString())
+ .property("summary", codecForString())
+ .property("refundable", codecForBoolean())
+ .property("paid", codecForBoolean())
+ .build("TalerMerchantApi.OrderHistoryEntry");
+
+export const codecForMerchant = (): Codec<TalerMerchantApi.Merchant> =>
+ buildCodecForObject<TalerMerchantApi.Merchant>()
+ .property("name", codecForString())
+ .property("email", codecOptional(codecForString()))
+ .property("logo", codecOptional(codecForString()))
+ .property("website", codecOptional(codecForString()))
+ .property("address", codecOptional(codecForLocation()))
+ .property("jurisdiction", codecOptional(codecForLocation()))
+ .build("TalerMerchantApi.MerchantInfo");
+
+export const codecForExchange = (): Codec<TalerMerchantApi.Exchange> =>
+ buildCodecForObject<TalerMerchantApi.Exchange>()
+ .property("master_pub", codecForString())
+ .property("priority", codecForNumber())
+ .property("url", codecForString())
+ .build("TalerMerchantApi.Exchange");
+
+export const codecForContractTerms =
+ (): Codec<TalerMerchantApi.ContractTerms> =>
+ buildCodecForObject<TalerMerchantApi.ContractTerms>()
+ .property("order_id", codecForString())
+ .property("fulfillment_url", codecOptional(codecForString()))
+ .property("fulfillment_message", codecOptional(codecForString()))
+ .property(
+ "fulfillment_message_i18n",
+ codecOptional(codecForInternationalizedString()),
+ )
+ .property("merchant_base_url", codecForString())
+ .property("h_wire", codecForString())
+ .property("auto_refund", codecOptional(codecForDuration))
+ .property("wire_method", codecForString())
+ .property("summary", codecForString())
+ .property(
+ "summary_i18n",
+ codecOptional(codecForInternationalizedString()),
+ )
+ .property("nonce", codecForString())
+ .property("amount", codecForAmountString())
+ .property("pay_deadline", codecForTimestamp)
+ .property("refund_deadline", codecForTimestamp)
+ .property("wire_transfer_deadline", codecForTimestamp)
+ .property("timestamp", codecForTimestamp)
+ .property("delivery_location", codecOptional(codecForLocation()))
+ .property("delivery_date", codecOptional(codecForTimestamp))
+ .property("max_fee", codecForAmountString())
+ .property("merchant", codecForMerchant())
+ .property("merchant_pub", codecForString())
+ .property("exchanges", codecForList(codecForExchange()))
+ .property("products", codecForList(codecForProduct()))
+ .property("extra", codecForAny())
+ .build("TalerMerchantApi.ContractTerms");
+
+export const codecForProduct = (): Codec<TalerMerchantApi.Product> =>
+ buildCodecForObject<TalerMerchantApi.Product>()
+ .property("product_id", codecOptional(codecForString()))
+ .property("description", codecForString())
+ .property(
+ "description_i18n",
+ codecOptional(codecForInternationalizedString()),
+ )
+ .property("quantity", codecOptional(codecForNumber()))
+ .property("unit", codecOptional(codecForString()))
+ .property("price", codecOptional(codecForAmountString()))
+ .property("image", codecOptional(codecForString()))
+ .property("taxes", codecOptional(codecForList(codecForTax())))
+ .property("delivery_date", codecOptional(codecForTimestamp))
+ .build("TalerMerchantApi.Product");
+
+export const codecForCheckPaymentPaidResponse =
+ (): Codec<TalerMerchantApi.CheckPaymentPaidResponse> =>
+ buildCodecForObject<TalerMerchantApi.CheckPaymentPaidResponse>()
+ .property("order_status", codecForConstString("paid"))
+ .property("refunded", codecForBoolean())
+ .property("refund_pending", codecForBoolean())
+ .property("wired", codecForBoolean())
+ .property("deposit_total", codecForAmountString())
+ .property("exchange_code", codecForNumber())
+ .property("exchange_http_status", codecForNumber())
+ .property("refund_amount", codecForAmountString())
+ .property("contract_terms", codecForContractTerms())
+ .property("wire_reports", codecForList(codecForTransactionWireReport()))
+ .property("wire_details", codecForList(codecForTransactionWireTransfer()))
+ .property("refund_details", codecForList(codecForRefundDetails()))
+ .property("order_status_url", codecForURL())
+ .build("TalerMerchantApi.CheckPaymentPaidResponse");
+
+export const codecForCheckPaymentUnpaidResponse =
+ (): Codec<TalerMerchantApi.CheckPaymentUnpaidResponse> =>
+ buildCodecForObject<TalerMerchantApi.CheckPaymentUnpaidResponse>()
+ .property("order_status", codecForConstString("unpaid"))
+ .property("taler_pay_uri", codecForTalerUriString())
+ .property("creation_time", codecForTimestamp)
+ .property("summary", codecForString())
+ .property("total_amount", codecForAmountString())
+ .property("already_paid_order_id", codecOptional(codecForString()))
+ .property("already_paid_fulfillment_url", codecOptional(codecForString()))
+ .property("order_status_url", codecForString())
+ .build("TalerMerchantApi.CheckPaymentPaidResponse");
+
+export const codecForCheckPaymentClaimedResponse =
+ (): Codec<TalerMerchantApi.CheckPaymentClaimedResponse> =>
+ buildCodecForObject<TalerMerchantApi.CheckPaymentClaimedResponse>()
+ .property("order_status", codecForConstString("claimed"))
+ .property("contract_terms", codecForContractTerms())
+ .build("TalerMerchantApi.CheckPaymentClaimedResponse");
+
+export const codecForMerchantOrderPrivateStatusResponse =
+ (): Codec<TalerMerchantApi.MerchantOrderStatusResponse> =>
+ buildCodecForUnion<TalerMerchantApi.MerchantOrderStatusResponse>()
+ .discriminateOn("order_status")
+ .alternative("paid", codecForCheckPaymentPaidResponse())
+ .alternative("unpaid", codecForCheckPaymentUnpaidResponse())
+ .alternative("claimed", codecForCheckPaymentClaimedResponse())
+ .build("TalerMerchantApi.MerchantOrderStatusResponse");
+
+export const codecForRefundDetails =
+ (): Codec<TalerMerchantApi.RefundDetails> =>
+ buildCodecForObject<TalerMerchantApi.RefundDetails>()
+ .property("reason", codecForString())
+ .property("pending", codecForBoolean())
+ .property("timestamp", codecForTimestamp)
+ .property("amount", codecForAmountString())
+ .build("TalerMerchantApi.RefundDetails");
+
+export const codecForTransactionWireTransfer =
+ (): Codec<TalerMerchantApi.TransactionWireTransfer> =>
+ buildCodecForObject<TalerMerchantApi.TransactionWireTransfer>()
+ .property("exchange_url", codecForURL())
+ .property("wtid", codecForString())
+ .property("execution_time", codecForTimestamp)
+ .property("amount", codecForAmountString())
+ .property("confirmed", codecForBoolean())
+ .build("TalerMerchantApi.TransactionWireTransfer");
+
+export const codecForTransactionWireReport =
+ (): Codec<TalerMerchantApi.TransactionWireReport> =>
+ buildCodecForObject<TalerMerchantApi.TransactionWireReport>()
+ .property("code", codecForNumber())
+ .property("hint", codecForString())
+ .property("exchange_code", codecForNumber())
+ .property("exchange_http_status", codecForNumber())
+ .property("coin_pub", codecForString())
+ .build("TalerMerchantApi.TransactionWireReport");
+
+export const codecForMerchantRefundResponse =
+ (): Codec<TalerMerchantApi.MerchantRefundResponse> =>
+ buildCodecForObject<TalerMerchantApi.MerchantRefundResponse>()
+ .property("taler_refund_uri", codecForTalerUriString())
+ .property("h_contract", codecForString())
+ .build("TalerMerchantApi.MerchantRefundResponse");
+
+export const codecForTansferList = (): Codec<TalerMerchantApi.TransferList> =>
+ buildCodecForObject<TalerMerchantApi.TransferList>()
+ .property("transfers", codecForList(codecForTransferDetails()))
+ .build("TalerMerchantApi.TransferList");
+
+export const codecForTransferDetails =
+ (): Codec<TalerMerchantApi.TransferDetails> =>
+ buildCodecForObject<TalerMerchantApi.TransferDetails>()
+ .property("credit_amount", codecForAmountString())
+ .property("wtid", codecForString())
+ .property("payto_uri", codecForPaytoString())
+ .property("exchange_url", codecForURL())
+ .property("transfer_serial_id", codecForNumber())
+ .property("execution_time", codecOptional(codecForTimestamp))
+ .property("verified", codecOptional(codecForBoolean()))
+ .property("confirmed", codecOptional(codecForBoolean()))
+ .build("TalerMerchantApi.TransferDetails");
+
+export const codecForOtpDeviceSummaryResponse =
+ (): Codec<TalerMerchantApi.OtpDeviceSummaryResponse> =>
+ buildCodecForObject<TalerMerchantApi.OtpDeviceSummaryResponse>()
+ .property("otp_devices", codecForList(codecForOtpDeviceEntry()))
+ .build("TalerMerchantApi.OtpDeviceSummaryResponse");
+
+export const codecForOtpDeviceEntry =
+ (): Codec<TalerMerchantApi.OtpDeviceEntry> =>
+ buildCodecForObject<TalerMerchantApi.OtpDeviceEntry>()
+ .property("otp_device_id", codecForString())
+ .property("device_description", codecForString())
+ .build("TalerMerchantApi.OtpDeviceEntry");
+
+export const codecForOtpDeviceDetails =
+ (): Codec<TalerMerchantApi.OtpDeviceDetails> =>
+ buildCodecForObject<TalerMerchantApi.OtpDeviceDetails>()
+ .property("device_description", codecForString())
+ .property("otp_algorithm", codecForNumber())
+ .property("otp_ctr", codecOptional(codecForNumber()))
+ .property("otp_timestamp", codecForNumber())
+ .property("otp_code", codecOptional(codecForString()))
+ .build("TalerMerchantApi.OtpDeviceDetails");
+
+export const codecForTemplateSummaryResponse =
+ (): Codec<TalerMerchantApi.TemplateSummaryResponse> =>
+ buildCodecForObject<TalerMerchantApi.TemplateSummaryResponse>()
+ .property("templates", codecForList(codecForTemplateEntry()))
+ .build("TalerMerchantApi.TemplateSummaryResponse");
+
+export const codecForTemplateEntry =
+ (): Codec<TalerMerchantApi.TemplateEntry> =>
+ buildCodecForObject<TalerMerchantApi.TemplateEntry>()
+ .property("template_id", codecForString())
+ .property("template_description", codecForString())
+ .build("TalerMerchantApi.TemplateEntry");
+
+export const codecForTemplateDetails =
+ (): Codec<TalerMerchantApi.TemplateDetails> =>
+ buildCodecForObject<TalerMerchantApi.TemplateDetails>()
+ .property("template_description", codecForString())
+ .property("otp_id", codecOptional(codecForString()))
+ .property("template_contract", codecForTemplateContractDetails())
+ .property("required_currency", codecOptional(codecForString()))
+ .property(
+ "editable_defaults",
+ codecOptional(codecForTemplateContractDetailsDefaults()),
+ )
+ .build("TalerMerchantApi.TemplateDetails");
+
+export const codecForTemplateContractDetails =
+ (): Codec<TalerMerchantApi.TemplateContractDetails> =>
+ buildCodecForObject<TalerMerchantApi.TemplateContractDetails>()
+ .property("summary", codecOptional(codecForString()))
+ .property("currency", codecOptional(codecForString()))
+ .property("amount", codecOptional(codecForAmountString()))
+ .property("minimum_age", codecForNumber())
+ .property("pay_duration", codecForDuration)
+ .build("TalerMerchantApi.TemplateContractDetails");
+
+export const codecForTemplateContractDetailsDefaults =
+ (): Codec<TalerMerchantApi.TemplateContractDetailsDefaults> =>
+ buildCodecForObject<TalerMerchantApi.TemplateContractDetailsDefaults>()
+ .property("summary", codecOptional(codecForString()))
+ .property("currency", codecOptional(codecForString()))
+ .property("amount", codecOptional(codecForAmountString()))
+ .property("minimum_age", codecOptional(codecForNumber()))
+ .property("pay_duration", codecOptional(codecForDuration))
+ .build("TalerMerchantApi.TemplateContractDetailsDefaults");
+
+export const codecForWalletTemplateDetails =
+ (): Codec<TalerMerchantApi.WalletTemplateDetails> =>
+ buildCodecForObject<TalerMerchantApi.WalletTemplateDetails>()
+ .property("template_contract", codecForTemplateContractDetails())
+ .property("required_currency", codecOptional(codecForString()))
+ .property(
+ "editable_defaults",
+ codecOptional(codecForTemplateContractDetailsDefaults()),
+ )
+ .build("TalerMerchantApi.WalletTemplateDetails");
+
+export const codecForWebhookSummaryResponse =
+ (): Codec<TalerMerchantApi.WebhookSummaryResponse> =>
+ buildCodecForObject<TalerMerchantApi.WebhookSummaryResponse>()
+ .property("webhooks", codecForList(codecForWebhookEntry()))
+ .build("TalerMerchantApi.WebhookSummaryResponse");
+
+export const codecForWebhookEntry = (): Codec<TalerMerchantApi.WebhookEntry> =>
+ buildCodecForObject<TalerMerchantApi.WebhookEntry>()
+ .property("webhook_id", codecForString())
+ .property("event_type", codecForString())
+ .build("TalerMerchantApi.WebhookEntry");
+
+export const codecForWebhookDetails =
+ (): Codec<TalerMerchantApi.WebhookDetails> =>
+ buildCodecForObject<TalerMerchantApi.WebhookDetails>()
+ .property("event_type", codecForString())
+ .property("url", codecForString())
+ .property("http_method", codecForString())
+ .property("header_template", codecOptional(codecForString()))
+ .property("body_template", codecOptional(codecForString()))
+ .build("TalerMerchantApi.WebhookDetails");
+
+export const codecForTokenFamilyKind =
+ (): Codec<TalerMerchantApi.TokenFamilyKind> =>
+ codecForEither(
+ codecForConstString("discount"),
+ codecForConstString("subscription"),
+ ) as any; //FIXME: create a codecForEnum
+export const codecForTokenFamilyDetails =
+ (): Codec<TalerMerchantApi.TokenFamilyDetails> =>
+ buildCodecForObject<TalerMerchantApi.TokenFamilyDetails>()
+ .property("slug", codecForString())
+ .property("name", codecForString())
+ .property("description", codecForString())
+ .property("description_i18n", codecForInternationalizedString())
+ .property("valid_after", codecForTimestamp)
+ .property("valid_before", codecForTimestamp)
+ .property("duration", codecForDuration)
+ .property("kind", codecForTokenFamilyKind())
+ .property("issued", codecForNumber())
+ .property("redeemed", codecForNumber())
+ .build("TalerMerchantApi.TokenFamilyDetails");
+
+export const codecForTokenFamiliesList =
+ (): Codec<TalerMerchantApi.TokenFamiliesList> =>
+ buildCodecForObject<TalerMerchantApi.TokenFamiliesList>()
+ .property("token_families", codecForList(codecForTokenFamilySummary()))
+ .build("TalerMerchantApi.TokenFamiliesList");
+
+export const codecForTokenFamilySummary =
+ (): Codec<TalerMerchantApi.TokenFamilySummary> =>
+ buildCodecForObject<TalerMerchantApi.TokenFamilySummary>()
+ .property("slug", codecForString())
+ .property("name", codecForString())
+ .property("valid_after", codecForTimestamp)
+ .property("valid_before", codecForTimestamp)
+ .property("kind", codecForTokenFamilyKind())
+ .build("TalerMerchantApi.TokenFamilySummary");
+
+export const codecForInstancesResponse =
+ (): Codec<TalerMerchantApi.InstancesResponse> =>
+ buildCodecForObject<TalerMerchantApi.InstancesResponse>()
+ .property("instances", codecForList(codecForInstance()))
+ .build("TalerMerchantApi.InstancesResponse");
+
+export const codecForInstance = (): Codec<TalerMerchantApi.Instance> =>
+ buildCodecForObject<TalerMerchantApi.Instance>()
+ .property("name", codecForString())
+ .property("user_type", codecForString())
+ .property("website", codecOptional(codecForString()))
+ .property("logo", codecOptional(codecForString()))
+ .property("id", codecForString())
+ .property("merchant_pub", codecForString())
+ .property("payment_targets", codecForList(codecForString()))
+ .property("deleted", codecForBoolean())
+ .build("TalerMerchantApi.Instance");
+
+export const codecForExchangeConfig =
+ (): Codec<TalerExchangeApi.ExchangeVersionResponse> =>
+ buildCodecForObject<TalerExchangeApi.ExchangeVersionResponse>()
+ .property("version", codecForString())
+ .property("name", codecForConstString("taler-exchange"))
+ .property("implementation", codecOptional(codecForURN()))
+ .property("currency", codecForString())
+ .property("currency_specification", codecForCurrencySpecificiation())
+ .property("supported_kyc_requirements", codecForList(codecForString()))
+ .build("TalerExchangeApi.ExchangeVersionResponse");
+
+export const codecForExchangeKeys =
+ (): Codec<TalerExchangeApi.ExchangeKeysResponse> =>
+ buildCodecForObject<TalerExchangeApi.ExchangeKeysResponse>()
+ .property("version", codecForString())
+ .property("base_url", codecForString())
+ .property("currency", codecForString())
+ .build("TalerExchangeApi.ExchangeKeysResponse");
+
+const codecForBalance = (): Codec<TalerCorebankApi.Balance> =>
+ buildCodecForObject<TalerCorebankApi.Balance>()
+ .property("amount", codecForAmountString())
+ .property(
+ "credit_debit_indicator",
+ codecForEither(
+ codecForConstString("credit"),
+ codecForConstString("debit"),
+ ),
+ )
+ .build("TalerCorebankApi.Balance");
+
+const codecForPublicAccount = (): Codec<TalerCorebankApi.PublicAccount> =>
+ buildCodecForObject<TalerCorebankApi.PublicAccount>()
+ .property("username", codecForString())
+ .property("balance", codecForBalance())
+ .property("payto_uri", codecForPaytoString())
+ .property("is_taler_exchange", codecForBoolean())
+ .property("row_id", codecOptional(codecForNumber()))
+ .build("TalerCorebankApi.PublicAccount");
+
+export const codecForPublicAccountsResponse =
+ (): Codec<TalerCorebankApi.PublicAccountsResponse> =>
+ buildCodecForObject<TalerCorebankApi.PublicAccountsResponse>()
+ .property("public_accounts", codecForList(codecForPublicAccount()))
+ .build("TalerCorebankApi.PublicAccountsResponse");
+
+export const codecForAccountMinimalData =
+ (): Codec<TalerCorebankApi.AccountMinimalData> =>
+ buildCodecForObject<TalerCorebankApi.AccountMinimalData>()
+ .property("username", codecForString())
+ .property("name", codecForString())
+ .property("payto_uri", codecForPaytoString())
+ .property("balance", codecForBalance())
+ .property("debit_threshold", codecForAmountString())
+ .property("is_public", codecForBoolean())
+ .property("is_taler_exchange", codecForBoolean())
+ .property("row_id", codecOptional(codecForNumber()))
+ .build("TalerCorebankApi.AccountMinimalData");
+
+export const codecForListBankAccountsResponse =
+ (): Codec<TalerCorebankApi.ListBankAccountsResponse> =>
+ buildCodecForObject<TalerCorebankApi.ListBankAccountsResponse>()
+ .property("accounts", codecForList(codecForAccountMinimalData()))
+ .build("TalerCorebankApi.ListBankAccountsResponse");
+
+export const codecForAccountData = (): Codec<TalerCorebankApi.AccountData> =>
+ buildCodecForObject<TalerCorebankApi.AccountData>()
+ .property("name", codecForString())
+ .property("balance", codecForBalance())
+ .property("payto_uri", codecForPaytoString())
+ .property("debit_threshold", codecForAmountString())
+ .property("contact_data", codecOptional(codecForChallengeContactData()))
+ .property("cashout_payto_uri", codecOptional(codecForPaytoString()))
+ .property("is_public", codecForBoolean())
+ .property("is_taler_exchange", codecForBoolean())
+ .property(
+ "tan_channel",
+ codecOptional(
+ codecForEither(
+ codecForConstString(TalerCorebankApi.TanChannel.SMS),
+ codecForConstString(TalerCorebankApi.TanChannel.EMAIL),
+ ),
+ ),
+ )
+ .build("TalerCorebankApi.AccountData");
+
+export const codecForChallengeContactData =
+ (): Codec<TalerCorebankApi.ChallengeContactData> =>
+ buildCodecForObject<TalerCorebankApi.ChallengeContactData>()
+ .property("email", codecOptional(codecForString()))
+ .property("phone", codecOptional(codecForString()))
+ .build("TalerCorebankApi.ChallengeContactData");
+
+export const codecForWithdrawalPublicInfo =
+ (): Codec<TalerCorebankApi.WithdrawalPublicInfo> =>
+ buildCodecForObject<TalerCorebankApi.WithdrawalPublicInfo>()
+ .property(
+ "status",
+ codecForEither(
+ codecForConstString("pending"),
+ codecForConstString("selected"),
+ codecForConstString("aborted"),
+ codecForConstString("confirmed"),
+ ),
+ )
+ .property("amount", codecForAmountString())
+ .property("username", codecForString())
+ .property("selected_reserve_pub", codecOptional(codecForString()))
+ .property(
+ "selected_exchange_account",
+ codecOptional(codecForPaytoString()),
+ )
+ .build("TalerCorebankApi.WithdrawalPublicInfo");
+
+export const codecForBankAccountTransactionsResponse =
+ (): Codec<TalerCorebankApi.BankAccountTransactionsResponse> =>
+ buildCodecForObject<TalerCorebankApi.BankAccountTransactionsResponse>()
+ .property(
+ "transactions",
+ codecForList(codecForBankAccountTransactionInfo()),
+ )
+ .build("TalerCorebankApi.BankAccountTransactionsResponse");
+
+export const codecForBankAccountTransactionInfo =
+ (): Codec<TalerCorebankApi.BankAccountTransactionInfo> =>
+ buildCodecForObject<TalerCorebankApi.BankAccountTransactionInfo>()
+ .property("creditor_payto_uri", codecForPaytoString())
+ .property("debtor_payto_uri", codecForPaytoString())
+ .property("amount", codecForAmountString())
+ .property(
+ "direction",
+ codecForEither(
+ codecForConstString("debit"),
+ codecForConstString("credit"),
+ ),
+ )
+ .property("subject", codecForString())
+ .property("row_id", codecForNumber())
+ .property("date", codecForTimestamp)
+ .build("TalerCorebankApi.BankAccountTransactionInfo");
+
+export const codecForCreateTransactionResponse =
+ (): Codec<TalerCorebankApi.CreateTransactionResponse> =>
+ buildCodecForObject<TalerCorebankApi.CreateTransactionResponse>()
+ .property("row_id", codecForNumber())
+ .build("TalerCorebankApi.CreateTransactionResponse");
+
+export const codecForRegisterAccountResponse =
+ (): Codec<TalerCorebankApi.RegisterAccountResponse> =>
+ buildCodecForObject<TalerCorebankApi.RegisterAccountResponse>()
+ .property("internal_payto_uri", codecForPaytoString())
+ .build("TalerCorebankApi.RegisterAccountResponse");
+
+export const codecForBankAccountCreateWithdrawalResponse =
+ (): Codec<TalerCorebankApi.BankAccountCreateWithdrawalResponse> =>
+ buildCodecForObject<TalerCorebankApi.BankAccountCreateWithdrawalResponse>()
+ .property("taler_withdraw_uri", codecForTalerUriString())
+ .property("withdrawal_id", codecForString())
+ .build("TalerCorebankApi.BankAccountCreateWithdrawalResponse");
+
+export const codecForCashoutPending =
+ (): Codec<TalerCorebankApi.CashoutResponse> =>
+ buildCodecForObject<TalerCorebankApi.CashoutResponse>()
+ .property("cashout_id", codecForNumber())
+ .build("TalerCorebankApi.CashoutPending");
+
+export const codecForCashoutConversionResponse =
+ (): Codec<TalerBankConversionApi.CashoutConversionResponse> =>
+ buildCodecForObject<TalerBankConversionApi.CashoutConversionResponse>()
+ .property("amount_credit", codecForAmountString())
+ .property("amount_debit", codecForAmountString())
+ .build("TalerCorebankApi.CashoutConversionResponse");
+
+export const codecForCashinConversionResponse =
+ (): Codec<TalerBankConversionApi.CashinConversionResponse> =>
+ buildCodecForObject<TalerBankConversionApi.CashinConversionResponse>()
+ .property("amount_credit", codecForAmountString())
+ .property("amount_debit", codecForAmountString())
+ .build("TalerCorebankApi.CashinConversionResponse");
+
+export const codecForCashouts = (): Codec<TalerCorebankApi.Cashouts> =>
+ buildCodecForObject<TalerCorebankApi.Cashouts>()
+ .property("cashouts", codecForList(codecForCashoutInfo()))
+ .build("TalerCorebankApi.Cashouts");
+
+export const codecForCashoutInfo = (): Codec<TalerCorebankApi.CashoutInfo> =>
+ buildCodecForObject<TalerCorebankApi.CashoutInfo>()
+ .property("cashout_id", codecForNumber())
+ .build("TalerCorebankApi.CashoutInfo");
+
+export const codecForGlobalCashouts =
+ (): Codec<TalerCorebankApi.GlobalCashouts> =>
+ buildCodecForObject<TalerCorebankApi.GlobalCashouts>()
+ .property("cashouts", codecForList(codecForGlobalCashoutInfo()))
+ .build("TalerCorebankApi.GlobalCashouts");
+
+export const codecForGlobalCashoutInfo =
+ (): Codec<TalerCorebankApi.GlobalCashoutInfo> =>
+ buildCodecForObject<TalerCorebankApi.GlobalCashoutInfo>()
+ .property("cashout_id", codecForNumber())
+ .property("username", codecForString())
+ .build("TalerCorebankApi.GlobalCashoutInfo");
+
+export const codecForCashoutStatusResponse =
+ (): Codec<TalerCorebankApi.CashoutStatusResponse> =>
+ buildCodecForObject<TalerCorebankApi.CashoutStatusResponse>()
+ .property("amount_debit", codecForAmountString())
+ .property("amount_credit", codecForAmountString())
+ .property("subject", codecForString())
+ .property("creation_time", codecForTimestamp)
+ .build("TalerCorebankApi.CashoutStatusResponse");
+
+export const codecForConversionRatesResponse =
+ (): Codec<TalerCorebankApi.ConversionRatesResponse> =>
+ buildCodecForObject<TalerCorebankApi.ConversionRatesResponse>()
+ .property("buy_at_ratio", codecForDecimalNumber())
+ .property("buy_in_fee", codecForDecimalNumber())
+ .property("sell_at_ratio", codecForDecimalNumber())
+ .property("sell_out_fee", codecForDecimalNumber())
+ .build("TalerCorebankApi.ConversionRatesResponse");
+
+export const codecForMonitorResponse =
+ (): Codec<TalerCorebankApi.MonitorResponse> =>
+ buildCodecForUnion<TalerCorebankApi.MonitorResponse>()
+ .discriminateOn("type")
+ .alternative("no-conversions", codecForMonitorNoConversion())
+ .alternative("with-conversions", codecForMonitorWithCashout())
+ .build("TalerWireGatewayApi.IncomingBankTransaction");
+
+export const codecForMonitorNoConversion =
+ (): Codec<TalerCorebankApi.MonitorNoConversion> =>
+ buildCodecForObject<TalerCorebankApi.MonitorNoConversion>()
+ .property("type", codecForConstString("no-conversions"))
+ .property("talerInCount", codecForNumber())
+ .property("talerInVolume", codecForAmountString())
+ .property("talerOutCount", codecForNumber())
+ .property("talerOutVolume", codecForAmountString())
+ .build("TalerCorebankApi.MonitorJustPayouts");
+
+export const codecForMonitorWithCashout =
+ (): Codec<TalerCorebankApi.MonitorWithConversion> =>
+ buildCodecForObject<TalerCorebankApi.MonitorWithConversion>()
+ .property("type", codecForConstString("with-conversions"))
+ .property("cashinCount", codecForNumber())
+ .property("cashinFiatVolume", codecForAmountString())
+ .property("cashinRegionalVolume", codecForAmountString())
+ .property("cashoutCount", codecForNumber())
+ .property("cashoutFiatVolume", codecForAmountString())
+ .property("cashoutRegionalVolume", codecForAmountString())
+ .property("talerInCount", codecForNumber())
+ .property("talerInVolume", codecForAmountString())
+ .property("talerOutCount", codecForNumber())
+ .property("talerOutVolume", codecForAmountString())
+ .build("TalerCorebankApi.MonitorWithCashout");
+
+export const codecForBankVersion =
+ (): Codec<TalerBankIntegrationApi.BankVersion> =>
+ buildCodecForObject<TalerBankIntegrationApi.BankVersion>()
+ .property("currency", codecForCurrencyName())
+ .property("currency_specification", codecForCurrencySpecificiation())
+ .property("name", codecForConstString("taler-bank-integration"))
+ .property("version", codecForLibtoolVersion())
+ .build("TalerBankIntegrationApi.BankVersion");
+
+export const codecForBankWithdrawalOperationStatus =
+ (): Codec<TalerBankIntegrationApi.BankWithdrawalOperationStatus> =>
+ buildCodecForObject<TalerBankIntegrationApi.BankWithdrawalOperationStatus>()
+ .property(
+ "status",
+ codecForEither(
+ codecForConstString("pending"),
+ codecForConstString("selected"),
+ codecForConstString("aborted"),
+ codecForConstString("confirmed"),
+ ),
+ )
+ .property("amount", codecForAmountString())
+ .property("sender_wire", codecOptional(codecForPaytoString()))
+ .property("suggested_exchange", codecOptional(codecForString()))
+ .property("confirm_transfer_url", codecOptional(codecForURL()))
+ .property("wire_types", codecForList(codecForString()))
+ .property("selected_reserve_pub", codecOptional(codecForString()))
+ .property("selected_exchange_account", codecOptional(codecForString()))
+ .build("TalerBankIntegrationApi.BankWithdrawalOperationStatus");
+
+export const codecForBankWithdrawalOperationPostResponse =
+ (): Codec<TalerBankIntegrationApi.BankWithdrawalOperationPostResponse> =>
+ buildCodecForObject<TalerBankIntegrationApi.BankWithdrawalOperationPostResponse>()
+ .property(
+ "status",
+ codecForEither(
+ codecForConstString("selected"),
+ codecForConstString("aborted"),
+ codecForConstString("confirmed"),
+ ),
+ )
+ .property("confirm_transfer_url", codecOptional(codecForURL()))
+ .build("TalerBankIntegrationApi.BankWithdrawalOperationPostResponse");
+
+export const codecForRevenueConfig = (): Codec<TalerRevenueApi.RevenueConfig> =>
+ buildCodecForObject<TalerRevenueApi.RevenueConfig>()
+ .property("name", codecForConstString("taler-revenue"))
+ .property("version", codecForString())
+ .property("currency", codecForString())
+ .property("implementation", codecOptional(codecForString()))
+ .build("TalerRevenueApi.RevenueConfig");
+
+export const codecForRevenueIncomingHistory =
+ (): Codec<TalerRevenueApi.RevenueIncomingHistory> =>
+ buildCodecForObject<TalerRevenueApi.RevenueIncomingHistory>()
+ .property("credit_account", codecForPaytoString())
+ .property(
+ "incoming_transactions",
+ codecForList(codecForRevenueIncomingBankTransaction()),
+ )
+ .build("TalerRevenueApi.MerchantIncomingHistory");
+
+export const codecForRevenueIncomingBankTransaction =
+ (): Codec<TalerRevenueApi.RevenueIncomingBankTransaction> =>
+ buildCodecForObject<TalerRevenueApi.RevenueIncomingBankTransaction>()
+ .property("amount", codecForAmountString())
+ .property("date", codecForTimestamp)
+ .property("debit_account", codecForPaytoString())
+ .property("row_id", codecForNumber())
+ .property("subject", codecForString())
+ .build("TalerRevenueApi.RevenueIncomingBankTransaction");
+
+export const codecForTransferResponse =
+ (): Codec<TalerWireGatewayApi.TransferResponse> =>
+ buildCodecForObject<TalerWireGatewayApi.TransferResponse>()
+ .property("row_id", codecForNumber())
+ .property("timestamp", codecForTimestamp)
+ .build("TalerWireGatewayApi.TransferResponse");
+
+export const codecForIncomingHistory =
+ (): Codec<TalerWireGatewayApi.IncomingHistory> =>
+ buildCodecForObject<TalerWireGatewayApi.IncomingHistory>()
+ .property("credit_account", codecForPaytoString())
+ .property(
+ "incoming_transactions",
+ codecForList(codecForIncomingBankTransaction()),
+ )
+ .build("TalerWireGatewayApi.IncomingHistory");
+
+export const codecForIncomingBankTransaction =
+ (): Codec<TalerWireGatewayApi.IncomingBankTransaction> =>
+ buildCodecForUnion<TalerWireGatewayApi.IncomingBankTransaction>()
+ .discriminateOn("type")
+ .alternative("RESERVE", codecForIncomingReserveTransaction())
+ .alternative("WAD", codecForIncomingWadTransaction())
+ .build("TalerWireGatewayApi.IncomingBankTransaction");
+
+export const codecForIncomingReserveTransaction =
+ (): Codec<TalerWireGatewayApi.IncomingReserveTransaction> =>
+ buildCodecForObject<TalerWireGatewayApi.IncomingReserveTransaction>()
+ .property("amount", codecForAmountString())
+ .property("date", codecForTimestamp)
+ .property("debit_account", codecForPaytoString())
+ .property("reserve_pub", codecForString())
+ .property("row_id", codecForNumber())
+ .property("type", codecForConstString("RESERVE"))
+ .build("TalerWireGatewayApi.IncomingReserveTransaction");
+
+export const codecForIncomingWadTransaction =
+ (): Codec<TalerWireGatewayApi.IncomingWadTransaction> =>
+ buildCodecForObject<TalerWireGatewayApi.IncomingWadTransaction>()
+ .property("amount", codecForAmountString())
+ .property("credit_account", codecForPaytoString())
+ .property("date", codecForTimestamp)
+ .property("debit_account", codecForPaytoString())
+ .property("origin_exchange_url", codecForURL())
+ .property("row_id", codecForNumber())
+ .property("type", codecForConstString("WAD"))
+ .property("wad_id", codecForString())
+ .build("TalerWireGatewayApi.IncomingWadTransaction");
+
+export const codecForOutgoingHistory =
+ (): Codec<TalerWireGatewayApi.OutgoingHistory> =>
+ buildCodecForObject<TalerWireGatewayApi.OutgoingHistory>()
+ .property("debit_account", codecForPaytoString())
+ .property(
+ "outgoing_transactions",
+ codecForList(codecForOutgoingBankTransaction()),
+ )
+ .build("TalerWireGatewayApi.OutgoingHistory");
+
+export const codecForOutgoingBankTransaction =
+ (): Codec<TalerWireGatewayApi.OutgoingBankTransaction> =>
+ buildCodecForObject<TalerWireGatewayApi.OutgoingBankTransaction>()
+ .property("amount", codecForAmountString())
+ .property("credit_account", codecForPaytoString())
+ .property("date", codecForTimestamp)
+ .property("exchange_base_url", codecForURL())
+ .property("row_id", codecForNumber())
+ .property("wtid", codecForString())
+ .build("TalerWireGatewayApi.OutgoingBankTransaction");
+
+export const codecForAddIncomingResponse =
+ (): Codec<TalerWireGatewayApi.AddIncomingResponse> =>
+ buildCodecForObject<TalerWireGatewayApi.AddIncomingResponse>()
+ .property("row_id", codecForNumber())
+ .property("timestamp", codecForTimestamp)
+ .build("TalerWireGatewayApi.AddIncomingResponse");
+
+export const codecForAmlRecords = (): Codec<TalerExchangeApi.AmlRecords> =>
+ buildCodecForObject<TalerExchangeApi.AmlRecords>()
+ .property("records", codecForList(codecForAmlRecord()))
+ .build("TalerExchangeApi.PublicAccountsResponse");
+
+export const codecForAmlRecord = (): Codec<TalerExchangeApi.AmlRecord> =>
+ buildCodecForObject<TalerExchangeApi.AmlRecord>()
+ .property("current_state", codecForNumber())
+ .property("h_payto", codecForString())
+ .property("rowid", codecForNumber())
+ .property("threshold", codecForAmountString())
+ .build("TalerExchangeApi.AmlRecord");
+
+export const codecForAmlDecisionDetails =
+ (): Codec<TalerExchangeApi.AmlDecisionDetails> =>
+ buildCodecForObject<TalerExchangeApi.AmlDecisionDetails>()
+ .property("aml_history", codecForList(codecForAmlDecisionDetail()))
+ .property("kyc_attributes", codecForList(codecForKycDetail()))
+ .build("TalerExchangeApi.AmlDecisionDetails");
+
+export const codecForAmlDecisionDetail =
+ (): Codec<TalerExchangeApi.AmlDecisionDetail> =>
+ buildCodecForObject<TalerExchangeApi.AmlDecisionDetail>()
+ .property("justification", codecForString())
+ .property("new_state", codecForNumber())
+ .property("decision_time", codecForTimestamp)
+ .property("new_threshold", codecForAmountString())
+ .property("decider_pub", codecForString())
+ .build("TalerExchangeApi.AmlDecisionDetail");
+
+export const codecForChallenge = (): Codec<TalerCorebankApi.Challenge> =>
+ buildCodecForObject<TalerCorebankApi.Challenge>()
+ .property("challenge_id", codecForNumber())
+ .build("TalerCorebankApi.Challenge");
+
+export const codecForTanTransmission =
+ (): Codec<TalerCorebankApi.TanTransmission> =>
+ buildCodecForObject<TalerCorebankApi.TanTransmission>()
+ .property(
+ "tan_channel",
+ codecForEither(
+ codecForConstString(TalerCorebankApi.TanChannel.SMS),
+ codecForConstString(TalerCorebankApi.TanChannel.EMAIL),
+ ),
+ )
+ .property("tan_info", codecForString())
+ .build("TalerCorebankApi.TanTransmission");
+
+interface KycDetail {
+ provider_section: string;
+ attributes?: Object;
+ collection_time: Timestamp;
+ expiration_time: Timestamp;
+}
+export const codecForKycDetail = (): Codec<TalerExchangeApi.KycDetail> =>
+ buildCodecForObject<TalerExchangeApi.KycDetail>()
+ .property("provider_section", codecForString())
+ .property("attributes", codecOptional(codecForAny()))
+ .property("collection_time", codecForTimestamp)
+ .property("expiration_time", codecForTimestamp)
+ .build("TalerExchangeApi.KycDetail");
+
+export const codecForAmlDecision = (): Codec<TalerExchangeApi.AmlDecision> =>
+ buildCodecForObject<TalerExchangeApi.AmlDecision>()
+ .property("justification", codecForString())
+ .property("new_threshold", codecForAmountString())
+ .property("h_payto", codecForString())
+ .property("new_state", codecForNumber())
+ .property("officer_sig", codecForString())
+ .property("decision_time", codecForTimestamp)
+ .property("kyc_requirements", codecOptional(codecForList(codecForString())))
+ .build("TalerExchangeApi.AmlDecision");
+
+// version: string;
+
+// // Name of the API.
+// name: "taler-conversion-info";
+
+// // Currency used by this bank.
+// regional_currency: string;
+
+// // How the bank SPA should render this currency.
+// regional_currency_specification: CurrencySpecification;
+
+// // External currency used during conversion.
+// fiat_currency: string;
+
+// // How the bank SPA should render this currency.
+// fiat_currency_specification: CurrencySpecification;
+
+// Extra conversion rate information.
+// // Only present if server opts in to report the static conversion rate.
+// conversion_info?: {
+
+// // Fee to subtract after applying the cashin ratio.
+// cashin_fee: AmountString;
+
+// // Fee to subtract after applying the cashout ratio.
+// cashout_fee: AmountString;
+
+// // Minimum amount authorised for cashin, in fiat before conversion
+// cashin_min_amount: AmountString;
+
+// // Minimum amount authorised for cashout, in regional before conversion
+// cashout_min_amount: AmountString;
+
+// // Smallest possible regional amount, converted amount is rounded to this amount
+// cashin_tiny_amount: AmountString;
+
+// // Smallest possible fiat amount, converted amount is rounded to this amount
+// cashout_tiny_amount: AmountString;
+
+// // Rounding mode used during cashin conversion
+// cashin_rounding_mode: "zero" | "up" | "nearest";
+
+// // Rounding mode used during cashout conversion
+// cashout_rounding_mode: "zero" | "up" | "nearest";
+// }
+export const codecForConversionInfo =
+ (): Codec<TalerBankConversionApi.ConversionInfo> =>
+ buildCodecForObject<TalerBankConversionApi.ConversionInfo>()
+ .property("cashin_fee", codecForAmountString())
+ .property("cashin_min_amount", codecForAmountString())
+ .property("cashin_ratio", codecForDecimalNumber())
+ .property(
+ "cashin_rounding_mode",
+ codecForEither(
+ codecForConstString("zero"),
+ codecForConstString("up"),
+ codecForConstString("nearest"),
+ ),
+ )
+ .property("cashin_tiny_amount", codecForAmountString())
+ .property("cashout_fee", codecForAmountString())
+ .property("cashout_min_amount", codecForAmountString())
+ .property("cashout_ratio", codecForDecimalNumber())
+ .property(
+ "cashout_rounding_mode",
+ codecForEither(
+ codecForConstString("zero"),
+ codecForConstString("up"),
+ codecForConstString("nearest"),
+ ),
+ )
+ .property("cashout_tiny_amount", codecForAmountString())
+ .build("ConversionBankConfig.ConversionInfo");
+
+export const codecForConversionBankConfig =
+ (): Codec<TalerBankConversionApi.IntegrationConfig> =>
+ buildCodecForObject<TalerBankConversionApi.IntegrationConfig>()
+ .property("name", codecForConstString("taler-conversion-info"))
+ .property("version", codecForString())
+ .property("regional_currency", codecForString())
+ .property(
+ "regional_currency_specification",
+ codecForCurrencySpecificiation(),
+ )
+ .property("fiat_currency", codecForString())
+ .property("fiat_currency_specification", codecForCurrencySpecificiation())
+
+ .property("conversion_rate", codecForConversionInfo())
+ .build("ConversionBankConfig.IntegrationConfig");
+
+// export const codecFor =
+// (): Codec<TalerWireGatewayApi.PublicAccountsResponse> =>
+// buildCodecForObject<TalerWireGatewayApi.PublicAccountsResponse>()
+// .property("", codecForString())
+// .build("TalerWireGatewayApi.PublicAccountsResponse");
+
+type EmailAddress = string;
+type PhoneNumber = string;
+type EddsaSignature = string;
+// base32 encoded RSA blinded signature.
+type BlindedRsaSignature = string;
+type Base32 = string;
+
+type DecimalNumber = string;
+type RsaSignature = string;
+type Float = number;
+type LibtoolVersion = string;
+// The type of a coin's blinded envelope depends on the cipher that is used
+// for signing with a denomination key.
+type CoinEnvelope = RSACoinEnvelope | CSCoinEnvelope;
+// For denomination signatures based on RSA, the planchet is just a blinded
+// coin's public EdDSA key.
+interface RSACoinEnvelope {
+ cipher: "RSA" | "RSA+age_restricted";
+ rsa_blinded_planchet: string; // Crockford Base32 encoded
+}
+// For denomination signatures based on Blind Clause-Schnorr, the planchet
+// consists of the public nonce and two Curve25519 scalars which are two
+// blinded challenges in the Blinded Clause-Schnorr signature scheme.
+// See https://taler.net/papers/cs-thesis.pdf for details.
+interface CSCoinEnvelope {
+ cipher: "CS" | "CS+age_restricted";
+ cs_nonce: string; // Crockford Base32 encoded
+ cs_blinded_c0: string; // Crockford Base32 encoded
+ cs_blinded_c1: string; // Crockford Base32 encoded
+}
+// Secret for blinding/unblinding.
+// An RSA blinding secret, which is basically
+// a 256-bit nonce, converted to Crockford Base32.
+type DenominationBlindingKeyP = string;
+
+//FIXME: implement this codec
+const codecForURL = codecForString;
+//FIXME: implement this codec
+const codecForLibtoolVersion = codecForString;
+//FIXME: implement this codec
+const codecForCurrencyName = codecForString;
+//FIXME: implement this codec
+const codecForDecimalNumber = codecForString;
+
+export type WithdrawalOperationStatus =
+ | "pending"
+ | "selected"
+ | "aborted"
+ | "confirmed";
+
+export namespace TalerWireGatewayApi {
+ export interface TransferResponse {
+ // Timestamp that indicates when the wire transfer will be executed.
+ // In cases where the wire transfer gateway is unable to know when
+ // the wire transfer will be executed, the time at which the request
+ // has been received and stored will be returned.
+ // The purpose of this field is for debugging (humans trying to find
+ // the transaction) as well as for taxation (determining which
+ // time period a transaction belongs to).
+ timestamp: Timestamp;
+
+ // Opaque ID of the transaction that the bank has made.
+ row_id: SafeUint64;
+ }
+
+ export interface TransferRequest {
+ // Nonce to make the request idempotent. Requests with the same
+ // transaction_uid that differ in any of the other fields
+ // are rejected.
+ request_uid: HashCode;
+
+ // Amount to transfer.
+ amount: AmountString;
+
+ // Base URL of the exchange. Shall be included by the bank gateway
+ // in the appropriate section of the wire transfer details.
+ exchange_base_url: string;
+
+ // Wire transfer identifier chosen by the exchange,
+ // used by the merchant to identify the Taler order(s)
+ // associated with this wire transfer.
+ wtid: ShortHashCode;
+
+ // The recipient's account identifier as a payto URI.
+ credit_account: PaytoString;
+ }
+
+ export interface IncomingHistory {
+ // Array of incoming transactions.
+ incoming_transactions: IncomingBankTransaction[];
+
+ // Payto URI to identify the receiver of funds.
+ // This must be one of the exchange's bank accounts.
+ // Credit account is shared by all incoming transactions
+ // as per the nature of the request.
+
+ // undefined if incoming transaction is empty
+ credit_account?: PaytoString;
+ }
+
+ // Union discriminated by the "type" field.
+ export type IncomingBankTransaction =
+ | IncomingReserveTransaction
+ | IncomingWadTransaction;
+
+ export interface IncomingReserveTransaction {
+ type: "RESERVE";
+
+ // Opaque identifier of the returned record.
+ row_id: SafeUint64;
+
+ // Date of the transaction.
+ date: Timestamp;
+
+ // Amount transferred.
+ amount: AmountString;
+
+ // Payto URI to identify the sender of funds.
+ debit_account: PaytoString;
+
+ // The reserve public key extracted from the transaction details.
+ reserve_pub: EddsaPublicKey;
+ }
+
+ export interface IncomingWadTransaction {
+ type: "WAD";
+
+ // Opaque identifier of the returned record.
+ row_id: SafeUint64;
+
+ // Date of the transaction.
+ date: Timestamp;
+
+ // Amount transferred.
+ amount: AmountString;
+
+ // Payto URI to identify the receiver of funds.
+ // This must be one of the exchange's bank accounts.
+ credit_account: PaytoString;
+
+ // Payto URI to identify the sender of funds.
+ debit_account: PaytoString;
+
+ // Base URL of the exchange that originated the wad.
+ origin_exchange_url: string;
+
+ // The reserve public key extracted from the transaction details.
+ wad_id: WadId;
+ }
+
+ export interface OutgoingHistory {
+ // Array of outgoing transactions.
+ outgoing_transactions: OutgoingBankTransaction[];
+
+ // Payto URI to identify the sender of funds.
+ // This must be one of the exchange's bank accounts.
+ // Credit account is shared by all incoming transactions
+ // as per the nature of the request.
+
+ // undefined if outgoing transactions is empty
+ debit_account?: PaytoString;
+ }
+
+ export interface OutgoingBankTransaction {
+ // Opaque identifier of the returned record.
+ row_id: SafeUint64;
+
+ // Date of the transaction.
+ date: Timestamp;
+
+ // Amount transferred.
+ amount: AmountString;
+
+ // Payto URI to identify the receiver of funds.
+ credit_account: PaytoString;
+
+ // The wire transfer ID in the outgoing transaction.
+ wtid: ShortHashCode;
+
+ // Base URL of the exchange.
+ exchange_base_url: string;
+ }
+
+ export interface AddIncomingRequest {
+ // Amount to transfer.
+ amount: AmountString;
+
+ // Reserve public key that is included in the wire transfer details
+ // to identify the reserve that is being topped up.
+ reserve_pub: EddsaPublicKey;
+
+ // Account (as payto URI) that makes the wire transfer to the exchange.
+ // Usually this account must be created by the test harness before this API is
+ // used. An exception is the "exchange-fakebank", where any debit account can be
+ // specified, as it is automatically created.
+ debit_account: PaytoString;
+ }
+
+ export interface AddIncomingResponse {
+ // Timestamp that indicates when the wire transfer will be executed.
+ // In cases where the wire transfer gateway is unable to know when
+ // the wire transfer will be executed, the time at which the request
+ // has been received and stored will be returned.
+ // The purpose of this field is for debugging (humans trying to find
+ // the transaction) as well as for taxation (determining which
+ // time period a transaction belongs to).
+ timestamp: Timestamp;
+
+ // Opaque ID of the transaction that the bank has made.
+ row_id: SafeUint64;
+ }
+}
+
+export namespace TalerRevenueApi {
+ export interface RevenueConfig {
+ // Name of the API.
+ name: "taler-revenue";
+
+ // libtool-style representation of the Bank protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // Currency used by this gateway.
+ currency: string;
+
+ // URN of the implementation (needed to interpret 'revision' in version).
+ // @since v0, may become mandatory in the future.
+ implementation?: string;
+ }
+
+ export interface RevenueIncomingHistory {
+ // Array of incoming transactions.
+ incoming_transactions: RevenueIncomingBankTransaction[];
+
+ // Payto URI to identify the receiver of funds.
+ // Credit account is shared by all incoming transactions
+ // as per the nature of the request.
+ credit_account: string;
+ }
+
+ export interface RevenueIncomingBankTransaction {
+ // Opaque identifier of the returned record.
+ row_id: SafeUint64;
+
+ // Date of the transaction.
+ date: Timestamp;
+
+ // Amount transferred.
+ amount: AmountString;
+
+ // Payto URI to identify the sender of funds.
+ debit_account: string;
+
+ // The wire transfer subject.
+ subject: string;
+ }
+}
+
+export namespace TalerBankConversionApi {
+ export interface ConversionInfo {
+ // Exchange rate to buy regional currency from fiat
+ cashin_ratio: DecimalNumber;
+
+ // Exchange rate to sell regional currency for fiat
+ cashout_ratio: DecimalNumber;
+
+ // Fee to subtract after applying the cashin ratio.
+ cashin_fee: AmountString;
+
+ // Fee to subtract after applying the cashout ratio.
+ cashout_fee: AmountString;
+
+ // Minimum amount authorised for cashin, in fiat before conversion
+ cashin_min_amount: AmountString;
+
+ // Minimum amount authorised for cashout, in regional before conversion
+ cashout_min_amount: AmountString;
+
+ // Smallest possible regional amount, converted amount is rounded to this amount
+ cashin_tiny_amount: AmountString;
+
+ // Smallest possible fiat amount, converted amount is rounded to this amount
+ cashout_tiny_amount: AmountString;
+
+ // Rounding mode used during cashin conversion
+ cashin_rounding_mode: "zero" | "up" | "nearest";
+
+ // Rounding mode used during cashout conversion
+ cashout_rounding_mode: "zero" | "up" | "nearest";
+ }
+
+ export interface IntegrationConfig {
+ // libtool-style representation of the Bank protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // Name of the API.
+ name: "taler-conversion-info";
+
+ // Currency used by this bank.
+ regional_currency: string;
+
+ // How the bank SPA should render this currency.
+ regional_currency_specification: CurrencySpecification;
+
+ // External currency used during conversion.
+ fiat_currency: string;
+
+ // How the bank SPA should render this currency.
+ fiat_currency_specification: CurrencySpecification;
+
+ // Extra conversion rate information.
+ // Only present if server opts in to report the static conversion rate.
+ conversion_rate: ConversionInfo;
+ }
+
+ export interface CashinConversionResponse {
+ // Amount that the user will get deducted from their fiat
+ // bank account, according to the 'amount_credit' value.
+ amount_debit: AmountString;
+ // Amount that the user will receive in their regional
+ // bank account, according to 'amount_debit'.
+ amount_credit: AmountString;
+ }
+
+ export interface CashoutConversionResponse {
+ // Amount that the user will get deducted from their regional
+ // bank account, according to the 'amount_credit' value.
+ amount_debit: AmountString;
+ // Amount that the user will receive in their fiat
+ // bank account, according to 'amount_debit'.
+ amount_credit: AmountString;
+ }
+
+ export type RoundingMode = "zero" | "up" | "nearest";
+
+ export interface ConversionRate {
+ // Exchange rate to buy regional currency from fiat
+ cashin_ratio: DecimalNumber;
+
+ // Fee to subtract after applying the cashin ratio.
+ cashin_fee: AmountString;
+
+ // Minimum amount authorised for cashin, in fiat before conversion
+ cashin_min_amount: AmountString;
+
+ // Smallest possible regional amount, converted amount is rounded to this amount
+ cashin_tiny_amount: AmountString;
+
+ // Rounding mode used during cashin conversion
+ cashin_rounding_mode: RoundingMode;
+
+ // Exchange rate to sell regional currency for fiat
+ cashout_ratio: DecimalNumber;
+
+ // Fee to subtract after applying the cashout ratio.
+ cashout_fee: AmountString;
+
+ // Minimum amount authorised for cashout, in regional before conversion
+ cashout_min_amount: AmountString;
+
+ // Smallest possible fiat amount, converted amount is rounded to this amount
+ cashout_tiny_amount: AmountString;
+
+ // Rounding mode used during cashout conversion
+ cashout_rounding_mode: RoundingMode;
+ }
+}
+export namespace TalerBankIntegrationApi {
+ export interface BankVersion {
+ // libtool-style representation of the Bank protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // Currency used by this bank.
+ currency: string;
+
+ // How the bank SPA should render this currency.
+ currency_specification?: CurrencySpecification;
+
+ // Name of the API.
+ name: "taler-bank-integration";
+ }
+
+ export interface BankWithdrawalOperationStatus {
+ // Current status of the operation
+ // pending: the operation is pending parameters selection (exchange and reserve public key)
+ // selected: the operations has been selected and is pending confirmation
+ // aborted: the operation has been aborted
+ // confirmed: the transfer has been confirmed and registered by the bank
+ status: WithdrawalOperationStatus;
+
+ // Amount that will be withdrawn with this operation
+ // (raw amount without fee considerations).
+ amount: AmountString;
+
+ // Bank account of the customer that is withdrawing, as a
+ // payto URI.
+ sender_wire?: PaytoString;
+
+ // Suggestion for an exchange given by the bank.
+ suggested_exchange?: string;
+
+ // URL that the user needs to navigate to in order to
+ // complete some final confirmation (e.g. 2FA).
+ // It may contain withdrawal operation id
+ confirm_transfer_url?: string;
+
+ // Wire transfer types supported by the bank.
+ wire_types: string[];
+
+ // Reserve public key selected by the exchange,
+ // only non-null if status is selected or confirmed.
+ selected_reserve_pub?: string;
+
+ // Exchange account selected by the wallet
+ // only non-null if status is selected or confirmed.
+ selected_exchange_account?: string;
+ }
+
+ export interface BankWithdrawalOperationPostRequest {
+ // Reserve public key.
+ reserve_pub: string;
+
+ // Payto address of the exchange selected for the withdrawal.
+ selected_exchange: PaytoString;
+ }
+
+ export interface BankWithdrawalOperationPostResponse {
+ // Current status of the operation
+ // pending: the operation is pending parameters selection (exchange and reserve public key)
+ // selected: the operations has been selected and is pending confirmation
+ // aborted: the operation has been aborted
+ // confirmed: the transfer has been confirmed and registered by the bank
+ status: Omit<"pending", WithdrawalOperationStatus>;
+
+ // URL that the user needs to navigate to in order to
+ // complete some final confirmation (e.g. 2FA).
+ //
+ // Only applicable when status is selected.
+ // It may contain withdrawal operation id
+ confirm_transfer_url?: string;
+ }
+}
+export namespace TalerCorebankApi {
+ export interface IntegrationConfig {
+ // libtool-style representation of the Bank protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ currency: string;
+
+ // How the bank SPA should render this currency.
+ currency_specification: CurrencySpecification;
+
+ // Name of the API.
+ name: "taler-bank-integration";
+ }
+ export interface Config {
+ // Name of this API, always "taler-corebank".
+ name: "libeufin-bank";
+ // name: "taler-corebank";
+
+ // API version in the form $n:$n:$n
+ version: string;
+
+ // Bank display name to be used in user interfaces.
+ // For consistency use "Taler Bank" if missing.
+ // @since v4, will become mandatory in the next version.
+ bank_name: string;
+
+ // If 'true' the server provides local currency conversion support
+ // If 'false' some parts of the API are not supported and return 501
+ allow_conversion: boolean;
+
+ // If 'true' anyone can register
+ // If 'false' only the admin can
+ allow_registrations: boolean;
+
+ // If 'true' account can delete themselves
+ // If 'false' only the admin can delete accounts
+ allow_deletions: boolean;
+
+ // If 'true' anyone can edit their name
+ // If 'false' only admin can
+ allow_edit_name: boolean;
+
+ // If 'true' anyone can edit their cashout account
+ // If 'false' only the admin
+ allow_edit_cashout_payto_uri: boolean;
+
+ // Default debt limit for newly created accounts
+ default_debit_threshold: AmountString;
+
+ // Currency used by this bank.
+ currency: string;
+
+ // How the bank SPA should render this currency.
+ currency_specification: CurrencySpecification;
+
+ // TAN channels supported by the server
+ supported_tan_channels: TanChannel[];
+
+ // Wire transfer type supported by the bank.
+ // Default to 'iban' is missing
+ // @since v4, may become mandatory in the future.
+ wire_type: string;
+ }
+
+ export interface BankAccountCreateWithdrawalRequest {
+ // Amount to withdraw.
+ amount: AmountString;
+ }
+ export interface BankAccountCreateWithdrawalResponse {
+ // ID of the withdrawal, can be used to view/modify the withdrawal operation.
+ withdrawal_id: string;
+
+ // URI that can be passed to the wallet to initiate the withdrawal.
+ taler_withdraw_uri: TalerUriString;
+ }
+ export interface WithdrawalPublicInfo {
+ // Current status of the operation
+ // pending: the operation is pending parameters selection (exchange and reserve public key)
+ // selected: the operations has been selected and is pending confirmation
+ // aborted: the operation has been aborted
+ // confirmed: the transfer has been confirmed and registered by the bank
+ status: WithdrawalOperationStatus;
+
+ // Amount that will be withdrawn with this operation
+ // (raw amount without fee considerations).
+ amount: AmountString;
+
+ // Account username
+ username: string;
+
+ // Reserve public key selected by the exchange,
+ // only non-null if status is selected or confirmed.
+ selected_reserve_pub?: string;
+
+ // Exchange account selected by the wallet
+ // only non-null if status is selected or confirmed.
+ selected_exchange_account?: PaytoString;
+ }
+
+ export interface BankAccountTransactionsResponse {
+ transactions: BankAccountTransactionInfo[];
+ }
+
+ export interface BankAccountTransactionInfo {
+ creditor_payto_uri: PaytoString;
+ debtor_payto_uri: PaytoString;
+
+ amount: AmountString;
+ direction: "debit" | "credit";
+
+ subject: string;
+
+ // Transaction unique ID. Matches
+ // $transaction_id from the URI.
+ row_id: number;
+ date: Timestamp;
+ }
+
+ export interface CreateTransactionRequest {
+ // Address in the Payto format of the wire transfer receiver.
+ // It needs at least the 'message' query string parameter.
+ payto_uri: PaytoString;
+
+ // Transaction amount (in the $currency:x.y format), optional.
+ // However, when not given, its value must occupy the 'amount'
+ // query string parameter of the 'payto' field. In case it
+ // is given in both places, the paytoUri's takes the precedence.
+ amount?: AmountString;
+
+ // Nonce to make the request idempotent. Requests with the same
+ // request_uid that differ in any of the other fields
+ // are rejected.
+ // @since v4, will become mandatory in the next version.
+ request_uid?: ShortHashCode;
+ }
+
+ export interface CreateTransactionResponse {
+ // ID identifying the transaction being created
+ row_id: Integer;
+ }
+
+ export interface RegisterAccountResponse {
+ // Internal payto URI of this bank account.
+ internal_payto_uri: PaytoString;
+ }
+
+ export interface RegisterAccountRequest {
+ // Username
+ username: string;
+
+ // Password.
+ password: string;
+
+ // Legal name of the account owner
+ name: string;
+
+ // Defaults to false.
+ is_public?: boolean;
+
+ // Is this a taler exchange account?
+ // If true:
+ // - incoming transactions to the account that do not
+ // have a valid reserve public key are automatically
+ // - the account provides the taler-wire-gateway-api endpoints
+ // Defaults to false.
+ is_taler_exchange?: boolean;
+
+ // Addresses where to send the TAN for transactions.
+ contact_data?: ChallengeContactData;
+
+ // 'payto' address of a fiat bank account.
+ // Payments will be sent to this bank account
+ // when the user wants to convert the regional currency
+ // back to fiat currency outside bank.
+ cashout_payto_uri?: PaytoString;
+
+ // Internal payto URI of this bank account.
+ // Used mostly for testing.
+ payto_uri?: PaytoString;
+
+ // If present, set the max debit allowed for this user
+ // Only admin can set this property.
+ debit_threshold?: AmountString;
+
+ // If present, enables 2FA and set the TAN channel used for challenges
+ // Only admin can set this property, other user can reconfig their account
+ // after creation.
+ tan_channel?: TanChannel;
+ }
+
+ export interface ChallengeContactData {
+ // E-Mail address
+ email?: EmailAddress;
+
+ // Phone number.
+ phone?: PhoneNumber;
+ }
+
+ export interface AccountReconfiguration {
+ // Addresses where to send the TAN for transactions.
+ // Currently only used for cashouts.
+ // If missing, cashouts will fail.
+ // In the future, might be used for other transactions
+ // as well.
+ // Only admin can change this property.
+ contact_data?: ChallengeContactData;
+
+ // 'payto' URI of a fiat bank account.
+ // Payments will be sent to this bank account
+ // when the user wants to convert the regional currency
+ // back to fiat currency outside bank.
+ // Only admin can change this property if not allowed in config
+ cashout_payto_uri?: PaytoString;
+
+ // If present, change the legal name associated with $username.
+ // Only admin can change this property if not allowed in config
+ name?: string;
+
+ // Make this account visible to anyone?
+ is_public?: boolean;
+
+ // If present, change the max debit allowed for this user
+ // Only admin can change this property.
+ debit_threshold?: AmountString;
+
+ //FIX: missing in SPEC
+ // If present, enables 2FA and set the TAN channel used for challenges
+ tan_channel?: TanChannel | null;
+ }
+
+ export interface AccountPasswordChange {
+ // New password.
+ new_password: string;
+ // Old password. If present, check that the old password matches.
+ // Optional for admin account.
+ old_password?: string;
+ }
+
+ export interface PublicAccountsResponse {
+ public_accounts: PublicAccount[];
+ }
+ export interface PublicAccount {
+ // Username of the account
+ username: string;
+
+ // Internal payto URI of this bank account.
+ payto_uri: string;
+
+ // Current balance of the account
+ balance: Balance;
+
+ // Is this a taler exchange account?
+ is_taler_exchange: boolean;
+
+ // Opaque unique ID used for pagination.
+ // @since v4, will become mandatory in the future.
+ row_id?: Integer;
+ }
+
+ export interface ListBankAccountsResponse {
+ accounts: AccountMinimalData[];
+ }
+ export interface Balance {
+ amount: AmountString;
+ credit_debit_indicator: "credit" | "debit";
+ }
+ export interface AccountMinimalData {
+ // Username
+ username: string;
+
+ // Legal name of the account owner.
+ name: string;
+
+ // Internal payto URI of this bank account.
+ payto_uri: PaytoString;
+
+ // current balance of the account
+ balance: Balance;
+
+ // Number indicating the max debit allowed for the requesting user.
+ debit_threshold: AmountString;
+
+ // Is this account visible to anyone?
+ is_public: boolean;
+
+ // Is this a taler exchange account?
+ is_taler_exchange: boolean;
+
+ // Opaque unique ID used for pagination.
+ // @since v4, will become mandatory in the future.
+ row_id?: Integer;
+ }
+
+ export interface AccountData {
+ // Legal name of the account owner.
+ name: string;
+
+ // Available balance on the account.
+ balance: Balance;
+
+ // payto://-URI of the account.
+ payto_uri: PaytoString;
+
+ // Number indicating the max debit allowed for the requesting user.
+ debit_threshold: AmountString;
+
+ contact_data?: ChallengeContactData;
+
+ // 'payto' address pointing the bank account
+ // where to send cashouts. This field is optional
+ // because not all the accounts are required to participate
+ // in the merchants' circuit. One example is the exchange:
+ // that never cashouts. Registering these accounts can
+ // be done via the access API.
+ cashout_payto_uri?: PaytoString;
+
+ // Is this account visible to anyone?
+ is_public: boolean;
+
+ // Is this a taler exchange account?
+ is_taler_exchange: boolean;
+
+ // Is 2FA enabled and what channel is used for challenges?
+ tan_channel?: TanChannel;
+ }
+
+ export interface CashoutRequest {
+ // Nonce to make the request idempotent. Requests with the same
+ // request_uid that differ in any of the other fields
+ // are rejected.
+ request_uid: ShortHashCode;
+
+ // Optional subject to associate to the
+ // cashout operation. This data will appear
+ // as the incoming wire transfer subject in
+ // the user's fiat bank account.
+ subject?: string;
+
+ // That is the plain amount that the user specified
+ // to cashout. Its $currency is the (regional) currency of the
+ // bank instance.
+ amount_debit: AmountString;
+
+ // That is the amount that will effectively be
+ // transferred by the bank to the user's bank
+ // account, that is external to the regional currency.
+ // It is expressed in the fiat currency and
+ // is calculated after the cashout fee and the
+ // exchange rate. See the /cashout-rates call.
+ // The client needs to calculate this amount
+ // correctly based on the amount_debit and the cashout rate,
+ // otherwise the request will fail.
+ amount_credit: AmountString;
+ }
+
+ export interface CashoutResponse {
+ // ID identifying the operation being created
+ cashout_id: number;
+ }
+
+ /**
+ * @deprecated since 4, use 2fa
+ */
+ export interface CashoutConfirmRequest {
+ // the TAN that confirms $CASHOUT_ID.
+ tan: string;
+ }
+
+ export interface Cashouts {
+ // Every string represents a cash-out operation ID.
+ cashouts: CashoutInfo[];
+ }
+
+ export interface CashoutInfo {
+ cashout_id: number;
+ /**
+ * @deprecated since 4, use new 2fa
+ */
+ status?: "pending" | "aborted" | "confirmed";
+ }
+ export interface GlobalCashouts {
+ // Every string represents a cash-out operation ID.
+ cashouts: GlobalCashoutInfo[];
+ }
+ export interface GlobalCashoutInfo {
+ cashout_id: number;
+ username: string;
+ }
+
+ export interface CashoutStatusResponse {
+ // Amount debited to the internal
+ // regional currency bank account.
+ amount_debit: AmountString;
+
+ // Amount credited to the external bank account.
+ amount_credit: AmountString;
+
+ // Transaction subject.
+ subject: string;
+
+ // Time when the cashout was created.
+ creation_time: Timestamp;
+ }
+
+ export interface ConversionRatesResponse {
+ // Exchange rate to buy the local currency from the external one
+ buy_at_ratio: DecimalNumber;
+
+ // Exchange rate to sell the local currency for the external one
+ sell_at_ratio: DecimalNumber;
+
+ // Fee to subtract after applying the buy ratio.
+ buy_in_fee: DecimalNumber;
+
+ // Fee to subtract after applying the sell ratio.
+ sell_out_fee: DecimalNumber;
+ }
+
+ export enum MonitorTimeframeParam {
+ hour,
+ day,
+ month,
+ year,
+ decade,
+ }
+
+ export type MonitorResponse = MonitorNoConversion | MonitorWithConversion;
+
+ // Monitoring stats when conversion is not supported
+ export interface MonitorNoConversion {
+ type: "no-conversions";
+
+ // How many payments were made to a Taler exchange by another
+ // bank account.
+ talerInCount: number;
+
+ // Overall volume that has been paid to a Taler
+ // exchange by another bank account.
+ talerInVolume: AmountString;
+
+ // How many payments were made by a Taler exchange to another
+ // bank account.
+ talerOutCount: number;
+
+ // Overall volume that has been paid by a Taler
+ // exchange to another bank account.
+ talerOutVolume: AmountString;
+ }
+ // Monitoring stats when conversion is supported
+ export interface MonitorWithConversion {
+ type: "with-conversions";
+
+ // How many cashin operations were confirmed by a
+ // wallet owner. Note: wallet owners
+ // are NOT required to be customers of the libeufin-bank.
+ cashinCount: number;
+
+ // Overall regional currency that has been paid by the regional admin account
+ // to regional bank accounts to fulfill all the confirmed cashin operations.
+ cashinRegionalVolume: AmountString;
+
+ // Overall fiat currency that has been paid to the fiat admin account
+ // by fiat bank accounts to fulfill all the confirmed cashin operations.
+ cashinFiatVolume: AmountString;
+
+ // How many cashout operations were confirmed.
+ cashoutCount: number;
+
+ // Overall regional currency that has been paid to the regional admin account
+ // by fiat bank accounts to fulfill all the confirmed cashout operations.
+ cashoutRegionalVolume: AmountString;
+
+ // Overall fiat currency that has been paid by the fiat admin account
+ // to fiat bank accounts to fulfill all the confirmed cashout operations.
+ cashoutFiatVolume: AmountString;
+
+ // How many payments were made to a Taler exchange by another
+ // bank account.
+ talerInCount: number;
+
+ // Overall volume that has been paid to a Taler
+ // exchange by another bank account.
+ talerInVolume: AmountString;
+
+ // How many payments were made by a Taler exchange to another
+ // bank account.
+ talerOutCount: number;
+
+ // Overall volume that has been paid by a Taler
+ // exchange to another bank account.
+ talerOutVolume: AmountString;
+ }
+ export interface TanTransmission {
+ // Channel of the last successful transmission of the TAN challenge.
+ tan_channel: TanChannel;
+
+ // Info of the last successful transmission of the TAN challenge.
+ tan_info: string;
+ }
+
+ export interface Challenge {
+ // Unique identifier of the challenge to solve to run this protected
+ // operation.
+ challenge_id: number;
+ }
+
+ export interface ChallengeSolve {
+ // The TAN code that solves $CHALLENGE_ID
+ tan: string;
+ }
+
+ export enum TanChannel {
+ SMS = "sms",
+ EMAIL = "email",
+ }
+}
+
+export namespace TalerExchangeApi {
+ export enum AmlState {
+ normal = 0,
+ pending = 1,
+ frozen = 2,
+ }
+
+ export interface AmlRecords {
+ // Array of AML records matching the query.
+ records: AmlRecord[];
+ }
+ export interface AmlRecord {
+ // Which payto-address is this record about.
+ // Identifies a GNU Taler wallet or an affected bank account.
+ h_payto: PaytoHash;
+
+ // What is the current AML state.
+ current_state: AmlState;
+
+ // Monthly transaction threshold before a review will be triggered
+ threshold: AmountString;
+
+ // RowID of the record.
+ rowid: Integer;
+ }
+
+ export interface AmlDecisionDetails {
+ // Array of AML decisions made for this account. Possibly
+ // contains only the most recent decision if "history" was
+ // not set to 'true'.
+ aml_history: AmlDecisionDetail[];
+
+ // Array of KYC attributes obtained for this account.
+ kyc_attributes: KycDetail[];
+ }
+ export interface AmlDecisionDetail {
+ // What was the justification given?
+ justification: string;
+
+ // What is the new AML state.
+ new_state: Integer;
+
+ // When was this decision made?
+ decision_time: Timestamp;
+
+ // What is the new AML decision threshold (in monthly transaction volume)?
+ new_threshold: AmountString;
+
+ // Who made the decision?
+ decider_pub: AmlOfficerPublicKeyP;
+ }
+ export interface KycDetail {
+ // Name of the configuration section that specifies the provider
+ // which was used to collect the KYC details
+ provider_section: string;
+
+ // The collected KYC data. NULL if the attribute data could not
+ // be decrypted (internal error of the exchange, likely the
+ // attribute key was changed).
+ attributes?: Object;
+
+ // Time when the KYC data was collected
+ collection_time: Timestamp;
+
+ // Time when the validity of the KYC data will expire
+ expiration_time: Timestamp;
+ }
+
+ export interface AmlDecision {
+ // Human-readable justification for the decision.
+ justification: string;
+
+ // At what monthly transaction volume should the
+ // decision be automatically reviewed?
+ new_threshold: AmountString;
+
+ // Which payto-address is the decision about?
+ // Identifies a GNU Taler wallet or an affected bank account.
+ h_payto: PaytoHash;
+
+ // What is the new AML state (e.g. frozen, unfrozen, etc.)
+ // Numerical values are defined in AmlDecisionState.
+ new_state: Integer;
+
+ // Signature by the AML officer over a
+ // TALER_MasterAmlOfficerStatusPS.
+ // Must have purpose TALER_SIGNATURE_MASTER_AML_KEY.
+ officer_sig: EddsaSignature;
+
+ // When was the decision made?
+ decision_time: Timestamp;
+
+ // Optional argument to impose new KYC requirements
+ // that the customer has to satisfy to unblock transactions.
+ kyc_requirements?: string[];
+ }
+
+ export interface ExchangeVersionResponse {
+ // libtool-style representation of the Exchange protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // Name of the protocol.
+ name: "taler-exchange";
+
+ // URN of the implementation (needed to interpret 'revision' in version).
+ // @since v18, may become mandatory in the future.
+ implementation?: string;
+
+ // Currency supported by this exchange, given
+ // as a currency code ("USD" or "EUR").
+ currency: string;
+
+ // How wallets should render this currency.
+ currency_specification: CurrencySpecification;
+
+ // Names of supported KYC requirements.
+ supported_kyc_requirements: string[];
+ }
+
+ export type AccountRestriction =
+ | RegexAccountRestriction
+ | DenyAllAccountRestriction;
+ // Account restriction that disables this type of
+ // account for the indicated operation categorically.
+ export interface DenyAllAccountRestriction {
+ type: "deny";
+ }
+ // Accounts interacting with this type of account
+ // restriction must have a payto://-URI matching
+ // the given regex.
+ export interface RegexAccountRestriction {
+ type: "regex";
+
+ // Regular expression that the payto://-URI of the
+ // partner account must follow. The regular expression
+ // should follow posix-egrep, but without support for character
+ // classes, GNU extensions, back-references or intervals. See
+ // https://www.gnu.org/software/findutils/manual/html_node/find_html/posix_002degrep-regular-expression-syntax.html
+ // for a description of the posix-egrep syntax. Applications
+ // may support regexes with additional features, but exchanges
+ // must not use such regexes.
+ payto_regex: string;
+
+ // Hint for a human to understand the restriction
+ // (that is hopefully easier to comprehend than the regex itself).
+ human_hint: string;
+
+ // Map from IETF BCP 47 language tags to localized
+ // human hints.
+ human_hint_i18n?: { [lang_tag: string]: string };
+ }
+
+ export interface WireAccount {
+ // payto:// URI identifying the account and wire method
+ payto_uri: PaytoString;
+
+ // URI to convert amounts from or to the currency used by
+ // this wire account of the exchange. Missing if no
+ // conversion is applicable.
+ conversion_url?: string;
+
+ // Restrictions that apply to bank accounts that would send
+ // funds to the exchange (crediting this exchange bank account).
+ // Optional, empty array for unrestricted.
+ credit_restrictions: AccountRestriction[];
+
+ // Restrictions that apply to bank accounts that would receive
+ // funds from the exchange (debiting this exchange bank account).
+ // Optional, empty array for unrestricted.
+ debit_restrictions: AccountRestriction[];
+
+ // Signature using the exchange's offline key over
+ // a TALER_MasterWireDetailsPS
+ // with purpose TALER_SIGNATURE_MASTER_WIRE_DETAILS.
+ master_sig: EddsaSignature;
+ }
+
+ export interface ExchangeKeysResponse {
+ // libtool-style representation of the Exchange protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // The exchange's base URL.
+ base_url: string;
+
+ // The exchange's currency or asset unit.
+ currency: string;
+
+ /**
+ * FIXME: PARTIALLY IMPLEMENTED!!
+ */
+
+ // How wallets should render this currency.
+ // currency_specification: CurrencySpecification;
+
+ // // Absolute cost offset for the STEFAN curve used
+ // // to (over) approximate fees payable by amount.
+ // stefan_abs: AmountString;
+
+ // // Factor to multiply the logarithm of the amount
+ // // with to (over) approximate fees payable by amount.
+ // // Note that the total to be paid is first to be
+ // // divided by the smallest denomination to obtain
+ // // the value that the logarithm is to be taken of.
+ // stefan_log: AmountString;
+
+ // // Linear cost factor for the STEFAN curve used
+ // // to (over) approximate fees payable by amount.
+ // //
+ // // Note that this is a scalar, as it is multiplied
+ // // with the actual amount.
+ // stefan_lin: Float;
+
+ // // Type of the asset. "fiat", "crypto", "regional"
+ // // or "stock". Wallets should adjust their UI/UX
+ // // based on this value.
+ // asset_type: string;
+
+ // // Array of wire accounts operated by the exchange for
+ // // incoming wire transfers.
+ // accounts: WireAccount[];
+
+ // // Object mapping names of wire methods (i.e. "iban" or "x-taler-bank")
+ // // to wire fees.
+ // wire_fees: { method: AggregateTransferFee[] };
+
+ // // List of exchanges that this exchange is partnering
+ // // with to enable wallet-to-wallet transfers.
+ // wads: ExchangePartner[];
+
+ // // Set to true if this exchange allows the use
+ // // of reserves for rewards.
+ // // @deprecated in protocol v18.
+ // rewards_allowed: false;
+
+ // // EdDSA master public key of the exchange, used to sign entries
+ // // in denoms and signkeys.
+ // master_public_key: EddsaPublicKey;
+
+ // // Relative duration until inactive reserves are closed;
+ // // not signed (!), can change without notice.
+ // reserve_closing_delay: RelativeTime;
+
+ // // Threshold amounts beyond which wallet should
+ // // trigger the KYC process of the issuing
+ // // exchange. Optional option, if not given there is no limit.
+ // // Currency must match currency.
+ // wallet_balance_limit_without_kyc?: AmountString[];
+
+ // // Denominations offered by this exchange
+ // denominations: DenomGroup[];
+
+ // // Compact EdDSA signature (binary-only) over the
+ // // contatentation of all of the master_sigs (in reverse
+ // // chronological order by group) in the arrays under
+ // // "denominations". Signature of TALER_ExchangeKeySetPS
+ // exchange_sig: EddsaSignature;
+
+ // // Public EdDSA key of the exchange that was used to generate the signature.
+ // // Should match one of the exchange's signing keys from signkeys. It is given
+ // // explicitly as the client might otherwise be confused by clock skew as to
+ // // which signing key was used for the exchange_sig.
+ // exchange_pub: EddsaPublicKey;
+
+ // // Denominations for which the exchange currently offers/requests recoup.
+ // recoup: Recoup[];
+
+ // // Array of globally applicable fees by time range.
+ // global_fees: GlobalFees[];
+
+ // // The date when the denomination keys were last updated.
+ // list_issue_date: Timestamp;
+
+ // // Auditors of the exchange.
+ // auditors: AuditorKeys[];
+
+ // // The exchange's signing keys.
+ // signkeys: SignKey[];
+
+ // // Optional field with a dictionary of (name, object) pairs defining the
+ // // supported and enabled extensions, such as age_restriction.
+ // extensions?: { name: ExtensionManifest };
+
+ // // Signature by the exchange master key of the SHA-256 hash of the
+ // // normalized JSON-object of field extensions, if it was set.
+ // // The signature has purpose TALER_SIGNATURE_MASTER_EXTENSIONS.
+ // extensions_sig?: EddsaSignature;
+ }
+
+ interface ExtensionManifest {
+ // The criticality of the extension MUST be provided. It has the same
+ // semantics as "critical" has for extensions in X.509:
+ // - if "true", the client must "understand" the extension before
+ // proceeding,
+ // - if "false", clients can safely skip extensions they do not
+ // understand.
+ // (see https://datatracker.ietf.org/doc/html/rfc5280#section-4.2)
+ critical: boolean;
+
+ // The version information MUST be provided in Taler's protocol version
+ // ranges notation, see
+ // https://docs.taler.net/core/api-common.html#protocol-version-ranges
+ version: LibtoolVersion;
+
+ // Optional configuration object, defined by the feature itself
+ config?: object;
+ }
+
+ interface SignKey {
+ // The actual exchange's EdDSA signing public key.
+ key: EddsaPublicKey;
+
+ // Initial validity date for the signing key.
+ stamp_start: Timestamp;
+
+ // Date when the exchange will stop using the signing key, allowed to overlap
+ // slightly with the next signing key's validity to allow for clock skew.
+ stamp_expire: Timestamp;
+
+ // Date when all signatures made by the signing key expire and should
+ // henceforth no longer be considered valid in legal disputes.
+ stamp_end: Timestamp;
+
+ // Signature over key and stamp_expire by the exchange master key.
+ // Signature of TALER_ExchangeSigningKeyValidityPS.
+ // Must have purpose TALER_SIGNATURE_MASTER_SIGNING_KEY_VALIDITY.
+ master_sig: EddsaSignature;
+ }
+
+ interface AuditorKeys {
+ // The auditor's EdDSA signing public key.
+ auditor_pub: EddsaPublicKey;
+
+ // The auditor's URL.
+ auditor_url: string;
+
+ // The auditor's name (for humans).
+ auditor_name: string;
+
+ // An array of denomination keys the auditor affirms with its signature.
+ // Note that the message only includes the hash of the public key, while the
+ // signature is actually over the expanded information including expiration
+ // times and fees. The exact format is described below.
+ denomination_keys: AuditorDenominationKey[];
+ }
+ interface AuditorDenominationKey {
+ // Hash of the public RSA key used to sign coins of the respective
+ // denomination. Note that the auditor's signature covers more than just
+ // the hash, but this other information is already provided in denoms and
+ // thus not repeated here.
+ denom_pub_h: HashCode;
+
+ // Signature of TALER_ExchangeKeyValidityPS.
+ auditor_sig: EddsaSignature;
+ }
+
+ interface GlobalFees {
+ // What date (inclusive) does these fees go into effect?
+ start_date: Timestamp;
+
+ // What date (exclusive) does this fees stop going into effect?
+ end_date: Timestamp;
+
+ // Account history fee, charged when a user wants to
+ // obtain a reserve/account history.
+ history_fee: AmountString;
+
+ // Annual fee charged for having an open account at the
+ // exchange. Charged to the account. If the account
+ // balance is insufficient to cover this fee, the account
+ // is automatically deleted/closed. (Note that the exchange
+ // will keep the account history around for longer for
+ // regulatory reasons.)
+ account_fee: AmountString;
+
+ // Purse fee, charged only if a purse is abandoned
+ // and was not covered by the account limit.
+ purse_fee: AmountString;
+
+ // How long will the exchange preserve the account history?
+ // After an account was deleted/closed, the exchange will
+ // retain the account history for legal reasons until this time.
+ history_expiration: RelativeTime;
+
+ // Non-negative number of concurrent purses that any
+ // account holder is allowed to create without having
+ // to pay the purse_fee.
+ purse_account_limit: Integer;
+
+ // How long does an exchange keep a purse around after a purse
+ // has expired (or been successfully merged)? A 'GET' request
+ // for a purse will succeed until the purse expiration time
+ // plus this value.
+ purse_timeout: RelativeTime;
+
+ // Signature of TALER_GlobalFeesPS.
+ master_sig: EddsaSignature;
+ }
+
+ interface Recoup {
+ // Hash of the public key of the denomination that is being revoked under
+ // emergency protocol (see /recoup).
+ h_denom_pub: HashCode;
+
+ // We do not include any signature here, as the primary use-case for
+ // this emergency involves the exchange having lost its signing keys,
+ // so such a signature here would be pretty worthless. However, the
+ // exchange will not honor /recoup requests unless they are for
+ // denomination keys listed here.
+ }
+
+ interface AggregateTransferFee {
+ // Per transfer wire transfer fee.
+ wire_fee: AmountString;
+
+ // Per transfer closing fee.
+ closing_fee: AmountString;
+
+ // What date (inclusive) does this fee go into effect?
+ // The different fees must cover the full time period in which
+ // any of the denomination keys are valid without overlap.
+ start_date: Timestamp;
+
+ // What date (exclusive) does this fee stop going into effect?
+ // The different fees must cover the full time period in which
+ // any of the denomination keys are valid without overlap.
+ end_date: Timestamp;
+
+ // Signature of TALER_MasterWireFeePS with
+ // purpose TALER_SIGNATURE_MASTER_WIRE_FEES.
+ sig: EddsaSignature;
+ }
+
+ interface ExchangePartner {
+ // Base URL of the partner exchange.
+ partner_base_url: string;
+
+ // Public master key of the partner exchange.
+ partner_master_pub: EddsaPublicKey;
+
+ // Per exchange-to-exchange transfer (wad) fee.
+ wad_fee: AmountString;
+
+ // Exchange-to-exchange wad (wire) transfer frequency.
+ wad_frequency: RelativeTime;
+
+ // When did this partnership begin (under these conditions)?
+ start_date: Timestamp;
+
+ // How long is this partnership expected to last?
+ end_date: Timestamp;
+
+ // Signature using the exchange's offline key over
+ // TALER_WadPartnerSignaturePS
+ // with purpose TALER_SIGNATURE_MASTER_PARTNER_DETAILS.
+ master_sig: EddsaSignature;
+ }
+
+ type DenomGroup =
+ | DenomGroupRsa
+ | DenomGroupCs
+ | DenomGroupRsaAgeRestricted
+ | DenomGroupCsAgeRestricted;
+ interface DenomGroupRsa extends DenomGroupCommon {
+ cipher: "RSA";
+
+ denoms: ({
+ rsa_pub: RsaPublicKey;
+ } & DenomCommon)[];
+ }
+ interface DenomGroupCs extends DenomGroupCommon {
+ cipher: "CS";
+
+ denoms: ({
+ cs_pub: Cs25519Point;
+ } & DenomCommon)[];
+ }
+
+ // Binary representation of the age groups.
+ // The bits set in the mask mark the edges at the beginning of a next age
+ // group. F.e. for the age groups
+ // 0-7, 8-9, 10-11, 12-13, 14-15, 16-17, 18-21, 21-*
+ // the following bits are set:
+ //
+ // 31 24 16 8 0
+ // | | | | |
+ // oooooooo oo1oo1o1 o1o1o1o1 ooooooo1
+ //
+ // A value of 0 means that the exchange does not support the extension for
+ // age-restriction.
+ type AgeMask = Integer;
+
+ interface DenomGroupRsaAgeRestricted extends DenomGroupCommon {
+ cipher: "RSA+age_restricted";
+ age_mask: AgeMask;
+
+ denoms: ({
+ rsa_pub: RsaPublicKey;
+ } & DenomCommon)[];
+ }
+ interface DenomGroupCsAgeRestricted extends DenomGroupCommon {
+ cipher: "CS+age_restricted";
+ age_mask: AgeMask;
+
+ denoms: ({
+ cs_pub: Cs25519Point;
+ } & DenomCommon)[];
+ }
+ // Common attributes for all denomination groups
+ interface DenomGroupCommon {
+ // How much are coins of this denomination worth?
+ value: AmountString;
+
+ // Fee charged by the exchange for withdrawing a coin of this denomination.
+ fee_withdraw: AmountString;
+
+ // Fee charged by the exchange for depositing a coin of this denomination.
+ fee_deposit: AmountString;
+
+ // Fee charged by the exchange for refreshing a coin of this denomination.
+ fee_refresh: AmountString;
+
+ // Fee charged by the exchange for refunding a coin of this denomination.
+ fee_refund: AmountString;
+ }
+ interface DenomCommon {
+ // Signature of TALER_DenominationKeyValidityPS.
+ master_sig: EddsaSignature;
+
+ // When does the denomination key become valid?
+ stamp_start: Timestamp;
+
+ // When is it no longer possible to withdraw coins
+ // of this denomination?
+ stamp_expire_withdraw: Timestamp;
+
+ // When is it no longer possible to deposit coins
+ // of this denomination?
+ stamp_expire_deposit: Timestamp;
+
+ // Timestamp indicating by when legal disputes relating to these coins must
+ // be settled, as the exchange will afterwards destroy its evidence relating to
+ // transactions involving this coin.
+ stamp_expire_legal: Timestamp;
+
+ // Set to 'true' if the exchange somehow "lost"
+ // the private key. The denomination was not
+ // necessarily revoked, but still cannot be used
+ // to withdraw coins at this time (theoretically,
+ // the private key could be recovered in the
+ // future; coins signed with the private key
+ // remain valid).
+ lost?: boolean;
+ }
+ type DenominationKey = RsaDenominationKey | CSDenominationKey;
+ interface RsaDenominationKey {
+ cipher: "RSA";
+
+ // 32-bit age mask.
+ age_mask: Integer;
+
+ // RSA public key
+ rsa_public_key: RsaPublicKey;
+ }
+ interface CSDenominationKey {
+ cipher: "CS";
+
+ // 32-bit age mask.
+ age_mask: Integer;
+
+ // Public key of the denomination.
+ cs_public_key: Cs25519Point;
+ }
+}
+
+export namespace TalerMerchantApi {
+ export interface VersionResponse {
+ // libtool-style representation of the Merchant protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // Name of the protocol.
+ name: "taler-merchant";
+
+ // URN of the implementation (needed to interpret 'revision' in version).
+ // @since **v8**, may become mandatory in the future.
+ implementation?: string;
+
+ // Default (!) currency supported by this backend.
+ // This is the currency that the backend should
+ // suggest by default to the user when entering
+ // amounts. See currencies for a list of
+ // supported currencies and how to render them.
+ currency: string;
+
+ // How services should render currencies supported
+ // by this backend. Maps
+ // currency codes (e.g. "EUR" or "KUDOS") to
+ // the respective currency specification.
+ // All currencies in this map are supported by
+ // the backend. Note that the actual currency
+ // specifications are a *hint* for applications
+ // that would like *advice* on how to render amounts.
+ // Applications *may* ignore the currency specification
+ // if they know how to render currencies that they are
+ // used with.
+ currencies: { [currency: string]: CurrencySpecification };
+
+ // Array of exchanges trusted by the merchant.
+ // Since protocol **v6**.
+ exchanges: ExchangeConfigInfo[];
+ }
+
+ export interface ExchangeConfigInfo {
+ // Base URL of the exchange REST API.
+ base_url: string;
+
+ // Currency for which the merchant is configured
+ // to trust the exchange.
+ // May not be the one the exchange actually uses,
+ // but is the only one we would trust this exchange for.
+ currency: string;
+
+ // Offline master public key of the exchange. The
+ // /keys data must be signed with this public
+ // key for us to trust it.
+ master_pub: EddsaPublicKey;
+ }
+ export interface ClaimRequest {
+ // Nonce to identify the wallet that claimed the order.
+ nonce: string;
+
+ // Token that authorizes the wallet to claim the order.
+ // *Optional* as the merchant may not have required it
+ // (create_token set to false in PostOrderRequest).
+ token?: ClaimToken;
+ }
+
+ export interface ClaimResponse {
+ // Contract terms of the claimed order
+ contract_terms: ContractTerms;
+
+ // Signature by the merchant over the contract terms.
+ sig: EddsaSignature;
+ }
+
+ export interface PaymentResponse {
+ // Signature on TALER_PaymentResponsePS with the public
+ // key of the merchant instance.
+ sig: EddsaSignature;
+
+ // Text to be shown to the point-of-sale staff as a proof of
+ // payment.
+ pos_confirmation?: string;
+ }
+
+ export interface PaymentStatusRequestParams {
+ // Hash of the order’s contract terms (this is used to
+ // authenticate the wallet/customer in case
+ // $ORDER_ID is guessable).
+ // Required once an order was claimed.
+ contractTermHash?: string;
+ // Authorizes the request via the claim token that
+ // was returned in the PostOrderResponse. Used with
+ // unclaimed orders only. Whether token authorization is
+ // required is determined by the merchant when the
+ // frontend creates the order.
+ claimToken?: string;
+ // Session ID that the payment must be bound to.
+ // If not specified, the payment is not session-bound.
+ sessionId?: string;
+ // If specified, the merchant backend will wait up to
+ // timeout_ms milliseconds for completion of the payment
+ // before sending the HTTP response. A client must never
+ // rely on this behavior, as the merchant backend may return
+ // a response immediately.
+ timeout?: number;
+ // If set to “yes”, poll for the order’s pending refunds
+ // to be picked up. timeout_ms specifies how long we
+ // will wait for the refund.
+ awaitRefundObtained?: boolean;
+ // Indicates that we are polling for a refund above the
+ // given AMOUNT. timeout_ms will specify how long we
+ // will wait for the refund.
+ refund?: AmountString;
+ // Since protocol v9 refunded orders are only returned
+ // under “already_paid_order_id” if this flag is set
+ // explicitly to “YES”.
+ allowRefundedForRepurchase?: boolean;
+ }
+ export interface GetKycStatusRequestParams {
+ // If specified, the KYC check should return
+ // the KYC status only for this wire account.
+ // Otherwise, for all wire accounts.
+ wireHash?: string;
+ // If specified, the KYC check should return
+ // the KYC status only for the given exchange.
+ // Otherwise, for all exchanges we interacted with.
+ exchangeURL?: string;
+ // If specified, the merchant will wait up to
+ // timeout_ms milliseconds for the exchanges to
+ // confirm completion of the KYC process(es).
+ timeout?: number;
+ }
+ export interface GetOtpDeviceRequestParams {
+ // Timestamp in seconds to use when calculating
+ // the current OTP code of the device. Since protocol v10.
+ faketime?: number;
+ // Price to use when calculating the current OTP
+ // code of the device. Since protocol v10.
+ price?: AmountString;
+ }
+ export interface GetOrderRequestParams {
+ // Session ID that the payment must be bound to.
+ // If not specified, the payment is not session-bound.
+ sessionId?: string;
+ // Timeout in milliseconds to wait for a payment if
+ // the answer would otherwise be negative (long polling).
+ timeout?: number;
+ // Since protocol v9 refunded orders are only returned
+ // under “already_paid_order_id” if this flag is set
+ // explicitly to “YES”.
+ allowRefundedForRepurchase?: boolean;
+ }
+ export interface ListWireTransferRequestParams {
+ // Filter for transfers to the given bank account
+ // (subject and amount MUST NOT be given in the payto URI).
+ paytoURI?: string;
+ // Filter for transfers executed before the given timestamp.
+ before?: number;
+ // Filter for transfers executed after the given timestamp.
+ after?: number;
+ // At most return the given number of results. Negative for
+ // descending in execution time, positive for ascending in
+ // execution time. Default is -20.
+ limit?: number;
+ // Starting transfer_serial_id for an iteration.
+ offset?: string;
+ // Filter transfers by verification status.
+ verified?: boolean;
+ order?: "asc" | "dec";
+ }
+ export interface ListOrdersRequestParams {
+ // If set to yes, only return paid orders, if no only
+ // unpaid orders. Do not give (or use “all”) to see all
+ // orders regardless of payment status.
+ paid?: boolean;
+ // If set to yes, only return refunded orders, if no only
+ // unrefunded orders. Do not give (or use “all”) to see
+ // all orders regardless of refund status.
+ refunded?: boolean;
+ // If set to yes, only return wired orders, if no only
+ // orders with missing wire transfers. Do not give (or
+ // use “all”) to see all orders regardless of wire transfer
+ // status.
+ wired?: boolean;
+ // At most return the given number of results. Negative
+ // for descending by row ID, positive for ascending by
+ // row ID. Default is 20. Since protocol v12.
+ limit?: number;
+ // Non-negative date in seconds after the UNIX Epoc, see delta
+ // for its interpretation. If not specified, we default to the
+ // oldest or most recent entry, depending on delta.
+ date?: AbsoluteTime;
+ // Starting product_serial_id for an iteration.
+ // Since protocol v12.
+ offset?: string;
+ // Timeout in milliseconds to wait for additional orders if the
+ // answer would otherwise be negative (long polling). Only useful
+ // if delta is positive. Note that the merchant MAY still return
+ // a response that contains fewer than delta orders.
+ timeout?: number;
+ // Since protocol v6. Filters by session ID.
+ sessionId?: string;
+ // Since protocol v6. Filters by fulfillment URL.
+ fulfillmentUrl?: string;
+
+ order?: "asc" | "dec";
+ }
+
+ export interface PayRequest {
+ // The coins used to make the payment.
+ coins: CoinPaySig[];
+
+ // Custom inputs from the wallet for the contract.
+ wallet_data?: Object;
+
+ // The session for which the payment is made (or replayed).
+ // Only set for session-based payments.
+ session_id?: string;
+ }
+ export interface CoinPaySig {
+ // Signature by the coin.
+ coin_sig: EddsaSignature;
+
+ // Public key of the coin being spent.
+ coin_pub: EddsaPublicKey;
+
+ // Signature made by the denomination public key.
+ ub_sig: RsaSignature;
+
+ // The hash of the denomination public key associated with this coin.
+ h_denom: HashCode;
+
+ // The amount that is subtracted from this coin with this payment.
+ contribution: AmountString;
+
+ // URL of the exchange this coin was withdrawn from.
+ exchange_url: string;
+ }
+
+ export interface StatusPaid {
+ type: "paid";
+
+ // Was the payment refunded (even partially, via refund or abort)?
+ refunded: boolean;
+
+ // Is any amount of the refund still waiting to be picked up (even partially)?
+ refund_pending: boolean;
+
+ // Amount that was refunded in total.
+ refund_amount: AmountString;
+
+ // Amount that already taken by the wallet.
+ refund_taken: AmountString;
+ }
+ export interface StatusGotoResponse {
+ type: "goto";
+ // The client should go to the reorder URL, there a fresh
+ // order might be created as this one is taken by another
+ // customer or wallet (or repurchase detection logic may
+ // apply).
+ public_reorder_url: string;
+ }
+ export interface StatusUnpaidResponse {
+ type: "unpaid";
+ // URI that the wallet must process to complete the payment.
+ taler_pay_uri: string;
+
+ // Status URL, can be used as a redirect target for the browser
+ // to show the order QR code / trigger the wallet.
+ fulfillment_url?: string;
+
+ // Alternative order ID which was paid for already in the same session.
+ // Only given if the same product was purchased before in the same session.
+ already_paid_order_id?: string;
+ }
+
+ export interface PaidRefundStatusResponse {
+ // Text to be shown to the point-of-sale staff as a proof of
+ // payment (present only if reusable OTP algorithm is used).
+ pos_confirmation?: string;
+
+ // True if the order has been subjected to
+ // refunds. False if it was simply paid.
+ refunded: boolean;
+ }
+ export interface PaidRequest {
+ // Signature on TALER_PaymentResponsePS with the public
+ // key of the merchant instance.
+ sig: EddsaSignature;
+
+ // Hash of the order's contract terms (this is used to authenticate the
+ // wallet/customer and to enable signature verification without
+ // database access).
+ h_contract: HashCode;
+
+ // Hash over custom inputs from the wallet for the contract.
+ wallet_data_hash?: HashCode;
+
+ // Session id for which the payment is proven.
+ session_id: string;
+ }
+
+ export interface AbortRequest {
+ // Hash of the order's contract terms (this is used to authenticate the
+ // wallet/customer in case $ORDER_ID is guessable).
+ h_contract: HashCode;
+
+ // List of coins the wallet would like to see refunds for.
+ // (Should be limited to the coins for which the original
+ // payment succeeded, as far as the wallet knows.)
+ coins: AbortingCoin[];
+ }
+ interface AbortingCoin {
+ // Public key of a coin for which the wallet is requesting an abort-related refund.
+ coin_pub: EddsaPublicKey;
+
+ // The amount to be refunded (matches the original contribution)
+ contribution: AmountString;
+
+ // URL of the exchange this coin was withdrawn from.
+ exchange_url: string;
+ }
+ export interface AbortResponse {
+ // List of refund responses about the coins that the wallet
+ // requested an abort for. In the same order as the coins
+ // from the original request.
+ // The rtransaction_id is implied to be 0.
+ refunds: MerchantAbortPayRefundStatus[];
+ }
+ export type MerchantAbortPayRefundStatus =
+ | MerchantAbortPayRefundSuccessStatus
+ | MerchantAbortPayRefundFailureStatus;
+ // Details about why a refund failed.
+ export interface MerchantAbortPayRefundFailureStatus {
+ // Used as tag for the sum type RefundStatus sum type.
+ type: "failure";
+
+ // HTTP status of the exchange request, must NOT be 200.
+ exchange_status: Integer;
+
+ // Taler error code from the exchange reply, if available.
+ exchange_code?: Integer;
+
+ // If available, HTTP reply from the exchange.
+ exchange_reply?: Object;
+ }
+ // Additional details needed to verify the refund confirmation signature
+ // (h_contract_terms and merchant_pub) are already known
+ // to the wallet and thus not included.
+ export interface MerchantAbortPayRefundSuccessStatus {
+ // Used as tag for the sum type MerchantCoinRefundStatus sum type.
+ type: "success";
+
+ // HTTP status of the exchange request, 200 (integer) required for refund confirmations.
+ exchange_status: 200;
+
+ // The EdDSA :ref:signature (binary-only) with purpose
+ // TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND using a current signing key of the
+ // exchange affirming the successful refund.
+ exchange_sig: EddsaSignature;
+
+ // Public EdDSA key of the exchange that was used to generate the signature.
+ // Should match one of the exchange's signing keys from /keys. It is given
+ // explicitly as the client might otherwise be confused by clock skew as to
+ // which signing key was used.
+ exchange_pub: EddsaPublicKey;
+ }
+
+ export interface WalletRefundRequest {
+ // Hash of the order's contract terms (this is used to authenticate the
+ // wallet/customer).
+ h_contract: HashCode;
+ }
+ export interface WalletRefundResponse {
+ // Amount that was refunded in total.
+ refund_amount: AmountString;
+
+ // Successful refunds for this payment, empty array for none.
+ refunds: MerchantCoinRefundStatus[];
+
+ // Public key of the merchant.
+ merchant_pub: EddsaPublicKey;
+ }
+ export type MerchantCoinRefundStatus =
+ | MerchantCoinRefundSuccessStatus
+ | MerchantCoinRefundFailureStatus;
+ // Details about why a refund failed.
+ export interface MerchantCoinRefundFailureStatus {
+ // Used as tag for the sum type RefundStatus sum type.
+ type: "failure";
+
+ // HTTP status of the exchange request, must NOT be 200.
+ exchange_status: Integer;
+
+ // Taler error code from the exchange reply, if available.
+ exchange_code?: Integer;
+
+ // If available, HTTP reply from the exchange.
+ exchange_reply?: Object;
+
+ // Refund transaction ID.
+ rtransaction_id: Integer;
+
+ // Public key of a coin that was refunded.
+ coin_pub: EddsaPublicKey;
+
+ // Amount that was refunded, including refund fee charged by the exchange
+ // to the customer.
+ refund_amount: AmountString;
+
+ // Timestamp when the merchant approved the refund.
+ // Useful for grouping refunds.
+ execution_time: Timestamp;
+ }
+ // Additional details needed to verify the refund confirmation signature
+ // (h_contract_terms and merchant_pub) are already known
+ // to the wallet and thus not included.
+ export interface MerchantCoinRefundSuccessStatus {
+ // Used as tag for the sum type MerchantCoinRefundStatus sum type.
+ type: "success";
+
+ // HTTP status of the exchange request, 200 (integer) required for refund confirmations.
+ exchange_status: 200;
+
+ // The EdDSA :ref:signature (binary-only) with purpose
+ // TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND using a current signing key of the
+ // exchange affirming the successful refund.
+ exchange_sig: EddsaSignature;
+
+ // Public EdDSA key of the exchange that was used to generate the signature.
+ // Should match one of the exchange's signing keys from /keys. It is given
+ // explicitly as the client might otherwise be confused by clock skew as to
+ // which signing key was used.
+ exchange_pub: EddsaPublicKey;
+
+ // Refund transaction ID.
+ rtransaction_id: Integer;
+
+ // Public key of a coin that was refunded.
+ coin_pub: EddsaPublicKey;
+
+ // Amount that was refunded, including refund fee charged by the exchange
+ // to the customer.
+ refund_amount: AmountString;
+
+ // Timestamp when the merchant approved the refund.
+ // Useful for grouping refunds.
+ execution_time: Timestamp;
+ }
+
+ interface RewardInformation {
+ // Exchange from which the reward will be withdrawn. Needed by the
+ // wallet to determine denominations, fees, etc.
+ exchange_url: string;
+
+ // URL where to go after obtaining the reward.
+ next_url: string;
+
+ // (Remaining) amount of the reward (including fees).
+ reward_amount: AmountString;
+
+ // Timestamp indicating when the reward is set to expire (may be in the past).
+ // Note that rewards that have expired MAY also result in a 404 response.
+ expiration: Timestamp;
+ }
+
+ interface RewardPickupRequest {
+ // List of planchets the wallet wants to use for the reward.
+ planchets: PlanchetDetail[];
+ }
+ interface PlanchetDetail {
+ // Hash of the denomination's public key (hashed to reduce
+ // bandwidth consumption).
+ denom_pub_hash: HashCode;
+
+ // Coin's blinded public key.
+ coin_ev: CoinEnvelope;
+ }
+ interface RewardResponse {
+ // Blind RSA signatures over the planchets.
+ // The order of the signatures matches the planchets list.
+ blind_sigs: BlindSignature[];
+ }
+ interface BlindSignature {
+ // The (blind) RSA signature. Still needs to be unblinded.
+ blind_sig: BlindedRsaSignature;
+ }
+
+ export interface InstanceConfigurationMessage {
+ // Name of the merchant instance to create (will become $INSTANCE).
+ // Must match the regex ^[A-Za-z0-9][A-Za-z0-9_.@-]+$.
+ id: string;
+
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // Type of the user (business or individual).
+ // Defaults to 'business'. Should become mandatory field
+ // in the future, left as optional for API compatibility for now.
+ user_type?: string;
+
+ // Merchant email for customer contact.
+ email?: string;
+
+ // Merchant public website.
+ website?: string;
+
+ // Merchant logo.
+ logo?: ImageDataUrl;
+
+ // Authentication settings for this instance
+ auth: InstanceAuthConfigurationMessage;
+
+ // The merchant's physical address (to be put into contracts).
+ address: Location;
+
+ // The jurisdiction under which the merchant conducts its business
+ // (to be put into contracts).
+ jurisdiction: Location;
+
+ // Use STEFAN curves to determine default fees?
+ // If false, no fees are allowed by default.
+ // Can always be overridden by the frontend on a per-order basis.
+ use_stefan: boolean;
+
+ // If the frontend does NOT specify an execution date, how long should
+ // we tell the exchange to wait to aggregate transactions before
+ // executing the wire transfer? This delay is added to the current
+ // time when we generate the advisory execution time for the exchange.
+ default_wire_transfer_delay: RelativeTime;
+
+ // If the frontend does NOT specify a payment deadline, how long should
+ // offers we make be valid by default?
+ default_pay_delay: RelativeTime;
+ }
+
+ export interface InstanceAuthConfigurationMessage {
+ // Type of authentication.
+ // "external": The mechant backend does not do
+ // any authentication checks. Instead an API
+ // gateway must do the authentication.
+ // "token": The merchant checks an auth token.
+ // See "token" for details.
+ method: "external" | "token";
+
+ // For method "token", this field is mandatory.
+ // The token MUST begin with the string "secret-token:".
+ // After the auth token has been set (with method "token"),
+ // the value must be provided in a "Authorization: Bearer $token"
+ // header.
+ token?: AccessToken;
+ }
+
+ export interface InstanceReconfigurationMessage {
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // Type of the user (business or individual).
+ // Defaults to 'business'. Should become mandatory field
+ // in the future, left as optional for API compatibility for now.
+ user_type?: string;
+
+ // Merchant email for customer contact.
+ email?: string;
+
+ // Merchant public website.
+ website?: string;
+
+ // Merchant logo.
+ logo?: ImageDataUrl;
+
+ // The merchant's physical address (to be put into contracts).
+ address: Location;
+
+ // The jurisdiction under which the merchant conducts its business
+ // (to be put into contracts).
+ jurisdiction: Location;
+
+ // Use STEFAN curves to determine default fees?
+ // If false, no fees are allowed by default.
+ // Can always be overridden by the frontend on a per-order basis.
+ use_stefan: boolean;
+
+ // If the frontend does NOT specify an execution date, how long should
+ // we tell the exchange to wait to aggregate transactions before
+ // executing the wire transfer? This delay is added to the current
+ // time when we generate the advisory execution time for the exchange.
+ default_wire_transfer_delay: RelativeTime;
+
+ // If the frontend does NOT specify a payment deadline, how long should
+ // offers we make be valid by default?
+ default_pay_delay: RelativeTime;
+ }
+
+ export interface InstancesResponse {
+ // List of instances that are present in the backend (see Instance).
+ instances: Instance[];
+ }
+
+ export interface Instance {
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // Type of the user ("business" or "individual").
+ user_type: string;
+
+ // Merchant public website.
+ website?: string;
+
+ // Merchant logo.
+ logo?: ImageDataUrl;
+
+ // Merchant instance this response is about ($INSTANCE).
+ id: string;
+
+ // Public key of the merchant/instance, in Crockford Base32 encoding.
+ merchant_pub: EddsaPublicKey;
+
+ // List of the payment targets supported by this instance. Clients can
+ // specify the desired payment target in /order requests. Note that
+ // front-ends do not have to support wallets selecting payment targets.
+ payment_targets: string[];
+
+ // Has this instance been deleted (but not purged)?
+ deleted: boolean;
+ }
+
+ export interface QueryInstancesResponse {
+ // Merchant name corresponding to this instance.
+ name: string;
+
+ // Type of the user ("business" or "individual").
+ user_type: string;
+
+ // Merchant email for customer contact.
+ email?: string;
+
+ // Merchant public website.
+ website?: string;
+
+ // Merchant logo.
+ logo?: ImageDataUrl;
+
+ // Public key of the merchant/instance, in Crockford Base32 encoding.
+ merchant_pub: EddsaPublicKey;
+
+ // The merchant's physical address (to be put into contracts).
+ address: Location;
+
+ // The jurisdiction under which the merchant conducts its business
+ // (to be put into contracts).
+ jurisdiction: Location;
+
+ // Use STEFAN curves to determine default fees?
+ // If false, no fees are allowed by default.
+ // Can always be overridden by the frontend on a per-order basis.
+ use_stefan: boolean;
+
+ // If the frontend does NOT specify an execution date, how long should
+ // we tell the exchange to wait to aggregate transactions before
+ // executing the wire transfer? This delay is added to the current
+ // time when we generate the advisory execution time for the exchange.
+ default_wire_transfer_delay: RelativeTime;
+
+ // If the frontend does NOT specify a payment deadline, how long should
+ // offers we make be valid by default?
+ default_pay_delay: RelativeTime;
+
+ // Authentication configuration.
+ // Does not contain the token when token auth is configured.
+ auth: {
+ method: "external" | "token";
+ };
+ }
+
+ export interface AccountKycRedirects {
+ // Array of pending KYCs.
+ pending_kycs: MerchantAccountKycRedirect[];
+
+ // Array of exchanges with no reply.
+ timeout_kycs: ExchangeKycTimeout[];
+ }
+
+ export interface MerchantAccountKycRedirect {
+ // URL that the user should open in a browser to
+ // proceed with the KYC process (as returned
+ // by the exchange's /kyc-check/ endpoint).
+ // Optional, missing if the account is blocked
+ // due to AML and not due to KYC.
+ kyc_url?: string;
+
+ // AML status of the account.
+ aml_status: Integer;
+
+ // Base URL of the exchange this is about.
+ exchange_url: string;
+
+ // Our bank wire account this is about.
+ payto_uri: PaytoString;
+ }
+
+ export interface ExchangeKycTimeout {
+ // Base URL of the exchange this is about.
+ exchange_url: string;
+
+ // Numeric error code indicating errors the exchange
+ // returned, or TALER_EC_INVALID for none.
+ exchange_code: number;
+
+ // HTTP status code returned by the exchange when we asked for
+ // information about the KYC status.
+ // 0 if there was no response at all.
+ exchange_http_status: number;
+ }
+
+ export interface AccountAddDetails {
+ // payto:// URI of the account.
+ payto_uri: PaytoString;
+
+ // URL from where the merchant can download information
+ // about incoming wire transfers to this account.
+ credit_facade_url?: string;
+
+ // Credentials to use when accessing the credit facade.
+ // Never returned on a GET (as this may be somewhat
+ // sensitive data). Can be set in POST
+ // or PATCH requests to update (or delete) credentials.
+ // To really delete credentials, set them to the type: "none".
+ credit_facade_credentials?: FacadeCredentials;
+ }
+
+ export type FacadeCredentials =
+ | NoFacadeCredentials
+ | BasicAuthFacadeCredentials;
+ export interface NoFacadeCredentials {
+ type: "none";
+ }
+ export interface BasicAuthFacadeCredentials {
+ type: "basic";
+
+ // Username to use to authenticate
+ username: string;
+
+ // Password to use to authenticate
+ password: string;
+ }
+ export interface AccountAddResponse {
+ // Hash over the wire details (including over the salt).
+ h_wire: HashCode;
+
+ // Salt used to compute h_wire.
+ salt: HashCode;
+ }
+
+ export interface AccountPatchDetails {
+ // URL from where the merchant can download information
+ // about incoming wire transfers to this account.
+ credit_facade_url?: string;
+
+ // Credentials to use when accessing the credit facade.
+ // Never returned on a GET (as this may be somewhat
+ // sensitive data). Can be set in POST
+ // or PATCH requests to update (or delete) credentials.
+ // To really delete credentials, set them to the type: "none".
+ // If the argument is omitted, the old credentials
+ // are simply preserved.
+ credit_facade_credentials?: FacadeCredentials;
+ }
+
+ export interface AccountsSummaryResponse {
+ // List of accounts that are known for the instance.
+ accounts: BankAccountSummaryEntry[];
+ }
+
+ // TODO: missing in docs
+ export interface BankAccountSummaryEntry {
+ // payto:// URI of the account.
+ payto_uri: PaytoString;
+
+ // Hash over the wire details (including over the salt).
+ h_wire: HashCode;
+ }
+ export interface BankAccountEntry {
+ // payto:// URI of the account.
+ payto_uri: PaytoString;
+
+ // Hash over the wire details (including over the salt).
+ h_wire: HashCode;
+
+ // Salt used to compute h_wire.
+ salt: HashCode;
+
+ // URL from where the merchant can download information
+ // about incoming wire transfers to this account.
+ credit_facade_url?: string;
+
+ // true if this account is active,
+ // false if it is historic.
+ active?: boolean;
+ }
+
+ export interface ProductAddDetail {
+ // Product ID to use.
+ product_id: string;
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions.
+ description_i18n?: { [lang_tag: string]: string };
+
+ // Unit in which the product is measured (liters, kilograms, packages, etc.).
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: AmountString;
+
+ // An optional base64-encoded product image.
+ image?: ImageDataUrl;
+
+ // A list of taxes paid by the merchant for one unit of this product.
+ taxes?: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Identifies where the product is in stock.
+ address?: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age?: Integer;
+ }
+
+ export interface ProductPatchDetail {
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions.
+ description_i18n?: { [lang_tag: string]: string };
+
+ // Unit in which the product is measured (liters, kilograms, packages, etc.).
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: AmountString;
+
+ // An optional base64-encoded product image.
+ image?: ImageDataUrl;
+
+ // A list of taxes paid by the merchant for one unit of this product.
+ taxes?: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Number of units of the product that were lost (spoiled, stolen, etc.).
+ total_lost?: Integer;
+
+ // Identifies where the product is in stock.
+ address?: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age?: Integer;
+ }
+
+ export interface InventorySummaryResponse {
+ // List of products that are present in the inventory.
+ products: InventoryEntry[];
+ }
+
+ export interface InventoryEntry {
+ // Product identifier, as found in the product.
+ product_id: string;
+ // product_serial_id of the product in the database.
+ product_serial: Integer;
+ }
+
+ export interface ProductDetail {
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions.
+ description_i18n: { [lang_tag: string]: string };
+
+ // Unit in which the product is measured (liters, kilograms, packages, etc.).
+ unit: string;
+
+ // The price for one unit of the product. Zero is used
+ // to imply that this product is not sold separately, or
+ // that the price is not fixed, and must be supplied by the
+ // front-end. If non-zero, this price MUST include applicable
+ // taxes.
+ price: AmountString;
+
+ // An optional base64-encoded product image.
+ image: ImageDataUrl;
+
+ // A list of taxes paid by the merchant for one unit of this product.
+ taxes: Tax[];
+
+ // Number of units of the product in stock in sum in total,
+ // including all existing sales ever. Given in product-specific
+ // units.
+ // A value of -1 indicates "infinite" (i.e. for "electronic" books).
+ total_stock: Integer;
+
+ // Number of units of the product that have already been sold.
+ total_sold: Integer;
+
+ // Number of units of the product that were lost (spoiled, stolen, etc.).
+ total_lost: Integer;
+
+ // Identifies where the product is in stock.
+ address: Location;
+
+ // Identifies when we expect the next restocking to happen.
+ next_restock?: Timestamp;
+
+ // Minimum age buyer must have (in years).
+ minimum_age?: Integer;
+ }
+ export interface LockRequest {
+ // UUID that identifies the frontend performing the lock
+ // Must be unique for the lifetime of the lock.
+ lock_uuid: string;
+
+ // How long does the frontend intend to hold the lock?
+ duration: RelativeTime;
+
+ // How many units should be locked?
+ quantity: Integer;
+ }
+
+ export interface PostOrderRequest {
+ // The order must at least contain the minimal
+ // order detail, but can override all.
+ order: Order;
+
+ // If set, the backend will then set the refund deadline to the current
+ // time plus the specified delay. If it's not set, refunds will not be
+ // possible.
+ refund_delay?: RelativeTime;
+
+ // Specifies the payment target preferred by the client. Can be used
+ // to select among the various (active) wire methods supported by the instance.
+ payment_target?: string;
+
+ // Specifies that some products are to be included in the
+ // order from the inventory. For these inventory management
+ // is performed (so the products must be in stock) and
+ // details are completed from the product data of the backend.
+ inventory_products?: MinimalInventoryProduct[];
+
+ // Specifies a lock identifier that was used to
+ // lock a product in the inventory. Only useful if
+ // inventory_products is set. Used in case a frontend
+ // reserved quantities of the individual products while
+ // the shopping cart was being built. Multiple UUIDs can
+ // be used in case different UUIDs were used for different
+ // products (i.e. in case the user started with multiple
+ // shopping sessions that were combined during checkout).
+ lock_uuids?: string[];
+
+ // Should a token for claiming the order be generated?
+ // False can make sense if the ORDER_ID is sufficiently
+ // high entropy to prevent adversarial claims (like it is
+ // if the backend auto-generates one). Default is 'true'.
+ create_token?: boolean;
+
+ // OTP device ID to associate with the order.
+ // This parameter is optional.
+ otp_id?: string;
+ }
+
+ type Order = MinimalOrderDetail | ContractTerms;
+
+ interface MinimalOrderDetail {
+ // Amount to be paid by the customer.
+ amount: AmountString;
+
+ // Short summary of the order.
+ summary: string;
+
+ // See documentation of fulfillment_url in ContractTerms.
+ // Either fulfillment_url or fulfillment_message must be specified.
+ // When creating an order, the fulfillment URL can
+ // contain ${ORDER_ID} which will be substituted with the
+ // order ID of the newly created order.
+ fulfillment_url?: string;
+
+ // See documentation of fulfillment_message in ContractTerms.
+ // Either fulfillment_url or fulfillment_message must be specified.
+ fulfillment_message?: string;
+ }
+
+ interface MinimalInventoryProduct {
+ // Which product is requested (here mandatory!).
+ product_id: string;
+
+ // How many units of the product are requested.
+ quantity: Integer;
+ }
+
+ export interface PostOrderResponse {
+ // Order ID of the response that was just created.
+ order_id: string;
+
+ // Token that authorizes the wallet to claim the order.
+ // Provided only if "create_token" was set to 'true'
+ // in the request.
+ token?: ClaimToken;
+ }
+ export interface OutOfStockResponse {
+ // Product ID of an out-of-stock item.
+ product_id: string;
+
+ // Requested quantity.
+ requested_quantity: Integer;
+
+ // Available quantity (must be below requested_quantity).
+ available_quantity: Integer;
+
+ // When do we expect the product to be again in stock?
+ // Optional, not given if unknown.
+ restock_expected?: Timestamp;
+ }
+
+ export interface OrderHistory {
+ // Timestamp-sorted array of all orders matching the query.
+ // The order of the sorting depends on the sign of delta.
+ orders: OrderHistoryEntry[];
+ }
+ export interface OrderHistoryEntry {
+ // Order ID of the transaction related to this entry.
+ order_id: string;
+
+ // Row ID of the order in the database.
+ row_id: number;
+
+ // When the order was created.
+ timestamp: Timestamp;
+
+ // The amount of money the order is for.
+ amount: AmountString;
+
+ // The summary of the order.
+ summary: string;
+
+ // Whether some part of the order is refundable,
+ // that is the refund deadline has not yet expired
+ // and the total amount refunded so far is below
+ // the value of the original transaction.
+ refundable: boolean;
+
+ // Whether the order has been paid or not.
+ paid: boolean;
+ }
+
+ export type MerchantOrderStatusResponse =
+ | CheckPaymentPaidResponse
+ | CheckPaymentClaimedResponse
+ | CheckPaymentUnpaidResponse;
+ export interface CheckPaymentPaidResponse {
+ // The customer paid for this contract.
+ order_status: "paid";
+
+ // Was the payment refunded (even partially)?
+ refunded: boolean;
+
+ // True if there are any approved refunds that the wallet has
+ // not yet obtained.
+ refund_pending: boolean;
+
+ // Did the exchange wire us the funds?
+ wired: boolean;
+
+ // Total amount the exchange deposited into our bank account
+ // for this contract, excluding fees.
+ deposit_total: AmountString;
+
+ // Numeric error code indicating errors the exchange
+ // encountered tracking the wire transfer for this purchase (before
+ // we even got to specific coin issues).
+ // 0 if there were no issues.
+ exchange_code: number;
+
+ // HTTP status code returned by the exchange when we asked for
+ // information to track the wire transfer for this purchase.
+ // 0 if there were no issues.
+ exchange_http_status: number;
+
+ // Total amount that was refunded, 0 if refunded is false.
+ refund_amount: AmountString;
+
+ // Contract terms.
+ contract_terms: ContractTerms;
+
+ // The wire transfer status from the exchange for this order if
+ // available, otherwise empty array.
+ wire_details: TransactionWireTransfer[];
+
+ // Reports about trouble obtaining wire transfer details,
+ // empty array if no trouble were encountered.
+ wire_reports: TransactionWireReport[];
+
+ // The refund details for this order. One entry per
+ // refunded coin; empty array if there are no refunds.
+ refund_details: RefundDetails[];
+
+ // Status URL, can be used as a redirect target for the browser
+ // to show the order QR code / trigger the wallet.
+ order_status_url: string;
+ }
+ export interface CheckPaymentClaimedResponse {
+ // A wallet claimed the order, but did not yet pay for the contract.
+ order_status: "claimed";
+
+ // Contract terms.
+ contract_terms: ContractTerms;
+ }
+ export interface CheckPaymentUnpaidResponse {
+ // The order was neither claimed nor paid.
+ order_status: "unpaid";
+
+ // URI that the wallet must process to complete the payment.
+ taler_pay_uri: string;
+
+ // when was the order created
+ creation_time: Timestamp;
+
+ // Order summary text.
+ summary: string;
+
+ // Total amount of the order (to be paid by the customer).
+ total_amount: AmountString;
+
+ // Alternative order ID which was paid for already in the same session.
+ // Only given if the same product was purchased before in the same session.
+ already_paid_order_id?: string;
+
+ // Fulfillment URL of an already paid order. Only given if under this
+ // session an already paid order with a fulfillment URL exists.
+ already_paid_fulfillment_url?: string;
+
+ // Status URL, can be used as a redirect target for the browser
+ // to show the order QR code / trigger the wallet.
+ order_status_url: string;
+
+ // We do we NOT return the contract terms here because they may not
+ // exist in case the wallet did not yet claim them.
+ }
+ export interface RefundDetails {
+ // Reason given for the refund.
+ reason: string;
+
+ // Set to true if a refund is still available for the wallet for this payment.
+ pending: boolean;
+
+ // When was the refund approved.
+ timestamp: Timestamp;
+
+ // Total amount that was refunded (minus a refund fee).
+ amount: AmountString;
+ }
+ export interface TransactionWireTransfer {
+ // Responsible exchange.
+ exchange_url: string;
+
+ // 32-byte wire transfer identifier.
+ wtid: Base32;
+
+ // Execution time of the wire transfer.
+ execution_time: Timestamp;
+
+ // Total amount that has been wire transferred
+ // to the merchant.
+ amount: AmountString;
+
+ // Was this transfer confirmed by the merchant via the
+ // POST /transfers API, or is it merely claimed by the exchange?
+ confirmed: boolean;
+ }
+ export interface TransactionWireReport {
+ // Numerical error code.
+ code: number;
+
+ // Human-readable error description.
+ hint: string;
+
+ // Numerical error code from the exchange.
+ exchange_code: number;
+
+ // HTTP status code received from the exchange.
+ exchange_http_status: number;
+
+ // Public key of the coin for which we got the exchange error.
+ coin_pub: CoinPublicKey;
+ }
+
+ export interface ForgetRequest {
+ // Array of valid JSON paths to forgettable fields in the order's
+ // contract terms.
+ fields: string[];
+ }
+
+ export interface RefundRequest {
+ // Amount to be refunded.
+ refund: AmountString;
+
+ // Human-readable refund justification.
+ reason: string;
+ }
+ export interface MerchantRefundResponse {
+ // URL (handled by the backend) that the wallet should access to
+ // trigger refund processing.
+ // taler://refund/...
+ taler_refund_uri: string;
+
+ // Contract hash that a client may need to authenticate an
+ // HTTP request to obtain the above URI in a wallet-friendly way.
+ h_contract: HashCode;
+ }
+
+ export interface TransferInformation {
+ // How much was wired to the merchant (minus fees).
+ credit_amount: AmountString;
+
+ // Raw wire transfer identifier identifying the wire transfer (a base32-encoded value).
+ wtid: WireTransferIdentifierRawP;
+
+ // Target account that received the wire transfer.
+ payto_uri: PaytoString;
+
+ // Base URL of the exchange that made the wire transfer.
+ exchange_url: string;
+ }
+
+ export interface TransferList {
+ // List of all the transfers that fit the filter that we know.
+ transfers: TransferDetails[];
+ }
+ export interface TransferDetails {
+ // How much was wired to the merchant (minus fees).
+ credit_amount: AmountString;
+
+ // Raw wire transfer identifier identifying the wire transfer (a base32-encoded value).
+ wtid: WireTransferIdentifierRawP;
+
+ // Target account that received the wire transfer.
+ payto_uri: PaytoString;
+
+ // Base URL of the exchange that made the wire transfer.
+ exchange_url: string;
+
+ // Serial number identifying the transfer in the merchant backend.
+ // Used for filtering via offset.
+ transfer_serial_id: number;
+
+ // Time of the execution of the wire transfer by the exchange, according to the exchange
+ // Only provided if we did get an answer from the exchange.
+ execution_time?: Timestamp;
+
+ // True if we checked the exchange's answer and are happy with it.
+ // False if we have an answer and are unhappy, missing if we
+ // do not have an answer from the exchange.
+ verified?: boolean;
+
+ // True if the merchant uses the POST /transfers API to confirm
+ // that this wire transfer took place (and it is thus not
+ // something merely claimed by the exchange).
+ confirmed?: boolean;
+ }
+
+ interface ReserveCreateRequest {
+ // Amount that the merchant promises to put into the reserve.
+ initial_balance: AmountString;
+
+ // Exchange the merchant intends to use for rewards.
+ exchange_url: string;
+
+ // Desired wire method, for example "iban" or "x-taler-bank".
+ wire_method: string;
+ }
+ interface ReserveCreateConfirmation {
+ // Public key identifying the reserve.
+ reserve_pub: EddsaPublicKey;
+
+ // Wire accounts of the exchange where to transfer the funds.
+ accounts: TalerExchangeApi.WireAccount[];
+ }
+
+ interface RewardReserveStatus {
+ // Array of all known reserves (possibly empty!).
+ reserves: ReserveStatusEntry[];
+ }
+ interface ReserveStatusEntry {
+ // Public key of the reserve.
+ reserve_pub: EddsaPublicKey;
+
+ // Timestamp when it was established.
+ creation_time: Timestamp;
+
+ // Timestamp when it expires.
+ expiration_time: Timestamp;
+
+ // Initial amount as per reserve creation call.
+ merchant_initial_amount: AmountString;
+
+ // Initial amount as per exchange, 0 if exchange did
+ // not confirm reserve creation yet.
+ exchange_initial_amount: AmountString;
+
+ // Amount picked up so far.
+ pickup_amount: AmountString;
+
+ // Amount approved for rewards that exceeds the pickup_amount.
+ committed_amount: AmountString;
+
+ // Is this reserve active (false if it was deleted but not purged)?
+ active: boolean;
+ }
+
+ interface ReserveDetail {
+ // Timestamp when it was established.
+ creation_time: Timestamp;
+
+ // Timestamp when it expires.
+ expiration_time: Timestamp;
+
+ // Initial amount as per reserve creation call.
+ merchant_initial_amount: AmountString;
+
+ // Initial amount as per exchange, 0 if exchange did
+ // not confirm reserve creation yet.
+ exchange_initial_amount: AmountString;
+
+ // Amount picked up so far.
+ pickup_amount: AmountString;
+
+ // Amount approved for rewards that exceeds the pickup_amount.
+ committed_amount: AmountString;
+
+ // Array of all rewards created by this reserves (possibly empty!).
+ // Only present if asked for explicitly.
+ rewards?: RewardStatusEntry[];
+
+ // Is this reserve active (false if it was deleted but not purged)?
+ active: boolean;
+
+ // Array of wire accounts of the exchange that could
+ // be used to fill the reserve, can be NULL
+ // if the reserve is inactive or was already filled
+ accounts?: TalerExchangeApi.WireAccount[];
+
+ // URL of the exchange hosting the reserve,
+ // NULL if the reserve is inactive
+ exchange_url: string;
+ }
+ interface RewardStatusEntry {
+ // Unique identifier for the reward.
+ reward_id: HashCode;
+
+ // Total amount of the reward that can be withdrawn.
+ total_amount: AmountString;
+
+ // Human-readable reason for why the reward was granted.
+ reason: string;
+ }
+
+ interface RewardCreateRequest {
+ // Amount that the customer should be rewarded.
+ amount: AmountString;
+
+ // Justification for giving the reward.
+ justification: string;
+
+ // URL that the user should be directed to after receiving the reward,
+ // will be included in the reward_token.
+ next_url: string;
+ }
+ interface RewardCreateConfirmation {
+ // Unique reward identifier for the reward that was created.
+ reward_id: HashCode;
+
+ // taler://reward URI for the reward.
+ taler_reward_uri: string;
+
+ // URL that will directly trigger processing
+ // the reward when the browser is redirected to it.
+ reward_status_url: string;
+
+ // When does the reward expire?
+ reward_expiration: Timestamp;
+ }
+
+ interface RewardDetails {
+ // Amount that we authorized for this reward.
+ total_authorized: AmountString;
+
+ // Amount that was picked up by the user already.
+ total_picked_up: AmountString;
+
+ // Human-readable reason given when authorizing the reward.
+ reason: string;
+
+ // Timestamp indicating when the reward is set to expire (may be in the past).
+ expiration: Timestamp;
+
+ // Reserve public key from which the reward is funded.
+ reserve_pub: EddsaPublicKey;
+
+ // Array showing the pickup operations of the wallet (possibly empty!).
+ // Only present if asked for explicitly.
+ pickups?: PickupDetail[];
+ }
+ interface PickupDetail {
+ // Unique identifier for the pickup operation.
+ pickup_id: HashCode;
+
+ // Number of planchets involved.
+ num_planchets: Integer;
+
+ // Total amount requested for this pickup_id.
+ requested_amount: AmountString;
+ }
+
+ interface RewardsResponse {
+ // List of rewards that are present in the backend.
+ rewards: Reward[];
+ }
+ interface Reward {
+ // ID of the reward in the backend database.
+ row_id: number;
+
+ // Unique identifier for the reward.
+ reward_id: HashCode;
+
+ // (Remaining) amount of the reward (including fees).
+ reward_amount: AmountString;
+ }
+
+ export interface OtpDeviceAddDetails {
+ // Device ID to use.
+ otp_device_id: string;
+
+ // Human-readable description for the device.
+ otp_device_description: string;
+
+ // A key encoded with RFC 3548 Base32.
+ // IMPORTANT: This is not using the typical
+ // Taler base32-crockford encoding.
+ // Instead it uses the RFC 3548 encoding to
+ // be compatible with the TOTP standard.
+ otp_key: string;
+
+ // Algorithm for computing the POS confirmation.
+ // "NONE" or 0: No algorithm (no pos confirmation will be generated)
+ // "TOTP_WITHOUT_PRICE" or 1: Without amounts (typical OTP device)
+ // "TOTP_WITH_PRICE" or 2: With amounts (special-purpose OTP device)
+ // The "String" variants are supported @since protocol **v7**.
+ otp_algorithm: Integer | string;
+
+ // Counter for counter-based OTP devices.
+ otp_ctr?: Integer;
+ }
+
+ export interface OtpDevicePatchDetails {
+ // Human-readable description for the device.
+ otp_device_description: string;
+
+ // A key encoded with RFC 3548 Base32.
+ // IMPORTANT: This is not using the typical
+ // Taler base32-crockford encoding.
+ // Instead it uses the RFC 3548 encoding to
+ // be compatible with the TOTP standard.
+ otp_key: string;
+
+ // Algorithm for computing the POS confirmation.
+ otp_algorithm: Integer;
+
+ // Counter for counter-based OTP devices.
+ otp_ctr?: Integer;
+ }
+
+ export interface OtpDeviceSummaryResponse {
+ // Array of devices that are present in our backend.
+ otp_devices: OtpDeviceEntry[];
+ }
+ export interface OtpDeviceEntry {
+ // Device identifier.
+ otp_device_id: string;
+
+ // Human-readable description for the device.
+ device_description: string;
+ }
+
+ export interface OtpDeviceDetails {
+ // Human-readable description for the device.
+ device_description: string;
+
+ // Algorithm for computing the POS confirmation.
+ //
+ // Currently, the following numbers are defined:
+ // 0: None
+ // 1: TOTP without price
+ // 2: TOTP with price
+ otp_algorithm: Integer;
+
+ // Counter for counter-based OTP devices.
+ otp_ctr?: Integer;
+
+ // Current time for time-based OTP devices.
+ // Will match the faketime argument of the
+ // query if one was present, otherwise the current
+ // time at the backend.
+ //
+ // Available since protocol **v10**.
+ otp_timestamp: Integer;
+
+ // Current OTP confirmation string of the device.
+ // Matches exactly the string that would be returned
+ // as part of a payment confirmation for the given
+ // amount and time (so may contain multiple OTP codes).
+ //
+ // If the otp_algorithm is time-based, the code is
+ // returned for the current time, or for the faketime
+ // if a TIMESTAMP query argument was provided by the client.
+ //
+ // When using OTP with counters, the counter is **NOT**
+ // increased merely because this endpoint created
+ // an OTP code (this is a GET request, after all!).
+ //
+ // If the otp_algorithm requires an amount, the
+ // amount argument must be specified in the
+ // query, otherwise the otp_code is not
+ // generated.
+ //
+ // This field is *optional* in the response, as it is
+ // only provided if we could compute it based on the
+ // otp_algorithm and matching client query arguments.
+ //
+ // Available since protocol **v10**.
+ otp_code?: string;
+ }
+ export interface TemplateAddDetails {
+ // Template ID to use.
+ template_id: string;
+
+ // Human-readable description for the template.
+ template_description: string;
+
+ // OTP device ID.
+ // This parameter is optional.
+ otp_id?: string;
+
+ // Additional information in a separate template.
+ template_contract: TemplateContractDetails;
+
+ // Key-value pairs matching a subset of the
+ // fields from template_contract that are
+ // user-editable defaults for this template.
+ // Since protocol **v13**.
+ editable_defaults?: TemplateContractDetailsDefaults;
+
+ // Required currency for payments. Useful if no
+ // amount is specified in the template_contract
+ // but the user should be required to pay in a
+ // particular currency anyway. Merchant backends
+ // may reject requests if the template_contract
+ // or editable_defaults do
+ // specify an amount in a different currency.
+ // This parameter is optional.
+ // Since protocol **v13**.
+ required_currency?: string;
+ }
+ export interface TemplateContractDetails {
+ // Human-readable summary for the template.
+ summary?: string;
+
+ // Required currency for payments to the template.
+ // The user may specify any amount, but it must be
+ // in this currency.
+ // This parameter is optional and should not be present
+ // if "amount" is given.
+ currency?: string;
+
+ // The price is imposed by the merchant and cannot be changed by the customer.
+ // This parameter is optional.
+ amount?: AmountString;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age: Integer;
+
+ // The time the customer need to pay before his order will be deleted.
+ // It is deleted if the customer did not pay and if the duration is over.
+ pay_duration: RelativeTime;
+ }
+
+ export interface TemplateContractDetailsDefaults {
+ summary?: string;
+
+ currency?: string;
+
+ amount?: AmountString;
+
+ minimum_age?: Integer;
+
+ pay_duration?: RelativeTime;
+ }
+ export interface TemplatePatchDetails {
+ // Human-readable description for the template.
+ template_description: string;
+
+ // OTP device ID.
+ // This parameter is optional.
+ otp_id?: string;
+
+ // Additional information in a separate template.
+ template_contract: TemplateContractDetails;
+
+ // Key-value pairs matching a subset of the
+ // fields from template_contract that are
+ // user-editable defaults for this template.
+ // Since protocol **v13**.
+ editable_defaults?: TemplateContractDetailsDefaults;
+
+ // Required currency for payments. Useful if no
+ // amount is specified in the template_contract
+ // but the user should be required to pay in a
+ // particular currency anyway. Merchant backends
+ // may reject requests if the template_contract
+ // or editable_defaults do
+ // specify an amount in a different currency.
+ // This parameter is optional.
+ // Since protocol **v13**.
+ required_currency?: string;
+ }
+
+ export interface TemplateSummaryResponse {
+ // List of templates that are present in our backend.
+ templates: TemplateEntry[];
+ }
+
+ export interface TemplateEntry {
+ // Template identifier, as found in the template.
+ template_id: string;
+
+ // Human-readable description for the template.
+ template_description: string;
+ }
+
+ export interface WalletTemplateDetails {
+ // Hard-coded information about the contrac terms
+ // for this template.
+ template_contract: TemplateContractDetails;
+
+ // Key-value pairs matching a subset of the
+ // fields from template_contract that are
+ // user-editable defaults for this template.
+ // Since protocol **v13**.
+ editable_defaults?: TemplateContractDetailsDefaults;
+
+ // Required currency for payments. Useful if no
+ // amount is specified in the template_contract
+ // but the user should be required to pay in a
+ // particular currency anyway. Merchant backends
+ // may reject requests if the template_contract
+ // or editable_defaults do
+ // specify an amount in a different currency.
+ // This parameter is optional.
+ // Since protocol **v13**.
+ required_currency?: string;
+ }
+
+ export interface TemplateDetails {
+ // Human-readable description for the template.
+ template_description: string;
+
+ // OTP device ID.
+ // This parameter is optional.
+ otp_id?: string;
+
+ // Additional information in a separate template.
+ template_contract: TemplateContractDetails;
+
+ // Key-value pairs matching a subset of the
+ // fields from template_contract that are
+ // user-editable defaults for this template.
+ // Since protocol **v13**.
+ editable_defaults?: TemplateContractDetailsDefaults;
+
+ // Required currency for payments. Useful if no
+ // amount is specified in the template_contract
+ // but the user should be required to pay in a
+ // particular currency anyway. Merchant backends
+ // may reject requests if the template_contract
+ // or editable_defaults do
+ // specify an amount in a different currency.
+ // This parameter is optional.
+ // Since protocol **v13**.
+ required_currency?: string;
+ }
+ export interface UsingTemplateDetails {
+ // Summary of the template
+ summary?: string;
+
+ // The amount entered by the customer.
+ amount?: AmountString;
+ }
+
+ export interface WebhookAddDetails {
+ // Webhook ID to use.
+ webhook_id: string;
+
+ // The event of the webhook: why the webhook is used.
+ event_type: string;
+
+ // URL of the webhook where the customer will be redirected.
+ url: string;
+
+ // Method used by the webhook
+ http_method: string;
+
+ // Header template of the webhook
+ header_template?: string;
+
+ // Body template by the webhook
+ body_template?: string;
+ }
+
+ export interface WebhookPatchDetails {
+ // The event of the webhook: why the webhook is used.
+ event_type: string;
+
+ // URL of the webhook where the customer will be redirected.
+ url: string;
+
+ // Method used by the webhook
+ http_method: string;
+
+ // Header template of the webhook
+ header_template?: string;
+
+ // Body template by the webhook
+ body_template?: string;
+ }
+
+ export interface WebhookSummaryResponse {
+ // Return webhooks that are present in our backend.
+ webhooks: WebhookEntry[];
+ }
+
+ export interface WebhookEntry {
+ // Webhook identifier, as found in the webhook.
+ webhook_id: string;
+
+ // The event of the webhook: why the webhook is used.
+ event_type: string;
+ }
+
+ export interface WebhookDetails {
+ // The event of the webhook: why the webhook is used.
+ event_type: string;
+
+ // URL of the webhook where the customer will be redirected.
+ url: string;
+
+ // Method used by the webhook
+ http_method: string;
+
+ // Header template of the webhook
+ header_template?: string;
+
+ // Body template by the webhook
+ body_template?: string;
+ }
+
+ export interface TokenFamilyCreateRequest {
+ // Identifier for the token family consisting of unreserved characters
+ // according to RFC 3986.
+ slug: string;
+
+ // Human-readable name for the token family.
+ name: string;
+
+ // Human-readable description for the token family.
+ description: string;
+
+ // Optional map from IETF BCP 47 language tags to localized descriptions.
+ description_i18n?: { [lang_tag: string]: string };
+
+ // Start time of the token family's validity period.
+ // If not specified, merchant backend will use the current time.
+ valid_after?: Timestamp;
+
+ // End time of the token family's validity period.
+ valid_before: Timestamp;
+
+ // Validity duration of an issued token.
+ duration: RelativeTime;
+
+ // Kind of the token family.
+ kind: TokenFamilyKind;
+ }
+
+ export enum TokenFamilyKind {
+ Discount = "discount",
+ Subscription = "subscription",
+ }
+
+ export interface TokenFamilyUpdateRequest {
+ // Human-readable name for the token family.
+ name: string;
+
+ // Human-readable description for the token family.
+ description: string;
+
+ // Optional map from IETF BCP 47 language tags to localized descriptions.
+ description_i18n: { [lang_tag: string]: string };
+
+ // Start time of the token family's validity period.
+ valid_after: Timestamp;
+
+ // End time of the token family's validity period.
+ valid_before: Timestamp;
+
+ // Validity duration of an issued token.
+ duration: RelativeTime;
+ }
+
+ export interface TokenFamiliesList {
+ // All configured token families of this instance.
+ token_families: TokenFamilySummary[];
+ }
+
+ export interface TokenFamilySummary {
+ // Identifier for the token family consisting of unreserved characters
+ // according to RFC 3986.
+ slug: string;
+
+ // Human-readable name for the token family.
+ name: string;
+
+ // Start time of the token family's validity period.
+ valid_after: Timestamp;
+
+ // End time of the token family's validity period.
+ valid_before: Timestamp;
+
+ // Kind of the token family.
+ kind: TokenFamilyKind;
+ }
+
+ export interface TokenFamilyDetails {
+ // Identifier for the token family consisting of unreserved characters
+ // according to RFC 3986.
+ slug: string;
+
+ // Human-readable name for the token family.
+ name: string;
+
+ // Human-readable description for the token family.
+ description: string;
+
+ // Optional map from IETF BCP 47 language tags to localized descriptions.
+ description_i18n?: { [lang_tag: string]: string };
+
+ // Start time of the token family's validity period.
+ valid_after: Timestamp;
+
+ // End time of the token family's validity period.
+ valid_before: Timestamp;
+
+ // Validity duration of an issued token.
+ duration: RelativeTime;
+
+ // Kind of the token family.
+ kind: TokenFamilyKind;
+
+ // How many tokens have been issued for this family.
+ issued: Integer;
+
+ // How many tokens have been redeemed for this family.
+ redeemed: Integer;
+ }
+ export interface ContractTerms {
+ // Human-readable description of the whole purchase.
+ summary: string;
+
+ // Map from IETF BCP 47 language tags to localized summaries.
+ summary_i18n?: { [lang_tag: string]: string };
+
+ // Unique, free-form identifier for the proposal.
+ // Must be unique within a merchant instance.
+ // For merchants that do not store proposals in their DB
+ // before the customer paid for them, the order_id can be used
+ // by the frontend to restore a proposal from the information
+ // encoded in it (such as a short product identifier and timestamp).
+ order_id: string;
+
+ // Total price for the transaction.
+ // The exchange will subtract deposit fees from that amount
+ // before transferring it to the merchant.
+ amount: AmountString;
+
+ // URL where the same contract could be ordered again (if
+ // available). Returned also at the public order endpoint
+ // for people other than the actual buyer (hence public,
+ // in case order IDs are guessable).
+ public_reorder_url?: string;
+
+ // URL that will show that the order was successful after
+ // it has been paid for. Optional. When POSTing to the
+ // merchant, the placeholder "${ORDER_ID}" will be
+ // replaced with the actual order ID (useful if the
+ // order ID is generated server-side and needs to be
+ // in the URL).
+ // Note that this placeholder can only be used once.
+ // Either fulfillment_url or fulfillment_message must be specified.
+ fulfillment_url?: string;
+
+ // Message shown to the customer after paying for the order.
+ // Either fulfillment_url or fulfillment_message must be specified.
+ fulfillment_message?: string;
+
+ // Map from IETF BCP 47 language tags to localized fulfillment
+ // messages.
+ fulfillment_message_i18n?: { [lang_tag: string]: string };
+
+ // Maximum total deposit fee accepted by the merchant for this contract.
+ // Overrides defaults of the merchant instance.
+ max_fee: AmountString;
+
+ // List of products that are part of the purchase (see Product).
+ products: Product[];
+
+ // Time when this contract was generated.
+ timestamp: Timestamp;
+
+ // After this deadline has passed, no refunds will be accepted.
+ refund_deadline: Timestamp;
+
+ // After this deadline, the merchant won't accept payments for the contract.
+ pay_deadline: Timestamp;
+
+ // Transfer deadline for the exchange. Must be in the
+ // deposit permissions of coins used to pay for this order.
+ wire_transfer_deadline: Timestamp;
+
+ // Merchant's public key used to sign this proposal; this information
+ // is typically added by the backend. Note that this can be an ephemeral key.
+ merchant_pub: EddsaPublicKey;
+
+ // Base URL of the (public!) merchant backend API.
+ // Must be an absolute URL that ends with a slash.
+ merchant_base_url: string;
+
+ // More info about the merchant, see below.
+ merchant: Merchant;
+
+ // The hash of the merchant instance's wire details.
+ h_wire: HashCode;
+
+ // Wire transfer method identifier for the wire method associated with h_wire.
+ // The wallet may only select exchanges via a matching auditor if the
+ // exchange also supports this wire method.
+ // The wire transfer fees must be added based on this wire transfer method.
+ wire_method: string;
+
+ // Exchanges that the merchant accepts even if it does not accept any auditors that audit them.
+ exchanges: Exchange[];
+
+ // Delivery location for (all!) products.
+ delivery_location?: Location;
+
+ // Time indicating when the order should be delivered.
+ // May be overwritten by individual products.
+ delivery_date?: Timestamp;
+
+ // Nonce generated by the wallet and echoed by the merchant
+ // in this field when the proposal is generated.
+ nonce: string;
+
+ // Specifies for how long the wallet should try to get an
+ // automatic refund for the purchase. If this field is
+ // present, the wallet should wait for a few seconds after
+ // the purchase and then automatically attempt to obtain
+ // a refund. The wallet should probe until "delay"
+ // after the payment was successful (i.e. via long polling
+ // or via explicit requests with exponential back-off).
+ //
+ // In particular, if the wallet is offline
+ // at that time, it MUST repeat the request until it gets
+ // one response from the merchant after the delay has expired.
+ // If the refund is granted, the wallet MUST automatically
+ // recover the payment. This is used in case a merchant
+ // knows that it might be unable to satisfy the contract and
+ // desires for the wallet to attempt to get the refund without any
+ // customer interaction. Note that it is NOT an error if the
+ // merchant does not grant a refund.
+ auto_refund?: RelativeTime;
+
+ // Extra data that is only interpreted by the merchant frontend.
+ // Useful when the merchant needs to store extra information on a
+ // contract without storing it separately in their database.
+ extra?: any;
+
+ // Minimum age the buyer must have (in years). Default is 0.
+ // This value is at least as large as the maximum over all
+ // minimum age requirements of the products in this contract.
+ // It might also be set independent of any product, due to
+ // legal requirements.
+ minimum_age?: Integer;
+ }
+
+ export interface Product {
+ // Merchant-internal identifier for the product.
+ product_id?: string;
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions.
+ description_i18n?: { [lang_tag: string]: string };
+
+ // The number of units of the product to deliver to the customer.
+ quantity?: Integer;
+
+ // Unit in which the product is measured (liters, kilograms, packages, etc.).
+ unit?: string;
+
+ // The price of the product; this is the total price for quantity times unit of this product.
+ price?: AmountString;
+
+ // An optional base64-encoded product image.
+ image?: ImageDataUrl;
+
+ // A list of taxes paid by the merchant for this product. Can be empty.
+ taxes?: Tax[];
+
+ // Time indicating when this product should be delivered.
+ delivery_date?: Timestamp;
+ }
+
+ export interface Tax {
+ // The name of the tax.
+ name: string;
+
+ // Amount paid in tax.
+ tax: AmountString;
+ }
+ export interface Merchant {
+ // The merchant's legal name of business.
+ name: string;
+
+ // Label for a location with the business address of the merchant.
+ email?: string;
+
+ // Label for a location with the business address of the merchant.
+ website?: string;
+
+ // An optional base64-encoded product image.
+ logo?: ImageDataUrl;
+
+ // Label for a location with the business address of the merchant.
+ address?: Location;
+
+ // Label for a location that denotes the jurisdiction for disputes.
+ // Some of the typical fields for a location (such as a street address) may be absent.
+ jurisdiction?: Location;
+ }
+ // Delivery location, loosely modeled as a subset of
+ // ISO20022's PostalAddress25.
+ export interface Location {
+ // Nation with its own government.
+ country?: string;
+
+ // Identifies a subdivision of a country such as state, region, county.
+ country_subdivision?: string;
+
+ // Identifies a subdivision within a country sub-division.
+ district?: string;
+
+ // Name of a built-up area, with defined boundaries, and a local government.
+ town?: string;
+
+ // Specific location name within the town.
+ town_location?: string;
+
+ // Identifier consisting of a group of letters and/or numbers that
+ // is added to a postal address to assist the sorting of mail.
+ post_code?: string;
+
+ // Name of a street or thoroughfare.
+ street?: string;
+
+ // Name of the building or house.
+ building_name?: string;
+
+ // Number that identifies the position of a building on a street.
+ building_number?: string;
+
+ // Free-form address lines, should not exceed 7 elements.
+ address_lines?: string[];
+ }
+ interface Auditor {
+ // Official name.
+ name: string;
+
+ // Auditor's public key.
+ auditor_pub: EddsaPublicKey;
+
+ // Base URL of the auditor.
+ url: string;
+ }
+ export interface Exchange {
+ // The exchange's base URL.
+ url: string;
+
+ // How much would the merchant like to use this exchange.
+ // The wallet should use a suitable exchange with high
+ // priority. The following priority values are used, but
+ // it should be noted that they are NOT in any way normative.
+ //
+ // 0: likely it will not work (recently seen with account
+ // restriction that would be bad for this merchant)
+ // 512: merchant does not know, might be down (merchant
+ // did not yet get /wire response).
+ // 1024: good choice (recently confirmed working)
+ priority: Integer;
+
+ // Master public key of the exchange.
+ master_pub: EddsaPublicKey;
+ }
+}
diff --git a/packages/taler-util/src/http-client/utils.ts b/packages/taler-util/src/http-client/utils.ts
new file mode 100644
index 000000000..bf186ce46
--- /dev/null
+++ b/packages/taler-util/src/http-client/utils.ts
@@ -0,0 +1,116 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { base64FromArrayBuffer } from "../base64.js";
+import { encodeCrock, getRandomBytes, stringToBytes } from "../taler-crypto.js";
+import { AccessToken, LongPollParams, PaginationParams } from "./types.js";
+
+/**
+ * Helper function to generate the "Authorization" HTTP header.
+ */
+export function makeBasicAuthHeader(
+ username: string,
+ password: string,
+): string {
+ const auth = `${username}:${password}`;
+ const authEncoded: string = base64FromArrayBuffer(stringToBytes(auth));
+ return `Basic ${authEncoded}`;
+}
+
+/**
+ * rfc8959
+ * @param token
+ * @returns
+ */
+export function makeBearerTokenAuthHeader(token: AccessToken): string {
+ return `Bearer ${token}`;
+}
+
+/**
+ * https://bugs.gnunet.org/view.php?id=7949
+ */
+export function addPaginationParams(url: URL, pagination?: PaginationParams) {
+ if (!pagination) return;
+ if (pagination.offset) {
+ url.searchParams.set("start", pagination.offset);
+ }
+ const order = !pagination || pagination.order === "asc" ? 1 : -1;
+ const limit =
+ !pagination || !pagination.limit || pagination.limit === 0
+ ? 5
+ : Math.abs(pagination.limit);
+ //always send delta
+ url.searchParams.set("delta", String(order * limit));
+}
+
+export function addMerchantPaginationParams(
+ url: URL,
+ pagination?: PaginationParams,
+) {
+ if (!pagination) return;
+ if (pagination.offset) {
+ url.searchParams.set("offset", pagination.offset);
+ }
+ const order = !pagination || pagination.order === "asc" ? 1 : -1;
+ const limit =
+ !pagination || !pagination.limit || pagination.limit === 0
+ ? 5
+ : Math.abs(pagination.limit);
+ //always send delta
+ url.searchParams.set("limit", String(order * limit));
+}
+
+export function addLongPollingParam(url: URL, param?: LongPollParams) {
+ if (!param) return;
+ if (param.timeoutMs) {
+ url.searchParams.set("long_poll_ms", String(param.timeoutMs));
+ }
+}
+
+export interface CacheEvictor<T> {
+ notifySuccess: (op: T) => Promise<void>;
+}
+
+export const nullEvictor: CacheEvictor<unknown> = {
+ notifySuccess: () => Promise.resolve(),
+};
+
+export class IdempotencyRetry {
+ public readonly uid: string;
+ public readonly timesLeft: number;
+ public readonly maxTries: number;
+
+ private constructor(timesLeft: number, maxTimesLeft: number) {
+ this.timesLeft = timesLeft;
+ this.maxTries = maxTimesLeft;
+ this.uid = encodeCrock(getRandomBytes(32))
+ }
+
+ static tryFiveTimes() {
+ return new IdempotencyRetry(5, 5)
+ }
+
+ next(): IdempotencyRetry | undefined {
+ const left = this.timesLeft -1
+ if (left <= 0) {
+ return undefined
+ }
+ return new IdempotencyRetry(left, this.maxTries);
+ }
+}
diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts
new file mode 100644
index 000000000..cc75debd5
--- /dev/null
+++ b/packages/taler-util/src/http-common.ts
@@ -0,0 +1,485 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ SPDX-License-Identifier: AGPL3.0-or-later
+*/
+
+import type { CancellationToken } from "./CancellationToken.js";
+import { Codec } from "./codec.js";
+import { j2s } from "./helpers.js";
+import {
+ TalerError,
+ base64FromArrayBuffer,
+ makeErrorDetail,
+ stringToBytes,
+} from "./index.js";
+import { Logger } from "./logging.js";
+import { TalerErrorCode } from "./taler-error-codes.js";
+import { AbsoluteTime, Duration } from "./time.js";
+import { TalerErrorDetail } from "./wallet-types.js";
+
+const textEncoder = new TextEncoder();
+
+const logger = new Logger("http.ts");
+
+/**
+ * An HTTP response that is returned by all request methods of this library.
+ */
+export interface HttpResponse {
+ requestUrl: string;
+ requestMethod: string;
+ status: number;
+ headers: Headers;
+ json(): Promise<any>;
+ text(): Promise<string>;
+ bytes(): Promise<ArrayBuffer>;
+}
+
+export const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
+
+export interface HttpRequestOptions {
+ method?: "POST" | "PATCH" | "PUT" | "GET" | "DELETE";
+ headers?: { [name: string]: string | undefined };
+
+ /**
+ * Timeout after which the request should be aborted.
+ */
+ timeout?: Duration;
+
+ /**
+ * Cancellation token that should abort the request when
+ * cancelled.
+ */
+ cancellationToken?: CancellationToken;
+
+ body?: string | ArrayBuffer | object;
+
+ /**
+ * How to handle redirects.
+ * Same semantics as WHATWG fetch.
+ */
+ redirect?: "follow" | "error" | "manual";
+}
+
+/**
+ * Headers, roughly modeled after the fetch API's headers object.
+ */
+export class Headers {
+ private headerMap = new Map<string, string>();
+
+ get(name: string): string | null {
+ const r = this.headerMap.get(name.toLowerCase());
+ if (r) {
+ return r;
+ }
+ return null;
+ }
+
+ set(name: string, value: string): void {
+ const normalizedName = name.toLowerCase();
+ const existing = this.headerMap.get(normalizedName);
+ if (existing !== undefined) {
+ this.headerMap.set(normalizedName, existing + "," + value);
+ } else {
+ this.headerMap.set(normalizedName, value);
+ }
+ }
+
+ toJSON(): any {
+ const m: Record<string, string> = {};
+ this.headerMap.forEach((v, k) => (m[k] = v));
+ return m;
+ }
+}
+
+/**
+ * Interface for the HTTP request library used by the wallet.
+ *
+ * The request library is bundled into an interface to make mocking and
+ * request tunneling easy.
+ */
+export interface HttpRequestLibrary {
+ /**
+ * Make an HTTP POST request with a JSON body.
+ */
+ fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>;
+}
+
+type TalerErrorResponse = {
+ code: number;
+} & unknown;
+
+type ResponseOrError<T> =
+ | { isError: false; response: T }
+ | { isError: true; talerErrorResponse: TalerErrorResponse };
+
+/**
+ * Read Taler error details from an HTTP response.
+ */
+export async function readTalerErrorResponse(
+ httpResponse: HttpResponse,
+): Promise<TalerErrorDetail> {
+ const contentType = httpResponse.headers.get("content-type");
+ if (contentType !== "application/json") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ contentType: contentType || "<null>",
+ },
+ "Error response did not even contain JSON. The request URL might be wrong or the service might be unavailable.",
+ );
+ }
+ let errJson;
+ try {
+ errJson = await httpResponse.json();
+ } catch (e: any) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ validationError: e.toString(),
+ },
+ "Couldn't parse JSON format from error response",
+ );
+ }
+
+ const talerErrorCode = errJson.code;
+ if (typeof talerErrorCode !== "number") {
+ logger.warn(
+ `malformed error response (status ${httpResponse.status}): ${j2s(
+ errJson,
+ )}`,
+ );
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ },
+ "Error response did not contain error code",
+ );
+ }
+ return errJson;
+}
+
+export async function readUnexpectedResponseDetails(
+ httpResponse: HttpResponse,
+): Promise<TalerErrorDetail> {
+ let errJson;
+ try {
+ errJson = await httpResponse.json();
+ } catch (e: any) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ validationError: e.toString(),
+ },
+ "Couldn't parse JSON format from error response",
+ );
+ }
+ const talerErrorCode = errJson.code;
+ if (typeof talerErrorCode !== "number") {
+ return makeErrorDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ },
+ "Error response did not contain error code",
+ );
+ }
+ return makeErrorDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ errorResponse: errJson,
+ },
+ `Unexpected HTTP status (${httpResponse.status}) in response`,
+ );
+}
+
+export async function readSuccessResponseJsonOrErrorCode<T>(
+ httpResponse: HttpResponse,
+ codec: Codec<T>,
+): Promise<ResponseOrError<T>> {
+ if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
+ return {
+ isError: true,
+ talerErrorResponse: await readTalerErrorResponse(httpResponse),
+ };
+ }
+ let respJson;
+ try {
+ respJson = await httpResponse.json();
+ } catch (e: any) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ validationError: e.toString(),
+ },
+ "Couldn't parse JSON format from response",
+ );
+ }
+ let parsedResponse: T;
+ try {
+ parsedResponse = codec.decode(respJson);
+ } catch (e: any) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ validationError: e.toString(),
+ },
+ "Response invalid",
+ );
+ }
+ return {
+ isError: false,
+ response: parsedResponse,
+ };
+}
+
+type HttpErrorDetails = {
+ requestUrl: string;
+ requestMethod: string;
+ httpStatusCode: number;
+};
+
+export function getHttpResponseErrorDetails(
+ httpResponse: HttpResponse,
+): HttpErrorDetails {
+ return {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ };
+}
+
+export function throwUnexpectedRequestError(
+ httpResponse: HttpResponse,
+ talerErrorResponse: TalerErrorResponse,
+): never {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ errorResponse: talerErrorResponse,
+ },
+ `Unexpected HTTP status ${httpResponse.status} in response`,
+ );
+}
+
+export async function readSuccessResponseJsonOrThrow<T>(
+ httpResponse: HttpResponse,
+ codec: Codec<T>,
+): Promise<T> {
+ const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codec);
+ if (!r.isError) {
+ return r.response;
+ }
+ throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
+}
+
+export async function expectSuccessResponseOrThrow<T>(
+ httpResponse: HttpResponse,
+): Promise<void> {
+ if (httpResponse.status >= 200 && httpResponse.status <= 299) {
+ return;
+ }
+ const errResp = await readTalerErrorResponse(httpResponse);
+ throwUnexpectedRequestError(httpResponse, errResp);
+}
+
+export async function readSuccessResponseTextOrErrorCode<T>(
+ httpResponse: HttpResponse,
+): Promise<ResponseOrError<string>> {
+ if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
+ let errJson;
+ try {
+ errJson = await httpResponse.json();
+ } catch (e: any) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ validationError: e.toString(),
+ },
+ "Couldn't parse JSON format from error response",
+ );
+ }
+
+ const talerErrorCode = errJson.code;
+ if (typeof talerErrorCode !== "number") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ httpStatusCode: httpResponse.status,
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ },
+ "Error response did not contain error code",
+ );
+ }
+ return {
+ isError: true,
+ talerErrorResponse: errJson,
+ };
+ }
+ const respJson = await httpResponse.text();
+ return {
+ isError: false,
+ response: respJson,
+ };
+}
+
+export async function checkSuccessResponseOrThrow(
+ httpResponse: HttpResponse,
+): Promise<void> {
+ if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
+ let errJson;
+ try {
+ errJson = await httpResponse.json();
+ } catch (e: any) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ httpStatusCode: httpResponse.status,
+ validationError: e.toString(),
+ },
+ "Couldn't parse JSON format from error response",
+ );
+ }
+
+ const talerErrorCode = errJson.code;
+ if (typeof talerErrorCode !== "number") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ httpStatusCode: httpResponse.status,
+ requestUrl: httpResponse.requestUrl,
+ requestMethod: httpResponse.requestMethod,
+ },
+ "Error response did not contain error code",
+ );
+ }
+ throwUnexpectedRequestError(httpResponse, errJson);
+ }
+}
+
+export async function readSuccessResponseTextOrThrow<T>(
+ httpResponse: HttpResponse,
+): Promise<string> {
+ const r = await readSuccessResponseTextOrErrorCode(httpResponse);
+ if (!r.isError) {
+ return r.response;
+ }
+ throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
+}
+
+/**
+ * Get the timestamp at which the response's content is considered expired.
+ */
+export function getExpiry(
+ httpResponse: HttpResponse,
+ opt: { minDuration?: Duration },
+): AbsoluteTime {
+ const expiryDateMs = new Date(
+ httpResponse.headers.get("expiry") ?? "",
+ ).getTime();
+ let t: AbsoluteTime;
+ if (Number.isNaN(expiryDateMs)) {
+ t = AbsoluteTime.now();
+ } else {
+ t = AbsoluteTime.fromMilliseconds(expiryDateMs);
+ }
+ if (opt.minDuration) {
+ const t2 = AbsoluteTime.addDuration(AbsoluteTime.now(), opt.minDuration);
+ return AbsoluteTime.max(t, t2);
+ }
+ return t;
+}
+
+export interface HttpLibArgs {
+ enableThrottling?: boolean;
+ /**
+ * Only allow HTTPS connections, not plain http.
+ */
+ requireTls?: boolean;
+ printAsCurl?: boolean;
+}
+
+export function encodeBody(body: any): ArrayBuffer {
+ if (body == null) {
+ return new ArrayBuffer(0);
+ }
+ if (typeof body === "string") {
+ return textEncoder.encode(body).buffer;
+ } else if (ArrayBuffer.isView(body)) {
+ return body.buffer;
+ } else if (body instanceof ArrayBuffer) {
+ return body;
+ } else if (typeof body === "object") {
+ return textEncoder.encode(JSON.stringify(body)).buffer;
+ }
+ throw new TypeError("unsupported request body type");
+}
+
+export function getDefaultHeaders(method: string): Record<string, string> {
+ const headers: Record<string, string> = {};
+
+ if (method === "POST" || method === "PUT" || method === "PATCH") {
+ // Default to JSON if we have a body
+ headers["Content-Type"] = "application/json";
+ }
+
+ headers["Accept"] = "application/json";
+
+ return headers;
+}
+
+/**
+ * Helper function to generate the "Authorization" HTTP header.
+ */
+export function makeBasicAuthHeader(
+ username: string,
+ password: string,
+): string {
+ const auth = `${username}:${password}`;
+ const authEncoded: string = base64FromArrayBuffer(stringToBytes(auth));
+ return `Basic ${authEncoded}`;
+}
diff --git a/packages/taler-util/src/http-impl.missing.ts b/packages/taler-util/src/http-impl.missing.ts
new file mode 100644
index 000000000..6ae6b93ec
--- /dev/null
+++ b/packages/taler-util/src/http-impl.missing.ts
@@ -0,0 +1,38 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ SPDX-License-Identifier: AGPL3.0-or-later
+*/
+
+/**
+ * Imports.
+ */
+import {
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+} from "./http.js";
+
+/**
+ * Implementation of the HTTP request library interface for node.
+ */
+export class HttpLibImpl implements HttpRequestLibrary {
+ fetch(
+ url: string,
+ opt?: HttpRequestOptions | undefined,
+ ): Promise<HttpResponse> {
+ throw new Error("Method not implemented.");
+ }
+}
diff --git a/packages/taler-util/src/http-impl.node.d.ts b/packages/taler-util/src/http-impl.node.d.ts
new file mode 100644
index 000000000..771dd991c
--- /dev/null
+++ b/packages/taler-util/src/http-impl.node.d.ts
@@ -0,0 +1,25 @@
+import { HttpLibArgs } from "./http-common.js";
+import {
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+} from "./http.js";
+/**
+ * Implementation of the HTTP request library interface for node.
+ */
+export declare class HttpLibImpl implements HttpRequestLibrary {
+ private throttle;
+ private throttlingEnabled;
+ constructor(args?: HttpLibArgs);
+ /**
+ * Set whether requests should be throttled.
+ */
+ setThrottling(enabled: boolean): void;
+ fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>;
+ get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>;
+ postJson(
+ url: string,
+ body: any,
+ opt?: HttpRequestOptions,
+ ): Promise<HttpResponse>;
+}
diff --git a/packages/taler-util/src/http-impl.node.ts b/packages/taler-util/src/http-impl.node.ts
new file mode 100644
index 000000000..8606bc451
--- /dev/null
+++ b/packages/taler-util/src/http-impl.node.ts
@@ -0,0 +1,324 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ SPDX-License-Identifier: AGPL3.0-or-later
+*/
+
+/**
+ * Imports.
+ */
+import type { FollowOptions, RedirectableRequest } from "follow-redirects";
+import followRedirects from "follow-redirects";
+import type { ClientRequest, IncomingMessage } from "node:http";
+import { RequestOptions } from "node:http";
+import * as net from "node:net";
+import { TalerError } from "./errors.js";
+import { HttpLibArgs, encodeBody, getDefaultHeaders } from "./http-common.js";
+import {
+ DEFAULT_REQUEST_TIMEOUT_MS,
+ Headers,
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+} from "./http.js";
+import {
+ Logger,
+ RequestThrottler,
+ TalerErrorCode,
+ URL,
+ typedArrayConcat,
+} from "./index.js";
+
+const http = followRedirects.http;
+const https = followRedirects.https;
+
+// Work around a node v20.0.0, v20.1.0, and v20.2.0 bug. The issue was fixed
+// in v20.3.0.
+// https://github.com/nodejs/node/issues/47822#issuecomment-1564708870
+// Safe to remove once support for Node v20 is dropped.
+if (
+ // check for `node` in case we want to use this in "exotic" JS envs
+ process.versions.node &&
+ process.versions.node.match(/20\.[0-2]\.0/)
+) {
+ //@ts-ignore
+ net.setDefaultAutoSelectFamily(false);
+}
+
+const logger = new Logger("http-impl.node.ts");
+
+const textDecoder = new TextDecoder();
+let SHOW_CURL_HTTP_REQUEST = false;
+export function setPrintHttpRequestAsCurl(b: boolean) {
+ SHOW_CURL_HTTP_REQUEST = b;
+}
+
+/**
+ * Implementation of the HTTP request library interface for node.
+ */
+export class HttpLibImpl implements HttpRequestLibrary {
+ private throttle = new RequestThrottler();
+ private throttlingEnabled = true;
+ private requireTls = false;
+
+ constructor(args?: HttpLibArgs) {
+ this.throttlingEnabled = args?.enableThrottling ?? true;
+ this.requireTls = args?.requireTls ?? false;
+ }
+
+ /**
+ * Set whether requests should be throttled.
+ */
+ setThrottling(enabled: boolean): void {
+ this.throttlingEnabled = enabled;
+ }
+
+ async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
+ const method = opt?.method?.toUpperCase() ?? "GET";
+
+ logger.trace(`Requesting ${method} ${url}`);
+
+ const parsedUrl = new URL(url);
+ if (this.throttlingEnabled && this.throttle.applyThrottle(url)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
+ {
+ requestMethod: method,
+ requestUrl: url,
+ throttleStats: this.throttle.getThrottleStats(url),
+ },
+ `request to origin ${parsedUrl.origin} was throttled`,
+ );
+ }
+ if (this.requireTls && parsedUrl.protocol !== "https:") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_NETWORK_ERROR,
+ {
+ requestMethod: method,
+ requestUrl: url,
+ },
+ `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`,
+ );
+ }
+ let timeoutMs: number | undefined;
+ if (typeof opt?.timeout?.d_ms === "number") {
+ timeoutMs = opt.timeout.d_ms;
+ } else {
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
+ }
+
+ const requestHeadersMap = getDefaultHeaders(method);
+ if (opt?.headers) {
+ Object.entries(opt?.headers).forEach(([key, value]) => {
+ if (value === undefined) return;
+ requestHeadersMap[key] = value
+ })
+ }
+ logger.trace(`request timeout ${timeoutMs} ms`);
+
+ let reqBody: ArrayBuffer | undefined;
+
+ if (
+ opt?.method == "POST" ||
+ opt?.method == "PATCH" ||
+ opt?.method == "PUT"
+ ) {
+ reqBody = encodeBody(opt.body);
+ }
+
+ let path = parsedUrl.pathname;
+ if (parsedUrl.search != null) {
+ path += parsedUrl.search;
+ }
+
+ let protocol: string;
+ if (parsedUrl.protocol === "https:") {
+ protocol = "https:";
+ } else if (parsedUrl.protocol === "http:") {
+ protocol = "http:";
+ } else {
+ throw Error(`unsupported protocol (${parsedUrl.protocol})`);
+ }
+
+ const options: RequestOptions & FollowOptions<RequestOptions> = {
+ protocol,
+ port: parsedUrl.port,
+ host: parsedUrl.hostname,
+ method: method,
+ path,
+ headers: requestHeadersMap,
+ timeout: timeoutMs,
+ followRedirects: opt?.redirect !== "manual",
+ };
+
+ const chunks: Uint8Array[] = [];
+
+ if (SHOW_CURL_HTTP_REQUEST) {
+ const payload =
+ !reqBody || reqBody.byteLength === 0
+ ? undefined
+ : textDecoder.decode(reqBody);
+ const headers = Object.entries(requestHeadersMap).reduce(
+ (prev, [key, value]) => {
+ return `${prev} -H "${key}: ${value}"`;
+ },
+ "",
+ );
+ function ifUndefined<T>(arg: string, v: undefined | T): string {
+ if (v === undefined) return "";
+ return arg + " '" + String(v) + "'";
+ }
+ console.log(
+ `curl -X ${options.method} ${parsedUrl.href} ${ifUndefined(
+ "-d",
+ payload,
+ )} ${headers}`,
+ );
+ }
+
+ let timeoutHandle: NodeJS.Timer | undefined = undefined;
+ let cancelCancelledHandler: (() => void) | undefined = undefined;
+
+ const doCleanup = () => {
+ if (timeoutHandle != null) {
+ clearTimeout(timeoutHandle);
+ }
+ if (cancelCancelledHandler) {
+ cancelCancelledHandler();
+ }
+ };
+
+ return new Promise((resolve, reject) => {
+ const handler = (res: IncomingMessage) => {
+ res.on("data", (d) => {
+ chunks.push(d);
+ });
+ res.on("end", () => {
+ const headers: Headers = new Headers();
+ for (const [k, v] of Object.entries(res.headers)) {
+ if (!v) {
+ continue;
+ }
+ if (typeof v === "string") {
+ headers.set(k, v);
+ } else {
+ headers.set(k, v.join(", "));
+ }
+ }
+ const data = typedArrayConcat(chunks);
+ const resp: HttpResponse = {
+ requestMethod: method,
+ requestUrl: parsedUrl.href,
+ status: res.statusCode || 0,
+ headers,
+ async bytes() {
+ return data;
+ },
+ json() {
+ const text = textDecoder.decode(data);
+ return JSON.parse(text);
+ },
+ async text() {
+ const text = textDecoder.decode(data);
+ return text;
+ },
+ };
+ doCleanup();
+ resolve(resp);
+ });
+ res.on("error", (e) => {
+ const code = "code" in e ? e.code : "unknown";
+ const err = TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ {
+ requestUrl: url,
+ requestMethod: method,
+ httpStatusCode: 0,
+ },
+ `Error in HTTP response handler: ${code}`,
+ );
+ doCleanup();
+ reject(err);
+ });
+ };
+
+ let req: RedirectableRequest<ClientRequest, IncomingMessage>;
+ if (options.protocol === "http:") {
+ req = http.request(options, handler);
+ } else if (options.protocol === "https:") {
+ req = https.request(options, handler);
+ } else {
+ throw new Error(`unsupported protocol ${options.protocol}`);
+ }
+
+ if (timeoutMs != null) {
+ timeoutHandle = setTimeout(() => {
+ logger.info(`request to ${url} timed out`);
+ const err = TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ {
+ requestUrl: url,
+ requestMethod: method,
+ httpStatusCode: 0,
+ },
+ `Request timed out after ${timeoutMs} ms`,
+ );
+ timeoutHandle = undefined;
+ req.destroy();
+ doCleanup();
+ reject(err);
+ req.destroy();
+ }, timeoutMs);
+ }
+
+ if (opt?.cancellationToken) {
+ cancelCancelledHandler = opt.cancellationToken.onCancelled(() => {
+ const err = TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ {
+ requestUrl: url,
+ requestMethod: method,
+ httpStatusCode: 0,
+ },
+ `Request cancelled`,
+ );
+ req.destroy();
+ doCleanup();
+ reject(err);
+ });
+ }
+
+ req.on("error", (e: Error) => {
+ const code = "code" in e ? e.code : "unknown";
+ const err = TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ {
+ requestUrl: url,
+ requestMethod: method,
+ httpStatusCode: 0,
+ },
+ `Error in HTTP request: ${code}`,
+ );
+ doCleanup();
+ reject(err);
+ });
+
+ if (reqBody) {
+ req.write(new Uint8Array(reqBody));
+ }
+ req.end();
+ });
+ }
+}
diff --git a/packages/taler-util/src/http-impl.qtart.ts b/packages/taler-util/src/http-impl.qtart.ts
new file mode 100644
index 000000000..b4e4ebbe7
--- /dev/null
+++ b/packages/taler-util/src/http-impl.qtart.ts
@@ -0,0 +1,211 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ SPDX-License-Identifier: AGPL3.0-or-later
+*/
+
+/**
+ * Imports.
+ */
+import { Logger, openPromise } from "@gnu-taler/taler-util";
+import { TalerError } from "./errors.js";
+import { HttpLibArgs, encodeBody, getDefaultHeaders } from "./http-common.js";
+import {
+ Headers,
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+} from "./http.js";
+import { RequestThrottler, TalerErrorCode, URL } from "./index.js";
+import { QjsHttpResp, qjsOs } from "./qtart.js";
+
+const logger = new Logger("http-impl.qtart.ts");
+
+const textDecoder = new TextDecoder();
+
+export class RequestTimeoutError extends Error {
+ public constructor() {
+ super("Request timed out");
+ Object.setPrototypeOf(this, RequestTimeoutError.prototype);
+ }
+}
+
+export class RequestCancelledError extends Error {
+ public constructor() {
+ super("Request cancelled");
+ Object.setPrototypeOf(this, RequestCancelledError.prototype);
+ }
+}
+
+/**
+ * Implementation of the HTTP request library interface for node.
+ */
+export class HttpLibImpl implements HttpRequestLibrary {
+ private throttle = new RequestThrottler();
+ private throttlingEnabled = true;
+ private requireTls = false;
+
+ constructor(args?: HttpLibArgs) {
+ this.throttlingEnabled = args?.enableThrottling ?? true;
+ this.requireTls = args?.requireTls ?? false;
+ }
+
+ /**
+ * Set whether requests should be throttled.
+ */
+ setThrottling(enabled: boolean): void {
+ this.throttlingEnabled = enabled;
+ }
+
+ async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
+ const method = (opt?.method ?? "GET").toUpperCase();
+
+ logger.trace(`Requesting ${method} ${url}`);
+
+ const parsedUrl = new URL(url);
+ if (this.throttlingEnabled && this.throttle.applyThrottle(url)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
+ {
+ requestMethod: method,
+ requestUrl: url,
+ throttleStats: this.throttle.getThrottleStats(url),
+ },
+ `request to origin ${parsedUrl.origin} was throttled`,
+ );
+ }
+ if (this.requireTls && parsedUrl.protocol !== "https:") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_NETWORK_ERROR,
+ {
+ requestMethod: method,
+ requestUrl: url,
+ },
+ `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`,
+ );
+ }
+
+ let data: ArrayBuffer | undefined = undefined;
+ const requestHeadersMap = getDefaultHeaders(method);
+ if (opt?.headers) {
+ Object.entries(opt?.headers).forEach(([key, value]) => {
+ if (value === undefined) return;
+ requestHeadersMap[key] = value
+ })
+ }
+ let headersList: string[] = [];
+ for (let headerName of Object.keys(requestHeadersMap)) {
+ headersList.push(`${headerName}: ${requestHeadersMap[headerName]}`);
+ }
+ if (method === "POST") {
+ data = encodeBody(opt?.body);
+ }
+
+ const cancelPromCap = openPromise<QjsHttpResp>();
+
+ // Just like WHATWG fetch(), the qjs http client doesn't
+ // really support cancellation, so cancellation here just
+ // means that the result is ignored!
+ const fetchProm = qjsOs.fetchHttp(url, {
+ method,
+ data,
+ headers: headersList,
+ });
+
+ let timeoutHandle: any = undefined;
+ let cancelCancelledHandler: (() => void) | undefined = undefined;
+
+ if (opt?.timeout && opt.timeout.d_ms !== "forever") {
+ timeoutHandle = setTimeout(() => {
+ cancelPromCap.reject(new RequestTimeoutError());
+ }, opt.timeout.d_ms);
+ }
+
+ if (opt?.cancellationToken) {
+ cancelCancelledHandler = opt.cancellationToken.onCancelled(() => {
+ cancelPromCap.reject(new RequestCancelledError());
+ });
+ }
+
+ let res: QjsHttpResp;
+ try {
+ res = await Promise.race([fetchProm, cancelPromCap.promise]);
+ } catch (e) {
+ if (e instanceof RequestCancelledError) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ {
+ requestUrl: url,
+ requestMethod: method,
+ httpStatusCode: 0,
+ },
+ `Request cancelled`,
+ );
+ }
+ if (e instanceof RequestTimeoutError) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ {
+ requestUrl: url,
+ requestMethod: method,
+ httpStatusCode: 0,
+ },
+ `Request timed out`,
+ );
+ }
+ throw e;
+ }
+
+ if (timeoutHandle != null) {
+ clearTimeout(timeoutHandle);
+ }
+
+ if (cancelCancelledHandler != null) {
+ cancelCancelledHandler();
+ }
+
+ const headers: Headers = new Headers();
+
+ if (res.headers) {
+ for (const headerStr of res.headers) {
+ const splitPos = headerStr.indexOf(":");
+ if (splitPos < 0) {
+ continue;
+ }
+ const headerName = headerStr.slice(0, splitPos).trim().toLowerCase();
+ const headerValue = headerStr.slice(splitPos + 1).trim();
+ headers.set(headerName, headerValue);
+ }
+ }
+
+ return {
+ requestMethod: method,
+ headers,
+ async bytes() {
+ return res.data;
+ },
+ json() {
+ const text = textDecoder.decode(res.data);
+ return JSON.parse(text);
+ },
+ async text() {
+ const text = textDecoder.decode(res.data);
+ return text;
+ },
+ requestUrl: url,
+ status: res.status,
+ };
+ }
+}
diff --git a/packages/taler-util/src/http-status-codes.ts b/packages/taler-util/src/http-status-codes.ts
new file mode 100644
index 000000000..848839990
--- /dev/null
+++ b/packages/taler-util/src/http-status-codes.ts
@@ -0,0 +1,379 @@
+/**
+ * Hypertext Transfer Protocol (HTTP) response status codes.
+ *
+ * @see {@link https://en.wikipedia.org/wiki/List_of_HTTP_status_codes}
+ */
+export enum HttpStatusCode {
+ /**
+ * The server has received the request headers and the client should proceed to send the request body
+ * (in the case of a request for which a body needs to be sent; for example, a POST request).
+ * Sending a large request body to a server after a request has been rejected for inappropriate headers would be inefficient.
+ * To have a server check the request's headers, a client must send Expect: 100-continue as a header in its initial request
+ * and receive a 100 Continue status code in response before sending the body. The response 417 Expectation Failed indicates the request should not be continued.
+ */
+ Continue = 100,
+
+ /**
+ * The requester has asked the server to switch protocols and the server has agreed to do so.
+ */
+ SwitchingProtocols = 101,
+
+ /**
+ * A WebDAV request may contain many sub-requests involving file operations, requiring a long time to complete the request.
+ * This code indicates that the server has received and is processing the request, but no response is available yet.
+ * This prevents the client from timing out and assuming the request was lost.
+ */
+ Processing = 102,
+
+ /**
+ * Standard response for successful HTTP requests.
+ * The actual response will depend on the request method used.
+ * In a GET request, the response will contain an entity corresponding to the requested resource.
+ * In a POST request, the response will contain an entity describing or containing the result of the action.
+ */
+ Ok = 200,
+
+ /**
+ * The request has been fulfilled, resulting in the creation of a new resource.
+ */
+ Created = 201,
+
+ /**
+ * The request has been accepted for processing, but the processing has not been completed.
+ * The request might or might not be eventually acted upon, and may be disallowed when processing occurs.
+ */
+ Accepted = 202,
+
+ /**
+ * SINCE HTTP/1.1
+ * The server is a transforming proxy that received a 200 OK from its origin,
+ * but is returning a modified version of the origin's response.
+ */
+ NonAuthoritativeInformation = 203,
+
+ /**
+ * The server successfully processed the request and is not returning any content.
+ */
+ NoContent = 204,
+
+ /**
+ * The server successfully processed the request, but is not returning any content.
+ * Unlike a 204 response, this response requires that the requester reset the document view.
+ */
+ ResetContent = 205,
+
+ /**
+ * The server is delivering only part of the resource (byte serving) due to a range header sent by the client.
+ * The range header is used by HTTP clients to enable resuming of interrupted downloads,
+ * or split a download into multiple simultaneous streams.
+ */
+ PartialContent = 206,
+
+ /**
+ * The message body that follows is an XML message and can contain a number of separate response codes,
+ * depending on how many sub-requests were made.
+ */
+ MultiStatus = 207,
+
+ /**
+ * The members of a DAV binding have already been enumerated in a preceding part of the (multistatus) response,
+ * and are not being included again.
+ */
+ AlreadyReported = 208,
+
+ /**
+ * The server has fulfilled a request for the resource,
+ * and the response is a representation of the result of one or more instance-manipulations applied to the current instance.
+ */
+ ImUsed = 226,
+
+ /**
+ * Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation).
+ * For example, this code could be used to present multiple video format options,
+ * to list files with different filename extensions, or to suggest word-sense disambiguation.
+ */
+ MultipleChoices = 300,
+
+ /**
+ * This and all future requests should be directed to the given URI.
+ */
+ MovedPermanently = 301,
+
+ /**
+ * This is an example of industry practice contradicting the standard.
+ * The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect
+ * (the original describing phrase was "Moved Temporarily"), but popular browsers implemented 302
+ * with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307
+ * to distinguish between the two behaviours. However, some Web applications and frameworks
+ * use the 302 status code as if it were the 303.
+ */
+ Found = 302,
+
+ /**
+ * SINCE HTTP/1.1
+ * The response to the request can be found under another URI using a GET method.
+ * When received in response to a POST (or PUT/DELETE), the client should presume that
+ * the server has received the data and should issue a redirect with a separate GET message.
+ */
+ SeeOther = 303,
+
+ /**
+ * Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match.
+ * In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy.
+ */
+ NotModified = 304,
+
+ /**
+ * SINCE HTTP/1.1
+ * The requested resource is available only through a proxy, the address for which is provided in the response.
+ * Many HTTP clients (such as Mozilla and Internet Explorer) do not correctly handle responses with this status code, primarily for security reasons.
+ */
+ UseProxy = 305,
+
+ /**
+ * No longer used. Originally meant "Subsequent requests should use the specified proxy."
+ */
+ SwitchProxy = 306,
+
+ /**
+ * SINCE HTTP/1.1
+ * In this case, the request should be repeated with another URI; however, future requests should still use the original URI.
+ * In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the original request.
+ * For example, a POST request should be repeated using another POST request.
+ */
+ TemporaryRedirect = 307,
+
+ /**
+ * The request and all future requests should be repeated using another URI.
+ * 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change.
+ * So, for example, submitting a form to a permanently redirected resource may continue smoothly.
+ */
+ PermanentRedirect = 308,
+
+ /**
+ * The server cannot or will not process the request due to an apparent client error
+ * (e.g., malformed request syntax, too large size, invalid request message framing, or deceptive request routing).
+ */
+ BadRequest = 400,
+
+ /**
+ * Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet
+ * been provided. The response must include a WWW-Authenticate header field containing a challenge applicable to the
+ * requested resource. See Basic access authentication and Digest access authentication. 401 semantically means
+ * "unauthenticated",i.e. the user does not have the necessary credentials.
+ */
+ Unauthorized = 401,
+
+ /**
+ * Reserved for future use. The original intention was that this code might be used as part of some form of digital
+ * cash or micro payment scheme, but that has not happened, and this code is not usually used.
+ * Google Developers API uses this status if a particular developer has exceeded the daily limit on requests.
+ */
+ PaymentRequired = 402,
+
+ /**
+ * The request was valid, but the server is refusing action.
+ * The user might not have the necessary permissions for a resource.
+ */
+ Forbidden = 403,
+
+ /**
+ * The requested resource could not be found but may be available in the future.
+ * Subsequent requests by the client are permissible.
+ */
+ NotFound = 404,
+
+ /**
+ * A request method is not supported for the requested resource;
+ * for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource.
+ */
+ MethodNotAllowed = 405,
+
+ /**
+ * The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request.
+ */
+ NotAcceptable = 406,
+
+ /**
+ * The client must first authenticate itself with the proxy.
+ */
+ ProxyAuthenticationRequired = 407,
+
+ /**
+ * The server timed out waiting for the request.
+ * According to HTTP specifications:
+ * "The client did not produce a request within the time that the server was prepared to wait. The client MAY repeat the request without modifications at any later time."
+ */
+ RequestTimeout = 408,
+
+ /**
+ * Indicates that the request could not be processed because of conflict in the request,
+ * such as an edit conflict between multiple simultaneous updates.
+ */
+ Conflict = 409,
+
+ /**
+ * Indicates that the resource requested is no longer available and will not be available again.
+ * This should be used when a resource has been intentionally removed and the resource should be purged.
+ * Upon receiving a 410 status code, the client should not request the resource in the future.
+ * Clients such as search engines should remove the resource from their indices.
+ * Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead.
+ */
+ Gone = 410,
+
+ /**
+ * The request did not specify the length of its content, which is required by the requested resource.
+ */
+ LengthRequired = 411,
+
+ /**
+ * The server does not meet one of the preconditions that the requester put on the request.
+ */
+ PreconditionFailed = 412,
+
+ /**
+ * The request is larger than the server is willing or able to process. Previously called "Request Entity Too Large".
+ */
+ PayloadTooLarge = 413,
+
+ /**
+ * The URI provided was too long for the server to process. Often the result of too much data being encoded as a query-string of a GET request,
+ * in which case it should be converted to a POST request.
+ * Called "Request-URI Too Long" previously.
+ */
+ UriTooLong = 414,
+
+ /**
+ * The request entity has a media type which the server or resource does not support.
+ * For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format.
+ */
+ UnsupportedMediaType = 415,
+
+ /**
+ * The client has asked for a portion of the file (byte serving), but the server cannot supply that portion.
+ * For example, if the client asked for a part of the file that lies beyond the end of the file.
+ * Called "Requested Range Not Satisfiable" previously.
+ */
+ RangeNotSatisfiable = 416,
+
+ /**
+ * The server cannot meet the requirements of the Expect request-header field.
+ */
+ ExpectationFailed = 417,
+
+ /**
+ * This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol,
+ * and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by
+ * teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including Google.com.
+ */
+ IAmATeapot = 418,
+
+ /**
+ * The request was directed at a server that is not able to produce a response (for example because a connection reuse).
+ */
+ MisdirectedRequest = 421,
+
+ /**
+ * The request was well-formed but was unable to be followed due to semantic errors.
+ */
+ UnprocessableEntity = 422,
+
+ /**
+ * The resource that is being accessed is locked.
+ */
+ Locked = 423,
+
+ /**
+ * The request failed due to failure of a previous request (e.g., a PROPPATCH).
+ */
+ FailedDependency = 424,
+
+ /**
+ * The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field.
+ */
+ UpgradeRequired = 426,
+
+ /**
+ * The origin server requires the request to be conditional.
+ * Intended to prevent "the 'lost update' problem, where a client
+ * GETs a resource's state, modifies it, and PUTs it back to the server,
+ * when meanwhile a third party has modified the state on the server, leading to a conflict."
+ */
+ PreconditionRequired = 428,
+
+ /**
+ * The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes.
+ */
+ TooManyRequests = 429,
+
+ /**
+ * The server is unwilling to process the request because either an individual header field,
+ * or all the header fields collectively, are too large.
+ */
+ RequestHeaderFieldsTooLarge = 431,
+
+ /**
+ * A server operator has received a legal demand to deny access to a resource or to a set of resources
+ * that includes the requested resource. The code 451 was chosen as a reference to the novel Fahrenheit 451.
+ */
+ UnavailableForLegalReasons = 451,
+
+ /**
+ * A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.
+ */
+ InternalServerError = 500,
+
+ /**
+ * The server either does not recognize the request method, or it lacks the ability to fulfill the request.
+ * Usually this implies future availability (e.g., a new feature of a web-service API).
+ */
+ NotImplemented = 501,
+
+ /**
+ * The server was acting as a gateway or proxy and received an invalid response from the upstream server.
+ */
+ BadGateway = 502,
+
+ /**
+ * The server is currently unavailable (because it is overloaded or down for maintenance).
+ * Generally, this is a temporary state.
+ */
+ ServiceUnavailable = 503,
+
+ /**
+ * The server was acting as a gateway or proxy and did not receive a timely response from the upstream server.
+ */
+ GatewayTimeout = 504,
+
+ /**
+ * The server does not support the HTTP protocol version used in the request
+ */
+ HttpVersionNotSupported = 505,
+
+ /**
+ * Transparent content negotiation for the request results in a circular reference.
+ */
+ VariantAlsoNegotiates = 506,
+
+ /**
+ * The server is unable to store the representation needed to complete the request.
+ */
+ InsufficientStorage = 507,
+
+ /**
+ * The server detected an infinite loop while processing the request.
+ */
+ LoopDetected = 508,
+
+ /**
+ * Further extensions to the request are required for the server to fulfill it.
+ */
+ NotExtended = 510,
+
+ /**
+ * The client needs to authenticate to gain network access.
+ * Intended for use by intercepting proxies used to control access to the network (e.g., "captive portals" used
+ * to require agreement to Terms of Service before granting full Internet access via a Wi-Fi hotspot).
+ */
+ NetworkAuthenticationRequired = 511,
+}
diff --git a/packages/taler-util/src/http.ts b/packages/taler-util/src/http.ts
new file mode 100644
index 000000000..8bf10d0e2
--- /dev/null
+++ b/packages/taler-util/src/http.ts
@@ -0,0 +1,37 @@
+/*
+ This file is part of TALER
+ (C) 2016 GNUnet e.V.
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Helpers for doing XMLHttpRequest-s that are based on ES6 promises.
+ * Allows for easy mocking for test cases.
+ *
+ * The API is inspired by the HTML5 fetch API.
+ */
+
+/**
+ * Imports
+ */
+
+import * as impl from "#http-impl";
+import * as common from "./http-common.js";
+
+export * from "./http-common.js";
+
+export function createPlatformHttpLib(
+ args?: common.HttpLibArgs,
+): common.HttpRequestLibrary {
+ return new impl.HttpLibImpl(args);
+}
diff --git a/packages/taler-util/src/i18n.ts b/packages/taler-util/src/i18n.ts
index 227798f48..f43f543ea 100644
--- a/packages/taler-util/src/i18n.ts
+++ b/packages/taler-util/src/i18n.ts
@@ -10,12 +10,12 @@ export let jed: any = undefined;
* Set up jed library for internationalization,
* based on browser language settings.
*/
-export function setupI18n(lang: string, strings: { [s: string]: any }): any {
+export function setupI18n(lang: string, strings: { [s: string]: any }): void {
lang = lang.replace("_", "-");
if (!strings[lang]) {
- lang = "en-US";
- logger.warn(`language ${lang} not found, defaulting to english`);
+ strings[lang] = {};
+ // logger.warn(`language ${lang} not found, defaulting to source strings`);
}
jed = new jedLib.Jed(strings[lang]);
}
@@ -28,10 +28,13 @@ export function internalSetStrings(langStrings: any): void {
jed = new jedLib.Jed(langStrings);
}
+declare const __translated: unique symbol;
+export type TranslatedString = string & { [__translated]: true };
+
/**
* Convert template strings to a msgid
*/
-function toI18nString(stringSeq: ReadonlyArray<string>): string {
+function toI18nString(stringSeq: ReadonlyArray<string>): TranslatedString {
let s = "";
for (let i = 0; i < stringSeq.length; i++) {
s += stringSeq[i];
@@ -39,13 +42,16 @@ function toI18nString(stringSeq: ReadonlyArray<string>): string {
s += `%${i + 1}$s`;
}
}
- return s;
+ return s as TranslatedString;
}
/**
* Internationalize a string template with arbitrary serialized values.
*/
-export function singular(stringSeq: TemplateStringsArray, ...values: any[]): string {
+export function singular(
+ stringSeq: TemplateStringsArray,
+ ...values: any[]
+): TranslatedString {
const s = toI18nString(stringSeq);
const tr = jed
.translate(s)
@@ -60,21 +66,30 @@ export function singular(stringSeq: TemplateStringsArray, ...values: any[]): str
export function translate(
stringSeq: TemplateStringsArray,
...values: any[]
-): any[] {
+): TranslatedString[] {
const s = toI18nString(stringSeq);
if (!s) return [];
- const translation: string = jed.ngettext(s, s, 1);
+ const translation: TranslatedString = jed.ngettext(s, s, 1);
return replacePlaceholderWithValues(translation, values);
}
/**
* Internationalize a string template without serializing
*/
-export function Translate({ children, ...rest }: { children: any }): any {
+export function Translate({
+ children,
+ debug,
+}: {
+ children: any;
+ debug?: boolean;
+}): any {
const c = [].concat(children);
const s = stringifyArray(c);
if (!s) return [];
- const translation: string = jed.ngettext(s, s, 1);
+ const translation: TranslatedString = jed.ngettext(s, s, 1);
+ if (debug) {
+ console.log("looking for ", s, "got", translation);
+ }
return replacePlaceholderWithValues(translation, c);
}
@@ -92,12 +107,12 @@ export function getJsonI18n<K extends string>(
export function getTranslatedArray(array: Array<any>) {
const s = stringifyArray(array);
- const translation: string = jed.ngettext(s, s, 1);
+ const translation: TranslatedString = jed.ngettext(s, s, 1);
return replacePlaceholderWithValues(translation, array);
}
function replacePlaceholderWithValues(
- translation: string,
+ translation: TranslatedString,
childArray: Array<any>,
): Array<any> {
const tr = translation.split(/%(\d+)\$s/);
@@ -145,4 +160,3 @@ export const i18n = {
Translate,
translate,
};
-
diff --git a/packages/taler-util/src/iban.test.ts b/packages/taler-util/src/iban.test.ts
new file mode 100644
index 000000000..a00e3b50a
--- /dev/null
+++ b/packages/taler-util/src/iban.test.ts
@@ -0,0 +1,30 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import test from "ava";
+import { generateIban, validateIban } from "./iban.js";
+
+test("iban validation", (t) => {
+ t.assert(validateIban("foo").type === "invalid");
+ t.assert(validateIban("NL71RABO9996666778").type === "valid");
+ t.assert(validateIban("NL71RABO9996666779").type === "invalid");
+});
+
+test("iban generation", (t) => {
+ let iban1 = generateIban("DE", 10);
+ console.log("generated IBAN", iban1);
+ t.assert(validateIban(iban1).type === "valid");
+});
diff --git a/packages/taler-util/src/iban.ts b/packages/taler-util/src/iban.ts
new file mode 100644
index 000000000..d386f90e0
--- /dev/null
+++ b/packages/taler-util/src/iban.ts
@@ -0,0 +1,296 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * IBAN validation.
+ *
+ * Currently only validates the checksum.
+ *
+ * It does not validate:
+ * - Country-specific length
+ * - Country-specific checksums
+ *
+ * The country list is also not complete.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+export type IbanValidationResult =
+ | { type: "invalid" }
+ | {
+ type: "valid";
+ normalizedIban: string;
+ };
+
+export interface IbanCountryInfo {
+ name: string;
+ isSepa?: boolean;
+ length?: number;
+}
+
+/**
+ * Incomplete list, see https://www.swift.com/resource/iban-registry-pdf
+ */
+export const ibanCountryInfoTable: Record<string, IbanCountryInfo> = {
+ AE: { name: "U.A.E." },
+ AF: { name: "Afghanistan" },
+ AL: { name: "Albania" },
+ AM: { name: "Armenia" },
+ AN: { name: "Netherlands Antilles" },
+ AR: { name: "Argentina" },
+ AT: { name: "Austria" },
+ AU: { name: "Australia" },
+ AZ: { name: "Azerbaijan" },
+ BA: { name: "Bosnia and Herzegovina" },
+ BD: { name: "Bangladesh" },
+ BE: { name: "Belgium" },
+ BG: { name: "Bulgaria" },
+ BH: { name: "Bahrain" },
+ BN: { name: "Brunei Darussalam" },
+ BO: { name: "Bolivia" },
+ BR: { name: "Brazil" },
+ BT: { name: "Bhutan" },
+ BY: { name: "Belarus" },
+ BZ: { name: "Belize" },
+ CA: { name: "Canada" },
+ CG: { name: "Congo" },
+ CH: { name: "Switzerland" },
+ CI: { name: "Cote d'Ivoire" },
+ CL: { name: "Chile" },
+ CM: { name: "Cameroon" },
+ CN: { name: "People's Republic of China" },
+ CO: { name: "Colombia" },
+ CR: { name: "Costa Rica" },
+ CS: { name: "Serbia and Montenegro" },
+ CZ: { name: "Czech Republic" },
+ DE: { name: "Germany" },
+ DK: { name: "Denmark" },
+ DO: { name: "Dominican Republic" },
+ DZ: { name: "Algeria" },
+ EC: { name: "Ecuador" },
+ EE: { name: "Estonia" },
+ EG: { name: "Egypt" },
+ ER: { name: "Eritrea" },
+ ES: { name: "Spain" },
+ ET: { name: "Ethiopia" },
+ FI: { name: "Finland" },
+ FO: { name: "Faroe Islands" },
+ FR: { name: "France" },
+ GB: { name: "United Kingdom" },
+ GD: { name: "Caribbean" },
+ GE: { name: "Georgia" },
+ GL: { name: "Greenland" },
+ GR: { name: "Greece" },
+ GT: { name: "Guatemala" },
+ HK: { name: "Hong Kong S.A.R." },
+ HN: { name: "Honduras" },
+ HR: { name: "Croatia" },
+ HT: { name: "Haiti" },
+ HU: { name: "Hungary" },
+ ID: { name: "Indonesia" },
+ IE: { name: "Ireland" },
+ IL: { name: "Israel" },
+ IN: { name: "India" },
+ IQ: { name: "Iraq" },
+ IR: { name: "Iran" },
+ IS: { name: "Iceland" },
+ IT: { name: "Italy" },
+ JM: { name: "Jamaica" },
+ JO: { name: "Jordan" },
+ JP: { name: "Japan" },
+ KE: { name: "Kenya" },
+ KG: { name: "Kyrgyzstan" },
+ KH: { name: "Cambodia" },
+ KR: { name: "South Korea" },
+ KW: { name: "Kuwait" },
+ KZ: { name: "Kazakhstan" },
+ LA: { name: "Laos" },
+ LB: { name: "Lebanon" },
+ LI: { name: "Liechtenstein" },
+ LK: { name: "Sri Lanka" },
+ LT: { name: "Lithuania" },
+ LU: { name: "Luxembourg" },
+ LV: { name: "Latvia" },
+ LY: { name: "Libya" },
+ MA: { name: "Morocco" },
+ MC: { name: "Principality of Monaco" },
+ MD: { name: "Moldava" },
+ ME: { name: "Montenegro" },
+ MK: { name: "Former Yugoslav Republic of Macedonia" },
+ ML: { name: "Mali" },
+ MM: { name: "Myanmar" },
+ MN: { name: "Mongolia" },
+ MO: { name: "Macau S.A.R." },
+ MT: { name: "Malta" },
+ MV: { name: "Maldives" },
+ MX: { name: "Mexico" },
+ MY: { name: "Malaysia" },
+ NG: { name: "Nigeria" },
+ NI: { name: "Nicaragua" },
+ NL: { name: "Netherlands" },
+ NO: { name: "Norway" },
+ NP: { name: "Nepal" },
+ NZ: { name: "New Zealand" },
+ OM: { name: "Oman" },
+ PA: { name: "Panama" },
+ PE: { name: "Peru" },
+ PH: { name: "Philippines" },
+ PK: { name: "Islamic Republic of Pakistan" },
+ PL: { name: "Poland" },
+ PR: { name: "Puerto Rico" },
+ PT: { name: "Portugal" },
+ PY: { name: "Paraguay" },
+ QA: { name: "Qatar" },
+ RE: { name: "Reunion" },
+ RO: { name: "Romania" },
+ RS: { name: "Serbia" },
+ RU: { name: "Russia" },
+ RW: { name: "Rwanda" },
+ SA: { name: "Saudi Arabia" },
+ SE: { name: "Sweden" },
+ SG: { name: "Singapore" },
+ SI: { name: "Slovenia" },
+ SK: { name: "Slovak" },
+ SN: { name: "Senegal" },
+ SO: { name: "Somalia" },
+ SR: { name: "Suriname" },
+ SV: { name: "El Salvador" },
+ SY: { name: "Syria" },
+ TH: { name: "Thailand" },
+ TJ: { name: "Tajikistan" },
+ TM: { name: "Turkmenistan" },
+ TN: { name: "Tunisia" },
+ TR: { name: "Turkey" },
+ TT: { name: "Trinidad and Tobago" },
+ TW: { name: "Taiwan" },
+ TZ: { name: "Tanzania" },
+ UA: { name: "Ukraine" },
+ US: { name: "United States" },
+ UY: { name: "Uruguay" },
+ VA: { name: "Vatican" },
+ VE: { name: "Venezuela" },
+ VN: { name: "Viet Nam" },
+ YE: { name: "Yemen" },
+ ZA: { name: "South Africa" },
+ ZW: { name: "Zimbabwe" },
+};
+
+let ccZero = "0".charCodeAt(0);
+let ccNine = "9".charCodeAt(0);
+let ccA = "A".charCodeAt(0);
+let ccZ = "Z".charCodeAt(0);
+
+/**
+ * Append a IBAN digit(s) based on a char code.
+ */
+function appendDigit(digits: number[], cc: number): boolean {
+ if (cc >= ccZero && cc <= ccNine) {
+ digits.push(cc - ccZero);
+ } else if (cc >= ccA && cc <= ccZ) {
+ const n = cc - ccA + 10;
+ digits.push(Math.floor(n / 10) % 10);
+ digits.push(n % 10);
+ } else {
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Compute MOD-97-10 as per ISO/IEC 7064:2003.
+ */
+function mod97(digits: number[]): number {
+ let i = 0;
+ let modAccum = 0;
+ while (i < digits.length) {
+ let n = 0;
+ while (n < 9 && i < digits.length) {
+ modAccum = modAccum * 10 + digits[i];
+ i++;
+ n++;
+ }
+ modAccum = modAccum % 97;
+ }
+ return modAccum;
+}
+
+export function validateIban(ibanString: string): IbanValidationResult {
+ let myIban = ibanString.toLocaleUpperCase().replace(" ", "");
+ let countryCode = myIban.substring(0, 2);
+ let countryInfo = ibanCountryInfoTable[countryCode];
+
+ if (!countryInfo) {
+ return {
+ type: "invalid",
+ };
+ }
+
+ let digits: number[] = [];
+
+ for (let i = 4; i < myIban.length; i++) {
+ const cc = myIban.charCodeAt(i);
+ if (!appendDigit(digits, cc)) {
+ return {
+ type: "invalid",
+ };
+ }
+ }
+
+ for (let i = 0; i < 4; i++) {
+ if (!appendDigit(digits, ibanString.charCodeAt(i))) {
+ return {
+ type: "invalid",
+ };
+ }
+ }
+
+ const rem = mod97(digits);
+ if (rem === 1) {
+ return {
+ type: "valid",
+ normalizedIban: myIban,
+ };
+ } else {
+ return {
+ type: "invalid",
+ };
+ }
+}
+
+export function generateIban(countryCode: string, length: number): string {
+ let ibanSuffix = "";
+ let digits: number[] = [];
+
+ for (let i = 0; i < length; i++) {
+ const cc = ccZero + (Math.floor(Math.random() * 100) % 10);
+ appendDigit(digits, cc);
+ ibanSuffix += String.fromCharCode(cc);
+ }
+
+ appendDigit(digits, countryCode.charCodeAt(0));
+ appendDigit(digits, countryCode.charCodeAt(1));
+
+ // Try using "00" as check digits
+ appendDigit(digits, ccZero);
+ appendDigit(digits, ccZero);
+
+ const requiredChecksum = 98 - mod97(digits);
+
+ const checkDigit1 = Math.floor(requiredChecksum / 10) % 10;
+ const checkDigit2 = requiredChecksum % 10;
+
+ return countryCode + checkDigit1 + checkDigit2 + ibanSuffix;
+}
diff --git a/packages/taler-util/src/index.browser.ts b/packages/taler-util/src/index.browser.ts
index 3b8e194b3..ec77b10c0 100644
--- a/packages/taler-util/src/index.browser.ts
+++ b/packages/taler-util/src/index.browser.ts
@@ -19,3 +19,7 @@
import { loadBrowserPrng } from "./prng-browser.js";
loadBrowserPrng();
export * from "./index.js";
+
+// The web stuff doesn't support package.json export declarations yet,
+// so we export more stuff here than we should.
+export * from "./http-common.js";
diff --git a/packages/taler-util/src/index.node.ts b/packages/taler-util/src/index.node.ts
index 018b4767f..ba4c6cf4e 100644
--- a/packages/taler-util/src/index.node.ts
+++ b/packages/taler-util/src/index.node.ts
@@ -21,3 +21,4 @@ initNodePrng();
export * from "./index.js";
export * from "./talerconfig.js";
export * from "./globbing/minimatch.js";
+export { setPrintHttpRequestAsCurl } from "./http-impl.node.js";
diff --git a/packages/taler-wallet-webextension/tests/__mocks__/linaria.ts b/packages/taler-util/src/index.qtart.ts
index 398ac0ec1..ddb9bcfd4 100644
--- a/packages/taler-wallet-webextension/tests/__mocks__/linaria.ts
+++ b/packages/taler-util/src/index.qtart.ts
@@ -14,20 +14,14 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+import { setPRNG } from "./nacl-fast.js";
-/**
- * Here we are mocking the linaria runtime since it should not be used in
- * runtime.
- */
-export const styled = new Proxy(function (tag: any) {
- return jest.fn(() => `mock-styled.${tag}`);
-}, {
- get(o, prop) {
- return o(prop);
- },
-})
+setPRNG(function (x: Uint8Array, n: number) {
+ // @ts-ignore
+ const va = globalThis._tart.randomBytes(n);
+ const v = new Uint8Array(va);
+ for (let i = 0; i < n; i++) x[i] = v[i];
+ for (let i = 0; i < v.length; i++) v[i] = 0;
+});
+export * from "./index.js";
diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts
index 4ad752954..9bd4834d2 100644
--- a/packages/taler-util/src/index.ts
+++ b/packages/taler-util/src/index.ts
@@ -2,29 +2,61 @@ import { TalerErrorCode } from "./taler-error-codes.js";
export { TalerErrorCode };
+export * from "./CancellationToken.js";
+export * from "./MerchantApiClient.js";
+export { RequestThrottler } from "./RequestThrottler.js";
+export * from "./ReserveStatus.js";
+export * from "./ReserveTransaction.js";
+export { TaskThrottler } from "./TaskThrottler.js";
export * from "./amounts.js";
-export * from "./backupTypes.js";
+export * from "./backup-types.js";
+export * from "./bank-api-client.js";
+export * from "./base64.js";
+export * from "./bitcoin.js";
export * from "./codec.js";
+export * from "./contract-terms.js";
+export * from "./errors.js";
+export { fnutil } from "./fnutils.js";
export * from "./helpers.js";
-export * from "./libtool-version.js";
-export * from "./notifications.js";
-export * from "./payto.js";
-export * from "./ReserveStatus.js";
-export * from "./ReserveTransaction.js";
-export * from "./talerTypes.js";
-export * from "./taleruri.js";
-export * from "./time.js";
-export * from "./transactionsTypes.js";
-export * from "./walletTypes.js";
+export * from "./http-client/bank-conversion.js";
+export * from "./http-client/authentication.js";
+export * from "./http-client/bank-core.js";
+export * from "./http-client/merchant.js";
+export * from "./http-client/bank-integration.js";
+export * from "./http-client/bank-revenue.js";
+export * from "./http-client/bank-wire.js";
+export * from "./http-client/exchange.js";
+export { CacheEvictor } from "./http-client/utils.js";
+export * from "./http-client/officer-account.js";
+export * from "./http-client/types.js";
+export * from "./http-status-codes.js";
export * from "./i18n.js";
-export * from "./logging.js";
-export * from "./url.js";
-export { fnutil } from "./fnutils.js";
+export * from "./iban.js";
+export * from "./invariants.js";
export * from "./kdf.js";
-export * from "./talerCrypto.js";
+export * from "./libeufin-api-types.js";
+export * from "./libtool-version.js";
+export * from "./logging.js";
+export * from "./merchant-api-types.js";
export {
+ crypto_sign_keyPair_fromSeed,
randomBytes,
secretbox,
secretbox_open,
- crypto_sign_keyPair_fromSeed,
+ setPRNG,
} from "./nacl-fast.js";
+export * from "./notifications.js";
+export * from "./observability.js";
+export * from "./operation.js";
+export * from "./payto.js";
+export * from "./promises.js";
+export * from "./rfc3548.js";
+export * from "./taler-crypto.js";
+export * from "./taler-types.js";
+export * from "./taleruri.js";
+export * from "./time.js";
+export * from "./timer.js";
+export * from "./transaction-test-data.js";
+export * from "./transactions-types.js";
+export * from "./url.js";
+export * from "./wallet-types.js";
diff --git a/packages/taler-wallet-core/src/util/invariants.ts b/packages/taler-util/src/invariants.ts
index b788d044e..c6e9b8113 100644
--- a/packages/taler-wallet-core/src/util/invariants.ts
+++ b/packages/taler-util/src/invariants.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
+ (C) 2019-2024 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -18,6 +18,21 @@
* Helpers for invariants.
*/
+/**
+ * An invariant has been violated.
+ */
+export class InvariantViolatedError extends Error {
+ constructor(message?: string) {
+ super(message);
+ Object.setPrototypeOf(this, InvariantViolatedError.prototype);
+ }
+}
+
+/**
+ * Check a database invariant.
+ *
+ * A violation of this invariant means that the database is inconsistent.
+ */
export function checkDbInvariant(b: boolean, m?: string): asserts b {
if (!b) {
if (m) {
@@ -28,6 +43,11 @@ export function checkDbInvariant(b: boolean, m?: string): asserts b {
}
}
+/**
+ * Check a logic invariant.
+ *
+ * A violation of this invariant means that there is a logic bug in the program.
+ */
export function checkLogicInvariant(b: boolean, m?: string): asserts b {
if (!b) {
if (m) {
diff --git a/packages/taler-util/src/iso-4217.ts b/packages/taler-util/src/iso-4217.ts
new file mode 100644
index 000000000..b155676ff
--- /dev/null
+++ b/packages/taler-util/src/iso-4217.ts
@@ -0,0 +1,1717 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+// From https://en.wikipedia.org/wiki/ISO_4217
+
+//modifications to the original data
+// * currency without decimal represented with 0
+// * removed 4 with label "No universal currency"
+// * numeric as number
+// * removed all field except:
+// - c: currency name
+// - a: alphabetic code
+// - n: numeric code
+// - d: minor unit
+type CurrencyInfo = {
+ /**
+ * name
+ */
+ c: string;
+ /**
+ * alphabetic code
+ */
+ a: string;
+ /**
+ * numeric code
+ */
+ n: number;
+ /**
+ * minor unit
+ * "0" means that there is no minor unit for that currency, whereas "1", "2"
+ * and "3" signify a ratio of 10:1, 100:1 and 1000:1 respectively.
+ */
+ d: number;
+};
+export const data: Array<CurrencyInfo> = [
+ {
+ c: "Afghani",
+ a: "AFN",
+ n: 971,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Lek",
+ a: "ALL",
+ n: 8,
+ d: 2,
+ },
+ {
+ c: "Algerian Dinar",
+ a: "DZD",
+ n: 12,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Kwanza",
+ a: "AOA",
+ n: 973,
+ d: 2,
+ },
+ {
+ c: "East Caribbean Dollar",
+ a: "XCD",
+ n: 951,
+ d: 2,
+ },
+ {
+ c: "East Caribbean Dollar",
+ a: "XCD",
+ n: 951,
+ d: 2,
+ },
+ {
+ c: "Argentine Peso",
+ a: "ARS",
+ n: 32,
+ d: 2,
+ },
+ {
+ c: "Armenian Dram",
+ a: "AMD",
+ n: 51,
+ d: 2,
+ },
+ {
+ c: "Aruban Florin",
+ a: "AWG",
+ n: 533,
+ d: 2,
+ },
+ {
+ c: "Australian Dollar",
+ a: "AUD",
+ n: 36,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Azerbaijan Manat",
+ a: "AZN",
+ n: 944,
+ d: 2,
+ },
+ {
+ c: "Bahamian Dollar",
+ a: "BSD",
+ n: 44,
+ d: 2,
+ },
+ {
+ c: "Bahraini Dinar",
+ a: "BHD",
+ n: 48,
+ d: 3,
+ },
+ {
+ c: "Taka",
+ a: "BDT",
+ n: 50,
+ d: 2,
+ },
+ {
+ c: "Barbados Dollar",
+ a: "BBD",
+ n: 52,
+ d: 2,
+ },
+ {
+ c: "Belarusian Ruble",
+ a: "BYN",
+ n: 933,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Belize Dollar",
+ a: "BZD",
+ n: 84,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BCEAO",
+ a: "XOF",
+ n: 952,
+ d: 0,
+ },
+ {
+ c: "Bermudian Dollar",
+ a: "BMD",
+ n: 60,
+ d: 2,
+ },
+ {
+ c: "Indian Rupee",
+ a: "INR",
+ n: 356,
+ d: 2,
+ },
+ {
+ c: "Ngultrum",
+ a: "BTN",
+ n: 64,
+ d: 2,
+ },
+ {
+ c: "Boliviano",
+ a: "BOB",
+ n: 68,
+ d: 2,
+ },
+ {
+ c: "Mvdol",
+ a: "BOV",
+ n: 984,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Convertible Mark",
+ a: "BAM",
+ n: 977,
+ d: 2,
+ },
+ {
+ c: "Pula",
+ a: "BWP",
+ n: 72,
+ d: 2,
+ },
+ {
+ c: "Norwegian Krone",
+ a: "NOK",
+ n: 578,
+ d: 2,
+ },
+ {
+ c: "Brazilian Real",
+ a: "BRL",
+ n: 986,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Brunei Dollar",
+ a: "BND",
+ n: 96,
+ d: 2,
+ },
+ {
+ c: "Bulgarian Lev",
+ a: "BGN",
+ n: 975,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BCEAO",
+ a: "XOF",
+ n: 952,
+ d: 0,
+ },
+ {
+ c: "Burundi Franc",
+ a: "BIF",
+ n: 108,
+ d: 0,
+ },
+ {
+ c: "Cabo Verde Escudo",
+ a: "CVE",
+ n: 132,
+ d: 2,
+ },
+ {
+ c: "Riel",
+ a: "KHR",
+ n: 116,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BEAC",
+ a: "XAF",
+ n: 950,
+ d: 0,
+ },
+ {
+ c: "Canadian Dollar",
+ a: "CAD",
+ n: 124,
+ d: 2,
+ },
+ {
+ c: "Cayman Islands Dollar",
+ a: "KYD",
+ n: 136,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BEAC",
+ a: "XAF",
+ n: 950,
+ d: 0,
+ },
+ {
+ c: "CFA Franc BEAC",
+ a: "XAF",
+ n: 950,
+ d: 0,
+ },
+ {
+ c: "Chilean Peso",
+ a: "CLP",
+ n: 152,
+ d: 0,
+ },
+ {
+ c: "Unidad de Fomento",
+ a: "CLF",
+ n: 990,
+ d: 4,
+ },
+ {
+ c: "Yuan Renminbi",
+ a: "CNY",
+ n: 156,
+ d: 2,
+ },
+ {
+ c: "Australian Dollar",
+ a: "AUD",
+ n: 36,
+ d: 2,
+ },
+ {
+ c: "Australian Dollar",
+ a: "AUD",
+ n: 36,
+ d: 2,
+ },
+ {
+ c: "Colombian Peso",
+ a: "COP",
+ n: 170,
+ d: 2,
+ },
+ {
+ c: "Unidad de Valor Real",
+ a: "COU",
+ n: 970,
+ d: 2,
+ },
+ {
+ c: "Comorian Franc",
+ a: "KMF",
+ n: 174,
+ d: 0,
+ },
+ {
+ c: "Congolese Franc",
+ a: "CDF",
+ n: 976,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BEAC",
+ a: "XAF",
+ n: 950,
+ d: 0,
+ },
+ {
+ c: "New Zealand Dollar",
+ a: "NZD",
+ n: 554,
+ d: 2,
+ },
+ {
+ c: "Costa Rican Colon",
+ a: "CRC",
+ n: 188,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BCEAO",
+ a: "XOF",
+ n: 952,
+ d: 0,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Cuban Peso",
+ a: "CUP",
+ n: 192,
+ d: 2,
+ },
+ {
+ c: "Peso Convertible",
+ a: "CUC",
+ n: 931,
+ d: 2,
+ },
+ {
+ c: "Netherlands Antillean Guilder",
+ a: "ANG",
+ n: 532,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Czech Koruna",
+ a: "CZK",
+ n: 203,
+ d: 2,
+ },
+ {
+ c: "Danish Krone",
+ a: "DKK",
+ n: 208,
+ d: 2,
+ },
+ {
+ c: "Djibouti Franc",
+ a: "DJF",
+ n: 262,
+ d: 0,
+ },
+ {
+ c: "East Caribbean Dollar",
+ a: "XCD",
+ n: 951,
+ d: 2,
+ },
+ {
+ c: "Dominican Peso",
+ a: "DOP",
+ n: 214,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Egyptian Pound",
+ a: "EGP",
+ n: 818,
+ d: 2,
+ },
+ {
+ c: "El Salvador Colon",
+ a: "SVC",
+ n: 222,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BEAC",
+ a: "XAF",
+ n: 950,
+ d: 0,
+ },
+ {
+ c: "Nakfa",
+ a: "ERN",
+ n: 232,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Lilangeni",
+ a: "SZL",
+ n: 748,
+ d: 2,
+ },
+ {
+ c: "Ethiopian Birr",
+ a: "ETB",
+ n: 230,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Falkland Islands Pound",
+ a: "FKP",
+ n: 238,
+ d: 2,
+ },
+ {
+ c: "Danish Krone",
+ a: "DKK",
+ n: 208,
+ d: 2,
+ },
+ {
+ c: "Fiji Dollar",
+ a: "FJD",
+ n: 242,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "CFP Franc",
+ a: "XPF",
+ n: 953,
+ d: 0,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BEAC",
+ a: "XAF",
+ n: 950,
+ d: 0,
+ },
+ {
+ c: "Dalasi",
+ a: "GMD",
+ n: 270,
+ d: 2,
+ },
+ {
+ c: "Lari",
+ a: "GEL",
+ n: 981,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Ghana Cedi",
+ a: "GHS",
+ n: 936,
+ d: 2,
+ },
+ {
+ c: "Gibraltar Pound",
+ a: "GIP",
+ n: 292,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Danish Krone",
+ a: "DKK",
+ n: 208,
+ d: 2,
+ },
+ {
+ c: "East Caribbean Dollar",
+ a: "XCD",
+ n: 951,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Quetzal",
+ a: "GTQ",
+ n: 320,
+ d: 2,
+ },
+ {
+ c: "Pound Sterling",
+ a: "GBP",
+ n: 826,
+ d: 2,
+ },
+ {
+ c: "Guinean Franc",
+ a: "GNF",
+ n: 324,
+ d: 0,
+ },
+ {
+ c: "CFA Franc BCEAO",
+ a: "XOF",
+ n: 952,
+ d: 0,
+ },
+ {
+ c: "Guyana Dollar",
+ a: "GYD",
+ n: 328,
+ d: 2,
+ },
+ {
+ c: "Gourde",
+ a: "HTG",
+ n: 332,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Australian Dollar",
+ a: "AUD",
+ n: 36,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Lempira",
+ a: "HNL",
+ n: 340,
+ d: 2,
+ },
+ {
+ c: "Hong Kong Dollar",
+ a: "HKD",
+ n: 344,
+ d: 2,
+ },
+ {
+ c: "Forint",
+ a: "HUF",
+ n: 348,
+ d: 2,
+ },
+ {
+ c: "Iceland Krona",
+ a: "ISK",
+ n: 352,
+ d: 0,
+ },
+ {
+ c: "Indian Rupee",
+ a: "INR",
+ n: 356,
+ d: 2,
+ },
+ {
+ c: "Rupiah",
+ a: "IDR",
+ n: 360,
+ d: 2,
+ },
+ {
+ c: "SDR (Special Drawing Right)",
+ a: "XDR",
+ n: 960,
+ d: 0,
+ },
+ {
+ c: "Iranian Rial",
+ a: "IRR",
+ n: 364,
+ d: 2,
+ },
+ {
+ c: "Iraqi Dinar",
+ a: "IQD",
+ n: 368,
+ d: 3,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Pound Sterling",
+ a: "GBP",
+ n: 826,
+ d: 2,
+ },
+ {
+ c: "New Israeli Sheqel",
+ a: "ILS",
+ n: 376,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Jamaican Dollar",
+ a: "JMD",
+ n: 388,
+ d: 2,
+ },
+ {
+ c: "Yen",
+ a: "JPY",
+ n: 392,
+ d: 0,
+ },
+ {
+ c: "Pound Sterling",
+ a: "GBP",
+ n: 826,
+ d: 2,
+ },
+ {
+ c: "Jordanian Dinar",
+ a: "JOD",
+ n: 400,
+ d: 3,
+ },
+ {
+ c: "Tenge",
+ a: "KZT",
+ n: 398,
+ d: 2,
+ },
+ {
+ c: "Kenyan Shilling",
+ a: "KES",
+ n: 404,
+ d: 2,
+ },
+ {
+ c: "Australian Dollar",
+ a: "AUD",
+ n: 36,
+ d: 2,
+ },
+ {
+ c: "North Korean Won",
+ a: "KPW",
+ n: 408,
+ d: 2,
+ },
+ {
+ c: "Won",
+ a: "KRW",
+ n: 410,
+ d: 0,
+ },
+ {
+ c: "Kuwaiti Dinar",
+ a: "KWD",
+ n: 414,
+ d: 3,
+ },
+ {
+ c: "Som",
+ a: "KGS",
+ n: 417,
+ d: 2,
+ },
+ {
+ c: "Lao Kip",
+ a: "LAK",
+ n: 418,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Lebanese Pound",
+ a: "LBP",
+ n: 422,
+ d: 2,
+ },
+ {
+ c: "Loti",
+ a: "LSL",
+ n: 426,
+ d: 2,
+ },
+ {
+ c: "Rand",
+ a: "ZAR",
+ n: 710,
+ d: 2,
+ },
+ {
+ c: "Liberian Dollar",
+ a: "LRD",
+ n: 430,
+ d: 2,
+ },
+ {
+ c: "Libyan Dinar",
+ a: "LYD",
+ n: 434,
+ d: 3,
+ },
+ {
+ c: "Swiss Franc",
+ a: "CHF",
+ n: 756,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Pataca",
+ a: "MOP",
+ n: 446,
+ d: 2,
+ },
+ {
+ c: "Denar",
+ a: "MKD",
+ n: 807,
+ d: 2,
+ },
+ {
+ c: "Malagasy Ariary",
+ a: "MGA",
+ n: 969,
+ d: 2,
+ },
+ {
+ c: "Malawi Kwacha",
+ a: "MWK",
+ n: 454,
+ d: 2,
+ },
+ {
+ c: "Malaysian Ringgit",
+ a: "MYR",
+ n: 458,
+ d: 2,
+ },
+ {
+ c: "Rufiyaa",
+ a: "MVR",
+ n: 462,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BCEAO",
+ a: "XOF",
+ n: 952,
+ d: 0,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Ouguiya",
+ a: "MRU",
+ n: 929,
+ d: 2,
+ },
+ {
+ c: "Mauritius Rupee",
+ a: "MUR",
+ n: 480,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "ADB Unit of Account",
+ a: "XUA",
+ n: 965,
+ d: 0,
+ },
+ {
+ c: "Mexican Peso",
+ a: "MXN",
+ n: 484,
+ d: 2,
+ },
+ {
+ c: "Mexican Unidad de Inversion",
+ a: "MXV",
+ n: 979,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Moldovan Leu",
+ a: "MDL",
+ n: 498,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Tugrik",
+ a: "MNT",
+ n: 496,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "East Caribbean Dollar",
+ a: "XCD",
+ n: 951,
+ d: 2,
+ },
+ {
+ c: "Moroccan Dirham",
+ a: "MAD",
+ n: 504,
+ d: 2,
+ },
+ {
+ c: "Mozambique Metical",
+ a: "MZN",
+ n: 943,
+ d: 2,
+ },
+ {
+ c: "Kyat",
+ a: "MMK",
+ n: 104,
+ d: 2,
+ },
+ {
+ c: "Namibia Dollar",
+ a: "NAD",
+ n: 516,
+ d: 2,
+ },
+ {
+ c: "Rand",
+ a: "ZAR",
+ n: 710,
+ d: 2,
+ },
+ {
+ c: "Australian Dollar",
+ a: "AUD",
+ n: 36,
+ d: 2,
+ },
+ {
+ c: "Nepalese Rupee",
+ a: "NPR",
+ n: 524,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "CFP Franc",
+ a: "XPF",
+ n: 953,
+ d: 0,
+ },
+ {
+ c: "New Zealand Dollar",
+ a: "NZD",
+ n: 554,
+ d: 2,
+ },
+ {
+ c: "Cordoba Oro",
+ a: "NIO",
+ n: 558,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BCEAO",
+ a: "XOF",
+ n: 952,
+ d: 0,
+ },
+ {
+ c: "Naira",
+ a: "NGN",
+ n: 566,
+ d: 2,
+ },
+ {
+ c: "New Zealand Dollar",
+ a: "NZD",
+ n: 554,
+ d: 2,
+ },
+ {
+ c: "Australian Dollar",
+ a: "AUD",
+ n: 36,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Norwegian Krone",
+ a: "NOK",
+ n: 578,
+ d: 2,
+ },
+ {
+ c: "Rial Omani",
+ a: "OMR",
+ n: 512,
+ d: 3,
+ },
+ {
+ c: "Pakistan Rupee",
+ a: "PKR",
+ n: 586,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Balboa",
+ a: "PAB",
+ n: 590,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Kina",
+ a: "PGK",
+ n: 598,
+ d: 2,
+ },
+ {
+ c: "Guarani",
+ a: "PYG",
+ n: 600,
+ d: 0,
+ },
+ {
+ c: "Sol",
+ a: "PEN",
+ n: 604,
+ d: 2,
+ },
+ {
+ c: "Philippine Peso",
+ a: "PHP",
+ n: 608,
+ d: 2,
+ },
+ {
+ c: "New Zealand Dollar",
+ a: "NZD",
+ n: 554,
+ d: 2,
+ },
+ {
+ c: "Zloty",
+ a: "PLN",
+ n: 985,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Qatari Rial",
+ a: "QAR",
+ n: 634,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Romanian Leu",
+ a: "RON",
+ n: 946,
+ d: 2,
+ },
+ {
+ c: "Russian Ruble",
+ a: "RUB",
+ n: 643,
+ d: 2,
+ },
+ {
+ c: "Rwanda Franc",
+ a: "RWF",
+ n: 646,
+ d: 0,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Saint Helena Pound",
+ a: "SHP",
+ n: 654,
+ d: 2,
+ },
+ {
+ c: "East Caribbean Dollar",
+ a: "XCD",
+ n: 951,
+ d: 2,
+ },
+ {
+ c: "East Caribbean Dollar",
+ a: "XCD",
+ n: 951,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "East Caribbean Dollar",
+ a: "XCD",
+ n: 951,
+ d: 2,
+ },
+ {
+ c: "Tala",
+ a: "WST",
+ n: 882,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Dobra",
+ a: "STN",
+ n: 930,
+ d: 2,
+ },
+ {
+ c: "Saudi Riyal",
+ a: "SAR",
+ n: 682,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BCEAO",
+ a: "XOF",
+ n: 952,
+ d: 0,
+ },
+ {
+ c: "Serbian Dinar",
+ a: "RSD",
+ n: 941,
+ d: 2,
+ },
+ {
+ c: "Seychelles Rupee",
+ a: "SCR",
+ n: 690,
+ d: 2,
+ },
+ {
+ c: "Leone",
+ a: "SLL",
+ n: 694,
+ d: 2,
+ },
+ {
+ c: "Leone",
+ a: "SLE",
+ n: 925,
+ d: 2,
+ },
+ {
+ c: "Singapore Dollar",
+ a: "SGD",
+ n: 702,
+ d: 2,
+ },
+ {
+ c: "Netherlands Antillean Guilder",
+ a: "ANG",
+ n: 532,
+ d: 2,
+ },
+ {
+ c: "Sucre",
+ a: "XSU",
+ n: 994,
+ d: 0,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Solomon Islands Dollar",
+ a: "SBD",
+ n: 90,
+ d: 2,
+ },
+ {
+ c: "Somali Shilling",
+ a: "SOS",
+ n: 706,
+ d: 2,
+ },
+ {
+ c: "Rand",
+ a: "ZAR",
+ n: 710,
+ d: 2,
+ },
+ {
+ c: "South Sudanese Pound",
+ a: "SSP",
+ n: 728,
+ d: 2,
+ },
+ {
+ c: "Euro",
+ a: "EUR",
+ n: 978,
+ d: 2,
+ },
+ {
+ c: "Sri Lanka Rupee",
+ a: "LKR",
+ n: 144,
+ d: 2,
+ },
+ {
+ c: "Sudanese Pound",
+ a: "SDG",
+ n: 938,
+ d: 2,
+ },
+ {
+ c: "Surinam Dollar",
+ a: "SRD",
+ n: 968,
+ d: 2,
+ },
+ {
+ c: "Norwegian Krone",
+ a: "NOK",
+ n: 578,
+ d: 2,
+ },
+ {
+ c: "Swedish Krona",
+ a: "SEK",
+ n: 752,
+ d: 2,
+ },
+ {
+ c: "Swiss Franc",
+ a: "CHF",
+ n: 756,
+ d: 2,
+ },
+ {
+ c: "WIR Euro",
+ a: "CHE",
+ n: 947,
+ d: 2,
+ },
+ {
+ c: "WIR Franc",
+ a: "CHW",
+ n: 948,
+ d: 2,
+ },
+ {
+ c: "Syrian Pound",
+ a: "SYP",
+ n: 760,
+ d: 2,
+ },
+ {
+ c: "New Taiwan Dollar",
+ a: "TWD",
+ n: 901,
+ d: 2,
+ },
+ {
+ c: "Somoni",
+ a: "TJS",
+ n: 972,
+ d: 2,
+ },
+ {
+ c: "Tanzanian Shilling",
+ a: "TZS",
+ n: 834,
+ d: 2,
+ },
+ {
+ c: "Baht",
+ a: "THB",
+ n: 764,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "CFA Franc BCEAO",
+ a: "XOF",
+ n: 952,
+ d: 0,
+ },
+ {
+ c: "New Zealand Dollar",
+ a: "NZD",
+ n: 554,
+ d: 2,
+ },
+ {
+ c: "Pa'anga",
+ a: "TOP",
+ n: 776,
+ d: 2,
+ },
+ {
+ c: "Trinidad and Tobago Dollar",
+ a: "TTD",
+ n: 780,
+ d: 2,
+ },
+ {
+ c: "Tunisian Dinar",
+ a: "TND",
+ n: 788,
+ d: 3,
+ },
+ {
+ c: "Turkish Lira",
+ a: "TRY",
+ n: 949,
+ d: 2,
+ },
+ {
+ c: "Turkmenistan New Manat",
+ a: "TMT",
+ n: 934,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "Australian Dollar",
+ a: "AUD",
+ n: 36,
+ d: 2,
+ },
+ {
+ c: "Uganda Shilling",
+ a: "UGX",
+ n: 800,
+ d: 0,
+ },
+ {
+ c: "Hryvnia",
+ a: "UAH",
+ n: 980,
+ d: 2,
+ },
+ {
+ c: "UAE Dirham",
+ a: "AED",
+ n: 784,
+ d: 2,
+ },
+ {
+ c: "Pound Sterling",
+ a: "GBP",
+ n: 826,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "US Dollar (Next day)",
+ a: "USN",
+ n: 997,
+ d: 2,
+ },
+ {
+ c: "Peso Uruguayo",
+ a: "UYU",
+ n: 858,
+ d: 2,
+ },
+ {
+ c: "Uruguay Peso en Unidades Indexadas (UI)",
+ a: "UYI",
+ n: 940,
+ d: 0,
+ },
+ {
+ c: "Unidad Previsional",
+ a: "UYW",
+ n: 927,
+ d: 4,
+ },
+ {
+ c: "Uzbekistan Sum",
+ a: "UZS",
+ n: 860,
+ d: 2,
+ },
+ {
+ c: "Vatu",
+ a: "VUV",
+ n: 548,
+ d: 0,
+ },
+ {
+ c: "Bolívar Soberano",
+ a: "VES",
+ n: 928,
+ d: 2,
+ },
+ {
+ c: "Bolívar Soberano",
+ a: "VED",
+ n: 926,
+ d: 2,
+ },
+ {
+ c: "Dong",
+ a: "VND",
+ n: 704,
+ d: 0,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "US Dollar",
+ a: "USD",
+ n: 840,
+ d: 2,
+ },
+ {
+ c: "CFP Franc",
+ a: "XPF",
+ n: 953,
+ d: 0,
+ },
+ {
+ c: "Moroccan Dirham",
+ a: "MAD",
+ n: 504,
+ d: 2,
+ },
+ {
+ c: "Yemeni Rial",
+ a: "YER",
+ n: 886,
+ d: 2,
+ },
+ {
+ c: "Zambian Kwacha",
+ a: "ZMW",
+ n: 967,
+ d: 2,
+ },
+ {
+ c: "Zimbabwe Dollar",
+ a: "ZWL",
+ n: 932,
+ d: 2,
+ },
+ {
+ c: "Bond Markets Unit European Composite Unit (EURCO)",
+ a: "XBA",
+ n: 955,
+ d: 0,
+ },
+ {
+ c: "Bond Markets Unit European Monetary Unit (E.M.U.-6)",
+ a: "XBB",
+ n: 956,
+ d: 0,
+ },
+ {
+ c: "Bond Markets Unit European Unit of Account 9 (E.U.A.-9)",
+ a: "XBC",
+ n: 957,
+ d: 0,
+ },
+ {
+ c: "Bond Markets Unit European Unit of Account 17 (E.U.A.-17)",
+ a: "XBD",
+ n: 958,
+ d: 0,
+ },
+ {
+ c: "Codes specifically reserved for testing purposes",
+ a: "XTS",
+ n: 963,
+ d: 0,
+ },
+ {
+ c: "The codes assigned for transactions where no currency is involved",
+ a: "XXX",
+ n: 999,
+ d: 0,
+ },
+ {
+ c: "Gold",
+ a: "XAU",
+ n: 959,
+ d: 0,
+ },
+ {
+ c: "Palladium",
+ a: "XPD",
+ n: 964,
+ d: 0,
+ },
+ {
+ c: "Platinum",
+ a: "XPT",
+ n: 962,
+ d: 0,
+ },
+ {
+ c: "Silver",
+ a: "XAG",
+ n: 961,
+ d: 0,
+ },
+];
diff --git a/packages/taler-util/src/kdf.d.ts b/packages/taler-util/src/kdf.d.ts
index 80a6da41e..eba1455ff 100644
--- a/packages/taler-util/src/kdf.d.ts
+++ b/packages/taler-util/src/kdf.d.ts
@@ -1,5 +1,21 @@
export declare function sha512(data: Uint8Array): Uint8Array;
-export declare function hmac(digest: (d: Uint8Array) => Uint8Array, blockSize: number, key: Uint8Array, message: Uint8Array): Uint8Array;
-export declare function hmacSha512(key: Uint8Array, message: Uint8Array): Uint8Array;
-export declare function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array;
-export declare function kdf(outputLength: number, ikm: Uint8Array, salt: Uint8Array, info: Uint8Array): Uint8Array;
+export declare function hmac(
+ digest: (d: Uint8Array) => Uint8Array,
+ blockSize: number,
+ key: Uint8Array,
+ message: Uint8Array,
+): Uint8Array;
+export declare function hmacSha512(
+ key: Uint8Array,
+ message: Uint8Array,
+): Uint8Array;
+export declare function hmacSha256(
+ key: Uint8Array,
+ message: Uint8Array,
+): Uint8Array;
+export declare function kdf(
+ outputLength: number,
+ ikm: Uint8Array,
+ salt: Uint8Array,
+ info: Uint8Array,
+): Uint8Array;
diff --git a/packages/taler-util/src/kdf.js b/packages/taler-util/src/kdf.js
index 32f17beac..6cd3d1ddf 100644
--- a/packages/taler-util/src/kdf.js
+++ b/packages/taler-util/src/kdf.js
@@ -16,61 +16,60 @@
import * as nacl from "./nacl-fast.js";
import { sha256 } from "./sha256.js";
export function sha512(data) {
- return nacl.hash(data);
+ return nacl.hash(data);
}
export function hmac(digest, blockSize, key, message) {
- if (key.byteLength > blockSize) {
- key = digest(key);
- }
- if (key.byteLength < blockSize) {
- const k = key;
- key = new Uint8Array(blockSize);
- key.set(k, 0);
- }
- const okp = new Uint8Array(blockSize);
- const ikp = new Uint8Array(blockSize);
- for (let i = 0; i < blockSize; i++) {
- ikp[i] = key[i] ^ 0x36;
- okp[i] = key[i] ^ 0x5c;
- }
- const b1 = new Uint8Array(blockSize + message.byteLength);
- b1.set(ikp, 0);
- b1.set(message, blockSize);
- const h0 = digest(b1);
- const b2 = new Uint8Array(blockSize + h0.length);
- b2.set(okp, 0);
- b2.set(h0, blockSize);
- return digest(b2);
+ if (key.byteLength > blockSize) {
+ key = digest(key);
+ }
+ if (key.byteLength < blockSize) {
+ const k = key;
+ key = new Uint8Array(blockSize);
+ key.set(k, 0);
+ }
+ const okp = new Uint8Array(blockSize);
+ const ikp = new Uint8Array(blockSize);
+ for (let i = 0; i < blockSize; i++) {
+ ikp[i] = key[i] ^ 0x36;
+ okp[i] = key[i] ^ 0x5c;
+ }
+ const b1 = new Uint8Array(blockSize + message.byteLength);
+ b1.set(ikp, 0);
+ b1.set(message, blockSize);
+ const h0 = digest(b1);
+ const b2 = new Uint8Array(blockSize + h0.length);
+ b2.set(okp, 0);
+ b2.set(h0, blockSize);
+ return digest(b2);
}
export function hmacSha512(key, message) {
- return hmac(sha512, 128, key, message);
+ return hmac(sha512, 128, key, message);
}
export function hmacSha256(key, message) {
- return hmac(sha256, 64, key, message);
+ return hmac(sha256, 64, key, message);
}
export function kdf(outputLength, ikm, salt, info) {
- // extract
- const prk = hmacSha512(salt, ikm);
- // expand
- const N = Math.ceil(outputLength / 32);
- const output = new Uint8Array(N * 32);
- for (let i = 0; i < N; i++) {
- let buf;
- if (i == 0) {
- buf = new Uint8Array(info.byteLength + 1);
- buf.set(info, 0);
- }
- else {
- buf = new Uint8Array(info.byteLength + 1 + 32);
- for (let j = 0; j < 32; j++) {
- buf[j] = output[(i - 1) * 32 + j];
- }
- buf.set(info, 32);
- }
- buf[buf.length - 1] = i + 1;
- const chunk = hmacSha256(prk, buf);
- output.set(chunk, i * 32);
+ // extract
+ const prk = hmacSha512(salt, ikm);
+ // expand
+ const N = Math.ceil(outputLength / 32);
+ const output = new Uint8Array(N * 32);
+ for (let i = 0; i < N; i++) {
+ let buf;
+ if (i == 0) {
+ buf = new Uint8Array(info.byteLength + 1);
+ buf.set(info, 0);
+ } else {
+ buf = new Uint8Array(info.byteLength + 1 + 32);
+ for (let j = 0; j < 32; j++) {
+ buf[j] = output[(i - 1) * 32 + j];
+ }
+ buf.set(info, 32);
}
- return output.slice(0, outputLength);
+ buf[buf.length - 1] = i + 1;
+ const chunk = hmacSha256(prk, buf);
+ output.set(chunk, i * 32);
+ }
+ return output.slice(0, outputLength);
}
-//# sourceMappingURL=kdf.js.map \ No newline at end of file
+//# sourceMappingURL=kdf.js.map
diff --git a/packages/taler-util/src/kdf.ts b/packages/taler-util/src/kdf.ts
index 7710de90c..8f4314340 100644
--- a/packages/taler-util/src/kdf.ts
+++ b/packages/taler-util/src/kdf.ts
@@ -58,50 +58,3 @@ export function hmacSha512(key: Uint8Array, message: Uint8Array): Uint8Array {
export function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array {
return hmac(sha256, 64, key, message);
}
-
-/**
- * HMAC-SHA512-SHA256 (see RFC 5869).
- */
-export function kdfKw(args: {
- outputLength: number;
- ikm: Uint8Array;
- salt?: Uint8Array;
- info?: Uint8Array;
-}) {
- return kdf(args.outputLength, args.ikm, args.salt, args.info);
-}
-
-export function kdf(
- outputLength: number,
- ikm: Uint8Array,
- salt?: Uint8Array,
- info?: Uint8Array,
-): Uint8Array {
- salt = salt ?? new Uint8Array(64);
- // extract
- const prk = hmacSha512(salt, ikm);
-
- info = info ?? new Uint8Array(0);
-
- // expand
- const N = Math.ceil(outputLength / 32);
- const output = new Uint8Array(N * 32);
- for (let i = 0; i < N; i++) {
- let buf;
- if (i == 0) {
- buf = new Uint8Array(info.byteLength + 1);
- buf.set(info, 0);
- } else {
- buf = new Uint8Array(info.byteLength + 1 + 32);
- for (let j = 0; j < 32; j++) {
- buf[j] = output[(i - 1) * 32 + j];
- }
- buf.set(info, 32);
- }
- buf[buf.length - 1] = i + 1;
- const chunk = hmacSha256(prk, buf);
- output.set(chunk, i * 32);
- }
-
- return output.slice(0, outputLength);
-}
diff --git a/packages/taler-util/src/libeufin-api-types.ts b/packages/taler-util/src/libeufin-api-types.ts
new file mode 100644
index 000000000..aa3d0cb7a
--- /dev/null
+++ b/packages/taler-util/src/libeufin-api-types.ts
@@ -0,0 +1,31 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export type FacadeCredentials =
+ | NoFacadeCredentials
+ | BasicAuthFacadeCredentials;
+export interface NoFacadeCredentials {
+ type: "none";
+}
+export interface BasicAuthFacadeCredentials {
+ type: "basic";
+
+ // Username to use to authenticate
+ username: string;
+
+ // Password to use to authenticate
+ password: string;
+}
diff --git a/packages/taler-util/src/libtool-version.test.ts b/packages/taler-util/src/libtool-version.test.ts
index d35642518..addd1b418 100644
--- a/packages/taler-util/src/libtool-version.test.ts
+++ b/packages/taler-util/src/libtool-version.test.ts
@@ -14,7 +14,7 @@
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import * as LibtoolVersion from "./libtool-version.js";
+import { LibtoolVersion } from "./libtool-version.js";
import test from "ava";
@@ -45,4 +45,6 @@ test("version comparison", (t) => {
compatible: true,
currentCmp: 0,
});
+ t.true(LibtoolVersion.compare("42:0:1", "41:0:0")?.compatible);
+ t.true(LibtoolVersion.compare("41:0:0", "42:0:1")?.compatible);
});
diff --git a/packages/taler-util/src/libtool-version.ts b/packages/taler-util/src/libtool-version.ts
index 5e9d0b74e..ed11a4e95 100644
--- a/packages/taler-util/src/libtool-version.ts
+++ b/packages/taler-util/src/libtool-version.ts
@@ -27,62 +27,65 @@ export interface VersionMatchResult {
* Is the first version compatible with the second?
*/
compatible: boolean;
+
/**
- * Is the first version older (-1), newser (+1) or
+ * Is the first version older (-1), newer (+1) or
* identical (0)?
*/
currentCmp: number;
}
-interface Version {
+export interface Version {
current: number;
revision: number;
age: number;
}
-/**
- * Compare two libtool-style version strings.
- */
-export function compare(
- me: string,
- other: string,
-): VersionMatchResult | undefined {
- const meVer = parseVersion(me);
- const otherVer = parseVersion(other);
-
- if (!(meVer && otherVer)) {
- return undefined;
- }
+export namespace LibtoolVersion {
+ /**
+ * Compare two libtool-style version strings.
+ */
+ export function compare(
+ me: string,
+ other: string,
+ ): VersionMatchResult | undefined {
+ const meVer = parseVersion(me);
+ const otherVer = parseVersion(other);
- const compatible =
- meVer.current - meVer.age <= otherVer.current &&
- meVer.current >= otherVer.current - otherVer.age;
+ if (!(meVer && otherVer)) {
+ return undefined;
+ }
- const currentCmp = Math.sign(meVer.current - otherVer.current);
+ const compatible =
+ meVer.current - meVer.age <= otherVer.current &&
+ meVer.current >= otherVer.current - otherVer.age;
- return { compatible, currentCmp };
-}
+ const currentCmp = Math.sign(meVer.current - otherVer.current);
-function parseVersion(v: string): Version | undefined {
- const [currentStr, revisionStr, ageStr, ...rest] = v.split(":");
- if (rest.length !== 0) {
- return undefined;
+ return { compatible, currentCmp };
}
- const current = Number.parseInt(currentStr);
- const revision = Number.parseInt(revisionStr);
- const age = Number.parseInt(ageStr);
- if (Number.isNaN(current)) {
- return undefined;
- }
+ export function parseVersion(v: string): Version | undefined {
+ const [currentStr, revisionStr, ageStr, ...rest] = v.split(":");
+ if (rest.length !== 0) {
+ return undefined;
+ }
+ const current = Number.parseInt(currentStr);
+ const revision = Number.parseInt(revisionStr);
+ const age = Number.parseInt(ageStr);
- if (Number.isNaN(revision)) {
- return undefined;
- }
+ if (Number.isNaN(current)) {
+ return undefined;
+ }
- if (Number.isNaN(age)) {
- return undefined;
- }
+ if (Number.isNaN(revision)) {
+ return undefined;
+ }
+
+ if (Number.isNaN(age)) {
+ return undefined;
+ }
- return { current, revision, age };
+ return { current, revision, age };
+ }
}
diff --git a/packages/taler-util/src/logging.ts b/packages/taler-util/src/logging.ts
index 0037d95a3..17bb184f7 100644
--- a/packages/taler-util/src/logging.ts
+++ b/packages/taler-util/src/logging.ts
@@ -23,6 +23,97 @@ const isNode =
typeof process.release !== "undefined" &&
process.release.name === "node";
+export enum LogLevel {
+ Trace = "trace",
+ Message = "message",
+ Info = "info",
+ Warn = "warn",
+ Error = "error",
+ None = "none",
+}
+
+let globalLogLevel = LogLevel.Info;
+const byTagLogLevel: Record<string, LogLevel> = {};
+
+let nativeLogging: boolean = false;
+
+// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/toString
+Error.prototype.toString = function () {
+ if (
+ this === null ||
+ (typeof this !== "object" && typeof this !== "function")
+ ) {
+ throw new TypeError();
+ }
+ let name = this.name;
+ name = name === undefined ? "Error" : `${name}`;
+ let msg = this.message;
+ msg = msg === undefined ? "" : `${msg}`;
+
+ let cause = "";
+ if ("cause" in this) {
+ cause = `\n Caused by: ${this.cause}`;
+ }
+ return `${name}: ${msg}${cause}`;
+};
+
+export function getGlobalLogLevel(): string {
+ return globalLogLevel;
+}
+
+export function setGlobalLogLevelFromString(logLevelStr: string): void {
+ globalLogLevel = getLevelForString(logLevelStr);
+}
+
+export function setLogLevelFromString(tag: string, logLevelStr: string): void {
+ byTagLogLevel[tag] = getLevelForString(logLevelStr);
+}
+
+export function enableNativeLogging() {
+ nativeLogging = true;
+}
+
+function getLevelForString(logLevelStr: string): LogLevel {
+ switch (logLevelStr.toLowerCase()) {
+ case "trace":
+ return LogLevel.Trace;
+ case "info":
+ return LogLevel.Info;
+ case "warn":
+ case "warning":
+ return LogLevel.Warn;
+ case "error":
+ return LogLevel.Error;
+ case "none":
+ return LogLevel.None;
+ default:
+ if (isNode) {
+ process.stderr.write(`Invalid log level, defaulting to WARNING\n`);
+ } else {
+ console.warn(`Invalid log level, defaulting to WARNING`);
+ }
+ return LogLevel.Warn;
+ }
+}
+
+function writeNativeLog(
+ message: any,
+ tag: string,
+ level: number,
+ args: any[],
+): void {
+ const logFn = (globalThis as any).__nativeLog;
+ if (logFn) {
+ let m: string;
+ if (args.length == 0) {
+ m = message;
+ } else {
+ m = message + " " + args.toString();
+ }
+ logFn(level, tag, message);
+ }
+}
+
function writeNodeLog(
message: any,
tag: string,
@@ -30,22 +121,23 @@ function writeNodeLog(
args: any[],
): void {
try {
- process.stderr.write(`${new Date().toISOString()} ${tag} ${level} `);
- process.stderr.write(`${message}`);
+ let msg = `${new Date().toISOString()} ${tag} ${level} ${message}`;
if (args.length != 0) {
- process.stderr.write(" ");
- process.stderr.write(JSON.stringify(args, undefined, 2));
+ msg += ` ${JSON.stringify(args, undefined, 2)}\n`;
+ } else {
+ msg += `\n`;
}
- process.stderr.write("\n");
+ process.stderr.write(msg);
} catch (e) {
// This can happen when we're trying to log something that doesn't want to be
// converted to a string.
- process.stderr.write(`${new Date().toISOString()} (logger) FATAL `);
+ let msg = `${new Date().toISOString()} (logger) FATAL `;
if (e instanceof Error) {
- process.stderr.write("failed to write log: ");
- process.stderr.write(e.message);
+ msg += `failed to write log: ${e.message}\n`;
+ } else {
+ msg += "failed to write log\n";
}
- process.stderr.write("\n");
+ process.stderr.write(msg);
}
}
@@ -56,22 +148,70 @@ function writeNodeLog(
export class Logger {
constructor(private tag: string) {}
- shouldLogTrace() {
- // FIXME: Implement logic to check loglevel
- return true;
+ shouldLogTrace(): boolean {
+ const level = byTagLogLevel[this.tag] ?? globalLogLevel;
+ switch (level) {
+ case LogLevel.Trace:
+ return true;
+ case LogLevel.Message:
+ case LogLevel.Info:
+ case LogLevel.Warn:
+ case LogLevel.Error:
+ case LogLevel.None:
+ return false;
+ }
+ }
+
+ shouldLogInfo(): boolean {
+ const level = byTagLogLevel[this.tag] ?? globalLogLevel;
+ switch (level) {
+ case LogLevel.Trace:
+ case LogLevel.Message:
+ case LogLevel.Info:
+ return true;
+ case LogLevel.Warn:
+ case LogLevel.Error:
+ case LogLevel.None:
+ return false;
+ }
}
- shouldLogInfo() {
- // FIXME: Implement logic to check loglevel
- return true;
+ shouldLogWarn(): boolean {
+ const level = byTagLogLevel[this.tag] ?? globalLogLevel;
+ switch (level) {
+ case LogLevel.Trace:
+ case LogLevel.Message:
+ case LogLevel.Info:
+ case LogLevel.Warn:
+ return true;
+ case LogLevel.Error:
+ case LogLevel.None:
+ return false;
+ }
}
- shouldLogWarn() {
- // FIXME: Implement logic to check loglevel
- return true;
+ shouldLogError(): boolean {
+ const level = byTagLogLevel[this.tag] ?? globalLogLevel;
+ switch (level) {
+ case LogLevel.Trace:
+ case LogLevel.Message:
+ case LogLevel.Info:
+ case LogLevel.Warn:
+ case LogLevel.Error:
+ return true;
+ case LogLevel.None:
+ return false;
+ }
}
info(message: string, ...args: any[]): void {
+ if (!this.shouldLogInfo()) {
+ return;
+ }
+ if (nativeLogging) {
+ writeNativeLog(message, this.tag, 2, args);
+ return;
+ }
if (isNode) {
writeNodeLog(message, this.tag, "INFO", args);
} else {
@@ -83,6 +223,13 @@ export class Logger {
}
warn(message: string, ...args: any[]): void {
+ if (!this.shouldLogWarn()) {
+ return;
+ }
+ if (nativeLogging) {
+ writeNativeLog(message, this.tag, 3, args);
+ return;
+ }
if (isNode) {
writeNodeLog(message, this.tag, "WARN", args);
} else {
@@ -94,6 +241,13 @@ export class Logger {
}
error(message: string, ...args: any[]): void {
+ if (!this.shouldLogError()) {
+ return;
+ }
+ if (nativeLogging) {
+ writeNativeLog(message, this.tag, 4, args);
+ return;
+ }
if (isNode) {
writeNodeLog(message, this.tag, "ERROR", args);
} else {
@@ -104,7 +258,14 @@ export class Logger {
}
}
- trace(message: any, ...args: any[]): void {
+ trace(message: string, ...args: any[]): void {
+ if (!this.shouldLogTrace()) {
+ return;
+ }
+ if (nativeLogging) {
+ writeNativeLog(message, this.tag, 1, args);
+ return;
+ }
if (isNode) {
writeNodeLog(message, this.tag, "TRACE", args);
} else {
@@ -114,4 +275,12 @@ export class Logger {
);
}
}
+
+ reportBreak(): void {
+ if (!this.shouldLogError()) {
+ return;
+ }
+ const location = new Error("programming error");
+ this.error(`assertion failed: ${location.stack}`);
+ }
}
diff --git a/packages/taler-wallet-cli/src/harness/merchantApiTypes.ts b/packages/taler-util/src/merchant-api-types.ts
index a93a0ed25..639ae8d13 100644
--- a/packages/taler-wallet-cli/src/harness/merchantApiTypes.ts
+++ b/packages/taler-util/src/merchant-api-types.ts
@@ -25,33 +25,41 @@
* Imports.
*/
import {
- ContractTerms,
- Duration,
- Codec,
- buildCodecForObject,
- codecForString,
- codecOptional,
- codecForConstString,
- codecForBoolean,
- codecForNumber,
- codecForContractTerms,
- codecForAny,
- buildCodecForUnion,
+ AbsoluteTime,
AmountString,
- Timestamp,
+ Codec,
CoinPublicKeyString,
EddsaPublicKeyString,
+ ExchangeWireAccount,
+ FacadeCredentials,
+ MerchantContractTerms,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ buildCodecForObject,
+ buildCodecForUnion,
codecForAmountString,
+ codecForAny,
+ codecForBoolean,
+ codecForCheckPaymentClaimedResponse,
+ codecForCheckPaymentUnpaidResponse,
+ codecForConstString,
+ codecForExchangeWireAccount,
+ codecForList,
+ codecForMerchantContractTerms,
+ codecForNumber,
+ codecForString,
+ codecForTimestamp,
+ codecOptional,
} from "@gnu-taler/taler-util";
-export interface PostOrderRequest {
+export interface MerchantPostOrderRequest {
// The order must at least contain the minimal
// order detail, but can override all
- order: Partial<ContractTerms>;
+ order: Partial<MerchantContractTerms>;
// if set, the backend will then set the refund deadline to the current
// time plus the specified delay.
- refund_delay?: Duration;
+ refund_delay?: TalerProtocolDuration;
// specifies the payment target preferred by the client. Can be used
// to select among the various (active) wire methods supported by the instance.
@@ -68,58 +76,46 @@ export interface PostOrderRequest {
export type ClaimToken = string;
-export interface PostOrderResponse {
+export interface MerchantPostOrderResponse {
order_id: string;
token?: ClaimToken;
}
-export const codecForPostOrderResponse = (): Codec<PostOrderResponse> =>
- buildCodecForObject<PostOrderResponse>()
- .property("order_id", codecForString())
- .property("token", codecOptional(codecForString()))
+export const codecForMerchantPostOrderResponse =
+ (): Codec<MerchantPostOrderResponse> =>
+ buildCodecForObject<MerchantPostOrderResponse>()
+ .property("order_id", codecForString())
+ .property("token", codecOptional(codecForString()))
+ .build("PostOrderResponse");
+
+export const codecForMerchantRefundDetails = (): Codec<RefundDetails> =>
+ buildCodecForObject<RefundDetails>()
+ .property("reason", codecForString())
+ .property("pending", codecForBoolean())
+ .property("amount", codecForAmountString())
+ .property("timestamp", codecForTimestamp)
.build("PostOrderResponse");
-export const codecForCheckPaymentPaidResponse = (): Codec<CheckPaymentPaidResponse> =>
- buildCodecForObject<CheckPaymentPaidResponse>()
- .property("order_status_url", codecForString())
- .property("order_status", codecForConstString("paid"))
- .property("refunded", codecForBoolean())
- .property("wired", codecForBoolean())
- .property("deposit_total", codecForAmountString())
- .property("exchange_ec", codecForNumber())
- .property("exchange_hc", codecForNumber())
- .property("refund_amount", codecForAmountString())
- .property("contract_terms", codecForContractTerms())
- // FIXME: specify
- .property("wire_details", codecForAny())
- .property("wire_reports", codecForAny())
- .property("refund_details", codecForAny())
- .build("CheckPaymentPaidResponse");
-
-export const codecForCheckPaymentUnpaidResponse = (): Codec<CheckPaymentUnpaidResponse> =>
- buildCodecForObject<CheckPaymentUnpaidResponse>()
- .property("order_status", codecForConstString("unpaid"))
- .property("taler_pay_uri", codecForString())
- .property("order_status_url", codecForString())
- .property("already_paid_order_id", codecOptional(codecForString()))
- .build("CheckPaymentPaidResponse");
-
-export const codecForCheckPaymentClaimedResponse = (): Codec<CheckPaymentClaimedResponse> =>
- buildCodecForObject<CheckPaymentClaimedResponse>()
- .property("order_status", codecForConstString("claimed"))
- .property("contract_terms", codecForContractTerms())
- .build("CheckPaymentClaimedResponse");
-
-export const codecForMerchantOrderPrivateStatusResponse = (): Codec<MerchantOrderPrivateStatusResponse> =>
- buildCodecForUnion<MerchantOrderPrivateStatusResponse>()
- .discriminateOn("order_status")
- .alternative("paid", codecForCheckPaymentPaidResponse())
- .alternative("unpaid", codecForCheckPaymentUnpaidResponse())
- .alternative("claimed", codecForCheckPaymentClaimedResponse())
- .build("MerchantOrderPrivateStatusResponse");
+export const codecForMerchantCheckPaymentPaidResponse =
+ (): Codec<MerchantCheckPaymentPaidResponse> =>
+ buildCodecForObject<MerchantCheckPaymentPaidResponse>()
+ .property("order_status_url", codecForString())
+ .property("order_status", codecForConstString("paid"))
+ .property("refunded", codecForBoolean())
+ .property("wired", codecForBoolean())
+ .property("deposit_total", codecForAmountString())
+ .property("exchange_ec", codecForNumber())
+ .property("exchange_hc", codecForNumber())
+ .property("refund_amount", codecForAmountString())
+ .property("contract_terms", codecForMerchantContractTerms())
+ // FIXME: specify
+ .property("wire_details", codecForAny())
+ .property("wire_reports", codecForAny())
+ .property("refund_details", codecForAny())
+ .build("CheckPaymentPaidResponse");
export type MerchantOrderPrivateStatusResponse =
- | CheckPaymentPaidResponse
+ | MerchantCheckPaymentPaidResponse
| CheckPaymentUnpaidResponse
| CheckPaymentClaimedResponse;
@@ -127,10 +123,10 @@ export interface CheckPaymentClaimedResponse {
// Wallet claimed the order, but didn't pay yet.
order_status: "claimed";
- contract_terms: ContractTerms;
+ contract_terms: MerchantContractTerms;
}
-export interface CheckPaymentPaidResponse {
+export interface MerchantCheckPaymentPaidResponse {
// did the customer pay for this contract
order_status: "paid";
@@ -159,7 +155,7 @@ export interface CheckPaymentPaidResponse {
refund_amount: AmountString;
// Contract terms
- contract_terms: ContractTerms;
+ contract_terms: MerchantContractTerms;
// Ihe wire transfer status from the exchange for this order if available, otherwise empty array
wire_details: TransactionWireTransfer[];
@@ -195,7 +191,10 @@ export interface RefundDetails {
reason: string;
// when was the refund approved
- timestamp: Timestamp;
+ timestamp: TalerProtocolTimestamp;
+
+ // has not been taken yet
+ pending: boolean;
// Total amount that was refunded (minus a refund fee).
amount: AmountString;
@@ -209,7 +208,7 @@ export interface TransactionWireTransfer {
wtid: string;
// execution time of the wire transfer
- execution_time: Timestamp;
+ execution_time: AbsoluteTime;
// Total amount that has been wire transferred
// to the merchant
@@ -237,20 +236,15 @@ export interface TransactionWireReport {
coin_pub: CoinPublicKeyString;
}
-export interface TippingReserveStatus {
- // Array of all known reserves (possibly empty!)
- reserves: ReserveStatusEntry[];
-}
-
export interface ReserveStatusEntry {
// Public key of the reserve
reserve_pub: string;
// Timestamp when it was established
- creation_time: Timestamp;
+ creation_time: AbsoluteTime;
// Timestamp when it expires
- expiration_time: Timestamp;
+ expiration_time: AbsoluteTime;
// Initial amount as per reserve creation call
merchant_initial_amount: AmountString;
@@ -269,33 +263,6 @@ export interface ReserveStatusEntry {
active: boolean;
}
-export interface TipCreateConfirmation {
- // Unique tip identifier for the tip that was created.
- tip_id: string;
-
- // taler://tip URI for the tip
- taler_tip_uri: string;
-
- // URL that will directly trigger processing
- // the tip when the browser is redirected to it
- tip_status_url: string;
-
- // when does the tip expire
- tip_expiration: Timestamp;
-}
-
-export interface TipCreateRequest {
- // Amount that the customer should be tipped
- amount: AmountString;
-
- // Justification for giving the tip
- justification: string;
-
- // URL that the user should be directed to after tipping,
- // will be included in the tip_token.
- next_url: string;
-}
-
export interface MerchantInstancesResponse {
// List of instances that are present in the backend (see Instance)
instances: MerchantInstanceDetail[];
@@ -316,3 +283,70 @@ export interface MerchantInstanceDetail {
// front-ends do not have to support wallets selecting payment targets.
payment_targets: string[];
}
+
+export interface MerchantTemplateContractDetails {
+ // Human-readable summary for the template.
+ summary?: string;
+
+ // The price is imposed by the merchant and cannot be changed by the customer.
+ // This parameter is optional.
+ amount?: string;
+
+ // Minimum age buyer must have (in years). Default is 0.
+ minimum_age: number;
+
+ // The time the customer need to pay before his order will be deleted.
+ // It is deleted if the customer did not pay and if the duration is over.
+ pay_duration: TalerProtocolDuration;
+}
+
+export interface MerchantTemplateAddDetails {
+ // Template ID to use.
+ template_id: string;
+
+ // Human-readable description for the template.
+ template_description: string;
+
+ // A base64-encoded image selected by the merchant.
+ // This parameter is optional.
+ // We are not sure about it.
+ image?: string;
+
+ // Additional information in a separate template.
+ template_contract: MerchantTemplateContractDetails;
+
+ // OTP device ID.
+ // This parameter is optional.
+ otp_id?: string;
+}
+
+export interface MerchantReserveCreateConfirmation {
+ // Public key identifying the reserve.
+ reserve_pub: EddsaPublicKeyString;
+
+ // Wire accounts of the exchange where to transfer the funds.
+ accounts: ExchangeWireAccount[];
+}
+
+export const codecForMerchantReserveCreateConfirmation =
+ (): Codec<MerchantReserveCreateConfirmation> =>
+ buildCodecForObject<MerchantReserveCreateConfirmation>()
+ .property("accounts", codecForList(codecForExchangeWireAccount()))
+ .property("reserve_pub", codecForString())
+ .build("MerchantReserveCreateConfirmation");
+
+export interface AccountAddDetails {
+ // payto:// URI of the account.
+ payto_uri: string;
+
+ // URL from where the merchant can download information
+ // about incoming wire transfers to this account.
+ credit_facade_url?: string;
+
+ // Credentials to use when accessing the credit facade.
+ // Never returned on a GET (as this may be somewhat
+ // sensitive data). Can be set in POST
+ // or PATCH requests to update (or delete) credentials.
+ // To really delete credentials, set them to the type: "none".
+ credit_facade_credentials?: FacadeCredentials;
+}
diff --git a/packages/taler-util/src/nacl-fast.ts b/packages/taler-util/src/nacl-fast.ts
index 500ac11c9..c45674bef 100644
--- a/packages/taler-util/src/nacl-fast.ts
+++ b/packages/taler-util/src/nacl-fast.ts
@@ -24,94 +24,24 @@ const gf0 = gf();
const gf1 = gf([1]);
const _121665 = gf([0xdb41, 1]);
const D = gf([
- 0x78a3,
- 0x1359,
- 0x4dca,
- 0x75eb,
- 0xd8ab,
- 0x4141,
- 0x0a4d,
- 0x0070,
- 0xe898,
- 0x7779,
- 0x4079,
- 0x8cc7,
- 0xfe73,
- 0x2b6f,
- 0x6cee,
- 0x5203,
+ 0x78a3, 0x1359, 0x4dca, 0x75eb, 0xd8ab, 0x4141, 0x0a4d, 0x0070, 0xe898,
+ 0x7779, 0x4079, 0x8cc7, 0xfe73, 0x2b6f, 0x6cee, 0x5203,
]);
const D2 = gf([
- 0xf159,
- 0x26b2,
- 0x9b94,
- 0xebd6,
- 0xb156,
- 0x8283,
- 0x149a,
- 0x00e0,
- 0xd130,
- 0xeef3,
- 0x80f2,
- 0x198e,
- 0xfce7,
- 0x56df,
- 0xd9dc,
- 0x2406,
+ 0xf159, 0x26b2, 0x9b94, 0xebd6, 0xb156, 0x8283, 0x149a, 0x00e0, 0xd130,
+ 0xeef3, 0x80f2, 0x198e, 0xfce7, 0x56df, 0xd9dc, 0x2406,
]);
const X = gf([
- 0xd51a,
- 0x8f25,
- 0x2d60,
- 0xc956,
- 0xa7b2,
- 0x9525,
- 0xc760,
- 0x692c,
- 0xdc5c,
- 0xfdd6,
- 0xe231,
- 0xc0a4,
- 0x53fe,
- 0xcd6e,
- 0x36d3,
- 0x2169,
+ 0xd51a, 0x8f25, 0x2d60, 0xc956, 0xa7b2, 0x9525, 0xc760, 0x692c, 0xdc5c,
+ 0xfdd6, 0xe231, 0xc0a4, 0x53fe, 0xcd6e, 0x36d3, 0x2169,
]);
const Y = gf([
- 0x6658,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
- 0x6666,
+ 0x6658, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666,
+ 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666, 0x6666,
]);
const I = gf([
- 0xa0b0,
- 0x4a0e,
- 0x1b27,
- 0xc4ee,
- 0xe478,
- 0xad2f,
- 0x1806,
- 0x2f43,
- 0xd7a7,
- 0x3dfb,
- 0x0099,
- 0x2b4d,
- 0xdf0b,
- 0x4fc1,
- 0x2480,
- 0x2b83,
+ 0xa0b0, 0x4a0e, 0x1b27, 0xc4ee, 0xe478, 0xad2f, 0x1806, 0x2f43, 0xd7a7,
+ 0x3dfb, 0x0099, 0x2b4d, 0xdf0b, 0x4fc1, 0x2480, 0x2b83,
]);
function ts64(x: Uint8Array, i: number, h: number, l: number): void {
@@ -653,22 +583,7 @@ function core_hsalsa20(
}
var sigma = new Uint8Array([
- 101,
- 120,
- 112,
- 97,
- 110,
- 100,
- 32,
- 51,
- 50,
- 45,
- 98,
- 121,
- 116,
- 101,
- 32,
- 107,
+ 101, 120, 112, 97, 110, 100, 32, 51, 50, 45, 98, 121, 116, 101, 32, 107,
]);
// "expand 32-byte k"
@@ -1854,6 +1769,74 @@ function crypto_scalarmult_base(q: Uint8Array, n: Uint8Array): number {
return crypto_scalarmult(q, n, _9);
}
+export function crypto_scalarmult_noclamp(
+ q: Uint8Array,
+ n: Uint8Array,
+ p: Uint8Array,
+): number {
+ const z = new Uint8Array(32);
+ const x = new Float64Array(80);
+ let r;
+ let i;
+ const a = gf(),
+ b = gf(),
+ c = gf(),
+ d = gf(),
+ e = gf(),
+ f = gf();
+ for (i = 0; i < 31; i++) z[i] = n[i];
+ unpack25519(x, p);
+ for (i = 0; i < 16; i++) {
+ b[i] = x[i];
+ d[i] = a[i] = c[i] = 0;
+ }
+ a[0] = d[0] = 1;
+ for (i = 254; i >= 0; --i) {
+ r = (z[i >>> 3] >>> (i & 7)) & 1;
+ sel25519(a, b, r);
+ sel25519(c, d, r);
+ A(e, a, c);
+ Z(a, a, c);
+ A(c, b, d);
+ Z(b, b, d);
+ S(d, e);
+ S(f, a);
+ M(a, c, a);
+ M(c, b, e);
+ A(e, a, c);
+ Z(a, a, c);
+ S(b, a);
+ Z(c, d, f);
+ M(a, c, _121665);
+ A(a, a, d);
+ M(c, c, a);
+ M(a, d, f);
+ M(d, b, x);
+ S(b, e);
+ sel25519(a, b, r);
+ sel25519(c, d, r);
+ }
+ for (i = 0; i < 16; i++) {
+ x[i + 16] = a[i];
+ x[i + 32] = c[i];
+ x[i + 48] = b[i];
+ x[i + 64] = d[i];
+ }
+ const x32 = x.subarray(32);
+ const x16 = x.subarray(16);
+ inv25519(x32, x32);
+ M(x16, x16, x32);
+ pack25519(q, x16);
+ return 0;
+}
+
+export function crypto_scalarmult_base_noclamp(
+ q: Uint8Array,
+ n: Uint8Array,
+): number {
+ return crypto_scalarmult_noclamp(q, n, _9);
+}
+
// prettier-ignore
const K = [
0x428a2f98, 0xd728ae22, 0x71374491, 0x23ef65cd,
@@ -2533,6 +2516,9 @@ function pack(r: Uint8Array, p: Float64Array[]): void {
r[31] ^= par25519(tx) << 7;
}
+/**
+ * Ed25519 scalar multiplication
+ */
function scalarmult(p: Float64Array[], q: Float64Array[], s: Uint8Array): void {
let b, i;
set25519(p[0], gf0);
@@ -2578,39 +2564,9 @@ function crypto_sign_keypair(
return 0;
}
-const L = new Float64Array([
- 0xed,
- 0xd3,
- 0xf5,
- 0x5c,
- 0x1a,
- 0x63,
- 0x12,
- 0x58,
- 0xd6,
- 0x9c,
- 0xf7,
- 0xa2,
- 0xde,
- 0xf9,
- 0xde,
- 0x14,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0x10,
+export const L = new Float64Array([
+ 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde,
+ 0xf9, 0xde, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10,
]);
function modL(r: Uint8Array, x: Float64Array): void {
@@ -2689,6 +2645,18 @@ function crypto_sign(
return smlen;
}
+function unpackpos(r: Float64Array[], p: Uint8Array): number {
+ // FIXME: implement directly
+ const q = [gf(), gf(), gf(), gf()];
+ if (unpackneg(q, p)) return -1;
+ const scalar0 = new Uint8Array(32);
+ const scalar1 = new Uint8Array(32);
+ scalar1[0] = 1;
+ const scalarNeg1 = crypto_core_ed25519_scalar_sub(scalar0, scalar1);
+ scalarmult(r, q, scalarNeg1);
+ return 0;
+}
+
function unpackneg(r: Float64Array[], p: Uint8Array): number {
const t = gf();
const chk = gf();
@@ -2731,6 +2699,45 @@ function unpackneg(r: Float64Array[], p: Uint8Array): number {
return 0;
}
+export function crypto_scalarmult_ed25519_base_noclamp(
+ s: Uint8Array,
+): Uint8Array {
+ const r = new Uint8Array(32);
+ const p = [gf(), gf(), gf(), gf()];
+
+ scalarbase(p, s);
+ pack(r, p);
+ return r;
+}
+
+export function crypto_scalarmult_ed25519_noclamp(
+ s: Uint8Array,
+ q: Uint8Array,
+): Uint8Array {
+ const r = new Uint8Array(32);
+ const p = [gf(), gf(), gf(), gf()];
+ const ql = [gf(), gf(), gf(), gf()];
+
+ if (unpackpos(ql, q)) throw new Error();
+ scalarmult(p, ql, s);
+ pack(r, p);
+ return r;
+}
+
+export function crypto_core_ed25519_add(
+ p1: Uint8Array,
+ p2: Uint8Array,
+): Uint8Array {
+ const q1 = [gf(), gf(), gf(), gf()];
+ const q2 = [gf(), gf(), gf(), gf()];
+ const res = new Uint8Array(32);
+ if (unpackpos(q1, p1)) throw new Error();
+ if (unpackpos(q2, p2)) throw new Error();
+ add(q1, q2);
+ pack(res, q1);
+ return res;
+}
+
function crypto_sign_open(
m: Uint8Array,
sm: Uint8Array,
@@ -2905,9 +2912,7 @@ export function x25519_edwards_keyPair_fromSecretKey(
return pk;
}
-export function crypto_sign_keyPair_fromSecretKey(
- secretKey: Uint8Array,
-): {
+export function crypto_sign_keyPair_fromSecretKey(secretKey: Uint8Array): {
publicKey: Uint8Array;
secretKey: Uint8Array;
} {
@@ -2919,9 +2924,7 @@ export function crypto_sign_keyPair_fromSecretKey(
return { publicKey: pk, secretKey: new Uint8Array(secretKey) };
}
-export function crypto_sign_keyPair_fromSeed(
- seed: Uint8Array,
-): {
+export function crypto_sign_keyPair_fromSeed(seed: Uint8Array): {
publicKey: Uint8Array;
secretKey: Uint8Array;
} {
@@ -3016,3 +3019,116 @@ export function secretbox_open(
if (crypto_secretbox_open(m, c, c.length, nonce, key) !== 0) return undefined;
return m.subarray(crypto_secretbox_ZEROBYTES);
}
+
+export function crypto_core_ed25519_scalar_add(
+ x: Uint8Array,
+ y: Uint8Array,
+): Uint8Array {
+ const z = new Float64Array(64);
+ for (let i = 0; i < 32; i++) {
+ z[i] = x[i] + y[i];
+ }
+ const o = new Uint8Array(32);
+ modL(o, z);
+ return o;
+}
+
+/**
+ * Reduce a scalar "s" to "s mod L". The input can be up to 64 bytes long.
+ */
+export function crypto_core_ed25519_scalar_reduce(x: Uint8Array): Uint8Array {
+ const len = x.length;
+ const z = new Float64Array(64);
+ for (let i = 0; i < len; i++) z[i] = x[i];
+ const o = new Uint8Array(32);
+ modL(o, z);
+ return o;
+}
+
+export function crypto_core_ed25519_scalar_sub(
+ x: Uint8Array,
+ y: Uint8Array,
+): Uint8Array {
+ const z = new Float64Array(64);
+ for (let i = 0; i < 32; i++) {
+ z[i] = x[i] - y[i];
+ }
+ const o = new Uint8Array(32);
+ modL(o, z);
+ return o;
+}
+
+export function crypto_edx25519_private_key_create(): Uint8Array {
+ const seed = new Uint8Array(32);
+ randombytes(seed, 32);
+ return crypto_edx25519_private_key_create_from_seed(seed);
+}
+
+export function crypto_edx25519_private_key_create_from_seed(
+ seed: Uint8Array,
+): Uint8Array {
+ const pk = hash(seed);
+ pk[0] &= 248;
+ pk[31] &= 127;
+ pk[31] |= 64;
+ return pk;
+}
+
+export function crypto_edx25519_get_public(priv: Uint8Array): Uint8Array {
+ return crypto_scalarmult_ed25519_base_noclamp(priv.subarray(0, 32));
+}
+
+export function crypto_edx25519_sign_detached(
+ m: Uint8Array,
+ skx: Uint8Array,
+ pkx: Uint8Array,
+): Uint8Array {
+ const n: number = m.length;
+ const h = new Uint8Array(64);
+ const r = new Uint8Array(64);
+ let i, j;
+ const x = new Float64Array(64);
+ const p = [gf(), gf(), gf(), gf()];
+
+ const sm = new Uint8Array(n + 64);
+
+ for (i = 0; i < n; i++) sm[64 + i] = m[i];
+ for (i = 0; i < 32; i++) sm[32 + i] = skx[32 + i];
+
+ crypto_hash(r, sm.subarray(32), n + 32);
+ reduce(r);
+ scalarbase(p, r);
+ pack(sm, p);
+
+ for (i = 32; i < 64; i++) sm[i] = pkx[i - 32];
+ crypto_hash(h, sm, n + 64);
+ reduce(h);
+
+ for (i = 0; i < 64; i++) x[i] = 0;
+ for (i = 0; i < 32; i++) x[i] = r[i];
+ for (i = 0; i < 32; i++) {
+ for (j = 0; j < 32; j++) {
+ x[i + j] += h[i] * skx[j];
+ }
+ }
+
+ modL(sm.subarray(32), x);
+ return sm.subarray(0, 64);
+}
+
+export function crypto_edx25519_sign_detached_verify(
+ msg: Uint8Array,
+ sig: Uint8Array,
+ publicKey: Uint8Array,
+): boolean {
+ checkArrayTypes(msg, sig, publicKey);
+ if (sig.length !== crypto_sign_BYTES) throw new Error("bad signature size");
+ if (publicKey.length !== crypto_sign_PUBLICKEYBYTES)
+ throw new Error("bad public key size");
+ const sm = new Uint8Array(crypto_sign_BYTES + msg.length);
+ const m = new Uint8Array(crypto_sign_BYTES + msg.length);
+ let i;
+ for (i = 0; i < crypto_sign_BYTES; i++) sm[i] = sig[i];
+ for (i = 0; i < msg.length; i++) sm[i + crypto_sign_BYTES] = msg[i];
+ return crypto_sign_open(m, sm, sm.length, publicKey) >= 0;
+}
diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts
index 289dcb689..1c6ca4b85 100644
--- a/packages/taler-util/src/notifications.ts
+++ b/packages/taler-util/src/notifications.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
+ (C) 2019-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -22,257 +22,219 @@
/**
* Imports.
*/
-import { TalerErrorDetails } from "./walletTypes.js";
+import { AbsoluteTime } from "./time.js";
+import { TransactionState } from "./transactions-types.js";
+import { ExchangeEntryState, TalerErrorDetail } from "./wallet-types.js";
export enum NotificationType {
- CoinWithdrawn = "coin-withdrawn",
- ProposalAccepted = "proposal-accepted",
- ProposalDownloaded = "proposal-downloaded",
- RefundsSubmitted = "refunds-submitted",
- RecoupStarted = "recoup-started",
- RecoupFinished = "recoup-finished",
- RefreshRevealed = "refresh-revealed",
- RefreshMelted = "refresh-melted",
- RefreshStarted = "refresh-started",
- RefreshUnwarranted = "refresh-unwarranted",
- ReserveUpdated = "reserve-updated",
- ReserveConfirmed = "reserve-confirmed",
- ReserveCreated = "reserve-created",
- WithdrawGroupCreated = "withdraw-group-created",
- WithdrawGroupFinished = "withdraw-group-finished",
- WaitingForRetry = "waiting-for-retry",
- RefundStarted = "refund-started",
- RefundQueried = "refund-queried",
- RefundFinished = "refund-finished",
- ExchangeOperationError = "exchange-operation-error",
- RefreshOperationError = "refresh-operation-error",
- RecoupOperationError = "recoup-operation-error",
- RefundApplyOperationError = "refund-apply-error",
- RefundStatusOperationError = "refund-status-error",
- ProposalOperationError = "proposal-error",
+ BalanceChange = "balance-change",
BackupOperationError = "backup-error",
- TipOperationError = "tip-error",
- PayOperationError = "pay-error",
- PayOperationSuccess = "pay-operation-success",
- WithdrawOperationError = "withdraw-error",
- ReserveNotYetFound = "reserve-not-yet-found",
- ReserveOperationError = "reserve-error",
- InternalError = "internal-error",
- PendingOperationProcessed = "pending-operation-processed",
- ProposalRefused = "proposal-refused",
- ReserveRegisteredWithBank = "reserve-registered-with-bank",
- DepositOperationError = "deposit-operation-error",
-}
-
-export interface ProposalAcceptedNotification {
- type: NotificationType.ProposalAccepted;
- proposalId: string;
-}
-
-export interface InternalErrorNotification {
- type: NotificationType.InternalError;
- message: string;
- exception: any;
-}
-
-export interface ReserveNotYetFoundNotification {
- type: NotificationType.ReserveNotYetFound;
- reservePub: string;
-}
-
-export interface CoinWithdrawnNotification {
- type: NotificationType.CoinWithdrawn;
-}
-
-export interface RefundStartedNotification {
- type: NotificationType.RefundStarted;
-}
-
-export interface RefundQueriedNotification {
- type: NotificationType.RefundQueried;
-}
-
-export interface ProposalDownloadedNotification {
- type: NotificationType.ProposalDownloaded;
- proposalId: string;
-}
-
-export interface RefundsSubmittedNotification {
- type: NotificationType.RefundsSubmitted;
- proposalId: string;
-}
-
-export interface RecoupStartedNotification {
- type: NotificationType.RecoupStarted;
-}
-
-export interface RecoupFinishedNotification {
- type: NotificationType.RecoupFinished;
-}
-
-export interface RefreshMeltedNotification {
- type: NotificationType.RefreshMelted;
-}
-
-export interface RefreshRevealedNotification {
- type: NotificationType.RefreshRevealed;
-}
-
-export interface RefreshStartedNotification {
- type: NotificationType.RefreshStarted;
-}
-
-export interface RefreshRefusedNotification {
- type: NotificationType.RefreshUnwarranted;
-}
-
-export interface ReserveConfirmedNotification {
- type: NotificationType.ReserveConfirmed;
-}
-
-export interface WithdrawalGroupCreatedNotification {
- type: NotificationType.WithdrawGroupCreated;
- withdrawalGroupId: string;
-}
-
-export interface WithdrawalGroupFinishedNotification {
- type: NotificationType.WithdrawGroupFinished;
- reservePub: string;
-}
-
-export interface WaitingForRetryNotification {
- type: NotificationType.WaitingForRetry;
- numPending: number;
- numGivingLiveness: number;
-}
-
-export interface RefundFinishedNotification {
- type: NotificationType.RefundFinished;
-}
-
-export interface ExchangeOperationErrorNotification {
- type: NotificationType.ExchangeOperationError;
- error: TalerErrorDetails;
-}
-
-export interface RefreshOperationErrorNotification {
- type: NotificationType.RefreshOperationError;
- error: TalerErrorDetails;
-}
+ TransactionStateTransition = "transaction-state-transition",
+ WithdrawalOperationTransition = "withdrawal-operation-transition",
+ ExchangeStateTransition = "exchange-state-transition",
+ TaskObservabilityEvent = "task-observability-event",
+ RequestObservabilityEvent = "request-observability-event",
+}
+
+export interface ErrorInfoSummary {
+ code: number;
+ hint?: string;
+ message?: string;
+}
+
+export interface TransactionStateTransitionNotification {
+ type: NotificationType.TransactionStateTransition;
+ transactionId: string;
+ oldTxState: TransactionState;
+ newTxState: TransactionState;
+ errorInfo?: ErrorInfoSummary;
+
+ /**
+ * Additional "user data" that is dependent on the
+ * state transition.
+ *
+ * Usage should be avoided.
+ *
+ * Currently used to notify the iOS app about
+ * the KYC URL.
+ */
+ experimentalUserData?: any;
+}
+
+export interface ExchangeStateTransitionNotification {
+ type: NotificationType.ExchangeStateTransition;
+ /**
+ * Identification of the exchange entry that this
+ * notification is about.
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * If missing, the notification means that
+ * the exchange entry is newly created.
+ */
+ oldExchangeState?: ExchangeEntryState;
+
+ /**
+ * New state of the exchange.
+ */
+ newExchangeState: ExchangeEntryState;
+
+ /**
+ * Summary of the error that occurred when trying to update the exchange entry,
+ * if applicable.
+ */
+ errorInfo?: ErrorInfoSummary;
+}
+
+export interface BalanceChangeNotification {
+ type: NotificationType.BalanceChange;
+
+ /**
+ * Transaction ID of the transaction that caused the balance update.
+ *
+ * Only used as a hint for debugging, should not be relied upon by clients.
+ */
+ hintTransactionId: string;
+}
+
+export interface TaskProgressNotification {
+ type: NotificationType.TaskObservabilityEvent;
+ taskId: string;
+ event: ObservabilityEvent;
+}
+
+export interface RequestProgressNotification {
+ type: NotificationType.RequestObservabilityEvent;
+ requestId: string;
+ operation: string;
+ event: ObservabilityEvent;
+}
+
+export enum ObservabilityEventType {
+ HttpFetchStart = "http-fetch-start",
+ HttpFetchFinishError = "http-fetch-finish-error",
+ HttpFetchFinishSuccess = "http-fetch-finish-success",
+ DbQueryStart = "db-query-start",
+ DbQueryFinishSuccess = "db-query-finish-success",
+ DbQueryFinishError = "db-query-finish-error",
+ RequestStart = "request-start",
+ RequestFinishSuccess = "request-finish-success",
+ RequestFinishError = "request-finish-error",
+ TaskStart = "task-start",
+ TaskStop = "task-stop",
+ TaskReset = "task-reset",
+ ShepherdTaskResult = "sheperd-task-result",
+ DeclareTaskDependency = "declare-task-dependency",
+ CryptoStart = "crypto-start",
+ CryptoFinishSuccess = "crypto-finish-success",
+ CryptoFinishError = "crypto-finish-error",
+ Message = "message",
+}
+
+export type ObservabilityEvent =
+ | {
+ id: string;
+ when: AbsoluteTime;
+ type: ObservabilityEventType.HttpFetchStart;
+ url: string;
+ }
+ | {
+ id: string;
+ when: AbsoluteTime;
+ type: ObservabilityEventType.HttpFetchFinishSuccess;
+ url: string;
+ status: number;
+ }
+ | {
+ id: string;
+ when: AbsoluteTime;
+ type: ObservabilityEventType.HttpFetchFinishError;
+ url: string;
+ error: TalerErrorDetail;
+ }
+ | {
+ type: ObservabilityEventType.DbQueryStart;
+ name: string;
+ location: string;
+ }
+ | {
+ type: ObservabilityEventType.DbQueryFinishSuccess;
+ name: string;
+ location: string;
+ }
+ | {
+ type: ObservabilityEventType.DbQueryFinishError;
+ name: string;
+ location: string;
+ }
+ | {
+ type: ObservabilityEventType.RequestStart;
+ }
+ | {
+ type: ObservabilityEventType.RequestFinishSuccess;
+ durationMs: number;
+ }
+ | {
+ type: ObservabilityEventType.RequestFinishError;
+ }
+ | {
+ type: ObservabilityEventType.TaskStart;
+ taskId: string;
+ }
+ | {
+ type: ObservabilityEventType.TaskStop;
+ taskId: string;
+ }
+ | {
+ type: ObservabilityEventType.TaskReset;
+ taskId: string;
+ }
+ | {
+ type: ObservabilityEventType.DeclareTaskDependency;
+ taskId: string;
+ }
+ | {
+ type: ObservabilityEventType.CryptoStart;
+ operation: string;
+ }
+ | {
+ type: ObservabilityEventType.CryptoFinishSuccess;
+ operation: string;
+ }
+ | {
+ type: ObservabilityEventType.CryptoFinishError;
+ operation: string;
+ }
+ | {
+ type: ObservabilityEventType.ShepherdTaskResult;
+ resultType: string;
+ }
+ | {
+ type: ObservabilityEventType.Message;
+ contents: string;
+ };
export interface BackupOperationErrorNotification {
type: NotificationType.BackupOperationError;
- error: TalerErrorDetails;
-}
-
-export interface RefundStatusOperationErrorNotification {
- type: NotificationType.RefundStatusOperationError;
- error: TalerErrorDetails;
-}
-
-export interface RefundApplyOperationErrorNotification {
- type: NotificationType.RefundApplyOperationError;
- error: TalerErrorDetails;
-}
-
-export interface PayOperationErrorNotification {
- type: NotificationType.PayOperationError;
- error: TalerErrorDetails;
-}
-
-export interface ProposalOperationErrorNotification {
- type: NotificationType.ProposalOperationError;
- error: TalerErrorDetails;
-}
-
-export interface TipOperationErrorNotification {
- type: NotificationType.TipOperationError;
- error: TalerErrorDetails;
+ error: TalerErrorDetail;
}
-
-export interface WithdrawOperationErrorNotification {
- type: NotificationType.WithdrawOperationError;
- error: TalerErrorDetails;
-}
-
-export interface RecoupOperationErrorNotification {
- type: NotificationType.RecoupOperationError;
- error: TalerErrorDetails;
-}
-
-export interface DepositOperationErrorNotification {
- type: NotificationType.DepositOperationError;
- error: TalerErrorDetails;
-}
-
-export interface ReserveOperationErrorNotification {
- type: NotificationType.ReserveOperationError;
- error: TalerErrorDetails;
-}
-
-export interface ReserveCreatedNotification {
- type: NotificationType.ReserveCreated;
- reservePub: string;
-}
-
-export interface PendingOperationProcessedNotification {
- type: NotificationType.PendingOperationProcessed;
-}
-
-export interface ProposalRefusedNotification {
- type: NotificationType.ProposalRefused;
-}
-
-export interface ReserveRegisteredWithBankNotification {
- type: NotificationType.ReserveRegisteredWithBank;
-}
-
/**
- * Notification sent when a pay (or pay replay) operation succeeded.
+ * This notification is required to signal UI that
+ * the withdrawal operation changed the state.
*
- * We send this notification because the confirmPay request can return
- * a "confirmed" response that indicates that the payment has been confirmed
- * by the user, but we're still waiting for the payment to succeed or fail.
+ * https://bugs.gnunet.org/view.php?id=8099
*/
-export interface PayOperationSuccessNotification {
- type: NotificationType.PayOperationSuccess;
- proposalId: string;
+export interface WithdrawalOperationTransitionNotification {
+ type: NotificationType.WithdrawalOperationTransition;
+ uri: string;
}
export type WalletNotification =
+ | BalanceChangeNotification
+ | WithdrawalOperationTransitionNotification
| BackupOperationErrorNotification
- | WithdrawOperationErrorNotification
- | ReserveOperationErrorNotification
- | ExchangeOperationErrorNotification
- | RefreshOperationErrorNotification
- | RefundStatusOperationErrorNotification
- | RefundApplyOperationErrorNotification
- | ProposalOperationErrorNotification
- | PayOperationErrorNotification
- | TipOperationErrorNotification
- | ProposalAcceptedNotification
- | ProposalDownloadedNotification
- | RefundsSubmittedNotification
- | RecoupStartedNotification
- | RecoupFinishedNotification
- | RefreshMeltedNotification
- | RefreshRevealedNotification
- | RefreshStartedNotification
- | RefreshRefusedNotification
- | ReserveCreatedNotification
- | ReserveConfirmedNotification
- | WithdrawalGroupFinishedNotification
- | WaitingForRetryNotification
- | RefundStartedNotification
- | RefundFinishedNotification
- | RefundQueriedNotification
- | WithdrawalGroupCreatedNotification
- | CoinWithdrawnNotification
- | RecoupOperationErrorNotification
- | DepositOperationErrorNotification
- | InternalErrorNotification
- | PendingOperationProcessedNotification
- | ProposalRefusedNotification
- | ReserveRegisteredWithBankNotification
- | ReserveNotYetFoundNotification
- | PayOperationSuccessNotification;
+ | ExchangeStateTransitionNotification
+ | TransactionStateTransitionNotification
+ | TaskProgressNotification
+ | RequestProgressNotification;
diff --git a/packages/taler-util/src/observability.ts b/packages/taler-util/src/observability.ts
new file mode 100644
index 000000000..0171142c8
--- /dev/null
+++ b/packages/taler-util/src/observability.ts
@@ -0,0 +1,98 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ CancellationToken,
+ ObservabilityEvent,
+} from "./index.js";
+import {
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+} from "./http-common.js";
+import { ObservabilityEventType } from "./notifications.js";
+import { getErrorDetailFromException } from "./errors.js";
+
+/**
+ * Observability sink can be passed into various operations (HTTP requests, DB access)
+ * to do structured logging within a particular context (task, request, ...).
+ */
+export interface ObservabilityContext {
+ observe(evt: ObservabilityEvent): void;
+}
+
+let seqId = 1000;
+
+export class ObservableHttpClientLibrary implements HttpRequestLibrary {
+ private readonly cancelatorById = new Map<string, CancellationToken.Source>();
+ constructor(
+ private impl: HttpRequestLibrary,
+ private oc: ObservabilityContext,
+ ) {}
+
+ public cancelRequest(id: string): void {
+ const cancelator = this.cancelatorById.get(id);
+ if (!cancelator) return;
+ cancelator.cancel();
+ }
+
+ async fetch(
+ url: string,
+ opt?: HttpRequestOptions | undefined,
+ ): Promise<HttpResponse> {
+ const id = `req-${seqId}`;
+ seqId = seqId + 1;
+
+ const cancelator = CancellationToken.create();
+ if (opt?.cancellationToken) {
+ opt.cancellationToken.onCancelled(cancelator.cancel);
+ }
+ this.cancelatorById.set(id, cancelator);
+
+ this.oc.observe({
+ id,
+ when: AbsoluteTime.now(),
+ type: ObservabilityEventType.HttpFetchStart,
+ url: url,
+ });
+
+ const optsWithCancel = opt ?? {};
+ optsWithCancel.cancellationToken = cancelator.token;
+ try {
+ const res = await this.impl.fetch(url, optsWithCancel);
+ this.oc.observe({
+ id,
+ when: AbsoluteTime.now(),
+ type: ObservabilityEventType.HttpFetchFinishSuccess,
+ url,
+ status: res.status,
+ });
+ return res;
+ } catch (e) {
+ this.oc.observe({
+ id,
+ when: AbsoluteTime.now(),
+ type: ObservabilityEventType.HttpFetchFinishError,
+ url,
+ error: getErrorDetailFromException(e),
+ });
+ throw e;
+ } finally {
+ this.cancelatorById.delete(id);
+ }
+ }
+}
diff --git a/packages/taler-util/src/operation.ts b/packages/taler-util/src/operation.ts
new file mode 100644
index 000000000..771f5860b
--- /dev/null
+++ b/packages/taler-util/src/operation.ts
@@ -0,0 +1,195 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ HttpResponse,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "./http-common.js";
+import {
+ Codec,
+ HttpStatusCode,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+} from "./index.js";
+
+type OperationFailWithBodyOrNever<ErrorEnum, ErrorMap> =
+ ErrorEnum extends keyof ErrorMap ? OperationFailWithBody<ErrorMap> : never;
+
+export type OperationResult<Body, ErrorEnum, K = never> =
+ | OperationOk<Body>
+ | OperationAlternative<ErrorEnum, any>
+ | OperationFail<ErrorEnum>
+ | OperationFailWithBodyOrNever<ErrorEnum, K>;
+
+export function isOperationOk<T, E>(
+ c: OperationResult<T, E>,
+): c is OperationOk<T> {
+ return c.type === "ok";
+}
+
+export function isOperationFail<T, E>(
+ c: OperationResult<T, E>,
+): c is OperationFail<E> {
+ return c.type === "fail";
+}
+
+/**
+ * successful operation
+ */
+export interface OperationOk<BodyT> {
+ type: "ok";
+
+ /**
+ * Parsed response body.
+ */
+ body: BodyT;
+}
+
+/**
+ * unsuccessful operation, see details
+ */
+export interface OperationFail<T> {
+ type: "fail";
+
+ /**
+ * Error case (either HTTP status code or TalerErrorCode)
+ */
+ case: T;
+
+ detail: TalerErrorDetail;
+}
+
+/**
+ * unsuccessful operation, see body
+ */
+export interface OperationAlternative<T, B> {
+ type: "fail";
+
+ case: T;
+ body: B;
+}
+
+export interface OperationFailWithBody<B> {
+ type: "fail";
+
+ case: keyof B;
+ body: B[OperationFailWithBody<B>["case"]];
+}
+
+export async function opSuccessFromHttp<T>(
+ resp: HttpResponse,
+ codec: Codec<T>,
+): Promise<OperationOk<T>> {
+ const body = await readSuccessResponseJsonOrThrow(resp, codec);
+ return { type: "ok" as const, body };
+}
+
+/**
+ * Success case, but instead of the body we're returning a fixed response
+ * to the client.
+ */
+export function opFixedSuccess<T>(body: T): OperationOk<T> {
+ return { type: "ok" as const, body };
+}
+
+export function opEmptySuccess(resp: HttpResponse): OperationOk<void> {
+ return { type: "ok" as const, body: void 0 };
+}
+
+export async function opKnownFailureWithBody<B>(
+ case_: keyof B,
+ body: B[typeof case_],
+): Promise<OperationFailWithBody<B>> {
+ return { type: "fail", case: case_, body };
+}
+
+export async function opKnownAlternativeFailure<T extends HttpStatusCode, B>(
+ resp: HttpResponse,
+ s: T,
+ codec: Codec<B>,
+): Promise<OperationAlternative<T, B>> {
+ const body = await readSuccessResponseJsonOrThrow(resp, codec);
+ return { type: "fail", case: s, body };
+}
+
+export async function opKnownHttpFailure<T extends HttpStatusCode>(
+ s: T,
+ resp: HttpResponse,
+): Promise<OperationFail<T>> {
+ const detail = await readTalerErrorResponse(resp);
+ return { type: "fail", case: s, detail };
+}
+
+export function opKnownTalerFailure<T extends TalerErrorCode>(
+ s: T,
+ detail: TalerErrorDetail,
+): OperationFail<T> {
+ return { type: "fail", case: s, detail };
+}
+
+export function opUnknownFailure(resp: HttpResponse, error: TalerErrorDetail): never {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ {
+ requestUrl: resp.requestUrl,
+ requestMethod: resp.requestMethod,
+ httpStatusCode: resp.status,
+ errorResponse: error,
+ },
+ `Unexpected HTTP status ${resp.status} in response`,
+ );
+}
+
+/**
+ * Convenience function to throw an error if the operation is not a success.
+ */
+export function narrowOpSuccessOrThrow<Body, ErrorEnum>(
+ opName: string,
+ opRes: OperationResult<Body, ErrorEnum>,
+): asserts opRes is OperationOk<Body> {
+ if (opRes.type !== "ok") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,
+ {
+ operation: opName,
+ error: String(opRes.case),
+ detail: "detail" in opRes ? opRes.detail : undefined,
+ },
+ `Operation ${opName} failed: ${String(opRes.case)}`,
+ );
+ }
+}
+
+export type ResultByMethod<
+ TT extends object,
+ p extends keyof TT,
+> = TT[p] extends (...args: any[]) => infer Ret
+ ? Ret extends Promise<infer Result>
+ ? Result extends OperationResult<any, any>
+ ? Result
+ : never
+ : never //api always use Promises
+ : never; //error cases just for functions
+
+export type FailCasesByMethod<TT extends object, p extends keyof TT> = Exclude<
+ ResultByMethod<TT, p>,
+ OperationOk<any>
+>;
diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts
index 504db533b..a471d0b87 100644
--- a/packages/taler-util/src/payto.ts
+++ b/packages/taler-util/src/payto.ts
@@ -14,16 +14,135 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import { generateFakeSegwitAddress } from "./bitcoin.js";
+import { Codec, Context, DecodingError, renderContext } from "./codec.js";
import { URLSearchParams } from "./url.js";
-interface PaytoUri {
- targetType: string;
+export type PaytoUri =
+ | PaytoUriUnknown
+ | PaytoUriIBAN
+ | PaytoUriTalerBank
+ | PaytoUriBitcoin;
+
+declare const __payto_str: unique symbol;
+export type PaytoString = string & { [__payto_str]: true };
+
+export function codecForPaytoString(): Codec<PaytoString> {
+ return {
+ decode(x: any, c?: Context): PaytoString {
+ if (typeof x !== "string") {
+ throw new DecodingError(
+ `expected string at ${renderContext(c)} but got ${typeof x}`,
+ );
+ }
+ if (!x.startsWith(paytoPfx)) {
+ throw new DecodingError(
+ `expected start with payto at ${renderContext(c)} but got "${x}"`,
+ );
+ }
+ return x as PaytoString;
+ },
+ };
+}
+
+export interface PaytoUriGeneric {
+ targetType: PaytoType | string;
targetPath: string;
params: { [name: string]: string };
}
+export interface PaytoUriUnknown extends PaytoUriGeneric {
+ isKnown: false;
+}
+
+export interface PaytoUriIBAN extends PaytoUriGeneric {
+ isKnown: true;
+ targetType: "iban";
+ iban: string;
+ bic?: string;
+}
+
+export interface PaytoUriTalerBank extends PaytoUriGeneric {
+ isKnown: true;
+ targetType: "x-taler-bank";
+ host: string;
+ account: string;
+}
+
+export interface PaytoUriBitcoin extends PaytoUriGeneric {
+ isKnown: true;
+ targetType: "bitcoin";
+ address: string;
+ segwitAddrs: Array<string>;
+}
+
const paytoPfx = "payto://";
+export type PaytoType = "iban" | "bitcoin" | "x-taler-bank";
+
+export function buildPayto(
+ type: "iban",
+ iban: string,
+ bic: string | undefined,
+): PaytoUriIBAN;
+export function buildPayto(
+ type: "bitcoin",
+ address: string,
+ reserve: string | undefined,
+): PaytoUriBitcoin;
+export function buildPayto(
+ type: "x-taler-bank",
+ host: string,
+ account: string,
+): PaytoUriTalerBank;
+export function buildPayto(
+ type: PaytoType,
+ first: string,
+ second?: string,
+): PaytoUriGeneric {
+ switch (type) {
+ case "bitcoin": {
+ const uppercased = first.toUpperCase();
+ const result: PaytoUriBitcoin = {
+ isKnown: true,
+ targetType: "bitcoin",
+ targetPath: first,
+ address: uppercased,
+ params: {},
+ segwitAddrs: !second ? [] : generateFakeSegwitAddress(second, first),
+ };
+ return result;
+ }
+ case "iban": {
+ const uppercased = first.toUpperCase();
+ const result: PaytoUriIBAN = {
+ isKnown: true,
+ targetType: "iban",
+ iban: uppercased,
+ params: {},
+ targetPath: !second ? uppercased : `${second}/${uppercased}`,
+ };
+ return result;
+ }
+ case "x-taler-bank": {
+ if (!second) throw Error("missing account for payto://x-taler-bank");
+ const result: PaytoUriTalerBank = {
+ isKnown: true,
+ targetType: "x-taler-bank",
+ host: first,
+ account: second,
+ params: {},
+ targetPath: `${first}/${second}`,
+ };
+ return result;
+ }
+ default: {
+ const unknownType: never = type;
+ throw Error(`unknown payto:// type ${unknownType}`);
+ }
+ }
+}
+
/**
* Add query parameters to a payto URI
*/
@@ -33,12 +152,38 @@ export function addPaytoQueryParams(
): string {
const [acct, search] = s.slice(paytoPfx.length).split("?");
const searchParams = new URLSearchParams(search || "");
- for (const k of Object.keys(params)) {
+ const keys = Object.keys(params);
+ if (keys.length === 0) {
+ return paytoPfx + acct;
+ }
+ for (const k of keys) {
searchParams.set(k, params[k]);
}
return paytoPfx + acct + "?" + searchParams.toString();
}
+/**
+ * Serialize a PaytoURI into a valid payto:// string
+ *
+ * @param p
+ * @returns
+ */
+export function stringifyPaytoUri(p: PaytoUri): PaytoString {
+ const url = new URL(`${paytoPfx}${p.targetType}/${p.targetPath}`);
+ const paramList = !p.params ? [] : Object.entries(p.params);
+ paramList.forEach(([key, value]) => {
+ url.searchParams.set(key, value);
+ });
+ return url.href as PaytoString;
+}
+
+/**
+ * Parse a valid payto:// uri into a PaytoUri object
+ * RFC 8905
+ *
+ * @param s
+ * @returns
+ */
export function parsePaytoUri(s: string): PaytoUri | undefined {
if (!s.startsWith(paytoPfx)) {
return undefined;
@@ -60,12 +205,89 @@ export function parsePaytoUri(s: string): PaytoUri | undefined {
const searchParams = new URLSearchParams(search || "");
searchParams.forEach((v, k) => {
- params[v] = k;
+ params[k] = v;
});
+ if (targetType === "x-taler-bank") {
+ const parts = targetPath.split("/");
+ const host = parts[0];
+ const account = parts[1];
+ return {
+ targetPath,
+ targetType,
+ params,
+ isKnown: true,
+ host,
+ account,
+ };
+ }
+ if (targetType === "iban") {
+ const parts = targetPath.split("/");
+ let iban: string | undefined = undefined;
+ let bic: string | undefined = undefined;
+ if (parts.length === 1) {
+ iban = parts[0].toUpperCase();
+ }
+ if (parts.length === 2) {
+ bic = parts[0];
+ iban = parts[1].toUpperCase();
+ } else {
+ iban = targetPath.toUpperCase();
+ }
+ return {
+ isKnown: true,
+ targetPath,
+ targetType,
+ params,
+ iban,
+ bic,
+ };
+ }
+ if (targetType === "bitcoin") {
+ const msg = /\b([A-Z0-9]{52})\b/.exec(params["message"]);
+ const reserve = !msg ? params["subject"] : msg[0];
+ const segwitAddrs = !reserve
+ ? []
+ : generateFakeSegwitAddress(reserve, targetPath);
+
+ const uppercased = targetType.toUpperCase();
+ const result: PaytoUriBitcoin = {
+ isKnown: true,
+ targetPath,
+ targetType,
+ address: uppercased,
+ params,
+ segwitAddrs,
+ };
+
+ return result;
+ }
return {
targetPath,
targetType,
params,
+ isKnown: false,
};
}
+
+export function talerPaytoFromExchangeReserve(
+ exchangeBaseUrl: string,
+ reservePub: string,
+): string {
+ const url = new URL(exchangeBaseUrl);
+ let proto: string;
+ if (url.protocol === "http:") {
+ proto = "taler-reserve-http";
+ } else if (url.protocol === "https:") {
+ proto = "taler-reserve";
+ } else {
+ throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
+ }
+
+ let path = url.pathname;
+ if (!path.endsWith("/")) {
+ path = path + "/";
+ }
+
+ return `payto://${proto}/${url.host}${url.pathname}${reservePub}`;
+}
diff --git a/packages/taler-util/src/promises.ts b/packages/taler-util/src/promises.ts
new file mode 100644
index 000000000..bc1e40260
--- /dev/null
+++ b/packages/taler-util/src/promises.ts
@@ -0,0 +1,112 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * An opened promise.
+ *
+ * @see {@link openPromise}
+ */
+export interface OpenedPromise<T> {
+ promise: Promise<T>;
+ resolve: (val: T) => void;
+ reject: (err: any) => void;
+ lastError?: any;
+}
+
+/**
+ * Get an unresolved promise together with its extracted resolve / reject
+ * function.
+ *
+ * Recent ECMAScript proposals also call this a promise capability.
+ */
+export function openPromise<T>(): OpenedPromise<T> {
+ let resolve: ((x?: any) => void) | null = null;
+ let promiseReject: ((reason?: any) => void) | null = null;
+ const promise = new Promise<T>((res, rej) => {
+ resolve = res;
+ promiseReject = rej;
+ });
+ if (!(resolve && promiseReject)) {
+ // Never happens, unless JS implementation is broken
+ throw Error("JS implementation is broken");
+ }
+ const result: OpenedPromise<T> = { resolve, reject: promiseReject, promise };
+ function saveLastError(reason?: any) {
+ result.lastError = reason;
+ promiseReject!(reason);
+ }
+ result.reject = saveLastError;
+ return result;
+}
+
+export class AsyncCondition {
+ private promCap?: OpenedPromise<void> = undefined;
+ constructor() {}
+
+ wait(): Promise<void> {
+ if (!this.promCap) {
+ this.promCap = openPromise<void>();
+ }
+ return this.promCap.promise;
+ }
+
+ trigger(): void {
+ if (this.promCap) {
+ this.promCap.resolve();
+ }
+ this.promCap = undefined;
+ }
+}
+
+/**
+ * Flag that can be raised to notify asynchronous waiters.
+ *
+ * You can think of it as a promise that can
+ * be un-resolved.
+ */
+export class AsyncFlag {
+ private promCap?: OpenedPromise<void> = undefined;
+ private internalFlagRaised: boolean = false;
+
+ constructor() {}
+
+ /**
+ * Wait until the flag is raised.
+ *
+ * Reset if before returning.
+ */
+ wait(): Promise<void> {
+ if (this.internalFlagRaised) {
+ return Promise.resolve();
+ }
+ if (!this.promCap) {
+ this.promCap = openPromise<void>();
+ }
+ return this.promCap.promise;
+ }
+
+ raise(): void {
+ this.internalFlagRaised = true;
+ if (this.promCap) {
+ this.promCap.resolve();
+ }
+ }
+
+ reset(): void {
+ this.internalFlagRaised = false;
+ this.promCap = undefined;
+ }
+}
diff --git a/packages/taler-util/src/punycode.ts b/packages/taler-util/src/punycode.ts
new file mode 100644
index 000000000..acb8ce911
--- /dev/null
+++ b/packages/taler-util/src/punycode.ts
@@ -0,0 +1,468 @@
+/*
+Copyright Mathias Bynens <https://mathiasbynens.be/>
+Copyright (c) 2022 Taler Systems S.A.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+
+/** Highest positive signed 32-bit float value */
+const maxInt = 2147483647; // aka. 0x7FFFFFFF or 2^31-1
+
+/** Bootstring parameters */
+const base = 36;
+const tMin = 1;
+const tMax = 26;
+const skew = 38;
+const damp = 700;
+const initialBias = 72;
+const initialN = 128; // 0x80
+const delimiter = "-"; // '\x2D'
+
+/** Regular expressions */
+const regexPunycode = /^xn--/;
+const regexNonASCII = /[^\0-\x7E]/; // non-ASCII chars
+const regexSeparators = /[\x2E\u3002\uFF0E\uFF61]/g; // RFC 3490 separators
+
+/** Error messages */
+const errors = {
+ overflow: "Overflow: input needs wider integers to process",
+ "not-basic": "Illegal input >= 0x80 (not a basic code point)",
+ "invalid-input": "Invalid input",
+} as { [x: string]: string };
+
+/** Convenience shortcuts */
+const baseMinusTMin = base - tMin;
+const floor = Math.floor;
+const stringFromCharCode = String.fromCharCode;
+
+/*--------------------------------------------------------------------------*/
+
+/**
+ * A generic error utility function.
+ * @private
+ * @param {String} type The error type.
+ * @returns {Error} Throws a `RangeError` with the applicable error message.
+ */
+function error(type: string) {
+ throw new RangeError(errors[type]);
+}
+
+/**
+ * A generic `Array#map` utility function.
+ * @private
+ * @param {Array} array The array to iterate over.
+ * @param {Function} callback The function that gets called for every array
+ * item.
+ * @returns {Array} A new array of values returned by the callback function.
+ */
+function map(array: any[], fn: (arg0: any) => any) {
+ const result = [];
+ let length = array.length;
+ while (length--) {
+ result[length] = fn(array[length]);
+ }
+ return result;
+}
+
+/**
+ * A simple `Array#map`-like wrapper to work with domain name strings or email
+ * addresses.
+ * @private
+ * @param {String} domain The domain name or email address.
+ * @param {Function} callback The function that gets called for every
+ * character.
+ * @returns {Array} A new string of characters returned by the callback
+ * function.
+ */
+function mapDomain(
+ string: string,
+ fn: { (string: any): any; (string: any): any; (arg0: any): any },
+) {
+ const parts = string.split("@");
+ let result = "";
+ if (parts.length > 1) {
+ // In email addresses, only the domain name should be punycoded. Leave
+ // the local part (i.e. everything up to `@`) intact.
+ result = parts[0] + "@";
+ string = parts[1];
+ }
+ // Avoid `split(regex)` for IE8 compatibility. See #17.
+ string = string.replace(regexSeparators, "\x2E");
+ const labels = string.split(".");
+ const encoded = map(labels, fn).join(".");
+ return result + encoded;
+}
+
+/**
+ * Creates an array containing the numeric code points of each Unicode
+ * character in the string. While JavaScript uses UCS-2 internally,
+ * this function will convert a pair of surrogate halves (each of which
+ * UCS-2 exposes as separate characters) into a single code point,
+ * matching UTF-16.
+ * @see `punycode.ucs2.encode`
+ * @see <https://mathiasbynens.be/notes/javascript-encoding>
+ * @memberOf punycode.ucs2
+ * @name decode
+ * @param {String} string The Unicode input string (UCS-2).
+ * @returns {Array} The new array of code points.
+ */
+function ucs2decode(string: string) {
+ const output = [];
+ let counter = 0;
+ const length = string.length;
+ while (counter < length) {
+ const value = string.charCodeAt(counter++);
+ if (value >= 0xd800 && value <= 0xdbff && counter < length) {
+ // It's a high surrogate, and there is a next character.
+ const extra = string.charCodeAt(counter++);
+ if ((extra & 0xfc00) == 0xdc00) {
+ // Low surrogate.
+ output.push(((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000);
+ } else {
+ // It's an unmatched surrogate; only append this code unit, in case the
+ // next code unit is the high surrogate of a surrogate pair.
+ output.push(value);
+ counter--;
+ }
+ } else {
+ output.push(value);
+ }
+ }
+ return output;
+}
+
+/**
+ * Creates a string based on an array of numeric code points.
+ * @see `punycode.ucs2.decode`
+ * @memberOf punycode.ucs2
+ * @name encode
+ * @param {Array} codePoints The array of numeric code points.
+ * @returns {String} The new Unicode string (UCS-2).
+ */
+const ucs2encode = (array: any): string => String.fromCodePoint(...array);
+
+/**
+ * Converts a basic code point into a digit/integer.
+ * @see `digitToBasic()`
+ * @private
+ * @param {Number} codePoint The basic numeric code point value.
+ * @returns {Number} The numeric value of a basic code point (for use in
+ * representing integers) in the range `0` to `base - 1`, or `base` if
+ * the code point does not represent a value.
+ */
+const basicToDigit = function (codePoint: number) {
+ if (codePoint - 0x30 < 0x0a) {
+ return codePoint - 0x16;
+ }
+ if (codePoint - 0x41 < 0x1a) {
+ return codePoint - 0x41;
+ }
+ if (codePoint - 0x61 < 0x1a) {
+ return codePoint - 0x61;
+ }
+ return base;
+};
+
+/**
+ * Converts a digit/integer into a basic code point.
+ * @see `basicToDigit()`
+ * @private
+ * @param {Number} digit The numeric value of a basic code point.
+ * @returns {Number} The basic code point whose value (when used for
+ * representing integers) is `digit`, which needs to be in the range
+ * `0` to `base - 1`. If `flag` is non-zero, the uppercase form is
+ * used; else, the lowercase form is used. The behavior is undefined
+ * if `flag` is non-zero and `digit` has no uppercase form.
+ */
+const digitToBasic = function (digit: number, flag: number) {
+ // 0..25 map to ASCII a..z or A..Z
+ // 26..35 map to ASCII 0..9
+ return digit + 22 + 75 * Number(digit < 26) - (Number(flag != 0) << 5);
+};
+
+/**
+ * Bias adaptation function as per section 3.4 of RFC 3492.
+ * https://tools.ietf.org/html/rfc3492#section-3.4
+ * @private
+ */
+const adapt = function (delta: number, numPoints: number, firstTime: boolean) {
+ let k = 0;
+ delta = firstTime ? floor(delta / damp) : delta >> 1;
+ delta += floor(delta / numPoints);
+ for (
+ ;
+ /* no initialization */ delta > (baseMinusTMin * tMax) >> 1;
+ k += base
+ ) {
+ delta = floor(delta / baseMinusTMin);
+ }
+ return floor(k + ((baseMinusTMin + 1) * delta) / (delta + skew));
+};
+
+/**
+ * Converts a Punycode string of ASCII-only symbols to a string of Unicode
+ * symbols.
+ * @memberOf punycode
+ * @param {String} input The Punycode string of ASCII-only symbols.
+ * @returns {String} The resulting string of Unicode symbols.
+ */
+const decode = function (input: string) {
+ // Don't use UCS-2.
+ const output = [];
+ const inputLength = input.length;
+ let i = 0;
+ let n = initialN;
+ let bias = initialBias;
+
+ // Handle the basic code points: let `basic` be the number of input code
+ // points before the last delimiter, or `0` if there is none, then copy
+ // the first basic code points to the output.
+
+ let basic = input.lastIndexOf(delimiter);
+ if (basic < 0) {
+ basic = 0;
+ }
+
+ for (let j = 0; j < basic; ++j) {
+ // if it's not a basic code point
+ if (input.charCodeAt(j) >= 0x80) {
+ error("not-basic");
+ }
+ output.push(input.charCodeAt(j));
+ }
+
+ // Main decoding loop: start just after the last delimiter if any basic code
+ // points were copied; start at the beginning otherwise.
+
+ for (
+ let index = basic > 0 ? basic + 1 : 0;
+ index < inputLength /* no final expression */;
+
+ ) {
+ // `index` is the index of the next character to be consumed.
+ // Decode a generalized variable-length integer into `delta`,
+ // which gets added to `i`. The overflow checking is easier
+ // if we increase `i` as we go, then subtract off its starting
+ // value at the end to obtain `delta`.
+ let oldi = i;
+ for (let w = 1, k = base /* no condition */; ; k += base) {
+ if (index >= inputLength) {
+ error("invalid-input");
+ }
+
+ const digit = basicToDigit(input.charCodeAt(index++));
+
+ if (digit >= base || digit > floor((maxInt - i) / w)) {
+ error("overflow");
+ }
+
+ i += digit * w;
+ const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias;
+
+ if (digit < t) {
+ break;
+ }
+
+ const baseMinusT = base - t;
+ if (w > floor(maxInt / baseMinusT)) {
+ error("overflow");
+ }
+
+ w *= baseMinusT;
+ }
+
+ const out = output.length + 1;
+ bias = adapt(i - oldi, out, oldi == 0);
+
+ // `i` was supposed to wrap around from `out` to `0`,
+ // incrementing `n` each time, so we'll fix that now:
+ if (floor(i / out) > maxInt - n) {
+ error("overflow");
+ }
+
+ n += floor(i / out);
+ i %= out;
+
+ // Insert `n` at position `i` of the output.
+ output.splice(i++, 0, n);
+ }
+
+ return String.fromCodePoint(...output);
+};
+
+/**
+ * Converts a string of Unicode symbols (e.g. a domain name label) to a
+ * Punycode string of ASCII-only symbols.
+ * @memberOf punycode
+ * @param {String} input The string of Unicode symbols.
+ * @returns {String} The resulting Punycode string of ASCII-only symbols.
+ */
+const encode = function (inputArg: string) {
+ const output = [];
+
+ // Convert the input in UCS-2 to an array of Unicode code points.
+ let input = ucs2decode(inputArg);
+
+ // Cache the length.
+ let inputLength = input.length;
+
+ // Initialize the state.
+ let n = initialN;
+ let delta = 0;
+ let bias = initialBias;
+
+ // Handle the basic code points.
+ for (const currentValue of input) {
+ if (currentValue < 0x80) {
+ output.push(stringFromCharCode(currentValue));
+ }
+ }
+
+ let basicLength = output.length;
+ let handledCPCount = basicLength;
+
+ // `handledCPCount` is the number of code points that have been handled;
+ // `basicLength` is the number of basic code points.
+
+ // Finish the basic string with a delimiter unless it's empty.
+ if (basicLength) {
+ output.push(delimiter);
+ }
+
+ // Main encoding loop:
+ while (handledCPCount < inputLength) {
+ // All non-basic code points < n have been handled already. Find the next
+ // larger one:
+ let m = maxInt;
+ for (const currentValue of input) {
+ if (currentValue >= n && currentValue < m) {
+ m = currentValue;
+ }
+ }
+
+ // Increase `delta` enough to advance the decoder's <n,i> state to <m,0>,
+ // but guard against overflow.
+ const handledCPCountPlusOne = handledCPCount + 1;
+ if (m - n > floor((maxInt - delta) / handledCPCountPlusOne)) {
+ error("overflow");
+ }
+
+ delta += (m - n) * handledCPCountPlusOne;
+ n = m;
+
+ for (const currentValue of input) {
+ if (currentValue < n && ++delta > maxInt) {
+ error("overflow");
+ }
+ if (currentValue == n) {
+ // Represent delta as a generalized variable-length integer.
+ let q = delta;
+ for (let k = base /* no condition */; ; k += base) {
+ const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias;
+ if (q < t) {
+ break;
+ }
+ const qMinusT = q - t;
+ const baseMinusT = base - t;
+ output.push(
+ stringFromCharCode(digitToBasic(t + (qMinusT % baseMinusT), 0)),
+ );
+ q = floor(qMinusT / baseMinusT);
+ }
+
+ output.push(stringFromCharCode(digitToBasic(q, 0)));
+ bias = adapt(
+ delta,
+ handledCPCountPlusOne,
+ handledCPCount == basicLength,
+ );
+ delta = 0;
+ ++handledCPCount;
+ }
+ }
+
+ ++delta;
+ ++n;
+ }
+ return output.join("");
+};
+
+/**
+ * Converts a Punycode string representing a domain name or an email address
+ * to Unicode. Only the Punycoded parts of the input will be converted, i.e.
+ * it doesn't matter if you call it on a string that has already been
+ * converted to Unicode.
+ * @memberOf punycode
+ * @param {String} input The Punycoded domain name or email address to
+ * convert to Unicode.
+ * @returns {String} The Unicode representation of the given Punycode
+ * string.
+ */
+const toUnicode = function (input: string) {
+ return mapDomain(input, function (string) {
+ return regexPunycode.test(string)
+ ? decode(string.slice(4).toLowerCase())
+ : string;
+ });
+};
+
+/**
+ * Converts a Unicode string representing a domain name or an email address to
+ * Punycode. Only the non-ASCII parts of the domain name will be converted,
+ * i.e. it doesn't matter if you call it with a domain that's already in
+ * ASCII.
+ * @memberOf punycode
+ * @param {String} input The domain name or email address to convert, as a
+ * Unicode string.
+ * @returns {String} The Punycode representation of the given domain name or
+ * email address.
+ */
+const toASCII = function (input: string) {
+ return mapDomain(input, function (string) {
+ return regexNonASCII.test(string) ? "xn--" + encode(string) : string;
+ });
+};
+
+/*--------------------------------------------------------------------------*/
+
+/** Define the public API */
+export const punycode = {
+ /**
+ * A string representing the current Punycode.js version number.
+ * @memberOf punycode
+ * @type String
+ */
+ version: "2.1.0",
+ /**
+ * An object of methods to convert from JavaScript's internal character
+ * representation (UCS-2) to Unicode code points, and back.
+ * @see <https://mathiasbynens.be/notes/javascript-encoding>
+ * @memberOf punycode
+ * @type Object
+ */
+ ucs2: {
+ decode: ucs2decode,
+ encode: ucs2encode,
+ },
+ decode: decode,
+ encode: encode,
+ toASCII: toASCII,
+ toUnicode: toUnicode,
+};
diff --git a/packages/taler-util/src/qtart.ts b/packages/taler-util/src/qtart.ts
new file mode 100644
index 000000000..e298a157c
--- /dev/null
+++ b/packages/taler-util/src/qtart.ts
@@ -0,0 +1,35 @@
+// @ts-ignore
+import * as _qjsOsImp from "os";
+// @ts-ignore
+import * as _qjsStdImp from "std";
+
+export interface QjsHttpResp {
+ status: number;
+ data: ArrayBuffer;
+ headers?: string[];
+}
+
+export interface QjsHttpOptions {
+ method: string;
+ debug?: boolean;
+ data?: ArrayBuffer;
+ headers?: string[];
+}
+
+export interface QjsOsLib {
+ fetchHttp(url: string, options?: QjsHttpOptions): Promise<QjsHttpResp>;
+ postMessageToHost(s: string): void;
+ setMessageFromHostHandler(h: (s: string) => void): void;
+ rename(oldPath: string, newPath: string): number;
+ remove(path: string): number;
+}
+
+export interface QjsStdLib {
+ writeFile(filename: string, contents: string): void;
+ loadFile(filename: string): string;
+}
+
+// This is not the nodejs "os" module, but the qjs "os" module.
+export const qjsOs: QjsOsLib = _qjsOsImp as any;
+
+export const qjsStd: QjsStdLib = _qjsStdImp as any;
diff --git a/packages/taler-util/src/rfc3548.ts b/packages/taler-util/src/rfc3548.ts
new file mode 100644
index 000000000..2dd18cdfc
--- /dev/null
+++ b/packages/taler-util/src/rfc3548.ts
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { getRandomBytes } from "./taler-crypto.js";
+
+const encTable = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+
+/**
+ * base32 RFC 3548
+ */
+export function encodeRfc3548Base32(data: ArrayBuffer) {
+ const dataBytes = new Uint8Array(data);
+ let sb = "";
+ const size = data.byteLength;
+ let bitBuf = 0;
+ let numBits = 0;
+ let pos = 0;
+ while (pos < size || numBits > 0) {
+ if (pos < size && numBits < 5) {
+ const d = dataBytes[pos++];
+ bitBuf = (bitBuf << 8) | d;
+ numBits += 8;
+ }
+ if (numBits < 5) {
+ // zero-padding
+ bitBuf = bitBuf << (5 - numBits);
+ numBits = 5;
+ }
+ const v = (bitBuf >>> (numBits - 5)) & 31;
+ sb += encTable[v];
+ numBits -= 5;
+ }
+ return sb;
+}
+
+export function isRfc3548Base32Charset(s: string): boolean {
+ for (let idx = 0; idx < s.length; idx++) {
+ const c = s.charAt(idx);
+ if (encTable.indexOf(c) === -1) return false;
+ }
+ return true;
+}
+
+export function randomRfc3548Base32Key(): string {
+ const buf = getRandomBytes(20);
+ return encodeRfc3548Base32(buf);
+}
diff --git a/packages/taler-util/src/segwit_addr.ts b/packages/taler-util/src/segwit_addr.ts
new file mode 100644
index 000000000..fc1b6140a
--- /dev/null
+++ b/packages/taler-util/src/segwit_addr.ts
@@ -0,0 +1,105 @@
+// Copyright (c) 2017, 2021 Pieter Wuille
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+import bech32 from "./bech32.js";
+
+export default {
+ encode: encode,
+ decode: decode,
+};
+
+function convertbits(
+ data: any,
+ frombits: number,
+ tobits: number,
+ pad: boolean,
+): any[] {
+ var acc = 0;
+ var bits = 0;
+ var ret = [];
+ var maxv = (1 << tobits) - 1;
+ for (var p = 0; p < data.length; ++p) {
+ var value = data[p];
+ if (value < 0 || value >> frombits !== 0) {
+ return []; //check this, was returning null
+ }
+ acc = (acc << frombits) | value;
+ bits += frombits;
+ while (bits >= tobits) {
+ bits -= tobits;
+ ret.push((acc >> bits) & maxv);
+ }
+ }
+ if (pad) {
+ if (bits > 0) {
+ ret.push((acc << (tobits - bits)) & maxv);
+ }
+ } else if (bits >= frombits || (acc << (tobits - bits)) & maxv) {
+ return []; //check this, was returning null
+ }
+ return ret;
+}
+
+function decode(hrp: any, addr: string) {
+ var bech32m = false;
+ var dec = bech32.decode(addr, bech32.encodings.BECH32);
+ if (dec === null) {
+ dec = bech32.decode(addr, bech32.encodings.BECH32M);
+ bech32m = true;
+ }
+ if (
+ dec === null ||
+ dec.hrp !== hrp ||
+ dec.data.length < 1 ||
+ dec.data[0] > 16
+ ) {
+ return null;
+ }
+ var res = convertbits(dec.data.slice(1), 5, 8, false);
+ if (res === null || res.length < 2 || res.length > 40) {
+ return null;
+ }
+ if (dec.data[0] === 0 && res.length !== 20 && res.length !== 32) {
+ return null;
+ }
+ if (dec.data[0] === 0 && bech32m) {
+ return null;
+ }
+ if (dec.data[0] !== 0 && !bech32m) {
+ return null;
+ }
+ return { version: dec.data[0], program: res };
+}
+
+function encode(hrp: any, version: number, program: any): string {
+ var enc = bech32.encodings.BECH32;
+ if (version > 0) {
+ enc = bech32.encodings.BECH32M;
+ }
+ var ret = bech32.encode(
+ hrp,
+ [version].concat(convertbits(program, 8, 5, true)),
+ enc,
+ );
+ if (decode(hrp, ret /*, enc*/) === null) {
+ return ""; //check this was returning null
+ }
+ return ret;
+}
diff --git a/packages/taler-util/src/sha256.ts b/packages/taler-util/src/sha256.ts
index 97723dbfc..ba8f09279 100644
--- a/packages/taler-util/src/sha256.ts
+++ b/packages/taler-util/src/sha256.ts
@@ -16,70 +16,17 @@ export const blockSize = 64;
// SHA-256 constants
const K = new Uint32Array([
- 0x428a2f98,
- 0x71374491,
- 0xb5c0fbcf,
- 0xe9b5dba5,
- 0x3956c25b,
- 0x59f111f1,
- 0x923f82a4,
- 0xab1c5ed5,
- 0xd807aa98,
- 0x12835b01,
- 0x243185be,
- 0x550c7dc3,
- 0x72be5d74,
- 0x80deb1fe,
- 0x9bdc06a7,
- 0xc19bf174,
- 0xe49b69c1,
- 0xefbe4786,
- 0x0fc19dc6,
- 0x240ca1cc,
- 0x2de92c6f,
- 0x4a7484aa,
- 0x5cb0a9dc,
- 0x76f988da,
- 0x983e5152,
- 0xa831c66d,
- 0xb00327c8,
- 0xbf597fc7,
- 0xc6e00bf3,
- 0xd5a79147,
- 0x06ca6351,
- 0x14292967,
- 0x27b70a85,
- 0x2e1b2138,
- 0x4d2c6dfc,
- 0x53380d13,
- 0x650a7354,
- 0x766a0abb,
- 0x81c2c92e,
- 0x92722c85,
- 0xa2bfe8a1,
- 0xa81a664b,
- 0xc24b8b70,
- 0xc76c51a3,
- 0xd192e819,
- 0xd6990624,
- 0xf40e3585,
- 0x106aa070,
- 0x19a4c116,
- 0x1e376c08,
- 0x2748774c,
- 0x34b0bcb5,
- 0x391c0cb3,
- 0x4ed8aa4a,
- 0x5b9cca4f,
- 0x682e6ff3,
- 0x748f82ee,
- 0x78a5636f,
- 0x84c87814,
- 0x8cc70208,
- 0x90befffa,
- 0xa4506ceb,
- 0xbef9a3f7,
- 0xc67178f2,
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
+ 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
+ 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
+ 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
+ 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
+ 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
+ 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function hashBlocks(
@@ -198,7 +145,7 @@ export class HashSha256 {
}
// Resets hash state making it possible
- // to re-use this instance to hash other data.
+ // to reuse this instance to hash other data.
reset(): this {
this.state[0] = 0x6a09e667;
this.state[1] = 0xbb67ae85;
diff --git a/packages/taler-util/src/taler-crypto.test.ts b/packages/taler-util/src/taler-crypto.test.ts
new file mode 100644
index 000000000..021730c7e
--- /dev/null
+++ b/packages/taler-util/src/taler-crypto.test.ts
@@ -0,0 +1,440 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports
+ */
+import test from "ava";
+import {
+ encodeCrock,
+ decodeCrock,
+ ecdhGetPublic,
+ eddsaGetPublic,
+ keyExchangeEddsaEcdh,
+ keyExchangeEcdhEddsa,
+ stringToBytes,
+ bytesToString,
+ deriveBSeed,
+ csBlind,
+ csUnblind,
+ csVerify,
+ deriveSecrets,
+ calcRBlind,
+ Edx25519,
+ getRandomBytes,
+ bigintToNaclArr,
+ bigintFromNaclArr,
+ kdf,
+} from "./taler-crypto.js";
+import { sha512 } from "./kdf.js";
+import * as nacl from "./nacl-fast.js";
+import { initNodePrng } from "./prng-node.js";
+
+// Since we import nacl-fast directly (and not via index.node.ts), we need to
+// init the PRNG manually.
+initNodePrng();
+import bigint from "big-integer";
+import { AssertionError } from "assert";
+import BigInteger from "big-integer";
+
+/**
+ * Used for testing, simple scalar multiplication with base point of Ed25519
+ * @param s scalar
+ * @returns new point sG
+ */
+async function scalarMultBase25519(s: Uint8Array): Promise<Uint8Array> {
+ return nacl.crypto_scalarmult_ed25519_base_noclamp(s);
+}
+
+test("encoding", (t) => {
+ const s = "Hello, World";
+ const encStr = encodeCrock(stringToBytes(s));
+ const outBuf = decodeCrock(encStr);
+ const sOut = bytesToString(outBuf);
+ t.deepEqual(s, sOut);
+});
+
+test("taler-exchange-tvg hash code", (t) => {
+ const input = "91JPRV3F5GG4EKJN41A62V35E8";
+ const output =
+ "CW96WR74JS8T53EC8GKSGD49QKH4ZNFTZXDAWMMV5GJ1E4BM6B8GPN5NVHDJ8ZVXNCW7Q4WBYCV61HCA3PZC2YJD850DT29RHHN7ESR";
+
+ const myOutput = encodeCrock(sha512(decodeCrock(input)));
+
+ t.deepEqual(myOutput, output);
+});
+
+test("taler-exchange-tvg ecdhe key", (t) => {
+ const priv1 = "X4T4N0M8PVQXQEBW2BA7049KFSM7J437NSDFC6GDNM3N5J9367A0";
+ const pub1 = "M997P494MS6A95G1P0QYWW2VNPSHSX5Q6JBY5B9YMNYWP0B50X3G";
+ const priv2 = "14A0MMQ64DCV8HE0CS3WBC9DHFJAHXRGV7NEARFJPC5R5E1697E0";
+ const skm =
+ "NXRY2YCY7H9B6KM928ZD55WG964G59YR0CPX041DYXKBZZ85SAWNPQ8B30QRM5FMHYCXJAN0EAADJYWEF1X3PAC2AJN28626TR5A6AR";
+
+ const myPub1 = nacl.scalarMult_base(decodeCrock(priv1));
+ t.deepEqual(encodeCrock(myPub1), pub1);
+
+ const mySkm = nacl.hash(
+ nacl.scalarMult(decodeCrock(priv2), decodeCrock(pub1)),
+ );
+ t.deepEqual(encodeCrock(mySkm), skm);
+});
+
+test("taler-exchange-tvg eddsa key", (t) => {
+ const priv = "9TM70AKDTS57AWY9JK2J4TMBTMW6K62WHHGZWYDG0VM5ABPZKD40";
+ const pub = "8GSJZ649T2PXMKZC01Y4ANNBE7MF14QVK9SQEC4E46ZHKCVG8AS0";
+
+ const pair = nacl.crypto_sign_keyPair_fromSeed(decodeCrock(priv));
+ t.deepEqual(encodeCrock(pair.publicKey), pub);
+});
+
+test("taler-exchange-tvg kdf", (t) => {
+ const salt = "94KPT83PCNS7J83KC5P78Y8";
+ const ikm = "94KPT83MD1JJ0WV5CDS6AX10D5Q70XBM41NPAY90DNGQ8SBJD5GPR";
+ const ctx =
+ "94KPT83141HPYVKMCNW78833D1TPWTSC41GPRWVF41NPWVVQDRG62WS04XMPWSKF4WG6JVH0EHM6A82J8S1G";
+ const outLen = 64;
+ const out =
+ "GTMR4QT05Z9WF5HKVG0WK9RPXGHSMHJNW377G9GJXCA8B0FEKPF4D27RJMSJZYWSQNTBJ5EYVV7ZW18B48Z0JVJJ80RHB706Y96Q358";
+
+ const myOut = kdf(
+ outLen,
+ decodeCrock(ikm),
+ decodeCrock(salt),
+ decodeCrock(ctx),
+ );
+
+ t.deepEqual(encodeCrock(myOut), out);
+});
+
+test("taler-exchange-tvg eddsa_ecdh", (t) => {
+ const priv_ecdhe = "4AFZWMSGTVCHZPQ0R81NWXDCK4N58G7SDBBE5KXE080Y50370JJG";
+ const pub_ecdhe = "FXFN5GPAFTKVPWJDPVXQ87167S8T82T5ZV8CDYC0NH2AE14X0M30";
+ const priv_eddsa = "1KG54M8T3X8BSFSZXCR3SQBSR7Y9P53NX61M864S7TEVMJ2XVPF0";
+ const pub_eddsa = "7BXWKG6N224C57RTDV8XEAHR108HG78NMA995BE8QAT5GC1S7E80";
+ const key_material =
+ "PKZ42Z56SVK2796HG1QYBRJ6ZQM2T9QGA3JA4AAZ8G7CWK9FPX175Q9JE5P0ZAX3HWWPHAQV4DPCK10R9X3SAXHRV0WF06BHEC2ZTKR";
+
+ const myEcdhePub = ecdhGetPublic(decodeCrock(priv_ecdhe));
+ t.deepEqual(encodeCrock(myEcdhePub), pub_ecdhe);
+
+ const myEddsaPub = eddsaGetPublic(decodeCrock(priv_eddsa));
+ t.deepEqual(encodeCrock(myEddsaPub), pub_eddsa);
+
+ const myKm1 = keyExchangeEddsaEcdh(
+ decodeCrock(priv_eddsa),
+ decodeCrock(pub_ecdhe),
+ );
+ t.deepEqual(encodeCrock(myKm1), key_material);
+
+ const myKm2 = keyExchangeEcdhEddsa(
+ decodeCrock(priv_ecdhe),
+ decodeCrock(pub_eddsa),
+ );
+ t.deepEqual(encodeCrock(myKm2), key_material);
+});
+
+test("incremental hashing #1", (t) => {
+ const n = 1024;
+ const d = nacl.randomBytes(n);
+
+ const h1 = nacl.hash(d);
+ const h2 = new nacl.HashState().update(d).finish();
+
+ const s = new nacl.HashState();
+ for (let i = 0; i < n; i++) {
+ const b = new Uint8Array(1);
+ b[0] = d[i];
+ s.update(b);
+ }
+
+ const h3 = s.finish();
+
+ t.deepEqual(encodeCrock(h1), encodeCrock(h2));
+ t.deepEqual(encodeCrock(h1), encodeCrock(h3));
+});
+
+test("incremental hashing #2", (t) => {
+ const n = 10;
+ const d = nacl.randomBytes(n);
+
+ const h1 = nacl.hash(d);
+ const h2 = new nacl.HashState().update(d).finish();
+ const s = new nacl.HashState();
+ for (let i = 0; i < n; i++) {
+ const b = new Uint8Array(1);
+ b[0] = d[i];
+ s.update(b);
+ }
+
+ const h3 = s.finish();
+
+ t.deepEqual(encodeCrock(h1), encodeCrock(h3));
+ t.deepEqual(encodeCrock(h1), encodeCrock(h2));
+});
+
+test("taler-exchange-tvg eddsa_ecdh #2", (t) => {
+ const priv_ecdhe = "W5FH9CFS3YPGSCV200GE8TH6MAACPKKGEG2A5JTFSD1HZ5RYT7Q0";
+ const pub_ecdhe = "FER9CRS2T8783TAANPZ134R704773XT0ZT1XPFXZJ9D4QX67ZN00";
+ const priv_eddsa = "MSZ1TBKC6YQ19ZFP3NTJVKWNVGFP35BBRW8FTAQJ9Z2B96VC9P4G";
+ const pub_eddsa = "Y7MKG85PBT8ZEGHF08JBVZXEV70TS0PY5Y2CMEN1WXEDN63KP1A0";
+ const key_material =
+ "G6RA58N61K7MT3WA13Q7VRTE1FQS6H43RX9HK8Z5TGAB61601GEGX51JRHHQMNKNM2R9AVC1STSGQDRHGKWVYP584YGBCTVMMJYQF30";
+
+ const myEcdhePub = ecdhGetPublic(decodeCrock(priv_ecdhe));
+ t.deepEqual(encodeCrock(myEcdhePub), pub_ecdhe);
+
+ const myEddsaPub = eddsaGetPublic(decodeCrock(priv_eddsa));
+ t.deepEqual(encodeCrock(myEddsaPub), pub_eddsa);
+
+ const myKm1 = keyExchangeEddsaEcdh(
+ decodeCrock(priv_eddsa),
+ decodeCrock(pub_ecdhe),
+ );
+ t.deepEqual(encodeCrock(myKm1), key_material);
+
+ const myKm2 = keyExchangeEcdhEddsa(
+ decodeCrock(priv_ecdhe),
+ decodeCrock(pub_eddsa),
+ );
+ t.deepEqual(encodeCrock(myKm2), key_material);
+});
+
+test("taler CS blind c", async (t) => {
+ /**$
+ * Test Vectors:
+ {
+ "operation": "cs_blind_signing",
+ "message_hash": "KZ7540050MWFPPPJ6C0910TC15AWD6KN6GMK4YH8PY5Z2RKP7NQMHZ1NDD7JHD9CA2CZXDKYN7XRX521YERAF6N50VJZMHWPH18TCFG",
+ "cs_public_key": "1903SZ7QE1K8T4BHTJ32KDJ153SBXT22DGNQDY5NKJE535J72H2G",
+ "cs_private_key": "K43QAMEPE9KJJTX6AJZD6N4SN1N3ARVAXZ2MRNPT85FHD4QD2C60",
+ "cs_nonce": "GWPVFP9160XNADYQZ4T6S7RACB2482KG1JCY0X2Z5R52W74YXY3G",
+ "cs_r_priv_0": "B01FJCRCST8JM10K17SJXY7S7HH7T65JMFQ03H6PNYY9Z167Q1T0",
+ "cs_r_priv_1": "N3GW5X6VYSB8PY83CYNHJ3PN6TCA5N5BCS4WT2WEEQH7MTK915P0",
+ "cs_r_pub_0": "J5XFBKFP9T6BM02H6ZV6Y568PQ2K398MD339036F25XTSP1A7T3G",
+ "cs_r_pub_1": "GA2CZKJ6CWFS81ZN1T5R4GQFHF7XJV6HWHDR1JA9VATKKXQN89J0",
+ "cs_bs_alpha_0": "R06FWJ4XEK4JKKKA03JARGD0PD5JAX8DK2N6J0K8CAZZMVQEJ1T0",
+ "cs_bs_alpha_1": "13NXE2FEHJS0Q5XCWNRF4V1NC3BSAHN6BW02WZ07PG6967156HYG",
+ "cs_bs_beta_0": "T3EZP42RJQXRTJ4FTDWF18Z422VX7KFGN8GJ3QCCM1QV3N456HD0",
+ "cs_bs_beta_1": "P3MECYGCCR58QVEDSW443699CDXVT8C8W5ZT22PPNRJ363M72H6G",
+ "cs_r_pub_blind_0": "CHK7JC4SXZ4Y9RDA3881S82F7BP99H35Q361WR6RBXN5YN2ZM1M0",
+ "cs_r_pub_blind_1": "4C65R74GA9PPDX4DC2B948W96T3Z6QEENK2NDJQPNB9QBTKCT590",
+ "cs_c_0": "F288QXT67TR36E6DHE399G8J24RM6C3DP16HGMH74B6WZ1DETR10",
+ "cs_c_1": "EFK5WTN01NCVS3DZCG20MQDHRHBATRG8589BA0XSZDZ6D0HFR470",
+ "cs_blind_s": "6KZF904YZA8KK4C8X5JV57E7B84SR8TDDN9GDC8QTRRSNTHJTM4G",
+ "cs_b": "0000000",
+ "cs_sig_s": "F4ZKMFW3Q7DFN0N94KAMG2JFFHAC362T0QZ6ZCVZ73RS8P91CR70",
+ "cs_sig_R": "CHK7JC4SXZ4Y9RDA3881S82F7BP99H35Q361WR6RBXN5YN2ZM1M0",
+ "cs_c_blind_0": "6TN5454DZCHBDXFAGQFXQY37FNX6YRKW0MPFEX4TG5EHXC98M840",
+ "cs_c_blind_1": "EX6MYRZX6EC93YB4EE3M7AR3PQDYYG4092917YF29HD36X58NG0G",
+ "cs_prehash_0": "D29BBP762HEN6ZHZ5T2T6S4VMV400K9Y659M1QQZYZ0WJS3V3EJSF0FVXSCD1E99JJJMW295EY8TEE97YEGSGEQ0Q0A9DDMS2NCAG9R",
+ "cs_prehash_1": "9BYD02BC29ZF26BG88DWFCCENCS8CD8VZN76XP8JPWKTN9JS73MBCD0F36N0JSM223MRNJZACNYPMW23SGRHYVSP6BTT79GSSK5R228"
+ }
+ */
+
+ type CsBlindSignature = {
+ sBlind: Uint8Array;
+ rPubBlind: Uint8Array;
+ };
+ /**
+ * CS denomination keypair
+ */
+ const priv = "K43QAMEPE9KJJTX6AJZD6N4SN1N3ARVAXZ2MRNPT85FHD4QD2C60";
+ const pub_cmp = "1903SZ7QE1K8T4BHTJ32KDJ153SBXT22DGNQDY5NKJE535J72H2G";
+ const pub = await scalarMultBase25519(decodeCrock(priv));
+ t.deepEqual(decodeCrock(pub_cmp), pub);
+
+ const nonce = "GWPVFP9160XNADYQZ4T6S7RACB2482KG1JCY0X2Z5R52W74YXY3G";
+ const msg_hash =
+ "KZ7540050MWFPPPJ6C0910TC15AWD6KN6GMK4YH8PY5Z2RKP7NQMHZ1NDD7JHD9CA2CZXDKYN7XRX521YERAF6N50VJZMHWPH18TCFG";
+
+ /**
+ * rPub is returned from the exchange's new /csr API
+ */
+ const rPriv0 = "B01FJCRCST8JM10K17SJXY7S7HH7T65JMFQ03H6PNYY9Z167Q1T0";
+ const rPriv1 = "N3GW5X6VYSB8PY83CYNHJ3PN6TCA5N5BCS4WT2WEEQH7MTK915P0";
+ const rPub0 = await scalarMultBase25519(decodeCrock(rPriv0));
+ const rPub1 = await scalarMultBase25519(decodeCrock(rPriv1));
+
+ const rPub: [Uint8Array, Uint8Array] = [rPub0, rPub1];
+
+ t.deepEqual(
+ rPub[0],
+ decodeCrock("J5XFBKFP9T6BM02H6ZV6Y568PQ2K398MD339036F25XTSP1A7T3G"),
+ );
+ t.deepEqual(
+ rPub[1],
+ decodeCrock("GA2CZKJ6CWFS81ZN1T5R4GQFHF7XJV6HWHDR1JA9VATKKXQN89J0"),
+ );
+
+ /**
+ * Test if blinding seed derivation is deterministic
+ * In the wallet the b-seed MUST be different from the Withdraw-Nonce or Refresh Nonce!
+ * (Eg. derive two different values from coin priv) -> See CS protocols for details
+ */
+ const priv_eddsa = "1KG54M8T3X8BSFSZXCR3SQBSR7Y9P53NX61M864S7TEVMJ2XVPF0";
+ // const pub_eddsa = eddsaGetPublic(decodeCrock(priv_eddsa));
+ const bseed1 = deriveBSeed(decodeCrock(priv_eddsa), rPub);
+ const bseed2 = deriveBSeed(decodeCrock(priv_eddsa), rPub);
+ t.deepEqual(bseed1, bseed2);
+
+ /**
+ * In this scenario the nonce from the test vectors is used as b-seed and refresh.
+ * This is only used in testing to test functionality.
+ * DO NOT USE the same values for blinding-seed and nonce anywhere else.
+ *
+ * Tests whether the blinding secrets are derived as in the exchange implementation
+ */
+ const bseed = decodeCrock(nonce);
+ const secrets = deriveSecrets(bseed);
+ t.deepEqual(
+ secrets.alpha[0],
+ decodeCrock("R06FWJ4XEK4JKKKA03JARGD0PD5JAX8DK2N6J0K8CAZZMVQEJ1T0"),
+ );
+ t.deepEqual(
+ secrets.alpha[1],
+ decodeCrock("13NXE2FEHJS0Q5XCWNRF4V1NC3BSAHN6BW02WZ07PG6967156HYG"),
+ );
+ t.deepEqual(
+ secrets.beta[0],
+ decodeCrock("T3EZP42RJQXRTJ4FTDWF18Z422VX7KFGN8GJ3QCCM1QV3N456HD0"),
+ );
+ t.deepEqual(
+ secrets.beta[1],
+ decodeCrock("P3MECYGCCR58QVEDSW443699CDXVT8C8W5ZT22PPNRJ363M72H6G"),
+ );
+
+ const rBlind = await calcRBlind(pub, secrets, rPub);
+ t.deepEqual(
+ rBlind[0],
+ decodeCrock("CHK7JC4SXZ4Y9RDA3881S82F7BP99H35Q361WR6RBXN5YN2ZM1M0"),
+ );
+ t.deepEqual(
+ rBlind[1],
+ decodeCrock("4C65R74GA9PPDX4DC2B948W96T3Z6QEENK2NDJQPNB9QBTKCT590"),
+ );
+
+ const c = await csBlind(bseed, rPub, pub, decodeCrock(msg_hash));
+ t.deepEqual(
+ c[0],
+ decodeCrock("F288QXT67TR36E6DHE399G8J24RM6C3DP16HGMH74B6WZ1DETR10"),
+ );
+ t.deepEqual(
+ c[1],
+ decodeCrock("EFK5WTN01NCVS3DZCG20MQDHRHBATRG8589BA0XSZDZ6D0HFR470"),
+ );
+
+ const lMod = Array.from(
+ new Uint8Array([
+ 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x14, 0xde, 0xf9, 0xde, 0xa2, 0xf7, 0x9c, 0xd6,
+ 0x58, 0x12, 0x63, 0x1a, 0x5c, 0xf5, 0xd3, 0xed,
+ ]),
+ );
+ const L = bigint.fromArray(lMod, 256, false).toString();
+ //Lmod needs to be 2^252+27742317777372353535851937790883648493
+ if (!L.startsWith("723700")) {
+ throw new AssertionError({ message: L });
+ }
+
+ const b = 0;
+ const blindsig: CsBlindSignature = {
+ sBlind: decodeCrock("6KZF904YZA8KK4C8X5JV57E7B84SR8TDDN9GDC8QTRRSNTHJTM4G"),
+ rPubBlind: rPub[b],
+ };
+
+ const sig = await csUnblind(bseed, rPub, pub, b, blindsig);
+ t.deepEqual(
+ sig.s,
+ decodeCrock("F4ZKMFW3Q7DFN0N94KAMG2JFFHAC362T0QZ6ZCVZ73RS8P91CR70"),
+ );
+ t.deepEqual(
+ sig.rPub,
+ decodeCrock("CHK7JC4SXZ4Y9RDA3881S82F7BP99H35Q361WR6RBXN5YN2ZM1M0"),
+ );
+
+ const res = await csVerify(decodeCrock(msg_hash), sig, pub);
+ t.deepEqual(res, true);
+});
+
+test("bigint/nacl conversion", async (t) => {
+ const b1 = BigInteger(42);
+ const n1 = bigintToNaclArr(b1, 32);
+ t.is(n1[0], 42);
+ t.is(n1.length, 32);
+ const b2 = bigintFromNaclArr(n1);
+ t.true(b1.eq(b2));
+});
+
+test("taler age restriction crypto", async (t) => {
+ const priv1 = await Edx25519.keyCreate();
+ const pub1 = await Edx25519.getPublic(priv1);
+
+ const seed = getRandomBytes(32);
+
+ const priv2 = await Edx25519.privateKeyDerive(priv1, seed);
+ const pub2 = await Edx25519.publicKeyDerive(pub1, seed);
+
+ const pub2Ref = await Edx25519.getPublic(priv2);
+
+ t.deepEqual(pub2, pub2Ref);
+});
+
+test("edx signing", async (t) => {
+ const priv1 = await Edx25519.keyCreate();
+ const pub1 = await Edx25519.getPublic(priv1);
+
+ const msg = stringToBytes("hello world");
+
+ const sig = nacl.crypto_edx25519_sign_detached(msg, priv1, pub1);
+
+ t.true(nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1));
+
+ sig[0]++;
+
+ t.false(nacl.crypto_edx25519_sign_detached_verify(msg, sig, pub1));
+});
+
+test("edx test vector", async (t) => {
+ // Generated by gnunet-crypto-tvg
+ const tv = {
+ operation: "edx25519_derive",
+ priv1_edx:
+ "P0JAQ53G66M7TSGQTCFVFMPCBC7WHBRYDZGQXM8VD88C72NJANR07V1DQRAE7KSH92HZ3B62PJVRYFTVFTQM43K5AQD8R4A7HWJ3P7G",
+ pub1_edx: "4YZ6D5MGWTWCTKY4W931V4S5SW0XG7AD4A60J2Z9CSEB9WE05WB0",
+ seed: "SQ3YAVGNZ2GYER9VQAJB2M1Z903Y458HYXWBSF9S2A9YKF85R4DHYJX35YXXX82CBGFW2TRBCR1ZCWSQ7A87QW5SHC8WP9JH48P8KK8",
+ priv2_edx:
+ "GQ7NCSVNKY0QS7GQVFP2TSG6P4YN1NCK303K5TYXXBKSZ61M3R4XFZ0KA42JND6GBZRXRSJY9EX3HMMY160VQ6Y6H2NZ8H0WVQRCG1R",
+ pub2_edx: "F5X6379F0FSY87MN9210FAN84PR8KYDJQ5G5784H1N3FY12ZKAPG",
+ };
+
+ {
+ const pub1Prime = await Edx25519.getPublic(decodeCrock(tv.priv1_edx));
+ t.deepEqual(pub1Prime, decodeCrock(tv.pub1_edx));
+ }
+
+ const pub2Prime = await Edx25519.publicKeyDerive(
+ decodeCrock(tv.pub1_edx),
+ decodeCrock(tv.seed),
+ );
+ t.deepEqual(pub2Prime, decodeCrock(tv.pub2_edx));
+
+ const priv2Prime = await Edx25519.privateKeyDerive(
+ decodeCrock(tv.priv1_edx),
+ decodeCrock(tv.seed),
+ );
+ t.deepEqual(priv2Prime, decodeCrock(tv.priv2_edx));
+});
diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts
new file mode 100644
index 000000000..e587773e2
--- /dev/null
+++ b/packages/taler-util/src/taler-crypto.ts
@@ -0,0 +1,1662 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Native implementation of GNU Taler crypto primitives.
+ */
+
+/**
+ * Imports.
+ */
+import * as nacl from "./nacl-fast.js";
+import { hmacSha256, hmacSha512 } from "./kdf.js";
+import bigint from "big-integer";
+import * as argon2 from "./argon2.js";
+import {
+ CoinEnvelope,
+ CoinPublicKeyString,
+ DenominationPubKey,
+ DenomKeyType,
+ HashCodeString,
+} from "./taler-types.js";
+import { Logger } from "./logging.js";
+import { secretbox } from "./nacl-fast.js";
+import * as fflate from "fflate";
+import { canonicalJson } from "./helpers.js";
+import { TalerProtocolDuration, TalerProtocolTimestamp } from "./time.js";
+import { AmountLike, Amounts } from "./amounts.js";
+
+export type Flavor<T, FlavorT extends string> = T & {
+ _flavor?: `taler.${FlavorT}`;
+};
+
+export type FlavorP<T, FlavorT extends string, S extends number> = T & {
+ _flavor?: `taler.${FlavorT}`;
+ _size?: S;
+};
+
+export function getRandomBytes(n: number): Uint8Array {
+ return nacl.randomBytes(n);
+}
+
+export function getRandomBytesF<T extends number, N extends string>(
+ n: T,
+): FlavorP<Uint8Array, N, T> {
+ return nacl.randomBytes(n);
+}
+
+export const useNative = true;
+
+/**
+ * Interface of the native Taler runtime library.
+ */
+interface NativeTartLib {
+ decodeUtf8(buf: Uint8Array): string;
+ decodeUtf8(str: string): Uint8Array;
+ randomBytes(n: number): Uint8Array;
+ encodeCrock(buf: Uint8Array | ArrayBuffer): string;
+ decodeCrock(str: string): Uint8Array;
+ hash(buf: Uint8Array): Uint8Array;
+ hashArgon2id(
+ password: Uint8Array,
+ salt: Uint8Array,
+ iterations: number,
+ memorySize: number,
+ hashLength: number,
+ ): Uint8Array;
+ eddsaGetPublic(buf: Uint8Array): Uint8Array;
+ ecdheGetPublic(buf: Uint8Array): Uint8Array;
+ eddsaSign(msg: Uint8Array, priv: Uint8Array): Uint8Array;
+ eddsaVerify(msg: Uint8Array, sig: Uint8Array, pub: Uint8Array): boolean;
+ kdf(
+ outLen: number,
+ ikm: Uint8Array,
+ salt?: Uint8Array,
+ info?: Uint8Array,
+ ): Uint8Array;
+ keyExchangeEcdhEddsa(ecdhPriv: Uint8Array, eddsaPub: Uint8Array): Uint8Array;
+ keyExchangeEddsaEcdh(eddsaPriv: Uint8Array, ecdhPub: Uint8Array): Uint8Array;
+ rsaBlind(hmsg: Uint8Array, bks: Uint8Array, rsaPub: Uint8Array): Uint8Array;
+ rsaUnblind(
+ blindSig: Uint8Array,
+ rsaPub: Uint8Array,
+ bks: Uint8Array,
+ ): Uint8Array;
+ rsaVerify(hmsg: Uint8Array, rsaSig: Uint8Array, rsaPub: Uint8Array): boolean;
+ hashStateInit(): any;
+ hashStateUpdate(st: any, data: Uint8Array): any;
+ hashStateFinish(st: any): Uint8Array;
+}
+
+// @ts-ignore
+let tart: NativeTartLib | undefined;
+
+if (useNative) {
+ // @ts-ignore
+ tart = globalThis._tart;
+}
+
+const encTable = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+
+class EncodingError extends Error {
+ constructor() {
+ super("Encoding error");
+ Object.setPrototypeOf(this, EncodingError.prototype);
+ }
+}
+
+function getValue(chr: string): number {
+ let a = chr;
+ switch (chr) {
+ case "O":
+ case "o":
+ a = "0";
+ break;
+ case "i":
+ case "I":
+ case "l":
+ case "L":
+ a = "1";
+ break;
+ case "u":
+ case "U":
+ a = "V";
+ }
+
+ if (a >= "0" && a <= "9") {
+ return a.charCodeAt(0) - "0".charCodeAt(0);
+ }
+
+ if (a >= "a" && a <= "z") a = a.toUpperCase();
+ let dec = 0;
+ if (a >= "A" && a <= "Z") {
+ if ("I" < a) dec++;
+ if ("L" < a) dec++;
+ if ("O" < a) dec++;
+ if ("U" < a) dec++;
+ return a.charCodeAt(0) - "A".charCodeAt(0) + 10 - dec;
+ }
+ throw new EncodingError();
+}
+
+export function encodeCrock(data: ArrayBuffer): string {
+ if (tart) {
+ return tart.encodeCrock(data);
+ }
+ const dataBytes = new Uint8Array(data);
+ let sb = "";
+ const size = data.byteLength;
+ let bitBuf = 0;
+ let numBits = 0;
+ let pos = 0;
+ while (pos < size || numBits > 0) {
+ if (pos < size && numBits < 5) {
+ const d = dataBytes[pos++];
+ bitBuf = (bitBuf << 8) | d;
+ numBits += 8;
+ }
+ if (numBits < 5) {
+ // zero-padding
+ bitBuf = bitBuf << (5 - numBits);
+ numBits = 5;
+ }
+ const v = (bitBuf >>> (numBits - 5)) & 31;
+ sb += encTable[v];
+ numBits -= 5;
+ }
+ return sb;
+}
+
+export function kdf(
+ outputLength: number,
+ ikm: Uint8Array,
+ salt?: Uint8Array,
+ info?: Uint8Array,
+): Uint8Array {
+ if (tart) {
+ return tart.kdf(outputLength, ikm, salt, info);
+ }
+ salt = salt ?? new Uint8Array(64);
+ // extract
+ const prk = hmacSha512(salt, ikm);
+
+ info = info ?? new Uint8Array(0);
+
+ // expand
+ const N = Math.ceil(outputLength / 32);
+ const output = new Uint8Array(N * 32);
+ for (let i = 0; i < N; i++) {
+ let buf;
+ if (i == 0) {
+ buf = new Uint8Array(info.byteLength + 1);
+ buf.set(info, 0);
+ } else {
+ buf = new Uint8Array(info.byteLength + 1 + 32);
+ for (let j = 0; j < 32; j++) {
+ buf[j] = output[(i - 1) * 32 + j];
+ }
+ buf.set(info, 32);
+ }
+ buf[buf.length - 1] = i + 1;
+ const chunk = hmacSha256(prk, buf);
+ output.set(chunk, i * 32);
+ }
+
+ return output.slice(0, outputLength);
+}
+
+/**
+ * HMAC-SHA512-SHA256 (see RFC 5869).
+ */
+export function kdfKw(args: {
+ outputLength: number;
+ ikm: Uint8Array;
+ salt?: Uint8Array;
+ info?: Uint8Array;
+}) {
+ return kdf(args.outputLength, args.ikm, args.salt, args.info);
+}
+
+export function decodeCrock(encoded: string): Uint8Array {
+ if (tart) {
+ return tart.decodeCrock(encoded);
+ }
+ const size = encoded.length;
+ let bitpos = 0;
+ let bitbuf = 0;
+ let readPosition = 0;
+ const outLen = Math.floor((size * 5) / 8);
+ const out = new Uint8Array(outLen);
+ let outPos = 0;
+
+ while (readPosition < size || bitpos > 0) {
+ if (readPosition < size) {
+ const v = getValue(encoded[readPosition++]);
+ bitbuf = (bitbuf << 5) | v;
+ bitpos += 5;
+ }
+ while (bitpos >= 8) {
+ const d = (bitbuf >>> (bitpos - 8)) & 0xff;
+ out[outPos++] = d;
+ bitpos -= 8;
+ }
+ if (readPosition == size && bitpos > 0) {
+ bitbuf = (bitbuf << (8 - bitpos)) & 0xff;
+ bitpos = bitbuf == 0 ? 0 : 8;
+ }
+ }
+ return out;
+}
+
+export async function hashArgon2id(
+ password: Uint8Array,
+ salt: Uint8Array,
+ iterations: number,
+ memorySize: number,
+ hashLength: number,
+): Promise<Uint8Array> {
+ if (tart) {
+ return tart.hashArgon2id(
+ password,
+ salt,
+ iterations,
+ memorySize,
+ hashLength,
+ );
+ }
+ return await argon2.hashArgon2id(
+ password,
+ salt,
+ iterations,
+ memorySize,
+ hashLength,
+ );
+}
+
+export function eddsaGetPublic(eddsaPriv: Uint8Array): Uint8Array {
+ if (tart) {
+ return tart.eddsaGetPublic(eddsaPriv);
+ }
+ const pair = nacl.crypto_sign_keyPair_fromSeed(eddsaPriv);
+ return pair.publicKey;
+}
+
+export function ecdhGetPublic(ecdhePriv: Uint8Array): Uint8Array {
+ if (tart) {
+ return tart.ecdheGetPublic(ecdhePriv);
+ }
+ return nacl.scalarMult_base(ecdhePriv);
+}
+
+export function keyExchangeEddsaEcdh(
+ eddsaPriv: Uint8Array,
+ ecdhPub: Uint8Array,
+): Uint8Array {
+ if (tart) {
+ return tart.keyExchangeEddsaEcdh(eddsaPriv, ecdhPub);
+ }
+ const ph = hash(eddsaPriv);
+ const a = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) {
+ a[i] = ph[i];
+ }
+ const x = nacl.scalarMult(a, ecdhPub);
+ return hash(x);
+}
+
+export function keyExchangeEcdhEddsa(
+ ecdhPriv: Uint8Array & MaterialEcdhePriv,
+ eddsaPub: Uint8Array & MaterialEddsaPub,
+): Uint8Array {
+ if (tart) {
+ return tart.keyExchangeEcdhEddsa(ecdhPriv, eddsaPub);
+ }
+ const curve25519Pub = nacl.sign_ed25519_pk_to_curve25519(eddsaPub);
+ const x = nacl.scalarMult(ecdhPriv, curve25519Pub);
+ return hash(x);
+}
+
+interface RsaPub {
+ N: bigint.BigInteger;
+ e: bigint.BigInteger;
+}
+
+/**
+ * KDF modulo a big integer.
+ */
+function kdfMod(
+ n: bigint.BigInteger,
+ ikm: Uint8Array,
+ salt: Uint8Array,
+ info: Uint8Array,
+): bigint.BigInteger {
+ const nbits = n.bitLength().toJSNumber();
+ const buflen = Math.floor((nbits - 1) / 8 + 1);
+ const mask = (1 << (8 - (buflen * 8 - nbits))) - 1;
+ let counter = 0;
+ while (true) {
+ const ctx = new Uint8Array(info.byteLength + 2);
+ ctx.set(info, 0);
+ ctx[ctx.length - 2] = (counter >>> 8) & 0xff;
+ ctx[ctx.length - 1] = counter & 0xff;
+ const buf = kdf(buflen, ikm, salt, ctx);
+ const arr = Array.from(buf);
+ arr[0] = arr[0] & mask;
+ const r = bigint.fromArray(arr, 256, false);
+ if (r.lt(n)) {
+ return r;
+ }
+ counter++;
+ }
+}
+
+function csKdfMod(
+ n: bigint.BigInteger,
+ ikm: Uint8Array,
+ salt: Uint8Array,
+ info: Uint8Array,
+): Uint8Array {
+ const nbits = n.bitLength().toJSNumber();
+ const buflen = Math.floor((nbits - 1) / 8 + 1);
+ const mask = (1 << (8 - (buflen * 8 - nbits))) - 1;
+ let counter = 0;
+ while (true) {
+ const ctx = new Uint8Array(info.byteLength + 2);
+ ctx.set(info, 0);
+ ctx[ctx.length - 2] = (counter >>> 8) & 0xff;
+ ctx[ctx.length - 1] = counter & 0xff;
+ const buf = kdf(buflen, ikm, salt, ctx);
+ const arr = Array.from(buf);
+ arr[0] = arr[0] & mask;
+ const r = bigint.fromArray(arr, 256, false);
+ if (r.lt(n)) {
+ return new Uint8Array(arr);
+ }
+ counter++;
+ }
+}
+
+// Newer versions of node have TextEncoder and TextDecoder as a global,
+// just like modern browsers.
+// In older versions of node or environments that do not have these
+// globals, they must be polyfilled (by adding them to global/globalThis)
+// before stringToBytes or bytesToString is called the first time.
+
+let encoder: any;
+let decoder: any;
+
+export function stringToBytes(s: string): Uint8Array {
+ if (!encoder) {
+ encoder = new TextEncoder();
+ }
+ return encoder.encode(s);
+}
+
+export function bytesToString(b: Uint8Array): string {
+ if (!decoder) {
+ decoder = new TextDecoder();
+ }
+ return decoder.decode(b);
+}
+
+function loadBigInt(arr: Uint8Array): bigint.BigInteger {
+ return bigint.fromArray(Array.from(arr), 256, false);
+}
+
+function rsaBlindingKeyDerive(
+ rsaPub: RsaPub,
+ bks: Uint8Array,
+): bigint.BigInteger {
+ const salt = stringToBytes("Blinding KDF extractor HMAC key");
+ const info = stringToBytes("Blinding KDF");
+ return kdfMod(rsaPub.N, bks, salt, info);
+}
+
+/*
+ * Test for malicious RSA key.
+ *
+ * Assuming n is an RSA modulous and r is generated using a call to
+ * GNUNET_CRYPTO_kdf_mod_mpi, if gcd(r,n) != 1 then n must be a
+ * malicious RSA key designed to deanomize the user.
+ *
+ * @param r KDF result
+ * @param n RSA modulus of the public key
+ */
+function rsaGcdValidate(r: bigint.BigInteger, n: bigint.BigInteger): void {
+ const t = bigint.gcd(r, n);
+ if (!t.equals(bigint.one)) {
+ throw Error("malicious RSA public key");
+ }
+}
+
+function rsaFullDomainHash(hm: Uint8Array, rsaPub: RsaPub): bigint.BigInteger {
+ const info = stringToBytes("RSA-FDA FTpsW!");
+ const salt = rsaPubEncode(rsaPub);
+ const r = kdfMod(rsaPub.N, hm, salt, info);
+ rsaGcdValidate(r, rsaPub.N);
+ return r;
+}
+
+function rsaPubDecode(rsaPub: Uint8Array): RsaPub {
+ const modulusLength = (rsaPub[0] << 8) | rsaPub[1];
+ const exponentLength = (rsaPub[2] << 8) | rsaPub[3];
+ if (4 + exponentLength + modulusLength != rsaPub.length) {
+ throw Error("invalid RSA public key (format wrong)");
+ }
+ const modulus = rsaPub.slice(4, 4 + modulusLength);
+ const exponent = rsaPub.slice(
+ 4 + modulusLength,
+ 4 + modulusLength + exponentLength,
+ );
+ const res = {
+ N: loadBigInt(modulus),
+ e: loadBigInt(exponent),
+ };
+ return res;
+}
+
+function rsaPubEncode(rsaPub: RsaPub): Uint8Array {
+ const mb = rsaPub.N.toArray(256).value;
+ const eb = rsaPub.e.toArray(256).value;
+ const out = new Uint8Array(4 + mb.length + eb.length);
+ out[0] = (mb.length >>> 8) & 0xff;
+ out[1] = mb.length & 0xff;
+ out[2] = (eb.length >>> 8) & 0xff;
+ out[3] = eb.length & 0xff;
+ out.set(mb, 4);
+ out.set(eb, 4 + mb.length);
+ return out;
+}
+
+export function rsaBlind(
+ hm: Uint8Array,
+ bks: Uint8Array,
+ rsaPubEnc: Uint8Array,
+): Uint8Array {
+ if (tart) {
+ return tart.rsaBlind(hm, bks, rsaPubEnc);
+ }
+ const rsaPub = rsaPubDecode(rsaPubEnc);
+ const data = rsaFullDomainHash(hm, rsaPub);
+ const r = rsaBlindingKeyDerive(rsaPub, bks);
+ const r_e = r.modPow(rsaPub.e, rsaPub.N);
+ const bm = r_e.multiply(data).mod(rsaPub.N);
+ return new Uint8Array(bm.toArray(256).value);
+}
+
+export function rsaUnblind(
+ sig: Uint8Array,
+ rsaPubEnc: Uint8Array,
+ bks: Uint8Array,
+): Uint8Array {
+ if (tart) {
+ return tart.rsaUnblind(sig, rsaPubEnc, bks);
+ }
+ const rsaPub = rsaPubDecode(rsaPubEnc);
+ const blinded_s = loadBigInt(sig);
+ const r = rsaBlindingKeyDerive(rsaPub, bks);
+ const r_inv = r.modInv(rsaPub.N);
+ const s = blinded_s.multiply(r_inv).mod(rsaPub.N);
+ return new Uint8Array(s.toArray(256).value);
+}
+
+export function rsaVerify(
+ hm: Uint8Array,
+ rsaSig: Uint8Array,
+ rsaPubEnc: Uint8Array,
+): boolean {
+ if (tart) {
+ return tart.rsaVerify(hm, rsaSig, rsaPubEnc);
+ }
+ const rsaPub = rsaPubDecode(rsaPubEnc);
+ const d = rsaFullDomainHash(hm, rsaPub);
+ const sig = loadBigInt(rsaSig);
+ const sig_e = sig.modPow(rsaPub.e, rsaPub.N);
+ return sig_e.equals(d);
+}
+
+export type CsSignature = {
+ s: Uint8Array;
+ rPub: Uint8Array;
+};
+
+export type CsBlindSignature = {
+ sBlind: Uint8Array;
+ rPubBlind: Uint8Array;
+};
+
+export type CsBlindingSecrets = {
+ alpha: [Uint8Array, Uint8Array];
+ beta: [Uint8Array, Uint8Array];
+};
+
+export function typedArrayConcat(chunks: Uint8Array[]): Uint8Array {
+ let payloadLen = 0;
+ for (const c of chunks) {
+ payloadLen += c.byteLength;
+ }
+ const buf = new ArrayBuffer(payloadLen);
+ const u8buf = new Uint8Array(buf);
+ let p = 0;
+ for (const c of chunks) {
+ u8buf.set(c, p);
+ p += c.byteLength;
+ }
+ return u8buf;
+}
+
+/**
+ * Map to scalar subgroup function
+ * perform clamping as described in RFC7748
+ * @param scalar
+ */
+function mtoSS(scalar: Uint8Array): Uint8Array {
+ scalar[0] &= 248;
+ scalar[31] &= 127;
+ scalar[31] |= 64;
+ return scalar;
+}
+
+/**
+ * The function returns the CS blinding secrets from a seed
+ * @param bseed seed to derive blinding secrets
+ * @returns blinding secrets
+ */
+export function deriveSecrets(bseed: Uint8Array): CsBlindingSecrets {
+ const outLen = 130;
+ const salt = stringToBytes("alphabeta");
+ const rndout = kdf(outLen, bseed, salt);
+ const secrets: CsBlindingSecrets = {
+ alpha: [mtoSS(rndout.slice(0, 32)), mtoSS(rndout.slice(64, 96))],
+ beta: [mtoSS(rndout.slice(32, 64)), mtoSS(rndout.slice(96, 128))],
+ };
+ return secrets;
+}
+
+/**
+ * calculation of the blinded public point R in CS
+ * @param csPub denomination publik key
+ * @param secrets client blinding secrets
+ * @param rPub public R received from /csr API
+ */
+export async function calcRBlind(
+ csPub: Uint8Array,
+ secrets: CsBlindingSecrets,
+ rPub: [Uint8Array, Uint8Array],
+): Promise<[Uint8Array, Uint8Array]> {
+ const aG0 = nacl.crypto_scalarmult_ed25519_base_noclamp(secrets.alpha[0]);
+ const aG1 = nacl.crypto_scalarmult_ed25519_base_noclamp(secrets.alpha[1]);
+
+ const bDp0 = nacl.crypto_scalarmult_ed25519_noclamp(secrets.beta[0], csPub);
+ const bDp1 = nacl.crypto_scalarmult_ed25519_noclamp(secrets.beta[1], csPub);
+
+ const res0 = nacl.crypto_core_ed25519_add(aG0, bDp0);
+ const res1 = nacl.crypto_core_ed25519_add(aG1, bDp1);
+ return [
+ nacl.crypto_core_ed25519_add(rPub[0], res0),
+ nacl.crypto_core_ed25519_add(rPub[1], res1),
+ ];
+}
+
+/**
+ * FDH function used in CS
+ * @param hm message hash
+ * @param rPub public R included in FDH
+ * @param csPub denomination public key as context
+ * @returns mapped Curve25519 scalar
+ */
+function csFDH(
+ hm: Uint8Array,
+ rPub: Uint8Array,
+ csPub: Uint8Array,
+): Uint8Array {
+ const lMod = Array.from(
+ new Uint8Array([
+ 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x14, 0xde, 0xf9, 0xde, 0xa2, 0xf7, 0x9c, 0xd6,
+ 0x58, 0x12, 0x63, 0x1a, 0x5c, 0xf5, 0xd3, 0xed,
+ ]),
+ );
+ const L = bigint.fromArray(lMod, 256, false);
+
+ const info = stringToBytes("Curve25519FDH");
+ const preshash = hash(typedArrayConcat([rPub, hm]));
+ return csKdfMod(L, preshash, csPub, info).reverse();
+}
+
+/**
+ * blinding seed derived from coin private key
+ * @param coinPriv private key of the corresponding coin
+ * @param rPub public R received from /csr API
+ * @returns blinding seed
+ */
+export function deriveBSeed(
+ coinPriv: Uint8Array,
+ rPub: [Uint8Array, Uint8Array],
+): Uint8Array {
+ const outLen = 32;
+ const salt = stringToBytes("b-seed");
+ const ikm = typedArrayConcat([coinPriv, rPub[0], rPub[1]]);
+ return kdf(outLen, ikm, salt);
+}
+
+/**
+ * Derive withdraw nonce, used in /csr request
+ * Note: In withdraw protocol, the nonce is chosen randomly
+ * @param coinPriv coin private key
+ * @returns nonce
+ */
+export function deriveWithdrawNonce(coinPriv: Uint8Array): Uint8Array {
+ const outLen = 32;
+ const salt = stringToBytes("n");
+ return kdf(outLen, coinPriv, salt);
+}
+
+/**
+ * Blind operation for CS signatures, used after /csr call
+ * @param bseed blinding seed to derive blinding secrets
+ * @param rPub public R received from /csr
+ * @param csPub denomination public key
+ * @param hm message to blind
+ * @returns two blinded c
+ */
+export async function csBlind(
+ bseed: Uint8Array,
+ rPub: [Uint8Array, Uint8Array],
+ csPub: Uint8Array,
+ hm: Uint8Array,
+): Promise<[Uint8Array, Uint8Array]> {
+ const secrets = deriveSecrets(bseed);
+ const rPubBlind = await calcRBlind(csPub, secrets, rPub);
+ const c_0 = csFDH(hm, rPubBlind[0], csPub);
+ const c_1 = csFDH(hm, rPubBlind[1], csPub);
+ return [
+ nacl.crypto_core_ed25519_scalar_add(c_0, secrets.beta[0]),
+ nacl.crypto_core_ed25519_scalar_add(c_1, secrets.beta[1]),
+ ];
+}
+
+/**
+ * Unblind operation to unblind the signature
+ * @param bseed seed to derive secrets
+ * @param rPub public R received from /csr
+ * @param csPub denomination public key
+ * @param b returned from exchange to select c
+ * @param csSig blinded signature
+ * @returns unblinded signature
+ */
+export async function csUnblind(
+ bseed: Uint8Array,
+ rPub: [Uint8Array, Uint8Array],
+ csPub: Uint8Array,
+ b: number,
+ csSig: CsBlindSignature,
+): Promise<CsSignature> {
+ if (b != 0 && b != 1) {
+ throw new Error();
+ }
+ const secrets = deriveSecrets(bseed);
+ const rPubDash = (await calcRBlind(csPub, secrets, rPub))[b];
+ const sig: CsSignature = {
+ s: nacl.crypto_core_ed25519_scalar_add(csSig.sBlind, secrets.alpha[b]),
+ rPub: rPubDash,
+ };
+ return sig;
+}
+
+/**
+ * Verification algorithm for CS signatures
+ * @param hm message signed
+ * @param csSig unblinded signature
+ * @param csPub denomination public key
+ * @returns true if valid, false if invalid
+ */
+export async function csVerify(
+ hm: Uint8Array,
+ csSig: CsSignature,
+ csPub: Uint8Array,
+): Promise<boolean> {
+ const cDash = csFDH(hm, csSig.rPub, csPub);
+ const sG = nacl.crypto_scalarmult_ed25519_base_noclamp(csSig.s);
+ const cbDp = nacl.crypto_scalarmult_ed25519_noclamp(cDash, csPub);
+ const sGeq = nacl.crypto_core_ed25519_add(csSig.rPub, cbDp);
+ return nacl.verify(sG, sGeq);
+}
+
+export interface EddsaKeyPair {
+ eddsaPub: Uint8Array;
+ eddsaPriv: Uint8Array;
+}
+
+export interface EcdheKeyPair {
+ ecdhePub: Uint8Array;
+ ecdhePriv: Uint8Array;
+}
+
+export interface Edx25519Keypair {
+ edxPub: string;
+ edxPriv: string;
+}
+
+export function createEddsaKeyPair(): EddsaKeyPair {
+ const eddsaPriv = nacl.randomBytes(32);
+ const eddsaPub = eddsaGetPublic(eddsaPriv);
+ return { eddsaPriv, eddsaPub };
+}
+
+export function createEcdheKeyPair(): EcdheKeyPair {
+ const ecdhePriv = nacl.randomBytes(32);
+ const ecdhePub = ecdhGetPublic(ecdhePriv);
+ return { ecdhePriv, ecdhePub };
+}
+
+export function hash(d: Uint8Array): Uint8Array {
+ if (tart) {
+ return tart.hash(d);
+ }
+ return nacl.hash(d);
+}
+
+/**
+ * Hash the input with SHA-512 and truncate the result
+ * to 32 bytes.
+ */
+export function hashTruncate32(d: Uint8Array): Uint8Array {
+ const sha512HashCode = hash(d);
+ return sha512HashCode.subarray(0, 32);
+}
+
+export function hashCoinEv(
+ coinEv: CoinEnvelope,
+ denomPubHash: HashCodeString,
+): Uint8Array {
+ const hashContext = createHashContext();
+ hashContext.update(decodeCrock(denomPubHash));
+ hashCoinEvInner(coinEv, hashContext);
+ return hashContext.finish();
+}
+
+const logger = new Logger("talerCrypto.ts");
+
+export function hashCoinEvInner(
+ coinEv: CoinEnvelope,
+ hashState: TalerHashState,
+): void {
+ const hashInputBuf = new ArrayBuffer(4);
+ const uint8ArrayBuf = new Uint8Array(hashInputBuf);
+ const dv = new DataView(hashInputBuf);
+ dv.setUint32(0, DenomKeyType.toIntTag(coinEv.cipher));
+ hashState.update(uint8ArrayBuf);
+ switch (coinEv.cipher) {
+ case DenomKeyType.Rsa:
+ hashState.update(decodeCrock(coinEv.rsa_blinded_planchet));
+ return;
+ default:
+ throw new Error();
+ }
+}
+
+export function hashCoinPub(
+ coinPub: CoinPublicKeyString,
+ ach?: HashCodeString,
+): Uint8Array {
+ if (!ach) {
+ return hash(decodeCrock(coinPub));
+ }
+
+ return hash(typedArrayConcat([decodeCrock(coinPub), decodeCrock(ach)]));
+}
+
+/**
+ * Hash a denomination public key.
+ */
+export function hashDenomPub(pub: DenominationPubKey): Uint8Array {
+ if (pub.cipher === DenomKeyType.Rsa) {
+ const pubBuf = decodeCrock(pub.rsa_public_key);
+ const hashInputBuf = new ArrayBuffer(pubBuf.length + 4 + 4);
+ const uint8ArrayBuf = new Uint8Array(hashInputBuf);
+ const dv = new DataView(hashInputBuf);
+ dv.setUint32(0, pub.age_mask ?? 0);
+ dv.setUint32(4, DenomKeyType.toIntTag(pub.cipher));
+ uint8ArrayBuf.set(pubBuf, 8);
+ return hash(uint8ArrayBuf);
+ } else if (pub.cipher === DenomKeyType.ClauseSchnorr) {
+ const pubBuf = decodeCrock(pub.cs_public_key);
+ const hashInputBuf = new ArrayBuffer(pubBuf.length + 4 + 4);
+ const uint8ArrayBuf = new Uint8Array(hashInputBuf);
+ const dv = new DataView(hashInputBuf);
+ dv.setUint32(0, pub.age_mask ?? 0);
+ dv.setUint32(4, DenomKeyType.toIntTag(pub.cipher));
+ uint8ArrayBuf.set(pubBuf, 8);
+ return hash(uint8ArrayBuf);
+ } else {
+ throw Error(
+ `unsupported cipher (${
+ (pub as DenominationPubKey).cipher
+ }), unable to hash`,
+ );
+ }
+}
+
+export function eddsaSign(msg: Uint8Array, eddsaPriv: Uint8Array): Uint8Array {
+ if (tart) {
+ return tart.eddsaSign(msg, eddsaPriv);
+ }
+ const pair = nacl.crypto_sign_keyPair_fromSeed(eddsaPriv);
+ return nacl.sign_detached(msg, pair.secretKey);
+}
+
+export function eddsaVerify(
+ msg: Uint8Array,
+ sig: Uint8Array,
+ eddsaPub: Uint8Array,
+): boolean {
+ if (tart) {
+ return tart.eddsaVerify(msg, sig, eddsaPub);
+ }
+ return nacl.sign_detached_verify(msg, sig, eddsaPub);
+}
+
+export interface TalerHashState {
+ update(data: Uint8Array): void;
+ finish(): Uint8Array;
+}
+
+export function createHashContext(): TalerHashState {
+ if (tart) {
+ const t = tart;
+ const st = tart.hashStateInit();
+ return {
+ finish: () => t.hashStateFinish(st),
+ update: (d) => t.hashStateUpdate(st, d),
+ };
+ }
+ return new nacl.HashState();
+}
+
+export interface FreshCoin {
+ coinPub: Uint8Array;
+ coinPriv: Uint8Array;
+ bks: Uint8Array;
+ maxAge: number;
+ ageCommitmentProof: AgeCommitmentProof | undefined;
+}
+
+export function bufferForUint32(n: number): Uint8Array {
+ const arrBuf = new ArrayBuffer(4);
+ const buf = new Uint8Array(arrBuf);
+ const dv = new DataView(arrBuf);
+ dv.setUint32(0, n);
+ return buf;
+}
+
+/**
+ * This makes the assumption that the uint64 fits a float,
+ * which should be true for all Taler protocol messages.
+ */
+export function bufferForUint64(n: number): Uint8Array {
+ const arrBuf = new ArrayBuffer(8);
+ const buf = new Uint8Array(arrBuf);
+ const dv = new DataView(arrBuf);
+ if (n < 0 || !Number.isInteger(n)) {
+ throw Error("non-negative integer expected");
+ }
+ dv.setBigUint64(0, BigInt(n));
+ return buf;
+}
+
+export function bufferForUint8(n: number): Uint8Array {
+ const arrBuf = new ArrayBuffer(1);
+ const buf = new Uint8Array(arrBuf);
+ const dv = new DataView(arrBuf);
+ dv.setUint8(0, n);
+ return buf;
+}
+
+export async function setupTipPlanchet(
+ secretSeed: Uint8Array,
+ denomPub: DenominationPubKey,
+ coinNumber: number,
+): Promise<FreshCoin> {
+ const info = stringToBytes("taler-tip-coin-derivation");
+ const saltArrBuf = new ArrayBuffer(4);
+ const salt = new Uint8Array(saltArrBuf);
+ const saltDataView = new DataView(saltArrBuf);
+ saltDataView.setUint32(0, coinNumber);
+ const out = kdf(64, secretSeed, salt, info);
+ const coinPriv = out.slice(0, 32);
+ const bks = out.slice(32, 64);
+ let maybeAcp: AgeCommitmentProof | undefined;
+ if (denomPub.age_mask != 0) {
+ maybeAcp = await AgeRestriction.restrictionCommitSeeded(
+ denomPub.age_mask,
+ AgeRestriction.AGE_UNRESTRICTED,
+ secretSeed,
+ );
+ }
+ return {
+ bks,
+ coinPriv,
+ coinPub: eddsaGetPublic(coinPriv),
+ maxAge: AgeRestriction.AGE_UNRESTRICTED,
+ ageCommitmentProof: maybeAcp,
+ };
+}
+/**
+ *
+ * @param paytoUri
+ * @param salt 16-byte salt
+ * @returns
+ */
+export function hashWire(paytoUri: string, salt: string): string {
+ const r = kdf(
+ 64,
+ stringToBytes(paytoUri + "\0"),
+ decodeCrock(salt),
+ stringToBytes("merchant-wire-signature"),
+ );
+ return encodeCrock(r);
+}
+
+export enum TalerSignaturePurpose {
+ MERCHANT_TRACK_TRANSACTION = 1103,
+ WALLET_RESERVE_WITHDRAW = 1200,
+ WALLET_COIN_DEPOSIT = 1201,
+ GLOBAL_FEES = 1022,
+ MASTER_DENOMINATION_KEY_VALIDITY = 1025,
+ MASTER_WIRE_FEES = 1028,
+ MASTER_WIRE_DETAILS = 1030,
+ WALLET_COIN_MELT = 1202,
+ TEST = 4242,
+ MERCHANT_PAYMENT_OK = 1104,
+ MERCHANT_CONTRACT = 1101,
+ MERCHANT_REFUND = 1102,
+ WALLET_COIN_RECOUP = 1203,
+ WALLET_COIN_LINK = 1204,
+ WALLET_COIN_RECOUP_REFRESH = 1206,
+ WALLET_AGE_ATTESTATION = 1207,
+ WALLET_PURSE_CREATE = 1210,
+ WALLET_PURSE_DEPOSIT = 1211,
+ WALLET_PURSE_MERGE = 1213,
+ WALLET_ACCOUNT_MERGE = 1214,
+ WALLET_PURSE_ECONTRACT = 1216,
+ WALLET_PURSE_DELETE = 1220,
+ WALLET_COIN_HISTORY = 1209,
+ EXCHANGE_CONFIRM_RECOUP = 1039,
+ EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041,
+ TALER_SIGNATURE_AML_DECISION = 1350,
+ TALER_SIGNATURE_AML_QUERY = 1351,
+ TALER_SIGNATURE_MASTER_AML_KEY = 1017,
+ ANASTASIS_POLICY_UPLOAD = 1400,
+ ANASTASIS_POLICY_DOWNLOAD = 1401,
+ SYNC_BACKUP_UPLOAD = 1450,
+}
+
+export enum WalletAccountMergeFlags {
+ /**
+ * Not a legal mode!
+ */
+ None = 0,
+
+ /**
+ * We are merging a fully paid-up purse into a reserve.
+ */
+ MergeFullyPaidPurse = 1,
+
+ CreateFromPurseQuota = 2,
+
+ CreateWithPurseFee = 3,
+}
+
+export class SignaturePurposeBuilder {
+ private chunks: Uint8Array[] = [];
+
+ constructor(private purposeNum: number) {}
+
+ put(bytes: Uint8Array): SignaturePurposeBuilder {
+ this.chunks.push(Uint8Array.from(bytes));
+ return this;
+ }
+
+ build(): Uint8Array {
+ let payloadLen = 0;
+ for (const c of this.chunks) {
+ payloadLen += c.byteLength;
+ }
+ const buf = new ArrayBuffer(4 + 4 + payloadLen);
+ const u8buf = new Uint8Array(buf);
+ let p = 8;
+ for (const c of this.chunks) {
+ u8buf.set(c, p);
+ p += c.byteLength;
+ }
+ const dvbuf = new DataView(buf);
+ dvbuf.setUint32(0, payloadLen + 4 + 4);
+ dvbuf.setUint32(4, this.purposeNum);
+ return u8buf;
+ }
+}
+
+export function buildSigPS(purposeNum: number): SignaturePurposeBuilder {
+ return new SignaturePurposeBuilder(purposeNum);
+}
+
+export type OpaqueData = Flavor<Uint8Array, any>;
+export type Edx25519PublicKey = FlavorP<Uint8Array, "Edx25519PublicKey", 32>;
+export type Edx25519PrivateKey = FlavorP<Uint8Array, "Edx25519PrivateKey", 64>;
+export type Edx25519Signature = FlavorP<Uint8Array, "Edx25519Signature", 64>;
+
+export type Edx25519PublicKeyEnc = FlavorP<string, "Edx25519PublicKeyEnc", 32>;
+export type Edx25519PrivateKeyEnc = FlavorP<
+ string,
+ "Edx25519PrivateKeyEnc",
+ 64
+>;
+
+/**
+ * Convert a big integer to a fixed-size, little-endian array.
+ */
+export function bigintToNaclArr(
+ x: bigint.BigInteger,
+ size: number,
+): Uint8Array {
+ const byteArr = new Uint8Array(size);
+ const arr = x.toArray(256).value.reverse();
+ byteArr.set(arr, 0);
+ return byteArr;
+}
+
+export function bigintFromNaclArr(arr: Uint8Array): bigint.BigInteger {
+ let rev = new Uint8Array(arr);
+ rev = rev.reverse();
+ return bigint.fromArray(Array.from(rev), 256, false);
+}
+
+export namespace Edx25519 {
+ const revL = [
+ 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2,
+ 0xde, 0xf9, 0xde, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10,
+ ];
+
+ const L = bigint.fromArray(revL.reverse(), 256, false);
+
+ export async function keyCreateFromSeed(
+ seed: OpaqueData,
+ ): Promise<Edx25519PrivateKey> {
+ return nacl.crypto_edx25519_private_key_create_from_seed(seed);
+ }
+
+ export async function keyCreate(): Promise<Edx25519PrivateKey> {
+ return nacl.crypto_edx25519_private_key_create();
+ }
+
+ export async function getPublic(
+ priv: Edx25519PrivateKey,
+ ): Promise<Edx25519PublicKey> {
+ return nacl.crypto_edx25519_get_public(priv);
+ }
+
+ export function sign(
+ msg: OpaqueData,
+ key: Edx25519PrivateKey,
+ ): Promise<Edx25519Signature> {
+ throw Error("not implemented");
+ }
+
+ async function deriveFactor(
+ pub: Edx25519PublicKey,
+ seed: OpaqueData,
+ ): Promise<OpaqueData> {
+ const res = kdfKw({
+ outputLength: 64,
+ salt: seed,
+ ikm: pub,
+ info: stringToBytes("edx25519-derivation"),
+ });
+
+ return res;
+ }
+
+ export async function privateKeyDerive(
+ priv: Edx25519PrivateKey,
+ seed: OpaqueData,
+ ): Promise<Edx25519PrivateKey> {
+ const pub = await getPublic(priv);
+ const privDec = priv;
+ const a = bigintFromNaclArr(privDec.subarray(0, 32));
+ const factorEnc = await deriveFactor(pub, seed);
+ const factorModL = bigintFromNaclArr(factorEnc).mod(L);
+
+ const aPrime = a.divide(8).multiply(factorModL).mod(L).multiply(8).mod(L);
+ const bPrime = nacl
+ .hash(typedArrayConcat([privDec.subarray(32, 64), factorEnc]))
+ .subarray(0, 32);
+
+ const newPriv = typedArrayConcat([bigintToNaclArr(aPrime, 32), bPrime]);
+
+ return newPriv;
+ }
+
+ export async function publicKeyDerive(
+ pub: Edx25519PublicKey,
+ seed: OpaqueData,
+ ): Promise<Edx25519PublicKey> {
+ const factorEnc = await deriveFactor(pub, seed);
+ const factorReduced = nacl.crypto_core_ed25519_scalar_reduce(factorEnc);
+ const res = nacl.crypto_scalarmult_ed25519_noclamp(factorReduced, pub);
+ return res;
+ }
+}
+
+export interface AgeCommitment {
+ mask: number;
+
+ /**
+ * Public keys, one for each age group specified in the age mask.
+ */
+ publicKeys: Edx25519PublicKeyEnc[];
+}
+
+export interface AgeProof {
+ /**
+ * Private keys. Typically smaller than the number of public keys,
+ * because we drop private keys from age groups that are restricted.
+ */
+ privateKeys: Edx25519PrivateKeyEnc[];
+}
+
+export interface AgeCommitmentProof {
+ commitment: AgeCommitment;
+ proof: AgeProof;
+}
+
+function invariant(cond: boolean): asserts cond {
+ if (!cond) {
+ throw Error("invariant failed");
+ }
+}
+
+export namespace AgeRestriction {
+ /**
+ * Smallest age value that the protocol considers "unrestricted".
+ */
+ export const AGE_UNRESTRICTED = 32;
+
+ export function hashCommitment(ac: AgeCommitment): HashCodeString {
+ const hc = new nacl.HashState();
+ for (const pub of ac.publicKeys) {
+ hc.update(decodeCrock(pub));
+ }
+ return encodeCrock(hc.finish().subarray(0, 32));
+ }
+
+ export function countAgeGroups(mask: number): number {
+ let count = 0;
+ let m = mask;
+ while (m > 0) {
+ count += m & 1;
+ m = m >> 1;
+ }
+ return count;
+ }
+
+ /**
+ * Get the starting points for age groups in the mask.
+ */
+ export function getAgeGroupsFromMask(mask: number): number[] {
+ const groups: number[] = [];
+ let age = 1;
+ let m = mask >> 1;
+ while (m > 0) {
+ if (m & 1) {
+ groups.push(age);
+ }
+ m = m >> 1;
+ age++;
+ }
+ return groups;
+ }
+
+ export function getAgeGroupIndex(mask: number, age: number): number {
+ invariant((mask & 1) === 1);
+ let i = 0;
+ let m = mask;
+ let a = age;
+ while (m > 0) {
+ if (a <= 0) {
+ break;
+ }
+ m = m >> 1;
+ i += m & 1;
+ a--;
+ }
+ return i;
+ }
+
+ export function ageGroupSpecToMask(ageGroupSpec: string): number {
+ throw Error("not implemented");
+ }
+
+ export async function restrictionCommit(
+ ageMask: number,
+ age: number,
+ ): Promise<AgeCommitmentProof> {
+ invariant((ageMask & 1) === 1);
+ const numPubs = countAgeGroups(ageMask) - 1;
+ const numPrivs = getAgeGroupIndex(ageMask, age);
+
+ const pubs: Edx25519PublicKey[] = [];
+ const privs: Edx25519PrivateKey[] = [];
+
+ for (let i = 0; i < numPubs; i++) {
+ const priv = await Edx25519.keyCreate();
+ const pub = await Edx25519.getPublic(priv);
+ pubs.push(pub);
+ if (i < numPrivs) {
+ privs.push(priv);
+ }
+ }
+
+ return {
+ commitment: {
+ mask: ageMask,
+ publicKeys: pubs.map((x) => encodeCrock(x)),
+ },
+ proof: {
+ privateKeys: privs.map((x) => encodeCrock(x)),
+ },
+ };
+ }
+
+ const PublishedAgeRestrictionBaseKey: Edx25519PublicKey = decodeCrock(
+ "CH0VKFDZ2GWRWHQBBGEK9MWV5YDQVJ0RXEE0KYT3NMB69F0R96TG",
+ );
+
+ export async function restrictionCommitSeeded(
+ ageMask: number,
+ age: number,
+ seed: Uint8Array,
+ ): Promise<AgeCommitmentProof> {
+ invariant((ageMask & 1) === 1);
+ const numPubs = countAgeGroups(ageMask) - 1;
+ const numPrivs = getAgeGroupIndex(ageMask, age);
+
+ const pubs: Edx25519PublicKey[] = [];
+ const privs: Edx25519PrivateKey[] = [];
+
+ for (let i = 0; i < numPrivs; i++) {
+ const privSeed = await kdfKw({
+ outputLength: 32,
+ ikm: seed,
+ info: stringToBytes("age-commitment"),
+ salt: bufferForUint32(i),
+ });
+
+ const priv = await Edx25519.keyCreateFromSeed(privSeed);
+ const pub = await Edx25519.getPublic(priv);
+ pubs.push(pub);
+ privs.push(priv);
+ }
+
+ for (let i = numPrivs; i < numPubs; i++) {
+ const deriveSeed = await kdfKw({
+ outputLength: 32,
+ ikm: seed,
+ info: stringToBytes("age-factor"),
+ salt: bufferForUint32(i),
+ });
+ const pub = await Edx25519.publicKeyDerive(
+ PublishedAgeRestrictionBaseKey,
+ deriveSeed,
+ );
+ pubs.push(pub);
+ }
+
+ return {
+ commitment: {
+ mask: ageMask,
+ publicKeys: pubs.map((x) => encodeCrock(x)),
+ },
+ proof: {
+ privateKeys: privs.map((x) => encodeCrock(x)),
+ },
+ };
+ }
+
+ /**
+ * Check that c1 = c2*salt
+ */
+ export async function commitCompare(
+ c1: AgeCommitment,
+ c2: AgeCommitment,
+ salt: OpaqueData,
+ ): Promise<boolean> {
+ if (c1.publicKeys.length != c2.publicKeys.length) {
+ return false;
+ }
+ for (let i = 0; i < c1.publicKeys.length; i++) {
+ const k1 = decodeCrock(c1.publicKeys[i]);
+ const k2 = await Edx25519.publicKeyDerive(
+ decodeCrock(c2.publicKeys[i]),
+ salt,
+ );
+ if (k1 != k2) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ export async function commitmentDerive(
+ commitmentProof: AgeCommitmentProof,
+ salt: OpaqueData,
+ ): Promise<AgeCommitmentProof> {
+ const newPrivs: Edx25519PrivateKey[] = [];
+ const newPubs: Edx25519PublicKey[] = [];
+
+ for (const oldPub of commitmentProof.commitment.publicKeys) {
+ newPubs.push(await Edx25519.publicKeyDerive(decodeCrock(oldPub), salt));
+ }
+
+ for (const oldPriv of commitmentProof.proof.privateKeys) {
+ newPrivs.push(
+ await Edx25519.privateKeyDerive(decodeCrock(oldPriv), salt),
+ );
+ }
+
+ return {
+ commitment: {
+ mask: commitmentProof.commitment.mask,
+ publicKeys: newPubs.map((x) => encodeCrock(x)),
+ },
+ proof: {
+ privateKeys: newPrivs.map((x) => encodeCrock(x)),
+ },
+ };
+ }
+
+ export function commitmentAttest(
+ commitmentProof: AgeCommitmentProof,
+ age: number,
+ ): Edx25519Signature {
+ const d = buildSigPS(TalerSignaturePurpose.WALLET_AGE_ATTESTATION)
+ .put(bufferForUint32(commitmentProof.commitment.mask))
+ .put(bufferForUint32(age))
+ .build();
+ const group = getAgeGroupIndex(commitmentProof.commitment.mask, age);
+ if (group === 0) {
+ // No attestation required.
+ return new Uint8Array(64);
+ }
+ const priv = commitmentProof.proof.privateKeys[group - 1];
+ const pub = commitmentProof.commitment.publicKeys[group - 1];
+ const sig = nacl.crypto_edx25519_sign_detached(
+ d,
+ decodeCrock(priv),
+ decodeCrock(pub),
+ );
+ return sig;
+ }
+
+ export function commitmentVerify(
+ commitment: AgeCommitment,
+ sig: string,
+ age: number,
+ ): boolean {
+ const d = buildSigPS(TalerSignaturePurpose.WALLET_AGE_ATTESTATION)
+ .put(bufferForUint32(commitment.mask))
+ .put(bufferForUint32(age))
+ .build();
+ const group = getAgeGroupIndex(commitment.mask, age);
+ if (group === 0) {
+ // No attestation required.
+ return true;
+ }
+ const pub = commitment.publicKeys[group - 1];
+ return nacl.crypto_edx25519_sign_detached_verify(
+ d,
+ decodeCrock(sig),
+ decodeCrock(pub),
+ );
+ }
+}
+
+// FIXME: make it a branded type!
+export type EncryptionNonce = FlavorP<Uint8Array, "EncryptionNonce", 24>;
+
+async function deriveKey(
+ keySeed: OpaqueData,
+ nonce: EncryptionNonce,
+ salt: string,
+): Promise<Uint8Array> {
+ return kdfKw({
+ outputLength: 32,
+ salt: nonce,
+ ikm: keySeed,
+ info: stringToBytes(salt),
+ });
+}
+
+export async function encryptWithDerivedKey(
+ nonce: EncryptionNonce,
+ keySeed: OpaqueData,
+ plaintext: OpaqueData,
+ salt: string,
+): Promise<OpaqueData> {
+ const key = await deriveKey(keySeed, nonce, salt);
+ const cipherText = secretbox(plaintext, nonce, key);
+ return typedArrayConcat([nonce, cipherText]);
+}
+
+const nonceSize = 24;
+
+export async function decryptWithDerivedKey(
+ ciphertext: OpaqueData,
+ keySeed: OpaqueData,
+ salt: string,
+): Promise<OpaqueData> {
+ const ctBuf = ciphertext;
+ const nonceBuf = ctBuf.slice(0, nonceSize);
+ const enc = ctBuf.slice(nonceSize);
+ const key = await deriveKey(keySeed, nonceBuf, salt);
+ const clearText = nacl.secretbox_open(enc, nonceBuf, key);
+ if (!clearText) {
+ throw Error("could not decrypt");
+ }
+ return clearText;
+}
+
+enum ContractFormatTag {
+ PaymentOffer = 0,
+ PaymentRequest = 1,
+}
+
+type MaterialEddsaPub = {
+ _materialType?: "eddsa-pub";
+ _size?: 32;
+};
+
+type MaterialEddsaPriv = {
+ _materialType?: "ecdhe-priv";
+ _size?: 32;
+};
+
+type MaterialEcdhePub = {
+ _materialType?: "ecdhe-pub";
+ _size?: 32;
+};
+
+type MaterialEcdhePriv = {
+ _materialType?: "ecdhe-priv";
+ _size?: 32;
+};
+
+type PursePublicKey = FlavorP<Uint8Array, "PursePublicKey", 32> &
+ MaterialEddsaPub;
+
+type ContractPrivateKey = FlavorP<Uint8Array, "ContractPrivateKey", 32> &
+ MaterialEcdhePriv;
+
+type MergePrivateKey = FlavorP<Uint8Array, "MergePrivateKey", 32> &
+ MaterialEddsaPriv;
+
+const mergeSalt = "p2p-merge-contract";
+const depositSalt = "p2p-deposit-contract";
+
+export function encryptContractForMerge(
+ pursePub: PursePublicKey,
+ contractPriv: ContractPrivateKey,
+ mergePriv: MergePrivateKey,
+ contractTerms: any,
+ nonce: EncryptionNonce,
+): Promise<OpaqueData> {
+ const contractTermsCanon = canonicalJson(contractTerms) + "\0";
+ const contractTermsBytes = stringToBytes(contractTermsCanon);
+ const contractTermsCompressed = fflate.zlibSync(contractTermsBytes);
+ const data = typedArrayConcat([
+ bufferForUint32(ContractFormatTag.PaymentOffer),
+ bufferForUint32(contractTermsBytes.length),
+ mergePriv,
+ contractTermsCompressed,
+ ]);
+ const key = keyExchangeEcdhEddsa(contractPriv, pursePub);
+ return encryptWithDerivedKey(nonce, key, data, mergeSalt);
+}
+
+export function encryptContractForDeposit(
+ pursePub: PursePublicKey,
+ contractPriv: ContractPrivateKey,
+ contractTerms: any,
+ nonce: EncryptionNonce,
+): Promise<OpaqueData> {
+ const contractTermsCanon = canonicalJson(contractTerms) + "\0";
+ const contractTermsBytes = stringToBytes(contractTermsCanon);
+ const contractTermsCompressed = fflate.zlibSync(contractTermsBytes);
+ const data = typedArrayConcat([
+ bufferForUint32(ContractFormatTag.PaymentRequest),
+ bufferForUint32(contractTermsBytes.length),
+ contractTermsCompressed,
+ ]);
+ const key = keyExchangeEcdhEddsa(contractPriv, pursePub);
+ return encryptWithDerivedKey(nonce, key, data, depositSalt);
+}
+
+export interface DecryptForMergeResult {
+ contractTerms: any;
+ mergePriv: Uint8Array;
+}
+
+export interface DecryptForDepositResult {
+ contractTerms: any;
+}
+
+export async function decryptContractForMerge(
+ enc: OpaqueData,
+ pursePub: PursePublicKey,
+ contractPriv: ContractPrivateKey,
+): Promise<DecryptForMergeResult> {
+ const key = keyExchangeEcdhEddsa(contractPriv, pursePub);
+ const dec = await decryptWithDerivedKey(enc, key, mergeSalt);
+ const mergePriv = dec.slice(8, 8 + 32);
+ const contractTermsCompressed = dec.slice(8 + 32);
+ const contractTermsBuf = fflate.unzlibSync(contractTermsCompressed);
+ // Slice of the '\0' at the end and decode to a string
+ const contractTermsString = bytesToString(
+ contractTermsBuf.slice(0, contractTermsBuf.length - 1),
+ );
+ return {
+ mergePriv: mergePriv,
+ contractTerms: JSON.parse(contractTermsString),
+ };
+}
+
+export async function decryptContractForDeposit(
+ enc: OpaqueData,
+ pursePub: PursePublicKey,
+ contractPriv: ContractPrivateKey,
+): Promise<DecryptForDepositResult> {
+ const key = keyExchangeEcdhEddsa(contractPriv, pursePub);
+ const dec = await decryptWithDerivedKey(enc, key, depositSalt);
+ const contractTermsCompressed = dec.slice(8);
+ const contractTermsBuf = fflate.unzlibSync(contractTermsCompressed);
+ // Slice of the '\0' at the end and decode to a string
+ const contractTermsString = bytesToString(
+ contractTermsBuf.slice(0, contractTermsBuf.length - 1),
+ );
+ return {
+ contractTerms: JSON.parse(contractTermsString),
+ };
+}
+
+export function amountToBuffer(amount: AmountLike): Uint8Array {
+ const amountJ = Amounts.jsonifyAmount(amount);
+ const buffer = new ArrayBuffer(8 + 4 + 12);
+ const dvbuf = new DataView(buffer);
+ const u8buf = new Uint8Array(buffer);
+ const curr = stringToBytes(amountJ.currency);
+ if (typeof dvbuf.setBigUint64 !== "undefined") {
+ dvbuf.setBigUint64(0, BigInt(amountJ.value));
+ } else {
+ const arr = bigint(amountJ.value).toArray(2 ** 8).value;
+ let offset = 8 - arr.length;
+ for (let i = 0; i < arr.length; i++) {
+ dvbuf.setUint8(offset++, arr[i]);
+ }
+ }
+ dvbuf.setUint32(8, amountJ.fraction);
+ u8buf.set(curr, 8 + 4);
+
+ return u8buf;
+}
+
+export function timestampRoundedToBuffer(
+ ts: TalerProtocolTimestamp,
+): Uint8Array {
+ const b = new ArrayBuffer(8);
+ const v = new DataView(b);
+ // The buffer we sign over represents the timestamp in microseconds.
+ if (typeof v.setBigUint64 !== "undefined") {
+ const s = BigInt(ts.t_s) * BigInt(1000 * 1000);
+ v.setBigUint64(0, s);
+ } else {
+ const s =
+ ts.t_s === "never" ? bigint.zero : bigint(ts.t_s).multiply(1000 * 1000);
+ const arr = s.toArray(2 ** 8).value;
+ let offset = 8 - arr.length;
+ for (let i = 0; i < arr.length; i++) {
+ v.setUint8(offset++, arr[i]);
+ }
+ }
+ return new Uint8Array(b);
+}
+
+export function durationRoundedToBuffer(ts: TalerProtocolDuration): Uint8Array {
+ const b = new ArrayBuffer(8);
+ const v = new DataView(b);
+ // The buffer we sign over represents the timestamp in microseconds.
+ if (typeof v.setBigUint64 !== "undefined") {
+ const s = BigInt(ts.d_us);
+ v.setBigUint64(0, s);
+ } else {
+ const s = ts.d_us === "forever" ? bigint.zero : bigint(ts.d_us);
+ const arr = s.toArray(2 ** 8).value;
+ let offset = 8 - arr.length;
+ for (let i = 0; i < arr.length; i++) {
+ v.setUint8(offset++, arr[i]);
+ }
+ }
+ return new Uint8Array(b);
+}
diff --git a/packages/taler-util/src/taler-error-codes.ts b/packages/taler-util/src/taler-error-codes.ts
index fec2cf0ff..c3c008a1c 100644
--- a/packages/taler-util/src/taler-error-codes.ts
+++ b/packages/taler-util/src/taler-error-codes.ts
@@ -22,6 +22,8 @@
*/
export enum TalerErrorCode {
+
+
/**
* Special code to indicate success (no error).
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -29,195 +31,343 @@ export enum TalerErrorCode {
*/
NONE = 0,
+
/**
- * A non-integer error code was returned in the JSON response.
+ * An error response did not include an error code in the format expected by the client. Most likely, the server does not speak the GNU Taler protocol. Check the URL and/or the network connection to the server.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
INVALID = 1,
+
/**
- * The response we got from the server was not even in JSON format.
+ * An internal failure happened on the client side. Details should be in the local logs. Check if you are using the latest available version or file a report with the developers.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_CLIENT_INTERNAL_ERROR = 2,
+
+
+ /**
+ * The response we got from the server was not in the expected format. Most likely, the server does not speak the GNU Taler protocol. Check the URL and/or the network connection to the server.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_INVALID_RESPONSE = 10,
+
/**
- * An operation timed out.
+ * The operation timed out. Trying again might help. Check the network connection.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_TIMEOUT = 11,
+
/**
- * The version string given does not follow the expected CURRENT:REVISION:AGE Format.
+ * The protocol version given by the server does not follow the required format. Most likely, the server does not speak the GNU Taler protocol. Check the URL and/or the network connection to the server.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_VERSION_MALFORMED = 12,
+
/**
- * The service responded with a reply that was in JSON but did not satsify the protocol. Note that invalid cryptographic signatures should have signature-specific error codes.
+ * The service responded with a reply that was in the right data format, but the content did not satisfy the protocol. Please file a bug report.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_REPLY_MALFORMED = 13,
+
/**
- * There is an error in the client-side configuration, for example the base URL specified is malformed.
+ * There is an error in the client-side configuration, for example an option is set to an invalid value. Check the logs and fix the local configuration.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_CONFIGURATION_INVALID = 14,
+
/**
- * The HTTP method used is invalid for this endpoint.
+ * The client made a request to a service, but received an error response it does not know how to handle. Please file a bug report.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_UNEXPECTED_REQUEST_ERROR = 15,
+
+
+ /**
+ * The token used by the client to authorize the request does not grant the required permissions for the request. Check the requirements and obtain a suitable authorization token to proceed.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_TOKEN_PERMISSION_INSUFFICIENT = 16,
+
+
+ /**
+ * The HTTP method used is invalid for this endpoint. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_METHOD_NOT_ALLOWED (405).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_METHOD_INVALID = 20,
+
/**
- * There is no endpoint defined for the URL provided by the client.
+ * There is no endpoint defined for the URL provided by the client. Check if you used the correct URL and/or file a report with the developers of the client software.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_ENDPOINT_UNKNOWN = 21,
+
/**
- * The JSON in the client's request was malformed (generic parse error).
+ * The JSON in the client's request was malformed. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_JSON_INVALID = 22,
+
/**
- * The payto:// URI provided by the client is malformed.
+ * Some of the HTTP headers provided by the client were malformed and caused the server to not be able to handle the request. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_HTTP_HEADERS_MALFORMED = 23,
+
+
+ /**
+ * The payto:// URI provided by the client is malformed. Check that you are using the correct syntax as of RFC 8905 and/or that you entered the bank account number correctly.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_PAYTO_URI_MALFORMED = 24,
+
/**
- * A required parameter in the request was missing.
+ * A required parameter in the request was missing. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_PARAMETER_MISSING = 25,
+
/**
- * A parameter in the request was malformed.
+ * A parameter in the request was malformed. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_PARAMETER_MALFORMED = 26,
+
+ /**
+ * The reserve public key was malformed.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_RESERVE_PUB_MALFORMED = 27,
+
+
/**
- * The currencies involved in the operation do not match.
+ * The body in the request could not be decompressed by the server. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_COMPRESSION_INVALID = 28,
+
+
+ /**
+ * The currency involved in the operation is not acceptable for this server. Check your configuration and make sure the currency specified for a given service provider is one of the currencies supported by that provider.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_CURRENCY_MISMATCH = 30,
+
/**
- * The URI is longer than the longest URI the HTTP server is willing to parse.
+ * The URI is longer than the longest URI the HTTP server is willing to parse. If you believe this was a legitimate request, contact the server administrators and/or the software developers to increase the limit.
* Returned with an HTTP status code of #MHD_HTTP_URI_TOO_LONG (414).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_URI_TOO_LONG = 31,
+
/**
- * The body is too large to be permissible for the endpoint.
- * Returned with an HTTP status code of #MHD_HTTP_PAYLOAD_TOO_LARGE (413).
+ * The body is too large to be permissible for the endpoint. If you believe this was a legitimate request, contact the server administrators and/or the software developers to increase the limit.
+ * Returned with an HTTP status code of #MHD_HTTP_CONTENT_TOO_LARGE (413).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_UPLOAD_EXCEEDS_LIMIT = 32,
+
+ /**
+ * The service refused the request due to lack of proper authorization.
+ * Returned with an HTTP status code of #MHD_HTTP_UNAUTHORIZED (401).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_UNAUTHORIZED = 40,
+
+
/**
- * The service failed initialize its connection to the database.
+ * The service refused the request as the given authorization token is unknown.
+ * Returned with an HTTP status code of #MHD_HTTP_UNAUTHORIZED (401).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_TOKEN_UNKNOWN = 41,
+
+
+ /**
+ * The service refused the request as the given authorization token expired.
+ * Returned with an HTTP status code of #MHD_HTTP_UNAUTHORIZED (401).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_TOKEN_EXPIRED = 42,
+
+
+ /**
+ * The service refused the request as the given authorization token is malformed.
+ * Returned with an HTTP status code of #MHD_HTTP_UNAUTHORIZED (401).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_TOKEN_MALFORMED = 43,
+
+
+ /**
+ * The service refused the request due to lack of proper rights on the resource.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_FORBIDDEN = 44,
+
+
+ /**
+ * The service failed initialize its connection to the database. The system administrator should check that the service has permissions to access the database and that the database is running.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_DB_SETUP_FAILED = 50,
+
/**
- * The service encountered an error event to just start the database transaction.
+ * The service encountered an error event to just start the database transaction. The system administrator should check that the database is running.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_DB_START_FAILED = 51,
+
/**
- * The service failed to store information in its database.
+ * The service failed to store information in its database. The system administrator should check that the database is running and review the service logs.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_DB_STORE_FAILED = 52,
+
/**
- * The service failed to fetch information from its database.
+ * The service failed to fetch information from its database. The system administrator should check that the database is running and review the service logs.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_DB_FETCH_FAILED = 53,
+
/**
- * The service encountered an error event to commit the database transaction (hard, unrecoverable error).
+ * The service encountered an unrecoverable error trying to commit a transaction to the database. The system administrator should check that the database is running and review the service logs.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_DB_COMMIT_FAILED = 54,
+
/**
- * The service encountered an error event to commit the database transaction, even after repeatedly retrying it there was always a conflicting transaction. (This indicates a repeated serialization error; should only happen if some client maliciously tries to create conflicting concurrent transactions.)
+ * The service encountered an error event to commit the database transaction, even after repeatedly retrying it there was always a conflicting transaction. This indicates a repeated serialization error; it should only happen if some client maliciously tries to create conflicting concurrent transactions. It could also be a sign of a missing index. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_DB_SOFT_FAILURE = 55,
+
/**
- * The service's database is inconsistent and violates service-internal invariants.
+ * The service's database is inconsistent and violates service-internal invariants. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_DB_INVARIANT_FAILURE = 56,
+
/**
- * The HTTP server experienced an internal invariant failure (bug).
+ * The HTTP server experienced an internal invariant failure (bug). Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_INTERNAL_INVARIANT_FAILURE = 60,
+
/**
- * The service could not compute a cryptographic hash over some JSON value.
+ * The service could not compute a cryptographic hash over some JSON value. Check if you are using the latest available version and/or file a report with the developers.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_FAILED_COMPUTE_JSON_HASH = 61,
+
/**
- * The HTTP server had insufficient memory to parse the request.
+ * The service could not compute an amount. Check if you are using the latest available version and/or file a report with the developers.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_FAILED_COMPUTE_AMOUNT = 62,
+
+
+ /**
+ * The HTTP server had insufficient memory to parse the request. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_PARSER_OUT_OF_MEMORY = 70,
+
/**
- * The HTTP server failed to allocate memory.
+ * The HTTP server failed to allocate memory. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_ALLOCATION_FAILURE = 71,
+
/**
- * The HTTP server failed to allocate memory for building JSON reply.
+ * The HTTP server failed to allocate memory for building JSON reply. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
GENERIC_JSON_ALLOCATION_FAILURE = 72,
+
+ /**
+ * The HTTP server failed to allocate memory for making a CURL request. Restarting services periodically can help, especially if Postgres is using excessive amounts of memory. Check with the system administrator to investigate.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_CURL_ALLOCATION_FAILURE = 73,
+
+
+ /**
+ * The backend could not locate a required template to generate an HTML reply. The system administrator should check if the resource files are installed in the correct location and are readable to the service.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_FAILED_TO_LOAD_TEMPLATE = 74,
+
+
+ /**
+ * The backend could not expand the template to generate an HTML reply. The system administrator should investigate the logs and check if the templates are well-formed.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ GENERIC_FAILED_TO_EXPAND_TEMPLATE = 75,
+
+
/**
* Exchange is badly configured and thus cannot operate.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -225,6 +375,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_GENERIC_BAD_CONFIGURATION = 1000,
+
/**
* Operation specified unknown for this endpoint.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -232,6 +383,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_GENERIC_OPERATION_UNKNOWN = 1001,
+
/**
* The number of segments included in the URI does not match the number of segments expected by the endpoint.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -239,6 +391,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_GENERIC_WRONG_NUMBER_OF_SEGMENTS = 1002,
+
/**
* The same coin was already used with a different denomination previously.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -246,6 +399,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_GENERIC_COIN_CONFLICTING_DENOMINATION_KEY = 1003,
+
/**
* The public key of given to a "/coins/" endpoint of the exchange was malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -253,6 +407,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_GENERIC_COINS_INVALID_COIN_PUB = 1004,
+
/**
* The exchange is not aware of the denomination key the wallet requested for the operation.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -260,6 +415,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN = 1005,
+
/**
* The signature of the denomination key over the coin is not valid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -267,13 +423,15 @@ export enum TalerErrorCode {
*/
EXCHANGE_DENOMINATION_SIGNATURE_INVALID = 1006,
+
/**
* The exchange failed to perform the operation as it could not find the private keys. This is a problem with the exchange setup, not with the client's request.
- * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * Returned with an HTTP status code of #MHD_HTTP_SERVICE_UNAVAILABLE (503).
* (A value of 0 indicates that the error is generated client-side).
*/
EXCHANGE_GENERIC_KEYS_MISSING = 1007,
+
/**
* Validity period of the denomination lies in the future.
* Returned with an HTTP status code of #MHD_HTTP_PRECONDITION_FAILED (412).
@@ -281,6 +439,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_GENERIC_DENOMINATION_VALIDITY_IN_FUTURE = 1008,
+
/**
* Denomination key of the coin is past its expiration time for the requested operation.
* Returned with an HTTP status code of #MHD_HTTP_GONE (410).
@@ -288,6 +447,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_GENERIC_DENOMINATION_EXPIRED = 1009,
+
/**
* Denomination key of the coin has been revoked.
* Returned with an HTTP status code of #MHD_HTTP_GONE (410).
@@ -295,6 +455,223 @@ export enum TalerErrorCode {
*/
EXCHANGE_GENERIC_DENOMINATION_REVOKED = 1010,
+
+ /**
+ * An operation where the exchange interacted with a security module timed out.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_SECMOD_TIMEOUT = 1011,
+
+
+ /**
+ * The respective coin did not have sufficient residual value for the operation. The "history" in this response provides the "residual_value" of the coin, which may be less than its "original_value".
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_INSUFFICIENT_FUNDS = 1012,
+
+
+ /**
+ * The exchange had an internal error reconstructing the transaction history of the coin that was being processed.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_COIN_HISTORY_COMPUTATION_FAILED = 1013,
+
+
+ /**
+ * The exchange failed to obtain the transaction history of the given coin from the database while generating an insufficient funds errors.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_HISTORY_DB_ERROR_INSUFFICIENT_FUNDS = 1014,
+
+
+ /**
+ * The same coin was already used with a different age hash previously.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_COIN_CONFLICTING_AGE_HASH = 1015,
+
+
+ /**
+ * The requested operation is not valid for the cipher used by the selected denomination.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_INVALID_DENOMINATION_CIPHER_FOR_OPERATION = 1016,
+
+
+ /**
+ * The provided arguments for the operation use inconsistent ciphers.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_CIPHER_MISMATCH = 1017,
+
+
+ /**
+ * The number of denominations specified in the request exceeds the limit of the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_NEW_DENOMS_ARRAY_SIZE_EXCESSIVE = 1018,
+
+
+ /**
+ * The coin is not known to the exchange (yet).
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_COIN_UNKNOWN = 1019,
+
+
+ /**
+ * The time at the server is too far off from the time specified in the request. Most likely the client system time is wrong.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_CLOCK_SKEW = 1020,
+
+
+ /**
+ * The specified amount for the coin is higher than the value of the denomination of the coin.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_AMOUNT_EXCEEDS_DENOMINATION_VALUE = 1021,
+
+
+ /**
+ * The exchange was not properly configured with global fees.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_GLOBAL_FEES_MISSING = 1022,
+
+
+ /**
+ * The exchange was not properly configured with wire fees.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_WIRE_FEES_MISSING = 1023,
+
+
+ /**
+ * The purse public key was malformed.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_PURSE_PUB_MALFORMED = 1024,
+
+
+ /**
+ * The purse is unknown.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_PURSE_UNKNOWN = 1025,
+
+
+ /**
+ * The purse has expired.
+ * Returned with an HTTP status code of #MHD_HTTP_GONE (410).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_PURSE_EXPIRED = 1026,
+
+
+ /**
+ * The exchange has no information about the "reserve_pub" that was given.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_RESERVE_UNKNOWN = 1027,
+
+
+ /**
+ * The exchange is not allowed to proceed with the operation until the client has satisfied a KYC check.
+ * Returned with an HTTP status code of #MHD_HTTP_UNAVAILABLE_FOR_LEGAL_REASONS (451).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_KYC_REQUIRED = 1028,
+
+
+ /**
+ * Inconsistency between provided age commitment and attest: either none or both must be provided
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_DEPOSIT_COIN_CONFLICTING_ATTEST_VS_AGE_COMMITMENT = 1029,
+
+
+ /**
+ * The provided attestation for the minimum age couldn't be verified by the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_DEPOSIT_COIN_AGE_ATTESTATION_FAILURE = 1030,
+
+
+ /**
+ * The purse was deleted.
+ * Returned with an HTTP status code of #MHD_HTTP_GONE (410).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_PURSE_DELETED = 1031,
+
+
+ /**
+ * The public key of the AML officer in the URL was malformed.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_AML_OFFICER_PUB_MALFORMED = 1032,
+
+
+ /**
+ * The signature affirming the GET request of the AML officer is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_AML_OFFICER_GET_SIGNATURE_INVALID = 1033,
+
+
+ /**
+ * The specified AML officer does not have access at this time.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_AML_OFFICER_ACCESS_DENIED = 1034,
+
+
+ /**
+ * The requested operation is denied pending the resolution of an anti-money laundering investigation by the exchange operator. This is a manual process, please wait and retry later.
+ * Returned with an HTTP status code of #MHD_HTTP_UNAVAILABLE_FOR_LEGAL_REASONS (451).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_AML_PENDING = 1035,
+
+
+ /**
+ * The requested operation is denied as the account was frozen on suspicion of money laundering. Please contact the exchange operator.
+ * Returned with an HTTP status code of #MHD_HTTP_UNAVAILABLE_FOR_LEGAL_REASONS (451).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_AML_FROZEN = 1036,
+
+
+ /**
+ * The exchange failed to start a KYC attribute conversion helper process. It is likely configured incorrectly.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GENERIC_KYC_CONVERTER_FAILED = 1037,
+
+
/**
* The exchange did not find information about the specified transaction in the database.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -302,6 +679,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSITS_GET_NOT_FOUND = 1100,
+
/**
* The wire hash of given to a "/deposits/" handler was malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -309,6 +687,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSITS_GET_INVALID_H_WIRE = 1101,
+
/**
* The merchant key of given to a "/deposits/" handler was malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -316,6 +695,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSITS_GET_INVALID_MERCHANT_PUB = 1102,
+
/**
* The hash of the contract terms given to a "/deposits/" handler was malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -323,6 +703,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSITS_GET_INVALID_H_CONTRACT_TERMS = 1103,
+
/**
* The coin public key of given to a "/deposits/" handler was malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -330,6 +711,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSITS_GET_INVALID_COIN_PUB = 1104,
+
/**
* The signature returned by the exchange in a /deposits/ request was malformed.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -337,6 +719,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSITS_GET_INVALID_SIGNATURE_BY_EXCHANGE = 1105,
+
/**
* The signature of the merchant is invalid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -344,6 +727,15 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSITS_GET_MERCHANT_SIGNATURE_INVALID = 1106,
+
+ /**
+ * The provided policy data was not accepted
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_DEPOSITS_POLICY_NOT_ACCEPTED = 1107,
+
+
/**
* The given reserve does not have sufficient funds to admit the requested withdraw operation at this time. The response includes the current "balance" of the reserve as well as the transaction "history" that lead to this balance.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -351,12 +743,14 @@ export enum TalerErrorCode {
*/
EXCHANGE_WITHDRAW_INSUFFICIENT_FUNDS = 1150,
+
/**
- * The exchange has no information about the "reserve_pub" that was given.
- * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * The given reserve does not have sufficient funds to admit the requested age-withdraw operation at this time. The response includes the current "balance" of the reserve as well as the transaction "history" that lead to this balance.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
- EXCHANGE_WITHDRAW_RESERVE_UNKNOWN = 1151,
+ EXCHANGE_AGE_WITHDRAW_INSUFFICIENT_FUNDS = 1151,
+
/**
* The amount to withdraw together with the fee exceeds the numeric range for Taler amounts. This is not a client failure, as the coin value and fees come from the exchange's configuration.
@@ -365,6 +759,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_WITHDRAW_AMOUNT_FEE_OVERFLOW = 1152,
+
/**
* The exchange failed to create the signature using the denomination key.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -372,6 +767,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_WITHDRAW_SIGNATURE_FAILED = 1153,
+
/**
* The signature of the reserve is not valid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -379,12 +775,22 @@ export enum TalerErrorCode {
*/
EXCHANGE_WITHDRAW_RESERVE_SIGNATURE_INVALID = 1154,
+
/**
* When computing the reserve history, we ended up with a negative overall balance, which should be impossible.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
- EXCHANGE_WITHDRAW_HISTORY_ERROR_INSUFFICIENT_FUNDS = 1155,
+ EXCHANGE_RESERVE_HISTORY_ERROR_INSUFFICIENT_FUNDS = 1155,
+
+
+ /**
+ * The reserve did not have sufficient funds in it to pay for a full reserve history statement.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_GET_RESERVE_HISTORY_ERROR_INSUFFICIENT_BALANCE = 1156,
+
/**
* Withdraw period of the coin to be withdrawn is in the past.
@@ -393,6 +799,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_WITHDRAW_DENOMINATION_KEY_LOST = 1158,
+
/**
* The client failed to unblind the blind signature.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -400,12 +807,62 @@ export enum TalerErrorCode {
*/
EXCHANGE_WITHDRAW_UNBLIND_FAILURE = 1159,
+
+ /**
+ * The client re-used a withdraw nonce, which is not allowed.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_WITHDRAW_NONCE_REUSE = 1160,
+
+
+ /**
+ * The client provided an unknown commitment for an age-withdraw request.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_AGE_WITHDRAW_COMMITMENT_UNKNOWN = 1161,
+
+
+ /**
+ * The total sum of amounts from the denominations did overflow.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_AGE_WITHDRAW_AMOUNT_OVERFLOW = 1162,
+
+
+ /**
+ * The total sum of value and fees from the denominations differs from the committed amount with fees.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_AGE_WITHDRAW_AMOUNT_INCORRECT = 1163,
+
+
+ /**
+ * The original commitment differs from the calculated hash
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_AGE_WITHDRAW_REVEAL_INVALID_HASH = 1164,
+
+
+ /**
+ * The maximum age in the commitment is too large for the reserve
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_AGE_WITHDRAW_MAXIMUM_AGE_TOO_LARGE = 1165,
+
+
/**
- * The respective coin did not have sufficient residual value for the /deposit operation (i.e. due to double spending). The "history" in the response provides the transaction history of the coin proving this fact.
+ * The batch withdraw included a planchet that was already withdrawn. This is not allowed.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
- EXCHANGE_DEPOSIT_INSUFFICIENT_FUNDS = 1200,
+ EXCHANGE_WITHDRAW_BATCH_IDEMPOTENT_PLANCHET = 1175,
+
/**
* The signature made by the coin over the deposit permission is not valid.
@@ -414,6 +871,15 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSIT_COIN_SIGNATURE_INVALID = 1205,
+
+ /**
+ * The same coin was already deposited for the same merchant and contract with other details.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_DEPOSIT_CONFLICTING_CONTRACT = 1206,
+
+
/**
* The stated value of the coin after the deposit fee is subtracted would be negative.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -421,6 +887,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSIT_NEGATIVE_VALUE_AFTER_FEE = 1207,
+
/**
* The stated refund deadline is after the wire deadline.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -428,6 +895,15 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSIT_REFUND_DEADLINE_AFTER_WIRE_DEADLINE = 1208,
+
+ /**
+ * The stated wire deadline is "never", which makes no sense.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_DEPOSIT_WIRE_DEADLINE_IS_NEVER = 1209,
+
+
/**
* The exchange failed to canonicalize and hash the given wire format. For example, the merchant failed to provide the "salt" or a valid payto:// URI in the wire details. Note that while the exchange will do some basic sanity checking on the wire details, it cannot warrant that the banking system will ultimately be able to route to the specified address, even if this check passed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -435,6 +911,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSIT_INVALID_WIRE_FORMAT_JSON = 1210,
+
/**
* The hash of the given wire address does not match the wire hash specified in the proposal data.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -442,6 +919,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSIT_INVALID_WIRE_FORMAT_CONTRACT_HASH_CONFLICT = 1211,
+
/**
* The signature provided by the exchange is not valid.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -449,26 +927,38 @@ export enum TalerErrorCode {
*/
EXCHANGE_DEPOSIT_INVALID_SIGNATURE_BY_EXCHANGE = 1221,
+
/**
- * The reserve status was requested using a unknown key.
- * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * The deposited amount is smaller than the deposit fee, which would result in a negative contribution.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
- EXCHANGE_RESERVES_GET_STATUS_UNKNOWN = 1250,
+ EXCHANGE_DEPOSIT_FEE_ABOVE_AMOUNT = 1222,
+
/**
- * The respective coin did not have sufficient residual value for the /refresh/melt operation. The "history" in this response provdes the "residual_value" of the coin, which may be less than its "original_value".
- * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * The proof of policy fulfillment was invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
- EXCHANGE_MELT_INSUFFICIENT_FUNDS = 1300,
+ EXCHANGE_EXTENSIONS_INVALID_FULFILLMENT = 1240,
+
/**
- * The exchange had an internal error reconstructing the transaction history of the coin that was being melted.
- * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * The coin history was requested with a bad signature.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_COIN_HISTORY_BAD_SIGNATURE = 1251,
+
+
+ /**
+ * The reserve history was requested with a bad signature.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
* (A value of 0 indicates that the error is generated client-side).
*/
- EXCHANGE_MELT_COIN_HISTORY_COMPUTATION_FAILED = 1301,
+ EXCHANGE_RESERVE_HISTORY_BAD_SIGNATURE = 1252,
+
/**
* The exchange encountered melt fees exceeding the melted coin's contribution.
@@ -477,6 +967,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MELT_FEES_EXCEED_CONTRIBUTION = 1302,
+
/**
* The signature made with the coin to be melted is invalid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -484,12 +975,6 @@ export enum TalerErrorCode {
*/
EXCHANGE_MELT_COIN_SIGNATURE_INVALID = 1303,
- /**
- * The exchange failed to obtain the transaction history of the given coin from the database while generating an insufficient funds errors.
- * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
- * (A value of 0 indicates that the error is generated client-side).
- */
- EXCHANGE_MELT_HISTORY_DB_ERROR_INSUFFICIENT_FUNDS = 1304,
/**
* The denomination of the given coin has past its expiration date and it is also not a valid zombie (that is, was not refreshed with the fresh coin being subjected to recoup).
@@ -498,6 +983,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MELT_COIN_EXPIRED_NO_ZOMBIE = 1305,
+
/**
* The signature returned by the exchange in a melt request was malformed.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -505,6 +991,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MELT_INVALID_SIGNATURE_BY_EXCHANGE = 1306,
+
/**
* The provided transfer keys do not match up with the original commitment. Information about the original commitment is included in the response.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -512,6 +999,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFRESHES_REVEAL_COMMITMENT_VIOLATION = 1353,
+
/**
* Failed to produce the blinded signatures over the coins to be returned.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -519,6 +1007,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFRESHES_REVEAL_SIGNING_ERROR = 1354,
+
/**
* The exchange is unaware of the refresh session specified in the request.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -526,6 +1015,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFRESHES_REVEAL_SESSION_UNKNOWN = 1355,
+
/**
* The size of the cut-and-choose dimension of the private transfer keys request does not match #TALER_CNC_KAPPA - 1.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -533,12 +1023,6 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFRESHES_REVEAL_CNC_TRANSFER_ARRAY_SIZE_INVALID = 1356,
- /**
- * The number of coins to be created in refresh exceeds the limits of the exchange. private transfer keys request does not match #TALER_CNC_KAPPA - 1.
- * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
- * (A value of 0 indicates that the error is generated client-side).
- */
- EXCHANGE_REFRESHES_REVEAL_NEW_DENOMS_ARRAY_SIZE_EXCESSIVE = 1357,
/**
* The number of envelopes given does not match the number of denomination keys given.
@@ -547,6 +1031,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFRESHES_REVEAL_NEW_DENOMS_ARRAY_SIZE_MISMATCH = 1358,
+
/**
* The exchange encountered a numeric overflow totaling up the cost for the refresh operation.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -554,6 +1039,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFRESHES_REVEAL_COST_CALCULATION_OVERFLOW = 1359,
+
/**
* The exchange's cost calculation shows that the melt amount is below the costs of the transaction.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -561,6 +1047,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFRESHES_REVEAL_AMOUNT_INSUFFICIENT = 1360,
+
/**
* The signature made with the coin over the link data is invalid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -568,6 +1055,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFRESHES_REVEAL_LINK_SIGNATURE_INVALID = 1361,
+
/**
* The refresh session hash given to a /refreshes/ handler was malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -575,6 +1063,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFRESHES_REVEAL_INVALID_RCH = 1362,
+
/**
* Operation specified invalid for this endpoint.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -582,6 +1071,23 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFRESHES_REVEAL_OPERATION_INVALID = 1363,
+
+ /**
+ * The client provided age commitment data, but age restriction is not supported on this server.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_REFRESHES_REVEAL_AGE_RESTRICTION_NOT_SUPPORTED = 1364,
+
+
+ /**
+ * The client provided invalid age commitment data: missing, not an array, or array of invalid size.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_REFRESHES_REVEAL_AGE_RESTRICTION_COMMITMENT_INVALID = 1365,
+
+
/**
* The coin specified in the link request is unknown to the exchange.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -589,6 +1095,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_LINK_COIN_UNKNOWN = 1400,
+
/**
* The public key of given to a /transfers/ handler was malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -596,6 +1103,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_TRANSFERS_GET_WTID_MALFORMED = 1450,
+
/**
* The exchange did not find information about the specified wire transfer identifier in the database.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -603,6 +1111,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_TRANSFERS_GET_WTID_NOT_FOUND = 1451,
+
/**
* The exchange did not find information about the wire transfer fees it charged.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -610,6 +1119,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_TRANSFERS_GET_WIRE_FEE_NOT_FOUND = 1452,
+
/**
* The exchange found a wire fee that was above the total transfer value (and thus could not have been charged).
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -617,6 +1127,23 @@ export enum TalerErrorCode {
*/
EXCHANGE_TRANSFERS_GET_WIRE_FEE_INCONSISTENT = 1453,
+
+ /**
+ * The wait target of the URL was not in the set of expected values.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSES_INVALID_WAIT_TARGET = 1475,
+
+
+ /**
+ * The signature on the purse status returned by the exchange was invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSES_GET_INVALID_SIGNATURE_BY_EXCHANGE = 1476,
+
+
/**
* The exchange knows literally nothing about the coin we were asked to refund. But without a transaction history, we cannot issue a refund. This is kind-of OK, the owner should just refresh it directly without executing the refund.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -624,6 +1151,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_COIN_NOT_FOUND = 1500,
+
/**
* We could not process the refund request as the coin's transaction history does not permit the requested refund because then refunds would exceed the deposit amount. The "history" in the response proves this.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -631,6 +1159,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_CONFLICT_DEPOSIT_INSUFFICIENT = 1501,
+
/**
* The exchange knows about the coin we were asked to refund, but not about the specific /deposit operation. Hence, we cannot issue a refund (as we do not know if this merchant public key is authorized to do a refund).
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -638,6 +1167,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_DEPOSIT_NOT_FOUND = 1502,
+
/**
* The exchange can no longer refund the customer/coin as the money was already transferred (paid out) to the merchant. (It should be past the refund deadline.)
* Returned with an HTTP status code of #MHD_HTTP_GONE (410).
@@ -645,6 +1175,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_MERCHANT_ALREADY_PAID = 1503,
+
/**
* The refund fee specified for the request is lower than the refund fee charged by the exchange for the given denomination key of the refunded coin.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -652,6 +1183,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_FEE_TOO_LOW = 1504,
+
/**
* The refunded amount is smaller than the refund fee, which would result in a negative refund.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -659,6 +1191,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_FEE_ABOVE_AMOUNT = 1505,
+
/**
* The signature of the merchant is invalid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -666,6 +1199,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_MERCHANT_SIGNATURE_INVALID = 1506,
+
/**
* Merchant backend failed to create the refund confirmation signature.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -673,6 +1207,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_MERCHANT_SIGNING_FAILED = 1507,
+
/**
* The signature returned by the exchange in a refund request was malformed.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -680,6 +1215,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_INVALID_SIGNATURE_BY_EXCHANGE = 1508,
+
/**
* The failure proof returned by the exchange is incorrect.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -687,6 +1223,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_INVALID_FAILURE_PROOF_BY_EXCHANGE = 1509,
+
/**
* Conflicting refund granted before with different amount but same refund transaction ID.
* Returned with an HTTP status code of #MHD_HTTP_FAILED_DEPENDENCY (424).
@@ -694,6 +1231,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_REFUND_INCONSISTENT_AMOUNT = 1510,
+
/**
* The given coin signature is invalid for the request.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -701,6 +1239,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_RECOUP_SIGNATURE_INVALID = 1550,
+
/**
* The exchange could not find the corresponding withdraw operation. The request is denied.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -708,6 +1247,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_RECOUP_WITHDRAW_NOT_FOUND = 1551,
+
/**
* The coin's remaining balance is zero. The request is denied.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -715,6 +1255,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_RECOUP_COIN_BALANCE_ZERO = 1552,
+
/**
* The exchange failed to reproduce the coin's blinding.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -722,6 +1263,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_RECOUP_BLINDING_FAILED = 1553,
+
/**
* The coin's remaining balance is zero. The request is denied.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -729,6 +1271,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_RECOUP_COIN_BALANCE_NEGATIVE = 1554,
+
/**
* The coin's denomination has not been revoked yet.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -736,6 +1279,39 @@ export enum TalerErrorCode {
*/
EXCHANGE_RECOUP_NOT_ELIGIBLE = 1555,
+
+ /**
+ * The given coin signature is invalid for the request.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RECOUP_REFRESH_SIGNATURE_INVALID = 1575,
+
+
+ /**
+ * The exchange could not find the corresponding melt operation. The request is denied.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RECOUP_REFRESH_MELT_NOT_FOUND = 1576,
+
+
+ /**
+ * The exchange failed to reproduce the coin's blinding.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RECOUP_REFRESH_BLINDING_FAILED = 1578,
+
+
+ /**
+ * The coin's denomination has not been revoked yet.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RECOUP_REFRESH_NOT_ELIGIBLE = 1580,
+
+
/**
* This exchange does not allow clients to request /keys for times other than the current (exchange) time.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -743,6 +1319,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_KEYS_TIMETRAVEL_FORBIDDEN = 1600,
+
/**
* A signature in the server's response was malformed.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -750,13 +1327,95 @@ export enum TalerErrorCode {
*/
EXCHANGE_WIRE_SIGNATURE_INVALID = 1650,
+
+ /**
+ * No bank accounts are enabled for the exchange. The administrator should enable-account using the taler-exchange-offline tool.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_WIRE_NO_ACCOUNTS_CONFIGURED = 1651,
+
+
+ /**
+ * The payto:// URI stored in the exchange database for its bank account is malformed.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_WIRE_INVALID_PAYTO_CONFIGURED = 1652,
+
+
/**
- * The exchange failed to talk to the process responsible for its private denomination keys.
+ * No wire fees are configured for an enabled wire method of the exchange. The administrator must set the wire-fee using the taler-exchange-offline tool.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
+ EXCHANGE_WIRE_FEES_NOT_CONFIGURED = 1653,
+
+
+ /**
+ * This purse was previously created with different meta data.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_PURSE_CREATE_CONFLICTING_META_DATA = 1675,
+
+
+ /**
+ * This purse was previously merged with different meta data.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_PURSE_MERGE_CONFLICTING_META_DATA = 1676,
+
+
+ /**
+ * The reserve has insufficient funds to create another purse.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_PURSE_CREATE_INSUFFICIENT_FUNDS = 1677,
+
+
+ /**
+ * The purse fee specified for the request is lower than the purse fee charged by the exchange at this time.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_PURSE_FEE_TOO_LOW = 1678,
+
+
+ /**
+ * The payment request cannot be deleted anymore, as it either already completed or timed out.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_DELETE_ALREADY_DECIDED = 1679,
+
+
+ /**
+ * The signature affirming the purse deletion is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_DELETE_SIGNATURE_INVALID = 1680,
+
+
+ /**
+ * Withdrawal from the reserve requires age restriction to be set.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_AGE_RESTRICTION_REQUIRED = 1681,
+
+
+ /**
+ * The exchange failed to talk to the process responsible for its private denomination keys or the helpers had no denominations (properly) configured.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
EXCHANGE_DENOMINATION_HELPER_UNAVAILABLE = 1700,
+
/**
* The response from the denomination key helper process was malformed.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -764,6 +1423,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_DENOMINATION_HELPER_BUG = 1701,
+
/**
* The helper refuses to sign with the key, because it is too early: the validity period has not yet started.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -771,13 +1431,23 @@ export enum TalerErrorCode {
*/
EXCHANGE_DENOMINATION_HELPER_TOO_EARLY = 1702,
+
+ /**
+ * The signature of the exchange on the reply was invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_DEPOSIT_EXCHANGE_SIGNATURE_INVALID = 1725,
+
+
/**
* The exchange failed to talk to the process responsible for its private signing keys.
- * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
* (A value of 0 indicates that the error is generated client-side).
*/
EXCHANGE_SIGNKEY_HELPER_UNAVAILABLE = 1750,
+
/**
* The response from the online signing key helper process was malformed.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -785,6 +1455,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_SIGNKEY_HELPER_BUG = 1751,
+
/**
* The helper refuses to sign with the key, because it is too early: the validity period has not yet started.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -792,6 +1463,79 @@ export enum TalerErrorCode {
*/
EXCHANGE_SIGNKEY_HELPER_TOO_EARLY = 1752,
+
+ /**
+ * The purse expiration time is in the past at the time of its creation.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_PURSE_EXPIRATION_BEFORE_NOW = 1775,
+
+
+ /**
+ * The purse expiration time is set to never, which is not allowed.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_PURSE_EXPIRATION_IS_NEVER = 1776,
+
+
+ /**
+ * The signature affirming the merge of the purse is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_PURSE_MERGE_SIGNATURE_INVALID = 1777,
+
+
+ /**
+ * The signature by the reserve affirming the merge is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_RESERVE_MERGE_SIGNATURE_INVALID = 1778,
+
+
+ /**
+ * The signature by the reserve affirming the open operation is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_OPEN_BAD_SIGNATURE = 1785,
+
+
+ /**
+ * The signature by the reserve affirming the close operation is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_CLOSE_BAD_SIGNATURE = 1786,
+
+
+ /**
+ * The signature by the reserve affirming the attestion request is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_ATTEST_BAD_SIGNATURE = 1787,
+
+
+ /**
+ * The exchange does not know an origin account to which the remaining reserve balance could be wired to, and the wallet failed to provide one.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_CLOSE_NO_TARGET_ACCOUNT = 1788,
+
+
+ /**
+ * The reserve balance is insufficient to pay for the open operation.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_RESERVES_OPEN_INSUFFICIENT_FUNDS = 1789,
+
+
/**
* The auditor that was supposed to be disabled is unknown to this exchange.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -799,6 +1543,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_AUDITOR_NOT_FOUND = 1800,
+
/**
* The exchange has a more recently signed conflicting instruction and is thus refusing the current change (replay detected).
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -806,6 +1551,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_AUDITOR_MORE_RECENT_PRESENT = 1801,
+
/**
* The signature to add or enable the auditor does not validate.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -813,6 +1559,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_AUDITOR_ADD_SIGNATURE_INVALID = 1802,
+
/**
* The signature to disable the auditor does not validate.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -820,6 +1567,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_AUDITOR_DEL_SIGNATURE_INVALID = 1803,
+
/**
* The signature to revoke the denomination does not validate.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -827,6 +1575,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_DENOMINATION_REVOKE_SIGNATURE_INVALID = 1804,
+
/**
* The signature to revoke the online signing key does not validate.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -834,6 +1583,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_SIGNKEY_REVOKE_SIGNATURE_INVALID = 1805,
+
/**
* The exchange has a more recently signed conflicting instruction and is thus refusing the current change (replay detected).
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -841,6 +1591,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_WIRE_MORE_RECENT_PRESENT = 1806,
+
/**
* The signingkey specified is unknown to the exchange.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -848,6 +1599,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_KEYS_SIGNKEY_UNKNOWN = 1807,
+
/**
* The signature to publish wire account does not validate.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -855,6 +1607,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_WIRE_DETAILS_SIGNATURE_INVALID = 1808,
+
/**
* The signature to add the wire account does not validate.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -862,6 +1615,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_WIRE_ADD_SIGNATURE_INVALID = 1809,
+
/**
* The signature to disable the wire account does not validate.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -869,6 +1623,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_WIRE_DEL_SIGNATURE_INVALID = 1810,
+
/**
* The wire account to be disabled is unknown to the exchange.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -876,6 +1631,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_WIRE_NOT_FOUND = 1811,
+
/**
* The signature to affirm wire fees does not validate.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -883,6 +1639,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_WIRE_FEE_SIGNATURE_INVALID = 1812,
+
/**
* The signature conflicts with a previous signature affirming different fees.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -890,6 +1647,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_WIRE_FEE_MISMATCH = 1813,
+
/**
* The signature affirming the denomination key is invalid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -897,6 +1655,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_KEYS_DENOMKEY_ADD_SIGNATURE_INVALID = 1814,
+
/**
* The signature affirming the signing key is invalid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -904,6 +1663,223 @@ export enum TalerErrorCode {
*/
EXCHANGE_MANAGEMENT_KEYS_SIGNKEY_ADD_SIGNATURE_INVALID = 1815,
+
+ /**
+ * The signature conflicts with a previous signature affirming different fees.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_MANAGEMENT_GLOBAL_FEE_MISMATCH = 1816,
+
+
+ /**
+ * The signature affirming the fee structure is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_MANAGEMENT_GLOBAL_FEE_SIGNATURE_INVALID = 1817,
+
+
+ /**
+ * The signature affirming the profit drain is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_MANAGEMENT_DRAIN_PROFITS_SIGNATURE_INVALID = 1818,
+
+
+ /**
+ * The signature affirming the AML decision is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_AML_DECISION_ADD_SIGNATURE_INVALID = 1825,
+
+
+ /**
+ * The AML officer specified is not allowed to make AML decisions right now.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_AML_DECISION_INVALID_OFFICER = 1826,
+
+
+ /**
+ * There is a more recent AML decision on file. The decision was rejected as timestamps of AML decisions must be monotonically increasing.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_AML_DECISION_MORE_RECENT_PRESENT = 1827,
+
+
+ /**
+ * There AML decision would impose an AML check of a type that is not provided by any KYC provider known to the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_AML_DECISION_UNKNOWN_CHECK = 1828,
+
+
+ /**
+ * The signature affirming the change in the AML officer status is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_MANAGEMENT_UPDATE_AML_OFFICER_SIGNATURE_INVALID = 1830,
+
+
+ /**
+ * A more recent decision about the AML officer status is known to the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_MANAGEMENT_AML_OFFICERS_MORE_RECENT_PRESENT = 1831,
+
+
+ /**
+ * The purse was previously created with different meta data.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_CREATE_CONFLICTING_META_DATA = 1850,
+
+
+ /**
+ * The purse was previously created with a different contract.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_CREATE_CONFLICTING_CONTRACT_STORED = 1851,
+
+
+ /**
+ * A coin signature for a deposit into the purse is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_CREATE_COIN_SIGNATURE_INVALID = 1852,
+
+
+ /**
+ * The purse expiration time is in the past.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_CREATE_EXPIRATION_BEFORE_NOW = 1853,
+
+
+ /**
+ * The purse expiration time is "never".
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_CREATE_EXPIRATION_IS_NEVER = 1854,
+
+
+ /**
+ * The purse signature over the purse meta data is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_CREATE_SIGNATURE_INVALID = 1855,
+
+
+ /**
+ * The signature over the encrypted contract is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_ECONTRACT_SIGNATURE_INVALID = 1856,
+
+
+ /**
+ * The signature from the exchange over the confirmation is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_CREATE_EXCHANGE_SIGNATURE_INVALID = 1857,
+
+
+ /**
+ * The coin was previously deposited with different meta data.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_DEPOSIT_CONFLICTING_META_DATA = 1858,
+
+
+ /**
+ * The encrypted contract was previously uploaded with different meta data.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_ECONTRACT_CONFLICTING_META_DATA = 1859,
+
+
+ /**
+ * The deposited amount is less than the purse fee.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_CREATE_PURSE_NEGATIVE_VALUE_AFTER_FEE = 1860,
+
+
+ /**
+ * The signature using the merge key is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_MERGE_INVALID_MERGE_SIGNATURE = 1876,
+
+
+ /**
+ * The signature using the reserve key is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_MERGE_INVALID_RESERVE_SIGNATURE = 1877,
+
+
+ /**
+ * The targeted purse is not yet full and thus cannot be merged. Retrying the request later may succeed.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_NOT_FULL = 1878,
+
+
+ /**
+ * The signature from the exchange over the confirmation is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_MERGE_EXCHANGE_SIGNATURE_INVALID = 1879,
+
+
+ /**
+ * The exchange of the target account is not a partner of this exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_MERGE_PURSE_PARTNER_UNKNOWN = 1880,
+
+
+ /**
+ * The signature affirming the new partner is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_MANAGEMENT_ADD_PARTNER_SIGNATURE_INVALID = 1890,
+
+
+ /**
+ * Conflicting data for the partner already exists with the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_MANAGEMENT_ADD_PARTNER_DATA_CONFLICT = 1891,
+
+
/**
* The auditor signature over the denomination meta data is invalid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -911,6 +1887,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_AUDITORS_AUDITOR_SIGNATURE_INVALID = 1900,
+
/**
* The auditor that was specified is unknown to this exchange.
* Returned with an HTTP status code of #MHD_HTTP_PRECONDITION_FAILED (412).
@@ -918,6 +1895,7 @@ export enum TalerErrorCode {
*/
EXCHANGE_AUDITORS_AUDITOR_UNKNOWN = 1901,
+
/**
* The auditor that was specified is no longer used by this exchange.
* Returned with an HTTP status code of #MHD_HTTP_GONE (410).
@@ -925,40 +1903,206 @@ export enum TalerErrorCode {
*/
EXCHANGE_AUDITORS_AUDITOR_INACTIVE = 1902,
+
/**
- * The backend could not find the merchant instance specified in the request.
+ * The signature affirming the wallet's KYC request was invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_KYC_WALLET_SIGNATURE_INVALID = 1925,
+
+
+ /**
+ * The exchange received an unexpected malformed response from its KYC backend.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_KYC_PROOF_BACKEND_INVALID_RESPONSE = 1926,
+
+
+ /**
+ * The backend signaled an unexpected failure.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_KYC_PROOF_BACKEND_ERROR = 1927,
+
+
+ /**
+ * The backend signaled an authorization failure.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_KYC_PROOF_BACKEND_AUTHORIZATION_FAILED = 1928,
+
+
+ /**
+ * The exchange is unaware of having made an the authorization request.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_GENERIC_INSTANCE_UNKNOWN = 2000,
+ EXCHANGE_KYC_PROOF_REQUEST_UNKNOWN = 1929,
+
/**
- * The start and end-times in the wire fee structure leave a hole. This is not allowed.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * The payto-URI hash did not match. Hence the request was denied.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_GENERIC_HOLE_IN_WIRE_FEE_STRUCTURE = 2001,
+ EXCHANGE_KYC_CHECK_AUTHORIZATION_FAILED = 1930,
+
/**
- * The reserve key of given to a /reserves/ handler was malformed.
- * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * The request used a logic specifier that is not known to the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_GENERIC_RESERVE_PUB_MALFORMED = 2002,
+ EXCHANGE_KYC_GENERIC_LOGIC_UNKNOWN = 1931,
+
/**
- * The backend could not locate a required template to generate an HTML reply.
- * Returned with an HTTP status code of #MHD_HTTP_NOT_ACCEPTABLE (406).
+ * The request requires a logic which is no longer configured at the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_GENERIC_FAILED_TO_LOAD_TEMPLATE = 2003,
+ EXCHANGE_KYC_GENERIC_LOGIC_GONE = 1932,
+
/**
- * The backend could not expand the template to generate an HTML reply.
+ * The logic plugin had a bug in its interaction with the KYC provider.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_GENERIC_FAILED_TO_EXPAND_TEMPLATE = 2004,
+ EXCHANGE_KYC_GENERIC_LOGIC_BUG = 1933,
+
+
+ /**
+ * The exchange could not process the request with its KYC provider because the provider refused access to the service. This indicates some configuration issue at the Taler exchange operator.
+ * Returned with an HTTP status code of #MHD_HTTP_NETWORK_AUTHENTICATION_REQUIRED (511).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_KYC_GENERIC_PROVIDER_ACCESS_REFUSED = 1934,
+
+
+ /**
+ * There was a timeout in the interaction between the exchange and the KYC provider. The most likely cause is some networking problem. Trying again later might succeed.
+ * Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_KYC_GENERIC_PROVIDER_TIMEOUT = 1935,
+
+
+ /**
+ * The KYC provider responded with a status that was completely unexpected by the KYC logic of the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_KYC_GENERIC_PROVIDER_UNEXPECTED_REPLY = 1936,
+
+
+ /**
+ * The rate limit of the exchange at the KYC provider has been exceeded. Trying much later might work.
+ * Returned with an HTTP status code of #MHD_HTTP_SERVICE_UNAVAILABLE (503).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_KYC_GENERIC_PROVIDER_RATE_LIMIT_EXCEEDED = 1937,
+
+
+ /**
+ * The request to the webhook lacked proper authorization or authentication data.
+ * Returned with an HTTP status code of #MHD_HTTP_UNAUTHORIZED (401).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_KYC_WEBHOOK_UNAUTHORIZED = 1938,
+
+
+ /**
+ * The exchange does not know a contract under the given contract public key.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_CONTRACTS_UNKNOWN = 1950,
+
+
+ /**
+ * The URL does not encode a valid exchange public key in its path.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_CONTRACTS_INVALID_CONTRACT_PUB = 1951,
+
+
+ /**
+ * The returned encrypted contract did not decrypt.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_CONTRACTS_DECRYPTION_FAILED = 1952,
+
+
+ /**
+ * The signature on the encrypted contract did not validate.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_CONTRACTS_SIGNATURE_INVALID = 1953,
+
+
+ /**
+ * The decrypted contract was malformed.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_CONTRACTS_DECODING_FAILED = 1954,
+
+
+ /**
+ * A coin signature for a deposit into the purse is invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_DEPOSIT_COIN_SIGNATURE_INVALID = 1975,
+
+
+ /**
+ * It is too late to deposit coins into the purse.
+ * Returned with an HTTP status code of #MHD_HTTP_GONE (410).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_PURSE_DEPOSIT_DECIDED_ALREADY = 1976,
+
+
+ /**
+ * TOTP key is not valid.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ EXCHANGE_TOTP_KEY_INVALID = 1980,
+
+
+ /**
+ * The backend could not find the merchant instance specified in the request.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_INSTANCE_UNKNOWN = 2000,
+
+
+ /**
+ * The start and end-times in the wire fee structure leave a hole. This is not allowed.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_HOLE_IN_WIRE_FEE_STRUCTURE = 2001,
+
+
+ /**
+ * The merchant was unable to obtain a valid answer to /wire from the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_EXCHANGE_WIRE_REQUEST_FAILED = 2002,
+
/**
* The proposal is not known to the backend.
@@ -967,6 +2111,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_ORDER_UNKNOWN = 2005,
+
/**
* The order provided to the backend could not be completed, because a product to be completed via inventory data is not actually in our inventory.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -974,12 +2119,14 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_PRODUCT_UNKNOWN = 2006,
+
/**
- * The tip ID is unknown. This could happen if the tip has expired.
+ * The reward ID is unknown. This could happen if the reward has expired.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_GENERIC_TIP_ID_UNKNOWN = 2007,
+ MERCHANT_GENERIC_REWARD_ID_UNKNOWN = 2007,
+
/**
* The contract obtained from the merchant backend was malformed.
@@ -988,6 +2135,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID = 2008,
+
/**
* The order we found does not match the provided contract hash.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -995,6 +2143,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_CONTRACT_HASH_DOES_NOT_MATCH_ORDER = 2009,
+
/**
* The exchange failed to provide a valid response to the merchant's /keys request.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
@@ -1002,6 +2151,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_EXCHANGE_KEYS_FAILURE = 2010,
+
/**
* The exchange failed to respond to the merchant on time.
* Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504).
@@ -1009,6 +2159,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_EXCHANGE_TIMEOUT = 2011,
+
/**
* The merchant failed to talk to the exchange.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1016,6 +2167,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_EXCHANGE_CONNECT_FAILURE = 2012,
+
/**
* The exchange returned a maformed response.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
@@ -1023,6 +2175,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_EXCHANGE_REPLY_MALFORMED = 2013,
+
/**
* The exchange returned an unexpected response status.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
@@ -1030,6 +2183,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_EXCHANGE_UNEXPECTED_STATUS = 2014,
+
/**
* The merchant refused the request due to lack of authorization.
* Returned with an HTTP status code of #MHD_HTTP_UNAUTHORIZED (401).
@@ -1037,6 +2191,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_UNAUTHORIZED = 2015,
+
/**
* The merchant instance specified in the request was deleted.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -1044,6 +2199,71 @@ export enum TalerErrorCode {
*/
MERCHANT_GENERIC_INSTANCE_DELETED = 2016,
+
+ /**
+ * The backend could not find the inbound wire transfer specified in the request.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_TRANSFER_UNKNOWN = 2017,
+
+
+ /**
+ * The backend could not find the template(id) because it is not exist.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_TEMPLATE_UNKNOWN = 2018,
+
+
+ /**
+ * The backend could not find the webhook(id) because it is not exist.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_WEBHOOK_UNKNOWN = 2019,
+
+
+ /**
+ * The backend could not find the webhook(serial) because it is not exist.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_PENDING_WEBHOOK_UNKNOWN = 2020,
+
+
+ /**
+ * The backend could not find the OTP device(id) because it is not exist.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_OTP_DEVICE_UNKNOWN = 2021,
+
+
+ /**
+ * The account is not known to the backend.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_ACCOUNT_UNKNOWN = 2022,
+
+
+ /**
+ * The wire hash was malformed.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_H_WIRE_MALFORMED = 2023,
+
+
+ /**
+ * The currency specified in the operation does not work with the current state of the given resource.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GENERIC_CURRENCY_MISMATCH = 2024,
+
+
/**
* The exchange failed to provide a valid answer to the tracking request, thus those details are not in the response.
* Returned with an HTTP status code of #MHD_HTTP_OK (200).
@@ -1051,6 +2271,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GET_ORDERS_EXCHANGE_TRACKING_FAILURE = 2100,
+
/**
* The merchant backend failed to construct the request for tracking to the exchange, thus tracking details are not in the response.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1058,6 +2279,7 @@ export enum TalerErrorCode {
*/
MERCHANT_GET_ORDERS_ID_EXCHANGE_REQUEST_FAILURE = 2103,
+
/**
* The merchant backend failed trying to contact the exchange for tracking details, thus those details are not in the response.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1065,13 +2287,23 @@ export enum TalerErrorCode {
*/
MERCHANT_GET_ORDERS_ID_EXCHANGE_LOOKUP_START_FAILURE = 2104,
+
/**
- * The token used to authenticate the client is invalid for this order.
+ * The claim token used to authenticate the client is invalid for this order.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_GET_ORDERS_ID_INVALID_TOKEN = 2105,
+
+ /**
+ * The contract terms hash used to authenticate the client is invalid for this order.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_GET_ORDERS_ID_INVALID_CONTRACT_HASH = 2106,
+
+
/**
* The exchange responded saying that funds were insufficient (for example, due to double-spending).
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1079,6 +2311,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS = 2150,
+
/**
* The denomination key used for payment is not listed among the denomination keys of the exchange.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1086,6 +2319,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND = 2151,
+
/**
* The denomination key used for payment is not audited by an auditor approved by the merchant.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1093,6 +2327,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_AUDITOR_FAILURE = 2152,
+
/**
* There was an integer overflow totaling up the amounts or deposit fees in the payment.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1100,6 +2335,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_AMOUNT_OVERFLOW = 2153,
+
/**
* The deposit fees exceed the total value of the payment.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1107,20 +2343,23 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_FEES_EXCEED_PAYMENT = 2154,
+
/**
* After considering deposit and wire fees, the payment is insufficient to satisfy the required amount for the contract. The client should revisit the logic used to calculate fees it must cover.
- * Returned with an HTTP status code of #MHD_HTTP_NOT_ACCEPTABLE (406).
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_DUE_TO_FEES = 2155,
+
/**
* Even if we do not consider deposit and wire fees, the payment is insufficient to satisfy the required amount for the contract.
- * Returned with an HTTP status code of #MHD_HTTP_NOT_ACCEPTABLE (406).
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_POST_ORDERS_ID_PAY_PAYMENT_INSUFFICIENT = 2156,
+
/**
* The signature over the contract of one of the coins was invalid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -1128,6 +2367,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_COIN_SIGNATURE_INVALID = 2157,
+
/**
* When we tried to find information about the exchange to issue the deposit, we failed. This usually only happens if the merchant backend is somehow unable to get its own HTTP client logic to work.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1135,6 +2375,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_LOOKUP_FAILED = 2158,
+
/**
* The refund deadline in the contract is after the transfer deadline.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1142,6 +2383,15 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_REFUND_DEADLINE_PAST_WIRE_TRANSFER_DEADLINE = 2159,
+
+ /**
+ * The order was already paid (maybe by another wallet).
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_ALREADY_PAID = 2160,
+
+
/**
* The payment is too late, the offer has expired.
* Returned with an HTTP status code of #MHD_HTTP_GONE (410).
@@ -1149,6 +2399,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_OFFER_EXPIRED = 2161,
+
/**
* The "merchant" field is missing in the proposal data. This is an internal error as the proposal is from the merchant's own database at this point.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1156,6 +2407,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_MERCHANT_FIELD_MISSING = 2162,
+
/**
* Failed to locate merchant's account information matching the wire hash given in the proposal.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1163,6 +2415,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_WIRE_HASH_UNKNOWN = 2163,
+
/**
* The deposit time for the denomination has expired.
* Returned with an HTTP status code of #MHD_HTTP_GONE (410).
@@ -1170,6 +2423,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_DEPOSIT_EXPIRED = 2165,
+
/**
* The exchange of the deposited coin charges a wire fee that could not be added to the total (total amount too high).
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1177,6 +2431,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_WIRE_FEE_ADDITION_FAILED = 2166,
+
/**
* The contract was not fully paid because of refunds. Note that clients MAY treat this as paid if, for example, contracts must be executed despite of refunds.
* Returned with an HTTP status code of #MHD_HTTP_PAYMENT_REQUIRED (402).
@@ -1184,6 +2439,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_REFUNDED = 2167,
+
/**
* According to our database, we have refunded more than we were paid (which should not be possible).
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1191,6 +2447,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_REFUNDS_EXCEED_PAYMENTS = 2168,
+
/**
* Legacy stuff. Remove me with protocol v1.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1198,6 +2455,7 @@ export enum TalerErrorCode {
*/
DEAD_QQQ_PAY_MERCHANT_POST_ORDERS_ID_ABORT_REFUND_REFUSED_PAYMENT_COMPLETE = 2169,
+
/**
* The payment failed at the exchange.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
@@ -1205,13 +2463,55 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAY_EXCHANGE_FAILED = 2170,
+
/**
- * The contract hash does not match the given order ID.
+ * The payment required a minimum age but one of the coins (of a denomination with support for age restriction) did not provide any age_commitment.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_AGE_COMMITMENT_MISSING = 2171,
+
+
+ /**
+ * The payment required a minimum age but one of the coins provided an age_commitment that contained a wrong number of public keys compared to the number of age groups defined in the denomination of the coin.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_AGE_COMMITMENT_SIZE_MISMATCH = 2172,
+
+
+ /**
+ * The payment required a minimum age but one of the coins provided a minimum_age_sig that couldn't be verified with the given age_commitment for that particular minimum age.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_AGE_VERIFICATION_FAILED = 2173,
+
+
+ /**
+ * The payment required no minimum age but one of the coins (of a denomination with support for age restriction) did not provide the required h_age_commitment.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_ORDERS_ID_PAY_AGE_COMMITMENT_HASH_MISSING = 2174,
+
+
+ /**
+ * The exchange does not support the selected bank account of the merchant. Likely the merchant had stale data on the bank accounts of the exchange and thus selected an inappropriate exchange when making the offer.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
+ MERCHANT_POST_ORDERS_ID_PAY_WIRE_METHOD_UNSUPPORTED = 2175,
+
+
+ /**
+ * The contract hash does not match the given order ID.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
MERCHANT_POST_ORDERS_ID_PAID_CONTRACT_HASH_MISMATCH = 2200,
+
/**
* The signature of the merchant is not valid for the given contract hash.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -1219,6 +2519,23 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_PAID_COIN_SIGNATURE_INVALID = 2201,
+
+ /**
+ * A token family with this ID but conflicting data exists.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_TOKEN_FAMILY_CONFLICT = 2225,
+
+
+ /**
+ * The backend is unaware of a token family with the given ID.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PATCH_TOKEN_FAMILY_NOT_FOUND = 2226,
+
+
/**
* The merchant failed to send the exchange the refund request.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1226,6 +2543,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_ABORT_EXCHANGE_REFUND_FAILED = 2251,
+
/**
* The merchant failed to find the exchange to process the lookup.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1233,6 +2551,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_ABORT_EXCHANGE_LOOKUP_FAILED = 2252,
+
/**
* The merchant could not find the contract.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -1240,6 +2559,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND = 2253,
+
/**
* The payment was already completed and thus cannot be aborted anymore.
* Returned with an HTTP status code of #MHD_HTTP_PRECONDITION_FAILED (412).
@@ -1247,6 +2567,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_ABORT_REFUND_REFUSED_PAYMENT_COMPLETE = 2254,
+
/**
* The hash provided by the wallet does not match the order.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -1254,6 +2575,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_HASH_MISSMATCH = 2255,
+
/**
* The array of coins cannot be empty.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1261,6 +2583,63 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_ABORT_COINS_ARRAY_EMPTY = 2256,
+
+ /**
+ * We are waiting for the exchange to provide us with key material before checking the wire transfer.
+ * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_AWAITING_KEYS = 2258,
+
+
+ /**
+ * We are waiting for the exchange to provide us with the list of aggregated transactions.
+ * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_AWAITING_LIST = 2259,
+
+
+ /**
+ * The endpoint indicated in the wire transfer does not belong to a GNU Taler exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_OK (200).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_FATAL_NO_EXCHANGE = 2260,
+
+
+ /**
+ * The exchange indicated in the wire transfer claims to know nothing about the wire transfer.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_FATAL_NOT_FOUND = 2261,
+
+
+ /**
+ * The interaction with the exchange is delayed due to rate limiting.
+ * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_RATE_LIMITED = 2262,
+
+
+ /**
+ * We experienced a transient failure in our interaction with the exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_ACCEPTED (202).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_TRANSIENT_FAILURE = 2263,
+
+
+ /**
+ * The response from the exchange was unacceptable and should be reviewed with an auditor.
+ * Returned with an HTTP status code of #MHD_HTTP_OK (200).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_HARD_FAILURE = 2264,
+
+
/**
* We could not claim the order because the backend is unaware of it.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -1268,6 +2647,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND = 2300,
+
/**
* We could not claim the order because someone else claimed it first.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1275,6 +2655,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED = 2301,
+
/**
* The client-side experienced an internal failure.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1282,6 +2663,7 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_CLAIM_CLIENT_INTERNAL_FAILURE = 2302,
+
/**
* The backend failed to sign the refund request.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1289,83 +2671,135 @@ export enum TalerErrorCode {
*/
MERCHANT_POST_ORDERS_ID_REFUND_SIGNATURE_FAILED = 2350,
+
/**
* The client failed to unblind the signature returned by the merchant.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_TIP_PICKUP_UNBLIND_FAILURE = 2400,
+ MERCHANT_REWARD_PICKUP_UNBLIND_FAILURE = 2400,
+
/**
* The exchange returned a failure code for the withdraw operation.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_TIP_PICKUP_EXCHANGE_ERROR = 2403,
+ MERCHANT_REWARD_PICKUP_EXCHANGE_ERROR = 2403,
+
/**
* The merchant failed to add up the amounts to compute the pick up value.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_TIP_PICKUP_SUMMATION_FAILED = 2404,
+ MERCHANT_REWARD_PICKUP_SUMMATION_FAILED = 2404,
+
/**
- * The tip expired.
+ * The reward expired.
* Returned with an HTTP status code of #MHD_HTTP_GONE (410).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_TIP_PICKUP_HAS_EXPIRED = 2405,
+ MERCHANT_REWARD_PICKUP_HAS_EXPIRED = 2405,
+
/**
* The requested withdraw amount exceeds the amount remaining to be picked up.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_TIP_PICKUP_AMOUNT_EXCEEDS_TIP_REMAINING = 2406,
+ MERCHANT_REWARD_PICKUP_AMOUNT_EXCEEDS_REWARD_REMAINING = 2406,
+
/**
* The merchant did not find the specified denomination key in the exchange's key set.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_TIP_PICKUP_DENOMINATION_UNKNOWN = 2407,
+ MERCHANT_REWARD_PICKUP_DENOMINATION_UNKNOWN = 2407,
+
/**
- * The backend lacks a wire transfer method configuration option for the given instance. Thus, this instance is unavailable (not findable for creating new orders).
+ * The merchant instance has no active bank accounts configured. However, at least one bank account must be available to create new orders.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_POST_ORDERS_INSTANCE_CONFIGURATION_LACKS_WIRE = 2500,
+
/**
- * The proposal had no timestamp and the backend failed to obtain the local time. Likely to be an internal error.
+ * The proposal had no timestamp and the merchant backend failed to obtain the current local time.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_POST_ORDERS_NO_LOCALTIME = 2501,
+
/**
- * The order provided to the backend could not be parsed, some required fields were missing or ill-formed.
+ * The order provided to the backend could not be parsed; likely some required fields were missing or ill-formed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_POST_ORDERS_PROPOSAL_PARSE_ERROR = 2502,
+
/**
- * The backend encountered an error: the proposal already exists.
+ * A conflicting order (sharing the same order identifier) already exists at this merchant backend instance.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_POST_ORDERS_ALREADY_EXISTS = 2503,
+
/**
- * The request is invalid: the wire deadline is before the refund deadline.
+ * The order creation request is invalid because the given wire deadline is before the refund deadline.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_POST_ORDERS_REFUND_AFTER_WIRE_DEADLINE = 2504,
+
+ /**
+ * The order creation request is invalid because the delivery date given is in the past.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_ORDERS_DELIVERY_DATE_IN_PAST = 2505,
+
+
+ /**
+ * The order creation request is invalid because a wire deadline of "never" is not allowed.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_ORDERS_WIRE_DEADLINE_IS_NEVER = 2506,
+
+
+ /**
+ * The order creation request is invalid because the given payment deadline is in the past.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_ORDERS_PAY_DEADLINE_IN_PAST = 2507,
+
+
+ /**
+ * The order creation request is invalid because the given refund deadline is in the past.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_ORDERS_REFUND_DEADLINE_IN_PAST = 2508,
+
+
+ /**
+ * The backend does not trust any exchange that would allow funds to be wired to any bank account of this instance using the wire method specified with the order. Note that right now, we do not support the use of exchange bank accounts with mandatory currency conversion.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_ORDERS_NO_EXCHANGES_FOR_WIRE_METHOD = 2509,
+
+
/**
* One of the paths to forget is malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1373,6 +2807,7 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_PATCH_ORDERS_ID_FORGET_PATH_SYNTAX_INCORRECT = 2510,
+
/**
* One of the paths to forget was not marked as forgettable.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1380,41 +2815,71 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_PATCH_ORDERS_ID_FORGET_PATH_NOT_FORGETTABLE = 2511,
+
/**
- * The order provided to the backend could not be deleted, our offer is still valid and awaiting payment.
+ * The order provided to the backend could not be deleted, our offer is still valid and awaiting payment. Deletion may work later after the offer has expired if it remains unpaid.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_DELETE_ORDERS_AWAITING_PAYMENT = 2520,
+
/**
- * The amount to be refunded is inconsistent: either is lower than the previous amount being awarded, or it is too big to be paid back. In this second case, the fault stays on the business dept. side.
+ * The order provided to the backend could not be deleted as the order was already paid.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_DELETE_ORDERS_ALREADY_PAID = 2521,
+
+
+ /**
+ * The amount to be refunded is inconsistent: either is lower than the previous amount being awarded, or it exceeds the original price paid by the customer.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_INCONSISTENT_AMOUNT = 2530,
+
/**
- * The frontend gave an unpaid order id to issue the refund to.
+ * Only paid orders can be refunded, and the frontend specified an unpaid order to issue a refund for.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_ORDER_UNPAID = 2531,
+
/**
- * The refund delay was set to 0 and thus no refunds are allowed for this order.
+ * The refund delay was set to 0 and thus no refunds are ever allowed for this order.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_NOT_ALLOWED_BY_CONTRACT = 2532,
+
+ /**
+ * The exchange says it does not know this transfer.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_TRANSFERS_EXCHANGE_UNKNOWN = 2550,
+
+
/**
* We internally failed to execute the /track/transfer request.
- * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_POST_TRANSFERS_REQUEST_ERROR = 2551,
+
+ /**
+ * The amount transferred differs between what was submitted and what the exchange claimed.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_TRANSFERS = 2552,
+
+
/**
* The exchange gave conflicting information about a coin which has been wire transferred.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1422,6 +2887,7 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_REPORTS = 2553,
+
/**
* The exchange charged a different wire fee than what it originally advertised, and it is higher.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
@@ -1429,6 +2895,7 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_POST_TRANSFERS_BAD_WIRE_FEE = 2554,
+
/**
* We did not find the account that the transfer was made to.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -1436,6 +2903,31 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_POST_TRANSFERS_ACCOUNT_NOT_FOUND = 2555,
+
+ /**
+ * The backend could not delete the transfer as the echange already replied to our inquiry about it and we have integrated the result.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_DELETE_TRANSFERS_ALREADY_CONFIRMED = 2556,
+
+
+ /**
+ * The backend was previously informed about a wire transfer with the same ID but a different amount. Multiple wire transfers with the same ID are not allowed. If the new amount is correct, the old transfer should first be deleted.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_SUBMISSION = 2557,
+
+
+ /**
+ * The amount transferred differs between what was submitted and what the exchange claimed.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_EXCHANGE_TRANSFERS_CONFLICTING_TRANSFERS = 2563,
+
+
/**
* The merchant backend cannot create an instance under the given identifier as one already exists. Use PATCH to modify the existing entry.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1443,6 +2935,7 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_POST_INSTANCES_ALREADY_EXISTS = 2600,
+
/**
* The merchant backend cannot create an instance because the authentication configuration field is malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1450,6 +2943,7 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_POST_INSTANCES_BAD_AUTH = 2601,
+
/**
* The merchant backend cannot update an instance's authentication settings because the provided authentication settings are malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1457,6 +2951,7 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_POST_INSTANCE_AUTH_BAD_AUTH = 2602,
+
/**
* The merchant backend cannot create an instance under the given identifier, the previous one was deleted but must be purged first.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1464,6 +2959,7 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_POST_INSTANCES_PURGE_REQUIRED = 2603,
+
/**
* The merchant backend cannot update an instance under the given identifier, the previous one was deleted but must be purged first.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1471,6 +2967,23 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_PATCH_INSTANCES_PURGE_REQUIRED = 2625,
+
+ /**
+ * The bank account referenced in the requested operation was not found.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_ACCOUNT_DELETE_UNKNOWN_ACCOUNT = 2626,
+
+
+ /**
+ * The bank account specified in the request already exists at the merchant.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_ACCOUNT_EXISTS = 2627,
+
+
/**
* The product ID exists.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1478,6 +2991,7 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_POST_PRODUCTS_CONFLICT_PRODUCT_EXISTS = 2650,
+
/**
* The update would have reduced the total amount of product lost, which is not allowed.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1485,13 +2999,15 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_PATCH_PRODUCTS_TOTAL_LOST_REDUCED = 2660,
+
/**
* The update would have mean that more stocks were lost than what remains from total inventory after sales, which is not allowed.
- * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
MERCHANT_PRIVATE_PATCH_PRODUCTS_TOTAL_LOST_EXCEEDS_STOCKS = 2661,
+
/**
* The update would have reduced the total amount of product in stock, which is not allowed.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1499,6 +3015,15 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_PATCH_PRODUCTS_TOTAL_STOCKED_REDUCED = 2662,
+
+ /**
+ * The update would have reduced the total amount of product sold, which is not allowed.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_PATCH_PRODUCTS_TOTAL_SOLD_REDUCED = 2663,
+
+
/**
* The lock request is for more products than we have left (unlocked) in stock.
* Returned with an HTTP status code of #MHD_HTTP_GONE (410).
@@ -1506,6 +3031,7 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_POST_PRODUCTS_LOCK_INSUFFICIENT_STOCKS = 2670,
+
/**
* The deletion request is for a product that is locked.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1513,6 +3039,7 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK = 2680,
+
/**
* The requested wire method is not supported by the exchange.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1520,6 +3047,15 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_POST_RESERVES_UNSUPPORTED_WIRE_METHOD = 2700,
+
+ /**
+ * The requested exchange does not allow rewards.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_RESERVES_REWARDS_NOT_ALLOWED = 2701,
+
+
/**
* The reserve could not be deleted because it is unknown.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -1527,33 +3063,38 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_DELETE_RESERVES_NO_SUCH_RESERVE = 2710,
+
/**
- * The reserve that was used to fund the tips has expired.
+ * The reserve that was used to fund the rewards has expired.
* Returned with an HTTP status code of #MHD_HTTP_GONE (410).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_PRIVATE_POST_TIP_AUTHORIZE_RESERVE_EXPIRED = 2750,
+ MERCHANT_PRIVATE_POST_REWARD_AUTHORIZE_RESERVE_EXPIRED = 2750,
+
/**
- * The reserve that was used to fund the tips was not found in the DB.
+ * The reserve that was used to fund the rewards was not found in the DB.
* Returned with an HTTP status code of #MHD_HTTP_SERVICE_UNAVAILABLE (503).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_PRIVATE_POST_TIP_AUTHORIZE_RESERVE_UNKNOWN = 2751,
+ MERCHANT_PRIVATE_POST_REWARD_AUTHORIZE_RESERVE_UNKNOWN = 2751,
+
/**
- * The backend knows the instance that was supposed to support the tip, and it was configured for tipping. However, the funds remaining are insufficient to cover the tip, and the merchant should top up the reserve.
+ * The backend knows the instance that was supposed to support the reward, and it was configured for rewardping. However, the funds remaining are insufficient to cover the reward, and the merchant should top up the reserve.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_PRIVATE_POST_TIP_AUTHORIZE_INSUFFICIENT_FUNDS = 2752,
+ MERCHANT_PRIVATE_POST_REWARD_AUTHORIZE_INSUFFICIENT_FUNDS = 2752,
+
/**
- * The backend failed to find a reserve needed to authorize the tip.
+ * The backend failed to find a reserve needed to authorize the reward.
* Returned with an HTTP status code of #MHD_HTTP_SERVICE_UNAVAILABLE (503).
* (A value of 0 indicates that the error is generated client-side).
*/
- MERCHANT_PRIVATE_POST_TIP_AUTHORIZE_RESERVE_NOT_FOUND = 2753,
+ MERCHANT_PRIVATE_POST_REWARD_AUTHORIZE_RESERVE_NOT_FOUND = 2753,
+
/**
* The merchant backend encountered a failure in computing the deposit total.
@@ -1562,6 +3103,71 @@ export enum TalerErrorCode {
*/
MERCHANT_PRIVATE_GET_ORDERS_ID_AMOUNT_ARITHMETIC_FAILURE = 2800,
+
+ /**
+ * The template ID already exists.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_TEMPLATES_CONFLICT_TEMPLATE_EXISTS = 2850,
+
+
+ /**
+ * The OTP device ID already exists.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_OTP_DEVICES_CONFLICT_OTP_DEVICE_EXISTS = 2851,
+
+
+ /**
+ * Amount given in the using template and in the template contract. There is a conflict.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_USING_TEMPLATES_AMOUNT_CONFLICT_TEMPLATES_CONTRACT_AMOUNT = 2860,
+
+
+ /**
+ * Subject given in the using template and in the template contract. There is a conflict.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_USING_TEMPLATES_SUMMARY_CONFLICT_TEMPLATES_CONTRACT_SUBJECT = 2861,
+
+
+ /**
+ * Amount not given in the using template and in the template contract. There is a conflict.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_USING_TEMPLATES_NO_AMOUNT = 2862,
+
+
+ /**
+ * Subject not given in the using template and in the template contract. There is a conflict.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_POST_USING_TEMPLATES_NO_SUMMARY = 2863,
+
+
+ /**
+ * The webhook ID elready exists.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_WEBHOOKS_CONFLICT_WEBHOOK_EXISTS = 2900,
+
+
+ /**
+ * The webhook serial elready exists.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ MERCHANT_PRIVATE_POST_PENDING_WEBHOOKS_CONFLICT_PENDING_WEBHOOK_EXISTS = 2910,
+
+
/**
* The signature from the exchange on the deposit confirmation is invalid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -1569,6 +3175,7 @@ export enum TalerErrorCode {
*/
AUDITOR_DEPOSIT_CONFIRMATION_SIGNATURE_INVALID = 3100,
+
/**
* The exchange key used for the signature on the deposit confirmation was revoked.
* Returned with an HTTP status code of #MHD_HTTP_GONE (410).
@@ -1576,83 +3183,87 @@ export enum TalerErrorCode {
*/
AUDITOR_EXCHANGE_SIGNING_KEY_REVOKED = 3101,
+
/**
* Wire transfer attempted with credit and debit party being the same bank account.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
BANK_SAME_ACCOUNT = 5101,
+
/**
* Wire transfer impossible, due to financial limitation of the party that attempted the payment.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
BANK_UNALLOWED_DEBIT = 5102,
+
/**
- * Negative number was used (as value and/or fraction) to initiate a Amount object.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * Negative numbers are not allowed (as value and/or fraction) to instantiate an amount object.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
BANK_NEGATIVE_NUMBER_AMOUNT = 5103,
+
/**
- * A number too big was used (as value and/or fraction) to initiate a amount object.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * A too big number was used (as value and/or fraction) to instantiate an amount object.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
BANK_NUMBER_TOO_BIG = 5104,
- /**
- * Could not login for the requested operation.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
- * (A value of 0 indicates that the error is generated client-side).
- */
- BANK_LOGIN_FAILED = 5105,
/**
- * The bank account referenced in the requested operation was not found. Returned along "400 Not found".
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * The bank account referenced in the requested operation was not found.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
BANK_UNKNOWN_ACCOUNT = 5106,
+
/**
* The transaction referenced in the requested operation (typically a reject operation), was not found.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
BANK_TRANSACTION_NOT_FOUND = 5107,
+
/**
* Bank received a malformed amount string.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
BANK_BAD_FORMAT_AMOUNT = 5108,
+
/**
- * The client does not own the account credited by the transaction which is to be rejected, so it has no rights do reject it. To be returned along HTTP 403 Forbidden.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * The client does not own the account credited by the transaction which is to be rejected, so it has no rights do reject it.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
* (A value of 0 indicates that the error is generated client-side).
*/
BANK_REJECT_NO_RIGHTS = 5109,
+
/**
- * This error code is returned when no known exception types captured the exception, and comes along with a 500 Internal Server Error.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * This error code is returned when no known exception types captured the exception.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
BANK_UNMANAGED_EXCEPTION = 5110,
+
/**
- * This error code is used for all those exceptions that do not really need a specific error code to return to the client, but need to signal the middleware that the bank is not responding with 500 Internal Server Error. Used for example when a client is trying to register with a unavailable username.
- * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * This error code is used for all those exceptions that do not really need a specific error code to return to the client. Used for example when a client is trying to register with a unavailable username.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
BANK_SOFT_EXCEPTION = 5111,
+
/**
* The request UID for a request to transfer funds has already been used, but with different details for the transfer.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1660,6 +3271,7 @@ export enum TalerErrorCode {
*/
BANK_TRANSFER_REQUEST_UID_REUSED = 5112,
+
/**
* The withdrawal operation already has a reserve selected. The current request conflicts with the existing selection.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
@@ -1667,6 +3279,263 @@ export enum TalerErrorCode {
*/
BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT = 5113,
+
+ /**
+ * The wire transfer subject duplicates an existing reserve public key. But wire transfer subjects must be unique.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_DUPLICATE_RESERVE_PUB_SUBJECT = 5114,
+
+
+ /**
+ * The client requested a transaction that is so far in the past, that it has been forgotten by the bank.
+ * Returned with an HTTP status code of #MHD_HTTP_GONE (410).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_ANCIENT_TRANSACTION_GONE = 5115,
+
+
+ /**
+ * The client attempted to abort a transaction that was already confirmed.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_ABORT_CONFIRM_CONFLICT = 5116,
+
+
+ /**
+ * The client attempted to confirm a transaction that was already aborted.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_CONFIRM_ABORT_CONFLICT = 5117,
+
+
+ /**
+ * The client attempted to register an account with the same name.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_REGISTER_CONFLICT = 5118,
+
+
+ /**
+ * The client attempted to confirm a withdrawal operation before the wallet posted the required details.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_POST_WITHDRAWAL_OPERATION_REQUIRED = 5119,
+
+
+ /**
+ * The client tried to register a new account under a reserved username (like 'admin' for example).
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_RESERVED_USERNAME_CONFLICT = 5120,
+
+
+ /**
+ * The client tried to register a new account with an username already in use.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_REGISTER_USERNAME_REUSE = 5121,
+
+
+ /**
+ * The client tried to register a new account with a payto:// URI already in use.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_REGISTER_PAYTO_URI_REUSE = 5122,
+
+
+ /**
+ * The client tried to delete an account with a non null balance.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_ACCOUNT_BALANCE_NOT_ZERO = 5123,
+
+
+ /**
+ * The client tried to create a transaction or an operation that credit an unknown account.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_UNKNOWN_CREDITOR = 5124,
+
+
+ /**
+ * The client tried to create a transaction or an operation that debit an unknown account.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_UNKNOWN_DEBTOR = 5125,
+
+
+ /**
+ * The client tried to perform an action prohibited for exchange accounts.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_ACCOUNT_IS_EXCHANGE = 5126,
+
+
+ /**
+ * The client tried to perform an action reserved for exchange accounts.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_ACCOUNT_IS_NOT_EXCHANGE = 5127,
+
+
+ /**
+ * Received currency conversion is wrong.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_BAD_CONVERSION = 5128,
+
+
+ /**
+ * The account referenced in this operation is missing tan info for the chosen channel.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_MISSING_TAN_INFO = 5129,
+
+
+ /**
+ * The client attempted to confirm a transaction with incomplete info.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_CONFIRM_INCOMPLETE = 5130,
+
+
+ /**
+ * The request rate is too high. The server is refusing requests to guard against brute-force attacks.
+ * Returned with an HTTP status code of #MHD_HTTP_TOO_MANY_REQUESTS (429).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_TAN_RATE_LIMITED = 5131,
+
+
+ /**
+ * This TAN channel is not supported.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_IMPLEMENTED (501).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_TAN_CHANNEL_NOT_SUPPORTED = 5132,
+
+
+ /**
+ * Failed to send TAN using the helper script. Either script is not found, or script timeout, or script terminated with a non-successful result.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_TAN_CHANNEL_SCRIPT_FAILED = 5133,
+
+
+ /**
+ * The client's response to the challenge was invalid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_TAN_CHALLENGE_FAILED = 5134,
+
+
+ /**
+ * A non-admin user has tried to change their legal name.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_NON_ADMIN_PATCH_LEGAL_NAME = 5135,
+
+
+ /**
+ * A non-admin user has tried to change their debt limit.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_NON_ADMIN_PATCH_DEBT_LIMIT = 5136,
+
+
+ /**
+ * A non-admin user has tried to change their password whihout providing the current one.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD = 5137,
+
+
+ /**
+ * Provided old password does not match current password.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_PATCH_BAD_OLD_PASSWORD = 5138,
+
+
+ /**
+ * An admin user has tried to become an exchange.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_PATCH_ADMIN_EXCHANGE = 5139,
+
+
+ /**
+ * A non-admin user has tried to change their cashout account.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_NON_ADMIN_PATCH_CASHOUT = 5140,
+
+
+ /**
+ * A non-admin user has tried to change their contact info.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_NON_ADMIN_PATCH_CONTACT = 5141,
+
+
+ /**
+ * The client tried to create a transaction that credit the admin account.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_ADMIN_CREDITOR = 5142,
+
+
+ /**
+ * The referenced challenge was not found.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_CHALLENGE_NOT_FOUND = 5143,
+
+
+ /**
+ * The referenced challenge has expired.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_TAN_CHALLENGE_EXPIRED = 5144,
+
+
+ /**
+ * A non-admin user has tried to create an account with 2fa.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ BANK_NON_ADMIN_SET_TAN_CHANNEL = 5145,
+
+
/**
* The sync service failed find the account in its database.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -1674,6 +3543,7 @@ export enum TalerErrorCode {
*/
SYNC_ACCOUNT_UNKNOWN = 6100,
+
/**
* The SHA-512 hash provided in the If-None-Match header is malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1681,6 +3551,7 @@ export enum TalerErrorCode {
*/
SYNC_BAD_IF_NONE_MATCH = 6101,
+
/**
* The SHA-512 hash provided in the If-Match header is malformed or missing.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1688,6 +3559,7 @@ export enum TalerErrorCode {
*/
SYNC_BAD_IF_MATCH = 6102,
+
/**
* The signature provided in the "Sync-Signature" header is malformed or missing.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1695,6 +3567,7 @@ export enum TalerErrorCode {
*/
SYNC_BAD_SYNC_SIGNATURE = 6103,
+
/**
* The signature provided in the "Sync-Signature" header does not match the account, old or new Etags.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -1702,6 +3575,7 @@ export enum TalerErrorCode {
*/
SYNC_INVALID_SIGNATURE = 6104,
+
/**
* The "Content-length" field for the upload is not a number.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1709,20 +3583,23 @@ export enum TalerErrorCode {
*/
SYNC_MALFORMED_CONTENT_LENGTH = 6105,
+
/**
* The "Content-length" field for the upload is too big based on the server's terms of service.
- * Returned with an HTTP status code of #MHD_HTTP_PAYLOAD_TOO_LARGE (413).
+ * Returned with an HTTP status code of #MHD_HTTP_CONTENT_TOO_LARGE (413).
* (A value of 0 indicates that the error is generated client-side).
*/
SYNC_EXCESSIVE_CONTENT_LENGTH = 6106,
+
/**
* The server is out of memory to handle the upload. Trying again later may succeed.
- * Returned with an HTTP status code of #MHD_HTTP_PAYLOAD_TOO_LARGE (413).
+ * Returned with an HTTP status code of #MHD_HTTP_CONTENT_TOO_LARGE (413).
* (A value of 0 indicates that the error is generated client-side).
*/
SYNC_OUT_OF_MEMORY_ON_CONTENT_LENGTH = 6107,
+
/**
* The uploaded data does not match the Etag.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1730,6 +3607,7 @@ export enum TalerErrorCode {
*/
SYNC_INVALID_UPLOAD = 6108,
+
/**
* HTTP server experienced a timeout while awaiting promised payment.
* Returned with an HTTP status code of #MHD_HTTP_REQUEST_TIMEOUT (408).
@@ -1737,6 +3615,7 @@ export enum TalerErrorCode {
*/
SYNC_PAYMENT_GENERIC_TIMEOUT = 6109,
+
/**
* Sync could not setup the payment request with its own backend.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1744,6 +3623,7 @@ export enum TalerErrorCode {
*/
SYNC_PAYMENT_CREATE_BACKEND_ERROR = 6110,
+
/**
* The sync service failed find the backup to be updated in its database.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -1751,6 +3631,7 @@ export enum TalerErrorCode {
*/
SYNC_PREVIOUS_BACKUP_UNKNOWN = 6111,
+
/**
* The "Content-length" field for the upload is missing.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1758,6 +3639,23 @@ export enum TalerErrorCode {
*/
SYNC_MISSING_CONTENT_LENGTH = 6112,
+
+ /**
+ * Sync had problems communicating with its payment backend.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ SYNC_GENERIC_BACKEND_ERROR = 6113,
+
+
+ /**
+ * Sync experienced a timeout communicating with its payment backend.
+ * Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ SYNC_GENERIC_BACKEND_TIMEOUT = 6114,
+
+
/**
* The wallet does not implement a version of the exchange protocol that is compatible with the protocol version of the exchange.
* Returned with an HTTP status code of #MHD_HTTP_NOT_IMPLEMENTED (501).
@@ -1765,6 +3663,7 @@ export enum TalerErrorCode {
*/
WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE = 7000,
+
/**
* The wallet encountered an unexpected exception. This is likely a bug in the wallet implementation.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1772,6 +3671,7 @@ export enum TalerErrorCode {
*/
WALLET_UNEXPECTED_EXCEPTION = 7001,
+
/**
* The wallet received a response from a server, but the response can't be parsed.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1779,6 +3679,7 @@ export enum TalerErrorCode {
*/
WALLET_RECEIVED_MALFORMED_RESPONSE = 7002,
+
/**
* The wallet tried to make a network request, but it received no response.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1786,6 +3687,7 @@ export enum TalerErrorCode {
*/
WALLET_NETWORK_ERROR = 7003,
+
/**
* The wallet tried to make a network request, but it was throttled.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1793,6 +3695,7 @@ export enum TalerErrorCode {
*/
WALLET_HTTP_REQUEST_THROTTLED = 7004,
+
/**
* The wallet made a request to a service, but received an error response it does not know how to handle.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1800,6 +3703,7 @@ export enum TalerErrorCode {
*/
WALLET_UNEXPECTED_REQUEST_ERROR = 7005,
+
/**
* The denominations offered by the exchange are insufficient. Likely the exchange is badly configured or not maintained.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1807,6 +3711,7 @@ export enum TalerErrorCode {
*/
WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT = 7006,
+
/**
* The wallet does not support the operation requested by a client.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1814,6 +3719,7 @@ export enum TalerErrorCode {
*/
WALLET_CORE_API_OPERATION_UNKNOWN = 7007,
+
/**
* The given taler://pay URI is invalid.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1821,6 +3727,7 @@ export enum TalerErrorCode {
*/
WALLET_INVALID_TALER_PAY_URI = 7008,
+
/**
* The signature on a coin by the exchange's denomination key is invalid after unblinding it.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1828,6 +3735,7 @@ export enum TalerErrorCode {
*/
WALLET_EXCHANGE_COIN_SIGNATURE_INVALID = 7009,
+
/**
* The exchange does not know about the reserve (yet), and thus withdrawal can't progress.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -1835,6 +3743,7 @@ export enum TalerErrorCode {
*/
WALLET_EXCHANGE_WITHDRAW_RESERVE_UNKNOWN_AT_EXCHANGE = 7010,
+
/**
* The wallet core service is not available.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1842,6 +3751,7 @@ export enum TalerErrorCode {
*/
WALLET_CORE_NOT_AVAILABLE = 7011,
+
/**
* The bank has aborted a withdrawal operation, and thus a withdrawal can't complete.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1849,6 +3759,7 @@ export enum TalerErrorCode {
*/
WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK = 7012,
+
/**
* An HTTP request made by the wallet timed out.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1856,6 +3767,7 @@ export enum TalerErrorCode {
*/
WALLET_HTTP_REQUEST_GENERIC_TIMEOUT = 7013,
+
/**
* The order has already been claimed by another wallet.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1863,6 +3775,7 @@ export enum TalerErrorCode {
*/
WALLET_ORDER_ALREADY_CLAIMED = 7014,
+
/**
* A group of withdrawal operations (typically for the same reserve at the same exchange) has errors and will be tried again later.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1870,12 +3783,14 @@ export enum TalerErrorCode {
*/
WALLET_WITHDRAWAL_GROUP_INCOMPLETE = 7015,
+
/**
- * The signature on a coin by the exchange's denomination key (obtained through the merchant via tipping) is invalid after unblinding it.
+ * The signature on a coin by the exchange's denomination key (obtained through the merchant via a reward) is invalid after unblinding it.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
- WALLET_TIPPING_COIN_SIGNATURE_INVALID = 7016,
+ WALLET_REWARD_COIN_SIGNATURE_INVALID = 7016,
+
/**
* The wallet does not implement a version of the bank integration API that is compatible with the version offered by the bank.
@@ -1884,6 +3799,7 @@ export enum TalerErrorCode {
*/
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE = 7017,
+
/**
* The wallet processed a taler://pay URI, but the merchant base URL in the downloaded contract terms does not match the merchant base URL derived from the URI.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1891,6 +3807,7 @@ export enum TalerErrorCode {
*/
WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH = 7018,
+
/**
* The merchant's signature on the contract terms is invalid.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1898,6 +3815,7 @@ export enum TalerErrorCode {
*/
WALLET_CONTRACT_TERMS_SIGNATURE_INVALID = 7019,
+
/**
* The contract terms given by the merchant are malformed.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1905,6 +3823,135 @@ export enum TalerErrorCode {
*/
WALLET_CONTRACT_TERMS_MALFORMED = 7020,
+
+ /**
+ * A pending operation failed, and thus the request can't be completed.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_PENDING_OPERATION_FAILED = 7021,
+
+
+ /**
+ * A payment was attempted, but the merchant had an internal server error (5xx).
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_PAY_MERCHANT_SERVER_ERROR = 7022,
+
+
+ /**
+ * The crypto worker failed.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_CRYPTO_WORKER_ERROR = 7023,
+
+
+ /**
+ * The crypto worker received a bad request.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_CRYPTO_WORKER_BAD_REQUEST = 7024,
+
+
+ /**
+ * A KYC step is required before withdrawal can proceed.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_WITHDRAWAL_KYC_REQUIRED = 7025,
+
+
+ /**
+ * The wallet does not have sufficient balance to create a deposit group.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE = 7026,
+
+
+ /**
+ * The wallet does not have sufficient balance to create a peer push payment.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE = 7027,
+
+
+ /**
+ * The wallet does not have sufficient balance to pay for an invoice.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_PEER_PULL_PAYMENT_INSUFFICIENT_BALANCE = 7028,
+
+
+ /**
+ * A group of refresh operations has errors and will be tried again later.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_REFRESH_GROUP_INCOMPLETE = 7029,
+
+
+ /**
+ * The exchange's self-reported base URL does not match the one that the wallet is using.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_EXCHANGE_BASE_URL_MISMATCH = 7030,
+
+
+ /**
+ * The order has already been paid by another wallet.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_ORDER_ALREADY_PAID = 7031,
+
+
+ /**
+ * An exchange that is required for some request is currently not available.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_EXCHANGE_UNAVAILABLE = 7032,
+
+
+ /**
+ * An exchange entry is still used by the exchange, thus it can't be deleted without purging.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_EXCHANGE_ENTRY_USED = 7033,
+
+
+ /**
+ * The wallet database is unavailable and the wallet thus is not operational.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_DB_UNAVAILABLE = 7034,
+
+
+ /**
+ * A taler:// URI is malformed and can't be parsed.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_TALER_URI_MALFORMED = 7035,
+
+
+ /**
+ * A wallet-core request was cancelled and thus can't provide a response.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ WALLET_CORE_REQUEST_CANCELLED = 7036,
+
+
/**
* We encountered a timeout with our payment backend.
* Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504).
@@ -1912,6 +3959,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_GENERIC_BACKEND_TIMEOUT = 8000,
+
/**
* The backend requested payment, but the request is malformed.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -1919,6 +3967,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_GENERIC_INVALID_PAYMENT_REQUEST = 8001,
+
/**
* The backend got an unexpected reply from the payment processor.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
@@ -1926,6 +3975,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_GENERIC_BACKEND_ERROR = 8002,
+
/**
* The "Content-length" field for the upload is missing.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1933,6 +3983,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_GENERIC_MISSING_CONTENT_LENGTH = 8003,
+
/**
* The "Content-length" field for the upload is malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -1940,6 +3991,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_GENERIC_MALFORMED_CONTENT_LENGTH = 8004,
+
/**
* The backend failed to setup an order with the payment processor.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
@@ -1947,6 +3999,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_GENERIC_ORDER_CREATE_BACKEND_ERROR = 8005,
+
/**
* The backend was not authorized to check for payment with the payment processor.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1954,6 +4007,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_GENERIC_PAYMENT_CHECK_UNAUTHORIZED = 8006,
+
/**
* The backend could not check payment status with the payment processor.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -1961,20 +4015,39 @@ export enum TalerErrorCode {
*/
ANASTASIS_GENERIC_PAYMENT_CHECK_START_FAILED = 8007,
+
+ /**
+ * The Anastasis provider could not be reached.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ ANASTASIS_GENERIC_PROVIDER_UNREACHABLE = 8008,
+
+
/**
- * The truth public key is unknown to the provider.
+ * HTTP server experienced a timeout while awaiting promised payment.
+ * Returned with an HTTP status code of #MHD_HTTP_REQUEST_TIMEOUT (408).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ ANASTASIS_PAYMENT_GENERIC_TIMEOUT = 8009,
+
+
+ /**
+ * The key share is unknown to the provider.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_TRUTH_UNKNOWN = 8108,
+
/**
- * The authorization method used by the truth is no longer supported by the provider.
+ * The authorization method used for the key share is no longer supported by the provider.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_TRUTH_AUTHORIZATION_METHOD_NO_LONGER_SUPPORTED = 8109,
+
/**
* The client needs to respond to the challenge.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -1982,6 +4055,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_TRUTH_CHALLENGE_RESPONSE_REQUIRED = 8110,
+
/**
* The client's response to the challenge was invalid.
* Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
@@ -1989,19 +4063,14 @@ export enum TalerErrorCode {
*/
ANASTASIS_TRUTH_CHALLENGE_FAILED = 8111,
+
/**
- * The service is unaware of having issued a challenge.
- * Returned with an HTTP status code of #MHD_HTTP_GONE (410).
+ * The backend is not aware of having issued the provided challenge code. Either this is the wrong code, or it has expired.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_TRUTH_CHALLENGE_UNKNOWN = 8112,
- /**
- * A challenge is already active, the service is thus not issuing a new one.
- * Returned with an HTTP status code of #MHD_HTTP_ALREADY_REPORTED (208).
- * (A value of 0 indicates that the error is generated client-side).
- */
- ANASTASIS_TRUTH_CHALLENGE_ACTIVE = 8113,
/**
* The backend failed to initiate the authorization process.
@@ -2010,6 +4079,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_TRUTH_AUTHORIZATION_START_FAILED = 8114,
+
/**
* The authorization succeeded, but the key share is no longer available.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -2017,6 +4087,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_TRUTH_KEY_SHARE_GONE = 8115,
+
/**
* The backend forgot the order we asked the client to pay for
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
@@ -2024,6 +4095,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_TRUTH_ORDER_DISAPPEARED = 8116,
+
/**
* The backend itself reported a bad exchange interaction.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
@@ -2031,6 +4103,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_TRUTH_BACKEND_EXCHANGE_BAD = 8117,
+
/**
* The backend reported a payment status we did not expect.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -2038,6 +4111,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_TRUTH_UNEXPECTED_PAYMENT_STATUS = 8118,
+
/**
* The backend failed to setup the order for payment.
* Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502).
@@ -2045,13 +4119,15 @@ export enum TalerErrorCode {
*/
ANASTASIS_TRUTH_PAYMENT_CREATE_BACKEND_ERROR = 8119,
+
/**
- * The decryption of the truth object failed with the provided key.
- * Returned with an HTTP status code of #MHD_HTTP_EXPECTATION_FAILED (417).
+ * The decryption of the key share failed with the provided key.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_TRUTH_DECRYPTION_FAILED = 8120,
+
/**
* The request rate is too high. The server is refusing requests to guard against brute-force attacks.
* Returned with an HTTP status code of #MHD_HTTP_TOO_MANY_REQUESTS (429).
@@ -2059,27 +4135,39 @@ export enum TalerErrorCode {
*/
ANASTASIS_TRUTH_RATE_LIMITED = 8121,
+
+ /**
+ * A request to issue a challenge is not valid for this authentication method.
+ * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ ANASTASIS_TRUTH_CHALLENGE_WRONG_METHOD = 8123,
+
+
/**
- * The backend failed to store the truth because the UUID is already in use.
+ * The backend failed to store the key share because the UUID is already in use.
* Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_TRUTH_UPLOAD_UUID_EXISTS = 8150,
+
/**
- * The backend failed to store the truth because the authorization method is not supported.
+ * The backend failed to store the key share because the authorization method is not supported.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_TRUTH_UPLOAD_METHOD_NOT_SUPPORTED = 8151,
+
/**
* The provided phone number is not an acceptable number.
- * Returned with an HTTP status code of #MHD_HTTP_EXPECTATION_FAILED (417).
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_SMS_PHONE_INVALID = 8200,
+
/**
* Failed to run the SMS transmission helper process.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -2087,20 +4175,23 @@ export enum TalerErrorCode {
*/
ANASTASIS_SMS_HELPER_EXEC_FAILED = 8201,
+
/**
- * Helper terminated with a non-successful result.
+ * Provider failed to send SMS. Helper terminated with a non-successful result.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_SMS_HELPER_COMMAND_FAILED = 8202,
+
/**
* The provided email address is not an acceptable address.
- * Returned with an HTTP status code of #MHD_HTTP_EXPECTATION_FAILED (417).
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_EMAIL_INVALID = 8210,
+
/**
* Failed to run the E-mail transmission helper process.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -2108,20 +4199,23 @@ export enum TalerErrorCode {
*/
ANASTASIS_EMAIL_HELPER_EXEC_FAILED = 8211,
+
/**
- * Helper terminated with a non-successful result.
+ * Provider failed to send E-mail. Helper terminated with a non-successful result.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_EMAIL_HELPER_COMMAND_FAILED = 8212,
+
/**
* The provided postal address is not an acceptable address.
- * Returned with an HTTP status code of #MHD_HTTP_EXPECTATION_FAILED (417).
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_POST_INVALID = 8220,
+
/**
* Failed to run the mail transmission helper process.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
@@ -2129,13 +4223,47 @@ export enum TalerErrorCode {
*/
ANASTASIS_POST_HELPER_EXEC_FAILED = 8221,
+
/**
- * Helper terminated with a non-successful result.
+ * Provider failed to send mail. Helper terminated with a non-successful result.
* Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_POST_HELPER_COMMAND_FAILED = 8222,
+
+ /**
+ * The provided IBAN address is not an acceptable IBAN.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ ANASTASIS_IBAN_INVALID = 8230,
+
+
+ /**
+ * The provider has not yet received the IBAN wire transfer authorizing the disclosure of the key share.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ ANASTASIS_IBAN_MISSING_TRANSFER = 8231,
+
+
+ /**
+ * The backend did not find a TOTP key in the data provided.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ ANASTASIS_TOTP_KEY_MISSING = 8240,
+
+
+ /**
+ * The key provided does not satisfy the format restrictions for an Anastasis TOTP key.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ ANASTASIS_TOTP_KEY_INVALID = 8241,
+
+
/**
* The given if-none-match header is malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -2143,13 +4271,15 @@ export enum TalerErrorCode {
*/
ANASTASIS_POLICY_BAD_IF_NONE_MATCH = 8301,
+
/**
* The server is out of memory to handle the upload. Trying again later may succeed.
- * Returned with an HTTP status code of #MHD_HTTP_PAYLOAD_TOO_LARGE (413).
+ * Returned with an HTTP status code of #MHD_HTTP_CONTENT_TOO_LARGE (413).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_POLICY_OUT_OF_MEMORY_ON_CONTENT_LENGTH = 8304,
+
/**
* The signature provided in the "Anastasis-Policy-Signature" header is malformed or missing.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -2157,6 +4287,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_POLICY_BAD_SIGNATURE = 8305,
+
/**
* The given if-match header is malformed.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -2164,6 +4295,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_POLICY_BAD_IF_MATCH = 8306,
+
/**
* The uploaded data does not match the Etag.
* Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400).
@@ -2171,6 +4303,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_POLICY_INVALID_UPLOAD = 8307,
+
/**
* The provider is unaware of the requested policy.
* Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
@@ -2178,6 +4311,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_POLICY_NOT_FOUND = 8350,
+
/**
* The given action is invalid for the current state of the reducer.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2185,6 +4319,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_ACTION_INVALID = 8400,
+
/**
* The given state of the reducer is invalid.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2192,6 +4327,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_STATE_INVALID = 8401,
+
/**
* The given input to the reducer is invalid.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2199,13 +4335,15 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_INPUT_INVALID = 8402,
+
/**
- * The selected authentication method does ot work for the Anastasis provider.
+ * The selected authentication method does not work for the Anastasis provider.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_REDUCER_AUTHENTICATION_METHOD_NOT_SUPPORTED = 8403,
+
/**
* The given input and action do not work for the current state.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2213,6 +4351,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_INPUT_INVALID_FOR_STATE = 8404,
+
/**
* We experienced an unexpected failure interacting with the backend.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2220,6 +4359,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_BACKEND_FAILURE = 8405,
+
/**
* The contents of a resource file did not match our expectations.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2227,6 +4367,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_RESOURCE_MALFORMED = 8406,
+
/**
* A required resource file is missing.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2234,6 +4375,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_RESOURCE_MISSING = 8407,
+
/**
* An input did not match the regular expression.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2241,6 +4383,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_INPUT_REGEX_FAILED = 8408,
+
/**
* An input did not match the custom validation logic.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2248,13 +4391,15 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_INPUT_VALIDATION_FAILED = 8409,
+
/**
- * Our attempts to download the recovery document failed with all providers.
+ * Our attempts to download the recovery document failed with all providers. Most likely the personal information you entered differs from the information you provided during the backup process and you should go back to the previous step. Alternatively, if you used a backup provider that is unknown to this application, you should add that provider manually.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
ANASTASIS_REDUCER_POLICY_LOOKUP_FAILED = 8410,
+
/**
* Anastasis provider reported a fatal failure.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2262,6 +4407,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_BACKUP_PROVIDER_FAILED = 8411,
+
/**
* Anastasis provider failed to respond to the configuration request.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2269,6 +4415,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_PROVIDER_CONFIG_FAILED = 8412,
+
/**
* The policy we downloaded is malformed. Must have been a client error while creating the backup.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2276,6 +4423,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_POLICY_MALFORMED = 8413,
+
/**
* We failed to obtain the policy, likely due to a network issue.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2283,6 +4431,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_NETWORK_FAILED = 8414,
+
/**
* The recovered secret did not match the required syntax.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2290,6 +4439,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_SECRET_MALFORMED = 8415,
+
/**
* The challenge data provided is too large for the available providers.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2297,6 +4447,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_CHALLENGE_DATA_TOO_BIG = 8416,
+
/**
* The provided core secret is too large for some of the providers.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2304,6 +4455,7 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_SECRET_TOO_BIG = 8417,
+
/**
* The provider returned in invalid configuration.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@@ -2311,10 +4463,181 @@ export enum TalerErrorCode {
*/
ANASTASIS_REDUCER_PROVIDER_INVALID_CONFIG = 8418,
+
+ /**
+ * The reducer encountered an internal error, likely a bug that needs to be reported.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ ANASTASIS_REDUCER_INTERNAL_ERROR = 8419,
+
+
+ /**
+ * The reducer already synchronized with all providers.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ ANASTASIS_REDUCER_PROVIDERS_ALREADY_SYNCED = 8420,
+
+
+ /**
+ * The Donau failed to perform the operation as it could not find the private keys. This is a problem with the Donau setup, not with the client's request.
+ * Returned with an HTTP status code of #MHD_HTTP_SERVICE_UNAVAILABLE (503).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ DONAU_GENERIC_KEYS_MISSING = 8607,
+
+
+ /**
+ * The signature of the charity key is not valid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ DONAU_CHARITY_SIGNATURE_INVALID = 8608,
+
+
+ /**
+ * The charity is unknown.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ DONAU_CHARITY_NOT_FOUND = 8609,
+
+
+ /**
+ * A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ LIBEUFIN_NEXUS_GENERIC_ERROR = 9000,
+
+
+ /**
+ * An uncaught exception happened in the LibEuFin nexus service.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ LIBEUFIN_NEXUS_UNCAUGHT_EXCEPTION = 9001,
+
+
+ /**
+ * A generic error happened in the LibEuFin sandbox. See the enclose details JSON for more information.
+ * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ LIBEUFIN_SANDBOX_GENERIC_ERROR = 9500,
+
+
+ /**
+ * An uncaught exception happened in the LibEuFin sandbox service.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ LIBEUFIN_SANDBOX_UNCAUGHT_EXCEPTION = 9501,
+
+
+ /**
+ * This validation method is not supported by the service.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ TALDIR_METHOD_NOT_SUPPORTED = 9600,
+
+
+ /**
+ * Number of allowed attempts for initiating a challenge exceeded.
+ * Returned with an HTTP status code of #MHD_HTTP_TOO_MANY_REQUESTS (429).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ TALDIR_REGISTER_RATE_LIMITED = 9601,
+
+
+ /**
+ * The client is unknown or unauthorized.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ CHALLENGER_GENERIC_CLIENT_UNKNOWN = 9750,
+
+
+ /**
+ * The client is not authorized to use the given redirect URI.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ CHALLENGER_GENERIC_CLIENT_FORBIDDEN_BAD_REDIRECT_URI = 9751,
+
+
+ /**
+ * The service failed to execute its helper process to send the challenge.
+ * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ CHALLENGER_HELPER_EXEC_FAILED = 9752,
+
+
+ /**
+ * The grant is unknown to the service (it could also have expired).
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ CHALLENGER_GRANT_UNKNOWN = 9753,
+
+
+ /**
+ * The code given is not even well-formed.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ CHALLENGER_CLIENT_FORBIDDEN_BAD_CODE = 9754,
+
+
+ /**
+ * The service is not aware of the referenced validation process.
+ * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ CHALLENGER_GENERIC_VALIDATION_UNKNOWN = 9755,
+
+
+ /**
+ * The code given is not valid.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ CHALLENGER_CLIENT_FORBIDDEN_INVALID_CODE = 9756,
+
+
+ /**
+ * Too many attempts have been made, validation is temporarily disabled for this address.
+ * Returned with an HTTP status code of #MHD_HTTP_TOO_MANY_REQUESTS (429).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ CHALLENGER_TOO_MANY_ATTEMPTS = 9757,
+
+
+ /**
+ * The PIN code provided is incorrect.
+ * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ CHALLENGER_INVALID_PIN = 9758,
+
+
+ /**
+ * The token cannot be valid as no address was ever provided by the client.
+ * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409).
+ * (A value of 0 indicates that the error is generated client-side).
+ */
+ CHALLENGER_MISSING_ADDRESS = 9759,
+
+
/**
* End of error code range.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
END = 9999,
+
+
}
diff --git a/packages/taler-util/src/taler-types.ts b/packages/taler-util/src/taler-types.ts
new file mode 100644
index 000000000..2b8e55e38
--- /dev/null
+++ b/packages/taler-util/src/taler-types.ts
@@ -0,0 +1,2416 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Type and schema definitions and helpers for the core GNU Taler protocol.
+ *
+ * Even though the rest of the wallet uses camelCase for fields, use snake_case
+ * here, since that's the convention for the Taler JSON+HTTP API.
+ */
+
+/**
+ * Imports.
+ */
+
+import { Amounts, codecForAmountString } from "./amounts.js";
+import {
+ Codec,
+ buildCodecForObject,
+ buildCodecForUnion,
+ codecForAny,
+ codecForBoolean,
+ codecForConstString,
+ codecForList,
+ codecForMap,
+ codecForNumber,
+ codecForString,
+ codecForStringURL,
+ codecOptional,
+} from "./codec.js";
+import { strcmp } from "./helpers.js";
+import {
+ CurrencySpecification,
+ codecForCurrencySpecificiation,
+ codecForEither,
+ codecForProduct,
+} from "./index.js";
+import { Edx25519PublicKeyEnc } from "./taler-crypto.js";
+import {
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ codecForDuration,
+ codecForTimestamp,
+} from "./time.js";
+
+/**
+ * Denomination as found in the /keys response from the exchange.
+ */
+export class ExchangeDenomination {
+ /**
+ * Value of one coin of the denomination.
+ */
+ value: string;
+
+ /**
+ * Public signing key of the denomination.
+ */
+ denom_pub: DenominationPubKey;
+
+ /**
+ * Fee for withdrawing.
+ */
+ fee_withdraw: string;
+
+ /**
+ * Fee for depositing.
+ */
+ fee_deposit: string;
+
+ /**
+ * Fee for refreshing.
+ */
+ fee_refresh: string;
+
+ /**
+ * Fee for refunding.
+ */
+ fee_refund: string;
+
+ /**
+ * Start date from which withdraw is allowed.
+ */
+ stamp_start: TalerProtocolTimestamp;
+
+ /**
+ * End date for withdrawing.
+ */
+ stamp_expire_withdraw: TalerProtocolTimestamp;
+
+ /**
+ * Expiration date after which the exchange can forget about
+ * the currency.
+ */
+ stamp_expire_legal: TalerProtocolTimestamp;
+
+ /**
+ * Date after which the coins of this denomination can't be
+ * deposited anymore.
+ */
+ stamp_expire_deposit: TalerProtocolTimestamp;
+
+ /**
+ * Signature over the denomination information by the exchange's master
+ * signing key.
+ */
+ master_sig: string;
+}
+
+/**
+ * Signature by the auditor that a particular denomination key is audited.
+ */
+export class AuditorDenomSig {
+ /**
+ * Denomination public key's hash.
+ */
+ denom_pub_h: string;
+
+ /**
+ * The signature.
+ */
+ auditor_sig: string;
+}
+
+/**
+ * Auditor information as given by the exchange in /keys.
+ */
+export class ExchangeAuditor {
+ /**
+ * Auditor's public key.
+ */
+ auditor_pub: string;
+
+ /**
+ * Base URL of the auditor.
+ */
+ auditor_url: string;
+
+ /**
+ * List of signatures for denominations by the auditor.
+ */
+ denomination_keys: AuditorDenomSig[];
+}
+
+export type ExchangeWithdrawValue =
+ | ExchangeRsaWithdrawValue
+ | ExchangeCsWithdrawValue;
+
+export interface ExchangeRsaWithdrawValue {
+ cipher: "RSA";
+}
+
+export interface ExchangeCsWithdrawValue {
+ cipher: "CS";
+
+ /**
+ * CSR R0 value
+ */
+ r_pub_0: string;
+
+ /**
+ * CSR R1 value
+ */
+ r_pub_1: string;
+}
+
+export interface RecoupRequest {
+ /**
+ * Hashed denomination public key of the coin we want to get
+ * paid back.
+ */
+ denom_pub_hash: string;
+
+ /**
+ * Signature over the coin public key by the denomination.
+ *
+ * The string variant is for the legacy exchange protocol.
+ */
+ denom_sig: UnblindedSignature;
+
+ /**
+ * Blinding key that was used during withdraw,
+ * used to prove that we were actually withdrawing the coin.
+ */
+ coin_blind_key_secret: string;
+
+ /**
+ * Signature of TALER_RecoupRequestPS created with the coin's private key.
+ */
+ coin_sig: string;
+
+ ewv: ExchangeWithdrawValue;
+}
+
+export interface RecoupRefreshRequest {
+ /**
+ * Hashed enomination public key of the coin we want to get
+ * paid back.
+ */
+ denom_pub_hash: string;
+
+ /**
+ * Signature over the coin public key by the denomination.
+ *
+ * The string variant is for the legacy exchange protocol.
+ */
+ denom_sig: UnblindedSignature;
+
+ /**
+ * Coin's blinding factor.
+ */
+ coin_blind_key_secret: string;
+
+ /**
+ * Signature of TALER_RecoupRefreshRequestPS created with
+ * the coin's private key.
+ */
+ coin_sig: string;
+
+ ewv: ExchangeWithdrawValue;
+}
+
+/**
+ * Response that we get from the exchange for a payback request.
+ */
+export interface RecoupConfirmation {
+ /**
+ * Public key of the reserve that will receive the payback.
+ */
+ reserve_pub?: string;
+
+ /**
+ * Public key of the old coin that will receive the recoup,
+ * provided if refreshed was true.
+ */
+ old_coin_pub?: string;
+}
+
+export type UnblindedSignature = RsaUnblindedSignature;
+
+export interface RsaUnblindedSignature {
+ cipher: DenomKeyType.Rsa;
+ rsa_signature: string;
+}
+
+/**
+ * Deposit permission for a single coin.
+ */
+export interface CoinDepositPermission {
+ /**
+ * Signature by the coin.
+ */
+ coin_sig: string;
+
+ /**
+ * Public key of the coin being spend.
+ */
+ coin_pub: string;
+
+ /**
+ * Signature made by the denomination public key.
+ *
+ * The string variant is for legacy protocol support.
+ */
+
+ ub_sig: UnblindedSignature;
+
+ /**
+ * The denomination public key associated with this coin.
+ */
+ h_denom: string;
+
+ /**
+ * The amount that is subtracted from this coin with this payment.
+ */
+ contribution: string;
+
+ /**
+ * URL of the exchange this coin was withdrawn from.
+ */
+ exchange_url: string;
+
+ minimum_age_sig?: EddsaSignatureString;
+
+ age_commitment?: Edx25519PublicKeyEnc[];
+
+ h_age_commitment?: string;
+}
+
+/**
+ * Information about an exchange as stored inside a
+ * merchant's contract terms.
+ */
+export interface ExchangeHandle {
+ // The exchange's base URL.
+ url: string;
+
+ // Master public key of the exchange.
+ master_pub: EddsaPublicKeyString;
+}
+
+export interface AuditorHandle {
+ /**
+ * Official name of the auditor.
+ */
+ name: string;
+
+ /**
+ * Master public signing key of the auditor.
+ */
+ auditor_pub: EddsaPublicKeyString;
+
+ /**
+ * Base URL of the auditor.
+ */
+ url: string;
+}
+
+// Delivery location, loosely modeled as a subset of
+// ISO20022's PostalAddress25.
+export interface Location {
+ // Nation with its own government.
+ country?: string;
+
+ // Identifies a subdivision of a country such as state, region, county.
+ country_subdivision?: string;
+
+ // Identifies a subdivision within a country sub-division.
+ district?: string;
+
+ // Name of a built-up area, with defined boundaries, and a local government.
+ town?: string;
+
+ // Specific location name within the town.
+ town_location?: string;
+
+ // Identifier consisting of a group of letters and/or numbers that
+ // is added to a postal address to assist the sorting of mail.
+ post_code?: string;
+
+ // Name of a street or thoroughfare.
+ street?: string;
+
+ // Name of the building or house.
+ building_name?: string;
+
+ // Number that identifies the position of a building on a street.
+ building_number?: string;
+
+ // Free-form address lines, should not exceed 7 elements.
+ address_lines?: string[];
+}
+
+export interface MerchantInfo {
+ // The merchant's legal name of business.
+ name: string;
+
+ // Label for a location with the business address of the merchant.
+ email?: string;
+
+ // Label for a location with the business address of the merchant.
+ website?: string;
+
+ // An optional base64-encoded product image.
+ logo?: ImageDataUrl;
+
+ // Label for a location with the business address of the merchant.
+ address?: Location;
+
+ // Label for a location that denotes the jurisdiction for disputes.
+ // Some of the typical fields for a location (such as a street address) may be absent.
+ jurisdiction?: Location;
+}
+
+export interface Tax {
+ // the name of the tax
+ name: string;
+
+ // amount paid in tax
+ tax: AmountString;
+}
+
+export interface Product {
+ // merchant-internal identifier for the product.
+ product_id?: string;
+
+ // Human-readable product description.
+ description: string;
+
+ // Map from IETF BCP 47 language tags to localized descriptions
+ description_i18n?: InternationalizedString;
+
+ // The number of units of the product to deliver to the customer.
+ quantity?: Integer;
+
+ // The unit in which the product is measured (liters, kilograms, packages, etc.)
+ unit?: string;
+
+ // The price of the product; this is the total price for quantity times unit of this product.
+ price?: AmountString;
+
+ // An optional base64-encoded product image
+ image?: ImageDataUrl;
+
+ // a list of taxes paid by the merchant for this product. Can be empty.
+ taxes?: Tax[];
+
+ // time indicating when this product should be delivered
+ delivery_date?: TalerProtocolTimestamp;
+}
+
+export interface InternationalizedString {
+ [lang_tag: string]: string;
+}
+
+/**
+ * Contract terms from a merchant.
+ * FIXME: Add type field!
+ */
+export interface MerchantContractTerms {
+ // The hash of the merchant instance's wire details.
+ h_wire: string;
+
+ // Specifies for how long the wallet should try to get an
+ // automatic refund for the purchase. If this field is
+ // present, the wallet should wait for a few seconds after
+ // the purchase and then automatically attempt to obtain
+ // a refund. The wallet should probe until "delay"
+ // after the payment was successful (i.e. via long polling
+ // or via explicit requests with exponential back-off).
+ //
+ // In particular, if the wallet is offline
+ // at that time, it MUST repeat the request until it gets
+ // one response from the merchant after the delay has expired.
+ // If the refund is granted, the wallet MUST automatically
+ // recover the payment. This is used in case a merchant
+ // knows that it might be unable to satisfy the contract and
+ // desires for the wallet to attempt to get the refund without any
+ // customer interaction. Note that it is NOT an error if the
+ // merchant does not grant a refund.
+ auto_refund?: TalerProtocolDuration;
+
+ // Wire transfer method identifier for the wire method associated with h_wire.
+ // The wallet may only select exchanges via a matching auditor if the
+ // exchange also supports this wire method.
+ // The wire transfer fees must be added based on this wire transfer method.
+ wire_method: string;
+
+ // Human-readable description of the whole purchase.
+ summary: string;
+
+ // Map from IETF BCP 47 language tags to localized summaries.
+ summary_i18n?: InternationalizedString;
+
+ // Unique, free-form identifier for the proposal.
+ // Must be unique within a merchant instance.
+ // For merchants that do not store proposals in their DB
+ // before the customer paid for them, the order_id can be used
+ // by the frontend to restore a proposal from the information
+ // encoded in it (such as a short product identifier and timestamp).
+ order_id: string;
+
+ // Total price for the transaction.
+ // The exchange will subtract deposit fees from that amount
+ // before transferring it to the merchant.
+ amount: string;
+
+ // Nonce generated by the wallet and echoed by the merchant
+ // in this field when the proposal is generated.
+ nonce: string;
+
+ // After this deadline, the merchant won't accept payments for the contract.
+ pay_deadline: TalerProtocolTimestamp;
+
+ // More info about the merchant, see below.
+ merchant: MerchantInfo;
+
+ // Merchant's public key used to sign this proposal; this information
+ // is typically added by the backend. Note that this can be an ephemeral key.
+ merchant_pub: string;
+
+ // Time indicating when the order should be delivered.
+ // May be overwritten by individual products.
+ delivery_date?: TalerProtocolTimestamp;
+
+ // Delivery location for (all!) products.
+ delivery_location?: Location;
+
+ // Exchanges that the merchant accepts even if it does not accept any auditors that audit them.
+ exchanges: ExchangeHandle[];
+
+ // List of products that are part of the purchase (see Product).
+ products?: Product[];
+
+ // After this deadline has passed, no refunds will be accepted.
+ refund_deadline: TalerProtocolTimestamp;
+
+ // Transfer deadline for the exchange. Must be in the
+ // deposit permissions of coins used to pay for this order.
+ wire_transfer_deadline: TalerProtocolTimestamp;
+
+ // Time when this contract was generated.
+ timestamp: TalerProtocolTimestamp;
+
+ // Base URL of the (public!) merchant backend API.
+ // Must be an absolute URL that ends with a slash.
+ merchant_base_url: string;
+
+ // URL that will show that the order was successful after
+ // it has been paid for. Optional, but either fulfillment_url
+ // or fulfillment_message must be specified in every
+ // contract terms.
+ //
+ // If a non-unique fulfillment URL is used, a customer can only
+ // buy the order once and will be redirected to a previous purchase
+ // when trying to buy an order with the same fulfillment URL a second
+ // time. This is useful for digital goods that a customer only needs
+ // to buy once but should be able to repeatedly download.
+ //
+ // For orders where the customer is expected to be able to make
+ // repeated purchases (for equivalent goods), the fulfillment URL
+ // should be made unique for every order. The easiest way to do
+ // this is to include a unique order ID in the fulfillment URL.
+ //
+ // When POSTing to the merchant, the placeholder text "${ORDER_ID}"
+ // is be replaced with the actual order ID (useful if the
+ // order ID is generated server-side and needs to be
+ // in the URL). Note that this placeholder can only be used once.
+ // Front-ends may use other means to generate a unique fulfillment URL.
+ fulfillment_url?: string;
+
+ // URL where the same contract could be ordered again (if
+ // available). Returned also at the public order endpoint
+ // for people other than the actual buyer (hence public,
+ // in case order IDs are guessable).
+ public_reorder_url?: string;
+
+ // Message shown to the customer after paying for the order.
+ // Either fulfillment_url or fulfillment_message must be specified.
+ fulfillment_message?: string;
+
+ // Map from IETF BCP 47 language tags to localized fulfillment
+ // messages.
+ fulfillment_message_i18n?: InternationalizedString;
+
+ // Maximum total deposit fee accepted by the merchant for this contract.
+ // Overrides defaults of the merchant instance.
+ max_fee: string;
+
+ // Extra data that is only interpreted by the merchant frontend.
+ // Useful when the merchant needs to store extra information on a
+ // contract without storing it separately in their database.
+ // Must really be an Object (not a string, integer, float or array).
+ extra?: any;
+
+ // Minimum age the buyer must have (in years). Default is 0.
+ // This value is at least as large as the maximum over all
+ // minimum age requirements of the products in this contract.
+ // It might also be set independent of any product, due to
+ // legal requirements.
+ minimum_age?: Integer;
+}
+
+/**
+ * Refund permission in the format that the merchant gives it to us.
+ */
+export interface MerchantAbortPayRefundDetails {
+ /**
+ * Amount to be refunded.
+ */
+ refund_amount: string;
+
+ /**
+ * Fee for the refund.
+ */
+ refund_fee: string;
+
+ /**
+ * Public key of the coin being refunded.
+ */
+ coin_pub: string;
+
+ /**
+ * Refund transaction ID between merchant and exchange.
+ */
+ rtransaction_id: number;
+
+ /**
+ * Exchange's key used for the signature.
+ */
+ exchange_pub?: string;
+
+ /**
+ * Exchange's signature to confirm the refund.
+ */
+ exchange_sig?: string;
+
+ /**
+ * Error replay from the exchange (if any).
+ */
+ exchange_reply?: any;
+
+ /**
+ * Error code from the exchange (if any).
+ */
+ exchange_code?: number;
+
+ /**
+ * HTTP status code of the exchange's response
+ * to the merchant's refund request.
+ */
+ exchange_http_status: number;
+}
+
+/**
+ * Planchet detail sent to the merchant.
+ */
+export interface TipPlanchetDetail {
+ /**
+ * Hashed denomination public key.
+ */
+ denom_pub_hash: string;
+
+ /**
+ * Coin's blinded public key.
+ */
+ coin_ev: CoinEnvelope;
+}
+
+/**
+ * Request sent to the merchant to pick up a tip.
+ */
+export interface TipPickupRequest {
+ /**
+ * Identifier of the tip.
+ */
+ tip_id: string;
+
+ /**
+ * List of planchets the wallet wants to use for the tip.
+ */
+ planchets: TipPlanchetDetail[];
+}
+
+/**
+ * Reserve signature, defined as separate class to facilitate
+ * schema validation.
+ */
+export interface MerchantBlindSigWrapperV1 {
+ /**
+ * Reserve signature.
+ */
+ blind_sig: string;
+}
+
+/**
+ * Response of the merchant
+ * to the TipPickupRequest.
+ */
+export interface MerchantTipResponseV1 {
+ /**
+ * The order of the signatures matches the planchets list.
+ */
+ blind_sigs: MerchantBlindSigWrapperV1[];
+}
+
+export interface MerchantBlindSigWrapperV2 {
+ blind_sig: BlindedDenominationSignature;
+}
+
+/**
+ * Response of the merchant
+ * to the TipPickupRequest.
+ */
+export interface MerchantTipResponseV2 {
+ /**
+ * The order of the signatures matches the planchets list.
+ */
+ blind_sigs: MerchantBlindSigWrapperV2[];
+}
+
+/**
+ * Element of the payback list that the
+ * exchange gives us in /keys.
+ */
+export class Recoup {
+ /**
+ * The hash of the denomination public key for which the payback is offered.
+ */
+ h_denom_pub: string;
+}
+
+/**
+ * Structure of one exchange signing key in the /keys response.
+ */
+export class ExchangeSignKeyJson {
+ stamp_start: TalerProtocolTimestamp;
+ stamp_expire: TalerProtocolTimestamp;
+ stamp_end: TalerProtocolTimestamp;
+ key: EddsaPublicKeyString;
+ master_sig: EddsaSignatureString;
+}
+
+/**
+ * Structure that the exchange gives us in /keys.
+ */
+export class ExchangeKeysJson {
+ /**
+ * Canonical, public base URL of the exchange.
+ */
+ base_url: string;
+
+ currency: string;
+
+ /**
+ * The exchange's master public key.
+ */
+ master_public_key: string;
+
+ /**
+ * The list of auditors (partially) auditing the exchange.
+ */
+ auditors: ExchangeAuditor[];
+
+ /**
+ * Timestamp when this response was issued.
+ */
+ list_issue_date: TalerProtocolTimestamp;
+
+ /**
+ * List of revoked denominations.
+ */
+ recoup?: Recoup[];
+
+ /**
+ * Short-lived signing keys used to sign online
+ * responses.
+ */
+ signkeys: ExchangeSignKeyJson[];
+
+ /**
+ * Protocol version.
+ */
+ version: string;
+
+ reserve_closing_delay: TalerProtocolDuration;
+
+ global_fees: GlobalFees[];
+
+ accounts: ExchangeWireAccount[];
+
+ wire_fees: { [methodName: string]: WireFeesJson[] };
+
+ denominations: DenomGroup[];
+}
+
+export type DenomGroup =
+ | DenomGroupRsa
+ | DenomGroupCs
+ | DenomGroupRsaAgeRestricted
+ | DenomGroupCsAgeRestricted;
+
+export interface DenomGroupCommon {
+ // How much are coins of this denomination worth?
+ value: AmountString;
+
+ // Fee charged by the exchange for withdrawing a coin of this denomination.
+ fee_withdraw: AmountString;
+
+ // Fee charged by the exchange for depositing a coin of this denomination.
+ fee_deposit: AmountString;
+
+ // Fee charged by the exchange for refreshing a coin of this denomination.
+ fee_refresh: AmountString;
+
+ // Fee charged by the exchange for refunding a coin of this denomination.
+ fee_refund: AmountString;
+
+ // XOR of all the SHA-512 hash values of the denominations' public keys
+ // in this group. Note that for hashing, the binary format of the
+ // public keys is used, and not their base32 encoding.
+ hash: HashCodeString;
+}
+
+export interface DenomCommon {
+ // Signature of TALER_DenominationKeyValidityPS.
+ master_sig: EddsaSignatureString;
+
+ // When does the denomination key become valid?
+ stamp_start: TalerProtocolTimestamp;
+
+ // When is it no longer possible to deposit coins
+ // of this denomination?
+ stamp_expire_withdraw: TalerProtocolTimestamp;
+
+ // Timestamp indicating by when legal disputes relating to these coins must
+ // be settled, as the exchange will afterwards destroy its evidence relating to
+ // transactions involving this coin.
+ stamp_expire_legal: TalerProtocolTimestamp;
+
+ stamp_expire_deposit: TalerProtocolTimestamp;
+
+ // Set to 'true' if the exchange somehow "lost"
+ // the private key. The denomination was not
+ // necessarily revoked, but still cannot be used
+ // to withdraw coins at this time (theoretically,
+ // the private key could be recovered in the
+ // future; coins signed with the private key
+ // remain valid).
+ lost?: boolean;
+}
+
+export type RsaPublicKeySring = string;
+export type AgeMask = number;
+export type ImageDataUrl = string;
+
+/**
+ * 32-byte value representing a point on Curve25519.
+ */
+export type Cs25519Point = string;
+
+export interface DenomGroupRsa extends DenomGroupCommon {
+ cipher: "RSA";
+
+ denoms: ({
+ rsa_pub: RsaPublicKeySring;
+ } & DenomCommon)[];
+}
+
+export interface DenomGroupRsaAgeRestricted extends DenomGroupCommon {
+ cipher: "RSA+age_restricted";
+ age_mask: AgeMask;
+
+ denoms: ({
+ rsa_pub: RsaPublicKeySring;
+ } & DenomCommon)[];
+}
+
+export interface DenomGroupCs extends DenomGroupCommon {
+ cipher: "CS";
+ age_mask: AgeMask;
+
+ denoms: ({
+ cs_pub: Cs25519Point;
+ } & DenomCommon)[];
+}
+
+export interface DenomGroupCsAgeRestricted extends DenomGroupCommon {
+ cipher: "CS+age_restricted";
+ age_mask: AgeMask;
+
+ denoms: ({
+ cs_pub: Cs25519Point;
+ } & DenomCommon)[];
+}
+
+export interface GlobalFees {
+ // What date (inclusive) does these fees go into effect?
+ start_date: TalerProtocolTimestamp;
+
+ // What date (exclusive) does this fees stop going into effect?
+ end_date: TalerProtocolTimestamp;
+
+ // Account history fee, charged when a user wants to
+ // obtain a reserve/account history.
+ history_fee: AmountString;
+
+ // Annual fee charged for having an open account at the
+ // exchange. Charged to the account. If the account
+ // balance is insufficient to cover this fee, the account
+ // is automatically deleted/closed. (Note that the exchange
+ // will keep the account history around for longer for
+ // regulatory reasons.)
+ account_fee: AmountString;
+
+ // Purse fee, charged only if a purse is abandoned
+ // and was not covered by the account limit.
+ purse_fee: AmountString;
+
+ // How long will the exchange preserve the account history?
+ // After an account was deleted/closed, the exchange will
+ // retain the account history for legal reasons until this time.
+ history_expiration: TalerProtocolDuration;
+
+ // Non-negative number of concurrent purses that any
+ // account holder is allowed to create without having
+ // to pay the purse_fee.
+ purse_account_limit: number;
+
+ // How long does an exchange keep a purse around after a purse
+ // has expired (or been successfully merged)? A 'GET' request
+ // for a purse will succeed until the purse expiration time
+ // plus this value.
+ purse_timeout: TalerProtocolDuration;
+
+ // Signature of TALER_GlobalFeesPS.
+ master_sig: string;
+}
+/**
+ * Wire fees as announced by the exchange.
+ */
+export class WireFeesJson {
+ /**
+ * Cost of a wire transfer.
+ */
+ wire_fee: string;
+
+ /**
+ * Cost of clising a reserve.
+ */
+ closing_fee: string;
+
+ /**
+ * Signature made with the exchange's master key.
+ */
+ sig: string;
+
+ /**
+ * Date from which the fee applies.
+ */
+ start_date: TalerProtocolTimestamp;
+
+ /**
+ * Data after which the fee doesn't apply anymore.
+ */
+ end_date: TalerProtocolTimestamp;
+}
+
+/**
+ * Proposal returned from the contract URL.
+ */
+export class Proposal {
+ /**
+ * Contract terms for the propoal.
+ * Raw, un-decoded JSON object.
+ */
+ contract_terms: any;
+
+ /**
+ * Signature over contract, made by the merchant. The public key used for signing
+ * must be contract_terms.merchant_pub.
+ */
+ sig: string;
+}
+
+/**
+ * Response from the internal merchant API.
+ */
+export class CheckPaymentResponse {
+ order_status: string;
+ refunded: boolean | undefined;
+ refunded_amount: string | undefined;
+ contract_terms: any | undefined;
+ taler_pay_uri: string | undefined;
+ contract_url: string | undefined;
+}
+
+/**
+ * Response from the bank.
+ */
+export class WithdrawOperationStatusResponse {
+ status: "selected" | "aborted" | "confirmed" | "pending";
+
+ selection_done: boolean;
+
+ transfer_done: boolean;
+
+ aborted: boolean;
+
+ amount: string;
+
+ sender_wire?: string;
+
+ suggested_exchange?: string;
+
+ confirm_transfer_url?: string;
+
+ wire_types: string[];
+}
+
+/**
+ * Response from the merchant.
+ */
+export class RewardPickupGetResponse {
+ reward_amount: string;
+
+ exchange_url: string;
+
+ next_url?: string;
+
+ expiration: TalerProtocolTimestamp;
+}
+
+export enum DenomKeyType {
+ Rsa = "RSA",
+ ClauseSchnorr = "CS",
+}
+
+export namespace DenomKeyType {
+ export function toIntTag(t: DenomKeyType): number {
+ switch (t) {
+ case DenomKeyType.Rsa:
+ return 1;
+ case DenomKeyType.ClauseSchnorr:
+ return 2;
+ }
+ }
+}
+
+export interface RsaBlindedDenominationSignature {
+ cipher: DenomKeyType.Rsa;
+ blinded_rsa_signature: string;
+}
+
+export interface CSBlindedDenominationSignature {
+ cipher: DenomKeyType.ClauseSchnorr;
+}
+
+export type BlindedDenominationSignature =
+ | RsaBlindedDenominationSignature
+ | CSBlindedDenominationSignature;
+
+export const codecForRsaBlindedDenominationSignature = () =>
+ buildCodecForObject<RsaBlindedDenominationSignature>()
+ .property("cipher", codecForConstString(DenomKeyType.Rsa))
+ .property("blinded_rsa_signature", codecForString())
+ .build("RsaBlindedDenominationSignature");
+
+export const codecForBlindedDenominationSignature = () =>
+ buildCodecForUnion<BlindedDenominationSignature>()
+ .discriminateOn("cipher")
+ .alternative(DenomKeyType.Rsa, codecForRsaBlindedDenominationSignature())
+ .build("BlindedDenominationSignature");
+
+export class ExchangeWithdrawResponse {
+ ev_sig: BlindedDenominationSignature;
+}
+
+export class ExchangeWithdrawBatchResponse {
+ ev_sigs: ExchangeWithdrawResponse[];
+}
+
+export interface MerchantPayResponse {
+ sig: string;
+ pos_confirmation?: string;
+}
+
+export interface ExchangeMeltRequest {
+ coin_pub: CoinPublicKeyString;
+ confirm_sig: EddsaSignatureString;
+ denom_pub_hash: HashCodeString;
+ denom_sig: UnblindedSignature;
+ rc: string;
+ value_with_fee: AmountString;
+ age_commitment_hash?: HashCodeString;
+}
+
+export interface ExchangeMeltResponse {
+ /**
+ * Which of the kappa indices does the client not have to reveal.
+ */
+ noreveal_index: number;
+
+ /**
+ * Signature of TALER_RefreshMeltConfirmationPS whereby the exchange
+ * affirms the successful melt and confirming the noreveal_index
+ */
+ exchange_sig: EddsaSignatureString;
+
+ /*
+ * public EdDSA key of the exchange that was used to generate the signature.
+ * Should match one of the exchange's signing keys from /keys. Again given
+ * explicitly as the client might otherwise be confused by clock skew as to
+ * which signing key was used.
+ */
+ exchange_pub: EddsaPublicKeyString;
+
+ /*
+ * Base URL to use for operations on the refresh context
+ * (so the reveal operation). If not given,
+ * the base URL is the same as the one used for this request.
+ * Can be used if the base URL for /refreshes/ differs from that
+ * for /coins/, i.e. for load balancing. Clients SHOULD
+ * respect the refresh_base_url if provided. Any HTTP server
+ * belonging to an exchange MUST generate a 307 or 308 redirection
+ * to the correct base URL should a client uses the wrong base
+ * URL, or if the base URL has changed since the melt.
+ *
+ * When melting the same coin twice (technically allowed
+ * as the response might have been lost on the network),
+ * the exchange may return different values for the refresh_base_url.
+ */
+ refresh_base_url?: string;
+}
+
+export interface ExchangeRevealItem {
+ ev_sig: BlindedDenominationSignature;
+}
+
+export interface ExchangeRevealResponse {
+ // List of the exchange's blinded RSA signatures on the new coins.
+ ev_sigs: ExchangeRevealItem[];
+}
+
+interface MerchantOrderStatusPaid {
+ // Was the payment refunded (even partially, via refund or abort)?
+ refunded: boolean;
+
+ // Is any amount of the refund still waiting to be picked up (even partially)?
+ refund_pending: boolean;
+
+ // Amount that was refunded in total.
+ refund_amount: AmountString;
+
+ // Amount that already taken by the wallet.
+ refund_taken: AmountString;
+}
+
+interface MerchantOrderRefundResponse {
+ /**
+ * Amount that was refunded in total.
+ */
+ refund_amount: AmountString;
+
+ /**
+ * Successful refunds for this payment, empty array for none.
+ */
+ refunds: MerchantCoinRefundStatus[];
+
+ /**
+ * Public key of the merchant.
+ */
+ merchant_pub: EddsaPublicKeyString;
+}
+
+export type MerchantCoinRefundStatus =
+ | MerchantCoinRefundSuccessStatus
+ | MerchantCoinRefundFailureStatus;
+
+export interface MerchantCoinRefundSuccessStatus {
+ type: "success";
+
+ // HTTP status of the exchange request, 200 (integer) required for refund confirmations.
+ exchange_status: 200;
+
+ // the EdDSA :ref:signature (binary-only) with purpose
+ // TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND using a current signing key of the
+ // exchange affirming the successful refund
+ exchange_sig: EddsaSignatureString;
+
+ // public EdDSA key of the exchange that was used to generate the signature.
+ // Should match one of the exchange's signing keys from /keys. It is given
+ // explicitly as the client might otherwise be confused by clock skew as to
+ // which signing key was used.
+ exchange_pub: EddsaPublicKeyString;
+
+ // Refund transaction ID.
+ rtransaction_id: number;
+
+ // public key of a coin that was refunded
+ coin_pub: EddsaPublicKeyString;
+
+ // Amount that was refunded, including refund fee charged by the exchange
+ // to the customer.
+ refund_amount: AmountString;
+
+ execution_time: TalerProtocolTimestamp;
+}
+
+export interface MerchantCoinRefundFailureStatus {
+ type: "failure";
+
+ // HTTP status of the exchange request, must NOT be 200.
+ exchange_status: number;
+
+ // Taler error code from the exchange reply, if available.
+ exchange_code?: number;
+
+ // If available, HTTP reply from the exchange.
+ exchange_reply?: any;
+
+ // Refund transaction ID.
+ rtransaction_id: number;
+
+ // public key of a coin that was refunded
+ coin_pub: EddsaPublicKeyString;
+
+ // Amount that was refunded, including refund fee charged by the exchange
+ // to the customer.
+ refund_amount: AmountString;
+
+ execution_time: TalerProtocolTimestamp;
+}
+
+export interface MerchantOrderStatusUnpaid {
+ /**
+ * URI that the wallet must process to complete the payment.
+ */
+ taler_pay_uri: string;
+
+ /**
+ * Alternative order ID which was paid for already in the same session.
+ *
+ * Only given if the same product was purchased before in the same session.
+ */
+ already_paid_order_id?: string;
+}
+
+/**
+ * Response body for the following endpoint:
+ *
+ * POST {talerBankIntegrationApi}/withdrawal-operation/{wopid}
+ */
+export interface BankWithdrawalOperationPostResponse {
+ // Current status of the operation
+ // pending: the operation is pending parameters selection (exchange and reserve public key)
+ // selected: the operations has been selected and is pending confirmation
+ // aborted: the operation has been aborted
+ // confirmed: the transfer has been confirmed and registered by the bank
+ status: "selected" | "aborted" | "confirmed" | "pending";
+
+ // URL that the user needs to navigate to in order to
+ // complete some final confirmation (e.g. 2FA).
+ //
+ // Only applicable when status is selected or pending.
+ // It may contain withdrawal operation id
+ confirm_transfer_url?: string;
+
+ // Deprecated field use status instead
+ // The transfer has been confirmed and registered by the bank.
+ // Does not guarantee that the funds have arrived at the exchange already.
+ transfer_done: boolean;
+}
+
+export const codeForBankWithdrawalOperationPostResponse =
+ (): Codec<BankWithdrawalOperationPostResponse> =>
+ buildCodecForObject<BankWithdrawalOperationPostResponse>()
+ .property(
+ "status",
+ codecForEither(
+ codecForConstString("selected"),
+ codecForConstString("confirmed"),
+ codecForConstString("aborted"),
+ codecForConstString("pending"),
+ ),
+ )
+ .property("confirm_transfer_url", codecOptional(codecForString()))
+ .property("transfer_done", codecForBoolean())
+ .build("BankWithdrawalOperationPostResponse");
+
+export type DenominationPubKey = RsaDenominationPubKey | CsDenominationPubKey;
+
+export interface RsaDenominationPubKey {
+ readonly cipher: DenomKeyType.Rsa;
+ readonly rsa_public_key: string;
+ readonly age_mask: number;
+}
+
+export interface CsDenominationPubKey {
+ readonly cipher: DenomKeyType.ClauseSchnorr;
+ readonly age_mask: number;
+ readonly cs_public_key: string;
+}
+
+export namespace DenominationPubKey {
+ export function cmp(
+ p1: DenominationPubKey,
+ p2: DenominationPubKey,
+ ): -1 | 0 | 1 {
+ if (p1.cipher < p2.cipher) {
+ return -1;
+ } else if (p1.cipher > p2.cipher) {
+ return +1;
+ } else if (
+ p1.cipher === DenomKeyType.Rsa &&
+ p2.cipher === DenomKeyType.Rsa
+ ) {
+ if ((p1.age_mask ?? 0) < (p2.age_mask ?? 0)) {
+ return -1;
+ } else if ((p1.age_mask ?? 0) > (p2.age_mask ?? 0)) {
+ return 1;
+ }
+ return strcmp(p1.rsa_public_key, p2.rsa_public_key);
+ } else if (
+ p1.cipher === DenomKeyType.ClauseSchnorr &&
+ p2.cipher === DenomKeyType.ClauseSchnorr
+ ) {
+ if ((p1.age_mask ?? 0) < (p2.age_mask ?? 0)) {
+ return -1;
+ } else if ((p1.age_mask ?? 0) > (p2.age_mask ?? 0)) {
+ return 1;
+ }
+ return strcmp(p1.cs_public_key, p2.cs_public_key);
+ } else {
+ throw Error("unsupported cipher");
+ }
+ }
+}
+
+export const codecForRsaDenominationPubKey = () =>
+ buildCodecForObject<RsaDenominationPubKey>()
+ .property("cipher", codecForConstString(DenomKeyType.Rsa))
+ .property("rsa_public_key", codecForString())
+ .property("age_mask", codecForNumber())
+ .build("DenominationPubKey");
+
+export const codecForCsDenominationPubKey = () =>
+ buildCodecForObject<CsDenominationPubKey>()
+ .property("cipher", codecForConstString(DenomKeyType.ClauseSchnorr))
+ .property("cs_public_key", codecForString())
+ .property("age_mask", codecForNumber())
+ .build("CsDenominationPubKey");
+
+export const codecForDenominationPubKey = () =>
+ buildCodecForUnion<DenominationPubKey>()
+ .discriminateOn("cipher")
+ .alternative(DenomKeyType.Rsa, codecForRsaDenominationPubKey())
+ .alternative(DenomKeyType.ClauseSchnorr, codecForCsDenominationPubKey())
+ .build("DenominationPubKey");
+
+declare const __amount_str: unique symbol;
+export type AmountString = string & { [__amount_str]: true };
+// export type AmountString = string;
+export type Base32String = string;
+export type EddsaSignatureString = string;
+export type EddsaPublicKeyString = string;
+export type CoinPublicKeyString = string;
+
+export const codecForDenomination = (): Codec<ExchangeDenomination> =>
+ buildCodecForObject<ExchangeDenomination>()
+ .property("value", codecForString())
+ .property("denom_pub", codecForDenominationPubKey())
+ .property("fee_withdraw", codecForString())
+ .property("fee_deposit", codecForString())
+ .property("fee_refresh", codecForString())
+ .property("fee_refund", codecForString())
+ .property("stamp_start", codecForTimestamp)
+ .property("stamp_expire_withdraw", codecForTimestamp)
+ .property("stamp_expire_legal", codecForTimestamp)
+ .property("stamp_expire_deposit", codecForTimestamp)
+ .property("master_sig", codecForString())
+ .build("Denomination");
+
+export const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> =>
+ buildCodecForObject<AuditorDenomSig>()
+ .property("denom_pub_h", codecForString())
+ .property("auditor_sig", codecForString())
+ .build("AuditorDenomSig");
+
+export const codecForAuditor = (): Codec<ExchangeAuditor> =>
+ buildCodecForObject<ExchangeAuditor>()
+ .property("auditor_pub", codecForString())
+ .property("auditor_url", codecForString())
+ .property("denomination_keys", codecForList(codecForAuditorDenomSig()))
+ .build("Auditor");
+
+export const codecForExchangeHandle = (): Codec<ExchangeHandle> =>
+ buildCodecForObject<ExchangeHandle>()
+ .property("master_pub", codecForString())
+ .property("url", codecForString())
+ .build("ExchangeHandle");
+
+export const codecForAuditorHandle = (): Codec<AuditorHandle> =>
+ buildCodecForObject<AuditorHandle>()
+ .property("name", codecForString())
+ .property("auditor_pub", codecForString())
+ .property("url", codecForString())
+ .build("AuditorHandle");
+
+export const codecForLocation = (): Codec<Location> =>
+ buildCodecForObject<Location>()
+ .property("country", codecOptional(codecForString()))
+ .property("country_subdivision", codecOptional(codecForString()))
+ .property("building_name", codecOptional(codecForString()))
+ .property("building_number", codecOptional(codecForString()))
+ .property("district", codecOptional(codecForString()))
+ .property("street", codecOptional(codecForString()))
+ .property("post_code", codecOptional(codecForString()))
+ .property("town", codecOptional(codecForString()))
+ .property("town_location", codecOptional(codecForString()))
+ .property("address_lines", codecOptional(codecForList(codecForString())))
+ .build("Location");
+
+export const codecForMerchantInfo = (): Codec<MerchantInfo> =>
+ buildCodecForObject<MerchantInfo>()
+ .property("name", codecForString())
+ .property("address", codecOptional(codecForLocation()))
+ .property("jurisdiction", codecOptional(codecForLocation()))
+ .build("MerchantInfo");
+
+export const codecForInternationalizedString =
+ (): Codec<InternationalizedString> => codecForMap(codecForString());
+
+export const codecForMerchantContractTerms = (): Codec<MerchantContractTerms> =>
+ buildCodecForObject<MerchantContractTerms>()
+ .property("order_id", codecForString())
+ .property("fulfillment_url", codecOptional(codecForString()))
+ .property("fulfillment_message", codecOptional(codecForString()))
+ .property(
+ "fulfillment_message_i18n",
+ codecOptional(codecForInternationalizedString()),
+ )
+ .property("merchant_base_url", codecForString())
+ .property("h_wire", codecForString())
+ .property("auto_refund", codecOptional(codecForDuration))
+ .property("wire_method", codecForString())
+ .property("summary", codecForString())
+ .property("summary_i18n", codecOptional(codecForInternationalizedString()))
+ .property("nonce", codecForString())
+ .property("amount", codecForAmountString())
+ .property("pay_deadline", codecForTimestamp)
+ .property("refund_deadline", codecForTimestamp)
+ .property("wire_transfer_deadline", codecForTimestamp)
+ .property("timestamp", codecForTimestamp)
+ .property("delivery_location", codecOptional(codecForLocation()))
+ .property("delivery_date", codecOptional(codecForTimestamp))
+ .property("max_fee", codecForAmountString())
+ .property("merchant", codecForMerchantInfo())
+ .property("merchant_pub", codecForString())
+ .property("exchanges", codecForList(codecForExchangeHandle()))
+ .property("products", codecOptional(codecForList(codecForProduct())))
+ .property("extra", codecForAny())
+ .property("minimum_age", codecOptional(codecForNumber()))
+ .build("MerchantContractTerms");
+
+export const codecForPeerContractTerms = (): Codec<PeerContractTerms> =>
+ buildCodecForObject<PeerContractTerms>()
+ .property("summary", codecForString())
+ .property("amount", codecForAmountString())
+ .property("purse_expiration", codecForTimestamp)
+ .build("PeerContractTerms");
+
+export const codecForMerchantRefundPermission =
+ (): Codec<MerchantAbortPayRefundDetails> =>
+ buildCodecForObject<MerchantAbortPayRefundDetails>()
+ .property("refund_amount", codecForAmountString())
+ .property("refund_fee", codecForAmountString())
+ .property("coin_pub", codecForString())
+ .property("rtransaction_id", codecForNumber())
+ .property("exchange_http_status", codecForNumber())
+ .property("exchange_code", codecOptional(codecForNumber()))
+ .property("exchange_reply", codecOptional(codecForAny()))
+ .property("exchange_sig", codecOptional(codecForString()))
+ .property("exchange_pub", codecOptional(codecForString()))
+ .build("MerchantRefundPermission");
+
+export const codecForBlindSigWrapperV2 = (): Codec<MerchantBlindSigWrapperV2> =>
+ buildCodecForObject<MerchantBlindSigWrapperV2>()
+ .property("blind_sig", codecForBlindedDenominationSignature())
+ .build("MerchantBlindSigWrapperV2");
+
+export const codecForMerchantTipResponseV2 = (): Codec<MerchantTipResponseV2> =>
+ buildCodecForObject<MerchantTipResponseV2>()
+ .property("blind_sigs", codecForList(codecForBlindSigWrapperV2()))
+ .build("MerchantTipResponseV2");
+
+export const codecForRecoup = (): Codec<Recoup> =>
+ buildCodecForObject<Recoup>()
+ .property("h_denom_pub", codecForString())
+ .build("Recoup");
+
+export const codecForExchangeSigningKey = (): Codec<ExchangeSignKeyJson> =>
+ buildCodecForObject<ExchangeSignKeyJson>()
+ .property("key", codecForString())
+ .property("master_sig", codecForString())
+ .property("stamp_end", codecForTimestamp)
+ .property("stamp_start", codecForTimestamp)
+ .property("stamp_expire", codecForTimestamp)
+ .build("ExchangeSignKeyJson");
+
+export const codecForGlobalFees = (): Codec<GlobalFees> =>
+ buildCodecForObject<GlobalFees>()
+ .property("start_date", codecForTimestamp)
+ .property("end_date", codecForTimestamp)
+ .property("history_fee", codecForAmountString())
+ .property("account_fee", codecForAmountString())
+ .property("purse_fee", codecForAmountString())
+ .property("history_expiration", codecForDuration)
+ .property("purse_account_limit", codecForNumber())
+ .property("purse_timeout", codecForDuration)
+ .property("master_sig", codecForString())
+ .build("GlobalFees");
+
+// FIXME: Validate properly!
+export const codecForNgDenominations: Codec<DenomGroup> = codecForAny();
+
+export const codecForExchangeKeysJson = (): Codec<ExchangeKeysJson> =>
+ buildCodecForObject<ExchangeKeysJson>()
+ .property("base_url", codecForString())
+ .property("currency", codecForString())
+ .property("master_public_key", codecForString())
+ .property("auditors", codecForList(codecForAuditor()))
+ .property("list_issue_date", codecForTimestamp)
+ .property("recoup", codecOptional(codecForList(codecForRecoup())))
+ .property("signkeys", codecForList(codecForExchangeSigningKey()))
+ .property("version", codecForString())
+ .property("reserve_closing_delay", codecForDuration)
+ .property("global_fees", codecForList(codecForGlobalFees()))
+ .property("accounts", codecForList(codecForExchangeWireAccount()))
+ .property("wire_fees", codecForMap(codecForList(codecForWireFeesJson())))
+ .property("denominations", codecForList(codecForNgDenominations))
+ .build("ExchangeKeysJson");
+
+export const codecForWireFeesJson = (): Codec<WireFeesJson> =>
+ buildCodecForObject<WireFeesJson>()
+ .property("wire_fee", codecForString())
+ .property("closing_fee", codecForString())
+ .property("sig", codecForString())
+ .property("start_date", codecForTimestamp)
+ .property("end_date", codecForTimestamp)
+ .build("WireFeesJson");
+
+export const codecForProposal = (): Codec<Proposal> =>
+ buildCodecForObject<Proposal>()
+ .property("contract_terms", codecForAny())
+ .property("sig", codecForString())
+ .build("Proposal");
+
+export const codecForCheckPaymentResponse = (): Codec<CheckPaymentResponse> =>
+ buildCodecForObject<CheckPaymentResponse>()
+ .property("order_status", codecForString())
+ .property("refunded", codecOptional(codecForBoolean()))
+ .property("refunded_amount", codecOptional(codecForString()))
+ .property("contract_terms", codecOptional(codecForAny()))
+ .property("taler_pay_uri", codecOptional(codecForString()))
+ .property("contract_url", codecOptional(codecForString()))
+ .build("CheckPaymentResponse");
+
+export const codecForWithdrawOperationStatusResponse =
+ (): Codec<WithdrawOperationStatusResponse> =>
+ buildCodecForObject<WithdrawOperationStatusResponse>()
+ .property(
+ "status",
+ codecForEither(
+ codecForConstString("selected"),
+ codecForConstString("confirmed"),
+ codecForConstString("aborted"),
+ codecForConstString("pending"),
+ ),
+ )
+ .property("selection_done", codecForBoolean())
+ .property("transfer_done", codecForBoolean())
+ .property("aborted", codecForBoolean())
+ .property("amount", codecForString())
+ .property("sender_wire", codecOptional(codecForString()))
+ .property("suggested_exchange", codecOptional(codecForString()))
+ .property("confirm_transfer_url", codecOptional(codecForString()))
+ .property("wire_types", codecForList(codecForString()))
+ .build("WithdrawOperationStatusResponse");
+
+export const codecForRewardPickupGetResponse =
+ (): Codec<RewardPickupGetResponse> =>
+ buildCodecForObject<RewardPickupGetResponse>()
+ .property("reward_amount", codecForString())
+ .property("exchange_url", codecForString())
+ .property("next_url", codecOptional(codecForString()))
+ .property("expiration", codecForTimestamp)
+ .build("TipPickupGetResponse");
+
+export const codecForRecoupConfirmation = (): Codec<RecoupConfirmation> =>
+ buildCodecForObject<RecoupConfirmation>()
+ .property("reserve_pub", codecOptional(codecForString()))
+ .property("old_coin_pub", codecOptional(codecForString()))
+ .build("RecoupConfirmation");
+
+export const codecForWithdrawResponse = (): Codec<ExchangeWithdrawResponse> =>
+ buildCodecForObject<ExchangeWithdrawResponse>()
+ .property("ev_sig", codecForBlindedDenominationSignature())
+ .build("WithdrawResponse");
+
+export const codecForExchangeWithdrawBatchResponse =
+ (): Codec<ExchangeWithdrawBatchResponse> =>
+ buildCodecForObject<ExchangeWithdrawBatchResponse>()
+ .property("ev_sigs", codecForList(codecForWithdrawResponse()))
+ .build("WithdrawBatchResponse");
+
+export const codecForMerchantPayResponse = (): Codec<MerchantPayResponse> =>
+ buildCodecForObject<MerchantPayResponse>()
+ .property("sig", codecForString())
+ .property("pos_confirmation", codecOptional(codecForString()))
+ .build("MerchantPayResponse");
+
+export const codecForExchangeMeltResponse = (): Codec<ExchangeMeltResponse> =>
+ buildCodecForObject<ExchangeMeltResponse>()
+ .property("exchange_pub", codecForString())
+ .property("exchange_sig", codecForString())
+ .property("noreveal_index", codecForNumber())
+ .property("refresh_base_url", codecOptional(codecForString()))
+ .build("ExchangeMeltResponse");
+
+export const codecForExchangeRevealItem = (): Codec<ExchangeRevealItem> =>
+ buildCodecForObject<ExchangeRevealItem>()
+ .property("ev_sig", codecForBlindedDenominationSignature())
+ .build("ExchangeRevealItem");
+
+export const codecForExchangeRevealResponse =
+ (): Codec<ExchangeRevealResponse> =>
+ buildCodecForObject<ExchangeRevealResponse>()
+ .property("ev_sigs", codecForList(codecForExchangeRevealItem()))
+ .build("ExchangeRevealResponse");
+
+export const codecForMerchantOrderStatusPaid =
+ (): Codec<MerchantOrderStatusPaid> =>
+ buildCodecForObject<MerchantOrderStatusPaid>()
+ .property("refund_amount", codecForAmountString())
+ .property("refund_taken", codecForAmountString())
+ .property("refund_pending", codecForBoolean())
+ .property("refunded", codecForBoolean())
+ .build("MerchantOrderStatusPaid");
+
+export const codecForMerchantOrderStatusUnpaid =
+ (): Codec<MerchantOrderStatusUnpaid> =>
+ buildCodecForObject<MerchantOrderStatusUnpaid>()
+ .property("taler_pay_uri", codecForString())
+ .property("already_paid_order_id", codecOptional(codecForString()))
+ .build("MerchantOrderStatusUnpaid");
+
+export interface AbortRequest {
+ // hash of the order's contract terms (this is used to authenticate the
+ // wallet/customer in case $ORDER_ID is guessable).
+ h_contract: string;
+
+ // List of coins the wallet would like to see refunds for.
+ // (Should be limited to the coins for which the original
+ // payment succeeded, as far as the wallet knows.)
+ coins: AbortingCoin[];
+}
+
+export interface AbortingCoin {
+ // Public key of a coin for which the wallet is requesting an abort-related refund.
+ coin_pub: EddsaPublicKeyString;
+
+ // The amount to be refunded (matches the original contribution)
+ contribution: AmountString;
+
+ // URL of the exchange this coin was withdrawn from.
+ exchange_url: string;
+}
+
+export interface AbortResponse {
+ // List of refund responses about the coins that the wallet
+ // requested an abort for. In the same order as the 'coins'
+ // from the original request.
+ // The rtransaction_id is implied to be 0.
+ refunds: MerchantAbortPayRefundStatus[];
+}
+
+export type MerchantAbortPayRefundStatus =
+ | MerchantAbortPayRefundSuccessStatus
+ | MerchantAbortPayRefundFailureStatus;
+
+// Details about why a refund failed.
+export interface MerchantAbortPayRefundFailureStatus {
+ // Used as tag for the sum type RefundStatus sum type.
+ type: "failure";
+
+ // HTTP status of the exchange request, must NOT be 200.
+ exchange_status: number;
+
+ // Taler error code from the exchange reply, if available.
+ exchange_code?: number;
+
+ // If available, HTTP reply from the exchange.
+ exchange_reply?: unknown;
+}
+
+// Additional details needed to verify the refund confirmation signature
+// (h_contract_terms and merchant_pub) are already known
+// to the wallet and thus not included.
+export interface MerchantAbortPayRefundSuccessStatus {
+ // Used as tag for the sum type MerchantCoinRefundStatus sum type.
+ type: "success";
+
+ // HTTP status of the exchange request, 200 (integer) required for refund confirmations.
+ exchange_status: 200;
+
+ // the EdDSA :ref:signature (binary-only) with purpose
+ // TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND using a current signing key of the
+ // exchange affirming the successful refund
+ exchange_sig: string;
+
+ // public EdDSA key of the exchange that was used to generate the signature.
+ // Should match one of the exchange's signing keys from /keys. It is given
+ // explicitly as the client might otherwise be confused by clock skew as to
+ // which signing key was used.
+ exchange_pub: string;
+}
+
+export interface FutureKeysResponse {
+ future_denoms: any[];
+
+ future_signkeys: any[];
+
+ master_pub: string;
+
+ denom_secmod_public_key: string;
+
+ // Public key of the signkey security module.
+ signkey_secmod_public_key: string;
+}
+
+export const codecForKeysManagementResponse = (): Codec<FutureKeysResponse> =>
+ buildCodecForObject<FutureKeysResponse>()
+ .property("master_pub", codecForString())
+ .property("future_signkeys", codecForList(codecForAny()))
+ .property("future_denoms", codecForList(codecForAny()))
+ .property("denom_secmod_public_key", codecForAny())
+ .property("signkey_secmod_public_key", codecForAny())
+ .build("FutureKeysResponse");
+
+export interface MerchantConfigResponse {
+ currency: string;
+ name: string;
+ version: string;
+}
+
+export const codecForMerchantConfigResponse =
+ (): Codec<MerchantConfigResponse> =>
+ buildCodecForObject<MerchantConfigResponse>()
+ .property("currency", codecForString())
+ .property("name", codecForString())
+ .property("version", codecForString())
+ .build("MerchantConfigResponse");
+
+export enum ExchangeProtocolVersion {
+ /**
+ * Current version supported by the wallet.
+ */
+ V12 = 12,
+}
+
+export enum MerchantProtocolVersion {
+ /**
+ * Current version supported by the wallet.
+ */
+ V3 = 3,
+}
+
+export type CoinEnvelope = CoinEnvelopeRsa | CoinEnvelopeCs;
+
+export interface CoinEnvelopeRsa {
+ cipher: DenomKeyType.Rsa;
+ rsa_blinded_planchet: string;
+}
+
+export interface CoinEnvelopeCs {
+ cipher: DenomKeyType.ClauseSchnorr;
+ // FIXME: add remaining fields
+}
+
+export type HashCodeString = string;
+
+export interface ExchangeWithdrawRequest {
+ denom_pub_hash: HashCodeString;
+ reserve_sig: EddsaSignatureString;
+ coin_ev: CoinEnvelope;
+}
+
+export interface ExchangeBatchWithdrawRequest {
+ planchets: ExchangeWithdrawRequest[];
+}
+
+export interface ExchangeRefreshRevealRequest {
+ new_denoms_h: HashCodeString[];
+ coin_evs: CoinEnvelope[];
+ /**
+ * kappa - 1 transfer private keys (ephemeral ECDHE keys).
+ */
+ transfer_privs: string[];
+
+ transfer_pub: EddsaPublicKeyString;
+
+ link_sigs: EddsaSignatureString[];
+
+ /**
+ * Iff the corresponding denomination has support for age restriction,
+ * the client MUST provide the original age commitment, i.e. the vector
+ * of public keys.
+ */
+ old_age_commitment?: Edx25519PublicKeyEnc[];
+}
+
+interface DepositConfirmationSignature {
+ // The EdDSA signature of `TALER_DepositConfirmationPS` using a current
+ // `signing key of the exchange <sign-key-priv>` affirming the successful
+ // deposit and that the exchange will transfer the funds after the refund
+ // deadline, or as soon as possible if the refund deadline is zero.
+ exchange_sig: EddsaSignatureString;
+}
+
+export interface BatchDepositSuccess {
+ // Optional base URL of the exchange for looking up wire transfers
+ // associated with this transaction. If not given,
+ // the base URL is the same as the one used for this request.
+ // Can be used if the base URL for ``/transactions/`` differs from that
+ // for ``/coins/``, i.e. for load balancing. Clients SHOULD
+ // respect the ``transaction_base_url`` if provided. Any HTTP server
+ // belonging to an exchange MUST generate a 307 or 308 redirection
+ // to the correct base URL should a client uses the wrong base
+ // URL, or if the base URL has changed since the deposit.
+ transaction_base_url?: string;
+
+ // Timestamp when the deposit was received by the exchange.
+ exchange_timestamp: TalerProtocolTimestamp;
+
+ // `Public EdDSA key of the exchange <sign-key-pub>` that was used to
+ // generate the signature.
+ // Should match one of the exchange's signing keys from ``/keys``. It is given
+ // explicitly as the client might otherwise be confused by clock skew as to
+ // which signing key was used.
+ exchange_pub: EddsaPublicKeyString;
+
+ // Array of deposit confirmation signatures from the exchange
+ // Entries must be in the same order the coins were given
+ // in the batch deposit request.
+ exchange_sig: EddsaSignatureString;
+}
+
+export const codecForBatchDepositSuccess = (): Codec<BatchDepositSuccess> =>
+ buildCodecForObject<BatchDepositSuccess>()
+ .property("exchange_pub", codecForString())
+ .property("exchange_sig", codecForString())
+ .property("exchange_timestamp", codecForTimestamp)
+ .property("transaction_base_url", codecOptional(codecForString()))
+ .build("BatchDepositSuccess");
+
+export interface TrackTransactionWired {
+ // Raw wire transfer identifier of the deposit.
+ wtid: Base32String;
+
+ // When was the wire transfer given to the bank.
+ execution_time: TalerProtocolTimestamp;
+
+ // The contribution of this coin to the total (without fees)
+ coin_contribution: AmountString;
+
+ // Binary-only Signature_ with purpose TALER_SIGNATURE_EXCHANGE_CONFIRM_WIRE
+ // over a TALER_ConfirmWirePS
+ // whereby the exchange affirms the successful wire transfer.
+ exchange_sig: EddsaSignatureString;
+
+ // Public EdDSA key of the exchange that was used to generate the signature.
+ // Should match one of the exchange's signing keys from /keys. Again given
+ // explicitly as the client might otherwise be confused by clock skew as to
+ // which signing key was used.
+ exchange_pub: EddsaPublicKeyString;
+}
+
+export const codecForTackTransactionWired = (): Codec<TrackTransactionWired> =>
+ buildCodecForObject<TrackTransactionWired>()
+ .property("wtid", codecForString())
+ .property("execution_time", codecForTimestamp)
+ .property("coin_contribution", codecForAmountString())
+ .property("exchange_sig", codecForString())
+ .property("exchange_pub", codecForString())
+ .build("TackTransactionWired");
+
+interface TrackTransactionAccepted {
+ // Legitimization target that the merchant should
+ // use to check for its KYC status using
+ // the /kyc-check/$REQUIREMENT_ROW/... endpoint.
+ // Optional, not present if the deposit has not
+ // yet been aggregated to the point that a KYC
+ // need has been evaluated.
+ requirement_row?: number;
+
+ // True if the KYC check for the merchant has been
+ // satisfied. False does not mean that KYC
+ // is strictly needed, unless also a
+ // legitimization_uuid is provided.
+ kyc_ok: boolean;
+
+ // Time by which the exchange currently thinks the deposit will be executed.
+ // Actual execution may be later if the KYC check is not satisfied by then.
+ execution_time: TalerProtocolTimestamp;
+}
+
+export const codecForTackTransactionAccepted =
+ (): Codec<TrackTransactionAccepted> =>
+ buildCodecForObject<TrackTransactionAccepted>()
+ .property("requirement_row", codecOptional(codecForNumber()))
+ .property("kyc_ok", codecForBoolean())
+ .property("execution_time", codecForTimestamp)
+ .build("TackTransactionAccepted");
+
+export type TrackTransaction =
+ | ({ type: "accepted" } & TrackTransactionAccepted)
+ | ({ type: "wired" } & TrackTransactionWired);
+
+export interface PurseDeposit {
+ /**
+ * Amount to be deposited, can be a fraction of the
+ * coin's total value.
+ */
+ amount: AmountString;
+
+ /**
+ * Hash of denomination RSA key with which the coin is signed.
+ */
+ denom_pub_hash: HashCodeString;
+
+ /**
+ * Exchange's unblinded RSA signature of the coin.
+ */
+ ub_sig: UnblindedSignature;
+
+ /**
+ * Age commitment for the coin, if the denomination is age-restricted.
+ */
+ age_commitment?: string[];
+
+ /**
+ * Attestation for the minimum age, if the denomination is age-restricted.
+ */
+ attest?: string;
+
+ /**
+ * Signature over TALER_PurseDepositSignaturePS
+ * of purpose TALER_SIGNATURE_WALLET_PURSE_DEPOSIT
+ * made by the customer with the
+ * coin's private key.
+ */
+ coin_sig: EddsaSignatureString;
+
+ /**
+ * Public key of the coin being deposited into the purse.
+ */
+ coin_pub: EddsaPublicKeyString;
+}
+
+export interface ExchangePurseMergeRequest {
+ // payto://-URI of the account the purse is to be merged into.
+ // Must be of the form: 'payto://taler/$EXCHANGE_URL/$RESERVE_PUB'.
+ payto_uri: string;
+
+ // EdDSA signature of the account/reserve affirming the merge
+ // over a TALER_AccountMergeSignaturePS.
+ // Must be of purpose TALER_SIGNATURE_ACCOUNT_MERGE
+ reserve_sig: EddsaSignatureString;
+
+ // EdDSA signature of the purse private key affirming the merge
+ // over a TALER_PurseMergeSignaturePS.
+ // Must be of purpose TALER_SIGNATURE_PURSE_MERGE.
+ merge_sig: EddsaSignatureString;
+
+ // Client-side timestamp of when the merge request was made.
+ merge_timestamp: TalerProtocolTimestamp;
+}
+
+export interface ExchangeGetContractResponse {
+ purse_pub: string;
+ econtract_sig: string;
+ econtract: string;
+}
+
+export const codecForExchangeGetContractResponse =
+ (): Codec<ExchangeGetContractResponse> =>
+ buildCodecForObject<ExchangeGetContractResponse>()
+ .property("purse_pub", codecForString())
+ .property("econtract_sig", codecForString())
+ .property("econtract", codecForString())
+ .build("ExchangeGetContractResponse");
+
+/**
+ * Contract terms between two wallets (as opposed to a merchant and wallet).
+ */
+export interface PeerContractTerms {
+ amount: AmountString;
+ summary: string;
+ purse_expiration: TalerProtocolTimestamp;
+}
+
+export interface EncryptedContract {
+ // Encrypted contract.
+ econtract: string;
+
+ // Signature over the (encrypted) contract.
+ econtract_sig: string;
+
+ // Ephemeral public key for the DH operation to decrypt the encrypted contract.
+ contract_pub: string;
+}
+
+/**
+ * Payload for /reserves/{reserve_pub}/purse
+ * endpoint of the exchange.
+ */
+export interface ExchangeReservePurseRequest {
+ /**
+ * Minimum amount that must be credited to the reserve, that is
+ * the total value of the purse minus the deposit fees.
+ * If the deposit fees are lower, the contribution to the
+ * reserve can be higher!
+ */
+ purse_value: AmountString;
+
+ // Minimum age required for all coins deposited into the purse.
+ min_age: number;
+
+ // Purse fee the reserve owner is willing to pay
+ // for the purse creation. Optional, if not present
+ // the purse is to be created from the purse quota
+ // of the reserve.
+ purse_fee: AmountString;
+
+ // Optional encrypted contract, in case the buyer is
+ // proposing the contract and thus establishing the
+ // purse with the payment.
+ econtract?: EncryptedContract;
+
+ // EdDSA public key used to approve merges of this purse.
+ merge_pub: EddsaPublicKeyString;
+
+ // EdDSA signature of the purse private key affirming the merge
+ // over a TALER_PurseMergeSignaturePS.
+ // Must be of purpose TALER_SIGNATURE_PURSE_MERGE.
+ merge_sig: EddsaSignatureString;
+
+ // EdDSA signature of the account/reserve affirming the merge.
+ // Must be of purpose TALER_SIGNATURE_WALLET_ACCOUNT_MERGE
+ reserve_sig: EddsaSignatureString;
+
+ // Purse public key.
+ purse_pub: EddsaPublicKeyString;
+
+ // EdDSA signature of the purse over
+ // TALER_PurseRequestSignaturePS of
+ // purpose TALER_SIGNATURE_PURSE_REQUEST
+ // confirming that the
+ // above details hold for this purse.
+ purse_sig: EddsaSignatureString;
+
+ // SHA-512 hash of the contact of the purse.
+ h_contract_terms: HashCodeString;
+
+ // Client-side timestamp of when the merge request was made.
+ merge_timestamp: TalerProtocolTimestamp;
+
+ // Indicative time by which the purse should expire
+ // if it has not been paid.
+ purse_expiration: TalerProtocolTimestamp;
+}
+
+export interface ExchangePurseDeposits {
+ // Array of coins to deposit into the purse.
+ deposits: PurseDeposit[];
+}
+
+/**
+ * @deprecated batch deposit should be used.
+ */
+export interface ExchangeDepositRequest {
+ // Amount to be deposited, can be a fraction of the
+ // coin's total value.
+ contribution: AmountString;
+
+ // The merchant's account details.
+ // In case of an auction policy, it refers to the seller.
+ merchant_payto_uri: string;
+
+ // The salt is used to hide the payto_uri from customers
+ // when computing the h_wire of the merchant.
+ wire_salt: string;
+
+ // SHA-512 hash of the contract of the merchant with the customer. Further
+ // details are never disclosed to the exchange.
+ h_contract_terms: HashCodeString;
+
+ // Hash of denomination RSA key with which the coin is signed.
+ denom_pub_hash: HashCodeString;
+
+ // Exchange's unblinded RSA signature of the coin.
+ ub_sig: UnblindedSignature;
+
+ // Timestamp when the contract was finalized.
+ timestamp: TalerProtocolTimestamp;
+
+ // Indicative time by which the exchange undertakes to transfer the funds to
+ // the merchant, in case of successful payment. A wire transfer deadline of 'never'
+ // is not allowed.
+ wire_transfer_deadline: TalerProtocolTimestamp;
+
+ // EdDSA public key of the merchant, so that the client can identify the
+ // merchant for refund requests.
+ //
+ // THIS FIELD WILL BE DEPRECATED, once the refund mechanism becomes a
+ // policy via extension.
+ merchant_pub: EddsaPublicKeyString;
+
+ // Date until which the merchant can issue a refund to the customer via the
+ // exchange, to be omitted if refunds are not allowed.
+ //
+ // THIS FIELD WILL BE DEPRECATED, once the refund mechanism becomes a
+ // policy via extension.
+ refund_deadline?: TalerProtocolTimestamp;
+
+ // CAVEAT: THIS IS WORK IN PROGRESS
+ // (Optional) policy for the deposit.
+ // This might be a refund, auction or escrow policy.
+ //
+ // Note that support for policies is an optional feature of the exchange.
+ // Optional features are so called "extensions" in Taler. The exchange
+ // provides the list of supported extensions, including policies, in the
+ // ExtensionsManifestsResponse response to the /keys endpoint.
+ policy?: any;
+
+ // Signature over TALER_DepositRequestPS, made by the customer with the
+ // coin's private key.
+ coin_sig: EddsaSignatureString;
+
+ h_age_commitment?: string;
+}
+
+export type WireSalt = string;
+
+export interface ExchangeBatchDepositRequest {
+ // The merchant's account details.
+ merchant_payto_uri: string;
+
+ // The salt is used to hide the ``payto_uri`` from customers
+ // when computing the ``h_wire`` of the merchant.
+ wire_salt: WireSalt;
+
+ // SHA-512 hash of the contract of the merchant with the customer. Further
+ // details are never disclosed to the exchange.
+ h_contract_terms: HashCodeString;
+
+ // The list of coins that are going to be deposited with this Request.
+ coins: BatchDepositRequestCoin[];
+
+ // Timestamp when the contract was finalized.
+ timestamp: TalerProtocolTimestamp;
+
+ // Indicative time by which the exchange undertakes to transfer the funds to
+ // the merchant, in case of successful payment. A wire transfer deadline of 'never'
+ // is not allowed.
+ wire_transfer_deadline: TalerProtocolTimestamp;
+
+ // EdDSA `public key of the merchant <merchant-pub>`, so that the client can identify the
+ // merchant for refund requests.
+ merchant_pub: EddsaPublicKeyString;
+
+ // Date until which the merchant can issue a refund to the customer via the
+ // exchange, to be omitted if refunds are not allowed.
+ //
+ // THIS FIELD WILL BE DEPRECATED, once the refund mechanism becomes a
+ // policy via extension.
+ refund_deadline?: TalerProtocolTimestamp;
+
+ // CAVEAT: THIS IS WORK IN PROGRESS
+ // (Optional) policy for the batch-deposit.
+ // This might be a refund, auction or escrow policy.
+ policy?: any;
+}
+
+export interface BatchDepositRequestCoin {
+ // EdDSA public key of the coin being deposited.
+ coin_pub: EddsaPublicKeyString;
+
+ // Hash of denomination RSA key with which the coin is signed.
+ denom_pub_hash: HashCodeString;
+
+ // Exchange's unblinded RSA signature of the coin.
+ ub_sig: UnblindedSignature;
+
+ // Amount to be deposited, can be a fraction of the
+ // coin's total value.
+ contribution: Amounts;
+
+ // Signature over `TALER_DepositRequestPS`, made by the customer with the
+ // `coin's private key <coin-priv>`.
+ coin_sig: EddsaSignatureString;
+
+ h_age_commitment?: string;
+}
+
+export interface WalletKycUuid {
+ // UUID that the wallet should use when initiating
+ // the KYC check.
+ requirement_row: number;
+
+ // Hash of the payto:// account URI for the wallet.
+ h_payto: string;
+}
+
+export const codecForWalletKycUuid = (): Codec<WalletKycUuid> =>
+ buildCodecForObject<WalletKycUuid>()
+ .property("requirement_row", codecForNumber())
+ .property("h_payto", codecForString())
+ .build("WalletKycUuid");
+
+export interface MerchantUsingTemplateDetails {
+ summary?: string;
+ amount?: AmountString;
+}
+
+export interface ExchangeRefundRequest {
+ // Amount to be refunded, can be a fraction of the
+ // coin's total deposit value (including deposit fee);
+ // must be larger than the refund fee.
+ refund_amount: AmountString;
+
+ // SHA-512 hash of the contact of the merchant with the customer.
+ h_contract_terms: HashCodeString;
+
+ // 64-bit transaction id of the refund transaction between merchant and customer.
+ rtransaction_id: number;
+
+ // EdDSA public key of the merchant.
+ merchant_pub: EddsaPublicKeyString;
+
+ // EdDSA signature of the merchant over a
+ // TALER_RefundRequestPS with purpose
+ // TALER_SIGNATURE_MERCHANT_REFUND
+ // affirming the refund.
+ merchant_sig: EddsaPublicKeyString;
+}
+
+export interface ExchangeRefundSuccessResponse {
+ // The EdDSA :ref:signature (binary-only) with purpose
+ // TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND over
+ // a TALER_RecoupRefreshConfirmationPS
+ // using a current signing key of the
+ // exchange affirming the successful refund.
+ exchange_sig: EddsaSignatureString;
+
+ // Public EdDSA key of the exchange that was used to generate the signature.
+ // Should match one of the exchange's signing keys from /keys. It is given
+ // explicitly as the client might otherwise be confused by clock skew as to
+ // which signing key was used.
+ exchange_pub: EddsaPublicKeyString;
+}
+
+export const codecForExchangeRefundSuccessResponse =
+ (): Codec<ExchangeRefundSuccessResponse> =>
+ buildCodecForObject<ExchangeRefundSuccessResponse>()
+ .property("exchange_pub", codecForString())
+ .property("exchange_sig", codecForString())
+ .build("ExchangeRefundSuccessResponse");
+
+export type AccountRestriction =
+ | RegexAccountRestriction
+ | DenyAllAccountRestriction;
+
+export interface DenyAllAccountRestriction {
+ type: "deny";
+}
+
+// Accounts interacting with this type of account
+// restriction must have a payto://-URI matching
+// the given regex.
+export interface RegexAccountRestriction {
+ type: "regex";
+
+ // Regular expression that the payto://-URI of the
+ // partner account must follow. The regular expression
+ // should follow posix-egrep, but without support for character
+ // classes, GNU extensions, back-references or intervals. See
+ // https://www.gnu.org/software/findutils/manual/html_node/find_html/posix_002degrep-regular-expression-syntax.html
+ // for a description of the posix-egrep syntax. Applications
+ // may support regexes with additional features, but exchanges
+ // must not use such regexes.
+ payto_regex: string;
+
+ // Hint for a human to understand the restriction
+ // (that is hopefully easier to comprehend than the regex itself).
+ human_hint: string;
+
+ // Map from IETF BCP 47 language tags to localized
+ // human hints.
+ human_hint_i18n?: InternationalizedString;
+}
+
+export interface ExchangeWireAccount {
+ // payto:// URI identifying the account and wire method
+ payto_uri: string;
+
+ // URI to convert amounts from or to the currency used by
+ // this wire account of the exchange. Missing if no
+ // conversion is applicable.
+ conversion_url?: string;
+
+ // Restrictions that apply to bank accounts that would send
+ // funds to the exchange (crediting this exchange bank account).
+ // Optional, empty array for unrestricted.
+ credit_restrictions: AccountRestriction[];
+
+ // Restrictions that apply to bank accounts that would receive
+ // funds from the exchange (debiting this exchange bank account).
+ // Optional, empty array for unrestricted.
+ debit_restrictions: AccountRestriction[];
+
+ // Signature using the exchange's offline key over
+ // a TALER_MasterWireDetailsPS
+ // with purpose TALER_SIGNATURE_MASTER_WIRE_DETAILS.
+ master_sig: EddsaSignatureString;
+
+ // Display label wallets should use to show this
+ // bank account.
+ // Since protocol **v19**.
+ bank_label?: string;
+ priority?: number;
+}
+
+export const codecForExchangeWireAccount = (): Codec<ExchangeWireAccount> =>
+ buildCodecForObject<ExchangeWireAccount>()
+ .property("conversion_url", codecOptional(codecForStringURL()))
+ .property("credit_restrictions", codecForList(codecForAny()))
+ .property("debit_restrictions", codecForList(codecForAny()))
+ .property("master_sig", codecForString())
+ .property("payto_uri", codecForString())
+ .property("bank_label", codecOptional(codecForString()))
+ .property("priority", codecOptional(codecForNumber()))
+ .build("WireAccount");
+
+export type Integer = number;
+
+export interface BankConversionInfoConfig {
+ // libtool-style representation of the Bank protocol version, see
+ // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
+ // The format is "current:revision:age".
+ version: string;
+
+ // Name of the API.
+ name: "taler-conversion-info";
+
+ regional_currency: string;
+
+ fiat_currency: string;
+
+ // Currency used by this bank.
+ regional_currency_specification: CurrencySpecification;
+
+ // External currency used during conversion.
+ fiat_currency_specification: CurrencySpecification;
+}
+
+export const codecForBankConversionInfoConfig =
+ (): Codec<BankConversionInfoConfig> =>
+ buildCodecForObject<BankConversionInfoConfig>()
+ .property("name", codecForConstString("taler-conversion-info"))
+ .property("version", codecForString())
+ .property("fiat_currency", codecForString())
+ .property("regional_currency", codecForString())
+ .property("fiat_currency_specification", codecForCurrencySpecificiation())
+ .property(
+ "regional_currency_specification",
+ codecForCurrencySpecificiation(),
+ )
+ .build("BankConversionInfoConfig");
+
+export interface DenominationExpiredMessage {
+ // Taler error code. Note that beyond
+ // expiration this message format is also
+ // used if the key is not yet valid, or
+ // has been revoked.
+ code: number;
+
+ // Signature by the exchange over a
+ // TALER_DenominationExpiredAffirmationPS.
+ // Must have purpose TALER_SIGNATURE_EXCHANGE_AFFIRM_DENOM_EXPIRED.
+ exchange_sig: EddsaSignatureString;
+
+ // Public key of the exchange used to create
+ // the 'exchange_sig.
+ exchange_pub: EddsaPublicKeyString;
+
+ // Hash of the denomination public key that is unknown.
+ h_denom_pub: HashCodeString;
+
+ // When was the signature created.
+ timestamp: TalerProtocolTimestamp;
+
+ // What kind of operation was requested that now
+ // failed?
+ oper: string;
+}
+
+export const codecForDenominationExpiredMessage = () =>
+ buildCodecForObject<DenominationExpiredMessage>()
+ .property("code", codecForNumber())
+ .property("exchange_sig", codecForString())
+ .property("exchange_pub", codecForString())
+ .property("h_denom_pub", codecForString())
+ .property("timestamp", codecForTimestamp)
+ .property("oper", codecForString())
+ .build("DenominationExpiredMessage");
+
+export interface CoinHistoryResponse {
+ // Current balance of the coin.
+ balance: AmountString;
+
+ // Hash of the coin's denomination.
+ h_denom_pub: HashCodeString;
+
+ // Transaction history for the coin.
+ history: any[];
+}
+
+export const codecForCoinHistoryResponse = () =>
+ buildCodecForObject<CoinHistoryResponse>()
+ .property("balance", codecForAmountString())
+ .property("h_denom_pub", codecForString())
+ .property("history", codecForAny())
+ .build("CoinHistoryResponse");
diff --git a/packages/taler-util/src/talerCrypto.test.ts b/packages/taler-util/src/talerCrypto.test.ts
deleted file mode 100644
index 1e3ceef61..000000000
--- a/packages/taler-util/src/talerCrypto.test.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports
- */
-import test from "ava";
-import {
- encodeCrock,
- decodeCrock,
- ecdheGetPublic,
- eddsaGetPublic,
- keyExchangeEddsaEcdhe,
- keyExchangeEcdheEddsa,
- stringToBytes,
- bytesToString,
-} from "./talerCrypto.js";
-import { sha512, kdf } from "./kdf.js";
-import * as nacl from "./nacl-fast.js";
-
-test("encoding", (t) => {
- const s = "Hello, World";
- const encStr = encodeCrock(stringToBytes(s));
- const outBuf = decodeCrock(encStr);
- const sOut = bytesToString(outBuf);
- t.deepEqual(s, sOut);
-});
-
-test("taler-exchange-tvg hash code", (t) => {
- const input = "91JPRV3F5GG4EKJN41A62V35E8";
- const output =
- "CW96WR74JS8T53EC8GKSGD49QKH4ZNFTZXDAWMMV5GJ1E4BM6B8GPN5NVHDJ8ZVXNCW7Q4WBYCV61HCA3PZC2YJD850DT29RHHN7ESR";
-
- const myOutput = encodeCrock(sha512(decodeCrock(input)));
-
- t.deepEqual(myOutput, output);
-});
-
-test("taler-exchange-tvg ecdhe key", (t) => {
- const priv1 = "X4T4N0M8PVQXQEBW2BA7049KFSM7J437NSDFC6GDNM3N5J9367A0";
- const pub1 = "M997P494MS6A95G1P0QYWW2VNPSHSX5Q6JBY5B9YMNYWP0B50X3G";
- const priv2 = "14A0MMQ64DCV8HE0CS3WBC9DHFJAHXRGV7NEARFJPC5R5E1697E0";
- const skm =
- "NXRY2YCY7H9B6KM928ZD55WG964G59YR0CPX041DYXKBZZ85SAWNPQ8B30QRM5FMHYCXJAN0EAADJYWEF1X3PAC2AJN28626TR5A6AR";
-
- const myPub1 = nacl.scalarMult_base(decodeCrock(priv1));
- t.deepEqual(encodeCrock(myPub1), pub1);
-
- const mySkm = nacl.hash(
- nacl.scalarMult(decodeCrock(priv2), decodeCrock(pub1)),
- );
- t.deepEqual(encodeCrock(mySkm), skm);
-});
-
-test("taler-exchange-tvg eddsa key", (t) => {
- const priv = "9TM70AKDTS57AWY9JK2J4TMBTMW6K62WHHGZWYDG0VM5ABPZKD40";
- const pub = "8GSJZ649T2PXMKZC01Y4ANNBE7MF14QVK9SQEC4E46ZHKCVG8AS0";
-
- const pair = nacl.crypto_sign_keyPair_fromSeed(decodeCrock(priv));
- t.deepEqual(encodeCrock(pair.publicKey), pub);
-});
-
-test("taler-exchange-tvg kdf", (t) => {
- const salt = "94KPT83PCNS7J83KC5P78Y8";
- const ikm = "94KPT83MD1JJ0WV5CDS6AX10D5Q70XBM41NPAY90DNGQ8SBJD5GPR";
- const ctx =
- "94KPT83141HPYVKMCNW78833D1TPWTSC41GPRWVF41NPWVVQDRG62WS04XMPWSKF4WG6JVH0EHM6A82J8S1G";
- const outLen = 64;
- const out =
- "GTMR4QT05Z9WF5HKVG0WK9RPXGHSMHJNW377G9GJXCA8B0FEKPF4D27RJMSJZYWSQNTBJ5EYVV7ZW18B48Z0JVJJ80RHB706Y96Q358";
-
- const myOut = kdf(
- outLen,
- decodeCrock(ikm),
- decodeCrock(salt),
- decodeCrock(ctx),
- );
-
- t.deepEqual(encodeCrock(myOut), out);
-});
-
-test("taler-exchange-tvg eddsa_ecdh", (t) => {
- const priv_ecdhe = "4AFZWMSGTVCHZPQ0R81NWXDCK4N58G7SDBBE5KXE080Y50370JJG";
- const pub_ecdhe = "FXFN5GPAFTKVPWJDPVXQ87167S8T82T5ZV8CDYC0NH2AE14X0M30";
- const priv_eddsa = "1KG54M8T3X8BSFSZXCR3SQBSR7Y9P53NX61M864S7TEVMJ2XVPF0";
- const pub_eddsa = "7BXWKG6N224C57RTDV8XEAHR108HG78NMA995BE8QAT5GC1S7E80";
- const key_material =
- "PKZ42Z56SVK2796HG1QYBRJ6ZQM2T9QGA3JA4AAZ8G7CWK9FPX175Q9JE5P0ZAX3HWWPHAQV4DPCK10R9X3SAXHRV0WF06BHEC2ZTKR";
-
- const myEcdhePub = ecdheGetPublic(decodeCrock(priv_ecdhe));
- t.deepEqual(encodeCrock(myEcdhePub), pub_ecdhe);
-
- const myEddsaPub = eddsaGetPublic(decodeCrock(priv_eddsa));
- t.deepEqual(encodeCrock(myEddsaPub), pub_eddsa);
-
- const myKm1 = keyExchangeEddsaEcdhe(
- decodeCrock(priv_eddsa),
- decodeCrock(pub_ecdhe),
- );
- t.deepEqual(encodeCrock(myKm1), key_material);
-
- const myKm2 = keyExchangeEcdheEddsa(
- decodeCrock(priv_ecdhe),
- decodeCrock(pub_eddsa),
- );
- t.deepEqual(encodeCrock(myKm2), key_material);
-});
-
-test("incremental hashing #1", (t) => {
- const n = 1024;
- const d = nacl.randomBytes(n);
-
- const h1 = nacl.hash(d);
- const h2 = new nacl.HashState().update(d).finish();
-
- const s = new nacl.HashState();
- for (let i = 0; i < n; i++) {
- const b = new Uint8Array(1);
- b[0] = d[i];
- s.update(b);
- }
-
- const h3 = s.finish();
-
- t.deepEqual(encodeCrock(h1), encodeCrock(h2));
- t.deepEqual(encodeCrock(h1), encodeCrock(h3));
-});
-
-test("incremental hashing #2", (t) => {
- const n = 10;
- const d = nacl.randomBytes(n);
-
- const h1 = nacl.hash(d);
- const h2 = new nacl.HashState().update(d).finish();
- const s = new nacl.HashState();
- for (let i = 0; i < n; i++) {
- const b = new Uint8Array(1);
- b[0] = d[i];
- s.update(b);
- }
-
- const h3 = s.finish();
-
- t.deepEqual(encodeCrock(h1), encodeCrock(h3));
- t.deepEqual(encodeCrock(h1), encodeCrock(h2));
-});
-
-test("taler-exchange-tvg eddsa_ecdh #2", (t) => {
- const priv_ecdhe = "W5FH9CFS3YPGSCV200GE8TH6MAACPKKGEG2A5JTFSD1HZ5RYT7Q0";
- const pub_ecdhe = "FER9CRS2T8783TAANPZ134R704773XT0ZT1XPFXZJ9D4QX67ZN00";
- const priv_eddsa = "MSZ1TBKC6YQ19ZFP3NTJVKWNVGFP35BBRW8FTAQJ9Z2B96VC9P4G";
- const pub_eddsa = "Y7MKG85PBT8ZEGHF08JBVZXEV70TS0PY5Y2CMEN1WXEDN63KP1A0";
- const key_material =
- "G6RA58N61K7MT3WA13Q7VRTE1FQS6H43RX9HK8Z5TGAB61601GEGX51JRHHQMNKNM2R9AVC1STSGQDRHGKWVYP584YGBCTVMMJYQF30";
-
- const myEcdhePub = ecdheGetPublic(decodeCrock(priv_ecdhe));
- t.deepEqual(encodeCrock(myEcdhePub), pub_ecdhe);
-
- const myEddsaPub = eddsaGetPublic(decodeCrock(priv_eddsa));
- t.deepEqual(encodeCrock(myEddsaPub), pub_eddsa);
-
- const myKm1 = keyExchangeEddsaEcdhe(
- decodeCrock(priv_eddsa),
- decodeCrock(pub_ecdhe),
- );
- t.deepEqual(encodeCrock(myKm1), key_material);
-
- const myKm2 = keyExchangeEcdheEddsa(
- decodeCrock(priv_ecdhe),
- decodeCrock(pub_eddsa),
- );
- t.deepEqual(encodeCrock(myKm2), key_material);
-});
diff --git a/packages/taler-util/src/talerCrypto.ts b/packages/taler-util/src/talerCrypto.ts
deleted file mode 100644
index 536c4dc48..000000000
--- a/packages/taler-util/src/talerCrypto.ts
+++ /dev/null
@@ -1,502 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Native implementation of GNU Taler crypto.
- */
-
-/**
- * Imports.
- */
-import * as nacl from "./nacl-fast.js";
-import { kdf } from "./kdf.js";
-import bigint from "big-integer";
-
-export function getRandomBytes(n: number): Uint8Array {
- return nacl.randomBytes(n);
-}
-
-const encTable = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
-
-class EncodingError extends Error {
- constructor() {
- super("Encoding error");
- Object.setPrototypeOf(this, EncodingError.prototype);
- }
-}
-
-function getValue(chr: string): number {
- let a = chr;
- switch (chr) {
- case "O":
- case "o":
- a = "0;";
- break;
- case "i":
- case "I":
- case "l":
- case "L":
- a = "1";
- break;
- case "u":
- case "U":
- a = "V";
- }
-
- if (a >= "0" && a <= "9") {
- return a.charCodeAt(0) - "0".charCodeAt(0);
- }
-
- if (a >= "a" && a <= "z") a = a.toUpperCase();
- let dec = 0;
- if (a >= "A" && a <= "Z") {
- if ("I" < a) dec++;
- if ("L" < a) dec++;
- if ("O" < a) dec++;
- if ("U" < a) dec++;
- return a.charCodeAt(0) - "A".charCodeAt(0) + 10 - dec;
- }
- throw new EncodingError();
-}
-
-export function encodeCrock(data: ArrayBuffer): string {
- const dataBytes = new Uint8Array(data);
- let sb = "";
- const size = data.byteLength;
- let bitBuf = 0;
- let numBits = 0;
- let pos = 0;
- while (pos < size || numBits > 0) {
- if (pos < size && numBits < 5) {
- const d = dataBytes[pos++];
- bitBuf = (bitBuf << 8) | d;
- numBits += 8;
- }
- if (numBits < 5) {
- // zero-padding
- bitBuf = bitBuf << (5 - numBits);
- numBits = 5;
- }
- const v = (bitBuf >>> (numBits - 5)) & 31;
- sb += encTable[v];
- numBits -= 5;
- }
- return sb;
-}
-
-export function decodeCrock(encoded: string): Uint8Array {
- const size = encoded.length;
- let bitpos = 0;
- let bitbuf = 0;
- let readPosition = 0;
- const outLen = Math.floor((size * 5) / 8);
- const out = new Uint8Array(outLen);
- let outPos = 0;
-
- while (readPosition < size || bitpos > 0) {
- if (readPosition < size) {
- const v = getValue(encoded[readPosition++]);
- bitbuf = (bitbuf << 5) | v;
- bitpos += 5;
- }
- while (bitpos >= 8) {
- const d = (bitbuf >>> (bitpos - 8)) & 0xff;
- out[outPos++] = d;
- bitpos -= 8;
- }
- if (readPosition == size && bitpos > 0) {
- bitbuf = (bitbuf << (8 - bitpos)) & 0xff;
- bitpos = bitbuf == 0 ? 0 : 8;
- }
- }
- return out;
-}
-
-export function eddsaGetPublic(eddsaPriv: Uint8Array): Uint8Array {
- const pair = nacl.crypto_sign_keyPair_fromSeed(eddsaPriv);
- return pair.publicKey;
-}
-
-export function ecdheGetPublic(ecdhePriv: Uint8Array): Uint8Array {
- return nacl.scalarMult_base(ecdhePriv);
-}
-
-export function keyExchangeEddsaEcdhe(
- eddsaPriv: Uint8Array,
- ecdhePub: Uint8Array,
-): Uint8Array {
- const ph = nacl.hash(eddsaPriv);
- const a = new Uint8Array(32);
- for (let i = 0; i < 32; i++) {
- a[i] = ph[i];
- }
- const x = nacl.scalarMult(a, ecdhePub);
- return nacl.hash(x);
-}
-
-export function keyExchangeEcdheEddsa(
- ecdhePriv: Uint8Array,
- eddsaPub: Uint8Array,
-): Uint8Array {
- const curve25519Pub = nacl.sign_ed25519_pk_to_curve25519(eddsaPub);
- const x = nacl.scalarMult(ecdhePriv, curve25519Pub);
- return nacl.hash(x);
-}
-
-interface RsaPub {
- N: bigint.BigInteger;
- e: bigint.BigInteger;
-}
-
-interface RsaBlindingKey {
- r: bigint.BigInteger;
-}
-
-/**
- * KDF modulo a big integer.
- */
-function kdfMod(
- n: bigint.BigInteger,
- ikm: Uint8Array,
- salt: Uint8Array,
- info: Uint8Array,
-): bigint.BigInteger {
- const nbits = n.bitLength().toJSNumber();
- const buflen = Math.floor((nbits - 1) / 8 + 1);
- const mask = (1 << (8 - (buflen * 8 - nbits))) - 1;
- let counter = 0;
- while (true) {
- const ctx = new Uint8Array(info.byteLength + 2);
- ctx.set(info, 0);
- ctx[ctx.length - 2] = (counter >>> 8) & 0xff;
- ctx[ctx.length - 1] = counter & 0xff;
- const buf = kdf(buflen, ikm, salt, ctx);
- const arr = Array.from(buf);
- arr[0] = arr[0] & mask;
- const r = bigint.fromArray(arr, 256, false);
- if (r.lt(n)) {
- return r;
- }
- counter++;
- }
-}
-
-// Newer versions of node have TextEncoder and TextDecoder as a global,
-// just like modern browsers.
-// In older versions of node or environments that do not have these
-// globals, they must be polyfilled (by adding them to globa/globalThis)
-// before stringToBytes or bytesToString is called the first time.
-
-let encoder: any;
-let decoder: any;
-
-export function stringToBytes(s: string): Uint8Array {
- if (!encoder) {
- // @ts-ignore
- encoder = new TextEncoder();
- }
- return encoder.encode(s);
-}
-
-export function bytesToString(b: Uint8Array): string {
- if (!decoder) {
- // @ts-ignore
- decoder = new TextDecoder();
- }
- return decoder.decode(b);
-}
-
-function loadBigInt(arr: Uint8Array): bigint.BigInteger {
- return bigint.fromArray(Array.from(arr), 256, false);
-}
-
-function rsaBlindingKeyDerive(
- rsaPub: RsaPub,
- bks: Uint8Array,
-): bigint.BigInteger {
- const salt = stringToBytes("Blinding KDF extractor HMAC key");
- const info = stringToBytes("Blinding KDF");
- return kdfMod(rsaPub.N, bks, salt, info);
-}
-
-/*
- * Test for malicious RSA key.
- *
- * Assuming n is an RSA modulous and r is generated using a call to
- * GNUNET_CRYPTO_kdf_mod_mpi, if gcd(r,n) != 1 then n must be a
- * malicious RSA key designed to deanomize the user.
- *
- * @param r KDF result
- * @param n RSA modulus of the public key
- */
-function rsaGcdValidate(r: bigint.BigInteger, n: bigint.BigInteger): void {
- const t = bigint.gcd(r, n);
- if (!t.equals(bigint.one)) {
- throw Error("malicious RSA public key");
- }
-}
-
-function rsaFullDomainHash(hm: Uint8Array, rsaPub: RsaPub): bigint.BigInteger {
- const info = stringToBytes("RSA-FDA FTpsW!");
- const salt = rsaPubEncode(rsaPub);
- const r = kdfMod(rsaPub.N, hm, salt, info);
- rsaGcdValidate(r, rsaPub.N);
- return r;
-}
-
-function rsaPubDecode(rsaPub: Uint8Array): RsaPub {
- const modulusLength = (rsaPub[0] << 8) | rsaPub[1];
- const exponentLength = (rsaPub[2] << 8) | rsaPub[3];
- if (4 + exponentLength + modulusLength != rsaPub.length) {
- throw Error("invalid RSA public key (format wrong)");
- }
- const modulus = rsaPub.slice(4, 4 + modulusLength);
- const exponent = rsaPub.slice(
- 4 + modulusLength,
- 4 + modulusLength + exponentLength,
- );
- const res = {
- N: loadBigInt(modulus),
- e: loadBigInt(exponent),
- };
- return res;
-}
-
-function rsaPubEncode(rsaPub: RsaPub): Uint8Array {
- const mb = rsaPub.N.toArray(256).value;
- const eb = rsaPub.e.toArray(256).value;
- const out = new Uint8Array(4 + mb.length + eb.length);
- out[0] = (mb.length >>> 8) & 0xff;
- out[1] = mb.length & 0xff;
- out[2] = (eb.length >>> 8) & 0xff;
- out[3] = eb.length & 0xff;
- out.set(mb, 4);
- out.set(eb, 4 + mb.length);
- return out;
-}
-
-export function rsaBlind(
- hm: Uint8Array,
- bks: Uint8Array,
- rsaPubEnc: Uint8Array,
-): Uint8Array {
- const rsaPub = rsaPubDecode(rsaPubEnc);
- const data = rsaFullDomainHash(hm, rsaPub);
- const r = rsaBlindingKeyDerive(rsaPub, bks);
- const r_e = r.modPow(rsaPub.e, rsaPub.N);
- const bm = r_e.multiply(data).mod(rsaPub.N);
- return new Uint8Array(bm.toArray(256).value);
-}
-
-export function rsaUnblind(
- sig: Uint8Array,
- rsaPubEnc: Uint8Array,
- bks: Uint8Array,
-): Uint8Array {
- const rsaPub = rsaPubDecode(rsaPubEnc);
- const blinded_s = loadBigInt(sig);
- const r = rsaBlindingKeyDerive(rsaPub, bks);
- const r_inv = r.modInv(rsaPub.N);
- const s = blinded_s.multiply(r_inv).mod(rsaPub.N);
- return new Uint8Array(s.toArray(256).value);
-}
-
-export function rsaVerify(
- hm: Uint8Array,
- rsaSig: Uint8Array,
- rsaPubEnc: Uint8Array,
-): boolean {
- const rsaPub = rsaPubDecode(rsaPubEnc);
- const d = rsaFullDomainHash(hm, rsaPub);
- const sig = loadBigInt(rsaSig);
- const sig_e = sig.modPow(rsaPub.e, rsaPub.N);
- return sig_e.equals(d);
-}
-
-export interface EddsaKeyPair {
- eddsaPub: Uint8Array;
- eddsaPriv: Uint8Array;
-}
-
-export interface EcdheKeyPair {
- ecdhePub: Uint8Array;
- ecdhePriv: Uint8Array;
-}
-
-export function createEddsaKeyPair(): EddsaKeyPair {
- const eddsaPriv = nacl.randomBytes(32);
- const eddsaPub = eddsaGetPublic(eddsaPriv);
- return { eddsaPriv, eddsaPub };
-}
-
-export function createEcdheKeyPair(): EcdheKeyPair {
- const ecdhePriv = nacl.randomBytes(32);
- const ecdhePub = ecdheGetPublic(ecdhePriv);
- return { ecdhePriv, ecdhePub };
-}
-
-export function hash(d: Uint8Array): Uint8Array {
- return nacl.hash(d);
-}
-
-export function eddsaSign(msg: Uint8Array, eddsaPriv: Uint8Array): Uint8Array {
- const pair = nacl.crypto_sign_keyPair_fromSeed(eddsaPriv);
- return nacl.sign_detached(msg, pair.secretKey);
-}
-
-export function eddsaVerify(
- msg: Uint8Array,
- sig: Uint8Array,
- eddsaPub: Uint8Array,
-): boolean {
- return nacl.sign_detached_verify(msg, sig, eddsaPub);
-}
-
-export function createHashContext(): nacl.HashState {
- return new nacl.HashState();
-}
-
-export interface FreshCoin {
- coinPub: Uint8Array;
- coinPriv: Uint8Array;
- bks: Uint8Array;
-}
-
-export function setupRefreshPlanchet(
- secretSeed: Uint8Array,
- coinNumber: number,
-): FreshCoin {
- const info = stringToBytes("taler-coin-derivation");
- const saltArrBuf = new ArrayBuffer(4);
- const salt = new Uint8Array(saltArrBuf);
- const saltDataView = new DataView(saltArrBuf);
- saltDataView.setUint32(0, coinNumber);
- const out = kdf(64, secretSeed, salt, info);
- const coinPriv = out.slice(0, 32);
- const bks = out.slice(32, 64);
- return {
- bks,
- coinPriv,
- coinPub: eddsaGetPublic(coinPriv),
- };
-}
-
-export function setupWithdrawPlanchet(
- secretSeed: Uint8Array,
- coinNumber: number,
-): FreshCoin {
- const info = stringToBytes("taler-withdrawal-coin-derivation");
- const saltArrBuf = new ArrayBuffer(4);
- const salt = new Uint8Array(saltArrBuf);
- const saltDataView = new DataView(saltArrBuf);
- saltDataView.setUint32(0, coinNumber);
- const out = kdf(64, secretSeed, salt, info);
- const coinPriv = out.slice(0, 32);
- const bks = out.slice(32, 64);
- return {
- bks,
- coinPriv,
- coinPub: eddsaGetPublic(coinPriv),
- };
-}
-
-export function setupTipPlanchet(
- secretSeed: Uint8Array,
- coinNumber: number,
-): FreshCoin {
- const info = stringToBytes("taler-tip-coin-derivation");
- const saltArrBuf = new ArrayBuffer(4);
- const salt = new Uint8Array(saltArrBuf);
- const saltDataView = new DataView(saltArrBuf);
- saltDataView.setUint32(0, coinNumber);
- const out = kdf(64, secretSeed, salt, info);
- const coinPriv = out.slice(0, 32);
- const bks = out.slice(32, 64);
- return {
- bks,
- coinPriv,
- coinPub: eddsaGetPublic(coinPriv),
- };
-}
-
-export function setupRefreshTransferPub(
- secretSeed: Uint8Array,
- transferPubIndex: number,
-): EcdheKeyPair {
- const info = stringToBytes("taler-transfer-pub-derivation");
- const saltArrBuf = new ArrayBuffer(4);
- const salt = new Uint8Array(saltArrBuf);
- const saltDataView = new DataView(saltArrBuf);
- saltDataView.setUint32(0, transferPubIndex);
- const out = kdf(32, secretSeed, salt, info);
- return {
- ecdhePriv: out,
- ecdhePub: ecdheGetPublic(out),
- };
-}
-
-export enum TalerSignaturePurpose {
- MERCHANT_TRACK_TRANSACTION = 1103,
- WALLET_RESERVE_WITHDRAW = 1200,
- WALLET_COIN_DEPOSIT = 1201,
- MASTER_DENOMINATION_KEY_VALIDITY = 1025,
- MASTER_WIRE_FEES = 1028,
- MASTER_WIRE_DETAILS = 1030,
- WALLET_COIN_MELT = 1202,
- TEST = 4242,
- MERCHANT_PAYMENT_OK = 1104,
- MERCHANT_CONTRACT = 1101,
- WALLET_COIN_RECOUP = 1203,
- WALLET_COIN_LINK = 1204,
- EXCHANGE_CONFIRM_RECOUP = 1039,
- EXCHANGE_CONFIRM_RECOUP_REFRESH = 1041,
- ANASTASIS_POLICY_UPLOAD = 1400,
- ANASTASIS_POLICY_DOWNLOAD = 1401,
- SYNC_BACKUP_UPLOAD = 1450,
-}
-
-export class SignaturePurposeBuilder {
- private chunks: Uint8Array[] = [];
-
- constructor(private purposeNum: number) {}
-
- put(bytes: Uint8Array): SignaturePurposeBuilder {
- this.chunks.push(Uint8Array.from(bytes));
- return this;
- }
-
- build(): Uint8Array {
- let payloadLen = 0;
- for (const c of this.chunks) {
- payloadLen += c.byteLength;
- }
- const buf = new ArrayBuffer(4 + 4 + payloadLen);
- const u8buf = new Uint8Array(buf);
- let p = 8;
- for (const c of this.chunks) {
- u8buf.set(c, p);
- p += c.byteLength;
- }
- const dvbuf = new DataView(buf);
- dvbuf.setUint32(0, payloadLen + 4 + 4);
- dvbuf.setUint32(4, this.purposeNum);
- return u8buf;
- }
-}
-
-export function buildSigPS(purposeNum: number): SignaturePurposeBuilder {
- return new SignaturePurposeBuilder(purposeNum);
-}
diff --git a/packages/taler-util/src/talerTypes.ts b/packages/taler-util/src/talerTypes.ts
deleted file mode 100644
index 56110ec1e..000000000
--- a/packages/taler-util/src/talerTypes.ts
+++ /dev/null
@@ -1,1453 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Type and schema definitions and helpers for the core GNU Taler protocol.
- *
- * Even though the rest of the wallet uses camelCase for fields, use snake_case
- * here, since that's the convention for the Taler JSON+HTTP API.
- */
-
-/**
- * Imports.
- */
-
-import {
- buildCodecForObject,
- codecForString,
- codecForList,
- codecOptional,
- codecForAny,
- codecForNumber,
- codecForBoolean,
- codecForMap,
- Codec,
- codecForConstNumber,
- buildCodecForUnion,
- codecForConstString,
-} from "./codec.js";
-import {
- Timestamp,
- codecForTimestamp,
- Duration,
- codecForDuration,
-} from "./time.js";
-import { codecForAmountString } from "./amounts.js";
-
-/**
- * Denomination as found in the /keys response from the exchange.
- */
-export class Denomination {
- /**
- * Value of one coin of the denomination.
- */
- value: string;
-
- /**
- * Public signing key of the denomination.
- */
- denom_pub: string;
-
- /**
- * Fee for withdrawing.
- */
- fee_withdraw: string;
-
- /**
- * Fee for depositing.
- */
- fee_deposit: string;
-
- /**
- * Fee for refreshing.
- */
- fee_refresh: string;
-
- /**
- * Fee for refunding.
- */
- fee_refund: string;
-
- /**
- * Start date from which withdraw is allowed.
- */
- stamp_start: Timestamp;
-
- /**
- * End date for withdrawing.
- */
- stamp_expire_withdraw: Timestamp;
-
- /**
- * Expiration date after which the exchange can forget about
- * the currency.
- */
- stamp_expire_legal: Timestamp;
-
- /**
- * Date after which the coins of this denomination can't be
- * deposited anymore.
- */
- stamp_expire_deposit: Timestamp;
-
- /**
- * Signature over the denomination information by the exchange's master
- * signing key.
- */
- master_sig: string;
-}
-
-/**
- * Signature by the auditor that a particular denomination key is audited.
- */
-export class AuditorDenomSig {
- /**
- * Denomination public key's hash.
- */
- denom_pub_h: string;
-
- /**
- * The signature.
- */
- auditor_sig: string;
-}
-
-/**
- * Auditor information as given by the exchange in /keys.
- */
-export class Auditor {
- /**
- * Auditor's public key.
- */
- auditor_pub: string;
-
- /**
- * Base URL of the auditor.
- */
- auditor_url: string;
-
- /**
- * List of signatures for denominations by the auditor.
- */
- denomination_keys: AuditorDenomSig[];
-}
-
-/**
- * Request that we send to the exchange to get a payback.
- */
-export interface RecoupRequest {
- /**
- * Hashed enomination public key of the coin we want to get
- * paid back.
- */
- denom_pub_hash: string;
-
- /**
- * Signature over the coin public key by the denomination.
- */
- denom_sig: string;
-
- /**
- * Coin public key of the coin we want to refund.
- */
- coin_pub: string;
-
- /**
- * Blinding key that was used during withdraw,
- * used to prove that we were actually withdrawing the coin.
- */
- coin_blind_key_secret: string;
-
- /**
- * Signature made by the coin, authorizing the payback.
- */
- coin_sig: string;
-
- /**
- * Was the coin refreshed (and thus the recoup should go to the old coin)?
- */
- refreshed: boolean;
-}
-
-/**
- * Response that we get from the exchange for a payback request.
- */
-export interface RecoupConfirmation {
- /**
- * Public key of the reserve that will receive the payback.
- */
- reserve_pub?: string;
-
- /**
- * Public key of the old coin that will receive the recoup,
- * provided if refreshed was true.
- */
- old_coin_pub?: string;
-}
-
-/**
- * Deposit permission for a single coin.
- */
-export interface CoinDepositPermission {
- /**
- * Signature by the coin.
- */
- coin_sig: string;
- /**
- * Public key of the coin being spend.
- */
- coin_pub: string;
- /**
- * Signature made by the denomination public key.
- */
- ub_sig: string;
- /**
- * The denomination public key associated with this coin.
- */
- h_denom: string;
- /**
- * The amount that is subtracted from this coin with this payment.
- */
- contribution: string;
-
- /**
- * URL of the exchange this coin was withdrawn from.
- */
- exchange_url: string;
-}
-
-/**
- * Information about an exchange as stored inside a
- * merchant's contract terms.
- */
-export interface ExchangeHandle {
- /**
- * Master public signing key of the exchange.
- */
- master_pub: string;
-
- /**
- * Base URL of the exchange.
- */
- url: string;
-}
-
-export interface AuditorHandle {
- /**
- * Official name of the auditor.
- */
- name: string;
-
- /**
- * Master public signing key of the auditor.
- */
- auditor_pub: string;
-
- /**
- * Base URL of the auditor.
- */
- url: string;
-}
-
-// Delivery location, loosely modeled as a subset of
-// ISO20022's PostalAddress25.
-export interface Location {
- // Nation with its own government.
- country?: string;
-
- // Identifies a subdivision of a country such as state, region, county.
- country_subdivision?: string;
-
- // Identifies a subdivision within a country sub-division.
- district?: string;
-
- // Name of a built-up area, with defined boundaries, and a local government.
- town?: string;
-
- // Specific location name within the town.
- town_location?: string;
-
- // Identifier consisting of a group of letters and/or numbers that
- // is added to a postal address to assist the sorting of mail.
- post_code?: string;
-
- // Name of a street or thoroughfare.
- street?: string;
-
- // Name of the building or house.
- building_name?: string;
-
- // Number that identifies the position of a building on a street.
- building_number?: string;
-
- // Free-form address lines, should not exceed 7 elements.
- address_lines?: string[];
-}
-
-export interface MerchantInfo {
- name: string;
- jurisdiction?: Location;
- address?: Location;
-}
-
-export interface Tax {
- // the name of the tax
- name: string;
-
- // amount paid in tax
- tax: AmountString;
-}
-
-export interface Product {
- // merchant-internal identifier for the product.
- product_id?: string;
-
- // Human-readable product description.
- description: string;
-
- // Map from IETF BCP 47 language tags to localized descriptions
- description_i18n?: { [lang_tag: string]: string };
-
- // The number of units of the product to deliver to the customer.
- quantity?: number;
-
- // The unit in which the product is measured (liters, kilograms, packages, etc.)
- unit?: string;
-
- // The price of the product; this is the total price for quantity times unit of this product.
- price?: AmountString;
-
- // An optional base64-encoded product image
- image?: string;
-
- // a list of taxes paid by the merchant for this product. Can be empty.
- taxes?: Tax[];
-
- // time indicating when this product should be delivered
- delivery_date?: Timestamp;
-}
-
-export interface InternationalizedString {
- [lang_tag: string]: string;
-}
-
-/**
- * Contract terms from a merchant.
- */
-export interface ContractTerms {
- /**
- * Hash of the merchant's wire details.
- */
- h_wire: string;
-
- /**
- * Hash of the merchant's wire details.
- */
- auto_refund?: Duration;
-
- /**
- * Wire method the merchant wants to use.
- */
- wire_method: string;
-
- /**
- * Human-readable short summary of the contract.
- */
- summary: string;
-
- summary_i18n?: InternationalizedString;
-
- /**
- * Nonce used to ensure freshness.
- */
- nonce: string;
-
- /**
- * Total amount payable.
- */
- amount: string;
-
- /**
- * Auditors accepted by the merchant.
- */
- auditors: AuditorHandle[];
-
- /**
- * Deadline to pay for the contract.
- */
- pay_deadline: Timestamp;
-
- /**
- * Maximum deposit fee covered by the merchant.
- */
- max_fee: string;
-
- /**
- * Information about the merchant.
- */
- merchant: MerchantInfo;
-
- /**
- * Public key of the merchant.
- */
- merchant_pub: string;
-
- /**
- * Time indicating when the order should be delivered.
- * May be overwritten by individual products.
- */
- delivery_date?: Timestamp;
-
- /**
- * Delivery location for (all!) products.
- */
- delivery_location?: Location;
-
- /**
- * List of accepted exchanges.
- */
- exchanges: ExchangeHandle[];
-
- /**
- * Products that are sold in this contract.
- */
- products?: Product[];
-
- /**
- * Deadline for refunds.
- */
- refund_deadline: Timestamp;
-
- /**
- * Deadline for the wire transfer.
- */
- wire_transfer_deadline: Timestamp;
-
- /**
- * Time when the contract was generated by the merchant.
- */
- timestamp: Timestamp;
-
- /**
- * Order id to uniquely identify the purchase within
- * one merchant instance.
- */
- order_id: string;
-
- /**
- * Base URL of the merchant's backend.
- */
- merchant_base_url: string;
-
- /**
- * Fulfillment URL to view the product or
- * delivery status.
- */
- fulfillment_url?: string;
-
- /**
- * URL meant to share the shopping cart.
- */
- public_reorder_url?: string;
-
- /**
- * Plain text fulfillment message in the merchant's default language.
- */
- fulfillment_message?: string;
-
- /**
- * Internationalized fulfillment messages.
- */
- fulfillment_message_i18n?: InternationalizedString;
-
- /**
- * Share of the wire fee that must be settled with one payment.
- */
- wire_fee_amortization?: number;
-
- /**
- * Maximum wire fee that the merchant agrees to pay for.
- */
- max_wire_fee?: string;
-
- /**
- * Extra data, interpreted by the mechant only.
- */
- extra?: any;
-}
-
-/**
- * Refund permission in the format that the merchant gives it to us.
- */
-export interface MerchantAbortPayRefundDetails {
- /**
- * Amount to be refunded.
- */
- refund_amount: string;
-
- /**
- * Fee for the refund.
- */
- refund_fee: string;
-
- /**
- * Public key of the coin being refunded.
- */
- coin_pub: string;
-
- /**
- * Refund transaction ID between merchant and exchange.
- */
- rtransaction_id: number;
-
- /**
- * Exchange's key used for the signature.
- */
- exchange_pub?: string;
-
- /**
- * Exchange's signature to confirm the refund.
- */
- exchange_sig?: string;
-
- /**
- * Error replay from the exchange (if any).
- */
- exchange_reply?: any;
-
- /**
- * Error code from the exchange (if any).
- */
- exchange_code?: number;
-
- /**
- * HTTP status code of the exchange's response
- * to the merchant's refund request.
- */
- exchange_http_status: number;
-}
-
-/**
- * Response for a refund pickup or a /pay in abort mode.
- */
-export interface MerchantRefundResponse {
- /**
- * Public key of the merchant
- */
- merchant_pub: string;
-
- /**
- * Contract terms hash of the contract that
- * is being refunded.
- */
- h_contract_terms: string;
-
- /**
- * The signed refund permissions, to be sent to the exchange.
- */
- refunds: MerchantAbortPayRefundDetails[];
-}
-
-/**
- * Planchet detail sent to the merchant.
- */
-export interface TipPlanchetDetail {
- /**
- * Hashed denomination public key.
- */
- denom_pub_hash: string;
-
- /**
- * Coin's blinded public key.
- */
- coin_ev: string;
-}
-
-/**
- * Request sent to the merchant to pick up a tip.
- */
-export interface TipPickupRequest {
- /**
- * Identifier of the tip.
- */
- tip_id: string;
-
- /**
- * List of planchets the wallet wants to use for the tip.
- */
- planchets: TipPlanchetDetail[];
-}
-
-/**
- * Reserve signature, defined as separate class to facilitate
- * schema validation with "@Checkable".
- */
-export interface BlindSigWrapper {
- /**
- * Reserve signature.
- */
- blind_sig: string;
-}
-
-/**
- * Response of the merchant
- * to the TipPickupRequest.
- */
-export interface TipResponse {
- /**
- * The order of the signatures matches the planchets list.
- */
- blind_sigs: BlindSigWrapper[];
-}
-
-/**
- * Element of the payback list that the
- * exchange gives us in /keys.
- */
-export class Recoup {
- /**
- * The hash of the denomination public key for which the payback is offered.
- */
- h_denom_pub: string;
-}
-
-/**
- * Structure of one exchange signing key in the /keys response.
- */
-export class ExchangeSignKeyJson {
- stamp_start: Timestamp;
- stamp_expire: Timestamp;
- stamp_end: Timestamp;
- key: EddsaPublicKeyString;
- master_sig: EddsaSignatureString;
-}
-
-/**
- * Structure that the exchange gives us in /keys.
- */
-export class ExchangeKeysJson {
- /**
- * List of offered denominations.
- */
- denoms: Denomination[];
-
- /**
- * The exchange's master public key.
- */
- master_public_key: string;
-
- /**
- * The list of auditors (partially) auditing the exchange.
- */
- auditors: Auditor[];
-
- /**
- * Timestamp when this response was issued.
- */
- list_issue_date: Timestamp;
-
- /**
- * List of revoked denominations.
- */
- recoup?: Recoup[];
-
- /**
- * Short-lived signing keys used to sign online
- * responses.
- */
- signkeys: ExchangeSignKeyJson[];
-
- /**
- * Protocol version.
- */
- version: string;
-
- reserve_closing_delay: Duration;
-}
-
-/**
- * Wire fees as announced by the exchange.
- */
-export class WireFeesJson {
- /**
- * Cost of a wire transfer.
- */
- wire_fee: string;
-
- /**
- * Cost of clising a reserve.
- */
- closing_fee: string;
-
- /**
- * Signature made with the exchange's master key.
- */
- sig: string;
-
- /**
- * Date from which the fee applies.
- */
- start_date: Timestamp;
-
- /**
- * Data after which the fee doesn't apply anymore.
- */
- end_date: Timestamp;
-}
-
-export interface AccountInfo {
- payto_uri: string;
- master_sig: string;
-}
-
-export interface ExchangeWireJson {
- accounts: AccountInfo[];
- fees: { [methodName: string]: WireFeesJson[] };
-}
-
-/**
- * Proposal returned from the contract URL.
- */
-export class Proposal {
- /**
- * Contract terms for the propoal.
- * Raw, un-decoded JSON object.
- */
- contract_terms: any;
-
- /**
- * Signature over contract, made by the merchant. The public key used for signing
- * must be contract_terms.merchant_pub.
- */
- sig: string;
-}
-
-/**
- * Response from the internal merchant API.
- */
-export class CheckPaymentResponse {
- order_status: string;
- refunded: boolean | undefined;
- refunded_amount: string | undefined;
- contract_terms: any | undefined;
- taler_pay_uri: string | undefined;
- contract_url: string | undefined;
-}
-
-/**
- * Response from the bank.
- */
-export class WithdrawOperationStatusResponse {
- selection_done: boolean;
-
- transfer_done: boolean;
-
- aborted: boolean;
-
- amount: string;
-
- sender_wire?: string;
-
- suggested_exchange?: string;
-
- confirm_transfer_url?: string;
-
- wire_types: string[];
-}
-
-/**
- * Response from the merchant.
- */
-export class TipPickupGetResponse {
- tip_amount: string;
-
- exchange_url: string;
-
- expiration: Timestamp;
-}
-
-export class WithdrawResponse {
- ev_sig: string;
-}
-
-/**
- * Easy to process format for the public data of coins
- * managed by the wallet.
- */
-export interface CoinDumpJson {
- coins: Array<{
- /**
- * The coin's denomination's public key.
- */
- denom_pub: string;
- /**
- * Hash of denom_pub.
- */
- denom_pub_hash: string;
- /**
- * Value of the denomination (without any fees).
- */
- denom_value: string;
- /**
- * Public key of the coin.
- */
- coin_pub: string;
- /**
- * Base URL of the exchange for the coin.
- */
- exchange_base_url: string;
- /**
- * Remaining value on the coin, to the knowledge of
- * the wallet.
- */
- remaining_value: string;
- /**
- * Public key of the parent coin.
- * Only present if this coin was obtained via refreshing.
- */
- refresh_parent_coin_pub: string | undefined;
- /**
- * Public key of the reserve for this coin.
- * Only present if this coin was obtained via refreshing.
- */
- withdrawal_reserve_pub: string | undefined;
- /**
- * Is the coin suspended?
- * Suspended coins are not considered for payments.
- */
- coin_suspended: boolean;
- }>;
-}
-
-export interface MerchantPayResponse {
- sig: string;
-}
-
-export interface ExchangeMeltResponse {
- /**
- * Which of the kappa indices does the client not have to reveal.
- */
- noreveal_index: number;
-
- /**
- * Signature of TALER_RefreshMeltConfirmationPS whereby the exchange
- * affirms the successful melt and confirming the noreveal_index
- */
- exchange_sig: EddsaSignatureString;
-
- /*
- * public EdDSA key of the exchange that was used to generate the signature.
- * Should match one of the exchange's signing keys from /keys. Again given
- * explicitly as the client might otherwise be confused by clock skew as to
- * which signing key was used.
- */
- exchange_pub: EddsaPublicKeyString;
-
- /*
- * Base URL to use for operations on the refresh context
- * (so the reveal operation). If not given,
- * the base URL is the same as the one used for this request.
- * Can be used if the base URL for /refreshes/ differs from that
- * for /coins/, i.e. for load balancing. Clients SHOULD
- * respect the refresh_base_url if provided. Any HTTP server
- * belonging to an exchange MUST generate a 307 or 308 redirection
- * to the correct base URL should a client uses the wrong base
- * URL, or if the base URL has changed since the melt.
- *
- * When melting the same coin twice (technically allowed
- * as the response might have been lost on the network),
- * the exchange may return different values for the refresh_base_url.
- */
- refresh_base_url?: string;
-}
-
-export interface ExchangeRevealItem {
- ev_sig: string;
-}
-
-export interface ExchangeRevealResponse {
- // List of the exchange's blinded RSA signatures on the new coins.
- ev_sigs: ExchangeRevealItem[];
-}
-
-interface MerchantOrderStatusPaid {
- /**
- * Was the payment refunded (even partially, via refund or abort)?
- */
- refunded: boolean;
-
- /**
- * Amount that was refunded in total.
- */
- refund_amount: AmountString;
-}
-
-interface MerchantOrderRefundResponse {
- /**
- * Amount that was refunded in total.
- */
- refund_amount: AmountString;
-
- /**
- * Successful refunds for this payment, empty array for none.
- */
- refunds: MerchantCoinRefundStatus[];
-
- /**
- * Public key of the merchant.
- */
- merchant_pub: EddsaPublicKeyString;
-}
-
-export type MerchantCoinRefundStatus =
- | MerchantCoinRefundSuccessStatus
- | MerchantCoinRefundFailureStatus;
-
-export interface MerchantCoinRefundSuccessStatus {
- type: "success";
-
- // HTTP status of the exchange request, 200 (integer) required for refund confirmations.
- exchange_status: 200;
-
- // the EdDSA :ref:signature (binary-only) with purpose
- // TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND using a current signing key of the
- // exchange affirming the successful refund
- exchange_sig: EddsaSignatureString;
-
- // public EdDSA key of the exchange that was used to generate the signature.
- // Should match one of the exchange's signing keys from /keys. It is given
- // explicitly as the client might otherwise be confused by clock skew as to
- // which signing key was used.
- exchange_pub: EddsaPublicKeyString;
-
- // Refund transaction ID.
- rtransaction_id: number;
-
- // public key of a coin that was refunded
- coin_pub: EddsaPublicKeyString;
-
- // Amount that was refunded, including refund fee charged by the exchange
- // to the customer.
- refund_amount: AmountString;
-
- execution_time: Timestamp;
-}
-
-export interface MerchantCoinRefundFailureStatus {
- type: "failure";
-
- // HTTP status of the exchange request, must NOT be 200.
- exchange_status: number;
-
- // Taler error code from the exchange reply, if available.
- exchange_code?: number;
-
- // If available, HTTP reply from the exchange.
- exchange_reply?: any;
-
- // Refund transaction ID.
- rtransaction_id: number;
-
- // public key of a coin that was refunded
- coin_pub: EddsaPublicKeyString;
-
- // Amount that was refunded, including refund fee charged by the exchange
- // to the customer.
- refund_amount: AmountString;
-
- execution_time: Timestamp;
-}
-
-export interface MerchantOrderStatusUnpaid {
- /**
- * URI that the wallet must process to complete the payment.
- */
- taler_pay_uri: string;
-
- /**
- * Alternative order ID which was paid for already in the same session.
- *
- * Only given if the same product was purchased before in the same session.
- */
- already_paid_order_id?: string;
-}
-
-/**
- * Response body for the following endpoint:
- *
- * POST {talerBankIntegrationApi}/withdrawal-operation/{wopid}
- */
-export interface BankWithdrawalOperationPostResponse {
- transfer_done: boolean;
-}
-
-export const codecForBankWithdrawalOperationPostResponse = (): Codec<BankWithdrawalOperationPostResponse> =>
- buildCodecForObject<BankWithdrawalOperationPostResponse>()
- .property("transfer_done", codecForBoolean())
- .build("BankWithdrawalOperationPostResponse");
-
-export type AmountString = string;
-export type Base32String = string;
-export type EddsaSignatureString = string;
-export type EddsaPublicKeyString = string;
-export type CoinPublicKeyString = string;
-
-export const codecForDenomination = (): Codec<Denomination> =>
- buildCodecForObject<Denomination>()
- .property("value", codecForString())
- .property("denom_pub", codecForString())
- .property("fee_withdraw", codecForString())
- .property("fee_deposit", codecForString())
- .property("fee_refresh", codecForString())
- .property("fee_refund", codecForString())
- .property("stamp_start", codecForTimestamp)
- .property("stamp_expire_withdraw", codecForTimestamp)
- .property("stamp_expire_legal", codecForTimestamp)
- .property("stamp_expire_deposit", codecForTimestamp)
- .property("master_sig", codecForString())
- .build("Denomination");
-
-export const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> =>
- buildCodecForObject<AuditorDenomSig>()
- .property("denom_pub_h", codecForString())
- .property("auditor_sig", codecForString())
- .build("AuditorDenomSig");
-
-export const codecForAuditor = (): Codec<Auditor> =>
- buildCodecForObject<Auditor>()
- .property("auditor_pub", codecForString())
- .property("auditor_url", codecForString())
- .property("denomination_keys", codecForList(codecForAuditorDenomSig()))
- .build("Auditor");
-
-export const codecForExchangeHandle = (): Codec<ExchangeHandle> =>
- buildCodecForObject<ExchangeHandle>()
- .property("master_pub", codecForString())
- .property("url", codecForString())
- .build("ExchangeHandle");
-
-export const codecForAuditorHandle = (): Codec<AuditorHandle> =>
- buildCodecForObject<AuditorHandle>()
- .property("name", codecForString())
- .property("auditor_pub", codecForString())
- .property("url", codecForString())
- .build("AuditorHandle");
-
-export const codecForLocation = (): Codec<Location> =>
- buildCodecForObject<Location>()
- .property("country", codecOptional(codecForString()))
- .property("country_subdivision", codecOptional(codecForString()))
- .property("building_name", codecOptional(codecForString()))
- .property("building_number", codecOptional(codecForString()))
- .property("district", codecOptional(codecForString()))
- .property("street", codecOptional(codecForString()))
- .property("post_code", codecOptional(codecForString()))
- .property("town", codecOptional(codecForString()))
- .property("town_location", codecOptional(codecForString()))
- .property("address_lines", codecOptional(codecForList(codecForString())))
- .build("Location");
-
-export const codecForMerchantInfo = (): Codec<MerchantInfo> =>
- buildCodecForObject<MerchantInfo>()
- .property("name", codecForString())
- .property("address", codecOptional(codecForLocation()))
- .property("jurisdiction", codecOptional(codecForLocation()))
- .build("MerchantInfo");
-
-export const codecForTax = (): Codec<Tax> =>
- buildCodecForObject<Tax>()
- .property("name", codecForString())
- .property("tax", codecForString())
- .build("Tax");
-
-export const codecForInternationalizedString = (): Codec<InternationalizedString> =>
- codecForMap(codecForString());
-
-export const codecForProduct = (): Codec<Product> =>
- buildCodecForObject<Product>()
- .property("product_id", codecOptional(codecForString()))
- .property("description", codecForString())
- .property(
- "description_i18n",
- codecOptional(codecForInternationalizedString()),
- )
- .property("quantity", codecOptional(codecForNumber()))
- .property("unit", codecOptional(codecForString()))
- .property("price", codecOptional(codecForString()))
- .build("Tax");
-
-export const codecForContractTerms = (): Codec<ContractTerms> =>
- buildCodecForObject<ContractTerms>()
- .property("order_id", codecForString())
- .property("fulfillment_url", codecOptional(codecForString()))
- .property("fulfillment_message", codecOptional(codecForString()))
- .property(
- "fulfillment_message_i18n",
- codecOptional(codecForInternationalizedString()),
- )
- .property("merchant_base_url", codecForString())
- .property("h_wire", codecForString())
- .property("auto_refund", codecOptional(codecForDuration))
- .property("wire_method", codecForString())
- .property("summary", codecForString())
- .property("summary_i18n", codecOptional(codecForInternationalizedString()))
- .property("nonce", codecForString())
- .property("amount", codecForString())
- .property("auditors", codecForList(codecForAuditorHandle()))
- .property("pay_deadline", codecForTimestamp)
- .property("refund_deadline", codecForTimestamp)
- .property("wire_transfer_deadline", codecForTimestamp)
- .property("timestamp", codecForTimestamp)
- .property("delivery_location", codecOptional(codecForLocation()))
- .property("delivery_date", codecOptional(codecForTimestamp))
- .property("max_fee", codecForString())
- .property("max_wire_fee", codecOptional(codecForString()))
- .property("merchant", codecForMerchantInfo())
- .property("merchant_pub", codecForString())
- .property("exchanges", codecForList(codecForExchangeHandle()))
- .property("products", codecOptional(codecForList(codecForProduct())))
- .property("extra", codecForAny())
- .build("ContractTerms");
-
-export const codecForMerchantRefundPermission = (): Codec<MerchantAbortPayRefundDetails> =>
- buildCodecForObject<MerchantAbortPayRefundDetails>()
- .property("refund_amount", codecForAmountString())
- .property("refund_fee", codecForAmountString())
- .property("coin_pub", codecForString())
- .property("rtransaction_id", codecForNumber())
- .property("exchange_http_status", codecForNumber())
- .property("exchange_code", codecOptional(codecForNumber()))
- .property("exchange_reply", codecOptional(codecForAny()))
- .property("exchange_sig", codecOptional(codecForString()))
- .property("exchange_pub", codecOptional(codecForString()))
- .build("MerchantRefundPermission");
-
-export const codecForMerchantRefundResponse = (): Codec<MerchantRefundResponse> =>
- buildCodecForObject<MerchantRefundResponse>()
- .property("merchant_pub", codecForString())
- .property("h_contract_terms", codecForString())
- .property("refunds", codecForList(codecForMerchantRefundPermission()))
- .build("MerchantRefundResponse");
-
-export const codecForBlindSigWrapper = (): Codec<BlindSigWrapper> =>
- buildCodecForObject<BlindSigWrapper>()
- .property("blind_sig", codecForString())
- .build("BlindSigWrapper");
-
-export const codecForTipResponse = (): Codec<TipResponse> =>
- buildCodecForObject<TipResponse>()
- .property("blind_sigs", codecForList(codecForBlindSigWrapper()))
- .build("TipResponse");
-
-export const codecForRecoup = (): Codec<Recoup> =>
- buildCodecForObject<Recoup>()
- .property("h_denom_pub", codecForString())
- .build("Recoup");
-
-export const codecForExchangeSigningKey = (): Codec<ExchangeSignKeyJson> =>
- buildCodecForObject<ExchangeSignKeyJson>()
- .property("key", codecForString())
- .property("master_sig", codecForString())
- .property("stamp_end", codecForTimestamp)
- .property("stamp_start", codecForTimestamp)
- .property("stamp_expire", codecForTimestamp)
- .build("ExchangeSignKeyJson");
-
-export const codecForExchangeKeysJson = (): Codec<ExchangeKeysJson> =>
- buildCodecForObject<ExchangeKeysJson>()
- .property("denoms", codecForList(codecForDenomination()))
- .property("master_public_key", codecForString())
- .property("auditors", codecForList(codecForAuditor()))
- .property("list_issue_date", codecForTimestamp)
- .property("recoup", codecOptional(codecForList(codecForRecoup())))
- .property("signkeys", codecForList(codecForExchangeSigningKey()))
- .property("version", codecForString())
- .property("reserve_closing_delay", codecForDuration)
- .build("KeysJson");
-
-export const codecForWireFeesJson = (): Codec<WireFeesJson> =>
- buildCodecForObject<WireFeesJson>()
- .property("wire_fee", codecForString())
- .property("closing_fee", codecForString())
- .property("sig", codecForString())
- .property("start_date", codecForTimestamp)
- .property("end_date", codecForTimestamp)
- .build("WireFeesJson");
-
-export const codecForAccountInfo = (): Codec<AccountInfo> =>
- buildCodecForObject<AccountInfo>()
- .property("payto_uri", codecForString())
- .property("master_sig", codecForString())
- .build("AccountInfo");
-
-export const codecForExchangeWireJson = (): Codec<ExchangeWireJson> =>
- buildCodecForObject<ExchangeWireJson>()
- .property("accounts", codecForList(codecForAccountInfo()))
- .property("fees", codecForMap(codecForList(codecForWireFeesJson())))
- .build("ExchangeWireJson");
-
-export const codecForProposal = (): Codec<Proposal> =>
- buildCodecForObject<Proposal>()
- .property("contract_terms", codecForAny())
- .property("sig", codecForString())
- .build("Proposal");
-
-export const codecForCheckPaymentResponse = (): Codec<CheckPaymentResponse> =>
- buildCodecForObject<CheckPaymentResponse>()
- .property("order_status", codecForString())
- .property("refunded", codecOptional(codecForBoolean()))
- .property("refunded_amount", codecOptional(codecForString()))
- .property("contract_terms", codecOptional(codecForAny()))
- .property("taler_pay_uri", codecOptional(codecForString()))
- .property("contract_url", codecOptional(codecForString()))
- .build("CheckPaymentResponse");
-
-export const codecForWithdrawOperationStatusResponse = (): Codec<WithdrawOperationStatusResponse> =>
- buildCodecForObject<WithdrawOperationStatusResponse>()
- .property("selection_done", codecForBoolean())
- .property("transfer_done", codecForBoolean())
- .property("aborted", codecForBoolean())
- .property("amount", codecForString())
- .property("sender_wire", codecOptional(codecForString()))
- .property("suggested_exchange", codecOptional(codecForString()))
- .property("confirm_transfer_url", codecOptional(codecForString()))
- .property("wire_types", codecForList(codecForString()))
- .build("WithdrawOperationStatusResponse");
-
-export const codecForTipPickupGetResponse = (): Codec<TipPickupGetResponse> =>
- buildCodecForObject<TipPickupGetResponse>()
- .property("tip_amount", codecForString())
- .property("exchange_url", codecForString())
- .property("expiration", codecForTimestamp)
- .build("TipPickupGetResponse");
-
-export const codecForRecoupConfirmation = (): Codec<RecoupConfirmation> =>
- buildCodecForObject<RecoupConfirmation>()
- .property("reserve_pub", codecOptional(codecForString()))
- .property("old_coin_pub", codecOptional(codecForString()))
- .build("RecoupConfirmation");
-
-export const codecForWithdrawResponse = (): Codec<WithdrawResponse> =>
- buildCodecForObject<WithdrawResponse>()
- .property("ev_sig", codecForString())
- .build("WithdrawResponse");
-
-export const codecForMerchantPayResponse = (): Codec<MerchantPayResponse> =>
- buildCodecForObject<MerchantPayResponse>()
- .property("sig", codecForString())
- .build("MerchantPayResponse");
-
-export const codecForExchangeMeltResponse = (): Codec<ExchangeMeltResponse> =>
- buildCodecForObject<ExchangeMeltResponse>()
- .property("exchange_pub", codecForString())
- .property("exchange_sig", codecForString())
- .property("noreveal_index", codecForNumber())
- .property("refresh_base_url", codecOptional(codecForString()))
- .build("ExchangeMeltResponse");
-
-export const codecForExchangeRevealItem = (): Codec<ExchangeRevealItem> =>
- buildCodecForObject<ExchangeRevealItem>()
- .property("ev_sig", codecForString())
- .build("ExchangeRevealItem");
-
-export const codecForExchangeRevealResponse = (): Codec<ExchangeRevealResponse> =>
- buildCodecForObject<ExchangeRevealResponse>()
- .property("ev_sigs", codecForList(codecForExchangeRevealItem()))
- .build("ExchangeRevealResponse");
-
-export const codecForMerchantCoinRefundSuccessStatus = (): Codec<MerchantCoinRefundSuccessStatus> =>
- buildCodecForObject<MerchantCoinRefundSuccessStatus>()
- .property("type", codecForConstString("success"))
- .property("coin_pub", codecForString())
- .property("exchange_status", codecForConstNumber(200))
- .property("exchange_sig", codecForString())
- .property("rtransaction_id", codecForNumber())
- .property("refund_amount", codecForString())
- .property("exchange_pub", codecForString())
- .property("execution_time", codecForTimestamp)
- .build("MerchantCoinRefundSuccessStatus");
-
-export const codecForMerchantCoinRefundFailureStatus = (): Codec<MerchantCoinRefundFailureStatus> =>
- buildCodecForObject<MerchantCoinRefundFailureStatus>()
- .property("type", codecForConstString("failure"))
- .property("coin_pub", codecForString())
- .property("exchange_status", codecForNumber())
- .property("rtransaction_id", codecForNumber())
- .property("refund_amount", codecForString())
- .property("exchange_code", codecOptional(codecForNumber()))
- .property("exchange_reply", codecOptional(codecForAny()))
- .property("execution_time", codecForTimestamp)
- .build("MerchantCoinRefundFailureStatus");
-
-export const codecForMerchantCoinRefundStatus = (): Codec<MerchantCoinRefundStatus> =>
- buildCodecForUnion<MerchantCoinRefundStatus>()
- .discriminateOn("type")
- .alternative("success", codecForMerchantCoinRefundSuccessStatus())
- .alternative("failure", codecForMerchantCoinRefundFailureStatus())
- .build("MerchantCoinRefundStatus");
-
-export const codecForMerchantOrderStatusPaid = (): Codec<MerchantOrderStatusPaid> =>
- buildCodecForObject<MerchantOrderStatusPaid>()
- .property("refund_amount", codecForString())
- .property("refunded", codecForBoolean())
- .build("MerchantOrderStatusPaid");
-
-export const codecForMerchantOrderRefundPickupResponse = (): Codec<MerchantOrderRefundResponse> =>
- buildCodecForObject<MerchantOrderRefundResponse>()
- .property("merchant_pub", codecForString())
- .property("refund_amount", codecForString())
- .property("refunds", codecForList(codecForMerchantCoinRefundStatus()))
- .build("MerchantOrderRefundPickupResponse");
-
-export const codecForMerchantOrderStatusUnpaid = (): Codec<MerchantOrderStatusUnpaid> =>
- buildCodecForObject<MerchantOrderStatusUnpaid>()
- .property("taler_pay_uri", codecForString())
- .property("already_paid_order_id", codecOptional(codecForString()))
- .build("MerchantOrderStatusUnpaid");
-
-export interface AbortRequest {
- // hash of the order's contract terms (this is used to authenticate the
- // wallet/customer in case $ORDER_ID is guessable).
- h_contract: string;
-
- // List of coins the wallet would like to see refunds for.
- // (Should be limited to the coins for which the original
- // payment succeeded, as far as the wallet knows.)
- coins: AbortingCoin[];
-}
-
-export interface AbortingCoin {
- // Public key of a coin for which the wallet is requesting an abort-related refund.
- coin_pub: EddsaPublicKeyString;
-
- // The amount to be refunded (matches the original contribution)
- contribution: AmountString;
-
- // URL of the exchange this coin was withdrawn from.
- exchange_url: string;
-}
-
-export interface AbortResponse {
- // List of refund responses about the coins that the wallet
- // requested an abort for. In the same order as the 'coins'
- // from the original request.
- // The rtransaction_id is implied to be 0.
- refunds: MerchantAbortPayRefundStatus[];
-}
-
-export const codecForAbortResponse = (): Codec<AbortResponse> =>
- buildCodecForObject<AbortResponse>()
- .property("refunds", codecForList(codecForMerchantAbortPayRefundStatus()))
- .build("AbortResponse");
-
-export type MerchantAbortPayRefundStatus =
- | MerchantAbortPayRefundSuccessStatus
- | MerchantAbortPayRefundFailureStatus;
-
-// Details about why a refund failed.
-export interface MerchantAbortPayRefundFailureStatus {
- // Used as tag for the sum type RefundStatus sum type.
- type: "failure";
-
- // HTTP status of the exchange request, must NOT be 200.
- exchange_status: number;
-
- // Taler error code from the exchange reply, if available.
- exchange_code?: number;
-
- // If available, HTTP reply from the exchange.
- exchange_reply?: unknown;
-}
-
-// Additional details needed to verify the refund confirmation signature
-// (h_contract_terms and merchant_pub) are already known
-// to the wallet and thus not included.
-export interface MerchantAbortPayRefundSuccessStatus {
- // Used as tag for the sum type MerchantCoinRefundStatus sum type.
- type: "success";
-
- // HTTP status of the exchange request, 200 (integer) required for refund confirmations.
- exchange_status: 200;
-
- // the EdDSA :ref:signature (binary-only) with purpose
- // TALER_SIGNATURE_EXCHANGE_CONFIRM_REFUND using a current signing key of the
- // exchange affirming the successful refund
- exchange_sig: string;
-
- // public EdDSA key of the exchange that was used to generate the signature.
- // Should match one of the exchange's signing keys from /keys. It is given
- // explicitly as the client might otherwise be confused by clock skew as to
- // which signing key was used.
- exchange_pub: string;
-}
-
-export const codecForMerchantAbortPayRefundSuccessStatus = (): Codec<MerchantAbortPayRefundSuccessStatus> =>
- buildCodecForObject<MerchantAbortPayRefundSuccessStatus>()
- .property("exchange_pub", codecForString())
- .property("exchange_sig", codecForString())
- .property("exchange_status", codecForConstNumber(200))
- .property("type", codecForConstString("success"))
- .build("MerchantAbortPayRefundSuccessStatus");
-
-export const codecForMerchantAbortPayRefundFailureStatus = (): Codec<MerchantAbortPayRefundFailureStatus> =>
- buildCodecForObject<MerchantAbortPayRefundFailureStatus>()
- .property("exchange_code", codecForNumber())
- .property("exchange_reply", codecForAny())
- .property("exchange_status", codecForNumber())
- .property("type", codecForConstString("failure"))
- .build("MerchantAbortPayRefundFailureStatus");
-
-export const codecForMerchantAbortPayRefundStatus = (): Codec<MerchantAbortPayRefundStatus> =>
- buildCodecForUnion<MerchantAbortPayRefundStatus>()
- .discriminateOn("type")
- .alternative("success", codecForMerchantAbortPayRefundSuccessStatus())
- .alternative("failure", codecForMerchantAbortPayRefundFailureStatus())
- .build("MerchantAbortPayRefundStatus");
-
-export interface TalerConfigResponse {
- name: string;
- version: string;
- currency?: string;
-}
-
-export const codecForTalerConfigResponse = (): Codec<TalerConfigResponse> =>
- buildCodecForObject<TalerConfigResponse>()
- .property("name", codecForString())
- .property("version", codecForString())
- .property("currency", codecOptional(codecForString()))
- .build("TalerConfigResponse");
-
-export interface FutureKeysResponse {
- future_denoms: any[];
-
- future_signkeys: any[];
-
- master_pub: string;
-
- denom_secmod_public_key: string;
-
- // Public key of the signkey security module.
- signkey_secmod_public_key: string;
-}
-
-export const codecForKeysManagementResponse = (): Codec<FutureKeysResponse> =>
- buildCodecForObject<FutureKeysResponse>()
- .property("master_pub", codecForString())
- .property("future_signkeys", codecForList(codecForAny()))
- .property("future_denoms", codecForList(codecForAny()))
- .property("denom_secmod_public_key", codecForAny())
- .property("signkey_secmod_public_key", codecForAny())
- .build("FutureKeysResponse");
diff --git a/packages/taler-util/src/talerconfig.ts b/packages/taler-util/src/talerconfig.ts
index 37aace10a..83c0044be 100644
--- a/packages/taler-util/src/talerconfig.ts
+++ b/packages/taler-util/src/talerconfig.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
+ (C) 2020-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -25,51 +25,13 @@
*/
import { AmountJson } from "./amounts.js";
import { Amounts } from "./amounts.js";
+import { Logger } from "./logging.js";
-const nodejs_fs = (function () {
- let fs: typeof import("fs");
- return function () {
- if (!fs) {
- /**
- * need to use an expression when doing a require if we want
- * webpack not to find out about the requirement
- */
- const _r = "require";
- fs = module[_r]("fs");
- }
- return fs;
- };
-})();
-
-const nodejs_path = (function () {
- let path: typeof import("path");
- return function () {
- if (!path) {
- /**
- * need to use an expression when doing a require if we want
- * webpack not to find out about the requirement
- */
- const _r = "require";
- path = module[_r]("path");
- }
- return path;
- };
-})();
-
-const nodejs_os = (function () {
- let os: typeof import("os");
- return function () {
- if (!os) {
- /**
- * need to use an expression when doing a require if we want
- * webpack not to find out about the requirement
- */
- const _r = "require";
- os = module[_r]("os");
- }
- return os;
- };
-})();
+import nodejs_path from "path";
+import nodejs_os from "os";
+import nodejs_fs from "fs";
+
+const logger = new Logger("talerconfig.ts");
export class ConfigError extends Error {
constructor(message: string) {
@@ -80,10 +42,30 @@ export class ConfigError extends Error {
}
}
+enum EntryOrigin {
+ /**
+ * From a default file.
+ */
+ DefaultFile = 1,
+ /**
+ * From a system/installation specific default value.
+ */
+ DefaultSystem = 2,
+ /**
+ * Loaded from file or string
+ */
+ Loaded = 3,
+ /**
+ * Changed after loading
+ */
+ Changed = 4,
+}
+
interface Entry {
value: string;
sourceLine: number;
sourceFile: string;
+ origin: EntryOrigin;
}
interface Section {
@@ -98,7 +80,7 @@ export class ConfigValue<T> {
constructor(
private sectionName: string,
private optionName: string,
- public value: string | undefined,
+ private value: string | undefined,
private converter: (x: string) => T,
) {}
@@ -130,6 +112,10 @@ export class ConfigValue<T> {
isDefined(): boolean {
return this.value !== undefined;
}
+
+ getValue(): string | undefined {
+ return this.value;
+ }
}
/**
@@ -138,10 +124,10 @@ export class ConfigValue<T> {
*/
export function expandPath(path: string): string {
if (path[0] === "~") {
- path = nodejs_path().join(nodejs_os().homedir(), path.slice(1));
+ path = nodejs_path.join(nodejs_os.homedir(), path.slice(1));
}
if (path[0] !== "/") {
- path = nodejs_path().join(process.cwd(), path);
+ path = nodejs_path.join(process.cwd(), path);
}
return path;
}
@@ -157,9 +143,9 @@ export function expandPath(path: string): string {
export function pathsub(
x: string,
lookup: (s: string, depth: number) => string | undefined,
- depth = 0,
+ recursionDepth = 0,
): string {
- if (depth >= 10) {
+ if (recursionDepth >= 128) {
throw Error("recursion in path substitution");
}
let s = x;
@@ -198,14 +184,14 @@ export function pathsub(
defaultValue = undefined;
}
- const r = lookup(inner, depth + 1);
+ const r = lookup(varname, depth + 1);
if (r !== undefined) {
- s = s.substr(0, start) + r + s.substr(p + 1);
+ s = s.substring(0, start) + r + s.substring(p + 1);
l = start + r.length;
continue;
} else if (defaultValue !== undefined) {
const resolvedDefault = pathsub(defaultValue, lookup, depth + 1);
- s = s.substr(0, start) + resolvedDefault + s.substr(p + 1);
+ s = s.substring(0, start) + resolvedDefault + s.substring(p + 1);
l = start + resolvedDefault.length;
continue;
}
@@ -215,9 +201,9 @@ export function pathsub(
} else {
const m = /^[a-zA-Z-_][a-zA-Z0-9-_]*/.exec(s.substring(l + 1));
if (m && m[0]) {
- const r = lookup(m[0], depth + 1);
+ const r = lookup(m[0], recursionDepth + 1);
if (r !== undefined) {
- s = s.substr(0, l) + r + s.substr(l + 1 + m[0].length);
+ s = s.substring(0, l) + r + s.substring(l + 1 + m[0].length);
l = l + r.length;
continue;
}
@@ -236,6 +222,7 @@ export interface LoadOptions {
export interface StringifyOptions {
diagnostics?: boolean;
+ excludeDefaults?: boolean;
}
export interface LoadedFile {
@@ -288,15 +275,15 @@ function normalizeInlineFilename(parentFile: string, f: string): string {
if (f[0] === "/") {
return f;
}
- const resolvedParentDir = nodejs_path().dirname(
- nodejs_fs().realpathSync(parentFile),
+ const resolvedParentDir = nodejs_path.dirname(
+ nodejs_fs.realpathSync(parentFile),
);
- return nodejs_path().join(resolvedParentDir, f);
+ return nodejs_path.join(resolvedParentDir, f);
}
/**
* Crude implementation of the which(1) shell command.
- *
+ *
* Tries to locate the location of an executable based on the
* "PATH" environment variable.
*/
@@ -306,8 +293,8 @@ function which(name: string): string | undefined {
return undefined;
}
for (const path of paths) {
- const filename = nodejs_path().join(path, name);
- if (nodejs_fs().existsSync(filename)) {
+ const filename = nodejs_path.join(path, name);
+ if (nodejs_fs.existsSync(filename)) {
return filename;
}
}
@@ -323,7 +310,11 @@ export class Configuration {
private nestLevel = 0;
- private loadFromFilename(filename: string, opts: LoadOptions = {}): void {
+ private loadFromFilename(
+ filename: string,
+ isDefaultSource: boolean,
+ opts: LoadOptions = {},
+ ): void {
filename = expandPath(filename);
const checkCycle = () => {
@@ -342,7 +333,7 @@ export class Configuration {
checkCycle();
- const s = nodejs_fs().readFileSync(filename, "utf-8");
+ const s = nodejs_fs.readFileSync(filename, "utf-8");
this.loadedFiles.push({
filename: filename,
level: this.nestLevel,
@@ -350,7 +341,7 @@ export class Configuration {
const oldNestLevel = this.nestLevel;
this.nestLevel += 1;
try {
- this.loadFromString(s, {
+ this.internalLoadFromString(s, isDefaultSource, {
...opts,
filename: filename,
});
@@ -359,43 +350,51 @@ export class Configuration {
}
}
- private loadGlob(parentFilename: string, fileglob: string): void {
- const resolvedParent = nodejs_fs().realpathSync(parentFilename);
- const parentDir = nodejs_path().dirname(resolvedParent);
+ private loadGlob(
+ parentFilename: string,
+ isDefaultSource: boolean,
+ fileglob: string,
+ ): void {
+ const resolvedParent = nodejs_fs.realpathSync(parentFilename);
+ const parentDir = nodejs_path.dirname(resolvedParent);
let fullFileglob: string;
if (fileglob.startsWith("/")) {
fullFileglob = fileglob;
} else {
- fullFileglob = nodejs_path().join(parentDir, fileglob);
+ fullFileglob = nodejs_path.join(parentDir, fileglob);
}
fullFileglob = expandPath(fullFileglob);
- const head = nodejs_path().dirname(fullFileglob);
- const tail = nodejs_path().basename(fullFileglob);
+ const head = nodejs_path.dirname(fullFileglob);
+ const tail = nodejs_path.basename(fullFileglob);
- const files = nodejs_fs().readdirSync(head);
+ const files = nodejs_fs.readdirSync(head);
for (const f of files) {
if (globMatch(tail, f)) {
- const fullPath = nodejs_path().join(head, f);
- this.loadFromFilename(fullPath);
+ const fullPath = nodejs_path.join(head, f);
+ this.loadFromFilename(fullPath, isDefaultSource);
}
}
}
- private loadSecret(sectionName: string, filename: string): void {
+ private loadSecret(
+ sectionName: string,
+ filename: string,
+ isDefaultSource: boolean,
+ ): void {
const sec = this.provideSection(sectionName);
sec.secretFilename = filename;
const otherCfg = new Configuration();
try {
- nodejs_fs().accessSync(filename, nodejs_fs().constants.R_OK);
+ nodejs_fs.accessSync(filename, nodejs_fs.constants.R_OK);
} catch (err) {
sec.inaccessible = true;
return;
}
- otherCfg.loadFromFilename(filename, {
+ otherCfg.loadFromFilename(filename, isDefaultSource, {
banDirectives: true,
});
const otherSec = otherCfg.provideSection(sectionName);
@@ -404,7 +403,11 @@ export class Configuration {
}
}
- loadFromString(s: string, opts: LoadOptions = {}): void {
+ private internalLoadFromString(
+ s: string,
+ isDefaultSource: boolean,
+ opts: LoadOptions = {},
+ ): void {
let lineNo = 0;
const fn = opts.filename ?? "<input>";
const reComment = /^\s*#.*$/;
@@ -440,7 +443,10 @@ export class Configuration {
);
}
const arg = directiveMatch[2].trim();
- this.loadFromFilename(normalizeInlineFilename(opts.filename, arg));
+ this.loadFromFilename(
+ normalizeInlineFilename(opts.filename, arg),
+ isDefaultSource,
+ );
break;
}
case "inline-secret": {
@@ -460,7 +466,7 @@ export class Configuration {
opts.filename,
sp[1],
);
- this.loadSecret(sp[0], secretFilename);
+ this.loadSecret(sp[0], secretFilename, isDefaultSource);
break;
}
case "inline-matching": {
@@ -470,7 +476,7 @@ export class Configuration {
`invalid configuration, @inline-matching@ directive in ${fn}:${lineNo} can only be used from a file`,
);
}
- this.loadGlob(opts.filename, arg);
+ this.loadGlob(opts.filename, isDefaultSource, arg);
break;
}
default:
@@ -503,6 +509,9 @@ export class Configuration {
value: val,
sourceFile: opts.filename ?? "<unknown>",
sourceLine: lineNo,
+ origin: isDefaultSource
+ ? EntryOrigin.DefaultFile
+ : EntryOrigin.Loaded,
};
continue;
}
@@ -512,6 +521,10 @@ export class Configuration {
}
}
+ loadFromString(s: string, opts: LoadOptions = {}): void {
+ return this.internalLoadFromString(s, false, opts);
+ }
+
private provideSection(section: string): Section {
const secNorm = section.toUpperCase();
if (this.sectionMap[secNorm]) {
@@ -537,6 +550,24 @@ export class Configuration {
value,
sourceLine: 0,
sourceFile: "<unknown>",
+ origin: EntryOrigin.Changed,
+ };
+ }
+
+ /**
+ * Set a string value to a value from default locations.
+ */
+ private setStringSystemDefault(
+ section: string,
+ option: string,
+ value: string,
+ ): void {
+ const sec = this.provideSection(section);
+ sec.entries[option.toUpperCase()] = {
+ value,
+ sourceLine: 0,
+ sourceFile: "<unknown>",
+ origin: EntryOrigin.DefaultSystem,
};
}
@@ -604,11 +635,14 @@ export class Configuration {
if (val !== undefined) {
return pathsub(val, (v, d) => this.lookupVariable(v, d), depth);
}
+
// Environment variables can be case sensitive, respect that.
const envVal = process.env[x];
if (envVal !== undefined) {
return envVal;
}
+
+ logger.warn(`unable to resolve variable '${x}'`);
return;
}
@@ -619,30 +653,79 @@ export class Configuration {
);
}
- loadFrom(dirname: string): void {
- const files = nodejs_fs().readdirSync(dirname);
+ loadDefaultsFromDir(dirname: string): void {
+ const files = nodejs_fs.readdirSync(dirname);
for (const f of files) {
- const fn = nodejs_path().join(dirname, f);
- this.loadFromFilename(fn);
+ const fn = nodejs_path.join(dirname, f);
+ this.loadFromFilename(fn, true);
}
}
private loadDefaults(): void {
- let bc = process.env["TALER_BASE_CONFIG"];
- if (!bc) {
+ let baseConfigDir = process.env["TALER_BASE_CONFIG"];
+ if (!baseConfigDir) {
/* Try to locate the configuration based on the location
* of the taler-config binary. */
const path = which("taler-config");
if (path) {
- bc = nodejs_fs().realpathSync(
- nodejs_path().dirname(path) + "/../share/taler/config.d",
+ baseConfigDir = nodejs_fs.realpathSync(
+ nodejs_path.dirname(path) + "/../share/taler/config.d",
);
}
}
- if (!bc) {
- bc = "/usr/share/taler/config.d";
+ if (!baseConfigDir) {
+ baseConfigDir = "/usr/share/taler/config.d";
}
- this.loadFrom(bc);
+
+ let installPrefix = process.env["TALER_PREFIX"];
+ if (!installPrefix) {
+ /* Try to locate install path based on the location
+ * of the taler-config binary. */
+ const path = which("taler-config");
+ if (path) {
+ installPrefix = nodejs_fs.realpathSync(
+ nodejs_path.dirname(path) + "/..",
+ );
+ }
+ }
+ if (!installPrefix) {
+ installPrefix = "/usr";
+ }
+
+ this.setStringSystemDefault(
+ "PATHS",
+ "LIBEXECDIR",
+ `${installPrefix}/taler/libexec/`,
+ );
+ this.setStringSystemDefault(
+ "PATHS",
+ "DOCDIR",
+ `${installPrefix}/share/doc/taler/`,
+ );
+ this.setStringSystemDefault(
+ "PATHS",
+ "ICONDIR",
+ `${installPrefix}/share/icons/`,
+ );
+ this.setStringSystemDefault(
+ "PATHS",
+ "LOCALEDIR",
+ `${installPrefix}/share/locale/`,
+ );
+ this.setStringSystemDefault("PATHS", "PREFIX", `${installPrefix}/`);
+ this.setStringSystemDefault("PATHS", "BINDIR", `${installPrefix}/bin`);
+ this.setStringSystemDefault(
+ "PATHS",
+ "LIBDIR",
+ `${installPrefix}/lib/taler/`,
+ );
+ this.setStringSystemDefault(
+ "PATHS",
+ "DATADIR",
+ `${installPrefix}/share/taler/`,
+ );
+
+ this.loadDefaultsFromDir(baseConfigDir);
}
getDefaultConfigFilename(): string | undefined {
@@ -650,19 +733,19 @@ export class Configuration {
const home = process.env["HOME"];
let fn: string | undefined;
if (xdg) {
- fn = nodejs_path().join(xdg, "taler.conf");
+ fn = nodejs_path.join(xdg, "taler.conf");
} else if (home) {
- fn = nodejs_path().join(home, ".config/taler.conf");
+ fn = nodejs_path.join(home, ".config/taler.conf");
}
- if (fn && nodejs_fs().existsSync(fn)) {
+ if (fn && nodejs_fs.existsSync(fn)) {
return fn;
}
const etc1 = "/etc/taler.conf";
- if (nodejs_fs().existsSync(etc1)) {
+ if (nodejs_fs.existsSync(etc1)) {
return etc1;
}
const etc2 = "/etc/taler/taler.conf";
- if (nodejs_fs().existsSync(etc2)) {
+ if (nodejs_fs.existsSync(etc2)) {
return etc2;
}
return undefined;
@@ -672,11 +755,13 @@ export class Configuration {
const cfg = new Configuration();
cfg.loadDefaults();
if (filename) {
- cfg.loadFromFilename(filename);
+ cfg.loadFromFilename(filename, false);
} else {
const fn = cfg.getDefaultConfigFilename();
if (fn) {
- cfg.loadFromFilename(fn);
+ // It's the default filename for the main config file,
+ // but we don't consider the values default values.
+ cfg.loadFromFilename(fn, false);
}
}
cfg.hintEntrypoint = filename;
@@ -698,26 +783,51 @@ export class Configuration {
}
for (const sectionName of Object.keys(this.sectionMap)) {
const sec = this.sectionMap[sectionName];
- if (opts.diagnostics && sec.secretFilename) {
- s += `# Secret section from ${sec.secretFilename}\n`;
- s += `# Secret accessible: ${!sec.inaccessible}\n`;
- }
- s += `[${sectionName}]\n`;
+ let headerWritten = false;
for (const optionName of Object.keys(sec.entries)) {
const entry = this.sectionMap[sectionName].entries[optionName];
+ if (
+ opts.excludeDefaults &&
+ (entry.origin === EntryOrigin.DefaultSystem ||
+ entry.origin === EntryOrigin.DefaultFile)
+ ) {
+ continue;
+ }
+ if (!headerWritten) {
+ if (opts.diagnostics && sec.secretFilename) {
+ s += `# Secret section from ${sec.secretFilename}\n`;
+ s += `# Secret accessible: ${!sec.inaccessible}\n`;
+ }
+ s += `[${sectionName}]\n`;
+ headerWritten = true;
+ }
if (entry !== undefined) {
if (opts.diagnostics) {
- s += `# ${entry.sourceFile}:${entry.sourceLine}\n`;
+ switch (entry.origin) {
+ case EntryOrigin.DefaultFile:
+ case EntryOrigin.Changed:
+ case EntryOrigin.Loaded:
+ s += `# ${entry.sourceFile}:${entry.sourceLine}\n`;
+ break;
+ case EntryOrigin.DefaultSystem:
+ s += `# (system/installation default)\n`;
+ break;
+ }
}
s += `${optionName} = ${entry.value}\n`;
}
}
- s += "\n";
+ if (headerWritten) {
+ s += "\n";
+ }
}
return s;
}
- write(filename: string): void {
- nodejs_fs().writeFileSync(filename, this.stringify());
+ write(filename: string, opts: { excludeDefaults?: boolean } = {}): void {
+ nodejs_fs.writeFileSync(
+ filename,
+ this.stringify({ excludeDefaults: opts.excludeDefaults }),
+ );
}
}
diff --git a/packages/taler-util/src/taleruri.test.ts b/packages/taler-util/src/taleruri.test.ts
index 5bf7ad4ee..7f10d21fd 100644
--- a/packages/taler-util/src/taleruri.test.ts
+++ b/packages/taler-util/src/taleruri.test.ts
@@ -15,23 +15,67 @@
*/
import test from "ava";
+import { AmountString } from "./taler-types.js";
import {
+ parseAddExchangeUri,
+ parseDevExperimentUri,
+ parsePayPullUri,
+ parsePayPushUri,
+ parsePayTemplateUri,
parsePayUri,
- parseWithdrawUri,
parseRefundUri,
- parseTipUri,
+ parseRestoreUri,
+ parseWithdrawExchangeUri,
+ parseWithdrawUri,
+ stringifyAddExchange,
+ stringifyDevExperimentUri,
+ stringifyPayPullUri,
+ stringifyPayPushUri,
+ stringifyPayTemplateUri,
+ stringifyPayUri,
+ stringifyRefundUri,
+ stringifyRestoreUri,
+ stringifyWithdrawExchange,
+ stringifyWithdrawUri,
} from "./taleruri.js";
-test("taler pay url parsing: wrong scheme", (t) => {
- const url1 = "talerfoo://";
- const r1 = parsePayUri(url1);
- t.is(r1, undefined);
+/**
+ * 5.1 action: withdraw https://lsd.gnunet.org/lsd0006/#name-action-withdraw
+ */
- const url2 = "taler://refund/a/b/c/d/e/f";
- const r2 = parsePayUri(url2);
- t.is(r2, undefined);
+test("taler withdraw uri parsing", (t) => {
+ const url1 = "taler://withdraw/bank.example.com/12345";
+ const r1 = parseWithdrawUri(url1);
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.is(r1.withdrawalOperationId, "12345");
+ t.is(r1.bankIntegrationApiBaseUrl, "https://bank.example.com/");
+});
+
+test("taler withdraw uri parsing (http)", (t) => {
+ const url1 = "taler+http://withdraw/bank.example.com/12345";
+ const r1 = parseWithdrawUri(url1);
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.is(r1.withdrawalOperationId, "12345");
+ t.is(r1.bankIntegrationApiBaseUrl, "http://bank.example.com/");
});
+test("taler withdraw URI (stringify)", (t) => {
+ const url = stringifyWithdrawUri({
+ bankIntegrationApiBaseUrl: "https://bank.taler.test/integration-api/",
+ withdrawalOperationId: "123",
+ });
+ t.deepEqual(url, "taler://withdraw/bank.taler.test/integration-api/123");
+});
+
+/**
+ * 5.2 action: pay https://lsd.gnunet.org/lsd0006/#name-action-pay
+ */
test("taler pay url parsing: defaults", (t) => {
const url1 = "taler://pay/example.com/myorder/";
const r1 = parsePayUri(url1);
@@ -75,17 +119,6 @@ test("taler pay url parsing (claim token)", (t) => {
t.is(r1.claimToken, "ASDF");
});
-test("taler refund uri parsing: non-https #1", (t) => {
- const url1 = "taler+http://refund/example.com/myorder/";
- const r1 = parseRefundUri(url1);
- if (!r1) {
- t.fail();
- return;
- }
- t.is(r1.merchantBaseUrl, "http://example.com/");
- t.is(r1.orderId, "myorder");
-});
-
test("taler pay uri parsing: non-https", (t) => {
const url1 = "taler+http://pay/example.com/myorder/";
const r1 = parsePayUri(url1);
@@ -107,26 +140,52 @@ test("taler pay uri parsing: missing session component", (t) => {
t.pass();
});
-test("taler withdraw uri parsing", (t) => {
- const url1 = "taler://withdraw/bank.example.com/12345";
- const r1 = parseWithdrawUri(url1);
- if (!r1) {
- t.fail();
- return;
- }
- t.is(r1.withdrawalOperationId, "12345");
- t.is(r1.bankIntegrationApiBaseUrl, "https://bank.example.com/");
+test("taler pay URI (stringify)", (t) => {
+ const url1 = stringifyPayUri({
+ merchantBaseUrl: "http://localhost:123/",
+ orderId: "foo",
+ sessionId: "",
+ });
+ t.deepEqual(url1, "taler+http://pay/localhost:123/foo/");
+
+ const url2 = stringifyPayUri({
+ merchantBaseUrl: "http://localhost:123/",
+ orderId: "foo",
+ sessionId: "bla",
+ });
+ t.deepEqual(url2, "taler+http://pay/localhost:123/foo/bla");
});
-test("taler withdraw uri parsing (http)", (t) => {
- const url1 = "taler+http://withdraw/bank.example.com/12345";
- const r1 = parseWithdrawUri(url1);
+test("taler pay URI (stringify with https)", (t) => {
+ const url1 = stringifyPayUri({
+ merchantBaseUrl: "https://localhost:123/",
+ orderId: "foo",
+ sessionId: "",
+ });
+ t.deepEqual(url1, "taler://pay/localhost:123/foo/");
+
+ const url2 = stringifyPayUri({
+ merchantBaseUrl: "https://localhost/",
+ orderId: "foo",
+ sessionId: "bla",
+ noncePriv: "123",
+ });
+ t.deepEqual(url2, "taler://pay/localhost/foo/bla?n=123");
+});
+
+/**
+ * 5.3 action: refund https://lsd.gnunet.org/lsd0006/#name-action-refund
+ */
+
+test("taler refund uri parsing: non-https #1", (t) => {
+ const url1 = "taler+http://refund/example.com/myorder/";
+ const r1 = parseRefundUri(url1);
if (!r1) {
t.fail();
return;
}
- t.is(r1.withdrawalOperationId, "12345");
- t.is(r1.bankIntegrationApiBaseUrl, "http://bank.example.com/");
+ t.is(r1.merchantBaseUrl, "http://example.com/");
+ t.is(r1.orderId, "myorder");
});
test("taler refund uri parsing", (t) => {
@@ -151,34 +210,358 @@ test("taler refund uri parsing with instance", (t) => {
t.is(r1.merchantBaseUrl, "https://merchant.example.com/instances/myinst/");
});
-test("taler tip pickup uri", (t) => {
- const url1 = "taler://tip/merchant.example.com/tipid";
- const r1 = parseTipUri(url1);
+test("taler refund URI (stringify)", (t) => {
+ const url = stringifyRefundUri({
+ merchantBaseUrl: "https://merchant.test/instance/pepe/",
+ orderId: "123",
+ });
+ t.deepEqual(url, "taler://refund/merchant.test/instance/pepe/123/");
+});
+
+/**
+ * 5.5 action: pay-push https://lsd.gnunet.org/lsd0006/#name-action-pay-push
+ */
+
+test("taler peer to peer push URI", (t) => {
+ const url1 = "taler://pay-push/exch.example.com/foo";
+ const r1 = parsePayPushUri(url1);
if (!r1) {
t.fail();
return;
}
- t.is(r1.merchantBaseUrl, "https://merchant.example.com/");
+ t.is(r1.exchangeBaseUrl, "https://exch.example.com/");
+ t.is(r1.contractPriv, "foo");
});
-test("taler tip pickup uri with instance", (t) => {
- const url1 = "taler://tip/merchant.example.com/instances/tipm/tipid";
- const r1 = parseTipUri(url1);
+test("taler peer to peer push URI (path)", (t) => {
+ const url1 = "taler://pay-push/exch.example.com:123/bla/foo";
+ const r1 = parsePayPushUri(url1);
if (!r1) {
t.fail();
return;
}
- t.is(r1.merchantBaseUrl, "https://merchant.example.com/instances/tipm/");
- t.is(r1.merchantTipId, "tipid");
+ t.is(r1.exchangeBaseUrl, "https://exch.example.com:123/bla/");
+ t.is(r1.contractPriv, "foo");
});
-test("taler tip pickup uri with instance and prefix", (t) => {
- const url1 = "taler://tip/merchant.example.com/my/pfx/tipm/tipid";
- const r1 = parseTipUri(url1);
+test("taler peer to peer push URI (http)", (t) => {
+ const url1 = "taler+http://pay-push/exch.example.com:123/bla/foo";
+ const r1 = parsePayPushUri(url1);
if (!r1) {
t.fail();
return;
}
- t.is(r1.merchantBaseUrl, "https://merchant.example.com/my/pfx/tipm/");
- t.is(r1.merchantTipId, "tipid");
+ t.is(r1.exchangeBaseUrl, "http://exch.example.com:123/bla/");
+ t.is(r1.contractPriv, "foo");
+});
+
+test("taler peer to peer push URI (stringify)", (t) => {
+ const url = stringifyPayPushUri({
+ exchangeBaseUrl: "https://foo.example.com/bla/",
+ contractPriv: "123",
+ });
+ t.deepEqual(url, "taler://pay-push/foo.example.com/bla/123");
+});
+
+/**
+ * 5.6 action: pay-pull https://lsd.gnunet.org/lsd0006/#name-action-pay-pull
+ */
+
+test("taler peer to peer pull URI", (t) => {
+ const url1 = "taler://pay-pull/exch.example.com/foo";
+ const r1 = parsePayPullUri(url1);
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.is(r1.exchangeBaseUrl, "https://exch.example.com/");
+ t.is(r1.contractPriv, "foo");
+});
+
+test("taler peer to peer pull URI (path)", (t) => {
+ const url1 = "taler://pay-pull/exch.example.com:123/bla/foo";
+ const r1 = parsePayPullUri(url1);
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.is(r1.exchangeBaseUrl, "https://exch.example.com:123/bla/");
+ t.is(r1.contractPriv, "foo");
+});
+
+test("taler peer to peer pull URI (http)", (t) => {
+ const url1 = "taler+http://pay-pull/exch.example.com:123/bla/foo";
+ const r1 = parsePayPullUri(url1);
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.is(r1.exchangeBaseUrl, "http://exch.example.com:123/bla/");
+ t.is(r1.contractPriv, "foo");
+});
+
+test("taler peer to peer pull URI (stringify)", (t) => {
+ const url = stringifyPayPullUri({
+ exchangeBaseUrl: "https://foo.example.com/bla/",
+ contractPriv: "123",
+ });
+ t.deepEqual(url, "taler://pay-pull/foo.example.com/bla/123");
+});
+
+/**
+ * 5.7 action: pay-template https://lsd.gnunet.org/lsd0006/#name-action-pay-template
+ */
+
+test("taler pay template URI (parsing)", (t) => {
+ const url1 =
+ "taler://pay-template/merchant.example.com/FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY?amount=KUDOS:5";
+ const r1 = parsePayTemplateUri(url1);
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(r1.merchantBaseUrl, "https://merchant.example.com/");
+ t.deepEqual(r1.templateId, "FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY");
+ t.deepEqual(r1.templateParams.amount, "KUDOS:5");
+});
+
+test("taler pay template URI (parsing, http with port)", (t) => {
+ const url1 =
+ "taler+http://pay-template/merchant.example.com:1234/FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY?amount=KUDOS:5";
+ const r1 = parsePayTemplateUri(url1);
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(r1.merchantBaseUrl, "http://merchant.example.com:1234/");
+ t.deepEqual(r1.templateId, "FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY");
+ t.deepEqual(r1.templateParams.amount, "KUDOS:5");
+});
+
+test("taler pay template URI (stringify)", (t) => {
+ const url1 = stringifyPayTemplateUri({
+ merchantBaseUrl: "http://merchant.example.com:1234/",
+ templateId: "FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY",
+ templateParams: {
+ amount: "KUDOS:5",
+ },
+ });
+ t.deepEqual(
+ url1,
+ "taler+http://pay-template/merchant.example.com:1234/FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY?amount=KUDOS%3A5",
+ );
+});
+
+/**
+ * 5.10 action: restore https://lsd.gnunet.org/lsd0006/#name-action-restore
+ */
+test("taler restore URI (parsing, http with port)", (t) => {
+ const r1 = parseRestoreUri(
+ "taler+http://restore/GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0/prov1.example.com,prov2.example.com:123",
+ );
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(
+ r1.walletRootPriv,
+ "GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0",
+ );
+ t.deepEqual(r1.providers[0], "http://prov1.example.com/");
+ t.deepEqual(r1.providers[1], "http://prov2.example.com:123/");
+});
+test("taler restore URI (parsing, https with port)", (t) => {
+ const r1 = parseRestoreUri(
+ "taler://restore/GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0/prov1.example.com,prov2.example.com:234,https%3A%2F%2Fprov1.example.com%2F",
+ );
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(
+ r1.walletRootPriv,
+ "GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0",
+ );
+ t.deepEqual(r1.providers[0], "https://prov1.example.com/");
+ t.deepEqual(r1.providers[1], "https://prov2.example.com:234/");
+});
+
+test("taler restore URI (stringify)", (t) => {
+ const url = stringifyRestoreUri({
+ walletRootPriv: "GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0",
+ providers: ["http://prov1.example.com", "https://prov2.example.com:234/"],
+ });
+ t.deepEqual(
+ url,
+ "taler://restore/GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0/http%3A%2F%2Fprov1.example.com%2F,https%3A%2F%2Fprov2.example.com%3A234%2F",
+ );
+});
+
+/**
+ * 5.11 action: dev-experiment https://lsd.gnunet.org/lsd0006/#name-action-dev-experiment
+ */
+
+test("taler dev exp URI (parsing)", (t) => {
+ const url1 = "taler://dev-experiment/123";
+ const r1 = parseDevExperimentUri(url1);
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(r1.devExperimentId, "123");
+});
+
+test("taler dev exp URI (stringify)", (t) => {
+ const url1 = stringifyDevExperimentUri({
+ devExperimentId: "123",
+ });
+ t.deepEqual(url1, "taler://dev-experiment/123");
+});
+
+/**
+ * 5.12 action: withdraw-exchange https://lsd.gnunet.org/lsd0006/#name-action-withdraw-exchange
+ */
+
+test("taler withdraw exchange URI (parse)", (t) => {
+ {
+ const r1 = parseWithdrawExchangeUri(
+ "taler://withdraw-exchange/exchange.demo.taler.net/someroot/GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0?a=KUDOS%3A2",
+ );
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(
+ r1.exchangePub,
+ "GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0",
+ );
+ t.deepEqual(
+ r1.exchangeBaseUrl,
+ "https://exchange.demo.taler.net/someroot/",
+ );
+ t.deepEqual(r1.amount, "KUDOS:2");
+ }
+ {
+ const r2 = parseWithdrawExchangeUri(
+ "taler://withdraw-exchange/exchange.demo.taler.net/someroot/",
+ );
+ if (!r2) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(r2.exchangePub, undefined);
+ t.deepEqual(r2.amount, undefined);
+ t.deepEqual(
+ r2.exchangeBaseUrl,
+ "https://exchange.demo.taler.net/someroot/",
+ );
+ }
+
+ {
+ const r3 = parseWithdrawExchangeUri(
+ "taler://withdraw-exchange/exchange.demo.taler.net/",
+ );
+ if (!r3) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(r3.exchangePub, undefined);
+ t.deepEqual(r3.amount, undefined);
+ t.deepEqual(r3.exchangeBaseUrl, "https://exchange.demo.taler.net/");
+ }
+
+ {
+ // No trailing slash, no path component
+ const r4 = parseWithdrawExchangeUri(
+ "taler://withdraw-exchange/exchange.demo.taler.net",
+ );
+ if (!r4) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(r4.exchangePub, undefined);
+ t.deepEqual(r4.amount, undefined);
+ t.deepEqual(r4.exchangeBaseUrl, "https://exchange.demo.taler.net/");
+ }
+});
+
+test("taler withdraw exchange URI (stringify)", (t) => {
+ const url = stringifyWithdrawExchange({
+ exchangeBaseUrl: "https://exchange.demo.taler.net",
+ exchangePub: "GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0",
+ });
+ t.deepEqual(
+ url,
+ "taler://withdraw-exchange/exchange.demo.taler.net/GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0",
+ );
+});
+
+test("taler withdraw exchange URI with amount (stringify)", (t) => {
+ const url = stringifyWithdrawExchange({
+ exchangeBaseUrl: "https://exchange.demo.taler.net",
+ exchangePub: "GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0",
+ amount: "KUDOS:19" as AmountString,
+ });
+ t.deepEqual(
+ url,
+ "taler://withdraw-exchange/exchange.demo.taler.net/GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0?a=KUDOS%3A19",
+ );
+});
+
+
+/**
+ * 5.13 action: add-exchange https://lsd.gnunet.org/lsd0006/#name-action-add-exchange
+ */
+
+test("taler add exchange URI (parse)", (t) => {
+ {
+ const r1 = parseAddExchangeUri(
+ "taler://add-exchange/exchange.example.com/",
+ );
+ if (!r1) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(
+ r1.exchangeBaseUrl,
+ "https://exchange.example.com/",
+ );
+ }
+ {
+ const r2 = parseAddExchangeUri(
+ "taler://add-exchange/exchanges.example.com/api/",
+ );
+ if (!r2) {
+ t.fail();
+ return;
+ }
+ t.deepEqual(
+ r2.exchangeBaseUrl,
+ "https://exchanges.example.com/api/",
+ );
+ }
+
+});
+
+test("taler add exchange URI (stringify)", (t) => {
+ const url = stringifyAddExchange({
+ exchangeBaseUrl: "https://exchange.demo.taler.net",
+ });
+ t.deepEqual(
+ url,
+ "taler://add-exchange/exchange.demo.taler.net/",
+ );
+});
+
+/**
+ * wrong uris
+ */
+test("taler pay url parsing: wrong scheme", (t) => {
+ const url1 = "talerfoo://";
+ const r1 = parsePayUri(url1);
+ t.is(r1, undefined);
+
+ const url2 = "taler://refund/a/b/c/d/e/f";
+ const r2 = parsePayUri(url2);
+ t.is(r2, undefined);
});
diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts
index 09c70682a..b4f9db6ef 100644
--- a/packages/taler-util/src/taleruri.ts
+++ b/packages/taler-util/src/taleruri.ts
@@ -14,100 +14,249 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+/**
+ * @fileoverview
+ * Construction and parsing of taler:// URIs.
+ * Specification: https://lsd.gnunet.org/lsd0006/
+ */
+
+/**
+ * Imports.
+ */
+import { Codec, Context, DecodingError, renderContext } from "./codec.js";
import { canonicalizeBaseUrl } from "./helpers.js";
-import { URLSearchParams } from "./url.js";
+import { opFixedSuccess, opKnownTalerFailure } from "./operation.js";
+import { TalerErrorCode } from "./taler-error-codes.js";
+import { AmountString } from "./taler-types.js";
+import { URL, URLSearchParams } from "./url.js";
+/**
+ * A parsed taler URI.
+ */
+export type TalerUri =
+ | PayUriResult
+ | PayTemplateUriResult
+ | DevExperimentUri
+ | PayPullUriResult
+ | PayPushUriResult
+ | BackupRestoreUri
+ | RefundUriResult
+ | WithdrawUriResult
+ | WithdrawExchangeUri
+ | AddExchangeUri;
+
+declare const __action_str: unique symbol;
+export type TalerUriString = string & { [__action_str]: true };
+
+export function codecForTalerUriString(): Codec<TalerUriString> {
+ return {
+ decode(x: any, c?: Context): TalerUriString {
+ if (typeof x !== "string") {
+ throw new DecodingError(
+ `expected string at ${renderContext(c)} but got ${typeof x}`,
+ );
+ }
+ if (parseTalerUri(x) === undefined) {
+ throw new DecodingError(
+ `invalid taler URI at ${renderContext(c)} but got "${x}"`,
+ );
+ }
+ return x as TalerUriString;
+ },
+ };
+}
export interface PayUriResult {
+ type: TalerUriAction.Pay;
merchantBaseUrl: string;
orderId: string;
sessionId: string;
- claimToken: string | undefined;
- noncePriv: string | undefined;
+ claimToken?: string;
+ noncePriv?: string;
+}
+
+export type TemplateParams = {
+ amount?: string;
+ summary?: string;
+};
+
+export interface PayTemplateUriResult {
+ type: TalerUriAction.PayTemplate;
+ merchantBaseUrl: string;
+ templateId: string;
+ templateParams: TemplateParams;
}
export interface WithdrawUriResult {
+ type: TalerUriAction.Withdraw;
bankIntegrationApiBaseUrl: string;
withdrawalOperationId: string;
}
export interface RefundUriResult {
+ type: TalerUriAction.Refund;
merchantBaseUrl: string;
orderId: string;
}
-export interface TipUriResult {
- merchantTipId: string;
- merchantBaseUrl: string;
+export interface PayPushUriResult {
+ type: TalerUriAction.PayPush;
+ exchangeBaseUrl: string;
+ contractPriv: string;
+}
+
+export interface PayPullUriResult {
+ type: TalerUriAction.PayPull;
+ exchangeBaseUrl: string;
+ contractPriv: string;
+}
+
+export interface DevExperimentUri {
+ type: TalerUriAction.DevExperiment;
+ devExperimentId: string;
+}
+
+export interface BackupRestoreUri {
+ type: TalerUriAction.Restore;
+ walletRootPriv: string;
+ providers: Array<string>;
+}
+
+export interface WithdrawExchangeUri {
+ type: TalerUriAction.WithdrawExchange;
+ exchangeBaseUrl: string;
+ exchangePub?: string;
+ amount?: AmountString;
+}
+
+export interface AddExchangeUri {
+ type: TalerUriAction.AddExchange;
+ exchangeBaseUrl: string;
}
/**
* Parse a taler[+http]://withdraw URI.
* Return undefined if not passed a valid URI.
*/
-export function parseWithdrawUri(s: string): WithdrawUriResult | undefined {
- const pi = parseProtoInfo(s, "withdraw");
- if (!pi) {
- return undefined;
+export function parseWithdrawUriWithError(s: string) {
+ const pi = parseProtoInfoWithError(s, "withdraw");
+ if (pi.type === "fail") {
+ return pi;
}
- const parts = pi.rest.split("/");
+ const parts = pi.body.rest.split("/");
if (parts.length < 2) {
- return undefined;
+ return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, {
+ code: TalerErrorCode.WALLET_TALER_URI_MALFORMED,
+ });
}
const host = parts[0].toLowerCase();
const pathSegments = parts.slice(1, parts.length - 1);
+ /**
+ * The statement below does not tolerate a slash-ended URI.
+ * This results in (1) the withdrawalId being passed as the
+ * empty string, and (2) the bankIntegrationApi ending with the
+ * actual withdrawal operation ID. That can be fixed by
+ * trimming the parts-list. FIXME
+ */
const withdrawId = parts[parts.length - 1];
const p = [host, ...pathSegments].join("/");
- return {
- bankIntegrationApiBaseUrl: canonicalizeBaseUrl(`${pi.innerProto}://${p}/`),
+ const result: WithdrawUriResult = {
+ type: TalerUriAction.Withdraw,
+ bankIntegrationApiBaseUrl: canonicalizeBaseUrl(
+ `${pi.body.innerProto}://${p}/`,
+ ),
withdrawalOperationId: withdrawId,
};
+ return opFixedSuccess(result);
+}
+
+/**
+ *
+ * @deprecated use parseWithdrawUriWithError
+ */
+export function parseWithdrawUri(s: string): WithdrawUriResult | undefined {
+ const r = parseWithdrawUriWithError(s);
+ if (r.type === "fail") return undefined;
+ return r.body;
+}
+
+/**
+ * Parse a taler[+http]://withdraw URI.
+ * Return undefined if not passed a valid URI.
+ */
+export function parseAddExchangeUriWithError(s: string) {
+ const pi = parseProtoInfoWithError(s, "add-exchange");
+ if (pi.type === "fail") {
+ return pi;
+ }
+ const parts = pi.body.rest.split("/");
+
+ if (parts.length < 2) {
+ return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, {
+ code: TalerErrorCode.WALLET_TALER_URI_MALFORMED,
+ });
+ }
+
+ const host = parts[0].toLowerCase();
+ const pathSegments = parts.slice(1, parts.length - 1);
+ /**
+ * The statement below does not tolerate a slash-ended URI.
+ * This results in (1) the withdrawalId being passed as the
+ * empty string, and (2) the bankIntegrationApi ending with the
+ * actual withdrawal operation ID. That can be fixed by
+ * trimming the parts-list. FIXME
+ */
+ const p = [host, ...pathSegments].join("/");
+
+ const result: AddExchangeUri = {
+ type: TalerUriAction.AddExchange,
+ exchangeBaseUrl: canonicalizeBaseUrl(
+ `${pi.body.innerProto}://${p}/`,
+ ),
+ };
+ return opFixedSuccess(result);
+}
+
+/**
+ *
+ * @deprecated use parseWithdrawUriWithError
+ */
+export function parseAddExchangeUri(s: string): AddExchangeUri | undefined {
+ const r = parseAddExchangeUriWithError(s);
+ if (r.type === "fail") return undefined;
+ return r.body;
}
+/**
+ * @deprecated use TalerUriAction
+ */
export enum TalerUriType {
TalerPay = "taler-pay",
+ TalerTemplate = "taler-template",
+ TalerPayTemplate = "taler-pay-template",
TalerWithdraw = "taler-withdraw",
TalerTip = "taler-tip",
TalerRefund = "taler-refund",
- TalerNotifyReserve = "taler-notify-reserve",
+ TalerPayPush = "taler-pay-push",
+ TalerPayPull = "taler-pay-pull",
+ TalerRecovery = "taler-recovery",
+ TalerDevExperiment = "taler-dev-experiment",
Unknown = "unknown",
}
-/**
- * Classify a taler:// URI.
- */
-export function classifyTalerUri(s: string): TalerUriType {
- const sl = s.toLowerCase();
- if (sl.startsWith("taler://pay/")) {
- return TalerUriType.TalerPay;
- }
- if (sl.startsWith("taler+http://pay/")) {
- return TalerUriType.TalerPay;
- }
- if (sl.startsWith("taler://tip/")) {
- return TalerUriType.TalerTip;
- }
- if (sl.startsWith("taler+http://tip/")) {
- return TalerUriType.TalerTip;
- }
- if (sl.startsWith("taler://refund/")) {
- return TalerUriType.TalerRefund;
- }
- if (sl.startsWith("taler+http://refund/")) {
- return TalerUriType.TalerRefund;
- }
- if (sl.startsWith("taler://withdraw/")) {
- return TalerUriType.TalerWithdraw;
- }
- if (sl.startsWith("taler+http://withdraw/")) {
- return TalerUriType.TalerWithdraw;
- }
- if (sl.startsWith("taler://notify-reserve/")) {
- return TalerUriType.TalerNotifyReserve;
- }
- return TalerUriType.Unknown;
+export enum TalerUriAction {
+ Pay = "pay",
+ Withdraw = "withdraw",
+ Refund = "refund",
+ PayPull = "pay-pull",
+ PayPush = "pay-push",
+ PayTemplate = "pay-template",
+ Restore = "restore",
+ DevExperiment = "dev-experiment",
+ WithdrawExchange = "withdraw-exchange",
+ AddExchange = "add-exchange",
}
interface TalerUriProtoInfo {
@@ -136,6 +285,95 @@ function parseProtoInfo(
}
}
+function parseProtoInfoWithError(s: string, action: string) {
+ if (
+ !s.toLowerCase().startsWith("taler://") &&
+ !s.toLowerCase().startsWith("taler+http://")
+ ) {
+ return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, {
+ code: TalerErrorCode.WALLET_TALER_URI_MALFORMED,
+ });
+ }
+ const pfxPlain = `taler://${action}/`;
+ const pfxHttp = `taler+http://${action}/`;
+ if (s.toLowerCase().startsWith(pfxPlain)) {
+ return opFixedSuccess({
+ innerProto: "https",
+ rest: s.substring(pfxPlain.length),
+ });
+ } else if (s.toLowerCase().startsWith(pfxHttp)) {
+ return opFixedSuccess({
+ innerProto: "http",
+ rest: s.substring(pfxHttp.length),
+ });
+ } else {
+ return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, {
+ code: TalerErrorCode.WALLET_TALER_URI_MALFORMED,
+ });
+ }
+}
+
+type Parser = (s: string) => TalerUri | undefined;
+const parsers: { [A in TalerUriAction]: Parser } = {
+ [TalerUriAction.Pay]: parsePayUri,
+ [TalerUriAction.PayPull]: parsePayPullUri,
+ [TalerUriAction.PayPush]: parsePayPushUri,
+ [TalerUriAction.PayTemplate]: parsePayTemplateUri,
+ [TalerUriAction.Restore]: parseRestoreUri,
+ [TalerUriAction.Refund]: parseRefundUri,
+ [TalerUriAction.Withdraw]: parseWithdrawUri,
+ [TalerUriAction.DevExperiment]: parseDevExperimentUri,
+ [TalerUriAction.WithdrawExchange]: parseWithdrawExchangeUri,
+ [TalerUriAction.AddExchange]: parseAddExchangeUri,
+};
+
+export function parseTalerUri(string: string): TalerUri | undefined {
+ const https = string.startsWith("taler://");
+ const http = string.startsWith("taler+http://");
+ if (!https && !http) return undefined;
+ const actionStart = https ? 8 : 13;
+ const actionEnd = string.indexOf("/", actionStart + 1);
+ const action = string.substring(actionStart, actionEnd);
+ const found = Object.values(TalerUriAction).find((x) => x === action);
+ if (!found) return undefined;
+ return parsers[found](string);
+}
+
+export function stringifyTalerUri(uri: TalerUri): string {
+ switch (uri.type) {
+ case TalerUriAction.DevExperiment: {
+ return stringifyDevExperimentUri(uri);
+ }
+ case TalerUriAction.Pay: {
+ return stringifyPayUri(uri);
+ }
+ case TalerUriAction.PayPull: {
+ return stringifyPayPullUri(uri);
+ }
+ case TalerUriAction.PayPush: {
+ return stringifyPayPushUri(uri);
+ }
+ case TalerUriAction.PayTemplate: {
+ return stringifyPayTemplateUri(uri);
+ }
+ case TalerUriAction.Restore: {
+ return stringifyRestoreUri(uri);
+ }
+ case TalerUriAction.Refund: {
+ return stringifyRefundUri(uri);
+ }
+ case TalerUriAction.Withdraw: {
+ return stringifyWithdrawUri(uri);
+ }
+ case TalerUriAction.WithdrawExchange: {
+ return stringifyWithdrawExchange(uri);
+ }
+ case TalerUriAction.AddExchange: {
+ return stringifyAddExchange(uri);
+ }
+ }
+}
+
/**
* Parse a taler[+http]://pay URI.
* Return undefined if not passed a valid URI.
@@ -161,37 +399,128 @@ export function parsePayUri(s: string): PayUriResult | undefined {
const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
return {
+ type: TalerUriAction.Pay,
merchantBaseUrl,
orderId,
- sessionId: sessionId,
+ sessionId,
claimToken,
noncePriv,
};
}
-/**
- * Parse a taler[+http]://tip URI.
- * Return undefined if not passed a valid URI.
- */
-export function parseTipUri(s: string): TipUriResult | undefined {
- const pi = parseProtoInfo(s, "tip");
+export function parsePayTemplateUri(
+ uriString: string,
+): PayTemplateUriResult | undefined {
+ const pi = parseProtoInfo(uriString, TalerUriAction.PayTemplate);
if (!pi) {
return undefined;
}
- const c = pi?.rest.split("?");
+ const c = pi.rest.split("?");
+
const parts = c[0].split("/");
if (parts.length < 2) {
return undefined;
}
+
+ const q = new URLSearchParams(c[1] ?? "");
+ const params: Record<string, string> = {};
+ q.forEach((v, k) => {
+ params[k] = v;
+ });
+
const host = parts[0].toLowerCase();
- const tipId = parts[parts.length - 1];
+ const templateId = parts[parts.length - 1];
const pathSegments = parts.slice(1, parts.length - 1);
- const p = [host, ...pathSegments].join("/");
- const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const merchantBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
return {
+ type: TalerUriAction.PayTemplate,
merchantBaseUrl,
- merchantTipId: tipId,
+ templateId,
+ templateParams: params,
+ };
+}
+
+export function parsePayPushUri(s: string): PayPushUriResult | undefined {
+ const pi = parseProtoInfo(s, TalerUriAction.PayPush);
+ if (!pi) {
+ return undefined;
+ }
+ const c = pi?.rest.split("?");
+ const parts = c[0].split("/");
+ if (parts.length < 2) {
+ return undefined;
+ }
+ const host = parts[0].toLowerCase();
+ const contractPriv = parts[parts.length - 1];
+ const pathSegments = parts.slice(1, parts.length - 1);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const exchangeBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
+
+ return {
+ type: TalerUriAction.PayPush,
+ exchangeBaseUrl,
+ contractPriv,
+ };
+}
+
+export function parsePayPullUri(s: string): PayPullUriResult | undefined {
+ const pi = parseProtoInfo(s, TalerUriAction.PayPull);
+ if (!pi) {
+ return undefined;
+ }
+ const c = pi?.rest.split("?");
+ const parts = c[0].split("/");
+ if (parts.length < 2) {
+ return undefined;
+ }
+ const host = parts[0].toLowerCase();
+ const contractPriv = parts[parts.length - 1];
+ const pathSegments = parts.slice(1, parts.length - 1);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const exchangeBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
+
+ return {
+ type: TalerUriAction.PayPull,
+ exchangeBaseUrl,
+ contractPriv,
+ };
+}
+
+export function parseWithdrawExchangeUri(
+ s: string,
+): WithdrawExchangeUri | undefined {
+ const pi = parseProtoInfo(s, "withdraw-exchange");
+ if (!pi) {
+ return undefined;
+ }
+ const c = pi?.rest.split("?");
+ const parts = c[0].split("/");
+ if (parts.length < 1) {
+ return undefined;
+ }
+ const host = parts[0].toLowerCase();
+ const exchangePub = parts.length > 1 ? parts[parts.length - 1] : undefined;
+ const pathSegments = parts.slice(1, parts.length - 1);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const exchangeBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
+ const q = new URLSearchParams(c[1] ?? "");
+ const amount = (q.get("a") ?? undefined) as AmountString | undefined;
+
+ return {
+ type: TalerUriAction.WithdrawExchange,
+ exchangeBaseUrl,
+ exchangePub: exchangePub != "" ? exchangePub : undefined,
+ amount,
};
}
@@ -213,11 +542,190 @@ export function parseRefundUri(s: string): RefundUriResult | undefined {
const sessionId = parts[parts.length - 1];
const orderId = parts[parts.length - 2];
const pathSegments = parts.slice(1, parts.length - 2);
- const p = [host, ...pathSegments].join("/");
- const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`);
+ const hostAndSegments = [host, ...pathSegments].join("/");
+ const merchantBaseUrl = canonicalizeBaseUrl(
+ `${pi.innerProto}://${hostAndSegments}/`,
+ );
return {
+ type: TalerUriAction.Refund,
merchantBaseUrl,
orderId,
};
}
+
+export function parseDevExperimentUri(s: string): DevExperimentUri | undefined {
+ const pi = parseProtoInfo(s, "dev-experiment");
+ const c = pi?.rest.split("?");
+ if (!c) {
+ return undefined;
+ }
+ const parts = c[0].split("/");
+ return {
+ type: TalerUriAction.DevExperiment,
+ devExperimentId: parts[0],
+ };
+}
+
+export function parseRestoreUri(uri: string): BackupRestoreUri | undefined {
+ const pi = parseProtoInfo(uri, "restore");
+ if (!pi) {
+ return undefined;
+ }
+ const c = pi.rest.split("?");
+ const parts = c[0].split("/");
+ if (parts.length < 2) {
+ return undefined;
+ }
+
+ const walletRootPriv = parts[0];
+ if (!walletRootPriv) return undefined;
+ const providers = new Array<string>();
+ parts[1].split(",").map((name) => {
+ const url = canonicalizeBaseUrl(
+ `${pi.innerProto}://${decodeURIComponent(name)}/`,
+ );
+ providers.push(url);
+ });
+ return {
+ type: TalerUriAction.Restore,
+ walletRootPriv,
+ providers,
+ };
+}
+
+// ================================================
+// To string functions
+// ================================================
+
+export function stringifyPayUri({
+ merchantBaseUrl,
+ orderId,
+ sessionId,
+ claimToken,
+ noncePriv,
+}: Omit<PayUriResult, "type">): string {
+ const { proto, path, query } = getUrlInfo(merchantBaseUrl, {
+ c: claimToken,
+ n: noncePriv,
+ });
+ return `${proto}://pay/${path}${orderId}/${sessionId}${query}`;
+}
+
+export function stringifyPayPullUri({
+ contractPriv,
+ exchangeBaseUrl,
+}: Omit<PayPullUriResult, "type">): string {
+ const { proto, path } = getUrlInfo(exchangeBaseUrl);
+ return `${proto}://pay-pull/${path}${contractPriv}`;
+}
+
+export function stringifyPayPushUri({
+ contractPriv,
+ exchangeBaseUrl,
+}: Omit<PayPushUriResult, "type">): string {
+ const { proto, path } = getUrlInfo(exchangeBaseUrl);
+
+ return `${proto}://pay-push/${path}${contractPriv}`;
+}
+
+export function stringifyRestoreUri({
+ providers,
+ walletRootPriv,
+}: Omit<BackupRestoreUri, "type">): string {
+ const list = providers
+ .map((url) => `${encodeURIComponent(new URL(url).href)}`)
+ .join(",");
+ return `taler://restore/${walletRootPriv}/${list}`;
+}
+
+export function stringifyWithdrawExchange({
+ exchangeBaseUrl,
+ exchangePub,
+ amount,
+}: Omit<WithdrawExchangeUri, "type">): string {
+ const { proto, path, query } = getUrlInfo(exchangeBaseUrl, {
+ a: amount,
+ });
+ return `${proto}://withdraw-exchange/${path}${exchangePub ?? ""}${query}`;
+}
+
+export function stringifyAddExchange({
+ exchangeBaseUrl,
+}: Omit<AddExchangeUri, "type">): string {
+ const { proto, path } = getUrlInfo(exchangeBaseUrl);
+ return `${proto}://add-exchange/${path}`;
+}
+
+export function stringifyDevExperimentUri({
+ devExperimentId,
+}: Omit<DevExperimentUri, "type">): string {
+ return `taler://dev-experiment/${devExperimentId}`;
+}
+
+export function stringifyPayTemplateUri({
+ merchantBaseUrl,
+ templateId,
+ templateParams,
+}: Omit<PayTemplateUriResult, "type">): string {
+ const { proto, path, query } = getUrlInfo(merchantBaseUrl, templateParams);
+ return `${proto}://pay-template/${path}${templateId}${query}`;
+}
+
+export function stringifyRefundUri({
+ merchantBaseUrl,
+ orderId,
+}: Omit<RefundUriResult, "type">): string {
+ const { proto, path } = getUrlInfo(merchantBaseUrl);
+ return `${proto}://refund/${path}${orderId}/`;
+}
+
+export function stringifyWithdrawUri({
+ bankIntegrationApiBaseUrl,
+ withdrawalOperationId,
+}: Omit<WithdrawUriResult, "type">): string {
+ const { proto, path } = getUrlInfo(bankIntegrationApiBaseUrl);
+ return `${proto}://withdraw/${path}${withdrawalOperationId}`;
+}
+
+/**
+ * Use baseUrl to defined http or https
+ * create path using host+port+pathname
+ * use params to create a query parameter string or empty
+ */
+function getUrlInfo(
+ baseUrl: string,
+ params: Record<string, string | undefined> = {},
+): { proto: string; path: string; query: string } {
+ const url = new URL(baseUrl);
+ let proto: string;
+ if (url.protocol === "https:") {
+ proto = "taler";
+ } else if (url.protocol === "http:") {
+ proto = "taler+http";
+ } else {
+ throw Error(`Unsupported URL protocol in ${baseUrl}`);
+ }
+ let path = url.hostname;
+ if (url.port) {
+ path = path + ":" + url.port;
+ }
+ if (url.pathname) {
+ path = path + url.pathname;
+ }
+ if (!path.endsWith("/")) {
+ path = path + "/";
+ }
+
+ const qp = new URLSearchParams();
+ let withParams = false;
+ Object.entries(params).forEach(([name, value]) => {
+ if (value !== undefined) {
+ withParams = true;
+ qp.append(name, value);
+ }
+ });
+ const query = withParams ? "?" + qp.toString() : "";
+
+ return { proto, path, query };
+}
diff --git a/packages/taler-util/src/time.test.ts b/packages/taler-util/src/time.test.ts
new file mode 100644
index 000000000..5dd8c7715
--- /dev/null
+++ b/packages/taler-util/src/time.test.ts
@@ -0,0 +1,39 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import test from "ava";
+import { AbsoluteTime, Duration } from "./time.js";
+
+test("duration parsing", (t) => {
+ const d1 = Duration.fromPrettyString("1h");
+ t.deepEqual(d1.d_ms, 60 * 60 * 1000);
+
+ const d2 = Duration.fromPrettyString(" 2h 1s 3m");
+ t.deepEqual(d2.d_ms, 2 * 60 * 60 * 1000 + 3 * 60 * 1000 + 1000);
+
+ t.throws(() => {
+ Duration.fromPrettyString("5g");
+ });
+ t.throws(() => {
+ Duration.fromPrettyString("s");
+ });
+ t.throws(() => {
+ Duration.fromPrettyString("s5");
+ });
+ t.throws(() => {
+ Duration.fromPrettyString("5 5 s");
+ });
+});
diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts
index c0858ada6..95b4911a0 100644
--- a/packages/taler-util/src/time.ts
+++ b/packages/taler-util/src/time.ts
@@ -21,13 +21,130 @@
/**
* Imports.
*/
-import { Codec, renderContext, Context } from "./codec.js";
+import { Codec, Context, renderContext } from "./codec.js";
-export class Timestamp {
+declare const flavor_AbsoluteTime: unique symbol;
+declare const flavor_TalerProtocolTimestamp: unique symbol;
+declare const flavor_TalerPreciseTimestamp: unique symbol;
+
+const opaque_AbsoluteTime: unique symbol = Symbol("opaque_AbsoluteTime");
+
+// FIXME: Make this opaque!
+export interface AbsoluteTime {
/**
* Timestamp in milliseconds.
*/
readonly t_ms: number | "never";
+
+ readonly _flavor?: typeof flavor_AbsoluteTime;
+
+ // Make the type opaque, we only want our constructors
+ // to able to create an AbsoluteTime value.
+ [opaque_AbsoluteTime]: true;
+}
+
+export interface TalerProtocolTimestamp {
+ /**
+ * Seconds (as integer) since epoch.
+ */
+ readonly t_s: number | "never";
+
+ readonly _flavor?: typeof flavor_TalerProtocolTimestamp;
+}
+
+/**
+ * Precise timestamp, typically used in the wallet-core
+ * API but not in other Taler APIs so far.
+ */
+export interface TalerPreciseTimestamp {
+ /**
+ * Seconds (as integer) since epoch.
+ */
+ readonly t_s: number | "never";
+
+ /**
+ * Optional microsecond offset (non-negative integer).
+ */
+ readonly off_us?: number;
+
+ readonly _flavor?: typeof flavor_TalerPreciseTimestamp;
+}
+
+export namespace TalerPreciseTimestamp {
+ export function now(): TalerPreciseTimestamp {
+ const absNow = AbsoluteTime.now();
+ return AbsoluteTime.toPreciseTimestamp(absNow);
+ }
+
+ export function round(t: TalerPreciseTimestamp): TalerProtocolTimestamp {
+ return {
+ t_s: t.t_s,
+ };
+ }
+
+ export function fromSeconds(s: number): TalerPreciseTimestamp {
+ return {
+ t_s: Math.floor(s),
+ off_us: Math.floor((s - Math.floor(s)) / 1000 / 1000),
+ };
+ }
+
+ export function fromMilliseconds(ms: number): TalerPreciseTimestamp {
+ return {
+ t_s: Math.floor(ms / 1000),
+ off_us: Math.floor((ms - Math.floor(ms / 1000) * 1000) * 1000),
+ };
+ }
+}
+
+export namespace TalerProtocolTimestamp {
+ export function now(): TalerProtocolTimestamp {
+ return AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now());
+ }
+
+ export function zero(): TalerProtocolTimestamp {
+ return {
+ t_s: 0,
+ };
+ }
+
+ export function never(): TalerProtocolTimestamp {
+ return {
+ t_s: "never",
+ };
+ }
+
+ export function isNever(t: TalerProtocolTimestamp): boolean {
+ return t.t_s === "never";
+ }
+
+ export function fromSeconds(s: number): TalerProtocolTimestamp {
+ return {
+ t_s: s,
+ };
+ }
+
+ export function min(
+ t1: TalerProtocolTimestamp,
+ t2: TalerProtocolTimestamp,
+ ): TalerProtocolTimestamp {
+ if (t1.t_s === "never") {
+ return { t_s: t2.t_s };
+ }
+ if (t2.t_s === "never") {
+ return { t_s: t1.t_s };
+ }
+ return { t_s: Math.min(t1.t_s, t2.t_s) };
+ }
+ export function max(
+ t1: TalerProtocolTimestamp,
+ t2: TalerProtocolTimestamp,
+ ): TalerProtocolTimestamp {
+ if (t1.t_s === "never" || t2.t_s === "never") {
+ return { t_s: "never" };
+ }
+ return { t_s: Math.max(t1.t_s, t2.t_s) };
+ }
}
export interface Duration {
@@ -37,56 +154,411 @@ export interface Duration {
readonly d_ms: number | "forever";
}
+export interface TalerProtocolDuration {
+ readonly d_us: number | "forever";
+}
+
+/**
+ * Timeshift in milliseconds.
+ */
let timeshift = 0;
+/**
+ * Set timetravel offset in milliseconds.
+ *
+ * Use carefully and only for testing.
+ */
export function setDangerousTimetravel(dt: number): void {
timeshift = dt;
}
-export function getTimestampNow(): Timestamp {
- return {
- t_ms: new Date().getTime() + timeshift,
- };
-}
+export namespace Duration {
+ export function toMilliseconds(d: Duration): number {
+ if (d.d_ms === "forever") {
+ return Number.MAX_VALUE;
+ }
+ return d.d_ms;
+ }
+ export function getRemaining(
+ deadline: AbsoluteTime,
+ now = AbsoluteTime.now(),
+ ): Duration {
+ if (deadline.t_ms === "never") {
+ return { d_ms: "forever" };
+ }
+ if (now.t_ms === "never") {
+ throw Error("invalid argument for 'now'");
+ }
+ if (deadline.t_ms < now.t_ms) {
+ return { d_ms: 0 };
+ }
+ return { d_ms: deadline.t_ms - now.t_ms };
+ }
-export function isTimestampExpired(t: Timestamp) {
- return timestampCmp(t, getTimestampNow()) <= 0;
-}
+ export function fromPrettyString(s: string): Duration {
+ let dMs = 0;
+ let currentNum = "";
+ let parsingNum = true;
+ for (let i = 0; i < s.length; i++) {
+ const cc = s.charCodeAt(i);
+ if (cc >= "0".charCodeAt(0) && cc <= "9".charCodeAt(0)) {
+ if (!parsingNum) {
+ throw Error("invalid duration, unexpected number");
+ }
+ currentNum += s[i];
+ continue;
+ }
+ if (s[i] == " ") {
+ if (currentNum != "") {
+ parsingNum = false;
+ }
+ continue;
+ }
-export function getDurationRemaining(
- deadline: Timestamp,
- now = getTimestampNow(),
-): Duration {
- if (deadline.t_ms === "never") {
- return { d_ms: "forever" };
+ if (currentNum == "") {
+ throw Error("invalid duration, missing number");
+ }
+
+ if (s[i] === "s") {
+ dMs += 1000 * Number.parseInt(currentNum, 10);
+ } else if (s[i] === "m") {
+ dMs += 60 * 1000 * Number.parseInt(currentNum, 10);
+ } else if (s[i] === "h") {
+ dMs += 60 * 60 * 1000 * Number.parseInt(currentNum, 10);
+ } else if (s[i] === "d") {
+ dMs += 24 * 60 * 60 * 1000 * Number.parseInt(currentNum, 10);
+ } else {
+ throw Error("invalid duration, unsupported unit");
+ }
+ currentNum = "";
+ parsingNum = true;
+ }
+ return {
+ d_ms: dMs,
+ };
+ }
+
+ /**
+ * Compare two durations. Returns 0 when equal, -1 when a < b
+ * and +1 when a > b.
+ */
+ export function cmp(d1: Duration, d2: Duration): 1 | 0 | -1 {
+ if (d1.d_ms === "forever") {
+ if (d2.d_ms === "forever") {
+ return 0;
+ }
+ return 1;
+ }
+ if (d2.d_ms === "forever") {
+ return -1;
+ }
+ if (d1.d_ms == d2.d_ms) {
+ return 0;
+ }
+ if (d1.d_ms > d2.d_ms) {
+ return 1;
+ }
+ return -1;
+ }
+
+ export function max(d1: Duration, d2: Duration): Duration {
+ return durationMax(d1, d2);
+ }
+
+ export function min(d1: Duration, d2: Duration): Duration {
+ return durationMin(d1, d2);
+ }
+
+ export function multiply(d1: Duration, n: number): Duration {
+ return durationMul(d1, n);
}
- if (now.t_ms === "never") {
- throw Error("invalid argument for 'now'");
+
+ export function toIntegerYears(d: Duration): number {
+ if (typeof d.d_ms !== "number") {
+ throw Error("infinite duration");
+ }
+ return Math.ceil(d.d_ms / 1000 / 60 / 60 / 24 / 365);
+ }
+
+ export function fromSpec(spec: {
+ seconds?: number;
+ minutes?: number;
+ hours?: number;
+ days?: number;
+ months?: number;
+ years?: number;
+ }): Duration {
+ let d_ms = 0;
+ d_ms += (spec.seconds ?? 0) * SECONDS;
+ d_ms += (spec.minutes ?? 0) * MINUTES;
+ d_ms += (spec.hours ?? 0) * HOURS;
+ d_ms += (spec.days ?? 0) * DAYS;
+ d_ms += (spec.months ?? 0) * MONTHS;
+ d_ms += (spec.years ?? 0) * YEARS;
+ return { d_ms };
+ }
+
+ export function getForever(): Duration {
+ return { d_ms: "forever" };
}
- if (deadline.t_ms < now.t_ms) {
+
+ export function getZero(): Duration {
return { d_ms: 0 };
}
- return { d_ms: deadline.t_ms - now.t_ms };
-}
-export function timestampMin(t1: Timestamp, t2: Timestamp): Timestamp {
- if (t1.t_ms === "never") {
- return { t_ms: t2.t_ms };
+ export function fromTalerProtocolDuration(
+ d: TalerProtocolDuration,
+ ): Duration {
+ if (d.d_us === "forever") {
+ return {
+ d_ms: "forever",
+ };
+ }
+ return {
+ d_ms: Math.floor(d.d_us / 1000),
+ };
+ }
+
+ export function toTalerProtocolDuration(d: Duration): TalerProtocolDuration {
+ if (d.d_ms === "forever") {
+ return {
+ d_us: "forever",
+ };
+ }
+ return {
+ d_us: d.d_ms * 1000,
+ };
}
- if (t2.t_ms === "never") {
- return { t_ms: t2.t_ms };
+
+ export function fromMilliseconds(ms: number): Duration {
+ return {
+ d_ms: ms,
+ };
+ }
+
+ export function clamp(args: {
+ lower: Duration;
+ upper: Duration;
+ value: Duration;
+ }): Duration {
+ return durationMax(durationMin(args.value, args.upper), args.lower);
}
- return { t_ms: Math.min(t1.t_ms, t2.t_ms) };
}
-export function timestampMax(t1: Timestamp, t2: Timestamp): Timestamp {
- if (t1.t_ms === "never") {
- return { t_ms: "never" };
+export namespace AbsoluteTime {
+ export function getStampMsNow(): number {
+ return new Date().getTime();
+ }
+
+ export function getStampMsNever(): number {
+ return Number.MAX_SAFE_INTEGER;
}
- if (t2.t_ms === "never") {
- return { t_ms: "never" };
+
+ export function now(): AbsoluteTime {
+ return {
+ t_ms: new Date().getTime() + timeshift,
+ [opaque_AbsoluteTime]: true,
+ };
+ }
+
+ export function never(): AbsoluteTime {
+ return {
+ t_ms: "never",
+ [opaque_AbsoluteTime]: true,
+ };
+ }
+
+ export function fromMilliseconds(ms: number): AbsoluteTime {
+ return {
+ t_ms: ms,
+ [opaque_AbsoluteTime]: true,
+ };
+ }
+
+ export function cmp(t1: AbsoluteTime, t2: AbsoluteTime): number {
+ if (t1.t_ms === "never") {
+ if (t2.t_ms === "never") {
+ return 0;
+ }
+ return 1;
+ }
+ if (t2.t_ms === "never") {
+ return -1;
+ }
+ if (t1.t_ms == t2.t_ms) {
+ return 0;
+ }
+ if (t1.t_ms > t2.t_ms) {
+ return 1;
+ }
+ return -1;
+ }
+
+ export function min(t1: AbsoluteTime, t2: AbsoluteTime): AbsoluteTime {
+ if (t1.t_ms === "never") {
+ return { t_ms: t2.t_ms, [opaque_AbsoluteTime]: true };
+ }
+ if (t2.t_ms === "never") {
+ return { t_ms: t2.t_ms, [opaque_AbsoluteTime]: true };
+ }
+ return { t_ms: Math.min(t1.t_ms, t2.t_ms), [opaque_AbsoluteTime]: true };
+ }
+
+ export function max(t1: AbsoluteTime, t2: AbsoluteTime): AbsoluteTime {
+ if (t1.t_ms === "never") {
+ return { t_ms: "never", [opaque_AbsoluteTime]: true };
+ }
+ if (t2.t_ms === "never") {
+ return { t_ms: "never", [opaque_AbsoluteTime]: true };
+ }
+ return { t_ms: Math.max(t1.t_ms, t2.t_ms), [opaque_AbsoluteTime]: true };
+ }
+
+ export function difference(t1: AbsoluteTime, t2: AbsoluteTime): Duration {
+ if (t1.t_ms === "never") {
+ return { d_ms: "forever" };
+ }
+ if (t2.t_ms === "never") {
+ return { d_ms: "forever" };
+ }
+ return { d_ms: Math.abs(t1.t_ms - t2.t_ms) };
+ }
+
+ export function isExpired(t: AbsoluteTime) {
+ return cmp(t, now()) <= 0;
+ }
+
+ export function isNever(t: AbsoluteTime): boolean {
+ return t.t_ms === "never";
+ }
+
+ export function fromProtocolTimestamp(
+ t: TalerProtocolTimestamp,
+ ): AbsoluteTime {
+ if (t.t_s === "never") {
+ return { t_ms: "never", [opaque_AbsoluteTime]: true };
+ }
+ return {
+ t_ms: t.t_s * 1000,
+ [opaque_AbsoluteTime]: true,
+ };
+ }
+
+ export function fromStampMs(stampMs: number): AbsoluteTime {
+ return {
+ t_ms: stampMs,
+ [opaque_AbsoluteTime]: true,
+ };
+ }
+
+ export function fromPreciseTimestamp(t: TalerPreciseTimestamp): AbsoluteTime {
+ if (t.t_s === "never") {
+ return { t_ms: "never", [opaque_AbsoluteTime]: true };
+ }
+ const offsetUs = t.off_us ?? 0;
+ return {
+ t_ms: t.t_s * 1000 + Math.floor(offsetUs / 1000),
+ [opaque_AbsoluteTime]: true,
+ };
+ }
+
+ export function toStampMs(at: AbsoluteTime): number {
+ if (at.t_ms === "never") {
+ return Number.MAX_SAFE_INTEGER;
+ }
+ return at.t_ms;
+ }
+
+ export function toPreciseTimestamp(at: AbsoluteTime): TalerPreciseTimestamp {
+ if (at.t_ms == "never") {
+ return {
+ t_s: "never",
+ };
+ }
+ const t_s = Math.floor(at.t_ms / 1000);
+ const off_us = Math.floor(1000 * (at.t_ms - t_s * 1000));
+ return {
+ t_s,
+ off_us,
+ };
+ }
+
+ export function toProtocolTimestamp(
+ at: AbsoluteTime,
+ ): TalerProtocolTimestamp {
+ if (at.t_ms === "never") {
+ return { t_s: "never" };
+ }
+ return {
+ t_s: Math.floor(at.t_ms / 1000),
+ };
+ }
+
+ export function isBetween(
+ t: AbsoluteTime,
+ start: AbsoluteTime,
+ end: AbsoluteTime,
+ ): boolean {
+ if (cmp(t, start) < 0) {
+ return false;
+ }
+ if (cmp(t, end) > 0) {
+ return false;
+ }
+ return true;
+ }
+
+ export function toIsoString(t: AbsoluteTime): string {
+ if (t.t_ms === "never") {
+ return "<never>";
+ } else {
+ return new Date(t.t_ms).toISOString();
+ }
+ }
+
+ export function addDuration(t1: AbsoluteTime, d: Duration): AbsoluteTime {
+ if (t1.t_ms === "never" || d.d_ms === "forever") {
+ return { t_ms: "never", [opaque_AbsoluteTime]: true };
+ }
+ return { t_ms: t1.t_ms + d.d_ms, [opaque_AbsoluteTime]: true };
+ }
+
+ /**
+ * Get the remaining duration until {@param t1}.
+ *
+ * If {@param t1} already happened, the remaining duration
+ * is zero.
+ */
+ export function remaining(t1: AbsoluteTime): Duration {
+ if (t1.t_ms === "never") {
+ return Duration.getForever();
+ }
+ const stampNow = now();
+ if (stampNow.t_ms === "never") {
+ throw Error("invariant violated");
+ }
+ return Duration.fromMilliseconds(Math.max(0, t1.t_ms - stampNow.t_ms));
+ }
+
+ export function subtractDuraction(
+ t1: AbsoluteTime,
+ d: Duration,
+ ): AbsoluteTime {
+ if (t1.t_ms === "never") {
+ return { t_ms: "never", [opaque_AbsoluteTime]: true };
+ }
+ if (d.d_ms === "forever") {
+ return { t_ms: 0, [opaque_AbsoluteTime]: true };
+ }
+ return { t_ms: Math.max(0, t1.t_ms - d.d_ms), [opaque_AbsoluteTime]: true };
+ }
+
+ export function stringify(t: AbsoluteTime): string {
+ if (t.t_ms === "never") {
+ return "never";
+ }
+ return new Date(t.t_ms).toISOString();
}
- return { t_ms: Math.max(t1.t_ms, t2.t_ms) };
}
const SECONDS = 1000;
@@ -96,43 +568,12 @@ const DAYS = HOURS * 24;
const MONTHS = DAYS * 30;
const YEARS = DAYS * 365;
-export function durationFromSpec(spec: {
- seconds?: number;
- minutes?: number;
- hours?: number;
- days?: number;
- months?: number;
- years?: number;
-}): Duration {
- let d_ms = 0;
- d_ms += (spec.seconds ?? 0) * SECONDS;
- d_ms += (spec.minutes ?? 0) * MINUTES;
- d_ms += (spec.hours ?? 0) * HOURS;
- d_ms += (spec.days ?? 0) * DAYS;
- d_ms += (spec.months ?? 0) * MONTHS;
- d_ms += (spec.years ?? 0) * YEARS;
- return { d_ms };
-}
-
-/**
- * Truncate a timestamp so that that it represents a multiple
- * of seconds. The timestamp is always rounded down.
- */
-export function timestampTruncateToSecond(t1: Timestamp): Timestamp {
- if (t1.t_ms === "never") {
- return { t_ms: "never" };
- }
- return {
- t_ms: Math.floor(t1.t_ms / 1000) * 1000,
- };
-}
-
export function durationMin(d1: Duration, d2: Duration): Duration {
if (d1.d_ms === "forever") {
return { d_ms: d2.d_ms };
}
if (d2.d_ms === "forever") {
- return { d_ms: d2.d_ms };
+ return { d_ms: d1.d_ms };
}
return { d_ms: Math.min(d1.d_ms, d2.d_ms) };
}
@@ -161,111 +602,76 @@ export function durationAdd(d1: Duration, d2: Duration): Duration {
return { d_ms: d1.d_ms + d2.d_ms };
}
-export function timestampCmp(t1: Timestamp, t2: Timestamp): number {
- if (t1.t_ms === "never") {
- if (t2.t_ms === "never") {
- return 0;
+export const codecForAbsoluteTime: Codec<AbsoluteTime> = {
+ decode(x: any, c?: Context): AbsoluteTime {
+ if (x === undefined) {
+ throw Error(`got undefined and expected absolute time at ${renderContext(c)}`);
}
- return 1;
- }
- if (t2.t_ms === "never") {
- return -1;
- }
- if (t1.t_ms == t2.t_ms) {
- return 0;
- }
- if (t1.t_ms > t2.t_ms) {
- return 1;
- }
- return -1;
-}
-
-export function timestampAddDuration(t1: Timestamp, d: Duration): Timestamp {
- if (t1.t_ms === "never" || d.d_ms === "forever") {
- return { t_ms: "never" };
- }
- return { t_ms: t1.t_ms + d.d_ms };
-}
-
-export function timestampSubtractDuraction(
- t1: Timestamp,
- d: Duration,
-): Timestamp {
- if (t1.t_ms === "never") {
- return { t_ms: "never" };
- }
- if (d.d_ms === "forever") {
- return { t_ms: 0 };
- }
- return { t_ms: Math.max(0, t1.t_ms - d.d_ms) };
-}
-
-export function stringifyTimestamp(t: Timestamp): string {
- if (t.t_ms === "never") {
- return "never";
- }
- return new Date(t.t_ms).toISOString();
-}
-
-export function timestampDifference(t1: Timestamp, t2: Timestamp): Duration {
- if (t1.t_ms === "never") {
- return { d_ms: "forever" };
- }
- if (t2.t_ms === "never") {
- return { d_ms: "forever" };
- }
- return { d_ms: Math.abs(t1.t_ms - t2.t_ms) };
-}
-
-export function timestampToIsoString(t: Timestamp): string {
- if (t.t_ms === "never") {
- return "<never>";
- } else {
- return new Date(t.t_ms).toISOString();
- }
-}
-
-export function timestampIsBetween(
- t: Timestamp,
- start: Timestamp,
- end: Timestamp,
-): boolean {
- if (timestampCmp(t, start) < 0) {
- return false;
- }
- if (timestampCmp(t, end) > 0) {
- return false;
- }
- return true;
-}
+ const t_ms = x.t_ms;
+ if (typeof t_ms === "string") {
+ if (t_ms === "never") {
+ return { t_ms: "never", [opaque_AbsoluteTime]: true };
+ }
+ } else if (typeof t_ms === "number") {
+ return { t_ms, [opaque_AbsoluteTime]: true };
+ }
+ throw Error(`expected timestamp at ${renderContext(c)}`);
+ },
+};
-export const codecForTimestamp: Codec<Timestamp> = {
- decode(x: any, c?: Context): Timestamp {
+export const codecForTimestamp: Codec<TalerProtocolTimestamp> = {
+ decode(x: any, c?: Context): TalerProtocolTimestamp {
+ // Compatibility, should be removed soon.
+ if (x === undefined) {
+ throw Error(`got undefined and expected timestamp at ${renderContext(c)}`);
+ }
const t_ms = x.t_ms;
if (typeof t_ms === "string") {
if (t_ms === "never") {
- return { t_ms: "never" };
+ return { t_s: "never" };
+ }
+ } else if (typeof t_ms === "number") {
+ return { t_s: Math.floor(t_ms / 1000) };
+ }
+ const t_s = x.t_s;
+ if (typeof t_s === "string") {
+ if (t_s === "never") {
+ return { t_s: "never" };
}
throw Error(`expected timestamp at ${renderContext(c)}`);
}
- if (typeof t_ms === "number") {
- return { t_ms };
+ if (typeof t_s === "number") {
+ return { t_s };
}
- throw Error(`expected timestamp at ${renderContext(c)}`);
+ throw Error(`expected protocol timestamp at ${renderContext(c)}`);
+ },
+};
+
+export const codecForPreciseTimestamp: Codec<TalerPreciseTimestamp> = {
+ decode(x: any, c?: Context): TalerPreciseTimestamp {
+ const t_ms = x.t_ms;
+ if (typeof t_ms === "string") {
+ if (t_ms === "never") {
+ return { t_s: "never" };
+ }
+ } else if (typeof t_ms === "number") {
+ return { t_s: Math.floor(t_ms / 1000) };
+ }
+ throw Error(`expected precise timestamp at ${renderContext(c)}`);
},
};
-export const codecForDuration: Codec<Duration> = {
- decode(x: any, c?: Context): Duration {
- const d_ms = x.d_ms;
- if (typeof d_ms === "string") {
- if (d_ms === "forever") {
- return { d_ms: "forever" };
+export const codecForDuration: Codec<TalerProtocolDuration> = {
+ decode(x: any, c?: Context): TalerProtocolDuration {
+ const d_us = x.d_us;
+ if (typeof d_us === "string") {
+ if (d_us === "forever") {
+ return { d_us: "forever" };
}
throw Error(`expected duration at ${renderContext(c)}`);
}
- if (typeof d_ms === "number") {
- return { d_ms };
+ if (typeof d_us === "number") {
+ return { d_us };
}
throw Error(`expected duration at ${renderContext(c)}`);
},
diff --git a/packages/taler-wallet-core/src/util/timer.ts b/packages/taler-util/src/timer.ts
index d9fe3439b..8db024512 100644
--- a/packages/taler-wallet-core/src/util/timer.ts
+++ b/packages/taler-util/src/timer.ts
@@ -53,7 +53,7 @@ class IntervalHandle {
* only event left. Has no effect in the browser.
*/
unref(): void {
- if (typeof this.h === "object") {
+ if (typeof this.h === "object" && "unref" in this.h) {
this.h.unref();
}
}
@@ -71,7 +71,7 @@ class TimeoutHandle {
* only event left. Has no effect in the browser.
*/
unref(): void {
- if (typeof this.h === "object") {
+ if (typeof this.h === "object" && "unref" in this.h) {
this.h.unref();
}
}
@@ -94,23 +94,9 @@ export const performanceNow: () => bigint = (() => {
return () => BigInt(Math.floor(performance.now() * 1000)) * BigInt(1000);
}
- return () => BigInt(0);
+ return () => BigInt(new Date().getTime()) * BigInt(1000) * BigInt(1000);
})();
-/**
- * Call a function every time the delay given in milliseconds passes.
- */
-export function every(delayMs: number, callback: () => void): TimerHandle {
- return new IntervalHandle(setInterval(callback, delayMs));
-}
-
-/**
- * Call a function after the delay given in milliseconds passes.
- */
-export function after(delayMs: number, callback: () => void): TimerHandle {
- return new TimeoutHandle(setTimeout(callback, delayMs));
-}
-
const nullTimerHandle = {
clear() {
// do nothing
@@ -125,13 +111,41 @@ const nullTimerHandle = {
/**
* Group of timers that can be destroyed at once.
*/
+export interface TimerAPI {
+ after(delayMs: number, callback: () => void): TimerHandle;
+ every(delayMs: number, callback: () => void): TimerHandle;
+}
+
+export class SetTimeoutTimerAPI implements TimerAPI {
+ /**
+ * Call a function every time the delay given in milliseconds passes.
+ */
+ every(delayMs: number, callback: () => void): TimerHandle {
+ return new IntervalHandle(setInterval(callback, delayMs));
+ }
+
+ /**
+ * Call a function after the delay given in milliseconds passes.
+ */
+ after(delayMs: number, callback: () => void): TimerHandle {
+ return new TimeoutHandle(setTimeout(callback, delayMs));
+ }
+}
+
+export const timer = new SetTimeoutTimerAPI();
+
+/**
+ * Implementation of [[TimerGroup]] using setTimeout
+ */
export class TimerGroup {
private stopped = false;
- private timerMap: { [index: number]: TimerHandle } = {};
+ private readonly timerMap: { [index: number]: TimerHandle } = {};
private idGen = 1;
+ constructor(public readonly timerApi: TimerAPI) {}
+
stopCurrentAndFutureTimers(): void {
this.stopped = true;
for (const x in this.timerMap) {
@@ -158,7 +172,7 @@ export class TimerGroup {
logger.warn("dropping timer since timer group is stopped");
return nullTimerHandle;
}
- const h = after(delayMs, callback);
+ const h = this.timerApi.after(delayMs, callback);
const myId = this.idGen++;
this.timerMap[myId] = h;
@@ -180,7 +194,7 @@ export class TimerGroup {
logger.warn("dropping timer since timer group is stopped");
return nullTimerHandle;
}
- const h = every(delayMs, callback);
+ const h = this.timerApi.every(delayMs, callback);
const myId = this.idGen++;
this.timerMap[myId] = h;
diff --git a/packages/taler-util/src/transaction-test-data.ts b/packages/taler-util/src/transaction-test-data.ts
new file mode 100644
index 000000000..378028144
--- /dev/null
+++ b/packages/taler-util/src/transaction-test-data.ts
@@ -0,0 +1,113 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ TransactionType,
+ PaymentStatus,
+ TransactionMajorState,
+} from "./transactions-types.js";
+import { RefreshReason } from "./wallet-types.js";
+
+/**
+ * Sample transaction list entries.
+ */
+export const sampleWalletCoreTransactions = [
+ {
+ type: TransactionType.Payment,
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ amountRaw: "KUDOS:10",
+ amountEffective: "KUDOS:10",
+ totalRefundRaw: "KUDOS:0",
+ totalRefundEffective: "KUDOS:0",
+ status: PaymentStatus.Paid,
+ refundPending: undefined,
+ posConfirmation: undefined,
+ pending: false,
+ refunds: [],
+ timestamp: {
+ t_s: 1677166045,
+ },
+ transactionId:
+ "txn:payment:NRRD9KJ8970P5HDAGPW1MBA6HZHB1XMFKF5M3CNR6WA0GT98DHY0",
+ proposalId: "NRRD9KJ8970P5HDAGPW1MBA6HZHB1XMFKF5M3CNR6WA0GT98DHY0",
+ info: {
+ merchant: {
+ name: "woocommerce",
+ website: "woocommerce.demo.taler.net",
+ email: "foo@example.com",
+ address: {},
+ jurisdiction: {},
+ },
+ orderId: "wc_order_KQCRldghIgDRB-100",
+ products: [
+ {
+ description: "Using GCC",
+ quantity: 1,
+ price: "KUDOS:10",
+ product_id: "28",
+ },
+ ],
+ summary: "WooTalerShop #100",
+ contractTermsHash:
+ "A02E1M6ARWKBJ87K2TV4S6WQ4X5YH7BRVR6MYCHCTVAED8MBXTFD6PZ5Q50Y7Z5K18PYBTDA14NQ56XPC1VCQW1EVRWTSB7ZYT65B5G",
+ fulfillmentUrl:
+ "https://woocommerce.demo.taler.net/?wc-api=wc_gnutaler_gateway&order_id=wc_order_KQCRldghIgDRB-100",
+ },
+ refundQueryActive: false,
+ frozen: false,
+ },
+ {
+ type: TransactionType.Refresh,
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ refreshReason: RefreshReason.PayMerchant,
+ amountEffective: "KUDOS:0",
+ amountRaw: "KUDOS:0",
+ refreshInputAmount: "KUDOS:1.5",
+ refreshOutputAmount: "KUDOS:1.4",
+ originatingTransactionId:
+ "txn:proposal:ZCGBZFE8KZ1CBYYGSC3ZC8E40KVJWV16VYCTHGC8FFSVZ5HD24BG",
+ pending: true,
+ timestamp: {
+ t_s: 1681376214,
+ },
+ transactionId:
+ "txn:refresh:QQSWHHXCRQ269G0E3RW14JMC6F7NFDYDW26NSFHRTXSKDS6CMCZ0",
+ frozen: false,
+ error: {
+ code: 7029,
+ when: {
+ t_ms: 1681376473665,
+ },
+ hint: "Error (WALLET_REFRESH_GROUP_INCOMPLETE)",
+ numErrors: 1,
+ errors: [
+ {
+ code: 7001,
+ when: {
+ t_ms: 1681376473189,
+ },
+ hint: "unexpected exception (message: exchange wire fee signature invalid)",
+ stack:
+ " at validateWireInfo (../taler-wallet-core-qjs.mjs:23166)\n",
+ },
+ ],
+ },
+ },
+];
diff --git a/packages/taler-util/src/transactions-types.ts b/packages/taler-util/src/transactions-types.ts
new file mode 100644
index 000000000..ac4c3d717
--- /dev/null
+++ b/packages/taler-util/src/transactions-types.ts
@@ -0,0 +1,794 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Type and schema definitions for the wallet's transaction list.
+ *
+ * @author Florian Dold
+ * @author Torsten Grote
+ */
+
+/**
+ * Imports.
+ */
+import {
+ Codec,
+ buildCodecForObject,
+ codecForAny,
+ codecForBoolean,
+ codecForConstString,
+ codecForEither,
+ codecForList,
+ codecForString,
+ codecOptional,
+} from "./codec.js";
+import {
+ AmountString,
+ InternationalizedString,
+ MerchantInfo,
+ codecForInternationalizedString,
+ codecForMerchantInfo,
+} from "./taler-types.js";
+import { TalerPreciseTimestamp, TalerProtocolTimestamp } from "./time.js";
+import {
+ RefreshReason,
+ ScopeInfo,
+ TalerErrorDetail,
+ TransactionIdStr,
+ TransactionStateFilter,
+ WithdrawalExchangeAccountDetails,
+ codecForScopeInfo,
+} from "./wallet-types.js";
+
+export interface TransactionsRequest {
+ /**
+ * return only transactions in the given currency
+ *
+ * it will be removed in next release
+ *
+ * @deprecated use scopeInfo
+ */
+ currency?: string;
+
+ /**
+ * return only transactions in the given scopeInfo
+ */
+ scopeInfo?: ScopeInfo;
+
+ /**
+ * if present, results will be limited to transactions related to the given search string
+ */
+ search?: string;
+
+ /**
+ * Sort order of the transaction items.
+ * By default, items are sorted ascending by their
+ * main timestamp.
+ *
+ * ascending: ascending by timestamp, but pending transactions first
+ * descending: ascending by timestamp, but pending transactions first
+ * stable-ascending: ascending by timestamp, with pending transactions amidst other transactions
+ * (stable in the sense of: pending transactions don't jump around)
+ */
+ sort?: "ascending" | "descending" | "stable-ascending";
+
+ /**
+ * If true, include all refreshes in the transactions list.
+ */
+ includeRefreshes?: boolean;
+
+ filterByState?: TransactionStateFilter;
+}
+
+export interface TransactionState {
+ major: TransactionMajorState;
+ minor?: TransactionMinorState;
+}
+
+export enum TransactionMajorState {
+ // No state, only used when reporting transitions into the initial state
+ None = "none",
+ Pending = "pending",
+ Done = "done",
+ Aborting = "aborting",
+ Aborted = "aborted",
+ Suspended = "suspended",
+ Dialog = "dialog",
+ SuspendedAborting = "suspended-aborting",
+ Failed = "failed",
+ Expired = "expired",
+ // Only used for the notification, never in the transaction history
+ Deleted = "deleted",
+}
+
+export enum TransactionMinorState {
+ // Placeholder until D37 is fully implemented
+ Unknown = "unknown",
+ Deposit = "deposit",
+ KycRequired = "kyc",
+ AmlRequired = "aml",
+ MergeKycRequired = "merge-kyc",
+ Track = "track",
+ SubmitPayment = "submit-payment",
+ RebindSession = "rebind-session",
+ Refresh = "refresh",
+ Pickup = "pickup",
+ AutoRefund = "auto-refund",
+ User = "user",
+ Bank = "bank",
+ Exchange = "exchange",
+ ClaimProposal = "claim-proposal",
+ CheckRefund = "check-refund",
+ CreatePurse = "create-purse",
+ DeletePurse = "delete-purse",
+ RefreshExpired = "refresh-expired",
+ Ready = "ready",
+ Merge = "merge",
+ Repurchase = "repurchase",
+ BankRegisterReserve = "bank-register-reserve",
+ BankConfirmTransfer = "bank-confirm-transfer",
+ WithdrawCoins = "withdraw-coins",
+ ExchangeWaitReserve = "exchange-wait-reserve",
+ AbortingBank = "aborting-bank",
+ Aborting = "aborting",
+ Refused = "refused",
+ Withdraw = "withdraw",
+ MerchantOrderProposed = "merchant-order-proposed",
+ Proposed = "proposed",
+ RefundAvailable = "refund-available",
+ AcceptRefund = "accept-refund",
+ PaidByOther = "paid-by-other",
+}
+
+export enum TransactionAction {
+ Delete = "delete",
+ Suspend = "suspend",
+ Resume = "resume",
+ Abort = "abort",
+ Fail = "fail",
+ Retry = "retry",
+}
+
+export interface TransactionsResponse {
+ // a list of past and pending transactions sorted by pending, timestamp and transactionId.
+ // In case two events are both pending and have the same timestamp,
+ // they are sorted by the transactionId
+ // (lexically ascending and locale-independent comparison).
+ transactions: Transaction[];
+}
+
+export interface TransactionCommon {
+ // opaque unique ID for the transaction, used as a starting point for paginating queries
+ // and for invoking actions on the transaction (e.g. deleting/hiding it from the history)
+ transactionId: TransactionIdStr;
+
+ // the type of the transaction; different types might provide additional information
+ type: TransactionType;
+
+ // main timestamp of the transaction
+ timestamp: TalerPreciseTimestamp;
+
+ /**
+ * Transaction state, as per DD37.
+ */
+ txState: TransactionState;
+
+ /**
+ * Possible transitions based on the current state.
+ */
+ txActions: TransactionAction[];
+
+ /**
+ * Raw amount of the transaction (exclusive of fees or other extra costs).
+ */
+ amountRaw: AmountString;
+
+ /**
+ * Amount added or removed from the wallet's balance (including all fees and other costs).
+ */
+ amountEffective: AmountString;
+
+ error?: TalerErrorDetail;
+
+ /**
+ * If the transaction minor state is in KycRequired this field is going to
+ * have the location where the user need to go to complete KYC information.
+ */
+ kycUrl?: string;
+}
+
+export type Transaction =
+ | TransactionWithdrawal
+ | TransactionPayment
+ | TransactionRefund
+ | TransactionRefresh
+ | TransactionDeposit
+ | TransactionPeerPullCredit
+ | TransactionPeerPullDebit
+ | TransactionPeerPushCredit
+ | TransactionPeerPushDebit
+ | TransactionInternalWithdrawal
+ | TransactionRecoup
+ | TransactionDenomLoss;
+
+export enum TransactionType {
+ Withdrawal = "withdrawal",
+ InternalWithdrawal = "internal-withdrawal",
+ Payment = "payment",
+ Refund = "refund",
+ Refresh = "refresh",
+ Deposit = "deposit",
+ PeerPushDebit = "peer-push-debit",
+ PeerPushCredit = "peer-push-credit",
+ PeerPullDebit = "peer-pull-debit",
+ PeerPullCredit = "peer-pull-credit",
+ Recoup = "recoup",
+ DenomLoss = "denom-loss",
+}
+
+export enum WithdrawalType {
+ TalerBankIntegrationApi = "taler-bank-integration-api",
+ ManualTransfer = "manual-transfer",
+}
+
+export type WithdrawalDetails =
+ | WithdrawalDetailsForManualTransfer
+ | WithdrawalDetailsForTalerBankIntegrationApi;
+
+interface WithdrawalDetailsForManualTransfer {
+ type: WithdrawalType.ManualTransfer;
+
+ /**
+ * Payto URIs that the exchange supports.
+ *
+ * Already contains the amount and message.
+ *
+ * @deprecated in favor of exchangeCreditAccounts
+ */
+ exchangePaytoUris: string[];
+
+ exchangeCreditAccountDetails?: WithdrawalExchangeAccountDetails[];
+
+ // Public key of the reserve
+ reservePub: string;
+
+ /**
+ * Is the reserve ready for withdrawal?
+ */
+ reserveIsReady: boolean;
+}
+
+interface WithdrawalDetailsForTalerBankIntegrationApi {
+ type: WithdrawalType.TalerBankIntegrationApi;
+
+ /**
+ * Set to true if the bank has confirmed the withdrawal, false if not.
+ * An unconfirmed withdrawal usually requires user-input and should be highlighted in the UI.
+ * See also bankConfirmationUrl below.
+ */
+ confirmed: boolean;
+
+ /**
+ * If the withdrawal is unconfirmed, this can include a URL for user
+ * initiated confirmation.
+ */
+ bankConfirmationUrl?: string;
+
+ // Public key of the reserve
+ reservePub: string;
+
+ /**
+ * Is the reserve ready for withdrawal?
+ */
+ reserveIsReady: boolean;
+
+ exchangeCreditAccountDetails?: WithdrawalExchangeAccountDetails[];
+}
+
+export enum DenomLossEventType {
+ DenomExpired = "denom-expired",
+ DenomVanished = "denom-vanished",
+ DenomUnoffered = "denom-unoffered",
+}
+
+/**
+ * A transaction to indicate financial loss due to denominations
+ * that became unusable for deposits.
+ */
+export interface TransactionDenomLoss extends TransactionCommon {
+ type: TransactionType.DenomLoss;
+ lossEventType: DenomLossEventType;
+ exchangeBaseUrl: string;
+}
+
+/**
+ * A withdrawal transaction (either bank-integrated or manual).
+ */
+export interface TransactionWithdrawal extends TransactionCommon {
+ type: TransactionType.Withdrawal;
+
+ /**
+ * Exchange of the withdrawal.
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Amount that got subtracted from the reserve balance.
+ */
+ amountRaw: AmountString;
+
+ /**
+ * Amount that actually was (or will be) added to the wallet's balance.
+ */
+ amountEffective: AmountString;
+
+ withdrawalDetails: WithdrawalDetails;
+}
+
+/**
+ * Internal withdrawal operation, only reported on request.
+ *
+ * Some transactions (peer-*-credit) internally do a withdrawal,
+ * but only the peer-*-credit transaction is reported.
+ *
+ * The internal withdrawal transaction allows to access the details of
+ * the underlying withdrawal for testing/debugging.
+ *
+ * It is usually not reported, so that amounts of transactions properly
+ * add up, since the amountEffecive of the withdrawal is already reported
+ * in the peer-*-credit transaction.
+ */
+export interface TransactionInternalWithdrawal extends TransactionCommon {
+ type: TransactionType.InternalWithdrawal;
+
+ /**
+ * Exchange of the withdrawal.
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Amount that got subtracted from the reserve balance.
+ */
+ amountRaw: AmountString;
+
+ /**
+ * Amount that actually was (or will be) added to the wallet's balance.
+ */
+ amountEffective: AmountString;
+
+ withdrawalDetails: WithdrawalDetails;
+}
+
+export interface PeerInfoShort {
+ expiration: TalerProtocolTimestamp | undefined;
+ summary: string | undefined;
+}
+
+/**
+ * Credit because we were paid for a P2P invoice we created.
+ */
+export interface TransactionPeerPullCredit extends TransactionCommon {
+ type: TransactionType.PeerPullCredit;
+
+ info: PeerInfoShort;
+ /**
+ * Exchange used.
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Amount that got subtracted from the reserve balance.
+ */
+ amountRaw: AmountString;
+
+ /**
+ * Amount that actually was (or will be) added to the wallet's balance.
+ */
+ amountEffective: AmountString;
+
+ /**
+ * URI to send to the other party.
+ *
+ * Only available in the right state.
+ */
+ talerUri: string | undefined;
+}
+
+/**
+ * Debit because we paid someone's invoice.
+ */
+export interface TransactionPeerPullDebit extends TransactionCommon {
+ type: TransactionType.PeerPullDebit;
+
+ info: PeerInfoShort;
+ /**
+ * Exchange used.
+ */
+ exchangeBaseUrl: string;
+
+ amountRaw: AmountString;
+
+ amountEffective: AmountString;
+}
+
+/**
+ * We sent money via a P2P payment.
+ */
+export interface TransactionPeerPushDebit extends TransactionCommon {
+ type: TransactionType.PeerPushDebit;
+
+ info: PeerInfoShort;
+ /**
+ * Exchange used.
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Amount that got subtracted from the reserve balance.
+ */
+ amountRaw: AmountString;
+
+ /**
+ * Amount that actually was (or will be) added to the wallet's balance.
+ */
+ amountEffective: AmountString;
+
+ /**
+ * URI to accept the payment.
+ *
+ * Only present if the transaction is in a state where the other party can
+ * accept the payment.
+ */
+ talerUri?: string;
+}
+
+/**
+ * We received money via a P2P payment.
+ */
+export interface TransactionPeerPushCredit extends TransactionCommon {
+ type: TransactionType.PeerPushCredit;
+
+ info: PeerInfoShort;
+ /**
+ * Exchange used.
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Amount that got subtracted from the reserve balance.
+ */
+ amountRaw: AmountString;
+
+ /**
+ * Amount that actually was (or will be) added to the wallet's balance.
+ */
+ amountEffective: AmountString;
+}
+
+/**
+ * The exchange revoked a key and the wallet recoups funds.
+ */
+export interface TransactionRecoup extends TransactionCommon {
+ type: TransactionType.Recoup;
+}
+
+export enum PaymentStatus {
+ /**
+ * Explicitly aborted after timeout / failure
+ */
+ Aborted = "aborted",
+
+ /**
+ * Payment failed, wallet will auto-retry.
+ * User should be given the option to retry now / abort.
+ */
+ Failed = "failed",
+
+ /**
+ * Paid successfully
+ */
+ Paid = "paid",
+
+ /**
+ * User accepted, payment is processing.
+ */
+ Accepted = "accepted",
+}
+
+export interface TransactionPayment extends TransactionCommon {
+ type: TransactionType.Payment;
+
+ /**
+ * Additional information about the payment.
+ */
+ info: OrderShortInfo;
+
+ /**
+ * Wallet-internal end-to-end identifier for the payment.
+ */
+ proposalId: string;
+
+ /**
+ * Amount that must be paid for the contract
+ */
+ amountRaw: AmountString;
+
+ /**
+ * Amount that was paid, including deposit, wire and refresh fees.
+ */
+ amountEffective: AmountString;
+
+ /**
+ * Amount that has been refunded by the merchant
+ */
+ totalRefundRaw: AmountString;
+
+ /**
+ * Amount will be added to the wallet's balance after fees and refreshing
+ */
+ totalRefundEffective: AmountString;
+
+ /**
+ * Amount pending to be picked up
+ */
+ refundPending: AmountString | undefined;
+
+ /**
+ * Reference to applied refunds
+ */
+ refunds: RefundInfoShort[];
+
+ /**
+ * Is the wallet currently checking for a refund?
+ */
+ refundQueryActive: boolean;
+
+ /**
+ * Does this purchase has an pos validation
+ */
+ posConfirmation: string | undefined;
+}
+
+export interface OrderShortInfo {
+ /**
+ * Order ID, uniquely identifies the order within a merchant instance
+ */
+ orderId: string;
+
+ /**
+ * Hash of the contract terms.
+ */
+ contractTermsHash: string;
+
+ /**
+ * More information about the merchant
+ */
+ merchant: MerchantInfo;
+
+ /**
+ * Summary of the order, given by the merchant
+ */
+ summary: string;
+
+ /**
+ * Map from IETF BCP 47 language tags to localized summaries
+ */
+ summary_i18n?: InternationalizedString;
+
+ /**
+ * URL of the fulfillment, given by the merchant
+ */
+ fulfillmentUrl?: string;
+
+ /**
+ * Plain text message that should be shown to the user
+ * when the payment is complete.
+ */
+ fulfillmentMessage?: string;
+
+ /**
+ * Translations of fulfillmentMessage.
+ */
+ fulfillmentMessage_i18n?: InternationalizedString;
+}
+
+export interface RefundInfoShort {
+ transactionId: string;
+ timestamp: TalerProtocolTimestamp;
+ amountEffective: AmountString;
+ amountRaw: AmountString;
+}
+
+/**
+ * Summary information about the payment that we got a refund for.
+ */
+export interface RefundPaymentInfo {
+ summary: string;
+ summary_i18n?: InternationalizedString;
+ /**
+ * More information about the merchant
+ */
+ merchant: MerchantInfo;
+}
+
+export interface TransactionRefund extends TransactionCommon {
+ type: TransactionType.Refund;
+
+ // Amount that has been refunded by the merchant
+ amountRaw: AmountString;
+
+ // Amount will be added to the wallet's balance after fees and refreshing
+ amountEffective: AmountString;
+
+ // ID for the transaction that is refunded
+ refundedTransactionId: string;
+
+ paymentInfo: RefundPaymentInfo | undefined;
+}
+
+/**
+ * A transaction shown for refreshes.
+ * Only shown for (1) refreshes not associated with other transactions
+ * and (2) refreshes in an error state.
+ */
+export interface TransactionRefresh extends TransactionCommon {
+ type: TransactionType.Refresh;
+
+ refreshReason: RefreshReason;
+
+ /**
+ * Transaction ID that caused this refresh.
+ */
+ originatingTransactionId?: string;
+
+ /**
+ * Always zero for refreshes
+ */
+ amountRaw: AmountString;
+
+ /**
+ * Fees, i.e. the effective, negative effect of the refresh
+ * on the balance.
+ *
+ * Only applicable for stand-alone refreshes, and zero for
+ * other refreshes where the transaction itself accounts for the
+ * refresh fee.
+ */
+ amountEffective: AmountString;
+
+ refreshInputAmount: AmountString;
+ refreshOutputAmount: AmountString;
+}
+
+export interface DepositTransactionTrackingState {
+ // Raw wire transfer identifier of the deposit.
+ wireTransferId: string;
+ // When was the wire transfer given to the bank.
+ timestampExecuted: TalerProtocolTimestamp;
+ // Total amount transfer for this wtid (including fees)
+ amountRaw: AmountString;
+ // Wire fee amount for this exchange
+ wireFee: AmountString;
+}
+
+/**
+ * Deposit transaction, which effectively sends
+ * money from this wallet somewhere else.
+ */
+export interface TransactionDeposit extends TransactionCommon {
+ type: TransactionType.Deposit;
+
+ depositGroupId: string;
+
+ /**
+ * Target for the deposit.
+ */
+ targetPaytoUri: string;
+
+ /**
+ * Raw amount that is being deposited
+ */
+ amountRaw: AmountString;
+
+ /**
+ * Effective amount that is being deposited
+ */
+ amountEffective: AmountString;
+
+ wireTransferDeadline: TalerProtocolTimestamp;
+
+ wireTransferProgress: number;
+
+ /**
+ * Did all the deposit requests succeed?
+ */
+ deposited: boolean;
+
+ trackingState: Array<DepositTransactionTrackingState>;
+}
+
+export interface TransactionByIdRequest {
+ transactionId: string;
+}
+
+export const codecForTransactionByIdRequest =
+ (): Codec<TransactionByIdRequest> =>
+ buildCodecForObject<TransactionByIdRequest>()
+ .property("transactionId", codecForString())
+ .build("TransactionByIdRequest");
+
+export interface WithdrawalTransactionByURIRequest {
+ talerWithdrawUri: string;
+}
+
+export const codecForWithdrawalTransactionByURIRequest =
+ (): Codec<WithdrawalTransactionByURIRequest> =>
+ buildCodecForObject<WithdrawalTransactionByURIRequest>()
+ .property("talerWithdrawUri", codecForString())
+ .build("WithdrawalTransactionByURIRequest");
+
+export const codecForTransactionsRequest = (): Codec<TransactionsRequest> =>
+ buildCodecForObject<TransactionsRequest>()
+ .property("currency", codecOptional(codecForString()))
+ .property("scopeInfo", codecOptional(codecForScopeInfo()))
+ .property("search", codecOptional(codecForString()))
+ .property(
+ "sort",
+ codecOptional(
+ codecForEither(
+ codecForConstString("ascending"),
+ codecForConstString("descending"),
+ codecForConstString("stable-ascending"),
+ ),
+ ),
+ )
+ .property("includeRefreshes", codecOptional(codecForBoolean()))
+ .build("TransactionsRequest");
+
+// FIXME: do full validation here!
+export const codecForTransactionsResponse = (): Codec<TransactionsResponse> =>
+ buildCodecForObject<TransactionsResponse>()
+ .property("transactions", codecForList(codecForAny()))
+ .build("TransactionsResponse");
+
+export const codecForOrderShortInfo = (): Codec<OrderShortInfo> =>
+ buildCodecForObject<OrderShortInfo>()
+ .property("contractTermsHash", codecForString())
+ .property("fulfillmentMessage", codecOptional(codecForString()))
+ .property(
+ "fulfillmentMessage_i18n",
+ codecOptional(codecForInternationalizedString()),
+ )
+ .property("fulfillmentUrl", codecOptional(codecForString()))
+ .property("merchant", codecForMerchantInfo())
+ .property("orderId", codecForString())
+ .property("summary", codecForString())
+ .property("summary_i18n", codecOptional(codecForInternationalizedString()))
+ .build("OrderShortInfo");
+
+export interface ListAssociatedRefreshesRequest {
+ transactionId: string;
+}
+
+export const codecForListAssociatedRefreshesRequest =
+ (): Codec<ListAssociatedRefreshesRequest> =>
+ buildCodecForObject<ListAssociatedRefreshesRequest>()
+ .property("transactionId", codecForString())
+ .build("ListAssociatedRefreshesRequest");
+
+export interface ListAssociatedRefreshesResponse {
+ transactionIds: string[];
+}
diff --git a/packages/taler-util/src/transactionsTypes.ts b/packages/taler-util/src/transactionsTypes.ts
deleted file mode 100644
index 2ee34022f..000000000
--- a/packages/taler-util/src/transactionsTypes.ts
+++ /dev/null
@@ -1,370 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Type and schema definitions for the wallet's transaction list.
- *
- * @author Florian Dold
- * @author Torsten Grote
- */
-
-/**
- * Imports.
- */
-import { Timestamp } from "./time.js";
-import {
- AmountString,
- Product,
- InternationalizedString,
- MerchantInfo,
- codecForInternationalizedString,
- codecForMerchantInfo,
- codecForProduct,
-} from "./talerTypes.js";
-import {
- Codec,
- buildCodecForObject,
- codecOptional,
- codecForString,
- codecForList,
- codecForAny,
-} from "./codec.js";
-import { TalerErrorDetails } from "./walletTypes.js";
-
-export interface TransactionsRequest {
- /**
- * return only transactions in the given currency
- */
- currency?: string;
-
- /**
- * if present, results will be limited to transactions related to the given search string
- */
- search?: string;
-}
-
-export interface TransactionsResponse {
- // a list of past and pending transactions sorted by pending, timestamp and transactionId.
- // In case two events are both pending and have the same timestamp,
- // they are sorted by the transactionId
- // (lexically ascending and locale-independent comparison).
- transactions: Transaction[];
-}
-
-export interface TransactionCommon {
- // opaque unique ID for the transaction, used as a starting point for paginating queries
- // and for invoking actions on the transaction (e.g. deleting/hiding it from the history)
- transactionId: string;
-
- // the type of the transaction; different types might provide additional information
- type: TransactionType;
-
- // main timestamp of the transaction
- timestamp: Timestamp;
-
- // true if the transaction is still pending, false otherwise
- // If a transaction is not longer pending, its timestamp will be updated,
- // but its transactionId will remain unchanged
- pending: boolean;
-
- /**
- * True if the transaction encountered a problem that might be
- * permanent. A frozen transaction won't be automatically retried.
- */
- frozen: boolean;
-
- // Raw amount of the transaction (exclusive of fees or other extra costs)
- amountRaw: AmountString;
-
- // Amount added or removed from the wallet's balance (including all fees and other costs)
- amountEffective: AmountString;
-
- error?: TalerErrorDetails;
-}
-
-export type Transaction =
- | TransactionWithdrawal
- | TransactionPayment
- | TransactionRefund
- | TransactionTip
- | TransactionRefresh
- | TransactionDeposit;
-
-export enum TransactionType {
- Withdrawal = "withdrawal",
- Payment = "payment",
- Refund = "refund",
- Refresh = "refresh",
- Tip = "tip",
- Deposit = "deposit",
-}
-
-export enum WithdrawalType {
- TalerBankIntegrationApi = "taler-bank-integration-api",
- ManualTransfer = "manual-transfer",
-}
-
-export type WithdrawalDetails =
- | WithdrawalDetailsForManualTransfer
- | WithdrawalDetailsForTalerBankIntegrationApi;
-
-interface WithdrawalDetailsForManualTransfer {
- type: WithdrawalType.ManualTransfer;
-
- /**
- * Payto URIs that the exchange supports.
- *
- * Already contains the amount and message.
- */
- exchangePaytoUris: string[];
-}
-
-interface WithdrawalDetailsForTalerBankIntegrationApi {
- type: WithdrawalType.TalerBankIntegrationApi;
-
- /**
- * Set to true if the bank has confirmed the withdrawal, false if not.
- * An unconfirmed withdrawal usually requires user-input and should be highlighted in the UI.
- * See also bankConfirmationUrl below.
- */
- confirmed: boolean;
-
- /**
- * If the withdrawal is unconfirmed, this can include a URL for user
- * initiated confirmation.
- */
- bankConfirmationUrl?: string;
-}
-
-// This should only be used for actual withdrawals
-// and not for tips that have their own transactions type.
-export interface TransactionWithdrawal extends TransactionCommon {
- type: TransactionType.Withdrawal;
-
- /**
- * Exchange of the withdrawal.
- */
- exchangeBaseUrl: string;
-
- /**
- * Amount that got subtracted from the reserve balance.
- */
- amountRaw: AmountString;
-
- /**
- * Amount that actually was (or will be) added to the wallet's balance.
- */
- amountEffective: AmountString;
-
- withdrawalDetails: WithdrawalDetails;
-}
-
-export enum PaymentStatus {
- /**
- * Explicitly aborted after timeout / failure
- */
- Aborted = "aborted",
-
- /**
- * Payment failed, wallet will auto-retry.
- * User should be given the option to retry now / abort.
- */
- Failed = "failed",
-
- /**
- * Paid successfully
- */
- Paid = "paid",
-
- /**
- * User accepted, payment is processing.
- */
- Accepted = "accepted",
-}
-
-export interface TransactionPayment extends TransactionCommon {
- type: TransactionType.Payment;
-
- /**
- * Additional information about the payment.
- */
- info: OrderShortInfo;
-
- /**
- * Wallet-internal end-to-end identifier for the payment.
- */
- proposalId: string;
-
- /**
- * How far did the wallet get with processing the payment?
- */
- status: PaymentStatus;
-
- /**
- * Amount that must be paid for the contract
- */
- amountRaw: AmountString;
-
- /**
- * Amount that was paid, including deposit, wire and refresh fees.
- */
- amountEffective: AmountString;
-}
-
-export interface OrderShortInfo {
- /**
- * Order ID, uniquely identifies the order within a merchant instance
- */
- orderId: string;
-
- /**
- * Hash of the contract terms.
- */
- contractTermsHash: string;
-
- /**
- * More information about the merchant
- */
- merchant: MerchantInfo;
-
- /**
- * Summary of the order, given by the merchant
- */
- summary: string;
-
- /**
- * Map from IETF BCP 47 language tags to localized summaries
- */
- summary_i18n?: InternationalizedString;
-
- /**
- * List of products that are part of the order
- */
- products: Product[] | undefined;
-
- /**
- * URL of the fulfillment, given by the merchant
- */
- fulfillmentUrl?: string;
-
- /**
- * Plain text message that should be shown to the user
- * when the payment is complete.
- */
- fulfillmentMessage?: string;
-
- /**
- * Translations of fulfillmentMessage.
- */
- fulfillmentMessage_i18n?: InternationalizedString;
-}
-
-export interface TransactionRefund extends TransactionCommon {
- type: TransactionType.Refund;
-
- // ID for the transaction that is refunded
- refundedTransactionId: string;
-
- // Additional information about the refunded payment
- info: OrderShortInfo;
-
- // Amount that has been refunded by the merchant
- amountRaw: AmountString;
-
- // Amount will be added to the wallet's balance after fees and refreshing
- amountEffective: AmountString;
-}
-
-export interface TransactionTip extends TransactionCommon {
- type: TransactionType.Tip;
-
- // Raw amount of the tip, without extra fees that apply
- amountRaw: AmountString;
-
- // Amount will be (or was) added to the wallet's balance after fees and refreshing
- amountEffective: AmountString;
-
- merchantBaseUrl: string;
-}
-
-// A transaction shown for refreshes that are not associated to other transactions
-// such as a refresh necessary before coin expiration.
-// It should only be returned by the API if the effective amount is different from zero.
-export interface TransactionRefresh extends TransactionCommon {
- type: TransactionType.Refresh;
-
- // Exchange that the coins are refreshed with
- exchangeBaseUrl: string;
-
- // Raw amount that is refreshed
- amountRaw: AmountString;
-
- // Amount that will be paid as fees for the refresh
- amountEffective: AmountString;
-}
-
-/**
- * Deposit transaction, which effectively sends
- * money from this wallet somewhere else.
- */
-export interface TransactionDeposit extends TransactionCommon {
- type: TransactionType.Deposit;
-
- depositGroupId: string;
-
- /**
- * Target for the deposit.
- */
- targetPaytoUri: string;
-
- /**
- * Raw amount that is being deposited
- */
- amountRaw: AmountString;
-
- /**
- * Effective amount that is being deposited
- */
- amountEffective: AmountString;
-}
-
-export const codecForTransactionsRequest = (): Codec<TransactionsRequest> =>
- buildCodecForObject<TransactionsRequest>()
- .property("currency", codecOptional(codecForString()))
- .property("search", codecOptional(codecForString()))
- .build("TransactionsRequest");
-
-// FIXME: do full validation here!
-export const codecForTransactionsResponse = (): Codec<TransactionsResponse> =>
- buildCodecForObject<TransactionsResponse>()
- .property("transactions", codecForList(codecForAny()))
- .build("TransactionsResponse");
-
-export const codecForOrderShortInfo = (): Codec<OrderShortInfo> =>
- buildCodecForObject<OrderShortInfo>()
- .property("contractTermsHash", codecForString())
- .property("fulfillmentMessage", codecOptional(codecForString()))
- .property(
- "fulfillmentMessage_i18n",
- codecOptional(codecForInternationalizedString()),
- )
- .property("fulfillmentUrl", codecOptional(codecForString()))
- .property("merchant", codecForMerchantInfo())
- .property("orderId", codecForString())
- .property("products", codecOptional(codecForList(codecForProduct())))
- .property("summary", codecForString())
- .property("summary_i18n", codecOptional(codecForInternationalizedString()))
- .build("OrderShortInfo");
diff --git a/packages/taler-util/src/twrpc-impl.missing.ts b/packages/taler-util/src/twrpc-impl.missing.ts
new file mode 100644
index 000000000..7d7fa84ae
--- /dev/null
+++ b/packages/taler-util/src/twrpc-impl.missing.ts
@@ -0,0 +1,26 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import type { RpcConnectArgs, RpcServerArgs } from "./twrpc.js";
+
+// Not implemented.
+export async function connectRpc<T>(args: RpcConnectArgs<T>): Promise<T> {
+ throw Error("not implemented");
+}
+
+export async function runRpcServer(args: RpcServerArgs): Promise<void> {
+ throw Error("not implemented");
+}
diff --git a/packages/taler-util/src/twrpc-impl.node.ts b/packages/taler-util/src/twrpc-impl.node.ts
new file mode 100644
index 000000000..30e362e5b
--- /dev/null
+++ b/packages/taler-util/src/twrpc-impl.node.ts
@@ -0,0 +1,216 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import * as net from "node:net";
+import * as fs from "node:fs";
+import { Logger } from "./logging.js";
+import { bytesToString, typedArrayConcat } from "./taler-crypto.js";
+import type { RpcConnectArgs, RpcServerArgs } from "./twrpc.js";
+
+interface ReadLinewiseArgs {
+ onLine(lineData: Uint8Array): void;
+ sock: net.Socket;
+}
+
+const logger = new Logger("twrpc-impl.node.ts");
+
+function readStreamLinewise(args: ReadLinewiseArgs): void {
+ let chunks: Uint8Array[] = [];
+ args.sock.on("data", (buf: Uint8Array) => {
+ // Process all newlines in the newly received buffer
+ while (1) {
+ const newlineIdx = buf.indexOf("\n".charCodeAt(0));
+ if (newlineIdx >= 0) {
+ let left = buf.subarray(0, newlineIdx + 1);
+ let right = buf.subarray(newlineIdx + 1);
+ chunks.push(left);
+ const line = typedArrayConcat(chunks);
+ args.onLine(line);
+ chunks = [];
+ buf = right;
+ } else {
+ chunks.push(buf);
+ break;
+ }
+ }
+ });
+}
+
+export async function connectRpc<T>(args: RpcConnectArgs<T>): Promise<T> {
+ let sockFilename = args.socketFilename;
+ return new Promise((resolve, reject) => {
+ const client = net.createConnection(sockFilename);
+ client.on("error", (e) => {
+ reject(e);
+ });
+ client.on("connect", () => {
+ let parsingBody: string | undefined = undefined;
+ let bodyChunks: string[] = [];
+
+ logger.info("connected!");
+ client.write("%hello-from-client\n");
+ const res = args.onEstablished({
+ sendMessage(m) {
+ client.write("%request\n");
+ client.write(JSON.stringify(m));
+ client.write("\n");
+ client.write("%end\n");
+ },
+ close() {
+ client.destroy();
+ },
+ });
+ readStreamLinewise({
+ sock: client,
+ onLine(line) {
+ const lineStr = bytesToString(line);
+ // Are we currently parsing the body of a request?
+ if (!parsingBody) {
+ const strippedLine = lineStr.trim();
+ if (strippedLine == "%message") {
+ parsingBody = "message";
+ } else if (strippedLine == "%hello-from-server") {
+ } else if (strippedLine.startsWith("%error:")) {
+ client.end();
+ res.onDisconnect();
+ } else {
+ logger.warn("got unknown request");
+ client.write("%error: invalid message\n");
+ client.end();
+ }
+ } else if (parsingBody == "message") {
+ const strippedLine = lineStr.trim();
+ if (strippedLine == "%end") {
+ let req = bodyChunks.join("");
+ let reqJson: any = undefined;
+ try {
+ reqJson = JSON.parse(req);
+ } catch (e) {
+ logger.warn("JSON message from server was invalid");
+ logger.info(`message was: ${req}`);
+ }
+ if (reqJson !== undefined) {
+ res.onMessage(reqJson);
+ } else {
+ client.write("%error: invalid JSON");
+ client.end();
+ }
+ bodyChunks = [];
+ parsingBody = undefined;
+ } else {
+ bodyChunks.push(lineStr);
+ }
+ } else {
+ logger.info("invalid parser state");
+ client.write("%error: internal error\n");
+ client.end();
+ }
+ },
+ });
+ client.on("close", () => {
+ res.onDisconnect();
+ });
+ client.on("data", () => {});
+ resolve(res.result);
+ });
+ });
+}
+
+export async function runRpcServer(args: RpcServerArgs): Promise<void> {
+ let sockFilename = args.socketFilename;
+ try {
+ fs.unlinkSync(sockFilename);
+ } catch (e) {
+ // Do nothing!
+ }
+ return new Promise((resolve, reject) => {
+ const server = net.createServer((sock) => {
+ // Are we currently parsing the body of a request?
+ let parsingBody: string | undefined = undefined;
+ let bodyChunks: string[] = [];
+
+ sock.write("%hello-from-server\n");
+ const handlers = args.onConnect({
+ sendResponse(message) {
+ sock.write("%message\n");
+ sock.write(JSON.stringify(message));
+ sock.write("\n");
+ sock.write("%end\n");
+ },
+ });
+
+ sock.on("error", (err) => {
+ logger.error(`connection error: ${err}`);
+ });
+
+ function processLine(line: Uint8Array) {
+ const lineStr = bytesToString(line);
+ if (!parsingBody) {
+ const strippedLine = lineStr.trim();
+ if (strippedLine == "%request") {
+ parsingBody = "request";
+ } else if (strippedLine === "%hello-from-client") {
+ // Nothing to do, ignore hello
+ } else if (strippedLine.startsWith("%error:")) {
+ logger.warn("got error from client");
+ sock.end();
+ handlers.onDisconnect();
+ } else {
+ logger.info("got unknown request");
+ sock.write("%error: invalid request\n");
+ sock.end();
+ }
+ } else if (parsingBody == "request") {
+ const strippedLine = lineStr.trim();
+ if (strippedLine == "%end") {
+ let req = bodyChunks.join("");
+ let reqJson: any = undefined;
+ try {
+ reqJson = JSON.parse(req);
+ } catch (e) {
+ logger.warn("JSON request from client was invalid");
+ }
+ if (reqJson !== undefined) {
+ handlers.onMessage(reqJson);
+ } else {
+ sock.write("%error: invalid JSON");
+ sock.end();
+ }
+ bodyChunks = [];
+ parsingBody = undefined;
+ } else {
+ bodyChunks.push(lineStr);
+ }
+ } else {
+ logger.error("invalid parser state");
+ sock.write("%error: internal error\n");
+ sock.end();
+ }
+ }
+
+ readStreamLinewise({
+ sock,
+ onLine: processLine,
+ });
+
+ sock.on("close", (hadError: boolean) => {
+ logger.trace(`connection closed, hadError=${hadError}`);
+ handlers.onDisconnect();
+ });
+ });
+ server.listen(args.socketFilename);
+ });
+}
diff --git a/packages/taler-util/src/twrpc-impl.qtart.ts b/packages/taler-util/src/twrpc-impl.qtart.ts
new file mode 100644
index 000000000..7d7fa84ae
--- /dev/null
+++ b/packages/taler-util/src/twrpc-impl.qtart.ts
@@ -0,0 +1,26 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import type { RpcConnectArgs, RpcServerArgs } from "./twrpc.js";
+
+// Not implemented.
+export async function connectRpc<T>(args: RpcConnectArgs<T>): Promise<T> {
+ throw Error("not implemented");
+}
+
+export async function runRpcServer(args: RpcServerArgs): Promise<void> {
+ throw Error("not implemented");
+}
diff --git a/packages/taler-util/src/twrpc.ts b/packages/taler-util/src/twrpc.ts
new file mode 100644
index 000000000..d221630d0
--- /dev/null
+++ b/packages/taler-util/src/twrpc.ts
@@ -0,0 +1,63 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { CoreApiResponse } from "./wallet-types.js";
+
+/**
+ * Implementation for the wallet-core IPC protocol.
+ *
+ * Currently the protocol is completely unstable and only used internally
+ * by the wallet for testing purposes.
+ */
+
+// Platform-specific implementation
+export { connectRpc, runRpcServer } from "#twrpc-impl";
+
+export type JsonMessage =
+ | string
+ | number
+ | boolean
+ | null
+ | JsonMessage[]
+ | { [key: string]: JsonMessage };
+
+export interface RpcServerClientHandlers {
+ onMessage(msg: JsonMessage): void;
+ onDisconnect(): void;
+}
+
+export interface RpcServerClient {
+ sendResponse(message: JsonMessage): void;
+}
+
+export interface RpcServerArgs {
+ socketFilename: string;
+ onConnect(client: RpcServerClient): RpcServerClientHandlers;
+}
+
+export interface RpcClientServerConnection {
+ sendMessage(m: JsonMessage): void;
+ close(): void;
+}
+
+export interface RpcConnectArgs<T> {
+ socketFilename: string;
+ onEstablished(connection: RpcClientServerConnection): {
+ result: T;
+ onDisconnect(): void;
+ onMessage(m: JsonMessage): void;
+ };
+}
diff --git a/packages/taler-util/src/types-test.ts b/packages/taler-util/src/types-test.ts
index 6998bb5fb..6acd2c26e 100644
--- a/packages/taler-util/src/types-test.ts
+++ b/packages/taler-util/src/types-test.ts
@@ -15,7 +15,7 @@
*/
import test from "ava";
-import { codecForContractTerms } from "./talerTypes.js";
+import { codecForMerchantContractTerms as codecForContractTerms } from "./taler-types.js";
test("contract terms validation", (t) => {
const c = {
@@ -29,13 +29,13 @@ test("contract terms validation", (t) => {
merchant_pub: "12345",
merchant: { name: "Foo" },
order_id: "test_order",
- pay_deadline: { t_ms: 42 },
- wire_transfer_deadline: { t_ms: 42 },
+ pay_deadline: { t_s: 42 },
+ wire_transfer_deadline: { t_s: 42 },
merchant_base_url: "https://example.com/pay",
products: [],
- refund_deadline: { t_ms: 42 },
+ refund_deadline: { t_s: 42 },
summary: "hello",
- timestamp: { t_ms: 42 },
+ timestamp: { t_s: 42 },
wire_method: "test",
};
@@ -71,13 +71,13 @@ test("contract terms validation (locations)", (t) => {
},
},
order_id: "test_order",
- pay_deadline: { t_ms: 42 },
- wire_transfer_deadline: { t_ms: 42 },
+ pay_deadline: { t_s: 42 },
+ wire_transfer_deadline: { t_s: 42 },
merchant_base_url: "https://example.com/pay",
products: [],
- refund_deadline: { t_ms: 42 },
+ refund_deadline: { t_s: 42 },
summary: "hello",
- timestamp: { t_ms: 42 },
+ timestamp: { t_s: 42 },
wire_method: "test",
delivery_location: {
country: "FR",
diff --git a/packages/taler-util/src/url.ts b/packages/taler-util/src/url.ts
index a52d80362..149997f3f 100644
--- a/packages/taler-util/src/url.ts
+++ b/packages/taler-util/src/url.ts
@@ -14,6 +14,8 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import { URLImpl, URLSearchParamsImpl } from "./whatwg-url.js";
+
interface URL {
hash: string;
host: string;
@@ -44,11 +46,20 @@ interface URLSearchParams {
callbackfn: (value: string, key: string, parent: URLSearchParams) => void,
thisArg?: any,
): void;
+ entries(): IterableIterator<[string, string]>;
+ keys(): IterableIterator<string>;
+ values(): IterableIterator<string>;
+ [Symbol.iterator](): IterableIterator<[string, string]>;
}
export interface URLSearchParamsCtor {
new (
- init?: string[][] | Record<string, string> | string | URLSearchParams,
+ init?:
+ | URLSearchParams
+ | string
+ | Record<string, string | ReadonlyArray<string>>
+ | Iterable<[string, string]>
+ | ReadonlyArray<[string, string]>,
): URLSearchParams;
}
@@ -71,19 +82,28 @@ export interface URLCtor {
delete Object.prototype.__magic__;
})();
+// Use native or pure JS URL implementation?
+const useOwnUrlImp = true;
+
// @ts-ignore
-const _URL = globalThis.URL;
-if (!_URL) {
- throw Error("FATAL: URL not available");
+let _URL = globalThis.URL;
+if (useOwnUrlImp || !_URL) {
+ // @ts-ignore
+ globalThis.URL = _URL = URLImpl;
+ // @ts-ignore
+ _URL = URLImpl;
}
export const URL: URLCtor = _URL;
// @ts-ignore
-const _URLSearchParams = globalThis.URLSearchParams;
+let _URLSearchParams = globalThis.URLSearchParams;
-if (!_URLSearchParams) {
- throw Error("FATAL: URLSearchParams not available");
+if (useOwnUrlImp || !_URLSearchParams) {
+ // @ts-ignore
+ globalThis.URLSearchParams = URLSearchParamsImpl;
+ // @ts-ignore
+ _URLSearchParams = URLSearchParamsImpl;
}
export const URLSearchParams: URLSearchParamsCtor = _URLSearchParams;
diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts
new file mode 100644
index 000000000..0653bc473
--- /dev/null
+++ b/packages/taler-util/src/wallet-types.ts
@@ -0,0 +1,3330 @@
+/*
+ This file is part of GNU Taler
+ (C) 2015-2020 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Types used by clients of the wallet.
+ *
+ * These types are defined in a separate file make tree shaking easier, since
+ * some components use these types (via RPC) but do not depend on the wallet
+ * code directly.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import { AmountJson, codecForAmountString } from "./amounts.js";
+import { BackupRecovery } from "./backup-types.js";
+import {
+ Codec,
+ Context,
+ DecodingError,
+ buildCodecForObject,
+ buildCodecForUnion,
+ codecForAny,
+ codecForBoolean,
+ codecForConstString,
+ codecForEither,
+ codecForList,
+ codecForMap,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ renderContext,
+} from "./codec.js";
+import {
+ CurrencySpecification,
+ TemplateParams,
+ WithdrawalOperationStatus,
+} from "./index.js";
+import { VersionMatchResult } from "./libtool-version.js";
+import { PaytoUri } from "./payto.js";
+import { AgeCommitmentProof } from "./taler-crypto.js";
+import { TalerErrorCode } from "./taler-error-codes.js";
+import {
+ AccountRestriction,
+ AmountString,
+ AuditorDenomSig,
+ CoinEnvelope,
+ DenomKeyType,
+ DenominationPubKey,
+ ExchangeAuditor,
+ ExchangeWireAccount,
+ InternationalizedString,
+ MerchantContractTerms,
+ MerchantInfo,
+ PeerContractTerms,
+ UnblindedSignature,
+ codecForExchangeWireAccount,
+ codecForMerchantContractTerms,
+ codecForPeerContractTerms,
+} from "./taler-types.js";
+import {
+ AbsoluteTime,
+ TalerPreciseTimestamp,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ codecForAbsoluteTime,
+ codecForPreciseTimestamp,
+ codecForTimestamp,
+} from "./time.js";
+import {
+ OrderShortInfo,
+ TransactionState,
+ TransactionType,
+} from "./transactions-types.js";
+
+/**
+ * Identifier for a transaction in the wallet.
+ */
+declare const __txId: unique symbol;
+export type TransactionIdStr = `txn:${string}:${string}` & { [__txId]: true };
+
+/**
+ * Identifier for a pending task in the wallet.
+ */
+declare const __pndId: unique symbol;
+export type PendingIdStr = `pnd:${string}:${string}` & { [__pndId]: true };
+
+declare const __tmbId: unique symbol;
+export type TombstoneIdStr = `tmb:${string}:${string}` & { [__tmbId]: true };
+
+function codecForTransactionIdStr(): Codec<TransactionIdStr> {
+ return {
+ decode(x: any, c?: Context): TransactionIdStr {
+ if (typeof x === "string" && x.startsWith("txn:")) {
+ return x as TransactionIdStr;
+ }
+ throw new DecodingError(
+ `expected string starting with "txn:" at ${renderContext(
+ c,
+ )} but got ${x}`,
+ );
+ },
+ };
+}
+
+function codecForPendingIdStr(): Codec<PendingIdStr> {
+ return {
+ decode(x: any, c?: Context): PendingIdStr {
+ if (typeof x === "string" && x.startsWith("txn:")) {
+ return x as PendingIdStr;
+ }
+ throw new DecodingError(
+ `expected string starting with "txn:" at ${renderContext(
+ c,
+ )} but got ${x}`,
+ );
+ },
+ };
+}
+
+function codecForTombstoneIdStr(): Codec<TombstoneIdStr> {
+ return {
+ decode(x: any, c?: Context): TombstoneIdStr {
+ if (typeof x === "string" && x.startsWith("tmb:")) {
+ return x as TombstoneIdStr;
+ }
+ throw new DecodingError(
+ `expected string starting with "tmb:" at ${renderContext(
+ c,
+ )} but got ${x}`,
+ );
+ },
+ };
+}
+
+/**
+ * Response for the create reserve request to the wallet.
+ */
+export class CreateReserveResponse {
+ /**
+ * Exchange URL where the bank should create the reserve.
+ * The URL is canonicalized in the response.
+ */
+ exchange: string;
+
+ /**
+ * Reserve public key of the newly created reserve.
+ */
+ reservePub: string;
+}
+
+export interface GetBalanceDetailRequest {
+ currency: string;
+}
+
+export const codecForGetBalanceDetailRequest =
+ (): Codec<GetBalanceDetailRequest> =>
+ buildCodecForObject<GetBalanceDetailRequest>()
+ .property("currency", codecForString())
+ .build("GetBalanceDetailRequest");
+
+/**
+ * How the amount should be interpreted in a transaction
+ * Effective = how the balance is change
+ * Raw = effective amount without fee
+ *
+ * Depending on the transaction, raw can be higher than effective
+ */
+export enum TransactionAmountMode {
+ Effective = "effective",
+ Raw = "raw",
+}
+
+export type GetPlanForOperationRequest =
+ | GetPlanForWithdrawRequest
+ | GetPlanForDepositRequest;
+// | GetPlanForPushDebitRequest
+// | GetPlanForPullCreditRequest
+// | GetPlanForPaymentRequest
+// | GetPlanForTipRequest
+// | GetPlanForRefundRequest
+// | GetPlanForPullDebitRequest
+// | GetPlanForPushCreditRequest;
+
+interface GetPlanForWalletInitiatedOperation {
+ instructedAmount: AmountString;
+ mode: TransactionAmountMode;
+}
+
+export interface ConvertAmountRequest {
+ amount: AmountString;
+ type: TransactionAmountMode;
+}
+
+export const codecForConvertAmountRequest =
+ buildCodecForObject<ConvertAmountRequest>()
+ .property("amount", codecForAmountString())
+ .property(
+ "type",
+ codecForEither(
+ codecForConstString(TransactionAmountMode.Raw),
+ codecForConstString(TransactionAmountMode.Effective),
+ ),
+ )
+ .build("ConvertAmountRequest");
+
+export interface GetAmountRequest {
+ currency: string;
+}
+
+export const codecForGetAmountRequest = buildCodecForObject<GetAmountRequest>()
+ .property("currency", codecForString())
+ .build("GetAmountRequest");
+
+interface GetPlanToCompleteOperation {
+ instructedAmount: AmountString;
+}
+
+const codecForGetPlanForWalletInitiatedOperation = <
+ T extends GetPlanForWalletInitiatedOperation,
+>() =>
+ buildCodecForObject<T>()
+ .property(
+ "mode",
+ codecForEither(
+ codecForConstString(TransactionAmountMode.Raw),
+ codecForConstString(TransactionAmountMode.Effective),
+ ),
+ )
+ .property("instructedAmount", codecForAmountString());
+
+interface GetPlanForWithdrawRequest extends GetPlanForWalletInitiatedOperation {
+ type: TransactionType.Withdrawal;
+ exchangeUrl?: string;
+}
+interface GetPlanForDepositRequest extends GetPlanForWalletInitiatedOperation {
+ type: TransactionType.Deposit;
+ account: string; //payto string
+}
+interface GetPlanForPushDebitRequest
+ extends GetPlanForWalletInitiatedOperation {
+ type: TransactionType.PeerPushDebit;
+}
+
+interface GetPlanForPullCreditRequest
+ extends GetPlanForWalletInitiatedOperation {
+ type: TransactionType.PeerPullCredit;
+ exchangeUrl: string;
+}
+
+const codecForGetPlanForWithdrawRequest =
+ codecForGetPlanForWalletInitiatedOperation<GetPlanForWithdrawRequest>()
+ .property("type", codecForConstString(TransactionType.Withdrawal))
+ .property("exchangeUrl", codecOptional(codecForString()))
+ .build("GetPlanForWithdrawRequest");
+
+const codecForGetPlanForDepositRequest =
+ codecForGetPlanForWalletInitiatedOperation<GetPlanForDepositRequest>()
+ .property("type", codecForConstString(TransactionType.Deposit))
+ .property("account", codecForString())
+ .build("GetPlanForDepositRequest");
+
+const codecForGetPlanForPushDebitRequest =
+ codecForGetPlanForWalletInitiatedOperation<GetPlanForPushDebitRequest>()
+ .property("type", codecForConstString(TransactionType.PeerPushDebit))
+ .build("GetPlanForPushDebitRequest");
+
+const codecForGetPlanForPullCreditRequest =
+ codecForGetPlanForWalletInitiatedOperation<GetPlanForPullCreditRequest>()
+ .property("type", codecForConstString(TransactionType.PeerPullCredit))
+ .property("exchangeUrl", codecForString())
+ .build("GetPlanForPullCreditRequest");
+
+interface GetPlanForPaymentRequest extends GetPlanToCompleteOperation {
+ type: TransactionType.Payment;
+ wireMethod: string;
+ ageRestriction: number;
+ maxDepositFee: AmountString;
+}
+
+// interface GetPlanForTipRequest extends GetPlanForOperationBase {
+// type: TransactionType.Tip;
+// }
+// interface GetPlanForRefundRequest extends GetPlanForOperationBase {
+// type: TransactionType.Refund;
+// }
+interface GetPlanForPullDebitRequest extends GetPlanToCompleteOperation {
+ type: TransactionType.PeerPullDebit;
+}
+interface GetPlanForPushCreditRequest extends GetPlanToCompleteOperation {
+ type: TransactionType.PeerPushCredit;
+}
+
+const codecForGetPlanForPaymentRequest =
+ buildCodecForObject<GetPlanForPaymentRequest>()
+ .property("type", codecForConstString(TransactionType.Payment))
+ .property("maxDepositFee", codecForAmountString())
+ .build("GetPlanForPaymentRequest");
+
+const codecForGetPlanForPullDebitRequest =
+ buildCodecForObject<GetPlanForPullDebitRequest>()
+ .property("type", codecForConstString(TransactionType.PeerPullDebit))
+ .build("GetPlanForPullDebitRequest");
+
+const codecForGetPlanForPushCreditRequest =
+ buildCodecForObject<GetPlanForPushCreditRequest>()
+ .property("type", codecForConstString(TransactionType.PeerPushCredit))
+ .build("GetPlanForPushCreditRequest");
+
+export const codecForGetPlanForOperationRequest =
+ (): Codec<GetPlanForOperationRequest> =>
+ buildCodecForUnion<GetPlanForOperationRequest>()
+ .discriminateOn("type")
+ .alternative(
+ TransactionType.Withdrawal,
+ codecForGetPlanForWithdrawRequest,
+ )
+ .alternative(TransactionType.Deposit, codecForGetPlanForDepositRequest)
+ // .alternative(
+ // TransactionType.PeerPushDebit,
+ // codecForGetPlanForPushDebitRequest,
+ // )
+ // .alternative(
+ // TransactionType.PeerPullCredit,
+ // codecForGetPlanForPullCreditRequest,
+ // )
+ // .alternative(TransactionType.Payment, codecForGetPlanForPaymentRequest)
+ // .alternative(
+ // TransactionType.PeerPullDebit,
+ // codecForGetPlanForPullDebitRequest,
+ // )
+ // .alternative(
+ // TransactionType.PeerPushCredit,
+ // codecForGetPlanForPushCreditRequest,
+ // )
+ .build("GetPlanForOperationRequest");
+
+export interface GetPlanForOperationResponse {
+ effectiveAmount: AmountString;
+ rawAmount: AmountString;
+ counterPartyAmount?: AmountString;
+ details: any;
+}
+
+export const codecForGetPlanForOperationResponse =
+ (): Codec<GetPlanForOperationResponse> =>
+ buildCodecForObject<GetPlanForOperationResponse>()
+ .property("effectiveAmount", codecForAmountString())
+ .property("rawAmount", codecForAmountString())
+ .property("details", codecForAny())
+ .property("counterPartyAmount", codecOptional(codecForAmountString()))
+ .build("GetPlanForOperationResponse");
+
+export interface AmountResponse {
+ effectiveAmount: AmountString;
+ rawAmount: AmountString;
+}
+
+export const codecForAmountResponse = (): Codec<AmountResponse> =>
+ buildCodecForObject<AmountResponse>()
+ .property("effectiveAmount", codecForAmountString())
+ .property("rawAmount", codecForAmountString())
+ .build("AmountResponse");
+
+export enum BalanceFlag {
+ IncomingKyc = "incoming-kyc",
+ IncomingAml = "incoming-aml",
+ IncomingConfirmation = "incoming-confirmation",
+ OutgoingKyc = "outgoing-kyc",
+}
+
+export interface WalletBalance {
+ scopeInfo: ScopeInfo;
+ available: AmountString;
+ pendingIncoming: AmountString;
+ pendingOutgoing: AmountString;
+
+ /**
+ * Does the balance for this currency have a pending
+ * transaction?
+ *
+ * @deprecated use flags and pendingIncoming/pendingOutgoing instead
+ */
+ hasPendingTransactions: boolean;
+
+ /**
+ * Is there a transaction that requires user input?
+ *
+ * @deprecated use flags instead
+ */
+ requiresUserInput: boolean;
+
+ flags: BalanceFlag[];
+}
+
+export const codecForScopeInfoGlobal = (): Codec<ScopeInfoGlobal> =>
+ buildCodecForObject<ScopeInfoGlobal>()
+ .property("currency", codecForString())
+ .property("type", codecForConstString(ScopeType.Global))
+ .build("ScopeInfoGlobal");
+
+export const codecForScopeInfoExchange = (): Codec<ScopeInfoExchange> =>
+ buildCodecForObject<ScopeInfoExchange>()
+ .property("currency", codecForString())
+ .property("type", codecForConstString(ScopeType.Exchange))
+ .property("url", codecForString())
+ .build("ScopeInfoExchange");
+
+export const codecForScopeInfoAuditor = (): Codec<ScopeInfoAuditor> =>
+ buildCodecForObject<ScopeInfoAuditor>()
+ .property("currency", codecForString())
+ .property("type", codecForConstString(ScopeType.Auditor))
+ .property("url", codecForString())
+ .build("ScopeInfoAuditor");
+
+export const codecForScopeInfo = (): Codec<ScopeInfo> =>
+ buildCodecForUnion<ScopeInfo>()
+ .discriminateOn("type")
+ .alternative(ScopeType.Global, codecForScopeInfoGlobal())
+ .alternative(ScopeType.Exchange, codecForScopeInfoExchange())
+ .alternative(ScopeType.Auditor, codecForScopeInfoAuditor())
+ .build("ScopeInfo");
+
+export interface GetCurrencySpecificationRequest {
+ scope: ScopeInfo;
+}
+
+export const codecForGetCurrencyInfoRequest =
+ (): Codec<GetCurrencySpecificationRequest> =>
+ buildCodecForObject<GetCurrencySpecificationRequest>()
+ .property("scope", codecForScopeInfo())
+ .build("GetCurrencySpecificationRequest");
+
+export interface ListExchangesForScopedCurrencyRequest {
+ scope: ScopeInfo;
+}
+
+export const codecForListExchangesForScopedCurrencyRequest =
+ (): Codec<ListExchangesForScopedCurrencyRequest> =>
+ buildCodecForObject<ListExchangesForScopedCurrencyRequest>()
+ .property("scope", codecForScopeInfo())
+ .build("ListExchangesForScopedCurrencyRequest");
+
+export interface GetCurrencySpecificationResponse {
+ currencySpecification: CurrencySpecification;
+}
+
+export interface BuiltinExchange {
+ exchangeBaseUrl: string;
+ currencyHint: string;
+}
+
+export interface PartialWalletRunConfig {
+ builtin?: Partial<WalletRunConfig["builtin"]>;
+ testing?: Partial<WalletRunConfig["testing"]>;
+ features?: Partial<WalletRunConfig["features"]>;
+}
+
+export interface WalletRunConfig {
+ /**
+ * Initialization values useful for a complete startup.
+ *
+ * These are values may be overridden by different wallets
+ */
+ builtin: {
+ exchanges: BuiltinExchange[];
+ };
+
+ /**
+ * Unsafe options which it should only be used to create
+ * testing environment.
+ */
+ testing: {
+ /**
+ * Allow withdrawal of denominations even though they are about to expire.
+ */
+ denomselAllowLate: boolean;
+ devModeActive: boolean;
+ insecureTrustExchange: boolean;
+ preventThrottling: boolean;
+ skipDefaults: boolean;
+ emitObservabilityEvents?: boolean;
+ };
+
+ /**
+ * Configurations values that may be safe to show to the user
+ */
+ features: {
+ allowHttp: boolean;
+ };
+}
+
+export interface InitRequest {
+ config?: PartialWalletRunConfig;
+}
+
+export const codecForInitRequest = (): Codec<InitRequest> =>
+ buildCodecForObject<InitRequest>()
+ .property("config", codecForAny())
+ .build("InitRequest");
+
+export interface InitResponse {
+ versionInfo: WalletCoreVersion;
+}
+
+export enum ScopeType {
+ Global = "global",
+ Exchange = "exchange",
+ Auditor = "auditor",
+}
+
+export type ScopeInfoGlobal = { type: ScopeType.Global; currency: string };
+export type ScopeInfoExchange = {
+ type: ScopeType.Exchange;
+ currency: string;
+ url: string;
+};
+export type ScopeInfoAuditor = {
+ type: ScopeType.Auditor;
+ currency: string;
+ url: string;
+};
+
+export type ScopeInfo = ScopeInfoGlobal | ScopeInfoExchange | ScopeInfoAuditor;
+
+export interface BalancesResponse {
+ balances: WalletBalance[];
+}
+
+export const codecForBalance = (): Codec<WalletBalance> =>
+ buildCodecForObject<WalletBalance>()
+ .property("scopeInfo", codecForAny()) // FIXME
+ .property("available", codecForAmountString())
+ .property("hasPendingTransactions", codecForBoolean())
+ .property("pendingIncoming", codecForAmountString())
+ .property("pendingOutgoing", codecForAmountString())
+ .property("requiresUserInput", codecForBoolean())
+ .property("flags", codecForAny()) // FIXME
+ .build("Balance");
+
+export const codecForBalancesResponse = (): Codec<BalancesResponse> =>
+ buildCodecForObject<BalancesResponse>()
+ .property("balances", codecForList(codecForBalance()))
+ .build("BalancesResponse");
+
+/**
+ * For terseness.
+ */
+export function mkAmount(
+ value: number,
+ fraction: number,
+ currency: string,
+): AmountJson {
+ return { value, fraction, currency };
+}
+
+/**
+ * Status of a coin.
+ */
+export enum CoinStatus {
+ /**
+ * Withdrawn and never shown to anybody.
+ */
+ Fresh = "fresh",
+
+ /**
+ * Coin was lost as the denomination is not usable anymore.
+ */
+ DenomLoss = "denom-loss",
+
+ /**
+ * Fresh, but currently marked as "suspended", thus won't be used
+ * for spending. Used for testing.
+ */
+ FreshSuspended = "fresh-suspended",
+
+ /**
+ * A coin that has been spent and refreshed.
+ */
+ Dormant = "dormant",
+}
+
+/**
+ * Easy to process format for the public data of coins
+ * managed by the wallet.
+ */
+export interface CoinDumpJson {
+ coins: Array<{
+ /**
+ * The coin's denomination's public key.
+ */
+ denom_pub: DenominationPubKey;
+ /**
+ * Hash of denom_pub.
+ */
+ denom_pub_hash: string;
+ /**
+ * Value of the denomination (without any fees).
+ */
+ denom_value: string;
+ /**
+ * Public key of the coin.
+ */
+ coin_pub: string;
+ /**
+ * Base URL of the exchange for the coin.
+ */
+ exchange_base_url: string;
+ /**
+ * Public key of the parent coin.
+ * Only present if this coin was obtained via refreshing.
+ */
+ refresh_parent_coin_pub: string | undefined;
+ /**
+ * Public key of the reserve for this coin.
+ * Only present if this coin was obtained via refreshing.
+ */
+ withdrawal_reserve_pub: string | undefined;
+ coin_status: CoinStatus;
+ spend_allocation:
+ | {
+ id: string;
+ amount: AmountString;
+ }
+ | undefined;
+ /**
+ * Information about the age restriction
+ */
+ ageCommitmentProof: AgeCommitmentProof | undefined;
+ }>;
+}
+
+export enum ConfirmPayResultType {
+ Done = "done",
+ Pending = "pending",
+}
+
+/**
+ * Result for confirmPay
+ */
+export interface ConfirmPayResultDone {
+ type: ConfirmPayResultType.Done;
+ contractTerms: MerchantContractTerms;
+ transactionId: TransactionIdStr;
+}
+
+export interface ConfirmPayResultPending {
+ type: ConfirmPayResultType.Pending;
+ transactionId: TransactionIdStr;
+ lastError: TalerErrorDetail | undefined;
+}
+
+export const codecForTalerErrorDetail = (): Codec<TalerErrorDetail> =>
+ buildCodecForObject<TalerErrorDetail>()
+ .property("code", codecForNumber())
+ .property("when", codecOptional(codecForAbsoluteTime))
+ .property("hint", codecOptional(codecForString()))
+ .build("TalerErrorDetail");
+
+export type ConfirmPayResult = ConfirmPayResultDone | ConfirmPayResultPending;
+
+export const codecForConfirmPayResultPending =
+ (): Codec<ConfirmPayResultPending> =>
+ buildCodecForObject<ConfirmPayResultPending>()
+ .property("lastError", codecOptional(codecForTalerErrorDetail()))
+ .property("transactionId", codecForTransactionIdStr())
+ .property("type", codecForConstString(ConfirmPayResultType.Pending))
+ .build("ConfirmPayResultPending");
+
+export const codecForConfirmPayResultDone = (): Codec<ConfirmPayResultDone> =>
+ buildCodecForObject<ConfirmPayResultDone>()
+ .property("type", codecForConstString(ConfirmPayResultType.Done))
+ .property("transactionId", codecForTransactionIdStr())
+ .property("contractTerms", codecForMerchantContractTerms())
+ .build("ConfirmPayResultDone");
+
+export const codecForConfirmPayResult = (): Codec<ConfirmPayResult> =>
+ buildCodecForUnion<ConfirmPayResult>()
+ .discriminateOn("type")
+ .alternative(
+ ConfirmPayResultType.Pending,
+ codecForConfirmPayResultPending(),
+ )
+ .alternative(ConfirmPayResultType.Done, codecForConfirmPayResultDone())
+ .build("ConfirmPayResult");
+
+/**
+ * Information about all sender wire details known to the wallet,
+ * as well as exchanges that accept these wire types.
+ */
+export interface SenderWireInfos {
+ /**
+ * Mapping from exchange base url to list of accepted
+ * wire types.
+ */
+ exchangeWireTypes: { [exchangeBaseUrl: string]: string[] };
+
+ /**
+ * Sender wire information stored in the wallet.
+ */
+ senderWires: string[];
+}
+
+/**
+ * Request to mark a reserve as confirmed.
+ */
+export interface ConfirmReserveRequest {
+ /**
+ * Public key of then reserve that should be marked
+ * as confirmed.
+ */
+ reservePub: string;
+}
+
+export const codecForConfirmReserveRequest = (): Codec<ConfirmReserveRequest> =>
+ buildCodecForObject<ConfirmReserveRequest>()
+ .property("reservePub", codecForString())
+ .build("ConfirmReserveRequest");
+
+export interface PrepareRefundResult {
+ proposalId: string;
+
+ effectivePaid: AmountString;
+ gone: AmountString;
+ granted: AmountString;
+ pending: boolean;
+ awaiting: AmountString;
+
+ info: OrderShortInfo;
+}
+
+export interface PrepareTipResult {
+ /**
+ * Unique ID for the tip assigned by the wallet.
+ * Typically different from the merchant-generated tip ID.
+ *
+ * @deprecated use transactionId instead
+ */
+ walletRewardId: string;
+
+ /**
+ * Tip transaction ID.
+ */
+ transactionId: TransactionIdStr;
+
+ /**
+ * Has the tip already been accepted?
+ */
+ accepted: boolean;
+
+ /**
+ * Amount that the merchant gave.
+ */
+ rewardAmountRaw: AmountString;
+
+ /**
+ * Amount that arrived at the wallet.
+ * Might be lower than the raw amount due to fees.
+ */
+ rewardAmountEffective: AmountString;
+
+ /**
+ * Base URL of the merchant backend giving then tip.
+ */
+ merchantBaseUrl: string;
+
+ /**
+ * Base URL of the exchange that is used to withdraw the tip.
+ * Determined by the merchant, the wallet/user has no choice here.
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Time when the tip will expire. After it expired, it can't be picked
+ * up anymore.
+ */
+ expirationTimestamp: TalerProtocolTimestamp;
+}
+
+export interface AcceptTipResponse {
+ transactionId: TransactionIdStr;
+ next_url?: string;
+}
+
+export const codecForPrepareTipResult = (): Codec<PrepareTipResult> =>
+ buildCodecForObject<PrepareTipResult>()
+ .property("accepted", codecForBoolean())
+ .property("rewardAmountRaw", codecForAmountString())
+ .property("rewardAmountEffective", codecForAmountString())
+ .property("exchangeBaseUrl", codecForString())
+ .property("merchantBaseUrl", codecForString())
+ .property("expirationTimestamp", codecForTimestamp)
+ .property("walletRewardId", codecForString())
+ .property("transactionId", codecForTransactionIdStr())
+ .build("PrepareRewardResult");
+
+export interface BenchmarkResult {
+ time: { [s: string]: number };
+ repetitions: number;
+}
+
+export enum PreparePayResultType {
+ PaymentPossible = "payment-possible",
+ InsufficientBalance = "insufficient-balance",
+ AlreadyConfirmed = "already-confirmed",
+}
+
+export const codecForPreparePayResultPaymentPossible =
+ (): Codec<PreparePayResultPaymentPossible> =>
+ buildCodecForObject<PreparePayResultPaymentPossible>()
+ .property("amountEffective", codecForAmountString())
+ .property("amountRaw", codecForAmountString())
+ .property("contractTerms", codecForMerchantContractTerms())
+ .property("transactionId", codecForTransactionIdStr())
+ .property("proposalId", codecForString())
+ .property("contractTermsHash", codecForString())
+ .property("talerUri", codecForString())
+ .property(
+ "status",
+ codecForConstString(PreparePayResultType.PaymentPossible),
+ )
+ .build("PreparePayResultPaymentPossible");
+
+export interface BalanceDetails {}
+
+/**
+ * Detailed reason for why the wallet's balance is insufficient.
+ */
+export interface PaymentInsufficientBalanceDetails {
+ /**
+ * Amount requested by the merchant.
+ */
+ amountRequested: AmountString;
+
+ /**
+ * Balance of type "available" (see balance.ts for definition).
+ */
+ balanceAvailable: AmountString;
+
+ /**
+ * Balance of type "material" (see balance.ts for definition).
+ */
+ balanceMaterial: AmountString;
+
+ /**
+ * Balance of type "age-acceptable" (see balance.ts for definition).
+ */
+ balanceAgeAcceptable: AmountString;
+
+ /**
+ * Balance of type "merchant-acceptable" (see balance.ts for definition).
+ */
+ balanceReceiverAcceptable: AmountString;
+
+ /**
+ * Balance of type "merchant-depositable" (see balance.ts for definition).
+ */
+ balanceReceiverDepositable: AmountString;
+
+ balanceExchangeDepositable: AmountString;
+
+ /**
+ * Maximum effective amount that the wallet can spend,
+ * when all fees are paid by the wallet.
+ */
+ maxEffectiveSpendAmount: AmountString;
+
+ perExchange: {
+ [url: string]: {
+ balanceAvailable: AmountString;
+ balanceMaterial: AmountString;
+ balanceExchangeDepositable: AmountString;
+ balanceAgeAcceptable: AmountString;
+ balanceReceiverAcceptable: AmountString;
+ balanceReceiverDepositable: AmountString;
+ maxEffectiveSpendAmount: AmountString;
+ /**
+ * Exchange doesn't have global fees configured for the relevant year,
+ * p2p payments aren't possible.
+ */
+ missingGlobalFees: boolean;
+ };
+ };
+}
+
+export const codecForPayMerchantInsufficientBalanceDetails =
+ (): Codec<PaymentInsufficientBalanceDetails> =>
+ buildCodecForObject<PaymentInsufficientBalanceDetails>()
+ .property("amountRequested", codecForAmountString())
+ .property("balanceAgeAcceptable", codecForAmountString())
+ .property("balanceAvailable", codecForAmountString())
+ .property("balanceMaterial", codecForAmountString())
+ .property("balanceReceiverAcceptable", codecForAmountString())
+ .property("balanceReceiverDepositable", codecForAmountString())
+ .property("balanceExchangeDepositable", codecForAmountString())
+ .property("perExchange", codecForAny())
+ .property("maxEffectiveSpendAmount", codecForAmountString())
+ .build("PayMerchantInsufficientBalanceDetails");
+
+export const codecForPreparePayResultInsufficientBalance =
+ (): Codec<PreparePayResultInsufficientBalance> =>
+ buildCodecForObject<PreparePayResultInsufficientBalance>()
+ .property("amountRaw", codecForAmountString())
+ .property("contractTerms", codecForAny())
+ .property("talerUri", codecForString())
+ .property("proposalId", codecForString())
+ .property("transactionId", codecForTransactionIdStr())
+ .property(
+ "status",
+ codecForConstString(PreparePayResultType.InsufficientBalance),
+ )
+ .property(
+ "balanceDetails",
+ codecForPayMerchantInsufficientBalanceDetails(),
+ )
+ .build("PreparePayResultInsufficientBalance");
+
+export const codecForPreparePayResultAlreadyConfirmed =
+ (): Codec<PreparePayResultAlreadyConfirmed> =>
+ buildCodecForObject<PreparePayResultAlreadyConfirmed>()
+ .property(
+ "status",
+ codecForConstString(PreparePayResultType.AlreadyConfirmed),
+ )
+ .property("amountEffective", codecOptional(codecForAmountString()))
+ .property("amountRaw", codecForAmountString())
+ .property("paid", codecForBoolean())
+ .property("talerUri", codecForString())
+ .property("contractTerms", codecForAny())
+ .property("contractTermsHash", codecForString())
+ .property("transactionId", codecForTransactionIdStr())
+ .property("proposalId", codecForString())
+ .build("PreparePayResultAlreadyConfirmed");
+
+export const codecForPreparePayResult = (): Codec<PreparePayResult> =>
+ buildCodecForUnion<PreparePayResult>()
+ .discriminateOn("status")
+ .alternative(
+ PreparePayResultType.AlreadyConfirmed,
+ codecForPreparePayResultAlreadyConfirmed(),
+ )
+ .alternative(
+ PreparePayResultType.InsufficientBalance,
+ codecForPreparePayResultInsufficientBalance(),
+ )
+ .alternative(
+ PreparePayResultType.PaymentPossible,
+ codecForPreparePayResultPaymentPossible(),
+ )
+ .build("PreparePayResult");
+
+/**
+ * Result of a prepare pay operation.
+ */
+export type PreparePayResult =
+ | PreparePayResultInsufficientBalance
+ | PreparePayResultAlreadyConfirmed
+ | PreparePayResultPaymentPossible;
+
+/**
+ * Payment is possible.
+ */
+export interface PreparePayResultPaymentPossible {
+ status: PreparePayResultType.PaymentPossible;
+ transactionId: TransactionIdStr;
+ /**
+ * @deprecated use transactionId instead
+ */
+ proposalId: string;
+ contractTerms: MerchantContractTerms;
+ contractTermsHash: string;
+ amountRaw: AmountString;
+ amountEffective: AmountString;
+ talerUri: string;
+}
+
+export interface PreparePayResultInsufficientBalance {
+ status: PreparePayResultType.InsufficientBalance;
+ transactionId: TransactionIdStr;
+ /**
+ * @deprecated use transactionId
+ */
+ proposalId: string;
+ contractTerms: MerchantContractTerms;
+ amountRaw: AmountString;
+ talerUri: string;
+ balanceDetails: PaymentInsufficientBalanceDetails;
+}
+
+export interface PreparePayResultAlreadyConfirmed {
+ status: PreparePayResultType.AlreadyConfirmed;
+ transactionId: TransactionIdStr;
+ contractTerms: MerchantContractTerms;
+ paid: boolean;
+ amountRaw: AmountString;
+ amountEffective: AmountString | undefined;
+ contractTermsHash: string;
+ /**
+ * @deprecated use transactionId
+ */
+ proposalId: string;
+ talerUri: string;
+}
+
+export interface BankWithdrawDetails {
+ status: WithdrawalOperationStatus;
+ amount: AmountJson;
+ senderWire?: string;
+ suggestedExchange?: string;
+ confirmTransferUrl?: string;
+ wireTypes: string[];
+ operationId: string;
+ apiBaseUrl: string;
+}
+
+export interface AcceptWithdrawalResponse {
+ reservePub: string;
+ confirmTransferUrl?: string;
+ transactionId: TransactionIdStr;
+}
+
+/**
+ * Details about a purchase, including refund status.
+ */
+export interface PurchaseDetails {
+ contractTerms: Record<string, undefined>;
+ hasRefund: boolean;
+ totalRefundAmount: AmountJson;
+ totalRefundAndRefreshFees: AmountJson;
+}
+
+export interface WalletDiagnostics {
+ walletManifestVersion: string;
+ walletManifestDisplayVersion: string;
+ errors: string[];
+ firefoxIdbProblem: boolean;
+ dbOutdated: boolean;
+}
+
+export interface TalerErrorDetail {
+ code: TalerErrorCode;
+ when?: AbsoluteTime;
+ hint?: string;
+ [x: string]: unknown;
+}
+
+/**
+ * Minimal information needed about a planchet for unblinding a signature.
+ *
+ * Can be a withdrawal/tipping/refresh planchet.
+ */
+export interface PlanchetUnblindInfo {
+ denomPub: DenominationPubKey;
+ blindingKey: string;
+}
+
+export interface WithdrawalPlanchet {
+ coinPub: string;
+ coinPriv: string;
+ reservePub: string;
+ denomPubHash: string;
+ denomPub: DenominationPubKey;
+ blindingKey: string;
+ withdrawSig: string;
+ coinEv: CoinEnvelope;
+ coinValue: AmountJson;
+ coinEvHash: string;
+ ageCommitmentProof?: AgeCommitmentProof;
+}
+
+export interface PlanchetCreationRequest {
+ secretSeed: string;
+ coinIndex: number;
+ value: AmountJson;
+ feeWithdraw: AmountJson;
+ denomPub: DenominationPubKey;
+ reservePub: string;
+ reservePriv: string;
+ restrictAge?: number;
+}
+
+/**
+ * Reasons for why a coin is being refreshed.
+ */
+export enum RefreshReason {
+ Manual = "manual",
+ PayMerchant = "pay-merchant",
+ PayDeposit = "pay-deposit",
+ PayPeerPush = "pay-peer-push",
+ PayPeerPull = "pay-peer-pull",
+ Refund = "refund",
+ AbortPay = "abort-pay",
+ AbortDeposit = "abort-deposit",
+ AbortPeerPushDebit = "abort-peer-push-debit",
+ AbortPeerPullDebit = "abort-peer-pull-debit",
+ Recoup = "recoup",
+ BackupRestored = "backup-restored",
+ Scheduled = "scheduled",
+}
+
+/**
+ * Request to refresh a single coin.
+ */
+export interface CoinRefreshRequest {
+ readonly coinPub: string;
+ readonly amount: AmountString;
+}
+
+/**
+ * Private data required to make a deposit permission.
+ */
+export interface DepositInfo {
+ exchangeBaseUrl: string;
+ contractTermsHash: string;
+ coinPub: string;
+ coinPriv: string;
+ spendAmount: AmountJson;
+ timestamp: TalerProtocolTimestamp;
+ refundDeadline: TalerProtocolTimestamp;
+ merchantPub: string;
+ feeDeposit: AmountJson;
+ wireInfoHash: string;
+ denomKeyType: DenomKeyType;
+ denomPubHash: string;
+ denomSig: UnblindedSignature;
+
+ requiredMinimumAge?: number;
+
+ ageCommitmentProof?: AgeCommitmentProof;
+}
+
+export interface ExchangesShortListResponse {
+ exchanges: ShortExchangeListItem[];
+}
+
+export interface ExchangesListResponse {
+ exchanges: ExchangeListItem[];
+}
+
+export interface ExchangeDetailedResponse {
+ exchange: ExchangeFullDetails;
+}
+
+export interface WalletCoreVersion {
+ implementationSemver: string;
+ implementationGitHash: string;
+
+ /**
+ * Wallet-core protocol version supported by this implementation
+ * of the API ("server" version).
+ */
+ version: string;
+ exchange: string;
+ merchant: string;
+
+ bankIntegrationApiRange: string;
+ bankConversionApiRange: string;
+ corebankApiRange: string;
+
+ /**
+ * @deprecated as bank was split into multiple APIs with separate versioning
+ */
+ bank: string;
+
+ /**
+ * @deprecated
+ */
+ hash: string | undefined;
+
+ /**
+ * @deprecated will be removed
+ */
+ devMode: boolean;
+}
+
+export interface KnownBankAccountsInfo {
+ uri: PaytoUri;
+ kyc_completed: boolean;
+ currency: string;
+ alias: string;
+}
+
+export interface KnownBankAccounts {
+ accounts: KnownBankAccountsInfo[];
+}
+
+/**
+ * Wire fee for one wire method
+ */
+export interface WireFee {
+ /**
+ * Fee for wire transfers.
+ */
+ wireFee: AmountString;
+
+ /**
+ * Fees to close and refund a reserve.
+ */
+ closingFee: AmountString;
+
+ /**
+ * Start date of the fee.
+ */
+ startStamp: TalerProtocolTimestamp;
+
+ /**
+ * End date of the fee.
+ */
+ endStamp: TalerProtocolTimestamp;
+
+ /**
+ * Signature made by the exchange master key.
+ */
+ sig: string;
+}
+
+export type WireFeeMap = { [wireMethod: string]: WireFee[] };
+
+export interface WireInfo {
+ feesForType: WireFeeMap;
+ accounts: ExchangeWireAccount[];
+}
+
+export interface ExchangeGlobalFees {
+ startDate: TalerProtocolTimestamp;
+ endDate: TalerProtocolTimestamp;
+
+ historyFee: AmountString;
+ accountFee: AmountString;
+ purseFee: AmountString;
+
+ historyTimeout: TalerProtocolDuration;
+ purseTimeout: TalerProtocolDuration;
+
+ purseLimit: number;
+
+ signature: string;
+}
+
+const codecForWireFee = (): Codec<WireFee> =>
+ buildCodecForObject<WireFee>()
+ .property("sig", codecForString())
+ .property("wireFee", codecForAmountString())
+ .property("closingFee", codecForAmountString())
+ .property("startStamp", codecForTimestamp)
+ .property("endStamp", codecForTimestamp)
+ .build("codecForWireFee");
+
+const codecForWireInfo = (): Codec<WireInfo> =>
+ buildCodecForObject<WireInfo>()
+ .property("feesForType", codecForMap(codecForList(codecForWireFee())))
+ .property("accounts", codecForList(codecForExchangeWireAccount()))
+ .build("codecForWireInfo");
+
+export interface DenominationInfo {
+ /**
+ * Value of one coin of the denomination.
+ */
+ value: AmountString;
+
+ /**
+ * Hash of the denomination public key.
+ * Stored in the database for faster lookups.
+ */
+ denomPubHash: string;
+
+ denomPub: DenominationPubKey;
+
+ /**
+ * Fee for withdrawing.
+ */
+ feeWithdraw: AmountString;
+
+ /**
+ * Fee for depositing.
+ */
+ feeDeposit: AmountString;
+
+ /**
+ * Fee for refreshing.
+ */
+ feeRefresh: AmountString;
+
+ /**
+ * Fee for refunding.
+ */
+ feeRefund: AmountString;
+
+ /**
+ * Validity start date of the denomination.
+ */
+ stampStart: TalerProtocolTimestamp;
+
+ /**
+ * Date after which the currency can't be withdrawn anymore.
+ */
+ stampExpireWithdraw: TalerProtocolTimestamp;
+
+ /**
+ * Date after the denomination officially doesn't exist anymore.
+ */
+ stampExpireLegal: TalerProtocolTimestamp;
+
+ /**
+ * Data after which coins of this denomination can't be deposited anymore.
+ */
+ stampExpireDeposit: TalerProtocolTimestamp;
+
+ exchangeBaseUrl: string;
+}
+
+export type DenomOperation = "deposit" | "withdraw" | "refresh" | "refund";
+export type DenomOperationMap<T> = { [op in DenomOperation]: T };
+
+export interface FeeDescription {
+ group: string;
+ from: AbsoluteTime;
+ until: AbsoluteTime;
+ fee?: AmountString;
+}
+
+export interface FeeDescriptionPair {
+ group: string;
+ from: AbsoluteTime;
+ until: AbsoluteTime;
+ left?: AmountString;
+ right?: AmountString;
+}
+
+export interface TimePoint<T> {
+ id: string;
+ group: string;
+ fee: AmountString;
+ type: "start" | "end";
+ moment: AbsoluteTime;
+ denom: T;
+}
+
+export interface ExchangeFullDetails {
+ exchangeBaseUrl: string;
+ currency: string;
+ paytoUris: string[];
+ auditors: ExchangeAuditor[];
+ wireInfo: WireInfo;
+ denomFees: DenomOperationMap<FeeDescription[]>;
+ transferFees: Record<string, FeeDescription[]>;
+ globalFees: FeeDescription[];
+}
+
+export enum ExchangeTosStatus {
+ Pending = "pending",
+ Proposed = "proposed",
+ Accepted = "accepted",
+}
+
+export enum ExchangeEntryStatus {
+ Preset = "preset",
+ Ephemeral = "ephemeral",
+ Used = "used",
+}
+
+export enum ExchangeUpdateStatus {
+ Initial = "initial",
+ InitialUpdate = "initial-update",
+ Suspended = "suspended",
+ UnavailableUpdate = "unavailable-update",
+ Ready = "ready",
+ ReadyUpdate = "ready-update",
+}
+
+export interface OperationErrorInfo {
+ error: TalerErrorDetail;
+}
+
+export interface ShortExchangeListItem {
+ exchangeBaseUrl: string;
+}
+
+/**
+ * Info about an exchange entry in the wallet.
+ */
+export interface ExchangeListItem {
+ exchangeBaseUrl: string;
+ masterPub: string | undefined;
+ currency: string;
+ paytoUris: string[];
+ tosStatus: ExchangeTosStatus;
+ exchangeEntryStatus: ExchangeEntryStatus;
+ exchangeUpdateStatus: ExchangeUpdateStatus;
+ ageRestrictionOptions: number[];
+
+ /**
+ * P2P payments are disabled with this exchange
+ * (e.g. because no global fees are configured).
+ */
+ peerPaymentsDisabled: boolean;
+
+ /**
+ * Set to true if this exchange doesn't charge any fees.
+ */
+ noFees: boolean;
+
+ scopeInfo: ScopeInfo;
+
+ lastUpdateTimestamp: TalerPreciseTimestamp | undefined;
+
+ /**
+ * Information about the last error that occurred when trying
+ * to update the exchange info.
+ */
+ lastUpdateErrorInfo?: OperationErrorInfo;
+}
+
+const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> =>
+ buildCodecForObject<AuditorDenomSig>()
+ .property("denom_pub_h", codecForString())
+ .property("auditor_sig", codecForString())
+ .build("AuditorDenomSig");
+
+const codecForExchangeAuditor = (): Codec<ExchangeAuditor> =>
+ buildCodecForObject<ExchangeAuditor>()
+ .property("auditor_pub", codecForString())
+ .property("auditor_url", codecForString())
+ .property("denomination_keys", codecForList(codecForAuditorDenomSig()))
+ .build("codecForExchangeAuditor");
+
+export const codecForFeeDescriptionPair = (): Codec<FeeDescriptionPair> =>
+ buildCodecForObject<FeeDescriptionPair>()
+ .property("group", codecForString())
+ .property("from", codecForAbsoluteTime)
+ .property("until", codecForAbsoluteTime)
+ .property("left", codecOptional(codecForAmountString()))
+ .property("right", codecOptional(codecForAmountString()))
+ .build("FeeDescriptionPair");
+
+export const codecForFeeDescription = (): Codec<FeeDescription> =>
+ buildCodecForObject<FeeDescription>()
+ .property("group", codecForString())
+ .property("from", codecForAbsoluteTime)
+ .property("until", codecForAbsoluteTime)
+ .property("fee", codecOptional(codecForAmountString()))
+ .build("FeeDescription");
+
+export const codecForFeesByOperations = (): Codec<
+ DenomOperationMap<FeeDescription[]>
+> =>
+ buildCodecForObject<DenomOperationMap<FeeDescription[]>>()
+ .property("deposit", codecForList(codecForFeeDescription()))
+ .property("withdraw", codecForList(codecForFeeDescription()))
+ .property("refresh", codecForList(codecForFeeDescription()))
+ .property("refund", codecForList(codecForFeeDescription()))
+ .build("DenomOperationMap");
+
+export const codecForExchangeFullDetails = (): Codec<ExchangeFullDetails> =>
+ buildCodecForObject<ExchangeFullDetails>()
+ .property("currency", codecForString())
+ .property("exchangeBaseUrl", codecForString())
+ .property("paytoUris", codecForList(codecForString()))
+ .property("auditors", codecForList(codecForExchangeAuditor()))
+ .property("wireInfo", codecForWireInfo())
+ .property("denomFees", codecForFeesByOperations())
+ .property(
+ "transferFees",
+ codecForMap(codecForList(codecForFeeDescription())),
+ )
+ .property("globalFees", codecForList(codecForFeeDescription()))
+ .build("ExchangeFullDetails");
+
+export const codecForExchangeListItem = (): Codec<ExchangeListItem> =>
+ buildCodecForObject<ExchangeListItem>()
+ .property("currency", codecForString())
+ .property("exchangeBaseUrl", codecForString())
+ .property("masterPub", codecOptional(codecForString()))
+ .property("paytoUris", codecForList(codecForString()))
+ .property("tosStatus", codecForAny())
+ .property("exchangeEntryStatus", codecForAny())
+ .property("exchangeUpdateStatus", codecForAny())
+ .property("ageRestrictionOptions", codecForList(codecForNumber()))
+ .property("scopeInfo", codecForScopeInfo())
+ .property("lastUpdateErrorInfo", codecForAny())
+ .property("lastUpdateTimestamp", codecOptional(codecForPreciseTimestamp))
+ .property("noFees", codecForBoolean())
+ .property("peerPaymentsDisabled", codecForBoolean())
+ .build("ExchangeListItem");
+
+export const codecForExchangesListResponse = (): Codec<ExchangesListResponse> =>
+ buildCodecForObject<ExchangesListResponse>()
+ .property("exchanges", codecForList(codecForExchangeListItem()))
+ .build("ExchangesListResponse");
+
+export interface AcceptManualWithdrawalResult {
+ /**
+ * Payto URIs that can be used to fund the withdrawal.
+ *
+ * @deprecated in favor of withdrawalAccountsList
+ */
+ exchangePaytoUris: string[];
+
+ /**
+ * Public key of the newly created reserve.
+ */
+ reservePub: string;
+
+ withdrawalAccountsList: WithdrawalExchangeAccountDetails[];
+
+ transactionId: TransactionIdStr;
+}
+
+export interface WithdrawalDetailsForAmount {
+ /**
+ * Did the user accept the current version of the exchange's
+ * terms of service?
+ *
+ * @deprecated the client should query the exchange entry instead
+ */
+ tosAccepted: boolean;
+
+ /**
+ * Amount that the user will transfer to the exchange.
+ */
+ amountRaw: AmountString;
+
+ /**
+ * Amount that will be added to the user's wallet balance.
+ */
+ amountEffective: AmountString;
+
+ /**
+ * Number of coins that would be used for withdrawal.
+ *
+ * The UIs should warn if this number is too high (roughly at >100).
+ */
+ numCoins: number;
+
+ /**
+ * Ways to pay the exchange.
+ *
+ * @deprecated in favor of withdrawalAccountsList
+ */
+ paytoUris: string[];
+
+ /**
+ * Ways to pay the exchange, including accounts that require currency conversion.
+ */
+ withdrawalAccountsList: WithdrawalExchangeAccountDetails[];
+
+ /**
+ * If the exchange supports age-restricted coins it will return
+ * the array of ages.
+ */
+ ageRestrictionOptions?: number[];
+
+ /**
+ * Scope info of the currency withdrawn.
+ */
+ scopeInfo: ScopeInfo;
+}
+
+export interface DenomSelItem {
+ denomPubHash: string;
+ count: number;
+ /**
+ * Number of denoms/planchets to skip, because
+ * a re-denomination effectively deleted them.
+ */
+ skip?: number;
+}
+
+/**
+ * Selected denominations withn some extra info.
+ */
+export interface DenomSelectionState {
+ totalCoinValue: AmountString;
+ totalWithdrawCost: AmountString;
+ selectedDenoms: DenomSelItem[];
+ earliestDepositExpiration: TalerProtocolTimestamp;
+ hasDenomWithAgeRestriction: boolean;
+}
+
+/**
+ * Information about what will happen doing a withdrawal.
+ *
+ * Sent to the wallet frontend to be rendered and shown to the user.
+ */
+export interface ExchangeWithdrawalDetails {
+ exchangePaytoUris: string[];
+
+ /**
+ * Filtered wire info to send to the bank.
+ */
+ exchangeWireAccounts: string[];
+
+ exchangeCreditAccountDetails: WithdrawalExchangeAccountDetails[];
+
+ /**
+ * Selected denominations for withdraw.
+ */
+ selectedDenoms: DenomSelectionState;
+
+ /**
+ * Did the user already accept the current terms of service for the exchange?
+ */
+ termsOfServiceAccepted: boolean;
+
+ /**
+ * The earliest deposit expiration of the selected coins.
+ */
+ earliestDepositExpiration: TalerProtocolTimestamp;
+
+ /**
+ * Result of checking the wallet's version
+ * against the exchange's version.
+ *
+ * Older exchanges don't return version information.
+ */
+ versionMatch: VersionMatchResult | undefined;
+
+ /**
+ * Libtool-style version string for the exchange or "unknown"
+ * for older exchanges.
+ */
+ exchangeVersion: string;
+
+ /**
+ * Libtool-style version string for the wallet.
+ */
+ walletVersion: string;
+
+ /**
+ * Amount that will be subtracted from the reserve's balance.
+ */
+ withdrawalAmountRaw: AmountString;
+
+ /**
+ * Amount that will actually be added to the wallet's balance.
+ */
+ withdrawalAmountEffective: AmountString;
+
+ /**
+ * If the exchange supports age-restricted coins it will return
+ * the array of ages.
+ *
+ */
+ ageRestrictionOptions?: number[];
+
+ scopeInfo: ScopeInfo;
+}
+
+export interface GetExchangeTosResult {
+ /**
+ * Markdown version of the current ToS.
+ */
+ content: string;
+
+ /**
+ * Version tag of the current ToS.
+ */
+ currentEtag: string;
+
+ /**
+ * Version tag of the last ToS that the user has accepted,
+ * if any.
+ */
+ acceptedEtag: string | undefined;
+
+ /**
+ * Accepted content type
+ */
+ contentType: string;
+
+ /**
+ * Language of the returned content.
+ *
+ * If missing, language is unknown.
+ */
+ contentLanguage: string | undefined;
+
+ /**
+ * Available languages as advertised by the exchange.
+ */
+ tosAvailableLanguages: string[];
+
+ tosStatus: ExchangeTosStatus;
+}
+
+export interface TestPayArgs {
+ merchantBaseUrl: string;
+ merchantAuthToken?: string;
+ amount: AmountString;
+ summary: string;
+ forcedCoinSel?: ForcedCoinSel;
+}
+
+export const codecForTestPayArgs = (): Codec<TestPayArgs> =>
+ buildCodecForObject<TestPayArgs>()
+ .property("merchantBaseUrl", codecForString())
+ .property("merchantAuthToken", codecOptional(codecForString()))
+ .property("amount", codecForAmountString())
+ .property("summary", codecForString())
+ .property("forcedCoinSel", codecForAny())
+ .build("TestPayArgs");
+
+export interface IntegrationTestArgs {
+ exchangeBaseUrl: string;
+ corebankApiBaseUrl: string;
+ merchantBaseUrl: string;
+ merchantAuthToken?: string;
+ amountToWithdraw: AmountString;
+ amountToSpend: AmountString;
+}
+
+export const codecForIntegrationTestArgs = (): Codec<IntegrationTestArgs> =>
+ buildCodecForObject<IntegrationTestArgs>()
+ .property("exchangeBaseUrl", codecForString())
+ .property("merchantBaseUrl", codecForString())
+ .property("merchantAuthToken", codecOptional(codecForString()))
+ .property("amountToSpend", codecForAmountString())
+ .property("amountToWithdraw", codecForAmountString())
+ .property("corebankApiBaseUrl", codecForString())
+ .build("IntegrationTestArgs");
+
+export interface IntegrationTestV2Args {
+ exchangeBaseUrl: string;
+ corebankApiBaseUrl: string;
+ merchantBaseUrl: string;
+ merchantAuthToken?: string;
+}
+
+export const codecForIntegrationTestV2Args = (): Codec<IntegrationTestV2Args> =>
+ buildCodecForObject<IntegrationTestV2Args>()
+ .property("exchangeBaseUrl", codecForString())
+ .property("merchantBaseUrl", codecForString())
+ .property("merchantAuthToken", codecOptional(codecForString()))
+ .property("corebankApiBaseUrl", codecForString())
+ .build("IntegrationTestV2Args");
+
+export interface GetExchangeEntryByUrlRequest {
+ exchangeBaseUrl: string;
+}
+
+export const codecForGetExchangeEntryByUrlRequest =
+ (): Codec<GetExchangeEntryByUrlRequest> =>
+ buildCodecForObject<GetExchangeEntryByUrlRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .build("GetExchangeEntryByUrlRequest");
+
+export type GetExchangeEntryByUrlResponse = ExchangeListItem;
+
+export interface AddExchangeRequest {
+ exchangeBaseUrl: string;
+
+ /**
+ * @deprecated use a separate API call to start a forced exchange update instead
+ */
+ forceUpdate?: boolean;
+
+ masterPub?: string;
+}
+
+export const codecForAddExchangeRequest = (): Codec<AddExchangeRequest> =>
+ buildCodecForObject<AddExchangeRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .property("forceUpdate", codecOptional(codecForBoolean()))
+ .property("masterPub", codecOptional(codecForString()))
+ .build("AddExchangeRequest");
+
+export interface UpdateExchangeEntryRequest {
+ exchangeBaseUrl: string;
+ force?: boolean;
+}
+
+export const codecForUpdateExchangeEntryRequest =
+ (): Codec<UpdateExchangeEntryRequest> =>
+ buildCodecForObject<UpdateExchangeEntryRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .property("force", codecOptional(codecForBoolean()))
+ .build("UpdateExchangeEntryRequest");
+
+export interface GetExchangeResourcesRequest {
+ exchangeBaseUrl: string;
+}
+
+export const codecForGetExchangeResourcesRequest =
+ (): Codec<GetExchangeResourcesRequest> =>
+ buildCodecForObject<GetExchangeResourcesRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .build("GetExchangeResourcesRequest");
+
+export interface GetExchangeResourcesResponse {
+ hasResources: boolean;
+}
+
+export interface DeleteExchangeRequest {
+ exchangeBaseUrl: string;
+ purge?: boolean;
+}
+
+export const codecForDeleteExchangeRequest = (): Codec<DeleteExchangeRequest> =>
+ buildCodecForObject<DeleteExchangeRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .property("purge", codecOptional(codecForBoolean()))
+ .build("DeleteExchangeRequest");
+
+export interface ForceExchangeUpdateRequest {
+ exchangeBaseUrl: string;
+}
+
+export const codecForForceExchangeUpdateRequest =
+ (): Codec<AddExchangeRequest> =>
+ buildCodecForObject<AddExchangeRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .build("AddExchangeRequest");
+
+export interface GetExchangeTosRequest {
+ exchangeBaseUrl: string;
+ acceptedFormat?: string[];
+ acceptLanguage?: string;
+}
+
+export const codecForGetExchangeTosRequest = (): Codec<GetExchangeTosRequest> =>
+ buildCodecForObject<GetExchangeTosRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .property("acceptedFormat", codecOptional(codecForList(codecForString())))
+ .property("acceptLanguage", codecOptional(codecForString()))
+ .build("GetExchangeTosRequest");
+
+export interface AcceptManualWithdrawalRequest {
+ exchangeBaseUrl: string;
+ amount: AmountString;
+ restrictAge?: number;
+}
+
+export const codecForAcceptManualWithdrawalRequest =
+ (): Codec<AcceptManualWithdrawalRequest> =>
+ buildCodecForObject<AcceptManualWithdrawalRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .property("amount", codecForAmountString())
+ .property("restrictAge", codecOptional(codecForNumber()))
+ .build("AcceptManualWithdrawalRequest");
+
+export interface GetWithdrawalDetailsForAmountRequest {
+ exchangeBaseUrl: string;
+ amount: AmountString;
+ restrictAge?: number;
+
+ /**
+ * ID provided by the client to cancel the request.
+ *
+ * If the same request is made again with the same clientCancellationId,
+ * all previous requests are cancelled.
+ *
+ * The cancelled request will receive an error response with
+ * an error code that indicates the cancellation.
+ *
+ * The cancellation is best-effort, responses might still arrive.
+ */
+ clientCancellationId?: string;
+}
+
+export interface AcceptBankIntegratedWithdrawalRequest {
+ talerWithdrawUri: string;
+ exchangeBaseUrl: string;
+ forcedDenomSel?: ForcedDenomSel;
+ restrictAge?: number;
+}
+
+export const codecForAcceptBankIntegratedWithdrawalRequest =
+ (): Codec<AcceptBankIntegratedWithdrawalRequest> =>
+ buildCodecForObject<AcceptBankIntegratedWithdrawalRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .property("talerWithdrawUri", codecForString())
+ .property("forcedDenomSel", codecForAny())
+ .property("restrictAge", codecOptional(codecForNumber()))
+ .build("AcceptBankIntegratedWithdrawalRequest");
+
+export const codecForGetWithdrawalDetailsForAmountRequest =
+ (): Codec<GetWithdrawalDetailsForAmountRequest> =>
+ buildCodecForObject<GetWithdrawalDetailsForAmountRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .property("amount", codecForAmountString())
+ .property("restrictAge", codecOptional(codecForNumber()))
+ .property("clientCancellationId", codecOptional(codecForString()))
+ .build("GetWithdrawalDetailsForAmountRequest");
+
+export interface AcceptExchangeTosRequest {
+ exchangeBaseUrl: string;
+}
+
+export const codecForAcceptExchangeTosRequest =
+ (): Codec<AcceptExchangeTosRequest> =>
+ buildCodecForObject<AcceptExchangeTosRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .build("AcceptExchangeTosRequest");
+
+export interface ForgetExchangeTosRequest {
+ exchangeBaseUrl: string;
+}
+
+export const codecForForgetExchangeTosRequest =
+ (): Codec<ForgetExchangeTosRequest> =>
+ buildCodecForObject<ForgetExchangeTosRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .build("ForgetExchangeTosRequest");
+
+export interface AcceptRefundRequest {
+ transactionId: TransactionIdStr;
+}
+
+export const codecForApplyRefundRequest = (): Codec<AcceptRefundRequest> =>
+ buildCodecForObject<AcceptRefundRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("AcceptRefundRequest");
+
+export interface ApplyRefundFromPurchaseIdRequest {
+ purchaseId: string;
+}
+
+export const codecForApplyRefundFromPurchaseIdRequest =
+ (): Codec<ApplyRefundFromPurchaseIdRequest> =>
+ buildCodecForObject<ApplyRefundFromPurchaseIdRequest>()
+ .property("purchaseId", codecForString())
+ .build("ApplyRefundFromPurchaseIdRequest");
+
+export interface GetWithdrawalDetailsForUriRequest {
+ talerWithdrawUri: string;
+ restrictAge?: number;
+ notifyChangeFromPendingTimeoutMs?: number;
+}
+
+export const codecForGetWithdrawalDetailsForUri =
+ (): Codec<GetWithdrawalDetailsForUriRequest> =>
+ buildCodecForObject<GetWithdrawalDetailsForUriRequest>()
+ .property("talerWithdrawUri", codecForString())
+ .property("restrictAge", codecOptional(codecForNumber()))
+ .property(
+ "notifyChangeFromPendingTimeoutMs",
+ codecOptional(codecForNumber()),
+ )
+ .build("GetWithdrawalDetailsForUriRequest");
+
+export interface ListKnownBankAccountsRequest {
+ currency?: string;
+}
+
+export const codecForListKnownBankAccounts =
+ (): Codec<ListKnownBankAccountsRequest> =>
+ buildCodecForObject<ListKnownBankAccountsRequest>()
+ .property("currency", codecOptional(codecForString()))
+ .build("ListKnownBankAccountsRequest");
+
+export interface AddKnownBankAccountsRequest {
+ payto: string;
+ alias: string;
+ currency: string;
+}
+export const codecForAddKnownBankAccounts =
+ (): Codec<AddKnownBankAccountsRequest> =>
+ buildCodecForObject<AddKnownBankAccountsRequest>()
+ .property("payto", codecForString())
+ .property("alias", codecForString())
+ .property("currency", codecForString())
+ .build("AddKnownBankAccountsRequest");
+
+export interface ForgetKnownBankAccountsRequest {
+ payto: string;
+}
+
+export const codecForForgetKnownBankAccounts =
+ (): Codec<ForgetKnownBankAccountsRequest> =>
+ buildCodecForObject<ForgetKnownBankAccountsRequest>()
+ .property("payto", codecForString())
+ .build("ForgetKnownBankAccountsRequest");
+
+export interface AbortProposalRequest {
+ proposalId: string;
+}
+
+export const codecForAbortProposalRequest = (): Codec<AbortProposalRequest> =>
+ buildCodecForObject<AbortProposalRequest>()
+ .property("proposalId", codecForString())
+ .build("AbortProposalRequest");
+
+export interface GetContractTermsDetailsRequest {
+ // @deprecated use transaction id
+ proposalId?: string;
+ transactionId?: string;
+}
+
+export const codecForGetContractTermsDetails =
+ (): Codec<GetContractTermsDetailsRequest> =>
+ buildCodecForObject<GetContractTermsDetailsRequest>()
+ .property("proposalId", codecOptional(codecForString()))
+ .property("transactionId", codecOptional(codecForString()))
+ .build("GetContractTermsDetails");
+
+export interface PreparePayRequest {
+ talerPayUri: string;
+}
+
+export const codecForPreparePayRequest = (): Codec<PreparePayRequest> =>
+ buildCodecForObject<PreparePayRequest>()
+ .property("talerPayUri", codecForString())
+ .build("PreparePay");
+
+export interface SharePaymentRequest {
+ merchantBaseUrl: string;
+ orderId: string;
+}
+export const codecForSharePaymentRequest = (): Codec<SharePaymentRequest> =>
+ buildCodecForObject<SharePaymentRequest>()
+ .property("merchantBaseUrl", codecForString())
+ .property("orderId", codecForString())
+ .build("SharePaymentRequest");
+
+export interface SharePaymentResult {
+ privatePayUri: string;
+}
+export const codecForSharePaymentResult = (): Codec<SharePaymentResult> =>
+ buildCodecForObject<SharePaymentResult>()
+ .property("privatePayUri", codecForString())
+ .build("SharePaymentResult");
+
+export interface PreparePayTemplateRequest {
+ talerPayTemplateUri: string;
+ templateParams?: TemplateParams;
+}
+
+export const codecForPreparePayTemplateRequest =
+ (): Codec<PreparePayTemplateRequest> =>
+ buildCodecForObject<PreparePayTemplateRequest>()
+ .property("talerPayTemplateUri", codecForString())
+ .property("templateParams", codecForAny())
+ .build("PreparePayTemplate");
+
+export interface ConfirmPayRequest {
+ /**
+ * @deprecated use transactionId instead
+ */
+ proposalId?: string;
+ transactionId?: TransactionIdStr;
+ sessionId?: string;
+ forcedCoinSel?: ForcedCoinSel;
+}
+
+export const codecForConfirmPayRequest = (): Codec<ConfirmPayRequest> =>
+ buildCodecForObject<ConfirmPayRequest>()
+ .property("proposalId", codecOptional(codecForString()))
+ .property("transactionId", codecOptional(codecForTransactionIdStr()))
+ .property("sessionId", codecOptional(codecForString()))
+ .property("forcedCoinSel", codecForAny())
+ .build("ConfirmPay");
+
+export interface CoreApiRequestEnvelope {
+ id: string;
+ operation: string;
+ args: unknown;
+}
+
+export type CoreApiResponse = CoreApiResponseSuccess | CoreApiResponseError;
+
+export type CoreApiMessageEnvelope = CoreApiResponse | CoreApiNotification;
+
+export interface CoreApiNotification {
+ type: "notification";
+ payload: unknown;
+}
+
+export interface CoreApiResponseSuccess {
+ // To distinguish the message from notifications
+ type: "response";
+ operation: string;
+ id: string;
+ result: unknown;
+}
+
+export interface CoreApiResponseError {
+ // To distinguish the message from notifications
+ type: "error";
+ operation: string;
+ id: string;
+ error: TalerErrorDetail;
+}
+
+export interface WithdrawTestBalanceRequest {
+ amount: AmountString;
+ /**
+ * Corebank API base URL.
+ */
+ corebankApiBaseUrl: string;
+ exchangeBaseUrl: string;
+ forcedDenomSel?: ForcedDenomSel;
+}
+
+/**
+ * Request to the crypto worker to make a sync signature.
+ */
+export interface MakeSyncSignatureRequest {
+ accountPriv: string;
+ oldHash: string | undefined;
+ newHash: string;
+}
+
+/**
+ * Planchet for a coin during refresh.
+ */
+export interface RefreshPlanchetInfo {
+ /**
+ * Public key for the coin.
+ */
+ coinPub: string;
+
+ /**
+ * Private key for the coin.
+ */
+ coinPriv: string;
+
+ /**
+ * Blinded public key.
+ */
+ coinEv: CoinEnvelope;
+
+ coinEvHash: string;
+
+ /**
+ * Blinding key used.
+ */
+ blindingKey: string;
+
+ maxAge: number;
+ ageCommitmentProof?: AgeCommitmentProof;
+}
+
+/**
+ * Strategy for loading recovery information.
+ */
+export enum RecoveryMergeStrategy {
+ /**
+ * Keep the local wallet root key, import and take over providers.
+ */
+ Ours = "ours",
+
+ /**
+ * Migrate to the wallet root key from the recovery information.
+ */
+ Theirs = "theirs",
+}
+
+/**
+ * Load recovery information into the wallet.
+ */
+export interface RecoveryLoadRequest {
+ recovery: BackupRecovery;
+ strategy?: RecoveryMergeStrategy;
+}
+
+export const codecForWithdrawTestBalance =
+ (): Codec<WithdrawTestBalanceRequest> =>
+ buildCodecForObject<WithdrawTestBalanceRequest>()
+ .property("amount", codecForAmountString())
+ .property("exchangeBaseUrl", codecForString())
+ .property("forcedDenomSel", codecForAny())
+ .property("corebankApiBaseUrl", codecForString())
+ .build("WithdrawTestBalanceRequest");
+
+export interface SetCoinSuspendedRequest {
+ coinPub: string;
+ suspended: boolean;
+}
+
+export const codecForSetCoinSuspendedRequest =
+ (): Codec<SetCoinSuspendedRequest> =>
+ buildCodecForObject<SetCoinSuspendedRequest>()
+ .property("coinPub", codecForString())
+ .property("suspended", codecForBoolean())
+ .build("SetCoinSuspendedRequest");
+
+export interface RefreshCoinSpec {
+ coinPub: string;
+ amount?: AmountString;
+}
+
+export const codecForRefreshCoinSpec = (): Codec<RefreshCoinSpec> =>
+ buildCodecForObject<RefreshCoinSpec>()
+ .property("amount", codecForAmountString())
+ .property("coinPub", codecForString())
+ .build("ForceRefreshRequest");
+
+export interface ForceRefreshRequest {
+ refreshCoinSpecs: RefreshCoinSpec[];
+}
+
+export const codecForForceRefreshRequest = (): Codec<ForceRefreshRequest> =>
+ buildCodecForObject<ForceRefreshRequest>()
+ .property("refreshCoinSpecs", codecForList(codecForRefreshCoinSpec()))
+ .build("ForceRefreshRequest");
+
+export interface PrepareRefundRequest {
+ talerRefundUri: string;
+}
+
+export interface StartRefundQueryForUriResponse {
+ /**
+ * Transaction id of the *payment* where the refund query was started.
+ */
+ transactionId: TransactionIdStr;
+}
+
+export const codecForPrepareRefundRequest = (): Codec<PrepareRefundRequest> =>
+ buildCodecForObject<PrepareRefundRequest>()
+ .property("talerRefundUri", codecForString())
+ .build("PrepareRefundRequest");
+
+export interface StartRefundQueryRequest {
+ transactionId: TransactionIdStr;
+}
+
+export const codecForStartRefundQueryRequest =
+ (): Codec<StartRefundQueryRequest> =>
+ buildCodecForObject<StartRefundQueryRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("StartRefundQueryRequest");
+
+export interface PrepareRewardRequest {
+ talerRewardUri: string;
+}
+
+export const codecForPrepareRewardRequest = (): Codec<PrepareRewardRequest> =>
+ buildCodecForObject<PrepareRewardRequest>()
+ .property("talerRewardUri", codecForString())
+ .build("PrepareRewardRequest");
+
+export interface AcceptRewardRequest {
+ /**
+ * @deprecated use transactionId
+ */
+ walletRewardId?: string;
+ /**
+ * it will be required when "walletRewardId" is removed
+ */
+ transactionId?: TransactionIdStr;
+}
+
+export const codecForAcceptTipRequest = (): Codec<AcceptRewardRequest> =>
+ buildCodecForObject<AcceptRewardRequest>()
+ .property("walletRewardId", codecOptional(codecForString()))
+ .property("transactionId", codecOptional(codecForTransactionIdStr()))
+ .build("AcceptRewardRequest");
+
+export interface FailTransactionRequest {
+ transactionId: TransactionIdStr;
+}
+
+export const codecForFailTransactionRequest =
+ (): Codec<FailTransactionRequest> =>
+ buildCodecForObject<FailTransactionRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("FailTransactionRequest");
+
+export interface SuspendTransactionRequest {
+ transactionId: TransactionIdStr;
+}
+
+export const codecForSuspendTransaction =
+ (): Codec<SuspendTransactionRequest> =>
+ buildCodecForObject<AbortTransactionRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("SuspendTransactionRequest");
+
+export interface ResumeTransactionRequest {
+ transactionId: TransactionIdStr;
+}
+
+export const codecForResumeTransaction = (): Codec<ResumeTransactionRequest> =>
+ buildCodecForObject<ResumeTransactionRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("ResumeTransactionRequest");
+
+export interface AbortTransactionRequest {
+ transactionId: TransactionIdStr;
+}
+
+export interface FailTransactionRequest {
+ transactionId: TransactionIdStr;
+}
+
+export const codecForAbortTransaction = (): Codec<AbortTransactionRequest> =>
+ buildCodecForObject<AbortTransactionRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("AbortTransactionRequest");
+
+export interface DepositGroupFees {
+ coin: AmountString;
+ wire: AmountString;
+ refresh: AmountString;
+}
+
+export interface CreateDepositGroupRequest {
+ /**
+ * Pre-allocated transaction ID.
+ * Allows clients to easily handle notifications
+ * that occur while the operation has been created but
+ * before the creation request has returned.
+ */
+ transactionId?: TransactionIdStr;
+ depositPaytoUri: string;
+ amount: AmountString;
+}
+
+export interface PrepareDepositRequest {
+ depositPaytoUri: string;
+ amount: AmountString;
+}
+export const codecForPrepareDepositRequest = (): Codec<PrepareDepositRequest> =>
+ buildCodecForObject<PrepareDepositRequest>()
+ .property("amount", codecForAmountString())
+ .property("depositPaytoUri", codecForString())
+ .build("PrepareDepositRequest");
+
+export interface PrepareDepositResponse {
+ totalDepositCost: AmountString;
+ effectiveDepositAmount: AmountString;
+ fees: DepositGroupFees;
+}
+
+export const codecForCreateDepositGroupRequest =
+ (): Codec<CreateDepositGroupRequest> =>
+ buildCodecForObject<CreateDepositGroupRequest>()
+ .property("amount", codecForAmountString())
+ .property("depositPaytoUri", codecForString())
+ .property("transactionId", codecOptional(codecForTransactionIdStr()))
+ .build("CreateDepositGroupRequest");
+
+export interface CreateDepositGroupResponse {
+ depositGroupId: string;
+ transactionId: TransactionIdStr;
+}
+
+export interface TxIdResponse {
+ transactionId: TransactionIdStr;
+}
+
+export interface WithdrawUriInfoResponse {
+ operationId: string;
+ status: WithdrawalOperationStatus;
+ confirmTransferUrl?: string;
+ amount: AmountString;
+ defaultExchangeBaseUrl?: string;
+ possibleExchanges: ExchangeListItem[];
+}
+
+export const codecForWithdrawUriInfoResponse =
+ (): Codec<WithdrawUriInfoResponse> =>
+ buildCodecForObject<WithdrawUriInfoResponse>()
+ .property("operationId", codecForString())
+ .property("confirmTransferUrl", codecOptional(codecForString()))
+ .property(
+ "status",
+ codecForEither(
+ codecForConstString("pending"),
+ codecForConstString("selected"),
+ codecForConstString("aborted"),
+ codecForConstString("confirmed"),
+ ),
+ )
+ .property("amount", codecForAmountString())
+ .property("defaultExchangeBaseUrl", codecOptional(codecForString()))
+ .property("possibleExchanges", codecForList(codecForExchangeListItem()))
+ .build("WithdrawUriInfoResponse");
+
+export interface WalletCurrencyInfo {
+ trustedAuditors: {
+ currency: string;
+ auditorPub: string;
+ auditorBaseUrl: string;
+ }[];
+ trustedExchanges: {
+ currency: string;
+ exchangeMasterPub: string;
+ exchangeBaseUrl: string;
+ }[];
+}
+
+export interface TestingListTasksForTransactionRequest {
+ transactionId: TransactionIdStr;
+}
+
+export interface TestingListTasksForTransactionsResponse {
+ taskIdList: string[];
+}
+
+export const codecForTestingListTasksForTransactionRequest =
+ (): Codec<TestingListTasksForTransactionRequest> =>
+ buildCodecForObject<TestingListTasksForTransactionRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("TestingListTasksForTransactionRequest");
+
+export interface DeleteTransactionRequest {
+ transactionId: TransactionIdStr;
+}
+
+export interface RetryTransactionRequest {
+ transactionId: TransactionIdStr;
+}
+
+export const codecForDeleteTransactionRequest =
+ (): Codec<DeleteTransactionRequest> =>
+ buildCodecForObject<DeleteTransactionRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("DeleteTransactionRequest");
+
+export const codecForRetryTransactionRequest =
+ (): Codec<RetryTransactionRequest> =>
+ buildCodecForObject<RetryTransactionRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("RetryTransactionRequest");
+
+export interface SetWalletDeviceIdRequest {
+ /**
+ * New wallet device ID to set.
+ */
+ walletDeviceId: string;
+}
+
+export const codecForSetWalletDeviceIdRequest =
+ (): Codec<SetWalletDeviceIdRequest> =>
+ buildCodecForObject<SetWalletDeviceIdRequest>()
+ .property("walletDeviceId", codecForString())
+ .build("SetWalletDeviceIdRequest");
+
+export interface WithdrawFakebankRequest {
+ amount: AmountString;
+ exchange: string;
+ bank: string;
+}
+
+export enum AttentionPriority {
+ High = "high",
+ Medium = "medium",
+ Low = "low",
+}
+
+export interface UserAttentionByIdRequest {
+ entityId: string;
+ type: AttentionType;
+}
+
+export const codecForUserAttentionByIdRequest =
+ (): Codec<UserAttentionByIdRequest> =>
+ buildCodecForObject<UserAttentionByIdRequest>()
+ .property("type", codecForAny())
+ .property("entityId", codecForString())
+ .build("UserAttentionByIdRequest");
+
+export const codecForUserAttentionsRequest = (): Codec<UserAttentionsRequest> =>
+ buildCodecForObject<UserAttentionsRequest>()
+ .property(
+ "priority",
+ codecOptional(
+ codecForEither(
+ codecForConstString(AttentionPriority.Low),
+ codecForConstString(AttentionPriority.Medium),
+ codecForConstString(AttentionPriority.High),
+ ),
+ ),
+ )
+ .build("UserAttentionsRequest");
+
+export interface UserAttentionsRequest {
+ priority?: AttentionPriority;
+}
+
+export type AttentionInfo =
+ | AttentionKycWithdrawal
+ | AttentionBackupUnpaid
+ | AttentionBackupExpiresSoon
+ | AttentionMerchantRefund
+ | AttentionExchangeTosChanged
+ | AttentionExchangeKeyExpired
+ | AttentionExchangeDenominationExpired
+ | AttentionAuditorTosChanged
+ | AttentionAuditorKeyExpires
+ | AttentionAuditorDenominationExpires
+ | AttentionPullPaymentPaid
+ | AttentionPushPaymentReceived;
+
+export enum AttentionType {
+ KycWithdrawal = "kyc-withdrawal",
+
+ BackupUnpaid = "backup-unpaid",
+ BackupExpiresSoon = "backup-expires-soon",
+ MerchantRefund = "merchant-refund",
+
+ ExchangeTosChanged = "exchange-tos-changed",
+ ExchangeKeyExpired = "exchange-key-expired",
+ ExchangeKeyExpiresSoon = "exchange-key-expires-soon",
+ ExchangeDenominationsExpired = "exchange-denominations-expired",
+ ExchangeDenominationsExpiresSoon = "exchange-denominations-expires-soon",
+
+ AuditorTosChanged = "auditor-tos-changed",
+ AuditorKeyExpires = "auditor-key-expires",
+ AuditorDenominationsExpires = "auditor-denominations-expires",
+
+ PullPaymentPaid = "pull-payment-paid",
+ PushPaymentReceived = "push-payment-withdrawn",
+}
+
+export const UserAttentionPriority: {
+ [type in AttentionType]: AttentionPriority;
+} = {
+ "kyc-withdrawal": AttentionPriority.Medium,
+
+ "backup-unpaid": AttentionPriority.High,
+ "backup-expires-soon": AttentionPriority.Medium,
+ "merchant-refund": AttentionPriority.Medium,
+
+ "exchange-tos-changed": AttentionPriority.Medium,
+
+ "exchange-key-expired": AttentionPriority.High,
+ "exchange-key-expires-soon": AttentionPriority.Medium,
+ "exchange-denominations-expired": AttentionPriority.High,
+ "exchange-denominations-expires-soon": AttentionPriority.Medium,
+
+ "auditor-tos-changed": AttentionPriority.Medium,
+ "auditor-key-expires": AttentionPriority.Medium,
+ "auditor-denominations-expires": AttentionPriority.Medium,
+
+ "pull-payment-paid": AttentionPriority.High,
+ "push-payment-withdrawn": AttentionPriority.High,
+};
+
+interface AttentionBackupExpiresSoon {
+ type: AttentionType.BackupExpiresSoon;
+ provider_base_url: string;
+}
+interface AttentionBackupUnpaid {
+ type: AttentionType.BackupUnpaid;
+ provider_base_url: string;
+ talerUri: string;
+}
+
+interface AttentionMerchantRefund {
+ type: AttentionType.MerchantRefund;
+ transactionId: TransactionIdStr;
+}
+
+interface AttentionKycWithdrawal {
+ type: AttentionType.KycWithdrawal;
+ transactionId: TransactionIdStr;
+}
+
+interface AttentionExchangeTosChanged {
+ type: AttentionType.ExchangeTosChanged;
+ exchange_base_url: string;
+}
+interface AttentionExchangeKeyExpired {
+ type: AttentionType.ExchangeKeyExpired;
+ exchange_base_url: string;
+}
+interface AttentionExchangeDenominationExpired {
+ type: AttentionType.ExchangeDenominationsExpired;
+ exchange_base_url: string;
+}
+interface AttentionAuditorTosChanged {
+ type: AttentionType.AuditorTosChanged;
+ auditor_base_url: string;
+}
+
+interface AttentionAuditorKeyExpires {
+ type: AttentionType.AuditorKeyExpires;
+ auditor_base_url: string;
+}
+interface AttentionAuditorDenominationExpires {
+ type: AttentionType.AuditorDenominationsExpires;
+ auditor_base_url: string;
+}
+interface AttentionPullPaymentPaid {
+ type: AttentionType.PullPaymentPaid;
+ transactionId: TransactionIdStr;
+}
+
+interface AttentionPushPaymentReceived {
+ type: AttentionType.PushPaymentReceived;
+ transactionId: TransactionIdStr;
+}
+
+export type UserAttentionUnreadList = Array<{
+ info: AttentionInfo;
+ when: TalerPreciseTimestamp;
+ read: boolean;
+}>;
+
+export interface UserAttentionsResponse {
+ pending: UserAttentionUnreadList;
+}
+
+export interface UserAttentionsCountResponse {
+ total: number;
+}
+
+export const codecForWithdrawFakebankRequest =
+ (): Codec<WithdrawFakebankRequest> =>
+ buildCodecForObject<WithdrawFakebankRequest>()
+ .property("amount", codecForAmountString())
+ .property("bank", codecForString())
+ .property("exchange", codecForString())
+ .build("WithdrawFakebankRequest");
+
+export interface ActiveTask {
+ id: string;
+ transaction: TransactionIdStr | undefined;
+ firstTry: AbsoluteTime | undefined;
+ nextTry: AbsoluteTime | undefined;
+ counter: number | undefined;
+ lastError: TalerErrorDetail | undefined;
+}
+
+export interface GetActiveTasks {
+ tasks: ActiveTask[];
+}
+
+export const codecForActiveTask = (): Codec<ActiveTask> =>
+ buildCodecForObject<ActiveTask>()
+ .property("id", codecForString())
+ .property("transaction", codecOptional(codecForTransactionIdStr()))
+ .property("counter", codecForNumber())
+ .property("firstTry", codecForAbsoluteTime)
+ .property("nextTry", codecForAbsoluteTime)
+ .property("lastError", codecForTalerErrorDetail())
+ .build("ActiveTask");
+
+export const codecForGetActiveTasks = (): Codec<GetActiveTasks> =>
+ buildCodecForObject<GetActiveTasks>()
+ .property("tasks", codecForList(codecForActiveTask()))
+ .build("GetActiveTasks");
+
+export interface ImportDbRequest {
+ dump: any;
+}
+
+export const codecForImportDbRequest = (): Codec<ImportDbRequest> =>
+ buildCodecForObject<ImportDbRequest>()
+ .property("dump", codecForAny())
+ .build("ImportDbRequest");
+
+export interface ForcedDenomSel {
+ denoms: {
+ value: AmountString;
+ count: number;
+ }[];
+}
+
+/**
+ * Forced coin selection for deposits/payments.
+ */
+export interface ForcedCoinSel {
+ coins: {
+ value: AmountString;
+ contribution: AmountString;
+ }[];
+}
+
+export interface TestPayResult {
+ /**
+ * Number of coins used for the payment.
+ */
+ numCoins: number;
+}
+
+export interface SelectedCoin {
+ denomPubHash: string;
+ coinPub: string;
+ contribution: AmountString;
+ exchangeBaseUrl: string;
+}
+
+export interface SelectedProspectiveCoin {
+ denomPubHash: string;
+ contribution: AmountString;
+ exchangeBaseUrl: string;
+}
+
+/**
+ * Result of selecting coins, contains the exchange, and selected
+ * coins with their denomination.
+ */
+export interface PayCoinSelection {
+ coins: SelectedCoin[];
+
+ /**
+ * How much of the wire fees is the customer paying?
+ */
+ customerWireFees: AmountString;
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ customerDepositFees: AmountString;
+}
+
+export interface ProspectivePayCoinSelection {
+ prospectiveCoins: SelectedProspectiveCoin[];
+
+ /**
+ * How much of the wire fees is the customer paying?
+ */
+ customerWireFees: AmountString;
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ customerDepositFees: AmountString;
+}
+
+export interface CheckPeerPushDebitRequest {
+ /**
+ * Preferred exchange to use for the p2p payment.
+ */
+ exchangeBaseUrl?: string;
+
+ /**
+ * Instructed amount.
+ *
+ * FIXME: Allow specifying the instructed amount type.
+ */
+ amount: AmountString;
+}
+
+export const codecForCheckPeerPushDebitRequest =
+ (): Codec<CheckPeerPushDebitRequest> =>
+ buildCodecForObject<CheckPeerPushDebitRequest>()
+ .property("exchangeBaseUrl", codecOptional(codecForString()))
+ .property("amount", codecForAmountString())
+ .build("CheckPeerPushDebitRequest");
+
+export interface CheckPeerPushDebitResponse {
+ amountRaw: AmountString;
+ amountEffective: AmountString;
+ exchangeBaseUrl: string;
+ /**
+ * Maximum expiration date, based on how close the coins
+ * used for the payment are to expiry.
+ *
+ * The value is based on when the wallet would typically
+ * automatically refresh the coins on its own, leaving enough
+ * time to get a refund for the push payment and refresh the
+ * coin.
+ */
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
+export interface InitiatePeerPushDebitRequest {
+ exchangeBaseUrl?: string;
+ partialContractTerms: PeerContractTerms;
+}
+
+export interface InitiatePeerPushDebitResponse {
+ exchangeBaseUrl: string;
+ pursePub: string;
+ mergePriv: string;
+ contractPriv: string;
+ transactionId: TransactionIdStr;
+}
+
+export const codecForInitiatePeerPushDebitRequest =
+ (): Codec<InitiatePeerPushDebitRequest> =>
+ buildCodecForObject<InitiatePeerPushDebitRequest>()
+ .property("partialContractTerms", codecForPeerContractTerms())
+ .build("InitiatePeerPushDebitRequest");
+
+export interface PreparePeerPushCreditRequest {
+ talerUri: string;
+}
+
+export interface PreparePeerPullDebitRequest {
+ talerUri: string;
+}
+
+export interface PreparePeerPushCreditResponse {
+ contractTerms: PeerContractTerms;
+ amountRaw: AmountString;
+ amountEffective: AmountString;
+
+ transactionId: TransactionIdStr;
+
+ exchangeBaseUrl: string;
+
+ /**
+ * @deprecated use transaction ID instead.
+ */
+ peerPushCreditId: string;
+
+ /**
+ * @deprecated
+ */
+ amount: AmountString;
+}
+
+export interface PreparePeerPullDebitResponse {
+ contractTerms: PeerContractTerms;
+ /**
+ * @deprecated Redundant field with bad name, will be removed soon.
+ */
+ amount: AmountString;
+
+ amountRaw: AmountString;
+ amountEffective: AmountString;
+
+ peerPullDebitId: string;
+
+ transactionId: TransactionIdStr;
+}
+
+export const codecForPreparePeerPushCreditRequest =
+ (): Codec<PreparePeerPushCreditRequest> =>
+ buildCodecForObject<PreparePeerPushCreditRequest>()
+ .property("talerUri", codecForString())
+ .build("CheckPeerPushPaymentRequest");
+
+export const codecForCheckPeerPullPaymentRequest =
+ (): Codec<PreparePeerPullDebitRequest> =>
+ buildCodecForObject<PreparePeerPullDebitRequest>()
+ .property("talerUri", codecForString())
+ .build("PreparePeerPullDebitRequest");
+
+export interface ConfirmPeerPushCreditRequest {
+ transactionId: string;
+}
+export interface AcceptPeerPushPaymentResponse {
+ transactionId: TransactionIdStr;
+}
+
+export interface AcceptPeerPullPaymentResponse {
+ transactionId: TransactionIdStr;
+}
+
+export const codecForConfirmPeerPushPaymentRequest =
+ (): Codec<ConfirmPeerPushCreditRequest> =>
+ buildCodecForObject<ConfirmPeerPushCreditRequest>()
+ .property("transactionId", codecForString())
+ .build("ConfirmPeerPushCreditRequest");
+
+export interface ConfirmPeerPullDebitRequest {
+ transactionId: TransactionIdStr;
+}
+
+export interface ApplyDevExperimentRequest {
+ devExperimentUri: string;
+}
+
+export const codecForApplyDevExperiment =
+ (): Codec<ApplyDevExperimentRequest> =>
+ buildCodecForObject<ApplyDevExperimentRequest>()
+ .property("devExperimentUri", codecForString())
+ .build("ApplyDevExperimentRequest");
+
+export const codecForAcceptPeerPullPaymentRequest =
+ (): Codec<ConfirmPeerPullDebitRequest> =>
+ buildCodecForObject<ConfirmPeerPullDebitRequest>()
+ .property("transactionId", codecForTransactionIdStr())
+ .build("ConfirmPeerPullDebitRequest");
+
+export interface CheckPeerPullCreditRequest {
+ exchangeBaseUrl?: string;
+ amount: AmountString;
+}
+export const codecForPreparePeerPullPaymentRequest =
+ (): Codec<CheckPeerPullCreditRequest> =>
+ buildCodecForObject<CheckPeerPullCreditRequest>()
+ .property("amount", codecForAmountString())
+ .property("exchangeBaseUrl", codecOptional(codecForString()))
+ .build("CheckPeerPullCreditRequest");
+
+export interface CheckPeerPullCreditResponse {
+ exchangeBaseUrl: string;
+ amountRaw: AmountString;
+ amountEffective: AmountString;
+
+ /**
+ * Number of coins that will be used,
+ * can be used by the UI to warn if excessively large.
+ */
+ numCoins: number;
+}
+export interface InitiatePeerPullCreditRequest {
+ exchangeBaseUrl?: string;
+ partialContractTerms: PeerContractTerms;
+}
+
+export const codecForInitiatePeerPullPaymentRequest =
+ (): Codec<InitiatePeerPullCreditRequest> =>
+ buildCodecForObject<InitiatePeerPullCreditRequest>()
+ .property("partialContractTerms", codecForPeerContractTerms())
+ .property("exchangeBaseUrl", codecOptional(codecForString()))
+ .build("InitiatePeerPullCreditRequest");
+
+export interface InitiatePeerPullCreditResponse {
+ /**
+ * Taler URI for the other party to make the payment
+ * that was requested.
+ *
+ * @deprecated since it's not necessarily valid yet until the tx is in the right state
+ */
+ talerUri: string;
+
+ transactionId: TransactionIdStr;
+}
+
+export interface ValidateIbanRequest {
+ iban: string;
+}
+
+export const codecForValidateIbanRequest = (): Codec<ValidateIbanRequest> =>
+ buildCodecForObject<ValidateIbanRequest>()
+ .property("iban", codecForString())
+ .build("ValidateIbanRequest");
+
+export interface ValidateIbanResponse {
+ valid: boolean;
+}
+
+export const codecForValidateIbanResponse = (): Codec<ValidateIbanResponse> =>
+ buildCodecForObject<ValidateIbanResponse>()
+ .property("valid", codecForBoolean())
+ .build("ValidateIbanResponse");
+
+export type TransactionStateFilter = "nonfinal";
+
+export interface TransactionRecordFilter {
+ onlyState?: TransactionStateFilter;
+ onlyCurrency?: string;
+}
+
+export interface StoredBackupList {
+ storedBackups: {
+ name: string;
+ }[];
+}
+
+export interface CreateStoredBackupResponse {
+ name: string;
+}
+
+export interface RecoverStoredBackupRequest {
+ name: string;
+}
+
+export interface DeleteStoredBackupRequest {
+ name: string;
+}
+
+export const codecForDeleteStoredBackupRequest =
+ (): Codec<DeleteStoredBackupRequest> =>
+ buildCodecForObject<DeleteStoredBackupRequest>()
+ .property("name", codecForString())
+ .build("DeleteStoredBackupRequest");
+
+export const codecForRecoverStoredBackupRequest =
+ (): Codec<RecoverStoredBackupRequest> =>
+ buildCodecForObject<RecoverStoredBackupRequest>()
+ .property("name", codecForString())
+ .build("RecoverStoredBackupRequest");
+
+export interface TestingSetTimetravelRequest {
+ offsetMs: number;
+}
+
+export const codecForTestingSetTimetravelRequest =
+ (): Codec<TestingSetTimetravelRequest> =>
+ buildCodecForObject<TestingSetTimetravelRequest>()
+ .property("offsetMs", codecForNumber())
+ .build("TestingSetTimetravelRequest");
+
+export interface AllowedAuditorInfo {
+ auditorBaseUrl: string;
+ auditorPub: string;
+}
+
+export interface AllowedExchangeInfo {
+ exchangeBaseUrl: string;
+ exchangePub: string;
+}
+
+/**
+ * Data extracted from the contract terms that is relevant for payment
+ * processing in the wallet.
+ */
+export interface WalletContractData {
+ /**
+ * Fulfillment URL, or the empty string if the order has no fulfillment URL.
+ *
+ * Stored as a non-nullable string as we use this field for IndexedDB indexing.
+ */
+ fulfillmentUrl: string;
+
+ contractTermsHash: string;
+ fulfillmentMessage?: string;
+ fulfillmentMessageI18n?: InternationalizedString;
+ merchantSig: string;
+ merchantPub: string;
+ merchant: MerchantInfo;
+ amount: AmountString;
+ orderId: string;
+ merchantBaseUrl: string;
+ summary: string;
+ summaryI18n: { [lang_tag: string]: string } | undefined;
+ autoRefund: TalerProtocolDuration | undefined;
+ payDeadline: TalerProtocolTimestamp;
+ refundDeadline: TalerProtocolTimestamp;
+ allowedExchanges: AllowedExchangeInfo[];
+ timestamp: TalerProtocolTimestamp;
+ wireMethod: string;
+ wireInfoHash: string;
+ maxDepositFee: AmountString;
+ minimumAge?: number;
+}
+
+export interface TestingWaitTransactionRequest {
+ transactionId: TransactionIdStr;
+ txState: TransactionState;
+}
+
+export interface TestingGetDenomStatsRequest {
+ exchangeBaseUrl: string;
+}
+
+export interface TestingGetDenomStatsResponse {
+ numKnown: number;
+ numOffered: number;
+ numLost: number;
+}
+
+export const codecForTestingGetDenomStatsRequest =
+ (): Codec<TestingGetDenomStatsRequest> =>
+ buildCodecForObject<TestingGetDenomStatsRequest>()
+ .property("exchangeBaseUrl", codecForString())
+ .build("TestingGetDenomStatsRequest");
+
+export interface WithdrawalExchangeAccountDetails {
+ /**
+ * Payto URI to credit the exchange.
+ *
+ * Depending on whether the (manual!) withdrawal is accepted or just
+ * being checked, this already includes the subject with the
+ * reserve public key.
+ */
+ paytoUri: string;
+
+ /**
+ * Status that indicates whether the account can be used
+ * by the user to send funds for a withdrawal.
+ *
+ * ok: account should be shown to the user
+ * error: account should not be shown to the user, UIs might render the error (in conversionError),
+ * especially in dev mode.
+ */
+ status: "ok" | "error";
+
+ /**
+ * Transfer amount. Might be in a different currency than the requested
+ * amount for withdrawal.
+ *
+ * Absent if this is a conversion account and the conversion failed.
+ */
+ transferAmount?: AmountString;
+
+ /**
+ * Currency specification for the external currency.
+ *
+ * Only included if this account requires a currency conversion.
+ */
+ currencySpecification?: CurrencySpecification;
+
+ /**
+ * Further restrictions for sending money to the
+ * exchange.
+ */
+ creditRestrictions?: AccountRestriction[];
+
+ /**
+ * Label given to the account or the account's bank by the exchange.
+ */
+ bankLabel?: string;
+
+ /*
+ * Display priority assigned to this bank account by the exchange.
+ */
+ priority?: number;
+
+ /**
+ * Error that happened when attempting to request the conversion rate.
+ */
+ conversionError?: TalerErrorDetail;
+}
+
+export interface PrepareWithdrawExchangeRequest {
+ /**
+ * A taler://withdraw-exchange URI.
+ */
+ talerUri: string;
+}
+
+export const codecForPrepareWithdrawExchangeRequest =
+ (): Codec<PrepareWithdrawExchangeRequest> =>
+ buildCodecForObject<PrepareWithdrawExchangeRequest>()
+ .property("talerUri", codecForString())
+ .build("PrepareWithdrawExchangeRequest");
+
+export interface PrepareWithdrawExchangeResponse {
+ /**
+ * Base URL of the exchange that already existed
+ * or was ephemerally added as an exchange entry to
+ * the wallet.
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Amount from the taler://withdraw-exchange URI.
+ * Only present if specified in the URI.
+ */
+ amount?: AmountString;
+}
+
+export interface ExchangeEntryState {
+ tosStatus: ExchangeTosStatus;
+ exchangeEntryStatus: ExchangeEntryStatus;
+ exchangeUpdateStatus: ExchangeUpdateStatus;
+}
+
+export interface ListGlobalCurrencyAuditorsResponse {
+ auditors: {
+ currency: string;
+ auditorBaseUrl: string;
+ auditorPub: string;
+ }[];
+}
+
+export interface ListGlobalCurrencyExchangesResponse {
+ exchanges: {
+ currency: string;
+ exchangeBaseUrl: string;
+ exchangeMasterPub: string;
+ }[];
+}
+
+export interface AddGlobalCurrencyExchangeRequest {
+ currency: string;
+ exchangeBaseUrl: string;
+ exchangeMasterPub: string;
+}
+
+export const codecForAddGlobalCurrencyExchangeRequest =
+ (): Codec<AddGlobalCurrencyExchangeRequest> =>
+ buildCodecForObject<AddGlobalCurrencyExchangeRequest>()
+ .property("currency", codecForString())
+ .property("exchangeBaseUrl", codecForString())
+ .property("exchangeMasterPub", codecForString())
+ .build("AddGlobalCurrencyExchangeRequest");
+
+export interface RemoveGlobalCurrencyExchangeRequest {
+ currency: string;
+ exchangeBaseUrl: string;
+ exchangeMasterPub: string;
+}
+
+export const codecForRemoveGlobalCurrencyExchangeRequest =
+ (): Codec<RemoveGlobalCurrencyExchangeRequest> =>
+ buildCodecForObject<RemoveGlobalCurrencyExchangeRequest>()
+ .property("currency", codecForString())
+ .property("exchangeBaseUrl", codecForString())
+ .property("exchangeMasterPub", codecForString())
+ .build("RemoveGlobalCurrencyExchangeRequest");
+
+export interface AddGlobalCurrencyAuditorRequest {
+ currency: string;
+ auditorBaseUrl: string;
+ auditorPub: string;
+}
+
+export const codecForAddGlobalCurrencyAuditorRequest =
+ (): Codec<AddGlobalCurrencyAuditorRequest> =>
+ buildCodecForObject<AddGlobalCurrencyAuditorRequest>()
+ .property("currency", codecForString())
+ .property("auditorBaseUrl", codecForString())
+ .property("auditorPub", codecForString())
+ .build("AddGlobalCurrencyAuditorRequest");
+
+export interface RemoveGlobalCurrencyAuditorRequest {
+ currency: string;
+ auditorBaseUrl: string;
+ auditorPub: string;
+}
+
+export const codecForRemoveGlobalCurrencyAuditorRequest =
+ (): Codec<RemoveGlobalCurrencyAuditorRequest> =>
+ buildCodecForObject<RemoveGlobalCurrencyAuditorRequest>()
+ .property("currency", codecForString())
+ .property("auditorBaseUrl", codecForString())
+ .property("auditorPub", codecForString())
+ .build("RemoveGlobalCurrencyAuditorRequest");
+
+export interface RetryLoopOpts {
+ /**
+ * Stop the retry loop when all lifeness-giving pending operations
+ * are done.
+ *
+ * Defaults to false.
+ */
+ stopWhenDone?: boolean;
+}
+
+/**
+ * Information about one provider.
+ *
+ * We don't store the account key here,
+ * as that's derived from the wallet root key.
+ */
+export interface ProviderInfo {
+ active: boolean;
+ syncProviderBaseUrl: string;
+ name: string;
+ terms?: BackupProviderTerms;
+ /**
+ * Last communication issue with the provider.
+ */
+ lastError?: TalerErrorDetail;
+ lastSuccessfulBackupTimestamp?: TalerPreciseTimestamp;
+ lastAttemptedBackupTimestamp?: TalerPreciseTimestamp;
+ paymentProposalIds: string[];
+ backupProblem?: BackupProblem;
+ paymentStatus: ProviderPaymentStatus;
+}
+
+export interface BackupProviderTerms {
+ supportedProtocolVersion: string;
+ annualFee: AmountString;
+ storageLimitInMegabytes: number;
+}
+
+export type BackupProblem =
+ | BackupUnreadableProblem
+ | BackupConflictingDeviceProblem;
+
+export interface BackupUnreadableProblem {
+ type: "backup-unreadable";
+}
+
+export interface BackupConflictingDeviceProblem {
+ type: "backup-conflicting-device";
+ otherDeviceId: string;
+ myDeviceId: string;
+ backupTimestamp: AbsoluteTime;
+}
+
+export type ProviderPaymentStatus =
+ | ProviderPaymentTermsChanged
+ | ProviderPaymentPaid
+ | ProviderPaymentInsufficientBalance
+ | ProviderPaymentUnpaid
+ | ProviderPaymentPending;
+
+export enum ProviderPaymentType {
+ Unpaid = "unpaid",
+ Pending = "pending",
+ InsufficientBalance = "insufficient-balance",
+ Paid = "paid",
+ TermsChanged = "terms-changed",
+}
+
+export interface ProviderPaymentUnpaid {
+ type: ProviderPaymentType.Unpaid;
+}
+
+export interface ProviderPaymentInsufficientBalance {
+ type: ProviderPaymentType.InsufficientBalance;
+ amount: AmountString;
+}
+
+export interface ProviderPaymentPending {
+ type: ProviderPaymentType.Pending;
+ talerUri?: string;
+}
+
+export interface ProviderPaymentPaid {
+ type: ProviderPaymentType.Paid;
+ paidUntil: AbsoluteTime;
+}
+
+export interface ProviderPaymentTermsChanged {
+ type: ProviderPaymentType.TermsChanged;
+ paidUntil: AbsoluteTime;
+ oldTerms: BackupProviderTerms;
+ newTerms: BackupProviderTerms;
+}
+
+// FIXME: Does not really belong here, move to sync API
+export interface SyncTermsOfServiceResponse {
+ // maximum backup size supported
+ storage_limit_in_megabytes: number;
+
+ // Fee for an account, per year.
+ annual_fee: AmountString;
+
+ // protocol version supported by the server,
+ // for now always "0.0".
+ version: string;
+}
+
+// FIXME: Does not really belong here, move to sync API
+export const codecForSyncTermsOfServiceResponse =
+ (): Codec<SyncTermsOfServiceResponse> =>
+ buildCodecForObject<SyncTermsOfServiceResponse>()
+ .property("storage_limit_in_megabytes", codecForNumber())
+ .property("annual_fee", codecForAmountString())
+ .property("version", codecForString())
+ .build("SyncTermsOfServiceResponse");
diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts
deleted file mode 100644
index 6e68ee080..000000000
--- a/packages/taler-util/src/walletTypes.ts
+++ /dev/null
@@ -1,1047 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2015-2020 Taler Systems SA
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Types used by clients of the wallet.
- *
- * These types are defined in a separate file make tree shaking easier, since
- * some components use these types (via RPC) but do not depend on the wallet
- * code directly.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- AmountJson,
- codecForAmountJson,
- codecForAmountString,
-} from "./amounts.js";
-import { Timestamp, codecForTimestamp } from "./time.js";
-import {
- buildCodecForObject,
- codecForString,
- codecOptional,
- Codec,
- codecForList,
- codecForBoolean,
- codecForConstString,
- codecForAny,
- buildCodecForUnion,
-} from "./codec.js";
-import {
- AmountString,
- codecForContractTerms,
- ContractTerms,
-} from "./talerTypes.js";
-import { OrderShortInfo, codecForOrderShortInfo } from "./transactionsTypes.js";
-import { BackupRecovery } from "./backupTypes.js";
-
-/**
- * Response for the create reserve request to the wallet.
- */
-export class CreateReserveResponse {
- /**
- * Exchange URL where the bank should create the reserve.
- * The URL is canonicalized in the response.
- */
- exchange: string;
-
- /**
- * Reserve public key of the newly created reserve.
- */
- reservePub: string;
-}
-
-export interface Balance {
- available: AmountString;
- pendingIncoming: AmountString;
- pendingOutgoing: AmountString;
-
- // Does the balance for this currency have a pending
- // transaction?
- hasPendingTransactions: boolean;
-
- // Is there a pending transaction that would affect the balance
- // and requires user input?
- requiresUserInput: boolean;
-}
-
-export interface BalancesResponse {
- balances: Balance[];
-}
-
-export const codecForBalance = (): Codec<Balance> =>
- buildCodecForObject<Balance>()
- .property("available", codecForString())
- .property("hasPendingTransactions", codecForBoolean())
- .property("pendingIncoming", codecForString())
- .property("pendingOutgoing", codecForString())
- .property("requiresUserInput", codecForBoolean())
- .build("Balance");
-
-export const codecForBalancesResponse = (): Codec<BalancesResponse> =>
- buildCodecForObject<BalancesResponse>()
- .property("balances", codecForList(codecForBalance()))
- .build("BalancesResponse");
-
-/**
- * For terseness.
- */
-export function mkAmount(
- value: number,
- fraction: number,
- currency: string,
-): AmountJson {
- return { value, fraction, currency };
-}
-
-export enum ConfirmPayResultType {
- Done = "done",
- Pending = "pending",
-}
-
-/**
- * Result for confirmPay
- */
-export interface ConfirmPayResultDone {
- type: ConfirmPayResultType.Done;
- contractTerms: ContractTerms;
-}
-
-export interface ConfirmPayResultPending {
- type: ConfirmPayResultType.Pending;
-
- lastError: TalerErrorDetails;
-}
-
-export type ConfirmPayResult = ConfirmPayResultDone | ConfirmPayResultPending;
-
-export const codecForConfirmPayResultPending = (): Codec<ConfirmPayResultPending> =>
- buildCodecForObject<ConfirmPayResultPending>()
- .property("lastError", codecForAny())
- .property("type", codecForConstString(ConfirmPayResultType.Pending))
- .build("ConfirmPayResultPending");
-
-export const codecForConfirmPayResultDone = (): Codec<ConfirmPayResultDone> =>
- buildCodecForObject<ConfirmPayResultDone>()
- .property("type", codecForConstString(ConfirmPayResultType.Done))
- .property("contractTerms", codecForContractTerms())
- .build("ConfirmPayResultDone");
-
-export const codecForConfirmPayResult = (): Codec<ConfirmPayResult> =>
- buildCodecForUnion<ConfirmPayResult>()
- .discriminateOn("type")
- .alternative(
- ConfirmPayResultType.Pending,
- codecForConfirmPayResultPending(),
- )
- .alternative(ConfirmPayResultType.Done, codecForConfirmPayResultDone())
- .build("ConfirmPayResult");
-
-/**
- * Information about all sender wire details known to the wallet,
- * as well as exchanges that accept these wire types.
- */
-export interface SenderWireInfos {
- /**
- * Mapping from exchange base url to list of accepted
- * wire types.
- */
- exchangeWireTypes: { [exchangeBaseUrl: string]: string[] };
-
- /**
- * Sender wire information stored in the wallet.
- */
- senderWires: string[];
-}
-
-/**
- * Request to create a reserve.
- */
-export interface CreateReserveRequest {
- /**
- * The initial amount for the reserve.
- */
- amount: AmountJson;
-
- /**
- * Exchange URL where the bank should create the reserve.
- */
- exchange: string;
-
- /**
- * Payto URI that identifies the exchange's account that the funds
- * for this reserve go into.
- */
- exchangePaytoUri?: string;
-
- /**
- * Wire details (as a payto URI) for the bank account that sent the funds to
- * the exchange.
- */
- senderWire?: string;
-
- /**
- * URL to fetch the withdraw status from the bank.
- */
- bankWithdrawStatusUrl?: string;
-}
-
-export const codecForCreateReserveRequest = (): Codec<CreateReserveRequest> =>
- buildCodecForObject<CreateReserveRequest>()
- .property("amount", codecForAmountJson())
- .property("exchange", codecForString())
- .property("exchangePaytoUri", codecForString())
- .property("senderWire", codecOptional(codecForString()))
- .property("bankWithdrawStatusUrl", codecOptional(codecForString()))
- .build("CreateReserveRequest");
-
-/**
- * Request to mark a reserve as confirmed.
- */
-export interface ConfirmReserveRequest {
- /**
- * Public key of then reserve that should be marked
- * as confirmed.
- */
- reservePub: string;
-}
-
-export const codecForConfirmReserveRequest = (): Codec<ConfirmReserveRequest> =>
- buildCodecForObject<ConfirmReserveRequest>()
- .property("reservePub", codecForString())
- .build("ConfirmReserveRequest");
-
-/**
- * Wire coins to the user's own bank account.
- */
-export class ReturnCoinsRequest {
- /**
- * The amount to wire.
- */
- amount: AmountJson;
-
- /**
- * The exchange to take the coins from.
- */
- exchange: string;
-
- /**
- * Wire details for the bank account of the customer that will
- * receive the funds.
- */
- senderWire?: string;
-
- /**
- * Verify that a value matches the schema of this class and convert it into a
- * member.
- */
- static checked: (obj: any) => ReturnCoinsRequest;
-}
-
-export interface PrepareTipResult {
- /**
- * Unique ID for the tip assigned by the wallet.
- * Typically different from the merchant-generated tip ID.
- */
- walletTipId: string;
-
- /**
- * Has the tip already been accepted?
- */
- accepted: boolean;
-
- /**
- * Amount that the merchant gave.
- */
- tipAmountRaw: AmountString;
-
- /**
- * Amount that arrived at the wallet.
- * Might be lower than the raw amount due to fees.
- */
- tipAmountEffective: AmountString;
-
- /**
- * Base URL of the merchant backend giving then tip.
- */
- merchantBaseUrl: string;
-
- /**
- * Base URL of the exchange that is used to withdraw the tip.
- * Determined by the merchant, the wallet/user has no choice here.
- */
- exchangeBaseUrl: string;
-
- /**
- * Time when the tip will expire. After it expired, it can't be picked
- * up anymore.
- */
- expirationTimestamp: Timestamp;
-}
-
-export const codecForPrepareTipResult = (): Codec<PrepareTipResult> =>
- buildCodecForObject<PrepareTipResult>()
- .property("accepted", codecForBoolean())
- .property("tipAmountRaw", codecForAmountString())
- .property("tipAmountEffective", codecForAmountString())
- .property("exchangeBaseUrl", codecForString())
- .property("merchantBaseUrl", codecForString())
- .property("expirationTimestamp", codecForTimestamp)
- .property("walletTipId", codecForString())
- .build("PrepareTipResult");
-
-export interface BenchmarkResult {
- time: { [s: string]: number };
- repetitions: number;
-}
-
-export enum PreparePayResultType {
- PaymentPossible = "payment-possible",
- InsufficientBalance = "insufficient-balance",
- AlreadyConfirmed = "already-confirmed",
-}
-
-export const codecForPreparePayResultPaymentPossible = (): Codec<PreparePayResultPaymentPossible> =>
- buildCodecForObject<PreparePayResultPaymentPossible>()
- .property("amountEffective", codecForAmountString())
- .property("amountRaw", codecForAmountString())
- .property("contractTerms", codecForContractTerms())
- .property("proposalId", codecForString())
- .property("contractTermsHash", codecForString())
- .property("noncePriv", codecForString())
- .property(
- "status",
- codecForConstString(PreparePayResultType.PaymentPossible),
- )
- .build("PreparePayResultPaymentPossible");
-
-export const codecForPreparePayResultInsufficientBalance = (): Codec<PreparePayResultInsufficientBalance> =>
- buildCodecForObject<PreparePayResultInsufficientBalance>()
- .property("amountRaw", codecForAmountString())
- .property("contractTerms", codecForAny())
- .property("proposalId", codecForString())
- .property("noncePriv", codecForString())
- .property(
- "status",
- codecForConstString(PreparePayResultType.InsufficientBalance),
- )
- .build("PreparePayResultInsufficientBalance");
-
-export const codecForPreparePayResultAlreadyConfirmed = (): Codec<PreparePayResultAlreadyConfirmed> =>
- buildCodecForObject<PreparePayResultAlreadyConfirmed>()
- .property(
- "status",
- codecForConstString(PreparePayResultType.AlreadyConfirmed),
- )
- .property("amountEffective", codecForAmountString())
- .property("amountRaw", codecForAmountString())
- .property("paid", codecForBoolean())
- .property("contractTerms", codecForAny())
- .property("contractTermsHash", codecForString())
- .property("proposalId", codecForString())
- .build("PreparePayResultAlreadyConfirmed");
-
-export const codecForPreparePayResult = (): Codec<PreparePayResult> =>
- buildCodecForUnion<PreparePayResult>()
- .discriminateOn("status")
- .alternative(
- PreparePayResultType.AlreadyConfirmed,
- codecForPreparePayResultAlreadyConfirmed(),
- )
- .alternative(
- PreparePayResultType.InsufficientBalance,
- codecForPreparePayResultInsufficientBalance(),
- )
- .alternative(
- PreparePayResultType.PaymentPossible,
- codecForPreparePayResultPaymentPossible(),
- )
- .build("PreparePayResult");
-
-export type PreparePayResult =
- | PreparePayResultInsufficientBalance
- | PreparePayResultAlreadyConfirmed
- | PreparePayResultPaymentPossible;
-
-export interface PreparePayResultPaymentPossible {
- status: PreparePayResultType.PaymentPossible;
- proposalId: string;
- contractTerms: ContractTerms;
- contractTermsHash: string;
- amountRaw: string;
- amountEffective: string;
- noncePriv: string;
-}
-
-export interface PreparePayResultInsufficientBalance {
- status: PreparePayResultType.InsufficientBalance;
- proposalId: string;
- contractTerms: ContractTerms;
- amountRaw: string;
- noncePriv: string;
-}
-
-export interface PreparePayResultAlreadyConfirmed {
- status: PreparePayResultType.AlreadyConfirmed;
- contractTerms: ContractTerms;
- paid: boolean;
- amountRaw: string;
- amountEffective: string;
- contractTermsHash: string;
- proposalId: string;
-}
-
-export interface BankWithdrawDetails {
- selectionDone: boolean;
- transferDone: boolean;
- amount: AmountJson;
- senderWire?: string;
- suggestedExchange?: string;
- confirmTransferUrl?: string;
- wireTypes: string[];
- extractedStatusUrl: string;
-}
-
-export interface AcceptWithdrawalResponse {
- reservePub: string;
- confirmTransferUrl?: string;
-}
-
-/**
- * Details about a purchase, including refund status.
- */
-export interface PurchaseDetails {
- contractTerms: Record<string, undefined>;
- hasRefund: boolean;
- totalRefundAmount: AmountJson;
- totalRefundAndRefreshFees: AmountJson;
-}
-
-export interface WalletDiagnostics {
- walletManifestVersion: string;
- walletManifestDisplayVersion: string;
- errors: string[];
- firefoxIdbProblem: boolean;
- dbOutdated: boolean;
-}
-
-export interface TalerErrorDetails {
- code: number;
- hint: string;
- message: string;
- details: unknown;
-}
-
-export interface PlanchetCreationResult {
- coinPub: string;
- coinPriv: string;
- reservePub: string;
- denomPubHash: string;
- denomPub: string;
- blindingKey: string;
- withdrawSig: string;
- coinEv: string;
- coinValue: AmountJson;
- coinEvHash: string;
-}
-
-export interface PlanchetCreationRequest {
- secretSeed: string;
- coinIndex: number;
- value: AmountJson;
- feeWithdraw: AmountJson;
- denomPub: string;
- reservePub: string;
- reservePriv: string;
-}
-
-/**
- * Reasons for why a coin is being refreshed.
- */
-export enum RefreshReason {
- Manual = "manual",
- Pay = "pay",
- Refund = "refund",
- AbortPay = "abort-pay",
- Recoup = "recoup",
- BackupRestored = "backup-restored",
- Scheduled = "scheduled",
-}
-
-/**
- * Wrapper for coin public keys.
- */
-export interface CoinPublicKey {
- readonly coinPub: string;
-}
-
-/**
- * Wrapper for refresh group IDs.
- */
-export interface RefreshGroupId {
- readonly refreshGroupId: string;
-}
-
-/**
- * Private data required to make a deposit permission.
- */
-export interface DepositInfo {
- exchangeBaseUrl: string;
- contractTermsHash: string;
- coinPub: string;
- coinPriv: string;
- spendAmount: AmountJson;
- timestamp: Timestamp;
- refundDeadline: Timestamp;
- merchantPub: string;
- feeDeposit: AmountJson;
- wireInfoHash: string;
- denomPubHash: string;
- denomSig: string;
-}
-
-export interface ExchangesListRespose {
- exchanges: ExchangeListItem[];
-}
-
-export interface ExchangeListItem {
- exchangeBaseUrl: string;
- currency: string;
- paytoUris: string[];
-}
-
-export const codecForExchangeListItem = (): Codec<ExchangeListItem> =>
- buildCodecForObject<ExchangeListItem>()
- .property("currency", codecForString())
- .property("exchangeBaseUrl", codecForString())
- .property("paytoUris", codecForList(codecForString()))
- .build("ExchangeListItem");
-
-export const codecForExchangesListResponse = (): Codec<ExchangesListRespose> =>
- buildCodecForObject<ExchangesListRespose>()
- .property("exchanges", codecForList(codecForExchangeListItem()))
- .build("ExchangesListRespose");
-
-export interface AcceptManualWithdrawalResult {
- /**
- * Payto URIs that can be used to fund the withdrawal.
- */
- exchangePaytoUris: string[];
-
- /**
- * Public key of the newly created reserve.
- */
- reservePub: string;
-}
-
-export interface ManualWithdrawalDetails {
- /**
- * Did the user accept the current version of the exchange's
- * terms of service?
- */
- tosAccepted: boolean;
-
- /**
- * Amount that the user will transfer to the exchange.
- */
- amountRaw: AmountString;
-
- /**
- * Amount that will be added to the user's wallet balance.
- */
- amountEffective: AmountString;
-
- /**
- * Ways to pay the exchange.
- */
- paytoUris: string[];
-}
-
-export interface GetExchangeTosResult {
- /**
- * Markdown version of the current ToS.
- */
- content: string;
-
- /**
- * Version tag of the current ToS.
- */
- currentEtag: string;
-
- /**
- * Version tag of the last ToS that the user has accepted,
- * if any.
- */
- acceptedEtag: string | undefined;
-
- /**
- * Accepted content type
- */
- contentType: string;
-}
-
-export interface TestPayArgs {
- merchantBaseUrl: string;
- merchantAuthToken?: string;
- amount: string;
- summary: string;
-}
-
-export const codecForTestPayArgs = (): Codec<TestPayArgs> =>
- buildCodecForObject<TestPayArgs>()
- .property("merchantBaseUrl", codecForString())
- .property("merchantAuthToken", codecOptional(codecForString()))
- .property("amount", codecForString())
- .property("summary", codecForString())
- .build("TestPayArgs");
-
-export interface IntegrationTestArgs {
- exchangeBaseUrl: string;
- bankBaseUrl: string;
- merchantBaseUrl: string;
- merchantAuthToken?: string;
- amountToWithdraw: string;
- amountToSpend: string;
-}
-
-export const codecForIntegrationTestArgs = (): Codec<IntegrationTestArgs> =>
- buildCodecForObject<IntegrationTestArgs>()
- .property("exchangeBaseUrl", codecForString())
- .property("bankBaseUrl", codecForString())
- .property("merchantBaseUrl", codecForString())
- .property("merchantAuthToken", codecOptional(codecForString()))
- .property("amountToSpend", codecForAmountString())
- .property("amountToWithdraw", codecForAmountString())
- .build("IntegrationTestArgs");
-
-export interface AddExchangeRequest {
- exchangeBaseUrl: string;
- forceUpdate?: boolean;
-}
-
-export const codecForAddExchangeRequest = (): Codec<AddExchangeRequest> =>
- buildCodecForObject<AddExchangeRequest>()
- .property("exchangeBaseUrl", codecForString())
- .property("forceUpdate", codecOptional(codecForBoolean()))
- .build("AddExchangeRequest");
-
-export interface ForceExchangeUpdateRequest {
- exchangeBaseUrl: string;
-}
-
-export const codecForForceExchangeUpdateRequest = (): Codec<AddExchangeRequest> =>
- buildCodecForObject<AddExchangeRequest>()
- .property("exchangeBaseUrl", codecForString())
- .build("AddExchangeRequest");
-
-export interface GetExchangeTosRequest {
- exchangeBaseUrl: string;
- acceptedFormat?: string[];
-}
-
-export const codecForGetExchangeTosRequest = (): Codec<GetExchangeTosRequest> =>
- buildCodecForObject<GetExchangeTosRequest>()
- .property("exchangeBaseUrl", codecForString())
- .property("acceptedFormat", codecOptional(codecForList(codecForString())))
- .build("GetExchangeTosRequest");
-
-export interface AcceptManualWithdrawalRequest {
- exchangeBaseUrl: string;
- amount: string;
-}
-
-export const codecForAcceptManualWithdrawalRequet = (): Codec<AcceptManualWithdrawalRequest> =>
- buildCodecForObject<AcceptManualWithdrawalRequest>()
- .property("exchangeBaseUrl", codecForString())
- .property("amount", codecForString())
- .build("AcceptManualWithdrawalRequest");
-
-export interface GetWithdrawalDetailsForAmountRequest {
- exchangeBaseUrl: string;
- amount: string;
-}
-
-export interface AcceptBankIntegratedWithdrawalRequest {
- talerWithdrawUri: string;
- exchangeBaseUrl: string;
-}
-
-export const codecForAcceptBankIntegratedWithdrawalRequest = (): Codec<AcceptBankIntegratedWithdrawalRequest> =>
- buildCodecForObject<AcceptBankIntegratedWithdrawalRequest>()
- .property("exchangeBaseUrl", codecForString())
- .property("talerWithdrawUri", codecForString())
- .build("AcceptBankIntegratedWithdrawalRequest");
-
-export const codecForGetWithdrawalDetailsForAmountRequest = (): Codec<GetWithdrawalDetailsForAmountRequest> =>
- buildCodecForObject<GetWithdrawalDetailsForAmountRequest>()
- .property("exchangeBaseUrl", codecForString())
- .property("amount", codecForString())
- .build("GetWithdrawalDetailsForAmountRequest");
-
-export interface AcceptExchangeTosRequest {
- exchangeBaseUrl: string;
- etag: string;
-}
-
-export const codecForAcceptExchangeTosRequest = (): Codec<AcceptExchangeTosRequest> =>
- buildCodecForObject<AcceptExchangeTosRequest>()
- .property("exchangeBaseUrl", codecForString())
- .property("etag", codecForString())
- .build("AcceptExchangeTosRequest");
-
-export interface ApplyRefundRequest {
- talerRefundUri: string;
-}
-
-export const codecForApplyRefundRequest = (): Codec<ApplyRefundRequest> =>
- buildCodecForObject<ApplyRefundRequest>()
- .property("talerRefundUri", codecForString())
- .build("ApplyRefundRequest");
-
-export interface GetWithdrawalDetailsForUriRequest {
- talerWithdrawUri: string;
-}
-
-export const codecForGetWithdrawalDetailsForUri = (): Codec<GetWithdrawalDetailsForUriRequest> =>
- buildCodecForObject<GetWithdrawalDetailsForUriRequest>()
- .property("talerWithdrawUri", codecForString())
- .build("GetWithdrawalDetailsForUriRequest");
-
-export interface GetExchangeWithdrawalInfo {
- exchangeBaseUrl: string;
- amount: AmountJson;
- tosAcceptedFormat?: string[];
-}
-
-export const codecForGetExchangeWithdrawalInfo = (): Codec<GetExchangeWithdrawalInfo> =>
- buildCodecForObject<GetExchangeWithdrawalInfo>()
- .property("exchangeBaseUrl", codecForString())
- .property("amount", codecForAmountJson())
- .property(
- "tosAcceptedFormat",
- codecOptional(codecForList(codecForString())),
- )
- .build("GetExchangeWithdrawalInfo");
-
-export interface AbortProposalRequest {
- proposalId: string;
-}
-
-export const codecForAbortProposalRequest = (): Codec<AbortProposalRequest> =>
- buildCodecForObject<AbortProposalRequest>()
- .property("proposalId", codecForString())
- .build("AbortProposalRequest");
-
-export interface PreparePayRequest {
- talerPayUri: string;
-}
-
-export const codecForPreparePayRequest = (): Codec<PreparePayRequest> =>
- buildCodecForObject<PreparePayRequest>()
- .property("talerPayUri", codecForString())
- .build("PreparePay");
-
-export interface ConfirmPayRequest {
- proposalId: string;
- sessionId?: string;
-}
-
-export const codecForConfirmPayRequest = (): Codec<ConfirmPayRequest> =>
- buildCodecForObject<ConfirmPayRequest>()
- .property("proposalId", codecForString())
- .property("sessionId", codecOptional(codecForString()))
- .build("ConfirmPay");
-
-export type CoreApiResponse = CoreApiResponseSuccess | CoreApiResponseError;
-
-export type CoreApiEnvelope = CoreApiResponse | CoreApiNotification;
-
-export interface CoreApiNotification {
- type: "notification";
- payload: unknown;
-}
-
-export interface CoreApiResponseSuccess {
- // To distinguish the message from notifications
- type: "response";
- operation: string;
- id: string;
- result: unknown;
-}
-
-export interface CoreApiResponseError {
- // To distinguish the message from notifications
- type: "error";
- operation: string;
- id: string;
- error: TalerErrorDetails;
-}
-
-export interface WithdrawTestBalanceRequest {
- amount: string;
- bankBaseUrl: string;
- exchangeBaseUrl: string;
-}
-
-export const withdrawTestBalanceDefaults = {
- amount: "TESTKUDOS:10",
- bankBaseUrl: "https://bank.test.taler.net/",
- exchangeBaseUrl: "https://exchange.test.taler.net/",
-};
-
-/**
- * Request to the crypto worker to make a sync signature.
- */
-export interface MakeSyncSignatureRequest {
- accountPriv: string;
- oldHash: string | undefined;
- newHash: string;
-}
-
-/**
- * Planchet for a coin during refresh.
- */
-export interface RefreshPlanchetInfo {
- /**
- * Public key for the coin.
- */
- publicKey: string;
-
- /**
- * Private key for the coin.
- */
- privateKey: string;
-
- /**
- * Blinded public key.
- */
- coinEv: string;
-
- coinEvHash: string;
-
- /**
- * Blinding key used.
- */
- blindingKey: string;
-}
-
-/**
- * Strategy for loading recovery information.
- */
-export enum RecoveryMergeStrategy {
- /**
- * Keep the local wallet root key, import and take over providers.
- */
- Ours = "ours",
-
- /**
- * Migrate to the wallet root key from the recovery information.
- */
- Theirs = "theirs",
-}
-
-/**
- * Load recovery information into the wallet.
- */
-export interface RecoveryLoadRequest {
- recovery: BackupRecovery;
- strategy?: RecoveryMergeStrategy;
-}
-
-export const codecForWithdrawTestBalance = (): Codec<WithdrawTestBalanceRequest> =>
- buildCodecForObject<WithdrawTestBalanceRequest>()
- .property("amount", codecForString())
- .property("bankBaseUrl", codecForString())
- .property("exchangeBaseUrl", codecForString())
- .build("WithdrawTestBalanceRequest");
-
-export interface ApplyRefundResponse {
- contractTermsHash: string;
-
- proposalId: string;
-
- amountEffectivePaid: AmountString;
-
- amountRefundGranted: AmountString;
-
- amountRefundGone: AmountString;
-
- pendingAtExchange: boolean;
-
- info: OrderShortInfo;
-}
-
-export const codecForApplyRefundResponse = (): Codec<ApplyRefundResponse> =>
- buildCodecForObject<ApplyRefundResponse>()
- .property("amountEffectivePaid", codecForAmountString())
- .property("amountRefundGone", codecForAmountString())
- .property("amountRefundGranted", codecForAmountString())
- .property("contractTermsHash", codecForString())
- .property("pendingAtExchange", codecForBoolean())
- .property("proposalId", codecForString())
- .property("info", codecForOrderShortInfo())
- .build("ApplyRefundResponse");
-
-export interface SetCoinSuspendedRequest {
- coinPub: string;
- suspended: boolean;
-}
-
-export const codecForSetCoinSuspendedRequest = (): Codec<SetCoinSuspendedRequest> =>
- buildCodecForObject<SetCoinSuspendedRequest>()
- .property("coinPub", codecForString())
- .property("suspended", codecForBoolean())
- .build("SetCoinSuspendedRequest");
-
-export interface ForceRefreshRequest {
- coinPubList: string[];
-}
-
-export const codecForForceRefreshRequest = (): Codec<ForceRefreshRequest> =>
- buildCodecForObject<ForceRefreshRequest>()
- .property("coinPubList", codecForList(codecForString()))
- .build("ForceRefreshRequest");
-
-export interface PrepareTipRequest {
- talerTipUri: string;
-}
-
-export const codecForPrepareTipRequest = (): Codec<PrepareTipRequest> =>
- buildCodecForObject<PrepareTipRequest>()
- .property("talerTipUri", codecForString())
- .build("PrepareTipRequest");
-
-export interface AcceptTipRequest {
- walletTipId: string;
-}
-
-export const codecForAcceptTipRequest = (): Codec<AcceptTipRequest> =>
- buildCodecForObject<AcceptTipRequest>()
- .property("walletTipId", codecForString())
- .build("AcceptTipRequest");
-
-export interface AbortPayWithRefundRequest {
- proposalId: string;
-}
-
-export const codecForAbortPayWithRefundRequest = (): Codec<AbortPayWithRefundRequest> =>
- buildCodecForObject<AbortPayWithRefundRequest>()
- .property("proposalId", codecForString())
- .build("AbortPayWithRefundRequest");
-
-export interface CreateDepositGroupRequest {
- depositPaytoUri: string;
- amount: string;
-}
-
-export const codecForCreateDepositGroupRequest = (): Codec<CreateDepositGroupRequest> =>
- buildCodecForObject<CreateDepositGroupRequest>()
- .property("amount", codecForAmountString())
- .property("depositPaytoUri", codecForString())
- .build("CreateDepositGroupRequest");
-
-export interface CreateDepositGroupResponse {
- depositGroupId: string;
-}
-
-export interface TrackDepositGroupRequest {
- depositGroupId: string;
-}
-
-export interface TrackDepositGroupResponse {
- responses: {
- status: number;
- body: any;
- }[];
-}
-
-export const codecForTrackDepositGroupRequest = (): Codec<TrackDepositGroupRequest> =>
- buildCodecForObject<TrackDepositGroupRequest>()
- .property("depositGroupId", codecForAmountString())
- .build("TrackDepositGroupRequest");
-
-export interface WithdrawUriInfoResponse {
- amount: AmountString;
- defaultExchangeBaseUrl?: string;
- possibleExchanges: ExchangeListItem[];
-}
-
-export const codecForWithdrawUriInfoResponse = (): Codec<WithdrawUriInfoResponse> =>
- buildCodecForObject<WithdrawUriInfoResponse>()
- .property("amount", codecForAmountString())
- .property("defaultExchangeBaseUrl", codecOptional(codecForString()))
- .property("possibleExchanges", codecForList(codecForExchangeListItem()))
- .build("WithdrawUriInfoResponse");
-
-export interface WalletCurrencyInfo {
- trustedAuditors: {
- currency: string;
- auditorPub: string;
- auditorBaseUrl: string;
- }[];
- trustedExchanges: {
- currency: string;
- exchangeMasterPub: string;
- exchangeBaseUrl: string;
- }[];
-}
-
-export interface DeleteTransactionRequest {
- transactionId: string;
-}
-
-export interface RetryTransactionRequest {
- transactionId: string;
-}
-
-export const codecForDeleteTransactionRequest = (): Codec<DeleteTransactionRequest> =>
- buildCodecForObject<DeleteTransactionRequest>()
- .property("transactionId", codecForString())
- .build("DeleteTransactionRequest");
-
-export const codecForRetryTransactionRequest = (): Codec<RetryTransactionRequest> =>
- buildCodecForObject<RetryTransactionRequest>()
- .property("transactionId", codecForString())
- .build("RetryTransactionRequest");
-
-export interface SetWalletDeviceIdRequest {
- /**
- * New wallet device ID to set.
- */
- walletDeviceId: string;
-}
-
-export const codecForSetWalletDeviceIdRequest = (): Codec<SetWalletDeviceIdRequest> =>
- buildCodecForObject<SetWalletDeviceIdRequest>()
- .property("walletDeviceId", codecForString())
- .build("SetWalletDeviceIdRequest");
-
-export interface WithdrawFakebankRequest {
- amount: AmountString;
- exchange: string;
- bank: string;
-}
-
-export const codecForWithdrawFakebankRequest = (): Codec<WithdrawFakebankRequest> =>
- buildCodecForObject<WithdrawFakebankRequest>()
- .property("amount", codecForAmountString())
- .property("bank", codecForString())
- .property("exchange", codecForString())
- .build("WithdrawFakebankRequest");
diff --git a/packages/taler-util/src/whatwg-url.ts b/packages/taler-util/src/whatwg-url.ts
new file mode 100644
index 000000000..13abf5397
--- /dev/null
+++ b/packages/taler-util/src/whatwg-url.ts
@@ -0,0 +1,2126 @@
+/*
+The MIT License (MIT)
+
+Copyright (c) Sebastian Mayr
+Copyright (c) 2022 Taler Systems S.A.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+// Vendored with modifications (TypeScript etc.) from https://github.com/jsdom/whatwg-url
+
+const utf8Encoder = new TextEncoder();
+const utf8Decoder = new TextDecoder("utf-8", { ignoreBOM: true });
+
+function utf8Encode(string: string | undefined) {
+ return utf8Encoder.encode(string);
+}
+
+function utf8DecodeWithoutBOM(
+ bytes: DataView | ArrayBuffer | null | undefined,
+) {
+ return utf8Decoder.decode(bytes);
+}
+
+// https://url.spec.whatwg.org/#concept-urlencoded-parser
+function parseUrlencoded(input: Uint8Array) {
+ const sequences = strictlySplitByteSequence(input, p("&"));
+ const output = [];
+ for (const bytes of sequences) {
+ if (bytes.length === 0) {
+ continue;
+ }
+
+ let name, value;
+ const indexOfEqual = bytes.indexOf(p("=")!);
+
+ if (indexOfEqual >= 0) {
+ name = bytes.slice(0, indexOfEqual);
+ value = bytes.slice(indexOfEqual + 1);
+ } else {
+ name = bytes;
+ value = new Uint8Array(0);
+ }
+
+ name = replaceByteInByteSequence(name, 0x2b, 0x20);
+ value = replaceByteInByteSequence(value, 0x2b, 0x20);
+
+ const nameString = utf8DecodeWithoutBOM(percentDecodeBytes(name));
+ const valueString = utf8DecodeWithoutBOM(percentDecodeBytes(value));
+
+ output.push([nameString, valueString]);
+ }
+ return output;
+}
+
+// https://url.spec.whatwg.org/#concept-urlencoded-string-parser
+function parseUrlencodedString(input: string | undefined) {
+ return parseUrlencoded(utf8Encode(input));
+}
+
+// https://url.spec.whatwg.org/#concept-urlencoded-serializer
+function serializeUrlencoded(tuples: any[], encodingOverride = undefined) {
+ let encoding = "utf-8";
+ if (encodingOverride !== undefined) {
+ // TODO "get the output encoding", i.e. handle encoding labels vs. names.
+ encoding = encodingOverride;
+ }
+
+ let output = "";
+ for (const [i, tuple] of tuples.entries()) {
+ // TODO: handle encoding override
+
+ const name = utf8PercentEncodeString(
+ tuple[0],
+ isURLEncodedPercentEncode,
+ true,
+ );
+
+ let value = tuple[1];
+ if (tuple.length > 2 && tuple[2] !== undefined) {
+ if (tuple[2] === "hidden" && name === "_charset_") {
+ value = encoding;
+ } else if (tuple[2] === "file") {
+ // value is a File object
+ value = value.name;
+ }
+ }
+
+ value = utf8PercentEncodeString(value, isURLEncodedPercentEncode, true);
+
+ if (i !== 0) {
+ output += "&";
+ }
+ output += `${name}=${value}`;
+ }
+ return output;
+}
+
+function strictlySplitByteSequence(buf: Uint8Array, cp: any) {
+ const list = [];
+ let last = 0;
+ let i = buf.indexOf(cp);
+ while (i >= 0) {
+ list.push(buf.slice(last, i));
+ last = i + 1;
+ i = buf.indexOf(cp, last);
+ }
+ if (last !== buf.length) {
+ list.push(buf.slice(last));
+ }
+ return list;
+}
+
+function replaceByteInByteSequence(buf: Uint8Array, from: number, to: number) {
+ let i = buf.indexOf(from);
+ while (i >= 0) {
+ buf[i] = to;
+ i = buf.indexOf(from, i + 1);
+ }
+ return buf;
+}
+
+function p(char: string) {
+ return char.codePointAt(0);
+}
+
+// https://url.spec.whatwg.org/#percent-encode
+function percentEncode(c: number) {
+ let hex = c.toString(16).toUpperCase();
+ if (hex.length === 1) {
+ hex = `0${hex}`;
+ }
+
+ return `%${hex}`;
+}
+
+// https://url.spec.whatwg.org/#percent-decode
+function percentDecodeBytes(input: Uint8Array) {
+ const output = new Uint8Array(input.byteLength);
+ let outputIndex = 0;
+ for (let i = 0; i < input.byteLength; ++i) {
+ const byte = input[i];
+ if (byte !== 0x25) {
+ output[outputIndex++] = byte;
+ } else if (
+ byte === 0x25 &&
+ (!isASCIIHex(input[i + 1]) || !isASCIIHex(input[i + 2]))
+ ) {
+ output[outputIndex++] = byte;
+ } else {
+ const bytePoint = parseInt(
+ String.fromCodePoint(input[i + 1], input[i + 2]),
+ 16,
+ );
+ output[outputIndex++] = bytePoint;
+ i += 2;
+ }
+ }
+
+ return output.slice(0, outputIndex);
+}
+
+// https://url.spec.whatwg.org/#string-percent-decode
+function percentDecodeString(input: string) {
+ const bytes = utf8Encode(input);
+ return percentDecodeBytes(bytes);
+}
+
+// https://url.spec.whatwg.org/#c0-control-percent-encode-set
+function isC0ControlPercentEncode(c: number) {
+ return c <= 0x1f || c > 0x7e;
+}
+
+// https://url.spec.whatwg.org/#fragment-percent-encode-set
+const extraFragmentPercentEncodeSet = new Set([
+ p(" "),
+ p('"'),
+ p("<"),
+ p(">"),
+ p("`"),
+]);
+
+function isFragmentPercentEncode(c: number) {
+ return isC0ControlPercentEncode(c) || extraFragmentPercentEncodeSet.has(c);
+}
+
+// https://url.spec.whatwg.org/#query-percent-encode-set
+const extraQueryPercentEncodeSet = new Set([
+ p(" "),
+ p('"'),
+ p("#"),
+ p("<"),
+ p(">"),
+]);
+
+function isQueryPercentEncode(c: number) {
+ return isC0ControlPercentEncode(c) || extraQueryPercentEncodeSet.has(c);
+}
+
+// https://url.spec.whatwg.org/#special-query-percent-encode-set
+function isSpecialQueryPercentEncode(c: number) {
+ return isQueryPercentEncode(c) || c === p("'");
+}
+
+// https://url.spec.whatwg.org/#path-percent-encode-set
+const extraPathPercentEncodeSet = new Set([p("?"), p("`"), p("{"), p("}")]);
+function isPathPercentEncode(c: number) {
+ return isQueryPercentEncode(c) || extraPathPercentEncodeSet.has(c);
+}
+
+// https://url.spec.whatwg.org/#userinfo-percent-encode-set
+const extraUserinfoPercentEncodeSet = new Set([
+ p("/"),
+ p(":"),
+ p(";"),
+ p("="),
+ p("@"),
+ p("["),
+ p("\\"),
+ p("]"),
+ p("^"),
+ p("|"),
+]);
+function isUserinfoPercentEncode(c: number) {
+ return isPathPercentEncode(c) || extraUserinfoPercentEncodeSet.has(c);
+}
+
+// https://url.spec.whatwg.org/#component-percent-encode-set
+const extraComponentPercentEncodeSet = new Set([
+ p("$"),
+ p("%"),
+ p("&"),
+ p("+"),
+ p(","),
+]);
+function isComponentPercentEncode(c: number) {
+ return isUserinfoPercentEncode(c) || extraComponentPercentEncodeSet.has(c);
+}
+
+// https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set
+const extraURLEncodedPercentEncodeSet = new Set([
+ p("!"),
+ p("'"),
+ p("("),
+ p(")"),
+ p("~"),
+]);
+
+function isURLEncodedPercentEncode(c: number) {
+ return isComponentPercentEncode(c) || extraURLEncodedPercentEncodeSet.has(c);
+}
+
+// https://url.spec.whatwg.org/#code-point-percent-encode-after-encoding
+// https://url.spec.whatwg.org/#utf-8-percent-encode
+// Assuming encoding is always utf-8 allows us to trim one of the logic branches. TODO: support encoding.
+// The "-Internal" variant here has code points as JS strings. The external version used by other files has code points
+// as JS numbers, like the rest of the codebase.
+function utf8PercentEncodeCodePointInternal(
+ codePoint: string,
+ percentEncodePredicate: (arg0: number) => any,
+) {
+ const bytes = utf8Encode(codePoint);
+ let output = "";
+ for (const byte of bytes) {
+ // Our percentEncodePredicate operates on bytes, not code points, so this is slightly different from the spec.
+ if (!percentEncodePredicate(byte)) {
+ output += String.fromCharCode(byte);
+ } else {
+ output += percentEncode(byte);
+ }
+ }
+
+ return output;
+}
+
+function utf8PercentEncodeCodePoint(
+ codePoint: number,
+ percentEncodePredicate: (arg0: number) => any,
+) {
+ return utf8PercentEncodeCodePointInternal(
+ String.fromCodePoint(codePoint),
+ percentEncodePredicate,
+ );
+}
+
+// https://url.spec.whatwg.org/#string-percent-encode-after-encoding
+// https://url.spec.whatwg.org/#string-utf-8-percent-encode
+function utf8PercentEncodeString(
+ input: string,
+ percentEncodePredicate: {
+ (c: number): boolean;
+ (c: number): boolean;
+ (arg0: number): any;
+ },
+ spaceAsPlus = false,
+) {
+ let output = "";
+ for (const codePoint of input) {
+ if (spaceAsPlus && codePoint === " ") {
+ output += "+";
+ } else {
+ output += utf8PercentEncodeCodePointInternal(
+ codePoint,
+ percentEncodePredicate,
+ );
+ }
+ }
+ return output;
+}
+
+// Note that we take code points as JS numbers, not JS strings.
+
+function isASCIIDigit(c: number) {
+ return c >= 0x30 && c <= 0x39;
+}
+
+function isASCIIAlpha(c: number) {
+ return (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a);
+}
+
+function isASCIIAlphanumeric(c: number) {
+ return isASCIIAlpha(c) || isASCIIDigit(c);
+}
+
+function isASCIIHex(c: number) {
+ return (
+ isASCIIDigit(c) || (c >= 0x41 && c <= 0x46) || (c >= 0x61 && c <= 0x66)
+ );
+}
+
+export class URLSearchParamsImpl {
+ _list: any[];
+ _url: any;
+ constructor(init: any, { doNotStripQMark = false }: any = {}) {
+ this._list = [];
+ this._url = null;
+
+ if (!doNotStripQMark && typeof init === "string" && init[0] === "?") {
+ init = init.slice(1);
+ }
+
+ if (Array.isArray(init)) {
+ for (const pair of init) {
+ if (pair.length !== 2) {
+ throw new TypeError(
+ "Failed to construct 'URLSearchParams': parameter 1 sequence's element does not " +
+ "contain exactly two elements.",
+ );
+ }
+ this._list.push([pair[0], pair[1]]);
+ }
+ } else if (
+ typeof init === "object" &&
+ Object.getPrototypeOf(init) === null
+ ) {
+ for (const name of Object.keys(init)) {
+ const value = init[name];
+ this._list.push([name, value]);
+ }
+ } else {
+ this._list = parseUrlencodedString(init);
+ }
+ }
+
+ _updateSteps() {
+ if (this._url !== null) {
+ let query: string | null = serializeUrlencoded(this._list);
+ if (query === "") {
+ query = null;
+ }
+ this._url._url.query = query;
+ }
+ }
+
+ append(name: string, value: string) {
+ this._list.push([name, value]);
+ this._updateSteps();
+ }
+
+ delete(name: string) {
+ let i = 0;
+ while (i < this._list.length) {
+ if (this._list[i][0] === name) {
+ this._list.splice(i, 1);
+ } else {
+ i++;
+ }
+ }
+ this._updateSteps();
+ }
+
+ get(name: string) {
+ for (const tuple of this._list) {
+ if (tuple[0] === name) {
+ return tuple[1];
+ }
+ }
+ return null;
+ }
+
+ getAll(name: string) {
+ const output = [];
+ for (const tuple of this._list) {
+ if (tuple[0] === name) {
+ output.push(tuple[1]);
+ }
+ }
+ return output;
+ }
+
+ forEach(
+ callbackfn: (
+ value: string,
+ key: string,
+ parent: URLSearchParamsImpl,
+ ) => void,
+ thisArg?: any,
+ ): void {
+ for (const tuple of this._list) {
+ callbackfn.call(thisArg, tuple[1], tuple[0], this);
+ }
+ }
+
+ has(name: string) {
+ for (const tuple of this._list) {
+ if (tuple[0] === name) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ set(name: string, value: string) {
+ let found = false;
+ let i = 0;
+ while (i < this._list.length) {
+ if (this._list[i][0] === name) {
+ if (found) {
+ this._list.splice(i, 1);
+ } else {
+ found = true;
+ this._list[i][1] = value;
+ i++;
+ }
+ } else {
+ i++;
+ }
+ }
+ if (!found) {
+ this._list.push([name, value]);
+ }
+ this._updateSteps();
+ }
+
+ sort() {
+ this._list.sort((a, b) => {
+ if (a[0] < b[0]) {
+ return -1;
+ }
+ if (a[0] > b[0]) {
+ return 1;
+ }
+ return 0;
+ });
+
+ this._updateSteps();
+ }
+
+ [Symbol.iterator]() {
+ return this._list[Symbol.iterator]();
+ }
+
+ toString() {
+ return serializeUrlencoded(this._list);
+ }
+}
+
+const specialSchemes = {
+ ftp: 21,
+ file: null,
+ http: 80,
+ https: 443,
+ ws: 80,
+ wss: 443,
+} as { [x: string]: number | null };
+
+const failure = Symbol("failure");
+
+function countSymbols(str: any) {
+ return [...str].length;
+}
+
+function at(input: any, idx: any) {
+ const c = input[idx];
+ return isNaN(c) ? undefined : String.fromCodePoint(c);
+}
+
+function isSingleDot(buffer: string) {
+ return buffer === "." || buffer.toLowerCase() === "%2e";
+}
+
+function isDoubleDot(buffer: string) {
+ buffer = buffer.toLowerCase();
+ return (
+ buffer === ".." ||
+ buffer === "%2e." ||
+ buffer === ".%2e" ||
+ buffer === "%2e%2e"
+ );
+}
+
+function isWindowsDriveLetterCodePoints(cp1: number, cp2: number) {
+ return isASCIIAlpha(cp1) && (cp2 === p(":") || cp2 === p("|"));
+}
+
+function isWindowsDriveLetterString(string: string) {
+ return (
+ string.length === 2 &&
+ isASCIIAlpha(string.codePointAt(0)!) &&
+ (string[1] === ":" || string[1] === "|")
+ );
+}
+
+function isNormalizedWindowsDriveLetterString(string: string) {
+ return (
+ string.length === 2 &&
+ isASCIIAlpha(string.codePointAt(0)!) &&
+ string[1] === ":"
+ );
+}
+
+function containsForbiddenHostCodePoint(string: string) {
+ return (
+ string.search(
+ /\u0000|\u0009|\u000A|\u000D|\u0020|#|\/|:|<|>|\?|@|\[|\\|\]|\^|\|/u,
+ ) !== -1
+ );
+}
+
+function containsForbiddenDomainCodePoint(string: string) {
+ return (
+ containsForbiddenHostCodePoint(string) ||
+ string.search(/[\u0000-\u001F]|%|\u007F/u) !== -1
+ );
+}
+
+function isSpecialScheme(scheme: string) {
+ return specialSchemes[scheme] !== undefined;
+}
+
+function isSpecial(url: any) {
+ return isSpecialScheme(url.scheme);
+}
+
+function isNotSpecial(url: UrlObj) {
+ return !isSpecialScheme(url.scheme);
+}
+
+function defaultPort(scheme: string) {
+ return specialSchemes[scheme];
+}
+
+function parseIPv4Number(input: string) {
+ if (input === "") {
+ return failure;
+ }
+
+ let R = 10;
+
+ if (
+ input.length >= 2 &&
+ input.charAt(0) === "0" &&
+ input.charAt(1).toLowerCase() === "x"
+ ) {
+ input = input.substring(2);
+ R = 16;
+ } else if (input.length >= 2 && input.charAt(0) === "0") {
+ input = input.substring(1);
+ R = 8;
+ }
+
+ if (input === "") {
+ return 0;
+ }
+
+ let regex = /[^0-7]/u;
+ if (R === 10) {
+ regex = /[^0-9]/u;
+ }
+ if (R === 16) {
+ regex = /[^0-9A-Fa-f]/u;
+ }
+
+ if (regex.test(input)) {
+ return failure;
+ }
+
+ return parseInt(input, R);
+}
+
+function parseIPv4(input: string) {
+ const parts = input.split(".");
+ if (parts[parts.length - 1] === "") {
+ if (parts.length > 1) {
+ parts.pop();
+ }
+ }
+
+ if (parts.length > 4) {
+ return failure;
+ }
+
+ const numbers = [];
+ for (const part of parts) {
+ const n = parseIPv4Number(part);
+ if (n === failure) {
+ return failure;
+ }
+
+ numbers.push(n);
+ }
+
+ for (let i = 0; i < numbers.length - 1; ++i) {
+ if (numbers[i] > 255) {
+ return failure;
+ }
+ }
+ if (numbers[numbers.length - 1] >= 256 ** (5 - numbers.length)) {
+ return failure;
+ }
+
+ let ipv4 = numbers.pop();
+ let counter = 0;
+
+ for (const n of numbers) {
+ ipv4! += n * 256 ** (3 - counter);
+ ++counter;
+ }
+
+ return ipv4;
+}
+
+function serializeIPv4(address: number) {
+ let output = "";
+ let n = address;
+
+ for (let i = 1; i <= 4; ++i) {
+ output = String(n % 256) + output;
+ if (i !== 4) {
+ output = `.${output}`;
+ }
+ n = Math.floor(n / 256);
+ }
+
+ return output;
+}
+
+function parseIPv6(inputArg: string) {
+ const address = [0, 0, 0, 0, 0, 0, 0, 0];
+ let pieceIndex = 0;
+ let compress = null;
+ let pointer = 0;
+
+ const input = Array.from(inputArg, (c) => c.codePointAt(0));
+
+ if (input[pointer] === p(":")) {
+ if (input[pointer + 1] !== p(":")) {
+ return failure;
+ }
+
+ pointer += 2;
+ ++pieceIndex;
+ compress = pieceIndex;
+ }
+
+ while (pointer < input.length) {
+ if (pieceIndex === 8) {
+ return failure;
+ }
+
+ if (input[pointer] === p(":")) {
+ if (compress !== null) {
+ return failure;
+ }
+ ++pointer;
+ ++pieceIndex;
+ compress = pieceIndex;
+ continue;
+ }
+
+ let value = 0;
+ let length = 0;
+
+ while (length < 4 && isASCIIHex(input[pointer]!)) {
+ value = value * 0x10 + parseInt(at(input, pointer)!, 16);
+ ++pointer;
+ ++length;
+ }
+
+ if (input[pointer] === p(".")) {
+ if (length === 0) {
+ return failure;
+ }
+
+ pointer -= length;
+
+ if (pieceIndex > 6) {
+ return failure;
+ }
+
+ let numbersSeen = 0;
+
+ while (input[pointer] !== undefined) {
+ let ipv4Piece = null;
+
+ if (numbersSeen > 0) {
+ if (input[pointer] === p(".") && numbersSeen < 4) {
+ ++pointer;
+ } else {
+ return failure;
+ }
+ }
+
+ if (!isASCIIDigit(input[pointer]!)) {
+ return failure;
+ }
+
+ while (isASCIIDigit(input[pointer]!)) {
+ const number = parseInt(at(input, pointer)!);
+ if (ipv4Piece === null) {
+ ipv4Piece = number;
+ } else if (ipv4Piece === 0) {
+ return failure;
+ } else {
+ ipv4Piece = ipv4Piece * 10 + number;
+ }
+ if (ipv4Piece > 255) {
+ return failure;
+ }
+ ++pointer;
+ }
+
+ address[pieceIndex] = address[pieceIndex] * 0x100 + ipv4Piece!;
+
+ ++numbersSeen;
+
+ if (numbersSeen === 2 || numbersSeen === 4) {
+ ++pieceIndex;
+ }
+ }
+
+ if (numbersSeen !== 4) {
+ return failure;
+ }
+
+ break;
+ } else if (input[pointer] === p(":")) {
+ ++pointer;
+ if (input[pointer] === undefined) {
+ return failure;
+ }
+ } else if (input[pointer] !== undefined) {
+ return failure;
+ }
+
+ address[pieceIndex] = value;
+ ++pieceIndex;
+ }
+
+ if (compress !== null) {
+ let swaps = pieceIndex - compress;
+ pieceIndex = 7;
+ while (pieceIndex !== 0 && swaps > 0) {
+ const temp = address[compress + swaps - 1];
+ address[compress + swaps - 1] = address[pieceIndex];
+ address[pieceIndex] = temp;
+ --pieceIndex;
+ --swaps;
+ }
+ } else if (compress === null && pieceIndex !== 8) {
+ return failure;
+ }
+
+ return address;
+}
+
+function serializeIPv6(address: any[]) {
+ let output = "";
+ const compress = findLongestZeroSequence(address);
+ let ignore0 = false;
+
+ for (let pieceIndex = 0; pieceIndex <= 7; ++pieceIndex) {
+ if (ignore0 && address[pieceIndex] === 0) {
+ continue;
+ } else if (ignore0) {
+ ignore0 = false;
+ }
+
+ if (compress === pieceIndex) {
+ const separator = pieceIndex === 0 ? "::" : ":";
+ output += separator;
+ ignore0 = true;
+ continue;
+ }
+
+ output += address[pieceIndex].toString(16);
+
+ if (pieceIndex !== 7) {
+ output += ":";
+ }
+ }
+
+ return output;
+}
+
+function parseHost(input: string, isNotSpecialArg = false) {
+ if (input[0] === "[") {
+ if (input[input.length - 1] !== "]") {
+ return failure;
+ }
+
+ return parseIPv6(input.substring(1, input.length - 1));
+ }
+
+ if (isNotSpecialArg) {
+ return parseOpaqueHost(input);
+ }
+
+ const domain = utf8DecodeWithoutBOM(percentDecodeString(input));
+ const asciiDomain = domainToASCII(domain);
+ if (asciiDomain === failure) {
+ return failure;
+ }
+
+ if (containsForbiddenDomainCodePoint(asciiDomain)) {
+ return failure;
+ }
+
+ if (endsInANumber(asciiDomain)) {
+ return parseIPv4(asciiDomain);
+ }
+
+ return asciiDomain;
+}
+
+function endsInANumber(input: string) {
+ const parts = input.split(".");
+ if (parts[parts.length - 1] === "") {
+ if (parts.length === 1) {
+ return false;
+ }
+ parts.pop();
+ }
+
+ const last = parts[parts.length - 1];
+ if (parseIPv4Number(last) !== failure) {
+ return true;
+ }
+
+ if (/^[0-9]+$/u.test(last)) {
+ return true;
+ }
+
+ return false;
+}
+
+function parseOpaqueHost(input: string) {
+ if (containsForbiddenHostCodePoint(input)) {
+ return failure;
+ }
+
+ return utf8PercentEncodeString(input, isC0ControlPercentEncode);
+}
+
+function findLongestZeroSequence(arr: number[]) {
+ let maxIdx = null;
+ let maxLen = 1; // only find elements > 1
+ let currStart = null;
+ let currLen = 0;
+
+ for (let i = 0; i < arr.length; ++i) {
+ if (arr[i] !== 0) {
+ if (currLen > maxLen) {
+ maxIdx = currStart;
+ maxLen = currLen;
+ }
+
+ currStart = null;
+ currLen = 0;
+ } else {
+ if (currStart === null) {
+ currStart = i;
+ }
+ ++currLen;
+ }
+ }
+
+ // if trailing zeros
+ if (currLen > maxLen) {
+ return currStart;
+ }
+
+ return maxIdx;
+}
+
+function serializeHost(host: number | number[] | string) {
+ if (typeof host === "number") {
+ return serializeIPv4(host);
+ }
+
+ // IPv6 serializer
+ if (host instanceof Array) {
+ return `[${serializeIPv6(host)}]`;
+ }
+
+ return host;
+}
+
+import { punycode } from "./punycode.js";
+
+function domainToASCII(domain: string, beStrict = false) {
+ // const result = tr46.toASCII(domain, {
+ // checkBidi: true,
+ // checkHyphens: false,
+ // checkJoiners: true,
+ // useSTD3ASCIIRules: beStrict,
+ // verifyDNSLength: beStrict,
+ // });
+ let result;
+ try {
+ result = punycode.toASCII(domain);
+ } catch (e) {
+ return failure;
+ }
+ if (result === null || result === "") {
+ return failure;
+ }
+ return result;
+}
+
+function trimControlChars(url: string) {
+ return url.replace(/^[\u0000-\u001F\u0020]+|[\u0000-\u001F\u0020]+$/gu, "");
+}
+
+function trimTabAndNewline(url: string) {
+ return url.replace(/\u0009|\u000A|\u000D/gu, "");
+}
+
+function shortenPath(url: UrlObj) {
+ const { path } = url;
+ if (path.length === 0) {
+ return;
+ }
+ if (
+ url.scheme === "file" &&
+ path.length === 1 &&
+ isNormalizedWindowsDriveLetter(path[0])
+ ) {
+ return;
+ }
+
+ path.pop();
+}
+
+function includesCredentials(url: UrlObj) {
+ return url.username !== "" || url.password !== "";
+}
+
+function cannotHaveAUsernamePasswordPort(url: UrlObj) {
+ return url.host === null || url.host === "" || url.scheme === "file";
+}
+
+function hasAnOpaquePath(url: UrlObj) {
+ return typeof url.path === "string";
+}
+
+function isNormalizedWindowsDriveLetter(string: string) {
+ return /^[A-Za-z]:$/u.test(string);
+}
+
+export interface UrlObj {
+ scheme: string;
+ username: string;
+ password: string;
+ host: string | number[] | number | null | undefined;
+ port: number | null;
+ path: string[];
+ query: any;
+ fragment: any;
+}
+
+class URLStateMachine {
+ pointer: number;
+ input: number[];
+ base: any;
+ encodingOverride: string;
+ url: UrlObj;
+ state: string;
+ stateOverride: string;
+ failure: boolean;
+ parseError: boolean;
+ buffer: string;
+ atFlag: boolean;
+ arrFlag: boolean;
+ passwordTokenSeenFlag: boolean;
+
+ constructor(
+ input: string,
+ base: any,
+ encodingOverride: string,
+ url: UrlObj,
+ stateOverride: string,
+ ) {
+ this.pointer = 0;
+ this.base = base || null;
+ this.encodingOverride = encodingOverride || "utf-8";
+ this.url = url;
+ this.failure = false;
+ this.parseError = false;
+
+ if (!this.url) {
+ this.url = {
+ scheme: "",
+ username: "",
+ password: "",
+ host: null,
+ port: null,
+ path: [],
+ query: null,
+ fragment: null,
+ };
+
+ const res = trimControlChars(input);
+ if (res !== input) {
+ this.parseError = true;
+ }
+ input = res;
+ }
+
+ const res = trimTabAndNewline(input);
+ if (res !== input) {
+ this.parseError = true;
+ }
+ input = res;
+
+ this.state = stateOverride || "scheme start";
+
+ this.buffer = "";
+ this.atFlag = false;
+ this.arrFlag = false;
+ this.passwordTokenSeenFlag = false;
+
+ this.input = Array.from(input, (c) => c.codePointAt(0)!);
+
+ for (; this.pointer <= this.input.length; ++this.pointer) {
+ const c = this.input[this.pointer];
+ const cStr = isNaN(c) ? undefined : String.fromCodePoint(c);
+
+ // exec state machine
+ const ret = this.table[`parse ${this.state}`].call(this, c, cStr!);
+ if (!ret) {
+ break; // terminate algorithm
+ } else if (ret === failure) {
+ this.failure = true;
+ break;
+ }
+ }
+ }
+
+ table = {
+ "parse scheme start": this.parseSchemeStart,
+ "parse scheme": this.parseScheme,
+ "parse no scheme": this.parseNoScheme,
+ "parse special relative or authority": this.parseSpecialRelativeOrAuthority,
+ "parse path or authority": this.parsePathOrAuthority,
+ "parse relative": this.parseRelative,
+ "parse relative slash": this.parseRelativeSlash,
+ "parse special authority slashes": this.parseSpecialAuthoritySlashes,
+ "parse special authority ignore slashes":
+ this.parseSpecialAuthorityIgnoreSlashes,
+ "parse authority": this.parseAuthority,
+ "parse host": this.parseHostName,
+ "parse hostname": this.parseHostName /* intentional duplication */,
+ "parse port": this.parsePort,
+ "parse file": this.parseFile,
+ "parse file slash": this.parseFileSlash,
+ "parse file host": this.parseFileHost,
+ "parse path start": this.parsePathStart,
+ "parse path": this.parsePath,
+ "parse opaque path": this.parseOpaquePath,
+ "parse query": this.parseQuery,
+ "parse fragment": this.parseFragment,
+ } as { [x: string]: (c: number, cStr: string) => any };
+
+ parseSchemeStart(c: number, cStr: string) {
+ if (isASCIIAlpha(c)) {
+ this.buffer += cStr.toLowerCase();
+ this.state = "scheme";
+ } else if (!this.stateOverride) {
+ this.state = "no scheme";
+ --this.pointer;
+ } else {
+ this.parseError = true;
+ return failure;
+ }
+
+ return true;
+ }
+
+ parseScheme(c: number, cStr: string) {
+ if (
+ isASCIIAlphanumeric(c) ||
+ c === p("+") ||
+ c === p("-") ||
+ c === p(".")
+ ) {
+ this.buffer += cStr.toLowerCase();
+ } else if (c === p(":")) {
+ if (this.stateOverride) {
+ if (isSpecial(this.url) && !isSpecialScheme(this.buffer)) {
+ return false;
+ }
+
+ if (!isSpecial(this.url) && isSpecialScheme(this.buffer)) {
+ return false;
+ }
+
+ if (
+ (includesCredentials(this.url) || this.url.port !== null) &&
+ this.buffer === "file"
+ ) {
+ return false;
+ }
+
+ if (this.url.scheme === "file" && this.url.host === "") {
+ return false;
+ }
+ }
+ this.url.scheme = this.buffer;
+ if (this.stateOverride) {
+ if (this.url.port === defaultPort(this.url.scheme)) {
+ this.url.port = null;
+ }
+ return false;
+ }
+ this.buffer = "";
+ if (this.url.scheme === "file") {
+ if (
+ this.input[this.pointer + 1] !== p("/") ||
+ this.input[this.pointer + 2] !== p("/")
+ ) {
+ this.parseError = true;
+ }
+ this.state = "file";
+ } else if (
+ isSpecial(this.url) &&
+ this.base !== null &&
+ this.base.scheme === this.url.scheme
+ ) {
+ this.state = "special relative or authority";
+ } else if (isSpecial(this.url)) {
+ this.state = "special authority slashes";
+ } else if (this.input[this.pointer + 1] === p("/")) {
+ this.state = "path or authority";
+ ++this.pointer;
+ } else {
+ this.url.path = [""];
+ this.state = "opaque path";
+ }
+ } else if (!this.stateOverride) {
+ this.buffer = "";
+ this.state = "no scheme";
+ this.pointer = -1;
+ } else {
+ this.parseError = true;
+ return failure;
+ }
+
+ return true;
+ }
+
+ parseNoScheme(c: number) {
+ if (this.base === null || (hasAnOpaquePath(this.base) && c !== p("#"))) {
+ return failure;
+ } else if (hasAnOpaquePath(this.base) && c === p("#")) {
+ this.url.scheme = this.base.scheme;
+ this.url.path = this.base.path;
+ this.url.query = this.base.query;
+ this.url.fragment = "";
+ this.state = "fragment";
+ } else if (this.base.scheme === "file") {
+ this.state = "file";
+ --this.pointer;
+ } else {
+ this.state = "relative";
+ --this.pointer;
+ }
+
+ return true;
+ }
+
+ parseSpecialRelativeOrAuthority(c: number) {
+ if (c === p("/") && this.input[this.pointer + 1] === p("/")) {
+ this.state = "special authority ignore slashes";
+ ++this.pointer;
+ } else {
+ this.parseError = true;
+ this.state = "relative";
+ --this.pointer;
+ }
+
+ return true;
+ }
+
+ parsePathOrAuthority(c: number) {
+ if (c === p("/")) {
+ this.state = "authority";
+ } else {
+ this.state = "path";
+ --this.pointer;
+ }
+
+ return true;
+ }
+
+ parseRelative(c: number) {
+ this.url.scheme = this.base.scheme;
+ if (c === p("/")) {
+ this.state = "relative slash";
+ } else if (isSpecial(this.url) && c === p("\\")) {
+ this.parseError = true;
+ this.state = "relative slash";
+ } else {
+ this.url.username = this.base.username;
+ this.url.password = this.base.password;
+ this.url.host = this.base.host;
+ this.url.port = this.base.port;
+ this.url.path = this.base.path.slice();
+ this.url.query = this.base.query;
+ if (c === p("?")) {
+ this.url.query = "";
+ this.state = "query";
+ } else if (c === p("#")) {
+ this.url.fragment = "";
+ this.state = "fragment";
+ } else if (!isNaN(c)) {
+ this.url.query = null;
+ this.url.path.pop();
+ this.state = "path";
+ --this.pointer;
+ }
+ }
+
+ return true;
+ }
+
+ parseRelativeSlash(c: number) {
+ if (isSpecial(this.url) && (c === p("/") || c === p("\\"))) {
+ if (c === p("\\")) {
+ this.parseError = true;
+ }
+ this.state = "special authority ignore slashes";
+ } else if (c === p("/")) {
+ this.state = "authority";
+ } else {
+ this.url.username = this.base.username;
+ this.url.password = this.base.password;
+ this.url.host = this.base.host;
+ this.url.port = this.base.port;
+ this.state = "path";
+ --this.pointer;
+ }
+
+ return true;
+ }
+
+ parseSpecialAuthoritySlashes(c: number) {
+ if (c === p("/") && this.input[this.pointer + 1] === p("/")) {
+ this.state = "special authority ignore slashes";
+ ++this.pointer;
+ } else {
+ this.parseError = true;
+ this.state = "special authority ignore slashes";
+ --this.pointer;
+ }
+
+ return true;
+ }
+
+ parseSpecialAuthorityIgnoreSlashes(c: number) {
+ if (c !== p("/") && c !== p("\\")) {
+ this.state = "authority";
+ --this.pointer;
+ } else {
+ this.parseError = true;
+ }
+
+ return true;
+ }
+
+ parseAuthority(c: number, cStr: string) {
+ if (c === p("@")) {
+ this.parseError = true;
+ if (this.atFlag) {
+ this.buffer = `%40${this.buffer}`;
+ }
+ this.atFlag = true;
+
+ // careful, this is based on buffer and has its own pointer (this.pointer != pointer) and inner chars
+ const len = countSymbols(this.buffer);
+ for (let pointer = 0; pointer < len; ++pointer) {
+ const codePoint = this.buffer.codePointAt(pointer);
+
+ if (codePoint === p(":") && !this.passwordTokenSeenFlag) {
+ this.passwordTokenSeenFlag = true;
+ continue;
+ }
+ const encodedCodePoints = utf8PercentEncodeCodePoint(
+ codePoint!,
+ isUserinfoPercentEncode,
+ );
+ if (this.passwordTokenSeenFlag) {
+ this.url.password += encodedCodePoints;
+ } else {
+ this.url.username += encodedCodePoints;
+ }
+ }
+ this.buffer = "";
+ } else if (
+ isNaN(c) ||
+ c === p("/") ||
+ c === p("?") ||
+ c === p("#") ||
+ (isSpecial(this.url) && c === p("\\"))
+ ) {
+ if (this.atFlag && this.buffer === "") {
+ this.parseError = true;
+ return failure;
+ }
+ this.pointer -= countSymbols(this.buffer) + 1;
+ this.buffer = "";
+ this.state = "host";
+ } else {
+ this.buffer += cStr;
+ }
+
+ return true;
+ }
+
+ parseHostName(c: number, cStr: string) {
+ if (this.stateOverride && this.url.scheme === "file") {
+ --this.pointer;
+ this.state = "file host";
+ } else if (c === p(":") && !this.arrFlag) {
+ if (this.buffer === "") {
+ this.parseError = true;
+ return failure;
+ }
+
+ if (this.stateOverride === "hostname") {
+ return false;
+ }
+
+ const host = parseHost(this.buffer, isNotSpecial(this.url));
+ if (host === failure) {
+ return failure;
+ }
+
+ this.url.host = host;
+ this.buffer = "";
+ this.state = "port";
+ } else if (
+ isNaN(c) ||
+ c === p("/") ||
+ c === p("?") ||
+ c === p("#") ||
+ (isSpecial(this.url) && c === p("\\"))
+ ) {
+ --this.pointer;
+ if (isSpecial(this.url) && this.buffer === "") {
+ this.parseError = true;
+ return failure;
+ } else if (
+ this.stateOverride &&
+ this.buffer === "" &&
+ (includesCredentials(this.url) || this.url.port !== null)
+ ) {
+ this.parseError = true;
+ return false;
+ }
+
+ const host = parseHost(this.buffer, isNotSpecial(this.url));
+ if (host === failure) {
+ return failure;
+ }
+
+ this.url.host = host;
+ this.buffer = "";
+ this.state = "path start";
+ if (this.stateOverride) {
+ return false;
+ }
+ } else {
+ if (c === p("[")) {
+ this.arrFlag = true;
+ } else if (c === p("]")) {
+ this.arrFlag = false;
+ }
+ this.buffer += cStr;
+ }
+
+ return true;
+ }
+
+ parsePort(c: number, cStr: any) {
+ if (isASCIIDigit(c)) {
+ this.buffer += cStr;
+ } else if (
+ isNaN(c) ||
+ c === p("/") ||
+ c === p("?") ||
+ c === p("#") ||
+ (isSpecial(this.url) && c === p("\\")) ||
+ this.stateOverride
+ ) {
+ if (this.buffer !== "") {
+ const port = parseInt(this.buffer);
+ if (port > 2 ** 16 - 1) {
+ this.parseError = true;
+ return failure;
+ }
+ this.url.port = port === defaultPort(this.url.scheme) ? null : port;
+ this.buffer = "";
+ }
+ if (this.stateOverride) {
+ return false;
+ }
+ this.state = "path start";
+ --this.pointer;
+ } else {
+ this.parseError = true;
+ return failure;
+ }
+
+ return true;
+ }
+
+ parseFile(c: number) {
+ this.url.scheme = "file";
+ this.url.host = "";
+
+ if (c === p("/") || c === p("\\")) {
+ if (c === p("\\")) {
+ this.parseError = true;
+ }
+ this.state = "file slash";
+ } else if (this.base !== null && this.base.scheme === "file") {
+ this.url.host = this.base.host;
+ this.url.path = this.base.path.slice();
+ this.url.query = this.base.query;
+ if (c === p("?")) {
+ this.url.query = "";
+ this.state = "query";
+ } else if (c === p("#")) {
+ this.url.fragment = "";
+ this.state = "fragment";
+ } else if (!isNaN(c)) {
+ this.url.query = null;
+ if (!startsWithWindowsDriveLetter(this.input, this.pointer)) {
+ shortenPath(this.url);
+ } else {
+ this.parseError = true;
+ this.url.path = [];
+ }
+
+ this.state = "path";
+ --this.pointer;
+ }
+ } else {
+ this.state = "path";
+ --this.pointer;
+ }
+
+ return true;
+ }
+
+ parseFileSlash(c: number) {
+ if (c === p("/") || c === p("\\")) {
+ if (c === p("\\")) {
+ this.parseError = true;
+ }
+ this.state = "file host";
+ } else {
+ if (this.base !== null && this.base.scheme === "file") {
+ if (
+ !startsWithWindowsDriveLetter(this.input, this.pointer) &&
+ isNormalizedWindowsDriveLetterString(this.base.path[0])
+ ) {
+ this.url.path.push(this.base.path[0]);
+ }
+ this.url.host = this.base.host;
+ }
+ this.state = "path";
+ --this.pointer;
+ }
+
+ return true;
+ }
+
+ parseFileHost(c: number, cStr: string) {
+ if (
+ isNaN(c) ||
+ c === p("/") ||
+ c === p("\\") ||
+ c === p("?") ||
+ c === p("#")
+ ) {
+ --this.pointer;
+ if (!this.stateOverride && isWindowsDriveLetterString(this.buffer)) {
+ this.parseError = true;
+ this.state = "path";
+ } else if (this.buffer === "") {
+ this.url.host = "";
+ if (this.stateOverride) {
+ return false;
+ }
+ this.state = "path start";
+ } else {
+ let host = parseHost(this.buffer, isNotSpecial(this.url));
+ if (host === failure) {
+ return failure;
+ }
+ if (host === "localhost") {
+ host = "";
+ }
+ this.url.host = host as any;
+
+ if (this.stateOverride) {
+ return false;
+ }
+
+ this.buffer = "";
+ this.state = "path start";
+ }
+ } else {
+ this.buffer += cStr;
+ }
+
+ return true;
+ }
+
+ parsePathStart(c: number) {
+ if (isSpecial(this.url)) {
+ if (c === p("\\")) {
+ this.parseError = true;
+ }
+ this.state = "path";
+
+ if (c !== p("/") && c !== p("\\")) {
+ --this.pointer;
+ }
+ } else if (!this.stateOverride && c === p("?")) {
+ this.url.query = "";
+ this.state = "query";
+ } else if (!this.stateOverride && c === p("#")) {
+ this.url.fragment = "";
+ this.state = "fragment";
+ } else if (c !== undefined) {
+ this.state = "path";
+ if (c !== p("/")) {
+ --this.pointer;
+ }
+ } else if (this.stateOverride && this.url.host === null) {
+ this.url.path.push("");
+ }
+
+ return true;
+ }
+
+ parsePath(c: number) {
+ if (
+ isNaN(c) ||
+ c === p("/") ||
+ (isSpecial(this.url) && c === p("\\")) ||
+ (!this.stateOverride && (c === p("?") || c === p("#")))
+ ) {
+ if (isSpecial(this.url) && c === p("\\")) {
+ this.parseError = true;
+ }
+
+ if (isDoubleDot(this.buffer)) {
+ shortenPath(this.url);
+ if (c !== p("/") && !(isSpecial(this.url) && c === p("\\"))) {
+ this.url.path.push("");
+ }
+ } else if (
+ isSingleDot(this.buffer) &&
+ c !== p("/") &&
+ !(isSpecial(this.url) && c === p("\\"))
+ ) {
+ this.url.path.push("");
+ } else if (!isSingleDot(this.buffer)) {
+ if (
+ this.url.scheme === "file" &&
+ this.url.path.length === 0 &&
+ isWindowsDriveLetterString(this.buffer)
+ ) {
+ this.buffer = `${this.buffer[0]}:`;
+ }
+ this.url.path.push(this.buffer);
+ }
+ this.buffer = "";
+ if (c === p("?")) {
+ this.url.query = "";
+ this.state = "query";
+ }
+ if (c === p("#")) {
+ this.url.fragment = "";
+ this.state = "fragment";
+ }
+ } else {
+ // TODO: If c is not a URL code point and not "%", parse error.
+
+ if (
+ c === p("%") &&
+ (!isASCIIHex(this.input[this.pointer + 1]) ||
+ !isASCIIHex(this.input[this.pointer + 2]))
+ ) {
+ this.parseError = true;
+ }
+
+ this.buffer += utf8PercentEncodeCodePoint(c, isPathPercentEncode);
+ }
+
+ return true;
+ }
+
+ parseOpaquePath(c: number) {
+ if (c === p("?")) {
+ this.url.query = "";
+ this.state = "query";
+ } else if (c === p("#")) {
+ this.url.fragment = "";
+ this.state = "fragment";
+ } else {
+ // TODO: Add: not a URL code point
+ if (!isNaN(c) && c !== p("%")) {
+ this.parseError = true;
+ }
+
+ if (
+ c === p("%") &&
+ (!isASCIIHex(this.input[this.pointer + 1]) ||
+ !isASCIIHex(this.input[this.pointer + 2]))
+ ) {
+ this.parseError = true;
+ }
+
+ if (!isNaN(c)) {
+ // @ts-ignore
+ this.url.path += utf8PercentEncodeCodePoint(
+ c,
+ isC0ControlPercentEncode,
+ );
+ }
+ }
+
+ return true;
+ }
+
+ parseQuery(c: number, cStr: string) {
+ if (
+ !isSpecial(this.url) ||
+ this.url.scheme === "ws" ||
+ this.url.scheme === "wss"
+ ) {
+ this.encodingOverride = "utf-8";
+ }
+
+ if ((!this.stateOverride && c === p("#")) || isNaN(c)) {
+ const queryPercentEncodePredicate = isSpecial(this.url)
+ ? isSpecialQueryPercentEncode
+ : isQueryPercentEncode;
+ this.url.query += utf8PercentEncodeString(
+ this.buffer,
+ queryPercentEncodePredicate,
+ );
+
+ this.buffer = "";
+
+ if (c === p("#")) {
+ this.url.fragment = "";
+ this.state = "fragment";
+ }
+ } else if (!isNaN(c)) {
+ // TODO: If c is not a URL code point and not "%", parse error.
+
+ if (
+ c === p("%") &&
+ (!isASCIIHex(this.input[this.pointer + 1]) ||
+ !isASCIIHex(this.input[this.pointer + 2]))
+ ) {
+ this.parseError = true;
+ }
+
+ this.buffer += cStr;
+ }
+
+ return true;
+ }
+
+ parseFragment(c: number) {
+ if (!isNaN(c)) {
+ // TODO: If c is not a URL code point and not "%", parse error.
+ if (
+ c === p("%") &&
+ (!isASCIIHex(this.input[this.pointer + 1]) ||
+ !isASCIIHex(this.input[this.pointer + 2]))
+ ) {
+ this.parseError = true;
+ }
+
+ this.url.fragment += utf8PercentEncodeCodePoint(
+ c,
+ isFragmentPercentEncode,
+ );
+ }
+
+ return true;
+ }
+}
+
+const fileOtherwiseCodePoints = new Set([p("/"), p("\\"), p("?"), p("#")]);
+
+function startsWithWindowsDriveLetter(input: number[], pointer: number) {
+ const length = input.length - pointer;
+ return (
+ length >= 2 &&
+ isWindowsDriveLetterCodePoints(input[pointer], input[pointer + 1]) &&
+ (length === 2 || fileOtherwiseCodePoints.has(input[pointer + 2]))
+ );
+}
+
+function serializeURL(url: any, excludeFragment?: boolean) {
+ let output = `${url.scheme}:`;
+ if (url.host !== null) {
+ output += "//";
+
+ if (url.username !== "" || url.password !== "") {
+ output += url.username;
+ if (url.password !== "") {
+ output += `:${url.password}`;
+ }
+ output += "@";
+ }
+
+ output += serializeHost(url.host);
+
+ if (url.port !== null) {
+ output += `:${url.port}`;
+ }
+ }
+
+ if (
+ url.host === null &&
+ !hasAnOpaquePath(url) &&
+ url.path.length > 1 &&
+ url.path[0] === ""
+ ) {
+ output += "/.";
+ }
+ output += serializePath(url);
+
+ if (url.query !== null) {
+ output += `?${url.query}`;
+ }
+
+ if (!excludeFragment && url.fragment !== null) {
+ output += `#${url.fragment}`;
+ }
+
+ return output;
+}
+
+function serializeOrigin(tuple: {
+ scheme: string;
+ port: number;
+ host: number | number[] | string;
+}) {
+ let result = `${tuple.scheme}://`;
+ result += serializeHost(tuple.host);
+
+ if (tuple.port !== null) {
+ result += `:${tuple.port}`;
+ }
+
+ return result;
+}
+
+function serializePath(url: UrlObj): string {
+ if (typeof url.path === "string") {
+ return url.path;
+ }
+
+ let output = "";
+ for (const segment of url.path) {
+ output += `/${segment}`;
+ }
+ return output;
+}
+
+function serializeURLOrigin(url: any): any {
+ // https://url.spec.whatwg.org/#concept-url-origin
+ switch (url.scheme) {
+ case "blob":
+ try {
+ return serializeURLOrigin(parseURL(serializePath(url)));
+ } catch (e) {
+ // serializing an opaque origin returns "null"
+ return "null";
+ }
+ case "ftp":
+ case "http":
+ case "https":
+ case "ws":
+ case "wss":
+ return serializeOrigin({
+ scheme: url.scheme,
+ host: url.host,
+ port: url.port,
+ });
+ case "file":
+ // The spec says:
+ // > Unfortunate as it is, this is left as an exercise to the reader. When in doubt, return a new opaque origin.
+ // Browsers tested so far:
+ // - Chrome says "file://", but treats file: URLs as cross-origin for most (all?) purposes; see e.g.
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=37586
+ // - Firefox says "null", but treats file: URLs as same-origin sometimes based on directory stuff; see
+ // https://developer.mozilla.org/en-US/docs/Archive/Misc_top_level/Same-origin_policy_for_file:_URIs
+ return "null";
+ default:
+ // serializing an opaque origin returns "null"
+ return "null";
+ }
+}
+
+export function basicURLParse(input: string, options?: any) {
+ if (options === undefined) {
+ options = {};
+ }
+
+ const usm = new URLStateMachine(
+ input,
+ options.baseURL,
+ options.encodingOverride,
+ options.url,
+ options.stateOverride,
+ );
+
+ if (usm.failure) {
+ return null;
+ }
+
+ return usm.url;
+}
+
+function setTheUsername(url: UrlObj, username: string) {
+ url.username = utf8PercentEncodeString(username, isUserinfoPercentEncode);
+}
+
+function setThePassword(url: UrlObj, password: string) {
+ url.password = utf8PercentEncodeString(password, isUserinfoPercentEncode);
+}
+
+function serializeInteger(integer: number) {
+ return String(integer);
+}
+
+function parseURL(
+ input: any,
+ options?: { baseURL?: any; encodingOverride?: any },
+) {
+ if (options === undefined) {
+ options = {};
+ }
+
+ // We don't handle blobs, so this just delegates:
+ return basicURLParse(input, {
+ baseURL: options.baseURL,
+ encodingOverride: options.encodingOverride,
+ });
+}
+
+export class URLImpl {
+ //Include URL type for "url" and "base" params.
+ constructor(url: string | URL, base?: string | URL) {
+ let parsedBase = null;
+ if (base !== undefined) {
+ if (base instanceof URL) {
+ base = base.href;
+ }
+ parsedBase = basicURLParse(base);
+ if (parsedBase === null) {
+ throw new TypeError(`Invalid base URL: ${base}`);
+ }
+ }
+
+ if (url instanceof URL) {
+ url = url.href;
+ }
+ const parsedURL = basicURLParse(url, { baseURL: parsedBase });
+ if (parsedURL === null) {
+ throw new TypeError(`Invalid URL: ${url}`);
+ }
+
+ const query = parsedURL.query !== null ? parsedURL.query : "";
+
+ this._url = parsedURL;
+
+ // We cannot invoke the "new URLSearchParams object" algorithm without going through the constructor, which strips
+ // question mark by default. Therefore the doNotStripQMark hack is used.
+ this._query = new URLSearchParamsImpl(query, {
+ doNotStripQMark: true,
+ });
+ this._query._url = this;
+ }
+
+ get href() {
+ return serializeURL(this._url);
+ }
+
+ set href(v) {
+ const parsedURL = basicURLParse(v);
+ if (parsedURL === null) {
+ throw new TypeError(`Invalid URL: ${v}`);
+ }
+
+ this._url = parsedURL;
+
+ this._query._list.splice(0);
+ const { query } = parsedURL;
+ if (query !== null) {
+ this._query._list = parseUrlencodedString(query);
+ }
+ }
+
+ get origin() {
+ return serializeURLOrigin(this._url);
+ }
+
+ get protocol() {
+ return `${this._url.scheme}:`;
+ }
+
+ set protocol(v) {
+ basicURLParse(`${v}:`, {
+ url: this._url,
+ stateOverride: "scheme start",
+ });
+ }
+
+ get username() {
+ return this._url.username;
+ }
+
+ set username(v) {
+ if (cannotHaveAUsernamePasswordPort(this._url)) {
+ return;
+ }
+
+ setTheUsername(this._url, v);
+ }
+
+ get password() {
+ return this._url.password;
+ }
+
+ set password(v) {
+ if (cannotHaveAUsernamePasswordPort(this._url)) {
+ return;
+ }
+
+ setThePassword(this._url, v);
+ }
+
+ get host() {
+ const url = this._url;
+
+ if (url.host === null) {
+ return "";
+ }
+
+ if (url.port === null) {
+ return serializeHost(url.host);
+ }
+
+ return `${serializeHost(url.host)}:${serializeInteger(url.port)}`;
+ }
+
+ set host(v) {
+ if (hasAnOpaquePath(this._url)) {
+ return;
+ }
+
+ basicURLParse(v, { url: this._url, stateOverride: "host" });
+ }
+
+ get hostname() {
+ if (this._url.host === null) {
+ return "";
+ }
+
+ return serializeHost(this._url.host);
+ }
+
+ set hostname(v) {
+ if (hasAnOpaquePath(this._url)) {
+ return;
+ }
+
+ basicURLParse(v, { url: this._url, stateOverride: "hostname" });
+ }
+
+ get port() {
+ if (this._url.port === null) {
+ return "";
+ }
+
+ return serializeInteger(this._url.port);
+ }
+
+ set port(v) {
+ if (cannotHaveAUsernamePasswordPort(this._url)) {
+ return;
+ }
+
+ if (v === "") {
+ this._url.port = null;
+ } else {
+ basicURLParse(v, { url: this._url, stateOverride: "port" });
+ }
+ }
+
+ get pathname() {
+ return serializePath(this._url);
+ }
+
+ set pathname(v: string) {
+ if (hasAnOpaquePath(this._url)) {
+ return;
+ }
+
+ this._url.path = [];
+ basicURLParse(v, { url: this._url, stateOverride: "path start" });
+ }
+
+ get search() {
+ if (this._url.query === null || this._url.query === "") {
+ return "";
+ }
+
+ return `?${this._url.query}`;
+ }
+
+ set search(v) {
+ const url = this._url;
+
+ if (v === "") {
+ url.query = null;
+ this._query._list = [];
+ return;
+ }
+
+ const input = v[0] === "?" ? v.substring(1) : v;
+ url.query = "";
+ basicURLParse(input, { url, stateOverride: "query" });
+ this._query._list = parseUrlencodedString(input);
+ }
+
+ get searchParams() {
+ return this._query;
+ }
+
+ get hash() {
+ if (this._url.fragment === null || this._url.fragment === "") {
+ return "";
+ }
+
+ return `#${this._url.fragment}`;
+ }
+
+ set hash(v) {
+ if (v === "") {
+ this._url.fragment = null;
+ return;
+ }
+
+ const input = v[0] === "#" ? v.substring(1) : v;
+ this._url.fragment = "";
+ basicURLParse(input, { url: this._url, stateOverride: "fragment" });
+ }
+
+ toJSON() {
+ return this.href;
+ }
+
+ // FIXME: type!
+ _url: any;
+ _query: any;
+}
diff --git a/packages/taler-util/tsconfig.json b/packages/taler-util/tsconfig.json
index 30cb65e1d..2e97142ce 100644
--- a/packages/taler-util/tsconfig.json
+++ b/packages/taler-util/tsconfig.json
@@ -4,11 +4,11 @@
"composite": true,
"declaration": true,
"declarationMap": false,
- "target": "ES6",
- "module": "ESNext",
- "moduleResolution": "node",
+ "target": "ES2020",
+ "module": "Node16",
+ "moduleResolution": "Node16",
"sourceMap": true,
- "lib": ["es6"],
+ "lib": ["ES2020"],
"types": ["node"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
diff --git a/packages/taler-wallet-cli/Makefile b/packages/taler-wallet-cli/Makefile
new file mode 100644
index 000000000..c8529c768
--- /dev/null
+++ b/packages/taler-wallet-cli/Makefile
@@ -0,0 +1,51 @@
+# This Makefile has been placed in the public domain.
+
+ifeq ($(TOPLEVEL), yes)
+ $(info top-level build)
+ -include ../../.config.mk
+ override DESTDIR := $(TOP_DESTDIR)
+else
+ $(info package-level build)
+ -include ../../.config.mk
+ -include .config.mk
+endif
+
+$(info prefix is $(prefix))
+
+all:
+ @echo use 'make install' to build and install taler-wallet-cli
+
+ifndef prefix
+.PHONY: warn-noprefix install
+warn-noprefix:
+ @echo "no prefix configured, did you run ./configure?"
+install: warn-noprefix
+else
+LIBDIR = $(prefix)/lib/taler-wallet-cli
+BINDIR=$(prefix)/bin
+NODEDIR=$(LIBDIR)/node_modules/taler-wallet-cli
+.PHONY: install install-nodeps deps
+install-nodeps:
+ ./build-node.mjs
+ @echo installing wallet CLI to $(DESTDIR)$(prefix)
+ install -d $(DESTDIR)$(BINDIR)
+ install -d $(DESTDIR)$(LIBDIR)/build
+ install -d $(DESTDIR)$(NODEDIR)/bin
+ install -d $(DESTDIR)$(NODEDIR)/dist
+ install ./dist/taler-wallet-cli-bundled.cjs $(DESTDIR)$(NODEDIR)/dist/
+ install ./dist/taler-wallet-cli-bundled.cjs.map $(DESTDIR)$(NODEDIR)/dist/
+ install ./bin/taler-wallet-cli.mjs $(DESTDIR)$(NODEDIR)/bin/
+ install ../idb-bridge/node_modules/better-sqlite3/build/Release/better_sqlite3.node $(DESTDIR)$(LIBDIR)/build/ \
+ || echo "sqlite3 unavailable, better-sqlite3 native module not found"
+ ln -sf ../lib/taler-wallet-cli/node_modules/taler-wallet-cli/bin/taler-wallet-cli.mjs $(DESTDIR)$(BINDIR)/taler-wallet-cli
+deps:
+ pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-cli...
+ pnpm run --filter @gnu-taler/taler-wallet-cli... compile
+install:
+ $(MAKE) deps
+ $(MAKE) install-nodeps
+endif
+
+.PHONY: deb
+deb:
+ dpkg-buildpackage -rfakeroot -b -uc -us
diff --git a/packages/taler-wallet-cli/README.md b/packages/taler-wallet-cli/README.md
new file mode 100644
index 000000000..27b2373c9
--- /dev/null
+++ b/packages/taler-wallet-cli/README.md
@@ -0,0 +1,9 @@
+# taler-wallet-cli
+
+This package provides `taler-wallet-cli`, the command-line interface for the
+GNU Taler wallet.
+
+## sqlite3 backend
+
+To be able to use the sqlite3 backend, make sure that better-sqlite3
+is installed as an optional dependency in the ../idb-bridge package.
diff --git a/packages/taler-wallet-cli/assets/.gitkeep b/packages/taler-wallet-cli/assets/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
--- a/packages/taler-wallet-cli/assets/.gitkeep
+++ /dev/null
diff --git a/packages/taler-wallet-cli/bin/taler-wallet-cli b/packages/taler-wallet-cli/bin/taler-wallet-cli
deleted file mode 100755
index ca8008e30..000000000
--- a/packages/taler-wallet-cli/bin/taler-wallet-cli
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/usr/bin/env node
-try {
- require('source-map-support').install();
-} catch (e) {
- // Do nothing.
-}
-require('../dist/taler-wallet-cli.js').main();
diff --git a/packages/taler-wallet-cli/bin/taler-wallet-cli-local.mjs b/packages/taler-wallet-cli/bin/taler-wallet-cli-local.mjs
new file mode 100755
index 000000000..3620330b0
--- /dev/null
+++ b/packages/taler-wallet-cli/bin/taler-wallet-cli-local.mjs
@@ -0,0 +1,8 @@
+#!/usr/bin/env node
+
+// Execute the wallet CLI from the source directory.
+// This script is meant for testing and must not
+// be installed.
+
+import { main } from '../lib/index.js';
+main();
diff --git a/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs b/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs
new file mode 100755
index 000000000..082007632
--- /dev/null
+++ b/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs
@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { main } from '../dist/taler-wallet-cli-bundled.cjs';
+main();
diff --git a/packages/taler-wallet-cli/build-node.mjs b/packages/taler-wallet-cli/build-node.mjs
new file mode 100755
index 000000000..047fc4527
--- /dev/null
+++ b/packages/taler-wallet-cli/build-node.mjs
@@ -0,0 +1,75 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import esbuild from "esbuild";
+import path from "path";
+import fs from "fs";
+
+const BASE = process.cwd();
+
+let GIT_ROOT = BASE;
+while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") {
+ GIT_ROOT = path.join(GIT_ROOT, "../");
+}
+if (GIT_ROOT === "/") {
+ console.log("not found");
+ process.exit(1);
+}
+const GIT_HASH = GIT_ROOT === "/" ? undefined : git_hash();
+
+let _package = JSON.parse(fs.readFileSync(path.join(BASE, "package.json")));
+
+function git_hash() {
+ const rev = fs
+ .readFileSync(path.join(GIT_ROOT, ".git", "HEAD"))
+ .toString()
+ .trim()
+ .split(/.*[: ]/)
+ .slice(-1)[0];
+ if (rev.indexOf("/") === -1) {
+ return rev;
+ } else {
+ return fs
+ .readFileSync(path.join(GIT_ROOT, ".git", rev))
+ .toString()
+ .trim();
+ }
+}
+
+export const buildConfig = {
+ entryPoints: ["src/index.ts"],
+ outfile: "dist/taler-wallet-cli-bundled.cjs",
+ bundle: true,
+ minify: false,
+ target: ["es2020"],
+ format: "cjs",
+ platform: "node",
+ sourcemap: true,
+ inject: ["src/import-meta-url.js"],
+ define: {
+ __VERSION__: `"${_package.version}"`,
+ __GIT_HASH__: `"${GIT_HASH}"`,
+ "walletCoreBuildInfo.implementationSemver": `"${_package.version}"`,
+ "walletCoreBuildInfo.implementationGitHash": `"${GIT_HASH}"`,
+ ["import.meta.url"]: "import_meta_url",
+ },
+};
+
+esbuild.build(buildConfig).catch((e) => {
+ console.log(e);
+ process.exit(1);
+});
diff --git a/packages/taler-wallet-cli/build-qtart.mjs b/packages/taler-wallet-cli/build-qtart.mjs
new file mode 100755
index 000000000..04b2e718f
--- /dev/null
+++ b/packages/taler-wallet-cli/build-qtart.mjs
@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import esbuild from "esbuild";
+import path from "path";
+import fs from "fs";
+
+const BASE = process.cwd();
+
+let GIT_ROOT = BASE;
+while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") {
+ GIT_ROOT = path.join(GIT_ROOT, "../");
+}
+if (GIT_ROOT === "/") {
+ console.log("not found");
+ process.exit(1);
+}
+const GIT_HASH = GIT_ROOT === "/" ? undefined : git_hash();
+
+let _package = JSON.parse(fs.readFileSync(path.join(BASE, "package.json")));
+
+function git_hash() {
+ const rev = fs
+ .readFileSync(path.join(GIT_ROOT, ".git", "HEAD"))
+ .toString()
+ .trim()
+ .split(/.*[: ]/)
+ .slice(-1)[0];
+ if (rev.indexOf("/") === -1) {
+ return rev;
+ } else {
+ return fs
+ .readFileSync(path.join(GIT_ROOT, ".git", rev))
+ .toString()
+ .trim();
+ }
+}
+
+export const buildConfig = {
+ entryPoints: ["src/index.ts"],
+ outfile: "dist/taler-wallet-cli.qtart.mjs",
+ bundle: true,
+ minify: false,
+ target: ["es2020"],
+ format: "esm",
+ platform: "neutral",
+ mainFields: ["module", "main"],
+ conditions: ["qtart"],
+ sourcemap: true,
+ // quickjs standard library
+ external: ["std", "os", "better-sqlite3"],
+ define: {
+ __VERSION__: `"${_package.version}"`,
+ __GIT_HASH__: `"${GIT_HASH}"`,
+ "walletCoreBuildInfo.implementationSemver": `"${_package.version}"`,
+ "walletCoreBuildInfo.implementationGitHash": `"${GIT_HASH}"`,
+ },
+};
+
+esbuild.build(buildConfig).catch((e) => {
+ console.log(e);
+ process.exit(1);
+});
diff --git a/packages/taler-wallet-cli/debian/README b/packages/taler-wallet-cli/debian/README
new file mode 100644
index 000000000..d221c6e37
--- /dev/null
+++ b/packages/taler-wallet-cli/debian/README
@@ -0,0 +1,8 @@
+For the moment, building the Debian package needs
+a preliminary manual step to install the taler-wallet-cli
+Node.JS package. In the future, this will either be invoked
+by DH, or added as packaging instructions to Debian.
+
+$ ./configure --prefix=/usr
+$ make install
+$ dpkg-buildpackage -rfakeroot -b -uc -us
diff --git a/packages/taler-wallet-cli/debian/changelog b/packages/taler-wallet-cli/debian/changelog
new file mode 100644
index 000000000..4030caec7
--- /dev/null
+++ b/packages/taler-wallet-cli/debian/changelog
@@ -0,0 +1,123 @@
+taler-wallet-cli (0.10.6) unstable; urgency=low
+
+ * Release 0.10.6
+
+ -- Florian Dold <dold@taler.net> Wed, 10 Apr 2024 15:19:33 +0200
+
+taler-wallet-cli (0.10.5) unstable; urgency=low
+
+ * Release 0.10.5
+
+ -- Florian Dold <dold@taler.net> Tue, 09 Apr 2024 16:13:58 +0200
+
+taler-wallet-cli (0.10.4) unstable; urgency=low
+
+ * Release 0.10.4
+
+ -- Florian Dold <dold@taler.net> Tue, 09 Apr 2024 14:39:44 +0200
+
+taler-wallet-cli (0.9.4a) unstable; urgency=low
+
+ * Release v0.9.4a.
+
+ -- Florian Dold <dold@taler.net> Thu, 07 Mar 2024 23:43:05 +0100
+
+taler-wallet-cli (0.9.3-1) unstable; urgency=low
+
+ * Misc bugfixes.
+
+ -- Christian Grothoff <grothoff@gnu.org> Tue, 13 Dec 2023 18:53:15 -0700
+
+taler-wallet-cli (0.9.3) unstable; urgency=low
+
+ * First release for GNU Taler v0.9.3.
+
+ -- Christian Grothoff <grothoff@gnu.org> Wed, 29 Nov 2023 08:53:15 -0800
+
+taler-wallet-cli (0.9.2-2) unstable; urgency=low
+
+ * Release with various Debian package fixes.
+
+ -- Christian Grothoff <grothoff@gnu.org> Sat, 3 Mar 2023 23:47:15 -0300
+
+taler-wallet-cli (0.9.2) unstable; urgency=low
+
+ * Official 0.9.2 release.
+
+ -- Christian Grothoff <grothoff@gnu.org> Sat, 25 Feb 2023 12:47:15 -0300
+
+taler-wallet-cli (0.9.1) unstable; urgency=low
+
+ * Official 0.9.1 release.
+
+ -- Christian Grothoff <grothoff@gnu.org> Thu, 26 Jan 2023 12:47:15 -0300
+
+taler-wallet-cli (0.9.0) unstable; urgency=low
+
+ * Official 0.9.0 release.
+
+ -- Christian Grothoff <grothoff@gnu.org> Sat, 5 Nov 2022 12:47:15 -0300
+
+taler-wallet-cli (0.8.99) unstable; urgency=low
+
+ * Preview for 0.9 wallet.
+
+ -- Christian Grothoff <grothoff@gnu.org> Tue, 21 Jun 2022 12:47:15 -0300
+
+taler-wallet-cli (0.8.3) unstable; urgency=low
+
+ * Official 0.8.3 release.
+
+ -- Sebastian Marchano <sebasjm@gmail.com> Fri, 26 Nov 2021 12:47:15 -0300
+
+taler-wallet-cli (0.8.2) unstable; urgency=low
+
+ * Official 0.8.2 release.
+
+ -- Florian Dold <dold@taler.net> Tue, 24 Aug 2021 15:47:15 +0200
+
+taler-wallet-cli (0.0.1-6) unstable; urgency=low
+
+ * Fix deposit tracking.
+
+ -- Florian Dold <dold@taler.net> Sat, 07 Aug 2021 17:40:08 +0200
+
+taler-wallet-cli (0.0.1-5) unstable; urgency=low
+
+ * Performance improvements.
+
+ -- Florian Dold <dold@taler.net> Fri, 06 Aug 2021 17:16:18 +0200
+
+taler-wallet-cli (0.0.1-4) unstable; urgency=low
+
+ * Deployment linting improvements.
+
+ -- Florian Dold <dold@taler.net> Fri, 06 Aug 2021 11:58:34 +0200
+
+taler-wallet-cli (0.0.1-3) unstable; urgency=low
+
+ * Deployment linting improvements.
+
+ -- Florian Dold <dold@taler.net> Thu, 05 Aug 2021 00:02:33 +0200
+
+taler-wallet-cli (0.0.1-2) unstable; urgency=low
+
+ * Improved denomination generator.
+
+ -- Florian Dold <dold@taler.net> Wed, 04 Aug 2021 23:26:17 +0200
+
+taler-wallet-cli (0.0.1-1) unstable; urgency=low
+
+ * Added exchange deployment linting tool.
+
+ -- Florian Dold <dold@taler.net> Wed, 04 Aug 2021 23:05:47 +0200
+
+taler-wallet-cli (0.0.1) unstable; urgency=low
+
+ * Initial Release.
+
+ -- Florian Dold <dold@taler.net> Sun, 14 Jul 2021 15:00:00 +0100
+
+Local variables:
+mode: debian-changelog
+End:
diff --git a/packages/taler-wallet-cli/debian/control b/packages/taler-wallet-cli/debian/control
new file mode 100644
index 000000000..41b507480
--- /dev/null
+++ b/packages/taler-wallet-cli/debian/control
@@ -0,0 +1,16 @@
+Source: taler-wallet-cli
+Section: networking
+Priority: optional
+Maintainer: Taler Systems SA <deb@taler.net>
+Uploaders: Christian Grothoff <grothoff@gnu.org>, Florian Dold <dold@taler.net>
+Build-Depends: debhelper-compat (= 12),
+Standards-Version: 4.1.0
+Vcs-Git: https://git.taler.net/wallet-core.git
+Homepage: https://taler.net/
+
+Package: taler-wallet-cli
+Architecture: all
+Depends: nodejs,
+ ${misc:Depends}
+Recommends:
+Description: This is a command-line interface version of the GNU Taler wallet.
diff --git a/debian/copyright b/packages/taler-wallet-cli/debian/copyright
index 555d608fa..555d608fa 100644
--- a/debian/copyright
+++ b/packages/taler-wallet-cli/debian/copyright
diff --git a/packages/taler-wallet-cli/debian/rules b/packages/taler-wallet-cli/debian/rules
new file mode 100755
index 000000000..0a8d6408c
--- /dev/null
+++ b/packages/taler-wallet-cli/debian/rules
@@ -0,0 +1,19 @@
+#!/usr/bin/make -f
+
+%:
+ dh ${@}
+
+# Override because our configure doesn't like extra arguments.
+override_dh_auto_configure:
+ ./configure --prefix=/usr
+
+override_dh_builddeb:
+ dh_builddeb -- -Zgzip
+
+# Override this step because it's very slow and likely
+# unnecessary for us.
+override_dh_strip_nondeterminism:
+ true
+
+get-orig-source:
+ uscan --force-download --rename
diff --git a/packages/taler-wallet-cli/package.json b/packages/taler-wallet-cli/package.json
index 52a8f817e..054931b25 100644
--- a/packages/taler-wallet-cli/package.json
+++ b/packages/taler-wallet-cli/package.json
@@ -1,9 +1,9 @@
{
"name": "@gnu-taler/taler-wallet-cli",
- "version": "0.8.1",
+ "version": "0.10.6",
"description": "",
"engines": {
- "node": ">=0.12.0"
+ "node": ">=0.18.0"
},
"repository": {
"type": "git",
@@ -11,14 +11,15 @@
},
"author": "Florian Dold",
"license": "GPL-3.0",
- "main": "dist/taler-wallet-cli.js",
"bin": {
- "taler-wallet-cli": "./bin/taler-wallet-cli"
+ "taler-wallet-cli": "./bin/taler-wallet-cli.mjs"
},
+ "type": "module",
"scripts": {
- "prepare": "tsc && rollup -c",
- "compile": "tsc && rollup -c",
- "clean": "rimraf lib dist tsconfig.tsbuildinfo",
+ "compile": "tsc && ./build-node.mjs",
+ "test": "tsc",
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "clean": "rm -rf lib dist tsconfig.tsbuildinfo",
"pretty": "prettier --write src"
},
"files": [
@@ -30,25 +31,14 @@
"src/"
],
"devDependencies": {
- "@rollup/plugin-commonjs": "^17.0.0",
- "@rollup/plugin-json": "^4.1.0",
- "@rollup/plugin-node-resolve": "^11.1.0",
- "@rollup/plugin-replace": "^2.3.4",
- "@types/node": "^14.14.22",
- "prettier": "^2.2.1",
- "rimraf": "^3.0.2",
- "rollup": "^2.37.1",
- "rollup-plugin-sourcemaps": "^0.6.3",
- "rollup-plugin-terser": "^7.0.2",
- "typedoc": "^0.20.16",
- "typescript": "^4.1.3"
+ "@types/node": "^18.11.17",
+ "prettier": "^3.1.1",
+ "typedoc": "^0.25.4",
+ "typescript": "^5.3.3"
},
"dependencies": {
"@gnu-taler/taler-util": "workspace:*",
"@gnu-taler/taler-wallet-core": "workspace:*",
- "axios": "^0.21.1",
- "cancellationtoken": "^2.2.0",
- "source-map-support": "^0.5.19",
- "tslib": "^2.1.0"
+ "tslib": "^2.6.2"
}
}
diff --git a/packages/taler-wallet-cli/rollup.config.js b/packages/taler-wallet-cli/rollup.config.js
deleted file mode 100644
index e7d7a7007..000000000
--- a/packages/taler-wallet-cli/rollup.config.js
+++ /dev/null
@@ -1,32 +0,0 @@
-// rollup.config.js
-import commonjs from "@rollup/plugin-commonjs";
-import nodeResolve from "@rollup/plugin-node-resolve";
-import json from "@rollup/plugin-json";
-import builtins from "builtin-modules";
-import pkg from "./package.json";
-import sourcemaps from 'rollup-plugin-sourcemaps';
-
-export default {
- input: "lib/index.js",
- output: {
- file: pkg.main,
- format: "cjs",
- sourcemap: true,
- },
- external: builtins,
- plugins: [
- nodeResolve({
- preferBuiltins: true,
- }),
-
- sourcemaps(),
-
- commonjs({
- sourceMap: true,
- transformMixedEsModules: true,
- }),
-
- json(),
- ],
-}
-
diff --git a/packages/taler-wallet-cli/src/assets.ts b/packages/taler-wallet-cli/src/assets.ts
deleted file mode 100644
index 72fc9fe04..000000000
--- a/packages/taler-wallet-cli/src/assets.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import path from "path";
-import fs from "fs";
-
-/**
- * Resolve an asset name into an absolute filename.
- *
- * The asset file should be placed in the "assets" directory
- * at the top level of the package (i.e. next to package.json).
- */
-export function resolveAsset(name: string): string {
- const n = __filename;
- const d = __dirname;
- let assetPath: string;
- // Currently both asset paths are the same.
- // This might change if the file that contains "resolveAsset"
- // ever moves. Thus, we're keeping the case distinction.
- if (n.endsWith("assets.js")) {
- // We're not bundled. Path is relative to the current file.
- assetPath = path.resolve(path.join(d, "..", "assets", name));
- } else if (n.endsWith("taler-wallet-cli.js")) {
- // We're bundled. Currently, this path is the same
- // FIXME: Take into account some ASSETS environment variable?
- assetPath = path.resolve(path.join(d, "..", "assets", name));
- } else {
- throw Error("Can't resolve asset (unknown)");
- }
- if (!fs.existsSync(assetPath)) {
- throw Error(`Asset '${name} not found'`);
- }
- return assetPath;
-}
diff --git a/packages/taler-wallet-cli/src/bench1.ts b/packages/taler-wallet-cli/src/bench1.ts
deleted file mode 100644
index 4a2651f36..000000000
--- a/packages/taler-wallet-cli/src/bench1.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- buildCodecForObject,
- codecForNumber,
- codecForString,
- codecOptional,
-} from "@gnu-taler/taler-util";
-import {
- getDefaultNodeWallet,
- NodeHttpLib,
- WalletApiOperation,
-} from "@gnu-taler/taler-wallet-core";
-
-/**
- * Entry point for the benchmark.
- *
- * The benchmark runs against an existing Taler deployment and does not
- * set up its own services.
- */
-export async function runBench1(configJson: any): Promise<void> {
- // Validate the configuration file for this benchmark.
- const b1conf = codecForBench1Config().decode(configJson);
-
- const myHttpLib = new NodeHttpLib();
- const wallet = await getDefaultNodeWallet({
- // No persistent DB storage.
- persistentStoragePath: undefined,
- httpLib: myHttpLib,
- });
- await wallet.client.call(WalletApiOperation.InitWallet, {});
-
- const numIter = b1conf.iterations ?? 1;
-
- for (let i = 0; i < numIter; i++) {
- await wallet.client.call(WalletApiOperation.WithdrawFakebank, {
- amount: "TESTKUDOS:10",
- bank: b1conf.bank,
- exchange: b1conf.exchange,
- });
-
- await wallet.runTaskLoop({
- stopWhenDone: true,
- });
-
- await wallet.client.call(WalletApiOperation.CreateDepositGroup, {
- amount: "TESTKUDOS:5",
- depositPaytoUri: "payto://x-taler-bank/localhost/foo",
- });
-
- await wallet.runTaskLoop({
- stopWhenDone: true,
- });
- }
-
- wallet.stop();
-}
-
-/**
- * Format of the configuration file passed to the benchmark
- */
-interface Bench1Config {
- /**
- * Base URL of the bank.
- */
- bank: string;
-
- /**
- * Base URL of the exchange.
- */
- exchange: string;
-
- /**
- * How many withdraw/deposit iterations should be made?
- * Defaults to 1.
- */
- iterations?: number;
-}
-
-/**
- * Schema validation codec for Bench1Config.
- */
-const codecForBench1Config = () =>
- buildCodecForObject<Bench1Config>()
- .property("bank", codecForString())
- .property("exchange", codecForString())
- .property("iterations", codecOptional(codecForNumber()))
- .build("Bench1Config");
diff --git a/packages/taler-wallet-cli/src/clk.ts b/packages/taler-wallet-cli/src/clk.ts
deleted file mode 100644
index ca6dcc1a4..000000000
--- a/packages/taler-wallet-cli/src/clk.ts
+++ /dev/null
@@ -1,614 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import process from "process";
-import path from "path";
-import readline from "readline";
-
-class Converter<T> {}
-
-export const INT = new Converter<number>();
-export const STRING: Converter<string> = new Converter<string>();
-
-export interface OptionArgs<T> {
- help?: string;
- default?: T;
- onPresentHandler?: (v: T) => void;
-}
-
-export interface ArgumentArgs<T> {
- metavar?: string;
- help?: string;
- default?: T;
-}
-
-export interface SubcommandArgs {
- help?: string;
-}
-
-export interface FlagArgs {
- help?: string;
-}
-
-export interface ProgramArgs {
- help?: string;
-}
-
-interface ArgumentDef {
- name: string;
- conv: Converter<any>;
- args: ArgumentArgs<any>;
- required: boolean;
-}
-
-interface SubcommandDef {
- commandGroup: CommandGroup<any, any>;
- name: string;
- args: SubcommandArgs;
-}
-
-type ActionFn<TG> = (x: TG) => void;
-
-type SubRecord<S extends keyof any, N extends keyof any, V> = {
- [Y in S]: { [X in N]: V };
-};
-
-interface OptionDef {
- name: string;
- flagspec: string[];
- /**
- * Converter, only present for options, not for flags.
- */
- conv?: Converter<any>;
- args: OptionArgs<any>;
- isFlag: boolean;
- required: boolean;
-}
-
-function splitOpt(opt: string): { key: string; value?: string } {
- const idx = opt.indexOf("=");
- if (idx == -1) {
- return { key: opt };
- }
- return { key: opt.substring(0, idx), value: opt.substring(idx + 1) };
-}
-
-function formatListing(key: string, value?: string): string {
- const res = " " + key;
- if (!value) {
- return res;
- }
- if (res.length >= 25) {
- return res + "\n" + " " + value;
- } else {
- return res.padEnd(24) + " " + value;
- }
-}
-
-export class CommandGroup<GN extends keyof any, TG> {
- private shortOptions: { [name: string]: OptionDef } = {};
- private longOptions: { [name: string]: OptionDef } = {};
- private subcommandMap: { [name: string]: SubcommandDef } = {};
- private subcommands: SubcommandDef[] = [];
- private options: OptionDef[] = [];
- private arguments: ArgumentDef[] = [];
-
- private myAction?: ActionFn<TG>;
-
- constructor(
- private argKey: string,
- private name: string | null,
- private scArgs: SubcommandArgs,
- ) {}
-
- action(f: ActionFn<TG>): void {
- if (this.myAction) {
- throw Error("only one action supported per command");
- }
- this.myAction = f;
- }
-
- requiredOption<N extends keyof any, V>(
- name: N,
- flagspec: string[],
- conv: Converter<V>,
- args: OptionArgs<V> = {},
- ): CommandGroup<GN, TG & SubRecord<GN, N, V>> {
- const def: OptionDef = {
- args: args,
- conv: conv,
- flagspec: flagspec,
- isFlag: false,
- required: true,
- name: name as string,
- };
- this.options.push(def);
- for (const flag of flagspec) {
- if (flag.startsWith("--")) {
- const flagname = flag.substring(2);
- this.longOptions[flagname] = def;
- } else if (flag.startsWith("-")) {
- const flagname = flag.substring(1);
- this.shortOptions[flagname] = def;
- } else {
- throw Error("option must start with '-' or '--'");
- }
- }
- return this as any;
- }
-
- maybeOption<N extends keyof any, V>(
- name: N,
- flagspec: string[],
- conv: Converter<V>,
- args: OptionArgs<V> = {},
- ): CommandGroup<GN, TG & SubRecord<GN, N, V | undefined>> {
- const def: OptionDef = {
- args: args,
- conv: conv,
- flagspec: flagspec,
- isFlag: false,
- required: false,
- name: name as string,
- };
- this.options.push(def);
- for (const flag of flagspec) {
- if (flag.startsWith("--")) {
- const flagname = flag.substring(2);
- this.longOptions[flagname] = def;
- } else if (flag.startsWith("-")) {
- const flagname = flag.substring(1);
- this.shortOptions[flagname] = def;
- } else {
- throw Error("option must start with '-' or '--'");
- }
- }
- return this as any;
- }
-
- requiredArgument<N extends keyof any, V>(
- name: N,
- conv: Converter<V>,
- args: ArgumentArgs<V> = {},
- ): CommandGroup<GN, TG & SubRecord<GN, N, V>> {
- const argDef: ArgumentDef = {
- args: args,
- conv: conv,
- name: name as string,
- required: true,
- };
- this.arguments.push(argDef);
- return this as any;
- }
-
- maybeArgument<N extends keyof any, V>(
- name: N,
- conv: Converter<V>,
- args: ArgumentArgs<V> = {},
- ): CommandGroup<GN, TG & SubRecord<GN, N, V | undefined>> {
- const argDef: ArgumentDef = {
- args: args,
- conv: conv,
- name: name as string,
- required: false,
- };
- this.arguments.push(argDef);
- return this as any;
- }
-
- flag<N extends string, V>(
- name: N,
- flagspec: string[],
- args: OptionArgs<V> = {},
- ): CommandGroup<GN, TG & SubRecord<GN, N, boolean>> {
- const def: OptionDef = {
- args: args,
- flagspec: flagspec,
- isFlag: true,
- required: false,
- name: name as string,
- };
- this.options.push(def);
- for (const flag of flagspec) {
- if (flag.startsWith("--")) {
- const flagname = flag.substring(2);
- this.longOptions[flagname] = def;
- } else if (flag.startsWith("-")) {
- const flagname = flag.substring(1);
- this.shortOptions[flagname] = def;
- } else {
- throw Error("option must start with '-' or '--'");
- }
- }
- return this as any;
- }
-
- subcommand<GN extends keyof any>(
- argKey: GN,
- name: string,
- args: SubcommandArgs = {},
- ): CommandGroup<GN, TG> {
- const cg = new CommandGroup<GN, {}>(argKey as string, name, args);
- const def: SubcommandDef = {
- commandGroup: cg,
- name: name as string,
- args: args,
- };
- cg.flag("help", ["-h", "--help"], {
- help: "Show this message and exit.",
- });
- this.subcommandMap[name as string] = def;
- this.subcommands.push(def);
- this.subcommands = this.subcommands.sort((x1, x2) => {
- const a = x1.name;
- const b = x2.name;
- if (a === b) {
- return 0;
- } else if (a < b) {
- return -1;
- } else {
- return 1;
- }
- });
- return cg as any;
- }
-
- printHelp(progName: string, parents: CommandGroup<any, any>[]): void {
- let usageSpec = "";
- for (const p of parents) {
- usageSpec += (p.name ?? progName) + " ";
- if (p.arguments.length >= 1) {
- usageSpec += "<ARGS...> ";
- }
- }
- usageSpec += (this.name ?? progName) + " ";
- if (this.subcommands.length != 0) {
- usageSpec += "COMMAND ";
- }
- for (const a of this.arguments) {
- const argName = a.args.metavar ?? a.name;
- usageSpec += `<${argName}> `;
- }
- usageSpec = usageSpec.trimRight();
- console.log(`Usage: ${usageSpec}`);
- if (this.scArgs.help) {
- console.log();
- console.log(this.scArgs.help);
- }
- if (this.options.length != 0) {
- console.log();
- console.log("Options:");
- for (const opt of this.options) {
- let optSpec = opt.flagspec.join(", ");
- if (!opt.isFlag) {
- optSpec = optSpec + "=VALUE";
- }
- console.log(formatListing(optSpec, opt.args.help));
- }
- }
-
- if (this.subcommands.length != 0) {
- console.log();
- console.log("Commands:");
- for (const subcmd of this.subcommands) {
- console.log(formatListing(subcmd.name, subcmd.args.help));
- }
- }
- }
-
- /**
- * Run the (sub-)command with the given command line parameters.
- */
- run(
- progname: string,
- parents: CommandGroup<any, any>[],
- unparsedArgs: string[],
- parsedArgs: any,
- ): void {
- let posArgIndex = 0;
- let argsTerminated = false;
- let i;
- let foundSubcommand: CommandGroup<any, any> | undefined = undefined;
- const myArgs: any = (parsedArgs[this.argKey] = {});
- const foundOptions: { [name: string]: boolean } = {};
- const currentName = this.name ?? progname;
- for (i = 0; i < unparsedArgs.length; i++) {
- const argVal = unparsedArgs[i];
- if (argsTerminated == false) {
- if (argVal === "--") {
- argsTerminated = true;
- continue;
- }
- if (argVal.startsWith("--")) {
- const opt = argVal.substring(2);
- const r = splitOpt(opt);
- const d = this.longOptions[r.key];
- if (!d) {
- console.error(
- `error: unknown option '--${r.key}' for ${currentName}`,
- );
- process.exit(-1);
- throw Error("not reached");
- }
- if (d.isFlag) {
- if (r.value !== undefined) {
- console.error(`error: flag '--${r.key}' does not take a value`);
- process.exit(-1);
- throw Error("not reached");
- }
- foundOptions[d.name] = true;
- myArgs[d.name] = true;
- } else {
- if (r.value === undefined) {
- if (i === unparsedArgs.length - 1) {
- console.error(`error: option '--${r.key}' needs an argument`);
- process.exit(-1);
- throw Error("not reached");
- }
- myArgs[d.name] = unparsedArgs[i + 1];
- i++;
- } else {
- myArgs[d.name] = r.value;
- }
- foundOptions[d.name] = true;
- }
- continue;
- }
- if (argVal.startsWith("-") && argVal != "-") {
- const optShort = argVal.substring(1);
- for (let si = 0; si < optShort.length; si++) {
- const chr = optShort[si];
- const opt = this.shortOptions[chr];
- if (!opt) {
- console.error(`error: option '-${chr}' not known`);
- process.exit(-1);
- }
- if (opt.isFlag) {
- myArgs[opt.name] = true;
- foundOptions[opt.name] = true;
- } else {
- if (si == optShort.length - 1) {
- if (i === unparsedArgs.length - 1) {
- console.error(`error: option '-${chr}' needs an argument`);
- process.exit(-1);
- throw Error("not reached");
- } else {
- myArgs[opt.name] = unparsedArgs[i + 1];
- i++;
- }
- } else {
- myArgs[opt.name] = optShort.substring(si + 1);
- }
- foundOptions[opt.name] = true;
- break;
- }
- }
- continue;
- }
- }
- if (this.subcommands.length != 0) {
- const subcmd = this.subcommandMap[argVal];
- if (!subcmd) {
- console.error(`error: unknown command '${argVal}'`);
- process.exit(-1);
- throw Error("not reached");
- }
- foundSubcommand = subcmd.commandGroup;
- break;
- } else {
- const d = this.arguments[posArgIndex];
- if (!d) {
- console.error(`error: too many arguments for ${currentName}`);
- process.exit(-1);
- throw Error("not reached");
- }
- myArgs[d.name] = unparsedArgs[i];
- posArgIndex++;
- }
- }
-
- if (parsedArgs[this.argKey].help) {
- this.printHelp(progname, parents);
- process.exit(0);
- throw Error("not reached");
- }
-
- for (let i = posArgIndex; i < this.arguments.length; i++) {
- const d = this.arguments[i];
- if (d.required) {
- if (d.args.default !== undefined) {
- myArgs[d.name] = d.args.default;
- } else {
- console.error(
- `error: missing positional argument '${d.name}' for ${currentName}`,
- );
- process.exit(-1);
- throw Error("not reached");
- }
- }
- }
-
- for (const option of this.options) {
- if (option.isFlag == false && option.required == true) {
- if (!foundOptions[option.name]) {
- if (option.args.default !== undefined) {
- myArgs[option.name] = option.args.default;
- } else {
- const name = option.flagspec.join(",");
- console.error(`error: missing option '${name}'`);
- process.exit(-1);
- throw Error("not reached");
- }
- }
- }
- }
-
- for (const option of this.options) {
- const ph = option.args.onPresentHandler;
- if (ph && foundOptions[option.name]) {
- ph(myArgs[option.name]);
- }
- }
-
- if (foundSubcommand) {
- foundSubcommand.run(
- progname,
- Array.prototype.concat(parents, [this]),
- unparsedArgs.slice(i + 1),
- parsedArgs,
- );
- } else if (this.myAction) {
- let r;
- try {
- r = this.myAction(parsedArgs);
- } catch (e) {
- console.error(`An error occurred while running ${currentName}`);
- console.error(e);
- process.exit(1);
- }
- Promise.resolve(r).catch((e) => {
- console.error(`An error occurred while running ${currentName}`);
- console.error(e);
- process.exit(1);
- });
- } else {
- this.printHelp(progname, parents);
- process.exit(-1);
- throw Error("not reached");
- }
- }
-}
-
-export class Program<PN extends keyof any, T> {
- private mainCommand: CommandGroup<any, any>;
-
- constructor(argKey: string, args: ProgramArgs = {}) {
- this.mainCommand = new CommandGroup<any, any>(argKey, null, {
- help: args.help,
- });
- this.mainCommand.flag("help", ["-h", "--help"], {
- help: "Show this message and exit.",
- });
- }
-
- run(): void {
- const args = process.argv;
- if (args.length < 2) {
- console.error(
- "Error while parsing command line arguments: not enough arguments",
- );
- process.exit(-1);
- }
- const progname = path.basename(args[1]);
- const rest = args.slice(2);
-
- this.mainCommand.run(progname, [], rest, {});
- }
-
- subcommand<GN extends keyof any>(
- argKey: GN,
- name: string,
- args: SubcommandArgs = {},
- ): CommandGroup<GN, T> {
- const cmd = this.mainCommand.subcommand(argKey, name as string, args);
- return cmd as any;
- }
-
- requiredOption<N extends keyof any, V>(
- name: N,
- flagspec: string[],
- conv: Converter<V>,
- args: OptionArgs<V> = {},
- ): Program<PN, T & SubRecord<PN, N, V>> {
- this.mainCommand.requiredOption(name, flagspec, conv, args);
- return this as any;
- }
-
- maybeOption<N extends keyof any, V>(
- name: N,
- flagspec: string[],
- conv: Converter<V>,
- args: OptionArgs<V> = {},
- ): Program<PN, T & SubRecord<PN, N, V | undefined>> {
- this.mainCommand.maybeOption(name, flagspec, conv, args);
- return this as any;
- }
-
- /**
- * Add a flag (option without value) to the program.
- */
- flag<N extends string>(
- name: N,
- flagspec: string[],
- args: OptionArgs<boolean> = {},
- ): Program<PN, T & SubRecord<PN, N, boolean>> {
- this.mainCommand.flag(name, flagspec, args);
- return this as any;
- }
-
- /**
- * Add a required positional argument to the program.
- */
- requiredArgument<N extends keyof any, V>(
- name: N,
- conv: Converter<V>,
- args: ArgumentArgs<V> = {},
- ): Program<N, T & SubRecord<PN, N, V>> {
- this.mainCommand.requiredArgument(name, conv, args);
- return this as any;
- }
-
- /**
- * Add an optional argument to the program.
- */
- maybeArgument<N extends keyof any, V>(
- name: N,
- conv: Converter<V>,
- args: ArgumentArgs<V> = {},
- ): Program<N, T & SubRecord<PN, N, V | undefined>> {
- this.mainCommand.maybeArgument(name, conv, args);
- return this as any;
- }
-}
-
-export type GetArgType<T> = T extends Program<any, infer AT>
- ? AT
- : T extends CommandGroup<any, infer AT>
- ? AT
- : any;
-
-export function program<PN extends keyof any>(
- argKey: PN,
- args: ProgramArgs = {},
-): Program<PN, {}> {
- return new Program(argKey as string, args);
-}
-
-export function prompt(question: string): Promise<string> {
- const stdinReadline = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
- });
- return new Promise<string>((resolve, reject) => {
- stdinReadline.question(question, (res) => {
- resolve(res);
- stdinReadline.close();
- });
- });
-}
diff --git a/packages/taler-wallet-cli/src/harness/harness.ts b/packages/taler-wallet-cli/src/harness/harness.ts
deleted file mode 100644
index b4ac16dbf..000000000
--- a/packages/taler-wallet-cli/src/harness/harness.ts
+++ /dev/null
@@ -1,1779 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Test harness for various GNU Taler components.
- * Also provides a fault-injection proxy.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports
- */
-import * as util from "util";
-import * as fs from "fs";
-import * as path from "path";
-import * as http from "http";
-import * as readline from "readline";
-import { deepStrictEqual } from "assert";
-import { ChildProcess, spawn } from "child_process";
-import { URL } from "url";
-import axios, { AxiosError } from "axios";
-import {
- codecForMerchantOrderPrivateStatusResponse,
- codecForPostOrderResponse,
- PostOrderRequest,
- PostOrderResponse,
- MerchantOrderPrivateStatusResponse,
- TippingReserveStatus,
- TipCreateConfirmation,
- TipCreateRequest,
- MerchantInstancesResponse,
-} from "./merchantApiTypes";
-import {
- openPromise,
- OperationFailedError,
- WalletCoreApiClient,
-} from "@gnu-taler/taler-wallet-core";
-import {
- AmountJson,
- Amounts,
- Configuration,
- AmountString,
- Codec,
- buildCodecForObject,
- codecForString,
- Duration,
- parsePaytoUri,
- CoreApiResponse,
- createEddsaKeyPair,
- eddsaGetPublic,
- EddsaKeyPair,
- encodeCrock,
- getRandomBytes,
-} from "@gnu-taler/taler-util";
-import { CoinConfig } from "./denomStructures.js";
-
-const exec = util.promisify(require("child_process").exec);
-
-export async function delayMs(ms: number): Promise<void> {
- return new Promise((resolve, reject) => {
- setTimeout(() => resolve(), ms);
- });
-}
-
-export interface WithAuthorization {
- Authorization?: string;
-}
-
-interface WaitResult {
- code: number | null;
- signal: NodeJS.Signals | null;
-}
-
-/**
- * Run a shell command, return stdout.
- */
-export async function sh(
- t: GlobalTestState,
- logName: string,
- command: string,
- env: { [index: string]: string | undefined } = process.env,
-): Promise<string> {
- console.log("running command", command);
- return new Promise((resolve, reject) => {
- const stdoutChunks: Buffer[] = [];
- const proc = spawn(command, {
- stdio: ["inherit", "pipe", "pipe"],
- shell: true,
- env: env,
- });
- proc.stdout.on("data", (x) => {
- if (x instanceof Buffer) {
- stdoutChunks.push(x);
- } else {
- throw Error("unexpected data chunk type");
- }
- });
- const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
- const stderrLog = fs.createWriteStream(stderrLogFileName, {
- flags: "a",
- });
- proc.stderr.pipe(stderrLog);
- proc.on("exit", (code, signal) => {
- console.log(`child process exited (${code} / ${signal})`);
- if (code != 0) {
- reject(Error(`Unexpected exit code ${code} for '${command}'`));
- return;
- }
- const b = Buffer.concat(stdoutChunks).toString("utf-8");
- resolve(b);
- });
- proc.on("error", () => {
- reject(Error("Child process had error"));
- });
- });
-}
-
-function shellescape(args: string[]) {
- const ret = args.map((s) => {
- if (/[^A-Za-z0-9_\/:=-]/.test(s)) {
- s = "'" + s.replace(/'/g, "'\\''") + "'";
- s = s.replace(/^(?:'')+/g, "").replace(/\\'''/g, "\\'");
- }
- return s;
- });
- return ret.join(" ");
-}
-
-/**
- * Run a shell command, return stdout.
- *
- * Log stderr to a log file.
- */
-export async function runCommand(
- t: GlobalTestState,
- logName: string,
- command: string,
- args: string[],
- env: { [index: string]: string | undefined } = process.env,
-): Promise<string> {
- console.log("running command", shellescape([command, ...args]));
- return new Promise((resolve, reject) => {
- const stdoutChunks: Buffer[] = [];
- const proc = spawn(command, args, {
- stdio: ["inherit", "pipe", "pipe"],
- shell: false,
- env: env,
- });
- proc.stdout.on("data", (x) => {
- if (x instanceof Buffer) {
- stdoutChunks.push(x);
- } else {
- throw Error("unexpected data chunk type");
- }
- });
- const stderrLogFileName = path.join(t.testDir, `${logName}-stderr.log`);
- const stderrLog = fs.createWriteStream(stderrLogFileName, {
- flags: "a",
- });
- proc.stderr.pipe(stderrLog);
- proc.on("exit", (code, signal) => {
- console.log(`child process exited (${code} / ${signal})`);
- if (code != 0) {
- reject(Error(`Unexpected exit code ${code} for '${command}'`));
- return;
- }
- const b = Buffer.concat(stdoutChunks).toString("utf-8");
- resolve(b);
- });
- proc.on("error", () => {
- reject(Error("Child process had error"));
- });
- });
-}
-
-export class ProcessWrapper {
- private waitPromise: Promise<WaitResult>;
- constructor(public proc: ChildProcess) {
- this.waitPromise = new Promise((resolve, reject) => {
- proc.on("exit", (code, signal) => {
- resolve({ code, signal });
- });
- proc.on("error", (err) => {
- reject(err);
- });
- });
- }
-
- wait(): Promise<WaitResult> {
- return this.waitPromise;
- }
-}
-
-export class GlobalTestParams {
- testDir: string;
-}
-
-export class GlobalTestState {
- testDir: string;
- procs: ProcessWrapper[];
- servers: http.Server[];
- inShutdown: boolean = false;
- constructor(params: GlobalTestParams) {
- this.testDir = params.testDir;
- this.procs = [];
- this.servers = [];
- }
-
- async assertThrowsOperationErrorAsync(
- block: () => Promise<void>,
- ): Promise<OperationFailedError> {
- try {
- await block();
- } catch (e) {
- if (e instanceof OperationFailedError) {
- return e;
- }
- throw Error(`expected OperationFailedError to be thrown, but got ${e}`);
- }
- throw Error(
- `expected OperationFailedError to be thrown, but block finished without throwing`,
- );
- }
-
- async assertThrowsAsync(block: () => Promise<void>): Promise<any> {
- try {
- await block();
- } catch (e) {
- return e;
- }
- throw Error(
- `expected exception to be thrown, but block finished without throwing`,
- );
- }
-
- assertAxiosError(e: any): asserts e is AxiosError {
- if (!e.isAxiosError) {
- throw Error("expected axios error");
- }
- }
-
- assertTrue(b: boolean): asserts b {
- if (!b) {
- throw Error("test assertion failed");
- }
- }
-
- assertDeepEqual<T>(actual: any, expected: T): asserts actual is T {
- deepStrictEqual(actual, expected);
- }
-
- assertAmountEquals(
- amtActual: string | AmountJson,
- amtExpected: string | AmountJson,
- ): void {
- if (Amounts.cmp(amtActual, amtExpected) != 0) {
- throw Error(
- `test assertion failed: expected ${Amounts.stringify(
- amtExpected,
- )} but got ${Amounts.stringify(amtActual)}`,
- );
- }
- }
-
- assertAmountLeq(a: string | AmountJson, b: string | AmountJson): void {
- if (Amounts.cmp(a, b) > 0) {
- throw Error(
- `test assertion failed: expected ${Amounts.stringify(
- a,
- )} to be less or equal (leq) than ${Amounts.stringify(b)}`,
- );
- }
- }
-
- shutdownSync(): void {
- for (const s of this.servers) {
- s.close();
- s.removeAllListeners();
- }
- for (const p of this.procs) {
- if (p.proc.exitCode == null) {
- p.proc.kill("SIGTERM");
- }
- }
- }
-
- spawnService(
- command: string,
- args: string[],
- logName: string,
- env: { [index: string]: string | undefined } = process.env,
- ): ProcessWrapper {
- console.log(
- `spawning process (${logName}): ${shellescape([command, ...args])}`,
- );
- const proc = spawn(command, args, {
- stdio: ["inherit", "pipe", "pipe"],
- env: env,
- });
- console.log(`spawned process (${logName}) with pid ${proc.pid}`);
- proc.on("error", (err) => {
- console.log(`could not start process (${command})`, err);
- });
- proc.on("exit", (code, signal) => {
- console.log(`process ${logName} exited`);
- });
- const stderrLogFileName = this.testDir + `/${logName}-stderr.log`;
- const stderrLog = fs.createWriteStream(stderrLogFileName, {
- flags: "a",
- });
- proc.stderr.pipe(stderrLog);
- const stdoutLogFileName = this.testDir + `/${logName}-stdout.log`;
- const stdoutLog = fs.createWriteStream(stdoutLogFileName, {
- flags: "a",
- });
- proc.stdout.pipe(stdoutLog);
- const procWrap = new ProcessWrapper(proc);
- this.procs.push(procWrap);
- return procWrap;
- }
-
- async shutdown(): Promise<void> {
- if (this.inShutdown) {
- return;
- }
- if (shouldLingerInTest()) {
- console.log("refusing to shut down, lingering was requested");
- return;
- }
- this.inShutdown = true;
- console.log("shutting down");
- for (const s of this.servers) {
- s.close();
- s.removeAllListeners();
- }
- for (const p of this.procs) {
- if (p.proc.exitCode == null) {
- console.log("killing process", p.proc.pid);
- p.proc.kill("SIGTERM");
- await p.wait();
- }
- }
- }
-}
-
-export function shouldLingerInTest(): boolean {
- return !!process.env["TALER_TEST_LINGER"];
-}
-
-export interface TalerConfigSection {
- options: Record<string, string | undefined>;
-}
-
-export interface TalerConfig {
- sections: Record<string, TalerConfigSection>;
-}
-
-export interface DbInfo {
- /**
- * Postgres connection string.
- */
- connStr: string;
-
- dbname: string;
-}
-
-export async function setupDb(gc: GlobalTestState): Promise<DbInfo> {
- const dbname = "taler-integrationtest";
- await exec(`dropdb "${dbname}" || true`);
- await exec(`createdb "${dbname}"`);
- return {
- connStr: `postgres:///${dbname}`,
- dbname,
- };
-}
-
-export interface BankConfig {
- currency: string;
- httpPort: number;
- database: string;
- allowRegistrations: boolean;
- maxDebt?: string;
-}
-
-export interface FakeBankConfig {
- currency: string;
- httpPort: number;
-}
-
-function setTalerPaths(config: Configuration, home: string) {
- config.setString("paths", "taler_home", home);
- // We need to make sure that the path of taler_runtime_dir isn't too long,
- // as it contains unix domain sockets (108 character limit).
- const runDir = fs.mkdtempSync("/tmp/taler-test-");
- config.setString("paths", "taler_runtime_dir", runDir);
- config.setString(
- "paths",
- "taler_data_home",
- "$TALER_HOME/.local/share/taler/",
- );
- config.setString("paths", "taler_config_home", "$TALER_HOME/.config/taler/");
- config.setString("paths", "taler_cache_home", "$TALER_HOME/.config/taler/");
-}
-
-function setCoin(config: Configuration, c: CoinConfig) {
- const s = `coin_${c.name}`;
- config.setString(s, "value", c.value);
- config.setString(s, "duration_withdraw", c.durationWithdraw);
- config.setString(s, "duration_spend", c.durationSpend);
- config.setString(s, "duration_legal", c.durationLegal);
- config.setString(s, "fee_deposit", c.feeDeposit);
- config.setString(s, "fee_withdraw", c.feeWithdraw);
- config.setString(s, "fee_refresh", c.feeRefresh);
- config.setString(s, "fee_refund", c.feeRefund);
- config.setString(s, "rsa_keysize", `${c.rsaKeySize}`);
-}
-
-/**
- * Send an HTTP request until it succeeds or the
- * process dies.
- */
-export async function pingProc(
- proc: ProcessWrapper | undefined,
- url: string,
- serviceName: string,
-): Promise<void> {
- if (!proc || proc.proc.exitCode !== null) {
- throw Error(`service process ${serviceName} not started, can't ping`);
- }
- while (true) {
- try {
- console.log(`pinging ${serviceName}`);
- const resp = await axios.get(url);
- console.log(`service ${serviceName} available`);
- return;
- } catch (e: any) {
- console.log(`service ${serviceName} not ready:`, e.toString());
- await delayMs(1000);
- }
- if (!proc || proc.proc.exitCode !== null) {
- throw Error(`service process ${serviceName} stopped unexpectedly`);
- }
- }
-}
-
-export interface HarnessExchangeBankAccount {
- accountName: string;
- accountPassword: string;
- accountPaytoUri: string;
- wireGatewayApiBaseUrl: string;
-}
-
-export interface BankServiceInterface {
- readonly baseUrl: string;
- readonly port: number;
-}
-
-export enum CreditDebitIndicator {
- Credit = "credit",
- Debit = "debit",
-}
-
-export interface BankAccountBalanceResponse {
- balance: {
- amount: AmountString;
- credit_debit_indicator: CreditDebitIndicator;
- };
-}
-
-export namespace BankAccessApi {
- export async function getAccountBalance(
- bank: BankServiceInterface,
- bankUser: BankUser,
- ): Promise<BankAccountBalanceResponse> {
- const url = new URL(`accounts/${bankUser.username}`, bank.baseUrl);
- const resp = await axios.get(url.href, {
- auth: bankUser,
- });
- return resp.data;
- }
-
- export async function createWithdrawalOperation(
- bank: BankServiceInterface,
- bankUser: BankUser,
- amount: string,
- ): Promise<WithdrawalOperationInfo> {
- const url = new URL(
- `accounts/${bankUser.username}/withdrawals`,
- bank.baseUrl,
- );
- const resp = await axios.post(
- url.href,
- {
- amount,
- },
- {
- auth: bankUser,
- },
- );
- return codecForWithdrawalOperationInfo().decode(resp.data);
- }
-}
-
-export namespace BankApi {
- export async function registerAccount(
- bank: BankServiceInterface,
- username: string,
- password: string,
- ): Promise<BankUser> {
- const url = new URL("testing/register", bank.baseUrl);
- await axios.post(url.href, {
- username,
- password,
- });
- return {
- password,
- username,
- accountPaytoUri: `payto://x-taler-bank/localhost/${username}`,
- };
- }
-
- export async function createRandomBankUser(
- bank: BankServiceInterface,
- ): Promise<BankUser> {
- const username = "user-" + encodeCrock(getRandomBytes(10));
- const password = "pw-" + encodeCrock(getRandomBytes(10));
- return await registerAccount(bank, username, password);
- }
-
- export async function adminAddIncoming(
- bank: BankServiceInterface,
- params: {
- exchangeBankAccount: HarnessExchangeBankAccount;
- amount: string;
- reservePub: string;
- debitAccountPayto: string;
- },
- ) {
- const url = new URL(
- `taler-wire-gateway/${params.exchangeBankAccount.accountName}/admin/add-incoming`,
- bank.baseUrl,
- );
- await axios.post(
- url.href,
- {
- amount: params.amount,
- reserve_pub: params.reservePub,
- debit_account: params.debitAccountPayto,
- },
- {
- auth: {
- username: params.exchangeBankAccount.accountName,
- password: params.exchangeBankAccount.accountPassword,
- },
- },
- );
- }
-
- export async function confirmWithdrawalOperation(
- bank: BankServiceInterface,
- bankUser: BankUser,
- wopi: WithdrawalOperationInfo,
- ): Promise<void> {
- const url = new URL(
- `accounts/${bankUser.username}/withdrawals/${wopi.withdrawal_id}/confirm`,
- bank.baseUrl,
- );
- await axios.post(
- url.href,
- {},
- {
- auth: bankUser,
- },
- );
- }
-
- export async function abortWithdrawalOperation(
- bank: BankServiceInterface,
- bankUser: BankUser,
- wopi: WithdrawalOperationInfo,
- ): Promise<void> {
- const url = new URL(
- `accounts/${bankUser.username}/withdrawals/${wopi.withdrawal_id}/abort`,
- bank.baseUrl,
- );
- await axios.post(
- url.href,
- {},
- {
- auth: bankUser,
- },
- );
- }
-}
-
-export class BankService implements BankServiceInterface {
- proc: ProcessWrapper | undefined;
-
- static fromExistingConfig(gc: GlobalTestState): BankService {
- const cfgFilename = gc.testDir + "/bank.conf";
- console.log("reading bank config from", cfgFilename);
- const config = Configuration.load(cfgFilename);
- const bc: BankConfig = {
- allowRegistrations: config
- .getYesNo("bank", "allow_registrations")
- .required(),
- currency: config.getString("taler", "currency").required(),
- database: config.getString("bank", "database").required(),
- httpPort: config.getNumber("bank", "http_port").required(),
- };
- return new BankService(gc, bc, cfgFilename);
- }
-
- static async create(
- gc: GlobalTestState,
- bc: BankConfig,
- ): Promise<BankService> {
- const config = new Configuration();
- setTalerPaths(config, gc.testDir + "/talerhome");
- config.setString("taler", "currency", bc.currency);
- config.setString("bank", "database", bc.database);
- config.setString("bank", "http_port", `${bc.httpPort}`);
- config.setString("bank", "serve", "http");
- config.setString("bank", "max_debt_bank", `${bc.currency}:999999`);
- config.setString("bank", "max_debt", bc.maxDebt ?? `${bc.currency}:100`);
- config.setString(
- "bank",
- "allow_registrations",
- bc.allowRegistrations ? "yes" : "no",
- );
- const cfgFilename = gc.testDir + "/bank.conf";
- config.write(cfgFilename);
-
- await sh(
- gc,
- "taler-bank-manage_django",
- `taler-bank-manage -c '${cfgFilename}' django migrate`,
- );
- await sh(
- gc,
- "taler-bank-manage_django",
- `taler-bank-manage -c '${cfgFilename}' django provide_accounts`,
- );
-
- return new BankService(gc, bc, cfgFilename);
- }
-
- setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) {
- const config = Configuration.load(this.configFile);
- config.setString("bank", "suggested_exchange", e.baseUrl);
- config.setString("bank", "suggested_exchange_payto", exchangePayto);
- }
-
- get baseUrl(): string {
- return `http://localhost:${this.bankConfig.httpPort}/`;
- }
-
- async createExchangeAccount(
- accountName: string,
- password: string,
- ): Promise<HarnessExchangeBankAccount> {
- await sh(
- this.globalTestState,
- "taler-bank-manage_django",
- `taler-bank-manage -c '${this.configFile}' django add_bank_account ${accountName}`,
- );
- await sh(
- this.globalTestState,
- "taler-bank-manage_django",
- `taler-bank-manage -c '${this.configFile}' django changepassword_unsafe ${accountName} ${password}`,
- );
- await sh(
- this.globalTestState,
- "taler-bank-manage_django",
- `taler-bank-manage -c '${this.configFile}' django top_up ${accountName} ${this.bankConfig.currency}:100000`,
- );
- return {
- accountName: accountName,
- accountPassword: password,
- accountPaytoUri: `payto://x-taler-bank/${accountName}`,
- wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/taler-wire-gateway/${accountName}/`,
- };
- }
-
- get port() {
- return this.bankConfig.httpPort;
- }
-
- private constructor(
- private globalTestState: GlobalTestState,
- private bankConfig: BankConfig,
- private configFile: string,
- ) {}
-
- async start(): Promise<void> {
- this.proc = this.globalTestState.spawnService(
- "taler-bank-manage",
- ["-c", this.configFile, "serve"],
- "bank",
- );
- }
-
- async pingUntilAvailable(): Promise<void> {
- const url = `http://localhost:${this.bankConfig.httpPort}/config`;
- await pingProc(this.proc, url, "bank");
- }
-}
-
-export class FakeBankService {
- proc: ProcessWrapper | undefined;
-
- static fromExistingConfig(gc: GlobalTestState): FakeBankService {
- const cfgFilename = gc.testDir + "/bank.conf";
- console.log("reading fakebank config from", cfgFilename);
- const config = Configuration.load(cfgFilename);
- const bc: FakeBankConfig = {
- currency: config.getString("taler", "currency").required(),
- httpPort: config.getNumber("bank", "http_port").required(),
- };
- return new FakeBankService(gc, bc, cfgFilename);
- }
-
- static async create(
- gc: GlobalTestState,
- bc: FakeBankConfig,
- ): Promise<FakeBankService> {
- const config = new Configuration();
- setTalerPaths(config, gc.testDir + "/talerhome");
- config.setString("taler", "currency", bc.currency);
- config.setString("bank", "http_port", `${bc.httpPort}`);
- const cfgFilename = gc.testDir + "/bank.conf";
- config.write(cfgFilename);
- return new FakeBankService(gc, bc, cfgFilename);
- }
-
- get baseUrl(): string {
- return `http://localhost:${this.bankConfig.httpPort}/`;
- }
-
- get port() {
- return this.bankConfig.httpPort;
- }
-
- private constructor(
- private globalTestState: GlobalTestState,
- private bankConfig: FakeBankConfig,
- private configFile: string,
- ) {}
-
- async start(): Promise<void> {
- this.proc = this.globalTestState.spawnService(
- "taler-fakebank-run",
- ["-c", this.configFile],
- "fakebank",
- );
- }
-
- async pingUntilAvailable(): Promise<void> {
- // Fakebank doesn't have "/config", so we ping just "/".
- const url = `http://localhost:${this.bankConfig.httpPort}/`;
- await pingProc(this.proc, url, "bank");
- }
-}
-
-export interface BankUser {
- username: string;
- password: string;
- accountPaytoUri: string;
-}
-
-export interface WithdrawalOperationInfo {
- withdrawal_id: string;
- taler_withdraw_uri: string;
-}
-
-const codecForWithdrawalOperationInfo = (): Codec<WithdrawalOperationInfo> =>
- buildCodecForObject<WithdrawalOperationInfo>()
- .property("withdrawal_id", codecForString())
- .property("taler_withdraw_uri", codecForString())
- .build("WithdrawalOperationInfo");
-
-export interface ExchangeConfig {
- name: string;
- currency: string;
- roundUnit?: string;
- httpPort: number;
- database: string;
-}
-
-export interface ExchangeServiceInterface {
- readonly baseUrl: string;
- readonly port: number;
- readonly name: string;
- readonly masterPub: string;
-}
-
-export class ExchangeService implements ExchangeServiceInterface {
- static fromExistingConfig(gc: GlobalTestState, exchangeName: string) {
- const cfgFilename = gc.testDir + `/exchange-${exchangeName}.conf`;
- const config = Configuration.load(cfgFilename);
- const ec: ExchangeConfig = {
- currency: config.getString("taler", "currency").required(),
- database: config.getString("exchangedb-postgres", "config").required(),
- httpPort: config.getNumber("exchange", "port").required(),
- name: exchangeName,
- roundUnit: config.getString("taler", "currency_round_unit").required(),
- };
- const privFile = config.getPath("exchange", "master_priv_file").required();
- const eddsaPriv = fs.readFileSync(privFile);
- const keyPair: EddsaKeyPair = {
- eddsaPriv,
- eddsaPub: eddsaGetPublic(eddsaPriv),
- };
- return new ExchangeService(gc, ec, cfgFilename, keyPair);
- }
-
- private currentTimetravel: Duration | undefined;
-
- setTimetravel(t: Duration | undefined): void {
- if (this.isRunning()) {
- throw Error("can't set time travel while the exchange is running");
- }
- this.currentTimetravel = t;
- }
-
- private get timetravelArg(): string | undefined {
- if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
- // Convert to microseconds
- return `--timetravel=+${this.currentTimetravel.d_ms * 1000}`;
- }
- return undefined;
- }
-
- /**
- * Return an empty array if no time travel is set,
- * and an array with the time travel command line argument
- * otherwise.
- */
- private get timetravelArgArr(): string[] {
- const tta = this.timetravelArg;
- if (tta) {
- return [tta];
- }
- return [];
- }
-
- async runWirewatchOnce() {
- await runCommand(
- this.globalState,
- `exchange-${this.name}-wirewatch-once`,
- "taler-exchange-wirewatch",
- [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
- );
- }
-
- async runAggregatorOnce() {
- await runCommand(
- this.globalState,
- `exchange-${this.name}-aggregator-once`,
- "taler-exchange-aggregator",
- [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
- );
- }
-
- async runTransferOnce() {
- await runCommand(
- this.globalState,
- `exchange-${this.name}-transfer-once`,
- "taler-exchange-transfer",
- [...this.timetravelArgArr, "-c", this.configFilename, "-t"],
- );
- }
-
- changeConfig(f: (config: Configuration) => void) {
- const config = Configuration.load(this.configFilename);
- f(config);
- config.write(this.configFilename);
- }
-
- static create(gc: GlobalTestState, e: ExchangeConfig) {
- const config = new Configuration();
- config.setString("taler", "currency", e.currency);
- config.setString(
- "taler",
- "currency_round_unit",
- e.roundUnit ?? `${e.currency}:0.01`,
- );
- setTalerPaths(config, gc.testDir + "/talerhome");
- config.setString(
- "exchange",
- "revocation_dir",
- "${TALER_DATA_HOME}/exchange/revocations",
- );
- config.setString("exchange", "max_keys_caching", "forever");
- config.setString("exchange", "db", "postgres");
- config.setString(
- "exchange-offline",
- "master_priv_file",
- "${TALER_DATA_HOME}/exchange/offline-keys/master.priv",
- );
- config.setString("exchange", "serve", "tcp");
- config.setString("exchange", "port", `${e.httpPort}`);
-
- config.setString("exchangedb-postgres", "config", e.database);
-
- config.setString("taler-exchange-secmod-eddsa", "lookahead_sign", "20 s");
- config.setString("taler-exchange-secmod-rsa", "lookahead_sign", "20 s");
-
- const exchangeMasterKey = createEddsaKeyPair();
-
- config.setString(
- "exchange",
- "master_public_key",
- encodeCrock(exchangeMasterKey.eddsaPub),
- );
-
- const masterPrivFile = config
- .getPath("exchange-offline", "master_priv_file")
- .required();
-
- fs.mkdirSync(path.dirname(masterPrivFile), { recursive: true });
-
- fs.writeFileSync(masterPrivFile, Buffer.from(exchangeMasterKey.eddsaPriv));
-
- const cfgFilename = gc.testDir + `/exchange-${e.name}.conf`;
- config.write(cfgFilename);
- return new ExchangeService(gc, e, cfgFilename, exchangeMasterKey);
- }
-
- addOfferedCoins(offeredCoins: ((curr: string) => CoinConfig)[]) {
- const config = Configuration.load(this.configFilename);
- offeredCoins.forEach((cc) =>
- setCoin(config, cc(this.exchangeConfig.currency)),
- );
- config.write(this.configFilename);
- }
-
- addCoinConfigList(ccs: CoinConfig[]) {
- const config = Configuration.load(this.configFilename);
- ccs.forEach((cc) => setCoin(config, cc));
- config.write(this.configFilename);
- }
-
- get masterPub() {
- return encodeCrock(this.keyPair.eddsaPub);
- }
-
- get port() {
- return this.exchangeConfig.httpPort;
- }
-
- async addBankAccount(
- localName: string,
- exchangeBankAccount: HarnessExchangeBankAccount,
- ): Promise<void> {
- const config = Configuration.load(this.configFilename);
- config.setString(
- `exchange-account-${localName}`,
- "wire_response",
- `\${TALER_DATA_HOME}/exchange/account-${localName}.json`,
- );
- config.setString(
- `exchange-account-${localName}`,
- "payto_uri",
- exchangeBankAccount.accountPaytoUri,
- );
- config.setString(`exchange-account-${localName}`, "enable_credit", "yes");
- config.setString(`exchange-account-${localName}`, "enable_debit", "yes");
- config.setString(
- `exchange-accountcredentials-${localName}`,
- "wire_gateway_url",
- exchangeBankAccount.wireGatewayApiBaseUrl,
- );
- config.setString(
- `exchange-accountcredentials-${localName}`,
- "wire_gateway_auth_method",
- "basic",
- );
- config.setString(
- `exchange-accountcredentials-${localName}`,
- "username",
- exchangeBankAccount.accountName,
- );
- config.setString(
- `exchange-accountcredentials-${localName}`,
- "password",
- exchangeBankAccount.accountPassword,
- );
- config.write(this.configFilename);
- }
-
- exchangeHttpProc: ProcessWrapper | undefined;
- exchangeWirewatchProc: ProcessWrapper | undefined;
-
- helperCryptoRsaProc: ProcessWrapper | undefined;
- helperCryptoEddsaProc: ProcessWrapper | undefined;
-
- constructor(
- private globalState: GlobalTestState,
- private exchangeConfig: ExchangeConfig,
- private configFilename: string,
- private keyPair: EddsaKeyPair,
- ) {}
-
- get name() {
- return this.exchangeConfig.name;
- }
-
- get baseUrl() {
- return `http://localhost:${this.exchangeConfig.httpPort}/`;
- }
-
- isRunning(): boolean {
- return !!this.exchangeWirewatchProc || !!this.exchangeHttpProc;
- }
-
- async stop(): Promise<void> {
- const wirewatch = this.exchangeWirewatchProc;
- if (wirewatch) {
- wirewatch.proc.kill("SIGTERM");
- await wirewatch.wait();
- this.exchangeWirewatchProc = undefined;
- }
- const httpd = this.exchangeHttpProc;
- if (httpd) {
- httpd.proc.kill("SIGTERM");
- await httpd.wait();
- this.exchangeHttpProc = undefined;
- }
- const cryptoRsa = this.helperCryptoRsaProc;
- if (cryptoRsa) {
- cryptoRsa.proc.kill("SIGTERM");
- await cryptoRsa.wait();
- this.helperCryptoRsaProc = undefined;
- }
- const cryptoEddsa = this.helperCryptoEddsaProc;
- if (cryptoEddsa) {
- cryptoEddsa.proc.kill("SIGTERM");
- await cryptoEddsa.wait();
- this.helperCryptoRsaProc = undefined;
- }
- }
-
- /**
- * Update keys signing the keys generated by the security module
- * with the offline signing key.
- */
- async keyup(): Promise<void> {
- await runCommand(
- this.globalState,
- "exchange-offline",
- "taler-exchange-offline",
- ["-c", this.configFilename, "download", "sign", "upload"],
- );
-
- const accounts: string[] = [];
- const accountTargetTypes: Set<string> = new Set();
-
- const config = Configuration.load(this.configFilename);
- for (const sectionName of config.getSectionNames()) {
- if (sectionName.startsWith("EXCHANGE-ACCOUNT-")) {
- const paytoUri = config.getString(sectionName, "payto_uri").required();
- const p = parsePaytoUri(paytoUri);
- if (!p) {
- throw Error(`invalid payto uri in exchange config: ${paytoUri}`);
- }
- accountTargetTypes.add(p?.targetType);
- accounts.push(paytoUri);
- }
- }
-
- console.log("configuring bank accounts", accounts);
-
- for (const acc of accounts) {
- await runCommand(
- this.globalState,
- "exchange-offline",
- "taler-exchange-offline",
- ["-c", this.configFilename, "enable-account", acc, "upload"],
- );
- }
-
- const year = new Date().getFullYear();
- for (const accTargetType of accountTargetTypes.values()) {
- for (let i = year; i < year + 5; i++) {
- await runCommand(
- this.globalState,
- "exchange-offline",
- "taler-exchange-offline",
- [
- "-c",
- this.configFilename,
- "wire-fee",
- `${i}`,
- accTargetType,
- `${this.exchangeConfig.currency}:0.01`,
- `${this.exchangeConfig.currency}:0.01`,
- "upload",
- ],
- );
- }
- }
- }
-
- async revokeDenomination(denomPubHash: string) {
- if (!this.isRunning()) {
- throw Error("exchange must be running when revoking denominations");
- }
- await runCommand(
- this.globalState,
- "exchange-offline",
- "taler-exchange-offline",
- [
- "-c",
- this.configFilename,
- "revoke-denomination",
- denomPubHash,
- "upload",
- ],
- );
- }
-
- async purgeSecmodKeys(): Promise<void> {
- const cfg = Configuration.load(this.configFilename);
- const rsaKeydir = cfg
- .getPath("taler-exchange-secmod-rsa", "KEY_DIR")
- .required();
- const eddsaKeydir = cfg
- .getPath("taler-exchange-secmod-eddsa", "KEY_DIR")
- .required();
- // Be *VERY* careful when changing this, or you will accidentally delete user data.
- await sh(this.globalState, "rm-secmod-keys", `rm -rf ${rsaKeydir}/COIN_*`);
- await sh(this.globalState, "rm-secmod-keys", `rm ${eddsaKeydir}/*`);
- }
-
- async purgeDatabase(): Promise<void> {
- await sh(
- this.globalState,
- "exchange-dbinit",
- `taler-exchange-dbinit -r -c "${this.configFilename}"`,
- );
- }
-
- async start(): Promise<void> {
- if (this.isRunning()) {
- throw Error("exchange is already running");
- }
- await sh(
- this.globalState,
- "exchange-dbinit",
- `taler-exchange-dbinit -c "${this.configFilename}"`,
- );
-
- this.helperCryptoEddsaProc = this.globalState.spawnService(
- "taler-exchange-secmod-eddsa",
- ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
- `exchange-crypto-eddsa-${this.name}`,
- );
-
- this.helperCryptoRsaProc = this.globalState.spawnService(
- "taler-exchange-secmod-rsa",
- ["-c", this.configFilename, "-LDEBUG", ...this.timetravelArgArr],
- `exchange-crypto-rsa-${this.name}`,
- );
-
- this.exchangeWirewatchProc = this.globalState.spawnService(
- "taler-exchange-wirewatch",
- ["-c", this.configFilename, ...this.timetravelArgArr],
- `exchange-wirewatch-${this.name}`,
- );
-
- this.exchangeHttpProc = this.globalState.spawnService(
- "taler-exchange-httpd",
- ["-c", this.configFilename, ...this.timetravelArgArr],
- `exchange-httpd-${this.name}`,
- );
-
- await this.pingUntilAvailable();
- await this.keyup();
- }
-
- async pingUntilAvailable(): Promise<void> {
- // We request /management/keys, since /keys can block
- // when we didn't do the key setup yet.
- const url = `http://localhost:${this.exchangeConfig.httpPort}/management/keys`;
- await pingProc(this.exchangeHttpProc, url, `exchange (${this.name})`);
- }
-}
-
-export interface MerchantConfig {
- name: string;
- currency: string;
- httpPort: number;
- database: string;
-}
-
-export interface PrivateOrderStatusQuery {
- instance?: string;
- orderId: string;
- sessionId?: string;
-}
-
-export interface MerchantServiceInterface {
- makeInstanceBaseUrl(instanceName?: string): string;
- readonly port: number;
- readonly name: string;
-}
-
-export class MerchantApiClient {
- constructor(
- private baseUrl: string,
- public readonly auth: MerchantAuthConfiguration,
- ) {}
-
- async changeAuth(auth: MerchantAuthConfiguration): Promise<void> {
- const url = new URL("private/auth", this.baseUrl);
- await axios.post(url.href, auth, {
- headers: this.makeAuthHeader(),
- });
- }
-
- async deleteInstance(instanceId: string) {
- const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
- await axios.delete(url.href, {
- headers: this.makeAuthHeader(),
- });
- }
-
- async createInstance(req: MerchantInstanceConfig): Promise<void> {
- const url = new URL("management/instances", this.baseUrl);
- await axios.post(url.href, req, {
- headers: this.makeAuthHeader(),
- });
- }
-
- async getInstances(): Promise<MerchantInstancesResponse> {
- const url = new URL("management/instances", this.baseUrl);
- const resp = await axios.get(url.href, {
- headers: this.makeAuthHeader(),
- });
- return resp.data;
- }
-
- async getInstanceFullDetails(instanceId: string): Promise<any> {
- const url = new URL(`management/instances/${instanceId}`, this.baseUrl);
- try {
- const resp = await axios.get(url.href, {
- headers: this.makeAuthHeader(),
- });
- return resp.data;
- } catch (e) {
- throw e;
- }
- }
-
- makeAuthHeader(): Record<string, string> {
- switch (this.auth.method) {
- case "external":
- return {};
- case "token":
- return {
- Authorization: `Bearer ${this.auth.token}`,
- };
- }
- }
-}
-
-/**
- * FIXME: This should be deprecated in favor of MerchantApiClient
- */
-export namespace MerchantPrivateApi {
- export async function createOrder(
- merchantService: MerchantServiceInterface,
- instanceName: string,
- req: PostOrderRequest,
- withAuthorization: WithAuthorization = {},
- ): Promise<PostOrderResponse> {
- const baseUrl = merchantService.makeInstanceBaseUrl(instanceName);
- let url = new URL("private/orders", baseUrl);
- const resp = await axios.post(url.href, req, {
- headers: withAuthorization,
- });
- return codecForPostOrderResponse().decode(resp.data);
- }
-
- export async function queryPrivateOrderStatus(
- merchantService: MerchantServiceInterface,
- query: PrivateOrderStatusQuery,
- withAuthorization: WithAuthorization = {},
- ): Promise<MerchantOrderPrivateStatusResponse> {
- const reqUrl = new URL(
- `private/orders/${query.orderId}`,
- merchantService.makeInstanceBaseUrl(query.instance),
- );
- if (query.sessionId) {
- reqUrl.searchParams.set("session_id", query.sessionId);
- }
- const resp = await axios.get(reqUrl.href, { headers: withAuthorization });
- return codecForMerchantOrderPrivateStatusResponse().decode(resp.data);
- }
-
- export async function giveRefund(
- merchantService: MerchantServiceInterface,
- r: {
- instance: string;
- orderId: string;
- amount: string;
- justification: string;
- },
- ): Promise<{ talerRefundUri: string }> {
- const reqUrl = new URL(
- `private/orders/${r.orderId}/refund`,
- merchantService.makeInstanceBaseUrl(r.instance),
- );
- const resp = await axios.post(reqUrl.href, {
- refund: r.amount,
- reason: r.justification,
- });
- return {
- talerRefundUri: resp.data.taler_refund_uri,
- };
- }
-
- export async function createTippingReserve(
- merchantService: MerchantServiceInterface,
- instance: string,
- req: CreateMerchantTippingReserveRequest,
- ): Promise<CreateMerchantTippingReserveConfirmation> {
- const reqUrl = new URL(
- `private/reserves`,
- merchantService.makeInstanceBaseUrl(instance),
- );
- const resp = await axios.post(reqUrl.href, req);
- // FIXME: validate
- return resp.data;
- }
-
- export async function queryTippingReserves(
- merchantService: MerchantServiceInterface,
- instance: string,
- ): Promise<TippingReserveStatus> {
- const reqUrl = new URL(
- `private/reserves`,
- merchantService.makeInstanceBaseUrl(instance),
- );
- const resp = await axios.get(reqUrl.href);
- // FIXME: validate
- return resp.data;
- }
-
- export async function giveTip(
- merchantService: MerchantServiceInterface,
- instance: string,
- req: TipCreateRequest,
- ): Promise<TipCreateConfirmation> {
- const reqUrl = new URL(
- `private/tips`,
- merchantService.makeInstanceBaseUrl(instance),
- );
- const resp = await axios.post(reqUrl.href, req);
- // FIXME: validate
- return resp.data;
- }
-}
-
-export interface CreateMerchantTippingReserveRequest {
- // Amount that the merchant promises to put into the reserve
- initial_balance: AmountString;
-
- // Exchange the merchant intends to use for tipping
- exchange_url: string;
-
- // Desired wire method, for example "iban" or "x-taler-bank"
- wire_method: string;
-}
-
-export interface CreateMerchantTippingReserveConfirmation {
- // Public key identifying the reserve
- reserve_pub: string;
-
- // Wire account of the exchange where to transfer the funds
- payto_uri: string;
-}
-
-export class MerchantService implements MerchantServiceInterface {
- static fromExistingConfig(gc: GlobalTestState, name: string) {
- const cfgFilename = gc.testDir + `/merchant-${name}.conf`;
- const config = Configuration.load(cfgFilename);
- const mc: MerchantConfig = {
- currency: config.getString("taler", "currency").required(),
- database: config.getString("merchantdb-postgres", "config").required(),
- httpPort: config.getNumber("merchant", "port").required(),
- name,
- };
- return new MerchantService(gc, mc, cfgFilename);
- }
-
- proc: ProcessWrapper | undefined;
-
- constructor(
- private globalState: GlobalTestState,
- private merchantConfig: MerchantConfig,
- private configFilename: string,
- ) {}
-
- private currentTimetravel: Duration | undefined;
-
- private isRunning(): boolean {
- return !!this.proc;
- }
-
- setTimetravel(t: Duration | undefined): void {
- if (this.isRunning()) {
- throw Error("can't set time travel while the exchange is running");
- }
- this.currentTimetravel = t;
- }
-
- private get timetravelArg(): string | undefined {
- if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
- // Convert to microseconds
- return `--timetravel=+${this.currentTimetravel.d_ms * 1000}`;
- }
- return undefined;
- }
-
- /**
- * Return an empty array if no time travel is set,
- * and an array with the time travel command line argument
- * otherwise.
- */
- private get timetravelArgArr(): string[] {
- const tta = this.timetravelArg;
- if (tta) {
- return [tta];
- }
- return [];
- }
-
- get port(): number {
- return this.merchantConfig.httpPort;
- }
-
- get name(): string {
- return this.merchantConfig.name;
- }
-
- async stop(): Promise<void> {
- const httpd = this.proc;
- if (httpd) {
- httpd.proc.kill("SIGTERM");
- await httpd.wait();
- this.proc = undefined;
- }
- }
-
- async start(): Promise<void> {
- await exec(`taler-merchant-dbinit -c "${this.configFilename}"`);
-
- this.proc = this.globalState.spawnService(
- "taler-merchant-httpd",
- ["-LDEBUG", "-c", this.configFilename, ...this.timetravelArgArr],
- `merchant-${this.merchantConfig.name}`,
- );
- }
-
- static async create(
- gc: GlobalTestState,
- mc: MerchantConfig,
- ): Promise<MerchantService> {
- const config = new Configuration();
- config.setString("taler", "currency", mc.currency);
-
- const cfgFilename = gc.testDir + `/merchant-${mc.name}.conf`;
- setTalerPaths(config, gc.testDir + "/talerhome");
- config.setString("merchant", "serve", "tcp");
- config.setString("merchant", "port", `${mc.httpPort}`);
- config.setString(
- "merchant",
- "keyfile",
- "${TALER_DATA_HOME}/merchant/merchant.priv",
- );
- config.setString("merchantdb-postgres", "config", mc.database);
- config.write(cfgFilename);
-
- return new MerchantService(gc, mc, cfgFilename);
- }
-
- addExchange(e: ExchangeServiceInterface): void {
- const config = Configuration.load(this.configFilename);
- config.setString(
- `merchant-exchange-${e.name}`,
- "exchange_base_url",
- e.baseUrl,
- );
- config.setString(
- `merchant-exchange-${e.name}`,
- "currency",
- this.merchantConfig.currency,
- );
- config.setString(`merchant-exchange-${e.name}`, "master_key", e.masterPub);
- config.write(this.configFilename);
- }
-
- async addDefaultInstance(): Promise<void> {
- return await this.addInstance({
- id: "default",
- name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
- auth: {
- method: "external",
- },
- });
- }
-
- async addInstance(
- instanceConfig: PartialMerchantInstanceConfig,
- ): Promise<void> {
- if (!this.proc) {
- throw Error("merchant must be running to add instance");
- }
- console.log("adding instance");
- const url = `http://localhost:${this.merchantConfig.httpPort}/management/instances`;
- const auth = instanceConfig.auth ?? { method: "external" };
- await axios.post(url, {
- auth,
- payto_uris: instanceConfig.paytoUris,
- id: instanceConfig.id,
- name: instanceConfig.name,
- address: instanceConfig.address ?? {},
- jurisdiction: instanceConfig.jurisdiction ?? {},
- default_max_wire_fee:
- instanceConfig.defaultMaxWireFee ??
- `${this.merchantConfig.currency}:1.0`,
- default_wire_fee_amortization:
- instanceConfig.defaultWireFeeAmortization ?? 3,
- default_max_deposit_fee:
- instanceConfig.defaultMaxDepositFee ??
- `${this.merchantConfig.currency}:1.0`,
- default_wire_transfer_delay: instanceConfig.defaultWireTransferDelay ?? {
- d_ms: "forever",
- },
- default_pay_delay: instanceConfig.defaultPayDelay ?? { d_ms: "forever" },
- });
- }
-
- makeInstanceBaseUrl(instanceName?: string): string {
- if (instanceName === undefined || instanceName === "default") {
- return `http://localhost:${this.merchantConfig.httpPort}/`;
- } else {
- return `http://localhost:${this.merchantConfig.httpPort}/instances/${instanceName}/`;
- }
- }
-
- async pingUntilAvailable(): Promise<void> {
- const url = `http://localhost:${this.merchantConfig.httpPort}/config`;
- await pingProc(this.proc, url, `merchant (${this.merchantConfig.name})`);
- }
-}
-
-export interface MerchantAuthConfiguration {
- method: "external" | "token";
- token?: string;
-}
-
-export interface PartialMerchantInstanceConfig {
- auth?: MerchantAuthConfiguration;
- id: string;
- name: string;
- paytoUris: string[];
- address?: unknown;
- jurisdiction?: unknown;
- defaultMaxWireFee?: string;
- defaultMaxDepositFee?: string;
- defaultWireFeeAmortization?: number;
- defaultWireTransferDelay?: Duration;
- defaultPayDelay?: Duration;
-}
-
-export interface MerchantInstanceConfig {
- auth: MerchantAuthConfiguration;
- id: string;
- name: string;
- payto_uris: string[];
- address: unknown;
- jurisdiction: unknown;
- default_max_wire_fee: string;
- default_max_deposit_fee: string;
- default_wire_fee_amortization: number;
- default_wire_transfer_delay: Duration;
- default_pay_delay: Duration;
-}
-
-type TestStatus = "pass" | "fail" | "skip";
-
-export interface TestRunResult {
- /**
- * Name of the test.
- */
- name: string;
-
- /**
- * How long did the test run?
- */
- timeSec: number;
-
- status: TestStatus;
-
- reason?: string;
-}
-
-export async function runTestWithState(
- gc: GlobalTestState,
- testMain: (t: GlobalTestState) => Promise<void>,
- testName: string,
- linger: boolean = false,
-): Promise<TestRunResult> {
- const startMs = new Date().getTime();
-
- const p = openPromise();
- let status: TestStatus;
-
- const handleSignal = (s: string) => {
- console.warn(
- `**** received fatal process event, terminating test ${testName}`,
- );
- gc.shutdownSync();
- process.exit(1);
- };
-
- process.on("SIGINT", handleSignal);
- process.on("SIGTERM", handleSignal);
- process.on("unhandledRejection", handleSignal);
- process.on("uncaughtException", handleSignal);
-
- try {
- console.log("running test in directory", gc.testDir);
- await Promise.race([testMain(gc), p.promise]);
- status = "pass";
- if (linger) {
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
- terminal: true,
- });
- await new Promise<void>((resolve, reject) => {
- rl.question("Press enter to shut down test.", () => {
- resolve();
- });
- });
- rl.close();
- }
- } catch (e) {
- console.error("FATAL: test failed with exception", e);
- status = "fail";
- } finally {
- await gc.shutdown();
- }
- const afterMs = new Date().getTime();
- return {
- name: testName,
- timeSec: (afterMs - startMs) / 1000,
- status,
- };
-}
-
-function shellWrap(s: string) {
- return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'";
-}
-
-export class WalletCli {
- private currentTimetravel: Duration | undefined;
- private _client: WalletCoreApiClient;
-
- setTimetravel(d: Duration | undefined) {
- this.currentTimetravel = d;
- }
-
- private get timetravelArg(): string | undefined {
- if (this.currentTimetravel && this.currentTimetravel.d_ms !== "forever") {
- // Convert to microseconds
- return `--timetravel=${this.currentTimetravel.d_ms * 1000}`;
- }
- return undefined;
- }
-
- constructor(
- private globalTestState: GlobalTestState,
- private name: string = "default",
- ) {
- const self = this;
- this._client = {
- async call(op: any, payload: any): Promise<any> {
- console.log("calling wallet with timetravel arg", self.timetravelArg);
- const resp = await sh(
- self.globalTestState,
- `wallet-${self.name}`,
- `taler-wallet-cli ${
- self.timetravelArg ?? ""
- } --no-throttle --wallet-db '${self.dbfile}' api '${op}' ${shellWrap(
- JSON.stringify(payload),
- )}`,
- );
- console.log(resp);
- const ar = JSON.parse(resp) as CoreApiResponse;
- if (ar.type === "error") {
- throw new OperationFailedError(ar.error);
- } else {
- return ar.result;
- }
- },
- };
- }
-
- get dbfile(): string {
- return this.globalTestState.testDir + `/walletdb-${this.name}.json`;
- }
-
- deleteDatabase() {
- fs.unlinkSync(this.dbfile);
- }
-
- private get timetravelArgArr(): string[] {
- const tta = this.timetravelArg;
- if (tta) {
- return [tta];
- }
- return [];
- }
-
- get client(): WalletCoreApiClient {
- return this._client;
- }
-
- async runUntilDone(args: { maxRetries?: number } = {}): Promise<void> {
- await runCommand(
- this.globalTestState,
- `wallet-${this.name}`,
- "taler-wallet-cli",
- [
- "--no-throttle",
- ...this.timetravelArgArr,
- "--wallet-db",
- this.dbfile,
- "run-until-done",
- ...(args.maxRetries ? ["--max-retries", `${args.maxRetries}`] : []),
- ],
- );
- }
-
- async runPending(): Promise<void> {
- await runCommand(
- this.globalTestState,
- `wallet-${this.name}`,
- "taler-wallet-cli",
- [
- "--no-throttle",
- ...this.timetravelArgArr,
- "--wallet-db",
- this.dbfile,
- "run-pending",
- ],
- );
- }
-}
diff --git a/packages/taler-wallet-cli/src/harness/helpers.ts b/packages/taler-wallet-cli/src/harness/helpers.ts
deleted file mode 100644
index 3b4e1643f..000000000
--- a/packages/taler-wallet-cli/src/harness/helpers.ts
+++ /dev/null
@@ -1,406 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Helpers to create typical test environments.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports
- */
-import {
- FaultInjectedExchangeService,
- FaultInjectedMerchantService,
-} from "./faultInjection";
-import { CoinConfig, defaultCoinConfig } from "./denomStructures";
-import {
- AmountString,
- Duration,
- ContractTerms,
- PreparePayResultType,
- ConfirmPayResultType,
-} from "@gnu-taler/taler-util";
-import {
- DbInfo,
- BankService,
- ExchangeService,
- MerchantService,
- WalletCli,
- GlobalTestState,
- setupDb,
- ExchangeServiceInterface,
- BankApi,
- BankAccessApi,
- MerchantServiceInterface,
- MerchantPrivateApi,
- HarnessExchangeBankAccount,
- WithAuthorization,
-} from "./harness.js";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-
-export interface SimpleTestEnvironment {
- commonDb: DbInfo;
- bank: BankService;
- exchange: ExchangeService;
- exchangeBankAccount: HarnessExchangeBankAccount;
- merchant: MerchantService;
- wallet: WalletCli;
-}
-
-export function getRandomIban(countryCode: string): string {
- return `${countryCode}715001051796${(Math.random() * 100000000)
- .toString()
- .substring(0, 6)}`;
-}
-
-export function getRandomString(): string {
- return Math.random().toString(36).substring(2);
-}
-
-/**
- * Run a test case with a simple TESTKUDOS Taler environment, consisting
- * of one exchange, one bank and one merchant.
- */
-export async function createSimpleTestkudosEnvironment(
- t: GlobalTestState,
- coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")),
-): Promise<SimpleTestEnvironment> {
- const db = await setupDb(t);
-
- const bank = await BankService.create(t, {
- allowRegistrations: true,
- currency: "TESTKUDOS",
- database: db.connStr,
- httpPort: 8082,
- });
-
- const exchange = ExchangeService.create(t, {
- name: "testexchange-1",
- currency: "TESTKUDOS",
- httpPort: 8081,
- database: db.connStr,
- });
-
- const merchant = await MerchantService.create(t, {
- name: "testmerchant-1",
- currency: "TESTKUDOS",
- httpPort: 8083,
- database: db.connStr,
- });
-
- const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
- "x",
- );
- exchange.addBankAccount("1", exchangeBankAccount);
-
- bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri);
-
- await bank.start();
-
- await bank.pingUntilAvailable();
-
- exchange.addCoinConfigList(coinConfig);
-
- await exchange.start();
- await exchange.pingUntilAvailable();
-
- merchant.addExchange(exchange);
-
- await merchant.start();
- await merchant.pingUntilAvailable();
-
- await merchant.addInstance({
- id: "default",
- name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
- });
-
- await merchant.addInstance({
- id: "minst1",
- name: "minst1",
- paytoUris: ["payto://x-taler-bank/minst1"],
- });
-
- console.log("setup done!");
-
- const wallet = new WalletCli(t);
-
- return {
- commonDb: db,
- exchange,
- merchant,
- wallet,
- bank,
- exchangeBankAccount,
- };
-}
-
-export interface FaultyMerchantTestEnvironment {
- commonDb: DbInfo;
- bank: BankService;
- exchange: ExchangeService;
- faultyExchange: FaultInjectedExchangeService;
- exchangeBankAccount: HarnessExchangeBankAccount;
- merchant: MerchantService;
- faultyMerchant: FaultInjectedMerchantService;
- wallet: WalletCli;
-}
-
-/**
- * Run a test case with a simple TESTKUDOS Taler environment, consisting
- * of one exchange, one bank and one merchant.
- */
-export async function createFaultInjectedMerchantTestkudosEnvironment(
- t: GlobalTestState,
-): Promise<FaultyMerchantTestEnvironment> {
- const db = await setupDb(t);
-
- const bank = await BankService.create(t, {
- allowRegistrations: true,
- currency: "TESTKUDOS",
- database: db.connStr,
- httpPort: 8082,
- });
-
- const exchange = ExchangeService.create(t, {
- name: "testexchange-1",
- currency: "TESTKUDOS",
- httpPort: 8081,
- database: db.connStr,
- });
-
- const merchant = await MerchantService.create(t, {
- name: "testmerchant-1",
- currency: "TESTKUDOS",
- httpPort: 8083,
- database: db.connStr,
- });
-
- const faultyMerchant = new FaultInjectedMerchantService(t, merchant, 9083);
- const faultyExchange = new FaultInjectedExchangeService(t, exchange, 9081);
-
- const exchangeBankAccount = await bank.createExchangeAccount(
- "MyExchange",
- "x",
- );
- exchange.addBankAccount("1", exchangeBankAccount);
-
- bank.setSuggestedExchange(
- faultyExchange,
- exchangeBankAccount.accountPaytoUri,
- );
-
- await bank.start();
-
- await bank.pingUntilAvailable();
-
- exchange.addOfferedCoins(defaultCoinConfig);
-
- await exchange.start();
- await exchange.pingUntilAvailable();
-
- merchant.addExchange(faultyExchange);
-
- await merchant.start();
- await merchant.pingUntilAvailable();
-
- await merchant.addInstance({
- id: "default",
- name: "Default Instance",
- paytoUris: [`payto://x-taler-bank/merchant-default`],
- });
-
- await merchant.addInstance({
- id: "minst1",
- name: "minst1",
- paytoUris: ["payto://x-taler-bank/minst1"],
- });
-
- console.log("setup done!");
-
- const wallet = new WalletCli(t);
-
- return {
- commonDb: db,
- exchange,
- merchant,
- wallet,
- bank,
- exchangeBankAccount,
- faultyMerchant,
- faultyExchange,
- };
-}
-
-/**
- * Withdraw balance.
- */
-export async function startWithdrawViaBank(
- t: GlobalTestState,
- p: {
- wallet: WalletCli;
- bank: BankService;
- exchange: ExchangeServiceInterface;
- amount: AmountString;
- },
-): Promise<void> {
- const { wallet, bank, exchange, amount } = p;
-
- const user = await BankApi.createRandomBankUser(bank);
- const wop = await BankAccessApi.createWithdrawalOperation(bank, user, amount);
-
- // Hand it to the wallet
-
- await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
- talerWithdrawUri: wop.taler_withdraw_uri,
- });
-
- await wallet.runPending();
-
- // Confirm it
-
- await BankApi.confirmWithdrawalOperation(bank, user, wop);
-
- // Withdraw
-
- await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, {
- exchangeBaseUrl: exchange.baseUrl,
- talerWithdrawUri: wop.taler_withdraw_uri,
- });
-}
-
-/**
- * Withdraw balance.
- */
-export async function withdrawViaBank(
- t: GlobalTestState,
- p: {
- wallet: WalletCli;
- bank: BankService;
- exchange: ExchangeServiceInterface;
- amount: AmountString;
- },
-): Promise<void> {
- const { wallet } = p;
-
- await startWithdrawViaBank(t, p);
-
- await wallet.runUntilDone();
-
- // Check balance
-
- await wallet.client.call(WalletApiOperation.GetBalances, {});
-}
-
-export async function applyTimeTravel(
- timetravelDuration: Duration,
- s: {
- exchange?: ExchangeService;
- merchant?: MerchantService;
- wallet?: WalletCli;
- },
-): Promise<void> {
- if (s.exchange) {
- await s.exchange.stop();
- s.exchange.setTimetravel(timetravelDuration);
- await s.exchange.start();
- await s.exchange.pingUntilAvailable();
- }
-
- if (s.merchant) {
- await s.merchant.stop();
- s.merchant.setTimetravel(timetravelDuration);
- await s.merchant.start();
- await s.merchant.pingUntilAvailable();
- }
-
- if (s.wallet) {
- s.wallet.setTimetravel(timetravelDuration);
- }
-}
-
-/**
- * Make a simple payment and check that it succeeded.
- */
-export async function makeTestPayment(
- t: GlobalTestState,
- args: {
- merchant: MerchantServiceInterface;
- wallet: WalletCli;
- order: Partial<ContractTerms>;
- instance?: string;
- },
- auth: WithAuthorization = {},
-): Promise<void> {
- // Set up order.
-
- const { wallet, merchant } = args;
- const instance = args.instance ?? "default";
-
- const orderResp = await MerchantPrivateApi.createOrder(
- merchant,
- instance,
- {
- order: args.order,
- },
- auth,
- );
-
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
- merchant,
- {
- orderId: orderResp.order_id,
- },
- auth,
- );
-
- t.assertTrue(orderStatus.order_status === "unpaid");
-
- // Make wallet pay for the order
-
- const preparePayResult = await wallet.client.call(
- WalletApiOperation.PreparePayForUri,
- {
- talerPayUri: orderStatus.taler_pay_uri,
- },
- );
-
- t.assertTrue(
- preparePayResult.status === PreparePayResultType.PaymentPossible,
- );
-
- const r2 = await wallet.client.call(WalletApiOperation.ConfirmPay, {
- proposalId: preparePayResult.proposalId,
- });
-
- t.assertTrue(r2.type === ConfirmPayResultType.Done);
-
- // Check if payment was successful.
-
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
- merchant,
- {
- orderId: orderResp.order_id,
- instance,
- },
- auth,
- );
-
- t.assertTrue(orderStatus.order_status === "paid");
-}
diff --git a/packages/taler-wallet-cli/src/harness/libeufin.ts b/packages/taler-wallet-cli/src/harness/libeufin.ts
deleted file mode 100644
index 11447b389..000000000
--- a/packages/taler-wallet-cli/src/harness/libeufin.ts
+++ /dev/null
@@ -1,1676 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import axios from "axios";
-import { URL } from "@gnu-taler/taler-util";
-import { getRandomIban, getRandomString } from "../harness/helpers.js";
-import {
- GlobalTestState,
- DbInfo,
- pingProc,
- ProcessWrapper,
- runCommand,
- setupDb,
- sh,
-} from "../harness/harness.js";
-
-export interface LibeufinSandboxServiceInterface {
- baseUrl: string;
-}
-
-export interface LibeufinNexusServiceInterface {
- baseUrl: string;
-}
-
-export interface LibeufinServices {
- libeufinSandbox: LibeufinSandboxService;
- libeufinNexus: LibeufinNexusService;
- commonDb: DbInfo;
-}
-
-export interface LibeufinSandboxConfig {
- httpPort: number;
- databaseJdbcUri: string;
-}
-
-export interface LibeufinNexusConfig {
- httpPort: number;
- databaseJdbcUri: string;
-}
-
-export interface DeleteBankConnectionRequest {
- bankConnectionId: string;
-}
-
-interface LibeufinNexusMoneyMovement {
- amount: string;
- creditDebitIndicator: string;
- details: {
- debtor: {
- name: string;
- };
- debtorAccount: {
- iban: string;
- };
- debtorAgent: {
- bic: string;
- };
- creditor: {
- name: string;
- };
- creditorAccount: {
- iban: string;
- };
- creditorAgent: {
- bic: string;
- };
- endToEndId: string;
- unstructuredRemittanceInformation: string;
- };
-}
-
-interface LibeufinNexusBatches {
- batchTransactions: Array<LibeufinNexusMoneyMovement>;
-}
-
-interface LibeufinNexusTransaction {
- amount: string;
- creditDebitIndicator: string;
- status: string;
- bankTransactionCode: string;
- valueDate: string;
- bookingDate: string;
- accountServicerRef: string;
- batches: Array<LibeufinNexusBatches>;
-}
-
-interface LibeufinNexusTransactions {
- transactions: Array<LibeufinNexusTransaction>;
-}
-
-export interface LibeufinCliDetails {
- nexusUrl: string;
- sandboxUrl: string;
- nexusDatabaseUri: string;
- sandboxDatabaseUri: string;
- user: LibeufinNexusUser;
-}
-
-export interface LibeufinEbicsSubscriberDetails {
- hostId: string;
- partnerId: string;
- userId: string;
-}
-
-export interface LibeufinEbicsConnectionDetails {
- subscriberDetails: LibeufinEbicsSubscriberDetails;
- ebicsUrl: string;
- connectionName: string;
-}
-
-export interface LibeufinBankAccountDetails {
- currency: string;
- iban: string;
- bic: string;
- personName: string;
- accountName: string;
-}
-
-export interface LibeufinNexusUser {
- username: string;
- password: string;
-}
-
-export interface LibeufinBackupFileDetails {
- passphrase: string;
- outputFile: string;
- connectionName: string;
-}
-
-export interface LibeufinKeyLetterDetails {
- outputFile: string;
- connectionName: string;
-}
-
-export interface LibeufinBankAccountImportDetails {
- offeredBankAccountName: string;
- nexusBankAccountName: string;
- connectionName: string;
-}
-
-export interface BankAccountInfo {
- iban: string;
- bic: string;
- name: string;
- currency: string;
- label: string;
-}
-
-export interface LibeufinPreparedPaymentDetails {
- creditorIban: string;
- creditorBic: string;
- creditorName: string;
- subject: string;
- amount: string;
- currency: string;
- nexusBankAccountName: string;
-}
-
-export interface LibeufinSandboxAddIncomingRequest {
- creditorIban: string;
- creditorBic: string;
- creditorName: string;
- debtorIban: string;
- debtorBic: string;
- debtorName: string;
- subject: string;
- amount: string;
- currency: string;
- uid: string;
- direction: string;
-}
-
-export class LibeufinSandboxService implements LibeufinSandboxServiceInterface {
- static async create(
- gc: GlobalTestState,
- sandboxConfig: LibeufinSandboxConfig,
- ): Promise<LibeufinSandboxService> {
- return new LibeufinSandboxService(gc, sandboxConfig);
- }
-
- sandboxProc: ProcessWrapper | undefined;
- globalTestState: GlobalTestState;
-
- constructor(
- gc: GlobalTestState,
- private sandboxConfig: LibeufinSandboxConfig,
- ) {
- this.globalTestState = gc;
- }
-
- get baseUrl(): string {
- return `http://localhost:${this.sandboxConfig.httpPort}/`;
- }
-
- async start(): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-sandbox-config",
- "libeufin-sandbox config localhost",
- {
- ...process.env,
- LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
- },
- );
- this.sandboxProc = this.globalTestState.spawnService(
- "libeufin-sandbox",
- ["serve", "--port", `${this.sandboxConfig.httpPort}`],
- "libeufin-sandbox",
- {
- ...process.env,
- LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
- LIBEUFIN_SANDBOX_ADMIN_PASSWORD: "secret",
- },
- );
- }
-
- async c53tick(): Promise<string> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-sandbox-c53tick",
- "libeufin-sandbox camt053tick",
- {
- ...process.env,
- LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
- },
- );
- return stdout;
- }
-
- async makeTransaction(
- debit: string,
- credit: string,
- amount: string, // $currency:x.y
- subject: string,): Promise<string> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-sandbox-maketransfer",
- `libeufin-sandbox make-transaction --debit-account=${debit} --credit-account=${credit} ${amount} "${subject}"`,
- {
- ...process.env,
- LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxConfig.databaseJdbcUri,
- },
- );
- return stdout;
- }
-
- async pingUntilAvailable(): Promise<void> {
- const url = this.baseUrl;
- await pingProc(this.sandboxProc, url, "libeufin-sandbox");
- }
-}
-
-export class LibeufinNexusService {
- static async create(
- gc: GlobalTestState,
- nexusConfig: LibeufinNexusConfig,
- ): Promise<LibeufinNexusService> {
- return new LibeufinNexusService(gc, nexusConfig);
- }
-
- nexusProc: ProcessWrapper | undefined;
- globalTestState: GlobalTestState;
-
- constructor(gc: GlobalTestState, private nexusConfig: LibeufinNexusConfig) {
- this.globalTestState = gc;
- }
-
- get baseUrl(): string {
- return `http://localhost:${this.nexusConfig.httpPort}/`;
- }
-
- async start(): Promise<void> {
- await runCommand(
- this.globalTestState,
- "libeufin-nexus-superuser",
- "libeufin-nexus",
- ["superuser", "admin", "--password", "test"],
- {
- ...process.env,
- LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusConfig.databaseJdbcUri,
- },
- );
-
- this.nexusProc = this.globalTestState.spawnService(
- "libeufin-nexus",
- ["serve", "--port", `${this.nexusConfig.httpPort}`],
- "libeufin-nexus",
- {
- ...process.env,
- LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusConfig.databaseJdbcUri,
- },
- );
- }
-
- async pingUntilAvailable(): Promise<void> {
- const url = `${this.baseUrl}config`;
- await pingProc(this.nexusProc, url, "libeufin-nexus");
- }
-
- async createNexusSuperuser(details: LibeufinNexusUser): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-nexus",
- `libeufin-nexus superuser ${details.username} --password=${details.password}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusConfig.databaseJdbcUri,
- },
- );
- console.log(stdout);
- }
-}
-
-export interface CreateEbicsSubscriberRequest {
- hostID: string;
- userID: string;
- partnerID: string;
- systemID?: string;
-}
-
-export interface TwgAddIncomingRequest {
- amount: string;
- reserve_pub: string;
- debit_account: string;
-}
-
-interface CreateEbicsBankAccountRequest {
- subscriber: {
- hostID: string;
- partnerID: string;
- userID: string;
- systemID?: string;
- };
- // IBAN
- iban: string;
- // BIC
- bic: string;
- // human name
- name: string;
- currency: string;
- label: string;
-}
-
-export interface SimulateIncomingTransactionRequest {
- debtorIban: string;
- debtorBic: string;
- debtorName: string;
-
- /**
- * Subject / unstructured remittance info.
- */
- subject: string;
-
- /**
- * Decimal amount without currency.
- */
- amount: string;
-}
-
-/**
- * The bundle aims at minimizing the amount of input
- * data that is required to initialize a new user + Ebics
- * connection.
- */
-export class NexusUserBundle {
- userReq: CreateNexusUserRequest;
- connReq: CreateEbicsBankConnectionRequest;
- anastasisReq: CreateAnastasisFacadeRequest;
- twgReq: CreateTalerWireGatewayFacadeRequest;
- twgTransferPermission: PostNexusPermissionRequest;
- twgHistoryPermission: PostNexusPermissionRequest;
- twgAddIncomingPermission: PostNexusPermissionRequest;
- localAccountName: string;
- remoteAccountName: string;
-
- constructor(salt: string, ebicsURL: string) {
- this.userReq = {
- username: `username-${salt}`,
- password: `password-${salt}`,
- };
-
- this.connReq = {
- name: `connection-${salt}`,
- ebicsURL: ebicsURL,
- hostID: `ebicshost,${salt}`,
- partnerID: `ebicspartner,${salt}`,
- userID: `ebicsuser,${salt}`,
- };
-
- this.twgReq = {
- currency: "EUR",
- name: `twg-${salt}`,
- reserveTransferLevel: "report",
- accountName: `local-account-${salt}`,
- connectionName: `connection-${salt}`,
- };
- this.anastasisReq = {
- currency: "EUR",
- name: `anastasis-${salt}`,
- reserveTransferLevel: "report",
- accountName: `local-account-${salt}`,
- connectionName: `connection-${salt}`,
- };
- this.remoteAccountName = `remote-account-${salt}`;
- this.localAccountName = `local-account-${salt}`;
- this.twgTransferPermission = {
- action: "grant",
- permission: {
- subjectId: `username-${salt}`,
- subjectType: "user",
- resourceType: "facade",
- resourceId: `twg-${salt}`,
- permissionName: "facade.talerWireGateway.transfer",
- },
- };
- this.twgHistoryPermission = {
- action: "grant",
- permission: {
- subjectId: `username-${salt}`,
- subjectType: "user",
- resourceType: "facade",
- resourceId: `twg-${salt}`,
- permissionName: "facade.talerWireGateway.history",
- },
- };
- }
-}
-
-/**
- * The bundle aims at minimizing the amount of input
- * data that is required to initialize a new Sandbox
- * customer, associating their bank account with a Ebics
- * subscriber.
- */
-export class SandboxUserBundle {
- ebicsBankAccount: CreateEbicsBankAccountRequest;
- constructor(salt: string) {
- this.ebicsBankAccount = {
- currency: "EUR",
- bic: "BELADEBEXXX",
- iban: getRandomIban("DE"),
- label: `remote-account-${salt}`,
- name: `Taler Exchange: ${salt}`,
- subscriber: {
- hostID: `ebicshost,${salt}`,
- partnerID: `ebicspartner,${salt}`,
- userID: `ebicsuser,${salt}`,
- },
- };
- }
-}
-
-export class LibeufinCli {
- cliDetails: LibeufinCliDetails;
- globalTestState: GlobalTestState;
-
- constructor(gc: GlobalTestState, cd: LibeufinCliDetails) {
- this.globalTestState = gc;
- this.cliDetails = cd;
- }
-
- env(): any {
- return {
- ...process.env,
- LIBEUFIN_SANDBOX_URL: this.cliDetails.sandboxUrl,
- LIBEUFIN_SANDBOX_USERNAME: "admin",
- LIBEUFIN_SANDBOX_PASSWORD: "secret",
- }
- }
-
- async checkSandbox(): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-checksandbox",
- "libeufin-cli sandbox check",
- this.env()
- );
- }
-
- async createEbicsHost(hostId: string): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-createebicshost",
- `libeufin-cli sandbox ebicshost create --host-id=${hostId}`,
- this.env()
- );
- console.log(stdout);
- }
-
- async createEbicsSubscriber(
- details: LibeufinEbicsSubscriberDetails,
- ): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-createebicssubscriber",
- "libeufin-cli sandbox ebicssubscriber create" +
- ` --host-id=${details.hostId}` +
- ` --partner-id=${details.partnerId}` +
- ` --user-id=${details.userId}`,
- this.env()
- );
- console.log(stdout);
- }
-
- async createEbicsBankAccount(
- sd: LibeufinEbicsSubscriberDetails,
- bankAccountDetails: LibeufinBankAccountDetails,
- ): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-createebicsbankaccount",
- "libeufin-cli sandbox ebicsbankaccount create" +
- ` --currency=${bankAccountDetails.currency}` +
- ` --iban=${bankAccountDetails.iban}` +
- ` --bic=${bankAccountDetails.bic}` +
- ` --person-name='${bankAccountDetails.personName}'` +
- ` --account-name=${bankAccountDetails.accountName}` +
- ` --ebics-host-id=${sd.hostId}` +
- ` --ebics-partner-id=${sd.partnerId}` +
- ` --ebics-user-id=${sd.userId}`,
- this.env()
- );
- console.log(stdout);
- }
-
- async generateTransactions(accountName: string): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-generatetransactions",
- `libeufin-cli sandbox bankaccount generate-transactions ${accountName}`,
- this.env()
- );
- console.log(stdout);
- }
-
- async showSandboxTransactions(accountName: string): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-showsandboxtransactions",
- `libeufin-cli sandbox bankaccount transactions ${accountName}`,
- this.env()
- );
- console.log(stdout);
- }
-
- async createEbicsConnection(
- connectionDetails: LibeufinEbicsConnectionDetails,
- ): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-createebicsconnection",
- `libeufin-cli connections new-ebics-connection` +
- ` --ebics-url=${connectionDetails.ebicsUrl}` +
- ` --host-id=${connectionDetails.subscriberDetails.hostId}` +
- ` --partner-id=${connectionDetails.subscriberDetails.partnerId}` +
- ` --ebics-user-id=${connectionDetails.subscriberDetails.userId}` +
- ` ${connectionDetails.connectionName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async createBackupFile(details: LibeufinBackupFileDetails): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-createbackupfile",
- `libeufin-cli connections export-backup` +
- ` --passphrase=${details.passphrase}` +
- ` --output-file=${details.outputFile}` +
- ` ${details.connectionName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async createKeyLetter(details: LibeufinKeyLetterDetails): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-createkeyletter",
- `libeufin-cli connections get-key-letter` +
- ` ${details.connectionName} ${details.outputFile}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async connect(connectionName: string): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-connect",
- `libeufin-cli connections connect ${connectionName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async downloadBankAccounts(connectionName: string): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-downloadbankaccounts",
- `libeufin-cli connections download-bank-accounts ${connectionName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async listOfferedBankAccounts(connectionName: string): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-listofferedbankaccounts",
- `libeufin-cli connections list-offered-bank-accounts ${connectionName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async importBankAccount(
- importDetails: LibeufinBankAccountImportDetails,
- ): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-importbankaccount",
- "libeufin-cli connections import-bank-account" +
- ` --offered-account-id=${importDetails.offeredBankAccountName}` +
- ` --nexus-bank-account-id=${importDetails.nexusBankAccountName}` +
- ` ${importDetails.connectionName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async fetchTransactions(bankAccountName: string): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-fetchtransactions",
- `libeufin-cli accounts fetch-transactions ${bankAccountName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async transactions(bankAccountName: string): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-transactions",
- `libeufin-cli accounts transactions ${bankAccountName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async preparePayment(details: LibeufinPreparedPaymentDetails): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-preparepayment",
- `libeufin-cli accounts prepare-payment` +
- ` --creditor-iban=${details.creditorIban}` +
- ` --creditor-bic=${details.creditorBic}` +
- ` --creditor-name='${details.creditorName}'` +
- ` --payment-subject='${details.subject}'` +
- ` --payment-amount=${details.currency}:${details.amount}` +
- ` ${details.nexusBankAccountName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async submitPayment(
- details: LibeufinPreparedPaymentDetails,
- paymentUuid: string,
- ): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-submitpayment",
- `libeufin-cli accounts submit-payment` +
- ` --payment-uuid=${paymentUuid}` +
- ` ${details.nexusBankAccountName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async newAnastasisFacade(req: NewAnastasisFacadeReq): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-new-anastasis-facade",
- `libeufin-cli facades new-anastasis-facade` +
- ` --currency ${req.currency}` +
- ` --facade-name ${req.facadeName}` +
- ` ${req.connectionName} ${req.accountName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
-
- async newTalerWireGatewayFacade(req: NewTalerWireGatewayReq): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-new-taler-wire-gateway-facade",
- `libeufin-cli facades new-taler-wire-gateway-facade` +
- ` --currency ${req.currency}` +
- ` --facade-name ${req.facadeName}` +
- ` ${req.connectionName} ${req.accountName}`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-
- async listFacades(): Promise<void> {
- const stdout = await sh(
- this.globalTestState,
- "libeufin-cli-facades-list",
- `libeufin-cli facades list`,
- {
- ...process.env,
- LIBEUFIN_NEXUS_URL: this.cliDetails.nexusUrl,
- LIBEUFIN_NEXUS_USERNAME: this.cliDetails.user.username,
- LIBEUFIN_NEXUS_PASSWORD: this.cliDetails.user.password,
- },
- );
- console.log(stdout);
- }
-}
-
-interface NewAnastasisFacadeReq {
- facadeName: string;
- connectionName: string;
- accountName: string;
- currency: string;
-}
-
-interface NewTalerWireGatewayReq {
- facadeName: string;
- connectionName: string;
- accountName: string;
- currency: string;
-}
-
-export namespace LibeufinSandboxApi {
-
- export async function rotateKeys(
- libeufinSandboxService: LibeufinSandboxServiceInterface,
- hostID: string,
- ) {
- const baseUrl = libeufinSandboxService.baseUrl;
- let url = new URL(`admin/ebics/hosts/${hostID}/rotate-keys`, baseUrl);
- await axios.post(url.href, {}, {
- auth: {
- username: "admin",
- password: "secret",
- },
- });
- }
- export async function createEbicsHost(
- libeufinSandboxService: LibeufinSandboxServiceInterface,
- hostID: string,
- ) {
- const baseUrl = libeufinSandboxService.baseUrl;
- let url = new URL("admin/ebics/hosts", baseUrl);
- await axios.post(url.href, {
- hostID,
- ebicsVersion: "2.5",
- },
- {
- auth: {
- username: "admin",
- password: "secret",
- },
- });
- }
-
- export async function createBankAccount(
- libeufinSandboxService: LibeufinSandboxServiceInterface,
- req: BankAccountInfo,
- ) {
- const baseUrl = libeufinSandboxService.baseUrl;
- let url = new URL(`admin/bank-accounts/${req.label}`, baseUrl);
- await axios.post(url.href, req, {
- auth: {
- username: "admin",
- password: "secret",
- },
- });
- }
-
- export async function createEbicsSubscriber(
- libeufinSandboxService: LibeufinSandboxServiceInterface,
- req: CreateEbicsSubscriberRequest,
- ) {
- const baseUrl = libeufinSandboxService.baseUrl;
- let url = new URL("admin/ebics/subscribers", baseUrl);
- await axios.post(url.href, req, {
- auth: {
- username: "admin",
- password: "secret",
- },
- });
- }
-
- export async function createEbicsBankAccount(
- libeufinSandboxService: LibeufinSandboxServiceInterface,
- req: CreateEbicsBankAccountRequest,
- ) {
- const baseUrl = libeufinSandboxService.baseUrl;
- let url = new URL("admin/ebics/bank-accounts", baseUrl);
- await axios.post(url.href, req, {
- auth: {
- username: "admin",
- password: "secret",
- },
- });
- }
-
- export async function bookPayment2(
- libeufinSandboxService: LibeufinSandboxService,
- req: LibeufinSandboxAddIncomingRequest,
- ) {
- const baseUrl = libeufinSandboxService.baseUrl;
- let url = new URL("admin/payments", baseUrl);
- await axios.post(url.href, req, {
- auth: {
- username: "admin",
- password: "secret",
- },
- });
- }
-
- export async function bookPayment(
- libeufinSandboxService: LibeufinSandboxService,
- creditorBundle: SandboxUserBundle,
- debitorBundle: SandboxUserBundle,
- subject: string,
- amount: string,
- currency: string,
- ) {
- let req: LibeufinSandboxAddIncomingRequest = {
- creditorIban: creditorBundle.ebicsBankAccount.iban,
- creditorBic: creditorBundle.ebicsBankAccount.bic,
- creditorName: creditorBundle.ebicsBankAccount.name,
- debtorIban: debitorBundle.ebicsBankAccount.iban,
- debtorBic: debitorBundle.ebicsBankAccount.bic,
- debtorName: debitorBundle.ebicsBankAccount.name,
- subject: subject,
- amount: amount,
- currency: currency,
- uid: getRandomString(),
- direction: "CRDT",
- };
- await bookPayment2(libeufinSandboxService, req);
- }
-
- export async function simulateIncomingTransaction(
- libeufinSandboxService: LibeufinSandboxServiceInterface,
- accountLabel: string,
- req: SimulateIncomingTransactionRequest,
- ) {
- const baseUrl = libeufinSandboxService.baseUrl;
- let url = new URL(
- `admin/bank-accounts/${accountLabel}/simulate-incoming-transaction`,
- baseUrl,
- );
- await axios.post(url.href, req, {
- auth: {
- username: "admin",
- password: "secret",
- },
- });
- }
-
- export async function getAccountTransactions(
- libeufinSandboxService: LibeufinSandboxServiceInterface,
- accountLabel: string,
- ): Promise<SandboxAccountTransactions> {
- const baseUrl = libeufinSandboxService.baseUrl;
- let url = new URL(
- `admin/bank-accounts/${accountLabel}/transactions`,
- baseUrl,
- );
- const res = await axios.get(url.href, {
- auth: {
- username: "admin",
- password: "secret",
- },
- });
- return res.data as SandboxAccountTransactions;
- }
-
- export async function getCamt053(
- libeufinSandboxService: LibeufinSandboxServiceInterface,
- accountLabel: string,
- ): Promise<any> {
- const baseUrl = libeufinSandboxService.baseUrl;
- let url = new URL("admin/payments/camt", baseUrl);
- return await axios.post(url.href, {
- bankaccount: accountLabel,
- type: 53,
- },
- {
- auth: {
- username: "admin",
- password: "secret",
- },
- });
- }
-
- export async function getAccountInfoWithBalance(
- libeufinSandboxService: LibeufinSandboxServiceInterface,
- accountLabel: string,
- ): Promise<any> {
- const baseUrl = libeufinSandboxService.baseUrl;
- let url = new URL(
- `admin/bank-accounts/${accountLabel}`,
- baseUrl,
- );
- return await axios.get(url.href, {
- auth: {
- username: "admin",
- password: "secret",
- },
- });
- }
-}
-
-export interface SandboxAccountTransactions {
- payments: {
- accountLabel: string;
- creditorIban: string;
- creditorBic?: string;
- creditorName: string;
- debtorIban: string;
- debtorBic: string;
- debtorName: string;
- amount: string;
- currency: string;
- subject: string;
- date: string;
- creditDebitIndicator: "debit" | "credit";
- accountServicerReference: string;
- }[];
-}
-
-export interface CreateEbicsBankConnectionRequest {
- name: string;
- ebicsURL: string;
- hostID: string;
- userID: string;
- partnerID: string;
- systemID?: string;
-}
-
-export interface CreateAnastasisFacadeRequest {
- name: string;
- connectionName: string;
- accountName: string;
- currency: string;
- reserveTransferLevel: "report" | "statement" | "notification";
-}
-
-
-export interface CreateTalerWireGatewayFacadeRequest {
- name: string;
- connectionName: string;
- accountName: string;
- currency: string;
- reserveTransferLevel: "report" | "statement" | "notification";
-}
-
-export interface UpdateNexusUserRequest {
- newPassword: string;
-}
-
-export interface NexusAuth {
- auth: {
- username: string;
- password: string;
- };
-}
-
-export interface CreateNexusUserRequest {
- username: string;
- password: string;
-}
-
-export interface PostNexusTaskRequest {
- name: string;
- cronspec: string;
- type: string; // fetch | submit
- params:
- | {
- level: string; // report | statement | all
- rangeType: string; // all | since-last | previous-days | latest
- }
- | {};
-}
-
-export interface PostNexusPermissionRequest {
- action: "revoke" | "grant";
- permission: {
- subjectType: string;
- subjectId: string;
- resourceType: string;
- resourceId: string;
- permissionName: string;
- };
-}
-
-export namespace LibeufinNexusApi {
- export async function getAllConnections(
- nexus: LibeufinNexusServiceInterface,
- ): Promise<any> {
- let url = new URL("bank-connections", nexus.baseUrl);
- const res = await axios.get(url.href, {
- auth: {
- username: "admin",
- password: "test",
- },
- });
- return res;
- }
-
- export async function deleteBankConnection(
- libeufinNexusService: LibeufinNexusServiceInterface,
- req: DeleteBankConnectionRequest,
- ): Promise<any> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL("bank-connections/delete-connection", baseUrl);
- return await axios.post(url.href, req, {
- auth: {
- username: "admin",
- password: "test",
- },
- });
- }
-
- export async function createEbicsBankConnection(
- libeufinNexusService: LibeufinNexusServiceInterface,
- req: CreateEbicsBankConnectionRequest,
- ): Promise<void> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL("bank-connections", baseUrl);
- await axios.post(
- url.href,
- {
- source: "new",
- type: "ebics",
- name: req.name,
- data: {
- ebicsURL: req.ebicsURL,
- hostID: req.hostID,
- userID: req.userID,
- partnerID: req.partnerID,
- systemID: req.systemID,
- },
- },
- {
- auth: {
- username: "admin",
- password: "test",
- },
- },
- );
- }
-
- export async function getBankAccount(
- libeufinNexusService: LibeufinNexusServiceInterface,
- accountName: string,
- ): Promise<any> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(
- `bank-accounts/${accountName}`,
- baseUrl,
- );
- return await axios.get(
- url.href,
- {
- auth: {
- username: "admin",
- password: "test",
- },
- },
- );
- }
-
-
- export async function submitInitiatedPayment(
- libeufinNexusService: LibeufinNexusServiceInterface,
- accountName: string,
- paymentId: string,
- ): Promise<void> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(
- `bank-accounts/${accountName}/payment-initiations/${paymentId}/submit`,
- baseUrl,
- );
- await axios.post(
- url.href,
- {},
- {
- auth: {
- username: "admin",
- password: "test",
- },
- },
- );
- }
-
- export async function fetchAccounts(
- libeufinNexusService: LibeufinNexusServiceInterface,
- connectionName: string,
- ): Promise<void> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(
- `bank-connections/${connectionName}/fetch-accounts`,
- baseUrl,
- );
- await axios.post(
- url.href,
- {},
- {
- auth: {
- username: "admin",
- password: "test",
- },
- },
- );
- }
-
- export async function importConnectionAccount(
- libeufinNexusService: LibeufinNexusServiceInterface,
- connectionName: string,
- offeredAccountId: string,
- nexusBankAccountId: string,
- ): Promise<void> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(
- `bank-connections/${connectionName}/import-account`,
- baseUrl,
- );
- await axios.post(
- url.href,
- {
- offeredAccountId,
- nexusBankAccountId,
- },
- {
- auth: {
- username: "admin",
- password: "test",
- },
- },
- );
- }
-
- export async function connectBankConnection(
- libeufinNexusService: LibeufinNexusServiceInterface,
- connectionName: string,
- ) {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`bank-connections/${connectionName}/connect`, baseUrl);
- await axios.post(
- url.href,
- {},
- {
- auth: {
- username: "admin",
- password: "test",
- },
- },
- );
- }
-
- export async function getPaymentInitiations(
- libeufinNexusService: LibeufinNexusService,
- accountName: string,
- username: string = "admin",
- password: string = "test",
- ): Promise<void> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(
- `/bank-accounts/${accountName}/payment-initiations`,
- baseUrl,
- );
- let response = await axios.get(url.href, {
- auth: {
- username: username,
- password: password,
- },
- });
- console.log(
- `Payment initiations of: ${accountName}`,
- JSON.stringify(response.data, null, 2),
- );
- }
-
- export async function getConfig(
- libeufinNexusService: LibeufinNexusService,
- ): Promise<void> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`/config`, baseUrl);
- let response = await axios.get(url.href);
- }
-
- // Uses the Anastasis API to get a list of transactions.
- export async function getAnastasisTransactions(
- libeufinNexusService: LibeufinNexusService,
- anastasisBaseUrl: string,
- params: {}, // of the request: {delta: 5, ..}
- username: string = "admin",
- password: string = "test",
- ): Promise<any> {
- let url = new URL("history/incoming", anastasisBaseUrl);
- let response = await axios.get(url.href, { params: params,
- auth: {
- username: username,
- password: password,
- },
- });
- return response;
- }
-
- // FIXME: this function should return some structured
- // object that represents a history.
- export async function getAccountTransactions(
- libeufinNexusService: LibeufinNexusService,
- accountName: string,
- username: string = "admin",
- password: string = "test",
- ): Promise<any> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`/bank-accounts/${accountName}/transactions`, baseUrl);
- let response = await axios.get(url.href, {
- auth: {
- username: username,
- password: password,
- },
- });
- return response;
- }
-
- export async function fetchTransactions(
- libeufinNexusService: LibeufinNexusService,
- accountName: string,
- rangeType: string = "all",
- level: string = "report",
- username: string = "admin",
- password: string = "test",
- ): Promise<any> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(
- `/bank-accounts/${accountName}/fetch-transactions`,
- baseUrl,
- );
- return await axios.post(
- url.href,
- {
- rangeType: rangeType,
- level: level,
- },
- {
- auth: {
- username: username,
- password: password,
- },
- },
- );
- }
-
- export async function changePassword(
- libeufinNexusService: LibeufinNexusServiceInterface,
- username: string,
- req: UpdateNexusUserRequest,
- auth: NexusAuth,
- ) {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`/users/${username}/password`, baseUrl);
- await axios.post(url.href, req, auth);
- }
-
- export async function getUser(
- libeufinNexusService: LibeufinNexusServiceInterface,
- auth: NexusAuth,
- ): Promise<any> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`/user`, baseUrl);
- return await axios.get(url.href, auth);
- }
-
- export async function createUser(
- libeufinNexusService: LibeufinNexusServiceInterface,
- req: CreateNexusUserRequest,
- ) {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`/users`, baseUrl);
- await axios.post(url.href, req, {
- auth: {
- username: "admin",
- password: "test",
- },
- });
- }
-
- export async function getAllPermissions(
- libeufinNexusService: LibeufinNexusServiceInterface,
- ): Promise<any> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`/permissions`, baseUrl);
- return await axios.get(url.href, {
- auth: {
- username: "admin",
- password: "test",
- },
- });
- }
-
- export async function postPermission(
- libeufinNexusService: LibeufinNexusServiceInterface,
- req: PostNexusPermissionRequest,
- ) {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`/permissions`, baseUrl);
- await axios.post(url.href, req, {
- auth: {
- username: "admin",
- password: "test",
- },
- });
- }
-
- export async function getTasks(
- libeufinNexusService: LibeufinNexusServiceInterface,
- bankAccountName: string,
- // When void, the request returns the list of all the
- // tasks under this bank account.
- taskName: string | void,
- ): Promise<any> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`/bank-accounts/${bankAccountName}/schedule`, baseUrl);
- if (taskName) url = new URL(taskName, `${url}/`);
-
- // It's caller's responsibility to interpret the response.
- return await axios.get(url.href, {
- auth: {
- username: "admin",
- password: "test",
- },
- });
- }
-
- export async function deleteTask(
- libeufinNexusService: LibeufinNexusServiceInterface,
- bankAccountName: string,
- taskName: string,
- ) {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(
- `/bank-accounts/${bankAccountName}/schedule/${taskName}`,
- baseUrl,
- );
- await axios.delete(url.href, {
- auth: {
- username: "admin",
- password: "test",
- },
- });
- }
-
- export async function postTask(
- libeufinNexusService: LibeufinNexusServiceInterface,
- bankAccountName: string,
- req: PostNexusTaskRequest,
- ): Promise<any> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`/bank-accounts/${bankAccountName}/schedule`, baseUrl);
- return await axios.post(url.href, req, {
- auth: {
- username: "admin",
- password: "test",
- },
- });
- }
-
- export async function deleteFacade(
- libeufinNexusService: LibeufinNexusServiceInterface,
- facadeName: string,
- ): Promise<any> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(`facades/${facadeName}`, baseUrl);
- return await axios.delete(url.href, {
- auth: {
- username: "admin",
- password: "test",
- },
- });
- }
-
- export async function getAllFacades(
- libeufinNexusService: LibeufinNexusServiceInterface,
- ): Promise<any> {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL("facades", baseUrl);
- return await axios.get(url.href, {
- auth: {
- username: "admin",
- password: "test",
- },
- });
- }
-
- export async function createAnastasisFacade(
- libeufinNexusService: LibeufinNexusServiceInterface,
- req: CreateAnastasisFacadeRequest,
- ) {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL("facades", baseUrl);
- await axios.post(
- url.href,
- {
- name: req.name,
- type: "anastasis",
- config: {
- bankAccount: req.accountName,
- bankConnection: req.connectionName,
- currency: req.currency,
- reserveTransferLevel: req.reserveTransferLevel,
- },
- },
- {
- auth: {
- username: "admin",
- password: "test",
- },
- },
- );
- }
-
- export async function createTwgFacade(
- libeufinNexusService: LibeufinNexusServiceInterface,
- req: CreateTalerWireGatewayFacadeRequest,
- ) {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL("facades", baseUrl);
- await axios.post(
- url.href,
- {
- name: req.name,
- type: "taler-wire-gateway",
- config: {
- bankAccount: req.accountName,
- bankConnection: req.connectionName,
- currency: req.currency,
- reserveTransferLevel: req.reserveTransferLevel,
- },
- },
- {
- auth: {
- username: "admin",
- password: "test",
- },
- },
- );
- }
-
- export async function submitAllPaymentInitiations(
- libeufinNexusService: LibeufinNexusServiceInterface,
- accountId: string,
- ) {
- const baseUrl = libeufinNexusService.baseUrl;
- let url = new URL(
- `/bank-accounts/${accountId}/submit-all-payment-initiations`,
- baseUrl,
- );
- await axios.post(
- url.href,
- {},
- {
- auth: {
- username: "admin",
- password: "test",
- },
- },
- );
- }
-}
-
-/**
- * Launch Nexus and Sandbox AND creates users / facades / bank accounts /
- * .. all that's required to start making banking traffic.
- */
-export async function launchLibeufinServices(
- t: GlobalTestState,
- nexusUserBundle: NexusUserBundle[],
- sandboxUserBundle: SandboxUserBundle[] = [],
- withFacades: string[] = [], // takes only "twg" and/or "anastasis"
-): Promise<LibeufinServices> {
- const db = await setupDb(t);
-
- const libeufinSandbox = await LibeufinSandboxService.create(t, {
- httpPort: 5010,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
- });
-
- await libeufinSandbox.start();
- await libeufinSandbox.pingUntilAvailable();
-
- const libeufinNexus = await LibeufinNexusService.create(t, {
- httpPort: 5011,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
- });
-
- await libeufinNexus.start();
- await libeufinNexus.pingUntilAvailable();
- console.log("Libeufin services launched!");
-
- for (let sb of sandboxUserBundle) {
- await LibeufinSandboxApi.createEbicsHost(
- libeufinSandbox,
- sb.ebicsBankAccount.subscriber.hostID,
- );
- await LibeufinSandboxApi.createEbicsSubscriber(
- libeufinSandbox,
- sb.ebicsBankAccount.subscriber,
- );
- await LibeufinSandboxApi.createEbicsBankAccount(
- libeufinSandbox,
- sb.ebicsBankAccount,
- );
- }
- console.log("Sandbox user(s) / account(s) / subscriber(s): created");
-
- for (let nb of nexusUserBundle) {
- await LibeufinNexusApi.createEbicsBankConnection(libeufinNexus, nb.connReq);
- await LibeufinNexusApi.connectBankConnection(
- libeufinNexus,
- nb.connReq.name,
- );
- await LibeufinNexusApi.fetchAccounts(libeufinNexus, nb.connReq.name);
- await LibeufinNexusApi.importConnectionAccount(
- libeufinNexus,
- nb.connReq.name,
- nb.remoteAccountName,
- nb.localAccountName,
- );
- await LibeufinNexusApi.createUser(libeufinNexus, nb.userReq);
- for (let facade of withFacades) {
- switch (facade) {
- case "twg":
- await LibeufinNexusApi.createTwgFacade(libeufinNexus, nb.twgReq);
- await LibeufinNexusApi.postPermission(
- libeufinNexus,
- nb.twgTransferPermission,
- );
- await LibeufinNexusApi.postPermission(
- libeufinNexus,
- nb.twgHistoryPermission,
- );
- break;
- case "anastasis":
- await LibeufinNexusApi.createAnastasisFacade(libeufinNexus, nb.anastasisReq);
- }
- }
- }
- console.log(
- "Nexus user(s) / connection(s) / facade(s) / permission(s): created",
- );
-
- return {
- commonDb: db,
- libeufinNexus: libeufinNexus,
- libeufinSandbox: libeufinSandbox,
- };
-}
-
-/**
- * Helper function that searches a payment among
- * a list, as returned by Nexus. The key is just
- * the payment subject.
- */
-export function findNexusPayment(
- key: string,
- payments: LibeufinNexusTransactions,
-): LibeufinNexusMoneyMovement | void {
- let transactions = payments["transactions"];
- for (let i = 0; i < transactions.length; i++) {
- let batches = transactions[i]["batches"];
- for (let y = 0; y < batches.length; y++) {
- let movements = batches[y]["batchTransactions"];
- for (let z = 0; z < movements.length; z++) {
- let movement = movements[z];
- if (movement["details"]["unstructuredRemittanceInformation"] == key)
- return movement;
- }
- }
- }
-}
diff --git a/packages/taler-wallet-cli/src/import-meta-url.js b/packages/taler-wallet-cli/src/import-meta-url.js
new file mode 100644
index 000000000..c0e657160
--- /dev/null
+++ b/packages/taler-wallet-cli/src/import-meta-url.js
@@ -0,0 +1,2 @@
+// Helper to make 'import.meta.url' available in esbuild-bundled code as well.
+export const import_meta_url = require("url").pathToFileURL(__filename);
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
index 142e98e7c..7bb74b1c6 100644
--- a/packages/taler-wallet-cli/src/index.ts
+++ b/packages/taler-wallet-cli/src/index.ts
@@ -17,49 +17,57 @@
/**
* Imports.
*/
-import os from "os";
-import fs from "fs";
-import path from "path";
-import { deepStrictEqual } from "assert";
-// Polyfill for encoding which isn't present globally in older nodejs versions
-import { TextEncoder, TextDecoder } from "util";
-// @ts-ignore
-global.TextEncoder = TextEncoder;
-// @ts-ignore
-global.TextDecoder = TextDecoder;
-import * as clk from "./clk.js";
-import { getTestInfo, runTests } from "./integrationtests/testrunner.js";
import {
- PreparePayResultType,
- setDangerousTimetravel,
- classifyTalerUri,
- TalerUriType,
- RecoveryMergeStrategy,
- Amounts,
+ AbsoluteTime,
addPaytoQueryParams,
+ AgeRestriction,
+ AmountString,
codecForList,
codecForString,
+ CoreApiResponse,
+ Duration,
+ encodeCrock,
+ getErrorDetailFromException,
+ getRandomBytes,
+ j2s,
Logger,
- Configuration,
- decodeCrock,
- rsaBlind,
+ NotificationType,
+ parsePaytoUri,
+ parseTalerUri,
+ PreparePayResultType,
+ sampleWalletCoreTransactions,
+ setDangerousTimetravel,
+ setGlobalLogLevelFromString,
+ summarizeTalerErrorDetail,
+ TalerUriAction,
+ TransactionIdStr,
+ WalletNotification,
} from "@gnu-taler/taler-util";
+import { clk } from "@gnu-taler/taler-util/clk";
+import {
+ getenv,
+ pathHomedir,
+ processExit,
+ readFile,
+ readlinePrompt,
+ setUnhandledRejectionHandler,
+} from "@gnu-taler/taler-util/compat";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import { JsonMessage, runRpcServer } from "@gnu-taler/taler-util/twrpc";
import {
- NodeHttpLib,
- getDefaultNodeWallet,
- OperationFailedAndReportedError,
- OperationFailedError,
- NodeThreadCryptoWorkerFactory,
- CryptoApi,
- walletCoreDebugFlags,
+ AccessStats,
+ createNativeWalletHost2,
+ Wallet,
WalletApiOperation,
WalletCoreApiClient,
- Wallet,
} from "@gnu-taler/taler-wallet-core";
-import { lintExchangeDeployment } from "./lint.js";
-import { runBench1 } from "./bench1.js";
-import { runEnv1 } from "./env1.js";
-import { GlobalTestState, runTestWithState } from "./harness/harness.js";
+import {
+ createRemoteWallet,
+ getClientFromRemoteWallet,
+ makeNotificationWaiter,
+} from "@gnu-taler/taler-wallet-core/remote";
+
+import * as fs from "node:fs";
// This module also serves as the entry point for the crypto
// thread worker, and thus must expose these two handlers.
@@ -70,7 +78,18 @@ export {
const logger = new Logger("taler-wallet-cli.ts");
-const defaultWalletDbPath = os.homedir + "/" + ".talerwalletdb.json";
+let observabilityEventFile: string | undefined = undefined;
+
+const EXIT_EXCEPTION = 4;
+const EXIT_API_ERROR = 5;
+
+setUnhandledRejectionHandler((error: any) => {
+ logger.error("unhandledRejection", error.message);
+ logger.error("stack", error.stack);
+ processExit(1);
+});
+
+const defaultWalletDbPath = pathHomedir() + "/" + ".talerwalletdb.json";
function assertUnreachable(x: never): never {
throw new Error("Didn't expect to get here");
@@ -87,7 +106,7 @@ async function doPay(
if (result.status === PreparePayResultType.InsufficientBalance) {
console.log("contract", result.contractTerms);
console.error("insufficient balance");
- process.exit(1);
+ processExit(1);
return;
}
if (result.status === PreparePayResultType.AlreadyConfirmed) {
@@ -96,8 +115,7 @@ async function doPay(
} else {
console.log("payment already in progress");
}
-
- process.exit(0);
+ processExit(0);
return;
}
if (result.status === "payment-possible") {
@@ -139,11 +157,11 @@ function applyVerbose(verbose: boolean): void {
// TODO
}
+declare const __VERSION__: string;
+declare const __GIT_HASH__: string;
function printVersion(): void {
- // eslint-disable-next-line @typescript-eslint/no-var-requires
- const info = require("../package.json");
- console.log(`${info.version}`);
- process.exit(0);
+ console.log(`${__VERSION__} ${__GIT_HASH__}`);
+ processExit(0);
}
export const walletCli = clk
@@ -151,7 +169,10 @@ export const walletCli = clk
help: "Command line interface for the GNU Taler wallet.",
})
.maybeOption("walletDbFile", ["--wallet-db"], clk.STRING, {
- help: "location of the wallet database file",
+ help: "Location of the wallet database file",
+ })
+ .maybeOption("walletConnection", ["--wallet-connection"], clk.STRING, {
+ help: "Connect to an RPC wallet",
})
.maybeOption("timetravel", ["--timetravel"], clk.INT, {
help: "modify system time by given offset in microseconds",
@@ -161,64 +182,197 @@ export const walletCli = clk
setDangerousTimetravel(x / 1000);
},
})
+ .maybeOption("cryptoWorker", ["--crypto-worker"], clk.STRING, {
+ help: "Override crypto worker implementation type.",
+ })
+ .maybeOption("log", ["-L", "--log"], clk.STRING, {
+ help: "configure log level (NONE, ..., TRACE)",
+ onPresentHandler: (x) => {
+ setGlobalLogLevelFromString(x);
+ },
+ })
.maybeOption("inhibit", ["--inhibit"], clk.STRING, {
- help:
- "Inhibit running certain operations, useful for debugging and testing.",
+ help: "Inhibit running certain operations, useful for debugging and testing.",
})
.flag("noThrottle", ["--no-throttle"], {
help: "Don't do any request throttling.",
})
+ .flag("noHttp", ["--no-http"], {
+ help: "Allow unsafe http connections.",
+ })
.flag("version", ["-v", "--version"], {
onPresentHandler: printVersion,
})
.flag("verbose", ["-V", "--verbose"], {
help: "Enable verbose output.",
+ })
+ .flag("skipDefaults", ["--skip-defaults"], {
+ help: "Skip configuring default exchanges.",
});
type WalletCliArgsType = clk.GetArgType<typeof walletCli>;
-async function withWallet<T>(
+function checkEnvFlag(name: string): boolean {
+ const val = getenv(name);
+ if (val == "1") {
+ return true;
+ }
+ return false;
+}
+
+export interface WalletContext {
+ /**
+ * High-level client for making API requests to wallet-core.
+ */
+ client: WalletCoreApiClient;
+
+ /**
+ * Low-level interface for making API requests to wallet-core.
+ */
+ makeCoreApiRequest(
+ operation: string,
+ payload: unknown,
+ ): Promise<CoreApiResponse>;
+
+ /**
+ * Return a promise that resolves after the wallet has emitted a notification
+ * that meets the criteria of the "cond" predicate.
+ */
+ waitForNotificationCond<T>(
+ cond: (n: WalletNotification) => T | false | undefined,
+ ): Promise<T>;
+}
+
+interface CreateWalletResult {
+ wallet: Wallet;
+ getStats: () => AccessStats;
+}
+
+async function createLocalWallet(
walletCliArgs: WalletCliArgsType,
- f: (w: { client: WalletCoreApiClient; ws: Wallet }) => Promise<T>,
-): Promise<T> {
+ notificationHandler?: (n: WalletNotification) => void,
+ noInit?: boolean,
+): Promise<CreateWalletResult> {
const dbPath = walletCliArgs.wallet.walletDbFile ?? defaultWalletDbPath;
- const myHttpLib = new NodeHttpLib();
- if (walletCliArgs.wallet.noThrottle) {
- myHttpLib.setThrottling(false);
- }
- const wallet = await getDefaultNodeWallet({
- persistentStoragePath: dbPath,
+ const myHttpLib = createPlatformHttpLib({
+ enableThrottling: walletCliArgs.wallet.noThrottle ? false : true,
+ requireTls: walletCliArgs.wallet.noHttp,
+ });
+ const wh = await createNativeWalletHost2({
+ persistentStoragePath: dbPath !== ":memory:" ? dbPath : undefined,
httpLib: myHttpLib,
+ notifyHandler: (n) => {
+ logger.info(`wallet notification: ${j2s(n)}`);
+ if (notificationHandler) {
+ notificationHandler(n);
+ }
+ },
+ cryptoWorkerType: walletCliArgs.wallet.cryptoWorker as any,
});
+
applyVerbose(walletCliArgs.wallet.verbose);
+ const res = { wallet: wh.wallet, getStats: wh.getDbStats };
+
+ if (noInit) {
+ return res;
+ }
try {
- const w = {
- ws: wallet,
- client: wallet.client,
- };
- await wallet.handleCoreApiRequest("initWallet", "native-init", {});
- const ret = await f(w);
- return ret;
+ await wh.wallet.handleCoreApiRequest("initWallet", "native-init", {
+ config: {
+ features: {},
+ testing: {
+ devModeActive: checkEnvFlag("TALER_WALLET_DEV_MODE"),
+ denomselAllowLate: checkEnvFlag(
+ "TALER_WALLET_DEBUG_DENOMSEL_ALLOW_LATE",
+ ),
+ emitObservabilityEvents: observabilityEventFile != null,
+ skipDefaults: walletCliArgs.wallet.skipDefaults,
+ },
+ },
+ });
+ return res;
} catch (e) {
- if (
- e instanceof OperationFailedAndReportedError ||
- e instanceof OperationFailedError
- ) {
- console.error("Operation failed: " + e.message);
- console.error(
- "Error details:",
- JSON.stringify(e.operationError, undefined, 2),
- );
- } else {
- console.error("caught unhandled exception (bug?):", e);
+ const ed = getErrorDetailFromException(e);
+ console.error("Operation failed: " + summarizeTalerErrorDetail(ed));
+ console.error("Error details:", JSON.stringify(ed, undefined, 2));
+ processExit(1);
+ }
+}
+
+async function withWallet<T>(
+ walletCliArgs: WalletCliArgsType,
+ f: (ctx: WalletContext) => Promise<T>,
+): Promise<T> {
+ const waiter = makeNotificationWaiter();
+
+ const onNotif = (notif: WalletNotification) => {
+ waiter.notify(notif);
+ if (observabilityEventFile) {
+ switch (notif.type) {
+ case NotificationType.RequestObservabilityEvent:
+ case NotificationType.TaskObservabilityEvent:
+ fs.appendFileSync(
+ observabilityEventFile,
+ JSON.stringify(notif) + "\n",
+ );
+ break;
+ }
}
- process.exit(1);
- } finally {
- logger.info("operation with wallet finished, stopping");
- wallet.stop();
+ };
+
+ if (walletCliArgs.wallet.walletConnection) {
+ logger.info("creating remote wallet");
+ const w = await createRemoteWallet({
+ name: "wallet",
+ notificationHandler: onNotif,
+ socketFilename: walletCliArgs.wallet.walletConnection,
+ });
+ const ctx: WalletContext = {
+ makeCoreApiRequest(operation, payload) {
+ return w.makeCoreApiRequest(operation, payload);
+ },
+ client: getClientFromRemoteWallet(w),
+ waitForNotificationCond: waiter.waitForNotificationCond,
+ };
+ const res = await f(ctx);
+ w.close();
+ return res;
+ } else {
+ const wh = await createLocalWallet(walletCliArgs, onNotif);
+ const ctx: WalletContext = {
+ client: wh.wallet.client,
+ waitForNotificationCond: waiter.waitForNotificationCond,
+ makeCoreApiRequest(operation, payload) {
+ return wh.wallet.handleCoreApiRequest(operation, "my-req", payload);
+ },
+ };
+ const result = await f(ctx);
+ wh.wallet.stop();
+ if (process.env.TALER_WALLET_DBSTATS) {
+ console.log("database stats:");
+ console.log(j2s(wh.getStats()));
+ }
+ return result;
}
}
+/**
+ * Run a function with a local wallet.
+ *
+ * Stops the wallet after the function is done.
+ */
+async function withLocalWallet<T>(
+ walletCliArgs: WalletCliArgsType,
+ f: (w: { client: WalletCoreApiClient; ws: Wallet }) => Promise<T>,
+): Promise<T> {
+ const wh = await createLocalWallet(walletCliArgs);
+ const w = wh.wallet;
+ const res = await f({ client: w.client, ws: w });
+ logger.info("Work done, stopping wallet.");
+ w.stop();
+ return res;
+}
+
walletCli
.subcommand("balance", "balance", { help: "Show wallet balance." })
.flag("json", ["--json"], {
@@ -238,79 +392,184 @@ walletCli
.subcommand("api", "api", { help: "Call the wallet-core API directly." })
.requiredArgument("operation", clk.STRING)
.requiredArgument("request", clk.STRING)
+ .flag("expectSuccess", ["--expect-success"], {
+ help: "Exit with non-zero status code when request fails instead of returning error JSON.",
+ })
.action(async (args) => {
await withWallet(args, async (wallet) => {
let requestJson;
+ logger.info(`handling 'api' request (${args.api.operation})`);
+ const jsonContent = args.api.request.startsWith("@")
+ ? readFile(args.api.request.substring(1))
+ : args.api.request;
try {
- requestJson = JSON.parse(args.api.request);
+ requestJson = JSON.parse(jsonContent);
} catch (e) {
console.error("Invalid JSON");
- process.exit(1);
+ processExit(1);
+ }
+ try {
+ const resp = await wallet.makeCoreApiRequest(
+ args.api.operation,
+ requestJson,
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ if (resp.type === "error") {
+ if (args.api.expectSuccess) {
+ processExit(EXIT_API_ERROR);
+ }
+ }
+ } catch (e) {
+ logger.error(`Got exception while handling API request ${e}`);
+ processExit(EXIT_EXCEPTION);
}
- const resp = await wallet.ws.handleCoreApiRequest(
- args.api.operation,
- "reqid-1",
- requestJson,
- );
- console.log(JSON.stringify(resp, undefined, 2));
});
+ logger.info("finished handling API request");
});
-walletCli
- .subcommand("", "pending", { help: "Show pending operations." })
+const transactionsCli = walletCli
+ .subcommand("transactions", "transactions", { help: "Manage transactions." })
+ .maybeOption("currency", ["--currency"], clk.STRING, {
+ help: "Filter by currency.",
+ })
+ .maybeOption("search", ["--search"], clk.STRING, {
+ help: "Filter by search string",
+ })
+ .flag("includeRefreshes", ["--include-refreshes"]);
+
+// Default action
+transactionsCli.action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const pending = await wallet.client.call(
+ WalletApiOperation.GetTransactions,
+ {
+ currency: args.transactions.currency,
+ search: args.transactions.search,
+ includeRefreshes: args.transactions.includeRefreshes,
+ },
+ );
+ console.log(JSON.stringify(pending, undefined, 2));
+ });
+});
+
+transactionsCli
+ .subcommand("deleteTransaction", "delete", {
+ help: "Permanently delete a transaction from the transaction list.",
+ })
+ .requiredArgument("transactionId", clk.STRING, {
+ help: "Identifier of the transaction to delete",
+ })
.action(async (args) => {
await withWallet(args, async (wallet) => {
- const pending = await wallet.client.call(
- WalletApiOperation.GetPendingOperations,
- {},
- );
- console.log(JSON.stringify(pending, undefined, 2));
+ await wallet.client.call(WalletApiOperation.DeleteTransaction, {
+ transactionId: args.deleteTransaction.transactionId as TransactionIdStr,
+ });
});
});
-walletCli
- .subcommand("transactions", "transactions", { help: "Show transactions." })
- .maybeOption("currency", ["--currency"], clk.STRING)
- .maybeOption("search", ["--search"], clk.STRING)
+transactionsCli
+ .subcommand("suspendTransaction", "suspend", {
+ help: "Suspend a transaction.",
+ })
+ .requiredArgument("transactionId", clk.STRING, {
+ help: "Identifier of the transaction to suspend.",
+ })
.action(async (args) => {
await withWallet(args, async (wallet) => {
- const pending = await wallet.client.call(
- WalletApiOperation.GetTransactions,
+ await wallet.client.call(WalletApiOperation.SuspendTransaction, {
+ transactionId: args.suspendTransaction
+ .transactionId as TransactionIdStr,
+ });
+ });
+ });
+
+transactionsCli
+ .subcommand("fail", "fail", {
+ help: "Fail a transaction (when it can't be aborted).",
+ })
+ .requiredArgument("transactionId", clk.STRING, {
+ help: "Identifier of the transaction to fail.",
+ })
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ await wallet.client.call(WalletApiOperation.FailTransaction, {
+ transactionId: args.fail.transactionId as TransactionIdStr,
+ });
+ });
+ });
+
+transactionsCli
+ .subcommand("resumeTransaction", "resume", {
+ help: "Resume a transaction.",
+ })
+ .requiredArgument("transactionId", clk.STRING, {
+ help: "Identifier of the transaction to suspend.",
+ })
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ await wallet.client.call(WalletApiOperation.ResumeTransaction, {
+ transactionId: args.resumeTransaction.transactionId as TransactionIdStr,
+ });
+ });
+ });
+
+transactionsCli
+ .subcommand("lookup", "lookup", {
+ help: "Look up a single transaction based on the transaction identifier.",
+ })
+ .requiredArgument("transactionId", clk.STRING, {
+ help: "Identifier of the transaction to delete",
+ })
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const tx = await wallet.client.call(
+ WalletApiOperation.GetTransactionById,
{
- currency: args.transactions.currency,
- search: args.transactions.search,
+ transactionId: args.lookup.transactionId,
},
);
- console.log(JSON.stringify(pending, undefined, 2));
+ console.log(j2s(tx));
});
});
-async function asyncSleep(milliSeconds: number): Promise<void> {
- return new Promise<void>((resolve, reject) => {
- setTimeout(() => resolve(), milliSeconds);
+transactionsCli
+ .subcommand("abortTransaction", "abort", {
+ help: "Abort a transaction.",
+ })
+ .requiredArgument("transactionId", clk.STRING, {
+ help: "Identifier of the transaction to delete",
+ })
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ await wallet.client.call(WalletApiOperation.AbortTransaction, {
+ transactionId: args.abortTransaction.transactionId as TransactionIdStr,
+ });
+ });
});
-}
walletCli
- .subcommand("runPendingOpt", "run-pending", {
- help: "Run pending operations.",
+ .subcommand("version", "version", {
+ help: "Show version details.",
})
- .flag("forceNow", ["-f", "--force-now"])
.action(async (args) => {
await withWallet(args, async (wallet) => {
- await wallet.ws.runPending(args.runPendingOpt.forceNow);
+ const versionInfo = await wallet.client.call(
+ WalletApiOperation.GetVersion,
+ {},
+ );
+ console.log(j2s(versionInfo));
});
});
-walletCli
- .subcommand("retryTransaction", "retry-transaction", {
+transactionsCli
+ .subcommand("retryTransaction", "retry", {
help: "Retry a transaction.",
})
.requiredArgument("transactionId", clk.STRING)
.action(async (args) => {
await withWallet(args, async (wallet) => {
await wallet.client.call(WalletApiOperation.RetryTransaction, {
- transactionId: args.retryTransaction.transactionId,
+ transactionId: args.retryTransaction.transactionId as TransactionIdStr,
});
});
});
@@ -319,29 +578,80 @@ walletCli
.subcommand("finishPendingOpt", "run-until-done", {
help: "Run until no more work is left.",
})
- .maybeOption("maxRetries", ["--max-retries"], clk.INT)
.action(async (args) => {
- await withWallet(args, async (wallet) => {
+ await withLocalWallet(args, async (wallet) => {
+ logger.info("running until pending operations are finished");
await wallet.ws.runTaskLoop({
- maxRetries: args.finishPendingOpt.maxRetries,
stopWhenDone: true,
});
wallet.ws.stop();
});
});
-walletCli
- .subcommand("deleteTransaction", "delete-transaction", {
- help: "Permanently delete a transaction from the transaction list.",
- })
- .requiredArgument("transactionId", clk.STRING, {
- help: "Identifier of the transaction to delete",
- })
+const withdrawCli = walletCli.subcommand("withdraw", "withdraw", {
+ help: "Withdraw with a taler://withdraw/ URI",
+});
+
+withdrawCli
+ .subcommand("withdrawCheckUri", "check-uri")
+ .requiredArgument("uri", clk.STRING)
+ .maybeOption("restrictAge", ["--restrict-age"], clk.INT)
.action(async (args) => {
+ const uri = args.withdrawCheckUri.uri;
+ const restrictAge = args.withdrawCheckUri.restrictAge;
+ console.log(`age restriction requested (${restrictAge})`);
await withWallet(args, async (wallet) => {
- await wallet.client.call(WalletApiOperation.DeleteTransaction, {
- transactionId: args.deleteTransaction.transactionId,
- });
+ const withdrawInfo = await wallet.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: uri,
+ restrictAge,
+ },
+ );
+ console.log("withdrawInfo", withdrawInfo);
+ });
+ });
+
+withdrawCli
+ .subcommand("withdrawCheckAmount", "check-amount")
+ .requiredArgument("exchange", clk.STRING)
+ .requiredArgument("amount", clk.AMOUNT)
+ .maybeOption("restrictAge", ["--restrict-age"], clk.INT)
+ .action(async (args) => {
+ const restrictAge = args.withdrawCheckAmount.restrictAge;
+ console.log(`age restriction requested (${restrictAge})`);
+ await withWallet(args, async (wallet) => {
+ const withdrawInfo = await wallet.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForAmount,
+ {
+ amount: args.withdrawCheckAmount.amount,
+ exchangeBaseUrl: args.withdrawCheckAmount.exchange,
+ restrictAge,
+ },
+ );
+ console.log("withdrawInfo", withdrawInfo);
+ });
+ });
+
+withdrawCli
+ .subcommand("withdrawAcceptUri", "accept-uri")
+ .requiredArgument("uri", clk.STRING)
+ .requiredOption("exchange", ["--exchange"], clk.STRING)
+ .maybeOption("restrictAge", ["--restrict-age"], clk.INT)
+ .action(async (args) => {
+ const uri = args.withdrawAcceptUri.uri;
+ const restrictAge = args.withdrawAcceptUri.restrictAge;
+ console.log(`age restriction requested (${restrictAge})`);
+ await withWallet(args, async (wallet) => {
+ const res = await wallet.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: args.withdrawAcceptUri.exchange,
+ talerWithdrawUri: uri,
+ restrictAge,
+ },
+ );
+ console.log(j2s(res));
});
});
@@ -349,69 +659,123 @@ walletCli
.subcommand("handleUri", "handle-uri", {
help: "Handle a taler:// URI.",
})
- .requiredArgument("uri", clk.STRING)
+ .maybeArgument("uri", clk.STRING)
+ .maybeOption("withdrawalExchange", ["--withdrawal-exchange"], clk.STRING, {
+ help: "Exchange to use for withdrawal operations.",
+ })
+ .maybeOption("restrictAge", ["--restrict-age"], clk.INT)
.flag("autoYes", ["-y", "--yes"])
.action(async (args) => {
await withWallet(args, async (wallet) => {
- const uri: string = args.handleUri.uri;
- const uriType = classifyTalerUri(uri);
- switch (uriType) {
- case TalerUriType.TalerPay:
+ let uri;
+ if (args.handleUri.uri) {
+ uri = args.handleUri.uri;
+ } else {
+ uri = await readlinePrompt("Taler URI: ");
+ }
+ const parsedTalerUri = parseTalerUri(uri);
+ if (!parsedTalerUri) {
+ throw Error("invalid taler URI");
+ }
+ switch (parsedTalerUri.type) {
+ case TalerUriAction.Pay:
await doPay(wallet.client, uri, {
alwaysYes: args.handleUri.autoYes,
});
break;
- case TalerUriType.TalerTip:
- {
- const res = await wallet.client.call(
- WalletApiOperation.PrepareTip,
- {
- talerTipUri: uri,
- },
- );
- console.log("tip status", res);
- await wallet.client.call(WalletApiOperation.AcceptTip, {
- walletTipId: res.walletTipId,
- });
- }
- break;
- case TalerUriType.TalerRefund:
- await wallet.client.call(WalletApiOperation.ApplyRefund, {
+ case TalerUriAction.Refund:
+ await wallet.client.call(WalletApiOperation.StartRefundQueryForUri, {
talerRefundUri: uri,
});
break;
- case TalerUriType.TalerWithdraw:
- {
- const withdrawInfo = await wallet.client.call(
- WalletApiOperation.GetWithdrawalDetailsForUri,
- {
- talerWithdrawUri: uri,
- },
- );
- console.log("withdrawInfo", withdrawInfo);
- const selectedExchange = withdrawInfo.defaultExchangeBaseUrl;
- if (!selectedExchange) {
- console.error("no suggested exchange!");
- process.exit(1);
- return;
- }
- const res = await wallet.client.call(
- WalletApiOperation.AcceptBankIntegratedWithdrawal,
- {
- exchangeBaseUrl: selectedExchange,
- talerWithdrawUri: uri,
- },
+ case TalerUriAction.Withdraw: {
+ const withdrawInfo = await wallet.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri: uri,
+ restrictAge: args.handleUri.restrictAge,
+ },
+ );
+ console.log("withdrawInfo", withdrawInfo);
+ const selectedExchange =
+ args.handleUri.withdrawalExchange ??
+ withdrawInfo.defaultExchangeBaseUrl;
+ if (!selectedExchange) {
+ console.error(
+ "no exchange specified for withdrawal (and no exchange suggested by the bank)",
);
+ processExit(1);
+ return;
}
+ const res = await wallet.client.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: selectedExchange,
+ talerWithdrawUri: uri,
+ },
+ );
+ console.log("accept withdrawal response", res);
break;
+ }
+ case TalerUriAction.DevExperiment: {
+ await wallet.client.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: uri,
+ });
+ break;
+ }
default:
- console.log(`URI type (${uriType}) not handled`);
+ console.log(`URI type (${parsedTalerUri}) not handled`);
break;
}
return;
});
});
+withdrawCli
+ .subcommand("withdrawManually", "manual", {
+ help: "Withdraw manually from an exchange.",
+ })
+ .requiredOption("exchange", ["--exchange"], clk.STRING, {
+ help: "Base URL of the exchange.",
+ })
+ .requiredOption("amount", ["--amount"], clk.AMOUNT, {
+ help: "Amount to withdraw",
+ })
+ .maybeOption("restrictAge", ["--restrict-age"], clk.INT)
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const exchangeBaseUrl = args.withdrawManually.exchange;
+ const amount = args.withdrawManually.amount;
+ const d = await wallet.client.call(
+ WalletApiOperation.GetWithdrawalDetailsForAmount,
+ {
+ amount: args.withdrawManually.amount,
+ exchangeBaseUrl: exchangeBaseUrl,
+ },
+ );
+ const acct = d.paytoUris[0];
+ if (!acct) {
+ console.log("exchange has no accounts");
+ return;
+ }
+ const resp = await wallet.client.call(
+ WalletApiOperation.AcceptManualWithdrawal,
+ {
+ amount,
+ exchangeBaseUrl,
+ restrictAge: parseInt(String(args.withdrawManually.restrictAge), 10),
+ },
+ );
+ const reservePub = resp.reservePub;
+ const completePaytoUri = addPaytoQueryParams(acct, {
+ amount: args.withdrawManually.amount,
+ message: `Taler top-up ${reservePub}`,
+ });
+ console.log("Created reserve", reservePub);
+ console.log("Payto URI", completePaytoUri);
+ });
+ });
+
const exchangesCli = walletCli.subcommand("exchangesCmd", "exchanges", {
help: "Manage exchanges.",
});
@@ -441,14 +805,33 @@ exchangesCli
.flag("force", ["-f", "--force"])
.action(async (args) => {
await withWallet(args, async (wallet) => {
- await wallet.client.call(WalletApiOperation.AddExchange, {
+ await wallet.client.call(WalletApiOperation.UpdateExchangeEntry, {
exchangeBaseUrl: args.exchangesUpdateCmd.url,
- forceUpdate: args.exchangesUpdateCmd.force,
+ force: args.exchangesUpdateCmd.force,
});
});
});
exchangesCli
+ .subcommand("exchangesShowCmd", "show", {
+ help: "Show exchange details",
+ })
+ .requiredArgument("url", clk.STRING, {
+ help: "Base URL of the exchange.",
+ })
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const resp = await wallet.client.call(
+ WalletApiOperation.GetExchangeDetailedInfo,
+ {
+ exchangeBaseUrl: args.exchangesShowCmd.url,
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ });
+ });
+
+exchangesCli
.subcommand("exchangesAddCmd", "add", {
help: "Add an exchange by base URL.",
})
@@ -464,19 +847,32 @@ exchangesCli
});
exchangesCli
+ .subcommand("exchangesAddCmd", "delete", {
+ help: "Delete an exchange by base URL.",
+ })
+ .requiredArgument("url", clk.STRING, {
+ help: "Base URL of the exchange.",
+ })
+ .flag("purge", ["--purge"])
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ await wallet.client.call(WalletApiOperation.DeleteExchange, {
+ exchangeBaseUrl: args.exchangesAddCmd.url,
+ purge: args.exchangesAddCmd.purge,
+ });
+ });
+ });
+
+exchangesCli
.subcommand("exchangesAcceptTosCmd", "accept-tos", {
help: "Accept terms of service.",
})
.requiredArgument("url", clk.STRING, {
help: "Base URL of the exchange.",
})
- .requiredArgument("etag", clk.STRING, {
- help: "ToS version tag to accept",
- })
.action(async (args) => {
await withWallet(args, async (wallet) => {
await wallet.client.call(WalletApiOperation.SetExchangeTosAccepted, {
- etag: args.exchangesAcceptTosCmd.etag,
exchangeBaseUrl: args.exchangesAcceptTosCmd.url,
});
});
@@ -484,17 +880,26 @@ exchangesCli
exchangesCli
.subcommand("exchangesTosCmd", "tos", {
- help: "Show terms of service.",
+ help: "Show/request terms of service.",
})
.requiredArgument("url", clk.STRING, {
help: "Base URL of the exchange.",
})
+ .maybeOption("contentTypes", ["--content-type"], clk.STRING)
.action(async (args) => {
+ let acceptedFormat: string[] | undefined = undefined;
+ if (args.exchangesTosCmd.contentTypes) {
+ const split = args.exchangesTosCmd.contentTypes
+ .split(",")
+ .map((x) => x.trim());
+ acceptedFormat = split;
+ }
await withWallet(args, async (wallet) => {
const tosResult = await wallet.client.call(
WalletApiOperation.GetExchangeTos,
{
exchangeBaseUrl: args.exchangesTosCmd.url,
+ acceptedFormat,
},
);
console.log(JSON.stringify(tosResult, undefined, 2));
@@ -505,96 +910,72 @@ const backupCli = walletCli.subcommand("backupArgs", "backup", {
help: "Subcommands for backups",
});
-backupCli
- .subcommand("setDeviceId", "set-device-id")
- .requiredArgument("deviceId", clk.STRING, {
- help: "new device ID",
- })
- .action(async (args) => {
- await withWallet(args, async (wallet) => {
- await wallet.client.call(WalletApiOperation.SetWalletDeviceId, {
- walletDeviceId: args.setDeviceId.deviceId,
- });
- });
- });
-
-backupCli.subcommand("exportPlain", "export-plain").action(async (args) => {
+backupCli.subcommand("exportDb", "export-db").action(async (args) => {
await withWallet(args, async (wallet) => {
- const backup = await wallet.client.call(
- WalletApiOperation.ExportBackupPlain,
- {},
- );
+ const backup = await wallet.client.call(WalletApiOperation.ExportDb, {});
console.log(JSON.stringify(backup, undefined, 2));
});
});
-backupCli.subcommand("recoverySave", "save-recovery").action(async (args) => {
+backupCli.subcommand("storeBackup", "store").action(async (args) => {
await withWallet(args, async (wallet) => {
- const recoveryJson = await wallet.client.call(
- WalletApiOperation.ExportBackupRecovery,
+ const resp = await wallet.client.call(
+ WalletApiOperation.CreateStoredBackup,
{},
);
- console.log(JSON.stringify(recoveryJson, undefined, 2));
- });
-});
-
-backupCli.subcommand("run", "run").action(async (args) => {
- await withWallet(args, async (wallet) => {
- await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
+ console.log(JSON.stringify(resp, undefined, 2));
});
});
-backupCli.subcommand("status", "status").action(async (args) => {
+backupCli.subcommand("storeBackup", "list-stored").action(async (args) => {
await withWallet(args, async (wallet) => {
- const status = await wallet.client.call(
- WalletApiOperation.GetBackupInfo,
+ const resp = await wallet.client.call(
+ WalletApiOperation.ListStoredBackups,
{},
);
- console.log(JSON.stringify(status, undefined, 2));
+ console.log(JSON.stringify(resp, undefined, 2));
});
});
backupCli
- .subcommand("recoveryLoad", "load-recovery")
- .maybeOption("strategy", ["--strategy"], clk.STRING, {
- help:
- "Strategy for resolving a conflict with the existing wallet key ('theirs' or 'ours')",
- })
+ .subcommand("storeBackup", "delete-stored")
+ .requiredArgument("name", clk.STRING)
.action(async (args) => {
await withWallet(args, async (wallet) => {
- const data = JSON.parse(await read(process.stdin));
- let strategy: RecoveryMergeStrategy | undefined;
- const stratStr = args.recoveryLoad.strategy;
- if (stratStr) {
- if (stratStr === "theirs") {
- strategy = RecoveryMergeStrategy.Theirs;
- } else if (stratStr === "ours") {
- strategy = RecoveryMergeStrategy.Theirs;
- } else {
- throw Error("invalid recovery strategy");
- }
- }
- await wallet.client.call(WalletApiOperation.ImportBackupRecovery, {
- recovery: data,
- strategy,
- });
+ const resp = await wallet.client.call(
+ WalletApiOperation.DeleteStoredBackup,
+ {
+ name: args.storeBackup.name,
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
});
});
backupCli
- .subcommand("addProvider", "add-provider")
- .requiredArgument("url", clk.STRING)
- .maybeArgument("name", clk.STRING)
- .flag("activate", ["--activate"])
+ .subcommand("recoverBackup", "recover-stored")
+ .requiredArgument("name", clk.STRING)
.action(async (args) => {
await withWallet(args, async (wallet) => {
- await wallet.client.call(WalletApiOperation.AddBackupProvider, {
- backupProviderBaseUrl: args.addProvider.url,
- activate: args.addProvider.activate,
- name: args.addProvider.name || args.addProvider.url,
- });
+ const resp = await wallet.client.call(
+ WalletApiOperation.RecoverStoredBackup,
+ {
+ name: args.recoverBackup.name,
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ });
+ });
+
+backupCli.subcommand("importDb", "import-db").action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const dumpRaw = await read(process.stdin);
+ const dump = JSON.parse(dumpRaw);
+ await wallet.client.call(WalletApiOperation.ImportDb, {
+ dump,
});
});
+});
const depositCli = walletCli.subcommand("depositArgs", "deposit", {
help: "Subcommands for depositing money to payto:// accounts",
@@ -602,10 +983,10 @@ const depositCli = walletCli.subcommand("depositArgs", "deposit", {
depositCli
.subcommand("createDepositArgs", "create")
- .requiredArgument("amount", clk.STRING)
+ .requiredArgument("amount", clk.AMOUNT)
.requiredArgument("targetPayto", clk.STRING)
.action(async (args) => {
- await withWallet(args, async (wallet) => {
+ await withLocalWallet(args, async (wallet) => {
const resp = await wallet.client.call(
WalletApiOperation.CreateDepositGroup,
{
@@ -614,168 +995,479 @@ depositCli
},
);
console.log(`Created deposit ${resp.depositGroupId}`);
- await wallet.ws.runPending();
});
});
-depositCli
- .subcommand("trackDepositArgs", "track")
- .requiredArgument("depositGroupId", clk.STRING)
+const peerCli = walletCli.subcommand("peerArgs", "p2p", {
+ help: "Subcommands for peer-to-peer payments.",
+});
+
+peerCli
+ .subcommand("checkPayPush", "check-push-debit", {
+ help: "Check fees for starting a peer-push debit transaction.",
+ })
+ .requiredArgument("amount", clk.AMOUNT, {
+ help: "Amount to pay",
+ })
.action(async (args) => {
await withWallet(args, async (wallet) => {
const resp = await wallet.client.call(
- WalletApiOperation.TrackDepositGroup,
+ WalletApiOperation.CheckPeerPushDebit,
{
- depositGroupId: args.trackDepositArgs.depositGroupId,
+ amount: args.checkPayPush.amount,
},
);
console.log(JSON.stringify(resp, undefined, 2));
});
});
-const advancedCli = walletCli.subcommand("advancedArgs", "advanced", {
- help:
- "Subcommands for advanced operations (only use if you know what you're doing!).",
-});
+peerCli
+ .subcommand("checkPayPull", "check-pull-credit", {
+ help: "Check fees for a starting peer-pull credit transaction.",
+ })
+ .requiredArgument("amount", clk.AMOUNT, {
+ help: "Amount to request",
+ })
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const resp = await wallet.client.call(
+ WalletApiOperation.CheckPeerPullCredit,
+ {
+ amount: args.checkPayPull.amount,
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ });
+ });
-advancedCli
- .subcommand("bench1", "bench1", {
- help: "Run the 'bench1' benchmark",
+peerCli
+ .subcommand("prepareIncomingPayPull", "prepare-pull-debit")
+ .requiredArgument("talerUri", clk.STRING)
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const resp = await wallet.client.call(
+ WalletApiOperation.PreparePeerPullDebit,
+ {
+ talerUri: args.prepareIncomingPayPull.talerUri,
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ });
+ });
+
+peerCli
+ .subcommand("confirmIncomingPayPull", "confirm-pull-debit")
+ .requiredArgument("transactionId", clk.STRING)
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const resp = await wallet.client.call(
+ WalletApiOperation.ConfirmPeerPullDebit,
+ {
+ transactionId: args.confirmIncomingPayPull
+ .transactionId as TransactionIdStr,
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ });
+ });
+
+peerCli
+ .subcommand("confirmIncomingPayPush", "confirm-push-credit")
+ .requiredArgument("transactionId", clk.STRING)
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const resp = await wallet.client.call(
+ WalletApiOperation.ConfirmPeerPushCredit,
+ {
+ transactionId: args.confirmIncomingPayPush.transactionId,
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ });
+ });
+
+peerCli
+ .subcommand("initiatePayPull", "initiate-pull-credit", {
+ help: "Initiate a peer-pull payment.",
+ })
+ .requiredArgument("amount", clk.AMOUNT, {
+ help: "Amount to request",
})
- .requiredOption("configJson", ["--config-json"], clk.STRING)
+ .maybeOption("summary", ["--summary"], clk.STRING, {
+ help: "Summary to use in the contract terms.",
+ })
+ .maybeOption("purseExpiration", ["--purse-expiration"], clk.STRING)
+ .maybeOption("exchangeBaseUrl", ["--exchange"], clk.STRING)
.action(async (args) => {
- let config: any;
- try {
- config = JSON.parse(args.bench1.configJson);
- } catch (e) {
- console.log("Could not parse config JSON");
+ let purseExpiration: AbsoluteTime;
+
+ if (args.initiatePayPull.purseExpiration) {
+ purseExpiration = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromPrettyString(args.initiatePayPull.purseExpiration),
+ );
+ } else {
+ purseExpiration = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ );
}
- await runBench1(config);
+
+ await withWallet(args, async (wallet) => {
+ const resp = await wallet.client.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: args.initiatePayPull.exchangeBaseUrl,
+ partialContractTerms: {
+ amount: args.initiatePayPull.amount,
+ summary: args.initiatePayPull.summary ?? "Invoice",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(purseExpiration),
+ },
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ });
});
-advancedCli
- .subcommand("env1", "env1", {
- help: "Run a test environment for bench1",
+peerCli
+ .subcommand("preparePushCredit", "prepare-push-credit")
+ .requiredArgument("talerUri", clk.STRING)
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const resp = await wallet.client.call(
+ WalletApiOperation.PreparePeerPushCredit,
+ {
+ talerUri: args.preparePushCredit.talerUri,
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
+ });
+ });
+
+peerCli
+ .subcommand("payPush", "initiate-push-debit", {
+ help: "Initiate a peer-push payment.",
+ })
+ .requiredArgument("amount", clk.AMOUNT, {
+ help: "Amount to pay",
})
+ .maybeOption("summary", ["--summary"], clk.STRING, {
+ help: "Summary to use in the contract terms.",
+ })
+ .maybeOption("purseExpiration", ["--purse-expiration"], clk.STRING)
.action(async (args) => {
- const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-env1-"));
- const testState = new GlobalTestState({
- testDir,
+ let purseExpiration: AbsoluteTime;
+
+ if (args.payPush.purseExpiration) {
+ purseExpiration = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromPrettyString(args.payPush.purseExpiration),
+ );
+ } else {
+ purseExpiration = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ );
+ }
+
+ await withWallet(args, async (wallet) => {
+ const resp = await wallet.client.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ amount: args.payPush.amount,
+ summary: args.payPush.summary ?? "Payment",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(purseExpiration),
+ },
+ },
+ );
+ console.log(JSON.stringify(resp, undefined, 2));
});
- await runTestWithState(testState, runEnv1, "env1", true);
});
+const advancedCli = walletCli.subcommand("advancedArgs", "advanced", {
+ help: "Subcommands for advanced operations (only use if you know what you're doing!).",
+});
+
advancedCli
- .subcommand("withdrawFakebank", "withdraw-fakebank", {
- help: "Withdraw via a fakebank.",
+ .subcommand("sampleTransactions", "sample-transactions", {
+ help: "Print sample wallet-core transactions",
})
- .requiredOption("exchange", ["--exchange"], clk.STRING, {
- help: "Base URL of the exchange to use",
+ .action(async (args) => {
+ console.log(JSON.stringify(sampleWalletCoreTransactions, undefined, 2));
+ });
+
+advancedCli
+ .subcommand("serve", "serve", {
+ help: "Serve the wallet API via a unix domain socket.",
})
- .requiredOption("amount", ["--amount"], clk.STRING, {
- help: "Amount to withdraw (before fees).",
+ .requiredOption("unixPath", ["--unix-path"], clk.STRING, {
+ default: "wallet-core.sock",
})
- .requiredOption("bank", ["--bank"], clk.STRING, {
- help: "Base URL of the Taler fakebank service.",
+ .flag("noInit", ["--no-init"], {
+ help: "Do not initialize the wallet. The client must send the initWallet message.",
})
.action(async (args) => {
- await withWallet(args, async (wallet) => {
- await wallet.client.call(WalletApiOperation.WithdrawFakebank, {
- amount: args.withdrawFakebank.amount,
- bank: args.withdrawFakebank.bank,
- exchange: args.withdrawFakebank.exchange,
+ logger.info(`serving at ${args.serve.unixPath}`);
+ const onNotif = (notif: WalletNotification) => {
+ if (observabilityEventFile) {
+ switch (notif.type) {
+ case NotificationType.RequestObservabilityEvent:
+ case NotificationType.TaskObservabilityEvent:
+ fs.appendFileSync(
+ observabilityEventFile,
+ JSON.stringify(notif) + "\n",
+ );
+ break;
+ }
+ }
+ };
+ const wh = await createLocalWallet(args, onNotif, args.serve.noInit);
+ const w = wh.wallet;
+ let nextClientId = 1;
+ const notifyHandlers = new Map<number, (n: WalletNotification) => void>();
+ w.addNotificationListener((n) => {
+ notifyHandlers.forEach((v, k) => {
+ v(n);
});
});
+ await runRpcServer({
+ socketFilename: args.serve.unixPath,
+ onConnect(client) {
+ logger.info("connected");
+ const clientId = nextClientId++;
+ notifyHandlers.set(clientId, (n: WalletNotification) => {
+ client.sendResponse({
+ type: "notification",
+ payload: n as unknown as JsonMessage,
+ });
+ });
+ return {
+ onDisconnect() {
+ notifyHandlers.delete(clientId);
+ logger.info("disconnected");
+ },
+ onMessage(msg) {
+ logger.info(`message: ${j2s(msg)}`);
+ const op = (msg as any).operation;
+ const id = (msg as any).id;
+ const payload = (msg as any).args;
+ w.handleCoreApiRequest(op, id, payload)
+ .then((resp) => {
+ logger.info("sending response");
+ client.sendResponse(resp as unknown as JsonMessage);
+ })
+ .catch((e) => {
+ logger.error(`unexpected error: ${e}`);
+ });
+ },
+ };
+ },
+ });
});
advancedCli
- .subcommand("manualWithdrawalDetails", "manual-withdrawal-details", {
- help: "Query withdrawal fees.",
+ .subcommand("init", "init", {
+ help: "Initialize the wallet (with DB) and exit.",
})
- .requiredArgument("exchange", clk.STRING)
- .requiredArgument("amount", clk.STRING)
+ .action(async (args) => {
+ await withWallet(args, async () => {});
+ });
+
+advancedCli
+ .subcommand("runPendingOpt", "run-pending", {
+ help: "Run pending operations.",
+ })
+ .action(async (args) => {
+ logger.error(
+ "Subcommand run-pending not supported anymore. Please use run-until-done or the client/server wallet.",
+ );
+ });
+
+advancedCli
+ .subcommand("pending", "pending", { help: "Show pending operations." })
.action(async (args) => {
await withWallet(args, async (wallet) => {
- const details = await wallet.client.call(
- WalletApiOperation.GetWithdrawalDetailsForAmount,
- {
- amount: args.manualWithdrawalDetails.amount,
- exchangeBaseUrl: args.manualWithdrawalDetails.exchange,
- },
+ const pending = await wallet.client.call(
+ WalletApiOperation.GetPendingOperations,
+ {},
);
- console.log(JSON.stringify(details, undefined, 2));
+ console.log(JSON.stringify(pending, undefined, 2));
});
});
advancedCli
- .subcommand("decode", "decode", {
- help: "Decode base32-crockford.",
+ .subcommand("benchInternal", "bench-internal", {
+ help: "Run the 'bench-internal' benchmark",
})
- .action((args) => {
- const enc = fs.readFileSync(0, "utf8");
- fs.writeFileSync(1, decodeCrock(enc.trim()));
+ .action(async (args) => {
+ const myHttpLib = createPlatformHttpLib();
+ const res = await createNativeWalletHost2({
+ // No persistent DB storage.
+ persistentStoragePath: undefined,
+ httpLib: myHttpLib,
+ });
+ const wallet = res.wallet;
+ await wallet.client.call(WalletApiOperation.InitWallet, {});
+ await wallet.client.call(WalletApiOperation.RunIntegrationTest, {
+ amountToSpend: "TESTKUDOS:1" as AmountString,
+ amountToWithdraw: "TESTKUDOS:3" as AmountString,
+ corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/",
+ exchangeBaseUrl: "http://localhost:8081/",
+ merchantBaseUrl: "http://localhost:8083/",
+ });
+ await wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+ wallet.stop();
});
advancedCli
- .subcommand("withdrawManually", "withdraw-manually", {
- help: "Withdraw manually from an exchange.",
+ .subcommand("genSegwit", "gen-segwit")
+ .requiredArgument("paytoUri", clk.STRING)
+ .requiredArgument("reservePub", clk.STRING)
+ .action(async (args) => {
+ const p = parsePaytoUri(args.genSegwit.paytoUri);
+ console.log(p);
+ });
+
+const currenciesCli = walletCli.subcommand("currencies", "currencies", {
+ help: "Manage currencies.",
+});
+
+currenciesCli
+ .subcommand("listGlobalAuditors", "list-global-auditors", {
+ help: "List global-currency auditors.",
})
- .requiredOption("exchange", ["--exchange"], clk.STRING, {
- help: "Base URL of the exchange.",
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const currencies = await wallet.client.call(
+ WalletApiOperation.ListGlobalCurrencyAuditors,
+ {},
+ );
+ console.log(JSON.stringify(currencies, undefined, 2));
+ });
+ });
+
+currenciesCli
+ .subcommand("listGlobalExchanges", "list-global-exchanges", {
+ help: "List global-currency exchanges.",
})
- .requiredOption("amount", ["--amount"], clk.STRING, {
- help: "Amount to withdraw",
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const currencies = await wallet.client.call(
+ WalletApiOperation.ListGlobalCurrencyExchanges,
+ {},
+ );
+ console.log(JSON.stringify(currencies, undefined, 2));
+ });
+ });
+
+currenciesCli
+ .subcommand("addGlobalExchange", "add-global-exchange", {
+ help: "Add a global-currency exchange.",
})
+ .requiredOption("currency", ["--currency"], clk.STRING)
+ .requiredOption("exchangeBaseUrl", ["--url"], clk.STRING)
+ .requiredOption("exchangePub", ["--pub"], clk.STRING)
.action(async (args) => {
await withWallet(args, async (wallet) => {
- const exchangeBaseUrl = args.withdrawManually.exchange;
- const amount = args.withdrawManually.amount;
- const d = await wallet.client.call(
- WalletApiOperation.GetWithdrawalDetailsForAmount,
+ const currencies = await wallet.client.call(
+ WalletApiOperation.AddGlobalCurrencyExchange,
{
- amount: args.withdrawManually.amount,
- exchangeBaseUrl: exchangeBaseUrl,
+ currency: args.addGlobalExchange.currency,
+ exchangeBaseUrl: args.addGlobalExchange.exchangeBaseUrl,
+ exchangeMasterPub: args.addGlobalExchange.exchangePub,
},
);
- const acct = d.paytoUris[0];
- if (!acct) {
- console.log("exchange has no accounts");
- return;
- }
- const resp = await wallet.client.call(
- WalletApiOperation.AcceptManualWithdrawal,
+ console.log(JSON.stringify(currencies, undefined, 2));
+ });
+ });
+
+currenciesCli
+ .subcommand("removeGlobalExchange", "remove-global-exchange", {
+ help: "Remove a global-currency exchange.",
+ })
+ .requiredOption("currency", ["--currency"], clk.STRING)
+ .requiredOption("exchangeBaseUrl", ["--url"], clk.STRING)
+ .requiredOption("exchangePub", ["--pub"], clk.STRING)
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const currencies = await wallet.client.call(
+ WalletApiOperation.RemoveGlobalCurrencyExchange,
{
- amount,
- exchangeBaseUrl,
+ currency: args.removeGlobalExchange.currency,
+ exchangeBaseUrl: args.removeGlobalExchange.exchangeBaseUrl,
+ exchangeMasterPub: args.removeGlobalExchange.exchangePub,
},
);
- const reservePub = resp.reservePub;
- const completePaytoUri = addPaytoQueryParams(acct, {
- amount: args.withdrawManually.amount,
- message: `Taler top-up ${reservePub}`,
- });
- console.log("Created reserve", reservePub);
- console.log("Payto URI", completePaytoUri);
+ console.log(JSON.stringify(currencies, undefined, 2));
});
});
-const currenciesCli = walletCli.subcommand("currencies", "currencies", {
- help: "Manage currencies.",
-});
+currenciesCli
+ .subcommand("addGlobalAuditor", "add-global-auditor", {
+ help: "Add a global-currency auditor.",
+ })
+ .requiredOption("currency", ["--currency"], clk.STRING)
+ .requiredOption("auditorBaseUrl", ["--url"], clk.STRING)
+ .requiredOption("auditorPub", ["--pub"], clk.STRING)
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ const currencies = await wallet.client.call(
+ WalletApiOperation.AddGlobalCurrencyAuditor,
+ {
+ currency: args.addGlobalAuditor.currency,
+ auditorBaseUrl: args.addGlobalAuditor.auditorBaseUrl,
+ auditorPub: args.addGlobalAuditor.auditorPub,
+ },
+ );
+ console.log(JSON.stringify(currencies, undefined, 2));
+ });
+ });
currenciesCli
- .subcommand("show", "show", { help: "Show currencies." })
+ .subcommand("removeGlobalAuditor", "remove-global-auditor", {
+ help: "Remove a global-currency auditor.",
+ })
+ .requiredOption("currency", ["--currency"], clk.STRING)
+ .requiredOption("auditorBaseUrl", ["--url"], clk.STRING)
+ .requiredOption("auditorPub", ["--pub"], clk.STRING)
.action(async (args) => {
await withWallet(args, async (wallet) => {
const currencies = await wallet.client.call(
- WalletApiOperation.ListCurrencies,
- {},
+ WalletApiOperation.RemoveGlobalCurrencyAuditor,
+ {
+ currency: args.removeGlobalAuditor.currency,
+ auditorBaseUrl: args.removeGlobalAuditor.auditorBaseUrl,
+ auditorPub: args.removeGlobalAuditor.auditorPub,
+ },
);
console.log(JSON.stringify(currencies, undefined, 2));
});
});
advancedCli
+ .subcommand("clearDatabase", "clear-database", {
+ help: "Clear the database, irrevocable deleting all data in the wallet.",
+ })
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ await wallet.client.call(WalletApiOperation.ClearDb, {});
+ });
+ });
+
+advancedCli
+ .subcommand("recycle", "recycle", {
+ help: "Export, clear and re-import the database via the backup mechanism.",
+ })
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ await wallet.client.call(WalletApiOperation.Recycle, {});
+ });
+ });
+
+advancedCli
.subcommand("payPrepare", "pay-prepare", {
help: "Claim an order but don't pay yet.",
})
@@ -809,6 +1501,19 @@ advancedCli
});
advancedCli
+ .subcommand("queryRefund", "query-refund", {
+ help: "Query refunds for a payment transaction.",
+ })
+ .requiredArgument("transactionId", clk.STRING)
+ .action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ await wallet.client.call(WalletApiOperation.StartRefundQuery, {
+ transactionId: args.queryRefund.transactionId as TransactionIdStr,
+ });
+ });
+ });
+
+advancedCli
.subcommand("payConfirm", "pay-confirm", {
help: "Confirm payment proposed by a merchant.",
})
@@ -831,7 +1536,11 @@ advancedCli
.action(async (args) => {
await withWallet(args, async (wallet) => {
await wallet.client.call(WalletApiOperation.ForceRefresh, {
- coinPubList: [args.refresh.coinPub],
+ refreshCoinSpecs: [
+ {
+ coinPub: args.refresh.coinPub,
+ },
+ ],
});
});
});
@@ -866,7 +1575,7 @@ advancedCli
);
} catch (e: any) {
console.log("could not parse coin list:", e.message);
- process.exit(1);
+ processExit(1);
}
for (const c of coinPubList) {
await wallet.client.call(WalletApiOperation.SetCoinSuspended, {
@@ -891,7 +1600,7 @@ advancedCli
);
} catch (e: any) {
console.log("could not parse coin list:", e.message);
- process.exit(1);
+ processExit(1);
}
for (const c of coinPubList) {
await wallet.client.call(WalletApiOperation.SetCoinSuspended, {
@@ -913,210 +1622,164 @@ advancedCli
console.log(`coin ${coin.coin_pub}`);
console.log(` exchange ${coin.exchange_base_url}`);
console.log(` denomPubHash ${coin.denom_pub_hash}`);
- console.log(
- ` remaining amount ${Amounts.stringify(coin.remaining_value)}`,
- );
+ console.log(` status ${coin.coin_status}`);
}
});
});
-const deploymentCli = walletCli.subcommand("deploymentArgs", "deployment", {
- help: "Subcommands for handling GNU Taler deployments.",
-});
-
-deploymentCli
- .subcommand("lintExchange", "lint-exchange", {
- help: "Run checks on the exchange deployment.",
- })
- .flag("cont", ["--continue"], {
- help: "Continue after errors if possible",
- })
- .flag("debug", ["--debug"], {
- help: "Output extra debug info",
- })
- .action(async (args) => {
- await lintExchangeDeployment(
- args.lintExchange.debug,
- args.lintExchange.cont,
- );
- });
-
-deploymentCli
- .subcommand("coincfg", "gen-coin-config", {
- help: "Generate a coin/denomination configuration for the exchange.",
- })
- .requiredOption("minAmount", ["--min-amount"], clk.STRING, {
- help: "Smallest denomination",
- })
- .requiredOption("maxAmount", ["--max-amount"], clk.STRING, {
- help: "Largest denomination",
- })
- .action(async (args) => {
- let out = "";
-
- const stamp = Math.floor(new Date().getTime() / 1000);
-
- const min = Amounts.parseOrThrow(args.coincfg.minAmount);
- const max = Amounts.parseOrThrow(args.coincfg.maxAmount);
- if (min.currency != max.currency) {
- console.error("currency mismatch");
- process.exit(1);
- }
- const currency = min.currency;
- let x = min;
- let n = 1;
-
- out += "# Coin configuration for the exchange.\n";
- out += '# Should be placed in "/etc/taler/conf.d/exchange-coins.conf".\n';
- out += "\n";
-
- while (Amounts.cmp(x, max) < 0) {
- out += `[COIN-${currency}-n${n}-t${stamp}]\n`;
- out += `VALUE = ${Amounts.stringify(x)}\n`;
- out += `DURATION_WITHDRAW = 7 days\n`;
- out += `DURATION_SPEND = 2 years\n`;
- out += `DURATION_LEGAL = 6 years\n`;
- out += `FEE_WITHDRAW = ${currency}:0\n`;
- out += `FEE_DEPOSIT = ${Amounts.stringify(min)}\n`;
- out += `FEE_REFRESH = ${currency}:0\n`;
- out += `FEE_REFUND = ${currency}:0\n`;
- out += `RSA_KEYSIZE = 2048\n`;
- out += "\n";
- x = Amounts.add(x, x).amount;
- n++;
- }
-
- console.log(out);
- });
-
-const deploymentConfigCli = deploymentCli.subcommand("configArgs", "config", {
- help: "Subcommands the Taler configuration.",
-});
-
-deploymentConfigCli
- .subcommand("show", "show")
- .flag("diagnostics", ["-d", "--diagnostics"])
- .maybeArgument("cfgfile", clk.STRING, {})
- .action(async (args) => {
- const cfg = Configuration.load(args.show.cfgfile);
- console.log(
- cfg.stringify({
- diagnostics: args.show.diagnostics,
- }),
- );
- });
-
const testCli = walletCli.subcommand("testingArgs", "testing", {
help: "Subcommands for testing.",
});
testCli
- .subcommand("listIntegrationtests", "list-integrationtests")
+ .subcommand("withdrawTestkudos", "withdraw-testkudos")
.action(async (args) => {
- for (const t of getTestInfo()) {
- let s = t.name;
- if (t.suites.length > 0) {
- s += ` (suites: ${t.suites.join(",")})`;
- }
- if (t.excludeByDefault) {
- s += ` [excluded by default]`;
- }
- console.log(s);
- }
+ await withWallet(args, async (wallet) => {
+ await wallet.client.call(WalletApiOperation.WithdrawTestkudos, {});
+ });
});
-testCli
- .subcommand("runIntegrationtests", "run-integrationtests")
- .maybeArgument("pattern", clk.STRING, {
- help: "Glob pattern to select which tests to run",
- })
- .maybeOption("suites", ["--suites"], clk.STRING, {
- help: "Only run selected suites (comma-separated list)",
- })
- .flag("dryRun", ["--dry"], {
- help: "Only print tests that will be selected to run.",
- })
- .flag("quiet", ["--quiet"], {
- help: "Produce less output.",
- })
- .action(async (args) => {
- await runTests({
- includePattern: args.runIntegrationtests.pattern,
- suiteSpec: args.runIntegrationtests.suites,
- dryRun: args.runIntegrationtests.dryRun,
- verbosity: args.runIntegrationtests.quiet ? 0 : 1,
+testCli.subcommand("withdrawKudos", "withdraw-kudos").action(async (args) => {
+ await withWallet(args, async (wallet) => {
+ await wallet.client.call(WalletApiOperation.WithdrawTestBalance, {
+ amount: "KUDOS:50" as AmountString,
+ corebankApiBaseUrl: "https://bank.demo.taler.net/",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
});
});
+});
-async function read(stream: NodeJS.ReadStream) {
- const chunks = [];
- for await (const chunk of stream) chunks.push(chunk);
- return Buffer.concat(chunks).toString("utf8");
-}
+class PerfTimer {
+ tStarted: bigint | undefined;
+ tSum = BigInt(0);
+ tSumSq = BigInt(0);
-testCli.subcommand("tvgcheck", "tvgcheck").action(async (args) => {
- const data = await read(process.stdin);
+ start() {
+ this.tStarted = process.hrtime.bigint();
+ }
- const lines = data.match(/[^\r\n]+/g);
+ stop() {
+ const now = process.hrtime.bigint();
+ const s = this.tStarted;
+ if (s == null) {
+ throw Error();
+ }
+ this.tSum = this.tSum + (now - s);
+ this.tSumSq = this.tSumSq + (now - s) * (now - s);
+ }
- if (!lines) {
- throw Error("can't split lines");
+ mean(nRuns: number): number {
+ return Number(this.tSum / BigInt(nRuns));
}
- const vals: Record<string, string> = {};
+ stdev(nRuns: number) {
+ const m = this.tSum / BigInt(nRuns);
+ const x = this.tSumSq / BigInt(nRuns) - m * m;
+ return Math.floor(Math.sqrt(Number(x)));
+ }
+}
- let inBlindSigningSection = false;
+testCli
+ .subcommand("benchmarkAgeRestrictions", "benchmark-age-restrictions")
+ .requiredOption("reps", ["--reps"], clk.INT, {
+ default: 100,
+ help: "repetitions (default: 100)",
+ })
+ .action(async (args) => {
+ const numReps = args.benchmarkAgeRestrictions.reps ?? 100;
+ let tCommit = new PerfTimer();
+ let tAttest = new PerfTimer();
+ let tVerify = new PerfTimer();
+ let tDerive = new PerfTimer();
+ let tCompare = new PerfTimer();
+
+ console.log("starting benchmark");
+
+ for (let i = 0; i < numReps; i++) {
+ console.log(`doing iteration ${i}`);
+ tCommit.start();
+ const commitProof = await AgeRestriction.restrictionCommit(
+ 0b1000001010101010101001,
+ 21,
+ );
+ tCommit.stop();
- for (const line of lines) {
- if (line === "blind signing:") {
- inBlindSigningSection = true;
- continue;
- }
- if (line[0] !== " ") {
- inBlindSigningSection = false;
- continue;
- }
- if (inBlindSigningSection) {
- const m = line.match(/ (\w+) (\w+)/);
- if (!m) {
- console.log("bad format");
- process.exit(2);
+ tAttest.start();
+ const attest = AgeRestriction.commitmentAttest(commitProof, 18);
+ tAttest.stop();
+
+ tVerify.start();
+ const attestRes = AgeRestriction.commitmentVerify(
+ commitProof.commitment,
+ encodeCrock(attest),
+ 18,
+ );
+ tVerify.stop();
+ if (!attestRes) {
+ throw Error();
}
- vals[m[1]] = m[2];
- }
- }
- console.log(vals);
+ const salt = getRandomBytes(32);
+ tDerive.start();
+ const deriv = await AgeRestriction.commitmentDerive(commitProof, salt);
+ tDerive.stop();
- const req = (k: string) => {
- if (!vals[k]) {
- throw Error(`no value for ${k}`);
+ tCompare.start();
+ const res2 = await AgeRestriction.commitCompare(
+ deriv.commitment,
+ commitProof.commitment,
+ salt,
+ );
+ tCompare.stop();
+ if (!res2) {
+ throw Error();
+ }
}
- return decodeCrock(vals[k]);
- };
-
- const myBm = rsaBlind(
- req("message_hash"),
- req("blinding_key_secret"),
- req("rsa_public_key"),
- );
- deepStrictEqual(req("blinded_message"), myBm);
+ console.log(
+ `edx25519-commit (ns): ${tCommit.mean(numReps)} (stdev ${tCommit.stdev(
+ numReps,
+ )})`,
+ );
+ console.log(
+ `edx25519-attest (ns): ${tAttest.mean(numReps)} (stdev ${tAttest.stdev(
+ numReps,
+ )})`,
+ );
+ console.log(
+ `edx25519-verify (ns): ${tVerify.mean(numReps)} (stdev ${tVerify.stdev(
+ numReps,
+ )})`,
+ );
+ console.log(
+ `edx25519-derive (ns): ${tDerive.mean(numReps)} (stdev ${tDerive.stdev(
+ numReps,
+ )})`,
+ );
+ console.log(
+ `edx25519-compare (ns): ${tCompare.mean(numReps)} (stdev ${tCompare.stdev(
+ numReps,
+ )})`,
+ );
+ });
- console.log("check passed!");
+testCli.subcommand("logtest", "logtest").action(async (args) => {
+ logger.trace("This is a trace message.");
+ logger.info("This is an info message.");
+ logger.warn("This is an warning message.");
+ logger.error("This is an error message.");
});
-testCli.subcommand("cryptoworker", "cryptoworker").action(async (args) => {
- const workerFactory = new NodeThreadCryptoWorkerFactory();
- const cryptoApi = new CryptoApi(workerFactory);
- const res = await cryptoApi.hashString("foo");
- console.log(res);
-});
+async function read(stream: NodeJS.ReadStream) {
+ const chunks = [];
+ for await (const chunk of stream) chunks.push(chunk);
+ return Buffer.concat(chunks).toString("utf8");
+}
export function main() {
- if (process.env["TALER_WALLET_DEBUG_DENOMSEL_ALLOW_LATE"]) {
- logger.warn("Allowing withdrawal of late denominations for debugging");
- walletCoreDebugFlags.denomselAllowLate = true;
+ const maybeFilename = getenv("TALER_WALLET_DEBUG_OBSERVE");
+ if (!!maybeFilename) {
+ observabilityEventFile = maybeFilename;
}
walletCli.run();
}
diff --git a/packages/taler-wallet-cli/src/integrationtests/scenario-prompt-payment.ts b/packages/taler-wallet-cli/src/integrationtests/scenario-prompt-payment.ts
deleted file mode 100644
index ea05de8e9..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/scenario-prompt-payment.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
-
-/**
- * Run test for basic, bank-integrated withdrawal.
- */
-export async function runPromptPaymentScenario(t: GlobalTestState) {
- // Set up test environment
-
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
-
- // Withdraw digital cash into the wallet.
-
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
-
- // Set up order.
-
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
- order: {
- summary: "Buy me!",
- amount: "TESTKUDOS:5",
- fulfillment_url: "taler://fulfillment-success/thx",
- },
- });
-
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
- orderId: orderResp.order_id,
- });
-
- t.assertTrue(orderStatus.order_status === "unpaid");
-
- console.log(orderStatus);
-
- // Wait "forever"
- await new Promise(() => {});
-}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts b/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts
deleted file mode 100644
index 28cca0758..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-denom-unoffered.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- PreparePayResultType,
- TalerErrorCode,
- TalerErrorDetails,
- TransactionType,
-} from "@gnu-taler/taler-util";
-import {
- WalletApiOperation,
-} from "@gnu-taler/taler-wallet-core";
-import { makeEventId } from "@gnu-taler/taler-wallet-core";
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
-
-export async function runDenomUnofferedTest(t: GlobalTestState) {
- // Set up test environment
-
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
-
- // Withdraw digital cash into the wallet.
-
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
-
- // Make the exchange forget the denomination.
- // Effectively we completely reset the exchange,
- // but keep the exchange master public key.
-
- await exchange.stop();
- await exchange.purgeDatabase();
- await exchange.purgeSecmodKeys();
- await exchange.start();
- await exchange.pingUntilAvailable();
-
- await merchant.stop();
- await merchant.start();
- await merchant.pingUntilAvailable();
-
- const order = {
- summary: "Buy me!",
- amount: "TESTKUDOS:5",
- fulfillment_url: "taler://fulfillment-success/thx",
- };
-
- {
- const orderResp = await MerchantPrivateApi.createOrder(
- merchant,
- "default",
- {
- order: order,
- },
- );
-
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
- merchant,
- {
- orderId: orderResp.order_id,
- },
- );
-
- t.assertTrue(orderStatus.order_status === "unpaid");
-
- // Make wallet pay for the order
-
- const preparePayResult = await wallet.client.call(
- WalletApiOperation.PreparePayForUri,
- {
- talerPayUri: orderStatus.taler_pay_uri,
- },
- );
-
- t.assertTrue(
- preparePayResult.status === PreparePayResultType.PaymentPossible,
- );
-
- const exc = await t.assertThrowsAsync(async () => {
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
- proposalId: preparePayResult.proposalId,
- });
- });
-
- const errorDetails: TalerErrorDetails = exc.operationError;
- // FIXME: We might want a more specific error code here!
- t.assertDeepEqual(
- errorDetails.code,
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- );
- const merchantErrorCode = (errorDetails.details as any).errorResponse.code;
- t.assertDeepEqual(
- merchantErrorCode,
- TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_DENOMINATION_KEY_NOT_FOUND,
- );
- }
-
- await wallet.client.call(WalletApiOperation.AddExchange, {
- exchangeBaseUrl: exchange.baseUrl,
- forceUpdate: true,
- });
-
- // Now withdrawal should work again.
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
-
- await wallet.runUntilDone();
-
- const txs = await wallet.client.call(WalletApiOperation.GetTransactions, {});
- console.log(JSON.stringify(txs, undefined, 2));
-}
-
-runDenomUnofferedTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts b/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts
deleted file mode 100644
index f33c8338b..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { GlobalTestState } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
-
-/**
- * Run test for basic, bank-integrated withdrawal and payment.
- */
-export async function runDepositTest(t: GlobalTestState) {
- // Set up test environment
-
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
-
- // Withdraw digital cash into the wallet.
-
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
-
- await wallet.runUntilDone();
-
- const { depositGroupId } = await wallet.client.call(
- WalletApiOperation.CreateDepositGroup,
- {
- amount: "TESTKUDOS:10",
- depositPaytoUri: "payto://x-taler-bank/localhost/foo",
- },
- );
-
- await wallet.runUntilDone();
-
- const transactions = await wallet.client.call(
- WalletApiOperation.GetTransactions,
- {},
- );
- console.log("transactions", JSON.stringify(transactions, undefined, 2));
- t.assertDeepEqual(transactions.transactions[0].type, "withdrawal");
- t.assertTrue(!transactions.transactions[0].pending);
- t.assertDeepEqual(transactions.transactions[1].type, "deposit");
- t.assertTrue(!transactions.transactions[1].pending);
- // The raw amount is what ends up on the bank account, which includes
- // deposit and wire fees.
- t.assertDeepEqual(transactions.transactions[1].amountRaw, "TESTKUDOS:9.79");
-
- const trackResult = wallet.client.call(WalletApiOperation.TrackDepositGroup, {
- depositGroupId,
- });
-
- console.log(JSON.stringify(trackResult, undefined, 2));
-}
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankaccount.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankaccount.ts
deleted file mode 100644
index 839ad5fa7..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankaccount.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import {
- NexusUserBundle,
- LibeufinNexusApi,
- LibeufinNexusService,
- LibeufinSandboxService,
- LibeufinSandboxApi,
- findNexusPayment,
-} from "../harness/libeufin";
-
-/**
- * Run basic test with LibEuFin.
- */
-export async function runLibeufinApiBankaccountTest(t: GlobalTestState) {
- const nexus = await LibeufinNexusService.create(t, {
- httpPort: 5011,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
- });
- await nexus.start();
- await nexus.pingUntilAvailable();
-
- await LibeufinNexusApi.createUser(nexus, {
- username: "one",
- password: "testing-the-bankaccount-api",
- });
- const sandbox = await LibeufinSandboxService.create(t, {
- httpPort: 5012,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
- });
- await sandbox.start();
- await sandbox.pingUntilAvailable();
- await LibeufinSandboxApi.createEbicsHost(sandbox, "mock");
- await LibeufinSandboxApi.createEbicsSubscriber(sandbox, {
- hostID: "mock",
- userID: "mock",
- partnerID: "mock",
- });
- await LibeufinSandboxApi.createEbicsBankAccount(sandbox, {
- subscriber: {
- hostID: "mock",
- partnerID: "mock",
- userID: "mock",
- },
- iban: "DE71500105179674997361",
- bic: "BELADEBEXXX",
- name: "mock",
- currency: "EUR",
- label: "mock",
- });
- await LibeufinNexusApi.createEbicsBankConnection(nexus, {
- name: "bankaccount-api-test-connection",
- ebicsURL: "http://localhost:5012/ebicsweb",
- hostID: "mock",
- userID: "mock",
- partnerID: "mock",
- });
- await LibeufinNexusApi.connectBankConnection(
- nexus,
- "bankaccount-api-test-connection",
- );
- await LibeufinNexusApi.fetchAccounts(
- nexus,
- "bankaccount-api-test-connection",
- );
-
- await LibeufinNexusApi.importConnectionAccount(
- nexus,
- "bankaccount-api-test-connection",
- "mock",
- "local-mock",
- );
- await LibeufinSandboxApi.simulateIncomingTransaction(
- sandbox,
- "mock", // creditor bankaccount label
- {
- debtorIban: "DE84500105176881385584",
- debtorBic: "BELADEBEXXX",
- debtorName: "mock2",
- amount: "1",
- subject: "mock subject",
- }
- );
- await LibeufinNexusApi.fetchTransactions(nexus, "local-mock");
- let transactions = await LibeufinNexusApi.getAccountTransactions(
- nexus,
- "local-mock",
- );
- let el = findNexusPayment("mock subject", transactions.data);
- t.assertTrue(el instanceof Object);
-}
-runLibeufinApiBankaccountTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankconnection.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankconnection.ts
deleted file mode 100644
index f1d507c03..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-bankconnection.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import {
- NexusUserBundle,
- LibeufinNexusApi,
- LibeufinNexusService,
- LibeufinSandboxService,
- LibeufinSandboxApi,
- findNexusPayment,
-} from "../harness/libeufin";
-
-/**
- * Run basic test with LibEuFin.
- */
-export async function runLibeufinApiBankconnectionTest(t: GlobalTestState) {
- const nexus = await LibeufinNexusService.create(t, {
- httpPort: 5011,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
- });
- await nexus.start();
- await nexus.pingUntilAvailable();
-
- await LibeufinNexusApi.createUser(nexus, {
- username: "one",
- password: "testing-the-bankconnection-api",
- });
-
- await LibeufinNexusApi.createEbicsBankConnection(nexus, {
- name: "bankconnection-api-test-connection",
- ebicsURL: "http://localhost:5012/ebicsweb",
- hostID: "mock",
- userID: "mock",
- partnerID: "mock",
- });
-
- let connections = await LibeufinNexusApi.getAllConnections(nexus);
- t.assertTrue(connections.data["bankConnections"].length == 1);
-
- await LibeufinNexusApi.deleteBankConnection(nexus, {
- bankConnectionId: "bankconnection-api-test-connection",
- });
- connections = await LibeufinNexusApi.getAllConnections(nexus);
- t.assertTrue(connections.data["bankConnections"].length == 0);
-}
-runLibeufinApiBankconnectionTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-facade-bad-request.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-facade-bad-request.ts
deleted file mode 100644
index b106cf304..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-facade-bad-request.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import axios from "axios";
-import { URL } from "@gnu-taler/taler-util";
-import { GlobalTestState } from "../harness/harness.js";
-import {
- SandboxUserBundle,
- NexusUserBundle,
- launchLibeufinServices,
- LibeufinNexusApi,
-} from "../harness/libeufin";
-
-export async function runLibeufinApiFacadeBadRequestTest(t: GlobalTestState) {
-
- /**
- * User saltetd "01"
- */
- const user01nexus = new NexusUserBundle(
- "01",
- "http://localhost:5010/ebicsweb",
- );
- const user01sandbox = new SandboxUserBundle("01");
-
- /**
- * Launch Sandbox and Nexus.
- */
- const libeufinServices = await launchLibeufinServices(
- t,
- [user01nexus],
- [user01sandbox],
- ["twg"],
- );
- console.log("malformed facade");
- const baseUrl = libeufinServices.libeufinNexus.baseUrl;
- let url = new URL("facades", baseUrl);
- let resp = await axios.post(
- url.href,
- {
- name: "malformed-facade",
- type: "taler-wire-gateway",
- config: {}, // malformation here.
- },
- {
- auth: {
- username: "admin",
- password: "test",
- },
- validateStatus: () => true,
- },
- );
- t.assertTrue(resp.status == 400);
-}
-
-runLibeufinApiFacadeBadRequestTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-facade.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-facade.ts
deleted file mode 100644
index c49d49712..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-facade.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import {
- SandboxUserBundle,
- NexusUserBundle,
- launchLibeufinServices,
- LibeufinNexusApi,
-} from "../harness/libeufin";
-
-/**
- * Run basic test with LibEuFin.
- */
-export async function runLibeufinApiFacadeTest(t: GlobalTestState) {
- /**
- * User saltetd "01"
- */
- const user01nexus = new NexusUserBundle(
- "01",
- "http://localhost:5010/ebicsweb",
- );
- const user01sandbox = new SandboxUserBundle("01");
-
- /**
- * Launch Sandbox and Nexus.
- */
- const libeufinServices = await launchLibeufinServices(
- t,
- [user01nexus],
- [user01sandbox],
- ["twg"],
- );
- let resp = await LibeufinNexusApi.getAllFacades(
- libeufinServices.libeufinNexus,
- );
- // check that original facade shows up.
- t.assertTrue(resp.data["facades"][0]["name"] == user01nexus.twgReq["name"]);
-
- const twgBaseUrl: string = resp.data["facades"][0]["baseUrl"];
- t.assertTrue(typeof twgBaseUrl === "string");
- t.assertTrue(twgBaseUrl.startsWith("http://"));
- t.assertTrue(twgBaseUrl.endsWith("/"));
-
- // delete it.
- resp = await LibeufinNexusApi.deleteFacade(
- libeufinServices.libeufinNexus,
- user01nexus.twgReq["name"],
- );
- // check that no facades show up.
- t.assertTrue(!resp.data.hasOwnProperty("facades"));
-}
-
-runLibeufinApiFacadeTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-permissions.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-permissions.ts
deleted file mode 100644
index e64f459a0..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-permissions.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import {
- NexusUserBundle,
- LibeufinNexusApi,
- LibeufinNexusService,
-} from "../harness/libeufin";
-
-/**
- * Run basic test with LibEuFin.
- */
-export async function runLibeufinApiPermissionsTest(t: GlobalTestState) {
- const nexus = await LibeufinNexusService.create(t, {
- httpPort: 5011,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
- });
- await nexus.start();
- await nexus.pingUntilAvailable();
-
- const user01nexus = new NexusUserBundle(
- "01",
- "http://localhost:5010/ebicsweb",
- );
-
- await LibeufinNexusApi.createUser(nexus, user01nexus.userReq);
- await LibeufinNexusApi.postPermission(
- nexus,
- user01nexus.twgTransferPermission,
- );
- let transferPermission = await LibeufinNexusApi.getAllPermissions(nexus);
- let element = transferPermission.data["permissions"].pop();
- t.assertTrue(
- element["permissionName"] == "facade.talerwiregateway.transfer" &&
- element["subjectId"] == "username-01",
- );
- let denyTransfer = user01nexus.twgTransferPermission;
-
- // Now revoke permission.
- denyTransfer["action"] = "revoke";
- await LibeufinNexusApi.postPermission(nexus, denyTransfer);
-
- transferPermission = await LibeufinNexusApi.getAllPermissions(nexus);
- t.assertTrue(transferPermission.data["permissions"].length == 0);
-}
-
-runLibeufinApiPermissionsTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-sandbox-camt.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-sandbox-camt.ts
deleted file mode 100644
index f5df4cfa3..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-sandbox-camt.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import {
- NexusUserBundle,
- LibeufinNexusApi,
- LibeufinNexusService,
- LibeufinSandboxService,
- LibeufinSandboxApi,
- findNexusPayment,
-} from "../harness/libeufin";
-
-// This test only checks that LibEuFin doesn't fail when
-// it generates Camt statements - no assertions take place.
-// Furthermore, it prints the Camt.053 being generated.
-export async function runLibeufinApiSandboxCamtTest(t: GlobalTestState) {
-
- const sandbox = await LibeufinSandboxService.create(t, {
- httpPort: 5012,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
- });
- await sandbox.start();
- await sandbox.pingUntilAvailable();
- await LibeufinSandboxApi.createBankAccount(sandbox, {
- iban: "DE71500105179674997361",
- bic: "BELADEBEXXX",
- name: "Mock Name",
- label: "mock-account-0",
- currency: "EUR"
- });
- await LibeufinSandboxApi.createBankAccount(sandbox, {
- iban: "DE71500105179674997361",
- bic: "BELADEBEXXX",
- name: "Mock Name",
- label: "mock-account-1",
- currency: "EUR"
- });
- await sandbox.makeTransaction("mock-account-0", "mock-account-1", "EUR:1", "+1");
- await sandbox.makeTransaction("mock-account-0", "mock-account-1", "EUR:1", "+1");
- await sandbox.makeTransaction("mock-account-0", "mock-account-1", "EUR:1", "+1");
- await sandbox.makeTransaction("mock-account-1", "mock-account-0", "EUR:5", "minus 5");
- await sandbox.c53tick();
- let ret = await LibeufinSandboxApi.getCamt053(sandbox, "mock-account-1");
- console.log(ret);
-}
-runLibeufinApiSandboxCamtTest.excludeByDefault = true;
-runLibeufinApiSandboxCamtTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-sandbox-transactions.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-sandbox-transactions.ts
deleted file mode 100644
index a90644926..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-sandbox-transactions.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import {
- NexusUserBundle,
- LibeufinNexusApi,
- LibeufinNexusService,
- LibeufinSandboxService,
- LibeufinSandboxApi,
- findNexusPayment,
-} from "../harness/libeufin";
-
-export async function runLibeufinApiSandboxTransactionsTest(t: GlobalTestState) {
-
- const sandbox = await LibeufinSandboxService.create(t, {
- httpPort: 5012,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
- });
- await sandbox.start();
- await sandbox.pingUntilAvailable();
- await LibeufinSandboxApi.createBankAccount(sandbox, {
- iban: "DE71500105179674997361",
- bic: "BELADEBEXXX",
- name: "Mock Name",
- label: "mock-account",
- currency: "EUR"
- });
- await LibeufinSandboxApi.simulateIncomingTransaction(
- sandbox,
- "mock-account",
- {
-
- debtorIban: "DE84500105176881385584",
- debtorBic: "BELADEBEXXX",
- debtorName: "mock2",
- subject: "mock subject",
- amount: "1" // EUR is default.
- }
- )
- await LibeufinSandboxApi.simulateIncomingTransaction(
- sandbox,
- "mock-account",
- {
-
- debtorIban: "DE84500105176881385584",
- debtorBic: "BELADEBEXXX",
- debtorName: "mock2",
- subject: "mock subject 2",
- amount: "1.1" // EUR is default.
- }
- )
- let ret = await LibeufinSandboxApi.getAccountInfoWithBalance(sandbox, "mock-account");
- t.assertAmountEquals(ret.data.balance, "EUR:2.1");
-}
-runLibeufinApiSandboxTransactionsTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-scheduling.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-scheduling.ts
deleted file mode 100644
index 3863c5711..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-scheduling.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState, setupDb } from "../harness/harness.js";
-import {
- SandboxUserBundle,
- NexusUserBundle,
- launchLibeufinServices,
- LibeufinSandboxApi,
- LibeufinNexusApi,
- LibeufinNexusService,
-} from "../harness/libeufin";
-
-/**
- * Test Nexus scheduling API. It creates a task, check whether it shows
- * up, then deletes it, and check if it's gone. Ideally, a check over the
- * _liveliness_ of a scheduled task should happen.
- */
-export async function runLibeufinApiSchedulingTest(t: GlobalTestState) {
- const nexus = await LibeufinNexusService.create(t, {
- httpPort: 5011,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
- });
- await nexus.start();
- await nexus.pingUntilAvailable();
-
- const user01nexus = new NexusUserBundle(
- "01",
- "http://localhost:5010/ebicsweb",
- );
- const user01sandbox = new SandboxUserBundle("01");
- await launchLibeufinServices(t, [user01nexus], [user01sandbox]);
- await LibeufinNexusApi.postTask(nexus, user01nexus.localAccountName, {
- name: "test-task",
- cronspec: "* * *",
- type: "fetch",
- params: {
- level: "all",
- rangeType: "all",
- },
- });
- let resp = await LibeufinNexusApi.getTasks(
- nexus,
- user01nexus.localAccountName,
- "test-task",
- );
- t.assertTrue(resp.data["taskName"] == "test-task");
- await LibeufinNexusApi.deleteTask(
- nexus,
- user01nexus.localAccountName,
- "test-task",
- );
- try {
- await LibeufinNexusApi.getTasks(
- nexus,
- user01nexus.localAccountName,
- "test-task",
- );
- } catch (err: any) {
- t.assertTrue(err.response.status == 404);
- }
-
- // Same with submit task.
- await LibeufinNexusApi.postTask(nexus, user01nexus.localAccountName, {
- name: "test-task",
- cronspec: "* * *",
- type: "submit",
- params: {},
- });
- resp = await LibeufinNexusApi.getTasks(
- nexus,
- user01nexus.localAccountName,
- "test-task",
- );
- t.assertTrue(resp.data["taskName"] == "test-task");
- await LibeufinNexusApi.deleteTask(
- nexus,
- user01nexus.localAccountName,
- "test-task",
- );
- try {
- await LibeufinNexusApi.getTasks(
- nexus,
- user01nexus.localAccountName,
- "test-task",
- );
- } catch (err: any) {
- t.assertTrue(err.response.status == 404);
- }
-}
-runLibeufinApiSchedulingTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-users.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-users.ts
deleted file mode 100644
index edf66690b..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-api-users.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import { LibeufinNexusApi, LibeufinNexusService } from "../harness/libeufin";
-
-/**
- * Run basic test with LibEuFin.
- */
-export async function runLibeufinApiUsersTest(t: GlobalTestState) {
- const nexus = await LibeufinNexusService.create(t, {
- httpPort: 5011,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
- });
- await nexus.start();
- await nexus.pingUntilAvailable();
-
- await LibeufinNexusApi.createUser(nexus, {
- username: "one",
- password: "will-be-changed",
- });
-
- await LibeufinNexusApi.changePassword(
- nexus,
- "one",
- {
- newPassword: "got-changed",
- },
- {
- auth: {
- username: "admin",
- password: "test",
- },
- },
- );
-
- let resp = await LibeufinNexusApi.getUser(nexus, {
- auth: {
- username: "one",
- password: "got-changed",
- },
- });
- console.log(resp.data);
- t.assertTrue(resp.data["username"] == "one" && !resp.data["superuser"]);
-}
-
-runLibeufinApiUsersTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-bad-gateway.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-bad-gateway.ts
deleted file mode 100644
index 786e61832..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-bad-gateway.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState, delayMs } from "../harness/harness.js";
-import {
- NexusUserBundle,
- LibeufinNexusApi,
- LibeufinNexusService,
- LibeufinSandboxService,
-} from "../harness/libeufin";
-
-/**
- * Testing how Nexus reacts when the Sandbox is unreachable.
- * Typically, because the user specified a wrong EBICS endpoint.
- */
-export async function runLibeufinBadGatewayTest(t: GlobalTestState) {
- /**
- * User saltetd "01"
- */
- const user01nexus = new NexusUserBundle(
- "01", "http://localhost:5010/not-found", // the EBICS endpoint at Sandbox
- );
-
- // Start Nexus
- const libeufinNexus = await LibeufinNexusService.create(t, {
- httpPort: 5011,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
- });
- await libeufinNexus.start();
- await libeufinNexus.pingUntilAvailable();
-
- // Start Sandbox
- const libeufinSandbox = await LibeufinSandboxService.create(t, {
- httpPort: 5010,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
- });
- await libeufinSandbox.start();
- await libeufinSandbox.pingUntilAvailable();
-
- // Connecting to a non-existent Sandbox endpoint.
- await LibeufinNexusApi.createEbicsBankConnection(
- libeufinNexus,
- user01nexus.connReq
- );
-
- // 502 Bad Gateway expected.
- try {
- await LibeufinNexusApi.connectBankConnection(
- libeufinNexus,
- user01nexus.connReq.name,
- );
- } catch(e: any) {
- t.assertTrue(e.response.status == 502);
- return;
- }
- t.assertTrue(false);
-}
-runLibeufinBadGatewayTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-basic.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-basic.ts
deleted file mode 100644
index 9e1842d03..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-basic.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { CoreApiResponse } from "@gnu-taler/taler-util";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures";
-import {
- DbInfo,
- HarnessExchangeBankAccount,
- ExchangeService,
- GlobalTestState,
- MerchantService,
- setupDb,
- WalletCli,
-} from "../harness/harness.js";
-import { makeTestPayment } from "../harness/helpers.js";
-import {
- LibeufinNexusApi,
- LibeufinNexusService,
- LibeufinSandboxApi,
- LibeufinSandboxService,
-} from "../harness/libeufin";
-
-const exchangeIban = "DE71500105179674997361";
-const customerIban = "DE84500105176881385584";
-const customerBic = "BELADEBEXXX";
-const merchantIban = "DE42500105171245624648";
-
-export interface LibeufinTestEnvironment {
- commonDb: DbInfo;
- exchange: ExchangeService;
- exchangeBankAccount: HarnessExchangeBankAccount;
- merchant: MerchantService;
- wallet: WalletCli;
- libeufinSandbox: LibeufinSandboxService;
- libeufinNexus: LibeufinNexusService;
-}
-
-/**
- * Create a Taler environment with LibEuFin and an EBICS account.
- */
-export async function createLibeufinTestEnvironment(
- t: GlobalTestState,
- coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("EUR")),
-): Promise<LibeufinTestEnvironment> {
- const db = await setupDb(t);
-
- const libeufinSandbox = await LibeufinSandboxService.create(t, {
- httpPort: 5010,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
- });
-
- await libeufinSandbox.start();
- await libeufinSandbox.pingUntilAvailable();
-
- const libeufinNexus = await LibeufinNexusService.create(t, {
- httpPort: 5011,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
- });
-
- await libeufinNexus.start();
- await libeufinNexus.pingUntilAvailable();
-
- await LibeufinSandboxApi.createEbicsHost(libeufinSandbox, "host01");
- // Subscriber and bank Account for the exchange
- await LibeufinSandboxApi.createEbicsSubscriber(libeufinSandbox, {
- hostID: "host01",
- partnerID: "partner01",
- userID: "user01",
- });
- await LibeufinSandboxApi.createEbicsBankAccount(libeufinSandbox, {
- bic: "DEUTDEBB101",
- iban: exchangeIban,
- label: "exchangeacct",
- name: "Taler Exchange",
- subscriber: {
- hostID: "host01",
- partnerID: "partner01",
- userID: "user01",
- },
- currency: "EUR",
- });
- // Subscriber and bank Account for the merchant
- // (Merchant doesn't need EBICS access, but sandbox right now only supports EBICS
- // accounts.)
- await LibeufinSandboxApi.createEbicsSubscriber(libeufinSandbox, {
- hostID: "host01",
- partnerID: "partner02",
- userID: "user02",
- });
- await LibeufinSandboxApi.createEbicsBankAccount(libeufinSandbox, {
- bic: "AUTOATW1XXX",
- iban: merchantIban,
- label: "merchantacct",
- name: "Merchant",
- subscriber: {
- hostID: "host01",
- partnerID: "partner02",
- userID: "user02",
- },
- currency: "EUR",
- });
-
- await LibeufinNexusApi.createEbicsBankConnection(libeufinNexus, {
- name: "myconn",
- ebicsURL: "http://localhost:5010/ebicsweb",
- hostID: "host01",
- partnerID: "partner01",
- userID: "user01",
- });
- await LibeufinNexusApi.connectBankConnection(libeufinNexus, "myconn");
- await LibeufinNexusApi.fetchAccounts(libeufinNexus, "myconn");
- await LibeufinNexusApi.importConnectionAccount(
- libeufinNexus,
- "myconn",
- "exchangeacct",
- "myacct",
- );
-
- await LibeufinNexusApi.createTwgFacade(libeufinNexus, {
- name: "twg1",
- accountName: "myacct",
- connectionName: "myconn",
- currency: "EUR",
- reserveTransferLevel: "report",
- });
-
- await LibeufinNexusApi.createUser(libeufinNexus, {
- username: "twguser",
- password: "twgpw",
- });
-
- await LibeufinNexusApi.postPermission(libeufinNexus, {
- action: "grant",
- permission: {
- subjectType: "user",
- subjectId: "twguser",
- resourceType: "facade",
- resourceId: "twg1",
- permissionName: "facade.talerWireGateway.history",
- },
- });
-
- await LibeufinNexusApi.postPermission(libeufinNexus, {
- action: "grant",
- permission: {
- subjectType: "user",
- subjectId: "twguser",
- resourceType: "facade",
- resourceId: "twg1",
- permissionName: "facade.talerWireGateway.transfer",
- },
- });
-
- const exchange = ExchangeService.create(t, {
- name: "testexchange-1",
- currency: "EUR",
- httpPort: 8081,
- database: db.connStr,
- });
-
- const merchant = await MerchantService.create(t, {
- name: "testmerchant-1",
- currency: "EUR",
- httpPort: 8083,
- database: db.connStr,
- });
-
- const exchangeBankAccount: HarnessExchangeBankAccount = {
- accountName: "twguser",
- accountPassword: "twgpw",
- accountPaytoUri: `payto://iban/${exchangeIban}?receiver-name=Exchange`,
- wireGatewayApiBaseUrl:
- "http://localhost:5011/facades/twg1/taler-wire-gateway/",
- };
-
- exchange.addBankAccount("1", exchangeBankAccount);
-
- exchange.addCoinConfigList(coinConfig);
-
- await exchange.start();
- await exchange.pingUntilAvailable();
-
- merchant.addExchange(exchange);
-
- await merchant.start();
- await merchant.pingUntilAvailable();
-
- await merchant.addInstance({
- id: "default",
- name: "Default Instance",
- paytoUris: [`payto://iban/${merchantIban}?receiver-name=Merchant`],
- defaultWireTransferDelay: { d_ms: 0 },
- });
-
- console.log("setup done!");
-
- const wallet = new WalletCli(t);
-
- return {
- commonDb: db,
- exchange,
- merchant,
- wallet,
- exchangeBankAccount,
- libeufinNexus,
- libeufinSandbox,
- };
-}
-
-/**
- * Run basic test with LibEuFin.
- */
-export async function runLibeufinBasicTest(t: GlobalTestState) {
- // Set up test environment
-
- const {
- wallet,
- exchange,
- merchant,
- libeufinSandbox,
- libeufinNexus,
- } = await createLibeufinTestEnvironment(t);
-
- await wallet.client.call(WalletApiOperation.AddExchange, {
- exchangeBaseUrl: exchange.baseUrl,
- });
-
- const wr = await wallet.client.call(
- WalletApiOperation.AcceptManualWithdrawal,
- {
- exchangeBaseUrl: exchange.baseUrl,
- amount: "EUR:10",
- },
- );
-
- const reservePub: string = wr.reservePub;
-
- await LibeufinSandboxApi.simulateIncomingTransaction(
- libeufinSandbox,
- "exchangeacct",
- {
- amount: "15.00",
- debtorBic: customerBic,
- debtorIban: customerIban,
- debtorName: "Jane Customer",
- subject: `Taler Top-up ${reservePub}`,
- },
- );
-
- await LibeufinNexusApi.fetchTransactions(libeufinNexus, "myacct");
-
- await exchange.runWirewatchOnce();
-
- await wallet.runUntilDone();
-
- const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
- console.log("balances", JSON.stringify(bal, undefined, 2));
- t.assertAmountEquals(bal.balances[0].available, "EUR:14.7");
-
- const order = {
- summary: "Buy me!",
- amount: "EUR:5",
- fulfillment_url: "taler://fulfillment-success/thx",
- };
-
- await makeTestPayment(t, { wallet, merchant, order });
-
- await exchange.runAggregatorOnce();
- await exchange.runTransferOnce();
-
- await LibeufinNexusApi.submitAllPaymentInitiations(libeufinNexus, "myacct");
-
- const exchangeTransactions = await LibeufinSandboxApi.getAccountTransactions(
- libeufinSandbox,
- "exchangeacct",
- );
-
- console.log(
- "exchange transactions:",
- JSON.stringify(exchangeTransactions, undefined, 2),
- );
-
- t.assertDeepEqual(
- exchangeTransactions.payments[0].creditDebitIndicator,
- "credit",
- );
- t.assertDeepEqual(
- exchangeTransactions.payments[1].creditDebitIndicator,
- "debit",
- );
- t.assertDeepEqual(exchangeTransactions.payments[1].debtorIban, exchangeIban);
- t.assertDeepEqual(
- exchangeTransactions.payments[1].creditorIban,
- merchantIban,
- );
-}
-runLibeufinBasicTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-c5x.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-c5x.ts
deleted file mode 100644
index 5a995fb69..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-c5x.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState, delayMs } from "../harness/harness.js";
-import {
- SandboxUserBundle,
- NexusUserBundle,
- launchLibeufinServices,
- LibeufinSandboxApi,
- LibeufinNexusApi,
-} from "../harness/libeufin";
-
-/**
- * This test checks how the C52 and C53 coordinate. It'll test
- * whether fresh transactions stop showing as C52 after they get
- * included in a bank statement.
- */
-export async function runLibeufinC5xTest(t: GlobalTestState) {
- /**
- * User saltetd "01"
- */
- const user01nexus = new NexusUserBundle(
- "01",
- "http://localhost:5010/ebicsweb",
- );
- const user01sandbox = new SandboxUserBundle("01");
-
- /**
- * User saltetd "02".
- */
- const user02nexus = new NexusUserBundle(
- "02",
- "http://localhost:5010/ebicsweb",
- );
- const user02sandbox = new SandboxUserBundle("02");
-
- /**
- * Launch Sandbox and Nexus.
- */
- const libeufinServices = await launchLibeufinServices(
- t,
- [user01nexus, user02nexus],
- [user01sandbox, user02sandbox],
- ["twg"],
- );
-
- // Check that C52 and C53 have zero entries.
-
- // C52
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- "all", // range
- "report", // level
- );
- // C53
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- "all", // range
- "statement", // level
- );
- const nexusTxs = await LibeufinNexusApi.getAccountTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- );
- t.assertTrue(nexusTxs.data["transactions"].length == 0);
-
- // Addressing one payment to user 01
- await libeufinServices.libeufinSandbox.makeTransaction(
- user02sandbox.ebicsBankAccount.label, // debit
- user01sandbox.ebicsBankAccount.label, // credit
- "EUR:10",
- "first payment",
- );
-
- // Checking that C52 has one and C53 has zero.
-
- let expectOne = await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- "all", // range
- "report", // C52
- );
- t.assertTrue(expectOne.data.newTransactions == 1);
- t.assertTrue(expectOne.data.downloadedTransactions == 1);
-
- let expectZero = await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- "all", // range
- "statement", // C53
- );
- t.assertTrue(expectZero.data.newTransactions == 0);
- t.assertTrue(expectZero.data.downloadedTransactions == 0);
-
- // Ticking now: the one payment should be downloaded
- // in a C53 but not in a C52. In any case, the payment
- // is not new anymore, because it was already ingested
- // when it was downloaded for the first time along the
- // c52 above.
- await libeufinServices.libeufinSandbox.c53tick();
-
- expectOne = await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- "all", // range
- "statement", // C53
- );
- t.assertTrue(expectOne.data.downloadedTransactions == 1);
- t.assertTrue(expectOne.data.newTransactions == 0);
-
- expectZero = await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- "all", // range
- "report", // C52
- );
- t.assertTrue(expectZero.data.downloadedTransactions == 0);
- t.assertTrue(expectZero.data.newTransactions == 0);
-}
-runLibeufinC5xTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-facade-anastasis.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-facade-anastasis.ts
deleted file mode 100644
index 0bbd4fd28..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-facade-anastasis.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import {
- SandboxUserBundle,
- NexusUserBundle,
- launchLibeufinServices,
- LibeufinNexusApi,
- LibeufinSandboxApi,
-} from "../harness/libeufin";
-
-/**
- * Testing the Anastasis API, offered by the Anastasis facade.
- */
-export async function runLibeufinAnastasisFacadeTest(t: GlobalTestState) {
- /**
- * User saltetd "01"
- */
- const user01nexus = new NexusUserBundle(
- "01",
- "http://localhost:5010/ebicsweb",
- );
- const user01sandbox = new SandboxUserBundle("01");
-
- /**
- * Launch Sandbox and Nexus.
- */
- const libeufinServices = await launchLibeufinServices(
- t,
- [user01nexus],
- [user01sandbox],
- ["anastasis"], // create only one Anastasis facade.
- );
- let resp = await LibeufinNexusApi.getAllFacades(
- libeufinServices.libeufinNexus,
- );
- // check that original facade shows up.
- t.assertTrue(resp.data["facades"][0]["name"] == user01nexus.anastasisReq["name"]);
- const anastasisBaseUrl: string = resp.data["facades"][0]["baseUrl"];
- t.assertTrue(typeof anastasisBaseUrl === "string");
- t.assertTrue(anastasisBaseUrl.startsWith("http://"));
- t.assertTrue(anastasisBaseUrl.endsWith("/"));
-
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- );
-
- await LibeufinNexusApi.postPermission(
- libeufinServices.libeufinNexus, {
- action: "grant",
- permission: {
- subjectId: user01nexus.userReq.username,
- subjectType: "user",
- resourceType: "facade",
- resourceId: user01nexus.anastasisReq.name,
- permissionName: "facade.anastasis.history",
- },
- }
- );
-
- // check if empty.
- let txsEmpty = await LibeufinNexusApi.getAnastasisTransactions(
- libeufinServices.libeufinNexus,
- anastasisBaseUrl, {delta: 5})
-
- t.assertTrue(txsEmpty.data.incoming_transactions.length == 0);
-
- LibeufinSandboxApi.simulateIncomingTransaction(
- libeufinServices.libeufinSandbox,
- user01sandbox.ebicsBankAccount.label,
- {
- debtorIban: "ES3314655813489414469157",
- debtorBic: "BCMAESM1XXX",
- debtorName: "Mock Donor",
- subject: "Anastasis donation",
- amount: "3", // Sandbox takes currency from its 'config'
- },
- )
-
- LibeufinSandboxApi.simulateIncomingTransaction(
- libeufinServices.libeufinSandbox,
- user01sandbox.ebicsBankAccount.label,
- {
- debtorIban: "ES3314655813489414469157",
- debtorBic: "BCMAESM1XXX",
- debtorName: "Mock Donor",
- subject: "another Anastasis donation",
- amount: "1", // Sandbox takes currency from its "config"
- },
- )
-
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- );
-
- let txs = await LibeufinNexusApi.getAnastasisTransactions(
- libeufinServices.libeufinNexus,
- anastasisBaseUrl,
- {delta: 5},
- user01nexus.userReq.username,
- user01nexus.userReq.password,
- );
-
- // check the two payments show up
- let txsList = txs.data.incoming_transactions
- t.assertTrue(txsList.length == 2);
- t.assertTrue([txsList[0].subject, txsList[1].subject].includes("Anastasis donation"));
- t.assertTrue([txsList[0].subject, txsList[1].subject].includes("another Anastasis donation"));
- t.assertTrue(txsList[0].row_id == 1)
- t.assertTrue(txsList[1].row_id == 2)
-
- LibeufinSandboxApi.simulateIncomingTransaction(
- libeufinServices.libeufinSandbox,
- user01sandbox.ebicsBankAccount.label,
- {
- debtorIban: "ES3314655813489414469157",
- debtorBic: "BCMAESM1XXX",
- debtorName: "Mock Donor",
- subject: "last Anastasis donation",
- amount: "10.10", // Sandbox takes currency from its "config"
- },
- )
-
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- );
-
- let txsLast = await LibeufinNexusApi.getAnastasisTransactions(
- libeufinServices.libeufinNexus,
- anastasisBaseUrl,
- {delta: 5, start: 2},
- user01nexus.userReq.username,
- user01nexus.userReq.password,
- );
- console.log(txsLast.data.incoming_transactions[0].subject == "last Anastasis donation");
-
- let txsReverse = await LibeufinNexusApi.getAnastasisTransactions(
- libeufinServices.libeufinNexus,
- anastasisBaseUrl,
- {delta: -5, start: 4},
- user01nexus.userReq.username,
- user01nexus.userReq.password,
- );
- t.assertTrue(txsReverse.data.incoming_transactions[0].row_id == 3);
- t.assertTrue(txsReverse.data.incoming_transactions[1].row_id == 2);
- t.assertTrue(txsReverse.data.incoming_transactions[2].row_id == 1);
-}
-
-runLibeufinAnastasisFacadeTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-keyrotation.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-keyrotation.ts
deleted file mode 100644
index 5dc31f0bf..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-keyrotation.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import {
- SandboxUserBundle,
- NexusUserBundle,
- launchLibeufinServices,
- LibeufinSandboxApi,
- LibeufinNexusApi,
-} from "../harness/libeufin";
-
-/**
- * Run basic test with LibEuFin.
- */
-export async function runLibeufinKeyrotationTest(t: GlobalTestState) {
- /**
- * User saltetd "01"
- */
- const user01nexus = new NexusUserBundle(
- "01",
- "http://localhost:5010/ebicsweb",
- );
- const user01sandbox = new SandboxUserBundle("01");
-
- /**
- * Launch Sandbox and Nexus.
- */
- const libeufinServices = await launchLibeufinServices(
- t, [user01nexus], [user01sandbox],
- );
-
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- );
-
- /* Rotate the Sandbox keys, and fetch the transactions again */
- await LibeufinSandboxApi.rotateKeys(
- libeufinServices.libeufinSandbox,
- user01sandbox.ebicsBankAccount.subscriber.hostID,
- );
-
- try {
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- );
- } catch (e: any) {
- /**
- * Asserting that Nexus responded with a 500 Internal server
- * error, because the bank signed the last response with a new
- * key pair that was never downloaded by Nexus.
- *
- * NOTE: the bank accepted the request addressed to the old
- * public key. Should it in this case reject the request even
- * before trying to verify it?
- */
- t.assertTrue(e.response.status == 500);
- t.assertTrue(e.response.data.code == 9000);
- }
-}
-runLibeufinKeyrotationTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-nexus-balance.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-nexus-balance.ts
deleted file mode 100644
index 23d76081f..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-nexus-balance.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState, delayMs } from "../harness/harness.js";
-import {
- SandboxUserBundle,
- NexusUserBundle,
- launchLibeufinServices,
- LibeufinSandboxApi,
- LibeufinNexusApi,
-} from "../harness/libeufin";
-
-/**
- * This test checks how the C52 and C53 coordinate. It'll test
- * whether fresh transactions stop showing as C52 after they get
- * included in a bank statement.
- */
-export async function runLibeufinNexusBalanceTest(t: GlobalTestState) {
- /**
- * User saltetd "01"
- */
- const user01nexus = new NexusUserBundle(
- "01",
- "http://localhost:5010/ebicsweb",
- );
- const user01sandbox = new SandboxUserBundle("01");
-
- /**
- * User saltetd "02".
- */
- const user02nexus = new NexusUserBundle(
- "02",
- "http://localhost:5010/ebicsweb",
- );
- const user02sandbox = new SandboxUserBundle("02");
-
- /**
- * Launch Sandbox and Nexus.
- */
- const libeufinServices = await launchLibeufinServices(
- t,
- [user01nexus, user02nexus],
- [user01sandbox, user02sandbox],
- ["twg"],
- );
-
- // user 01 gets 10
- await libeufinServices.libeufinSandbox.makeTransaction(
- user02sandbox.ebicsBankAccount.label, // debit
- user01sandbox.ebicsBankAccount.label, // credit
- "EUR:10",
- "first payment",
- );
-
- // user 01 gets another 10
- await libeufinServices.libeufinSandbox.makeTransaction(
- user02sandbox.ebicsBankAccount.label, // debit
- user01sandbox.ebicsBankAccount.label, // credit
- "EUR:10",
- "first payment",
- );
-
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- "all", // range
- "report", // level
- );
-
- // Check that user 01 has 20, via Nexus.
- let accountInfo = await LibeufinNexusApi.getBankAccount(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName
- );
- t.assertTrue(accountInfo.data.lastSeenBalance == "EUR:20");
-
- // user 01 gives 30
- await libeufinServices.libeufinSandbox.makeTransaction(
- user01sandbox.ebicsBankAccount.label, // credit
- user02sandbox.ebicsBankAccount.label, // debit
- "EUR:30",
- "third payment",
- );
-
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- "all", // range
- "report", // level
- );
-
- let accountInfoDebit = await LibeufinNexusApi.getBankAccount(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName
- );
- t.assertTrue(accountInfoDebit.data.lastSeenBalance == "-EUR:10");
-}
-runLibeufinNexusBalanceTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-refund-multiple-users.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-refund-multiple-users.ts
deleted file mode 100644
index 39517f247..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-refund-multiple-users.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState, delayMs } from "../harness/harness.js";
-import {
- SandboxUserBundle,
- NexusUserBundle,
- launchLibeufinServices,
- LibeufinSandboxApi,
- LibeufinNexusApi,
-} from "../harness/libeufin";
-
-/**
- * User 01 expects a refund from user 02, and expectedly user 03
- * should not be involved in the process.
- */
-export async function runLibeufinRefundMultipleUsersTest(t: GlobalTestState) {
- /**
- * User saltetd "01"
- */
- const user01nexus = new NexusUserBundle(
- "01",
- "http://localhost:5010/ebicsweb",
- );
- const user01sandbox = new SandboxUserBundle("01");
-
- /**
- * User saltetd "02"
- */
- const user02nexus = new NexusUserBundle(
- "02",
- "http://localhost:5010/ebicsweb",
- );
- const user02sandbox = new SandboxUserBundle("02");
-
- /**
- * User saltetd "03"
- */
- const user03nexus = new NexusUserBundle(
- "03",
- "http://localhost:5010/ebicsweb",
- );
- const user03sandbox = new SandboxUserBundle("03");
-
- /**
- * Launch Sandbox and Nexus.
- */
- const libeufinServices = await launchLibeufinServices(
- t,
- [user01nexus, user02nexus],
- [user01sandbox, user02sandbox],
- ["twg"],
- );
-
- // user 01 gets the payment
- await libeufinServices.libeufinSandbox.makeTransaction(
- user02sandbox.ebicsBankAccount.label, // debit
- user01sandbox.ebicsBankAccount.label, // credit
- "EUR:1",
- "not a public key",
- );
-
- // user 01 fetches the payments
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- );
-
- // user 01 tries to submit the reimbursement, as
- // the payment didn't have a valid public key in
- // the subject.
- await LibeufinNexusApi.submitInitiatedPayment(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- "1", // so far the only one that can exist.
- );
-
- // user 02 checks whether a reimbursement arrived.
- let history = await LibeufinSandboxApi.getAccountTransactions(
- libeufinServices.libeufinSandbox,
- user02sandbox.ebicsBankAccount["label"],
- );
- // reimbursement arrived IFF the total payments are 2:
- // 1 the original (faulty) transaction + 1 the reimbursement.
- t.assertTrue(history["payments"].length == 2);
-}
-
-runLibeufinRefundMultipleUsersTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-refund.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-refund.ts
deleted file mode 100644
index d91ae88bb..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-refund.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState, delayMs } from "../harness/harness.js";
-import {
- SandboxUserBundle,
- NexusUserBundle,
- launchLibeufinServices,
- LibeufinSandboxApi,
- LibeufinNexusApi,
-} from "../harness/libeufin";
-
-/**
- * Run basic test with LibEuFin.
- */
-export async function runLibeufinRefundTest(t: GlobalTestState) {
- /**
- * User saltetd "01"
- */
- const user01nexus = new NexusUserBundle(
- "01",
- "http://localhost:5010/ebicsweb",
- );
- const user01sandbox = new SandboxUserBundle("01");
-
- /**
- * User saltetd "02"
- */
- const user02nexus = new NexusUserBundle(
- "02",
- "http://localhost:5010/ebicsweb",
- );
- const user02sandbox = new SandboxUserBundle("02");
-
- /**
- * Launch Sandbox and Nexus.
- */
- const libeufinServices = await launchLibeufinServices(
- t,
- [user01nexus, user02nexus],
- [user01sandbox, user02sandbox],
- ["twg"],
- );
-
- // user 02 pays user 01 with a faulty (non Taler) subject.
- await libeufinServices.libeufinSandbox.makeTransaction(
- user02sandbox.ebicsBankAccount.label, // debit
- user01sandbox.ebicsBankAccount.label, // credit
- "EUR:1",
- "not a public key",
- );
-
- // The bad payment should be now ingested and prepared as
- // a reimbursement.
- await LibeufinNexusApi.fetchTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- );
- // Check that the payment arrived at the Nexus.
- const nexusTxs = await LibeufinNexusApi.getAccountTransactions(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- );
- t.assertTrue(nexusTxs.data["transactions"].length == 1);
-
- // Submit the reimbursement
- await LibeufinNexusApi.submitInitiatedPayment(
- libeufinServices.libeufinNexus,
- user01nexus.localAccountName,
- // The initiated payment (= the reimbursement) ID below
- // got set by the Taler facade; at this point only one must
- // exist. If "1" is not found, a 404 will make this test fail.
- "1",
- );
-
- // user 02 checks whether the reimbursement arrived.
- let history = await LibeufinSandboxApi.getAccountTransactions(
- libeufinServices.libeufinSandbox,
- user02sandbox.ebicsBankAccount["label"],
- );
- // 2 payments must exist: 1 the original (faulty) payment +
- // 1 the reimbursement.
- t.assertTrue(history["payments"].length == 2);
-}
-runLibeufinRefundTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-sandbox-wire-transfer-cli.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-sandbox-wire-transfer-cli.ts
deleted file mode 100644
index 5560f091a..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-sandbox-wire-transfer-cli.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import {
- NexusUserBundle,
- LibeufinNexusApi,
- LibeufinNexusService,
- LibeufinSandboxService,
- LibeufinSandboxApi,
- findNexusPayment,
-} from "../harness/libeufin";
-
-export async function runLibeufinSandboxWireTransferCliTest(t: GlobalTestState) {
-
- const sandbox = await LibeufinSandboxService.create(t, {
- httpPort: 5012,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
- });
- await sandbox.start();
- await sandbox.pingUntilAvailable();
- await LibeufinSandboxApi.createBankAccount(sandbox, {
- iban: "DE71500105179674997361",
- bic: "BELADEBEXXX",
- name: "Mock Name",
- label: "mock-account",
- currency: "EUR"
- });
-
- await LibeufinSandboxApi.createBankAccount(sandbox, {
- iban: "DE71500105179674997364",
- bic: "BELADEBEXXX",
- name: "Mock Name 2",
- label: "mock-account-2",
- currency: "EUR"
- });
- await sandbox.makeTransaction(
- "mock-account", "mock-account-2", "EUR:1", "one!"
- );
- await sandbox.makeTransaction(
- "mock-account", "mock-account-2", "EUR:1", "two!"
- );
- await sandbox.makeTransaction(
- "mock-account", "mock-account-2", "EUR:1", "three!"
- );
- await sandbox.makeTransaction(
- "mock-account-2", "mock-account", "EUR:1", "Give one back."
- );
- await sandbox.makeTransaction(
- "mock-account-2", "mock-account", "EUR:0.11", "Give fraction back."
- );
- let ret = await LibeufinSandboxApi.getAccountInfoWithBalance(sandbox, "mock-account-2");
- console.log(ret.data.balance)
- t.assertTrue(ret.data.balance == "EUR:1.89")
-}
-runLibeufinSandboxWireTransferCliTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-tutorial.ts b/packages/taler-wallet-cli/src/integrationtests/test-libeufin-tutorial.ts
deleted file mode 100644
index 71a1e8c4b..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-libeufin-tutorial.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState } from "../harness/harness.js";
-import {
- LibeufinNexusService,
- LibeufinSandboxService,
- LibeufinCli,
-} from "../harness/libeufin";
-
-/**
- * Run basic test with LibEuFin.
- */
-export async function runLibeufinTutorialTest(t: GlobalTestState) {
- // Set up test environment
-
- const libeufinSandbox = await LibeufinSandboxService.create(t, {
- httpPort: 5010,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
- });
-
- await libeufinSandbox.start();
- await libeufinSandbox.pingUntilAvailable();
-
- const libeufinNexus = await LibeufinNexusService.create(t, {
- httpPort: 5011,
- databaseJdbcUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
- });
-
- const nexusUser = { username: "foo", password: "secret" };
- const libeufinCli = new LibeufinCli(t, {
- sandboxUrl: libeufinSandbox.baseUrl,
- nexusUrl: libeufinNexus.baseUrl,
- sandboxDatabaseUri: `jdbc:sqlite:${t.testDir}/libeufin-sandbox.sqlite3`,
- nexusDatabaseUri: `jdbc:sqlite:${t.testDir}/libeufin-nexus.sqlite3`,
- user: nexusUser,
- });
-
- const ebicsDetails = {
- hostId: "testhost",
- partnerId: "partner01",
- userId: "user01",
- };
- const bankAccountDetails = {
- currency: "EUR",
- iban: "DE18500105172929531888",
- bic: "INGDDEFFXXX",
- personName: "Jane Normal",
- accountName: "testacct01",
- };
-
- await libeufinCli.checkSandbox();
- await libeufinCli.createEbicsHost("testhost");
- await libeufinCli.createEbicsSubscriber(ebicsDetails);
- await libeufinCli.createEbicsBankAccount(ebicsDetails, bankAccountDetails);
- await libeufinCli.generateTransactions(bankAccountDetails.accountName);
-
- await libeufinNexus.start();
- await libeufinNexus.pingUntilAvailable();
-
- await libeufinNexus.createNexusSuperuser(nexusUser);
- const connectionDetails = {
- subscriberDetails: ebicsDetails,
- ebicsUrl: `${libeufinSandbox.baseUrl}ebicsweb`, // FIXME: need appropriate URL concatenation
- connectionName: "my-ebics-conn",
- };
- await libeufinCli.createEbicsConnection(connectionDetails);
- await libeufinCli.createBackupFile({
- passphrase: "secret",
- outputFile: `${t.testDir}/connection-backup.json`,
- connectionName: connectionDetails.connectionName,
- });
- await libeufinCli.createKeyLetter({
- outputFile: `${t.testDir}/letter.pdf`,
- connectionName: connectionDetails.connectionName,
- });
- await libeufinCli.connect(connectionDetails.connectionName);
- await libeufinCli.downloadBankAccounts(connectionDetails.connectionName);
- await libeufinCli.listOfferedBankAccounts(connectionDetails.connectionName);
-
- const bankAccountImportDetails = {
- offeredBankAccountName: bankAccountDetails.accountName,
- nexusBankAccountName: "at-nexus-testacct01",
- connectionName: connectionDetails.connectionName,
- };
-
- await libeufinCli.importBankAccount(bankAccountImportDetails);
- await libeufinSandbox.c53tick()
- await libeufinCli.fetchTransactions(bankAccountImportDetails.nexusBankAccountName);
- await libeufinCli.transactions(bankAccountImportDetails.nexusBankAccountName);
-
- const paymentDetails = {
- creditorIban: "DE42500105171245624648",
- creditorBic: "BELADEBEXXX",
- creditorName: "Mina Musterfrau",
- subject: "Purchase 01234",
- amount: "1.0",
- currency: "EUR",
- nexusBankAccountName: bankAccountImportDetails.nexusBankAccountName,
- };
- await libeufinCli.preparePayment(paymentDetails);
- await libeufinCli.submitPayment(paymentDetails, "1");
-
- await libeufinCli.newTalerWireGatewayFacade({
- accountName: bankAccountImportDetails.nexusBankAccountName,
- connectionName: "my-ebics-conn",
- currency: "EUR",
- facadeName: "my-twg",
- });
- await libeufinCli.listFacades();
-}
-runLibeufinTutorialTest.suites = ["libeufin"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-pay-abort.ts b/packages/taler-wallet-cli/src/integrationtests/test-pay-abort.ts
deleted file mode 100644
index 0fa9ec81d..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-pay-abort.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Fault injection test to check aborting partial payment
- * via refunds.
- */
-
-/**
- * Imports.
- */
-import { URL, PreparePayResultType, TalerErrorCode } from "@gnu-taler/taler-util";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import {
- FaultInjectionRequestContext,
- FaultInjectionResponseContext,
-} from "../harness/faultInjection";
-import { GlobalTestState, MerchantPrivateApi, setupDb } from "../harness/harness.js";
-import {
- createFaultInjectedMerchantTestkudosEnvironment,
- withdrawViaBank,
-} from "../harness/helpers.js";
-
-/**
- * Run test for basic, bank-integrated withdrawal.
- */
-export async function runPayAbortTest(t: GlobalTestState) {
- const {
- bank,
- faultyExchange,
- wallet,
- faultyMerchant,
- } = await createFaultInjectedMerchantTestkudosEnvironment(t);
- // Set up test environment
-
- await withdrawViaBank(t, {
- wallet,
- exchange: faultyExchange,
- amount: "TESTKUDOS:20",
- bank,
- });
-
- const orderResp = await MerchantPrivateApi.createOrder(
- faultyMerchant,
- "default",
- {
- order: {
- summary: "Buy me!",
- amount: "TESTKUDOS:15",
- fulfillment_url: "taler://fulfillment-success/thx",
- },
- },
- );
-
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
- faultyMerchant,
- {
- orderId: orderResp.order_id,
- },
- );
-
- t.assertTrue(orderStatus.order_status === "unpaid");
-
- // Make wallet pay for the order
-
- const preparePayResult = await wallet.client.call(
- WalletApiOperation.PreparePayForUri,
- {
- talerPayUri: orderStatus.taler_pay_uri,
- },
- );
-
- t.assertTrue(
- preparePayResult.status === PreparePayResultType.PaymentPossible,
- );
-
- // We let only the first deposit through!
- let firstDepositUrl: string | undefined;
-
- faultyExchange.faultProxy.addFault({
- async modifyRequest(ctx: FaultInjectionRequestContext) {
- const url = new URL(ctx.requestUrl);
- if (url.pathname.endsWith("/deposit")) {
- if (!firstDepositUrl) {
- firstDepositUrl = url.href;
- return;
- }
- if (url.href != firstDepositUrl) {
- url.pathname = "/doesntexist";
- ctx.requestUrl = url.href;
- }
- }
- },
- async modifyResponse(ctx: FaultInjectionResponseContext) {
- const url = new URL(ctx.request.requestUrl);
- if (url.pathname.endsWith("/deposit") && url.href != firstDepositUrl) {
- ctx.responseBody = Buffer.from("{}");
- ctx.statusCode = 500;
- }
- },
- });
-
- faultyMerchant.faultProxy.addFault({
- async modifyResponse(ctx: FaultInjectionResponseContext) {
- const url = new URL(ctx.request.requestUrl);
- if (url.pathname.endsWith("/pay") && url.href != firstDepositUrl) {
- ctx.responseBody = Buffer.from("{}");
- ctx.statusCode = 400;
- }
- },
- });
-
- await t.assertThrowsOperationErrorAsync(async () => {
- await wallet.client.call(WalletApiOperation.ConfirmPay, {
- proposalId: preparePayResult.proposalId,
- });
- });
-
- let txr = await wallet.client.call(WalletApiOperation.GetTransactions, {});
- console.log(JSON.stringify(txr, undefined, 2));
-
- t.assertDeepEqual(txr.transactions[1].type, "payment");
- t.assertDeepEqual(txr.transactions[1].pending, true);
- t.assertDeepEqual(
- txr.transactions[1].error?.code,
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- );
-
- await wallet.client.call(WalletApiOperation.AbortFailedPayWithRefund, {
- proposalId: preparePayResult.proposalId,
- });
-
- await wallet.runUntilDone();
-
- txr = await wallet.client.call(WalletApiOperation.GetTransactions, {});
- console.log(JSON.stringify(txr, undefined, 2));
-
- const txTypes = txr.transactions.map((x) => x.type);
-
- t.assertDeepEqual(txTypes, ["withdrawal", "payment", "refund"]);
-}
-
-runPayAbortTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-on-demo.ts b/packages/taler-wallet-cli/src/integrationtests/test-payment-on-demo.ts
deleted file mode 100644
index 1d419fd9a..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-payment-on-demo.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- GlobalTestState,
- BankApi,
- WalletCli,
- BankAccessApi
-} from "../harness/harness.js";
-import {
- makeTestPayment,
-} from "../harness/helpers.js";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-
-/**
- * Run test for basic, bank-integrated withdrawal and payment.
- */
-export async function runPaymentDemoTest(t: GlobalTestState) {
-
- // Withdraw digital cash into the wallet.
- let bankInterface = {
- baseUrl: "https://bank.demo.taler.net/",
- port: 0 // unused.
- };
- let user = await BankApi.createRandomBankUser(bankInterface);
- let wop = await BankAccessApi.createWithdrawalOperation(bankInterface, user, "KUDOS:20");
-
- let wallet = new WalletCli(t);
- await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
- talerWithdrawUri: wop.taler_withdraw_uri,
- });
-
- await wallet.runPending();
-
- // Confirm it
-
- await BankApi.confirmWithdrawalOperation(bankInterface, user, wop);
-
- // Withdraw
-
- await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, {
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- talerWithdrawUri: wop.taler_withdraw_uri,
- });
- await wallet.runUntilDone();
-
- let balanceBefore = await wallet.client.call(WalletApiOperation.GetBalances, {});
- t.assertTrue(balanceBefore["balances"].length == 1);
-
- const order = {
- summary: "Buy me!",
- amount: "KUDOS:5",
- fulfillment_url: "taler://fulfillment-success/thx",
- };
-
- let merchant = {
- makeInstanceBaseUrl: function(instanceName?: string) {
- return "https://backend.demo.taler.net/instances/donations/";
- },
- port: 0,
- name: "donations",
- };
-
- t.assertTrue("TALER_ENV_FRONTENDS_APITOKEN" in process.env);
-
- await makeTestPayment(
- t,
- {
- merchant, wallet, order
- },
- {
- "Authorization": `Bearer ${process.env["TALER_ENV_FRONTENDS_APITOKEN"]}`,
- });
-
- await wallet.runUntilDone();
-
- let balanceAfter = await wallet.client.call(WalletApiOperation.GetBalances, {});
- t.assertTrue(balanceAfter["balances"].length == 1);
- t.assertTrue(balanceBefore["balances"][0]["available"] > balanceAfter["balances"][0]["available"]);
-}
-
-runPaymentDemoTest.excludeByDefault = true;
-runPaymentDemoTest.suites = ["buildbot"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-refund-gone.ts b/packages/taler-wallet-cli/src/integrationtests/test-refund-gone.ts
deleted file mode 100644
index acb74b3d3..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-refund-gone.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
-import {
- createSimpleTestkudosEnvironment,
- withdrawViaBank,
- applyTimeTravel,
-} from "../harness/helpers.js";
-import {
- durationFromSpec,
- timestampAddDuration,
- getTimestampNow,
- timestampTruncateToSecond,
-} from "@gnu-taler/taler-util";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-
-/**
- * Run test for basic, bank-integrated withdrawal.
- */
-export async function runRefundGoneTest(t: GlobalTestState) {
- // Set up test environment
-
- const {
- wallet,
- bank,
- exchange,
- merchant,
- } = await createSimpleTestkudosEnvironment(t);
-
- // Withdraw digital cash into the wallet.
-
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
-
- // Set up order.
-
- const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
- order: {
- summary: "Buy me!",
- amount: "TESTKUDOS:5",
- fulfillment_url: "taler://fulfillment-success/thx",
- pay_deadline: timestampTruncateToSecond(
- timestampAddDuration(
- getTimestampNow(),
- durationFromSpec({
- minutes: 10,
- }),
- ),
- ),
- },
- refund_delay: durationFromSpec({ minutes: 1 }),
- });
-
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
- orderId: orderResp.order_id,
- });
-
- t.assertTrue(orderStatus.order_status === "unpaid");
-
- // Make wallet pay for the order
-
- const r1 = await wallet.client.call(WalletApiOperation.PreparePayForUri, {
- talerPayUri: orderStatus.taler_pay_uri,
- });
-
- const r2 = await wallet.client.call(WalletApiOperation.ConfirmPay, {
- proposalId: r1.proposalId,
- });
-
- // Check if payment was successful.
-
- orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
- orderId: orderResp.order_id,
- });
-
- t.assertTrue(orderStatus.order_status === "paid");
-
- console.log(orderStatus);
-
- await applyTimeTravel(durationFromSpec({ hours: 1 }), { exchange, wallet });
-
- await exchange.runAggregatorOnce();
-
- const ref = await MerchantPrivateApi.giveRefund(merchant, {
- amount: "TESTKUDOS:5",
- instance: "default",
- justification: "foo",
- orderId: orderResp.order_id,
- });
-
- console.log(ref);
-
- let rr = await wallet.client.call(WalletApiOperation.ApplyRefund, {
- talerRefundUri: ref.talerRefundUri,
- });
-
- t.assertAmountEquals(rr.amountRefundGone, "TESTKUDOS:5");
- console.log(rr);
-
- await wallet.runUntilDone();
-
- let r = await wallet.client.call(WalletApiOperation.GetBalances, {});
- console.log(JSON.stringify(r, undefined, 2));
-
- const r3 = await wallet.client.call(WalletApiOperation.GetTransactions, {});
- console.log(JSON.stringify(r3, undefined, 2));
-
- await t.shutdown();
-}
-
-runRefundGoneTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-tipping.ts b/packages/taler-wallet-cli/src/integrationtests/test-tipping.ts
deleted file mode 100644
index c6a7f8402..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-tipping.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { GlobalTestState, MerchantPrivateApi, BankApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
-
-/**
- * Run test for basic, bank-integrated withdrawal.
- */
-export async function runTippingTest(t: GlobalTestState) {
- // Set up test environment
-
- const {
- wallet,
- bank,
- exchange,
- merchant,
- exchangeBankAccount,
- } = await createSimpleTestkudosEnvironment(t);
-
- const mbu = await BankApi.createRandomBankUser(bank);
-
- const tipReserveResp = await MerchantPrivateApi.createTippingReserve(
- merchant,
- "default",
- {
- exchange_url: exchange.baseUrl,
- initial_balance: "TESTKUDOS:10",
- wire_method: "x-taler-bank",
- },
- );
-
- console.log("tipReserveResp:", tipReserveResp);
-
- t.assertDeepEqual(
- tipReserveResp.payto_uri,
- exchangeBankAccount.accountPaytoUri,
- );
-
- await BankApi.adminAddIncoming(bank, {
- amount: "TESTKUDOS:10",
- debitAccountPayto: mbu.accountPaytoUri,
- exchangeBankAccount,
- reservePub: tipReserveResp.reserve_pub,
- });
-
- await exchange.runWirewatchOnce();
-
- await merchant.stop();
- await merchant.start();
- await merchant.pingUntilAvailable();
-
- const r = await MerchantPrivateApi.queryTippingReserves(merchant, "default");
- console.log("tipping reserves:", JSON.stringify(r, undefined, 2));
-
- t.assertTrue(r.reserves.length === 1);
- t.assertDeepEqual(
- r.reserves[0].exchange_initial_amount,
- r.reserves[0].merchant_initial_amount,
- );
-
- const tip = await MerchantPrivateApi.giveTip(merchant, "default", {
- amount: "TESTKUDOS:5",
- justification: "why not?",
- next_url: "https://example.com/after-tip",
- });
-
- console.log("created tip", tip);
-
- const doTip = async (): Promise<void> => {
- const ptr = await wallet.client.call(WalletApiOperation.PrepareTip, {
- talerTipUri: tip.taler_tip_uri,
- });
-
- console.log(ptr);
-
- t.assertAmountEquals(ptr.tipAmountRaw, "TESTKUDOS:5");
- t.assertAmountEquals(ptr.tipAmountEffective, "TESTKUDOS:4.85");
-
- await wallet.client.call(WalletApiOperation.AcceptTip, {
- walletTipId: ptr.walletTipId,
- });
-
- await wallet.runUntilDone();
-
- const bal = await wallet.client.call(WalletApiOperation.GetBalances, {});
-
- console.log(bal);
-
- t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:4.85");
-
- const txns = await wallet.client.call(
- WalletApiOperation.GetTransactions,
- {},
- );
-
- console.log("Transactions:", JSON.stringify(txns, undefined, 2));
-
- t.assertDeepEqual(txns.transactions[0].type, "tip");
- t.assertDeepEqual(txns.transactions[0].pending, false);
- t.assertAmountEquals(
- txns.transactions[0].amountEffective,
- "TESTKUDOS:4.85",
- );
- t.assertAmountEquals(txns.transactions[0].amountRaw, "TESTKUDOS:5.0");
- };
-
- // Check twice so make sure tip handling is idempotent
- await doTip();
- await doTip();
-}
-
-runTippingTest.suites = ["wallet", "wallet-tipping"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts b/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts
deleted file mode 100644
index 23e01e5e1..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-basic.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { GlobalTestState, WalletCli } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
-import { SyncService } from "../harness/sync";
-
-/**
- * Run test for basic, bank-integrated withdrawal.
- */
-export async function runWalletBackupBasicTest(t: GlobalTestState) {
- // Set up test environment
-
- const {
- commonDb,
- merchant,
- wallet,
- bank,
- exchange,
- } = await createSimpleTestkudosEnvironment(t);
-
- const sync = await SyncService.create(t, {
- currency: "TESTKUDOS",
- annualFee: "TESTKUDOS:0.5",
- database: commonDb.connStr,
- fulfillmentUrl: "taler://fulfillment-success",
- httpPort: 8089,
- name: "sync1",
- paymentBackendUrl: merchant.makeInstanceBaseUrl(),
- uploadLimitMb: 10,
- });
-
- await sync.start();
- await sync.pingUntilAvailable();
-
- await wallet.client.call(WalletApiOperation.AddBackupProvider, {
- backupProviderBaseUrl: sync.baseUrl,
- activate: false,
- name: sync.baseUrl,
- });
-
- {
- const bi = await wallet.client.call(WalletApiOperation.GetBackupInfo, {});
- t.assertDeepEqual(bi.providers[0].active, false);
- }
-
- await wallet.client.call(WalletApiOperation.AddBackupProvider, {
- backupProviderBaseUrl: sync.baseUrl,
- activate: true,
- name: sync.baseUrl,
- });
-
- {
- const bi = await wallet.client.call(WalletApiOperation.GetBackupInfo, {});
- t.assertDeepEqual(bi.providers[0].active, true);
- }
-
- await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
-
- {
- const bi = await wallet.client.call(WalletApiOperation.GetBackupInfo, {});
- console.log(bi);
- t.assertDeepEqual(
- bi.providers[0].paymentStatus.type,
- "insufficient-balance",
- );
- }
-
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:10" });
-
- await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
-
- {
- const bi = await wallet.client.call(WalletApiOperation.GetBackupInfo, {});
- console.log(bi);
- }
-
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:5" });
-
- await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
-
- {
- const bi = await wallet.client.call(WalletApiOperation.GetBackupInfo, {});
- console.log(bi);
- }
-
- const backupRecovery = await wallet.client.call(
- WalletApiOperation.ExportBackupRecovery,
- {},
- );
-
- const wallet2 = new WalletCli(t, "wallet2");
-
- // Check that the second wallet is a fresh wallet.
- {
- const bal = await wallet2.client.call(WalletApiOperation.GetBalances, {});
- t.assertTrue(bal.balances.length === 0);
- }
-
- await wallet2.client.call(WalletApiOperation.ImportBackupRecovery, {
- recovery: backupRecovery,
- });
-
- await wallet2.client.call(WalletApiOperation.RunBackupCycle, {});
-
- // Check that now the old balance is available!
- {
- const bal = await wallet2.client.call(WalletApiOperation.GetBalances, {});
- t.assertTrue(bal.balances.length === 1);
- console.log(bal);
- }
-
- // Now do some basic checks that the restored wallet is still functional
- {
- const bal1 = await wallet2.client.call(WalletApiOperation.GetBalances, {});
-
- t.assertAmountEquals(bal1.balances[0].available, "TESTKUDOS:14.1");
-
- await withdrawViaBank(t, {
- wallet: wallet2,
- bank,
- exchange,
- amount: "TESTKUDOS:10",
- });
-
- await wallet2.runUntilDone();
-
- const bal2 = await wallet2.client.call(WalletApiOperation.GetBalances, {});
-
- t.assertAmountEquals(bal2.balances[0].available, "TESTKUDOS:23.82");
- }
-}
-
-runWalletBackupBasicTest.suites = ["wallet", "wallet-backup"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-doublespend.ts b/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-doublespend.ts
deleted file mode 100644
index 8c20dcc2b..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-wallet-backup-doublespend.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { PreparePayResultType } from "@gnu-taler/taler-util";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { GlobalTestState, WalletCli, MerchantPrivateApi } from "../harness/harness.js";
-import {
- createSimpleTestkudosEnvironment,
- makeTestPayment,
- withdrawViaBank,
-} from "../harness/helpers.js";
-import { SyncService } from "../harness/sync";
-
-/**
- * Run test for basic, bank-integrated withdrawal.
- */
-export async function runWalletBackupDoublespendTest(t: GlobalTestState) {
- // Set up test environment
-
- const {
- commonDb,
- merchant,
- wallet,
- bank,
- exchange,
- } = await createSimpleTestkudosEnvironment(t);
-
- const sync = await SyncService.create(t, {
- currency: "TESTKUDOS",
- annualFee: "TESTKUDOS:0.5",
- database: commonDb.connStr,
- fulfillmentUrl: "taler://fulfillment-success",
- httpPort: 8089,
- name: "sync1",
- paymentBackendUrl: merchant.makeInstanceBaseUrl(),
- uploadLimitMb: 10,
- });
-
- await sync.start();
- await sync.pingUntilAvailable();
-
- await wallet.client.call(WalletApiOperation.AddBackupProvider, {
- backupProviderBaseUrl: sync.baseUrl,
- activate: true,
- name: sync.baseUrl,
- });
-
- await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:10" });
-
- await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
- await wallet.runUntilDone();
- await wallet.client.call(WalletApiOperation.RunBackupCycle, {});
-
- const backupRecovery = await wallet.client.call(
- WalletApiOperation.ExportBackupRecovery,
- {},
- );
-
- const wallet2 = new WalletCli(t, "wallet2");
-
- await wallet2.client.call(WalletApiOperation.ImportBackupRecovery, {
- recovery: backupRecovery,
- });
-
- await wallet2.client.call(WalletApiOperation.RunBackupCycle, {});
-
- console.log(
- "wallet1 balance before spend:",
- await wallet.client.call(WalletApiOperation.GetBalances, {}),
- );
-
- await makeTestPayment(t, {
- merchant,
- wallet,
- order: {
- summary: "foo",
- amount: "TESTKUDOS:7",
- },
- });
-
- await wallet.runUntilDone();
-
- console.log(
- "wallet1 balance after spend:",
- await wallet.client.call(WalletApiOperation.GetBalances, {}),
- );
-
- {
- console.log(
- "wallet2 balance:",
- await wallet2.client.call(WalletApiOperation.GetBalances, {}),
- );
- }
-
- // Now we double-spend with the second wallet
-
- {
- const instance = "default";
-
- const orderResp = await MerchantPrivateApi.createOrder(merchant, instance, {
- order: {
- amount: "TESTKUDOS:8",
- summary: "bla",
- fulfillment_url: "taler://fulfillment-success",
- },
- });
-
- let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(
- merchant,
- {
- orderId: orderResp.order_id,
- },
- );
-
- t.assertTrue(orderStatus.order_status === "unpaid");
-
- // Make wallet pay for the order
-
- const preparePayResult = await wallet2.client.call(
- WalletApiOperation.PreparePayForUri,
- {
- talerPayUri: orderStatus.taler_pay_uri,
- },
- );
-
- t.assertTrue(
- preparePayResult.status === PreparePayResultType.PaymentPossible,
- );
-
- const res = await wallet2.client.call(WalletApiOperation.ConfirmPay, {
- proposalId: preparePayResult.proposalId,
- });
-
- console.log(res);
-
- // FIXME: wait for a notification that indicates insufficient funds!
-
- await withdrawViaBank(t, {
- wallet: wallet2,
- bank,
- exchange,
- amount: "TESTKUDOS:50",
- });
-
- const bal = await wallet2.client.call(WalletApiOperation.GetBalances, {});
- console.log("bal", bal);
-
- await wallet2.runUntilDone();
- }
-}
-
-runWalletBackupDoublespendTest.suites = ["wallet", "wallet-backup"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts
deleted file mode 100644
index 35969c78f..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState, BankApi, BankAccessApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
-import { codecForBalancesResponse } from "@gnu-taler/taler-util";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-
-/**
- * Run test for basic, bank-integrated withdrawal.
- */
-export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) {
- // Set up test environment
-
- const { wallet, bank, exchange } = await createSimpleTestkudosEnvironment(t);
-
- // Create a withdrawal operation
-
- const user = await BankApi.createRandomBankUser(bank);
- const wop = await BankAccessApi.createWithdrawalOperation(
- bank,
- user,
- "TESTKUDOS:10",
- );
-
- // Hand it to the wallet
-
- const r1 = await wallet.client.call(WalletApiOperation.GetWithdrawalDetailsForUri, {
- talerWithdrawUri: wop.taler_withdraw_uri,
- });
-
- await wallet.runPending();
-
- // Confirm it
-
- await BankApi.confirmWithdrawalOperation(bank, user, wop);
-
- // Withdraw
-
- const r2 = await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, {
- exchangeBaseUrl: exchange.baseUrl,
- talerWithdrawUri: wop.taler_withdraw_uri,
- });
- await wallet.runUntilDone();
-
- // Check balance
-
- const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
- t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
-
- await t.shutdown();
-}
-
-runWithdrawalBankIntegratedTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts
deleted file mode 100644
index b93d1b500..000000000
--- a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { GlobalTestState, BankApi } from "../harness/harness.js";
-import { createSimpleTestkudosEnvironment } from "../harness/helpers.js";
-import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-
-/**
- * Run test for basic, bank-integrated withdrawal.
- */
-export async function runTestWithdrawalManualTest(t: GlobalTestState) {
- // Set up test environment
-
- const {
- wallet,
- bank,
- exchange,
- exchangeBankAccount,
- } = await createSimpleTestkudosEnvironment(t);
-
- // Create a withdrawal operation
-
- const user = await BankApi.createRandomBankUser(bank);
-
- await wallet.client.call(WalletApiOperation.AddExchange, {
- exchangeBaseUrl: exchange.baseUrl,
- });
-
-
- const wres = await wallet.client.call(WalletApiOperation.AcceptManualWithdrawal, {
- exchangeBaseUrl: exchange.baseUrl,
- amount: "TESTKUDOS:10",
- });
-
- const reservePub: string = wres.reservePub;
-
- await BankApi.adminAddIncoming(bank, {
- exchangeBankAccount,
- amount: "TESTKUDOS:10",
- debitAccountPayto: user.accountPaytoUri,
- reservePub: reservePub,
- });
-
- await exchange.runWirewatchOnce();
-
- await wallet.runUntilDone();
-
- // Check balance
-
- const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {});
- t.assertAmountEquals("TESTKUDOS:9.72", balResp.balances[0].available);
-
- await t.shutdown();
-}
-
-runTestWithdrawalManualTest.suites = ["wallet"];
diff --git a/packages/taler-wallet-cli/tsconfig.json b/packages/taler-wallet-cli/tsconfig.json
index 945161176..4b46790c8 100644
--- a/packages/taler-wallet-cli/tsconfig.json
+++ b/packages/taler-wallet-cli/tsconfig.json
@@ -2,11 +2,11 @@
"compileOnSave": true,
"compilerOptions": {
"composite": true,
- "target": "ES2018",
- "module": "ESNext",
- "moduleResolution": "node",
+ "target": "ES2020",
+ "module": "Node16",
+ "moduleResolution": "Node16",
"sourceMap": true,
- "lib": ["es6"],
+ "lib": ["ES2020"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
@@ -21,7 +21,7 @@
"baseUrl": "./src",
"typeRoots": ["./node_modules/@types"]
},
- "include": ["src/**/*"],
+ "include": ["src/**/*", "../taler-util/src/twrpc.ts"],
"references": [
{
"path": "../taler-wallet-core/"
diff --git a/packages/taler-wallet-core/.gitignore b/packages/taler-wallet-core/.gitignore
index 502167fa0..13d7285e1 100644
--- a/packages/taler-wallet-core/.gitignore
+++ b/packages/taler-wallet-core/.gitignore
@@ -1 +1,3 @@
/lib
+/coverage
+/src/version.json
diff --git a/packages/taler-wallet-core/README.md b/packages/taler-wallet-core/README.md
new file mode 100644
index 000000000..73da40caf
--- /dev/null
+++ b/packages/taler-wallet-core/README.md
@@ -0,0 +1,4 @@
+# taler-wallet-core
+
+This package implements `taler-wallet-core`, the main logic and storage
+for the GNU Taler wallet.
diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json
index 0d726a6d7..3b5cb6c91 100644
--- a/packages/taler-wallet-core/package.json
+++ b/packages/taler-wallet-core/package.json
@@ -1,9 +1,9 @@
{
"name": "@gnu-taler/taler-wallet-core",
- "version": "0.8.1",
+ "version": "0.10.6",
"description": "",
"engines": {
- "node": ">=0.12.0"
+ "node": ">=0.18.0"
},
"repository": {
"type": "git",
@@ -12,12 +12,13 @@
"author": "Florian Dold",
"license": "GPL-3.0",
"scripts": {
- "prepare": "tsc && rollup -c",
- "compile": "tsc && rollup -c",
+ "compile": "tsc",
"pretty": "prettier --write src",
"test": "tsc && ava",
- "coverage": "tsc && nyc ava",
- "clean": "rimraf dist lib tsconfig.tsbuildinfo"
+ "typedoc": "typedoc --out dist/typedoc ./src/",
+ "coverage": "tsc && c8 --src src --all ava",
+ "coverage:html": "tsc && c8 -r html --src src --all ava",
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo"
},
"files": [
"AUTHORS",
@@ -28,47 +29,55 @@
"src/",
"lib/"
],
- "main": "./dist/taler-wallet-core.js",
- "browser": {
- "./dist/taler-wallet-core.js": "./dist/taler-wallet-core.browser.js",
- "./lib/index.node.js": "./lib/index.browser.js"
- },
- "module": "./lib/index.node.js",
"type": "module",
"types": "./lib/index.node.d.ts",
+ "exports": {
+ ".": {
+ "browser": "./lib/index.browser.js",
+ "node": "./lib/index.node.js",
+ "default": "./lib/index.js"
+ },
+ "./remote": {
+ "default": "./lib/remote.js"
+ },
+ "./dbless": {
+ "default": "./lib/dbless.js"
+ }
+ },
+ "imports": {
+ "#host-impl": {
+ "types": "./lib/host-impl.node.js",
+ "node": "./lib/host-impl.node.js",
+ "qtart": "./lib/host-impl.qtart.js",
+ "default": "./lib/host-impl.missing.js"
+ }
+ },
"devDependencies": {
- "@ava/typescript": "^1.1.1",
+ "@ava/typescript": "^4.1.0",
"@gnu-taler/pogen": "workspace:*",
- "@microsoft/api-extractor": "^7.13.0",
- "@typescript-eslint/eslint-plugin": "^4.14.0",
- "@typescript-eslint/parser": "^4.14.0",
- "ava": "^3.15.0",
- "eslint": "^7.18.0",
- "eslint-config-airbnb-typescript": "^12.0.0",
- "eslint-plugin-import": "^2.22.1",
- "eslint-plugin-jsx-a11y": "^6.4.1",
- "eslint-plugin-react": "^7.22.0",
- "eslint-plugin-react-hooks": "^4.2.0",
+ "@typescript-eslint/eslint-plugin": "^5.36.1",
+ "@typescript-eslint/parser": "^5.36.1",
+ "ava": "^6.0.1",
+ "c8": "^8.0.1",
+ "eslint": "^8.8.0",
+ "eslint-config-airbnb-typescript": "^17.1.0",
+ "eslint-plugin-import": "^2.29.1",
+ "eslint-plugin-jsx-a11y": "^6.8.0",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.3.0",
"jed": "^1.1.1",
- "nyc": "^15.1.0",
"po2json": "^0.4.5",
- "prettier": "^2.2.1",
- "rimraf": "^3.0.2",
- "rollup": "^2.37.1",
- "rollup-plugin-sourcemaps": "^0.6.3",
- "source-map-resolve": "^0.6.0",
- "typedoc": "^0.20.16",
- "typescript": "^4.1.3"
+ "prettier": "^3.1.1",
+ "typedoc": "^0.25.4",
+ "typescript": "^5.3.3"
},
"dependencies": {
"@gnu-taler/idb-bridge": "workspace:*",
"@gnu-taler/taler-util": "workspace:*",
- "@types/node": "^14.14.22",
- "axios": "^0.21.1",
- "big-integer": "^1.6.48",
- "fflate": "^0.6.0",
- "source-map-support": "^0.5.19",
- "tslib": "^2.1.0"
+ "@types/node": "^18.11.17",
+ "big-integer": "^1.6.52",
+ "fflate": "^0.8.1",
+ "tslib": "^2.6.2"
},
"ava": {
"files": [
diff --git a/packages/taler-wallet-core/rollup.config.js b/packages/taler-wallet-core/rollup.config.js
deleted file mode 100644
index 927cb8a2e..000000000
--- a/packages/taler-wallet-core/rollup.config.js
+++ /dev/null
@@ -1,66 +0,0 @@
-// rollup.config.js
-import commonjs from "@rollup/plugin-commonjs";
-import nodeResolve from "@rollup/plugin-node-resolve";
-import json from "@rollup/plugin-json";
-import builtins from "builtin-modules";
-import pkg from "./package.json";
-import sourcemaps from 'rollup-plugin-sourcemaps';
-
-const nodeEntryPoint = {
- input: "lib/index.node.js",
- output: {
- file: pkg.main,
- format: "cjs",
- sourcemap: true,
- },
- external: builtins,
- plugins: [
- nodeResolve({
- preferBuiltins: true,
- }),
-
- sourcemaps(),
-
- commonjs({
- include: [/node_modules/, /dist/],
- extensions: [".js"],
- ignoreGlobal: false,
- sourceMap: true,
- }),
-
- json(),
- ],
-}
-
-const browserEntryPoint = {
- input: "lib/index.browser.js",
- output: {
- file: pkg.browser[pkg.main],
- format: "cjs",
- sourcemap: true,
- },
- external: builtins,
- plugins: [
- nodeResolve({
- browser: true,
- preferBuiltins: true,
- }),
-
- sourcemaps(),
-
- commonjs({
- include: [/node_modules/, /dist/],
- extensions: [".js"],
- ignoreGlobal: false,
- sourceMap: true,
- }),
-
- json(),
- ],
-}
-
-export default [
- nodeEntryPoint,
- browserEntryPoint
-]
-
diff --git a/packages/taler-wallet-core/src/attention.ts b/packages/taler-wallet-core/src/attention.ts
new file mode 100644
index 000000000..60d2117f1
--- /dev/null
+++ b/packages/taler-wallet-core/src/attention.ts
@@ -0,0 +1,133 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AttentionInfo,
+ Logger,
+ TalerPreciseTimestamp,
+ UserAttentionByIdRequest,
+ UserAttentionPriority,
+ UserAttentionUnreadList,
+ UserAttentionsCountResponse,
+ UserAttentionsRequest,
+ UserAttentionsResponse,
+} from "@gnu-taler/taler-util";
+import { timestampPreciseFromDb, timestampPreciseToDb } from "./db.js";
+import { InternalWalletState, WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("operations/attention.ts");
+
+export async function getUserAttentionsUnreadCount(
+ wex: WalletExecutionContext,
+ req: UserAttentionsRequest,
+): Promise<UserAttentionsCountResponse> {
+ const total = await wex.db.runReadOnlyTx(["userAttention"], async (tx) => {
+ let count = 0;
+ await tx.userAttention.iter().forEach((x) => {
+ if (
+ req.priority !== undefined &&
+ UserAttentionPriority[x.info.type] !== req.priority
+ )
+ return;
+ if (x.read !== undefined) return;
+ count++;
+ });
+
+ return count;
+ });
+
+ return { total };
+}
+
+export async function getUserAttentions(
+ wex: WalletExecutionContext,
+ req: UserAttentionsRequest,
+): Promise<UserAttentionsResponse> {
+ return await wex.db.runReadOnlyTx(["userAttention"], async (tx) => {
+ const pending: UserAttentionUnreadList = [];
+ await tx.userAttention.iter().forEach((x) => {
+ if (
+ req.priority !== undefined &&
+ UserAttentionPriority[x.info.type] !== req.priority
+ )
+ return;
+ pending.push({
+ info: x.info,
+ when: timestampPreciseFromDb(x.created),
+ read: x.read !== undefined,
+ });
+ });
+
+ return { pending };
+ });
+}
+
+export async function markAttentionRequestAsRead(
+ wex: WalletExecutionContext,
+ req: UserAttentionByIdRequest,
+): Promise<void> {
+ await wex.db.runReadWriteTx(["userAttention"], async (tx) => {
+ const ua = await tx.userAttention.get([req.entityId, req.type]);
+ if (!ua) throw Error("attention request not found");
+ tx.userAttention.put({
+ ...ua,
+ read: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ });
+}
+
+/**
+ * the wallet need the user attention to complete a task
+ * internal API
+ *
+ * @param wex
+ * @param info
+ */
+export async function addAttentionRequest(
+ wex: WalletExecutionContext,
+ info: AttentionInfo,
+ entityId: string,
+): Promise<void> {
+ await wex.db.runReadWriteTx(["userAttention"], async (tx) => {
+ await tx.userAttention.put({
+ info,
+ entityId,
+ created: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ read: undefined,
+ });
+ });
+}
+
+/**
+ * user completed the task, attention request is not needed
+ * internal API
+ *
+ * @param wex
+ * @param created
+ */
+export async function removeAttentionRequest(
+ wex: WalletExecutionContext,
+ req: UserAttentionByIdRequest,
+): Promise<void> {
+ await wex.db.runReadWriteTx(["userAttention"], async (tx) => {
+ const ua = await tx.userAttention.get([req.entityId, req.type]);
+ if (!ua) throw Error("attention request not found");
+ await tx.userAttention.delete([req.entityId, req.type]);
+ });
+}
diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts
new file mode 100644
index 000000000..c32ed8b8c
--- /dev/null
+++ b/packages/taler-wallet-core/src/backup/index.ts
@@ -0,0 +1,956 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of wallet backups (export/import/upload) and sync
+ * server management.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AttentionType,
+ BackupRecovery,
+ Codec,
+ Duration,
+ EddsaKeyPair,
+ HttpStatusCode,
+ Logger,
+ PreparePayResult,
+ ProviderInfo,
+ ProviderPaymentStatus,
+ RecoveryLoadRequest,
+ RecoveryMergeStrategy,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ URL,
+ buildCodecForObject,
+ buildCodecForUnion,
+ bytesToString,
+ canonicalJson,
+ canonicalizeBaseUrl,
+ checkDbInvariant,
+ checkLogicInvariant,
+ codecForBoolean,
+ codecForConstString,
+ codecForList,
+ codecForString,
+ codecForSyncTermsOfServiceResponse,
+ codecOptional,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ hash,
+ j2s,
+ kdf,
+ notEmpty,
+ secretbox,
+ secretbox_open,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import {
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { gunzipSync, gzipSync } from "fflate";
+import { addAttentionRequest, removeAttentionRequest } from "../attention.js";
+import {
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+} from "../common.js";
+import {
+ BackupProviderRecord,
+ BackupProviderState,
+ BackupProviderStateTag,
+ ConfigRecord,
+ ConfigRecordKey,
+ WalletBackupConfState,
+ WalletDbReadOnlyTransaction,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseToDb,
+} from "../db.js";
+import { preparePayForUri } from "../pay-merchant.js";
+import { InternalWalletState, WalletExecutionContext } from "../wallet.js";
+
+const logger = new Logger("operations/backup.ts");
+
+function concatArrays(xs: Uint8Array[]): Uint8Array {
+ let len = 0;
+ for (const x of xs) {
+ len += x.byteLength;
+ }
+ const out = new Uint8Array(len);
+ let offset = 0;
+ for (const x of xs) {
+ out.set(x, offset);
+ offset += x.length;
+ }
+ return out;
+}
+
+const magic = "TLRWBK01";
+
+/**
+ * Encrypt the backup.
+ *
+ * Blob format:
+ * Magic "TLRWBK01" (8 bytes)
+ * Nonce (24 bytes)
+ * Compressed JSON blob (rest)
+ */
+export async function encryptBackup(
+ config: WalletBackupConfState,
+ blob: any,
+): Promise<Uint8Array> {
+ const chunks: Uint8Array[] = [];
+ chunks.push(stringToBytes(magic));
+ const nonceStr = config.lastBackupNonce;
+ checkLogicInvariant(!!nonceStr);
+ const nonce = decodeCrock(nonceStr).slice(0, 24);
+ chunks.push(nonce);
+ const backupJsonContent = canonicalJson(blob);
+ logger.trace("backup JSON size", backupJsonContent.length);
+ const compressedContent = gzipSync(stringToBytes(backupJsonContent), {
+ mtime: 0,
+ });
+ const secret = deriveBlobSecret(config);
+ const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
+ chunks.push(encrypted);
+ return concatArrays(chunks);
+}
+
+function deriveAccountKeyPair(
+ bc: WalletBackupConfState,
+ providerUrl: string,
+): EddsaKeyPair {
+ const privateKey = kdf(
+ 32,
+ decodeCrock(bc.walletRootPriv),
+ stringToBytes("taler-sync-account-key-salt"),
+ stringToBytes(providerUrl),
+ );
+ return {
+ eddsaPriv: privateKey,
+ eddsaPub: eddsaGetPublic(privateKey),
+ };
+}
+
+function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
+ return kdf(
+ 32,
+ decodeCrock(bc.walletRootPriv),
+ stringToBytes("taler-sync-blob-secret-salt"),
+ stringToBytes("taler-sync-blob-secret-info"),
+ );
+}
+
+interface BackupForProviderArgs {
+ backupProviderBaseUrl: string;
+}
+
+function getNextBackupTimestamp(): TalerPreciseTimestamp {
+ // FIXME: Randomize!
+ return AbsoluteTime.toPreciseTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ minutes: 5 }),
+ ),
+ );
+}
+
+async function runBackupCycleForProvider(
+ wex: WalletExecutionContext,
+ args: BackupForProviderArgs,
+): Promise<TaskRunResult> {
+ const provider = await wex.db.runReadOnlyTx(
+ ["backupProviders"],
+ async (tx) => {
+ return tx.backupProviders.get(args.backupProviderBaseUrl);
+ },
+ );
+
+ if (!provider) {
+ logger.warn("provider disappeared");
+ return TaskRunResult.finished();
+ }
+
+ //const backupJson = await exportBackup(ws);
+ // FIXME: re-implement backup
+ const backupJson = {};
+ const backupConfig = await provideBackupState(wex);
+ const encBackup = await encryptBackup(backupConfig, backupJson);
+ const currentBackupHash = hash(encBackup);
+
+ const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
+
+ const newHash = encodeCrock(currentBackupHash);
+ const oldHash = provider.lastBackupHash;
+
+ logger.trace(`trying to upload backup to ${provider.baseUrl}`);
+ logger.trace(`old hash ${oldHash}, new hash ${newHash}`);
+
+ const syncSigResp = await wex.cryptoApi.makeSyncSignature({
+ newHash: encodeCrock(currentBackupHash),
+ oldHash: provider.lastBackupHash,
+ accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
+ });
+
+ logger.trace(`sync signature is ${syncSigResp}`);
+
+ const accountBackupUrl = new URL(
+ `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
+ provider.baseUrl,
+ );
+
+ if (provider.shouldRetryFreshProposal) {
+ accountBackupUrl.searchParams.set("fresh", "yes");
+ }
+
+ const resp = await wex.http.fetch(accountBackupUrl.href, {
+ method: "POST",
+ body: encBackup,
+ headers: {
+ "content-type": "application/octet-stream",
+ "sync-signature": syncSigResp.sig,
+ "if-none-match": newHash,
+ ...(provider.lastBackupHash
+ ? {
+ "if-match": provider.lastBackupHash,
+ }
+ : {}),
+ },
+ });
+
+ logger.trace(`sync response status: ${resp.status}`);
+
+ if (resp.status === HttpStatusCode.NotModified) {
+ await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ return;
+ }
+ prov.lastBackupCycleTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ prov.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
+ };
+ await tx.backupProviders.put(prov);
+ });
+
+ removeAttentionRequest(wex, {
+ entityId: provider.baseUrl,
+ type: AttentionType.BackupUnpaid,
+ });
+
+ return TaskRunResult.finished();
+ }
+
+ if (resp.status === HttpStatusCode.PaymentRequired) {
+ logger.trace("payment required for backup");
+ logger.trace(`headers: ${j2s(resp.headers)}`);
+ const talerUri = resp.headers.get("taler");
+ if (!talerUri) {
+ throw Error("no taler URI available to pay provider");
+ }
+
+ //We can't delay downloading the proposal since we need the id
+ //FIXME: check download errors
+ let res: PreparePayResult | undefined = undefined;
+ try {
+ res = await preparePayForUri(wex, talerUri);
+ } catch (e) {
+ const error = TalerError.fromException(e);
+ if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) {
+ throw error;
+ }
+ }
+
+ if (res === undefined) {
+ //claimed
+
+ await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ prov.shouldRetryFreshProposal = true;
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ });
+
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
+ }
+ const result = res;
+
+ await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ // const opId = TaskIdentifiers.forBackup(prov);
+ // await scheduleRetryInTx(ws, tx, opId);
+ prov.currentPaymentProposalId = result.proposalId;
+ prov.shouldRetryFreshProposal = false;
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ });
+
+ addAttentionRequest(
+ wex,
+ {
+ type: AttentionType.BackupUnpaid,
+ provider_base_url: provider.baseUrl,
+ talerUri,
+ },
+ provider.baseUrl,
+ );
+
+ throw Error("not implemented");
+ // return {
+ // type: TaskRunResultType.Pending,
+ // };
+ }
+
+ if (resp.status === HttpStatusCode.NoContent) {
+ await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ return;
+ }
+ prov.lastBackupHash = encodeCrock(currentBackupHash);
+ prov.lastBackupCycleTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ prov.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(getNextBackupTimestamp()),
+ };
+ await tx.backupProviders.put(prov);
+ });
+
+ removeAttentionRequest(wex, {
+ entityId: provider.baseUrl,
+ type: AttentionType.BackupUnpaid,
+ });
+
+ return {
+ type: TaskRunResultType.Finished,
+ };
+ }
+
+ if (resp.status === HttpStatusCode.Conflict) {
+ logger.info("conflicting backup found");
+ const backupEnc = new Uint8Array(await resp.bytes());
+ const backupConfig = await provideBackupState(wex);
+ // const blob = await decryptBackup(backupConfig, backupEnc);
+ // FIXME: Re-implement backup import with merging
+ // await importBackup(ws, blob, cryptoData);
+ await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const prov = await tx.backupProviders.get(provider.baseUrl);
+ if (!prov) {
+ logger.warn("backup provider not found anymore");
+ return;
+ }
+ prov.lastBackupHash = encodeCrock(hash(backupEnc));
+ // FIXME: Allocate error code for this situation?
+ // FIXME: Add operation retry record!
+ const opId = TaskIdentifiers.forBackup(prov);
+ //await scheduleRetryInTx(ws, tx, opId);
+ prov.state = {
+ tag: BackupProviderStateTag.Retrying,
+ };
+ await tx.backupProviders.put(prov);
+ });
+ logger.info("processed existing backup");
+ // Now upload our own, merged backup.
+ return await runBackupCycleForProvider(wex, args);
+ }
+
+ // Some other response that we did not expect!
+
+ logger.error("parsing error response");
+
+ const err = await readTalerErrorResponse(resp);
+ logger.error(`got error response from backup provider: ${j2s(err)}`);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: err,
+ };
+}
+
+export async function processBackupForProvider(
+ wex: WalletExecutionContext,
+ backupProviderBaseUrl: string,
+): Promise<TaskRunResult> {
+ const provider = await wex.db.runReadOnlyTx(
+ ["backupProviders"],
+ async (tx) => {
+ return await tx.backupProviders.get(backupProviderBaseUrl);
+ },
+ );
+ if (!provider) {
+ throw Error("unknown backup provider");
+ }
+
+ logger.info(`running backup for provider ${backupProviderBaseUrl}`);
+
+ return await runBackupCycleForProvider(wex, {
+ backupProviderBaseUrl: provider.baseUrl,
+ });
+}
+
+export interface RemoveBackupProviderRequest {
+ provider: string;
+}
+
+export const codecForRemoveBackupProvider =
+ (): Codec<RemoveBackupProviderRequest> =>
+ buildCodecForObject<RemoveBackupProviderRequest>()
+ .property("provider", codecForString())
+ .build("RemoveBackupProviderRequest");
+
+export async function removeBackupProvider(
+ wex: WalletExecutionContext,
+ req: RemoveBackupProviderRequest,
+): Promise<void> {
+ await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ await tx.backupProviders.delete(req.provider);
+ });
+}
+
+export interface RunBackupCycleRequest {
+ /**
+ * List of providers to backup or empty for all known providers.
+ */
+ providers?: Array<string>;
+}
+
+export const codecForRunBackupCycle = (): Codec<RunBackupCycleRequest> =>
+ buildCodecForObject<RunBackupCycleRequest>()
+ .property("providers", codecOptional(codecForList(codecForString())))
+ .build("RunBackupCycleRequest");
+
+/**
+ * Do one backup cycle that consists of:
+ * 1. Exporting a backup and try to upload it.
+ * Stop if this step succeeds.
+ * 2. Download, verify and import backups from connected sync accounts.
+ * 3. Upload the updated backup blob.
+ */
+export async function runBackupCycle(
+ wex: WalletExecutionContext,
+ req: RunBackupCycleRequest,
+): Promise<void> {
+ const providers = await wex.db.runReadOnlyTx(
+ ["backupProviders"],
+ async (tx) => {
+ if (req.providers) {
+ const rs = await Promise.all(
+ req.providers.map((id) => tx.backupProviders.get(id)),
+ );
+ return rs.filter(notEmpty);
+ }
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+
+ for (const provider of providers) {
+ await runBackupCycleForProvider(wex, {
+ backupProviderBaseUrl: provider.baseUrl,
+ });
+ }
+}
+
+export interface AddBackupProviderRequest {
+ backupProviderBaseUrl: string;
+
+ name: string;
+ /**
+ * Activate the provider. Should only be done after
+ * the user has reviewed the provider.
+ */
+ activate?: boolean;
+}
+
+export const codecForAddBackupProviderRequest =
+ (): Codec<AddBackupProviderRequest> =>
+ buildCodecForObject<AddBackupProviderRequest>()
+ .property("backupProviderBaseUrl", codecForString())
+ .property("name", codecForString())
+ .property("activate", codecOptional(codecForBoolean()))
+ .build("AddBackupProviderRequest");
+
+export type AddBackupProviderResponse =
+ | AddBackupProviderOk
+ | AddBackupProviderPaymentRequired;
+
+interface AddBackupProviderOk {
+ status: "ok";
+}
+interface AddBackupProviderPaymentRequired {
+ status: "payment-required";
+ talerUri?: string;
+}
+
+export const codecForAddBackupProviderOk = (): Codec<AddBackupProviderOk> =>
+ buildCodecForObject<AddBackupProviderOk>()
+ .property("status", codecForConstString("ok"))
+ .build("AddBackupProviderOk");
+
+export const codecForAddBackupProviderPaymenrRequired =
+ (): Codec<AddBackupProviderPaymentRequired> =>
+ buildCodecForObject<AddBackupProviderPaymentRequired>()
+ .property("status", codecForConstString("payment-required"))
+ .property("talerUri", codecOptional(codecForString()))
+ .build("AddBackupProviderPaymentRequired");
+
+export const codecForAddBackupProviderResponse =
+ (): Codec<AddBackupProviderResponse> =>
+ buildCodecForUnion<AddBackupProviderResponse>()
+ .discriminateOn("status")
+ .alternative("ok", codecForAddBackupProviderOk())
+ .alternative(
+ "payment-required",
+ codecForAddBackupProviderPaymenrRequired(),
+ )
+ .build("AddBackupProviderResponse");
+
+export async function addBackupProvider(
+ wex: WalletExecutionContext,
+ req: AddBackupProviderRequest,
+): Promise<AddBackupProviderResponse> {
+ logger.info(`adding backup provider ${j2s(req)}`);
+ await provideBackupState(wex);
+ const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
+ await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ const oldProv = await tx.backupProviders.get(canonUrl);
+ if (oldProv) {
+ logger.info("old backup provider found");
+ if (req.activate) {
+ oldProv.state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ };
+ logger.info("setting existing backup provider to active");
+ await tx.backupProviders.put(oldProv);
+ }
+ return;
+ }
+ });
+ const termsUrl = new URL("config", canonUrl);
+ const resp = await wex.http.fetch(termsUrl.href);
+ const terms = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForSyncTermsOfServiceResponse(),
+ );
+ await wex.db.runReadWriteTx(["backupProviders"], async (tx) => {
+ let state: BackupProviderState;
+ //FIXME: what is the difference provisional and ready?
+ if (req.activate) {
+ state = {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ } else {
+ state = {
+ tag: BackupProviderStateTag.Provisional,
+ };
+ }
+ await tx.backupProviders.put({
+ state,
+ name: req.name,
+ terms: {
+ annualFee: terms.annual_fee,
+ storageLimitInMegabytes: terms.storage_limit_in_megabytes,
+ supportedProtocolVersion: terms.version,
+ },
+ shouldRetryFreshProposal: false,
+ paymentProposalIds: [],
+ baseUrl: canonUrl,
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ });
+
+ return await runFirstBackupCycleForProvider(wex, {
+ backupProviderBaseUrl: canonUrl,
+ });
+}
+
+async function runFirstBackupCycleForProvider(
+ wex: WalletExecutionContext,
+ args: BackupForProviderArgs,
+): Promise<AddBackupProviderResponse> {
+ throw Error("not implemented");
+ // const resp = await runBackupCycleForProvider(ws, args);
+ // switch (resp.type) {
+ // case TaskRunResultType.Error:
+ // throw TalerError.fromDetail(
+ // TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ // resp.errorDetail as any, //FIXME create an error for backup problems
+ // );
+ // case TaskRunResultType.Finished:
+ // return {
+ // status: "ok",
+ // };
+ // case TaskRunResultType.Pending:
+ // return {
+ // status: "payment-required",
+ // talerUri: "FIXME",
+ // //talerUri: resp.result.talerUri,
+ // };
+ // default:
+ // assertUnreachable(resp);
+ // }
+}
+
+export async function restoreFromRecoverySecret(): Promise<void> {
+ return;
+}
+
+export interface BackupInfo {
+ walletRootPub: string;
+ deviceId: string;
+ providers: ProviderInfo[];
+}
+
+async function getProviderPaymentInfo(
+ wex: WalletExecutionContext,
+ provider: BackupProviderRecord,
+): Promise<ProviderPaymentStatus> {
+ throw Error("not implemented");
+ // if (!provider.currentPaymentProposalId) {
+ // return {
+ // type: ProviderPaymentType.Unpaid,
+ // };
+ // }
+ // const status = await checkPaymentByProposalId(
+ // ws,
+ // provider.currentPaymentProposalId,
+ // ).catch(() => undefined);
+
+ // if (!status) {
+ // return {
+ // type: ProviderPaymentType.Unpaid,
+ // };
+ // }
+
+ // switch (status.status) {
+ // case PreparePayResultType.InsufficientBalance:
+ // return {
+ // type: ProviderPaymentType.InsufficientBalance,
+ // amount: status.amountRaw,
+ // };
+ // case PreparePayResultType.PaymentPossible:
+ // return {
+ // type: ProviderPaymentType.Pending,
+ // talerUri: status.talerUri,
+ // };
+ // case PreparePayResultType.AlreadyConfirmed:
+ // if (status.paid) {
+ // return {
+ // type: ProviderPaymentType.Paid,
+ // paidUntil: AbsoluteTime.addDuration(
+ // AbsoluteTime.fromProtocolTimestamp(status.contractTerms.timestamp),
+ // durationFromSpec({ years: 1 }), //FIXME: take this from the contract term
+ // ),
+ // };
+ // } else {
+ // return {
+ // type: ProviderPaymentType.Pending,
+ // talerUri: status.talerUri,
+ // };
+ // }
+ // default:
+ // assertUnreachable(status);
+ // }
+}
+
+/**
+ * Get information about the current state of wallet backups.
+ */
+export async function getBackupInfo(
+ wex: WalletExecutionContext,
+): Promise<BackupInfo> {
+ const backupConfig = await provideBackupState(wex);
+ const providerRecords = await wex.db.runReadOnlyTx(
+ ["backupProviders", "operationRetries"],
+ async (tx) => {
+ return await tx.backupProviders.iter().mapAsync(async (bp) => {
+ const opId = TaskIdentifiers.forBackup(bp);
+ const retryRecord = await tx.operationRetries.get(opId);
+ return {
+ provider: bp,
+ retryRecord,
+ };
+ });
+ },
+ );
+ const providers: ProviderInfo[] = [];
+ for (const x of providerRecords) {
+ providers.push({
+ active: x.provider.state.tag !== BackupProviderStateTag.Provisional,
+ syncProviderBaseUrl: x.provider.baseUrl,
+ lastSuccessfulBackupTimestamp: timestampOptionalPreciseFromDb(
+ x.provider.lastBackupCycleTimestamp,
+ ),
+ paymentProposalIds: x.provider.paymentProposalIds,
+ lastError:
+ x.provider.state.tag === BackupProviderStateTag.Retrying
+ ? x.retryRecord?.lastError
+ : undefined,
+ paymentStatus: await getProviderPaymentInfo(wex, x.provider),
+ terms: x.provider.terms,
+ name: x.provider.name,
+ });
+ }
+ return {
+ deviceId: backupConfig.deviceId,
+ walletRootPub: backupConfig.walletRootPub,
+ providers,
+ };
+}
+
+/**
+ * Get backup recovery information, including the wallet's
+ * private key.
+ */
+export async function getBackupRecovery(
+ wex: WalletExecutionContext,
+): Promise<BackupRecovery> {
+ const bs = await provideBackupState(wex);
+ const providers = await wex.db.runReadOnlyTx(
+ ["backupProviders"],
+ async (tx) => {
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+ return {
+ providers: providers
+ .filter((x) => x.state.tag !== BackupProviderStateTag.Provisional)
+ .map((x) => {
+ return {
+ name: x.name,
+ url: x.baseUrl,
+ };
+ }),
+ walletRootPriv: bs.walletRootPriv,
+ };
+}
+
+async function backupRecoveryTheirs(
+ wex: WalletExecutionContext,
+ br: BackupRecovery,
+) {
+ await wex.db.runReadWriteTx(["backupProviders", "config"], async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ checkDbInvariant(!!backupStateEntry);
+ checkDbInvariant(
+ backupStateEntry.key === ConfigRecordKey.WalletBackupState,
+ );
+ backupStateEntry.value.lastBackupNonce = undefined;
+ backupStateEntry.value.lastBackupTimestamp = undefined;
+ backupStateEntry.value.lastBackupCheckTimestamp = undefined;
+ backupStateEntry.value.lastBackupPlainHash = undefined;
+ backupStateEntry.value.walletRootPriv = br.walletRootPriv;
+ backupStateEntry.value.walletRootPub = encodeCrock(
+ eddsaGetPublic(decodeCrock(br.walletRootPriv)),
+ );
+ await tx.config.put(backupStateEntry);
+ for (const prov of br.providers) {
+ const existingProv = await tx.backupProviders.get(prov.url);
+ if (!existingProv) {
+ await tx.backupProviders.put({
+ baseUrl: prov.url,
+ name: prov.name,
+ paymentProposalIds: [],
+ shouldRetryFreshProposal: false,
+ state: {
+ tag: BackupProviderStateTag.Ready,
+ nextBackupTimestamp: timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ ),
+ },
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ }
+ }
+ const providers = await tx.backupProviders.iter().toArray();
+ for (const prov of providers) {
+ prov.lastBackupCycleTimestamp = undefined;
+ prov.lastBackupHash = undefined;
+ await tx.backupProviders.put(prov);
+ }
+ });
+}
+
+async function backupRecoveryOurs(
+ wex: WalletExecutionContext,
+ br: BackupRecovery,
+) {
+ throw Error("not implemented");
+}
+
+export async function loadBackupRecovery(
+ wex: WalletExecutionContext,
+ br: RecoveryLoadRequest,
+): Promise<void> {
+ const bs = await provideBackupState(wex);
+ const providers = await wex.db.runReadOnlyTx(
+ ["backupProviders"],
+ async (tx) => {
+ return await tx.backupProviders.iter().toArray();
+ },
+ );
+ let strategy = br.strategy;
+ if (
+ br.recovery.walletRootPriv != bs.walletRootPriv &&
+ providers.length > 0 &&
+ !strategy
+ ) {
+ throw Error(
+ "recovery load strategy must be specified for wallet with existing providers",
+ );
+ } else if (!strategy) {
+ // Default to using the new key if we don't have providers yet.
+ strategy = RecoveryMergeStrategy.Theirs;
+ }
+ if (strategy === RecoveryMergeStrategy.Theirs) {
+ return backupRecoveryTheirs(wex, br.recovery);
+ } else {
+ return backupRecoveryOurs(wex, br.recovery);
+ }
+}
+
+export async function decryptBackup(
+ backupConfig: WalletBackupConfState,
+ data: Uint8Array,
+): Promise<any> {
+ const rMagic = bytesToString(data.slice(0, 8));
+ if (rMagic != magic) {
+ throw Error("invalid backup file (magic tag mismatch)");
+ }
+
+ const nonce = data.slice(8, 8 + 24);
+ const box = data.slice(8 + 24);
+ const secret = deriveBlobSecret(backupConfig);
+ const dataCompressed = secretbox_open(box, nonce, secret);
+ if (!dataCompressed) {
+ throw Error("decryption failed");
+ }
+ return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
+}
+
+export async function provideBackupState(
+ wex: WalletExecutionContext,
+): Promise<WalletBackupConfState> {
+ const bs: ConfigRecord | undefined = await wex.db.runReadOnlyTx(
+ ["config"],
+ async (tx) => {
+ return await tx.config.get(ConfigRecordKey.WalletBackupState);
+ },
+ );
+ if (bs) {
+ checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
+ return bs.value;
+ }
+ // We need to generate the key outside of the transaction
+ // due to how IndexedDB works.
+ const k = await wex.cryptoApi.createEddsaKeypair({});
+ const d = getRandomBytes(5);
+ // FIXME: device ID should be configured when wallet is initialized
+ // and be based on hostname
+ const deviceId = `wallet-core-${encodeCrock(d)}`;
+ return await wex.db.runReadWriteTx(["config"], async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ if (!backupStateEntry) {
+ backupStateEntry = {
+ key: ConfigRecordKey.WalletBackupState,
+ value: {
+ deviceId,
+ walletRootPub: k.pub,
+ walletRootPriv: k.priv,
+ lastBackupPlainHash: undefined,
+ },
+ };
+ await tx.config.put(backupStateEntry);
+ }
+ checkDbInvariant(
+ backupStateEntry.key === ConfigRecordKey.WalletBackupState,
+ );
+ return backupStateEntry.value;
+ });
+}
+
+export async function getWalletBackupState(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<["config"]>,
+): Promise<WalletBackupConfState> {
+ const bs = await tx.config.get(ConfigRecordKey.WalletBackupState);
+ checkDbInvariant(!!bs, "wallet backup state should be in DB");
+ checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState);
+ return bs.value;
+}
+
+export async function setWalletDeviceId(
+ wex: WalletExecutionContext,
+ deviceId: string,
+): Promise<void> {
+ await provideBackupState(wex);
+ await wex.db.runReadWriteTx(["config"], async (tx) => {
+ let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
+ ConfigRecordKey.WalletBackupState,
+ );
+ if (
+ !backupStateEntry ||
+ backupStateEntry.key !== ConfigRecordKey.WalletBackupState
+ ) {
+ return;
+ }
+ backupStateEntry.value.deviceId = deviceId;
+ await tx.config.put(backupStateEntry);
+ });
+}
+
+export async function getWalletDeviceId(
+ wex: WalletExecutionContext,
+): Promise<string> {
+ const bs = await provideBackupState(wex);
+ return bs.deviceId;
+}
diff --git a/packages/taler-wallet-core/src/backup/state.ts b/packages/taler-wallet-core/src/backup/state.ts
new file mode 100644
index 000000000..72f850b25
--- /dev/null
+++ b/packages/taler-wallet-core/src/backup/state.ts
@@ -0,0 +1,15 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts
new file mode 100644
index 000000000..ca7642163
--- /dev/null
+++ b/packages/taler-wallet-core/src/balance.ts
@@ -0,0 +1,762 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Functions to compute the wallet's balance.
+ *
+ * There are multiple definition of the wallet's balance.
+ * We use the following terminology:
+ *
+ * - "available": Balance that is available
+ * for spending from transactions in their final state and
+ * expected to be available from pending refreshes.
+ *
+ * - "pending-incoming": Expected (positive!) delta
+ * to the available balance that we expect to have
+ * after pending operations reach the "done" state.
+ *
+ * - "pending-outgoing": Amount that is currently allocated
+ * to be spent, but the spend operation could still be aborted
+ * and part of the pending-outgoing amount could be recovered.
+ *
+ * - "material": Balance that the wallet believes it could spend *right now*,
+ * without waiting for any operations to complete.
+ * This balance type is important when showing "insufficient balance" error messages.
+ *
+ * - "age-acceptable": Subset of the material balance that can be spent
+ * with age restrictions applied.
+ *
+ * - "merchant-acceptable": Subset of the material balance that can be spent with a particular
+ * merchant (restricted via min age, exchange, auditor, wire_method).
+ *
+ * - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant
+ * can accept via their supported wire methods.
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AmountJson,
+ AmountLike,
+ Amounts,
+ assertUnreachable,
+ BalanceFlag,
+ BalancesResponse,
+ GetBalanceDetailRequest,
+ j2s,
+ Logger,
+ parsePaytoUri,
+ ScopeInfo,
+ ScopeType,
+} from "@gnu-taler/taler-util";
+import { ExchangeRestrictionSpec, findMatchingWire } from "./coinSelection.js";
+import {
+ DepositOperationStatus,
+ ExchangeEntryDbRecordStatus,
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ PeerPushDebitStatus,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ WalletDbReadOnlyTransaction,
+ WithdrawalGroupStatus,
+} from "./db.js";
+import {
+ getExchangeScopeInfo,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import { getDenomInfo, WalletExecutionContext } from "./wallet.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("operations/balance.ts");
+
+interface WalletBalance {
+ scopeInfo: ScopeInfo;
+ available: AmountJson;
+ pendingIncoming: AmountJson;
+ pendingOutgoing: AmountJson;
+ flagIncomingKyc: boolean;
+ flagIncomingAml: boolean;
+ flagIncomingConfirmation: boolean;
+ flagOutgoingKyc: boolean;
+}
+
+/**
+ * Compute the available amount that the wallet expects to get
+ * out of a refresh group.
+ */
+function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson {
+ // Don't count finished refreshes, since the refresh already resulted
+ // in coins being added to the wallet.
+ let available = Amounts.zeroOfCurrency(r.currency);
+ if (r.timestampFinished) {
+ return available;
+ }
+ for (let i = 0; i < r.oldCoinPubs.length; i++) {
+ available = Amounts.add(available, r.expectedOutputPerCoin[i]).amount;
+ }
+ return available;
+}
+
+function getBalanceKey(scopeInfo: ScopeInfo): string {
+ switch (scopeInfo.type) {
+ case ScopeType.Auditor:
+ return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
+ case ScopeType.Exchange:
+ return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`;
+ case ScopeType.Global:
+ return `${scopeInfo.type};${scopeInfo.currency}`;
+ }
+}
+
+class BalancesStore {
+ private exchangeScopeCache: Record<string, ScopeInfo> = {};
+ private balanceStore: Record<string, WalletBalance> = {};
+
+ constructor(
+ private wex: WalletExecutionContext,
+ private tx: WalletDbReadOnlyTransaction<
+ [
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+ ) {}
+
+ /**
+ * Add amount to a balance field, both for
+ * the slicing by exchange and currency.
+ */
+ private async initBalance(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<WalletBalance> {
+ let scopeInfo: ScopeInfo | undefined =
+ this.exchangeScopeCache[exchangeBaseUrl];
+ if (!scopeInfo) {
+ scopeInfo = await getExchangeScopeInfo(
+ this.tx,
+ exchangeBaseUrl,
+ currency,
+ );
+ this.exchangeScopeCache[exchangeBaseUrl] = scopeInfo;
+ }
+ const balanceKey = getBalanceKey(scopeInfo);
+ const b = this.balanceStore[balanceKey];
+ if (!b) {
+ const zero = Amounts.zeroOfCurrency(currency);
+ this.balanceStore[balanceKey] = {
+ scopeInfo,
+ available: zero,
+ pendingIncoming: zero,
+ pendingOutgoing: zero,
+ flagIncomingAml: false,
+ flagIncomingConfirmation: false,
+ flagIncomingKyc: false,
+ flagOutgoingKyc: false,
+ };
+ }
+ return this.balanceStore[balanceKey];
+ }
+
+ async addZero(currency: string, exchangeBaseUrl: string): Promise<void> {
+ await this.initBalance(currency, exchangeBaseUrl);
+ }
+
+ async addAvailable(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.available = Amounts.add(b.available, amount).amount;
+ }
+
+ async addPendingIncoming(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.pendingIncoming = Amounts.add(b.pendingIncoming, amount).amount;
+ }
+
+ async addPendingOutgoing(
+ currency: string,
+ exchangeBaseUrl: string,
+ amount: AmountLike,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.pendingOutgoing = Amounts.add(b.pendingOutgoing, amount).amount;
+ }
+
+ async setFlagIncomingAml(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingAml = true;
+ }
+
+ async setFlagIncomingKyc(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingKyc = true;
+ }
+
+ async setFlagIncomingConfirmation(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagIncomingConfirmation = true;
+ }
+
+ async setFlagOutgoingKyc(
+ currency: string,
+ exchangeBaseUrl: string,
+ ): Promise<void> {
+ const b = await this.initBalance(currency, exchangeBaseUrl);
+ b.flagOutgoingKyc = true;
+ }
+
+ toBalancesResponse(): BalancesResponse {
+ const balancesResponse: BalancesResponse = {
+ balances: [],
+ };
+
+ const balanceStore = this.balanceStore;
+
+ Object.keys(balanceStore)
+ .sort()
+ .forEach((c) => {
+ const v = balanceStore[c];
+ const flags: BalanceFlag[] = [];
+ if (v.flagIncomingAml) {
+ flags.push(BalanceFlag.IncomingAml);
+ }
+ if (v.flagIncomingKyc) {
+ flags.push(BalanceFlag.IncomingKyc);
+ }
+ if (v.flagIncomingConfirmation) {
+ flags.push(BalanceFlag.IncomingConfirmation);
+ }
+ if (v.flagOutgoingKyc) {
+ flags.push(BalanceFlag.OutgoingKyc);
+ }
+ balancesResponse.balances.push({
+ scopeInfo: v.scopeInfo,
+ available: Amounts.stringify(v.available),
+ pendingIncoming: Amounts.stringify(v.pendingIncoming),
+ pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
+ // FIXME: This field is basically not implemented, do we even need it?
+ hasPendingTransactions: false,
+ // FIXME: This field is basically not implemented, do we even need it?
+ requiresUserInput: false,
+ flags,
+ });
+ });
+ return balancesResponse;
+ }
+}
+
+/**
+ * Get balance information.
+ */
+export async function getBalancesInsideTransaction(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "coinAvailability",
+ "refreshGroups",
+ "depositGroups",
+ "withdrawalGroups",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "peerPushDebit",
+ ]
+ >,
+): Promise<BalancesResponse> {
+ const balanceStore: BalancesStore = new BalancesStore(wex, tx);
+
+ const keyRangeActive = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+
+ await tx.exchanges.iter().forEachAsync(async (ex) => {
+ if (
+ ex.entryStatus === ExchangeEntryDbRecordStatus.Used ||
+ ex.tosAcceptedTimestamp != null
+ ) {
+ const det = await getExchangeWireDetailsInTx(tx, ex.baseUrl);
+ if (det) {
+ await balanceStore.addZero(det.currency, ex.baseUrl);
+ }
+ }
+ });
+
+ await tx.coinAvailability.iter().forEachAsync(async (ca) => {
+ const count = ca.visibleCoinCount ?? 0;
+ await balanceStore.addZero(ca.currency, ca.exchangeBaseUrl);
+ for (let i = 0; i < count; i++) {
+ await balanceStore.addAvailable(
+ ca.currency,
+ ca.exchangeBaseUrl,
+ ca.value,
+ );
+ }
+ });
+
+ await tx.refreshGroups.iter().forEachAsync(async (r) => {
+ switch (r.operationStatus) {
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended:
+ break;
+ default:
+ return;
+ }
+ const perExchange = r.infoPerExchange;
+ if (!perExchange) {
+ return;
+ }
+ for (const [e, x] of Object.entries(perExchange)) {
+ await balanceStore.addAvailable(r.currency, e, x.outputEffective);
+ }
+ });
+
+ await tx.withdrawalGroups.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (wgRecord) => {
+ const currency = Amounts.currencyOf(wgRecord.denomsSel.totalCoinValue);
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.Done:
+ // Does not count as pendingIncoming
+ return;
+ case WithdrawalGroupStatus.PendingReady:
+ case WithdrawalGroupStatus.AbortingBank:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ // Pending, but no special flag.
+ break;
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.PendingKyc:
+ await balanceStore.setFlagIncomingKyc(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ );
+ break;
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.SuspendedAml:
+ await balanceStore.setFlagIncomingAml(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ );
+ break;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ await balanceStore.setFlagIncomingConfirmation(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ );
+ break;
+ default:
+ assertUnreachable(wgRecord.status);
+ }
+ await balanceStore.addPendingIncoming(
+ currency,
+ wgRecord.exchangeBaseUrl,
+ wgRecord.denomsSel.totalCoinValue,
+ );
+ });
+
+ await tx.peerPushDebit.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (ppdRecord) => {
+ switch (ppdRecord.status) {
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.SuspendedCreatePurse: {
+ const currency = Amounts.currencyOf(ppdRecord.amount);
+ await balanceStore.addPendingOutgoing(
+ currency,
+ ppdRecord.exchangeBaseUrl,
+ ppdRecord.totalCost,
+ );
+ break;
+ }
+ }
+ });
+
+ await tx.depositGroups.indexes.byStatus
+ .iter(keyRangeActive)
+ .forEachAsync(async (dgRecord) => {
+ const perExchange = dgRecord.infoPerExchange;
+ if (!perExchange) {
+ return;
+ }
+ for (const [e, x] of Object.entries(perExchange)) {
+ const currency = Amounts.currencyOf(dgRecord.amount);
+ switch (dgRecord.operationStatus) {
+ case DepositOperationStatus.SuspendedKyc:
+ case DepositOperationStatus.PendingKyc:
+ await balanceStore.setFlagOutgoingKyc(currency, e);
+ }
+
+ switch (dgRecord.operationStatus) {
+ case DepositOperationStatus.SuspendedKyc:
+ case DepositOperationStatus.PendingKyc:
+ case DepositOperationStatus.PendingTrack:
+ case DepositOperationStatus.SuspendedAborting:
+ case DepositOperationStatus.SuspendedDeposit:
+ case DepositOperationStatus.SuspendedTrack:
+ case DepositOperationStatus.PendingDeposit: {
+ const perExchange = dgRecord.infoPerExchange;
+ if (perExchange) {
+ for (const [e, v] of Object.entries(perExchange)) {
+ await balanceStore.addPendingOutgoing(
+ currency,
+ e,
+ v.amountEffective,
+ );
+ }
+ }
+ }
+ }
+ }
+ });
+
+ return balanceStore.toBalancesResponse();
+}
+
+/**
+ * Get detailed balance information, sliced by exchange and by currency.
+ */
+export async function getBalances(
+ wex: WalletExecutionContext,
+): Promise<BalancesResponse> {
+ logger.trace("starting to compute balance");
+
+ const wbal = await wex.db.runReadWriteTx(
+ [
+ "coinAvailability",
+ "coins",
+ "depositGroups",
+ "exchangeDetails",
+ "exchanges",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ "purchases",
+ "refreshGroups",
+ "withdrawalGroups",
+ "peerPushDebit",
+ ],
+ async (tx) => {
+ return getBalancesInsideTransaction(wex, tx);
+ },
+ );
+
+ logger.trace("finished computing wallet balance");
+
+ return wbal;
+}
+
+export interface PaymentRestrictionsForBalance {
+ currency: string;
+ minAge: number;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ restrictWireMethods: string[] | undefined;
+ depositPaytoUri: string | undefined;
+}
+
+export interface AcceptableExchanges {
+ /**
+ * Exchanges accepted by the merchant, but wire method might not match.
+ */
+ acceptableExchanges: string[];
+
+ /**
+ * Exchanges accepted by the merchant, including a matching
+ * wire method, i.e. the merchant can deposit coins there.
+ */
+ depositableExchanges: string[];
+}
+
+export interface PaymentBalanceDetails {
+ /**
+ * Balance of type "available" (see balance.ts for definition).
+ */
+ balanceAvailable: AmountJson;
+
+ /**
+ * Balance of type "material" (see balance.ts for definition).
+ */
+ balanceMaterial: AmountJson;
+
+ /**
+ * Balance of type "age-acceptable" (see balance.ts for definition).
+ */
+ balanceAgeAcceptable: AmountJson;
+
+ /**
+ * Balance of type "merchant-acceptable" (see balance.ts for definition).
+ */
+ balanceReceiverAcceptable: AmountJson;
+
+ /**
+ * Balance of type "merchant-depositable" (see balance.ts for definition).
+ */
+ balanceReceiverDepositable: AmountJson;
+
+ /**
+ * Balance that's depositable with the exchange.
+ * This balance is reduced by the exchange's debit restrictions
+ * and wire fee configuration.
+ */
+ balanceExchangeDepositable: AmountJson;
+
+ maxEffectiveSpendAmount: AmountJson;
+}
+
+export async function getPaymentBalanceDetails(
+ wex: WalletExecutionContext,
+ req: PaymentRestrictionsForBalance,
+): Promise<PaymentBalanceDetails> {
+ return await wex.db.runReadOnlyTx(
+ [
+ "coinAvailability",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ ],
+ async (tx) => {
+ return getPaymentBalanceDetailsInTx(wex, tx, req);
+ },
+ );
+}
+
+export async function getPaymentBalanceDetailsInTx(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "denominations",
+ ]
+ >,
+ req: PaymentRestrictionsForBalance,
+): Promise<PaymentBalanceDetails> {
+ const d: PaymentBalanceDetails = {
+ balanceAvailable: Amounts.zeroOfCurrency(req.currency),
+ balanceMaterial: Amounts.zeroOfCurrency(req.currency),
+ balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
+ balanceReceiverAcceptable: Amounts.zeroOfCurrency(req.currency),
+ balanceReceiverDepositable: Amounts.zeroOfCurrency(req.currency),
+ maxEffectiveSpendAmount: Amounts.zeroOfCurrency(req.currency),
+ balanceExchangeDepositable: Amounts.zeroOfCurrency(req.currency),
+ };
+
+ logger.info(`computing balance details for ${j2s(req)}`);
+
+ const availableCoins = await tx.coinAvailability.getAll();
+
+ for (const ca of availableCoins) {
+ if (ca.currency != req.currency) {
+ continue;
+ }
+
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ ca.exchangeBaseUrl,
+ ca.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+
+ const wireDetails = await getExchangeWireDetailsInTx(
+ tx,
+ ca.exchangeBaseUrl,
+ );
+ if (!wireDetails) {
+ continue;
+ }
+
+ const singleCoinAmount: AmountJson = Amounts.parseOrThrow(ca.value);
+ const coinAmount: AmountJson = Amounts.mult(
+ singleCoinAmount,
+ ca.freshCoinCount,
+ ).amount;
+
+ let wireOkay = false;
+ if (req.restrictWireMethods == null) {
+ wireOkay = true;
+ } else {
+ for (const wm of req.restrictWireMethods) {
+ const wmf = findMatchingWire(wm, req.depositPaytoUri, wireDetails);
+ if (wmf) {
+ wireOkay = true;
+ break;
+ }
+ }
+ }
+
+ if (wireOkay) {
+ d.balanceExchangeDepositable = Amounts.add(
+ d.balanceExchangeDepositable,
+ coinAmount,
+ ).amount;
+ }
+
+ let ageOkay = ca.maxAge === 0 || ca.maxAge > req.minAge;
+
+ let merchantExchangeAcceptable = false;
+
+ if (!req.restrictExchanges) {
+ merchantExchangeAcceptable = true;
+ } else {
+ for (const ex of req.restrictExchanges.exchanges) {
+ if (ex.exchangeBaseUrl === ca.exchangeBaseUrl) {
+ merchantExchangeAcceptable = true;
+ break;
+ }
+ }
+ for (const acceptedAuditor of req.restrictExchanges.auditors) {
+ for (const exchangeAuditor of wireDetails.auditors) {
+ if (acceptedAuditor.auditorBaseUrl === exchangeAuditor.auditor_url) {
+ merchantExchangeAcceptable = true;
+ break;
+ }
+ }
+ }
+ }
+
+ const merchantExchangeDepositable = merchantExchangeAcceptable && wireOkay;
+
+ d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
+ d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
+ if (ageOkay) {
+ d.balanceAgeAcceptable = Amounts.add(
+ d.balanceAgeAcceptable,
+ coinAmount,
+ ).amount;
+ if (merchantExchangeAcceptable) {
+ d.balanceReceiverAcceptable = Amounts.add(
+ d.balanceReceiverAcceptable,
+ coinAmount,
+ ).amount;
+ if (merchantExchangeDepositable) {
+ d.balanceReceiverDepositable = Amounts.add(
+ d.balanceReceiverDepositable,
+ coinAmount,
+ ).amount;
+ }
+ }
+ }
+
+ if (
+ ageOkay &&
+ wireOkay &&
+ merchantExchangeAcceptable &&
+ merchantExchangeDepositable
+ ) {
+ d.maxEffectiveSpendAmount = Amounts.add(
+ d.maxEffectiveSpendAmount,
+ Amounts.mult(ca.value, ca.freshCoinCount).amount,
+ ).amount;
+
+ d.maxEffectiveSpendAmount = Amounts.sub(
+ d.maxEffectiveSpendAmount,
+ Amounts.mult(denom.feeDeposit, ca.freshCoinCount).amount,
+ ).amount;
+ }
+ }
+
+ await tx.refreshGroups.iter().forEach((r) => {
+ if (r.currency != req.currency) {
+ return;
+ }
+ d.balanceAvailable = Amounts.add(
+ d.balanceAvailable,
+ computeRefreshGroupAvailableAmount(r),
+ ).amount;
+ });
+
+ return d;
+}
+
+export async function getBalanceDetail(
+ wex: WalletExecutionContext,
+ req: GetBalanceDetailRequest,
+): Promise<PaymentBalanceDetails> {
+ const exchanges: { exchangeBaseUrl: string; exchangePub: string }[] = [];
+ const wires = new Array<string>();
+ await wex.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || req.currency !== details.currency) {
+ continue;
+ }
+ details.wireInfo.accounts.forEach((a) => {
+ const payto = parsePaytoUri(a.payto_uri);
+ if (payto && !wires.includes(payto.targetType)) {
+ wires.push(payto.targetType);
+ }
+ });
+ exchanges.push({
+ exchangePub: details.masterPublicKey,
+ exchangeBaseUrl: e.baseUrl,
+ });
+ }
+ });
+
+ return await getPaymentBalanceDetails(wex, {
+ currency: req.currency,
+ restrictExchanges: {
+ auditors: [],
+ exchanges,
+ },
+ restrictWireMethods: wires,
+ minAge: 0,
+ depositPaytoUri: undefined,
+ });
+}
diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts
new file mode 100644
index 000000000..c7cb2857e
--- /dev/null
+++ b/packages/taler-wallet-core/src/coinSelection.test.ts
@@ -0,0 +1,281 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Amounts,
+ DenomKeyType,
+ Duration,
+ j2s,
+} from "@gnu-taler/taler-util";
+import test from "ava";
+import {
+ AvailableDenom,
+ CoinSelectionTally,
+ emptyTallyForPeerPayment,
+ testing_selectGreedy,
+} from "./coinSelection.js";
+
+const inTheDistantFuture = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ hours: 1 })),
+);
+
+const inThePast = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.subtractDuraction(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+);
+
+test("p2p: should select the coin", (t) => {
+ const instructedAmount = Amounts.parseOrThrow("LOCAL:2");
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ t.log(`tally before: ${j2s(tally)}`);
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ createCandidates([
+ {
+ amount: "LOCAL:10" as AmountString,
+ numAvailable: 5,
+ depositFee: "LOCAL:0.1" as AmountString,
+ fromExchange: "http://exchange.localhost/",
+ },
+ ]),
+ tally,
+ );
+
+ t.log(`coins: ${j2s(coins)}`);
+ t.log(`tally: ${j2s(tally)}`);
+
+ t.assert(coins != null);
+
+ t.deepEqual(coins, {
+ "hash0;32;http://exchange.localhost/": {
+ exchangeBaseUrl: "http://exchange.localhost/",
+ denomPubHash: "hash0",
+ maxAge: 32,
+ contributions: [Amounts.parseOrThrow("LOCAL:2.1")],
+ },
+ });
+});
+
+test("p2p: should select 3 coins", (t) => {
+ const instructedAmount = Amounts.parseOrThrow("LOCAL:20");
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ createCandidates([
+ {
+ amount: "LOCAL:10" as AmountString,
+ numAvailable: 5,
+ depositFee: "LOCAL:0.1" as AmountString,
+ fromExchange: "http://exchange.localhost/",
+ },
+ ]),
+ tally,
+ );
+
+ t.deepEqual(coins, {
+ "hash0;32;http://exchange.localhost/": {
+ exchangeBaseUrl: "http://exchange.localhost/",
+ denomPubHash: "hash0",
+ maxAge: 32,
+ contributions: [
+ Amounts.parseOrThrow("LOCAL:10"),
+ Amounts.parseOrThrow("LOCAL:10"),
+ Amounts.parseOrThrow("LOCAL:0.3"),
+ ],
+ },
+ });
+});
+
+test("p2p: can't select since the instructed amount is too high", (t) => {
+ const instructedAmount = Amounts.parseOrThrow("LOCAL:60");
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ createCandidates([
+ {
+ amount: "LOCAL:10" as AmountString,
+ numAvailable: 5,
+ depositFee: "LOCAL:0.1" as AmountString,
+ fromExchange: "http://exchange.localhost/",
+ },
+ ]),
+ tally,
+ );
+
+ t.is(coins, undefined);
+});
+
+test("pay: select one coin to pay with fee", (t) => {
+ const payment = Amounts.parseOrThrow("LOCAL:2");
+ const exchangeWireFee = Amounts.parseOrThrow("LOCAL:0.1");
+ const zero = Amounts.zeroOfCurrency(payment.currency);
+ const tally = {
+ amountPayRemaining: payment,
+ amountDepositFeeLimitRemaining: zero,
+ customerDepositFees: zero,
+ customerWireFees: zero,
+ wireFeeCoveredForExchange: new Set<string>(),
+ lastDepositFee: zero,
+ } satisfies CoinSelectionTally;
+ const coins = testing_selectGreedy(
+ {
+ wireFeesPerExchange: { "http://exchange.localhost/": exchangeWireFee },
+ },
+ createCandidates([
+ {
+ amount: "LOCAL:10" as AmountString,
+ numAvailable: 5,
+ depositFee: "LOCAL:0.1" as AmountString,
+ fromExchange: "http://exchange.localhost/",
+ },
+ ]),
+ tally,
+ );
+
+ t.deepEqual(coins, {
+ "hash0;32;http://exchange.localhost/": {
+ exchangeBaseUrl: "http://exchange.localhost/",
+ denomPubHash: "hash0",
+ maxAge: 32,
+ contributions: [Amounts.parseOrThrow("LOCAL:2.2")],
+ },
+ });
+
+ t.deepEqual(tally, {
+ amountPayRemaining: Amounts.parseOrThrow("LOCAL:0"),
+ amountDepositFeeLimitRemaining: zero,
+ customerDepositFees: Amounts.parse("LOCAL:0.1"),
+ customerWireFees: Amounts.parse("LOCAL:0.1"),
+ wireFeeCoveredForExchange: new Set(["http://exchange.localhost/"]),
+ lastDepositFee: Amounts.parse("LOCAL:0.1"),
+ });
+});
+
+function createCandidates(
+ ar: {
+ amount: AmountString;
+ depositFee: AmountString;
+ numAvailable: number;
+ fromExchange: string;
+ }[],
+): AvailableDenom[] {
+ return ar.map((r, idx) => {
+ return {
+ denomPub: {
+ age_mask: 0,
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: "PPP",
+ },
+ denomPubHash: `hash${idx}`,
+ value: r.amount,
+ feeDeposit: r.depositFee,
+ feeRefresh: "LOCAL:0" as AmountString,
+ feeRefund: "LOCAL:0" as AmountString,
+ feeWithdraw: "LOCAL:0" as AmountString,
+ stampExpireDeposit: inTheDistantFuture,
+ stampExpireLegal: inTheDistantFuture,
+ stampExpireWithdraw: inTheDistantFuture,
+ stampStart: inThePast,
+ exchangeBaseUrl: r.fromExchange,
+ numAvailable: r.numAvailable,
+ maxAge: 32,
+ };
+ });
+}
+
+test("p2p: regression STATER", (t) => {
+ const candidates = [
+ {
+ denomPub: {
+ age_mask: 349441,
+ cipher: "RSA",
+ rsa_public_key:
+ "040000WTR9ERP6FYDM4581C1WY4DX6EA6ZP0RKDEY1VCEG1HGZQDB1E1MT0HSPWKVWYY8GN99YG8JV2BQHCV608V3AP00HZ44M4R2RDK3MEG1HY3H5VP2YESFDXC8C2J0BT6E662JJYN4MCFR8Q8ZFD7ZCA8HGBNVG4JMTS5MBDTF9CX3JC25H702K1FG2C54HR48767D18F2H11HMVK7EEF51QRGE08T704VRCNZ6WTM3Z73Z5DW4W26GBEWTDZZ4HX94HRJEH8YENXAW5T5E39TQQN7MZ7HEPB59BQWB0DDMM8MAE274BV3HC2AJVCSXFJSKBAK1B9HKERPWF7Z5556VJG6YJ9236G5SFM3RC22PJM2SXHYBWFV1WBAYF1F2026C0CM5Q3RPQETHCWZTEX8KJ2J1K904002",
+ },
+ denomPubHash:
+ "TF5S4VJ8P3NN0SM5R1KW5MP665KEFMGAT2RPR70BMG0WQ5A72J53GDDE0YSCTWEXHRW8FMMX3X27RQK4D1VH69GVJBYR5RSJY3X5FS8",
+ feeDeposit: "STATER:1",
+ feeRefresh: "STATER:0",
+ feeRefund: "STATER:0",
+ feeWithdraw: "STATER:0",
+ stampExpireDeposit: {
+ t_s: 1772722025,
+ },
+ stampExpireLegal: {
+ t_s: 1961938025,
+ },
+ stampExpireWithdraw: {
+ t_s: 1709650025,
+ },
+ stampStart: {
+ t_s: 1709045225,
+ },
+ value: "STATER:2",
+ exchangeBaseUrl: "https://exchange.taler.grothoff.org/",
+ numAvailable: 6,
+ maxAge: 32,
+ },
+ {
+ denomPub: {
+ age_mask: 349441,
+ cipher: "RSA",
+ rsa_public_key:
+ "040000Y84BTTQCZ28AS2KZ867V05WES3YPN34X51DNF14ADGW2HNG9YFXCCNVQ2JA9ZT3KSBD17ZN9Y71KGWAWEFYMHE0S61DW63WN58VWRXQ92440V1JSZDD7FDTYEVNGG8ZVARVZ4GGF1RCDM93R28M067S5CPRZFCCQBRFFM9YDK2W06WDXE96BDCB8MZEYPHSGK5CTDY6XJE18EMRWYRBAG0H8P6QGQS73REXX66PTJ3MRX3AK3ARZF8417QKMZZPNS1JV5EYPAC7X8R1F9G1GWAQXVVQ2XTA5NMVMNJDJ0KEM93AXD4W2C7XMVJFSQN8RVB9KZ8JXWGN1YJQK7P6476HV896THKQ05QK4F0C65P4HA7QDX84C91F42PZVMH8AMYMA2NBXEYXS0EV8NXZHMZ30JF04002",
+ },
+ denomPubHash:
+ "WCMKBGR8ZKJ62YZXCRNT3EHPFQQ2M0B5CGZXW0PYA76G8PPXJMXZ7Q3WBP2DA3Z4BF21K3X9AG769RYCC39C3PT0R1DCTJA2PRTSHSR",
+ feeDeposit: "STATER:1",
+ feeRefresh: "STATER:0",
+ feeRefund: "STATER:0",
+ feeWithdraw: "STATER:0",
+ stampExpireDeposit: {
+ t_s: 1772722025,
+ },
+ stampExpireLegal: {
+ t_s: 1961938025,
+ },
+ stampExpireWithdraw: {
+ t_s: 1709650025,
+ },
+ stampStart: {
+ t_s: 1709045225,
+ },
+ value: "STATER:1",
+ exchangeBaseUrl: "https://exchange.taler.grothoff.org/",
+ numAvailable: 1,
+ maxAge: 32,
+ },
+ ];
+ const instructedAmount = Amounts.parseOrThrow("STATER:1");
+ const tally = emptyTallyForPeerPayment(instructedAmount);
+ const res = testing_selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ candidates as any,
+ tally,
+ );
+ t.assert(!!res);
+});
diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts
new file mode 100644
index 000000000..6a7d79d83
--- /dev/null
+++ b/packages/taler-wallet-core/src/coinSelection.ts
@@ -0,0 +1,1254 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Selection of coins for payments.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ AccountRestriction,
+ AgeRestriction,
+ AllowedAuditorInfo,
+ AllowedExchangeInfo,
+ AmountJson,
+ Amounts,
+ checkDbInvariant,
+ checkLogicInvariant,
+ CoinStatus,
+ DenominationInfo,
+ ExchangeGlobalFees,
+ ForcedCoinSel,
+ InternationalizedString,
+ j2s,
+ Logger,
+ parsePaytoUri,
+ PayCoinSelection,
+ PaymentInsufficientBalanceDetails,
+ ProspectivePayCoinSelection,
+ SelectedCoin,
+ SelectedProspectiveCoin,
+ strcmp,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+import { getPaymentBalanceDetailsInTx } from "./balance.js";
+import { getAutoRefreshExecuteThreshold } from "./common.js";
+import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js";
+import {
+ ExchangeWireDetails,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import { getDenomInfo, WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("coinSelection.ts");
+
+export type PreviousPayCoins = {
+ coinPub: string;
+ contribution: AmountJson;
+}[];
+
+export interface ExchangeRestrictionSpec {
+ exchanges: AllowedExchangeInfo[];
+ auditors: AllowedAuditorInfo[];
+}
+
+export interface CoinSelectionTally {
+ /**
+ * Amount that still needs to be paid.
+ * May increase during the computation when fees need to be covered.
+ */
+ amountPayRemaining: AmountJson;
+
+ /**
+ * Allowance given by the merchant towards deposit fees
+ * (and wire fees after wire fee limit is exhausted)
+ */
+ amountDepositFeeLimitRemaining: AmountJson;
+
+ customerDepositFees: AmountJson;
+
+ customerWireFees: AmountJson;
+
+ wireFeeCoveredForExchange: Set<string>;
+
+ lastDepositFee: AmountJson;
+}
+
+/**
+ * Account for the fees of spending a coin.
+ */
+function tallyFees(
+ tally: CoinSelectionTally,
+ wireFeesPerExchange: Record<string, AmountJson>,
+ exchangeBaseUrl: string,
+ feeDeposit: AmountJson,
+): void {
+ const currency = tally.amountPayRemaining.currency;
+
+ if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) {
+ const wf =
+ wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.zeroOfCurrency(currency);
+ // The remaining, amortized amount needs to be paid by the
+ // wallet or covered by the deposit fee allowance.
+ let wfRemaining = wf;
+ // This is the amount forgiven via the deposit fee allowance.
+ const wfDepositForgiven = Amounts.min(
+ tally.amountDepositFeeLimitRemaining,
+ wfRemaining,
+ );
+ tally.amountDepositFeeLimitRemaining = Amounts.sub(
+ tally.amountDepositFeeLimitRemaining,
+ wfDepositForgiven,
+ ).amount;
+ wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount;
+ tally.customerWireFees = Amounts.add(
+ tally.customerWireFees,
+ wfRemaining,
+ ).amount;
+ tally.amountPayRemaining = Amounts.add(
+ tally.amountPayRemaining,
+ wfRemaining,
+ ).amount;
+ tally.wireFeeCoveredForExchange.add(exchangeBaseUrl);
+ }
+
+ const dfForgiven = Amounts.min(
+ feeDeposit,
+ tally.amountDepositFeeLimitRemaining,
+ );
+
+ tally.amountDepositFeeLimitRemaining = Amounts.sub(
+ tally.amountDepositFeeLimitRemaining,
+ dfForgiven,
+ ).amount;
+
+ // How much does the user spend on deposit fees for this coin?
+ const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount;
+ tally.customerDepositFees = Amounts.add(
+ tally.customerDepositFees,
+ dfRemaining,
+ ).amount;
+ tally.amountPayRemaining = Amounts.add(
+ tally.amountPayRemaining,
+ dfRemaining,
+ ).amount;
+ tally.lastDepositFee = feeDeposit;
+}
+
+export type SelectPayCoinsResult =
+ | {
+ type: "failure";
+ insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
+ }
+ | { type: "prospective"; result: ProspectivePayCoinSelection }
+ | { type: "success"; coinSel: PayCoinSelection };
+
+async function internalSelectPayCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ]
+ >,
+ req: SelectPayCoinRequestNg,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally }
+ | undefined
+> {
+ const { contractTermsAmount, depositFeeLimit } = req;
+ const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ restrictWireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ requiredMinimumAge: req.requiredMinimumAge,
+ includePendingCoins,
+ },
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `instructed amount: ${Amounts.stringify(req.contractTermsAmount)}`,
+ );
+ logger.trace(`wire fees per exchange: ${j2s(wireFeesPerExchange)}`);
+ logger.trace(`candidates: ${j2s(candidateDenoms)}`);
+ }
+
+ const coinRes: SelectedCoin[] = [];
+ const currency = contractTermsAmount.currency;
+
+ let tally: CoinSelectionTally = {
+ amountPayRemaining: contractTermsAmount,
+ amountDepositFeeLimitRemaining: depositFeeLimit,
+ customerDepositFees: Amounts.zeroOfCurrency(currency),
+ customerWireFees: Amounts.zeroOfCurrency(currency),
+ wireFeeCoveredForExchange: new Set(),
+ lastDepositFee: Amounts.zeroOfCurrency(currency),
+ };
+
+ await maybeRepairCoinSelection(
+ wex,
+ tx,
+ req.prevPayCoins ?? [],
+ coinRes,
+ tally,
+ {
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ );
+
+ let selectedDenom: SelResult | undefined;
+ if (req.forcedSelection) {
+ selectedDenom = selectForced(req, candidateDenoms);
+ } else {
+ // FIXME: Here, we should select coins in a smarter way.
+ // Instead of always spending the next-largest coin,
+ // we should try to find the smallest coin that covers the
+ // amount.
+ selectedDenom = selectGreedy(
+ {
+ wireFeesPerExchange: wireFeesPerExchange,
+ },
+ candidateDenoms,
+ tally,
+ );
+ }
+
+ if (!selectedDenom) {
+ return undefined;
+ }
+ return {
+ sel: selectedDenom,
+ coinRes,
+ tally,
+ };
+}
+
+/**
+ * Select coins to spend under the merchant's constraints.
+ *
+ * The prevPayCoins can be specified to "repair" a coin selection
+ * by adding additional coins, after a broken (e.g. double-spent) coin
+ * has been removed from the selection.
+ */
+export async function selectPayCoins(
+ wex: WalletExecutionContext,
+ req: SelectPayCoinRequestNg,
+): Promise<SelectPayCoinsResult> {
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selecting coins for ${j2s(req)}`);
+ }
+
+ return await wex.db.runReadOnlyTx(
+ [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchanges",
+ "exchangeDetails",
+ "coins",
+ ],
+ async (tx) => {
+ const materialAvSel = await internalSelectPayCoins(wex, tx, req, false);
+
+ if (!materialAvSel) {
+ const prospectiveAvSel = await internalSelectPayCoins(
+ wex,
+ tx,
+ req,
+ true,
+ );
+
+ if (prospectiveAvSel) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvSel.sel)) {
+ const mySel = prospectiveAvSel.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ customerDepositFees: Amounts.stringify(
+ prospectiveAvSel.tally.customerDepositFees,
+ ),
+ customerWireFees: Amounts.stringify(
+ prospectiveAvSel.tally.customerWireFees,
+ ),
+ },
+ } satisfies SelectPayCoinsResult;
+ }
+
+ return {
+ type: "failure",
+ insufficientBalanceDetails: await reportInsufficientBalanceDetails(
+ wex,
+ tx,
+ {
+ restrictExchanges: req.restrictExchanges,
+ instructedAmount: req.contractTermsAmount,
+ requiredMinimumAge: req.requiredMinimumAge,
+ wireMethod: req.restrictWireMethod,
+ depositPaytoUri: req.depositPaytoUri,
+ },
+ ),
+ } satisfies SelectPayCoinsResult;
+ }
+
+ const coinSel = await assembleSelectPayCoinsSuccessResult(
+ tx,
+ materialAvSel.sel,
+ materialAvSel.coinRes,
+ materialAvSel.tally,
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`coin selection: ${j2s(coinSel)}`);
+ }
+
+ return {
+ type: "success",
+ coinSel,
+ };
+ },
+ );
+}
+
+async function maybeRepairCoinSelection(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ prevPayCoins: PreviousPayCoins,
+ coinRes: SelectedCoin[],
+ tally: CoinSelectionTally,
+ feeInfo: {
+ wireFeesPerExchange: Record<string, AmountJson>;
+ },
+): Promise<void> {
+ // Look at existing pay coin selection and tally up
+ for (const prev of prevPayCoins) {
+ const coin = await tx.coins.get(prev.coinPub);
+ if (!coin) {
+ continue;
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+ tallyFees(
+ tally,
+ feeInfo.wireFeesPerExchange,
+ coin.exchangeBaseUrl,
+ Amounts.parseOrThrow(denom.feeDeposit),
+ );
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ prev.contribution,
+ ).amount;
+
+ coinRes.push({
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ denomPubHash: coin.denomPubHash,
+ coinPub: prev.coinPub,
+ contribution: Amounts.stringify(prev.contribution),
+ });
+ }
+}
+
+/**
+ * Returns undefined if the success response could not be assembled,
+ * as not enough coins are actually available.
+ */
+async function assembleSelectPayCoinsSuccessResult(
+ tx: WalletDbReadOnlyTransaction<["coins"]>,
+ finalSel: SelResult,
+ coinRes: SelectedCoin[],
+ tally: CoinSelectionTally,
+): Promise<PayCoinSelection> {
+ for (const dph of Object.keys(finalSel)) {
+ const selInfo = finalSel[dph];
+ const numRequested = selInfo.contributions.length;
+ const query = [
+ selInfo.exchangeBaseUrl,
+ selInfo.denomPubHash,
+ selInfo.maxAge,
+ CoinStatus.Fresh,
+ ];
+ logger.trace(`query: ${j2s(query)}`);
+ const coins =
+ await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
+ query,
+ numRequested,
+ );
+ if (coins.length != numRequested) {
+ throw Error(
+ `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
+ );
+ }
+
+ for (let i = 0; i < selInfo.contributions.length; i++) {
+ coinRes.push({
+ denomPubHash: coins[i].denomPubHash,
+ coinPub: coins[i].coinPub,
+ contribution: Amounts.stringify(selInfo.contributions[i]),
+ exchangeBaseUrl: coins[i].exchangeBaseUrl,
+ });
+ }
+ }
+
+ return {
+ coins: coinRes,
+ customerDepositFees: Amounts.stringify(tally.customerDepositFees),
+ customerWireFees: Amounts.stringify(tally.customerWireFees),
+ };
+}
+
+interface ReportInsufficientBalanceRequest {
+ instructedAmount: AmountJson;
+ requiredMinimumAge: number | undefined;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ wireMethod: string | undefined;
+ depositPaytoUri: string | undefined;
+}
+
+export async function reportInsufficientBalanceDetails(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "coinAvailability",
+ "exchanges",
+ "exchangeDetails",
+ "refreshGroups",
+ "denominations",
+ ]
+ >,
+ req: ReportInsufficientBalanceRequest,
+): Promise<PaymentInsufficientBalanceDetails> {
+ const details = await getPaymentBalanceDetailsInTx(wex, tx, {
+ restrictExchanges: req.restrictExchanges,
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined,
+ currency: Amounts.currencyOf(req.instructedAmount),
+ minAge: req.requiredMinimumAge ?? 0,
+ depositPaytoUri: req.depositPaytoUri,
+ });
+ const perExchange: PaymentInsufficientBalanceDetails["perExchange"] = {};
+ const exchanges = await tx.exchanges.getAll();
+
+ for (const exch of exchanges) {
+ if (!exch.detailsPointer) {
+ continue;
+ }
+ let missingGlobalFees = false;
+ const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
+ if (!exchWire) {
+ missingGlobalFees = true;
+ } else {
+ const globalFees = getGlobalFees(exchWire);
+ if (!globalFees) {
+ missingGlobalFees = true;
+ }
+ }
+ const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, {
+ restrictExchanges: {
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.baseUrl,
+ exchangePub: exch.detailsPointer?.masterPublicKey,
+ },
+ ],
+ auditors: [],
+ },
+ restrictWireMethods: req.wireMethod ? [req.wireMethod] : [],
+ currency: Amounts.currencyOf(req.instructedAmount),
+ minAge: req.requiredMinimumAge ?? 0,
+ depositPaytoUri: req.depositPaytoUri,
+ });
+ perExchange[exch.baseUrl] = {
+ balanceAvailable: Amounts.stringify(exchDet.balanceAvailable),
+ balanceMaterial: Amounts.stringify(exchDet.balanceMaterial),
+ balanceExchangeDepositable: Amounts.stringify(
+ exchDet.balanceExchangeDepositable,
+ ),
+ balanceAgeAcceptable: Amounts.stringify(exchDet.balanceAgeAcceptable),
+ balanceReceiverAcceptable: Amounts.stringify(
+ exchDet.balanceReceiverAcceptable,
+ ),
+ balanceReceiverDepositable: Amounts.stringify(
+ exchDet.balanceReceiverDepositable,
+ ),
+ maxEffectiveSpendAmount: Amounts.stringify(
+ exchDet.maxEffectiveSpendAmount,
+ ),
+ missingGlobalFees,
+ };
+ }
+
+ return {
+ amountRequested: Amounts.stringify(req.instructedAmount),
+ balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable),
+ balanceAvailable: Amounts.stringify(details.balanceAvailable),
+ balanceMaterial: Amounts.stringify(details.balanceMaterial),
+ balanceReceiverAcceptable: Amounts.stringify(
+ details.balanceReceiverAcceptable,
+ ),
+ balanceExchangeDepositable: Amounts.stringify(
+ details.balanceExchangeDepositable,
+ ),
+ balanceReceiverDepositable: Amounts.stringify(
+ details.balanceReceiverDepositable,
+ ),
+ maxEffectiveSpendAmount: Amounts.stringify(details.maxEffectiveSpendAmount),
+ perExchange,
+ };
+}
+
+function makeAvailabilityKey(
+ exchangeBaseUrl: string,
+ denomPubHash: string,
+ maxAge: number,
+): string {
+ return `${denomPubHash};${maxAge};${exchangeBaseUrl}`;
+}
+
+/**
+ * Selection result.
+ */
+interface SelResult {
+ /**
+ * Map from an availability key
+ * to an array of contributions.
+ */
+ [avKey: string]: {
+ exchangeBaseUrl: string;
+ denomPubHash: string;
+ maxAge: number;
+ contributions: AmountJson[];
+ };
+}
+
+export function testing_selectGreedy(
+ ...args: Parameters<typeof selectGreedy>
+): ReturnType<typeof selectGreedy> {
+ return selectGreedy(...args);
+}
+
+export interface SelectGreedyRequest {
+ wireFeesPerExchange: Record<string, AmountJson>;
+}
+
+function selectGreedy(
+ req: SelectGreedyRequest,
+ candidateDenoms: AvailableDenom[],
+ tally: CoinSelectionTally,
+): SelResult | undefined {
+ const selectedDenom: SelResult = {};
+ for (const denom of candidateDenoms) {
+ const contributions: AmountJson[] = [];
+
+ // Don't use this coin if depositing it is more expensive than
+ // the amount it would give the merchant.
+ if (Amounts.cmp(denom.feeDeposit, denom.value) > 0) {
+ tally.lastDepositFee = Amounts.parseOrThrow(denom.feeDeposit);
+ continue;
+ }
+
+ for (
+ let i = 0;
+ i < denom.numAvailable && Amounts.isNonZero(tally.amountPayRemaining);
+ i++
+ ) {
+ tallyFees(
+ tally,
+ req.wireFeesPerExchange,
+ denom.exchangeBaseUrl,
+ Amounts.parseOrThrow(denom.feeDeposit),
+ );
+
+ const coinSpend = Amounts.max(
+ Amounts.min(tally.amountPayRemaining, denom.value),
+ denom.feeDeposit,
+ );
+
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ coinSpend,
+ ).amount;
+
+ contributions.push(coinSpend);
+ }
+
+ if (contributions.length) {
+ const avKey = makeAvailabilityKey(
+ denom.exchangeBaseUrl,
+ denom.denomPubHash,
+ denom.maxAge,
+ );
+ let sd = selectedDenom[avKey];
+ if (!sd) {
+ sd = {
+ contributions: [],
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ maxAge: denom.maxAge,
+ };
+ }
+ sd.contributions.push(...contributions);
+ selectedDenom[avKey] = sd;
+ }
+ }
+ return Amounts.isZero(tally.amountPayRemaining) ? selectedDenom : undefined;
+}
+
+function selectForced(
+ req: SelectPayCoinRequestNg,
+ candidateDenoms: AvailableDenom[],
+): SelResult | undefined {
+ const selectedDenom: SelResult = {};
+
+ const forcedSelection = req.forcedSelection;
+ checkLogicInvariant(!!forcedSelection);
+
+ for (const forcedCoin of forcedSelection.coins) {
+ let found = false;
+ for (const aci of candidateDenoms) {
+ if (aci.numAvailable <= 0) {
+ continue;
+ }
+ if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
+ aci.numAvailable--;
+ const avKey = makeAvailabilityKey(
+ aci.exchangeBaseUrl,
+ aci.denomPubHash,
+ aci.maxAge,
+ );
+ let sd = selectedDenom[avKey];
+ if (!sd) {
+ sd = {
+ contributions: [],
+ denomPubHash: aci.denomPubHash,
+ exchangeBaseUrl: aci.exchangeBaseUrl,
+ maxAge: aci.maxAge,
+ };
+ }
+ sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value));
+ selectedDenom[avKey] = sd;
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ throw Error("can't find coin for forced coin selection");
+ }
+ }
+ return selectedDenom;
+}
+
+export function checkAccountRestriction(
+ paytoUri: string,
+ restrictions: AccountRestriction[],
+): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } {
+ for (const myRestriction of restrictions) {
+ switch (myRestriction.type) {
+ case "deny":
+ return { ok: false };
+ case "regex":
+ const regex = new RegExp(myRestriction.payto_regex);
+ if (!regex.test(paytoUri)) {
+ return {
+ ok: false,
+ hint: myRestriction.human_hint,
+ hintI18n: myRestriction.human_hint_i18n,
+ };
+ }
+ }
+ }
+ return {
+ ok: true,
+ };
+}
+
+export interface SelectPayCoinRequestNg {
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ restrictWireMethod: string;
+ contractTermsAmount: AmountJson;
+ depositFeeLimit: AmountJson;
+ prevPayCoins?: PreviousPayCoins;
+ requiredMinimumAge?: number;
+ forcedSelection?: ForcedCoinSel;
+
+ /**
+ * Deposit payto URI, in case we already know the account that
+ * will be deposited into.
+ *
+ * That is typically the case when the wallet does a deposit to
+ * return funds to the user's own bank account.
+ */
+ depositPaytoUri?: string;
+}
+
+export type AvailableDenom = DenominationInfo & {
+ maxAge: number;
+ numAvailable: number;
+};
+
+export function findMatchingWire(
+ wireMethod: string,
+ depositPaytoUri: string | undefined,
+ exchangeWireDetails: ExchangeWireDetails,
+): { wireFee: AmountJson } | undefined {
+ for (const acc of exchangeWireDetails.wireInfo.accounts) {
+ const pp = parsePaytoUri(acc.payto_uri);
+ checkLogicInvariant(!!pp);
+ if (pp.targetType !== wireMethod) {
+ continue;
+ }
+ const wireFeeStr = exchangeWireDetails.wireInfo.feesForType[
+ wireMethod
+ ]?.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ })?.wireFee;
+
+ if (!wireFeeStr) {
+ continue;
+ }
+
+ let debitAccountCheckOk = false;
+ if (depositPaytoUri) {
+ // FIXME: We should somehow propagate the hint here!
+ const checkResult = checkAccountRestriction(
+ depositPaytoUri,
+ acc.debit_restrictions,
+ );
+ if (checkResult.ok) {
+ debitAccountCheckOk = true;
+ }
+ } else {
+ debitAccountCheckOk = true;
+ }
+
+ if (!debitAccountCheckOk) {
+ continue;
+ }
+
+ return {
+ wireFee: Amounts.parseOrThrow(wireFeeStr),
+ };
+ }
+ return undefined;
+}
+
+function checkExchangeAccepted(
+ exchangeDetails: ExchangeWireDetails,
+ exchangeRestrictions: ExchangeRestrictionSpec | undefined,
+): boolean {
+ if (!exchangeRestrictions) {
+ return true;
+ }
+ let accepted = false;
+ for (const allowedExchange of exchangeRestrictions.exchanges) {
+ if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
+ accepted = true;
+ break;
+ }
+ }
+ for (const allowedAuditor of exchangeRestrictions.auditors) {
+ for (const providedAuditor of exchangeDetails.auditors) {
+ if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
+ accepted = true;
+ break;
+ }
+ }
+ }
+ return accepted;
+}
+
+interface SelectPayCandidatesRequest {
+ instructedAmount: AmountJson;
+ restrictWireMethod: string | undefined;
+ depositPaytoUri?: string;
+ restrictExchanges: ExchangeRestrictionSpec | undefined;
+ requiredMinimumAge?: number;
+
+ /**
+ * If set to true, the coin selection will also use coins that are not
+ * materially available yet, but that are expected to become available
+ * as the output of a refresh operation.
+ */
+ includePendingCoins: boolean;
+}
+
+async function selectPayCandidates(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ ["exchanges", "coinAvailability", "exchangeDetails", "denominations"]
+ >,
+ req: SelectPayCandidatesRequest,
+): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
+ // FIXME: Use the existing helper (from balance.ts) to
+ // get acceptable exchanges.
+ logger.shouldLogTrace() &&
+ logger.trace(`selecting available coin candidates for ${j2s(req)}`);
+ const denoms: AvailableDenom[] = [];
+ const exchanges = await tx.exchanges.iter().toArray();
+ const wfPerExchange: Record<string, AmountJson> = {};
+ for (const exchange of exchanges) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchange.baseUrl,
+ );
+ // 1. exchange has same currency
+ if (exchangeDetails?.currency !== req.instructedAmount.currency) {
+ logger.shouldLogTrace() &&
+ logger.trace(`skipping ${exchange.baseUrl} due to currency mismatch`);
+ continue;
+ }
+
+ // 2. Exchange supports wire method (only for pay/deposit)
+ if (req.restrictWireMethod) {
+ const wire = findMatchingWire(
+ req.restrictWireMethod,
+ req.depositPaytoUri,
+ exchangeDetails,
+ );
+ if (!wire) {
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `skipping ${exchange.baseUrl} due to missing wire info mismatch`,
+ );
+ }
+ continue;
+ }
+ wfPerExchange[exchange.baseUrl] = wire.wireFee;
+ }
+
+ // 3. exchange is trusted in the exchange list or auditor list
+ let accepted = checkExchangeAccepted(
+ exchangeDetails,
+ req.restrictExchanges,
+ );
+ if (!accepted) {
+ if (logger.shouldLogTrace()) {
+ logger.trace(`skipping ${exchange.baseUrl} due to unacceptability`);
+ }
+ continue;
+ }
+
+ // 4. filter coins restricted by age
+ let ageLower = 0;
+ let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
+ if (req.requiredMinimumAge) {
+ ageLower = req.requiredMinimumAge;
+ }
+
+ const myExchangeCoins =
+ await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
+ GlobalIDB.KeyRange.bound(
+ [exchangeDetails.exchangeBaseUrl, ageLower, 1],
+ [exchangeDetails.exchangeBaseUrl, ageUpper, Number.MAX_SAFE_INTEGER],
+ ),
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `exchange ${exchange.baseUrl} has ${myExchangeCoins.length} candidate records`,
+ );
+ }
+
+ let numUsable = 0;
+
+ // 5. save denoms with how many coins are available
+ // FIXME: Check that the individual denomination is audited!
+ // FIXME: Should we exclude denominations that are
+ // not spendable anymore?
+ for (const coinAvail of myExchangeCoins) {
+ const denom = await tx.denominations.get([
+ coinAvail.exchangeBaseUrl,
+ coinAvail.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ if (denom.isRevoked) {
+ logger.trace("denom is revoked");
+ continue;
+ }
+ if (!denom.isOffered) {
+ logger.trace("denom is unoffered");
+ continue;
+ }
+ numUsable++;
+ let numAvailable = coinAvail.freshCoinCount ?? 0;
+ if (req.includePendingCoins) {
+ numAvailable += coinAvail.pendingRefreshOutputCount ?? 0;
+ }
+ denoms.push({
+ ...DenominationRecord.toDenomInfo(denom),
+ numAvailable,
+ maxAge: coinAvail.maxAge,
+ });
+ }
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `exchange ${exchange.baseUrl} has ${numUsable} candidate records with usable denominations`,
+ );
+ }
+ }
+ // Sort by available amount (descending), deposit fee (ascending) and
+ // denomPub (ascending) if deposit fee is the same
+ // (to guarantee deterministic results)
+ denoms.sort(
+ (o1, o2) =>
+ -Amounts.cmp(o1.value, o2.value) ||
+ Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
+ strcmp(o1.denomPubHash, o2.denomPubHash),
+ );
+ return [denoms, wfPerExchange];
+}
+
+export interface PeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
+
+ /**
+ * Info of Coins that were selected.
+ */
+ coins: SelectedCoin[];
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
+
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
+export interface ProspectivePeerCoinSelectionDetails {
+ exchangeBaseUrl: string;
+
+ prospectiveCoins: SelectedProspectiveCoin[];
+
+ /**
+ * How much of the deposit fees is the customer paying?
+ */
+ depositFees: AmountJson;
+
+ maxExpirationDate: TalerProtocolTimestamp;
+}
+
+export type SelectPeerCoinsResult =
+ | { type: "success"; result: PeerCoinSelectionDetails }
+ // Successful, but using coins that are not materially available yet.
+ | { type: "prospective"; result: ProspectivePeerCoinSelectionDetails }
+ | {
+ type: "failure";
+ insufficientBalanceDetails: PaymentInsufficientBalanceDetails;
+ };
+
+export interface PeerCoinSelectionRequest {
+ instructedAmount: AmountJson;
+
+ /**
+ * Instruct the coin selection to repair this coin
+ * selection instead of selecting completely new coins.
+ */
+ repair?: PreviousPayCoins;
+}
+
+export async function computeCoinSelMaxExpirationDate(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["coins", "denominations"]>,
+ selectedDenom: SelResult,
+): Promise<TalerProtocolTimestamp> {
+ let minAutorefreshExecuteThreshold = TalerProtocolTimestamp.never();
+ for (const dph of Object.keys(selectedDenom)) {
+ const selInfo = selectedDenom[dph];
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ selInfo.exchangeBaseUrl,
+ selInfo.denomPubHash,
+ );
+ if (!denom) {
+ continue;
+ }
+ // Compute earliest time that a selected denom
+ // would have its coins auto-refreshed.
+ minAutorefreshExecuteThreshold = TalerProtocolTimestamp.min(
+ minAutorefreshExecuteThreshold,
+ AbsoluteTime.toProtocolTimestamp(
+ getAutoRefreshExecuteThreshold({
+ stampExpireDeposit: denom.stampExpireDeposit,
+ stampExpireWithdraw: denom.stampExpireWithdraw,
+ }),
+ ),
+ );
+ }
+ return minAutorefreshExecuteThreshold;
+}
+
+export function emptyTallyForPeerPayment(
+ instructedAmount: AmountJson,
+): CoinSelectionTally {
+ const currency = instructedAmount.currency;
+ const zero = Amounts.zeroOfCurrency(currency);
+ return {
+ amountPayRemaining: instructedAmount,
+ customerDepositFees: zero,
+ lastDepositFee: zero,
+ amountDepositFeeLimitRemaining: zero,
+ customerWireFees: zero,
+ wireFeeCoveredForExchange: new Set(),
+ };
+}
+
+function getGlobalFees(
+ wireDetails: ExchangeWireDetails,
+): ExchangeGlobalFees | undefined {
+ const now = AbsoluteTime.now();
+ for (let gf of wireDetails.globalFees) {
+ const isActive = AbsoluteTime.isBetween(
+ now,
+ AbsoluteTime.fromProtocolTimestamp(gf.startDate),
+ AbsoluteTime.fromProtocolTimestamp(gf.endDate),
+ );
+ if (!isActive) {
+ continue;
+ }
+ return gf;
+ }
+ return undefined;
+}
+
+async function internalSelectPeerCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ]
+ >,
+ req: PeerCoinSelectionRequest,
+ exch: ExchangeWireDetails,
+ includePendingCoins: boolean,
+): Promise<
+ | { sel: SelResult; tally: CoinSelectionTally; resCoins: SelectedCoin[] }
+ | undefined
+> {
+ const candidatesRes = await selectPayCandidates(wex, tx, {
+ instructedAmount: req.instructedAmount,
+ restrictExchanges: {
+ auditors: [],
+ exchanges: [
+ {
+ exchangeBaseUrl: exch.exchangeBaseUrl,
+ exchangePub: exch.masterPublicKey,
+ },
+ ],
+ },
+ restrictWireMethod: undefined,
+ includePendingCoins,
+ });
+ const candidates = candidatesRes[0];
+ if (logger.shouldLogTrace()) {
+ logger.trace(`peer payment candidate coins: ${j2s(candidates)}`);
+ }
+ const tally = emptyTallyForPeerPayment(req.instructedAmount);
+ const resCoins: SelectedCoin[] = [];
+
+ await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, {
+ wireFeesPerExchange: {},
+ });
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`candidates: ${j2s(candidates)}`);
+ logger.trace(`instructedAmount: ${j2s(req.instructedAmount)}`);
+ logger.trace(`tally: ${j2s(tally)}`);
+ }
+
+ const selRes = selectGreedy(
+ {
+ wireFeesPerExchange: {},
+ },
+ candidates,
+ tally,
+ );
+ if (!selRes) {
+ return undefined;
+ }
+
+ return {
+ sel: selRes,
+ tally,
+ resCoins,
+ };
+}
+
+export async function selectPeerCoins(
+ wex: WalletExecutionContext,
+ req: PeerCoinSelectionRequest,
+): Promise<SelectPeerCoinsResult> {
+ const instructedAmount = req.instructedAmount;
+ if (Amounts.isZero(instructedAmount)) {
+ // Other parts of the code assume that we have at least
+ // one coin to spend.
+ throw new Error("amount of zero not allowed");
+ }
+
+ return await wex.db.runReadWriteTx(
+ [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "exchangeDetails",
+ ],
+ async (tx): Promise<SelectPeerCoinsResult> => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ const currency = Amounts.currencyOf(instructedAmount);
+ for (const exch of exchanges) {
+ if (exch.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl);
+ if (!exchWire) {
+ continue;
+ }
+ const globalFees = getGlobalFees(exchWire);
+ if (!globalFees) {
+ continue;
+ }
+
+ const avRes = await internalSelectPeerCoins(
+ wex,
+ tx,
+ req,
+ exchWire,
+ false,
+ );
+
+ if (!avRes) {
+ // Try to see if we can do a prospective selection
+ const prospectiveAvRes = await internalSelectPeerCoins(
+ wex,
+ tx,
+ req,
+ exchWire,
+ true,
+ );
+ if (prospectiveAvRes) {
+ const prospectiveCoins: SelectedProspectiveCoin[] = [];
+ for (const avKey of Object.keys(prospectiveAvRes.sel)) {
+ const mySel = prospectiveAvRes.sel[avKey];
+ for (const contrib of mySel.contributions) {
+ prospectiveCoins.push({
+ denomPubHash: mySel.denomPubHash,
+ contribution: Amounts.stringify(contrib),
+ exchangeBaseUrl: mySel.exchangeBaseUrl,
+ });
+ }
+ }
+ const maxExpirationDate = await computeCoinSelMaxExpirationDate(
+ wex,
+ tx,
+ prospectiveAvRes.sel,
+ );
+ return {
+ type: "prospective",
+ result: {
+ prospectiveCoins,
+ depositFees: prospectiveAvRes.tally.customerDepositFees,
+ exchangeBaseUrl: exch.baseUrl,
+ maxExpirationDate,
+ },
+ };
+ }
+ } else if (avRes) {
+ const r = await assembleSelectPayCoinsSuccessResult(
+ tx,
+ avRes.sel,
+ avRes.resCoins,
+ avRes.tally,
+ );
+
+ const maxExpirationDate = await computeCoinSelMaxExpirationDate(
+ wex,
+ tx,
+ avRes.sel,
+ );
+
+ return {
+ type: "success",
+ result: {
+ coins: r.coins,
+ depositFees: Amounts.parseOrThrow(r.customerDepositFees),
+ exchangeBaseUrl: exch.baseUrl,
+ maxExpirationDate,
+ },
+ };
+ }
+ }
+ const insufficientBalanceDetails = await reportInsufficientBalanceDetails(
+ wex,
+ tx,
+ {
+ restrictExchanges: undefined,
+ instructedAmount: req.instructedAmount,
+ requiredMinimumAge: undefined,
+ wireMethod: undefined,
+ depositPaytoUri: undefined,
+ },
+ );
+ return {
+ type: "failure",
+ insufficientBalanceDetails,
+ };
+ },
+ );
+}
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
index dd8542def..6d116c47e 100644
--- a/packages/taler-wallet-core/src/common.ts
+++ b/packages/taler-wallet-core/src/common.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
+ (C) 2022 GNUnet e.V.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,142 +15,757 @@
*/
/**
- * Common interface of the internal wallet state. This object is passed
- * to the various operations (exchange management, withdrawal, refresh, reserve
- * management, etc.).
- *
- * Some operations can be accessed via this state object. This allows mutual
- * recursion between operations, without having cycling dependencies between
- * the respective TypeScript files.
- *
- * (You can think of this as a "header file" for the wallet implementation.)
- */
-
-/**
* Imports.
*/
-import { WalletNotification, BalancesResponse } from "@gnu-taler/taler-util";
-import { CryptoApi } from "./crypto/workers/cryptoApi.js";
-import { ExchangeDetailsRecord, ExchangeRecord, WalletStoresV1 } from "./db.js";
-import { PendingOperationsResponse } from "./pending-types.js";
-import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
-import { HttpRequestLibrary } from "./util/http.js";
-import { AsyncCondition } from "./util/promiseUtils.js";
import {
- DbAccess,
- GetReadOnlyAccess,
- GetReadWriteAccess,
-} from "./util/query.js";
-import { TimerGroup } from "./util/timer.js";
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ CoinRefreshRequest,
+ CoinStatus,
+ Duration,
+ ExchangeEntryState,
+ ExchangeEntryStatus,
+ ExchangeTosStatus,
+ ExchangeUpdateStatus,
+ Logger,
+ RefreshReason,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TombstoneIdStr,
+ TransactionIdStr,
+ assertUnreachable,
+ checkDbInvariant,
+ checkLogicInvariant,
+ durationMul,
+} from "@gnu-taler/taler-util";
+import {
+ BackupProviderRecord,
+ CoinRecord,
+ DbPreciseTimestamp,
+ DepositGroupRecord,
+ ExchangeEntryDbRecordStatus,
+ ExchangeEntryDbUpdateStatus,
+ ExchangeEntryRecord,
+ PeerPullCreditRecord,
+ PeerPullPaymentIncomingRecord,
+ PeerPushDebitRecord,
+ PeerPushPaymentIncomingRecord,
+ PurchaseRecord,
+ RecoupGroupRecord,
+ RefreshGroupRecord,
+ RewardRecord,
+ WalletDbReadWriteTransaction,
+ WithdrawalGroupRecord,
+ timestampPreciseToDb,
+} from "./db.js";
+import { createRefreshGroup } from "./refresh.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+
+const logger = new Logger("operations/common.ts");
+
+export interface CoinsSpendInfo {
+ coinPubs: string[];
+ contributions: AmountJson[];
+ refreshReason: RefreshReason;
+ /**
+ * Identifier for what the coin has been spent for.
+ */
+ allocationId: TransactionIdStr;
+}
+
+export async function makeCoinsVisible(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["coins", "coinAvailability"]>,
+ transactionId: string,
+): Promise<void> {
+ const coins =
+ await tx.coins.indexes.bySourceTransactionId.getAll(transactionId);
+ for (const coinRecord of coins) {
+ if (!coinRecord.visible) {
+ coinRecord.visible = 1;
+ await tx.coins.put(coinRecord);
+ const ageRestriction = coinRecord.maxAge;
+ const car = await tx.coinAvailability.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ageRestriction,
+ ]);
+ if (!car) {
+ logger.error("missing coin availability record");
+ continue;
+ }
+ const visCount = car.visibleCoinCount ?? 0;
+ car.visibleCoinCount = visCount + 1;
+ await tx.coinAvailability.put(car);
+ }
+ }
+}
+
+export async function makeCoinAvailable(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["coins", "coinAvailability", "denominations"]
+ >,
+ coinRecord: CoinRecord,
+): Promise<void> {
+ checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
+ const existingCoin = await tx.coins.get(coinRecord.coinPub);
+ if (existingCoin) {
+ return;
+ }
+ const denom = await tx.denominations.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ const ageRestriction = coinRecord.maxAge;
+ let car = await tx.coinAvailability.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ageRestriction,
+ ]);
+ if (!car) {
+ car = {
+ maxAge: ageRestriction,
+ value: denom.value,
+ currency: denom.currency,
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ freshCoinCount: 0,
+ visibleCoinCount: 0,
+ };
+ }
+ car.freshCoinCount++;
+ await tx.coins.put(coinRecord);
+ await tx.coinAvailability.put(car);
+}
+
+export async function spendCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ ]
+ >,
+ csi: CoinsSpendInfo,
+): Promise<void> {
+ if (csi.coinPubs.length != csi.contributions.length) {
+ throw Error("assertion failed");
+ }
+ if (csi.coinPubs.length === 0) {
+ return;
+ }
+ let refreshCoinPubs: CoinRefreshRequest[] = [];
+ for (let i = 0; i < csi.coinPubs.length; i++) {
+ const coin = await tx.coins.get(csi.coinPubs[i]);
+ if (!coin) {
+ throw Error("coin allocated for payment doesn't exist anymore");
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denom);
+ const coinAvailability = await tx.coinAvailability.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ coin.maxAge,
+ ]);
+ checkDbInvariant(!!coinAvailability);
+ const contrib = csi.contributions[i];
+ if (coin.status !== CoinStatus.Fresh) {
+ const alloc = coin.spendAllocation;
+ if (!alloc) {
+ continue;
+ }
+ if (alloc.id !== csi.allocationId) {
+ // FIXME: assign error code
+ logger.info("conflicting coin allocation ID");
+ logger.info(`old ID: ${alloc.id}, new ID: ${csi.allocationId}`);
+ throw Error("conflicting coin allocation (id)");
+ }
+ if (0 !== Amounts.cmp(alloc.amount, contrib)) {
+ // FIXME: assign error code
+ throw Error("conflicting coin allocation (contrib)");
+ }
+ continue;
+ }
+ coin.status = CoinStatus.Dormant;
+ coin.spendAllocation = {
+ id: csi.allocationId,
+ amount: Amounts.stringify(contrib),
+ };
+ const remaining = Amounts.sub(denom.value, contrib);
+ if (remaining.saturated) {
+ throw Error("not enough remaining balance on coin for payment");
+ }
+ refreshCoinPubs.push({
+ amount: Amounts.stringify(remaining.amount),
+ coinPub: coin.coinPub,
+ });
+ checkDbInvariant(!!coinAvailability);
+ if (coinAvailability.freshCoinCount === 0) {
+ throw Error(
+ `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
+ );
+ }
+ coinAvailability.freshCoinCount--;
+ if (coin.visible) {
+ if (!coinAvailability.visibleCoinCount) {
+ logger.error("coin availability inconsistent");
+ } else {
+ coinAvailability.visibleCoinCount--;
+ }
+ }
+ await tx.coins.put(coin);
+ await tx.coinAvailability.put(coinAvailability);
+ }
+
+ await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(csi.contributions[0]),
+ refreshCoinPubs,
+ csi.refreshReason,
+ csi.allocationId,
+ );
+}
-export const EXCHANGE_COINS_LOCK = "exchange-coins-lock";
-export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock";
+export enum TombstoneTag {
+ DeleteWithdrawalGroup = "delete-withdrawal-group",
+ DeleteReserve = "delete-reserve",
+ DeletePayment = "delete-payment",
+ DeleteReward = "delete-reward",
+ DeleteRefreshGroup = "delete-refresh-group",
+ DeleteDepositGroup = "delete-deposit-group",
+ DeleteRefund = "delete-refund",
+ DeletePeerPullDebit = "delete-peer-pull-debit",
+ DeletePeerPushDebit = "delete-peer-push-debit",
+ DeletePeerPullCredit = "delete-peer-pull-credit",
+ DeletePeerPushCredit = "delete-peer-push-credit",
+}
+
+export function getExchangeTosStatusFromRecord(
+ exchange: ExchangeEntryRecord,
+): ExchangeTosStatus {
+ if (!exchange.tosAcceptedEtag) {
+ return ExchangeTosStatus.Proposed;
+ }
+ if (exchange.tosAcceptedEtag == exchange.tosCurrentEtag) {
+ return ExchangeTosStatus.Accepted;
+ }
+ return ExchangeTosStatus.Proposed;
+}
-export interface TrustInfo {
- isTrusted: boolean;
- isAudited: boolean;
+export function getExchangeUpdateStatusFromRecord(
+ r: ExchangeEntryRecord,
+): ExchangeUpdateStatus {
+ switch (r.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ return ExchangeUpdateStatus.UnavailableUpdate;
+ case ExchangeEntryDbUpdateStatus.Initial:
+ return ExchangeUpdateStatus.Initial;
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ return ExchangeUpdateStatus.InitialUpdate;
+ case ExchangeEntryDbUpdateStatus.Ready:
+ return ExchangeUpdateStatus.Ready;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ return ExchangeUpdateStatus.ReadyUpdate;
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ return ExchangeUpdateStatus.Suspended;
+ default:
+ assertUnreachable(r.updateStatus);
+ }
+}
+
+export function getExchangeEntryStatusFromRecord(
+ r: ExchangeEntryRecord,
+): ExchangeEntryStatus {
+ switch (r.entryStatus) {
+ case ExchangeEntryDbRecordStatus.Ephemeral:
+ return ExchangeEntryStatus.Ephemeral;
+ case ExchangeEntryDbRecordStatus.Preset:
+ return ExchangeEntryStatus.Preset;
+ case ExchangeEntryDbRecordStatus.Used:
+ return ExchangeEntryStatus.Used;
+ default:
+ assertUnreachable(r.entryStatus);
+ }
}
/**
- * Interface for exchange-related operations.
+ * Compute the state of an exchange entry from the DB
+ * record.
*/
-export interface ExchangeOperations {
- // FIXME: Should other operations maybe always use
- // updateExchangeFromUrl?
- getExchangeDetails(
- tx: GetReadOnlyAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- exchangeBaseUrl: string,
- ): Promise<ExchangeDetailsRecord | undefined>;
- getExchangeTrust(
- ws: InternalWalletState,
- exchangeInfo: ExchangeRecord,
- ): Promise<TrustInfo>;
- updateExchangeFromUrl(
- ws: InternalWalletState,
- baseUrl: string,
- acceptedFormat?: string[],
- forceNow?: boolean,
- ): Promise<{
- exchange: ExchangeRecord;
- exchangeDetails: ExchangeDetailsRecord;
- }>;
-}
-
-export interface RecoupOperations {
- createRecoupGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- recoupGroups: typeof WalletStoresV1.recoupGroups;
- denominations: typeof WalletStoresV1.denominations;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coins: typeof WalletStoresV1.coins;
- }>,
- coinPubs: string[],
- ): Promise<string>;
- processRecoupGroup(
- ws: InternalWalletState,
- recoupGroupId: string,
- forceNow?: boolean,
- ): Promise<void>;
-}
-
-export type NotificationListener = (n: WalletNotification) => void;
+export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState {
+ return {
+ exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
+ exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
+ tosStatus: getExchangeTosStatusFromRecord(r),
+ };
+}
+
+export type ParsedTombstone =
+ | {
+ tag: TombstoneTag.DeleteWithdrawalGroup;
+ withdrawalGroupId: string;
+ }
+ | { tag: TombstoneTag.DeleteRefund; refundGroupId: string }
+ | { tag: TombstoneTag.DeleteReserve; reservePub: string }
+ | { tag: TombstoneTag.DeleteRefreshGroup; refreshGroupId: string }
+ | { tag: TombstoneTag.DeleteReward; walletTipId: string }
+ | { tag: TombstoneTag.DeletePayment; proposalId: string };
+
+export function constructTombstone(p: ParsedTombstone): TombstoneIdStr {
+ switch (p.tag) {
+ case TombstoneTag.DeleteWithdrawalGroup:
+ return `tmb:${p.tag}:${p.withdrawalGroupId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteRefund:
+ return `tmb:${p.tag}:${p.refundGroupId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteReserve:
+ return `tmb:${p.tag}:${p.reservePub}` as TombstoneIdStr;
+ case TombstoneTag.DeletePayment:
+ return `tmb:${p.tag}:${p.proposalId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteRefreshGroup:
+ return `tmb:${p.tag}:${p.refreshGroupId}` as TombstoneIdStr;
+ case TombstoneTag.DeleteReward:
+ return `tmb:${p.tag}:${p.walletTipId}` as TombstoneIdStr;
+ default:
+ assertUnreachable(p);
+ }
+}
/**
- * Internal, shard wallet state that is used by the implementation
- * of wallet operations.
- *
- * FIXME: This should not be exported anywhere from the taler-wallet-core package,
- * as it's an opaque implementation detail.
+ * Uniform interface for a particular wallet transaction.
*/
-export interface InternalWalletState {
- memoProcessReserve: AsyncOpMemoMap<void>;
- memoMakePlanchet: AsyncOpMemoMap<void>;
- memoGetPending: AsyncOpMemoSingle<PendingOperationsResponse>;
- memoGetBalance: AsyncOpMemoSingle<BalancesResponse>;
- memoProcessRefresh: AsyncOpMemoMap<void>;
- memoProcessRecoup: AsyncOpMemoMap<void>;
- memoProcessDeposit: AsyncOpMemoMap<void>;
- cryptoApi: CryptoApi;
-
- timerGroup: TimerGroup;
- stopped: boolean;
+export interface TransactionManager {
+ get taskId(): TaskIdStr;
+ get transactionId(): TransactionIdStr;
+ fail(): Promise<void>;
+ abort(): Promise<void>;
+ suspend(): Promise<void>;
+ resume(): Promise<void>;
+ process(): Promise<TaskRunResult>;
+}
+
+export enum TaskRunResultType {
+ Finished = "finished",
+ Backoff = "backoff",
+ Progress = "progress",
+ Error = "error",
+ LongpollReturnedPending = "longpoll-returned-pending",
+ ScheduleLater = "schedule-later",
+}
+
+export type TaskRunResult =
+ | TaskRunFinishedResult
+ | TaskRunErrorResult
+ | TaskRunBackoffResult
+ | TaskRunProgressResult
+ | TaskRunLongpollReturnedPendingResult
+ | TaskRunScheduleLaterResult;
+export namespace TaskRunResult {
+ /**
+ * Task is finished and does not need to be processed again.
+ */
+ export function finished(): TaskRunResult {
+ return {
+ type: TaskRunResultType.Finished,
+ };
+ }
+ /**
+ * Task is waiting for something, should be invoked
+ * again with exponentiall back-off until some other
+ * result is returned.
+ */
+ export function backoff(): TaskRunResult {
+ return {
+ type: TaskRunResultType.Backoff,
+ };
+ }
/**
- * Asynchronous condition to interrupt the sleep of the
- * retry loop.
- *
- * Used to allow processing of new work faster.
+ * Task made progress and should be processed again.
*/
- latch: AsyncCondition;
+ export function progress(): TaskRunResult {
+ return {
+ type: TaskRunResultType.Progress,
+ };
+ }
+ /**
+ * Run the task again at a fixed time in the future.
+ */
+ export function runAgainAt(runAt: AbsoluteTime): TaskRunResult {
+ return {
+ type: TaskRunResultType.ScheduleLater,
+ runAt,
+ };
+ }
+ /**
+ * Longpolling returned, but what we're waiting for
+ * is still pending on the other side.
+ */
+ export function longpollReturnedPending(): TaskRunLongpollReturnedPendingResult {
+ return {
+ type: TaskRunResultType.LongpollReturnedPending,
+ };
+ }
+}
- listeners: NotificationListener[];
+export interface TaskRunFinishedResult {
+ type: TaskRunResultType.Finished;
+}
- initCalled: boolean;
+export interface TaskRunBackoffResult {
+ type: TaskRunResultType.Backoff;
+}
- exchangeOps: ExchangeOperations;
- recoupOps: RecoupOperations;
+export interface TaskRunProgressResult {
+ type: TaskRunResultType.Progress;
+}
- db: DbAccess<typeof WalletStoresV1>;
- http: HttpRequestLibrary;
+export interface TaskRunScheduleLaterResult {
+ type: TaskRunResultType.ScheduleLater;
+ runAt: AbsoluteTime;
+}
- notify(n: WalletNotification): void;
+export interface TaskRunLongpollReturnedPendingResult {
+ type: TaskRunResultType.LongpollReturnedPending;
+}
- addNotificationListener(f: (n: WalletNotification) => void): void;
+export interface TaskRunErrorResult {
+ type: TaskRunResultType.Error;
+ errorDetail: TalerErrorDetail;
+}
- /**
- * Stop ongoing processing.
- */
- stop(): void;
+export interface DbRetryInfo {
+ firstTry: DbPreciseTimestamp;
+ nextRetry: DbPreciseTimestamp;
+ retryCounter: number;
+}
- /**
- * Run an async function after acquiring a list of locks, identified
- * by string tokens.
- */
- runSequentialized<T>(tokens: string[], f: () => Promise<T>): Promise<T>;
+export interface RetryPolicy {
+ readonly backoffDelta: Duration;
+ readonly backoffBase: number;
+ readonly maxTimeout: Duration;
+}
+
+const defaultRetryPolicy: RetryPolicy = {
+ backoffBase: 1.5,
+ backoffDelta: Duration.fromSpec({ seconds: 1 }),
+ maxTimeout: Duration.fromSpec({ minutes: 2 }),
+};
+
+function updateTimeout(
+ r: DbRetryInfo,
+ p: RetryPolicy = defaultRetryPolicy,
+): void {
+ const now = AbsoluteTime.now();
+ if (now.t_ms === "never") {
+ throw Error("assertion failed");
+ }
+ if (p.backoffDelta.d_ms === "forever") {
+ r.nextRetry = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ );
+ return;
+ }
+
+ const nextIncrement =
+ p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
+
+ const t =
+ now.t_ms +
+ (p.maxTimeout.d_ms === "forever"
+ ? nextIncrement
+ : Math.min(p.maxTimeout.d_ms, nextIncrement));
+ r.nextRetry = timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
+}
+
+export function computeDbBackoff(retryCounter: number): DbPreciseTimestamp {
+ const now = AbsoluteTime.now();
+ if (now.t_ms === "never") {
+ throw Error("assertion failed");
+ }
+ const p = defaultRetryPolicy;
+ if (p.backoffDelta.d_ms === "forever") {
+ throw Error("assertion failed");
+ }
+
+ const nextIncrement =
+ p.backoffDelta.d_ms * Math.pow(p.backoffBase, retryCounter);
- runUntilDone(req?: { maxRetries?: number }): Promise<void>;
+ const t =
+ now.t_ms +
+ (p.maxTimeout.d_ms === "forever"
+ ? nextIncrement
+ : Math.min(p.maxTimeout.d_ms, nextIncrement));
+ return timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t));
}
+
+export namespace DbRetryInfo {
+ export function reset(p: RetryPolicy = defaultRetryPolicy): DbRetryInfo {
+ const now = TalerPreciseTimestamp.now();
+ const info: DbRetryInfo = {
+ firstTry: timestampPreciseToDb(now),
+ nextRetry: timestampPreciseToDb(now),
+ retryCounter: 0,
+ };
+ updateTimeout(info, p);
+ return info;
+ }
+
+ export function increment(
+ r: DbRetryInfo | undefined,
+ p: RetryPolicy = defaultRetryPolicy,
+ ): DbRetryInfo {
+ if (!r) {
+ return reset(p);
+ }
+ const r2 = { ...r };
+ r2.retryCounter++;
+ updateTimeout(r2, p);
+ return r2;
+ }
+}
+
+/**
+ * Timestamp after which the wallet would do an auto-refresh.
+ */
+export function getAutoRefreshExecuteThreshold(d: {
+ stampExpireWithdraw: TalerProtocolTimestamp;
+ stampExpireDeposit: TalerProtocolTimestamp;
+}): AbsoluteTime {
+ const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ d.stampExpireWithdraw,
+ );
+ const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
+ d.stampExpireDeposit,
+ );
+ const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
+ const deltaDiv = durationMul(delta, 0.5);
+ return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
+}
+
+/**
+ * Parsed representation of task identifiers.
+ */
+export type ParsedTaskIdentifier =
+ | {
+ tag: PendingTaskType.Withdraw;
+ withdrawalGroupId: string;
+ }
+ | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string }
+ | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string }
+ | { tag: PendingTaskType.Deposit; depositGroupId: string }
+ | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string }
+ | { tag: PendingTaskType.PeerPullCredit; pursePub: string }
+ | { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string }
+ | { tag: PendingTaskType.PeerPushDebit; pursePub: string }
+ | { tag: PendingTaskType.Purchase; proposalId: string }
+ | { tag: PendingTaskType.Recoup; recoupGroupId: string }
+ | { tag: PendingTaskType.RewardPickup; walletRewardId: string }
+ | { tag: PendingTaskType.Refresh; refreshGroupId: string };
+
+export function parseTaskIdentifier(x: string): ParsedTaskIdentifier {
+ const task = x.split(":");
+
+ if (task.length < 2) {
+ throw Error("task id should have al least 2 parts separated by ':'");
+ }
+
+ const [type, ...rest] = task;
+ switch (type) {
+ case PendingTaskType.Backup:
+ return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) };
+ case PendingTaskType.Deposit:
+ return { tag: type, depositGroupId: rest[0] };
+ case PendingTaskType.ExchangeUpdate:
+ return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) };
+ case PendingTaskType.PeerPullCredit:
+ return { tag: type, pursePub: rest[0] };
+ case PendingTaskType.PeerPullDebit:
+ return { tag: type, peerPullDebitId: rest[0] };
+ case PendingTaskType.PeerPushCredit:
+ return { tag: type, peerPushCreditId: rest[0] };
+ case PendingTaskType.PeerPushDebit:
+ return { tag: type, pursePub: rest[0] };
+ case PendingTaskType.Purchase:
+ return { tag: type, proposalId: rest[0] };
+ case PendingTaskType.Recoup:
+ return { tag: type, recoupGroupId: rest[0] };
+ case PendingTaskType.Refresh:
+ return { tag: type, refreshGroupId: rest[0] };
+ case PendingTaskType.RewardPickup:
+ return { tag: type, walletRewardId: rest[0] };
+ case PendingTaskType.Withdraw:
+ return { tag: type, withdrawalGroupId: rest[0] };
+ default:
+ throw Error("invalid task identifier");
+ }
+}
+
+export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskIdStr {
+ switch (p.tag) {
+ case PendingTaskType.Backup:
+ return `${p.tag}:${p.backupProviderBaseUrl}` as TaskIdStr;
+ case PendingTaskType.Deposit:
+ return `${p.tag}:${p.depositGroupId}` as TaskIdStr;
+ case PendingTaskType.ExchangeUpdate:
+ return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskIdStr;
+ case PendingTaskType.PeerPullDebit:
+ return `${p.tag}:${p.peerPullDebitId}` as TaskIdStr;
+ case PendingTaskType.PeerPushCredit:
+ return `${p.tag}:${p.peerPushCreditId}` as TaskIdStr;
+ case PendingTaskType.PeerPullCredit:
+ return `${p.tag}:${p.pursePub}` as TaskIdStr;
+ case PendingTaskType.PeerPushDebit:
+ return `${p.tag}:${p.pursePub}` as TaskIdStr;
+ case PendingTaskType.Purchase:
+ return `${p.tag}:${p.proposalId}` as TaskIdStr;
+ case PendingTaskType.Recoup:
+ return `${p.tag}:${p.recoupGroupId}` as TaskIdStr;
+ case PendingTaskType.Refresh:
+ return `${p.tag}:${p.refreshGroupId}` as TaskIdStr;
+ case PendingTaskType.RewardPickup:
+ return `${p.tag}:${p.walletRewardId}` as TaskIdStr;
+ case PendingTaskType.Withdraw:
+ return `${p.tag}:${p.withdrawalGroupId}` as TaskIdStr;
+ default:
+ assertUnreachable(p);
+ }
+}
+
+export namespace TaskIdentifiers {
+ export function forWithdrawal(wg: WithdrawalGroupRecord): TaskIdStr {
+ return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskIdStr;
+ }
+ export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskIdStr {
+ return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
+ exch.baseUrl,
+ )}` as TaskIdStr;
+ }
+ export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskIdStr {
+ return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent(
+ exchBaseUrl,
+ )}` as TaskIdStr;
+ }
+ export function forTipPickup(tipRecord: RewardRecord): TaskIdStr {
+ return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskIdStr;
+ }
+ export function forRefresh(
+ refreshGroupRecord: RefreshGroupRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskIdStr;
+ }
+ export function forPay(purchaseRecord: PurchaseRecord): TaskIdStr {
+ return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskIdStr;
+ }
+ export function forRecoup(recoupRecord: RecoupGroupRecord): TaskIdStr {
+ return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskIdStr;
+ }
+ export function forDeposit(depositRecord: DepositGroupRecord): TaskIdStr {
+ return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskIdStr;
+ }
+ export function forBackup(backupRecord: BackupProviderRecord): TaskIdStr {
+ return `${PendingTaskType.Backup}:${encodeURIComponent(
+ backupRecord.baseUrl,
+ )}` as TaskIdStr;
+ }
+ export function forPeerPushPaymentInitiation(
+ ppi: PeerPushDebitRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskIdStr;
+ }
+ export function forPeerPullPaymentInitiation(
+ ppi: PeerPullCreditRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskIdStr;
+ }
+ export function forPeerPullPaymentDebit(
+ ppi: PeerPullPaymentIncomingRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskIdStr;
+ }
+ export function forPeerPushCredit(
+ ppi: PeerPushPaymentIncomingRecord,
+ ): TaskIdStr {
+ return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskIdStr;
+ }
+}
+
+/**
+ * Result of a transaction transition.
+ */
+export enum TransitionResultType {
+ Transition = 1,
+ Stay = 2,
+ Delete = 3,
+}
+
+export type TransitionResult<R> =
+ | { type: TransitionResultType.Stay }
+ | { type: TransitionResultType.Transition; rec: R }
+ | { type: TransitionResultType.Delete };
+
+export const TransitionResult = {
+ stay<T>(): TransitionResult<T> {
+ return { type: TransitionResultType.Stay };
+ },
+ delete<T>(): TransitionResult<T> {
+ return { type: TransitionResultType.Delete };
+ },
+ transition<T>(rec: T): TransitionResult<T> {
+ return {
+ type: TransitionResultType.Transition,
+ rec,
+ };
+ },
+};
+
+/**
+ * Transaction context.
+ * Uniform interface to all transactions.
+ */
+export interface TransactionContext {
+ get taskId(): TaskIdStr | undefined;
+ get transactionId(): TransactionIdStr;
+ abortTransaction(): Promise<void>;
+ suspendTransaction(): Promise<void>;
+ resumeTransaction(): Promise<void>;
+ failTransaction(): Promise<void>;
+ deleteTransaction(): Promise<void>;
+}
+
+/**
+ * Type and schema definitions for pending tasks in the wallet.
+ *
+ * These are only used internally, and are not part of the stable public
+ * interface to the wallet.
+ */
+
+export enum PendingTaskType {
+ ExchangeUpdate = "exchange-update",
+ Purchase = "purchase",
+ Refresh = "refresh",
+ Recoup = "recoup",
+ RewardPickup = "reward-pickup",
+ Withdraw = "withdraw",
+ Deposit = "deposit",
+ Backup = "backup",
+ PeerPushDebit = "peer-push-debit",
+ PeerPullCredit = "peer-pull-credit",
+ PeerPushCredit = "peer-push-credit",
+ PeerPullDebit = "peer-pull-debit",
+}
+
+declare const __taskIdStr: unique symbol;
+export type TaskIdStr = string & { [__taskIdStr]: true };
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
new file mode 100644
index 000000000..77ee65e52
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -0,0 +1,1755 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2020 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of crypto-related high-level functions for the Taler wallet.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+
+import {
+ AgeCommitmentProof,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ AmountString,
+ amountToBuffer,
+ BlindedDenominationSignature,
+ bufferForUint32,
+ bufferForUint64,
+ buildSigPS,
+ canonicalJson,
+ CoinDepositPermission,
+ CoinEnvelope,
+ createHashContext,
+ decodeCrock,
+ decryptContractForDeposit,
+ decryptContractForMerge,
+ DenomKeyType,
+ DepositInfo,
+ durationRoundedToBuffer,
+ ecdhGetPublic,
+ eddsaGetPublic,
+ EddsaPublicKeyString,
+ eddsaSign,
+ eddsaVerify,
+ encodeCrock,
+ encryptContractForDeposit,
+ encryptContractForMerge,
+ ExchangeProtocolVersion,
+ getRandomBytes,
+ GlobalFees,
+ hash,
+ HashCodeString,
+ hashCoinEv,
+ hashCoinEvInner,
+ hashCoinPub,
+ hashDenomPub,
+ hashTruncate32,
+ kdf,
+ kdfKw,
+ keyExchangeEcdhEddsa,
+ Logger,
+ MakeSyncSignatureRequest,
+ PlanchetCreationRequest,
+ PlanchetUnblindInfo,
+ PurseDeposit,
+ RecoupRefreshRequest,
+ RecoupRequest,
+ RefreshPlanchetInfo,
+ rsaBlind,
+ rsaUnblind,
+ rsaVerify,
+ setupTipPlanchet,
+ stringToBytes,
+ TalerProtocolTimestamp,
+ TalerSignaturePurpose,
+ timestampRoundedToBuffer,
+ UnblindedSignature,
+ WireFee,
+ WithdrawalPlanchet,
+} from "@gnu-taler/taler-util";
+// FIXME: Crypto should not use DB Types!
+import { DenominationRecord, timestampProtocolFromDb } from "../db.js";
+import {
+ CreateRecoupRefreshReqRequest,
+ CreateRecoupReqRequest,
+ DecryptContractForDepositRequest,
+ DecryptContractForDepositResponse,
+ DecryptContractRequest,
+ DecryptContractResponse,
+ DerivedRefreshSession,
+ DerivedTipPlanchet,
+ DeriveRefreshSessionRequest,
+ DeriveTipRequest,
+ EncryptContractForDepositRequest,
+ EncryptContractForDepositResponse,
+ EncryptContractRequest,
+ EncryptContractResponse,
+ SignCoinHistoryRequest,
+ SignCoinHistoryResponse,
+ SignDeletePurseRequest,
+ SignDeletePurseResponse,
+ SignPurseMergeRequest,
+ SignPurseMergeResponse,
+ SignRefundRequest,
+ SignRefundResponse,
+ SignReservePurseCreateRequest,
+ SignReservePurseCreateResponse,
+ SignTrackTransactionRequest,
+} from "./cryptoTypes.js";
+
+const logger = new Logger("cryptoImplementation.ts");
+
+/**
+ * Interface for (asynchronous) cryptographic operations that
+ * Taler uses.
+ */
+export interface TalerCryptoInterface {
+ /**
+ * Create a pre-coin of the given denomination to be withdrawn from then given
+ * reserve.
+ */
+ createPlanchet(req: PlanchetCreationRequest): Promise<WithdrawalPlanchet>;
+
+ eddsaSign(req: EddsaSignRequest): Promise<EddsaSignResponse>;
+
+ /**
+ * Create a planchet used for tipping, including the private keys.
+ */
+ createTipPlanchet(req: DeriveTipRequest): Promise<DerivedTipPlanchet>;
+
+ signTrackTransaction(
+ req: SignTrackTransactionRequest,
+ ): Promise<EddsaSigningResult>;
+
+ createRecoupRequest(req: CreateRecoupReqRequest): Promise<RecoupRequest>;
+
+ createRecoupRefreshRequest(
+ req: CreateRecoupRefreshReqRequest,
+ ): Promise<RecoupRefreshRequest>;
+
+ isValidPaymentSignature(
+ req: PaymentSignatureValidationRequest,
+ ): Promise<ValidationResult>;
+
+ isValidWireFee(req: WireFeeValidationRequest): Promise<ValidationResult>;
+
+ isValidGlobalFees(
+ req: GlobalFeesValidationRequest,
+ ): Promise<ValidationResult>;
+
+ isValidDenom(req: DenominationValidationRequest): Promise<ValidationResult>;
+
+ isValidWireAccount(
+ req: WireAccountValidationRequest,
+ ): Promise<ValidationResult>;
+
+ isValidContractTermsSignature(
+ req: ContractTermsValidationRequest,
+ ): Promise<ValidationResult>;
+
+ createEddsaKeypair(req: {}): Promise<EddsaKeypair>;
+
+ eddsaGetPublic(req: EddsaGetPublicRequest): Promise<EddsaGetPublicResponse>;
+
+ unblindDenominationSignature(
+ req: UnblindDenominationSignatureRequest,
+ ): Promise<UnblindedSignature>;
+
+ rsaUnblind(req: RsaUnblindRequest): Promise<RsaUnblindResponse>;
+
+ rsaVerify(req: RsaVerificationRequest): Promise<ValidationResult>;
+
+ rsaBlind(req: RsaBlindRequest): Promise<RsaBlindResponse>;
+
+ signDepositPermission(
+ depositInfo: DepositInfo,
+ ): Promise<CoinDepositPermission>;
+
+ deriveRefreshSession(
+ req: DeriveRefreshSessionRequest,
+ ): Promise<DerivedRefreshSession>;
+
+ hashString(req: HashStringRequest): Promise<HashStringResult>;
+
+ signCoinLink(req: SignCoinLinkRequest): Promise<EddsaSigningResult>;
+
+ makeSyncSignature(req: MakeSyncSignatureRequest): Promise<EddsaSigningResult>;
+
+ setupRefreshPlanchet(
+ req: SetupRefreshPlanchetRequest,
+ ): Promise<FreshCoinEncoded>;
+
+ setupWithdrawalPlanchet(
+ req: SetupWithdrawalPlanchetRequest,
+ ): Promise<FreshCoinEncoded>;
+
+ keyExchangeEcdheEddsa(
+ req: KeyExchangeEcdheEddsaRequest,
+ ): Promise<KeyExchangeResult>;
+
+ ecdheGetPublic(req: EddsaGetPublicRequest): Promise<EddsaGetPublicResponse>;
+
+ setupRefreshTransferPub(
+ req: SetupRefreshTransferPubRequest,
+ ): Promise<TransferPubResponse>;
+
+ signPurseCreation(req: SignPurseCreationRequest): Promise<EddsaSigningResult>;
+
+ signPurseDeposits(
+ req: SignPurseDepositsRequest,
+ ): Promise<SignPurseDepositsResponse>;
+
+ encryptContractForMerge(
+ req: EncryptContractRequest,
+ ): Promise<EncryptContractResponse>;
+
+ decryptContractForMerge(
+ req: DecryptContractRequest,
+ ): Promise<DecryptContractResponse>;
+
+ encryptContractForDeposit(
+ req: EncryptContractForDepositRequest,
+ ): Promise<EncryptContractForDepositResponse>;
+
+ decryptContractForDeposit(
+ req: DecryptContractForDepositRequest,
+ ): Promise<DecryptContractForDepositResponse>;
+
+ signPurseMerge(req: SignPurseMergeRequest): Promise<SignPurseMergeResponse>;
+
+ signReservePurseCreate(
+ req: SignReservePurseCreateRequest,
+ ): Promise<SignReservePurseCreateResponse>;
+
+ signRefund(req: SignRefundRequest): Promise<SignRefundResponse>;
+
+ signDeletePurse(
+ req: SignDeletePurseRequest,
+ ): Promise<SignDeletePurseResponse>;
+
+ signCoinHistoryRequest(
+ req: SignCoinHistoryRequest,
+ ): Promise<SignCoinHistoryResponse>;
+}
+
+/**
+ * Implementation of the Taler crypto interface where every function
+ * always throws. Only useful in practice as a way to iterate through
+ * all possible crypto functions.
+ *
+ * (This list can be easily auto-generated by your favorite IDE).
+ */
+export const nullCrypto: TalerCryptoInterface = {
+ createPlanchet: function (
+ req: PlanchetCreationRequest,
+ ): Promise<WithdrawalPlanchet> {
+ throw new Error("Function not implemented.");
+ },
+ eddsaSign: function (req: EddsaSignRequest): Promise<EddsaSignResponse> {
+ throw new Error("Function not implemented.");
+ },
+ createTipPlanchet: function (
+ req: DeriveTipRequest,
+ ): Promise<DerivedTipPlanchet> {
+ throw new Error("Function not implemented.");
+ },
+ signTrackTransaction: function (
+ req: SignTrackTransactionRequest,
+ ): Promise<EddsaSigningResult> {
+ throw new Error("Function not implemented.");
+ },
+ createRecoupRequest: function (
+ req: CreateRecoupReqRequest,
+ ): Promise<RecoupRequest> {
+ throw new Error("Function not implemented.");
+ },
+ createRecoupRefreshRequest: function (
+ req: CreateRecoupRefreshReqRequest,
+ ): Promise<RecoupRefreshRequest> {
+ throw new Error("Function not implemented.");
+ },
+ isValidPaymentSignature: function (
+ req: PaymentSignatureValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidWireFee: function (
+ req: WireFeeValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidDenom: function (
+ req: DenominationValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidWireAccount: function (
+ req: WireAccountValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidGlobalFees: function (
+ req: GlobalFeesValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidContractTermsSignature: function (
+ req: ContractTermsValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ createEddsaKeypair: function (req: unknown): Promise<EddsaKeypair> {
+ throw new Error("Function not implemented.");
+ },
+ eddsaGetPublic: function (req: EddsaGetPublicRequest): Promise<EddsaKeypair> {
+ throw new Error("Function not implemented.");
+ },
+ unblindDenominationSignature: function (
+ req: UnblindDenominationSignatureRequest,
+ ): Promise<UnblindedSignature> {
+ throw new Error("Function not implemented.");
+ },
+ rsaUnblind: function (req: RsaUnblindRequest): Promise<RsaUnblindResponse> {
+ throw new Error("Function not implemented.");
+ },
+ rsaVerify: function (req: RsaVerificationRequest): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ signDepositPermission: function (
+ depositInfo: DepositInfo,
+ ): Promise<CoinDepositPermission> {
+ throw new Error("Function not implemented.");
+ },
+ deriveRefreshSession: function (
+ req: DeriveRefreshSessionRequest,
+ ): Promise<DerivedRefreshSession> {
+ throw new Error("Function not implemented.");
+ },
+ hashString: function (req: HashStringRequest): Promise<HashStringResult> {
+ throw new Error("Function not implemented.");
+ },
+ signCoinLink: function (
+ req: SignCoinLinkRequest,
+ ): Promise<EddsaSigningResult> {
+ throw new Error("Function not implemented.");
+ },
+ makeSyncSignature: function (
+ req: MakeSyncSignatureRequest,
+ ): Promise<EddsaSigningResult> {
+ throw new Error("Function not implemented.");
+ },
+ setupRefreshPlanchet: function (
+ req: SetupRefreshPlanchetRequest,
+ ): Promise<FreshCoinEncoded> {
+ throw new Error("Function not implemented.");
+ },
+ rsaBlind: function (req: RsaBlindRequest): Promise<RsaBlindResponse> {
+ throw new Error("Function not implemented.");
+ },
+ keyExchangeEcdheEddsa: function (
+ req: KeyExchangeEcdheEddsaRequest,
+ ): Promise<KeyExchangeResult> {
+ throw new Error("Function not implemented.");
+ },
+ setupWithdrawalPlanchet: function (
+ req: SetupWithdrawalPlanchetRequest,
+ ): Promise<FreshCoinEncoded> {
+ throw new Error("Function not implemented.");
+ },
+ ecdheGetPublic: function (
+ req: EddsaGetPublicRequest,
+ ): Promise<EddsaGetPublicResponse> {
+ throw new Error("Function not implemented.");
+ },
+ setupRefreshTransferPub: function (
+ req: SetupRefreshTransferPubRequest,
+ ): Promise<TransferPubResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signPurseCreation: function (
+ req: SignPurseCreationRequest,
+ ): Promise<EddsaSigningResult> {
+ throw new Error("Function not implemented.");
+ },
+ signPurseDeposits: function (
+ req: SignPurseDepositsRequest,
+ ): Promise<SignPurseDepositsResponse> {
+ throw new Error("Function not implemented.");
+ },
+ encryptContractForMerge: function (
+ req: EncryptContractRequest,
+ ): Promise<EncryptContractResponse> {
+ throw new Error("Function not implemented.");
+ },
+ decryptContractForMerge: function (
+ req: DecryptContractRequest,
+ ): Promise<DecryptContractResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signPurseMerge: function (
+ req: SignPurseMergeRequest,
+ ): Promise<SignPurseMergeResponse> {
+ throw new Error("Function not implemented.");
+ },
+ encryptContractForDeposit: function (
+ req: EncryptContractForDepositRequest,
+ ): Promise<EncryptContractForDepositResponse> {
+ throw new Error("Function not implemented.");
+ },
+ decryptContractForDeposit: function (
+ req: DecryptContractForDepositRequest,
+ ): Promise<DecryptContractForDepositResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signReservePurseCreate: function (
+ req: SignReservePurseCreateRequest,
+ ): Promise<SignReservePurseCreateResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signRefund: function (req: SignRefundRequest): Promise<SignRefundResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signDeletePurse: function (
+ req: SignDeletePurseRequest,
+ ): Promise<SignDeletePurseResponse> {
+ throw new Error("Function not implemented.");
+ },
+ signCoinHistoryRequest: function (
+ req: SignCoinHistoryRequest,
+ ): Promise<SignCoinHistoryResponse> {
+ throw new Error("Function not implemented.");
+ },
+};
+
+export type WithArg<X> = X extends (req: infer T) => infer R
+ ? (tci: TalerCryptoInterfaceR, req: T) => R
+ : never;
+
+export type TalerCryptoInterfaceR = {
+ [x in keyof TalerCryptoInterface]: WithArg<TalerCryptoInterface[x]>;
+};
+
+export interface SignCoinLinkRequest {
+ oldCoinPriv: string;
+ newDenomHash: string;
+ oldCoinPub: string;
+ transferPub: string;
+ coinEv: CoinEnvelope;
+}
+
+export interface SetupRefreshPlanchetRequest {
+ transferSecret: string;
+ coinNumber: number;
+}
+
+export interface SetupWithdrawalPlanchetRequest {
+ secretSeed: string;
+ coinNumber: number;
+}
+
+export interface SignPurseCreationRequest {
+ pursePriv: string;
+ purseExpiration: TalerProtocolTimestamp;
+ purseAmount: AmountString;
+ hContractTerms: HashCodeString;
+ mergePub: EddsaPublicKeyString;
+ minAge: number;
+}
+
+export interface SpendCoinDetails {
+ coinPub: string;
+ coinPriv: string;
+ contribution: AmountString;
+ denomPubHash: string;
+ denomSig: UnblindedSignature;
+ ageCommitmentProof: AgeCommitmentProof | undefined;
+}
+
+export interface SignPurseDepositsRequest {
+ pursePub: string;
+ exchangeBaseUrl: string;
+ coins: SpendCoinDetails[];
+}
+
+export interface SignPurseDepositsResponse {
+ deposits: PurseDeposit[];
+}
+
+export interface RsaVerificationRequest {
+ hm: string;
+ sig: string;
+ pk: string;
+}
+
+export interface RsaBlindRequest {
+ hm: string;
+ bks: string;
+ pub: string;
+}
+
+export interface EddsaSigningResult {
+ sig: string;
+}
+
+export interface ValidationResult {
+ valid: boolean;
+}
+
+export interface HashStringRequest {
+ str: string;
+}
+
+export interface HashStringResult {
+ h: string;
+}
+
+export interface WireFeeValidationRequest {
+ type: string;
+ wf: WireFee;
+ masterPub: string;
+}
+
+export interface GlobalFeesValidationRequest {
+ gf: GlobalFees;
+ masterPub: string;
+}
+
+export interface DenominationValidationRequest {
+ denom: DenominationRecord;
+ masterPub: string;
+}
+
+export interface PaymentSignatureValidationRequest {
+ sig: string;
+ contractHash: string;
+ merchantPub: string;
+}
+
+export interface ContractTermsValidationRequest {
+ contractTermsHash: string;
+ sig: string;
+ merchantPub: string;
+}
+
+export interface WireAccountValidationRequest {
+ versionCurrent: ExchangeProtocolVersion;
+ paytoUri: string;
+ sig: string;
+ masterPub: string;
+ conversionUrl?: string;
+ debitRestrictions?: any[];
+ creditRestrictions?: any[];
+}
+
+export interface EddsaKeypair {
+ priv: string;
+ pub: string;
+}
+
+export interface EddsaGetPublicRequest {
+ priv: string;
+}
+
+export interface EddsaGetPublicResponse {
+ pub: string;
+}
+
+export interface EcdheGetPublicRequest {
+ priv: string;
+}
+
+export interface EcdheGetPublicResponse {
+ pub: string;
+}
+
+export interface UnblindDenominationSignatureRequest {
+ planchet: PlanchetUnblindInfo;
+ evSig: BlindedDenominationSignature;
+}
+
+export interface FreshCoinEncoded {
+ coinPub: string;
+ coinPriv: string;
+ bks: string;
+}
+
+export interface RsaUnblindRequest {
+ blindedSig: string;
+ bk: string;
+ pk: string;
+}
+
+export interface RsaBlindResponse {
+ blinded: string;
+}
+
+export interface RsaUnblindResponse {
+ sig: string;
+}
+
+export interface KeyExchangeEcdheEddsaRequest {
+ ecdhePriv: string;
+ eddsaPub: string;
+}
+
+export interface KeyExchangeResult {
+ h: string;
+}
+
+export interface SetupRefreshTransferPubRequest {
+ secretSeed: string;
+ transferPubIndex: number;
+}
+
+export interface TransferPubResponse {
+ transferPub: string;
+ transferPriv: string;
+}
+
+/**
+ * JS-native implementation of the Taler crypto worker operations.
+ */
+export const nativeCryptoR: TalerCryptoInterfaceR = {
+ async eddsaSign(
+ tci: TalerCryptoInterfaceR,
+ req: EddsaSignRequest,
+ ): Promise<EddsaSignResponse> {
+ return {
+ sig: encodeCrock(eddsaSign(decodeCrock(req.msg), decodeCrock(req.priv))),
+ };
+ },
+
+ async rsaBlind(
+ tci: TalerCryptoInterfaceR,
+ req: RsaBlindRequest,
+ ): Promise<RsaBlindResponse> {
+ const res = rsaBlind(
+ decodeCrock(req.hm),
+ decodeCrock(req.bks),
+ decodeCrock(req.pub),
+ );
+ return {
+ blinded: encodeCrock(res),
+ };
+ },
+
+ async setupRefreshPlanchet(
+ tci: TalerCryptoInterfaceR,
+ req: SetupRefreshPlanchetRequest,
+ ): Promise<FreshCoinEncoded> {
+ const transferSecret = decodeCrock(req.transferSecret);
+ const coinNumber = req.coinNumber;
+ // See TALER_transfer_secret_to_planchet_secret in C impl
+ const planchetMasterSecret = kdfKw({
+ ikm: transferSecret,
+ outputLength: 32,
+ salt: bufferForUint32(coinNumber),
+ info: stringToBytes("taler-coin-derivation"),
+ });
+
+ const coinPriv = kdfKw({
+ ikm: planchetMasterSecret,
+ outputLength: 32,
+ salt: stringToBytes("coin"),
+ });
+
+ const bks = kdfKw({
+ ikm: planchetMasterSecret,
+ outputLength: 32,
+ salt: stringToBytes("bks"),
+ });
+
+ const coinPrivEnc = encodeCrock(coinPriv);
+ const coinPubRes = await tci.eddsaGetPublic(tci, {
+ priv: coinPrivEnc,
+ });
+
+ return {
+ bks: encodeCrock(bks),
+ coinPriv: coinPrivEnc,
+ coinPub: coinPubRes.pub,
+ };
+ },
+
+ async setupWithdrawalPlanchet(
+ tci: TalerCryptoInterfaceR,
+ req: SetupWithdrawalPlanchetRequest,
+ ): Promise<FreshCoinEncoded> {
+ const info = stringToBytes("taler-withdrawal-coin-derivation");
+ const saltArrBuf = new ArrayBuffer(4);
+ const salt = new Uint8Array(saltArrBuf);
+ const saltDataView = new DataView(saltArrBuf);
+ saltDataView.setUint32(0, req.coinNumber);
+ const secretSeedDec = decodeCrock(req.secretSeed);
+ const out = kdf(64, secretSeedDec, salt, info);
+ const coinPriv = out.slice(0, 32);
+ const bks = out.slice(32, 64);
+ const coinPrivEnc = encodeCrock(coinPriv);
+ const coinPubRes = await tci.eddsaGetPublic(tci, {
+ priv: coinPrivEnc,
+ });
+ return {
+ bks: encodeCrock(bks),
+ coinPriv: coinPrivEnc,
+ coinPub: coinPubRes.pub,
+ };
+ },
+
+ async createPlanchet(
+ tci: TalerCryptoInterfaceR,
+ req: PlanchetCreationRequest,
+ ): Promise<WithdrawalPlanchet> {
+ const denomPub = req.denomPub;
+ if (denomPub.cipher === DenomKeyType.Rsa) {
+ const reservePub = decodeCrock(req.reservePub);
+ const derivedPlanchet = await tci.setupWithdrawalPlanchet(tci, {
+ coinNumber: req.coinIndex,
+ secretSeed: req.secretSeed,
+ });
+
+ let maybeAcp: AgeCommitmentProof | undefined = undefined;
+ let maybeAgeCommitmentHash: string | undefined = undefined;
+ if (denomPub.age_mask) {
+ const age = req.restrictAge || AgeRestriction.AGE_UNRESTRICTED;
+ logger.info(`creating age-restricted planchet (age ${age})`);
+ maybeAcp = await AgeRestriction.restrictionCommitSeeded(
+ denomPub.age_mask,
+ age,
+ stringToBytes(req.secretSeed),
+ );
+ maybeAgeCommitmentHash = AgeRestriction.hashCommitment(
+ maybeAcp.commitment,
+ );
+ }
+
+ const coinPubHash = hashCoinPub(
+ derivedPlanchet.coinPub,
+ maybeAgeCommitmentHash,
+ );
+
+ const blindResp = await tci.rsaBlind(tci, {
+ bks: derivedPlanchet.bks,
+ hm: encodeCrock(coinPubHash),
+ pub: denomPub.rsa_public_key,
+ });
+ const coinEv: CoinEnvelope = {
+ cipher: DenomKeyType.Rsa,
+ rsa_blinded_planchet: blindResp.blinded,
+ };
+ const amountWithFee = Amounts.add(req.value, req.feeWithdraw).amount;
+ const denomPubHash = hashDenomPub(req.denomPub);
+ const evHash = hashCoinEv(coinEv, encodeCrock(denomPubHash));
+ const withdrawRequest = buildSigPS(
+ TalerSignaturePurpose.WALLET_RESERVE_WITHDRAW,
+ )
+ .put(amountToBuffer(amountWithFee))
+ .put(denomPubHash)
+ .put(evHash)
+ .build();
+
+ const sigResult = await tci.eddsaSign(tci, {
+ msg: encodeCrock(withdrawRequest),
+ priv: req.reservePriv,
+ });
+
+ const planchet: WithdrawalPlanchet = {
+ blindingKey: derivedPlanchet.bks,
+ coinEv,
+ coinPriv: derivedPlanchet.coinPriv,
+ coinPub: derivedPlanchet.coinPub,
+ coinValue: req.value,
+ denomPub,
+ denomPubHash: encodeCrock(denomPubHash),
+ reservePub: encodeCrock(reservePub),
+ withdrawSig: sigResult.sig,
+ coinEvHash: encodeCrock(evHash),
+ ageCommitmentProof: maybeAcp,
+ };
+ return planchet;
+ } else {
+ throw Error("unsupported cipher, unable to create planchet");
+ }
+ },
+
+ async createTipPlanchet(
+ tci: TalerCryptoInterfaceR,
+ req: DeriveTipRequest,
+ ): Promise<DerivedTipPlanchet> {
+ if (req.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error(`unsupported cipher (${req.denomPub.cipher})`);
+ }
+ const fc = await setupTipPlanchet(
+ decodeCrock(req.secretSeed),
+ req.denomPub,
+ req.planchetIndex,
+ );
+ const maybeAch = fc.ageCommitmentProof
+ ? AgeRestriction.hashCommitment(fc.ageCommitmentProof.commitment)
+ : undefined;
+ const denomPub = decodeCrock(req.denomPub.rsa_public_key);
+ const coinPubHash = hashCoinPub(encodeCrock(fc.coinPub), maybeAch);
+ const blindResp = await tci.rsaBlind(tci, {
+ bks: encodeCrock(fc.bks),
+ hm: encodeCrock(coinPubHash),
+ pub: encodeCrock(denomPub),
+ });
+ const coinEv = {
+ cipher: DenomKeyType.Rsa,
+ rsa_blinded_planchet: blindResp.blinded,
+ };
+ const tipPlanchet: DerivedTipPlanchet = {
+ blindingKey: encodeCrock(fc.bks),
+ coinEv,
+ coinEvHash: encodeCrock(
+ hashCoinEv(coinEv, encodeCrock(hashDenomPub(req.denomPub))),
+ ),
+ coinPriv: encodeCrock(fc.coinPriv),
+ coinPub: encodeCrock(fc.coinPub),
+ ageCommitmentProof: fc.ageCommitmentProof,
+ };
+ return tipPlanchet;
+ },
+
+ async signTrackTransaction(
+ tci: TalerCryptoInterfaceR,
+ req: SignTrackTransactionRequest,
+ ): Promise<EddsaSigningResult> {
+ const p = buildSigPS(TalerSignaturePurpose.MERCHANT_TRACK_TRANSACTION)
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.wireHash))
+ .put(decodeCrock(req.coinPub))
+ .build();
+ return { sig: encodeCrock(eddsaSign(p, decodeCrock(req.merchantPriv))) };
+ },
+
+ /**
+ * Create and sign a message to recoup a coin.
+ */
+ async createRecoupRequest(
+ tci: TalerCryptoInterfaceR,
+ req: CreateRecoupReqRequest,
+ ): Promise<RecoupRequest> {
+ const p = buildSigPS(TalerSignaturePurpose.WALLET_COIN_RECOUP)
+ .put(decodeCrock(req.denomPubHash))
+ .put(decodeCrock(req.blindingKey))
+ .build();
+
+ const coinPriv = decodeCrock(req.coinPriv);
+ const coinSig = eddsaSign(p, coinPriv);
+ if (req.denomPub.cipher === DenomKeyType.Rsa) {
+ const paybackRequest: RecoupRequest = {
+ coin_blind_key_secret: req.blindingKey,
+ coin_sig: encodeCrock(coinSig),
+ denom_pub_hash: req.denomPubHash,
+ denom_sig: req.denomSig,
+ // FIXME!
+ ewv: {
+ cipher: "RSA",
+ },
+ };
+ return paybackRequest;
+ } else {
+ throw new Error();
+ }
+ },
+
+ /**
+ * Create and sign a message to recoup a coin.
+ */
+ async createRecoupRefreshRequest(
+ tci: TalerCryptoInterfaceR,
+ req: CreateRecoupRefreshReqRequest,
+ ): Promise<RecoupRefreshRequest> {
+ const p = buildSigPS(TalerSignaturePurpose.WALLET_COIN_RECOUP_REFRESH)
+ .put(decodeCrock(req.denomPubHash))
+ .put(decodeCrock(req.blindingKey))
+ .build();
+
+ const coinPriv = decodeCrock(req.coinPriv);
+ const coinSig = eddsaSign(p, coinPriv);
+ if (req.denomPub.cipher === DenomKeyType.Rsa) {
+ const recoupRequest: RecoupRefreshRequest = {
+ coin_blind_key_secret: req.blindingKey,
+ coin_sig: encodeCrock(coinSig),
+ denom_pub_hash: req.denomPubHash,
+ denom_sig: req.denomSig,
+ // FIXME!
+ ewv: {
+ cipher: "RSA",
+ },
+ };
+ return recoupRequest;
+ } else {
+ throw new Error();
+ }
+ },
+
+ /**
+ * Check if a payment signature is valid.
+ */
+ async isValidPaymentSignature(
+ tci: TalerCryptoInterfaceR,
+ req: PaymentSignatureValidationRequest,
+ ): Promise<ValidationResult> {
+ const { contractHash, sig, merchantPub } = req;
+ const p = buildSigPS(TalerSignaturePurpose.MERCHANT_PAYMENT_OK)
+ .put(decodeCrock(contractHash))
+ .build();
+ const sigBytes = decodeCrock(sig);
+ const pubBytes = decodeCrock(merchantPub);
+ return { valid: eddsaVerify(p, sigBytes, pubBytes) };
+ },
+
+ /**
+ * Check if a wire fee is correctly signed.
+ */
+ async isValidWireFee(
+ tci: TalerCryptoInterfaceR,
+ req: WireFeeValidationRequest,
+ ): Promise<ValidationResult> {
+ const { type, wf, masterPub } = req;
+ const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_FEES)
+ .put(hash(stringToBytes(type + "\0")))
+ .put(timestampRoundedToBuffer(wf.startStamp))
+ .put(timestampRoundedToBuffer(wf.endStamp))
+ .put(amountToBuffer(wf.wireFee))
+ .put(amountToBuffer(wf.closingFee))
+ .build();
+ const sig = decodeCrock(wf.sig);
+ const pub = decodeCrock(masterPub);
+ return { valid: eddsaVerify(p, sig, pub) };
+ },
+
+ /**
+ * Check if a global fee is correctly signed.
+ */
+ async isValidGlobalFees(
+ tci: TalerCryptoInterfaceR,
+ req: GlobalFeesValidationRequest,
+ ): Promise<ValidationResult> {
+ const { gf, masterPub } = req;
+ const p = buildSigPS(TalerSignaturePurpose.GLOBAL_FEES)
+ .put(timestampRoundedToBuffer(gf.start_date))
+ .put(timestampRoundedToBuffer(gf.end_date))
+ .put(durationRoundedToBuffer(gf.purse_timeout))
+ .put(durationRoundedToBuffer(gf.history_expiration))
+ .put(amountToBuffer(Amounts.parseOrThrow(gf.history_fee)))
+ .put(amountToBuffer(Amounts.parseOrThrow(gf.account_fee)))
+ .put(amountToBuffer(Amounts.parseOrThrow(gf.purse_fee)))
+ .put(bufferForUint32(gf.purse_account_limit))
+ .build();
+ const sig = decodeCrock(gf.master_sig);
+ const pub = decodeCrock(masterPub);
+ return { valid: eddsaVerify(p, sig, pub) };
+ },
+
+ /**
+ * Check if the signature of a denomination is valid.
+ */
+ async isValidDenom(
+ tci: TalerCryptoInterfaceR,
+ req: DenominationValidationRequest,
+ ): Promise<ValidationResult> {
+ const { masterPub, denom } = req;
+ const value: AmountJson = Amounts.parseOrThrow(denom.value);
+ const p = buildSigPS(TalerSignaturePurpose.MASTER_DENOMINATION_KEY_VALIDITY)
+ .put(decodeCrock(masterPub))
+ .put(timestampRoundedToBuffer(timestampProtocolFromDb(denom.stampStart)))
+ .put(
+ timestampRoundedToBuffer(
+ timestampProtocolFromDb(denom.stampExpireWithdraw),
+ ),
+ )
+ .put(
+ timestampRoundedToBuffer(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ ),
+ )
+ .put(
+ timestampRoundedToBuffer(
+ timestampProtocolFromDb(denom.stampExpireLegal),
+ ),
+ )
+ .put(amountToBuffer(value))
+ .put(amountToBuffer(denom.fees.feeWithdraw))
+ .put(amountToBuffer(denom.fees.feeDeposit))
+ .put(amountToBuffer(denom.fees.feeRefresh))
+ .put(amountToBuffer(denom.fees.feeRefund))
+ .put(decodeCrock(denom.denomPubHash))
+ .build();
+ const sig = decodeCrock(denom.masterSig);
+ const pub = decodeCrock(masterPub);
+ const res = eddsaVerify(p, sig, pub);
+ return { valid: res };
+ },
+
+ async isValidWireAccount(
+ tci: TalerCryptoInterfaceR,
+ req: WireAccountValidationRequest,
+ ): Promise<ValidationResult> {
+ const { sig, masterPub, paytoUri } = req;
+ const paytoHash = hashTruncate32(stringToBytes(paytoUri + "\0"));
+ const pb = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_DETAILS);
+ pb.put(paytoHash);
+ if (req.versionCurrent >= 15) {
+ let conversionUrlHash;
+ if (!req.conversionUrl) {
+ conversionUrlHash = new Uint8Array(64);
+ } else {
+ conversionUrlHash = hash(stringToBytes(req.conversionUrl + "\0"));
+ }
+ pb.put(conversionUrlHash);
+ pb.put(hash(stringToBytes(canonicalJson(req.debitRestrictions) + "\0")));
+ pb.put(hash(stringToBytes(canonicalJson(req.creditRestrictions) + "\0")));
+ }
+ const p = pb.build();
+ return { valid: eddsaVerify(p, decodeCrock(sig), decodeCrock(masterPub)) };
+ },
+
+ async isValidContractTermsSignature(
+ tci: TalerCryptoInterfaceR,
+ req: ContractTermsValidationRequest,
+ ): Promise<ValidationResult> {
+ const cthDec = decodeCrock(req.contractTermsHash);
+ const p = buildSigPS(TalerSignaturePurpose.MERCHANT_CONTRACT)
+ .put(cthDec)
+ .build();
+ return {
+ valid: eddsaVerify(p, decodeCrock(req.sig), decodeCrock(req.merchantPub)),
+ };
+ },
+
+ /**
+ * Create a new EdDSA key pair.
+ */
+ async createEddsaKeypair(tci: TalerCryptoInterfaceR): Promise<EddsaKeypair> {
+ const eddsaPriv = encodeCrock(getRandomBytes(32));
+ const eddsaPubRes = await tci.eddsaGetPublic(tci, {
+ priv: eddsaPriv,
+ });
+ return {
+ priv: eddsaPriv,
+ pub: eddsaPubRes.pub,
+ };
+ },
+
+ async eddsaGetPublic(
+ tci: TalerCryptoInterfaceR,
+ req: EddsaGetPublicRequest,
+ ): Promise<EddsaKeypair> {
+ return {
+ priv: req.priv,
+ pub: encodeCrock(eddsaGetPublic(decodeCrock(req.priv))),
+ };
+ },
+
+ async unblindDenominationSignature(
+ tci: TalerCryptoInterfaceR,
+ req: UnblindDenominationSignatureRequest,
+ ): Promise<UnblindedSignature> {
+ if (req.evSig.cipher === DenomKeyType.Rsa) {
+ if (req.planchet.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw new Error(
+ "planchet cipher does not match blind signature cipher",
+ );
+ }
+ const denomSig = rsaUnblind(
+ decodeCrock(req.evSig.blinded_rsa_signature),
+ decodeCrock(req.planchet.denomPub.rsa_public_key),
+ decodeCrock(req.planchet.blindingKey),
+ );
+ return {
+ cipher: DenomKeyType.Rsa,
+ rsa_signature: encodeCrock(denomSig),
+ };
+ } else {
+ throw Error(`unblinding for cipher ${req.evSig.cipher} not implemented`);
+ }
+ },
+
+ /**
+ * Unblind a blindly signed value.
+ */
+ async rsaUnblind(
+ tci: TalerCryptoInterfaceR,
+ req: RsaUnblindRequest,
+ ): Promise<RsaUnblindResponse> {
+ const denomSig = rsaUnblind(
+ decodeCrock(req.blindedSig),
+ decodeCrock(req.pk),
+ decodeCrock(req.bk),
+ );
+ return { sig: encodeCrock(denomSig) };
+ },
+
+ /**
+ * Unblind a blindly signed value.
+ */
+ async rsaVerify(
+ tci: TalerCryptoInterfaceR,
+ req: RsaVerificationRequest,
+ ): Promise<ValidationResult> {
+ return {
+ valid: rsaVerify(
+ hash(decodeCrock(req.hm)),
+ decodeCrock(req.sig),
+ decodeCrock(req.pk),
+ ),
+ };
+ },
+
+ /**
+ * Generate updated coins (to store in the database)
+ * and deposit permissions for each given coin.
+ */
+ async signDepositPermission(
+ tci: TalerCryptoInterfaceR,
+ depositInfo: DepositInfo,
+ ): Promise<CoinDepositPermission> {
+ // FIXME: put extensions here if used
+ const hExt = new Uint8Array(64);
+ let hAgeCommitment: Uint8Array;
+ let minimumAgeSig: string | undefined = undefined;
+ if (depositInfo.ageCommitmentProof) {
+ const ach = AgeRestriction.hashCommitment(
+ depositInfo.ageCommitmentProof.commitment,
+ );
+ hAgeCommitment = decodeCrock(ach);
+ if (depositInfo.requiredMinimumAge) {
+ minimumAgeSig = encodeCrock(
+ AgeRestriction.commitmentAttest(
+ depositInfo.ageCommitmentProof,
+ depositInfo.requiredMinimumAge,
+ ),
+ );
+ }
+ } else {
+ // All zeros.
+ hAgeCommitment = new Uint8Array(32);
+ }
+ // FIXME: Actually allow passing user data here!
+ const walletDataHash = new Uint8Array(64);
+ let d: Uint8Array;
+ if (depositInfo.denomKeyType === DenomKeyType.Rsa) {
+ d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT)
+ .put(decodeCrock(depositInfo.contractTermsHash))
+ .put(hAgeCommitment)
+ .put(hExt)
+ .put(decodeCrock(depositInfo.wireInfoHash))
+ .put(decodeCrock(depositInfo.denomPubHash))
+ .put(timestampRoundedToBuffer(depositInfo.timestamp))
+ .put(timestampRoundedToBuffer(depositInfo.refundDeadline))
+ .put(amountToBuffer(depositInfo.spendAmount))
+ .put(amountToBuffer(depositInfo.feeDeposit))
+ .put(decodeCrock(depositInfo.merchantPub))
+ .put(walletDataHash)
+ .build();
+ } else {
+ throw Error("unsupported exchange protocol version");
+ }
+ const coinSigRes = await this.eddsaSign(tci, {
+ msg: encodeCrock(d),
+ priv: depositInfo.coinPriv,
+ });
+
+ if (depositInfo.denomKeyType === DenomKeyType.Rsa) {
+ const s: CoinDepositPermission = {
+ coin_pub: depositInfo.coinPub,
+ coin_sig: coinSigRes.sig,
+ contribution: Amounts.stringify(depositInfo.spendAmount),
+ h_denom: depositInfo.denomPubHash,
+ exchange_url: depositInfo.exchangeBaseUrl,
+ ub_sig: {
+ cipher: DenomKeyType.Rsa,
+ rsa_signature: depositInfo.denomSig.rsa_signature,
+ },
+ };
+
+ if (depositInfo.requiredMinimumAge) {
+ // These are only required by the merchant
+ s.minimum_age_sig = minimumAgeSig;
+ s.age_commitment =
+ depositInfo.ageCommitmentProof?.commitment.publicKeys;
+ } else if (depositInfo.ageCommitmentProof) {
+ s.h_age_commitment = encodeCrock(hAgeCommitment);
+ }
+
+ return s;
+ } else {
+ throw Error(
+ `unsupported denomination cipher (${depositInfo.denomKeyType})`,
+ );
+ }
+ },
+
+ async deriveRefreshSession(
+ tci: TalerCryptoInterfaceR,
+ req: DeriveRefreshSessionRequest,
+ ): Promise<DerivedRefreshSession> {
+ const {
+ newCoinDenoms,
+ feeRefresh: meltFee,
+ kappa,
+ meltCoinDenomPubHash,
+ meltCoinPriv,
+ meltCoinPub,
+ sessionSecretSeed: refreshSessionSecretSeed,
+ } = req;
+
+ const currency = Amounts.currencyOf(newCoinDenoms[0].value);
+ let valueWithFee = Amounts.zeroOfCurrency(currency);
+
+ for (const ncd of newCoinDenoms) {
+ const t = Amounts.add(ncd.value, ncd.feeWithdraw).amount;
+ valueWithFee = Amounts.add(
+ valueWithFee,
+ Amounts.mult(t, ncd.count).amount,
+ ).amount;
+ }
+
+ // melt fee
+ valueWithFee = Amounts.add(valueWithFee, meltFee).amount;
+
+ const sessionHc = createHashContext();
+
+ const transferPubs: string[] = [];
+ const transferPrivs: string[] = [];
+
+ const planchetsForGammas: RefreshPlanchetInfo[][] = [];
+
+ for (let i = 0; i < kappa; i++) {
+ const transferKeyPair = await tci.setupRefreshTransferPub(tci, {
+ secretSeed: refreshSessionSecretSeed,
+ transferPubIndex: i,
+ });
+ sessionHc.update(decodeCrock(transferKeyPair.transferPub));
+ transferPrivs.push(transferKeyPair.transferPriv);
+ transferPubs.push(transferKeyPair.transferPub);
+ }
+
+ for (const denomSel of newCoinDenoms) {
+ for (let i = 0; i < denomSel.count; i++) {
+ if (denomSel.denomPub.cipher === DenomKeyType.Rsa) {
+ const denomPubHash = hashDenomPub(denomSel.denomPub);
+ sessionHc.update(denomPubHash);
+ } else {
+ throw new Error();
+ }
+ }
+ }
+
+ sessionHc.update(decodeCrock(meltCoinPub));
+ sessionHc.update(amountToBuffer(valueWithFee));
+
+ for (let i = 0; i < kappa; i++) {
+ const planchets: RefreshPlanchetInfo[] = [];
+ for (let j = 0; j < newCoinDenoms.length; j++) {
+ const denomSel = newCoinDenoms[j];
+ for (let k = 0; k < denomSel.count; k++) {
+ const coinIndex = planchets.length;
+ const transferSecretRes = await tci.keyExchangeEcdheEddsa(tci, {
+ ecdhePriv: transferPrivs[i],
+ eddsaPub: meltCoinPub,
+ });
+ let coinPub: Uint8Array;
+ let coinPriv: Uint8Array;
+ let blindingFactor: Uint8Array;
+ let fresh: FreshCoinEncoded = await tci.setupRefreshPlanchet(tci, {
+ coinNumber: coinIndex,
+ transferSecret: transferSecretRes.h,
+ });
+ let newAc: AgeCommitmentProof | undefined = undefined;
+ let newAch: HashCodeString | undefined = undefined;
+ if (req.meltCoinAgeCommitmentProof) {
+ newAc = await AgeRestriction.commitmentDerive(
+ req.meltCoinAgeCommitmentProof,
+ decodeCrock(transferSecretRes.h),
+ );
+ newAch = AgeRestriction.hashCommitment(newAc.commitment);
+ }
+ coinPriv = decodeCrock(fresh.coinPriv);
+ coinPub = decodeCrock(fresh.coinPub);
+ blindingFactor = decodeCrock(fresh.bks);
+ const coinPubHash = hashCoinPub(fresh.coinPub, newAch);
+ if (denomSel.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error("unsupported cipher, can't create refresh session");
+ }
+ const blindResult = await tci.rsaBlind(tci, {
+ bks: encodeCrock(blindingFactor),
+ hm: encodeCrock(coinPubHash),
+ pub: denomSel.denomPub.rsa_public_key,
+ });
+ const coinEv: CoinEnvelope = {
+ cipher: DenomKeyType.Rsa,
+ rsa_blinded_planchet: blindResult.blinded,
+ };
+ const coinEvHash = hashCoinEv(
+ coinEv,
+ encodeCrock(hashDenomPub(denomSel.denomPub)),
+ );
+ const planchet: RefreshPlanchetInfo = {
+ blindingKey: encodeCrock(blindingFactor),
+ coinEv,
+ coinPriv: encodeCrock(coinPriv),
+ coinPub: encodeCrock(coinPub),
+ coinEvHash: encodeCrock(coinEvHash),
+ maxAge: req.meltCoinMaxAge,
+ ageCommitmentProof: newAc,
+ };
+ planchets.push(planchet);
+ hashCoinEvInner(coinEv, sessionHc);
+ }
+ }
+ planchetsForGammas.push(planchets);
+ }
+
+ const sessionHash = sessionHc.finish();
+ let confirmData: Uint8Array;
+ let hAgeCommitment: Uint8Array;
+ if (req.meltCoinAgeCommitmentProof) {
+ hAgeCommitment = decodeCrock(
+ AgeRestriction.hashCommitment(
+ req.meltCoinAgeCommitmentProof.commitment,
+ ),
+ );
+ } else {
+ hAgeCommitment = new Uint8Array(32);
+ }
+ confirmData = buildSigPS(TalerSignaturePurpose.WALLET_COIN_MELT)
+ .put(sessionHash)
+ .put(decodeCrock(meltCoinDenomPubHash))
+ .put(hAgeCommitment)
+ .put(amountToBuffer(valueWithFee))
+ .put(amountToBuffer(meltFee))
+ .build();
+
+ const confirmSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(confirmData),
+ priv: meltCoinPriv,
+ });
+
+ const refreshSession: DerivedRefreshSession = {
+ confirmSig: confirmSigResp.sig,
+ hash: encodeCrock(sessionHash),
+ meltCoinPub: meltCoinPub,
+ planchetsForGammas: planchetsForGammas,
+ transferPrivs,
+ transferPubs,
+ meltValueWithFee: valueWithFee,
+ };
+
+ return refreshSession;
+ },
+
+ /**
+ * Hash a string including the zero terminator.
+ */
+ async hashString(
+ tci: TalerCryptoInterfaceR,
+ req: HashStringRequest,
+ ): Promise<HashStringResult> {
+ const b = stringToBytes(req.str + "\0");
+ return { h: encodeCrock(hash(b)) };
+ },
+
+ async signCoinLink(
+ tci: TalerCryptoInterfaceR,
+ req: SignCoinLinkRequest,
+ ): Promise<EddsaSigningResult> {
+ const coinEvHash = hashCoinEv(req.coinEv, req.newDenomHash);
+ // FIXME: fill in
+ const hAgeCommitment = new Uint8Array(32);
+ const coinLink = buildSigPS(TalerSignaturePurpose.WALLET_COIN_LINK)
+ .put(decodeCrock(req.newDenomHash))
+ .put(decodeCrock(req.transferPub))
+ .put(hAgeCommitment)
+ .put(coinEvHash)
+ .build();
+ return tci.eddsaSign(tci, {
+ msg: encodeCrock(coinLink),
+ priv: req.oldCoinPriv,
+ });
+ },
+
+ async makeSyncSignature(
+ tci: TalerCryptoInterfaceR,
+ req: MakeSyncSignatureRequest,
+ ): Promise<EddsaSigningResult> {
+ const hNew = decodeCrock(req.newHash);
+ let hOld: Uint8Array;
+ if (req.oldHash) {
+ hOld = decodeCrock(req.oldHash);
+ } else {
+ hOld = new Uint8Array(64);
+ }
+ const sigBlob = buildSigPS(TalerSignaturePurpose.SYNC_BACKUP_UPLOAD)
+ .put(hOld)
+ .put(hNew)
+ .build();
+ const uploadSig = eddsaSign(sigBlob, decodeCrock(req.accountPriv));
+ return { sig: encodeCrock(uploadSig) };
+ },
+ async keyExchangeEcdheEddsa(
+ tci: TalerCryptoInterfaceR,
+ req: KeyExchangeEcdheEddsaRequest,
+ ): Promise<KeyExchangeResult> {
+ return {
+ h: encodeCrock(
+ keyExchangeEcdhEddsa(
+ decodeCrock(req.ecdhePriv),
+ decodeCrock(req.eddsaPub),
+ ),
+ ),
+ };
+ },
+ async ecdheGetPublic(
+ tci: TalerCryptoInterfaceR,
+ req: EcdheGetPublicRequest,
+ ): Promise<EcdheGetPublicResponse> {
+ return {
+ pub: encodeCrock(ecdhGetPublic(decodeCrock(req.priv))),
+ };
+ },
+ async setupRefreshTransferPub(
+ tci: TalerCryptoInterfaceR,
+ req: SetupRefreshTransferPubRequest,
+ ): Promise<TransferPubResponse> {
+ const info = stringToBytes("taler-transfer-pub-derivation");
+ const saltArrBuf = new ArrayBuffer(4);
+ const salt = new Uint8Array(saltArrBuf);
+ const saltDataView = new DataView(saltArrBuf);
+ saltDataView.setUint32(0, req.transferPubIndex);
+ const out = kdf(32, decodeCrock(req.secretSeed), salt, info);
+ const transferPriv = encodeCrock(out);
+ return {
+ transferPriv,
+ transferPub: (await tci.ecdheGetPublic(tci, { priv: transferPriv })).pub,
+ };
+ },
+ async signPurseCreation(
+ tci: TalerCryptoInterfaceR,
+ req: SignPurseCreationRequest,
+ ): Promise<EddsaSigningResult> {
+ const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_CREATE)
+ .put(timestampRoundedToBuffer(req.purseExpiration))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
+ .put(decodeCrock(req.hContractTerms))
+ .put(decodeCrock(req.mergePub))
+ .put(bufferForUint32(req.minAge))
+ .build();
+ return await tci.eddsaSign(tci, {
+ msg: encodeCrock(sigBlob),
+ priv: req.pursePriv,
+ });
+ },
+ async signPurseDeposits(
+ tci: TalerCryptoInterfaceR,
+ req: SignPurseDepositsRequest,
+ ): Promise<SignPurseDepositsResponse> {
+ const hExchangeBaseUrl = hash(stringToBytes(req.exchangeBaseUrl + "\0"));
+ const deposits: PurseDeposit[] = [];
+ for (const c of req.coins) {
+ let haveAch: boolean;
+ let maybeAch: Uint8Array;
+ if (c.ageCommitmentProof) {
+ haveAch = true;
+ maybeAch = decodeCrock(
+ AgeRestriction.hashCommitment(c.ageCommitmentProof.commitment),
+ );
+ } else {
+ haveAch = false;
+ maybeAch = new Uint8Array(32);
+ }
+ const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_DEPOSIT)
+ .put(amountToBuffer(Amounts.parseOrThrow(c.contribution)))
+ .put(decodeCrock(c.denomPubHash))
+ .put(maybeAch)
+ .put(decodeCrock(req.pursePub))
+ .put(hExchangeBaseUrl)
+ .build();
+ const sigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(sigBlob),
+ priv: c.coinPriv,
+ });
+ deposits.push({
+ amount: c.contribution,
+ coin_pub: c.coinPub,
+ coin_sig: sigResp.sig,
+ denom_pub_hash: c.denomPubHash,
+ ub_sig: c.denomSig,
+ age_commitment: c.ageCommitmentProof
+ ? c.ageCommitmentProof.commitment.publicKeys
+ : undefined,
+ });
+ }
+ return {
+ deposits,
+ };
+ },
+ async encryptContractForMerge(
+ tci: TalerCryptoInterfaceR,
+ req: EncryptContractRequest,
+ ): Promise<EncryptContractResponse> {
+ const enc = await encryptContractForMerge(
+ decodeCrock(req.pursePub),
+ decodeCrock(req.contractPriv),
+ decodeCrock(req.mergePriv),
+ req.contractTerms,
+ decodeCrock(req.nonce),
+ );
+ const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_ECONTRACT)
+ .put(hash(enc))
+ .put(decodeCrock(req.contractPub))
+ .build();
+ const sig = eddsaSign(sigBlob, decodeCrock(req.pursePriv));
+ return {
+ econtract: {
+ contract_pub: req.contractPub,
+ econtract: encodeCrock(enc),
+ econtract_sig: encodeCrock(sig),
+ },
+ };
+ },
+ async decryptContractForMerge(
+ tci: TalerCryptoInterfaceR,
+ req: DecryptContractRequest,
+ ): Promise<DecryptContractResponse> {
+ const res = await decryptContractForMerge(
+ decodeCrock(req.ciphertext),
+ decodeCrock(req.pursePub),
+ decodeCrock(req.contractPriv),
+ );
+ return {
+ contractTerms: res.contractTerms,
+ mergePriv: encodeCrock(res.mergePriv),
+ };
+ },
+ async encryptContractForDeposit(
+ tci: TalerCryptoInterfaceR,
+ req: EncryptContractForDepositRequest,
+ ): Promise<EncryptContractForDepositResponse> {
+ const enc = await encryptContractForDeposit(
+ decodeCrock(req.pursePub),
+ decodeCrock(req.contractPriv),
+ req.contractTerms,
+ decodeCrock(req.nonce),
+ );
+ const sigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_ECONTRACT)
+ .put(hash(enc))
+ .put(decodeCrock(req.contractPub))
+ .build();
+ const sig = eddsaSign(sigBlob, decodeCrock(req.pursePriv));
+ return {
+ econtract: {
+ contract_pub: req.contractPub,
+ econtract: encodeCrock(enc),
+ econtract_sig: encodeCrock(sig),
+ },
+ };
+ },
+ async decryptContractForDeposit(
+ tci: TalerCryptoInterfaceR,
+ req: DecryptContractForDepositRequest,
+ ): Promise<DecryptContractForDepositResponse> {
+ const res = await decryptContractForDeposit(
+ decodeCrock(req.ciphertext),
+ decodeCrock(req.pursePub),
+ decodeCrock(req.contractPriv),
+ );
+ return {
+ contractTerms: res.contractTerms,
+ };
+ },
+ async signPurseMerge(
+ tci: TalerCryptoInterfaceR,
+ req: SignPurseMergeRequest,
+ ): Promise<SignPurseMergeResponse> {
+ const mergeSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_MERGE)
+ .put(timestampRoundedToBuffer(req.mergeTimestamp))
+ .put(decodeCrock(req.pursePub))
+ .put(hashTruncate32(stringToBytes(req.reservePayto + "\0")))
+ .build();
+ const mergeSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(mergeSigBlob),
+ priv: req.mergePriv,
+ });
+
+ const reserveSigBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_ACCOUNT_MERGE,
+ )
+ .put(timestampRoundedToBuffer(req.purseExpiration))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseFee)))
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.pursePub))
+ .put(timestampRoundedToBuffer(req.mergeTimestamp))
+ // FIXME: put in min_age
+ .put(bufferForUint32(0))
+ .put(bufferForUint32(req.flags))
+ .build();
+
+ logger.info(
+ `signing WALLET_ACCOUNT_MERGE over ${encodeCrock(reserveSigBlob)}`,
+ );
+
+ const reserveSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(reserveSigBlob),
+ priv: req.reservePriv,
+ });
+
+ return {
+ mergeSig: mergeSigResp.sig,
+ accountSig: reserveSigResp.sig,
+ };
+ },
+ async signReservePurseCreate(
+ tci: TalerCryptoInterfaceR,
+ req: SignReservePurseCreateRequest,
+ ): Promise<SignReservePurseCreateResponse> {
+ const mergeSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_MERGE)
+ .put(timestampRoundedToBuffer(req.mergeTimestamp))
+ .put(decodeCrock(req.pursePub))
+ .put(hashTruncate32(stringToBytes(req.reservePayto + "\0")))
+ .build();
+ const mergeSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(mergeSigBlob),
+ priv: req.mergePriv,
+ });
+
+ logger.info(`payto URI: ${req.reservePayto}`);
+ logger.info(`signing WALLET_PURSE_MERGE over ${encodeCrock(mergeSigBlob)}`);
+
+ const reserveSigBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_ACCOUNT_MERGE,
+ )
+ .put(timestampRoundedToBuffer(req.purseExpiration))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseFee)))
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.pursePub))
+ .put(timestampRoundedToBuffer(req.mergeTimestamp))
+ // FIXME: put in min_age
+ .put(bufferForUint32(0))
+ .put(bufferForUint32(req.flags))
+ .build();
+
+ logger.info(
+ `signing WALLET_ACCOUNT_MERGE over ${encodeCrock(reserveSigBlob)}`,
+ );
+
+ const reserveSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(reserveSigBlob),
+ priv: req.reservePriv,
+ });
+
+ const mergePub = encodeCrock(eddsaGetPublic(decodeCrock(req.mergePriv)));
+
+ const purseSigBlob = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_CREATE)
+ .put(timestampRoundedToBuffer(req.purseExpiration))
+ .put(amountToBuffer(Amounts.parseOrThrow(req.purseAmount)))
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(mergePub))
+ // FIXME: add age!
+ .put(bufferForUint32(0))
+ .build();
+
+ const purseSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(purseSigBlob),
+ priv: req.pursePriv,
+ });
+
+ return {
+ mergeSig: mergeSigResp.sig,
+ accountSig: reserveSigResp.sig,
+ purseSig: purseSigResp.sig,
+ };
+ },
+ async signRefund(
+ tci: TalerCryptoInterfaceR,
+ req: SignRefundRequest,
+ ): Promise<SignRefundResponse> {
+ const refundSigBlob = buildSigPS(TalerSignaturePurpose.MERCHANT_REFUND)
+ .put(decodeCrock(req.contractTermsHash))
+ .put(decodeCrock(req.coinPub))
+ .put(bufferForUint64(req.rtransactionId))
+ .put(amountToBuffer(req.refundAmount))
+ .build();
+ const refundSigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(refundSigBlob),
+ priv: req.merchantPriv,
+ });
+ return {
+ sig: refundSigResp.sig,
+ };
+ },
+ async signDeletePurse(
+ tci: TalerCryptoInterfaceR,
+ req: SignDeletePurseRequest,
+ ): Promise<SignDeletePurseResponse> {
+ const deleteSigBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_PURSE_DELETE,
+ ).build();
+ const sigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(deleteSigBlob),
+ priv: req.pursePriv,
+ });
+ return {
+ sig: sigResp.sig,
+ };
+ },
+ async signCoinHistoryRequest(
+ tci: TalerCryptoInterfaceR,
+ req: SignCoinHistoryRequest,
+ ): Promise<SignCoinHistoryResponse> {
+ const coinHistorySigBlob = buildSigPS(
+ TalerSignaturePurpose.WALLET_COIN_HISTORY,
+ )
+ .put(bufferForUint64(req.startOffset))
+ .build();
+ const sigResp = await tci.eddsaSign(tci, {
+ msg: encodeCrock(coinHistorySigBlob),
+ priv: req.coinPriv,
+ });
+ return {
+ sig: sigResp.sig,
+ };
+ },
+};
+
+export interface EddsaSignRequest {
+ msg: string;
+ priv: string;
+}
+
+export interface EddsaSignResponse {
+ sig: string;
+}
+
+export const nativeCrypto: TalerCryptoInterface = Object.fromEntries(
+ Object.keys(nativeCryptoR).map((name) => {
+ return [
+ name,
+ (req: any) =>
+ nativeCryptoR[name as keyof TalerCryptoInterfaceR](nativeCryptoR, req),
+ ];
+ }),
+) as any;
diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
index 922fbbfac..df25b87e4 100644
--- a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
+++ b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
@@ -27,13 +27,27 @@
/**
* Imports.
*/
-import { AmountJson } from "@gnu-taler/taler-util";
+import {
+ AgeCommitmentProof,
+ AmountJson,
+ AmountString,
+ CoinEnvelope,
+ DenominationPubKey,
+ EddsaPublicKeyString,
+ EddsaSignatureString,
+ ExchangeProtocolVersion,
+ RefreshPlanchetInfo,
+ TalerProtocolTimestamp,
+ UnblindedSignature,
+ WalletAccountMergeFlags,
+} from "@gnu-taler/taler-util";
export interface RefreshNewDenomInfo {
count: number;
- value: AmountJson;
- feeWithdraw: AmountJson;
- denomPub: string;
+ value: AmountString;
+ feeWithdraw: AmountString;
+ denomPub: DenominationPubKey;
+ denomPubHash: string;
}
/**
@@ -41,11 +55,14 @@ export interface RefreshNewDenomInfo {
* secret seed.
*/
export interface DeriveRefreshSessionRequest {
+ exchangeProtocolVersion: ExchangeProtocolVersion;
sessionSecretSeed: string;
kappa: number;
meltCoinPub: string;
meltCoinPriv: string;
meltCoinDenomPubHash: string;
+ meltCoinMaxAge: number;
+ meltCoinAgeCommitmentProof?: AgeCommitmentProof;
newCoinDenoms: RefreshNewDenomInfo[];
feeRefresh: AmountJson;
}
@@ -67,32 +84,7 @@ export interface DerivedRefreshSession {
/**
* Planchets for each cut-and-choose instance.
*/
- planchetsForGammas: {
- /**
- * Public key for the coin.
- */
- publicKey: string;
-
- /**
- * Private key for the coin.
- */
- privateKey: string;
-
- /**
- * Blinded public key.
- */
- coinEv: string;
-
- /**
- * Hash of the blinded public key.
- */
- coinEvHash: string;
-
- /**
- * Blinding key used.
- */
- blindingKey: string;
- }[][];
+ planchetsForGammas: RefreshPlanchetInfo[][];
/**
* The transfer keys, kappa of them.
@@ -117,7 +109,7 @@ export interface DerivedRefreshSession {
export interface DeriveTipRequest {
secretSeed: string;
- denomPub: string;
+ denomPub: DenominationPubKey;
planchetIndex: number;
}
@@ -126,10 +118,11 @@ export interface DeriveTipRequest {
*/
export interface DerivedTipPlanchet {
blindingKey: string;
- coinEv: string;
+ coinEv: CoinEnvelope;
coinEvHash: string;
coinPriv: string;
coinPub: string;
+ ageCommitmentProof: AgeCommitmentProof | undefined;
}
export interface SignTrackTransactionRequest {
@@ -139,3 +132,200 @@ export interface SignTrackTransactionRequest {
merchantPriv: string;
merchantPub: string;
}
+
+/**
+ * Request to create a recoup request payload.
+ */
+export interface CreateRecoupReqRequest {
+ coinPub: string;
+ coinPriv: string;
+ blindingKey: string;
+ denomPub: DenominationPubKey;
+ denomPubHash: string;
+ denomSig: UnblindedSignature;
+}
+
+/**
+ * Request to create a recoup-refresh request payload.
+ */
+export interface CreateRecoupRefreshReqRequest {
+ coinPub: string;
+ coinPriv: string;
+ blindingKey: string;
+ denomPub: DenominationPubKey;
+ denomPubHash: string;
+ denomSig: UnblindedSignature;
+}
+
+export interface EncryptedContract {
+ /**
+ * Encrypted contract.
+ */
+ econtract: string;
+
+ /**
+ * Signature over the (encrypted) contract.
+ */
+ econtract_sig: EddsaSignatureString;
+
+ /**
+ * Ephemeral public key for the DH operation to decrypt the encrypted contract.
+ */
+ contract_pub: EddsaPublicKeyString;
+}
+
+export interface EncryptContractRequest {
+ contractTerms: any;
+ contractPriv: string;
+ contractPub: string;
+ pursePub: string;
+ pursePriv: string;
+ mergePriv: string;
+ nonce: string;
+}
+
+export interface EncryptContractResponse {
+ econtract: EncryptedContract;
+}
+
+export interface EncryptContractForDepositRequest {
+ contractTerms: any;
+
+ contractPriv: string;
+ contractPub: string;
+
+ pursePub: string;
+ pursePriv: string;
+
+ nonce: string;
+}
+
+export interface EncryptContractForDepositResponse {
+ econtract: EncryptedContract;
+}
+
+export interface DecryptContractRequest {
+ ciphertext: string;
+ pursePub: string;
+ contractPriv: string;
+}
+
+export interface DecryptContractResponse {
+ contractTerms: any;
+ mergePriv: string;
+}
+
+export interface DecryptContractForDepositRequest {
+ ciphertext: string;
+ pursePub: string;
+ contractPriv: string;
+}
+
+export interface DecryptContractForDepositResponse {
+ contractTerms: any;
+}
+
+export interface SignPurseMergeRequest {
+ mergeTimestamp: TalerProtocolTimestamp;
+
+ pursePub: string;
+
+ reservePayto: string;
+
+ reservePriv: string;
+
+ mergePriv: string;
+
+ purseExpiration: TalerProtocolTimestamp;
+
+ purseAmount: AmountString;
+ purseFee: AmountString;
+
+ contractTermsHash: string;
+
+ /**
+ * Flags.
+ */
+ flags: WalletAccountMergeFlags;
+}
+
+export interface SignPurseMergeResponse {
+ /**
+ * Signature made by the purse's merge private key.
+ */
+ mergeSig: string;
+
+ accountSig: string;
+}
+
+export interface SignRefundRequest {
+ merchantPriv: string;
+ merchantPub: string;
+ contractTermsHash: string;
+ coinPub: string;
+ rtransactionId: number;
+ refundAmount: AmountString;
+}
+
+export interface SignRefundResponse {
+ sig: string;
+}
+
+export interface SignDeletePurseRequest {
+ pursePriv: string;
+}
+
+export interface SignDeletePurseResponse {
+ sig: EddsaSignatureString;
+}
+
+export interface SignCoinHistoryRequest {
+ coinPub: string;
+ coinPriv: string;
+ startOffset: number;
+}
+
+export interface SignCoinHistoryResponse {
+ sig: EddsaSignatureString;
+}
+
+export interface SignReservePurseCreateRequest {
+ mergeTimestamp: TalerProtocolTimestamp;
+
+ pursePub: string;
+
+ pursePriv: string;
+
+ reservePayto: string;
+
+ reservePriv: string;
+
+ mergePriv: string;
+
+ purseExpiration: TalerProtocolTimestamp;
+
+ purseAmount: AmountString;
+ purseFee: AmountString;
+
+ contractTermsHash: string;
+
+ /**
+ * Flags.
+ */
+ flags: WalletAccountMergeFlags;
+}
+
+/**
+ * Response with signatures needed for creation of a purse
+ * from a reserve for a PULL payment.
+ */
+export interface SignReservePurseCreateResponse {
+ /**
+ * Signature made by the purse's merge private key.
+ */
+ mergeSig: string;
+
+ accountSig: string;
+
+ purseSig: string;
+}
diff --git a/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.test.ts b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.test.ts
new file mode 100644
index 000000000..96e2ee735
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.test.ts
@@ -0,0 +1,128 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AbsoluteTime, TalerErrorCode } from "@gnu-taler/taler-util";
+import test from "ava";
+import { CryptoDispatcher, CryptoWorkerFactory } from "./crypto-dispatcher.js";
+import {
+ CryptoWorker,
+ CryptoWorkerResponseMessage,
+} from "./cryptoWorkerInterface.js";
+
+export class MyCryptoWorker implements CryptoWorker {
+ /**
+ * Function to be called when we receive a message from the worker thread.
+ */
+ onmessage: undefined | ((m: any) => void) = undefined;
+
+ /**
+ * Function to be called when we receive an error from the worker thread.
+ */
+ onerror: undefined | ((m: any) => void) = undefined;
+
+ /**
+ * Add an event listener for either an "error" or "message" event.
+ */
+ addEventListener(event: "message" | "error", fn: (x: any) => void): void {
+ switch (event) {
+ case "message":
+ this.onmessage = fn;
+ break;
+ case "error":
+ this.onerror = fn;
+ break;
+ }
+ }
+
+ private dispatchMessage(msg: any): void {
+ if (this.onmessage) {
+ this.onmessage(msg);
+ }
+ }
+
+ /**
+ * Send a message to the worker thread.
+ */
+ postMessage(msg: any): void {
+ const handleRequest = async () => {
+ let responseMsg: CryptoWorkerResponseMessage;
+ if (msg.operation === "testSuccess") {
+ responseMsg = {
+ id: msg.id,
+ type: "success",
+ result: {
+ testResult: 42,
+ },
+ };
+ } else if (msg.operation === "testError") {
+ responseMsg = {
+ id: msg.id,
+ type: "error",
+ error: {
+ code: TalerErrorCode.ANASTASIS_EMAIL_INVALID,
+ when: AbsoluteTime.now(),
+ hint: "bla",
+ },
+ };
+ } else if (msg.operation === "testTimeout") {
+ // Don't respond
+ return;
+ }
+ try {
+ setTimeout(() => this.dispatchMessage(responseMsg), 0);
+ } catch (e) {
+ console.error("got error during dispatch", e);
+ }
+ };
+ handleRequest().catch((e) => {
+ console.error("Error while handling crypto request:", e);
+ });
+ }
+
+ /**
+ * Forcibly terminate the worker thread.
+ */
+ terminate(): void {
+ // This is a no-op.
+ }
+}
+
+export class MyCryptoWorkerFactory implements CryptoWorkerFactory {
+ startWorker(): CryptoWorker {
+ return new MyCryptoWorker();
+ }
+
+ getConcurrency(): number {
+ return 1;
+ }
+}
+
+test("continues after error", async (t) => {
+ const cryptoDisp = new CryptoDispatcher(new MyCryptoWorkerFactory());
+ const resp1 = await cryptoDisp.doRpc("testSuccess", 0, {});
+ t.assert((resp1 as any).testResult === 42);
+ const exc = await t.throwsAsync(async () => {
+ const resp2 = await cryptoDisp.doRpc("testError", 0, {});
+ });
+
+ // Check that it still works after one error.
+ const resp2 = await cryptoDisp.doRpc("testSuccess", 0, {});
+ t.assert((resp2 as any).testResult === 42);
+
+ // Check that it still works after timeout.
+ const resp3 = await cryptoDisp.doRpc("testSuccess", 0, {});
+ t.assert((resp3 as any).testResult === 42);
+});
diff --git a/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts
new file mode 100644
index 000000000..f86163723
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/crypto-dispatcher.ts
@@ -0,0 +1,386 @@
+/*
+ This file is part of GNU Taler
+ (C) 2016 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * API to access the Taler crypto worker.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import {
+ j2s,
+ Logger,
+ openPromise,
+ performanceNow,
+ TalerError,
+ TalerErrorCode,
+ timer,
+ TimerHandle,
+} from "@gnu-taler/taler-util";
+import { nullCrypto, TalerCryptoInterface } from "../cryptoImplementation.js";
+import { CryptoWorker } from "./cryptoWorkerInterface.js";
+
+const logger = new Logger("cryptoDispatcher.ts");
+
+/**
+ * State of a crypto worker.
+ */
+interface WorkerInfo {
+ /**
+ * The actual worker thread.
+ */
+ w: CryptoWorker | null;
+
+ /**
+ * Work we're currently executing or null if not busy.
+ */
+ currentWorkItem: WorkItem | null;
+
+ /**
+ * Timer to terminate the worker if it's not busy enough.
+ */
+ idleTimeoutHandle: TimerHandle | null;
+}
+
+interface WorkItem {
+ operation: string;
+ req: unknown;
+ resolve: any;
+ reject: any;
+
+ /**
+ * Serial id to identify a matching response.
+ */
+ rpcId: number;
+
+ /**
+ * Time when the work was submitted to a (non-busy) worker thread.
+ */
+ startTime: BigInt;
+
+ state: WorkItemState;
+}
+
+/**
+ * Number of different priorities. Each priority p
+ * must be 0 <= p < NUM_PRIO.
+ */
+const NUM_PRIO = 5;
+
+/**
+ * A crypto worker factory is responsible for creating new
+ * crypto workers on-demand.
+ */
+export interface CryptoWorkerFactory {
+ /**
+ * Start a new worker.
+ */
+ startWorker(): CryptoWorker;
+
+ /**
+ * Query the number of workers that should be
+ * run at the same time.
+ */
+ getConcurrency(): number;
+}
+
+export class CryptoApiStoppedError extends Error {
+ constructor() {
+ super("Crypto API stopped");
+ Object.setPrototypeOf(this, CryptoApiStoppedError.prototype);
+ }
+}
+
+export enum WorkItemState {
+ Pending = 1,
+ Running = 2,
+ Finished = 3,
+}
+
+/**
+ * Dispatcher for cryptographic operations to underlying crypto workers.
+ */
+export class CryptoDispatcher {
+ private nextRpcId = 1;
+ private workers: WorkerInfo[];
+ private workQueues: WorkItem[][];
+
+ private workerFactory: CryptoWorkerFactory;
+
+ /**
+ * Number of busy workers.
+ */
+ private numBusy = 0;
+
+ /**
+ * Did we stop accepting new requests?
+ */
+ private stopped = false;
+
+ /**
+ * Terminate all worker threads.
+ */
+ terminateWorkers(): void {
+ for (const worker of this.workers) {
+ if (worker.idleTimeoutHandle) {
+ worker.idleTimeoutHandle.clear();
+ worker.idleTimeoutHandle = null;
+ }
+ if (worker.currentWorkItem) {
+ worker.currentWorkItem.reject(new CryptoApiStoppedError());
+ worker.currentWorkItem = null;
+ }
+ if (worker.w) {
+ logger.trace("terminating worker");
+ worker.w.terminate();
+ worker.w = null;
+ }
+ }
+ }
+
+ stop(): void {
+ this.stopped = true;
+ this.terminateWorkers();
+ }
+
+ /**
+ * Start a worker (if not started) and set as busy.
+ */
+ wake(ws: WorkerInfo, work: WorkItem): void {
+ if (this.stopped) {
+ return;
+ }
+ if (ws.currentWorkItem !== null) {
+ throw Error("assertion failed");
+ }
+ ws.currentWorkItem = work;
+ this.numBusy++;
+ let worker: CryptoWorker;
+ if (!ws.w) {
+ worker = this.workerFactory.startWorker();
+ worker.onmessage = (m: any) => this.handleWorkerMessage(ws, m);
+ worker.onerror = (e: any) => this.handleWorkerError(ws, e);
+ ws.w = worker;
+ } else {
+ worker = ws.w;
+ }
+
+ const msg: any = {
+ req: work.req,
+ id: work.rpcId,
+ operation: work.operation,
+ };
+ this.resetWorkerTimeout(ws);
+ work.startTime = performanceNow();
+ work.state = WorkItemState.Running;
+ timer.after(0, () => worker.postMessage(msg));
+ }
+
+ resetWorkerTimeout(ws: WorkerInfo): void {
+ if (ws.idleTimeoutHandle !== null) {
+ ws.idleTimeoutHandle.clear();
+ ws.idleTimeoutHandle = null;
+ }
+ const destroy = (): void => {
+ logger.trace("destroying crypto worker after idle timeout");
+ // terminate worker if it's idle
+ if (ws.w && ws.currentWorkItem === null) {
+ ws.w.terminate();
+ ws.w = null;
+ }
+ };
+ ws.idleTimeoutHandle = timer.after(15 * 1000, destroy);
+ ws.idleTimeoutHandle.unref();
+ }
+
+ private resetWorker(ws: WorkerInfo, e: any): void {
+ try {
+ if (ws.w) {
+ ws.w.terminate();
+ ws.w = null;
+ }
+ } catch (e) {
+ logger.error(e as string);
+ }
+ if (ws.currentWorkItem !== null) {
+ ws.currentWorkItem.state = WorkItemState.Finished;
+ ws.currentWorkItem.reject(e);
+ ws.currentWorkItem = null;
+ this.numBusy--;
+ }
+ this.findWork(ws);
+ }
+
+ handleWorkerError(ws: WorkerInfo, e: any): void {
+ if (ws.currentWorkItem) {
+ logger.error(`error in worker during ${ws.currentWorkItem.operation}`, e);
+ } else {
+ logger.error("error in worker", e);
+ }
+ logger.error(e.message);
+ this.resetWorker(ws, e);
+ }
+
+ private findWork(ws: WorkerInfo): void {
+ // try to find more work for this worker
+ for (let i = 0; i < NUM_PRIO; i++) {
+ const q = this.workQueues[NUM_PRIO - i - 1];
+ if (q.length !== 0) {
+ const work: WorkItem | undefined = q.shift();
+ if (!work) {
+ continue;
+ }
+ this.wake(ws, work);
+ return;
+ }
+ }
+ }
+
+ handleWorkerMessage(ws: WorkerInfo, msg: any): void {
+ const id = msg.id;
+ if (typeof id !== "number") {
+ logger.error("rpc id must be number");
+ return;
+ }
+ const currentWorkItem = ws.currentWorkItem;
+ ws.currentWorkItem = null;
+ if (!currentWorkItem) {
+ logger.error("unsolicited response from worker");
+ return;
+ }
+ if (id !== currentWorkItem.rpcId) {
+ logger.error(`RPC with id ${id} has no registry entry`);
+ return;
+ }
+ if (currentWorkItem.state === WorkItemState.Running) {
+ this.numBusy--;
+ currentWorkItem.state = WorkItemState.Finished;
+ if (msg.type === "success") {
+ currentWorkItem.resolve(msg.result);
+ } else if (msg.type === "error") {
+ currentWorkItem.reject(
+ TalerError.fromDetail(TalerErrorCode.WALLET_CRYPTO_WORKER_ERROR, {
+ innerError: msg.error,
+ }),
+ );
+ } else {
+ logger.warn(`bad message: ${j2s(msg)}`);
+ currentWorkItem.reject(new Error("bad message from crypto worker"));
+ }
+ }
+ this.findWork(ws);
+ }
+
+ cryptoApi: TalerCryptoInterface;
+
+ constructor(workerFactory: CryptoWorkerFactory) {
+ const fns: any = {};
+ for (const name of Object.keys(nullCrypto)) {
+ fns[name] = (x: any) => this.doRpc(name, 0, x);
+ }
+
+ this.cryptoApi = fns;
+
+ this.workerFactory = workerFactory;
+ this.workers = new Array<WorkerInfo>(workerFactory.getConcurrency());
+
+ for (let i = 0; i < this.workers.length; i++) {
+ this.workers[i] = {
+ currentWorkItem: null,
+ idleTimeoutHandle: null,
+ w: null,
+ };
+ }
+
+ this.workQueues = [];
+ for (let i = 0; i < NUM_PRIO; i++) {
+ this.workQueues.push([]);
+ }
+ }
+
+ doRpc<T>(operation: string, priority: number, req: unknown): Promise<T> {
+ if (this.stopped) {
+ throw new CryptoApiStoppedError();
+ }
+ const rpcId = this.nextRpcId++;
+ const myProm = openPromise<T>();
+ const workItem: WorkItem = {
+ operation,
+ req,
+ resolve: myProm.resolve,
+ reject: myProm.reject,
+ rpcId,
+ startTime: BigInt(0),
+ state: WorkItemState.Pending,
+ };
+ let scheduled = false;
+ if (this.numBusy === this.workers.length) {
+ // All workers are busy, queue work item
+ const q = this.workQueues[priority];
+ if (!q) {
+ throw Error("assertion failed");
+ }
+ this.workQueues[priority].push(workItem);
+ scheduled = true;
+ }
+ if (!scheduled) {
+ for (const ws of this.workers) {
+ if (ws.currentWorkItem !== null) {
+ continue;
+ }
+ this.wake(ws, workItem);
+ scheduled = true;
+ break;
+ }
+ }
+
+ if (!scheduled) {
+ // Could not schedule work.
+ throw Error("assertion failed");
+ }
+
+ // Make sure that we wait for the result while a timer is active
+ // to prevent the event loop from dying, as just waiting for a promise
+ // does not keep the process alive in Node.
+ // (The worker child process won't keep us alive either, because we un-ref
+ // it to make sure it doesn't keep us alive if there is no work.)
+ return new Promise<T>((resolve, reject) => {
+ let timeoutHandle: TimerHandle | undefined = undefined;
+ const timeoutMs = 5000;
+ const onTimeout = () => {
+ // FIXME: Maybe destroy and re-init worker if request is in processing
+ // state and really taking too long?
+ logger.warn(
+ `crypto RPC call ('${operation}') has been queued for a long time`,
+ );
+ timeoutHandle = timer.after(timeoutMs, onTimeout);
+ };
+ myProm.promise
+ .then((x) => {
+ timeoutHandle?.clear();
+ resolve(x);
+ })
+ .catch((x) => {
+ logger.info(`crypto RPC call ${operation} threw`);
+ timeoutHandle?.clear();
+ reject(x);
+ });
+ });
+ }
+}
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
deleted file mode 100644
index 6bace01a3..000000000
--- a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts
+++ /dev/null
@@ -1,457 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2016 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * API to access the Taler crypto worker thread.
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import { CoinRecord, DenominationRecord, WireFee } from "../../db.js";
-
-import { CryptoWorker } from "./cryptoWorker.js";
-
-import { RecoupRequest, CoinDepositPermission } from "@gnu-taler/taler-util";
-
-import {
- BenchmarkResult,
- PlanchetCreationResult,
- PlanchetCreationRequest,
- DepositInfo,
- MakeSyncSignatureRequest,
-} from "@gnu-taler/taler-util";
-
-import * as timer from "../../util/timer.js";
-import { Logger } from "@gnu-taler/taler-util";
-import {
- DerivedRefreshSession,
- DerivedTipPlanchet,
- DeriveRefreshSessionRequest,
- DeriveTipRequest,
- SignTrackTransactionRequest,
-} from "../cryptoTypes.js";
-
-const logger = new Logger("cryptoApi.ts");
-
-/**
- * State of a crypto worker.
- */
-interface WorkerState {
- /**
- * The actual worker thread.
- */
- w: CryptoWorker | null;
-
- /**
- * Work we're currently executing or null if not busy.
- */
- currentWorkItem: WorkItem | null;
-
- /**
- * Timer to terminate the worker if it's not busy enough.
- */
- terminationTimerHandle: timer.TimerHandle | null;
-}
-
-interface WorkItem {
- operation: string;
- args: any[];
- resolve: any;
- reject: any;
-
- /**
- * Serial id to identify a matching response.
- */
- rpcId: number;
-
- /**
- * Time when the work was submitted to a (non-busy) worker thread.
- */
- startTime: BigInt;
-}
-
-/**
- * Number of different priorities. Each priority p
- * must be 0 <= p < NUM_PRIO.
- */
-const NUM_PRIO = 5;
-
-export interface CryptoWorkerFactory {
- /**
- * Start a new worker.
- */
- startWorker(): CryptoWorker;
-
- /**
- * Query the number of workers that should be
- * run at the same time.
- */
- getConcurrency(): number;
-}
-
-/**
- * Crypto API that interfaces manages a background crypto thread
- * for the execution of expensive operations.
- */
-export class CryptoApi {
- private nextRpcId = 1;
- private workers: WorkerState[];
- private workQueues: WorkItem[][];
-
- private workerFactory: CryptoWorkerFactory;
-
- /**
- * Number of busy workers.
- */
- private numBusy = 0;
-
- /**
- * Did we stop accepting new requests?
- */
- private stopped = false;
-
- /**
- * Terminate all worker threads.
- */
- terminateWorkers(): void {
- for (const worker of this.workers) {
- if (worker.w) {
- logger.trace("terminating worker");
- worker.w.terminate();
- if (worker.terminationTimerHandle) {
- worker.terminationTimerHandle.clear();
- worker.terminationTimerHandle = null;
- }
- if (worker.currentWorkItem) {
- worker.currentWorkItem.reject(Error("explicitly terminated"));
- worker.currentWorkItem = null;
- }
- worker.w = null;
- }
- }
- }
-
- stop(): void {
- this.terminateWorkers();
- this.stopped = true;
- }
-
- /**
- * Start a worker (if not started) and set as busy.
- */
- wake(ws: WorkerState, work: WorkItem): void {
- if (this.stopped) {
- logger.trace("cryptoApi is stopped");
- return;
- }
- if (ws.currentWorkItem !== null) {
- throw Error("assertion failed");
- }
- ws.currentWorkItem = work;
- this.numBusy++;
- let worker: CryptoWorker;
- if (!ws.w) {
- worker = this.workerFactory.startWorker();
- worker.onmessage = (m: any) => this.handleWorkerMessage(ws, m);
- worker.onerror = (e: any) => this.handleWorkerError(ws, e);
- ws.w = worker;
- } else {
- worker = ws.w;
- }
-
- const msg: any = {
- args: work.args,
- id: work.rpcId,
- operation: work.operation,
- };
- this.resetWorkerTimeout(ws);
- work.startTime = timer.performanceNow();
- timer.after(0, () => worker.postMessage(msg));
- }
-
- resetWorkerTimeout(ws: WorkerState): void {
- if (ws.terminationTimerHandle !== null) {
- ws.terminationTimerHandle.clear();
- ws.terminationTimerHandle = null;
- }
- const destroy = (): void => {
- // terminate worker if it's idle
- if (ws.w && ws.currentWorkItem === null) {
- ws.w.terminate();
- ws.w = null;
- }
- };
- ws.terminationTimerHandle = timer.after(15 * 1000, destroy);
- //ws.terminationTimerHandle.unref();
- }
-
- handleWorkerError(ws: WorkerState, e: any): void {
- if (ws.currentWorkItem) {
- logger.error(`error in worker during ${ws.currentWorkItem.operation}`, e);
- } else {
- logger.error("error in worker", e);
- }
- logger.error(e.message);
- try {
- if (ws.w) {
- ws.w.terminate();
- ws.w = null;
- }
- } catch (e) {
- logger.error(e as string);
- }
- if (ws.currentWorkItem !== null) {
- ws.currentWorkItem.reject(e);
- ws.currentWorkItem = null;
- this.numBusy--;
- }
- this.findWork(ws);
- }
-
- private findWork(ws: WorkerState): void {
- // try to find more work for this worker
- for (let i = 0; i < NUM_PRIO; i++) {
- const q = this.workQueues[NUM_PRIO - i - 1];
- if (q.length !== 0) {
- const work: WorkItem | undefined = q.shift();
- if (!work) {
- continue;
- }
- this.wake(ws, work);
- return;
- }
- }
- }
-
- handleWorkerMessage(ws: WorkerState, msg: any): void {
- const id = msg.data.id;
- if (typeof id !== "number") {
- console.error("rpc id must be number");
- return;
- }
- const currentWorkItem = ws.currentWorkItem;
- ws.currentWorkItem = null;
- this.numBusy--;
- this.findWork(ws);
- if (!currentWorkItem) {
- console.error("unsolicited response from worker");
- return;
- }
- if (id !== currentWorkItem.rpcId) {
- console.error(`RPC with id ${id} has no registry entry`);
- return;
- }
-
- currentWorkItem.resolve(msg.data.result);
- }
-
- constructor(workerFactory: CryptoWorkerFactory) {
- this.workerFactory = workerFactory;
- this.workers = new Array<WorkerState>(workerFactory.getConcurrency());
-
- for (let i = 0; i < this.workers.length; i++) {
- this.workers[i] = {
- currentWorkItem: null,
- terminationTimerHandle: null,
- w: null,
- };
- }
-
- this.workQueues = [];
- for (let i = 0; i < NUM_PRIO; i++) {
- this.workQueues.push([]);
- }
- }
-
- private doRpc<T>(
- operation: string,
- priority: number,
- ...args: any[]
- ): Promise<T> {
- const p: Promise<T> = new Promise<T>((resolve, reject) => {
- const rpcId = this.nextRpcId++;
- const workItem: WorkItem = {
- operation,
- args,
- resolve,
- reject,
- rpcId,
- startTime: BigInt(0),
- };
-
- if (this.numBusy === this.workers.length) {
- const q = this.workQueues[priority];
- if (!q) {
- throw Error("assertion failed");
- }
- this.workQueues[priority].push(workItem);
- return;
- }
-
- for (const ws of this.workers) {
- if (ws.currentWorkItem !== null) {
- continue;
- }
- this.wake(ws, workItem);
- return;
- }
-
- throw Error("assertion failed");
- });
-
- return p;
- }
-
- createPlanchet(
- req: PlanchetCreationRequest,
- ): Promise<PlanchetCreationResult> {
- return this.doRpc<PlanchetCreationResult>("createPlanchet", 1, req);
- }
-
- createTipPlanchet(req: DeriveTipRequest): Promise<DerivedTipPlanchet> {
- return this.doRpc<DerivedTipPlanchet>("createTipPlanchet", 1, req);
- }
-
- signTrackTransaction(req: SignTrackTransactionRequest): Promise<string> {
- return this.doRpc<string>("signTrackTransaction", 1, req);
- }
-
- hashString(str: string): Promise<string> {
- return this.doRpc<string>("hashString", 1, str);
- }
-
- hashEncoded(encodedBytes: string): Promise<string> {
- return this.doRpc<string>("hashEncoded", 1, encodedBytes);
- }
-
- isValidDenom(denom: DenominationRecord, masterPub: string): Promise<boolean> {
- return this.doRpc<boolean>("isValidDenom", 2, denom, masterPub);
- }
-
- isValidWireFee(
- type: string,
- wf: WireFee,
- masterPub: string,
- ): Promise<boolean> {
- return this.doRpc<boolean>("isValidWireFee", 2, type, wf, masterPub);
- }
-
- isValidPaymentSignature(
- sig: string,
- contractHash: string,
- merchantPub: string,
- ): Promise<boolean> {
- return this.doRpc<boolean>(
- "isValidPaymentSignature",
- 1,
- sig,
- contractHash,
- merchantPub,
- );
- }
-
- signDepositPermission(
- depositInfo: DepositInfo,
- ): Promise<CoinDepositPermission> {
- return this.doRpc<CoinDepositPermission>(
- "signDepositPermission",
- 3,
- depositInfo,
- );
- }
-
- createEddsaKeypair(): Promise<{ priv: string; pub: string }> {
- return this.doRpc<{ priv: string; pub: string }>("createEddsaKeypair", 1);
- }
-
- eddsaGetPublic(key: string): Promise<{ priv: string; pub: string }> {
- return this.doRpc<{ priv: string; pub: string }>("eddsaGetPublic", 1, key);
- }
-
- rsaUnblind(sig: string, bk: string, pk: string): Promise<string> {
- return this.doRpc<string>("rsaUnblind", 4, sig, bk, pk);
- }
-
- rsaVerify(hm: string, sig: string, pk: string): Promise<boolean> {
- return this.doRpc<boolean>("rsaVerify", 4, hm, sig, pk);
- }
-
- isValidWireAccount(
- paytoUri: string,
- sig: string,
- masterPub: string,
- ): Promise<boolean> {
- return this.doRpc<boolean>(
- "isValidWireAccount",
- 4,
- paytoUri,
- sig,
- masterPub,
- );
- }
-
- isValidContractTermsSignature(
- contractTermsHash: string,
- sig: string,
- merchantPub: string,
- ): Promise<boolean> {
- return this.doRpc<boolean>(
- "isValidContractTermsSignature",
- 4,
- contractTermsHash,
- sig,
- merchantPub,
- );
- }
-
- createRecoupRequest(coin: CoinRecord): Promise<RecoupRequest> {
- return this.doRpc<RecoupRequest>("createRecoupRequest", 1, coin);
- }
-
- deriveRefreshSession(
- req: DeriveRefreshSessionRequest,
- ): Promise<DerivedRefreshSession> {
- return this.doRpc<DerivedRefreshSession>("deriveRefreshSession", 4, req);
- }
-
- signCoinLink(
- oldCoinPriv: string,
- newDenomHash: string,
- oldCoinPub: string,
- transferPub: string,
- coinEv: string,
- ): Promise<string> {
- return this.doRpc<string>(
- "signCoinLink",
- 4,
- oldCoinPriv,
- newDenomHash,
- oldCoinPub,
- transferPub,
- coinEv,
- );
- }
-
- benchmark(repetitions: number): Promise<BenchmarkResult> {
- return this.doRpc<BenchmarkResult>("benchmark", 1, repetitions);
- }
-
- makeSyncSignature(req: MakeSyncSignatureRequest): Promise<string> {
- return this.doRpc<string>("makeSyncSignature", 3, req);
- }
-}
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts
deleted file mode 100644
index c42ece778..000000000
--- a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts
+++ /dev/null
@@ -1,593 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2020 Taler Systems SA
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Synchronous implementation of crypto-related functions for the wallet.
- *
- * The functionality is parameterized over an Emscripten environment.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-
-// FIXME: Crypto should not use DB Types!
-import {
- CoinRecord,
- DenominationRecord,
- WireFee,
- CoinSourceType,
-} from "../../db.js";
-
-import {
- buildSigPS,
- CoinDepositPermission,
- RecoupRequest,
- RefreshPlanchetInfo,
- SignaturePurposeBuilder,
- TalerSignaturePurpose,
-} from "@gnu-taler/taler-util";
-// FIXME: These types should be internal to the wallet!
-import {
- BenchmarkResult,
- PlanchetCreationResult,
- PlanchetCreationRequest,
- DepositInfo,
- MakeSyncSignatureRequest,
-} from "@gnu-taler/taler-util";
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import * as timer from "../../util/timer.js";
-import {
- encodeCrock,
- decodeCrock,
- createEddsaKeyPair,
- hash,
- rsaBlind,
- eddsaVerify,
- eddsaSign,
- rsaUnblind,
- stringToBytes,
- createHashContext,
- keyExchangeEcdheEddsa,
- setupRefreshPlanchet,
- rsaVerify,
- setupRefreshTransferPub,
- setupTipPlanchet,
- setupWithdrawPlanchet,
- eddsaGetPublic,
-} from "@gnu-taler/taler-util";
-import { randomBytes } from "@gnu-taler/taler-util";
-import { kdf } from "@gnu-taler/taler-util";
-import { Timestamp, timestampTruncateToSecond } from "@gnu-taler/taler-util";
-
-import { Logger } from "@gnu-taler/taler-util";
-import {
- DerivedRefreshSession,
- DerivedTipPlanchet,
- DeriveRefreshSessionRequest,
- DeriveTipRequest,
- SignTrackTransactionRequest,
-} from "../cryptoTypes.js";
-import bigint from "big-integer";
-
-const logger = new Logger("cryptoImplementation.ts");
-
-function amountToBuffer(amount: AmountJson): Uint8Array {
- const buffer = new ArrayBuffer(8 + 4 + 12);
- const dvbuf = new DataView(buffer);
- const u8buf = new Uint8Array(buffer);
- const curr = stringToBytes(amount.currency);
- if (typeof dvbuf.setBigUint64 !== "undefined") {
- dvbuf.setBigUint64(0, BigInt(amount.value));
- } else {
- const arr = bigint(amount.value).toArray(2 ** 8).value;
- let offset = 8 - arr.length;
- for (let i = 0; i < arr.length; i++) {
- dvbuf.setUint8(offset++, arr[i]);
- }
- }
- dvbuf.setUint32(8, amount.fraction);
- u8buf.set(curr, 8 + 4);
-
- return u8buf;
-}
-
-function timestampRoundedToBuffer(ts: Timestamp): Uint8Array {
- const b = new ArrayBuffer(8);
- const v = new DataView(b);
- const tsRounded = timestampTruncateToSecond(ts);
- if (typeof v.setBigUint64 !== "undefined") {
- const s = BigInt(tsRounded.t_ms) * BigInt(1000);
- v.setBigUint64(0, s);
- } else {
- const s =
- tsRounded.t_ms === "never"
- ? bigint.zero
- : bigint(tsRounded.t_ms).times(1000);
- const arr = s.toArray(2 ** 8).value;
- let offset = 8 - arr.length;
- for (let i = 0; i < arr.length; i++) {
- v.setUint8(offset++, arr[i]);
- }
- }
- return new Uint8Array(b);
-}
-
-export class CryptoImplementation {
- static enableTracing = false;
-
- /**
- * Create a pre-coin of the given denomination to be withdrawn from then given
- * reserve.
- */
- createPlanchet(req: PlanchetCreationRequest): PlanchetCreationResult {
- const reservePub = decodeCrock(req.reservePub);
- const reservePriv = decodeCrock(req.reservePriv);
- const denomPub = decodeCrock(req.denomPub);
- const derivedPlanchet = setupWithdrawPlanchet(
- decodeCrock(req.secretSeed),
- req.coinIndex,
- );
- const coinPubHash = hash(derivedPlanchet.coinPub);
- const ev = rsaBlind(coinPubHash, derivedPlanchet.bks, denomPub);
- const amountWithFee = Amounts.add(req.value, req.feeWithdraw).amount;
- const denomPubHash = hash(denomPub);
- const evHash = hash(ev);
-
- const withdrawRequest = buildSigPS(
- TalerSignaturePurpose.WALLET_RESERVE_WITHDRAW,
- )
- .put(reservePub)
- .put(amountToBuffer(amountWithFee))
- .put(denomPubHash)
- .put(evHash)
- .build();
-
- const sig = eddsaSign(withdrawRequest, reservePriv);
-
- const planchet: PlanchetCreationResult = {
- blindingKey: encodeCrock(derivedPlanchet.bks),
- coinEv: encodeCrock(ev),
- coinPriv: encodeCrock(derivedPlanchet.coinPriv),
- coinPub: encodeCrock(derivedPlanchet.coinPub),
- coinValue: req.value,
- denomPub: encodeCrock(denomPub),
- denomPubHash: encodeCrock(denomPubHash),
- reservePub: encodeCrock(reservePub),
- withdrawSig: encodeCrock(sig),
- coinEvHash: encodeCrock(evHash),
- };
- return planchet;
- }
-
- /**
- * Create a planchet used for tipping, including the private keys.
- */
- createTipPlanchet(req: DeriveTipRequest): DerivedTipPlanchet {
- const fc = setupTipPlanchet(decodeCrock(req.secretSeed), req.planchetIndex);
- const denomPub = decodeCrock(req.denomPub);
- const coinPubHash = hash(fc.coinPub);
- const ev = rsaBlind(coinPubHash, fc.bks, denomPub);
-
- const tipPlanchet: DerivedTipPlanchet = {
- blindingKey: encodeCrock(fc.bks),
- coinEv: encodeCrock(ev),
- coinEvHash: encodeCrock(hash(ev)),
- coinPriv: encodeCrock(fc.coinPriv),
- coinPub: encodeCrock(fc.coinPub),
- };
- return tipPlanchet;
- }
-
- signTrackTransaction(req: SignTrackTransactionRequest): string {
- const p = buildSigPS(TalerSignaturePurpose.MERCHANT_TRACK_TRANSACTION)
- .put(decodeCrock(req.contractTermsHash))
- .put(decodeCrock(req.wireHash))
- .put(decodeCrock(req.merchantPub))
- .put(decodeCrock(req.coinPub))
- .build();
- return encodeCrock(eddsaSign(p, decodeCrock(req.merchantPriv)));
- }
-
- /**
- * Create and sign a message to recoup a coin.
- */
- createRecoupRequest(coin: CoinRecord): RecoupRequest {
- const p = buildSigPS(TalerSignaturePurpose.WALLET_COIN_RECOUP)
- .put(decodeCrock(coin.coinPub))
- .put(decodeCrock(coin.denomPubHash))
- .put(decodeCrock(coin.blindingKey))
- .build();
-
- const coinPriv = decodeCrock(coin.coinPriv);
- const coinSig = eddsaSign(p, coinPriv);
- const paybackRequest: RecoupRequest = {
- coin_blind_key_secret: coin.blindingKey,
- coin_pub: coin.coinPub,
- coin_sig: encodeCrock(coinSig),
- denom_pub_hash: coin.denomPubHash,
- denom_sig: coin.denomSig,
- refreshed: coin.coinSource.type === CoinSourceType.Refresh,
- };
- return paybackRequest;
- }
-
- /**
- * Check if a payment signature is valid.
- */
- isValidPaymentSignature(
- sig: string,
- contractHash: string,
- merchantPub: string,
- ): boolean {
- const p = buildSigPS(TalerSignaturePurpose.MERCHANT_PAYMENT_OK)
- .put(decodeCrock(contractHash))
- .build();
- const sigBytes = decodeCrock(sig);
- const pubBytes = decodeCrock(merchantPub);
- return eddsaVerify(p, sigBytes, pubBytes);
- }
-
- /**
- * Check if a wire fee is correctly signed.
- */
- isValidWireFee(type: string, wf: WireFee, masterPub: string): boolean {
- const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_FEES)
- .put(hash(stringToBytes(type + "\0")))
- .put(timestampRoundedToBuffer(wf.startStamp))
- .put(timestampRoundedToBuffer(wf.endStamp))
- .put(amountToBuffer(wf.wireFee))
- .put(amountToBuffer(wf.closingFee))
- .build();
- const sig = decodeCrock(wf.sig);
- const pub = decodeCrock(masterPub);
- return eddsaVerify(p, sig, pub);
- }
-
- /**
- * Check if the signature of a denomination is valid.
- */
- isValidDenom(denom: DenominationRecord, masterPub: string): boolean {
- const p = buildSigPS(TalerSignaturePurpose.MASTER_DENOMINATION_KEY_VALIDITY)
- .put(decodeCrock(masterPub))
- .put(timestampRoundedToBuffer(denom.stampStart))
- .put(timestampRoundedToBuffer(denom.stampExpireWithdraw))
- .put(timestampRoundedToBuffer(denom.stampExpireDeposit))
- .put(timestampRoundedToBuffer(denom.stampExpireLegal))
- .put(amountToBuffer(denom.value))
- .put(amountToBuffer(denom.feeWithdraw))
- .put(amountToBuffer(denom.feeDeposit))
- .put(amountToBuffer(denom.feeRefresh))
- .put(amountToBuffer(denom.feeRefund))
- .put(decodeCrock(denom.denomPubHash))
- .build();
- const sig = decodeCrock(denom.masterSig);
- const pub = decodeCrock(masterPub);
- const res = eddsaVerify(p, sig, pub);
- return res;
- }
-
- isValidWireAccount(
- paytoUri: string,
- sig: string,
- masterPub: string,
- ): boolean {
- const h = kdf(
- 64,
- stringToBytes("exchange-wire-signature"),
- stringToBytes(paytoUri + "\0"),
- new Uint8Array(0),
- );
- const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_DETAILS)
- .put(h)
- .build();
- return eddsaVerify(p, decodeCrock(sig), decodeCrock(masterPub));
- }
-
- isValidContractTermsSignature(
- contractTermsHash: string,
- sig: string,
- merchantPub: string,
- ): boolean {
- const cthDec = decodeCrock(contractTermsHash);
- const p = buildSigPS(TalerSignaturePurpose.MERCHANT_CONTRACT)
- .put(cthDec)
- .build();
- return eddsaVerify(p, decodeCrock(sig), decodeCrock(merchantPub));
- }
-
- /**
- * Create a new EdDSA key pair.
- */
- createEddsaKeypair(): { priv: string; pub: string } {
- const pair = createEddsaKeyPair();
- return {
- priv: encodeCrock(pair.eddsaPriv),
- pub: encodeCrock(pair.eddsaPub),
- };
- }
-
- eddsaGetPublic(key: string): { priv: string; pub: string } {
- return {
- priv: key,
- pub: encodeCrock(eddsaGetPublic(decodeCrock(key))),
- };
- }
-
- /**
- * Unblind a blindly signed value.
- */
- rsaUnblind(blindedSig: string, bk: string, pk: string): string {
- const denomSig = rsaUnblind(
- decodeCrock(blindedSig),
- decodeCrock(pk),
- decodeCrock(bk),
- );
- return encodeCrock(denomSig);
- }
-
- /**
- * Unblind a blindly signed value.
- */
- rsaVerify(hm: string, sig: string, pk: string): boolean {
- return rsaVerify(hash(decodeCrock(hm)), decodeCrock(sig), decodeCrock(pk));
- }
-
- /**
- * Generate updated coins (to store in the database)
- * and deposit permissions for each given coin.
- */
- signDepositPermission(depositInfo: DepositInfo): CoinDepositPermission {
- const d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT)
- .put(decodeCrock(depositInfo.contractTermsHash))
- .put(decodeCrock(depositInfo.wireInfoHash))
- .put(decodeCrock(depositInfo.denomPubHash))
- .put(timestampRoundedToBuffer(depositInfo.timestamp))
- .put(timestampRoundedToBuffer(depositInfo.refundDeadline))
- .put(amountToBuffer(depositInfo.spendAmount))
- .put(amountToBuffer(depositInfo.feeDeposit))
- .put(decodeCrock(depositInfo.merchantPub))
- .put(decodeCrock(depositInfo.coinPub))
- .build();
- const coinSig = eddsaSign(d, decodeCrock(depositInfo.coinPriv));
-
- const s: CoinDepositPermission = {
- coin_pub: depositInfo.coinPub,
- coin_sig: encodeCrock(coinSig),
- contribution: Amounts.stringify(depositInfo.spendAmount),
- h_denom: depositInfo.denomPubHash,
- exchange_url: depositInfo.exchangeBaseUrl,
- ub_sig: depositInfo.denomSig,
- };
- return s;
- }
-
- deriveRefreshSession(
- req: DeriveRefreshSessionRequest,
- ): DerivedRefreshSession {
- const {
- newCoinDenoms,
- feeRefresh: meltFee,
- kappa,
- meltCoinDenomPubHash,
- meltCoinPriv,
- meltCoinPub,
- sessionSecretSeed: refreshSessionSecretSeed,
- } = req;
-
- const currency = newCoinDenoms[0].value.currency;
- let valueWithFee = Amounts.getZero(currency);
-
- for (const ncd of newCoinDenoms) {
- const t = Amounts.add(ncd.value, ncd.feeWithdraw).amount;
- valueWithFee = Amounts.add(
- valueWithFee,
- Amounts.mult(t, ncd.count).amount,
- ).amount;
- }
-
- // melt fee
- valueWithFee = Amounts.add(valueWithFee, meltFee).amount;
-
- const sessionHc = createHashContext();
-
- const transferPubs: string[] = [];
- const transferPrivs: string[] = [];
-
- const planchetsForGammas: RefreshPlanchetInfo[][] = [];
-
- for (let i = 0; i < kappa; i++) {
- const transferKeyPair = setupRefreshTransferPub(
- decodeCrock(refreshSessionSecretSeed),
- i,
- );
- sessionHc.update(transferKeyPair.ecdhePub);
- transferPrivs.push(encodeCrock(transferKeyPair.ecdhePriv));
- transferPubs.push(encodeCrock(transferKeyPair.ecdhePub));
- }
-
- for (const denomSel of newCoinDenoms) {
- for (let i = 0; i < denomSel.count; i++) {
- const r = decodeCrock(denomSel.denomPub);
- sessionHc.update(r);
- }
- }
-
- sessionHc.update(decodeCrock(meltCoinPub));
- sessionHc.update(amountToBuffer(valueWithFee));
- for (let i = 0; i < kappa; i++) {
- const planchets: RefreshPlanchetInfo[] = [];
- for (let j = 0; j < newCoinDenoms.length; j++) {
- const denomSel = newCoinDenoms[j];
- for (let k = 0; k < denomSel.count; k++) {
- const coinNumber = planchets.length;
- const transferPriv = decodeCrock(transferPrivs[i]);
- const oldCoinPub = decodeCrock(meltCoinPub);
- const transferSecret = keyExchangeEcdheEddsa(
- transferPriv,
- oldCoinPub,
- );
- const fresh = setupRefreshPlanchet(transferSecret, coinNumber);
- const coinPriv = fresh.coinPriv;
- const coinPub = fresh.coinPub;
- const blindingFactor = fresh.bks;
- const pubHash = hash(coinPub);
- const denomPub = decodeCrock(denomSel.denomPub);
- const ev = rsaBlind(pubHash, blindingFactor, denomPub);
- const planchet: RefreshPlanchetInfo = {
- blindingKey: encodeCrock(blindingFactor),
- coinEv: encodeCrock(ev),
- privateKey: encodeCrock(coinPriv),
- publicKey: encodeCrock(coinPub),
- coinEvHash: encodeCrock(hash(ev)),
- };
- planchets.push(planchet);
- sessionHc.update(ev);
- }
- }
- planchetsForGammas.push(planchets);
- }
-
- const sessionHash = sessionHc.finish();
- const confirmData = buildSigPS(TalerSignaturePurpose.WALLET_COIN_MELT)
- .put(sessionHash)
- .put(decodeCrock(meltCoinDenomPubHash))
- .put(amountToBuffer(valueWithFee))
- .put(amountToBuffer(meltFee))
- .put(decodeCrock(meltCoinPub))
- .build();
-
- const confirmSig = eddsaSign(confirmData, decodeCrock(meltCoinPriv));
-
- const refreshSession: DerivedRefreshSession = {
- confirmSig: encodeCrock(confirmSig),
- hash: encodeCrock(sessionHash),
- meltCoinPub: meltCoinPub,
- planchetsForGammas: planchetsForGammas,
- transferPrivs,
- transferPubs,
- meltValueWithFee: valueWithFee,
- };
-
- return refreshSession;
- }
-
- /**
- * Hash a string including the zero terminator.
- */
- hashString(str: string): string {
- const b = stringToBytes(str + "\0");
- return encodeCrock(hash(b));
- }
-
- /**
- * Hash a crockford encoded value.
- */
- hashEncoded(encodedBytes: string): string {
- return encodeCrock(hash(decodeCrock(encodedBytes)));
- }
-
- signCoinLink(
- oldCoinPriv: string,
- newDenomHash: string,
- oldCoinPub: string,
- transferPub: string,
- coinEv: string,
- ): string {
- const coinEvHash = hash(decodeCrock(coinEv));
- const coinLink = buildSigPS(TalerSignaturePurpose.WALLET_COIN_LINK)
- .put(decodeCrock(newDenomHash))
- .put(decodeCrock(transferPub))
- .put(coinEvHash)
- .build();
- const coinPriv = decodeCrock(oldCoinPriv);
- const sig = eddsaSign(coinLink, coinPriv);
- return encodeCrock(sig);
- }
-
- benchmark(repetitions: number): BenchmarkResult {
- let time_hash = BigInt(0);
- for (let i = 0; i < repetitions; i++) {
- const start = timer.performanceNow();
- this.hashString("hello world");
- time_hash += timer.performanceNow() - start;
- }
-
- let time_hash_big = BigInt(0);
- for (let i = 0; i < repetitions; i++) {
- const ba = randomBytes(4096);
- const start = timer.performanceNow();
- hash(ba);
- time_hash_big += timer.performanceNow() - start;
- }
-
- let time_eddsa_create = BigInt(0);
- for (let i = 0; i < repetitions; i++) {
- const start = timer.performanceNow();
- createEddsaKeyPair();
- time_eddsa_create += timer.performanceNow() - start;
- }
-
- let time_eddsa_sign = BigInt(0);
- const p = randomBytes(4096);
-
- const pair = createEddsaKeyPair();
-
- for (let i = 0; i < repetitions; i++) {
- const start = timer.performanceNow();
- eddsaSign(p, pair.eddsaPriv);
- time_eddsa_sign += timer.performanceNow() - start;
- }
-
- const sig = eddsaSign(p, pair.eddsaPriv);
-
- let time_eddsa_verify = BigInt(0);
- for (let i = 0; i < repetitions; i++) {
- const start = timer.performanceNow();
- eddsaVerify(p, sig, pair.eddsaPub);
- time_eddsa_verify += timer.performanceNow() - start;
- }
-
- return {
- repetitions,
- time: {
- hash_small: Number(time_hash),
- hash_big: Number(time_hash_big),
- eddsa_create: Number(time_eddsa_create),
- eddsa_sign: Number(time_eddsa_sign),
- eddsa_verify: Number(time_eddsa_verify),
- },
- };
- }
-
- makeSyncSignature(req: MakeSyncSignatureRequest): string {
- const hNew = decodeCrock(req.newHash);
- let hOld: Uint8Array;
- if (req.oldHash) {
- hOld = decodeCrock(req.oldHash);
- } else {
- hOld = new Uint8Array(64);
- }
- const sigBlob = buildSigPS(TalerSignaturePurpose.SYNC_BACKUP_UPLOAD)
- .put(hOld)
- .put(hNew)
- .build();
- const uploadSig = eddsaSign(sigBlob, decodeCrock(req.accountPriv));
- return encodeCrock(uploadSig);
- }
-}
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoWorker.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoWorker.ts
deleted file mode 100644
index 9f3ee6f50..000000000
--- a/packages/taler-wallet-core/src/crypto/workers/cryptoWorker.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export interface CryptoWorker {
- postMessage(message: any): void;
-
- terminate(): void;
-
- onmessage: ((m: any) => void) | undefined;
- onerror: ((m: any) => void) | undefined;
-}
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoWorkerInterface.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoWorkerInterface.ts
new file mode 100644
index 000000000..b3620e950
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/cryptoWorkerInterface.ts
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { TalerErrorDetail } from "@gnu-taler/taler-util";
+
+/**
+ * Common interface for all crypto workers.
+ */
+export interface CryptoWorker {
+ postMessage(message: any): void;
+ terminate(): void;
+ onmessage: ((m: any) => void) | undefined;
+ onerror: ((m: any) => void) | undefined;
+}
+
+/**
+ * Type of requests sent to the crypto worker.
+ */
+export type CryptoWorkerRequestMessage = {
+ /**
+ * Operation ID to correlate request with the response.
+ */
+ id: number;
+
+ /**
+ * Operation to execute.
+ */
+ operation: string;
+
+ /**
+ * Operation-specific request payload.
+ */
+ req: any;
+};
+
+/**
+ * Type of messages sent back by the crypto worker.
+ */
+export type CryptoWorkerResponseMessage =
+ | {
+ type: "success";
+ id: number;
+ result: any;
+ }
+ | {
+ type: "error";
+ id?: number;
+ error: TalerErrorDetail;
+ };
diff --git a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
index 3f7f9e170..eaa0108bb 100644
--- a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
+++ b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts
@@ -17,15 +17,19 @@
/**
* Imports
*/
-import { CryptoWorkerFactory } from "./cryptoApi.js";
-import { CryptoWorker } from "./cryptoWorker.js";
-import os from "os";
-import { CryptoImplementation } from "./cryptoImplementation.js";
import { Logger } from "@gnu-taler/taler-util";
+import os from "os";
+import url from "url";
+import { nativeCryptoR } from "../cryptoImplementation.js";
+import { CryptoWorkerFactory } from "./crypto-dispatcher.js";
+import { CryptoWorker } from "./cryptoWorkerInterface.js";
+import { processRequestWithImpl } from "./worker-common.js";
const logger = new Logger("nodeThreadWorker.ts");
-const f = __filename;
+const f = import.meta.url
+ ? url.fileURLToPath(import.meta.url)
+ : "__not_available__";
const workerCode = `
// Try loading the glue library for embedded
@@ -69,59 +73,31 @@ const workerCode = `
* a message.
*/
export function handleWorkerMessage(msg: any): void {
- const args = msg.args;
- if (!Array.isArray(args)) {
- console.error("args must be array");
- return;
- }
- const id = msg.id;
- if (typeof id !== "number") {
- console.error("RPC id must be number");
- return;
- }
- const operation = msg.operation;
- if (typeof operation !== "string") {
- console.error("RPC operation must be string");
- return;
- }
-
const handleRequest = async (): Promise<void> => {
- const impl = new CryptoImplementation();
-
- if (!(operation in impl)) {
- console.error(`crypto operation '${operation}' not found`);
- return;
- }
-
+ const responseMsg = await processRequestWithImpl(msg, nativeCryptoR);
try {
- const result = (impl as any)[operation](...args);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const _r = "require";
- const worker_threads: typeof import("worker_threads") = module[_r](
- "worker_threads",
- );
+ const worker_threads: typeof import("worker_threads") =
+ module[_r]("worker_threads");
// const worker_threads = require("worker_threads");
-
const p = worker_threads.parentPort;
- worker_threads.parentPort?.postMessage;
if (p) {
- p.postMessage({ data: { result, id } });
+ p.postMessage(responseMsg);
} else {
- console.error("parent port not available (not running in thread?");
+ logger.error("parent port not available (not running in thread?");
}
- } catch (e) {
- console.error("error during operation", e);
+ } catch (e: any) {
+ logger.error(`error in node worker: ${e.stack ?? e.toString()}`);
return;
}
};
- handleRequest().catch((e) => {
- console.error("error in node worker", e);
- });
+ handleRequest();
}
export function handleWorkerError(e: Error): void {
- console.log("got error from worker", e);
+ logger.error(`got error from worker: ${e.stack ?? e.toString()}`);
}
export class NodeThreadCryptoWorkerFactory implements CryptoWorkerFactory {
@@ -162,7 +138,7 @@ class NodeThreadCryptoWorker implements CryptoWorker {
this.nodeWorker = new worker_threads.Worker(workerCode, { eval: true });
this.nodeWorker.on("error", (err: Error) => {
- console.error("error in node worker:", err);
+ logger.error("error in node worker:", err);
if (this.onerror) {
this.onerror(err);
}
@@ -175,7 +151,7 @@ class NodeThreadCryptoWorker implements CryptoWorker {
this.onmessage(v);
}
});
- this.nodeWorker.unref();
+ //this.nodeWorker.unref();
}
/**
diff --git a/packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts b/packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts
deleted file mode 100644
index f6b8ac5d7..000000000
--- a/packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { CryptoImplementation } from "./cryptoImplementation.js";
-
-import { CryptoWorkerFactory } from "./cryptoApi.js";
-import { CryptoWorker } from "./cryptoWorker.js";
-
-/**
- * The synchronous crypto worker produced by this factory doesn't run in the
- * background, but actually blocks the caller until the operation is done.
- */
-export class SynchronousCryptoWorkerFactory implements CryptoWorkerFactory {
- startWorker(): CryptoWorker {
- if (typeof require === "undefined") {
- throw Error("cannot make worker, require(...) not defined");
- }
- return new SynchronousCryptoWorker();
- }
-
- getConcurrency(): number {
- return 1;
- }
-}
-
-/**
- * Worker implementation that uses node subprocesses.
- */
-export class SynchronousCryptoWorker {
- /**
- * Function to be called when we receive a message from the worker thread.
- */
- onmessage: undefined | ((m: any) => void);
-
- /**
- * Function to be called when we receive an error from the worker thread.
- */
- onerror: undefined | ((m: any) => void);
-
- constructor() {
- this.onerror = undefined;
- this.onmessage = undefined;
- }
-
- /**
- * Add an event listener for either an "error" or "message" event.
- */
- addEventListener(event: "message" | "error", fn: (x: any) => void): void {
- switch (event) {
- case "message":
- this.onmessage = fn;
- break;
- case "error":
- this.onerror = fn;
- break;
- }
- }
-
- private dispatchMessage(msg: any): void {
- if (this.onmessage) {
- this.onmessage({ data: msg });
- }
- }
-
- private async handleRequest(
- operation: string,
- id: number,
- args: string[],
- ): Promise<void> {
- const impl = new CryptoImplementation();
-
- if (!(operation in impl)) {
- console.error(`crypto operation '${operation}' not found`);
- return;
- }
-
- let result: any;
- try {
- result = (impl as any)[operation](...args);
- } catch (e) {
- console.log("error during operation", e);
- return;
- }
-
- try {
- setTimeout(() => this.dispatchMessage({ result, id }), 0);
- } catch (e) {
- console.log("got error during dispatch", e);
- }
- }
-
- /**
- * Send a message to the worker thread.
- */
- postMessage(msg: any): void {
- const args = msg.args;
- if (!Array.isArray(args)) {
- console.error("args must be array");
- return;
- }
- const id = msg.id;
- if (typeof id !== "number") {
- console.error("RPC id must be number");
- return;
- }
- const operation = msg.operation;
- if (typeof operation !== "string") {
- console.error("RPC operation must be string");
- return;
- }
-
- this.handleRequest(operation, id, args).catch((e) => {
- console.error("Error while handling crypto request:", e);
- });
- }
-
- /**
- * Forcibly terminate the worker thread.
- */
- terminate(): void {
- // This is a no-op.
- }
-}
diff --git a/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerFactoryPlain.ts b/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerFactoryPlain.ts
new file mode 100644
index 000000000..66381bc0e
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerFactoryPlain.ts
@@ -0,0 +1,38 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { CryptoWorkerFactory } from "./crypto-dispatcher.js";
+import { CryptoWorker } from "./cryptoWorkerInterface.js";
+import { SynchronousCryptoWorkerPlain } from "./synchronousWorkerPlain.js";
+
+/**
+ * The synchronous crypto worker produced by this factory doesn't run in the
+ * background, but actually blocks the caller until the operation is done.
+ */
+export class SynchronousCryptoWorkerFactoryPlain
+ implements CryptoWorkerFactory
+{
+ startWorker(): CryptoWorker {
+ return new SynchronousCryptoWorkerPlain();
+ }
+
+ getConcurrency(): number {
+ return 1;
+ }
+}
diff --git a/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerPlain.ts b/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerPlain.ts
new file mode 100644
index 000000000..c80f2f58f
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/synchronousWorkerPlain.ts
@@ -0,0 +1,98 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { j2s, Logger } from "@gnu-taler/taler-util";
+import {
+ nativeCryptoR,
+ TalerCryptoInterfaceR,
+} from "../cryptoImplementation.js";
+import { CryptoWorker } from "./cryptoWorkerInterface.js";
+import { processRequestWithImpl } from "./worker-common.js";
+
+const logger = new Logger("synchronousWorker.ts");
+
+/**
+ * Worker implementation that synchronously executes cryptographic
+ * operations.
+ */
+export class SynchronousCryptoWorkerPlain implements CryptoWorker {
+ /**
+ * Function to be called when we receive a message from the worker thread.
+ */
+ onmessage: undefined | ((m: any) => void);
+
+ /**
+ * Function to be called when we receive an error from the worker thread.
+ */
+ onerror: undefined | ((m: any) => void);
+
+ cryptoImplR: TalerCryptoInterfaceR;
+
+ constructor() {
+ this.onerror = undefined;
+ this.onmessage = undefined;
+ this.cryptoImplR = { ...nativeCryptoR };
+ }
+
+ /**
+ * Add an event listener for either an "error" or "message" event.
+ */
+ addEventListener(event: "message" | "error", fn: (x: any) => void): void {
+ switch (event) {
+ case "message":
+ this.onmessage = fn;
+ break;
+ case "error":
+ this.onerror = fn;
+ break;
+ }
+ }
+
+ private dispatchMessage(msg: any): void {
+ if (this.onmessage) {
+ this.onmessage(msg);
+ }
+ }
+
+ /**
+ * Send a message to the worker thread.
+ */
+ postMessage(msg: any): void {
+ const handleRequest = async () => {
+ const responseMsg = await processRequestWithImpl(msg, this.cryptoImplR);
+ try {
+ setTimeout(() => this.dispatchMessage(responseMsg), 0);
+ } catch (e) {
+ logger.error("got error during dispatch", e);
+ }
+ };
+ handleRequest().catch((e) => {
+ logger.error("Error while handling crypto request:", e);
+ logger.error("Stack:", e.stack);
+ logger.error(`request was ${j2s(msg)}`);
+ });
+ }
+
+ /**
+ * Forcibly terminate the worker thread.
+ */
+ terminate(): void {
+ // This is a no-op.
+ }
+}
diff --git a/packages/taler-wallet-core/src/crypto/workers/worker-common.ts b/packages/taler-wallet-core/src/crypto/workers/worker-common.ts
new file mode 100644
index 000000000..63147ce92
--- /dev/null
+++ b/packages/taler-wallet-core/src/crypto/workers/worker-common.ts
@@ -0,0 +1,107 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ j2s,
+ Logger,
+ stringifyError as safeStringifyError,
+ TalerErrorCode,
+} from "@gnu-taler/taler-util";
+import {
+ getErrorDetailFromException,
+ makeErrorDetail,
+} from "@gnu-taler/taler-util";
+import { TalerCryptoInterfaceR } from "../cryptoImplementation.js";
+import {
+ CryptoWorkerRequestMessage,
+ CryptoWorkerResponseMessage,
+} from "./cryptoWorkerInterface.js";
+
+const logger = new Logger("worker-common.ts");
+
+/**
+ * Process a crypto worker request by calling into the table
+ * of supported operations.
+ *
+ * Does not throw, but returns an error response instead.
+ */
+export async function processRequestWithImpl(
+ reqMsg: CryptoWorkerRequestMessage,
+ impl: TalerCryptoInterfaceR,
+): Promise<CryptoWorkerResponseMessage> {
+ if (typeof reqMsg !== "object") {
+ logger.error("request must be an object");
+ return {
+ type: "error",
+ error: makeErrorDetail(TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST, {
+ detail: "",
+ }),
+ };
+ }
+ const id = reqMsg.id;
+ if (typeof id !== "number") {
+ const msg = "RPC id must be number";
+ logger.error(msg);
+ return {
+ type: "error",
+ error: makeErrorDetail(TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST, {
+ detail: msg,
+ }),
+ };
+ }
+ const operation = reqMsg.operation;
+ if (typeof operation !== "string") {
+ const msg = "RPC operation must be string";
+ logger.error(msg);
+ return {
+ type: "error",
+ id,
+ error: makeErrorDetail(TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST, {
+ detail: msg,
+ }),
+ };
+ }
+
+ if (!(operation in impl)) {
+ const msg = `crypto operation '${operation}' not found`;
+ logger.error(msg);
+ return {
+ type: "error",
+ id,
+ error: makeErrorDetail(TalerErrorCode.WALLET_CRYPTO_WORKER_BAD_REQUEST, {
+ detail: msg,
+ }),
+ };
+ }
+
+ let responseMsg: CryptoWorkerResponseMessage;
+
+ try {
+ const result = await (impl as any)[operation](impl, reqMsg.req);
+ responseMsg = { type: "success", result, id };
+ } catch (e: any) {
+ logger.error(`error during operation: ${safeStringifyError(e)}`);
+ responseMsg = {
+ type: "error",
+ error: getErrorDetailFromException(e),
+ id,
+ };
+ }
+ return responseMsg;
+}
diff --git a/packages/taler-wallet-core/src/db-utils.ts b/packages/taler-wallet-core/src/db-utils.ts
deleted file mode 100644
index 075bddde5..000000000
--- a/packages/taler-wallet-core/src/db-utils.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { IDBDatabase, IDBFactory, IDBTransaction } from "@gnu-taler/idb-bridge";
-import { Logger } from "@gnu-taler/taler-util";
-import {
- CURRENT_DB_CONFIG_KEY,
- TALER_DB_NAME,
- TALER_META_DB_NAME,
- walletMetadataStore,
- WalletStoresV1,
- WALLET_DB_MINOR_VERSION,
-} from "./db.js";
-import {
- DbAccess,
- IndexDescriptor,
- openDatabase,
- StoreDescriptor,
- StoreWithIndexes,
-} from "./util/query.js";
-
-const logger = new Logger("db-utils.ts");
-
-function upgradeFromStoreMap(
- storeMap: any,
- db: IDBDatabase,
- oldVersion: number,
- newVersion: number,
- upgradeTransaction: IDBTransaction,
-): void {
- if (oldVersion === 0) {
- for (const n in storeMap) {
- const swi: StoreWithIndexes<StoreDescriptor<unknown>, any> = storeMap[n];
- const storeDesc: StoreDescriptor<unknown> = swi.store;
- const s = db.createObjectStore(storeDesc.name, {
- autoIncrement: storeDesc.autoIncrement,
- keyPath: storeDesc.keyPath,
- });
- for (const indexName in swi.indexMap as any) {
- const indexDesc: IndexDescriptor = swi.indexMap[indexName];
- s.createIndex(indexDesc.name, indexDesc.keyPath, {
- multiEntry: indexDesc.multiEntry,
- });
- }
- }
- return;
- }
- if (oldVersion === newVersion) {
- return;
- }
- logger.info(`upgrading database from ${oldVersion} to ${newVersion}`);
- throw Error("upgrade not supported");
-}
-
-function onTalerDbUpgradeNeeded(
- db: IDBDatabase,
- oldVersion: number,
- newVersion: number,
- upgradeTransaction: IDBTransaction,
-) {
- upgradeFromStoreMap(
- WalletStoresV1,
- db,
- oldVersion,
- newVersion,
- upgradeTransaction,
- );
-}
-
-function onMetaDbUpgradeNeeded(
- db: IDBDatabase,
- oldVersion: number,
- newVersion: number,
- upgradeTransaction: IDBTransaction,
-) {
- upgradeFromStoreMap(
- walletMetadataStore,
- db,
- oldVersion,
- newVersion,
- upgradeTransaction,
- );
-}
-
-/**
- * Return a promise that resolves
- * to the taler wallet db.
- */
-export async function openTalerDatabase(
- idbFactory: IDBFactory,
- onVersionChange: () => void,
-): Promise<DbAccess<typeof WalletStoresV1>> {
- const metaDbHandle = await openDatabase(
- idbFactory,
- TALER_META_DB_NAME,
- 1,
- () => {},
- onMetaDbUpgradeNeeded,
- );
-
- const metaDb = new DbAccess(metaDbHandle, walletMetadataStore);
- let currentMainVersion: string | undefined;
- await metaDb
- .mktx((x) => ({
- metaConfig: x.metaConfig,
- }))
- .runReadWrite(async (tx) => {
- const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY);
- if (!dbVersionRecord) {
- currentMainVersion = TALER_DB_NAME;
- await tx.metaConfig.put({
- key: CURRENT_DB_CONFIG_KEY,
- value: TALER_DB_NAME,
- });
- } else {
- currentMainVersion = dbVersionRecord.value;
- }
- });
-
- if (currentMainVersion !== TALER_DB_NAME) {
- switch (currentMainVersion) {
- case "taler-wallet-main-v2":
- // We consider this a pre-release
- // development version, no migration is done.
- await metaDb
- .mktx((x) => ({
- metaConfig: x.metaConfig,
- }))
- .runReadWrite(async (tx) => {
- await tx.metaConfig.put({
- key: CURRENT_DB_CONFIG_KEY,
- value: TALER_DB_NAME,
- });
- });
- break;
- default:
- throw Error(
- `migration from database ${currentMainVersion} not supported`,
- );
- }
- }
-
- const mainDbHandle = await openDatabase(
- idbFactory,
- TALER_DB_NAME,
- WALLET_DB_MINOR_VERSION,
- onVersionChange,
- onTalerDbUpgradeNeeded,
- );
-
- return new DbAccess(mainDbHandle, WalletStoresV1);
-}
-
-export function deleteTalerDatabase(idbFactory: IDBFactory): void {
- idbFactory.deleteDatabase(TALER_DB_NAME);
-}
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
index 902f749cf..5bab70968 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2021-2024 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -18,27 +18,100 @@
* Imports.
*/
import {
- describeStore,
- describeContents,
- describeIndex,
-} from "./util/query.js";
+ Event,
+ IDBDatabase,
+ IDBFactory,
+ IDBObjectStore,
+ IDBRequest,
+ IDBTransaction,
+ structuredEncapsulate,
+ structuredRevive,
+} from "@gnu-taler/idb-bridge";
import {
- AmountJson,
+ AbsoluteTime,
+ AgeCommitmentProof,
AmountString,
- Auditor,
- CoinDepositPermission,
- ContractTerms,
- Duration,
- ExchangeSignKeyJson,
- InternationalizedString,
- MerchantInfo,
- Product,
+ Amounts,
+ AttentionInfo,
+ BackupProviderTerms,
+ Codec,
+ CoinEnvelope,
+ CoinPublicKeyString,
+ CoinRefreshRequest,
+ CoinStatus,
+ DenomLossEventType,
+ DenomSelectionState,
+ DenominationInfo,
+ DenominationPubKey,
+ EddsaPublicKeyString,
+ EddsaSignatureString,
+ ExchangeAuditor,
+ ExchangeGlobalFees,
+ HashCodeString,
+ Logger,
RefreshReason,
- TalerErrorDetails,
- Timestamp,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ Transaction,
+ TransactionIdStr,
+ UnblindedSignature,
+ WireInfo,
+ WithdrawalExchangeAccountDetails,
+ codecForAny,
} from "@gnu-taler/taler-util";
-import { RetryInfo } from "./util/retries.js";
-import { PayCoinSelection } from "./util/coinSelection.js";
+import { DbRetryInfo, TaskIdentifiers } from "./common.js";
+import {
+ DbAccess,
+ DbAccessImpl,
+ DbReadOnlyTransaction,
+ DbReadWriteTransaction,
+ IndexDescriptor,
+ StoreDescriptor,
+ StoreNames,
+ StoreWithIndexes,
+ describeContents,
+ describeIndex,
+ describeStore,
+ describeStoreV2,
+ openDatabase,
+} from "./query.js";
+
+/**
+ * This file contains the database schema of the Taler wallet together
+ * with some helper functions.
+ *
+ * Some design considerations:
+ * - By convention, each object store must have a corresponding "<Name>Record"
+ * interface defined for it.
+ * - For records that represent operations, there should be exactly
+ * one top-level enum field that indicates the status of the operation.
+ * This field should be present even if redundant, because the field
+ * will have an index.
+ * - Amounts are stored as strings, except when they are needed for
+ * indexing.
+ * - Every record that has a corresponding transaction item must have
+ * an index for a mandatory timestamp field.
+ * - Optional fields should be avoided, use "T | undefined" instead.
+ * - Do all records have some obvious, indexed field that can
+ * be used for range queries?
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ FIXMEs:
+ - Contract terms can be quite large. We currently tend to read the
+ full contract terms from the DB quite often.
+ Instead, we should probably extract what we need into a separate object
+ store.
+ - More object stores should have an "id" primary key,
+ as this makes referencing less expensive.
+ - Coin selections should probably go into a separate object store.
+ - Some records should be split up into an extra "details" record
+ that we don't always need to iterate over.
+ */
/**
* Name of the Taler database. This is effectively the major
@@ -46,7 +119,7 @@ import { PayCoinSelection } from "./util/coinSelection.js";
* for all previous versions must be written, which should be
* avoided.
*/
-export const TALER_DB_NAME = "taler-wallet-main-v3";
+export const TALER_WALLET_MAIN_DB_NAME = "taler-wallet-main-v10";
/**
* Name of the metadata database. This database is used
@@ -54,8 +127,20 @@ export const TALER_DB_NAME = "taler-wallet-main-v3";
*
* (Minor migrations are handled via upgrade transactions.)
*/
-export const TALER_META_DB_NAME = "taler-wallet-meta";
+export const TALER_WALLET_META_DB_NAME = "taler-wallet-meta";
+
+/**
+ * Name of the "stored backups" database.
+ * Stored backups are created before manually importing a backup.
+ * We use IndexedDB for this purpose, since we don't have file system
+ * access on some platforms.
+ */
+export const TALER_WALLET_STORED_BACKUPS_DB_NAME =
+ "taler-wallet-stored-backups";
+/**
+ * Name of the "meta config" database.
+ */
export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
/**
@@ -65,237 +150,270 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
* backwards-compatible way or object stores and indices
* are added.
*/
-export const WALLET_DB_MINOR_VERSION = 1;
+export const WALLET_DB_MINOR_VERSION = 10;
-export enum ReserveRecordStatus {
- /**
- * Reserve must be registered with the bank.
- */
- REGISTERING_BANK = "registering-bank",
+declare const symDbProtocolTimestamp: unique symbol;
- /**
- * We've registered reserve's information with the bank
- * and are now waiting for the user to confirm the withdraw
- * with the bank (typically 2nd factor auth).
- */
- WAIT_CONFIRM_BANK = "wait-confirm-bank",
+declare const symDbPreciseTimestamp: unique symbol;
- /**
- * Querying reserve status with the exchange.
- */
- QUERYING_STATUS = "querying-status",
+/**
+ * Timestamp, stored as microseconds.
+ *
+ * Always rounded to a full second.
+ */
+export type DbProtocolTimestamp = number & { [symDbProtocolTimestamp]: true };
- /**
- * The corresponding withdraw record has been created.
- * No further processing is done, unless explicitly requested
- * by the user.
- */
- DORMANT = "dormant",
+/**
+ * Timestamp, stored as microseconds.
+ */
+export type DbPreciseTimestamp = number & { [symDbPreciseTimestamp]: true };
- /**
- * The bank aborted the withdrawal.
- */
- BANK_ABORTED = "bank-aborted",
+const DB_TIMESTAMP_FOREVER = Number.MAX_SAFE_INTEGER;
+
+export function timestampPreciseFromDb(
+ dbTs: DbPreciseTimestamp,
+): TalerPreciseTimestamp {
+ return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000));
}
-/**
- * Extra info about a reserve that is used
- * with a bank-integrated withdrawal.
- */
-export interface ReserveBankInfo {
- /**
- * Status URL that the wallet will use to query the status
- * of the Taler withdrawal operation on the bank's side.
- */
- statusUrl: string;
+export function timestampOptionalPreciseFromDb(
+ dbTs: DbPreciseTimestamp | undefined,
+): TalerPreciseTimestamp | undefined {
+ if (!dbTs) {
+ return undefined;
+ }
+ return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000));
+}
- /**
- * URL that the user can be redirected to, and allows
- * them to confirm (or abort) the bank-integrated withdrawal.
- */
- confirmUrl?: string;
+export function timestampPreciseToDb(
+ stamp: TalerPreciseTimestamp,
+): DbPreciseTimestamp {
+ if (stamp.t_s === "never") {
+ return DB_TIMESTAMP_FOREVER as DbPreciseTimestamp;
+ } else {
+ let tUs = stamp.t_s * 1000000;
+ if (stamp.off_us) {
+ tUs += stamp.off_us;
+ }
+ return tUs as DbPreciseTimestamp;
+ }
+}
- /**
- * Exchange payto URI that the bank will use to fund the reserve.
- */
- exchangePaytoUri: string;
+export function timestampProtocolToDb(
+ stamp: TalerProtocolTimestamp,
+): DbProtocolTimestamp {
+ if (stamp.t_s === "never") {
+ return DB_TIMESTAMP_FOREVER as DbProtocolTimestamp;
+ } else {
+ let tUs = stamp.t_s * 1000000;
+ return tUs as DbProtocolTimestamp;
+ }
+}
+
+export function timestampProtocolFromDb(
+ stamp: DbProtocolTimestamp,
+): TalerProtocolTimestamp {
+ return TalerProtocolTimestamp.fromSeconds(Math.floor(stamp / 1000000));
+}
+
+export function timestampAbsoluteFromDb(
+ stamp: DbProtocolTimestamp | DbPreciseTimestamp,
+): AbsoluteTime {
+ if (stamp >= DB_TIMESTAMP_FOREVER) {
+ return AbsoluteTime.never();
+ }
+ return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000));
+}
+
+export function timestampOptionalAbsoluteFromDb(
+ stamp: DbProtocolTimestamp | DbPreciseTimestamp | undefined,
+): AbsoluteTime | undefined {
+ if (stamp == null) {
+ return undefined;
+ }
+ if (stamp >= DB_TIMESTAMP_FOREVER) {
+ return AbsoluteTime.never();
+ }
+ return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000));
}
/**
- * A reserve record as stored in the wallet's database.
+ * Format of the operation status code: 0x0abc_nnnn
+
+ * a=1: active
+ * 0x0100_nnnn: pending
+ * 0x0101_nnnn: dialog
+ * 0x0102_nnnn: (reserved)
+ * 0x0103_nnnn: aborting
+ * 0x0110_nnnn: suspended
+ * 0x0113_nnnn: suspended-aborting
+ * a=5: final
+ * 0x0500_nnnn: done
+ * 0x0501_nnnn: failed
+ * 0x0502_nnnn: expired
+ * 0x0503_nnnn: aborted
+ *
+ * nnnn=0000 should always be the most generic minor state for the major state
*/
-export interface ReserveRecord {
- /**
- * The reserve public key.
- */
- reservePub: string;
- /**
- * The reserve private key.
- */
- reservePriv: string;
+/**
+ * First possible operation status in the active range (inclusive).
+ */
+export const OPERATION_STATUS_ACTIVE_FIRST = 0x0100_0000;
- /**
- * The exchange base URL.
- */
- exchangeBaseUrl: string;
+/**
+ * LAST possible operation status in the active range (inclusive).
+ */
+export const OPERATION_STATUS_ACTIVE_LAST = 0x0113_ffff;
+/**
+ * Status of a withdrawal.
+ */
+export enum WithdrawalGroupStatus {
/**
- * Currency of the reserve.
+ * Reserve must be registered with the bank.
*/
- currency: string;
+ PendingRegisteringBank = 0x0100_0001,
+ SuspendedRegisteringBank = 0x0110_0001,
/**
- * Time when the reserve was created.
+ * We've registered reserve's information with the bank
+ * and are now waiting for the user to confirm the withdraw
+ * with the bank (typically 2nd factor auth).
*/
- timestampCreated: Timestamp;
+ PendingWaitConfirmBank = 0x0100_0002,
+ SuspendedWaitConfirmBank = 0x0110_0002,
/**
- * Time when the information about this reserve was posted to the bank.
- *
- * Only applies if bankWithdrawStatusUrl is defined.
- *
- * Set to 0 if that hasn't happened yet.
+ * Querying reserve status with the exchange.
*/
- timestampReserveInfoPosted: Timestamp | undefined;
+ PendingQueryingStatus = 0x0100_0003,
+ SuspendedQueryingStatus = 0x0110_0003,
/**
- * Time when the reserve was confirmed by the bank.
- *
- * Set to undefined if not confirmed yet.
+ * Ready for withdrawal.
*/
- timestampBankConfirmed: Timestamp | undefined;
+ PendingReady = 0x0100_0004,
+ SuspendedReady = 0x0110_0004,
/**
- * Wire information (as payto URI) for the bank account that
- * transferred funds for this reserve.
+ * We are telling the bank that we don't want to complete
+ * the withdrawal!
*/
- senderWire?: string;
+ AbortingBank = 0x0103_0001,
+ SuspendedAbortingBank = 0x0113_0001,
/**
- * Amount that was sent by the user to fund the reserve.
+ * Exchange wants KYC info from the user.
*/
- instructedAmount: AmountJson;
+ PendingKyc = 0x0100_0005,
+ SuspendedKyc = 0x0110_005,
/**
- * Extra state for when this is a withdrawal involving
- * a Taler-integrated bank.
+ * Exchange is doing AML checks.
*/
- bankInfo?: ReserveBankInfo;
-
- initialWithdrawalGroupId: string;
+ PendingAml = 0x0100_0006,
+ SuspendedAml = 0x0110_0006,
/**
- * Did we start the first withdrawal for this reserve?
- *
- * We only report a pending withdrawal for the reserve before
- * the first withdrawal has started.
- */
- initialWithdrawalStarted: boolean;
-
- /**
- * Initial denomination selection, stored here so that
- * we can show this information in the transactions/balances
- * before we have a withdrawal group.
+ * The corresponding withdraw record has been created.
+ * No further processing is done, unless explicitly requested
+ * by the user.
*/
- initialDenomSel: DenomSelectionState;
-
- reserveStatus: ReserveRecordStatus;
+ Done = 0x0500_0000,
/**
- * Was a reserve query requested? If so, query again instead
- * of going into dormant status.
+ * The bank aborted the withdrawal.
*/
- requestedQuery: boolean;
+ FailedBankAborted = 0x0501_0001,
- /**
- * Time of the last successful status query.
- */
- lastSuccessfulStatusQuery: Timestamp | undefined;
+ FailedAbortingBank = 0x0501_0002,
/**
- * Retry info. This field is present even if no retry is scheduled,
- * because we need it to be present for the index on the object store
- * to work.
+ * Aborted in a state where we were supposed to
+ * talk to the exchange. Money might have been
+ * wired or not.
*/
- retryInfo: RetryInfo;
+ AbortedExchange = 0x0503_0001,
- /**
- * Last error that happened in a reserve operation
- * (either talking to the bank or the exchange).
- */
- lastError: TalerErrorDetails | undefined;
+ AbortedBank = 0x0503_0002,
}
/**
- * Record that indicates the wallet trusts
- * a particular auditor.
+ * Extra info about a withdrawal that is used
+ * with a bank-integrated withdrawal.
*/
-export interface AuditorTrustRecord {
+export interface ReserveBankInfo {
+ talerWithdrawUri: string;
+
/**
- * Currency that we trust this auditor for.
+ * URL that the user can be redirected to, and allows
+ * them to confirm (or abort) the bank-integrated withdrawal.
*/
- currency: string;
+ confirmUrl: string | undefined;
/**
- * Base URL of the auditor.
+ * Exchange payto URI that the bank will use to fund the reserve.
*/
- auditorBaseUrl: string;
+ exchangePaytoUri: string;
/**
- * Public key of the auditor.
+ * Time when the information about this reserve was posted to the bank.
+ *
+ * Only applies if bankWithdrawStatusUrl is defined.
+ *
+ * Set to undefined if that hasn't happened yet.
*/
- auditorPub: string;
+ timestampReserveInfoPosted: DbPreciseTimestamp | undefined;
/**
- * UIDs for the operation of adding this auditor
- * as a trusted auditor.
+ * Time when the reserve was confirmed by the bank.
+ *
+ * Set to undefined if not confirmed yet.
*/
- uids: string[];
+ timestampBankConfirmed: DbPreciseTimestamp | undefined;
}
/**
- * Record to indicate trust for a particular exchange.
+ * Status of a denomination.
*/
-export interface ExchangeTrustRecord {
+export enum DenominationVerificationStatus {
/**
- * Currency that we trust this exchange for.
+ * Verification was delayed (pending).
*/
- currency: string;
+ Unverified = 0x0100_0000,
/**
- * Canonicalized exchange base URL.
+ * Verified as valid.
*/
- exchangeBaseUrl: string;
+ VerifiedGood = 0x0500_0000,
/**
- * Master public key of the exchange.
+ * Verified as invalid.
*/
- exchangeMasterPub: string;
+ VerifiedBad = 0x0501_0000,
+}
+export interface DenomFees {
/**
- * UIDs for the operation of adding this exchange
- * as trusted.
+ * Fee for withdrawing.
*/
- uids: string[];
-}
+ feeWithdraw: AmountString;
-/**
- * Status of a denomination.
- */
-export enum DenominationVerificationStatus {
/**
- * Verification was delayed.
+ * Fee for depositing.
*/
- Unverified = "unverified",
+ feeDeposit: AmountString;
+
/**
- * Verified as valid.
+ * Fee for refreshing.
*/
- VerifiedGood = "verified-good",
+ feeRefresh: AmountString;
+
/**
- * Verified as invalid.
+ * Fee for refunding.
*/
- VerifiedBad = "verified-bad",
+ feeRefund: AmountString;
}
/**
@@ -303,14 +421,18 @@ export enum DenominationVerificationStatus {
*/
export interface DenominationRecord {
/**
- * Value of one coin of the denomination.
+ * Currency of the denomination.
+ *
+ * Stored separately as we have an index on it.
*/
- value: AmountJson;
+ currency: string;
+
+ value: AmountString;
/**
* The denomination public key.
*/
- denomPub: string;
+ denomPub: DenominationPubKey;
/**
* Hash of the denomination public key.
@@ -318,45 +440,27 @@ export interface DenominationRecord {
*/
denomPubHash: string;
- /**
- * Fee for withdrawing.
- */
- feeWithdraw: AmountJson;
-
- /**
- * Fee for depositing.
- */
- feeDeposit: AmountJson;
-
- /**
- * Fee for refreshing.
- */
- feeRefresh: AmountJson;
-
- /**
- * Fee for refunding.
- */
- feeRefund: AmountJson;
+ fees: DenomFees;
/**
* Validity start date of the denomination.
*/
- stampStart: Timestamp;
+ stampStart: DbProtocolTimestamp;
/**
* Date after which the currency can't be withdrawn anymore.
*/
- stampExpireWithdraw: Timestamp;
+ stampExpireWithdraw: DbProtocolTimestamp;
/**
* Date after the denomination officially doesn't exist anymore.
*/
- stampExpireLegal: Timestamp;
+ stampExpireLegal: DbProtocolTimestamp;
/**
* Data after which coins of this denomination can't be deposited anymore.
*/
- stampExpireDeposit: Timestamp;
+ stampExpireDeposit: DbProtocolTimestamp;
/**
* Signature by the exchange's master key over the denomination
@@ -384,6 +488,13 @@ export interface DenominationRecord {
isRevoked: boolean;
/**
+ * If set to true, the exchange announced that the private key for this
+ * denomination is lost. Thus it can't be used to sign new coins
+ * during withdrawal/refresh/..., but the coins can still be spent.
+ */
+ isLost?: boolean;
+
+ /**
* Base URL of the exchange.
*/
exchangeBaseUrl: string;
@@ -393,23 +504,47 @@ export interface DenominationRecord {
* on the denomination.
*/
exchangeMasterPub: string;
+}
+
+export namespace DenominationRecord {
+ export function toDenomInfo(d: DenominationRecord): DenominationInfo {
+ return {
+ denomPub: d.denomPub,
+ denomPubHash: d.denomPubHash,
+ feeDeposit: Amounts.stringify(d.fees.feeDeposit),
+ feeRefresh: Amounts.stringify(d.fees.feeRefresh),
+ feeRefund: Amounts.stringify(d.fees.feeRefund),
+ feeWithdraw: Amounts.stringify(d.fees.feeWithdraw),
+ stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
+ stampExpireLegal: timestampProtocolFromDb(d.stampExpireLegal),
+ stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
+ stampStart: timestampProtocolFromDb(d.stampStart),
+ value: Amounts.stringify(d.value),
+ exchangeBaseUrl: d.exchangeBaseUrl,
+ };
+ }
+}
+
+export interface ExchangeSignkeysRecord {
+ stampStart: DbProtocolTimestamp;
+ stampExpire: DbProtocolTimestamp;
+ stampEnd: DbProtocolTimestamp;
+ signkeyPub: EddsaPublicKeyString;
+ masterSig: EddsaSignatureString;
/**
- * Latest list issue date of the "/keys" response
- * that includes this denomination.
+ * Exchange details that thiis signkeys record belongs to.
*/
- listIssueDate: Timestamp;
+ exchangeDetailsRowId: number;
}
/**
- * Information about one of the exchange's bank accounts.
+ * Exchange details for a particular
+ * (exchangeBaseUrl, masterPublicKey, currency) tuple.
*/
-export interface ExchangeBankAccount {
- payto_uri: string;
- master_sig: string;
-}
-
export interface ExchangeDetailsRecord {
+ rowId?: number;
+
/**
* Master public key of the exchange.
*/
@@ -425,102 +560,123 @@ export interface ExchangeDetailsRecord {
/**
* Auditors (partially) auditing the exchange.
*/
- auditors: Auditor[];
+ auditors: ExchangeAuditor[];
/**
* Last observed protocol version.
*/
- protocolVersion: string;
-
- reserveClosingDelay: Duration;
-
- /**
- * Signing keys we got from the exchange, can also contain
- * older signing keys that are not returned by /keys anymore.
- *
- * FIXME: Should this be put into a separate object store?
- */
- signingKeys: ExchangeSignKeyJson[];
-
- /**
- * Terms of service text or undefined if not downloaded yet.
- *
- * This is just used as a cache of the last downloaded ToS.
- */
- termsOfServiceText: string | undefined;
+ protocolVersionRange: string;
- /**
- * content-type of the last downloaded termsOfServiceText.
- */
- termsOfServiceContentType: string | undefined;
+ reserveClosingDelay: TalerProtocolDuration;
/**
- * ETag for last terms of service download.
+ * Fees for exchange services
*/
- termsOfServiceLastEtag: string | undefined;
+ globalFees: ExchangeGlobalFees[];
- /**
- * ETag for last terms of service accepted.
- */
- termsOfServiceAcceptedEtag: string | undefined;
+ wireInfo: WireInfo;
/**
- * Timestamp when the ToS was accepted.
- *
- * Used during backup merging.
+ * Age restrictions supported by the exchange (bitmask).
*/
- termsOfServiceAcceptedTimestamp: Timestamp | undefined;
-
- wireInfo: WireInfo;
-}
-
-export interface WireInfo {
- feesForType: { [wireMethod: string]: WireFee[] };
-
- accounts: ExchangeBankAccount[];
+ ageMask?: number;
}
export interface ExchangeDetailsPointer {
masterPublicKey: string;
+
currency: string;
/**
* Timestamp when the (masterPublicKey, currency) pointer
* has been updated.
*/
- updateClock: Timestamp;
+ updateClock: DbPreciseTimestamp;
+}
+
+export enum ExchangeEntryDbRecordStatus {
+ Preset = 1,
+ Ephemeral = 2,
+ Used = 3,
+}
+
+// FIXME: Use status ranges for this as well?
+export enum ExchangeEntryDbUpdateStatus {
+ Initial = 1,
+ InitialUpdate = 2,
+ Suspended = 3,
+ UnavailableUpdate = 4,
+ // Reserved 5 for backwards compatibility.
+ Ready = 6,
+ ReadyUpdate = 7,
}
/**
* Exchange record as stored in the wallet's database.
*/
-export interface ExchangeRecord {
+export interface ExchangeEntryRecord {
/**
* Base url of the exchange.
*/
baseUrl: string;
/**
+ * Currency hint for a preset exchange, relevant
+ * when we didn't contact a preset exchange yet.
+ */
+ presetCurrencyHint?: string;
+
+ /**
+ * When did we confirm the last withdrawal from this exchange?
+ *
+ * Used mostly in the UI to suggest exchanges.
+ */
+ lastWithdrawal?: DbPreciseTimestamp;
+
+ /**
* Pointer to the current exchange details.
+ *
+ * Should usually not change. Only changes when the
+ * exchange advertises a different master public key and/or
+ * currency.
+ *
+ * We could use a rowID here, but having the currency in the
+ * details pointer lets us do fewer DB queries
*/
detailsPointer: ExchangeDetailsPointer | undefined;
+ entryStatus: ExchangeEntryDbRecordStatus;
+
+ updateStatus: ExchangeEntryDbUpdateStatus;
+
/**
- * Is this a permanent or temporary exchange record?
+ * If set to true, the next update to the exchange
+ * status will request /keys with no-cache headers set.
*/
- permanent: boolean;
+ cachebreakNextUpdate?: boolean;
/**
- * Last time when the exchange was updated.
+ * Etag of the current ToS of the exchange.
*/
- lastUpdate: Timestamp | undefined;
+ tosCurrentEtag: string | undefined;
+
+ tosAcceptedEtag: string | undefined;
+
+ tosAcceptedTimestamp: DbPreciseTimestamp | undefined;
+
+ /**
+ * Last time when the exchange /keys info was updated.
+ */
+ lastUpdate: DbPreciseTimestamp | undefined;
/**
* Next scheduled update for the exchange.
- *
- * (This field must always be present, so we can index on the timestamp.)
*/
- nextUpdate: Timestamp;
+ nextUpdateStamp: DbPreciseTimestamp;
+
+ updateRetryCounter?: number;
+
+ lastKeysEtag: string | undefined;
/**
* Next time that we should check if coins need to be refreshed.
@@ -528,14 +684,30 @@ export interface ExchangeRecord {
* Updated whenever the exchange's denominations are updated or when
* the refresh check has been done.
*/
- nextRefreshCheck: Timestamp;
+ nextRefreshCheckStamp: DbPreciseTimestamp;
- lastError?: TalerErrorDetails;
+ /**
+ * Public key of the reserve that we're currently using for
+ * receiving P2P payments.
+ */
+ currentMergeReserveRowId?: number;
+
+ /**
+ * Defaults to false.
+ */
+ peerPaymentsDisabled?: boolean;
/**
- * Retry status for fetching updated information about the exchange.
+ * Defaults to false.
*/
- retryInfo: RetryInfo;
+ noFees?: boolean;
+}
+
+export enum PlanchetStatus {
+ Pending = 0x0100_0000,
+ KycRequired = 0x0100_0001,
+ WithdrawalDone = 0x0500_000,
+ AbortedReplaced = 0x0503_0001,
}
/**
@@ -563,54 +735,27 @@ export interface PlanchetRecord {
*/
coinIdx: number;
- withdrawalDone: boolean;
+ planchetStatus: PlanchetStatus;
- lastError: TalerErrorDetails | undefined;
-
- /**
- * Public key of the reserve that this planchet
- * is being withdrawn from.
- *
- * Can be the empty string (non-null/undefined for DB indexing)
- * if this is a tipping reserve.
- */
- reservePub: string;
+ lastError: TalerErrorDetail | undefined;
denomPubHash: string;
- denomPub: string;
-
blindingKey: string;
withdrawSig: string;
- coinEv: string;
+ coinEv: CoinEnvelope;
coinEvHash: string;
- coinValue: AmountJson;
-
- isFromTip: boolean;
-}
-
-/**
- * Status of a coin.
- */
-export enum CoinStatus {
- /**
- * Withdrawn and never shown to anybody.
- */
- Fresh = "fresh",
- /**
- * A coin that has been spent and refreshed.
- */
- Dormant = "dormant",
+ ageCommitmentProof?: AgeCommitmentProof;
}
export enum CoinSourceType {
Withdraw = "withdraw",
Refresh = "refresh",
- Tip = "tip",
+ Reward = "reward",
}
export interface WithdrawCoinSource {
@@ -634,16 +779,20 @@ export interface WithdrawCoinSource {
export interface RefreshCoinSource {
type: CoinSourceType.Refresh;
+ refreshGroupId: string;
oldCoinPub: string;
}
-export interface TipCoinSource {
- type: CoinSourceType.Tip;
- walletTipId: string;
+export interface RewardCoinSource {
+ type: CoinSourceType.Reward;
+ walletRewardId: string;
coinIndex: number;
}
-export type CoinSource = WithdrawCoinSource | RefreshCoinSource | TipCoinSource;
+export type CoinSource =
+ | WithdrawCoinSource
+ | RefreshCoinSource
+ | RewardCoinSource;
/**
* CoinRecord as stored in the "coins" data store
@@ -656,6 +805,14 @@ export interface CoinRecord {
coinSource: CoinSource;
/**
+ * Source transaction ID of the coin.
+ *
+ * Used to make the coin visible after the transaction
+ * has entered a final state.
+ */
+ sourceTransactionId?: string;
+
+ /**
* Public key of the coin.
*/
coinPub: string;
@@ -666,11 +823,6 @@ export interface CoinRecord {
coinPriv: string;
/**
- * Key used by the exchange used to sign the coin.
- */
- denomPub: string;
-
- /**
* Hash of the public key that signs the coin.
*/
denomPubHash: string;
@@ -678,12 +830,7 @@ export interface CoinRecord {
/**
* Unblinded signature by the exchange.
*/
- denomSig: string;
-
- /**
- * Amount that's left on the coin.
- */
- currentAmount: AmountJson;
+ denomSig: UnblindedSignature;
/**
* Base URL that identifies the exchange from which we got the
@@ -692,11 +839,6 @@ export interface CoinRecord {
exchangeBaseUrl: string;
/**
- * The coin is currently suspended, and will not be used for payments.
- */
- suspended: boolean;
-
- /**
* Blinding key used when withdrawing the coin.
* Potentionally used again during payback.
*/
@@ -716,130 +858,67 @@ export interface CoinRecord {
status: CoinStatus;
/**
- * Information about what the coin has been allocated for.
- * Used to prevent allocation of the same coin for two different payments.
+ * Non-zero for visible.
+ *
+ * A coin is visible when it is fresh and the
+ * source transaction is in a final state.
*/
- allocation?: CoinAllocation;
-}
+ visible?: number;
-export interface CoinAllocation {
- id: string;
- amount: AmountString;
-}
-
-export enum ProposalStatus {
- /**
- * Not downloaded yet.
- */
- DOWNLOADING = "downloading",
- /**
- * Proposal downloaded, but the user needs to accept/reject it.
- */
- PROPOSED = "proposed",
- /**
- * The user has accepted the proposal.
- */
- ACCEPTED = "accepted",
/**
- * The user has rejected the proposal.
- */
- REFUSED = "refused",
- /**
- * Downloading or processing the proposal has failed permanently.
- */
- PERMANENTLY_FAILED = "permanently-failed",
- /**
- * Downloaded proposal was detected as a re-purchase.
+ * Information about what the coin has been allocated for.
+ *
+ * Used for:
+ * - Diagnostics
+ * - Idempotency of applying a coin selection (e.g. after re-selection)
*/
- REPURCHASE = "repurchase",
-}
+ spendAllocation: CoinAllocation | undefined;
-export interface ProposalDownload {
/**
- * The contract that was offered by the merchant.
+ * Maximum age of purchases that can be made with this coin.
+ *
+ * (Used for indexing, redundant with {@link ageCommitmentProof}).
*/
- contractTermsRaw: any;
+ maxAge: number;
- contractData: WalletContractData;
+ ageCommitmentProof: AgeCommitmentProof | undefined;
}
/**
- * Record for a downloaded order, stored in the wallet's database.
+ * Coin allocation, i.e. what a coin has been used for.
*/
-export interface ProposalRecord {
- orderId: string;
-
- merchantBaseUrl: string;
-
- /**
- * Downloaded data from the merchant.
- */
- download: ProposalDownload | undefined;
-
- /**
- * Unique ID when the order is stored in the wallet DB.
- */
- proposalId: string;
-
- /**
- * Timestamp (in ms) of when the record
- * was created.
- */
- timestamp: Timestamp;
-
- /**
- * Private key for the nonce.
- */
- noncePriv: string;
-
- /**
- * Public key for the nonce.
- */
- noncePub: string;
-
- claimToken: string | undefined;
-
- proposalStatus: ProposalStatus;
-
- repurchaseProposalId: string | undefined;
-
- /**
- * Session ID we got when downloading the contract.
- */
- downloadSessionId?: string;
-
+export interface CoinAllocation {
/**
- * Retry info, even present when the operation isn't active to allow indexing
- * on the next retry timestamp.
+ * ID of the allocation, should be the ID of the transaction that
*/
- retryInfo?: RetryInfo;
-
- lastError: TalerErrorDetails | undefined;
+ id: TransactionIdStr;
+ amount: AmountString;
}
/**
- * Status of a tip we got from a merchant.
+ * Status of a reward we got from a merchant.
*/
-export interface TipRecord {
- lastError: TalerErrorDetails | undefined;
-
+export interface RewardRecord {
/**
* Has the user accepted the tip? Only after the tip has been accepted coins
* withdrawn from the tip may be used.
*/
- acceptedTimestamp: Timestamp | undefined;
+ acceptedTimestamp: DbPreciseTimestamp | undefined;
/**
* The tipped amount.
*/
- tipAmountRaw: AmountJson;
+ rewardAmountRaw: AmountString;
- tipAmountEffective: AmountJson;
+ /**
+ * Effect on the balance (including fees etc).
+ */
+ rewardAmountEffective: AmountString;
/**
* Timestamp, the tip can't be picked up anymore after this deadline.
*/
- tipExpiration: Timestamp;
+ rewardExpiration: DbProtocolTimestamp;
/**
* The exchange that will sign our coins, chosen by the merchant.
@@ -854,6 +933,9 @@ export interface TipRecord {
/**
* Denomination selection made by the wallet for picking up
* this tip.
+ *
+ * FIXME: Put this into some DenomSelectionCacheRecord instead of
+ * storing it here!
*/
denomsSel: DenomSelectionState;
@@ -862,7 +944,7 @@ export interface TipRecord {
/**
* Tip ID chosen by the wallet.
*/
- walletTipId: string;
+ walletRewardId: string;
/**
* Secret seed used to derive planchets for this tip.
@@ -870,46 +952,83 @@ export interface TipRecord {
secretSeed: string;
/**
- * The merchant's identifier for this tip.
+ * The merchant's identifier for this reward.
*/
- merchantTipId: string;
+ merchantRewardId: string;
- createdTimestamp: Timestamp;
+ createdTimestamp: DbPreciseTimestamp;
/**
- * Timestamp for when the wallet finished picking up the tip
- * from the merchant.
+ * The url to be redirected after the tip is accepted.
*/
- pickedUpTimestamp: Timestamp | undefined;
+ next_url: string | undefined;
/**
- * Retry info, even present when the operation isn't active to allow indexing
- * on the next retry timestamp.
+ * Timestamp for when the wallet finished picking up the tip
+ * from the merchant.
*/
- retryInfo: RetryInfo;
+ pickedUpTimestamp: DbPreciseTimestamp | undefined;
+
+ status: RewardRecordStatus;
+}
+
+export enum RewardRecordStatus {
+ PendingPickup = 0x0100_0000,
+ SuspendedPickup = 0x0110_0000,
+ DialogAccept = 0x0101_0000,
+ Done = 0x0500_0000,
+ Aborted = 0x0500_0000,
+ Failed = 0x0501_000,
}
export enum RefreshCoinStatus {
- Pending = "pending",
- Finished = "finished",
+ Pending = 0x0100_0000,
+ Finished = 0x0500_0000,
/**
* The refresh for this coin has been frozen, because of a permanent error.
* More info in lastErrorPerCoin.
*/
- Frozen = "frozen",
+ Failed = 0x0501_000,
}
-export interface RefreshGroupRecord {
+export enum RefreshOperationStatus {
+ Pending = 0x0100_0000,
+ Suspended = 0x0110_0000,
+
+ Finished = 0x0500_000,
+ Failed = 0x0501_000,
+}
+
+/**
+ * Status of a single element of a deposit group.
+ */
+export enum DepositElementStatus {
+ DepositPending = 0x0100_0000,
/**
- * Retry info, even present when the operation isn't active to allow indexing
- * on the next retry timestamp.
+ * Accepted, but tracking.
*/
- retryInfo: RetryInfo;
+ Tracking = 0x0100_0001,
+ KycRequired = 0x0100_0002,
+ Wired = 0x0500_0000,
+ RefundSuccess = 0x0503_0000,
+ RefundFailed = 0x0501_0000,
+}
- lastError: TalerErrorDetails | undefined;
+export interface RefreshGroupPerExchangeInfo {
+ /**
+ * (Expected) output once the refresh group succeeded.
+ */
+ outputEffective: AmountString;
+}
- lastErrorPerCoin: { [coinIndex: number]: TalerErrorDetails };
+/**
+ * Group of refresh operations. The refreshed coins do not
+ * have to belong to the same exchange, but must have the same
+ * currency.
+ */
+export interface RefreshGroupRecord {
+ operationStatus: RefreshOperationStatus;
/**
* Unique, randomly generated identifier for this group of
@@ -918,19 +1037,24 @@ export interface RefreshGroupRecord {
refreshGroupId: string;
/**
+ * Currency of this refresh group.
+ */
+ currency: string;
+
+ /**
* Reason why this refresh group has been created.
*/
reason: RefreshReason;
+ originatingTransactionId?: string;
+
oldCoinPubs: string[];
- // FIXME: Should this go into a separate
- // object store for faster updates?
- refreshSessionPerCoin: (RefreshSessionRecord | undefined)[];
+ inputPerCoin: AmountString[];
- inputPerCoin: AmountJson[];
+ expectedOutputPerCoin: AmountString[];
- estimatedOutputPerCoin: AmountJson[];
+ infoPerExchange?: Record<string, RefreshGroupPerExchangeInfo>;
/**
* Flag for each coin whether refreshing finished.
@@ -940,23 +1064,25 @@ export interface RefreshGroupRecord {
*/
statusPerCoin: RefreshCoinStatus[];
- timestampCreated: Timestamp;
+ timestampCreated: DbPreciseTimestamp;
/**
* Timestamp when the refresh session finished.
*/
- timestampFinished: Timestamp | undefined;
-
- /**
- * No coins are pending, but at least one is frozen.
- */
- frozen?: boolean;
+ timestampFinished: DbPreciseTimestamp | undefined;
}
/**
* Ongoing refresh
*/
export interface RefreshSessionRecord {
+ refreshGroupId: string;
+
+ /**
+ * Index of the coin in the refresh group.
+ */
+ coinIndex: number;
+
/**
* 512-bit secret that can be used to derive
* the other cryptographic material for the refresh session.
@@ -967,7 +1093,7 @@ export interface RefreshSessionRecord {
* Sum of the value of denominations we want
* to withdraw in this session, without fees.
*/
- amountRefreshOutput: AmountJson;
+ amountRefreshOutput: AmountString;
/**
* Hashed denominations of the newly requested coins.
@@ -981,173 +1107,178 @@ export interface RefreshSessionRecord {
* The no-reveal-index after we've done the melting.
*/
norevealIndex?: number;
+
+ lastError?: TalerErrorDetail;
}
-/**
- * Wire fee for one wire method as stored in the
- * wallet's database.
- */
-export interface WireFee {
+export enum RefundReason {
/**
- * Fee for wire transfers.
+ * Normal refund given by the merchant.
*/
- wireFee: AmountJson;
-
+ NormalRefund = "normal-refund",
/**
- * Fees to close and refund a reserve.
+ * Refund from an aborted payment.
*/
- closingFee: AmountJson;
+ AbortRefund = "abort-pay-refund",
+}
+export enum PurchaseStatus {
/**
- * Start date of the fee.
+ * Not downloaded yet.
*/
- startStamp: Timestamp;
+ PendingDownloadingProposal = 0x0100_0000,
+ SuspendedDownloadingProposal = 0x0110_0000,
/**
- * End date of the fee.
+ * The user has accepted the proposal.
*/
- endStamp: Timestamp;
+ PendingPaying = 0x0100_0001,
+ SuspendedPaying = 0x0110_0001,
/**
- * Signature made by the exchange master key.
+ * Currently in the process of aborting with a refund.
*/
- sig: string;
-}
+ AbortingWithRefund = 0x0103_0000,
+ SuspendedAbortingWithRefund = 0x0113_0000,
-export enum RefundState {
- Failed = "failed",
- Applied = "applied",
- Pending = "pending",
-}
-
-/**
- * State of one refund from the merchant, maintained by the wallet.
- */
-export type WalletRefundItem =
- | WalletRefundFailedItem
- | WalletRefundPendingItem
- | WalletRefundAppliedItem;
+ /**
+ * Paying a second time, likely with different session ID
+ */
+ PendingPayingReplay = 0x0100_0002,
+ SuspendedPayingReplay = 0x0110_0002,
-export interface WalletRefundItemCommon {
- // Execution time as claimed by the merchant
- executionTime: Timestamp;
+ /**
+ * Query for refunds (until query succeeds).
+ */
+ PendingQueryingRefund = 0x0100_0003,
+ SuspendedQueryingRefund = 0x0110_0003,
/**
- * Time when the wallet became aware of the refund.
+ * Query for refund (until auto-refund deadline is reached).
*/
- obtainedTime: Timestamp;
+ PendingQueryingAutoRefund = 0x0100_0004,
+ SuspendedQueryingAutoRefund = 0x0110_0004,
- refundAmount: AmountJson;
+ PendingAcceptRefund = 0x0100_0005,
+ SuspendedPendingAcceptRefund = 0x0110_0005,
- refundFee: AmountJson;
+ /**
+ * Proposal downloaded, but the user needs to accept/reject it.
+ */
+ DialogProposed = 0x0101_0000,
/**
- * Upper bound on the refresh cost incurred by
- * applying this refund.
- *
- * Might be lower in practice when two refunds on the same
- * coin are refreshed in the same refresh operation.
+ * Proposal shared to other wallet or read from other wallet
+ * the user needs to accept/reject it.
*/
- totalRefreshCostBound: AmountJson;
+ DialogShared = 0x0101_0001,
- coinPub: string;
+ /**
+ * The user has rejected the proposal.
+ */
+ AbortedProposalRefused = 0x0503_0000,
- rtransactionId: number;
-}
+ /**
+ * Downloading or processing the proposal has failed permanently.
+ */
+ FailedClaim = 0x0501_0000,
-/**
- * Failed refund, either because the merchant did
- * something wrong or it expired.
- */
-export interface WalletRefundFailedItem extends WalletRefundItemCommon {
- type: RefundState.Failed;
-}
+ /**
+ * Tried to abort, but aborting failed or was cancelled.
+ */
+ FailedAbort = 0x0501_0001,
-export interface WalletRefundPendingItem extends WalletRefundItemCommon {
- type: RefundState.Pending;
-}
+ FailedPaidByOther = 0x0501_0002,
-export interface WalletRefundAppliedItem extends WalletRefundItemCommon {
- type: RefundState.Applied;
-}
+ /**
+ * Payment was successful.
+ */
+ Done = 0x0500_0000,
-export enum RefundReason {
/**
- * Normal refund given by the merchant.
+ * Downloaded proposal was detected as a re-purchase.
*/
- NormalRefund = "normal-refund",
+ DoneRepurchaseDetected = 0x0500_0001,
+
/**
- * Refund from an aborted payment.
+ * The payment has been aborted.
*/
- AbortRefund = "abort-pay-refund",
-}
+ AbortedIncompletePayment = 0x0503_0000,
-export interface AllowedAuditorInfo {
- auditorBaseUrl: string;
- auditorPub: string;
-}
+ AbortedRefunded = 0x0503_0001,
-export interface AllowedExchangeInfo {
- exchangeBaseUrl: string;
- exchangePub: string;
+ AbortedOrderDeleted = 0x0503_0002,
}
/**
- * Data extracted from the contract terms that is relevant for payment
- * processing in the wallet.
+ * Partial information about the downloaded proposal.
+ * Only contains data that is relevant for indexing on the
+ * "purchases" object stores.
*/
-export interface WalletContractData {
- products?: Product[];
- summaryI18n: { [lang_tag: string]: string } | undefined;
+export interface ProposalDownloadInfo {
+ contractTermsHash: string;
+ fulfillmentUrl?: string;
+ currency: string;
+ contractTermsMerchantSig: string;
+}
+
+export interface DbCoinSelection {
+ coinPubs: string[];
+ coinContributions: AmountString[];
+}
+export interface PurchasePayInfo {
/**
- * Fulfillment URL, or the empty string if the order has no fulfillment URL.
- *
- * Stored as a non-nullable string as we use this field for IndexedDB indexing.
+ * Undefined if payment is blocked by a pending refund.
*/
- fulfillmentUrl: string;
-
- contractTermsHash: string;
- fulfillmentMessage?: string;
- fulfillmentMessageI18n?: InternationalizedString;
- merchantSig: string;
- merchantPub: string;
- merchant: MerchantInfo;
- amount: AmountJson;
- orderId: string;
- merchantBaseUrl: string;
- summary: string;
- autoRefund: Duration | undefined;
- maxWireFee: AmountJson;
- wireFeeAmortization: number;
- payDeadline: Timestamp;
- refundDeadline: Timestamp;
- allowedAuditors: AllowedAuditorInfo[];
- allowedExchanges: AllowedExchangeInfo[];
- timestamp: Timestamp;
- wireMethod: string;
- wireInfoHash: string;
- maxDepositFee: AmountJson;
-}
-
-export enum AbortStatus {
- None = "none",
- AbortRefund = "abort-refund",
- AbortFinished = "abort-finished",
+ payCoinSelection?: DbCoinSelection;
+ /**
+ * Undefined if payment is blocked by a pending refund.
+ */
+ payCoinSelectionUid?: string;
+ totalPayCost: AmountString;
}
/**
* Record that stores status information about one purchase, starting from when
* the customer accepts a proposal. Includes refund status if applicable.
+ *
+ * Key: {@link proposalId}
+ * Operation status: {@link purchaseStatus}
*/
export interface PurchaseRecord {
/**
* Proposal ID for this purchase. Uniquely identifies the
* purchase and the proposal.
+ * Assigned by the wallet.
*/
proposalId: string;
/**
+ * Order ID, assigned by the merchant.
+ */
+ orderId: string;
+
+ merchantBaseUrl: string;
+
+ /**
+ * Claim token used when downloading the contract terms.
+ */
+ claimToken: string | undefined;
+
+ /**
+ * Session ID we got when downloading the contract.
+ */
+ downloadSessionId: string | undefined;
+
+ /**
+ * If this purchase is a repurchase, this field identifies the original purchase.
+ */
+ repurchaseProposalId: string | undefined;
+
+ purchaseStatus: PurchaseStatus;
+
+ /**
* Private key for the nonce.
*/
noncePriv: string;
@@ -1159,22 +1290,10 @@ export interface PurchaseRecord {
/**
* Downloaded and parsed proposal data.
- *
- * FIXME: Move this into another object store,
- * to improve read/write perf on purchases.
*/
- download: ProposalDownload;
+ download: ProposalDownloadInfo | undefined;
- /**
- * Deposit permissions, available once the user has accepted the payment.
- *
- * This value is cached and derived from payCoinSelection.
- */
- coinDepositPermissions: CoinDepositPermission[] | undefined;
-
- payCoinSelection: PayCoinSelection;
-
- payCoinSelectionUid: string;
+ payInfo: PurchasePayInfo | undefined;
/**
* Pending removals from pay coin selection.
@@ -1186,79 +1305,65 @@ export interface PurchaseRecord {
*/
pendingRemovedCoinPubs?: string[];
- totalPayCost: AmountJson;
-
/**
* Timestamp of the first time that sending a payment to the merchant
* for this purchase was successful.
*/
- timestampFirstSuccessfulPay: Timestamp | undefined;
+ timestampFirstSuccessfulPay: DbPreciseTimestamp | undefined;
merchantPaySig: string | undefined;
- /**
- * When was the purchase made?
- * Refers to the time that the user accepted.
- */
- timestampAccept: Timestamp;
-
- /**
- * Pending refunds for the purchase. A refund is pending
- * when the merchant reports a transient error from the exchange.
- */
- refunds: { [refundKey: string]: WalletRefundItem };
+ posConfirmation: string | undefined;
/**
- * When was the last refund made?
- * Set to 0 if no refund was made on the purchase.
+ * This purchase was created by reading
+ * a payment share or the wallet
+ * the nonce public by a payment share
*/
- timestampLastRefundStatus: Timestamp | undefined;
-
- /**
- * Last session signature that we submitted to /pay (if any).
- */
- lastSessionId: string | undefined;
+ shared: boolean;
/**
- * Set for the first payment, or on re-plays.
+ * When was the purchase record created?
*/
- paymentSubmitPending: boolean;
+ timestamp: DbPreciseTimestamp;
/**
- * Do we need to query the merchant for the refund status
- * of the payment?
+ * When was the purchase made?
+ * Refers to the time that the user accepted.
*/
- refundQueryRequested: boolean;
-
- abortStatus: AbortStatus;
-
- payRetryInfo?: RetryInfo;
-
- lastPayError: TalerErrorDetails | undefined;
+ timestampAccept: DbPreciseTimestamp | undefined;
/**
- * Retry information for querying the refund status with the merchant.
+ * When was the last refund made?
+ * Set to 0 if no refund was made on the purchase.
*/
- refundStatusRetryInfo: RetryInfo;
+ timestampLastRefundStatus: DbPreciseTimestamp | undefined;
/**
- * Last error (or undefined) for querying the refund status with the merchant.
+ * Last session signature that we submitted to /pay (if any).
*/
- lastRefundStatusError: TalerErrorDetails | undefined;
+ lastSessionId: string | undefined;
/**
* Continue querying the refund status until this deadline has expired.
*/
- autoRefundDeadline: Timestamp | undefined;
+ autoRefundDeadline: DbProtocolTimestamp | undefined;
/**
- * Is the payment frozen? I.e. did we encounter
- * an error where it doesn't make sense to retry.
+ * How much merchant has refund to be taken but the wallet
+ * did not picked up yet
*/
- payFrozen?: boolean;
+ refundAmountAwaiting: AmountString | undefined;
}
-export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
+export enum ConfigRecordKey {
+ WalletBackupState = "walletBackupState",
+ CurrencyDefaultsApplied = "currencyDefaultsApplied",
+ DevMode = "devMode",
+ // Only for testing, do not use!
+ TestLoopTx = "testTxLoop",
+ LastInitInfo = "lastInitInfo",
+}
/**
* Configuration key/value entries to configure
@@ -1266,10 +1371,12 @@ export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
*/
export type ConfigRecord =
| {
- key: typeof WALLET_BACKUP_STATE_KEY;
+ key: ConfigRecordKey.WalletBackupState;
value: WalletBackupConfState;
}
- | { key: "currencyDefaultsApplied"; value: boolean };
+ | { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean }
+ | { key: ConfigRecordKey.TestLoopTx; value: number }
+ | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp };
export interface WalletBackupConfState {
deviceId: string;
@@ -1284,73 +1391,196 @@ export interface WalletBackupConfState {
/**
* Timestamp stored in the last backup.
*/
- lastBackupTimestamp?: Timestamp;
+ lastBackupTimestamp?: DbPreciseTimestamp;
/**
* Last time we tried to do a backup.
*/
- lastBackupCheckTimestamp?: Timestamp;
+ lastBackupCheckTimestamp?: DbPreciseTimestamp;
lastBackupNonce?: string;
}
-/**
- * Selected denominations withn some extra info.
- */
-export interface DenomSelectionState {
- totalCoinValue: AmountJson;
- totalWithdrawCost: AmountJson;
- selectedDenoms: {
- denomPubHash: string;
- count: number;
- }[];
+// FIXME: Should these be numeric codes?
+export const enum WithdrawalRecordType {
+ BankManual = "bank-manual",
+ BankIntegrated = "bank-integrated",
+ PeerPullCredit = "peer-pull-credit",
+ PeerPushCredit = "peer-push-credit",
+ Recoup = "recoup",
+}
+
+export interface WgInfoBankIntegrated {
+ withdrawalType: WithdrawalRecordType.BankIntegrated;
+ /**
+ * Extra state for when this is a withdrawal involving
+ * a Taler-integrated bank.
+ */
+ bankInfo: ReserveBankInfo;
+ /**
+ * Info about withdrawal accounts, possibly including currency conversion.
+ */
+ exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[];
+}
+
+export interface WgInfoBankManual {
+ withdrawalType: WithdrawalRecordType.BankManual;
+
+ /**
+ * Info about withdrawal accounts, possibly including currency conversion.
+ */
+ exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[];
+}
+
+export interface WgInfoBankPeerPull {
+ withdrawalType: WithdrawalRecordType.PeerPullCredit;
+
+ // FIXME: include a transaction ID here?
+
+ /**
+ * Needed to quickly construct the taler:// URI for the counterparty
+ * without a join.
+ */
+ contractPriv: string;
+}
+
+export interface WgInfoBankPeerPush {
+ withdrawalType: WithdrawalRecordType.PeerPushCredit;
+
+ // FIXME: include a transaction ID here?
}
+export interface WgInfoBankRecoup {
+ withdrawalType: WithdrawalRecordType.Recoup;
+}
+
+export type WgInfo =
+ | WgInfoBankIntegrated
+ | WgInfoBankManual
+ | WgInfoBankPeerPull
+ | WgInfoBankPeerPush
+ | WgInfoBankRecoup;
+
+export type KycUserType = "individual" | "business";
+
+export interface KycPendingInfo {
+ paytoHash: string;
+ requirementRow: number;
+}
/**
* Group of withdrawal operations that need to be executed.
- * (Either for a normal withdrawal or from a tip.)
+ * (Either for a normal withdrawal or from a reward.)
*
* The withdrawal group record is only created after we know
* the coin selection we want to withdraw.
*/
export interface WithdrawalGroupRecord {
+ /**
+ * Unique identifier for the withdrawal group.
+ */
withdrawalGroupId: string;
+ wgInfo: WgInfo;
+
+ kycPending?: KycPendingInfo;
+
+ kycUrl?: string;
+
/**
* Secret seed used to derive planchets.
+ * Stored since planchets are created lazily.
*/
secretSeed: string;
+ /**
+ * Public key of the reserve that we're withdrawing from.
+ */
reservePub: string;
+ /**
+ * The reserve private key.
+ *
+ * FIXME: Already in the reserves object store, redundant!
+ */
+ reservePriv: string;
+
+ /**
+ * The exchange base URL that we're withdrawing from.
+ * (Redundantly stored, as the reserve record also has this info.)
+ */
exchangeBaseUrl: string;
/**
* When was the withdrawal operation started started?
* Timestamp in milliseconds.
*/
- timestampStart: Timestamp;
+ timestampStart: DbPreciseTimestamp;
/**
* When was the withdrawal operation completed?
*/
- timestampFinish?: Timestamp;
+ timestampFinish?: DbPreciseTimestamp;
+
+ /**
+ * Current status of the reserve.
+ */
+ status: WithdrawalGroupStatus;
+
+ /**
+ * Wire information (as payto URI) for the bank account that
+ * transferred funds for this reserve.
+ *
+ * FIXME: Doesn't this belong to the bankAccounts object store?
+ */
+ senderWire?: string;
+
+ /**
+ * Restrict withdrawals from this reserve to this age.
+ */
+ restrictAge?: number;
+
+ /**
+ * Amount that was sent by the user to fund the reserve.
+ */
+ instructedAmount: AmountString;
+
+ /**
+ * Amount that was observed when querying the reserve that
+ * we are withdrawing from.
+ *
+ * Useful for diagnostics.
+ */
+ reserveBalanceAmount?: AmountString;
/**
* Amount including fees (i.e. the amount subtracted from the
* reserve to withdraw all coins in this withdrawal session).
+ *
+ * (Initial amount confirmed by the user, might differ with denomSel
+ * on reselection.)
*/
- rawWithdrawalAmount: AmountJson;
+ rawWithdrawalAmount: AmountString;
- denomsSel: DenomSelectionState;
-
- denomSelUid: string;
+ /**
+ * Amount that will be added to the balance when the withdrawal succeeds.
+ *
+ * (Initial amount confirmed by the user, might differ with denomSel
+ * on reselection.)
+ */
+ effectiveWithdrawalAmount: AmountString;
/**
- * Retry info, always present even on completed operations so that indexing works.
+ * Denominations selected for withdrawal.
*/
- retryInfo: RetryInfo;
+ denomsSel: DenomSelectionState;
- lastError: TalerErrorDetails | undefined;
+ /**
+ * UID of the denomination selection.
+ *
+ * Used for merging backups.
+ *
+ * FIXME: Should this not also include a timestamp for more logical merging?
+ */
+ denomSelUid: string;
}
export interface BankWithdrawUriRecord {
@@ -1365,6 +1595,14 @@ export interface BankWithdrawUriRecord {
reservePub: string;
}
+export enum RecoupOperationStatus {
+ Pending = 0x0100_0000,
+ Suspended = 0x0110_0000,
+
+ Finished = 0x0500_000,
+ Failed = 0x0501_000,
+}
+
/**
* Status of recoup operations that were grouped together.
*
@@ -1377,9 +1615,13 @@ export interface RecoupGroupRecord {
*/
recoupGroupId: string;
- timestampStarted: Timestamp;
+ exchangeBaseUrl: string;
+
+ operationStatus: RecoupOperationStatus;
+
+ timestampStarted: DbPreciseTimestamp;
- timestampFinished: Timestamp | undefined;
+ timestampFinished: DbPreciseTimestamp | undefined;
/**
* Public keys that identify the coins being recouped
@@ -1395,27 +1637,10 @@ export interface RecoupGroupRecord {
recoupFinishedPerCoin: boolean[];
/**
- * We store old amount (i.e. before recoup) of recouped coins here,
- * as the balance of a recouped coin is set to zero when the
- * recoup group is created.
- */
- oldAmountPerCoin: AmountJson[];
-
- /**
* Public keys of coins that should be scheduled for refreshing
* after all individual recoups are done.
*/
- scheduleRefreshCoins: string[];
-
- /**
- * Retry info.
- */
- retryInfo: RetryInfo;
-
- /**
- * Last error that occurred, if any.
- */
- lastError: TalerErrorDetails | undefined;
+ scheduleRefreshCoins: CoinRefreshRequest[];
}
export enum BackupProviderStateTag {
@@ -1430,20 +1655,12 @@ export type BackupProviderState =
}
| {
tag: BackupProviderStateTag.Ready;
- nextBackupTimestamp: Timestamp;
+ nextBackupTimestamp: DbPreciseTimestamp;
}
| {
tag: BackupProviderStateTag.Retrying;
- retryInfo: RetryInfo;
- lastError?: TalerErrorDetails;
};
-export interface BackupProviderTerms {
- supportedProtocolVersion: string;
- annualFee: AmountString;
- storageLimitInMegabytes: number;
-}
-
export interface BackupProviderRecord {
/**
* Base URL of the provider.
@@ -1477,7 +1694,7 @@ export interface BackupProviderRecord {
* Does NOT correspond to the timestamp of the backup,
* which only changes when the backup content changes.
*/
- lastBackupCycleTimestamp?: Timestamp;
+ lastBackupCycleTimestamp?: DbPreciseTimestamp;
/**
* Proposal that we're currently trying to pay for.
@@ -1488,6 +1705,8 @@ export interface BackupProviderRecord {
*/
currentPaymentProposalId?: string;
+ shouldRetryFreshProposal: boolean;
+
/**
* Proposals that were used to pay (or attempt to pay) the provider.
*
@@ -1504,12 +1723,60 @@ export interface BackupProviderRecord {
uids: string[];
}
+export enum DepositOperationStatus {
+ PendingDeposit = 0x0100_0000,
+ PendingTrack = 0x0100_0001,
+ PendingKyc = 0x0100_0002,
+
+ Aborting = 0x0103_0000,
+
+ SuspendedDeposit = 0x0110_0000,
+ SuspendedTrack = 0x0110_0001,
+ SuspendedKyc = 0x0110_0002,
+
+ SuspendedAborting = 0x0113_0000,
+
+ Finished = 0x0500_0000,
+ Failed = 0x0501_0000,
+ Aborted = 0x0503_0000,
+}
+
+export interface DepositTrackingInfo {
+ // Raw wire transfer identifier of the deposit.
+ wireTransferId: string;
+ // When was the wire transfer given to the bank.
+ timestampExecuted: DbProtocolTimestamp;
+ // Total amount transfer for this wtid (including fees)
+ amountRaw: AmountString;
+ // Wire fee amount for this exchange
+ wireFee: AmountString;
+
+ exchangePub: string;
+}
+
+export interface DepositInfoPerExchange {
+ /**
+ * Expected effective amount that will be deposited
+ * from coins of this exchange.
+ */
+ amountEffective: AmountString;
+}
+
/**
* Group of deposits made by the wallet.
*/
export interface DepositGroupRecord {
depositGroupId: string;
+ currency: string;
+
+ /**
+ * Instructed amount.
+ */
+ amount: AmountString;
+
+ wireTransferDeadline: DbProtocolTimestamp;
+
merchantPub: string;
merchantPriv: string;
@@ -1525,106 +1792,729 @@ export interface DepositGroupRecord {
salt: string;
};
+ contractTermsHash: string;
+
+ payCoinSelection?: DbCoinSelection;
+
+ payCoinSelectionUid?: string;
+
+ totalPayCost: AmountString;
+
+ /**
+ * The counterparty effective deposit amount.
+ */
+ counterpartyEffectiveDepositAmount: AmountString;
+
+ timestampCreated: DbPreciseTimestamp;
+
+ timestampFinished: DbPreciseTimestamp | undefined;
+
+ operationStatus: DepositOperationStatus;
+
+ statusPerCoin?: DepositElementStatus[];
+
+ infoPerExchange?: Record<string, DepositInfoPerExchange>;
+
+ /**
+ * When the deposit transaction was aborted and
+ * refreshes were tried, we create a refresh
+ * group and store the ID here.
+ */
+ abortRefreshGroupId?: string;
+
+ kycInfo?: DepositKycInfo;
+
+ // FIXME: Do we need this and should it be in this object store?
+ trackingState?: {
+ [signature: string]: DepositTrackingInfo;
+ };
+}
+
+export interface DepositKycInfo {
+ kycUrl: string;
+ requirementRow: number;
+ paytoHash: string;
+ exchangeBaseUrl: string;
+}
+
+export interface TombstoneRecord {
/**
- * Verbatim contract terms.
+ * Tombstone ID, with the syntax "tmb:<type>:<key>".
*/
- contractTermsRaw: ContractTerms;
+ id: string;
+}
+export enum PeerPushDebitStatus {
+ /**
+ * Initiated, but no purse created yet.
+ */
+ PendingCreatePurse = 0x0100_0000 /* ACTIVE_START */,
+ PendingReady = 0x0100_0001,
+ AbortingDeletePurse = 0x0103_0000,
+ /**
+ * Refresh after the purse got deleted by the wallet.
+ */
+ AbortingRefreshDeleted = 0x0103_0001,
+ /**
+ * Refresh after the purse expired.
+ */
+ AbortingRefreshExpired = 0x0103_0002,
+
+ SuspendedCreatePurse = 0x0110_0000,
+ SuspendedReady = 0x0110_0001,
+ SuspendedAbortingDeletePurse = 0x0113_0000,
+ SuspendedAbortingRefreshDeleted = 0x0113_0001,
+ SuspendedAbortingRefreshExpired = 0x0113_0002,
+
+ Done = 0x0500_0000,
+ Aborted = 0x0503_0000,
+ Failed = 0x0501_0000,
+ Expired = 0x0502_0000,
+}
+
+export interface DbPeerPushPaymentCoinSelection {
+ contributions: AmountString[];
+ coinPubs: CoinPublicKeyString[];
+}
+
+/**
+ * Record for a push P2P payment that this wallet initiated.
+ */
+export interface PeerPushDebitRecord {
+ /**
+ * What exchange are funds coming from?
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Instructed amount.
+ */
+ amount: AmountString;
+
+ totalCost: AmountString;
+
+ coinSel?: DbPeerPushPaymentCoinSelection;
+
+ contractTermsHash: HashCodeString;
+
+ /**
+ * Purse public key. Used as the primary key to look
+ * up this record.
+ */
+ pursePub: string;
+
+ /**
+ * Purse private key.
+ */
+ pursePriv: string;
+
+ /**
+ * Public key of the merge capability of the purse.
+ */
+ mergePub: string;
+
+ /**
+ * Private key of the merge capability of the purse.
+ */
+ mergePriv: string;
+
+ contractPriv: string;
+ contractPub: string;
+
+ /**
+ * 24 byte nonce.
+ */
+ contractEncNonce: string;
+
+ purseExpiration: DbProtocolTimestamp;
+
+ timestampCreated: DbPreciseTimestamp;
+
+ abortRefreshGroupId?: string;
+
+ /**
+ * Status of the peer push payment initiation.
+ */
+ status: PeerPushDebitStatus;
+}
+
+export enum PeerPullPaymentCreditStatus {
+ PendingCreatePurse = 0x0100_0000,
+ /**
+ * Purse created, waiting for the other party to accept the
+ * invoice and deposit money into it.
+ */
+ PendingReady = 0x0100_0001,
+ PendingMergeKycRequired = 0x0100_0002,
+ PendingWithdrawing = 0x0100_0003,
+
+ AbortingDeletePurse = 0x0103_0000,
+
+ SuspendedCreatePurse = 0x0110_0000,
+ SuspendedReady = 0x0110_0001,
+ SuspendedMergeKycRequired = 0x0110_0002,
+ SuspendedWithdrawing = 0x0110_0000,
+
+ SuspendedAbortingDeletePurse = 0x0113_0000,
+
+ Done = 0x0500_0000,
+ Failed = 0x0501_0000,
+ Expired = 0x0502_0000,
+ Aborted = 0x0503_0000,
+}
+
+export interface PeerPullCreditRecord {
+ /**
+ * What exchange are we using for the payment request?
+ */
+ exchangeBaseUrl: string;
+
+ /**
+ * Amount requested.
+ * FIXME: What type of instructed amount is i?
+ */
+ amount: AmountString;
+
+ estimatedAmountEffective: AmountString;
+
+ /**
+ * Purse public key. Used as the primary key to look
+ * up this record.
+ */
+ pursePub: string;
+
+ /**
+ * Purse private key.
+ */
+ pursePriv: string;
+
+ /**
+ * Hash of the contract terms. Also
+ * used to look up the contract terms in the DB.
+ */
contractTermsHash: string;
- payCoinSelection: PayCoinSelection;
+ mergePub: string;
+ mergePriv: string;
- payCoinSelectionUid: string;
+ contractPub: string;
+ contractPriv: string;
- totalPayCost: AmountJson;
+ contractEncNonce: string;
- effectiveDepositAmount: AmountJson;
+ mergeTimestamp: DbPreciseTimestamp;
- depositedPerCoin: boolean[];
+ mergeReserveRowId: number;
- timestampCreated: Timestamp;
+ /**
+ * Status of the peer pull payment initiation.
+ */
+ status: PeerPullPaymentCreditStatus;
- timestampFinished: Timestamp | undefined;
+ kycInfo?: KycPendingInfo;
- lastError: TalerErrorDetails | undefined;
+ kycUrl?: string;
+ withdrawalGroupId: string | undefined;
+}
+
+export enum PeerPushCreditStatus {
+ PendingMerge = 0x0100_0000,
+ PendingMergeKycRequired = 0x0100_0001,
/**
- * Retry info.
+ * Merge was successful and withdrawal group has been created, now
+ * everything is in the hand of the withdrawal group.
*/
- retryInfo?: RetryInfo;
+ PendingWithdrawing = 0x0100_0002,
+
+ SuspendedMerge = 0x0110_0000,
+ SuspendedMergeKycRequired = 0x0110_0001,
+ SuspendedWithdrawing = 0x0110_0002,
+
+ DialogProposed = 0x0101_0000,
+
+ Done = 0x0500_0000,
+ Aborted = 0x0503_0000,
+ Failed = 0x0501_0000,
}
/**
- * Record for a deposits that the wallet observed
- * as a result of double spending, but which is not
- * present in the wallet's own database otherwise.
+ * Record for a push P2P payment that this wallet was offered.
+ *
+ * Unique: (exchangeBaseUrl, pursePub)
*/
-export interface GhostDepositGroupRecord {
+export interface PeerPushPaymentIncomingRecord {
+ peerPushCreditId: string;
+
+ exchangeBaseUrl: string;
+
+ pursePub: string;
+
+ mergePriv: string;
+
+ contractPriv: string;
+
+ timestamp: DbPreciseTimestamp;
+
+ estimatedAmountEffective: AmountString;
+
+ /**
+ * Hash of the contract terms. Also
+ * used to look up the contract terms in the DB.
+ */
+ contractTermsHash: string;
+
/**
- * When multiple deposits for the same contract terms hash
- * have a different timestamp, we choose the earliest one.
+ * Status of the peer push payment incoming initiation.
*/
- timestamp: Timestamp;
+ status: PeerPushCreditStatus;
+
+ /**
+ * Associated withdrawal group.
+ */
+ withdrawalGroupId: string | undefined;
+
+ /**
+ * Currency of the peer push payment credit transaction.
+ *
+ * Mandatory in current schema version, optional for compatibility
+ * with older (ver_minor<4) DB versions.
+ */
+ currency: string | undefined;
+
+ kycInfo?: KycPendingInfo;
+
+ kycUrl?: string;
+}
+
+export enum PeerPullDebitRecordStatus {
+ PendingDeposit = 0x0100_0001,
+ AbortingRefresh = 0x0103_0001,
+
+ SuspendedDeposit = 0x0110_0001,
+ SuspendedAbortingRefresh = 0x0113_0001,
+
+ DialogProposed = 0x0101_0001,
+
+ Done = 0x0500_0000,
+ Aborted = 0x0503_0000,
+ Failed = 0x0501_0000,
+}
+
+export interface PeerPullPaymentCoinSelection {
+ contributions: AmountString[];
+ coinPubs: CoinPublicKeyString[];
+
+ /**
+ * Total cost based on the coin selection.
+ * Non undefined after status === "Accepted"
+ */
+ totalCost: AmountString | undefined;
+}
+
+/**
+ * AKA PeerPullDebit.
+ */
+export interface PeerPullPaymentIncomingRecord {
+ peerPullDebitId: string;
+
+ pursePub: string;
+
+ exchangeBaseUrl: string;
+
+ amount: AmountString;
contractTermsHash: string;
- deposits: {
- coinPub: string;
- amount: AmountString;
- timestamp: Timestamp;
- depositFee: AmountString;
- merchantPub: string;
- coinSig: string;
- wireHash: string;
- }[];
+ timestampCreated: DbPreciseTimestamp;
+
+ /**
+ * Contract priv that we got from the other party.
+ */
+ contractPriv: string;
+
+ /**
+ * Status of the peer push payment incoming initiation.
+ */
+ status: PeerPullDebitRecordStatus;
+
+ /**
+ * Estimated total cost when the record was created.
+ */
+ totalCostEstimated: AmountString;
+
+ abortRefreshGroupId?: string;
+
+ coinSel?: PeerPullPaymentCoinSelection;
}
-export interface TombstoneRecord {
+/**
+ * Store for extra information about a reserve.
+ *
+ * Mostly used to store the private key for a reserve and to allow
+ * other records to reference the reserve key pair via a small row ID.
+ *
+ * In the future, we might also store KYC info about a reserve here.
+ */
+export interface ReserveRecord {
+ rowId?: number;
+ reservePub: string;
+ reservePriv: string;
+}
+
+export interface OperationRetryRecord {
/**
- * Tombstone ID, with the syntax "<type>:<key>".
+ * Unique identifier for the operation. Typically of
+ * the format `${opType}-${opUniqueKey}`
+ *
+ * @see {@link TaskIdentifiers}
*/
id: string;
+
+ lastError?: TalerErrorDetail;
+
+ retryInfo: DbRetryInfo;
+}
+
+/**
+ * Availability of coins of a given denomination (and age restriction!).
+ *
+ * We can't store this information with the denomination record, as one denomination
+ * can be withdrawn with multiple age restrictions.
+ */
+export interface CoinAvailabilityRecord {
+ currency: string;
+ value: AmountString;
+ denomPubHash: string;
+ exchangeBaseUrl: string;
+
+ /**
+ * Age restriction on the coin, or 0 for no age restriction (or
+ * denomination without age restriction support).
+ */
+ maxAge: number;
+
+ /**
+ * Number of fresh coins of this denomination that are available.
+ */
+ freshCoinCount: number;
+
+ /**
+ * Number of fresh coins that are available
+ * and visible, i.e. the source transaction is in
+ * a final state.
+ */
+ visibleCoinCount: number;
+
+ /**
+ * Number of coins that we expect to obtain via a pending refresh.
+ */
+ pendingRefreshOutputCount?: number;
+}
+
+export interface ContractTermsRecord {
+ /**
+ * Contract terms hash.
+ */
+ h: string;
+
+ /**
+ * Contract terms JSON.
+ */
+ contractTermsRaw: any;
+}
+
+export interface UserAttentionRecord {
+ info: AttentionInfo;
+
+ entityId: string;
+
+ /**
+ * When the notification was created.
+ */
+ created: DbPreciseTimestamp;
+
+ /**
+ * When the user mark this notification as read.
+ */
+ read: DbPreciseTimestamp | undefined;
+}
+
+export interface DbExchangeHandle {
+ url: string;
+ exchangeMasterPub: string;
+}
+
+export interface DbAuditorHandle {
+ url: string;
+ auditorPub: string;
+}
+
+export enum RefundGroupStatus {
+ Pending = 0x0100_0000,
+ Done = 0x0500_0000,
+ Failed = 0x0501_0000,
+ Aborted = 0x0503_0000,
+ Expired = 0x0502_0000,
+}
+
+/**
+ * Metadata about a group of refunds with the merchant.
+ */
+export interface RefundGroupRecord {
+ status: RefundGroupStatus;
+
+ /**
+ * Timestamp when the refund group was created.
+ */
+ timestampCreated: DbPreciseTimestamp;
+
+ proposalId: string;
+
+ refundGroupId: string;
+
+ refreshGroupId?: string;
+
+ amountRaw: AmountString;
+
+ /**
+ * Estimated effective amount, based on
+ * refund fees and refresh costs.
+ */
+ amountEffective: AmountString;
+}
+
+export enum RefundItemStatus {
+ /**
+ * Intermittent error that the merchant is
+ * reporting from the exchange.
+ *
+ * We'll try again!
+ */
+ Pending = 0x0100_0000,
+ /**
+ * Refund was obtained successfully.
+ */
+ Done = 0x0500_0000,
+ /**
+ * Permanent error reported by the exchange
+ * for the refund.
+ */
+ Failed = 0x0501_0000,
+}
+
+/**
+ * Refund for a single coin in a payment with a merchant.
+ */
+export interface RefundItemRecord {
+ /**
+ * Auto-increment DB record ID.
+ */
+ id?: number;
+
+ status: RefundItemStatus;
+
+ refundGroupId: string;
+
+ /**
+ * Execution time as claimed by the merchant
+ */
+ executionTime: DbProtocolTimestamp;
+
+ /**
+ * Time when the wallet became aware of the refund.
+ */
+ obtainedTime: DbPreciseTimestamp;
+
+ refundAmount: AmountString;
+
+ coinPub: string;
+
+ rtxid: number;
+}
+
+export function passthroughCodec<T>(): Codec<T> {
+ return codecForAny();
+}
+
+export interface GlobalCurrencyAuditorRecord {
+ id?: number;
+ currency: string;
+ auditorBaseUrl: string;
+ auditorPub: string;
+}
+
+export interface GlobalCurrencyExchangeRecord {
+ id?: number;
+ currency: string;
+ exchangeBaseUrl: string;
+ exchangeMasterPub: string;
+}
+
+/**
+ * Primary key: transactionItem.transactionId
+ */
+export interface TransactionRecord {
+ /**
+ * Transaction item returned to the client.
+ */
+ transactionItem: Transaction;
+
+ /**
+ * Exchanges involved in the transaction.
+ */
+ exchanges: string[];
+
+ currency: string;
+}
+
+export enum DenomLossStatus {
+ /**
+ * Done indicates that the loss happened.
+ */
+ Done = 0x0500_0000,
+
+ /**
+ * Aborted in the sense that the loss was reversed.
+ */
+ Aborted = 0x0503_0001,
}
+export interface DenomLossEventRecord {
+ denomLossEventId: string;
+ currency: string;
+ denomPubHashes: string[];
+ status: DenomLossStatus;
+ timestampCreated: DbPreciseTimestamp;
+ amount: string;
+ eventType: DenomLossEventType;
+ exchangeBaseUrl: string;
+}
+
+/**
+ * Schema definition for the IndexedDB
+ * wallet database.
+ */
export const WalletStoresV1 = {
+ denomLossEvents: describeStoreV2({
+ recordCodec: passthroughCodec<DenomLossEventRecord>(),
+ storeName: "denomLossEvents",
+ keyPath: "denomLossEventId",
+ versionAdded: 9,
+ indexes: {
+ byCurrency: describeIndex("byCurrency", "currency", {
+ versionAdded: 9,
+ }),
+ byStatus: describeIndex("byStatus", "status", {
+ versionAdded: 10,
+ }),
+ },
+ }),
+ transactions: describeStoreV2({
+ recordCodec: passthroughCodec<TransactionRecord>(),
+ storeName: "transactions",
+ keyPath: "transactionItem.transactionId",
+ versionAdded: 7,
+ indexes: {
+ byCurrency: describeIndex("byCurrency", "currency", {
+ versionAdded: 7,
+ }),
+ byExchange: describeIndex("byExchange", "exchanges", {
+ versionAdded: 7,
+ multiEntry: true,
+ }),
+ },
+ }),
+ globalCurrencyAuditors: describeStoreV2({
+ recordCodec: passthroughCodec<GlobalCurrencyAuditorRecord>(),
+ storeName: "globalCurrencyAuditors",
+ keyPath: "id",
+ autoIncrement: true,
+ versionAdded: 3,
+ indexes: {
+ byCurrencyAndUrlAndPub: describeIndex(
+ "byCurrencyAndUrlAndPub",
+ ["currency", "auditorBaseUrl", "auditorPub"],
+ {
+ unique: true,
+ versionAdded: 4,
+ },
+ ),
+ },
+ }),
+ globalCurrencyExchanges: describeStoreV2({
+ recordCodec: passthroughCodec<GlobalCurrencyExchangeRecord>(),
+ storeName: "globalCurrencyExchanges",
+ keyPath: "id",
+ autoIncrement: true,
+ versionAdded: 3,
+ indexes: {
+ byCurrencyAndUrlAndPub: describeIndex(
+ "byCurrencyAndUrlAndPub",
+ ["currency", "exchangeBaseUrl", "exchangeMasterPub"],
+ {
+ unique: true,
+ versionAdded: 4,
+ },
+ ),
+ },
+ }),
+ coinAvailability: describeStore(
+ "coinAvailability",
+ describeContents<CoinAvailabilityRecord>({
+ keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"],
+ }),
+ {
+ byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [
+ "exchangeBaseUrl",
+ "maxAge",
+ "freshCoinCount",
+ ]),
+ byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
+ versionAdded: 8,
+ }),
+ },
+ ),
coins: describeStore(
- describeContents<CoinRecord>("coins", {
+ "coins",
+ describeContents<CoinRecord>({
keyPath: "coinPub",
}),
{
byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"),
byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"),
+ byExchangeDenomPubHashAndAgeAndStatus: describeIndex(
+ "byExchangeDenomPubHashAndAgeAndStatus",
+ ["exchangeBaseUrl", "denomPubHash", "maxAge", "status"],
+ ),
byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"),
+ bySourceTransactionId: describeIndex(
+ "bySourceTransactionId",
+ "sourceTransactionId",
+ {
+ versionAdded: 9,
+ },
+ ),
},
),
- config: describeStore(
- describeContents<ConfigRecord>("config", { keyPath: "key" }),
- {},
- ),
- auditorTrust: describeStore(
- describeContents<AuditorTrustRecord>("auditorTrust", {
- keyPath: ["currency", "auditorBaseUrl"],
+ reserves: describeStore(
+ "reserves",
+ describeContents<ReserveRecord>({
+ keyPath: "rowId",
+ autoIncrement: true,
}),
{
- byAuditorPub: describeIndex("byAuditorPub", "auditorPub"),
- byUid: describeIndex("byUid", "uids", {
- multiEntry: true,
- }),
+ byReservePub: describeIndex("byReservePub", "reservePub", {}),
},
),
- exchangeTrust: describeStore(
- describeContents<ExchangeTrustRecord>("exchangeTrust", {
- keyPath: ["currency", "exchangeBaseUrl"],
- }),
- {
- byExchangeMasterPub: describeIndex(
- "byExchangeMasterPub",
- "exchangeMasterPub",
- ),
- },
+ config: describeStore(
+ "config",
+ describeContents<ConfigRecord>({ keyPath: "key" }),
+ {},
),
denominations: describeStore(
- describeContents<DenominationRecord>("denominations", {
+ "denominations",
+ describeContents<DenominationRecord>({
keyPath: ["exchangeBaseUrl", "denomPubHash"],
}),
{
@@ -1632,96 +2522,145 @@ export const WalletStoresV1 = {
},
),
exchanges: describeStore(
- describeContents<ExchangeRecord>("exchanges", {
+ "exchanges",
+ describeContents<ExchangeEntryRecord>({
keyPath: "baseUrl",
}),
{},
),
exchangeDetails: describeStore(
- describeContents<ExchangeDetailsRecord>("exchangeDetails", {
- keyPath: ["exchangeBaseUrl", "currency", "masterPublicKey"],
+ "exchangeDetails",
+ describeContents<ExchangeDetailsRecord>({
+ keyPath: "rowId",
+ autoIncrement: true,
}),
- {},
+ {
+ byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
+ versionAdded: 2,
+ }),
+ byPointer: describeIndex(
+ "byDetailsPointer",
+ ["exchangeBaseUrl", "currency", "masterPublicKey"],
+ {
+ unique: true,
+ },
+ ),
+ },
),
- proposals: describeStore(
- describeContents<ProposalRecord>("proposals", { keyPath: "proposalId" }),
+ exchangeSignKeys: describeStore(
+ "exchangeSignKeys",
+ describeContents<ExchangeSignkeysRecord>({
+ keyPath: ["exchangeDetailsRowId", "signkeyPub"],
+ }),
{
- byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
- "merchantBaseUrl",
- "orderId",
+ byExchangeDetailsRowId: describeIndex("byExchangeDetailsRowId", [
+ "exchangeDetailsRowId",
]),
},
),
refreshGroups: describeStore(
- describeContents<RefreshGroupRecord>("refreshGroups", {
+ "refreshGroups",
+ describeContents<RefreshGroupRecord>({
keyPath: "refreshGroupId",
}),
+ {
+ byStatus: describeIndex("byStatus", "operationStatus"),
+ byOriginatingTransactionId: describeIndex(
+ "byOriginatingTransactionId",
+ "originatingTransactionId",
+ {
+ versionAdded: 5,
+ },
+ ),
+ },
+ ),
+ refreshSessions: describeStore(
+ "refreshSessions",
+ describeContents<RefreshSessionRecord>({
+ keyPath: ["refreshGroupId", "coinIndex"],
+ }),
{},
),
recoupGroups: describeStore(
- describeContents<RecoupGroupRecord>("recoupGroups", {
+ "recoupGroups",
+ describeContents<RecoupGroupRecord>({
keyPath: "recoupGroupId",
}),
- {},
- ),
- reserves: describeStore(
- describeContents<ReserveRecord>("reserves", { keyPath: "reservePub" }),
{
- byInitialWithdrawalGroupId: describeIndex(
- "byInitialWithdrawalGroupId",
- "initialWithdrawalGroupId",
- ),
+ byStatus: describeIndex("byStatus", "operationStatus", {
+ versionAdded: 6,
+ }),
},
),
purchases: describeStore(
- describeContents<PurchaseRecord>("purchases", { keyPath: "proposalId" }),
+ "purchases",
+ describeContents<PurchaseRecord>({ keyPath: "proposalId" }),
{
+ byStatus: describeIndex("byStatus", "purchaseStatus"),
byFulfillmentUrl: describeIndex(
"byFulfillmentUrl",
- "download.contractData.fulfillmentUrl",
+ "download.fulfillmentUrl",
),
- byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [
- "download.contractData.merchantBaseUrl",
- "download.contractData.orderId",
+ byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
+ "merchantBaseUrl",
+ "orderId",
]),
},
),
- tips: describeStore(
- describeContents<TipRecord>("tips", { keyPath: "walletTipId" }),
+ rewards: describeStore(
+ "rewards",
+ describeContents<RewardRecord>({ keyPath: "walletRewardId" }),
{
- byMerchantTipIdAndBaseUrl: describeIndex("byMerchantTipIdAndBaseUrl", [
- "merchantTipId",
+ byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [
+ "merchantRewardId",
"merchantBaseUrl",
]),
+ byStatus: describeIndex("byStatus", "status", {
+ versionAdded: 8,
+ }),
},
),
withdrawalGroups: describeStore(
- describeContents<WithdrawalGroupRecord>("withdrawalGroups", {
+ "withdrawalGroups",
+ describeContents<WithdrawalGroupRecord>({
keyPath: "withdrawalGroupId",
}),
{
- byReservePub: describeIndex("byReservePub", "reservePub"),
+ byStatus: describeIndex("byStatus", "status"),
+ byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
+ versionAdded: 2,
+ }),
+ byTalerWithdrawUri: describeIndex(
+ "byTalerWithdrawUri",
+ "wgInfo.bankInfo.talerWithdrawUri",
+ ),
},
),
planchets: describeStore(
- describeContents<PlanchetRecord>("planchets", { keyPath: "coinPub" }),
+ "planchets",
+ describeContents<PlanchetRecord>({ keyPath: "coinPub" }),
{
- byGroupAndIndex: describeIndex("byGroupAndIndex", [
- "withdrawalGroupId",
- "coinIdx",
- ]),
+ byGroupAndIndex: describeIndex(
+ "byGroupAndIndex",
+ ["withdrawalGroupId", "coinIdx"],
+ {
+ unique: true,
+ },
+ ),
byGroup: describeIndex("byGroup", "withdrawalGroupId"),
byCoinEvHash: describeIndex("byCoinEv", "coinEvHash"),
},
),
bankWithdrawUris: describeStore(
- describeContents<BankWithdrawUriRecord>("bankWithdrawUris", {
+ "bankWithdrawUris",
+ describeContents<BankWithdrawUriRecord>({
keyPath: "talerWithdrawUri",
}),
{},
),
backupProviders: describeStore(
- describeContents<BackupProviderRecord>("backupProviders", {
+ "backupProviders",
+ describeContents<BackupProviderRecord>({
keyPath: "baseUrl",
}),
{
@@ -1735,23 +2674,184 @@ export const WalletStoresV1 = {
},
),
depositGroups: describeStore(
- describeContents<DepositGroupRecord>("depositGroups", {
+ "depositGroups",
+ describeContents<DepositGroupRecord>({
keyPath: "depositGroupId",
}),
- {},
+ {
+ byStatus: describeIndex("byStatus", "operationStatus"),
+ },
),
tombstones: describeStore(
- describeContents<TombstoneRecord>("tombstones", { keyPath: "id" }),
+ "tombstones",
+ describeContents<TombstoneRecord>({ keyPath: "id" }),
{},
),
- ghostDepositGroups: describeStore(
- describeContents<GhostDepositGroupRecord>("ghostDepositGroups", {
- keyPath: "contractTermsHash",
+ operationRetries: describeStore(
+ "operationRetries",
+ describeContents<OperationRetryRecord>({
+ keyPath: "id",
+ }),
+ {},
+ ),
+ peerPushCredit: describeStore(
+ "peerPushCredit",
+ describeContents<PeerPushPaymentIncomingRecord>({
+ keyPath: "peerPushCreditId",
+ }),
+ {
+ byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
+ "exchangeBaseUrl",
+ "pursePub",
+ ]),
+ byExchangeAndContractPriv: describeIndex(
+ "byExchangeAndContractPriv",
+ ["exchangeBaseUrl", "contractPriv"],
+ {
+ unique: true,
+ },
+ ),
+ byWithdrawalGroupId: describeIndex(
+ "byWithdrawalGroupId",
+ "withdrawalGroupId",
+ {},
+ ),
+ byStatus: describeIndex("byStatus", "status"),
+ },
+ ),
+ peerPullDebit: describeStore(
+ "peerPullDebit",
+ describeContents<PeerPullPaymentIncomingRecord>({
+ keyPath: "peerPullDebitId",
+ }),
+ {
+ byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
+ "exchangeBaseUrl",
+ "pursePub",
+ ]),
+ byExchangeAndContractPriv: describeIndex(
+ "byExchangeAndContractPriv",
+ ["exchangeBaseUrl", "contractPriv"],
+ {
+ unique: true,
+ },
+ ),
+ byStatus: describeIndex("byStatus", "status"),
+ },
+ ),
+ peerPullCredit: describeStore(
+ "peerPullCredit",
+ describeContents<PeerPullCreditRecord>({
+ keyPath: "pursePub",
+ }),
+ {
+ byStatus: describeIndex("byStatus", "status"),
+ byWithdrawalGroupId: describeIndex(
+ "byWithdrawalGroupId",
+ "withdrawalGroupId",
+ {},
+ ),
+ },
+ ),
+ peerPushDebit: describeStore(
+ "peerPushDebit",
+ describeContents<PeerPushDebitRecord>({
+ keyPath: "pursePub",
+ }),
+ {
+ byStatus: describeIndex("byStatus", "status"),
+ },
+ ),
+ bankAccounts: describeStore(
+ "bankAccounts",
+ describeContents<BankAccountsRecord>({
+ keyPath: "uri",
+ }),
+ {},
+ ),
+ contractTerms: describeStore(
+ "contractTerms",
+ describeContents<ContractTermsRecord>({
+ keyPath: "h",
+ }),
+ {},
+ ),
+ userAttention: describeStore(
+ "userAttention",
+ describeContents<UserAttentionRecord>({
+ keyPath: ["entityId", "info.type"],
+ }),
+ {},
+ ),
+ refundGroups: describeStore(
+ "refundGroups",
+ describeContents<RefundGroupRecord>({
+ keyPath: "refundGroupId",
+ }),
+ {
+ byProposalId: describeIndex("byProposalId", "proposalId"),
+ byStatus: describeIndex("byStatus", "status", {}),
+ },
+ ),
+ refundItems: describeStore(
+ "refundItems",
+ describeContents<RefundItemRecord>({
+ keyPath: "id",
+ autoIncrement: true,
+ }),
+ {
+ byCoinPubAndRtxid: describeIndex("byCoinPubAndRtxid", [
+ "coinPub",
+ "rtxid",
+ ]),
+ // FIXME: Why is this a list of index keys? Confusing!
+ byRefundGroupId: describeIndex("byRefundGroupId", ["refundGroupId"]),
+ },
+ ),
+ fixups: describeStore(
+ "fixups",
+ describeContents<FixupRecord>({
+ keyPath: "fixupName",
}),
{},
),
};
+export type WalletDbStoresArr = Array<StoreNames<typeof WalletStoresV1>>;
+
+export type WalletDbReadWriteTransaction<StoresArr extends WalletDbStoresArr> =
+ DbReadWriteTransaction<typeof WalletStoresV1, StoresArr>;
+
+export type WalletDbReadOnlyTransaction<StoresArr extends WalletDbStoresArr> =
+ DbReadOnlyTransaction<typeof WalletStoresV1, StoresArr>;
+
+export type WalletDbAllStoresReadOnlyTransaction<> = DbReadOnlyTransaction<
+ typeof WalletStoresV1,
+ WalletDbStoresArr
+>;
+
+export type WalletDbAllStoresReadWriteTransaction<> = DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ WalletDbStoresArr
+>;
+
+/**
+ * An applied migration.
+ */
+export interface FixupRecord {
+ fixupName: string;
+}
+
+/**
+ * User accounts
+ */
+export interface BankAccountsRecord {
+ uri: string;
+ currency: string;
+ kycCompleted: boolean;
+ alias: string;
+}
+
export interface MetaConfigRecord {
key: string;
value: any;
@@ -1759,7 +2859,501 @@ export interface MetaConfigRecord {
export const walletMetadataStore = {
metaConfig: describeStore(
- describeContents<MetaConfigRecord>("metaConfig", { keyPath: "key" }),
+ "metaConfig",
+ describeContents<MetaConfigRecord>({ keyPath: "key" }),
+ {},
+ ),
+};
+
+export interface StoredBackupMeta {
+ name: string;
+}
+
+export const StoredBackupStores = {
+ backupMeta: describeStore(
+ "backupMeta",
+ describeContents<StoredBackupMeta>({ keyPath: "name" }),
{},
),
+ backupData: describeStore("backupData", describeContents<any>({}), {}),
};
+
+export interface DbDumpRecord {
+ /**
+ * Key, serialized with structuredEncapsulated.
+ *
+ * Only present for out-of-line keys (i.e. no key path).
+ */
+ key?: any;
+ /**
+ * Value, serialized with structuredEncapsulated.
+ */
+ value: any;
+}
+
+export interface DbIndexDump {
+ keyPath: string | string[];
+ multiEntry: boolean;
+ unique: boolean;
+}
+
+export interface DbStoreDump {
+ keyPath?: string | string[];
+ autoIncrement: boolean;
+ indexes: { [indexName: string]: DbIndexDump };
+ records: DbDumpRecord[];
+}
+
+export interface DbDumpDatabase {
+ version: number;
+ stores: { [storeName: string]: DbStoreDump };
+}
+
+export interface DbDump {
+ databases: {
+ [name: string]: DbDumpDatabase;
+ };
+}
+
+export async function exportSingleDb(
+ idb: IDBFactory,
+ dbName: string,
+): Promise<DbDumpDatabase> {
+ const myDb = await openDatabase(
+ idb,
+ dbName,
+ undefined,
+ () => {
+ logger.info(`unexpected onversionchange in exportSingleDb of ${dbName}`);
+ },
+ () => {
+ logger.info(`unexpected onupgradeneeded in exportSingleDb of ${dbName}`);
+ },
+ );
+
+ const singleDbDump: DbDumpDatabase = {
+ version: myDb.version,
+ stores: {},
+ };
+
+ return new Promise((resolve, reject) => {
+ const tx = myDb.transaction(Array.from(myDb.objectStoreNames));
+ tx.addEventListener("complete", () => {
+ //myDb.close();
+ resolve(singleDbDump);
+ });
+ // tslint:disable-next-line:prefer-for-of
+ for (let i = 0; i < myDb.objectStoreNames.length; i++) {
+ const name = myDb.objectStoreNames[i];
+ const store = tx.objectStore(name);
+ const storeDump: DbStoreDump = {
+ autoIncrement: store.autoIncrement,
+ keyPath: store.keyPath,
+ indexes: {},
+ records: [],
+ };
+ const indexNames = store.indexNames;
+ for (let j = 0; j < indexNames.length; j++) {
+ const idxName = indexNames[j];
+ const index = store.index(idxName);
+ storeDump.indexes[idxName] = {
+ keyPath: index.keyPath,
+ multiEntry: index.multiEntry,
+ unique: index.unique,
+ };
+ }
+ singleDbDump.stores[name] = storeDump;
+ store.openCursor().addEventListener("success", (e: Event) => {
+ const cursor = (e.target as any).result;
+ if (cursor) {
+ const rec: DbDumpRecord = {
+ value: structuredEncapsulate(cursor.value),
+ };
+ // Only store key if necessary, i.e. when
+ // the key is not stored as part of the object via
+ // a key path.
+ if (store.keyPath == null) {
+ rec.key = structuredEncapsulate(cursor.key);
+ }
+ storeDump.records.push(rec);
+ cursor.continue();
+ }
+ });
+ }
+ });
+}
+
+export async function exportDb(idb: IDBFactory): Promise<DbDump> {
+ const dbDump: DbDump = {
+ databases: {},
+ };
+
+ dbDump.databases[TALER_WALLET_META_DB_NAME] = await exportSingleDb(
+ idb,
+ TALER_WALLET_META_DB_NAME,
+ );
+ dbDump.databases[TALER_WALLET_MAIN_DB_NAME] = await exportSingleDb(
+ idb,
+ TALER_WALLET_MAIN_DB_NAME,
+ );
+
+ return dbDump;
+}
+
+async function recoverFromDump(
+ db: IDBDatabase,
+ dbDump: DbDumpDatabase,
+): Promise<void> {
+ const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite");
+ const txProm = promiseFromTransaction(tx);
+ const storeNames = db.objectStoreNames;
+ for (let i = 0; i < storeNames.length; i++) {
+ const name = db.objectStoreNames[i];
+ const storeDump = dbDump.stores[name];
+ if (!storeDump) continue;
+ await promiseFromRequest(tx.objectStore(name).clear());
+ logger.info(`importing ${storeDump.records.length} records into ${name}`);
+ for (let rec of storeDump.records) {
+ await promiseFromRequest(tx.objectStore(name).put(rec.value, rec.key));
+ logger.info("importing record done");
+ }
+ }
+ tx.commit();
+ return await txProm;
+}
+
+function checkDbDump(x: any): x is DbDump {
+ return "databases" in x;
+}
+
+export async function importDb(db: IDBDatabase, dumpJson: any): Promise<void> {
+ const d = structuredRevive(dumpJson);
+ if (checkDbDump(d)) {
+ const walletDb = d.databases[TALER_WALLET_MAIN_DB_NAME];
+ if (!walletDb) {
+ throw Error(
+ `unable to import, main wallet database (${TALER_WALLET_MAIN_DB_NAME}) not found`,
+ );
+ }
+ await recoverFromDump(db, walletDb);
+ } else {
+ throw Error("unable to import, doesn't look like a valid DB dump");
+ }
+}
+
+export interface FixupDescription {
+ name: string;
+ fn(
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ Array<StoreNames<typeof WalletStoresV1>>
+ >,
+ ): Promise<void>;
+}
+
+/**
+ * Manual migrations between minor versions of the DB schema.
+ */
+export const walletDbFixups: FixupDescription[] = [];
+
+const logger = new Logger("db.ts");
+
+export async function applyFixups(
+ db: DbAccess<typeof WalletStoresV1>,
+): Promise<void> {
+ logger.trace("applying fixups");
+ await db.runAllStoresReadWriteTx({}, async (tx) => {
+ for (const fixupInstruction of walletDbFixups) {
+ logger.trace(`checking fixup ${fixupInstruction.name}`);
+ const fixupRecord = await tx.fixups.get(fixupInstruction.name);
+ if (fixupRecord) {
+ continue;
+ }
+ logger.info(`applying DB fixup ${fixupInstruction.name}`);
+ await fixupInstruction.fn(tx);
+ await tx.fixups.put({
+ fixupName: fixupInstruction.name,
+ });
+ }
+ });
+}
+
+/**
+ * Upgrade an IndexedDB in an upgrade transaction.
+ *
+ * The upgrade is made based on a store map, i.e. the metadata
+ * structure that describes all the object stores and indexes.
+ */
+function upgradeFromStoreMap(
+ storeMap: any, // FIXME: nail down type
+ db: IDBDatabase,
+ oldVersion: number,
+ newVersion: number,
+ upgradeTransaction: IDBTransaction,
+): void {
+ if (oldVersion === 0) {
+ for (const n in storeMap) {
+ const swi: StoreWithIndexes<
+ any,
+ StoreDescriptor<unknown>,
+ any
+ > = storeMap[n];
+ const storeDesc: StoreDescriptor<unknown> = swi.store;
+ const s = db.createObjectStore(swi.storeName, {
+ autoIncrement: storeDesc.autoIncrement,
+ keyPath: storeDesc.keyPath,
+ });
+ for (const indexName in swi.indexMap as any) {
+ const indexDesc: IndexDescriptor = swi.indexMap[indexName];
+ s.createIndex(indexDesc.name, indexDesc.keyPath, {
+ multiEntry: indexDesc.multiEntry,
+ unique: indexDesc.unique,
+ });
+ }
+ }
+ return;
+ }
+ if (oldVersion === newVersion) {
+ return;
+ }
+ logger.info(`upgrading database from ${oldVersion} to ${newVersion}`);
+ for (const n in storeMap) {
+ const swi: StoreWithIndexes<any, StoreDescriptor<unknown>, any> = storeMap[
+ n
+ ];
+ const storeDesc: StoreDescriptor<unknown> = swi.store;
+ const storeAddedVersion = storeDesc.versionAdded ?? 0;
+ let s: IDBObjectStore;
+ if (storeAddedVersion > oldVersion) {
+ // Be tolerant if object store already exists.
+ // Probably means somebody deployed without
+ // adding the "addedInVersion" attribute.
+ if (!upgradeTransaction.objectStoreNames.contains(swi.storeName)) {
+ try {
+ s = db.createObjectStore(swi.storeName, {
+ autoIncrement: storeDesc.autoIncrement,
+ keyPath: storeDesc.keyPath,
+ });
+ } catch (e) {
+ const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : "";
+ throw new Error(
+ `Migration failed. Could not create store ${swi.storeName}.${moreInfo}`,
+ // @ts-expect-error no support for options.cause yet
+ { cause: e },
+ );
+ }
+ }
+ }
+
+ s = upgradeTransaction.objectStore(swi.storeName);
+
+ for (const indexName in swi.indexMap as any) {
+ const indexDesc: IndexDescriptor = swi.indexMap[indexName];
+ const indexAddedVersion = indexDesc.versionAdded ?? 0;
+ if (indexAddedVersion <= oldVersion) {
+ continue;
+ }
+ // Be tolerant if index already exists.
+ // Probably means somebody deployed without
+ // adding the "addedInVersion" attribute.
+ if (!s.indexNames.contains(indexDesc.name)) {
+ try {
+ s.createIndex(indexDesc.name, indexDesc.keyPath, {
+ multiEntry: indexDesc.multiEntry,
+ unique: indexDesc.unique,
+ });
+ } catch (e) {
+ const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : "";
+ throw Error(
+ `Migration failed. Could not create index ${indexDesc.name}/${indexDesc.keyPath}. ${moreInfo}`,
+ // @ts-expect-error no support for options.cause yet
+ { cause: e },
+ );
+ }
+ }
+ }
+ }
+}
+
+function promiseFromTransaction(transaction: IDBTransaction): Promise<void> {
+ return new Promise<void>((resolve, reject) => {
+ transaction.oncomplete = () => {
+ resolve();
+ };
+ transaction.onerror = () => {
+ reject();
+ };
+ });
+}
+
+export function promiseFromRequest(request: IDBRequest): Promise<any> {
+ return new Promise((resolve, reject) => {
+ request.onsuccess = () => {
+ resolve(request.result);
+ };
+ request.onerror = () => {
+ reject(request.error);
+ };
+ });
+}
+
+/**
+ * Purge all data in the given database.
+ */
+export function clearDatabase(db: IDBDatabase): Promise<void> {
+ // db.objectStoreNames is a DOMStringList, so we need to convert
+ let stores: string[] = [];
+ for (let i = 0; i < db.objectStoreNames.length; i++) {
+ stores.push(db.objectStoreNames[i]);
+ }
+ const tx = db.transaction(stores, "readwrite");
+ for (const store of stores) {
+ tx.objectStore(store).clear();
+ }
+ return promiseFromTransaction(tx);
+}
+
+function onTalerDbUpgradeNeeded(
+ db: IDBDatabase,
+ oldVersion: number,
+ newVersion: number,
+ upgradeTransaction: IDBTransaction,
+) {
+ upgradeFromStoreMap(
+ WalletStoresV1,
+ db,
+ oldVersion,
+ newVersion,
+ upgradeTransaction,
+ );
+}
+
+function onMetaDbUpgradeNeeded(
+ db: IDBDatabase,
+ oldVersion: number,
+ newVersion: number,
+ upgradeTransaction: IDBTransaction,
+) {
+ upgradeFromStoreMap(
+ walletMetadataStore,
+ db,
+ oldVersion,
+ newVersion,
+ upgradeTransaction,
+ );
+}
+
+function onStoredBackupsDbUpgradeNeeded(
+ db: IDBDatabase,
+ oldVersion: number,
+ newVersion: number,
+ upgradeTransaction: IDBTransaction,
+) {
+ upgradeFromStoreMap(
+ StoredBackupStores,
+ db,
+ oldVersion,
+ newVersion,
+ upgradeTransaction,
+ );
+}
+
+export async function openStoredBackupsDatabase(
+ idbFactory: IDBFactory,
+): Promise<DbAccess<typeof StoredBackupStores>> {
+ const backupsDbHandle = await openDatabase(
+ idbFactory,
+ TALER_WALLET_STORED_BACKUPS_DB_NAME,
+ 1,
+ () => {},
+ onStoredBackupsDbUpgradeNeeded,
+ );
+
+ const handle = new DbAccessImpl(backupsDbHandle, StoredBackupStores);
+ return handle;
+}
+
+/**
+ * Return a promise that resolves
+ * to the taler wallet db.
+ *
+ * @param onVersionChange Called when another client concurrenctly connects to the database
+ * with a higher version.
+ */
+export async function openTalerDatabase(
+ idbFactory: IDBFactory,
+ onVersionChange: () => void,
+): Promise<DbAccess<typeof WalletStoresV1>> {
+ const metaDbHandle = await openDatabase(
+ idbFactory,
+ TALER_WALLET_META_DB_NAME,
+ 1,
+ () => {},
+ onMetaDbUpgradeNeeded,
+ );
+
+ const metaDb = new DbAccessImpl(metaDbHandle, walletMetadataStore);
+ let currentMainVersion: string | undefined;
+ await metaDb.runReadWriteTx(["metaConfig"], async (tx) => {
+ const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY);
+ if (!dbVersionRecord) {
+ currentMainVersion = TALER_WALLET_MAIN_DB_NAME;
+ await tx.metaConfig.put({
+ key: CURRENT_DB_CONFIG_KEY,
+ value: TALER_WALLET_MAIN_DB_NAME,
+ });
+ } else {
+ currentMainVersion = dbVersionRecord.value;
+ }
+ });
+
+ if (currentMainVersion !== TALER_WALLET_MAIN_DB_NAME) {
+ switch (currentMainVersion) {
+ case "taler-wallet-main-v2":
+ case "taler-wallet-main-v3":
+ case "taler-wallet-main-v4": // temporary, we might migrate v4 later
+ case "taler-wallet-main-v5":
+ case "taler-wallet-main-v6":
+ case "taler-wallet-main-v7":
+ case "taler-wallet-main-v8":
+ case "taler-wallet-main-v9":
+ // We consider this a pre-release
+ // development version, no migration is done.
+ await metaDb.runReadWriteTx(["metaConfig"], async (tx) => {
+ await tx.metaConfig.put({
+ key: CURRENT_DB_CONFIG_KEY,
+ value: TALER_WALLET_MAIN_DB_NAME,
+ });
+ });
+ break;
+ default:
+ throw Error(
+ `major migration from database major=${currentMainVersion} not supported`,
+ );
+ }
+ }
+
+ const mainDbHandle = await openDatabase(
+ idbFactory,
+ TALER_WALLET_MAIN_DB_NAME,
+ WALLET_DB_MINOR_VERSION,
+ onVersionChange,
+ onTalerDbUpgradeNeeded,
+ );
+
+ const handle = new DbAccessImpl(mainDbHandle, WalletStoresV1);
+
+ await applyFixups(handle);
+
+ return handle;
+}
+
+export async function deleteTalerDatabase(
+ idbFactory: IDBFactory,
+): Promise<void> {
+ return new Promise((resolve, reject) => {
+ const req = idbFactory.deleteDatabase(TALER_WALLET_MAIN_DB_NAME);
+ req.onerror = () => reject(req.error);
+ req.onsuccess = () => resolve();
+ });
+}
diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts
new file mode 100644
index 000000000..dfefe6ef5
--- /dev/null
+++ b/packages/taler-wallet-core/src/dbless.ts
@@ -0,0 +1,419 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Helper functions to run wallet functionality (withdrawal, deposit, refresh)
+ * without a database or retry loop.
+ *
+ * Used for benchmarking, where we want to benchmark the exchange, but the
+ * normal wallet would be too sluggish.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ AmountString,
+ Amounts,
+ DenominationPubKey,
+ ExchangeBatchDepositRequest,
+ ExchangeBatchWithdrawRequest,
+ ExchangeMeltRequest,
+ ExchangeProtocolVersion,
+ Logger,
+ TalerCorebankApiClient,
+ UnblindedSignature,
+ codecForAny,
+ codecForBankWithdrawalOperationPostResponse,
+ codecForBatchDepositSuccess,
+ codecForExchangeMeltResponse,
+ codecForExchangeRevealResponse,
+ codecForExchangeWithdrawBatchResponse,
+ encodeCrock,
+ getRandomBytes,
+ hashWire,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
+import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
+import { DenominationRecord } from "./db.js";
+import { ExchangeInfo, downloadExchangeInfo } from "./exchanges.js";
+import { assembleRefreshRevealRequest } from "./refresh.js";
+import { isWithdrawableDenom } from "./denominations.js";
+import { getBankStatusUrl, getBankWithdrawalInfo } from "./withdraw.js";
+
+export { downloadExchangeInfo };
+
+const logger = new Logger("dbless.ts");
+
+export interface ReserveKeypair {
+ reservePub: string;
+ reservePriv: string;
+}
+
+/**
+ * Denormalized info about a coin.
+ */
+export interface CoinInfo {
+ coinPub: string;
+ coinPriv: string;
+ exchangeBaseUrl: string;
+ denomSig: UnblindedSignature;
+ denomPub: DenominationPubKey;
+ denomPubHash: string;
+ feeDeposit: string;
+ feeRefresh: string;
+ maxAge: number;
+}
+
+/**
+ * Check the status of a reserve, use long-polling to wait
+ * until the reserve actually has been created.
+ */
+export async function checkReserve(
+ http: HttpRequestLibrary,
+ exchangeBaseUrl: string,
+ reservePub: string,
+ longpollTimeoutMs: number = 500,
+): Promise<void> {
+ const reqUrl = new URL(`reserves/${reservePub}`, exchangeBaseUrl);
+ if (longpollTimeoutMs) {
+ reqUrl.searchParams.set("timeout_ms", `${longpollTimeoutMs}`);
+ }
+ const resp = await http.fetch(reqUrl.href, { method: "GET" });
+ if (resp.status !== 200) {
+ throw new Error("reserve not okay");
+ }
+}
+
+export interface TopupReserveWithBankArgs {
+ http: HttpRequestLibrary;
+ reservePub: string;
+ corebankApiBaseUrl: string;
+ exchangeInfo: ExchangeInfo;
+ amount: AmountString;
+}
+
+export async function topupReserveWithBank(
+ args: TopupReserveWithBankArgs,
+) {
+ const { http, corebankApiBaseUrl, amount, exchangeInfo, reservePub } = args;
+ const bankClient = new TalerCorebankApiClient(corebankApiBaseUrl);
+ const bankUser = await bankClient.createRandomBankUser();
+ const wopi = await bankClient.createWithdrawalOperation(
+ bankUser.username,
+ amount,
+ );
+ const bankInfo = await getBankWithdrawalInfo(http, wopi.taler_withdraw_uri);
+ const bankStatusUrl = getBankStatusUrl(wopi.taler_withdraw_uri);
+ if (!bankInfo.suggestedExchange) {
+ throw Error("no suggested exchange");
+ }
+ const plainPaytoUris =
+ exchangeInfo.keys.accounts.map((x) => x.payto_uri) ?? [];
+ if (plainPaytoUris.length <= 0) {
+ throw new Error();
+ }
+ const httpResp = await http.fetch(bankStatusUrl, {
+ method: "POST",
+ body: {
+ reserve_pub: reservePub,
+ selected_exchange: plainPaytoUris[0],
+ },
+ });
+ await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForBankWithdrawalOperationPostResponse(),
+ );
+ await bankClient.confirmWithdrawalOperation(bankUser.username, {
+ withdrawalOperationId: wopi.withdrawal_id,
+ });
+}
+
+export async function withdrawCoin(args: {
+ http: HttpRequestLibrary;
+ cryptoApi: TalerCryptoInterface;
+ reserveKeyPair: ReserveKeypair;
+ denom: DenominationRecord;
+ exchangeBaseUrl: string;
+}): Promise<CoinInfo> {
+ const { http, cryptoApi, reserveKeyPair, denom, exchangeBaseUrl } = args;
+ const planchet = await cryptoApi.createPlanchet({
+ coinIndex: 0,
+ denomPub: denom.denomPub,
+ feeWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw),
+ reservePriv: reserveKeyPair.reservePriv,
+ reservePub: reserveKeyPair.reservePub,
+ secretSeed: encodeCrock(getRandomBytes(32)),
+ value: Amounts.parseOrThrow(denom.value),
+ });
+
+ const reqBody: ExchangeBatchWithdrawRequest = {
+ planchets: [
+ {
+ denom_pub_hash: planchet.denomPubHash,
+ reserve_sig: planchet.withdrawSig,
+ coin_ev: planchet.coinEv,
+ },
+ ],
+ };
+ const reqUrl = new URL(
+ `reserves/${planchet.reservePub}/batch-withdraw`,
+ exchangeBaseUrl,
+ ).href;
+
+ const resp = await http.fetch(reqUrl, { method: "POST", body: reqBody });
+ const rBatch = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeWithdrawBatchResponse(),
+ );
+
+ const ubSig = await cryptoApi.unblindDenominationSignature({
+ planchet,
+ evSig: rBatch.ev_sigs[0].ev_sig,
+ });
+
+ return {
+ coinPriv: planchet.coinPriv,
+ coinPub: planchet.coinPub,
+ denomSig: ubSig,
+ denomPub: denom.denomPub,
+ denomPubHash: denom.denomPubHash,
+ feeDeposit: Amounts.stringify(denom.fees.feeDeposit),
+ feeRefresh: Amounts.stringify(denom.fees.feeRefresh),
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ maxAge: AgeRestriction.AGE_UNRESTRICTED,
+ };
+}
+
+export interface FindDenomOptions {
+ denomselAllowLate?: boolean;
+}
+
+export function findDenomOrThrow(
+ exchangeInfo: ExchangeInfo,
+ amount: AmountString,
+ options: FindDenomOptions = {},
+): DenominationRecord {
+ const denomselAllowLate = options.denomselAllowLate ?? false;
+ for (const d of exchangeInfo.keys.currentDenominations) {
+ const value: AmountJson = Amounts.parseOrThrow(d.value);
+ if (
+ Amounts.cmp(value, amount) === 0 &&
+ isWithdrawableDenom(d, denomselAllowLate)
+ ) {
+ return d;
+ }
+ }
+ throw new Error("no matching denomination found");
+}
+
+export async function depositCoin(args: {
+ http: HttpRequestLibrary;
+ cryptoApi: TalerCryptoInterface;
+ exchangeBaseUrl: string;
+ coin: CoinInfo;
+ amount: AmountString;
+ depositPayto?: string;
+ merchantPub?: string;
+ contractTermsHash?: string;
+ // 16 bytes, crockford encoded
+ wireSalt?: string;
+}): Promise<void> {
+ const { coin, http, cryptoApi } = args;
+ const depositPayto =
+ args.depositPayto ?? "payto://x-taler-bank/localhost/foo?receiver-name=foo";
+ const wireSalt = args.wireSalt ?? encodeCrock(getRandomBytes(16));
+ const timestampNow = AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now());
+ const contractTermsHash =
+ args.contractTermsHash ?? encodeCrock(getRandomBytes(64));
+ const depositTimestamp = timestampNow;
+ const refundDeadline = timestampNow;
+ const wireTransferDeadline = timestampNow;
+ const merchantPub = args.merchantPub ?? encodeCrock(getRandomBytes(32));
+ const dp = await cryptoApi.signDepositPermission({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contractTermsHash,
+ denomKeyType: coin.denomPub.cipher,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ feeDeposit: Amounts.parseOrThrow(coin.feeDeposit),
+ merchantPub,
+ spendAmount: Amounts.parseOrThrow(args.amount),
+ timestamp: depositTimestamp,
+ refundDeadline: refundDeadline,
+ wireInfoHash: hashWire(depositPayto, wireSalt),
+ });
+ const requestBody: ExchangeBatchDepositRequest = {
+ coins: [
+ {
+ contribution: Amounts.stringify(dp.contribution),
+ coin_pub: dp.coin_pub,
+ coin_sig: dp.coin_sig,
+ denom_pub_hash: dp.h_denom,
+ ub_sig: dp.ub_sig,
+ },
+ ],
+ merchant_payto_uri: depositPayto,
+ wire_salt: wireSalt,
+ h_contract_terms: contractTermsHash,
+ timestamp: depositTimestamp,
+ wire_transfer_deadline: wireTransferDeadline,
+ refund_deadline: refundDeadline,
+ merchant_pub: merchantPub,
+ };
+ const url = new URL(`batch-deposit`, dp.exchange_url);
+ const httpResp = await http.fetch(url.href, {
+ method: "POST",
+ body: requestBody,
+ });
+ await readSuccessResponseJsonOrThrow(httpResp, codecForBatchDepositSuccess());
+}
+
+export async function refreshCoin(req: {
+ http: HttpRequestLibrary;
+ cryptoApi: TalerCryptoInterface;
+ oldCoin: CoinInfo;
+ newDenoms: DenominationRecord[];
+}): Promise<void> {
+ const { cryptoApi, oldCoin, http } = req;
+ const refreshSessionSeed = encodeCrock(getRandomBytes(32));
+ const session = await cryptoApi.deriveRefreshSession({
+ exchangeProtocolVersion: ExchangeProtocolVersion.V12,
+ feeRefresh: Amounts.parseOrThrow(oldCoin.feeRefresh),
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ sessionSecretSeed: refreshSessionSeed,
+ newCoinDenoms: req.newDenoms.map((x) => ({
+ count: 1,
+ denomPub: x.denomPub,
+ denomPubHash: x.denomPubHash,
+ feeWithdraw: x.fees.feeWithdraw,
+ value: x.value,
+ })),
+ meltCoinMaxAge: oldCoin.maxAge,
+ });
+
+ const meltReqBody: ExchangeMeltRequest = {
+ coin_pub: oldCoin.coinPub,
+ confirm_sig: session.confirmSig,
+ denom_pub_hash: oldCoin.denomPubHash,
+ denom_sig: oldCoin.denomSig,
+ rc: session.hash,
+ value_with_fee: Amounts.stringify(session.meltValueWithFee),
+ };
+
+ logger.info("requesting melt");
+
+ const meltReqUrl = new URL(
+ `coins/${oldCoin.coinPub}/melt`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ logger.info("requesting melt done");
+
+ const meltHttpResp = await http.fetch(meltReqUrl.href, {
+ method: "POST",
+ body: meltReqBody,
+ });
+
+ const meltResponse = await readSuccessResponseJsonOrThrow(
+ meltHttpResp,
+ codecForExchangeMeltResponse(),
+ );
+
+ const norevealIndex = meltResponse.noreveal_index;
+
+ const revealRequest = await assembleRefreshRevealRequest({
+ cryptoApi,
+ derived: session,
+ newDenoms: req.newDenoms.map((x) => ({
+ count: 1,
+ denomPubHash: x.denomPubHash,
+ })),
+ norevealIndex,
+ oldCoinPriv: oldCoin.coinPriv,
+ oldCoinPub: oldCoin.coinPub,
+ });
+
+ logger.info("requesting reveal");
+ const reqUrl = new URL(
+ `refreshes/${session.hash}/reveal`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ const revealResp = await http.fetch(reqUrl.href, {
+ method: "POST",
+ body: revealRequest,
+ });
+
+ logger.info("requesting reveal done");
+
+ const reveal = await readSuccessResponseJsonOrThrow(
+ revealResp,
+ codecForExchangeRevealResponse(),
+ );
+
+ // We could unblind here, but we only use this function to
+ // benchmark the exchange.
+}
+
+/**
+ * Create a reserve for testing withdrawals.
+ *
+ * The reserve is created using the test-only API "/admin/add-incoming".
+ */
+export async function createTestingReserve(args: {
+ http: HttpRequestLibrary;
+ corebankApiBaseUrl: string;
+ amount: string;
+ reservePub: string;
+ exchangeInfo: ExchangeInfo;
+}): Promise<void> {
+ const { http, corebankApiBaseUrl, amount, reservePub } = args;
+ const paytoUri = args.exchangeInfo.keys.accounts[0].payto_uri;
+ const pt = parsePaytoUri(paytoUri);
+ if (!pt) {
+ throw Error("failed to parse payto URI");
+ }
+ const components = pt.targetPath.split("/");
+ const creditorAcct = components[components.length - 1];
+ const fbReq = await http.fetch(
+ new URL(
+ `accounts/${creditorAcct}/taler-wire-gateway/admin/add-incoming`,
+ corebankApiBaseUrl,
+ ).href,
+ {
+ method: "POST",
+ body: {
+ amount,
+ reserve_pub: reservePub,
+ debit_account: "payto://x-taler-bank/localhost/testdebtor",
+ },
+ },
+ );
+ await readSuccessResponseJsonOrThrow(fbReq, codecForAny());
+}
diff --git a/packages/taler-wallet-core/src/denomSelection.ts b/packages/taler-wallet-core/src/denomSelection.ts
new file mode 100644
index 000000000..ecc1fa881
--- /dev/null
+++ b/packages/taler-wallet-core/src/denomSelection.ts
@@ -0,0 +1,199 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Selection of denominations for withdrawals.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ DenomSelectionState,
+ ForcedDenomSel,
+ Logger,
+} from "@gnu-taler/taler-util";
+import { DenominationRecord, timestampAbsoluteFromDb } from "./db.js";
+import { isWithdrawableDenom } from "./denominations.js";
+
+const logger = new Logger("denomSelection.ts");
+
+/**
+ * Get a list of denominations (with repetitions possible)
+ * whose total value is as close as possible to the available
+ * amount, but never larger.
+ */
+export function selectWithdrawalDenominations(
+ amountAvailable: AmountJson,
+ denoms: DenominationRecord[],
+ denomselAllowLate: boolean = false,
+): DenomSelectionState {
+ let remaining = Amounts.copy(amountAvailable);
+
+ const selectedDenoms: {
+ count: number;
+ denomPubHash: string;
+ }[] = [];
+
+ let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let earliestDepositExpiration: AbsoluteTime | undefined;
+ let hasDenomWithAgeRestriction = false;
+
+ denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate));
+ denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `selecting withdrawal denoms for ${Amounts.stringify(amountAvailable)}`,
+ );
+ }
+
+ for (const d of denoms) {
+ const cost = Amounts.add(d.value, d.fees.feeWithdraw).amount;
+ const res = Amounts.divmod(remaining, cost);
+ const count = res.quotient;
+ remaining = Amounts.sub(remaining, Amounts.mult(cost, count).amount).amount;
+ if (count > 0) {
+ totalCoinValue = Amounts.add(
+ totalCoinValue,
+ Amounts.mult(d.value, count).amount,
+ ).amount;
+ totalWithdrawCost = Amounts.add(
+ totalWithdrawCost,
+ Amounts.mult(cost, count).amount,
+ ).amount;
+ selectedDenoms.push({
+ count,
+ denomPubHash: d.denomPubHash,
+ });
+ hasDenomWithAgeRestriction =
+ hasDenomWithAgeRestriction || d.denomPub.age_mask > 0;
+ const expireDeposit = timestampAbsoluteFromDb(d.stampExpireDeposit);
+ if (!earliestDepositExpiration) {
+ earliestDepositExpiration = expireDeposit;
+ } else {
+ earliestDepositExpiration = AbsoluteTime.min(
+ expireDeposit,
+ earliestDepositExpiration,
+ );
+ }
+ }
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(
+ `denom_pub_hash=${
+ d.denomPubHash
+ }, count=${count}, val=${Amounts.stringify(
+ d.value,
+ )}, wdfee=${Amounts.stringify(d.fees.feeWithdraw)}`,
+ );
+ }
+
+ if (Amounts.isZero(remaining)) {
+ break;
+ }
+ }
+
+ if (logger.shouldLogTrace()) {
+ logger.trace("(end of denom selection)");
+ }
+
+ earliestDepositExpiration ??= AbsoluteTime.never();
+
+ return {
+ selectedDenoms,
+ totalCoinValue: Amounts.stringify(totalCoinValue),
+ totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
+ earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
+ earliestDepositExpiration,
+ ),
+ hasDenomWithAgeRestriction,
+ };
+}
+
+export function selectForcedWithdrawalDenominations(
+ amountAvailable: AmountJson,
+ denoms: DenominationRecord[],
+ forcedDenomSel: ForcedDenomSel,
+ denomselAllowLate: boolean,
+): DenomSelectionState {
+ const selectedDenoms: {
+ count: number;
+ denomPubHash: string;
+ }[] = [];
+
+ let totalCoinValue = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let totalWithdrawCost = Amounts.zeroOfCurrency(amountAvailable.currency);
+ let earliestDepositExpiration: AbsoluteTime | undefined;
+ let hasDenomWithAgeRestriction = false;
+
+ denoms = denoms.filter((d) => isWithdrawableDenom(d, denomselAllowLate));
+ denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
+
+ for (const fds of forcedDenomSel.denoms) {
+ const count = fds.count;
+ const denom = denoms.find((x) => {
+ return Amounts.cmp(x.value, fds.value) == 0;
+ });
+ if (!denom) {
+ throw Error(
+ `unable to find denom for forced selection (value ${fds.value})`,
+ );
+ }
+ const cost = Amounts.add(denom.value, denom.fees.feeWithdraw).amount;
+ totalCoinValue = Amounts.add(
+ totalCoinValue,
+ Amounts.mult(denom.value, count).amount,
+ ).amount;
+ totalWithdrawCost = Amounts.add(
+ totalWithdrawCost,
+ Amounts.mult(cost, count).amount,
+ ).amount;
+ selectedDenoms.push({
+ count,
+ denomPubHash: denom.denomPubHash,
+ });
+ hasDenomWithAgeRestriction =
+ hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
+ const expireDeposit = timestampAbsoluteFromDb(denom.stampExpireDeposit);
+ if (!earliestDepositExpiration) {
+ earliestDepositExpiration = expireDeposit;
+ } else {
+ earliestDepositExpiration = AbsoluteTime.min(
+ expireDeposit,
+ earliestDepositExpiration,
+ );
+ }
+ }
+
+ earliestDepositExpiration ??= AbsoluteTime.never();
+
+ return {
+ selectedDenoms,
+ totalCoinValue: Amounts.stringify(totalCoinValue),
+ totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
+ earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
+ earliestDepositExpiration,
+ ),
+ hasDenomWithAgeRestriction,
+ };
+}
diff --git a/packages/taler-wallet-core/src/denominations.test.ts b/packages/taler-wallet-core/src/denominations.test.ts
new file mode 100644
index 000000000..98af5d1a4
--- /dev/null
+++ b/packages/taler-wallet-core/src/denominations.test.ts
@@ -0,0 +1,870 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AbsoluteTime,
+ FeeDescription,
+ FeeDescriptionPair,
+ Amounts,
+ DenominationInfo,
+ AmountString,
+} from "@gnu-taler/taler-util";
+// import { expect } from "chai";
+import {
+ createPairTimeline,
+ createTimeline,
+ selectBestForOverlappingDenominations,
+} from "./denominations.js";
+import test, { ExecutionContext } from "ava";
+
+/**
+ * Create some constants to be used as reference in the tests
+ */
+const VALUES: AmountString[] = Array.from({ length: 10 }).map(
+ (undef, t) => `USD:${t}` as AmountString,
+);
+const TIMESTAMPS = Array.from({ length: 20 }).map((undef, t_s) => ({ t_s }));
+const ABS_TIME = TIMESTAMPS.map((m) => AbsoluteTime.fromProtocolTimestamp(m));
+
+function normalize(
+ list: DenominationInfo[],
+): (DenominationInfo & { group: string })[] {
+ return list.map((e, idx) => ({
+ ...e,
+ denomPubHash: `id${idx}`,
+ group: Amounts.stringifyValue(e.value),
+ }));
+}
+
+//Avoiding to make an error-prone/time-consuming refactor
+//this function calls AVA's deepEqual from a chai interface
+function expect(t: ExecutionContext, thing: any): any {
+ return {
+ deep: {
+ equal: (another: any) => t.deepEqual(thing, another),
+ equals: (another: any) => t.deepEqual(thing, another),
+ },
+ };
+}
+
+// describe("Denomination timeline creation", (t) => {
+// describe("single value example", (t) => {
+
+test("should have one row with start and exp", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[2],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[1],
+ } as FeeDescription,
+ ]);
+});
+
+test("should have two rows with the second denom in the middle if second is better", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[3],
+ until: ABS_TIME[4],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should have two rows with the first denom in the middle if second is worse", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should add a gap when there no fee", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[2],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[3],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[3],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[3],
+ until: ABS_TIME[4],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should have three rows when first denom is between second and second is worse", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[3],
+ until: ABS_TIME[4],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should have one row when first denom is between second and second is better", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[4],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should only add the best1", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should only add the best2", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[5],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[5],
+ stampExpireDeposit: TIMESTAMPS[6],
+ feeDeposit: VALUES[3],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[5],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[5],
+ until: ABS_TIME[6],
+ fee: VALUES[3],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should only add the best3", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[5],
+ feeDeposit: VALUES[3],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[5],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[5],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[5],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[]);
+});
+// })
+
+// describe("multiple value example", (t) => {
+
+//TODO: test the same start but different value
+
+test("should not merge when there is different value", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[2],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[2]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[]);
+});
+
+test("should not merge when there is different value (with duplicates)", (t) => {
+ const timeline = createTimeline(
+ normalize([
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[2],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[1],
+ stampStart: TIMESTAMPS[1],
+ stampExpireDeposit: TIMESTAMPS[3],
+ feeDeposit: VALUES[1],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ {
+ value: VALUES[2],
+ stampStart: TIMESTAMPS[2],
+ stampExpireDeposit: TIMESTAMPS[4],
+ feeDeposit: VALUES[2],
+ } as Partial<DenominationInfo> as DenominationInfo,
+ ]),
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ );
+
+ expect(t, timeline).deep.equal([
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[2]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[]);
+});
+
+// it.skip("real world example: bitcoin exchange", (t) => {
+// const timeline = createDenominationTimeline(
+// bitcoinExchanges[0].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))),
+// "stampExpireDeposit", "feeDeposit");
+
+// expect(t,timeline).deep.equal([{
+// fee: Amounts.parseOrThrow('BITCOINBTC:0.00000001'),
+// from: { t_ms: 1652978648000 },
+// until: { t_ms: 1699633748000 },
+// value: Amounts.parseOrThrow('BITCOINBTC:0.01048576'),
+// }, {
+// fee: Amounts.parseOrThrow('BITCOINBTC:0.00000003'),
+// from: { t_ms: 1699633748000 },
+// until: { t_ms: 1707409448000 },
+// value: Amounts.parseOrThrow('BITCOINBTC:0.01048576'),
+// }] as FeeDescription[])
+// })
+
+// })
+
+// })
+
+// describe("Denomination timeline pair creation", (t) => {
+
+// describe("single value example", (t) => {
+
+test("should return empty", (t) => {
+ const left = [] as FeeDescription[];
+ const right = [] as FeeDescription[];
+
+ const pairs = createPairTimeline(left, right);
+
+ expect(t, pairs).deep.equals([]);
+});
+
+test("should return first element", (t) => {
+ const left = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[];
+
+ const right = [] as FeeDescription[];
+
+ {
+ const pairs = createPairTimeline(left, right);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: undefined,
+ },
+ ] as FeeDescriptionPair[]);
+ }
+ {
+ const pairs = createPairTimeline(right, left);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ right: VALUES[1],
+ left: undefined,
+ },
+ ] as FeeDescriptionPair[]);
+ }
+});
+
+test("should add both to the same row", (t) => {
+ const left = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[];
+
+ const right = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[];
+
+ {
+ const pairs = createPairTimeline(left, right);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: VALUES[2],
+ },
+ ] as FeeDescriptionPair[]);
+ }
+ {
+ const pairs = createPairTimeline(right, left);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[2],
+ right: VALUES[1],
+ },
+ ] as FeeDescriptionPair[]);
+ }
+});
+
+test("should repeat the first and change the second", (t) => {
+ const left = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[5],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[];
+
+ const right = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[2],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[3],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[3],
+ until: ABS_TIME[4],
+ fee: VALUES[3],
+ },
+ ] as FeeDescription[];
+
+ {
+ const pairs = createPairTimeline(left, right);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: VALUES[2],
+ },
+ {
+ from: ABS_TIME[2],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: undefined,
+ },
+ {
+ from: ABS_TIME[3],
+ until: ABS_TIME[4],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: VALUES[3],
+ },
+ {
+ from: ABS_TIME[4],
+ until: ABS_TIME[5],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: undefined,
+ },
+ ] as FeeDescriptionPair[]);
+ }
+});
+
+// })
+
+// describe("multiple value example", (t) => {
+
+test("should separate denominations of different value", (t) => {
+ const left = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[1],
+ },
+ ] as FeeDescription[];
+
+ const right = [
+ {
+ group: Amounts.stringifyValue(VALUES[2]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[];
+
+ {
+ const pairs = createPairTimeline(left, right);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: undefined,
+ },
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[2]),
+ left: undefined,
+ right: VALUES[2],
+ },
+ ] as FeeDescriptionPair[]);
+ }
+ {
+ const pairs = createPairTimeline(right, left);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: undefined,
+ right: VALUES[1],
+ },
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[2]),
+ left: VALUES[2],
+ right: undefined,
+ },
+ ] as FeeDescriptionPair[]);
+ }
+});
+
+test("should separate denominations of different value2", (t) => {
+ const left = [
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ fee: VALUES[1],
+ },
+ {
+ group: Amounts.stringifyValue(VALUES[1]),
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[];
+
+ const right = [
+ {
+ group: Amounts.stringifyValue(VALUES[2]),
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ fee: VALUES[2],
+ },
+ ] as FeeDescription[];
+
+ {
+ const pairs = createPairTimeline(left, right);
+ expect(t, pairs).deep.equals([
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[2],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[1],
+ right: undefined,
+ },
+ {
+ from: ABS_TIME[2],
+ until: ABS_TIME[4],
+ group: Amounts.stringifyValue(VALUES[1]),
+ left: VALUES[2],
+ right: undefined,
+ },
+ {
+ from: ABS_TIME[1],
+ until: ABS_TIME[3],
+ group: Amounts.stringifyValue(VALUES[2]),
+ left: undefined,
+ right: VALUES[2],
+ },
+ ] as FeeDescriptionPair[]);
+ }
+ // {
+ // const pairs = createDenominationPairTimeline(right, left)
+ // expect(t,pairs).deep.equals([{
+ // from: moments[1],
+ // until: moments[3],
+ // value: values[1],
+ // left: undefined,
+ // right: values[1],
+ // }, {
+ // from: moments[1],
+ // until: moments[3],
+ // value: values[2],
+ // left: values[2],
+ // right: undefined,
+ // }] as FeeDescriptionPair[])
+ // }
+});
+// it.skip("should render real world", (t) => {
+// const left = createDenominationTimeline(
+// bitcoinExchanges[0].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))),
+// "stampExpireDeposit", "feeDeposit");
+// const right = createDenominationTimeline(
+// bitcoinExchanges[1].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))),
+// "stampExpireDeposit", "feeDeposit");
+
+// const pairs = createDenominationPairTimeline(left, right)
+// })
+
+// })
+// })
diff --git a/packages/taler-wallet-core/src/denominations.ts b/packages/taler-wallet-core/src/denominations.ts
new file mode 100644
index 000000000..d41307d5d
--- /dev/null
+++ b/packages/taler-wallet-core/src/denominations.ts
@@ -0,0 +1,479 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ AmountString,
+ DenominationInfo,
+ Duration,
+ FeeDescription,
+ FeeDescriptionPair,
+ TalerProtocolTimestamp,
+ TimePoint,
+} from "@gnu-taler/taler-util";
+import { DenominationRecord, timestampProtocolFromDb } from "./db.js";
+
+/**
+ * Given a list of denominations with the same value and same period of time:
+ * return the one that will be used.
+ * The best denomination is the one that will minimize the fee cost.
+ *
+ * @param list denominations of same value
+ * @returns
+ */
+export function selectBestForOverlappingDenominations<
+ T extends DenominationInfo,
+>(list: T[]): T | undefined {
+ let minDeposit: DenominationInfo | undefined = undefined;
+ //TODO: improve denomination selection, this is a trivial implementation
+ list.forEach((e) => {
+ if (minDeposit === undefined) {
+ minDeposit = e;
+ return;
+ }
+ if (Amounts.cmp(minDeposit.feeDeposit, e.feeDeposit) > -1) {
+ minDeposit = e;
+ }
+ });
+ return minDeposit;
+}
+
+export function selectMinimumFee<T extends { fee: AmountString }>(
+ list: T[],
+): T | undefined {
+ let minFee: T | undefined = undefined;
+ //TODO: improve denomination selection, this is a trivial implementation
+ list.forEach((e) => {
+ if (minFee === undefined) {
+ minFee = e;
+ return;
+ }
+ if (Amounts.cmp(minFee.fee, e.fee) > -1) {
+ minFee = e;
+ }
+ });
+ return minFee;
+}
+
+type PropsWithReturnType<T extends object, F> = Exclude<
+ {
+ [K in keyof T]: T[K] extends F ? K : never;
+ }[keyof T],
+ undefined
+>;
+
+/**
+ * Takes two timelines and create one to compare them.
+ *
+ * For both lists the next condition should be true:
+ * for any element in the position "idx" then
+ * list[idx].until === list[idx+1].from
+ *
+ * @see {createTimeline}
+ *
+ * @param left list denominations @type {FeeDescription}
+ * @param right list denominations @type {FeeDescription}
+ * @returns list of pairs for the same time
+ */
+export function createPairTimeline(
+ left: FeeDescription[],
+ right: FeeDescription[],
+): FeeDescriptionPair[] {
+ //FIXME: we need to create a copy of the array because
+ //this algorithm is using splice, remove splice and
+ //remove this array duplication
+ left = [...left];
+ right = [...right];
+
+ //both list empty, discarded
+ if (left.length === 0 && right.length === 0) return [];
+
+ const pairList: FeeDescriptionPair[] = [];
+
+ let li = 0; //left list index
+ let ri = 0; //right list index
+
+ while (li < left.length && ri < right.length) {
+ const currentGroup =
+ Number.parseFloat(left[li].group) < Number.parseFloat(right[ri].group)
+ ? left[li].group
+ : right[ri].group;
+ const lgs = li; //left group start index
+ const rgs = ri; //right group start index
+
+ let lgl = 0; //left group length (until next value)
+ while (li + lgl < left.length && left[li + lgl].group === currentGroup) {
+ lgl++;
+ }
+ let rgl = 0; //right group length (until next value)
+ while (ri + rgl < right.length && right[ri + rgl].group === currentGroup) {
+ rgl++;
+ }
+ const leftGroupIsEmpty = lgl === 0;
+ const rightGroupIsEmpty = rgl === 0;
+ //check which start after, add gap so both list starts at the same time
+ // one list may be empty
+ const leftStartTime: AbsoluteTime = leftGroupIsEmpty
+ ? AbsoluteTime.never()
+ : left[li].from;
+ const rightStartTime: AbsoluteTime = rightGroupIsEmpty
+ ? AbsoluteTime.never()
+ : right[ri].from;
+
+ //first time cut is the smallest time
+ let timeCut: AbsoluteTime = leftStartTime;
+
+ if (AbsoluteTime.cmp(leftStartTime, rightStartTime) < 0) {
+ const ends = rightGroupIsEmpty ? left[li + lgl - 1].until : right[0].from;
+
+ right.splice(ri, 0, {
+ from: leftStartTime,
+ until: ends,
+ group: left[li].group,
+ });
+ rgl++;
+
+ timeCut = leftStartTime;
+ }
+ if (AbsoluteTime.cmp(leftStartTime, rightStartTime) > 0) {
+ const ends = leftGroupIsEmpty ? right[ri + rgl - 1].until : left[0].from;
+
+ left.splice(li, 0, {
+ from: rightStartTime,
+ until: ends,
+ group: right[ri].group,
+ });
+ lgl++;
+
+ timeCut = rightStartTime;
+ }
+
+ //check which ends sooner, add gap so both list ends at the same time
+ // here both list are non empty
+ const leftEndTime: AbsoluteTime = left[li + lgl - 1].until;
+ const rightEndTime: AbsoluteTime = right[ri + rgl - 1].until;
+
+ if (AbsoluteTime.cmp(leftEndTime, rightEndTime) > 0) {
+ right.splice(ri + rgl, 0, {
+ from: rightEndTime,
+ until: leftEndTime,
+ group: left[0].group,
+ });
+ rgl++;
+ }
+ if (AbsoluteTime.cmp(leftEndTime, rightEndTime) < 0) {
+ left.splice(li + lgl, 0, {
+ from: leftEndTime,
+ until: rightEndTime,
+ group: right[0].group,
+ });
+ lgl++;
+ }
+
+ //now both lists are non empty and (starts,ends) at the same time
+ while (li < lgs + lgl && ri < rgs + rgl) {
+ if (
+ AbsoluteTime.cmp(left[li].from, timeCut) !== 0 &&
+ AbsoluteTime.cmp(right[ri].from, timeCut) !== 0
+ ) {
+ // timeCut comes from the latest "until" (expiration from the previous)
+ // and this value comes from the latest left or right
+ // it should be the same as the "from" from one of the latest left or right
+ // otherwise it means that there is missing a gap object in the middle
+ // the list is not complete and the behavior is undefined
+ throw Error(
+ "one of the list is not completed: list[i].until !== list[i+1].from",
+ );
+ }
+
+ pairList.push({
+ left: left[li].fee,
+ right: right[ri].fee,
+ from: timeCut,
+ until: AbsoluteTime.never(),
+ group: currentGroup,
+ });
+
+ if (left[li].until.t_ms === right[ri].until.t_ms) {
+ timeCut = left[li].until;
+ ri++;
+ li++;
+ } else if (left[li].until.t_ms < right[ri].until.t_ms) {
+ timeCut = left[li].until;
+ li++;
+ } else if (left[li].until.t_ms > right[ri].until.t_ms) {
+ timeCut = right[ri].until;
+ ri++;
+ }
+ pairList[pairList.length - 1].until = timeCut;
+
+ // if (
+ // (li < left.length && left[li].group !== currentGroup) ||
+ // (ri < right.length && right[ri].group !== currentGroup)
+ // ) {
+ // //value changed, should break
+ // //this if will catch when both (left and right) change at the same time
+ // //if just one side changed it will catch in the while condition
+ // break;
+ // }
+ }
+ }
+ //one of the list left or right can still have elements
+ if (li < left.length) {
+ let timeCut =
+ pairList.length > 0 &&
+ pairList[pairList.length - 1].group === left[li].group
+ ? pairList[pairList.length - 1].until
+ : left[li].from;
+ while (li < left.length) {
+ pairList.push({
+ left: left[li].fee,
+ right: undefined,
+ from: timeCut,
+ until: left[li].until,
+ group: left[li].group,
+ });
+ timeCut = left[li].until;
+ li++;
+ }
+ }
+ if (ri < right.length) {
+ let timeCut =
+ pairList.length > 0 &&
+ pairList[pairList.length - 1].group === right[ri].group
+ ? pairList[pairList.length - 1].until
+ : right[ri].from;
+ while (ri < right.length) {
+ pairList.push({
+ right: right[ri].fee,
+ left: undefined,
+ from: timeCut,
+ until: right[ri].until,
+ group: right[ri].group,
+ });
+ timeCut = right[ri].until;
+ ri++;
+ }
+ }
+ return pairList;
+}
+
+/**
+ * Create a usage timeline with the entity given.
+ *
+ * If there are multiple entities that can be used in the same period,
+ * the list will contain the one that minimize the fee cost.
+ * @see selectBestForOverlappingDenominations
+ *
+ * @param list list of entities
+ * @param idProp property used for identification
+ * @param periodStartProp property of element of the list that will be used as start of the usage period
+ * @param periodEndProp property of element of the list that will be used as end of the usage period
+ * @param feeProp property of the element of the list that will be used as fee reference
+ * @param groupProp property of the element of the list that will be used for grouping
+ * @returns list of @type {FeeDescription} sorted by usage period
+ */
+export function createTimeline<Type extends object>(
+ list: Type[],
+ idProp: PropsWithReturnType<Type, string>,
+ periodStartProp: PropsWithReturnType<Type, TalerProtocolTimestamp>,
+ periodEndProp: PropsWithReturnType<Type, TalerProtocolTimestamp>,
+ feeProp: PropsWithReturnType<Type, AmountString>,
+ groupProp: PropsWithReturnType<Type, string> | undefined,
+ selectBestForOverlapping: (l: Type[]) => Type | undefined,
+): FeeDescription[] {
+ /**
+ * First we create a list with with point in the timeline sorted
+ * by time and categorized by starting or ending.
+ */
+ const sortedPointsInTime = list
+ .reduce((ps, denom) => {
+ //exclude denoms with bad configuration
+ const id = denom[idProp] as string;
+ const stampStart = denom[periodStartProp] as TalerProtocolTimestamp;
+ const stampEnd = denom[periodEndProp] as TalerProtocolTimestamp;
+ const fee = denom[feeProp] as AmountJson;
+ const group = !groupProp ? "" : (denom[groupProp] as string);
+
+ if (!id) {
+ throw Error(
+ `denomination without hash ${JSON.stringify(denom, undefined, 2)}`,
+ );
+ }
+ if (stampStart.t_s >= stampEnd.t_s) {
+ throw Error(`denom ${id} has start after the end`);
+ }
+ ps.push({
+ type: "start",
+ fee: Amounts.stringify(fee),
+ group,
+ id,
+ moment: AbsoluteTime.fromProtocolTimestamp(stampStart),
+ denom,
+ });
+ ps.push({
+ type: "end",
+ fee: Amounts.stringify(fee),
+ group,
+ id,
+ moment: AbsoluteTime.fromProtocolTimestamp(stampEnd),
+ denom,
+ });
+ return ps;
+ }, [] as TimePoint<Type>[])
+ .sort((a, b) => {
+ const v = a.group == b.group ? 0 : a.group > b.group ? 1 : -1;
+ if (v != 0) return v;
+ const t = AbsoluteTime.cmp(a.moment, b.moment);
+ if (t != 0) return t;
+ if (a.type === b.type) return 0;
+ return a.type === "start" ? 1 : -1;
+ });
+
+ const activeAtTheSameTime: Type[] = [];
+ return sortedPointsInTime.reduce((result, cursor, idx) => {
+ /**
+ * Now that we have move one step forward, we should
+ * update the previous element ending period with the
+ * current start time.
+ */
+ let prev = result.length > 0 ? result[result.length - 1] : undefined;
+ const prevHasSameValue = prev && prev.group == cursor.group;
+ if (prev) {
+ if (prevHasSameValue) {
+ prev.until = cursor.moment;
+
+ if (prev.from.t_ms === prev.until.t_ms) {
+ result.pop();
+ prev = result[result.length - 1];
+ }
+ } else {
+ // the last end adds a gap that we have to remove
+ result.pop();
+ }
+ }
+
+ /**
+ * With the current moment in the iteration we
+ * should keep updated which entities are current
+ * active in this period of time.
+ */
+ if (cursor.type === "end") {
+ const loc = activeAtTheSameTime.findIndex((v) => v[idProp] === cursor.id);
+ if (loc === -1) {
+ throw Error(`denomination ${cursor.id} has an end but no start`);
+ }
+ activeAtTheSameTime.splice(loc, 1);
+ } else if (cursor.type === "start") {
+ activeAtTheSameTime.push(cursor.denom);
+ } else {
+ const exhaustiveCheck: never = cursor.type;
+ throw new Error(`not TimePoint defined for type: ${exhaustiveCheck}`);
+ }
+
+ if (idx == sortedPointsInTime.length - 1) {
+ /**
+ * This is the last element in the list, if we continue
+ * a gap will normally be added which is not necessary.
+ * Also, the last element should be ending and the list of active
+ * element should be empty
+ */
+ if (cursor.type !== "end") {
+ throw Error(
+ `denomination ${cursor.id} starts after ending or doesn't have an ending`,
+ );
+ }
+ if (activeAtTheSameTime.length > 0) {
+ throw Error(
+ `there are ${activeAtTheSameTime.length} denominations without ending`,
+ );
+ }
+ return result;
+ }
+
+ const current = selectBestForOverlapping(activeAtTheSameTime);
+
+ if (current) {
+ /**
+ * We have a candidate to add in the list, check that we are
+ * not adding a duplicate.
+ * Next element in the list will defined the ending.
+ */
+ const currentFee = current[feeProp] as AmountJson;
+ if (
+ prev === undefined || //is the first
+ !prev.fee || //is a gap
+ Amounts.cmp(prev.fee, currentFee) !== 0 // prev has different fee
+ ) {
+ result.push({
+ group: cursor.group,
+ from: cursor.moment,
+ until: AbsoluteTime.never(), //not yet known
+ fee: Amounts.stringify(currentFee),
+ });
+ } else {
+ prev.until = cursor.moment;
+ }
+ } else {
+ /**
+ * No active element in this period of time, so we add a gap (no fee)
+ * Next element in the list will defined the ending.
+ */
+ result.push({
+ group: cursor.group,
+ from: cursor.moment,
+ until: AbsoluteTime.never(), //not yet known
+ });
+ }
+
+ return result;
+ }, [] as FeeDescription[]);
+}
+
+/**
+ * Check if a denom is withdrawable based on the expiration time,
+ * revocation and offered state.
+ */
+export function isWithdrawableDenom(
+ d: DenominationRecord,
+ denomselAllowLate?: boolean,
+): boolean {
+ const now = AbsoluteTime.now();
+ const start = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampStart),
+ );
+ const withdrawExpire = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireWithdraw),
+ );
+ const started = AbsoluteTime.cmp(now, start) >= 0;
+ let lastPossibleWithdraw: AbsoluteTime;
+ if (denomselAllowLate) {
+ lastPossibleWithdraw = start;
+ } else {
+ lastPossibleWithdraw = AbsoluteTime.subtractDuraction(
+ withdrawExpire,
+ Duration.fromSpec({ minutes: 5 }),
+ );
+ }
+ const remaining = Duration.getRemaining(lastPossibleWithdraw, now);
+ const stillOkay = remaining.d_ms !== 0;
+ return started && stillOkay && !d.isRevoked && d.isOffered && !d.isLost;
+}
diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts
new file mode 100644
index 000000000..50f26ea9c
--- /dev/null
+++ b/packages/taler-wallet-core/src/deposits.ts
@@ -0,0 +1,1755 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of the deposit transaction.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ BatchDepositRequestCoin,
+ CancellationToken,
+ CoinRefreshRequest,
+ CreateDepositGroupRequest,
+ CreateDepositGroupResponse,
+ DepositGroupFees,
+ Duration,
+ ExchangeBatchDepositRequest,
+ ExchangeHandle,
+ ExchangeRefundRequest,
+ HttpStatusCode,
+ Logger,
+ MerchantContractTerms,
+ NotificationType,
+ PrepareDepositRequest,
+ PrepareDepositResponse,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TrackTransaction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WireFee,
+ assertUnreachable,
+ canonicalJson,
+ checkDbInvariant,
+ checkLogicInvariant,
+ codecForBatchDepositSuccess,
+ codecForTackTransactionAccepted,
+ codecForTackTransactionWired,
+ encodeCrock,
+ getRandomBytes,
+ hashTruncate32,
+ hashWire,
+ j2s,
+ parsePaytoUri,
+ stringToBytes,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import { selectPayCoins } from "./coinSelection.js";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import {
+ DepositElementStatus,
+ DepositGroupRecord,
+ DepositInfoPerExchange,
+ DepositOperationStatus,
+ DepositTrackingInfo,
+ KycPendingInfo,
+ RefreshOperationStatus,
+ timestampPreciseToDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import { getExchangeWireDetailsInTx } from "./exchanges.js";
+import {
+ extractContractData,
+ generateDepositPermissions,
+ getTotalPaymentCost,
+} from "./pay-merchant.js";
+import {
+ CreateRefreshGroupResult,
+ createRefreshGroup,
+ getTotalRefreshCost,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("deposits.ts");
+
+export class DepositTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public depositGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const depositGroupId = this.depositGroupId;
+ const ws = this.wex;
+ // FIXME: We should check first if we are in a final state
+ // where deletion is allowed.
+ await ws.db.runReadWriteTx(["depositGroups", "tombstones"], async (tx) => {
+ const tipRecord = await tx.depositGroups.get(depositGroupId);
+ if (tipRecord) {
+ await tx.depositGroups.delete(depositGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
+ });
+ }
+ });
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ let newOpStatus: DepositOperationStatus | undefined;
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.PendingDeposit:
+ newOpStatus = DepositOperationStatus.SuspendedDeposit;
+ break;
+ case DepositOperationStatus.PendingKyc:
+ newOpStatus = DepositOperationStatus.SuspendedKyc;
+ break;
+ case DepositOperationStatus.PendingTrack:
+ newOpStatus = DepositOperationStatus.SuspendedTrack;
+ break;
+ case DepositOperationStatus.Aborting:
+ newOpStatus = DepositOperationStatus.SuspendedAborting;
+ break;
+ }
+ if (!newOpStatus) {
+ return undefined;
+ }
+ dg.operationStatus = newOpStatus;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.Finished:
+ return undefined;
+ case DepositOperationStatus.PendingDeposit:
+ case DepositOperationStatus.SuspendedDeposit: {
+ dg.operationStatus = DepositOperationStatus.Aborting;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ }
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't resume deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ let newOpStatus: DepositOperationStatus | undefined;
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.SuspendedDeposit:
+ newOpStatus = DepositOperationStatus.PendingDeposit;
+ break;
+ case DepositOperationStatus.SuspendedAborting:
+ newOpStatus = DepositOperationStatus.Aborting;
+ break;
+ case DepositOperationStatus.SuspendedKyc:
+ newOpStatus = DepositOperationStatus.PendingKyc;
+ break;
+ case DepositOperationStatus.SuspendedTrack:
+ newOpStatus = DepositOperationStatus.PendingTrack;
+ break;
+ }
+ if (!newOpStatus) {
+ return undefined;
+ }
+ dg.operationStatus = newOpStatus;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, depositGroupId, transactionId, taskId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ logger.warn(
+ `can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`,
+ );
+ return undefined;
+ }
+ const oldState = computeDepositTransactionStatus(dg);
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.SuspendedAborting:
+ case DepositOperationStatus.Aborting: {
+ dg.operationStatus = DepositOperationStatus.Failed;
+ await tx.depositGroups.put(dg);
+ return {
+ oldTxState: oldState,
+ newTxState: computeDepositTransactionStatus(dg),
+ };
+ }
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(taskId);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+}
+
+/**
+ * Get the (DD37-style) transaction status based on the
+ * database record of a deposit group.
+ */
+export function computeDepositTransactionStatus(
+ dg: DepositGroupRecord,
+): TransactionState {
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.Finished:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case DepositOperationStatus.PendingDeposit:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Deposit,
+ };
+ case DepositOperationStatus.PendingKyc:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case DepositOperationStatus.PendingTrack:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Track,
+ };
+ case DepositOperationStatus.SuspendedKyc:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case DepositOperationStatus.SuspendedTrack:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Track,
+ };
+ case DepositOperationStatus.SuspendedDeposit:
+ return {
+ major: TransactionMajorState.Suspended,
+ };
+ case DepositOperationStatus.Aborting:
+ return {
+ major: TransactionMajorState.Aborting,
+ };
+ case DepositOperationStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case DepositOperationStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case DepositOperationStatus.SuspendedAborting:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ };
+ default:
+ assertUnreachable(dg.operationStatus);
+ }
+}
+
+/**
+ * Compute the possible actions possible on a deposit transaction
+ * based on the current transaction state.
+ */
+export function computeDepositTransactionActions(
+ dg: DepositGroupRecord,
+): TransactionAction[] {
+ switch (dg.operationStatus) {
+ case DepositOperationStatus.Finished:
+ return [TransactionAction.Delete];
+ case DepositOperationStatus.PendingDeposit:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case DepositOperationStatus.SuspendedDeposit:
+ return [TransactionAction.Resume];
+ case DepositOperationStatus.Aborting:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case DepositOperationStatus.Aborted:
+ return [TransactionAction.Delete];
+ case DepositOperationStatus.Failed:
+ return [TransactionAction.Delete];
+ case DepositOperationStatus.SuspendedAborting:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case DepositOperationStatus.PendingKyc:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case DepositOperationStatus.PendingTrack:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case DepositOperationStatus.SuspendedKyc:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case DepositOperationStatus.SuspendedTrack:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ default:
+ assertUnreachable(dg.operationStatus);
+ }
+}
+
+async function refundDepositGroup(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const statusPerCoin = depositGroup.statusPerCoin;
+ const payCoinSelection = depositGroup.payCoinSelection;
+ if (!statusPerCoin) {
+ throw Error(
+ "unable to refund deposit group without coin selection (status missing)",
+ );
+ }
+ if (!payCoinSelection) {
+ throw Error(
+ "unable to refund deposit group without coin selection (selection missing)",
+ );
+ }
+ const newTxPerCoin = [...statusPerCoin];
+ logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`);
+ for (let i = 0; i < statusPerCoin.length; i++) {
+ const st = statusPerCoin[i];
+ switch (st) {
+ case DepositElementStatus.RefundFailed:
+ case DepositElementStatus.RefundSuccess:
+ break;
+ default: {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coinExchange = await wex.db.runReadOnlyTx(
+ ["coins"],
+ async (tx) => {
+ const coinRecord = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coinRecord);
+ return coinRecord.exchangeBaseUrl;
+ },
+ );
+ const refundAmount = payCoinSelection.coinContributions[i];
+ // We use a constant refund transaction ID, since there can
+ // only be one refund.
+ const rtid = 1;
+ const sig = await wex.cryptoApi.signRefund({
+ coinPub,
+ contractTermsHash: depositGroup.contractTermsHash,
+ merchantPriv: depositGroup.merchantPriv,
+ merchantPub: depositGroup.merchantPub,
+ refundAmount: refundAmount,
+ rtransactionId: rtid,
+ });
+ const refundReq: ExchangeRefundRequest = {
+ h_contract_terms: depositGroup.contractTermsHash,
+ merchant_pub: depositGroup.merchantPub,
+ merchant_sig: sig.sig,
+ refund_amount: refundAmount,
+ rtransaction_id: rtid,
+ };
+ const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange);
+ const httpResp = await wex.http.fetch(refundUrl.href, {
+ method: "POST",
+ body: refundReq,
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(
+ `coin ${i} refund HTTP status for coin: ${httpResp.status}`,
+ );
+ let newStatus: DepositElementStatus;
+ if (httpResp.status === 200) {
+ // FIXME: validate response
+ newStatus = DepositElementStatus.RefundSuccess;
+ } else {
+ // FIXME: Store problem somewhere!
+ newStatus = DepositElementStatus.RefundFailed;
+ }
+ // FIXME: Handle case where refund request needs to be tried again
+ newTxPerCoin[i] = newStatus;
+ break;
+ }
+ }
+ }
+ let isDone = true;
+ for (let i = 0; i < newTxPerCoin.length; i++) {
+ if (
+ newTxPerCoin[i] != DepositElementStatus.RefundFailed &&
+ newTxPerCoin[i] != DepositElementStatus.RefundSuccess
+ ) {
+ isDone = false;
+ }
+ }
+
+ const currency = Amounts.currencyOf(depositGroup.totalPayCost);
+
+ const res = await wex.db.runReadWriteTx(
+ [
+ "depositGroups",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ newDg.statusPerCoin = newTxPerCoin;
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < newTxPerCoin.length; i++) {
+ refreshCoins.push({
+ amount: payCoinSelection.coinContributions[i],
+ coinPub: payCoinSelection.coinPubs[i],
+ });
+ }
+ let refreshRes: CreateRefreshGroupResult | undefined = undefined;
+ if (isDone) {
+ refreshRes = await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ refreshCoins,
+ RefreshReason.AbortDeposit,
+ constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: newDg.depositGroupId,
+ }),
+ );
+ newDg.abortRefreshGroupId = refreshRes.refreshGroupId;
+ }
+ await tx.depositGroups.put(newDg);
+ return { refreshRes };
+ },
+ );
+
+ if (res?.refreshRes) {
+ for (const notif of res.refreshRes.notifications) {
+ wex.ws.notify(notif);
+ }
+ }
+
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Check whether the refresh associated with the
+ * aborting deposit group is done.
+ *
+ * If done, mark the deposit transaction as aborted.
+ *
+ * Otherwise continue waiting.
+ *
+ * FIXME: Wait for the refresh group notifications instead of periodically
+ * checking the refresh group status.
+ * FIXME: This is just one transaction, can't we do this in the initial
+ * transaction of processDepositGroup?
+ */
+async function waitForRefreshOnDepositGroup(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: depositGroup.depositGroupId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["depositGroups", "refreshGroups"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: DepositOperationStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into aborted.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = DepositOperationStatus.Aborted;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = DepositOperationStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = DepositOperationStatus.Aborted;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computeDepositTransactionStatus(newDg);
+ newDg.operationStatus = newOpState;
+ const newTxState = computeDepositTransactionStatus(newDg);
+ await tx.depositGroups.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ return TaskRunResult.backoff();
+}
+
+async function processDepositGroupAborting(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ logger.info("processing deposit tx in 'aborting'");
+ const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
+ if (!abortRefreshGroupId) {
+ logger.info("refunding deposit group");
+ return refundDepositGroup(wex, depositGroup);
+ }
+ logger.info("waiting for refresh");
+ return waitForRefreshOnDepositGroup(wex, depositGroup);
+}
+
+async function processDepositGroupPendingKyc(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const { depositGroupId } = depositGroup;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+
+ const kycInfo = depositGroup.kycInfo;
+ const userType = "individual";
+
+ if (!kycInfo) {
+ throw Error("invalid DB state, in pending(kyc), but no kycInfo present");
+ }
+
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ kycInfo.exchangeBaseUrl,
+ );
+ url.searchParams.set("timeout_ms", "10000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const newDg = await tx.depositGroups.get(depositGroupId);
+ if (!newDg) {
+ return;
+ }
+ if (newDg.operationStatus !== DepositOperationStatus.PendingKyc) {
+ return;
+ }
+ const oldTxState = computeDepositTransactionStatus(newDg);
+ newDg.operationStatus = DepositOperationStatus.PendingTrack;
+ const newTxState = computeDepositTransactionStatus(newDg);
+ await tx.depositGroups.put(newDg);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ // FIXME: Do we have to update the URL here?
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Tracking information from the exchange indicated that
+ * KYC is required. We need to check the KYC info
+ * and transition the transaction to the KYC required state.
+ */
+async function transitionToKycRequired(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+ kycInfo: KycPendingInfo,
+ exchangeUrl: string,
+): Promise<TaskRunResult> {
+ const { depositGroupId } = depositGroup;
+ const userType = "individual";
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusReq = await wex.http.fetch(url.href, {
+ method: "GET",
+ });
+ if (kycStatusReq.status === HttpStatusCode.Ok) {
+ logger.warn("kyc requested, but already fulfilled");
+ return TaskRunResult.backoff();
+ } else if (kycStatusReq.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusReq.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return undefined;
+ }
+ if (dg.operationStatus !== DepositOperationStatus.PendingTrack) {
+ return undefined;
+ }
+ const oldTxState = computeDepositTransactionStatus(dg);
+ dg.kycInfo = {
+ exchangeBaseUrl: exchangeUrl,
+ kycUrl: kycStatus.kyc_url,
+ paytoHash: kycInfo.paytoHash,
+ requirementRow: kycInfo.requirementRow,
+ };
+ await tx.depositGroups.put(dg);
+ const newTxState = computeDepositTransactionStatus(dg);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`);
+ }
+}
+
+async function processDepositGroupPendingTrack(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+): Promise<TaskRunResult> {
+ const statusPerCoin = depositGroup.statusPerCoin;
+ const payCoinSelection = depositGroup.payCoinSelection;
+ if (!statusPerCoin) {
+ throw Error(
+ "unable to refund deposit group without coin selection (status missing)",
+ );
+ }
+ if (!payCoinSelection) {
+ throw Error(
+ "unable to refund deposit group without coin selection (selection missing)",
+ );
+ }
+ const { depositGroupId } = depositGroup;
+ for (let i = 0; i < statusPerCoin.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ // FIXME: Make the URL part of the coin selection?
+ const exchangeBaseUrl = await wex.db.runReadWriteTx(
+ ["coins"],
+ async (tx) => {
+ const coinRecord = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coinRecord);
+ return coinRecord.exchangeBaseUrl;
+ },
+ );
+
+ let updatedTxStatus: DepositElementStatus | undefined = undefined;
+ let newWiredCoin:
+ | {
+ id: string;
+ value: DepositTrackingInfo;
+ }
+ | undefined;
+
+ if (statusPerCoin[i] !== DepositElementStatus.Wired) {
+ const track = await trackDeposit(
+ wex,
+ depositGroup,
+ coinPub,
+ exchangeBaseUrl,
+ );
+
+ if (track.type === "accepted") {
+ if (!track.kyc_ok && track.requirement_row !== undefined) {
+ const paytoHash = encodeCrock(
+ hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")),
+ );
+ const { requirement_row: requirementRow } = track;
+ const kycInfo: KycPendingInfo = {
+ paytoHash,
+ requirementRow,
+ };
+ return transitionToKycRequired(
+ wex,
+ depositGroup,
+ kycInfo,
+ exchangeBaseUrl,
+ );
+ } else {
+ updatedTxStatus = DepositElementStatus.Tracking;
+ }
+ } else if (track.type === "wired") {
+ updatedTxStatus = DepositElementStatus.Wired;
+
+ const payto = parsePaytoUri(depositGroup.wire.payto_uri);
+ if (!payto) {
+ throw Error(`unparsable payto: ${depositGroup.wire.payto_uri}`);
+ }
+
+ const fee = await getExchangeWireFee(
+ wex,
+ payto.targetType,
+ exchangeBaseUrl,
+ track.execution_time,
+ );
+ const raw = Amounts.parseOrThrow(track.coin_contribution);
+ const wireFee = Amounts.parseOrThrow(fee.wireFee);
+
+ newWiredCoin = {
+ value: {
+ amountRaw: Amounts.stringify(raw),
+ wireFee: Amounts.stringify(wireFee),
+ exchangePub: track.exchange_pub,
+ timestampExecuted: timestampProtocolToDb(track.execution_time),
+ wireTransferId: track.wtid,
+ },
+ id: track.exchange_sig,
+ };
+ } else {
+ updatedTxStatus = DepositElementStatus.DepositPending;
+ }
+ }
+
+ if (updatedTxStatus !== undefined) {
+ await wex.db.runReadWriteTx(["depositGroups"], async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return;
+ }
+ if (!dg.statusPerCoin) {
+ return;
+ }
+ if (updatedTxStatus !== undefined) {
+ dg.statusPerCoin[i] = updatedTxStatus;
+ }
+ if (newWiredCoin) {
+ /**
+ * FIXME: if there is a new wire information from the exchange
+ * it should add up to the previous tracking states.
+ *
+ * This may loose information by overriding prev state.
+ *
+ * And: add checks to integration tests
+ */
+ if (!dg.trackingState) {
+ dg.trackingState = {};
+ }
+
+ dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
+ }
+ await tx.depositGroups.put(dg);
+ });
+ }
+ }
+
+ let allWired = true;
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return undefined;
+ }
+ if (!dg.statusPerCoin) {
+ return undefined;
+ }
+ const oldTxState = computeDepositTransactionStatus(dg);
+ for (let i = 0; i < dg.statusPerCoin.length; i++) {
+ if (dg.statusPerCoin[i] !== DepositElementStatus.Wired) {
+ allWired = false;
+ break;
+ }
+ }
+ if (allWired) {
+ dg.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ dg.operationStatus = DepositOperationStatus.Finished;
+ await tx.depositGroups.put(dg);
+ }
+ const newTxState = computeDepositTransactionStatus(dg);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+ notifyTransition(wex, transactionId, transitionInfo);
+ if (allWired) {
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ return TaskRunResult.finished();
+ } else {
+ return TaskRunResult.longpollReturnedPending();
+ }
+}
+
+async function processDepositGroupPendingDeposit(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+ cancellationToken?: CancellationToken,
+): Promise<TaskRunResult> {
+ logger.info("processing deposit group in pending(deposit)");
+ const depositGroupId = depositGroup.depositGroupId;
+ const contractTermsRec = await wex.db.runReadOnlyTx(
+ ["contractTerms"],
+ async (tx) => {
+ return tx.contractTerms.get(depositGroup.contractTermsHash);
+ },
+ );
+ if (!contractTermsRec) {
+ throw Error("contract terms for deposit not found in database");
+ }
+ const contractTerms: MerchantContractTerms =
+ contractTermsRec.contractTermsRaw;
+ const contractData = extractContractData(
+ contractTermsRec.contractTermsRaw,
+ depositGroup.contractTermsHash,
+ "",
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId,
+ });
+
+ // Check for cancellation before expensive operations.
+ cancellationToken?.throwIfCancelled();
+
+ if (!depositGroup.payCoinSelection) {
+ logger.info("missing coin selection for deposit group, selecting now");
+ // FIXME: Consider doing the coin selection inside the txn
+ const payCoinSel = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ });
+
+ switch (payCoinSel.type) {
+ case "success":
+ logger.info("coin selection success");
+ break;
+ case "failure":
+ logger.info("coin selection failure");
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ logger.info("coin selection prospective");
+ throw Error("insufficient balance (waiting on pending refresh)");
+ default:
+ assertUnreachable(payCoinSel);
+ }
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ [
+ "depositGroups",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ ],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return false;
+ }
+ if (dg.statusPerCoin) {
+ return false;
+ }
+ dg.payCoinSelection = {
+ coinContributions: payCoinSel.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub),
+ };
+ dg.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ dg.statusPerCoin = payCoinSel.coinSel.coins.map(
+ () => DepositElementStatus.DepositPending,
+ );
+ await tx.depositGroups.put(dg);
+ await spendCoins(wex, tx, {
+ allocationId: transactionId,
+ coinPubs: dg.payCoinSelection.coinPubs,
+ contributions: dg.payCoinSelection.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayDeposit,
+ });
+ return true;
+ },
+ );
+
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
+ // FIXME: Cache these!
+ const depositPermissions = await generateDepositPermissions(
+ wex,
+ depositGroup.payCoinSelection,
+ contractData,
+ );
+
+ // Exchanges involved in the deposit
+ const exchanges: Set<string> = new Set();
+
+ for (const dp of depositPermissions) {
+ exchanges.add(dp.exchange_url);
+ }
+
+ // We need to do one batch per exchange.
+ for (const exchangeUrl of exchanges.values()) {
+ const coins: BatchDepositRequestCoin[] = [];
+ const batchIndexes: number[] = [];
+
+ const batchReq: ExchangeBatchDepositRequest = {
+ coins,
+ h_contract_terms: depositGroup.contractTermsHash,
+ merchant_payto_uri: depositGroup.wire.payto_uri,
+ merchant_pub: contractTerms.merchant_pub,
+ timestamp: contractTerms.timestamp,
+ wire_salt: depositGroup.wire.salt,
+ wire_transfer_deadline: contractTerms.wire_transfer_deadline,
+ refund_deadline: contractTerms.refund_deadline,
+ };
+
+ for (let i = 0; i < depositPermissions.length; i++) {
+ const perm = depositPermissions[i];
+ if (perm.exchange_url != exchangeUrl) {
+ continue;
+ }
+ coins.push({
+ coin_pub: perm.coin_pub,
+ coin_sig: perm.coin_sig,
+ contribution: Amounts.stringify(perm.contribution),
+ denom_pub_hash: perm.h_denom,
+ ub_sig: perm.ub_sig,
+ h_age_commitment: perm.h_age_commitment,
+ });
+ batchIndexes.push(i);
+ }
+
+ // Check for cancellation before making network request.
+ cancellationToken?.throwIfCancelled();
+ const url = new URL(`batch-deposit`, exchangeUrl);
+ logger.info(`depositing to ${url.href}`);
+ logger.trace(`deposit request: ${j2s(batchReq)}`);
+ const httpResp = await wex.http.fetch(url.href, {
+ method: "POST",
+ body: batchReq,
+ cancellationToken: cancellationToken,
+ });
+ await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForBatchDepositSuccess(),
+ );
+
+ await wex.db.runReadWriteTx(["depositGroups"], async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return;
+ }
+ if (!dg.statusPerCoin) {
+ return;
+ }
+ for (const batchIndex of batchIndexes) {
+ const coinStatus = dg.statusPerCoin[batchIndex];
+ switch (coinStatus) {
+ case DepositElementStatus.DepositPending:
+ dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking;
+ await tx.depositGroups.put(dg);
+ }
+ }
+ });
+ }
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["depositGroups"],
+ async (tx) => {
+ const dg = await tx.depositGroups.get(depositGroupId);
+ if (!dg) {
+ return undefined;
+ }
+ const oldTxState = computeDepositTransactionStatus(dg);
+ dg.operationStatus = DepositOperationStatus.PendingTrack;
+ await tx.depositGroups.put(dg);
+ const newTxState = computeDepositTransactionStatus(dg);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+}
+
+/**
+ * Process a deposit group that is not in its final state yet.
+ */
+export async function processDepositGroup(
+ wex: WalletExecutionContext,
+ depositGroupId: string,
+): Promise<TaskRunResult> {
+ const depositGroup = await wex.db.runReadOnlyTx(
+ ["depositGroups"],
+ async (tx) => {
+ return tx.depositGroups.get(depositGroupId);
+ },
+ );
+ if (!depositGroup) {
+ logger.warn(`deposit group ${depositGroupId} not found`);
+ return TaskRunResult.finished();
+ }
+
+ switch (depositGroup.operationStatus) {
+ case DepositOperationStatus.PendingTrack:
+ return processDepositGroupPendingTrack(wex, depositGroup);
+ case DepositOperationStatus.PendingKyc:
+ return processDepositGroupPendingKyc(wex, depositGroup);
+ case DepositOperationStatus.PendingDeposit:
+ return processDepositGroupPendingDeposit(wex, depositGroup);
+ case DepositOperationStatus.Aborting:
+ return processDepositGroupAborting(wex, depositGroup);
+ }
+
+ return TaskRunResult.finished();
+}
+
+/**
+ * FIXME: Consider moving this to exchanges.ts.
+ */
+async function getExchangeWireFee(
+ wex: WalletExecutionContext,
+ wireType: string,
+ baseUrl: string,
+ time: TalerProtocolTimestamp,
+): Promise<WireFee> {
+ const exchangeDetails = await wex.db.runReadOnlyTx(
+ ["exchangeDetails", "exchanges"],
+ async (tx) => {
+ const ex = await tx.exchanges.get(baseUrl);
+ if (!ex || !ex.detailsPointer) return undefined;
+ return await tx.exchangeDetails.indexes.byPointer.get([
+ baseUrl,
+ ex.detailsPointer.currency,
+ ex.detailsPointer.masterPublicKey,
+ ]);
+ },
+ );
+
+ if (!exchangeDetails) {
+ throw Error(`exchange missing: ${baseUrl}`);
+ }
+
+ const fees = exchangeDetails.wireInfo.feesForType[wireType];
+ if (!fees || fees.length === 0) {
+ throw Error(
+ `exchange ${baseUrl} doesn't have fees for wire type ${wireType}`,
+ );
+ }
+ const fee = fees.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.fromProtocolTimestamp(time),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ });
+ if (!fee) {
+ throw Error(
+ `exchange ${exchangeDetails.exchangeBaseUrl} doesn't have fees for wire type ${wireType} at ${time.t_s}`,
+ );
+ }
+
+ return fee;
+}
+
+async function trackDeposit(
+ wex: WalletExecutionContext,
+ depositGroup: DepositGroupRecord,
+ coinPub: string,
+ exchangeUrl: string,
+): Promise<TrackTransaction> {
+ const wireHash = hashWire(
+ depositGroup.wire.payto_uri,
+ depositGroup.wire.salt,
+ );
+
+ const url = new URL(
+ `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`,
+ exchangeUrl,
+ );
+ const sigResp = await wex.cryptoApi.signTrackTransaction({
+ coinPub,
+ contractTermsHash: depositGroup.contractTermsHash,
+ merchantPriv: depositGroup.merchantPriv,
+ merchantPub: depositGroup.merchantPub,
+ wireHash,
+ });
+ url.searchParams.set("merchant_sig", sigResp.sig);
+ url.searchParams.set("timeout_ms", "30000");
+ const httpResp = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.trace(`deposits response status: ${httpResp.status}`);
+ switch (httpResp.status) {
+ case HttpStatusCode.Accepted: {
+ const accepted = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForTackTransactionAccepted(),
+ );
+ return { type: "accepted", ...accepted };
+ }
+ case HttpStatusCode.Ok: {
+ const wired = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForTackTransactionWired(),
+ );
+ return { type: "wired", ...wired };
+ }
+ default: {
+ throw Error(
+ `unexpected response from track-transaction (${httpResp.status})`,
+ );
+ }
+ }
+}
+
+/**
+ * Check if creating a deposit group is possible and calculate
+ * the associated fees.
+ */
+export async function checkDepositGroup(
+ wex: WalletExecutionContext,
+ req: PrepareDepositRequest,
+): Promise<PrepareDepositResponse> {
+ const p = parsePaytoUri(req.depositPaytoUri);
+ if (!p) {
+ throw Error("invalid payto URI");
+ }
+ const amount = Amounts.parseOrThrow(req.amount);
+ const currency = Amounts.currencyOf(amount);
+
+ const exchangeInfos: ExchangeHandle[] = [];
+
+ await wex.db.runReadOnlyTx(["exchangeDetails", "exchanges"], async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || amount.currency !== details.currency) {
+ continue;
+ }
+ exchangeInfos.push({
+ master_pub: details.masterPublicKey,
+ url: e.baseUrl,
+ });
+ }
+ });
+
+ const now = AbsoluteTime.now();
+ const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
+ const contractTerms: MerchantContractTerms = {
+ exchanges: exchangeInfos,
+ amount: req.amount,
+ max_fee: Amounts.stringify(amount),
+ wire_method: p.targetType,
+ timestamp: nowRounded,
+ merchant_base_url: "",
+ summary: "",
+ nonce: "",
+ wire_transfer_deadline: nowRounded,
+ order_id: "",
+ h_wire: "",
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })),
+ ),
+ merchant: {
+ name: "(wallet)",
+ },
+ merchant_pub: "",
+ refund_deadline: TalerProtocolTimestamp.zero(),
+ };
+
+ const { h: contractTermsHash } = await wex.cryptoApi.hashString({
+ str: canonicalJson(contractTerms),
+ });
+
+ const contractData = extractContractData(
+ contractTerms,
+ contractTermsHash,
+ "",
+ );
+
+ const payCoinSel = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ });
+
+ let selCoins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (payCoinSel.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ selCoins = payCoinSel.result.prospectiveCoins;
+ break;
+ case "success":
+ selCoins = payCoinSel.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(payCoinSel);
+ }
+
+ const totalDepositCost = await getTotalPaymentCost(wex, currency, selCoins);
+
+ const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount(
+ wex,
+ p.targetType,
+ selCoins,
+ );
+
+ const fees = await getTotalFeesForDepositAmount(
+ wex,
+ p.targetType,
+ amount,
+ selCoins,
+ );
+
+ return {
+ totalDepositCost: Amounts.stringify(totalDepositCost),
+ effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount),
+ fees,
+ };
+}
+
+export function generateDepositGroupTxId(): string {
+ const depositGroupId = encodeCrock(getRandomBytes(32));
+ return constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: depositGroupId,
+ });
+}
+
+export async function createDepositGroup(
+ wex: WalletExecutionContext,
+ req: CreateDepositGroupRequest,
+): Promise<CreateDepositGroupResponse> {
+ const p = parsePaytoUri(req.depositPaytoUri);
+ if (!p) {
+ throw Error("invalid payto URI");
+ }
+
+ const amount = Amounts.parseOrThrow(req.amount);
+ const currency = amount.currency;
+
+ const exchangeInfos: { url: string; master_pub: string }[] = [];
+
+ await wex.db.runReadOnlyTx(["exchanges", "exchangeDetails"], async (tx) => {
+ const allExchanges = await tx.exchanges.iter().toArray();
+ for (const e of allExchanges) {
+ const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
+ if (!details || amount.currency !== details.currency) {
+ continue;
+ }
+ exchangeInfos.push({
+ master_pub: details.masterPublicKey,
+ url: e.baseUrl,
+ });
+ }
+ });
+
+ const now = AbsoluteTime.now();
+ const wireDeadline = AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })),
+ );
+ const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
+ const noncePair = await wex.cryptoApi.createEddsaKeypair({});
+ const merchantPair = await wex.cryptoApi.createEddsaKeypair({});
+ const wireSalt = encodeCrock(getRandomBytes(16));
+ const wireHash = hashWire(req.depositPaytoUri, wireSalt);
+ const contractTerms: MerchantContractTerms = {
+ exchanges: exchangeInfos,
+ amount: req.amount,
+ max_fee: Amounts.stringify(amount),
+ wire_method: p.targetType,
+ timestamp: nowRounded,
+ merchant_base_url: "",
+ summary: "",
+ nonce: noncePair.pub,
+ wire_transfer_deadline: wireDeadline,
+ order_id: "",
+ h_wire: wireHash,
+ pay_deadline: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })),
+ ),
+ merchant: {
+ name: "(wallet)",
+ },
+ merchant_pub: merchantPair.pub,
+ refund_deadline: TalerProtocolTimestamp.zero(),
+ };
+
+ const { h: contractTermsHash } = await wex.cryptoApi.hashString({
+ str: canonicalJson(contractTerms),
+ });
+
+ const contractData = extractContractData(
+ contractTerms,
+ contractTermsHash,
+ "",
+ );
+
+ const payCoinSel = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (payCoinSel.type) {
+ case "success":
+ coins = payCoinSel.coinSel.coins;
+ break;
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = payCoinSel.result.prospectiveCoins;
+ break;
+ default:
+ assertUnreachable(payCoinSel);
+ }
+
+ const totalDepositCost = await getTotalPaymentCost(wex, currency, coins);
+
+ let depositGroupId: string;
+ if (req.transactionId) {
+ const txId = parseTransactionIdentifier(req.transactionId);
+ if (!txId || txId.tag !== TransactionType.Deposit) {
+ throw Error("invalid transaction ID");
+ }
+ depositGroupId = txId.depositGroupId;
+ } else {
+ depositGroupId = encodeCrock(getRandomBytes(32));
+ }
+
+ const infoPerExchange: Record<string, DepositInfoPerExchange> = {};
+
+ for (let i = 0; i < coins.length; i++) {
+ let depPerExchange = infoPerExchange[coins[i].exchangeBaseUrl];
+ if (!depPerExchange) {
+ infoPerExchange[coins[i].exchangeBaseUrl] = depPerExchange = {
+ amountEffective: Amounts.stringify(
+ Amounts.zeroOfAmount(totalDepositCost),
+ ),
+ };
+ }
+ const contrib = coins[i].contribution;
+ depPerExchange.amountEffective = Amounts.stringify(
+ Amounts.add(depPerExchange.amountEffective, contrib).amount,
+ );
+ }
+
+ const counterpartyEffectiveDepositAmount =
+ await getCounterpartyEffectiveDepositAmount(wex, p.targetType, coins);
+
+ const depositGroup: DepositGroupRecord = {
+ contractTermsHash,
+ depositGroupId,
+ currency: Amounts.currencyOf(totalDepositCost),
+ amount: contractData.amount,
+ noncePriv: noncePair.priv,
+ noncePub: noncePair.pub,
+ timestampCreated: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(now),
+ ),
+ timestampFinished: undefined,
+ statusPerCoin: undefined,
+ payCoinSelection: undefined,
+ payCoinSelectionUid: undefined,
+ merchantPriv: merchantPair.priv,
+ merchantPub: merchantPair.pub,
+ totalPayCost: Amounts.stringify(totalDepositCost),
+ counterpartyEffectiveDepositAmount: Amounts.stringify(
+ counterpartyEffectiveDepositAmount,
+ ),
+ wireTransferDeadline: timestampProtocolToDb(
+ contractTerms.wire_transfer_deadline,
+ ),
+ wire: {
+ payto_uri: req.depositPaytoUri,
+ salt: wireSalt,
+ },
+ operationStatus: DepositOperationStatus.PendingDeposit,
+ infoPerExchange,
+ };
+
+ if (payCoinSel.type === "success") {
+ depositGroup.payCoinSelection = {
+ coinContributions: payCoinSel.coinSel.coins.map((x) => x.contribution),
+ coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub),
+ };
+ depositGroup.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ depositGroup.statusPerCoin = payCoinSel.coinSel.coins.map(
+ () => DepositElementStatus.DepositPending,
+ );
+ }
+
+ const ctx = new DepositTransactionContext(wex, depositGroupId);
+ const transactionId = ctx.transactionId;
+
+ const newTxState = await wex.db.runReadWriteTx(
+ [
+ "depositGroups",
+ "coins",
+ "recoupGroups",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coinAvailability",
+ "contractTerms",
+ ],
+ async (tx) => {
+ if (depositGroup.payCoinSelection) {
+ await spendCoins(wex, tx, {
+ allocationId: transactionId,
+ coinPubs: depositGroup.payCoinSelection.coinPubs,
+ contributions: depositGroup.payCoinSelection.coinContributions.map(
+ (x) => Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayDeposit,
+ });
+ }
+ await tx.depositGroups.put(depositGroup);
+ await tx.contractTerms.put({
+ contractTermsRaw: contractTerms,
+ h: contractTermsHash,
+ });
+ return computeDepositTransactionStatus(depositGroup);
+ },
+ );
+
+ wex.ws.notify({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState,
+ });
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ depositGroupId,
+ transactionId,
+ };
+}
+
+/**
+ * Get the amount that will be deposited on the users bank
+ * account after depositing, not considering aggregation.
+ */
+export async function getCounterpartyEffectiveDepositAmount(
+ wex: WalletExecutionContext,
+ wireType: string,
+ pcs: SelectedProspectiveCoin[],
+): Promise<AmountJson> {
+ const amt: AmountJson[] = [];
+ const fees: AmountJson[] = [];
+ const exchangeSet: Set<string> = new Set();
+
+ await wex.db.runReadOnlyTx(
+ ["coins", "denominations", "exchangeDetails", "exchanges"],
+ async (tx) => {
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ );
+ if (!denom) {
+ throw Error("can't find denomination to calculate deposit amount");
+ }
+ amt.push(Amounts.parseOrThrow(pcs[i].contribution));
+ fees.push(Amounts.parseOrThrow(denom.feeDeposit));
+ exchangeSet.add(pcs[i].exchangeBaseUrl);
+ }
+
+ for (const exchangeUrl of exchangeSet.values()) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeUrl,
+ );
+ if (!exchangeDetails) {
+ continue;
+ }
+
+ // FIXME/NOTE: the line below _likely_ throws exception
+ // about "find method not found on undefined" when the wireType
+ // is not supported by the Exchange.
+ const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ })?.wireFee;
+ if (fee) {
+ fees.push(Amounts.parseOrThrow(fee));
+ }
+ }
+ },
+ );
+ return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
+}
+
+/**
+ * Get the fee amount that will be charged when trying to deposit the
+ * specified amount using the selected coins and the wire method.
+ */
+async function getTotalFeesForDepositAmount(
+ wex: WalletExecutionContext,
+ wireType: string,
+ total: AmountJson,
+ pcs: SelectedProspectiveCoin[],
+): Promise<DepositGroupFees> {
+ const wireFee: AmountJson[] = [];
+ const coinFee: AmountJson[] = [];
+ const refreshFee: AmountJson[] = [];
+ const exchangeSet: Set<string> = new Set();
+ const currency = Amounts.currencyOf(total);
+
+ await wex.db.runReadOnlyTx(
+ ["coins", "denominations", "exchanges", "exchangeDetails"],
+ async (tx) => {
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ );
+ if (!denom) {
+ throw Error("can't find denomination to calculate deposit amount");
+ }
+ coinFee.push(Amounts.parseOrThrow(denom.feeDeposit));
+ exchangeSet.add(pcs[i].exchangeBaseUrl);
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
+ denom,
+ amountLeft,
+ );
+ refreshFee.push(refreshCost);
+ }
+
+ for (const exchangeUrl of exchangeSet.values()) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeUrl,
+ );
+ if (!exchangeDetails) {
+ continue;
+ }
+ const fee = exchangeDetails.wireInfo.feesForType[wireType]?.find(
+ (x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ },
+ )?.wireFee;
+ if (fee) {
+ wireFee.push(Amounts.parseOrThrow(fee));
+ }
+ }
+ },
+ );
+
+ return {
+ coin: Amounts.stringify(Amounts.sumOrZero(total.currency, coinFee).amount),
+ wire: Amounts.stringify(Amounts.sumOrZero(total.currency, wireFee).amount),
+ refresh: Amounts.stringify(
+ Amounts.sumOrZero(total.currency, refreshFee).amount,
+ ),
+ };
+}
diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts
new file mode 100644
index 000000000..db2ff5d06
--- /dev/null
+++ b/packages/taler-wallet-core/src/dev-experiments.ts
@@ -0,0 +1,147 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of dev experiments, i.e. scenarios
+ * triggered by taler://dev-experiment URIs.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+
+import {
+ DenomLossEventType,
+ Logger,
+ RefreshReason,
+ TalerPreciseTimestamp,
+ encodeCrock,
+ getRandomBytes,
+ parseDevExperimentUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+} from "@gnu-taler/taler-util/http";
+import { PendingTaskType, constructTaskIdentifier } from "./common.js";
+import {
+ DenomLossEventRecord,
+ DenomLossStatus,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ timestampPreciseToDb,
+} from "./db.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("dev-experiments.ts");
+
+/**
+ * Apply a dev experiment to the wallet database / state.
+ */
+export async function applyDevExperiment(
+ wex: WalletExecutionContext,
+ uri: string,
+): Promise<void> {
+ logger.info(`applying dev experiment ${uri}`);
+ const parsedUri = parseDevExperimentUri(uri);
+ if (!parsedUri) {
+ logger.info("unable to parse dev experiment URI");
+ return;
+ }
+ if (!wex.ws.config.testing.devModeActive) {
+ throw Error("can't handle devmode URI unless devmode is active");
+ }
+
+ switch (parsedUri.devExperimentId) {
+ case "start-block-refresh": {
+ wex.ws.devExperimentState.blockRefreshes = true;
+ return;
+ }
+ case "stop-block-refresh": {
+ wex.ws.devExperimentState.blockRefreshes = false;
+ return;
+ }
+ case "insert-pending-refresh": {
+ const refreshGroupId = encodeCrock(getRandomBytes(32));
+ await wex.db.runReadWriteTx(["refreshGroups"], async (tx) => {
+ const newRg: RefreshGroupRecord = {
+ currency: "TESTKUDOS",
+ expectedOutputPerCoin: [],
+ inputPerCoin: [],
+ oldCoinPubs: [],
+ operationStatus: RefreshOperationStatus.Pending,
+ reason: RefreshReason.Manual,
+ refreshGroupId,
+ statusPerCoin: [],
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ timestampFinished: undefined,
+ originatingTransactionId: undefined,
+ infoPerExchange: {},
+ };
+ await tx.refreshGroups.put(newRg);
+ });
+ wex.taskScheduler.startShepherdTask(
+ constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId,
+ }),
+ );
+ return;
+ }
+ case "insert-denom-loss": {
+ await wex.db.runReadWriteTx(["denomLossEvents"], async (tx) => {
+ const eventId = encodeCrock(getRandomBytes(32));
+ const newRg: DenomLossEventRecord = {
+ amount: "TESTKUDOS:42",
+ currency: "TESTKUDOS",
+ exchangeBaseUrl: "https://exchange.test.taler.net/",
+ denomLossEventId: eventId,
+ denomPubHashes: [
+ encodeCrock(getRandomBytes(64)),
+ encodeCrock(getRandomBytes(64)),
+ ],
+ eventType: DenomLossEventType.DenomExpired,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ await tx.denomLossEvents.put(newRg);
+ });
+ return;
+ }
+ }
+
+ throw Error(`dev-experiment id not understood ${parsedUri.devExperimentId}`);
+}
+
+export class DevExperimentHttpLib implements HttpRequestLibrary {
+ _isDevExperimentLib = true;
+ underlyingLib: HttpRequestLibrary;
+
+ constructor(lib: HttpRequestLibrary) {
+ this.underlyingLib = lib;
+ }
+
+ fetch(
+ url: string,
+ opt?: HttpRequestOptions | undefined,
+ ): Promise<HttpResponse> {
+ logger.trace(`devexperiment httplib ${url}`);
+ return this.underlyingLib.fetch(url, opt);
+ }
+}
diff --git a/packages/taler-wallet-core/src/errors.ts b/packages/taler-wallet-core/src/errors.ts
deleted file mode 100644
index d788405ff..000000000
--- a/packages/taler-wallet-core/src/errors.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Classes and helpers for error handling specific to wallet operations.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import { TalerErrorCode, TalerErrorDetails } from "@gnu-taler/taler-util";
-
-/**
- * This exception is there to let the caller know that an error happened,
- * but the error has already been reported by writing it to the database.
- */
-export class OperationFailedAndReportedError extends Error {
- static fromCode(
- ec: TalerErrorCode,
- message: string,
- details: Record<string, unknown>,
- ): OperationFailedAndReportedError {
- return new OperationFailedAndReportedError(
- makeErrorDetails(ec, message, details),
- );
- }
-
- constructor(public operationError: TalerErrorDetails) {
- super(operationError.message);
-
- // Set the prototype explicitly.
- Object.setPrototypeOf(this, OperationFailedAndReportedError.prototype);
- }
-}
-
-/**
- * This exception is thrown when an error occurred and the caller is
- * responsible for recording the failure in the database.
- */
-export class OperationFailedError extends Error {
- static fromCode(
- ec: TalerErrorCode,
- message: string,
- details: Record<string, unknown>,
- ): OperationFailedError {
- return new OperationFailedError(makeErrorDetails(ec, message, details));
- }
-
- constructor(public operationError: TalerErrorDetails) {
- super(operationError.message);
-
- // Set the prototype explicitly.
- Object.setPrototypeOf(this, OperationFailedError.prototype);
- }
-}
-
-export function makeErrorDetails(
- ec: TalerErrorCode,
- message: string,
- details: Record<string, unknown>,
-): TalerErrorDetails {
- return {
- code: ec,
- hint: `Error: ${TalerErrorCode[ec]}`,
- details: details,
- message,
- };
-}
-
-/**
- * Run an operation and call the onOpError callback
- * when there was an exception or operation error that must be reported.
- * The cause will be re-thrown to the caller.
- */
-export async function guardOperationException<T>(
- op: () => Promise<T>,
- onOpError: (e: TalerErrorDetails) => Promise<void>,
-): Promise<T> {
- try {
- return await op();
- } catch (e) {
- if (e instanceof OperationFailedAndReportedError) {
- throw e;
- }
- if (e instanceof OperationFailedError) {
- await onOpError(e.operationError);
- throw new OperationFailedAndReportedError(e.operationError);
- }
- if (e instanceof Error) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- `unexpected exception (message: ${e.message})`,
- {
- stack: e.stack,
- },
- );
- await onOpError(opErr);
- throw new OperationFailedAndReportedError(opErr);
- }
- // Something was thrown that is not even an exception!
- // Try to stringify it.
- let excString: string;
- try {
- excString = e.toString();
- } catch (e) {
- // Something went horribly wrong.
- excString = "can't stringify exception";
- }
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- `unexpected exception (not an exception, ${excString})`,
- {},
- );
- await onOpError(opErr);
- throw new OperationFailedAndReportedError(opErr);
- }
-}
diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts
new file mode 100644
index 000000000..4a784cebb
--- /dev/null
+++ b/packages/taler-wallet-core/src/exchanges.ts
@@ -0,0 +1,2578 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of exchange entry management in wallet-core.
+ * The details of exchange entry management are specified in DD48.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ Amount,
+ Amounts,
+ AsyncFlag,
+ CancellationToken,
+ CoinRefreshRequest,
+ CoinStatus,
+ DeleteExchangeRequest,
+ DenomKeyType,
+ DenomLossEventType,
+ DenomOperationMap,
+ DenominationInfo,
+ DenominationPubKey,
+ Duration,
+ EddsaPublicKeyString,
+ ExchangeAuditor,
+ ExchangeDetailedResponse,
+ ExchangeGlobalFees,
+ ExchangeListItem,
+ ExchangeSignKeyJson,
+ ExchangeTosStatus,
+ ExchangeWireAccount,
+ ExchangesListResponse,
+ FeeDescription,
+ GetExchangeEntryByUrlRequest,
+ GetExchangeResourcesResponse,
+ GetExchangeTosResult,
+ GlobalFees,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ OperationErrorInfo,
+ Recoup,
+ RefreshReason,
+ ScopeInfo,
+ ScopeType,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolDuration,
+ TalerProtocolTimestamp,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletNotification,
+ WireFee,
+ WireFeeMap,
+ WireFeesJson,
+ WireInfo,
+ assertUnreachable,
+ canonicalizeBaseUrl,
+ checkDbInvariant,
+ codecForExchangeKeysJson,
+ durationMul,
+ encodeCrock,
+ getRandomBytes,
+ hashDenomPub,
+ j2s,
+ makeErrorDetail,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ getExpiry,
+ readSuccessResponseJsonOrThrow,
+ readSuccessResponseTextOrThrow,
+} from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskIdentifiers,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ computeDbBackoff,
+ constructTaskIdentifier,
+ getAutoRefreshExecuteThreshold,
+ getExchangeEntryStatusFromRecord,
+ getExchangeState,
+ getExchangeTosStatusFromRecord,
+ getExchangeUpdateStatusFromRecord,
+} from "./common.js";
+import {
+ DenomLossEventRecord,
+ DenomLossStatus,
+ DenominationRecord,
+ DenominationVerificationStatus,
+ ExchangeDetailsRecord,
+ ExchangeEntryDbRecordStatus,
+ ExchangeEntryDbUpdateStatus,
+ ExchangeEntryRecord,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletStoresV1,
+ timestampAbsoluteFromDb,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import {
+ createTimeline,
+ isWithdrawableDenom,
+ selectBestForOverlappingDenominations,
+ selectMinimumFee,
+} from "./denominations.js";
+import { DbReadOnlyTransaction } from "./query.js";
+import { createRecoupGroup } from "./recoup.js";
+import { createRefreshGroup } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js";
+import { InternalWalletState, WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("exchanges.ts");
+
+function getExchangeRequestTimeout(): Duration {
+ return Duration.fromSpec({
+ seconds: 15,
+ });
+}
+
+interface ExchangeTosDownloadResult {
+ tosText: string;
+ tosEtag: string;
+ tosContentType: string;
+ tosContentLanguage: string | undefined;
+ tosAvailableLanguages: string[];
+}
+
+async function downloadExchangeWithTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ http: HttpRequestLibrary,
+ timeout: Duration,
+ acceptFormat: string,
+ acceptLanguage: string | undefined,
+): Promise<ExchangeTosDownloadResult> {
+ logger.trace(`downloading exchange tos (type ${acceptFormat})`);
+ const reqUrl = new URL("terms", exchangeBaseUrl);
+ const headers: {
+ Accept: string;
+ "Accept-Language"?: string;
+ } = {
+ Accept: acceptFormat,
+ };
+
+ if (acceptLanguage) {
+ headers["Accept-Language"] = acceptLanguage;
+ }
+
+ const resp = await http.fetch(reqUrl.href, {
+ headers,
+ timeout,
+ cancellationToken: wex.cancellationToken,
+ });
+ const tosText = await readSuccessResponseTextOrThrow(resp);
+ const tosEtag = resp.headers.get("etag") || "unknown";
+ const tosContentLanguage = resp.headers.get("content-language") || undefined;
+ const tosContentType = resp.headers.get("content-type") || "text/plain";
+ const availLangStr = resp.headers.get("avail-languages") || "";
+ // Work around exchange bug that reports the same language multiple times.
+ const availLangSet = new Set<string>(
+ availLangStr.split(",").map((x) => x.trim()),
+ );
+ const tosAvailableLanguages = [...availLangSet];
+
+ return {
+ tosText,
+ tosEtag,
+ tosContentType,
+ tosContentLanguage,
+ tosAvailableLanguages,
+ };
+}
+
+/**
+ * Get exchange details from the database.
+ */
+async function getExchangeRecordsInternal(
+ tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
+ exchangeBaseUrl: string,
+): Promise<ExchangeDetailsRecord | undefined> {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ logger.warn(`no exchange found for ${exchangeBaseUrl}`);
+ return;
+ }
+ const dp = r.detailsPointer;
+ if (!dp) {
+ logger.warn(`no exchange details pointer for ${exchangeBaseUrl}`);
+ return;
+ }
+ const { currency, masterPublicKey } = dp;
+ const details = await tx.exchangeDetails.indexes.byPointer.get([
+ r.baseUrl,
+ currency,
+ masterPublicKey,
+ ]);
+ if (!details) {
+ logger.warn(
+ `no exchange details with pointer ${j2s(dp)} for ${exchangeBaseUrl}`,
+ );
+ }
+ return details;
+}
+
+export async function getExchangeScopeInfo(
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "globalCurrencyExchanges",
+ "globalCurrencyAuditors",
+ ]
+ >,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<ScopeInfo> {
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ return {
+ type: ScopeType.Exchange,
+ currency: currency,
+ url: exchangeBaseUrl,
+ };
+ }
+ return internalGetExchangeScopeInfo(tx, det);
+}
+
+async function internalGetExchangeScopeInfo(
+ tx: WalletDbReadOnlyTransaction<
+ ["globalCurrencyExchanges", "globalCurrencyAuditors"]
+ >,
+ exchangeDetails: ExchangeDetailsRecord,
+): Promise<ScopeInfo> {
+ const globalExchangeRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([
+ exchangeDetails.currency,
+ exchangeDetails.exchangeBaseUrl,
+ exchangeDetails.masterPublicKey,
+ ]);
+ if (globalExchangeRec) {
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Global,
+ };
+ } else {
+ for (const aud of exchangeDetails.auditors) {
+ const globalAuditorRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([
+ exchangeDetails.currency,
+ aud.auditor_url,
+ aud.auditor_pub,
+ ]);
+ if (globalAuditorRec) {
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Auditor,
+ url: aud.auditor_url,
+ };
+ }
+ }
+ }
+ return {
+ currency: exchangeDetails.currency,
+ type: ScopeType.Exchange,
+ url: exchangeDetails.exchangeBaseUrl,
+ };
+}
+
+async function makeExchangeListItem(
+ tx: WalletDbReadOnlyTransaction<
+ ["globalCurrencyExchanges", "globalCurrencyAuditors"]
+ >,
+ r: ExchangeEntryRecord,
+ exchangeDetails: ExchangeDetailsRecord | undefined,
+ lastError: TalerErrorDetail | undefined,
+): Promise<ExchangeListItem> {
+ const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
+ ? {
+ error: lastError,
+ }
+ : undefined;
+
+ let scopeInfo: ScopeInfo | undefined = undefined;
+
+ if (exchangeDetails) {
+ scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
+ }
+
+ return {
+ exchangeBaseUrl: r.baseUrl,
+ masterPub: exchangeDetails?.masterPublicKey,
+ noFees: r.noFees ?? false,
+ peerPaymentsDisabled: r.peerPaymentsDisabled ?? false,
+ currency: exchangeDetails?.currency ?? r.presetCurrencyHint ?? "UNKNOWN",
+ exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r),
+ exchangeEntryStatus: getExchangeEntryStatusFromRecord(r),
+ tosStatus: getExchangeTosStatusFromRecord(r),
+ ageRestrictionOptions: exchangeDetails?.ageMask
+ ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
+ : [],
+ paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
+ lastUpdateTimestamp: timestampOptionalPreciseFromDb(r.lastUpdate),
+ lastUpdateErrorInfo,
+ scopeInfo: scopeInfo ?? {
+ type: ScopeType.Exchange,
+ currency: "UNKNOWN",
+ url: r.baseUrl,
+ },
+ };
+}
+
+export interface ExchangeWireDetails {
+ currency: string;
+ masterPublicKey: EddsaPublicKeyString;
+ wireInfo: WireInfo;
+ exchangeBaseUrl: string;
+ auditors: ExchangeAuditor[];
+ globalFees: ExchangeGlobalFees[];
+}
+
+export async function getExchangeWireDetailsInTx(
+ tx: WalletDbReadOnlyTransaction<["exchanges", "exchangeDetails"]>,
+ exchangeBaseUrl: string,
+): Promise<ExchangeWireDetails | undefined> {
+ const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ if (!det) {
+ return undefined;
+ }
+ return {
+ currency: det.currency,
+ masterPublicKey: det.masterPublicKey,
+ wireInfo: det.wireInfo,
+ exchangeBaseUrl: det.exchangeBaseUrl,
+ auditors: det.auditors,
+ globalFees: det.globalFees,
+ };
+}
+
+export async function lookupExchangeByUri(
+ wex: WalletExecutionContext,
+ req: GetExchangeEntryByUrlRequest,
+): Promise<ExchangeListItem> {
+ return await wex.db.runReadOnlyTx(
+ [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ async (tx) => {
+ const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl);
+ if (!exchangeRec) {
+ throw Error("exchange not found");
+ }
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ exchangeRec.baseUrl,
+ );
+ const opRetryRecord = await tx.operationRetries.get(
+ TaskIdentifiers.forExchangeUpdate(exchangeRec),
+ );
+ return await makeExchangeListItem(
+ tx,
+ exchangeRec,
+ exchangeDetails,
+ opRetryRecord?.lastError,
+ );
+ },
+ );
+}
+
+/**
+ * Mark the current ToS version as accepted by the user.
+ */
+export async function acceptExchangeTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const notif = await wex.db.runReadWriteTx(
+ ["exchangeDetails", "exchanges"],
+ async (tx) => {
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (exch && exch.tosCurrentEtag) {
+ const oldExchangeState = getExchangeState(exch);
+ exch.tosAcceptedEtag = exch.tosCurrentEtag;
+ exch.tosAcceptedTimestamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ wex.ws.exchangeCache.clear();
+ return {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification;
+ }
+ return undefined;
+ },
+ );
+ if (notif) {
+ wex.ws.notify(notif);
+ }
+}
+
+/**
+ * Mark the current ToS version as accepted by the user.
+ */
+export async function forgetExchangeTermsOfService(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const notif = await wex.db.runReadWriteTx(
+ ["exchangeDetails", "exchanges"],
+ async (tx) => {
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (exch) {
+ const oldExchangeState = getExchangeState(exch);
+ exch.tosAcceptedEtag = undefined;
+ exch.tosAcceptedTimestamp = undefined;
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ wex.ws.exchangeCache.clear();
+ return {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification;
+ }
+ return undefined;
+ },
+ );
+ if (notif) {
+ wex.ws.notify(notif);
+ }
+}
+
+/**
+ * Validate wire fees and wire accounts.
+ *
+ * Throw an exception if they are invalid.
+ */
+async function validateWireInfo(
+ wex: WalletExecutionContext,
+ versionCurrent: number,
+ wireInfo: ExchangeKeysDownloadResult,
+ masterPublicKey: string,
+): Promise<WireInfo> {
+ for (const a of wireInfo.accounts) {
+ logger.trace("validating exchange acct");
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.ws.cryptoApi.isValidWireAccount({
+ masterPub: masterPublicKey,
+ paytoUri: a.payto_uri,
+ sig: a.master_sig,
+ versionCurrent,
+ conversionUrl: a.conversion_url,
+ creditRestrictions: a.credit_restrictions,
+ debitRestrictions: a.debit_restrictions,
+ });
+ isValid = v;
+ }
+ if (!isValid) {
+ throw Error("exchange acct signature invalid");
+ }
+ }
+ logger.trace("account validation done");
+ const feesForType: WireFeeMap = {};
+ for (const wireMethod of Object.keys(wireInfo.wireFees)) {
+ const feeList: WireFee[] = [];
+ for (const x of wireInfo.wireFees[wireMethod]) {
+ const startStamp = x.start_date;
+ const endStamp = x.end_date;
+ const fee: WireFee = {
+ closingFee: Amounts.stringify(x.closing_fee),
+ endStamp,
+ sig: x.sig,
+ startStamp,
+ wireFee: Amounts.stringify(x.wire_fee),
+ };
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.ws.cryptoApi.isValidWireFee({
+ masterPub: masterPublicKey,
+ type: wireMethod,
+ wf: fee,
+ });
+ isValid = v;
+ }
+ if (!isValid) {
+ throw Error("exchange wire fee signature invalid");
+ }
+ feeList.push(fee);
+ }
+ feesForType[wireMethod] = feeList;
+ }
+
+ return {
+ accounts: wireInfo.accounts,
+ feesForType,
+ };
+}
+
+/**
+ * Validate global fees.
+ *
+ * Throw an exception if they are invalid.
+ */
+async function validateGlobalFees(
+ wex: WalletExecutionContext,
+ fees: GlobalFees[],
+ masterPub: string,
+): Promise<ExchangeGlobalFees[]> {
+ const egf: ExchangeGlobalFees[] = [];
+ for (const gf of fees) {
+ logger.trace("validating exchange global fees");
+ let isValid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ isValid = true;
+ } else {
+ const { valid: v } = await wex.cryptoApi.isValidGlobalFees({
+ masterPub,
+ gf,
+ });
+ isValid = v;
+ }
+
+ if (!isValid) {
+ throw Error("exchange global fees signature invalid: " + gf.master_sig);
+ }
+ egf.push({
+ accountFee: Amounts.stringify(gf.account_fee),
+ historyFee: Amounts.stringify(gf.history_fee),
+ purseFee: Amounts.stringify(gf.purse_fee),
+ startDate: gf.start_date,
+ endDate: gf.end_date,
+ signature: gf.master_sig,
+ historyTimeout: gf.history_expiration,
+ purseLimit: gf.purse_account_limit,
+ purseTimeout: gf.purse_timeout,
+ });
+ }
+
+ return egf;
+}
+
+/**
+ * Add an exchange entry to the wallet database in the
+ * entry state "preset".
+ *
+ * Returns the notification to the caller that should be emitted
+ * if the DB transaction succeeds.
+ */
+export async function addPresetExchangeEntry(
+ tx: WalletDbReadWriteTransaction<["exchanges"]>,
+ exchangeBaseUrl: string,
+ currencyHint?: string,
+): Promise<{ notification?: WalletNotification }> {
+ let exchange = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchange) {
+ const r: ExchangeEntryRecord = {
+ entryStatus: ExchangeEntryDbRecordStatus.Preset,
+ updateStatus: ExchangeEntryDbUpdateStatus.Initial,
+ baseUrl: exchangeBaseUrl,
+ presetCurrencyHint: currencyHint,
+ detailsPointer: undefined,
+ lastUpdate: undefined,
+ lastKeysEtag: undefined,
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ };
+ await tx.exchanges.put(r);
+ return {
+ notification: {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: exchangeBaseUrl,
+ // Exchange did not exist yet
+ oldExchangeState: undefined,
+ newExchangeState: getExchangeState(r),
+ },
+ };
+ }
+ return {};
+}
+
+async function provideExchangeRecordInTx(
+ ws: InternalWalletState,
+ tx: WalletDbReadWriteTransaction<["exchanges", "exchangeDetails"]>,
+ baseUrl: string,
+): Promise<{
+ exchange: ExchangeEntryRecord;
+ exchangeDetails: ExchangeDetailsRecord | undefined;
+ notification?: WalletNotification;
+}> {
+ let notification: WalletNotification | undefined = undefined;
+ let exchange = await tx.exchanges.get(baseUrl);
+ if (!exchange) {
+ const r: ExchangeEntryRecord = {
+ entryStatus: ExchangeEntryDbRecordStatus.Ephemeral,
+ updateStatus: ExchangeEntryDbUpdateStatus.InitialUpdate,
+ baseUrl: baseUrl,
+ detailsPointer: undefined,
+ lastUpdate: undefined,
+ nextUpdateStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ nextRefreshCheckStamp: timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ ),
+ // The first update should always be done in a way that ignores the cache,
+ // so that removing and re-adding an exchange works properly, even
+ // if /keys is cached in the browser.
+ cachebreakNextUpdate: true,
+ lastKeysEtag: undefined,
+ tosAcceptedEtag: undefined,
+ tosAcceptedTimestamp: undefined,
+ tosCurrentEtag: undefined,
+ };
+ await tx.exchanges.put(r);
+ exchange = r;
+ notification = {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: r.baseUrl,
+ oldExchangeState: undefined,
+ newExchangeState: getExchangeState(r),
+ };
+ }
+ const exchangeDetails = await getExchangeRecordsInternal(tx, baseUrl);
+ return { exchange, exchangeDetails, notification };
+}
+
+export interface ExchangeKeysDownloadResult {
+ baseUrl: string;
+ masterPublicKey: string;
+ currency: string;
+ auditors: ExchangeAuditor[];
+ currentDenominations: DenominationRecord[];
+ protocolVersion: string;
+ signingKeys: ExchangeSignKeyJson[];
+ reserveClosingDelay: TalerProtocolDuration;
+ expiry: TalerProtocolTimestamp;
+ recoup: Recoup[];
+ listIssueDate: TalerProtocolTimestamp;
+ globalFees: GlobalFees[];
+ accounts: ExchangeWireAccount[];
+ wireFees: { [methodName: string]: WireFeesJson[] };
+}
+
+/**
+ * Download and validate an exchange's /keys data.
+ */
+async function downloadExchangeKeysInfo(
+ baseUrl: string,
+ http: HttpRequestLibrary,
+ timeout: Duration,
+ cancellationToken: CancellationToken,
+ noCache: boolean,
+): Promise<ExchangeKeysDownloadResult> {
+ const keysUrl = new URL("keys", baseUrl);
+
+ const headers: Record<string, string> = {};
+ if (noCache) {
+ headers["cache-control"] = "no-cache";
+ }
+ const resp = await http.fetch(keysUrl.href, {
+ timeout,
+ cancellationToken,
+ headers,
+ });
+
+ logger.info("got response to /keys request");
+
+ // We must make sure to parse out the protocol version
+ // before we validate the body.
+ // Otherwise the parser might complain with a hard to understand
+ // message about some other field, when it is just a version
+ // incompatibility.
+
+ const keysJson = await resp.json();
+
+ const protocolVersion = keysJson.version;
+ if (typeof protocolVersion !== "string") {
+ throw Error("bad exchange, does not even specify protocol version");
+ }
+
+ const versionRes = LibtoolVersion.compare(
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ protocolVersion,
+ );
+ if (!versionRes) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl: resp.requestUrl,
+ httpStatusCode: resp.status,
+ requestMethod: resp.requestMethod,
+ },
+ "exchange protocol version malformed",
+ );
+ }
+ if (!versionRes.compatible) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ exchangeProtocolVersion: protocolVersion,
+ walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ },
+ "exchange protocol version not compatible with wallet",
+ );
+ }
+
+ const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeKeysJson(),
+ );
+
+ if (exchangeKeysJsonUnchecked.denominations.length === 0) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
+ {
+ exchangeBaseUrl: baseUrl,
+ },
+ "exchange doesn't offer any denominations",
+ );
+ }
+
+ const currency = exchangeKeysJsonUnchecked.currency;
+
+ const currentDenominations: DenominationRecord[] = [];
+
+ for (const denomGroup of exchangeKeysJsonUnchecked.denominations) {
+ switch (denomGroup.cipher) {
+ case "RSA":
+ case "RSA+age_restricted": {
+ let ageMask = 0;
+ if (denomGroup.cipher === "RSA+age_restricted") {
+ ageMask = denomGroup.age_mask;
+ }
+ for (const denomIn of denomGroup.denoms) {
+ const denomPub: DenominationPubKey = {
+ age_mask: ageMask,
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key: denomIn.rsa_pub,
+ };
+ const denomPubHash = encodeCrock(hashDenomPub(denomPub));
+ const value = Amounts.parseOrThrow(denomGroup.value);
+ const rec: DenominationRecord = {
+ denomPub,
+ denomPubHash,
+ exchangeBaseUrl: baseUrl,
+ exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key,
+ isOffered: true,
+ isRevoked: false,
+ isLost: denomIn.lost ?? false,
+ value: Amounts.stringify(value),
+ currency: value.currency,
+ stampExpireDeposit: timestampProtocolToDb(
+ denomIn.stamp_expire_deposit,
+ ),
+ stampExpireLegal: timestampProtocolToDb(denomIn.stamp_expire_legal),
+ stampExpireWithdraw: timestampProtocolToDb(
+ denomIn.stamp_expire_withdraw,
+ ),
+ stampStart: timestampProtocolToDb(denomIn.stamp_start),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ masterSig: denomIn.master_sig,
+ fees: {
+ feeDeposit: Amounts.stringify(denomGroup.fee_deposit),
+ feeRefresh: Amounts.stringify(denomGroup.fee_refresh),
+ feeRefund: Amounts.stringify(denomGroup.fee_refund),
+ feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw),
+ },
+ };
+ currentDenominations.push(rec);
+ }
+ break;
+ }
+ case "CS+age_restricted":
+ case "CS":
+ logger.warn("Clause-Schnorr denominations not supported");
+ continue;
+ default:
+ logger.warn(
+ `denomination type ${(denomGroup as any).cipher} not supported`,
+ );
+ continue;
+ }
+ }
+
+ return {
+ masterPublicKey: exchangeKeysJsonUnchecked.master_public_key,
+ currency,
+ baseUrl: exchangeKeysJsonUnchecked.base_url,
+ auditors: exchangeKeysJsonUnchecked.auditors,
+ currentDenominations,
+ protocolVersion: exchangeKeysJsonUnchecked.version,
+ signingKeys: exchangeKeysJsonUnchecked.signkeys,
+ reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay,
+ expiry: AbsoluteTime.toProtocolTimestamp(
+ getExpiry(resp, {
+ minDuration: Duration.fromSpec({ hours: 1 }),
+ }),
+ ),
+ recoup: exchangeKeysJsonUnchecked.recoup ?? [],
+ listIssueDate: exchangeKeysJsonUnchecked.list_issue_date,
+ globalFees: exchangeKeysJsonUnchecked.global_fees,
+ accounts: exchangeKeysJsonUnchecked.accounts,
+ wireFees: exchangeKeysJsonUnchecked.wire_fees,
+ };
+}
+
+async function downloadTosFromAcceptedFormat(
+ wex: WalletExecutionContext,
+ baseUrl: string,
+ timeout: Duration,
+ acceptedFormat?: string[],
+ acceptLanguage?: string,
+): Promise<ExchangeTosDownloadResult> {
+ let tosFound: ExchangeTosDownloadResult | undefined;
+ // Remove this when exchange supports multiple content-type in accept header
+ if (acceptedFormat)
+ for (const format of acceptedFormat) {
+ const resp = await downloadExchangeWithTermsOfService(
+ wex,
+ baseUrl,
+ wex.http,
+ timeout,
+ format,
+ acceptLanguage,
+ );
+ if (resp.tosContentType === format) {
+ tosFound = resp;
+ break;
+ }
+ }
+ if (tosFound !== undefined) {
+ return tosFound;
+ }
+ // If none of the specified format was found try text/plain
+ return await downloadExchangeWithTermsOfService(
+ wex,
+ baseUrl,
+ wex.http,
+ timeout,
+ "text/plain",
+ acceptLanguage,
+ );
+}
+
+/**
+ * Transition an exchange into an updating state.
+ *
+ * If the update is forced, the exchange is put into an updating state
+ * even if the old information should still be up to date.
+ *
+ * If the exchange entry doesn't exist,
+ * a new ephemeral entry is created.
+ */
+async function startUpdateExchangeEntry(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ options: { forceUpdate?: boolean } = {},
+): Promise<void> {
+ const canonBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
+
+ logger.info(
+ `starting update of exchange entry ${canonBaseUrl}, forced=${options.forceUpdate ?? false
+ }`,
+ );
+
+ const { notification } = await wex.db.runReadWriteTx(
+ ["exchanges", "exchangeDetails"],
+ async (tx) => {
+ wex.ws.exchangeCache.clear();
+ return provideExchangeRecordInTx(wex.ws, tx, exchangeBaseUrl);
+ },
+ );
+
+ logger.trace("created exchange record");
+
+ if (notification) {
+ wex.ws.notify(notification);
+ }
+
+ const { oldExchangeState, newExchangeState, taskId } =
+ await wex.db.runReadWriteTx(
+ ["exchanges", "operationRetries"],
+ async (tx) => {
+ const r = await tx.exchanges.get(canonBaseUrl);
+ if (!r) {
+ throw Error("exchange not found");
+ }
+ const oldExchangeState = getExchangeState(r);
+ switch (r.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.Ready: {
+ const nextUpdateTimestamp = AbsoluteTime.fromPreciseTimestamp(
+ timestampPreciseFromDb(r.nextUpdateStamp),
+ );
+ // Only update if entry is outdated or update is forced.
+ if (
+ options.forceUpdate ||
+ AbsoluteTime.isExpired(nextUpdateTimestamp)
+ ) {
+ r.updateStatus = ExchangeEntryDbUpdateStatus.ReadyUpdate;
+ r.cachebreakNextUpdate = options.forceUpdate;
+ }
+ break;
+ }
+ case ExchangeEntryDbUpdateStatus.Initial:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ r.updateStatus = ExchangeEntryDbUpdateStatus.InitialUpdate;
+ break;
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ r.cachebreakNextUpdate = options.forceUpdate;
+ break;
+ }
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(r);
+ const newExchangeState = getExchangeState(r);
+ // Reset retries for updating the exchange entry.
+ const taskId = TaskIdentifiers.forExchangeUpdate(r);
+ await tx.operationRetries.delete(taskId);
+ return { oldExchangeState, newExchangeState, taskId };
+ },
+ );
+ wex.ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: canonBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ });
+ await wex.taskScheduler.resetTaskRetries(taskId);
+}
+
+/**
+ * Basic information about an exchange in a ready state.
+ */
+export interface ReadyExchangeSummary {
+ exchangeBaseUrl: string;
+ currency: string;
+ masterPub: string;
+ tosStatus: ExchangeTosStatus;
+ tosAcceptedEtag: string | undefined;
+ tosCurrentEtag: string | undefined;
+ wireInfo: WireInfo;
+ protocolVersionRange: string;
+ tosAcceptedTimestamp: TalerPreciseTimestamp | undefined;
+ scopeInfo: ScopeInfo;
+}
+
+async function internalWaitReadyExchange(
+ wex: WalletExecutionContext,
+ canonUrl: string,
+ exchangeNotifFlag: AsyncFlag,
+ options: {
+ cancellationToken?: CancellationToken;
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ const operationId = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: canonUrl,
+ });
+ while (true) {
+ if (wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+ logger.info(`waiting for ready exchange ${canonUrl}`);
+ const { exchange, exchangeDetails, retryInfo, scopeInfo } =
+ await wex.db.runReadOnlyTx(
+ [
+ "exchanges",
+ "exchangeDetails",
+ "operationRetries",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ async (tx) => {
+ const exchange = await tx.exchanges.get(canonUrl);
+ const exchangeDetails = await getExchangeRecordsInternal(
+ tx,
+ canonUrl,
+ );
+ const retryInfo = await tx.operationRetries.get(operationId);
+ let scopeInfo: ScopeInfo | undefined = undefined;
+ if (exchange && exchangeDetails) {
+ scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails);
+ }
+ return { exchange, exchangeDetails, retryInfo, scopeInfo };
+ },
+ );
+
+ if (!exchange) {
+ throw Error("exchange entry does not exist anymore");
+ }
+
+ let ready = false;
+
+ switch (exchange.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Ready:
+ ready = true;
+ break;
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ // If the update is forced,
+ // we wait until we're in a full "ready" state,
+ // as we're not happy with the stale information.
+ if (!options.forceUpdate) {
+ ready = true;
+ }
+ break;
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ {
+ exchangeBaseUrl: canonUrl,
+ innerError: retryInfo?.lastError,
+ },
+ );
+ default: {
+ if (retryInfo) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE,
+ {
+ exchangeBaseUrl: canonUrl,
+ innerError: retryInfo?.lastError,
+ },
+ );
+ }
+ }
+ }
+
+ if (!ready) {
+ logger.info("waiting for exchange update notification");
+ await exchangeNotifFlag.wait();
+ logger.info("done waiting for exchange update notification");
+ exchangeNotifFlag.reset();
+ continue;
+ }
+
+ if (!exchangeDetails) {
+ throw Error("invariant failed");
+ }
+
+ if (!scopeInfo) {
+ throw Error("invariant failed");
+ }
+
+ const res: ReadyExchangeSummary = {
+ currency: exchangeDetails.currency,
+ exchangeBaseUrl: canonUrl,
+ masterPub: exchangeDetails.masterPublicKey,
+ tosStatus: getExchangeTosStatusFromRecord(exchange),
+ tosAcceptedEtag: exchange.tosAcceptedEtag,
+ wireInfo: exchangeDetails.wireInfo,
+ protocolVersionRange: exchangeDetails.protocolVersionRange,
+ tosCurrentEtag: exchange.tosCurrentEtag,
+ tosAcceptedTimestamp: timestampOptionalPreciseFromDb(
+ exchange.tosAcceptedTimestamp,
+ ),
+ scopeInfo,
+ };
+
+ if (options.expectedMasterPub) {
+ if (res.masterPub !== options.expectedMasterPub) {
+ throw Error(
+ "public key of the exchange does not match expected public key",
+ );
+ }
+ }
+ return res;
+ }
+}
+
+/**
+ * Ensure that a fresh exchange entry exists for the given
+ * exchange base URL.
+ *
+ * The cancellation token can be used to abort waiting for the
+ * updated exchange entry.
+ *
+ * If an exchange entry for the database doesn't exist in the
+ * DB, it will be added ephemerally.
+ *
+ * If the expectedMasterPub is given and does not match the actual
+ * master pub, an exception will be thrown. However, the exchange
+ * will still have been added as an ephemeral exchange entry.
+ */
+export async function fetchFreshExchange(
+ wex: WalletExecutionContext,
+ baseUrl: string,
+ options: {
+ cancellationToken?: CancellationToken;
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ const canonUrl = canonicalizeBaseUrl(baseUrl);
+
+ if (!options.forceUpdate) {
+ const cachedResp = wex.ws.exchangeCache.get(canonUrl);
+ if (cachedResp) {
+ return cachedResp;
+ }
+ } else {
+ wex.ws.exchangeCache.clear();
+ }
+
+ wex.taskScheduler.ensureRunning();
+
+ await startUpdateExchangeEntry(wex, canonUrl, {
+ forceUpdate: options.forceUpdate,
+ });
+
+ const resp = await waitReadyExchange(wex, canonUrl, options);
+ wex.ws.exchangeCache.put(canonUrl, resp);
+ return resp;
+}
+
+async function waitReadyExchange(
+ wex: WalletExecutionContext,
+ canonUrl: string,
+ options: {
+ forceUpdate?: boolean;
+ expectedMasterPub?: string;
+ } = {},
+): Promise<ReadyExchangeSummary> {
+ logger.trace(`waiting for exchange ${canonUrl} to become ready`);
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const exchangeNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.ExchangeStateTransition &&
+ notif.exchangeBaseUrl === canonUrl
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ exchangeNotifFlag.raise();
+ }
+ });
+
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ exchangeNotifFlag.raise();
+ });
+
+ try {
+ const res = await internalWaitReadyExchange(
+ wex,
+ canonUrl,
+ exchangeNotifFlag,
+ options,
+ );
+ logger.info("done waiting for ready exchange");
+ return res;
+ } finally {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+function checkPeerPaymentsDisabled(
+ keysInfo: ExchangeKeysDownloadResult,
+): boolean {
+ const now = AbsoluteTime.now();
+ for (let gf of keysInfo.globalFees) {
+ const isActive = AbsoluteTime.isBetween(
+ now,
+ AbsoluteTime.fromProtocolTimestamp(gf.start_date),
+ AbsoluteTime.fromProtocolTimestamp(gf.end_date),
+ );
+ if (!isActive) {
+ continue;
+ }
+ return false;
+ }
+ // No global fees, we can't do p2p payments!
+ return true;
+}
+
+function checkNoFees(keysInfo: ExchangeKeysDownloadResult): boolean {
+ for (const gf of keysInfo.globalFees) {
+ if (!Amounts.isZero(gf.account_fee)) {
+ return false;
+ }
+ if (!Amounts.isZero(gf.history_fee)) {
+ return false;
+ }
+ if (!Amounts.isZero(gf.purse_fee)) {
+ return false;
+ }
+ }
+ for (const denom of keysInfo.currentDenominations) {
+ if (!Amounts.isZero(denom.fees.feeWithdraw)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeDeposit)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeRefund)) {
+ return false;
+ }
+ if (!Amounts.isZero(denom.fees.feeRefresh)) {
+ return false;
+ }
+ }
+ for (const wft of Object.values(keysInfo.wireFees)) {
+ for (const wf of wft) {
+ if (!Amounts.isZero(wf.wire_fee)) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+/**
+ * Update an exchange entry in the wallet's database
+ * by fetching the /keys and /wire information.
+ * Optionally link the reserve entry to the new or existing
+ * exchange entry in then DB.
+ */
+export async function updateExchangeFromUrlHandler(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<TaskRunResult> {
+ logger.trace(`updating exchange info for ${exchangeBaseUrl}`);
+ exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
+
+ const oldExchangeRec = await wex.db.runReadOnlyTx(
+ ["exchanges"],
+ async (tx) => {
+ return tx.exchanges.get(exchangeBaseUrl);
+ },
+ );
+
+ if (!oldExchangeRec) {
+ logger.info(`not updating exchange ${exchangeBaseUrl}, no record in DB`);
+ return TaskRunResult.finished();
+ }
+
+ let updateRequestedExplicitly = false;
+
+ switch (oldExchangeRec.updateStatus) {
+ case ExchangeEntryDbUpdateStatus.Suspended:
+ logger.info(`not updating exchange in status "suspended"`);
+ return TaskRunResult.finished();
+ case ExchangeEntryDbUpdateStatus.Initial:
+ logger.info(`not updating exchange in status "initial"`);
+ return TaskRunResult.finished();
+ case ExchangeEntryDbUpdateStatus.InitialUpdate:
+ case ExchangeEntryDbUpdateStatus.ReadyUpdate:
+ updateRequestedExplicitly = true;
+ break;
+ case ExchangeEntryDbUpdateStatus.UnavailableUpdate:
+ // Only retry when scheduled to respect backoff
+ break;
+ case ExchangeEntryDbUpdateStatus.Ready:
+ break;
+ default:
+ assertUnreachable(oldExchangeRec.updateStatus);
+ }
+
+ let refreshCheckNecessary = true;
+
+ if (!updateRequestedExplicitly) {
+ // If the update wasn't requested explicitly,
+ // check if we really need to update.
+
+ let nextUpdateStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextUpdateStamp,
+ );
+
+ let nextRefreshCheckStamp = timestampAbsoluteFromDb(
+ oldExchangeRec.nextRefreshCheckStamp,
+ );
+
+ let updateNecessary = true;
+
+ if (
+ !AbsoluteTime.isNever(nextUpdateStamp) &&
+ !AbsoluteTime.isExpired(nextUpdateStamp)
+ ) {
+ logger.info(
+ `exchange update for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextUpdateStamp,
+ )}`,
+ );
+ updateNecessary = false;
+ }
+
+ if (
+ !AbsoluteTime.isNever(nextRefreshCheckStamp) &&
+ !AbsoluteTime.isExpired(nextRefreshCheckStamp)
+ ) {
+ logger.info(
+ `exchange refresh check for ${exchangeBaseUrl} not necessary, scheduled for ${AbsoluteTime.toIsoString(
+ nextRefreshCheckStamp,
+ )}`,
+ );
+ refreshCheckNecessary = false;
+ }
+
+ if (!(updateNecessary || refreshCheckNecessary)) {
+ logger.trace("update not necessary, running again later");
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.min(nextUpdateStamp, nextRefreshCheckStamp),
+ );
+ }
+ }
+
+ // When doing the auto-refresh check, we always update
+ // the key info before that.
+
+ logger.trace("updating exchange /keys info");
+
+ const timeout = getExchangeRequestTimeout();
+
+ const keysInfo = await downloadExchangeKeysInfo(
+ exchangeBaseUrl,
+ wex.http,
+ timeout,
+ wex.cancellationToken,
+ oldExchangeRec.cachebreakNextUpdate ?? false,
+ );
+
+ logger.trace("validating exchange wire info");
+
+ const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion);
+ if (!version) {
+ // Should have been validated earlier.
+ throw Error("unexpected invalid version");
+ }
+
+ const wireInfo = await validateWireInfo(
+ wex,
+ version.current,
+ keysInfo,
+ keysInfo.masterPublicKey,
+ );
+
+ const globalFees = await validateGlobalFees(
+ wex,
+ keysInfo.globalFees,
+ keysInfo.masterPublicKey,
+ );
+
+ if (keysInfo.baseUrl != exchangeBaseUrl) {
+ logger.warn("exchange base URL mismatch");
+ const errorDetail: TalerErrorDetail = makeErrorDetail(
+ TalerErrorCode.WALLET_EXCHANGE_BASE_URL_MISMATCH,
+ {
+ urlWallet: exchangeBaseUrl,
+ urlExchange: keysInfo.baseUrl,
+ },
+ );
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail,
+ };
+ }
+
+ logger.trace("finished validating exchange /wire info");
+
+ // We download the text/plain version here,
+ // because that one needs to exist, and we
+ // will get the current etag from the response.
+ const tosDownload = await downloadTosFromAcceptedFormat(
+ wex,
+ exchangeBaseUrl,
+ timeout,
+ ["text/plain"],
+ );
+
+ logger.trace("updating exchange info in database");
+
+ let ageMask = 0;
+ for (const x of keysInfo.currentDenominations) {
+ if (
+ isWithdrawableDenom(x, wex.ws.config.testing.denomselAllowLate) &&
+ x.denomPub.age_mask != 0
+ ) {
+ ageMask = x.denomPub.age_mask;
+ break;
+ }
+ }
+ let noFees = checkNoFees(keysInfo);
+ let peerPaymentsDisabled = checkPeerPaymentsDisabled(keysInfo);
+
+ const updated = await wex.db.runReadWriteTx(
+ [
+ "exchanges",
+ "exchangeDetails",
+ "exchangeSignKeys",
+ "denominations",
+ "coins",
+ "refreshGroups",
+ "recoupGroups",
+ "coinAvailability",
+ "denomLossEvents",
+ ],
+ async (tx) => {
+ const r = await tx.exchanges.get(exchangeBaseUrl);
+ if (!r) {
+ logger.warn(`exchange ${exchangeBaseUrl} no longer present`);
+ return;
+ }
+
+ wex.ws.refreshCostCache.clear();
+ wex.ws.exchangeCache.clear();
+ wex.ws.denomInfoCache.clear();
+
+ const oldExchangeState = getExchangeState(r);
+ const existingDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
+ let detailsPointerChanged = false;
+ if (!existingDetails) {
+ detailsPointerChanged = true;
+ }
+ let detailsIncompatible = false;
+ if (existingDetails) {
+ if (existingDetails.masterPublicKey !== keysInfo.masterPublicKey) {
+ detailsIncompatible = true;
+ detailsPointerChanged = true;
+ }
+ if (existingDetails.currency !== keysInfo.currency) {
+ detailsIncompatible = true;
+ detailsPointerChanged = true;
+ }
+ // FIXME: We need to do some consistency checks!
+ }
+ if (detailsIncompatible) {
+ logger.warn(
+ `exchange ${r.baseUrl} has incompatible data in /keys, not updating`,
+ );
+ // We don't support this gracefully right now.
+ // See https://bugs.taler.net/n/8576
+ r.updateStatus = ExchangeEntryDbUpdateStatus.UnavailableUpdate;
+ r.updateRetryCounter = (r.updateRetryCounter ?? 0) + 1;
+ r.nextUpdateStamp = computeDbBackoff(r.updateRetryCounter);
+ r.nextRefreshCheckStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()),
+ );
+ r.cachebreakNextUpdate = true;
+ await tx.exchanges.put(r);
+ return {
+ oldExchangeState,
+ newExchangeState: getExchangeState(r),
+ };
+ }
+ r.updateRetryCounter = 0;
+ const newDetails: ExchangeDetailsRecord = {
+ auditors: keysInfo.auditors,
+ currency: keysInfo.currency,
+ masterPublicKey: keysInfo.masterPublicKey,
+ protocolVersionRange: keysInfo.protocolVersion,
+ reserveClosingDelay: keysInfo.reserveClosingDelay,
+ globalFees,
+ exchangeBaseUrl: r.baseUrl,
+ wireInfo,
+ ageMask,
+ };
+ r.noFees = noFees;
+ r.peerPaymentsDisabled = peerPaymentsDisabled;
+ r.tosCurrentEtag = tosDownload.tosEtag;
+ if (existingDetails?.rowId) {
+ newDetails.rowId = existingDetails.rowId;
+ }
+ r.lastUpdate = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ r.nextUpdateStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(
+ AbsoluteTime.fromProtocolTimestamp(keysInfo.expiry),
+ ),
+ );
+ // New denominations might be available.
+ r.nextRefreshCheckStamp = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ if (detailsPointerChanged) {
+ r.detailsPointer = {
+ currency: newDetails.currency,
+ masterPublicKey: newDetails.masterPublicKey,
+ updateClock: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+ }
+
+ r.updateStatus = ExchangeEntryDbUpdateStatus.Ready;
+ r.cachebreakNextUpdate = false;
+ await tx.exchanges.put(r);
+ logger.info(`putting new exchange details in DB: ${j2s(newDetails)}`);
+ const drRowId = await tx.exchangeDetails.put(newDetails);
+ checkDbInvariant(typeof drRowId.key === "number");
+
+ for (const sk of keysInfo.signingKeys) {
+ // FIXME: validate signing keys before inserting them
+ await tx.exchangeSignKeys.put({
+ exchangeDetailsRowId: drRowId.key,
+ masterSig: sk.master_sig,
+ signkeyPub: sk.key,
+ stampEnd: timestampProtocolToDb(sk.stamp_end),
+ stampExpire: timestampProtocolToDb(sk.stamp_expire),
+ stampStart: timestampProtocolToDb(sk.stamp_start),
+ });
+ }
+
+ // In the future: Filter out old denominations by index
+ const allOldDenoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ const oldDenomByDph = new Map<string, DenominationRecord>();
+ for (const denom of allOldDenoms) {
+ oldDenomByDph.set(denom.denomPubHash, denom);
+ }
+
+ logger.trace("updating denominations in database");
+ const currentDenomSet = new Set<string>(
+ keysInfo.currentDenominations.map((x) => x.denomPubHash),
+ );
+
+ for (const currentDenom of keysInfo.currentDenominations) {
+ const oldDenom = oldDenomByDph.get(currentDenom.denomPubHash);
+ if (oldDenom) {
+ // FIXME: Do consistency check, report to auditor if necessary.
+ // See https://bugs.taler.net/n/8594
+
+ // Mark lost denominations as lost.
+ if (currentDenom.isLost && !oldDenom.isLost) {
+ logger.warn(
+ `marking denomination ${currentDenom.denomPubHash} of ${exchangeBaseUrl} as lost`,
+ );
+ oldDenom.isLost = true;
+ await tx.denominations.put(currentDenom);
+ }
+ } else {
+ await tx.denominations.put(currentDenom);
+ }
+ }
+
+ // Update list issue date for all denominations,
+ // and mark non-offered denominations as such.
+ for (const x of allOldDenoms) {
+ if (!currentDenomSet.has(x.denomPubHash)) {
+ // FIXME: Here, an auditor report should be created, unless
+ // the denomination is really legally expired.
+ if (x.isOffered) {
+ x.isOffered = false;
+ logger.info(
+ `setting denomination ${x.denomPubHash} to offered=false`,
+ );
+ }
+ } else {
+ if (!x.isOffered) {
+ x.isOffered = true;
+ logger.info(
+ `setting denomination ${x.denomPubHash} to offered=true`,
+ );
+ }
+ }
+ await tx.denominations.put(x);
+ }
+
+ logger.trace("done updating denominations in database");
+
+ const denomLossResult = await handleDenomLoss(
+ wex,
+ tx,
+ newDetails.currency,
+ exchangeBaseUrl,
+ );
+
+ await handleRecoup(wex, tx, exchangeBaseUrl, keysInfo.recoup);
+
+ const newExchangeState = getExchangeState(r);
+
+ return {
+ exchange: r,
+ exchangeDetails: newDetails,
+ oldExchangeState,
+ newExchangeState,
+ denomLossResult,
+ };
+ },
+ );
+
+ if (!updated) {
+ throw Error("something went wrong with updating the exchange");
+ }
+
+ if (updated.denomLossResult) {
+ for (const notif of updated.denomLossResult.notifications) {
+ wex.ws.notify(notif);
+ }
+ }
+
+ logger.trace("done updating exchange info in database");
+
+ logger.trace(`doing auto-refresh check for '${exchangeBaseUrl}'`);
+
+ let minCheckThreshold = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ days: 1 }),
+ );
+
+ if (refreshCheckNecessary) {
+ // Do auto-refresh.
+ await wex.db.runReadWriteTx(
+ [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ "exchanges",
+ ],
+ async (tx) => {
+ const exchange = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchange || !exchange.detailsPointer) {
+ return;
+ }
+ const coins = await tx.coins.indexes.byBaseUrl
+ .iter(exchangeBaseUrl)
+ .toArray();
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const coin of coins) {
+ if (coin.status !== CoinStatus.Fresh) {
+ continue;
+ }
+ const denom = await tx.denominations.get([
+ exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ logger.warn("denomination not in database");
+ continue;
+ }
+ const executeThreshold =
+ getAutoRefreshExecuteThresholdForDenom(denom);
+ if (AbsoluteTime.isExpired(executeThreshold)) {
+ refreshCoins.push({
+ coinPub: coin.coinPub,
+ amount: denom.value,
+ });
+ } else {
+ const checkThreshold = getAutoRefreshCheckThreshold(denom);
+ minCheckThreshold = AbsoluteTime.min(
+ minCheckThreshold,
+ checkThreshold,
+ );
+ }
+ }
+ if (refreshCoins.length > 0) {
+ const res = await createRefreshGroup(
+ wex,
+ tx,
+ exchange.detailsPointer?.currency,
+ refreshCoins,
+ RefreshReason.Scheduled,
+ undefined,
+ );
+ logger.trace(
+ `created refresh group for auto-refresh (${res.refreshGroupId})`,
+ );
+ }
+ logger.trace(
+ `next refresh check at ${AbsoluteTime.toIsoString(
+ minCheckThreshold,
+ )}`,
+ );
+ exchange.nextRefreshCheckStamp = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(minCheckThreshold),
+ );
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(exchange);
+ },
+ );
+ }
+
+ wex.ws.notify({
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: updated.newExchangeState,
+ oldExchangeState: updated.oldExchangeState,
+ });
+
+ // Next invocation will cause the task to be run again
+ // at the necessary time.
+ return TaskRunResult.progress();
+}
+
+interface DenomLossResult {
+ notifications: WalletNotification[];
+}
+
+async function handleDenomLoss(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["coinAvailability", "denominations", "denomLossEvents", "coins"]
+ >,
+ currency: string,
+ exchangeBaseUrl: string,
+): Promise<DenomLossResult> {
+ const coinAvailabilityRecs =
+ await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ const denomsVanished: string[] = [];
+ const denomsUnoffered: string[] = [];
+ const denomsExpired: string[] = [];
+ let amountVanished = Amount.zeroOfCurrency(currency);
+ let amountExpired = Amount.zeroOfCurrency(currency);
+ let amountUnoffered = Amount.zeroOfCurrency(currency);
+
+ const result: DenomLossResult = {
+ notifications: [],
+ };
+
+ for (const coinAv of coinAvailabilityRecs) {
+ if (coinAv.freshCoinCount <= 0) {
+ continue;
+ }
+ const n = coinAv.freshCoinCount;
+ const denom = await tx.denominations.get([
+ coinAv.exchangeBaseUrl,
+ coinAv.denomPubHash,
+ ]);
+ const timestampExpireDeposit = !denom
+ ? undefined
+ : timestampAbsoluteFromDb(denom.stampExpireDeposit);
+ if (!denom) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsVanished.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountVanished = amountVanished.add(total);
+ } else if (!denom.isOffered) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsUnoffered.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountUnoffered = amountUnoffered.add(total);
+ } else if (
+ timestampExpireDeposit &&
+ AbsoluteTime.isExpired(timestampExpireDeposit)
+ ) {
+ // Remove availability
+ coinAv.freshCoinCount = 0;
+ coinAv.visibleCoinCount = 0;
+ await tx.coinAvailability.put(coinAv);
+ denomsExpired.push(coinAv.denomPubHash);
+ const total = Amount.from(coinAv.value).mult(n);
+ amountExpired = amountExpired.add(total);
+ } else {
+ // Denomination is still fine!
+ continue;
+ }
+
+ logger.warn(`denomination ${coinAv.denomPubHash} is a loss`);
+
+ const coins = await tx.coins.indexes.byDenomPubHash.getAll(
+ coinAv.denomPubHash,
+ );
+ for (const coin of coins) {
+ switch (coin.status) {
+ case CoinStatus.Fresh:
+ case CoinStatus.FreshSuspended: {
+ coin.status = CoinStatus.DenomLoss;
+ await tx.coins.put(coin);
+ break;
+ }
+ }
+ }
+ }
+
+ if (denomsVanished.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountVanished.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsVanished,
+ eventType: DenomLossEventType.DenomVanished,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ if (denomsUnoffered.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountUnoffered.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsUnoffered,
+ eventType: DenomLossEventType.DenomUnoffered,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ if (denomsExpired.length > 0) {
+ const denomLossEventId = encodeCrock(getRandomBytes(32));
+ await tx.denomLossEvents.add({
+ denomLossEventId,
+ amount: amountExpired.toString(),
+ currency,
+ exchangeBaseUrl,
+ denomPubHashes: denomsUnoffered,
+ eventType: DenomLossEventType.DenomExpired,
+ status: DenomLossStatus.Done,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ result.notifications.push({
+ type: NotificationType.TransactionStateTransition,
+ transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+ result.notifications.push({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+ }
+
+ return result;
+}
+
+export function computeDenomLossTransactionStatus(
+ rec: DenomLossEventRecord,
+): TransactionState {
+ switch (rec.status) {
+ case DenomLossStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case DenomLossStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ }
+}
+
+export class DenomLossTransactionContext implements TransactionContext {
+ get taskId(): TaskIdStr | undefined {
+ return undefined;
+ }
+ transactionId: TransactionIdStr;
+
+ abortTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ suspendTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ resumeTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ failTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ async deleteTransaction(): Promise<void> {
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ ["denomLossEvents"],
+ async (tx) => {
+ const rec = await tx.denomLossEvents.get(this.denomLossEventId);
+ if (rec) {
+ const oldTxState = computeDenomLossTransactionStatus(rec);
+ await tx.denomLossEvents.delete(this.denomLossEventId);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.Deleted,
+ },
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ }
+
+ constructor(
+ private wex: WalletExecutionContext,
+ public denomLossEventId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId,
+ });
+ }
+}
+
+async function handleRecoup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "recoupGroups", "refreshGroups"]
+ >,
+ exchangeBaseUrl: string,
+ recoup: Recoup[],
+): Promise<void> {
+ // Handle recoup
+ const recoupDenomList = recoup;
+ const newlyRevokedCoinPubs: string[] = [];
+ logger.trace("recoup list from exchange", recoupDenomList);
+ for (const recoupInfo of recoupDenomList) {
+ const oldDenom = await tx.denominations.get([
+ exchangeBaseUrl,
+ recoupInfo.h_denom_pub,
+ ]);
+ if (!oldDenom) {
+ // We never even knew about the revoked denomination, all good.
+ continue;
+ }
+ if (oldDenom.isRevoked) {
+ // We already marked the denomination as revoked,
+ // this implies we revoked all coins
+ logger.trace("denom already revoked");
+ continue;
+ }
+ logger.info("revoking denom", recoupInfo.h_denom_pub);
+ oldDenom.isRevoked = true;
+ await tx.denominations.put(oldDenom);
+ const affectedCoins = await tx.coins.indexes.byDenomPubHash.getAll(
+ recoupInfo.h_denom_pub,
+ );
+ for (const ac of affectedCoins) {
+ newlyRevokedCoinPubs.push(ac.coinPub);
+ }
+ }
+ if (newlyRevokedCoinPubs.length != 0) {
+ logger.info("recouping coins", newlyRevokedCoinPubs);
+ await createRecoupGroup(wex, tx, exchangeBaseUrl, newlyRevokedCoinPubs);
+ }
+}
+
+function getAutoRefreshExecuteThresholdForDenom(
+ d: DenominationRecord,
+): AbsoluteTime {
+ return getAutoRefreshExecuteThreshold({
+ stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
+ stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
+ });
+}
+
+/**
+ * Timestamp after which the wallet would do the next check for an auto-refresh.
+ */
+function getAutoRefreshCheckThreshold(d: DenominationRecord): AbsoluteTime {
+ const expireWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireWithdraw),
+ );
+ const expireDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(d.stampExpireDeposit),
+ );
+ const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit);
+ const deltaDiv = durationMul(delta, 0.75);
+ return AbsoluteTime.addDuration(expireWithdraw, deltaDiv);
+}
+
+/**
+ * Find a payto:// URI of the exchange that is of one
+ * of the given target types.
+ *
+ * Throws if no matching account was found.
+ */
+export async function getExchangePaytoUri(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ supportedTargetTypes: string[],
+): Promise<string> {
+ // We do the update here, since the exchange might not even exist
+ // yet in our database.
+ const details = await wex.db.runReadOnlyTx(
+ ["exchanges", "exchangeDetails"],
+ async (tx) => {
+ return getExchangeRecordsInternal(tx, exchangeBaseUrl);
+ },
+ );
+ const accounts = details?.wireInfo.accounts ?? [];
+ for (const account of accounts) {
+ const res = parsePaytoUri(account.payto_uri);
+ if (!res) {
+ continue;
+ }
+ if (supportedTargetTypes.includes(res.targetType)) {
+ return account.payto_uri;
+ }
+ }
+ throw Error(
+ `no matching account found at exchange ${exchangeBaseUrl} for wire types ${j2s(
+ supportedTargetTypes,
+ )}`,
+ );
+}
+
+/**
+ * Get the exchange ToS in the requested format.
+ * Try to download in the accepted format not cached.
+ */
+export async function getExchangeTos(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ acceptedFormat?: string[],
+ acceptLanguage?: string,
+): Promise<GetExchangeTosResult> {
+ const exch = await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ const tosDownload = await downloadTosFromAcceptedFormat(
+ wex,
+ exchangeBaseUrl,
+ getExchangeRequestTimeout(),
+ acceptedFormat,
+ acceptLanguage,
+ );
+
+ await wex.db.runReadWriteTx(["exchanges"], async (tx) => {
+ const updateExchangeEntry = await tx.exchanges.get(exchangeBaseUrl);
+ if (updateExchangeEntry) {
+ updateExchangeEntry.tosCurrentEtag = tosDownload.tosEtag;
+ wex.ws.exchangeCache.clear();
+ await tx.exchanges.put(updateExchangeEntry);
+ }
+ });
+
+ return {
+ acceptedEtag: exch.tosAcceptedEtag,
+ currentEtag: tosDownload.tosEtag,
+ content: tosDownload.tosText,
+ contentType: tosDownload.tosContentType,
+ contentLanguage: tosDownload.tosContentLanguage,
+ tosStatus: exch.tosStatus,
+ tosAvailableLanguages: tosDownload.tosAvailableLanguages,
+ };
+}
+
+/**
+ * Parsed information about an exchange,
+ * obtained by requesting /keys.
+ */
+export interface ExchangeInfo {
+ keys: ExchangeKeysDownloadResult;
+}
+
+/**
+ * Helper function to download the exchange /keys info.
+ *
+ * Only used for testing / dbless wallet.
+ */
+export async function downloadExchangeInfo(
+ exchangeBaseUrl: string,
+ http: HttpRequestLibrary,
+): Promise<ExchangeInfo> {
+ const keysInfo = await downloadExchangeKeysInfo(
+ exchangeBaseUrl,
+ http,
+ Duration.getForever(),
+ CancellationToken.CONTINUE,
+ false,
+ );
+ return {
+ keys: keysInfo,
+ };
+}
+
+/**
+ * List all exchange entries known to the wallet.
+ */
+export async function listExchanges(
+ wex: WalletExecutionContext,
+): Promise<ExchangesListResponse> {
+ const exchanges: ExchangeListItem[] = [];
+ await wex.db.runReadOnlyTx(
+ [
+ "exchanges",
+ "operationRetries",
+ "exchangeDetails",
+ "globalCurrencyAuditors",
+ "globalCurrencyExchanges",
+ ],
+ async (tx) => {
+ const exchangeRecords = await tx.exchanges.iter().toArray();
+ for (const r of exchangeRecords) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: r.baseUrl,
+ });
+ const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl);
+ const opRetryRecord = await tx.operationRetries.get(taskId);
+ exchanges.push(
+ await makeExchangeListItem(
+ tx,
+ r,
+ exchangeDetails,
+ opRetryRecord?.lastError,
+ ),
+ );
+ }
+ },
+ );
+ return { exchanges };
+}
+
+/**
+ * Transition an exchange to the "used" entry state if necessary.
+ *
+ * Should be called whenever the exchange is actively used by the client (for withdrawals etc.).
+ *
+ * The caller should emit the returned notification iff the current transaction
+ * succeeded.
+ */
+export async function markExchangeUsed(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["exchanges"]>,
+ exchangeBaseUrl: string,
+): Promise<{ notif: WalletNotification | undefined }> {
+ exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl);
+ logger.info(`marking exchange ${exchangeBaseUrl} as used`);
+ const exch = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exch) {
+ return {
+ notif: undefined,
+ };
+ }
+ const oldExchangeState = getExchangeState(exch);
+ switch (exch.entryStatus) {
+ case ExchangeEntryDbRecordStatus.Ephemeral:
+ case ExchangeEntryDbRecordStatus.Preset: {
+ exch.entryStatus = ExchangeEntryDbRecordStatus.Used;
+ await tx.exchanges.put(exch);
+ const newExchangeState = getExchangeState(exch);
+ return {
+ notif: {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl,
+ newExchangeState: newExchangeState,
+ oldExchangeState: oldExchangeState,
+ } satisfies WalletNotification,
+ };
+ }
+ default:
+ return {
+ notif: undefined,
+ };
+ }
+}
+
+/**
+ * Get detailed information about the exchange including a timeline
+ * for the fees charged by the exchange.
+ */
+export async function getExchangeDetailedInfo(
+ wex: WalletExecutionContext,
+ exchangeBaseurl: string,
+): Promise<ExchangeDetailedResponse> {
+ const exchange = await wex.db.runReadOnlyTx(
+ ["exchanges", "exchangeDetails", "denominations"],
+ async (tx) => {
+ const ex = await tx.exchanges.get(exchangeBaseurl);
+ const dp = ex?.detailsPointer;
+ if (!dp) {
+ return;
+ }
+ const { currency } = dp;
+ const exchangeDetails = await getExchangeRecordsInternal(tx, ex.baseUrl);
+ if (!exchangeDetails) {
+ return;
+ }
+ const denominationRecords =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(ex.baseUrl);
+
+ if (!denominationRecords) {
+ return;
+ }
+
+ const denominations: DenominationInfo[] = denominationRecords.map((x) =>
+ DenominationRecord.toDenomInfo(x),
+ );
+
+ return {
+ info: {
+ exchangeBaseUrl: ex.baseUrl,
+ currency,
+ paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
+ auditors: exchangeDetails.auditors,
+ wireInfo: exchangeDetails.wireInfo,
+ globalFees: exchangeDetails.globalFees,
+ },
+ denominations,
+ };
+ },
+ );
+
+ if (!exchange) {
+ throw Error(`exchange with base url "${exchangeBaseurl}" not found`);
+ }
+
+ const denoms = exchange.denominations.map((d) => ({
+ ...d,
+ group: Amounts.stringifyValue(d.value),
+ }));
+ const denomFees: DenomOperationMap<FeeDescription[]> = {
+ deposit: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireDeposit",
+ "feeDeposit",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ refresh: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeRefresh",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ refund: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeRefund",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ withdraw: createTimeline(
+ denoms,
+ "denomPubHash",
+ "stampStart",
+ "stampExpireWithdraw",
+ "feeWithdraw",
+ "group",
+ selectBestForOverlappingDenominations,
+ ),
+ };
+
+ const transferFees = Object.entries(
+ exchange.info.wireInfo.feesForType,
+ ).reduce(
+ (prev, [wireType, infoForType]) => {
+ const feesByGroup = [
+ ...infoForType.map((w) => ({
+ ...w,
+ fee: Amounts.stringify(w.closingFee),
+ group: "closing",
+ })),
+ ...infoForType.map((w) => ({ ...w, fee: w.wireFee, group: "wire" })),
+ ];
+ prev[wireType] = createTimeline(
+ feesByGroup,
+ "sig",
+ "startStamp",
+ "endStamp",
+ "fee",
+ "group",
+ selectMinimumFee,
+ );
+ return prev;
+ },
+ {} as Record<string, FeeDescription[]>,
+ );
+
+ const globalFeesByGroup = [
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.accountFee,
+ group: "account",
+ })),
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.historyFee,
+ group: "history",
+ })),
+ ...exchange.info.globalFees.map((w) => ({
+ ...w,
+ fee: w.purseFee,
+ group: "purse",
+ })),
+ ];
+
+ const globalFees = createTimeline(
+ globalFeesByGroup,
+ "signature",
+ "startDate",
+ "endDate",
+ "fee",
+ "group",
+ selectMinimumFee,
+ );
+
+ return {
+ exchange: {
+ ...exchange.info,
+ denomFees,
+ transferFees,
+ globalFees,
+ },
+ };
+}
+
+async function internalGetExchangeResources(
+ wex: WalletExecutionContext,
+ tx: DbReadOnlyTransaction<
+ typeof WalletStoresV1,
+ ["exchanges", "coins", "withdrawalGroups"]
+ >,
+ exchangeBaseUrl: string,
+): Promise<GetExchangeResourcesResponse> {
+ let numWithdrawals = 0;
+ let numCoins = 0;
+ numCoins = await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl);
+ numWithdrawals =
+ await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count(exchangeBaseUrl);
+ const total = numWithdrawals + numCoins;
+ return {
+ hasResources: total != 0,
+ };
+}
+
+/**
+ * Purge information in the database associated with the exchange.
+ *
+ * Deletes information specific to the exchange and withdrawals,
+ * but keeps some transactions (payments, p2p, refreshes) around.
+ */
+async function purgeExchange(
+ tx: WalletDbReadWriteTransaction<
+ [
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "coinAvailability",
+ "coins",
+ "denominations",
+ "exchangeSignKeys",
+ "withdrawalGroups",
+ "planchets",
+ ]
+ >,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ const detRecs = await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll();
+ for (const r of detRecs) {
+ if (r.rowId == null) {
+ // Should never happen, as rowId is the primary key.
+ continue;
+ }
+ await tx.exchangeDetails.delete(r.rowId);
+ const signkeyRecs =
+ await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll(r.rowId);
+ for (const rec of signkeyRecs) {
+ await tx.exchangeSignKeys.delete([r.rowId, rec.signkeyPub]);
+ }
+ }
+ // FIXME: Also remove records related to transactions?
+ await tx.exchanges.delete(exchangeBaseUrl);
+
+ {
+ const coinAvailabilityRecs =
+ await tx.coinAvailability.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const rec of coinAvailabilityRecs) {
+ await tx.coinAvailability.delete([
+ exchangeBaseUrl,
+ rec.denomPubHash,
+ rec.maxAge,
+ ]);
+ }
+ }
+
+ {
+ const coinRecs = await tx.coins.indexes.byBaseUrl.getAll(exchangeBaseUrl);
+ for (const rec of coinRecs) {
+ await tx.coins.delete(rec.coinPub);
+ }
+ }
+
+ {
+ const denomRecs =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ for (const rec of denomRecs) {
+ await tx.denominations.delete(rec.denomPubHash);
+ }
+ }
+
+ {
+ const withdrawalGroupRecs =
+ await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const wg of withdrawalGroupRecs) {
+ await tx.withdrawalGroups.delete(wg.withdrawalGroupId);
+ const planchets = await tx.planchets.indexes.byGroup.getAll(
+ wg.withdrawalGroupId,
+ );
+ for (const p of planchets) {
+ await tx.planchets.delete(p.coinPub);
+ }
+ }
+ }
+}
+
+export async function deleteExchange(
+ wex: WalletExecutionContext,
+ req: DeleteExchangeRequest,
+): Promise<void> {
+ let inUse: boolean = false;
+ const exchangeBaseUrl = canonicalizeBaseUrl(req.exchangeBaseUrl);
+ await wex.db.runReadWriteTx(
+ [
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "coinAvailability",
+ "coins",
+ "denominations",
+ "exchangeSignKeys",
+ "withdrawalGroups",
+ "planchets",
+ ],
+ async (tx) => {
+ const exchangeRec = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRec) {
+ // Nothing to delete!
+ logger.info("no exchange found to delete");
+ return;
+ }
+ const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl);
+ if (res.hasResources && !req.purge) {
+ inUse = true;
+ return;
+ }
+ await purgeExchange(tx, exchangeBaseUrl);
+ wex.ws.exchangeCache.clear();
+ },
+ );
+
+ if (inUse) {
+ throw TalerError.fromUncheckedDetail({
+ code: TalerErrorCode.WALLET_EXCHANGE_ENTRY_USED,
+ hint: "Exchange in use.",
+ });
+ }
+}
+
+export async function getExchangeResources(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<GetExchangeResourcesResponse> {
+ // Withdrawals include internal withdrawals from peer transactions
+ const res = await wex.db.runReadOnlyTx(
+ ["exchanges", "withdrawalGroups", "coins"],
+ async (tx) => {
+ const exchangeRecord = await tx.exchanges.get(exchangeBaseUrl);
+ if (!exchangeRecord) {
+ return undefined;
+ }
+ return internalGetExchangeResources(wex, tx, exchangeBaseUrl);
+ },
+ );
+ if (!res) {
+ throw Error("exchange not found");
+ }
+ return res;
+}
diff --git a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts b/packages/taler-wallet-core/src/headless/NodeHttpLib.ts
deleted file mode 100644
index 5a90994b1..000000000
--- a/packages/taler-wallet-core/src/headless/NodeHttpLib.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-
- SPDX-License-Identifier: AGPL3.0-or-later
-*/
-
-/**
- * Imports.
- */
-import {
- Headers,
- HttpRequestLibrary,
- HttpRequestOptions,
- HttpResponse,
-} from "../util/http.js";
-import { RequestThrottler } from "../util/RequestThrottler.js";
-import Axios, { AxiosResponse } from "axios";
-import { OperationFailedError, makeErrorDetails } from "../errors.js";
-import { Logger, bytesToString } from "@gnu-taler/taler-util";
-import { TalerErrorCode, URL } from "@gnu-taler/taler-util";
-
-const logger = new Logger("NodeHttpLib.ts");
-
-/**
- * Implementation of the HTTP request library interface for node.
- */
-export class NodeHttpLib implements HttpRequestLibrary {
- private throttle = new RequestThrottler();
- private throttlingEnabled = true;
-
- /**
- * Set whether requests should be throttled.
- */
- setThrottling(enabled: boolean): void {
- this.throttlingEnabled = enabled;
- }
-
- async fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
- const method = opt?.method ?? "GET";
- let body = opt?.body;
-
- logger.trace(`Requesting ${method} ${url}`);
-
- const parsedUrl = new URL(url);
- if (this.throttlingEnabled && this.throttle.applyThrottle(url)) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
- `request to origin ${parsedUrl.origin} was throttled`,
- {
- requestMethod: method,
- requestUrl: url,
- throttleStats: this.throttle.getThrottleStats(url),
- },
- );
- }
- let timeout: number | undefined;
- if (typeof opt?.timeout?.d_ms === "number") {
- timeout = opt.timeout.d_ms;
- }
- let resp: AxiosResponse;
- try {
- resp = await Axios({
- method,
- url: url,
- responseType: "arraybuffer",
- headers: opt?.headers,
- validateStatus: () => true,
- transformResponse: (x) => x,
- data: body,
- timeout,
- maxRedirects: 0,
- });
- } catch (e: any) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_NETWORK_ERROR,
- `${e.message}`,
- {
- requestUrl: url,
- requestMethod: method,
- },
- );
- }
-
- const makeText = async (): Promise<string> => {
- const respText = new Uint8Array(resp.data);
- return bytesToString(respText);
- };
-
- const makeJson = async (): Promise<any> => {
- let responseJson;
- const respText = await makeText();
- try {
- responseJson = JSON.parse(respText);
- } catch (e) {
- logger.trace(`invalid json: '${resp.data}'`);
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "invalid JSON",
- {
- httpStatusCode: resp.status,
- requestUrl: url,
- requestMethod: method,
- },
- ),
- );
- }
- if (responseJson === null || typeof responseJson !== "object") {
- logger.trace(`invalid json (not an object): '${respText}'`);
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "invalid JSON",
- {
- httpStatusCode: resp.status,
- requestUrl: url,
- requestMethod: method,
- },
- ),
- );
- }
- return responseJson;
- };
- const makeBytes = async () => {
- if (typeof resp.data.byteLength !== "number") {
- throw Error("expected array buffer");
- }
- const buf = resp.data;
- return buf;
- };
- const headers = new Headers();
- for (const hn of Object.keys(resp.headers)) {
- headers.set(hn, resp.headers[hn]);
- }
- return {
- requestUrl: url,
- requestMethod: method,
- headers,
- status: resp.status,
- text: makeText,
- json: makeJson,
- bytes: makeBytes,
- };
- }
- async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
- return this.fetch(url, {
- method: "GET",
- ...opt,
- });
- }
-
- async postJson(
- url: string,
- body: any,
- opt?: HttpRequestOptions,
- ): Promise<HttpResponse> {
- return this.fetch(url, {
- method: "POST",
- body,
- ...opt,
- });
- }
-}
diff --git a/packages/taler-wallet-core/src/headless/helpers.ts b/packages/taler-wallet-core/src/headless/helpers.ts
deleted file mode 100644
index f2285e149..000000000
--- a/packages/taler-wallet-core/src/headless/helpers.ts
+++ /dev/null
@@ -1,164 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Helpers to create headless wallets.
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- MemoryBackend,
- BridgeIDBFactory,
- shimIndexedDB,
-} from "@gnu-taler/idb-bridge";
-import { openTalerDatabase } from "../db-utils.js";
-import { HttpRequestLibrary } from "../util/http.js";
-import { NodeThreadCryptoWorkerFactory } from "../crypto/workers/nodeThreadWorker.js";
-import { NodeHttpLib } from "./NodeHttpLib.js";
-import { Logger } from "@gnu-taler/taler-util";
-import { SynchronousCryptoWorkerFactory } from "../crypto/workers/synchronousWorker.js";
-import type { IDBFactory } from "@gnu-taler/idb-bridge";
-import { WalletNotification } from "@gnu-taler/taler-util";
-import { Wallet } from "../wallet.js";
-import * as fs from "fs";
-
-const logger = new Logger("headless/helpers.ts");
-
-export interface DefaultNodeWalletArgs {
- /**
- * Location of the wallet database.
- *
- * If not specified, the wallet starts out with an empty database and
- * the wallet database is stored only in memory.
- */
- persistentStoragePath?: string;
-
- /**
- * Handler for asynchronous notifications from the wallet.
- */
- notifyHandler?: (n: WalletNotification) => void;
-
- /**
- * If specified, use this as HTTP request library instead
- * of the default one.
- */
- httpLib?: HttpRequestLibrary;
-}
-
-/**
- * Generate a random alphanumeric ID. Does *not* use cryptographically
- * secure randomness.
- */
-function makeId(length: number): string {
- let result = "";
- const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
- for (let i = 0; i < length; i++) {
- result += characters.charAt(Math.floor(Math.random() * characters.length));
- }
- return result;
-}
-
-/**
- * Get a wallet instance with default settings for node.
- */
-export async function getDefaultNodeWallet(
- args: DefaultNodeWalletArgs = {},
-): Promise<Wallet> {
- BridgeIDBFactory.enableTracing = false;
- const myBackend = new MemoryBackend();
- myBackend.enableTracing = false;
-
- const storagePath = args.persistentStoragePath;
- if (storagePath) {
- try {
- const dbContentStr: string = fs.readFileSync(storagePath, {
- encoding: "utf-8",
- });
- const dbContent = JSON.parse(dbContentStr);
- myBackend.importDump(dbContent);
- } catch (e: any) {
- const code: string = e.code;
- if (code === "ENOENT") {
- logger.trace("wallet file doesn't exist yet");
- } else {
- logger.error("could not open wallet database file");
- throw e;
- }
- }
-
- myBackend.afterCommitCallback = async () => {
- logger.trace("committing database");
- // Allow caller to stop persisting the wallet.
- if (args.persistentStoragePath === undefined) {
- return;
- }
- const tmpPath = `${args.persistentStoragePath}-${makeId(5)}.tmp`;
- const dbContent = myBackend.exportDump();
- fs.writeFileSync(tmpPath, JSON.stringify(dbContent, undefined, 2), {
- encoding: "utf-8",
- });
- // Atomically move the temporary file onto the DB path.
- fs.renameSync(tmpPath, args.persistentStoragePath);
- };
- }
-
- BridgeIDBFactory.enableTracing = false;
-
- const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
- const myIdbFactory: IDBFactory = (myBridgeIdbFactory as any) as IDBFactory;
-
- let myHttpLib;
- if (args.httpLib) {
- myHttpLib = args.httpLib;
- } else {
- myHttpLib = new NodeHttpLib();
- }
-
- const myVersionChange = (): Promise<void> => {
- logger.error("version change requested, should not happen");
- throw Error(
- "BUG: wallet DB version change event can't happen with memory IDB",
- );
- };
-
- shimIndexedDB(myBridgeIdbFactory);
-
- const myDb = await openTalerDatabase(myIdbFactory, myVersionChange);
-
- let workerFactory;
- try {
- // Try if we have worker threads available, fails in older node versions.
- const _r = "require";
- const worker_threads = module[_r]("worker_threads");
- // require("worker_threads");
- workerFactory = new NodeThreadCryptoWorkerFactory();
- } catch (e) {
- logger.warn(
- "worker threads not available, falling back to synchronous workers",
- );
- workerFactory = new SynchronousCryptoWorkerFactory();
- }
-
- const w = await Wallet.create(myDb, myHttpLib, workerFactory);
-
- if (args.notifyHandler) {
- w.addNotificationListener(args.notifyHandler);
- }
- return w;
-}
diff --git a/packages/taler-wallet-core/src/host-common.ts b/packages/taler-wallet-core/src/host-common.ts
new file mode 100644
index 000000000..7651e5a12
--- /dev/null
+++ b/packages/taler-wallet-core/src/host-common.ts
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { WalletNotification } from "@gnu-taler/taler-util";
+import { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
+
+/**
+ * Helpers to initiate a wallet in a host environment.
+ */
+
+/**
+ */
+export interface DefaultNodeWalletArgs {
+ /**
+ * Location of the wallet database.
+ *
+ * If not specified, the wallet starts out with an empty database and
+ * the wallet database is stored only in memory.
+ */
+ persistentStoragePath?: string;
+
+ /**
+ * Handler for asynchronous notifications from the wallet.
+ */
+ notifyHandler?: (n: WalletNotification) => void;
+
+ /**
+ * If specified, use this as HTTP request library instead
+ * of the default one.
+ */
+ httpLib?: HttpRequestLibrary;
+
+ cryptoWorkerType?: "sync" | "node-worker-thread";
+}
+
+/**
+ * Generate a random alphanumeric ID. Does *not* use cryptographically
+ * secure randomness.
+ */
+export function makeTempfileId(length: number): string {
+ let result = "";
+ const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
+ for (let i = 0; i < length; i++) {
+ result += characters.charAt(Math.floor(Math.random() * characters.length));
+ }
+ return result;
+}
diff --git a/packages/taler-wallet-core/src/host-impl.missing.ts b/packages/taler-wallet-core/src/host-impl.missing.ts
new file mode 100644
index 000000000..464a5af15
--- /dev/null
+++ b/packages/taler-wallet-core/src/host-impl.missing.ts
@@ -0,0 +1,41 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Helpers to create headless wallets.
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import type { AccessStats, IDBFactory } from "@gnu-taler/idb-bridge";
+import { DefaultNodeWalletArgs } from "./host-common.js";
+import { Wallet } from "./index.js";
+
+/**
+ * Get a wallet instance with default settings for node.
+ *
+ * Extended version that allows getting DB stats.
+ */
+export async function createNativeWalletHost2(
+ args: DefaultNodeWalletArgs = {},
+): Promise<{
+ wallet: Wallet;
+ getDbStats: () => AccessStats;
+}> {
+ throw Error("not implemented");
+}
diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts
new file mode 100644
index 000000000..ec026b296
--- /dev/null
+++ b/packages/taler-wallet-core/src/host-impl.node.ts
@@ -0,0 +1,212 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Helpers to create headless wallets.
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import type { IDBFactory } from "@gnu-taler/idb-bridge";
+// eslint-disable-next-line no-duplicate-imports
+import {
+ AccessStats,
+ BridgeIDBFactory,
+ MemoryBackend,
+ createSqliteBackend,
+ shimIndexedDB,
+} from "@gnu-taler/idb-bridge";
+import { createNodeSqlite3Impl } from "@gnu-taler/idb-bridge/node-sqlite3-bindings";
+import {
+ Logger,
+ SetTimeoutTimerAPI,
+ WalletRunConfig,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import * as fs from "fs";
+import { NodeThreadCryptoWorkerFactory } from "./crypto/workers/nodeThreadWorker.js";
+import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
+import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js";
+import { Wallet } from "./wallet.js";
+
+const logger = new Logger("host-impl.node.ts");
+
+interface MakeDbResult {
+ idbFactory: BridgeIDBFactory;
+ getStats: () => AccessStats;
+}
+
+async function makeFileDb(
+ args: DefaultNodeWalletArgs = {},
+): Promise<MakeDbResult> {
+ const myBackend = new MemoryBackend();
+ myBackend.enableTracing = false;
+ const storagePath = args.persistentStoragePath;
+ if (storagePath) {
+ try {
+ const dbContentStr: string = fs.readFileSync(storagePath, {
+ encoding: "utf-8",
+ });
+ const dbContent = JSON.parse(dbContentStr);
+ myBackend.importDump(dbContent);
+ } catch (e: any) {
+ const code: string = e.code;
+ if (code === "ENOENT") {
+ logger.trace("wallet file doesn't exist yet");
+ } else {
+ logger.error("could not open wallet database file");
+ throw Error(
+ "could not open wallet database file",
+ // @ts-expect-error no support for options.cause yet
+ { cause: e },
+ );
+ }
+ }
+
+ myBackend.afterCommitCallback = async () => {
+ logger.trace("committing database");
+ // Allow caller to stop persisting the wallet.
+ if (args.persistentStoragePath === undefined) {
+ return;
+ }
+ const tmpPath = `${args.persistentStoragePath}-${makeTempfileId(5)}.tmp`;
+ logger.trace("exported DB dump");
+ const dbContent = myBackend.exportDump();
+ fs.writeFileSync(tmpPath, JSON.stringify(dbContent, undefined, 2), {
+ encoding: "utf-8",
+ });
+ // Atomically move the temporary file onto the DB path.
+ fs.renameSync(tmpPath, args.persistentStoragePath);
+ logger.trace("committing database done");
+ };
+ }
+
+ BridgeIDBFactory.enableTracing = false;
+
+ const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
+ return {
+ idbFactory: myBridgeIdbFactory,
+ getStats: () => myBackend.accessStats,
+ };
+}
+
+async function makeSqliteDb(
+ args: DefaultNodeWalletArgs,
+): Promise<MakeDbResult> {
+ BridgeIDBFactory.enableTracing = false;
+ const imp = await createNodeSqlite3Impl();
+ const dbFilename = args.persistentStoragePath ?? ":memory:";
+ logger.info(`using database ${dbFilename}`);
+ const myBackend = await createSqliteBackend(imp, {
+ filename: dbFilename,
+ });
+ myBackend.enableTracing = false;
+ if (process.env.TALER_WALLET_DBSTATS) {
+ myBackend.trackStats = true;
+ }
+ const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
+ return {
+ getStats() {
+ return myBackend.accessStats;
+ },
+ idbFactory: myBridgeIdbFactory,
+ };
+}
+
+/**
+ * Get a wallet instance with default settings for node.
+ *
+ * Extended version that allows getting DB stats.
+ */
+export async function createNativeWalletHost2(
+ args: DefaultNodeWalletArgs = {},
+): Promise<{
+ wallet: Wallet;
+ getDbStats: () => AccessStats;
+}> {
+ const myHttpFactory = (config: WalletRunConfig) => {
+ let myHttpLib;
+ if (args.httpLib) {
+ myHttpLib = args.httpLib;
+ } else {
+ myHttpLib = createPlatformHttpLib({
+ enableThrottling: true,
+ requireTls: !config.features.allowHttp,
+ });
+ }
+ return myHttpLib;
+ };
+
+ let dbResp: MakeDbResult;
+
+ if (
+ args.persistentStoragePath &&
+ args.persistentStoragePath.endsWith(".json")
+ ) {
+ logger.info("using JSON file DB backend (slow, only use for testing)");
+ dbResp = await makeFileDb(args);
+ } else {
+ logger.info(`using sqlite3 DB backend`);
+ dbResp = await makeSqliteDb(args);
+ }
+
+ const myIdbFactory: IDBFactory = dbResp.idbFactory as any as IDBFactory;
+
+ shimIndexedDB(dbResp.idbFactory);
+
+ let workerFactory;
+ const cryptoWorkerType = args.cryptoWorkerType ?? "node-worker-thread";
+ if (cryptoWorkerType === "sync") {
+ logger.info("using synchronous crypto worker");
+ workerFactory = new SynchronousCryptoWorkerFactoryPlain();
+ } else if (cryptoWorkerType === "node-worker-thread") {
+ try {
+ // Try if we have worker threads available, fails in older node versions.
+ const _r = "require";
+ // eslint-disable-next-line no-unused-vars
+ const worker_threads = module[_r]("worker_threads");
+ // require("worker_threads");
+ workerFactory = new NodeThreadCryptoWorkerFactory();
+ logger.info("using node thread crypto worker");
+ } catch (e) {
+ logger.warn(
+ "worker threads not available, falling back to synchronous workers",
+ );
+ workerFactory = new SynchronousCryptoWorkerFactoryPlain();
+ }
+ } else {
+ throw Error(`unsupported crypto worker type '${cryptoWorkerType}'`);
+ }
+
+ const timer = new SetTimeoutTimerAPI();
+
+ const w = await Wallet.create(
+ myIdbFactory,
+ myHttpFactory,
+ timer,
+ workerFactory,
+ );
+
+ if (args.notifyHandler) {
+ w.addNotificationListener(args.notifyHandler);
+ }
+ return {
+ wallet: w,
+ getDbStats: dbResp.getStats,
+ };
+}
diff --git a/packages/taler-wallet-core/src/host-impl.qtart.ts b/packages/taler-wallet-core/src/host-impl.qtart.ts
new file mode 100644
index 000000000..9c985d0c1
--- /dev/null
+++ b/packages/taler-wallet-core/src/host-impl.qtart.ts
@@ -0,0 +1,219 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Helpers to create headless wallets.
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import type {
+ IDBFactory,
+ ResultRow,
+ Sqlite3Interface,
+ Sqlite3Statement,
+} from "@gnu-taler/idb-bridge";
+// eslint-disable-next-line no-duplicate-imports
+import {
+ AccessStats,
+ BridgeIDBFactory,
+ MemoryBackend,
+ createSqliteBackend,
+ shimIndexedDB,
+} from "@gnu-taler/idb-bridge";
+import {
+ Logger,
+ SetTimeoutTimerAPI,
+ WalletRunConfig,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import { qjsOs, qjsStd } from "@gnu-taler/taler-util/qtart";
+import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
+import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js";
+import { Wallet } from "./wallet.js";
+
+const logger = new Logger("host-impl.qtart.ts");
+
+interface MakeDbResult {
+ idbFactory: BridgeIDBFactory;
+ getStats: () => AccessStats;
+}
+
+let numStmt = 0;
+
+export async function createQtartSqlite3Impl(): Promise<Sqlite3Interface> {
+ const tart: any = (globalThis as any)._tart;
+ if (!tart) {
+ throw Error("globalThis._qtart not defined");
+ }
+ return {
+ open(filename: string) {
+ const internalDbHandle = tart.sqlite3Open(filename);
+ return {
+ internalDbHandle,
+ close() {
+ tart.sqlite3Close(internalDbHandle);
+ },
+ prepare(stmtStr): Sqlite3Statement {
+ const stmtHandle = tart.sqlite3Prepare(internalDbHandle, stmtStr);
+ return {
+ internalStatement: stmtHandle,
+ getAll(params): ResultRow[] {
+ numStmt++;
+ return tart.sqlite3StmtGetAll(stmtHandle, params);
+ },
+ getFirst(params): ResultRow | undefined {
+ numStmt++;
+ return tart.sqlite3StmtGetFirst(stmtHandle, params);
+ },
+ run(params) {
+ numStmt++;
+ return tart.sqlite3StmtRun(stmtHandle, params);
+ },
+ };
+ },
+ exec(sqlStr): void {
+ numStmt++;
+ tart.sqlite3Exec(internalDbHandle, sqlStr);
+ },
+ };
+ },
+ };
+}
+
+async function makeSqliteDb(
+ args: DefaultNodeWalletArgs,
+): Promise<MakeDbResult> {
+ BridgeIDBFactory.enableTracing = false;
+ const imp = await createQtartSqlite3Impl();
+ const myBackend = await createSqliteBackend(imp, {
+ filename: args.persistentStoragePath ?? ":memory:",
+ });
+ myBackend.trackStats = true;
+ myBackend.enableTracing = false;
+ const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
+ return {
+ getStats() {
+ return {
+ ...myBackend.accessStats,
+ primitiveStatements: numStmt,
+ };
+ },
+ idbFactory: myBridgeIdbFactory,
+ };
+}
+
+async function makeFileDb(
+ args: DefaultNodeWalletArgs = {},
+): Promise<MakeDbResult> {
+ BridgeIDBFactory.enableTracing = false;
+ const myBackend = new MemoryBackend();
+ myBackend.enableTracing = false;
+
+ const storagePath = args.persistentStoragePath;
+ if (storagePath) {
+ const dbContentStr = qjsStd.loadFile(storagePath);
+ if (dbContentStr != null) {
+ const dbContent = JSON.parse(dbContentStr);
+ myBackend.importDump(dbContent);
+ }
+
+ myBackend.afterCommitCallback = async () => {
+ logger.trace("committing database");
+ // Allow caller to stop persisting the wallet.
+ if (args.persistentStoragePath === undefined) {
+ return;
+ }
+ const tmpPath = `${args.persistentStoragePath}-${makeTempfileId(5)}.tmp`;
+ const dbContent = myBackend.exportDump();
+ logger.trace("exported DB dump");
+ qjsStd.writeFile(tmpPath, JSON.stringify(dbContent, undefined, 2));
+ // Atomically move the temporary file onto the DB path.
+ const res = qjsOs.rename(tmpPath, args.persistentStoragePath);
+ if (res != 0) {
+ throw Error("db commit failed at rename");
+ }
+ logger.trace("committing database done");
+ };
+ }
+
+ const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
+ return {
+ idbFactory: myBridgeIdbFactory,
+ getStats: () => myBackend.accessStats,
+ };
+}
+
+export async function createNativeWalletHost2(
+ args: DefaultNodeWalletArgs = {},
+): Promise<{
+ wallet: Wallet;
+ getDbStats: () => AccessStats;
+}> {
+ BridgeIDBFactory.enableTracing = false;
+
+ let dbResp: MakeDbResult;
+
+ if (
+ args.persistentStoragePath &&
+ args.persistentStoragePath.endsWith(".json")
+ ) {
+ logger.info("using JSON file DB backend (slow, only use for testing)");
+ dbResp = await makeFileDb(args);
+ } else {
+ logger.info("using sqlite3 DB backend");
+ dbResp = await makeSqliteDb(args);
+ }
+
+ const myIdbFactory: IDBFactory = dbResp.idbFactory as any as IDBFactory;
+
+ shimIndexedDB(dbResp.idbFactory);
+
+ const myHttpFactory = (config: WalletRunConfig) => {
+ let myHttpLib;
+ if (args.httpLib) {
+ myHttpLib = args.httpLib;
+ } else {
+ myHttpLib = createPlatformHttpLib({
+ enableThrottling: true,
+ requireTls: !config.features.allowHttp,
+ });
+ }
+ return myHttpLib;
+ };
+
+ let workerFactory;
+ workerFactory = new SynchronousCryptoWorkerFactoryPlain();
+
+ const timer = new SetTimeoutTimerAPI();
+
+ const w = await Wallet.create(
+ myIdbFactory,
+ myHttpFactory,
+ timer,
+ workerFactory,
+ );
+
+ if (args.notifyHandler) {
+ w.addNotificationListener(args.notifyHandler);
+ }
+ return {
+ wallet: w,
+ getDbStats: dbResp.getStats,
+ };
+}
diff --git a/packages/taler-wallet-core/src/host.ts b/packages/taler-wallet-core/src/host.ts
new file mode 100644
index 000000000..feccf42a6
--- /dev/null
+++ b/packages/taler-wallet-core/src/host.ts
@@ -0,0 +1,46 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { DefaultNodeWalletArgs } from "./host-common.js";
+import { Wallet } from "./index.js";
+import * as hostImpl from "#host-impl";
+import { AccessStats } from "@gnu-taler/idb-bridge";
+
+/**
+ * Helpers to initiate a wallet in a host environment.
+ */
+
+/**
+ * Get a wallet instance.
+ */
+export async function createNativeWalletHost2(
+ args: DefaultNodeWalletArgs = {},
+): Promise<{
+ wallet: Wallet;
+ getDbStats: () => AccessStats;
+}> {
+ return hostImpl.createNativeWalletHost2(args);
+}
+
+/**
+ * Get a wallet instance.
+ */
+export async function createNativeWalletHost(
+ args: DefaultNodeWalletArgs = {},
+): Promise<Wallet> {
+ const res = await hostImpl.createNativeWalletHost2(args);
+ return res.wallet;
+}
diff --git a/packages/taler-wallet-core/src/index.browser.ts b/packages/taler-wallet-core/src/index.browser.ts
index 88ea52479..9409673a0 100644
--- a/packages/taler-wallet-core/src/index.browser.ts
+++ b/packages/taler-wallet-core/src/index.browser.ts
@@ -15,3 +15,4 @@
*/
export * from "./index.js";
+export { SynchronousCryptoWorkerPlain } from "./crypto/workers/synchronousWorkerPlain.js";
diff --git a/packages/taler-wallet-core/src/index.node.ts b/packages/taler-wallet-core/src/index.node.ts
index 0860ccc26..13392d39c 100644
--- a/packages/taler-wallet-core/src/index.node.ts
+++ b/packages/taler-wallet-core/src/index.node.ts
@@ -16,10 +16,8 @@
export * from "./index.js";
-// Utils for using the wallet under node
-export { NodeHttpLib } from "./headless/NodeHttpLib.js";
-export {
- getDefaultNodeWallet,
- DefaultNodeWalletArgs,
-} from "./headless/helpers.js";
export * from "./crypto/workers/nodeThreadWorker.js";
+export { SynchronousCryptoWorkerPlain } from "./crypto/workers/synchronousWorkerPlain.js";
+
+export type { AccessStats } from "@gnu-taler/idb-bridge";
+export * from "./crypto/workers/synchronousWorkerFactoryPlain.js";
diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts
index 0b360a248..fe2d3af15 100644
--- a/packages/taler-wallet-core/src/index.ts
+++ b/packages/taler-wallet-core/src/index.ts
@@ -18,31 +18,29 @@
* Module entry point for the wallet when used as a node module.
*/
-// Errors
-export * from "./errors.js";
-
-// Util functionality
-export * from "./util/promiseUtils.js";
-export * from "./util/query.js";
-export * from "./util/http.js";
-
+export * from "./crypto/cryptoImplementation.js";
+export * from "./crypto/cryptoTypes.js";
+export {
+ CryptoDispatcher,
+ CryptoWorkerFactory,
+} from "./crypto/workers/crypto-dispatcher.js";
+export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js";
+export { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
+export * from "./host-common.js";
+export * from "./host.js";
export * from "./versions.js";
-
-export * from "./db.js";
-export * from "./db-utils.js";
-
-// Crypto and crypto workers
-// export * from "./crypto/workers/nodeThreadWorker.js";
-export { CryptoImplementation } from "./crypto/workers/cryptoImplementation.js";
-export type { CryptoWorker } from "./crypto/workers/cryptoWorker.js";
-export { CryptoWorkerFactory, CryptoApi } from "./crypto/workers/cryptoApi.js";
-
-export * from "./pending-types.js";
-
-export * from "./util/debugFlags.js";
-export { InternalWalletState } from "./common.js";
export * from "./wallet-api-types.js";
export * from "./wallet.js";
-export * from "./operations/backup/index.js";
-export { makeEventId } from "./operations/transactions.js";
+export { parseTransactionIdentifier } from "./transactions.js";
+
+export { createPairTimeline } from "./denominations.js";
+
+// FIXME: Should these really be exported?!
+export {
+ WalletStoresV1,
+ deleteTalerDatabase,
+ exportDb,
+ importDb,
+} from "./db.js";
+export { DbAccess } from "./query.js";
diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts
new file mode 100644
index 000000000..03e702568
--- /dev/null
+++ b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts
@@ -0,0 +1,767 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ Duration,
+ TransactionAmountMode,
+} from "@gnu-taler/taler-util";
+import test, { ExecutionContext } from "ava";
+import {
+ CoinInfo,
+ convertDepositAmountForAvailableCoins,
+ convertWithdrawalAmountFromAvailableCoins,
+ getMaxDepositAmountForAvailableCoins,
+} from "./instructedAmountConversion.js";
+
+function makeCurrencyHelper(currency: string) {
+ return (sx: TemplateStringsArray, ...vx: any[]) => {
+ const s = String.raw({ raw: sx }, ...vx);
+ return Amounts.parseOrThrow(`${currency}:${s}`);
+ };
+}
+
+const kudos = makeCurrencyHelper("kudos");
+
+function defaultFeeConfig(value: AmountJson, totalAvailable: number): CoinInfo {
+ return {
+ id: Amounts.stringify(value),
+ denomDeposit: kudos`0.01`,
+ denomRefresh: kudos`0.01`,
+ denomWithdraw: kudos`0.01`,
+ exchangeBaseUrl: "1",
+ duration: Duration.getForever(),
+ exchangePurse: undefined,
+ exchangeWire: undefined,
+ maxAge: AgeRestriction.AGE_UNRESTRICTED,
+ totalAvailable,
+ value,
+ };
+}
+type Coin = [AmountJson, number];
+
+/**
+ * Making a deposit with effective amount
+ *
+ */
+
+test("deposit effective 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.99");
+});
+
+test("deposit effective 10", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`10`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "9.98");
+});
+
+test("deposit effective 24", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "23.94");
+});
+
+test("deposit effective 40", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`40`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "35");
+ t.is(Amounts.stringifyValue(result.raw), "34.9");
+});
+
+test("deposit with wire fee effective 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.1`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.89");
+});
+
+/**
+ * Making a deposit with raw amount, using the result from effective
+ *
+ */
+
+test("deposit raw 1.99 (effective 2)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`1.99`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.99");
+});
+
+test("deposit raw 9.98 (effective 10)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`9.98`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "9.98");
+});
+
+test("deposit raw 23.94 (effective 24)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`23.94`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "23.94");
+});
+
+test("deposit raw 34.9 (effective 40)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`34.9`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "35");
+ t.is(Amounts.stringifyValue(result.raw), "34.9");
+});
+
+test("deposit with wire fee raw 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.1`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "1.89");
+});
+
+/**
+ * Calculating the max amount possible to deposit
+ *
+ */
+
+test("deposit max 35", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ wireFee: kudos`0.00`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.raw), "34.9");
+ t.is(Amounts.stringifyValue(result.effective), "35");
+});
+
+test("deposit max 35 with wirefee", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ wireFee: kudos`1`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.raw), "33.9");
+ t.is(Amounts.stringifyValue(result.effective), "35");
+});
+
+test("deposit max repeated denom", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 1],
+ [kudos`2`, 1],
+ [kudos`5`, 1],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ "2": {
+ wireFee: kudos`0.00`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.raw), "8.97");
+ t.is(Amounts.stringifyValue(result.effective), "9");
+});
+
+/**
+ * Making a withdrawal with effective amount
+ *
+ */
+
+test("withdraw effective 2", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "2.01");
+});
+
+test("withdraw effective 10", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`10`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "10.02");
+});
+
+test("withdraw effective 24", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "24.06");
+});
+
+test("withdraw effective 40", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`40`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "40");
+ t.is(Amounts.stringifyValue(result.raw), "40.08");
+});
+
+/**
+ * Making a deposit with raw amount, using the result from effective
+ *
+ */
+
+test("withdraw raw 2.01 (effective 2)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2.01`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "2");
+ t.is(Amounts.stringifyValue(result.raw), "2.01");
+});
+
+test("withdraw raw 10.02 (effective 10)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`10.02`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "10");
+ t.is(Amounts.stringifyValue(result.raw), "10.02");
+});
+
+test("withdraw raw 24.06 (effective 24)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24.06`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24");
+ t.is(Amounts.stringifyValue(result.raw), "24.06");
+});
+
+test("withdraw raw 40.08 (effective 40)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`40.08`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "40");
+ t.is(Amounts.stringifyValue(result.raw), "40.08");
+});
+
+test("withdraw raw 25", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`25`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.94");
+});
+
+test("withdraw effective 24.8 (raw 25)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`24.8`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.94");
+});
+
+/**
+ * Making a deposit with refresh
+ *
+ */
+
+test("deposit with refresh: effective 3", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`3`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "3.1");
+ t.is(Amounts.stringifyValue(result.raw), "2.98");
+ expectDefined(t, result.refresh);
+ //FEES
+ //deposit 2 x 0.01
+ //refresh 1 x 0.01
+ //withdraw 9 x 0.01
+ //-----------------
+ //op 0.12
+
+ //coins sent 2 x 2.0
+ //coins recv 9 x 0.1
+ //-------------------
+ //effective 3.10
+ //raw 2.98
+ t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
+ t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 9]]);
+});
+
+test("deposit with refresh: raw 2.98 (effective 3)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`2.98`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "3.2");
+ t.is(Amounts.stringifyValue(result.raw), "3.09");
+ expectDefined(t, result.refresh);
+ //FEES
+ //deposit 1 x 0.01
+ //refresh 1 x 0.01
+ //withdraw 8 x 0.01
+ //-----------------
+ //op 0.10
+
+ //coins sent 1 x 2.0
+ //coins recv 8 x 0.1
+ //-------------------
+ //effective 3.20
+ //raw 3.09
+ t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
+ t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 8]]);
+});
+
+test("deposit with refresh: effective 3.2 (raw 2.98)", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 5],
+ [kudos`5`, 5],
+ ];
+ const result = convertDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`3.2`,
+ TransactionAmountMode.Effective,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "3.3");
+ t.is(Amounts.stringifyValue(result.raw), "3.2");
+ expectDefined(t, result.refresh);
+ //FEES
+ //deposit 2 x 0.01
+ //refresh 1 x 0.01
+ //withdraw 7 x 0.01
+ //-----------------
+ //op 0.10
+
+ //coins sent 2 x 2.0
+ //coins recv 7 x 0.1
+ //-------------------
+ //effective 3.30
+ //raw 3.20
+ t.is(Amounts.stringifyValue(result.refresh.selected.id), "2");
+ t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 7]]);
+});
+
+function expectDefined<T>(
+ t: ExecutionContext,
+ v: T | undefined,
+): asserts v is T {
+ t.assert(v !== undefined);
+}
+
+function asCoinList(v: { info: CoinInfo; size: number }[]): any {
+ return v.map((c) => {
+ return [c.info.value, c.size];
+ });
+}
+
+/**
+ * regression tests
+ */
+
+test("demo: withdraw raw 25", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ [kudos`10`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`25`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.92");
+ // coins received
+ // 8 x 0.1
+ // 2 x 0.2
+ // 2 x 10.0
+ // total effective 24.8
+ // fee 12 x 0.01 = 0.12
+ // total raw 24.92
+ // left in reserve 25 - 24.92 == 0.08
+
+ //current wallet impl: hides the left in reserve fee
+ //shows fee = 0.2
+});
+
+test("demo: deposit max after withdraw raw 25", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 8],
+ [kudos`1`, 0],
+ [kudos`2`, 2],
+ [kudos`5`, 0],
+ [kudos`10`, 2],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.01`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.effective), "24.8");
+ t.is(Amounts.stringifyValue(result.raw), "24.67");
+
+ // 8 x 0.1
+ // 2 x 0.2
+ // 2 x 10.0
+ // total effective 24.8
+ // deposit fee 12 x 0.01 = 0.12
+ // wire fee 0.01
+ // total raw: 24.8 - 0.13 = 24.67
+
+ // current wallet impl fee 0.14
+});
+
+test("demo: withdraw raw 13", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 0],
+ [kudos`1`, 0],
+ [kudos`2`, 0],
+ [kudos`5`, 0],
+ [kudos`10`, 0],
+ ];
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {},
+ },
+ kudos`13`,
+ TransactionAmountMode.Raw,
+ );
+ t.is(Amounts.stringifyValue(result.effective), "12.8");
+ t.is(Amounts.stringifyValue(result.raw), "12.9");
+ // coins received
+ // 8 x 0.1
+ // 1 x 0.2
+ // 1 x 10.0
+ // total effective 12.8
+ // fee 10 x 0.01 = 0.10
+ // total raw 12.9
+ // left in reserve 13 - 12.9 == 0.1
+
+ //current wallet impl: hides the left in reserve fee
+ //shows fee = 0.2
+});
+
+test("demo: deposit max after withdraw raw 13", (t) => {
+ const coinList: Coin[] = [
+ [kudos`0.1`, 8],
+ [kudos`1`, 0],
+ [kudos`2`, 1],
+ [kudos`5`, 0],
+ [kudos`10`, 1],
+ ];
+ const result = getMaxDepositAmountForAvailableCoins(
+ {
+ list: coinList.map(([v, t]) => defaultFeeConfig(v, t)),
+ exchanges: {
+ one: {
+ wireFee: kudos`0.01`,
+ purseFee: kudos`0.00`,
+ creditDeadline: AbsoluteTime.never(),
+ debitDeadline: AbsoluteTime.never(),
+ },
+ },
+ },
+ "KUDOS",
+ );
+ t.is(Amounts.stringifyValue(result.effective), "12.8");
+ t.is(Amounts.stringifyValue(result.raw), "12.69");
+
+ // 8 x 0.1
+ // 1 x 0.2
+ // 1 x 10.0
+ // total effective 12.8
+ // deposit fee 10 x 0.01 = 0.10
+ // wire fee 0.01
+ // total raw: 12.8 - 0.11 = 12.69
+
+ // current wallet impl fee 0.14
+});
diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts
new file mode 100644
index 000000000..63ccb8b56
--- /dev/null
+++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts
@@ -0,0 +1,865 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ AmountResponse,
+ Amounts,
+ ConvertAmountRequest,
+ Duration,
+ GetAmountRequest,
+ GetPlanForOperationRequest,
+ TransactionAmountMode,
+ TransactionType,
+ checkDbInvariant,
+ parsePaytoUri,
+ strcmp,
+} from "@gnu-taler/taler-util";
+import { DenominationRecord, timestampProtocolFromDb } from "./db.js";
+import { getExchangeWireDetailsInTx } from "./exchanges.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+export interface CoinInfo {
+ id: string;
+ value: AmountJson;
+ denomDeposit: AmountJson;
+ denomWithdraw: AmountJson;
+ denomRefresh: AmountJson;
+ totalAvailable: number | undefined;
+ exchangeWire: AmountJson | undefined;
+ exchangePurse: AmountJson | undefined;
+ duration: Duration;
+ exchangeBaseUrl: string;
+ maxAge: number;
+}
+
+/**
+ * If the operation going to be plan subtracts
+ * or adds amount in the wallet db
+ */
+export enum OperationType {
+ Credit = "credit",
+ Debit = "debit",
+}
+
+// FIXME: Name conflict ...
+interface ExchangeInfo {
+ wireFee: AmountJson | undefined;
+ purseFee: AmountJson | undefined;
+ creditDeadline: AbsoluteTime;
+ debitDeadline: AbsoluteTime;
+}
+
+function getOperationType(txType: TransactionType): OperationType {
+ const operationType =
+ txType === TransactionType.Withdrawal
+ ? OperationType.Credit
+ : txType === TransactionType.Deposit
+ ? OperationType.Debit
+ : undefined;
+ if (!operationType) {
+ throw Error(`operation type ${txType} not yet supported`);
+ }
+ return operationType;
+}
+
+interface SelectedCoins {
+ totalValue: AmountJson;
+ coins: { info: CoinInfo; size: number }[];
+ refresh?: RefreshChoice;
+}
+
+function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter {
+ switch (req.type) {
+ case TransactionType.Withdrawal: {
+ return {
+ exchanges:
+ req.exchangeUrl === undefined ? undefined : [req.exchangeUrl],
+ };
+ }
+ case TransactionType.Deposit: {
+ const payto = parsePaytoUri(req.account);
+ if (!payto) {
+ throw Error(`wrong payto ${req.account}`);
+ }
+ return {
+ wireMethod: payto.targetType,
+ };
+ }
+ }
+}
+
+interface RefreshChoice {
+ /**
+ * Amount that need to be covered
+ */
+ gap: AmountJson;
+ totalFee: AmountJson;
+ selected: CoinInfo;
+ totalChangeValue: AmountJson;
+ refreshEffective: AmountJson;
+ coins: { info: CoinInfo; size: number }[];
+
+ // totalValue: AmountJson;
+ // totalDepositFee: AmountJson;
+ // totalRefreshFee: AmountJson;
+ // totalChangeContribution: AmountJson;
+ // totalChangeWithdrawalFee: AmountJson;
+}
+
+interface CoinsFilter {
+ shouldCalculatePurseFee?: boolean;
+ exchanges?: string[];
+ wireMethod?: string;
+ ageRestricted?: number;
+}
+
+interface AvailableCoins {
+ list: CoinInfo[];
+ exchanges: Record<string, ExchangeInfo>;
+}
+
+/**
+ * Get all the denoms that can be used for a operation that is limited
+ * by the following restrictions.
+ * This function is costly (by the database access) but with high chances
+ * of being cached
+ */
+async function getAvailableDenoms(
+ wex: WalletExecutionContext,
+ op: TransactionType,
+ currency: string,
+ filters: CoinsFilter = {},
+): Promise<AvailableCoins> {
+ const operationType = getOperationType(TransactionType.Deposit);
+
+ return await wex.db.runReadOnlyTx(
+ ["exchanges", "exchangeDetails", "denominations", "coinAvailability"],
+ async (tx) => {
+ const list: CoinInfo[] = [];
+ const exchanges: Record<string, ExchangeInfo> = {};
+
+ const databaseExchanges = await tx.exchanges.iter().toArray();
+ const filteredExchanges =
+ filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl);
+
+ for (const exchangeBaseUrl of filteredExchanges) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ exchangeBaseUrl,
+ );
+ // 1.- exchange has same currency
+ if (exchangeDetails?.currency !== currency) {
+ continue;
+ }
+
+ let deadline = AbsoluteTime.never();
+ // 2.- exchange supports wire method
+ let wireFee: AmountJson | undefined;
+ if (filters.wireMethod) {
+ const wireMethodWithDates =
+ exchangeDetails.wireInfo.feesForType[filters.wireMethod];
+
+ if (!wireMethodWithDates) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`,
+ );
+ }
+ const wireMethodFee = wireMethodWithDates.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startStamp),
+ AbsoluteTime.fromProtocolTimestamp(x.endStamp),
+ );
+ });
+
+ if (!wireMethodFee) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`,
+ );
+ }
+ wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee);
+ deadline = AbsoluteTime.min(
+ deadline,
+ AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp),
+ );
+ }
+ // exchanges[exchangeBaseUrl].wireFee = wireMethodFee;
+
+ // 3.- exchange supports wire method
+ let purseFee: AmountJson | undefined;
+ if (filters.shouldCalculatePurseFee) {
+ const purseFeeFound = exchangeDetails.globalFees.find((x) => {
+ return AbsoluteTime.isBetween(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(x.startDate),
+ AbsoluteTime.fromProtocolTimestamp(x.endDate),
+ );
+ });
+ if (!purseFeeFound) {
+ throw Error(
+ `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
+ );
+ }
+ purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee);
+ deadline = AbsoluteTime.min(
+ deadline,
+ AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate),
+ );
+ }
+
+ let creditDeadline = AbsoluteTime.never();
+ let debitDeadline = AbsoluteTime.never();
+ //4.- filter coins restricted by age
+ if (operationType === OperationType.Credit) {
+ // FIXME: Use denom groups instead of querying all denominations!
+ const ds =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ exchangeBaseUrl,
+ );
+ for (const denom of ds) {
+ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireWithdraw),
+ );
+ const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ );
+ creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
+ debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
+ list.push(
+ buildCoinInfoFromDenom(
+ denom,
+ purseFee,
+ wireFee,
+ AgeRestriction.AGE_UNRESTRICTED,
+ Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom
+ ),
+ );
+ }
+ } else {
+ const ageLower = filters.ageRestricted ?? 0;
+ const ageUpper = AgeRestriction.AGE_UNRESTRICTED;
+
+ const myExchangeCoins =
+ await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
+ GlobalIDB.KeyRange.bound(
+ [exchangeDetails.exchangeBaseUrl, ageLower, 1],
+ [
+ exchangeDetails.exchangeBaseUrl,
+ ageUpper,
+ Number.MAX_SAFE_INTEGER,
+ ],
+ ),
+ );
+ //5.- save denoms with how many coins are available
+ // FIXME: Check that the individual denomination is audited!
+ // FIXME: Should we exclude denominations that are
+ // not spendable anymore?
+ for (const coinAvail of myExchangeCoins) {
+ const denom = await tx.denominations.get([
+ coinAvail.exchangeBaseUrl,
+ coinAvail.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ if (denom.isRevoked || !denom.isOffered) {
+ continue;
+ }
+ const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireWithdraw),
+ );
+ const expiresDeposit = AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ );
+ creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw);
+ debitDeadline = AbsoluteTime.min(deadline, expiresDeposit);
+ list.push(
+ buildCoinInfoFromDenom(
+ denom,
+ purseFee,
+ wireFee,
+ coinAvail.maxAge,
+ coinAvail.freshCoinCount,
+ ),
+ );
+ }
+ }
+
+ exchanges[exchangeBaseUrl] = {
+ purseFee,
+ wireFee,
+ debitDeadline,
+ creditDeadline,
+ };
+ }
+
+ return { list, exchanges };
+ },
+ );
+}
+
+function buildCoinInfoFromDenom(
+ denom: DenominationRecord,
+ purseFee: AmountJson | undefined,
+ wireFee: AmountJson | undefined,
+ maxAge: number,
+ total: number,
+): CoinInfo {
+ return {
+ id: denom.denomPubHash,
+ denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw),
+ denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
+ denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh),
+ exchangePurse: purseFee,
+ exchangeWire: wireFee,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ duration: AbsoluteTime.difference(
+ AbsoluteTime.now(),
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(denom.stampExpireDeposit),
+ ),
+ ),
+ totalAvailable: total,
+ value: Amounts.parseOrThrow(denom.value),
+ maxAge,
+ };
+}
+
+export async function convertDepositAmount(
+ wex: WalletExecutionContext,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ const amount = Amounts.parseOrThrow(req.amount);
+ // const filter = getCoinsFilter(req);
+
+ const denoms = await getAvailableDenoms(
+ wex,
+ TransactionType.Deposit,
+ amount.currency,
+ {},
+ );
+ const result = convertDepositAmountForAvailableCoins(
+ denoms,
+ amount,
+ req.type,
+ );
+
+ return {
+ effectiveAmount: Amounts.stringify(result.effective),
+ rawAmount: Amounts.stringify(result.raw),
+ };
+}
+
+const LOG_REFRESH = false;
+const LOG_DEPOSIT = false;
+export function convertDepositAmountForAvailableCoins(
+ denoms: AvailableCoins,
+ amount: AmountJson,
+ mode: TransactionAmountMode,
+): AmountAndRefresh {
+ const zero = Amounts.zeroOfCurrency(amount.currency);
+ if (!denoms.list.length) {
+ // no coins in the database
+ return { effective: zero, raw: zero };
+ }
+ const depositDenoms = rankDenominationForDeposit(denoms.list, mode);
+
+ //FIXME: we are not taking into account
+ // * exchanges with multiple accounts
+ // * wallet with multiple exchanges
+ const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero;
+ const adjustedAmount = Amounts.add(amount, wireFee).amount;
+
+ const selected = selectGreedyCoins(depositDenoms, adjustedAmount);
+
+ const gap = Amounts.sub(amount, selected.totalValue).amount;
+
+ const result = getTotalEffectiveAndRawForDeposit(
+ selected.coins,
+ amount.currency,
+ );
+ result.raw = Amounts.sub(result.raw, wireFee).amount;
+
+ if (Amounts.isZero(gap)) {
+ // exact amount founds
+ return result;
+ }
+
+ if (LOG_DEPOSIT) {
+ const logInfo = selected.coins.map((c) => {
+ return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`;
+ });
+ console.log(
+ "deposit used:",
+ logInfo.join(", "),
+ "gap:",
+ Amounts.stringifyValue(gap),
+ );
+ }
+
+ const refreshDenoms = rankDenominationForRefresh(denoms.list);
+ /**
+ * FIXME: looking for refresh AFTER selecting greedy is not optimal
+ */
+ const refreshCoin = searchBestRefreshCoin(
+ depositDenoms,
+ refreshDenoms,
+ gap,
+ mode,
+ );
+
+ if (refreshCoin) {
+ const fee = Amounts.sub(result.effective, result.raw).amount;
+ const effective = Amounts.add(
+ result.effective,
+ refreshCoin.refreshEffective,
+ ).amount;
+ const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount;
+ //found with change
+ return {
+ effective,
+ raw,
+ refresh: refreshCoin,
+ };
+ }
+
+ // there is a gap, but no refresh coin was found
+ return result;
+}
+
+export async function getMaxDepositAmount(
+ wex: WalletExecutionContext,
+ req: GetAmountRequest,
+): Promise<AmountResponse> {
+ // const filter = getCoinsFilter(req);
+
+ const denoms = await getAvailableDenoms(
+ wex,
+ TransactionType.Deposit,
+ req.currency,
+ {},
+ );
+
+ const result = getMaxDepositAmountForAvailableCoins(denoms, req.currency);
+ return {
+ effectiveAmount: Amounts.stringify(result.effective),
+ rawAmount: Amounts.stringify(result.raw),
+ };
+}
+
+export function getMaxDepositAmountForAvailableCoins(
+ denoms: AvailableCoins,
+ currency: string,
+) {
+ const zero = Amounts.zeroOfCurrency(currency);
+ if (!denoms.list.length) {
+ // no coins in the database
+ return { effective: zero, raw: zero };
+ }
+
+ const result = getTotalEffectiveAndRawForDeposit(
+ denoms.list.map((info) => {
+ return { info, size: info.totalAvailable ?? 0 };
+ }),
+ currency,
+ );
+
+ const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero;
+ result.raw = Amounts.sub(result.raw, wireFee).amount;
+
+ return result;
+}
+
+export async function convertPeerPushAmount(
+ wex: WalletExecutionContext,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ throw Error("to be implemented after 1.0");
+}
+
+export async function getMaxPeerPushAmount(
+ wex: WalletExecutionContext,
+ req: GetAmountRequest,
+): Promise<AmountResponse> {
+ throw Error("to be implemented after 1.0");
+}
+
+export async function convertWithdrawalAmount(
+ wex: WalletExecutionContext,
+ req: ConvertAmountRequest,
+): Promise<AmountResponse> {
+ const amount = Amounts.parseOrThrow(req.amount);
+
+ const denoms = await getAvailableDenoms(
+ wex,
+ TransactionType.Withdrawal,
+ amount.currency,
+ {},
+ );
+
+ const result = convertWithdrawalAmountFromAvailableCoins(
+ denoms,
+ amount,
+ req.type,
+ );
+
+ return {
+ effectiveAmount: Amounts.stringify(result.effective),
+ rawAmount: Amounts.stringify(result.raw),
+ };
+}
+
+export function convertWithdrawalAmountFromAvailableCoins(
+ denoms: AvailableCoins,
+ amount: AmountJson,
+ mode: TransactionAmountMode,
+) {
+ const zero = Amounts.zeroOfCurrency(amount.currency);
+ if (!denoms.list.length) {
+ // no coins in the database
+ return { effective: zero, raw: zero };
+ }
+ const withdrawDenoms = rankDenominationForWithdrawals(denoms.list, mode);
+
+ const selected = selectGreedyCoins(withdrawDenoms, amount);
+
+ return getTotalEffectiveAndRawForWithdrawal(selected.coins, amount.currency);
+}
+
+/** *****************************************************
+ * HELPERS
+ * *****************************************************
+ */
+
+/**
+ *
+ * @param depositDenoms
+ * @param refreshDenoms
+ * @param amount
+ * @param mode
+ * @returns
+ */
+function searchBestRefreshCoin(
+ depositDenoms: SelectableElement[],
+ refreshDenoms: Record<string, SelectableElement[]>,
+ amount: AmountJson,
+ mode: TransactionAmountMode,
+): RefreshChoice | undefined {
+ let choice: RefreshChoice | undefined = undefined;
+ let refreshIdx = 0;
+ refreshIteration: while (refreshIdx < depositDenoms.length) {
+ const d = depositDenoms[refreshIdx];
+
+ const denomContribution =
+ mode === TransactionAmountMode.Effective
+ ? d.value
+ : Amounts.sub(d.value, d.info.denomRefresh, d.info.denomDeposit).amount;
+
+ const changeAfterDeposit = Amounts.sub(denomContribution, amount).amount;
+ if (Amounts.isZero(changeAfterDeposit)) {
+ //this coin is not big enough to use for refresh
+ //since the list is sorted, we can break here
+ break refreshIteration;
+ }
+
+ const withdrawDenoms = refreshDenoms[d.info.exchangeBaseUrl];
+ const change = selectGreedyCoins(withdrawDenoms, changeAfterDeposit);
+
+ const zero = Amounts.zeroOfCurrency(amount.currency);
+ const withdrawChangeFee = change.coins.reduce((cur, prev) => {
+ return Amounts.add(
+ cur,
+ Amounts.mult(prev.info.denomWithdraw, prev.size).amount,
+ ).amount;
+ }, zero);
+
+ const withdrawChangeValue = change.coins.reduce((cur, prev) => {
+ return Amounts.add(cur, Amounts.mult(prev.info.value, prev.size).amount)
+ .amount;
+ }, zero);
+
+ const totalFee = Amounts.add(
+ d.info.denomDeposit,
+ d.info.denomRefresh,
+ withdrawChangeFee,
+ ).amount;
+
+ if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) {
+ //found cheaper change
+ choice = {
+ gap: amount,
+ totalFee: totalFee,
+ totalChangeValue: change.totalValue, //change after refresh
+ refreshEffective: Amounts.sub(d.info.value, withdrawChangeValue).amount, // what of the denom used is not recovered
+ selected: d.info,
+ coins: change.coins,
+ };
+ }
+ refreshIdx++;
+ }
+ if (choice) {
+ if (LOG_REFRESH) {
+ const logInfo = choice.coins.map((c) => {
+ return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`;
+ });
+ console.log(
+ "refresh used:",
+ Amounts.stringifyValue(choice.selected.value),
+ "change:",
+ logInfo.join(", "),
+ "fee:",
+ Amounts.stringifyValue(choice.totalFee),
+ "refreshEffective:",
+ Amounts.stringifyValue(choice.refreshEffective),
+ "totalChangeValue:",
+ Amounts.stringifyValue(choice.totalChangeValue),
+ );
+ }
+ }
+ return choice;
+}
+
+/**
+ * Returns a copy of the list sorted for the best denom to withdraw first
+ *
+ * @param denoms
+ * @returns
+ */
+function rankDenominationForWithdrawals(
+ denoms: CoinInfo[],
+ mode: TransactionAmountMode,
+): SelectableElement[] {
+ const copyList = [...denoms];
+ /**
+ * Rank coins
+ */
+ copyList.sort((d1, d2) => {
+ // the best coin to use is
+ // 1.- the one that contrib more and pay less fee
+ // 2.- it takes more time before expires
+
+ //different exchanges may have different wireFee
+ //ranking should take the relative contribution in the exchange
+ //which is (value - denomFee / fixedFee)
+ const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient;
+ const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient;
+ const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1;
+ return (
+ contribCmp ||
+ Duration.cmp(d1.duration, d2.duration) ||
+ strcmp(d1.id, d2.id)
+ );
+ });
+
+ return copyList.map((info) => {
+ switch (mode) {
+ case TransactionAmountMode.Effective: {
+ //if the user instructed "effective" then we need to selected
+ //greedy total coin value
+ return {
+ info,
+ value: info.value,
+ total: Number.MAX_SAFE_INTEGER,
+ };
+ }
+ case TransactionAmountMode.Raw: {
+ //if the user instructed "raw" then we need to selected
+ //greedy total coin raw amount (without fee)
+ return {
+ info,
+ value: Amounts.add(info.value, info.denomWithdraw).amount,
+ total: Number.MAX_SAFE_INTEGER,
+ };
+ }
+ }
+ });
+}
+
+/**
+ * Returns a copy of the list sorted for the best denom to deposit first
+ *
+ * @param denoms
+ * @returns
+ */
+function rankDenominationForDeposit(
+ denoms: CoinInfo[],
+ mode: TransactionAmountMode,
+): SelectableElement[] {
+ const copyList = [...denoms];
+ /**
+ * Rank coins
+ */
+ copyList.sort((d1, d2) => {
+ // the best coin to use is
+ // 1.- the one that contrib more and pay less fee
+ // 2.- it takes more time before expires
+
+ //different exchanges may have different wireFee
+ //ranking should take the relative contribution in the exchange
+ //which is (value - denomFee / fixedFee)
+ const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient;
+ const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient;
+ const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1;
+ return (
+ contribCmp ||
+ Duration.cmp(d1.duration, d2.duration) ||
+ strcmp(d1.id, d2.id)
+ );
+ });
+
+ return copyList.map((info) => {
+ switch (mode) {
+ case TransactionAmountMode.Effective: {
+ //if the user instructed "effective" then we need to selected
+ //greedy total coin value
+ return {
+ info,
+ value: info.value,
+ total: info.totalAvailable ?? 0,
+ };
+ }
+ case TransactionAmountMode.Raw: {
+ //if the user instructed "raw" then we need to selected
+ //greedy total coin raw amount (without fee)
+ return {
+ info,
+ value: Amounts.sub(info.value, info.denomDeposit).amount,
+ total: info.totalAvailable ?? 0,
+ };
+ }
+ }
+ });
+}
+
+/**
+ * Returns a copy of the list sorted for the best denom to withdraw first
+ *
+ * @param denoms
+ * @returns
+ */
+function rankDenominationForRefresh(
+ denoms: CoinInfo[],
+): Record<string, SelectableElement[]> {
+ const groupByExchange: Record<string, CoinInfo[]> = {};
+ for (const d of denoms) {
+ if (!groupByExchange[d.exchangeBaseUrl]) {
+ groupByExchange[d.exchangeBaseUrl] = [];
+ }
+ groupByExchange[d.exchangeBaseUrl].push(d);
+ }
+
+ const result: Record<string, SelectableElement[]> = {};
+ for (const d of denoms) {
+ result[d.exchangeBaseUrl] = rankDenominationForWithdrawals(
+ groupByExchange[d.exchangeBaseUrl],
+ TransactionAmountMode.Raw,
+ );
+ }
+ return result;
+}
+
+interface SelectableElement {
+ total: number;
+ value: AmountJson;
+ info: CoinInfo;
+}
+
+function selectGreedyCoins(
+ coins: SelectableElement[],
+ limit: AmountJson,
+): SelectedCoins {
+ const result: SelectedCoins = {
+ totalValue: Amounts.zeroOfCurrency(limit.currency),
+ coins: [],
+ };
+ if (!coins.length) return result;
+
+ let denomIdx = 0;
+ iterateDenoms: while (denomIdx < coins.length) {
+ const denom = coins[denomIdx];
+ // let total = denom.total;
+ const left = Amounts.sub(limit, result.totalValue).amount;
+
+ if (Amounts.isZero(denom.value)) {
+ // 0 contribution denoms should be the last
+ break iterateDenoms;
+ }
+
+ //use Amounts.divmod instead of iterate
+ const div = Amounts.divmod(left, denom.value);
+ const size = Math.min(div.quotient, denom.total);
+ if (size > 0) {
+ const mul = Amounts.mult(denom.value, size).amount;
+ const progress = Amounts.add(result.totalValue, mul).amount;
+
+ result.totalValue = progress;
+ result.coins.push({ info: denom.info, size });
+ denom.total = denom.total - size;
+ }
+
+ //go next denom
+ denomIdx++;
+ }
+
+ return result;
+}
+
+type AmountWithFee = { raw: AmountJson; effective: AmountJson };
+type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice };
+
+export function getTotalEffectiveAndRawForDeposit(
+ list: { info: CoinInfo; size: number }[],
+ currency: string,
+): AmountWithFee {
+ const init = {
+ raw: Amounts.zeroOfCurrency(currency),
+ effective: Amounts.zeroOfCurrency(currency),
+ };
+ return list.reduce((prev, cur) => {
+ const ef = Amounts.mult(cur.info.value, cur.size).amount;
+ const rw = Amounts.mult(
+ Amounts.sub(cur.info.value, cur.info.denomDeposit).amount,
+ cur.size,
+ ).amount;
+
+ prev.effective = Amounts.add(prev.effective, ef).amount;
+ prev.raw = Amounts.add(prev.raw, rw).amount;
+ return prev;
+ }, init);
+}
+
+function getTotalEffectiveAndRawForWithdrawal(
+ list: { info: CoinInfo; size: number }[],
+ currency: string,
+): AmountWithFee {
+ const init = {
+ raw: Amounts.zeroOfCurrency(currency),
+ effective: Amounts.zeroOfCurrency(currency),
+ };
+ return list.reduce((prev, cur) => {
+ const ef = Amounts.mult(cur.info.value, cur.size).amount;
+ const rw = Amounts.mult(
+ Amounts.add(cur.info.value, cur.info.denomWithdraw).amount,
+ cur.size,
+ ).amount;
+
+ prev.effective = Amounts.add(prev.effective, ef).amount;
+ prev.raw = Amounts.add(prev.raw, rw).amount;
+ return prev;
+ }, init);
+}
diff --git a/packages/taler-wallet-core/src/observable-wrappers.ts b/packages/taler-wallet-core/src/observable-wrappers.ts
new file mode 100644
index 000000000..b36f41611
--- /dev/null
+++ b/packages/taler-wallet-core/src/observable-wrappers.ts
@@ -0,0 +1,282 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @fileoverview Wrappers/proxies to make various interfaces observable.
+ */
+
+/**
+ * Imports.
+ */
+import { IDBDatabase } from "@gnu-taler/idb-bridge";
+import {
+ ObservabilityContext,
+ ObservabilityEventType,
+ RetryLoopOpts,
+} from "@gnu-taler/taler-util";
+import { TaskIdStr } from "./common.js";
+import { TalerCryptoInterface } from "./index.js";
+import {
+ DbAccess,
+ DbReadOnlyTransaction,
+ DbReadWriteTransaction,
+ StoreNames,
+} from "./query.js";
+import { TaskScheduler } from "./shepherd.js";
+
+/**
+ * Task scheduler with extra observability events.
+ */
+export class ObservableTaskScheduler implements TaskScheduler {
+ constructor(
+ private impl: TaskScheduler,
+ private oc: ObservabilityContext,
+ ) {}
+
+ private taskDepCache = new Set<string>();
+
+ private declareDep(taskId: TaskIdStr): void {
+ if (this.taskDepCache.size > 500) {
+ this.taskDepCache.clear();
+ }
+ if (!this.taskDepCache.has(taskId)) {
+ this.taskDepCache.add(taskId);
+ this.oc.observe({
+ type: ObservabilityEventType.DeclareTaskDependency,
+ taskId,
+ });
+ }
+ }
+
+ getActiveTasks(): TaskIdStr[] {
+ return this.impl.getActiveTasks();
+ }
+
+ ensureRunning(): void {
+ return this.impl.ensureRunning();
+ }
+
+ run(opts?: RetryLoopOpts | undefined): Promise<void> {
+ return this.impl.run(opts);
+ }
+ startShepherdTask(taskId: TaskIdStr): void {
+ this.declareDep(taskId);
+ this.oc.observe({
+ type: ObservabilityEventType.TaskStart,
+ taskId,
+ });
+ return this.impl.startShepherdTask(taskId);
+ }
+ stopShepherdTask(taskId: TaskIdStr): void {
+ this.declareDep(taskId);
+ this.oc.observe({
+ type: ObservabilityEventType.TaskStop,
+ taskId,
+ });
+ return this.impl.stopShepherdTask(taskId);
+ }
+ resetTaskRetries(taskId: TaskIdStr): Promise<void> {
+ this.declareDep(taskId);
+ if (this.taskDepCache.size > 500) {
+ this.taskDepCache.clear();
+ }
+ this.oc.observe({
+ type: ObservabilityEventType.TaskReset,
+ taskId,
+ });
+ return this.impl.resetTaskRetries(taskId);
+ }
+ reload(): void {
+ return this.impl.reload();
+ }
+}
+
+const locRegex = /\s*at\s*([a-zA-Z0-9_.!]*)\s*/;
+
+export function getCallerInfo(up: number = 2): string {
+ const stack = new Error().stack ?? "";
+ const identifies: string[] = [];
+ for (const line of stack.split("\n")) {
+ let l = line.match(locRegex);
+ if (l) {
+ identifies.push(l[1]);
+ }
+ }
+ return identifies.slice(up, up + 2).join("/");
+}
+
+export class ObservableDbAccess<StoreMap> implements DbAccess<StoreMap> {
+ constructor(
+ private impl: DbAccess<StoreMap>,
+ private oc: ObservabilityContext,
+ ) {}
+ idbHandle(): IDBDatabase {
+ return this.impl.idbHandle();
+ }
+
+ async runAllStoresReadWriteTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadWriteTransaction<StoreMap, StoreNames<StoreMap>[]>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: "<unknown>",
+ location,
+ });
+ try {
+ const ret = await this.impl.runAllStoresReadWriteTx(options, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+
+ async runAllStoresReadOnlyTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadOnlyTransaction<StoreMap, StoreNames<StoreMap>[]>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: "<unknown>",
+ location,
+ });
+ try {
+ const ret = await this.impl.runAllStoresReadOnlyTx(options, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+
+ async runReadWriteTx<T, StoreNameArray extends StoreNames<StoreMap>[]>(
+ storeNames: StoreNameArray,
+ txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: "<unknown>",
+ location,
+ });
+ try {
+ const ret = await this.impl.runReadWriteTx(storeNames, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+
+ async runReadOnlyTx<T, StoreNameArray extends StoreNames<StoreMap>[]>(
+ storeNames: StoreNameArray,
+ txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const location = getCallerInfo();
+ try {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryStart,
+ name: "<unknown>",
+ location,
+ });
+ const ret = await this.impl.runReadOnlyTx(storeNames, txf);
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishSuccess,
+ name: "<unknown>",
+ location,
+ });
+ return ret;
+ } catch (e) {
+ this.oc.observe({
+ type: ObservabilityEventType.DbQueryFinishError,
+ name: "<unknown>",
+ location,
+ });
+ throw e;
+ }
+ }
+}
+
+export function observeTalerCrypto(
+ impl: TalerCryptoInterface,
+ oc: ObservabilityContext,
+): TalerCryptoInterface {
+ return Object.fromEntries(
+ Object.keys(impl).map((name) => {
+ return [
+ name,
+ async (req: any) => {
+ oc.observe({
+ type: ObservabilityEventType.CryptoStart,
+ operation: name,
+ });
+ try {
+ const res = await (impl as any)[name](req);
+ oc.observe({
+ type: ObservabilityEventType.CryptoFinishSuccess,
+ operation: name,
+ });
+ return res;
+ } catch (e) {
+ oc.observe({
+ type: ObservabilityEventType.CryptoFinishError,
+ operation: name,
+ });
+ throw e;
+ }
+ },
+ ];
+ }),
+ ) as any;
+}
diff --git a/packages/taler-wallet-core/src/operations/README.md b/packages/taler-wallet-core/src/operations/README.md
deleted file mode 100644
index 32e2fbfc8..000000000
--- a/packages/taler-wallet-core/src/operations/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Wallet Operations
-
-This folder contains the implementations for all wallet operations that operate on the wallet state.
-
-To avoid cyclic dependencies, these files must **not** reference each other. Instead, other operations should only be accessed via injected dependencies.
-
-Avoiding cyclic dependencies is important for module bundlers. \ No newline at end of file
diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts
deleted file mode 100644
index a66bc2e84..000000000
--- a/packages/taler-wallet-core/src/operations/backup/export.ts
+++ /dev/null
@@ -1,512 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of wallet backups (export/import/upload) and sync
- * server management.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- Amounts,
- BackupBackupProvider,
- BackupBackupProviderTerms,
- BackupCoin,
- BackupCoinSource,
- BackupCoinSourceType,
- BackupDenomination,
- BackupExchange,
- BackupExchangeDetails,
- BackupExchangeWireFee,
- BackupProposal,
- BackupProposalStatus,
- BackupPurchase,
- BackupRecoupGroup,
- BackupRefreshGroup,
- BackupRefreshOldCoin,
- BackupRefreshSession,
- BackupRefundItem,
- BackupRefundState,
- BackupReserve,
- BackupTip,
- BackupWithdrawalGroup,
- canonicalizeBaseUrl,
- canonicalJson,
- getTimestampNow,
- Logger,
- timestampToIsoString,
- WalletBackupContentV1,
- hash,
- encodeCrock,
- getRandomBytes,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-import { InternalWalletState } from "../../common.js";
-import {
- AbortStatus,
- CoinSourceType,
- CoinStatus,
- ProposalStatus,
- RefreshCoinStatus,
- RefundState,
- WALLET_BACKUP_STATE_KEY,
-} from "../../db.js";
-import { getWalletBackupState, provideBackupState } from "./state.js";
-
-const logger = new Logger("backup/export.ts");
-
-export async function exportBackup(
- ws: InternalWalletState,
-): Promise<WalletBackupContentV1> {
- await provideBackupState(ws);
- return ws.db
- .mktx((x) => ({
- config: x.config,
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- coins: x.coins,
- denominations: x.denominations,
- purchases: x.purchases,
- proposals: x.proposals,
- refreshGroups: x.refreshGroups,
- backupProviders: x.backupProviders,
- tips: x.tips,
- recoupGroups: x.recoupGroups,
- reserves: x.reserves,
- withdrawalGroups: x.withdrawalGroups,
- }))
- .runReadWrite(async (tx) => {
- const bs = await getWalletBackupState(ws, tx);
-
- const backupExchangeDetails: BackupExchangeDetails[] = [];
- const backupExchanges: BackupExchange[] = [];
- const backupCoinsByDenom: { [dph: string]: BackupCoin[] } = {};
- const backupDenominationsByExchange: {
- [url: string]: BackupDenomination[];
- } = {};
- const backupReservesByExchange: { [url: string]: BackupReserve[] } = {};
- const backupPurchases: BackupPurchase[] = [];
- const backupProposals: BackupProposal[] = [];
- const backupRefreshGroups: BackupRefreshGroup[] = [];
- const backupBackupProviders: BackupBackupProvider[] = [];
- const backupTips: BackupTip[] = [];
- const backupRecoupGroups: BackupRecoupGroup[] = [];
- const withdrawalGroupsByReserve: {
- [reservePub: string]: BackupWithdrawalGroup[];
- } = {};
-
- await tx.withdrawalGroups.iter().forEachAsync(async (wg) => {
- const withdrawalGroups = (withdrawalGroupsByReserve[
- wg.reservePub
- ] ??= []);
- withdrawalGroups.push({
- raw_withdrawal_amount: Amounts.stringify(wg.rawWithdrawalAmount),
- selected_denoms: wg.denomsSel.selectedDenoms.map((x) => ({
- count: x.count,
- denom_pub_hash: x.denomPubHash,
- })),
- timestamp_created: wg.timestampStart,
- timestamp_finish: wg.timestampFinish,
- withdrawal_group_id: wg.withdrawalGroupId,
- secret_seed: wg.secretSeed,
- selected_denoms_id: wg.denomSelUid,
- });
- });
-
- await tx.reserves.iter().forEach((reserve) => {
- const backupReserve: BackupReserve = {
- initial_selected_denoms: reserve.initialDenomSel.selectedDenoms.map(
- (x) => ({
- count: x.count,
- denom_pub_hash: x.denomPubHash,
- }),
- ),
- initial_withdrawal_group_id: reserve.initialWithdrawalGroupId,
- instructed_amount: Amounts.stringify(reserve.instructedAmount),
- reserve_priv: reserve.reservePriv,
- timestamp_created: reserve.timestampCreated,
- withdrawal_groups:
- withdrawalGroupsByReserve[reserve.reservePub] ?? [],
- // FIXME!
- timestamp_last_activity: reserve.timestampCreated,
- };
- const backupReserves = (backupReservesByExchange[
- reserve.exchangeBaseUrl
- ] ??= []);
- backupReserves.push(backupReserve);
- });
-
- await tx.tips.iter().forEach((tip) => {
- backupTips.push({
- exchange_base_url: tip.exchangeBaseUrl,
- merchant_base_url: tip.merchantBaseUrl,
- merchant_tip_id: tip.merchantTipId,
- wallet_tip_id: tip.walletTipId,
- secret_seed: tip.secretSeed,
- selected_denoms: tip.denomsSel.selectedDenoms.map((x) => ({
- count: x.count,
- denom_pub_hash: x.denomPubHash,
- })),
- timestamp_finished: tip.pickedUpTimestamp,
- timestamp_accepted: tip.acceptedTimestamp,
- timestamp_created: tip.createdTimestamp,
- timestamp_expiration: tip.tipExpiration,
- tip_amount_raw: Amounts.stringify(tip.tipAmountRaw),
- selected_denoms_uid: tip.denomSelUid,
- });
- });
-
- await tx.recoupGroups.iter().forEach((recoupGroup) => {
- backupRecoupGroups.push({
- recoup_group_id: recoupGroup.recoupGroupId,
- timestamp_created: recoupGroup.timestampStarted,
- timestamp_finish: recoupGroup.timestampFinished,
- coins: recoupGroup.coinPubs.map((x, i) => ({
- coin_pub: x,
- recoup_finished: recoupGroup.recoupFinishedPerCoin[i],
- old_amount: Amounts.stringify(recoupGroup.oldAmountPerCoin[i]),
- })),
- });
- });
-
- await tx.backupProviders.iter().forEach((bp) => {
- let terms: BackupBackupProviderTerms | undefined;
- if (bp.terms) {
- terms = {
- annual_fee: Amounts.stringify(bp.terms.annualFee),
- storage_limit_in_megabytes: bp.terms.storageLimitInMegabytes,
- supported_protocol_version: bp.terms.supportedProtocolVersion,
- };
- }
- backupBackupProviders.push({
- terms,
- base_url: canonicalizeBaseUrl(bp.baseUrl),
- pay_proposal_ids: bp.paymentProposalIds,
- uids: bp.uids,
- });
- });
-
- await tx.coins.iter().forEach((coin) => {
- let bcs: BackupCoinSource;
- switch (coin.coinSource.type) {
- case CoinSourceType.Refresh:
- bcs = {
- type: BackupCoinSourceType.Refresh,
- old_coin_pub: coin.coinSource.oldCoinPub,
- };
- break;
- case CoinSourceType.Tip:
- bcs = {
- type: BackupCoinSourceType.Tip,
- coin_index: coin.coinSource.coinIndex,
- wallet_tip_id: coin.coinSource.walletTipId,
- };
- break;
- case CoinSourceType.Withdraw:
- bcs = {
- type: BackupCoinSourceType.Withdraw,
- coin_index: coin.coinSource.coinIndex,
- reserve_pub: coin.coinSource.reservePub,
- withdrawal_group_id: coin.coinSource.withdrawalGroupId,
- };
- break;
- }
-
- const coins = (backupCoinsByDenom[coin.denomPubHash] ??= []);
- coins.push({
- blinding_key: coin.blindingKey,
- coin_priv: coin.coinPriv,
- coin_source: bcs,
- current_amount: Amounts.stringify(coin.currentAmount),
- fresh: coin.status === CoinStatus.Fresh,
- denom_sig: coin.denomSig,
- });
- });
-
- await tx.denominations.iter().forEach((denom) => {
- const backupDenoms = (backupDenominationsByExchange[
- denom.exchangeBaseUrl
- ] ??= []);
- backupDenoms.push({
- coins: backupCoinsByDenom[denom.denomPubHash] ?? [],
- denom_pub: denom.denomPub,
- fee_deposit: Amounts.stringify(denom.feeDeposit),
- fee_refresh: Amounts.stringify(denom.feeRefresh),
- fee_refund: Amounts.stringify(denom.feeRefund),
- fee_withdraw: Amounts.stringify(denom.feeWithdraw),
- is_offered: denom.isOffered,
- is_revoked: denom.isRevoked,
- master_sig: denom.masterSig,
- stamp_expire_deposit: denom.stampExpireDeposit,
- stamp_expire_legal: denom.stampExpireLegal,
- stamp_expire_withdraw: denom.stampExpireWithdraw,
- stamp_start: denom.stampStart,
- value: Amounts.stringify(denom.value),
- list_issue_date: denom.listIssueDate,
- });
- });
-
- await tx.exchanges.iter().forEachAsync(async (ex) => {
- const dp = ex.detailsPointer;
- if (!dp) {
- return;
- }
- backupExchanges.push({
- base_url: ex.baseUrl,
- currency: dp.currency,
- master_public_key: dp.masterPublicKey,
- update_clock: dp.updateClock,
- });
- });
-
- await tx.exchangeDetails.iter().forEachAsync(async (ex) => {
- // Only back up permanently added exchanges.
-
- const wi = ex.wireInfo;
- const wireFees: BackupExchangeWireFee[] = [];
-
- Object.keys(wi.feesForType).forEach((x) => {
- for (const f of wi.feesForType[x]) {
- wireFees.push({
- wire_type: x,
- closing_fee: Amounts.stringify(f.closingFee),
- end_stamp: f.endStamp,
- sig: f.sig,
- start_stamp: f.startStamp,
- wire_fee: Amounts.stringify(f.wireFee),
- });
- }
- });
-
- backupExchangeDetails.push({
- base_url: ex.exchangeBaseUrl,
- reserve_closing_delay: ex.reserveClosingDelay,
- accounts: ex.wireInfo.accounts.map((x) => ({
- payto_uri: x.payto_uri,
- master_sig: x.master_sig,
- })),
- auditors: ex.auditors.map((x) => ({
- auditor_pub: x.auditor_pub,
- auditor_url: x.auditor_url,
- denomination_keys: x.denomination_keys,
- })),
- master_public_key: ex.masterPublicKey,
- currency: ex.currency,
- protocol_version: ex.protocolVersion,
- wire_fees: wireFees,
- signing_keys: ex.signingKeys.map((x) => ({
- key: x.key,
- master_sig: x.master_sig,
- stamp_end: x.stamp_end,
- stamp_expire: x.stamp_expire,
- stamp_start: x.stamp_start,
- })),
- tos_accepted_etag: ex.termsOfServiceAcceptedEtag,
- tos_accepted_timestamp: ex.termsOfServiceAcceptedTimestamp,
- denominations:
- backupDenominationsByExchange[ex.exchangeBaseUrl] ?? [],
- reserves: backupReservesByExchange[ex.exchangeBaseUrl] ?? [],
- });
- });
-
- const purchaseProposalIdSet = new Set<string>();
-
- await tx.purchases.iter().forEach((purch) => {
- const refunds: BackupRefundItem[] = [];
- purchaseProposalIdSet.add(purch.proposalId);
- for (const refundKey of Object.keys(purch.refunds)) {
- const ri = purch.refunds[refundKey];
- const common = {
- coin_pub: ri.coinPub,
- execution_time: ri.executionTime,
- obtained_time: ri.obtainedTime,
- refund_amount: Amounts.stringify(ri.refundAmount),
- rtransaction_id: ri.rtransactionId,
- total_refresh_cost_bound: Amounts.stringify(
- ri.totalRefreshCostBound,
- ),
- };
- switch (ri.type) {
- case RefundState.Applied:
- refunds.push({ type: BackupRefundState.Applied, ...common });
- break;
- case RefundState.Failed:
- refunds.push({ type: BackupRefundState.Failed, ...common });
- break;
- case RefundState.Pending:
- refunds.push({ type: BackupRefundState.Pending, ...common });
- break;
- }
- }
-
- backupPurchases.push({
- contract_terms_raw: purch.download.contractTermsRaw,
- auto_refund_deadline: purch.autoRefundDeadline,
- merchant_pay_sig: purch.merchantPaySig,
- pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({
- coin_pub: x,
- contribution: Amounts.stringify(
- purch.payCoinSelection.coinContributions[i],
- ),
- })),
- proposal_id: purch.proposalId,
- refunds,
- timestamp_accept: purch.timestampAccept,
- timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
- abort_status:
- purch.abortStatus === AbortStatus.None
- ? undefined
- : purch.abortStatus,
- nonce_priv: purch.noncePriv,
- merchant_sig: purch.download.contractData.merchantSig,
- total_pay_cost: Amounts.stringify(purch.totalPayCost),
- pay_coins_uid: purch.payCoinSelectionUid,
- });
- });
-
- await tx.proposals.iter().forEach((prop) => {
- if (purchaseProposalIdSet.has(prop.proposalId)) {
- return;
- }
- let propStatus: BackupProposalStatus;
- switch (prop.proposalStatus) {
- case ProposalStatus.ACCEPTED:
- return;
- case ProposalStatus.DOWNLOADING:
- case ProposalStatus.PROPOSED:
- propStatus = BackupProposalStatus.Proposed;
- break;
- case ProposalStatus.PERMANENTLY_FAILED:
- propStatus = BackupProposalStatus.PermanentlyFailed;
- break;
- case ProposalStatus.REFUSED:
- propStatus = BackupProposalStatus.Refused;
- break;
- case ProposalStatus.REPURCHASE:
- propStatus = BackupProposalStatus.Repurchase;
- break;
- }
- backupProposals.push({
- claim_token: prop.claimToken,
- nonce_priv: prop.noncePriv,
- proposal_id: prop.noncePriv,
- proposal_status: propStatus,
- repurchase_proposal_id: prop.repurchaseProposalId,
- timestamp: prop.timestamp,
- contract_terms_raw: prop.download?.contractTermsRaw,
- download_session_id: prop.downloadSessionId,
- merchant_base_url: prop.merchantBaseUrl,
- order_id: prop.orderId,
- merchant_sig: prop.download?.contractData.merchantSig,
- });
- });
-
- await tx.refreshGroups.iter().forEach((rg) => {
- const oldCoins: BackupRefreshOldCoin[] = [];
-
- for (let i = 0; i < rg.oldCoinPubs.length; i++) {
- let refreshSession: BackupRefreshSession | undefined;
- const s = rg.refreshSessionPerCoin[i];
- if (s) {
- refreshSession = {
- new_denoms: s.newDenoms.map((x) => ({
- count: x.count,
- denom_pub_hash: x.denomPubHash,
- })),
- session_secret_seed: s.sessionSecretSeed,
- noreveal_index: s.norevealIndex,
- };
- }
- oldCoins.push({
- coin_pub: rg.oldCoinPubs[i],
- estimated_output_amount: Amounts.stringify(
- rg.estimatedOutputPerCoin[i],
- ),
- finished: rg.statusPerCoin[i] === RefreshCoinStatus.Finished,
- input_amount: Amounts.stringify(rg.inputPerCoin[i]),
- refresh_session: refreshSession,
- });
- }
-
- backupRefreshGroups.push({
- reason: rg.reason as any,
- refresh_group_id: rg.refreshGroupId,
- timestamp_created: rg.timestampCreated,
- timestamp_finish: rg.timestampFinished,
- old_coins: oldCoins,
- });
- });
-
- const ts = getTimestampNow();
-
- if (!bs.lastBackupTimestamp) {
- bs.lastBackupTimestamp = ts;
- }
-
- const backupBlob: WalletBackupContentV1 = {
- schema_id: "gnu-taler-wallet-backup-content",
- schema_version: 1,
- exchanges: backupExchanges,
- exchange_details: backupExchangeDetails,
- wallet_root_pub: bs.walletRootPub,
- backup_providers: backupBackupProviders,
- current_device_id: bs.deviceId,
- proposals: backupProposals,
- purchases: backupPurchases,
- recoup_groups: backupRecoupGroups,
- refresh_groups: backupRefreshGroups,
- tips: backupTips,
- timestamp: bs.lastBackupTimestamp,
- trusted_auditors: {},
- trusted_exchanges: {},
- intern_table: {},
- error_reports: [],
- tombstones: [],
- };
-
- // If the backup changed, we change our nonce and timestamp.
-
- let h = encodeCrock(hash(stringToBytes(canonicalJson(backupBlob))));
- if (h !== bs.lastBackupPlainHash) {
- logger.trace(
- `plain backup hash changed (from ${bs.lastBackupPlainHash}to ${h})`,
- );
- bs.lastBackupTimestamp = ts;
- backupBlob.timestamp = ts;
- bs.lastBackupPlainHash = encodeCrock(
- hash(stringToBytes(canonicalJson(backupBlob))),
- );
- bs.lastBackupNonce = encodeCrock(getRandomBytes(32));
- logger.trace(
- `setting timestamp to ${timestampToIsoString(ts)} and nonce to ${
- bs.lastBackupNonce
- }`,
- );
- await tx.config.put({
- key: WALLET_BACKUP_STATE_KEY,
- value: bs,
- });
- } else {
- logger.trace("backup hash did not change");
- }
-
- return backupBlob;
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts
deleted file mode 100644
index 7623ab189..000000000
--- a/packages/taler-wallet-core/src/operations/backup/import.ts
+++ /dev/null
@@ -1,915 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- BackupPurchase,
- AmountJson,
- Amounts,
- BackupDenomSel,
- WalletBackupContentV1,
- getTimestampNow,
- BackupCoinSourceType,
- BackupProposalStatus,
- codecForContractTerms,
- BackupRefundState,
- RefreshReason,
- BackupRefreshReason,
-} from "@gnu-taler/taler-util";
-import {
- WalletContractData,
- DenomSelectionState,
- DenominationVerificationStatus,
- CoinSource,
- CoinSourceType,
- CoinStatus,
- ReserveBankInfo,
- ReserveRecordStatus,
- ProposalDownload,
- ProposalStatus,
- WalletRefundItem,
- RefundState,
- AbortStatus,
- RefreshSessionRecord,
- WireInfo,
- WalletStoresV1,
- RefreshCoinStatus,
-} from "../../db.js";
-import { PayCoinSelection } from "../../util/coinSelection.js";
-import { j2s } from "@gnu-taler/taler-util";
-import {
- checkDbInvariant,
- checkLogicInvariant,
-} from "../../util/invariants.js";
-import { Logger } from "@gnu-taler/taler-util";
-import { initRetryInfo } from "../../util/retries.js";
-import { InternalWalletState } from "../../common.js";
-import { provideBackupState } from "./state.js";
-import { makeEventId, TombstoneTag } from "../transactions.js";
-import { getExchangeDetails } from "../exchanges.js";
-import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
-
-const logger = new Logger("operations/backup/import.ts");
-
-function checkBackupInvariant(b: boolean, m?: string): asserts b {
- if (!b) {
- if (m) {
- throw Error(`BUG: backup invariant failed (${m})`);
- } else {
- throw Error("BUG: backup invariant failed");
- }
- }
-}
-
-/**
- * Re-compute information about the coin selection for a payment.
- */
-async function recoverPayCoinSelection(
- tx: GetReadWriteAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- coins: typeof WalletStoresV1.coins;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- contractData: WalletContractData,
- backupPurchase: BackupPurchase,
-): Promise<PayCoinSelection> {
- const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub);
- const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) =>
- Amounts.parseOrThrow(x.contribution),
- );
-
- const coveredExchanges: Set<string> = new Set();
-
- let totalWireFee: AmountJson = Amounts.getZero(contractData.amount.currency);
- let totalDepositFees: AmountJson = Amounts.getZero(
- contractData.amount.currency,
- );
-
- for (const coinPub of coinPubs) {
- const coinRecord = await tx.coins.get(coinPub);
- checkBackupInvariant(!!coinRecord);
- const denom = await tx.denominations.get([
- coinRecord.exchangeBaseUrl,
- coinRecord.denomPubHash,
- ]);
- checkBackupInvariant(!!denom);
- totalDepositFees = Amounts.add(totalDepositFees, denom.feeDeposit).amount;
-
- if (!coveredExchanges.has(coinRecord.exchangeBaseUrl)) {
- const exchangeDetails = await getExchangeDetails(
- tx,
- coinRecord.exchangeBaseUrl,
- );
- checkBackupInvariant(!!exchangeDetails);
- let wireFee: AmountJson | undefined;
- const feesForType = exchangeDetails.wireInfo.feesForType;
- checkBackupInvariant(!!feesForType);
- for (const fee of feesForType[contractData.wireMethod] || []) {
- if (
- fee.startStamp <= contractData.timestamp &&
- fee.endStamp >= contractData.timestamp
- ) {
- wireFee = fee.wireFee;
- break;
- }
- }
- if (wireFee) {
- totalWireFee = Amounts.add(totalWireFee, wireFee).amount;
- }
- coveredExchanges.add(coinRecord.exchangeBaseUrl);
- }
- }
-
- let customerWireFee: AmountJson;
-
- const amortizedWireFee = Amounts.divide(
- totalWireFee,
- contractData.wireFeeAmortization,
- );
- if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
- customerWireFee = amortizedWireFee;
- } else {
- customerWireFee = Amounts.getZero(contractData.amount.currency);
- }
-
- const customerDepositFees = Amounts.sub(
- totalDepositFees,
- contractData.maxDepositFee,
- ).amount;
-
- return {
- coinPubs,
- coinContributions,
- paymentAmount: contractData.amount,
- customerWireFees: customerWireFee,
- customerDepositFees,
- };
-}
-
-async function getDenomSelStateFromBackup(
- tx: GetReadOnlyAccess<{ denominations: typeof WalletStoresV1.denominations }>,
- exchangeBaseUrl: string,
- sel: BackupDenomSel,
-): Promise<DenomSelectionState> {
- const d0 = await tx.denominations.get([
- exchangeBaseUrl,
- sel[0].denom_pub_hash,
- ]);
- checkBackupInvariant(!!d0);
- const selectedDenoms: {
- denomPubHash: string;
- count: number;
- }[] = [];
- let totalCoinValue = Amounts.getZero(d0.value.currency);
- let totalWithdrawCost = Amounts.getZero(d0.value.currency);
- for (const s of sel) {
- const d = await tx.denominations.get([exchangeBaseUrl, s.denom_pub_hash]);
- checkBackupInvariant(!!d);
- totalCoinValue = Amounts.add(totalCoinValue, d.value).amount;
- totalWithdrawCost = Amounts.add(totalWithdrawCost, d.value, d.feeWithdraw)
- .amount;
- }
- return {
- selectedDenoms,
- totalCoinValue,
- totalWithdrawCost,
- };
-}
-
-export interface CompletedCoin {
- coinPub: string;
- coinEvHash: string;
-}
-
-/**
- * Precomputed cryptographic material for a backup import.
- *
- * We separate this data from the backup blob as we want the backup
- * blob to be small, and we can't compute it during the database transaction,
- * as the async crypto worker communication would auto-close the database transaction.
- */
-export interface BackupCryptoPrecomputedData {
- denomPubToHash: Record<string, string>;
- coinPrivToCompletedCoin: Record<string, CompletedCoin>;
- proposalNoncePrivToPub: { [priv: string]: string };
- proposalIdToContractTermsHash: { [proposalId: string]: string };
- reservePrivToPub: Record<string, string>;
-}
-
-export async function importBackup(
- ws: InternalWalletState,
- backupBlobArg: any,
- cryptoComp: BackupCryptoPrecomputedData,
-): Promise<void> {
- await provideBackupState(ws);
-
- logger.info(`importing backup ${j2s(backupBlobArg)}`);
-
- return ws.db
- .mktx((x) => ({
- config: x.config,
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- coins: x.coins,
- denominations: x.denominations,
- purchases: x.purchases,
- proposals: x.proposals,
- refreshGroups: x.refreshGroups,
- backupProviders: x.backupProviders,
- tips: x.tips,
- recoupGroups: x.recoupGroups,
- reserves: x.reserves,
- withdrawalGroups: x.withdrawalGroups,
- tombstones: x.tombstones,
- depositGroups: x.depositGroups,
- }))
- .runReadWrite(async (tx) => {
- // FIXME: validate schema!
- const backupBlob = backupBlobArg as WalletBackupContentV1;
-
- // FIXME: validate version
-
- for (const tombstone of backupBlob.tombstones) {
- await tx.tombstones.put({
- id: tombstone,
- });
- }
-
- const tombstoneSet = new Set(
- (await tx.tombstones.iter().toArray()).map((x) => x.id),
- );
-
- // FIXME: Validate that the "details pointer" is correct
-
- for (const backupExchange of backupBlob.exchanges) {
- const existingExchange = await tx.exchanges.get(
- backupExchange.base_url,
- );
- if (existingExchange) {
- continue;
- }
- await tx.exchanges.put({
- baseUrl: backupExchange.base_url,
- detailsPointer: {
- currency: backupExchange.currency,
- masterPublicKey: backupExchange.master_public_key,
- updateClock: backupExchange.update_clock,
- },
- permanent: true,
- retryInfo: initRetryInfo(),
- lastUpdate: undefined,
- nextUpdate: getTimestampNow(),
- nextRefreshCheck: getTimestampNow(),
- });
- }
-
- for (const backupExchangeDetails of backupBlob.exchange_details) {
- const existingExchangeDetails = await tx.exchangeDetails.get([
- backupExchangeDetails.base_url,
- backupExchangeDetails.currency,
- backupExchangeDetails.master_public_key,
- ]);
-
- if (!existingExchangeDetails) {
- const wireInfo: WireInfo = {
- accounts: backupExchangeDetails.accounts.map((x) => ({
- master_sig: x.master_sig,
- payto_uri: x.payto_uri,
- })),
- feesForType: {},
- };
- for (const fee of backupExchangeDetails.wire_fees) {
- const w = (wireInfo.feesForType[fee.wire_type] ??= []);
- w.push({
- closingFee: Amounts.parseOrThrow(fee.closing_fee),
- endStamp: fee.end_stamp,
- sig: fee.sig,
- startStamp: fee.start_stamp,
- wireFee: Amounts.parseOrThrow(fee.wire_fee),
- });
- }
- await tx.exchangeDetails.put({
- exchangeBaseUrl: backupExchangeDetails.base_url,
- termsOfServiceAcceptedEtag: backupExchangeDetails.tos_accepted_etag,
- termsOfServiceText: undefined,
- termsOfServiceLastEtag: undefined,
- termsOfServiceContentType: undefined,
- termsOfServiceAcceptedTimestamp:
- backupExchangeDetails.tos_accepted_timestamp,
- wireInfo,
- currency: backupExchangeDetails.currency,
- auditors: backupExchangeDetails.auditors.map((x) => ({
- auditor_pub: x.auditor_pub,
- auditor_url: x.auditor_url,
- denomination_keys: x.denomination_keys,
- })),
- masterPublicKey: backupExchangeDetails.master_public_key,
- protocolVersion: backupExchangeDetails.protocol_version,
- reserveClosingDelay: backupExchangeDetails.reserve_closing_delay,
- signingKeys: backupExchangeDetails.signing_keys.map((x) => ({
- key: x.key,
- master_sig: x.master_sig,
- stamp_end: x.stamp_end,
- stamp_expire: x.stamp_expire,
- stamp_start: x.stamp_start,
- })),
- });
- }
-
- for (const backupDenomination of backupExchangeDetails.denominations) {
- const denomPubHash =
- cryptoComp.denomPubToHash[backupDenomination.denom_pub];
- checkLogicInvariant(!!denomPubHash);
- const existingDenom = await tx.denominations.get([
- backupExchangeDetails.base_url,
- denomPubHash,
- ]);
- if (!existingDenom) {
- logger.info(
- `importing backup denomination: ${j2s(backupDenomination)}`,
- );
-
- await tx.denominations.put({
- denomPub: backupDenomination.denom_pub,
- denomPubHash: denomPubHash,
- exchangeBaseUrl: backupExchangeDetails.base_url,
- exchangeMasterPub: backupExchangeDetails.master_public_key,
- feeDeposit: Amounts.parseOrThrow(backupDenomination.fee_deposit),
- feeRefresh: Amounts.parseOrThrow(backupDenomination.fee_refresh),
- feeRefund: Amounts.parseOrThrow(backupDenomination.fee_refund),
- feeWithdraw: Amounts.parseOrThrow(
- backupDenomination.fee_withdraw,
- ),
- isOffered: backupDenomination.is_offered,
- isRevoked: backupDenomination.is_revoked,
- masterSig: backupDenomination.master_sig,
- stampExpireDeposit: backupDenomination.stamp_expire_deposit,
- stampExpireLegal: backupDenomination.stamp_expire_legal,
- stampExpireWithdraw: backupDenomination.stamp_expire_withdraw,
- stampStart: backupDenomination.stamp_start,
- verificationStatus: DenominationVerificationStatus.VerifiedGood,
- value: Amounts.parseOrThrow(backupDenomination.value),
- listIssueDate: backupDenomination.list_issue_date,
- });
- }
- for (const backupCoin of backupDenomination.coins) {
- const compCoin =
- cryptoComp.coinPrivToCompletedCoin[backupCoin.coin_priv];
- checkLogicInvariant(!!compCoin);
- const existingCoin = await tx.coins.get(compCoin.coinPub);
- if (!existingCoin) {
- let coinSource: CoinSource;
- switch (backupCoin.coin_source.type) {
- case BackupCoinSourceType.Refresh:
- coinSource = {
- type: CoinSourceType.Refresh,
- oldCoinPub: backupCoin.coin_source.old_coin_pub,
- };
- break;
- case BackupCoinSourceType.Tip:
- coinSource = {
- type: CoinSourceType.Tip,
- coinIndex: backupCoin.coin_source.coin_index,
- walletTipId: backupCoin.coin_source.wallet_tip_id,
- };
- break;
- case BackupCoinSourceType.Withdraw:
- coinSource = {
- type: CoinSourceType.Withdraw,
- coinIndex: backupCoin.coin_source.coin_index,
- reservePub: backupCoin.coin_source.reserve_pub,
- withdrawalGroupId:
- backupCoin.coin_source.withdrawal_group_id,
- };
- break;
- }
- await tx.coins.put({
- blindingKey: backupCoin.blinding_key,
- coinEvHash: compCoin.coinEvHash,
- coinPriv: backupCoin.coin_priv,
- currentAmount: Amounts.parseOrThrow(backupCoin.current_amount),
- denomSig: backupCoin.denom_sig,
- coinPub: compCoin.coinPub,
- suspended: false,
- exchangeBaseUrl: backupExchangeDetails.base_url,
- denomPub: backupDenomination.denom_pub,
- denomPubHash,
- status: backupCoin.fresh
- ? CoinStatus.Fresh
- : CoinStatus.Dormant,
- coinSource,
- });
- }
- }
- }
-
- for (const backupReserve of backupExchangeDetails.reserves) {
- const reservePub =
- cryptoComp.reservePrivToPub[backupReserve.reserve_priv];
- const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub);
- if (tombstoneSet.has(ts)) {
- continue;
- }
- checkLogicInvariant(!!reservePub);
- const existingReserve = await tx.reserves.get(reservePub);
- const instructedAmount = Amounts.parseOrThrow(
- backupReserve.instructed_amount,
- );
- if (!existingReserve) {
- let bankInfo: ReserveBankInfo | undefined;
- if (backupReserve.bank_info) {
- bankInfo = {
- exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri,
- statusUrl: backupReserve.bank_info.status_url,
- confirmUrl: backupReserve.bank_info.confirm_url,
- };
- }
- await tx.reserves.put({
- currency: instructedAmount.currency,
- instructedAmount,
- exchangeBaseUrl: backupExchangeDetails.base_url,
- reservePub,
- reservePriv: backupReserve.reserve_priv,
- requestedQuery: false,
- bankInfo,
- timestampCreated: backupReserve.timestamp_created,
- timestampBankConfirmed:
- backupReserve.bank_info?.timestamp_bank_confirmed,
- timestampReserveInfoPosted:
- backupReserve.bank_info?.timestamp_reserve_info_posted,
- senderWire: backupReserve.sender_wire,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- lastSuccessfulStatusQuery: { t_ms: "never" },
- initialWithdrawalGroupId:
- backupReserve.initial_withdrawal_group_id,
- initialWithdrawalStarted:
- backupReserve.withdrawal_groups.length > 0,
- // FIXME!
- reserveStatus: ReserveRecordStatus.QUERYING_STATUS,
- initialDenomSel: await getDenomSelStateFromBackup(
- tx,
- backupExchangeDetails.base_url,
- backupReserve.initial_selected_denoms,
- ),
- });
- }
- for (const backupWg of backupReserve.withdrawal_groups) {
- const ts = makeEventId(
- TombstoneTag.DeleteWithdrawalGroup,
- backupWg.withdrawal_group_id,
- );
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingWg = await tx.withdrawalGroups.get(
- backupWg.withdrawal_group_id,
- );
- if (!existingWg) {
- await tx.withdrawalGroups.put({
- denomsSel: await getDenomSelStateFromBackup(
- tx,
- backupExchangeDetails.base_url,
- backupWg.selected_denoms,
- ),
- exchangeBaseUrl: backupExchangeDetails.base_url,
- lastError: undefined,
- rawWithdrawalAmount: Amounts.parseOrThrow(
- backupWg.raw_withdrawal_amount,
- ),
- reservePub,
- retryInfo: initRetryInfo(),
- secretSeed: backupWg.secret_seed,
- timestampStart: backupWg.timestamp_created,
- timestampFinish: backupWg.timestamp_finish,
- withdrawalGroupId: backupWg.withdrawal_group_id,
- denomSelUid: backupWg.selected_denoms_id,
- });
- }
- }
- }
- }
-
- for (const backupProposal of backupBlob.proposals) {
- const ts = makeEventId(
- TombstoneTag.DeletePayment,
- backupProposal.proposal_id,
- );
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingProposal = await tx.proposals.get(
- backupProposal.proposal_id,
- );
- if (!existingProposal) {
- let download: ProposalDownload | undefined;
- let proposalStatus: ProposalStatus;
- switch (backupProposal.proposal_status) {
- case BackupProposalStatus.Proposed:
- if (backupProposal.contract_terms_raw) {
- proposalStatus = ProposalStatus.PROPOSED;
- } else {
- proposalStatus = ProposalStatus.DOWNLOADING;
- }
- break;
- case BackupProposalStatus.Refused:
- proposalStatus = ProposalStatus.REFUSED;
- break;
- case BackupProposalStatus.Repurchase:
- proposalStatus = ProposalStatus.REPURCHASE;
- break;
- case BackupProposalStatus.PermanentlyFailed:
- proposalStatus = ProposalStatus.PERMANENTLY_FAILED;
- break;
- }
- if (backupProposal.contract_terms_raw) {
- checkDbInvariant(!!backupProposal.merchant_sig);
- const parsedContractTerms = codecForContractTerms().decode(
- backupProposal.contract_terms_raw,
- );
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- const contractTermsHash =
- cryptoComp.proposalIdToContractTermsHash[
- backupProposal.proposal_id
- ];
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(
- parsedContractTerms.max_wire_fee,
- );
- } else {
- maxWireFee = Amounts.getZero(amount.currency);
- }
- download = {
- contractData: {
- amount,
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig: backupProposal.merchant_sig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee,
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization:
- parsedContractTerms.wire_fee_amortization || 1,
- allowedAuditors: parsedContractTerms.auditors.map((x) => ({
- auditorBaseUrl: x.url,
- auditorPub: x.auditor_pub,
- })),
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.parseOrThrow(
- parsedContractTerms.max_fee,
- ),
- merchant: parsedContractTerms.merchant,
- products: parsedContractTerms.products,
- summaryI18n: parsedContractTerms.summary_i18n,
- },
- contractTermsRaw: backupProposal.contract_terms_raw,
- };
- }
- await tx.proposals.put({
- claimToken: backupProposal.claim_token,
- lastError: undefined,
- merchantBaseUrl: backupProposal.merchant_base_url,
- timestamp: backupProposal.timestamp,
- orderId: backupProposal.order_id,
- noncePriv: backupProposal.nonce_priv,
- noncePub:
- cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
- proposalId: backupProposal.proposal_id,
- repurchaseProposalId: backupProposal.repurchase_proposal_id,
- retryInfo: initRetryInfo(),
- download,
- proposalStatus,
- });
- }
- }
-
- for (const backupPurchase of backupBlob.purchases) {
- const ts = makeEventId(
- TombstoneTag.DeletePayment,
- backupPurchase.proposal_id,
- );
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingPurchase = await tx.purchases.get(
- backupPurchase.proposal_id,
- );
- if (!existingPurchase) {
- const refunds: { [refundKey: string]: WalletRefundItem } = {};
- for (const backupRefund of backupPurchase.refunds) {
- const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`;
- const coin = await tx.coins.get(backupRefund.coin_pub);
- checkBackupInvariant(!!coin);
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- checkBackupInvariant(!!denom);
- const common = {
- coinPub: backupRefund.coin_pub,
- executionTime: backupRefund.execution_time,
- obtainedTime: backupRefund.obtained_time,
- refundAmount: Amounts.parseOrThrow(backupRefund.refund_amount),
- refundFee: denom.feeRefund,
- rtransactionId: backupRefund.rtransaction_id,
- totalRefreshCostBound: Amounts.parseOrThrow(
- backupRefund.total_refresh_cost_bound,
- ),
- };
- switch (backupRefund.type) {
- case BackupRefundState.Applied:
- refunds[key] = {
- type: RefundState.Applied,
- ...common,
- };
- break;
- case BackupRefundState.Failed:
- refunds[key] = {
- type: RefundState.Failed,
- ...common,
- };
- break;
- case BackupRefundState.Pending:
- refunds[key] = {
- type: RefundState.Pending,
- ...common,
- };
- break;
- }
- }
- let abortStatus: AbortStatus;
- switch (backupPurchase.abort_status) {
- case "abort-finished":
- abortStatus = AbortStatus.AbortFinished;
- break;
- case "abort-refund":
- abortStatus = AbortStatus.AbortRefund;
- break;
- case undefined:
- abortStatus = AbortStatus.None;
- break;
- default:
- logger.warn(
- `got backup purchase abort_status ${j2s(
- backupPurchase.abort_status,
- )}`,
- );
- throw Error("not reachable");
- }
- const parsedContractTerms = codecForContractTerms().decode(
- backupPurchase.contract_terms_raw,
- );
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- const contractTermsHash =
- cryptoComp.proposalIdToContractTermsHash[
- backupPurchase.proposal_id
- ];
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
- } else {
- maxWireFee = Amounts.getZero(amount.currency);
- }
- const download: ProposalDownload = {
- contractData: {
- amount,
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig: backupPurchase.merchant_sig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee,
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization:
- parsedContractTerms.wire_fee_amortization || 1,
- allowedAuditors: parsedContractTerms.auditors.map((x) => ({
- auditorBaseUrl: x.url,
- auditorPub: x.auditor_pub,
- })),
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
- merchant: parsedContractTerms.merchant,
- products: parsedContractTerms.products,
- summaryI18n: parsedContractTerms.summary_i18n,
- },
- contractTermsRaw: backupPurchase.contract_terms_raw,
- };
- await tx.purchases.put({
- proposalId: backupPurchase.proposal_id,
- noncePriv: backupPurchase.nonce_priv,
- noncePub:
- cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
- lastPayError: undefined,
- autoRefundDeadline: { t_ms: "never" },
- refundStatusRetryInfo: initRetryInfo(),
- lastRefundStatusError: undefined,
- timestampAccept: backupPurchase.timestamp_accept,
- timestampFirstSuccessfulPay:
- backupPurchase.timestamp_first_successful_pay,
- timestampLastRefundStatus: undefined,
- merchantPaySig: backupPurchase.merchant_pay_sig,
- lastSessionId: undefined,
- abortStatus,
- // FIXME!
- payRetryInfo: initRetryInfo(),
- download,
- paymentSubmitPending: !backupPurchase.timestamp_first_successful_pay,
- refundQueryRequested: false,
- payCoinSelection: await recoverPayCoinSelection(
- tx,
- download.contractData,
- backupPurchase,
- ),
- coinDepositPermissions: undefined,
- totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost),
- refunds,
- payCoinSelectionUid: backupPurchase.pay_coins_uid,
- });
- }
- }
-
- for (const backupRefreshGroup of backupBlob.refresh_groups) {
- const ts = makeEventId(
- TombstoneTag.DeleteRefreshGroup,
- backupRefreshGroup.refresh_group_id,
- );
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingRg = await tx.refreshGroups.get(
- backupRefreshGroup.refresh_group_id,
- );
- if (!existingRg) {
- let reason: RefreshReason;
- switch (backupRefreshGroup.reason) {
- case BackupRefreshReason.AbortPay:
- reason = RefreshReason.AbortPay;
- break;
- case BackupRefreshReason.BackupRestored:
- reason = RefreshReason.BackupRestored;
- break;
- case BackupRefreshReason.Manual:
- reason = RefreshReason.Manual;
- break;
- case BackupRefreshReason.Pay:
- reason = RefreshReason.Pay;
- break;
- case BackupRefreshReason.Recoup:
- reason = RefreshReason.Recoup;
- break;
- case BackupRefreshReason.Refund:
- reason = RefreshReason.Refund;
- break;
- case BackupRefreshReason.Scheduled:
- reason = RefreshReason.Scheduled;
- break;
- }
- const refreshSessionPerCoin: (
- | RefreshSessionRecord
- | undefined
- )[] = [];
- for (const oldCoin of backupRefreshGroup.old_coins) {
- const c = await tx.coins.get(oldCoin.coin_pub);
- checkBackupInvariant(!!c);
- if (oldCoin.refresh_session) {
- const denomSel = await getDenomSelStateFromBackup(
- tx,
- c.exchangeBaseUrl,
- oldCoin.refresh_session.new_denoms,
- );
- refreshSessionPerCoin.push({
- sessionSecretSeed: oldCoin.refresh_session.session_secret_seed,
- norevealIndex: oldCoin.refresh_session.noreveal_index,
- newDenoms: oldCoin.refresh_session.new_denoms.map((x) => ({
- count: x.count,
- denomPubHash: x.denom_pub_hash,
- })),
- amountRefreshOutput: denomSel.totalCoinValue,
- });
- } else {
- refreshSessionPerCoin.push(undefined);
- }
- }
- await tx.refreshGroups.put({
- timestampFinished: backupRefreshGroup.timestamp_finish,
- timestampCreated: backupRefreshGroup.timestamp_created,
- refreshGroupId: backupRefreshGroup.refresh_group_id,
- reason,
- lastError: undefined,
- lastErrorPerCoin: {},
- oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub),
- statusPerCoin: backupRefreshGroup.old_coins.map((x) =>
- x.finished
- ? RefreshCoinStatus.Finished
- : RefreshCoinStatus.Pending,
- ),
- inputPerCoin: backupRefreshGroup.old_coins.map((x) =>
- Amounts.parseOrThrow(x.input_amount),
- ),
- estimatedOutputPerCoin: backupRefreshGroup.old_coins.map((x) =>
- Amounts.parseOrThrow(x.estimated_output_amount),
- ),
- refreshSessionPerCoin,
- retryInfo: initRetryInfo(),
- });
- }
- }
-
- for (const backupTip of backupBlob.tips) {
- const ts = makeEventId(TombstoneTag.DeleteTip, backupTip.wallet_tip_id);
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingTip = await tx.tips.get(backupTip.wallet_tip_id);
- if (!existingTip) {
- const denomsSel = await getDenomSelStateFromBackup(
- tx,
- backupTip.exchange_base_url,
- backupTip.selected_denoms,
- );
- await tx.tips.put({
- acceptedTimestamp: backupTip.timestamp_accepted,
- createdTimestamp: backupTip.timestamp_created,
- denomsSel,
- exchangeBaseUrl: backupTip.exchange_base_url,
- lastError: undefined,
- merchantBaseUrl: backupTip.exchange_base_url,
- merchantTipId: backupTip.merchant_tip_id,
- pickedUpTimestamp: backupTip.timestamp_finished,
- retryInfo: initRetryInfo(),
- secretSeed: backupTip.secret_seed,
- tipAmountEffective: denomsSel.totalCoinValue,
- tipAmountRaw: Amounts.parseOrThrow(backupTip.tip_amount_raw),
- tipExpiration: backupTip.timestamp_expiration,
- walletTipId: backupTip.wallet_tip_id,
- denomSelUid: backupTip.selected_denoms_uid,
- });
- }
- }
-
- // We now process tombstones.
- // The import code above should already prevent
- // importing things that are tombstoned,
- // but we do tombstone processing last just to be sure.
-
- for (const tombstone of tombstoneSet) {
- const [type, ...rest] = tombstone.split(":");
- if (type === TombstoneTag.DeleteDepositGroup) {
- await tx.depositGroups.delete(rest[0]);
- } else if (type === TombstoneTag.DeletePayment) {
- await tx.purchases.delete(rest[0]);
- await tx.proposals.delete(rest[0]);
- } else if (type === TombstoneTag.DeleteRefreshGroup) {
- await tx.refreshGroups.delete(rest[0]);
- } else if (type === TombstoneTag.DeleteRefund) {
- // Nothing required, will just prevent display
- // in the transactions list
- } else if (type === TombstoneTag.DeleteReserve) {
- // FIXME: Once we also have account (=kyc) reserves,
- // we need to check if the reserve is an account before deleting here
- await tx.reserves.delete(rest[0]);
- } else if (type === TombstoneTag.DeleteTip) {
- await tx.tips.delete(rest[0]);
- } else if (type === TombstoneTag.DeleteWithdrawalGroup) {
- await tx.withdrawalGroups.delete(rest[0]);
- } else {
- logger.warn(`unable to process tombstone of type '${type}'`);
- }
- }
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts
deleted file mode 100644
index 913ffcb2e..000000000
--- a/packages/taler-wallet-core/src/operations/backup/index.ts
+++ /dev/null
@@ -1,1005 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of wallet backups (export/import/upload) and sync
- * server management.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- AmountString,
- BackupRecovery,
- buildCodecForObject,
- canonicalizeBaseUrl,
- canonicalJson,
- Codec,
- codecForAmountString,
- codecForBoolean,
- codecForList,
- codecForNumber,
- codecForString,
- codecOptional,
- ConfirmPayResultType,
- durationFromSpec,
- getTimestampNow,
- j2s,
- Logger,
- notEmpty,
- NotificationType,
- PreparePayResultType,
- RecoveryLoadRequest,
- RecoveryMergeStrategy,
- TalerErrorDetails,
- Timestamp,
- timestampAddDuration,
- URL,
- WalletBackupContentV1,
-} from "@gnu-taler/taler-util";
-import { gunzipSync, gzipSync } from "fflate";
-import { InternalWalletState } from "../../common.js";
-import { kdf } from "@gnu-taler/taler-util";
-import {
- secretbox,
- secretbox_open,
-} from "@gnu-taler/taler-util";
-import {
- bytesToString,
- decodeCrock,
- eddsaGetPublic,
- EddsaKeyPair,
- encodeCrock,
- getRandomBytes,
- hash,
- rsaBlind,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-import { CryptoApi } from "../../crypto/workers/cryptoApi.js";
-import {
- BackupProviderRecord,
- BackupProviderState,
- BackupProviderStateTag,
- BackupProviderTerms,
- ConfigRecord,
- WalletBackupConfState,
- WalletStoresV1,
- WALLET_BACKUP_STATE_KEY,
-} from "../../db.js";
-import { guardOperationException } from "../../errors.js";
-import {
- HttpResponseStatus,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
-} from "../../util/http.js";
-import {
- checkDbInvariant,
- checkLogicInvariant,
-} from "../../util/invariants.js";
-import { GetReadWriteAccess } from "../../util/query.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../../util/retries.js";
-import {
- checkPaymentByProposalId,
- confirmPay,
- preparePayForUri,
-} from "../pay.js";
-import { exportBackup } from "./export.js";
-import { BackupCryptoPrecomputedData, importBackup } from "./import.js";
-import { getWalletBackupState, provideBackupState } from "./state.js";
-
-const logger = new Logger("operations/backup.ts");
-
-function concatArrays(xs: Uint8Array[]): Uint8Array {
- let len = 0;
- for (const x of xs) {
- len += x.byteLength;
- }
- const out = new Uint8Array(len);
- let offset = 0;
- for (const x of xs) {
- out.set(x, offset);
- offset += x.length;
- }
- return out;
-}
-
-const magic = "TLRWBK01";
-
-/**
- * Encrypt the backup.
- *
- * Blob format:
- * Magic "TLRWBK01" (8 bytes)
- * Nonce (24 bytes)
- * Compressed JSON blob (rest)
- */
-export async function encryptBackup(
- config: WalletBackupConfState,
- blob: WalletBackupContentV1,
-): Promise<Uint8Array> {
- const chunks: Uint8Array[] = [];
- chunks.push(stringToBytes(magic));
- const nonceStr = config.lastBackupNonce;
- checkLogicInvariant(!!nonceStr);
- const nonce = decodeCrock(nonceStr).slice(0, 24);
- chunks.push(nonce);
- const backupJsonContent = canonicalJson(blob);
- logger.trace("backup JSON size", backupJsonContent.length);
- const compressedContent = gzipSync(stringToBytes(backupJsonContent), {
- mtime: 0,
- });
- const secret = deriveBlobSecret(config);
- const encrypted = secretbox(compressedContent, nonce.slice(0, 24), secret);
- chunks.push(encrypted);
- return concatArrays(chunks);
-}
-
-/**
- * Compute cryptographic values for a backup blob.
- *
- * FIXME: Take data that we already know from the DB.
- * FIXME: Move computations into crypto worker.
- */
-async function computeBackupCryptoData(
- cryptoApi: CryptoApi,
- backupContent: WalletBackupContentV1,
-): Promise<BackupCryptoPrecomputedData> {
- const cryptoData: BackupCryptoPrecomputedData = {
- coinPrivToCompletedCoin: {},
- denomPubToHash: {},
- proposalIdToContractTermsHash: {},
- proposalNoncePrivToPub: {},
- reservePrivToPub: {},
- };
- for (const backupExchangeDetails of backupContent.exchange_details) {
- for (const backupDenom of backupExchangeDetails.denominations) {
- for (const backupCoin of backupDenom.coins) {
- const coinPub = encodeCrock(
- eddsaGetPublic(decodeCrock(backupCoin.coin_priv)),
- );
- const blindedCoin = rsaBlind(
- hash(decodeCrock(backupCoin.coin_priv)),
- decodeCrock(backupCoin.blinding_key),
- decodeCrock(backupDenom.denom_pub),
- );
- cryptoData.coinPrivToCompletedCoin[backupCoin.coin_priv] = {
- coinEvHash: encodeCrock(hash(blindedCoin)),
- coinPub,
- };
- }
- cryptoData.denomPubToHash[backupDenom.denom_pub] = encodeCrock(
- hash(decodeCrock(backupDenom.denom_pub)),
- );
- }
- for (const backupReserve of backupExchangeDetails.reserves) {
- cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock(
- eddsaGetPublic(decodeCrock(backupReserve.reserve_priv)),
- );
- }
- }
- for (const prop of backupContent.proposals) {
- const contractTermsHash = await cryptoApi.hashString(
- canonicalJson(prop.contract_terms_raw),
- );
- const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv)));
- cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub;
- cryptoData.proposalIdToContractTermsHash[
- prop.proposal_id
- ] = contractTermsHash;
- }
- for (const purch of backupContent.purchases) {
- const contractTermsHash = await cryptoApi.hashString(
- canonicalJson(purch.contract_terms_raw),
- );
- const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(purch.nonce_priv)));
- cryptoData.proposalNoncePrivToPub[purch.nonce_priv] = noncePub;
- cryptoData.proposalIdToContractTermsHash[
- purch.proposal_id
- ] = contractTermsHash;
- }
- return cryptoData;
-}
-
-function deriveAccountKeyPair(
- bc: WalletBackupConfState,
- providerUrl: string,
-): EddsaKeyPair {
- const privateKey = kdf(
- 32,
- decodeCrock(bc.walletRootPriv),
- stringToBytes("taler-sync-account-key-salt"),
- stringToBytes(providerUrl),
- );
- return {
- eddsaPriv: privateKey,
- eddsaPub: eddsaGetPublic(privateKey),
- };
-}
-
-function deriveBlobSecret(bc: WalletBackupConfState): Uint8Array {
- return kdf(
- 32,
- decodeCrock(bc.walletRootPriv),
- stringToBytes("taler-sync-blob-secret-salt"),
- stringToBytes("taler-sync-blob-secret-info"),
- );
-}
-
-interface BackupForProviderArgs {
- backupProviderBaseUrl: string;
-
- /**
- * Should we attempt one more upload after trying
- * to pay?
- */
- retryAfterPayment: boolean;
-}
-
-function getNextBackupTimestamp(): Timestamp {
- // FIXME: Randomize!
- return timestampAddDuration(
- getTimestampNow(),
- durationFromSpec({ minutes: 5 }),
- );
-}
-
-async function runBackupCycleForProvider(
- ws: InternalWalletState,
- args: BackupForProviderArgs,
-): Promise<void> {
- const provider = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- return tx.backupProviders.get(args.backupProviderBaseUrl);
- });
-
- if (!provider) {
- logger.warn("provider disappeared");
- return;
- }
-
- const backupJson = await exportBackup(ws);
- const backupConfig = await provideBackupState(ws);
- const encBackup = await encryptBackup(backupConfig, backupJson);
- const currentBackupHash = hash(encBackup);
-
- const accountKeyPair = deriveAccountKeyPair(backupConfig, provider.baseUrl);
-
- const newHash = encodeCrock(currentBackupHash);
- const oldHash = provider.lastBackupHash;
-
- logger.trace(`trying to upload backup to ${provider.baseUrl}`);
- logger.trace(`old hash ${oldHash}, new hash ${newHash}`);
-
- const syncSig = await ws.cryptoApi.makeSyncSignature({
- newHash: encodeCrock(currentBackupHash),
- oldHash: provider.lastBackupHash,
- accountPriv: encodeCrock(accountKeyPair.eddsaPriv),
- });
-
- logger.trace(`sync signature is ${syncSig}`);
-
- const accountBackupUrl = new URL(
- `/backups/${encodeCrock(accountKeyPair.eddsaPub)}`,
- provider.baseUrl,
- );
-
- const resp = await ws.http.fetch(accountBackupUrl.href, {
- method: "POST",
- body: encBackup,
- headers: {
- "content-type": "application/octet-stream",
- "sync-signature": syncSig,
- "if-none-match": newHash,
- ...(provider.lastBackupHash
- ? {
- "if-match": provider.lastBackupHash,
- }
- : {}),
- },
- });
-
- logger.trace(`sync response status: ${resp.status}`);
-
- if (resp.status === HttpResponseStatus.NotModified) {
- await ws.db
- .mktx((x) => ({ backupProvider: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const prov = await tx.backupProvider.get(provider.baseUrl);
- if (!prov) {
- return;
- }
- prov.lastBackupCycleTimestamp = getTimestampNow();
- prov.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getNextBackupTimestamp(),
- };
- await tx.backupProvider.put(prov);
- });
- return;
- }
-
- if (resp.status === HttpResponseStatus.PaymentRequired) {
- logger.trace("payment required for backup");
- logger.trace(`headers: ${j2s(resp.headers)}`);
- const talerUri = resp.headers.get("taler");
- if (!talerUri) {
- throw Error("no taler URI available to pay provider");
- }
- const res = await preparePayForUri(ws, talerUri);
- let proposalId = res.proposalId;
- let doPay: boolean = false;
- switch (res.status) {
- case PreparePayResultType.InsufficientBalance:
- // FIXME: record in provider state!
- logger.warn("insufficient balance to pay for backup provider");
- proposalId = res.proposalId;
- break;
- case PreparePayResultType.PaymentPossible:
- doPay = true;
- break;
- case PreparePayResultType.AlreadyConfirmed:
- break;
- }
-
- // FIXME: check if the provider is overcharging us!
-
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const provRec = await tx.backupProviders.get(provider.baseUrl);
- checkDbInvariant(!!provRec);
- const ids = new Set(provRec.paymentProposalIds);
- ids.add(proposalId);
- provRec.paymentProposalIds = Array.from(ids).sort();
- provRec.currentPaymentProposalId = proposalId;
- // FIXME: allocate error code for this!
- await tx.backupProviders.put(provRec);
- await incrementBackupRetryInTx(
- tx,
- args.backupProviderBaseUrl,
- undefined,
- );
- });
-
- if (doPay) {
- const confirmRes = await confirmPay(ws, proposalId);
- switch (confirmRes.type) {
- case ConfirmPayResultType.Pending:
- logger.warn("payment not yet finished yet");
- break;
- }
- }
-
- if (args.retryAfterPayment) {
- await runBackupCycleForProvider(ws, {
- ...args,
- retryAfterPayment: false,
- });
- }
- return;
- }
-
- if (resp.status === HttpResponseStatus.NoContent) {
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const prov = await tx.backupProviders.get(provider.baseUrl);
- if (!prov) {
- return;
- }
- prov.lastBackupHash = encodeCrock(currentBackupHash);
- prov.lastBackupCycleTimestamp = getTimestampNow();
- prov.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getNextBackupTimestamp(),
- };
- await tx.backupProviders.put(prov);
- });
- return;
- }
-
- if (resp.status === HttpResponseStatus.Conflict) {
- logger.info("conflicting backup found");
- const backupEnc = new Uint8Array(await resp.bytes());
- const backupConfig = await provideBackupState(ws);
- const blob = await decryptBackup(backupConfig, backupEnc);
- const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
- await importBackup(ws, blob, cryptoData);
- await ws.db
- .mktx((x) => ({ backupProvider: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const prov = await tx.backupProvider.get(provider.baseUrl);
- if (!prov) {
- logger.warn("backup provider not found anymore");
- return;
- }
- prov.lastBackupHash = encodeCrock(hash(backupEnc));
- // FIXME: Allocate error code for this situation?
- prov.state = {
- tag: BackupProviderStateTag.Retrying,
- retryInfo: initRetryInfo(),
- };
- await tx.backupProvider.put(prov);
- });
- logger.info("processed existing backup");
- // Now upload our own, merged backup.
- await runBackupCycleForProvider(ws, {
- ...args,
- retryAfterPayment: false,
- });
- return;
- }
-
- // Some other response that we did not expect!
-
- logger.error("parsing error response");
-
- const err = await readTalerErrorResponse(resp);
- logger.error(`got error response from backup provider: ${j2s(err)}`);
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- incrementBackupRetryInTx(tx, args.backupProviderBaseUrl, err);
- });
-}
-
-async function incrementBackupRetryInTx(
- tx: GetReadWriteAccess<{
- backupProviders: typeof WalletStoresV1.backupProviders;
- }>,
- backupProviderBaseUrl: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- const pr = await tx.backupProviders.get(backupProviderBaseUrl);
- if (!pr) {
- return;
- }
- if (pr.state.tag === BackupProviderStateTag.Retrying) {
- pr.state.retryInfo.retryCounter++;
- pr.state.lastError = err;
- updateRetryInfoTimeout(pr.state.retryInfo);
- } else if (pr.state.tag === BackupProviderStateTag.Ready) {
- pr.state = {
- tag: BackupProviderStateTag.Retrying,
- retryInfo: initRetryInfo(),
- lastError: err,
- };
- }
- await tx.backupProviders.put(pr);
-}
-
-async function incrementBackupRetry(
- ws: InternalWalletState,
- backupProviderBaseUrl: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) =>
- incrementBackupRetryInTx(tx, backupProviderBaseUrl, err),
- );
-}
-
-export async function processBackupForProvider(
- ws: InternalWalletState,
- backupProviderBaseUrl: string,
-): Promise<void> {
- const provider = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- return await tx.backupProviders.get(backupProviderBaseUrl);
- });
- if (!provider) {
- throw Error("unknown backup provider");
- }
-
- const onOpErr = (err: TalerErrorDetails): Promise<void> =>
- incrementBackupRetry(ws, backupProviderBaseUrl, err);
-
- const run = async () => {
- await runBackupCycleForProvider(ws, {
- backupProviderBaseUrl: provider.baseUrl,
- retryAfterPayment: true,
- });
- };
-
- await guardOperationException(run, onOpErr);
-}
-
-export interface RemoveBackupProviderRequest {
- provider: string;
-}
-
-export const codecForRemoveBackupProvider = (): Codec<RemoveBackupProviderRequest> =>
- buildCodecForObject<RemoveBackupProviderRequest>()
- .property("provider", codecForString())
- .build("RemoveBackupProviderRequest");
-
-export async function removeBackupProvider(
- ws: InternalWalletState,
- req: RemoveBackupProviderRequest,
-): Promise<void> {
- await ws.db
- .mktx(({ backupProviders }) => ({ backupProviders }))
- .runReadWrite(async (tx) => {
- await tx.backupProviders.delete(req.provider);
- });
-}
-
-export interface RunBackupCycleRequest {
- /**
- * List of providers to backup or empty for all known providers.
- */
- providers?: Array<string>;
-}
-
-export const codecForRunBackupCycle = (): Codec<RunBackupCycleRequest> =>
- buildCodecForObject<RunBackupCycleRequest>()
- .property("providers", codecOptional(codecForList(codecForString())))
- .build("RunBackupCycleRequest");
-
-/**
- * Do one backup cycle that consists of:
- * 1. Exporting a backup and try to upload it.
- * Stop if this step succeeds.
- * 2. Download, verify and import backups from connected sync accounts.
- * 3. Upload the updated backup blob.
- */
-export async function runBackupCycle(
- ws: InternalWalletState,
- req: RunBackupCycleRequest,
-): Promise<void> {
- const providers = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- if (req.providers) {
- const rs = await Promise.all(
- req.providers.map((id) => tx.backupProviders.get(id)),
- );
- return rs.filter(notEmpty);
- }
- return await tx.backupProviders.iter().toArray();
- });
-
- for (const provider of providers) {
- await runBackupCycleForProvider(ws, {
- backupProviderBaseUrl: provider.baseUrl,
- retryAfterPayment: true,
- });
- }
-}
-
-interface SyncTermsOfServiceResponse {
- // maximum backup size supported
- storage_limit_in_megabytes: number;
-
- // Fee for an account, per year.
- annual_fee: AmountString;
-
- // protocol version supported by the server,
- // for now always "0.0".
- version: string;
-}
-
-const codecForSyncTermsOfServiceResponse = (): Codec<SyncTermsOfServiceResponse> =>
- buildCodecForObject<SyncTermsOfServiceResponse>()
- .property("storage_limit_in_megabytes", codecForNumber())
- .property("annual_fee", codecForAmountString())
- .property("version", codecForString())
- .build("SyncTermsOfServiceResponse");
-
-export interface AddBackupProviderRequest {
- backupProviderBaseUrl: string;
-
- name: string;
- /**
- * Activate the provider. Should only be done after
- * the user has reviewed the provider.
- */
- activate?: boolean;
-}
-
-export const codecForAddBackupProviderRequest = (): Codec<AddBackupProviderRequest> =>
- buildCodecForObject<AddBackupProviderRequest>()
- .property("backupProviderBaseUrl", codecForString())
- .property("name", codecForString())
- .property("activate", codecOptional(codecForBoolean()))
- .build("AddBackupProviderRequest");
-
-export async function addBackupProvider(
- ws: InternalWalletState,
- req: AddBackupProviderRequest,
-): Promise<void> {
- logger.info(`adding backup provider ${j2s(req)}`);
- await provideBackupState(ws);
- const canonUrl = canonicalizeBaseUrl(req.backupProviderBaseUrl);
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const oldProv = await tx.backupProviders.get(canonUrl);
- if (oldProv) {
- logger.info("old backup provider found");
- if (req.activate) {
- oldProv.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getTimestampNow(),
- };
- logger.info("setting existing backup provider to active");
- await tx.backupProviders.put(oldProv);
- }
- return;
- }
- });
- const termsUrl = new URL("config", canonUrl);
- const resp = await ws.http.get(termsUrl.href);
- const terms = await readSuccessResponseJsonOrThrow(
- resp,
- codecForSyncTermsOfServiceResponse(),
- );
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- let state: BackupProviderState;
- if (req.activate) {
- state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getTimestampNow(),
- };
- } else {
- state = {
- tag: BackupProviderStateTag.Provisional,
- };
- }
- await tx.backupProviders.put({
- state,
- name: req.name,
- terms: {
- annualFee: terms.annual_fee,
- storageLimitInMegabytes: terms.storage_limit_in_megabytes,
- supportedProtocolVersion: terms.version,
- },
- paymentProposalIds: [],
- baseUrl: canonUrl,
- uids: [encodeCrock(getRandomBytes(32))],
- });
- });
-}
-
-export async function restoreFromRecoverySecret(): Promise<void> {}
-
-/**
- * Information about one provider.
- *
- * We don't store the account key here,
- * as that's derived from the wallet root key.
- */
-export interface ProviderInfo {
- active: boolean;
- syncProviderBaseUrl: string;
- name: string;
- terms?: BackupProviderTerms;
- /**
- * Last communication issue with the provider.
- */
- lastError?: TalerErrorDetails;
- lastSuccessfulBackupTimestamp?: Timestamp;
- lastAttemptedBackupTimestamp?: Timestamp;
- paymentProposalIds: string[];
- backupProblem?: BackupProblem;
- paymentStatus: ProviderPaymentStatus;
-}
-
-export type BackupProblem =
- | BackupUnreadableProblem
- | BackupConflictingDeviceProblem;
-
-export interface BackupUnreadableProblem {
- type: "backup-unreadable";
-}
-
-export interface BackupUnreadableProblem {
- type: "backup-unreadable";
-}
-
-export interface BackupConflictingDeviceProblem {
- type: "backup-conflicting-device";
- otherDeviceId: string;
- myDeviceId: string;
- backupTimestamp: Timestamp;
-}
-
-export type ProviderPaymentStatus =
- | ProviderPaymentTermsChanged
- | ProviderPaymentPaid
- | ProviderPaymentInsufficientBalance
- | ProviderPaymentUnpaid
- | ProviderPaymentPending;
-
-export interface BackupInfo {
- walletRootPub: string;
- deviceId: string;
- providers: ProviderInfo[];
-}
-
-export async function importBackupPlain(
- ws: InternalWalletState,
- blob: any,
-): Promise<void> {
- // FIXME: parse
- const backup: WalletBackupContentV1 = blob;
-
- const cryptoData = await computeBackupCryptoData(ws.cryptoApi, backup);
-
- await importBackup(ws, blob, cryptoData);
-}
-
-export enum ProviderPaymentType {
- Unpaid = "unpaid",
- Pending = "pending",
- InsufficientBalance = "insufficient-balance",
- Paid = "paid",
- TermsChanged = "terms-changed",
-}
-
-export interface ProviderPaymentUnpaid {
- type: ProviderPaymentType.Unpaid;
-}
-
-export interface ProviderPaymentInsufficientBalance {
- type: ProviderPaymentType.InsufficientBalance;
-}
-
-export interface ProviderPaymentPending {
- type: ProviderPaymentType.Pending;
-}
-
-export interface ProviderPaymentPaid {
- type: ProviderPaymentType.Paid;
- paidUntil: Timestamp;
-}
-
-export interface ProviderPaymentTermsChanged {
- type: ProviderPaymentType.TermsChanged;
- paidUntil: Timestamp;
- oldTerms: BackupProviderTerms;
- newTerms: BackupProviderTerms;
-}
-
-async function getProviderPaymentInfo(
- ws: InternalWalletState,
- provider: BackupProviderRecord,
-): Promise<ProviderPaymentStatus> {
- if (!provider.currentPaymentProposalId) {
- return {
- type: ProviderPaymentType.Unpaid,
- };
- }
- const status = await checkPaymentByProposalId(
- ws,
- provider.currentPaymentProposalId,
- );
- if (status.status === PreparePayResultType.InsufficientBalance) {
- return {
- type: ProviderPaymentType.InsufficientBalance,
- };
- }
- if (status.status === PreparePayResultType.PaymentPossible) {
- return {
- type: ProviderPaymentType.Pending,
- };
- }
- if (status.status === PreparePayResultType.AlreadyConfirmed) {
- if (status.paid) {
- return {
- type: ProviderPaymentType.Paid,
- paidUntil: timestampAddDuration(
- status.contractTerms.timestamp,
- durationFromSpec({ years: 1 }),
- ),
- };
- } else {
- return {
- type: ProviderPaymentType.Pending,
- };
- }
- }
- throw Error("not reached");
-}
-
-/**
- * Get information about the current state of wallet backups.
- */
-export async function getBackupInfo(
- ws: InternalWalletState,
-): Promise<BackupInfo> {
- const backupConfig = await provideBackupState(ws);
- const providerRecords = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- return await tx.backupProviders.iter().toArray();
- });
- const providers: ProviderInfo[] = [];
- for (const x of providerRecords) {
- providers.push({
- active: x.state.tag !== BackupProviderStateTag.Provisional,
- syncProviderBaseUrl: x.baseUrl,
- lastSuccessfulBackupTimestamp: x.lastBackupCycleTimestamp,
- paymentProposalIds: x.paymentProposalIds,
- lastError:
- x.state.tag === BackupProviderStateTag.Retrying
- ? x.state.lastError
- : undefined,
- paymentStatus: await getProviderPaymentInfo(ws, x),
- terms: x.terms,
- name: x.name,
- });
- }
- return {
- deviceId: backupConfig.deviceId,
- walletRootPub: backupConfig.walletRootPub,
- providers,
- };
-}
-
-/**
- * Get backup recovery information, including the wallet's
- * private key.
- */
-export async function getBackupRecovery(
- ws: InternalWalletState,
-): Promise<BackupRecovery> {
- const bs = await provideBackupState(ws);
- const providers = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- return await tx.backupProviders.iter().toArray();
- });
- return {
- providers: providers
- .filter((x) => x.state.tag !== BackupProviderStateTag.Provisional)
- .map((x) => {
- return {
- url: x.baseUrl,
- };
- }),
- walletRootPriv: bs.walletRootPriv,
- };
-}
-
-async function backupRecoveryTheirs(
- ws: InternalWalletState,
- br: BackupRecovery,
-) {
- await ws.db
- .mktx((x) => ({ config: x.config, backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- WALLET_BACKUP_STATE_KEY,
- );
- checkDbInvariant(!!backupStateEntry);
- checkDbInvariant(backupStateEntry.key === WALLET_BACKUP_STATE_KEY);
- backupStateEntry.value.lastBackupNonce = undefined;
- backupStateEntry.value.lastBackupTimestamp = undefined;
- backupStateEntry.value.lastBackupCheckTimestamp = undefined;
- backupStateEntry.value.lastBackupPlainHash = undefined;
- backupStateEntry.value.walletRootPriv = br.walletRootPriv;
- backupStateEntry.value.walletRootPub = encodeCrock(
- eddsaGetPublic(decodeCrock(br.walletRootPriv)),
- );
- await tx.config.put(backupStateEntry);
- for (const prov of br.providers) {
- const existingProv = await tx.backupProviders.get(prov.url);
- if (!existingProv) {
- await tx.backupProviders.put({
- baseUrl: prov.url,
- name: "not-defined",
- paymentProposalIds: [],
- state: {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getTimestampNow(),
- },
- uids: [encodeCrock(getRandomBytes(32))],
- });
- }
- }
- const providers = await tx.backupProviders.iter().toArray();
- for (const prov of providers) {
- prov.lastBackupCycleTimestamp = undefined;
- prov.lastBackupHash = undefined;
- await tx.backupProviders.put(prov);
- }
- });
-}
-
-async function backupRecoveryOurs(ws: InternalWalletState, br: BackupRecovery) {
- throw Error("not implemented");
-}
-
-export async function loadBackupRecovery(
- ws: InternalWalletState,
- br: RecoveryLoadRequest,
-): Promise<void> {
- const bs = await provideBackupState(ws);
- const providers = await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadOnly(async (tx) => {
- return await tx.backupProviders.iter().toArray();
- });
- let strategy = br.strategy;
- if (
- br.recovery.walletRootPriv != bs.walletRootPriv &&
- providers.length > 0 &&
- !strategy
- ) {
- throw Error(
- "recovery load strategy must be specified for wallet with existing providers",
- );
- } else if (!strategy) {
- // Default to using the new key if we don't have providers yet.
- strategy = RecoveryMergeStrategy.Theirs;
- }
- if (strategy === RecoveryMergeStrategy.Theirs) {
- return backupRecoveryTheirs(ws, br.recovery);
- } else {
- return backupRecoveryOurs(ws, br.recovery);
- }
-}
-
-export async function exportBackupEncrypted(
- ws: InternalWalletState,
-): Promise<Uint8Array> {
- await provideBackupState(ws);
- const blob = await exportBackup(ws);
- const bs = await ws.db
- .mktx((x) => ({ config: x.config }))
- .runReadOnly(async (tx) => {
- return await getWalletBackupState(ws, tx);
- });
- return encryptBackup(bs, blob);
-}
-
-export async function decryptBackup(
- backupConfig: WalletBackupConfState,
- data: Uint8Array,
-): Promise<WalletBackupContentV1> {
- const rMagic = bytesToString(data.slice(0, 8));
- if (rMagic != magic) {
- throw Error("invalid backup file (magic tag mismatch)");
- }
-
- const nonce = data.slice(8, 8 + 24);
- const box = data.slice(8 + 24);
- const secret = deriveBlobSecret(backupConfig);
- const dataCompressed = secretbox_open(box, nonce, secret);
- if (!dataCompressed) {
- throw Error("decryption failed");
- }
- return JSON.parse(bytesToString(gunzipSync(dataCompressed)));
-}
-
-export async function importBackupEncrypted(
- ws: InternalWalletState,
- data: Uint8Array,
-): Promise<void> {
- const backupConfig = await provideBackupState(ws);
- const blob = await decryptBackup(backupConfig, data);
- const cryptoData = await computeBackupCryptoData(ws.cryptoApi, blob);
- await importBackup(ws, blob, cryptoData);
-}
diff --git a/packages/taler-wallet-core/src/operations/backup/state.ts b/packages/taler-wallet-core/src/operations/backup/state.ts
deleted file mode 100644
index dc89c3d99..000000000
--- a/packages/taler-wallet-core/src/operations/backup/state.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-import {
- ConfigRecord,
- WalletBackupConfState,
- WalletStoresV1,
- WALLET_BACKUP_STATE_KEY,
-} from "../../db.js";
-import { checkDbInvariant } from "../../util/invariants.js";
-import { GetReadOnlyAccess } from "../../util/query.js";
-import { InternalWalletState } from "../../common.js";
-
-export async function provideBackupState(
- ws: InternalWalletState,
-): Promise<WalletBackupConfState> {
- const bs: ConfigRecord | undefined = await ws.db
- .mktx((x) => ({
- config: x.config,
- }))
- .runReadOnly(async (tx) => {
- return await tx.config.get(WALLET_BACKUP_STATE_KEY);
- });
- if (bs) {
- checkDbInvariant(bs.key === WALLET_BACKUP_STATE_KEY);
- return bs.value;
- }
- // We need to generate the key outside of the transaction
- // due to how IndexedDB works.
- const k = await ws.cryptoApi.createEddsaKeypair();
- const d = getRandomBytes(5);
- // FIXME: device ID should be configured when wallet is initialized
- // and be based on hostname
- const deviceId = `wallet-core-${encodeCrock(d)}`;
- return await ws.db
- .mktx((x) => ({
- config: x.config,
- }))
- .runReadWrite(async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- WALLET_BACKUP_STATE_KEY,
- );
- if (!backupStateEntry) {
- backupStateEntry = {
- key: WALLET_BACKUP_STATE_KEY,
- value: {
- deviceId,
- walletRootPub: k.pub,
- walletRootPriv: k.priv,
- lastBackupPlainHash: undefined,
- },
- };
- await tx.config.put(backupStateEntry);
- }
- checkDbInvariant(backupStateEntry.key === WALLET_BACKUP_STATE_KEY);
- return backupStateEntry.value;
- });
-}
-
-export async function getWalletBackupState(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{ config: typeof WalletStoresV1.config }>,
-): Promise<WalletBackupConfState> {
- const bs = await tx.config.get(WALLET_BACKUP_STATE_KEY);
- checkDbInvariant(!!bs, "wallet backup state should be in DB");
- checkDbInvariant(bs.key === WALLET_BACKUP_STATE_KEY);
- return bs.value;
-}
-
-export async function setWalletDeviceId(
- ws: InternalWalletState,
- deviceId: string,
-): Promise<void> {
- await provideBackupState(ws);
- await ws.db
- .mktx((x) => ({
- config: x.config,
- }))
- .runReadWrite(async (tx) => {
- let backupStateEntry: ConfigRecord | undefined = await tx.config.get(
- WALLET_BACKUP_STATE_KEY,
- );
- if (
- !backupStateEntry ||
- backupStateEntry.key !== WALLET_BACKUP_STATE_KEY
- ) {
- return;
- }
- backupStateEntry.value.deviceId = deviceId;
- await tx.config.put(backupStateEntry);
- });
-}
-
-export async function getWalletDeviceId(
- ws: InternalWalletState,
-): Promise<string> {
- const bs = await provideBackupState(ws);
- return bs.deviceId;
-}
diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts
deleted file mode 100644
index 298893920..000000000
--- a/packages/taler-wallet-core/src/operations/balance.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AmountJson,
- BalancesResponse,
- Amounts,
- Logger,
-} from "@gnu-taler/taler-util";
-import { CoinStatus, WalletStoresV1 } from "../db.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-import { InternalWalletState } from "../common.js";
-
-const logger = new Logger("operations/balance.ts");
-
-interface WalletBalance {
- available: AmountJson;
- pendingIncoming: AmountJson;
- pendingOutgoing: AmountJson;
-}
-
-/**
- * Get balance information.
- */
-export async function getBalancesInsideTransaction(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- reserves: typeof WalletStoresV1.reserves;
- coins: typeof WalletStoresV1.coins;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
- }>,
-): Promise<BalancesResponse> {
- const balanceStore: Record<string, WalletBalance> = {};
-
- /**
- * Add amount to a balance field, both for
- * the slicing by exchange and currency.
- */
- const initBalance = (currency: string): WalletBalance => {
- const b = balanceStore[currency];
- if (!b) {
- balanceStore[currency] = {
- available: Amounts.getZero(currency),
- pendingIncoming: Amounts.getZero(currency),
- pendingOutgoing: Amounts.getZero(currency),
- };
- }
- return balanceStore[currency];
- };
-
- // Initialize balance to zero, even if we didn't start withdrawing yet.
- await tx.reserves.iter().forEach((r) => {
- const b = initBalance(r.currency);
- if (!r.initialWithdrawalStarted) {
- b.pendingIncoming = Amounts.add(
- b.pendingIncoming,
- r.initialDenomSel.totalCoinValue,
- ).amount;
- }
- });
-
- await tx.coins.iter().forEach((c) => {
- // Only count fresh coins, as dormant coins will
- // already be in a refresh session.
- if (c.status === CoinStatus.Fresh) {
- const b = initBalance(c.currentAmount.currency);
- b.available = Amounts.add(b.available, c.currentAmount).amount;
- }
- });
-
- await tx.refreshGroups.iter().forEach((r) => {
- // Don't count finished refreshes, since the refresh already resulted
- // in coins being added to the wallet.
- if (r.timestampFinished) {
- return;
- }
- for (let i = 0; i < r.oldCoinPubs.length; i++) {
- const session = r.refreshSessionPerCoin[i];
- if (session) {
- const b = initBalance(session.amountRefreshOutput.currency);
- // We are always assuming the refresh will succeed, thus we
- // report the output as available balance.
- b.available = Amounts.add(
- b.available,
- session.amountRefreshOutput,
- ).amount;
- } else {
- const b = initBalance(r.inputPerCoin[i].currency);
- b.available = Amounts.add(
- b.available,
- r.estimatedOutputPerCoin[i],
- ).amount;
- }
- }
- });
-
- await tx.withdrawalGroups.iter().forEach((wds) => {
- if (wds.timestampFinish) {
- return;
- }
- const b = initBalance(wds.denomsSel.totalWithdrawCost.currency);
- b.pendingIncoming = Amounts.add(
- b.pendingIncoming,
- wds.denomsSel.totalCoinValue,
- ).amount;
- });
-
- const balancesResponse: BalancesResponse = {
- balances: [],
- };
-
- Object.keys(balanceStore)
- .sort()
- .forEach((c) => {
- const v = balanceStore[c];
- balancesResponse.balances.push({
- available: Amounts.stringify(v.available),
- pendingIncoming: Amounts.stringify(v.pendingIncoming),
- pendingOutgoing: Amounts.stringify(v.pendingOutgoing),
- hasPendingTransactions: false,
- requiresUserInput: false,
- });
- });
-
- return balancesResponse;
-}
-
-/**
- * Get detailed balance information, sliced by exchange and by currency.
- */
-export async function getBalances(
- ws: InternalWalletState,
-): Promise<BalancesResponse> {
- logger.trace("starting to compute balance");
-
- const wbal = await ws.db
- .mktx((x) => ({
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- reserves: x.reserves,
- purchases: x.purchases,
- withdrawalGroups: x.withdrawalGroups,
- }))
- .runReadOnly(async (tx) => {
- return getBalancesInsideTransaction(ws, tx);
- });
-
- logger.trace("finished computing wallet balance");
-
- return wbal;
-}
diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts
deleted file mode 100644
index 740242050..000000000
--- a/packages/taler-wallet-core/src/operations/deposits.ts
+++ /dev/null
@@ -1,470 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- Amounts,
- buildCodecForObject,
- canonicalJson,
- Codec,
- codecForString,
- codecForTimestamp,
- codecOptional,
- ContractTerms,
- CreateDepositGroupRequest,
- CreateDepositGroupResponse,
- durationFromSpec,
- getTimestampNow,
- Logger,
- NotificationType,
- parsePaytoUri,
- TalerErrorDetails,
- Timestamp,
- timestampAddDuration,
- timestampTruncateToSecond,
- TrackDepositGroupRequest,
- TrackDepositGroupResponse,
- URL,
-} from "@gnu-taler/taler-util";
-import { InternalWalletState } from "../common.js";
-import { kdf } from "@gnu-taler/taler-util";
-import {
- encodeCrock,
- getRandomBytes,
- stringToBytes,
-} from "@gnu-taler/taler-util";
-import { DepositGroupRecord } from "../db.js";
-import { guardOperationException } from "../errors.js";
-import { selectPayCoins } from "../util/coinSelection.js";
-import { readSuccessResponseJsonOrThrow } from "../util/http.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import { getExchangeDetails } from "./exchanges.js";
-import {
- applyCoinSpend,
- extractContractData,
- generateDepositPermissions,
- getCandidatePayCoins,
- getEffectiveDepositAmount,
- getTotalPaymentCost,
-} from "./pay.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("deposits.ts");
-
-interface DepositSuccess {
- // Optional base URL of the exchange for looking up wire transfers
- // associated with this transaction. If not given,
- // the base URL is the same as the one used for this request.
- // Can be used if the base URL for /transactions/ differs from that
- // for /coins/, i.e. for load balancing. Clients SHOULD
- // respect the transaction_base_url if provided. Any HTTP server
- // belonging to an exchange MUST generate a 307 or 308 redirection
- // to the correct base URL should a client uses the wrong base
- // URL, or if the base URL has changed since the deposit.
- transaction_base_url?: string;
-
- // timestamp when the deposit was received by the exchange.
- exchange_timestamp: Timestamp;
-
- // the EdDSA signature of TALER_DepositConfirmationPS using a current
- // signing key of the exchange affirming the successful
- // deposit and that the exchange will transfer the funds after the refund
- // deadline, or as soon as possible if the refund deadline is zero.
- exchange_sig: string;
-
- // public EdDSA key of the exchange that was used to
- // generate the signature.
- // Should match one of the exchange's signing keys from /keys. It is given
- // explicitly as the client might otherwise be confused by clock skew as to
- // which signing key was used.
- exchange_pub: string;
-}
-
-const codecForDepositSuccess = (): Codec<DepositSuccess> =>
- buildCodecForObject<DepositSuccess>()
- .property("exchange_pub", codecForString())
- .property("exchange_sig", codecForString())
- .property("exchange_timestamp", codecForTimestamp)
- .property("transaction_base_url", codecOptional(codecForString()))
- .build("DepositSuccess");
-
-function hashWire(paytoUri: string, salt: string): string {
- const r = kdf(
- 64,
- stringToBytes(paytoUri + "\0"),
- stringToBytes(salt + "\0"),
- stringToBytes("merchant-wire-signature"),
- );
- return encodeCrock(r);
-}
-
-async function resetDepositGroupRetry(
- ws: InternalWalletState,
- depositGroupId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.depositGroups.get(depositGroupId);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.depositGroups.put(x);
- }
- });
-}
-
-async function incrementDepositRetry(
- ws: InternalWalletState,
- depositGroupId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ depositGroups: x.depositGroups }))
- .runReadWrite(async (tx) => {
- const r = await tx.depositGroups.get(depositGroupId);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.depositGroups.put(r);
- });
- if (err) {
- ws.notify({ type: NotificationType.DepositOperationError, error: err });
- }
-}
-
-export async function processDepositGroup(
- ws: InternalWalletState,
- depositGroupId: string,
- forceNow = false,
-): Promise<void> {
- await ws.memoProcessDeposit.memo(depositGroupId, async () => {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementDepositRetry(ws, depositGroupId, e);
- return await guardOperationException(
- async () => await processDepositGroupImpl(ws, depositGroupId, forceNow),
- onOpErr,
- );
- });
-}
-
-async function processDepositGroupImpl(
- ws: InternalWalletState,
- depositGroupId: string,
- forceNow: boolean = false,
-): Promise<void> {
- if (forceNow) {
- await resetDepositGroupRetry(ws, depositGroupId);
- }
- const depositGroup = await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- }))
- .runReadOnly(async (tx) => {
- return tx.depositGroups.get(depositGroupId);
- });
- if (!depositGroup) {
- logger.warn(`deposit group ${depositGroupId} not found`);
- return;
- }
- if (depositGroup.timestampFinished) {
- logger.trace(`deposit group ${depositGroupId} already finished`);
- return;
- }
-
- const contractData = extractContractData(
- depositGroup.contractTermsRaw,
- depositGroup.contractTermsHash,
- "",
- );
-
- const depositPermissions = await generateDepositPermissions(
- ws,
- depositGroup.payCoinSelection,
- contractData,
- );
-
- for (let i = 0; i < depositPermissions.length; i++) {
- if (depositGroup.depositedPerCoin[i]) {
- continue;
- }
- const perm = depositPermissions[i];
- const url = new URL(`coins/${perm.coin_pub}/deposit`, perm.exchange_url);
- const httpResp = await ws.http.postJson(url.href, {
- contribution: Amounts.stringify(perm.contribution),
- wire: depositGroup.wire,
- h_wire: depositGroup.contractTermsRaw.h_wire,
- h_contract_terms: depositGroup.contractTermsHash,
- ub_sig: perm.ub_sig,
- timestamp: depositGroup.contractTermsRaw.timestamp,
- wire_transfer_deadline:
- depositGroup.contractTermsRaw.wire_transfer_deadline,
- refund_deadline: depositGroup.contractTermsRaw.refund_deadline,
- coin_sig: perm.coin_sig,
- denom_pub_hash: perm.h_denom,
- merchant_pub: depositGroup.merchantPub,
- });
- await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess());
- await ws.db
- .mktx((x) => ({ depositGroups: x.depositGroups }))
- .runReadWrite(async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return;
- }
- dg.depositedPerCoin[i] = true;
- await tx.depositGroups.put(dg);
- });
- }
-
- await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- }))
- .runReadWrite(async (tx) => {
- const dg = await tx.depositGroups.get(depositGroupId);
- if (!dg) {
- return;
- }
- let allDeposited = true;
- for (const d of depositGroup.depositedPerCoin) {
- if (!d) {
- allDeposited = false;
- }
- }
- if (allDeposited) {
- dg.timestampFinished = getTimestampNow();
- delete dg.lastError;
- delete dg.retryInfo;
- await tx.depositGroups.put(dg);
- }
- });
-}
-
-export async function trackDepositGroup(
- ws: InternalWalletState,
- req: TrackDepositGroupRequest,
-): Promise<TrackDepositGroupResponse> {
- const responses: {
- status: number;
- body: any;
- }[] = [];
- const depositGroup = await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- }))
- .runReadOnly(async (tx) => {
- return tx.depositGroups.get(req.depositGroupId);
- });
- if (!depositGroup) {
- throw Error("deposit group not found");
- }
- const contractData = extractContractData(
- depositGroup.contractTermsRaw,
- depositGroup.contractTermsHash,
- "",
- );
-
- const depositPermissions = await generateDepositPermissions(
- ws,
- depositGroup.payCoinSelection,
- contractData,
- );
-
- const wireHash = depositGroup.contractTermsRaw.h_wire;
-
- for (const dp of depositPermissions) {
- const url = new URL(
- `deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${dp.coin_pub}`,
- dp.exchange_url,
- );
- const sig = await ws.cryptoApi.signTrackTransaction({
- coinPub: dp.coin_pub,
- contractTermsHash: depositGroup.contractTermsHash,
- merchantPriv: depositGroup.merchantPriv,
- merchantPub: depositGroup.merchantPub,
- wireHash,
- });
- url.searchParams.set("merchant_sig", sig);
- const httpResp = await ws.http.get(url.href);
- const body = await httpResp.json();
- responses.push({
- body,
- status: httpResp.status,
- });
- }
- return {
- responses,
- };
-}
-
-export async function createDepositGroup(
- ws: InternalWalletState,
- req: CreateDepositGroupRequest,
-): Promise<CreateDepositGroupResponse> {
- const p = parsePaytoUri(req.depositPaytoUri);
- if (!p) {
- throw Error("invalid payto URI");
- }
-
- const amount = Amounts.parseOrThrow(req.amount);
-
- const exchangeInfos: { url: string; master_pub: string }[] = [];
-
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- const allExchanges = await tx.exchanges.iter().toArray();
- for (const e of allExchanges) {
- const details = await getExchangeDetails(tx, e.baseUrl);
- if (!details) {
- continue;
- }
- exchangeInfos.push({
- master_pub: details.masterPublicKey,
- url: e.baseUrl,
- });
- }
- });
-
- const timestamp = getTimestampNow();
- const timestampRound = timestampTruncateToSecond(timestamp);
- const noncePair = await ws.cryptoApi.createEddsaKeypair();
- const merchantPair = await ws.cryptoApi.createEddsaKeypair();
- const wireSalt = encodeCrock(getRandomBytes(64));
- const wireHash = hashWire(req.depositPaytoUri, wireSalt);
- const contractTerms: ContractTerms = {
- auditors: [],
- exchanges: exchangeInfos,
- amount: req.amount,
- max_fee: Amounts.stringify(amount),
- max_wire_fee: Amounts.stringify(amount),
- wire_method: p.targetType,
- timestamp: timestampRound,
- merchant_base_url: "",
- summary: "",
- nonce: noncePair.pub,
- wire_transfer_deadline: timestampRound,
- order_id: "",
- h_wire: wireHash,
- pay_deadline: timestampAddDuration(
- timestampRound,
- durationFromSpec({ hours: 1 }),
- ),
- merchant: {
- name: "",
- },
- merchant_pub: merchantPair.pub,
- refund_deadline: { t_ms: 0 },
- };
-
- const contractTermsHash = await ws.cryptoApi.hashString(
- canonicalJson(contractTerms),
- );
-
- const contractData = extractContractData(
- contractTerms,
- contractTermsHash,
- "",
- );
-
- const candidates = await getCandidatePayCoins(ws, {
- allowedAuditors: contractData.allowedAuditors,
- allowedExchanges: contractData.allowedExchanges,
- amount: contractData.amount,
- maxDepositFee: contractData.maxDepositFee,
- maxWireFee: contractData.maxWireFee,
- timestamp: contractData.timestamp,
- wireFeeAmortization: contractData.wireFeeAmortization,
- wireMethod: contractData.wireMethod,
- });
-
- const payCoinSel = selectPayCoins({
- candidates,
- contractTermsAmount: contractData.amount,
- depositFeeLimit: contractData.maxDepositFee,
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: contractData.maxWireFee,
- prevPayCoins: [],
- });
-
- if (!payCoinSel) {
- throw Error("insufficient funds");
- }
-
- const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel);
-
- const depositGroupId = encodeCrock(getRandomBytes(32));
-
- const effectiveDepositAmount = await getEffectiveDepositAmount(
- ws,
- p.targetType,
- payCoinSel,
- );
-
- const depositGroup: DepositGroupRecord = {
- contractTermsHash,
- contractTermsRaw: contractTerms,
- depositGroupId,
- noncePriv: noncePair.priv,
- noncePub: noncePair.pub,
- timestampCreated: timestamp,
- timestampFinished: undefined,
- payCoinSelection: payCoinSel,
- payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
- depositedPerCoin: payCoinSel.coinPubs.map(() => false),
- merchantPriv: merchantPair.priv,
- merchantPub: merchantPair.pub,
- totalPayCost: totalDepositCost,
- effectiveDepositAmount,
- wire: {
- payto_uri: req.depositPaytoUri,
- salt: wireSalt,
- },
- retryInfo: initRetryInfo(),
- lastError: undefined,
- };
-
- await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- denominations: x.denominations,
- }))
- .runReadWrite(async (tx) => {
- await applyCoinSpend(
- ws,
- tx,
- payCoinSel,
- `deposit-group:${depositGroup.depositGroupId}`,
- );
- await tx.depositGroups.put(depositGroup);
- });
-
- return { depositGroupId };
-}
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts
deleted file mode 100644
index 629957efb..000000000
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ /dev/null
@@ -1,732 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- Amounts,
- Auditor,
- canonicalizeBaseUrl,
- codecForExchangeKeysJson,
- codecForExchangeWireJson,
- compare,
- Denomination,
- Duration,
- durationFromSpec,
- ExchangeSignKeyJson,
- ExchangeWireJson,
- getTimestampNow,
- isTimestampExpired,
- Logger,
- NotificationType,
- parsePaytoUri,
- Recoup,
- TalerErrorCode,
- URL,
- TalerErrorDetails,
- Timestamp,
-} from "@gnu-taler/taler-util";
-import { decodeCrock, encodeCrock, hash } from "@gnu-taler/taler-util";
-import { CryptoApi } from "../crypto/workers/cryptoApi.js";
-import {
- DenominationRecord,
- DenominationVerificationStatus,
- ExchangeDetailsRecord,
- ExchangeRecord,
- WalletStoresV1,
- WireFee,
- WireInfo,
-} from "../db.js";
-import {
- getExpiryTimestamp,
- HttpRequestLibrary,
- readSuccessResponseJsonOrThrow,
- readSuccessResponseTextOrThrow,
-} from "../util/http.js";
-import { DbAccess, GetReadOnlyAccess } from "../util/query.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import {
- guardOperationException,
- makeErrorDetails,
- OperationFailedError,
-} from "../errors.js";
-import { InternalWalletState, TrustInfo } from "../common.js";
-import {
- WALLET_CACHE_BREAKER_CLIENT_VERSION,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
-} from "../versions.js";
-
-const logger = new Logger("exchanges.ts");
-
-function denominationRecordFromKeys(
- exchangeBaseUrl: string,
- exchangeMasterPub: string,
- listIssueDate: Timestamp,
- denomIn: Denomination,
-): DenominationRecord {
- const denomPubHash = encodeCrock(hash(decodeCrock(denomIn.denom_pub)));
- const d: DenominationRecord = {
- denomPub: denomIn.denom_pub,
- denomPubHash,
- exchangeBaseUrl,
- exchangeMasterPub,
- feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit),
- feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh),
- feeRefund: Amounts.parseOrThrow(denomIn.fee_refund),
- feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw),
- isOffered: true,
- isRevoked: false,
- masterSig: denomIn.master_sig,
- stampExpireDeposit: denomIn.stamp_expire_deposit,
- stampExpireLegal: denomIn.stamp_expire_legal,
- stampExpireWithdraw: denomIn.stamp_expire_withdraw,
- stampStart: denomIn.stamp_start,
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: Amounts.parseOrThrow(denomIn.value),
- listIssueDate,
- };
- return d;
-}
-
-async function handleExchangeUpdateError(
- ws: InternalWalletState,
- baseUrl: string,
- err: TalerErrorDetails,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ exchanges: x.exchanges }))
- .runReadOnly(async (tx) => {
- const exchange = await tx.exchanges.get(baseUrl);
- if (!exchange) {
- return;
- }
- exchange.retryInfo.retryCounter++;
- updateRetryInfoTimeout(exchange.retryInfo);
- exchange.lastError = err;
- });
- if (err) {
- ws.notify({ type: NotificationType.ExchangeOperationError, error: err });
- }
-}
-
-function getExchangeRequestTimeout(e: ExchangeRecord): Duration {
- return { d_ms: 5000 };
-}
-
-export interface ExchangeTosDownloadResult {
- tosText: string;
- tosEtag: string;
- tosContentType: string;
-}
-
-export async function downloadExchangeWithTermsOfService(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
- contentType: string,
-): Promise<ExchangeTosDownloadResult> {
- const reqUrl = new URL("terms", exchangeBaseUrl);
- reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
- const headers = {
- Accept: contentType,
- };
-
- const resp = await http.get(reqUrl.href, {
- headers,
- timeout,
- });
- const tosText = await readSuccessResponseTextOrThrow(resp);
- const tosEtag = resp.headers.get("etag") || "unknown";
- const tosContentType = resp.headers.get("content-type") || "text/plain";
-
- return { tosText, tosEtag, tosContentType };
-}
-
-/**
- * Get exchange details from the database.
- */
-export async function getExchangeDetails(
- tx: GetReadOnlyAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- exchangeBaseUrl: string,
-): Promise<ExchangeDetailsRecord | undefined> {
- const r = await tx.exchanges.get(exchangeBaseUrl);
- if (!r) {
- return;
- }
- const dp = r.detailsPointer;
- if (!dp) {
- return;
- }
- const { currency, masterPublicKey } = dp;
- return await tx.exchangeDetails.get([r.baseUrl, currency, masterPublicKey]);
-}
-
-getExchangeDetails.makeContext = (db: DbAccess<typeof WalletStoresV1>) =>
- db.mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }));
-
-export async function acceptExchangeTermsOfService(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- etag: string | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadWrite(async (tx) => {
- const d = await getExchangeDetails(tx, exchangeBaseUrl);
- if (d) {
- d.termsOfServiceAcceptedEtag = etag;
- await tx.exchangeDetails.put(d);
- }
- });
-}
-
-async function validateWireInfo(
- wireInfo: ExchangeWireJson,
- masterPublicKey: string,
- cryptoApi: CryptoApi,
-): Promise<WireInfo> {
- for (const a of wireInfo.accounts) {
- logger.trace("validating exchange acct");
- const isValid = await cryptoApi.isValidWireAccount(
- a.payto_uri,
- a.master_sig,
- masterPublicKey,
- );
- if (!isValid) {
- throw Error("exchange acct signature invalid");
- }
- }
- const feesForType: { [wireMethod: string]: WireFee[] } = {};
- for (const wireMethod of Object.keys(wireInfo.fees)) {
- const feeList: WireFee[] = [];
- for (const x of wireInfo.fees[wireMethod]) {
- const startStamp = x.start_date;
- const endStamp = x.end_date;
- const fee: WireFee = {
- closingFee: Amounts.parseOrThrow(x.closing_fee),
- endStamp,
- sig: x.sig,
- startStamp,
- wireFee: Amounts.parseOrThrow(x.wire_fee),
- };
- const isValid = await cryptoApi.isValidWireFee(
- wireMethod,
- fee,
- masterPublicKey,
- );
- if (!isValid) {
- throw Error("exchange wire fee signature invalid");
- }
- feeList.push(fee);
- }
- feesForType[wireMethod] = feeList;
- }
-
- return {
- accounts: wireInfo.accounts,
- feesForType,
- };
-}
-
-/**
- * Fetch wire information for an exchange.
- *
- * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized.
- */
-async function downloadExchangeWithWireInfo(
- exchangeBaseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
-): Promise<ExchangeWireJson> {
- const reqUrl = new URL("wire", exchangeBaseUrl);
- reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
-
- const resp = await http.get(reqUrl.href, {
- timeout,
- });
- const wireInfo = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeWireJson(),
- );
-
- return wireInfo;
-}
-
-export async function updateExchangeFromUrl(
- ws: InternalWalletState,
- baseUrl: string,
- acceptedFormat?: string[],
- forceNow = false,
-): Promise<{
- exchange: ExchangeRecord;
- exchangeDetails: ExchangeDetailsRecord;
-}> {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- handleExchangeUpdateError(ws, baseUrl, e);
- return await guardOperationException(
- () => updateExchangeFromUrlImpl(ws, baseUrl, acceptedFormat, forceNow),
- onOpErr,
- );
-}
-
-async function provideExchangeRecord(
- ws: InternalWalletState,
- baseUrl: string,
- now: Timestamp,
-): Promise<ExchangeRecord> {
- return await ws.db
- .mktx((x) => ({ exchanges: x.exchanges }))
- .runReadWrite(async (tx) => {
- let r = await tx.exchanges.get(baseUrl);
- if (!r) {
- r = {
- permanent: true,
- baseUrl: baseUrl,
- retryInfo: initRetryInfo(),
- detailsPointer: undefined,
- lastUpdate: undefined,
- nextUpdate: now,
- nextRefreshCheck: now,
- };
- await tx.exchanges.put(r);
- }
- return r;
- });
-}
-
-interface ExchangeKeysDownloadResult {
- masterPublicKey: string;
- currency: string;
- auditors: Auditor[];
- currentDenominations: DenominationRecord[];
- protocolVersion: string;
- signingKeys: ExchangeSignKeyJson[];
- reserveClosingDelay: Duration;
- expiry: Timestamp;
- recoup: Recoup[];
- listIssueDate: Timestamp;
-}
-
-/**
- * Download and validate an exchange's /keys data.
- */
-async function downloadKeysInfo(
- baseUrl: string,
- http: HttpRequestLibrary,
- timeout: Duration,
-): Promise<ExchangeKeysDownloadResult> {
- const keysUrl = new URL("keys", baseUrl);
- keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION);
-
- const resp = await http.get(keysUrl.href, {
- timeout,
- });
- const exchangeKeysJson = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeKeysJson(),
- );
-
- logger.info("received /keys response");
-
- if (exchangeKeysJson.denoms.length === 0) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
- "exchange doesn't offer any denominations",
- {
- exchangeBaseUrl: baseUrl,
- },
- );
- throw new OperationFailedError(opErr);
- }
-
- const protocolVersion = exchangeKeysJson.version;
-
- const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion);
- if (versionRes?.compatible != true) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE,
- "exchange protocol version not compatible with wallet",
- {
- exchangeProtocolVersion: protocolVersion,
- walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- },
- );
- throw new OperationFailedError(opErr);
- }
-
- const currency = Amounts.parseOrThrow(
- exchangeKeysJson.denoms[0].value,
- ).currency.toUpperCase();
-
- return {
- masterPublicKey: exchangeKeysJson.master_public_key,
- currency,
- auditors: exchangeKeysJson.auditors,
- currentDenominations: exchangeKeysJson.denoms.map((d) =>
- denominationRecordFromKeys(
- baseUrl,
- exchangeKeysJson.master_public_key,
- exchangeKeysJson.list_issue_date,
- d,
- ),
- ),
- protocolVersion: exchangeKeysJson.version,
- signingKeys: exchangeKeysJson.signkeys,
- reserveClosingDelay: exchangeKeysJson.reserve_closing_delay,
- expiry: getExpiryTimestamp(resp, {
- minDuration: durationFromSpec({ hours: 1 }),
- }),
- recoup: exchangeKeysJson.recoup ?? [],
- listIssueDate: exchangeKeysJson.list_issue_date,
- };
-}
-
-/**
- * Update or add exchange DB entry by fetching the /keys and /wire information.
- * Optionally link the reserve entry to the new or existing
- * exchange entry in then DB.
- */
-async function updateExchangeFromUrlImpl(
- ws: InternalWalletState,
- baseUrl: string,
- acceptedFormat?: string[],
- forceNow = false,
-): Promise<{
- exchange: ExchangeRecord;
- exchangeDetails: ExchangeDetailsRecord;
-}> {
- logger.trace(`updating exchange info for ${baseUrl}`);
- const now = getTimestampNow();
- baseUrl = canonicalizeBaseUrl(baseUrl);
-
- const r = await provideExchangeRecord(ws, baseUrl, now);
-
- if (!forceNow && r && !isTimestampExpired(r.nextUpdate)) {
- const res = await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- const exchange = await tx.exchanges.get(baseUrl);
- if (!exchange) {
- return;
- }
- const exchangeDetails = await getExchangeDetails(tx, baseUrl);
- if (!exchangeDetails) {
- return;
- }
- return { exchange, exchangeDetails };
- });
- if (res) {
- logger.info("using existing exchange info");
- return res;
- }
- }
-
- logger.info("updating exchange /keys info");
-
- const timeout = getExchangeRequestTimeout(r);
-
- const keysInfo = await downloadKeysInfo(baseUrl, ws.http, timeout);
-
- logger.info("updating exchange /wire info");
- const wireInfoDownload = await downloadExchangeWithWireInfo(
- baseUrl,
- ws.http,
- timeout,
- );
-
- logger.info("validating exchange /wire info");
-
- const wireInfo = await validateWireInfo(
- wireInfoDownload,
- keysInfo.masterPublicKey,
- ws.cryptoApi,
- );
-
- logger.info("finished validating exchange /wire info");
-
- let tosFound: ExchangeTosDownloadResult | undefined;
- //Remove this when exchange supports multiple content-type in accept header
- if (acceptedFormat) for (const format of acceptedFormat) {
- const resp = await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- format
- );
- if (resp.tosContentType === format) {
- tosFound = resp
- break
- }
- }
- // If none of the specified format was found try text/plain
- const tosDownload = tosFound !== undefined ? tosFound :
- await downloadExchangeWithTermsOfService(
- baseUrl,
- ws.http,
- timeout,
- "text/plain"
- );
-
- let recoupGroupId: string | undefined = undefined;
-
- logger.trace("updating exchange info in database");
-
- const updated = await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- denominations: x.denominations,
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- recoupGroups: x.recoupGroups,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.exchanges.get(baseUrl);
- if (!r) {
- logger.warn(`exchange ${baseUrl} no longer present`);
- return;
- }
- let details = await getExchangeDetails(tx, r.baseUrl);
- if (details) {
- // FIXME: We need to do some consistency checks!
- }
- // FIXME: validate signing keys and merge with old set
- details = {
- auditors: keysInfo.auditors,
- currency: keysInfo.currency,
- masterPublicKey: keysInfo.masterPublicKey,
- protocolVersion: keysInfo.protocolVersion,
- signingKeys: keysInfo.signingKeys,
- reserveClosingDelay: keysInfo.reserveClosingDelay,
- exchangeBaseUrl: r.baseUrl,
- wireInfo,
- termsOfServiceText: tosDownload.tosText,
- termsOfServiceAcceptedEtag: undefined,
- termsOfServiceContentType: tosDownload.tosContentType,
- termsOfServiceLastEtag: tosDownload.tosEtag,
- termsOfServiceAcceptedTimestamp: getTimestampNow(),
- };
- // FIXME: only update if pointer got updated
- r.lastError = undefined;
- r.retryInfo = initRetryInfo();
- r.lastUpdate = getTimestampNow();
- r.nextUpdate = keysInfo.expiry;
- // New denominations might be available.
- r.nextRefreshCheck = getTimestampNow();
- r.detailsPointer = {
- currency: details.currency,
- masterPublicKey: details.masterPublicKey,
- // FIXME: only change if pointer really changed
- updateClock: getTimestampNow(),
- };
- await tx.exchanges.put(r);
- await tx.exchangeDetails.put(details);
-
- logger.trace("updating denominations in database");
- const currentDenomSet = new Set<string>(
- keysInfo.currentDenominations.map((x) => x.denomPubHash),
- );
- for (const currentDenom of keysInfo.currentDenominations) {
- const oldDenom = await tx.denominations.get([
- baseUrl,
- currentDenom.denomPubHash,
- ]);
- if (oldDenom) {
- // FIXME: Do consistency check, report to auditor if necessary.
- } else {
- await tx.denominations.put(currentDenom);
- }
- }
-
- // Update list issue date for all denominations,
- // and mark non-offered denominations as such.
- await tx.denominations.indexes.byExchangeBaseUrl
- .iter(r.baseUrl)
- .forEachAsync(async (x) => {
- if (!currentDenomSet.has(x.denomPubHash)) {
- // FIXME: Here, an auditor report should be created, unless
- // the denomination is really legally expired.
- if (x.isOffered) {
- x.isOffered = false;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=false`,
- );
- }
- } else {
- x.listIssueDate = keysInfo.listIssueDate;
- if (!x.isOffered) {
- x.isOffered = true;
- logger.info(
- `setting denomination ${x.denomPubHash} to offered=true`,
- );
- }
- }
- await tx.denominations.put(x);
- });
-
- logger.trace("done updating denominations in database");
-
- // Handle recoup
- const recoupDenomList = keysInfo.recoup;
- const newlyRevokedCoinPubs: string[] = [];
- logger.trace("recoup list from exchange", recoupDenomList);
- for (const recoupInfo of recoupDenomList) {
- const oldDenom = await tx.denominations.get([
- r.baseUrl,
- recoupInfo.h_denom_pub,
- ]);
- if (!oldDenom) {
- // We never even knew about the revoked denomination, all good.
- continue;
- }
- if (oldDenom.isRevoked) {
- // We already marked the denomination as revoked,
- // this implies we revoked all coins
- logger.trace("denom already revoked");
- continue;
- }
- logger.trace("revoking denom", recoupInfo.h_denom_pub);
- oldDenom.isRevoked = true;
- await tx.denominations.put(oldDenom);
- const affectedCoins = await tx.coins.indexes.byDenomPubHash
- .iter(recoupInfo.h_denom_pub)
- .toArray();
- for (const ac of affectedCoins) {
- newlyRevokedCoinPubs.push(ac.coinPub);
- }
- }
- if (newlyRevokedCoinPubs.length != 0) {
- logger.trace("recouping coins", newlyRevokedCoinPubs);
- recoupGroupId = await ws.recoupOps.createRecoupGroup(
- ws,
- tx,
- newlyRevokedCoinPubs,
- );
- }
- return {
- exchange: r,
- exchangeDetails: details,
- };
- });
-
- if (recoupGroupId) {
- // Asynchronously start recoup. This doesn't need to finish
- // for the exchange update to be considered finished.
- ws.recoupOps.processRecoupGroup(ws, recoupGroupId).catch((e) => {
- logger.error("error while recouping coins:", e);
- });
- }
-
- if (!updated) {
- throw Error("something went wrong with updating the exchange");
- }
-
- logger.trace("done updating exchange info in database");
-
- return {
- exchange: updated.exchange,
- exchangeDetails: updated.exchangeDetails,
- };
-}
-
-export async function getExchangePaytoUri(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- supportedTargetTypes: string[],
-): Promise<string> {
- // We do the update here, since the exchange might not even exist
- // yet in our database.
- const details = await getExchangeDetails
- .makeContext(ws.db)
- .runReadOnly(async (tx) => {
- return getExchangeDetails(tx, exchangeBaseUrl);
- });
- const accounts = details?.wireInfo.accounts ?? [];
- for (const account of accounts) {
- const res = parsePaytoUri(account.payto_uri);
- if (!res) {
- continue;
- }
- if (supportedTargetTypes.includes(res.targetType)) {
- return account.payto_uri;
- }
- }
- throw Error("no matching exchange account found");
-}
-
-/**
- * Check if and how an exchange is trusted and/or audited.
- */
-export async function getExchangeTrust(
- ws: InternalWalletState,
- exchangeInfo: ExchangeRecord,
-): Promise<TrustInfo> {
- let isTrusted = false;
- let isAudited = false;
-
- return await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- exchangesTrustStore: x.exchangeTrust,
- auditorTrust: x.auditorTrust,
- }))
- .runReadOnly(async (tx) => {
- const exchangeDetails = await getExchangeDetails(
- tx,
- exchangeInfo.baseUrl,
- );
-
- if (!exchangeDetails) {
- throw Error(`exchange ${exchangeInfo.baseUrl} details not available`);
- }
- const exchangeTrustRecord = await tx.exchangesTrustStore.indexes.byExchangeMasterPub.get(
- exchangeDetails.masterPublicKey,
- );
- if (
- exchangeTrustRecord &&
- exchangeTrustRecord.uids.length > 0 &&
- exchangeTrustRecord.currency === exchangeDetails.currency
- ) {
- isTrusted = true;
- }
-
- for (const auditor of exchangeDetails.auditors) {
- const auditorTrustRecord = await tx.auditorTrust.indexes.byAuditorPub.get(
- auditor.auditor_pub,
- );
- if (auditorTrustRecord && auditorTrustRecord.uids.length > 0) {
- isAudited = true;
- break;
- }
- }
-
- return { isTrusted, isAudited };
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts
deleted file mode 100644
index 8fad55994..000000000
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ /dev/null
@@ -1,1768 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the payment operation, including downloading and
- * claiming of proposals.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import {
- AmountJson,
- Amounts,
- timestampIsBetween,
- getTimestampNow,
- isTimestampExpired,
- Timestamp,
- RefreshReason,
- CoinDepositPermission,
- NotificationType,
- TalerErrorDetails,
- Duration,
- durationMax,
- durationMin,
- durationMul,
- ContractTerms,
- codecForProposal,
- TalerErrorCode,
- codecForContractTerms,
- timestampAddDuration,
- ConfirmPayResult,
- ConfirmPayResultType,
- codecForMerchantPayResponse,
- PreparePayResult,
- PreparePayResultType,
- parsePayUri,
- Logger,
- URL,
- getDurationRemaining,
-} from "@gnu-taler/taler-util";
-import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-import {
- PayCoinSelection,
- CoinCandidateSelection,
- AvailableCoinInfo,
- selectPayCoins,
- PreviousPayCoins,
-} from "../util/coinSelection.js";
-import { j2s } from "@gnu-taler/taler-util";
-import {
- initRetryInfo,
- updateRetryInfoTimeout,
- getRetryDuration,
-} from "../util/retries.js";
-import { getTotalRefreshCost, createRefreshGroup } from "./refresh.js";
-import { InternalWalletState, EXCHANGE_COINS_LOCK } from "../common.js";
-import { ContractTermsUtil } from "../util/contractTerms.js";
-import { getExchangeDetails } from "./exchanges.js";
-import { GetReadWriteAccess } from "../util/query.js";
-import {
- AbortStatus,
- AllowedAuditorInfo,
- AllowedExchangeInfo,
- BackupProviderStateTag,
- CoinRecord,
- CoinStatus,
- DenominationRecord,
- ProposalRecord,
- ProposalStatus,
- PurchaseRecord,
- WalletContractData,
- WalletStoresV1,
-} from "../db.js";
-import {
- getHttpResponseErrorDetails,
- HttpResponseStatus,
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- readTalerErrorResponse,
- readUnexpectedResponseDetails,
- throwUnexpectedRequestError,
-} from "../util/http.js";
-import {
- guardOperationException,
- makeErrorDetails,
- OperationFailedAndReportedError,
- OperationFailedError,
-} from "../errors.js";
-
-/**
- * Logger.
- */
-const logger = new Logger("pay.ts");
-
-/**
- * Compute the total cost of a payment to the customer.
- *
- * This includes the amount taken by the merchant, fees (wire/deposit) contributed
- * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
- * of coins that are too small to spend.
- */
-export async function getTotalPaymentCost(
- ws: InternalWalletState,
- pcs: PayCoinSelection,
-): Promise<AmountJson> {
- return ws.db
- .mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
- .runReadOnly(async (tx) => {
- const costs = [];
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate payment cost, coin not found");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error(
- "can't calculate payment cost, denomination for coin not found",
- );
- }
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter()
- .toArray();
- const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i])
- .amount;
- const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
- costs.push(pcs.coinContributions[i]);
- costs.push(refreshCost);
- }
- const zero = Amounts.getZero(pcs.paymentAmount.currency);
- return Amounts.sum([zero, ...costs]).amount;
- });
-}
-
-/**
- * Get the amount that will be deposited on the merchant's bank
- * account, not considering aggregation.
- */
-export async function getEffectiveDepositAmount(
- ws: InternalWalletState,
- wireType: string,
- pcs: PayCoinSelection,
-): Promise<AmountJson> {
- const amt: AmountJson[] = [];
- const fees: AmountJson[] = [];
- const exchangeSet: Set<string> = new Set();
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- for (let i = 0; i < pcs.coinPubs.length; i++) {
- const coin = await tx.coins.get(pcs.coinPubs[i]);
- if (!coin) {
- throw Error("can't calculate deposit amountt, coin not found");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error("can't find denomination to calculate deposit amount");
- }
- amt.push(pcs.coinContributions[i]);
- fees.push(denom.feeDeposit);
- exchangeSet.add(coin.exchangeBaseUrl);
- }
- for (const exchangeUrl of exchangeSet.values()) {
- const exchangeDetails = await getExchangeDetails(tx, exchangeUrl);
- if (!exchangeDetails) {
- continue;
- }
- const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
- return timestampIsBetween(
- getTimestampNow(),
- x.startStamp,
- x.endStamp,
- );
- })?.wireFee;
- if (fee) {
- fees.push(fee);
- }
- }
- });
- return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
-}
-
-function isSpendableCoin(coin: CoinRecord, denom: DenominationRecord): boolean {
- if (coin.suspended) {
- return false;
- }
- if (denom.isRevoked) {
- return false;
- }
- if (!denom.isOffered) {
- return false;
- }
- if (coin.status !== CoinStatus.Fresh) {
- return false;
- }
- if (isTimestampExpired(denom.stampExpireDeposit)) {
- return false;
- }
- return true;
-}
-
-export interface CoinSelectionRequest {
- amount: AmountJson;
-
- allowedAuditors: AllowedAuditorInfo[];
- allowedExchanges: AllowedExchangeInfo[];
-
- /**
- * Timestamp of the contract.
- */
- timestamp: Timestamp;
-
- wireMethod: string;
-
- wireFeeAmortization: number;
-
- maxWireFee: AmountJson;
-
- maxDepositFee: AmountJson;
-}
-
-/**
- * Get candidate coins. From these candidate coins,
- * the actual contributions will be computed later.
- *
- * The resulting candidate coin list is sorted deterministically.
- *
- * TODO: Exclude more coins:
- * - when we already have a coin with more remaining amount than
- * the payment amount, coins with even higher amounts can be skipped.
- */
-export async function getCandidatePayCoins(
- ws: InternalWalletState,
- req: CoinSelectionRequest,
-): Promise<CoinCandidateSelection> {
- const candidateCoins: AvailableCoinInfo[] = [];
- const wireFeesPerExchange: Record<string, AmountJson> = {};
-
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- denominations: x.denominations,
- coins: x.coins,
- }))
- .runReadOnly(async (tx) => {
- const exchanges = await tx.exchanges.iter().toArray();
- for (const exchange of exchanges) {
- let isOkay = false;
- const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
- if (!exchangeDetails) {
- continue;
- }
- const exchangeFees = exchangeDetails.wireInfo;
- if (!exchangeFees) {
- continue;
- }
-
- // is the exchange explicitly allowed?
- for (const allowedExchange of req.allowedExchanges) {
- if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
- isOkay = true;
- break;
- }
- }
-
- // is the exchange allowed because of one of its auditors?
- if (!isOkay) {
- for (const allowedAuditor of req.allowedAuditors) {
- for (const auditor of exchangeDetails.auditors) {
- if (auditor.auditor_pub === allowedAuditor.auditorPub) {
- isOkay = true;
- break;
- }
- }
- if (isOkay) {
- break;
- }
- }
- }
-
- if (!isOkay) {
- continue;
- }
-
- const coins = await tx.coins.indexes.byBaseUrl
- .iter(exchange.baseUrl)
- .toArray();
-
- if (!coins || coins.length === 0) {
- continue;
- }
-
- // Denomination of the first coin, we assume that all other
- // coins have the same currency
- const firstDenom = await tx.denominations.get([
- exchange.baseUrl,
- coins[0].denomPubHash,
- ]);
- if (!firstDenom) {
- throw Error("db inconsistent");
- }
- const currency = firstDenom.value.currency;
- for (const coin of coins) {
- const denom = await tx.denominations.get([
- exchange.baseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error("db inconsistent");
- }
- if (denom.value.currency !== currency) {
- logger.warn(
- `same pubkey for different currencies at exchange ${exchange.baseUrl}`,
- );
- continue;
- }
- if (!isSpendableCoin(coin, denom)) {
- continue;
- }
- candidateCoins.push({
- availableAmount: coin.currentAmount,
- coinPub: coin.coinPub,
- denomPub: coin.denomPub,
- feeDeposit: denom.feeDeposit,
- exchangeBaseUrl: denom.exchangeBaseUrl,
- });
- }
-
- let wireFee: AmountJson | undefined;
- for (const fee of exchangeFees.feesForType[req.wireMethod] || []) {
- if (
- fee.startStamp <= req.timestamp &&
- fee.endStamp >= req.timestamp
- ) {
- wireFee = fee.wireFee;
- break;
- }
- }
- if (wireFee) {
- wireFeesPerExchange[exchange.baseUrl] = wireFee;
- }
- }
- });
-
- return {
- candidateCoins,
- wireFeesPerExchange,
- };
-}
-
-/**
- * Apply a coin selection to the database. Marks coins as spent
- * and creates a refresh session for the remaining amount.
- *
- * FIXME: This does not deal well with conflicting spends!
- * When two payments are made in parallel, the same coin can be selected
- * for two payments.
- * However, this is a situation that can also happen via sync.
- */
-export async function applyCoinSpend(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- coinSelection: PayCoinSelection,
- allocationId: string,
-) {
- for (let i = 0; i < coinSelection.coinPubs.length; i++) {
- const coin = await tx.coins.get(coinSelection.coinPubs[i]);
- if (!coin) {
- throw Error("coin allocated for payment doesn't exist anymore");
- }
- const contrib = coinSelection.coinContributions[i];
- if (coin.status !== CoinStatus.Fresh) {
- const alloc = coin.allocation;
- if (!alloc) {
- continue;
- }
- if (alloc.id !== allocationId) {
- // FIXME: assign error code
- throw Error("conflicting coin allocation (id)");
- }
- if (0 !== Amounts.cmp(alloc.amount, contrib)) {
- // FIXME: assign error code
- throw Error("conflicting coin allocation (contrib)");
- }
- continue;
- }
- coin.status = CoinStatus.Dormant;
- coin.allocation = {
- id: allocationId,
- amount: Amounts.stringify(contrib),
- };
- const remaining = Amounts.sub(coin.currentAmount, contrib);
- if (remaining.saturated) {
- throw Error("not enough remaining balance on coin for payment");
- }
- coin.currentAmount = remaining.amount;
- await tx.coins.put(coin);
- }
- const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
- coinPub: x,
- }));
- await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay);
-}
-
-/**
- * Record all information that is necessary to
- * pay for a proposal in the wallet's database.
- */
-async function recordConfirmPay(
- ws: InternalWalletState,
- proposal: ProposalRecord,
- coinSelection: PayCoinSelection,
- coinDepositPermissions: CoinDepositPermission[],
- sessionIdOverride: string | undefined,
-): Promise<PurchaseRecord> {
- const d = proposal.download;
- if (!d) {
- throw Error("proposal is in invalid state");
- }
- let sessionId;
- if (sessionIdOverride) {
- sessionId = sessionIdOverride;
- } else {
- sessionId = proposal.downloadSessionId;
- }
- logger.trace(
- `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
- );
- const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
- const t: PurchaseRecord = {
- abortStatus: AbortStatus.None,
- download: d,
- lastSessionId: sessionId,
- payCoinSelection: coinSelection,
- payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
- totalPayCost: payCostInfo,
- coinDepositPermissions,
- timestampAccept: getTimestampNow(),
- timestampLastRefundStatus: undefined,
- proposalId: proposal.proposalId,
- lastPayError: undefined,
- lastRefundStatusError: undefined,
- payRetryInfo: initRetryInfo(),
- refundStatusRetryInfo: initRetryInfo(),
- refundQueryRequested: false,
- timestampFirstSuccessfulPay: undefined,
- autoRefundDeadline: undefined,
- paymentSubmitPending: true,
- refunds: {},
- merchantPaySig: undefined,
- noncePriv: proposal.noncePriv,
- noncePub: proposal.noncePub,
- };
-
- await ws.db
- .mktx((x) => ({
- proposals: x.proposals,
- purchases: x.purchases,
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- denominations: x.denominations,
- }))
- .runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposal.proposalId);
- if (p) {
- p.proposalStatus = ProposalStatus.ACCEPTED;
- delete p.lastError;
- p.retryInfo = initRetryInfo();
- await tx.proposals.put(p);
- }
- await tx.purchases.put(t);
- await applyCoinSpend(ws, tx, coinSelection, `proposal:${t.proposalId}`);
- });
-
- ws.notify({
- type: NotificationType.ProposalAccepted,
- proposalId: proposal.proposalId,
- });
- return t;
-}
-
-async function incrementProposalRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadWrite(async (tx) => {
- const pr = await tx.proposals.get(proposalId);
- if (!pr) {
- return;
- }
- if (!pr.retryInfo) {
- return;
- }
- pr.retryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.retryInfo);
- pr.lastError = err;
- await tx.proposals.put(pr);
- });
- if (err) {
- ws.notify({ type: NotificationType.ProposalOperationError, error: err });
- }
-}
-
-async function incrementPurchasePayRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- logger.warn("incrementing purchase pay retry with error", err);
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const pr = await tx.purchases.get(proposalId);
- if (!pr) {
- return;
- }
- if (!pr.payRetryInfo) {
- pr.payRetryInfo = initRetryInfo();
- }
- pr.payRetryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.payRetryInfo);
- logger.trace(
- `retrying pay in ${
- getDurationRemaining(pr.payRetryInfo.nextRetry).d_ms
- } ms`,
- );
- pr.lastPayError = err;
- await tx.purchases.put(pr);
- });
- if (err) {
- ws.notify({ type: NotificationType.PayOperationError, error: err });
- }
-}
-
-export async function processDownloadProposal(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (err: TalerErrorDetails): Promise<void> =>
- incrementProposalRetry(ws, proposalId, err);
- await guardOperationException(
- () => processDownloadProposalImpl(ws, proposalId, forceNow),
- onOpErr,
- );
-}
-
-async function resetDownloadProposalRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposalId);
- if (p) {
- delete p.retryInfo;
- await tx.proposals.put(p);
- }
- });
-}
-
-async function failProposalPermanently(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetails,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposalId);
- if (!p) {
- return;
- }
- delete p.retryInfo;
- p.lastError = err;
- p.proposalStatus = ProposalStatus.PERMANENTLY_FAILED;
- await tx.proposals.put(p);
- });
-}
-
-function getProposalRequestTimeout(proposal: ProposalRecord): Duration {
- return durationMax(
- { d_ms: 60000 },
- durationMin({ d_ms: 5000 }, getRetryDuration(proposal.retryInfo)),
- );
-}
-
-function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
- return durationMul(
- { d_ms: 15000 },
- 1 + purchase.payCoinSelection.coinPubs.length / 5,
- );
-}
-
-export function extractContractData(
- parsedContractTerms: ContractTerms,
- contractTermsHash: string,
- merchantSig: string,
-): WalletContractData {
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
- } else {
- maxWireFee = Amounts.getZero(amount.currency);
- }
- return {
- amount,
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee,
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
- allowedAuditors: parsedContractTerms.auditors.map((x) => ({
- auditorBaseUrl: x.url,
- auditorPub: x.auditor_pub,
- })),
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
- merchant: parsedContractTerms.merchant,
- products: parsedContractTerms.products,
- summaryI18n: parsedContractTerms.summary_i18n,
- };
-}
-
-async function processDownloadProposalImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetDownloadProposalRetry(ws, proposalId);
- }
- const proposal = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadOnly(async (tx) => {
- return tx.proposals.get(proposalId);
- });
- if (!proposal) {
- return;
- }
- if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
- return;
- }
-
- const orderClaimUrl = new URL(
- `orders/${proposal.orderId}/claim`,
- proposal.merchantBaseUrl,
- ).href;
- logger.trace("downloading contract from '" + orderClaimUrl + "'");
-
- const requestBody: {
- nonce: string;
- token?: string;
- } = {
- nonce: proposal.noncePub,
- };
- if (proposal.claimToken) {
- requestBody.token = proposal.claimToken;
- }
-
- const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, {
- timeout: getProposalRequestTimeout(proposal),
- });
- const r = await readSuccessResponseJsonOrErrorCode(
- httpResponse,
- codecForProposal(),
- );
- if (r.isError) {
- switch (r.talerErrorResponse.code) {
- case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED:
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
- "order already claimed (likely by other wallet)",
- {
- orderId: proposal.orderId,
- claimUrl: orderClaimUrl,
- },
- );
- default:
- throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
- }
- }
- const proposalResp = r.response;
-
- // The proposalResp contains the contract terms as raw JSON,
- // as the coded to parse them doesn't necessarily round-trip.
- // We need this raw JSON to compute the contract terms hash.
-
- // FIXME: Do better error handling, check if the
- // contract terms have all their forgettable information still
- // present. The wallet should never accept contract terms
- // with missing information from the merchant.
-
- const isWellFormed = ContractTermsUtil.validateForgettable(
- proposalResp.contract_terms,
- );
-
- if (!isWellFormed) {
- logger.trace(
- `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
- );
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
- "validation for well-formedness failed",
- {},
- );
- await failProposalPermanently(ws, proposalId, err);
- throw new OperationFailedAndReportedError(err);
- }
-
- const contractTermsHash = ContractTermsUtil.hashContractTerms(
- proposalResp.contract_terms,
- );
-
- logger.info(`Contract terms hash: ${contractTermsHash}`);
-
- let parsedContractTerms: ContractTerms;
-
- try {
- parsedContractTerms = codecForContractTerms().decode(
- proposalResp.contract_terms,
- );
- } catch (e) {
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
- "schema validation failed",
- {},
- );
- await failProposalPermanently(ws, proposalId, err);
- throw new OperationFailedAndReportedError(err);
- }
-
- const sigValid = await ws.cryptoApi.isValidContractTermsSignature(
- contractTermsHash,
- proposalResp.sig,
- parsedContractTerms.merchant_pub,
- );
-
- if (!sigValid) {
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
- "merchant's signature on contract terms is invalid",
- {
- merchantPub: parsedContractTerms.merchant_pub,
- orderId: parsedContractTerms.order_id,
- },
- );
- await failProposalPermanently(ws, proposalId, err);
- throw new OperationFailedAndReportedError(err);
- }
-
- const fulfillmentUrl = parsedContractTerms.fulfillment_url;
-
- const baseUrlForDownload = proposal.merchantBaseUrl;
- const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
-
- if (baseUrlForDownload !== baseUrlFromContractTerms) {
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
- "merchant base URL mismatch",
- {
- baseUrlForDownload,
- baseUrlFromContractTerms,
- },
- );
- await failProposalPermanently(ws, proposalId, err);
- throw new OperationFailedAndReportedError(err);
- }
-
- const contractData = extractContractData(
- parsedContractTerms,
- contractTermsHash,
- proposalResp.sig,
- );
-
- await ws.db
- .mktx((x) => ({ proposals: x.proposals, purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposalId);
- if (!p) {
- return;
- }
- if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
- return;
- }
- p.download = {
- contractData,
- contractTermsRaw: proposalResp.contract_terms,
- };
- if (
- fulfillmentUrl &&
- (fulfillmentUrl.startsWith("http://") ||
- fulfillmentUrl.startsWith("https://"))
- ) {
- const differentPurchase = await tx.purchases.indexes.byFulfillmentUrl.get(
- fulfillmentUrl,
- );
- if (differentPurchase) {
- logger.warn("repurchase detected");
- p.proposalStatus = ProposalStatus.REPURCHASE;
- p.repurchaseProposalId = differentPurchase.proposalId;
- await tx.proposals.put(p);
- return;
- }
- }
- p.proposalStatus = ProposalStatus.PROPOSED;
- await tx.proposals.put(p);
- });
-
- ws.notify({
- type: NotificationType.ProposalDownloaded,
- proposalId: proposal.proposalId,
- });
-}
-
-/**
- * Download a proposal and store it in the database.
- * Returns an id for it to retrieve it later.
- *
- * @param sessionId Current session ID, if the proposal is being
- * downloaded in the context of a session ID.
- */
-async function startDownloadProposal(
- ws: InternalWalletState,
- merchantBaseUrl: string,
- orderId: string,
- sessionId: string | undefined,
- claimToken: string | undefined,
- noncePriv: string | undefined,
-): Promise<string> {
-
- const oldProposal = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadOnly(async (tx) => {
- return tx.proposals.indexes.byUrlAndOrderId.get([
- merchantBaseUrl,
- orderId,
- ]);
- });
-
- /**
- * If we have already claimed this proposal with the same sessionId
- * nonce and claim token, reuse it.
- */
- if (oldProposal &&
- oldProposal.downloadSessionId === sessionId &&
- (!noncePriv || oldProposal.noncePriv === noncePriv) &&
- oldProposal.claimToken === claimToken) {
- await processDownloadProposal(ws, oldProposal.proposalId);
- return oldProposal.proposalId;
- }
-
- const { priv, pub } = await (noncePriv ? ws.cryptoApi.eddsaGetPublic(noncePriv) : ws.cryptoApi.createEddsaKeypair());
- const proposalId = encodeCrock(getRandomBytes(32));
-
- const proposalRecord: ProposalRecord = {
- download: undefined,
- noncePriv: priv,
- noncePub: pub,
- claimToken,
- timestamp: getTimestampNow(),
- merchantBaseUrl,
- orderId,
- proposalId: proposalId,
- proposalStatus: ProposalStatus.DOWNLOADING,
- repurchaseProposalId: undefined,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- downloadSessionId: sessionId,
- };
-
- await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadWrite(async (tx) => {
- const existingRecord = await tx.proposals.indexes.byUrlAndOrderId.get([
- merchantBaseUrl,
- orderId,
- ]);
- if (existingRecord) {
- // Created concurrently
- return;
- }
- await tx.proposals.put(proposalRecord);
- });
-
- await processDownloadProposal(ws, proposalId);
- return proposalId;
-}
-
-async function storeFirstPaySuccess(
- ws: InternalWalletState,
- proposalId: string,
- sessionId: string | undefined,
- paySig: string,
-): Promise<void> {
- const now = getTimestampNow();
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
-
- if (!purchase) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- if (!isFirst) {
- logger.warn("payment success already stored");
- return;
- }
- purchase.timestampFirstSuccessfulPay = now;
- purchase.paymentSubmitPending = false;
- purchase.lastPayError = undefined;
- purchase.lastSessionId = sessionId;
- purchase.payRetryInfo = initRetryInfo();
- purchase.merchantPaySig = paySig;
- if (isFirst) {
- const ar = purchase.download.contractData.autoRefund;
- if (ar) {
- logger.info("auto_refund present");
- purchase.refundQueryRequested = true;
- purchase.refundStatusRetryInfo = initRetryInfo();
- purchase.lastRefundStatusError = undefined;
- purchase.autoRefundDeadline = timestampAddDuration(now, ar);
- }
- }
- await tx.purchases.put(purchase);
- });
-}
-
-async function storePayReplaySuccess(
- ws: InternalWalletState,
- proposalId: string,
- sessionId: string | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
-
- if (!purchase) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
- if (isFirst) {
- throw Error("invalid payment state");
- }
- purchase.paymentSubmitPending = false;
- purchase.lastPayError = undefined;
- purchase.payRetryInfo = initRetryInfo();
- purchase.lastSessionId = sessionId;
- await tx.purchases.put(purchase);
- });
-}
-
-/**
- * Handle a 409 Conflict response from the merchant.
- *
- * We do this by going through the coin history provided by the exchange and
- * (1) verifying the signatures from the exchange
- * (2) adjusting the remaining coin value and refreshing it
- * (3) re-do coin selection with the bad coin removed
- */
-async function handleInsufficientFunds(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetails,
-): Promise<void> {
- logger.trace("handling insufficient funds, trying to re-select coins");
-
- const proposal = await ws.db
- .mktx((x) => ({ purchaes: x.purchases }))
- .runReadOnly(async (tx) => {
- return tx.purchaes.get(proposalId);
- });
- if (!proposal) {
- return;
- }
-
- const brokenCoinPub = (err as any).coin_pub;
-
- const exchangeReply = (err as any).exchange_reply;
- if (
- exchangeReply.code !== TalerErrorCode.EXCHANGE_DEPOSIT_INSUFFICIENT_FUNDS
- ) {
- // FIXME: set as failed
- throw Error("can't handle error code");
- }
-
- logger.trace(`got error details: ${j2s(err)}`);
-
- const { contractData } = proposal.download;
-
- const candidates = await getCandidatePayCoins(ws, {
- allowedAuditors: contractData.allowedAuditors,
- allowedExchanges: contractData.allowedExchanges,
- amount: contractData.amount,
- maxDepositFee: contractData.maxDepositFee,
- maxWireFee: contractData.maxWireFee,
- timestamp: contractData.timestamp,
- wireFeeAmortization: contractData.wireFeeAmortization,
- wireMethod: contractData.wireMethod,
- });
-
- const prevPayCoins: PreviousPayCoins = [];
-
- await ws.db
- .mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
- .runReadOnly(async (tx) => {
- for (let i = 0; i < proposal.payCoinSelection.coinPubs.length; i++) {
- const coinPub = proposal.payCoinSelection.coinPubs[i];
- if (coinPub === brokenCoinPub) {
- continue;
- }
- const contrib = proposal.payCoinSelection.coinContributions[i];
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- continue;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- continue;
- }
- prevPayCoins.push({
- coinPub,
- contribution: contrib,
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: denom.feeDeposit,
- });
- }
- });
-
- const res = selectPayCoins({
- candidates,
- contractTermsAmount: contractData.amount,
- depositFeeLimit: contractData.maxDepositFee,
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: contractData.maxWireFee,
- prevPayCoins,
- });
-
- if (!res) {
- logger.trace("insufficient funds for coin re-selection");
- return;
- }
-
- logger.trace("re-selected coins");
-
- await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- coins: x.coins,
- denominations: x.denominations,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- p.payCoinSelection = res;
- p.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
- p.coinDepositPermissions = undefined;
- await tx.purchases.put(p);
- await applyCoinSpend(ws, tx, res, `proposal:${p.proposalId}`);
- });
-}
-
-async function unblockBackup(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ backupProviders: x.backupProviders }))
- .runReadWrite(async (tx) => {
- const bp = await tx.backupProviders.indexes.byPaymentProposalId
- .iter(proposalId)
- .forEachAsync(async (bp) => {
- if (bp.state.tag === BackupProviderStateTag.Retrying) {
- bp.state = {
- tag: BackupProviderStateTag.Ready,
- nextBackupTimestamp: getTimestampNow(),
- };
- }
- });
- });
-}
-
-/**
- * Submit a payment to the merchant.
- *
- * If the wallet has previously paid, it just transmits the merchant's
- * own signature certifying that the wallet has previously paid.
- */
-async function submitPay(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<ConfirmPayResult> {
- const purchase = await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- throw Error("Purchase not found: " + proposalId);
- }
- if (purchase.abortStatus !== AbortStatus.None) {
- throw Error("not submitting payment for aborted purchase");
- }
- const sessionId = purchase.lastSessionId;
-
- logger.trace("paying with session ID", sessionId);
-
- if (!purchase.merchantPaySig) {
- const payUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/pay`,
- purchase.download.contractData.merchantBaseUrl,
- ).href;
-
- let depositPermissions: CoinDepositPermission[];
-
- if (purchase.coinDepositPermissions) {
- depositPermissions = purchase.coinDepositPermissions;
- } else {
- // FIXME: also cache!
- depositPermissions = await generateDepositPermissions(
- ws,
- purchase.payCoinSelection,
- purchase.download.contractData,
- );
- }
-
- const reqBody = {
- coins: depositPermissions,
- session_id: purchase.lastSessionId,
- };
-
- logger.trace(
- "making pay request ... ",
- JSON.stringify(reqBody, undefined, 2),
- );
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- ws.http.postJson(payUrl, reqBody, {
- timeout: getPayRequestTimeout(purchase),
- }),
- );
-
- logger.trace(`got resp ${JSON.stringify(resp)}`);
-
- // Hide transient errors.
- if (
- (purchase.payRetryInfo?.retryCounter ?? 0) <= 5 &&
- resp.status >= 500 &&
- resp.status <= 599
- ) {
- logger.trace("treating /pay error as transient");
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "/pay failed",
- getHttpResponseErrorDetails(resp),
- );
- incrementPurchasePayRetry(ws, proposalId, undefined);
- return {
- type: ConfirmPayResultType.Pending,
- lastError: err,
- };
- }
-
- if (resp.status === HttpResponseStatus.BadRequest) {
- const errDetails = await readUnexpectedResponseDetails(resp);
- logger.warn("unexpected 400 response for /pay");
- logger.warn(j2s(errDetails));
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const purch = await tx.purchases.get(proposalId);
- if (!purch) {
- return;
- }
- purch.payFrozen = true;
- purch.lastPayError = errDetails;
- delete purch.payRetryInfo;
- await tx.purchases.put(purch);
- });
- // FIXME: Maybe introduce a new return type for this instead of throwing?
- throw new OperationFailedAndReportedError(errDetails);
- }
-
- if (resp.status === HttpResponseStatus.Conflict) {
- const err = await readTalerErrorResponse(resp);
- if (
- err.code ===
- TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
- ) {
- // Do this in the background, as it might take some time
- handleInsufficientFunds(ws, proposalId, err).catch(async (e) => {
- await incrementProposalRetry(ws, proposalId, {
- code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- message: "unexpected exception",
- hint: "unexpected exception",
- details: {
- exception: e.toString(),
- },
- });
- });
-
- return {
- type: ConfirmPayResultType.Pending,
- // FIXME: should we return something better here?
- lastError: err,
- };
- }
- }
-
- const merchantResp = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantPayResponse(),
- );
-
- logger.trace("got success from pay URL", merchantResp);
-
- const merchantPub = purchase.download.contractData.merchantPub;
- const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
- merchantResp.sig,
- purchase.download.contractData.contractTermsHash,
- merchantPub,
- );
-
- if (!valid) {
- logger.error("merchant payment signature invalid");
- // FIXME: properly display error
- throw Error("merchant payment signature invalid");
- }
-
- await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp.sig);
- await unblockBackup(ws, proposalId);
- } else {
- const payAgainUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/paid`,
- purchase.download.contractData.merchantBaseUrl,
- ).href;
- const reqBody = {
- sig: purchase.merchantPaySig,
- h_contract: purchase.download.contractData.contractTermsHash,
- session_id: sessionId ?? "",
- };
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
- ws.http.postJson(payAgainUrl, reqBody),
- );
- // Hide transient errors.
- if (
- (purchase.payRetryInfo?.retryCounter ?? 0) <= 5 &&
- resp.status >= 500 &&
- resp.status <= 599
- ) {
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "/paid failed",
- getHttpResponseErrorDetails(resp),
- );
- incrementPurchasePayRetry(ws, proposalId, undefined);
- return {
- type: ConfirmPayResultType.Pending,
- lastError: err,
- };
- }
- if (resp.status !== 204) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "/paid failed",
- getHttpResponseErrorDetails(resp),
- );
- }
- await storePayReplaySuccess(ws, proposalId, sessionId);
- await unblockBackup(ws, proposalId);
- }
-
- ws.notify({
- type: NotificationType.PayOperationSuccess,
- proposalId: purchase.proposalId,
- });
-
- return {
- type: ConfirmPayResultType.Done,
- contractTerms: purchase.download.contractTermsRaw,
- };
-}
-
-export async function checkPaymentByProposalId(
- ws: InternalWalletState,
- proposalId: string,
- sessionId?: string,
-): Promise<PreparePayResult> {
- let proposal = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadOnly(async (tx) => {
- return tx.proposals.get(proposalId);
- });
- if (!proposal) {
- throw Error(`could not get proposal ${proposalId}`);
- }
- if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
- const existingProposalId = proposal.repurchaseProposalId;
- if (!existingProposalId) {
- throw Error("invalid proposal state");
- }
- logger.trace("using existing purchase for same product");
- proposal = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadOnly(async (tx) => {
- return tx.proposals.get(existingProposalId);
- });
- if (!proposal) {
- throw Error("existing proposal is in wrong state");
- }
- }
- const d = proposal.download;
- if (!d) {
- logger.error("bad proposal", proposal);
- throw Error("proposal is in invalid state");
- }
- const contractData = d.contractData;
- const merchantSig = d.contractData.merchantSig;
- if (!merchantSig) {
- throw Error("BUG: proposal is in invalid state");
- }
-
- proposalId = proposal.proposalId;
-
- // First check if we already paid for it.
- const purchase = await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (!purchase) {
- // If not already paid, check if we could pay for it.
- const candidates = await getCandidatePayCoins(ws, {
- allowedAuditors: contractData.allowedAuditors,
- allowedExchanges: contractData.allowedExchanges,
- amount: contractData.amount,
- maxDepositFee: contractData.maxDepositFee,
- maxWireFee: contractData.maxWireFee,
- timestamp: contractData.timestamp,
- wireFeeAmortization: contractData.wireFeeAmortization,
- wireMethod: contractData.wireMethod,
- });
- const res = selectPayCoins({
- candidates,
- contractTermsAmount: contractData.amount,
- depositFeeLimit: contractData.maxDepositFee,
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: contractData.maxWireFee,
- prevPayCoins: [],
- });
-
- if (!res) {
- logger.info("not confirming payment, insufficient coins");
- return {
- status: PreparePayResultType.InsufficientBalance,
- contractTerms: d.contractTermsRaw,
- proposalId: proposal.proposalId,
- noncePriv: proposal.noncePriv,
- amountRaw: Amounts.stringify(d.contractData.amount),
- };
- }
-
- const totalCost = await getTotalPaymentCost(ws, res);
- logger.trace("costInfo", totalCost);
- logger.trace("coinsForPayment", res);
-
- return {
- status: PreparePayResultType.PaymentPossible,
- contractTerms: d.contractTermsRaw,
- proposalId: proposal.proposalId,
- noncePriv: proposal.noncePriv,
- amountEffective: Amounts.stringify(totalCost),
- amountRaw: Amounts.stringify(res.paymentAmount),
- contractTermsHash: d.contractData.contractTermsHash,
- };
- }
-
- if (purchase.lastSessionId !== sessionId) {
- logger.trace(
- "automatically re-submitting payment with different session ID",
- );
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- return;
- }
- p.lastSessionId = sessionId;
- await tx.purchases.put(p);
- });
- const r = await guardOperationException(
- () => submitPay(ws, proposalId),
- (e: TalerErrorDetails): Promise<void> =>
- incrementPurchasePayRetry(ws, proposalId, e),
- );
- if (r.type !== ConfirmPayResultType.Done) {
- throw Error("submitting pay failed");
- }
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: purchase.download.contractTermsRaw,
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- paid: true,
- amountRaw: Amounts.stringify(purchase.download.contractData.amount),
- amountEffective: Amounts.stringify(purchase.totalPayCost),
- proposalId,
- };
- } else if (!purchase.timestampFirstSuccessfulPay) {
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: purchase.download.contractTermsRaw,
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- paid: false,
- amountRaw: Amounts.stringify(purchase.download.contractData.amount),
- amountEffective: Amounts.stringify(purchase.totalPayCost),
- proposalId,
- };
- } else {
- const paid = !purchase.paymentSubmitPending;
- return {
- status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: purchase.download.contractTermsRaw,
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- paid,
- amountRaw: Amounts.stringify(purchase.download.contractData.amount),
- amountEffective: Amounts.stringify(purchase.totalPayCost),
- ...(paid ? { nextUrl: purchase.download.contractData.orderId } : {}),
- proposalId,
- };
- }
-}
-
-/**
- * Check if a payment for the given taler://pay/ URI is possible.
- *
- * If the payment is possible, the signature are already generated but not
- * yet send to the merchant.
- */
-export async function preparePayForUri(
- ws: InternalWalletState,
- talerPayUri: string,
-): Promise<PreparePayResult> {
- const uriResult = parsePayUri(talerPayUri);
-
- if (!uriResult) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
- `invalid taler://pay URI (${talerPayUri})`,
- {
- talerPayUri,
- },
- );
- }
-
- let proposalId = await startDownloadProposal(
- ws,
- uriResult.merchantBaseUrl,
- uriResult.orderId,
- uriResult.sessionId,
- uriResult.claimToken,
- uriResult.noncePriv,
- );
-
- return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
-}
-
-/**
- * Generate deposit permissions for a purchase.
- *
- * Accesses the database and the crypto worker.
- */
-export async function generateDepositPermissions(
- ws: InternalWalletState,
- payCoinSel: PayCoinSelection,
- contractData: WalletContractData,
-): Promise<CoinDepositPermission[]> {
- const depositPermissions: CoinDepositPermission[] = [];
- const coinWithDenom: Array<{
- coin: CoinRecord;
- denom: DenominationRecord;
- }> = [];
- await ws.db
- .mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
- .runReadOnly(async (tx) => {
- for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
- const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
- if (!coin) {
- throw Error("can't pay, allocated coin not found anymore");
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error(
- "can't pay, denomination of allocated coin not found anymore",
- );
- }
- coinWithDenom.push({ coin, denom });
- }
- });
-
- for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
- const { coin, denom } = coinWithDenom[i];
- const dp = await ws.cryptoApi.signDepositPermission({
- coinPriv: coin.coinPriv,
- coinPub: coin.coinPub,
- contractTermsHash: contractData.contractTermsHash,
- denomPubHash: coin.denomPubHash,
- denomSig: coin.denomSig,
- exchangeBaseUrl: coin.exchangeBaseUrl,
- feeDeposit: denom.feeDeposit,
- merchantPub: contractData.merchantPub,
- refundDeadline: contractData.refundDeadline,
- spendAmount: payCoinSel.coinContributions[i],
- timestamp: contractData.timestamp,
- wireInfoHash: contractData.wireInfoHash,
- });
- depositPermissions.push(dp);
- }
- return depositPermissions;
-}
-
-/**
- * Add a contract to the wallet and sign coins, and send them.
- */
-export async function confirmPay(
- ws: InternalWalletState,
- proposalId: string,
- sessionIdOverride?: string,
-): Promise<ConfirmPayResult> {
- logger.trace(
- `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
- );
- const proposal = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadOnly(async (tx) => {
- return tx.proposals.get(proposalId);
- });
-
- if (!proposal) {
- throw Error(`proposal with id ${proposalId} not found`);
- }
-
- const d = proposal.download;
- if (!d) {
- throw Error("proposal is in invalid state");
- }
-
- const existingPurchase = await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (
- purchase &&
- sessionIdOverride !== undefined &&
- sessionIdOverride != purchase.lastSessionId
- ) {
- logger.trace(`changing session ID to ${sessionIdOverride}`);
- purchase.lastSessionId = sessionIdOverride;
- purchase.paymentSubmitPending = true;
- await tx.purchases.put(purchase);
- }
- return purchase;
- });
-
- if (existingPurchase) {
- logger.trace("confirmPay: submitting payment for existing purchase");
- return await guardOperationException(
- () => submitPay(ws, proposalId),
- (e: TalerErrorDetails): Promise<void> =>
- incrementPurchasePayRetry(ws, proposalId, e),
- );
- }
-
- logger.trace("confirmPay: purchase record does not exist yet");
-
- const contractData = d.contractData;
-
- const candidates = await getCandidatePayCoins(ws, {
- allowedAuditors: contractData.allowedAuditors,
- allowedExchanges: contractData.allowedExchanges,
- amount: contractData.amount,
- maxDepositFee: contractData.maxDepositFee,
- maxWireFee: contractData.maxWireFee,
- timestamp: contractData.timestamp,
- wireFeeAmortization: contractData.wireFeeAmortization,
- wireMethod: contractData.wireMethod,
- });
-
- const res = selectPayCoins({
- candidates,
- contractTermsAmount: contractData.amount,
- depositFeeLimit: contractData.maxDepositFee,
- wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
- wireFeeLimit: contractData.maxWireFee,
- prevPayCoins: [],
- });
-
- logger.trace("coin selection result", res);
-
- if (!res) {
- // Should not happen, since checkPay should be called first
- // FIXME: Actually, this should be handled gracefully,
- // and the status should be stored in the DB.
- logger.warn("not confirming payment, insufficient coins");
- throw Error("insufficient balance");
- }
-
- const depositPermissions = await generateDepositPermissions(
- ws,
- res,
- d.contractData,
- );
- await recordConfirmPay(
- ws,
- proposal,
- res,
- depositPermissions,
- sessionIdOverride,
- );
-
- return await guardOperationException(
- () => submitPay(ws, proposalId),
- (e: TalerErrorDetails): Promise<void> =>
- incrementPurchasePayRetry(ws, proposalId, e),
- );
-}
-
-export async function processPurchasePay(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementPurchasePayRetry(ws, proposalId, e);
- await guardOperationException(
- () => processPurchasePayImpl(ws, proposalId, forceNow),
- onOpErr,
- );
-}
-
-async function resetPurchasePayRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (p) {
- p.payRetryInfo = initRetryInfo();
- await tx.purchases.put(p);
- }
- });
-}
-
-async function processPurchasePayImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetPurchasePayRetry(ws, proposalId);
- }
- const purchase = await ws.db
- .mktx((x) => ({ purchases: x.purchases }))
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- return;
- }
- if (!purchase.paymentSubmitPending) {
- return;
- }
- logger.trace(`processing purchase pay ${proposalId}`);
- await submitPay(ws, proposalId);
-}
-
-export async function refuseProposal(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- const success = await ws.db
- .mktx((x) => ({ proposals: x.proposals }))
- .runReadWrite(async (tx) => {
- const proposal = await tx.proposals.get(proposalId);
- if (!proposal) {
- logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
- return false;
- }
- if (proposal.proposalStatus !== ProposalStatus.PROPOSED) {
- return false;
- }
- proposal.proposalStatus = ProposalStatus.REFUSED;
- await tx.proposals.put(proposal);
- return true;
- });
- if (success) {
- ws.notify({
- type: NotificationType.ProposalRefused,
- });
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts
deleted file mode 100644
index a87b1c8b1..000000000
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ /dev/null
@@ -1,354 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Derive pending tasks from the wallet database.
- */
-
-/**
- * Imports.
- */
-import {
- ProposalStatus,
- ReserveRecordStatus,
- AbortStatus,
- WalletStoresV1,
- BackupProviderStateTag,
- RefreshCoinStatus,
-} from "../db.js";
-import {
- PendingOperationsResponse,
- PendingTaskType,
- ReserveType,
-} from "../pending-types.js";
-import {
- getTimestampNow,
- isTimestampExpired,
- Timestamp,
-} from "@gnu-taler/taler-util";
-import { InternalWalletState } from "../common.js";
-import { getBalancesInsideTransaction } from "./balance.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-
-async function gatherExchangePending(
- tx: GetReadOnlyAccess<{
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.exchanges.iter().forEachAsync(async (e) => {
- resp.pendingOperations.push({
- type: PendingTaskType.ExchangeUpdate,
- givesLifeness: false,
- timestampDue: e.nextUpdate,
- exchangeBaseUrl: e.baseUrl,
- lastError: e.lastError,
- });
-
- resp.pendingOperations.push({
- type: PendingTaskType.ExchangeCheckRefresh,
- timestampDue: e.nextRefreshCheck,
- givesLifeness: false,
- exchangeBaseUrl: e.baseUrl,
- });
- });
-}
-
-async function gatherReservePending(
- tx: GetReadOnlyAccess<{ reserves: typeof WalletStoresV1.reserves }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.reserves.iter().forEach((reserve) => {
- const reserveType = reserve.bankInfo
- ? ReserveType.TalerBankWithdraw
- : ReserveType.Manual;
- switch (reserve.reserveStatus) {
- case ReserveRecordStatus.DORMANT:
- // nothing to report as pending
- break;
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- case ReserveRecordStatus.QUERYING_STATUS:
- case ReserveRecordStatus.REGISTERING_BANK:
- resp.pendingOperations.push({
- type: PendingTaskType.Reserve,
- givesLifeness: true,
- timestampDue: reserve.retryInfo.nextRetry,
- stage: reserve.reserveStatus,
- timestampCreated: reserve.timestampCreated,
- reserveType,
- reservePub: reserve.reservePub,
- retryInfo: reserve.retryInfo,
- });
- break;
- default:
- // FIXME: report problem!
- break;
- }
- });
-}
-
-async function gatherRefreshPending(
- tx: GetReadOnlyAccess<{ refreshGroups: typeof WalletStoresV1.refreshGroups }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.refreshGroups.iter().forEach((r) => {
- if (r.timestampFinished) {
- return;
- }
- if (r.frozen) {
- return;
- }
- resp.pendingOperations.push({
- type: PendingTaskType.Refresh,
- givesLifeness: true,
- timestampDue: r.retryInfo.nextRetry,
- refreshGroupId: r.refreshGroupId,
- finishedPerCoin: r.statusPerCoin.map(
- (x) => x === RefreshCoinStatus.Finished,
- ),
- retryInfo: r.retryInfo,
- });
- });
-}
-
-async function gatherWithdrawalPending(
- tx: GetReadOnlyAccess<{
- withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
- planchets: typeof WalletStoresV1.planchets;
- }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.withdrawalGroups.iter().forEachAsync(async (wsr) => {
- if (wsr.timestampFinish) {
- return;
- }
- let numCoinsWithdrawn = 0;
- let numCoinsTotal = 0;
- await tx.planchets.indexes.byGroup
- .iter(wsr.withdrawalGroupId)
- .forEach((x) => {
- numCoinsTotal++;
- if (x.withdrawalDone) {
- numCoinsWithdrawn++;
- }
- });
- resp.pendingOperations.push({
- type: PendingTaskType.Withdraw,
- givesLifeness: true,
- timestampDue: wsr.retryInfo.nextRetry,
- withdrawalGroupId: wsr.withdrawalGroupId,
- lastError: wsr.lastError,
- retryInfo: wsr.retryInfo,
- });
- });
-}
-
-async function gatherProposalPending(
- tx: GetReadOnlyAccess<{ proposals: typeof WalletStoresV1.proposals }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.proposals.iter().forEach((proposal) => {
- if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
- // Nothing to do, user needs to choose.
- } else if (proposal.proposalStatus == ProposalStatus.DOWNLOADING) {
- const timestampDue = proposal.retryInfo?.nextRetry ?? getTimestampNow();
- resp.pendingOperations.push({
- type: PendingTaskType.ProposalDownload,
- givesLifeness: true,
- timestampDue,
- merchantBaseUrl: proposal.merchantBaseUrl,
- orderId: proposal.orderId,
- proposalId: proposal.proposalId,
- proposalTimestamp: proposal.timestamp,
- lastError: proposal.lastError,
- retryInfo: proposal.retryInfo,
- });
- }
- });
-}
-
-async function gatherDepositPending(
- tx: GetReadOnlyAccess<{ depositGroups: typeof WalletStoresV1.depositGroups }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.depositGroups.iter().forEach((dg) => {
- if (dg.timestampFinished) {
- return;
- }
- const timestampDue = dg.retryInfo?.nextRetry ?? getTimestampNow();
- resp.pendingOperations.push({
- type: PendingTaskType.Deposit,
- givesLifeness: true,
- timestampDue,
- depositGroupId: dg.depositGroupId,
- lastError: dg.lastError,
- retryInfo: dg.retryInfo,
- });
- });
-}
-
-async function gatherTipPending(
- tx: GetReadOnlyAccess<{ tips: typeof WalletStoresV1.tips }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.tips.iter().forEach((tip) => {
- if (tip.pickedUpTimestamp) {
- return;
- }
- if (tip.acceptedTimestamp) {
- resp.pendingOperations.push({
- type: PendingTaskType.TipPickup,
- givesLifeness: true,
- timestampDue: tip.retryInfo.nextRetry,
- merchantBaseUrl: tip.merchantBaseUrl,
- tipId: tip.walletTipId,
- merchantTipId: tip.merchantTipId,
- });
- }
- });
-}
-
-async function gatherPurchasePending(
- tx: GetReadOnlyAccess<{ purchases: typeof WalletStoresV1.purchases }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.purchases.iter().forEach((pr) => {
- if (
- pr.paymentSubmitPending &&
- pr.abortStatus === AbortStatus.None &&
- !pr.payFrozen
- ) {
- const timestampDue = pr.payRetryInfo?.nextRetry ?? getTimestampNow();
- resp.pendingOperations.push({
- type: PendingTaskType.Pay,
- givesLifeness: true,
- timestampDue,
- isReplay: false,
- proposalId: pr.proposalId,
- retryInfo: pr.payRetryInfo,
- lastError: pr.lastPayError,
- });
- }
- if (pr.refundQueryRequested) {
- resp.pendingOperations.push({
- type: PendingTaskType.RefundQuery,
- givesLifeness: true,
- timestampDue: pr.refundStatusRetryInfo.nextRetry,
- proposalId: pr.proposalId,
- retryInfo: pr.refundStatusRetryInfo,
- lastError: pr.lastRefundStatusError,
- });
- }
- });
-}
-
-async function gatherRecoupPending(
- tx: GetReadOnlyAccess<{ recoupGroups: typeof WalletStoresV1.recoupGroups }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.recoupGroups.iter().forEach((rg) => {
- if (rg.timestampFinished) {
- return;
- }
- resp.pendingOperations.push({
- type: PendingTaskType.Recoup,
- givesLifeness: true,
- timestampDue: rg.retryInfo.nextRetry,
- recoupGroupId: rg.recoupGroupId,
- retryInfo: rg.retryInfo,
- lastError: rg.lastError,
- });
- });
-}
-
-async function gatherBackupPending(
- tx: GetReadOnlyAccess<{
- backupProviders: typeof WalletStoresV1.backupProviders;
- }>,
- now: Timestamp,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.backupProviders.iter().forEach((bp) => {
- if (bp.state.tag === BackupProviderStateTag.Ready) {
- resp.pendingOperations.push({
- type: PendingTaskType.Backup,
- givesLifeness: false,
- timestampDue: bp.state.nextBackupTimestamp,
- backupProviderBaseUrl: bp.baseUrl,
- lastError: undefined,
- });
- } else if (bp.state.tag === BackupProviderStateTag.Retrying) {
- resp.pendingOperations.push({
- type: PendingTaskType.Backup,
- givesLifeness: false,
- timestampDue: bp.state.retryInfo.nextRetry,
- backupProviderBaseUrl: bp.baseUrl,
- retryInfo: bp.state.retryInfo,
- lastError: bp.state.lastError,
- });
- }
- });
-}
-
-export async function getPendingOperations(
- ws: InternalWalletState,
-): Promise<PendingOperationsResponse> {
- const now = getTimestampNow();
- return await ws.db
- .mktx((x) => ({
- backupProviders: x.backupProviders,
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- reserves: x.reserves,
- refreshGroups: x.refreshGroups,
- coins: x.coins,
- withdrawalGroups: x.withdrawalGroups,
- proposals: x.proposals,
- tips: x.tips,
- purchases: x.purchases,
- planchets: x.planchets,
- depositGroups: x.depositGroups,
- recoupGroups: x.recoupGroups,
- }))
- .runReadWrite(async (tx) => {
- const walletBalance = await getBalancesInsideTransaction(ws, tx);
- const resp: PendingOperationsResponse = {
- walletBalance,
- pendingOperations: [],
- };
- await gatherExchangePending(tx, now, resp);
- await gatherReservePending(tx, now, resp);
- await gatherRefreshPending(tx, now, resp);
- await gatherWithdrawalPending(tx, now, resp);
- await gatherProposalPending(tx, now, resp);
- await gatherDepositPending(tx, now, resp);
- await gatherTipPending(tx, now, resp);
- await gatherPurchasePending(tx, now, resp);
- await gatherRecoupPending(tx, now, resp);
- await gatherBackupPending(tx, now, resp);
- return resp;
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts
deleted file mode 100644
index b1f46e4ba..000000000
--- a/packages/taler-wallet-core/src/operations/recoup.ts
+++ /dev/null
@@ -1,485 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the recoup operation, which allows to recover the
- * value of coins held in a revoked denomination.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-/**
- * Imports.
- */
-import {
- Amounts,
- codecForRecoupConfirmation,
- getTimestampNow,
- NotificationType,
- RefreshReason,
- TalerErrorDetails,
-} from "@gnu-taler/taler-util";
-import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-import {
- CoinRecord,
- CoinSourceType,
- CoinStatus,
- RecoupGroupRecord,
- RefreshCoinSource,
- ReserveRecordStatus,
- WithdrawCoinSource,
- WalletStoresV1,
-} from "../db.js";
-
-import { readSuccessResponseJsonOrThrow } from "../util/http.js";
-import { Logger, URL } from "@gnu-taler/taler-util";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import { guardOperationException } from "../errors.js";
-import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
-import { getReserveRequestTimeout, processReserve } from "./reserves.js";
-import { InternalWalletState } from "../common.js";
-import { GetReadWriteAccess } from "../util/query.js";
-
-const logger = new Logger("operations/recoup.ts");
-
-async function incrementRecoupRetry(
- ws: InternalWalletState,
- recoupGroupId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- recoupGroups: x.recoupGroups,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.recoupGroups.get(recoupGroupId);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.recoupGroups.put(r);
- });
- if (err) {
- ws.notify({ type: NotificationType.RecoupOperationError, error: err });
- }
-}
-
-async function putGroupAsFinished(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- recoupGroups: typeof WalletStoresV1.recoupGroups;
- denominations: typeof WalletStoresV1.denominations;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coins: typeof WalletStoresV1.coins;
- }>,
- recoupGroup: RecoupGroupRecord,
- coinIdx: number,
-): Promise<void> {
- logger.trace(
- `setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
- );
- if (recoupGroup.timestampFinished) {
- return;
- }
- recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
- let allFinished = true;
- for (const b of recoupGroup.recoupFinishedPerCoin) {
- if (!b) {
- allFinished = false;
- }
- }
- if (allFinished) {
- logger.trace("all recoups of recoup group are finished");
- recoupGroup.timestampFinished = getTimestampNow();
- recoupGroup.retryInfo = initRetryInfo();
- recoupGroup.lastError = undefined;
- if (recoupGroup.scheduleRefreshCoins.length > 0) {
- const refreshGroupId = await createRefreshGroup(
- ws,
- tx,
- recoupGroup.scheduleRefreshCoins.map((x) => ({ coinPub: x })),
- RefreshReason.Recoup,
- );
- processRefreshGroup(ws, refreshGroupId.refreshGroupId).then((e) => {
- console.error("error while refreshing after recoup", e);
- });
- }
- }
- await tx.recoupGroups.put(recoupGroup);
-}
-
-async function recoupTipCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
-): Promise<void> {
- // We can't really recoup a coin we got via tipping.
- // Thus we just put the coin to sleep.
- // FIXME: somehow report this to the user
- await ws.db
- .mktx((x) => ({
- recoupGroups: x.recoupGroups,
- denominations: WalletStoresV1.denominations,
- refreshGroups: WalletStoresV1.refreshGroups,
- coins: WalletStoresV1.coins,
- }))
- .runReadWrite(async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- });
-}
-
-async function recoupWithdrawCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
- cs: WithdrawCoinSource,
-): Promise<void> {
- const reservePub = cs.reservePub;
- const reserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.get(reservePub);
- });
- if (!reserve) {
- // FIXME: We should at least emit some pending operation / warning for this?
- return;
- }
-
- ws.notify({
- type: NotificationType.RecoupStarted,
- });
-
- const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
- const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
- const resp = await ws.http.postJson(reqUrl.href, recoupRequest, {
- timeout: getReserveRequestTimeout(reserve),
- });
- const recoupConfirmation = await readSuccessResponseJsonOrThrow(
- resp,
- codecForRecoupConfirmation(),
- );
-
- if (recoupConfirmation.reserve_pub !== reservePub) {
- throw Error(`Coin's reserve doesn't match reserve on recoup`);
- }
-
- // FIXME: verify that our expectations about the amount match
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- reserves: x.reserves,
- recoupGroups: x.recoupGroups,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const updatedCoin = await tx.coins.get(coin.coinPub);
- if (!updatedCoin) {
- return;
- }
- const updatedReserve = await tx.reserves.get(reserve.reservePub);
- if (!updatedReserve) {
- return;
- }
- updatedCoin.status = CoinStatus.Dormant;
- const currency = updatedCoin.currentAmount.currency;
- updatedCoin.currentAmount = Amounts.getZero(currency);
- if (updatedReserve.reserveStatus === ReserveRecordStatus.DORMANT) {
- updatedReserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- updatedReserve.retryInfo = initRetryInfo();
- } else {
- updatedReserve.requestedQuery = true;
- updatedReserve.retryInfo = initRetryInfo();
- }
- await tx.coins.put(updatedCoin);
- await tx.reserves.put(updatedReserve);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- });
-
- ws.notify({
- type: NotificationType.RecoupFinished,
- });
-}
-
-async function recoupRefreshCoin(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
- coin: CoinRecord,
- cs: RefreshCoinSource,
-): Promise<void> {
- ws.notify({
- type: NotificationType.RecoupStarted,
- });
-
- const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
- const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
- logger.trace(`making recoup request for ${coin.coinPub}`);
-
- const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
- const recoupConfirmation = await readSuccessResponseJsonOrThrow(
- resp,
- codecForRecoupConfirmation(),
- );
-
- if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
- throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
- }
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- reserves: x.reserves,
- recoupGroups: x.recoupGroups,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
- const oldCoin = await tx.coins.get(cs.oldCoinPub);
- const revokedCoin = await tx.coins.get(coin.coinPub);
- if (!revokedCoin) {
- logger.warn("revoked coin for recoup not found");
- return;
- }
- if (!oldCoin) {
- logger.warn("refresh old coin for recoup not found");
- return;
- }
- revokedCoin.status = CoinStatus.Dormant;
- oldCoin.currentAmount = Amounts.add(
- oldCoin.currentAmount,
- recoupGroup.oldAmountPerCoin[coinIdx],
- ).amount;
- logger.trace(
- "recoup: setting old coin amount to",
- Amounts.stringify(oldCoin.currentAmount),
- );
- recoupGroup.scheduleRefreshCoins.push(oldCoin.coinPub);
- await tx.coins.put(revokedCoin);
- await tx.coins.put(oldCoin);
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- });
-}
-
-async function resetRecoupGroupRetry(
- ws: InternalWalletState,
- recoupGroupId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- recoupGroups: x.recoupGroups,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.recoupGroups.get(recoupGroupId);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.recoupGroups.put(x);
- }
- });
-}
-
-export async function processRecoupGroup(
- ws: InternalWalletState,
- recoupGroupId: string,
- forceNow = false,
-): Promise<void> {
- await ws.memoProcessRecoup.memo(recoupGroupId, async () => {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementRecoupRetry(ws, recoupGroupId, e);
- return await guardOperationException(
- async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow),
- onOpErr,
- );
- });
-}
-
-async function processRecoupGroupImpl(
- ws: InternalWalletState,
- recoupGroupId: string,
- forceNow = false,
-): Promise<void> {
- if (forceNow) {
- await resetRecoupGroupRetry(ws, recoupGroupId);
- }
- const recoupGroup = await ws.db
- .mktx((x) => ({
- recoupGroups: x.recoupGroups,
- }))
- .runReadOnly(async (tx) => {
- return tx.recoupGroups.get(recoupGroupId);
- });
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.timestampFinished) {
- logger.trace("recoup group finished");
- return;
- }
- const ps = recoupGroup.coinPubs.map((x, i) =>
- processRecoup(ws, recoupGroupId, i),
- );
- await Promise.all(ps);
-
- const reserveSet = new Set<string>();
- for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
- const coinPub = recoupGroup.coinPubs[i];
- const coin = await ws.db
- .mktx((x) => ({
- coins: x.coins,
- }))
- .runReadOnly(async (tx) => {
- return tx.coins.get(coinPub);
- });
- if (!coin) {
- throw Error(`Coin ${coinPub} not found, can't request recoup`);
- }
- if (coin.coinSource.type === CoinSourceType.Withdraw) {
- reserveSet.add(coin.coinSource.reservePub);
- }
- }
-
- for (const r of reserveSet.values()) {
- processReserve(ws, r).catch((e) => {
- logger.error(`processing reserve ${r} after recoup failed`);
- });
- }
-}
-
-export async function createRecoupGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- recoupGroups: typeof WalletStoresV1.recoupGroups;
- denominations: typeof WalletStoresV1.denominations;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- coins: typeof WalletStoresV1.coins;
- }>,
- coinPubs: string[],
-): Promise<string> {
- const recoupGroupId = encodeCrock(getRandomBytes(32));
-
- const recoupGroup: RecoupGroupRecord = {
- recoupGroupId,
- coinPubs: coinPubs,
- lastError: undefined,
- timestampFinished: undefined,
- timestampStarted: getTimestampNow(),
- retryInfo: initRetryInfo(),
- recoupFinishedPerCoin: coinPubs.map(() => false),
- // Will be populated later
- oldAmountPerCoin: [],
- scheduleRefreshCoins: [],
- };
-
- for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
- const coinPub = coinPubs[coinIdx];
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- continue;
- }
- if (Amounts.isZero(coin.currentAmount)) {
- await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
- continue;
- }
- recoupGroup.oldAmountPerCoin[coinIdx] = coin.currentAmount;
- coin.currentAmount = Amounts.getZero(coin.currentAmount.currency);
- await tx.coins.put(coin);
- }
-
- await tx.recoupGroups.put(recoupGroup);
-
- return recoupGroupId;
-}
-
-async function processRecoup(
- ws: InternalWalletState,
- recoupGroupId: string,
- coinIdx: number,
-): Promise<void> {
- const coin = await ws.db
- .mktx((x) => ({
- recoupGroups: x.recoupGroups,
- coins: x.coins,
- }))
- .runReadOnly(async (tx) => {
- const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
- if (!recoupGroup) {
- return;
- }
- if (recoupGroup.timestampFinished) {
- return;
- }
- if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
- return;
- }
-
- const coinPub = recoupGroup.coinPubs[coinIdx];
-
- const coin = await tx.coins.get(coinPub);
- if (!coin) {
- throw Error(`Coin ${coinPub} not found, can't request payback`);
- }
- return coin;
- });
-
- if (!coin) {
- return;
- }
-
- const cs = coin.coinSource;
-
- switch (cs.type) {
- case CoinSourceType.Tip:
- return recoupTipCoin(ws, recoupGroupId, coinIdx, coin);
- case CoinSourceType.Refresh:
- return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs);
- case CoinSourceType.Withdraw:
- return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs);
- default:
- throw Error("unknown coin source type");
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts
deleted file mode 100644
index 144514e1c..000000000
--- a/packages/taler-wallet-core/src/operations/refresh.ts
+++ /dev/null
@@ -1,987 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-import {
- CoinRecord,
- CoinSourceType,
- CoinStatus,
- DenominationRecord,
- RefreshCoinStatus,
- RefreshGroupRecord,
- WalletStoresV1,
-} from "../db.js";
-import {
- codecForExchangeMeltResponse,
- codecForExchangeRevealResponse,
- CoinPublicKey,
- fnutil,
- NotificationType,
- RefreshGroupId,
- RefreshPlanchetInfo,
- RefreshReason,
- stringifyTimestamp,
- TalerErrorDetails,
- timestampToIsoString,
-} from "@gnu-taler/taler-util";
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { amountToPretty } from "@gnu-taler/taler-util";
-import {
- HttpResponseStatus,
- readSuccessResponseJsonOrThrow,
- readUnexpectedResponseDetails,
-} from "../util/http.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { Logger } from "@gnu-taler/taler-util";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import {
- Duration,
- durationFromSpec,
- durationMul,
- getTimestampNow,
- isTimestampExpired,
- Timestamp,
- timestampAddDuration,
- timestampDifference,
- timestampMin,
- URL,
-} from "@gnu-taler/taler-util";
-import { guardOperationException } from "../errors.js";
-import { updateExchangeFromUrl } from "./exchanges.js";
-import { EXCHANGE_COINS_LOCK, InternalWalletState } from "../common.js";
-import {
- isWithdrawableDenom,
- selectWithdrawalDenominations,
-} from "./withdraw.js";
-import { RefreshNewDenomInfo } from "../crypto/cryptoTypes.js";
-import { GetReadWriteAccess } from "../util/query.js";
-
-const logger = new Logger("refresh.ts");
-
-/**
- * Get the amount that we lose when refreshing a coin of the given denomination
- * with a certain amount left.
- *
- * If the amount left is zero, then the refresh cost
- * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of
- * the right denominations), then the cost is the full amount left.
- *
- * Considers refresh fees, withdrawal fees after refresh and amounts too small
- * to refresh.
- */
-export function getTotalRefreshCost(
- denoms: DenominationRecord[],
- refreshedDenom: DenominationRecord,
- amountLeft: AmountJson,
-): AmountJson {
- const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh)
- .amount;
- const withdrawDenoms = selectWithdrawalDenominations(withdrawAmount, denoms);
- const resultingAmount = Amounts.add(
- Amounts.getZero(withdrawAmount.currency),
- ...withdrawDenoms.selectedDenoms.map(
- (d) => Amounts.mult(d.denom.value, d.count).amount,
- ),
- ).amount;
- const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
- logger.trace(
- `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
- totalCost,
- )}`,
- );
- return totalCost;
-}
-
-function updateGroupStatus(rg: RefreshGroupRecord): void {
- let allDone = fnutil.all(
- rg.statusPerCoin,
- (x) => x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Frozen,
- );
- let anyFrozen = fnutil.any(
- rg.statusPerCoin,
- (x) => x === RefreshCoinStatus.Frozen,
- );
- if (allDone) {
- if (anyFrozen) {
- rg.frozen = true;
- rg.retryInfo = initRetryInfo();
- } else {
- rg.timestampFinished = getTimestampNow();
- rg.retryInfo = initRetryInfo();
- }
- }
-}
-
-/**
- * Create a refresh session for one particular coin inside a refresh group.
- */
-async function refreshCreateSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
- );
-
- const d = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- coins: x.coins,
- }))
- .runReadWrite(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- if (
- refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished
- ) {
- return;
- }
- const existingRefreshSession =
- refreshGroup.refreshSessionPerCoin[coinIndex];
- if (existingRefreshSession) {
- return;
- }
- const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
- const coin = await tx.coins.get(oldCoinPub);
- if (!coin) {
- throw Error("Can't refresh, coin not found");
- }
- return { refreshGroup, coin };
- });
-
- if (!d) {
- return;
- }
-
- const { refreshGroup, coin } = d;
-
- const { exchange } = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl);
- if (!exchange) {
- throw Error("db inconsistent: exchange of coin not found");
- }
-
- // FIXME: use helper functions from withdraw.ts
- // to update and filter withdrawable denoms.
-
- const { availableAmount, availableDenoms } = await ws.db
- .mktx((x) => ({
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- const oldDenom = await tx.denominations.get([
- exchange.baseUrl,
- coin.denomPubHash,
- ]);
-
- if (!oldDenom) {
- throw Error("db inconsistent: denomination for coin not found");
- }
-
- // FIXME: use an index here, based on the withdrawal expiration time.
- const availableDenoms: DenominationRecord[] = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(exchange.baseUrl)
- .toArray();
-
- const availableAmount = Amounts.sub(
- refreshGroup.inputPerCoin[coinIndex],
- oldDenom.feeRefresh,
- ).amount;
- return { availableAmount, availableDenoms };
- });
-
- const newCoinDenoms = selectWithdrawalDenominations(
- availableAmount,
- availableDenoms,
- );
-
- if (logger.shouldLogTrace()) {
- logger.trace(`printing selected denominations for refresh`);
- logger.trace(`current time: ${stringifyTimestamp(getTimestampNow())}`);
- for (const denom of newCoinDenoms.selectedDenoms) {
- logger.trace(`denom ${denom.denom}, count ${denom.count}`);
- logger.trace(
- `withdrawal expiration ${stringifyTimestamp(
- denom.denom.stampExpireWithdraw,
- )}`,
- );
- }
- }
-
- if (newCoinDenoms.selectedDenoms.length === 0) {
- logger.trace(
- `not refreshing, available amount ${amountToPretty(
- availableAmount,
- )} too small`,
- );
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
- updateGroupStatus(rg);
-
- await tx.refreshGroups.put(rg);
- });
- ws.notify({ type: NotificationType.RefreshUnwarranted });
- return;
- }
-
- const sessionSecretSeed = encodeCrock(getRandomBytes(64));
-
- // Store refresh session for this coin in the database.
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- coins: x.coins,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.refreshSessionPerCoin[coinIndex]) {
- return;
- }
- rg.refreshSessionPerCoin[coinIndex] = {
- norevealIndex: undefined,
- sessionSecretSeed: sessionSecretSeed,
- newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
- count: x.count,
- denomPubHash: x.denom.denomPubHash,
- })),
- amountRefreshOutput: newCoinDenoms.totalCoinValue,
- };
- await tx.refreshGroups.put(rg);
- });
- logger.info(
- `created refresh session for coin #${coinIndex} in ${refreshGroupId}`,
- );
- ws.notify({ type: NotificationType.RefreshStarted });
-}
-
-function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
- return { d_ms: 5000 };
-}
-
-async function refreshMelt(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- const d = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- coins: x.coins,
- denominations: x.denominations,
- }))
- .runReadWrite(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
- if (!refreshSession) {
- return;
- }
- if (refreshSession.norevealIndex !== undefined) {
- return;
- }
-
- const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
- checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
- const oldDenom = await tx.denominations.get([
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- ]);
- checkDbInvariant(
- !!oldDenom,
- "denomination for melted coin doesn't exist",
- );
-
- const newCoinDenoms: RefreshNewDenomInfo[] = [];
-
- for (const dh of refreshSession.newDenoms) {
- const newDenom = await tx.denominations.get([
- oldCoin.exchangeBaseUrl,
- dh.denomPubHash,
- ]);
- checkDbInvariant(
- !!newDenom,
- "new denomination for refresh not in database",
- );
- newCoinDenoms.push({
- count: dh.count,
- denomPub: newDenom.denomPub,
- feeWithdraw: newDenom.feeWithdraw,
- value: newDenom.value,
- });
- }
- return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
- });
-
- if (!d) {
- return;
- }
-
- const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d;
-
- const derived = await ws.cryptoApi.deriveRefreshSession({
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: oldDenom.feeRefresh,
- newCoinDenoms,
- sessionSecretSeed: refreshSession.sessionSecretSeed,
- });
-
- const reqUrl = new URL(
- `coins/${oldCoin.coinPub}/melt`,
- oldCoin.exchangeBaseUrl,
- );
- const meltReq = {
- coin_pub: oldCoin.coinPub,
- confirm_sig: derived.confirmSig,
- denom_pub_hash: oldCoin.denomPubHash,
- denom_sig: oldCoin.denomSig,
- rc: derived.hash,
- value_with_fee: Amounts.stringify(derived.meltValueWithFee),
- };
- logger.trace(`melt request for coin:`, meltReq);
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
- return await ws.http.postJson(reqUrl.href, meltReq, {
- timeout: getRefreshRequestTimeout(refreshGroup),
- });
- });
-
- if (resp.status === HttpResponseStatus.NotFound) {
- const errDetails = await readUnexpectedResponseDetails(resp);
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.timestampFinished) {
- return;
- }
- if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
- return;
- }
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Frozen;
- rg.lastErrorPerCoin[coinIndex] = errDetails;
- updateGroupStatus(rg);
- await tx.refreshGroups.put(rg);
- });
- return;
- }
-
- const meltResponse = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeMeltResponse(),
- );
-
- const norevealIndex = meltResponse.noreveal_index;
-
- refreshSession.norevealIndex = norevealIndex;
-
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- return;
- }
- if (rg.timestampFinished) {
- return;
- }
- const rs = rg.refreshSessionPerCoin[coinIndex];
- if (!rs) {
- return;
- }
- if (rs.norevealIndex !== undefined) {
- return;
- }
- rs.norevealIndex = norevealIndex;
- await tx.refreshGroups.put(rg);
- });
-
- ws.notify({
- type: NotificationType.RefreshMelted,
- });
-}
-
-async function refreshReveal(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- const d = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- coins: x.coins,
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
- if (!refreshGroup) {
- return;
- }
- const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
- if (!refreshSession) {
- return;
- }
- const norevealIndex = refreshSession.norevealIndex;
- if (norevealIndex === undefined) {
- throw Error("can't reveal without melting first");
- }
-
- const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
- checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
- const oldDenom = await tx.denominations.get([
- oldCoin.exchangeBaseUrl,
- oldCoin.denomPubHash,
- ]);
- checkDbInvariant(
- !!oldDenom,
- "denomination for melted coin doesn't exist",
- );
-
- const newCoinDenoms: RefreshNewDenomInfo[] = [];
-
- for (const dh of refreshSession.newDenoms) {
- const newDenom = await tx.denominations.get([
- oldCoin.exchangeBaseUrl,
- dh.denomPubHash,
- ]);
- checkDbInvariant(
- !!newDenom,
- "new denomination for refresh not in database",
- );
- newCoinDenoms.push({
- count: dh.count,
- denomPub: newDenom.denomPub,
- feeWithdraw: newDenom.feeWithdraw,
- value: newDenom.value,
- });
- }
- return {
- oldCoin,
- oldDenom,
- newCoinDenoms,
- refreshSession,
- refreshGroup,
- norevealIndex,
- };
- });
-
- if (!d) {
- return;
- }
-
- const {
- oldCoin,
- oldDenom,
- newCoinDenoms,
- refreshSession,
- refreshGroup,
- norevealIndex,
- } = d;
-
- const derived = await ws.cryptoApi.deriveRefreshSession({
- kappa: 3,
- meltCoinDenomPubHash: oldCoin.denomPubHash,
- meltCoinPriv: oldCoin.coinPriv,
- meltCoinPub: oldCoin.coinPub,
- feeRefresh: oldDenom.feeRefresh,
- newCoinDenoms,
- sessionSecretSeed: refreshSession.sessionSecretSeed,
- });
-
- const privs = Array.from(derived.transferPrivs);
- privs.splice(norevealIndex, 1);
-
- const planchets = derived.planchetsForGammas[norevealIndex];
- if (!planchets) {
- throw Error("refresh index error");
- }
-
- const evs = planchets.map((x: RefreshPlanchetInfo) => x.coinEv);
- const newDenomsFlat: string[] = [];
- const linkSigs: string[] = [];
-
- for (let i = 0; i < refreshSession.newDenoms.length; i++) {
- const dsel = refreshSession.newDenoms[i];
- for (let j = 0; j < dsel.count; j++) {
- const newCoinIndex = linkSigs.length;
- const linkSig = await ws.cryptoApi.signCoinLink(
- oldCoin.coinPriv,
- dsel.denomPubHash,
- oldCoin.coinPub,
- derived.transferPubs[norevealIndex],
- planchets[newCoinIndex].coinEv,
- );
- linkSigs.push(linkSig);
- newDenomsFlat.push(dsel.denomPubHash);
- }
- }
-
- const req = {
- coin_evs: evs,
- new_denoms_h: newDenomsFlat,
- rc: derived.hash,
- transfer_privs: privs,
- transfer_pub: derived.transferPubs[norevealIndex],
- link_sigs: linkSigs,
- };
-
- const reqUrl = new URL(
- `refreshes/${derived.hash}/reveal`,
- oldCoin.exchangeBaseUrl,
- );
-
- const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], async () => {
- return await ws.http.postJson(reqUrl.href, req, {
- timeout: getRefreshRequestTimeout(refreshGroup),
- });
- });
-
- const reveal = await readSuccessResponseJsonOrThrow(
- resp,
- codecForExchangeRevealResponse(),
- );
-
- const coins: CoinRecord[] = [];
-
- for (let i = 0; i < refreshSession.newDenoms.length; i++) {
- for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
- const newCoinIndex = coins.length;
- // FIXME: Look up in earlier transaction!
- const denom = await ws.db
- .mktx((x) => ({
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- return tx.denominations.get([
- oldCoin.exchangeBaseUrl,
- refreshSession.newDenoms[i].denomPubHash,
- ]);
- });
- if (!denom) {
- console.error("denom not found");
- continue;
- }
- const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
- const denomSig = await ws.cryptoApi.rsaUnblind(
- reveal.ev_sigs[newCoinIndex].ev_sig,
- pc.blindingKey,
- denom.denomPub,
- );
- const coin: CoinRecord = {
- blindingKey: pc.blindingKey,
- coinPriv: pc.privateKey,
- coinPub: pc.publicKey,
- currentAmount: denom.value,
- denomPub: denom.denomPub,
- denomPubHash: denom.denomPubHash,
- denomSig,
- exchangeBaseUrl: oldCoin.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Refresh,
- oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
- },
- suspended: false,
- coinEvHash: pc.coinEv,
- };
-
- coins.push(coin);
- }
- }
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (!rg) {
- logger.warn("no refresh session found");
- return;
- }
- const rs = rg.refreshSessionPerCoin[coinIndex];
- if (!rs) {
- return;
- }
- rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
- updateGroupStatus(rg);
- for (const coin of coins) {
- await tx.coins.put(coin);
- }
- await tx.refreshGroups.put(rg);
- });
- logger.trace("refresh finished (end of reveal)");
- ws.notify({
- type: NotificationType.RefreshRevealed,
- });
-}
-
-async function incrementRefreshRetry(
- ws: InternalWalletState,
- refreshGroupId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.refreshGroups.get(refreshGroupId);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.refreshGroups.put(r);
- });
- if (err) {
- ws.notify({ type: NotificationType.RefreshOperationError, error: err });
- }
-}
-
-/**
- * Actually process a refresh group that has been created.
- */
-export async function processRefreshGroup(
- ws: InternalWalletState,
- refreshGroupId: string,
- forceNow = false,
-): Promise<void> {
- await ws.memoProcessRefresh.memo(refreshGroupId, async () => {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementRefreshRetry(ws, refreshGroupId, e);
- return await guardOperationException(
- async () => await processRefreshGroupImpl(ws, refreshGroupId, forceNow),
- onOpErr,
- );
- });
-}
-
-async function resetRefreshGroupRetry(
- ws: InternalWalletState,
- refreshGroupId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.refreshGroups.get(refreshGroupId);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.refreshGroups.put(x);
- }
- });
-}
-
-async function processRefreshGroupImpl(
- ws: InternalWalletState,
- refreshGroupId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetRefreshGroupRetry(ws, refreshGroupId);
- }
- const refreshGroup = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- }))
- .runReadOnly(async (tx) => {
- return tx.refreshGroups.get(refreshGroupId);
- });
- if (!refreshGroup) {
- return;
- }
- if (refreshGroup.timestampFinished) {
- return;
- }
- // Process refresh sessions of the group in parallel.
- const ps = refreshGroup.oldCoinPubs.map((x, i) =>
- processRefreshSession(ws, refreshGroupId, i),
- );
- await Promise.all(ps);
- logger.trace("refresh finished");
-}
-
-async function processRefreshSession(
- ws: InternalWalletState,
- refreshGroupId: string,
- coinIndex: number,
-): Promise<void> {
- logger.trace(
- `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
- );
- let refreshGroup = await ws.db
- .mktx((x) => ({ refreshGroups: x.refreshGroups }))
- .runReadOnly(async (tx) => {
- return tx.refreshGroups.get(refreshGroupId);
- });
- if (!refreshGroup) {
- return;
- }
- if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
- return;
- }
- if (!refreshGroup.refreshSessionPerCoin[coinIndex]) {
- await refreshCreateSession(ws, refreshGroupId, coinIndex);
- refreshGroup = await ws.db
- .mktx((x) => ({ refreshGroups: x.refreshGroups }))
- .runReadOnly(async (tx) => {
- return tx.refreshGroups.get(refreshGroupId);
- });
- if (!refreshGroup) {
- return;
- }
- }
- const refreshSession = refreshGroup.refreshSessionPerCoin[coinIndex];
- if (!refreshSession) {
- if (refreshGroup.statusPerCoin[coinIndex] !== RefreshCoinStatus.Finished) {
- throw Error(
- "BUG: refresh session was not created and coin not marked as finished",
- );
- }
- return;
- }
- if (refreshSession.norevealIndex === undefined) {
- await refreshMelt(ws, refreshGroupId, coinIndex);
- }
- await refreshReveal(ws, refreshGroupId, coinIndex);
-}
-
-/**
- * Create a refresh group for a list of coins.
- *
- * Refreshes the remaining amount on the coin, effectively capturing the remaining
- * value in the refresh group.
- *
- * The caller must ensure that
- * the remaining amount was updated correctly before the coin was deposited or
- * credited.
- *
- * The caller must also ensure that the coins that should be refreshed exist
- * in the current database transaction.
- */
-export async function createRefreshGroup(
- ws: InternalWalletState,
- tx: GetReadWriteAccess<{
- denominations: typeof WalletStoresV1.denominations;
- coins: typeof WalletStoresV1.coins;
- refreshGroups: typeof WalletStoresV1.refreshGroups;
- }>,
- oldCoinPubs: CoinPublicKey[],
- reason: RefreshReason,
-): Promise<RefreshGroupId> {
- const refreshGroupId = encodeCrock(getRandomBytes(32));
-
- const inputPerCoin: AmountJson[] = [];
- const estimatedOutputPerCoin: AmountJson[] = [];
-
- const denomsPerExchange: Record<string, DenominationRecord[]> = {};
-
- const getDenoms = async (
- exchangeBaseUrl: string,
- ): Promise<DenominationRecord[]> => {
- if (denomsPerExchange[exchangeBaseUrl]) {
- return denomsPerExchange[exchangeBaseUrl];
- }
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(exchangeBaseUrl)
- .filter((x) => {
- return isWithdrawableDenom(x);
- });
- denomsPerExchange[exchangeBaseUrl] = allDenoms;
- return allDenoms;
- };
-
- for (const ocp of oldCoinPubs) {
- const coin = await tx.coins.get(ocp.coinPub);
- checkDbInvariant(!!coin, "coin must be in database");
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- checkDbInvariant(
- !!denom,
- "denomination for existing coin must be in database",
- );
- const refreshAmount = coin.currentAmount;
- inputPerCoin.push(refreshAmount);
- coin.currentAmount = Amounts.getZero(refreshAmount.currency);
- coin.status = CoinStatus.Dormant;
- await tx.coins.put(coin);
- const denoms = await getDenoms(coin.exchangeBaseUrl);
- const cost = getTotalRefreshCost(denoms, denom, refreshAmount);
- const output = Amounts.sub(refreshAmount, cost).amount;
- estimatedOutputPerCoin.push(output);
- }
-
- const refreshGroup: RefreshGroupRecord = {
- timestampFinished: undefined,
- statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
- lastError: undefined,
- lastErrorPerCoin: {},
- oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
- reason,
- refreshGroupId,
- refreshSessionPerCoin: oldCoinPubs.map(() => undefined),
- retryInfo: initRetryInfo(),
- inputPerCoin,
- estimatedOutputPerCoin,
- timestampCreated: getTimestampNow(),
- };
-
- if (oldCoinPubs.length == 0) {
- logger.warn("created refresh group with zero coins");
- refreshGroup.timestampFinished = getTimestampNow();
- }
-
- await tx.refreshGroups.put(refreshGroup);
-
- logger.trace(`created refresh group ${refreshGroupId}`);
-
- processRefreshGroup(ws, refreshGroupId).catch((e) => {
- logger.warn(`processing refresh group ${refreshGroupId} failed`);
- });
-
- return {
- refreshGroupId,
- };
-}
-
-/**
- * Timestamp after which the wallet would do the next check for an auto-refresh.
- */
-function getAutoRefreshCheckThreshold(d: DenominationRecord): Timestamp {
- const delta = timestampDifference(
- d.stampExpireWithdraw,
- d.stampExpireDeposit,
- );
- const deltaDiv = durationMul(delta, 0.75);
- return timestampAddDuration(d.stampExpireWithdraw, deltaDiv);
-}
-
-/**
- * Timestamp after which the wallet would do an auto-refresh.
- */
-function getAutoRefreshExecuteThreshold(d: DenominationRecord): Timestamp {
- const delta = timestampDifference(
- d.stampExpireWithdraw,
- d.stampExpireDeposit,
- );
- const deltaDiv = durationMul(delta, 0.5);
- return timestampAddDuration(d.stampExpireWithdraw, deltaDiv);
-}
-
-export async function autoRefresh(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- logger.info(`doing auto-refresh check for '${exchangeBaseUrl}'`);
- await updateExchangeFromUrl(ws, exchangeBaseUrl, undefined, true);
- let minCheckThreshold = timestampAddDuration(
- getTimestampNow(),
- durationFromSpec({ days: 1 }),
- );
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- refreshGroups: x.refreshGroups,
- exchanges: x.exchanges,
- }))
- .runReadWrite(async (tx) => {
- const exchange = await tx.exchanges.get(exchangeBaseUrl);
- if (!exchange) {
- return;
- }
- const coins = await tx.coins.indexes.byBaseUrl
- .iter(exchangeBaseUrl)
- .toArray();
- const refreshCoins: CoinPublicKey[] = [];
- for (const coin of coins) {
- if (coin.status !== CoinStatus.Fresh) {
- continue;
- }
- if (coin.suspended) {
- continue;
- }
- const denom = await tx.denominations.get([
- exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- logger.warn("denomination not in database");
- continue;
- }
- const executeThreshold = getAutoRefreshExecuteThreshold(denom);
- if (isTimestampExpired(executeThreshold)) {
- refreshCoins.push(coin);
- } else {
- const checkThreshold = getAutoRefreshCheckThreshold(denom);
- minCheckThreshold = timestampMin(minCheckThreshold, checkThreshold);
- }
- }
- if (refreshCoins.length > 0) {
- const res = await createRefreshGroup(
- ws,
- tx,
- refreshCoins,
- RefreshReason.Scheduled,
- );
- logger.info(
- `created refresh group for auto-refresh (${res.refreshGroupId})`,
- );
- }
- logger.info(
- `current wallet time: ${timestampToIsoString(getTimestampNow())}`,
- );
- logger.info(
- `next refresh check at ${timestampToIsoString(minCheckThreshold)}`,
- );
- exchange.nextRefreshCheck = minCheckThreshold;
- await tx.exchanges.put(exchange);
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts
deleted file mode 100644
index a5846f259..000000000
--- a/packages/taler-wallet-core/src/operations/refund.ts
+++ /dev/null
@@ -1,777 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the refund operation.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import {
- AbortingCoin,
- AbortRequest,
- AmountJson,
- Amounts,
- ApplyRefundResponse,
- codecForAbortResponse,
- codecForMerchantOrderRefundPickupResponse,
- CoinPublicKey,
- getTimestampNow,
- Logger,
- MerchantCoinRefundFailureStatus,
- MerchantCoinRefundStatus,
- MerchantCoinRefundSuccessStatus,
- NotificationType,
- parseRefundUri,
- RefreshReason,
- TalerErrorCode,
- TalerErrorDetails,
- URL,
- timestampAddDuration,
- codecForMerchantOrderStatusPaid,
- isTimestampExpired,
-} from "@gnu-taler/taler-util";
-import {
- AbortStatus,
- CoinStatus,
- PurchaseRecord,
- RefundReason,
- RefundState,
- WalletStoresV1,
-} from "../db.js";
-import { readSuccessResponseJsonOrThrow } from "../util/http.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadWriteAccess } from "../util/query.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import { guardOperationException } from "../errors.js";
-import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
-import { InternalWalletState } from "../common.js";
-
-const logger = new Logger("refund.ts");
-
-/**
- * Retry querying and applying refunds for an order later.
- */
-async function incrementPurchaseQueryRefundRetry(
- ws: InternalWalletState,
- proposalId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadWrite(async (tx) => {
- const pr = await tx.purchases.get(proposalId);
- if (!pr) {
- return;
- }
- if (!pr.refundStatusRetryInfo) {
- return;
- }
- pr.refundStatusRetryInfo.retryCounter++;
- updateRetryInfoTimeout(pr.refundStatusRetryInfo);
- pr.lastRefundStatusError = err;
- await tx.purchases.put(pr);
- });
- if (err) {
- ws.notify({
- type: NotificationType.RefundStatusOperationError,
- error: err,
- });
- }
-}
-
-function getRefundKey(d: MerchantCoinRefundStatus): string {
- return `${d.coin_pub}-${d.rtransaction_id}`;
-}
-
-async function applySuccessfulRefund(
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- p: PurchaseRecord,
- refreshCoinsMap: Record<string, { coinPub: string }>,
- r: MerchantCoinRefundSuccessStatus,
-): Promise<void> {
- // FIXME: check signature before storing it as valid!
-
- const refundKey = getRefundKey(r);
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error("inconsistent database");
- }
- refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
- const refundAmount = Amounts.parseOrThrow(r.refund_amount);
- const refundFee = denom.feeRefund;
- coin.status = CoinStatus.Dormant;
- coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
- coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
- logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
- await tx.coins.put(coin);
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- denom,
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Applied,
- obtainedTime: getTimestampNow(),
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.feeRefund,
- totalRefreshCostBound,
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-}
-
-async function storePendingRefund(
- tx: GetReadWriteAccess<{
- denominations: typeof WalletStoresV1.denominations;
- coins: typeof WalletStoresV1.coins;
- }>,
- p: PurchaseRecord,
- r: MerchantCoinRefundFailureStatus,
-): Promise<void> {
- const refundKey = getRefundKey(r);
-
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
-
- if (!denom) {
- throw Error("inconsistent database");
- }
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- denom,
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Pending,
- obtainedTime: getTimestampNow(),
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.feeRefund,
- totalRefreshCostBound,
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-}
-
-async function storeFailedRefund(
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- p: PurchaseRecord,
- refreshCoinsMap: Record<string, { coinPub: string }>,
- r: MerchantCoinRefundFailureStatus,
-): Promise<void> {
- const refundKey = getRefundKey(r);
-
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
-
- if (!denom) {
- throw Error("inconsistent database");
- }
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- denom,
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Failed,
- obtainedTime: getTimestampNow(),
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.feeRefund,
- totalRefreshCostBound,
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-
- if (p.abortStatus === AbortStatus.AbortRefund) {
- // Refund failed because the merchant didn't even try to deposit
- // the coin yet, so we try to refresh.
- if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) {
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- logger.warn("denomination for coin missing");
- return;
- }
- let contrib: AmountJson | undefined;
- for (let i = 0; i < p.payCoinSelection.coinPubs.length; i++) {
- if (p.payCoinSelection.coinPubs[i] === r.coin_pub) {
- contrib = p.payCoinSelection.coinContributions[i];
- }
- }
- if (contrib) {
- coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount;
- coin.currentAmount = Amounts.sub(
- coin.currentAmount,
- denom.feeRefund,
- ).amount;
- }
- refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
- await tx.coins.put(coin);
- }
- }
-}
-
-async function acceptRefunds(
- ws: InternalWalletState,
- proposalId: string,
- refunds: MerchantCoinRefundStatus[],
- reason: RefundReason,
-): Promise<void> {
- logger.trace("handling refunds", refunds);
- const now = getTimestampNow();
-
- await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- coins: x.coins,
- denominations: x.denominations,
- refreshGroups: x.refreshGroups,
- }))
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.error("purchase not found, not adding refunds");
- return;
- }
-
- const refreshCoinsMap: Record<string, CoinPublicKey> = {};
-
- for (const refundStatus of refunds) {
- const refundKey = getRefundKey(refundStatus);
- const existingRefundInfo = p.refunds[refundKey];
-
- const isPermanentFailure =
- refundStatus.type === "failure" &&
- refundStatus.exchange_status >= 400 &&
- refundStatus.exchange_status < 500;
-
- // Already failed.
- if (existingRefundInfo?.type === RefundState.Failed) {
- continue;
- }
-
- // Already applied.
- if (existingRefundInfo?.type === RefundState.Applied) {
- continue;
- }
-
- // Still pending.
- if (
- refundStatus.type === "failure" &&
- !isPermanentFailure &&
- existingRefundInfo?.type === RefundState.Pending
- ) {
- continue;
- }
-
- // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
-
- if (refundStatus.type === "success") {
- await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
- } else if (isPermanentFailure) {
- await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
- } else {
- await storePendingRefund(tx, p, refundStatus);
- }
- }
-
- const refreshCoinsPubs = Object.values(refreshCoinsMap);
- if (refreshCoinsPubs.length > 0) {
- await createRefreshGroup(
- ws,
- tx,
- refreshCoinsPubs,
- RefreshReason.Refund,
- );
- }
-
- // Are we done with querying yet, or do we need to do another round
- // after a retry delay?
- let queryDone = true;
-
- if (
- p.timestampFirstSuccessfulPay &&
- p.autoRefundDeadline &&
- p.autoRefundDeadline.t_ms > now.t_ms
- ) {
- queryDone = false;
- }
-
- let numPendingRefunds = 0;
- for (const ri of Object.values(p.refunds)) {
- switch (ri.type) {
- case RefundState.Pending:
- numPendingRefunds++;
- break;
- }
- }
-
- if (numPendingRefunds > 0) {
- queryDone = false;
- }
-
- if (queryDone) {
- p.timestampLastRefundStatus = now;
- p.lastRefundStatusError = undefined;
- p.refundStatusRetryInfo = initRetryInfo();
- p.refundQueryRequested = false;
- if (p.abortStatus === AbortStatus.AbortRefund) {
- p.abortStatus = AbortStatus.AbortFinished;
- }
- logger.trace("refund query done");
- } else {
- // No error, but we need to try again!
- p.timestampLastRefundStatus = now;
- p.refundStatusRetryInfo.retryCounter++;
- updateRetryInfoTimeout(p.refundStatusRetryInfo);
- p.lastRefundStatusError = undefined;
- logger.trace("refund query not done");
- }
-
- await tx.purchases.put(p);
- });
-
- ws.notify({
- type: NotificationType.RefundQueried,
- });
-}
-
-/**
- * Summary of the refund status of a purchase.
- */
-export interface RefundSummary {
- pendingAtExchange: boolean;
- amountEffectivePaid: AmountJson;
- amountRefundGranted: AmountJson;
- amountRefundGone: AmountJson;
-}
-
-/**
- * Accept a refund, return the contract hash for the contract
- * that was involved in the refund.
- */
-export async function applyRefund(
- ws: InternalWalletState,
- talerRefundUri: string,
-): Promise<ApplyRefundResponse> {
- const parseResult = parseRefundUri(talerRefundUri);
-
- logger.trace("applying refund", parseResult);
-
- if (!parseResult) {
- throw Error("invalid refund URI");
- }
-
- let purchase = await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadOnly(async (tx) => {
- return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
- parseResult.merchantBaseUrl,
- parseResult.orderId,
- ]);
- });
-
- if (!purchase) {
- throw Error(
- `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
- );
- }
-
- const proposalId = purchase.proposalId;
-
- logger.info("processing purchase for refund");
- const success = await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.error("no purchase found for refund URL");
- return false;
- }
- p.refundQueryRequested = true;
- p.lastRefundStatusError = undefined;
- p.refundStatusRetryInfo = initRetryInfo();
- await tx.purchases.put(p);
- return true;
- });
-
- if (success) {
- ws.notify({
- type: NotificationType.RefundStarted,
- });
- await processPurchaseQueryRefundImpl(ws, proposalId, true, false);
- }
-
- purchase = await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (!purchase) {
- throw Error("purchase no longer exists");
- }
-
- const p = purchase;
-
- let amountRefundGranted = Amounts.getZero(
- purchase.download.contractData.amount.currency,
- );
- let amountRefundGone = Amounts.getZero(
- purchase.download.contractData.amount.currency,
- );
-
- let pendingAtExchange = false;
-
- Object.keys(purchase.refunds).forEach((rk) => {
- const refund = p.refunds[rk];
- if (refund.type === RefundState.Pending) {
- pendingAtExchange = true;
- }
- if (
- refund.type === RefundState.Applied ||
- refund.type === RefundState.Pending
- ) {
- amountRefundGranted = Amounts.add(
- amountRefundGranted,
- Amounts.sub(
- refund.refundAmount,
- refund.refundFee,
- refund.totalRefreshCostBound,
- ).amount,
- ).amount;
- } else {
- amountRefundGone = Amounts.add(amountRefundGone, refund.refundAmount)
- .amount;
- }
- });
-
- return {
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- proposalId: purchase.proposalId,
- amountEffectivePaid: Amounts.stringify(purchase.totalPayCost),
- amountRefundGone: Amounts.stringify(amountRefundGone),
- amountRefundGranted: Amounts.stringify(amountRefundGranted),
- pendingAtExchange,
- info: {
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- merchant: purchase.download.contractData.merchant,
- orderId: purchase.download.contractData.orderId,
- products: purchase.download.contractData.products,
- summary: purchase.download.contractData.summary,
- fulfillmentMessage: purchase.download.contractData.fulfillmentMessage,
- summary_i18n: purchase.download.contractData.summaryI18n,
- fulfillmentMessage_i18n:
- purchase.download.contractData.fulfillmentMessageI18n,
- },
- };
-}
-
-export async function processPurchaseQueryRefund(
- ws: InternalWalletState,
- proposalId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementPurchaseQueryRefundRetry(ws, proposalId, e);
- await guardOperationException(
- () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow, true),
- onOpErr,
- );
-}
-
-async function resetPurchaseQueryRefundRetry(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.purchases.get(proposalId);
- if (x) {
- x.refundStatusRetryInfo = initRetryInfo();
- await tx.purchases.put(x);
- }
- });
-}
-
-async function processPurchaseQueryRefundImpl(
- ws: InternalWalletState,
- proposalId: string,
- forceNow: boolean,
- waitForAutoRefund: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetPurchaseQueryRefundRetry(ws, proposalId);
- }
- const purchase = await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- return;
- }
-
- if (!purchase.refundQueryRequested) {
- return;
- }
-
- if (purchase.timestampFirstSuccessfulPay) {
- if (
- waitForAutoRefund &&
- purchase.autoRefundDeadline &&
- !isTimestampExpired(purchase.autoRefundDeadline)
- ) {
- const requestUrl = new URL(
- `orders/${purchase.download.contractData.orderId}`,
- purchase.download.contractData.merchantBaseUrl,
- );
- requestUrl.searchParams.set(
- "h_contract",
- purchase.download.contractData.contractTermsHash,
- );
- // Long-poll for one second
- requestUrl.searchParams.set("timeout_ms", "1000");
- requestUrl.searchParams.set("await_refund_obtained", "yes");
- logger.trace("making long-polling request for auto-refund");
- const resp = await ws.http.get(requestUrl.href);
- const orderStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantOrderStatusPaid(),
- );
- if (!orderStatus.refunded) {
- incrementPurchaseQueryRefundRetry(ws, proposalId, undefined);
- return;
- }
- }
-
- const requestUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/refund`,
- purchase.download.contractData.merchantBaseUrl,
- );
-
- logger.trace(`making refund request to ${requestUrl.href}`);
-
- const request = await ws.http.postJson(requestUrl.href, {
- h_contract: purchase.download.contractData.contractTermsHash,
- });
-
- logger.trace(
- "got json",
- JSON.stringify(await request.json(), undefined, 2),
- );
-
- const refundResponse = await readSuccessResponseJsonOrThrow(
- request,
- codecForMerchantOrderRefundPickupResponse(),
- );
-
- await acceptRefunds(
- ws,
- proposalId,
- refundResponse.refunds,
- RefundReason.NormalRefund,
- );
- } else if (purchase.abortStatus === AbortStatus.AbortRefund) {
- const requestUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/abort`,
- purchase.download.contractData.merchantBaseUrl,
- );
-
- const abortingCoins: AbortingCoin[] = [];
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- }))
- .runReadOnly(async (tx) => {
- for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) {
- const coinPub = purchase.payCoinSelection.coinPubs[i];
- const coin = await tx.coins.get(coinPub);
- checkDbInvariant(!!coin, "expected coin to be present");
- abortingCoins.push({
- coin_pub: coinPub,
- contribution: Amounts.stringify(
- purchase.payCoinSelection.coinContributions[i],
- ),
- exchange_url: coin.exchangeBaseUrl,
- });
- }
- });
-
- const abortReq: AbortRequest = {
- h_contract: purchase.download.contractData.contractTermsHash,
- coins: abortingCoins,
- };
-
- logger.trace(`making order abort request to ${requestUrl.href}`);
-
- const request = await ws.http.postJson(requestUrl.href, abortReq);
- const abortResp = await readSuccessResponseJsonOrThrow(
- request,
- codecForAbortResponse(),
- );
-
- const refunds: MerchantCoinRefundStatus[] = [];
-
- if (abortResp.refunds.length != abortingCoins.length) {
- // FIXME: define error code!
- throw Error("invalid order abort response");
- }
-
- for (let i = 0; i < abortResp.refunds.length; i++) {
- const r = abortResp.refunds[i];
- refunds.push({
- ...r,
- coin_pub: purchase.payCoinSelection.coinPubs[i],
- refund_amount: Amounts.stringify(
- purchase.payCoinSelection.coinContributions[i],
- ),
- rtransaction_id: 0,
- execution_time: timestampAddDuration(
- purchase.download.contractData.timestamp,
- {
- d_ms: 1000,
- },
- ),
- });
- }
- await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
- }
-}
-
-export async function abortFailedPayWithRefund(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- purchases: x.purchases,
- }))
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- if (purchase.timestampFirstSuccessfulPay) {
- // No point in aborting it. We don't even report an error.
- logger.warn(`tried to abort successful payment`);
- return;
- }
- if (purchase.abortStatus !== AbortStatus.None) {
- return;
- }
- purchase.refundQueryRequested = true;
- purchase.paymentSubmitPending = false;
- purchase.abortStatus = AbortStatus.AbortRefund;
- purchase.lastPayError = undefined;
- purchase.payRetryInfo = initRetryInfo();
- await tx.purchases.put(purchase);
- });
- processPurchaseQueryRefund(ws, proposalId, true).catch((e) => {
- logger.trace(`error during refund processing after abort pay: ${e}`);
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/reserves.ts b/packages/taler-wallet-core/src/operations/reserves.ts
deleted file mode 100644
index 4b5862bef..000000000
--- a/packages/taler-wallet-core/src/operations/reserves.ts
+++ /dev/null
@@ -1,829 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- CreateReserveRequest,
- CreateReserveResponse,
- TalerErrorDetails,
- AcceptWithdrawalResponse,
- Amounts,
- codecForBankWithdrawalOperationPostResponse,
- codecForReserveStatus,
- codecForWithdrawOperationStatusResponse,
- Duration,
- durationMax,
- durationMin,
- getTimestampNow,
- NotificationType,
- ReserveTransactionType,
- TalerErrorCode,
- addPaytoQueryParams,
-} from "@gnu-taler/taler-util";
-import { randomBytes } from "@gnu-taler/taler-util";
-import {
- ReserveRecordStatus,
- ReserveBankInfo,
- ReserveRecord,
- WithdrawalGroupRecord,
- WalletStoresV1,
-} from "../db.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
-import { canonicalizeBaseUrl } from "@gnu-taler/taler-util";
-import {
- initRetryInfo,
- getRetryDuration,
- updateRetryInfoTimeout,
-} from "../util/retries.js";
-import { guardOperationException, OperationFailedError } from "../errors.js";
-import {
- updateExchangeFromUrl,
- getExchangePaytoUri,
- getExchangeDetails,
- getExchangeTrust,
-} from "./exchanges.js";
-import { InternalWalletState } from "../common.js";
-import {
- updateWithdrawalDenoms,
- getCandidateWithdrawalDenoms,
- selectWithdrawalDenominations,
- denomSelectionInfoToState,
- processWithdrawGroup,
- getBankWithdrawalInfo,
-} from "./withdraw.js";
-import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-import { Logger, URL } from "@gnu-taler/taler-util";
-import {
- readSuccessResponseJsonOrErrorCode,
- readSuccessResponseJsonOrThrow,
- throwUnexpectedRequestError,
-} from "../util/http.js";
-import { GetReadOnlyAccess } from "../util/query.js";
-
-const logger = new Logger("reserves.ts");
-
-async function resetReserveRetry(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.reserves.get(reservePub);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.reserves.put(x);
- }
- });
-}
-
-/**
- * Create a reserve, but do not flag it as confirmed yet.
- *
- * Adds the corresponding exchange as a trusted exchange if it is neither
- * audited nor trusted already.
- */
-export async function createReserve(
- ws: InternalWalletState,
- req: CreateReserveRequest,
-): Promise<CreateReserveResponse> {
- const keypair = await ws.cryptoApi.createEddsaKeypair();
- const now = getTimestampNow();
- const canonExchange = canonicalizeBaseUrl(req.exchange);
-
- let reserveStatus;
- if (req.bankWithdrawStatusUrl) {
- reserveStatus = ReserveRecordStatus.REGISTERING_BANK;
- } else {
- reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- }
-
- let bankInfo: ReserveBankInfo | undefined;
-
- if (req.bankWithdrawStatusUrl) {
- if (!req.exchangePaytoUri) {
- throw Error(
- "Exchange payto URI must be specified for a bank-integrated withdrawal",
- );
- }
- bankInfo = {
- statusUrl: req.bankWithdrawStatusUrl,
- exchangePaytoUri: req.exchangePaytoUri,
- };
- }
-
- const initialWithdrawalGroupId = encodeCrock(getRandomBytes(32));
-
- await updateWithdrawalDenoms(ws, canonExchange);
- const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
- const denomSelInfo = selectWithdrawalDenominations(req.amount, denoms);
- const initialDenomSel = denomSelectionInfoToState(denomSelInfo);
-
- const reserveRecord: ReserveRecord = {
- instructedAmount: req.amount,
- initialWithdrawalGroupId,
- initialDenomSel,
- initialWithdrawalStarted: false,
- timestampCreated: now,
- exchangeBaseUrl: canonExchange,
- reservePriv: keypair.priv,
- reservePub: keypair.pub,
- senderWire: req.senderWire,
- timestampBankConfirmed: undefined,
- timestampReserveInfoPosted: undefined,
- bankInfo,
- reserveStatus,
- lastSuccessfulStatusQuery: undefined,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- currency: req.amount.currency,
- requestedQuery: false,
- };
-
- const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange);
- const exchangeDetails = exchangeInfo.exchangeDetails;
- if (!exchangeDetails) {
- logger.trace(exchangeDetails);
- throw Error("exchange not updated");
- }
- const { isAudited, isTrusted } = await getExchangeTrust(
- ws,
- exchangeInfo.exchange,
- );
-
- const resp = await ws.db
- .mktx((x) => ({
- exchangeTrust: x.exchangeTrust,
- reserves: x.reserves,
- bankWithdrawUris: x.bankWithdrawUris,
- }))
- .runReadWrite(async (tx) => {
- // Check if we have already created a reserve for that bankWithdrawStatusUrl
- if (reserveRecord.bankInfo?.statusUrl) {
- const bwi = await tx.bankWithdrawUris.get(
- reserveRecord.bankInfo.statusUrl,
- );
- if (bwi) {
- const otherReserve = await tx.reserves.get(bwi.reservePub);
- if (otherReserve) {
- logger.trace(
- "returning existing reserve for bankWithdrawStatusUri",
- );
- return {
- exchange: otherReserve.exchangeBaseUrl,
- reservePub: otherReserve.reservePub,
- };
- }
- }
- await tx.bankWithdrawUris.put({
- reservePub: reserveRecord.reservePub,
- talerWithdrawUri: reserveRecord.bankInfo.statusUrl,
- });
- }
- if (!isAudited && !isTrusted) {
- await tx.exchangeTrust.put({
- currency: reserveRecord.currency,
- exchangeBaseUrl: reserveRecord.exchangeBaseUrl,
- exchangeMasterPub: exchangeDetails.masterPublicKey,
- uids: [encodeCrock(getRandomBytes(32))],
- });
- }
- await tx.reserves.put(reserveRecord);
- const r: CreateReserveResponse = {
- exchange: canonExchange,
- reservePub: keypair.pub,
- };
- return r;
- });
-
- if (reserveRecord.reservePub === resp.reservePub) {
- // Only emit notification when a new reserve was created.
- ws.notify({
- type: NotificationType.ReserveCreated,
- reservePub: reserveRecord.reservePub,
- });
- }
-
- // Asynchronously process the reserve, but return
- // to the caller already.
- processReserve(ws, resp.reservePub, true).catch((e) => {
- logger.error("Processing reserve (after createReserve) failed:", e);
- });
-
- return resp;
-}
-
-/**
- * Re-query the status of a reserve.
- */
-export async function forceQueryReserve(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const reserve = await tx.reserves.get(reservePub);
- if (!reserve) {
- return;
- }
- // Only force status query where it makes sense
- switch (reserve.reserveStatus) {
- case ReserveRecordStatus.DORMANT:
- reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- break;
- default:
- reserve.requestedQuery = true;
- break;
- }
- reserve.retryInfo = initRetryInfo();
- await tx.reserves.put(reserve);
- });
- await processReserve(ws, reservePub, true);
-}
-
-/**
- * First fetch information required to withdraw from the reserve,
- * then deplete the reserve, withdrawing coins until it is empty.
- *
- * The returned promise resolves once the reserve is set to the
- * state DORMANT.
- */
-export async function processReserve(
- ws: InternalWalletState,
- reservePub: string,
- forceNow = false,
-): Promise<void> {
- return ws.memoProcessReserve.memo(reservePub, async () => {
- const onOpError = (err: TalerErrorDetails): Promise<void> =>
- incrementReserveRetry(ws, reservePub, err);
- await guardOperationException(
- () => processReserveImpl(ws, reservePub, forceNow),
- onOpError,
- );
- });
-}
-
-async function registerReserveWithBank(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- const reserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return await tx.reserves.get(reservePub);
- });
- switch (reserve?.reserveStatus) {
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- case ReserveRecordStatus.REGISTERING_BANK:
- break;
- default:
- return;
- }
- const bankInfo = reserve.bankInfo;
- if (!bankInfo) {
- return;
- }
- const bankStatusUrl = bankInfo.statusUrl;
- const httpResp = await ws.http.postJson(
- bankStatusUrl,
- {
- reserve_pub: reservePub,
- selected_exchange: bankInfo.exchangePaytoUri,
- },
- {
- timeout: getReserveRequestTimeout(reserve),
- },
- );
- await readSuccessResponseJsonOrThrow(
- httpResp,
- codecForBankWithdrawalOperationPostResponse(),
- );
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.reserves.get(reservePub);
- if (!r) {
- return;
- }
- switch (r.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- r.timestampReserveInfoPosted = getTimestampNow();
- r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK;
- if (!r.bankInfo) {
- throw Error("invariant failed");
- }
- r.retryInfo = initRetryInfo();
- await tx.reserves.put(r);
- });
- ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
- return processReserveBankStatus(ws, reservePub);
-}
-
-async function processReserveBankStatus(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- const onOpError = (err: TalerErrorDetails): Promise<void> =>
- incrementReserveRetry(ws, reservePub, err);
- await guardOperationException(
- () => processReserveBankStatusImpl(ws, reservePub),
- onOpError,
- );
-}
-
-export function getReserveRequestTimeout(r: ReserveRecord): Duration {
- return durationMax(
- { d_ms: 60000 },
- durationMin({ d_ms: 5000 }, getRetryDuration(r.retryInfo)),
- );
-}
-
-async function processReserveBankStatusImpl(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<void> {
- const reserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.get(reservePub);
- });
- switch (reserve?.reserveStatus) {
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- case ReserveRecordStatus.REGISTERING_BANK:
- break;
- default:
- return;
- }
- const bankStatusUrl = reserve.bankInfo?.statusUrl;
- if (!bankStatusUrl) {
- return;
- }
-
- const statusResp = await ws.http.get(bankStatusUrl, {
- timeout: getReserveRequestTimeout(reserve),
- });
- const status = await readSuccessResponseJsonOrThrow(
- statusResp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- if (status.aborted) {
- logger.trace("bank aborted the withdrawal");
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.reserves.get(reservePub);
- if (!r) {
- return;
- }
- switch (r.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- const now = getTimestampNow();
- r.timestampBankConfirmed = now;
- r.reserveStatus = ReserveRecordStatus.BANK_ABORTED;
- r.retryInfo = initRetryInfo();
- await tx.reserves.put(r);
- });
- return;
- }
-
- if (status.selection_done) {
- if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) {
- await registerReserveWithBank(ws, reservePub);
- return await processReserveBankStatus(ws, reservePub);
- }
- } else {
- await registerReserveWithBank(ws, reservePub);
- return await processReserveBankStatus(ws, reservePub);
- }
-
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.reserves.get(reservePub);
- if (!r) {
- return;
- }
- if (status.transfer_done) {
- switch (r.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- const now = getTimestampNow();
- r.timestampBankConfirmed = now;
- r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
- r.retryInfo = initRetryInfo();
- } else {
- switch (r.reserveStatus) {
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- break;
- default:
- return;
- }
- if (r.bankInfo) {
- r.bankInfo.confirmUrl = status.confirm_transfer_url;
- }
- }
- await tx.reserves.put(r);
- });
-}
-
-async function incrementReserveRetry(
- ws: InternalWalletState,
- reservePub: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const r = await tx.reserves.get(reservePub);
- if (!r) {
- return;
- }
- if (!r.retryInfo) {
- return;
- }
- r.retryInfo.retryCounter++;
- updateRetryInfoTimeout(r.retryInfo);
- r.lastError = err;
- await tx.reserves.put(r);
- });
- if (err) {
- ws.notify({
- type: NotificationType.ReserveOperationError,
- error: err,
- });
- }
-}
-
-/**
- * Update the information about a reserve that is stored in the wallet
- * by querying the reserve's exchange.
- *
- * If the reserve have funds that are not allocated in a withdrawal group yet
- * and are big enough to withdraw with available denominations,
- * create a new withdrawal group for the remaining amount.
- */
-async function updateReserve(
- ws: InternalWalletState,
- reservePub: string,
-): Promise<{ ready: boolean }> {
- const reserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.get(reservePub);
- });
- if (!reserve) {
- throw Error("reserve not in db");
- }
-
- if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
- return { ready: true };
- }
-
- const resp = await ws.http.get(
- new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl).href,
- {
- timeout: getReserveRequestTimeout(reserve),
- },
- );
-
- const result = await readSuccessResponseJsonOrErrorCode(
- resp,
- codecForReserveStatus(),
- );
- if (result.isError) {
- if (
- resp.status === 404 &&
- result.talerErrorResponse.code ===
- TalerErrorCode.EXCHANGE_RESERVES_GET_STATUS_UNKNOWN
- ) {
- ws.notify({
- type: NotificationType.ReserveNotYetFound,
- reservePub,
- });
- await incrementReserveRetry(ws, reservePub, undefined);
- return { ready: false };
- } else {
- throwUnexpectedRequestError(resp, result.talerErrorResponse);
- }
- }
-
- const reserveInfo = result.response;
- const balance = Amounts.parseOrThrow(reserveInfo.balance);
- const currency = balance.currency;
-
- await updateWithdrawalDenoms(ws, reserve.exchangeBaseUrl);
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- reserve.exchangeBaseUrl,
- );
-
- const newWithdrawalGroup = await ws.db
- .mktx((x) => ({
- coins: x.coins,
- planchets: x.planchets,
- withdrawalGroups: x.withdrawalGroups,
- reserves: x.reserves,
- }))
- .runReadWrite(async (tx) => {
- const newReserve = await tx.reserves.get(reserve.reservePub);
- if (!newReserve) {
- return;
- }
- let amountReservePlus = Amounts.getZero(currency);
- let amountReserveMinus = Amounts.getZero(currency);
-
- // Subtract withdrawal groups for this reserve from the available amount.
- await tx.withdrawalGroups.indexes.byReservePub
- .iter(reservePub)
- .forEach((wg) => {
- const cost = wg.denomsSel.totalWithdrawCost;
- amountReserveMinus = Amounts.add(amountReserveMinus, cost).amount;
- });
-
- for (const entry of reserveInfo.history) {
- switch (entry.type) {
- case ReserveTransactionType.Credit:
- amountReservePlus = Amounts.add(
- amountReservePlus,
- Amounts.parseOrThrow(entry.amount),
- ).amount;
- break;
- case ReserveTransactionType.Recoup:
- amountReservePlus = Amounts.add(
- amountReservePlus,
- Amounts.parseOrThrow(entry.amount),
- ).amount;
- break;
- case ReserveTransactionType.Closing:
- amountReserveMinus = Amounts.add(
- amountReserveMinus,
- Amounts.parseOrThrow(entry.amount),
- ).amount;
- break;
- case ReserveTransactionType.Withdraw: {
- // Now we check if the withdrawal transaction
- // is part of any withdrawal known to this wallet.
- const planchet = await tx.planchets.indexes.byCoinEvHash.get(
- entry.h_coin_envelope,
- );
- if (planchet) {
- // Amount is already accounted in some withdrawal session
- break;
- }
- const coin = await tx.coins.indexes.byCoinEvHash.get(
- entry.h_coin_envelope,
- );
- if (coin) {
- // Amount is already accounted in some withdrawal session
- break;
- }
- // Amount has been claimed by some withdrawal we don't know about
- amountReserveMinus = Amounts.add(
- amountReserveMinus,
- Amounts.parseOrThrow(entry.amount),
- ).amount;
- break;
- }
- }
- }
-
- const remainingAmount = Amounts.sub(amountReservePlus, amountReserveMinus)
- .amount;
- const denomSelInfo = selectWithdrawalDenominations(
- remainingAmount,
- denoms,
- );
-
- logger.trace(
- `Remaining unclaimed amount in reseve is ${Amounts.stringify(
- remainingAmount,
- )} and can be withdrawn with ${
- denomSelInfo.selectedDenoms.length
- } coins`,
- );
-
- if (denomSelInfo.selectedDenoms.length === 0) {
- newReserve.reserveStatus = ReserveRecordStatus.DORMANT;
- newReserve.lastError = undefined;
- newReserve.retryInfo = initRetryInfo();
- await tx.reserves.put(newReserve);
- return;
- }
-
- let withdrawalGroupId: string;
-
- if (!newReserve.initialWithdrawalStarted) {
- withdrawalGroupId = newReserve.initialWithdrawalGroupId;
- newReserve.initialWithdrawalStarted = true;
- } else {
- withdrawalGroupId = encodeCrock(randomBytes(32));
- }
-
- const withdrawalRecord: WithdrawalGroupRecord = {
- withdrawalGroupId: withdrawalGroupId,
- exchangeBaseUrl: reserve.exchangeBaseUrl,
- reservePub: reserve.reservePub,
- rawWithdrawalAmount: remainingAmount,
- timestampStart: getTimestampNow(),
- retryInfo: initRetryInfo(),
- lastError: undefined,
- denomsSel: denomSelectionInfoToState(denomSelInfo),
- secretSeed: encodeCrock(getRandomBytes(64)),
- denomSelUid: encodeCrock(getRandomBytes(32)),
- };
-
- newReserve.lastError = undefined;
- newReserve.retryInfo = initRetryInfo();
- newReserve.reserveStatus = ReserveRecordStatus.DORMANT;
-
- await tx.reserves.put(newReserve);
- await tx.withdrawalGroups.put(withdrawalRecord);
- return withdrawalRecord;
- });
-
- if (newWithdrawalGroup) {
- logger.trace("processing new withdraw group");
- ws.notify({
- type: NotificationType.WithdrawGroupCreated,
- withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId,
- });
- await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId);
- }
-
- return { ready: true };
-}
-
-async function processReserveImpl(
- ws: InternalWalletState,
- reservePub: string,
- forceNow = false,
-): Promise<void> {
- const reserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.get(reservePub);
- });
- if (!reserve) {
- logger.trace("not processing reserve: reserve does not exist");
- return;
- }
- if (!forceNow) {
- const now = getTimestampNow();
- if (reserve.retryInfo.nextRetry.t_ms > now.t_ms) {
- logger.trace("processReserve retry not due yet");
- return;
- }
- } else {
- await resetReserveRetry(ws, reservePub);
- }
- logger.trace(
- `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
- );
- switch (reserve.reserveStatus) {
- case ReserveRecordStatus.REGISTERING_BANK:
- await processReserveBankStatus(ws, reservePub);
- return await processReserveImpl(ws, reservePub, true);
- case ReserveRecordStatus.QUERYING_STATUS:
- const res = await updateReserve(ws, reservePub);
- if (res.ready) {
- return await processReserveImpl(ws, reservePub, true);
- }
- break;
- case ReserveRecordStatus.DORMANT:
- // nothing to do
- break;
- case ReserveRecordStatus.WAIT_CONFIRM_BANK:
- await processReserveBankStatus(ws, reservePub);
- break;
- case ReserveRecordStatus.BANK_ABORTED:
- break;
- default:
- console.warn("unknown reserve record status:", reserve.reserveStatus);
- assertUnreachable(reserve.reserveStatus);
- break;
- }
-}
-export async function createTalerWithdrawReserve(
- ws: InternalWalletState,
- talerWithdrawUri: string,
- selectedExchange: string,
-): Promise<AcceptWithdrawalResponse> {
- await updateExchangeFromUrl(ws, selectedExchange);
- const withdrawInfo = await getBankWithdrawalInfo(ws, talerWithdrawUri);
- const exchangePaytoUri = await getExchangePaytoUri(
- ws,
- selectedExchange,
- withdrawInfo.wireTypes,
- );
- const reserve = await createReserve(ws, {
- amount: withdrawInfo.amount,
- bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl,
- exchange: selectedExchange,
- senderWire: withdrawInfo.senderWire,
- exchangePaytoUri: exchangePaytoUri,
- });
- // We do this here, as the reserve should be registered before we return,
- // so that we can redirect the user to the bank's status page.
- await processReserveBankStatus(ws, reserve.reservePub);
- const processedReserve = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.get(reserve.reservePub);
- });
- if (processedReserve?.reserveStatus === ReserveRecordStatus.BANK_ABORTED) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
- "withdrawal aborted by bank",
- {},
- );
- }
- return {
- reservePub: reserve.reservePub,
- confirmTransferUrl: withdrawInfo.confirmTransferUrl,
- };
-}
-
-/**
- * Get payto URIs needed to fund a reserve.
- */
-export async function getFundingPaytoUris(
- tx: GetReadOnlyAccess<{
- reserves: typeof WalletStoresV1.reserves;
- exchanges: typeof WalletStoresV1.exchanges;
- exchangeDetails: typeof WalletStoresV1.exchangeDetails;
- }>,
- reservePub: string,
-): Promise<string[]> {
- const r = await tx.reserves.get(reservePub);
- if (!r) {
- logger.error(`reserve ${reservePub} not found (DB corrupted?)`);
- return [];
- }
- const exchangeDetails = await getExchangeDetails(tx, r.exchangeBaseUrl);
- if (!exchangeDetails) {
- logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`);
- return [];
- }
- const plainPaytoUris =
- exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
- if (!plainPaytoUris) {
- logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`);
- return [];
- }
- return plainPaytoUris.map((x) =>
- addPaytoQueryParams(x, {
- amount: Amounts.stringify(r.instructedAmount),
- message: `Taler Withdrawal ${r.reservePub}`,
- }),
- );
-}
diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts
deleted file mode 100644
index d2071cd53..000000000
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ /dev/null
@@ -1,435 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { Logger } from "@gnu-taler/taler-util";
-import {
- HttpRequestLibrary,
- readSuccessResponseJsonOrThrow,
- checkSuccessResponseOrThrow,
-} from "../util/http.js";
-import {
- AmountString,
- codecForAny,
- CheckPaymentResponse,
- codecForCheckPaymentResponse,
- IntegrationTestArgs,
- Amounts,
- TestPayArgs,
- URL,
- PreparePayResultType,
-} from "@gnu-taler/taler-util";
-import { createTalerWithdrawReserve } from "./reserves.js";
-import { InternalWalletState } from "../common.js";
-import { confirmPay, preparePayForUri } from "./pay.js";
-import { getBalances } from "./balance.js";
-import { applyRefund } from "./refund.js";
-
-const logger = new Logger("operations/testing.ts");
-
-interface BankUser {
- username: string;
- password: string;
-}
-
-interface BankWithdrawalResponse {
- taler_withdraw_uri: string;
- withdrawal_id: string;
-}
-
-interface MerchantBackendInfo {
- baseUrl: string;
- authToken?: string;
-}
-
-/**
- * Generate a random alphanumeric ID. Does *not* use cryptographically
- * secure randomness.
- */
-function makeId(length: number): string {
- let result = "";
- const characters =
- "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
- for (let i = 0; i < length; i++) {
- result += characters.charAt(Math.floor(Math.random() * characters.length));
- }
- return result;
-}
-
-/**
- * Helper function to generate the "Authorization" HTTP header.
- */
-function makeAuth(username: string, password: string): string {
- const auth = `${username}:${password}`;
- const authEncoded: string = Buffer.from(auth).toString("base64");
- return `Basic ${authEncoded}`;
-}
-
-export async function withdrawTestBalance(
- ws: InternalWalletState,
- amount = "TESTKUDOS:10",
- bankBaseUrl = "https://bank.test.taler.net/",
- exchangeBaseUrl = "https://exchange.test.taler.net/",
-): Promise<void> {
- const bankUser = await registerRandomBankUser(ws.http, bankBaseUrl);
- logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`);
-
- const wresp = await createBankWithdrawalUri(
- ws.http,
- bankBaseUrl,
- bankUser,
- amount,
- );
-
- await createTalerWithdrawReserve(
- ws,
- wresp.taler_withdraw_uri,
- exchangeBaseUrl,
- );
-
- await confirmBankWithdrawalUri(
- ws.http,
- bankBaseUrl,
- bankUser,
- wresp.withdrawal_id,
- );
-}
-
-function getMerchantAuthHeader(m: MerchantBackendInfo): Record<string, string> {
- if (m.authToken) {
- return {
- Authorization: `Bearer ${m.authToken}`,
- };
- }
- return {};
-}
-
-async function createBankWithdrawalUri(
- http: HttpRequestLibrary,
- bankBaseUrl: string,
- bankUser: BankUser,
- amount: AmountString,
-): Promise<BankWithdrawalResponse> {
- const reqUrl = new URL(
- `accounts/${bankUser.username}/withdrawals`,
- bankBaseUrl,
- ).href;
- const resp = await http.postJson(
- reqUrl,
- {
- amount,
- },
- {
- headers: {
- Authorization: makeAuth(bankUser.username, bankUser.password),
- },
- },
- );
- const respJson = await readSuccessResponseJsonOrThrow(resp, codecForAny());
- return respJson;
-}
-
-async function confirmBankWithdrawalUri(
- http: HttpRequestLibrary,
- bankBaseUrl: string,
- bankUser: BankUser,
- withdrawalId: string,
-): Promise<void> {
- const reqUrl = new URL(
- `accounts/${bankUser.username}/withdrawals/${withdrawalId}/confirm`,
- bankBaseUrl,
- ).href;
- const resp = await http.postJson(
- reqUrl,
- {},
- {
- headers: {
- Authorization: makeAuth(bankUser.username, bankUser.password),
- },
- },
- );
- await readSuccessResponseJsonOrThrow(resp, codecForAny());
- return;
-}
-
-async function registerRandomBankUser(
- http: HttpRequestLibrary,
- bankBaseUrl: string,
-): Promise<BankUser> {
- const reqUrl = new URL("testing/register", bankBaseUrl).href;
- const randId = makeId(8);
- const bankUser: BankUser = {
- username: `testuser-${randId}`,
- password: `testpw-${randId}`,
- };
-
- const resp = await http.postJson(reqUrl, bankUser);
- await checkSuccessResponseOrThrow(resp);
- return bankUser;
-}
-
-async function refund(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- orderId: string,
- reason: string,
- refundAmount: string,
-): Promise<string> {
- const reqUrl = new URL(
- `private/orders/${orderId}/refund`,
- merchantBackend.baseUrl,
- );
- const refundReq = {
- order_id: orderId,
- reason,
- refund: refundAmount,
- };
- const resp = await http.postJson(reqUrl.href, refundReq, {
- headers: getMerchantAuthHeader(merchantBackend),
- });
- const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
- const refundUri = r.taler_refund_uri;
- if (!refundUri) {
- throw Error("no refund URI in response");
- }
- return refundUri;
-}
-
-async function createOrder(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- amount: string,
- summary: string,
- fulfillmentUrl: string,
-): Promise<{ orderId: string }> {
- const t = Math.floor(new Date().getTime() / 1000) + 15 * 60;
- const reqUrl = new URL("private/orders", merchantBackend.baseUrl).href;
- const orderReq = {
- order: {
- amount,
- summary,
- fulfillment_url: fulfillmentUrl,
- refund_deadline: { t_ms: t * 1000 },
- wire_transfer_deadline: { t_ms: t * 1000 },
- },
- };
- const resp = await http.postJson(reqUrl, orderReq, {
- headers: getMerchantAuthHeader(merchantBackend),
- });
- const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
- const orderId = r.order_id;
- if (!orderId) {
- throw Error("no order id in response");
- }
- return { orderId };
-}
-
-async function checkPayment(
- http: HttpRequestLibrary,
- merchantBackend: MerchantBackendInfo,
- orderId: string,
-): Promise<CheckPaymentResponse> {
- const reqUrl = new URL(`private/orders/${orderId}`, merchantBackend.baseUrl);
- reqUrl.searchParams.set("order_id", orderId);
- const resp = await http.get(reqUrl.href, {
- headers: getMerchantAuthHeader(merchantBackend),
- });
- return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse());
-}
-
-interface BankUser {
- username: string;
- password: string;
-}
-
-interface BankWithdrawalResponse {
- taler_withdraw_uri: string;
- withdrawal_id: string;
-}
-
-async function makePayment(
- ws: InternalWalletState,
- merchant: MerchantBackendInfo,
- amount: string,
- summary: string,
-): Promise<{ orderId: string }> {
- const orderResp = await createOrder(
- ws.http,
- merchant,
- amount,
- summary,
- "taler://fulfillment-success/thx",
- );
-
- logger.trace("created order with orderId", orderResp.orderId);
-
- let paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
-
- logger.trace("payment status", paymentStatus);
-
- const talerPayUri = paymentStatus.taler_pay_uri;
- if (!talerPayUri) {
- throw Error("no taler://pay/ URI in payment response");
- }
-
- const preparePayResult = await preparePayForUri(ws, talerPayUri);
-
- logger.trace("prepare pay result", preparePayResult);
-
- if (preparePayResult.status != "payment-possible") {
- throw Error("payment not possible");
- }
-
- const confirmPayResult = await confirmPay(
- ws,
- preparePayResult.proposalId,
- undefined,
- );
-
- logger.trace("confirmPayResult", confirmPayResult);
-
- paymentStatus = await checkPayment(ws.http, merchant, orderResp.orderId);
-
- logger.trace("payment status after wallet payment:", paymentStatus);
-
- if (paymentStatus.order_status !== "paid") {
- throw Error("payment did not succeed");
- }
-
- return {
- orderId: orderResp.orderId,
- };
-}
-
-export async function runIntegrationTest(
- ws: InternalWalletState,
- args: IntegrationTestArgs,
-): Promise<void> {
- logger.info("running test with arguments", args);
-
- const parsedSpendAmount = Amounts.parseOrThrow(args.amountToSpend);
- const currency = parsedSpendAmount.currency;
-
- logger.info("withdrawing test balance");
- await withdrawTestBalance(
- ws,
- args.amountToWithdraw,
- args.bankBaseUrl,
- args.exchangeBaseUrl,
- );
- await ws.runUntilDone();
- logger.info("done withdrawing test balance");
-
- const balance = await getBalances(ws);
-
- logger.trace(JSON.stringify(balance, null, 2));
-
- const myMerchant: MerchantBackendInfo = {
- baseUrl: args.merchantBaseUrl,
- authToken: args.merchantAuthToken,
- };
-
- await makePayment(ws, myMerchant, args.amountToSpend, "hello world");
-
- // Wait until the refresh is done
- await ws.runUntilDone();
-
- logger.trace("withdrawing test balance for refund");
- const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
- const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
- const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
- const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
-
- await withdrawTestBalance(
- ws,
- Amounts.stringify(withdrawAmountTwo),
- args.bankBaseUrl,
- args.exchangeBaseUrl,
- );
-
- // Wait until the withdraw is done
- await ws.runUntilDone();
-
- const { orderId: refundOrderId } = await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountTwo),
- "order that will be refunded",
- );
-
- const refundUri = await refund(
- ws.http,
- myMerchant,
- refundOrderId,
- "test refund",
- Amounts.stringify(refundAmount),
- );
-
- logger.trace("refund URI", refundUri);
-
- await applyRefund(ws, refundUri);
-
- logger.trace("integration test: applied refund");
-
- // Wait until the refund is done
- await ws.runUntilDone();
-
- logger.trace("integration test: making payment after refund");
-
- await makePayment(
- ws,
- myMerchant,
- Amounts.stringify(spendAmountThree),
- "payment after refund",
- );
-
- logger.trace("integration test: make payment done");
-
- await ws.runUntilDone();
-
- logger.trace("integration test: all done!");
-}
-
-export async function testPay(ws: InternalWalletState, args: TestPayArgs) {
- logger.trace("creating order");
- const merchant = {
- authToken: args.merchantAuthToken,
- baseUrl: args.merchantBaseUrl,
- };
- const orderResp = await createOrder(
- ws.http,
- merchant,
- args.amount,
- args.summary,
- "taler://fulfillment-success/thank+you",
- );
- logger.trace("created new order with order ID", orderResp.orderId);
- const checkPayResp = await checkPayment(ws.http, merchant, orderResp.orderId);
- const talerPayUri = checkPayResp.taler_pay_uri;
- if (!talerPayUri) {
- console.error("fatal: no taler pay URI received from backend");
- process.exit(1);
- return;
- }
- logger.trace("taler pay URI:", talerPayUri);
- const result = await preparePayForUri(ws, talerPayUri);
- if (result.status !== PreparePayResultType.PaymentPossible) {
- throw Error(`unexpected prepare pay status: ${result.status}`);
- }
- await confirmPay(ws, result.proposalId, undefined);
-}
diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts
deleted file mode 100644
index a90e5270f..000000000
--- a/packages/taler-wallet-core/src/operations/tip.ts
+++ /dev/null
@@ -1,420 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- PrepareTipResult,
- parseTipUri,
- codecForTipPickupGetResponse,
- Amounts,
- getTimestampNow,
- TalerErrorDetails,
- NotificationType,
- TipPlanchetDetail,
- TalerErrorCode,
- codecForTipResponse,
- Logger,
- URL,
-} from "@gnu-taler/taler-util";
-import { DerivedTipPlanchet } from "../crypto/cryptoTypes.js";
-import {
- DenominationRecord,
- CoinRecord,
- CoinSourceType,
- CoinStatus,
-} from "../db.js";
-import { j2s } from "@gnu-taler/taler-util";
-import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import { guardOperationException, makeErrorDetails } from "../errors.js";
-import { updateExchangeFromUrl } from "./exchanges.js";
-import { InternalWalletState } from "../common.js";
-import {
- getExchangeWithdrawalInfo,
- updateWithdrawalDenoms,
- getCandidateWithdrawalDenoms,
- selectWithdrawalDenominations,
- denomSelectionInfoToState,
-} from "./withdraw.js";
-import {
- getHttpResponseErrorDetails,
- readSuccessResponseJsonOrThrow,
-} from "../util/http.js";
-import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
-
-const logger = new Logger("operations/tip.ts");
-
-export async function prepareTip(
- ws: InternalWalletState,
- talerTipUri: string,
-): Promise<PrepareTipResult> {
- const res = parseTipUri(talerTipUri);
- if (!res) {
- throw Error("invalid taler://tip URI");
- }
-
- let tipRecord = await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadOnly(async (tx) => {
- return tx.tips.indexes.byMerchantTipIdAndBaseUrl.get([
- res.merchantTipId,
- res.merchantBaseUrl,
- ]);
- });
-
- if (!tipRecord) {
- const tipStatusUrl = new URL(
- `tips/${res.merchantTipId}`,
- res.merchantBaseUrl,
- );
- logger.trace("checking tip status from", tipStatusUrl.href);
- const merchantResp = await ws.http.get(tipStatusUrl.href);
- const tipPickupStatus = await readSuccessResponseJsonOrThrow(
- merchantResp,
- codecForTipPickupGetResponse(),
- );
- logger.trace(`status ${j2s(tipPickupStatus)}`);
-
- const amount = Amounts.parseOrThrow(tipPickupStatus.tip_amount);
-
- logger.trace("new tip, creating tip record");
- await updateExchangeFromUrl(ws, tipPickupStatus.exchange_url);
- const withdrawDetails = await getExchangeWithdrawalInfo(
- ws,
- tipPickupStatus.exchange_url,
- amount,
- );
-
- const walletTipId = encodeCrock(getRandomBytes(32));
- await updateWithdrawalDenoms(ws, tipPickupStatus.exchange_url);
- const denoms = await getCandidateWithdrawalDenoms(
- ws,
- tipPickupStatus.exchange_url,
- );
- const selectedDenoms = selectWithdrawalDenominations(amount, denoms);
-
- const secretSeed = encodeCrock(getRandomBytes(64));
- const denomSelUid = encodeCrock(getRandomBytes(32));
-
- const newTipRecord = {
- walletTipId: walletTipId,
- acceptedTimestamp: undefined,
- tipAmountRaw: amount,
- tipExpiration: tipPickupStatus.expiration,
- exchangeBaseUrl: tipPickupStatus.exchange_url,
- merchantBaseUrl: res.merchantBaseUrl,
- createdTimestamp: getTimestampNow(),
- merchantTipId: res.merchantTipId,
- tipAmountEffective: Amounts.sub(
- amount,
- Amounts.add(withdrawDetails.overhead, withdrawDetails.withdrawFee)
- .amount,
- ).amount,
- retryInfo: initRetryInfo(),
- lastError: undefined,
- denomsSel: denomSelectionInfoToState(selectedDenoms),
- pickedUpTimestamp: undefined,
- secretSeed,
- denomSelUid,
- };
- await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadWrite(async (tx) => {
- await tx.tips.put(newTipRecord);
- });
- tipRecord = newTipRecord;
- }
-
- const tipStatus: PrepareTipResult = {
- accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
- tipAmountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
- exchangeBaseUrl: tipRecord.exchangeBaseUrl,
- merchantBaseUrl: tipRecord.merchantBaseUrl,
- expirationTimestamp: tipRecord.tipExpiration,
- tipAmountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
- walletTipId: tipRecord.walletTipId,
- };
-
- return tipStatus;
-}
-
-async function incrementTipRetry(
- ws: InternalWalletState,
- walletTipId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadWrite(async (tx) => {
- const t = await tx.tips.get(walletTipId);
- if (!t) {
- return;
- }
- if (!t.retryInfo) {
- return;
- }
- t.retryInfo.retryCounter++;
- updateRetryInfoTimeout(t.retryInfo);
- t.lastError = err;
- await tx.tips.put(t);
- });
- if (err) {
- ws.notify({ type: NotificationType.TipOperationError, error: err });
- }
-}
-
-export async function processTip(
- ws: InternalWalletState,
- tipId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementTipRetry(ws, tipId, e);
- await guardOperationException(
- () => processTipImpl(ws, tipId, forceNow),
- onOpErr,
- );
-}
-
-async function resetTipRetry(
- ws: InternalWalletState,
- tipId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadWrite(async (tx) => {
- const x = await tx.tips.get(tipId);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.tips.put(x);
- }
- });
-}
-
-async function processTipImpl(
- ws: InternalWalletState,
- walletTipId: string,
- forceNow: boolean,
-): Promise<void> {
- if (forceNow) {
- await resetTipRetry(ws, walletTipId);
- }
- const tipRecord = await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadOnly(async (tx) => {
- return tx.tips.get(walletTipId);
- });
- if (!tipRecord) {
- return;
- }
-
- if (tipRecord.pickedUpTimestamp) {
- logger.warn("tip already picked up");
- return;
- }
-
- const denomsForWithdraw = tipRecord.denomsSel;
-
- const planchets: DerivedTipPlanchet[] = [];
- // Planchets in the form that the merchant expects
- const planchetsDetail: TipPlanchetDetail[] = [];
- const denomForPlanchet: { [index: number]: DenominationRecord } = [];
-
- for (const dh of denomsForWithdraw.selectedDenoms) {
- const denom = await ws.db
- .mktx((x) => ({
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- return tx.denominations.get([
- tipRecord.exchangeBaseUrl,
- dh.denomPubHash,
- ]);
- });
- checkDbInvariant(!!denom, "denomination should be in database");
- for (let i = 0; i < dh.count; i++) {
- const deriveReq = {
- denomPub: denom.denomPub,
- planchetIndex: planchets.length,
- secretSeed: tipRecord.secretSeed,
- };
- logger.trace(`deriving tip planchet: ${j2s(deriveReq)}`);
- const p = await ws.cryptoApi.createTipPlanchet(deriveReq);
- logger.trace(`derive result: ${j2s(p)}`);
- denomForPlanchet[planchets.length] = denom;
- planchets.push(p);
- planchetsDetail.push({
- coin_ev: p.coinEv,
- denom_pub_hash: denom.denomPubHash,
- });
- }
- }
-
- const tipStatusUrl = new URL(
- `tips/${tipRecord.merchantTipId}/pickup`,
- tipRecord.merchantBaseUrl,
- );
-
- const req = { planchets: planchetsDetail };
- logger.trace(`sending tip request: ${j2s(req)}`);
- const merchantResp = await ws.http.postJson(tipStatusUrl.href, req);
-
- logger.trace(`got tip response, status ${merchantResp.status}`);
-
- // Hide transient errors.
- if (
- tipRecord.retryInfo.retryCounter < 5 &&
- ((merchantResp.status >= 500 && merchantResp.status <= 599) ||
- merchantResp.status === 424)
- ) {
- logger.trace(`got transient tip error`);
- const err = makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "tip pickup failed (transient)",
- getHttpResponseErrorDetails(merchantResp),
- );
- await incrementTipRetry(ws, tipRecord.walletTipId, err);
- // FIXME: Maybe we want to signal to the caller that the transient error happened?
- return;
- }
-
- const response = await readSuccessResponseJsonOrThrow(
- merchantResp,
- codecForTipResponse(),
- );
-
- if (response.blind_sigs.length !== planchets.length) {
- throw Error("number of tip responses does not match requested planchets");
- }
-
- const newCoinRecords: CoinRecord[] = [];
-
- for (let i = 0; i < response.blind_sigs.length; i++) {
- const blindedSig = response.blind_sigs[i].blind_sig;
-
- const denom = denomForPlanchet[i];
- checkLogicInvariant(!!denom);
- const planchet = planchets[i];
- checkLogicInvariant(!!planchet);
-
- const denomSig = await ws.cryptoApi.rsaUnblind(
- blindedSig,
- planchet.blindingKey,
- denom.denomPub,
- );
-
- const isValid = await ws.cryptoApi.rsaVerify(
- planchet.coinPub,
- denomSig,
- denom.denomPub,
- );
-
- if (!isValid) {
- await ws.db
- .mktx((x) => ({ tips: x.tips }))
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.tips.get(walletTipId);
- if (!tipRecord) {
- return;
- }
- tipRecord.lastError = makeErrorDetails(
- TalerErrorCode.WALLET_TIPPING_COIN_SIGNATURE_INVALID,
- "invalid signature from the exchange (via merchant tip) after unblinding",
- {},
- );
- await tx.tips.put(tipRecord);
- });
- return;
- }
-
- newCoinRecords.push({
- blindingKey: planchet.blindingKey,
- coinPriv: planchet.coinPriv,
- coinPub: planchet.coinPub,
- coinSource: {
- type: CoinSourceType.Tip,
- coinIndex: i,
- walletTipId: walletTipId,
- },
- currentAmount: denom.value,
- denomPub: denom.denomPub,
- denomPubHash: denom.denomPubHash,
- denomSig: denomSig,
- exchangeBaseUrl: tipRecord.exchangeBaseUrl,
- status: CoinStatus.Fresh,
- suspended: false,
- coinEvHash: planchet.coinEvHash,
- });
- }
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- tips: x.tips,
- withdrawalGroups: x.withdrawalGroups,
- }))
- .runReadWrite(async (tx) => {
- const tr = await tx.tips.get(walletTipId);
- if (!tr) {
- return;
- }
- if (tr.pickedUpTimestamp) {
- return;
- }
- tr.pickedUpTimestamp = getTimestampNow();
- tr.lastError = undefined;
- tr.retryInfo = initRetryInfo();
- await tx.tips.put(tr);
- for (const cr of newCoinRecords) {
- await tx.coins.put(cr);
- }
- });
-}
-
-export async function acceptTip(
- ws: InternalWalletState,
- tipId: string,
-): Promise<void> {
- const found = await ws.db
- .mktx((x) => ({
- tips: x.tips,
- }))
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.tips.get(tipId);
- if (!tipRecord) {
- logger.error("tip not found");
- return false;
- }
- tipRecord.acceptedTimestamp = getTimestampNow();
- await tx.tips.put(tipRecord);
- return true;
- });
- if (found) {
- await processTip(ws, tipId);
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts
deleted file mode 100644
index dc738b77f..000000000
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ /dev/null
@@ -1,597 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import { InternalWalletState } from "../common.js";
-import {
- WalletRefundItem,
- RefundState,
- ReserveRecordStatus,
- AbortStatus,
- ReserveRecord,
-} from "../db.js";
-import { AmountJson, Amounts, timestampCmp } from "@gnu-taler/taler-util";
-import {
- TransactionsRequest,
- TransactionsResponse,
- Transaction,
- TransactionType,
- PaymentStatus,
- WithdrawalType,
- WithdrawalDetails,
- OrderShortInfo,
-} from "@gnu-taler/taler-util";
-import { getFundingPaytoUris } from "./reserves.js";
-import { getExchangeDetails } from "./exchanges.js";
-import { processWithdrawGroup } from "./withdraw.js";
-import { processPurchasePay } from "./pay.js";
-import { processDepositGroup } from "./deposits.js";
-import { processTip } from "./tip.js";
-import { processRefreshGroup } from "./refresh.js";
-
-/**
- * Create an event ID from the type and the primary key for the event.
- */
-export function makeEventId(
- type: TransactionType | TombstoneTag,
- ...args: string[]
-): string {
- return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
-}
-
-function shouldSkipCurrency(
- transactionsRequest: TransactionsRequest | undefined,
- currency: string,
-): boolean {
- if (!transactionsRequest?.currency) {
- return false;
- }
- return transactionsRequest.currency.toLowerCase() !== currency.toLowerCase();
-}
-
-function shouldSkipSearch(
- transactionsRequest: TransactionsRequest | undefined,
- fields: string[],
-): boolean {
- if (!transactionsRequest?.search) {
- return false;
- }
- const needle = transactionsRequest.search.trim();
- for (const f of fields) {
- if (f.indexOf(needle) >= 0) {
- return false;
- }
- }
- return true;
-}
-
-/**
- * Retrieve the full event history for this wallet.
- */
-export async function getTransactions(
- ws: InternalWalletState,
- transactionsRequest?: TransactionsRequest,
-): Promise<TransactionsResponse> {
- const transactions: Transaction[] = [];
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- proposals: x.proposals,
- purchases: x.purchases,
- refreshGroups: x.refreshGroups,
- reserves: x.reserves,
- tips: x.tips,
- withdrawalGroups: x.withdrawalGroups,
- planchets: x.planchets,
- recoupGroups: x.recoupGroups,
- depositGroups: x.depositGroups,
- tombstones: x.tombstones,
- }))
- .runReadOnly(
- // Report withdrawals that are currently in progress.
- async (tx) => {
- tx.withdrawalGroups.iter().forEachAsync(async (wsr) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- wsr.rawWithdrawalAmount.currency,
- )
- ) {
- return;
- }
-
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
-
- const r = await tx.reserves.get(wsr.reservePub);
- if (!r) {
- return;
- }
- let amountRaw: AmountJson | undefined = undefined;
- if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
- amountRaw = r.instructedAmount;
- } else {
- amountRaw = wsr.denomsSel.totalWithdrawCost;
- }
- let withdrawalDetails: WithdrawalDetails;
- if (r.bankInfo) {
- withdrawalDetails = {
- type: WithdrawalType.TalerBankIntegrationApi,
- confirmed: true,
- bankConfirmationUrl: r.bankInfo.confirmUrl,
- };
- } else {
- const exchangeDetails = await getExchangeDetails(
- tx,
- wsr.exchangeBaseUrl,
- );
- if (!exchangeDetails) {
- // FIXME: report somehow
- return;
- }
- withdrawalDetails = {
- type: WithdrawalType.ManualTransfer,
- exchangePaytoUris:
- exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ??
- [],
- };
- }
- transactions.push({
- type: TransactionType.Withdrawal,
- amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
- amountRaw: Amounts.stringify(amountRaw),
- withdrawalDetails,
- exchangeBaseUrl: wsr.exchangeBaseUrl,
- pending: !wsr.timestampFinish,
- timestamp: wsr.timestampStart,
- transactionId: makeEventId(
- TransactionType.Withdrawal,
- wsr.withdrawalGroupId,
- ),
- frozen: false,
- ...(wsr.lastError ? { error: wsr.lastError } : {}),
- });
- });
-
- // Report pending withdrawals based on reserves that
- // were created, but where the actual withdrawal group has
- // not started yet.
- tx.reserves.iter().forEachAsync(async (r) => {
- if (shouldSkipCurrency(transactionsRequest, r.currency)) {
- return;
- }
- if (shouldSkipSearch(transactionsRequest, [])) {
- return;
- }
- if (r.initialWithdrawalStarted) {
- return;
- }
- if (r.reserveStatus === ReserveRecordStatus.BANK_ABORTED) {
- return;
- }
- let withdrawalDetails: WithdrawalDetails;
- if (r.bankInfo) {
- withdrawalDetails = {
- type: WithdrawalType.TalerBankIntegrationApi,
- confirmed: false,
- bankConfirmationUrl: r.bankInfo.confirmUrl,
- };
- } else {
- withdrawalDetails = {
- type: WithdrawalType.ManualTransfer,
- exchangePaytoUris: await getFundingPaytoUris(tx, r.reservePub),
- };
- }
- transactions.push({
- type: TransactionType.Withdrawal,
- amountRaw: Amounts.stringify(r.instructedAmount),
- amountEffective: Amounts.stringify(
- r.initialDenomSel.totalCoinValue,
- ),
- exchangeBaseUrl: r.exchangeBaseUrl,
- pending: true,
- timestamp: r.timestampCreated,
- withdrawalDetails: withdrawalDetails,
- transactionId: makeEventId(
- TransactionType.Withdrawal,
- r.initialWithdrawalGroupId,
- ),
- frozen: false,
- ...(r.lastError ? { error: r.lastError } : {}),
- });
- });
-
- tx.depositGroups.iter().forEachAsync(async (dg) => {
- const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount);
- if (shouldSkipCurrency(transactionsRequest, amount.currency)) {
- return;
- }
-
- transactions.push({
- type: TransactionType.Deposit,
- amountRaw: Amounts.stringify(dg.effectiveDepositAmount),
- amountEffective: Amounts.stringify(dg.totalPayCost),
- pending: !dg.timestampFinished,
- frozen: false,
- timestamp: dg.timestampCreated,
- targetPaytoUri: dg.wire.payto_uri,
- transactionId: makeEventId(
- TransactionType.Deposit,
- dg.depositGroupId,
- ),
- depositGroupId: dg.depositGroupId,
- ...(dg.lastError ? { error: dg.lastError } : {}),
- });
- });
-
- tx.purchases.iter().forEachAsync(async (pr) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- pr.download.contractData.amount.currency,
- )
- ) {
- return;
- }
- const contractData = pr.download.contractData;
- if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
- return;
- }
- const proposal = await tx.proposals.get(pr.proposalId);
- if (!proposal) {
- return;
- }
- const info: OrderShortInfo = {
- merchant: contractData.merchant,
- orderId: contractData.orderId,
- products: contractData.products,
- summary: contractData.summary,
- summary_i18n: contractData.summaryI18n,
- contractTermsHash: contractData.contractTermsHash,
- };
- if (contractData.fulfillmentUrl !== "") {
- info.fulfillmentUrl = contractData.fulfillmentUrl;
- }
- const paymentTransactionId = makeEventId(
- TransactionType.Payment,
- pr.proposalId,
- );
- const err = pr.lastPayError ?? pr.lastRefundStatusError;
- transactions.push({
- type: TransactionType.Payment,
- amountRaw: Amounts.stringify(contractData.amount),
- amountEffective: Amounts.stringify(pr.totalPayCost),
- status: pr.timestampFirstSuccessfulPay
- ? PaymentStatus.Paid
- : PaymentStatus.Accepted,
- pending:
- !pr.timestampFirstSuccessfulPay &&
- pr.abortStatus === AbortStatus.None,
- timestamp: pr.timestampAccept,
- transactionId: paymentTransactionId,
- proposalId: pr.proposalId,
- info: info,
- frozen: pr.payFrozen ?? false,
- ...(err ? { error: err } : {}),
- });
-
- const refundGroupKeys = new Set<string>();
-
- for (const rk of Object.keys(pr.refunds)) {
- const refund = pr.refunds[rk];
- const groupKey = `${refund.executionTime.t_ms}`;
- refundGroupKeys.add(groupKey);
- }
-
- for (const groupKey of refundGroupKeys.values()) {
- const refundTombstoneId = makeEventId(
- TombstoneTag.DeleteRefund,
- pr.proposalId,
- groupKey,
- );
- const tombstone = await tx.tombstones.get(refundTombstoneId);
- if (tombstone) {
- continue;
- }
- const refundTransactionId = makeEventId(
- TransactionType.Refund,
- pr.proposalId,
- groupKey,
- );
- let r0: WalletRefundItem | undefined;
- let amountRaw = Amounts.getZero(contractData.amount.currency);
- let amountEffective = Amounts.getZero(contractData.amount.currency);
- for (const rk of Object.keys(pr.refunds)) {
- const refund = pr.refunds[rk];
- const myGroupKey = `${refund.executionTime.t_ms}`;
- if (myGroupKey !== groupKey) {
- continue;
- }
- if (!r0) {
- r0 = refund;
- }
-
- if (refund.type === RefundState.Applied) {
- amountRaw = Amounts.add(amountRaw, refund.refundAmount).amount;
- amountEffective = Amounts.add(
- amountEffective,
- Amounts.sub(
- refund.refundAmount,
- refund.refundFee,
- refund.totalRefreshCostBound,
- ).amount,
- ).amount;
- }
- }
- if (!r0) {
- throw Error("invariant violated");
- }
- transactions.push({
- type: TransactionType.Refund,
- info,
- refundedTransactionId: paymentTransactionId,
- transactionId: refundTransactionId,
- timestamp: r0.obtainedTime,
- amountEffective: Amounts.stringify(amountEffective),
- amountRaw: Amounts.stringify(amountRaw),
- pending: false,
- frozen: false,
- });
- }
- });
-
- tx.tips.iter().forEachAsync(async (tipRecord) => {
- if (
- shouldSkipCurrency(
- transactionsRequest,
- tipRecord.tipAmountRaw.currency,
- )
- ) {
- return;
- }
- if (!tipRecord.acceptedTimestamp) {
- return;
- }
- transactions.push({
- type: TransactionType.Tip,
- amountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
- amountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
- pending: !tipRecord.pickedUpTimestamp,
- frozen: false,
- timestamp: tipRecord.acceptedTimestamp,
- transactionId: makeEventId(
- TransactionType.Tip,
- tipRecord.walletTipId,
- ),
- merchantBaseUrl: tipRecord.merchantBaseUrl,
- error: tipRecord.lastError,
- });
- });
- },
- );
-
- const txPending = transactions.filter((x) => x.pending);
- const txNotPending = transactions.filter((x) => !x.pending);
-
- txPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
- txNotPending.sort((h1, h2) => timestampCmp(h1.timestamp, h2.timestamp));
-
- return { transactions: [...txNotPending, ...txPending] };
-}
-
-export enum TombstoneTag {
- DeleteWithdrawalGroup = "delete-withdrawal-group",
- DeleteReserve = "delete-reserve",
- DeletePayment = "delete-payment",
- DeleteTip = "delete-tip",
- DeleteRefreshGroup = "delete-refresh-group",
- DeleteDepositGroup = "delete-deposit-group",
- DeleteRefund = "delete-refund",
-}
-
-export async function retryTransactionNow(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const [type, ...rest] = transactionId.split(":");
-}
-
-/**
- * Immediately retry the underlying operation
- * of a transaction.
- */
-export async function retryTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const [type, ...rest] = transactionId.split(":");
-
- switch (type) {
- case TransactionType.Deposit:
- const depositGroupId = rest[0];
- processDepositGroup(ws, depositGroupId, true);
- break;
- case TransactionType.Withdrawal:
- const withdrawalGroupId = rest[0];
- await processWithdrawGroup(ws, withdrawalGroupId, true);
- break;
- case TransactionType.Payment:
- const proposalId = rest[0];
- await processPurchasePay(ws, proposalId, true);
- break;
- case TransactionType.Tip:
- const walletTipId = rest[0];
- await processTip(ws, walletTipId, true);
- break;
- case TransactionType.Refresh:
- const refreshGroupId = rest[0];
- await processRefreshGroup(ws, refreshGroupId, true);
- break;
- default:
- break;
- }
-}
-
-/**
- * Permanently delete a transaction based on the transaction ID.
- */
-export async function deleteTransaction(
- ws: InternalWalletState,
- transactionId: string,
-): Promise<void> {
- const [type, ...rest] = transactionId.split(":");
-
- if (type === TransactionType.Withdrawal) {
- const withdrawalGroupId = rest[0];
- await ws.db
- .mktx((x) => ({
- withdrawalGroups: x.withdrawalGroups,
- reserves: x.reserves,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- const withdrawalGroupRecord = await tx.withdrawalGroups.get(
- withdrawalGroupId,
- );
- if (withdrawalGroupRecord) {
- await tx.withdrawalGroups.delete(withdrawalGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
- });
- return;
- }
- const reserveRecord:
- | ReserveRecord
- | undefined = await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
- withdrawalGroupId,
- );
- if (reserveRecord && !reserveRecord.initialWithdrawalStarted) {
- const reservePub = reserveRecord.reservePub;
- await tx.reserves.delete(reservePub);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteReserve + ":" + reservePub,
- });
- }
- });
- } else if (type === TransactionType.Payment) {
- const proposalId = rest[0];
- await ws.db
- .mktx((x) => ({
- proposals: x.proposals,
- purchases: x.purchases,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- let found = false;
- const proposal = await tx.proposals.get(proposalId);
- if (proposal) {
- found = true;
- await tx.proposals.delete(proposalId);
- }
- const purchase = await tx.purchases.get(proposalId);
- if (purchase) {
- found = true;
- await tx.purchases.delete(proposalId);
- }
- if (found) {
- await tx.tombstones.put({
- id: TombstoneTag.DeletePayment + ":" + proposalId,
- });
- }
- });
- } else if (type === TransactionType.Refresh) {
- const refreshGroupId = rest[0];
- await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- const rg = await tx.refreshGroups.get(refreshGroupId);
- if (rg) {
- await tx.refreshGroups.delete(refreshGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteRefreshGroup + ":" + refreshGroupId,
- });
- }
- });
- } else if (type === TransactionType.Tip) {
- const tipId = rest[0];
- await ws.db
- .mktx((x) => ({
- tips: x.tips,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.tips.get(tipId);
- if (tipRecord) {
- await tx.tips.delete(tipId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteTip + ":" + tipId,
- });
- }
- });
- } else if (type === TransactionType.Deposit) {
- const depositGroupId = rest[0];
- await ws.db
- .mktx((x) => ({
- depositGroups: x.depositGroups,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- const tipRecord = await tx.depositGroups.get(depositGroupId);
- if (tipRecord) {
- await tx.depositGroups.delete(depositGroupId);
- await tx.tombstones.put({
- id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
- });
- }
- });
- } else if (type === TransactionType.Refund) {
- const proposalId = rest[0];
- const executionTimeStr = rest[1];
-
- await ws.db
- .mktx((x) => ({
- proposals: x.proposals,
- purchases: x.purchases,
- tombstones: x.tombstones,
- }))
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (purchase) {
- // This should just influence the history view,
- // but won't delete any actual refund information.
- await tx.tombstones.put({
- id: makeEventId(
- TombstoneTag.DeleteRefund,
- proposalId,
- executionTimeStr,
- ),
- });
- }
- });
- } else {
- throw Error(`can't delete a '${type}' transaction`);
- }
-}
diff --git a/packages/taler-wallet-core/src/operations/withdraw.test.ts b/packages/taler-wallet-core/src/operations/withdraw.test.ts
deleted file mode 100644
index b4f0d35e6..000000000
--- a/packages/taler-wallet-core/src/operations/withdraw.test.ts
+++ /dev/null
@@ -1,345 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { Amounts } from "@gnu-taler/taler-util";
-import test from "ava";
-import { DenominationRecord, DenominationVerificationStatus } from "../db.js";
-import { selectWithdrawalDenominations } from "./withdraw.js";
-
-test("withdrawal selection bug repro", (t) => {
- const amount = {
- currency: "KUDOS",
- fraction: 43000000,
- value: 23,
- };
-
- const denoms: DenominationRecord[] = [
- {
- denomPub:
- "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002",
- denomPubHash:
- "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "4F0P456CNNTTWK8BFJHGM3JTD6FVVNZY8EP077GYAHDJ5Y81S5RQ3SMS925NXMDVG9A88JAAP0E2GDZBC21PP5NHFFVWHAW3AVT8J3R",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 1000,
- },
- listIssueDate: { t_ms: 0 },
- },
- {
- denomPub:
- "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002",
- denomPubHash:
- "447WA23SCBATMABHA0793F92MYTBYVPYMMQHCPKMKVY5P7RZRFMQ6VRW0Y8HRA7177GTBT0TBT08R21DZD129AJ995H9G09XBFE55G8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "P99AW82W46MZ0AKW7Z58VQPXFNTJQM9DVTYPBDF6KVYF38PPVDAZTV7JQ8TY7HGEC7JJJAY4E7AY7J3W1WV10DAZZQHHKTAVTSRAC20",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 10,
- },
- listIssueDate: { t_ms: 0 },
- },
- {
- denomPub:
- "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002",
- denomPubHash:
- "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "8S4VZGHE5WE0N5ZVCHYW9KZZR4YAKK15S46MV1HR1QB9AAMH3NWPW4DCR4NYGJK33Q8YNFY80SWNS6XKAP5DEVK933TM894FJ2VGE3G",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 5,
- },
- listIssueDate: { t_ms: 0 },
- },
- {
- denomPub:
- "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002",
- denomPubHash:
- "8T51NEY81VMPQ180EQ5WR0YH7GMNNT90W55Q0514KZM18AZT71FHJGJHQXGK0WTA7ACN1X2SD0S53XPBQ1A9KH960R48VCVVM6E3TH8",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "E3AWGAG8VB42P3KXM8B04Z6M483SX59R3Y4T53C3NXCA2NPB6C7HVCMVX05DC6S58E9X40NGEBQNYXKYMYCF3ASY2C4WP1WCZ4ME610",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 1,
- },
- listIssueDate: { t_ms: 0 },
- },
- {
- denomPub:
- "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002",
- denomPubHash:
- "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "0ES1RKV002XB4YP21SN0QB7RSDHGYT0XAE65JYN8AVJAA6H7JZFN7JADXT521DJS89XMGPZGR8GCXF1516Y0Q9QDV00E6NMFA6CF838",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 10000000,
- value: 0,
- },
- listIssueDate: { t_ms: 0 },
- },
- {
- denomPub:
- "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002",
- denomPubHash:
- "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG",
- exchangeBaseUrl: "https://exchange.demo.taler.net/",
- exchangeMasterPub: "",
- feeDeposit: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefresh: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeRefund: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- feeWithdraw: {
- currency: "KUDOS",
- fraction: 1000000,
- value: 0,
- },
- isOffered: true,
- isRevoked: false,
- masterSig:
- "58QEB6C6N7602E572E3JYANVVJ9BRW0V9E2ZFDW940N47YVQDK9SAFPWBN5YGT3G1742AFKQ0CYR4DM2VWV0Z0T1XMEKWN6X2EZ9M0R",
- stampExpireDeposit: {
- t_ms: 1742909388000,
- },
- stampExpireLegal: {
- t_ms: 1900589388000,
- },
- stampExpireWithdraw: {
- t_ms: 1679837388000,
- },
- stampStart: {
- t_ms: 1585229388000,
- },
- verificationStatus: DenominationVerificationStatus.Unverified,
- value: {
- currency: "KUDOS",
- fraction: 0,
- value: 2,
- },
- listIssueDate: { t_ms: 0 },
- },
- ];
-
- const res = selectWithdrawalDenominations(amount, denoms);
-
- console.error("cost", Amounts.stringify(res.totalWithdrawCost));
- console.error("withdraw amount", Amounts.stringify(amount));
-
- t.assert(Amounts.cmp(res.totalWithdrawCost, amount) <= 0);
- t.pass();
-});
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts
deleted file mode 100644
index 620ad88be..000000000
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ /dev/null
@@ -1,1072 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2021 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- AmountJson,
- Amounts,
- BankWithdrawDetails,
- codecForTalerConfigResponse,
- codecForWithdrawOperationStatusResponse,
- codecForWithdrawResponse,
- compare,
- durationFromSpec,
- ExchangeListItem,
- getDurationRemaining,
- getTimestampNow,
- Logger,
- NotificationType,
- parseWithdrawUri,
- TalerErrorCode,
- TalerErrorDetails,
- Timestamp,
- timestampCmp,
- timestampSubtractDuraction,
- WithdrawResponse,
- URL,
- WithdrawUriInfoResponse,
- VersionMatchResult,
-} from "@gnu-taler/taler-util";
-import {
- CoinRecord,
- CoinSourceType,
- CoinStatus,
- DenominationRecord,
- DenominationVerificationStatus,
- DenomSelectionState,
- ExchangeDetailsRecord,
- ExchangeRecord,
- PlanchetRecord,
-} from "../db.js";
-import { walletCoreDebugFlags } from "../util/debugFlags.js";
-import { readSuccessResponseJsonOrThrow } from "../util/http.js";
-import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
-import {
- guardOperationException,
- makeErrorDetails,
- OperationFailedError,
-} from "../errors.js";
-import { InternalWalletState } from "../common.js";
-import {
- WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
-} from "../versions.js";
-
-/**
- * Logger for this file.
- */
-const logger = new Logger("withdraw.ts");
-
-/**
- * FIXME: Eliminate this in favor of DenomSelectionState.
- */
-interface DenominationSelectionInfo {
- totalCoinValue: AmountJson;
- totalWithdrawCost: AmountJson;
- selectedDenoms: {
- /**
- * How many times do we withdraw this denomination?
- */
- count: number;
- denom: DenominationRecord;
- }[];
-}
-
-/**
- * Information about what will happen when creating a reserve.
- *
- * Sent to the wallet frontend to be rendered and shown to the user.
- */
-export interface ExchangeWithdrawDetails {
- /**
- * Exchange that the reserve will be created at.
- */
- exchangeInfo: ExchangeRecord;
-
- exchangeDetails: ExchangeDetailsRecord;
-
- /**
- * Filtered wire info to send to the bank.
- */
- exchangeWireAccounts: string[];
-
- /**
- * Selected denominations for withdraw.
- */
- selectedDenoms: DenominationSelectionInfo;
-
- /**
- * Fees for withdraw.
- */
- withdrawFee: AmountJson;
-
- /**
- * Remaining balance that is too small to be withdrawn.
- */
- overhead: AmountJson;
-
- /**
- * Does the wallet know about an auditor for
- * the exchange that the reserve.
- */
- isAudited: boolean;
-
- /**
- * Did the user already accept the current terms of service for the exchange?
- */
- termsOfServiceAccepted: boolean;
-
- /**
- * The exchange is trusted directly.
- */
- isTrusted: boolean;
-
- /**
- * The earliest deposit expiration of the selected coins.
- */
- earliestDepositExpiration: Timestamp;
-
- /**
- * Number of currently offered denominations.
- */
- numOfferedDenoms: number;
-
- /**
- * Public keys of trusted auditors for the currency we're withdrawing.
- */
- trustedAuditorPubs: string[];
-
- /**
- * Result of checking the wallet's version
- * against the exchange's version.
- *
- * Older exchanges don't return version information.
- */
- versionMatch: VersionMatchResult | undefined;
-
- /**
- * Libtool-style version string for the exchange or "unknown"
- * for older exchanges.
- */
- exchangeVersion: string;
-
- /**
- * Libtool-style version string for the wallet.
- */
- walletVersion: string;
-}
-
-/**
- * Check if a denom is withdrawable based on the expiration time,
- * revocation and offered state.
- */
-export function isWithdrawableDenom(d: DenominationRecord): boolean {
- const now = getTimestampNow();
- const started = timestampCmp(now, d.stampStart) >= 0;
- let lastPossibleWithdraw: Timestamp;
- if (walletCoreDebugFlags.denomselAllowLate) {
- lastPossibleWithdraw = d.stampExpireWithdraw;
- } else {
- lastPossibleWithdraw = timestampSubtractDuraction(
- d.stampExpireWithdraw,
- durationFromSpec({ minutes: 5 }),
- );
- }
- const remaining = getDurationRemaining(lastPossibleWithdraw, now);
- const stillOkay = remaining.d_ms !== 0;
- return started && stillOkay && !d.isRevoked && d.isOffered;
-}
-
-/**
- * Get a list of denominations (with repetitions possible)
- * whose total value is as close as possible to the available
- * amount, but never larger.
- */
-export function selectWithdrawalDenominations(
- amountAvailable: AmountJson,
- denoms: DenominationRecord[],
-): DenominationSelectionInfo {
- let remaining = Amounts.copy(amountAvailable);
-
- const selectedDenoms: {
- count: number;
- denom: DenominationRecord;
- }[] = [];
-
- let totalCoinValue = Amounts.getZero(amountAvailable.currency);
- let totalWithdrawCost = Amounts.getZero(amountAvailable.currency);
-
- denoms = denoms.filter(isWithdrawableDenom);
- denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
-
- for (const d of denoms) {
- let count = 0;
- const cost = Amounts.add(d.value, d.feeWithdraw).amount;
- for (;;) {
- if (Amounts.cmp(remaining, cost) < 0) {
- break;
- }
- remaining = Amounts.sub(remaining, cost).amount;
- count++;
- }
- if (count > 0) {
- totalCoinValue = Amounts.add(
- totalCoinValue,
- Amounts.mult(d.value, count).amount,
- ).amount;
- totalWithdrawCost = Amounts.add(
- totalWithdrawCost,
- Amounts.mult(cost, count).amount,
- ).amount;
- selectedDenoms.push({
- count,
- denom: d,
- });
- }
-
- if (Amounts.isZero(remaining)) {
- break;
- }
- }
-
- if (logger.shouldLogTrace()) {
- logger.trace(
- `selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`,
- );
- for (const sd of selectedDenoms) {
- logger.trace(
- `denom_pub_hash=${sd.denom.denomPubHash}, count=${sd.count}`,
- );
- }
- logger.trace("(end of withdrawal denom list)");
- }
-
- return {
- selectedDenoms,
- totalCoinValue,
- totalWithdrawCost,
- };
-}
-
-/**
- * Get information about a withdrawal from
- * a taler://withdraw URI by asking the bank.
- */
-export async function getBankWithdrawalInfo(
- ws: InternalWalletState,
- talerWithdrawUri: string,
-): Promise<BankWithdrawDetails> {
- const uriResult = parseWithdrawUri(talerWithdrawUri);
- if (!uriResult) {
- throw Error(`can't parse URL ${talerWithdrawUri}`);
- }
-
- const configReqUrl = new URL("config", uriResult.bankIntegrationApiBaseUrl);
-
- const configResp = await ws.http.get(configReqUrl.href);
- const config = await readSuccessResponseJsonOrThrow(
- configResp,
- codecForTalerConfigResponse(),
- );
-
- const versionRes = compare(
- WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- config.version,
- );
- if (versionRes?.compatible != true) {
- const opErr = makeErrorDetails(
- TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
- "bank integration protocol version not compatible with wallet",
- {
- exchangeProtocolVersion: config.version,
- walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- },
- );
- throw new OperationFailedError(opErr);
- }
-
- const reqUrl = new URL(
- `withdrawal-operation/${uriResult.withdrawalOperationId}`,
- uriResult.bankIntegrationApiBaseUrl,
- );
- const resp = await ws.http.get(reqUrl.href);
- const status = await readSuccessResponseJsonOrThrow(
- resp,
- codecForWithdrawOperationStatusResponse(),
- );
-
- return {
- amount: Amounts.parseOrThrow(status.amount),
- confirmTransferUrl: status.confirm_transfer_url,
- extractedStatusUrl: reqUrl.href,
- selectionDone: status.selection_done,
- senderWire: status.sender_wire,
- suggestedExchange: status.suggested_exchange,
- transferDone: status.transfer_done,
- wireTypes: status.wire_types,
- };
-}
-
-/**
- * Return denominations that can potentially used for a withdrawal.
- */
-export async function getCandidateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<DenominationRecord[]> {
- return await ws.db
- .mktx((x) => ({ denominations: x.denominations }))
- .runReadOnly(async (tx) => {
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
- exchangeBaseUrl,
- );
- return allDenoms.filter(isWithdrawableDenom);
- });
-}
-
-/**
- * Generate a planchet for a coin index in a withdrawal group.
- * Does not actually withdraw the coin yet.
- *
- * Split up so that we can parallelize the crypto, but serialize
- * the exchange requests per reserve.
- */
-async function processPlanchetGenerate(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- coinIdx: number,
-): Promise<void> {
- const withdrawalGroup = await ws.db
- .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
- .runReadOnly(async (tx) => {
- return await tx.withdrawalGroups.get(withdrawalGroupId);
- });
- if (!withdrawalGroup) {
- return;
- }
- let planchet = await ws.db
- .mktx((x) => ({
- planchets: x.planchets,
- }))
- .runReadOnly(async (tx) => {
- return tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- });
- if (!planchet) {
- let ci = 0;
- let denomPubHash: string | undefined;
- for (
- let di = 0;
- di < withdrawalGroup.denomsSel.selectedDenoms.length;
- di++
- ) {
- const d = withdrawalGroup.denomsSel.selectedDenoms[di];
- if (coinIdx >= ci && coinIdx < ci + d.count) {
- denomPubHash = d.denomPubHash;
- break;
- }
- ci += d.count;
- }
- if (!denomPubHash) {
- throw Error("invariant violated");
- }
-
- const { denom, reserve } = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- const denom = await tx.denominations.get([
- withdrawalGroup.exchangeBaseUrl,
- denomPubHash!,
- ]);
- if (!denom) {
- throw Error("invariant violated");
- }
- const reserve = await tx.reserves.get(withdrawalGroup.reservePub);
- if (!reserve) {
- throw Error("invariant violated");
- }
- return { denom, reserve };
- });
- const r = await ws.cryptoApi.createPlanchet({
- denomPub: denom.denomPub,
- feeWithdraw: denom.feeWithdraw,
- reservePriv: reserve.reservePriv,
- reservePub: reserve.reservePub,
- value: denom.value,
- coinIndex: coinIdx,
- secretSeed: withdrawalGroup.secretSeed,
- });
- const newPlanchet: PlanchetRecord = {
- blindingKey: r.blindingKey,
- coinEv: r.coinEv,
- coinEvHash: r.coinEvHash,
- coinIdx,
- coinPriv: r.coinPriv,
- coinPub: r.coinPub,
- coinValue: r.coinValue,
- denomPub: r.denomPub,
- denomPubHash: r.denomPubHash,
- isFromTip: false,
- reservePub: r.reservePub,
- withdrawalDone: false,
- withdrawSig: r.withdrawSig,
- withdrawalGroupId: withdrawalGroupId,
- lastError: undefined,
- };
- await ws.db
- .mktx((x) => ({ planchets: x.planchets }))
- .runReadWrite(async (tx) => {
- const p = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- if (p) {
- planchet = p;
- return;
- }
- await tx.planchets.put(newPlanchet);
- planchet = newPlanchet;
- });
- }
-}
-
-/**
- * Send the withdrawal request for a generated planchet to the exchange.
- *
- * The verification of the response is done asynchronously to enable parallelism.
- */
-async function processPlanchetExchangeRequest(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- coinIdx: number,
-): Promise<WithdrawResponse | undefined> {
- const d = await ws.db
- .mktx((x) => ({
- withdrawalGroups: x.withdrawalGroups,
- planchets: x.planchets,
- exchanges: x.exchanges,
- denominations: x.denominations,
- }))
- .runReadOnly(async (tx) => {
- const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!withdrawalGroup) {
- return;
- }
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- if (planchet.withdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- return;
- }
- const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
- if (!exchange) {
- logger.error("db inconsistent: exchange for planchet not found");
- return;
- }
-
- const denom = await tx.denominations.get([
- withdrawalGroup.exchangeBaseUrl,
- planchet.denomPubHash,
- ]);
-
- if (!denom) {
- console.error("db inconsistent: denom for planchet not found");
- return;
- }
-
- logger.trace(
- `processing planchet #${coinIdx} in withdrawal ${withdrawalGroupId}`,
- );
-
- const reqBody: any = {
- denom_pub_hash: planchet.denomPubHash,
- reserve_pub: planchet.reservePub,
- reserve_sig: planchet.withdrawSig,
- coin_ev: planchet.coinEv,
- };
- const reqUrl = new URL(
- `reserves/${planchet.reservePub}/withdraw`,
- exchange.baseUrl,
- ).href;
-
- return { reqUrl, reqBody };
- });
-
- if (!d) {
- return;
- }
- const { reqUrl, reqBody } = d;
-
- try {
- const resp = await ws.http.postJson(reqUrl, reqBody);
- const r = await readSuccessResponseJsonOrThrow(
- resp,
- codecForWithdrawResponse(),
- );
- return r;
- } catch (e) {
- logger.trace("withdrawal request failed", e);
- logger.trace(e);
- if (!(e instanceof OperationFailedError)) {
- throw e;
- }
- const errDetails = e.operationError;
- await ws.db
- .mktx((x) => ({ planchets: x.planchets }))
- .runReadWrite(async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = errDetails;
- await tx.planchets.put(planchet);
- });
- return;
- }
-}
-
-async function processPlanchetVerifyAndStoreCoin(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- coinIdx: number,
- resp: WithdrawResponse,
-): Promise<void> {
- const d = await ws.db
- .mktx((x) => ({
- withdrawalGroups: x.withdrawalGroups,
- planchets: x.planchets,
- }))
- .runReadOnly(async (tx) => {
- const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!withdrawalGroup) {
- return;
- }
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- if (planchet.withdrawalDone) {
- logger.warn("processPlanchet: planchet already withdrawn");
- return;
- }
- return { planchet, exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl };
- });
-
- if (!d) {
- return;
- }
-
- const { planchet, exchangeBaseUrl } = d;
-
- const denomSig = await ws.cryptoApi.rsaUnblind(
- resp.ev_sig,
- planchet.blindingKey,
- planchet.denomPub,
- );
-
- const isValid = await ws.cryptoApi.rsaVerify(
- planchet.coinPub,
- denomSig,
- planchet.denomPub,
- );
-
- if (!isValid) {
- await ws.db
- .mktx((x) => ({ planchets: x.planchets }))
- .runReadWrite(async (tx) => {
- let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
- withdrawalGroupId,
- coinIdx,
- ]);
- if (!planchet) {
- return;
- }
- planchet.lastError = makeErrorDetails(
- TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
- "invalid signature from the exchange after unblinding",
- {},
- );
- await tx.planchets.put(planchet);
- });
- return;
- }
-
- const coin: CoinRecord = {
- blindingKey: planchet.blindingKey,
- coinPriv: planchet.coinPriv,
- coinPub: planchet.coinPub,
- currentAmount: planchet.coinValue,
- denomPub: planchet.denomPub,
- denomPubHash: planchet.denomPubHash,
- denomSig,
- coinEvHash: planchet.coinEvHash,
- exchangeBaseUrl: exchangeBaseUrl,
- status: CoinStatus.Fresh,
- coinSource: {
- type: CoinSourceType.Withdraw,
- coinIndex: coinIdx,
- reservePub: planchet.reservePub,
- withdrawalGroupId: withdrawalGroupId,
- },
- suspended: false,
- };
-
- const planchetCoinPub = planchet.coinPub;
-
- const firstSuccess = await ws.db
- .mktx((x) => ({
- coins: x.coins,
- withdrawalGroups: x.withdrawalGroups,
- reserves: x.reserves,
- planchets: x.planchets,
- }))
- .runReadWrite(async (tx) => {
- const ws = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!ws) {
- return false;
- }
- const p = await tx.planchets.get(planchetCoinPub);
- if (!p || p.withdrawalDone) {
- return false;
- }
- p.withdrawalDone = true;
- await tx.planchets.put(p);
- await tx.coins.add(coin);
- return true;
- });
-
- if (firstSuccess) {
- ws.notify({
- type: NotificationType.CoinWithdrawn,
- });
- }
-}
-
-export function denomSelectionInfoToState(
- dsi: DenominationSelectionInfo,
-): DenomSelectionState {
- return {
- selectedDenoms: dsi.selectedDenoms.map((x) => {
- return {
- count: x.count,
- denomPubHash: x.denom.denomPubHash,
- };
- }),
- totalCoinValue: dsi.totalCoinValue,
- totalWithdrawCost: dsi.totalWithdrawCost,
- };
-}
-
-/**
- * Make sure that denominations that currently can be used for withdrawal
- * are validated, and the result of validation is stored in the database.
- */
-export async function updateWithdrawalDenoms(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
-): Promise<void> {
- logger.trace(
- `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
- );
- const exchangeDetails = await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- return ws.exchangeOps.getExchangeDetails(tx, exchangeBaseUrl);
- });
- if (!exchangeDetails) {
- logger.error("exchange details not available");
- throw Error(`exchange ${exchangeBaseUrl} details not available`);
- }
- // First do a pass where the validity of candidate denominations
- // is checked and the result is stored in the database.
- logger.trace("getting candidate denominations");
- const denominations = await getCandidateWithdrawalDenoms(ws, exchangeBaseUrl);
- logger.trace(`got ${denominations.length} candidate denominations`);
- const batchSize = 500;
- let current = 0;
-
- while (current < denominations.length) {
- const updatedDenominations: DenominationRecord[] = [];
- // Do a batch of batchSize
- for (
- let batchIdx = 0;
- batchIdx < batchSize && current < denominations.length;
- batchIdx++, current++
- ) {
- const denom = denominations[current];
- if (denom.verificationStatus === DenominationVerificationStatus.Unverified) {
- logger.trace(
- `Validating denomination (${current + 1}/${
- denominations.length
- }) signature of ${denom.denomPubHash}`,
- );
- const valid = await ws.cryptoApi.isValidDenom(
- denom,
- exchangeDetails.masterPublicKey,
- );
- logger.trace(`Done validating ${denom.denomPubHash}`);
- if (!valid) {
- logger.warn(
- `Signature check for denomination h=${denom.denomPubHash} failed`,
- );
- denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
- } else {
- denom.verificationStatus = DenominationVerificationStatus.VerifiedGood;
- }
- updatedDenominations.push(denom);
- }
- }
- if (updatedDenominations.length > 0) {
- logger.trace("writing denomination batch to db");
- await ws.db
- .mktx((x) => ({ denominations: x.denominations }))
- .runReadWrite(async (tx) => {
- for (let i = 0; i < updatedDenominations.length; i++) {
- const denom = updatedDenominations[i];
- await tx.denominations.put(denom);
- }
- });
- logger.trace("done with DB write");
- }
- }
-}
-
-async function incrementWithdrawalRetry(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- err: TalerErrorDetails | undefined,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
- .runReadWrite(async (tx) => {
- const wsr = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wsr) {
- return;
- }
- wsr.retryInfo.retryCounter++;
- updateRetryInfoTimeout(wsr.retryInfo);
- wsr.lastError = err;
- await tx.withdrawalGroups.put(wsr);
- });
- if (err) {
- ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
- }
-}
-
-export async function processWithdrawGroup(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- forceNow = false,
-): Promise<void> {
- const onOpErr = (e: TalerErrorDetails): Promise<void> =>
- incrementWithdrawalRetry(ws, withdrawalGroupId, e);
- await guardOperationException(
- () => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow),
- onOpErr,
- );
-}
-
-async function resetWithdrawalGroupRetry(
- ws: InternalWalletState,
- withdrawalGroupId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
- .runReadWrite(async (tx) => {
- const x = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (x) {
- x.retryInfo = initRetryInfo();
- await tx.withdrawalGroups.put(x);
- }
- });
-}
-
-async function processWithdrawGroupImpl(
- ws: InternalWalletState,
- withdrawalGroupId: string,
- forceNow: boolean,
-): Promise<void> {
- logger.trace("processing withdraw group", withdrawalGroupId);
- if (forceNow) {
- await resetWithdrawalGroupRetry(ws, withdrawalGroupId);
- }
- const withdrawalGroup = await ws.db
- .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
- .runReadOnly(async (tx) => {
- return tx.withdrawalGroups.get(withdrawalGroupId);
- });
- if (!withdrawalGroup) {
- logger.trace("withdraw session doesn't exist");
- return;
- }
-
- await ws.exchangeOps.updateExchangeFromUrl(
- ws,
- withdrawalGroup.exchangeBaseUrl,
- );
-
- const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
- .map((x) => x.count)
- .reduce((a, b) => a + b);
-
- let work: Promise<void>[] = [];
-
- for (let i = 0; i < numTotalCoins; i++) {
- work.push(processPlanchetGenerate(ws, withdrawalGroupId, i));
- }
-
- // Generate coins concurrently (parallelism only happens in the crypto API workers)
- await Promise.all(work);
-
- work = [];
-
- for (let coinIdx = 0; coinIdx < numTotalCoins; coinIdx++) {
- const resp = await processPlanchetExchangeRequest(
- ws,
- withdrawalGroupId,
- coinIdx,
- );
- if (!resp) {
- continue;
- }
- work.push(
- processPlanchetVerifyAndStoreCoin(ws, withdrawalGroupId, coinIdx, resp),
- );
- }
-
- await Promise.all(work);
-
- let numFinished = 0;
- let finishedForFirstTime = false;
- let errorsPerCoin: Record<number, TalerErrorDetails> = {};
-
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- withdrawalGroups: x.withdrawalGroups,
- reserves: x.reserves,
- planchets: x.planchets,
- }))
- .runReadWrite(async (tx) => {
- const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
- if (!wg) {
- return;
- }
-
- await tx.planchets.indexes.byGroup
- .iter(withdrawalGroupId)
- .forEach((x) => {
- if (x.withdrawalDone) {
- numFinished++;
- }
- if (x.lastError) {
- errorsPerCoin[x.coinIdx] = x.lastError;
- }
- });
- logger.trace(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
- if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
- finishedForFirstTime = true;
- wg.timestampFinish = getTimestampNow();
- wg.lastError = undefined;
- wg.retryInfo = initRetryInfo();
- }
-
- await tx.withdrawalGroups.put(wg);
- });
-
- if (numFinished != numTotalCoins) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
- `withdrawal did not finish (${numFinished} / ${numTotalCoins} coins withdrawn)`,
- {
- errorsPerCoin,
- },
- );
- }
-
- if (finishedForFirstTime) {
- ws.notify({
- type: NotificationType.WithdrawGroupFinished,
- reservePub: withdrawalGroup.reservePub,
- });
- }
-}
-
-export async function getExchangeWithdrawalInfo(
- ws: InternalWalletState,
- baseUrl: string,
- amount: AmountJson,
-): Promise<ExchangeWithdrawDetails> {
- const {
- exchange,
- exchangeDetails,
- } = await ws.exchangeOps.updateExchangeFromUrl(ws, baseUrl);
- await updateWithdrawalDenoms(ws, baseUrl);
- const denoms = await getCandidateWithdrawalDenoms(ws, baseUrl);
- const selectedDenoms = selectWithdrawalDenominations(amount, denoms);
- const exchangeWireAccounts: string[] = [];
- for (const account of exchangeDetails.wireInfo.accounts) {
- exchangeWireAccounts.push(account.payto_uri);
- }
-
- const { isTrusted, isAudited } = await ws.exchangeOps.getExchangeTrust(
- ws,
- exchange,
- );
-
- let earliestDepositExpiration =
- selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit;
- for (let i = 1; i < selectedDenoms.selectedDenoms.length; i++) {
- const expireDeposit =
- selectedDenoms.selectedDenoms[i].denom.stampExpireDeposit;
- if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) {
- earliestDepositExpiration = expireDeposit;
- }
- }
-
- const possibleDenoms = await ws.db
- .mktx((x) => ({ denominations: x.denominations }))
- .runReadOnly(async (tx) => {
- return tx.denominations.indexes.byExchangeBaseUrl
- .iter()
- .filter((d) => d.isOffered);
- });
-
- let versionMatch;
- if (exchangeDetails.protocolVersion) {
- versionMatch = compare(
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- exchangeDetails.protocolVersion,
- );
-
- if (
- versionMatch &&
- !versionMatch.compatible &&
- versionMatch.currentCmp === -1
- ) {
- console.warn(
- `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
- `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
- );
- }
- }
-
- let tosAccepted = false;
-
- if (exchangeDetails.termsOfServiceLastEtag) {
- if (
- exchangeDetails.termsOfServiceAcceptedEtag ===
- exchangeDetails.termsOfServiceLastEtag
- ) {
- tosAccepted = true;
- }
- }
-
- const withdrawFee = Amounts.sub(
- selectedDenoms.totalWithdrawCost,
- selectedDenoms.totalCoinValue,
- ).amount;
-
- const ret: ExchangeWithdrawDetails = {
- earliestDepositExpiration,
- exchangeInfo: exchange,
- exchangeDetails,
- exchangeWireAccounts,
- exchangeVersion: exchangeDetails.protocolVersion || "unknown",
- isAudited,
- isTrusted,
- numOfferedDenoms: possibleDenoms.length,
- overhead: Amounts.sub(amount, selectedDenoms.totalWithdrawCost).amount,
- selectedDenoms,
- // FIXME: delete this field / replace by something we can display to the user
- trustedAuditorPubs: [],
- versionMatch,
- walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
- withdrawFee,
- termsOfServiceAccepted: tosAccepted,
- };
- return ret;
-}
-
-export async function getWithdrawalDetailsForUri(
- ws: InternalWalletState,
- talerWithdrawUri: string,
-): Promise<WithdrawUriInfoResponse> {
- logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
- const info = await getBankWithdrawalInfo(ws, talerWithdrawUri);
- logger.trace(`got bank info`);
- if (info.suggestedExchange) {
- // FIXME: right now the exchange gets permanently added,
- // we might want to only temporarily add it.
- try {
- await ws.exchangeOps.updateExchangeFromUrl(ws, info.suggestedExchange);
- } catch (e) {
- // We still continued if it failed, as other exchanges might be available.
- // We don't want to fail if the bank-suggested exchange is broken/offline.
- logger.trace(
- `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
- );
- }
- }
-
- const exchanges: ExchangeListItem[] = [];
-
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- const exchangeRecords = await tx.exchanges.iter().toArray();
- for (const r of exchangeRecords) {
- const details = await ws.exchangeOps.getExchangeDetails(tx, r.baseUrl);
- if (details) {
- exchanges.push({
- exchangeBaseUrl: details.exchangeBaseUrl,
- currency: details.currency,
- paytoUris: details.wireInfo.accounts.map((x) => x.payto_uri),
- });
- }
- }
- });
-
- return {
- amount: Amounts.stringify(info.amount),
- defaultExchangeBaseUrl: info.suggestedExchange,
- possibleExchanges: exchanges,
- };
-}
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
new file mode 100644
index 000000000..f9d20d415
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -0,0 +1,3441 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of the payment operation, including downloading and
+ * claiming of proposals.
+ *
+ * @author Florian Dold
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbortingCoin,
+ AbortRequest,
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ AmountString,
+ assertUnreachable,
+ AsyncFlag,
+ checkDbInvariant,
+ codecForAbortResponse,
+ codecForMerchantContractTerms,
+ codecForMerchantOrderStatusPaid,
+ codecForMerchantPayResponse,
+ codecForMerchantPostOrderResponse,
+ codecForProposal,
+ codecForWalletRefundResponse,
+ CoinDepositPermission,
+ CoinRefreshRequest,
+ ConfirmPayResult,
+ ConfirmPayResultType,
+ ContractTermsUtil,
+ Duration,
+ encodeCrock,
+ ForcedCoinSel,
+ getRandomBytes,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ makeErrorDetail,
+ makePendingOperationFailedError,
+ MerchantCoinRefundStatus,
+ MerchantContractTerms,
+ MerchantPayResponse,
+ MerchantUsingTemplateDetails,
+ NotificationType,
+ parsePayTemplateUri,
+ parsePayUri,
+ parseTalerUri,
+ PreparePayResult,
+ PreparePayResultType,
+ PreparePayTemplateRequest,
+ randomBytes,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ SharePaymentResult,
+ StartRefundQueryForUriResponse,
+ stringifyPayUri,
+ stringifyTalerUri,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TalerProtocolViolationError,
+ TalerUriAction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletContractData,
+} from "@gnu-taler/taler-util";
+import {
+ getHttpResponseErrorDetails,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ readUnexpectedResponseDetails,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import { PreviousPayCoins, selectPayCoins } from "./coinSelection.js";
+import {
+ constructTaskIdentifier,
+ PendingTaskType,
+ spendCoins,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResultType,
+} from "./common.js";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import {
+ CoinRecord,
+ DbCoinSelection,
+ DenominationRecord,
+ PurchaseRecord,
+ PurchaseStatus,
+ RefundGroupRecord,
+ RefundGroupStatus,
+ RefundItemRecord,
+ RefundItemStatus,
+ RefundReason,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletStoresV1,
+} from "./db.js";
+import { DbReadWriteTransaction, StoreNames } from "./query.js";
+import {
+ calculateRefreshOutput,
+ createRefreshGroup,
+ getTotalRefreshCost,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import {
+ EXCHANGE_COINS_LOCK,
+ getDenomInfo,
+ WalletExecutionContext,
+} from "./wallet.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("pay-merchant.ts");
+
+export class PayMerchantTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public proposalId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId,
+ });
+ }
+
+ /**
+ * Transition a payment transition.
+ */
+ async transition(
+ f: (rec: PurchaseRecord) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ return this.transitionExtra(
+ {
+ extraStores: [],
+ },
+ f,
+ );
+ }
+
+ /**
+ * Transition a payment transition.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transitionExtra<
+ StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
+ >(
+ opts: { extraStores: StoreNameArray },
+ f: (
+ rec: PurchaseRecord,
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ ["purchases", ...StoreNameArray]
+ >,
+ ) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ const ws = this.wex;
+ const extraStores = opts.extraStores ?? [];
+ const transitionInfo = await ws.db.runReadWriteTx(
+ ["purchases", ...extraStores],
+ async (tx) => {
+ const purchaseRec = await tx.purchases.get(this.proposalId);
+ if (!purchaseRec) {
+ throw Error("purchase not found anymore");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchaseRec);
+ const res = await f(purchaseRec, tx);
+ switch (res) {
+ case TransitionResultType.Transition: {
+ await tx.purchases.put(purchaseRec);
+ const newTxState = computePayMerchantTransactionState(purchaseRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(ws, this.transactionId, transitionInfo);
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex: ws, proposalId } = this;
+ await ws.db.runReadWriteTx(["purchases", "tombstones"], async (tx) => {
+ let found = false;
+ const purchase = await tx.purchases.get(proposalId);
+ if (purchase) {
+ found = true;
+ await tx.purchases.delete(proposalId);
+ }
+ if (found) {
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePayment + ":" + proposalId,
+ });
+ }
+ });
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newStatus = transitionSuspend[purchase.purchaseStatus];
+ if (!newStatus) {
+ return undefined;
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ [
+ "purchases",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ const oldStatus = purchase.purchaseStatus;
+ switch (oldStatus) {
+ case PurchaseStatus.Done:
+ return;
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.SuspendedPaying: {
+ purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+ if (purchase.payInfo && purchase.payInfo.payCoinSelection) {
+ const coinSel = purchase.payInfo.payCoinSelection;
+ const currency = Amounts.currencyOf(
+ purchase.payInfo.totalPayCost,
+ );
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < coinSel.coinPubs.length; i++) {
+ refreshCoins.push({
+ amount: coinSel.coinContributions[i],
+ coinPub: coinSel.coinPubs[i],
+ });
+ }
+ await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ refreshCoins,
+ RefreshReason.AbortPay,
+ this.transactionId,
+ );
+ }
+ break;
+ }
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ case PurchaseStatus.PendingAcceptRefund:
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ case PurchaseStatus.PendingQueryingRefund:
+ case PurchaseStatus.SuspendedQueryingRefund:
+ if (!purchase.timestampFirstSuccessfulPay) {
+ throw Error("invalid state");
+ }
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ break;
+ case PurchaseStatus.DialogProposed:
+ purchase.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
+ break;
+ default:
+ return;
+ }
+ await tx.purchases.put(purchase);
+ await tx.operationRetries.delete(this.taskId);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newStatus = transitionResume[purchase.purchaseStatus];
+ if (!newStatus) {
+ return undefined;
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, proposalId, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ [
+ "purchases",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ let newState: PurchaseStatus | undefined = undefined;
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.AbortingWithRefund:
+ newState = PurchaseStatus.FailedAbort;
+ break;
+ }
+ if (newState) {
+ purchase.purchaseStatus = newState;
+ await tx.purchases.put(purchase);
+ }
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ }
+}
+
+export class RefundTransactionContext implements TransactionContext {
+ public transactionId: TransactionIdStr;
+ public taskId: TaskIdStr | undefined = undefined;
+ constructor(
+ public wex: WalletExecutionContext,
+ public refundGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, refundGroupId, transactionId } = this;
+ await wex.db.runReadWriteTx(["refundGroups", "tombstones"], async (tx) => {
+ const refundRecord = await tx.refundGroups.get(refundGroupId);
+ if (!refundRecord) {
+ return;
+ }
+ await tx.refundGroups.delete(refundGroupId);
+ await tx.tombstones.put({ id: transactionId });
+ // FIXME: Also tombstone the refund items, so that they won't reappear.
+ });
+ }
+
+ suspendTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ abortTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ resumeTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+
+ failTransaction(): Promise<void> {
+ throw new Error("Unsupported operation");
+ }
+}
+
+/**
+ * Compute the total cost of a payment to the customer.
+ *
+ * This includes the amount taken by the merchant, fees (wire/deposit) contributed
+ * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
+ * of coins that are too small to spend.
+ */
+export async function getTotalPaymentCost(
+ wex: WalletExecutionContext,
+ currency: string,
+ pcs: SelectedProspectiveCoin[],
+): Promise<AmountJson> {
+ return wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ const costs: AmountJson[] = [];
+ for (let i = 0; i < pcs.length; i++) {
+ const denom = await tx.denominations.get([
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ );
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
+ costs.push(refreshCost);
+ }
+ const zero = Amounts.zeroOfCurrency(currency);
+ return Amounts.sum([zero, ...costs]).amount;
+ });
+}
+
+async function failProposalPermanently(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ err: TalerErrorDetail,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ // FIXME: We don't store the error detail here?!
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedClaim;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
+ return Duration.multiply(
+ { d_ms: 15000 },
+ 1 + (purchase.payInfo?.payCoinSelection?.coinPubs.length ?? 0) / 5,
+ );
+}
+
+/**
+ * Return the proposal download data for a purchase, throw if not available.
+ */
+export async function expectProposalDownload(
+ wex: WalletExecutionContext,
+ p: PurchaseRecord,
+ parentTx?: WalletDbReadOnlyTransaction<["contractTerms"]>,
+): Promise<{
+ contractData: WalletContractData;
+ contractTermsRaw: any;
+}> {
+ if (!p.download) {
+ throw Error("expected proposal to be downloaded");
+ }
+ const download = p.download;
+
+ async function getFromTransaction(
+ tx: Exclude<typeof parentTx, undefined>,
+ ): Promise<ReturnType<typeof expectProposalDownload>> {
+ const contractTerms = await tx.contractTerms.get(
+ download.contractTermsHash,
+ );
+ if (!contractTerms) {
+ throw Error("contract terms not found");
+ }
+ return {
+ contractData: extractContractData(
+ contractTerms.contractTermsRaw,
+ download.contractTermsHash,
+ download.contractTermsMerchantSig,
+ ),
+ contractTermsRaw: contractTerms.contractTermsRaw,
+ };
+ }
+
+ if (parentTx) {
+ return getFromTransaction(parentTx);
+ }
+ return await wex.db.runReadOnlyTx(["contractTerms"], getFromTransaction);
+}
+
+export function extractContractData(
+ parsedContractTerms: MerchantContractTerms,
+ contractTermsHash: string,
+ merchantSig: string,
+): WalletContractData {
+ const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
+ return {
+ amount: Amounts.stringify(amount),
+ contractTermsHash: contractTermsHash,
+ fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
+ merchantBaseUrl: parsedContractTerms.merchant_base_url,
+ merchantPub: parsedContractTerms.merchant_pub,
+ merchantSig,
+ orderId: parsedContractTerms.order_id,
+ summary: parsedContractTerms.summary,
+ autoRefund: parsedContractTerms.auto_refund,
+ payDeadline: parsedContractTerms.pay_deadline,
+ refundDeadline: parsedContractTerms.refund_deadline,
+ allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
+ exchangeBaseUrl: x.url,
+ exchangePub: x.master_pub,
+ })),
+ timestamp: parsedContractTerms.timestamp,
+ wireMethod: parsedContractTerms.wire_method,
+ wireInfoHash: parsedContractTerms.h_wire,
+ maxDepositFee: Amounts.stringify(parsedContractTerms.max_fee),
+ merchant: parsedContractTerms.merchant,
+ summaryI18n: parsedContractTerms.summary_i18n,
+ minimumAge: parsedContractTerms.minimum_age,
+ };
+}
+
+async function processDownloadProposal(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return await tx.purchases.get(proposalId);
+ });
+
+ if (!proposal) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ if (proposal.purchaseStatus != PurchaseStatus.PendingDownloadingProposal) {
+ logger.error(
+ `unexpected state ${proposal.purchaseStatus}/${
+ PurchaseStatus[proposal.purchaseStatus]
+ } for ${ctx.transactionId} in processDownloadProposal`,
+ );
+ return TaskRunResult.finished();
+ }
+
+ const transactionId = ctx.transactionId;
+
+ const orderClaimUrl = new URL(
+ `orders/${proposal.orderId}/claim`,
+ proposal.merchantBaseUrl,
+ ).href;
+ logger.trace("downloading contract from '" + orderClaimUrl + "'");
+
+ const requestBody: {
+ nonce: string;
+ token?: string;
+ } = {
+ nonce: proposal.noncePub,
+ };
+ if (proposal.claimToken) {
+ requestBody.token = proposal.claimToken;
+ }
+
+ const httpResponse = await wex.http.fetch(orderClaimUrl, {
+ method: "POST",
+ body: requestBody,
+ cancellationToken: wex.cancellationToken,
+ });
+ const r = await readSuccessResponseJsonOrErrorCode(
+ httpResponse,
+ codecForProposal(),
+ );
+ if (r.isError) {
+ switch (r.talerErrorResponse.code) {
+ case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
+ {
+ orderId: proposal.orderId,
+ claimUrl: orderClaimUrl,
+ },
+ "order already claimed (likely by other wallet)",
+ );
+ default:
+ throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
+ }
+ }
+ const proposalResp = r.response;
+
+ // The proposalResp contains the contract terms as raw JSON,
+ // as the code to parse them doesn't necessarily round-trip.
+ // We need this raw JSON to compute the contract terms hash.
+
+ // FIXME: Do better error handling, check if the
+ // contract terms have all their forgettable information still
+ // present. The wallet should never accept contract terms
+ // with missing information from the merchant.
+
+ const isWellFormed = ContractTermsUtil.validateForgettable(
+ proposalResp.contract_terms,
+ );
+
+ if (!isWellFormed) {
+ logger.trace(
+ `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
+ );
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ "validation for well-formedness failed",
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(
+ proposalResp.contract_terms,
+ );
+
+ logger.info(`Contract terms hash: ${contractTermsHash}`);
+
+ let parsedContractTerms: MerchantContractTerms;
+
+ try {
+ parsedContractTerms = codecForMerchantContractTerms().decode(
+ proposalResp.contract_terms,
+ );
+ } catch (e) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ `schema validation failed: ${e}`,
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const sigValid = await wex.cryptoApi.isValidContractTermsSignature({
+ contractTermsHash,
+ merchantPub: parsedContractTerms.merchant_pub,
+ sig: proposalResp.sig,
+ });
+
+ if (!sigValid) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
+ {
+ merchantPub: parsedContractTerms.merchant_pub,
+ orderId: parsedContractTerms.order_id,
+ },
+ "merchant's signature on contract terms is invalid",
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const fulfillmentUrl = parsedContractTerms.fulfillment_url;
+
+ const baseUrlForDownload = proposal.merchantBaseUrl;
+ const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
+
+ if (baseUrlForDownload !== baseUrlFromContractTerms) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
+ {
+ baseUrlForDownload,
+ baseUrlFromContractTerms,
+ },
+ "merchant base URL mismatch",
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const contractData = extractContractData(
+ parsedContractTerms,
+ contractTermsHash,
+ proposalResp.sig,
+ );
+
+ logger.trace(`extracted contract data: ${j2s(contractData)}`);
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases", "contractTerms"],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingDownloadingProposal) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.download = {
+ contractTermsHash,
+ contractTermsMerchantSig: contractData.merchantSig,
+ currency: Amounts.currencyOf(contractData.amount),
+ fulfillmentUrl: contractData.fulfillmentUrl,
+ };
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: proposalResp.contract_terms,
+ });
+ const isResourceFulfillmentUrl =
+ fulfillmentUrl &&
+ (fulfillmentUrl.startsWith("http://") ||
+ fulfillmentUrl.startsWith("https://"));
+ let otherPurchase: PurchaseRecord | undefined;
+ if (isResourceFulfillmentUrl) {
+ otherPurchase =
+ await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
+ }
+ // FIXME: Adjust this to account for refunds, don't count as repurchase
+ // if original order is refunded.
+ if (
+ otherPurchase &&
+ (otherPurchase.purchaseStatus == PurchaseStatus.Done ||
+ otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying ||
+ otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay)
+ ) {
+ logger.warn("repurchase detected");
+ p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected;
+ p.repurchaseProposalId = otherPurchase.proposalId;
+ await tx.purchases.put(p);
+ } else {
+ p.purchaseStatus = p.shared
+ ? PurchaseStatus.DialogShared
+ : PurchaseStatus.DialogProposed;
+ await tx.purchases.put(p);
+ }
+ const newTxState = computePayMerchantTransactionState(p);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return TaskRunResult.progress();
+}
+
+/**
+ * Create a new purchase transaction if necessary. If a purchase
+ * record for the provided arguments already exists,
+ * return the old proposal ID.
+ */
+async function createOrReusePurchase(
+ wex: WalletExecutionContext,
+ merchantBaseUrl: string,
+ orderId: string,
+ sessionId: string | undefined,
+ claimToken: string | undefined,
+ noncePriv: string | undefined,
+): Promise<string> {
+ const oldProposals = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.getAll([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ });
+
+ const oldProposal = oldProposals.find((p) => {
+ return (
+ p.downloadSessionId === sessionId &&
+ (!noncePriv || p.noncePriv === noncePriv) &&
+ p.claimToken === claimToken
+ );
+ });
+ // If we have already claimed this proposal with the same sessionId
+ // nonce and claim token, reuse it. */
+ if (
+ oldProposal &&
+ oldProposal.downloadSessionId === sessionId &&
+ (!noncePriv || oldProposal.noncePriv === noncePriv) &&
+ oldProposal.claimToken === claimToken
+ ) {
+ logger.info(
+ `Found old proposal (status=${
+ PurchaseStatus[oldProposal.purchaseStatus]
+ }) for order ${orderId} at ${merchantBaseUrl}`,
+ );
+ if (oldProposal.purchaseStatus === PurchaseStatus.DialogShared) {
+ const download = await expectProposalDownload(wex, oldProposal);
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ false,
+ );
+ logger.info(`old proposal paid: ${paid}`);
+ if (paid) {
+ // if this transaction was shared and the order is paid then it
+ // means that another wallet already paid the proposal
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(oldProposal.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: oldProposal.proposalId,
+ });
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+ }
+ return oldProposal.proposalId;
+ }
+
+ let noncePair: EddsaKeypair;
+ let shared = false;
+ if (noncePriv) {
+ shared = true;
+ noncePair = {
+ priv: noncePriv,
+ pub: (await wex.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
+ };
+ } else {
+ noncePair = await wex.cryptoApi.createEddsaKeypair({});
+ }
+
+ const { priv, pub } = noncePair;
+ const proposalId = encodeCrock(getRandomBytes(32));
+
+ const proposalRecord: PurchaseRecord = {
+ download: undefined,
+ noncePriv: priv,
+ noncePub: pub,
+ claimToken,
+ timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ merchantBaseUrl,
+ orderId,
+ proposalId: proposalId,
+ purchaseStatus: PurchaseStatus.PendingDownloadingProposal,
+ repurchaseProposalId: undefined,
+ downloadSessionId: sessionId,
+ autoRefundDeadline: undefined,
+ lastSessionId: undefined,
+ merchantPaySig: undefined,
+ payInfo: undefined,
+ refundAmountAwaiting: undefined,
+ timestampAccept: undefined,
+ timestampFirstSuccessfulPay: undefined,
+ timestampLastRefundStatus: undefined,
+ pendingRemovedCoinPubs: undefined,
+ posConfirmation: undefined,
+ shared: shared,
+ };
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ await tx.purchases.put(proposalRecord);
+ const oldTxState: TransactionState = {
+ major: TransactionMajorState.None,
+ };
+ const newTxState = computePayMerchantTransactionState(proposalRecord);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ notifyTransition(wex, transactionId, transitionInfo);
+ return proposalId;
+}
+
+async function storeFirstPaySuccess(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ sessionId: string | undefined,
+ payResponse: MerchantPayResponse,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["contractTerms", "purchases"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (!isFirst) {
+ logger.warn("payment success already stored");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ if (purchase.purchaseStatus === PurchaseStatus.PendingPaying) {
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(now);
+ purchase.lastSessionId = sessionId;
+ purchase.merchantPaySig = payResponse.sig;
+ purchase.posConfirmation = payResponse.pos_confirmation;
+ const dl = purchase.download;
+ checkDbInvariant(!!dl);
+ const contractTermsRecord = await tx.contractTerms.get(
+ dl.contractTermsHash,
+ );
+ checkDbInvariant(!!contractTermsRecord);
+ const contractData = extractContractData(
+ contractTermsRecord.contractTermsRaw,
+ dl.contractTermsHash,
+ dl.contractTermsMerchantSig,
+ );
+ const protoAr = contractData.autoRefund;
+ if (protoAr) {
+ const ar = Duration.fromTalerProtocolDuration(protoAr);
+ logger.info("auto_refund present");
+ purchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
+ purchase.autoRefundDeadline = timestampProtocolToDb(
+ AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
+ ),
+ );
+ }
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+async function storePayReplaySuccess(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ sessionId: string | undefined,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (isFirst) {
+ throw Error("invalid payment state");
+ }
+ const oldTxState = computePayMerchantTransactionState(purchase);
+ if (
+ purchase.purchaseStatus === PurchaseStatus.PendingPaying ||
+ purchase.purchaseStatus === PurchaseStatus.PendingPayingReplay
+ ) {
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ purchase.lastSessionId = sessionId;
+ await tx.purchases.put(purchase);
+ const newTxState = computePayMerchantTransactionState(purchase);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+/**
+ * Handle a 409 Conflict response from the merchant.
+ *
+ * We do this by going through the coin history provided by the exchange and
+ * (1) verifying the signatures from the exchange
+ * (2) adjusting the remaining coin value and refreshing it
+ * (3) re-do coin selection with the bad coin removed
+ */
+async function handleInsufficientFunds(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ err: TalerErrorDetail,
+): Promise<void> {
+ logger.trace("handling insufficient funds, trying to re-select coins");
+
+ const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!proposal) {
+ return;
+ }
+
+ logger.trace(`got error details: ${j2s(err)}`);
+
+ const exchangeReply = (err as any).exchange_reply;
+ if (
+ exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
+ ) {
+ // FIXME: set as failed
+ if (logger.shouldLogTrace()) {
+ logger.trace("got exchange error reply (see below)");
+ logger.trace(j2s(exchangeReply));
+ }
+ throw Error(`unable to handle /pay error response (${exchangeReply.code})`);
+ }
+
+ const brokenCoinPub = (exchangeReply as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ throw new TalerProtocolViolationError();
+ }
+
+ const { contractData } = await expectProposalDownload(wex, proposal);
+
+ const prevPayCoins: PreviousPayCoins = [];
+
+ const payInfo = proposal.payInfo;
+ if (!payInfo) {
+ return;
+ }
+
+ const payCoinSelection = payInfo.payCoinSelection;
+ if (!payCoinSelection) {
+ return;
+ }
+
+ await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const contrib = payCoinSelection.coinContributions[i];
+ prevPayCoins.push({
+ coinPub,
+ contribution: Amounts.parseOrThrow(contrib),
+ });
+ }
+ });
+
+ const res = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins,
+ requiredMinimumAge: contractData.minimumAge,
+ });
+
+ switch (res.type) {
+ case "failure":
+ logger.trace("insufficient funds for coin re-selection");
+ return;
+ case "prospective":
+ return;
+ case "success":
+ break;
+ default:
+ assertUnreachable(res);
+ }
+
+ logger.trace("re-selected coins");
+
+ await wex.db.runReadWriteTx(
+ [
+ "purchases",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ const payInfo = p.payInfo;
+ if (!payInfo) {
+ return;
+ }
+ // Convert to DB format
+ payInfo.payCoinSelection = {
+ coinContributions: res.coinSel.coins.map((x) => x.contribution),
+ coinPubs: res.coinSel.coins.map((x) => x.coinPub),
+ };
+ payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ await tx.purchases.put(p);
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:proposal:${p.proposalId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: payInfo.payCoinSelection.coinPubs,
+ contributions: payInfo.payCoinSelection.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ },
+ );
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ }),
+ });
+}
+
+// FIXME: Should take a transaction ID instead of a proposal ID
+// FIXME: Does way more than checking the payment
+// FIXME: Should return immediately.
+async function checkPaymentByProposalId(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ sessionId?: string,
+): Promise<PreparePayResult> {
+ let proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!proposal) {
+ throw Error(`could not get proposal ${proposalId}`);
+ }
+ if (proposal.purchaseStatus === PurchaseStatus.DoneRepurchaseDetected) {
+ const existingProposalId = proposal.repurchaseProposalId;
+ if (existingProposalId) {
+ logger.trace("using existing purchase for same product");
+ const oldProposal = await wex.db.runReadOnlyTx(
+ ["purchases"],
+ async (tx) => {
+ return tx.purchases.get(existingProposalId);
+ },
+ );
+ if (oldProposal) {
+ proposal = oldProposal;
+ }
+ }
+ }
+ const d = await expectProposalDownload(wex, proposal);
+ const contractData = d.contractData;
+ const merchantSig = d.contractData.merchantSig;
+ if (!merchantSig) {
+ throw Error("BUG: proposal is in invalid state");
+ }
+
+ proposalId = proposal.proposalId;
+
+ const currency = Amounts.currencyOf(contractData.amount);
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ const transactionId = ctx.transactionId;
+
+ const talerUri = stringifyTalerUri({
+ type: TalerUriAction.Pay,
+ merchantBaseUrl: proposal.merchantBaseUrl,
+ orderId: proposal.orderId,
+ sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "",
+ claimToken: proposal.claimToken,
+ });
+
+ // First check if we already paid for it.
+ const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+
+ if (
+ !purchase ||
+ purchase.purchaseStatus === PurchaseStatus.DialogProposed ||
+ purchase.purchaseStatus === PurchaseStatus.DialogShared
+ ) {
+ const instructedAmount = Amounts.parseOrThrow(contractData.amount);
+ // If not already paid, check if we could pay for it.
+ const res = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ contractTermsAmount: instructedAmount,
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ restrictWireMethod: contractData.wireMethod,
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (res.type) {
+ case "failure": {
+ logger.info("not allowing payment, insufficient coins");
+ logger.info(
+ `insufficient balance details: ${j2s(
+ res.insufficientBalanceDetails,
+ )}`,
+ );
+ return {
+ status: PreparePayResultType.InsufficientBalance,
+ contractTerms: d.contractTermsRaw,
+ proposalId: proposal.proposalId,
+ transactionId,
+ amountRaw: Amounts.stringify(d.contractData.amount),
+ talerUri,
+ balanceDetails: res.insufficientBalanceDetails,
+ };
+ }
+ case "prospective":
+ coins = res.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = res.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(res);
+ }
+
+ const totalCost = await getTotalPaymentCost(wex, currency, coins);
+ logger.trace("costInfo", totalCost);
+ logger.trace("coinsForPayment", res);
+
+ return {
+ status: PreparePayResultType.PaymentPossible,
+ contractTerms: d.contractTermsRaw,
+ transactionId,
+ proposalId: proposal.proposalId,
+ amountEffective: Amounts.stringify(totalCost),
+ amountRaw: Amounts.stringify(instructedAmount),
+ contractTermsHash: d.contractData.contractTermsHash,
+ talerUri,
+ };
+ }
+
+ if (
+ purchase.purchaseStatus === PurchaseStatus.Done &&
+ purchase.lastSessionId !== sessionId
+ ) {
+ logger.trace(
+ "automatically re-submitting payment with different session ID",
+ );
+ logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.lastSessionId = sessionId;
+ p.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ await tx.purchases.put(p);
+ const newTxState = computePayMerchantTransactionState(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Consider changing the API here so that we don't have to
+ // wait inline for the repurchase.
+
+ await waitPaymentResult(wex, proposalId, sessionId);
+ const download = await expectProposalDownload(wex, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid: true,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ } else if (!purchase.timestampFirstSuccessfulPay) {
+ const download = await expectProposalDownload(wex, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid: purchase.purchaseStatus === PurchaseStatus.FailedPaidByOther,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ } else {
+ const paid =
+ purchase.purchaseStatus === PurchaseStatus.Done ||
+ purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund ||
+ purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund;
+ const download = await expectProposalDownload(wex, purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: purchase.payInfo
+ ? Amounts.stringify(purchase.payInfo.totalPayCost)
+ : undefined,
+ ...(paid ? { nextUrl: download.contractData.orderId } : {}),
+ transactionId,
+ proposalId,
+ talerUri,
+ };
+ }
+}
+
+export async function getContractTermsDetails(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<WalletContractData> {
+ const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = await expectProposalDownload(wex, proposal);
+
+ return d.contractData;
+}
+
+/**
+ * Check if a payment for the given taler://pay/ URI is possible.
+ *
+ * If the payment is possible, the signature are already generated but not
+ * yet send to the merchant.
+ */
+export async function preparePayForUri(
+ wex: WalletExecutionContext,
+ talerPayUri: string,
+): Promise<PreparePayResult> {
+ const uriResult = parsePayUri(talerPayUri);
+
+ if (!uriResult) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
+ {
+ talerPayUri,
+ },
+ `invalid taler://pay URI (${talerPayUri})`,
+ );
+ }
+
+ const proposalId = await createOrReusePurchase(
+ wex,
+ uriResult.merchantBaseUrl,
+ uriResult.orderId,
+ uriResult.sessionId,
+ uriResult.claimToken,
+ uriResult.noncePriv,
+ );
+
+ await waitProposalDownloaded(wex, proposalId);
+
+ return checkPaymentByProposalId(wex, proposalId, uriResult.sessionId);
+}
+
+/**
+ * Wait until a proposal is at least downloaded.
+ */
+async function waitProposalDownloaded(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ // FIXME: This doesn't support cancellation yet
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ logger.info(`waiting for ${ctx.transactionId} to be downloaded`);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const payNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ payNotifFlag.raise();
+ }
+ });
+
+ try {
+ await internalWaitProposalDownloaded(ctx, payNotifFlag);
+ logger.info(`done waiting for ${ctx.transactionId} to be downloaded`);
+ } finally {
+ cancelNotif();
+ }
+}
+
+async function internalWaitProposalDownloaded(
+ ctx: PayMerchantTransactionContext,
+ payNotifFlag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx(
+ ["purchases", "operationRetries"],
+ async (tx) => {
+ return {
+ purchase: await tx.purchases.get(ctx.proposalId),
+ retryInfo: await tx.operationRetries.get(ctx.taskId),
+ };
+ },
+ );
+ if (!purchase) {
+ throw Error("purchase does not exist anymore");
+ }
+ if (purchase.download) {
+ return;
+ }
+ if (retryInfo) {
+ if (retryInfo.lastError) {
+ throw TalerError.fromUncheckedDetail(retryInfo.lastError);
+ } else {
+ throw Error("transient error while waiting for proposal download");
+ }
+ }
+ await payNotifFlag.wait();
+ payNotifFlag.reset();
+ }
+}
+
+export async function preparePayForTemplate(
+ wex: WalletExecutionContext,
+ req: PreparePayTemplateRequest,
+): Promise<PreparePayResult> {
+ const parsedUri = parsePayTemplateUri(req.talerPayTemplateUri);
+ const templateDetails: MerchantUsingTemplateDetails = {};
+ if (!parsedUri) {
+ throw Error("invalid taler-template URI");
+ }
+ logger.trace(`parsed URI: ${j2s(parsedUri)}`);
+
+ const amountFromUri = parsedUri.templateParams.amount;
+ if (amountFromUri != null) {
+ const templateParamsAmount = req.templateParams?.amount;
+ if (templateParamsAmount != null) {
+ templateDetails.amount = templateParamsAmount as AmountString;
+ } else {
+ if (Amounts.isCurrency(amountFromUri)) {
+ throw Error(
+ "Amount from template URI only has a currency without value. The value must be provided in the templateParams.",
+ );
+ } else {
+ templateDetails.amount = amountFromUri as AmountString;
+ }
+ }
+ }
+ if (
+ parsedUri.templateParams.summary !== undefined &&
+ typeof parsedUri.templateParams.summary === "string"
+ ) {
+ templateDetails.summary =
+ req.templateParams?.summary ?? parsedUri.templateParams.summary;
+ }
+ const reqUrl = new URL(
+ `templates/${parsedUri.templateId}`,
+ parsedUri.merchantBaseUrl,
+ );
+ const httpReq = await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: templateDetails,
+ });
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpReq,
+ codecForMerchantPostOrderResponse(),
+ );
+
+ const payUri = stringifyPayUri({
+ merchantBaseUrl: parsedUri.merchantBaseUrl,
+ orderId: resp.order_id,
+ sessionId: "",
+ claimToken: resp.token,
+ });
+
+ return await preparePayForUri(wex, payUri);
+}
+
+/**
+ * Generate deposit permissions for a purchase.
+ *
+ * Accesses the database and the crypto worker.
+ */
+export async function generateDepositPermissions(
+ wex: WalletExecutionContext,
+ payCoinSel: DbCoinSelection,
+ contractData: WalletContractData,
+): Promise<CoinDepositPermission[]> {
+ const depositPermissions: CoinDepositPermission[] = [];
+ const coinWithDenom: Array<{
+ coin: CoinRecord;
+ denom: DenominationRecord;
+ }> = [];
+ await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
+ const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't pay, allocated coin not found anymore");
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error(
+ "can't pay, denomination of allocated coin not found anymore",
+ );
+ }
+ coinWithDenom.push({ coin, denom });
+ }
+ });
+
+ for (let i = 0; i < payCoinSel.coinContributions.length; i++) {
+ const { coin, denom } = coinWithDenom[i];
+ let wireInfoHash: string;
+ wireInfoHash = contractData.wireInfoHash;
+ const dp = await wex.cryptoApi.signDepositPermission({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contractTermsHash: contractData.contractTermsHash,
+ denomPubHash: coin.denomPubHash,
+ denomKeyType: denom.denomPub.cipher,
+ denomSig: coin.denomSig,
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ feeDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit),
+ merchantPub: contractData.merchantPub,
+ refundDeadline: contractData.refundDeadline,
+ spendAmount: Amounts.parseOrThrow(payCoinSel.coinContributions[i]),
+ timestamp: contractData.timestamp,
+ wireInfoHash,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ depositPermissions.push(dp);
+ }
+ return depositPermissions;
+}
+
+async function internalWaitPaymentResult(
+ ctx: PayMerchantTransactionContext,
+ purchaseNotifFlag: AsyncFlag,
+ waitSessionId?: string,
+): Promise<ConfirmPayResult> {
+ while (true) {
+ const txRes = await ctx.wex.db.runReadOnlyTx(
+ ["purchases", "operationRetries"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(ctx.proposalId);
+ const retryRecord = await tx.operationRetries.get(ctx.taskId);
+ return { purchase, retryRecord };
+ },
+ );
+
+ if (!txRes.purchase) {
+ throw Error("purchase gone");
+ }
+
+ const purchase = txRes.purchase;
+
+ logger.info(
+ `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`,
+ );
+
+ const d = await expectProposalDownload(ctx.wex, purchase);
+
+ if (txRes.purchase.timestampFirstSuccessfulPay) {
+ if (
+ waitSessionId == null ||
+ txRes.purchase.lastSessionId === waitSessionId
+ ) {
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
+ };
+ }
+ }
+
+ if (txRes.retryRecord) {
+ return {
+ type: ConfirmPayResultType.Pending,
+ lastError: txRes.retryRecord.lastError,
+ transactionId: ctx.transactionId,
+ };
+ }
+
+ if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) {
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: d.contractTermsRaw,
+ transactionId: ctx.transactionId,
+ };
+ }
+
+ await purchaseNotifFlag.wait();
+ purchaseNotifFlag.reset();
+ }
+}
+
+/**
+ * Wait until either:
+ * a) the payment succeeded (if provided under the {@param waitSessionId}), or
+ * b) the attempt to pay failed (merchant unavailable, etc.)
+ */
+async function waitPaymentResult(
+ wex: WalletExecutionContext,
+ proposalId: string,
+ waitSessionId?: string,
+): Promise<ConfirmPayResult> {
+ // FIXME: We don't support cancelletion yet!
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const purchaseNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our purchase.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ purchaseNotifFlag.raise();
+ }
+ });
+
+ try {
+ logger.info(`waiting for first payment success on ${ctx.transactionId}`);
+ const res = await internalWaitPaymentResult(
+ ctx,
+ purchaseNotifFlag,
+ waitSessionId,
+ );
+ logger.info(
+ `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`,
+ );
+ return res;
+ } finally {
+ cancelNotif();
+ }
+}
+
+/**
+ * Confirm payment for a proposal previously claimed by the wallet.
+ */
+export async function confirmPay(
+ wex: WalletExecutionContext,
+ transactionId: string,
+ sessionIdOverride?: string,
+ forcedCoinSel?: ForcedCoinSel,
+): Promise<ConfirmPayResult> {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+ if (parsedTx?.tag !== TransactionType.Payment) {
+ throw Error("expected payment transaction ID");
+ }
+ const proposalId = parsedTx.proposalId;
+ logger.trace(
+ `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
+ );
+ const proposal = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = await expectProposalDownload(wex, proposal);
+ if (!d) {
+ throw Error("proposal is in invalid state");
+ }
+
+ const existingPurchase = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (
+ purchase &&
+ sessionIdOverride !== undefined &&
+ sessionIdOverride != purchase.lastSessionId
+ ) {
+ logger.trace(`changing session ID to ${sessionIdOverride}`);
+ purchase.lastSessionId = sessionIdOverride;
+ if (purchase.purchaseStatus === PurchaseStatus.Done) {
+ purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ }
+ await tx.purchases.put(purchase);
+ }
+ return purchase;
+ },
+ );
+
+ if (existingPurchase && existingPurchase.payInfo) {
+ logger.trace("confirmPay: submitting payment for existing purchase");
+ const ctx = new PayMerchantTransactionContext(
+ wex,
+ existingPurchase.proposalId,
+ );
+ await wex.taskScheduler.resetTaskRetries(ctx.taskId);
+ return waitPaymentResult(wex, proposalId);
+ }
+
+ logger.trace("confirmPay: purchase record does not exist yet");
+
+ const contractData = d.contractData;
+
+ const currency = Amounts.currencyOf(contractData.amount);
+
+ const selectCoinsResult = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ forcedSelection: forcedCoinSel,
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (selectCoinsResult.type) {
+ case "failure": {
+ // Should not happen, since checkPay should be called first
+ // FIXME: Actually, this should be handled gracefully,
+ // and the status should be stored in the DB.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+ case "prospective": {
+ coins = selectCoinsResult.result.prospectiveCoins;
+ break;
+ }
+ case "success":
+ coins = selectCoinsResult.coinSel.coins;
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
+ }
+
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(wex, currency, coins);
+
+ let sessionId: string | undefined;
+ if (sessionIdOverride) {
+ sessionId = sessionIdOverride;
+ } else {
+ sessionId = proposal.downloadSessionId;
+ }
+
+ logger.trace(
+ `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
+ );
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const p = await tx.purchases.get(proposal.proposalId);
+ if (!p) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ switch (p.purchaseStatus) {
+ case PurchaseStatus.DialogShared:
+ case PurchaseStatus.DialogProposed:
+ p.payInfo = {
+ totalPayCost: Amounts.stringify(payCostInfo),
+ };
+ if (selectCoinsResult.type === "success") {
+ p.payInfo.payCoinSelection = {
+ coinContributions: selectCoinsResult.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ };
+ p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
+ }
+ p.lastSessionId = sessionId;
+ p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ p.purchaseStatus = PurchaseStatus.PendingPaying;
+ await tx.purchases.put(p);
+ if (p.payInfo.payCoinSelection) {
+ const sel = p.payInfo.payCoinSelection;
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: sel.coinPubs,
+ contributions: sel.coinContributions.map((x) =>
+ Amounts.parseOrThrow(x),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ }
+
+ break;
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPaying:
+ default:
+ break;
+ }
+ const newTxState = computePayMerchantTransactionState(p);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ // In case we're sharing the payment and we're long-polling
+ wex.taskScheduler.stopShepherdTask(ctx.taskId);
+
+ // Wait until we have completed the first attempt to pay.
+ return waitPaymentResult(wex, proposalId);
+}
+
+export async function processPurchase(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!purchase) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: {
+ // FIXME: allocate more specific error code
+ code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ when: AbsoluteTime.now(),
+ hint: `trying to pay for purchase that is not in the database`,
+ proposalId: proposalId,
+ },
+ };
+ }
+
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.PendingDownloadingProposal:
+ return processDownloadProposal(wex, proposalId);
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.PendingPayingReplay:
+ return processPurchasePay(wex, proposalId);
+ case PurchaseStatus.PendingQueryingRefund:
+ return processPurchaseQueryRefund(wex, purchase);
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ return processPurchaseAutoRefund(wex, purchase);
+ case PurchaseStatus.AbortingWithRefund:
+ return processPurchaseAbortingRefund(wex, purchase);
+ case PurchaseStatus.PendingAcceptRefund:
+ return processPurchaseAcceptRefund(wex, purchase);
+ case PurchaseStatus.DialogShared:
+ return processPurchaseDialogShared(wex, purchase);
+ case PurchaseStatus.FailedClaim:
+ case PurchaseStatus.Done:
+ case PurchaseStatus.DoneRepurchaseDetected:
+ case PurchaseStatus.DialogProposed:
+ case PurchaseStatus.AbortedProposalRefused:
+ case PurchaseStatus.AbortedIncompletePayment:
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ case PurchaseStatus.SuspendedPaying:
+ case PurchaseStatus.SuspendedPayingReplay:
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ case PurchaseStatus.SuspendedQueryingRefund:
+ case PurchaseStatus.FailedAbort:
+ case PurchaseStatus.FailedPaidByOther:
+ return TaskRunResult.finished();
+ default:
+ assertUnreachable(purchase.purchaseStatus);
+ }
+}
+
+async function processPurchasePay(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<TaskRunResult> {
+ const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!purchase) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: {
+ // FIXME: allocate more specific error code
+ code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ when: AbsoluteTime.now(),
+ hint: `trying to pay for purchase that is not in the database`,
+ proposalId: proposalId,
+ },
+ };
+ }
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.PendingPayingReplay:
+ break;
+ default:
+ return TaskRunResult.finished();
+ }
+ logger.trace(`processing purchase pay ${proposalId}`);
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+
+ const sessionId = purchase.lastSessionId;
+
+ logger.trace(`paying with session ID ${sessionId}`);
+ const payInfo = purchase.payInfo;
+ checkDbInvariant(!!payInfo, "payInfo");
+
+ const download = await expectProposalDownload(wex, purchase);
+
+ if (purchase.shared) {
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ false,
+ );
+
+ if (paid) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, {
+ orderId: purchase.orderId,
+ fulfillmentUrl: download.contractData.fulfillmentUrl,
+ }),
+ };
+ }
+ }
+
+ const contractData = download.contractData;
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ if (!payInfo.payCoinSelection) {
+ const selectCoinsResult = await selectPayCoins(wex, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractData.allowedExchanges,
+ },
+ restrictWireMethod: contractData.wireMethod,
+ contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
+ depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ switch (selectCoinsResult.type) {
+ case "failure": {
+ // Should not happen, since checkPay should be called first
+ // FIXME: Actually, this should be handled gracefully,
+ // and the status should be stored in the DB.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+ case "prospective": {
+ throw Error("insufficient balance (pending refresh)");
+ }
+ case "success":
+ break;
+ default:
+ assertUnreachable(selectCoinsResult);
+ }
+
+ logger.trace("coin selection result", selectCoinsResult);
+
+ const payCostInfo = await getTotalPaymentCost(
+ wex,
+ currency,
+ selectCoinsResult.coinSel.coins,
+ );
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ [
+ "purchases",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return false;
+ }
+ if (p.payInfo?.payCoinSelection) {
+ return false;
+ }
+ switch (p.purchaseStatus) {
+ case PurchaseStatus.DialogShared:
+ case PurchaseStatus.DialogProposed:
+ p.payInfo = {
+ totalPayCost: Amounts.stringify(payCostInfo),
+ payCoinSelection: {
+ coinContributions: selectCoinsResult.coinSel.coins.map(
+ (x) => x.contribution,
+ ),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ },
+ };
+ p.payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(16));
+ p.purchaseStatus = PurchaseStatus.PendingPaying;
+ await tx.purchases.put(p);
+
+ await spendCoins(wex, tx, {
+ //`txn:proposal:${p.proposalId}`
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: proposalId,
+ }),
+ coinPubs: selectCoinsResult.coinSel.coins.map((x) => x.coinPub),
+ contributions: selectCoinsResult.coinSel.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ return true;
+ case PurchaseStatus.Done:
+ case PurchaseStatus.PendingPaying:
+ default:
+ break;
+ }
+ return false;
+ },
+ );
+
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
+ if (!purchase.merchantPaySig) {
+ const payUrl = new URL(
+ `orders/${download.contractData.orderId}/pay`,
+ download.contractData.merchantBaseUrl,
+ ).href;
+
+ let depositPermissions: CoinDepositPermission[];
+ // FIXME: Cache!
+ depositPermissions = await generateDepositPermissions(
+ wex,
+ payInfo.payCoinSelection,
+ download.contractData,
+ );
+
+ const reqBody = {
+ coins: depositPermissions,
+ session_id: purchase.lastSessionId,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`making pay request ... ${j2s(reqBody)}`);
+ }
+
+ const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ wex.http.fetch(payUrl, {
+ method: "POST",
+ body: reqBody,
+ timeout: getPayRequestTimeout(purchase),
+ cancellationToken: wex.cancellationToken,
+ }),
+ );
+
+ logger.trace(`got resp ${JSON.stringify(resp)}`);
+
+ if (resp.status >= 500 && resp.status <= 599) {
+ const errDetails = await readUnexpectedResponseDetails(resp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
+ {
+ requestError: errDetails,
+ },
+ ),
+ };
+ }
+
+ if (resp.status === HttpStatusCode.Conflict) {
+ const err = await readTalerErrorResponse(resp);
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
+ ) {
+ // Do this in the background, as it might take some time
+ // FIXME: Why? We're already in a (background) task!
+ handleInsufficientFunds(wex, proposalId, err).catch(async (e) => {
+ logger.error("handling insufficient funds failed");
+ logger.error(`${e.toString()}`);
+ });
+
+ // FIXME: Should we really consider this to be pending?
+
+ return TaskRunResult.backoff();
+ }
+ }
+
+ if (resp.status >= 400 && resp.status <= 499) {
+ logger.trace("got generic 4xx from merchant");
+ const err = await readTalerErrorResponse(resp);
+ if (logger.shouldLogTrace()) {
+ logger.trace(`error body: ${j2s(err)}`);
+ }
+ throwUnexpectedRequestError(resp, err);
+ }
+
+ const merchantResp = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantPayResponse(),
+ );
+
+ logger.trace("got success from pay URL", merchantResp);
+
+ const merchantPub = download.contractData.merchantPub;
+ const { valid } = await wex.cryptoApi.isValidPaymentSignature({
+ contractHash: download.contractData.contractTermsHash,
+ merchantPub,
+ sig: merchantResp.sig,
+ });
+
+ if (!valid) {
+ logger.error("merchant payment signature invalid");
+ // FIXME: properly display error
+ throw Error("merchant payment signature invalid");
+ }
+
+ await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp);
+ } else {
+ const payAgainUrl = new URL(
+ `orders/${download.contractData.orderId}/paid`,
+ download.contractData.merchantBaseUrl,
+ ).href;
+ const reqBody = {
+ sig: purchase.merchantPaySig,
+ h_contract: download.contractData.contractTermsHash,
+ session_id: sessionId ?? "",
+ };
+ logger.trace(`/paid request body: ${j2s(reqBody)}`);
+ const resp = await wex.ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ wex.http.fetch(payAgainUrl, {
+ method: "POST",
+ body: reqBody,
+ cancellationToken: wex.cancellationToken,
+ }),
+ );
+ logger.trace(`/paid response status: ${resp.status}`);
+ if (
+ resp.status !== HttpStatusCode.NoContent &&
+ resp.status != HttpStatusCode.Ok
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
+ getHttpResponseErrorDetails(resp),
+ "/paid failed",
+ );
+ }
+ await storePayReplaySuccess(wex, proposalId, sessionId);
+ }
+
+ return TaskRunResult.progress();
+}
+
+export async function refuseProposal(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const proposal = await tx.purchases.get(proposalId);
+ if (!proposal) {
+ logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
+ return undefined;
+ }
+ if (
+ proposal.purchaseStatus !== PurchaseStatus.DialogProposed &&
+ proposal.purchaseStatus !== PurchaseStatus.DialogShared
+ ) {
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(proposal);
+ proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
+ const newTxState = computePayMerchantTransactionState(proposal);
+ await tx.purchases.put(proposal);
+ return { oldTxState, newTxState };
+ },
+ );
+
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+const transitionSuspend: {
+ [x in PurchaseStatus]?: {
+ next: PurchaseStatus | undefined;
+ };
+} = {
+ [PurchaseStatus.PendingDownloadingProposal]: {
+ next: PurchaseStatus.SuspendedDownloadingProposal,
+ },
+ [PurchaseStatus.AbortingWithRefund]: {
+ next: PurchaseStatus.SuspendedAbortingWithRefund,
+ },
+ [PurchaseStatus.PendingPaying]: {
+ next: PurchaseStatus.SuspendedPaying,
+ },
+ [PurchaseStatus.PendingPayingReplay]: {
+ next: PurchaseStatus.SuspendedPayingReplay,
+ },
+ [PurchaseStatus.PendingQueryingAutoRefund]: {
+ next: PurchaseStatus.SuspendedQueryingAutoRefund,
+ },
+};
+
+const transitionResume: {
+ [x in PurchaseStatus]?: {
+ next: PurchaseStatus | undefined;
+ };
+} = {
+ [PurchaseStatus.SuspendedDownloadingProposal]: {
+ next: PurchaseStatus.PendingDownloadingProposal,
+ },
+ [PurchaseStatus.SuspendedAbortingWithRefund]: {
+ next: PurchaseStatus.AbortingWithRefund,
+ },
+ [PurchaseStatus.SuspendedPaying]: {
+ next: PurchaseStatus.PendingPaying,
+ },
+ [PurchaseStatus.SuspendedPayingReplay]: {
+ next: PurchaseStatus.PendingPayingReplay,
+ },
+ [PurchaseStatus.SuspendedQueryingAutoRefund]: {
+ next: PurchaseStatus.PendingQueryingAutoRefund,
+ },
+};
+
+export function computePayMerchantTransactionState(
+ purchaseRecord: PurchaseRecord,
+): TransactionState {
+ switch (purchaseRecord.purchaseStatus) {
+ // Pending States
+ case PurchaseStatus.PendingDownloadingProposal:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.PendingPaying:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.SubmitPayment,
+ };
+ case PurchaseStatus.PendingPayingReplay:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.RebindSession,
+ };
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AutoRefund,
+ };
+ case PurchaseStatus.PendingQueryingRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CheckRefund,
+ };
+ case PurchaseStatus.PendingAcceptRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AcceptRefund,
+ };
+ // Suspended Pending States
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.SuspendedPaying:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.SubmitPayment,
+ };
+ case PurchaseStatus.SuspendedPayingReplay:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.RebindSession,
+ };
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AutoRefund,
+ };
+ case PurchaseStatus.SuspendedQueryingRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CheckRefund,
+ };
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AcceptRefund,
+ };
+ // Aborting States
+ case PurchaseStatus.AbortingWithRefund:
+ return {
+ major: TransactionMajorState.Aborting,
+ };
+ // Suspended Aborting States
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ };
+ // Dialog States
+ case PurchaseStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.MerchantOrderProposed,
+ };
+ case PurchaseStatus.DialogShared:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.MerchantOrderProposed,
+ };
+ // Final States
+ case PurchaseStatus.AbortedProposalRefused:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Refused,
+ };
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PurchaseStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PurchaseStatus.DoneRepurchaseDetected:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Repurchase,
+ };
+ case PurchaseStatus.AbortedIncompletePayment:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PurchaseStatus.FailedClaim:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.FailedAbort:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.AbortingBank,
+ };
+ case PurchaseStatus.FailedPaidByOther:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.PaidByOther,
+ };
+ default:
+ assertUnreachable(purchaseRecord.purchaseStatus);
+ }
+}
+
+export function computePayMerchantTransactionActions(
+ purchaseRecord: PurchaseRecord,
+): TransactionAction[] {
+ switch (purchaseRecord.purchaseStatus) {
+ // Pending States
+ case PurchaseStatus.PendingDownloadingProposal:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingPaying:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingPayingReplay:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingQueryingAutoRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingQueryingRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case PurchaseStatus.PendingAcceptRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ // Suspended Pending States
+ case PurchaseStatus.SuspendedDownloadingProposal:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPaying:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPayingReplay:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedQueryingAutoRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedQueryingRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PurchaseStatus.SuspendedPendingAcceptRefund:
+ // Special "abort" since it goes back to "done".
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ // Aborting States
+ case PurchaseStatus.AbortingWithRefund:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case PurchaseStatus.SuspendedAbortingWithRefund:
+ return [TransactionAction.Fail, TransactionAction.Resume];
+ // Dialog States
+ case PurchaseStatus.DialogProposed:
+ return [];
+ case PurchaseStatus.DialogShared:
+ return [];
+ // Final States
+ case PurchaseStatus.AbortedProposalRefused:
+ case PurchaseStatus.AbortedOrderDeleted:
+ case PurchaseStatus.AbortedRefunded:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.Done:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.DoneRepurchaseDetected:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.AbortedIncompletePayment:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedClaim:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedAbort:
+ return [TransactionAction.Delete];
+ case PurchaseStatus.FailedPaidByOther:
+ return [TransactionAction.Delete];
+ default:
+ assertUnreachable(purchaseRecord.purchaseStatus);
+ }
+}
+
+export async function sharePayment(
+ wex: WalletExecutionContext,
+ merchantBaseUrl: string,
+ orderId: string,
+): Promise<SharePaymentResult> {
+ const result = await wex.db.runReadWriteTx(["purchases"], async (tx) => {
+ const p = await tx.purchases.indexes.byUrlAndOrderId.get([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (
+ p.purchaseStatus !== PurchaseStatus.DialogProposed &&
+ p.purchaseStatus !== PurchaseStatus.DialogShared
+ ) {
+ // FIXME: purchase can be shared before being paid
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ if (p.purchaseStatus === PurchaseStatus.DialogProposed) {
+ p.purchaseStatus = PurchaseStatus.DialogShared;
+ p.shared = true;
+ await tx.purchases.put(p);
+ }
+
+ const newTxState = computePayMerchantTransactionState(p);
+
+ return {
+ proposalId: p.proposalId,
+ nonce: p.noncePriv,
+ session: p.lastSessionId ?? p.downloadSessionId,
+ token: p.claimToken,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
+ });
+
+ if (result === undefined) {
+ throw Error("This purchase can't be shared");
+ }
+
+ const ctx = new PayMerchantTransactionContext(wex, result.proposalId);
+
+ notifyTransition(wex, ctx.transactionId, result.transitionInfo);
+
+ // schedule a task to watch for the status
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ const privatePayUri = stringifyPayUri({
+ merchantBaseUrl,
+ orderId,
+ sessionId: result.session ?? "",
+ noncePriv: result.nonce,
+ claimToken: result.token,
+ });
+
+ return { privatePayUri };
+}
+
+async function checkIfOrderIsAlreadyPaid(
+ wex: WalletExecutionContext,
+ contract: WalletContractData,
+ doLongPolling: boolean,
+) {
+ const requestUrl = new URL(
+ `orders/${contract.orderId}`,
+ contract.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set("h_contract", contract.contractTermsHash);
+
+ if (doLongPolling) {
+ requestUrl.searchParams.set("timeout_ms", "30000");
+ }
+
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (
+ resp.status === HttpStatusCode.Ok ||
+ resp.status === HttpStatusCode.Accepted ||
+ resp.status === HttpStatusCode.Found
+ ) {
+ return true;
+ } else if (resp.status === HttpStatusCode.PaymentRequired) {
+ return false;
+ }
+ // forbidden, not found, not acceptable
+ throw Error(`this order cant be paid: ${resp.status}`);
+}
+
+async function processPurchaseDialogShared(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing dialog-shared for proposal ${proposalId}`);
+ const download = await expectProposalDownload(wex, purchase);
+
+ if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) {
+ return TaskRunResult.finished();
+ }
+
+ const paid = await checkIfOrderIsAlreadyPaid(
+ wex,
+ download.contractData,
+ true,
+ );
+ if (paid) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.FailedPaidByOther;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ return TaskRunResult.backoff();
+}
+
+async function processPurchaseAutoRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing auto-refund for proposal ${proposalId}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ const download = await expectProposalDownload(wex, purchase);
+
+ const noAutoRefundOrExpired = !purchase.autoRefundDeadline ||
+ AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(purchase.autoRefundDeadline),
+ ),
+ )
+
+ const totalKnownRefund = await wex.db.runReadOnlyTx(
+ ["refundGroups"],
+ async (tx) => {
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+ const am = Amounts.parseOrThrow(download.contractData.amount);
+ return refunds.reduce((prev, cur) => {
+ if (
+ cur.status === RefundGroupStatus.Done ||
+ cur.status === RefundGroupStatus.Pending
+ ) {
+ return Amounts.add(prev, cur.amountEffective).amount;
+ }
+ return prev;
+ }, Amounts.zeroOfAmount(am));
+ },
+ );
+
+ const refundedIsLessThanPrice = Amounts.cmp(download.contractData.amount, totalKnownRefund) === +1
+ const nothingMoreToRefund = !refundedIsLessThanPrice
+
+ if (noAutoRefundOrExpired || nothingMoreToRefund) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.finished();
+ }
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
+
+ requestUrl.searchParams.set("timeout_ms", "10000");
+ requestUrl.searchParams.set("await_refund_obtained", "yes");
+ requestUrl.searchParams.set("refund", Amounts.stringify(totalKnownRefund));
+
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
+
+ // FIXME: Check other status codes!
+
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+
+ if (orderStatus.refund_pending) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
+
+ return TaskRunResult.longpollReturnedPending();
+}
+
+async function processPurchaseAbortingRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ const download = await expectProposalDownload(wex, purchase);
+ logger.trace(`processing aborting-refund for proposal ${proposalId}`);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/abort`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ const abortingCoins: AbortingCoin[] = [];
+
+ const payCoinSelection = purchase.payInfo?.payCoinSelection;
+ if (!payCoinSelection) {
+ throw Error("can't abort, no coins selected");
+ }
+
+ await wex.db.runReadOnlyTx(["coins"], async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coin = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coin, "expected coin to be present");
+ abortingCoins.push({
+ coin_pub: coinPub,
+ contribution: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ exchange_url: coin.exchangeBaseUrl,
+ });
+ }
+ });
+
+ const abortReq: AbortRequest = {
+ h_contract: download.contractData.contractTermsHash,
+ coins: abortingCoins,
+ };
+
+ logger.trace(`making order abort request to ${requestUrl.href}`);
+
+ const abortHttpResp = await wex.http.fetch(requestUrl.href, {
+ method: "POST",
+ body: abortReq,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (abortHttpResp.status === HttpStatusCode.NotFound) {
+ const err = await readTalerErrorResponse(abortHttpResp);
+ if (
+ err.code ===
+ TalerErrorCode.MERCHANT_POST_ORDERS_ID_ABORT_CONTRACT_NOT_FOUND
+ ) {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ await ctx.transition(async (rec) => {
+ if (rec.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
+ rec.purchaseStatus = PurchaseStatus.AbortedOrderDeleted;
+ return TransitionResultType.Transition;
+ }
+ return TransitionResultType.Stay;
+ });
+ }
+ }
+
+ const abortResp = await readSuccessResponseJsonOrThrow(
+ abortHttpResp,
+ codecForAbortResponse(),
+ );
+
+ const refunds: MerchantCoinRefundStatus[] = [];
+
+ if (abortResp.refunds.length != abortingCoins.length) {
+ // FIXME: define error code!
+ throw Error("invalid order abort response");
+ }
+
+ for (let i = 0; i < abortResp.refunds.length; i++) {
+ const r = abortResp.refunds[i];
+ refunds.push({
+ ...r,
+ coin_pub: payCoinSelection.coinPubs[i],
+ refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ rtransaction_id: 0,
+ execution_time: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.fromProtocolTimestamp(download.contractData.timestamp),
+ Duration.fromSpec({ seconds: 1 }),
+ ),
+ ),
+ });
+ }
+ return await storeRefunds(wex, purchase, refunds, RefundReason.AbortRefund);
+}
+
+async function processPurchaseQueryRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing query-refund for proposal ${proposalId}`);
+
+ const download = await expectProposalDownload(wex, purchase);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
+
+ const resp = await wex.http.fetch(requestUrl.href, {
+ cancellationToken: wex.cancellationToken,
+ });
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
+ if (!orderStatus.refund_pending) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ p.refundAmountAwaiting = undefined;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else {
+ const refundAwaiting = Amounts.sub(
+ Amounts.parseOrThrow(orderStatus.refund_amount),
+ Amounts.parseOrThrow(orderStatus.refund_taken),
+ ).amount;
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.PendingQueryingRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.refundAmountAwaiting = Amounts.stringify(refundAwaiting);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
+}
+
+async function processPurchaseAcceptRefund(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+): Promise<TaskRunResult> {
+ const download = await expectProposalDownload(wex, purchase);
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/refund`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ logger.trace(`making refund request to ${requestUrl.href}`);
+
+ const request = await wex.http.fetch(requestUrl.href, {
+ method: "POST",
+ body: {
+ h_contract: download.contractData.contractTermsHash,
+ },
+ cancellationToken: wex.cancellationToken,
+ });
+
+ const refundResponse = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForWalletRefundResponse(),
+ );
+ return await storeRefunds(
+ wex,
+ purchase,
+ refundResponse.refunds,
+ RefundReason.AbortRefund,
+ );
+}
+
+export async function startRefundQueryForUri(
+ wex: WalletExecutionContext,
+ talerUri: string,
+): Promise<StartRefundQueryForUriResponse> {
+ const parsedUri = parseTalerUri(talerUri);
+ if (!parsedUri) {
+ throw Error("invalid taler:// URI");
+ }
+ if (parsedUri.type !== TalerUriAction.Refund) {
+ throw Error("expected taler://refund URI");
+ }
+ const purchaseRecord = await wex.db.runReadOnlyTx(
+ ["purchases"],
+ async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.get([
+ parsedUri.merchantBaseUrl,
+ parsedUri.orderId,
+ ]);
+ },
+ );
+ if (!purchaseRecord) {
+ logger.error(
+ `no purchase for order ID "${parsedUri.orderId}" from merchant "${parsedUri.merchantBaseUrl}" when processing "${talerUri}"`,
+ );
+ throw Error("no purchase found, can't refund");
+ }
+ const proposalId = purchaseRecord.proposalId;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+ await startQueryRefund(wex, proposalId);
+ return {
+ transactionId,
+ };
+}
+
+export async function startQueryRefund(
+ wex: WalletExecutionContext,
+ proposalId: string,
+): Promise<void> {
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["purchases"],
+ async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ logger.warn(`purchase ${proposalId} does not exist anymore`);
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.Done) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingQueryingRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+}
+
+async function computeRefreshRequest(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<["coins", "denominations"]>,
+ items: RefundItemRecord[],
+): Promise<CoinRefreshRequest[]> {
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const item of items) {
+ const coin = await tx.coins.get(item.coinPub);
+ if (!coin) {
+ throw Error("coin not found");
+ }
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error("denom not found");
+ }
+ if (item.status === RefundItemStatus.Done) {
+ const refundedAmount = Amounts.sub(
+ item.refundAmount,
+ denomInfo.feeRefund,
+ ).amount;
+ refreshCoins.push({
+ amount: Amounts.stringify(refundedAmount),
+ coinPub: item.coinPub,
+ });
+ }
+ }
+ return refreshCoins;
+}
+
+/**
+ * Compute the refund item status based on the merchant's response.
+ */
+function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus {
+ if (rf.type === "success") {
+ return RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ return RefundItemStatus.Pending;
+ } else {
+ return RefundItemStatus.Failed;
+ }
+ }
+}
+
+/**
+ * Store refunds, possibly creating a new refund group.
+ */
+async function storeRefunds(
+ wex: WalletExecutionContext,
+ purchase: PurchaseRecord,
+ refunds: MerchantCoinRefundStatus[],
+ reason: RefundReason,
+): Promise<TaskRunResult> {
+ logger.info(`storing refunds: ${j2s(refunds)}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: purchase.proposalId,
+ });
+
+ const newRefundGroupId = encodeCrock(randomBytes(32));
+ const now = TalerPreciseTimestamp.now();
+
+ const download = await expectProposalDownload(wex, purchase);
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ const result = await wex.db.runReadWriteTx(
+ [
+ "coins",
+ "denominations",
+ "purchases",
+ "refundItems",
+ "refundGroups",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ async (tx) => {
+ const myPurchase = await tx.purchases.get(purchase.proposalId);
+ if (!myPurchase) {
+ logger.warn("purchase group not found anymore");
+ return;
+ }
+ let isAborting: boolean;
+ switch (myPurchase.purchaseStatus) {
+ case PurchaseStatus.PendingAcceptRefund:
+ isAborting = false;
+ break;
+ case PurchaseStatus.AbortingWithRefund:
+ isAborting = true;
+ break;
+ default:
+ logger.warn("wrong state, not accepting refund");
+ return;
+ }
+
+ let newGroup: RefundGroupRecord | undefined = undefined;
+ // Pending, but not part of an aborted refund group.
+ let numPendingItemsTotal = 0;
+ const newGroupRefunds: RefundItemRecord[] = [];
+
+ for (const rf of refunds) {
+ const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([
+ rf.coin_pub,
+ rf.rtransaction_id,
+ ]);
+ if (oldItem) {
+ logger.info("already have refund in database");
+ if (oldItem.status === RefundItemStatus.Done) {
+ continue;
+ }
+ if (rf.type === "success") {
+ oldItem.status = RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ oldItem.status = RefundItemStatus.Pending;
+ numPendingItemsTotal += 1;
+ } else {
+ oldItem.status = RefundItemStatus.Failed;
+ }
+ }
+ await tx.refundItems.put(oldItem);
+ } else {
+ // Put refund item into a new group!
+ if (!newGroup) {
+ newGroup = {
+ proposalId: purchase.proposalId,
+ refundGroupId: newRefundGroupId,
+ status: RefundGroupStatus.Pending,
+ timestampCreated: timestampPreciseToDb(now),
+ amountEffective: Amounts.stringify(
+ Amounts.zeroOfCurrency(currency),
+ ),
+ amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
+ };
+ }
+ const status: RefundItemStatus = getItemStatus(rf);
+ const newItem: RefundItemRecord = {
+ coinPub: rf.coin_pub,
+ executionTime: timestampProtocolToDb(rf.execution_time),
+ obtainedTime: timestampPreciseToDb(now),
+ refundAmount: rf.refund_amount,
+ refundGroupId: newGroup.refundGroupId,
+ rtxid: rf.rtransaction_id,
+ status,
+ };
+ if (status === RefundItemStatus.Pending) {
+ numPendingItemsTotal += 1;
+ }
+ newGroupRefunds.push(newItem);
+ await tx.refundItems.put(newItem);
+ }
+ }
+
+ // Now that we know all the refunds for the new refund group,
+ // we can compute the raw/effective amounts.
+ if (newGroup) {
+ const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
+ const refreshCoins = await computeRefreshRequest(
+ wex,
+ tx,
+ newGroupRefunds,
+ );
+ const outInfo = await calculateRefreshOutput(
+ wex,
+ tx,
+ currency,
+ refreshCoins,
+ );
+ newGroup.amountEffective = Amounts.stringify(
+ Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
+ );
+ newGroup.amountRaw = Amounts.stringify(
+ Amounts.sumOrZero(currency, amountsRaw).amount,
+ );
+ await tx.refundGroups.put(newGroup);
+ }
+
+ const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll(
+ myPurchase.proposalId,
+ );
+
+ for (const refundGroup of refundGroups) {
+ switch (refundGroup.status) {
+ case RefundGroupStatus.Aborted:
+ case RefundGroupStatus.Expired:
+ case RefundGroupStatus.Failed:
+ case RefundGroupStatus.Done:
+ continue;
+ case RefundGroupStatus.Pending:
+ break;
+ default:
+ assertUnreachable(refundGroup.status);
+ }
+ const items = await tx.refundItems.indexes.byRefundGroupId.getAll([
+ refundGroup.refundGroupId,
+ ]);
+ let numPending = 0;
+ let numFailed = 0;
+ for (const item of items) {
+ if (item.status === RefundItemStatus.Pending) {
+ numPending++;
+ }
+ if (item.status === RefundItemStatus.Failed) {
+ numFailed++;
+ }
+ }
+ if (numPending === 0) {
+ // We're done for this refund group!
+ if (numFailed === 0) {
+ refundGroup.status = RefundGroupStatus.Done;
+ } else {
+ refundGroup.status = RefundGroupStatus.Failed;
+ }
+ await tx.refundGroups.put(refundGroup);
+ const refreshCoins = await computeRefreshRequest(wex, tx, items);
+ await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(download.contractData.amount),
+ refreshCoins,
+ RefreshReason.Refund,
+ // Since refunds are really just pseudo-transactions,
+ // the originating transaction for the refresh is the payment transaction.
+ constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: myPurchase.proposalId,
+ }),
+ );
+ }
+ }
+
+ const oldTxState = computePayMerchantTransactionState(myPurchase);
+
+ const shouldCheckAutoRefund =
+ myPurchase.autoRefundDeadline &&
+ !AbsoluteTime.isExpired(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(myPurchase.autoRefundDeadline),
+ ),
+ );
+
+ if (numPendingItemsTotal === 0) {
+ if (isAborting) {
+ myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded;
+ } else if (shouldCheckAutoRefund) {
+ myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund;
+ } else {
+ myPurchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ myPurchase.refundAmountAwaiting = undefined;
+ }
+ await tx.purchases.put(myPurchase);
+ const newTxState = computePayMerchantTransactionState(myPurchase);
+
+ return {
+ numPendingItemsTotal,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
+ },
+ );
+
+ if (!result) {
+ return TaskRunResult.finished();
+ }
+
+ notifyTransition(wex, transactionId, result.transitionInfo);
+
+ if (result.numPendingItemsTotal > 0) {
+ return TaskRunResult.backoff();
+ } else {
+ return TaskRunResult.progress();
+ }
+}
+
+export function computeRefundTransactionState(
+ refundGroupRecord: RefundGroupRecord,
+): TransactionState {
+ switch (refundGroupRecord.status) {
+ case RefundGroupStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case RefundGroupStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case RefundGroupStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case RefundGroupStatus.Pending:
+ return {
+ major: TransactionMajorState.Pending,
+ };
+ case RefundGroupStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts
new file mode 100644
index 000000000..0bb290440
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-common.ts
@@ -0,0 +1,157 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AmountJson,
+ AmountString,
+ Amounts,
+ Codec,
+ SelectedProspectiveCoin,
+ TalerProtocolTimestamp,
+ buildCodecForObject,
+ checkDbInvariant,
+ codecForAmountString,
+ codecForTimestamp,
+ codecOptional,
+} from "@gnu-taler/taler-util";
+import { SpendCoinDetails } from "./crypto/cryptoImplementation.js";
+import { DbPeerPushPaymentCoinSelection, ReserveRecord } from "./db.js";
+import { getTotalRefreshCost } from "./refresh.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+
+/**
+ * Get information about the coin selected for signatures.
+ */
+export async function queryCoinInfosForSelection(
+ wex: WalletExecutionContext,
+ csel: DbPeerPushPaymentCoinSelection,
+): Promise<SpendCoinDetails[]> {
+ let infos: SpendCoinDetails[] = [];
+ await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ for (let i = 0; i < csel.coinPubs.length; i++) {
+ const coin = await tx.coins.get(csel.coinPubs[i]);
+ if (!coin) {
+ throw Error("coin not found anymore");
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denom) {
+ throw Error("denom for coin not found anymore");
+ }
+ infos.push({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ contribution: csel.contributions[i],
+ });
+ }
+ });
+ return infos;
+}
+
+export async function getTotalPeerPaymentCost(
+ wex: WalletExecutionContext,
+ pcs: SelectedProspectiveCoin[],
+): Promise<AmountJson> {
+ return wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ const costs: AmountJson[] = [];
+ for (let i = 0; i < pcs.length; i++) {
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ pcs[i].exchangeBaseUrl,
+ pcs[i].denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error(
+ "can't calculate payment cost, denomination for coin not found",
+ );
+ }
+ const amountLeft = Amounts.sub(
+ denomInfo.value,
+ pcs[i].contribution,
+ ).amount;
+ const refreshCost = await getTotalRefreshCost(
+ wex,
+ tx,
+ denomInfo,
+ amountLeft,
+ );
+ costs.push(Amounts.parseOrThrow(pcs[i].contribution));
+ costs.push(refreshCost);
+ }
+ const zero = Amounts.zeroOfAmount(pcs[0].contribution);
+ return Amounts.sum([zero, ...costs]).amount;
+ });
+}
+
+interface ExchangePurseStatus {
+ balance: AmountString;
+ deposit_timestamp?: TalerProtocolTimestamp;
+ merge_timestamp?: TalerProtocolTimestamp;
+}
+
+export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
+ buildCodecForObject<ExchangePurseStatus>()
+ .property("balance", codecForAmountString())
+ .property("deposit_timestamp", codecOptional(codecForTimestamp))
+ .property("merge_timestamp", codecOptional(codecForTimestamp))
+ .build("ExchangePurseStatus");
+
+export async function getMergeReserveInfo(
+ wex: WalletExecutionContext,
+ req: {
+ exchangeBaseUrl: string;
+ },
+): Promise<ReserveRecord> {
+ // We have to eagerly create the key pair outside of the transaction,
+ // due to the async crypto API.
+ const newReservePair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const mergeReserveRecord: ReserveRecord = await wex.db.runReadWriteTx(
+ ["exchanges", "reserves"],
+ async (tx) => {
+ const ex = await tx.exchanges.get(req.exchangeBaseUrl);
+ checkDbInvariant(!!ex);
+ if (ex.currentMergeReserveRowId != null) {
+ const reserve = await tx.reserves.get(ex.currentMergeReserveRowId);
+ checkDbInvariant(!!reserve);
+ return reserve;
+ }
+ const reserve: ReserveRecord = {
+ reservePriv: newReservePair.priv,
+ reservePub: newReservePair.pub,
+ };
+ const insertResp = await tx.reserves.put(reserve);
+ checkDbInvariant(typeof insertResp.key === "number");
+ reserve.rowId = insertResp.key;
+ ex.currentMergeReserveRowId = reserve.rowId;
+ await tx.exchanges.put(ex);
+ return reserve;
+ },
+ );
+
+ return mergeReserveRecord;
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
new file mode 100644
index 000000000..4155f83e6
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
@@ -0,0 +1,1201 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ CheckPeerPullCreditRequest,
+ CheckPeerPullCreditResponse,
+ ContractTermsUtil,
+ ExchangeReservePurseRequest,
+ HttpStatusCode,
+ InitiatePeerPullCreditRequest,
+ InitiatePeerPullCreditResponse,
+ Logger,
+ NotificationType,
+ PeerContractTerms,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TalerUriAction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ WalletAccountMergeFlags,
+ WalletKycUuid,
+ assertUnreachable,
+ checkDbInvariant,
+ codecForAny,
+ codecForWalletKycUuid,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ stringifyTalerUri,
+ talerPaytoFromExchangeReserve,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
+ KycPendingInfo,
+ KycUserType,
+ PeerPullCreditRecord,
+ PeerPullPaymentCreditStatus,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampOptionalPreciseFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+} from "./db.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ codecForExchangePurseStatus,
+ getMergeReserveInfo,
+} from "./pay-peer-common.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+import {
+ getExchangeWithdrawalInfo,
+ internalCreateWithdrawalGroup,
+ waitWithdrawalFinal,
+} from "./withdraw.js";
+
+const logger = new Logger("pay-peer-pull-credit.ts");
+
+export class PeerPullCreditTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public pursePub: string,
+ ) {
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub,
+ });
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex: ws, pursePub } = this;
+ await ws.db.runReadWriteTx(
+ ["withdrawalGroups", "peerPullCredit", "tombstones"],
+ async (tx) => {
+ const pullIni = await tx.peerPullCredit.get(pursePub);
+ if (!pullIni) {
+ return;
+ }
+ if (pullIni.withdrawalGroupId) {
+ const withdrawalGroupId = pullIni.withdrawalGroupId;
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ }
+ }
+ await tx.peerPullCredit.delete(pursePub);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub,
+ });
+ },
+ );
+
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedCreatePurse;
+ break;
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedMergeKycRequired;
+ break;
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedWithdrawing;
+ break;
+ case PeerPullPaymentCreditStatus.PendingReady:
+ newStatus = PeerPullPaymentCreditStatus.SuspendedReady;
+ break;
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ newStatus =
+ PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ case PeerPullPaymentCreditStatus.PendingReady:
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ break;
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPullPaymentCreditStatus.Failed;
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ case PeerPullPaymentCreditStatus.PendingReady:
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.Aborted:
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ newStatus = PeerPullPaymentCreditStatus.PendingCreatePurse;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ newStatus = PeerPullPaymentCreditStatus.PendingReady;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPullPaymentCreditStatus.PendingWithdrawing;
+ break;
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, pursePub, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const pullCreditRec = await tx.peerPullCredit.get(pursePub);
+ if (!pullCreditRec) {
+ logger.warn(`peer pull credit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
+ switch (pullCreditRec.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ throw Error("can't abort anymore");
+ case PeerPullPaymentCreditStatus.PendingReady:
+ newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
+ break;
+ case PeerPullPaymentCreditStatus.Done:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ break;
+ default:
+ assertUnreachable(pullCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ pullCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPullCreditTransactionState(pullCreditRec);
+ await tx.peerPullCredit.put(pullCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+async function queryPurseForPeerPullCredit(
+ wex: WalletExecutionContext,
+ pullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ const purseDepositUrl = new URL(
+ `purses/${pullIni.pursePub}/deposit`,
+ pullIni.exchangeBaseUrl,
+ );
+ purseDepositUrl.searchParams.set("timeout_ms", "30000");
+ logger.info(`querying purse status via ${purseDepositUrl.href}`);
+ const resp = await wex.http.fetch(purseDepositUrl.href, {
+ timeout: { d_ms: 60000 },
+ cancellationToken: wex.cancellationToken,
+ });
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullIni.pursePub,
+ });
+
+ logger.info(`purse status code: HTTP ${resp.status}`);
+
+ switch (resp.status) {
+ case HttpStatusCode.Gone: {
+ // Exchange says that purse doesn't exist anymore => expired!
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
+ if (!finPi) {
+ logger.warn("peerPullCredit not found anymore");
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(finPi);
+ if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
+ finPi.status = PeerPullPaymentCreditStatus.Expired;
+ }
+ await tx.peerPullCredit.put(finPi);
+ const newTxState = computePeerPullCreditTransactionState(finPi);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+ }
+ case HttpStatusCode.NotFound:
+ // FIXME: Maybe check error code? 404 could also mean something else.
+ return TaskRunResult.longpollReturnedPending();
+ }
+
+ const result = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangePurseStatus(),
+ );
+
+ logger.trace(`purse status: ${j2s(result)}`);
+
+ const depositTimestamp = result.deposit_timestamp;
+
+ if (!depositTimestamp || TalerProtocolTimestamp.isNever(depositTimestamp)) {
+ logger.info("purse not ready yet (no deposit)");
+ return TaskRunResult.backoff();
+ }
+
+ const reserve = await wex.db.runReadOnlyTx(["reserves"], async (tx) => {
+ return await tx.reserves.get(pullIni.mergeReserveRowId);
+ });
+
+ if (!reserve) {
+ throw Error("reserve for peer pull credit not found in wallet DB");
+ }
+
+ await internalCreateWithdrawalGroup(wex, {
+ amount: Amounts.parseOrThrow(pullIni.amount),
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.PeerPullCredit,
+ contractPriv: pullIni.contractPriv,
+ },
+ forcedWithdrawalGroupId: pullIni.withdrawalGroupId,
+ exchangeBaseUrl: pullIni.exchangeBaseUrl,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair: {
+ priv: reserve.reservePriv,
+ pub: reserve.reservePub,
+ },
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
+ if (!finPi) {
+ logger.warn("peerPullCredit not found anymore");
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(finPi);
+ if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
+ finPi.status = PeerPullPaymentCreditStatus.PendingWithdrawing;
+ }
+ await tx.peerPullCredit.put(finPi);
+ const newTxState = computePeerPullCreditTransactionState(finPi);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+}
+
+async function longpollKycStatus(
+ wex: WalletExecutionContext,
+ pursePub: string,
+ exchangeUrl: string,
+ kycInfo: KycPendingInfo,
+ userType: KycUserType,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub,
+ });
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "10000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const peerIni = await tx.peerPullCredit.get(pursePub);
+ if (!peerIni) {
+ return;
+ }
+ if (
+ peerIni.status !== PeerPullPaymentCreditStatus.PendingMergeKycRequired
+ ) {
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(peerIni);
+ peerIni.status = PeerPullPaymentCreditStatus.PendingCreatePurse;
+ const newTxState = computePeerPullCreditTransactionState(peerIni);
+ await tx.peerPullCredit.put(peerIni);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ return TaskRunResult.longpollReturnedPending();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+async function processPeerPullCreditAbortingDeletePurse(
+ wex: WalletExecutionContext,
+ peerPullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ const { pursePub, pursePriv } = peerPullIni;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub,
+ });
+
+ const sigResp = await wex.cryptoApi.signDeletePurse({
+ pursePriv,
+ });
+ const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl);
+ const resp = await wex.http.fetch(purseUrl.href, {
+ method: "DELETE",
+ headers: {
+ "taler-purse-signature": sigResp.sig,
+ },
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`deleted purse with response status ${resp.status}`);
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ [
+ "peerPullCredit",
+ "refreshGroups",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ async (tx) => {
+ const ppiRec = await tx.peerPullCredit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse) {
+ return undefined;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(ppiRec);
+ ppiRec.status = PeerPullPaymentCreditStatus.Aborted;
+ await tx.peerPullCredit.put(ppiRec);
+ const newTxState = computePeerPullCreditTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return TaskRunResult.backoff();
+}
+
+async function handlePeerPullCreditWithdrawing(
+ wex: WalletExecutionContext,
+ pullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ if (!pullIni.withdrawalGroupId) {
+ throw Error("invalid db state (withdrawing, but no withdrawal group ID");
+ }
+ await waitWithdrawalFinal(wex, pullIni.withdrawalGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullIni.pursePub,
+ });
+ const wgId = pullIni.withdrawalGroupId;
+ let finished: boolean = false;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullCredit", "withdrawalGroups"],
+ async (tx) => {
+ const ppi = await tx.peerPullCredit.get(pullIni.pursePub);
+ if (!ppi) {
+ finished = true;
+ return;
+ }
+ if (ppi.status !== PeerPullPaymentCreditStatus.PendingWithdrawing) {
+ finished = true;
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(ppi);
+ const wg = await tx.withdrawalGroups.get(wgId);
+ if (!wg) {
+ // FIXME: Fail the operation instead?
+ return undefined;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.Done:
+ finished = true;
+ ppi.status = PeerPullPaymentCreditStatus.Done;
+ break;
+ // FIXME: Also handle other final states!
+ }
+ await tx.peerPullCredit.put(ppi);
+ const newTxState = computePeerPullCreditTransactionState(ppi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ if (finished) {
+ return TaskRunResult.finished();
+ } else {
+ // FIXME: Return indicator that we depend on the other operation!
+ return TaskRunResult.backoff();
+ }
+}
+
+async function handlePeerPullCreditCreatePurse(
+ wex: WalletExecutionContext,
+ pullIni: PeerPullCreditRecord,
+): Promise<TaskRunResult> {
+ const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
+ const pursePub = pullIni.pursePub;
+ const mergeReserve = await wex.db.runReadOnlyTx(["reserves"], async (tx) => {
+ return tx.reserves.get(pullIni.mergeReserveRowId);
+ });
+
+ if (!mergeReserve) {
+ throw Error("merge reserve for peer pull payment not found in database");
+ }
+
+ const contractTermsRecord = await wex.db.runReadOnlyTx(
+ ["contractTerms"],
+ async (tx) => {
+ return tx.contractTerms.get(pullIni.contractTermsHash);
+ },
+ );
+
+ if (!contractTermsRecord) {
+ throw Error("contract terms for peer pull payment not found in database");
+ }
+
+ const contractTerms: PeerContractTerms = contractTermsRecord.contractTermsRaw;
+
+ const reservePayto = talerPaytoFromExchangeReserve(
+ pullIni.exchangeBaseUrl,
+ mergeReserve.reservePub,
+ );
+
+ const econtractResp = await wex.cryptoApi.encryptContractForDeposit({
+ contractPriv: pullIni.contractPriv,
+ contractPub: pullIni.contractPub,
+ contractTerms: contractTermsRecord.contractTermsRaw,
+ pursePriv: pullIni.pursePriv,
+ pursePub: pullIni.pursePub,
+ nonce: pullIni.contractEncNonce,
+ });
+
+ const mergeTimestamp = timestampPreciseFromDb(pullIni.mergeTimestamp);
+
+ const purseExpiration = contractTerms.purse_expiration;
+ const sigRes = await wex.cryptoApi.signReservePurseCreate({
+ contractTermsHash: pullIni.contractTermsHash,
+ flags: WalletAccountMergeFlags.CreateWithPurseFee,
+ mergePriv: pullIni.mergePriv,
+ mergeTimestamp: TalerPreciseTimestamp.round(mergeTimestamp),
+ purseAmount: pullIni.amount,
+ purseExpiration: purseExpiration,
+ purseFee: purseFee,
+ pursePriv: pullIni.pursePriv,
+ pursePub: pullIni.pursePub,
+ reservePayto,
+ reservePriv: mergeReserve.reservePriv,
+ });
+
+ const reservePurseReqBody: ExchangeReservePurseRequest = {
+ merge_sig: sigRes.mergeSig,
+ merge_timestamp: TalerPreciseTimestamp.round(mergeTimestamp),
+ h_contract_terms: pullIni.contractTermsHash,
+ merge_pub: pullIni.mergePub,
+ min_age: 0,
+ purse_expiration: purseExpiration,
+ purse_fee: purseFee,
+ purse_pub: pullIni.pursePub,
+ purse_sig: sigRes.purseSig,
+ purse_value: pullIni.amount,
+ reserve_sig: sigRes.accountSig,
+ econtract: econtractResp.econtract,
+ };
+
+ logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
+
+ const reservePurseMergeUrl = new URL(
+ `reserves/${mergeReserve.reservePub}/purse`,
+ pullIni.exchangeBaseUrl,
+ );
+
+ const httpResp = await wex.http.fetch(reservePurseMergeUrl.href, {
+ method: "POST",
+ body: reservePurseReqBody,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ const respJson = await httpResp.json();
+ const kycPending = codecForWalletKycUuid().decode(respJson);
+ logger.info(`kyc uuid response: ${j2s(kycPending)}`);
+ return processPeerPullCreditKycRequired(wex, pullIni, kycPending);
+ }
+
+ const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
+
+ logger.info(`reserve merge response: ${j2s(resp)}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullIni.pursePub,
+ });
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const pi2 = await tx.peerPullCredit.get(pursePub);
+ if (!pi2) {
+ return;
+ }
+ const oldTxState = computePeerPullCreditTransactionState(pi2);
+ pi2.status = PeerPullPaymentCreditStatus.PendingReady;
+ await tx.peerPullCredit.put(pi2);
+ const newTxState = computePeerPullCreditTransactionState(pi2);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+}
+
+export async function processPeerPullCredit(
+ wex: WalletExecutionContext,
+ pursePub: string,
+): Promise<TaskRunResult> {
+ const pullIni = await wex.db.runReadOnlyTx(["peerPullCredit"], async (tx) => {
+ return tx.peerPullCredit.get(pursePub);
+ });
+ if (!pullIni) {
+ throw Error("peer pull payment initiation not found in database");
+ }
+
+ const retryTag = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub,
+ });
+
+ logger.trace(`processing ${retryTag}, status=${pullIni.status}`);
+
+ switch (pullIni.status) {
+ case PeerPullPaymentCreditStatus.Done: {
+ return TaskRunResult.finished();
+ }
+ case PeerPullPaymentCreditStatus.PendingReady:
+ return queryPurseForPeerPullCredit(wex, pullIni);
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired: {
+ if (!pullIni.kycInfo) {
+ throw Error("invalid state, kycInfo required");
+ }
+ return await longpollKycStatus(
+ wex,
+ pursePub,
+ pullIni.exchangeBaseUrl,
+ pullIni.kycInfo,
+ "individual",
+ );
+ }
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ return handlePeerPullCreditCreatePurse(wex, pullIni);
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ return await processPeerPullCreditAbortingDeletePurse(wex, pullIni);
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ return handlePeerPullCreditWithdrawing(wex, pullIni);
+ case PeerPullPaymentCreditStatus.Aborted:
+ case PeerPullPaymentCreditStatus.Failed:
+ case PeerPullPaymentCreditStatus.Expired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ break;
+ default:
+ assertUnreachable(pullIni.status);
+ }
+
+ return TaskRunResult.finished();
+}
+
+async function processPeerPullCreditKycRequired(
+ wex: WalletExecutionContext,
+ peerIni: PeerPullCreditRecord,
+ kycPending: WalletKycUuid,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: peerIni.pursePub,
+ });
+ const { pursePub } = peerIni;
+
+ const userType = "individual";
+ const url = new URL(
+ `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
+ peerIni.exchangeBaseUrl,
+ );
+
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return TaskRunResult.backoff();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const { transitionInfo, result } = await wex.db.runReadWriteTx(
+ ["peerPullCredit"],
+ async (tx) => {
+ const peerInc = await tx.peerPullCredit.get(pursePub);
+ if (!peerInc) {
+ return {
+ transitionInfo: undefined,
+ result: TaskRunResult.finished(),
+ };
+ }
+ const oldTxState = computePeerPullCreditTransactionState(peerInc);
+ peerInc.kycInfo = {
+ paytoHash: kycPending.h_payto,
+ requirementRow: kycPending.requirement_row,
+ };
+ peerInc.kycUrl = kycStatus.kyc_url;
+ peerInc.status = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
+ const newTxState = computePeerPullCreditTransactionState(peerInc);
+ await tx.peerPullCredit.put(peerInc);
+ // We'll remove this eventually! New clients should rely on the
+ // kycUrl field of the transaction, not the error code.
+ const res: TaskRunResult = {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
+ {
+ kycUrl: kycStatus.kyc_url,
+ },
+ ),
+ };
+ return {
+ transitionInfo: { oldTxState, newTxState },
+ result: res,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+/**
+ * Check fees and available exchanges for a peer push payment initiation.
+ */
+export async function checkPeerPullPaymentInitiation(
+ wex: WalletExecutionContext,
+ req: CheckPeerPullCreditRequest,
+): Promise<CheckPeerPullCreditResponse> {
+ // FIXME: We don't support exchanges with purse fees yet.
+ // Select an exchange where we have money in the specified currency
+ // FIXME: How do we handle regional currency scopes here? Is it an additional input?
+
+ logger.trace("checking peer-pull-credit fees");
+
+ const currency = Amounts.currencyOf(req.amount);
+ let exchangeUrl;
+ if (req.exchangeBaseUrl) {
+ exchangeUrl = req.exchangeBaseUrl;
+ } else {
+ exchangeUrl = await getPreferredExchangeForCurrency(wex, currency);
+ }
+
+ if (!exchangeUrl) {
+ throw Error("no exchange found for initiating a peer pull payment");
+ }
+
+ logger.trace(`found ${exchangeUrl} as preferred exchange`);
+
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ exchangeUrl,
+ Amounts.parseOrThrow(req.amount),
+ undefined,
+ );
+
+ logger.trace(`got withdrawal info`);
+
+ let numCoins = 0;
+ for (let i = 0; i < wi.selectedDenoms.selectedDenoms.length; i++) {
+ numCoins += wi.selectedDenoms.selectedDenoms[i].count;
+ }
+
+ return {
+ exchangeBaseUrl: exchangeUrl,
+ amountEffective: wi.withdrawalAmountEffective,
+ amountRaw: req.amount,
+ numCoins,
+ };
+}
+
+/**
+ * Find a preferred exchange based on when we withdrew last from this exchange.
+ */
+async function getPreferredExchangeForCurrency(
+ wex: WalletExecutionContext,
+ currency: string,
+): Promise<string | undefined> {
+ // Find an exchange with the matching currency.
+ // Prefer exchanges with the most recent withdrawal.
+ const url = await wex.db.runReadOnlyTx(["exchanges"], async (tx) => {
+ const exchanges = await tx.exchanges.iter().toArray();
+ let candidate = undefined;
+ for (const e of exchanges) {
+ if (e.detailsPointer?.currency !== currency) {
+ continue;
+ }
+ if (!candidate) {
+ candidate = e;
+ continue;
+ }
+ if (candidate.lastWithdrawal && !e.lastWithdrawal) {
+ continue;
+ }
+ const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
+ e.lastWithdrawal,
+ );
+ const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
+ candidate.lastWithdrawal,
+ );
+ if (exchangeLastWithdrawal && candidateLastWithdrawal) {
+ if (
+ AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
+ AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
+ ) > 0
+ ) {
+ candidate = e;
+ }
+ }
+ }
+ if (candidate) {
+ return candidate.baseUrl;
+ }
+ return undefined;
+ });
+ return url;
+}
+
+/**
+ * Initiate a peer pull payment.
+ */
+export async function initiatePeerPullPayment(
+ wex: WalletExecutionContext,
+ req: InitiatePeerPullCreditRequest,
+): Promise<InitiatePeerPullCreditResponse> {
+ const currency = Amounts.currencyOf(req.partialContractTerms.amount);
+ let maybeExchangeBaseUrl: string | undefined;
+ if (req.exchangeBaseUrl) {
+ maybeExchangeBaseUrl = req.exchangeBaseUrl;
+ } else {
+ maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(wex, currency);
+ }
+
+ if (!maybeExchangeBaseUrl) {
+ throw Error("no exchange found for initiating a peer pull payment");
+ }
+
+ const exchangeBaseUrl = maybeExchangeBaseUrl;
+
+ await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ const mergeReserveInfo = await getMergeReserveInfo(wex, {
+ exchangeBaseUrl: exchangeBaseUrl,
+ });
+
+ const pursePair = await wex.cryptoApi.createEddsaKeypair({});
+ const mergePair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const contractTerms = req.partialContractTerms;
+
+ const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+
+ const mergeReserveRowId = mergeReserveInfo.rowId;
+ checkDbInvariant(!!mergeReserveRowId);
+
+ const contractEncNonce = encodeCrock(getRandomBytes(24));
+
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ exchangeBaseUrl,
+ Amounts.parseOrThrow(req.partialContractTerms.amount),
+ undefined,
+ );
+
+ const mergeTimestamp = TalerPreciseTimestamp.now();
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullCredit", "contractTerms"],
+ async (tx) => {
+ const ppi: PeerPullCreditRecord = {
+ amount: req.partialContractTerms.amount,
+ contractTermsHash: hContractTerms,
+ exchangeBaseUrl: exchangeBaseUrl,
+ pursePriv: pursePair.priv,
+ pursePub: pursePair.pub,
+ mergePriv: mergePair.priv,
+ mergePub: mergePair.pub,
+ status: PeerPullPaymentCreditStatus.PendingCreatePurse,
+ mergeTimestamp: timestampPreciseToDb(mergeTimestamp),
+ contractEncNonce,
+ mergeReserveRowId: mergeReserveRowId,
+ contractPriv: contractKeyPair.priv,
+ contractPub: contractKeyPair.pub,
+ withdrawalGroupId,
+ estimatedAmountEffective: wi.withdrawalAmountEffective,
+ };
+ await tx.peerPullCredit.put(ppi);
+ const oldTxState: TransactionState = {
+ major: TransactionMajorState.None,
+ };
+ const newTxState = computePeerPullCreditTransactionState(ppi);
+ await tx.contractTerms.put({
+ contractTermsRaw: contractTerms,
+ h: hContractTerms,
+ });
+ return { oldTxState, newTxState };
+ },
+ );
+
+ const ctx = new PeerPullCreditTransactionContext(wex, pursePair.pub);
+
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // The pending-incoming balance has changed.
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ return {
+ talerUri: stringifyTalerUri({
+ type: TalerUriAction.PayPull,
+ exchangeBaseUrl: exchangeBaseUrl,
+ contractPriv: contractKeyPair.priv,
+ }),
+ transactionId: ctx.transactionId,
+ };
+}
+
+export function computePeerPullCreditTransactionState(
+ pullCreditRecord: PeerPullCreditRecord,
+): TransactionState {
+ switch (pullCreditRecord.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.MergeKycRequired,
+ };
+ case PeerPullPaymentCreditStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPullPaymentCreditStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.MergeKycRequired,
+ };
+ case PeerPullPaymentCreditStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPullPaymentCreditStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPullPaymentCreditStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ }
+}
+
+export function computePeerPullCreditTransactionActions(
+ pullCreditRecord: PeerPullCreditRecord,
+): TransactionAction[] {
+ switch (pullCreditRecord.status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.PendingReady:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.PendingWithdrawing:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ return [TransactionAction.Abort, TransactionAction.Resume];
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPullPaymentCreditStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPullPaymentCreditStatus.Failed:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.Expired:
+ return [TransactionAction.Delete];
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
new file mode 100644
index 000000000..705317eb6
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -0,0 +1,994 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of the peer-pull-debit transaction, i.e.
+ * paying for an invoice the wallet received from another wallet.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AcceptPeerPullPaymentResponse,
+ Amounts,
+ CoinRefreshRequest,
+ ConfirmPeerPullDebitRequest,
+ ContractTermsUtil,
+ ExchangePurseDeposits,
+ HttpStatusCode,
+ Logger,
+ NotificationType,
+ PeerContractTerms,
+ PreparePeerPullDebitRequest,
+ PreparePeerPullDebitResponse,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolViolationError,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ assertUnreachable,
+ checkLogicInvariant,
+ codecForAny,
+ codecForExchangeGetContractResponse,
+ codecForPeerContractTerms,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ parsePayPullUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpResponse,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ TransitionResultType,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import {
+ PeerPullDebitRecordStatus,
+ PeerPullPaymentIncomingRecord,
+ RefreshOperationStatus,
+ WalletStoresV1,
+ timestampPreciseToDb,
+} from "./db.js";
+import {
+ codecForExchangePurseStatus,
+ getTotalPeerPaymentCost,
+ queryCoinInfosForSelection,
+} from "./pay-peer-common.js";
+import { DbReadWriteTransaction, StoreNames } from "./query.js";
+import { createRefreshGroup } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("pay-peer-pull-debit.ts");
+
+/**
+ * Common context for a peer-pull-debit transaction.
+ */
+export class PeerPullDebitTransactionContext implements TransactionContext {
+ wex: WalletExecutionContext;
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+ peerPullDebitId: string;
+
+ constructor(wex: WalletExecutionContext, peerPullDebitId: string) {
+ this.wex = wex;
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ this.peerPullDebitId = peerPullDebitId;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const transactionId = this.transactionId;
+ const ws = this.wex;
+ const peerPullDebitId = this.peerPullDebitId;
+ await ws.db.runReadWriteTx(["peerPullDebit", "tombstones"], async (tx) => {
+ const debit = await tx.peerPullDebit.get(peerPullDebitId);
+ if (debit) {
+ await tx.peerPullDebit.delete(peerPullDebitId);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ });
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const taskId = this.taskId;
+ const transactionId = this.transactionId;
+ const wex = this.wex;
+ const peerPullDebitId = this.peerPullDebitId;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullDebit"],
+ async (tx) => {
+ const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pullDebitRec) {
+ logger.warn(`peer pull debit ${peerPullDebitId} not found`);
+ return;
+ }
+ let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
+ switch (pullDebitRec.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ break;
+ case PeerPullDebitRecordStatus.Done:
+ break;
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
+ break;
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ break;
+ case PeerPullDebitRecordStatus.Aborted:
+ break;
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
+ break;
+ case PeerPullDebitRecordStatus.Failed:
+ break;
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ break;
+ default:
+ assertUnreachable(pullDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
+ pullDebitRec.status = newStatus;
+ const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
+ await tx.peerPullDebit.put(pullDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(taskId);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transition(async (pi) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+ return TransitionResultType.Transition;
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ return TransitionResultType.Transition;
+ case PeerPullDebitRecordStatus.Aborted:
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.Failed:
+ case PeerPullDebitRecordStatus.DialogProposed:
+ case PeerPullDebitRecordStatus.Done:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return TransitionResultType.Stay;
+ }
+ });
+ this.wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+
+ async failTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transition(async (pi) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ // FIXME: Should we also abort the corresponding refresh session?!
+ pi.status = PeerPullDebitRecordStatus.Failed;
+ return TransitionResultType.Transition;
+ default:
+ return TransitionResultType.Stay;
+ }
+ });
+ this.wex.taskScheduler.stopShepherdTask(this.taskId);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const ctx = this;
+ await ctx.transitionExtra(
+ {
+ extraStores: [
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "coinAvailability",
+ ],
+ },
+ async (pi, tx) => {
+ switch (pi.status) {
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ break;
+ default:
+ return TransitionResultType.Stay;
+ }
+ const currency = Amounts.currencyOf(pi.totalCostEstimated);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (!pi.coinSel) {
+ throw Error("invalid db state");
+ }
+
+ for (let i = 0; i < pi.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: pi.coinSel.contributions[i],
+ coinPub: pi.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ ctx.wex,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPullDebit,
+ this.transactionId,
+ );
+
+ pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
+ pi.abortRefreshGroupId = refresh.refreshGroupId;
+ return TransitionResultType.Transition;
+ },
+ );
+ }
+
+ async transition(
+ f: (rec: PeerPullPaymentIncomingRecord) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ return this.transitionExtra(
+ {
+ extraStores: [],
+ },
+ f,
+ );
+ }
+
+ async transitionExtra<
+ StoreNameArray extends Array<StoreNames<typeof WalletStoresV1>> = [],
+ >(
+ opts: { extraStores: StoreNameArray },
+ f: (
+ rec: PeerPullPaymentIncomingRecord,
+ tx: DbReadWriteTransaction<
+ typeof WalletStoresV1,
+ ["peerPullDebit", ...StoreNameArray]
+ >,
+ ) => Promise<TransitionResultType>,
+ ): Promise<void> {
+ const wex = this.wex;
+ const extraStores = opts.extraStores ?? [];
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullDebit", ...extraStores],
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(this.peerPullDebitId);
+ if (!pi) {
+ throw Error("peer pull payment not found anymore");
+ }
+ const oldTxState = computePeerPullDebitTransactionState(pi);
+ const res = await f(pi, tx);
+ switch (res) {
+ case TransitionResultType.Transition: {
+ await tx.peerPullDebit.put(pi);
+ const newTxState = computePeerPullDebitTransactionState(pi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ default:
+ return undefined;
+ }
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(this.taskId);
+ notifyTransition(wex, this.transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(this.taskId);
+ }
+}
+
+async function handlePurseCreationConflict(
+ ctx: PeerPullDebitTransactionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+ resp: HttpResponse,
+): Promise<TaskRunResult> {
+ const ws = ctx.wex;
+ const errResp = await readTalerErrorResponse(resp);
+ if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+
+ // FIXME: Properly parse!
+ const brokenCoinPub = (errResp as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ // FIXME: Details!
+ throw new TalerProtocolViolationError();
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const sel = peerPullInc.coinSel;
+ if (!sel) {
+ throw Error("invalid state (coin selection expected)");
+ }
+
+ const repair: PreviousPayCoins = [];
+
+ for (let i = 0; i < sel.coinPubs.length; i++) {
+ if (sel.coinPubs[i] != brokenCoinPub) {
+ repair.push({
+ coinPub: sel.coinPubs[i],
+ contribution: Amounts.parseOrThrow(sel.contributions[i]),
+ });
+ }
+ }
+
+ const coinSelRes = await selectPeerCoins(ws, {
+ instructedAmount,
+ repair,
+ });
+
+ switch (coinSelRes.type) {
+ case "failure":
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ case "prospective":
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending (blocked on refresh)",
+ );
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(
+ ws,
+ coinSelRes.result.coins,
+ );
+
+ await ws.db.runReadWriteTx(["peerPullDebit"], async (tx) => {
+ const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
+ if (!myPpi) {
+ return;
+ }
+ switch (myPpi.status) {
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ case PeerPullDebitRecordStatus.SuspendedDeposit: {
+ const sel = coinSelRes.result;
+ myPpi.coinSel = {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ break;
+ }
+ default:
+ return;
+ }
+ await tx.peerPullDebit.put(myPpi);
+ });
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPullDebitPendingDeposit(
+ wex: WalletExecutionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPullInc.pursePub;
+
+ const coinSel = peerPullInc.coinSel;
+
+ if (!coinSel) {
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("insufficient balance (locked behind refresh)");
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const peerPullDebitId = peerPullInc.peerPullDebitId;
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ // FIXME: Missing notification here!
+
+ const transitionDone = await wex.db.runReadWriteTx(
+ [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pi) {
+ return false;
+ }
+ if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
+ return false;
+ }
+ if (pi.coinSel) {
+ return false;
+ }
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPull,
+ });
+ pi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ await tx.peerPullDebit.put(pi);
+ return true;
+ },
+ );
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+ }
+
+ const coins = await queryCoinInfosForSelection(wex, coinSel);
+
+ const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
+ pursePub: peerPullInc.pursePub,
+ coins,
+ });
+
+ const purseDepositUrl = new URL(
+ `purses/${pursePub}/deposit`,
+ peerPullInc.exchangeBaseUrl,
+ );
+
+ const depositPayload: ExchangePurseDeposits = {
+ deposits: depositSigsResp.deposits,
+ };
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
+ }
+
+ const httpResp = await wex.http.fetch(purseDepositUrl.href, {
+ method: "POST",
+ body: depositPayload,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ const ctx = new PeerPullDebitTransactionContext(
+ wex,
+ peerPullInc.peerPullDebitId,
+ );
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok: {
+ const resp = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForAny(),
+ );
+ logger.trace(`purse deposit response: ${j2s(resp)}`);
+
+ await ctx.transition(async (r) => {
+ if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
+ return TransitionResultType.Stay;
+ }
+ r.status = PeerPullDebitRecordStatus.Done;
+ return TransitionResultType.Transition;
+ });
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.Gone: {
+ await ctx.abortTransaction();
+ return TaskRunResult.backoff();
+ }
+ case HttpStatusCode.Conflict: {
+ return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+}
+
+async function processPeerPullDebitAbortingRefresh(
+ wex: WalletExecutionContext,
+ peerPullInc: PeerPullPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ const peerPullDebitId = peerPullInc.peerPullDebitId;
+ const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPullDebit", "refreshGroups"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPullDebitRecordStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPullDebitRecordStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPullDebitRecordStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPullDebitRecordStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPullDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPullDebitTransactionState(newDg);
+ await tx.peerPullDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+export async function processPeerPullDebit(
+ wex: WalletExecutionContext,
+ peerPullDebitId: string,
+): Promise<TaskRunResult> {
+ const peerPullInc = await wex.db.runReadOnlyTx(
+ ["peerPullDebit"],
+ async (tx) => {
+ return tx.peerPullDebit.get(peerPullDebitId);
+ },
+ );
+ if (!peerPullInc) {
+ throw Error("peer pull debit not found");
+ }
+
+ switch (peerPullInc.status) {
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return await processPeerPullDebitPendingDeposit(wex, peerPullInc);
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return await processPeerPullDebitAbortingRefresh(wex, peerPullInc);
+ }
+ return TaskRunResult.finished();
+}
+
+export async function confirmPeerPullDebit(
+ wex: WalletExecutionContext,
+ req: ConfirmPeerPullDebitRequest,
+): Promise<AcceptPeerPullPaymentResponse> {
+ let peerPullDebitId: string;
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) {
+ throw Error("invalid peer-pull-debit transaction identifier");
+ }
+ peerPullDebitId = parsedTx.peerPullDebitId;
+
+ const peerPullInc = await wex.db.runReadOnlyTx(
+ ["peerPullDebit"],
+ async (tx) => {
+ return tx.peerPullDebit.get(peerPullDebitId);
+ },
+ );
+
+ if (!peerPullInc) {
+ throw Error(
+ `can't accept unknown incoming p2p pull payment (${req.transactionId})`,
+ );
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ // FIXME: Missing notification here!
+
+ await wex.db.runReadWriteTx(
+ [
+ "exchanges",
+ "coins",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPullDebit",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const pi = await tx.peerPullDebit.get(peerPullDebitId);
+ if (!pi) {
+ throw Error();
+ }
+ if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) {
+ return;
+ }
+ if (coinSelRes.type == "success") {
+ await spendCoins(wex, tx, {
+ // allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPull,
+ });
+ pi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ totalCost: Amounts.stringify(totalAmount),
+ };
+ }
+ pi.status = PeerPullDebitRecordStatus.PendingDeposit;
+ await tx.peerPullDebit.put(pi);
+ },
+ );
+
+ const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
+
+ const transactionId = ctx.transactionId;
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ transactionId,
+ };
+}
+
+/**
+ * Look up information about an incoming peer pull payment.
+ * Store the results in the wallet DB.
+ */
+export async function preparePeerPullDebit(
+ wex: WalletExecutionContext,
+ req: PreparePeerPullDebitRequest,
+): Promise<PreparePeerPullDebitResponse> {
+ const uri = parsePayPullUri(req.talerUri);
+
+ if (!uri) {
+ throw Error("got invalid taler://pay-pull URI");
+ }
+
+ const existing = await wex.db.runReadOnlyTx(
+ ["peerPullDebit", "contractTerms"],
+ async (tx) => {
+ const peerPullDebitRecord =
+ await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
+ uri.exchangeBaseUrl,
+ uri.contractPriv,
+ ]);
+ if (!peerPullDebitRecord) {
+ return;
+ }
+ const contractTerms = await tx.contractTerms.get(
+ peerPullDebitRecord.contractTermsHash,
+ );
+ if (!contractTerms) {
+ return;
+ }
+ return { peerPullDebitRecord, contractTerms };
+ },
+ );
+
+ if (existing) {
+ return {
+ amount: existing.peerPullDebitRecord.amount,
+ amountRaw: existing.peerPullDebitRecord.amount,
+ amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
+ contractTerms: existing.contractTerms.contractTermsRaw,
+ peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
+ }),
+ };
+ }
+
+ const exchangeBaseUrl = uri.exchangeBaseUrl;
+ const contractPriv = uri.contractPriv;
+ const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+ const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+ const contractHttpResp = await wex.http.fetch(getContractUrl.href);
+
+ const contractResp = await readSuccessResponseJsonOrThrow(
+ contractHttpResp,
+ codecForExchangeGetContractResponse(),
+ );
+
+ const pursePub = contractResp.purse_pub;
+
+ const dec = await wex.cryptoApi.decryptContractForDeposit({
+ ciphertext: contractResp.econtract,
+ contractPriv: contractPriv,
+ pursePub: pursePub,
+ });
+
+ const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
+
+ const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
+
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ purseHttpResp,
+ codecForExchangePurseStatus(),
+ );
+
+ const peerPullDebitId = encodeCrock(getRandomBytes(32));
+
+ let contractTerms: PeerContractTerms;
+
+ if (dec.contractTerms) {
+ contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+ // FIXME: Check that the purseStatus balance matches contract terms amount
+ } else {
+ // FIXME: In this case, where do we get the purse expiration from?!
+ // https://bugs.gnunet.org/view.php?id=7706
+ throw Error("pull payments without contract terms not supported yet");
+ }
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ // FIXME: Why don't we compute the totalCost here?!
+
+ const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ if (logger.shouldLogTrace()) {
+ logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
+ }
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ await wex.db.runReadWriteTx(
+ ["peerPullDebit", "contractTerms"],
+ async (tx) => {
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: contractTerms,
+ }),
+ await tx.peerPullDebit.add({
+ peerPullDebitId,
+ contractPriv: contractPriv,
+ exchangeBaseUrl: exchangeBaseUrl,
+ pursePub: pursePub,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ contractTermsHash,
+ amount: contractTerms.amount,
+ status: PeerPullDebitRecordStatus.DialogProposed,
+ totalCostEstimated: Amounts.stringify(totalAmount),
+ });
+ },
+ );
+
+ return {
+ amount: contractTerms.amount,
+ amountEffective: Amounts.stringify(totalAmount),
+ amountRaw: contractTerms.amount,
+ contractTerms: contractTerms,
+ peerPullDebitId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: peerPullDebitId,
+ }),
+ };
+}
+
+export function computePeerPullDebitTransactionState(
+ pullDebitRecord: PeerPullPaymentIncomingRecord,
+): TransactionState {
+ switch (pullDebitRecord.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Deposit,
+ };
+ case PeerPullDebitRecordStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Deposit,
+ };
+ case PeerPullDebitRecordStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPullDebitRecordStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ }
+}
+
+export function computePeerPullDebitTransactionActions(
+ pullDebitRecord: PeerPullPaymentIncomingRecord,
+): TransactionAction[] {
+ switch (pullDebitRecord.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ return [];
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPullDebitRecordStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.SuspendedDeposit:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPullDebitRecordStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return [TransactionAction.Fail, TransactionAction.Suspend];
+ case PeerPullDebitRecordStatus.Failed:
+ return [TransactionAction.Delete];
+ case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
new file mode 100644
index 000000000..281b3ff61
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
@@ -0,0 +1,1034 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AcceptPeerPushPaymentResponse,
+ Amounts,
+ ConfirmPeerPushCreditRequest,
+ ContractTermsUtil,
+ ExchangePurseMergeRequest,
+ HttpStatusCode,
+ Logger,
+ NotificationType,
+ PeerContractTerms,
+ PreparePeerPushCreditRequest,
+ PreparePeerPushCreditResponse,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ WalletAccountMergeFlags,
+ WalletKycUuid,
+ assertUnreachable,
+ checkDbInvariant,
+ codecForAny,
+ codecForExchangeGetContractResponse,
+ codecForPeerContractTerms,
+ codecForWalletKycUuid,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ parsePayPushUri,
+ talerPaytoFromExchangeReserve,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
+ KycPendingInfo,
+ KycUserType,
+ PeerPushCreditStatus,
+ PeerPushPaymentIncomingRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampPreciseToDb,
+} from "./db.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ codecForExchangePurseStatus,
+ getMergeReserveInfo,
+} from "./pay-peer-common.js";
+import {
+ TransitionInfo,
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+import {
+ PerformCreateWithdrawalGroupResult,
+ getExchangeWithdrawalInfo,
+ internalPerformCreateWithdrawalGroup,
+ internalPrepareCreateWithdrawalGroup,
+ waitWithdrawalFinal,
+} from "./withdraw.js";
+
+const logger = new Logger("pay-peer-push-credit.ts");
+
+export class PeerPushCreditTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public peerPushCreditId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, peerPushCreditId } = this;
+ await wex.db.runReadWriteTx(
+ ["withdrawalGroups", "peerPushCredit", "tombstones"],
+ async (tx) => {
+ const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushInc) {
+ return;
+ }
+ if (pushInc.withdrawalGroupId) {
+ const withdrawalGroupId = pushInc.withdrawalGroupId;
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ }
+ }
+ await tx.peerPushCredit.delete(peerPushCreditId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId,
+ });
+ },
+ );
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ break;
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired;
+ break;
+ case PeerPushCreditStatus.PendingMerge:
+ newStatus = PeerPushCreditStatus.SuspendedMerge;
+ break;
+ case PeerPushCreditStatus.PendingWithdrawing:
+ // FIXME: Suspend internal withdrawal transaction!
+ newStatus = PeerPushCreditStatus.SuspendedWithdrawing;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.Done:
+ break;
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingMerge:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingWithdrawing:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingWithdrawing:
+ case PeerPushCreditStatus.SuspendedMerge:
+ newStatus = PeerPushCreditStatus.PendingMerge;
+ break;
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ newStatus = PeerPushCreditStatus.PendingMergeKycRequired;
+ break;
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ // FIXME: resume underlying "internal-withdrawal" transaction.
+ newStatus = PeerPushCreditStatus.PendingWithdrawing;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.Aborted:
+ case PeerPushCreditStatus.Failed:
+ // Already in a final state.
+ return;
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingWithdrawing:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPushCreditStatus.Failed;
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+export async function preparePeerPushCredit(
+ wex: WalletExecutionContext,
+ req: PreparePeerPushCreditRequest,
+): Promise<PreparePeerPushCreditResponse> {
+ const uri = parsePayPushUri(req.talerUri);
+
+ if (!uri) {
+ throw Error("got invalid taler://pay-push URI");
+ }
+
+ const existing = await wex.db.runReadOnlyTx(
+ ["contractTerms", "peerPushCredit"],
+ async (tx) => {
+ const existingPushInc =
+ await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([
+ uri.exchangeBaseUrl,
+ uri.contractPriv,
+ ]);
+ if (!existingPushInc) {
+ return;
+ }
+ const existingContractTermsRec = await tx.contractTerms.get(
+ existingPushInc.contractTermsHash,
+ );
+ if (!existingContractTermsRec) {
+ throw Error(
+ "contract terms for peer push payment credit not found in database",
+ );
+ }
+ const existingContractTerms = codecForPeerContractTerms().decode(
+ existingContractTermsRec.contractTermsRaw,
+ );
+ return { existingPushInc, existingContractTerms };
+ },
+ );
+
+ if (existing) {
+ return {
+ amount: existing.existingContractTerms.amount,
+ amountEffective: existing.existingPushInc.estimatedAmountEffective,
+ amountRaw: existing.existingContractTerms.amount,
+ contractTerms: existing.existingContractTerms,
+ peerPushCreditId: existing.existingPushInc.peerPushCreditId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: existing.existingPushInc.peerPushCreditId,
+ }),
+ exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl,
+ };
+ }
+
+ const exchangeBaseUrl = uri.exchangeBaseUrl;
+
+ await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ const contractPriv = uri.contractPriv;
+ const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+ const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+ const contractHttpResp = await wex.http.fetch(getContractUrl.href);
+
+ const contractResp = await readSuccessResponseJsonOrThrow(
+ contractHttpResp,
+ codecForExchangeGetContractResponse(),
+ );
+
+ const pursePub = contractResp.purse_pub;
+
+ const dec = await wex.cryptoApi.decryptContractForMerge({
+ ciphertext: contractResp.econtract,
+ contractPriv: contractPriv,
+ pursePub: pursePub,
+ });
+
+ const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
+
+ const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
+
+ const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ purseHttpResp,
+ codecForExchangePurseStatus(),
+ );
+
+ logger.info(
+ `peer push credit, purse balance ${purseStatus.balance}, contract amount ${contractTerms.amount}`,
+ );
+
+ const peerPushCreditId = encodeCrock(getRandomBytes(32));
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(
+ dec.contractTerms,
+ );
+
+ const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ exchangeBaseUrl,
+ Amounts.parseOrThrow(purseStatus.balance),
+ undefined,
+ );
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["contractTerms", "peerPushCredit"],
+ async (tx) => {
+ const rec: PeerPushPaymentIncomingRecord = {
+ peerPushCreditId,
+ contractPriv: contractPriv,
+ exchangeBaseUrl: exchangeBaseUrl,
+ mergePriv: dec.mergePriv,
+ pursePub: pursePub,
+ timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ contractTermsHash,
+ status: PeerPushCreditStatus.DialogProposed,
+ withdrawalGroupId,
+ currency: Amounts.currencyOf(purseStatus.balance),
+ estimatedAmountEffective: Amounts.stringify(
+ wi.withdrawalAmountEffective,
+ ),
+ };
+ await tx.peerPushCredit.add(rec);
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: dec.contractTerms,
+ });
+
+ const newTxState = computePeerPushCreditTransactionState(rec);
+
+ return {
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState,
+ } satisfies TransitionInfo;
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ return {
+ amount: purseStatus.balance,
+ amountEffective: wi.withdrawalAmountEffective,
+ amountRaw: purseStatus.balance,
+ contractTerms: dec.contractTerms,
+ peerPushCreditId,
+ transactionId,
+ exchangeBaseUrl,
+ };
+}
+
+async function longpollKycStatus(
+ wex: WalletExecutionContext,
+ peerPushCreditId: string,
+ exchangeUrl: string,
+ kycInfo: KycPendingInfo,
+ userType: KycUserType,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "30000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) {
+ return;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ peerInc.status = PeerPushCreditStatus.PendingMerge;
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ await tx.peerPushCredit.put(peerInc);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ // FIXME: Do we have to update the URL here?
+ return TaskRunResult.longpollReturnedPending();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+async function processPeerPushCreditKycRequired(
+ wex: WalletExecutionContext,
+ peerInc: PeerPushPaymentIncomingRecord,
+ kycPending: WalletKycUuid,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: peerInc.peerPushCreditId,
+ });
+ const { peerPushCreditId } = peerInc;
+
+ const userType = "individual";
+ const url = new URL(
+ `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
+ peerInc.exchangeBaseUrl,
+ );
+
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return TaskRunResult.finished();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const { transitionInfo, result } = await wex.db.runReadWriteTx(
+ ["peerPushCredit"],
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return {
+ transitionInfo: undefined,
+ result: TaskRunResult.finished(),
+ };
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ peerInc.kycInfo = {
+ paytoHash: kycPending.h_payto,
+ requirementRow: kycPending.requirement_row,
+ };
+ peerInc.kycUrl = kycStatus.kyc_url;
+ peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired;
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ await tx.peerPushCredit.put(peerInc);
+ // We'll remove this eventually! New clients should rely on the
+ // kycUrl field of the transaction, not the error code.
+ const res: TaskRunResult = {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
+ {
+ kycUrl: kycStatus.kyc_url,
+ },
+ ),
+ };
+ return {
+ transitionInfo: { oldTxState, newTxState },
+ result: res,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return result;
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+async function handlePendingMerge(
+ wex: WalletExecutionContext,
+ peerInc: PeerPushPaymentIncomingRecord,
+ contractTerms: PeerContractTerms,
+): Promise<TaskRunResult> {
+ const { peerPushCreditId } = peerInc;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ const amount = Amounts.parseOrThrow(contractTerms.amount);
+
+ const mergeReserveInfo = await getMergeReserveInfo(wex, {
+ exchangeBaseUrl: peerInc.exchangeBaseUrl,
+ });
+
+ const mergeTimestamp = TalerProtocolTimestamp.now();
+
+ const reservePayto = talerPaytoFromExchangeReserve(
+ peerInc.exchangeBaseUrl,
+ mergeReserveInfo.reservePub,
+ );
+
+ const sigRes = await wex.cryptoApi.signPurseMerge({
+ contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
+ flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
+ mergePriv: peerInc.mergePriv,
+ mergeTimestamp: mergeTimestamp,
+ purseAmount: Amounts.stringify(amount),
+ purseExpiration: contractTerms.purse_expiration,
+ purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)),
+ pursePub: peerInc.pursePub,
+ reservePayto,
+ reservePriv: mergeReserveInfo.reservePriv,
+ });
+
+ const mergePurseUrl = new URL(
+ `purses/${peerInc.pursePub}/merge`,
+ peerInc.exchangeBaseUrl,
+ );
+
+ const mergeReq: ExchangePurseMergeRequest = {
+ payto_uri: reservePayto,
+ merge_timestamp: mergeTimestamp,
+ merge_sig: sigRes.mergeSig,
+ reserve_sig: sigRes.accountSig,
+ };
+
+ const mergeHttpResp = await wex.http.fetch(mergePurseUrl.href, {
+ method: "POST",
+ body: mergeReq,
+ });
+
+ if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ const respJson = await mergeHttpResp.json();
+ const kycPending = codecForWalletKycUuid().decode(respJson);
+ logger.info(`kyc uuid response: ${j2s(kycPending)}`);
+ return processPeerPushCreditKycRequired(wex, peerInc, kycPending);
+ }
+
+ logger.trace(`merge request: ${j2s(mergeReq)}`);
+ const res = await readSuccessResponseJsonOrThrow(
+ mergeHttpResp,
+ codecForAny(),
+ );
+ logger.trace(`merge response: ${j2s(res)}`);
+
+ const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(wex, {
+ amount,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.PeerPushCredit,
+ },
+ forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
+ exchangeBaseUrl: peerInc.exchangeBaseUrl,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair: {
+ priv: mergeReserveInfo.reservePriv,
+ pub: mergeReserveInfo.reservePub,
+ },
+ });
+
+ const txRes = await wex.db.runReadWriteTx(
+ [
+ "contractTerms",
+ "peerPushCredit",
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ ],
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return undefined;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined =
+ undefined;
+ switch (peerInc.status) {
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingMergeKycRequired: {
+ peerInc.status = PeerPushCreditStatus.PendingWithdrawing;
+ wgCreateRes = await internalPerformCreateWithdrawalGroup(
+ wex,
+ tx,
+ withdrawalGroupPrep,
+ );
+ peerInc.withdrawalGroupId =
+ wgCreateRes.withdrawalGroup.withdrawalGroupId;
+ break;
+ }
+ }
+ await tx.peerPushCredit.put(peerInc);
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ return {
+ peerPushCreditTransition: { oldTxState, newTxState },
+ wgCreateRes,
+ };
+ },
+ );
+ // Transaction was committed, now we can emit notifications.
+ if (txRes?.wgCreateRes?.exchangeNotif) {
+ wex.ws.notify(txRes.wgCreateRes.exchangeNotif);
+ }
+ notifyTransition(
+ wex,
+ withdrawalGroupPrep.transactionId,
+ txRes?.wgCreateRes?.transitionInfo,
+ );
+ notifyTransition(wex, transactionId, txRes?.peerPushCreditTransition);
+
+ return TaskRunResult.backoff();
+}
+
+async function handlePendingWithdrawing(
+ wex: WalletExecutionContext,
+ peerInc: PeerPushPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ if (!peerInc.withdrawalGroupId) {
+ throw Error("invalid db state (withdrawing, but no withdrawal group ID");
+ }
+ await waitWithdrawalFinal(wex, peerInc.withdrawalGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: peerInc.peerPushCreditId,
+ });
+ const wgId = peerInc.withdrawalGroupId;
+ let finished: boolean = false;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushCredit", "withdrawalGroups"],
+ async (tx) => {
+ const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId);
+ if (!ppi) {
+ finished = true;
+ return;
+ }
+ if (ppi.status !== PeerPushCreditStatus.PendingWithdrawing) {
+ finished = true;
+ return;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(ppi);
+ const wg = await tx.withdrawalGroups.get(wgId);
+ if (!wg) {
+ // FIXME: Fail the operation instead?
+ return undefined;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.Done:
+ finished = true;
+ ppi.status = PeerPushCreditStatus.Done;
+ break;
+ // FIXME: Also handle other final states!
+ }
+ await tx.peerPushCredit.put(ppi);
+ const newTxState = computePeerPushCreditTransactionState(ppi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ if (finished) {
+ return TaskRunResult.finished();
+ } else {
+ // FIXME: Return indicator that we depend on the other operation!
+ return TaskRunResult.backoff();
+ }
+}
+
+export async function processPeerPushCredit(
+ wex: WalletExecutionContext,
+ peerPushCreditId: string,
+): Promise<TaskRunResult> {
+ let peerInc: PeerPushPaymentIncomingRecord | undefined;
+ let contractTerms: PeerContractTerms | undefined;
+ await wex.db.runReadWriteTx(
+ ["contractTerms", "peerPushCredit"],
+ async (tx) => {
+ peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
+ if (ctRec) {
+ contractTerms = ctRec.contractTermsRaw;
+ }
+ await tx.peerPushCredit.put(peerInc);
+ },
+ );
+
+ if (!peerInc) {
+ throw Error(
+ `can't accept unknown incoming p2p push payment (${peerPushCreditId})`,
+ );
+ }
+
+ logger.info(
+ `processing peerPushCredit in state ${peerInc.status.toString(16)}`,
+ );
+
+ checkDbInvariant(!!contractTerms);
+
+ switch (peerInc.status) {
+ case PeerPushCreditStatus.PendingMergeKycRequired: {
+ if (!peerInc.kycInfo) {
+ throw Error("invalid state, kycInfo required");
+ }
+ return await longpollKycStatus(
+ wex,
+ peerPushCreditId,
+ peerInc.exchangeBaseUrl,
+ peerInc.kycInfo,
+ "individual",
+ );
+ }
+
+ case PeerPushCreditStatus.PendingMerge:
+ return handlePendingMerge(wex, peerInc, contractTerms);
+
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return handlePendingWithdrawing(wex, peerInc);
+
+ default:
+ return TaskRunResult.finished();
+ }
+}
+
+export async function confirmPeerPushCredit(
+ wex: WalletExecutionContext,
+ req: ConfirmPeerPushCreditRequest,
+): Promise<AcceptPeerPushPaymentResponse> {
+ let peerInc: PeerPushPaymentIncomingRecord | undefined;
+ let peerPushCreditId: string;
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (!parsedTx) {
+ throw Error("invalid transaction ID");
+ }
+ if (parsedTx.tag !== TransactionType.PeerPushCredit) {
+ throw Error("invalid transaction ID type");
+ }
+ peerPushCreditId = parsedTx.peerPushCreditId;
+
+ logger.trace(`confirming peer-push-credit ${peerPushCreditId}`);
+
+ await wex.db.runReadWriteTx(
+ ["contractTerms", "peerPushCredit"],
+ async (tx) => {
+ peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ if (peerInc.status === PeerPushCreditStatus.DialogProposed) {
+ peerInc.status = PeerPushCreditStatus.PendingMerge;
+ }
+ await tx.peerPushCredit.put(peerInc);
+ },
+ );
+
+ if (!peerInc) {
+ throw Error(
+ `can't accept unknown incoming p2p push payment (${req.transactionId})`,
+ );
+ }
+
+ const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ return {
+ transactionId,
+ };
+}
+
+export function computePeerPushCreditTransactionState(
+ pushCreditRecord: PeerPushPaymentIncomingRecord,
+): TransactionState {
+ switch (pushCreditRecord.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case PeerPushCreditStatus.PendingMerge:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Merge,
+ };
+ case PeerPushCreditStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPushCreditStatus.SuspendedMerge:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Merge,
+ };
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.MergeKycRequired,
+ };
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPushCreditStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPushCreditStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ default:
+ assertUnreachable(pushCreditRecord.status);
+ }
+}
+
+export function computePeerPushCreditTransactionActions(
+ pushCreditRecord: PeerPushPaymentIncomingRecord,
+): TransactionAction[] {
+ switch (pushCreditRecord.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.PendingMerge:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushCreditStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushCreditStatus.SuspendedMerge:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushCreditStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.Failed:
+ return [TransactionAction.Delete];
+ default:
+ assertUnreachable(pushCreditRecord.status);
+ }
+}
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
new file mode 100644
index 000000000..b6771be89
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -0,0 +1,1266 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ CheckPeerPushDebitRequest,
+ CheckPeerPushDebitResponse,
+ CoinRefreshRequest,
+ ContractTermsUtil,
+ HttpStatusCode,
+ InitiatePeerPushDebitRequest,
+ InitiatePeerPushDebitResponse,
+ Logger,
+ NotificationType,
+ RefreshReason,
+ SelectedProspectiveCoin,
+ TalerError,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TalerProtocolViolationError,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ assertUnreachable,
+ checkDbInvariant,
+ checkLogicInvariant,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+} from "@gnu-taler/taler-util";
+import {
+ HttpResponse,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+} from "@gnu-taler/taler-util/http";
+import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TransactionContext,
+ constructTaskIdentifier,
+ spendCoins,
+} from "./common.js";
+import { EncryptContractRequest } from "./crypto/cryptoTypes.js";
+import {
+ PeerPushDebitRecord,
+ PeerPushDebitStatus,
+ RefreshOperationStatus,
+ timestampPreciseToDb,
+ timestampProtocolFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import {
+ codecForExchangePurseStatus,
+ getTotalPeerPaymentCost,
+ queryCoinInfosForSelection,
+} from "./pay-peer-common.js";
+import { createRefreshGroup, waitRefreshFinal } from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+
+const logger = new Logger("pay-peer-push-debit.ts");
+
+export class PeerPushDebitTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public pursePub: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId } = this;
+ await wex.db.runReadWriteTx(["peerPushDebit", "tombstones"], async (tx) => {
+ const debit = await tx.peerPushDebit.get(pursePub);
+ if (debit) {
+ await tx.peerPushDebit.delete(pursePub);
+ await tx.tombstones.put({ id: transactionId });
+ }
+ });
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ newStatus = PeerPushDebitStatus.SuspendedCreatePurse;
+ break;
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshDeleted;
+ break;
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingRefreshExpired;
+ break;
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ newStatus = PeerPushDebitStatus.SuspendedAbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.PendingReady:
+ newStatus = PeerPushDebitStatus.SuspendedReady;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ // Network request might already be in-flight!
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Expired:
+ case PeerPushDebitStatus.Failed:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ newStatus = PeerPushDebitStatus.AbortingDeletePurse;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ newStatus = PeerPushDebitStatus.AbortingRefreshDeleted;
+ break;
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ newStatus = PeerPushDebitStatus.AbortingRefreshExpired;
+ break;
+ case PeerPushDebitStatus.SuspendedReady:
+ newStatus = PeerPushDebitStatus.PendingReady;
+ break;
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ newStatus = PeerPushDebitStatus.PendingCreatePurse;
+ break;
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.startShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, pursePub, transactionId, taskId: retryTag } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ const pushDebitRec = await tx.peerPushDebit.get(pursePub);
+ if (!pushDebitRec) {
+ logger.warn(`peer push debit ${pursePub} not found`);
+ return;
+ }
+ let newStatus: PeerPushDebitStatus | undefined = undefined;
+ switch (pushDebitRec.status) {
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ // FIXME: What to do about the refresh group?
+ newStatus = PeerPushDebitStatus.Failed;
+ break;
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.PendingCreatePurse:
+ newStatus = PeerPushDebitStatus.Failed;
+ break;
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ // Do nothing
+ break;
+ default:
+ assertUnreachable(pushDebitRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ pushDebitRec.status = newStatus;
+ const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
+ await tx.peerPushDebit.put(pushDebitRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+export async function checkPeerPushDebit(
+ wex: WalletExecutionContext,
+ req: CheckPeerPushDebitRequest,
+): Promise<CheckPeerPushDebitResponse> {
+ const instructedAmount = Amounts.parseOrThrow(req.amount);
+ logger.trace(
+ `checking peer push debit for ${Amounts.stringify(instructedAmount)}`,
+ );
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+ logger.trace(`selected peer coins (len=${coins.length})`);
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+ logger.trace("computed total peer payment cost");
+ return {
+ exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
+ amountEffective: Amounts.stringify(totalAmount),
+ amountRaw: req.amount,
+ maxExpirationDate: coinSelRes.result.maxExpirationDate,
+ };
+}
+
+async function handlePurseCreationConflict(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+ resp: HttpResponse,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const errResp = await readTalerErrorResponse(resp);
+ const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
+ if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+
+ // FIXME: Properly parse!
+ const brokenCoinPub = (errResp as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ // FIXME: Details!
+ throw new TalerProtocolViolationError();
+ }
+
+ const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount);
+ const sel = peerPushInitiation.coinSel;
+
+ checkDbInvariant(!!sel);
+
+ const repair: PreviousPayCoins = [];
+
+ for (let i = 0; i < sel.coinPubs.length; i++) {
+ if (sel.coinPubs[i] != brokenCoinPub) {
+ repair.push({
+ coinPub: sel.coinPubs[i],
+ contribution: Amounts.parseOrThrow(sel.contributions[i]),
+ });
+ }
+ }
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ repair,
+ });
+
+ switch (coinSelRes.type) {
+ case "failure":
+ case "prospective":
+ // FIXME: Details!
+ throw Error(
+ "insufficient balance to re-select coins to repair double spending",
+ );
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ await wex.db.runReadWriteTx(["peerPushDebit"], async (tx) => {
+ const myPpi = await tx.peerPushDebit.get(peerPushInitiation.pursePub);
+ if (!myPpi) {
+ return;
+ }
+ switch (myPpi.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.SuspendedCreatePurse: {
+ const sel = coinSelRes.result;
+ myPpi.coinSel = {
+ coinPubs: sel.coins.map((x) => x.coinPub),
+ contributions: sel.coins.map((x) => x.contribution),
+ };
+ break;
+ }
+ default:
+ return;
+ }
+ await tx.peerPushDebit.put(myPpi);
+ });
+ return TaskRunResult.progress();
+}
+
+async function processPeerPushDebitCreateReserve(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const purseExpiration = peerPushInitiation.purseExpiration;
+ const hContractTerms = peerPushInitiation.contractTermsHash;
+ const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
+ const transactionId = ctx.transactionId;
+
+ logger.trace(`processing ${transactionId} pending(create-reserve)`);
+
+ const contractTermsRecord = await wex.db.runReadOnlyTx(
+ ["contractTerms"],
+ async (tx) => {
+ return tx.contractTerms.get(hContractTerms);
+ },
+ );
+
+ if (!contractTermsRecord) {
+ throw Error(
+ `db invariant failed, contract terms for ${transactionId} missing`,
+ );
+ }
+
+ if (!peerPushInitiation.coinSel) {
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount: Amounts.parseOrThrow(peerPushInitiation.amount),
+ });
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ throw Error("insufficient funds (blocked on refresh)");
+ case "success":
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+ const transitionDone = await wex.db.runReadWriteTx(
+ [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPushDebit",
+ ],
+ async (tx) => {
+ const ppi = await tx.peerPushDebit.get(pursePub);
+ if (!ppi) {
+ return false;
+ }
+ if (ppi.coinSel) {
+ return false;
+ }
+
+ ppi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ };
+ // FIXME: Instead of directly doing a spendCoin here,
+ // we might want to mark the coins as used and spend them
+ // after we've been able to create the purse.
+ await spendCoins(wex, tx, {
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPush,
+ });
+
+ await tx.peerPushDebit.put(ppi);
+ return true;
+ },
+ );
+ if (transitionDone) {
+ return TaskRunResult.progress();
+ }
+ return TaskRunResult.backoff();
+ }
+
+ const purseSigResp = await wex.cryptoApi.signPurseCreation({
+ hContractTerms,
+ mergePub: peerPushInitiation.mergePub,
+ minAge: 0,
+ purseAmount: peerPushInitiation.amount,
+ purseExpiration: timestampProtocolFromDb(purseExpiration),
+ pursePriv: peerPushInitiation.pursePriv,
+ });
+
+ const coins = await queryCoinInfosForSelection(
+ wex,
+ peerPushInitiation.coinSel,
+ );
+
+ const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
+ exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
+ pursePub: peerPushInitiation.pursePub,
+ coins,
+ });
+
+ const encryptContractRequest: EncryptContractRequest = {
+ contractTerms: contractTermsRecord.contractTermsRaw,
+ mergePriv: peerPushInitiation.mergePriv,
+ pursePriv: peerPushInitiation.pursePriv,
+ pursePub: peerPushInitiation.pursePub,
+ contractPriv: peerPushInitiation.contractPriv,
+ contractPub: peerPushInitiation.contractPub,
+ nonce: peerPushInitiation.contractEncNonce,
+ };
+
+ logger.trace(`encrypt contract request: ${j2s(encryptContractRequest)}`);
+
+ const econtractResp = await wex.cryptoApi.encryptContractForMerge(
+ encryptContractRequest,
+ );
+
+ const createPurseUrl = new URL(
+ `purses/${peerPushInitiation.pursePub}/create`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+
+ const reqBody = {
+ amount: peerPushInitiation.amount,
+ merge_pub: peerPushInitiation.mergePub,
+ purse_sig: purseSigResp.sig,
+ h_contract_terms: hContractTerms,
+ purse_expiration: timestampProtocolFromDb(purseExpiration),
+ deposits: depositSigsResp.deposits,
+ min_age: 0,
+ econtract: econtractResp.econtract,
+ };
+
+ logger.trace(`request body: ${j2s(reqBody)}`);
+
+ const httpResp = await wex.http.fetch(createPurseUrl.href, {
+ method: "POST",
+ body: reqBody,
+ cancellationToken: wex.cancellationToken,
+ });
+
+ {
+ const resp = await httpResp.json();
+ logger.info(`resp: ${j2s(resp)}`);
+ }
+
+ switch (httpResp.status) {
+ case HttpStatusCode.Ok:
+ break;
+ case HttpStatusCode.Forbidden: {
+ // FIXME: Store this error!
+ await ctx.failTransaction();
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.Conflict: {
+ // Handle double-spending
+ return handlePurseCreationConflict(wex, peerPushInitiation, httpResp);
+ }
+ default: {
+ const errResp = await readTalerErrorResponse(httpResp);
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: errResp,
+ };
+ }
+ }
+
+ if (httpResp.status !== HttpStatusCode.Ok) {
+ // FIXME: do proper error reporting
+ throw Error("got error response from exchange");
+ }
+
+ await transitionPeerPushDebitTransaction(wex, pursePub, {
+ stFrom: PeerPushDebitStatus.PendingCreatePurse,
+ stTo: PeerPushDebitStatus.PendingReady,
+ });
+
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPushDebitAbortingDeletePurse(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const { pursePub, pursePriv } = peerPushInitiation;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+
+ const sigResp = await wex.cryptoApi.signDeletePurse({
+ pursePriv,
+ });
+ const purseUrl = new URL(
+ `purses/${pursePub}`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+ const resp = await wex.http.fetch(purseUrl.href, {
+ method: "DELETE",
+ headers: {
+ "taler-purse-signature": sigResp.sig,
+ },
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`deleted purse with response status ${resp.status}`);
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ [
+ "peerPushDebit",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPushDebitStatus.AbortingDeletePurse) {
+ return undefined;
+ }
+ const currency = Amounts.currencyOf(ppiRec.amount);
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (!ppiRec.coinSel) {
+ return undefined;
+ }
+
+ for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: ppiRec.coinSel.contributions[i],
+ coinPub: ppiRec.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPushDebit,
+ transactionId,
+ );
+ ppiRec.status = PeerPushDebitStatus.AbortingRefreshDeleted;
+ ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ return TaskRunResult.backoff();
+}
+
+interface SimpleTransition {
+ stFrom: PeerPushDebitStatus;
+ stTo: PeerPushDebitStatus;
+}
+
+async function transitionPeerPushDebitTransaction(
+ wex: WalletExecutionContext,
+ pursePub: string,
+ transitionSpec: SimpleTransition,
+): Promise<void> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== transitionSpec.stFrom) {
+ return undefined;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ ppiRec.status = transitionSpec.stTo;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+}
+
+async function processPeerPushDebitAbortingRefreshDeleted(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: peerPushInitiation.pursePub,
+ });
+ if (peerPushInitiation.abortRefreshGroupId) {
+ await waitRefreshFinal(wex, peerPushInitiation.abortRefreshGroupId);
+ }
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["refreshGroups", "peerPushDebit"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPushDebitStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPushDebitStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPushDebitStatus.Aborted;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPushDebitStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPushDebit.get(pursePub);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPushDebitTransactionState(newDg);
+ await tx.peerPushDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+async function processPeerPushDebitAbortingRefreshExpired(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ const pursePub = peerPushInitiation.pursePub;
+ const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
+ checkLogicInvariant(!!abortRefreshGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: peerPushInitiation.pursePub,
+ });
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["peerPushDebit", "refreshGroups"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
+ let newOpState: PeerPushDebitStatus | undefined;
+ if (!refreshGroup) {
+ // Maybe it got manually deleted? Means that we should
+ // just go into failed.
+ logger.warn("no aborting refresh group found for deposit group");
+ newOpState = PeerPushDebitStatus.Failed;
+ } else {
+ if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
+ newOpState = PeerPushDebitStatus.Expired;
+ } else if (
+ refreshGroup.operationStatus === RefreshOperationStatus.Failed
+ ) {
+ newOpState = PeerPushDebitStatus.Failed;
+ }
+ }
+ if (newOpState) {
+ const newDg = await tx.peerPushDebit.get(pursePub);
+ if (!newDg) {
+ return;
+ }
+ const oldTxState = computePeerPushDebitTransactionState(newDg);
+ newDg.status = newOpState;
+ const newTxState = computePeerPushDebitTransactionState(newDg);
+ await tx.peerPushDebit.put(newDg);
+ return { oldTxState, newTxState };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ // FIXME: Shouldn't this be finished in some cases?!
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Process the "pending(ready)" state of a peer-push-debit transaction.
+ */
+async function processPeerPushDebitReady(
+ wex: WalletExecutionContext,
+ peerPushInitiation: PeerPushDebitRecord,
+): Promise<TaskRunResult> {
+ logger.trace("processing peer-push-debit pending(ready)");
+ const pursePub = peerPushInitiation.pursePub;
+ const transactionId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub,
+ });
+ const mergeUrl = new URL(
+ `purses/${pursePub}/merge`,
+ peerPushInitiation.exchangeBaseUrl,
+ );
+ mergeUrl.searchParams.set("timeout_ms", "30000");
+ logger.info(`long-polling on purse status at ${mergeUrl.href}`);
+ const resp = await wex.http.fetch(mergeUrl.href, {
+ // timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ if (resp.status === HttpStatusCode.Ok) {
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangePurseStatus(),
+ );
+ const mergeTimestamp = purseStatus.merge_timestamp;
+ logger.info(`got purse status ${j2s(purseStatus)}`);
+ if (!mergeTimestamp || TalerProtocolTimestamp.isNever(mergeTimestamp)) {
+ return TaskRunResult.backoff();
+ } else {
+ await transitionPeerPushDebitTransaction(
+ wex,
+ peerPushInitiation.pursePub,
+ {
+ stFrom: PeerPushDebitStatus.PendingReady,
+ stTo: PeerPushDebitStatus.Done,
+ },
+ );
+ return TaskRunResult.progress();
+ }
+ } else if (resp.status === HttpStatusCode.Gone) {
+ logger.info(`purse ${pursePub} is gone, aborting peer-push-debit`);
+ const transitionInfo = await wex.db.runReadWriteTx(
+ [
+ "peerPushDebit",
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coinAvailability",
+ "coins",
+ ],
+ async (tx) => {
+ const ppiRec = await tx.peerPushDebit.get(pursePub);
+ if (!ppiRec) {
+ return undefined;
+ }
+ if (ppiRec.status !== PeerPushDebitStatus.PendingReady) {
+ return undefined;
+ }
+ const currency = Amounts.currencyOf(ppiRec.amount);
+ const oldTxState = computePeerPushDebitTransactionState(ppiRec);
+ const coinPubs: CoinRefreshRequest[] = [];
+
+ if (ppiRec.coinSel) {
+ for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
+ coinPubs.push({
+ amount: ppiRec.coinSel.contributions[i],
+ coinPub: ppiRec.coinSel.coinPubs[i],
+ });
+ }
+
+ const refresh = await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ coinPubs,
+ RefreshReason.AbortPeerPushDebit,
+ transactionId,
+ );
+
+ ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
+ }
+ ppiRec.status = PeerPushDebitStatus.AbortingRefreshExpired;
+ await tx.peerPushDebit.put(ppiRec);
+ const newTxState = computePeerPushDebitTransactionState(ppiRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.backoff();
+ } else {
+ logger.warn(`unexpected HTTP status for purse: ${resp.status}`);
+ return TaskRunResult.longpollReturnedPending();
+ }
+}
+
+export async function processPeerPushDebit(
+ wex: WalletExecutionContext,
+ pursePub: string,
+): Promise<TaskRunResult> {
+ const peerPushInitiation = await wex.db.runReadOnlyTx(
+ ["peerPushDebit"],
+ async (tx) => {
+ return tx.peerPushDebit.get(pursePub);
+ },
+ );
+ if (!peerPushInitiation) {
+ throw Error("peer push payment not found");
+ }
+
+ switch (peerPushInitiation.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return processPeerPushDebitCreateReserve(wex, peerPushInitiation);
+ case PeerPushDebitStatus.PendingReady:
+ return processPeerPushDebitReady(wex, peerPushInitiation);
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return processPeerPushDebitAbortingDeletePurse(wex, peerPushInitiation);
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return processPeerPushDebitAbortingRefreshDeleted(
+ wex,
+ peerPushInitiation,
+ );
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return processPeerPushDebitAbortingRefreshExpired(
+ wex,
+ peerPushInitiation,
+ );
+ default: {
+ const txState = computePeerPushDebitTransactionState(peerPushInitiation);
+ logger.warn(
+ `not processing peer-push-debit transaction in state ${j2s(txState)}`,
+ );
+ }
+ }
+
+ return TaskRunResult.finished();
+}
+
+/**
+ * Initiate sending a peer-to-peer push payment.
+ */
+export async function initiatePeerPushDebit(
+ wex: WalletExecutionContext,
+ req: InitiatePeerPushDebitRequest,
+): Promise<InitiatePeerPushDebitResponse> {
+ const instructedAmount = Amounts.parseOrThrow(
+ req.partialContractTerms.amount,
+ );
+ const purseExpiration = req.partialContractTerms.purse_expiration;
+ const contractTerms = req.partialContractTerms;
+
+ const pursePair = await wex.cryptoApi.createEddsaKeypair({});
+ const mergePair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
+
+ const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({});
+
+ const coinSelRes = await selectPeerCoins(wex, {
+ instructedAmount,
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+
+ switch (coinSelRes.type) {
+ case "failure":
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ {
+ insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
+ },
+ );
+ case "prospective":
+ coins = coinSelRes.result.prospectiveCoins;
+ break;
+ case "success":
+ coins = coinSelRes.result.coins;
+ break;
+ default:
+ assertUnreachable(coinSelRes);
+ }
+
+ const sel = coinSelRes.result;
+
+ logger.info(`selected p2p coins (push):`);
+ logger.trace(`${j2s(coinSelRes)}`);
+
+ const totalAmount = await getTotalPeerPaymentCost(wex, coins);
+
+ logger.info(`computed total peer payment cost`);
+
+ const pursePub = pursePair.pub;
+
+ const ctx = new PeerPushDebitTransactionContext(wex, pursePub);
+
+ const transactionId = ctx.transactionId;
+
+ const contractEncNonce = encodeCrock(getRandomBytes(24));
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ [
+ "exchanges",
+ "contractTerms",
+ "coins",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "peerPushDebit",
+ ],
+ async (tx) => {
+ const ppi: PeerPushDebitRecord = {
+ amount: Amounts.stringify(instructedAmount),
+ contractPriv: contractKeyPair.priv,
+ contractPub: contractKeyPair.pub,
+ contractTermsHash: hContractTerms,
+ exchangeBaseUrl: sel.exchangeBaseUrl,
+ mergePriv: mergePair.priv,
+ mergePub: mergePair.pub,
+ purseExpiration: timestampProtocolToDb(purseExpiration),
+ pursePriv: pursePair.priv,
+ pursePub: pursePair.pub,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ status: PeerPushDebitStatus.PendingCreatePurse,
+ contractEncNonce,
+ totalCost: Amounts.stringify(totalAmount),
+ };
+
+ if (coinSelRes.type === "success") {
+ ppi.coinSel = {
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) => x.contribution),
+ };
+ // FIXME: Instead of directly doing a spendCoin here,
+ // we might want to mark the coins as used and spend them
+ // after we've been able to create the purse.
+ await spendCoins(wex, tx, {
+ allocationId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pursePair.pub,
+ }),
+ coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
+ contributions: coinSelRes.result.coins.map((x) =>
+ Amounts.parseOrThrow(x.contribution),
+ ),
+ refreshReason: RefreshReason.PayPeerPush,
+ });
+ }
+
+ await tx.peerPushDebit.add(ppi);
+
+ await tx.contractTerms.put({
+ h: hContractTerms,
+ contractTermsRaw: contractTerms,
+ });
+
+ const newTxState = computePeerPushDebitTransactionState(ppi);
+ return {
+ oldTxState: { major: TransactionMajorState.None },
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ contractPriv: contractKeyPair.priv,
+ mergePriv: mergePair.priv,
+ pursePub: pursePair.pub,
+ exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pursePair.pub,
+ }),
+ };
+}
+
+export function computePeerPushDebitTransactionActions(
+ ppiRecord: PeerPushDebitRecord,
+): TransactionAction[] {
+ switch (ppiRecord.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushDebitStatus.PendingReady:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushDebitStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushDebitStatus.SuspendedReady:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushDebitStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.Expired:
+ return [TransactionAction.Delete];
+ case PeerPushDebitStatus.Failed:
+ return [TransactionAction.Delete];
+ }
+}
+
+export function computePeerPushDebitTransactionState(
+ ppiRecord: PeerPushDebitRecord,
+): TransactionState {
+ switch (ppiRecord.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPushDebitStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPushDebitStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPushDebitStatus.AbortingDeletePurse:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPushDebitStatus.AbortingRefreshDeleted:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPushDebitStatus.AbortingRefreshExpired:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.RefreshExpired,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.DeletePurse,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingRefreshExpired:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.RefreshExpired,
+ };
+ case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Refresh,
+ };
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ };
+ case PeerPushDebitStatus.SuspendedReady:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Ready,
+ };
+ case PeerPushDebitStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPushDebitStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case PeerPushDebitStatus.Expired:
+ return {
+ major: TransactionMajorState.Expired,
+ };
+ }
+}
diff --git a/packages/taler-wallet-core/src/pending-types.ts b/packages/taler-wallet-core/src/pending-types.ts
deleted file mode 100644
index 5033163a1..000000000
--- a/packages/taler-wallet-core/src/pending-types.ts
+++ /dev/null
@@ -1,256 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Type and schema definitions for pending tasks in the wallet.
- *
- * These are only used internally, and are not part of the stable public
- * interface to the wallet.
- */
-
-/**
- * Imports.
- */
-import {
- TalerErrorDetails,
- BalancesResponse,
- Timestamp,
-} from "@gnu-taler/taler-util";
-import { ReserveRecordStatus } from "./db.js";
-import { RetryInfo } from "./util/retries.js";
-
-export enum PendingTaskType {
- ExchangeUpdate = "exchange-update",
- ExchangeCheckRefresh = "exchange-check-refresh",
- Pay = "pay",
- ProposalChoice = "proposal-choice",
- ProposalDownload = "proposal-download",
- Refresh = "refresh",
- Reserve = "reserve",
- Recoup = "recoup",
- RefundQuery = "refund-query",
- TipPickup = "tip-pickup",
- Withdraw = "withdraw",
- Deposit = "deposit",
- Backup = "backup",
-}
-
-/**
- * Information about a pending operation.
- */
-export type PendingTaskInfo = PendingTaskInfoCommon &
- (
- | PendingExchangeUpdateTask
- | PendingExchangeCheckRefreshTask
- | PendingPayTask
- | PendingProposalDownloadTask
- | PendingRefreshTask
- | PendingRefundQueryTask
- | PendingReserveTask
- | PendingTipPickupTask
- | PendingWithdrawTask
- | PendingRecoupTask
- | PendingDepositTask
- | PendingBackupTask
- );
-
-export interface PendingBackupTask {
- type: PendingTaskType.Backup;
- backupProviderBaseUrl: string;
- lastError: TalerErrorDetails | undefined;
-}
-
-/**
- * The wallet is currently updating information about an exchange.
- */
-export interface PendingExchangeUpdateTask {
- type: PendingTaskType.ExchangeUpdate;
- exchangeBaseUrl: string;
- lastError: TalerErrorDetails | undefined;
-}
-
-/**
- * The wallet should check whether coins from this exchange
- * need to be auto-refreshed.
- */
-export interface PendingExchangeCheckRefreshTask {
- type: PendingTaskType.ExchangeCheckRefresh;
- exchangeBaseUrl: string;
-}
-
-export enum ReserveType {
- /**
- * Manually created.
- */
- Manual = "manual",
- /**
- * Withdrawn from a bank that has "tight" Taler integration
- */
- TalerBankWithdraw = "taler-bank-withdraw",
-}
-
-/**
- * Status of processing a reserve.
- *
- * Does *not* include the withdrawal operation that might result
- * from this.
- */
-export interface PendingReserveTask {
- type: PendingTaskType.Reserve;
- retryInfo: RetryInfo | undefined;
- stage: ReserveRecordStatus;
- timestampCreated: Timestamp;
- reserveType: ReserveType;
- reservePub: string;
- bankWithdrawConfirmUrl?: string;
-}
-
-/**
- * Status of an ongoing withdrawal operation.
- */
-export interface PendingRefreshTask {
- type: PendingTaskType.Refresh;
- lastError?: TalerErrorDetails;
- refreshGroupId: string;
- finishedPerCoin: boolean[];
- retryInfo: RetryInfo;
-}
-
-/**
- * Status of downloading signed contract terms from a merchant.
- */
-export interface PendingProposalDownloadTask {
- type: PendingTaskType.ProposalDownload;
- merchantBaseUrl: string;
- proposalTimestamp: Timestamp;
- proposalId: string;
- orderId: string;
- lastError?: TalerErrorDetails;
- retryInfo?: RetryInfo;
-}
-
-/**
- * User must choose whether to accept or reject the merchant's
- * proposed contract terms.
- */
-export interface PendingProposalChoiceOperation {
- type: PendingTaskType.ProposalChoice;
- merchantBaseUrl: string;
- proposalTimestamp: Timestamp;
- proposalId: string;
-}
-
-/**
- * The wallet is picking up a tip that the user has accepted.
- */
-export interface PendingTipPickupTask {
- type: PendingTaskType.TipPickup;
- tipId: string;
- merchantBaseUrl: string;
- merchantTipId: string;
-}
-
-/**
- * The wallet is signing coins and then sending them to
- * the merchant.
- */
-export interface PendingPayTask {
- type: PendingTaskType.Pay;
- proposalId: string;
- isReplay: boolean;
- retryInfo?: RetryInfo;
- lastError: TalerErrorDetails | undefined;
-}
-
-/**
- * The wallet is querying the merchant about whether any refund
- * permissions are available for a purchase.
- */
-export interface PendingRefundQueryTask {
- type: PendingTaskType.RefundQuery;
- proposalId: string;
- retryInfo: RetryInfo;
- lastError: TalerErrorDetails | undefined;
-}
-
-export interface PendingRecoupTask {
- type: PendingTaskType.Recoup;
- recoupGroupId: string;
- retryInfo: RetryInfo;
- lastError: TalerErrorDetails | undefined;
-}
-
-/**
- * Status of an ongoing withdrawal operation.
- */
-export interface PendingWithdrawTask {
- type: PendingTaskType.Withdraw;
- lastError: TalerErrorDetails | undefined;
- retryInfo: RetryInfo;
- withdrawalGroupId: string;
-}
-
-/**
- * Status of an ongoing deposit operation.
- */
-export interface PendingDepositTask {
- type: PendingTaskType.Deposit;
- lastError: TalerErrorDetails | undefined;
- retryInfo: RetryInfo | undefined;
- depositGroupId: string;
-}
-
-/**
- * Fields that are present in every pending operation.
- */
-export interface PendingTaskInfoCommon {
- /**
- * Type of the pending operation.
- */
- type: PendingTaskType;
-
- /**
- * Set to true if the operation indicates that something is really in progress,
- * as opposed to some regular scheduled operation that can be tried later.
- */
- givesLifeness: boolean;
-
- /**
- * Timestamp when the pending operation should be executed next.
- */
- timestampDue: Timestamp;
-
- /**
- * Retry info. Currently used to stop the wallet after any operation
- * exceeds a number of retries.
- */
- retryInfo?: RetryInfo;
-}
-
-/**
- * Response returned from the pending operations API.
- */
-export interface PendingOperationsResponse {
- /**
- * List of pending operations.
- */
- pendingOperations: PendingTaskInfo[];
-
- /**
- * Current wallet balance, including pending balances.
- */
- walletBalance: BalancesResponse;
-}
diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/query.ts
index a95cbf1ff..d78e9bc6e 100644
--- a/packages/taler-wallet-core/src/util/query.ts
+++ b/packages/taler-wallet-core/src/query.ts
@@ -23,19 +23,19 @@
/**
* Imports.
*/
-import { openPromise } from "./promiseUtils.js";
import {
+ IDBCursor,
+ IDBDatabase,
+ IDBFactory,
+ IDBKeyPath,
+ IDBKeyRange,
IDBRequest,
IDBTransaction,
+ IDBTransactionMode,
IDBValidKey,
- IDBDatabase,
- IDBFactory,
IDBVersionChangeEvent,
- IDBCursor,
- IDBKeyPath,
} from "@gnu-taler/idb-bridge";
-import { Logger } from "@gnu-taler/taler-util";
-import { performanceNow } from "./timer.js";
+import { Codec, Logger, openPromise } from "@gnu-taler/taler-util";
const logger = new Logger("query.ts");
@@ -61,6 +61,13 @@ export interface IndexOptions {
* undefined if added in the first version.
*/
versionAdded?: number;
+
+ /**
+ * Does this index enforce unique keys?
+ *
+ * Defaults to false.
+ */
+ unique?: boolean;
}
function requestToPromise(req: IDBRequest): Promise<any> {
@@ -152,6 +159,19 @@ class ResultStream<T> {
return arr;
}
+ async mapAsync<R>(f: (x: T) => Promise<R>): Promise<R[]> {
+ const arr: R[] = [];
+ while (true) {
+ const x = await this.next();
+ if (x.hasValue) {
+ arr.push(await f(x.value));
+ } else {
+ break;
+ }
+ }
+ return arr;
+ }
+
async forEachAsync(f: (x: T) => Promise<void>): Promise<void> {
while (true) {
const x = await this.next();
@@ -219,7 +239,7 @@ class ResultStream<T> {
export function openDatabase(
idbFactory: IDBFactory,
databaseName: string,
- databaseVersion: number,
+ databaseVersion: number | undefined,
onVersionChange: () => void,
onUpgradeNeeded: (
db: IDBDatabase,
@@ -230,14 +250,14 @@ export function openDatabase(
): Promise<IDBDatabase> {
return new Promise<IDBDatabase>((resolve, reject) => {
const req = idbFactory.open(databaseName, databaseVersion);
- req.onerror = (e) => {
- logger.error("database error", e);
- reject(new Error("database error"));
+ req.onerror = (event) => {
+ // @ts-expect-error
+ reject(new Error(`database opening error`, { cause: req.error }));
};
req.onsuccess = (e) => {
req.result.onversionchange = (evt: IDBVersionChangeEvent) => {
logger.info(
- `handling live db version change from ${evt.oldVersion} to ${evt.newVersion}`,
+ `handling versionchange on ${databaseName} from ${evt.oldVersion} to ${evt.newVersion}`,
);
req.result.close();
onVersionChange();
@@ -248,12 +268,21 @@ export function openDatabase(
const db = req.result;
const newVersion = e.newVersion;
if (!newVersion) {
- throw Error("upgrade needed, but new version unknown");
+ // @ts-expect-error
+ throw Error("upgrade needed, but new version unknown", {
+ cause: req.error,
+ });
}
const transaction = req.transaction;
if (!transaction) {
- throw Error("no transaction handle available in upgrade handler");
+ // @ts-expect-error
+ throw Error("no transaction handle available in upgrade handler", {
+ cause: req.error,
+ });
}
+ logger.info(
+ `handling upgradeneeded event on ${databaseName} from ${e.oldVersion} to ${e.newVersion}`,
+ );
onUpgradeNeeded(db, e.oldVersion, newVersion, transaction);
};
});
@@ -263,25 +292,41 @@ export interface IndexDescriptor {
name: string;
keyPath: IDBKeyPath | IDBKeyPath[];
multiEntry?: boolean;
+ unique?: boolean;
+ versionAdded?: number;
}
export interface StoreDescriptor<RecordType> {
_dummy: undefined & RecordType;
- name: string;
keyPath?: IDBKeyPath | IDBKeyPath[];
autoIncrement?: boolean;
+ /**
+ * Database version that this store was added in, or
+ * undefined if added in the first version.
+ */
+ versionAdded?: number;
}
export interface StoreOptions {
keyPath?: IDBKeyPath | IDBKeyPath[];
autoIncrement?: boolean;
+
+ /**
+ * First minor database version that this store was added in, or
+ * undefined if added in the first version.
+ */
+ versionAdded?: number;
}
export function describeContents<RecordType = never>(
- name: string,
options: StoreOptions,
): StoreDescriptor<RecordType> {
- return { name, keyPath: options.keyPath, _dummy: undefined as any };
+ return {
+ keyPath: options.keyPath,
+ _dummy: undefined as any,
+ autoIncrement: options.autoIncrement,
+ versionAdded: options.versionAdded,
+ };
}
export function describeIndex(
@@ -293,13 +338,23 @@ export function describeIndex(
keyPath,
name,
multiEntry: options.multiEntry,
+ unique: options.unique,
+ versionAdded: options.versionAdded,
};
}
interface IndexReadOnlyAccessor<RecordType> {
- iter(query?: IDBValidKey): ResultStream<RecordType>;
+ iter(query?: IDBKeyRange | IDBValidKey): ResultStream<RecordType>;
get(query: IDBValidKey): Promise<RecordType | undefined>;
- getAll(query: IDBValidKey, count?: number): Promise<RecordType[]>;
+ getAll(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<RecordType[]>;
+ getAllKeys(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<IDBValidKey[]>;
+ count(query?: IDBValidKey): Promise<number>;
}
type GetIndexReadOnlyAccess<RecordType, IndexMap> = {
@@ -307,9 +362,17 @@ type GetIndexReadOnlyAccess<RecordType, IndexMap> = {
};
interface IndexReadWriteAccessor<RecordType> {
- iter(query: IDBValidKey): ResultStream<RecordType>;
+ iter(query: IDBKeyRange | IDBValidKey): ResultStream<RecordType>;
get(query: IDBValidKey): Promise<RecordType | undefined>;
- getAll(query: IDBValidKey, count?: number): Promise<RecordType[]>;
+ getAll(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<RecordType[]>;
+ getAllKeys(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<IDBValidKey[]>;
+ count(query?: IDBValidKey): Promise<number>;
}
type GetIndexReadWriteAccess<RecordType, IndexMap> = {
@@ -318,24 +381,41 @@ type GetIndexReadWriteAccess<RecordType, IndexMap> = {
export interface StoreReadOnlyAccessor<RecordType, IndexMap> {
get(key: IDBValidKey): Promise<RecordType | undefined>;
+ getAll(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<RecordType[]>;
iter(query?: IDBValidKey): ResultStream<RecordType>;
indexes: GetIndexReadOnlyAccess<RecordType, IndexMap>;
}
+export interface InsertResponse {
+ /**
+ * Key of the newly inserted (via put/add) record.
+ */
+ key: IDBValidKey;
+}
+
export interface StoreReadWriteAccessor<RecordType, IndexMap> {
get(key: IDBValidKey): Promise<RecordType | undefined>;
+ getAll(
+ query?: IDBKeyRange | IDBValidKey,
+ count?: number,
+ ): Promise<RecordType[]>;
iter(query?: IDBValidKey): ResultStream<RecordType>;
- put(r: RecordType): Promise<void>;
- add(r: RecordType): Promise<void>;
+ put(r: RecordType, key?: IDBValidKey): Promise<InsertResponse>;
+ add(r: RecordType, key?: IDBValidKey): Promise<InsertResponse>;
delete(key: IDBValidKey): Promise<void>;
indexes: GetIndexReadWriteAccess<RecordType, IndexMap>;
}
export interface StoreWithIndexes<
- SD extends StoreDescriptor<unknown>,
- IndexMap
+ StoreName extends string,
+ RecordType,
+ IndexMap,
> {
- store: SD;
+ storeName: StoreName;
+ store: StoreDescriptor<RecordType>;
indexMap: IndexMap;
/**
@@ -345,64 +425,139 @@ export interface StoreWithIndexes<
mark: Symbol;
}
-export type GetRecordType<T> = T extends StoreDescriptor<infer X> ? X : unknown;
-
const storeWithIndexesSymbol = Symbol("StoreWithIndexesMark");
-export function describeStore<SD extends StoreDescriptor<unknown>, IndexMap>(
- s: SD,
+export function describeStore<StoreName extends string, RecordType, IndexMap>(
+ name: StoreName,
+ s: StoreDescriptor<RecordType>,
m: IndexMap,
-): StoreWithIndexes<SD, IndexMap> {
+): StoreWithIndexes<StoreName, RecordType, IndexMap> {
return {
+ storeName: name,
store: s,
indexMap: m,
mark: storeWithIndexesSymbol,
};
}
-export type GetReadOnlyAccess<BoundStores> = {
- [P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes<
- infer SD,
- infer IM
- >
- ? StoreReadOnlyAccessor<GetRecordType<SD>, IM>
- : unknown;
-};
+export function describeStoreV2<
+ StoreName extends string,
+ RecordType,
+ IndexMap extends { [x: string]: IndexDescriptor } = {},
+>(args: {
+ storeName: StoreName;
+ recordCodec: Codec<RecordType>;
+ keyPath?: IDBKeyPath | IDBKeyPath[];
+ autoIncrement?: boolean;
+ /**
+ * Database version that this store was added in, or
+ * undefined if added in the first version.
+ */
+ versionAdded?: number;
+ indexes?: IndexMap;
+}): StoreWithIndexes<StoreName, RecordType, IndexMap> {
+ return {
+ storeName: args.storeName,
+ store: {
+ _dummy: undefined as any,
+ autoIncrement: args.autoIncrement,
+ keyPath: args.keyPath,
+ versionAdded: args.versionAdded,
+ },
+ indexMap: args.indexes ?? ({} as IndexMap),
+ mark: storeWithIndexesSymbol,
+ };
+}
-export type GetReadWriteAccess<BoundStores> = {
- [P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes<
- infer SD,
- infer IM
- >
- ? StoreReadWriteAccessor<GetRecordType<SD>, IM>
- : unknown;
-};
+type KeyPathComponents = string | number;
-type ReadOnlyTransactionFunction<BoundStores, T> = (
- t: GetReadOnlyAccess<BoundStores>,
-) => Promise<T>;
+/**
+ * Follow a key path (dot-separated) in an object.
+ */
+type DerefKeyPath<T, P> = P extends `${infer PX extends keyof T &
+ KeyPathComponents}`
+ ? T[PX]
+ : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}`
+ ? DerefKeyPath<T[P0], Rest>
+ : unknown;
-type ReadWriteTransactionFunction<BoundStores, T> = (
- t: GetReadWriteAccess<BoundStores>,
-) => Promise<T>;
+/**
+ * Return a path if it is a valid dot-separate path to an object.
+ * Otherwise, return "never".
+ */
+type ValidateKeyPath<T, P> = P extends `${infer PX extends keyof T &
+ KeyPathComponents}`
+ ? PX
+ : P extends `${infer P0 extends keyof T & KeyPathComponents}.${infer Rest}`
+ ? `${P0}.${ValidateKeyPath<T[P0], Rest>}`
+ : never;
+
+// function foo<T, P>(
+// x: T,
+// p: P extends ValidateKeyPath<T, P> ? P : never,
+// ): void {}
+
+// foo({x: [0,1,2]}, "x.0");
+
+export type StoreNames<StoreMap> = StoreMap extends {
+ [P in keyof StoreMap]: StoreWithIndexes<infer SN1, infer SD1, infer IM1>;
+}
+ ? keyof StoreMap
+ : unknown;
-export interface TransactionContext<BoundStores> {
- runReadWrite<T>(f: ReadWriteTransactionFunction<BoundStores, T>): Promise<T>;
- runReadOnly<T>(f: ReadOnlyTransactionFunction<BoundStores, T>): Promise<T>;
+export type DbReadWriteTransaction<
+ StoreMap,
+ StoresArr extends Array<StoreNames<StoreMap>>,
+> = StoreMap extends {
+ [P in string]: StoreWithIndexes<infer _SN1, infer _SD1, infer _IM1>;
}
+ ? {
+ [X in StoresArr[number] &
+ keyof StoreMap]: StoreMap[X] extends StoreWithIndexes<
+ infer _StoreName,
+ infer RecordType,
+ infer IndexMap
+ >
+ ? StoreReadWriteAccessor<RecordType, IndexMap>
+ : unknown;
+ }
+ : never;
-type CheckDescriptor<T> = T extends StoreWithIndexes<infer SD, infer IM>
- ? StoreWithIndexes<SD, IM>
- : unknown;
+export type DbReadOnlyTransaction<
+ StoreMap,
+ StoresArr extends Array<StoreNames<StoreMap>>,
+> = StoreMap extends {
+ [P in string]: StoreWithIndexes<infer _SN1, infer _SD1, infer _IM1>;
+}
+ ? {
+ [X in StoresArr[number] &
+ keyof StoreMap]: StoreMap[X] extends StoreWithIndexes<
+ infer _StoreName,
+ infer RecordType,
+ infer IndexMap
+ >
+ ? StoreReadOnlyAccessor<RecordType, IndexMap>
+ : unknown;
+ }
+ : never;
-type GetPickerType<F, SM> = F extends (x: SM) => infer Out
- ? { [P in keyof Out]: CheckDescriptor<Out[P]> }
+/**
+ * Convert the type of an array to a union of the contents.
+ *
+ * Example:
+ * Input ["foo", "bar"]
+ * Output "foo" | "bar"
+ */
+export type UnionFromArray<Arr> = Arr extends {
+ [X in keyof Arr]: Arr[X] & string;
+}
+ ? Arr[keyof Arr & number]
: unknown;
function runTx<Arg, Res>(
tx: IDBTransaction,
arg: Arg,
- f: (t: Arg) => Promise<Res>,
+ f: (t: Arg, t2: IDBTransaction) => Promise<Res>,
): Promise<Res> {
const stack = Error("Failed transaction was started here.");
return new Promise((resolve, reject) => {
@@ -419,14 +574,15 @@ function runTx<Arg, Res>(
if (!gotFunResult) {
const msg =
"BUG: transaction closed before transaction function returned";
- console.error(msg);
+ logger.error(msg);
+ logger.error(`${stack.stack}`);
reject(Error(msg));
}
resolve(funResult);
};
tx.onerror = () => {
logger.error("error in transaction");
- logger.error(`${stack}`);
+ logger.error(`${stack.stack}`);
};
tx.onabort = () => {
let msg: string;
@@ -438,9 +594,10 @@ function runTx<Arg, Res>(
msg = "Transaction aborted (no DB error)";
}
logger.error(msg);
+ logger.error(`${stack.stack}`);
reject(new TransactionAbortedError(msg));
};
- const resP = Promise.resolve().then(() => f(arg));
+ const resP = Promise.resolve().then(() => f(arg, tx));
resP
.then((result) => {
gotFunResult = true;
@@ -464,13 +621,13 @@ function runTx<Arg, Res>(
function makeReadContext(
tx: IDBTransaction,
- storePick: { [n: string]: StoreWithIndexes<any, any> },
+ storePick: { [n: string]: StoreWithIndexes<any, any, any> },
): any {
const ctx: { [s: string]: StoreReadOnlyAccessor<any, any> } = {};
for (const storeAlias in storePick) {
const indexes: { [s: string]: IndexReadOnlyAccessor<any> } = {};
const swi = storePick[storeAlias];
- const storeName = swi.store.name;
+ const storeName = swi.storeName;
for (const indexAlias in storePick[storeAlias].indexMap) {
const indexDescriptor: IndexDescriptor =
storePick[storeAlias].indexMap[indexAlias];
@@ -488,9 +645,23 @@ function makeReadContext(
return new ResultStream<any>(req);
},
getAll(query, count) {
- const req = tx.objectStore(storeName).index(indexName).getAll(query, count);
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .getAll(query, count);
return requestToPromise(req);
- }
+ },
+ getAllKeys(query, count) {
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .getAllKeys(query, count);
+ return requestToPromise(req);
+ },
+ count(query) {
+ const req = tx.objectStore(storeName).index(indexName).count(query);
+ return requestToPromise(req);
+ },
};
}
ctx[storeAlias] = {
@@ -499,6 +670,10 @@ function makeReadContext(
const req = tx.objectStore(storeName).get(key);
return requestToPromise(req);
},
+ getAll(query, count) {
+ const req = tx.objectStore(storeName).getAll(query, count);
+ return requestToPromise(req);
+ },
iter(query) {
const req = tx.objectStore(storeName).openCursor(query);
return new ResultStream<any>(req);
@@ -510,13 +685,13 @@ function makeReadContext(
function makeWriteContext(
tx: IDBTransaction,
- storePick: { [n: string]: StoreWithIndexes<any, any> },
+ storePick: { [n: string]: StoreWithIndexes<any, any, any> },
): any {
const ctx: { [s: string]: StoreReadWriteAccessor<any, any> } = {};
for (const storeAlias in storePick) {
const indexes: { [s: string]: IndexReadWriteAccessor<any> } = {};
const swi = storePick[storeAlias];
- const storeName = swi.store.name;
+ const storeName = swi.storeName;
for (const indexAlias in storePick[storeAlias].indexMap) {
const indexDescriptor: IndexDescriptor =
storePick[storeAlias].indexMap[indexAlias];
@@ -534,9 +709,23 @@ function makeWriteContext(
return new ResultStream<any>(req);
},
getAll(query, count) {
- const req = tx.objectStore(storeName).index(indexName).getAll(query, count);
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .getAll(query, count);
return requestToPromise(req);
- }
+ },
+ getAllKeys(query, count) {
+ const req = tx
+ .objectStore(storeName)
+ .index(indexName)
+ .getAllKeys(query, count);
+ return requestToPromise(req);
+ },
+ count(query) {
+ const req = tx.objectStore(storeName).index(indexName).count(query);
+ return requestToPromise(req);
+ },
};
}
ctx[storeAlias] = {
@@ -545,17 +734,27 @@ function makeWriteContext(
const req = tx.objectStore(storeName).get(key);
return requestToPromise(req);
},
+ getAll(query, count) {
+ const req = tx.objectStore(storeName).getAll(query, count);
+ return requestToPromise(req);
+ },
iter(query) {
const req = tx.objectStore(storeName).openCursor(query);
return new ResultStream<any>(req);
},
- add(r) {
- const req = tx.objectStore(storeName).add(r);
- return requestToPromise(req);
+ async add(r, k) {
+ const req = tx.objectStore(storeName).add(r, k);
+ const key = await requestToPromise(req);
+ return {
+ key: key,
+ };
},
- put(r) {
- const req = tx.objectStore(storeName).put(r);
- return requestToPromise(req);
+ async put(r, k) {
+ const req = tx.objectStore(storeName).put(r, k);
+ const key = await requestToPromise(req);
+ return {
+ key: key,
+ };
},
delete(k) {
const req = tx.objectStore(storeName).delete(k);
@@ -566,50 +765,148 @@ function makeWriteContext(
return ctx;
}
+export interface DbAccess<StoreMap> {
+ idbHandle(): IDBDatabase;
+
+ runAllStoresReadWriteTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadWriteTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T>;
+
+ runAllStoresReadOnlyTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadOnlyTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T>;
+
+ runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ storeNames: StoreNameArray,
+ txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T>;
+
+ runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ storeNames: StoreNameArray,
+ txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T>;
+}
+
+export interface TriggerSpec {
+ /**
+ * Trigger run after every successful commit, run outside of the transaction.
+ */
+ afterCommit?: (mode: IDBTransactionMode, stores: string[]) => void;
+}
+
/**
* Type-safe access to a database with a particular store map.
*
* A store map is the metadata that describes the store.
*/
-export class DbAccess<StoreMap> {
- constructor(private db: IDBDatabase, private stores: StoreMap) {}
-
- mktx<
- PickerType extends (x: StoreMap) => unknown,
- BoundStores extends GetPickerType<PickerType, StoreMap>
- >(f: PickerType): TransactionContext<BoundStores> {
- const storePick = f(this.stores) as any;
- if (typeof storePick !== "object" || storePick === null) {
- throw Error();
- }
- const storeNames: string[] = [];
- for (const storeAlias of Object.keys(storePick)) {
- const swi = (storePick as any)[storeAlias] as StoreWithIndexes<any, any>;
- if (swi.mark !== storeWithIndexesSymbol) {
- throw Error("invalid store descriptor returned from selector function");
- }
- storeNames.push(swi.store.name);
+export class DbAccessImpl<StoreMap> implements DbAccess<StoreMap> {
+ constructor(
+ private db: IDBDatabase,
+ private stores: StoreMap,
+ private triggers: TriggerSpec = {},
+ ) {}
+
+ idbHandle(): IDBDatabase {
+ return this.db;
+ }
+
+ runAllStoresReadWriteTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadWriteTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+ {};
+ const strStoreNames: string[] = [];
+ for (const sn of Object.keys(this.stores as any)) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
}
+ const tx = this.db.transaction(strStoreNames, "readwrite");
+ const writeContext = makeWriteContext(tx, accessibleStores);
+ return runTx(tx, writeContext, txf);
+ }
- const runReadOnly = <T>(
- txf: ReadOnlyTransactionFunction<BoundStores, T>,
- ): Promise<T> => {
- const tx = this.db.transaction(storeNames, "readonly");
- const readContext = makeReadContext(tx, storePick);
- return runTx(tx, readContext, txf);
- };
+ runAllStoresReadOnlyTx<T>(
+ options: {
+ label?: string;
+ },
+ txf: (
+ tx: DbReadOnlyTransaction<StoreMap, Array<StoreNames<StoreMap>>>,
+ ) => Promise<T>,
+ ): Promise<T> {
+ const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+ {};
+ const strStoreNames: string[] = [];
+ for (const sn of Object.keys(this.stores as any)) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
+ }
+ const tx = this.db.transaction(strStoreNames, "readonly");
+ const writeContext = makeReadContext(tx, accessibleStores);
+ return runTx(tx, writeContext, txf);
+ }
- const runReadWrite = <T>(
- txf: ReadWriteTransactionFunction<BoundStores, T>,
- ): Promise<T> => {
- const tx = this.db.transaction(storeNames, "readwrite");
- const writeContext = makeWriteContext(tx, storePick);
- return runTx(tx, writeContext, txf);
- };
+ runReadWriteTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ storeNames: StoreNameArray,
+ txf: (tx: DbReadWriteTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+ {};
+ const strStoreNames: string[] = [];
+ for (const sn of storeNames) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
+ }
+ const mode = "readwrite";
+ const tx = this.db.transaction(strStoreNames, mode);
+ const writeContext = makeWriteContext(tx, accessibleStores);
+ const res = runTx(tx, writeContext, txf);
+ if (this.triggers.afterCommit) {
+ this.triggers.afterCommit(mode, strStoreNames);
+ }
+ return res;
+ }
- return {
- runReadOnly,
- runReadWrite,
- };
+ runReadOnlyTx<T, StoreNameArray extends Array<StoreNames<StoreMap>>>(
+ storeNames: StoreNameArray,
+ txf: (tx: DbReadOnlyTransaction<StoreMap, StoreNameArray>) => Promise<T>,
+ ): Promise<T> {
+ const accessibleStores: { [x: string]: StoreWithIndexes<any, any, any> } =
+ {};
+ const strStoreNames: string[] = [];
+ for (const sn of storeNames) {
+ const swi = (this.stores as any)[sn] as StoreWithIndexes<any, any, any>;
+ strStoreNames.push(swi.storeName);
+ accessibleStores[swi.storeName] = swi;
+ }
+ const mode = "readonly";
+ const tx = this.db.transaction(strStoreNames, mode);
+ const readContext = makeReadContext(tx, accessibleStores);
+ const res = runTx(tx, readContext, txf);
+ if (this.triggers.afterCommit) {
+ this.triggers.afterCommit(mode, strStoreNames);
+ }
+ return res;
}
+
+ registerPostCommitTrigger(args: {
+ handler: (storeNames: string[]) => void;
+ }): void {}
}
diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts
new file mode 100644
index 000000000..758ba106d
--- /dev/null
+++ b/packages/taler-wallet-core/src/recoup.ts
@@ -0,0 +1,544 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2020 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Implementation of the recoup operation, which allows to recover the
+ * value of coins held in a revoked denomination.
+ *
+ * @author Florian Dold <dold@taler.net>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ Amounts,
+ CoinStatus,
+ Logger,
+ RefreshReason,
+ TalerPreciseTimestamp,
+ TransactionIdStr,
+ TransactionType,
+ URL,
+ checkDbInvariant,
+ codecForRecoupConfirmation,
+ codecForReserveStatus,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
+ CoinRecord,
+ CoinSourceType,
+ RecoupGroupRecord,
+ RecoupOperationStatus,
+ RefreshCoinSource,
+ WalletDbReadWriteTransaction,
+ WithdrawCoinSource,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampPreciseToDb,
+} from "./db.js";
+import { createRefreshGroup } from "./refresh.js";
+import { constructTransactionIdentifier } from "./transactions.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+import { internalCreateWithdrawalGroup } from "./withdraw.js";
+
+export const logger = new Logger("operations/recoup.ts");
+
+/**
+ * Store a recoup group record in the database after marking
+ * a coin in the group as finished.
+ */
+export async function putGroupAsFinished(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["recoupGroups", "denominations", "refreshGroups", "coins"]
+ >,
+ recoupGroup: RecoupGroupRecord,
+ coinIdx: number,
+): Promise<void> {
+ logger.trace(
+ `setting coin ${coinIdx} of ${recoupGroup.coinPubs.length} as finished`,
+ );
+ if (recoupGroup.timestampFinished) {
+ return;
+ }
+ recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
+ await tx.recoupGroups.put(recoupGroup);
+}
+
+async function recoupRewardCoin(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+): Promise<void> {
+ // We can't really recoup a coin we got via tipping.
+ // Thus we just put the coin to sleep.
+ // FIXME: somehow report this to the user
+ await wex.db.runReadWriteTx(
+ ["recoupGroups", "denominations", "refreshGroups", "coins"],
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+async function recoupRefreshCoin(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+ cs: RefreshCoinSource,
+): Promise<void> {
+ const d = await wex.db.runReadOnlyTx(
+ ["coins", "denominations"],
+ async (tx) => {
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ return;
+ }
+ return { denomInfo };
+ },
+ );
+ if (!d) {
+ // FIXME: We should at least emit some pending operation / warning for this?
+ return;
+ }
+
+ const recoupRequest = await wex.cryptoApi.createRecoupRefreshRequest({
+ blindingKey: coin.blindingKey,
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPub: d.denomInfo.denomPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ });
+ const reqUrl = new URL(
+ `/coins/${coin.coinPub}/recoup-refresh`,
+ coin.exchangeBaseUrl,
+ );
+ logger.trace(`making recoup request for ${coin.coinPub}`);
+
+ const resp = await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: recoupRequest,
+ });
+ const recoupConfirmation = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForRecoupConfirmation(),
+ );
+
+ if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
+ throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
+ }
+
+ await wex.db.runReadWriteTx(
+ ["coins", "denominations", "recoupGroups", "refreshGroups"],
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ const oldCoin = await tx.coins.get(cs.oldCoinPub);
+ const revokedCoin = await tx.coins.get(coin.coinPub);
+ if (!revokedCoin) {
+ logger.warn("revoked coin for recoup not found");
+ return;
+ }
+ if (!oldCoin) {
+ logger.warn("refresh old coin for recoup not found");
+ return;
+ }
+ const oldCoinDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ const revokedCoinDenom = await getDenomInfo(
+ wex,
+ tx,
+ revokedCoin.exchangeBaseUrl,
+ revokedCoin.denomPubHash,
+ );
+ checkDbInvariant(!!oldCoinDenom);
+ checkDbInvariant(!!revokedCoinDenom);
+ revokedCoin.status = CoinStatus.Dormant;
+ if (!revokedCoin.spendAllocation) {
+ // We don't know what happened to this coin
+ logger.error(
+ `can't refresh-recoup coin ${revokedCoin.coinPub}, no spendAllocation known`,
+ );
+ } else {
+ let residualAmount = Amounts.sub(
+ revokedCoinDenom.value,
+ revokedCoin.spendAllocation.amount,
+ ).amount;
+ recoupGroup.scheduleRefreshCoins.push({
+ coinPub: oldCoin.coinPub,
+ amount: Amounts.stringify(residualAmount),
+ });
+ }
+ await tx.coins.put(revokedCoin);
+ await tx.coins.put(oldCoin);
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+export async function recoupWithdrawCoin(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+ coinIdx: number,
+ coin: CoinRecord,
+ cs: WithdrawCoinSource,
+): Promise<void> {
+ const reservePub = cs.reservePub;
+ const denomInfo = await wex.db.runReadOnlyTx(
+ ["denominations"],
+ async (tx) => {
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ return denomInfo;
+ },
+ );
+ if (!denomInfo) {
+ // FIXME: We should at least emit some pending operation / warning for this?
+ return;
+ }
+
+ const recoupRequest = await wex.cryptoApi.createRecoupRequest({
+ blindingKey: coin.blindingKey,
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ denomPub: denomInfo.denomPub,
+ denomPubHash: coin.denomPubHash,
+ denomSig: coin.denomSig,
+ });
+ const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
+ logger.trace(`requesting recoup via ${reqUrl.href}`);
+ const resp = await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: recoupRequest,
+ });
+ const recoupConfirmation = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForRecoupConfirmation(),
+ );
+
+ logger.trace(`got recoup confirmation ${j2s(recoupConfirmation)}`);
+
+ if (recoupConfirmation.reserve_pub !== reservePub) {
+ throw Error(`Coin's reserve doesn't match reserve on recoup`);
+ }
+
+ // FIXME: verify that our expectations about the amount match
+ await wex.db.runReadWriteTx(
+ ["coins", "denominations", "recoupGroups", "refreshGroups"],
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+ const updatedCoin = await tx.coins.get(coin.coinPub);
+ if (!updatedCoin) {
+ return;
+ }
+ updatedCoin.status = CoinStatus.Dormant;
+ await tx.coins.put(updatedCoin);
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ },
+ );
+}
+
+export async function processRecoupGroup(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+): Promise<TaskRunResult> {
+ let recoupGroup = await wex.db.runReadOnlyTx(["recoupGroups"], async (tx) => {
+ return tx.recoupGroups.get(recoupGroupId);
+ });
+ if (!recoupGroup) {
+ return TaskRunResult.finished();
+ }
+ if (recoupGroup.timestampFinished) {
+ logger.trace("recoup group finished");
+ return TaskRunResult.finished();
+ }
+ const ps = recoupGroup.coinPubs.map(async (x, i) => {
+ try {
+ await processRecoupForCoin(wex, recoupGroupId, i);
+ } catch (e) {
+ logger.warn(`processRecoup failed: ${e}`);
+ throw e;
+ }
+ });
+ await Promise.all(ps);
+
+ recoupGroup = await wex.db.runReadOnlyTx(["recoupGroups"], async (tx) => {
+ return tx.recoupGroups.get(recoupGroupId);
+ });
+ if (!recoupGroup) {
+ return TaskRunResult.finished();
+ }
+
+ for (const b of recoupGroup.recoupFinishedPerCoin) {
+ if (!b) {
+ return TaskRunResult.finished();
+ }
+ }
+
+ logger.info("all recoups of recoup group are finished");
+
+ const reserveSet = new Set<string>();
+ const reservePrivMap: Record<string, string> = {};
+ for (let i = 0; i < recoupGroup.coinPubs.length; i++) {
+ const coinPub = recoupGroup.coinPubs[i];
+ await wex.db.runReadOnlyTx(["coins", "reserves"], async (tx) => {
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ throw Error(`Coin ${coinPub} not found, can't request recoup`);
+ }
+ if (coin.coinSource.type === CoinSourceType.Withdraw) {
+ const reserve = await tx.reserves.indexes.byReservePub.get(
+ coin.coinSource.reservePub,
+ );
+ if (!reserve) {
+ return;
+ }
+ reserveSet.add(coin.coinSource.reservePub);
+ reservePrivMap[coin.coinSource.reservePub] = reserve.reservePriv;
+ }
+ });
+ }
+
+ for (const reservePub of reserveSet) {
+ const reserveUrl = new URL(
+ `reserves/${reservePub}`,
+ recoupGroup.exchangeBaseUrl,
+ );
+ logger.info(`querying reserve status for recoup via ${reserveUrl}`);
+
+ const resp = await wex.http.fetch(reserveUrl.href);
+
+ const result = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForReserveStatus(),
+ );
+ await internalCreateWithdrawalGroup(wex, {
+ amount: Amounts.parseOrThrow(result.balance),
+ exchangeBaseUrl: recoupGroup.exchangeBaseUrl,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair: {
+ pub: reservePub,
+ priv: reservePrivMap[reservePub],
+ },
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.Recoup,
+ },
+ });
+ }
+
+ await wex.db.runReadWriteTx(
+ [
+ "recoupGroups",
+ "coinAvailability",
+ "denominations",
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ ],
+ async (tx) => {
+ const rg2 = await tx.recoupGroups.get(recoupGroupId);
+ if (!rg2) {
+ return;
+ }
+ rg2.timestampFinished = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ rg2.operationStatus = RecoupOperationStatus.Finished;
+ if (rg2.scheduleRefreshCoins.length > 0) {
+ await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount),
+ rg2.scheduleRefreshCoins,
+ RefreshReason.Recoup,
+ constructTransactionIdentifier({
+ tag: TransactionType.Recoup,
+ recoupGroupId: rg2.recoupGroupId,
+ }),
+ );
+ }
+ await tx.recoupGroups.put(rg2);
+ },
+ );
+ return TaskRunResult.finished();
+}
+
+export class RecoupTransactionContext implements TransactionContext {
+ abortTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ suspendTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ resumeTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ failTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ deleteTransaction(): Promise<void> {
+ throw new Error("Method not implemented.");
+ }
+ public transactionId: TransactionIdStr;
+ public taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ private recoupGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Recoup,
+ recoupGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId,
+ });
+ }
+}
+
+export async function createRecoupGroup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["recoupGroups", "denominations", "refreshGroups", "coins"]
+ >,
+ exchangeBaseUrl: string,
+ coinPubs: string[],
+): Promise<string> {
+ const recoupGroupId = encodeCrock(getRandomBytes(32));
+
+ const recoupGroup: RecoupGroupRecord = {
+ recoupGroupId,
+ exchangeBaseUrl: exchangeBaseUrl,
+ coinPubs: coinPubs,
+ timestampFinished: undefined,
+ timestampStarted: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ recoupFinishedPerCoin: coinPubs.map(() => false),
+ scheduleRefreshCoins: [],
+ operationStatus: RecoupOperationStatus.Pending,
+ };
+
+ for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
+ const coinPub = coinPubs[coinIdx];
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ await putGroupAsFinished(wex, tx, recoupGroup, coinIdx);
+ continue;
+ }
+ await tx.coins.put(coin);
+ }
+
+ await tx.recoupGroups.put(recoupGroup);
+
+ const ctx = new RecoupTransactionContext(wex, recoupGroupId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return recoupGroupId;
+}
+
+/**
+ * Run the recoup protocol for a single coin in a recoup group.
+ */
+async function processRecoupForCoin(
+ wex: WalletExecutionContext,
+ recoupGroupId: string,
+ coinIdx: number,
+): Promise<void> {
+ const coin = await wex.db.runReadOnlyTx(
+ ["coins", "recoupGroups"],
+ async (tx) => {
+ const recoupGroup = await tx.recoupGroups.get(recoupGroupId);
+ if (!recoupGroup) {
+ return;
+ }
+ if (recoupGroup.timestampFinished) {
+ return;
+ }
+ if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
+ return;
+ }
+
+ const coinPub = recoupGroup.coinPubs[coinIdx];
+
+ const coin = await tx.coins.get(coinPub);
+ if (!coin) {
+ throw Error(`Coin ${coinPub} not found, can't request recoup`);
+ }
+ return coin;
+ },
+ );
+
+ if (!coin) {
+ return;
+ }
+
+ const cs = coin.coinSource;
+
+ switch (cs.type) {
+ case CoinSourceType.Reward:
+ return recoupRewardCoin(wex, recoupGroupId, coinIdx, coin);
+ case CoinSourceType.Refresh:
+ return recoupRefreshCoin(wex, recoupGroupId, coinIdx, coin, cs);
+ case CoinSourceType.Withdraw:
+ return recoupWithdrawCoin(wex, recoupGroupId, coinIdx, coin, cs);
+ default:
+ throw Error("unknown coin source type");
+ }
+}
diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts
new file mode 100644
index 000000000..99ac5737b
--- /dev/null
+++ b/packages/taler-wallet-core/src/refresh.ts
@@ -0,0 +1,1857 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @fileoverview
+ * Implementation of the refresh transaction.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AgeCommitment,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ amountToPretty,
+ assertUnreachable,
+ AsyncFlag,
+ checkDbInvariant,
+ codecForCoinHistoryResponse,
+ codecForExchangeMeltResponse,
+ codecForExchangeRevealResponse,
+ CoinPublicKeyString,
+ CoinRefreshRequest,
+ CoinStatus,
+ DenominationInfo,
+ DenomKeyType,
+ Duration,
+ encodeCrock,
+ ExchangeMeltRequest,
+ ExchangeProtocolVersion,
+ ExchangeRefreshRevealRequest,
+ fnutil,
+ ForceRefreshRequest,
+ getErrorDetailFromException,
+ getRandomBytes,
+ HashCodeString,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ makeErrorDetail,
+ NotificationType,
+ RefreshReason,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import {
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import {
+ constructTaskIdentifier,
+ makeCoinsVisible,
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResult,
+ TransitionResultType,
+} from "./common.js";
+import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
+import {
+ DerivedRefreshSession,
+ RefreshNewDenomInfo,
+} from "./crypto/cryptoTypes.js";
+import { CryptoApiStoppedError } from "./crypto/workers/crypto-dispatcher.js";
+import {
+ CoinAvailabilityRecord,
+ CoinRecord,
+ CoinSourceType,
+ DenominationRecord,
+ RefreshCoinStatus,
+ RefreshGroupPerExchangeInfo,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ RefreshSessionRecord,
+ timestampPreciseToDb,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletDbStoresArr,
+} from "./db.js";
+import { selectWithdrawalDenominations } from "./denomSelection.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+ TransitionInfo,
+} from "./transactions.js";
+import {
+ EXCHANGE_COINS_LOCK,
+ getDenomInfo,
+ WalletExecutionContext,
+} from "./wallet.js";
+import { getCandidateWithdrawalDenomsTx } from "./withdraw.js";
+
+const logger = new Logger("refresh.ts");
+
+/**
+ * Update the materialized refresh transaction based
+ * on the refresh group record.
+ */
+async function updateRefreshTransaction(
+ ctx: RefreshTransactionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "refreshGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+): Promise<void> {}
+
+export class RefreshTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public refreshGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId,
+ });
+ }
+
+ /**
+ * Transition a withdrawal transaction.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transition<StoreNameArray extends WalletDbStoresArr = []>(
+ opts: { extraStores?: StoreNameArray; transactionLabel?: string },
+ f: (
+ rec: RefreshGroupRecord | undefined,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "refreshGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ...StoreNameArray,
+ ]
+ >,
+ ) => Promise<TransitionResult<RefreshGroupRecord>>,
+ ): Promise<TransitionInfo | undefined> {
+ const baseStores = [
+ "refreshGroups" as const,
+ "transactions" as const,
+ "operationRetries" as const,
+ "exchanges" as const,
+ "exchangeDetails" as const,
+ ];
+ let stores = opts.extraStores
+ ? [...baseStores, ...opts.extraStores]
+ : baseStores;
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ stores,
+ async (tx) => {
+ const wgRec = await tx.refreshGroups.get(this.refreshGroupId);
+ let oldTxState: TransactionState;
+ if (wgRec) {
+ oldTxState = computeRefreshTransactionState(wgRec);
+ } else {
+ oldTxState = {
+ major: TransactionMajorState.None,
+ };
+ }
+ const res = await f(wgRec, tx);
+ switch (res.type) {
+ case TransitionResultType.Transition: {
+ await tx.refreshGroups.put(res.rec);
+ await updateRefreshTransaction(this, tx);
+ const newTxState = computeRefreshTransactionState(res.rec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ case TransitionResultType.Delete:
+ await tx.refreshGroups.delete(this.refreshGroupId);
+ await updateRefreshTransaction(this, tx);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.None,
+ },
+ };
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ return transitionInfo;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ await this.transition(
+ {
+ extraStores: ["tombstones"],
+ },
+ async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteRefreshGroup + ":" + this.refreshGroupId,
+ });
+ return TransitionResult.delete();
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ await this.transition({}, async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ case RefreshOperationStatus.Suspended:
+ case RefreshOperationStatus.Failed:
+ return TransitionResult.stay();
+ case RefreshOperationStatus.Pending: {
+ rec.operationStatus = RefreshOperationStatus.Suspended;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ assertUnreachable(rec.operationStatus);
+ }
+ });
+ }
+
+ async abortTransaction(): Promise<void> {
+ // Refresh transactions only support fail, not abort.
+ throw new Error("refresh transactions cannot be aborted");
+ }
+
+ async resumeTransaction(): Promise<void> {
+ await this.transition({}, async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ case RefreshOperationStatus.Failed:
+ case RefreshOperationStatus.Pending:
+ return TransitionResult.stay();
+ case RefreshOperationStatus.Suspended: {
+ rec.operationStatus = RefreshOperationStatus.Pending;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ assertUnreachable(rec.operationStatus);
+ }
+ });
+ }
+
+ async failTransaction(): Promise<void> {
+ await this.transition({}, async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ case RefreshOperationStatus.Failed:
+ return TransitionResult.stay();
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended: {
+ rec.operationStatus = RefreshOperationStatus.Failed;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ assertUnreachable(rec.operationStatus);
+ }
+ });
+ }
+}
+
+export async function getTotalRefreshCost(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ refreshedDenom: DenominationInfo,
+ amountLeft: AmountJson,
+): Promise<AmountJson> {
+ const cacheKey = `denom=${refreshedDenom.exchangeBaseUrl}/${
+ refreshedDenom.denomPubHash
+ };left=${Amounts.stringify(amountLeft)}`;
+ const cacheRes = wex.ws.refreshCostCache.get(cacheKey);
+ if (cacheRes) {
+ return cacheRes;
+ }
+ const allDenoms = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ refreshedDenom.exchangeBaseUrl,
+ Amounts.currencyOf(amountLeft),
+ );
+ const res = getTotalRefreshCostInternal(
+ allDenoms,
+ refreshedDenom,
+ amountLeft,
+ );
+ wex.ws.refreshCostCache.put(cacheKey, res);
+ return res;
+}
+
+/**
+ * Get the amount that we lose when refreshing a coin of the given denomination
+ * with a certain amount left.
+ *
+ * If the amount left is zero, then the refresh cost
+ * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of
+ * the right denominations), then the cost is the full amount left.
+ *
+ * Considers refresh fees, withdrawal fees after refresh and amounts too small
+ * to refresh.
+ */
+export function getTotalRefreshCostInternal(
+ denoms: DenominationRecord[],
+ refreshedDenom: DenominationInfo,
+ amountLeft: AmountJson,
+): AmountJson {
+ const withdrawAmount = Amounts.sub(
+ amountLeft,
+ refreshedDenom.feeRefresh,
+ ).amount;
+ const denomMap = Object.fromEntries(denoms.map((x) => [x.denomPubHash, x]));
+ const withdrawDenoms = selectWithdrawalDenominations(
+ withdrawAmount,
+ denoms,
+ false,
+ );
+ const resultingAmount = Amounts.add(
+ Amounts.zeroOfCurrency(withdrawAmount.currency),
+ ...withdrawDenoms.selectedDenoms.map(
+ (d) => Amounts.mult(denomMap[d.denomPubHash].value, d.count).amount,
+ ),
+ ).amount;
+ const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
+ logger.trace(
+ `total refresh cost for ${amountToPretty(amountLeft)} is ${amountToPretty(
+ totalCost,
+ )}`,
+ );
+ return totalCost;
+}
+
+async function getCoinAvailabilityForDenom(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["coins", "coinAvailability", "denominations"]
+ >,
+ denom: DenominationInfo,
+ ageRestriction: number,
+): Promise<CoinAvailabilityRecord> {
+ checkDbInvariant(!!denom);
+ let car = await tx.coinAvailability.get([
+ denom.exchangeBaseUrl,
+ denom.denomPubHash,
+ ageRestriction,
+ ]);
+ if (!car) {
+ car = {
+ maxAge: ageRestriction,
+ value: denom.value,
+ currency: Amounts.currencyOf(denom.value),
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ freshCoinCount: 0,
+ visibleCoinCount: 0,
+ };
+ }
+ return car;
+}
+
+/**
+ * Create a refresh session for one particular coin inside a refresh group.
+ */
+async function initRefreshSession(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["refreshSessions", "coinAvailability", "coins", "denominations"]
+ >,
+ refreshGroup: RefreshGroupRecord,
+ coinIndex: number,
+): Promise<void> {
+ const refreshGroupId = refreshGroup.refreshGroupId;
+ logger.trace(
+ `creating refresh session for coin ${coinIndex} in refresh group ${refreshGroupId}`,
+ );
+ const oldCoinPub = refreshGroup.oldCoinPubs[coinIndex];
+ const oldCoin = await tx.coins.get(oldCoinPub);
+ if (!oldCoin) {
+ throw Error("Can't refresh, coin not found");
+ }
+
+ const exchangeBaseUrl = oldCoin.exchangeBaseUrl;
+
+ const sessionSecretSeed = encodeCrock(getRandomBytes(64));
+
+ const oldDenom = await getDenomInfo(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+
+ if (!oldDenom) {
+ throw Error("db inconsistent: denomination for coin not found");
+ }
+
+ const currency = refreshGroup.currency;
+
+ const availableDenoms = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ currency,
+ );
+
+ const availableAmount = Amounts.sub(
+ refreshGroup.inputPerCoin[coinIndex],
+ oldDenom.feeRefresh,
+ ).amount;
+
+ const newCoinDenoms = selectWithdrawalDenominations(
+ availableAmount,
+ availableDenoms,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+
+ if (newCoinDenoms.selectedDenoms.length === 0) {
+ logger.trace(
+ `not refreshing, available amount ${amountToPretty(
+ availableAmount,
+ )} too small`,
+ );
+ refreshGroup.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ return;
+ }
+
+ for (let i = 0; i < newCoinDenoms.selectedDenoms.length; i++) {
+ const dph = newCoinDenoms.selectedDenoms[i].denomPubHash;
+ const denom = await getDenomInfo(wex, tx, oldDenom.exchangeBaseUrl, dph);
+ if (!denom) {
+ logger.error(`denom ${dph} not in DB`);
+ continue;
+ }
+ const car = await getCoinAvailabilityForDenom(
+ wex,
+ tx,
+ denom,
+ oldCoin.maxAge,
+ );
+ car.pendingRefreshOutputCount =
+ (car.pendingRefreshOutputCount ?? 0) +
+ newCoinDenoms.selectedDenoms[i].count;
+ await tx.coinAvailability.put(car);
+ }
+
+ const newSession: RefreshSessionRecord = {
+ coinIndex,
+ refreshGroupId,
+ norevealIndex: undefined,
+ sessionSecretSeed: sessionSecretSeed,
+ newDenoms: newCoinDenoms.selectedDenoms.map((x) => ({
+ count: x.count,
+ denomPubHash: x.denomPubHash,
+ })),
+ amountRefreshOutput: Amounts.stringify(newCoinDenoms.totalCoinValue),
+ };
+ await tx.refreshSessions.put(newSession);
+}
+
+/**
+ * Uninitialize a refresh session.
+ *
+ * Adjust the coin availability of involved coins.
+ */
+async function destroyRefreshSession(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coinAvailability", "coins"]
+ >,
+ refreshGroup: RefreshGroupRecord,
+ refreshSession: RefreshSessionRecord,
+): Promise<void> {
+ for (let i = 0; i < refreshSession.newDenoms.length; i++) {
+ const oldCoin = await tx.coins.get(
+ refreshGroup.oldCoinPubs[refreshSession.coinIndex],
+ );
+ if (!oldCoin) {
+ continue;
+ }
+ const dph = refreshSession.newDenoms[i].denomPubHash;
+ const denom = await getDenomInfo(wex, tx, oldCoin.exchangeBaseUrl, dph);
+ if (!denom) {
+ logger.error(`denom ${dph} not in DB`);
+ continue;
+ }
+ const car = await getCoinAvailabilityForDenom(
+ wex,
+ tx,
+ denom,
+ oldCoin.maxAge,
+ );
+ checkDbInvariant(car.pendingRefreshOutputCount != null);
+ car.pendingRefreshOutputCount =
+ car.pendingRefreshOutputCount - refreshSession.newDenoms[i].count;
+ await tx.coinAvailability.put(car);
+ }
+}
+
+function getRefreshRequestTimeout(rg: RefreshGroupRecord): Duration {
+ return Duration.fromSpec({
+ seconds: 5,
+ });
+}
+
+/**
+ * Run the melt step of a refresh session.
+ *
+ * If the melt step succeeds or fails permanently,
+ * the status in the refresh group is updated.
+ *
+ * When a transient error occurs, an exception is thrown.
+ */
+async function refreshMelt(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+ const d = await wex.db.runReadWriteTx(
+ ["refreshGroups", "refreshSessions", "coins", "denominations"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ const refreshSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ return;
+ }
+ if (refreshSession.norevealIndex !== undefined) {
+ return;
+ }
+
+ const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
+ checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
+ const oldDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!oldDenom,
+ "denomination for melted coin doesn't exist",
+ );
+
+ const newCoinDenoms: RefreshNewDenomInfo[] = [];
+
+ for (const dh of refreshSession.newDenoms) {
+ const newDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ dh.denomPubHash,
+ );
+ checkDbInvariant(
+ !!newDenom,
+ "new denomination for refresh not in database",
+ );
+ newCoinDenoms.push({
+ count: dh.count,
+ denomPub: newDenom.denomPub,
+ denomPubHash: newDenom.denomPubHash,
+ feeWithdraw: newDenom.feeWithdraw,
+ value: Amounts.stringify(newDenom.value),
+ });
+ }
+ return { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const { newCoinDenoms, oldCoin, oldDenom, refreshGroup, refreshSession } = d;
+
+ let exchangeProtocolVersion: ExchangeProtocolVersion;
+ switch (d.oldDenom.denomPub.cipher) {
+ case DenomKeyType.Rsa: {
+ exchangeProtocolVersion = ExchangeProtocolVersion.V12;
+ break;
+ }
+ default:
+ throw Error("unsupported key type");
+ }
+
+ const derived = await wex.cryptoApi.deriveRefreshSession({
+ exchangeProtocolVersion,
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
+ meltCoinMaxAge: oldCoin.maxAge,
+ meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
+ newCoinDenoms,
+ sessionSecretSeed: refreshSession.sessionSecretSeed,
+ });
+
+ const reqUrl = new URL(
+ `coins/${oldCoin.coinPub}/melt`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ let maybeAch: HashCodeString | undefined;
+ if (oldCoin.ageCommitmentProof) {
+ maybeAch = AgeRestriction.hashCommitment(
+ oldCoin.ageCommitmentProof.commitment,
+ );
+ }
+
+ const meltReqBody: ExchangeMeltRequest = {
+ coin_pub: oldCoin.coinPub,
+ confirm_sig: derived.confirmSig,
+ denom_pub_hash: oldCoin.denomPubHash,
+ denom_sig: oldCoin.denomSig,
+ rc: derived.hash,
+ value_with_fee: Amounts.stringify(derived.meltValueWithFee),
+ age_commitment_hash: maybeAch,
+ };
+
+ const resp = await wex.ws.runSequentialized(
+ [EXCHANGE_COINS_LOCK],
+ async () => {
+ return await wex.http.fetch(reqUrl.href, {
+ method: "POST",
+ body: meltReqBody,
+ timeout: getRefreshRequestTimeout(refreshGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ },
+ );
+
+ switch (resp.status) {
+ case HttpStatusCode.NotFound: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshMeltNotFound(ctx, coinIndex, errDetail);
+ return;
+ }
+ case HttpStatusCode.Gone: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshMeltGone(ctx, coinIndex, errDetail);
+ return;
+ }
+ case HttpStatusCode.Conflict: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshMeltConflict(
+ ctx,
+ coinIndex,
+ errDetail,
+ derived,
+ oldCoin,
+ );
+ return;
+ }
+ case HttpStatusCode.Ok:
+ break;
+ default: {
+ const errDetail = await readTalerErrorResponse(resp);
+ throwUnexpectedRequestError(resp, errDetail);
+ }
+ }
+
+ const meltResponse = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeMeltResponse(),
+ );
+
+ const norevealIndex = meltResponse.noreveal_index;
+
+ refreshSession.norevealIndex = norevealIndex;
+
+ await wex.db.runReadWriteTx(
+ ["refreshGroups", "refreshSessions"],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ if (!rs) {
+ return;
+ }
+ if (rs.norevealIndex !== undefined) {
+ return;
+ }
+ rs.norevealIndex = norevealIndex;
+ await tx.refreshSessions.put(rs);
+ },
+ );
+}
+
+async function handleRefreshMeltGone(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+): Promise<void> {
+ // const expiredMsg = codecForDenominationExpiredMessage().decode(errDetails);
+
+ // FIXME: Validate signature.
+
+ await ctx.wex.db.runReadWriteTx(
+ [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error("db invariant failed: missing refresh session in database");
+ }
+ refreshSession.lastError = errDetails;
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ },
+ );
+}
+
+async function handleRefreshMeltConflict(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+ derived: DerivedRefreshSession,
+ oldCoin: CoinRecord,
+): Promise<void> {
+ // Just log for better diagnostics here, error status
+ // will be handled later.
+ logger.error(
+ `melt request for ${Amounts.stringify(
+ derived.meltValueWithFee,
+ )} failed in refresh group ${ctx.refreshGroupId} due to conflict`,
+ );
+
+ const historySig = await ctx.wex.cryptoApi.signCoinHistoryRequest({
+ coinPriv: oldCoin.coinPriv,
+ coinPub: oldCoin.coinPub,
+ startOffset: 0,
+ });
+
+ const historyUrl = new URL(
+ `coins/${oldCoin.coinPub}/history`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ const historyResp = await ctx.wex.http.fetch(historyUrl.href, {
+ method: "GET",
+ headers: {
+ "Taler-Coin-History-Signature": historySig.sig,
+ },
+ cancellationToken: ctx.wex.cancellationToken,
+ });
+
+ const historyJson = await readSuccessResponseJsonOrThrow(
+ historyResp,
+ codecForCoinHistoryResponse(),
+ );
+ logger.info(`coin history: ${j2s(historyJson)}`);
+
+ // FIXME: If response seems wrong, report to auditor (in the future!);
+
+ await ctx.wex.db.runReadWriteTx(
+ [
+ "refreshGroups",
+ "refreshSessions",
+ "denominations",
+ "coins",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ if (Amounts.isZero(historyJson.balance)) {
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error(
+ "db invariant failed: missing refresh session in database",
+ );
+ }
+ refreshSession.lastError = errDetails;
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ } else {
+ // Try again with new denoms!
+ rg.inputPerCoin[coinIndex] = historyJson.balance;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error(
+ "db invariant failed: missing refresh session in database",
+ );
+ }
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ await tx.refreshSessions.delete([ctx.refreshGroupId, coinIndex]);
+ await initRefreshSession(ctx.wex, tx, rg, coinIndex);
+ }
+ },
+ );
+}
+
+async function handleRefreshMeltNotFound(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+): Promise<void> {
+ // FIXME: Validate the exchange's error response
+ await ctx.wex.db.runReadWriteTx(
+ [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error("db invariant failed: missing refresh session in database");
+ }
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ refreshSession.lastError = errDetails;
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ },
+ );
+}
+
+export async function assembleRefreshRevealRequest(args: {
+ cryptoApi: TalerCryptoInterface;
+ derived: DerivedRefreshSession;
+ norevealIndex: number;
+ oldCoinPub: CoinPublicKeyString;
+ oldCoinPriv: string;
+ newDenoms: {
+ denomPubHash: string;
+ count: number;
+ }[];
+ oldAgeCommitment?: AgeCommitment;
+}): Promise<ExchangeRefreshRevealRequest> {
+ const {
+ derived,
+ norevealIndex,
+ cryptoApi,
+ oldCoinPriv,
+ oldCoinPub,
+ newDenoms,
+ } = args;
+ const privs = Array.from(derived.transferPrivs);
+ privs.splice(norevealIndex, 1);
+
+ const planchets = derived.planchetsForGammas[norevealIndex];
+ if (!planchets) {
+ throw Error("refresh index error");
+ }
+
+ const newDenomsFlat: string[] = [];
+ const linkSigs: string[] = [];
+
+ for (let i = 0; i < newDenoms.length; i++) {
+ const dsel = newDenoms[i];
+ for (let j = 0; j < dsel.count; j++) {
+ const newCoinIndex = linkSigs.length;
+ const linkSig = await cryptoApi.signCoinLink({
+ coinEv: planchets[newCoinIndex].coinEv,
+ newDenomHash: dsel.denomPubHash,
+ oldCoinPriv: oldCoinPriv,
+ oldCoinPub: oldCoinPub,
+ transferPub: derived.transferPubs[norevealIndex],
+ });
+ linkSigs.push(linkSig.sig);
+ newDenomsFlat.push(dsel.denomPubHash);
+ }
+ }
+
+ const req: ExchangeRefreshRevealRequest = {
+ coin_evs: planchets.map((x) => x.coinEv),
+ new_denoms_h: newDenomsFlat,
+ transfer_privs: privs,
+ transfer_pub: derived.transferPubs[norevealIndex],
+ link_sigs: linkSigs,
+ old_age_commitment: args.oldAgeCommitment?.publicKeys,
+ };
+ return req;
+}
+
+async function refreshReveal(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ logger.trace(
+ `doing refresh reveal for ${refreshGroupId} (old coin ${coinIndex})`,
+ );
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+ const d = await wex.db.runReadOnlyTx(
+ ["refreshGroups", "refreshSessions", "coins", "denominations"],
+ async (tx) => {
+ const refreshGroup = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroup) {
+ return;
+ }
+ const refreshSession = await tx.refreshSessions.get([
+ refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ return;
+ }
+ const norevealIndex = refreshSession.norevealIndex;
+ if (norevealIndex === undefined) {
+ throw Error("can't reveal without melting first");
+ }
+
+ const oldCoin = await tx.coins.get(refreshGroup.oldCoinPubs[coinIndex]);
+ checkDbInvariant(!!oldCoin, "melt coin doesn't exist");
+ const oldDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ oldCoin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!oldDenom,
+ "denomination for melted coin doesn't exist",
+ );
+
+ const newCoinDenoms: RefreshNewDenomInfo[] = [];
+
+ for (const dh of refreshSession.newDenoms) {
+ const newDenom = await getDenomInfo(
+ wex,
+ tx,
+ oldCoin.exchangeBaseUrl,
+ dh.denomPubHash,
+ );
+ checkDbInvariant(
+ !!newDenom,
+ "new denomination for refresh not in database",
+ );
+ newCoinDenoms.push({
+ count: dh.count,
+ denomPub: newDenom.denomPub,
+ denomPubHash: newDenom.denomPubHash,
+ feeWithdraw: newDenom.feeWithdraw,
+ value: Amounts.stringify(newDenom.value),
+ });
+ }
+ return {
+ oldCoin,
+ oldDenom,
+ newCoinDenoms,
+ refreshSession,
+ refreshGroup,
+ norevealIndex,
+ };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const {
+ oldCoin,
+ oldDenom,
+ newCoinDenoms,
+ refreshSession,
+ refreshGroup,
+ norevealIndex,
+ } = d;
+
+ let exchangeProtocolVersion: ExchangeProtocolVersion;
+ switch (d.oldDenom.denomPub.cipher) {
+ case DenomKeyType.Rsa: {
+ exchangeProtocolVersion = ExchangeProtocolVersion.V12;
+ break;
+ }
+ default:
+ throw Error("unsupported key type");
+ }
+
+ const derived = await wex.cryptoApi.deriveRefreshSession({
+ exchangeProtocolVersion,
+ kappa: 3,
+ meltCoinDenomPubHash: oldCoin.denomPubHash,
+ meltCoinPriv: oldCoin.coinPriv,
+ meltCoinPub: oldCoin.coinPub,
+ feeRefresh: Amounts.parseOrThrow(oldDenom.feeRefresh),
+ newCoinDenoms,
+ meltCoinMaxAge: oldCoin.maxAge,
+ meltCoinAgeCommitmentProof: oldCoin.ageCommitmentProof,
+ sessionSecretSeed: refreshSession.sessionSecretSeed,
+ });
+
+ const reqUrl = new URL(
+ `refreshes/${derived.hash}/reveal`,
+ oldCoin.exchangeBaseUrl,
+ );
+
+ const req = await assembleRefreshRevealRequest({
+ cryptoApi: wex.cryptoApi,
+ derived,
+ newDenoms: newCoinDenoms,
+ norevealIndex: norevealIndex,
+ oldCoinPriv: oldCoin.coinPriv,
+ oldCoinPub: oldCoin.coinPub,
+ oldAgeCommitment: oldCoin.ageCommitmentProof?.commitment,
+ });
+
+ const resp = await wex.ws.runSequentialized(
+ [EXCHANGE_COINS_LOCK],
+ async () => {
+ return await wex.http.fetch(reqUrl.href, {
+ body: req,
+ method: "POST",
+ timeout: getRefreshRequestTimeout(refreshGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ },
+ );
+
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ break;
+ case HttpStatusCode.Conflict:
+ case HttpStatusCode.Gone: {
+ const errDetail = await readTalerErrorResponse(resp);
+ await handleRefreshRevealError(ctx, coinIndex, errDetail);
+ return;
+ }
+ default: {
+ const errDetail = await readTalerErrorResponse(resp);
+ throwUnexpectedRequestError(resp, errDetail);
+ }
+ }
+
+ const reveal = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeRevealResponse(),
+ );
+
+ const coins: CoinRecord[] = [];
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ });
+
+ for (let i = 0; i < refreshSession.newDenoms.length; i++) {
+ const ncd = newCoinDenoms[i];
+ for (let j = 0; j < refreshSession.newDenoms[i].count; j++) {
+ const newCoinIndex = coins.length;
+ const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex];
+ if (ncd.denomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error("cipher unsupported");
+ }
+ const evSig = reveal.ev_sigs[newCoinIndex].ev_sig;
+ const denomSig = await wex.cryptoApi.unblindDenominationSignature({
+ planchet: {
+ blindingKey: pc.blindingKey,
+ denomPub: ncd.denomPub,
+ },
+ evSig,
+ });
+ const coin: CoinRecord = {
+ blindingKey: pc.blindingKey,
+ coinPriv: pc.coinPriv,
+ coinPub: pc.coinPub,
+ denomPubHash: ncd.denomPubHash,
+ denomSig,
+ exchangeBaseUrl: oldCoin.exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ coinSource: {
+ type: CoinSourceType.Refresh,
+ refreshGroupId,
+ oldCoinPub: refreshGroup.oldCoinPubs[coinIndex],
+ },
+ sourceTransactionId: transactionId,
+ coinEvHash: pc.coinEvHash,
+ maxAge: pc.maxAge,
+ ageCommitmentProof: pc.ageCommitmentProof,
+ spendAllocation: undefined,
+ };
+
+ coins.push(coin);
+ }
+ }
+
+ await wex.db.runReadWriteTx(
+ [
+ "coins",
+ "denominations",
+ "coinAvailability",
+ "refreshGroups",
+ "refreshSessions",
+ ],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ logger.warn("no refresh session found");
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ if (!rs) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Finished;
+ for (const coin of coins) {
+ const existingCoin = await tx.coins.get(coin.coinPub);
+ if (existingCoin) {
+ continue;
+ }
+ await tx.coins.add(coin);
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denomInfo);
+ const car = await getCoinAvailabilityForDenom(
+ wex,
+ tx,
+ denomInfo,
+ coin.maxAge,
+ );
+ checkDbInvariant(
+ car.pendingRefreshOutputCount != null &&
+ car.pendingRefreshOutputCount > 0,
+ );
+ car.pendingRefreshOutputCount--;
+ car.freshCoinCount++;
+ await tx.coinAvailability.put(car);
+ }
+ await tx.refreshGroups.put(rg);
+ },
+ );
+ logger.trace("refresh finished (end of reveal)");
+}
+
+async function handleRefreshRevealError(
+ ctx: RefreshTransactionContext,
+ coinIndex: number,
+ errDetails: TalerErrorDetail,
+): Promise<void> {
+ await ctx.wex.db.runReadWriteTx(
+ [
+ "refreshGroups",
+ "refreshSessions",
+ "coins",
+ "denominations",
+ "coinAvailability",
+ ],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(ctx.refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ if (rg.timestampFinished) {
+ return;
+ }
+ if (rg.statusPerCoin[coinIndex] !== RefreshCoinStatus.Pending) {
+ return;
+ }
+ rg.statusPerCoin[coinIndex] = RefreshCoinStatus.Failed;
+ const refreshSession = await tx.refreshSessions.get([
+ ctx.refreshGroupId,
+ coinIndex,
+ ]);
+ if (!refreshSession) {
+ throw Error("db invariant failed: missing refresh session in database");
+ }
+ refreshSession.lastError = errDetails;
+ await destroyRefreshSession(ctx.wex, tx, rg, refreshSession);
+ await tx.refreshGroups.put(rg);
+ await tx.refreshSessions.put(refreshSession);
+ },
+ );
+}
+
+export async function processRefreshGroup(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+): Promise<TaskRunResult> {
+ logger.trace(`processing refresh group ${refreshGroupId}`);
+
+ const refreshGroup = await wex.db.runReadOnlyTx(
+ ["refreshGroups"],
+ async (tx) => tx.refreshGroups.get(refreshGroupId),
+ );
+ if (!refreshGroup) {
+ return TaskRunResult.finished();
+ }
+ if (refreshGroup.timestampFinished) {
+ return TaskRunResult.finished();
+ }
+
+ if (
+ wex.ws.config.testing.devModeActive &&
+ wex.ws.devExperimentState.blockRefreshes
+ ) {
+ throw Error("refresh blocked");
+ }
+
+ // Process refresh sessions of the group in parallel.
+ logger.trace(
+ `processing refresh sessions for ${refreshGroup.oldCoinPubs.length} old coins`,
+ );
+ let errors: TalerErrorDetail[] = [];
+ let inShutdown = false;
+ const ps = refreshGroup.oldCoinPubs.map((x, i) =>
+ processRefreshSession(wex, refreshGroupId, i).catch((x) => {
+ if (x instanceof CryptoApiStoppedError) {
+ inShutdown = true;
+ logger.info(
+ "crypto API stopped while processing refresh group, probably the wallet is currently shutting down.",
+ );
+ return;
+ }
+ if (x instanceof TalerError) {
+ logger.warn("process refresh session got exception (TalerError)");
+ logger.warn(`exc ${x}`);
+ logger.warn(`exc stack ${x.stack}`);
+ logger.warn(`error detail: ${j2s(x.errorDetail)}`);
+ } else {
+ logger.warn("process refresh session got exception");
+ logger.warn(`exc ${x}`);
+ logger.warn(`exc stack ${x.stack}`);
+ }
+ errors.push(getErrorDetailFromException(x));
+ }),
+ );
+ await Promise.all(ps);
+ if (inShutdown) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+
+ // We've processed all refresh session and can now update the
+ // status of the whole refresh group.
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ ["coins", "coinAvailability", "refreshGroups"],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ if (!rg) {
+ return;
+ }
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Pending:
+ break;
+ default:
+ return undefined;
+ }
+ const oldTxState = computeRefreshTransactionState(rg);
+ const allFinal = fnutil.all(
+ rg.statusPerCoin,
+ (x) =>
+ x === RefreshCoinStatus.Finished || x === RefreshCoinStatus.Failed,
+ );
+ const anyFailed = fnutil.any(
+ rg.statusPerCoin,
+ (x) => x === RefreshCoinStatus.Failed,
+ );
+ if (allFinal) {
+ if (anyFailed) {
+ rg.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ rg.operationStatus = RefreshOperationStatus.Failed;
+ } else {
+ rg.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ rg.operationStatus = RefreshOperationStatus.Finished;
+ }
+ await makeCoinsVisible(wex, tx, ctx.transactionId);
+ await tx.refreshGroups.put(rg);
+ const newTxState = computeRefreshTransactionState(rg);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+
+ if (transitionInfo) {
+ notifyTransition(wex, ctx.transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ }
+
+ if (errors.length > 0) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_REFRESH_GROUP_INCOMPLETE,
+ {
+ numErrors: errors.length,
+ errors: errors.slice(0, 5),
+ },
+ ),
+ };
+ }
+
+ return TaskRunResult.backoff();
+}
+
+async function processRefreshSession(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+ coinIndex: number,
+): Promise<void> {
+ logger.trace(
+ `processing refresh session for coin ${coinIndex} of group ${refreshGroupId}`,
+ );
+ let { refreshGroup, refreshSession } = await wex.db.runReadOnlyTx(
+ ["refreshGroups", "refreshSessions"],
+ async (tx) => {
+ const rg = await tx.refreshGroups.get(refreshGroupId);
+ const rs = await tx.refreshSessions.get([refreshGroupId, coinIndex]);
+ return {
+ refreshGroup: rg,
+ refreshSession: rs,
+ };
+ },
+ );
+ if (!refreshGroup) {
+ return;
+ }
+ if (refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Finished) {
+ return;
+ }
+ if (!refreshSession) {
+ // No refresh session for that coin.
+ return;
+ }
+ if (refreshSession.norevealIndex === undefined) {
+ await refreshMelt(wex, refreshGroupId, coinIndex);
+ }
+ await refreshReveal(wex, refreshGroupId, coinIndex);
+}
+
+export interface RefreshOutputInfo {
+ outputPerCoin: AmountJson[];
+ perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>;
+}
+
+export async function calculateRefreshOutput(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+): Promise<RefreshOutputInfo> {
+ const estimatedOutputPerCoin: AmountJson[] = [];
+
+ const denomsPerExchange: Record<string, DenominationRecord[]> = {};
+
+ const infoPerExchange: Record<string, RefreshGroupPerExchangeInfo> = {};
+
+ for (const ocp of oldCoinPubs) {
+ const coin = await tx.coins.get(ocp.coinPub);
+ checkDbInvariant(!!coin, "coin must be in database");
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!denom,
+ "denomination for existing coin must be in database",
+ );
+ const refreshAmount = ocp.amount;
+ const cost = await getTotalRefreshCost(
+ wex,
+ tx,
+ denom,
+ Amounts.parseOrThrow(refreshAmount),
+ );
+ const output = Amounts.sub(refreshAmount, cost).amount;
+ let exchInfo = infoPerExchange[coin.exchangeBaseUrl];
+ if (!exchInfo) {
+ infoPerExchange[coin.exchangeBaseUrl] = exchInfo = {
+ outputEffective: Amounts.stringify(Amounts.zeroOfAmount(cost)),
+ };
+ }
+ exchInfo.outputEffective = Amounts.stringify(
+ Amounts.add(exchInfo.outputEffective, output).amount,
+ );
+ estimatedOutputPerCoin.push(output);
+ }
+
+ return {
+ outputPerCoin: estimatedOutputPerCoin,
+ perExchangeInfo: infoPerExchange,
+ };
+}
+
+async function applyRefreshToOldCoins(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["denominations", "coins", "refreshGroups", "coinAvailability"]
+ >,
+ oldCoinPubs: CoinRefreshRequest[],
+ refreshGroupId: string,
+): Promise<void> {
+ for (const ocp of oldCoinPubs) {
+ const coin = await tx.coins.get(ocp.coinPub);
+ checkDbInvariant(!!coin, "coin must be in database");
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!denom,
+ "denomination for existing coin must be in database",
+ );
+ switch (coin.status) {
+ case CoinStatus.Dormant:
+ break;
+ case CoinStatus.Fresh: {
+ coin.status = CoinStatus.Dormant;
+ const coinAv = await tx.coinAvailability.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ coin.maxAge,
+ ]);
+ checkDbInvariant(!!coinAv);
+ checkDbInvariant(coinAv.freshCoinCount > 0);
+ coinAv.freshCoinCount--;
+ await tx.coinAvailability.put(coinAv);
+ break;
+ }
+ case CoinStatus.FreshSuspended: {
+ // For suspended coins, we don't have to adjust coin
+ // availability, as they are not counted as available.
+ coin.status = CoinStatus.Dormant;
+ break;
+ }
+ case CoinStatus.DenomLoss:
+ break;
+ default:
+ assertUnreachable(coin.status);
+ }
+ if (!coin.spendAllocation) {
+ coin.spendAllocation = {
+ amount: Amounts.stringify(ocp.amount),
+ // id: `txn:refresh:${refreshGroupId}`,
+ id: constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId,
+ }),
+ };
+ }
+ await tx.coins.put(coin);
+ }
+}
+
+export interface CreateRefreshGroupResult {
+ refreshGroupId: string;
+ notifications: WalletNotification[];
+}
+
+/**
+ * Create a refresh group for a list of coins.
+ *
+ * Refreshes the remaining amount on the coin, effectively capturing the remaining
+ * value in the refresh group.
+ *
+ * The caller must also ensure that the coins that should be refreshed exist
+ * in the current database transaction.
+ */
+export async function createRefreshGroup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "denominations",
+ "coins",
+ "refreshGroups",
+ "refreshSessions",
+ "coinAvailability",
+ ]
+ >,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+ refreshReason: RefreshReason,
+ originatingTransactionId: string | undefined,
+): Promise<CreateRefreshGroupResult> {
+ // FIXME: Check that involved exchanges are reasonably up-to-date.
+ // Otherwise, error out.
+
+ const refreshGroupId = encodeCrock(getRandomBytes(32));
+
+ const outInfo = await calculateRefreshOutput(wex, tx, currency, oldCoinPubs);
+
+ const estimatedOutputPerCoin = outInfo.outputPerCoin;
+
+ await applyRefreshToOldCoins(wex, tx, oldCoinPubs, refreshGroupId);
+
+ const refreshGroup: RefreshGroupRecord = {
+ operationStatus: RefreshOperationStatus.Pending,
+ currency,
+ timestampFinished: undefined,
+ statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
+ oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
+ originatingTransactionId,
+ reason: refreshReason,
+ refreshGroupId,
+ inputPerCoin: oldCoinPubs.map((x) => x.amount),
+ expectedOutputPerCoin: estimatedOutputPerCoin.map((x) =>
+ Amounts.stringify(x),
+ ),
+ infoPerExchange: outInfo.perExchangeInfo,
+ timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ };
+
+ if (oldCoinPubs.length == 0) {
+ logger.warn("created refresh group with zero coins");
+ refreshGroup.timestampFinished = timestampPreciseToDb(
+ TalerPreciseTimestamp.now(),
+ );
+ refreshGroup.operationStatus = RefreshOperationStatus.Finished;
+ }
+
+ for (let i = 0; i < oldCoinPubs.length; i++) {
+ await initRefreshSession(wex, tx, refreshGroup, i);
+ }
+
+ await tx.refreshGroups.put(refreshGroup);
+
+ const newTxState = computeRefreshTransactionState(refreshGroup);
+
+ logger.trace(`created refresh group ${refreshGroupId}`);
+
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+
+ // Shepherd the task.
+ // If the current transaction fails to commit the refresh
+ // group to the DB, the shepherd will give up.
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ refreshGroupId,
+ notifications: [
+ {
+ type: NotificationType.TransactionStateTransition,
+ transactionId: ctx.transactionId,
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState,
+ },
+ ],
+ };
+}
+
+export function computeRefreshTransactionState(
+ rg: RefreshGroupRecord,
+): TransactionState {
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case RefreshOperationStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ case RefreshOperationStatus.Pending:
+ return {
+ major: TransactionMajorState.Pending,
+ };
+ case RefreshOperationStatus.Suspended:
+ return {
+ major: TransactionMajorState.Suspended,
+ };
+ }
+}
+
+export function computeRefreshTransactionActions(
+ rg: RefreshGroupRecord,
+): TransactionAction[] {
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Finished:
+ return [TransactionAction.Delete];
+ case RefreshOperationStatus.Failed:
+ return [TransactionAction.Delete];
+ case RefreshOperationStatus.Pending:
+ return [
+ TransactionAction.Retry,
+ TransactionAction.Suspend,
+ TransactionAction.Fail,
+ ];
+ case RefreshOperationStatus.Suspended:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ }
+}
+
+export function getRefreshesForTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<string[]> {
+ return wex.db.runReadOnlyTx(["refreshGroups"], async (tx) => {
+ const groups =
+ await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll(
+ transactionId,
+ );
+ return groups.map((x) =>
+ constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: x.refreshGroupId,
+ }),
+ );
+ });
+}
+
+export interface ForceRefreshResult {
+ refreshGroupId: string;
+}
+
+export async function forceRefresh(
+ wex: WalletExecutionContext,
+ req: ForceRefreshRequest,
+): Promise<ForceRefreshResult> {
+ if (req.refreshCoinSpecs.length == 0) {
+ throw Error("refusing to create empty refresh group");
+ }
+ const res = await wex.db.runReadWriteTx(
+ [
+ "refreshGroups",
+ "coinAvailability",
+ "refreshSessions",
+ "denominations",
+ "coins",
+ ],
+ async (tx) => {
+ let coinPubs: CoinRefreshRequest[] = [];
+ for (const c of req.refreshCoinSpecs) {
+ const coin = await tx.coins.get(c.coinPub);
+ if (!coin) {
+ throw Error(`coin (pubkey ${c}) not found`);
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(!!denom);
+ coinPubs.push({
+ coinPub: c.coinPub,
+ amount: c.amount ?? denom.value,
+ });
+ }
+ return await createRefreshGroup(
+ wex,
+ tx,
+ Amounts.currencyOf(coinPubs[0].amount),
+ coinPubs,
+ RefreshReason.Manual,
+ undefined,
+ );
+ },
+ );
+
+ for (const notif of res.notifications) {
+ wex.ws.notify(notif);
+ }
+
+ return {
+ refreshGroupId: res.refreshGroupId,
+ };
+}
+
+/**
+ * Wait until a refresh operation is final.
+ */
+export async function waitRefreshFinal(
+ wex: WalletExecutionContext,
+ refreshGroupId: string,
+): Promise<void> {
+ const ctx = new RefreshTransactionContext(wex, refreshGroupId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const refreshNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our refresh.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ refreshNotifFlag.raise();
+ }
+ });
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ refreshNotifFlag.raise();
+ });
+
+ try {
+ await internalWaitRefreshFinal(ctx, refreshNotifFlag);
+ } catch (e) {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+async function internalWaitRefreshFinal(
+ ctx: RefreshTransactionContext,
+ flag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ if (ctx.wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+
+ // Check if refresh is final
+ const res = await ctx.wex.db.runReadOnlyTx(
+ ["refreshGroups", "operationRetries"],
+ async (tx) => {
+ return {
+ rg: await tx.refreshGroups.get(ctx.refreshGroupId),
+ };
+ },
+ );
+ const { rg } = res;
+ if (!rg) {
+ // Must've been deleted, we consider that final.
+ return;
+ }
+ switch (rg.operationStatus) {
+ case RefreshOperationStatus.Failed:
+ case RefreshOperationStatus.Finished:
+ // Transaction is final
+ return;
+ case RefreshOperationStatus.Pending:
+ case RefreshOperationStatus.Suspended:
+ break;
+ }
+
+ // Wait for the next transition
+ await flag.wait();
+ flag.reset();
+ }
+}
diff --git a/packages/taler-wallet-core/src/remote.ts b/packages/taler-wallet-core/src/remote.ts
new file mode 100644
index 000000000..d7623baab
--- /dev/null
+++ b/packages/taler-wallet-core/src/remote.ts
@@ -0,0 +1,191 @@
+/*
+ This file is part of GNU Taler
+ (C) 2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ CoreApiRequestEnvelope,
+ CoreApiResponse,
+ Logger,
+ OpenedPromise,
+ openPromise,
+ TalerError,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
+import { connectRpc, JsonMessage } from "@gnu-taler/taler-util/twrpc";
+import { WalletCoreApiClient } from "./wallet-api-types.js";
+
+const logger = new Logger("remote.ts");
+
+export interface RemoteWallet {
+ /**
+ * Low-level interface for making API requests to wallet-core.
+ */
+ makeCoreApiRequest(
+ operation: string,
+ payload: unknown,
+ ): Promise<CoreApiResponse>;
+
+ /**
+ * Close the connection to the remote wallet.
+ */
+ close(): void;
+}
+
+export interface RemoteWalletConnectArgs {
+ name?: string;
+ socketFilename: string;
+ notificationHandler?: (n: WalletNotification) => void;
+}
+
+export async function createRemoteWallet(
+ args: RemoteWalletConnectArgs,
+): Promise<RemoteWallet> {
+ let nextRequestId = 1;
+ let requestMap: Map<
+ string,
+ {
+ promiseCapability: OpenedPromise<CoreApiResponse>;
+ }
+ > = new Map();
+
+ const ctx = await connectRpc<RemoteWallet>({
+ socketFilename: args.socketFilename,
+ onEstablished(connection) {
+ const ctx: RemoteWallet = {
+ makeCoreApiRequest(operation, payload) {
+ const id = `req-${nextRequestId}`;
+ nextRequestId += 1;
+ const req: CoreApiRequestEnvelope = {
+ operation,
+ id,
+ args: payload,
+ };
+ const promiseCap = openPromise<CoreApiResponse>();
+ requestMap.set(id, {
+ promiseCapability: promiseCap,
+ });
+ connection.sendMessage(req as unknown as JsonMessage);
+ return promiseCap.promise;
+ },
+ close() {
+ connection.close();
+ },
+ };
+ return {
+ result: ctx,
+ onDisconnect() {
+ logger.info(`${args.name}: remote wallet disconnected`);
+ },
+ onMessage(m) {
+ // FIXME: use a codec for parsing the response envelope!
+
+ if (typeof m !== "object" || m == null) {
+ logger.warn(`${args.name}: message not understood (wrong type)`);
+ return;
+ }
+ const type = (m as any).type;
+ if (type === "response" || type === "error") {
+ const id = (m as any).id;
+ if (typeof id !== "string") {
+ logger.warn(
+ `${args.name}: message not understood (no id in response)`,
+ );
+ return;
+ }
+ const h = requestMap.get(id);
+ if (!h) {
+ logger.warn(
+ `${args.name}: no handler registered for response id ${id}`,
+ );
+ return;
+ }
+ h.promiseCapability.resolve(m as any);
+ } else if (type === "notification") {
+ if (args.notificationHandler) {
+ args.notificationHandler((m as any).payload);
+ }
+ } else {
+ logger.warn(`${args.name}: message not understood`);
+ }
+ },
+ };
+ },
+ });
+ return ctx;
+}
+
+/**
+ * Get a high-level API client from a remove wallet.
+ */
+export function getClientFromRemoteWallet(
+ w: RemoteWallet,
+): WalletCoreApiClient {
+ const client: WalletCoreApiClient = {
+ async call(op, payload): Promise<any> {
+ const res = await w.makeCoreApiRequest(op, payload);
+ switch (res.type) {
+ case "error":
+ throw TalerError.fromUncheckedDetail(res.error);
+ case "response":
+ return res.result;
+ }
+ },
+ };
+ return client;
+}
+
+export interface WalletNotificationWaiter {
+ notify(wn: WalletNotification): void;
+ waitForNotificationCond<T>(
+ cond: (n: WalletNotification) => T | false | undefined,
+ ): Promise<T>;
+}
+
+interface NotificationCondEntry<T> {
+ condition: (n: WalletNotification) => T | false | undefined;
+ promiseCapability: OpenedPromise<T>;
+}
+
+/**
+ * Helper that allows creating a promise that resolves when the
+ * wallet
+ */
+export function makeNotificationWaiter(): WalletNotificationWaiter {
+ // Bookkeeping for waiting on notification conditions
+ let nextCondIndex = 1;
+ const condMap: Map<number, NotificationCondEntry<any>> = new Map();
+ function onNotification(n: WalletNotification) {
+ condMap.forEach((cond, condKey) => {
+ const res = cond.condition(n);
+ if (res) {
+ cond.promiseCapability.resolve(res);
+ }
+ });
+ }
+ function waitForNotificationCond<T>(
+ cond: (n: WalletNotification) => T | false | undefined,
+ ) {
+ const promCap = openPromise<T>();
+ condMap.set(nextCondIndex++, {
+ condition: cond,
+ promiseCapability: promCap,
+ });
+ return promCap.promise;
+ }
+ return {
+ waitForNotificationCond,
+ notify: onNotification,
+ };
+}
diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts
new file mode 100644
index 000000000..58bdcf0dd
--- /dev/null
+++ b/packages/taler-wallet-core/src/shepherd.ts
@@ -0,0 +1,1104 @@
+/*
+ This file is part of GNU Taler
+ (C) 2024 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ AsyncCondition,
+ CancellationToken,
+ Duration,
+ Logger,
+ NotificationType,
+ ObservabilityContext,
+ ObservabilityEventType,
+ RetryLoopOpts,
+ TalerErrorDetail,
+ TaskThrottler,
+ TransactionIdStr,
+ TransactionState,
+ TransactionType,
+ WalletNotification,
+ assertUnreachable,
+ getErrorDetailFromException,
+ j2s,
+} from "@gnu-taler/taler-util";
+import { processBackupForProvider } from "./backup/index.js";
+import {
+ DbRetryInfo,
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ constructTaskIdentifier,
+ getExchangeState,
+ parseTaskIdentifier,
+} from "./common.js";
+import {
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ OperationRetryRecord,
+ WalletDbAllStoresReadOnlyTransaction,
+ WalletDbReadOnlyTransaction,
+ timestampAbsoluteFromDb,
+} from "./db.js";
+import {
+ computeDepositTransactionStatus,
+ processDepositGroup,
+} from "./deposits.js";
+import {
+ computeDenomLossTransactionStatus,
+ updateExchangeFromUrlHandler,
+} from "./exchanges.js";
+import {
+ computePayMerchantTransactionState,
+ computeRefundTransactionState,
+ processPurchase,
+} from "./pay-merchant.js";
+import {
+ computePeerPullCreditTransactionState,
+ processPeerPullCredit,
+} from "./pay-peer-pull-credit.js";
+import {
+ computePeerPullDebitTransactionState,
+ processPeerPullDebit,
+} from "./pay-peer-pull-debit.js";
+import {
+ computePeerPushCreditTransactionState,
+ processPeerPushCredit,
+} from "./pay-peer-push-credit.js";
+import {
+ computePeerPushDebitTransactionState,
+ processPeerPushDebit,
+} from "./pay-peer-push-debit.js";
+import { processRecoupGroup } from "./recoup.js";
+import {
+ computeRefreshTransactionState,
+ processRefreshGroup,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import {
+ InternalWalletState,
+ WalletExecutionContext,
+ getNormalWalletExecutionContext,
+ getObservedWalletExecutionContext,
+} from "./wallet.js";
+import {
+ computeWithdrawalTransactionStatus,
+ processWithdrawalGroup,
+} from "./withdraw.js";
+
+const logger = new Logger("shepherd.ts");
+
+/**
+ * Info about one task being shepherded.
+ */
+interface ShepherdInfo {
+ cts: CancellationToken.Source;
+}
+
+/**
+ * Check if a task is alive, i.e. whether it prevents
+ * the main task loop from exiting.
+ */
+function taskGivesLiveness(taskId: string): boolean {
+ const parsedTaskId = parseTaskIdentifier(taskId);
+ switch (parsedTaskId.tag) {
+ case PendingTaskType.Backup:
+ case PendingTaskType.ExchangeUpdate:
+ return false;
+ case PendingTaskType.Deposit:
+ case PendingTaskType.PeerPullCredit:
+ case PendingTaskType.PeerPullDebit:
+ case PendingTaskType.PeerPushCredit:
+ case PendingTaskType.Refresh:
+ case PendingTaskType.Recoup:
+ case PendingTaskType.RewardPickup:
+ case PendingTaskType.Withdraw:
+ case PendingTaskType.PeerPushDebit:
+ case PendingTaskType.Purchase:
+ return true;
+ default:
+ assertUnreachable(parsedTaskId);
+ }
+}
+
+export interface TaskScheduler {
+ ensureRunning(): void;
+ run(opts?: RetryLoopOpts): Promise<void>;
+ startShepherdTask(taskId: TaskIdStr): void;
+ stopShepherdTask(taskId: TaskIdStr): void;
+ resetTaskRetries(taskId: TaskIdStr): Promise<void>;
+ reload(): void;
+ getActiveTasks(): TaskIdStr[];
+}
+
+export class TaskSchedulerImpl implements TaskScheduler {
+ private sheps: Map<TaskIdStr, ShepherdInfo> = new Map();
+
+ private iterCond = new AsyncCondition();
+
+ private throttler = new TaskThrottler();
+
+ isRunning: boolean = false;
+
+ constructor(private ws: InternalWalletState) {}
+
+ private async loadTasksFromDb(): Promise<void> {
+ const activeTasks = await getActiveTaskIds(this.ws);
+
+ logger.info(`active tasks from DB: ${j2s(activeTasks)}`);
+
+ for (const tid of activeTasks.taskIds) {
+ this.startShepherdTask(tid);
+ }
+ }
+
+ getActiveTasks(): TaskIdStr[] {
+ return [...this.sheps.keys()];
+ }
+
+ ensureRunning(): void {
+ if (this.isRunning) {
+ return;
+ }
+ this.run()
+ .catch((e) => {
+ logger.error("error running task loop");
+ logger.error(`err: ${e}`);
+ })
+ .then(() => {
+ logger.info("done running task loop");
+ });
+ }
+
+ async run(opts: RetryLoopOpts = {}): Promise<void> {
+ if (this.isRunning) {
+ throw Error("task loop already running");
+ }
+ logger.info("Running task loop.");
+ this.isRunning = true;
+ await this.loadTasksFromDb();
+ logger.info("loaded!");
+ logger.info(`sheps: ${this.sheps.size}`);
+ while (true) {
+ if (opts.stopWhenDone) {
+ let alive = false;
+ const taskIds = [...this.sheps.keys()];
+ logger.info(`current task IDs: ${j2s(taskIds)}`);
+ logger.info(`sheps: ${this.sheps.size}`);
+ for (const taskId of taskIds) {
+ if (taskGivesLiveness(taskId)) {
+ alive = true;
+ break;
+ }
+ }
+ if (!alive) {
+ logger.info("Breaking out of task loop (no more work).");
+ break;
+ }
+ }
+ if (this.ws.stopped) {
+ logger.info("Breaking out of task loop (wallet stopped).");
+ break;
+ }
+ await this.iterCond.wait();
+ }
+ this.isRunning = false;
+ logger.info("Done with task loop.");
+ }
+
+ startShepherdTask(taskId: TaskIdStr): void {
+ this.ensureRunning();
+ // Run in the background, no await!
+ this.internalStartShepherdTask(taskId);
+ }
+
+ /**
+ * Stop and re-load all existing tasks.
+ *
+ * Mostly useful to interrupt all waits when time-travelling.
+ */
+ reload(): void {
+ this.ensureRunning();
+ const tasksIds = [...this.sheps.keys()];
+ logger.info(`reloading sheperd with ${tasksIds.length} tasks`);
+ for (const taskId of tasksIds) {
+ this.stopShepherdTask(taskId);
+ }
+ for (const taskId of tasksIds) {
+ this.startShepherdTask(taskId);
+ }
+ }
+
+ private async internalStartShepherdTask(taskId: TaskIdStr): Promise<void> {
+ logger.trace(`Starting to shepherd task ${taskId}`);
+ const oldShep = this.sheps.get(taskId);
+ if (oldShep) {
+ logger.trace(`Already have a shepherd for ${taskId}`);
+ return;
+ }
+ logger.trace(`Creating new shepherd for ${taskId}`);
+ const newShep: ShepherdInfo = {
+ cts: CancellationToken.create(),
+ };
+ this.sheps.set(taskId, newShep);
+ try {
+ await this.internalShepherdTask(taskId, newShep);
+ } finally {
+ logger.trace(`Done shepherding ${taskId}`);
+ this.sheps.delete(taskId);
+ this.iterCond.trigger();
+ }
+ }
+
+ stopShepherdTask(taskId: TaskIdStr): void {
+ logger.trace(`Stopping shepherding of ${taskId}`);
+ const oldShep = this.sheps.get(taskId);
+ if (oldShep) {
+ logger.trace(`Cancelling old shepherd for ${taskId}`);
+ oldShep.cts.cancel();
+ this.sheps.delete(taskId);
+ this.iterCond.trigger();
+ }
+ }
+
+ restartShepherdTask(taskId: TaskIdStr): void {
+ this.stopShepherdTask(taskId);
+ this.startShepherdTask(taskId);
+ }
+
+ async resetTaskRetries(taskId: TaskIdStr): Promise<void> {
+ const maybeNotification = await this.ws.db.runAllStoresReadWriteTx(
+ {},
+ async (tx) => {
+ await tx.operationRetries.delete(taskId);
+ return taskToRetryNotification(this.ws, tx, taskId, undefined);
+ },
+ );
+ this.stopShepherdTask(taskId);
+ if (maybeNotification) {
+ this.ws.notify(maybeNotification);
+ }
+ this.startShepherdTask(taskId);
+ }
+
+ private async wait(
+ taskId: TaskIdStr,
+ info: ShepherdInfo,
+ delay: Duration,
+ ): Promise<void> {
+ try {
+ await info.cts.token.racePromise(this.ws.timerGroup.resolveAfter(delay));
+ } catch (e) {
+ logger.info(`waiting for ${taskId} interrupted`);
+ }
+ }
+
+ private async internalShepherdTask(
+ taskId: TaskIdStr,
+ info: ShepherdInfo,
+ ): Promise<void> {
+ while (true) {
+ if (this.ws.stopped) {
+ logger.trace(`Shepherd for ${taskId} stopping as wallet is stopped`);
+ return;
+ }
+ if (info.cts.token.isCancelled) {
+ logger.trace(`Shepherd for ${taskId} got cancelled`);
+ return;
+ }
+ const isThrottled = this.throttler.applyThrottle(taskId);
+ if (isThrottled) {
+ logger.warn(
+ `task ${taskId} throttled, this is very likely a bug in wallet-core, please report`,
+ );
+ logger.warn("waiting for 60 seconds");
+ await this.ws.timerGroup.resolveAfter(
+ Duration.fromSpec({ seconds: 60 }),
+ );
+ }
+ const wex = getWalletExecutionContextForTask(
+ this.ws,
+ taskId,
+ info.cts.token,
+ );
+ const startTime = AbsoluteTime.now();
+ logger.trace(`Shepherd for ${taskId} will call handler`);
+ let res: TaskRunResult;
+ try {
+ res = await callOperationHandlerForTaskId(wex, taskId);
+ } catch (e) {
+ res = {
+ type: TaskRunResultType.Error,
+ errorDetail: getErrorDetailFromException(e),
+ };
+ }
+ if (info.cts.token.isCancelled) {
+ logger.info("task cancelled, not processing result");
+ return;
+ }
+ if (this.ws.stopped) {
+ logger.info("wallet stopped, not processing result");
+ return;
+ }
+ wex.oc.observe({
+ type: ObservabilityEventType.ShepherdTaskResult,
+ resultType: res.type,
+ });
+ switch (res.type) {
+ case TaskRunResultType.Error: {
+ logger.trace(`Shepherd for ${taskId} got error result.`);
+ const retryRecord = await storePendingTaskError(
+ this.ws,
+ taskId,
+ res.errorDetail,
+ );
+ const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry);
+ const delay = AbsoluteTime.remaining(t);
+ logger.trace(`Waiting for ${delay.d_ms} ms`);
+ await this.wait(taskId, info, delay);
+ break;
+ }
+ case TaskRunResultType.Backoff: {
+ logger.trace(`Shepherd for ${taskId} got backoff result.`);
+ const retryRecord = await storePendingTaskPending(this.ws, taskId);
+ const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry);
+ const delay = AbsoluteTime.remaining(t);
+ logger.trace(`Waiting for ${delay.d_ms} ms`);
+ await this.wait(taskId, info, delay);
+ break;
+ }
+ case TaskRunResultType.Progress: {
+ logger.trace(
+ `Shepherd for ${taskId} got progress result, re-running immediately.`,
+ );
+ await storeTaskProgress(this.ws, taskId);
+ break;
+ }
+ case TaskRunResultType.ScheduleLater: {
+ logger.trace(`Shepherd for ${taskId} got schedule-later result.`);
+ await storeTaskProgress(this.ws, taskId);
+ const delay = AbsoluteTime.remaining(res.runAt);
+ logger.trace(`Waiting for ${delay.d_ms} ms`);
+ await this.wait(taskId, info, delay);
+ break;
+ }
+ case TaskRunResultType.Finished:
+ logger.trace(`Shepherd for ${taskId} got finished result.`);
+ await storePendingTaskFinished(this.ws, taskId);
+ return;
+ case TaskRunResultType.LongpollReturnedPending: {
+ await storeTaskProgress(this.ws, taskId);
+ // Make sure that we are waiting a bit if long-polling returned too early.
+ const endTime = AbsoluteTime.now();
+ const taskDuration = AbsoluteTime.difference(endTime, startTime);
+ if (
+ Duration.cmp(taskDuration, Duration.fromSpec({ seconds: 20 })) < 0
+ ) {
+ logger.info(
+ `long-poller for ${taskId} returned unexpectedly early (${taskDuration.d_ms} ms), waiting 10 seconds`,
+ );
+ await this.wait(taskId, info, Duration.fromSpec({ seconds: 10 }));
+ } else {
+ logger.info(`task ${taskId} will long-poll again`);
+ }
+ break;
+ }
+ default:
+ assertUnreachable(res);
+ }
+ }
+ }
+}
+
+async function storePendingTaskError(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+ e: TalerErrorDetail,
+): Promise<OperationRetryRecord> {
+ logger.info(`storing pending task error for ${pendingTaskId}`);
+ const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => {
+ let retryRecord = await tx.operationRetries.get(pendingTaskId);
+ if (!retryRecord) {
+ retryRecord = {
+ id: pendingTaskId,
+ lastError: e,
+ retryInfo: DbRetryInfo.reset(),
+ };
+ } else {
+ retryRecord.lastError = e;
+ retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
+ }
+ await tx.operationRetries.put(retryRecord);
+ return {
+ notification: await taskToRetryNotification(ws, tx, pendingTaskId, e),
+ retryRecord,
+ };
+ });
+ if (res?.notification) {
+ ws.notify(res.notification);
+ }
+ return res.retryRecord;
+}
+
+/**
+ * Task made progress, clear error.
+ */
+async function storeTaskProgress(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<void> {
+ await ws.db.runReadWriteTx(["operationRetries"], async (tx) => {
+ await tx.operationRetries.delete(pendingTaskId);
+ });
+}
+
+async function storePendingTaskPending(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<OperationRetryRecord> {
+ const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => {
+ let retryRecord = await tx.operationRetries.get(pendingTaskId);
+ let hadError = false;
+ if (!retryRecord) {
+ retryRecord = {
+ id: pendingTaskId,
+ retryInfo: DbRetryInfo.reset(),
+ };
+ } else {
+ if (retryRecord.lastError) {
+ hadError = true;
+ }
+ delete retryRecord.lastError;
+ retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
+ }
+ await tx.operationRetries.put(retryRecord);
+ let notification: WalletNotification | undefined = undefined;
+ if (hadError) {
+ notification = await taskToRetryNotification(
+ ws,
+ tx,
+ pendingTaskId,
+ undefined,
+ );
+ }
+ return {
+ notification,
+ retryRecord,
+ };
+ });
+ if (res.notification) {
+ ws.notify(res.notification);
+ }
+ return res.retryRecord;
+}
+
+async function storePendingTaskFinished(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<void> {
+ await ws.db.runReadWriteTx(["operationRetries"], async (tx) => {
+ await tx.operationRetries.delete(pendingTaskId);
+ });
+}
+
+function getWalletExecutionContextForTask(
+ ws: InternalWalletState,
+ taskId: TaskIdStr,
+ cancellationToken: CancellationToken,
+): WalletExecutionContext {
+ let oc: ObservabilityContext;
+ let wex: WalletExecutionContext;
+
+ if (ws.config.testing.emitObservabilityEvents) {
+ oc = {
+ observe(evt) {
+ if (ws.config.testing.emitObservabilityEvents) {
+ ws.notify({
+ type: NotificationType.TaskObservabilityEvent,
+ taskId,
+ event: evt,
+ });
+ }
+ },
+ };
+
+ wex = getObservedWalletExecutionContext(ws, cancellationToken, oc);
+ } else {
+ oc = {
+ observe(evt) {},
+ };
+ wex = getNormalWalletExecutionContext(ws, cancellationToken, oc);
+ }
+ return wex;
+}
+
+async function callOperationHandlerForTaskId(
+ wex: WalletExecutionContext,
+ taskId: TaskIdStr,
+): Promise<TaskRunResult> {
+ const pending = parseTaskIdentifier(taskId);
+ switch (pending.tag) {
+ case PendingTaskType.ExchangeUpdate:
+ return await updateExchangeFromUrlHandler(wex, pending.exchangeBaseUrl);
+ case PendingTaskType.Refresh:
+ return await processRefreshGroup(wex, pending.refreshGroupId);
+ case PendingTaskType.Withdraw:
+ return await processWithdrawalGroup(wex, pending.withdrawalGroupId);
+ case PendingTaskType.Purchase:
+ return await processPurchase(wex, pending.proposalId);
+ case PendingTaskType.Recoup:
+ return await processRecoupGroup(wex, pending.recoupGroupId);
+ case PendingTaskType.Deposit:
+ return await processDepositGroup(wex, pending.depositGroupId);
+ case PendingTaskType.Backup:
+ return await processBackupForProvider(wex, pending.backupProviderBaseUrl);
+ case PendingTaskType.PeerPushDebit:
+ return await processPeerPushDebit(wex, pending.pursePub);
+ case PendingTaskType.PeerPullCredit:
+ return await processPeerPullCredit(wex, pending.pursePub);
+ case PendingTaskType.PeerPullDebit:
+ return await processPeerPullDebit(wex, pending.peerPullDebitId);
+ case PendingTaskType.PeerPushCredit:
+ return await processPeerPushCredit(wex, pending.peerPushCreditId);
+ case PendingTaskType.RewardPickup:
+ throw Error("not supported anymore");
+ default:
+ return assertUnreachable(pending);
+ }
+ throw Error(`not reached ${pending.tag}`);
+}
+
+/**
+ * Generate an appropriate error transition notification
+ * for applicable tasks.
+ *
+ * Namely, transition notifications are generated for:
+ * - exchange update errors
+ * - transactions
+ */
+async function taskToRetryNotification(
+ ws: InternalWalletState,
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ pendingTaskId: string,
+ e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+ const parsedTaskId = parseTaskIdentifier(pendingTaskId);
+
+ switch (parsedTaskId.tag) {
+ case PendingTaskType.ExchangeUpdate:
+ return makeExchangeRetryNotification(ws, tx, pendingTaskId, e);
+ case PendingTaskType.PeerPullCredit:
+ case PendingTaskType.PeerPullDebit:
+ case PendingTaskType.Withdraw:
+ case PendingTaskType.PeerPushCredit:
+ case PendingTaskType.Deposit:
+ case PendingTaskType.Refresh:
+ case PendingTaskType.RewardPickup:
+ case PendingTaskType.PeerPushDebit:
+ case PendingTaskType.Purchase:
+ return makeTransactionRetryNotification(ws, tx, pendingTaskId, e);
+ case PendingTaskType.Backup:
+ case PendingTaskType.Recoup:
+ return undefined;
+ }
+}
+
+async function getTransactionState(
+ ws: InternalWalletState,
+ tx: WalletDbReadOnlyTransaction<
+ [
+ "depositGroups",
+ "withdrawalGroups",
+ "purchases",
+ "refundGroups",
+ "peerPullCredit",
+ "peerPullDebit",
+ "peerPushDebit",
+ "peerPushCredit",
+ "rewards",
+ "refreshGroups",
+ "denomLossEvents",
+ ]
+ >,
+ transactionId: string,
+): Promise<TransactionState | undefined> {
+ const parsedTxId = parseTransactionIdentifier(transactionId);
+ if (!parsedTxId) {
+ throw Error("invalid tx identifier");
+ }
+ switch (parsedTxId.tag) {
+ case TransactionType.Deposit: {
+ const rec = await tx.depositGroups.get(parsedTxId.depositGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeDepositTransactionStatus(rec);
+ }
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal: {
+ const rec = await tx.withdrawalGroups.get(parsedTxId.withdrawalGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeWithdrawalTransactionStatus(rec);
+ }
+ case TransactionType.Payment: {
+ const rec = await tx.purchases.get(parsedTxId.proposalId);
+ if (!rec) {
+ return;
+ }
+ return computePayMerchantTransactionState(rec);
+ }
+ case TransactionType.Refund: {
+ const rec = await tx.refundGroups.get(parsedTxId.refundGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeRefundTransactionState(rec);
+ }
+ case TransactionType.PeerPullCredit: {
+ const rec = await tx.peerPullCredit.get(parsedTxId.pursePub);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPullCreditTransactionState(rec);
+ }
+ case TransactionType.PeerPullDebit: {
+ const rec = await tx.peerPullDebit.get(parsedTxId.peerPullDebitId);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPullDebitTransactionState(rec);
+ }
+ case TransactionType.PeerPushCredit: {
+ const rec = await tx.peerPushCredit.get(parsedTxId.peerPushCreditId);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPushCreditTransactionState(rec);
+ }
+ case TransactionType.PeerPushDebit: {
+ const rec = await tx.peerPushDebit.get(parsedTxId.pursePub);
+ if (!rec) {
+ return undefined;
+ }
+ return computePeerPushDebitTransactionState(rec);
+ }
+ case TransactionType.Refresh: {
+ const rec = await tx.refreshGroups.get(parsedTxId.refreshGroupId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeRefreshTransactionState(rec);
+ }
+ case TransactionType.Recoup:
+ throw Error("not yet supported");
+ case TransactionType.DenomLoss: {
+ const rec = await tx.denomLossEvents.get(parsedTxId.denomLossEventId);
+ if (!rec) {
+ return undefined;
+ }
+ return computeDenomLossTransactionStatus(rec);
+ }
+ default:
+ assertUnreachable(parsedTxId);
+ }
+}
+
+async function makeTransactionRetryNotification(
+ ws: InternalWalletState,
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ pendingTaskId: string,
+ e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+ const txId = convertTaskToTransactionId(pendingTaskId);
+ if (!txId) {
+ return undefined;
+ }
+ const txState = await getTransactionState(ws, tx, txId);
+ if (!txState) {
+ return undefined;
+ }
+ const notif: WalletNotification = {
+ type: NotificationType.TransactionStateTransition,
+ transactionId: txId,
+ oldTxState: txState,
+ newTxState: txState,
+ };
+ if (e) {
+ notif.errorInfo = {
+ code: e.code as number,
+ hint: e.hint,
+ };
+ }
+ return notif;
+}
+
+async function makeExchangeRetryNotification(
+ ws: InternalWalletState,
+ tx: WalletDbAllStoresReadOnlyTransaction,
+ pendingTaskId: string,
+ e: TalerErrorDetail | undefined,
+): Promise<WalletNotification | undefined> {
+ logger.info("making exchange retry notification");
+ const parsedTaskId = parseTaskIdentifier(pendingTaskId);
+ if (parsedTaskId.tag !== PendingTaskType.ExchangeUpdate) {
+ throw Error("invalid task identifier");
+ }
+ const rec = await tx.exchanges.get(parsedTaskId.exchangeBaseUrl);
+
+ if (!rec) {
+ logger.info(`exchange ${parsedTaskId.exchangeBaseUrl} not found`);
+ return undefined;
+ }
+
+ const notif: WalletNotification = {
+ type: NotificationType.ExchangeStateTransition,
+ exchangeBaseUrl: parsedTaskId.exchangeBaseUrl,
+ oldExchangeState: getExchangeState(rec),
+ newExchangeState: getExchangeState(rec),
+ };
+ if (e) {
+ notif.errorInfo = {
+ code: e.code as number,
+ hint: e.hint,
+ };
+ }
+ return notif;
+}
+
+export function listTaskForTransactionId(transactionId: string): TaskIdStr[] {
+ const tid = parseTransactionIdentifier(transactionId);
+ if (!tid) {
+ throw Error("invalid task ID");
+ }
+ switch (tid.tag) {
+ case TransactionType.Deposit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId: tid.depositGroupId,
+ }),
+ ];
+ case TransactionType.InternalWithdrawal:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: tid.withdrawalGroupId,
+ }),
+ ];
+ case TransactionType.Payment:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId: tid.proposalId,
+ }),
+ ];
+ case TransactionType.PeerPullCredit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: tid.pursePub,
+ }),
+ ];
+ case TransactionType.PeerPullDebit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId: tid.peerPullDebitId,
+ }),
+ ];
+ case TransactionType.PeerPushCredit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: tid.peerPushCreditId,
+ }),
+ ];
+ case TransactionType.PeerPushDebit:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: tid.pursePub,
+ }),
+ ];
+ case TransactionType.Recoup:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId: tid.recoupGroupId,
+ }),
+ ];
+ case TransactionType.Refresh:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId: tid.refreshGroupId,
+ }),
+ ];
+ case TransactionType.Refund:
+ return [];
+ case TransactionType.Withdrawal:
+ return [
+ constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: tid.withdrawalGroupId,
+ }),
+ ];
+ case TransactionType.DenomLoss:
+ return [];
+ default:
+ assertUnreachable(tid);
+ }
+}
+
+/**
+ * Convert the task ID for a task that processes a transaction int
+ * the ID for the transaction.
+ */
+export function convertTaskToTransactionId(
+ taskId: string,
+): TransactionIdStr | undefined {
+ const parsedTaskId = parseTaskIdentifier(taskId);
+ switch (parsedTaskId.tag) {
+ case PendingTaskType.PeerPullCredit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: parsedTaskId.pursePub,
+ });
+ case PendingTaskType.PeerPullDebit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: parsedTaskId.peerPullDebitId,
+ });
+ // FIXME: This doesn't distinguish internal-withdrawal.
+ // Maybe we should have a different task type for that as well?
+ // Or maybe transaction IDs should be valid task identifiers?
+ case PendingTaskType.Withdraw:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: parsedTaskId.withdrawalGroupId,
+ });
+ case PendingTaskType.PeerPushCredit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: parsedTaskId.peerPushCreditId,
+ });
+ case PendingTaskType.Deposit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: parsedTaskId.depositGroupId,
+ });
+ case PendingTaskType.Refresh:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: parsedTaskId.refreshGroupId,
+ });
+ case PendingTaskType.PeerPushDebit:
+ return constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: parsedTaskId.pursePub,
+ });
+ case PendingTaskType.Purchase:
+ return constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: parsedTaskId.proposalId,
+ });
+ default:
+ return undefined;
+ }
+}
+
+export interface ActiveTaskIdsResult {
+ taskIds: TaskIdStr[];
+}
+
+export async function getActiveTaskIds(
+ ws: InternalWalletState,
+): Promise<ActiveTaskIdsResult> {
+ const res: ActiveTaskIdsResult = {
+ taskIds: [],
+ };
+ await ws.db.runReadWriteTx(
+ [
+ "exchanges",
+ "refreshGroups",
+ "withdrawalGroups",
+ "purchases",
+ "depositGroups",
+ "recoupGroups",
+ "peerPullCredit",
+ "peerPushDebit",
+ "peerPullDebit",
+ "peerPushCredit",
+ ],
+ async (tx) => {
+ const active = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+
+ // Withdrawals
+
+ {
+ const activeRecs =
+ await tx.withdrawalGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: rec.withdrawalGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // Deposits
+
+ {
+ const activeRecs =
+ await tx.depositGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId: rec.depositGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // Refreshes
+
+ {
+ const activeRecs =
+ await tx.refreshGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId: rec.refreshGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // Purchases
+
+ {
+ const activeRecs = await tx.purchases.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId: rec.proposalId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-push-debit
+
+ {
+ const activeRecs =
+ await tx.peerPushDebit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub: rec.pursePub,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-push-credit
+
+ {
+ const activeRecs =
+ await tx.peerPushCredit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId: rec.peerPushCreditId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-pull-debit
+
+ {
+ const activeRecs =
+ await tx.peerPullDebit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId: rec.peerPullDebitId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // peer-pull-credit
+
+ {
+ const activeRecs =
+ await tx.peerPullCredit.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: rec.pursePub,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // recoup
+
+ {
+ const activeRecs =
+ await tx.recoupGroups.indexes.byStatus.getAll(active);
+ for (const rec of activeRecs) {
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId: rec.recoupGroupId,
+ });
+ res.taskIds.push(taskId);
+ }
+ }
+
+ // exchange update
+
+ {
+ const exchanges = await tx.exchanges.getAll();
+ for (const rec of exchanges) {
+ const taskIdUpdate = constructTaskIdentifier({
+ tag: PendingTaskType.ExchangeUpdate,
+ exchangeBaseUrl: rec.baseUrl,
+ });
+ res.taskIds.push(taskIdUpdate);
+ }
+ }
+
+ // FIXME: Recoup!
+ },
+ );
+
+ return res;
+}
diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts
new file mode 100644
index 000000000..32c0765b4
--- /dev/null
+++ b/packages/taler-wallet-core/src/testing.ts
@@ -0,0 +1,917 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @file
+ * Implementation of wallet-core operations that are used for testing,
+ * but typically not in the production wallet.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ addPaytoQueryParams,
+ Amounts,
+ AmountString,
+ checkLogicInvariant,
+ CheckPaymentResponse,
+ codecForAny,
+ codecForCheckPaymentResponse,
+ ConfirmPayResultType,
+ Duration,
+ IntegrationTestArgs,
+ IntegrationTestV2Args,
+ j2s,
+ Logger,
+ NotificationType,
+ OpenedPromise,
+ openPromise,
+ parsePaytoUri,
+ PreparePayResultType,
+ TalerCorebankApiClient,
+ TestPayArgs,
+ TestPayResult,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ WithdrawTestBalanceRequest,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ readSuccessResponseJsonOrThrow,
+} from "@gnu-taler/taler-util/http";
+import { getBalances } from "./balance.js";
+import { createDepositGroup } from "./deposits.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ confirmPay,
+ preparePayForUri,
+ startRefundQueryForUri,
+} from "./pay-merchant.js";
+import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js";
+import {
+ confirmPeerPullDebit,
+ preparePeerPullDebit,
+} from "./pay-peer-pull-debit.js";
+import {
+ confirmPeerPushCredit,
+ preparePeerPushCredit,
+} from "./pay-peer-push-credit.js";
+import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
+import { getRefreshesForTransaction } from "./refresh.js";
+import { getTransactionById, getTransactions } from "./transactions.js";
+import type { WalletExecutionContext } from "./wallet.js";
+import { acceptWithdrawalFromUri } from "./withdraw.js";
+
+const logger = new Logger("operations/testing.ts");
+
+interface MerchantBackendInfo {
+ baseUrl: string;
+ authToken?: string;
+}
+
+export interface WithdrawTestBalanceResult {
+ /**
+ * Transaction ID of the newly created withdrawal transaction.
+ */
+ transactionId: string;
+
+ /**
+ * Account of the user registered for the withdrawal.
+ */
+ accountPaytoUri: string;
+}
+
+export async function withdrawTestBalance(
+ wex: WalletExecutionContext,
+ req: WithdrawTestBalanceRequest,
+): Promise<WithdrawTestBalanceResult> {
+ const amount = req.amount;
+ const exchangeBaseUrl = req.exchangeBaseUrl;
+ const corebankApiBaseUrl = req.corebankApiBaseUrl;
+
+ logger.trace(
+ `Registering bank user, bank access base url ${corebankApiBaseUrl}`,
+ );
+
+ const corebankClient = new TalerCorebankApiClient(corebankApiBaseUrl);
+
+ const bankUser = await corebankClient.createRandomBankUser();
+ logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`);
+
+ corebankClient.setAuth(bankUser);
+
+ const wresp = await corebankClient.createWithdrawalOperation(
+ bankUser.username,
+ amount,
+ );
+
+ const acceptResp = await acceptWithdrawalFromUri(wex, {
+ talerWithdrawUri: wresp.taler_withdraw_uri,
+ selectedExchange: exchangeBaseUrl,
+ forcedDenomSel: req.forcedDenomSel,
+ });
+
+ await corebankClient.confirmWithdrawalOperation(bankUser.username, {
+ withdrawalOperationId: wresp.withdrawal_id,
+ });
+
+ return {
+ transactionId: acceptResp.transactionId,
+ accountPaytoUri: bankUser.accountPaytoUri,
+ };
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+function getMerchantAuthHeader(m: MerchantBackendInfo): Record<string, string> {
+ if (m.authToken) {
+ return {
+ Authorization: `Bearer ${m.authToken}`,
+ };
+ }
+ return {};
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+async function refund(
+ http: HttpRequestLibrary,
+ merchantBackend: MerchantBackendInfo,
+ orderId: string,
+ reason: string,
+ refundAmount: string,
+): Promise<string> {
+ const reqUrl = new URL(
+ `private/orders/${orderId}/refund`,
+ merchantBackend.baseUrl,
+ );
+ const refundReq = {
+ order_id: orderId,
+ reason,
+ refund: refundAmount,
+ };
+ const resp = await http.fetch(reqUrl.href, {
+ method: "POST",
+ body: refundReq,
+ headers: getMerchantAuthHeader(merchantBackend),
+ });
+ const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
+ const refundUri = r.taler_refund_uri;
+ if (!refundUri) {
+ throw Error("no refund URI in response");
+ }
+ return refundUri;
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+async function createOrder(
+ http: HttpRequestLibrary,
+ merchantBackend: MerchantBackendInfo,
+ amount: string,
+ summary: string,
+ fulfillmentUrl: string,
+): Promise<{ orderId: string }> {
+ const t = Math.floor(new Date().getTime() / 1000) + 15 * 60;
+ const reqUrl = new URL("private/orders", merchantBackend.baseUrl).href;
+ const orderReq = {
+ order: {
+ amount,
+ summary,
+ fulfillment_url: fulfillmentUrl,
+ refund_deadline: { t_s: t },
+ wire_transfer_deadline: { t_s: t },
+ },
+ };
+ const resp = await http.fetch(reqUrl, {
+ method: "POST",
+ body: orderReq,
+ headers: getMerchantAuthHeader(merchantBackend),
+ });
+ const r = await readSuccessResponseJsonOrThrow(resp, codecForAny());
+ const orderId = r.order_id;
+ if (!orderId) {
+ throw Error("no order id in response");
+ }
+ return { orderId };
+}
+
+/**
+ * FIXME: User MerchantApiClient instead.
+ */
+async function checkPayment(
+ http: HttpRequestLibrary,
+ merchantBackend: MerchantBackendInfo,
+ orderId: string,
+): Promise<CheckPaymentResponse> {
+ const reqUrl = new URL(`private/orders/${orderId}`, merchantBackend.baseUrl);
+ reqUrl.searchParams.set("order_id", orderId);
+ const resp = await http.fetch(reqUrl.href, {
+ headers: getMerchantAuthHeader(merchantBackend),
+ });
+ return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse());
+}
+
+interface MakePaymentResult {
+ orderId: string;
+ paymentTransactionId: string;
+}
+
+async function makePayment(
+ wex: WalletExecutionContext,
+ merchant: MerchantBackendInfo,
+ amount: string,
+ summary: string,
+): Promise<MakePaymentResult> {
+ const orderResp = await createOrder(
+ wex.http,
+ merchant,
+ amount,
+ summary,
+ "taler://fulfillment-success/thx",
+ );
+
+ logger.trace("created order with orderId", orderResp.orderId);
+
+ let paymentStatus = await checkPayment(wex.http, merchant, orderResp.orderId);
+
+ logger.trace("payment status", paymentStatus);
+
+ const talerPayUri = paymentStatus.taler_pay_uri;
+ if (!talerPayUri) {
+ throw Error("no taler://pay/ URI in payment response");
+ }
+
+ const preparePayResult = await preparePayForUri(wex, talerPayUri);
+
+ logger.trace("prepare pay result", preparePayResult);
+
+ if (preparePayResult.status != "payment-possible") {
+ throw Error("payment not possible");
+ }
+
+ const confirmPayResult = await confirmPay(
+ wex,
+ preparePayResult.transactionId,
+ undefined,
+ );
+
+ logger.trace("confirmPayResult", confirmPayResult);
+
+ paymentStatus = await checkPayment(wex.http, merchant, orderResp.orderId);
+
+ logger.trace("payment status after wallet payment:", paymentStatus);
+
+ if (paymentStatus.order_status !== "paid") {
+ throw Error("payment did not succeed");
+ }
+
+ return {
+ orderId: orderResp.orderId,
+ paymentTransactionId: preparePayResult.transactionId,
+ };
+}
+
+export async function runIntegrationTest(
+ wex: WalletExecutionContext,
+ args: IntegrationTestArgs,
+): Promise<void> {
+ logger.info("running test with arguments", args);
+
+ const parsedSpendAmount = Amounts.parseOrThrow(args.amountToSpend);
+ const currency = parsedSpendAmount.currency;
+
+ logger.info("withdrawing test balance");
+ const withdrawRes1 = await withdrawTestBalance(wex, {
+ amount: args.amountToWithdraw,
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+ await waitUntilGivenTransactionsFinal(wex, [withdrawRes1.transactionId]);
+ logger.info("done withdrawing test balance");
+
+ const balance = await getBalances(wex);
+
+ logger.trace(JSON.stringify(balance, null, 2));
+
+ const myMerchant: MerchantBackendInfo = {
+ baseUrl: args.merchantBaseUrl,
+ authToken: args.merchantAuthToken,
+ };
+
+ const makePaymentRes = await makePayment(
+ wex,
+ myMerchant,
+ args.amountToSpend,
+ "hello world",
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ makePaymentRes.paymentTransactionId,
+ );
+
+ logger.trace("withdrawing test balance for refund");
+ const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
+ const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
+ const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
+ const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
+
+ const withdrawRes2 = await withdrawTestBalance(wex, {
+ amount: Amounts.stringify(withdrawAmountTwo),
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+
+ await waitUntilGivenTransactionsFinal(wex, [withdrawRes2.transactionId]);
+
+ const { orderId: refundOrderId } = await makePayment(
+ wex,
+ myMerchant,
+ Amounts.stringify(spendAmountTwo),
+ "order that will be refunded",
+ );
+
+ const refundUri = await refund(
+ wex.http,
+ myMerchant,
+ refundOrderId,
+ "test refund",
+ Amounts.stringify(refundAmount),
+ );
+
+ logger.trace("refund URI", refundUri);
+
+ const refundResp = await startRefundQueryForUri(wex, refundUri);
+
+ logger.trace("integration test: applied refund");
+
+ // Wait until the refund is done
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ refundResp.transactionId,
+ );
+
+ logger.trace("integration test: making payment after refund");
+
+ const paymentResp2 = await makePayment(
+ wex,
+ myMerchant,
+ Amounts.stringify(spendAmountThree),
+ "payment after refund",
+ );
+
+ logger.trace("integration test: make payment done");
+
+ await waitUntilGivenTransactionsFinal(wex, [
+ paymentResp2.paymentTransactionId,
+ ]);
+ await waitUntilGivenTransactionsFinal(
+ wex,
+ await getRefreshesForTransaction(wex, paymentResp2.paymentTransactionId),
+ );
+
+ logger.trace("integration test: all done!");
+}
+
+/**
+ * Wait until all transactions are in a final state.
+ */
+export async function waitUntilAllTransactionsFinal(
+ wex: WalletExecutionContext,
+): Promise<void> {
+ logger.info("waiting until all transactions are in a final state");
+ wex.taskScheduler.ensureRunning();
+ let p: OpenedPromise<void> | undefined = undefined;
+ const cancelNotifs = wex.ws.addNotificationListener((notif) => {
+ if (!p) {
+ return;
+ }
+ if (notif.type === NotificationType.TransactionStateTransition) {
+ switch (notif.newTxState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ break;
+ default:
+ p.resolve();
+ }
+ }
+ });
+ while (1) {
+ p = openPromise();
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ let finished = true;
+ for (const tx of txs.transactions) {
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ finished = false;
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ break;
+ }
+ }
+ if (finished) {
+ break;
+ }
+ // Wait until transaction state changed
+ await p.promise;
+ }
+ cancelNotifs();
+ logger.info("done waiting until all transactions are in a final state");
+}
+
+/**
+ * Wait until all chosen transactions are in a final state.
+ */
+export async function waitUntilGivenTransactionsFinal(
+ wex: WalletExecutionContext,
+ transactionIds: string[],
+): Promise<void> {
+ logger.info(
+ `waiting until given ${transactionIds.length} transactions are in a final state`,
+ );
+ logger.info(`transaction IDs are: ${j2s(transactionIds)}`);
+ if (transactionIds.length === 0) {
+ return;
+ }
+ wex.taskScheduler.ensureRunning();
+ const txIdSet = new Set(transactionIds);
+ let p: OpenedPromise<void> | undefined = undefined;
+ const cancelNotifs = wex.ws.addNotificationListener((notif) => {
+ if (!p) {
+ return;
+ }
+ if (notif.type === NotificationType.TransactionStateTransition) {
+ if (!txIdSet.has(notif.transactionId)) {
+ return;
+ }
+ switch (notif.newTxState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ break;
+ default:
+ p.resolve();
+ }
+ }
+ });
+ while (1) {
+ p = openPromise();
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ let finished = true;
+ for (const tx of txs.transactions) {
+ if (!txIdSet.has(tx.transactionId)) {
+ // Don't look at this transaction, we're not interested in it.
+ continue;
+ }
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ finished = false;
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ break;
+ }
+ }
+ if (finished) {
+ break;
+ }
+ // Wait until transaction state changed
+ await p.promise;
+ }
+ cancelNotifs();
+ logger.info("done waiting until given transactions are in a final state");
+}
+
+export async function waitUntilRefreshesDone(
+ wex: WalletExecutionContext,
+): Promise<void> {
+ logger.info("waiting until all refresh transactions are in a final state");
+ wex.taskScheduler.ensureRunning();
+ let p: OpenedPromise<void> | undefined = undefined;
+ const cancelNotifs = wex.ws.addNotificationListener((notif) => {
+ if (!p) {
+ return;
+ }
+ if (notif.type === NotificationType.TransactionStateTransition) {
+ switch (notif.newTxState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ break;
+ default:
+ p.resolve();
+ }
+ }
+ });
+ while (1) {
+ p = openPromise();
+ const txs = await getTransactions(wex, {
+ includeRefreshes: true,
+ filterByState: "nonfinal",
+ });
+ let finished = true;
+ for (const tx of txs.transactions) {
+ if (tx.type !== TransactionType.Refresh) {
+ continue;
+ }
+ switch (tx.txState.major) {
+ case TransactionMajorState.Pending:
+ case TransactionMajorState.Aborting:
+ case TransactionMajorState.Suspended:
+ case TransactionMajorState.SuspendedAborting:
+ finished = false;
+ logger.info(
+ `continuing waiting, ${tx.transactionId} in ${tx.txState.major}(${tx.txState.minor})`,
+ );
+ break;
+ }
+ }
+ if (finished) {
+ break;
+ }
+ // Wait until transaction state changed
+ await p.promise;
+ }
+ cancelNotifs();
+ logger.info("done waiting until all refreshes are in a final state");
+}
+
+async function waitUntilTransactionPendingReady(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ logger.info(`starting waiting for ${transactionId} to be in pending(ready)`);
+ wex.taskScheduler.ensureRunning();
+ let p: OpenedPromise<void> | undefined = undefined;
+ const cancelNotifs = wex.ws.addNotificationListener((notif) => {
+ if (!p) {
+ return;
+ }
+ if (notif.type === NotificationType.TransactionStateTransition) {
+ p.resolve();
+ }
+ });
+ while (1) {
+ p = openPromise();
+ const tx = await getTransactionById(wex, {
+ transactionId,
+ });
+ if (
+ tx.txState.major == TransactionMajorState.Pending &&
+ tx.txState.minor === TransactionMinorState.Ready
+ ) {
+ break;
+ }
+ // Wait until transaction state changed
+ await p.promise;
+ }
+ logger.info(`done waiting for ${transactionId} to be in pending(ready)`);
+ cancelNotifs();
+}
+
+/**
+ * Wait until a transaction is in a particular state.
+ */
+export async function waitTransactionState(
+ wex: WalletExecutionContext,
+ transactionId: string,
+ txState: TransactionState,
+): Promise<void> {
+ logger.info(
+ `starting waiting for ${transactionId} to be in ${JSON.stringify(
+ txState,
+ )})`,
+ );
+ wex.taskScheduler.ensureRunning();
+ let p: OpenedPromise<void> | undefined = undefined;
+ const cancelNotifs = wex.ws.addNotificationListener((notif) => {
+ if (!p) {
+ return;
+ }
+ if (notif.type === NotificationType.TransactionStateTransition) {
+ p.resolve();
+ }
+ });
+ while (1) {
+ p = openPromise();
+ const tx = await getTransactionById(wex, {
+ transactionId,
+ });
+ if (
+ tx.txState.major === txState.major &&
+ tx.txState.minor === txState.minor
+ ) {
+ break;
+ }
+ // Wait until transaction state changed
+ await p.promise;
+ }
+ logger.info(
+ `done waiting for ${transactionId} to be in ${JSON.stringify(txState)}`,
+ );
+ cancelNotifs();
+}
+
+export async function waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ await waitUntilGivenTransactionsFinal(wex, [transactionId]);
+ await waitUntilGivenTransactionsFinal(
+ wex,
+ await getRefreshesForTransaction(wex, transactionId),
+ );
+}
+
+export async function waitUntilTransactionFinal(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ await waitUntilGivenTransactionsFinal(wex, [transactionId]);
+}
+
+export async function runIntegrationTest2(
+ wex: WalletExecutionContext,
+ args: IntegrationTestV2Args,
+): Promise<void> {
+ wex.taskScheduler.ensureRunning();
+ logger.info("running test with arguments", args);
+
+ const exchangeInfo = await fetchFreshExchange(wex, args.exchangeBaseUrl);
+
+ const currency = exchangeInfo.currency;
+
+ const amountToWithdraw = Amounts.parseOrThrow(`${currency}:10`);
+ const amountToSpend = Amounts.parseOrThrow(`${currency}:2`);
+
+ logger.info("withdrawing test balance");
+ const withdrawalRes = await withdrawTestBalance(wex, {
+ amount: Amounts.stringify(amountToWithdraw),
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+ await waitUntilTransactionFinal(wex, withdrawalRes.transactionId);
+ logger.info("done withdrawing test balance");
+
+ const balance = await getBalances(wex);
+
+ logger.trace(JSON.stringify(balance, null, 2));
+
+ const myMerchant: MerchantBackendInfo = {
+ baseUrl: args.merchantBaseUrl,
+ authToken: args.merchantAuthToken,
+ };
+
+ const makePaymentRes = await makePayment(
+ wex,
+ myMerchant,
+ Amounts.stringify(amountToSpend),
+ "hello world",
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ makePaymentRes.paymentTransactionId,
+ );
+
+ logger.trace("withdrawing test balance for refund");
+ const withdrawAmountTwo = Amounts.parseOrThrow(`${currency}:18`);
+ const spendAmountTwo = Amounts.parseOrThrow(`${currency}:7`);
+ const refundAmount = Amounts.parseOrThrow(`${currency}:6`);
+ const spendAmountThree = Amounts.parseOrThrow(`${currency}:3`);
+
+ const withdrawalRes2 = await withdrawTestBalance(wex, {
+ amount: Amounts.stringify(withdrawAmountTwo),
+ corebankApiBaseUrl: args.corebankApiBaseUrl,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ });
+
+ // Wait until the withdraw is done
+ await waitUntilTransactionFinal(wex, withdrawalRes2.transactionId);
+
+ const { orderId: refundOrderId } = await makePayment(
+ wex,
+ myMerchant,
+ Amounts.stringify(spendAmountTwo),
+ "order that will be refunded",
+ );
+
+ const refundUri = await refund(
+ wex.http,
+ myMerchant,
+ refundOrderId,
+ "test refund",
+ Amounts.stringify(refundAmount),
+ );
+
+ logger.trace("refund URI", refundUri);
+
+ const refundResp = await startRefundQueryForUri(wex, refundUri);
+
+ logger.trace("integration test: applied refund");
+
+ // Wait until the refund is done
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ refundResp.transactionId,
+ );
+
+ logger.trace("integration test: making payment after refund");
+
+ const makePaymentRes2 = await makePayment(
+ wex,
+ myMerchant,
+ Amounts.stringify(spendAmountThree),
+ "payment after refund",
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ makePaymentRes2.paymentTransactionId,
+ );
+
+ logger.trace("integration test: make payment done");
+
+ const peerPushInit = await initiatePeerPushDebit(wex, {
+ partialContractTerms: {
+ amount: `${currency}:1` as AmountString,
+ summary: "Payment Peer Push Test",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ });
+
+ await waitUntilTransactionPendingReady(wex, peerPushInit.transactionId);
+ const txDetails = await getTransactionById(wex, {
+ transactionId: peerPushInit.transactionId,
+ });
+
+ if (txDetails.type !== TransactionType.PeerPushDebit) {
+ throw Error("internal invariant failed");
+ }
+
+ if (!txDetails.talerUri) {
+ throw Error("internal invariant failed");
+ }
+
+ const peerPushCredit = await preparePeerPushCredit(wex, {
+ talerUri: txDetails.talerUri,
+ });
+
+ await confirmPeerPushCredit(wex, {
+ transactionId: peerPushCredit.transactionId,
+ });
+
+ const peerPullInit = await initiatePeerPullPayment(wex, {
+ partialContractTerms: {
+ amount: `${currency}:1` as AmountString,
+ summary: "Payment Peer Pull Test",
+ purse_expiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ ),
+ },
+ });
+
+ await waitUntilTransactionPendingReady(wex, peerPullInit.transactionId);
+
+ const peerPullInc = await preparePeerPullDebit(wex, {
+ talerUri: peerPullInit.talerUri,
+ });
+
+ await confirmPeerPullDebit(wex, {
+ transactionId: peerPullInc.transactionId,
+ });
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPullInc.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPullInit.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPushCredit.transactionId,
+ );
+
+ await waitUntilTransactionWithAssociatedRefreshesFinal(
+ wex,
+ peerPushInit.transactionId,
+ );
+
+ let depositPayto = withdrawalRes.accountPaytoUri;
+
+ const parsedPayto = parsePaytoUri(depositPayto);
+ if (!parsedPayto) {
+ throw Error("invalid payto");
+ }
+
+ // Work around libeufin-bank bug where receiver-name is missing
+ if (!parsedPayto.params["receiver-name"]) {
+ depositPayto = addPaytoQueryParams(depositPayto, {
+ "receiver-name": "Test",
+ });
+ }
+
+ await createDepositGroup(wex, {
+ amount: `${currency}:5` as AmountString,
+ depositPaytoUri: depositPayto,
+ });
+
+ logger.trace("integration test: all done!");
+}
+
+export async function testPay(
+ wex: WalletExecutionContext,
+ args: TestPayArgs,
+): Promise<TestPayResult> {
+ logger.trace("creating order");
+ const merchant = {
+ authToken: args.merchantAuthToken,
+ baseUrl: args.merchantBaseUrl,
+ };
+ const orderResp = await createOrder(
+ wex.http,
+ merchant,
+ args.amount,
+ args.summary,
+ "taler://fulfillment-success/thank+you",
+ );
+ logger.trace("created new order with order ID", orderResp.orderId);
+ const checkPayResp = await checkPayment(
+ wex.http,
+ merchant,
+ orderResp.orderId,
+ );
+ const talerPayUri = checkPayResp.taler_pay_uri;
+ if (!talerPayUri) {
+ console.error("fatal: no taler pay URI received from backend");
+ process.exit(1);
+ }
+ logger.trace("taler pay URI:", talerPayUri);
+ const result = await preparePayForUri(wex, talerPayUri);
+ if (result.status !== PreparePayResultType.PaymentPossible) {
+ throw Error(`unexpected prepare pay status: ${result.status}`);
+ }
+ const r = await confirmPay(
+ wex,
+ result.transactionId,
+ undefined,
+ args.forcedCoinSel,
+ );
+ if (r.type != ConfirmPayResultType.Done) {
+ throw Error("payment not done");
+ }
+ const purchase = await wex.db.runReadOnlyTx(["purchases"], async (tx) => {
+ return tx.purchases.get(result.proposalId);
+ });
+ checkLogicInvariant(!!purchase);
+ return {
+ numCoins: purchase.payInfo?.payCoinSelection?.coinContributions.length ?? 0,
+ };
+}
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
new file mode 100644
index 000000000..43ef09220
--- /dev/null
+++ b/packages/taler-wallet-core/src/transactions.ts
@@ -0,0 +1,2015 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbsoluteTime,
+ Amounts,
+ assertUnreachable,
+ checkDbInvariant,
+ DepositTransactionTrackingState,
+ j2s,
+ Logger,
+ NotificationType,
+ OrderShortInfo,
+ PeerContractTerms,
+ RefundInfoShort,
+ RefundPaymentInfo,
+ ScopeType,
+ stringifyPayPullUri,
+ stringifyPayPushUri,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ Transaction,
+ TransactionAction,
+ TransactionByIdRequest,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionRecordFilter,
+ TransactionsRequest,
+ TransactionsResponse,
+ TransactionState,
+ TransactionType,
+ TransactionWithdrawal,
+ WalletContractData,
+ WithdrawalTransactionByURIRequest,
+ WithdrawalType,
+} from "@gnu-taler/taler-util";
+import {
+ constructTaskIdentifier,
+ PendingTaskType,
+ TaskIdentifiers,
+ TaskIdStr,
+ TransactionContext,
+} from "./common.js";
+import {
+ DenomLossEventRecord,
+ DepositElementStatus,
+ DepositGroupRecord,
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ OperationRetryRecord,
+ PeerPullCreditRecord,
+ PeerPullDebitRecordStatus,
+ PeerPullPaymentIncomingRecord,
+ PeerPushCreditStatus,
+ PeerPushDebitRecord,
+ PeerPushDebitStatus,
+ PeerPushPaymentIncomingRecord,
+ PurchaseRecord,
+ PurchaseStatus,
+ RefreshGroupRecord,
+ RefreshOperationStatus,
+ RefundGroupRecord,
+ timestampPreciseFromDb,
+ timestampProtocolFromDb,
+ WalletDbReadOnlyTransaction,
+ WithdrawalGroupRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+} from "./db.js";
+import {
+ computeDepositTransactionActions,
+ computeDepositTransactionStatus,
+ DepositTransactionContext,
+} from "./deposits.js";
+import {
+ computeDenomLossTransactionStatus,
+ DenomLossTransactionContext,
+ ExchangeWireDetails,
+ getExchangeWireDetailsInTx,
+} from "./exchanges.js";
+import {
+ computePayMerchantTransactionActions,
+ computePayMerchantTransactionState,
+ computeRefundTransactionState,
+ expectProposalDownload,
+ extractContractData,
+ PayMerchantTransactionContext,
+ RefundTransactionContext,
+} from "./pay-merchant.js";
+import {
+ computePeerPullCreditTransactionActions,
+ computePeerPullCreditTransactionState,
+ PeerPullCreditTransactionContext,
+} from "./pay-peer-pull-credit.js";
+import {
+ computePeerPullDebitTransactionActions,
+ computePeerPullDebitTransactionState,
+ PeerPullDebitTransactionContext,
+} from "./pay-peer-pull-debit.js";
+import {
+ computePeerPushCreditTransactionActions,
+ computePeerPushCreditTransactionState,
+ PeerPushCreditTransactionContext,
+} from "./pay-peer-push-credit.js";
+import {
+ computePeerPushDebitTransactionActions,
+ computePeerPushDebitTransactionState,
+ PeerPushDebitTransactionContext,
+} from "./pay-peer-push-debit.js";
+import {
+ computeRefreshTransactionActions,
+ computeRefreshTransactionState,
+ RefreshTransactionContext,
+} from "./refresh.js";
+import type { WalletExecutionContext } from "./wallet.js";
+import {
+ augmentPaytoUrisForWithdrawal,
+ computeWithdrawalTransactionActions,
+ computeWithdrawalTransactionStatus,
+ WithdrawTransactionContext,
+} from "./withdraw.js";
+
+const logger = new Logger("taler-wallet-core:transactions.ts");
+
+function shouldSkipCurrency(
+ transactionsRequest: TransactionsRequest | undefined,
+ currency: string,
+ exchangesInTransaction: string[],
+): boolean {
+ if (transactionsRequest?.scopeInfo) {
+ const sameCurrency = Amounts.isSameCurrency(
+ currency,
+ transactionsRequest.scopeInfo.currency,
+ );
+ switch (transactionsRequest.scopeInfo.type) {
+ case ScopeType.Global: {
+ return !sameCurrency;
+ }
+ case ScopeType.Exchange: {
+ return (
+ !sameCurrency ||
+ (exchangesInTransaction.length > 0 &&
+ !exchangesInTransaction.includes(transactionsRequest.scopeInfo.url))
+ );
+ }
+ case ScopeType.Auditor: {
+ // same currency and same auditor
+ throw Error("filering balance in auditor scope is not implemented");
+ }
+ default:
+ assertUnreachable(transactionsRequest.scopeInfo);
+ }
+ }
+ // FIXME: remove next release
+ if (transactionsRequest?.currency) {
+ return (
+ transactionsRequest.currency.toLowerCase() !== currency.toLowerCase()
+ );
+ }
+ return false;
+}
+
+function shouldSkipSearch(
+ transactionsRequest: TransactionsRequest | undefined,
+ fields: string[],
+): boolean {
+ if (!transactionsRequest?.search) {
+ return false;
+ }
+ const needle = transactionsRequest.search.trim();
+ for (const f of fields) {
+ if (f.indexOf(needle) >= 0) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Fallback order of transactions that have the same timestamp.
+ */
+const txOrder: { [t in TransactionType]: number } = {
+ [TransactionType.Withdrawal]: 1,
+ [TransactionType.Payment]: 3,
+ [TransactionType.PeerPullCredit]: 4,
+ [TransactionType.PeerPullDebit]: 5,
+ [TransactionType.PeerPushCredit]: 6,
+ [TransactionType.PeerPushDebit]: 7,
+ [TransactionType.Refund]: 8,
+ [TransactionType.Deposit]: 9,
+ [TransactionType.Refresh]: 10,
+ [TransactionType.Recoup]: 11,
+ [TransactionType.InternalWithdrawal]: 12,
+ [TransactionType.DenomLoss]: 13,
+};
+
+export async function getTransactionById(
+ wex: WalletExecutionContext,
+ req: TransactionByIdRequest,
+): Promise<Transaction> {
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+
+ if (!parsedTx) {
+ throw Error("invalid transaction ID");
+ }
+
+ switch (parsedTx.tag) {
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal: {
+ const withdrawalGroupId = parsedTx.withdrawalGroupId;
+ return await wex.db.runReadWriteTx(
+ [
+ "withdrawalGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+
+ if (!withdrawalGroupRecord) throw Error("not found");
+
+ const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
+ const ort = await tx.operationRetries.get(opId);
+
+ if (
+ withdrawalGroupRecord.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ return buildTransactionForBankIntegratedWithdraw(
+ withdrawalGroupRecord,
+ ort,
+ );
+ }
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroupRecord.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) throw Error("not exchange details");
+
+ return buildTransactionForManualWithdraw(
+ withdrawalGroupRecord,
+ exchangeDetails,
+ ort,
+ );
+ },
+ );
+ }
+
+ case TransactionType.DenomLoss: {
+ const rec = await wex.db.runReadOnlyTx(
+ ["denomLossEvents"],
+ async (tx) => {
+ return tx.denomLossEvents.get(parsedTx.denomLossEventId);
+ },
+ );
+ if (!rec) {
+ throw Error("denom loss record not found");
+ }
+ return buildTransactionForDenomLoss(rec);
+ }
+
+ case TransactionType.Recoup:
+ throw new Error("not yet supported");
+
+ case TransactionType.Payment: {
+ const proposalId = parsedTx.proposalId;
+ return await wex.db.runReadWriteTx(
+ [
+ "purchases",
+ "tombstones",
+ "operationRetries",
+ "contractTerms",
+ "refundGroups",
+ ],
+ async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) throw Error("not found");
+ const download = await expectProposalDownload(wex, purchase, tx);
+ const contractData = download.contractData;
+ const payOpId = TaskIdentifiers.forPay(purchase);
+ const payRetryRecord = await tx.operationRetries.get(payOpId);
+
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+
+ return buildTransactionForPurchase(
+ purchase,
+ contractData,
+ refunds,
+ payRetryRecord,
+ );
+ },
+ );
+ }
+
+ case TransactionType.Refresh: {
+ // FIXME: We should return info about the refresh here!;
+ const refreshGroupId = parsedTx.refreshGroupId;
+ return await wex.db.runReadOnlyTx(
+ ["refreshGroups", "operationRetries"],
+ async (tx) => {
+ const refreshGroupRec = await tx.refreshGroups.get(refreshGroupId);
+ if (!refreshGroupRec) {
+ throw Error("not found");
+ }
+ const retries = await tx.operationRetries.get(
+ TaskIdentifiers.forRefresh(refreshGroupRec),
+ );
+ return buildTransactionForRefresh(refreshGroupRec, retries);
+ },
+ );
+ }
+
+ case TransactionType.Deposit: {
+ const depositGroupId = parsedTx.depositGroupId;
+ return await wex.db.runReadWriteTx(
+ ["depositGroups", "operationRetries"],
+ async (tx) => {
+ const depositRecord = await tx.depositGroups.get(depositGroupId);
+ if (!depositRecord) throw Error("not found");
+
+ const retries = await tx.operationRetries.get(
+ TaskIdentifiers.forDeposit(depositRecord),
+ );
+ return buildTransactionForDeposit(depositRecord, retries);
+ },
+ );
+ }
+
+ case TransactionType.Refund: {
+ return await wex.db.runReadOnlyTx(
+ ["refundGroups", "purchases", "operationRetries", "contractTerms"],
+ async (tx) => {
+ const refundRecord = await tx.refundGroups.get(
+ parsedTx.refundGroupId,
+ );
+ if (!refundRecord) {
+ throw Error("not found");
+ }
+ const contractData = await lookupMaybeContractData(
+ tx,
+ refundRecord?.proposalId,
+ );
+ return buildTransactionForRefund(refundRecord, contractData);
+ },
+ );
+ }
+ case TransactionType.PeerPullDebit: {
+ return await wex.db.runReadWriteTx(
+ ["peerPullDebit", "contractTerms"],
+ async (tx) => {
+ const debit = await tx.peerPullDebit.get(parsedTx.peerPullDebitId);
+ if (!debit) throw Error("not found");
+ const contractTermsRec = await tx.contractTerms.get(
+ debit.contractTermsHash,
+ );
+ if (!contractTermsRec)
+ throw Error("contract terms for peer-pull-debit not found");
+ return buildTransactionForPullPaymentDebit(
+ debit,
+ contractTermsRec.contractTermsRaw,
+ );
+ },
+ );
+ }
+
+ case TransactionType.PeerPushDebit: {
+ return await wex.db.runReadWriteTx(
+ ["peerPushDebit", "contractTerms"],
+ async (tx) => {
+ const debit = await tx.peerPushDebit.get(parsedTx.pursePub);
+ if (!debit) throw Error("not found");
+ const ct = await tx.contractTerms.get(debit.contractTermsHash);
+ checkDbInvariant(!!ct);
+ return buildTransactionForPushPaymentDebit(
+ debit,
+ ct.contractTermsRaw,
+ );
+ },
+ );
+ }
+
+ case TransactionType.PeerPushCredit: {
+ const peerPushCreditId = parsedTx.peerPushCreditId;
+ return await wex.db.runReadWriteTx(
+ [
+ "peerPushCredit",
+ "contractTerms",
+ "withdrawalGroups",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushInc) throw Error("not found");
+ const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
+ checkDbInvariant(!!ct);
+
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pushInc.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ return buildTransactionForPeerPushCredit(
+ pushInc,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ );
+ },
+ );
+ }
+
+ case TransactionType.PeerPullCredit: {
+ const pursePub = parsedTx.pursePub;
+ return await wex.db.runReadWriteTx(
+ [
+ "peerPullCredit",
+ "contractTerms",
+ "withdrawalGroups",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const pushInc = await tx.peerPullCredit.get(pursePub);
+ if (!pushInc) throw Error("not found");
+ const ct = await tx.contractTerms.get(pushInc.contractTermsHash);
+ checkDbInvariant(!!ct);
+
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pushInc.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pushInc.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId =
+ TaskIdentifiers.forPeerPullPaymentInitiation(pushInc);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ return buildTransactionForPeerPullCredit(
+ pushInc,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ );
+ },
+ );
+ }
+ }
+}
+
+function buildTransactionForPushPaymentDebit(
+ pi: PeerPushDebitRecord,
+ contractTerms: PeerContractTerms,
+ ort?: OperationRetryRecord,
+): Transaction {
+ let talerUri: string | undefined = undefined;
+ switch (pi.status) {
+ case PeerPushDebitStatus.PendingReady:
+ case PeerPushDebitStatus.SuspendedReady:
+ talerUri = stringifyPayPushUri({
+ exchangeBaseUrl: pi.exchangeBaseUrl,
+ contractPriv: pi.contractPriv,
+ });
+ }
+ const txState = computePeerPushDebitTransactionState(pi);
+ return {
+ type: TransactionType.PeerPushDebit,
+ txState,
+ txActions: computePeerPushDebitTransactionActions(pi),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(pi.totalCost))
+ : pi.totalCost,
+ amountRaw: pi.amount,
+ exchangeBaseUrl: pi.exchangeBaseUrl,
+ info: {
+ expiration: contractTerms.purse_expiration,
+ summary: contractTerms.summary,
+ },
+ timestamp: timestampPreciseFromDb(pi.timestampCreated),
+ talerUri,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushDebit,
+ pursePub: pi.pursePub,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForPullPaymentDebit(
+ pi: PeerPullPaymentIncomingRecord,
+ contractTerms: PeerContractTerms,
+ ort?: OperationRetryRecord,
+): Transaction {
+ const txState = computePeerPullDebitTransactionState(pi);
+ return {
+ type: TransactionType.PeerPullDebit,
+ txState,
+ txActions: computePeerPullDebitTransactionActions(pi),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(pi.amount))
+ : pi.coinSel?.totalCost
+ ? pi.coinSel?.totalCost
+ : Amounts.stringify(pi.amount),
+ amountRaw: Amounts.stringify(pi.amount),
+ exchangeBaseUrl: pi.exchangeBaseUrl,
+ info: {
+ expiration: contractTerms.purse_expiration,
+ summary: contractTerms.summary,
+ },
+ timestamp: timestampPreciseFromDb(pi.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: pi.peerPullDebitId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForPeerPullCredit(
+ pullCredit: PeerPullCreditRecord,
+ pullCreditOrt: OperationRetryRecord | undefined,
+ peerContractTerms: PeerContractTerms,
+ wsr: WithdrawalGroupRecord | undefined,
+ wsrOrt: OperationRetryRecord | undefined,
+): Transaction {
+ if (wsr) {
+ if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPullCredit) {
+ throw Error(`Unexpected withdrawalType: ${wsr.wgInfo.withdrawalType}`);
+ }
+ /**
+ * FIXME: this should be handled in the withdrawal process.
+ * PeerPull withdrawal fails until reserve have funds but it is not
+ * an error from the user perspective.
+ */
+ const silentWithdrawalErrorForInvoice =
+ wsrOrt?.lastError &&
+ wsrOrt.lastError.code ===
+ TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE &&
+ Object.values(wsrOrt.lastError.errorsPerCoin ?? {}).every((e) => {
+ return (
+ e.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
+ e.httpStatusCode === 409
+ );
+ });
+ const txState = computePeerPullCreditTransactionState(pullCredit);
+ return {
+ type: TransactionType.PeerPullCredit,
+ txState,
+ txActions: computePeerPullCreditTransactionActions(pullCredit),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
+ : Amounts.stringify(wsr.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wsr.instructedAmount),
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ talerUri: stringifyPayPullUri({
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ contractPriv: wsr.wgInfo.contractPriv,
+ }),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullCredit.pursePub,
+ }),
+ kycUrl: pullCredit.kycUrl,
+ ...(wsrOrt?.lastError
+ ? {
+ error: silentWithdrawalErrorForInvoice
+ ? undefined
+ : wsrOrt.lastError,
+ }
+ : {}),
+ };
+ }
+
+ const txState = computePeerPullCreditTransactionState(pullCredit);
+ return {
+ type: TransactionType.PeerPullCredit,
+ txState,
+ txActions: computePeerPullCreditTransactionActions(pullCredit),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
+ : Amounts.stringify(pullCredit.estimatedAmountEffective),
+ amountRaw: Amounts.stringify(peerContractTerms.amount),
+ exchangeBaseUrl: pullCredit.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(pullCredit.mergeTimestamp),
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ talerUri: stringifyPayPullUri({
+ exchangeBaseUrl: pullCredit.exchangeBaseUrl,
+ contractPriv: pullCredit.contractPriv,
+ }),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPullCredit,
+ pursePub: pullCredit.pursePub,
+ }),
+ kycUrl: pullCredit.kycUrl,
+ ...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}),
+ };
+}
+
+function buildTransactionForPeerPushCredit(
+ pushInc: PeerPushPaymentIncomingRecord,
+ pushOrt: OperationRetryRecord | undefined,
+ peerContractTerms: PeerContractTerms,
+ wsr: WithdrawalGroupRecord | undefined,
+ wsrOrt: OperationRetryRecord | undefined,
+): Transaction {
+ if (wsr) {
+ if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) {
+ throw Error("invalid withdrawal group type for push payment credit");
+ }
+
+ const txState = computePeerPushCreditTransactionState(pushInc);
+ return {
+ type: TransactionType.PeerPushCredit,
+ txState,
+ txActions: computePeerPushCreditTransactionActions(pushInc),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wsr.instructedAmount))
+ : Amounts.stringify(wsr.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wsr.instructedAmount),
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ timestamp: timestampPreciseFromDb(wsr.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: pushInc.peerPushCreditId,
+ }),
+ kycUrl: pushInc.kycUrl,
+ ...(wsrOrt?.lastError ? { error: wsrOrt.lastError } : {}),
+ };
+ }
+
+ const txState = computePeerPushCreditTransactionState(pushInc);
+ return {
+ type: TransactionType.PeerPushCredit,
+ txState,
+ txActions: computePeerPushCreditTransactionActions(pushInc),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(peerContractTerms.amount))
+ : // FIXME: This is wrong, needs to consider fees!
+ Amounts.stringify(peerContractTerms.amount),
+ amountRaw: Amounts.stringify(peerContractTerms.amount),
+ exchangeBaseUrl: pushInc.exchangeBaseUrl,
+ info: {
+ expiration: peerContractTerms.purse_expiration,
+ summary: peerContractTerms.summary,
+ },
+ kycUrl: pushInc.kycUrl,
+ timestamp: timestampPreciseFromDb(pushInc.timestamp),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: pushInc.peerPushCreditId,
+ }),
+ ...(pushOrt?.lastError ? { error: pushOrt.lastError } : {}),
+ };
+}
+
+function buildTransactionForBankIntegratedWithdraw(
+ wgRecord: WithdrawalGroupRecord,
+ ort?: OperationRetryRecord,
+): TransactionWithdrawal {
+ if (wgRecord.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated)
+ throw Error("");
+
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+ return {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed ? true : false,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reservePub: wgRecord.reservePub,
+ bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: wgRecord.withdrawalGroupId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+export function isUnsuccessfulTransaction(state: TransactionState): boolean {
+ return (
+ state.major === TransactionMajorState.Aborted ||
+ state.major === TransactionMajorState.Expired ||
+ state.major === TransactionMajorState.Aborting ||
+ state.major === TransactionMajorState.Deleted ||
+ state.major === TransactionMajorState.Failed
+ );
+}
+
+function buildTransactionForManualWithdraw(
+ withdrawalGroup: WithdrawalGroupRecord,
+ exchangeDetails: ExchangeWireDetails,
+ ort?: OperationRetryRecord,
+): TransactionWithdrawal {
+ if (withdrawalGroup.wgInfo.withdrawalType !== WithdrawalRecordType.BankManual)
+ throw Error("");
+
+ const plainPaytoUris =
+ exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+
+ const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ withdrawalGroup.reservePub,
+ withdrawalGroup.instructedAmount,
+ );
+
+ const txState = computeWithdrawalTransactionStatus(withdrawalGroup);
+
+ return {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(withdrawalGroup),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(
+ Amounts.zeroOfAmount(withdrawalGroup.instructedAmount),
+ )
+ : Amounts.stringify(withdrawalGroup.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(withdrawalGroup.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris,
+ exchangeCreditAccountDetails:
+ withdrawalGroup.wgInfo.exchangeCreditAccounts,
+ reserveIsReady:
+ withdrawalGroup.status === WithdrawalGroupStatus.Done ||
+ withdrawalGroup.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: withdrawalGroup.kycUrl,
+ exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(withdrawalGroup.timestampStart),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForRefund(
+ refundRecord: RefundGroupRecord,
+ maybeContractData: WalletContractData | undefined,
+): Transaction {
+ let paymentInfo: RefundPaymentInfo | undefined = undefined;
+
+ if (maybeContractData) {
+ paymentInfo = {
+ merchant: maybeContractData.merchant,
+ summary: maybeContractData.summary,
+ summary_i18n: maybeContractData.summaryI18n,
+ };
+ }
+
+ const txState = computeRefundTransactionState(refundRecord);
+ return {
+ type: TransactionType.Refund,
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective))
+ : refundRecord.amountEffective,
+ amountRaw: refundRecord.amountRaw,
+ refundedTransactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: refundRecord.proposalId,
+ }),
+ timestamp: timestampPreciseFromDb(refundRecord.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId: refundRecord.refundGroupId,
+ }),
+ txState,
+ txActions: [],
+ paymentInfo,
+ };
+}
+
+function buildTransactionForRefresh(
+ refreshGroupRecord: RefreshGroupRecord,
+ ort?: OperationRetryRecord,
+): Transaction {
+ const inputAmount = Amounts.sumOrZero(
+ refreshGroupRecord.currency,
+ refreshGroupRecord.inputPerCoin,
+ ).amount;
+ const outputAmount = Amounts.sumOrZero(
+ refreshGroupRecord.currency,
+ refreshGroupRecord.expectedOutputPerCoin,
+ ).amount;
+ const txState = computeRefreshTransactionState(refreshGroupRecord);
+ return {
+ type: TransactionType.Refresh,
+ txState,
+ txActions: computeRefreshTransactionActions(refreshGroupRecord),
+ refreshReason: refreshGroupRecord.reason,
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(inputAmount))
+ : Amounts.stringify(Amounts.sub(outputAmount, inputAmount).amount),
+ amountRaw: Amounts.stringify(
+ Amounts.zeroOfCurrency(refreshGroupRecord.currency),
+ ),
+ refreshInputAmount: Amounts.stringify(inputAmount),
+ refreshOutputAmount: Amounts.stringify(outputAmount),
+ originatingTransactionId: refreshGroupRecord.originatingTransactionId,
+ timestamp: timestampPreciseFromDb(refreshGroupRecord.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Refresh,
+ refreshGroupId: refreshGroupRecord.refreshGroupId,
+ }),
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+function buildTransactionForDenomLoss(rec: DenomLossEventRecord): Transaction {
+ const txState = computeDenomLossTransactionStatus(rec);
+ return {
+ type: TransactionType.DenomLoss,
+ txState,
+ txActions: [TransactionAction.Delete],
+ amountRaw: Amounts.stringify(rec.amount),
+ amountEffective: Amounts.stringify(rec.amount),
+ timestamp: timestampPreciseFromDb(rec.timestampCreated),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.DenomLoss,
+ denomLossEventId: rec.denomLossEventId,
+ }),
+ lossEventType: rec.eventType,
+ exchangeBaseUrl: rec.exchangeBaseUrl,
+ };
+}
+
+function buildTransactionForDeposit(
+ dg: DepositGroupRecord,
+ ort?: OperationRetryRecord,
+): Transaction {
+ let deposited = true;
+ if (dg.statusPerCoin) {
+ for (const d of dg.statusPerCoin) {
+ if (d == DepositElementStatus.DepositPending) {
+ deposited = false;
+ }
+ }
+ } else {
+ deposited = false;
+ }
+
+ const trackingState: DepositTransactionTrackingState[] = [];
+
+ for (const ts of Object.values(dg.trackingState ?? {})) {
+ trackingState.push({
+ amountRaw: ts.amountRaw,
+ timestampExecuted: timestampProtocolFromDb(ts.timestampExecuted),
+ wireFee: ts.wireFee,
+ wireTransferId: ts.wireTransferId,
+ });
+ }
+
+ let wireTransferProgress = 0;
+ if (dg.statusPerCoin) {
+ wireTransferProgress =
+ (100 *
+ dg.statusPerCoin.reduce(
+ (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
+ 0,
+ )) /
+ dg.statusPerCoin.length;
+ }
+
+ const txState = computeDepositTransactionStatus(dg);
+ return {
+ type: TransactionType.Deposit,
+ txState,
+ txActions: computeDepositTransactionActions(dg),
+ amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost))
+ : Amounts.stringify(dg.totalPayCost),
+ timestamp: timestampPreciseFromDb(dg.timestampCreated),
+ targetPaytoUri: dg.wire.payto_uri,
+ wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Deposit,
+ depositGroupId: dg.depositGroupId,
+ }),
+ wireTransferProgress,
+ depositGroupId: dg.depositGroupId,
+ trackingState,
+ deposited,
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+async function lookupMaybeContractData(
+ tx: WalletDbReadOnlyTransaction<["purchases", "contractTerms"]>,
+ proposalId: string,
+): Promise<WalletContractData | undefined> {
+ let contractData: WalletContractData | undefined = undefined;
+ const purchaseTx = await tx.purchases.get(proposalId);
+ if (purchaseTx && purchaseTx.download) {
+ const download = purchaseTx.download;
+ const contractTermsRecord = await tx.contractTerms.get(
+ download.contractTermsHash,
+ );
+ if (!contractTermsRecord) {
+ return;
+ }
+ contractData = extractContractData(
+ contractTermsRecord?.contractTermsRaw,
+ download.contractTermsHash,
+ download.contractTermsMerchantSig,
+ );
+ }
+
+ return contractData;
+}
+
+async function buildTransactionForPurchase(
+ purchaseRecord: PurchaseRecord,
+ contractData: WalletContractData,
+ refundsInfo: RefundGroupRecord[],
+ ort?: OperationRetryRecord,
+): Promise<Transaction> {
+ const zero = Amounts.zeroOfAmount(contractData.amount);
+
+ const info: OrderShortInfo = {
+ merchant: contractData.merchant,
+ orderId: contractData.orderId,
+ summary: contractData.summary,
+ summary_i18n: contractData.summaryI18n,
+ contractTermsHash: contractData.contractTermsHash,
+ };
+
+ if (contractData.fulfillmentUrl !== "") {
+ info.fulfillmentUrl = contractData.fulfillmentUrl;
+ }
+
+ const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({
+ amountEffective: r.amountEffective,
+ amountRaw: r.amountRaw,
+ timestamp: TalerPreciseTimestamp.round(
+ timestampPreciseFromDb(r.timestampCreated),
+ ),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId: r.refundGroupId,
+ }),
+ }));
+
+ const timestamp = purchaseRecord.timestampAccept;
+ checkDbInvariant(!!timestamp);
+ checkDbInvariant(!!purchaseRecord.payInfo);
+
+ const txState = computePayMerchantTransactionState(purchaseRecord);
+ return {
+ type: TransactionType.Payment,
+ txState,
+ txActions: computePayMerchantTransactionActions(purchaseRecord),
+ amountRaw: Amounts.stringify(contractData.amount),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(zero)
+ : Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
+ totalRefundRaw: Amounts.stringify(zero), // FIXME!
+ totalRefundEffective: Amounts.stringify(zero), // FIXME!
+ refundPending:
+ purchaseRecord.refundAmountAwaiting === undefined
+ ? undefined
+ : Amounts.stringify(purchaseRecord.refundAmountAwaiting),
+ refunds,
+ posConfirmation: purchaseRecord.posConfirmation,
+ timestamp: timestampPreciseFromDb(timestamp),
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: purchaseRecord.proposalId,
+ }),
+ proposalId: purchaseRecord.proposalId,
+ info,
+ refundQueryActive:
+ purchaseRecord.purchaseStatus === PurchaseStatus.PendingQueryingRefund,
+ ...(ort?.lastError ? { error: ort.lastError } : {}),
+ };
+}
+
+export async function getWithdrawalTransactionByUri(
+ wex: WalletExecutionContext,
+ request: WithdrawalTransactionByURIRequest,
+): Promise<TransactionWithdrawal | undefined> {
+ return await wex.db.runReadWriteTx(
+ ["withdrawalGroups", "exchangeDetails", "exchanges", "operationRetries"],
+ async (tx) => {
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ request.talerWithdrawUri,
+ );
+
+ if (!withdrawalGroupRecord) {
+ return undefined;
+ }
+
+ const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord);
+ const ort = await tx.operationRetries.get(opId);
+
+ if (
+ withdrawalGroupRecord.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ return buildTransactionForBankIntegratedWithdraw(
+ withdrawalGroupRecord,
+ ort,
+ );
+ }
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroupRecord.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) throw Error("not exchange details");
+
+ return buildTransactionForManualWithdraw(
+ withdrawalGroupRecord,
+ exchangeDetails,
+ ort,
+ );
+ },
+ );
+}
+
+/**
+ * Retrieve the full event history for this wallet.
+ */
+export async function getTransactions(
+ wex: WalletExecutionContext,
+ transactionsRequest?: TransactionsRequest,
+): Promise<TransactionsResponse> {
+ const transactions: Transaction[] = [];
+
+ const filter: TransactionRecordFilter = {};
+ if (transactionsRequest?.filterByState) {
+ filter.onlyState = transactionsRequest.filterByState;
+ }
+
+ await wex.db.runReadOnlyTx(
+ [
+ "coins",
+ "denominations",
+ "depositGroups",
+ "exchangeDetails",
+ "exchanges",
+ "operationRetries",
+ "peerPullDebit",
+ "peerPushDebit",
+ "peerPushCredit",
+ "peerPullCredit",
+ "planchets",
+ "purchases",
+ "contractTerms",
+ "recoupGroups",
+ "rewards",
+ "tombstones",
+ "withdrawalGroups",
+ "refreshGroups",
+ "refundGroups",
+ "denomLossEvents",
+ ],
+ async (tx) => {
+ await iterRecordsForPeerPushDebit(tx, filter, async (pi) => {
+ const amount = Amounts.parseOrThrow(pi.amount);
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ const ct = await tx.contractTerms.get(pi.contractTermsHash);
+ checkDbInvariant(!!ct);
+ transactions.push(
+ buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw),
+ );
+ });
+
+ await iterRecordsForPeerPullDebit(tx, filter, async (pi) => {
+ const amount = Amounts.parseOrThrow(pi.amount);
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ if (
+ pi.status !== PeerPullDebitRecordStatus.PendingDeposit &&
+ pi.status !== PeerPullDebitRecordStatus.Done
+ ) {
+ // FIXME: Why?!
+ return;
+ }
+
+ const contractTermsRec = await tx.contractTerms.get(
+ pi.contractTermsHash,
+ );
+ if (!contractTermsRec) {
+ return;
+ }
+
+ transactions.push(
+ buildTransactionForPullPaymentDebit(
+ pi,
+ contractTermsRec.contractTermsRaw,
+ ),
+ );
+ });
+
+ await iterRecordsForPeerPushCredit(tx, filter, async (pi) => {
+ if (!pi.currency) {
+ // Legacy transaction
+ return;
+ }
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(transactionsRequest, pi.currency, exchangesInTx)
+ ) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ if (pi.status === PeerPushCreditStatus.DialogProposed) {
+ // We don't report proposed push credit transactions, user needs
+ // to scan URI again and confirm to see it.
+ return;
+ }
+ const ct = await tx.contractTerms.get(pi.contractTermsHash);
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pi.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ checkDbInvariant(!!ct);
+ transactions.push(
+ buildTransactionForPeerPushCredit(
+ pi,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ ),
+ );
+ });
+
+ await iterRecordsForPeerPullCredit(tx, filter, async (pi) => {
+ const currency = Amounts.currencyOf(pi.amount);
+ const exchangesInTx = [pi.exchangeBaseUrl];
+ if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
+ return;
+ }
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+ const ct = await tx.contractTerms.get(pi.contractTermsHash);
+ let wg: WithdrawalGroupRecord | undefined = undefined;
+ let wgOrt: OperationRetryRecord | undefined = undefined;
+ if (pi.withdrawalGroupId) {
+ wg = await tx.withdrawalGroups.get(pi.withdrawalGroupId);
+ if (wg) {
+ const withdrawalOpId = TaskIdentifiers.forWithdrawal(wg);
+ wgOrt = await tx.operationRetries.get(withdrawalOpId);
+ }
+ }
+ const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi);
+ let pushIncOrt = await tx.operationRetries.get(pushIncOpId);
+
+ checkDbInvariant(!!ct);
+ transactions.push(
+ buildTransactionForPeerPullCredit(
+ pi,
+ pushIncOrt,
+ ct.contractTermsRaw,
+ wg,
+ wgOrt,
+ ),
+ );
+ });
+
+ await iterRecordsForRefund(tx, filter, async (refundGroup) => {
+ const currency = Amounts.currencyOf(refundGroup.amountRaw);
+
+ const exchangesInTx: string[] = [];
+ const p = await tx.purchases.get(refundGroup.proposalId);
+ if (!p || !p.payInfo || !p.payInfo.payCoinSelection) {
+ //refund with no payment
+ return;
+ }
+
+ // FIXME: This is very slow, should become obsolete with materialized transactions.
+ for (const cp of p.payInfo.payCoinSelection.coinPubs) {
+ const c = await tx.coins.get(cp);
+ if (c?.exchangeBaseUrl) {
+ exchangesInTx.push(c.exchangeBaseUrl);
+ }
+ }
+
+ if (shouldSkipCurrency(transactionsRequest, currency, exchangesInTx)) {
+ return;
+ }
+ const contractData = await lookupMaybeContractData(
+ tx,
+ refundGroup.proposalId,
+ );
+ transactions.push(buildTransactionForRefund(refundGroup, contractData));
+ });
+
+ await iterRecordsForRefresh(tx, filter, async (rg) => {
+ const exchangesInTx = rg.infoPerExchange
+ ? Object.keys(rg.infoPerExchange)
+ : [];
+ if (
+ shouldSkipCurrency(transactionsRequest, rg.currency, exchangesInTx)
+ ) {
+ return;
+ }
+ let required = false;
+ const opId = TaskIdentifiers.forRefresh(rg);
+ if (transactionsRequest?.includeRefreshes) {
+ required = true;
+ } else if (rg.operationStatus !== RefreshOperationStatus.Finished) {
+ const ort = await tx.operationRetries.get(opId);
+ if (ort) {
+ required = true;
+ }
+ }
+ if (required) {
+ const ort = await tx.operationRetries.get(opId);
+ transactions.push(buildTransactionForRefresh(rg, ort));
+ }
+ });
+
+ await iterRecordsForWithdrawal(tx, filter, async (wsr) => {
+ const exchangesInTx = [wsr.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ Amounts.currencyOf(wsr.rawWithdrawalAmount),
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+
+ if (shouldSkipSearch(transactionsRequest, [])) {
+ return;
+ }
+
+ const opId = TaskIdentifiers.forWithdrawal(wsr);
+ const ort = await tx.operationRetries.get(opId);
+
+ switch (wsr.wgInfo.withdrawalType) {
+ case WithdrawalRecordType.PeerPullCredit:
+ // Will be reported by the corresponding p2p transaction.
+ // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
+ // FIXME: Still report if requested with verbose option?
+ return;
+ case WithdrawalRecordType.PeerPushCredit:
+ // Will be reported by the corresponding p2p transaction.
+ // FIXME: If this is an orphan withdrawal, still report it as a withdrawal!
+ // FIXME: Still report if requested with verbose option?
+ return;
+ case WithdrawalRecordType.BankIntegrated:
+ transactions.push(
+ buildTransactionForBankIntegratedWithdraw(wsr, ort),
+ );
+ return;
+ case WithdrawalRecordType.BankManual: {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ wsr.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) {
+ // FIXME: report somehow
+ return;
+ }
+
+ transactions.push(
+ buildTransactionForManualWithdraw(wsr, exchangeDetails, ort),
+ );
+ return;
+ }
+ case WithdrawalRecordType.Recoup:
+ // FIXME: Do we also report a transaction here?
+ return;
+ }
+ });
+
+ await iterRecordsForDenomLoss(tx, filter, async (rec) => {
+ const amount = Amounts.parseOrThrow(rec.amount);
+ const exchangesInTx = [rec.exchangeBaseUrl];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ transactions.push(buildTransactionForDenomLoss(rec));
+ });
+
+ await iterRecordsForDeposit(tx, filter, async (dg) => {
+ const amount = Amounts.parseOrThrow(dg.amount);
+ const exchangesInTx = dg.infoPerExchange
+ ? Object.keys(dg.infoPerExchange)
+ : [];
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ amount.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ const opId = TaskIdentifiers.forDeposit(dg);
+ const retryRecord = await tx.operationRetries.get(opId);
+
+ transactions.push(buildTransactionForDeposit(dg, retryRecord));
+ });
+
+ await iterRecordsForPurchase(tx, filter, async (purchase) => {
+ const download = purchase.download;
+ if (!download) {
+ return;
+ }
+ if (!purchase.payInfo) {
+ return;
+ }
+
+ const exchangesInTx: string[] = [];
+ for (const cp of purchase.payInfo.payCoinSelection?.coinPubs ?? []) {
+ const c = await tx.coins.get(cp);
+ if (c?.exchangeBaseUrl) {
+ exchangesInTx.push(c.exchangeBaseUrl);
+ }
+ }
+
+ if (
+ shouldSkipCurrency(
+ transactionsRequest,
+ download.currency,
+ exchangesInTx,
+ )
+ ) {
+ return;
+ }
+ const contractTermsRecord = await tx.contractTerms.get(
+ download.contractTermsHash,
+ );
+ if (!contractTermsRecord) {
+ return;
+ }
+ if (
+ shouldSkipSearch(transactionsRequest, [
+ contractTermsRecord?.contractTermsRaw?.summary || "",
+ ])
+ ) {
+ return;
+ }
+
+ const contractData = extractContractData(
+ contractTermsRecord?.contractTermsRaw,
+ download.contractTermsHash,
+ download.contractTermsMerchantSig,
+ );
+
+ const payOpId = TaskIdentifiers.forPay(purchase);
+ const payRetryRecord = await tx.operationRetries.get(payOpId);
+
+ const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
+ purchase.proposalId,
+ );
+
+ transactions.push(
+ await buildTransactionForPurchase(
+ purchase,
+ contractData,
+ refunds,
+ payRetryRecord,
+ ),
+ );
+ });
+ },
+ );
+
+ // One-off checks, because of a bug where the wallet previously
+ // did not migrate the DB correctly and caused these amounts
+ // to be missing sometimes.
+ for (let tx of transactions) {
+ if (!tx.amountEffective) {
+ logger.warn(`missing amountEffective in ${j2s(tx)}`);
+ }
+ if (!tx.amountRaw) {
+ logger.warn(`missing amountRaw in ${j2s(tx)}`);
+ }
+ if (!tx.timestamp) {
+ logger.warn(`missing timestamp in ${j2s(tx)}`);
+ }
+ }
+
+ const isPending = (x: Transaction) =>
+ x.txState.major === TransactionMajorState.Pending ||
+ x.txState.major === TransactionMajorState.Aborting ||
+ x.txState.major === TransactionMajorState.Dialog;
+
+ let sortSign: number;
+ if (transactionsRequest?.sort == "descending") {
+ sortSign = -1;
+ } else {
+ sortSign = 1;
+ }
+
+ const txCmp = (h1: Transaction, h2: Transaction) => {
+ // Order transactions by timestamp. Newest transactions come first.
+ const tsCmp = AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
+ AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
+ );
+ // If the timestamp is exactly the same, order by transaction type.
+ if (tsCmp === 0) {
+ return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
+ }
+ return sortSign * tsCmp;
+ };
+
+ if (transactionsRequest?.sort === "stable-ascending") {
+ transactions.sort(txCmp);
+ return { transactions };
+ }
+
+ const txPending = transactions.filter((x) => isPending(x));
+ const txNotPending = transactions.filter((x) => !isPending(x));
+
+ txPending.sort(txCmp);
+ txNotPending.sort(txCmp);
+
+ return { transactions: [...txPending, ...txNotPending] };
+}
+
+export type ParsedTransactionIdentifier =
+ | { tag: TransactionType.Deposit; depositGroupId: string }
+ | { tag: TransactionType.Payment; proposalId: string }
+ | { tag: TransactionType.PeerPullDebit; peerPullDebitId: string }
+ | { tag: TransactionType.PeerPullCredit; pursePub: string }
+ | { tag: TransactionType.PeerPushCredit; peerPushCreditId: string }
+ | { tag: TransactionType.PeerPushDebit; pursePub: string }
+ | { tag: TransactionType.Refresh; refreshGroupId: string }
+ | { tag: TransactionType.Refund; refundGroupId: string }
+ | { tag: TransactionType.Withdrawal; withdrawalGroupId: string }
+ | { tag: TransactionType.InternalWithdrawal; withdrawalGroupId: string }
+ | { tag: TransactionType.Recoup; recoupGroupId: string }
+ | { tag: TransactionType.DenomLoss; denomLossEventId: string };
+
+export function constructTransactionIdentifier(
+ pTxId: ParsedTransactionIdentifier,
+): TransactionIdStr {
+ switch (pTxId.tag) {
+ case TransactionType.Deposit:
+ return `txn:${pTxId.tag}:${pTxId.depositGroupId}` as TransactionIdStr;
+ case TransactionType.Payment:
+ return `txn:${pTxId.tag}:${pTxId.proposalId}` as TransactionIdStr;
+ case TransactionType.PeerPullCredit:
+ return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
+ case TransactionType.PeerPullDebit:
+ return `txn:${pTxId.tag}:${pTxId.peerPullDebitId}` as TransactionIdStr;
+ case TransactionType.PeerPushCredit:
+ return `txn:${pTxId.tag}:${pTxId.peerPushCreditId}` as TransactionIdStr;
+ case TransactionType.PeerPushDebit:
+ return `txn:${pTxId.tag}:${pTxId.pursePub}` as TransactionIdStr;
+ case TransactionType.Refresh:
+ return `txn:${pTxId.tag}:${pTxId.refreshGroupId}` as TransactionIdStr;
+ case TransactionType.Refund:
+ return `txn:${pTxId.tag}:${pTxId.refundGroupId}` as TransactionIdStr;
+ case TransactionType.Withdrawal:
+ return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
+ case TransactionType.InternalWithdrawal:
+ return `txn:${pTxId.tag}:${pTxId.withdrawalGroupId}` as TransactionIdStr;
+ case TransactionType.Recoup:
+ return `txn:${pTxId.tag}:${pTxId.recoupGroupId}` as TransactionIdStr;
+ case TransactionType.DenomLoss:
+ return `txn:${pTxId.tag}:${pTxId.denomLossEventId}` as TransactionIdStr;
+ default:
+ assertUnreachable(pTxId);
+ }
+}
+
+/**
+ * Parse a transaction identifier string into a typed, structured representation.
+ */
+export function parseTransactionIdentifier(
+ transactionId: string,
+): ParsedTransactionIdentifier | undefined {
+ const txnParts = transactionId.split(":");
+
+ if (txnParts.length < 3) {
+ throw Error("id should have al least 3 parts separated by ':'");
+ }
+
+ const [prefix, type, ...rest] = txnParts;
+
+ if (prefix != "txn") {
+ throw Error("invalid transaction identifier");
+ }
+
+ switch (type) {
+ case TransactionType.Deposit:
+ return { tag: TransactionType.Deposit, depositGroupId: rest[0] };
+ case TransactionType.Payment:
+ return { tag: TransactionType.Payment, proposalId: rest[0] };
+ case TransactionType.PeerPullCredit:
+ return { tag: TransactionType.PeerPullCredit, pursePub: rest[0] };
+ case TransactionType.PeerPullDebit:
+ return {
+ tag: TransactionType.PeerPullDebit,
+ peerPullDebitId: rest[0],
+ };
+ case TransactionType.PeerPushCredit:
+ return {
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: rest[0],
+ };
+ case TransactionType.PeerPushDebit:
+ return { tag: TransactionType.PeerPushDebit, pursePub: rest[0] };
+ case TransactionType.Refresh:
+ return { tag: TransactionType.Refresh, refreshGroupId: rest[0] };
+ case TransactionType.Refund:
+ return {
+ tag: TransactionType.Refund,
+ refundGroupId: rest[0],
+ };
+ case TransactionType.Withdrawal:
+ return {
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: rest[0],
+ };
+ case TransactionType.DenomLoss:
+ return {
+ tag: TransactionType.DenomLoss,
+ denomLossEventId: rest[0],
+ };
+ default:
+ return undefined;
+ }
+}
+
+function maybeTaskFromTransaction(
+ transactionId: string,
+): TaskIdStr | undefined {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+
+ if (!parsedTx) {
+ throw Error("invalid transaction identifier");
+ }
+
+ // FIXME: We currently don't cancel active long-polling tasks here.
+
+ switch (parsedTx.tag) {
+ case TransactionType.PeerPullCredit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullCredit,
+ pursePub: parsedTx.pursePub,
+ });
+ case TransactionType.Deposit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Deposit,
+ depositGroupId: parsedTx.depositGroupId,
+ });
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId: parsedTx.withdrawalGroupId,
+ });
+ case TransactionType.Payment:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId: parsedTx.proposalId,
+ });
+ case TransactionType.Refresh:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Refresh,
+ refreshGroupId: parsedTx.refreshGroupId,
+ });
+ case TransactionType.PeerPullDebit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPullDebit,
+ peerPullDebitId: parsedTx.peerPullDebitId,
+ });
+ case TransactionType.PeerPushCredit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId: parsedTx.peerPushCreditId,
+ });
+ case TransactionType.PeerPushDebit:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushDebit,
+ pursePub: parsedTx.pursePub,
+ });
+ case TransactionType.Refund:
+ // Nothing to do for a refund transaction.
+ return undefined;
+ case TransactionType.Recoup:
+ return constructTaskIdentifier({
+ tag: PendingTaskType.Recoup,
+ recoupGroupId: parsedTx.recoupGroupId,
+ });
+ case TransactionType.DenomLoss:
+ // Nothing to do for denom loss
+ return undefined;
+ default:
+ assertUnreachable(parsedTx);
+ }
+}
+
+/**
+ * Immediately retry the underlying operation
+ * of a transaction.
+ */
+export async function retryTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ logger.info(`resetting retry timeout for ${transactionId}`);
+ const taskId = maybeTaskFromTransaction(transactionId);
+ if (taskId) {
+ wex.taskScheduler.resetTaskRetries(taskId);
+ }
+}
+
+async function getContextForTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<TransactionContext> {
+ const tx = parseTransactionIdentifier(transactionId);
+ if (!tx) {
+ throw Error("invalid transaction ID");
+ }
+ switch (tx.tag) {
+ case TransactionType.Deposit:
+ return new DepositTransactionContext(wex, tx.depositGroupId);
+ case TransactionType.Refresh:
+ return new RefreshTransactionContext(wex, tx.refreshGroupId);
+ case TransactionType.InternalWithdrawal:
+ case TransactionType.Withdrawal:
+ return new WithdrawTransactionContext(wex, tx.withdrawalGroupId);
+ case TransactionType.Payment:
+ return new PayMerchantTransactionContext(wex, tx.proposalId);
+ case TransactionType.PeerPullCredit:
+ return new PeerPullCreditTransactionContext(wex, tx.pursePub);
+ case TransactionType.PeerPushDebit:
+ return new PeerPushDebitTransactionContext(wex, tx.pursePub);
+ case TransactionType.PeerPullDebit:
+ return new PeerPullDebitTransactionContext(wex, tx.peerPullDebitId);
+ case TransactionType.PeerPushCredit:
+ return new PeerPushCreditTransactionContext(wex, tx.peerPushCreditId);
+ case TransactionType.Refund:
+ return new RefundTransactionContext(wex, tx.refundGroupId);
+ case TransactionType.Recoup:
+ //return new RecoupTransactionContext(ws, tx.recoupGroupId);
+ throw new Error("not yet supported");
+ case TransactionType.DenomLoss:
+ return new DenomLossTransactionContext(wex, tx.denomLossEventId);
+ default:
+ assertUnreachable(tx);
+ }
+}
+
+/**
+ * Suspends a pending transaction, stopping any associated network activities,
+ * but with a chance of trying again at a later time. This could be useful if
+ * a user needs to save battery power or bandwidth and an operation is expected
+ * to take longer (such as a backup, recovery or very large withdrawal operation).
+ */
+export async function suspendTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.suspendTransaction();
+}
+
+export async function failTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.failTransaction();
+}
+
+/**
+ * Resume a suspended transaction.
+ */
+export async function resumeTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.resumeTransaction();
+}
+
+/**
+ * Permanently delete a transaction based on the transaction ID.
+ */
+export async function deleteTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.deleteTransaction();
+ if (ctx.taskId) {
+ wex.taskScheduler.stopShepherdTask(ctx.taskId);
+ }
+}
+
+export async function abortTransaction(
+ wex: WalletExecutionContext,
+ transactionId: string,
+): Promise<void> {
+ const ctx = await getContextForTransaction(wex, transactionId);
+ await ctx.abortTransaction();
+}
+
+export interface TransitionInfo {
+ oldTxState: TransactionState;
+ newTxState: TransactionState;
+}
+
+/**
+ * Notify of a state transition if necessary.
+ */
+export function notifyTransition(
+ wex: WalletExecutionContext,
+ transactionId: string,
+ transitionInfo: TransitionInfo | undefined,
+ experimentalUserData: any = undefined,
+): void {
+ if (
+ transitionInfo &&
+ !(
+ transitionInfo.oldTxState.major === transitionInfo.newTxState.major &&
+ transitionInfo.oldTxState.minor === transitionInfo.newTxState.minor
+ )
+ ) {
+ wex.ws.notify({
+ type: NotificationType.TransactionStateTransition,
+ oldTxState: transitionInfo.oldTxState,
+ newTxState: transitionInfo.newTxState,
+ transactionId,
+ experimentalUserData,
+ });
+ }
+}
+
+/**
+ * Iterate refresh records based on a filter.
+ */
+async function iterRecordsForRefresh(
+ tx: WalletDbReadOnlyTransaction<["refreshGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: RefreshGroupRecord) => Promise<void>,
+): Promise<void> {
+ let refreshGroups: RefreshGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ RefreshOperationStatus.Pending,
+ RefreshOperationStatus.Suspended,
+ );
+ refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ refreshGroups = await tx.refreshGroups.indexes.byStatus.getAll();
+ }
+
+ for (const r of refreshGroups) {
+ await f(r);
+ }
+}
+
+async function iterRecordsForWithdrawal(
+ tx: WalletDbReadOnlyTransaction<["withdrawalGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: WithdrawalGroupRecord) => Promise<void>,
+): Promise<void> {
+ let withdrawalGroupRecords: WithdrawalGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ withdrawalGroupRecords =
+ await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ withdrawalGroupRecords =
+ await tx.withdrawalGroups.indexes.byStatus.getAll();
+ }
+ for (const wgr of withdrawalGroupRecords) {
+ await f(wgr);
+ }
+}
+
+async function iterRecordsForDeposit(
+ tx: WalletDbReadOnlyTransaction<["depositGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: DepositGroupRecord) => Promise<void>,
+): Promise<void> {
+ let dgs: DepositGroupRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange);
+ } else {
+ dgs = await tx.depositGroups.indexes.byStatus.getAll();
+ }
+
+ for (const dg of dgs) {
+ await f(dg);
+ }
+}
+
+async function iterRecordsForDenomLoss(
+ tx: WalletDbReadOnlyTransaction<["denomLossEvents"]>,
+ filter: TransactionRecordFilter,
+ f: (r: DenomLossEventRecord) => Promise<void>,
+): Promise<void> {
+ let dgs: DenomLossEventRecord[];
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ dgs = await tx.denomLossEvents.indexes.byStatus.getAll(keyRange);
+ } else {
+ dgs = await tx.denomLossEvents.indexes.byStatus.getAll();
+ }
+
+ for (const dg of dgs) {
+ await f(dg);
+ }
+}
+
+async function iterRecordsForRefund(
+ tx: WalletDbReadOnlyTransaction<["refundGroups"]>,
+ filter: TransactionRecordFilter,
+ f: (r: RefundGroupRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.refundGroups.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPurchase(
+ tx: WalletDbReadOnlyTransaction<["purchases"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PurchaseRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.purchases.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPullCredit(
+ tx: WalletDbReadOnlyTransaction<["peerPullCredit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPullCreditRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPullCredit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPullDebit(
+ tx: WalletDbReadOnlyTransaction<["peerPullDebit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPullPaymentIncomingRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPullDebit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPushDebit(
+ tx: WalletDbReadOnlyTransaction<["peerPushDebit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPushDebitRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPushDebit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
+
+async function iterRecordsForPeerPushCredit(
+ tx: WalletDbReadOnlyTransaction<["peerPushCredit"]>,
+ filter: TransactionRecordFilter,
+ f: (r: PeerPushPaymentIncomingRecord) => Promise<void>,
+): Promise<void> {
+ if (filter.onlyState === "nonfinal") {
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OPERATION_STATUS_ACTIVE_FIRST,
+ OPERATION_STATUS_ACTIVE_LAST,
+ );
+ await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f);
+ } else {
+ await tx.peerPushCredit.indexes.byStatus.iter().forEachAsync(f);
+ }
+}
diff --git a/packages/taler-wallet-core/src/util/asyncMemo.ts b/packages/taler-wallet-core/src/util/asyncMemo.ts
deleted file mode 100644
index 6e88081b6..000000000
--- a/packages/taler-wallet-core/src/util/asyncMemo.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-interface MemoEntry<T> {
- p: Promise<T>;
- t: number;
- n: number;
-}
-
-export class AsyncOpMemoMap<T> {
- private n = 0;
- private memoMap: { [k: string]: MemoEntry<T> } = {};
-
- private cleanUp(key: string, n: number): void {
- const r = this.memoMap[key];
- if (r && r.n === n) {
- delete this.memoMap[key];
- }
- }
-
- memo(key: string, pg: () => Promise<T>): Promise<T> {
- const res = this.memoMap[key];
- if (res) {
- return res.p;
- }
- const n = this.n++;
- // Wrap the operation in case it immediately throws
- const p = Promise.resolve().then(() => pg());
- this.memoMap[key] = {
- p,
- n,
- t: new Date().getTime(),
- };
- return p.finally(() => {
- this.cleanUp(key, n);
- });
- }
- clear(): void {
- this.memoMap = {};
- }
-}
-
-export class AsyncOpMemoSingle<T> {
- private n = 0;
- private memoEntry: MemoEntry<T> | undefined;
-
- private cleanUp(n: number): void {
- if (this.memoEntry && this.memoEntry.n === n) {
- this.memoEntry = undefined;
- }
- }
-
- memo(pg: () => Promise<T>): Promise<T> {
- const res = this.memoEntry;
- if (res) {
- return res.p;
- }
- const n = this.n++;
- // Wrap the operation in case it immediately throws
- const p = Promise.resolve().then(() => pg());
- p.finally(() => {
- this.cleanUp(n);
- });
- this.memoEntry = {
- p,
- n,
- t: new Date().getTime(),
- };
- return p;
- }
- clear(): void {
- this.memoEntry = undefined;
- }
-}
diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts
deleted file mode 100644
index ed48b8dd1..000000000
--- a/packages/taler-wallet-core/src/util/coinSelection.test.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import test from "ava";
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { AvailableCoinInfo, selectPayCoins } from "./coinSelection.js";
-
-function a(x: string): AmountJson {
- const amt = Amounts.parse(x);
- if (!amt) {
- throw Error("invalid amount");
- }
- return amt;
-}
-
-function fakeAci(current: string, feeDeposit: string): AvailableCoinInfo {
- return {
- availableAmount: a(current),
- coinPub: "foobar",
- denomPub: "foobar",
- feeDeposit: a(feeDeposit),
- exchangeBaseUrl: "https://example.com/",
- };
-}
-
-test("coin selection 1", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.1"),
- fakeAci("EUR:1.0", "EUR:0.0"),
- ];
-
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.1"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
-
- if (!res) {
- t.fail();
- return;
- }
- t.true(res.coinPubs.length === 2);
- t.pass();
-});
-
-test("coin selection 2", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.0"),
- // Merchant covers the fee, this one shouldn't be used
- fakeAci("EUR:1.0", "EUR:0.0"),
- ];
-
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.5"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
-
- if (!res) {
- t.fail();
- return;
- }
- t.true(res.coinPubs.length === 2);
- t.pass();
-});
-
-test("coin selection 3", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- // this coin should be selected instead of previous one with fee
- fakeAci("EUR:1.0", "EUR:0.0"),
- ];
-
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.5"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
-
- if (!res) {
- t.fail();
- return;
- }
- t.true(res.coinPubs.length === 2);
- t.pass();
-});
-
-test("coin selection 4", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- ];
-
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.5"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
-
- if (!res) {
- t.fail();
- return;
- }
- t.true(res.coinPubs.length === 3);
- t.pass();
-});
-
-test("coin selection 5", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- ];
-
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:4.0"),
- depositFeeLimit: a("EUR:0.2"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
-
- t.true(!res);
- t.pass();
-});
-
-test("coin selection 6", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.5"),
- fakeAci("EUR:1.0", "EUR:0.5"),
- ];
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.2"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
- t.true(!res);
- t.pass();
-});
-
-test("coin selection 7", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.1"),
- fakeAci("EUR:1.0", "EUR:0.1"),
- ];
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:2.0"),
- depositFeeLimit: a("EUR:0.2"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
- t.truthy(res);
- t.true(Amounts.cmp(res!.customerDepositFees, "EUR:0.0") === 0);
- t.true(
- Amounts.cmp(Amounts.sum(res!.coinContributions).amount, "EUR:2.0") === 0,
- );
- t.pass();
-});
-
-test("coin selection 8", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.2"),
- fakeAci("EUR:0.1", "EUR:0.2"),
- fakeAci("EUR:0.05", "EUR:0.05"),
- fakeAci("EUR:0.05", "EUR:0.05"),
- ];
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:1.1"),
- depositFeeLimit: a("EUR:0.4"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
- t.truthy(res);
- t.true(res!.coinContributions.length === 3);
- t.pass();
-});
-
-test("coin selection 9", (t) => {
- const acis: AvailableCoinInfo[] = [
- fakeAci("EUR:1.0", "EUR:0.2"),
- fakeAci("EUR:0.2", "EUR:0.2"),
- ];
- const res = selectPayCoins({
- candidates: {
- candidateCoins: acis,
- wireFeesPerExchange: {},
- },
- contractTermsAmount: a("EUR:1.2"),
- depositFeeLimit: a("EUR:0.4"),
- wireFeeLimit: a("EUR:0"),
- wireFeeAmortization: 1,
- });
- t.truthy(res);
- t.true(res!.coinContributions.length === 2);
- t.true(
- Amounts.cmp(Amounts.sum(res!.coinContributions).amount, "EUR:1.2") === 0,
- );
- t.pass();
-});
diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts
deleted file mode 100644
index 500cee5d8..000000000
--- a/packages/taler-wallet-core/src/util/coinSelection.ts
+++ /dev/null
@@ -1,332 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Selection of coins for payments.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { strcmp, Logger } from "@gnu-taler/taler-util";
-
-const logger = new Logger("coinSelection.ts");
-
-/**
- * Result of selecting coins, contains the exchange, and selected
- * coins with their denomination.
- */
-export interface PayCoinSelection {
- /**
- * Amount requested by the merchant.
- */
- paymentAmount: AmountJson;
-
- /**
- * Public keys of the coins that were selected.
- */
- coinPubs: string[];
-
- /**
- * Amount that each coin contributes.
- */
- coinContributions: AmountJson[];
-
- /**
- * How much of the wire fees is the customer paying?
- */
- customerWireFees: AmountJson;
-
- /**
- * How much of the deposit fees is the customer paying?
- */
- customerDepositFees: AmountJson;
-}
-
-/**
- * Structure to describe a coin that is available to be
- * used in a payment.
- */
-export interface AvailableCoinInfo {
- /**
- * Public key of the coin.
- */
- coinPub: string;
-
- /**
- * Coin's denomination public key.
- */
- denomPub: string;
-
- /**
- * Amount still remaining (typically the full amount,
- * as coins are always refreshed after use.)
- */
- availableAmount: AmountJson;
-
- /**
- * Deposit fee for the coin.
- */
- feeDeposit: AmountJson;
-
- exchangeBaseUrl: string;
-}
-
-export type PreviousPayCoins = {
- coinPub: string;
- contribution: AmountJson;
- feeDeposit: AmountJson;
- exchangeBaseUrl: string;
-}[];
-
-export interface CoinCandidateSelection {
- candidateCoins: AvailableCoinInfo[];
- wireFeesPerExchange: Record<string, AmountJson>;
-}
-
-export interface SelectPayCoinRequest {
- candidates: CoinCandidateSelection;
- contractTermsAmount: AmountJson;
- depositFeeLimit: AmountJson;
- wireFeeLimit: AmountJson;
- wireFeeAmortization: number;
- prevPayCoins?: PreviousPayCoins;
-}
-
-interface CoinSelectionTally {
- /**
- * Amount that still needs to be paid.
- * May increase during the computation when fees need to be covered.
- */
- amountPayRemaining: AmountJson;
-
- /**
- * Allowance given by the merchant towards wire fees
- */
- amountWireFeeLimitRemaining: AmountJson;
-
- /**
- * Allowance given by the merchant towards deposit fees
- * (and wire fees after wire fee limit is exhausted)
- */
- amountDepositFeeLimitRemaining: AmountJson;
-
- customerDepositFees: AmountJson;
-
- customerWireFees: AmountJson;
-
- wireFeeCoveredForExchange: Set<string>;
-}
-
-/**
- * Account for the fees of spending a coin.
- */
-function tallyFees(
- tally: CoinSelectionTally,
- wireFeesPerExchange: Record<string, AmountJson>,
- wireFeeAmortization: number,
- exchangeBaseUrl: string,
- feeDeposit: AmountJson,
-): CoinSelectionTally {
- const currency = tally.amountPayRemaining.currency;
- let amountWireFeeLimitRemaining = tally.amountWireFeeLimitRemaining;
- let amountDepositFeeLimitRemaining = tally.amountDepositFeeLimitRemaining;
- let customerDepositFees = tally.customerDepositFees;
- let customerWireFees = tally.customerWireFees;
- let amountPayRemaining = tally.amountPayRemaining;
- const wireFeeCoveredForExchange = new Set(tally.wireFeeCoveredForExchange);
-
- if (!tally.wireFeeCoveredForExchange.has(exchangeBaseUrl)) {
- const wf =
- wireFeesPerExchange[exchangeBaseUrl] ?? Amounts.getZero(currency);
- const wfForgiven = Amounts.min(amountWireFeeLimitRemaining, wf);
- amountWireFeeLimitRemaining = Amounts.sub(
- amountWireFeeLimitRemaining,
- wfForgiven,
- ).amount;
- // The remaining, amortized amount needs to be paid by the
- // wallet or covered by the deposit fee allowance.
- let wfRemaining = Amounts.divide(
- Amounts.sub(wf, wfForgiven).amount,
- wireFeeAmortization,
- );
-
- // This is the amount forgiven via the deposit fee allowance.
- const wfDepositForgiven = Amounts.min(
- amountDepositFeeLimitRemaining,
- wfRemaining,
- );
- amountDepositFeeLimitRemaining = Amounts.sub(
- amountDepositFeeLimitRemaining,
- wfDepositForgiven,
- ).amount;
-
- wfRemaining = Amounts.sub(wfRemaining, wfDepositForgiven).amount;
- customerWireFees = Amounts.add(customerWireFees, wfRemaining).amount;
- amountPayRemaining = Amounts.add(amountPayRemaining, wfRemaining).amount;
-
- wireFeeCoveredForExchange.add(exchangeBaseUrl);
- }
-
- const dfForgiven = Amounts.min(feeDeposit, amountDepositFeeLimitRemaining);
-
- amountDepositFeeLimitRemaining = Amounts.sub(
- amountDepositFeeLimitRemaining,
- dfForgiven,
- ).amount;
-
- // How much does the user spend on deposit fees for this coin?
- const dfRemaining = Amounts.sub(feeDeposit, dfForgiven).amount;
- customerDepositFees = Amounts.add(customerDepositFees, dfRemaining).amount;
- amountPayRemaining = Amounts.add(amountPayRemaining, dfRemaining).amount;
-
- return {
- amountDepositFeeLimitRemaining,
- amountPayRemaining,
- amountWireFeeLimitRemaining,
- customerDepositFees,
- customerWireFees,
- wireFeeCoveredForExchange,
- };
-}
-
-/**
- * Given a list of candidate coins, select coins to spend under the merchant's
- * constraints.
- *
- * The prevPayCoins can be specified to "repair" a coin selection
- * by adding additional coins, after a broken (e.g. double-spent) coin
- * has been removed from the selection.
- *
- * This function is only exported for the sake of unit tests.
- */
-export function selectPayCoins(
- req: SelectPayCoinRequest,
-): PayCoinSelection | undefined {
- const {
- candidates,
- contractTermsAmount,
- depositFeeLimit,
- wireFeeLimit,
- wireFeeAmortization,
- } = req;
-
- if (candidates.candidateCoins.length === 0) {
- return undefined;
- }
- const coinPubs: string[] = [];
- const coinContributions: AmountJson[] = [];
- const currency = contractTermsAmount.currency;
-
- let tally: CoinSelectionTally = {
- amountPayRemaining: contractTermsAmount,
- amountWireFeeLimitRemaining: wireFeeLimit,
- amountDepositFeeLimitRemaining: depositFeeLimit,
- customerDepositFees: Amounts.getZero(currency),
- customerWireFees: Amounts.getZero(currency),
- wireFeeCoveredForExchange: new Set(),
- };
-
- const prevPayCoins = req.prevPayCoins ?? [];
-
- // Look at existing pay coin selection and tally up
- for (const prev of prevPayCoins) {
- tally = tallyFees(
- tally,
- candidates.wireFeesPerExchange,
- wireFeeAmortization,
- prev.exchangeBaseUrl,
- prev.feeDeposit,
- );
- tally.amountPayRemaining = Amounts.sub(
- tally.amountPayRemaining,
- prev.contribution,
- ).amount;
-
- coinPubs.push(prev.coinPub);
- coinContributions.push(prev.contribution);
- }
-
- const prevCoinPubs = new Set(prevPayCoins.map((x) => x.coinPub));
-
- // Sort by available amount (descending), deposit fee (ascending) and
- // denomPub (ascending) if deposit fee is the same
- // (to guarantee deterministic results)
- const candidateCoins = [...candidates.candidateCoins].sort(
- (o1, o2) =>
- -Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
- Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
- strcmp(o1.denomPub, o2.denomPub),
- );
-
- // FIXME: Here, we should select coins in a smarter way.
- // Instead of always spending the next-largest coin,
- // we should try to find the smallest coin that covers the
- // amount.
-
- for (const aci of candidateCoins) {
- // Don't use this coin if depositing it is more expensive than
- // the amount it would give the merchant.
- if (Amounts.cmp(aci.feeDeposit, aci.availableAmount) > 0) {
- continue;
- }
-
- if (Amounts.isZero(tally.amountPayRemaining)) {
- // We have spent enough!
- break;
- }
-
- // The same coin can't contribute twice to the same payment,
- // by a fundamental, intentional limitation of the protocol.
- if (prevCoinPubs.has(aci.coinPub)) {
- continue;
- }
-
- tally = tallyFees(
- tally,
- candidates.wireFeesPerExchange,
- wireFeeAmortization,
- aci.exchangeBaseUrl,
- aci.feeDeposit,
- );
-
- let coinSpend = Amounts.max(
- Amounts.min(tally.amountPayRemaining, aci.availableAmount),
- aci.feeDeposit,
- );
-
- tally.amountPayRemaining = Amounts.sub(
- tally.amountPayRemaining,
- coinSpend,
- ).amount;
- coinPubs.push(aci.coinPub);
- coinContributions.push(coinSpend);
- }
-
- if (Amounts.isZero(tally.amountPayRemaining)) {
- return {
- paymentAmount: contractTermsAmount,
- coinContributions,
- coinPubs,
- customerDepositFees: tally.customerDepositFees,
- customerWireFees: tally.customerWireFees,
- };
- }
- return undefined;
-}
diff --git a/packages/taler-wallet-core/src/util/http.ts b/packages/taler-wallet-core/src/util/http.ts
deleted file mode 100644
index d01f2ee42..000000000
--- a/packages/taler-wallet-core/src/util/http.ts
+++ /dev/null
@@ -1,342 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Helpers for doing XMLHttpRequest-s that are based on ES6 promises.
- * Allows for easy mocking for test cases.
- *
- * The API is inspired by the HTML5 fetch API.
- */
-
-/**
- * Imports
- */
-import { OperationFailedError, makeErrorDetails } from "../errors.js";
-import {
- Logger,
- Duration,
- Timestamp,
- getTimestampNow,
- timestampAddDuration,
- timestampMax,
- TalerErrorDetails,
- Codec,
-} from "@gnu-taler/taler-util";
-import { TalerErrorCode } from "@gnu-taler/taler-util";
-
-const logger = new Logger("http.ts");
-
-/**
- * An HTTP response that is returned by all request methods of this library.
- */
-export interface HttpResponse {
- requestUrl: string;
- requestMethod: string;
- status: number;
- headers: Headers;
- json(): Promise<any>;
- text(): Promise<string>;
- bytes(): Promise<ArrayBuffer>;
-}
-
-export interface HttpRequestOptions {
- method?: "POST" | "PUT" | "GET";
- headers?: { [name: string]: string };
- timeout?: Duration;
- body?: string | ArrayBuffer | ArrayBufferView;
-}
-
-export enum HttpResponseStatus {
- Ok = 200,
- NoContent = 204,
- Gone = 210,
- NotModified = 304,
- BadRequest = 400,
- PaymentRequired = 402,
- NotFound = 404,
- Conflict = 409,
-}
-
-/**
- * Headers, roughly modeled after the fetch API's headers object.
- */
-export class Headers {
- private headerMap = new Map<string, string>();
-
- get(name: string): string | null {
- const r = this.headerMap.get(name.toLowerCase());
- if (r) {
- return r;
- }
- return null;
- }
-
- set(name: string, value: string): void {
- const normalizedName = name.toLowerCase();
- const existing = this.headerMap.get(normalizedName);
- if (existing !== undefined) {
- this.headerMap.set(normalizedName, existing + "," + value);
- } else {
- this.headerMap.set(normalizedName, value);
- }
- }
-
- toJSON(): any {
- const m: Record<string, string> = {};
- this.headerMap.forEach((v, k) => (m[k] = v));
- return m;
- }
-}
-
-/**
- * Interface for the HTTP request library used by the wallet.
- *
- * The request library is bundled into an interface to make mocking and
- * request tunneling easy.
- */
-export interface HttpRequestLibrary {
- /**
- * Make an HTTP GET request.
- */
- get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>;
-
- /**
- * Make an HTTP POST request with a JSON body.
- */
- postJson(
- url: string,
- body: any,
- opt?: HttpRequestOptions,
- ): Promise<HttpResponse>;
-
- /**
- * Make an HTTP POST request with a JSON body.
- */
- fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse>;
-}
-
-type TalerErrorResponse = {
- code: number;
-} & unknown;
-
-type ResponseOrError<T> =
- | { isError: false; response: T }
- | { isError: true; talerErrorResponse: TalerErrorResponse };
-
-export async function readTalerErrorResponse(
- httpResponse: HttpResponse,
-): Promise<TalerErrorDetails> {
- const errJson = await httpResponse.json();
- const talerErrorCode = errJson.code;
- if (typeof talerErrorCode !== "number") {
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Error response did not contain error code",
- {
- requestUrl: httpResponse.requestUrl,
- requestMethod: httpResponse.requestMethod,
- httpStatusCode: httpResponse.status,
- },
- ),
- );
- }
- return errJson;
-}
-
-export async function readUnexpectedResponseDetails(
- httpResponse: HttpResponse,
-): Promise<TalerErrorDetails> {
- const errJson = await httpResponse.json();
- const talerErrorCode = errJson.code;
- if (typeof talerErrorCode !== "number") {
- return makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Error response did not contain error code",
- {
- requestUrl: httpResponse.requestUrl,
- requestMethod: httpResponse.requestMethod,
- httpStatusCode: httpResponse.status,
- },
- );
- }
- return makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "Unexpected error code in response",
- {
- requestUrl: httpResponse.requestUrl,
- httpStatusCode: httpResponse.status,
- errorResponse: errJson,
- },
- );
-}
-
-export async function readSuccessResponseJsonOrErrorCode<T>(
- httpResponse: HttpResponse,
- codec: Codec<T>,
-): Promise<ResponseOrError<T>> {
- if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
- return {
- isError: true,
- talerErrorResponse: await readTalerErrorResponse(httpResponse),
- };
- }
- const respJson = await httpResponse.json();
- let parsedResponse: T;
- try {
- parsedResponse = codec.decode(respJson);
- } catch (e: any) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Response invalid",
- {
- requestUrl: httpResponse.requestUrl,
- httpStatusCode: httpResponse.status,
- validationError: e.toString(),
- },
- );
- }
- return {
- isError: false,
- response: parsedResponse,
- };
-}
-
-export function getHttpResponseErrorDetails(
- httpResponse: HttpResponse,
-): Record<string, unknown> {
- return {
- requestUrl: httpResponse.requestUrl,
- httpStatusCode: httpResponse.status,
- };
-}
-
-export function throwUnexpectedRequestError(
- httpResponse: HttpResponse,
- talerErrorResponse: TalerErrorResponse,
-): never {
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
- "Unexpected error code in response",
- {
- requestUrl: httpResponse.requestUrl,
- httpStatusCode: httpResponse.status,
- errorResponse: talerErrorResponse,
- },
- ),
- );
-}
-
-export async function readSuccessResponseJsonOrThrow<T>(
- httpResponse: HttpResponse,
- codec: Codec<T>,
-): Promise<T> {
- const r = await readSuccessResponseJsonOrErrorCode(httpResponse, codec);
- if (!r.isError) {
- return r.response;
- }
- throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
-}
-
-export async function readSuccessResponseTextOrErrorCode<T>(
- httpResponse: HttpResponse,
-): Promise<ResponseOrError<string>> {
- if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
- const errJson = await httpResponse.json();
- const talerErrorCode = errJson.code;
- if (typeof talerErrorCode !== "number") {
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Error response did not contain error code",
- {
- httpStatusCode: httpResponse.status,
- requestUrl: httpResponse.requestUrl,
- requestMethod: httpResponse.requestMethod,
- },
- ),
- );
- }
- return {
- isError: true,
- talerErrorResponse: errJson,
- };
- }
- const respJson = await httpResponse.text();
- return {
- isError: false,
- response: respJson,
- };
-}
-
-export async function checkSuccessResponseOrThrow(
- httpResponse: HttpResponse,
-): Promise<void> {
- if (!(httpResponse.status >= 200 && httpResponse.status < 300)) {
- const errJson = await httpResponse.json();
- const talerErrorCode = errJson.code;
- if (typeof talerErrorCode !== "number") {
- throw new OperationFailedError(
- makeErrorDetails(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Error response did not contain error code",
- {
- httpStatusCode: httpResponse.status,
- requestUrl: httpResponse.requestUrl,
- requestMethod: httpResponse.requestMethod,
- },
- ),
- );
- }
- throwUnexpectedRequestError(httpResponse, errJson);
- }
-}
-
-export async function readSuccessResponseTextOrThrow<T>(
- httpResponse: HttpResponse,
-): Promise<string> {
- const r = await readSuccessResponseTextOrErrorCode(httpResponse);
- if (!r.isError) {
- return r.response;
- }
- throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
-}
-
-/**
- * Get the timestamp at which the response's content is considered expired.
- */
-export function getExpiryTimestamp(
- httpResponse: HttpResponse,
- opt: { minDuration?: Duration },
-): Timestamp {
- const expiryDateMs = new Date(
- httpResponse.headers.get("expiry") ?? "",
- ).getTime();
- let t: Timestamp;
- if (Number.isNaN(expiryDateMs)) {
- t = getTimestampNow();
- } else {
- t = {
- t_ms: expiryDateMs,
- };
- }
- if (opt.minDuration) {
- const t2 = timestampAddDuration(getTimestampNow(), opt.minDuration);
- return timestampMax(t, t2);
- }
- return t;
-}
diff --git a/packages/taler-wallet-core/src/util/promiseUtils.ts b/packages/taler-wallet-core/src/util/promiseUtils.ts
deleted file mode 100644
index d409686d9..000000000
--- a/packages/taler-wallet-core/src/util/promiseUtils.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-export interface OpenedPromise<T> {
- promise: Promise<T>;
- resolve: (val: T) => void;
- reject: (err: any) => void;
-}
-
-/**
- * Get an unresolved promise together with its extracted resolve / reject
- * function.
- */
-export function openPromise<T>(): OpenedPromise<T> {
- let resolve: ((x?: any) => void) | null = null;
- let reject: ((reason?: any) => void) | null = null;
- const promise = new Promise<T>((res, rej) => {
- resolve = res;
- reject = rej;
- });
- if (!(resolve && reject)) {
- // Never happens, unless JS implementation is broken
- throw Error();
- }
- return { resolve, reject, promise };
-}
-
-export class AsyncCondition {
- private _waitPromise: Promise<void>;
- private _resolveWaitPromise: (val: void) => void;
- constructor() {
- const op = openPromise<void>();
- this._waitPromise = op.promise;
- this._resolveWaitPromise = op.resolve;
- }
-
- wait(): Promise<void> {
- return this._waitPromise;
- }
-
- trigger(): void {
- this._resolveWaitPromise();
- const op = openPromise<void>();
- this._waitPromise = op.promise;
- this._resolveWaitPromise = op.resolve;
- }
-}
diff --git a/packages/taler-wallet-core/src/util/retries.ts b/packages/taler-wallet-core/src/util/retries.ts
deleted file mode 100644
index cac7b1b52..000000000
--- a/packages/taler-wallet-core/src/util/retries.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Helpers for dealing with retry timeouts.
- */
-
-/**
- * Imports.
- */
-import { Timestamp, Duration, getTimestampNow } from "@gnu-taler/taler-util";
-
-export interface RetryInfo {
- firstTry: Timestamp;
- nextRetry: Timestamp;
- retryCounter: number;
-}
-
-export interface RetryPolicy {
- readonly backoffDelta: Duration;
- readonly backoffBase: number;
-}
-
-const defaultRetryPolicy: RetryPolicy = {
- backoffBase: 1.5,
- backoffDelta: { d_ms: 200 },
-};
-
-export function updateRetryInfoTimeout(
- r: RetryInfo,
- p: RetryPolicy = defaultRetryPolicy,
-): void {
- const now = getTimestampNow();
- if (now.t_ms === "never") {
- throw Error("assertion failed");
- }
- if (p.backoffDelta.d_ms === "forever") {
- r.nextRetry = { t_ms: "never" };
- return;
- }
- const t =
- now.t_ms + p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
- r.nextRetry = { t_ms: t };
-}
-
-export function getRetryDuration(
- r: RetryInfo | undefined,
- p: RetryPolicy = defaultRetryPolicy,
-): Duration {
- if (!r) {
- // If we don't have any retry info, run immediately.
- return { d_ms: 0 };
- }
- if (p.backoffDelta.d_ms === "forever") {
- return { d_ms: "forever" };
- }
- const t = p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter);
- return { d_ms: t };
-}
-
-export function initRetryInfo(
- p: RetryPolicy = defaultRetryPolicy,
-): RetryInfo {
- const now = getTimestampNow();
- const info = {
- firstTry: now,
- nextRetry: now,
- retryCounter: 0,
- };
- updateRetryInfoTimeout(info, p);
- return info;
-}
diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts
index b798871c2..ad58a66ec 100644
--- a/packages/taler-wallet-core/src/versions.ts
+++ b/packages/taler-wallet-core/src/versions.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 Taler Systems S.A.
+ (C) 2019-2023 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -19,27 +19,66 @@
*
* Uses libtool's current:revision:age versioning.
*/
-export const WALLET_EXCHANGE_PROTOCOL_VERSION = "9:0:0";
+export const WALLET_EXCHANGE_PROTOCOL_VERSION = "17:0:0";
/**
* Protocol version spoken with the merchant.
*
* Uses libtool's current:revision:age versioning.
*/
-export const WALLET_MERCHANT_PROTOCOL_VERSION = "1:0:0";
+export const WALLET_MERCHANT_PROTOCOL_VERSION = "5:0:1";
/**
- * Protocol version spoken with the merchant.
+ * Protocol version spoken with the bank (bank integration API).
+ *
+ * Uses libtool's current:revision:age versioning.
+ */
+export const WALLET_BANK_INTEGRATION_PROTOCOL_VERSION = "1:0:0";
+
+/**
+ * Protocol version spoken with the bank (corebank API).
+ *
+ * Uses libtool's current:revision:age versioning.
+ */
+export const WALLET_COREBANK_API_PROTOCOL_VERSION = "2:0:0";
+
+/**
+ * Protocol version spoken with the bank (conversion API).
*
* Uses libtool's current:revision:age versioning.
*/
-export const WALLET_BANK_INTEGRATION_PROTOCOL_VERSION = "0:0:0";
+export const WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION = "2:0:0";
+
+/**
+ * Libtool version of the wallet-core API.
+ */
+export const WALLET_CORE_API_PROTOCOL_VERSION = "4:0:0";
/**
- * Cache breaker that is appended to queries such as /keys and /wire
- * to break through caching, if it has been accidentally/badly configured
- * by the exchange.
+ * Libtool rules:
*
- * This is only a temporary measure.
+ * If the library source code has changed at all since the last update,
+ * then increment revision (‘c:r:a’ becomes ‘c:r+1:a’).
+ * If any interfaces have been added, removed, or changed since the last
+ * update, increment current, and set revision to 0.
+ * If any interfaces have been added since the last public release, then
+ * increment age.
+ * If any interfaces have been removed or changed since the last public
+ * release, then set age to 0.
*/
-export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "3";
+
+// Provided either by bundler or in the next lines.
+declare global {
+ const walletCoreBuildInfo: {
+ implementationSemver: string;
+ implementationGitHash: string;
+ };
+}
+
+// Provide walletCoreBuildInfo if the bundler does not override it.
+if (!("walletCoreBuildInfo" in globalThis)) {
+ (globalThis as any).walletCoreBuildInfo = {
+ implementationSemver: "unknown",
+ implementationGitHash: "unknown",
+ } satisfies typeof walletCoreBuildInfo;
+}
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
index 991c03ee2..15803ce8d 100644
--- a/packages/taler-wallet-core/src/wallet-api-types.ts
+++ b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -16,273 +16,1278 @@
/**
* Type declarations for the high-level interface to wallet-core.
+ *
+ * Documentation is auto-generated from this file.
*/
/**
* Imports.
*/
import {
- AbortPayWithRefundRequest,
+ AbortTransactionRequest,
AcceptBankIntegratedWithdrawalRequest,
AcceptExchangeTosRequest,
AcceptManualWithdrawalRequest,
AcceptManualWithdrawalResult,
- AcceptTipRequest,
AcceptWithdrawalResponse,
AddExchangeRequest,
- ApplyRefundRequest,
- ApplyRefundResponse,
+ AddGlobalCurrencyAuditorRequest,
+ AddGlobalCurrencyExchangeRequest,
+ AddKnownBankAccountsRequest,
+ AmountResponse,
+ ApplyDevExperimentRequest,
BackupRecovery,
BalancesResponse,
+ CheckPeerPullCreditRequest,
+ CheckPeerPullCreditResponse,
+ CheckPeerPushDebitRequest,
+ CheckPeerPushDebitResponse,
CoinDumpJson,
ConfirmPayRequest,
ConfirmPayResult,
+ ConfirmPeerPullDebitRequest,
+ ConfirmPeerPushCreditRequest,
+ ConvertAmountRequest,
CreateDepositGroupRequest,
CreateDepositGroupResponse,
+ CreateStoredBackupResponse,
+ DeleteExchangeRequest,
+ DeleteStoredBackupRequest,
DeleteTransactionRequest,
- ExchangesListRespose,
+ ExchangeDetailedResponse,
+ ExchangesListResponse,
+ ExchangesShortListResponse,
+ FailTransactionRequest,
ForceRefreshRequest,
+ ForgetKnownBankAccountsRequest,
+ GetActiveTasks,
+ GetAmountRequest,
+ GetBalanceDetailRequest,
+ GetContractTermsDetailsRequest,
+ GetCurrencySpecificationRequest,
+ GetCurrencySpecificationResponse,
+ GetExchangeEntryByUrlRequest,
+ GetExchangeEntryByUrlResponse,
+ GetExchangeResourcesRequest,
+ GetExchangeResourcesResponse,
GetExchangeTosRequest,
GetExchangeTosResult,
+ GetPlanForOperationRequest,
+ GetPlanForOperationResponse,
GetWithdrawalDetailsForAmountRequest,
GetWithdrawalDetailsForUriRequest,
+ ImportDbRequest,
+ InitRequest,
+ InitResponse,
+ InitiatePeerPullCreditRequest,
+ InitiatePeerPullCreditResponse,
+ InitiatePeerPushDebitRequest,
+ InitiatePeerPushDebitResponse,
IntegrationTestArgs,
- ManualWithdrawalDetails,
+ KnownBankAccounts,
+ ListAssociatedRefreshesRequest,
+ ListAssociatedRefreshesResponse,
+ ListExchangesForScopedCurrencyRequest,
+ ListGlobalCurrencyAuditorsResponse,
+ ListGlobalCurrencyExchangesResponse,
+ ListKnownBankAccountsRequest,
+ PrepareDepositRequest,
+ PrepareDepositResponse,
PreparePayRequest,
PreparePayResult,
- PrepareTipRequest,
- PrepareTipResult,
+ PreparePayTemplateRequest,
+ PreparePeerPullDebitRequest,
+ PreparePeerPullDebitResponse,
+ PreparePeerPushCreditRequest,
+ PreparePeerPushCreditResponse,
+ PrepareRefundRequest,
+ PrepareWithdrawExchangeRequest,
+ PrepareWithdrawExchangeResponse,
+ RecoverStoredBackupRequest,
RecoveryLoadRequest,
+ RemoveGlobalCurrencyAuditorRequest,
+ RemoveGlobalCurrencyExchangeRequest,
RetryTransactionRequest,
SetCoinSuspendedRequest,
SetWalletDeviceIdRequest,
+ SharePaymentRequest,
+ SharePaymentResult,
+ StartRefundQueryForUriResponse,
+ StartRefundQueryRequest,
+ StoredBackupList,
TestPayArgs,
- TrackDepositGroupRequest,
- TrackDepositGroupResponse,
+ TestPayResult,
+ TestingGetDenomStatsRequest,
+ TestingGetDenomStatsResponse,
+ TestingListTasksForTransactionRequest,
+ TestingListTasksForTransactionsResponse,
+ TestingSetTimetravelRequest,
+ TestingWaitTransactionRequest,
+ Transaction,
+ TransactionByIdRequest,
+ TransactionWithdrawal,
TransactionsRequest,
TransactionsResponse,
- WalletBackupContentV1,
- WalletCurrencyInfo,
- WithdrawFakebankRequest,
+ TxIdResponse,
+ UpdateExchangeEntryRequest,
+ UserAttentionByIdRequest,
+ UserAttentionsCountResponse,
+ UserAttentionsRequest,
+ UserAttentionsResponse,
+ ValidateIbanRequest,
+ ValidateIbanResponse,
+ WalletContractData,
+ WalletCoreVersion,
WithdrawTestBalanceRequest,
WithdrawUriInfoResponse,
+ WithdrawalDetailsForAmount,
+ WithdrawalTransactionByURIRequest,
} from "@gnu-taler/taler-util";
import {
AddBackupProviderRequest,
+ AddBackupProviderResponse,
BackupInfo,
-} from "./operations/backup/index.js";
-import { PendingOperationsResponse } from "./pending-types.js";
+ RemoveBackupProviderRequest,
+ RunBackupCycleRequest,
+} from "./backup/index.js";
+import { PaymentBalanceDetails } from "./balance.js";
export enum WalletApiOperation {
InitWallet = "initWallet",
+ SetWalletRunConfig = "setWalletRunConfig",
WithdrawTestkudos = "withdrawTestkudos",
WithdrawTestBalance = "withdrawTestBalance",
PreparePayForUri = "preparePayForUri",
+ SharePayment = "sharePayment",
+ PreparePayForTemplate = "preparePayForTemplate",
+ GetContractTermsDetails = "getContractTermsDetails",
RunIntegrationTest = "runIntegrationTest",
+ RunIntegrationTestV2 = "runIntegrationTestV2",
+ TestCrypto = "testCrypto",
TestPay = "testPay",
AddExchange = "addExchange",
GetTransactions = "getTransactions",
+ GetTransactionById = "getTransactionById",
+ GetWithdrawalTransactionByUri = "getWithdrawalTransactionByUri",
+ TestingGetSampleTransactions = "testingGetSampleTransactions",
ListExchanges = "listExchanges",
+ GetExchangeEntryByUrl = "getExchangeEntryByUrl",
+ ListKnownBankAccounts = "listKnownBankAccounts",
+ AddKnownBankAccounts = "addKnownBankAccounts",
+ ForgetKnownBankAccounts = "forgetKnownBankAccounts",
GetWithdrawalDetailsForUri = "getWithdrawalDetailsForUri",
GetWithdrawalDetailsForAmount = "getWithdrawalDetailsForAmount",
AcceptManualWithdrawal = "acceptManualWithdrawal",
GetBalances = "getBalances",
+ GetBalanceDetail = "getBalanceDetail",
+ GetPlanForOperation = "getPlanForOperation",
+ ConvertDepositAmount = "ConvertDepositAmount",
+ GetMaxDepositAmount = "GetMaxDepositAmount",
+ ConvertPeerPushAmount = "ConvertPeerPushAmount",
+ GetMaxPeerPushAmount = "GetMaxPeerPushAmount",
+ ConvertWithdrawalAmount = "ConvertWithdrawalAmount",
+ GetUserAttentionRequests = "getUserAttentionRequests",
+ GetUserAttentionUnreadCount = "getUserAttentionUnreadCount",
+ MarkAttentionRequestAsRead = "markAttentionRequestAsRead",
GetPendingOperations = "getPendingOperations",
+ GetActiveTasks = "getActiveTasks",
SetExchangeTosAccepted = "setExchangeTosAccepted",
- ApplyRefund = "applyRefund",
+ SetExchangeTosForgotten = "SetExchangeTosForgotten",
+ StartRefundQueryForUri = "startRefundQueryForUri",
+ StartRefundQuery = "startRefundQuery",
AcceptBankIntegratedWithdrawal = "acceptBankIntegratedWithdrawal",
GetExchangeTos = "getExchangeTos",
+ GetExchangeDetailedInfo = "getExchangeDetailedInfo",
RetryPendingNow = "retryPendingNow",
- AbortFailedPayWithRefund = "abortFailedPayWithRefund",
+ AbortTransaction = "abortTransaction",
+ FailTransaction = "failTransaction",
+ SuspendTransaction = "suspendTransaction",
+ ResumeTransaction = "resumeTransaction",
+ DeleteTransaction = "deleteTransaction",
+ RetryTransaction = "retryTransaction",
ConfirmPay = "confirmPay",
DumpCoins = "dumpCoins",
SetCoinSuspended = "setCoinSuspended",
ForceRefresh = "forceRefresh",
- PrepareTip = "prepareTip",
- AcceptTip = "acceptTip",
ExportBackup = "exportBackup",
AddBackupProvider = "addBackupProvider",
+ RemoveBackupProvider = "removeBackupProvider",
RunBackupCycle = "runBackupCycle",
ExportBackupRecovery = "exportBackupRecovery",
ImportBackupRecovery = "importBackupRecovery",
GetBackupInfo = "getBackupInfo",
- TrackDepositGroup = "trackDepositGroup",
- DeleteTransaction = "deleteTransaction",
- RetryTransaction = "retryTransaction",
- GetCoins = "getCoins",
- ListCurrencies = "listCurrencies",
+ PrepareDeposit = "prepareDeposit",
+ GetVersion = "getVersion",
+ GenerateDepositGroupTxId = "generateDepositGroupTxId",
CreateDepositGroup = "createDepositGroup",
SetWalletDeviceId = "setWalletDeviceId",
- ExportBackupPlain = "exportBackupPlain",
- WithdrawFakebank = "withdrawFakebank",
+ ImportDb = "importDb",
+ ExportDb = "exportDb",
+ PreparePeerPushCredit = "preparePeerPushCredit",
+ CheckPeerPushDebit = "checkPeerPushDebit",
+ InitiatePeerPushDebit = "initiatePeerPushDebit",
+ ConfirmPeerPushCredit = "confirmPeerPushCredit",
+ CheckPeerPullCredit = "checkPeerPullCredit",
+ InitiatePeerPullCredit = "initiatePeerPullCredit",
+ PreparePeerPullDebit = "preparePeerPullDebit",
+ ConfirmPeerPullDebit = "confirmPeerPullDebit",
+ ClearDb = "clearDb",
+ Recycle = "recycle",
+ ApplyDevExperiment = "applyDevExperiment",
+ ValidateIban = "validateIban",
+ TestingWaitTransactionsFinal = "testingWaitTransactionsFinal",
+ TestingWaitRefreshesFinal = "testingWaitRefreshesFinal",
+ TestingWaitTransactionState = "testingWaitTransactionState",
+ TestingSetTimetravel = "testingSetTimetravel",
+ GetCurrencySpecification = "getCurrencySpecification",
+ ListStoredBackups = "listStoredBackups",
+ CreateStoredBackup = "createStoredBackup",
+ DeleteStoredBackup = "deleteStoredBackup",
+ RecoverStoredBackup = "recoverStoredBackup",
+ UpdateExchangeEntry = "updateExchangeEntry",
+ ListExchangesForScopedCurrency = "listExchangesForScopedCurrency",
+ PrepareWithdrawExchange = "prepareWithdrawExchange",
+ TestingInfiniteTransactionLoop = "testingInfiniteTransactionLoop",
+ GetExchangeResources = "getExchangeResources",
+ DeleteExchange = "deleteExchange",
+ ListGlobalCurrencyExchanges = "listGlobalCurrencyExchanges",
+ ListGlobalCurrencyAuditors = "listGlobalCurrencyAuditors",
+ AddGlobalCurrencyExchange = "addGlobalCurrencyExchange",
+ RemoveGlobalCurrencyExchange = "removeGlobalCurrencyExchange",
+ AddGlobalCurrencyAuditor = "addGlobalCurrencyAuditor",
+ RemoveGlobalCurrencyAuditor = "removeGlobalCurrencyAuditor",
+ ListAssociatedRefreshes = "listAssociatedRefreshes",
+ TestingListTaskForTransaction = "testingListTasksForTransaction",
+ TestingGetDenomStats = "testingGetDenomStats",
+ TestingPing = "testingPing",
}
+// group: Initialization
+
+type EmptyObject = Record<string, never>;
+
+/**
+ * Initialize wallet-core.
+ *
+ * Must be the first request made to wallet-core.
+ */
+export type InitWalletOp = {
+ op: WalletApiOperation.InitWallet;
+ request: InitRequest;
+ response: InitResponse;
+};
+
+/**
+ * Change the configuration of wallet-core.
+ *
+ * Currently an alias for the initWallet request.
+ */
+export type SetWalletRunConfigOp = {
+ op: WalletApiOperation.SetWalletRunConfig;
+ request: InitRequest;
+ response: InitResponse;
+};
+
+export type GetVersionOp = {
+ op: WalletApiOperation.GetVersion;
+ request: EmptyObject;
+ response: WalletCoreVersion;
+};
+
+// group: Basic Wallet Information
+
+/**
+ * Get current wallet balance.
+ */
+export type GetBalancesOp = {
+ op: WalletApiOperation.GetBalances;
+ request: EmptyObject;
+ response: BalancesResponse;
+};
+export type GetBalancesDetailOp = {
+ op: WalletApiOperation.GetBalanceDetail;
+ request: GetBalanceDetailRequest;
+ response: PaymentBalanceDetails;
+};
+
+export type GetPlanForOperationOp = {
+ op: WalletApiOperation.GetPlanForOperation;
+ request: GetPlanForOperationRequest;
+ response: GetPlanForOperationResponse;
+};
+
+export type ConvertDepositAmountOp = {
+ op: WalletApiOperation.ConvertDepositAmount;
+ request: ConvertAmountRequest;
+ response: AmountResponse;
+};
+export type GetMaxDepositAmountOp = {
+ op: WalletApiOperation.GetMaxDepositAmount;
+ request: GetAmountRequest;
+ response: AmountResponse;
+};
+export type ConvertPeerPushAmountOp = {
+ op: WalletApiOperation.ConvertPeerPushAmount;
+ request: ConvertAmountRequest;
+ response: AmountResponse;
+};
+export type GetMaxPeerPushAmountOp = {
+ op: WalletApiOperation.GetMaxPeerPushAmount;
+ request: GetAmountRequest;
+ response: AmountResponse;
+};
+export type ConvertWithdrawalAmountOp = {
+ op: WalletApiOperation.ConvertWithdrawalAmount;
+ request: ConvertAmountRequest;
+ response: AmountResponse;
+};
+
+// group: Managing Transactions
+
+/**
+ * Get transactions.
+ */
+export type GetTransactionsOp = {
+ op: WalletApiOperation.GetTransactions;
+ request: TransactionsRequest;
+ response: TransactionsResponse;
+};
+
+/**
+ * List refresh transactions associated with another transaction.
+ */
+export type ListAssociatedRefreshesOp = {
+ op: WalletApiOperation.ListAssociatedRefreshes;
+ request: ListAssociatedRefreshesRequest;
+ response: ListAssociatedRefreshesResponse;
+};
+
+/**
+ * Get sample transactions.
+ */
+export type TestingGetSampleTransactionsOp = {
+ op: WalletApiOperation.TestingGetSampleTransactions;
+ request: EmptyObject;
+ response: TransactionsResponse;
+};
+
+export type GetTransactionByIdOp = {
+ op: WalletApiOperation.GetTransactionById;
+ request: TransactionByIdRequest;
+ response: Transaction;
+};
+
+export type GetWithdrawalTransactionByUriOp = {
+ op: WalletApiOperation.GetWithdrawalTransactionByUri;
+ request: WithdrawalTransactionByURIRequest;
+ response: TransactionWithdrawal | undefined;
+};
+
+export type RetryPendingNowOp = {
+ op: WalletApiOperation.RetryPendingNow;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Delete a transaction locally in the wallet.
+ */
+export type DeleteTransactionOp = {
+ op: WalletApiOperation.DeleteTransaction;
+ request: DeleteTransactionRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Immediately retry a transaction.
+ */
+export type RetryTransactionOp = {
+ op: WalletApiOperation.RetryTransaction;
+ request: RetryTransactionRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Abort a transaction
+ *
+ * For payment transactions, it puts the payment into an "aborting" state.
+ */
+export type AbortTransactionOp = {
+ op: WalletApiOperation.AbortTransaction;
+ request: AbortTransactionRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Cancel aborting a transaction
+ *
+ * For payment transactions, it puts the payment into an "aborting" state.
+ */
+export type FailTransactionOp = {
+ op: WalletApiOperation.FailTransaction;
+ request: FailTransactionRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Suspend a transaction
+ */
+export type SuspendTransactionOp = {
+ op: WalletApiOperation.SuspendTransaction;
+ request: AbortTransactionRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Resume a transaction
+ */
+export type ResumeTransactionOp = {
+ op: WalletApiOperation.ResumeTransaction;
+ request: AbortTransactionRequest;
+ response: EmptyObject;
+};
+
+// group: Withdrawals
+
+/**
+ * Get details for withdrawing a particular amount (manual withdrawal).
+ */
+export type GetWithdrawalDetailsForAmountOp = {
+ op: WalletApiOperation.GetWithdrawalDetailsForAmount;
+ request: GetWithdrawalDetailsForAmountRequest;
+ response: WithdrawalDetailsForAmount;
+};
+
+/**
+ * Get details for withdrawing via a particular taler:// URI.
+ */
+export type GetWithdrawalDetailsForUriOp = {
+ op: WalletApiOperation.GetWithdrawalDetailsForUri;
+ request: GetWithdrawalDetailsForUriRequest;
+ response: WithdrawUriInfoResponse;
+};
+
+/**
+ * Accept a bank-integrated withdrawal.
+ */
+export type AcceptBankIntegratedWithdrawalOp = {
+ op: WalletApiOperation.AcceptBankIntegratedWithdrawal;
+ request: AcceptBankIntegratedWithdrawalRequest;
+ response: AcceptWithdrawalResponse;
+};
+
+/**
+ * Create a manual withdrawal.
+ */
+export type AcceptManualWithdrawalOp = {
+ op: WalletApiOperation.AcceptManualWithdrawal;
+ request: AcceptManualWithdrawalRequest;
+ response: AcceptManualWithdrawalResult;
+};
+
+// group: Merchant Payments
+
+/**
+ * Prepare to make a payment based on a taler://pay/ URI.
+ */
+export type PreparePayForUriOp = {
+ op: WalletApiOperation.PreparePayForUri;
+ request: PreparePayRequest;
+ response: PreparePayResult;
+};
+
+export type SharePaymentOp = {
+ op: WalletApiOperation.SharePayment;
+ request: SharePaymentRequest;
+ response: SharePaymentResult;
+};
+
+/**
+ * Prepare to make a payment based on a taler://pay-template/ URI.
+ */
+export type PreparePayForTemplateOp = {
+ op: WalletApiOperation.PreparePayForTemplate;
+ request: PreparePayTemplateRequest;
+ response: PreparePayResult;
+};
+
+export type GetContractTermsDetailsOp = {
+ op: WalletApiOperation.GetContractTermsDetails;
+ request: GetContractTermsDetailsRequest;
+ response: WalletContractData;
+};
+
+/**
+ * Confirm a payment that was previously prepared with
+ * {@link PreparePayForUriOp}
+ */
+export type ConfirmPayOp = {
+ op: WalletApiOperation.ConfirmPay;
+ request: ConfirmPayRequest;
+ response: ConfirmPayResult;
+};
+
+/**
+ * Check for a refund based on a taler://refund URI.
+ */
+export type StartRefundQueryForUriOp = {
+ op: WalletApiOperation.StartRefundQueryForUri;
+ request: PrepareRefundRequest;
+ response: StartRefundQueryForUriResponse;
+};
+
+export type StartRefundQueryOp = {
+ op: WalletApiOperation.StartRefundQuery;
+ request: StartRefundQueryRequest;
+ response: EmptyObject;
+};
+
+// group: Global Currency management
+
+export type ListGlobalCurrencyAuditorsOp = {
+ op: WalletApiOperation.ListGlobalCurrencyAuditors;
+ request: EmptyObject;
+ response: ListGlobalCurrencyAuditorsResponse;
+};
+
+export type ListGlobalCurrencyExchangesOp = {
+ op: WalletApiOperation.ListGlobalCurrencyExchanges;
+ request: EmptyObject;
+ response: ListGlobalCurrencyExchangesResponse;
+};
+
+export type AddGlobalCurrencyExchangeOp = {
+ op: WalletApiOperation.AddGlobalCurrencyExchange;
+ request: AddGlobalCurrencyExchangeRequest;
+ response: EmptyObject;
+};
+
+export type AddGlobalCurrencyAuditorOp = {
+ op: WalletApiOperation.AddGlobalCurrencyAuditor;
+ request: AddGlobalCurrencyAuditorRequest;
+ response: EmptyObject;
+};
+
+export type RemoveGlobalCurrencyExchangeOp = {
+ op: WalletApiOperation.RemoveGlobalCurrencyExchange;
+ request: RemoveGlobalCurrencyExchangeRequest;
+ response: EmptyObject;
+};
+
+export type RemoveGlobalCurrencyAuditorOp = {
+ op: WalletApiOperation.RemoveGlobalCurrencyAuditor;
+ request: RemoveGlobalCurrencyAuditorRequest;
+ response: EmptyObject;
+};
+
+// group: Exchange Management
+
+/**
+ * List exchanges known to the wallet.
+ */
+export type ListExchangesOp = {
+ op: WalletApiOperation.ListExchanges;
+ request: EmptyObject;
+ response: ExchangesListResponse;
+};
+
+/**
+ * List exchanges that are available for withdrawing a particular
+ * scoped currency.
+ */
+export type ListExchangesForScopedCurrencyOp = {
+ op: WalletApiOperation.ListExchangesForScopedCurrency;
+ request: ListExchangesForScopedCurrencyRequest;
+ response: ExchangesShortListResponse;
+};
+
+/**
+ * Prepare for withdrawing via a taler://withdraw-exchange URI.
+ */
+export type PrepareWithdrawExchangeOp = {
+ op: WalletApiOperation.PrepareWithdrawExchange;
+ request: PrepareWithdrawExchangeRequest;
+ response: PrepareWithdrawExchangeResponse;
+};
+
+/**
+ * Add / force-update an exchange.
+ */
+export type AddExchangeOp = {
+ op: WalletApiOperation.AddExchange;
+ request: AddExchangeRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Update an exchange entry.
+ */
+export type UpdateExchangeEntryOp = {
+ op: WalletApiOperation.UpdateExchangeEntry;
+ request: UpdateExchangeEntryRequest;
+ response: EmptyObject;
+};
+
+export type ListKnownBankAccountsOp = {
+ op: WalletApiOperation.ListKnownBankAccounts;
+ request: ListKnownBankAccountsRequest;
+ response: KnownBankAccounts;
+};
+
+export type AddKnownBankAccountsOp = {
+ op: WalletApiOperation.AddKnownBankAccounts;
+ request: AddKnownBankAccountsRequest;
+ response: EmptyObject;
+};
+
+export type ForgetKnownBankAccountsOp = {
+ op: WalletApiOperation.ForgetKnownBankAccounts;
+ request: ForgetKnownBankAccountsRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Accept a particular version of the exchange terms of service.
+ */
+export type SetExchangeTosAcceptedOp = {
+ op: WalletApiOperation.SetExchangeTosAccepted;
+ request: AcceptExchangeTosRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Accept a particular version of the exchange terms of service.
+ */
+export type SetExchangeTosForgottenOp = {
+ op: WalletApiOperation.SetExchangeTosForgotten;
+ request: AcceptExchangeTosRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Get the current terms of a service of an exchange.
+ */
+export type GetExchangeTosOp = {
+ op: WalletApiOperation.GetExchangeTos;
+ request: GetExchangeTosRequest;
+ response: GetExchangeTosResult;
+};
+
+/**
+ * Get the current terms of a service of an exchange.
+ */
+export type GetExchangeDetailedInfoOp = {
+ op: WalletApiOperation.GetExchangeDetailedInfo;
+ request: AddExchangeRequest;
+ response: ExchangeDetailedResponse;
+};
+
+/**
+ * Get the current terms of a service of an exchange.
+ */
+export type GetExchangeEntryByUrlOp = {
+ op: WalletApiOperation.GetExchangeEntryByUrl;
+ request: GetExchangeEntryByUrlRequest;
+ response: GetExchangeEntryByUrlResponse;
+};
+
+/**
+ * Get resources associated with an exchange.
+ */
+export type GetExchangeResourcesOp = {
+ op: WalletApiOperation.GetExchangeResources;
+ request: GetExchangeResourcesRequest;
+ response: GetExchangeResourcesResponse;
+};
+
+/**
+ * Get resources associated with an exchange.
+ */
+export type DeleteExchangeOp = {
+ op: WalletApiOperation.GetExchangeResources;
+ request: DeleteExchangeRequest;
+ response: EmptyObject;
+};
+
+export type GetCurrencySpecificationOp = {
+ op: WalletApiOperation.GetCurrencySpecification;
+ request: GetCurrencySpecificationRequest;
+ response: GetCurrencySpecificationResponse;
+};
+
+// group: Deposits
+
+/**
+ * Generate a fresh transaction ID for a deposit group.
+ *
+ * The resulting transaction ID can be specified when creating
+ * a deposit group, so that the client can already start waiting for notifications
+ * on that specific deposit group before the GreateDepositGroup request returns.
+ */
+export type GenerateDepositGroupTxIdOp = {
+ op: WalletApiOperation.GenerateDepositGroupTxId;
+ request: EmptyObject;
+ response: TxIdResponse;
+};
+
+/**
+ * Create a new deposit group.
+ *
+ * Deposit groups are used to deposit multiple coins to a bank
+ * account, usually the wallet user's own bank account.
+ */
+export type CreateDepositGroupOp = {
+ op: WalletApiOperation.CreateDepositGroup;
+ request: CreateDepositGroupRequest;
+ response: CreateDepositGroupResponse;
+};
+
+export type PrepareDepositOp = {
+ op: WalletApiOperation.PrepareDeposit;
+ request: PrepareDepositRequest;
+ response: PrepareDepositResponse;
+};
+
+// group: Backups
+
+/**
+ * Export the recovery information for the wallet.
+ */
+export type ExportBackupRecoveryOp = {
+ op: WalletApiOperation.ExportBackupRecovery;
+ request: EmptyObject;
+ response: BackupRecovery;
+};
+
+/**
+ * Import recovery information into the wallet.
+ */
+export type ImportBackupRecoveryOp = {
+ op: WalletApiOperation.ImportBackupRecovery;
+ request: RecoveryLoadRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Manually make and upload a backup.
+ */
+export type RunBackupCycleOp = {
+ op: WalletApiOperation.RunBackupCycle;
+ request: RunBackupCycleRequest;
+ response: EmptyObject;
+};
+
+export type ExportBackupOp = {
+ op: WalletApiOperation.ExportBackup;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Add a new backup provider.
+ */
+export type AddBackupProviderOp = {
+ op: WalletApiOperation.AddBackupProvider;
+ request: AddBackupProviderRequest;
+ response: AddBackupProviderResponse;
+};
+
+export type RemoveBackupProviderOp = {
+ op: WalletApiOperation.RemoveBackupProvider;
+ request: RemoveBackupProviderRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Get some useful stats about the backup state.
+ */
+export type GetBackupInfoOp = {
+ op: WalletApiOperation.GetBackupInfo;
+ request: EmptyObject;
+ response: BackupInfo;
+};
+
+/**
+ * Set the internal device ID of the wallet, used to
+ * identify whether a different/new wallet is accessing
+ * the backup of another wallet.
+ */
+export type SetWalletDeviceIdOp = {
+ op: WalletApiOperation.SetWalletDeviceId;
+ request: SetWalletDeviceIdRequest;
+ response: EmptyObject;
+};
+
+export type ListStoredBackupsOp = {
+ op: WalletApiOperation.ListStoredBackups;
+ request: EmptyObject;
+ response: StoredBackupList;
+};
+
+export type CreateStoredBackupsOp = {
+ op: WalletApiOperation.CreateStoredBackup;
+ request: EmptyObject;
+ response: CreateStoredBackupResponse;
+};
+
+export type RecoverStoredBackupsOp = {
+ op: WalletApiOperation.RecoverStoredBackup;
+ request: RecoverStoredBackupRequest;
+ response: EmptyObject;
+};
+
+export type DeleteStoredBackupOp = {
+ op: WalletApiOperation.DeleteStoredBackup;
+ request: DeleteStoredBackupRequest;
+ response: EmptyObject;
+};
+
+// group: Peer Payments
+
+/**
+ * Check if initiating a peer push payment is possible
+ * based on the funds in the wallet.
+ */
+export type CheckPeerPushDebitOp = {
+ op: WalletApiOperation.CheckPeerPushDebit;
+ request: CheckPeerPushDebitRequest;
+ response: CheckPeerPushDebitResponse;
+};
+
+/**
+ * Initiate an outgoing peer push payment.
+ */
+export type InitiatePeerPushDebitOp = {
+ op: WalletApiOperation.InitiatePeerPushDebit;
+ request: InitiatePeerPushDebitRequest;
+ response: InitiatePeerPushDebitResponse;
+};
+
+/**
+ * Check an incoming peer push payment.
+ */
+export type PreparePeerPushCreditOp = {
+ op: WalletApiOperation.PreparePeerPushCredit;
+ request: PreparePeerPushCreditRequest;
+ response: PreparePeerPushCreditResponse;
+};
+
+/**
+ * Accept an incoming peer push payment.
+ */
+export type ConfirmPeerPushCreditOp = {
+ op: WalletApiOperation.ConfirmPeerPushCredit;
+ request: ConfirmPeerPushCreditRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Check fees for an outgoing peer pull payment.
+ */
+export type CheckPeerPullCreditOp = {
+ op: WalletApiOperation.CheckPeerPullCredit;
+ request: CheckPeerPullCreditRequest;
+ response: CheckPeerPullCreditResponse;
+};
+
+/**
+ * Initiate an outgoing peer pull payment.
+ */
+export type InitiatePeerPullCreditOp = {
+ op: WalletApiOperation.InitiatePeerPullCredit;
+ request: InitiatePeerPullCreditRequest;
+ response: InitiatePeerPullCreditResponse;
+};
+
+/**
+ * Prepare for an incoming peer pull payment.
+ */
+export type PreparePeerPullDebitOp = {
+ op: WalletApiOperation.PreparePeerPullDebit;
+ request: PreparePeerPullDebitRequest;
+ response: PreparePeerPullDebitResponse;
+};
+
+/**
+ * Accept an incoming peer pull payment (i.e. pay the other party).
+ */
+export type ConfirmPeerPullDebitOp = {
+ op: WalletApiOperation.ConfirmPeerPullDebit;
+ request: ConfirmPeerPullDebitRequest;
+ response: EmptyObject;
+};
+
+// group: Data Validation
+
+export type ValidateIbanOp = {
+ op: WalletApiOperation.ValidateIban;
+ request: ValidateIbanRequest;
+ response: ValidateIbanResponse;
+};
+
+// group: Database Management
+
+/**
+ * Export the wallet database's contents to JSON.
+ */
+export type ExportDbOp = {
+ op: WalletApiOperation.ExportDb;
+ request: EmptyObject;
+ response: any;
+};
+
+export type ImportDbOp = {
+ op: WalletApiOperation.ImportDb;
+ request: ImportDbRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Dangerously clear the whole wallet database.
+ */
+export type ClearDbOp = {
+ op: WalletApiOperation.ClearDb;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Export a backup, clear the database and re-import it.
+ */
+export type RecycleOp = {
+ op: WalletApiOperation.Recycle;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+// group: Testing and Debugging
+
+/**
+ * Apply a developer experiment to the current wallet state.
+ *
+ * This allows UI developers / testers to play around without
+ * an elaborate test environment.
+ */
+export type ApplyDevExperimentOp = {
+ op: WalletApiOperation.ApplyDevExperiment;
+ request: ApplyDevExperimentRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Run a simple integration test on a test deployment
+ * of the exchange and merchant.
+ */
+export type RunIntegrationTestOp = {
+ op: WalletApiOperation.RunIntegrationTest;
+ request: IntegrationTestArgs;
+ response: EmptyObject;
+};
+
+/**
+ * Run a simple integration test on a test deployment
+ * of the exchange and merchant.
+ */
+export type RunIntegrationTestV2Op = {
+ op: WalletApiOperation.RunIntegrationTestV2;
+ request: IntegrationTestArgs;
+ response: EmptyObject;
+};
+
+/**
+ * Test crypto worker.
+ */
+export type TestCryptoOp = {
+ op: WalletApiOperation.TestCrypto;
+ request: EmptyObject;
+ response: any;
+};
+
+/**
+ * Make withdrawal on a test deployment of the exchange
+ * and merchant.
+ */
+export type WithdrawTestBalanceOp = {
+ op: WalletApiOperation.WithdrawTestBalance;
+ request: WithdrawTestBalanceRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Make a withdrawal of testkudos on test.taler.net.
+ */
+export type WithdrawTestkudosOp = {
+ op: WalletApiOperation.WithdrawTestkudos;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Make a test payment using a test deployment of
+ * the exchange and merchant.
+ */
+export type TestPayOp = {
+ op: WalletApiOperation.TestPay;
+ request: TestPayArgs;
+ response: TestPayResult;
+};
+
+/**
+ * Get wallet-internal pending tasks.
+ */
+export type GetUserAttentionRequests = {
+ op: WalletApiOperation.GetUserAttentionRequests;
+ request: UserAttentionsRequest;
+ response: UserAttentionsResponse;
+};
+
+/**
+ * Get wallet-internal pending tasks.
+ */
+export type MarkAttentionRequestAsRead = {
+ op: WalletApiOperation.MarkAttentionRequestAsRead;
+ request: UserAttentionByIdRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Get wallet-internal pending tasks.
+ */
+export type GetUserAttentionsUnreadCount = {
+ op: WalletApiOperation.GetUserAttentionUnreadCount;
+ request: UserAttentionsRequest;
+ response: UserAttentionsCountResponse;
+};
+
+/**
+ * Get wallet-internal pending tasks.
+ *
+ * @deprecated
+ */
+export type GetPendingTasksOp = {
+ op: WalletApiOperation.GetPendingOperations;
+ request: EmptyObject;
+ response: any;
+};
+
+export type GetActiveTasksOp = {
+ op: WalletApiOperation.GetActiveTasks;
+ request: EmptyObject;
+ response: GetActiveTasks;
+};
+
+/**
+ * Dump all coins of the wallet in a simple JSON format.
+ */
+export type DumpCoinsOp = {
+ op: WalletApiOperation.DumpCoins;
+ request: EmptyObject;
+ response: CoinDumpJson;
+};
+
+/**
+ * Add an offset to the wallet's internal time.
+ */
+export type TestingSetTimetravelOp = {
+ op: WalletApiOperation.TestingSetTimetravel;
+ request: TestingSetTimetravelRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Add an offset to the wallet's internal time.
+ */
+export type TestingListTasksForTransactionOp = {
+ op: WalletApiOperation.TestingListTaskForTransaction;
+ request: TestingListTasksForTransactionRequest;
+ response: TestingListTasksForTransactionsResponse;
+};
+
+/**
+ * Wait until all transactions are in a final state.
+ */
+export type TestingWaitTransactionsFinalOp = {
+ op: WalletApiOperation.TestingWaitTransactionsFinal;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Wait until all refresh transactions are in a final state.
+ */
+export type TestingWaitRefreshesFinalOp = {
+ op: WalletApiOperation.TestingWaitRefreshesFinal;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Wait until a transaction is in a particular state.
+ */
+export type TestingWaitTransactionStateOp = {
+ op: WalletApiOperation.TestingWaitTransactionState;
+ request: TestingWaitTransactionRequest;
+ response: EmptyObject;
+};
+
+export type TestingPingOp = {
+ op: WalletApiOperation.TestingPing;
+ request: EmptyObject;
+ response: EmptyObject;
+};
+
+/**
+ * Get stats about an exchange denomination.
+ */
+export type TestingGetDenomStatsOp = {
+ op: WalletApiOperation.TestingGetDenomStats;
+ request: TestingGetDenomStatsRequest;
+ response: TestingGetDenomStatsResponse;
+};
+
+/**
+ * Set a coin as (un-)suspended.
+ * Suspended coins won't be used for payments.
+ */
+export type SetCoinSuspendedOp = {
+ op: WalletApiOperation.SetCoinSuspended;
+ request: SetCoinSuspendedRequest;
+ response: EmptyObject;
+};
+
+/**
+ * Force a refresh on coins where it would not
+ * be necessary.
+ */
+export type ForceRefreshOp = {
+ op: WalletApiOperation.ForceRefresh;
+ request: ForceRefreshRequest;
+ response: EmptyObject;
+};
+
export type WalletOperations = {
- [WalletApiOperation.InitWallet]: {
- request: {};
- response: {};
- };
- [WalletApiOperation.WithdrawFakebank]: {
- request: WithdrawFakebankRequest;
- response: {};
- };
- [WalletApiOperation.PreparePayForUri]: {
- request: PreparePayRequest;
- response: PreparePayResult;
- };
- [WalletApiOperation.WithdrawTestkudos]: {
- request: {};
- response: {};
- };
- [WalletApiOperation.ConfirmPay]: {
- request: ConfirmPayRequest;
- response: ConfirmPayResult;
- };
- [WalletApiOperation.AbortFailedPayWithRefund]: {
- request: AbortPayWithRefundRequest;
- response: {};
- };
- [WalletApiOperation.GetBalances]: {
- request: {};
- response: BalancesResponse;
- };
- [WalletApiOperation.GetTransactions]: {
- request: TransactionsRequest;
- response: TransactionsResponse;
- };
- [WalletApiOperation.GetPendingOperations]: {
- request: {};
- response: PendingOperationsResponse;
- };
- [WalletApiOperation.DumpCoins]: {
- request: {};
- response: CoinDumpJson;
- };
- [WalletApiOperation.SetCoinSuspended]: {
- request: SetCoinSuspendedRequest;
- response: {};
- };
- [WalletApiOperation.ForceRefresh]: {
- request: ForceRefreshRequest;
- response: {};
- };
- [WalletApiOperation.DeleteTransaction]: {
- request: DeleteTransactionRequest;
- response: {};
- };
- [WalletApiOperation.RetryTransaction]: {
- request: RetryTransactionRequest;
- response: {};
- };
- [WalletApiOperation.PrepareTip]: {
- request: PrepareTipRequest;
- response: PrepareTipResult;
- };
- [WalletApiOperation.AcceptTip]: {
- request: AcceptTipRequest;
- response: {};
- };
- [WalletApiOperation.ApplyRefund]: {
- request: ApplyRefundRequest;
- response: ApplyRefundResponse;
- };
- [WalletApiOperation.ListCurrencies]: {
- request: {};
- response: WalletCurrencyInfo;
- };
- [WalletApiOperation.GetWithdrawalDetailsForAmount]: {
- request: GetWithdrawalDetailsForAmountRequest;
- response: ManualWithdrawalDetails;
- };
- [WalletApiOperation.GetWithdrawalDetailsForUri]: {
- request: GetWithdrawalDetailsForUriRequest;
- response: WithdrawUriInfoResponse;
- };
- [WalletApiOperation.AcceptBankIntegratedWithdrawal]: {
- request: AcceptBankIntegratedWithdrawalRequest;
- response: AcceptWithdrawalResponse;
- };
- [WalletApiOperation.AcceptManualWithdrawal]: {
- request: AcceptManualWithdrawalRequest;
- response: AcceptManualWithdrawalResult;
- };
- [WalletApiOperation.ListExchanges]: {
- request: {};
- response: ExchangesListRespose;
- };
- [WalletApiOperation.AddExchange]: {
- request: AddExchangeRequest;
- response: {};
- };
- [WalletApiOperation.SetExchangeTosAccepted]: {
- request: AcceptExchangeTosRequest;
- response: {};
- };
- [WalletApiOperation.GetExchangeTos]: {
- request: GetExchangeTosRequest;
- response: GetExchangeTosResult;
- };
- [WalletApiOperation.TrackDepositGroup]: {
- request: TrackDepositGroupRequest;
- response: TrackDepositGroupResponse;
- };
- [WalletApiOperation.CreateDepositGroup]: {
- request: CreateDepositGroupRequest;
- response: CreateDepositGroupResponse;
- };
- [WalletApiOperation.SetWalletDeviceId]: {
- request: SetWalletDeviceIdRequest;
- response: {};
- };
- [WalletApiOperation.ExportBackupPlain]: {
- request: {};
- response: WalletBackupContentV1;
- };
- [WalletApiOperation.ExportBackupRecovery]: {
- request: {};
- response: BackupRecovery;
- };
- [WalletApiOperation.ImportBackupRecovery]: {
- request: RecoveryLoadRequest;
- response: {};
- };
- [WalletApiOperation.RunBackupCycle]: {
- request: {};
- response: {};
- };
- [WalletApiOperation.AddBackupProvider]: {
- request: AddBackupProviderRequest;
- response: {};
- };
- [WalletApiOperation.GetBackupInfo]: {
- request: {};
- response: BackupInfo;
- };
- [WalletApiOperation.RunIntegrationTest]: {
- request: IntegrationTestArgs;
- response: {};
- };
- [WalletApiOperation.WithdrawTestBalance]: {
- request: WithdrawTestBalanceRequest;
- response: {};
- };
- [WalletApiOperation.TestPay]: {
- request: TestPayArgs;
- response: {};
- };
-};
-
-export type RequestType<
- Op extends WalletApiOperation & keyof WalletOperations
+ [WalletApiOperation.InitWallet]: InitWalletOp;
+ [WalletApiOperation.SetWalletRunConfig]: SetWalletRunConfigOp;
+ [WalletApiOperation.GetVersion]: GetVersionOp;
+ [WalletApiOperation.PreparePayForUri]: PreparePayForUriOp;
+ [WalletApiOperation.SharePayment]: SharePaymentOp;
+ [WalletApiOperation.PreparePayForTemplate]: PreparePayForTemplateOp;
+ [WalletApiOperation.GetContractTermsDetails]: GetContractTermsDetailsOp;
+ [WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp;
+ [WalletApiOperation.ConfirmPay]: ConfirmPayOp;
+ [WalletApiOperation.AbortTransaction]: AbortTransactionOp;
+ [WalletApiOperation.FailTransaction]: FailTransactionOp;
+ [WalletApiOperation.SuspendTransaction]: SuspendTransactionOp;
+ [WalletApiOperation.ResumeTransaction]: ResumeTransactionOp;
+ [WalletApiOperation.GetBalances]: GetBalancesOp;
+ [WalletApiOperation.ConvertDepositAmount]: ConvertDepositAmountOp;
+ [WalletApiOperation.GetMaxDepositAmount]: GetMaxDepositAmountOp;
+ [WalletApiOperation.ConvertPeerPushAmount]: ConvertPeerPushAmountOp;
+ [WalletApiOperation.GetMaxPeerPushAmount]: GetMaxPeerPushAmountOp;
+ [WalletApiOperation.ConvertWithdrawalAmount]: ConvertWithdrawalAmountOp;
+ [WalletApiOperation.GetPlanForOperation]: GetPlanForOperationOp;
+ [WalletApiOperation.GetBalanceDetail]: GetBalancesDetailOp;
+ [WalletApiOperation.GetTransactions]: GetTransactionsOp;
+ [WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp;
+ [WalletApiOperation.GetTransactionById]: GetTransactionByIdOp;
+ [WalletApiOperation.GetWithdrawalTransactionByUri]: GetWithdrawalTransactionByUriOp;
+ [WalletApiOperation.RetryPendingNow]: RetryPendingNowOp;
+ [WalletApiOperation.GetPendingOperations]: GetPendingTasksOp;
+ [WalletApiOperation.GetActiveTasks]: GetActiveTasksOp;
+ [WalletApiOperation.GetUserAttentionRequests]: GetUserAttentionRequests;
+ [WalletApiOperation.GetUserAttentionUnreadCount]: GetUserAttentionsUnreadCount;
+ [WalletApiOperation.MarkAttentionRequestAsRead]: MarkAttentionRequestAsRead;
+ [WalletApiOperation.DumpCoins]: DumpCoinsOp;
+ [WalletApiOperation.SetCoinSuspended]: SetCoinSuspendedOp;
+ [WalletApiOperation.ForceRefresh]: ForceRefreshOp;
+ [WalletApiOperation.DeleteTransaction]: DeleteTransactionOp;
+ [WalletApiOperation.RetryTransaction]: RetryTransactionOp;
+ [WalletApiOperation.StartRefundQueryForUri]: StartRefundQueryForUriOp;
+ [WalletApiOperation.StartRefundQuery]: StartRefundQueryOp;
+ [WalletApiOperation.GetWithdrawalDetailsForAmount]: GetWithdrawalDetailsForAmountOp;
+ [WalletApiOperation.GetWithdrawalDetailsForUri]: GetWithdrawalDetailsForUriOp;
+ [WalletApiOperation.AcceptBankIntegratedWithdrawal]: AcceptBankIntegratedWithdrawalOp;
+ [WalletApiOperation.AcceptManualWithdrawal]: AcceptManualWithdrawalOp;
+ [WalletApiOperation.ListExchanges]: ListExchangesOp;
+ [WalletApiOperation.ListExchangesForScopedCurrency]: ListExchangesForScopedCurrencyOp;
+ [WalletApiOperation.AddExchange]: AddExchangeOp;
+ [WalletApiOperation.ListKnownBankAccounts]: ListKnownBankAccountsOp;
+ [WalletApiOperation.AddKnownBankAccounts]: AddKnownBankAccountsOp;
+ [WalletApiOperation.ForgetKnownBankAccounts]: ForgetKnownBankAccountsOp;
+ [WalletApiOperation.SetExchangeTosAccepted]: SetExchangeTosAcceptedOp;
+ [WalletApiOperation.SetExchangeTosForgotten]: SetExchangeTosForgottenOp;
+ [WalletApiOperation.GetExchangeTos]: GetExchangeTosOp;
+ [WalletApiOperation.GetExchangeDetailedInfo]: GetExchangeDetailedInfoOp;
+ [WalletApiOperation.GetExchangeEntryByUrl]: GetExchangeEntryByUrlOp;
+ [WalletApiOperation.PrepareDeposit]: PrepareDepositOp;
+ [WalletApiOperation.GenerateDepositGroupTxId]: GenerateDepositGroupTxIdOp;
+ [WalletApiOperation.CreateDepositGroup]: CreateDepositGroupOp;
+ [WalletApiOperation.SetWalletDeviceId]: SetWalletDeviceIdOp;
+ [WalletApiOperation.ExportBackupRecovery]: ExportBackupRecoveryOp;
+ [WalletApiOperation.ImportBackupRecovery]: ImportBackupRecoveryOp;
+ [WalletApiOperation.RunBackupCycle]: RunBackupCycleOp;
+ [WalletApiOperation.ExportBackup]: ExportBackupOp;
+ [WalletApiOperation.AddBackupProvider]: AddBackupProviderOp;
+ [WalletApiOperation.RemoveBackupProvider]: RemoveBackupProviderOp;
+ [WalletApiOperation.GetBackupInfo]: GetBackupInfoOp;
+ [WalletApiOperation.RunIntegrationTest]: RunIntegrationTestOp;
+ [WalletApiOperation.RunIntegrationTestV2]: RunIntegrationTestV2Op;
+ [WalletApiOperation.TestCrypto]: TestCryptoOp;
+ [WalletApiOperation.WithdrawTestBalance]: WithdrawTestBalanceOp;
+ [WalletApiOperation.TestPay]: TestPayOp;
+ [WalletApiOperation.ExportDb]: ExportDbOp;
+ [WalletApiOperation.ImportDb]: ImportDbOp;
+ [WalletApiOperation.CheckPeerPushDebit]: CheckPeerPushDebitOp;
+ [WalletApiOperation.InitiatePeerPushDebit]: InitiatePeerPushDebitOp;
+ [WalletApiOperation.PreparePeerPushCredit]: PreparePeerPushCreditOp;
+ [WalletApiOperation.ConfirmPeerPushCredit]: ConfirmPeerPushCreditOp;
+ [WalletApiOperation.CheckPeerPullCredit]: CheckPeerPullCreditOp;
+ [WalletApiOperation.InitiatePeerPullCredit]: InitiatePeerPullCreditOp;
+ [WalletApiOperation.PreparePeerPullDebit]: PreparePeerPullDebitOp;
+ [WalletApiOperation.ConfirmPeerPullDebit]: ConfirmPeerPullDebitOp;
+ [WalletApiOperation.ClearDb]: ClearDbOp;
+ [WalletApiOperation.Recycle]: RecycleOp;
+ [WalletApiOperation.ApplyDevExperiment]: ApplyDevExperimentOp;
+ [WalletApiOperation.ValidateIban]: ValidateIbanOp;
+ [WalletApiOperation.TestingWaitTransactionsFinal]: TestingWaitTransactionsFinalOp;
+ [WalletApiOperation.TestingWaitRefreshesFinal]: TestingWaitRefreshesFinalOp;
+ [WalletApiOperation.TestingSetTimetravel]: TestingSetTimetravelOp;
+ [WalletApiOperation.TestingWaitTransactionState]: TestingWaitTransactionStateOp;
+ [WalletApiOperation.GetCurrencySpecification]: GetCurrencySpecificationOp;
+ [WalletApiOperation.CreateStoredBackup]: CreateStoredBackupsOp;
+ [WalletApiOperation.ListStoredBackups]: ListStoredBackupsOp;
+ [WalletApiOperation.DeleteStoredBackup]: DeleteStoredBackupOp;
+ [WalletApiOperation.RecoverStoredBackup]: RecoverStoredBackupsOp;
+ [WalletApiOperation.UpdateExchangeEntry]: UpdateExchangeEntryOp;
+ [WalletApiOperation.PrepareWithdrawExchange]: PrepareWithdrawExchangeOp;
+ [WalletApiOperation.TestingInfiniteTransactionLoop]: any;
+ [WalletApiOperation.DeleteExchange]: DeleteExchangeOp;
+ [WalletApiOperation.GetExchangeResources]: GetExchangeResourcesOp;
+ [WalletApiOperation.ListGlobalCurrencyAuditors]: ListGlobalCurrencyAuditorsOp;
+ [WalletApiOperation.ListGlobalCurrencyExchanges]: ListGlobalCurrencyExchangesOp;
+ [WalletApiOperation.AddGlobalCurrencyAuditor]: AddGlobalCurrencyAuditorOp;
+ [WalletApiOperation.RemoveGlobalCurrencyAuditor]: RemoveGlobalCurrencyAuditorOp;
+ [WalletApiOperation.AddGlobalCurrencyExchange]: AddGlobalCurrencyExchangeOp;
+ [WalletApiOperation.RemoveGlobalCurrencyExchange]: RemoveGlobalCurrencyExchangeOp;
+ [WalletApiOperation.ListAssociatedRefreshes]: ListAssociatedRefreshesOp;
+ [WalletApiOperation.TestingListTaskForTransaction]: TestingListTasksForTransactionOp;
+ [WalletApiOperation.TestingGetDenomStats]: TestingGetDenomStatsOp;
+ [WalletApiOperation.TestingPing]: TestingPingOp;
+};
+
+export type WalletCoreRequestType<
+ Op extends WalletApiOperation & keyof WalletOperations,
> = WalletOperations[Op] extends { request: infer T } ? T : never;
-export type ResponseType<
- Op extends WalletApiOperation & keyof WalletOperations
+export type WalletCoreResponseType<
+ Op extends WalletApiOperation & keyof WalletOperations,
> = WalletOperations[Op] extends { response: infer T } ? T : never;
+export type WalletCoreOpKeys = WalletApiOperation & keyof WalletOperations;
+
export interface WalletCoreApiClient {
- call<Op extends WalletApiOperation & keyof WalletOperations>(
+ call<Op extends keyof WalletOperations>(
operation: Op,
- payload: RequestType<Op>,
- ): Promise<ResponseType<Op>>;
+ payload: WalletCoreRequestType<Op>,
+ ): Promise<WalletCoreResponseType<Op>>;
}
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index 32e3945e8..d1234d39f 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -15,33 +15,147 @@
*/
/**
- * High-level wallet operations that should be indepentent from the underlying
+ * High-level wallet operations that should be independent from the underlying
* browser extension interface.
*/
/**
* Imports.
*/
+import { IDBFactory } from "@gnu-taler/idb-bridge";
import {
- BalancesResponse,
+ AbsoluteTime,
+ ActiveTask,
+ AmountJson,
+ AmountString,
+ Amounts,
+ AsyncCondition,
+ CancellationToken,
+ CoinDumpJson,
+ CoinStatus,
+ CoreApiResponse,
+ CreateStoredBackupResponse,
+ DeleteStoredBackupRequest,
+ DenominationInfo,
+ Duration,
+ ExchangesShortListResponse,
+ GetCurrencySpecificationResponse,
+ InitResponse,
+ KnownBankAccounts,
+ KnownBankAccountsInfo,
+ ListGlobalCurrencyAuditorsResponse,
+ ListGlobalCurrencyExchangesResponse,
+ Logger,
+ NotificationType,
+ ObservabilityContext,
+ ObservabilityEventType,
+ ObservableHttpClientLibrary,
+ OpenedPromise,
+ PartialWalletRunConfig,
+ PrepareWithdrawExchangeRequest,
+ PrepareWithdrawExchangeResponse,
+ RecoverStoredBackupRequest,
+ RetryLoopOpts,
+ StoredBackupList,
+ TalerError,
+ TalerErrorCode,
+ TalerProtocolTimestamp,
+ TalerUriAction,
+ TestingGetDenomStatsResponse,
+ TestingListTasksForTransactionsResponse,
+ TestingWaitTransactionRequest,
+ TimerAPI,
+ TimerGroup,
+ TransactionType,
+ ValidateIbanResponse,
+ WalletCoreVersion,
+ WalletNotification,
+ WalletRunConfig,
+ checkDbInvariant,
+ codecForAbortTransaction,
+ codecForAcceptBankIntegratedWithdrawalRequest,
+ codecForAcceptExchangeTosRequest,
+ codecForAcceptManualWithdrawalRequest,
+ codecForAcceptPeerPullPaymentRequest,
+ codecForAddExchangeRequest,
+ codecForAddGlobalCurrencyAuditorRequest,
+ codecForAddGlobalCurrencyExchangeRequest,
+ codecForAddKnownBankAccounts,
codecForAny,
+ codecForApplyDevExperiment,
+ codecForCheckPeerPullPaymentRequest,
+ codecForCheckPeerPushDebitRequest,
+ codecForConfirmPayRequest,
+ codecForConfirmPeerPushPaymentRequest,
+ codecForConvertAmountRequest,
+ codecForCreateDepositGroupRequest,
+ codecForDeleteExchangeRequest,
+ codecForDeleteStoredBackupRequest,
codecForDeleteTransactionRequest,
+ codecForFailTransactionRequest,
+ codecForForceRefreshRequest,
+ codecForForgetKnownBankAccounts,
+ codecForGetAmountRequest,
+ codecForGetBalanceDetailRequest,
+ codecForGetContractTermsDetails,
+ codecForGetCurrencyInfoRequest,
+ codecForGetExchangeEntryByUrlRequest,
+ codecForGetExchangeResourcesRequest,
+ codecForGetExchangeTosRequest,
+ codecForGetWithdrawalDetailsForAmountRequest,
+ codecForGetWithdrawalDetailsForUri,
+ codecForImportDbRequest,
+ codecForInitRequest,
+ codecForInitiatePeerPullPaymentRequest,
+ codecForInitiatePeerPushDebitRequest,
+ codecForIntegrationTestArgs,
+ codecForIntegrationTestV2Args,
+ codecForListExchangesForScopedCurrencyRequest,
+ codecForListKnownBankAccounts,
+ codecForPrepareDepositRequest,
+ codecForPreparePayRequest,
+ codecForPreparePayTemplateRequest,
+ codecForPreparePeerPullPaymentRequest,
+ codecForPreparePeerPushCreditRequest,
+ codecForPrepareRefundRequest,
+ codecForPrepareWithdrawExchangeRequest,
+ codecForRecoverStoredBackupRequest,
+ codecForRemoveGlobalCurrencyAuditorRequest,
+ codecForRemoveGlobalCurrencyExchangeRequest,
+ codecForResumeTransaction,
codecForRetryTransactionRequest,
+ codecForSetCoinSuspendedRequest,
codecForSetWalletDeviceIdRequest,
- codecForGetExchangeWithdrawalInfo,
- durationFromSpec,
- durationMin,
- getDurationRemaining,
- isTimestampExpired,
+ codecForSharePaymentRequest,
+ codecForStartRefundQueryRequest,
+ codecForSuspendTransaction,
+ codecForTestPayArgs,
+ codecForTestingGetDenomStatsRequest,
+ codecForTestingListTasksForTransactionRequest,
+ codecForTestingSetTimetravelRequest,
+ codecForTransactionByIdRequest,
+ codecForTransactionsRequest,
+ codecForUpdateExchangeEntryRequest,
+ codecForUserAttentionByIdRequest,
+ codecForUserAttentionsRequest,
+ codecForValidateIbanRequest,
+ codecForWithdrawTestBalance,
+ getErrorDetailFromException,
j2s,
- TalerErrorCode,
- Timestamp,
- timestampMin,
- WalletNotification,
- codecForWithdrawFakebankRequest,
- URL,
+ openPromise,
parsePaytoUri,
+ parseTalerUri,
+ performanceNow,
+ sampleWalletCoreTransactions,
+ setDangerousTimetravel,
+ validateIban,
} from "@gnu-taler/taler-util";
+import type { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
+import {
+ getUserAttentions,
+ getUserAttentionsUnreadCount,
+ markAttentionRequestAsRead,
+} from "./attention.js";
import {
addBackupProvider,
codecForAddBackupProviderRequest,
@@ -50,597 +164,391 @@ import {
getBackupInfo,
getBackupRecovery,
loadBackupRecovery,
- processBackupForProvider,
removeBackupProvider,
runBackupCycle,
-} from "./operations/backup/index.js";
-import { exportBackup } from "./operations/backup/export.js";
-import { getBalances } from "./operations/balance.js";
+ setWalletDeviceId,
+} from "./backup/index.js";
+import { getBalanceDetail, getBalances } from "./balance.js";
+import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import {
- createDepositGroup,
- processDepositGroup,
- trackDepositGroup,
-} from "./operations/deposits.js";
+ CryptoDispatcher,
+ CryptoWorkerFactory,
+} from "./crypto/workers/crypto-dispatcher.js";
import {
- makeErrorDetails,
- OperationFailedAndReportedError,
- OperationFailedError,
-} from "./errors.js";
+ CoinSourceType,
+ ConfigRecordKey,
+ DenominationRecord,
+ WalletDbReadOnlyTransaction,
+ WalletStoresV1,
+ clearDatabase,
+ exportDb,
+ importDb,
+ openStoredBackupsDatabase,
+ openTalerDatabase,
+ timestampAbsoluteFromDb,
+ timestampProtocolToDb,
+} from "./db.js";
+import {
+ checkDepositGroup,
+ createDepositGroup,
+ generateDepositGroupTxId,
+} from "./deposits.js";
+import { DevExperimentHttpLib, applyDevExperiment } from "./dev-experiments.js";
import {
+ ReadyExchangeSummary,
acceptExchangeTermsOfService,
- getExchangeDetails,
- getExchangeTrust,
- updateExchangeFromUrl,
-} from "./operations/exchanges.js";
+ addPresetExchangeEntry,
+ deleteExchange,
+ fetchFreshExchange,
+ forgetExchangeTermsOfService,
+ getExchangeDetailedInfo,
+ getExchangeResources,
+ getExchangeTos,
+ listExchanges,
+ lookupExchangeByUri,
+} from "./exchanges.js";
+import {
+ convertDepositAmount,
+ convertPeerPushAmount,
+ convertWithdrawalAmount,
+ getMaxDepositAmount,
+ getMaxPeerPushAmount,
+} from "./instructedAmountConversion.js";
+import {
+ ObservableDbAccess,
+ ObservableTaskScheduler,
+ observeTalerCrypto,
+} from "./observable-wrappers.js";
import {
confirmPay,
+ getContractTermsDetails,
+ preparePayForTemplate,
preparePayForUri,
- processDownloadProposal,
- processPurchasePay,
-} from "./operations/pay.js";
-import { getPendingOperations } from "./operations/pending.js";
-import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js";
+ sharePayment,
+ startQueryRefund,
+ startRefundQueryForUri,
+} from "./pay-merchant.js";
+import {
+ checkPeerPullPaymentInitiation,
+ initiatePeerPullPayment,
+} from "./pay-peer-pull-credit.js";
import {
- autoRefresh,
- createRefreshGroup,
- processRefreshGroup,
-} from "./operations/refresh.js";
+ confirmPeerPullDebit,
+ preparePeerPullDebit,
+} from "./pay-peer-pull-debit.js";
import {
- abortFailedPayWithRefund,
- applyRefund,
- processPurchaseQueryRefund,
-} from "./operations/refund.js";
+ confirmPeerPushCredit,
+ preparePeerPushCredit,
+} from "./pay-peer-push-credit.js";
import {
- createReserve,
- createTalerWithdrawReserve,
- getFundingPaytoUris,
- processReserve,
-} from "./operations/reserves.js";
+ checkPeerPushDebit,
+ initiatePeerPushDebit,
+} from "./pay-peer-push-debit.js";
+import { DbAccess } from "./query.js";
+import { forceRefresh } from "./refresh.js";
import {
- ExchangeOperations,
- InternalWalletState,
- NotificationListener,
- RecoupOperations,
-} from "./common.js";
+ TaskScheduler,
+ TaskSchedulerImpl,
+ convertTaskToTransactionId,
+ listTaskForTransactionId,
+} from "./shepherd.js";
import {
runIntegrationTest,
+ runIntegrationTest2,
testPay,
+ waitTransactionState,
+ waitUntilAllTransactionsFinal,
+ waitUntilRefreshesDone,
withdrawTestBalance,
-} from "./operations/testing.js";
-import { acceptTip, prepareTip, processTip } from "./operations/tip.js";
+} from "./testing.js";
import {
+ abortTransaction,
+ constructTransactionIdentifier,
deleteTransaction,
+ failTransaction,
+ getTransactionById,
getTransactions,
+ getWithdrawalTransactionByUri,
+ parseTransactionIdentifier,
+ resumeTransaction,
retryTransaction,
-} from "./operations/transactions.js";
-import {
- getExchangeWithdrawalInfo,
- getWithdrawalDetailsForUri,
- processWithdrawGroup,
-} from "./operations/withdraw.js";
+ suspendTransaction,
+} from "./transactions.js";
import {
- AuditorTrustRecord,
- CoinSourceType,
- ReserveRecordStatus,
- WalletStoresV1,
-} from "./db.js";
-import { NotificationType } from "@gnu-taler/taler-util";
-import {
- PendingTaskInfo,
- PendingOperationsResponse,
- PendingTaskType,
-} from "./pending-types.js";
-import { CoinDumpJson } from "@gnu-taler/taler-util";
-import { codecForTransactionsRequest } from "@gnu-taler/taler-util";
-import {
- AcceptManualWithdrawalResult,
- AcceptWithdrawalResponse,
- codecForAbortPayWithRefundRequest,
- codecForAcceptBankIntegratedWithdrawalRequest,
- codecForAcceptExchangeTosRequest,
- codecForAcceptManualWithdrawalRequet,
- codecForAcceptTipRequest,
- codecForAddExchangeRequest,
- codecForApplyRefundRequest,
- codecForConfirmPayRequest,
- codecForCreateDepositGroupRequest,
- codecForForceRefreshRequest,
- codecForGetExchangeTosRequest,
- codecForGetWithdrawalDetailsForAmountRequest,
- codecForGetWithdrawalDetailsForUri,
- codecForIntegrationTestArgs,
- codecForPreparePayRequest,
- codecForPrepareTipRequest,
- codecForSetCoinSuspendedRequest,
- codecForTestPayArgs,
- codecForTrackDepositGroupRequest,
- codecForWithdrawTestBalance,
- CoreApiResponse,
- ExchangeListItem,
- ExchangesListRespose,
- GetExchangeTosResult,
- ManualWithdrawalDetails,
- RefreshReason,
-} from "@gnu-taler/taler-util";
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { assertUnreachable } from "./util/assertUnreachable.js";
-import { Logger } from "@gnu-taler/taler-util";
-import { setWalletDeviceId } from "./operations/backup/state.js";
-import { WalletCoreApiClient } from "./wallet-api-types.js";
-import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
-import { CryptoApi, CryptoWorkerFactory } from "./crypto/workers/cryptoApi.js";
-import { TimerGroup } from "./util/timer.js";
+ WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
+ WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ WALLET_COREBANK_API_PROTOCOL_VERSION,
+ WALLET_CORE_API_PROTOCOL_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ WALLET_MERCHANT_PROTOCOL_VERSION,
+} from "./versions.js";
import {
- AsyncCondition,
- OpenedPromise,
- openPromise,
-} from "./util/promiseUtils.js";
-import { DbAccess } from "./util/query.js";
+ WalletApiOperation,
+ WalletCoreApiClient,
+ WalletCoreResponseType,
+} from "./wallet-api-types.js";
import {
- HttpRequestLibrary,
- readSuccessResponseJsonOrThrow,
-} from "./util/http.js";
-
-const builtinAuditors: AuditorTrustRecord[] = [
- {
- currency: "KUDOS",
- auditorPub: "BW9DC48PHQY4NH011SHHX36DZZ3Q22Y6X7FZ1VD1CMZ2PTFZ6PN0",
- auditorBaseUrl: "https://auditor.demo.taler.net/",
- uids: ["5P25XF8TVQP9AW6VYGY2KV47WT5Y3ZXFSJAA570GJPX5SVJXKBVG"],
- },
-];
+ acceptWithdrawalFromUri,
+ createManualWithdrawal,
+ getWithdrawalDetailsForAmount,
+ getWithdrawalDetailsForUri,
+} from "./withdraw.js";
const logger = new Logger("wallet.ts");
-async function getWithdrawalDetailsForAmount(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- amount: AmountJson,
-): Promise<ManualWithdrawalDetails> {
- const wi = await getExchangeWithdrawalInfo(ws, exchangeBaseUrl, amount);
- const paytoUris = wi.exchangeDetails.wireInfo.accounts.map(
- (x) => x.payto_uri,
- );
- if (!paytoUris) {
- throw Error("exchange is in invalid state");
- }
- return {
- amountRaw: Amounts.stringify(amount),
- amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
- paytoUris,
- tosAccepted: wi.termsOfServiceAccepted,
- };
-}
-
/**
- * Execute one operation based on the pending operation info record.
+ * Execution context for code that is run in the wallet.
+ *
+ * Typically the execution context is either for a wallet-core
+ * request handler or for a shepherded task.
*/
-async function processOnePendingOperation(
- ws: InternalWalletState,
- pending: PendingTaskInfo,
- forceNow = false,
-): Promise<void> {
- logger.trace(`running pending ${JSON.stringify(pending, undefined, 2)}`);
- switch (pending.type) {
- case PendingTaskType.ExchangeUpdate:
- await updateExchangeFromUrl(
- ws,
- pending.exchangeBaseUrl,
- undefined,
- forceNow,
- );
- break;
- case PendingTaskType.Refresh:
- await processRefreshGroup(ws, pending.refreshGroupId, forceNow);
- break;
- case PendingTaskType.Reserve:
- await processReserve(ws, pending.reservePub, forceNow);
- break;
- case PendingTaskType.Withdraw:
- await processWithdrawGroup(ws, pending.withdrawalGroupId, forceNow);
- break;
- case PendingTaskType.ProposalDownload:
- await processDownloadProposal(ws, pending.proposalId, forceNow);
- break;
- case PendingTaskType.TipPickup:
- await processTip(ws, pending.tipId, forceNow);
- break;
- case PendingTaskType.Pay:
- await processPurchasePay(ws, pending.proposalId, forceNow);
- break;
- case PendingTaskType.RefundQuery:
- await processPurchaseQueryRefund(ws, pending.proposalId, forceNow);
- break;
- case PendingTaskType.Recoup:
- await processRecoupGroup(ws, pending.recoupGroupId, forceNow);
- break;
- case PendingTaskType.ExchangeCheckRefresh:
- await autoRefresh(ws, pending.exchangeBaseUrl);
- break;
- case PendingTaskType.Deposit:
- await processDepositGroup(ws, pending.depositGroupId);
- break;
- case PendingTaskType.Backup:
- await processBackupForProvider(ws, pending.backupProviderBaseUrl);
- break;
- default:
- assertUnreachable(pending);
- }
+export interface WalletExecutionContext {
+ readonly ws: InternalWalletState;
+ readonly cryptoApi: TalerCryptoInterface;
+ readonly cancellationToken: CancellationToken;
+ readonly http: HttpRequestLibrary;
+ readonly db: DbAccess<typeof WalletStoresV1>;
+ readonly oc: ObservabilityContext;
+ readonly taskScheduler: TaskScheduler;
}
-/**
- * Process pending operations.
- */
-export async function runPending(
- ws: InternalWalletState,
- forceNow = false,
-): Promise<void> {
- const pendingOpsResponse = await getPendingOperations(ws);
- for (const p of pendingOpsResponse.pendingOperations) {
- if (!forceNow && !isTimestampExpired(p.timestampDue)) {
- continue;
- }
- try {
- await processOnePendingOperation(ws, p, forceNow);
- } catch (e) {
- if (e instanceof OperationFailedAndReportedError) {
- console.error(
- "Operation failed:",
- JSON.stringify(e.operationError, undefined, 2),
- );
- } else {
- console.error(e);
- }
- }
- }
-}
+export const EXCHANGE_COINS_LOCK = "exchange-coins-lock";
+export const EXCHANGE_RESERVES_LOCK = "exchange-reserves-lock";
-export interface RetryLoopOpts {
- /**
- * Stop when the number of retries is exceeded for any pending
- * operation.
- */
- maxRetries?: number;
+export type NotificationListener = (n: WalletNotification) => void;
- /**
- * Stop the retry loop when all lifeness-giving pending operations
- * are done.
- *
- * Defaults to false.
- */
- stopWhenDone?: boolean;
-}
+type CancelFn = () => void;
/**
- * Main retry loop of the wallet.
- *
- * Looks up pending operations from the wallet, runs them, repeat.
+ * Insert the hard-coded defaults for exchanges, coins and
+ * auditors into the database, unless these defaults have
+ * already been applied.
*/
-async function runTaskLoop(
- ws: InternalWalletState,
- opts: RetryLoopOpts = {},
-): Promise<void> {
- for (let iteration = 0; !ws.stopped; iteration++) {
- const pending = await getPendingOperations(ws);
- logger.trace(`pending operations: ${j2s(pending)}`);
- let numGivingLiveness = 0;
- let numDue = 0;
- let minDue: Timestamp = { t_ms: "never" };
- for (const p of pending.pendingOperations) {
- minDue = timestampMin(minDue, p.timestampDue);
- if (isTimestampExpired(p.timestampDue)) {
- numDue++;
- }
- if (p.givesLifeness) {
- numGivingLiveness++;
- }
-
- const maxRetries = opts.maxRetries;
-
- if (maxRetries && p.retryInfo && p.retryInfo.retryCounter > maxRetries) {
- logger.warn(
- `stopping, as ${maxRetries} retries are exceeded in an operation of type ${p.type}`,
- );
- return;
- }
- }
-
- if (opts.stopWhenDone && numGivingLiveness === 0 && iteration !== 0) {
- logger.warn(`stopping, as no pending operations have lifeness`);
+async function fillDefaults(wex: WalletExecutionContext): Promise<void> {
+ const notifications: WalletNotification[] = [];
+ await wex.db.runReadWriteTx(["config", "exchanges"], async (tx) => {
+ const appliedRec = await tx.config.get("currencyDefaultsApplied");
+ let alreadyApplied = appliedRec ? !!appliedRec.value : false;
+ if (alreadyApplied) {
+ logger.trace("defaults already applied");
return;
}
-
- // Make sure that we run tasks that don't give lifeness at least
- // one time.
- if (iteration !== 0 && numDue === 0) {
- // We've executed pending, due operations at least one.
- // Now we don't have any more operations available,
- // and need to wait.
-
- // Wait for at most 5 seconds to the next check.
- const dt = durationMin(
- durationFromSpec({
- seconds: 5,
- }),
- getDurationRemaining(minDue),
- );
- logger.trace(`waiting for at most ${dt.d_ms} ms`);
- const timeout = ws.timerGroup.resolveAfter(dt);
- ws.notify({
- type: NotificationType.WaitingForRetry,
- numGivingLiveness,
- numPending: pending.pendingOperations.length,
- });
- // Wait until either the timeout, or we are notified (via the latch)
- // that more work might be available.
- await Promise.race([timeout, ws.latch.wait()]);
- } else {
- logger.trace(
- `running ${pending.pendingOperations.length} pending operations`,
+ for (const exch of wex.ws.config.builtin.exchanges) {
+ const resp = await addPresetExchangeEntry(
+ tx,
+ exch.exchangeBaseUrl,
+ exch.currencyHint,
);
- for (const p of pending.pendingOperations) {
- if (!isTimestampExpired(p.timestampDue)) {
- continue;
- }
- try {
- await processOnePendingOperation(ws, p);
- } catch (e) {
- if (e instanceof OperationFailedAndReportedError) {
- logger.warn("operation processed resulted in reported error");
- } else {
- logger.error("Uncaught exception", e);
- ws.notify({
- type: NotificationType.InternalError,
- message: "uncaught exception",
- exception: e,
- });
- }
- }
- ws.notify({
- type: NotificationType.PendingOperationProcessed,
- });
+ if (resp.notification) {
+ notifications.push(resp.notification);
}
}
- }
- logger.trace("exiting wallet retry loop");
-}
-
-/**
- * Insert the hard-coded defaults for exchanges, coins and
- * auditors into the database, unless these defaults have
- * already been applied.
- */
-async function fillDefaults(ws: InternalWalletState): Promise<void> {
- await ws.db
- .mktx((x) => ({ config: x.config, auditorTrustStore: x.auditorTrust }))
- .runReadWrite(async (tx) => {
- let applied = false;
- await tx.config.iter().forEach((x) => {
- if (x.key == "currencyDefaultsApplied" && x.value == true) {
- applied = true;
- }
- });
- if (!applied) {
- for (const c of builtinAuditors) {
- await tx.auditorTrustStore.put(c);
- }
- }
+ await tx.config.put({
+ key: ConfigRecordKey.CurrencyDefaultsApplied,
+ value: true,
});
-}
-
-/**
- * Create a reserve for a manual withdrawal.
- *
- * Adds the corresponding exchange as a trusted exchange if it is neither
- * audited nor trusted already.
- */
-async function acceptManualWithdrawal(
- ws: InternalWalletState,
- exchangeBaseUrl: string,
- amount: AmountJson,
-): Promise<AcceptManualWithdrawalResult> {
- try {
- const resp = await createReserve(ws, {
- amount,
- exchange: exchangeBaseUrl,
- });
- const exchangePaytoUris = await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- reserves: x.reserves,
- }))
- .runReadWrite((tx) => getFundingPaytoUris(tx, resp.reservePub));
- return {
- reservePub: resp.reservePub,
- exchangePaytoUris,
- };
- } finally {
- ws.latch.trigger();
+ });
+ for (const notif of notifications) {
+ wex.ws.notify(notif);
}
}
-async function getExchangeTos(
- ws: InternalWalletState,
+export async function getDenomInfo(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
exchangeBaseUrl: string,
- acceptedFormat?: string[],
-): Promise<GetExchangeTosResult> {
- const { exchangeDetails } = await updateExchangeFromUrl(
- ws,
- exchangeBaseUrl,
- acceptedFormat,
- );
- const content = exchangeDetails.termsOfServiceText;
- const currentEtag = exchangeDetails.termsOfServiceLastEtag;
- const contentType = exchangeDetails.termsOfServiceContentType;
- if (
- content === undefined ||
- currentEtag === undefined ||
- contentType === undefined
- ) {
- throw Error("exchange is in invalid state");
+ denomPubHash: string,
+): Promise<DenominationInfo | undefined> {
+ const cacheKey = `${exchangeBaseUrl}:${denomPubHash}`;
+ const cached = wex.ws.denomInfoCache.get(cacheKey);
+ if (cached) {
+ return cached;
}
- return {
- acceptedEtag: exchangeDetails.termsOfServiceAcceptedEtag,
- currentEtag,
- content,
- contentType,
- };
+ const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]);
+ if (d) {
+ const denomInfo = DenominationRecord.toDenomInfo(d);
+ wex.ws.denomInfoCache.put(cacheKey, denomInfo);
+ return denomInfo;
+ }
+ return undefined;
}
-async function getExchanges(
- ws: InternalWalletState,
-): Promise<ExchangesListRespose> {
- const exchanges: ExchangeListItem[] = [];
- await ws.db
- .mktx((x) => ({
- exchanges: x.exchanges,
- exchangeDetails: x.exchangeDetails,
- }))
- .runReadOnly(async (tx) => {
- const exchangeRecords = await tx.exchanges.iter().toArray();
- for (const r of exchangeRecords) {
- const dp = r.detailsPointer;
- if (!dp) {
- continue;
- }
- const { currency } = dp;
- const exchangeDetails = await getExchangeDetails(tx, r.baseUrl);
- if (!exchangeDetails) {
- continue;
- }
- exchanges.push({
- exchangeBaseUrl: r.baseUrl,
- currency,
- paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri),
+/**
+ * List bank accounts known to the wallet from
+ * previous withdrawals.
+ */
+async function listKnownBankAccounts(
+ wex: WalletExecutionContext,
+ currency?: string,
+): Promise<KnownBankAccounts> {
+ const accounts: KnownBankAccountsInfo[] = [];
+ await wex.db.runReadOnlyTx(["bankAccounts"], async (tx) => {
+ const knownAccounts = await tx.bankAccounts.iter().toArray();
+ for (const r of knownAccounts) {
+ if (currency && currency !== r.currency) {
+ continue;
+ }
+ const payto = parsePaytoUri(r.uri);
+ if (payto) {
+ accounts.push({
+ uri: payto,
+ alias: r.alias,
+ kyc_completed: r.kycCompleted,
+ currency: r.currency,
});
}
- });
- return { exchanges };
+ }
+ });
+ return { accounts };
}
-async function acceptWithdrawal(
- ws: InternalWalletState,
- talerWithdrawUri: string,
- selectedExchange: string,
-): Promise<AcceptWithdrawalResponse> {
- try {
- return createTalerWithdrawReserve(ws, talerWithdrawUri, selectedExchange);
- } finally {
- ws.latch.trigger();
- }
+/**
+ */
+async function addKnownBankAccounts(
+ wex: WalletExecutionContext,
+ payto: string,
+ alias: string,
+ currency: string,
+): Promise<void> {
+ await wex.db.runReadWriteTx(["bankAccounts"], async (tx) => {
+ tx.bankAccounts.put({
+ uri: payto,
+ alias: alias,
+ currency: currency,
+ kycCompleted: false,
+ });
+ });
+ return;
}
/**
- * Inform the wallet that the status of a reserve has changed (e.g. due to a
- * confirmation from the bank.).
*/
-export async function handleNotifyReserve(
- ws: InternalWalletState,
+async function forgetKnownBankAccounts(
+ wex: WalletExecutionContext,
+ payto: string,
): Promise<void> {
- const reserves = await ws.db
- .mktx((x) => ({
- reserves: x.reserves,
- }))
- .runReadOnly(async (tx) => {
- return tx.reserves.iter().toArray();
- });
- for (const r of reserves) {
- if (r.reserveStatus === ReserveRecordStatus.WAIT_CONFIRM_BANK) {
- try {
- processReserve(ws, r.reservePub);
- } catch (e) {
- console.error(e);
- }
+ await wex.db.runReadWriteTx(["bankAccounts"], async (tx) => {
+ const account = await tx.bankAccounts.get(payto);
+ if (!account) {
+ throw Error(`account not found: ${payto}`);
}
- }
+ tx.bankAccounts.delete(account.uri);
+ });
+ return;
}
async function setCoinSuspended(
- ws: InternalWalletState,
+ wex: WalletExecutionContext,
coinPub: string,
suspended: boolean,
): Promise<void> {
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- }))
- .runReadWrite(async (tx) => {
- const c = await tx.coins.get(coinPub);
- if (!c) {
- logger.warn(`coin ${coinPub} not found, won't suspend`);
+ await wex.db.runReadWriteTx(["coins", "coinAvailability"], async (tx) => {
+ const c = await tx.coins.get(coinPub);
+ if (!c) {
+ logger.warn(`coin ${coinPub} not found, won't suspend`);
+ return;
+ }
+ const coinAvailability = await tx.coinAvailability.get([
+ c.exchangeBaseUrl,
+ c.denomPubHash,
+ c.maxAge,
+ ]);
+ checkDbInvariant(!!coinAvailability);
+ if (suspended) {
+ if (c.status !== CoinStatus.Fresh) {
return;
}
- c.suspended = suspended;
- await tx.coins.put(c);
- });
+ if (coinAvailability.freshCoinCount === 0) {
+ throw Error(
+ `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
+ );
+ }
+ coinAvailability.freshCoinCount--;
+ c.status = CoinStatus.FreshSuspended;
+ } else {
+ if (c.status == CoinStatus.Dormant) {
+ return;
+ }
+ coinAvailability.freshCoinCount++;
+ c.status = CoinStatus.Fresh;
+ }
+ await tx.coins.put(c);
+ await tx.coinAvailability.put(coinAvailability);
+ });
}
/**
* Dump the public information of coins we have in an easy-to-process format.
*/
-async function dumpCoins(ws: InternalWalletState): Promise<CoinDumpJson> {
+async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> {
const coinsJson: CoinDumpJson = { coins: [] };
- await ws.db
- .mktx((x) => ({
- coins: x.coins,
- denominations: x.denominations,
- withdrawalGroups: x.withdrawalGroups,
- }))
- .runReadOnly(async (tx) => {
- const coins = await tx.coins.iter().toArray();
- for (const c of coins) {
- const denom = await tx.denominations.get([
- c.exchangeBaseUrl,
- c.denomPubHash,
- ]);
- if (!denom) {
- console.error("no denom session found for coin");
- continue;
- }
- const cs = c.coinSource;
- let refreshParentCoinPub: string | undefined;
- if (cs.type == CoinSourceType.Refresh) {
- refreshParentCoinPub = cs.oldCoinPub;
- }
- let withdrawalReservePub: string | undefined;
- if (cs.type == CoinSourceType.Withdraw) {
- const ws = await tx.withdrawalGroups.get(cs.withdrawalGroupId);
- if (!ws) {
- console.error("no withdrawal session found for coin");
- continue;
- }
- withdrawalReservePub = ws.reservePub;
- }
- coinsJson.coins.push({
- coin_pub: c.coinPub,
- denom_pub: c.denomPub,
- denom_pub_hash: c.denomPubHash,
- denom_value: Amounts.stringify(denom.value),
- exchange_base_url: c.exchangeBaseUrl,
- refresh_parent_coin_pub: refreshParentCoinPub,
- remaining_value: Amounts.stringify(c.currentAmount),
- withdrawal_reserve_pub: withdrawalReservePub,
- coin_suspended: c.suspended,
- });
+ logger.info("dumping coins");
+ await wex.db.runReadOnlyTx(["coins", "denominations"], async (tx) => {
+ const coins = await tx.coins.iter().toArray();
+ for (const c of coins) {
+ const denom = await tx.denominations.get([
+ c.exchangeBaseUrl,
+ c.denomPubHash,
+ ]);
+ if (!denom) {
+ logger.warn("no denom found for coin");
+ continue;
}
- });
+ const cs = c.coinSource;
+ let refreshParentCoinPub: string | undefined;
+ if (cs.type == CoinSourceType.Refresh) {
+ refreshParentCoinPub = cs.oldCoinPub;
+ }
+ let withdrawalReservePub: string | undefined;
+ if (cs.type == CoinSourceType.Withdraw) {
+ withdrawalReservePub = cs.reservePub;
+ }
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ c.exchangeBaseUrl,
+ c.denomPubHash,
+ );
+ if (!denomInfo) {
+ logger.warn("no denomination found for coin");
+ continue;
+ }
+ coinsJson.coins.push({
+ coin_pub: c.coinPub,
+ denom_pub: denomInfo.denomPub,
+ denom_pub_hash: c.denomPubHash,
+ denom_value: denom.value,
+ exchange_base_url: c.exchangeBaseUrl,
+ refresh_parent_coin_pub: refreshParentCoinPub,
+ withdrawal_reserve_pub: withdrawalReservePub,
+ coin_status: c.status,
+ ageCommitmentProof: c.ageCommitmentProof,
+ spend_allocation: c.spendAllocation
+ ? {
+ amount: c.spendAllocation.amount,
+ id: c.spendAllocation.id,
+ }
+ : undefined,
+ });
+ }
+ });
return coinsJson;
}
/**
* Get an API client from an internal wallet state object.
*/
-export async function getClientFromWalletState(
+let id = 0;
+async function getClientFromWalletState(
ws: InternalWalletState,
): Promise<WalletCoreApiClient> {
- let id = 0;
const client: WalletCoreApiClient = {
async call(op, payload): Promise<any> {
- const res = await handleCoreApiRequest(ws, op, `${id++}`, payload);
+ id = (id + 1) % (Number.MAX_SAFE_INTEGER - 100);
+ const res = await handleCoreApiRequest(ws, op, String(id), payload);
switch (res.type) {
case "error":
- throw new OperationFailedError(res.error);
+ throw TalerError.fromUncheckedDetail(res.error);
case "response":
return res.result;
}
@@ -649,320 +557,1003 @@ export async function getClientFromWalletState(
return client;
}
+async function createStoredBackup(
+ wex: WalletExecutionContext,
+): Promise<CreateStoredBackupResponse> {
+ const backup = await exportDb(wex.ws.idb);
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ const name = `backup-${new Date().getTime()}`;
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.backupMeta.add({
+ name,
+ });
+ await tx.backupData.add(backup, name);
+ });
+ return {
+ name,
+ };
+}
+
+async function listStoredBackups(
+ wex: WalletExecutionContext,
+): Promise<StoredBackupList> {
+ const storedBackups: StoredBackupList = {
+ storedBackups: [],
+ };
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.backupMeta.iter().forEach((x) => {
+ storedBackups.storedBackups.push({
+ name: x.name,
+ });
+ });
+ });
+ return storedBackups;
+}
+
+async function deleteStoredBackup(
+ wex: WalletExecutionContext,
+ req: DeleteStoredBackupRequest,
+): Promise<void> {
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ await tx.backupData.delete(req.name);
+ await tx.backupMeta.delete(req.name);
+ });
+}
+
+async function recoverStoredBackup(
+ wex: WalletExecutionContext,
+ req: RecoverStoredBackupRequest,
+): Promise<void> {
+ logger.info(`Recovering stored backup ${req.name}`);
+ const { name } = req;
+ const backupsDb = await openStoredBackupsDatabase(wex.ws.idb);
+ const bd = await backupsDb.runAllStoresReadWriteTx({}, async (tx) => {
+ const backupMeta = tx.backupMeta.get(name);
+ if (!backupMeta) {
+ throw Error("backup not found");
+ }
+ const backupData = await tx.backupData.get(name);
+ if (!backupData) {
+ throw Error("no backup data (DB corrupt)");
+ }
+ return backupData;
+ });
+ logger.info(`backup found, now importing`);
+ await importDb(wex.db.idbHandle(), bd);
+ logger.info(`import done`);
+}
+
+async function handlePrepareWithdrawExchange(
+ wex: WalletExecutionContext,
+ req: PrepareWithdrawExchangeRequest,
+): Promise<PrepareWithdrawExchangeResponse> {
+ const parsedUri = parseTalerUri(req.talerUri);
+ if (parsedUri?.type !== TalerUriAction.WithdrawExchange) {
+ throw Error("expected a taler://withdraw-exchange URI");
+ }
+ const exchangeBaseUrl = parsedUri.exchangeBaseUrl;
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
+ if (parsedUri.exchangePub && exchange.masterPub != parsedUri.exchangePub) {
+ throw Error("mismatch of exchange master public key (URI vs actual)");
+ }
+ if (parsedUri.amount) {
+ const amt = Amounts.parseOrThrow(parsedUri.amount);
+ if (amt.currency !== exchange.currency) {
+ throw Error("mismatch of currency (URI vs exchange)");
+ }
+ }
+ return {
+ exchangeBaseUrl,
+ amount: parsedUri.amount,
+ };
+}
+
+/**
+ * Response returned from the pending operations API.
+ *
+ * @deprecated this is a placeholder for the response type of a deprecated wallet-core request.
+ */
+export interface PendingOperationsResponse {
+ /**
+ * List of pending operations.
+ */
+ pendingOperations: any[];
+}
+
/**
* Implementation of the "wallet-core" API.
*/
-async function dispatchRequestInternal(
- ws: InternalWalletState,
- operation: string,
+async function dispatchRequestInternal<Op extends WalletApiOperation>(
+ wex: WalletExecutionContext,
+ cts: CancellationToken.Source,
+ operation: WalletApiOperation,
payload: unknown,
-): Promise<Record<string, any>> {
- if (!ws.initCalled && operation !== "initWallet") {
+): Promise<WalletCoreResponseType<typeof operation>> {
+ if (!wex.ws.initCalled && operation !== WalletApiOperation.InitWallet) {
throw Error(
`wallet must be initialized before running operation ${operation}`,
);
}
+ // FIXME: Can we make this more type-safe by using the request/response type
+ // definitions we already have?
switch (operation) {
- case "initWallet": {
- ws.initCalled = true;
- await fillDefaults(ws);
+ case WalletApiOperation.CreateStoredBackup:
+ return createStoredBackup(wex);
+ case WalletApiOperation.DeleteStoredBackup: {
+ const req = codecForDeleteStoredBackupRequest().decode(payload);
+ await deleteStoredBackup(wex, req);
return {};
}
- case "withdrawTestkudos": {
- await withdrawTestBalance(
- ws,
- "TESTKUDOS:10",
- "https://bank.test.taler.net/",
- "https://exchange.test.taler.net/",
- );
+ case WalletApiOperation.ListStoredBackups:
+ return listStoredBackups(wex);
+ case WalletApiOperation.RecoverStoredBackup: {
+ const req = codecForRecoverStoredBackupRequest().decode(payload);
+ await recoverStoredBackup(wex, req);
return {};
}
- case "withdrawTestBalance": {
+ case WalletApiOperation.SetWalletRunConfig:
+ case WalletApiOperation.InitWallet: {
+ const req = codecForInitRequest().decode(payload);
+
+ logger.info(`init request: ${j2s(req)}`);
+
+ if (wex.ws.initCalled) {
+ logger.info("initializing wallet (repeat initialization)");
+ } else {
+ logger.info("initializing wallet (first initialization)");
+ }
+
+ // Write to the DB to make sure that we're failing early in
+ // case the DB is not writeable.
+ try {
+ await wex.db.runReadWriteTx(["config"], async (tx) => {
+ tx.config.put({
+ key: ConfigRecordKey.LastInitInfo,
+ value: timestampProtocolToDb(TalerProtocolTimestamp.now()),
+ });
+ });
+ } catch (e) {
+ logger.error("error writing to database during initialization");
+ throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
+ innerError: getErrorDetailFromException(e),
+ });
+ }
+
+ wex.ws.initWithConfig(applyRunConfigDefaults(req.config));
+
+ wex.ws.initCalled = true;
+ if (wex.ws.config.testing.skipDefaults) {
+ logger.trace("skipping defaults");
+ } else {
+ logger.trace("filling defaults");
+ await fillDefaults(wex);
+ }
+ const resp: InitResponse = {
+ versionInfo: getVersion(wex),
+ };
+ return resp;
+ }
+ case WalletApiOperation.WithdrawTestkudos: {
+ await withdrawTestBalance(wex, {
+ amount: "TESTKUDOS:10" as AmountString,
+ corebankApiBaseUrl: "https://bank.test.taler.net/",
+ exchangeBaseUrl: "https://exchange.test.taler.net/",
+ });
+ return {
+ versionInfo: getVersion(wex),
+ };
+ }
+ case WalletApiOperation.WithdrawTestBalance: {
const req = codecForWithdrawTestBalance().decode(payload);
- await withdrawTestBalance(
- ws,
- req.amount,
- req.bankBaseUrl,
- req.exchangeBaseUrl,
- );
+ await withdrawTestBalance(wex, req);
return {};
}
- case "runIntegrationTest": {
+ case WalletApiOperation.TestingListTaskForTransaction: {
+ const req =
+ codecForTestingListTasksForTransactionRequest().decode(payload);
+ return {
+ taskIdList: listTaskForTransactionId(req.transactionId),
+ } satisfies TestingListTasksForTransactionsResponse;
+ }
+ case WalletApiOperation.RunIntegrationTest: {
const req = codecForIntegrationTestArgs().decode(payload);
- await runIntegrationTest(ws, req);
+ await runIntegrationTest(wex, req);
return {};
}
- case "testPay": {
- const req = codecForTestPayArgs().decode(payload);
- await testPay(ws, req);
+ case WalletApiOperation.RunIntegrationTestV2: {
+ const req = codecForIntegrationTestV2Args().decode(payload);
+ await runIntegrationTest2(wex, req);
return {};
}
- case "getTransactions": {
+ case WalletApiOperation.ValidateIban: {
+ const req = codecForValidateIbanRequest().decode(payload);
+ const valRes = validateIban(req.iban);
+ const resp: ValidateIbanResponse = {
+ valid: valRes.type === "valid",
+ };
+ return resp;
+ }
+ case WalletApiOperation.TestPay: {
+ const req = codecForTestPayArgs().decode(payload);
+ return await testPay(wex, req);
+ }
+ case WalletApiOperation.GetTransactions: {
const req = codecForTransactionsRequest().decode(payload);
- return await getTransactions(ws, req);
+ return await getTransactions(wex, req);
+ }
+ case WalletApiOperation.GetTransactionById: {
+ const req = codecForTransactionByIdRequest().decode(payload);
+ return await getTransactionById(wex, req);
}
- case "addExchange": {
+ case WalletApiOperation.GetWithdrawalTransactionByUri: {
+ const req = codecForGetWithdrawalDetailsForUri().decode(payload);
+ return await getWithdrawalTransactionByUri(wex, req);
+ }
+ case WalletApiOperation.AddExchange: {
const req = codecForAddExchangeRequest().decode(payload);
- await updateExchangeFromUrl(
- ws,
- req.exchangeBaseUrl,
- undefined,
- req.forceUpdate,
- );
+ await fetchFreshExchange(wex, req.exchangeBaseUrl, {
+ expectedMasterPub: req.masterPub,
+ });
return {};
}
- case "listExchanges": {
- return await getExchanges(ws);
+ case WalletApiOperation.TestingPing: {
+ return {};
}
- case "getWithdrawalDetailsForUri": {
- const req = codecForGetWithdrawalDetailsForUri().decode(payload);
- return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri);
+ case WalletApiOperation.UpdateExchangeEntry: {
+ const req = codecForUpdateExchangeEntryRequest().decode(payload);
+ await fetchFreshExchange(wex, req.exchangeBaseUrl, {
+ forceUpdate: !!req.force,
+ });
+ return {};
}
- case "getExchangeWithdrawalInfo": {
- const req = codecForGetExchangeWithdrawalInfo().decode(payload);
- return await getExchangeWithdrawalInfo(
- ws,
- req.exchangeBaseUrl,
- req.amount,
- );
+ case WalletApiOperation.TestingGetDenomStats: {
+ const req = codecForTestingGetDenomStatsRequest().decode(payload);
+ const denomStats: TestingGetDenomStatsResponse = {
+ numKnown: 0,
+ numLost: 0,
+ numOffered: 0,
+ };
+ await wex.db.runReadOnlyTx(["denominations"], async (tx) => {
+ const denoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
+ req.exchangeBaseUrl,
+ );
+ for (const d of denoms) {
+ denomStats.numKnown++;
+ if (d.isOffered) {
+ denomStats.numOffered++;
+ }
+ if (d.isLost) {
+ denomStats.numLost++;
+ }
+ }
+ });
+ return denomStats;
}
- case "acceptManualWithdrawal": {
- const req = codecForAcceptManualWithdrawalRequet().decode(payload);
- const res = await acceptManualWithdrawal(
- ws,
- req.exchangeBaseUrl,
- Amounts.parseOrThrow(req.amount),
- );
+ case WalletApiOperation.ListExchanges: {
+ return await listExchanges(wex);
+ }
+ case WalletApiOperation.GetExchangeEntryByUrl: {
+ const req = codecForGetExchangeEntryByUrlRequest().decode(payload);
+ return lookupExchangeByUri(wex, req);
+ }
+ case WalletApiOperation.ListExchangesForScopedCurrency: {
+ const req =
+ codecForListExchangesForScopedCurrencyRequest().decode(payload);
+ const exchangesResp = await listExchanges(wex);
+ const result: ExchangesShortListResponse = {
+ exchanges: [],
+ };
+ // Right now we only filter on the currency, as wallet-core doesn't
+ // fully support scoped currencies yet.
+ for (const exch of exchangesResp.exchanges) {
+ if (exch.currency === req.scope.currency) {
+ result.exchanges.push({
+ exchangeBaseUrl: exch.exchangeBaseUrl,
+ });
+ }
+ }
+ return result;
+ }
+ case WalletApiOperation.GetExchangeDetailedInfo: {
+ const req = codecForAddExchangeRequest().decode(payload);
+ return await getExchangeDetailedInfo(wex, req.exchangeBaseUrl);
+ }
+ case WalletApiOperation.ListKnownBankAccounts: {
+ const req = codecForListKnownBankAccounts().decode(payload);
+ return await listKnownBankAccounts(wex, req.currency);
+ }
+ case WalletApiOperation.AddKnownBankAccounts: {
+ const req = codecForAddKnownBankAccounts().decode(payload);
+ await addKnownBankAccounts(wex, req.payto, req.alias, req.currency);
+ return {};
+ }
+ case WalletApiOperation.ForgetKnownBankAccounts: {
+ const req = codecForForgetKnownBankAccounts().decode(payload);
+ await forgetKnownBankAccounts(wex, req.payto);
+ return {};
+ }
+ case WalletApiOperation.GetWithdrawalDetailsForUri: {
+ const req = codecForGetWithdrawalDetailsForUri().decode(payload);
+ return await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri, {
+ notifyChangeFromPendingTimeoutMs: req.notifyChangeFromPendingTimeoutMs,
+ restrictAge: req.restrictAge,
+ });
+ }
+ case WalletApiOperation.AcceptManualWithdrawal: {
+ const req = codecForAcceptManualWithdrawalRequest().decode(payload);
+ const res = await createManualWithdrawal(wex, {
+ amount: Amounts.parseOrThrow(req.amount),
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ restrictAge: req.restrictAge,
+ });
return res;
}
- case "getWithdrawalDetailsForAmount": {
- const req = codecForGetWithdrawalDetailsForAmountRequest().decode(
- payload,
- );
- return await getWithdrawalDetailsForAmount(
- ws,
- req.exchangeBaseUrl,
- Amounts.parseOrThrow(req.amount),
- );
+ case WalletApiOperation.GetWithdrawalDetailsForAmount: {
+ const req =
+ codecForGetWithdrawalDetailsForAmountRequest().decode(payload);
+ const resp = await getWithdrawalDetailsForAmount(wex, cts, req);
+ return resp;
+ }
+ case WalletApiOperation.GetBalances: {
+ return await getBalances(wex);
+ }
+ case WalletApiOperation.GetBalanceDetail: {
+ const req = codecForGetBalanceDetailRequest().decode(payload);
+ return await getBalanceDetail(wex, req);
+ }
+ case WalletApiOperation.GetUserAttentionRequests: {
+ const req = codecForUserAttentionsRequest().decode(payload);
+ return await getUserAttentions(wex, req);
+ }
+ case WalletApiOperation.MarkAttentionRequestAsRead: {
+ const req = codecForUserAttentionByIdRequest().decode(payload);
+ return await markAttentionRequestAsRead(wex, req);
+ }
+ case WalletApiOperation.GetUserAttentionUnreadCount: {
+ const req = codecForUserAttentionsRequest().decode(payload);
+ return await getUserAttentionsUnreadCount(wex, req);
}
- case "getBalances": {
- return await getBalances(ws);
+ case WalletApiOperation.GetPendingOperations: {
+ // FIXME: Eventually remove the handler after deprecation period.
+ return {
+ pendingOperations: [],
+ } satisfies PendingOperationsResponse;
}
- case "getPendingOperations": {
- return await getPendingOperations(ws);
+ case WalletApiOperation.SetExchangeTosAccepted: {
+ const req = codecForAcceptExchangeTosRequest().decode(payload);
+ await acceptExchangeTermsOfService(wex, req.exchangeBaseUrl);
+ return {};
}
- case "setExchangeTosAccepted": {
+ case WalletApiOperation.SetExchangeTosForgotten: {
const req = codecForAcceptExchangeTosRequest().decode(payload);
- await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag);
+ await forgetExchangeTermsOfService(wex, req.exchangeBaseUrl);
return {};
}
- case "applyRefund": {
- const req = codecForApplyRefundRequest().decode(payload);
- return await applyRefund(ws, req.talerRefundUri);
+ case WalletApiOperation.AcceptBankIntegratedWithdrawal: {
+ const req =
+ codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
+ return await acceptWithdrawalFromUri(wex, {
+ selectedExchange: req.exchangeBaseUrl,
+ talerWithdrawUri: req.talerWithdrawUri,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ });
}
- case "acceptBankIntegratedWithdrawal": {
- const req = codecForAcceptBankIntegratedWithdrawalRequest().decode(
- payload,
- );
- return await acceptWithdrawal(
- ws,
- req.talerWithdrawUri,
+ case WalletApiOperation.GetExchangeTos: {
+ const req = codecForGetExchangeTosRequest().decode(payload);
+ return getExchangeTos(
+ wex,
req.exchangeBaseUrl,
+ req.acceptedFormat,
+ req.acceptLanguage,
);
}
- case "getExchangeTos": {
- const req = codecForGetExchangeTosRequest().decode(payload);
- return getExchangeTos(ws, req.exchangeBaseUrl, req.acceptedFormat);
+ case WalletApiOperation.GetContractTermsDetails: {
+ const req = codecForGetContractTermsDetails().decode(payload);
+ if (req.proposalId) {
+ // FIXME: deprecated path
+ return getContractTermsDetails(wex, req.proposalId);
+ }
+ if (req.transactionId) {
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (parsedTx?.tag === TransactionType.Payment) {
+ return getContractTermsDetails(wex, parsedTx.proposalId);
+ }
+ throw Error("transactionId is not a payment transaction");
+ }
+ throw Error("transactionId missing");
}
- case "retryPendingNow": {
- await runPending(ws, true);
+ case WalletApiOperation.RetryPendingNow: {
+ logger.error("retryPendingNow currently not implemented");
return {};
}
- // FIXME: Deprecate one of the aliases!
- case "preparePayForUri":
- case "preparePay": {
+ case WalletApiOperation.SharePayment: {
+ const req = codecForSharePaymentRequest().decode(payload);
+ return await sharePayment(wex, req.merchantBaseUrl, req.orderId);
+ }
+ case WalletApiOperation.PrepareWithdrawExchange: {
+ const req = codecForPrepareWithdrawExchangeRequest().decode(payload);
+ return handlePrepareWithdrawExchange(wex, req);
+ }
+ case WalletApiOperation.PreparePayForUri: {
const req = codecForPreparePayRequest().decode(payload);
- return await preparePayForUri(ws, req.talerPayUri);
+ return await preparePayForUri(wex, req.talerPayUri);
+ }
+ case WalletApiOperation.PreparePayForTemplate: {
+ const req = codecForPreparePayTemplateRequest().decode(payload);
+ return preparePayForTemplate(wex, req);
}
- case "confirmPay": {
+ case WalletApiOperation.ConfirmPay: {
const req = codecForConfirmPayRequest().decode(payload);
- return await confirmPay(ws, req.proposalId, req.sessionId);
+ let transactionId;
+ if (req.proposalId) {
+ // legacy client support
+ transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: req.proposalId,
+ });
+ } else if (req.transactionId) {
+ transactionId = req.transactionId;
+ } else {
+ throw Error("transactionId or (deprecated) proposalId required");
+ }
+ return await confirmPay(wex, transactionId, req.sessionId);
}
- case "abortFailedPayWithRefund": {
- const req = codecForAbortPayWithRefundRequest().decode(payload);
- await abortFailedPayWithRefund(ws, req.proposalId);
+ case WalletApiOperation.AbortTransaction: {
+ const req = codecForAbortTransaction().decode(payload);
+ await abortTransaction(wex, req.transactionId);
return {};
}
- case "dumpCoins": {
- return await dumpCoins(ws);
- }
- case "setCoinSuspended": {
- const req = codecForSetCoinSuspendedRequest().decode(payload);
- await setCoinSuspended(ws, req.coinPub, req.suspended);
+ case WalletApiOperation.SuspendTransaction: {
+ const req = codecForSuspendTransaction().decode(payload);
+ await suspendTransaction(wex, req.transactionId);
return {};
}
- case "forceRefresh": {
- const req = codecForForceRefreshRequest().decode(payload);
- const coinPubs = req.coinPubList.map((x) => ({ coinPub: x }));
- const refreshGroupId = await ws.db
- .mktx((x) => ({
- refreshGroups: x.refreshGroups,
- denominations: x.denominations,
- coins: x.coins,
- }))
- .runReadWrite(async (tx) => {
- return await createRefreshGroup(
- ws,
- tx,
- coinPubs,
- RefreshReason.Manual,
+ case WalletApiOperation.GetActiveTasks: {
+ const allTasksId = wex.taskScheduler.getActiveTasks();
+
+ const tasksInfo = await Promise.all(
+ allTasksId.map(async (id) => {
+ return await wex.ws.db.runReadOnlyTx(
+ ["operationRetries"],
+ async (tx) => {
+ return tx.operationRetries.get(id);
+ },
);
- });
- processRefreshGroup(ws, refreshGroupId.refreshGroupId, true).catch(
- (x) => {
- logger.error(x);
- },
+ }),
);
- return {
- refreshGroupId,
- };
+
+ const tasks = allTasksId.map((taskId, i): ActiveTask => {
+ const transaction = convertTaskToTransactionId(taskId);
+ const d = tasksInfo[i];
+
+ const firstTry = !d
+ ? undefined
+ : timestampAbsoluteFromDb(d.retryInfo.firstTry);
+ const nextTry = !d
+ ? undefined
+ : timestampAbsoluteFromDb(d.retryInfo.nextRetry);
+ const counter = d?.retryInfo.retryCounter;
+ const lastError = d?.lastError;
+
+ return {
+ id: taskId,
+ counter,
+ firstTry,
+ nextTry,
+ lastError,
+ transaction,
+ };
+ });
+ return { tasks };
}
- case "prepareTip": {
- const req = codecForPrepareTipRequest().decode(payload);
- return await prepareTip(ws, req.talerTipUri);
+ case WalletApiOperation.FailTransaction: {
+ const req = codecForFailTransactionRequest().decode(payload);
+ await failTransaction(wex, req.transactionId);
+ return {};
}
- case "acceptTip": {
- const req = codecForAcceptTipRequest().decode(payload);
- await acceptTip(ws, req.walletTipId);
+ case WalletApiOperation.ResumeTransaction: {
+ const req = codecForResumeTransaction().decode(payload);
+ await resumeTransaction(wex, req.transactionId);
return {};
}
- case "exportBackupPlain": {
- return exportBackup(ws);
+ case WalletApiOperation.DumpCoins: {
+ return await dumpCoins(wex);
}
- case "addBackupProvider": {
- const req = codecForAddBackupProviderRequest().decode(payload);
- await addBackupProvider(ws, req);
+ case WalletApiOperation.SetCoinSuspended: {
+ const req = codecForSetCoinSuspendedRequest().decode(payload);
+ await setCoinSuspended(wex, req.coinPub, req.suspended);
return {};
}
- case "runBackupCycle": {
+ case WalletApiOperation.TestingGetSampleTransactions:
+ return { transactions: sampleWalletCoreTransactions };
+ case WalletApiOperation.ForceRefresh: {
+ const req = codecForForceRefreshRequest().decode(payload);
+ return await forceRefresh(wex, req);
+ }
+ case WalletApiOperation.StartRefundQueryForUri: {
+ const req = codecForPrepareRefundRequest().decode(payload);
+ return await startRefundQueryForUri(wex, req.talerRefundUri);
+ }
+ case WalletApiOperation.StartRefundQuery: {
+ const req = codecForStartRefundQueryRequest().decode(payload);
+ const txIdParsed = parseTransactionIdentifier(req.transactionId);
+ if (!txIdParsed) {
+ throw Error("invalid transaction ID");
+ }
+ if (txIdParsed.tag !== TransactionType.Payment) {
+ throw Error("expected payment transaction ID");
+ }
+ await startQueryRefund(wex, txIdParsed.proposalId);
+ return {};
+ }
+ case WalletApiOperation.AddBackupProvider: {
+ const req = codecForAddBackupProviderRequest().decode(payload);
+ return await addBackupProvider(wex, req);
+ }
+ case WalletApiOperation.RunBackupCycle: {
const req = codecForRunBackupCycle().decode(payload);
- await runBackupCycle(ws, req);
+ await runBackupCycle(wex, req);
return {};
}
- case "removeBackupProvider": {
+ case WalletApiOperation.RemoveBackupProvider: {
const req = codecForRemoveBackupProvider().decode(payload);
- await removeBackupProvider(ws, req);
+ await removeBackupProvider(wex, req);
return {};
}
- case "exportBackupRecovery": {
- const resp = await getBackupRecovery(ws);
+ case WalletApiOperation.ExportBackupRecovery: {
+ const resp = await getBackupRecovery(wex);
return resp;
}
- case "importBackupRecovery": {
+ case WalletApiOperation.TestingWaitTransactionState: {
+ const req = payload as TestingWaitTransactionRequest;
+ await waitTransactionState(wex, req.transactionId, req.txState);
+ return {};
+ }
+ case WalletApiOperation.GetCurrencySpecification: {
+ // Ignore result, just validate in this mock implementation
+ const req = codecForGetCurrencyInfoRequest().decode(payload);
+ // Hard-coded mock for KUDOS and TESTKUDOS
+ if (req.scope.currency === "KUDOS") {
+ const kudosResp: GetCurrencySpecificationResponse = {
+ currencySpecification: {
+ name: "Kudos (Taler Demonstrator)",
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ alt_unit_names: {
+ "0": "ク",
+ },
+ },
+ };
+ return kudosResp;
+ } else if (req.scope.currency === "TESTKUDOS") {
+ const testkudosResp: GetCurrencySpecificationResponse = {
+ currencySpecification: {
+ name: "Test (Taler Unstable Demonstrator)",
+ num_fractional_input_digits: 0,
+ num_fractional_normal_digits: 0,
+ num_fractional_trailing_zero_digits: 0,
+ alt_unit_names: {
+ "0": "テ",
+ },
+ },
+ };
+ return testkudosResp;
+ }
+ const defaultResp: GetCurrencySpecificationResponse = {
+ currencySpecification: {
+ name: req.scope.currency,
+ num_fractional_input_digits: 2,
+ num_fractional_normal_digits: 2,
+ num_fractional_trailing_zero_digits: 2,
+ alt_unit_names: {
+ "0": req.scope.currency,
+ },
+ },
+ };
+ return defaultResp;
+ }
+ case WalletApiOperation.ImportBackupRecovery: {
const req = codecForAny().decode(payload);
- await loadBackupRecovery(ws, req);
+ await loadBackupRecovery(wex, req);
return {};
}
- case "getBackupInfo": {
- const resp = await getBackupInfo(ws);
+ // case WalletApiOperation.GetPlanForOperation: {
+ // const req = codecForGetPlanForOperationRequest().decode(payload);
+ // return await getPlanForOperation(ws, req);
+ // }
+ case WalletApiOperation.ConvertDepositAmount: {
+ const req = codecForConvertAmountRequest.decode(payload);
+ return await convertDepositAmount(wex, req);
+ }
+ case WalletApiOperation.GetMaxDepositAmount: {
+ const req = codecForGetAmountRequest.decode(payload);
+ return await getMaxDepositAmount(wex, req);
+ }
+ case WalletApiOperation.ConvertPeerPushAmount: {
+ const req = codecForConvertAmountRequest.decode(payload);
+ return await convertPeerPushAmount(wex, req);
+ }
+ case WalletApiOperation.GetMaxPeerPushAmount: {
+ const req = codecForGetAmountRequest.decode(payload);
+ return await getMaxPeerPushAmount(wex, req);
+ }
+ case WalletApiOperation.ConvertWithdrawalAmount: {
+ const req = codecForConvertAmountRequest.decode(payload);
+ return await convertWithdrawalAmount(wex, req);
+ }
+ case WalletApiOperation.GetBackupInfo: {
+ const resp = await getBackupInfo(wex);
return resp;
}
- case "createDepositGroup": {
- const req = codecForCreateDepositGroupRequest().decode(payload);
- return await createDepositGroup(ws, req);
+ case WalletApiOperation.PrepareDeposit: {
+ const req = codecForPrepareDepositRequest().decode(payload);
+ return await checkDepositGroup(wex, req);
}
- case "trackDepositGroup": {
- const req = codecForTrackDepositGroupRequest().decode(payload);
- return trackDepositGroup(ws, req);
+ case WalletApiOperation.GenerateDepositGroupTxId:
+ return {
+ transactionId: generateDepositGroupTxId(),
+ };
+ case WalletApiOperation.CreateDepositGroup: {
+ const req = codecForCreateDepositGroupRequest().decode(payload);
+ return await createDepositGroup(wex, req);
}
- case "deleteTransaction": {
+ case WalletApiOperation.DeleteTransaction: {
const req = codecForDeleteTransactionRequest().decode(payload);
- await deleteTransaction(ws, req.transactionId);
+ await deleteTransaction(wex, req.transactionId);
return {};
}
- case "retryTransaction": {
+ case WalletApiOperation.RetryTransaction: {
const req = codecForRetryTransactionRequest().decode(payload);
- await retryTransaction(ws, req.transactionId);
+ await retryTransaction(wex, req.transactionId);
return {};
}
- case "setWalletDeviceId": {
+ case WalletApiOperation.SetWalletDeviceId: {
const req = codecForSetWalletDeviceIdRequest().decode(payload);
- await setWalletDeviceId(ws, req.walletDeviceId);
+ await setWalletDeviceId(wex, req.walletDeviceId);
return {};
}
- case "listCurrencies": {
- return await ws.db
- .mktx((x) => ({
- auditorTrust: x.auditorTrust,
- exchangeTrust: x.exchangeTrust,
- }))
- .runReadOnly(async (tx) => {
- const trustedAuditors = await tx.auditorTrust.iter().toArray();
- const trustedExchanges = await tx.exchangeTrust.iter().toArray();
- return {
- trustedAuditors: trustedAuditors.map((x) => ({
- currency: x.currency,
- auditorBaseUrl: x.auditorBaseUrl,
- auditorPub: x.auditorPub,
- })),
- trustedExchanges: trustedExchanges.map((x) => ({
- currency: x.currency,
- exchangeBaseUrl: x.exchangeBaseUrl,
- exchangeMasterPub: x.exchangeMasterPub,
- })),
- };
+ case WalletApiOperation.TestCrypto: {
+ return await wex.cryptoApi.hashString({ str: "hello world" });
+ }
+ case WalletApiOperation.ClearDb: {
+ wex.ws.clearAllCaches();
+ await clearDatabase(wex.db.idbHandle());
+ return {};
+ }
+ case WalletApiOperation.Recycle: {
+ throw Error("not implemented");
+ return {};
+ }
+ case WalletApiOperation.ExportDb: {
+ const dbDump = await exportDb(wex.ws.idb);
+ return dbDump;
+ }
+ case WalletApiOperation.ListGlobalCurrencyExchanges: {
+ const resp: ListGlobalCurrencyExchangesResponse = {
+ exchanges: [],
+ };
+ await wex.db.runReadOnlyTx(["globalCurrencyExchanges"], async (tx) => {
+ const gceList = await tx.globalCurrencyExchanges.iter().toArray();
+ for (const gce of gceList) {
+ resp.exchanges.push({
+ currency: gce.currency,
+ exchangeBaseUrl: gce.exchangeBaseUrl,
+ exchangeMasterPub: gce.exchangeMasterPub,
+ });
+ }
+ });
+ return resp;
+ }
+ case WalletApiOperation.ListGlobalCurrencyAuditors: {
+ const resp: ListGlobalCurrencyAuditorsResponse = {
+ auditors: [],
+ };
+ await wex.db.runReadOnlyTx(["globalCurrencyAuditors"], async (tx) => {
+ const gcaList = await tx.globalCurrencyAuditors.iter().toArray();
+ for (const gca of gcaList) {
+ resp.auditors.push({
+ currency: gca.currency,
+ auditorBaseUrl: gca.auditorBaseUrl,
+ auditorPub: gca.auditorPub,
+ });
+ }
+ });
+ return resp;
+ }
+ case WalletApiOperation.AddGlobalCurrencyExchange: {
+ const req = codecForAddGlobalCurrencyExchangeRequest().decode(payload);
+ await wex.db.runReadWriteTx(["globalCurrencyExchanges"], async (tx) => {
+ const key = [req.currency, req.exchangeBaseUrl, req.exchangeMasterPub];
+ const existingRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (existingRec) {
+ return;
+ }
+ wex.ws.exchangeCache.clear();
+ await tx.globalCurrencyExchanges.add({
+ currency: req.currency,
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ exchangeMasterPub: req.exchangeMasterPub,
});
+ });
+ return {};
}
- case "withdrawFakebank": {
- const req = codecForWithdrawFakebankRequest().decode(payload);
- const amount = Amounts.parseOrThrow(req.amount);
- const details = await getWithdrawalDetailsForAmount(
- ws,
- req.exchange,
- amount,
- );
- const wres = await acceptManualWithdrawal(ws, req.exchange, amount);
- const paytoUri = details.paytoUris[0];
- const pt = parsePaytoUri(paytoUri);
- if (!pt) {
- throw Error("failed to parse payto URI");
- }
- const components = pt.targetPath.split("/");
- const creditorAcct = components[components.length - 1];
- logger.info(`making testbank transfer to '${creditorAcct}''`)
- const fbReq = await ws.http.postJson(
- new URL(`${creditorAcct}/admin/add-incoming`, req.bank).href,
- {
- amount: Amounts.stringify(amount),
- reserve_pub: wres.reservePub,
- debit_account: "payto://x-taler-bank/localhost/testdebtor",
- },
- );
- const fbResp = await readSuccessResponseJsonOrThrow(fbReq, codecForAny());
- logger.info(`started fakebank withdrawal: ${j2s(fbResp)}`);
+ case WalletApiOperation.RemoveGlobalCurrencyExchange: {
+ const req = codecForRemoveGlobalCurrencyExchangeRequest().decode(payload);
+ await wex.db.runReadWriteTx(["globalCurrencyExchanges"], async (tx) => {
+ const key = [req.currency, req.exchangeBaseUrl, req.exchangeMasterPub];
+ const existingRec =
+ await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (!existingRec) {
+ return;
+ }
+ wex.ws.exchangeCache.clear();
+ checkDbInvariant(!!existingRec.id);
+ await tx.globalCurrencyExchanges.delete(existingRec.id);
+ });
+ return {};
+ }
+ case WalletApiOperation.AddGlobalCurrencyAuditor: {
+ const req = codecForAddGlobalCurrencyAuditorRequest().decode(payload);
+ await wex.db.runReadWriteTx(["globalCurrencyAuditors"], async (tx) => {
+ const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
+ const existingRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (existingRec) {
+ return;
+ }
+ await tx.globalCurrencyAuditors.add({
+ currency: req.currency,
+ auditorBaseUrl: req.auditorBaseUrl,
+ auditorPub: req.auditorPub,
+ });
+ wex.ws.exchangeCache.clear();
+ });
+ return {};
+ }
+ case WalletApiOperation.RemoveGlobalCurrencyAuditor: {
+ const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload);
+ await wex.db.runReadWriteTx(["globalCurrencyAuditors"], async (tx) => {
+ const key = [req.currency, req.auditorBaseUrl, req.auditorPub];
+ const existingRec =
+ await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get(
+ key,
+ );
+ if (!existingRec) {
+ return;
+ }
+ checkDbInvariant(!!existingRec.id);
+ await tx.globalCurrencyAuditors.delete(existingRec.id);
+ wex.ws.exchangeCache.clear();
+ });
+ return {};
+ }
+ case WalletApiOperation.ImportDb: {
+ const req = codecForImportDbRequest().decode(payload);
+ await importDb(wex.db.idbHandle(), req.dump);
+ return [];
+ }
+ case WalletApiOperation.CheckPeerPushDebit: {
+ const req = codecForCheckPeerPushDebitRequest().decode(payload);
+ return await checkPeerPushDebit(wex, req);
+ }
+ case WalletApiOperation.InitiatePeerPushDebit: {
+ const req = codecForInitiatePeerPushDebitRequest().decode(payload);
+ return await initiatePeerPushDebit(wex, req);
+ }
+ case WalletApiOperation.PreparePeerPushCredit: {
+ const req = codecForPreparePeerPushCreditRequest().decode(payload);
+ return await preparePeerPushCredit(wex, req);
+ }
+ case WalletApiOperation.ConfirmPeerPushCredit: {
+ const req = codecForConfirmPeerPushPaymentRequest().decode(payload);
+ return await confirmPeerPushCredit(wex, req);
+ }
+ case WalletApiOperation.CheckPeerPullCredit: {
+ const req = codecForPreparePeerPullPaymentRequest().decode(payload);
+ return await checkPeerPullPaymentInitiation(wex, req);
+ }
+ case WalletApiOperation.InitiatePeerPullCredit: {
+ const req = codecForInitiatePeerPullPaymentRequest().decode(payload);
+ return await initiatePeerPullPayment(wex, req);
+ }
+ case WalletApiOperation.PreparePeerPullDebit: {
+ const req = codecForCheckPeerPullPaymentRequest().decode(payload);
+ return await preparePeerPullDebit(wex, req);
+ }
+ case WalletApiOperation.ConfirmPeerPullDebit: {
+ const req = codecForAcceptPeerPullPaymentRequest().decode(payload);
+ return await confirmPeerPullDebit(wex, req);
+ }
+ case WalletApiOperation.ApplyDevExperiment: {
+ const req = codecForApplyDevExperiment().decode(payload);
+ await applyDevExperiment(wex, req.devExperimentUri);
return {};
}
+ case WalletApiOperation.GetVersion: {
+ return getVersion(wex);
+ }
+ case WalletApiOperation.TestingWaitTransactionsFinal:
+ return await waitUntilAllTransactionsFinal(wex);
+ case WalletApiOperation.TestingWaitRefreshesFinal:
+ return await waitUntilRefreshesDone(wex);
+ case WalletApiOperation.TestingSetTimetravel: {
+ const req = codecForTestingSetTimetravelRequest().decode(payload);
+ setDangerousTimetravel(req.offsetMs);
+ wex.taskScheduler.reload();
+ return {};
+ }
+ case WalletApiOperation.DeleteExchange: {
+ const req = codecForDeleteExchangeRequest().decode(payload);
+ await deleteExchange(wex, req);
+ return {};
+ }
+ case WalletApiOperation.GetExchangeResources: {
+ const req = codecForGetExchangeResourcesRequest().decode(payload);
+ return await getExchangeResources(wex, req.exchangeBaseUrl);
+ }
+ case WalletApiOperation.TestingInfiniteTransactionLoop: {
+ const myDelayMs = (payload as any).delayMs ?? 5;
+ const shouldFetch = !!(payload as any).shouldFetch;
+ const doFetch = async () => {
+ while (1) {
+ const url =
+ "https://exchange.demo.taler.net/reserves/01PMMB9PJN0QBWAFBXV6R0KNJJMAKXCV4D6FDG0GJFDJQXGYP32G?timeout_ms=30000";
+ logger.info(`fetching ${url}`);
+ const res = await wex.http.fetch(url);
+ logger.info(`fetch result ${res.status}`);
+ }
+ };
+ if (shouldFetch) {
+ // In the background!
+ doFetch();
+ }
+ let loopCount = 0;
+ while (true) {
+ logger.info(`looping test write tx, iteration ${loopCount}`);
+ await wex.db.runReadWriteTx(["config"], async (tx) => {
+ await tx.config.put({
+ key: ConfigRecordKey.TestLoopTx,
+ value: loopCount,
+ });
+ });
+ if (myDelayMs != 0) {
+ await new Promise<void>((resolve, reject) => {
+ setTimeout(() => resolve(), myDelayMs);
+ });
+ }
+ loopCount = (loopCount + 1) % (Number.MAX_SAFE_INTEGER - 1);
+ }
+ }
+ // default:
+ // assertUnreachable(operation);
}
- throw OperationFailedError.fromCode(
+ throw TalerError.fromDetail(
TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,
- "unknown operation",
{
operation,
},
+ "unknown operation",
);
}
+export function getVersion(wex: WalletExecutionContext): WalletCoreVersion {
+ const result: WalletCoreVersion = {
+ implementationSemver: walletCoreBuildInfo.implementationSemver,
+ implementationGitHash: walletCoreBuildInfo.implementationGitHash,
+ hash: undefined,
+ version: WALLET_CORE_API_PROTOCOL_VERSION,
+ exchange: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ merchant: WALLET_MERCHANT_PROTOCOL_VERSION,
+ bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION,
+ bankIntegrationApiRange: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION,
+ bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ devMode: wex.ws.config.testing.devModeActive,
+ };
+ return result;
+}
+
+export function getObservedWalletExecutionContext(
+ ws: InternalWalletState,
+ cancellationToken: CancellationToken,
+ oc: ObservabilityContext,
+): WalletExecutionContext {
+ const wex: WalletExecutionContext = {
+ ws,
+ cancellationToken,
+ cryptoApi: observeTalerCrypto(ws.cryptoApi, oc),
+ db: new ObservableDbAccess(ws.db, oc),
+ http: new ObservableHttpClientLibrary(ws.http, oc),
+ taskScheduler: new ObservableTaskScheduler(ws.taskScheduler, oc),
+ oc,
+ };
+ return wex;
+}
+
+export function getNormalWalletExecutionContext(
+ ws: InternalWalletState,
+ cancellationToken: CancellationToken,
+ oc: ObservabilityContext,
+): WalletExecutionContext {
+ const wex: WalletExecutionContext = {
+ ws,
+ cancellationToken,
+ cryptoApi: ws.cryptoApi,
+ db: ws.db,
+ get http() {
+ if (ws.initCalled) {
+ return ws.http;
+ }
+ throw Error("wallet not initialized");
+ },
+ taskScheduler: ws.taskScheduler,
+ oc,
+ };
+ return wex;
+}
+
/**
* Handle a request to the wallet-core API.
*/
-export async function handleCoreApiRequest(
+async function handleCoreApiRequest(
ws: InternalWalletState,
operation: string,
id: string,
payload: unknown,
): Promise<CoreApiResponse> {
+ let wex: WalletExecutionContext;
+ let oc: ObservabilityContext;
+
+ const cts = CancellationToken.create();
+
+ if (ws.initCalled && ws.config.testing.emitObservabilityEvents) {
+ oc = {
+ observe(evt) {
+ ws.notify({
+ type: NotificationType.RequestObservabilityEvent,
+ operation,
+ requestId: id,
+ event: evt,
+ });
+ },
+ };
+
+ wex = getObservedWalletExecutionContext(ws, cts.token, oc);
+ } else {
+ oc = {
+ observe(evt) {},
+ };
+ wex = getNormalWalletExecutionContext(ws, cts.token, oc);
+ }
+
try {
- const result = await dispatchRequestInternal(ws, operation, payload);
+ const start = performanceNow();
+ await ws.ensureWalletDbOpen();
+ oc.observe({
+ type: ObservabilityEventType.RequestStart,
+ });
+ const result = await dispatchRequestInternal(
+ wex,
+ cts,
+ operation as any,
+ payload,
+ );
+ const end = performanceNow();
+ oc.observe({
+ type: ObservabilityEventType.RequestFinishSuccess,
+ durationMs: Number((end - start) / 1000n / 1000n),
+ });
return {
type: "response",
operation,
@@ -970,65 +1561,90 @@ export async function handleCoreApiRequest(
result,
};
} catch (e: any) {
- if (
- e instanceof OperationFailedError ||
- e instanceof OperationFailedAndReportedError
- ) {
- return {
- type: "error",
- operation,
- id,
- error: e.operationError,
- };
- } else {
- try {
- logger.error("Caught unexpected exception:");
- logger.error(e.stack);
- } catch (e) {}
- return {
- type: "error",
- operation,
- id,
- error: makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- `unexpected exception: ${e}`,
- {},
- ),
- };
- }
+ const err = getErrorDetailFromException(e);
+ logger.info(
+ `finished wallet core request ${operation} with error: ${j2s(err)}`,
+ );
+ oc.observe({
+ type: ObservabilityEventType.RequestFinishError,
+ });
+ return {
+ type: "error",
+ operation,
+ id,
+ error: err,
+ };
}
}
+export function applyRunConfigDefaults(
+ wcp?: PartialWalletRunConfig,
+): WalletRunConfig {
+ return {
+ builtin: {
+ exchanges: wcp?.builtin?.exchanges ?? [
+ {
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ currencyHint: "KUDOS",
+ },
+ ],
+ },
+ features: {
+ allowHttp: wcp?.features?.allowHttp ?? false,
+ },
+ testing: {
+ denomselAllowLate: wcp?.testing?.denomselAllowLate ?? false,
+ devModeActive: wcp?.testing?.devModeActive ?? false,
+ insecureTrustExchange: wcp?.testing?.insecureTrustExchange ?? false,
+ preventThrottling: wcp?.testing?.preventThrottling ?? false,
+ skipDefaults: wcp?.testing?.skipDefaults ?? false,
+ emitObservabilityEvents: wcp?.testing?.emitObservabilityEvents ?? false,
+ },
+ };
+}
+
+export type HttpFactory = (config: WalletRunConfig) => HttpRequestLibrary;
+
/**
* Public handle to a running wallet.
*/
export class Wallet {
private ws: InternalWalletState;
- private _client: WalletCoreApiClient;
+ private _client: WalletCoreApiClient | undefined;
private constructor(
- db: DbAccess<typeof WalletStoresV1>,
- http: HttpRequestLibrary,
+ idb: IDBFactory,
+ httpFactory: HttpFactory,
+ timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
) {
- this.ws = new InternalWalletStateImpl(db, http, cryptoWorkerFactory);
+ this.ws = new InternalWalletState(
+ idb,
+ httpFactory,
+ timer,
+ cryptoWorkerFactory,
+ );
}
- get client() {
+ get client(): WalletCoreApiClient {
+ if (!this._client) {
+ throw Error();
+ }
return this._client;
}
static async create(
- db: DbAccess<typeof WalletStoresV1>,
- http: HttpRequestLibrary,
+ idb: IDBFactory,
+ httpFactory: HttpFactory,
+ timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
): Promise<Wallet> {
- const w = new Wallet(db, http, cryptoWorkerFactory);
+ const w = new Wallet(idb, httpFactory, timer, cryptoWorkerFactory);
w._client = await getClientFromWalletState(w.ws);
return w;
}
- addNotificationListener(f: (n: WalletNotification) => void): void {
+ addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
return this.ws.addNotificationListener(f);
}
@@ -1036,56 +1652,94 @@ export class Wallet {
this.ws.stop();
}
- runPending(forceNow: boolean = false) {
- return runPending(this.ws, forceNow);
+ async runTaskLoop(opts?: RetryLoopOpts): Promise<void> {
+ await this.ws.ensureWalletDbOpen();
+ return this.ws.taskScheduler.run(opts);
}
- runTaskLoop(opts?: RetryLoopOpts) {
- return runTaskLoop(this.ws, opts);
- }
-
- handleCoreApiRequest(
+ async handleCoreApiRequest(
operation: string,
id: string,
payload: unknown,
): Promise<CoreApiResponse> {
+ await this.ws.ensureWalletDbOpen();
return handleCoreApiRequest(this.ws, operation, id, payload);
}
}
+export interface DevExperimentState {
+ blockRefreshes?: boolean;
+}
+
+export class Cache<T> {
+ private map: Map<string, [AbsoluteTime, T]> = new Map();
+
+ constructor(
+ private maxCapacity: number,
+ private cacheDuration: Duration,
+ ) {}
+
+ get(key: string): T | undefined {
+ const r = this.map.get(key);
+ if (!r) {
+ return undefined;
+ }
+
+ if (AbsoluteTime.isExpired(r[0])) {
+ this.map.delete(key);
+ return undefined;
+ }
+
+ return r[1];
+ }
+
+ clear(): void {
+ this.map.clear();
+ }
+
+ put(key: string, value: T): void {
+ if (this.map.size > this.maxCapacity) {
+ this.map.clear();
+ }
+ const expiry = AbsoluteTime.addDuration(
+ AbsoluteTime.now(),
+ this.cacheDuration,
+ );
+ this.map.set(key, [expiry, value]);
+ }
+}
+
/**
* Internal state of the wallet.
*
* This ties together all the operation implementations.
*/
-class InternalWalletStateImpl implements InternalWalletState {
- memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoGetPending: AsyncOpMemoSingle<PendingOperationsResponse> = new AsyncOpMemoSingle();
- memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new AsyncOpMemoSingle();
- memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- memoProcessDeposit: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
- cryptoApi: CryptoApi;
-
- timerGroup: TimerGroup = new TimerGroup();
- latch = new AsyncCondition();
+export class InternalWalletState {
+ cryptoApi: TalerCryptoInterface;
+ cryptoDispatcher: CryptoDispatcher;
+
+ readonly timerGroup: TimerGroup;
+ workAvailable = new AsyncCondition();
stopped = false;
- listeners: NotificationListener[] = [];
+ private listeners: NotificationListener[] = [];
- initCalled: boolean = false;
+ initCalled = false;
- exchangeOps: ExchangeOperations = {
- getExchangeDetails,
- getExchangeTrust,
- updateExchangeFromUrl,
- };
+ refreshCostCache: Cache<AmountJson> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
- recoupOps: RecoupOperations = {
- createRecoupGroup: createRecoupGroup,
- processRecoupGroup: processRecoupGroup,
- };
+ denomInfoCache: Cache<DenominationInfo> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
+
+ exchangeCache: Cache<ReadyExchangeSummary> = new Cache(
+ 1000,
+ Duration.fromSpec({ minutes: 1 }),
+ );
/**
* Promises that are waiting for a particular resource.
@@ -1097,20 +1751,88 @@ class InternalWalletStateImpl implements InternalWalletState {
*/
private resourceLocks: Set<string> = new Set();
+ taskScheduler: TaskScheduler = new TaskSchedulerImpl(this);
+
+ private _config: Readonly<WalletRunConfig> | undefined;
+
+ private _db: DbAccess<typeof WalletStoresV1> | undefined = undefined;
+
+ private _http: HttpRequestLibrary | undefined = undefined;
+
+ get db(): DbAccess<typeof WalletStoresV1> {
+ if (!this._db) {
+ throw Error("db not initialized");
+ }
+ return this._db;
+ }
+
+ devExperimentState: DevExperimentState = {};
+
+ clientCancellationMap: Map<string, CancellationToken.Source> = new Map();
+
+ clearAllCaches(): void {
+ this.exchangeCache.clear();
+ this.denomInfoCache.clear();
+ this.refreshCostCache.clear();
+ }
+
+ initWithConfig(newConfig: WalletRunConfig): void {
+ this._config = newConfig;
+
+ logger.info(`setting new config to ${j2s(newConfig)}`);
+
+ this._http = this.httpFactory(newConfig);
+
+ if (this.config.testing.devModeActive) {
+ this._http = new DevExperimentHttpLib(this.http);
+ }
+ }
+
+ get config(): WalletRunConfig {
+ if (!this._config) {
+ throw Error("config not initialized");
+ }
+ return this._config;
+ }
+
+ get http(): HttpRequestLibrary {
+ if (!this._http) {
+ throw Error("wallet not initialized");
+ }
+ return this._http;
+ }
+
constructor(
- // FIXME: Make this a getter and make
- // the actual value nullable.
- // Check if we are in a DB migration / garbage collection
- // and throw an error in that case.
- public db: DbAccess<typeof WalletStoresV1>,
- public http: HttpRequestLibrary,
+ public idb: IDBFactory,
+ private httpFactory: HttpFactory,
+ public timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
) {
- this.cryptoApi = new CryptoApi(cryptoWorkerFactory);
+ this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory);
+ this.cryptoApi = this.cryptoDispatcher.cryptoApi;
+ this.timerGroup = new TimerGroup(timer);
+ }
+
+ async ensureWalletDbOpen(): Promise<void> {
+ if (this._db) {
+ return;
+ }
+ const myVersionChange = async (): Promise<void> => {
+ logger.info("version change requested for Taler DB");
+ };
+ try {
+ const myDb = await openTalerDatabase(this.idb, myVersionChange);
+ this._db = myDb;
+ } catch (e) {
+ logger.error("error writing to database during initialization");
+ throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, {
+ innerError: getErrorDetailFromException(e),
+ });
+ }
}
notify(n: WalletNotification): void {
- logger.trace("Notification", n);
+ logger.trace(`Notification: ${j2s(n)}`);
for (const l of this.listeners) {
const nc = JSON.parse(JSON.stringify(n));
setTimeout(() => {
@@ -1119,32 +1841,34 @@ class InternalWalletStateImpl implements InternalWalletState {
}
}
- addNotificationListener(f: (n: WalletNotification) => void): void {
+ addNotificationListener(f: (n: WalletNotification) => void): CancelFn {
this.listeners.push(f);
+ return () => {
+ const idx = this.listeners.indexOf(f);
+ if (idx >= 0) {
+ this.listeners.splice(idx, 1);
+ }
+ };
}
/**
* Stop ongoing processing.
*/
stop(): void {
+ logger.trace("stopping (at internal wallet state)");
this.stopped = true;
this.timerGroup.stopCurrentAndFutureTimers();
- this.cryptoApi.stop();
- }
-
- async runUntilDone(
- req: {
- maxRetries?: number;
- } = {},
- ): Promise<void> {
- await runTaskLoop(this, { ...req, stopWhenDone: true });
+ this.cryptoDispatcher.stop();
}
/**
* Run an async function after acquiring a list of locks, identified
* by string tokens.
*/
- async runSequentialized<T>(tokens: string[], f: () => Promise<T>) {
+ async runSequentialized<T>(
+ tokens: string[],
+ f: () => Promise<T>,
+ ): Promise<T> {
// Make sure locks are always acquired in the same order
tokens = [...tokens].sort();
@@ -1169,7 +1893,7 @@ class InternalWalletStateImpl implements InternalWalletState {
} finally {
for (const token of tokens) {
this.resourceLocks.delete(token);
- let waiter = (this.resourceWaiters[token] ?? []).shift();
+ const waiter = (this.resourceWaiters[token] ?? []).shift();
if (waiter) {
waiter.resolve();
}
diff --git a/packages/taler-wallet-core/src/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts
new file mode 100644
index 000000000..2a081b481
--- /dev/null
+++ b/packages/taler-wallet-core/src/withdraw.test.ts
@@ -0,0 +1,364 @@
+/*
+ This file is part of GNU Taler
+ (C) 2020 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountString, Amounts, DenomKeyType } from "@gnu-taler/taler-util";
+import test from "ava";
+import {
+ DenominationRecord,
+ DenominationVerificationStatus,
+ timestampProtocolToDb,
+} from "./db.js";
+import { selectWithdrawalDenominations } from "./denomSelection.js";
+
+test("withdrawal selection bug repro", (t) => {
+ const amount = {
+ currency: "KUDOS",
+ fraction: 43000000,
+ value: 23,
+ };
+
+ const denoms: DenominationRecord[] = [
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "4F0P456CNNTTWK8BFJHGM3JTD6FVVNZY8EP077GYAHDJ5Y81S5RQ3SMS925NXMDVG9A88JAAP0E2GDZBC21PP5NHFFVWHAW3AVT8J3R",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ currency: "KUDOS",
+ value: "KUDOS:1000" as AmountString,
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002",
+ age_mask: 0,
+ },
+
+ denomPubHash:
+ "447WA23SCBATMABHA0793F92MYTBYVPYMMQHCPKMKVY5P7RZRFMQ6VRW0Y8HRA7177GTBT0TBT08R21DZD129AJ995H9G09XBFE55G8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "P99AW82W46MZ0AKW7Z58VQPXFNTJQM9DVTYPBDF6KVYF38PPVDAZTV7JQ8TY7HGEC7JJJAY4E7AY7J3W1WV10DAZZQHHKTAVTSRAC20",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:10" as AmountString,
+ currency: "KUDOS",
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "8S4VZGHE5WE0N5ZVCHYW9KZZR4YAKK15S46MV1HR1QB9AAMH3NWPW4DCR4NYGJK33Q8YNFY80SWNS6XKAP5DEVK933TM894FJ2VGE3G",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:5" as AmountString,
+ currency: "KUDOS",
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002",
+ age_mask: 0,
+ },
+
+ denomPubHash:
+ "8T51NEY81VMPQ180EQ5WR0YH7GMNNT90W55Q0514KZM18AZT71FHJGJHQXGK0WTA7ACN1X2SD0S53XPBQ1A9KH960R48VCVVM6E3TH8",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "E3AWGAG8VB42P3KXM8B04Z6M483SX59R3Y4T53C3NXCA2NPB6C7HVCMVX05DC6S58E9X40NGEBQNYXKYMYCF3ASY2C4WP1WCZ4ME610",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:1" as AmountString,
+ currency: "KUDOS",
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "0ES1RKV002XB4YP21SN0QB7RSDHGYT0XAE65JYN8AVJAA6H7JZFN7JADXT521DJS89XMGPZGR8GCXF1516Y0Q9QDV00E6NMFA6CF838",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 10000000,
+ value: 0,
+ }),
+ currency: "KUDOS",
+ },
+ {
+ denomPub: {
+ cipher: DenomKeyType.Rsa,
+ rsa_public_key:
+ "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002",
+ age_mask: 0,
+ },
+ denomPubHash:
+ "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ exchangeMasterPub: "",
+ fees: {
+ feeDeposit: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefresh: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeRefund: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ feeWithdraw: Amounts.stringify({
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ }),
+ },
+ isOffered: true,
+ isRevoked: false,
+ masterSig:
+ "58QEB6C6N7602E572E3JYANVVJ9BRW0V9E2ZFDW940N47YVQDK9SAFPWBN5YGT3G1742AFKQ0CYR4DM2VWV0Z0T1XMEKWN6X2EZ9M0R",
+ stampExpireDeposit: timestampProtocolToDb({
+ t_s: 1742909388,
+ }),
+ stampExpireLegal: timestampProtocolToDb({
+ t_s: 1900589388,
+ }),
+ stampExpireWithdraw: timestampProtocolToDb({
+ t_s: 1679837388,
+ }),
+ stampStart: timestampProtocolToDb({
+ t_s: 1585229388,
+ }),
+ verificationStatus: DenominationVerificationStatus.Unverified,
+ value: "KUDOS:2" as AmountString,
+ currency: "KUDOS",
+ },
+ ];
+
+ const res = selectWithdrawalDenominations(amount, denoms);
+
+ t.assert(Amounts.cmp(res.totalWithdrawCost, amount) <= 0);
+ t.pass();
+});
diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts
new file mode 100644
index 000000000..ecd654edf
--- /dev/null
+++ b/packages/taler-wallet-core/src/withdraw.ts
@@ -0,0 +1,3233 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019-2024 Taler Systems SA
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * @fileoverview Implementation of Taler withdrawals, both
+ * bank-integrated and manual.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ AbsoluteTime,
+ AcceptManualWithdrawalResult,
+ AcceptWithdrawalResponse,
+ AgeRestriction,
+ Amount,
+ AmountJson,
+ AmountLike,
+ AmountString,
+ Amounts,
+ AsyncFlag,
+ BankWithdrawDetails,
+ CancellationToken,
+ CoinStatus,
+ CurrencySpecification,
+ DenomKeyType,
+ DenomSelItem,
+ DenomSelectionState,
+ Duration,
+ ExchangeBatchWithdrawRequest,
+ ExchangeUpdateStatus,
+ ExchangeWireAccount,
+ ExchangeWithdrawBatchResponse,
+ ExchangeWithdrawRequest,
+ ExchangeWithdrawResponse,
+ ExchangeWithdrawalDetails,
+ ForcedDenomSel,
+ GetWithdrawalDetailsForAmountRequest,
+ HttpStatusCode,
+ LibtoolVersion,
+ Logger,
+ NotificationType,
+ ObservabilityEventType,
+ TalerBankIntegrationHttpClient,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerPreciseTimestamp,
+ Transaction,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ URL,
+ UnblindedSignature,
+ WalletNotification,
+ WithdrawUriInfoResponse,
+ WithdrawalDetailsForAmount,
+ WithdrawalExchangeAccountDetails,
+ WithdrawalType,
+ addPaytoQueryParams,
+ assertUnreachable,
+ canonicalizeBaseUrl,
+ checkDbInvariant,
+ codeForBankWithdrawalOperationPostResponse,
+ codecForCashinConversionResponse,
+ codecForConversionBankConfig,
+ codecForExchangeWithdrawBatchResponse,
+ codecForReserveStatus,
+ codecForWalletKycUuid,
+ codecForWithdrawOperationStatusResponse,
+ encodeCrock,
+ getErrorDetailFromException,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ parseWithdrawUri,
+} from "@gnu-taler/taler-util";
+import {
+ HttpRequestLibrary,
+ HttpResponse,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ throwUnexpectedRequestError,
+} from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ TransitionResult,
+ TransitionResultType,
+ constructTaskIdentifier,
+ makeCoinAvailable,
+ makeCoinsVisible,
+} from "./common.js";
+import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
+import {
+ CoinRecord,
+ CoinSourceType,
+ DenominationRecord,
+ DenominationVerificationStatus,
+ KycPendingInfo,
+ PlanchetRecord,
+ PlanchetStatus,
+ WalletDbReadOnlyTransaction,
+ WalletDbReadWriteTransaction,
+ WalletDbStoresArr,
+ WalletStoresV1,
+ WgInfo,
+ WithdrawalGroupRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampAbsoluteFromDb,
+ timestampPreciseFromDb,
+ timestampPreciseToDb,
+} from "./db.js";
+import {
+ selectForcedWithdrawalDenominations,
+ selectWithdrawalDenominations,
+} from "./denomSelection.js";
+import { isWithdrawableDenom } from "./denominations.js";
+import {
+ ReadyExchangeSummary,
+ fetchFreshExchange,
+ getExchangePaytoUri,
+ getExchangeWireDetailsInTx,
+ listExchanges,
+ markExchangeUsed,
+} from "./exchanges.js";
+import { DbAccess } from "./query.js";
+import {
+ TransitionInfo,
+ constructTransactionIdentifier,
+ isUnsuccessfulTransaction,
+ notifyTransition,
+} from "./transactions.js";
+import {
+ WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+} from "./versions.js";
+import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
+
+/**
+ * Logger for this file.
+ */
+const logger = new Logger("operations/withdraw.ts");
+
+/**
+ * Update the materialized withdrawal transaction based
+ * on the withdrawal group record.
+ */
+async function updateWithdrawalTransaction(
+ ctx: WithdrawTransactionContext,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "withdrawalGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ]
+ >,
+): Promise<void> {
+ const wgRecord = await tx.withdrawalGroups.get(ctx.withdrawalGroupId);
+ if (!wgRecord) {
+ await tx.transactions.delete(ctx.transactionId);
+ return;
+ }
+ const retryRecord = await tx.operationRetries.get(ctx.taskId);
+
+ let transactionItem: Transaction;
+
+ if (wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) {
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+ transactionItem = {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed
+ ? true
+ : false,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reservePub: wgRecord.reservePub,
+ bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: ctx.transactionId,
+ };
+ } else if (
+ wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankManual
+ ) {
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ wgRecord.exchangeBaseUrl,
+ );
+ const plainPaytoUris =
+ exchangeDetails?.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+
+ const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ wgRecord.reservePub,
+ wgRecord.instructedAmount,
+ );
+
+ const txState = computeWithdrawalTransactionStatus(wgRecord);
+
+ transactionItem = {
+ type: TransactionType.Withdrawal,
+ txState,
+ txActions: computeWithdrawalTransactionActions(wgRecord),
+ amountEffective: isUnsuccessfulTransaction(txState)
+ ? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
+ : Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
+ amountRaw: Amounts.stringify(wgRecord.instructedAmount),
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ reservePub: wgRecord.reservePub,
+ exchangePaytoUris,
+ exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
+ reserveIsReady:
+ wgRecord.status === WithdrawalGroupStatus.Done ||
+ wgRecord.status === WithdrawalGroupStatus.PendingReady,
+ },
+ kycUrl: wgRecord.kycUrl,
+ exchangeBaseUrl: wgRecord.exchangeBaseUrl,
+ timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
+ transactionId: ctx.transactionId,
+ };
+ } else {
+ // FIXME: If this is an orphaned withdrawal for a p2p transaction, we
+ // still might want to report the withdrawal.
+ return;
+ }
+
+ if (retryRecord?.lastError) {
+ transactionItem.error = retryRecord.lastError;
+ }
+
+ await tx.transactions.put({
+ currency: Amounts.currencyOf(wgRecord.instructedAmount),
+ transactionItem,
+ exchanges: [wgRecord.exchangeBaseUrl],
+ });
+
+ // FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted?
+}
+
+export class WithdrawTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public withdrawalGroupId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Withdraw,
+ withdrawalGroupId,
+ });
+ }
+
+ /**
+ * Transition a withdrawal transaction.
+ * Extra object stores may be accessed during the transition.
+ */
+ async transition<StoreNameArray extends WalletDbStoresArr = []>(
+ opts: { extraStores?: StoreNameArray; transactionLabel?: string },
+ f: (
+ rec: WithdrawalGroupRecord | undefined,
+ tx: WalletDbReadWriteTransaction<
+ [
+ "withdrawalGroups",
+ "transactions",
+ "operationRetries",
+ "exchanges",
+ "exchangeDetails",
+ ...StoreNameArray,
+ ]
+ >,
+ ) => Promise<TransitionResult<WithdrawalGroupRecord>>,
+ ): Promise<TransitionInfo | undefined> {
+ const baseStores = [
+ "withdrawalGroups" as const,
+ "transactions" as const,
+ "operationRetries" as const,
+ "exchanges" as const,
+ "exchangeDetails" as const,
+ ];
+ let stores = opts.extraStores
+ ? [...baseStores, ...opts.extraStores]
+ : baseStores;
+ const transitionInfo = await this.wex.db.runReadWriteTx(
+ stores,
+ async (tx) => {
+ const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId);
+ let oldTxState: TransactionState;
+ if (wgRec) {
+ oldTxState = computeWithdrawalTransactionStatus(wgRec);
+ } else {
+ oldTxState = {
+ major: TransactionMajorState.None,
+ };
+ }
+ const res = await f(wgRec, tx);
+ switch (res.type) {
+ case TransitionResultType.Transition: {
+ await tx.withdrawalGroups.put(res.rec);
+ await updateWithdrawalTransaction(this, tx);
+ const newTxState = computeWithdrawalTransactionStatus(res.rec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ case TransitionResultType.Delete:
+ await tx.withdrawalGroups.delete(this.withdrawalGroupId);
+ await updateWithdrawalTransaction(this, tx);
+ return {
+ oldTxState,
+ newTxState: {
+ major: TransactionMajorState.None,
+ },
+ };
+ default:
+ return undefined;
+ }
+ },
+ );
+ notifyTransition(this.wex, this.transactionId, transitionInfo);
+ return transitionInfo;
+ }
+
+ async deleteTransaction(): Promise<void> {
+ await this.transition(
+ {
+ extraStores: ["tombstones"],
+ transactionLabel: "delete-transaction-withdraw",
+ },
+ async (rec, tx) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ if (rec) {
+ await tx.tombstones.put({
+ id:
+ TombstoneTag.DeleteWithdrawalGroup + ":" + rec.withdrawalGroupId,
+ });
+ }
+ return TransitionResult.delete();
+ },
+ );
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "suspend-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.PendingReady:
+ newStatus = WithdrawalGroupStatus.SuspendedReady;
+ break;
+ case WithdrawalGroupStatus.AbortingBank:
+ newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
+ break;
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
+ break;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
+ break;
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
+ break;
+ case WithdrawalGroupStatus.PendingKyc:
+ newStatus = WithdrawalGroupStatus.SuspendedKyc;
+ break;
+ case WithdrawalGroupStatus.PendingAml:
+ newStatus = WithdrawalGroupStatus.SuspendedAml;
+ break;
+ default:
+ logger.warn(
+ `Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
+ );
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "abort-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ newStatus = WithdrawalGroupStatus.AbortingBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedAml:
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.PendingKyc:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ newStatus = WithdrawalGroupStatus.AbortedExchange;
+ break;
+ case WithdrawalGroupStatus.PendingReady:
+ newStatus = WithdrawalGroupStatus.SuspendedReady;
+ break;
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.AbortingBank:
+ // No transition needed, but not an error
+ return TransitionResult.stay();
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ // Not allowed
+ throw Error("abort not allowed in current state");
+ default:
+ assertUnreachable(wg.status);
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "resume-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedReady:
+ newStatus = WithdrawalGroupStatus.PendingReady;
+ break;
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ newStatus = WithdrawalGroupStatus.AbortingBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
+ break;
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
+ break;
+ case WithdrawalGroupStatus.SuspendedAml:
+ newStatus = WithdrawalGroupStatus.PendingAml;
+ break;
+ case WithdrawalGroupStatus.SuspendedKyc:
+ newStatus = WithdrawalGroupStatus.PendingKyc;
+ break;
+ default:
+ logger.warn(
+ `Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
+ );
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+
+ async failTransaction(): Promise<void> {
+ const { withdrawalGroupId } = this;
+ await this.transition(
+ {
+ transactionLabel: "fail-transaction-withdraw",
+ },
+ async (wg, _tx) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ let newStatus: WithdrawalGroupStatus | undefined = undefined;
+ switch (wg.status) {
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.AbortingBank:
+ newStatus = WithdrawalGroupStatus.FailedAbortingBank;
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ wg.status = newStatus;
+ return TransitionResult.transition(wg);
+ },
+ );
+ }
+}
+
+/**
+ * Compute the DD37 transaction state of a withdrawal transaction
+ * from the database's withdrawal group record.
+ */
+export function computeWithdrawalTransactionStatus(
+ wgRecord: WithdrawalGroupRecord,
+): TransactionState {
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case WithdrawalGroupStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankRegisterReserve,
+ };
+ case WithdrawalGroupStatus.PendingReady:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.WithdrawCoins,
+ };
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ExchangeWaitReserve,
+ };
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ };
+ case WithdrawalGroupStatus.AbortingBank:
+ return {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.Bank,
+ };
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ return {
+ major: TransactionMajorState.SuspendedAborting,
+ minor: TransactionMinorState.Bank,
+ };
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.ExchangeWaitReserve,
+ };
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.BankRegisterReserve,
+ };
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.BankConfirmTransfer,
+ };
+ case WithdrawalGroupStatus.SuspendedReady:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.WithdrawCoins,
+ };
+ case WithdrawalGroupStatus.PendingAml:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AmlRequired,
+ };
+ case WithdrawalGroupStatus.PendingKyc:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case WithdrawalGroupStatus.SuspendedAml:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.AmlRequired,
+ };
+ case WithdrawalGroupStatus.SuspendedKyc:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.AbortingBank,
+ };
+ case WithdrawalGroupStatus.AbortedExchange:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Exchange,
+ };
+ case WithdrawalGroupStatus.AbortedBank:
+ return {
+ major: TransactionMajorState.Aborted,
+ minor: TransactionMinorState.Bank,
+ };
+ }
+}
+
+/**
+ * Compute DD37 transaction actions for a withdrawal transaction
+ * based on the database's withdrawal group record.
+ */
+export function computeWithdrawalTransactionActions(
+ wgRecord: WithdrawalGroupRecord,
+): TransactionAction[] {
+ switch (wgRecord.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.Done:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingReady:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return [TransactionAction.Suspend, TransactionAction.Abort];
+ case WithdrawalGroupStatus.AbortingBank:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedReady:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingAml:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.PendingKyc:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedAml:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.SuspendedKyc:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.AbortedExchange:
+ return [TransactionAction.Delete];
+ case WithdrawalGroupStatus.AbortedBank:
+ return [TransactionAction.Delete];
+ }
+}
+
+/**
+ * Get information about a withdrawal from
+ * a taler://withdraw URI by asking the bank.
+ *
+ * FIXME: Move into bank client.
+ */
+export async function getBankWithdrawalInfo(
+ http: HttpRequestLibrary,
+ talerWithdrawUri: string,
+): Promise<BankWithdrawDetails> {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse URL ${talerWithdrawUri}`);
+ }
+
+ const bankApi = new TalerBankIntegrationHttpClient(
+ uriResult.bankIntegrationApiBaseUrl,
+ http,
+ );
+
+ const { body: config } = await bankApi.getConfig();
+
+ if (!bankApi.isCompatible(config.version)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
+ {
+ bankProtocolVersion: config.version,
+ walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
+ },
+ "bank integration protocol version not compatible with wallet",
+ );
+ }
+
+ const resp = await bankApi.getWithdrawalOperationById(
+ uriResult.withdrawalOperationId,
+ );
+
+ if (resp.type === "fail") {
+ throw TalerError.fromUncheckedDetail(resp.detail);
+ }
+ const { body: status } = resp;
+
+ logger.info(`bank withdrawal operation status: ${j2s(status)}`);
+
+ return {
+ operationId: uriResult.withdrawalOperationId,
+ apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
+ amount: Amounts.parseOrThrow(status.amount),
+ confirmTransferUrl: status.confirm_transfer_url,
+ senderWire: status.sender_wire,
+ suggestedExchange: status.suggested_exchange,
+ wireTypes: status.wire_types,
+ status: status.status,
+ };
+}
+
+/**
+ * Return denominations that can potentially used for a withdrawal.
+ */
+async function getCandidateWithdrawalDenoms(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ return await wex.db.runReadOnlyTx(["denominations"], async (tx) => {
+ return getCandidateWithdrawalDenomsTx(wex, tx, exchangeBaseUrl, currency);
+ });
+}
+
+export async function getCandidateWithdrawalDenomsTx(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadOnlyTransaction<["denominations"]>,
+ exchangeBaseUrl: string,
+ currency: string,
+): Promise<DenominationRecord[]> {
+ // FIXME(https://bugs.taler.net/n/8446): Use denom groups instead of querying all denominations!
+ const allDenoms =
+ await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
+ return allDenoms
+ .filter((d) => d.currency === currency)
+ .filter((d) =>
+ isWithdrawableDenom(d, wex.ws.config.testing.denomselAllowLate),
+ );
+}
+
+/**
+ * Generate a planchet for a coin index in a withdrawal group.
+ * Does not actually withdraw the coin yet.
+ *
+ * Split up so that we can parallelize the crypto, but serialize
+ * the exchange requests per reserve.
+ */
+async function processPlanchetGenerate(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+ coinIdx: number,
+): Promise<void> {
+ let planchet = await wex.db.runReadOnlyTx(["planchets"], async (tx) => {
+ return tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ });
+ if (planchet) {
+ return;
+ }
+ let ci = 0;
+ let isSkipped = false;
+ let maybeDenomPubHash: string | undefined;
+ for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
+ const d = withdrawalGroup.denomsSel.selectedDenoms[di];
+ if (coinIdx >= ci && coinIdx < ci + d.count) {
+ maybeDenomPubHash = d.denomPubHash;
+ if (coinIdx >= ci + d.count - (d.skip ?? 0)) {
+ isSkipped = true;
+ }
+ break;
+ }
+ ci += d.count;
+ }
+ if (isSkipped) {
+ return;
+ }
+ if (!maybeDenomPubHash) {
+ throw Error("invariant violated");
+ }
+ const denomPubHash = maybeDenomPubHash;
+
+ const denom = await wex.db.runReadOnlyTx(["denominations"], async (tx) => {
+ return getDenomInfo(wex, tx, withdrawalGroup.exchangeBaseUrl, denomPubHash);
+ });
+ checkDbInvariant(!!denom);
+ const r = await wex.cryptoApi.createPlanchet({
+ denomPub: denom.denomPub,
+ feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
+ reservePriv: withdrawalGroup.reservePriv,
+ reservePub: withdrawalGroup.reservePub,
+ value: Amounts.parseOrThrow(denom.value),
+ coinIndex: coinIdx,
+ secretSeed: withdrawalGroup.secretSeed,
+ restrictAge: withdrawalGroup.restrictAge,
+ });
+ const newPlanchet: PlanchetRecord = {
+ blindingKey: r.blindingKey,
+ coinEv: r.coinEv,
+ coinEvHash: r.coinEvHash,
+ coinIdx,
+ coinPriv: r.coinPriv,
+ coinPub: r.coinPub,
+ denomPubHash: r.denomPubHash,
+ planchetStatus: PlanchetStatus.Pending,
+ withdrawSig: r.withdrawSig,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ ageCommitmentProof: r.ageCommitmentProof,
+ lastError: undefined,
+ };
+ await wex.db.runReadWriteTx(["planchets"], async (tx) => {
+ const p = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (p) {
+ planchet = p;
+ return;
+ }
+ await tx.planchets.put(newPlanchet);
+ planchet = newPlanchet;
+ });
+}
+
+interface WithdrawalRequestBatchArgs {
+ coinStartIndex: number;
+
+ batchSize: number;
+}
+
+interface WithdrawalBatchResult {
+ coinIdxs: number[];
+ batchResp: ExchangeWithdrawBatchResponse;
+}
+
+// FIXME: Move to exchange API types
+enum ExchangeAmlStatus {
+ Normal = 0,
+ Pending = 1,
+ Frozen = 2,
+}
+
+async function handleKycRequired(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+ resp: HttpResponse,
+ startIdx: number,
+ requestCoinIdxs: number[],
+): Promise<void> {
+ logger.info("withdrawal requires KYC");
+ const respJson = await resp.json();
+ const uuidResp = codecForWalletKycUuid().decode(respJson);
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
+ const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
+ const userType = "individual";
+ const kycInfo: KycPendingInfo = {
+ paytoHash: uuidResp.h_payto,
+ requirementRow: uuidResp.requirement_row,
+ };
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ let kycUrl: string;
+ let amlStatus: ExchangeAmlStatus | undefined;
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return;
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ kycUrl = kycStatus.kyc_url;
+ } else if (
+ kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
+ ) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`aml status: ${j2s(kycStatus)}`);
+ amlStatus = kycStatus.aml_status;
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+
+ await ctx.transition(
+ {
+ extraStores: ["planchets"],
+ },
+ async (wg2, tx) => {
+ if (!wg2) {
+ return TransitionResult.stay();
+ }
+ for (let i = startIdx; i < requestCoinIdxs.length; i++) {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ requestCoinIdxs[i],
+ ]);
+ if (!planchet) {
+ continue;
+ }
+ planchet.planchetStatus = PlanchetStatus.KycRequired;
+ await tx.planchets.put(planchet);
+ }
+ if (wg2.status !== WithdrawalGroupStatus.PendingReady) {
+ return TransitionResult.stay();
+ }
+ wg2.kycPending = {
+ paytoHash: uuidResp.h_payto,
+ requirementRow: uuidResp.requirement_row,
+ };
+ wg2.kycUrl = kycUrl;
+ wg2.status =
+ amlStatus === ExchangeAmlStatus.Normal || amlStatus === undefined
+ ? WithdrawalGroupStatus.PendingKyc
+ : amlStatus === ExchangeAmlStatus.Pending
+ ? WithdrawalGroupStatus.PendingAml
+ : amlStatus === ExchangeAmlStatus.Frozen
+ ? WithdrawalGroupStatus.SuspendedAml
+ : assertUnreachable(amlStatus);
+ return TransitionResult.transition(wg2);
+ },
+ );
+}
+
+/**
+ * Send the withdrawal request for a generated planchet to the exchange.
+ *
+ * The verification of the response is done asynchronously to enable parallelism.
+ */
+async function processPlanchetExchangeBatchRequest(
+ wex: WalletExecutionContext,
+ wgContext: WithdrawalGroupStatusInfo,
+ args: WithdrawalRequestBatchArgs,
+): Promise<WithdrawalBatchResult> {
+ const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
+ logger.info(
+ `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
+ );
+
+ const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
+ // Indices of coins that are included in the batch request
+ const requestCoinIdxs: number[] = [];
+
+ await wex.db.runReadOnlyTx(["planchets", "denominations"], async (tx) => {
+ for (
+ let coinIdx = args.coinStartIndex;
+ coinIdx < args.coinStartIndex + args.batchSize &&
+ coinIdx < wgContext.numPlanchets;
+ coinIdx++
+ ) {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ continue;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ logger.warn("processPlanchet: planchet already withdrawn");
+ continue;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) {
+ continue;
+ }
+ const denom = await getDenomInfo(
+ wex,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ planchet.denomPubHash,
+ );
+
+ if (!denom) {
+ logger.error("db inconsistent: denom for planchet not found");
+ continue;
+ }
+
+ const planchetReq: ExchangeWithdrawRequest = {
+ denom_pub_hash: planchet.denomPubHash,
+ reserve_sig: planchet.withdrawSig,
+ coin_ev: planchet.coinEv,
+ };
+ batchReq.planchets.push(planchetReq);
+ requestCoinIdxs.push(coinIdx);
+ }
+ });
+
+ if (batchReq.planchets.length == 0) {
+ logger.warn("empty withdrawal batch");
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+
+ async function storeCoinError(
+ errDetail: TalerErrorDetail,
+ coinIdx: number,
+ ): Promise<void> {
+ logger.trace(`withdrawal request failed: ${j2s(errDetail)}`);
+ await wex.db.runReadWriteTx(["planchets"], async (tx) => {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ planchet.lastError = errDetail;
+ await tx.planchets.put(planchet);
+ });
+ }
+
+ // FIXME: handle individual error codes better!
+
+ const reqUrl = new URL(
+ `reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
+ withdrawalGroup.exchangeBaseUrl,
+ ).href;
+
+ try {
+ const resp = await wex.http.fetch(reqUrl, {
+ method: "POST",
+ body: batchReq,
+ cancellationToken: wex.cancellationToken,
+ timeout: Duration.fromSpec({ seconds: 40 }),
+ });
+ if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ await handleKycRequired(wex, withdrawalGroup, resp, 0, requestCoinIdxs);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+ if (resp.status === HttpStatusCode.Gone) {
+ const e = await readTalerErrorResponse(resp);
+ // FIXME: Store in place of the planchet that is actually affected!
+ await storeCoinError(e, requestCoinIdxs[0]);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+ const r = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForExchangeWithdrawBatchResponse(),
+ );
+ return {
+ coinIdxs: requestCoinIdxs,
+ batchResp: r,
+ };
+ } catch (e) {
+ const errDetail = getErrorDetailFromException(e);
+ // We don't know which coin is affected, so we store the error
+ // with the first coin of the batch.
+ await storeCoinError(errDetail, requestCoinIdxs[0]);
+ return {
+ batchResp: { ev_sigs: [] },
+ coinIdxs: [],
+ };
+ }
+}
+
+async function processPlanchetVerifyAndStoreCoin(
+ wex: WalletExecutionContext,
+ wgContext: WithdrawalGroupStatusInfo,
+ coinIdx: number,
+ resp: ExchangeWithdrawResponse,
+): Promise<void> {
+ const withdrawalGroup = wgContext.wgRecord;
+ logger.trace(`checking and storing planchet idx=${coinIdx}`);
+ const d = await wex.db.runReadOnlyTx(
+ ["planchets", "denominations"],
+ async (tx) => {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ logger.warn("processPlanchet: planchet already withdrawn");
+ return;
+ }
+ const denomInfo = await getDenomInfo(
+ wex,
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ planchet.denomPubHash,
+ );
+ if (!denomInfo) {
+ return;
+ }
+ return {
+ planchet,
+ denomInfo,
+ exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
+ };
+ },
+ );
+
+ if (!d) {
+ return;
+ }
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
+ });
+
+ const { planchet, denomInfo } = d;
+
+ const planchetDenomPub = denomInfo.denomPub;
+ if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
+ throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
+ }
+
+ let evSig = resp.ev_sig;
+ if (!(evSig.cipher === DenomKeyType.Rsa)) {
+ throw Error("unsupported cipher");
+ }
+
+ const denomSigRsa = await wex.cryptoApi.rsaUnblind({
+ bk: planchet.blindingKey,
+ blindedSig: evSig.blinded_rsa_signature,
+ pk: planchetDenomPub.rsa_public_key,
+ });
+
+ const isValid = await wex.cryptoApi.rsaVerify({
+ hm: planchet.coinPub,
+ pk: planchetDenomPub.rsa_public_key,
+ sig: denomSigRsa.sig,
+ });
+
+ if (!isValid) {
+ await wex.db.runReadWriteTx(["planchets"], async (tx) => {
+ let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroup.withdrawalGroupId,
+ coinIdx,
+ ]);
+ if (!planchet) {
+ return;
+ }
+ planchet.lastError = makeErrorDetail(
+ TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
+ {},
+ "invalid signature from the exchange after unblinding",
+ );
+ await tx.planchets.put(planchet);
+ });
+ return;
+ }
+
+ let denomSig: UnblindedSignature;
+ if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
+ denomSig = {
+ cipher: planchetDenomPub.cipher,
+ rsa_signature: denomSigRsa.sig,
+ };
+ } else {
+ throw Error("unsupported cipher");
+ }
+
+ const coin: CoinRecord = {
+ blindingKey: planchet.blindingKey,
+ coinPriv: planchet.coinPriv,
+ coinPub: planchet.coinPub,
+ denomPubHash: planchet.denomPubHash,
+ denomSig,
+ coinEvHash: planchet.coinEvHash,
+ exchangeBaseUrl: d.exchangeBaseUrl,
+ status: CoinStatus.Fresh,
+ coinSource: {
+ type: CoinSourceType.Withdraw,
+ coinIndex: coinIdx,
+ reservePub: withdrawalGroup.reservePub,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ },
+ sourceTransactionId: transactionId,
+ maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
+ ageCommitmentProof: planchet.ageCommitmentProof,
+ spendAllocation: undefined,
+ };
+
+ const planchetCoinPub = planchet.coinPub;
+
+ wgContext.planchetsFinished.add(planchet.coinPub);
+
+ await wex.db.runReadWriteTx(
+ ["planchets", "coins", "coinAvailability", "denominations"],
+ async (tx) => {
+ const p = await tx.planchets.get(planchetCoinPub);
+ if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ return;
+ }
+ p.planchetStatus = PlanchetStatus.WithdrawalDone;
+ p.lastError = undefined;
+ await tx.planchets.put(p);
+ await makeCoinAvailable(wex, tx, coin);
+ },
+ );
+}
+
+/**
+ * Make sure that denominations that currently can be used for withdrawal
+ * are validated, and the result of validation is stored in the database.
+ */
+export async function updateWithdrawalDenoms(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+): Promise<void> {
+ logger.trace(
+ `updating denominations used for withdrawal for ${exchangeBaseUrl}`,
+ );
+ const exchangeDetails = await wex.db.runReadOnlyTx(
+ ["exchanges", "exchangeDetails"],
+ async (tx) => {
+ return getExchangeWireDetailsInTx(tx, exchangeBaseUrl);
+ },
+ );
+ if (!exchangeDetails) {
+ logger.error("exchange details not available");
+ throw Error(`exchange ${exchangeBaseUrl} details not available`);
+ }
+ // First do a pass where the validity of candidate denominations
+ // is checked and the result is stored in the database.
+ logger.trace("getting candidate denominations");
+ const denominations = await getCandidateWithdrawalDenoms(
+ wex,
+ exchangeBaseUrl,
+ exchangeDetails.currency,
+ );
+ logger.trace(`got ${denominations.length} candidate denominations`);
+ const batchSize = 500;
+ let current = 0;
+
+ while (current < denominations.length) {
+ const updatedDenominations: DenominationRecord[] = [];
+ // Do a batch of batchSize
+ for (
+ let batchIdx = 0;
+ batchIdx < batchSize && current < denominations.length;
+ batchIdx++, current++
+ ) {
+ const denom = denominations[current];
+ if (
+ denom.verificationStatus === DenominationVerificationStatus.Unverified
+ ) {
+ logger.trace(
+ `Validating denomination (${current + 1}/${
+ denominations.length
+ }) signature of ${denom.denomPubHash}`,
+ );
+ let valid = false;
+ if (wex.ws.config.testing.insecureTrustExchange) {
+ valid = true;
+ } else {
+ const res = await wex.cryptoApi.isValidDenom({
+ denom,
+ masterPub: exchangeDetails.masterPublicKey,
+ });
+ valid = res.valid;
+ }
+ logger.trace(`Done validating ${denom.denomPubHash}`);
+ if (!valid) {
+ logger.warn(
+ `Signature check for denomination h=${denom.denomPubHash} failed`,
+ );
+ denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
+ } else {
+ denom.verificationStatus =
+ DenominationVerificationStatus.VerifiedGood;
+ }
+ updatedDenominations.push(denom);
+ }
+ }
+ if (updatedDenominations.length > 0) {
+ logger.trace("writing denomination batch to db");
+ await wex.db.runReadWriteTx(["denominations"], async (tx) => {
+ for (let i = 0; i < updatedDenominations.length; i++) {
+ const denom = updatedDenominations[i];
+ await tx.denominations.put(denom);
+ }
+ });
+ wex.ws.denomInfoCache.clear();
+ logger.trace("done with DB write");
+ }
+ }
+}
+
+/**
+ * Update the information about a reserve that is stored in the wallet
+ * by querying the reserve's exchange.
+ *
+ * If the reserve have funds that are not allocated in a withdrawal group yet
+ * and are big enough to withdraw with available denominations,
+ * create a new withdrawal group for the remaining amount.
+ */
+async function processQueryReserve(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+ if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
+ return TaskRunResult.backoff();
+ }
+ const reservePub = withdrawalGroup.reservePub;
+
+ const reserveUrl = new URL(
+ `reserves/${reservePub}`,
+ withdrawalGroup.exchangeBaseUrl,
+ );
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+
+ logger.trace(`querying reserve status via ${reserveUrl.href}`);
+
+ const resp = await wex.http.fetch(reserveUrl.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+
+ logger.trace(`reserve status code: HTTP ${resp.status}`);
+
+ const result = await readSuccessResponseJsonOrErrorCode(
+ resp,
+ codecForReserveStatus(),
+ );
+
+ if (result.isError) {
+ logger.trace(
+ `got reserve status error, EC=${result.talerErrorResponse.code}`,
+ );
+ if (resp.status === HttpStatusCode.NotFound) {
+ return TaskRunResult.longpollReturnedPending();
+ } else {
+ throwUnexpectedRequestError(resp, result.talerErrorResponse);
+ }
+ }
+
+ logger.trace(`got reserve status ${j2s(result.response)}`);
+
+ const transitionResult = await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.PendingReady;
+ wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
+ return TransitionResult.transition(wg);
+ });
+
+ if (transitionResult) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+}
+
+/**
+ * Withdrawal context that is kept in-memory.
+ *
+ * Used to store some cached info during a withdrawal operation.
+ */
+interface WithdrawalGroupStatusInfo {
+ numPlanchets: number;
+ planchetsFinished: Set<string>;
+
+ /**
+ * Cached withdrawal group record from the database.
+ */
+ wgRecord: WithdrawalGroupRecord;
+}
+
+async function processWithdrawalGroupAbortingBank(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const wgInfo = withdrawalGroup.wgInfo;
+ if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
+ throw Error("invalid state (aborting(bank) without bank info");
+ }
+ const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
+ logger.info(`aborting withdrawal at ${abortUrl}`);
+ const abortResp = await wex.http.fetch(abortUrl, {
+ method: "POST",
+ body: {},
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`abort response status: ${abortResp.status}`);
+
+ await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.AbortedBank;
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ return TransitionResult.transition(wg);
+ });
+ return TaskRunResult.finished();
+}
+
+async function processWithdrawalGroupPendingKyc(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+ const userType = "individual";
+ const kycInfo = withdrawalGroup.kycPending;
+ if (!kycInfo) {
+ throw Error("no kyc info available in pending(kyc)");
+ }
+ const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "30000");
+ logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`);
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ // FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ await ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.PendingKyc: {
+ delete rec.kycPending;
+ delete rec.kycUrl;
+ rec.status = WithdrawalGroupStatus.PendingReady;
+ return TransitionResult.transition(rec);
+ }
+ default:
+ return TransitionResult.stay();
+ }
+ });
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const kycUrl = kycStatus.kyc_url;
+ if (typeof kycUrl === "string") {
+ await ctx.transition({}, async (rec) => {
+ if (!rec) {
+ return TransitionResult.stay();
+ }
+ switch (rec.status) {
+ case WithdrawalGroupStatus.PendingReady: {
+ rec.kycUrl = kycUrl;
+ return TransitionResult.transition(rec);
+ }
+ }
+ return TransitionResult.stay();
+ });
+ }
+ } else if (
+ kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
+ ) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`aml status: ${j2s(kycStatus)}`);
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+ return TaskRunResult.backoff();
+}
+
+/**
+ * Select new denominations for a withdrawal group.
+ * Necessary when denominations expired or got revoked
+ * before the withdrawal could complete.
+ */
+async function redenominateWithdrawal(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`);
+ await wex.db.runReadWriteTx(
+ ["withdrawalGroups", "planchets", "denominations"],
+ async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ return;
+ }
+ const currency = Amounts.currencyOf(wg.denomsSel.totalWithdrawCost);
+ const exchangeBaseUrl = wg.exchangeBaseUrl;
+
+ const candidates = await getCandidateWithdrawalDenomsTx(
+ wex,
+ tx,
+ exchangeBaseUrl,
+ currency,
+ );
+
+ const oldSel = wg.denomsSel;
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`old denom sel: ${j2s(oldSel)}`);
+ }
+
+ let zero = Amount.zeroOfCurrency(currency);
+ let amountRemaining = zero;
+ let prevTotalCoinValue = zero;
+ let prevTotalWithdrawalCost = zero;
+ let prevHasDenomWithAgeRestriction = false;
+ let prevEarliestDepositExpiration = AbsoluteTime.never();
+ let prevDenoms: DenomSelItem[] = [];
+ let coinIndex = 0;
+ for (let i = 0; i < oldSel.selectedDenoms.length; i++) {
+ const sel = wg.denomsSel.selectedDenoms[i];
+ const denom = await tx.denominations.get([
+ exchangeBaseUrl,
+ sel.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error("denom in use but not not found");
+ }
+ // FIXME: Also check planchet if there was a different error or planchet already withdrawn
+ let denomOkay = isWithdrawableDenom(
+ denom,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ const numCoins = sel.count - (sel.skip ?? 0);
+ const denomValue = Amount.from(denom.value).mult(numCoins);
+ const denomFeeWithdraw = Amount.from(denom.fees.feeWithdraw).mult(
+ numCoins,
+ );
+ if (denomOkay) {
+ prevTotalCoinValue = prevTotalCoinValue.add(denomValue);
+ prevTotalWithdrawalCost = prevTotalWithdrawalCost.add(
+ denomValue,
+ denomFeeWithdraw,
+ );
+ prevDenoms.push({
+ count: sel.count,
+ denomPubHash: sel.denomPubHash,
+ skip: sel.skip,
+ });
+ prevHasDenomWithAgeRestriction =
+ prevHasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
+ prevEarliestDepositExpiration = AbsoluteTime.min(
+ prevEarliestDepositExpiration,
+ timestampAbsoluteFromDb(denom.stampExpireDeposit),
+ );
+ } else {
+ amountRemaining = amountRemaining.add(denomValue, denomFeeWithdraw);
+ prevDenoms.push({
+ count: sel.count,
+ denomPubHash: sel.denomPubHash,
+ skip: (sel.skip ?? 0) + numCoins,
+ });
+
+ for (let j = 0; j < sel.count; j++) {
+ const ci = coinIndex + j;
+ const p = await tx.planchets.indexes.byGroupAndIndex.get([
+ withdrawalGroupId,
+ ci,
+ ]);
+ if (!p) {
+ // Maybe planchet wasn't yet generated.
+ // No problem!
+ logger.info(
+ `not aborting planchet #${coinIndex}, planchet not found`,
+ );
+ continue;
+ }
+ logger.info(`aborting planchet #${coinIndex}`);
+ p.planchetStatus = PlanchetStatus.AbortedReplaced;
+ await tx.planchets.put(p);
+ }
+ }
+
+ coinIndex += sel.count;
+ }
+
+ const newSel = selectWithdrawalDenominations(
+ amountRemaining.toJson(),
+ candidates,
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`new denom sel: ${j2s(newSel)}`);
+ }
+
+ const mergedSel: DenomSelectionState = {
+ selectedDenoms: [...prevDenoms, ...newSel.selectedDenoms],
+ totalCoinValue: zero
+ .add(prevTotalCoinValue, newSel.totalCoinValue)
+ .toString(),
+ totalWithdrawCost: zero
+ .add(prevTotalWithdrawalCost, newSel.totalWithdrawCost)
+ .toString(),
+ hasDenomWithAgeRestriction:
+ prevHasDenomWithAgeRestriction || newSel.hasDenomWithAgeRestriction,
+ earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.min(
+ prevEarliestDepositExpiration,
+ AbsoluteTime.fromProtocolTimestamp(
+ newSel.earliestDepositExpiration,
+ ),
+ ),
+ ),
+ };
+ wg.denomsSel = mergedSel;
+ if (logger.shouldLogTrace()) {
+ logger.trace(`merged denom sel: ${j2s(mergedSel)}`);
+ }
+ await tx.withdrawalGroups.put(wg);
+ },
+ );
+}
+
+async function processWithdrawalGroupPendingReady(
+ wex: WalletExecutionContext,
+ withdrawalGroup: WithdrawalGroupRecord,
+): Promise<TaskRunResult> {
+ const { withdrawalGroupId } = withdrawalGroup;
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
+
+ await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl);
+
+ if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
+ logger.warn("Finishing empty withdrawal group (no denoms)");
+ await ctx.transition({}, async (wg) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+ wg.status = WithdrawalGroupStatus.Done;
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ return TransitionResult.transition(wg);
+ });
+ return TaskRunResult.finished();
+ }
+
+ const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
+ .map((x) => x.count)
+ .reduce((a, b) => a + b);
+
+ const wgContext: WithdrawalGroupStatusInfo = {
+ numPlanchets: numTotalCoins,
+ planchetsFinished: new Set<string>(),
+ wgRecord: withdrawalGroup,
+ };
+
+ await wex.db.runReadOnlyTx(["planchets"], async (tx) => {
+ const planchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const p of planchets) {
+ if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
+ wgContext.planchetsFinished.add(p.coinPub);
+ }
+ }
+ });
+
+ // We sequentially generate planchets, so that
+ // large withdrawal groups don't make the wallet unresponsive.
+ for (let i = 0; i < numTotalCoins; i++) {
+ await processPlanchetGenerate(wex, withdrawalGroup, i);
+ }
+
+ const maxBatchSize = 100;
+
+ for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
+ const resp = await processPlanchetExchangeBatchRequest(wex, wgContext, {
+ batchSize: maxBatchSize,
+ coinStartIndex: i,
+ });
+ let work: Promise<void>[] = [];
+ work = [];
+ for (let j = 0; j < resp.coinIdxs.length; j++) {
+ if (!resp.batchResp.ev_sigs[j]) {
+ // response may not be available when there is kyc needed
+ continue;
+ }
+ work.push(
+ processPlanchetVerifyAndStoreCoin(
+ wex,
+ wgContext,
+ resp.coinIdxs[j],
+ resp.batchResp.ev_sigs[j],
+ ),
+ );
+ }
+ await Promise.all(work);
+ }
+
+ let redenomRequired = false;
+
+ await wex.db.runReadOnlyTx(["planchets"], async (tx) => {
+ const planchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const p of planchets) {
+ if (p.planchetStatus !== PlanchetStatus.Pending) {
+ continue;
+ }
+ if (!p.lastError) {
+ continue;
+ }
+ switch (p.lastError.code) {
+ case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED:
+ case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_REVOKED:
+ redenomRequired = true;
+ return;
+ }
+ }
+ });
+
+ if (redenomRequired) {
+ logger.warn(`withdrawal ${withdrawalGroupId} requires redenomination`);
+ await fetchFreshExchange(wex, exchangeBaseUrl, {
+ forceUpdate: true,
+ });
+ await updateWithdrawalDenoms(wex, exchangeBaseUrl);
+ await redenominateWithdrawal(wex, withdrawalGroupId);
+ return TaskRunResult.backoff();
+ }
+
+ const errorsPerCoin: Record<number, TalerErrorDetail> = {};
+ let numPlanchetErrors = 0;
+ let numActive = 0;
+ let numDone = 0;
+ const maxReportedErrors = 5;
+
+ const res = await ctx.transition(
+ {
+ extraStores: ["coins", "coinAvailability", "planchets"],
+ },
+ async (wg, tx) => {
+ if (!wg) {
+ return TransitionResult.stay();
+ }
+
+ const groupPlanchets =
+ await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
+ for (const x of groupPlanchets) {
+ switch (x.planchetStatus) {
+ case PlanchetStatus.KycRequired:
+ case PlanchetStatus.Pending:
+ numActive++;
+ break;
+ case PlanchetStatus.WithdrawalDone:
+ numDone++;
+ break;
+ }
+ if (x.lastError) {
+ numPlanchetErrors++;
+ if (numPlanchetErrors < maxReportedErrors) {
+ errorsPerCoin[x.coinIdx] = x.lastError;
+ }
+ }
+ }
+
+ if (wg.timestampFinish === undefined && numActive === 0) {
+ wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ wg.status = WithdrawalGroupStatus.Done;
+ await makeCoinsVisible(wex, tx, ctx.transactionId);
+ }
+ return TransitionResult.transition(wg);
+ },
+ );
+
+ if (!res) {
+ throw Error("withdrawal group does not exist anymore");
+ }
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ if (numPlanchetErrors > 0) {
+ return {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
+ {
+ errorsPerCoin,
+ numErrors: numPlanchetErrors,
+ },
+ ),
+ };
+ }
+
+ return TaskRunResult.backoff();
+}
+
+export async function processWithdrawalGroup(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ logger.trace("processing withdrawal group", withdrawalGroupId);
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ return tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+
+ if (!withdrawalGroup) {
+ throw Error(`withdrawal group ${withdrawalGroupId} not found`);
+ }
+
+ switch (withdrawalGroup.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ return await processBankRegisterReserve(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ return processQueryReserve(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return await processReserveBankStatus(wex, withdrawalGroupId);
+ case WithdrawalGroupStatus.PendingAml:
+ // FIXME: Handle this case, withdrawal doesn't support AML yet.
+ return TaskRunResult.backoff();
+ case WithdrawalGroupStatus.PendingKyc:
+ return processWithdrawalGroupPendingKyc(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.PendingReady:
+ // Continue with the actual withdrawal!
+ return await processWithdrawalGroupPendingReady(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.AbortingBank:
+ return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup);
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedAbortingBank:
+ case WithdrawalGroupStatus.SuspendedAml:
+ case WithdrawalGroupStatus.SuspendedKyc:
+ case WithdrawalGroupStatus.SuspendedQueryingStatus:
+ case WithdrawalGroupStatus.SuspendedReady:
+ case WithdrawalGroupStatus.SuspendedRegisteringBank:
+ case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ // Nothing to do.
+ return TaskRunResult.finished();
+ default:
+ assertUnreachable(withdrawalGroup.status);
+ }
+}
+
+const AGE_MASK_GROUPS = "8:10:12:14:16:18"
+ .split(":")
+ .map((n) => parseInt(n, 10));
+
+export async function getExchangeWithdrawalInfo(
+ wex: WalletExecutionContext,
+ exchangeBaseUrl: string,
+ instructedAmount: AmountJson,
+ ageRestricted: number | undefined,
+): Promise<ExchangeWithdrawalDetails> {
+ logger.trace("updating exchange");
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl, {
+ cancellationToken: wex.cancellationToken,
+ });
+
+ wex.cancellationToken.throwIfCancelled();
+
+ if (exchange.currency != instructedAmount.currency) {
+ // Specifying the amount in the conversion input currency is not yet supported.
+ // We might add support for it later.
+ throw new Error(
+ `withdrawal only supported when specifying target currency ${exchange.currency}`,
+ );
+ }
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount,
+ },
+ wex.cancellationToken,
+ );
+
+ logger.trace("updating withdrawal denoms");
+ await updateWithdrawalDenoms(wex, exchangeBaseUrl);
+
+ wex.cancellationToken.throwIfCancelled();
+
+ logger.trace("getting candidate denoms");
+ const candidateDenoms = await getCandidateWithdrawalDenoms(
+ wex,
+ exchangeBaseUrl,
+ instructedAmount.currency,
+ );
+
+ wex.cancellationToken.throwIfCancelled();
+
+ logger.trace("selecting withdrawal denoms");
+ // FIXME: Why not in a transaction?
+ const selectedDenoms = selectWithdrawalDenominations(
+ instructedAmount,
+ candidateDenoms,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+
+ logger.trace("selection done");
+
+ if (selectedDenoms.selectedDenoms.length === 0) {
+ throw Error(
+ `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify(
+ instructedAmount,
+ )}`,
+ );
+ }
+
+ const exchangeWireAccounts: string[] = [];
+
+ for (const account of exchange.wireInfo.accounts) {
+ exchangeWireAccounts.push(account.payto_uri);
+ }
+
+ let versionMatch;
+ if (exchange.protocolVersionRange) {
+ versionMatch = LibtoolVersion.compare(
+ WALLET_EXCHANGE_PROTOCOL_VERSION,
+ exchange.protocolVersionRange,
+ );
+
+ if (
+ versionMatch &&
+ !versionMatch.compatible &&
+ versionMatch.currentCmp === -1
+ ) {
+ logger.warn(
+ `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
+ `(exchange has ${exchange.protocolVersionRange}), checking for updates`,
+ );
+ }
+ }
+
+ let tosAccepted = false;
+ if (exchange.tosAcceptedTimestamp) {
+ if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
+ tosAccepted = true;
+ }
+ }
+
+ const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
+ if (!paytoUris) {
+ throw Error("exchange is in invalid state");
+ }
+
+ const ret: ExchangeWithdrawalDetails = {
+ earliestDepositExpiration: selectedDenoms.earliestDepositExpiration,
+ exchangePaytoUris: paytoUris,
+ exchangeWireAccounts,
+ exchangeCreditAccountDetails: withdrawalAccountsList,
+ exchangeVersion: exchange.protocolVersionRange || "unknown",
+ selectedDenoms,
+ versionMatch,
+ walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
+ termsOfServiceAccepted: tosAccepted,
+ withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
+ withdrawalAmountRaw: Amounts.stringify(instructedAmount),
+ // TODO: remove hardcoding, this should be calculated from the denominations info
+ // force enabled for testing
+ ageRestrictionOptions: selectedDenoms.hasDenomWithAgeRestriction
+ ? AGE_MASK_GROUPS
+ : undefined,
+ scopeInfo: exchange.scopeInfo,
+ };
+ return ret;
+}
+
+export interface GetWithdrawalDetailsForUriOpts {
+ restrictAge?: number;
+ notifyChangeFromPendingTimeoutMs?: number;
+}
+
+type WithdrawalOperationMemoryMap = {
+ [uri: string]: boolean | undefined;
+};
+
+const ongoingChecks: WithdrawalOperationMemoryMap = {};
+
+/**
+ * Get more information about a taler://withdraw URI.
+ *
+ * As side effects, the bank (via the bank integration API) is queried
+ * and the exchange suggested by the bank is ephemerally added
+ * to the wallet's list of known exchanges.
+ */
+export async function getWithdrawalDetailsForUri(
+ wex: WalletExecutionContext,
+ talerWithdrawUri: string,
+ opts: GetWithdrawalDetailsForUriOpts = {},
+): Promise<WithdrawUriInfoResponse> {
+ logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
+ const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri);
+ logger.trace(`got bank info`);
+ if (info.suggestedExchange) {
+ try {
+ // If the exchange entry doesn't exist yet,
+ // it'll be created as an ephemeral entry.
+ await fetchFreshExchange(wex, info.suggestedExchange);
+ } catch (e) {
+ // We still continued if it failed, as other exchanges might be available.
+ // We don't want to fail if the bank-suggested exchange is broken/offline.
+ logger.trace(
+ `querying bank-suggested exchange (${info.suggestedExchange}) failed`,
+ );
+ }
+ }
+
+ const currency = Amounts.currencyOf(info.amount);
+
+ const listExchangesResp = await listExchanges(wex);
+ const possibleExchanges = listExchangesResp.exchanges.filter((x) => {
+ return (
+ x.currency === currency &&
+ (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
+ x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
+ );
+ });
+
+ // FIXME: this should be removed after the extended version of
+ // withdrawal state machine. issue #8099
+ if (
+ info.status === "pending" &&
+ opts.notifyChangeFromPendingTimeoutMs !== undefined &&
+ !ongoingChecks[talerWithdrawUri]
+ ) {
+ ongoingChecks[talerWithdrawUri] = true;
+ const bankApi = new TalerBankIntegrationHttpClient(
+ info.apiBaseUrl,
+ wex.http,
+ );
+
+ bankApi
+ .getWithdrawalOperationById(info.operationId, {
+ old_state: "pending",
+ timeoutMs: opts.notifyChangeFromPendingTimeoutMs,
+ })
+ .then((resp) => {
+ if (resp.type === "ok" && resp.body.status !== "pending") {
+ wex.ws.notify({
+ type: NotificationType.WithdrawalOperationTransition,
+ uri: talerWithdrawUri,
+ });
+ }
+ })
+ .finally(() => {
+ ongoingChecks[talerWithdrawUri] = false;
+ });
+ }
+
+ return {
+ operationId: info.operationId,
+ confirmTransferUrl: info.confirmTransferUrl,
+ status: info.status,
+ amount: Amounts.stringify(info.amount),
+ defaultExchangeBaseUrl: info.suggestedExchange,
+ possibleExchanges,
+ };
+}
+
+export function augmentPaytoUrisForWithdrawal(
+ plainPaytoUris: string[],
+ reservePub: string,
+ instructedAmount: AmountLike,
+): string[] {
+ return plainPaytoUris.map((x) =>
+ addPaytoQueryParams(x, {
+ amount: Amounts.stringify(instructedAmount),
+ message: `Taler Withdrawal ${reservePub}`,
+ }),
+ );
+}
+
+/**
+ * Get payto URIs that can be used to fund a withdrawal operation.
+ */
+export async function getFundingPaytoUris(
+ tx: WalletDbReadOnlyTransaction<
+ ["withdrawalGroups", "exchanges", "exchangeDetails"]
+ >,
+ withdrawalGroupId: string,
+): Promise<string[]> {
+ const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
+ checkDbInvariant(!!withdrawalGroup);
+ const exchangeDetails = await getExchangeWireDetailsInTx(
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) {
+ logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
+ return [];
+ }
+ const plainPaytoUris =
+ exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+ if (!plainPaytoUris) {
+ logger.error(
+ `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
+ );
+ return [];
+ }
+ return augmentPaytoUrisForWithdrawal(
+ plainPaytoUris,
+ withdrawalGroup.reservePub,
+ withdrawalGroup.instructedAmount,
+ );
+}
+
+async function getWithdrawalGroupRecordTx(
+ db: DbAccess<typeof WalletStoresV1>,
+ req: {
+ withdrawalGroupId: string;
+ },
+): Promise<WithdrawalGroupRecord | undefined> {
+ return await db.runReadOnlyTx(["withdrawalGroups"], async (tx) => {
+ return tx.withdrawalGroups.get(req.withdrawalGroupId);
+ });
+}
+
+export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
+ return { d_ms: 60000 };
+}
+
+export function getBankStatusUrl(talerWithdrawUri: string): string {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ return url.href;
+}
+
+export function getBankAbortUrl(talerWithdrawUri: string): string {
+ const uriResult = parseWithdrawUri(talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ return url.href;
+}
+
+async function registerReserveWithBank(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const withdrawalGroup = await wex.db.runReadOnlyTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ return await tx.withdrawalGroups.get(withdrawalGroupId);
+ },
+ );
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ switch (withdrawalGroup?.status) {
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ break;
+ default:
+ return;
+ }
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("expecting withdrawal type = bank integrated");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ return;
+ }
+ const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
+ const reqBody = {
+ reserve_pub: withdrawalGroup.reservePub,
+ selected_exchange: bankInfo.exchangePaytoUri,
+ };
+ logger.info(`registering reserve with bank: ${j2s(reqBody)}`);
+ const httpResp = await wex.http.fetch(bankStatusUrl, {
+ method: "POST",
+ body: reqBody,
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ const status = await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codeForBankWithdrawalOperationPostResponse(),
+ );
+
+ await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
+ AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
+ );
+ r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
+ r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
+ return TransitionResult.transition(r);
+ });
+}
+
+async function transitionBankAborted(
+ ctx: WithdrawTransactionContext,
+): Promise<TaskRunResult> {
+ logger.info("bank aborted the withdrawal");
+ await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.FailedBankAborted;
+ return TransitionResult.transition(r);
+ });
+ return TaskRunResult.progress();
+}
+
+async function processBankRegisterReserve(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("wrong withdrawal record type");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ throw Error("no bank info in bank-integrated withdrawal");
+ }
+
+ const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
+ }
+ const url = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+
+ const statusResp = await wex.http.fetch(url.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (status.aborted) {
+ return transitionBankAborted(ctx);
+ }
+
+ // FIXME: Put confirm transfer URL in the DB!
+
+ await registerReserveWithBank(wex, withdrawalGroupId);
+ return TaskRunResult.progress();
+}
+
+async function processReserveBankStatus(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<TaskRunResult> {
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
+ withdrawalGroupId,
+ });
+
+ if (!withdrawalGroup) {
+ return TaskRunResult.finished();
+ }
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ if (
+ withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
+ ) {
+ throw Error("wrong withdrawal record type");
+ }
+ const bankInfo = withdrawalGroup.wgInfo.bankInfo;
+ if (!bankInfo) {
+ throw Error("no bank info in bank-integrated withdrawal");
+ }
+
+ const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
+ if (!uriResult) {
+ throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
+ }
+ const bankStatusUrl = new URL(
+ `withdrawal-operation/${uriResult.withdrawalOperationId}`,
+ uriResult.bankIntegrationApiBaseUrl,
+ );
+ bankStatusUrl.searchParams.set("long_poll_ms", "30000");
+
+ logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`);
+ const statusResp = await wex.http.fetch(bankStatusUrl.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ cancellationToken: wex.cancellationToken,
+ });
+ logger.info(
+ `long-polling for withdrawal operation returned status ${statusResp.status}`,
+ );
+
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (logger.shouldLogTrace()) {
+ logger.trace(`response body: ${j2s(status)}`);
+ }
+
+ if (status.aborted) {
+ return transitionBankAborted(ctx);
+ }
+
+ if (!status.transfer_done) {
+ return TaskRunResult.longpollReturnedPending();
+ }
+
+ const transitionInfo = await ctx.transition({}, async (r) => {
+ if (!r) {
+ return TransitionResult.stay();
+ }
+ // Re-check reserve status within transaction
+ switch (r.status) {
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ break;
+ default:
+ return TransitionResult.stay();
+ }
+ if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
+ throw Error("invariant failed");
+ }
+ if (status.transfer_done) {
+ logger.info("withdrawal: transfer confirmed by bank.");
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
+ r.status = WithdrawalGroupStatus.PendingQueryingStatus;
+ return TransitionResult.transition(r);
+ } else {
+ return TransitionResult.stay();
+ }
+ });
+
+ if (transitionInfo) {
+ return TaskRunResult.progress();
+ } else {
+ return TaskRunResult.backoff();
+ }
+}
+
+export interface PrepareCreateWithdrawalGroupResult {
+ withdrawalGroup: WithdrawalGroupRecord;
+ transactionId: string;
+ creationInfo?: {
+ amount: AmountJson;
+ canonExchange: string;
+ };
+}
+
+export async function internalPrepareCreateWithdrawalGroup(
+ wex: WalletExecutionContext,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ amount: AmountJson;
+ exchangeBaseUrl: string;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<PrepareCreateWithdrawalGroupResult> {
+ const reserveKeyPair =
+ args.reserveKeyPair ?? (await wex.cryptoApi.createEddsaKeypair({}));
+ const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
+ const secretSeed = encodeCrock(getRandomBytes(32));
+ const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
+ const amount = args.amount;
+ const currency = Amounts.currencyOf(amount);
+
+ let withdrawalGroupId;
+
+ if (args.forcedWithdrawalGroupId) {
+ withdrawalGroupId = args.forcedWithdrawalGroupId;
+ const wgId = withdrawalGroupId;
+ const existingWg = await wex.db.runReadOnlyTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ return tx.withdrawalGroups.get(wgId);
+ },
+ );
+
+ if (existingWg) {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWg.withdrawalGroupId,
+ });
+ return { withdrawalGroup: existingWg, transactionId };
+ }
+ } else {
+ withdrawalGroupId = encodeCrock(getRandomBytes(32));
+ }
+
+ await updateWithdrawalDenoms(wex, canonExchange);
+ const denoms = await getCandidateWithdrawalDenoms(
+ wex,
+ canonExchange,
+ currency,
+ );
+
+ let initialDenomSel: DenomSelectionState;
+ const denomSelUid = encodeCrock(getRandomBytes(16));
+ if (args.forcedDenomSel) {
+ logger.warn("using forced denom selection");
+ initialDenomSel = selectForcedWithdrawalDenominations(
+ amount,
+ denoms,
+ args.forcedDenomSel,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ } else {
+ initialDenomSel = selectWithdrawalDenominations(
+ amount,
+ denoms,
+ wex.ws.config.testing.denomselAllowLate,
+ );
+ }
+
+ const withdrawalGroup: WithdrawalGroupRecord = {
+ denomSelUid,
+ denomsSel: initialDenomSel,
+ exchangeBaseUrl: canonExchange,
+ instructedAmount: Amounts.stringify(amount),
+ timestampStart: timestampPreciseToDb(now),
+ rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
+ effectiveWithdrawalAmount: initialDenomSel.totalCoinValue,
+ secretSeed,
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ status: args.reserveStatus,
+ withdrawalGroupId,
+ restrictAge: args.restrictAge,
+ senderWire: undefined,
+ timestampFinish: undefined,
+ wgInfo: args.wgInfo,
+ };
+
+ await fetchFreshExchange(wex, canonExchange);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
+ });
+
+ return {
+ withdrawalGroup,
+ transactionId,
+ creationInfo: {
+ canonExchange,
+ amount,
+ },
+ };
+}
+
+export interface PerformCreateWithdrawalGroupResult {
+ withdrawalGroup: WithdrawalGroupRecord;
+ transitionInfo: TransitionInfo | undefined;
+
+ /**
+ * Notification for the exchange state transition.
+ *
+ * Should be emitted after the transaction has succeeded.
+ */
+ exchangeNotif: WalletNotification | undefined;
+}
+
+export async function internalPerformCreateWithdrawalGroup(
+ wex: WalletExecutionContext,
+ tx: WalletDbReadWriteTransaction<
+ ["withdrawalGroups", "reserves", "exchanges"]
+ >,
+ prep: PrepareCreateWithdrawalGroupResult,
+): Promise<PerformCreateWithdrawalGroupResult> {
+ const { withdrawalGroup } = prep;
+ if (!prep.creationInfo) {
+ return {
+ withdrawalGroup,
+ transitionInfo: undefined,
+ exchangeNotif: undefined,
+ };
+ }
+ const existingWg = await tx.withdrawalGroups.get(
+ withdrawalGroup.withdrawalGroupId,
+ );
+ if (existingWg) {
+ return {
+ withdrawalGroup: existingWg,
+ exchangeNotif: undefined,
+ transitionInfo: undefined,
+ };
+ }
+ await tx.withdrawalGroups.add(withdrawalGroup);
+ await tx.reserves.put({
+ reservePub: withdrawalGroup.reservePub,
+ reservePriv: withdrawalGroup.reservePriv,
+ });
+
+ const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
+ if (exchange) {
+ exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now());
+ await tx.exchanges.put(exchange);
+ }
+
+ const oldTxState = {
+ major: TransactionMajorState.None,
+ minor: undefined,
+ };
+ const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
+ const transitionInfo = {
+ oldTxState,
+ newTxState,
+ };
+
+ const exchangeUsedRes = await markExchangeUsed(
+ wex,
+ tx,
+ prep.withdrawalGroup.exchangeBaseUrl,
+ );
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ withdrawalGroup,
+ transitionInfo,
+ exchangeNotif: exchangeUsedRes.notif,
+ };
+}
+
+/**
+ * Create a withdrawal group.
+ *
+ * If a forcedWithdrawalGroupId is given and a
+ * withdrawal group with this ID already exists,
+ * the existing one is returned. No conflict checking
+ * of the other arguments is done in that case.
+ */
+export async function internalCreateWithdrawalGroup(
+ wex: WalletExecutionContext,
+ args: {
+ reserveStatus: WithdrawalGroupStatus;
+ amount: AmountJson;
+ exchangeBaseUrl: string;
+ forcedWithdrawalGroupId?: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ wgInfo: WgInfo;
+ },
+): Promise<WithdrawalGroupRecord> {
+ const prep = await internalPrepareCreateWithdrawalGroup(wex, args);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
+ });
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ prep.withdrawalGroup.withdrawalGroupId,
+ );
+ const res = await wex.db.runReadWriteTx(
+ [
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ "transactions",
+ "operationRetries",
+ ],
+ async (tx) => {
+ const res = await internalPerformCreateWithdrawalGroup(wex, tx, prep);
+ await updateWithdrawalTransaction(ctx, tx);
+ return res;
+ },
+ );
+ if (res.exchangeNotif) {
+ wex.ws.notify(res.exchangeNotif);
+ }
+ notifyTransition(wex, transactionId, res.transitionInfo);
+ return res.withdrawalGroup;
+}
+
+/**
+ * Accept a bank-integrated withdrawal.
+ *
+ * Before returning, the wallet tries to register the reserve with the bank.
+ *
+ * Thus after this call returns, the withdrawal operation can be confirmed
+ * with the bank.
+ */
+export async function acceptWithdrawalFromUri(
+ wex: WalletExecutionContext,
+ req: {
+ talerWithdrawUri: string;
+ selectedExchange: string;
+ forcedDenomSel?: ForcedDenomSel;
+ restrictAge?: number;
+ },
+): Promise<AcceptWithdrawalResponse> {
+ const selectedExchange = canonicalizeBaseUrl(req.selectedExchange);
+ logger.info(
+ `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
+ );
+ const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
+ ["withdrawalGroups"],
+ async (tx) => {
+ return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
+ req.talerWithdrawUri,
+ );
+ },
+ );
+
+ if (existingWithdrawalGroup) {
+ let url: string | undefined;
+ if (
+ existingWithdrawalGroup.wgInfo.withdrawalType ===
+ WithdrawalRecordType.BankIntegrated
+ ) {
+ url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
+ }
+ return {
+ reservePub: existingWithdrawalGroup.reservePub,
+ confirmTransferUrl: url,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Withdrawal,
+ withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
+ }),
+ };
+ }
+
+ await fetchFreshExchange(wex, selectedExchange);
+ const withdrawInfo = await getBankWithdrawalInfo(
+ wex.http,
+ req.talerWithdrawUri,
+ );
+ const exchangePaytoUri = await getExchangePaytoUri(
+ wex,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+
+ const exchange = await fetchFreshExchange(wex, selectedExchange);
+
+ const withdrawalAccountList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: withdrawInfo.amount,
+ },
+ CancellationToken.CONTINUE,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ amount: withdrawInfo.amount,
+ exchangeBaseUrl: req.selectedExchange,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankIntegrated,
+ exchangeCreditAccounts: withdrawalAccountList,
+ bankInfo: {
+ exchangePaytoUri,
+ talerWithdrawUri: req.talerWithdrawUri,
+ confirmUrl: withdrawInfo.confirmTransferUrl,
+ timestampBankConfirmed: undefined,
+ timestampReserveInfoPosted: undefined,
+ },
+ },
+ restrictAge: req.restrictAge,
+ forcedDenomSel: req.forcedDenomSel,
+ reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ await waitWithdrawalRegistered(wex, ctx);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+ transactionId: ctx.transactionId,
+ };
+}
+
+async function internalWaitWithdrawalRegistered(
+ wex: WalletExecutionContext,
+ ctx: WithdrawTransactionContext,
+ withdrawalNotifFlag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx(
+ ["withdrawalGroups", "operationRetries"],
+ async (tx) => {
+ return {
+ withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
+ retryRec: await tx.operationRetries.get(ctx.taskId),
+ };
+ },
+ );
+
+ if (!withdrawalRec) {
+ throw Error("withdrawal not found anymore");
+ }
+
+ switch (withdrawalRec.status) {
+ case WithdrawalGroupStatus.FailedBankAborted:
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
+ {},
+ );
+ case WithdrawalGroupStatus.PendingKyc:
+ case WithdrawalGroupStatus.PendingAml:
+ case WithdrawalGroupStatus.PendingQueryingStatus:
+ case WithdrawalGroupStatus.PendingReady:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.PendingWaitConfirmBank:
+ return;
+ case WithdrawalGroupStatus.PendingRegisteringBank:
+ break;
+ default: {
+ if (retryRec) {
+ if (retryRec.lastError) {
+ throw TalerError.fromUncheckedDetail(retryRec.lastError);
+ } else {
+ throw Error("withdrawal unexpectedly pending");
+ }
+ }
+ }
+ }
+
+ await withdrawalNotifFlag.wait();
+ withdrawalNotifFlag.reset();
+ }
+}
+
+async function waitWithdrawalRegistered(
+ wex: WalletExecutionContext,
+ ctx: WithdrawTransactionContext,
+): Promise<void> {
+ // FIXME: Doesn't support cancellation yet
+ // FIXME: We should use Symbol.dispose magic here for cleanup!
+
+ const withdrawalNotifFlag = new AsyncFlag();
+ // Raise exchangeNotifFlag whenever we get a notification
+ // about our exchange.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ logger.info(`raising update notification: ${j2s(notif)}`);
+ withdrawalNotifFlag.raise();
+ }
+ });
+
+ try {
+ const res = await internalWaitWithdrawalRegistered(
+ wex,
+ ctx,
+ withdrawalNotifFlag,
+ );
+ logger.info("done waiting for ready exchange");
+ return res;
+ } finally {
+ cancelNotif();
+ }
+}
+
+async function fetchAccount(
+ wex: WalletExecutionContext,
+ instructedAmount: AmountJson,
+ acct: ExchangeWireAccount,
+ reservePub: string | undefined,
+ cancellationToken: CancellationToken,
+): Promise<WithdrawalExchangeAccountDetails> {
+ let paytoUri: string;
+ let transferAmount: AmountString | undefined = undefined;
+ let currencySpecification: CurrencySpecification | undefined = undefined;
+ if (acct.conversion_url != null) {
+ const reqUrl = new URL("cashin-rate", acct.conversion_url);
+ reqUrl.searchParams.set(
+ "amount_credit",
+ Amounts.stringify(instructedAmount),
+ );
+ const httpResp = await wex.http.fetch(reqUrl.href, {
+ cancellationToken,
+ });
+ const respOrErr = await readSuccessResponseJsonOrErrorCode(
+ httpResp,
+ codecForCashinConversionResponse(),
+ );
+ if (respOrErr.isError) {
+ return {
+ status: "error",
+ paytoUri: acct.payto_uri,
+ conversionError: respOrErr.talerErrorResponse,
+ };
+ }
+ const resp = respOrErr.response;
+ paytoUri = acct.payto_uri;
+ transferAmount = resp.amount_debit;
+ const configUrl = new URL("config", acct.conversion_url);
+ const configResp = await wex.http.fetch(configUrl.href, {
+ cancellationToken,
+ });
+ const configRespOrError = await readSuccessResponseJsonOrErrorCode(
+ configResp,
+ codecForConversionBankConfig(),
+ );
+ if (configRespOrError.isError) {
+ return {
+ status: "error",
+ paytoUri: acct.payto_uri,
+ conversionError: configRespOrError.talerErrorResponse,
+ };
+ }
+ const configParsed = configRespOrError.response;
+ currencySpecification = configParsed.fiat_currency_specification;
+ } else {
+ paytoUri = acct.payto_uri;
+ transferAmount = Amounts.stringify(instructedAmount);
+ }
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ amount: Amounts.stringify(transferAmount),
+ });
+ if (reservePub != null) {
+ paytoUri = addPaytoQueryParams(paytoUri, {
+ message: `Taler Withdrawal ${reservePub}`,
+ });
+ }
+ const acctInfo: WithdrawalExchangeAccountDetails = {
+ status: "ok",
+ paytoUri,
+ transferAmount,
+ bankLabel: acct.bank_label,
+ priority: acct.priority,
+ currencySpecification,
+ creditRestrictions: acct.credit_restrictions,
+ };
+ if (transferAmount != null) {
+ acctInfo.transferAmount = transferAmount;
+ }
+ return acctInfo;
+}
+
+/**
+ * Gather information about bank accounts that can be used for
+ * withdrawals. This includes accounts that are in a different
+ * currency and require conversion.
+ */
+async function fetchWithdrawalAccountInfo(
+ wex: WalletExecutionContext,
+ req: {
+ exchange: ReadyExchangeSummary;
+ instructedAmount: AmountJson;
+ reservePub?: string;
+ },
+ cancellationToken: CancellationToken,
+): Promise<WithdrawalExchangeAccountDetails[]> {
+ const { exchange } = req;
+ const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
+ for (let acct of exchange.wireInfo.accounts) {
+ const acctInfo = await fetchAccount(
+ wex,
+ req.instructedAmount,
+ acct,
+ req.reservePub,
+ cancellationToken,
+ );
+ withdrawalAccounts.push(acctInfo);
+ }
+ withdrawalAccounts.sort((x1, x2) => {
+ // Accounts without explicit priority have prio 0.
+ const n1 = x1.priority ?? 0;
+ const n2 = x2.priority ?? 0;
+ return Math.sign(n2 - n1);
+ });
+ return withdrawalAccounts;
+}
+
+/**
+ * Create a manual withdrawal operation.
+ *
+ * Adds the corresponding exchange as a trusted exchange if it is neither
+ * audited nor trusted already.
+ *
+ * Asynchronously starts the withdrawal.
+ */
+export async function createManualWithdrawal(
+ wex: WalletExecutionContext,
+ req: {
+ exchangeBaseUrl: string;
+ amount: AmountLike;
+ restrictAge?: number;
+ forcedDenomSel?: ForcedDenomSel;
+ },
+): Promise<AcceptManualWithdrawalResult> {
+ const { exchangeBaseUrl } = req;
+ const amount = Amounts.parseOrThrow(req.amount);
+ const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ if (exchange.currency != amount.currency) {
+ throw Error(
+ "manual withdrawal with conversion from foreign currency is not yet supported",
+ );
+ }
+ const reserveKeyPair: EddsaKeypair = await wex.cryptoApi.createEddsaKeypair(
+ {},
+ );
+
+ const withdrawalAccountsList = await fetchWithdrawalAccountInfo(
+ wex,
+ {
+ exchange,
+ instructedAmount: amount,
+ reservePub: reserveKeyPair.pub,
+ },
+ CancellationToken.CONTINUE,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
+ amount: Amounts.jsonifyAmount(req.amount),
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.BankManual,
+ exchangeCreditAccounts: withdrawalAccountsList,
+ },
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair,
+ });
+
+ const ctx = new WithdrawTransactionContext(
+ wex,
+ withdrawalGroup.withdrawalGroupId,
+ );
+
+ const exchangePaytoUris = await wex.db.runReadOnlyTx(
+ ["withdrawalGroups", "exchanges", "exchangeDetails"],
+ async (tx) => {
+ return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
+ },
+ );
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: ctx.transactionId,
+ });
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris: exchangePaytoUris,
+ withdrawalAccountsList: withdrawalAccountsList,
+ transactionId: ctx.transactionId,
+ };
+}
+
+/**
+ * Wait until a refresh operation is final.
+ */
+export async function waitWithdrawalFinal(
+ wex: WalletExecutionContext,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
+ const withdrawalNotifFlag = new AsyncFlag();
+ // Raise purchaseNotifFlag whenever we get a notification
+ // about our refresh.
+ const cancelNotif = wex.ws.addNotificationListener((notif) => {
+ if (
+ notif.type === NotificationType.TransactionStateTransition &&
+ notif.transactionId === ctx.transactionId
+ ) {
+ withdrawalNotifFlag.raise();
+ }
+ });
+ const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
+ cancelNotif();
+ withdrawalNotifFlag.raise();
+ });
+
+ try {
+ await internalWaitWithdrawalFinal(ctx, withdrawalNotifFlag);
+ } catch (e) {
+ unregisterOnCancelled();
+ cancelNotif();
+ }
+}
+
+async function internalWaitWithdrawalFinal(
+ ctx: WithdrawTransactionContext,
+ flag: AsyncFlag,
+): Promise<void> {
+ while (true) {
+ if (ctx.wex.cancellationToken.isCancelled) {
+ throw Error("cancelled");
+ }
+
+ // Check if refresh is final
+ const res = await ctx.wex.db.runReadOnlyTx(
+ ["withdrawalGroups", "operationRetries"],
+ async (tx) => {
+ return {
+ wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
+ };
+ },
+ );
+ const { wg } = res;
+ if (!wg) {
+ // Must've been deleted, we consider that final.
+ return;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.AbortedBank:
+ case WithdrawalGroupStatus.AbortedExchange:
+ case WithdrawalGroupStatus.Done:
+ case WithdrawalGroupStatus.FailedAbortingBank:
+ case WithdrawalGroupStatus.FailedBankAborted:
+ // Transaction is final
+ return;
+ }
+
+ // Wait for the next transition
+ await flag.wait();
+ flag.reset();
+ }
+}
+
+export async function getWithdrawalDetailsForAmount(
+ wex: WalletExecutionContext,
+ cts: CancellationToken.Source,
+ req: GetWithdrawalDetailsForAmountRequest,
+): Promise<WithdrawalDetailsForAmount> {
+ const clientCancelKey = req.clientCancellationId
+ ? `ccid:getWithdrawalDetailsForAmount:${req.clientCancellationId}`
+ : undefined;
+ if (clientCancelKey) {
+ const prevCts = wex.ws.clientCancellationMap.get(clientCancelKey);
+ if (prevCts) {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Cancelling previous key ${clientCancelKey}`,
+ });
+ prevCts.cancel();
+ } else {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `No previous key ${clientCancelKey}`,
+ });
+ }
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Setting clientCancelKey ${clientCancelKey} to ${cts}`,
+ });
+ wex.ws.clientCancellationMap.set(clientCancelKey, cts);
+ }
+ try {
+ return await internalGetWithdrawalDetailsForAmount(wex, req);
+ } finally {
+ wex.oc.observe({
+ type: ObservabilityEventType.Message,
+ contents: `Deleting clientCancelKey ${clientCancelKey} to ${cts}`,
+ });
+ if (clientCancelKey && !cts.token.isCancelled) {
+ wex.ws.clientCancellationMap.delete(clientCancelKey);
+ }
+ }
+}
+
+async function internalGetWithdrawalDetailsForAmount(
+ wex: WalletExecutionContext,
+ req: GetWithdrawalDetailsForAmountRequest,
+): Promise<WithdrawalDetailsForAmount> {
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ req.exchangeBaseUrl,
+ Amounts.parseOrThrow(req.amount),
+ req.restrictAge,
+ );
+ let numCoins = 0;
+ for (const x of wi.selectedDenoms.selectedDenoms) {
+ numCoins += x.count;
+ }
+ const resp: WithdrawalDetailsForAmount = {
+ amountRaw: req.amount,
+ amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
+ paytoUris: wi.exchangePaytoUris,
+ tosAccepted: wi.termsOfServiceAccepted,
+ ageRestrictionOptions: wi.ageRestrictionOptions,
+ withdrawalAccountsList: wi.exchangeCreditAccountDetails,
+ numCoins,
+ scopeInfo: wi.scopeInfo,
+ };
+ return resp;
+}
diff --git a/packages/taler-wallet-core/tsconfig.json b/packages/taler-wallet-core/tsconfig.json
index 3da332364..7a1a0fcce 100644
--- a/packages/taler-wallet-core/tsconfig.json
+++ b/packages/taler-wallet-core/tsconfig.json
@@ -4,16 +4,18 @@
"composite": true,
"declaration": true,
"declarationMap": false,
- "target": "ES2017",
- "module": "ESNext",
- "moduleResolution": "node",
+ "target": "ES2020",
+ "module": "Node16",
+ "moduleResolution": "Node16",
+ "resolveJsonModule": true,
"sourceMap": true,
- "lib": ["es6"],
+ "lib": ["ES2020"],
+ "resolvePackageJsonImports": true,
"types": ["node"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
- "strictPropertyInitialization": false,
+ "strictPropertyInitialization": true,
"outDir": "lib",
"noImplicitAny": true,
"noImplicitThis": true,
@@ -21,7 +23,7 @@
"esModuleInterop": true,
"importHelpers": true,
"rootDir": "./src",
- "typeRoots": ["./node_modules/@types"],
+ "typeRoots": ["./node_modules/@types"]
},
"references": [
{
@@ -31,5 +33,5 @@
"path": "../taler-util/"
}
],
- "include": ["src/**/*"]
+ "include": ["src/**/*", "src/*.json"]
}
diff --git a/packages/taler-wallet-core/watch_test.sh b/packages/taler-wallet-core/watch_test.sh
new file mode 100755
index 000000000..124e18e21
--- /dev/null
+++ b/packages/taler-wallet-core/watch_test.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+./node_modules/typescript/bin/tsc --watch &
+./node_modules/ava/entrypoints/cli.mjs -w "$@"
diff --git a/packages/taler-wallet-embedded/README.md b/packages/taler-wallet-embedded/README.md
new file mode 100644
index 000000000..b59e79e9a
--- /dev/null
+++ b/packages/taler-wallet-embedded/README.md
@@ -0,0 +1,4 @@
+# taler-wallet-embedded
+
+Minimal wrapper and build system code to make `taler-wallet-core` run
+on embedded interpreters (currently `quickjs-tart`).
diff --git a/packages/taler-wallet-embedded/build.mjs b/packages/taler-wallet-embedded/build.mjs
new file mode 100755
index 000000000..f2bf7b986
--- /dev/null
+++ b/packages/taler-wallet-embedded/build.mjs
@@ -0,0 +1,76 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import esbuild from "esbuild";
+import path from "path";
+import fs from "fs";
+
+const BASE = process.cwd();
+
+let GIT_ROOT = BASE;
+while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") {
+ GIT_ROOT = path.join(GIT_ROOT, "../");
+}
+if (GIT_ROOT === "/") {
+ console.log("not found");
+ process.exit(1);
+}
+const GIT_HASH = GIT_ROOT === "/" ? undefined : git_hash();
+
+let _package = JSON.parse(fs.readFileSync(path.join(BASE, "package.json")));
+
+function git_hash() {
+ const rev = fs
+ .readFileSync(path.join(GIT_ROOT, ".git", "HEAD"))
+ .toString()
+ .trim()
+ .split(/.*[: ]/)
+ .slice(-1)[0];
+ if (rev.indexOf("/") === -1) {
+ return rev;
+ } else {
+ return fs
+ .readFileSync(path.join(GIT_ROOT, ".git", rev))
+ .toString()
+ .trim();
+ }
+}
+
+export const buildConfig = {
+ entryPoints: ["src/wallet-qjs.ts"],
+ outfile: "dist/taler-wallet-core-qjs.mjs",
+ bundle: true,
+ minify: false,
+ target: ["es2020"],
+ external: ["os", "std", "better-sqlite3"],
+ format: "esm",
+ platform: "neutral",
+ mainFields: ["module", "main"],
+ conditions: ["qtart"],
+ sourcemap: true,
+ define: {
+ __VERSION__: `"${_package.version}"`,
+ __GIT_HASH__: `"${GIT_HASH}"`,
+ "walletCoreBuildInfo.implementationSemver": `"${_package.version}"`,
+ "walletCoreBuildInfo.implementationGitHash": `"${GIT_HASH}"`,
+ },
+};
+
+esbuild.build(buildConfig).catch((e) => {
+ console.log(e);
+ process.exit(1);
+});
diff --git a/packages/taler-wallet-embedded/package.json b/packages/taler-wallet-embedded/package.json
index 6be21a50d..cca4e6e2a 100644
--- a/packages/taler-wallet-embedded/package.json
+++ b/packages/taler-wallet-embedded/package.json
@@ -1,22 +1,23 @@
{
"name": "@gnu-taler/taler-wallet-embedded",
- "version": "0.8.1",
+ "version": "0.10.6",
"description": "",
"engines": {
- "node": ">=0.12.0"
+ "node": ">=0.18.0"
},
"repository": {
"type": "git",
"url": "git://git.taler.net/wallet-core.git"
},
- "main": "dist/taler-wallet-embedded.js",
"author": "Florian Dold",
"license": "GPL-3.0",
+ "type": "module",
"scripts": {
- "compile": "tsc && rollup -c",
+ "compile": "./build.mjs",
"pretty": "prettier --write src",
- "coverage": "tsc && nyc ava",
- "clean": "rimraf lib dist tsconfig.tsbuildinfo"
+ "typedoc": "typedoc --out dist/typedoc ./src/wallet-qjs.ts",
+ "clean": "rm -rf lib dist tsconfig.tsbuildinfo",
+ "deps": "pnpm install --frozen-lockfile --filter @gnu-taler/taler-wallet-embedded..."
},
"files": [
"AUTHORS",
@@ -27,21 +28,15 @@
"src/"
],
"devDependencies": {
- "@rollup/plugin-commonjs": "^17.0.0",
- "@rollup/plugin-json": "^4.1.0",
- "@rollup/plugin-node-resolve": "^11.1.0",
- "@rollup/plugin-replace": "^2.3.4",
- "@types/node": "^14.14.22",
- "prettier": "^2.2.1",
- "rimraf": "^3.0.2",
- "rollup": "^2.43.0",
- "rollup-plugin-sourcemaps": "^0.6.3",
- "rollup-plugin-terser": "^7.0.2",
- "typescript": "^4.2.3"
+ "@types/node": "^18.11.17",
+ "esbuild": "^0.19.9",
+ "prettier": "^3.1.1"
},
"dependencies": {
- "@gnu-taler/taler-wallet-core": "workspace:*",
+ "@gnu-taler/anastasis-core": "workspace:*",
+ "@gnu-taler/idb-bridge": "workspace:*",
"@gnu-taler/taler-util": "workspace:*",
- "tslib": "^2.1.0"
+ "@gnu-taler/taler-wallet-core": "workspace:*",
+ "tslib": "^2.6.2"
}
}
diff --git a/packages/taler-wallet-embedded/rollup.config.js b/packages/taler-wallet-embedded/rollup.config.js
deleted file mode 100644
index 7cdca3b98..000000000
--- a/packages/taler-wallet-embedded/rollup.config.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// rollup.config.js
-import commonjs from "@rollup/plugin-commonjs";
-import nodeResolve from "@rollup/plugin-node-resolve";
-import json from "@rollup/plugin-json";
-import builtins from "builtin-modules";
-import pkg from "./package.json";
-
-export default {
- input: "lib/index.js",
- output: {
- file: pkg.main,
- format: "cjs",
- },
- external: builtins,
- plugins: [
- nodeResolve({
- preferBuiltins: true,
- }),
-
- commonjs({
- include: [/node_modules/, /dist/],
- extensions: [".js", ".ts"],
- ignoreGlobal: false,
- sourceMap: false,
- }),
-
- json(),
- ],
-}
-
diff --git a/packages/taler-wallet-embedded/src/index.ts b/packages/taler-wallet-embedded/src/index.ts
deleted file mode 100644
index e01281bc3..000000000
--- a/packages/taler-wallet-embedded/src/index.ts
+++ /dev/null
@@ -1,288 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- getDefaultNodeWallet,
- DefaultNodeWalletArgs,
- NodeHttpLib,
- makeErrorDetails,
- handleWorkerError,
- handleWorkerMessage,
- HttpRequestLibrary,
- OpenedPromise,
- HttpResponse,
- HttpRequestOptions,
- openPromise,
- Headers,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
- WALLET_MERCHANT_PROTOCOL_VERSION,
- Wallet,
-} from "@gnu-taler/taler-wallet-core";
-
-import fs from "fs";
-import {
- CoreApiEnvelope,
- CoreApiResponse,
- CoreApiResponseSuccess,
- WalletNotification,
- TalerErrorCode,
-} from "@gnu-taler/taler-util";
-
-export { handleWorkerError, handleWorkerMessage };
-
-export class NativeHttpLib implements HttpRequestLibrary {
- useNfcTunnel = false;
-
- private nodeHttpLib: HttpRequestLibrary = new NodeHttpLib();
-
- private requestId = 1;
-
- private requestMap: {
- [id: number]: OpenedPromise<HttpResponse>;
- } = {};
-
- constructor(private sendMessage: (m: string) => void) {}
-
- fetch(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
- return this.nodeHttpLib.fetch(url, opt);
- }
-
- get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
- if (this.useNfcTunnel) {
- const myId = this.requestId++;
- const p = openPromise<HttpResponse>();
- this.requestMap[myId] = p;
- const request = {
- method: "get",
- url,
- };
- this.sendMessage(
- JSON.stringify({
- type: "tunnelHttp",
- request,
- id: myId,
- }),
- );
- return p.promise;
- } else {
- return this.nodeHttpLib.get(url, opt);
- }
- }
-
- postJson(
- url: string,
- body: any,
- opt?: HttpRequestOptions,
- ): Promise<HttpResponse> {
- if (this.useNfcTunnel) {
- const myId = this.requestId++;
- const p = openPromise<HttpResponse>();
- this.requestMap[myId] = p;
- const request = {
- method: "postJson",
- url,
- body,
- };
- this.sendMessage(
- JSON.stringify({ type: "tunnelHttp", request, id: myId }),
- );
- return p.promise;
- } else {
- return this.nodeHttpLib.postJson(url, body, opt);
- }
- }
-
- handleTunnelResponse(msg: any): void {
- const myId = msg.id;
- const p = this.requestMap[myId];
- if (!p) {
- console.error(
- `no matching request for tunneled HTTP response, id=${myId}`,
- );
- }
- const headers = new Headers();
- if (msg.status != 0) {
- const resp: HttpResponse = {
- // FIXME: pass through this URL
- requestUrl: "",
- headers,
- status: msg.status,
- requestMethod: "FIXME",
- json: async () => JSON.parse(msg.responseText),
- text: async () => msg.responseText,
- bytes: async () => {
- throw Error("bytes() not supported for tunnel response");
- },
- };
- p.resolve(resp);
- } else {
- p.reject(new Error(`unexpected HTTP status code ${msg.status}`));
- }
- delete this.requestMap[myId];
- }
-}
-
-function sendNativeMessage(ev: CoreApiEnvelope): void {
- // @ts-ignore
- const sendMessage = globalThis.__native_sendMessage;
- if (typeof sendMessage !== "function") {
- const errMsg =
- "FATAL: cannot install native wallet listener: native functions missing";
- console.error(errMsg);
- throw new Error(errMsg);
- }
- const m = JSON.stringify(ev);
- // @ts-ignore
- sendMessage(m);
-}
-
-class NativeWalletMessageHandler {
- walletArgs: DefaultNodeWalletArgs | undefined;
- maybeWallet: Wallet | undefined;
- wp = openPromise<Wallet>();
- httpLib = new NodeHttpLib();
-
- /**
- * Handle a request from the native wallet.
- */
- async handleMessage(
- operation: string,
- id: string,
- args: any,
- ): Promise<CoreApiResponse> {
- const wrapResponse = (result: unknown): CoreApiResponseSuccess => {
- return {
- type: "response",
- id,
- operation,
- result,
- };
- };
-
- const reinit = async () => {
- const w = await getDefaultNodeWallet(this.walletArgs);
- this.maybeWallet = w;
- await w.handleCoreApiRequest("initWallet", "native-init", {});
- w.runTaskLoop().catch((e) => {
- console.error("Error during wallet retry loop", e);
- });
- this.wp.resolve(w);
- };
-
- switch (operation) {
- case "init": {
- this.walletArgs = {
- notifyHandler: async (notification: WalletNotification) => {
- sendNativeMessage({ type: "notification", payload: notification });
- },
- persistentStoragePath: args.persistentStoragePath,
- httpLib: this.httpLib,
- };
- await reinit();
- return wrapResponse({
- supported_protocol_versions: {
- exchange: WALLET_EXCHANGE_PROTOCOL_VERSION,
- merchant: WALLET_MERCHANT_PROTOCOL_VERSION,
- },
- });
- }
- case "startTunnel": {
- // this.httpLib.useNfcTunnel = true;
- throw Error("not implemented");
- }
- case "stopTunnel": {
- // this.httpLib.useNfcTunnel = false;
- throw Error("not implemented");
- }
- case "tunnelResponse": {
- // httpLib.handleTunnelResponse(msg.args);
- throw Error("not implemented");
- }
- case "reset": {
- const oldArgs = this.walletArgs;
- this.walletArgs = { ...oldArgs };
- if (oldArgs && oldArgs.persistentStoragePath) {
- try {
- fs.unlinkSync(oldArgs.persistentStoragePath);
- } catch (e) {
- console.error("Error while deleting the wallet db:", e);
- }
- // Prevent further storage!
- this.walletArgs.persistentStoragePath = undefined;
- }
- const wallet = await this.wp.promise;
- wallet.stop();
- this.wp = openPromise<Wallet>();
- this.maybeWallet = undefined;
- await reinit();
- return wrapResponse({});
- }
- default: {
- const wallet = await this.wp.promise;
- return await wallet.handleCoreApiRequest(operation, id, args);
- }
- }
- }
-}
-
-export function installNativeWalletListener(): void {
- const handler = new NativeWalletMessageHandler();
- const onMessage = async (msgStr: any): Promise<void> => {
- if (typeof msgStr !== "string") {
- console.error("expected string as message");
- return;
- }
- const msg = JSON.parse(msgStr);
- const operation = msg.operation;
- if (typeof operation !== "string") {
- console.error(
- "message to native wallet helper must contain operation of type string",
- );
- return;
- }
- const id = msg.id;
- console.log(`native listener: got request for ${operation} (${id})`);
-
- try {
- const respMsg = await handler.handleMessage(operation, id, msg.args);
- console.log(
- `native listener: sending success response for ${operation} (${id})`,
- );
- sendNativeMessage(respMsg);
- } catch (e) {
- const respMsg: CoreApiResponse = {
- type: "error",
- id,
- operation,
- error: makeErrorDetails(
- TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
- "unexpected exception",
- {},
- ),
- };
- sendNativeMessage(respMsg);
- return;
- }
- };
-
- // @ts-ignore
- globalThis.__native_onMessage = onMessage;
-
- console.log("native wallet listener installed");
-}
diff --git a/packages/taler-wallet-embedded/src/wallet-qjs.ts b/packages/taler-wallet-embedded/src/wallet-qjs.ts
new file mode 100644
index 000000000..8502c779a
--- /dev/null
+++ b/packages/taler-wallet-embedded/src/wallet-qjs.ts
@@ -0,0 +1,392 @@
+/*
+ This file is part of GNU Taler
+ (C) 2019 GNUnet e.V.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Entry-point for the wallet under qtart, the QuickJS-based GNU Taler runtime.
+ */
+
+/**
+ * Imports.
+ */
+import {
+ discoverPolicies,
+ getBackupStartState,
+ getRecoveryStartState,
+ mergeDiscoveryAggregate,
+ reduceAction,
+} from "@gnu-taler/anastasis-core";
+import { userIdentifierDerive } from "@gnu-taler/anastasis-core/lib/crypto.js";
+import {
+ AmountString,
+ CoreApiMessageEnvelope,
+ CoreApiResponse,
+ CoreApiResponseSuccess,
+ Logger,
+ PartialWalletRunConfig,
+ WalletNotification,
+ enableNativeLogging,
+ getErrorDetailFromException,
+ j2s,
+ openPromise,
+ performanceNow,
+ setGlobalLogLevelFromString,
+} from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import { qjsOs } from "@gnu-taler/taler-util/qtart";
+import {
+ DefaultNodeWalletArgs,
+ Wallet,
+ WalletApiOperation,
+ createNativeWalletHost2,
+} from "@gnu-taler/taler-wallet-core";
+
+setGlobalLogLevelFromString("trace");
+
+const logger = new Logger("taler-wallet-embedded/index.ts");
+
+/**
+ * Sends JSON to the host application, i.e. the process that
+ * runs the JavaScript interpreter (quickjs / qtart) to run
+ * the embedded wallet.
+ */
+function sendNativeMessage(ev: CoreApiMessageEnvelope): void {
+ const m = JSON.stringify(ev);
+ qjsOs.postMessageToHost(m);
+}
+
+class NativeWalletMessageHandler {
+ walletArgs: DefaultNodeWalletArgs | undefined;
+ walletConfig: PartialWalletRunConfig | undefined;
+ maybeWallet: Wallet | undefined;
+ wp = openPromise<Wallet>();
+ httpLib = createPlatformHttpLib();
+
+ /**
+ * Handle a request from the native wallet.
+ */
+ async handleMessage(
+ operation: string,
+ id: string,
+ args: any,
+ ): Promise<CoreApiResponse> {
+ const wrapSuccessResponse = (result: unknown): CoreApiResponseSuccess => {
+ return {
+ type: "response",
+ id,
+ operation,
+ result,
+ };
+ };
+
+ let initResponse: any = {};
+
+ const reinit = async () => {
+ logger.info("in reinit");
+ const wR = await createNativeWalletHost2(this.walletArgs);
+ const w = wR.wallet;
+ this.maybeWallet = w;
+ const resp = await w.handleCoreApiRequest(
+ "initWallet",
+ "native-init",
+ {
+ config: this.walletConfig
+ },
+ );
+ initResponse = resp.type == "response" ? resp.result : resp.error;
+ w.runTaskLoop().catch((e) => {
+ logger.error(
+ `Error during wallet retry loop: ${e.stack ?? e.toString()}`,
+ );
+ });
+ this.wp.resolve(w);
+ };
+
+ switch (operation) {
+ case "init": {
+ this.walletArgs = {
+ notifyHandler: async (notification: WalletNotification) => {
+ sendNativeMessage({ type: "notification", payload: notification });
+ },
+ persistentStoragePath: args.persistentStoragePath,
+ httpLib: this.httpLib,
+ cryptoWorkerType: args.cryptoWorkerType,
+ ...args,
+ };
+ this.walletConfig = args.config ?? {};
+ const logLevel = args.logLevel;
+ if (logLevel) {
+ setGlobalLogLevelFromString(logLevel);
+ }
+ const nativeLogging = args.useNativeLogging ?? false;
+ if (nativeLogging) {
+ enableNativeLogging();
+ }
+ await reinit();
+ return wrapSuccessResponse({
+ ...initResponse,
+ });
+ }
+ case "startTunnel": {
+ // this.httpLib.useNfcTunnel = true;
+ throw Error("not implemented");
+ }
+ case "stopTunnel": {
+ // this.httpLib.useNfcTunnel = false;
+ throw Error("not implemented");
+ }
+ case "tunnelResponse": {
+ // httpLib.handleTunnelResponse(msg.args);
+ throw Error("not implemented");
+ }
+ case "reset": {
+ throw Error(
+ "reset not supported anymore, please use the clearDb wallet-core request",
+ );
+ }
+ default: {
+ const wallet = await this.wp.promise;
+ return await wallet.handleCoreApiRequest(operation, id, args);
+ }
+ }
+ }
+}
+
+/**
+ * Handle an Anastasis request from the native app.
+ */
+async function handleAnastasisRequest(
+ operation: string,
+ id: string,
+ args: any,
+): Promise<CoreApiResponse> {
+ const wrapSuccessResponse = (result: unknown): CoreApiResponseSuccess => {
+ return {
+ type: "response",
+ id,
+ operation,
+ result,
+ };
+ };
+
+ let req = args ?? {};
+
+ switch (operation) {
+ case "anastasisReduce":
+ // TODO: do some input validation here
+ let reduceRes = await reduceAction(req.state, req.action, req.args ?? {});
+ // For now, this will return "success" even if the wrapped Anastasis
+ // response is a ReducerStateError.
+ return wrapSuccessResponse(reduceRes);
+ case "anastasisStartBackup":
+ return wrapSuccessResponse(await getBackupStartState());
+ case "anastasisStartRecovery":
+ return wrapSuccessResponse(await getRecoveryStartState());
+ case "anastasisDiscoverPolicies":
+ let discoverRes = await discoverPolicies(req.state, req.cursor);
+ let aggregatedPolicies = mergeDiscoveryAggregate(
+ discoverRes.policies ?? [],
+ req.state.discoveryState?.aggregatedPolicies ?? [],
+ );
+ return wrapSuccessResponse({
+ ...req.state,
+ discoveryState: {
+ state: "finished",
+ aggregatedPolicies,
+ cursor: discoverRes.cursor,
+ },
+ });
+ default:
+ throw Error("unsupported anastasis operation");
+ }
+}
+
+export function installNativeWalletListener(): void {
+ setGlobalLogLevelFromString("trace");
+ const handler = new NativeWalletMessageHandler();
+ const onMessage = async (msgStr: any): Promise<void> => {
+ if (typeof msgStr !== "string") {
+ logger.error("expected string as message");
+ return;
+ }
+ const msg = JSON.parse(msgStr);
+ const operation = msg.operation;
+ if (typeof operation !== "string") {
+ logger.error(
+ "message to native wallet helper must contain operation of type string",
+ );
+ return;
+ }
+ const id = msg.id;
+ logger.info(`native listener: got request for ${operation} (${id})`);
+
+ const startTimeNs = performanceNow();
+
+ let respMsg: CoreApiResponse;
+ try {
+ if (msg.operation.startsWith("anastasis")) {
+ respMsg = await handleAnastasisRequest(operation, id, msg.args ?? {});
+ } else if (msg.operation === "testing-dangerously-eval") {
+ // Eval code, used only for testing. No client may rely on this.
+ logger.info(`evaluating ${msg.args.jscode}`);
+ const f = new Function(msg.args.jscode);
+ f();
+ respMsg = {
+ type: "response",
+ result: {},
+ operation: "testing-dangerously-eval",
+ id: msg.id,
+ };
+ }
+ {
+ respMsg = await handler.handleMessage(operation, id, msg.args ?? {});
+ }
+ } catch (e) {
+ respMsg = {
+ type: "error",
+ id,
+ operation,
+ error: getErrorDetailFromException(e),
+ };
+ }
+ const endTimeNs = performanceNow();
+ const requestDurationMs = Math.round(
+ Number((endTimeNs - startTimeNs) / 1000n / 1000n),
+ );
+ logger.info(
+ `native listener: sending back ${respMsg.type} message for operation ${operation} (${id}) after ${requestDurationMs} ms`,
+ );
+ sendNativeMessage(respMsg);
+ };
+
+ qjsOs.setMessageFromHostHandler((m) => onMessage(m));
+
+ logger.info("native wallet listener installed");
+}
+
+// @ts-ignore
+globalThis.installNativeWalletListener = installNativeWalletListener;
+
+export async function testWithGv() {
+ const w = await createNativeWalletHost2({});
+ await w.wallet.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ features: {
+ allowHttp: true,
+ },
+ },
+ });
+ await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, {
+ amountToSpend: "KUDOS:1" as AmountString,
+ amountToWithdraw: "KUDOS:3" as AmountString,
+ corebankApiBaseUrl: "https://bank.demo.taler.net/",
+ exchangeBaseUrl: "https://exchange.demo.taler.net/",
+ merchantBaseUrl: "https://backend.demo.taler.net/",
+ merchantAuthToken: "secret-token:sandbox",
+ });
+ await w.wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+}
+
+export async function testWithFdold() {
+ const w = await createNativeWalletHost2({});
+ await w.wallet.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ features: {
+ allowHttp: true,
+ },
+ },
+ });
+ await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, {
+ amountToSpend: "TESTKUDOS:1" as AmountString,
+ amountToWithdraw: "TESTKUDOS:3" as AmountString,
+ corebankApiBaseUrl: "https://bank.taler.fdold.eu/",
+ exchangeBaseUrl: "https://exchange.taler.fdold.eu/",
+ merchantBaseUrl: "https://merchant.taler.fdold.eu/",
+ });
+ await w.wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+}
+
+export async function testWithLocal(path: string) {
+ console.log("running local test");
+ const w = await createNativeWalletHost2({
+ persistentStoragePath: path ?? "walletdb.json",
+ });
+ console.log("created wallet");
+ await w.wallet.client.call(WalletApiOperation.InitWallet, {
+ config: {
+ features: {
+ allowHttp: true,
+ },
+ testing: {
+ skipDefaults: true,
+ },
+ },
+ });
+ console.log("initialized wallet");
+ await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, {
+ amountToSpend: "TESTKUDOS:1" as AmountString,
+ amountToWithdraw: "TESTKUDOS:3" as AmountString,
+ corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/",
+ exchangeBaseUrl: "http://localhost:8081/",
+ merchantBaseUrl: "http://localhost:8083/",
+ });
+ console.log("started integration test");
+ await w.wallet.runTaskLoop({
+ stopWhenDone: true,
+ });
+ console.log("done with task loop");
+ w.wallet.stop();
+ console.log("DB stats:", j2s(w.getDbStats()));
+}
+
+export async function testArgon2id() {
+ const userIdVector = {
+ input_id_data: {
+ name: "Fleabag",
+ ssn: "AB123",
+ },
+ input_server_salt: "FZ48EFS7WS3R2ZR4V53A3GFFY4",
+ output_id:
+ "YS45R6CGJV84K1NN7T14ZBCPVTZ6H15XJSM1FV0R748MHPV82SM0126EBZKBAAGCR34Q9AFKPEW1HRT2Q9GQ5JRA3642AB571DKZS18",
+ };
+
+ if (
+ (await userIdentifierDerive(
+ userIdVector.input_id_data,
+ userIdVector.input_server_salt,
+ )) != userIdVector.output_id
+ ) {
+ throw Error("argon2id is not working!");
+ }
+
+ console.log("argon2id is working!");
+}
+
+// @ts-ignore
+globalThis.testWithGv = testWithGv;
+// @ts-ignore
+globalThis.testWithLocal = testWithLocal;
+// @ts-ignore
+globalThis.testArgon2id = testArgon2id;
+// @ts-ignore
+globalThis.testReduceAction = reduceAction;
+// @ts-ignore
+globalThis.testDiscoverPolicies = discoverPolicies;
+// @ts-ignore
+globalThis.testWithFdold = testWithFdold;
diff --git a/packages/taler-wallet-embedded/tsconfig.json b/packages/taler-wallet-embedded/tsconfig.json
index fa759bdaa..3dd8cdcb2 100644
--- a/packages/taler-wallet-embedded/tsconfig.json
+++ b/packages/taler-wallet-embedded/tsconfig.json
@@ -4,11 +4,11 @@
"composite": true,
"declaration": true,
"declarationMap": true,
- "target": "ES6",
- "module": "ESNext",
- "moduleResolution": "node",
+ "target": "ES2020",
+ "module": "Node16",
+ "moduleResolution": "Node16",
"sourceMap": true,
- "lib": ["es6"],
+ "lib": ["ES2020"],
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
diff --git a/packages/taler-wallet-webextension/.eslintrc.cjs b/packages/taler-wallet-webextension/.eslintrc.cjs
new file mode 100644
index 000000000..05618b499
--- /dev/null
+++ b/packages/taler-wallet-webextension/.eslintrc.cjs
@@ -0,0 +1,28 @@
+module.exports = {
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react/recommended',
+ ],
+ parser: '@typescript-eslint/parser',
+ plugins: ['@typescript-eslint', 'header'],
+ root: true,
+ rules: {
+ "react/no-unknown-property": 0,
+ "react/no-unescaped-entities": 0,
+ "@typescript-eslint/no-namespace": 0,
+ "@typescript-eslint/no-unused-vars": [2,{argsIgnorePattern:"^_"}],
+ "header/header": [2,"copyleft-header.js"]
+ },
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ jsx: true,
+ },
+ settings: {
+ react: {
+ version: "18",
+ pragma: "h",
+ }
+ },
+};
diff --git a/packages/taler-wallet-webextension/.gitignore b/packages/taler-wallet-webextension/.gitignore
index 2897bd5d0..9e7c76524 100644
--- a/packages/taler-wallet-webextension/.gitignore
+++ b/packages/taler-wallet-webextension/.gitignore
@@ -2,3 +2,4 @@ extension/
/storybook-static/
/.linaria-cache/
/lib
+/coverage
diff --git a/packages/taler-wallet-webextension/.storybook/main.js b/packages/taler-wallet-webextension/.storybook/main.js
deleted file mode 100644
index cd58d4d1d..000000000
--- a/packages/taler-wallet-webextension/.storybook/main.js
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-
-module.exports = {
- "stories": [
- "../src/**/*.stories.tsx",
- ],
- "addons": [
- "storybook-dark-mode",
- "@storybook/addon-a11y",
- "@storybook/addon-essentials" //docs, control, actions, viewport, toolbar, background
- ],
- // sb does not yet support new jsx transform by default
- // https://github.com/storybookjs/storybook/issues/12881
- // https://github.com/storybookjs/storybook/issues/12952
- babel: async (options) => ({
- ...options,
- presets: [
- ...options.presets,
- [
- '@babel/preset-react', {
- runtime: 'automatic',
- },
- 'preset-react-jsx-transform'
- ],
- "@linaria",
- ],
- }),
- webpackFinal: (config) => {
- // should be removed after storybook 6.3
- // https://github.com/storybookjs/storybook/issues/12853#issuecomment-821576113
- config.resolve.alias = {
- react: "preact/compat",
- "react-dom": "preact/compat",
- };
-
- // we need to add @linaria loader AFTER the babel-loader
- // https://github.com/callstack/linaria/blob/master/docs/BUNDLERS_INTEGRATION.md#webpack
- config.module.rules[0] = {
- ...(config.module.rules[0]),
- loader: undefined, // Disable the predefined babel-loader on the rule
- use: [
- {
- ...(config.module.rules[0].use[0]),
- loader: 'babel-loader',
- },
- {
- loader: '@linaria/webpack-loader',
- options: {
- sourceMap: true, //always true since this is dev
- babelOptions: {
- presets: config.module.rules[0].use[0].options.presets,
- }
- // Pass the current babel options to linaria's babel instance
- }
- }
- ]
- };
-
- return config;
- },
-}
diff --git a/packages/taler-wallet-webextension/.storybook/preview.js b/packages/taler-wallet-webextension/.storybook/preview.js
deleted file mode 100644
index 0efb96308..000000000
--- a/packages/taler-wallet-webextension/.storybook/preview.js
+++ /dev/null
@@ -1,171 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { h, Fragment } from "preact"
-import { NavBar } from '../src/NavigationBar'
-import { LogoHeader } from '../src/components/LogoHeader'
-import { TranslationProvider } from '../src/context/translation'
-
-export const parameters = {
- controls: { expanded: true },
- actions: { argTypesRegex: "^on[A-Z].*" },
-}
-
-export const globalTypes = {
- locale: {
- name: 'Locale',
- description: 'Internationalization locale',
- defaultValue: 'en',
- toolbar: {
- icon: 'globe',
- items: [
- { value: 'en', right: '🇺🇸', title: 'English' },
- { value: 'de', right: '🇪🇸', title: 'German' },
- ],
- },
- },
-};
-
-
-
-export const decorators = [
- (Story, { kind }) => {
- if (kind.startsWith('popup')) {
-
- function Body() {
- const isTestingHeader = (/.*\/header\/?.*/.test(kind));
- if (isTestingHeader) {
- // simple box with correct width and height
- return <div style={{ width: 400, height: 320 }}>
- <Story />
- </div>
- }
-
- const path = /popup(\/.*).*/.exec(kind)[1];
- // add a fake header so it looks similar
- return <Fragment>
- <NavBar path={path} devMode={path === '/dev'} />
- <div style={{ width: 400, height: 290 }}>
- <Story />
- </div>
- </Fragment>
- }
-
- return <div class="popup-container">
- <style>{`
- html {
- font-family: sans-serif; /* 1 */
- }
- body {
- margin: 0;
- }`}
- </style>
- <style>{`
- html {
- }
- h1 {
- font-size: 2em;
- }
- input {
- font: inherit;
- }
- body {
- margin: 0;
- font-size: 100%;
- padding: 0;
- overflow: hidden;
- background-color: #f8faf7;
- font-family: Arial, Helvetica, sans-serif;
- }`}
- </style>
- <div style={{ width: 400, border: 'black solid 1px' }}>
- <Body />
- </div>
- </div>
- }
- if (kind.startsWith('cta')) {
- return <div>
- <style>{`
- html {
- font-family: sans-serif; /* 1 */
- }
- body {
- margin: 0;
- }`}
- </style>
- <style>{`
- html {
- }
- h1 {
- font-size: 2em;
- }
- input {
- font: inherit;
- }
- body {
- margin: 0;
- font-size: 100%;
- padding: 0;
- font-family: Arial, Helvetica, sans-serif;
- }`}
- </style>
- <link key="1" rel="stylesheet" type="text/css" href="/static/style/pure.css" />
- <link key="2" rel="stylesheet" type="text/css" href="/static/style/wallet.css" />
- <Story />
- </div>
- }
- if (kind.startsWith('wallet')) {
- const path = /wallet(\/.*).*/.exec(kind)[1];
- return <div class="wallet-container">
- <style>{`
- html {
- font-family: sans-serif; /* 1 */
- }
- body {
- margin: 0;
- }`}
- </style>
- <style>{`
- html {
- }
- h1 {
- font-size: 2em;
- }
- input {
- font: inherit;
- }
- body {
- margin: 0;
- font-size: 100%;
- padding: 0;
- background-color: #f8faf7;
- font-family: Arial, Helvetica, sans-serif;
- }`}
- </style>
- <LogoHeader />
- <NavBar path={path} devMode={path === '/dev'} />
- <Story />
- </div>
- }
- return <div>
- <h1>this story is not under wallet or popup, check title property</h1>
- <Story />
- </div>
- },
- (Story, { globals }) => <TranslationProvider initial='en' forceLang={globals.locale}>
- <Story />
- </TranslationProvider>,
-];
diff --git a/packages/taler-wallet-webextension/README.md b/packages/taler-wallet-webextension/README.md
new file mode 100644
index 000000000..a29147c0d
--- /dev/null
+++ b/packages/taler-wallet-webextension/README.md
@@ -0,0 +1,4 @@
+# taler-wallet-webextension
+
+This package contains the implementation of the GNU Taler wallet browser extension,
+using the cross-browser WebExtension APIs.
diff --git a/packages/taler-wallet-webextension/build.mjs b/packages/taler-wallet-webextension/build.mjs
new file mode 100755
index 000000000..e1fd5c0e1
--- /dev/null
+++ b/packages/taler-wallet-webextension/build.mjs
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build } from "@gnu-taler/web-util/build";
+import linaria from "@linaria/esbuild";
+import { shakerPlugin } from "@linaria/shaker";
+
+await build({
+ type: "production",
+ source: {
+ js: [
+ "src/popupEntryPoint.tsx",
+ "src/walletEntryPoint.tsx",
+ "src/background.ts",
+ "src/taler-wallet-interaction-loader.ts",
+ "src/taler-wallet-interaction-support.ts",
+ "src/browserWorkerEntry.ts",
+ ],
+ },
+ destination: "./dist/prod",
+ css: "linaria",
+ linariaPlugin: () => {
+ // linaria has a bug if this run in web-util library
+ return linaria({
+ babelOptions: {
+ presets: [
+ "@babel/preset-typescript",
+ "@babel/preset-react",
+ "@linaria",
+ ],
+ },
+ // sourceMap: true,
+ });
+ },
+});
diff --git a/packages/taler-wallet-webextension/clean_and_build.sh b/packages/taler-wallet-webextension/clean_and_build.sh
index fb8b31c7e..fa8d514f2 100755
--- a/packages/taler-wallet-webextension/clean_and_build.sh
+++ b/packages/taler-wallet-webextension/clean_and_build.sh
@@ -1,6 +1,18 @@
-#!/usr/bin/env bash
-# This file is in the public domain.
-[ "also-wallet" == "$1" ] && { pnpm -C ../taler-wallet-core/ compile || exit 1; }
-[ "also-util" == "$1" ] && { pnpm -C ../taler-util/ prepare || exit 1; }
-pnpm clean && pnpm compile && rm -rf extension/ && ./pack.sh && (cd extension/ && unzip taler*.zip)
+#!/bin/bash
+set -e
+
+rm -rf dist lib tsconfig.tsbuildinfo .linaria-cache
+
+echo typecheck and bundle...
+node build.mjs &
+pnpm tsc --noEmit &
+wait -n
+wait -n
+
+echo testing...
+pnpm test -- -R dot
+
+echo packing...
+rm -rf extension/
+./pack.sh dev
diff --git a/packages/taler-wallet-webextension/clean_and_build_fast.sh b/packages/taler-wallet-webextension/clean_and_build_fast.sh
deleted file mode 100755
index 707070437..000000000
--- a/packages/taler-wallet-webextension/clean_and_build_fast.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/usr/bin/env bash
-# This file is in the public domain.
-rm -rf dist lib tsconfig.tsbuildinfo && (cd ../.. && rm -rf build/web && ./contrib/build-fast-web.sh) && rm -rf extension/ && ./pack.sh && (cd extension/ && unzip taler*.zip)
-
diff --git a/packages/taler-wallet-webextension/copyleft-header.js b/packages/taler-wallet-webextension/copyleft-header.js
new file mode 100644
index 000000000..cb788e5a1
--- /dev/null
+++ b/packages/taler-wallet-webextension/copyleft-header.js
@@ -0,0 +1,15 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */ \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/dev.mjs b/packages/taler-wallet-webextension/dev.mjs
new file mode 100755
index 000000000..dc597c248
--- /dev/null
+++ b/packages/taler-wallet-webextension/dev.mjs
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { getFilesInDirectory, initializeDev } from "@gnu-taler/web-util/build";
+import { serve } from "@gnu-taler/web-util/node";
+import linaria from "@linaria/esbuild";
+
+const allStaticFiles = getFilesInDirectory("src/pwa");
+
+const devEntryPoints = [
+ "src/popupEntryPoint.dev.tsx",
+ "src/walletEntryPoint.dev.tsx",
+ "src/background.dev.ts",
+ "src/browserWorkerEntry.ts",
+ "src/stories.tsx",
+];
+
+const build = initializeDev({
+ type: "development",
+ source: {
+ js: devEntryPoints,
+ assets: allStaticFiles,
+ },
+ destination: "./dist/dev",
+ public: "/app",
+ css: "linaria",
+ linariaPlugin: () => {
+ // linaria has a bug if this run in web-util library
+ return linaria({
+ babelOptions: {
+ presets: [
+ "@babel/preset-typescript",
+ "@babel/preset-react",
+ "@linaria",
+ ],
+ },
+ sourceMap: true,
+ });
+ },
+});
+
+await build();
+
+serve({
+ folder: "./dist/dev",
+ port: 8080,
+ source: "./src",
+ onSourceUpdate: build,
+});
+
+// FIXME: create a mocha test in the browser as it was before
+
+// fs.writeFileSync("dev-html/manifest.json", fs.readFileSync("manifest-v2.json"))
+// fs.writeFileSync("dev-html/mocha.css", fs.readFileSync("node_modules/mocha/mocha.css"))
+// fs.writeFileSync("dev-html/mocha.js", fs.readFileSync("node_modules/mocha/mocha.js"))
+// fs.writeFileSync("dev-html/mocha.js.map", fs.readFileSync("node_modules/mocha/mocha.js.map"))
diff --git a/packages/taler-wallet-webextension/manifest-common.json b/packages/taler-wallet-webextension/manifest-common.json
new file mode 100644
index 000000000..2b06acd6d
--- /dev/null
+++ b/packages/taler-wallet-webextension/manifest-common.json
@@ -0,0 +1,18 @@
+{
+ "name": "GNU Taler Wallet (git)",
+ "description": "Privacy preserving and transparent payments",
+ "author": "GNU Taler Developers",
+ "version": "0.10.6",
+ "icons": {
+ "16": "static/img/taler-logo-16.png",
+ "19": "static/img/taler-logo-19.png",
+ "32": "static/img/taler-logo-32.png",
+ "38": "static/img/taler-logo-38.png",
+ "48": "static/img/taler-logo-48.png",
+ "64": "static/img/taler-logo-64.png",
+ "128": "static/img/taler-logo-128.png",
+ "256": "static/img/taler-logo-256.png",
+ "512": "static/img/taler-logo-512.png"
+ },
+ "version_name": "0.10.6"
+}
diff --git a/packages/taler-wallet-webextension/manifest-v2.json b/packages/taler-wallet-webextension/manifest-v2.json
new file mode 100644
index 000000000..6f2096b05
--- /dev/null
+++ b/packages/taler-wallet-webextension/manifest-v2.json
@@ -0,0 +1,85 @@
+{
+ "manifest_version": 2,
+ "minimum_chrome_version": "51",
+ "minimum_opera_version": "36",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "wallet@taler.net",
+ "strict_min_version": "57.0"
+ }
+ },
+ "commands": {
+ "_execute_browser_action": {
+ "suggested_key": {
+ "default": "Alt+W"
+ }
+ }
+ },
+ "permissions": [
+ "unlimitedStorage",
+ "storage",
+ "<all_urls>",
+ "activeTab"
+ ],
+ "web_accessible_resources": [
+ "static/wallet.html",
+ "dist/taler-wallet-interaction-loader.js.map",
+ "dist/taler-wallet-interaction-loader.js",
+ "dist/taler-wallet-interaction-support.js.map",
+ "dist/taler-wallet-interaction-support.js"
+ ],
+ "content_scripts": [
+ {
+ "matches": [
+ "file://*/*",
+ "http://*/*",
+ "https://*/*"
+ ],
+ "js": [
+ "dist/taler-wallet-interaction-loader.js"
+ ],
+ "run_at": "document_start"
+ }
+ ],
+ "protocol_handlers": [
+ {
+ "protocol": "ext+taler+http",
+ "name": "Taler Wallet WebExtension",
+ "uriTemplate": "/static/wallet.html#/cta/taler-uri/%s"
+ },
+ {
+ "protocol": "web+taler+http",
+ "name": "Taler Wallet WebExtension",
+ "uriTemplate": "/static/wallet.html#/cta/taler-uri/%s"
+ },
+ {
+ "protocol": "ext+taler",
+ "name": "Taler Wallet WebExtension",
+ "uriTemplate": "/static/wallet.html#/cta/taler-uri/%s"
+ },
+ {
+ "protocol": "web+taler",
+ "name": "Taler Wallet WebExtension",
+ "uriTemplate": "/static/wallet.html#/cta/taler-uri/%s"
+ }
+ ],
+ "browser_action": {
+ "default_icon": {
+ "16": "static/img/taler-logo-16.png",
+ "19": "static/img/taler-logo-19.png",
+ "32": "static/img/taler-logo-32.png",
+ "38": "static/img/taler-logo-38.png",
+ "48": "static/img/taler-logo-48.png",
+ "64": "static/img/taler-logo-64.png",
+ "128": "static/img/taler-logo-128.png",
+ "256": "static/img/taler-logo-256.png",
+ "512": "static/img/taler-logo-512.png"
+ },
+ "default_title": "GNU Taler Wallet",
+ "default_popup": "static/popup.html"
+ },
+ "background": {
+ "page": "static/background.html",
+ "persistent": true
+ }
+} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/manifest-v3.json b/packages/taler-wallet-webextension/manifest-v3.json
new file mode 100644
index 000000000..65a75824b
--- /dev/null
+++ b/packages/taler-wallet-webextension/manifest-v3.json
@@ -0,0 +1,79 @@
+{
+ "manifest_version": 3,
+ "minimum_chrome_version": "88",
+ "icons": {
+ "16": "static/img/taler-logo-16.png",
+ "19": "static/img/taler-logo-19.png",
+ "32": "static/img/taler-logo-32.png",
+ "38": "static/img/taler-logo-38.png",
+ "48": "static/img/taler-logo-48.png",
+ "64": "static/img/taler-logo-64.png",
+ "128": "static/img/taler-logo-128.png",
+ "256": "static/img/taler-logo-256.png",
+ "512": "static/img/taler-logo-512.png"
+ },
+ "permissions": [
+ "unlimitedStorage",
+ "storage",
+ "activeTab",
+ "scripting",
+ "declarativeContent",
+ "alarms"
+ ],
+ "host_permissions": [
+ "<all_urls>"
+ ],
+ "commands": {
+ "_execute_action": {
+ "suggested_key": {
+ "default": "Alt+W"
+ }
+ }
+ },
+ "content_scripts": [
+ {
+ "id": "taler-wallet-interaction",
+ "matches": [
+ "http://*/*",
+ "https://*/*"
+ ],
+ "js": [
+ "dist/taler-wallet-interaction-loader.js"
+ ],
+ "run_at": "document_start"
+ }
+ ],
+ "web_accessible_resources": [
+ {
+ "resources": [
+ "static/wallet.html",
+ "dist/taler-wallet-interaction-loader.js.map",
+ "dist/taler-wallet-interaction-loader.js",
+ "dist/taler-wallet-interaction-support.js.map",
+ "dist/taler-wallet-interaction-support.js"
+ ],
+ "matches": [
+ "https://*/*",
+ "http://*/*"
+ ]
+ }
+ ],
+ "action": {
+ "default_icon": {
+ "16": "static/img/taler-logo-16.png",
+ "19": "static/img/taler-logo-19.png",
+ "32": "static/img/taler-logo-32.png",
+ "38": "static/img/taler-logo-38.png",
+ "48": "static/img/taler-logo-48.png",
+ "64": "static/img/taler-logo-64.png",
+ "128": "static/img/taler-logo-128.png",
+ "256": "static/img/taler-logo-256.png",
+ "512": "static/img/taler-logo-512.png"
+ },
+ "default_title": "GNU Taler Wallet",
+ "default_popup": "static/popup.html"
+ },
+ "background": {
+ "service_worker": "service_worker.js"
+ }
+} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/manifest.json b/packages/taler-wallet-webextension/manifest.json
deleted file mode 100644
index c87d0c0f3..000000000
--- a/packages/taler-wallet-webextension/manifest.json
+++ /dev/null
@@ -1,49 +0,0 @@
-{
- "manifest_version": 2,
-
- "name": "GNU Taler Wallet (git)",
- "description": "Privacy preserving and transparent payments",
- "author": "GNU Taler Developers",
- "version": "0.8.0.10",
- "version_name": "0.8.1-dev.10",
-
- "minimum_chrome_version": "51",
- "minimum_opera_version": "36",
-
- "applications": {
- "gecko": {
- "id": "wallet@taler.net",
- "strict_min_version": "57.0"
- }
- },
-
- "icons": {
- "32": "static/img/icon.png",
- "128": "static/img/logo.png"
- },
-
- "permissions": [
- "storage",
- "activeTab"
- ],
-
- "optional_permissions": [
- "webRequest",
- "webRequestBlocking",
- "http://*/*",
- "https://*/*"
- ],
-
- "browser_action": {
- "default_icon": {
- "32": "static/img/icon.png"
- },
- "default_title": "Taler",
- "default_popup": "static/popup.html"
- },
-
- "background": {
- "page": "static/background.html",
- "persistent": true
- }
-}
diff --git a/packages/taler-wallet-webextension/pack.sh b/packages/taler-wallet-webextension/pack.sh
index e762ab867..f83948a4d 100755
--- a/packages/taler-wallet-webextension/pack.sh
+++ b/packages/taler-wallet-webextension/pack.sh
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# This file is in the public domain.
+ENV=$1
set -eu
if [[ ! -e package.json ]]; then
@@ -8,15 +9,54 @@ if [[ ! -e package.json ]]; then
exit 1
fi
-vers_manifest=$(jq -r '.version' manifest.json)
+[[ "$ENV" == "prod" || "$ENV" == "dev" ]] || { echo "first argument must be prod or dev"; exit 1; }
+vers_manifest=$(jq -r '.version' manifest-common.json)
+
+
+# Create version form Manifest v2
+zipfile="taler-wallet-webextension-${vers_manifest}.zip"
+
+TEMP_DIR=$(mktemp -d)
+jq -s 'add | .name = "GNU Taler Wallet" ' manifest-common.json manifest-v2.json > $TEMP_DIR/manifest.json
+cp -r static $TEMP_DIR
+cp -r dist/prod $TEMP_DIR/dist
+
+find $TEMP_DIR/dist \( -name "test.*" -o -name "*.test.*" -o -name "stories.*" -o -name "*.dev.*" \) -delete
+[[ "$ENV" == "prod" ]] && find $TEMP_DIR/dist \( -name "*.map" \) -delete
+find $TEMP_DIR/dist -type d -empty -delete
+
+(cd $TEMP_DIR && zip -q -r "$zipfile" dist static manifest.json)
+
+mkdir -p extension/v2
+mv "$TEMP_DIR/$zipfile" ./extension/v2/
+rm -rf $TEMP_DIR
+# also provide unpacked version
+rm -rf extension/v2/unpacked
+mkdir -p extension/v2/unpacked
+(cd extension/v2/unpacked && unzip -q ../$zipfile)
+echo "Packed webextension: extension/v2/$zipfile"
+cp -rf src extension/v2/unpacked
+
+# Create version form Manifest v3
zipfile="taler-wallet-webextension-${vers_manifest}.zip"
TEMP_DIR=$(mktemp -d)
-jq '. | .name = "GNU Taler Wallet" ' manifest.json > $TEMP_DIR/manifest.json
-cp -r dist static $TEMP_DIR
-(cd $TEMP_DIR && zip -r "$zipfile" dist static manifest.json)
-mkdir -p extension
-mv "$TEMP_DIR/$zipfile" ./extension/
+jq -s 'add | .name = "GNU Taler Wallet" ' manifest-common.json manifest-v3.json > $TEMP_DIR/manifest.json
+cp -r static $TEMP_DIR
+cp -r dist/prod $TEMP_DIR/dist
+cp -r service_worker.js $TEMP_DIR
+
+find $TEMP_DIR/dist \( -name "test.*" -o -name "*.test.*" -o -name "stories.*" -o -name "*.dev.*" \) -delete
+[[ "$ENV" == "prod" ]] && find $TEMP_DIR/dist \( -name "*.map" \) -delete
+find $TEMP_DIR/dist -type d -empty -delete
+
+(cd $TEMP_DIR && zip -q -r "$zipfile" dist static manifest.json service_worker.js)
+mkdir -p extension/v3
+mv "$TEMP_DIR/$zipfile" ./extension/v3/
rm -rf $TEMP_DIR
-echo "Packed webextension: extension/$zipfile"
+# also provide unpacked version
+rm -rf extension/v3/unpacked
+mkdir -p extension/v3/unpacked
+(cd extension/v3/unpacked && unzip -q ../$zipfile)
+echo "Packed webextension: extension/v3/$zipfile"
diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json
index 4023e4ebd..fe95e93c4 100644
--- a/packages/taler-wallet-webextension/package.json
+++ b/packages/taler-wallet-webextension/package.json
@@ -1,83 +1,77 @@
{
"name": "@gnu-taler/taler-wallet-webextension",
- "version": "0.8.1-dev.2",
+ "version": "0.10.6",
"description": "GNU Taler Wallet browser extension",
"main": "./build/index.js",
"types": "./build/index.d.ts",
+ "type": "module",
"author": "Florian Dold",
"license": "AGPL-3.0-or-later",
"private": false,
"scripts": {
- "clean": "rimraf dist lib tsconfig.tsbuildinfo",
- "test": "jest ./tests",
- "compile": "tsc && rollup -c",
- "build-storybook": "build-storybook",
- "storybook": "start-storybook -s . -p 6006",
- "watch": "tsc --watch & rollup -w -c"
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo extension",
+ "test": "./test.mjs && mocha --require source-map-support/register 'dist/test/**/*.test.js' 'dist/test/**/test.js'",
+ "test:coverage": "nyc pnpm test",
+ "test:firefox": "web-ext run --source-dir extension/v2/unpacked --verbose --firefox-profile $(mktemp -d) --browser-console --devtools -f $FIREFOX_PATH/firefox-bin",
+ "test:firefox-private": "web-ext run --source-dir extension/v2/unpacked --verbose --firefox-profile $(mktemp -d) --browser-console --devtools -f $FIREFOX_PATH/firefox-bin --pref browser.privatebrowsing.autostart=true",
+ "compile": "tsc && ./build.mjs",
+ "typedoc": "typedoc --out dist/typedoc ./src/ --entryPointStrategy expand",
+ "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
+ "dev": "./dev.mjs",
+ "pretty": "prettier --write src",
+ "i18n:extract": "pogen extract",
+ "i18n:merge": "pogen merge",
+ "i18n:emit": "pogen emit",
+ "i18n": "pnpm i18n:extract && pnpm i18n:merge && pnpm i18n:emit"
},
"dependencies": {
"@gnu-taler/taler-util": "workspace:*",
"@gnu-taler/taler-wallet-core": "workspace:*",
- "date-fns": "^2.22.1",
+ "date-fns": "^2.29.2",
"history": "4.10.1",
- "preact": "^10.5.13",
- "preact-router": "^3.2.1",
+ "jsqr": "^1.4.0",
+ "preact": "10.11.3",
+ "preact-router": "3.2.1",
"qrcode-generator": "^1.4.4",
- "tslib": "^2.1.0"
+ "tslib": "^2.6.2"
},
"devDependencies": {
- "@babel/core": "7.13.16",
- "@babel/plugin-transform-react-jsx-source": "^7.12.13",
- "@babel/preset-typescript": "^7.13.0",
- "@linaria/babel-preset": "3.0.0-beta.4",
- "@linaria/core": "3.0.0-beta.4",
- "@linaria/react": "3.0.0-beta.4",
- "@linaria/rollup": "3.0.0-beta.4",
- "@linaria/webpack-loader": "3.0.0-beta.4",
- "@rollup/plugin-alias": "^3.1.2",
- "@rollup/plugin-commonjs": "^17.0.0",
- "@rollup/plugin-image": "^2.0.6",
- "@rollup/plugin-json": "^4.1.0",
- "@rollup/plugin-node-resolve": "^11.1.0",
- "@rollup/plugin-replace": "^2.3.4",
- "@storybook/addon-a11y": "^6.2.9",
- "@storybook/addon-essentials": "^6.2.9",
- "@storybook/preact": "^6.2.9",
- "@testing-library/preact": "^2.0.1",
- "@types/chrome": "^0.0.128",
- "@types/enzyme": "^3.10.8",
+ "eslint": "^8.56.0",
+ "@typescript-eslint/eslint-plugin": "^6.19.0",
+ "@typescript-eslint/parser": "^6.19.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-react": "^7.33.2",
+ "@babel/preset-react": "^7.22.3",
+ "@babel/preset-typescript": "7.18.6",
+ "@gnu-taler/pogen": "workspace:*",
+ "@gnu-taler/web-util": "workspace:*",
+ "@linaria/babel-preset": "5.0.4",
+ "@linaria/core": "5.0.2",
+ "@linaria/esbuild": "5.0.4",
+ "@linaria/react": "5.0.3",
+ "@linaria/shaker": "5.0.3",
+ "@types/chai": "^4.3.0",
+ "@types/chrome": "0.0.197",
"@types/history": "^4.7.8",
- "@types/jest": "^26.0.23",
- "@types/node": "^14.14.22",
- "ava": "3.15.0",
- "babel-loader": "^8.2.2",
- "babel-plugin-transform-react-jsx": "^6.24.1",
- "enzyme": "^3.11.0",
- "enzyme-adapter-preact-pure": "^3.1.0",
- "jest": "^26.6.3",
- "jest-preset-preact": "^4.0.2",
- "preact-cli": "^3.0.5",
+ "@types/mocha": "^9.0.0",
+ "@types/node": "^18.11.17",
+ "chai": "^4.3.6",
+ "esbuild": "^0.19.9",
+ "mocha": "^9.2.0",
+ "nyc": "^15.1.0",
+ "polished": "^4.1.4",
+ "preact-cli": "^3.3.5",
"preact-render-to-string": "^5.1.19",
- "rimraf": "^3.0.2",
- "rollup": "^2.37.1",
- "rollup-plugin-css-only": "^3.1.0",
- "rollup-plugin-ignore": "^1.0.9",
- "rollup-plugin-sourcemaps": "^0.6.3",
- "rollup-plugin-terser": "^7.0.2",
- "storybook-dark-mode": "^1.0.8",
- "typescript": "^4.1.3"
+ "typescript": "5.3.3",
+ "web-ext": "^7.11.0"
},
- "jest": {
- "preset": "jest-preset-preact",
- "setupFiles": [
- "<rootDir>/tests/__mocks__/setupTests.ts"
+ "nyc": {
+ "include": [
+ "**"
],
- "moduleNameMapper": {
- "\\.(css|less)$": "identity-obj-proxy",
- "@linaria/react": "<rootDir>/tests/__mocks__/linaria.ts"
- },
- "transform": {
- "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|po)$": "<rootDir>/tests/__mocks__/fileTransformer.js"
- }
+ "exclude": []
+ },
+ "pogen": {
+ "domain": "taler-wallet-webex"
}
-} \ No newline at end of file
+}
diff --git a/packages/taler-wallet-webextension/rollup.config.js b/packages/taler-wallet-webextension/rollup.config.js
deleted file mode 100644
index 150db1fff..000000000
--- a/packages/taler-wallet-webextension/rollup.config.js
+++ /dev/null
@@ -1,110 +0,0 @@
-// rollup.config.js
-import linaria from '@linaria/rollup';
-import alias from '@rollup/plugin-alias';
-import commonjs from "@rollup/plugin-commonjs";
-import image from '@rollup/plugin-image';
-import json from "@rollup/plugin-json";
-import nodeResolve from "@rollup/plugin-node-resolve";
-import replace from "@rollup/plugin-replace";
-import css from 'rollup-plugin-css-only';
-import ignore from "rollup-plugin-ignore";
-
-const makePlugins = () => [
- alias({
- entries: [
- { find: 'react', replacement: 'preact/compat' },
- { find: 'react-dom', replacement: 'preact/compat' }
- ]
- }),
-
- ignore(["module", "os"]),
- nodeResolve({
- browser: true,
- preferBuiltins: true,
- }),
-
- //terser(),
-
-
- replace({
- "process.env.NODE_ENV": JSON.stringify("production"),
- "__filename": "'__webextension__'",
- }),
-
- commonjs({
- include: [/node_modules/, /dist/],
- extensions: [".js"],
- ignoreGlobal: true,
- sourceMap: true,
- }),
-
- json(),
- image(),
-
- linaria({
- sourceMap: process.env.NODE_ENV !== 'production',
- }),
-
-];
-
-
-const webExtensionWalletEntryPoint = {
- input: "lib/walletEntryPoint.js",
- output: {
- file: "dist/walletEntryPoint.js",
- format: "iife",
- exports: "none",
- name: "webExtensionWalletEntry",
- },
- plugins: [
- ...makePlugins(),
- css({
- output: 'walletEntryPoint.css',
- }),
- ],
-};
-
-const webExtensionPopupEntryPoint = {
- input: "lib/popupEntryPoint.js",
- output: {
- file: "dist/popupEntryPoint.js",
- format: "iife",
- exports: "none",
- name: "webExtensionPopupEntry",
- },
- plugins: [
- ...makePlugins(),
- css({
- output: 'popupEntryPoint.css',
- }),
- ],
-};
-
-const webExtensionBackgroundPageScript = {
- input: "lib/background.js",
- output: {
- file: "dist/background.js",
- format: "iife",
- exports: "none",
- name: "webExtensionBackgroundScript",
- },
- plugins: makePlugins(),
-};
-
-const webExtensionCryptoWorker = {
- input: "lib/browserWorkerEntry.js",
- output: {
- file: "dist/browserWorkerEntry.js",
- format: "iife",
- exports: "none",
- name: "webExtensionCryptoWorker",
- },
- plugins: makePlugins(),
-};
-
-export default [
- webExtensionPopupEntryPoint,
- webExtensionWalletEntryPoint,
- webExtensionBackgroundPageScript,
- webExtensionCryptoWorker,
-];
diff --git a/packages/taler-wallet-webextension/service_worker.js b/packages/taler-wallet-webextension/service_worker.js
new file mode 100644
index 000000000..38064e245
--- /dev/null
+++ b/packages/taler-wallet-webextension/service_worker.js
@@ -0,0 +1,11 @@
+/* eslint-disable no-undef */
+/**
+ * Wrapper to catch any initialization error and show it in the logs
+ */
+try {
+ importScripts("dist/background.js");
+ self.skipWaiting();
+ console.log("SERVICE WORKER init: ok");
+} catch (e) {
+ console.error("SERVICE WORKER failed:", e);
+}
diff --git a/packages/taler-wallet-webextension/src/NavigationBar.tsx b/packages/taler-wallet-webextension/src/NavigationBar.tsx
index 9edd8ca67..fe348f7fb 100644
--- a/packages/taler-wallet-webextension/src/NavigationBar.tsx
+++ b/packages/taler-wallet-webextension/src/NavigationBar.tsx
@@ -1,93 +1,304 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Popup shown to the user when they click
* the Taler browser action button.
*
- * @author Florian Dold
+ * @author sebasjm
*/
/**
* Imports.
*/
-import { i18n } from "@gnu-taler/taler-util";
-import { ComponentChildren, JSX, h } from "preact";
-import Match from "preact-router/match";
-import { useDevContext } from "./context/devContext";
-import { PopupNavigation } from './components/styled'
-
-export enum Pages {
- welcome = '/welcome',
- balance = '/balance',
- manual_withdraw = '/manual-withdraw',
- settings = '/settings',
- dev = '/dev',
- cta = '/cta',
- backup = '/backup',
- history = '/history',
- transaction = '/transaction/:tid',
- provider_detail = '/provider/:pid',
- provider_add = '/provider/add',
-
- reset_required = '/reset-required',
- payback = '/payback',
- return_coins = '/return-coins',
-
- pay = '/pay',
- refund = '/refund',
- tips = '/tip',
- withdraw = '/withdraw',
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { Fragment, h, VNode } from "preact";
+import { EnabledBySettings } from "./components/EnabledBySettings.js";
+import {
+ NavigationHeader,
+ NavigationHeaderHolder,
+ SvgIcon,
+} from "./components/styled/index.js";
+import { useBackendContext } from "./context/backend.js";
+import { useAsyncAsHook } from "./hooks/useAsyncAsHook.js";
+import searchIcon from "./svg/search_24px.inline.svg";
+import qrIcon from "./svg/qr_code_24px.inline.svg";
+import settingsIcon from "./svg/settings_black_24dp.inline.svg";
+import warningIcon from "./svg/warning_24px.inline.svg";
+import { parseTalerUri, TalerUriAction } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+
+/**
+ * List of pages used by the wallet
+ *
+ * @author sebasjm
+ */
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+type PageLocation<DynamicPart extends object> = {
+ pattern: string;
+ (params: DynamicPart): string;
+};
+
+function replaceAll(
+ pattern: string,
+ vars: Record<string, string>,
+ values: Record<string, string>,
+): string {
+ let result = pattern;
+ for (const v in vars) {
+ result = result.replace(
+ vars[v],
+ !values[v] ? "" : encodeURIComponent(values[v]),
+ );
+ }
+ return result;
}
-interface TabProps {
- target: string;
- current?: string;
- children?: ComponentChildren;
+// eslint-disable-next-line @typescript-eslint/ban-types
+function pageDefinition<T extends object>(pattern: string): PageLocation<T> {
+ const patternParams = pattern.match(/(:[\w?]*)/g);
+ if (!patternParams)
+ throw Error(
+ `page definition pattern ${pattern} doesn't have any parameter`,
+ );
+
+ const vars = patternParams.reduce(
+ (prev, cur) => {
+ const pName = cur.match(/(\w+)/g);
+
+ //skip things like :? in the path pattern
+ if (!pName || !pName[0]) return prev;
+ const name = pName[0];
+ return { ...prev, [name]: cur };
+ },
+ {} as Record<string, string>,
+ );
+
+ const f = (values: T): string =>
+ replaceAll(pattern, vars, (values ?? {}) as Record<string, string>);
+ f.pattern = pattern;
+ return f;
}
-function Tab(props: TabProps): JSX.Element {
- let cssClass = "";
- if (props.current?.startsWith(props.target)) {
- cssClass = "active";
+export const Pages = {
+ welcome: "/welcome",
+ balance: "/balance",
+ balanceHistory: pageDefinition<{ currency?: string }>(
+ "/balance/history/:currency?",
+ ),
+ searchHistory: pageDefinition<{ currency?: string }>(
+ "/search/history/:currency?",
+ ),
+ balanceDeposit: pageDefinition<{ amount: string }>(
+ "/balance/deposit/:amount",
+ ),
+ balanceTransaction: pageDefinition<{ tid: string }>(
+ "/balance/transaction/:tid",
+ ),
+ sendCash: pageDefinition<{ amount?: string }>("/destination/send/:amount"),
+ receiveCash: pageDefinition<{ amount?: string }>("/destination/get/:amount?"),
+ dev: "/dev",
+
+ exchanges: "/exchanges",
+ backup: "/backup",
+ backupProviderDetail: pageDefinition<{ pid: string }>(
+ "/backup/provider/:pid",
+ ),
+ backupProviderAdd: "/backup/provider/add",
+
+ qr: "/qr",
+ notifications: "/notifications",
+ settings: "/settings",
+ settingsExchangeAdd: pageDefinition<{ currency?: string }>(
+ "/settings/exchange/add/:currency?",
+ ),
+
+ defaultCta: pageDefinition<{ uri: string }>("/taler-uri/:uri"),
+ cta: pageDefinition<{ action: string }>("/cta/:action"),
+ ctaPay: "/cta/pay",
+ ctaPayTemplate: "/cta/pay/template",
+ ctaRecovery: "/cta/recovery",
+ ctaRefund: "/cta/refund",
+ ctaWithdraw: "/cta/withdraw",
+ ctaDeposit: "/cta/deposit",
+ ctaExperiment: "/cta/experiment",
+ ctaAddExchange: "/cta/add/exchange",
+ ctaInvoiceCreate: pageDefinition<{ amount?: string }>(
+ "/cta/invoice/create/:amount?",
+ ),
+ ctaTransferCreate: pageDefinition<{ amount?: string }>(
+ "/cta/transfer/create/:amount?",
+ ),
+ ctaInvoicePay: "/cta/invoice/pay",
+ ctaTransferPickup: "/cta/transfer/pickup",
+ ctaWithdrawManual: pageDefinition<{ amount?: string }>(
+ "/cta/manual-withdraw/:amount?",
+ ),
+};
+
+const talerUriActionToPageName: {
+ [t in TalerUriAction]: keyof typeof Pages | undefined;
+} = {
+ [TalerUriAction.Withdraw]: "ctaWithdraw",
+ [TalerUriAction.Pay]: "ctaPay",
+ [TalerUriAction.Refund]: "ctaRefund",
+ [TalerUriAction.PayPull]: "ctaInvoicePay",
+ [TalerUriAction.PayPush]: "ctaTransferPickup",
+ [TalerUriAction.Restore]: "ctaRecovery",
+ [TalerUriAction.PayTemplate]: "ctaPayTemplate",
+ [TalerUriAction.WithdrawExchange]: "ctaWithdrawManual",
+ [TalerUriAction.DevExperiment]: "ctaExperiment",
+ [TalerUriAction.AddExchange]: "ctaAddExchange",
+};
+
+export function getPathnameForTalerURI(talerUri: string): string | undefined {
+ const uri = parseTalerUri(talerUri);
+ if (!uri) {
+ return undefined;
}
+ const pageName = talerUriActionToPageName[uri.type];
+ if (!pageName) {
+ return undefined;
+ }
+ const pageString: string =
+ typeof Pages[pageName] === "function"
+ ? (Pages[pageName] as any)()
+ : Pages[pageName];
+ return `${pageString}?talerUri=${encodeURIComponent(talerUri)}`;
+}
+
+export type PopupNavBarOptions = "balance" | "backup" | "dev";
+export function PopupNavBar({ path }: { path?: PopupNavBarOptions }): VNode {
+ const api = useBackendContext();
+ const hook = useAsyncAsHook(async () => {
+ return await api.wallet.call(
+ WalletApiOperation.GetUserAttentionUnreadCount,
+ {},
+ );
+ });
+ const attentionCount = !hook || hook.hasError ? 0 : hook.response.total;
+
+ const { i18n } = useTranslationContext();
return (
- <a href={props.target} class={cssClass}>
- {props.children}
- </a>
+ <NavigationHeader>
+ <a href={Pages.balance} class={path === "balance" ? "active" : ""}>
+ <i18n.Translate>Balance</i18n.Translate>
+ </a>
+ <EnabledBySettings name="backup">
+ <a href={Pages.backup} class={path === "backup" ? "active" : ""}>
+ <i18n.Translate>Backup</i18n.Translate>
+ </a>
+ </EnabledBySettings>
+ <div style={{ display: "flex", paddingTop: 4, justifyContent: "right" }}>
+ {attentionCount > 0 ? (
+ <a href={Pages.notifications}>
+ <SvgIcon
+ title={i18n.str`Notifications`}
+ dangerouslySetInnerHTML={{ __html: warningIcon }}
+ color="yellow"
+ />
+ </a>
+ ) : (
+ <Fragment />
+ )}
+ <a href={Pages.qr}>
+ <SvgIcon
+ title={i18n.str`QR Reader and Taler URI`}
+ dangerouslySetInnerHTML={{ __html: qrIcon }}
+ color="white"
+ />
+ </a>
+ <a href={Pages.settings}>
+ <SvgIcon
+ title={i18n.str`Settings`}
+ dangerouslySetInnerHTML={{ __html: settingsIcon }}
+ color="white"
+ />
+ </a>
+ </div>
+ </NavigationHeader>
);
}
+export type WalletNavBarOptions = "balance" | "backup" | "dev";
+export function WalletNavBar({ path }: { path?: WalletNavBarOptions }): VNode {
+ const { i18n } = useTranslationContext();
-export function NavBar({ devMode, path }: { path: string, devMode: boolean }) {
- return <PopupNavigation devMode={devMode}>
- <div>
- <Tab target="/balance" current={path}>{i18n.str`Balance`}</Tab>
- <Tab target="/history" current={path}>{i18n.str`History`}</Tab>
- <Tab target="/backup" current={path}>{i18n.str`Backup`}</Tab>
- <Tab target="/settings" current={path}>{i18n.str`Settings`}</Tab>
- {devMode && <Tab target="/dev" current={path}>{i18n.str`Dev`}</Tab>}
- </div>
- </PopupNavigation>
-}
+ const api = useBackendContext();
+ const hook = useAsyncAsHook(async () => {
+ return await api.wallet.call(
+ WalletApiOperation.GetUserAttentionUnreadCount,
+ {},
+ );
+ });
+ const attentionCount =
+ (!hook || hook.hasError ? 0 : hook.response?.total) ?? 0;
-export function WalletNavBar() {
- const { devMode } = useDevContext()
- return <Match>{({ path }: any) => {
- console.log("path", path)
- return <NavBar devMode={devMode} path={path} />
- }}</Match>
-}
+ return (
+ <NavigationHeaderHolder>
+ <NavigationHeader>
+ <a href={Pages.balance} class={path === "balance" ? "active" : ""}>
+ <i18n.Translate>Balance</i18n.Translate>
+ </a>
+ <EnabledBySettings name="backup">
+ <a href={Pages.backup} class={path === "backup" ? "active" : ""}>
+ <i18n.Translate>Backup</i18n.Translate>
+ </a>
+ </EnabledBySettings>
+
+ {attentionCount > 0 ? (
+ <a href={Pages.notifications}>
+ <i18n.Translate>Notifications</i18n.Translate>
+ </a>
+ ) : (
+ <Fragment />
+ )}
+ <EnabledBySettings name="advancedMode">
+ <a href={Pages.dev} class={path === "dev" ? "active" : ""}>
+ <i18n.Translate>Dev tools</i18n.Translate>
+ </a>
+ </EnabledBySettings>
+
+ <div
+ style={{ display: "flex", paddingTop: 4, justifyContent: "right" }}
+ >
+ <a href={Pages.searchHistory({})}>
+ <SvgIcon
+ title={i18n.str`Search transactions`}
+ dangerouslySetInnerHTML={{ __html: searchIcon }}
+ color="white"
+ />
+ </a>
+ <a href={Pages.qr}>
+ <SvgIcon
+ title={i18n.str`QR Reader and Taler URI`}
+ dangerouslySetInnerHTML={{ __html: qrIcon }}
+ color="white"
+ />
+ </a>
+ <a href={Pages.settings}>
+ <SvgIcon
+ title={i18n.str`Settings`}
+ dangerouslySetInnerHTML={{ __html: settingsIcon }}
+ color="white"
+ />
+ </a>
+ </div>
+ </NavigationHeader>
+ </NavigationHeaderHolder>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/background.dev.ts b/packages/taler-wallet-webextension/src/background.dev.ts
new file mode 100644
index 000000000..96cf63409
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/background.dev.ts
@@ -0,0 +1,36 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Entry point for the background page.
+ *
+ * @author sebasjm
+ */
+
+/**
+ * Imports.
+ */
+import { platform, setupPlatform } from "./platform/background.js";
+import devAPI from "./platform/dev.js";
+import { wxMain } from "./wxBackend.js";
+
+setupPlatform(devAPI);
+
+async function start() {
+ await platform.notifyWhenAppIsReady();
+ await wxMain();
+}
+start();
diff --git a/packages/taler-wallet-webextension/src/background.ts b/packages/taler-wallet-webextension/src/background.ts
index dcbf96139..7df66eff8 100644
--- a/packages/taler-wallet-webextension/src/background.ts
+++ b/packages/taler-wallet-webextension/src/background.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -17,14 +17,33 @@
/**
* Entry point for the background page.
*
- * @author Florian Dold
+ * @author sebasjm
*/
/**
* Imports.
*/
-import { wxMain } from "./wxBackend";
+import { platform, setupPlatform } from "./platform/background.js";
+import chromeAPI from "./platform/chrome.js";
+import firefoxAPI from "./platform/firefox.js";
+import { wxMain } from "./wxBackend.js";
-window.addEventListener("load", () => {
- wxMain();
-});
+const isFirefox =
+ typeof (window as any) !== "undefined" &&
+ typeof (window as any)["InstallTrigger"] !== "undefined";
+
+// FIXME: create different entry point for any platform instead of
+// switching in runtime
+if (isFirefox) {
+ setupPlatform(firefoxAPI);
+} else {
+ setupPlatform(chromeAPI);
+}
+
+// setGlobalLogLevelFromString("trace")
+
+async function start() {
+ await platform.notifyWhenAppIsReady();
+ await wxMain();
+}
+start();
diff --git a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js
deleted file mode 100644
index e9492a2fb..000000000
--- a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js
+++ /dev/null
@@ -1,44 +0,0 @@
-"use strict";
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.BrowserCryptoWorkerFactory = void 0;
-/**
- * API to access the Taler crypto worker thread.
- * @author Florian Dold
- */
-class BrowserCryptoWorkerFactory {
- startWorker() {
- const workerCtor = Worker;
- const workerPath = "/browserWorkerEntry.js";
- return new workerCtor(workerPath);
- }
- getConcurrency() {
- let concurrency = 2;
- try {
- // only works in the browser
- // tslint:disable-next-line:no-string-literal
- concurrency = navigator["hardwareConcurrency"];
- concurrency = Math.max(1, Math.ceil(concurrency / 2));
- }
- catch (e) {
- concurrency = 2;
- }
- return concurrency;
- }
-}
-exports.BrowserCryptoWorkerFactory = BrowserCryptoWorkerFactory;
-//# sourceMappingURL=browserCryptoWorkerFactory.js.map \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js.map b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js.map
deleted file mode 100644
index db56d4451..000000000
--- a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"file":"browserCryptoWorkerFactory.js","sourceRoot":"","sources":["browserCryptoWorkerFactory.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;GAcG;;;AAEH;;;GAGG;AAEH,MAAa,0BAA0B;IACrC,WAAW;QACT,MAAM,UAAU,GAAG,MAAM,CAAC;QAC1B,MAAM,UAAU,GAAG,wBAAwB,CAAC;QAC5C,OAAO,IAAI,UAAU,CAAC,UAAU,CAAiB,CAAC;IACpD,CAAC;IAED,cAAc;QACZ,IAAI,WAAW,GAAG,CAAC,CAAC;QACpB,IAAI;YACF,4BAA4B;YAC5B,6CAA6C;YAC7C,WAAW,GAAI,SAAiB,CAAC,qBAAqB,CAAC,CAAC;YACxD,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC,CAAC,CAAC;SACvD;QAAC,OAAO,CAAC,EAAE;YACV,WAAW,GAAG,CAAC,CAAC;SACjB;QACD,OAAO,WAAW,CAAC;IACrB,CAAC;CACF;AAnBD,gEAmBC"} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts
index a8315dc6d..c93097da8 100644
--- a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts
+++ b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts
@@ -1,17 +1,17 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
@@ -19,12 +19,17 @@
* @author Florian Dold
*/
-import type { CryptoWorker, CryptoWorkerFactory } from "@gnu-taler/taler-wallet-core";
+import type {
+ CryptoWorker,
+ CryptoWorkerFactory,
+} from "@gnu-taler/taler-wallet-core";
export class BrowserCryptoWorkerFactory implements CryptoWorkerFactory {
startWorker(): CryptoWorker {
const workerCtor = Worker;
const workerPath = "/dist/browserWorkerEntry.js";
+ // FIXME: This is not really the same interface as the crypto worker!
+ // We need to wrap it.
return new workerCtor(workerPath) as CryptoWorker;
}
diff --git a/packages/taler-wallet-webextension/src/browserHttpLib.ts b/packages/taler-wallet-webextension/src/browserHttpLib.ts
deleted file mode 100644
index 63fd456f4..000000000
--- a/packages/taler-wallet-webextension/src/browserHttpLib.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Imports.
- */
-import {
- OperationFailedError,
- HttpRequestLibrary,
- HttpRequestOptions,
- HttpResponse,
- Headers,
-} from "@gnu-taler/taler-wallet-core";
-import { Logger, TalerErrorCode } from "@gnu-taler/taler-util";
-
-const logger = new Logger("browserHttpLib");
-
-/**
- * An implementation of the [[HttpRequestLibrary]] using the
- * browser's XMLHttpRequest.
- */
-export class BrowserHttpLib implements HttpRequestLibrary {
- fetch(url: string, options?: HttpRequestOptions): Promise<HttpResponse> {
- const method = options?.method ?? "GET";
- let requestBody = options?.body;
- return new Promise<HttpResponse>((resolve, reject) => {
- const myRequest = new XMLHttpRequest();
- myRequest.open(method, url);
- if (options?.headers) {
- for (const headerName in options.headers) {
- myRequest.setRequestHeader(headerName, options.headers[headerName]);
- }
- }
- myRequest.responseType = "arraybuffer";
- if (requestBody) {
- myRequest.send(requestBody);
- } else {
- myRequest.send();
- }
-
- myRequest.onerror = (e) => {
- logger.error("http request error");
- reject(
- OperationFailedError.fromCode(
- TalerErrorCode.WALLET_NETWORK_ERROR,
- "Could not make request",
- {
- requestUrl: url,
- },
- ),
- );
- };
-
- myRequest.addEventListener("readystatechange", (e) => {
- if (myRequest.readyState === XMLHttpRequest.DONE) {
- if (myRequest.status === 0) {
- const exc = OperationFailedError.fromCode(
- TalerErrorCode.WALLET_NETWORK_ERROR,
- "HTTP request failed (status 0, maybe URI scheme was wrong?)",
- {
- requestUrl: url,
- },
- );
- reject(exc);
- return;
- }
- const makeText = async (): Promise<string> => {
- const td = new TextDecoder();
- return td.decode(myRequest.response);
- };
- const makeJson = async (): Promise<any> => {
- let responseJson;
- try {
- const td = new TextDecoder();
- const responseString = td.decode(myRequest.response);
- responseJson = JSON.parse(responseString);
- } catch (e) {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Invalid JSON from HTTP response",
- {
- requestUrl: url,
- httpStatusCode: myRequest.status,
- },
- );
- }
- if (responseJson === null || typeof responseJson !== "object") {
- throw OperationFailedError.fromCode(
- TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
- "Invalid JSON from HTTP response",
- {
- requestUrl: url,
- httpStatusCode: myRequest.status,
- },
- );
- }
- return responseJson;
- };
-
- const headers = myRequest.getAllResponseHeaders();
- const arr = headers.trim().split(/[\r\n]+/);
-
- // Create a map of header names to values
- const headerMap: Headers = new Headers();
- arr.forEach(function (line) {
- const parts = line.split(": ");
- const headerName = parts.shift();
- if (!headerName) {
- logger.warn("skipping invalid header");
- return;
- }
- const value = parts.join(": ");
- headerMap.set(headerName, value);
- });
- const resp: HttpResponse = {
- requestUrl: url,
- status: myRequest.status,
- headers: headerMap,
- requestMethod: method,
- json: makeJson,
- text: makeText,
- bytes: async () => myRequest.response,
- };
- resolve(resp);
- }
- });
- });
- }
-
- get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
- return this.fetch(url, {
- method: "GET",
- ...opt,
- });
- }
-
- postJson(
- url: string,
- body: any,
- opt?: HttpRequestOptions,
- ): Promise<HttpResponse> {
- return this.fetch(url, {
- method: "POST",
- body: JSON.stringify(body),
- ...opt,
- });
- }
-
- stop(): void {
- // Nothing to do
- }
-}
diff --git a/packages/taler-wallet-webextension/src/browserWorkerEntry.ts b/packages/taler-wallet-webextension/src/browserWorkerEntry.ts
index b5c26a7bb..bb1794e56 100644
--- a/packages/taler-wallet-webextension/src/browserWorkerEntry.ts
+++ b/packages/taler-wallet-webextension/src/browserWorkerEntry.ts
@@ -1,18 +1,18 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
/**
* Web worker for crypto operations.
@@ -22,38 +22,51 @@
* Imports.
*/
-import { Logger } from "@gnu-taler/taler-util";
-import { CryptoImplementation } from "@gnu-taler/taler-wallet-core";
+import {
+ j2s,
+ Logger,
+ getErrorDetailFromException,
+} from "@gnu-taler/taler-util";
+import { nativeCrypto } from "@gnu-taler/taler-wallet-core";
const logger = new Logger("browserWorkerEntry.ts");
-const worker: Worker = (self as any) as Worker;
+const worker: Worker = self as any as Worker;
async function handleRequest(
operation: string,
id: number,
- args: string[],
+ req: unknown,
): Promise<void> {
- const impl = new CryptoImplementation();
+ const impl = nativeCrypto;
if (!(operation in impl)) {
console.error(`crypto operation '${operation}' not found`);
return;
}
+ logger.info(`browser worker crypto request: ${j2s(req)}`);
+
+ let responseMsg: any;
try {
- const result = (impl as any)[operation](...args);
- worker.postMessage({ result, id });
- } catch (e) {
- logger.error("error during operation", e);
- return;
+ const result = await (impl as any)[operation](impl, req);
+ responseMsg = { type: "success", result, id };
+ } catch (e: any) {
+ logger.error(`error during operation: ${e.stack ?? e.toString()}`);
+ responseMsg = {
+ type: "error",
+ id,
+ error: getErrorDetailFromException(e),
+ };
}
+
+ worker.postMessage(responseMsg);
}
worker.onmessage = (msg: MessageEvent) => {
- const args = msg.data.args;
- if (!Array.isArray(args)) {
- console.error("args must be array");
+ const req = msg.data.req;
+ if (typeof req !== "object") {
+ console.error("request must be an object");
return;
}
const id = msg.data.id;
@@ -67,7 +80,7 @@ worker.onmessage = (msg: MessageEvent) => {
return;
}
- handleRequest(operation, id, args).catch((e) => {
+ handleRequest(operation, id, req).catch((e) => {
console.error("error in browser worker", e);
});
};
diff --git a/packages/taler-wallet-webextension/src/chromeBadge.ts b/packages/taler-wallet-webextension/src/chromeBadge.ts
index 7bc5d368d..63d0372b6 100644
--- a/packages/taler-wallet-webextension/src/chromeBadge.ts
+++ b/packages/taler-wallet-webextension/src/chromeBadge.ts
@@ -1,20 +1,20 @@
/*
- This file is part of TALER
- (C) 2016 INRIA
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { isFirefox } from "./compat";
+import { platform } from "./platform/background.js";
/**
* Polyfill for requestAnimationFrame, which
@@ -198,7 +198,7 @@ export class ChromeBadge {
this.canvas.width,
this.canvas.height,
);
- chrome.browserAction.setIcon({ imageData });
+ chrome.action.setIcon({ imageData });
} catch (e) {
// Might fail if browser has over-eager canvas fingerprinting countermeasures.
// There's nothing we can do then ...
@@ -210,7 +210,7 @@ export class ChromeBadge {
if (this.animationRunning) {
return;
}
- if (isFirefox()) {
+ if (platform.isFirefox()) {
// Firefox does not support badge animations properly
return;
}
diff --git a/packages/taler-wallet-webextension/src/compat.js b/packages/taler-wallet-webextension/src/compat.js
deleted file mode 100644
index fdfcbd4b9..000000000
--- a/packages/taler-wallet-webextension/src/compat.js
+++ /dev/null
@@ -1,61 +0,0 @@
-"use strict";
-/*
- This file is part of TALER
- (C) 2017 INRIA
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-Object.defineProperty(exports, "__esModule", { value: true });
-exports.getPermissionsApi = exports.isNode = exports.isFirefox = void 0;
-/**
- * Compatibility helpers needed for browsers that don't implement
- * WebExtension APIs consistently.
- */
-function isFirefox() {
- const rt = chrome.runtime;
- if (typeof rt.getBrowserInfo === "function") {
- return true;
- }
- return false;
-}
-exports.isFirefox = isFirefox;
-/**
- * Check if we are running under nodejs.
- */
-function isNode() {
- return typeof process !== "undefined" && process.release.name === "node";
-}
-exports.isNode = isNode;
-function getPermissionsApi() {
- const myBrowser = globalThis.browser;
- if (typeof myBrowser === "object" &&
- typeof myBrowser.permissions === "object") {
- return {
- addPermissionsListener: () => {
- // Not supported yet.
- },
- contains: myBrowser.permissions.contains,
- request: myBrowser.permissions.request,
- remove: myBrowser.permissions.remove,
- };
- }
- else {
- return {
- addPermissionsListener: chrome.permissions.onAdded.addListener.bind(chrome.permissions.onAdded),
- contains: chrome.permissions.contains,
- request: chrome.permissions.request,
- remove: chrome.permissions.remove,
- };
- }
-}
-exports.getPermissionsApi = getPermissionsApi;
-//# sourceMappingURL=compat.js.map \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/compat.ts b/packages/taler-wallet-webextension/src/compat.ts
deleted file mode 100644
index 36846e615..000000000
--- a/packages/taler-wallet-webextension/src/compat.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- This file is part of TALER
- (C) 2017 INRIA
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Compatibility helpers needed for browsers that don't implement
- * WebExtension APIs consistently.
- */
-
-// globalThis polyfill, see https://mathiasbynens.be/notes/globalthis
-(function () {
- if (typeof globalThis === "object") return;
- Object.defineProperty(Object.prototype, "__magic__", {
- get: function () {
- return this;
- },
- configurable: true, // This makes it possible to `delete` the getter later.
- });
- // @ts-ignore: polyfill magic
- __magic__.globalThis = __magic__; // lolwat
- // @ts-ignore: polyfill magic
- delete Object.prototype.__magic__;
-})();
-
-export function isFirefox(): boolean {
- const rt = chrome.runtime as any;
- if (typeof rt.getBrowserInfo === "function") {
- return true;
- }
- return false;
-}
-
-/**
- * Check if we are running under nodejs.
- */
-export function isNode(): boolean {
- return typeof process !== "undefined" && process.release.name === "node";
-}
-
-/**
- * Compatibility API that works on multiple browsers.
- */
-export interface CrossBrowserPermissionsApi {
- contains(
- permissions: chrome.permissions.Permissions,
- callback: (result: boolean) => void,
- ): void;
-
- addPermissionsListener(
- callback: (permissions: chrome.permissions.Permissions) => void,
- ): void;
-
- request(
- permissions: chrome.permissions.Permissions,
- callback?: (granted: boolean) => void,
- ): void;
-
- remove(
- permissions: chrome.permissions.Permissions,
- callback?: (removed: boolean) => void,
- ): void;
-}
-
-export function getPermissionsApi(): CrossBrowserPermissionsApi {
- const myBrowser = (globalThis as any).browser;
- if (
- typeof myBrowser === "object" &&
- typeof myBrowser.permissions === "object"
- ) {
- return {
- addPermissionsListener: () => {
- // Not supported yet.
- },
- contains: myBrowser.permissions.contains,
- request: myBrowser.permissions.request,
- remove: myBrowser.permissions.remove,
- };
- } else {
- return {
- addPermissionsListener: chrome.permissions.onAdded.addListener.bind(
- chrome.permissions.onAdded,
- ),
- contains: chrome.permissions.contains,
- request: chrome.permissions.request,
- remove: chrome.permissions.remove,
- };
- }
-}
diff --git a/packages/taler-wallet-webextension/src/components/Amount.stories.tsx b/packages/taler-wallet-webextension/src/components/Amount.stories.tsx
new file mode 100644
index 000000000..fa28088eb
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/Amount.stories.tsx
@@ -0,0 +1,115 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { styled } from "@linaria/react";
+import { Fragment, h, VNode } from "preact";
+import { Amount } from "./Amount.js";
+import { AmountString } from "@gnu-taler/taler-util";
+
+export default {
+ title: "amount",
+ component: Amount,
+};
+
+const Table = styled.table`
+ td {
+ padding: 4px;
+ }
+ td {
+ border-bottom: 1px solid black;
+ }
+`;
+
+function ProductTable(
+ prods: string[],
+ AmountRender: (p: { value: AmountString; index: number }) => VNode = Amount,
+): VNode {
+ return (
+ <Table>
+ <tr>
+ <td>product</td>
+ <td>price</td>
+ </tr>
+ {prods.map((value, i) => {
+ return (
+ <tr key={i}>
+ <td>p{i}</td>
+ <td>
+ <AmountRender value={value as AmountString} index={i} />
+ {/* <Amount value={value} fracSize={fracSize} /> */}
+ </td>
+ </tr>
+ );
+ })}
+ </Table>
+ );
+}
+
+export const WithoutFixedSizeDefault = (): VNode =>
+ ProductTable(["ARS:19", "ARS:0.1", "ARS:10.02"]);
+
+export const WithFixedSizeZero = (): VNode =>
+ ProductTable(["ARS:19", "ARS:0.1", "ARS:10.02"], ({ value }) => {
+ return <Amount value={value} maxFracSize={0} />;
+ });
+
+export const WithFixedSizeFour = (): VNode =>
+ ProductTable(
+ ["ARS:19", "ARS:0.1", "ARS:10.02", "ARS:10.0123", "ARS:10.0123123"],
+ ({ value }) => {
+ return <Amount value={value} maxFracSize={4} />;
+ },
+ );
+
+export const WithFixedSizeFourNegative = (): VNode =>
+ ProductTable(
+ ["ARS:19", "ARS:0.1", "ARS:10.02", "ARS:10.0123", "ARS:10.0123123"],
+ ({ value, index }) => {
+ return (
+ <Amount value={value} maxFracSize={4} negative={index % 2 === 0} />
+ );
+ },
+ );
+
+export const WithFixedSizeFourOverflow = (): VNode =>
+ ProductTable(
+ ["ARS:19", "ARS:0.1", "ARS:10123123.02", "ARS:10.0123", "ARS:10.0123123"],
+ ({ value, index }) => {
+ return (
+ <Amount value={value} maxFracSize={4} negative={index % 2 === 0} />
+ );
+ },
+ );
+
+export const WithFixedSizeFourAccounting = (): VNode =>
+ ProductTable(
+ ["ARS:19", "ARS:0.1", "ARS:10123123.02", "ARS:10.0123", "ARS:10.0123123"],
+ ({ value, index }) => {
+ return (
+ <Amount
+ value={value}
+ signType="accounting"
+ maxFracSize={4}
+ negative={index % 2 === 0}
+ />
+ );
+ },
+ );
diff --git a/packages/taler-wallet-webextension/src/components/Amount.tsx b/packages/taler-wallet-webextension/src/components/Amount.tsx
new file mode 100644
index 000000000..09f65473c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/Amount.tsx
@@ -0,0 +1,107 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ amountFractionalBase,
+ amountFractionalLength,
+ AmountJson,
+ Amounts,
+ AmountString,
+} from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+
+export function Amount({
+ value,
+ maxFracSize,
+ negative,
+ hideCurrency,
+ signType = "standard",
+ signDisplay = "auto",
+}: {
+ negative?: boolean;
+ value: AmountJson | AmountString;
+ maxFracSize?: number;
+ hideCurrency?: boolean;
+ signType?: "accounting" | "standard";
+ signDisplay?: "auto" | "always" | "never" | "exceptZero";
+}): VNode {
+ const aj = Amounts.jsonifyAmount(value);
+ const minFractional =
+ maxFracSize !== undefined && maxFracSize < 2 ? maxFracSize : 2;
+ const af = aj.fraction % amountFractionalBase;
+ let s = "";
+ if ((af && maxFracSize) || minFractional > 0) {
+ s += ".";
+ let n = af;
+ for (
+ let i = 0;
+ (maxFracSize === undefined || i < maxFracSize) &&
+ i < amountFractionalLength;
+ i++
+ ) {
+ if (!n && i >= minFractional) {
+ break;
+ }
+ s = s + Math.floor((n / amountFractionalBase) * 10).toString();
+ n = (n * 10) % amountFractionalBase;
+ }
+ }
+ const fontSize = 18;
+ const letterSpacing = 0;
+ const mult = 0.7;
+ return (
+ <span style={{ textAlign: "right", whiteSpace: "nowrap" }}>
+ <span
+ style={{
+ display: "inline-block",
+ fontFamily: "monospace",
+ fontSize,
+ }}
+ >
+ {negative ? (signType === "accounting" ? "(" : "-") : ""}
+ <span
+ style={{
+ display: "inline-block",
+ textAlign: "right",
+ fontFamily: "monospace",
+ fontSize,
+ letterSpacing,
+ }}
+ >
+ {aj.value}
+ </span>
+ <span
+ style={{
+ display: "inline-block",
+ width: !maxFracSize ? undefined : `${(maxFracSize + 1) * mult}em`,
+ textAlign: "left",
+ fontFamily: "monospace",
+ fontSize,
+ letterSpacing,
+ }}
+ >
+ {s}
+ {negative && signType === "accounting" ? ")" : ""}
+ </span>
+ </span>
+ {hideCurrency ? undefined : (
+ <Fragment>
+ &nbsp;
+ <span>{aj.currency}</span>
+ </Fragment>
+ )}
+ </span>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/AmountField.stories.tsx b/packages/taler-wallet-webextension/src/components/AmountField.stories.tsx
new file mode 100644
index 000000000..daa06fa65
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/AmountField.stories.tsx
@@ -0,0 +1,69 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountJson, Amounts } from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { AmountFieldHandler, nullFunction, withSafe } from "../mui/handlers.js";
+import { AmountField } from "./AmountField.js";
+
+export default {
+ title: "amountField",
+};
+
+function RenderAmount(): VNode {
+ const [value, setValue] = useState<AmountJson | undefined>({
+ currency: "USD",
+ value: 1,
+ fraction: 0,
+ });
+
+ const error = value === undefined ? undefined : undefined;
+
+ const handler: AmountFieldHandler = {
+ value: value ?? Amounts.zeroOfCurrency("USD"),
+ onInput: withSafe(async (e) => {
+ setValue(e);
+ }, nullFunction),
+ error,
+ };
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <AmountField
+ required
+ label={i18n.str`Amount`}
+ highestDenom={2000000}
+ lowestDenom={0.01}
+ handler={handler}
+ />
+ <p>
+ <pre>
+ value : {value?.value} <br />
+ fraction : {value?.fraction}
+ </pre>
+ </p>
+ </Fragment>
+ );
+}
+
+export const AmountFieldExample = (): VNode => RenderAmount();
diff --git a/packages/taler-wallet-webextension/src/components/AmountField.tsx b/packages/taler-wallet-webextension/src/components/AmountField.tsx
new file mode 100644
index 000000000..c330c72b5
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/AmountField.tsx
@@ -0,0 +1,223 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ amountFractionalBase,
+ amountFractionalLength,
+ AmountJson,
+ amountMaxValue,
+ Amounts,
+ Result,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { AmountFieldHandler } from "../mui/handlers.js";
+import { TextField } from "../mui/TextField.js";
+
+const HIGH_DENOM_SYMBOL = ["", "K", "M", "G", "T", "P"];
+const LOW_DENOM_SYMBOL = ["", "m", "mm", "n", "p", "f"];
+
+/**
+ * Show normalized value based on the currency unit
+ */
+export function AmountField({
+ label,
+ handler,
+ lowestDenom = 1,
+ highestDenom = 1,
+ required,
+}: {
+ label: TranslatedString;
+ lowestDenom?: number;
+ highestDenom?: number;
+ required?: boolean;
+ handler: AmountFieldHandler;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [unit, setUnit] = useState(1);
+ const [error, setError] = useState<string>("");
+
+ const normal = normalize(handler.value, unit);
+ const previousValue = Amounts.stringifyValue(normal);
+
+ const [textValue, setTextValue] = useState<string>(previousValue);
+ useEffect(() => {
+ setTextValue(previousValue);
+ }, [previousValue]);
+
+ function updateUnit(newUnit: number) {
+ setUnit(newUnit);
+ const newNorm = normalize(handler.value, newUnit);
+ setTextValue(Amounts.stringifyValue(newNorm));
+ }
+
+ const currency = handler.value.currency;
+
+ const currencyLabels = buildLabelsForCurrency(
+ currency,
+ lowestDenom,
+ highestDenom,
+ );
+
+ function positiveAmount(value: string): string {
+ if (!value) {
+ if (handler.onInput) {
+ handler.onInput(Amounts.zeroOfCurrency(currency));
+ }
+ } else
+ try {
+ const parsed = Amounts.parseOrThrow(`${currency}:${value.trim()}`);
+
+ const realValue = denormalize(parsed, unit);
+
+ if (handler.onInput) {
+ handler.onInput(realValue);
+ }
+ setError("");
+ } catch (e) {
+ setError(i18n.str`Amount is not valid`);
+ }
+ setTextValue(value);
+ return value;
+ }
+
+ return (
+ <Fragment>
+ <TextField
+ label={label}
+ type="text"
+ min="0"
+ inputmode="decimal"
+ step="0.1"
+ variant="filled"
+ error={handler.error}
+ required={required}
+ startAdornment={
+ currencyLabels.length === 1 ? (
+ <div
+ style={{
+ marginTop: 20,
+ padding: "5px 12px 8px 12px",
+ }}
+ >
+ {currency}
+ </div>
+ ) : (
+ <select
+ disabled={!handler.onInput}
+ onChange={(e) => {
+ const unit = Number.parseFloat(e.currentTarget.value);
+ updateUnit(unit);
+ }}
+ value={String(unit)}
+ style={{
+ marginTop: 20,
+ padding: "5px 12px 8px 12px",
+ background: "transparent",
+ border: 0,
+ }}
+ >
+ {currencyLabels.map((c) => (
+ <option key={c} value={c.unit}>
+ <div>{c.name}</div>
+ </option>
+ ))}
+ </select>
+ )
+ }
+ value={textValue}
+ disabled={!handler.onInput}
+ onInput={positiveAmount}
+ />
+ {error && <div style={{ color: "red" }}>{error}</div>}
+ </Fragment>
+ );
+}
+
+/**
+ * Return the real value of a normalized unit
+ * If the value is 20 and the unit is kilo == 1000 the returned value will be amount * 1000
+ * @param amount
+ * @param unit
+ * @returns
+ */
+function denormalize(amount: AmountJson, unit: number): AmountJson {
+ if (unit === 1 || Amounts.isZero(amount)) return amount;
+ const result =
+ unit < 1
+ ? Amounts.divide(amount, 1 / unit)
+ : Amounts.mult(amount, unit).amount;
+ return result;
+}
+
+/**
+ * Return the amount in the current unit.
+ * If the value is 20000 and the unit is kilo == 1000 and the returned value will be amount / unit
+ *
+ * @param amount
+ * @param unit
+ * @returns
+ */
+function normalize(amount: AmountJson, unit: number): AmountJson {
+ if (unit === 1 || Amounts.isZero(amount)) return amount;
+ const result =
+ unit < 1
+ ? Amounts.mult(amount, 1 / unit).amount
+ : Amounts.divide(amount, unit);
+ return result;
+}
+
+/**
+ * Take every label in HIGH_DENOM_SYMBOL and LOW_DENOM_SYMBOL and create
+ * which create the corresponding unit multiplier
+ * @param currency
+ * @param lowestDenom
+ * @param highestDenom
+ * @returns
+ */
+function buildLabelsForCurrency(
+ currency: string,
+ lowestDenom: number,
+ highestDenom: number,
+): Array<{ name: string; unit: number }> {
+ let hd = Math.floor(Math.log10(highestDenom || 1) / 3);
+ let ld = Math.ceil((-1 * Math.log10(lowestDenom || 1)) / 3);
+
+ const result: Array<{ name: string; unit: number }> = [
+ {
+ name: currency,
+ unit: 1,
+ },
+ ];
+
+ while (hd > 0) {
+ result.push({
+ name: `${HIGH_DENOM_SYMBOL[hd]}${currency}`,
+ unit: Math.pow(10, hd * 3),
+ });
+ hd--;
+ }
+ while (ld > 0) {
+ result.push({
+ name: `${LOW_DENOM_SYMBOL[ld]}${currency}`,
+ unit: Math.pow(10, -1 * ld * 3),
+ });
+ ld--;
+ }
+ return result;
+}
diff --git a/packages/taler-wallet-webextension/src/components/BalanceTable.tsx b/packages/taler-wallet-webextension/src/components/BalanceTable.tsx
new file mode 100644
index 000000000..6dd577b88
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/BalanceTable.tsx
@@ -0,0 +1,64 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Amounts, ScopeType, WalletBalance } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import {
+ TableWithRoundRows as TableWithRoundedRows
+} from "./styled/index.js";
+
+export function BalanceTable({
+ balances,
+ goToWalletHistory,
+}: {
+ balances: WalletBalance[];
+ goToWalletHistory: (currency: string) => void;
+}): VNode {
+ return (
+ <Fragment>
+ <TableWithRoundedRows>
+ {balances.map((entry, idx) => {
+ const av = Amounts.parseOrThrow(entry.available);
+
+ return (
+ <tr
+ key={idx}
+ onClick={() => goToWalletHistory(av.currency)}
+ style={{ cursor: "pointer" }}
+ >
+ <td>{av.currency}</td>
+ <td
+ style={{
+ fontSize: "2em",
+ textAlign: "right",
+ width: "100%",
+ }}
+ >
+ {Amounts.stringifyValue(av, 2)}
+ <div style={{ fontSize: "small", color: "grey" }}>
+ {entry.scopeInfo.type === ScopeType.Exchange ||
+ entry.scopeInfo.type === ScopeType.Auditor
+ ? entry.scopeInfo.url
+ : undefined}
+ </div>
+ </td>
+ </tr>
+ );
+ })}
+ </TableWithRoundedRows>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx b/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx
new file mode 100644
index 000000000..8b6377fc5
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx
@@ -0,0 +1,360 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountJson,
+ Amounts,
+ parsePaytoUri,
+ segwitMinAmount,
+ stringifyPaytoUri,
+ TranslatedString,
+ WithdrawalExchangeAccountDetails,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { CopiedIcon, CopyIcon } from "../svg/index.js";
+import { Amount } from "./Amount.js";
+import { ButtonBox, TooltipLeft, WarningBox } from "./styled/index.js";
+import { Button } from "../mui/Button.js";
+
+export interface BankDetailsProps {
+ subject: string;
+ amount: AmountJson;
+ accounts: WithdrawalExchangeAccountDetails[];
+}
+
+export function BankDetailsByPaytoType({
+ subject,
+ amount,
+ accounts: unsortedAccounts,
+}: BankDetailsProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [index, setIndex] = useState(0);
+
+ if (!unsortedAccounts.length) {
+ return <div>the exchange account list is empty</div>;
+ }
+
+ const accounts = unsortedAccounts.sort((a, b) => {
+ return (b.priority ?? 0) - (a.priority ?? 0);
+ });
+
+ const selectedAccount = accounts[index];
+ const altCurrency = selectedAccount.currencySpecification?.name;
+
+ const payto = parsePaytoUri(selectedAccount.paytoUri);
+
+ if (!payto) return <Fragment />;
+ payto.params["amount"] = altCurrency
+ ? selectedAccount.transferAmount!
+ : Amounts.stringify(amount);
+ payto.params["message"] = subject;
+
+ function Frame({
+ title,
+ children,
+ }: {
+ title: TranslatedString;
+ children: ComponentChildren;
+ }): VNode {
+ return (
+ <section
+ style={{
+ textAlign: "left",
+ border: "solid 1px black",
+ padding: 8,
+ borderRadius: 4,
+ }}
+ >
+ <div
+ style={{
+ display: "flex",
+ width: "100%",
+ justifyContent: "space-between",
+ }}
+ >
+ <p style={{ marginTop: 0 }}>{title}</p>
+ <div></div>
+ </div>
+
+ {children}
+
+ {accounts.length > 1 ? (
+ <Fragment>
+ {accounts.map((ac, acIdx) => {
+ const accountLabel = ac.bankLabel ?? `Account #${acIdx + 1}`;
+ return (
+ <Button
+ key={acIdx}
+ variant={acIdx === index ? "contained" : "outlined"}
+ onClick={async () => {
+ setIndex(acIdx);
+ }}
+ >
+ {accountLabel} (
+ {ac.currencySpecification?.name ?? amount.currency})
+ </Button>
+ );
+ })}
+
+ {/* <Button variant={currency === altCurrency ? "contained" : "outlined"}
+ onClick={async () => {
+ setCurrency(altCurrency)
+ }}
+ >
+ <i18n.Translate>{altCurrency}</i18n.Translate>
+ </Button> */}
+ </Fragment>
+ ) : undefined}
+ </section>
+ );
+ }
+
+ if (payto.isKnown && payto.targetType === "bitcoin") {
+ const min = segwitMinAmount(amount.currency);
+ const addrs = payto.segwitAddrs.map(
+ (a) => `${a} ${Amounts.stringifyValue(min)}`,
+ );
+ addrs.unshift(`${payto.targetPath} ${Amounts.stringifyValue(amount)}`);
+ const copyContent = addrs.join("\n");
+ return (
+ <Frame title={i18n.str`Bitcoin transfer details`}>
+ <p>
+ <i18n.Translate>
+ The exchange need a transaction with 3 output, one output is the
+ exchange account and the other two are segwit fake address for
+ metadata with an minimum amount.
+ </i18n.Translate>
+ </p>
+
+ <p>
+ <i18n.Translate>
+ In bitcoincore wallet use &apos;Add Recipient&apos; button to add
+ two additional recipient and copy addresses and amounts
+ </i18n.Translate>
+ </p>
+ <table>
+ <tr>
+ <td>
+ <div>
+ {payto.targetPath} <Amount value={amount} hideCurrency /> BTC
+ </div>
+ {payto.segwitAddrs.map((addr, i) => (
+ <div key={i}>
+ {addr} <Amount value={min} hideCurrency /> BTC
+ </div>
+ ))}
+ </td>
+ <td></td>
+ <td>
+ <CopyButton getContent={() => copyContent} />
+ </td>
+ </tr>
+ </table>
+ <p>
+ <i18n.Translate>
+ Make sure the amount show{" "}
+ {Amounts.stringifyValue(Amounts.sum([amount, min, min]).amount)}{" "}
+ BTC, else you have to change the base unit to BTC
+ </i18n.Translate>
+ </p>
+ </Frame>
+ );
+ }
+
+ const accountPart = !payto.isKnown ? (
+ <Fragment>
+ <Row name={i18n.str`Account`} value={payto.targetPath} />
+ </Fragment>
+ ) : payto.targetType === "x-taler-bank" ? (
+ <Fragment>
+ <Row name={i18n.str`Bank host`} value={payto.host} />
+ <Row name={i18n.str`Bank account`} value={payto.account} />
+ </Fragment>
+ ) : payto.targetType === "iban" ? (
+ <Fragment>
+ {payto.bic !== undefined ? (
+ <Row name={i18n.str`BIC`} value={payto.bic} />
+ ) : undefined}
+ <Row name={i18n.str`IBAN`} value={payto.iban} />
+ </Fragment>
+ ) : undefined;
+
+ const receiver =
+ payto.params["receiver-name"] || payto.params["receiver"] || undefined;
+ return (
+ <Frame title={i18n.str`Bank transfer details`}>
+ <table>
+ <tbody>
+ <tr>
+ <td colSpan={3}>
+ <i18n.Translate>Step 1:</i18n.Translate>
+ &nbsp;
+ <i18n.Translate>
+ Copy this code and paste it into the subject/purpose field in
+ your banking app or bank website
+ </i18n.Translate>
+ </td>
+ </tr>
+ <Row name={i18n.str`Subject`} value={subject} literal />
+
+ <tr>
+ <td colSpan={3}>
+ <i18n.Translate>Step 2:</i18n.Translate>
+ &nbsp;
+ <i18n.Translate>
+ If you don't already have it in your banking favourites list,
+ then copy and paste this IBAN and the name into the receiver
+ fields in your banking app or website
+ </i18n.Translate>
+ </td>
+ </tr>
+ {accountPart}
+ {receiver ? (
+ <Row name={i18n.str`Receiver name`} value={receiver} />
+ ) : undefined}
+
+ <tr>
+ <td colSpan={3}>
+ <i18n.Translate>Step 3:</i18n.Translate>
+ &nbsp;
+ <i18n.Translate>
+ Finish the wire transfer setting the amount in your banking app
+ or website, then this withdrawal will proceed automatically.
+ </i18n.Translate>
+ </td>
+ </tr>
+ <Row
+ name={i18n.str`Amount`}
+ value={
+ <Amount
+ value={altCurrency ? selectedAccount.transferAmount! : amount}
+ hideCurrency
+ />
+ }
+ />
+
+ <tr>
+ <td colSpan={3}>
+ <WarningBox style={{ margin: 0 }}>
+ <span>
+ <i18n.Translate>
+ Make sure ALL data is correct, including the subject;
+ otherwise, the money will not arrive in this wallet. You can
+ use the copy buttons (<CopyIcon />) to prevent typing errors
+ or the "payto://" URI below to copy just one value.
+ </i18n.Translate>
+ </span>
+ </WarningBox>
+ </td>
+ </tr>
+
+ <tr>
+ <td colSpan={2} width="100%" style={{ wordBreak: "break-all" }}>
+ <i18n.Translate>
+ Alternative if your bank already supports PayTo URI, you can use
+ this{" "}
+ <a
+ target="_bank"
+ rel="noreferrer"
+ title="RFC 8905 for designating targets for payments"
+ href="https://tools.ietf.org/html/rfc8905"
+ >
+ PayTo URI
+ </a>{" "}
+ link instead
+ </i18n.Translate>
+ </td>
+ <td>
+ <CopyButton getContent={() => stringifyPaytoUri(payto)} />
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </Frame>
+ );
+}
+
+function CopyButton({ getContent }: { getContent: () => string }): VNode {
+ const [copied, setCopied] = useState(false);
+ function copyText(): void {
+ navigator.clipboard.writeText(getContent() || "");
+ setCopied(true);
+ }
+ useEffect(() => {
+ if (copied) {
+ setTimeout(() => {
+ setCopied(false);
+ }, 1000);
+ }
+ }, [copied]);
+
+ if (!copied) {
+ return (
+ <ButtonBox onClick={copyText}>
+ <CopyIcon />
+ </ButtonBox>
+ );
+ }
+ return (
+ <TooltipLeft content="Copied">
+ <ButtonBox disabled>
+ <CopiedIcon />
+ </ButtonBox>
+ </TooltipLeft>
+ );
+}
+
+function Row({
+ name,
+ value,
+ literal,
+}: {
+ name: TranslatedString;
+ value: string | VNode;
+ literal?: boolean;
+}): VNode {
+ const preRef = useRef<HTMLPreElement>(null);
+ const tdRef = useRef<HTMLTableCellElement>(null);
+
+ function getContent(): string {
+ return preRef.current?.textContent || tdRef.current?.textContent || "";
+ }
+
+ return (
+ <tr>
+ <td style={{ paddingRight: 8 }}>
+ <b>{name}</b>
+ </td>
+ {literal ? (
+ <td>
+ <pre
+ ref={preRef}
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ >
+ {value}
+ </pre>
+ </td>
+ ) : (
+ <td ref={tdRef}>{value}</td>
+ )}
+ <td>
+ <CopyButton getContent={getContent} />
+ </td>
+ </tr>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/Banner.stories.tsx b/packages/taler-wallet-webextension/src/components/Banner.stories.tsx
new file mode 100644
index 000000000..ee2dbfc69
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/Banner.stories.tsx
@@ -0,0 +1,126 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Fragment, h, VNode } from "preact";
+import { Avatar } from "../mui/Avatar.js";
+import { Typography } from "../mui/Typography.js";
+import { Banner } from "./Banner.js";
+import { SvgIcon } from "./styled/index.js";
+import wifiIcon from "../svg/wifi.inline.svg";
+export default {
+ title: "banner",
+ component: Banner,
+};
+
+function Wrapper({ children }: any): VNode {
+ return (
+ <div
+ style={{
+ display: "flex",
+ backgroundColor: "lightgray",
+ padding: 10,
+ width: "100%",
+ // width: 400,
+ // height: 400,
+ justifyContent: "center",
+ }}
+ >
+ <div style={{ flexGrow: 1 }}>{children}</div>
+ </div>
+ );
+}
+function SignalWifiOffIcon({ ...rest }: any): VNode {
+ return <SvgIcon {...rest} dangerouslySetInnerHTML={{ __html: wifiIcon }} />;
+}
+
+export const BasicExample = (): VNode => (
+ <Fragment>
+ <Wrapper>
+ <p>
+ Example taken from:
+ <a
+ target="_blank"
+ rel="noreferrer"
+ href="https://medium.com/material-ui/introducing-material-ui-design-system-93e921beb8df"
+ >
+ https://medium.com/material-ui/introducing-material-ui-design-system-93e921beb8df
+ </a>
+ </p>
+ <Banner
+ // elements={[
+ // {
+ // icon: <SignalWifiOffIcon color="gray" />,
+ // description: (
+ // <Typography>
+ // You have lost connection to the internet. This app is offline.
+ // </Typography>
+ // ),
+ // },
+ // ]}
+ confirm={{
+ label: "turn on wifi",
+ action: async () => {
+ return;
+ },
+ }}
+ >
+ <div />
+ </Banner>
+ </Wrapper>
+ </Fragment>
+);
+
+export const PendingOperation = (): VNode => (
+ <Fragment>
+ <Wrapper>
+ <Banner
+ title="PENDING TRANSACTIONS"
+ style={{ backgroundColor: "lightcyan", padding: 8 }}
+ // elements={[
+ // {
+ // icon: (
+ // <Avatar
+ // style={{
+ // border: "solid blue 1px",
+ // color: "blue",
+ // boxSizing: "border-box",
+ // }}
+ // >
+ // P
+ // </Avatar>
+ // ),
+ // description: (
+ // <Fragment>
+ // <Typography inline bold>
+ // EUR 37.95
+ // </Typography>
+ // &nbsp;
+ // <Typography inline>- 5 feb 2022</Typography>
+ // </Fragment>
+ // ),
+ // },
+ // ]}
+ >
+ asd
+ </Banner>
+ </Wrapper>
+ </Fragment>
+);
diff --git a/packages/taler-wallet-webextension/src/components/Banner.tsx b/packages/taler-wallet-webextension/src/components/Banner.tsx
new file mode 100644
index 000000000..40a4847b8
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/Banner.tsx
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { ComponentChildren, Fragment, h, JSX, VNode } from "preact";
+import { Button } from "../mui/Button.js";
+import { Divider } from "../mui/Divider.js";
+import { Grid } from "../mui/Grid.js";
+import { Paper } from "../mui/Paper.js";
+
+interface Props extends JSX.HTMLAttributes<HTMLDivElement> {
+ titleHead?: VNode | TranslatedString;
+ children: ComponentChildren;
+ // elements: {
+ // icon?: VNode;
+ // description: VNode;
+ // action?: () => void;
+ // }[];
+ confirm?: {
+ label: string;
+ action: () => Promise<void>;
+ };
+}
+
+export function Banner({
+ titleHead,
+ children,
+ confirm,
+ href,
+ ...rest
+}: Props): VNode {
+ return (
+ <Fragment>
+ <Paper elevation={0} {...rest}>
+ {titleHead && (
+ <Grid container>
+ <Grid item>{titleHead}</Grid>
+ </Grid>
+ )}
+ <Grid container columns={1}>
+ {children}
+ </Grid>
+ {confirm && (
+ <Grid container justifyContent="flex-end" spacing={8}>
+ <Grid item>
+ <Button color="primary" onClick={confirm.action}>
+ {confirm.label}
+ </Button>
+ </Grid>
+ </Grid>
+ )}
+ </Paper>
+ <Divider />
+ </Fragment>
+ );
+}
+
+export default Banner;
diff --git a/packages/taler-wallet-webextension/src/components/Checkbox.tsx b/packages/taler-wallet-webextension/src/components/Checkbox.tsx
index 2d7b98087..ec1b93a01 100644
--- a/packages/taler-wallet-webextension/src/components/Checkbox.tsx
+++ b/packages/taler-wallet-webextension/src/components/Checkbox.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 Taler Systems SA
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,17 +14,24 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { JSX } from "preact/jsx-runtime";
-import { h } from "preact";
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
interface Props {
- enabled: boolean;
- onToggle: () => void;
- label: string;
+ enabled?: boolean;
+ onToggle?: () => Promise<void>;
+ label: TranslatedString;
name: string;
- description?: string;
+ description?: VNode | TranslatedString;
}
-export function Checkbox({ name, enabled, onToggle, label, description }: Props): JSX.Element {
+export function Checkbox({
+ name,
+ enabled,
+ onToggle,
+ label,
+ description,
+}: Props): VNode {
+
return (
<div>
<input
@@ -32,23 +39,26 @@ export function Checkbox({ name, enabled, onToggle, label, description }: Props)
onClick={onToggle}
type="checkbox"
id={`checkbox-${name}`}
- style={{ width: "1.5em", height: "1.5em", verticalAlign: "middle" }} />
+ style={{ width: "1.5em", height: "1.5em", verticalAlign: "middle" }}
+ />
<label
htmlFor={`checkbox-${name}`}
style={{ marginLeft: "0.5em", fontWeight: "bold" }}
>
{label}
</label>
- {description && <span
- style={{
- color: "#383838",
- fontSize: "smaller",
- display: "block",
- marginLeft: "2em",
- }}
- >
- {description}
- </span>}
+ {description && (
+ <span
+ style={{
+ color: "#383838",
+ fontSize: "smaller",
+ display: "block",
+ marginLeft: "2em",
+ }}
+ >
+ {description}
+ </span>
+ )}
</div>
);
}
diff --git a/packages/taler-wallet-webextension/src/components/CheckboxOutlined.tsx b/packages/taler-wallet-webextension/src/components/CheckboxOutlined.tsx
index 5e30ee3d1..79712c2f4 100644
--- a/packages/taler-wallet-webextension/src/components/CheckboxOutlined.tsx
+++ b/packages/taler-wallet-webextension/src/components/CheckboxOutlined.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 Taler Systems SA
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,45 +14,55 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { JSX } from "preact/jsx-runtime";
-import { Outlined, StyledCheckboxLabel } from "./styled/index";
-import { h } from 'preact';
+import { Outlined, StyledCheckboxLabel } from "./styled/index.js";
+import { h, VNode } from "preact";
interface Props {
- enabled: boolean;
- onToggle: () => void;
- label: string;
+ enabled?: boolean;
+ onToggle?: () => Promise<void>;
+ label: VNode;
name: string;
}
+const Tick = (): VNode => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ focusable="false"
+ style={{ backgroundColor: "green" }}
+ >
+ <path
+ fill="none"
+ stroke="white"
+ stroke-width="3"
+ d="M1.73 12.91l6.37 6.37L22.79 4.59"
+ />
+ </svg>
+);
-const Tick = () => <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- aria-hidden="true"
- focusable="false"
- style={{ backgroundColor: 'green' }}
->
- <path
- fill="none"
- stroke="white"
- stroke-width="3"
- d="M1.73 12.91l6.37 6.37L22.79 4.59"
- />
-</svg>
-
-export function CheckboxOutlined({ name, enabled, onToggle, label }: Props): JSX.Element {
+export function CheckboxOutlined({
+ name,
+ enabled,
+ onToggle,
+ label,
+}: Props): VNode {
return (
- <Outlined>
- <StyledCheckboxLabel onClick={onToggle}>
+ <StyledCheckboxLabel onClick={onToggle}>
+ <Outlined>
<span>
- <input type="checkbox" name={name} checked={enabled} disabled={false} />
+ <input
+ type="checkbox"
+ name={name}
+ checked={enabled}
+ disabled={false}
+ />
<div>
<Tick />
</div>
<label for={name}>{label}</label>
</span>
- </StyledCheckboxLabel>
- </Outlined>
+ </Outlined>
+ </StyledCheckboxLabel>
);
}
diff --git a/packages/taler-wallet-webextension/src/components/CopyButton.tsx b/packages/taler-wallet-webextension/src/components/CopyButton.tsx
new file mode 100644
index 000000000..2024e2423
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/CopyButton.tsx
@@ -0,0 +1,54 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { CopiedIcon, CopyIcon } from "../svg/index.js";
+import { ButtonBox, TooltipLeft } from "./styled/index.js";
+
+export function CopyButton({
+ getContent,
+}: {
+ getContent: () => string;
+}): VNode {
+ const [copied, setCopied] = useState(false);
+ function copyText(): void {
+ navigator.clipboard.writeText(getContent() || "");
+ setCopied(true);
+ }
+ useEffect(() => {
+ if (copied) {
+ setTimeout(() => {
+ setCopied(false);
+ }, 1000);
+ }
+ }, [copied]);
+
+ if (!copied) {
+ return (
+ <ButtonBox onClick={copyText}>
+ <CopyIcon />
+ </ButtonBox>
+ );
+ }
+ return (
+ <TooltipLeft content="Copied">
+ <ButtonBox disabled>
+ <CopiedIcon />
+ </ButtonBox>
+ </TooltipLeft>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/CurrentAlerts.tsx b/packages/taler-wallet-webextension/src/components/CurrentAlerts.tsx
new file mode 100644
index 000000000..b1ed3b02c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/CurrentAlerts.tsx
@@ -0,0 +1,147 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import {
+ Alert as AlertNotification,
+ useAlertContext,
+} from "../context/alert.js";
+import { Alert } from "../mui/Alert.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+
+/**
+ *
+ * @author sebasjm
+ */
+
+function AlertContext({
+ context,
+ cause,
+}: {
+ cause: unknown;
+ context: undefined | object;
+}): VNode {
+ const [more, setMore] = useState(false);
+ const [wrap, setWrap] = useState(false);
+ const { i18n } = useTranslationContext();
+ if (!more) {
+ return (
+ <div style={{ display: "flex", justifyContent: "right" }}>
+ <a
+ onClick={() => setMore(true)}
+ style={{ cursor: "pointer", textDecoration: "underline" }}
+ >
+ <i18n.Translate>more info</i18n.Translate>
+ </a>
+ </div>
+ );
+ }
+ const errorInfo = JSON.stringify(
+ context === undefined ? { cause } : { context, cause },
+ undefined,
+ 2,
+ );
+ return (
+ <Fragment>
+ <div style={{ display: "flex", justifyContent: "right" }}>
+ <a
+ onClick={() => setWrap(!wrap)}
+ style={{ cursor: "pointer", textDecoration: "underline" }}
+ >
+ <i18n.Translate>wrap text</i18n.Translate>
+ </a>
+ &nbsp;&nbsp;
+ <a
+ onClick={() => navigator.clipboard.writeText(errorInfo)}
+ style={{ cursor: "pointer", textDecoration: "underline" }}
+ >
+ <i18n.Translate>copy content</i18n.Translate>
+ </a>
+ &nbsp;&nbsp;
+ <a
+ onClick={() => setMore(false)}
+ style={{ cursor: "pointer", textDecoration: "underline" }}
+ >
+ <i18n.Translate>less info</i18n.Translate>
+ </a>
+ </div>
+ <pre
+ style={
+ wrap
+ ? {
+ whiteSpace: "pre-wrap",
+ overflowWrap: "anywhere",
+ }
+ : {
+ overflow: "overlay",
+ }
+ }
+ >
+ {errorInfo}
+ </pre>
+ </Fragment>
+ );
+}
+
+export function ErrorAlertView({
+ error,
+ onClose,
+}: {
+ error: AlertNotification;
+ onClose?: () => Promise<void>;
+}): VNode {
+ return (
+ <Wrapper>
+ <AlertView alert={error} onClose={onClose} />
+ </Wrapper>
+ );
+}
+
+export function AlertView({
+ alert,
+ onClose,
+}: {
+ alert: AlertNotification;
+ onClose?: () => Promise<void>;
+}): VNode {
+ return (
+ <Alert title={alert.message} severity={alert.type} onClose={onClose}>
+ <div style={{ display: "flex", flexDirection: "column" }}>
+ <div>{alert.description}</div>
+ {alert.type === "error" && alert.cause !== undefined ? (
+ <AlertContext context={alert.context} cause={alert.cause} />
+ ) : undefined}
+ </div>
+ </Alert>
+ );
+}
+
+export function CurrentAlerts(): VNode {
+ const { alerts, removeAlert } = useAlertContext();
+ if (alerts.length === 0) return <Fragment />;
+ return (
+ <Wrapper>
+ {alerts.map((n, i) => (
+ <AlertView key={i} alert={n} onClose={async () => removeAlert(n)} />
+ ))}
+ </Wrapper>
+ );
+}
+
+function Wrapper({ children }: { children: ComponentChildren }): VNode {
+ return <div style={{ margin: "1em" }}>{children}</div>;
+}
diff --git a/packages/taler-wallet-webextension/src/components/DebugCheckbox.tsx b/packages/taler-wallet-webextension/src/components/DebugCheckbox.tsx
deleted file mode 100644
index f0c682ccb..000000000
--- a/packages/taler-wallet-webextension/src/components/DebugCheckbox.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
- import { JSX, h } from "preact";
-
-export function DebugCheckbox({ enabled, onToggle }: { enabled: boolean; onToggle: () => void; }): JSX.Element {
- return (
- <div>
- <input
- checked={enabled}
- onClick={onToggle}
- type="checkbox"
- id="checkbox-perm"
- style={{ width: "1.5em", height: "1.5em", verticalAlign: "middle" }} />
- <label
- htmlFor="checkbox-perm"
- style={{ marginLeft: "0.5em", fontWeight: "bold" }}
- >
- Automatically open wallet based on page content
- </label>
- <span
- style={{
- color: "#383838",
- fontSize: "smaller",
- display: "block",
- marginLeft: "2em",
- }}
- >
- (Enabling this option below will make using the wallet faster, but
- requires more permissions from your browser.)
- </span>
- </div>
- );
-}
diff --git a/packages/taler-wallet-webextension/src/components/Diagnostics.tsx b/packages/taler-wallet-webextension/src/components/Diagnostics.tsx
index b48deb847..8bd0abcaf 100644
--- a/packages/taler-wallet-webextension/src/components/Diagnostics.tsx
+++ b/packages/taler-wallet-webextension/src/components/Diagnostics.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,59 +15,72 @@
*/
import { WalletDiagnostics } from "@gnu-taler/taler-util";
-import { h } from "preact";
-import { JSX } from "preact/jsx-runtime";
-import { PageLink } from "../renderHtml";
+import { Fragment, h, VNode } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
interface Props {
timedOut: boolean;
- diagnostics: WalletDiagnostics | undefined
+ diagnostics: WalletDiagnostics | undefined;
}
-export function Diagnostics({timedOut, diagnostics}: Props): JSX.Element | null {
-
+export function Diagnostics({ timedOut, diagnostics }: Props): VNode {
+ const { i18n } = useTranslationContext();
if (timedOut) {
- return <p>Diagnostics timed out. Could not talk to the wallet backend.</p>;
+ return (
+ <p>
+ <i18n.Translate>
+ Diagnostics timed out. Could not talk to the wallet backend.
+ </i18n.Translate>
+ </p>
+ );
}
if (diagnostics) {
if (diagnostics.errors.length === 0) {
- return null;
- } else {
- return (
- <div
- style={{
- borderLeft: "0.5em solid red",
- paddingLeft: "1em",
- paddingTop: "0.2em",
- paddingBottom: "0.2em",
- }}
- >
- <p>Problems detected:</p>
- <ol>
- {diagnostics.errors.map((errMsg) => (
- <li key={errMsg}>{errMsg}</li>
- ))}
- </ol>
- {diagnostics.firefoxIdbProblem ? (
- <p>
+ return <Fragment />;
+ }
+ return (
+ <div
+ style={{
+ borderLeft: "0.5em solid red",
+ paddingLeft: "1em",
+ paddingTop: "0.2em",
+ paddingBottom: "0.2em",
+ }}
+ >
+ <p>
+ <i18n.Translate>Problems detected:</i18n.Translate>
+ </p>
+ <ol>
+ {diagnostics.errors.map((errMsg) => (
+ <li key={errMsg}>{errMsg}</li>
+ ))}
+ </ol>
+ {diagnostics.firefoxIdbProblem ? (
+ <p>
+ <i18n.Translate>
Please check in your <code>about:config</code> settings that you
have IndexedDB enabled (check the preference name{" "}
<code>dom.indexedDB.enabled</code>).
- </p>
- ) : null}
- {diagnostics.dbOutdated ? (
- <p>
+ </i18n.Translate>
+ </p>
+ ) : null}
+ {diagnostics.dbOutdated ? (
+ <p>
+ <i18n.Translate>
Your wallet database is outdated. Currently automatic migration is
- not supported. Please go{" "}
- <PageLink pageName="/reset-required">here</PageLink> to reset
- the wallet database.
- </p>
- ) : null}
- </div>
- );
- }
+ not supported. Please go <i18n.Translate>here</i18n.Translate>
+ to reset the wallet database.
+ </i18n.Translate>
+ </p>
+ ) : null}
+ </div>
+ );
}
- return <p>Running diagnostics ...</p>;
+ return (
+ <p>
+ <i18n.Translate>Running diagnostics</i18n.Translate> ...
+ </p>
+ );
}
diff --git a/packages/taler-wallet-webextension/src/components/EditableText.tsx b/packages/taler-wallet-webextension/src/components/EditableText.tsx
index 6f3388bf9..1da090492 100644
--- a/packages/taler-wallet-webextension/src/components/EditableText.tsx
+++ b/packages/taler-wallet-webextension/src/components/EditableText.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 Taler Systems SA
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,9 +14,9 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { h } from "preact";
+import { h, VNode } from "preact";
import { useRef, useState } from "preact/hooks";
-import { JSX } from "preact/jsx-runtime";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
interface Props {
value: string;
@@ -25,25 +25,44 @@ interface Props {
name: string;
description?: string;
}
-export function EditableText({ name, value, onChange, label, description }: Props): JSX.Element {
- const [editing, setEditing] = useState(false)
- const ref = useRef<HTMLInputElement>(null)
+export function EditableText({
+ name,
+ value,
+ onChange,
+ label,
+ description,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [editing, setEditing] = useState(false);
+ const ref = useRef<HTMLInputElement>(null);
let InputText;
if (!editing) {
- InputText = () => <div style={{ display: 'flex', justifyContent: 'space-between' }}>
- <p>{value}</p>
- <button onClick={() => setEditing(true)}>edit</button>
- </div>
+ InputText = function InputToEdit(): VNode {
+ return (
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <p>{value}</p>
+ <button onClick={() => setEditing(true)}>
+ <i18n.Translate>Edit</i18n.Translate>
+ </button>
+ </div>
+ );
+ };
} else {
- InputText = () => <div style={{ display: 'flex', justifyContent: 'space-between' }}>
- <input
- value={value}
- ref={ref}
- type="text"
- id={`text-${name}`}
- />
- <button onClick={() => { if (ref.current) onChange(ref.current.value).then(r => setEditing(false)) }}>confirm</button>
- </div>
+ InputText = function InputEditing(): VNode {
+ return (
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <input value={value} ref={ref} type="text" id={`text-${name}`} />
+ <button
+ onClick={() => {
+ if (ref.current)
+ onChange(ref.current.value).then(() => setEditing(false));
+ }}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ );
+ };
}
return (
<div>
@@ -54,16 +73,18 @@ export function EditableText({ name, value, onChange, label, description }: Prop
{label}
</label>
<InputText />
- {description && <span
- style={{
- color: "#383838",
- fontSize: "smaller",
- display: "block",
- marginLeft: "2em",
- }}
- >
- {description}
- </span>}
+ {description && (
+ <span
+ style={{
+ color: "#383838",
+ fontSize: "smaller",
+ display: "block",
+ marginLeft: "2em",
+ }}
+ >
+ {description}
+ </span>
+ )}
</div>
);
}
diff --git a/packages/taler-wallet-webextension/src/components/EnabledBySettings.tsx b/packages/taler-wallet-webextension/src/components/EnabledBySettings.tsx
new file mode 100644
index 000000000..6f666d301
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/EnabledBySettings.tsx
@@ -0,0 +1,38 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { useSettings } from "../hooks/useSettings.js";
+import { Settings } from "../platform/api.js";
+
+export function EnabledBySettings<K extends keyof Settings>({
+ children,
+ value,
+ name,
+}: {
+ name: K;
+ value?: Settings[K];
+ children: ComponentChildren;
+}): VNode {
+ const [settings] = useSettings();
+ if (value === undefined) {
+ if (!settings[name]) return <Fragment />;
+ return <Fragment>{children}</Fragment>;
+ }
+ if (settings[name] !== value) {
+ return <Fragment />;
+ }
+ return <Fragment>{children}</Fragment>;
+}
diff --git a/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx b/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx
index cfcef16d5..06c8a81ef 100644
--- a/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx
+++ b/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 Taler Systems SA
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -13,22 +13,48 @@
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- import { VNode, h } from "preact";
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
import { useState } from "preact/hooks";
-import arrowDown from '../../static/img/chevron-down.svg';
-import { ErrorBox } from "./styled";
+import arrowDown from "../svg/chevron-down.inline.svg";
+import { ErrorBox } from "./styled/index.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
-export function ErrorMessage({ title, description }: { title?: string|VNode; description?: string; }) {
+export function ErrorMessage({
+ title,
+ description,
+}: {
+ title: TranslatedString;
+ description?: string | VNode | Error;
+}): VNode | null {
const [showErrorDetail, setShowErrorDetail] = useState(false);
- if (!title)
- return null;
- return <ErrorBox style={{paddingTop: 0, paddingBottom: 0}}>
- <div>
- <p>{title}</p>
- { description && <button onClick={() => { setShowErrorDetail(v => !v); }}>
- <img style={{ height: '1.5em' }} src={arrowDown} />
- </button> }
- </div>
- {showErrorDetail && <p>{description}</p>}
- </ErrorBox>;
+ const [showMore, setShowMore] = useState(false);
+ const { i18n } = useTranslationContext();
+ return (
+ <ErrorBox style={{ paddingTop: 0, paddingBottom: 0 }}>
+ <div>
+ <p>{title}</p>
+ {description && (
+ <button
+ onClick={() => {
+ setShowErrorDetail((v) => !v);
+ }}
+ >
+ <div
+ style={{ height: "1.5em" }}
+ dangerouslySetInnerHTML={{ __html: arrowDown }}
+ />
+ </button>
+ )}
+ </div>
+ {showErrorDetail && description && <p>
+ {description instanceof Error && !showMore ? description.message : description.toString()}
+ {description instanceof Error && <div>
+ <a href="#" onClick={(e) => {
+ setShowMore(!showMore)
+ e.preventDefault()
+ }}>{showMore ? i18n.str`show less` : i18n.str`show more`} </a> </div>}
+ </p>}
+ </ErrorBox>
+ );
}
diff --git a/packages/taler-wallet-webextension/src/components/ErrorTalerOperation.tsx b/packages/taler-wallet-webextension/src/components/ErrorTalerOperation.tsx
new file mode 100644
index 000000000..3298840e2
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/ErrorTalerOperation.tsx
@@ -0,0 +1,73 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { TalerErrorDetail, TranslatedString } from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import arrowDown from "../svg/chevron-down.inline.svg";
+import { ErrorBox } from "./styled/index.js";
+import { EnabledBySettings } from "./EnabledBySettings.js";
+
+export function ErrorTalerOperation({
+ title,
+ error,
+}: {
+ title?: TranslatedString;
+ error?: TalerErrorDetail;
+}): VNode | null {
+ const [showErrorDetail, setShowErrorDetail] = useState(false);
+
+ if (!title || !error) return null;
+ // const errorCode: number | undefined = (error.details as any)?.errorResponse?.code
+ const errorHint: string | undefined = (error.details as any)?.errorResponse
+ ?.hint;
+
+ return (
+ <ErrorBox style={{ paddingTop: 0, paddingBottom: 0 }}>
+ <div>
+ <p>{title}</p>
+ {error && (
+ <button
+ onClick={() => {
+ setShowErrorDetail((v) => !v);
+ }}
+ >
+ <div
+ style={{
+ transform: !showErrorDetail ? undefined : "scaleY(-1)",
+ height: 24,
+ }}
+ dangerouslySetInnerHTML={{ __html: arrowDown }}
+ />
+ </button>
+ )}
+ </div>
+ {showErrorDetail && (
+ <Fragment>
+ <div style={{ padding: 5, textAlign: "left" }}>
+ <div>
+ <b>{error.hint}</b> {!errorHint ? "" : `: ${errorHint}`}{" "}
+ </div>
+ </div>
+ <EnabledBySettings name="showJsonOnError">
+ <div style={{ textAlign: "left", overflowX: "auto" }}>
+ <pre>{JSON.stringify(error, undefined, 2)}</pre>
+ </div>
+ </EnabledBySettings>
+ </Fragment>
+ )}
+ </ErrorBox>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/ExchangeToS.tsx b/packages/taler-wallet-webextension/src/components/ExchangeToS.tsx
index cfa20280f..e7247ba33 100644
--- a/packages/taler-wallet-webextension/src/components/ExchangeToS.tsx
+++ b/packages/taler-wallet-webextension/src/components/ExchangeToS.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 Taler Systems SA
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -13,66 +13,82 @@
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- import { Fragment, VNode } from "preact"
-import { useState } from "preact/hooks"
-import { JSXInternal } from "preact/src/jsx"
-import { h } from 'preact';
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
-export function ExchangeXmlTos({ doc }: { doc: Document }) {
- const termsNode = doc.querySelector('[ids=terms-of-service]')
+export function ExchangeXmlTos({ doc }: { doc: Document }): VNode {
+ if (typeof window === "undefined") {
+ // in nodejs env we don't have xml api
+ return <div />;
+ }
+ const termsNode = doc.querySelector("[ids=terms-of-service]");
if (!termsNode) {
- return <div>
- <p>The exchange send us an xml but there is no node with 'ids=terms-of-service'. This is the content:</p>
- <pre>{new XMLSerializer().serializeToString(doc)}</pre>
- </div>
+ return (
+ <div>
+ <p>
+ The exchange send us an xml but there is no node with
+ &apos;ids=terms-of-service&apos;. This is the content:
+ </p>
+ <pre>{new XMLSerializer().serializeToString(doc)}</pre>
+ </div>
+ );
}
- return <Fragment>
- {Array.from(termsNode.children).map(renderChild)}
- </Fragment>
+ return <Fragment>{Array.from(termsNode.children).map(renderChild)}</Fragment>;
}
/**
* Map XML elements into HTML
- * @param child
- * @returns
+ * @param child
+ * @returns
*/
function renderChild(child: Element): VNode {
- const children = Array.from(child.children)
+ const children = Array.from(child.children);
switch (child.nodeName) {
- case 'title': return <header>{child.textContent}</header>
- case '#text': return <Fragment />
- case 'paragraph': return <p>{child.textContent}</p>
- case 'section': {
- return <AnchorWithOpenState href={`#terms-${child.getAttribute('ids')}`}>
- {children.map(renderChild)}
- </AnchorWithOpenState>
+ case "title":
+ return <header>{child.textContent}</header>;
+ case "#text":
+ return <Fragment />;
+ case "paragraph":
+ return <p>{child.textContent}</p>;
+ case "section": {
+ return (
+ <AnchorWithOpenState href={`#terms-${child.getAttribute("ids")}`}>
+ {children.map(renderChild)}
+ </AnchorWithOpenState>
+ );
}
- case 'bullet_list': {
- return <ul>{children.map(renderChild)}</ul>
+ case "bullet_list": {
+ return <ul>{children.map(renderChild)}</ul>;
}
- case 'enumerated_list': {
- return <ol>{children.map(renderChild)}</ol>
+ case "enumerated_list": {
+ return <ol>{children.map(renderChild)}</ol>;
}
- case 'list_item': {
- return <li>{children.map(renderChild)}</li>
+ case "list_item": {
+ return <li>{children.map(renderChild)}</li>;
}
- case 'block_quote': {
- return <div>{children.map(renderChild)}</div>
+ case "block_quote": {
+ return <div>{children.map(renderChild)}</div>;
}
- default: return <div style={{ color: 'red', display: 'hidden' }}>unknown tag {child.nodeName} <a></a></div>
+ default:
+ return (
+ <div style={{ color: "red", display: "hidden" }}>
+ unknown tag {child.nodeName}
+ </div>
+ );
}
}
/**
* Simple anchor with a state persisted into 'data-open' prop
- * @returns
+ * @returns
*/
-function AnchorWithOpenState(props: JSXInternal.HTMLAttributes<HTMLAnchorElement>) {
- const [open, setOpen] = useState<boolean>(false)
- function doClick(e: JSXInternal.TargetedMouseEvent<HTMLAnchorElement>) {
+function AnchorWithOpenState(
+ props: h.JSX.HTMLAttributes<HTMLAnchorElement>,
+): VNode {
+ const [open, setOpen] = useState<boolean>(false);
+ function doClick(e: h.JSX.TargetedMouseEvent<HTMLAnchorElement>): void {
setOpen(!open);
e.preventDefault();
}
- return <a data-open={open ? 'true' : 'false'} onClick={doClick} {...props} />
+ return <a data-open={open ? "true" : "false"} onClick={doClick} {...props} />;
}
-
diff --git a/packages/taler-wallet-webextension/src/components/HistoryItem.tsx b/packages/taler-wallet-webextension/src/components/HistoryItem.tsx
new file mode 100644
index 000000000..9be9326b2
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/HistoryItem.tsx
@@ -0,0 +1,432 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountJson,
+ Amounts,
+ AmountString,
+ AbsoluteTime,
+ Transaction,
+ TransactionType,
+ WithdrawalType,
+ TransactionMajorState,
+ DenomLossEventType,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Avatar } from "../mui/Avatar.js";
+import { Pages } from "../NavigationBar.js";
+import { assertUnreachable } from "../utils/index.js";
+import {
+ Column,
+ ExtraLargeText,
+ HistoryRow,
+ LargeText,
+ LightText,
+ SmallLightText,
+} from "./styled/index.js";
+import { Time } from "./Time.js";
+
+export function HistoryItem(props: { tx: Transaction }): VNode {
+ const tx = props.tx;
+ const { i18n } = useTranslationContext();
+ /**
+ *
+ */
+ switch (tx.type) {
+ case TransactionType.Withdrawal:
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"credit"}
+ title={new URL(tx.exchangeBaseUrl).hostname}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"W"}
+ currentState={tx.txState.major}
+ description={
+ tx.txState.major === TransactionMajorState.Pending
+ ? tx.withdrawalDetails.type ===
+ WithdrawalType.TalerBankIntegrationApi
+ ? !tx.withdrawalDetails.confirmed
+ ? i18n.str`Need approval in the Bank`
+ : i18n.str`Waiting for wire transfer to complete`
+ : tx.withdrawalDetails.type === WithdrawalType.ManualTransfer
+ ? i18n.str`Waiting for wire transfer to complete`
+ : "" //pending but no message
+ : undefined
+ }
+ />
+ );
+ case TransactionType.InternalWithdrawal:
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"credit"}
+ title={new URL(tx.exchangeBaseUrl).hostname}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"I"}
+ currentState={tx.txState.major}
+ description={
+ tx.txState.major === TransactionMajorState.Pending
+ ? tx.withdrawalDetails.type ===
+ WithdrawalType.TalerBankIntegrationApi
+ ? !tx.withdrawalDetails.confirmed
+ ? i18n.str`Need approval in the Bank`
+ : i18n.str`Exchange is waiting the wire transfer`
+ : tx.withdrawalDetails.type === WithdrawalType.ManualTransfer
+ ? i18n.str`Exchange is waiting the wire transfer`
+ : "" //pending but no message
+ : undefined
+ }
+ />
+ );
+ case TransactionType.Payment:
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"debit"}
+ title={tx.info.merchant.name}
+ subtitle={tx.info.summary}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"P"}
+ currentState={tx.txState.major}
+ description={
+ tx.txState.major === TransactionMajorState.Pending
+ ? i18n.str`Payment in progress`
+ : undefined
+ }
+ />
+ );
+ case TransactionType.Refund:
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"credit"}
+ subtitle={tx.paymentInfo ? tx.paymentInfo.summary : undefined} //FIXME: DD37 wallet-core is not returning this value
+ title={
+ tx.paymentInfo
+ ? tx.paymentInfo.merchant.name
+ : "--unknown merchant--"
+ } //FIXME: DD37 wallet-core is not returning this value
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"R"}
+ currentState={tx.txState.major}
+ description={
+ tx.txState.major === TransactionMajorState.Pending
+ ? i18n.str`Executing refund...`
+ : undefined
+ }
+ />
+ );
+ case TransactionType.Refresh:
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"credit"}
+ title={"Refresh"}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"R"}
+ currentState={tx.txState.major}
+ description={
+ tx.txState.major === TransactionMajorState.Pending
+ ? i18n.str`Refreshing coins...`
+ : undefined
+ }
+ />
+ );
+ case TransactionType.Deposit:{
+ const payto = parsePaytoUri(tx.targetPaytoUri);
+ const title = payto === undefined || !payto.isKnown ? tx.targetPaytoUri :
+ payto.params["receiver-name"] ;
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"debit"}
+ title={title}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"D"}
+ currentState={tx.txState.major}
+ description={
+ tx.txState.major === TransactionMajorState.Pending
+ ? i18n.str`Deposit in progress`
+ : undefined
+ }
+ />
+ );
+ }
+ case TransactionType.PeerPullCredit:
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"credit"}
+ title={tx.info.summary || "Invoice"}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"I"}
+ currentState={tx.txState.major}
+ description={
+ tx.txState.major === TransactionMajorState.Pending
+ ? i18n.str`Waiting to be paid`
+ : undefined
+ }
+ />
+ );
+ case TransactionType.PeerPullDebit:
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"debit"}
+ title={tx.info.summary || "Invoice"}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"I"}
+ currentState={tx.txState.major}
+ description={
+ tx.txState.major === TransactionMajorState.Pending
+ ? i18n.str`Payment in progress`
+ : undefined
+ }
+ />
+ );
+ case TransactionType.PeerPushCredit:
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"credit"}
+ title={tx.info.summary || "Transfer"}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"T"}
+ currentState={tx.txState.major}
+ description={
+ tx.txState.major === TransactionMajorState.Pending
+ ? i18n.str`Receiving the transfer`
+ : undefined
+ }
+ />
+ );
+ case TransactionType.PeerPushDebit:
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"debit"}
+ title={tx.info.summary || "Transfer"}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"T"}
+ currentState={tx.txState.major}
+ description={
+ tx.txState.major === TransactionMajorState.Pending
+ ? i18n.str`Waiting to be received`
+ : undefined
+ }
+ />
+ );
+ case TransactionType.DenomLoss: {
+ switch (tx.lossEventType) {
+ case DenomLossEventType.DenomExpired: {
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"debit"}
+ title={i18n.str`Denomination expired`}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"L"}
+ currentState={tx.txState.major}
+ description={undefined}
+ />
+ );
+ }
+ case DenomLossEventType.DenomVanished: {
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"debit"}
+ title={i18n.str`Denomination vanished`}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"L"}
+ currentState={tx.txState.major}
+ description={undefined}
+ />
+ );
+ }
+ case DenomLossEventType.DenomUnoffered: {
+ return (
+ <Layout
+ id={tx.transactionId}
+ amount={tx.amountEffective}
+ debitCreditIndicator={"debit"}
+ title={i18n.str`Denomination unoffered`}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)}
+ iconPath={"L"}
+ currentState={tx.txState.major}
+ description={undefined}
+ />
+ );
+ }
+ default: {
+ assertUnreachable(tx.lossEventType);
+ }
+ }
+ break;
+ }
+ case TransactionType.Recoup:
+ throw Error("recoup transaction not implemented");
+ default: {
+ assertUnreachable(tx);
+ }
+ }
+}
+
+function Layout(props: LayoutProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <HistoryRow
+ href={Pages.balanceTransaction({ tid: props.id })}
+ style={{
+ backgroundColor:
+ props.currentState === TransactionMajorState.Pending ||
+ props.currentState === TransactionMajorState.Dialog
+ ? "lightcyan"
+ : props.currentState === TransactionMajorState.Failed
+ ? "#ff000040"
+ : props.currentState === TransactionMajorState.Aborted ||
+ props.currentState === TransactionMajorState.Aborting
+ ? "#00000010"
+ : "inherit",
+ alignItems: "center",
+ }}
+ >
+ <Avatar
+ style={{
+ border: "solid gray 1px",
+ color: "gray",
+ boxSizing: "border-box",
+ }}
+ >
+ {props.iconPath}
+ </Avatar>
+ <Column>
+ <LargeText>
+ <div>{props.title}</div>
+ {props.subtitle && (
+ <div style={{ color: "gray", fontSize: "medium", marginTop: 5 }}>
+ {props.subtitle}
+ </div>
+ )}
+ </LargeText>
+ {props.description && (
+ <LightText style={{ marginTop: 5, marginBottom: 5 }}>
+ <i18n.Translate>{props.description}</i18n.Translate>
+ </LightText>
+ )}
+ <SmallLightText style={{ marginTop: 5 }}>
+ <Time timestamp={props.timestamp} format="HH:mm" />
+ </SmallLightText>
+ </Column>
+ <TransactionAmount
+ currentState={props.currentState}
+ amount={Amounts.parseOrThrow(props.amount)}
+ debitCreditIndicator={props.debitCreditIndicator}
+ />
+ </HistoryRow>
+ );
+}
+
+interface LayoutProps {
+ debitCreditIndicator: "debit" | "credit" | "unknown";
+ amount: AmountString | "unknown";
+ timestamp: AbsoluteTime;
+ title: string;
+ subtitle?: string;
+ id: string;
+ iconPath: string;
+ currentState: TransactionMajorState;
+ description?: string;
+}
+
+interface TransactionAmountProps {
+ debitCreditIndicator: "debit" | "credit" | "unknown";
+ amount: AmountJson;
+ currentState: TransactionMajorState;
+}
+
+function TransactionAmount(props: TransactionAmountProps): VNode {
+ const { i18n } = useTranslationContext();
+ let sign: string;
+ switch (props.debitCreditIndicator) {
+ case "credit":
+ sign = "+";
+ break;
+ case "debit":
+ sign = "-";
+ break;
+ case "unknown":
+ sign = "";
+ }
+ return (
+ <Column
+ style={{
+ textAlign: "center",
+ color:
+ props.currentState !== TransactionMajorState.Done
+ ? "gray"
+ : sign === "+"
+ ? "darkgreen"
+ : sign === "-"
+ ? "darkred"
+ : undefined,
+ }}
+ >
+ <ExtraLargeText>
+ {sign}
+ {Amounts.stringifyValue(props.amount, 2)}
+ </ExtraLargeText>
+ {props.currentState === TransactionMajorState.Aborted ? (
+ <div
+ style={{
+ color: "black",
+ border: "1px black solid",
+ borderRadius: 8,
+ padding: 4,
+ }}
+ >
+ <i18n.Translate>ABORTED</i18n.Translate>
+ </div>
+ ) : props.currentState === TransactionMajorState.Failed ? (
+ <div
+ style={{
+ color: "red",
+ border: "1px darkred solid",
+ borderRadius: 8,
+ padding: 4,
+ }}
+ >
+ <i18n.Translate>FAILED</i18n.Translate>
+ </div>
+ ) : undefined}
+ </Column>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/Loading.tsx b/packages/taler-wallet-webextension/src/components/Loading.tsx
new file mode 100644
index 000000000..b0209f855
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/Loading.tsx
@@ -0,0 +1,100 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { Fragment, h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import ProgressIcon from "../svg/progress.inline.svg";
+import { CenteredText } from "./styled/index.js";
+
+const fadeIn = css`
+ & {
+ animation: fadein 3s;
+ }
+ @keyframes fadein {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+`;
+
+export function Loading(): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <section style={{ margin: "auto" }}>
+ <CenteredText class={fadeIn}>
+ <i18n.Translate>Loading</i18n.Translate>...
+ </CenteredText>
+ {/* <div class={ripple} style={{ "--size": "250px" }}>
+ <div></div>
+ <div></div>
+ </div> */}
+ <div class={fadeIn} dangerouslySetInnerHTML={{ __html: ProgressIcon }} />
+ </section>
+ );
+}
+
+const ripple = css`
+ & {
+ display: inline-block;
+ position: relative;
+ width: var(--size);
+ height: var(--size);
+ }
+ & div {
+ position: absolute;
+ border: 4px solid black;
+ opacity: 1;
+ border-radius: 50%;
+ animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;
+ }
+ & div:nth-child(2) {
+ animation-delay: -0.3s;
+ }
+ @keyframes lds-ripple {
+ 0% {
+ top: calc(var(--size) / 2);
+ left: calc(var(--size) / 2);
+ width: 0;
+ height: 0;
+ opacity: 0;
+ }
+ 14.9% {
+ top: calc(var(--size) / 2);
+ left: calc(var(--size) / 2);
+ width: 0;
+ height: 0;
+ opacity: 0;
+ }
+ 15% {
+ top: calc(var(--size) / 2);
+ left: calc(var(--size) / 2);
+ width: 0;
+ height: 0;
+ opacity: 1;
+ }
+ 100% {
+ top: 0px;
+ left: 0px;
+ width: var(--size);
+ height: var(--size);
+ opacity: 0;
+ }
+ }
+`;
diff --git a/packages/taler-wallet-webextension/src/components/LogoHeader.tsx b/packages/taler-wallet-webextension/src/components/LogoHeader.tsx
index 9b75c62a1..2330b1b95 100644
--- a/packages/taler-wallet-webextension/src/components/LogoHeader.tsx
+++ b/packages/taler-wallet-webextension/src/components/LogoHeader.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,18 +14,22 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { h } from "preact";
+import { h, VNode } from "preact";
+import logo from "../svg/logo-2021.inline.svg";
-export function LogoHeader() {
- return <div style={{
- display: 'flex',
- justifyContent: 'space-around',
- margin: '2em',
- }}>
- <img style={{
- width: 150,
- height: 70,
- }} src="/static/img/logo-2021.svg" width="150" />
- </div>
-
-} \ No newline at end of file
+export function LogoHeader(): VNode {
+ return (
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "space-around",
+ margin: "2em",
+ }}
+ >
+ <div
+ style={{ width: 150, height: 70 }}
+ dangerouslySetInnerHTML={{ __html: logo }}
+ ></div>
+ </div>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/Modal.tsx b/packages/taler-wallet-webextension/src/components/Modal.tsx
new file mode 100644
index 000000000..f8c0f1651
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/Modal.tsx
@@ -0,0 +1,95 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { styled } from "@linaria/react";
+import { ComponentChildren, h, VNode } from "preact";
+import { ButtonHandler } from "../mui/handlers.js";
+import closeIcon from "../svg/close_24px.inline.svg";
+import { Link } from "./styled/index.js";
+
+interface Props {
+ children: ComponentChildren;
+ onClose: ButtonHandler;
+ title: string;
+}
+
+const FullSize = styled.div`
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ justify-content: center;
+ z-index: 10;
+`;
+
+const Header = styled.div`
+ display: flex;
+ justify-content: space-between;
+ height: 5%;
+ vertical-align: center;
+ align-items: center;
+`;
+
+const Body = styled.div`
+ height: 95%;
+`;
+
+export function Modal({ title, children, onClose }: Props): VNode {
+ return (
+ <div style={{ top: 0, width: "100%", height: "100%" }}>
+
+ <FullSize onClick={onClose?.onClick}>
+ <div
+ onClick={(e) => e.stopPropagation()}
+ style={{
+ background: "white",
+ width: 600,
+ height: "80%",
+ margin: "auto",
+ borderRadius: 8,
+ padding: 8,
+ zIndex: 100,
+ // overflow: "scroll",
+ }}
+ >
+ <Header>
+ <div>
+ <h2>{title}</h2>
+ </div>
+ <Link onClick={onClose?.onClick}>
+ <div
+ style={{
+ height: 24,
+ width: 24,
+ marginLeft: 4,
+ marginRight: 4,
+ // fill: "white",
+ }}
+ dangerouslySetInnerHTML={{ __html: closeIcon }}
+ />
+ </Link>
+ </Header>
+ <hr />
+
+ <Body onClick={(e: any) => e.stopPropagation()}>{children}</Body>
+ </div>
+ </FullSize>
+ </div>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/MultiActionButton.tsx b/packages/taler-wallet-webextension/src/components/MultiActionButton.tsx
new file mode 100644
index 000000000..7d3cf3f57
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/MultiActionButton.tsx
@@ -0,0 +1,129 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Button } from "../mui/Button.js";
+import arrowDown from "../svg/chevron-down.inline.svg";
+import { ParagraphClickable } from "./styled/index.js";
+
+export interface Props {
+ label: (s: string) => TranslatedString;
+ actions: string[];
+ onClick: (s: string) => Promise<void>;
+}
+
+/**
+ * functionality: it will receive a list of actions, take the first actions as
+ * the first chosen action
+ * the user may change the chosen action
+ * when the user click the button it will call onClick with the chosen action
+ * as argument
+ *
+ * visually: it is a primary button with a select handler on the right
+ *
+ * @returns
+ */
+export function MultiActionButton({
+ label,
+ actions,
+ onClick: doClick,
+}: Props): VNode {
+ const defaultAction = actions.length > 0 ? actions[0] : "";
+
+ const [opened, setOpened] = useState(false);
+ const [selected, setSelected] = useState<string>(defaultAction);
+
+ const canChange = actions.length > 1;
+ const options = canChange ? actions.filter((a) => a !== selected) : [];
+ function select(m: string): void {
+ setSelected(m);
+ setOpened(false);
+ }
+
+ if (!canChange) {
+ return (
+ <Button variant="contained" onClick={() => doClick(selected)}>
+ {label(selected)}
+ </Button>
+ );
+ }
+
+ return (
+ <div style={{ position: "relative", display: "inline-block" }}>
+ {opened && (
+ <div
+ style={{
+ position: "absolute",
+ bottom: 32 + 5,
+ right: 0,
+ marginLeft: 8,
+ marginRight: 8,
+ borderRadius: 5,
+ border: "1px solid blue",
+ background: "white",
+ boxShadow: "0px 8px 16px 0px rgba(0,0,0,0.2)",
+ zIndex: 1,
+ }}
+ >
+ {options.map((m) => (
+ <ParagraphClickable key={m} onClick={() => select(m)}>
+ {label(m)}
+ </ParagraphClickable>
+ ))}
+ </div>
+ )}
+ <Button
+ variant="contained"
+ onClick={() => doClick(selected)}
+ style={{
+ borderTopRightRadius: 0,
+ borderBottomRightRadius: 0,
+ marginRight: 0,
+ // maxWidth: 170,
+ overflowX: "hidden",
+ textOverflow: "ellipsis",
+ }}
+ >
+ {label(selected)}
+ </Button>
+
+ <Button
+ variant="outlined"
+ onClick={async () => setOpened((s) => !s)}
+ style={{
+ marginLeft: 0,
+ borderTopLeftRadius: 0,
+ borderBottomLeftRadius: 0,
+ paddingLeft: 4,
+ paddingRight: 4,
+ minWidth: "unset",
+ }}
+ >
+ <div
+ style={{
+ height: 24,
+ width: 24,
+ marginLeft: 4,
+ marginRight: 4,
+ // fill: "white",
+ }}
+ dangerouslySetInnerHTML={{ __html: arrowDown }}
+ />
+ </Button>
+ </div>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/Part.tsx b/packages/taler-wallet-webextension/src/components/Part.tsx
index 75c9df16f..b95bbf3b7 100644
--- a/packages/taler-wallet-webextension/src/components/Part.tsx
+++ b/packages/taler-wallet-webextension/src/components/Part.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 Taler Systems SA
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -13,20 +13,188 @@
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { AmountLike } from "@gnu-taler/taler-util";
-import { ExtraLargeText, LargeText, SmallLightText } from "./styled";
-import { h } from 'preact';
-export type Kind = 'positive' | 'negative' | 'neutral';
+import {
+ PaytoUri,
+ stringifyPaytoUri,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { styled } from "@linaria/react";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import {
+ ExtraLargeText,
+ LargeText,
+ SmallBoldText,
+ SmallLightText,
+} from "./styled/index.js";
+
+export type Kind = "positive" | "negative" | "neutral";
interface Props {
- title: string, text: AmountLike, kind: Kind, big?: boolean
+ title: VNode | TranslatedString;
+ text: VNode | TranslatedString;
+ kind?: Kind;
+ big?: boolean;
+ showSign?: boolean;
+}
+export function Part({
+ text,
+ title,
+ kind = "neutral",
+ big,
+ showSign,
+}: Props): VNode {
+ const Text = big ? ExtraLargeText : LargeText;
+ return (
+ <div style={{ margin: "1em" }}>
+ <SmallBoldText style={{ marginBottom: "1em" }}>{title}</SmallBoldText>
+ <Text
+ style={{
+ color:
+ kind == "positive" ? "green" : kind == "negative" ? "red" : "black",
+ fontWeight: "lighten",
+ }}
+ >
+ {!showSign || kind === "neutral"
+ ? undefined
+ : kind === "positive"
+ ? "+"
+ : "-"}
+ {text}
+ </Text>
+ </div>
+ );
+}
+
+const CollasibleBox = styled.div`
+ border: 1px solid black;
+ border-radius: 0.25em;
+ display: flex;
+ vertical-align: middle;
+ justify-content: space-between;
+ flex-direction: column;
+ /* margin: 0.5em; */
+ padding: 0.5em;
+ /* margin: 1em; */
+ /* width: 100%; */
+ /* color: #721c24; */
+ /* background: #f8d7da; */
+
+ & > div {
+ display: flex;
+ justify-content: space-between;
+ div {
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ & > button {
+ align-self: center;
+ font-size: 100%;
+ padding: 0;
+ height: 28px;
+ width: 28px;
+ }
+ }
+`;
+import arrowDown from "../svg/chevron-down.inline.svg";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+
+export function PartCollapsible({ text, title, big, showSign }: Props): VNode {
+ const Text = big ? ExtraLargeText : LargeText;
+ const [collapsed, setCollapsed] = useState(true);
+
+ return (
+ <CollasibleBox>
+ <div>
+ <SmallBoldText>{title}</SmallBoldText>
+ <button
+ onClick={() => {
+ setCollapsed((v) => !v);
+ }}
+ >
+ <div
+ style={{
+ transform: !collapsed ? "scaleY(-1)" : undefined,
+ height: 24,
+ }}
+ dangerouslySetInnerHTML={{ __html: arrowDown }}
+ />
+ </button>
+ </div>
+ {/* <SmallBoldText
+ style={{
+ paddingBottom: "1em",
+ paddingTop: "1em",
+ paddingLeft: "1em",
+ border: "black solid 1px",
+ }}
+ >
+
+ </SmallBoldText> */}
+ {!collapsed && <div style={{ display: "block" }}>{text}</div>}
+ </CollasibleBox>
+ );
+}
+
+interface PropsPayto {
+ payto: PaytoUri;
+ kind: Kind;
+ big?: boolean;
}
-export function Part({ text, title, kind, big }: Props) {
+export function PartPayto({ payto, kind, big }: PropsPayto): VNode {
const Text = big ? ExtraLargeText : LargeText;
- return <div style={{ margin: '1em' }}>
- <SmallLightText style={{ margin: '.5em' }}>{title}</SmallLightText>
- <Text style={{ color: kind == 'positive' ? 'green' : (kind == 'negative' ? 'red' : 'black') }}>
- {text}
- </Text>
- </div>
+ let text: VNode | undefined = undefined;
+ let title = "";
+ const { i18n } = useTranslationContext();
+ if (payto.isKnown) {
+ if (payto.targetType === "x-taler-bank") {
+ text = (
+ <a target="_bank" rel="noreferrer" href={payto.host}>
+ {payto.account}
+ </a>
+ );
+ title = i18n.str`Bank account`;
+ } else if (payto.targetType === "bitcoin") {
+ text =
+ payto.segwitAddrs && payto.segwitAddrs.length > 0 ? (
+ <ul>
+ <li>{payto.targetPath}</li>
+ <li>{payto.segwitAddrs[0]}</li>
+ <li>{payto.segwitAddrs[1]}</li>
+ </ul>
+ ) : (
+ <Fragment>{payto.targetPath}</Fragment>
+ );
+ title = i18n.str`Bitcoin address`;
+ } else if (payto.targetType === "iban") {
+ if (payto.bic) {
+ text = (
+ <Fragment>
+ {payto.bic}/{payto.iban}
+ </Fragment>
+ );
+ title = i18n.str`BIC/IBAN`;
+ } else {
+ text = <Fragment>{payto.iban}</Fragment>;
+ title = i18n.str`IBAN`;
+ }
+ }
+ }
+ if (!text) {
+ text = <Fragment>{stringifyPaytoUri(payto)}</Fragment>;
+ title = "Payto URI";
+ }
+ return (
+ <div style={{ margin: "1em" }}>
+ <SmallBoldText>{title}</SmallBoldText>
+ <Text
+ style={{
+ color:
+ kind == "positive" ? "green" : kind == "negative" ? "red" : "black",
+ }}
+ >
+ {text}
+ </Text>
+ </div>
+ );
}
diff --git a/packages/taler-wallet-webextension/src/components/PaymentButtons.tsx b/packages/taler-wallet-webextension/src/components/PaymentButtons.tsx
new file mode 100644
index 000000000..7fa0376c9
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/PaymentButtons.tsx
@@ -0,0 +1,239 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountJson,
+ Amounts,
+ PaymentInsufficientBalanceDetails,
+ PreparePayResult,
+ PreparePayResultType,
+ TranslatedString,
+ parsePayUri,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useBackendContext } from "../context/backend.js";
+import { Button } from "../mui/Button.js";
+import { ButtonHandler } from "../mui/handlers.js";
+import { assertUnreachable } from "../utils/index.js";
+import { Amount } from "./Amount.js";
+import { Part } from "./Part.js";
+import { QR } from "./QR.js";
+import { LinkSuccess, WarningBox } from "./styled/index.js";
+
+interface Props {
+ payStatus: PreparePayResult;
+ payHandler: ButtonHandler | undefined;
+ uri: string;
+ amount: AmountJson;
+ goToWalletManualWithdraw: (currency: string) => Promise<void>;
+}
+
+export function PaymentButtons({
+ payStatus,
+ uri,
+ payHandler,
+ amount,
+ goToWalletManualWithdraw,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ if (payStatus.status === PreparePayResultType.PaymentPossible) {
+ return (
+ <Fragment>
+ <section>
+ <Button
+ variant="contained"
+ color="success"
+ onClick={payHandler?.onClick}
+ >
+ <i18n.Translate>
+ Pay &nbsp;
+ {<Amount value={amount} />}
+ </i18n.Translate>
+ </Button>
+ </section>
+ <PayWithMobile uri={uri} />
+ </Fragment>
+ );
+ }
+
+ if (payStatus.status === PreparePayResultType.InsufficientBalance) {
+ const reason = getReason(payStatus.balanceDetails);
+
+ let BalanceMessage = "";
+ switch (reason) {
+ case "age-acceptable": {
+ BalanceMessage = i18n.str`Balance is not enough because you have ${Amounts.stringifyValue(
+ payStatus.balanceDetails.balanceAgeAcceptable,
+ )} ${amount.currency} to pay for this contract which is restricted.`;
+ break;
+ }
+ case "available": {
+ BalanceMessage = i18n.str`Balance is not enough because you have ${Amounts.stringifyValue(
+ payStatus.balanceDetails.balanceAvailable,
+ )} ${amount.currency} available.`;
+ break;
+ }
+ case "merchant-acceptable": {
+ BalanceMessage = i18n.str`Balance is not enough because merchant will just accept ${Amounts.stringifyValue(
+ payStatus.balanceDetails.balanceReceiverAcceptable,
+ )} ${amount.currency
+ } . To know more you can check which exchange and auditors the merchant trust.`;
+ break;
+ }
+ case "merchant-depositable": {
+ BalanceMessage = i18n.str`Balance is not enough because merchant will just accept ${Amounts.stringifyValue(
+ payStatus.balanceDetails.balanceReceiverDepositable,
+ )} ${amount.currency
+ } . To know more you can check which wire methods the merchant accepts.`;
+ break;
+ }
+ case "material": {
+ BalanceMessage = i18n.str`Balance is not enough because you have ${Amounts.stringifyValue(
+ payStatus.balanceDetails.balanceMaterial,
+ )} ${amount.currency
+ } to spend right know. There are some coins that need to be refreshed.`;
+ break;
+ }
+ case "fee-gap": {
+ BalanceMessage = i18n.str`Balance looks like it should be enough, but doesn't cover all fees requested by the merchant and payment processor. Please ensure there is at least ${Amounts.stringifyValue(
+ Amounts.stringify(
+ Amounts.sub(
+ amount,
+ payStatus.balanceDetails.maxEffectiveSpendAmount,
+ ).amount,
+ ),
+ )} ${amount.currency
+ } more balance in your wallet or ask your merchant to cover more of the fees.`;
+ break;
+ }
+ default:
+ assertUnreachable(reason);
+ }
+
+ return (
+ <Fragment>
+ <section>
+ <WarningBox>{BalanceMessage}</WarningBox>
+ </section>
+ <section>
+ <Button
+ variant="contained"
+ color="success"
+ onClick={() => goToWalletManualWithdraw(Amounts.stringify(amount))}
+ >
+ <i18n.Translate>Get digital cash</i18n.Translate>
+ </Button>
+ </section>
+ <PayWithMobile uri={uri} />
+ </Fragment>
+ );
+ }
+ if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
+ return (
+ <Fragment>
+ <section>
+ {payStatus.paid && payStatus.contractTerms.fulfillment_message && (
+ <Part
+ title={i18n.str`Merchant message`}
+ text={
+ payStatus.contractTerms.fulfillment_message as TranslatedString
+ }
+ kind="neutral"
+ />
+ )}
+ </section>
+ </Fragment>
+ );
+ }
+
+ assertUnreachable(payStatus);
+}
+
+function PayWithMobile({ uri }: { uri: string }): VNode {
+ const { i18n } = useTranslationContext();
+ const api = useBackendContext();
+
+ const payUri = parsePayUri(uri);
+
+ const [showQR, setShowQR] = useState<string | undefined>(undefined);
+ async function sharePrivatePaymentURI() {
+ if (!payUri) {
+ return;
+ }
+ if (!showQR) {
+ const result = await api.wallet.call(WalletApiOperation.SharePayment, {
+ merchantBaseUrl: payUri.merchantBaseUrl,
+ orderId: payUri.orderId,
+ });
+ setShowQR(result.privatePayUri);
+ } else {
+ setShowQR(undefined);
+ }
+ }
+ if (!payUri) {
+ return <Fragment />
+ }
+ return (
+ <section>
+ <LinkSuccess upperCased onClick={sharePrivatePaymentURI}>
+ {!showQR ? i18n.str`Pay with a mobile phone` : i18n.str`Hide QR`}
+ </LinkSuccess>
+ {showQR && (
+ <div>
+ <QR text={showQR} />
+ <i18n.Translate>
+ Scan the QR code or &nbsp;
+ <a href={showQR}>
+ <i18n.Translate>click here</i18n.Translate>
+ </a>
+ </i18n.Translate>
+ </div>
+ )}
+ </section>
+ );
+}
+
+type NoEnoughBalanceReason =
+ | "available"
+ | "material"
+ | "age-acceptable"
+ | "merchant-acceptable"
+ | "merchant-depositable"
+ | "fee-gap";
+
+function getReason(
+ info: PaymentInsufficientBalanceDetails,
+): NoEnoughBalanceReason {
+ if (Amounts.cmp(info.amountRequested, info.balanceAvailable) > 0) {
+ return "available";
+ }
+ if (Amounts.cmp(info.amountRequested, info.balanceMaterial) > 0) {
+ return "material";
+ }
+ if (Amounts.cmp(info.amountRequested, info.balanceAgeAcceptable) > 0) {
+ return "age-acceptable";
+ }
+ if (Amounts.cmp(info.amountRequested, info.balanceReceiverAcceptable) > 0) {
+ return "merchant-acceptable";
+ }
+ if (Amounts.cmp(info.amountRequested, info.balanceReceiverDepositable) > 0) {
+ return "merchant-depositable";
+ }
+ return "fee-gap";
+}
diff --git a/packages/taler-wallet-webextension/src/components/PendingTransactions.stories.tsx b/packages/taler-wallet-webextension/src/components/PendingTransactions.stories.tsx
new file mode 100644
index 000000000..d1c49aea2
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/PendingTransactions.stories.tsx
@@ -0,0 +1,118 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ TalerProtocolTimestamp,
+ Transaction,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { PendingTransactionsView as TestedComponent } from "./PendingTransactions.js";
+
+export default {
+ title: "PendingTransactions",
+ component: TestedComponent,
+};
+
+export const OnePendingTransaction = tests.createExample(TestedComponent, {
+ transactions: [
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ ],
+});
+
+export const ThreePendingTransactions = tests.createExample(TestedComponent, {
+ transactions: [
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ ],
+});
+
+export const TenPendingTransactions = tests.createExample(TestedComponent, {
+ transactions: [
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ {
+ amountEffective: "USD:10",
+ type: TransactionType.Withdrawal,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1),
+ } as Transaction,
+ ],
+});
diff --git a/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx b/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx
new file mode 100644
index 000000000..c94010ede
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx
@@ -0,0 +1,205 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ Amounts,
+ NotificationType,
+ Transaction,
+ TransactionMajorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { Fragment, h, JSX, VNode } from "preact";
+import { useEffect } from "preact/hooks";
+import { useBackendContext } from "../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { Avatar } from "../mui/Avatar.js";
+import { Grid } from "../mui/Grid.js";
+import { Typography } from "../mui/Typography.js";
+import Banner from "./Banner.js";
+import { Time } from "./Time.js";
+
+interface Props extends JSX.HTMLAttributes {
+ goToTransaction?: (id: string) => Promise<void>;
+ goToURL: (url: string) => void;
+}
+
+/**
+ * this cache will save the tx from the previous render
+ */
+const cache = { tx: [] as Transaction[] };
+
+export function PendingTransactions({
+ goToTransaction,
+ goToURL,
+}: Props): VNode {
+ const api = useBackendContext();
+ const state = useAsyncAsHook(() =>
+ api.wallet.call(WalletApiOperation.GetTransactions, {}),
+ );
+
+ useEffect(() => {
+ return api.listener.onUpdateNotification(
+ [NotificationType.TransactionStateTransition],
+ state?.retry,
+ );
+ });
+
+ const transactions =
+ !state || state.hasError
+ ? cache.tx
+ : state.response.transactions.filter(
+ (t) => t.txState.major === TransactionMajorState.Pending,
+ );
+
+ if (state && !state.hasError) {
+ cache.tx = transactions;
+ }
+ if (!transactions.length) {
+ return <Fragment />;
+ }
+ return (
+ <PendingTransactionsView
+ goToTransaction={goToTransaction}
+ goToURL={goToURL}
+ transactions={transactions}
+ />
+ );
+}
+
+export function PendingTransactionsView({
+ transactions,
+ goToTransaction,
+ goToURL,
+}: {
+ goToTransaction?: (id: string) => Promise<void>;
+ goToURL: (id: string) => void;
+ transactions: Transaction[];
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const kycTransaction = transactions.find((tx) => tx.kycUrl);
+ if (kycTransaction) {
+ return (
+ <div
+ style={{
+ backgroundColor: "#fff3cd",
+ color: "#664d03",
+ display: "flex",
+ justifyContent: "center",
+ }}
+ >
+ <Banner
+ titleHead={i18n.str`KYC requirement`}
+ style={{
+ backgroundColor: "lightred",
+ maxHeight: 150,
+ padding: 8,
+ flexGrow: 1, //#fff3cd //#ffecb5
+ maxWidth: 500,
+ overflowY: transactions.length > 3 ? "scroll" : "hidden",
+ }}
+ >
+ <Grid
+ container
+ item
+ xs={1}
+ wrap="nowrap"
+ role="button"
+ spacing={1}
+ alignItems="center"
+ onClick={() => {
+ goToURL(kycTransaction.kycUrl ?? "#");
+ }}
+ >
+ <Grid item>
+ <Typography inline bold>
+ One or more transaction require a KYC step to complete
+ </Typography>
+ </Grid>
+ </Grid>
+ </Banner>
+ </div>
+ );
+ }
+
+ if (!goToTransaction) return <Fragment />;
+
+ return (
+ <div
+ style={{
+ backgroundColor: "lightcyan",
+ display: "flex",
+ justifyContent: "center",
+ }}
+ >
+ <Banner
+ titleHead={i18n.str`PENDING OPERATIONS`}
+ style={{
+ backgroundColor: "lightcyan",
+ maxHeight: 150,
+ padding: 8,
+ flexGrow: 1,
+ maxWidth: 500,
+ overflowY: transactions.length > 3 ? "scroll" : "hidden",
+ }}
+ >
+ {transactions.map((t, i) => {
+ const amount = Amounts.parseOrThrow(t.amountEffective);
+ return (
+ <Grid
+ container
+ item
+ xs={1}
+ key={i}
+ wrap="nowrap"
+ role="button"
+ spacing={1}
+ alignItems="center"
+ onClick={() => {
+ goToTransaction(t.transactionId);
+ }}
+ >
+ <Grid item xs={"auto"}>
+ <Avatar
+ style={{
+ border: "solid blue 1px",
+ color: "blue",
+ boxSizing: "border-box",
+ }}
+ >
+ {t.type.substring(0, 1)}
+ </Avatar>
+ </Grid>
+
+ <Grid item>
+ <Typography inline bold>
+ {amount.currency} {Amounts.stringifyValue(amount)}
+ </Typography>
+ &nbsp;-&nbsp;
+ <Time
+ timestamp={AbsoluteTime.fromPreciseTimestamp(t.timestamp)}
+ format="dd MMMM yyyy"
+ />
+ </Grid>
+ </Grid>
+ );
+ })}
+ </Banner>
+ </div>
+ );
+}
+
+export default PendingTransactions;
diff --git a/packages/taler-wallet-webextension/src/components/ProductList.tsx b/packages/taler-wallet-webextension/src/components/ProductList.tsx
new file mode 100644
index 000000000..748935dff
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/ProductList.tsx
@@ -0,0 +1,89 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Amounts, Product } from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import { SmallLightText } from "./styled/index.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+
+export function ProductList({ products }: { products: Product[] }): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <SmallLightText style={{ margin: ".5em" }}>
+ <i18n.Translate>List of products</i18n.Translate>
+ </SmallLightText>
+ <dl>
+ {products.map((p, i) => {
+ if (p.price) {
+ const pPrice = Amounts.parseOrThrow(p.price);
+ return (
+ <div key={i} style={{ display: "flex", textAlign: "left" }}>
+ <div>
+ <img
+ src={p.image ? p.image : undefined}
+ style={{ width: 32, height: 32 }}
+ />
+ </div>
+ <div>
+ <dt>
+ {p.quantity ?? 1} x {p.description}{" "}
+ <span style={{ color: "gray" }}>
+ {Amounts.stringify(pPrice)}
+ </span>
+ </dt>
+ <dd>
+ <b>
+ {Amounts.stringify(
+ Amounts.mult(pPrice, p.quantity ?? 1).amount,
+ )}
+ </b>
+ </dd>
+ </div>
+ </div>
+ );
+ }
+ return (
+ <div key={i} style={{ display: "flex", textAlign: "left" }}>
+ <div>
+ <img src={p.image} style={{ width: 32, height: 32 }} />
+ </div>
+ <div>
+ <dt>
+ {p.quantity ?? 1} x {p.description}
+ </dt>
+ <dd>
+ <i18n.Translate>Total</i18n.Translate>
+ {` `}
+ {p.price ? (
+ `${Amounts.stringifyValue(
+ Amounts.mult(
+ Amounts.parseOrThrow(p.price),
+ p.quantity ?? 1,
+ ).amount,
+ )} ${p}`
+ ) : (
+ <i18n.Translate>free</i18n.Translate>
+ )}
+ </dd>
+ </div>
+ </div>
+ );
+ })}
+ </dl>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/QR.stories.tsx b/packages/taler-wallet-webextension/src/components/QR.stories.tsx
new file mode 100644
index 000000000..1d1f15b69
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/QR.stories.tsx
@@ -0,0 +1,31 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { QR } from "./QR.js";
+
+export default {
+ title: "qr",
+};
+
+export const Restore = tests.createExample(QR, {
+ text: "taler://restore/6J0RZTJC6AV21WXK87BTE67WTHE9P2QSHF2BZXTP7PDZY2ARYBPG@sync1.demo.taler.net,sync2.demo.taler.net,sync1.demo.taler.net,sync3.demo.taler.net",
+});
diff --git a/packages/taler-wallet-webextension/src/components/QR.tsx b/packages/taler-wallet-webextension/src/components/QR.tsx
index 8e3f69295..60710ab15 100644
--- a/packages/taler-wallet-webextension/src/components/QR.tsx
+++ b/packages/taler-wallet-webextension/src/components/QR.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,24 +14,35 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
- import { h, VNode } from "preact";
- import { useEffect, useRef } from "preact/hooks";
- import qrcode from "qrcode-generator";
-
- export function QR({ text }: { text: string; }):VNode {
- const divRef = useRef<HTMLDivElement>(null);
- useEffect(() => {
- if (!divRef.current) return
- const qr = qrcode(0, 'L');
- qr.addData(text);
- qr.make();
- divRef.current.innerHTML = qr.createSvgTag({
- scalable: true,
- });
- });
-
- return <div style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
- <div style={{ width: '50%', minWidth: 200, maxWidth: 300 }} ref={divRef} />
- </div>;
- }
- \ No newline at end of file
+import { h, VNode } from "preact";
+import { useEffect, useRef } from "preact/hooks";
+import qrcode from "qrcode-generator";
+
+export function QR({ text }: { text: string }): VNode {
+ const divRef = useRef<HTMLDivElement>(null);
+ useEffect(() => {
+ if (!divRef.current) return;
+ const qr = qrcode(0, "H");
+ qr.addData(text);
+ qr.make();
+ divRef.current.innerHTML = qr.createSvgTag({
+ scalable: true,
+ });
+ });
+
+ return (
+ <div
+ style={{
+ width: "100%",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ }}
+ >
+ <div
+ style={{ width: "50%", minWidth: 200, maxWidth: 300 }}
+ ref={divRef}
+ />
+ </div>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/SelectList.tsx b/packages/taler-wallet-webextension/src/components/SelectList.tsx
index 536e5b89a..6eb72a266 100644
--- a/packages/taler-wallet-webextension/src/components/SelectList.tsx
+++ b/packages/taler-wallet-webextension/src/components/SelectList.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 Taler Systems SA
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,55 +14,80 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { JSX } from "preact/jsx-runtime";
-import { NiceSelect } from "./styled/index";
-import { h } from "preact";
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { NiceSelect } from "./styled/index.js";
interface Props {
value?: string;
- onChange: (s: string) => void;
- label: string;
+ onChange?: (s: string) => void;
+ label: VNode | TranslatedString;
list: {
- [label: string]: string
- }
+ [label: string]: string;
+ };
name: string;
description?: string;
canBeNull?: boolean;
+ maxWidth?: boolean;
}
-export function SelectList({ name, value, list, canBeNull, onChange, label, description }: Props): JSX.Element {
- return <div>
- <label
- htmlFor={`text-${name}`}
- style={{ marginLeft: "0.5em", fontWeight: "bold" }}
- > {label}</label>
- <NiceSelect>
- <select name={name} onChange={(e) => {
- console.log(e.currentTarget.value, value)
- onChange(e.currentTarget.value)
- }}>
- {value !== undefined ? <option selected>
- {list[value]}
- </option> : <option selected disabled>
- Select one option
- </option>}
- {Object.keys(list)
- .filter((l) => l !== value)
- .map(key => <option value={key} key={key}>{list[key]}</option>)
- }
- </select>
- </NiceSelect>
- {description && <span
- style={{
- color: "#383838",
- fontSize: "smaller",
- display: "block",
- marginLeft: "2em",
- }}
- >
- {description}
- </span>}
-
- </div>
-
+export function SelectList({
+ name,
+ value,
+ list,
+ onChange,
+ label,
+ maxWidth,
+ description,
+ canBeNull,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <label
+ htmlFor={`text-${name}`}
+ style={{ marginLeft: "0.5em", fontWeight: "bold" }}
+ >
+ {" "}
+ {label}
+ </label>
+ <NiceSelect>
+ <select
+ name={name}
+ value={value}
+ style={maxWidth ? { width: "100%" } : undefined}
+ onChange={(e) => {
+ if (onChange) onChange(e.currentTarget.value);
+ }}
+ >
+ {value === undefined ||
+ (canBeNull && (
+ <option selected disabled>
+ <i18n.Translate>Select one option</i18n.Translate>
+ </option>
+ ))}
+ {Object.keys(list)
+ // .filter((l) => l !== value)
+ .map((key) => (
+ <option value={key} key={key}>
+ {list[key]}
+ </option>
+ ))}
+ </select>
+ </NiceSelect>
+ {description && (
+ <span
+ style={{
+ color: "#383838",
+ fontSize: "smaller",
+ display: "block",
+ marginLeft: "2em",
+ }}
+ >
+ {description}
+ </span>
+ )}
+ </Fragment>
+ );
}
diff --git a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx
new file mode 100644
index 000000000..0e23d5850
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx
@@ -0,0 +1,98 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ ErrorView,
+ HiddenView,
+ LoadingView,
+ ShowView,
+} from "./ShowFullContractTermPopup.js";
+import { AmountString, WalletContractData } from "@gnu-taler/taler-util";
+
+export default {
+ title: "ShowFullContractTermPopup",
+};
+
+const cd: WalletContractData = {
+ amount: "ARS:2" as AmountString,
+ contractTermsHash:
+ "92X0KSJPZ8XS2XECCGFWTCGW8XMFCXTT2S6WHZDP6H9Y3TSKMTHY94WXEWDERTNN5XWCYGW4VN5CF2D4846HXTW7P06J4CZMHCWKC9G",
+ fulfillmentUrl: "",
+ merchantBaseUrl: "https://merchant-backend.taler.ar/",
+ merchantPub: "JZYHJ13M91GMSQMT75J8Q6ZN0QP8XF8CRHR7K5MMWYE8JQB6AAPG",
+ merchantSig:
+ "0YA1WETV15R6K8QKS79QA3QMT16010F42Q49VSKYQ71HVQKAG0A4ZJCA4YTKHE9EA5SP156TJSKZEJJJ87305N6PS80PC48RNKYZE08",
+ orderId: "2022.220-0281XKKB8W7YE",
+ summary: "w",
+ payDeadline: {
+ t_s: 1660002673,
+ },
+ refundDeadline: {
+ t_s: 1660002673,
+ },
+ allowedExchanges: [
+ {
+ exchangeBaseUrl: "https://exchange.taler.ar/",
+ exchangePub: "1C2EYE90PYDNVRTQ25A3PA0KW5W4WPAJNNQHVHV49PT6W5CERFV0",
+ },
+ ],
+ timestamp: {
+ t_s: 1659972710,
+ },
+ wireMethod: "x-taler-bank",
+ wireInfoHash:
+ "QDT28374ZHYJ59WQFZ3TW1D5WKJVDYHQT86VHED3TNMB15ANJSKXDYPPNX01348KDYCX6T4WXA5A8FJJ8YWNEB1JW726C1JPKHM89DR",
+ maxDepositFee: "ARS:1" as AmountString,
+ merchant: {
+ name: "Default",
+ address: {
+ country: "ar",
+ },
+ jurisdiction: {
+ country: "ar",
+ },
+ },
+ // products: [],
+ autoRefund: undefined,
+ summaryI18n: undefined,
+ // deliveryDate: undefined,
+ // deliveryLocation: undefined,
+};
+
+export const ShowingSimpleOrder = tests.createExample(ShowView, {
+ contractTerms: cd,
+});
+export const Error = tests.createExample(ErrorView, {
+ transactionId: "asd",
+ error: {
+ hasError: true,
+ message: "message",
+ // details: {
+ // co
+ // },
+ type: "error",
+ // details: {
+ // code: 123,
+ // },
+ },
+});
+export const Loading = tests.createExample(LoadingView, {});
+export const Hidden = tests.createExample(HiddenView, {});
diff --git a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx
new file mode 100644
index 000000000..e655def39
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx
@@ -0,0 +1,413 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ Duration,
+ Location,
+ TransactionIdStr,
+ WalletContractData,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { styled } from "@linaria/react";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Loading } from "../components/Loading.js";
+import { Modal } from "../components/Modal.js";
+import { Time } from "../components/Time.js";
+import { alertFromError, useAlertContext } from "../context/alert.js";
+import { useBackendContext } from "../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { ButtonHandler } from "../mui/handlers.js";
+import { compose, StateViewMap } from "../utils/index.js";
+import { Amount } from "./Amount.js";
+import { ErrorAlertView } from "./CurrentAlerts.js";
+import { Link } from "./styled/index.js";
+
+const ContractTermsTable = styled.table`
+ width: 100%;
+ border-spacing: 0px;
+ & > tr > td {
+ padding: 5px;
+ }
+ & > tr > td:nth-child(2n) {
+ text-align: right;
+ overflow-wrap: anywhere;
+ }
+ & > tr:nth-child(2n) {
+ background: #ebebeb;
+ }
+`;
+
+function locationAsText(l: Location | undefined): VNode {
+ if (!l) return <span />;
+ const lines = [
+ ...(l.address_lines || []).map((e) => [e]),
+ [l.town_location, l.town, l.street],
+ [l.building_name, l.building_number],
+ [l.country, l.country_subdivision],
+ [l.district, l.post_code],
+ ];
+ //remove all missing value
+ //then remove all empty lines
+ const curated = lines
+ .map((l) => l.filter((v) => !!v))
+ .filter((l) => l.length > 0);
+ return (
+ <span>
+ {curated.map((c, i) => (
+ <div key={i}>{c.join(",")}</div>
+ ))}
+ </span>
+ );
+}
+
+type State = States.Loading | States.Error | States.Hidden | States.Show;
+
+export namespace States {
+ export interface Loading {
+ status: "loading";
+ hideHandler: ButtonHandler;
+ }
+ export interface Error {
+ status: "error";
+ transactionId: string;
+ error: HookError;
+ hideHandler: ButtonHandler;
+ }
+ export interface Hidden {
+ status: "hidden";
+ showHandler: ButtonHandler;
+ }
+ export interface Show {
+ status: "show";
+ hideHandler: ButtonHandler;
+ contractTerms: WalletContractData;
+ }
+}
+
+interface Props {
+ transactionId: TransactionIdStr;
+}
+
+function useComponentState({ transactionId }: Props): State {
+ const api = useBackendContext();
+ const [show, setShow] = useState(false);
+ const { pushAlertOnError } = useAlertContext();
+ const hook = useAsyncAsHook(async () => {
+ if (!show) return undefined;
+ return await api.wallet.call(WalletApiOperation.GetContractTermsDetails, {
+ transactionId,
+ });
+ }, [show]);
+
+ const hideHandler = {
+ onClick: pushAlertOnError(async () => setShow(false)),
+ };
+ const showHandler = {
+ onClick: pushAlertOnError(async () => setShow(true)),
+ };
+ if (!show) {
+ return {
+ status: "hidden",
+ showHandler,
+ };
+ }
+ if (!hook) return { status: "loading", hideHandler };
+ if (hook.hasError)
+ return { status: "error", transactionId, error: hook, hideHandler };
+ if (!hook.response) return { status: "loading", hideHandler };
+ return {
+ status: "show",
+ contractTerms: hook.response,
+ hideHandler,
+ };
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: LoadingView,
+ error: ErrorView,
+ show: ShowView,
+ hidden: HiddenView,
+};
+
+export const ShowFullContractTermPopup = compose(
+ "ShowFullContractTermPopup",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
+
+export function LoadingView({ hideHandler }: States.Loading): VNode {
+ return (
+ <Modal title="Full detail" onClose={hideHandler}>
+ <Loading />
+ </Modal>
+ );
+}
+
+export function ErrorView({
+ hideHandler,
+ error,
+ transactionId,
+}: States.Error): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Modal title="Full detail" onClose={hideHandler}>
+ <ErrorAlertView
+ error={alertFromError(
+ i18n,
+ i18n.str`Could not load purchase proposal details`,
+ error,
+ { transactionId },
+ )}
+ />
+ </Modal>
+ );
+}
+
+export function HiddenView({ showHandler }: States.Hidden): VNode {
+ return <Link onClick={showHandler?.onClick}>Show full details</Link>;
+}
+
+export function ShowView({ contractTerms, hideHandler }: States.Show): VNode {
+ const createdAt = AbsoluteTime.fromProtocolTimestamp(contractTerms.timestamp);
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Modal title="Full detail" onClose={hideHandler}>
+ <div style={{ overflowY: "auto", height: "95%", padding: 5 }}>
+ <ContractTermsTable>
+ <tr>
+ <td>
+ <i18n.Translate>Order Id</i18n.Translate>
+ </td>
+ <td>{contractTerms.orderId}</td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Summary</i18n.Translate>
+ </td>
+ <td>{contractTerms.summary}</td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Amount</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={contractTerms.amount} />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Merchant name</i18n.Translate>
+ </td>
+ <td>{contractTerms.merchant.name}</td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Merchant jurisdiction</i18n.Translate>
+ </td>
+ <td>{locationAsText(contractTerms.merchant.jurisdiction)}</td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Merchant address</i18n.Translate>
+ </td>
+ <td>{locationAsText(contractTerms.merchant.address)}</td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Merchant logo</i18n.Translate>
+ </td>
+ <td>
+ <div>
+ <img
+ src={contractTerms.merchant.logo}
+ style={{ width: 64, height: 64, margin: 4 }}
+ />
+ </div>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Merchant website</i18n.Translate>
+ </td>
+ <td>{contractTerms.merchant.website}</td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Merchant email</i18n.Translate>
+ </td>
+ <td>{contractTerms.merchant.email}</td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Merchant public key</i18n.Translate>
+ </td>
+ <td>
+ <span title={contractTerms.merchantPub}>
+ {contractTerms.merchantPub.substring(0, 6)}...
+ </span>
+ </td>
+ </tr>
+ {/* <tr>
+ <td>
+ <i18n.Translate>Delivery date</i18n.Translate>
+ </td>
+ <td>
+ {contractTerms.deliveryDate && (
+ <Time
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ contractTerms.deliveryDate,
+ )}
+ format="dd MMMM yyyy, HH:mm"
+ />
+ )}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Delivery location</i18n.Translate>
+ </td>
+ <td>{locationAsText(contractTerms.deliveryLocation)}</td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Products</i18n.Translate>
+ </td>
+ <td>
+ {!contractTerms.products || contractTerms.products.length === 0
+ ? "none"
+ : contractTerms.products
+ .map((p) => `${p.description} x ${p.quantity}`)
+ .join(", ")}
+ </td>
+ </tr> */}
+ <tr>
+ <td>
+ <i18n.Translate>Created at</i18n.Translate>
+ </td>
+ <td>
+ {contractTerms.timestamp && (
+ <Time
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ contractTerms.timestamp,
+ )}
+ format="dd MMMM yyyy, HH:mm"
+ />
+ )}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Refund deadline</i18n.Translate>
+ </td>
+ <td>
+ {
+ <Time
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ contractTerms.refundDeadline,
+ )}
+ format="dd MMMM yyyy, HH:mm"
+ />
+ }
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Auto refund</i18n.Translate>
+ </td>
+ <td>
+ {
+ <Time
+ timestamp={AbsoluteTime.addDuration(
+ createdAt,
+ !contractTerms.autoRefund
+ ? Duration.getZero()
+ : Duration.fromTalerProtocolDuration(
+ contractTerms.autoRefund,
+ ),
+ )}
+ format="dd MMMM yyyy, HH:mm"
+ />
+ }
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Pay deadline</i18n.Translate>
+ </td>
+ <td>
+ {
+ <Time
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ contractTerms.payDeadline,
+ )}
+ format="dd MMMM yyyy, HH:mm"
+ />
+ }
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Fulfillment URL</i18n.Translate>
+ </td>
+ <td>{contractTerms.fulfillmentUrl}</td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Fulfillment message</i18n.Translate>
+ </td>
+ <td>{contractTerms.fulfillmentMessage}</td>
+ </tr>
+ {/* <tr>
+ <td>Public reorder URL</td>
+ <td>{contractTerms.public_reorder_url}</td>
+ </tr> */}
+ <tr>
+ <td>
+ <i18n.Translate>Max deposit fee</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={contractTerms.maxDepositFee} />
+ </td>
+ </tr>
+ {/* <tr>
+ <td>Extra</td>
+ <td>
+ <pre>{contractTerms.}</pre>
+ </td>
+ </tr> */}
+ <tr>
+ <td>
+ <i18n.Translate>Exchanges</i18n.Translate>
+ </td>
+ <td>
+ {(contractTerms.allowedExchanges || []).map((e) => (
+ <Fragment key={e.exchangePub}>
+ <a href={e.exchangeBaseUrl} title={e.exchangePub}>
+ {e.exchangePub.substring(0, 6)}...
+ </a>
+ &nbsp;
+ </Fragment>
+ ))}
+ </td>
+ </tr>
+ </ContractTermsTable>
+ </div>
+ </Modal>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts b/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts
new file mode 100644
index 000000000..1585e3992
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts
@@ -0,0 +1,91 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ComponentChildren } from "preact";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { SelectFieldHandler, ToggleHandler } from "../../mui/handlers.js";
+import { StateViewMap, compose } from "../../utils/index.js";
+import { ErrorAlertView } from "../CurrentAlerts.js";
+import { useComponentState } from "./state.js";
+import { TermsState } from "./utils.js";
+import {
+ ShowButtonsAcceptedTosView,
+ ShowButtonsNonAcceptedTosView,
+ ShowTosContentView,
+} from "./views.js";
+
+export interface Props {
+ exchangeUrl: string;
+ readOnly?: boolean;
+ showEvenIfaccepted?: boolean;
+ children: ComponentChildren;
+}
+
+export type State =
+ | State.Loading
+ | State.Error
+ | State.ShowButtonsAccepted
+ | State.ShowButtonsNotAccepted
+ | State.ShowContent;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface Error {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ terms: TermsState;
+ }
+ export interface ShowContent extends BaseInfo {
+ status: "show-content";
+ termsAccepted: ToggleHandler;
+ showingTermsOfService?: ToggleHandler;
+ tosLang: SelectFieldHandler;
+ tosFormat: SelectFieldHandler;
+ }
+ export interface ShowButtonsAccepted extends BaseInfo {
+ status: "show-buttons-accepted";
+ termsAccepted: ToggleHandler;
+ showingTermsOfService: ToggleHandler;
+ children: ComponentChildren,
+ }
+ export interface ShowButtonsNotAccepted extends BaseInfo {
+ status: "show-buttons-not-accepted";
+ showingTermsOfService: ToggleHandler;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ "show-content": ShowTosContentView,
+ "show-buttons-accepted": ShowButtonsAcceptedTosView,
+ "show-buttons-not-accepted": ShowButtonsNonAcceptedTosView,
+};
+
+export const TermsOfService = compose(
+ "TermsOfService",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts b/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts
new file mode 100644
index 000000000..76524f0f4
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts
@@ -0,0 +1,160 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useState } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { Props, State } from "./index.js";
+import { buildTermsOfServiceState } from "./utils.js";
+
+const supportedFormats = {
+ "text/html": "HTML",
+ "text/xml" : "XML",
+ "text/markdown" : "Markdown",
+ "text/plain" : "Plain text",
+ "text/pdf" : "PDF",
+}
+
+export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, children }: Props): State {
+ const api = useBackendContext();
+ const [showContent, setShowContent] = useState<boolean>(!!readOnly);
+ const { i18n, lang } = useTranslationContext();
+ const [tosLang, setTosLang] = useState<string>()
+ const { pushAlertOnError } = useAlertContext();
+
+ const [format, setFormat] = useState("text/html")
+
+ const acceptedLang = tosLang ?? lang
+ /**
+ * For the exchange selected, bring the status of the terms of service
+ */
+ const terms = useAsyncAsHook(async () => {
+ const exchangeTos = await api.wallet.call(
+ WalletApiOperation.GetExchangeTos,
+ {
+ exchangeBaseUrl: exchangeUrl,
+ acceptedFormat: [format],
+ acceptLanguage: acceptedLang,
+ },
+ );
+
+ const supportedLangs = exchangeTos.tosAvailableLanguages.reduce((prev, cur) => {
+ prev[cur] = cur
+ return prev;
+ }, {} as Record<string, string>)
+
+ const state = buildTermsOfServiceState(exchangeTos);
+
+ return { state, supportedLangs };
+ }, [acceptedLang, format]);
+
+ if (!terms) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (terms.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the status of the term of service`,
+ terms,
+ ),
+ };
+ }
+ const { state, supportedLangs } = terms.response;
+
+ async function onUpdate(accepted: boolean): Promise<void> {
+ if (!state) return;
+
+ if (accepted) {
+ await api.wallet.call(WalletApiOperation.SetExchangeTosAccepted, {
+ exchangeBaseUrl: exchangeUrl,
+ });
+ } else {
+ // mark as not accepted
+ }
+ terms?.retry()
+ }
+
+ const accepted = state.status === "accepted";
+
+ const base = {
+ error: undefined,
+ showingTermsOfService: {
+ value: showContent && (!accepted || showEvenIfaccepted),
+ button: {
+ onClick: accepted && !showEvenIfaccepted ? undefined : pushAlertOnError(async () => {
+ setShowContent(!showContent);
+ }),
+ },
+ },
+ terms: state,
+ termsAccepted: {
+ value: accepted,
+ button: {
+ onClick: readOnly ? undefined : pushAlertOnError(async () => {
+ const newValue = !accepted; //toggle
+ await onUpdate(newValue);
+ setShowContent(false);
+ }),
+ },
+ },
+ };
+
+ if (accepted) {
+ return {
+ status: "show-buttons-accepted",
+ ...base,
+ children,
+ };
+ }
+
+ if ((accepted && showEvenIfaccepted) || showContent) {
+ return {
+ status: "show-content",
+ error: undefined,
+ terms: state,
+ showingTermsOfService: readOnly ? undefined : base.showingTermsOfService,
+ termsAccepted: base.termsAccepted,
+ tosFormat: {
+ onChange: pushAlertOnError(async (s) => {
+ setFormat(s)
+ }),
+ list: supportedFormats,
+ value: format ?? ""
+ },
+ tosLang: {
+ onChange: pushAlertOnError(async (s) => {
+ setTosLang(s)
+ }),
+ list: supportedLangs,
+ value: tosLang ?? lang
+ }
+ };
+ }
+ //showing buttons
+ return {
+ status: "show-buttons-not-accepted",
+ ...base,
+ };
+
+}
diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/stories.tsx b/packages/taler-wallet-webextension/src/components/TermsOfService/stories.tsx
new file mode 100644
index 000000000..a28729eae
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/TermsOfService/stories.tsx
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ShowTosContentView } from "./views.js";
+import { ExchangeTosStatus } from "@gnu-taler/taler-util";
+
+export default {
+ title: "TermsOfService",
+};
+
+export const Ready = tests.createExample(ShowTosContentView, {
+ tosLang: {
+ list: {
+ es: "es",
+ en: "en",
+ },
+ value: "es",
+ onChange: (() => { }) as any
+ },
+ tosFormat: {
+ list: {
+ es: "es",
+ en: "en",
+ },
+ value: "es",
+ onChange: (() => { }) as any
+ },
+ terms: {
+ content: {
+ type: "plain",
+ content: "hola"
+ },
+ status: ExchangeTosStatus.Accepted,
+ version: "1"
+ },
+ status: "show-content",
+ termsAccepted: {
+ button: {},
+ }
+});
diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/test.ts b/packages/taler-wallet-webextension/src/components/TermsOfService/test.ts
new file mode 100644
index 000000000..170e7cad8
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/TermsOfService/test.ts
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+
+describe("Term of service states", () => {
+ it.skip("should assert", () => {
+ expect([]).deep.equals([]);
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/utils.ts b/packages/taler-wallet-webextension/src/components/TermsOfService/utils.ts
new file mode 100644
index 000000000..96e268689
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/TermsOfService/utils.ts
@@ -0,0 +1,108 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ ExchangeTosStatus,
+ GetExchangeTosResult,
+ Logger,
+} from "@gnu-taler/taler-util";
+
+export function buildTermsOfServiceState(
+ tos: GetExchangeTosResult,
+): TermsState {
+ const content: TermsDocument | undefined = parseTermsOfServiceContent(
+ tos.contentType,
+ tos.content,
+ );
+
+ return { content, status: tos.tosStatus, version: tos.currentEtag };
+}
+
+const logger = new Logger("termsofservice");
+
+function parseTermsOfServiceContent(
+ type: string,
+ text: string,
+): TermsDocument | undefined {
+ if (type === "text/xml") {
+ try {
+ const document = new DOMParser().parseFromString(text, "text/xml");
+ return { type: "xml", document };
+ } catch (e) {
+ logger.error("error parsing xml", e);
+ }
+ } else if (type === "text/html") {
+ try {
+ return { type: "html", html: text };
+ } catch (e) {
+ logger.error("error parsing url", e);
+ }
+ } else if (type === "text/json") {
+ try {
+ const data = JSON.parse(text);
+ return { type: "json", data };
+ } catch (e) {
+ logger.error("error parsing json", e);
+ }
+ } else if (type === "text/pdf") {
+ try {
+ const location = new URL(text);
+ return { type: "pdf", location };
+ } catch (e) {
+ logger.error("error parsing url", e);
+ }
+ }
+ const content = text;
+ return { type: "plain", content };
+}
+
+export type TermsState = {
+ content: TermsDocument | undefined;
+ status: ExchangeTosStatus;
+ version: string;
+};
+
+export type TermsDocument =
+ | TermsDocumentXml
+ | TermsDocumentHtml
+ | TermsDocumentPlain
+ | TermsDocumentJson
+ | TermsDocumentPdf;
+
+export interface TermsDocumentXml {
+ type: "xml";
+ document: Document;
+}
+
+export interface TermsDocumentHtml {
+ type: "html";
+ html: string;
+}
+
+export interface TermsDocumentPlain {
+ type: "plain";
+ content: string;
+}
+
+export interface TermsDocumentJson {
+ type: "json";
+ data: any;
+}
+
+export interface TermsDocumentPdf {
+ type: "pdf";
+ location: URL;
+}
diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx b/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx
new file mode 100644
index 000000000..40cfba3bc
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx
@@ -0,0 +1,225 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ExchangeTosStatus } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { CheckboxOutlined } from "../../components/CheckboxOutlined.js";
+import { ExchangeXmlTos } from "../../components/ExchangeToS.js";
+import {
+ Input,
+ LinkSuccess,
+ TermsOfServiceStyle,
+ WarningBox
+} from "../../components/styled/index.js";
+import { Button } from "../../mui/Button.js";
+import { State } from "./index.js";
+import { SelectList } from "../SelectList.js";
+import { EnabledBySettings } from "../EnabledBySettings.js";
+
+export function ShowButtonsAcceptedTosView({
+ termsAccepted,
+ showingTermsOfService,
+ children,
+}: State.ShowButtonsAccepted): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ {showingTermsOfService.button.onClick !== undefined && (
+ <Fragment>
+ <section style={{ justifyContent: "space-around", display: "flex" }}>
+ <LinkSuccess
+ upperCased
+ onClick={showingTermsOfService.button.onClick}
+ >
+ <i18n.Translate>Show terms of service</i18n.Translate>
+ </LinkSuccess>
+ </section>
+ {termsAccepted.button.onClick !== undefined && (
+ <section style={{ justifyContent: "space-around", display: "flex" }}>
+ <CheckboxOutlined
+ name="terms"
+ enabled={termsAccepted.value}
+ label={
+ <i18n.Translate>
+ I accept the exchange terms of service
+ </i18n.Translate>
+ }
+ onToggle={termsAccepted.button.onClick}
+ />
+ </section>
+ )}
+ </Fragment>
+ )}
+ {children}
+ </Fragment>
+ );
+}
+
+export function ShowButtonsNonAcceptedTosView({
+ showingTermsOfService,
+ terms,
+}: State.ShowButtonsNotAccepted): VNode {
+ const { i18n } = useTranslationContext();
+ // const ableToReviewTermsOfService =
+ // showingTermsOfService.button.onClick !== undefined;
+
+ // if (!ableToReviewTermsOfService) {
+ // return (
+ // <Fragment>
+ // {terms.status === ExchangeTosStatus.Pending && (
+ // <section style={{ justifyContent: "space-around", display: "flex" }}>
+ // <WarningText>
+ // <i18n.Translate>
+ // Exchange doesn&apos;t have terms of service
+ // </i18n.Translate>
+ // </WarningText>
+ // </section>
+ // )}
+ // </Fragment>
+ // );
+ // }
+
+ return (
+ <Fragment>
+ {/* {terms.status === ExchangeTosStatus.NotFound && (
+ <section style={{ justifyContent: "space-around", display: "flex" }}>
+ <WarningText>
+ <i18n.Translate>
+ Exchange doesn&apos;t have terms of service
+ </i18n.Translate>
+ </WarningText>
+ </section>
+ )} */}
+ <section style={{ justifyContent: "space-around", display: "flex" }}>
+ <Button
+ variant="contained"
+ color="success"
+ onClick={showingTermsOfService.button.onClick}
+ >
+ <i18n.Translate>Review exchange terms of service</i18n.Translate>
+ </Button>
+ </section>
+ </Fragment>
+ );
+}
+
+export function ShowTosContentView({
+ termsAccepted,
+ showingTermsOfService,
+ terms,
+ tosLang,
+ tosFormat,
+}: State.ShowContent): VNode {
+ const { i18n } = useTranslationContext();
+ const ableToReviewTermsOfService =
+ termsAccepted.button.onClick !== undefined;
+
+ return (
+ <section>
+ <Input style={{ display: "flex", justifyContent: "end" }}>
+ <EnabledBySettings name="selectTosFormat">
+ <SelectList
+ label={i18n.str`Format`}
+ list={tosFormat.list}
+ name="format"
+ value={tosFormat.value}
+ onChange={tosFormat.onChange}
+ />
+ </EnabledBySettings>
+ <SelectList
+ label={i18n.str`Language`}
+ list={tosLang.list}
+ name="lang"
+ value={tosLang.value}
+ onChange={tosLang.onChange}
+ />
+ </Input>
+
+ {!terms.content && (
+ <section style={{ justifyContent: "space-around", display: "flex" }}>
+ <WarningBox>
+ <i18n.Translate>
+ The exchange replied with a empty terms of service
+ </i18n.Translate>
+ </WarningBox>
+ </section>
+ )}
+ {terms.content && (
+ <section style={{ justifyContent: "space-around", display: "flex" }}>
+ {terms.content.type === "xml" &&
+ (!terms.content.document ? (
+ <WarningBox>
+ <i18n.Translate>
+ No terms of service. The exchange replied with a empty
+ document
+ </i18n.Translate>
+ </WarningBox>
+ ) : (
+ <TermsOfServiceStyle>
+ <ExchangeXmlTos doc={terms.content.document} />
+ </TermsOfServiceStyle>
+ ))}
+ {terms.content.type === "plain" &&
+ (!terms.content.content ? (
+ <WarningBox>
+ <i18n.Translate>
+ No terms of service. The exchange replied with a empty text
+ </i18n.Translate>
+ </WarningBox>
+ ) : (
+ <div style={{ textAlign: "left" }}>
+ <pre>{terms.content.content}</pre>
+ </div>
+ ))}
+ {terms.content.type === "html" && (
+ <iframe style={{ width: "100%" }} srcDoc={terms.content.html} />
+ )}
+ {terms.content.type === "pdf" && (
+ <a href={terms.content.location.toString()} download="tos.pdf">
+ <i18n.Translate>Download Terms of Service</i18n.Translate>
+ </a>
+ )}
+ </section>
+ )}
+ {showingTermsOfService && ableToReviewTermsOfService && (
+ <section style={{ justifyContent: "space-around", display: "flex" }}>
+ <LinkSuccess
+ upperCased
+ onClick={showingTermsOfService.button.onClick}
+ >
+ <i18n.Translate>Hide terms of service</i18n.Translate>
+ </LinkSuccess>
+ </section>
+ )}
+ {termsAccepted.button.onClick && terms.status !== ExchangeTosStatus.Accepted && (
+ <section style={{ justifyContent: "space-around", display: "flex" }}>
+ <CheckboxOutlined
+ name="terms"
+ enabled={termsAccepted.value}
+ label={
+ <i18n.Translate>
+ I accept the exchange terms of service
+ </i18n.Translate>
+ }
+ onToggle={termsAccepted.button.onClick}
+ />
+ </section>
+ )}
+ </section>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/Time.tsx b/packages/taler-wallet-webextension/src/components/Time.tsx
new file mode 100644
index 000000000..eee295756
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/Time.tsx
@@ -0,0 +1,46 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AbsoluteTime } from "@gnu-taler/taler-util";
+import { formatISO, format } from "date-fns";
+import { h, VNode } from "preact";
+
+/**
+ *
+ * @deprecated use web-util
+ * @returns
+ */
+export function Time({
+ timestamp,
+ format: formatString,
+}: {
+ timestamp: AbsoluteTime | undefined;
+ format: string;
+}): VNode {
+ return (
+ <time
+ dateTime={
+ !timestamp || timestamp.t_ms === "never"
+ ? undefined
+ : formatISO(timestamp.t_ms)
+ }
+ >
+ {!timestamp || timestamp.t_ms === "never"
+ ? "never"
+ : format(timestamp.t_ms, formatString)}
+ </time>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/components/TransactionItem.tsx b/packages/taler-wallet-webextension/src/components/TransactionItem.tsx
deleted file mode 100644
index 991e97c94..000000000
--- a/packages/taler-wallet-webextension/src/components/TransactionItem.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { AmountString, Timestamp, Transaction, TransactionType } from '@gnu-taler/taler-util';
-import { format, formatDistance } from 'date-fns';
-import { h } from 'preact';
-import imageBank from '../../static/img/ri-bank-line.svg';
-import imageHandHeart from '../../static/img/ri-hand-heart-line.svg';
-import imageRefresh from '../../static/img/ri-refresh-line.svg';
-import imageRefund from '../../static/img/ri-refund-2-line.svg';
-import imageShoppingCart from '../../static/img/ri-shopping-cart-line.svg';
-import { Pages } from "../NavigationBar";
-import { Column, ExtraLargeText, HistoryRow, SmallLightText, LargeText, LightText } from './styled/index';
-
-export function TransactionItem(props: { tx: Transaction, multiCurrency: boolean }): JSX.Element {
- const tx = props.tx;
- switch (tx.type) {
- case TransactionType.Withdrawal:
- return (
- <TransactionLayout
- id={tx.transactionId}
- amount={tx.amountEffective}
- debitCreditIndicator={"credit"}
- title={new URL(tx.exchangeBaseUrl).hostname}
- timestamp={tx.timestamp}
- iconPath={imageBank}
- pending={tx.pending}
- multiCurrency={props.multiCurrency}
- />
- );
- case TransactionType.Payment:
- return (
- <TransactionLayout
- id={tx.transactionId}
- amount={tx.amountEffective}
- debitCreditIndicator={"debit"}
- title={tx.info.merchant.name}
- subtitle={tx.info.summary}
- timestamp={tx.timestamp}
- iconPath={imageShoppingCart}
- pending={tx.pending}
- multiCurrency={props.multiCurrency}
- />
- );
- case TransactionType.Refund:
- return (
- <TransactionLayout
- id={tx.transactionId}
- amount={tx.amountEffective}
- debitCreditIndicator={"credit"}
- title={tx.info.merchant.name}
- timestamp={tx.timestamp}
- iconPath={imageRefund}
- pending={tx.pending}
- multiCurrency={props.multiCurrency}
- />
- );
- case TransactionType.Tip:
- return (
- <TransactionLayout
- id={tx.transactionId}
- amount={tx.amountEffective}
- debitCreditIndicator={"credit"}
- title={new URL(tx.merchantBaseUrl).hostname}
- timestamp={tx.timestamp}
- iconPath={imageHandHeart}
- pending={tx.pending}
- multiCurrency={props.multiCurrency}
- />
- );
- case TransactionType.Refresh:
- return (
- <TransactionLayout
- id={tx.transactionId}
- amount={tx.amountEffective}
- debitCreditIndicator={"credit"}
- title={new URL(tx.exchangeBaseUrl).hostname}
- timestamp={tx.timestamp}
- iconPath={imageRefresh}
- pending={tx.pending}
- multiCurrency={props.multiCurrency}
- />
- );
- case TransactionType.Deposit:
- return (
- <TransactionLayout
- id={tx.transactionId}
- amount={tx.amountEffective}
- debitCreditIndicator={"debit"}
- title={tx.targetPaytoUri}
- timestamp={tx.timestamp}
- iconPath={imageRefresh}
- pending={tx.pending}
- multiCurrency={props.multiCurrency}
- />
- );
- }
-}
-
-function TransactionLayout(props: TransactionLayoutProps): JSX.Element {
- const date = new Date(props.timestamp.t_ms);
- const dateStr = format(date, 'dd MMM, hh:mm')
-
- return (
- <HistoryRow href={Pages.transaction.replace(':tid', props.id)}>
- <img src={props.iconPath} />
- <Column>
- <LargeText>
- <div>{props.title}</div>
- {props.subtitle && <div style={{color:'gray', fontSize:'medium', marginTop: 5}}>{props.subtitle}</div>}
- </LargeText>
- {props.pending &&
- <LightText style={{ marginTop: 5, marginBottom: 5 }}>Waiting for confirmation</LightText>
- }
- <SmallLightText style={{marginTop:5 }}>{dateStr}</SmallLightText>
- </Column>
- <TransactionAmount
- pending={props.pending}
- amount={props.amount}
- multiCurrency={props.multiCurrency}
- debitCreditIndicator={props.debitCreditIndicator}
- />
- </HistoryRow>
- );
-}
-
-interface TransactionLayoutProps {
- debitCreditIndicator: "debit" | "credit" | "unknown";
- amount: AmountString | "unknown";
- timestamp: Timestamp;
- title: string;
- subtitle?: string;
- id: string;
- iconPath: string;
- pending: boolean;
- multiCurrency: boolean;
-}
-
-interface TransactionAmountProps {
- debitCreditIndicator: "debit" | "credit" | "unknown";
- amount: AmountString | "unknown";
- pending: boolean;
- multiCurrency: boolean;
-}
-
-function TransactionAmount(props: TransactionAmountProps): JSX.Element {
- const [currency, amount] = props.amount.split(":");
- let sign: string;
- switch (props.debitCreditIndicator) {
- case "credit":
- sign = "+";
- break;
- case "debit":
- sign = "-";
- break;
- case "unknown":
- sign = "";
- }
- return (
- <Column style={{
- textAlign: 'center',
- color:
- props.pending ? "gray" :
- (sign === '+' ? 'darkgreen' :
- (sign === '-' ? 'darkred' :
- undefined))
- }}>
- <ExtraLargeText>
- {sign}
- {amount}
- </ExtraLargeText>
- {props.multiCurrency && <div>{currency}</div>}
- {props.pending && <div>PENDING</div>}
- </Column>
- );
-}
-
diff --git a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx
new file mode 100644
index 000000000..1c566c3e4
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx
@@ -0,0 +1,834 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ NotificationType,
+ ObservabilityEventType,
+ RequestProgressNotification,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TaskProgressNotification,
+ WalletNotification,
+ assertUnreachable
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, JSX, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { Pages } from "../NavigationBar.js";
+import { useBackendContext } from "../context/backend.js";
+import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { useSettings } from "../hooks/useSettings.js";
+import { Button } from "../mui/Button.js";
+import { WxApiType } from "../wxApi.js";
+import { Modal } from "./Modal.js";
+import { Time } from "./Time.js";
+
+interface Props extends JSX.HTMLAttributes {
+}
+
+export function WalletActivity({ }: Props): VNode {
+ const { i18n } = useTranslationContext()
+ const [settings, updateSettings] = useSettings()
+ const api = useBackendContext();
+ useEffect(() => {
+ document.body.style.marginBottom = "250px"
+ return () => {
+ document.body.style.marginBottom = "0px"
+ }
+ })
+ const [table, setTable] = useState<"tasks" | "events">("tasks")
+ return (
+ <div style={{ position: "fixed", bottom: 0, background: "white", zIndex: 1, height: 250, overflowY: "scroll", width: "100%" }}>
+ <div style={{ display: "flex", justifyContent: "space-between", float: "right" }}>
+ <div />
+ <div>
+ <div style={{ padding: 4, margin: 2, border: "solid 1px black" }} onClick={() => {
+ updateSettings("showWalletActivity", false)
+ }}>
+ close
+ </div>
+ </div>
+ </div>
+ <div style={{ display: "flex", justifyContent: "space-around" }}>
+ <Button variant={table === "tasks" ? "contained" : "outlined"}
+ style={{ margin: 4 }}
+ onClick={async () => {
+ setTable("tasks")
+ }}
+ >
+ <i18n.Translate>Tasks</i18n.Translate>
+ </Button>
+ <Button variant={table === "events" ? "contained" : "outlined"}
+ style={{ margin: 4 }}
+ onClick={async () => {
+ setTable("events")
+ }}
+ >
+ <i18n.Translate>Events</i18n.Translate>
+ </Button>
+
+ </div>
+ {(function (): VNode {
+ switch (table) {
+ case "events": {
+ return <ObservabilityEventsTable />
+ }
+ case "tasks": {
+ return <ActiveTasksTable />
+ }
+ default: {
+ assertUnreachable(table)
+ }
+ }
+ })()}
+ </div>
+ );
+}
+
+interface MoreInfoPRops { events: (WalletNotification & { when: AbsoluteTime })[], onClick: (content: VNode) => void }
+type Notif = {
+ id: string;
+ events: (WalletNotification & { when: AbsoluteTime })[];
+ description: string;
+ start: AbsoluteTime;
+ end: AbsoluteTime;
+ reference: {
+ eventType: NotificationType,
+ referenceType: "task" | "transaction" | "operation" | "exchange",
+ id: string;
+ } | undefined,
+ MoreInfo: (p: MoreInfoPRops) => VNode;
+}
+
+function ShowBalanceChange({ events }: MoreInfoPRops): VNode {
+ if (!events.length) return <Fragment />;
+ const not = events[0];
+ if (not.type !== NotificationType.BalanceChange) return <Fragment />;
+ return <Fragment>
+ <dt>Transaction</dt>
+ <dd>
+ <a title={not.hintTransactionId} href={Pages.balanceTransaction({ tid: not.hintTransactionId })}>{not.hintTransactionId.substring(0, 10)}</a>
+ </dd>
+ </Fragment>
+}
+
+function ShowBackupOperationError({ events, onClick }: MoreInfoPRops): VNode {
+ if (!events.length) return <Fragment />;
+ const not = events[0];
+ if (not.type !== NotificationType.BackupOperationError) return <Fragment />;
+ return <Fragment>
+ <dt>Error</dt>
+ <dd>
+ <a href="#" onClick={(e) => {
+ e.preventDefault();
+ const error = not.error
+ onClick(<Fragment>
+ <dl>
+ <dt>Code</dt>
+ <dd>{TalerErrorCode[error.code]} ({error.code})</dd>
+ <dt>Hint</dt>
+ <dd>{error.hint ?? "--"}</dd>
+ <dt>Time</dt>
+ <dd><Time
+ timestamp={error.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ /></dd>
+ </dl>
+ <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
+ {JSON.stringify(error, undefined, 2)}
+ </pre>
+ </Fragment>)
+ }}>{TalerErrorCode[not.error.code]}</a>
+ </dd>
+ </Fragment>
+}
+
+function ShowTransactionStateTransition({ events, onClick }: MoreInfoPRops): VNode {
+ if (!events.length) return <Fragment />;
+ const not = events[0];
+ if (not.type !== NotificationType.TransactionStateTransition) return <Fragment />;
+ return <Fragment>
+ <dt>Old state</dt>
+ <dd>
+ {not.oldTxState.major} - {not.oldTxState.minor ?? ""}
+ </dd>
+ <dt>New state</dt>
+ <dd>
+ {not.newTxState.major} - {not.newTxState.minor ?? ""}
+ </dd>
+ <dt>Transaction</dt>
+ <dd>
+ <a title={not.transactionId} href={Pages.balanceTransaction({ tid: not.transactionId })}>{not.transactionId.substring(0, 10)}</a>
+ </dd>
+ {not.errorInfo ? <Fragment>
+ <dt>Error</dt>
+ <dd>
+ <a href="#" onClick={(e) => {
+ if (!not.errorInfo) return;
+ e.preventDefault();
+ const error = not.errorInfo;
+ onClick(<Fragment>
+ <dl>
+ <dt>Code</dt>
+ <dd>{TalerErrorCode[error.code]} ({error.code})</dd>
+ <dt>Hint</dt>
+ <dd>{error.hint ?? "--"}</dd>
+ <dt>Message</dt>
+ <dd>{error.message ?? "--"}</dd>
+ </dl>
+ </Fragment>)
+
+ }}>{TalerErrorCode[not.errorInfo.code]}</a>
+ </dd>
+ </Fragment> : undefined}
+ <dt>Experimental</dt>
+ <dd>
+ <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
+ {JSON.stringify(not.experimentalUserData, undefined, 2)}
+ </pre>
+ </dd>
+
+
+ </Fragment>
+}
+function ShowExchangeStateTransition({ events, onClick }: MoreInfoPRops): VNode {
+ if (!events.length) return <Fragment />;
+ const not = events[0];
+ if (not.type !== NotificationType.ExchangeStateTransition) return <Fragment />;
+ return <Fragment>
+ <dt>Exchange</dt>
+ <dd>
+ {not.exchangeBaseUrl}
+ </dd>
+ {not.oldExchangeState && not.newExchangeState.exchangeEntryStatus !== not.oldExchangeState?.exchangeEntryStatus && <Fragment>
+ <dt>Entry status</dt>
+ <dd>
+ from {not.oldExchangeState.exchangeEntryStatus} to {not.newExchangeState.exchangeEntryStatus}
+ </dd>
+ </Fragment>}
+ {not.oldExchangeState && not.newExchangeState.exchangeUpdateStatus !== not.oldExchangeState?.exchangeUpdateStatus && <Fragment>
+ <dt>Update status</dt>
+ <dd>
+ from {not.oldExchangeState.exchangeUpdateStatus} to {not.newExchangeState.exchangeUpdateStatus}
+ </dd>
+ </Fragment>}
+ {not.oldExchangeState && not.newExchangeState.tosStatus !== not.oldExchangeState?.tosStatus && <Fragment>
+ <dt>Tos status</dt>
+ <dd>
+ from {not.oldExchangeState.tosStatus} to {not.newExchangeState.tosStatus}
+ </dd>
+ </Fragment>}
+ </Fragment>
+}
+
+type ObservaNotifWithTime = ((TaskProgressNotification | RequestProgressNotification) & {
+ when: AbsoluteTime;
+})
+function ShowObservabilityEvent({ events, onClick }: MoreInfoPRops): VNode {
+ // let prev: ObservaNotifWithTime;
+ const asd = events.map(not => {
+ if (not.type !== NotificationType.RequestObservabilityEvent && not.type !== NotificationType.TaskObservabilityEvent) return <Fragment />;
+
+ const title = (function () {
+ switch (not.event.type) {
+ case ObservabilityEventType.HttpFetchFinishError:
+ case ObservabilityEventType.HttpFetchFinishSuccess:
+ case ObservabilityEventType.HttpFetchStart: return "HTTP Request"
+ case ObservabilityEventType.DbQueryFinishSuccess:
+ case ObservabilityEventType.DbQueryFinishError:
+ case ObservabilityEventType.DbQueryStart: return "Database"
+ case ObservabilityEventType.RequestFinishSuccess:
+ case ObservabilityEventType.RequestFinishError:
+ case ObservabilityEventType.RequestStart: return "Wallet"
+ case ObservabilityEventType.CryptoFinishSuccess:
+ case ObservabilityEventType.CryptoFinishError:
+ case ObservabilityEventType.CryptoStart: return "Crypto"
+ case ObservabilityEventType.TaskStart: return "Task start"
+ case ObservabilityEventType.TaskStop: return "Task stop"
+ case ObservabilityEventType.TaskReset: return "Task reset"
+ case ObservabilityEventType.ShepherdTaskResult: return "Schedule"
+ case ObservabilityEventType.DeclareTaskDependency: return "Task dependency"
+ case ObservabilityEventType.Message: return "Message"
+ }
+ })();
+
+ return <ShowObervavilityDetails title={title} notif={not} onClick={onClick} />
+
+ })
+ return <table>
+ <thead>
+ <td>Event</td>
+ <td>Info</td>
+ <td>Start</td>
+ <td>End</td>
+ </thead>
+ <tbody>
+ {asd}
+ </tbody>
+ </table>
+}
+
+function ShowObervavilityDetails({ title, notif, onClick, prev }: { title: string, notif: ObservaNotifWithTime, prev?: ObservaNotifWithTime, onClick: (content: VNode) => void }): VNode {
+ switch (notif.event.type) {
+ case ObservabilityEventType.HttpFetchStart:
+ case ObservabilityEventType.HttpFetchFinishError:
+ case ObservabilityEventType.HttpFetchFinishSuccess: {
+ return <tr>
+ <td><a href="#" onClick={(e) => {
+ e.preventDefault();
+ onClick(<Fragment>
+ <pre
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>);
+ }}>{title}</a></td>
+ <td>
+ {notif.event.url} {
+ prev?.event.type === ObservabilityEventType.HttpFetchFinishSuccess ? `(${prev.event.status})`
+ : prev?.event.type === ObservabilityEventType.HttpFetchFinishError ? <a href="#" onClick={(e) => {
+ e.preventDefault();
+ if (prev.event.type !== ObservabilityEventType.HttpFetchFinishError) return;
+ const error = prev.event.error
+ onClick(<Fragment>
+ <dl>
+ <dt>Code</dt>
+ <dd>{TalerErrorCode[error.code]} ({error.code})</dd>
+ <dt>Hint</dt>
+ <dd>{error.hint ?? "--"}</dd>
+ <dt>Time</dt>
+ <dd><Time
+ timestamp={error.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ /></dd>
+ </dl>
+ <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
+ {JSON.stringify(error, undefined, 2)}
+ </pre>
+
+ </Fragment>)
+ }}>fail</a> : undefined
+ }
+ </td>
+ <td> <Time
+ timestamp={notif.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ /></td>
+ <td> <Time
+ timestamp={prev?.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ /></td>
+ </tr>
+
+ }
+ case ObservabilityEventType.DbQueryStart:
+ case ObservabilityEventType.DbQueryFinishSuccess:
+ case ObservabilityEventType.DbQueryFinishError: {
+ return <tr>
+ <td><a href="#" onClick={(e) => {
+ e.preventDefault();
+ onClick(<Fragment>
+ <pre
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>);
+ }}>{title}</a></td>
+ <td>
+ {notif.event.location} {notif.event.name}
+ </td>
+ <td>
+ <Time
+ timestamp={notif.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ <td>
+ <Time
+ timestamp={prev?.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ </tr>
+ }
+
+ case ObservabilityEventType.TaskStart:
+ case ObservabilityEventType.TaskStop:
+ case ObservabilityEventType.DeclareTaskDependency:
+ case ObservabilityEventType.TaskReset: {
+ return <tr>
+ <td><a href="#" onClick={(e) => {
+ e.preventDefault();
+ onClick(<Fragment>
+ <pre
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>);
+ }}>{title}</a></td>
+ <td>
+ {notif.event.taskId}
+ </td>
+ <td>
+ <Time
+ timestamp={notif.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ <td>
+ <Time
+ timestamp={prev?.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ </tr>
+ }
+ case ObservabilityEventType.ShepherdTaskResult: {
+ return <tr>
+ <td><a href="#" onClick={(e) => {
+ e.preventDefault();
+ onClick(<Fragment>
+ <pre
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>);
+ }}>{title}</a></td>
+ <td>
+ {notif.event.resultType}
+ </td>
+ <td>
+ <Time
+ timestamp={notif.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ <td>
+ <Time
+ timestamp={prev?.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ </tr>
+
+ }
+ case ObservabilityEventType.CryptoStart:
+ case ObservabilityEventType.CryptoFinishSuccess:
+ case ObservabilityEventType.CryptoFinishError: {
+ return <tr>
+ <td><a href="#" onClick={(e) => {
+ e.preventDefault();
+ onClick(<Fragment>
+ <pre
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>);
+ }}>{title}</a></td>
+ <td>
+ {notif.event.operation}
+ </td>
+ <td>
+ <Time
+ timestamp={notif.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ <td>
+ <Time
+ timestamp={prev?.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ </tr>
+ }
+ case ObservabilityEventType.RequestStart:
+ case ObservabilityEventType.RequestFinishSuccess:
+ case ObservabilityEventType.RequestFinishError: {
+ return <tr >
+ <td><a href="#" onClick={(e) => {
+ e.preventDefault();
+ onClick(<Fragment>
+ <pre
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
+ >
+ {JSON.stringify({ event: notif, prev }, undefined, 2)}
+ </pre>
+ </Fragment>);
+ }}>{title}</a></td>
+ <td>
+ {notif.event.type}
+ </td>
+ <td>
+ <Time
+ timestamp={notif.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ <td>
+ <Time
+ timestamp={prev?.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ </tr>
+ }
+ case ObservabilityEventType.Message:
+ // FIXME
+ return <></>;
+ }
+}
+
+function getNotificationFor(id: string, event: WalletNotification, start: AbsoluteTime, list: Notif[]): Notif | undefined {
+ const eventWithTime = { ...event, when: start }
+ switch (event.type) {
+ case NotificationType.BalanceChange: {
+ return ({
+ id,
+ events: [eventWithTime],
+ reference: {
+ eventType: event.type,
+ referenceType: "transaction",
+ id: event.hintTransactionId,
+ },
+ description: "Balance change",
+ start,
+ end: AbsoluteTime.never(),
+ MoreInfo: ShowBalanceChange
+ })
+ }
+ case NotificationType.BackupOperationError: {
+ return ({
+ id,
+ events: [eventWithTime],
+ reference: undefined,
+ description: "Backup error",
+ start,
+ end: AbsoluteTime.never(),
+ MoreInfo: ShowBackupOperationError
+ })
+ }
+ case NotificationType.TransactionStateTransition: {
+ const found = list.find(a => a.reference?.eventType === event.type && a.reference.id === event.transactionId)
+ if (found) {
+ found.end = start;
+ found.events.unshift(eventWithTime)
+ return undefined
+ }
+ return ({
+ id,
+ events: [eventWithTime],
+ reference: {
+ eventType: event.type,
+ referenceType: "transaction",
+ id: event.transactionId,
+ },
+ description: event.type,
+ start,
+ end: AbsoluteTime.never(),
+ MoreInfo: ShowTransactionStateTransition
+ })
+ }
+ case NotificationType.ExchangeStateTransition: {
+ const found = list.find(a => a.reference?.eventType === event.type && a.reference.id === event.exchangeBaseUrl)
+ if (found) {
+ found.end = start;
+ found.events.unshift(eventWithTime)
+ return undefined
+ }
+ return ({
+ id,
+ events: [eventWithTime],
+ description: "Exchange update",
+ reference: {
+ eventType: event.type,
+ referenceType: "exchange",
+ id: event.exchangeBaseUrl,
+ },
+ start,
+ end: AbsoluteTime.never(),
+ MoreInfo: ShowExchangeStateTransition
+ })
+ }
+ case NotificationType.TaskObservabilityEvent: {
+ const found = list.find(a => a.reference?.eventType === event.type && a.reference.id === event.taskId)
+ if (found) {
+ found.end = start;
+ found.events.unshift(eventWithTime)
+ return undefined
+ }
+ return ({
+ id,
+ events: [eventWithTime],
+ reference: {
+ eventType: event.type,
+ referenceType: "task",
+ id: event.taskId,
+ },
+ description: `Task update ${event.taskId}`,
+ start,
+ end: AbsoluteTime.never(),
+ MoreInfo: ShowObservabilityEvent
+ })
+ }
+ case NotificationType.WithdrawalOperationTransition: {
+ const found = list.find(a => a.reference?.eventType === event.type && a.reference.id === event.uri)
+ if (found) {
+ found.end = start;
+ found.events.unshift(eventWithTime)
+ return undefined
+ }
+ return ({
+ id,
+ events: [eventWithTime],
+ reference: {
+ eventType: event.type,
+ referenceType: "task",
+ id: event.uri,
+ },
+ description: `Withdrawal operation updated`,
+ start,
+ end: AbsoluteTime.never(),
+ MoreInfo: ShowObservabilityEvent
+ })
+ }
+ case NotificationType.RequestObservabilityEvent: {
+ const found = list.find(a => a.reference?.eventType === event.type && a.reference.id === event.requestId)
+ if (found) {
+ found.end = start;
+ found.events.unshift(eventWithTime)
+ return undefined
+ }
+ return ({
+ id,
+ events: [eventWithTime],
+ reference: {
+ eventType: event.type,
+ referenceType: "operation",
+ id: event.requestId,
+ },
+ description: `wallet.${event.operation}(${event.requestId})`,
+ start,
+ end: AbsoluteTime.never(),
+ MoreInfo: ShowObservabilityEvent
+ })
+ }
+ default: {
+ assertUnreachable(event)
+ }
+ }
+}
+
+
+function refresh(api: WxApiType, onUpdate: (list: Notif[]) => void) {
+ api.background.call("getNotifications", undefined).then(notif => {
+
+ const list: Notif[] = []
+ for (const n of notif) {
+ if (n.notification.type === NotificationType.RequestObservabilityEvent &&
+ n.notification.operation === "getActiveTasks") {
+ //ignore monitor request
+ continue;
+ }
+ const event = getNotificationFor(String(list.length), n.notification, n.when, list)
+ // pepe.
+ if (event) {
+ list.unshift(event)
+ }
+ }
+ onUpdate(list);
+ }).catch(error => {
+ console.log(error)
+ })
+}
+
+export function ObservabilityEventsTable({ }: {}): VNode {
+ const { i18n } = useTranslationContext()
+ const api = useBackendContext();
+
+ const [notifications, setNotifications] = useState<Notif[]>([])
+ const [showDetails, setShowDetails] = useState<VNode>()
+
+ useEffect(() => {
+ let lastTimeout: ReturnType<typeof setTimeout>;
+ function periodicRefresh() {
+
+ refresh(api, setNotifications)
+
+ lastTimeout = setTimeout(() => {
+ periodicRefresh();
+ }, 1000)
+
+ //clear on unload
+ return () => { clearTimeout(lastTimeout) }
+ }
+ return periodicRefresh()
+ }, [1]);
+
+ return <div>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+
+ <div style={{ padding: 4, margin: 2, border: "solid 1px black" }} onClick={() => {
+ api.background.call("clearNotifications", undefined).then(d => {
+ refresh(api, setNotifications)
+ })
+ }}>
+ clear
+ </div>
+
+
+ </div>
+ {showDetails && <Modal title="event details" onClose={{ onClick: (async () => { setShowDetails(undefined) }) as any }} >
+ {showDetails}
+ </Modal>}
+ {notifications.map((not) => {
+ return (
+ <details key={not.id}>
+ <summary>
+ <div style={{ width: "90%", display: "inline-flex", justifyContent: "space-between", padding: 4 }}>
+ <div style={{ padding: 4 }}>
+ {not.description}
+ </div>
+ <div style={{ padding: 4 }}>
+ <Time
+ timestamp={not.start}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </div>
+ <div style={{ padding: 4 }}><Time
+ timestamp={not.end}
+ format="yyyy/MM/dd HH:mm:ss"
+ /></div>
+ </div>
+ </summary>
+ <not.MoreInfo events={not.events} onClick={(details) => {
+ setShowDetails(details)
+ }} />
+ </details>
+ );
+ })}
+ </div >
+}
+
+function ErroDetailModal({ error, onClose }: { error: TalerErrorDetail, onClose: () => void }): VNode {
+ return <Modal title="Full detail" onClose={{
+ onClick: onClose as any
+ }}>
+ <dl>
+ <dt>Code</dt>
+ <dd>{TalerErrorCode[error.code]} ({error.code})</dd>
+ <dt>Hint</dt>
+ <dd>{error.hint ?? "--"}</dd>
+ <dt>Time</dt>
+ <dd><Time
+ timestamp={error.when}
+ format="yyyy/MM/dd HH:mm:ss"
+ /></dd>
+ </dl>
+ <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
+ {JSON.stringify(error, undefined, 2)}
+ </pre>
+ </Modal>
+}
+
+export function ActiveTasksTable({ }: {}): VNode {
+ const { i18n } = useTranslationContext()
+ const api = useBackendContext();
+ const state = useAsyncAsHook(() => {
+ return api.wallet.call(WalletApiOperation.GetActiveTasks, {});
+ });
+ const [showError, setShowError] = useState<TalerErrorDetail>()
+ const tasks = state && !state.hasError ? state.response.tasks : [];
+
+ useEffect(() => {
+ if (!state || state.hasError) return
+ const lastTimeout = setTimeout(() => {
+ state.retry();
+ }, 1000)
+ return () => {
+ clearTimeout(lastTimeout)
+ }
+ }, [tasks])
+
+ // const listenAllEvents = Array.from<NotificationType>({ length: 1 });
+ // listenAllEvents.includes = () => true
+ // useEffect(() => {
+ // return api.listener.onUpdateNotification(listenAllEvents, (notif) => {
+ // state?.retry()
+ // });
+ // });
+ return <Fragment>
+ {showError && <ErroDetailModal error={showError} onClose={(async () => { setShowError(undefined) })} />}
+
+ <table style={{ width: "100%" }}>
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Type</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Id</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Since</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Next try</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Error</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Transaction</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {tasks.map((task) => {
+ const [type, id] = task.id.split(":")
+ return (
+ <tr>
+ <td>{type}</td>
+ <td title={id}>{id.substring(0, 10)}</td>
+ <td>
+ <Time
+ timestamp={task.firstTry}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ <td>
+ <Time
+ timestamp={task.nextTry}
+ format="yyyy/MM/dd HH:mm:ss"
+ />
+ </td>
+ <td>{!task.lastError?.code ? "" : <a href="#" onClick={(e) => { e.preventDefault(); setShowError(task.lastError) }}>{TalerErrorCode[task.lastError.code]}</a>}</td>
+ <td>
+ {task.transaction ? <a title={task.transaction} href={Pages.balanceTransaction({ tid: task.transaction })}>{task.transaction.substring(0, 10)}</a> : "--"}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </Fragment>
+} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/components/index.stories.tsx b/packages/taler-wallet-webextension/src/components/index.stories.tsx
new file mode 100644
index 000000000..4a7a068d3
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/components/index.stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export * as a1 from "./Banner.stories.js";
+export * as a2 from "./PendingTransactions.stories.js";
+export * as a3 from "./Amount.stories.js";
+export * as a4 from "./ShowFullContractTermPopup.stories.js";
+export * as a5 from "./TermsOfService/stories.js";
+export * as a6 from "./QR.stories.js";
+export * as a7 from "./AmountField.stories.js";
diff --git a/packages/taler-wallet-webextension/src/components/styled/index.tsx b/packages/taler-wallet-webextension/src/components/styled/index.tsx
index 65c1f49e9..89678c74a 100644
--- a/packages/taler-wallet-webextension/src/components/styled/index.tsx
+++ b/packages/taler-wallet-webextension/src/components/styled/index.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,18 +14,18 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-
// need to import linaria types, otherwise compiler will complain
-import type * as Linaria from '@linaria/core';
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+// import type * as Linaria from "@linaria/core";
-import { styled } from '@linaria/react';
+import { styled } from "@linaria/react";
export const PaymentStatus = styled.div<{ color: string }>`
padding: 5px;
border-radius: 5px;
color: white;
- background-color: ${p => p.color};
-`
+ background-color: ${(p: any) => p.color};
+`;
export const WalletAction = styled.div`
display: flex;
@@ -35,19 +35,28 @@ export const WalletAction = styled.div`
align-items: center;
margin: auto;
- height: 100%;
-
+
& h1:first-child {
- margin-top: 0;
+ margin-top: 0;
+ }
+ & > * {
+ width: 600px;
}
section {
margin-bottom: 2em;
- & button {
+ table td {
+ padding: 5px 5px;
+ }
+ table tr {
+ border-bottom: 1px solid black;
+ border-top: 1px solid black;
+ }
+ button {
margin-right: 8px;
margin-left: 8px;
}
}
-`
+`;
export const WalletActionOld = styled.section`
border: solid 5px black;
border-radius: 10px;
@@ -59,39 +68,53 @@ export const WalletActionOld = styled.section`
margin: auto;
height: 100%;
-
+
& h1:first-child {
- margin-top: 0;
+ margin-top: 0;
}
-`
+`;
+
+export const Title = styled.h1`
+ font-size: 2em;
+ margin-top: 1em;
+ margin-bottom: 1em;
+`;
+export const SubTitle = styled.h1`
+ font-size: 1.5em;
+ margin-top: 1em;
+ margin-bottom: 1em;
+`;
export const DateSeparator = styled.div`
color: gray;
- margin: .2em;
+ margin: 0.2em;
margin-top: 1em;
-`
+`;
export const WalletBox = styled.div<{ noPadding?: boolean }>`
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
& > * {
- width: 400px;
+ width: 800px;
}
& > section {
- padding-left: ${({ noPadding }) => noPadding ? '0px' : '8px'};
- padding-right: ${({ noPadding }) => noPadding ? '0px' : '8px'};
- // this margin will send the section up when used with a header
- margin-bottom: auto;
+ padding: ${({ noPadding }: any) => (noPadding ? "0px" : "8px")};
+
+ margin-bottom: auto;
overflow: auto;
table td {
- padding: 5px 10px;
+ padding: 5px 5px;
}
table tr {
border-bottom: 1px solid black;
border-top: 1px solid black;
}
+ button {
+ margin-right: 8px;
+ margin-left: 8px;
+ }
}
& > header {
@@ -122,31 +145,31 @@ export const WalletBox = styled.div<{ noPadding?: boolean }>`
flex-direction: row;
justify-content: space-between;
display: flex;
- background-color: #f7f7f7;
- & button {
+ button {
margin-right: 8px;
margin-left: 8px;
}
}
-`
+`;
export const Middle = styled.div`
- justify-content: space-around;
- display: flex;
- flex-direction: column;
- height: 100%;
-`
+ justify-content: space-around;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+`;
export const PopupBox = styled.div<{ noPadding?: boolean }>`
height: 290px;
- width: 400px;
+ width: 500px;
+ overflow-y: visible;
display: flex;
flex-direction: column;
justify-content: space-between;
& > section {
- padding: ${({ noPadding }) => noPadding ? '0px' : '8px'};
+ padding: ${({ noPadding }: any) => (noPadding ? "0px" : "8px")};
// this margin will send the section up when used with a header
- margin-bottom: auto;
+ margin-bottom: auto;
overflow-y: auto;
table td {
@@ -156,6 +179,10 @@ export const PopupBox = styled.div<{ noPadding?: boolean }>`
border-bottom: 1px solid black;
border-top: 1px solid black;
}
+ button {
+ margin-right: 8px;
+ margin-left: 8px;
+ }
}
& > section[data-expanded] {
@@ -196,36 +223,207 @@ export const PopupBox = styled.div<{ noPadding?: boolean }>`
flex-direction: row;
justify-content: space-between;
display: flex;
- & button {
+ button {
margin-right: 8px;
margin-left: 8px;
}
}
+`;
+
+export const TableWithRoundRows = styled.table`
+ border-collapse: separate;
+ border-spacing: 0px 10px;
+ margin-top: -10px;
+
+ td {
+ border: solid 1px #000;
+ border-style: solid none;
+ padding: 10px;
+ }
+ td:first-child {
+ border-left-style: solid;
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+ }
+ td:last-child {
+ border-right-style: solid;
+ border-bottom-right-radius: 5px;
+ border-top-right-radius: 5px;
+ }
+`;
+
+const Tooltip = styled.div<{ content: string }>`
+ display: block;
+ position: relative;
+
+ ::before {
+ position: absolute;
+ z-index: 1000001;
+ width: 0;
+ height: 0;
+ color: darkgray;
+ pointer-events: none;
+ content: "";
+ border: 6px solid transparent;
+
+ border-bottom-color: darkgray;
+ }
+
+ ::after {
+ position: absolute;
+ z-index: 1000001;
+ padding: 0.5em 0.75em;
+ font: normal normal 11px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+ -webkit-font-smoothing: subpixel-antialiased;
+ color: white;
+ text-align: center;
+ text-decoration: none;
+ text-shadow: none;
+ text-transform: none;
+ letter-spacing: normal;
+ word-wrap: break-word;
+ white-space: pre;
+ pointer-events: none;
+ content: attr(content);
+ background: darkgray;
+ border-radius: 6px;
+ }
+`;
+
+export const TooltipBottom = styled(Tooltip)`
+ ::before {
+ top: auto;
+ right: 50%;
+ bottom: -7px;
+ margin-right: -6px;
+ }
+ ::after {
+ top: 100%;
+ right: -50%;
+ margin-top: 6px;
+ }
+`;
+
+export const TooltipRight = styled(Tooltip)`
+ ::before {
+ top: 0px;
+ left: 16px;
+ transform: rotate(-90deg);
+ }
+ ::after {
+ top: -50%;
+ left: 28px;
+ margin-top: 6px;
+ }
+`;
+
+export const TooltipLeft = styled(Tooltip)`
+ ::before {
+ top: 0px;
+ right: 16px;
+ transform: rotate(90deg);
+ }
+ ::after {
+ top: -50%;
+ right: 28px;
+ margin-top: 6px;
+ }
+`;
+
+export const Overlay = styled.div`
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 2;
+ cursor: pointer;
+`;
+
+export const NotifyUpdateFadeOut = styled.div`
+ border: 2px solid red;
+ transition: all 0.4s ease-out;
+ animation: fadeout 1s forwards;
+ animation-delay: 0.1s;
+ @keyframes fadeout {
+ to {
+ border-color: #f5f5f5;
+ }
+ }
+`;
-`
+export const CenteredDialog = styled.div`
+ position: absolute;
+ text-align: left;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+
+ top: 50%;
+ left: 50%;
+ /* font-size: 50px; */
+ color: black;
+ transform: translate(-50%, -50%);
+ -ms-transform: translate(-50%, -50%);
+ cursor: initial;
+ background-color: white;
+ border-radius: 10px;
+
+ max-height: 70%;
+
+ & > header {
+ border-top-right-radius: 6px;
+ border-top-left-radius: 6px;
+ padding: 10px;
+ background-color: #f5f5f5;
+ border-bottom: 1px solid #dbdbdb;
+ font-weight: bold;
+ }
+ & > section {
+ padding: 10px;
+ flex-grow: 1;
+ flex-shrink: 1;
+ overflow: auto;
+ }
+ & > footer {
+ border-top: 1px solid #dbdbdb;
+ border-bottom-right-radius: 6px;
+ border-bottom-left-radius: 6px;
+ padding: 10px;
+ display: flex;
+ justify-content: space-between;
+ }
+`;
export const Button = styled.button<{ upperCased?: boolean }>`
display: inline-block;
- zoom: 1;
+ /* zoom: 1; */
line-height: normal;
white-space: nowrap;
- vertical-align: middle;
- text-align: center;
+ vertical-align: middle; //check this
+ /* text-align: center; */
cursor: pointer;
user-select: none;
box-sizing: border-box;
- text-transform: ${({ upperCased }) => upperCased ? 'uppercase' : 'none'};
+ text-transform: ${({ upperCased }: any) =>
+ upperCased ? "uppercase" : "none"};
font-family: inherit;
font-size: 100%;
padding: 0.5em 1em;
- color: #444; /* rgba not supported (IE 8) */
+ /* color: #444; rgba not supported (IE 8) */
color: rgba(0, 0, 0, 0.8); /* rgba supported */
border: 1px solid #999; /*IE 6/7/8*/
- border: none rgba(0, 0, 0, 0); /*IE9 + everything else*/
- background-color: '#e6e6e6';
+ /* border: none rgba(0, 0, 0, 0); IE9 + everything else */
+ background-color: "#e6e6e6";
text-decoration: none;
border-radius: 2px;
+ text-transform: uppercase;
:focus {
outline: 0;
@@ -244,11 +442,11 @@ export const Button = styled.button<{ upperCased?: boolean }>`
}
:hover {
- filter: alpha(opacity=90);
+ filter: alpha(opacity=80);
background-image: linear-gradient(
transparent,
- rgba(0, 0, 0, 0.05) 40%,
- rgba(0, 0, 0, 0.1)
+ rgba(0, 0, 0, 0.1) 40%,
+ rgba(0, 0, 0, 0.2)
);
}
`;
@@ -258,12 +456,13 @@ export const Link = styled.a<{ upperCased?: boolean }>`
zoom: 1;
line-height: normal;
white-space: nowrap;
- vertical-align: middle;
+ /* vertical-align: middle; */
text-align: center;
cursor: pointer;
user-select: none;
box-sizing: border-box;
- text-transform: ${({ upperCased }) => upperCased ? 'uppercase' : 'none'};
+ text-transform: ${({ upperCased }: any) =>
+ upperCased ? "uppercase" : "none"};
font-family: inherit;
font-size: 100%;
@@ -304,11 +503,10 @@ export const FontIcon = styled.div`
text-align: center;
font-weight: bold;
/* vertical-align: text-top; */
-`
+`;
export const ButtonBox = styled(Button)`
- padding: .5em;
- width: fit-content;
- height: 2em;
+ padding: 8px;
+ /* font-size: small; */
& > ${FontIcon} {
width: 1em;
@@ -316,95 +514,107 @@ export const ButtonBox = styled(Button)`
display: inline;
line-height: 0px;
}
- background-color: transparent;
+ background-color: #f7f7f7;
border: 1px solid;
border-radius: 4px;
border-color: black;
color: black;
-`
-
+ /* text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); */
+ /* -webkit-border-horizontal-spacing: 0px;
+ -webkit-border-vertical-spacing: 0px; */
+`;
const ButtonVariant = styled(Button)`
color: white;
border-radius: 4px;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
-`
+`;
+
+export const LinkDestructive = styled(Link)`
+ background-color: rgb(202, 60, 60);
+`;
-export const ButtonPrimary = styled(ButtonVariant)`
- background-color: rgb(66, 184, 221);
-`
+export const LinkPrimary = styled(Link)`
+ color: black;
+`;
+
+export const ButtonPrimary = styled(ButtonVariant) <{ small?: boolean }>`
+ font-size: ${({ small }: any) => (small ? "small" : "inherit")};
+ background-color: #0042b2;
+ border-color: #0042b2;
+`;
export const ButtonBoxPrimary = styled(ButtonBox)`
- color: rgb(66, 184, 221);
- border-color: rgb(66, 184, 221);
-`
+ color: #0042b2;
+ border-color: #0042b2;
+`;
export const ButtonSuccess = styled(ButtonVariant)`
background-color: #388e3c;
-`
+`;
export const LinkSuccess = styled(Link)`
color: #388e3c;
-`
+`;
export const ButtonBoxSuccess = styled(ButtonBox)`
color: #388e3c;
border-color: #388e3c;
-`
+`;
export const ButtonWarning = styled(ButtonVariant)`
background-color: rgb(223, 117, 20);
-`
+`;
export const LinkWarning = styled(Link)`
color: rgb(223, 117, 20);
-`
+`;
export const ButtonBoxWarning = styled(ButtonBox)`
color: rgb(223, 117, 20);
border-color: rgb(223, 117, 20);
-`
+`;
export const ButtonDestructive = styled(ButtonVariant)`
background-color: rgb(202, 60, 60);
-`
+`;
export const ButtonBoxDestructive = styled(ButtonBox)`
color: rgb(202, 60, 60);
border-color: rgb(202, 60, 60);
-`
-
+`;
export const BoldLight = styled.div`
-color: gray;
-font-weight: bold;
-`
+ color: gray;
+ font-weight: bold;
+`;
export const Centered = styled.div`
text-align: center;
& > :not(:first-child) {
margin-top: 15px;
}
-`
+`;
+
export const Row = styled.div`
display: flex;
margin: 0.5em 0;
justify-content: space-between;
padding: 0.5em;
-`
+`;
export const Row2 = styled.div`
display: flex;
/* margin: 0.5em 0; */
justify-content: space-between;
padding: 0.5em;
-`
+`;
export const Column = styled.div`
display: flex;
flex-direction: column;
margin: 0em 1em;
justify-content: space-between;
-`
+`;
export const RowBorderGray = styled(Row)`
border: 1px solid gray;
/* border-radius: 0.5em; */
-`
+`;
export const RowLightBorderGray = styled(Row2)`
border: 1px solid lightgray;
@@ -414,7 +624,7 @@ export const RowLightBorderGray = styled(Row2)`
border: 1px solid lightgray;
background-color: red;
}
-`
+`;
export const HistoryRow = styled.a`
text-decoration: none;
@@ -423,7 +633,7 @@ export const HistoryRow = styled.a`
display: flex;
justify-content: space-between;
padding: 0.5em;
-
+
border: 1px solid lightgray;
border-top: 0px;
@@ -439,7 +649,7 @@ export const HistoryRow = styled.a`
margin-left: auto;
align-self: center;
}
-`
+`;
export const ListOfProducts = styled.div`
& > div > a > img {
@@ -453,83 +663,113 @@ export const ListOfProducts = styled.div`
margin-right: auto;
margin-left: 1em;
}
-`
+`;
export const LightText = styled.div`
color: gray;
-`
+`;
+
+export const SuccessText = styled.div`
+ color: #388e3c;
+`;
+
+export const DestructiveText = styled.div`
+ color: rgb(202, 60, 60);
+`;
export const WarningText = styled.div`
color: rgb(223, 117, 20);
-`
+`;
export const SmallText = styled.div`
- font-size: small;
-`
+ font-size: small;
+`;
+
+export const SmallBoldText = styled.div`
+ font-size: small;
+ font-weight: bold;
+`;
+
export const LargeText = styled.div`
- font-size: large;
-`
+ font-size: large;
+`;
export const ExtraLargeText = styled.div`
- font-size: x-large;
-`
+ font-size: x-large;
+`;
export const SmallLightText = styled(SmallText)`
color: gray;
-`
+`;
export const CenteredText = styled.div`
white-space: nowrap;
text-align: center;
-`
+`;
export const CenteredBoldText = styled(CenteredText)`
white-space: nowrap;
text-align: center;
font-weight: bold;
color: ${((props: any): any => String(props.color) as any) as any};
-`
+`;
export const Input = styled.div<{ invalid?: boolean }>`
& label {
display: block;
padding: 5px;
- color: ${({ invalid }) => !invalid ? 'inherit' : 'red'}
+ color: ${({ invalid }: any) => (!invalid ? "inherit" : "red")};
}
& input {
display: block;
padding: 5px;
width: calc(100% - 4px - 10px);
- border-color: ${({ invalid }) => !invalid ? 'inherit' : 'red'}
+ border-color: ${({ invalid }: any) => (!invalid ? "inherit" : "red")};
}
-`
+`;
export const InputWithLabel = styled.div<{ invalid?: boolean }>`
+ /* display: flex; */
+
& label {
display: block;
+ font-weight: bold;
+ margin-left: 0.5em;
padding: 5px;
- color: ${({ invalid }) => !invalid ? 'inherit' : 'red'}
+ color: ${({ invalid }: any) => (!invalid ? "inherit" : "red")};
}
- & > div {
- position: relative;
- display: flex;
- top: 0px;
- bottom: 0px;
-
- & > div {
- position: absolute;
- background-color: lightgray;
- padding: 5px;
- margin: 2px;
- }
- & > input {
- flex: 1;
- padding: 5px;
- border-color: ${({ invalid }) => !invalid ? 'inherit' : 'red'}
- }
+ & div {
+ line-height: 24px;
+ display: flex;
+ }
+ & div > span {
+ background-color: lightgray;
+ box-sizing: border-box;
+ border-bottom-left-radius: 0.25em;
+ border-top-left-radius: 0.25em;
+ height: 2em;
+ display: inline-block;
+ padding-left: 0.5em;
+ padding-right: 0.5em;
+ align-items: center;
+ display: flex;
+ }
+ & input {
+ border-width: 1px;
+ box-sizing: border-box;
+ height: 2em;
+ /* border-color: lightgray; */
+ border-bottom-right-radius: 0.25em;
+ border-top-right-radius: 0.25em;
+ border-color: ${({ invalid }: any) => (!invalid ? "lightgray" : "red")};
}
-`
+ margin-bottom: 16px;
+`;
+
+export const ErrorText = styled.div`
+ color: red;
+`;
export const ErrorBox = styled.div`
border: 2px solid #f5c6cb;
@@ -539,6 +779,7 @@ export const ErrorBox = styled.div`
flex-direction: column;
/* margin: 0.5em; */
padding: 1em;
+ margin: 1em;
/* width: 100%; */
color: #721c24;
background: #f8d7da;
@@ -555,49 +796,105 @@ export const ErrorBox = styled.div`
width: 28px;
}
}
-`
+`;
+
+export const RedBanner = styled.div`
+ width: 80%;
+ padding: 4px;
+ text-align: center;
+ background: red;
+ border: 1px solid #df3a3a;
+ border-radius: 4px;
+ font-weight: bold;
+ margin: 4px;
+`;
+
+export const InfoBox = styled(ErrorBox)`
+ color: black;
+ background-color: #d1e7dd;
+ border-color: #badbcc;
+`;
export const SuccessBox = styled(ErrorBox)`
color: #0f5132;
background-color: #d1e7dd;
border-color: #badbcc;
-`
+`;
export const WarningBox = styled(ErrorBox)`
color: #664d03;
background-color: #fff3cd;
border-color: #ffecb5;
-`
+`;
+
+export const NavigationHeaderHolder = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ background-color: #0042b2;
+`;
-export const PopupNavigation = styled.div<{ devMode?: boolean }>`
- background-color:#0042b2;
+export const NavigationHeader = styled.div`
+ background-color: #0042b2;
height: 35px;
justify-content: space-around;
display: flex;
- & > div {
- width: 400px;
+ & {
+ width: 500px;
}
- & > div > a {
+ & > a,
+ & > div {
color: #f8faf7;
display: inline-block;
- width: calc(400px / ${({ devMode }) => !devMode ? 4 : 5});
+ width: 100%;
text-align: center;
text-decoration: none;
vertical-align: middle;
line-height: 35px;
}
- & > div > a.active {
+ & > a.active {
background-color: #f8faf7;
color: #0042b2;
font-weight: bold;
}
`;
-export const NiceSelect = styled.div`
+interface SvgIconProps {
+ title: string;
+ color: string;
+ onClick?: any;
+ transform?: string;
+}
+export const SvgIcon = styled.div<SvgIconProps>`
+ & > svg {
+ fill: ${({ color }: any) => color};
+ transform: ${({ transform }: any) => (transform ? transform : "")};
+ }
+ /* width: 24px;
+ height: 24px; */
+ margin-left: 8px;
+ margin-right: 8px;
+ display: inline;
+ padding: 4px;
+ cursor: ${({ onClick }: any) => (onClick ? "pointer" : "inherit")};
+`;
+
+export const Icon = styled.div`
+ background-color: gray;
+ width: 24px;
+ height: 24px;
+ margin-left: 8px;
+ margin-right: 8px;
+ padding: 4px;
+`;
+
+const image = `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`;
+export const NiceSelect = styled.div`
& > select {
-webkit-appearance: none;
-moz-appearance: none;
@@ -605,11 +902,18 @@ export const NiceSelect = styled.div`
appearance: none;
outline: 0;
box-shadow: none;
- background-image: none;
+
+ background-image: ${image};
+ background-position: right 4px center;
+ background-repeat: no-repeat;
+ background-size: 32px 32px;
+
background-color: white;
- flex: 1;
- padding: 0.5em 1em;
+ border-radius: 0.25rem;
+ font-size: 1em;
+ padding: 8px 32px 8px 8px;
+ /* 0.5em 3em 0.5em 1em; */
cursor: pointer;
}
@@ -617,29 +921,8 @@ export const NiceSelect = styled.div`
display: flex;
/* width: 10em; */
overflow: hidden;
- border-radius: .25em;
-
- &::after {
- content: '\u25BC';
- position: absolute;
- top: 0;
- right: 0;
- padding: 0.5em 1em;
- cursor: pointer;
- pointer-events: none;
- -webkit-transition: .25s all ease;
- -o-transition: .25s all ease;
- transition: .25s all ease;
- }
-
- &:hover::after {
- /* color: #f39c12; */
- }
-
- &::-ms-expand {
- display: none;
- }
-`
+ border-radius: 0.25em;
+`;
export const Outlined = styled.div`
border: 2px solid #388e3c;
@@ -647,13 +930,12 @@ export const Outlined = styled.div`
width: fit-content;
border-radius: 2px;
color: #388e3c;
-`
+`;
/* { width: "1.5em", height: "1.5em", verticalAlign: "middle" } */
export const CheckboxSuccess = styled.input`
vertical-align: center;
-
-`
+`;
export const TermsSection = styled.a`
border: 1px solid black;
@@ -664,13 +946,13 @@ export const TermsSection = styled.a`
text-decoration: none;
color: inherit;
flex-direction: column;
-
+
display: flex;
&[data-open="true"] {
- display: flex;
+ display: flex;
}
&[data-open="false"] > *:not(:first-child) {
- display: none;
+ display: none;
}
header {
@@ -681,15 +963,15 @@ export const TermsSection = styled.a`
height: auto;
}
- &[data-open="true"] header:after {
- content: '\\2227';
+ &[data-open="true"] header:after {
+ content: "\\2227";
}
- &[data-open="false"] header:after {
- content: '\\2228';
+ &[data-open="false"] header:after {
+ content: "\\2228";
}
`;
-export const TermsOfService = styled.div`
+export const TermsOfServiceStyle = styled.div`
display: flex;
flex-direction: column;
text-align: left;
@@ -712,13 +994,13 @@ export const TermsOfService = styled.div`
padding: 1em;
margin-top: 2px;
margin-bottom: 2px;
-
+
display: flex;
&[data-open="true"] {
- display: flex;
+ display: flex;
}
&[data-open="false"] > *:not(:first-child) {
- display: none;
+ display: none;
}
header {
@@ -729,27 +1011,27 @@ export const TermsOfService = styled.div`
height: auto;
}
- &[data-open="true"] > header:after {
- content: '\\2227';
+ &[data-open="true"] > header:after {
+ content: "\\2227";
}
- &[data-open="false"] > header:after {
- content: '\\2228';
+ &[data-open="false"] > header:after {
+ content: "\\2228";
}
}
-
-`
+`;
export const StyledCheckboxLabel = styled.div`
color: green;
text-transform: uppercase;
/* font-weight: bold; */
text-align: center;
+ cursor: pointer;
span {
-
input {
display: none;
opacity: 0;
width: 1em;
height: 1em;
+ cursor: pointer;
}
div {
display: inline-grid;
@@ -758,7 +1040,7 @@ export const StyledCheckboxLabel = styled.div`
margin-right: 1em;
border-radius: 2px;
border: 2px solid currentColor;
-
+
svg {
transition: transform 0.1s ease-in 25ms;
transform: scale(0);
@@ -768,6 +1050,7 @@ export const StyledCheckboxLabel = styled.div`
label {
padding: 0px;
font-size: small;
+ cursor: pointer;
}
}
@@ -776,12 +1059,25 @@ export const StyledCheckboxLabel = styled.div`
}
input:disabled + div {
color: #959495;
- };
+ }
input:disabled + div + label {
color: #959495;
- };
+ }
input:focus + div + label {
box-shadow: 0 0 0 0.05em #fff, 0 0 0.15em 0.1em currentColor;
}
+`;
-` \ No newline at end of file
+export const ParagraphClickable = styled.p`
+ cursor: pointer;
+ margin: 0px;
+ padding: 8px 16px;
+ :hover {
+ filter: alpha(opacity=80);
+ background-image: linear-gradient(
+ transparent,
+ rgba(0, 0, 0, 0.1) 40%,
+ rgba(0, 0, 0, 0.2)
+ );
+ }
+`;
diff --git a/packages/taler-wallet-webextension/src/context/alert.ts b/packages/taler-wallet-webextension/src/context/alert.ts
new file mode 100644
index 000000000..e30fdd72c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/context/alert.ts
@@ -0,0 +1,277 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext, useState } from "preact/hooks";
+import { HookError } from "../hooks/useAsyncAsHook.js";
+import { SafeHandler, withSafe } from "../mui/handlers.js";
+import { BackgroundError } from "../wxApi.js";
+import {
+ InternationalizationAPI,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { platform } from "../platform/foreground.js";
+
+export type AlertType = "info" | "warning" | "error" | "success";
+
+export interface InfoAlert {
+ message: TranslatedString;
+ description: TranslatedString | VNode;
+ type: "info" | "warning" | "success";
+}
+
+export type Alert = InfoAlert | ErrorAlert;
+
+export interface ErrorAlert {
+ message: TranslatedString;
+ description: TranslatedString | VNode;
+ type: "error";
+ context: object | undefined;
+ cause: any | undefined;
+}
+
+type Type = {
+ alerts: Alert[];
+ pushAlert: (n: Alert) => void;
+ removeAlert: (n: Alert) => void;
+ /**
+ *
+ * @param h
+ * @returns
+ * @deprecated use safely
+ */
+ pushAlertOnError: <T>(h: (p: T) => Promise<void>) => SafeHandler<T>;
+ safely: <T>(name: string, h: (p: T) => Promise<void>) => SafeHandler<T>;
+};
+
+const initial: Type = {
+ alerts: [],
+ pushAlertOnError: () => {
+ throw Error("alert context not initialized");
+ },
+ safely: () => {
+ throw Error("alert context not initialized");
+ },
+ pushAlert: () => {
+ null;
+ },
+ removeAlert: () => {
+ null;
+ },
+};
+
+const Context = createContext<Type>(initial);
+
+type AlertWithDate = Alert & { since: Date };
+
+type Props = Partial<Type> & {
+ children: ComponentChildren;
+};
+
+export const AlertProvider = ({ children }: Props): VNode => {
+ const timeout = 3000;
+
+ const [alerts, setAlerts] = useState<AlertWithDate[]>([]);
+
+ const pushAlert = (n: Alert): void => {
+ const entry = { ...n, since: new Date() };
+ setAlerts((ns) => [...ns, entry]);
+ if (n.type !== "error") {
+ setTimeout(() => {
+ setAlerts((ns) => ns.filter((x) => x.since !== entry.since));
+ }, timeout);
+ }
+ };
+
+ const removeAlert = (alert: Alert): void => {
+ setAlerts((ns: AlertWithDate[]) => ns.filter((n) => n !== alert));
+ };
+
+ const { i18n } = useTranslationContext();
+
+ function pushAlertOnError<T>(
+ handler: (p: T) => Promise<void>,
+ ): SafeHandler<T> {
+ return withSafe(handler, (e) => {
+ const a = alertFromError(i18n, e.message as TranslatedString, e);
+ pushAlert(a);
+ });
+ }
+
+ function safely<T>(
+ name: string,
+ handler: (p: T) => Promise<void>,
+ ): SafeHandler<T> {
+ const message = i18n.str`Error was thrown trying to: "${name}"`;
+ return withSafe(handler, (e) => {
+ const a = alertFromError(i18n, message, e);
+ pushAlert(a);
+ });
+ }
+
+ return h(Context.Provider, {
+ value: { alerts, pushAlert, removeAlert, pushAlertOnError, safely },
+ children,
+ });
+};
+
+export const useAlertContext = (): Type => useContext(Context);
+
+export function alertFromError(
+ i18n: InternationalizationAPI,
+ message: TranslatedString,
+ error: HookError,
+ ...context: any[]
+): ErrorAlert;
+
+export function alertFromError(
+ i18n: InternationalizationAPI,
+ message: TranslatedString,
+ error: Error,
+ ...context: any[]
+): ErrorAlert;
+
+export function alertFromError(
+ i18n: InternationalizationAPI,
+ message: TranslatedString,
+ error: TalerErrorDetail,
+ ...context: any[]
+): ErrorAlert;
+
+export function alertFromError(
+ i18n: InternationalizationAPI,
+ message: TranslatedString,
+ error: HookError | TalerErrorDetail | Error,
+ ...context: any[]
+): ErrorAlert {
+ let description: TranslatedString;
+ let cause: any;
+
+ if (typeof error === "object" && error !== null) {
+ if ("code" in error) {
+ //TalerErrorDetail
+ description = (error.hint ??
+ `Error code: ${error.code}`) as TranslatedString;
+ cause = {
+ details: error,
+ };
+ } else if ("hasError" in error) {
+ //HookError
+ description = error.message as TranslatedString;
+ if (error.type === "taler") {
+ const msg = isWalletNotAvailable(i18n, error.details);
+ if (msg) {
+ description = msg;
+ } else {
+ const msg2 = isHttpError(i18n, error.details);
+ if (msg2) {
+ description = msg2;
+ }
+ }
+ cause = {
+ details: error.details,
+ };
+ }
+ } else {
+ if (error instanceof BackgroundError) {
+ const msg = isWalletNotAvailable(i18n, error.errorDetail);
+ if (msg) {
+ description = msg;
+ } else {
+ const msg2 = isHttpError(i18n, error.errorDetail);
+ if (msg2) {
+ description = msg2;
+ } else {
+ description = (error.errorDetail.hint ??
+ `Error code: ${error.errorDetail.code}`) as TranslatedString;
+ }
+ }
+ cause = {
+ details: error.errorDetail,
+ stack: error.stack,
+ };
+ } else {
+ description = error.message as TranslatedString;
+ cause = {
+ stack: error.stack,
+ };
+ }
+ }
+ } else {
+ description = "" as TranslatedString;
+ cause = error;
+ }
+
+ return {
+ type: "error",
+ message,
+ description,
+ cause,
+ context,
+ };
+}
+
+function isWalletNotAvailable(
+ i18n: InternationalizationAPI,
+ detail: TalerErrorDetail,
+): TranslatedString | undefined {
+ if (
+ detail.code === TalerErrorCode.WALLET_CORE_NOT_AVAILABLE &&
+ detail.lastError
+ ) {
+ const le = detail.lastError as TalerErrorDetail;
+ if (le.code === TalerErrorCode.WALLET_DB_UNAVAILABLE) {
+ if (platform.isFirefox() && platform.runningOnPrivateMode()) {
+ return i18n.str`Could not open the wallet database. Firefox is known to run into this problem under "permanent private mode".`;
+ } else {
+ return i18n.str`Could not open the wallet database.`;
+ }
+ } else {
+ return (detail.hint ?? `Error code: ${detail.code}`) as TranslatedString;
+ }
+ }
+ return undefined;
+}
+
+function isHttpError(
+ i18n: InternationalizationAPI,
+ detail: TalerErrorDetail,
+): TranslatedString | undefined {
+ if (
+ detail.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR &&
+ detail.errorResponse
+ ) {
+ const er = detail.errorResponse as TalerErrorDetail;
+ return (
+ (er.hint as TranslatedString) ??
+ detail.hint ??
+ i18n.str`Unexpected request error, code: ${er.code}`
+ );
+ }
+ return undefined;
+}
+//
diff --git a/packages/taler-wallet-webextension/src/context/backend.ts b/packages/taler-wallet-webextension/src/context/backend.ts
new file mode 100644
index 000000000..280fb266d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/context/backend.ts
@@ -0,0 +1,52 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+import { wxApi, WxApiType } from "../wxApi.js";
+
+type Type = WxApiType;
+
+const initial = wxApi;
+
+const Context = createContext<Type>(initial);
+
+type Props = Partial<Type> & {
+ children: ComponentChildren;
+};
+
+export const BackendProvider = ({
+ wallet,
+ background,
+ listener,
+ children,
+}: Props): VNode => {
+ return h(Context.Provider, {
+ value: {
+ wallet: wallet ?? initial.wallet,
+ background: background ?? initial.background,
+ listener: listener ?? initial.listener,
+ },
+ children,
+ });
+};
+
+export const useBackendContext = (): Type => useContext(Context);
diff --git a/packages/taler-wallet-webextension/src/context/devContext.ts b/packages/taler-wallet-webextension/src/context/devContext.ts
deleted file mode 100644
index ea2ba4ceb..000000000
--- a/packages/taler-wallet-webextension/src/context/devContext.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createContext, h, VNode } from 'preact'
-import { useContext, useState } from 'preact/hooks'
-import { useLocalStorage } from '../hooks/useLocalStorage';
-
-interface Type {
- devMode: boolean;
- toggleDevMode: () => void;
-}
-const Context = createContext<Type>({
- devMode: false,
- toggleDevMode: () => null
-})
-
-export const useDevContext = (): Type => useContext(Context);
-
-export const DevContextProvider = ({ children }: { children: any }): VNode => {
- const [value, setter] = useLocalStorage('devMode')
- const devMode = value === "true"
- const toggleDevMode = () => setter(v => !v ? "true" : undefined)
- return h(Context.Provider, { value: { devMode, toggleDevMode }, children });
-}
diff --git a/packages/taler-wallet-webextension/src/context/iocContext.ts b/packages/taler-wallet-webextension/src/context/iocContext.ts
new file mode 100644
index 000000000..89f984f2f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/context/iocContext.ts
@@ -0,0 +1,67 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+import { platform } from "../platform/foreground.js";
+
+interface Type {
+ findTalerUriInActiveTab: () => Promise<string | undefined>;
+ findTalerUriInClipboard: () => Promise<string | undefined>;
+}
+const Context = createContext<Type>({
+ findTalerUriInActiveTab: async () => undefined,
+ findTalerUriInClipboard: async () => undefined,
+});
+
+/**
+ * Inversion of control Context
+ *
+ * This context act as a proxy between API that need to be replaced in
+ * different environments
+ *
+ * @returns
+ */
+export const useIocContext = (): Type => useContext(Context);
+
+export const IoCProviderForTesting = ({
+ value,
+ children,
+}: {
+ value: Type;
+ children: any;
+}): VNode => {
+ return h(Context.Provider, { value, children });
+};
+
+export const IoCProviderForRuntime = ({
+ children,
+}: {
+ children: any;
+}): VNode => {
+ return h(Context.Provider, {
+ value: {
+ findTalerUriInActiveTab: platform.findTalerUriInActiveTab,
+ findTalerUriInClipboard: platform.findTalerUriInClipboard,
+ },
+ children,
+ });
+};
diff --git a/packages/taler-wallet-webextension/src/context/translation.ts b/packages/taler-wallet-webextension/src/context/translation.ts
deleted file mode 100644
index 5f57958de..000000000
--- a/packages/taler-wallet-webextension/src/context/translation.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createContext, h, VNode } from 'preact'
-import { useContext, useEffect } from 'preact/hooks'
-import { useLang } from '../hooks/useLang'
-//@ts-ignore: type declaration
-import * as jedLib from "jed";
-import { strings } from "../i18n/strings";
-import { setupI18n } from '@gnu-taler/taler-util';
-
-interface Type {
- lang: string;
- changeLanguage: (l: string) => void;
-}
-const initial = {
- lang: 'en',
- changeLanguage: () => {
- // do not change anything
- }
-}
-const Context = createContext<Type>(initial)
-
-interface Props {
- initial?: string,
- children: any,
- forceLang?: string
-}
-
-//we use forceLang when we don't want to use the saved state, but sone forced
-//runtime lang predefined lang
-export const TranslationProvider = ({ initial, children, forceLang }: Props): VNode => {
- const [lang, changeLanguage] = useLang(initial)
- useEffect(() => {
- if (forceLang) {
- changeLanguage(forceLang)
- }
- })
- useEffect(()=> {
- setupI18n(lang, strings)
- },[lang])
- if (forceLang) {
- setupI18n(forceLang, strings)
- } else {
- setupI18n(lang, strings)
- }
- return h(Context.Provider, { value: { lang, changeLanguage }, children });
-}
-
-export const useTranslationContext = (): Type => useContext(Context);
diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/index.ts b/packages/taler-wallet-webextension/src/cta/Deposit/index.ts
new file mode 100644
index 000000000..6b228188b
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Deposit/index.ts
@@ -0,0 +1,69 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountJson, AmountString } from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { ButtonHandler } from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+
+export interface Props {
+ talerDepositUri: string | undefined;
+ amountStr: AmountString | undefined;
+ cancel: () => Promise<void>;
+ onSuccess: (tx: string) => Promise<void>;
+}
+
+export type State = State.Loading | State.LoadingUriError | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+ export interface Ready {
+ status: "ready";
+ error: undefined;
+ fee: AmountJson;
+ cost: AmountJson;
+ effective: AmountJson;
+ confirm: ButtonHandler;
+ cancel: () => Promise<void>;
+ }
+ export interface Completed {
+ status: "completed";
+ error: undefined;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ ready: ReadyView,
+};
+
+export const DepositPage = compose(
+ "Deposit",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/state.ts b/packages/taler-wallet-webextension/src/cta/Deposit/state.ts
new file mode 100644
index 000000000..efcef8c28
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Deposit/state.ts
@@ -0,0 +1,79 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ talerDepositUri,
+ amountStr,
+ cancel,
+ onSuccess,
+}: Props): State {
+ const api = useBackendContext();
+ const { pushAlertOnError } = useAlertContext();
+ const info = useAsyncAsHook(async () => {
+ if (!talerDepositUri) throw Error("ERROR_NO-URI-FOR-DEPOSIT");
+ if (!amountStr) throw Error("ERROR_NO-AMOUNT-FOR-DEPOSIT");
+ const amount = Amounts.parse(amountStr);
+ if (!amount) throw Error("ERROR_INVALID-AMOUNT-FOR-DEPOSIT");
+ const deposit = await api.wallet.call(WalletApiOperation.PrepareDeposit, {
+ amount: Amounts.stringify(amount),
+ depositPaytoUri: talerDepositUri,
+ });
+ return { deposit, uri: talerDepositUri, amount };
+ });
+ const { i18n } = useTranslationContext();
+
+ if (!info) return { status: "loading", error: undefined };
+ if (info.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the status of deposit`,
+ info,
+ ),
+ };
+ }
+
+ const { deposit, uri, amount } = info.response;
+ async function doDeposit(): Promise<void> {
+ const resp = await api.wallet.call(WalletApiOperation.CreateDepositGroup, {
+ amount: Amounts.stringify(amount),
+ depositPaytoUri: uri,
+ });
+ onSuccess(resp.transactionId);
+ }
+
+ return {
+ status: "ready",
+ error: undefined,
+ confirm: {
+ onClick: pushAlertOnError(doDeposit),
+ },
+ fee: Amounts.sub(deposit.totalDepositCost, deposit.effectiveDepositAmount)
+ .amount,
+ cost: Amounts.parseOrThrow(deposit.totalDepositCost),
+ effective: Amounts.parseOrThrow(deposit.effectiveDepositAmount),
+ cancel,
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/stories.tsx b/packages/taler-wallet-webextension/src/cta/Deposit/stories.tsx
new file mode 100644
index 000000000..cd65ce8e1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Deposit/stories.tsx
@@ -0,0 +1,37 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "deposit",
+};
+
+export const Ready = tests.createExample(ReadyView, {
+ status: "ready",
+ confirm: {},
+ cost: Amounts.parseOrThrow("EUR:1.2"),
+ effective: Amounts.parseOrThrow("EUR:1"),
+ fee: Amounts.parseOrThrow("EUR:0.2"),
+ error: undefined,
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/test.ts b/packages/taler-wallet-webextension/src/cta/Deposit/test.ts
new file mode 100644
index 000000000..100929918
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Deposit/test.ts
@@ -0,0 +1,118 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountString, Amounts } from "@gnu-taler/taler-util";
+import { expect } from "chai";
+import { createWalletApiMock } from "../../test-utils.js";
+import { useComponentState } from "./state.js";
+import * as tests from "@gnu-taler/web-util/testing";
+import { Props } from "./index.js";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+
+describe("Deposit CTA states", () => {
+ it("should tell the user that the URI is missing", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+
+ const props: Props = {
+ talerDepositUri: undefined,
+ amountStr: undefined,
+ cancel: async () => {
+ null;
+ },
+ onSuccess: async () => {
+ null;
+ },
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equals("loading");
+ },
+ ({ status, error }) => {
+ expect(status).equals("error");
+
+ if (!error) expect.fail();
+ // if (!error.hasError) expect.fail();
+ // if (error.operational) expect.fail();
+ expect(error.description).eq("ERROR_NO-URI-FOR-DEPOSIT");
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should be ready after loading", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.PrepareDeposit,
+ undefined,
+ {
+ effectiveDepositAmount: "EUR:1" as AmountString,
+ totalDepositCost: "EUR:1.2" as AmountString,
+ fees: {
+ coin: "EUR:0" as AmountString,
+ refresh: "EUR:0.2" as AmountString,
+ wire: "EUR:0" as AmountString,
+ },
+ },
+ );
+
+ const props = {
+ talerDepositUri: "payto://refund/asdasdas",
+ amountStr: "EUR:1" as AmountString,
+ cancel: async () => {
+ null;
+ },
+ onSuccess: async () => {
+ null;
+ },
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equals("loading");
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ if (state.error) expect.fail();
+ expect(state.confirm.onClick).not.undefined;
+ expect(state.cost).deep.eq(Amounts.parseOrThrow("EUR:1.2"));
+ expect(state.fee).deep.eq(Amounts.parseOrThrow("EUR:0.2"));
+ expect(state.effective).deep.eq(Amounts.parseOrThrow("EUR:1"));
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/views.tsx b/packages/taler-wallet-webextension/src/cta/Deposit/views.tsx
new file mode 100644
index 000000000..c683a755c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Deposit/views.tsx
@@ -0,0 +1,72 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { Amount } from "../../components/Amount.js";
+import { Part } from "../../components/Part.js";
+import { Button } from "../../mui/Button.js";
+import { State } from "./index.js";
+
+/**
+ *
+ * @author sebasjm
+ */
+
+export function ReadyView(state: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <section>
+ {Amounts.isNonZero(state.cost) && (
+ <Part
+ big
+ title={i18n.str`Cost`}
+ text={<Amount value={state.cost} />}
+ kind="negative"
+ />
+ )}
+ {Amounts.isNonZero(state.fee) && (
+ <Part
+ big
+ title={i18n.str`Fee`}
+ text={<Amount value={state.fee} />}
+ kind="negative"
+ />
+ )}
+ <Part
+ big
+ title={i18n.str`To be received`}
+ text={<Amount value={state.effective} />}
+ kind="positive"
+ />
+ </section>
+ <section>
+ <Button
+ variant="contained"
+ color="success"
+ onClick={state.confirm.onClick}
+ >
+ <i18n.Translate>
+ Send &nbsp; {<Amount value={state.cost} />}
+ </i18n.Translate>
+ </Button>
+ </section>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/cta/DevExperiment/index.ts b/packages/taler-wallet-webextension/src/cta/DevExperiment/index.ts
new file mode 100644
index 000000000..ec09fd9f1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/DevExperiment/index.ts
@@ -0,0 +1,73 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { ButtonHandler } from "../../mui/handlers.js";
+import { StateViewMap, compose } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { InsertLostView, InsertPendingRefreshView, UnknownView } from "./views.js";
+
+export interface Props {
+ talerExperimentUri: string | undefined;
+ onCancel: () => Promise<void>;
+ onSuccess: () => Promise<void>;
+}
+
+export type State = State.Loading | State.LoadingUriError | State.Unknown | State.InsertLost | State.PendingRefresh;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+ export interface InsertLost {
+ status: "insertLost";
+ error: undefined;
+ confirm: ButtonHandler;
+ cancel: () => Promise<void>;
+ }
+ export interface PendingRefresh {
+ status: "pendingRefresh";
+ error: undefined;
+ confirm: ButtonHandler;
+ cancel: () => Promise<void>;
+ }
+ export interface Unknown {
+ status: "unknown";
+ experimentId: string;
+ error: undefined;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ pendingRefresh: InsertPendingRefreshView,
+ insertLost: InsertLostView,
+ unknown: UnknownView,
+};
+
+export const DevExperimentPage = compose(
+ "DevExperiment",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/DevExperiment/state.ts b/packages/taler-wallet-webextension/src/cta/DevExperiment/state.ts
new file mode 100644
index 000000000..774a1129d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/DevExperiment/state.ts
@@ -0,0 +1,83 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { parseDevExperimentUri } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ talerExperimentUri,
+ onCancel,
+ onSuccess,
+}: Props): State {
+ const api = useBackendContext();
+ const { pushAlertOnError } = useAlertContext();
+ const { i18n } = useTranslationContext();
+
+ async function doApply(): Promise<void> {
+ if (!talerExperimentUri) return;
+ await api.wallet.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: talerExperimentUri
+ })
+ // const resp = await api.wallet.call(WalletApiOperation.CreateDepositGroup, {
+ // amount: Amounts.stringify(amount),
+ // depositPaytoUri: uri,
+ // });
+ onSuccess();
+ }
+ const uri = talerExperimentUri === undefined ? undefined : parseDevExperimentUri(talerExperimentUri);
+
+ if (!uri) {
+ return {
+ status: "error",
+ error: {
+ type: "error",
+ message: i18n.str`Invalid dev experiment URI.`,
+ description: i18n.str`URI: ${talerExperimentUri}`,
+ cause: {},
+ context: {},
+ },
+ };
+ }
+ if (uri.devExperimentId === "insert-denom-loss") {
+ return {
+ status: "insertLost",
+ error: undefined,
+ confirm: {
+ onClick: pushAlertOnError(doApply),
+ },
+ cancel: onCancel,
+ };
+ }
+ if (uri.devExperimentId === "insert-pending-refresh") {
+ return {
+ status: "pendingRefresh",
+ error: undefined,
+ confirm: {
+ onClick: pushAlertOnError(doApply),
+ },
+ cancel: onCancel,
+ };
+ }
+ return {
+ status: "unknown",
+ error: undefined,
+ experimentId: uri.devExperimentId,
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/cta/DevExperiment/stories.tsx b/packages/taler-wallet-webextension/src/cta/DevExperiment/stories.tsx
new file mode 100644
index 000000000..c9851495f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/DevExperiment/stories.tsx
@@ -0,0 +1,33 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { InsertLostView } from "./views.js";
+
+export default {
+ title: "dev-experiment",
+};
+
+export const Ready = tests.createExample(InsertLostView, {
+ status: "insertLost",
+ confirm: {},
+ error: undefined,
+});
diff --git a/packages/taler-wallet-webextension/src/cta/DevExperiment/test.ts b/packages/taler-wallet-webextension/src/cta/DevExperiment/test.ts
new file mode 100644
index 000000000..d4f2ca8b1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/DevExperiment/test.ts
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { createWalletApiMock } from "../../test-utils.js";
+import { Props } from "./index.js";
+import { useComponentState } from "./state.js";
+
+describe("DevExperiment CTA states", () => {
+ it("should tell the user that the URI is missing", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+
+ const props: Props = {
+ talerExperimentUri: undefined,
+ onCancel: async () => {
+ null;
+ },
+ onSuccess: async () => {
+ null;
+ },
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equals("error");
+ },
+ ({ status, error }) => {
+ expect(status).equals("error");
+
+ if (!error) expect.fail();
+ // if (!error.hasError) expect.fail();
+ // if (error.operational) expect.fail();
+ // expect(error.description).eq("ERROR_NO-URI-FOR-DEPOSIT");
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+});
diff --git a/packages/taler-wallet-webextension/src/cta/DevExperiment/views.tsx b/packages/taler-wallet-webextension/src/cta/DevExperiment/views.tsx
new file mode 100644
index 000000000..afad17ad1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/DevExperiment/views.tsx
@@ -0,0 +1,74 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { Amount } from "../../components/Amount.js";
+import { Part } from "../../components/Part.js";
+import { Button } from "../../mui/Button.js";
+import { State } from "./index.js";
+
+/**
+ *
+ * @author sebasjm
+ */
+
+export function InsertLostView(state: State.InsertLost): VNode {
+ const { i18n } = useTranslationContext();
+ return <Fragment>
+ <section>
+ <Part
+ title={i18n.str`Experiment`}
+ text={i18n.str`Insert lost denomination`}
+ />
+ </section>
+ <section>
+ <Button
+ variant="contained"
+ color="success"
+ onClick={state.confirm.onClick}
+ >
+ <i18n.Translate>Apply</i18n.Translate>
+ </Button>
+ </section>
+ </Fragment>
+}
+
+export function InsertPendingRefreshView(state: State.PendingRefresh): VNode {
+ const { i18n } = useTranslationContext();
+ return <Fragment>
+ <section>
+ <Part
+ title={i18n.str`Experiment`}
+ text={i18n.str`Pending refresh`}
+ />
+ </section>
+ <section>
+ <Button
+ variant="contained"
+ color="success"
+ onClick={state.confirm.onClick}
+ >
+ <i18n.Translate>Apply</i18n.Translate>
+ </Button>
+ </section>
+ </Fragment>
+}
+
+export function UnknownView(state: State.Unknown): VNode {
+ return <div>unknown experiment "{state.experimentId}"</div>
+}
diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts
new file mode 100644
index 000000000..fd3fb52f8
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts
@@ -0,0 +1,82 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountJson, AmountString } from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js";
+import { ButtonHandler, TextFieldHandler } from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { ExchangeSelectionPage } from "../../wallet/ExchangeSelection/index.js";
+import { NoExchangesView } from "../../wallet/ExchangeSelection/views.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+
+export interface Props {
+ amount: AmountString;
+ onClose: () => Promise<void>;
+ onSuccess: (tx: string) => Promise<void>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.Ready
+ | SelectExchangeState.Selecting
+ | SelectExchangeState.NoExchangeFound;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ cancel: ButtonHandler;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ doSelectExchange: ButtonHandler;
+ create: ButtonHandler;
+ subject: TextFieldHandler;
+ expiration: TextFieldHandler;
+ toBeReceived: AmountJson;
+ requestAmount: AmountJson;
+ exchangeUrl: string;
+ error: undefined;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ "no-exchange-found": NoExchangesView,
+ "selecting-exchange": ExchangeSelectionPage,
+ ready: ReadyView,
+};
+
+export const InvoiceCreatePage = compose(
+ "InvoiceCreatePage",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts
new file mode 100644
index 000000000..daa3ee76d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts
@@ -0,0 +1,196 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/* eslint-disable react-hooks/rules-of-hooks */
+import { Amounts, TalerProtocolTimestamp } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { isFuture, parse } from "date-fns";
+import { useState } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { useSelectedExchange } from "../../hooks/useSelectedExchange.js";
+import { RecursiveState } from "../../utils/index.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ amount: amountStr,
+ onClose,
+ onSuccess,
+}: Props): RecursiveState<State> {
+ const amount = Amounts.parseOrThrow(amountStr);
+ const api = useBackendContext();
+
+ const hook = useAsyncAsHook(() =>
+ api.wallet.call(WalletApiOperation.ListExchanges, {}),
+ );
+ const { i18n } = useTranslationContext();
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the list of exchanges`,
+ hook,
+ ),
+ };
+ }
+ // if (hook.hasError) {
+ // return {
+ // status: "loading-uri",
+ // error: hook,
+ // };
+ // }
+
+ const exchangeList = hook.response.exchanges;
+
+ return () => {
+ const [subject, setSubject] = useState<string | undefined>();
+ const [timestamp, setTimestamp] = useState<string | undefined>();
+ const { pushAlertOnError } = useAlertContext();
+
+ const selectedExchange = useSelectedExchange({
+ currency: amount.currency,
+ defaultExchange: undefined,
+ list: exchangeList,
+ });
+
+ if (selectedExchange.status !== "ready") {
+ return selectedExchange;
+ }
+
+ const exchange = selectedExchange.selected;
+
+ const hook = useAsyncAsHook(async () => {
+ const resp = await api.wallet.call(
+ WalletApiOperation.CheckPeerPullCredit,
+ {
+ amount: amountStr,
+ exchangeBaseUrl: exchange.exchangeBaseUrl,
+ },
+ );
+ return resp;
+ });
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the invoice status`,
+ hook,
+ ),
+ };
+ // return {
+ // status: "loading-uri",
+ // error: hook,
+ // };
+ }
+
+ const { amountEffective, amountRaw } = hook.response;
+ const requestAmount = Amounts.parseOrThrow(amountRaw);
+ const toBeReceived = Amounts.parseOrThrow(amountEffective);
+
+ let purse_expiration: TalerProtocolTimestamp | undefined = undefined;
+ let timestampError: string | undefined = undefined;
+
+ const t =
+ timestamp === undefined
+ ? undefined
+ : parse(timestamp, "dd/MM/yyyy", new Date());
+
+ if (t !== undefined) {
+ if (Number.isNaN(t.getTime())) {
+ timestampError = 'Should have the format "dd/MM/yyyy"';
+ } else {
+ if (!isFuture(t)) {
+ timestampError = "Should be in the future";
+ } else {
+ purse_expiration = {
+ t_s: t.getTime() / 1000,
+ };
+ }
+ }
+ }
+
+ async function accept(): Promise<void> {
+ if (!subject || !purse_expiration) return;
+
+ const resp = await api.wallet.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.exchangeBaseUrl,
+ partialContractTerms: {
+ amount: Amounts.stringify(amount),
+ summary: subject,
+ purse_expiration,
+ },
+ },
+ );
+
+ onSuccess(resp.transactionId);
+ }
+ const unableToCreate =
+ !subject || Amounts.isZero(amount) || !purse_expiration;
+
+ return {
+ status: "ready",
+ subject: {
+ error:
+ subject === undefined
+ ? undefined
+ : !subject
+ ? "Can't be empty"
+ : undefined,
+ value: subject ?? "",
+ onInput: pushAlertOnError(async (e) => setSubject(e)),
+ },
+ expiration: {
+ error: timestampError,
+ value: timestamp === undefined ? "" : timestamp,
+ onInput: pushAlertOnError(async (e) => {
+ setTimestamp(e);
+ }),
+ },
+ doSelectExchange: selectedExchange.doSelect,
+ exchangeUrl: exchange.exchangeBaseUrl,
+ create: {
+ onClick: unableToCreate ? undefined : pushAlertOnError(accept),
+ },
+ cancel: {
+ onClick: pushAlertOnError(onClose),
+ },
+ requestAmount,
+ toBeReceived,
+ error: undefined,
+ };
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/stories.tsx b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/stories.tsx
new file mode 100644
index 000000000..779f130aa
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/stories.tsx
@@ -0,0 +1,52 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { nullFunction } from "../../mui/handlers.js";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "invoice create",
+};
+
+export const Ready = tests.createExample(ReadyView, {
+ requestAmount: {
+ currency: "ARS",
+ value: 1,
+ fraction: 0,
+ },
+ expiration: {
+ value: "2/12/12",
+ },
+ cancel: {},
+ toBeReceived: {
+ currency: "ARS",
+ value: 1,
+ fraction: 0,
+ },
+ doSelectExchange: {},
+ exchangeUrl: "https://exchange.taler.ar",
+ subject: {
+ value: "some subject",
+ onInput: nullFunction,
+ },
+ create: {},
+});
diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/test.ts b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/test.ts
new file mode 100644
index 000000000..3ebedfd5a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/test.ts
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+
+describe("Invoice create state", () => {
+ it.skip("should create some tests", () => {
+ expect([]).deep.equals([]);
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx
new file mode 100644
index 000000000..e2c37fbba
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx
@@ -0,0 +1,155 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, h, VNode } from "preact";
+import { Part } from "../../components/Part.js";
+import { TermsOfService } from "../../components/TermsOfService/index.js";
+import { Button } from "../../mui/Button.js";
+import { TextField } from "../../mui/TextField.js";
+import {
+ ExchangeDetails,
+ getAmountWithFee,
+ InvoiceCreationDetails,
+} from "../../wallet/Transaction.js";
+import { State } from "./index.js";
+
+export function ReadyView({
+ exchangeUrl,
+ subject,
+ expiration,
+ create,
+ toBeReceived,
+ requestAmount,
+ doSelectExchange: _doSelectExchange,
+}: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+
+ async function oneDayExpiration(): Promise<void> {
+ if (expiration.onInput) {
+ expiration.onInput(
+ format(new Date().getTime() + 1000 * 60 * 60 * 24, "dd/MM/yyyy"),
+ );
+ }
+ }
+
+ async function oneWeekExpiration(): Promise<void> {
+ if (expiration.onInput) {
+ expiration.onInput(
+ format(new Date().getTime() + 1000 * 60 * 60 * 24 * 7, "dd/MM/yyyy"),
+ );
+ }
+ }
+ async function _30DaysExpiration(): Promise<void> {
+ if (expiration.onInput) {
+ expiration.onInput(
+ format(new Date().getTime() + 1000 * 60 * 60 * 24 * 30, "dd/MM/yyyy"),
+ );
+ }
+ }
+ return (
+ <Fragment>
+ <section style={{ textAlign: "left" }}>
+ <Part
+ title={
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ }}
+ >
+ <i18n.Translate>Exchange</i18n.Translate>
+ {/* <Button onClick={doSelectExchange.onClick} variant="text">
+ <SvgIcon
+ title="Edit"
+ dangerouslySetInnerHTML={{ __html: editIcon }}
+ color="black"
+ />
+ </Button> */}
+ </div>
+ }
+ text={<ExchangeDetails exchange={exchangeUrl} />}
+ kind="neutral"
+ big
+ />
+ <p>
+ <TextField
+ label="Subject"
+ variant="filled"
+ error={subject.error}
+ helperText={i18n.str`Short description of the invoice`}
+ required
+ fullWidth
+ value={subject.value}
+ onChange={subject.onInput}
+ />
+ </p>
+
+ <p>
+ <TextField
+ label="Expiration"
+ variant="filled"
+ error={expiration.error}
+ required
+ fullWidth
+ value={expiration.value}
+ onChange={expiration.onInput}
+ />
+ <p>
+ <Button
+ variant="outlined"
+ disabled={!expiration.onInput}
+ onClick={oneDayExpiration}
+ >
+ 1 day
+ </Button>
+ <Button
+ variant="outlined"
+ disabled={!expiration.onInput}
+ onClick={oneWeekExpiration}
+ >
+ 1 week
+ </Button>
+ <Button
+ variant="outlined"
+ disabled={!expiration.onInput}
+ onClick={_30DaysExpiration}
+ >
+ 30 days
+ </Button>
+ </p>
+ </p>
+
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <InvoiceCreationDetails
+ amount={getAmountWithFee(toBeReceived, requestAmount, "credit")}
+ />
+ }
+ />
+ </section>
+ <section>
+ <TermsOfService key="terms" exchangeUrl={exchangeUrl} >
+ <Button onClick={create.onClick} variant="contained" color="success">
+ <i18n.Translate>Create</i18n.Translate>
+ </Button>
+ </TermsOfService>
+ </section>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/index.ts b/packages/taler-wallet-webextension/src/cta/InvoicePay/index.ts
new file mode 100644
index 000000000..f0cd63fbe
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/InvoicePay/index.ts
@@ -0,0 +1,97 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ PreparePayResult,
+ TalerErrorDetail,
+} from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { ButtonHandler } from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+
+export interface Props {
+ talerPayPullUri: string;
+ onClose: () => Promise<void>;
+ goToWalletManualWithdraw: (amount?: string) => Promise<void>;
+ onSuccess: (tx: string) => Promise<void>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.NoEnoughBalance
+ | State.NoBalanceForCurrency
+ | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ uri: string;
+ cancel: ButtonHandler;
+ effective: AmountJson;
+ raw: AmountJson;
+ goToWalletManualWithdraw: (currency: string) => Promise<void>;
+ summary: string | undefined;
+ expiration: AbsoluteTime | undefined;
+ payStatus: PreparePayResult;
+ }
+
+ export interface NoBalanceForCurrency extends BaseInfo {
+ status: "no-balance-for-currency";
+ balance: undefined;
+ }
+ export interface NoEnoughBalance extends BaseInfo {
+ status: "no-enough-balance";
+ balance: AmountJson;
+ }
+
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ balance: AmountJson;
+ accept: ButtonHandler;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ "no-balance-for-currency": ReadyView,
+ "no-enough-balance": ReadyView,
+ ready: ReadyView,
+};
+
+export const InvoicePayPage = compose(
+ "InvoicePayPage",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts b/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts
new file mode 100644
index 000000000..99de03d2d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts
@@ -0,0 +1,171 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ NotificationType,
+ PreparePayResult,
+ PreparePayResultType,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useEffect } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ talerPayPullUri,
+ onClose,
+ goToWalletManualWithdraw,
+ onSuccess,
+}: Props): State {
+ const api = useBackendContext();
+ const { i18n } = useTranslationContext();
+ const { pushAlertOnError } = useAlertContext();
+ const hook = useAsyncAsHook(async () => {
+ const p2p = await api.wallet.call(WalletApiOperation.PreparePeerPullDebit, {
+ talerUri: talerPayPullUri,
+ });
+ const balance = await api.wallet.call(WalletApiOperation.GetBalances, {});
+ return { p2p, balance };
+ });
+
+ useEffect(() =>
+ api.listener.onUpdateNotification(
+ [NotificationType.TransactionStateTransition],
+ hook?.retry,
+ ),
+ );
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the transfer payment status`,
+ hook,
+ ),
+ };
+ }
+ // if (hook.hasError) {
+ // return {
+ // status: "loading-uri",
+ // error: hook,
+ // };
+ // }
+
+ const { contractTerms, transactionId, amountEffective, amountRaw } =
+ hook.response.p2p;
+
+ const amountStr: string = contractTerms.amount;
+ const amount = Amounts.parseOrThrow(amountStr);
+ const effective = Amounts.parseOrThrow(amountEffective);
+ const raw = Amounts.parseOrThrow(amountRaw);
+ const summary: string | undefined = contractTerms.summary;
+ const expiration: TalerProtocolTimestamp | undefined =
+ contractTerms.purse_expiration;
+
+ const foundBalance = hook.response.balance.balances.find(
+ (b) => Amounts.parseOrThrow(b.available).currency === amount.currency,
+ );
+
+ const paymentPossible: PreparePayResult = {
+ status: PreparePayResultType.PaymentPossible,
+ proposalId: "fakeID",
+ contractTerms: {} as any,
+ contractTermsHash: "asd",
+ amountRaw: hook.response.p2p.amount,
+ amountEffective: hook.response.p2p.amount,
+ } as PreparePayResult;
+
+ const insufficientBalance: PreparePayResult = {
+ status: PreparePayResultType.InsufficientBalance,
+ talerUri: "taler://pay",
+ proposalId: "fakeID",
+ contractTerms: {} as any,
+ amountRaw: hook.response.p2p.amount,
+ noncePriv: "",
+ } as any; //FIXME: check this interface with new values
+
+ const baseResult = {
+ uri: talerPayPullUri,
+ cancel: {
+ onClick: pushAlertOnError(onClose),
+ },
+ effective,
+ raw,
+ goToWalletManualWithdraw,
+ summary,
+ expiration: expiration
+ ? AbsoluteTime.fromProtocolTimestamp(expiration)
+ : undefined,
+ };
+
+ if (!foundBalance) {
+ return {
+ status: "no-balance-for-currency",
+ error: undefined,
+ balance: undefined,
+ ...baseResult,
+ payStatus: insufficientBalance,
+ };
+ }
+
+ const foundAmount = Amounts.parseOrThrow(foundBalance.available);
+
+ //FIXME: should use pay result type since it check for coins exceptions
+ if (Amounts.cmp(foundAmount, amount) < 0) {
+ //payStatus.status === PreparePayResultType.InsufficientBalance) {
+ return {
+ status: "no-enough-balance",
+ error: undefined,
+ balance: foundAmount,
+ ...baseResult,
+ payStatus: insufficientBalance,
+ };
+ }
+
+ async function accept(): Promise<void> {
+ const resp = await api.wallet.call(
+ WalletApiOperation.ConfirmPeerPullDebit,
+ {
+ transactionId,
+ },
+ );
+ onSuccess(resp.transactionId);
+ }
+
+ return {
+ status: "ready",
+ error: undefined,
+ ...baseResult,
+ payStatus: paymentPossible,
+ balance: foundAmount,
+ accept: {
+ onClick: pushAlertOnError(accept),
+ },
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/stories.tsx b/packages/taler-wallet-webextension/src/cta/InvoicePay/stories.tsx
new file mode 100644
index 000000000..8993476ea
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/InvoicePay/stories.tsx
@@ -0,0 +1,56 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AbsoluteTime,
+ PreparePayResult,
+ PreparePayResultType,
+} from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "invoice payment",
+};
+
+export const Ready = tests.createExample(ReadyView, {
+ effective: {
+ currency: "ARS",
+ value: 1,
+ fraction: 0,
+ },
+ raw: {
+ currency: "ARS",
+ value: 1,
+ fraction: 0,
+ },
+ summary: "some subject",
+ uri: "taler://pay/merchant.ar/123",
+ payStatus: {
+ status: PreparePayResultType.PaymentPossible,
+ amountEffective: "ARS:1",
+ } as PreparePayResult,
+ expiration: AbsoluteTime.fromMilliseconds(
+ new Date().getTime() + 1000 * 60 * 60,
+ ),
+ accept: {},
+ cancel: {},
+});
diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/test.ts b/packages/taler-wallet-webextension/src/cta/InvoicePay/test.ts
new file mode 100644
index 000000000..4a3d08ed0
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/InvoicePay/test.ts
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+
+describe("Invoice payment state", () => {
+ it.skip("should create some states", () => {
+ expect([]).deep.equals([]);
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx b/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx
new file mode 100644
index 000000000..547d5ac9a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { Part } from "../../components/Part.js";
+import { PaymentButtons } from "../../components/PaymentButtons.js";
+import { Time } from "../../components/Time.js";
+import {
+ getAmountWithFee,
+ InvoicePaymentDetails,
+} from "../../wallet/Transaction.js";
+import { State } from "./index.js";
+
+export function ReadyView(
+ state: State.Ready | State.NoBalanceForCurrency | State.NoEnoughBalance,
+): VNode {
+ const { i18n } = useTranslationContext();
+ const { summary, effective, raw, expiration, uri, status, payStatus } = state;
+ return (
+ <Fragment>
+ <section style={{ textAlign: "left" }}>
+ <Part title={i18n.str`Subject`} text={<div>{summary}</div>} />
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <InvoicePaymentDetails
+ amount={getAmountWithFee(effective, raw, "debit")}
+ />
+ }
+ />
+ <Part
+ title={i18n.str`Valid until`}
+ text={<Time timestamp={expiration} format="dd MMMM yyyy, HH:mm" />}
+ kind="neutral"
+ />
+ </section>
+ <PaymentButtons
+ amount={effective}
+ payStatus={payStatus}
+ uri={uri}
+ payHandler={status === "ready" ? state.accept : undefined}
+ goToWalletManualWithdraw={state.goToWalletManualWithdraw}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx b/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx
deleted file mode 100644
index 622e7950f..000000000
--- a/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ContractTerms, PreparePayResultType } from '@gnu-taler/taler-util';
-import { createExample } from '../test-utils';
-import { PaymentRequestView as TestedComponent } from './Pay';
-
-export default {
- title: 'cta/pay',
- component: TestedComponent,
- argTypes: {
- },
-};
-
-export const NoBalance = createExample(TestedComponent, {
- payStatus: {
- status: PreparePayResultType.InsufficientBalance,
- noncePriv: '',
- proposalId: "proposal1234",
- contractTerms: {
- merchant: {
- name: 'someone'
- },
- summary: 'some beers',
- amount: 'USD:10',
- } as Partial<ContractTerms> as any,
- amountRaw: 'USD:10',
- }
-});
-
-export const NoEnoughBalance = createExample(TestedComponent, {
- payStatus: {
- status: PreparePayResultType.InsufficientBalance,
- noncePriv: '',
- proposalId: "proposal1234",
- contractTerms: {
- merchant: {
- name: 'someone'
- },
- summary: 'some beers',
- amount: 'USD:10',
- } as Partial<ContractTerms> as any,
- amountRaw: 'USD:10',
- },
- balance: {
- currency: 'USD',
- fraction: 40000000,
- value: 9
- }
-});
-
-export const PaymentPossible = createExample(TestedComponent, {
- uri: 'taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0',
- payStatus: {
- status: PreparePayResultType.PaymentPossible,
- amountEffective: 'USD:10',
- amountRaw: 'USD:10',
- noncePriv: '',
- contractTerms: {
- nonce: '123213123',
- merchant: {
- name: 'someone'
- },
- amount: 'USD:10',
- summary: 'some beers',
- } as Partial<ContractTerms> as any,
- contractTermsHash: '123456',
- proposalId: 'proposal1234'
- }
-});
-
-export const PaymentPossibleWithFee = createExample(TestedComponent, {
- uri: 'taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0',
- payStatus: {
- status: PreparePayResultType.PaymentPossible,
- amountEffective: 'USD:10.20',
- amountRaw: 'USD:10',
- noncePriv: '',
- contractTerms: {
- nonce: '123213123',
- merchant: {
- name: 'someone'
- },
- amount: 'USD:10',
- summary: 'some beers',
- } as Partial<ContractTerms> as any,
- contractTermsHash: '123456',
- proposalId: 'proposal1234'
- }
-});
-
-export const AlreadyConfirmedWithFullfilment = createExample(TestedComponent, {
- payStatus: {
- status: PreparePayResultType.AlreadyConfirmed,
- amountEffective: 'USD:10',
- amountRaw: 'USD:10',
- contractTerms: {
- merchant: {
- name: 'someone'
- },
- fulfillment_message: 'congratulations! you are looking at the fulfillment message! ',
- summary: 'some beers',
- amount: 'USD:10',
- } as Partial<ContractTerms> as any,
- contractTermsHash: '123456',
- proposalId: 'proposal1234',
- paid: false,
- }
-});
-
-export const AlreadyConfirmedWithoutFullfilment = createExample(TestedComponent, {
- payStatus: {
- status: PreparePayResultType.AlreadyConfirmed,
- amountEffective: 'USD:10',
- amountRaw: 'USD:10',
- contractTerms: {
- merchant: {
- name: 'someone'
- },
- summary: 'some beers',
- amount: 'USD:10',
- } as Partial<ContractTerms> as any,
- contractTermsHash: '123456',
- proposalId: 'proposal1234',
- paid: false,
- }
-});
-
-export const AlreadyPaid = createExample(TestedComponent, {
- payStatus: {
- status: PreparePayResultType.AlreadyConfirmed,
- amountEffective: 'USD:10',
- amountRaw: 'USD:10',
- contractTerms: {
- merchant: {
- name: 'someone'
- },
- fulfillment_message: 'congratulations! you are looking at the fulfillment message! ',
- summary: 'some beers',
- amount: 'USD:10',
- } as Partial<ContractTerms> as any,
- contractTermsHash: '123456',
- proposalId: 'proposal1234',
- paid: true,
- }
-});
diff --git a/packages/taler-wallet-webextension/src/cta/Pay.tsx b/packages/taler-wallet-webextension/src/cta/Pay.tsx
deleted file mode 100644
index 675b14ff9..000000000
--- a/packages/taler-wallet-webextension/src/cta/Pay.tsx
+++ /dev/null
@@ -1,300 +0,0 @@
-/*
- This file is part of TALER
- (C) 2015 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Page shown to the user to confirm entering
- * a contract.
- */
-
-/**
- * Imports.
- */
-// import * as i18n from "../i18n";
-
-import { AmountJson, AmountLike, Amounts, ConfirmPayResult, ConfirmPayResultDone, ConfirmPayResultType, ContractTerms, getJsonI18n, i18n, PreparePayResult, PreparePayResultType } from "@gnu-taler/taler-util";
-import { Fragment, JSX, VNode } from "preact";
-import { useEffect, useState } from "preact/hooks";
-import { LogoHeader } from "../components/LogoHeader";
-import { Part } from "../components/Part";
-import { QR } from "../components/QR";
-import { ButtonSuccess, ErrorBox, LinkSuccess, SuccessBox, WalletAction, WarningBox } from "../components/styled";
-import { useBalances } from "../hooks/useBalances";
-import * as wxApi from "../wxApi";
-
-interface Props {
- talerPayUri?: string
-}
-
-// export function AlreadyPaid({ payStatus }: { payStatus: PreparePayResult }) {
-// const fulfillmentUrl = payStatus.contractTerms.fulfillment_url;
-// let message;
-// if (fulfillmentUrl) {
-// message = (
-// <span>
-// You have already paid for this article. Click{" "}
-// <a href={fulfillmentUrl} target="_bank" rel="external">here</a> to view it again.
-// </span>
-// );
-// } else {
-// message = <span>
-// You have already paid for this article:{" "}
-// <em>
-// {payStatus.contractTerms.fulfillment_message ?? "no message given"}
-// </em>
-// </span>;
-// }
-// return <section class="main">
-// <h1>GNU Taler Wallet</h1>
-// <article class="fade">
-// {message}
-// </article>
-// </section>
-// }
-
-const doPayment = async (payStatus: PreparePayResult): Promise<ConfirmPayResultDone> => {
- if (payStatus.status !== "payment-possible") {
- throw Error(`invalid state: ${payStatus.status}`);
- }
- const proposalId = payStatus.proposalId;
- const res = await wxApi.confirmPay(proposalId, undefined);
- if (res.type !== ConfirmPayResultType.Done) {
- throw Error("payment pending");
- }
- const fu = res.contractTerms.fulfillment_url;
- if (fu) {
- document.location.href = fu;
- }
- return res;
-};
-
-
-
-export function PayPage({ talerPayUri }: Props): JSX.Element {
- const [payStatus, setPayStatus] = useState<PreparePayResult | undefined>(undefined);
- const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>(undefined);
- const [payErrMsg, setPayErrMsg] = useState<string | undefined>(undefined);
-
- const balance = useBalances()
- const balanceWithoutError = balance?.hasError ? [] : (balance?.response.balances || [])
-
- const foundBalance = balanceWithoutError.find(b => payStatus && Amounts.parseOrThrow(b.available).currency === Amounts.parseOrThrow(payStatus?.amountRaw).currency)
- const foundAmount = foundBalance ? Amounts.parseOrThrow(foundBalance.available) : undefined
-
- useEffect(() => {
- if (!talerPayUri) return;
- const doFetch = async (): Promise<void> => {
- try {
- const p = await wxApi.preparePay(talerPayUri);
- setPayStatus(p);
- } catch (e) {
- if (e instanceof Error) {
- setPayErrMsg(e.message)
- }
- }
- };
- doFetch();
- }, [talerPayUri]);
-
- if (!talerPayUri) {
- return <span>missing pay uri</span>
- }
-
- if (!payStatus) {
- if (payErrMsg) {
- return <WalletAction>
- <LogoHeader />
- <h2>
- {i18n.str`Digital cash payment`}
- </h2>
- <section>
- <p>Could not get the payment information for this order</p>
- <ErrorBox>
- {payErrMsg}
- </ErrorBox>
- </section>
- </WalletAction>
- }
- return <span>Loading payment information ...</span>;
- }
-
- const onClick = async () => {
- try {
- const res = await doPayment(payStatus)
- setPayResult(res);
- } catch (e) {
- console.error(e);
- if (e instanceof Error) {
- setPayErrMsg(e.message);
- }
- }
-
- }
-
- return <PaymentRequestView uri={talerPayUri}
- payStatus={payStatus} payResult={payResult}
- onClick={onClick} payErrMsg={payErrMsg}
- balance={foundAmount} />;
-}
-
-export interface PaymentRequestViewProps {
- payStatus: PreparePayResult;
- payResult?: ConfirmPayResult;
- onClick: () => void;
- payErrMsg?: string;
- uri: string;
- balance: AmountJson | undefined;
-}
-export function PaymentRequestView({ uri, payStatus, payResult, onClick, payErrMsg, balance }: PaymentRequestViewProps) {
- let totalFees: AmountJson = Amounts.getZero(payStatus.amountRaw);
- const contractTerms: ContractTerms = payStatus.contractTerms;
-
- if (!contractTerms) {
- return (
- <span>
- Error: did not get contract terms from merchant or wallet backend.
- </span>
- );
- }
-
- if (payStatus.status === PreparePayResultType.PaymentPossible) {
- const amountRaw = Amounts.parseOrThrow(payStatus.amountRaw);
- const amountEffective: AmountJson = Amounts.parseOrThrow(
- payStatus.amountEffective,
- );
- totalFees = Amounts.sub(amountEffective, amountRaw).amount;
- }
-
- let merchantName: VNode;
- if (contractTerms.merchant && contractTerms.merchant.name) {
- merchantName = <strong>{contractTerms.merchant.name}</strong>;
- } else {
- merchantName = <strong>(pub: {contractTerms.merchant_pub})</strong>;
- }
-
- function Alternative() {
- const [showQR, setShowQR] = useState<boolean>(false)
- const privateUri = payStatus.status !== PreparePayResultType.AlreadyConfirmed ? `${uri}&n=${payStatus.noncePriv}` : uri
- return <section>
- <LinkSuccess upperCased onClick={() => setShowQR(qr => !qr)}>
- {!showQR ? i18n.str`Pay with a mobile phone` : i18n.str`Hide QR`}
- </LinkSuccess>
- {showQR && <div>
- <QR text={privateUri} />
- Scan the QR code or <a href={privateUri}>click here</a>
- </div>}
- </section>
- }
-
- function ButtonsSection() {
- if (payResult) {
- if (payResult.type === ConfirmPayResultType.Pending) {
- return <section>
- <div>
- <p>Processing...</p>
- </div>
- </section>
- }
- return null
- }
- if (payErrMsg) {
- return <section>
- <div>
- <p>Payment failed: {payErrMsg}</p>
- <button class="pure-button button-success" onClick={onClick} >
- {i18n.str`Retry`}
- </button>
- </div>
- </section>
- }
- if (payStatus.status === PreparePayResultType.PaymentPossible) {
- return <Fragment>
- <section>
- <ButtonSuccess upperCased onClick={onClick}>
- {i18n.str`Pay`} {amountToString(payStatus.amountEffective)}
- </ButtonSuccess>
- </section>
- <Alternative />
- </Fragment>
- }
- if (payStatus.status === PreparePayResultType.InsufficientBalance) {
- return <Fragment>
- <section>
- {balance ? <WarningBox>
- Your balance of {amountToString(balance)} is not enough to pay for this purchase
- </WarningBox> : <WarningBox>
- Your balance is not enough to pay for this purchase.
- </WarningBox>}
- </section>
- <section>
- <ButtonSuccess upperCased>
- {i18n.str`Withdraw digital cash`}
- </ButtonSuccess>
- </section>
- <Alternative />
- </Fragment>
- }
- if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
- return <Fragment>
- <section>
- {payStatus.paid && contractTerms.fulfillment_message && <Part title="Merchant message" text={contractTerms.fulfillment_message} kind='neutral' />}
- </section>
- {!payStatus.paid && <Alternative />}
- </Fragment>
- }
- return <span />
- }
-
- return <WalletAction>
- <LogoHeader />
-
- <h2>
- {i18n.str`Digital cash payment`}
- </h2>
- {payStatus.status === PreparePayResultType.AlreadyConfirmed &&
- (payStatus.paid ? <SuccessBox> Already paid </SuccessBox> : <WarningBox> Already claimed </WarningBox>)
- }
- {payResult && payResult.type === ConfirmPayResultType.Done && (
- <SuccessBox>
- <h3>Payment complete</h3>
- <p>{!payResult.contractTerms.fulfillment_message ?
- "You will now be sent back to the merchant you came from." :
- payResult.contractTerms.fulfillment_message
- }</p>
- </SuccessBox>
- )}
- <section>
- {payStatus.status !== PreparePayResultType.InsufficientBalance && Amounts.isNonZero(totalFees) &&
- <Part big title="Total to pay" text={amountToString(payStatus.amountEffective)} kind='negative' />
- }
- <Part big title="Purchase amount" text={amountToString(payStatus.amountRaw)} kind='neutral' />
- {Amounts.isNonZero(totalFees) && <Fragment>
- <Part big title="Fee" text={amountToString(totalFees)} kind='negative' />
- </Fragment>
- }
- <Part title="Merchant" text={contractTerms.merchant.name} kind='neutral' />
- <Part title="Purchase" text={contractTerms.summary} kind='neutral' />
- {contractTerms.order_id && <Part title="Receipt" text={`#${contractTerms.order_id}`} kind='neutral' />}
- </section>
- <ButtonsSection />
-
- </WalletAction>
-}
-
-function amountToString(text: AmountLike) {
- const aj = Amounts.jsonifyAmount(text)
- const amount = Amounts.stringifyValue(aj, 2)
- return `${amount} ${aj.currency}`
-}
diff --git a/packages/taler-wallet-webextension/src/cta/Payment/index.ts b/packages/taler-wallet-webextension/src/cta/Payment/index.ts
new file mode 100644
index 000000000..c9bead89c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Payment/index.ts
@@ -0,0 +1,101 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountJson,
+ PreparePayResult,
+ PreparePayResultAlreadyConfirmed,
+ PreparePayResultInsufficientBalance,
+ PreparePayResultPaymentPossible,
+} from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { ButtonHandler } from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { BaseView } from "./views.js";
+
+export interface Props {
+ talerPayUri: string;
+ goToWalletManualWithdraw: (amount?: string) => Promise<void>;
+ cancel: () => Promise<void>;
+ onSuccess: (tx: string) => Promise<void>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.Ready
+ | State.NoEnoughBalance
+ | State.NoBalanceForCurrency
+ | State.Confirmed;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ interface BaseInfo {
+ amount: AmountJson;
+ uri: string;
+ error: undefined;
+ goToWalletManualWithdraw: (amount?: string) => Promise<void>;
+ cancel: () => Promise<void>;
+ }
+ export interface NoBalanceForCurrency extends BaseInfo {
+ status: "no-balance-for-currency";
+ payStatus: PreparePayResult;
+ balance: undefined;
+ }
+ export interface NoEnoughBalance extends BaseInfo {
+ status: "no-enough-balance";
+ payStatus: PreparePayResultInsufficientBalance;
+ balance: AmountJson;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ payStatus: PreparePayResultPaymentPossible;
+ payHandler: ButtonHandler;
+ balance: AmountJson;
+ }
+
+ export interface Confirmed extends BaseInfo {
+ status: "confirmed";
+ payStatus: PreparePayResultAlreadyConfirmed;
+ balance: AmountJson;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ "no-balance-for-currency": BaseView,
+ "no-enough-balance": BaseView,
+ confirmed: BaseView,
+ ready: BaseView,
+};
+
+export const PaymentPage = compose(
+ "Payment",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/Payment/state.ts b/packages/taler-wallet-webextension/src/cta/Payment/state.ts
new file mode 100644
index 000000000..4733e5aee
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Payment/state.ts
@@ -0,0 +1,174 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ ConfirmPayResultType,
+ NotificationType,
+ PreparePayResultType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useEffect } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { ButtonHandler } from "../../mui/handlers.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ talerPayUri,
+ cancel,
+ goToWalletManualWithdraw,
+ onSuccess,
+}: Props): State {
+ const { pushAlertOnError } = useAlertContext();
+ const api = useBackendContext();
+ const { i18n } = useTranslationContext();
+
+ const hook = useAsyncAsHook(async () => {
+ if (!talerPayUri) throw Error("ERROR_NO-URI-FOR-PAYMENT");
+ const payStatus = await api.wallet.call(
+ WalletApiOperation.PreparePayForUri,
+ {
+ talerPayUri: talerPayUri,
+ },
+ );
+ const balance = await api.wallet.call(WalletApiOperation.GetBalances, {});
+ return { payStatus, balance, uri: talerPayUri };
+ }, []);
+
+ useEffect(
+ () =>
+ api.listener.onUpdateNotification(
+ [NotificationType.TransactionStateTransition],
+ hook?.retry,
+ ),
+ [hook],
+ );
+
+ const hookResponse = !hook || hook.hasError ? undefined : hook.response;
+
+ useEffect(() => {
+ if (!hookResponse) return;
+ const { payStatus } = hookResponse;
+ if (
+ payStatus &&
+ payStatus.status === PreparePayResultType.AlreadyConfirmed &&
+ payStatus.paid
+ ) {
+ const fu = payStatus.contractTerms.fulfillment_url;
+ if (fu) {
+ setTimeout(() => {
+ document.location.href = fu;
+ }, 3000);
+ }
+ }
+ }, [hookResponse]);
+
+ if (!hook) return { status: "loading", error: undefined };
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the payment and balance status`,
+ hook,
+ ),
+ };
+ }
+ // if (hook.hasError) {
+ // return {
+ // status: "loading-uri",
+ // error: hook,
+ // };
+ // }
+ const { payStatus } = hook.response;
+
+ const amount = Amounts.parseOrThrow(payStatus.amountRaw);
+
+ const foundBalance = hook.response.balance.balances.find(
+ (b) => Amounts.parseOrThrow(b.available).currency === amount.currency,
+ );
+
+ const baseResult = {
+ uri: hook.response.uri,
+ amount,
+ error: undefined,
+ cancel,
+ goToWalletManualWithdraw,
+ };
+
+ if (!foundBalance) {
+ return {
+ status: "no-balance-for-currency",
+ balance: undefined,
+ payStatus,
+ ...baseResult,
+ };
+ }
+
+ const foundAmount = Amounts.parseOrThrow(foundBalance.available);
+
+ if (payStatus.status === PreparePayResultType.InsufficientBalance) {
+ return {
+ status: "no-enough-balance",
+ balance: foundAmount,
+ payStatus,
+ ...baseResult,
+ };
+ }
+
+ if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
+ return {
+ status: "confirmed",
+ balance: foundAmount,
+ payStatus,
+ ...baseResult,
+ };
+ }
+
+ async function doPayment(): Promise<void> {
+ const res = await api.wallet.call(WalletApiOperation.ConfirmPay, {
+ proposalId: payStatus.proposalId,
+ });
+ // handle confirm pay
+ if (res.type !== ConfirmPayResultType.Done) {
+ onSuccess(res.transactionId);
+ return;
+ }
+ const fu = res.contractTerms.fulfillment_url;
+ if (fu) {
+ if (typeof window !== "undefined") {
+ document.location.href = fu;
+ }
+ }
+ onSuccess(res.transactionId);
+ }
+
+ const payHandler: ButtonHandler = {
+ onClick: pushAlertOnError(doPayment),
+ };
+
+ // (payStatus.status === PreparePayResultType.PaymentPossible)
+ return {
+ status: "ready",
+ payHandler,
+ payStatus,
+ ...baseResult,
+ balance: foundAmount,
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx
new file mode 100644
index 000000000..d03f48746
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx
@@ -0,0 +1,513 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AmountString,
+ Amounts,
+ MerchantContractTerms as ContractTerms,
+ PreparePayResultType,
+ TransactionIdStr,
+} from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import beer from "../../../static-dev/beer.png";
+import merchantIcon from "../../../static-dev/merchant-icon.jpeg";
+import { nullFunction } from "../../mui/handlers.js";
+import { BaseView } from "./views.js";
+
+export default {
+ title: "payment",
+ component: BaseView,
+ argTypes: {},
+};
+
+export const NoEnoughBalanceAvailable = tests.createExample(BaseView, {
+ status: "no-enough-balance",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 12,
+ },
+
+ uri: "",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.InsufficientBalance,
+ balanceDetails: {
+ amountRequested: "USD:10" as AmountString,
+ balanceAvailable: "USD:9" as AmountString,
+ balanceMaterial: "USD:9" as AmountString,
+ balanceAgeAcceptable: "USD:9" as AmountString,
+ balanceReceiverAcceptable: "USD:9" as AmountString,
+ balanceReceiverDepositable: "USD:9" as AmountString,
+ maxEffectiveSpendAmount: "USD:9.5" as AmountString,
+ balanceExchangeDepositable: "USD:9.5" as AmountString,
+ perExchange: {},
+ },
+ talerUri: "taler://pay/..",
+
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ contractTerms: {
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ summary: "some beers",
+ amount: "USD:10",
+ } as Partial<ContractTerms> as any,
+ amountRaw: "USD:10" as AmountString,
+ },
+});
+
+export const NoEnoughBalanceMaterial = tests.createExample(BaseView, {
+ status: "no-enough-balance",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 12,
+ },
+
+ uri: "",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.InsufficientBalance,
+ balanceDetails: {
+ amountRequested: "USD:10" as AmountString,
+ balanceAvailable: "USD:10" as AmountString,
+ balanceMaterial: "USD:9" as AmountString,
+ balanceAgeAcceptable: "USD:9" as AmountString,
+ balanceReceiverAcceptable: "USD:9" as AmountString,
+ balanceReceiverDepositable: "USD:0" as AmountString,
+ maxEffectiveSpendAmount: "USD:9.5" as AmountString,
+ balanceExchangeDepositable: "USD:9.5" as AmountString,
+ perExchange: {},
+ },
+ talerUri: "taler://pay/..",
+
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ contractTerms: {
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ summary: "some beers",
+ amount: "USD:10" as AmountString,
+ } as Partial<ContractTerms> as any,
+ amountRaw: "USD:10" as AmountString,
+ },
+});
+
+export const NoEnoughBalanceAgeAcceptable = tests.createExample(BaseView, {
+ status: "no-enough-balance",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 12,
+ },
+
+ uri: "",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.InsufficientBalance,
+ balanceDetails: {
+ amountRequested: "USD:10" as AmountString,
+ balanceAvailable: "USD:10" as AmountString,
+ balanceMaterial: "USD:10" as AmountString,
+ balanceAgeAcceptable: "USD:9" as AmountString,
+ balanceReceiverAcceptable: "USD:9" as AmountString,
+ balanceReceiverDepositable: "USD:9" as AmountString,
+ maxEffectiveSpendAmount: "USD:9.5" as AmountString,
+ balanceExchangeDepositable: "USD:9.5" as AmountString,
+ perExchange: {},
+ },
+ talerUri: "taler://pay/..",
+
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ contractTerms: {
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ minimum_age: 18,
+ summary: "some beers",
+ amount: "USD:10",
+ } as Partial<ContractTerms> as any,
+ amountRaw: "USD:10" as AmountString,
+ },
+});
+
+export const NoEnoughBalanceMerchantAcceptable = tests.createExample(BaseView, {
+ status: "no-enough-balance",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 12,
+ },
+
+ uri: "",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.InsufficientBalance,
+ balanceDetails: {
+ amountRequested: "USD:10" as AmountString,
+ balanceAvailable: "USD:10" as AmountString,
+ balanceMaterial: "USD:10" as AmountString,
+ balanceAgeAcceptable: "USD:10" as AmountString,
+ balanceReceiverAcceptable: "USD:9" as AmountString,
+ balanceReceiverDepositable: "USD:9" as AmountString,
+ maxEffectiveSpendAmount: "USD:9.5" as AmountString,
+ balanceExchangeDepositable: "USD:9.5" as AmountString,
+ perExchange: {},
+ },
+ talerUri: "taler://pay/..",
+
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ contractTerms: {
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ summary: "some beers",
+ amount: "USD:10" as AmountString,
+ } as Partial<ContractTerms> as any,
+ amountRaw: "USD:10" as AmountString,
+ },
+});
+
+export const NoEnoughBalanceMerchantDepositable = tests.createExample(
+ BaseView,
+ {
+ status: "no-enough-balance",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 12,
+ },
+
+ uri: "",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.InsufficientBalance,
+ balanceDetails: {
+ amountRequested: "USD:10" as AmountString,
+ balanceAvailable: "USD:10" as AmountString,
+ balanceMaterial: "USD:10" as AmountString,
+ balanceAgeAcceptable: "USD:10" as AmountString,
+ balanceReceiverAcceptable: "USD:10" as AmountString,
+ balanceReceiverDepositable: "USD:9" as AmountString,
+ maxEffectiveSpendAmount: "USD:9.5" as AmountString,
+ balanceExchangeDepositable: "USD:9.5" as AmountString,
+ perExchange: {},
+ },
+ talerUri: "taler://pay/..",
+
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ contractTerms: {
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ summary: "some beers",
+ amount: "USD:10" as AmountString,
+ } as Partial<ContractTerms> as any,
+ amountRaw: "USD:10" as AmountString,
+ },
+ },
+);
+
+export const NoEnoughBalanceFeeGap = tests.createExample(BaseView, {
+ status: "no-enough-balance",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 12,
+ },
+
+ uri: "",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.InsufficientBalance,
+ balanceDetails: {
+ amountRequested: "USD:10" as AmountString,
+ balanceAvailable: "USD:10" as AmountString,
+ balanceMaterial: "USD:10" as AmountString,
+ balanceAgeAcceptable: "USD:10" as AmountString,
+ balanceReceiverAcceptable: "USD:10" as AmountString,
+ balanceReceiverDepositable: "USD:10" as AmountString,
+ maxEffectiveSpendAmount: "USD:9.5" as AmountString,
+ balanceExchangeDepositable: "USD:9.5" as AmountString,
+ perExchange: {},
+ },
+ talerUri: "taler://pay/..",
+
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ contractTerms: {
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ minimum_age: 18,
+ summary: "some beers",
+ amount: "USD:10" as AmountString,
+ } as Partial<ContractTerms> as any,
+ amountRaw: "USD:10" as AmountString,
+ },
+});
+
+export const PaymentPossible = tests.createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+ payHandler: {
+ onClick: nullFunction,
+ },
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.PaymentPossible,
+ talerUri: "taler://pay/..",
+ amountEffective: "USD:10" as AmountString,
+ amountRaw: "USD:10" as AmountString,
+
+ contractTerms: {
+ nonce: "123213123",
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ pay_deadline: {
+ t_s: new Date().getTime() / 1000 + 60 * 60 * 3,
+ },
+ amount: "USD:10" as AmountString,
+ summary: "some beers",
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ },
+});
+
+export const PaymentPossibleWithFee = tests.createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+ payHandler: {
+ onClick: nullFunction,
+ },
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.PaymentPossible,
+ talerUri: "taler://pay/..",
+ amountEffective: "USD:10.20" as AmountString,
+ amountRaw: "USD:10" as AmountString,
+
+ contractTerms: {
+ nonce: "123213123",
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ amount: "USD:10" as AmountString,
+ summary: "some beers",
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ },
+});
+
+export const TicketWithAProductList = tests.createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+ payHandler: {
+ onClick: nullFunction,
+ },
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.PaymentPossible,
+ talerUri: "taler://pay/..",
+ amountEffective: "USD:10.20" as AmountString,
+ amountRaw: "USD:10" as AmountString,
+
+ contractTerms: {
+ nonce: "123213123",
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ amount: "USD:10",
+ summary: "some beers",
+ products: [
+ {
+ description: "ten beers",
+ price: "USD:1",
+ quantity: 10,
+ image: beer,
+ },
+ {
+ description: "beer without image",
+ price: "USD:1",
+ quantity: 10,
+ },
+ {
+ description: "one brown beer",
+ price: "USD:2",
+ quantity: 1,
+ image: beer,
+ },
+ ],
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ },
+});
+
+export const TicketWithShipping = tests.createExample(BaseView, {
+ status: "ready",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+ payHandler: {
+ onClick: nullFunction,
+ },
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.PaymentPossible,
+ talerUri: "taler://pay/..",
+ amountEffective: "USD:10.20" as AmountString,
+ amountRaw: "USD:10" as AmountString,
+
+ contractTerms: {
+ nonce: "123213123",
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ amount: "USD:10",
+ summary: "banana pi set",
+ products: [
+ {
+ description: "banana pi",
+ price: "USD:2",
+ quantity: 1,
+ },
+ ],
+ delivery_date: {
+ t_s: new Date().getTime() / 1000 + 30 * 24 * 60 * 60,
+ },
+ delivery_location: {
+ town: "Liverpool",
+ street: "Down st 1234",
+ },
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ },
+});
+
+export const AlreadyConfirmedByOther = tests.createExample(BaseView, {
+ status: "confirmed",
+ error: undefined,
+ amount: Amounts.parseOrThrow("USD:10"),
+ balance: {
+ currency: "USD",
+ fraction: 40000000,
+ value: 11,
+ },
+
+ uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0",
+ payStatus: {
+ transactionId: " " as TransactionIdStr,
+ status: PreparePayResultType.AlreadyConfirmed,
+ talerUri: "taler://pay/..",
+ amountEffective: "USD:10" as AmountString,
+ amountRaw: "USD:10" as AmountString,
+ contractTerms: {
+ merchant: {
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
+ },
+ summary: "some beers",
+ amount: "USD:10",
+ } as Partial<ContractTerms> as any,
+ contractTermsHash: "123456",
+ proposalId: "96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0",
+ paid: false,
+ },
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Payment/test.ts b/packages/taler-wallet-webextension/src/cta/Payment/test.ts
new file mode 100644
index 000000000..5847cc833
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Payment/test.ts
@@ -0,0 +1,576 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AmountString,
+ Amounts,
+ ConfirmPayResult,
+ ConfirmPayResultType,
+ NotificationType,
+ PreparePayResultInsufficientBalance,
+ PreparePayResultPaymentPossible,
+ PreparePayResultType,
+ ScopeType,
+ TransactionMajorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { expect } from "chai";
+import * as tests from "@gnu-taler/web-util/testing";
+import { ErrorAlert, useAlertContext } from "../../context/alert.js";
+import { nullFunction } from "../../mui/handlers.js";
+import { createWalletApiMock } from "../../test-utils.js";
+import { useComponentState } from "./state.js";
+
+describe("Payment CTA states", () => {
+ it("should tell the user that the URI is missing", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerPayUri: "",
+ cancel: nullFunction,
+ goToWalletManualWithdraw: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ ({ status, error }) => {
+ expect(status).equals("error");
+ if (error === undefined) expect.fail();
+ // expect(error.hasError).true;
+ // expect(error.operational).false;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should response with no balance", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerPayUri: "taller://pay",
+ cancel: nullFunction,
+ goToWalletManualWithdraw: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.PreparePayForUri,
+ undefined,
+ {
+ status: PreparePayResultType.InsufficientBalance,
+ amountRaw: "USD:10",
+ } as PreparePayResultInsufficientBalance,
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetBalances,
+ {},
+ { balances: [] },
+ );
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ (state) => {
+ if (state.status !== "no-balance-for-currency") {
+ expect(state).eq({});
+ return;
+ }
+ expect(state.balance).undefined;
+ expect(state.amount).deep.equal(Amounts.parseOrThrow("USD:10"));
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should not be able to pay if there is no enough balance", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerPayUri: "taller://pay",
+ cancel: nullFunction,
+ goToWalletManualWithdraw: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.PreparePayForUri,
+ undefined,
+ {
+ status: PreparePayResultType.InsufficientBalance,
+ amountRaw: "USD:10",
+ } as PreparePayResultInsufficientBalance,
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetBalances,
+ {},
+ {
+ balances: [
+ {
+ flags: [],
+ available: "USD:5" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "USD",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ },
+ );
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ (state) => {
+ if (state.status !== "no-enough-balance") expect.fail();
+ expect(state.balance).deep.equal(Amounts.parseOrThrow("USD:5"));
+ expect(state.amount).deep.equal(Amounts.parseOrThrow("USD:10"));
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should be able to pay (without fee)", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerPayUri: "taller://pay",
+ cancel: nullFunction,
+ goToWalletManualWithdraw: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.PreparePayForUri,
+ undefined,
+ {
+ status: PreparePayResultType.PaymentPossible,
+ amountRaw: "USD:10",
+ amountEffective: "USD:10",
+ } as PreparePayResultPaymentPossible,
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetBalances,
+ {},
+ {
+ balances: [
+ {
+ flags: [],
+ available: "USD:15" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "USD",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ },
+ );
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ (state) => {
+ if (state.status !== "ready") {
+ expect(state).eq({});
+ return;
+ }
+ expect(state.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(state.amount).deep.equal(Amounts.parseOrThrow("USD:10"));
+ expect(state.payHandler.onClick).not.undefined;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should be able to pay (with fee)", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerPayUri: "taller://pay",
+ cancel: nullFunction,
+ goToWalletManualWithdraw: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.PreparePayForUri,
+ undefined,
+ {
+ status: PreparePayResultType.PaymentPossible,
+ amountRaw: "USD:9",
+ amountEffective: "USD:10",
+ } as PreparePayResultPaymentPossible,
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetBalances,
+ {},
+ {
+ balances: [
+ {
+ flags: [],
+ available: "USD:15" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "USD",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ },
+ );
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(state.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ expect(state.payHandler.onClick).not.undefined;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should get confirmation done after pay successfully", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerPayUri: "taller://pay",
+ cancel: nullFunction,
+ goToWalletManualWithdraw: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.PreparePayForUri,
+ undefined,
+ {
+ status: PreparePayResultType.PaymentPossible,
+ amountRaw: "USD:9",
+ amountEffective: "USD:10",
+ } as PreparePayResultPaymentPossible,
+ );
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetBalances,
+ {},
+ {
+ balances: [
+ {
+ flags: [],
+ available: "USD:15" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "USD",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ },
+ );
+ handler.addWalletCallResponse(WalletApiOperation.ConfirmPay, undefined, {
+ type: ConfirmPayResultType.Done,
+ contractTerms: {},
+ } as ConfirmPayResult);
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ (state) => {
+ if (state.status !== "ready") {
+ expect(state).eq({});
+ return;
+ }
+ expect(state.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(state.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ if (state.payHandler.onClick === undefined) expect.fail();
+ state.payHandler.onClick();
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should not stay in ready state after pay with error", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerPayUri: "taller://pay",
+ cancel: nullFunction,
+ goToWalletManualWithdraw: nullFunction,
+ onSuccess: nullFunction,
+ };
+ handler.addWalletCallResponse(
+ WalletApiOperation.PreparePayForUri,
+ undefined,
+ {
+ status: PreparePayResultType.PaymentPossible,
+ amountRaw: "USD:9",
+ amountEffective: "USD:10",
+ } as PreparePayResultPaymentPossible,
+ );
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetBalances,
+ {},
+ {
+ balances: [
+ {
+ flags: [],
+ available: "USD:15" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "USD",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ },
+ );
+ handler.addWalletCallResponse(WalletApiOperation.ConfirmPay, undefined, {
+ type: ConfirmPayResultType.Pending,
+ lastError: { code: 1 },
+ } as ConfirmPayResult);
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ () => {
+ const state = useComponentState(props);
+ // const { alerts } = useAlertContext();
+ return { ...state, alerts: {} };
+ },
+ {},
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(state.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ // expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
+ if (state.payHandler.onClick === undefined) expect.fail();
+ state.payHandler.onClick();
+ },
+ // (state) => {
+ // if (state.status !== "ready") expect.fail();
+ // expect(state.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ // expect(state.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+
+ // // FIXME: check that the error is pushed to the alertContext
+ // // expect(state.alerts.length).eq(1);
+ // // const alert = state.alerts[0]
+ // // if (alert.type !== "error") expect.fail();
+
+ // // expect(alert.cause.errorDetail.payResult).deep.equal({
+ // // type: ConfirmPayResultType.Pending,
+ // // lastError: { code: 1 },
+ // // });
+ // },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should update balance if a coins is withdraw", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+
+ const props = {
+ talerPayUri: "taller://pay",
+ cancel: nullFunction,
+ goToWalletManualWithdraw: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.PreparePayForUri,
+ undefined,
+ {
+ status: PreparePayResultType.PaymentPossible,
+ amountRaw: "USD:9",
+ amountEffective: "USD:10",
+ } as PreparePayResultPaymentPossible,
+ );
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetBalances,
+ {},
+ {
+ balances: [
+ {
+ flags: [],
+ available: "USD:10" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "USD",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ },
+ );
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.PreparePayForUri,
+ undefined,
+ {
+ status: PreparePayResultType.PaymentPossible,
+ amountRaw: "USD:9",
+ amountEffective: "USD:10",
+ } as PreparePayResultPaymentPossible,
+ );
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetBalances,
+ {},
+ {
+ balances: [
+ {
+ flags: [],
+ available: "USD:15" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "USD",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ },
+ );
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.balance).deep.equal(Amounts.parseOrThrow("USD:10"));
+ expect(state.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ // expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
+ expect(state.payHandler.onClick).not.undefined;
+
+ handler.notifyEventFromWallet({
+ type: NotificationType.TransactionStateTransition,
+ newTxState: {} as any,
+ oldTxState: {} as any,
+ transactionId: "123",
+ }
+
+ );
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.balance).deep.equal(Amounts.parseOrThrow("USD:15"));
+ expect(state.amount).deep.equal(Amounts.parseOrThrow("USD:9"));
+ // expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1"));
+ expect(state.payHandler.onClick).not.undefined;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx
new file mode 100644
index 000000000..8bbb8dac2
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx
@@ -0,0 +1,132 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ MerchantContractTerms as ContractTerms,
+ PreparePayResultType,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { Part } from "../../components/Part.js";
+import { PaymentButtons } from "../../components/PaymentButtons.js";
+import { ShowFullContractTermPopup } from "../../components/ShowFullContractTermPopup.js";
+import { Time } from "../../components/Time.js";
+import { SuccessBox, WarningBox } from "../../components/styled/index.js";
+import { MerchantDetails } from "../../wallet/Transaction.js";
+import { State } from "./index.js";
+import { EnabledBySettings } from "../../components/EnabledBySettings.js";
+
+type SupportedStates =
+ | State.Ready
+ | State.Confirmed
+ | State.NoBalanceForCurrency
+ | State.NoEnoughBalance;
+
+export function BaseView(state: SupportedStates): VNode {
+ const { i18n } = useTranslationContext();
+
+ const contractTerms: ContractTerms = state.payStatus.contractTerms;
+
+ const effective =
+ "amountEffective" in state.payStatus
+ ? state.payStatus.amountEffective
+ ? Amounts.parseOrThrow(state.payStatus.amountEffective)
+ : Amounts.zeroOfCurrency(state.amount.currency)
+ : state.amount;
+
+ return (
+ <Fragment>
+ <ShowImportantMessage state={state} />
+
+ <section style={{ textAlign: "left" }}>
+ <Part
+ title={i18n.str`Purchase`}
+ text={contractTerms.summary as TranslatedString}
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Merchant`}
+ text={<MerchantDetails merchant={contractTerms.merchant} />}
+ kind="neutral"
+ />
+ {contractTerms.pay_deadline && (
+ <Part
+ title={i18n.str`Valid until`}
+ text={
+ <Time
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ contractTerms.pay_deadline,
+ )}
+ format="dd MMMM yyyy, HH:mm"
+ />
+ }
+ kind="neutral"
+ />
+ )}
+ </section>
+ <EnabledBySettings name="advancedMode">
+ <section style={{ textAlign: "left" }}>
+ <ShowFullContractTermPopup
+ transactionId={state.payStatus.transactionId}
+ />
+ </section>
+ </EnabledBySettings>
+ <PaymentButtons
+ amount={effective}
+ payStatus={state.payStatus}
+ uri={state.uri}
+ payHandler={state.status === "ready" ? state.payHandler : undefined}
+ goToWalletManualWithdraw={state.goToWalletManualWithdraw}
+ />
+ </Fragment>
+ );
+}
+
+function ShowImportantMessage({ state }: { state: SupportedStates }): VNode {
+ const { i18n } = useTranslationContext();
+ const { payStatus } = state;
+ if (payStatus.status === PreparePayResultType.AlreadyConfirmed) {
+ if (payStatus.paid) {
+ if (payStatus.contractTerms.fulfillment_url) {
+ return (
+ <SuccessBox>
+ <i18n.Translate>
+ Already paid, you are going to be redirected to{" "}
+ <a href={payStatus.contractTerms.fulfillment_url}>
+ {payStatus.contractTerms.fulfillment_url}
+ </a>
+ </i18n.Translate>
+ </SuccessBox>
+ );
+ }
+ return (
+ <SuccessBox>
+ <i18n.Translate>Already paid</i18n.Translate>
+ </SuccessBox>
+ );
+ }
+ return (
+ <WarningBox>
+ <i18n.Translate>Already claimed</i18n.Translate>
+ </WarningBox>
+ );
+ }
+
+ return <Fragment />;
+}
diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/index.ts b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/index.ts
new file mode 100644
index 000000000..f5a8c8814
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/index.ts
@@ -0,0 +1,83 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+import { PaymentPage } from "../Payment/index.js";
+import {
+ AmountFieldHandler,
+ ButtonHandler,
+ TextFieldHandler,
+} from "../../mui/handlers.js";
+
+export interface Props {
+ talerTemplateUri: string;
+ goToWalletManualWithdraw: (amount?: string) => Promise<void>;
+ cancel: () => Promise<void>;
+ onSuccess: (tx: string) => Promise<void>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.OrderReady
+ | State.FillTemplate;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface FillTemplate {
+ status: "fill-template";
+ error: undefined;
+ currency: string;
+ amount?: AmountFieldHandler;
+ summary?: TextFieldHandler;
+ onCreate: ButtonHandler;
+ }
+
+ export interface OrderReady {
+ status: "order-ready";
+ error: undefined;
+ talerPayUri: string;
+ onSuccess: (tx: string) => Promise<void>;
+ cancel: () => Promise<void>;
+ goToWalletManualWithdraw: () => Promise<void>;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ "fill-template": ReadyView,
+ "order-ready": PaymentPage,
+};
+
+export const PaymentTemplatePage = compose(
+ "PaymentTemplate",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts
new file mode 100644
index 000000000..6b4584fea
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts
@@ -0,0 +1,174 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Amounts, PreparePayResult } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useState } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { AmountFieldHandler, TextFieldHandler } from "../../mui/handlers.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ talerTemplateUri,
+ cancel,
+ goToWalletManualWithdraw,
+ onSuccess,
+}: Props): State {
+ const api = useBackendContext();
+ const { i18n } = useTranslationContext();
+ const { safely } = useAlertContext();
+
+ const url = talerTemplateUri ? new URL(talerTemplateUri) : undefined;
+
+ const amountParam = !url
+ ? undefined
+ : url.searchParams.get("amount") ?? undefined;
+ const summaryParam = !url
+ ? undefined
+ : url.searchParams.get("summary") ?? undefined;
+
+ const parsedAmount = !amountParam ? undefined : Amounts.parse(amountParam);
+ const currency = parsedAmount ? parsedAmount.currency : amountParam;
+
+ const initialAmount =
+ parsedAmount ?? (currency ? Amounts.zeroOfCurrency(currency) : undefined);
+ const [amount, setAmount] = useState(initialAmount);
+ const [summary, setSummary] = useState(summaryParam);
+ const [newOrder, setNewOrder] = useState("");
+
+ const hook = useAsyncAsHook(async () => {
+ if (!talerTemplateUri) throw Error("ERROR_NO-URI-FOR-PAYMENT-TEMPLATE");
+ let payStatus: PreparePayResult | undefined = undefined;
+ if (!amountParam && !summaryParam) {
+ payStatus = await api.wallet.call(
+ WalletApiOperation.PreparePayForTemplate,
+ {
+ talerPayTemplateUri: talerTemplateUri,
+ templateParams: {},
+ },
+ );
+ }
+ const balance = await api.wallet.call(WalletApiOperation.GetBalances, {});
+ return { payStatus, balance, uri: talerTemplateUri };
+ }, []);
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the status of the order template`,
+ hook,
+ ),
+ };
+ }
+
+ if (hook.response.payStatus) {
+ return {
+ status: "order-ready",
+ error: undefined,
+ cancel,
+ goToWalletManualWithdraw,
+ onSuccess,
+ talerPayUri: hook.response.payStatus.talerUri!,
+ };
+ }
+
+ if (newOrder) {
+ return {
+ status: "order-ready",
+ error: undefined,
+ cancel,
+ goToWalletManualWithdraw,
+ onSuccess,
+ talerPayUri: newOrder,
+ };
+ }
+
+ async function createOrder() {
+ try {
+ const templateParams: Record<string, string> = {};
+ if (amount) {
+ templateParams["amount"] = Amounts.stringify(amount);
+ }
+ if (summary) {
+ templateParams["summary"] = summary;
+ }
+ const payStatus = await api.wallet.call(
+ WalletApiOperation.PreparePayForTemplate,
+ {
+ talerPayTemplateUri: talerTemplateUri,
+ templateParams,
+ },
+ );
+ setNewOrder(payStatus.talerUri!);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ const errors = undefinedIfEmpty({
+ amount: amount && Amounts.isZero(amount) ? i18n.str`required` : undefined,
+ summary: summary !== undefined && !summary ? i18n.str`required` : undefined,
+ });
+ return {
+ status: "fill-template",
+ error: undefined,
+ currency: currency!, //currency is always not null
+ amount:
+ amount !== undefined
+ ? ({
+ onInput: (a) => {
+ setAmount(a);
+ },
+ value: amount,
+ error: errors?.amount,
+ } as AmountFieldHandler)
+ : undefined,
+ summary:
+ summary !== undefined
+ ? ({
+ onInput: (t) => {
+ setSummary(t);
+ },
+ value: summary,
+ error: errors?.summary,
+ } as TextFieldHandler)
+ : undefined,
+ onCreate: {
+ onClick: errors
+ ? undefined
+ : safely("create order for pay template", createOrder),
+ },
+ };
+}
+
+function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
+ return Object.keys(obj).some(
+ (k) => (obj as Record<string, unknown>)[k] !== undefined,
+ )
+ ? obj
+ : undefined;
+}
diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/stories.tsx b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/stories.tsx
new file mode 100644
index 000000000..02607fa30
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/stories.tsx
@@ -0,0 +1,34 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "payment template",
+ component: ReadyView,
+ argTypes: {},
+};
+
+export const PaymentPossible = tests.createExample(ReadyView, {
+ status: "fill-template",
+ error: undefined,
+});
diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/test.ts b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/test.ts
new file mode 100644
index 000000000..d15761eae
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/test.ts
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+import * as tests from "@gnu-taler/web-util/testing";
+import { nullFunction } from "../../mui/handlers.js";
+import { createWalletApiMock } from "../../test-utils.js";
+import { useComponentState } from "./state.js";
+
+describe("Order template CTA states", () => {
+ it("should tell the user that the URI is missing", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerTemplateUri: "",
+ cancel: nullFunction,
+ goToWalletManualWithdraw: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ ({ status, error }) => {
+ expect(status).equals("error");
+ if (error === undefined) expect.fail();
+ // expect(error.hasError).true;
+ // expect(error.operational).false;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx
new file mode 100644
index 000000000..88658b5e1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx
@@ -0,0 +1,77 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Fragment, h, VNode } from "preact";
+import { AmountField } from "../../components/AmountField.js";
+import { Part } from "../../components/Part.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Button } from "../../mui/Button.js";
+import { TextField } from "../../mui/TextField.js";
+import { State } from "./index.js";
+
+export function ReadyView({
+ currency,
+ amount,
+ summary,
+ onCreate,
+}: State.FillTemplate): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <section style={{ textAlign: "left" }}>
+ {/* <Part
+ title={
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ }}
+ >
+ <i18n.Translate>Merchant</i18n.Translate>
+ </div>
+ }
+ text={<ExchangeDetails exchange={exchangeUrl} />}
+ kind="neutral"
+ big
+ /> */}
+ {!amount ? undefined : (
+ <p>
+ <AmountField label={i18n.str`Amount`} handler={amount} />
+ </p>
+ )}
+ {!summary ? undefined : (
+ <p>
+ <TextField
+ label="Summary"
+ variant="filled"
+ required
+ fullWidth
+ error={summary.error}
+ value={summary.value}
+ onChange={summary.onInput}
+ />
+ </p>
+ )}
+ </section>
+ <section>
+ <Button onClick={onCreate.onClick} variant="contained" color="success">
+ <i18n.Translate>Review order</i18n.Translate>
+ </Button>
+ </section>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Recovery/index.ts b/packages/taler-wallet-webextension/src/cta/Recovery/index.ts
new file mode 100644
index 000000000..79056c15b
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Recovery/index.ts
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { ButtonHandler } from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+
+export interface Props {
+ talerRecoveryUri?: string;
+ onCancel: () => Promise<void>;
+ onSuccess: () => Promise<void>;
+}
+
+export type State = State.Loading | State.LoadingUriError | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ cancel: ButtonHandler;
+ }
+
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ accept: ButtonHandler;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ ready: ReadyView,
+};
+
+export const RecoveryPage = compose(
+ "Recovery",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/Recovery/state.ts b/packages/taler-wallet-webextension/src/cta/Recovery/state.ts
new file mode 100644
index 000000000..5399c5bfc
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Recovery/state.ts
@@ -0,0 +1,84 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { parseRestoreUri } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ talerRecoveryUri,
+ onCancel,
+ onSuccess,
+}: Props): State {
+ const api = useBackendContext();
+ const { pushAlertOnError } = useAlertContext();
+ const { i18n } = useTranslationContext();
+ if (!talerRecoveryUri) {
+ return {
+ status: "error",
+ error: {
+ type: "error",
+ message: i18n.str`Missing URI`,
+ description: i18n.str``,
+ cause: new Error("something"),
+ context: {},
+ },
+ };
+ }
+ const info = parseRestoreUri(talerRecoveryUri);
+
+ if (!info) {
+ return {
+ status: "error",
+ error: {
+ type: "error",
+ message: i18n.str`Could not parse the recovery URI`,
+ description: i18n.str``,
+ cause: new Error("something"),
+ context: {},
+ },
+ };
+ }
+ const recovery = info;
+
+ async function recoverBackup(): Promise<void> {
+ await api.wallet.call(WalletApiOperation.ImportBackupRecovery, {
+ recovery: {
+ walletRootPriv: recovery.walletRootPriv,
+ providers: recovery.providers.map((url) => ({
+ name: new URL(url).hostname,
+ url,
+ })),
+ },
+ });
+ onSuccess();
+ }
+
+ return {
+ status: "ready",
+
+ accept: {
+ onClick: pushAlertOnError(recoverBackup),
+ },
+ cancel: {
+ onClick: pushAlertOnError(onCancel),
+ },
+ error: undefined,
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Recovery/stories.tsx b/packages/taler-wallet-webextension/src/cta/Recovery/stories.tsx
new file mode 100644
index 000000000..4d8dc3737
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Recovery/stories.tsx
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "recovery",
+};
diff --git a/packages/taler-wallet-webextension/src/cta/Recovery/test.ts b/packages/taler-wallet-webextension/src/cta/Recovery/test.ts
new file mode 100644
index 000000000..68c75b380
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Recovery/test.ts
@@ -0,0 +1,21 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+describe("Backup import CTA states", () => {
+ it.skip("should test something", async () => {
+ return;
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Recovery/views.tsx b/packages/taler-wallet-webextension/src/cta/Recovery/views.tsx
new file mode 100644
index 000000000..5a3a00daa
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Recovery/views.tsx
@@ -0,0 +1,41 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Fragment, h, VNode } from "preact";
+import { LogoHeader } from "../../components/LogoHeader.js";
+import { SubTitle, WalletAction } from "../../components/styled/index.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Button } from "../../mui/Button.js";
+import { State } from "./index.js";
+
+export function ReadyView({ accept, cancel }: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <section>
+ <p>
+ <i18n.Translate>Import backup, show info</i18n.Translate>
+ </p>
+ <Button variant="contained" onClick={accept.onClick}>
+ Import
+ </Button>
+ <Button variant="contained" onClick={cancel.onClick}>
+ Cancel
+ </Button>
+ </section>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx b/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx
deleted file mode 100644
index 88e714cb7..000000000
--- a/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { OrderShortInfo } from '@gnu-taler/taler-util';
-import { createExample } from '../test-utils';
-import { View as TestedComponent } from './Refund';
-
-
-export default {
- title: 'cta/refund',
- component: TestedComponent,
- argTypes: {
- },
-};
-
-export const Complete = createExample(TestedComponent, {
- applyResult: {
- amountEffectivePaid: 'USD:10',
- amountRefundGone: 'USD:0',
- amountRefundGranted: 'USD:2',
- contractTermsHash: 'QWEASDZXC',
- info: {
- summary: 'tasty cold beer',
- contractTermsHash: 'QWEASDZXC',
- } as Partial<OrderShortInfo> as any,
- pendingAtExchange: false,
- proposalId: "proposal123",
- }
-});
-
-export const Partial = createExample(TestedComponent, {
- applyResult: {
- amountEffectivePaid: 'USD:10',
- amountRefundGone: 'USD:1',
- amountRefundGranted: 'USD:2',
- contractTermsHash: 'QWEASDZXC',
- info: {
- summary: 'tasty cold beer',
- contractTermsHash: 'QWEASDZXC',
- } as Partial<OrderShortInfo> as any,
- pendingAtExchange: false,
- proposalId: "proposal123",
- }
-});
-
-export const InProgress = createExample(TestedComponent, {
- applyResult: {
- amountEffectivePaid: 'USD:10',
- amountRefundGone: 'USD:1',
- amountRefundGranted: 'USD:2',
- contractTermsHash: 'QWEASDZXC',
- info: {
- summary: 'tasty cold beer',
- contractTermsHash: 'QWEASDZXC',
- } as Partial<OrderShortInfo> as any,
- pendingAtExchange: true,
- proposalId: "proposal123",
- }
-});
diff --git a/packages/taler-wallet-webextension/src/cta/Refund.tsx b/packages/taler-wallet-webextension/src/cta/Refund.tsx
deleted file mode 100644
index 943095360..000000000
--- a/packages/taler-wallet-webextension/src/cta/Refund.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- This file is part of TALER
- (C) 2015-2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Page that shows refund status for purchases.
- *
- * @author Florian Dold
- */
-
-import * as wxApi from "../wxApi";
-import { AmountView } from "../renderHtml";
-import {
- ApplyRefundResponse,
- Amounts,
-} from "@gnu-taler/taler-util";
-import { useEffect, useState } from "preact/hooks";
-import { JSX } from "preact/jsx-runtime";
-import { h } from 'preact';
-
-interface Props {
- talerRefundUri?: string
-}
-export interface ViewProps {
- applyResult: ApplyRefundResponse;
-}
-export function View({ applyResult }: ViewProps) {
- return <section class="main">
- <h1>GNU Taler Wallet</h1>
- <article class="fade">
- <h2>Refund Status</h2>
- <p>
- The product <em>{applyResult.info.summary}</em> has received a total
- effective refund of{" "}
- <AmountView amount={applyResult.amountRefundGranted} />.
- </p>
- {applyResult.pendingAtExchange ? (
- <p>Refund processing is still in progress.</p>
- ) : null}
- {!Amounts.isZero(applyResult.amountRefundGone) ? (
- <p>
- The refund amount of{" "}
- <AmountView amount={applyResult.amountRefundGone} />{" "}
- could not be applied.
- </p>
- ) : null}
- </article>
- </section>
-}
-export function RefundPage({ talerRefundUri }: Props): JSX.Element {
- const [applyResult, setApplyResult] = useState<ApplyRefundResponse | undefined>(undefined);
- const [errMsg, setErrMsg] = useState<string | undefined>(undefined);
-
- useEffect(() => {
- if (!talerRefundUri) return;
- const doFetch = async (): Promise<void> => {
- try {
- const result = await wxApi.applyRefund(talerRefundUri);
- setApplyResult(result);
- } catch (e) {
- console.error(e);
- setErrMsg(e.message);
- console.log("err message", e.message);
- }
- };
- doFetch();
- }, [talerRefundUri]);
-
- console.log("rendering");
-
- if (!talerRefundUri) {
- return <span>missing taler refund uri</span>;
- }
-
- if (errMsg) {
- return <span>Error: {errMsg}</span>;
- }
-
- if (!applyResult) {
- return <span>Updating refund status</span>;
- }
-
- return <View applyResult={applyResult} />;
-}
diff --git a/packages/taler-wallet-webextension/src/cta/Refund/index.ts b/packages/taler-wallet-webextension/src/cta/Refund/index.ts
new file mode 100644
index 000000000..42e9cc534
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Refund/index.ts
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountJson, Product } from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { ButtonHandler } from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { IgnoredView, ReadyView } from "./views.js";
+
+export interface Props {
+ talerRefundUri?: string;
+ cancel: () => Promise<void>;
+ onSuccess: (tx: string) => Promise<void>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.Ready
+ // | State.InProgress
+ | State.Ignored;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ interface BaseInfo {
+ merchantName: string;
+ // products: Product[] | undefined;
+ amount: AmountJson;
+ // awaitingAmount: AmountJson;
+ // granted: AmountJson;
+ }
+
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+
+ accept: ButtonHandler;
+ ignore: ButtonHandler;
+ orderId: string;
+ cancel: () => Promise<void>;
+ }
+
+ export interface Ignored extends BaseInfo {
+ status: "ignored";
+ error: undefined;
+ }
+ // export interface InProgress extends BaseInfo {
+ // status: "in-progress";
+ // error: undefined;
+ // }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ // "in-progress": InProgressView,
+ ignored: IgnoredView,
+ ready: ReadyView,
+};
+
+export const RefundPage = compose(
+ "Refund",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/Refund/state.ts b/packages/taler-wallet-webextension/src/cta/Refund/state.ts
new file mode 100644
index 000000000..6f0a98151
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Refund/state.ts
@@ -0,0 +1,141 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ NotificationType,
+ TransactionPayment,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useEffect, useState } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ talerRefundUri,
+ cancel,
+ onSuccess,
+}: Props): State {
+ const api = useBackendContext();
+ const { i18n } = useTranslationContext();
+ const [ignored, setIgnored] = useState(false);
+ const { pushAlertOnError } = useAlertContext();
+
+ const info = useAsyncAsHook(async () => {
+ if (!talerRefundUri) throw Error("ERROR_NO-URI-FOR-REFUND");
+ const refund = await api.wallet.call(
+ WalletApiOperation.StartRefundQueryForUri,
+ {
+ talerRefundUri,
+ },
+ );
+ const purchase = await api.wallet.call(
+ WalletApiOperation.GetTransactionById,
+ {
+ transactionId: refund.transactionId,
+ },
+ );
+ if (purchase.type !== TransactionType.Payment) {
+ throw Error("Refund of non purchase transaction is not handled");
+ }
+ return { refund, purchase, uri: talerRefundUri };
+ });
+
+ useEffect(() =>
+ api.listener.onUpdateNotification(
+ [NotificationType.TransactionStateTransition],
+ info?.retry,
+ ),
+ );
+
+ if (!info) {
+ return { status: "loading", error: undefined };
+ }
+ if (info.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the refund status`,
+ info,
+ ),
+ };
+ }
+ // if (info.hasError) {
+ // return {
+ // status: "loading-uri",
+ // error: info,
+ // };
+ // }
+
+ const { refund, purchase, uri } = info.response;
+
+ const doAccept = async (): Promise<void> => {
+ const res = await api.wallet.call(
+ WalletApiOperation.StartRefundQueryForUri,
+ {
+ talerRefundUri: uri,
+ },
+ );
+
+ onSuccess(res.transactionId);
+ };
+
+ const doIgnore = async (): Promise<void> => {
+ setIgnored(true);
+ };
+
+ const baseInfo = {
+ amount: Amounts.parseOrThrow(purchase.amountEffective),
+ // granted: Amounts.parseOrThrow(info.response.refund.granted),
+ // awaitingAmount: Amounts.parseOrThrow(refund.awaiting),
+ merchantName: purchase.info.merchant.name,
+ // products: purchase.info.products,
+ error: undefined,
+ };
+
+ if (ignored) {
+ return {
+ status: "ignored",
+ ...baseInfo,
+ };
+ }
+
+ //FIXME: DD37 wallet-core is not returning this value
+ // if (refund.pending) {
+ // return {
+ // status: "in-progress",
+ // ...baseInfo,
+ // };
+ // }
+
+ return {
+ status: "ready",
+ ...baseInfo,
+ orderId: purchase.info.orderId,
+ accept: {
+ onClick: pushAlertOnError(doAccept),
+ },
+ ignore: {
+ onClick: pushAlertOnError(doIgnore),
+ },
+ cancel,
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Refund/stories.tsx b/packages/taler-wallet-webextension/src/cta/Refund/stories.tsx
new file mode 100644
index 000000000..03d55ee91
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Refund/stories.tsx
@@ -0,0 +1,82 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import beer from "../../../static-dev/beer.png";
+import * as tests from "@gnu-taler/web-util/testing";
+import { IgnoredView, ReadyView } from "./views.js";
+export default {
+ title: "refund",
+};
+
+// export const InProgress = tests.createExample(InProgressView, {
+// status: "in-progress",
+// error: undefined,
+// amount: Amounts.parseOrThrow("USD:1"),
+// awaitingAmount: Amounts.parseOrThrow("USD:1"),
+// granted: Amounts.parseOrThrow("USD:0"),
+// merchantName: "the merchant",
+// products: undefined,
+// });
+
+export const Ready = tests.createExample(ReadyView, {
+ status: "ready",
+ error: undefined,
+ accept: {},
+ ignore: {},
+
+ amount: Amounts.parseOrThrow("USD:1"),
+ // awaitingAmount: Amounts.parseOrThrow("USD:1"),
+ // granted: Amounts.parseOrThrow("USD:0"),
+ merchantName: "the merchant",
+ // products: [],
+ orderId: "abcdef",
+});
+
+export const WithAProductList = tests.createExample(ReadyView, {
+ status: "ready",
+ error: undefined,
+ accept: {},
+ ignore: {},
+ amount: Amounts.parseOrThrow("USD:1"),
+ // awaitingAmount: Amounts.parseOrThrow("USD:1"),
+ // granted: Amounts.parseOrThrow("USD:0"),
+ merchantName: "the merchant",
+ // products: [
+ // {
+ // description: "beer",
+ // image: beer,
+ // quantity: 2,
+ // },
+ // {
+ // description: "t-shirt",
+ // price: "EUR:1",
+ // quantity: 5,
+ // },
+ // ],
+ orderId: "abcdef",
+});
+
+export const Ignored = tests.createExample(IgnoredView, {
+ status: "ignored",
+ error: undefined,
+ merchantName: "the merchant",
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Refund/test.ts b/packages/taler-wallet-webextension/src/cta/Refund/test.ts
new file mode 100644
index 000000000..bc0e61fcb
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Refund/test.ts
@@ -0,0 +1,287 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ Amounts,
+ NotificationType,
+ OrderShortInfo,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { expect } from "chai";
+import * as tests from "@gnu-taler/web-util/testing";
+import { nullFunction } from "../../mui/handlers.js";
+import { createWalletApiMock } from "../../test-utils.js";
+import { useComponentState } from "./state.js";
+
+/**
+ * Commenting this tests out since the behavior
+ */
+
+describe("Refund CTA states", () => {
+ // it("should tell the user that the URI is missing", async () => {
+ // const { handler, TestingContext } = createWalletApiMock();
+ // const props = {
+ // talerRefundUri: undefined,
+ // cancel: nullFunction,
+ // onSuccess: nullFunction,
+ // };
+ // const hookBehavior = await tests.hookBehaveLikeThis(
+ // useComponentState,
+ // props,
+ // [
+ // ({ status, error }) => {
+ // expect(status).equals("loading");
+ // expect(error).undefined;
+ // },
+ // ({ status, error }) => {
+ // expect(status).equals("error");
+ // if (!error) expect.fail();
+ // // if (!error.hasError) expect.fail();
+ // // if (error.operational) expect.fail();
+ // expect(error.description).eq("ERROR_NO-URI-FOR-REFUND");
+ // },
+ // ],
+ // TestingContext,
+ // );
+ // expect(hookBehavior).deep.equal({ result: "ok" });
+ // expect(handler.getCallingQueueState()).eq("empty");
+ // });
+ // it("should be ready after loading", async () => {
+ // const { handler, TestingContext } = createWalletApiMock();
+ // const props = {
+ // talerRefundUri: "taler://refund/asdasdas",
+ // cancel: nullFunction,
+ // onSuccess: nullFunction,
+ // };
+ // handler.addWalletCallResponse(
+ // WalletApiOperation.StartRefundQueryForUri,
+ // undefined,
+ // {
+ // // awaiting: "EUR:2",
+ // // effectivePaid: "EUR:2",
+ // // gone: "EUR:0",
+ // // granted: "EUR:0",
+ // // pending: false,
+ // // proposalId: "1",
+ // // info: {
+ // // contractTermsHash: "123",
+ // // merchant: {
+ // // name: "the merchant name",
+ // // },
+ // // orderId: "orderId1",
+ // // summary: "the summary",
+ // // } as OrderShortInfo,
+ // },
+ // );
+ // const hookBehavior = await tests.hookBehaveLikeThis(
+ // useComponentState,
+ // props,
+ // [
+ // ({ status, error }) => {
+ // expect(status).equals("loading");
+ // expect(error).undefined;
+ // },
+ // (state) => {
+ // if (state.status !== "ready") expect.fail();
+ // if (state.error) expect.fail();
+ // expect(state.accept.onClick).not.undefined;
+ // expect(state.ignore.onClick).not.undefined;
+ // expect(state.merchantName).eq("the merchant name");
+ // expect(state.orderId).eq("orderId1");
+ // expect(state.products).undefined;
+ // },
+ // ],
+ // TestingContext,
+ // );
+ // expect(hookBehavior).deep.equal({ result: "ok" });
+ // expect(handler.getCallingQueueState()).eq("empty");
+ // });
+ // it("should be ignored after clicking the ignore button", async () => {
+ // const { handler, TestingContext } = createWalletApiMock();
+ // const props = {
+ // talerRefundUri: "taler://refund/asdasdas",
+ // cancel: async () => {
+ // null;
+ // },
+ // onSuccess: async () => {
+ // null;
+ // },
+ // };
+ // handler.addWalletCallResponse(
+ // WalletApiOperation.StartRefundQueryForUri,
+ // undefined,
+ // {
+ // // awaiting: "EUR:2",
+ // // effectivePaid: "EUR:2",
+ // // gone: "EUR:0",
+ // // granted: "EUR:0",
+ // // pending: false,
+ // // proposalId: "1",
+ // // info: {
+ // // contractTermsHash: "123",
+ // // merchant: {
+ // // name: "the merchant name",
+ // // },
+ // // orderId: "orderId1",
+ // // summary: "the summary",
+ // // } as OrderShortInfo,
+ // },
+ // );
+ // const hookBehavior = await tests.hookBehaveLikeThis(
+ // useComponentState,
+ // props,
+ // [
+ // ({ status, error }) => {
+ // expect(status).equals("loading");
+ // expect(error).undefined;
+ // },
+ // (state) => {
+ // if (state.status !== "ready") expect.fail();
+ // if (state.error) expect.fail();
+ // expect(state.accept.onClick).not.undefined;
+ // expect(state.merchantName).eq("the merchant name");
+ // expect(state.orderId).eq("orderId1");
+ // expect(state.products).undefined;
+ // if (state.ignore.onClick === undefined) expect.fail();
+ // state.ignore.onClick();
+ // },
+ // (state) => {
+ // if (state.status !== "ignored") expect.fail();
+ // if (state.error) expect.fail();
+ // expect(state.merchantName).eq("the merchant name");
+ // },
+ // ],
+ // TestingContext,
+ // );
+ // expect(hookBehavior).deep.equal({ result: "ok" });
+ // expect(handler.getCallingQueueState()).eq("empty");
+ // });
+ // it("should be in progress when doing refresh", async () => {
+ // const { handler, TestingContext } = createWalletApiMock();
+ // const props = {
+ // talerRefundUri: "taler://refund/asdasdas",
+ // cancel: async () => {
+ // null;
+ // },
+ // onSuccess: async () => {
+ // null;
+ // },
+ // };
+ // handler.addWalletCallResponse(
+ // WalletApiOperation.StartRefundQueryForUri,
+ // undefined,
+ // {
+ // // awaiting: "EUR:2",
+ // // effectivePaid: "EUR:2",
+ // // gone: "EUR:0",
+ // // granted: "EUR:0",
+ // // pending: true,
+ // // proposalId: "1",
+ // // info: {
+ // // contractTermsHash: "123",
+ // // merchant: {
+ // // name: "the merchant name",
+ // // },
+ // // orderId: "orderId1",
+ // // summary: "the summary",
+ // // } as OrderShortInfo,
+ // },
+ // );
+ // handler.addWalletCallResponse(
+ // WalletApiOperation.StartRefundQueryForUri,
+ // undefined,
+ // {
+ // // awaiting: "EUR:1",
+ // // effectivePaid: "EUR:2",
+ // // gone: "EUR:0",
+ // // granted: "EUR:1",
+ // // pending: true,
+ // // proposalId: "1",
+ // // info: {
+ // // contractTermsHash: "123",
+ // // merchant: {
+ // // name: "the merchant name",
+ // // },
+ // // orderId: "orderId1",
+ // // summary: "the summary",
+ // // } as OrderShortInfo,
+ // },
+ // );
+ // handler.addWalletCallResponse(
+ // WalletApiOperation.StartRefundQueryForUri,
+ // undefined,
+ // {
+ // // awaiting: "EUR:0",
+ // // effectivePaid: "EUR:2",
+ // // gone: "EUR:0",
+ // // granted: "EUR:2",
+ // // pending: false,
+ // // proposalId: "1",
+ // // info: {
+ // // contractTermsHash: "123",
+ // // merchant: {
+ // // name: "the merchant name",
+ // // },
+ // // orderId: "orderId1",
+ // // summary: "the summary",
+ // // } as OrderShortInfo,
+ // },
+ // );
+ // const hookBehavior = await tests.hookBehaveLikeThis(
+ // useComponentState,
+ // props,
+ // [
+ // ({ status, error }) => {
+ // expect(status).equals("loading");
+ // expect(error).undefined;
+ // },
+ // (state) => {
+ // if (state.status !== "in-progress") expect.fail();
+ // if (state.error) expect.fail();
+ // expect(state.merchantName).eq("the merchant name");
+ // expect(state.products).undefined;
+ // expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2"));
+ // // expect(state.progress).closeTo(1 / 3, 0.01)
+ // handler.notifyEventFromWallet(NotificationType.TransactionStateTransition);
+ // },
+ // (state) => {
+ // if (state.status !== "in-progress") expect.fail();
+ // if (state.error) expect.fail();
+ // expect(state.merchantName).eq("the merchant name");
+ // expect(state.products).undefined;
+ // expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2"));
+ // // expect(state.progress).closeTo(2 / 3, 0.01)
+ // handler.notifyEventFromWallet(NotificationType.TransactionStateTransition);
+ // },
+ // (state) => {
+ // if (state.status !== "ready") expect.fail();
+ // if (state.error) expect.fail();
+ // expect(state.merchantName).eq("the merchant name");
+ // expect(state.products).undefined;
+ // expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2"));
+ // },
+ // ],
+ // TestingContext,
+ // );
+ // expect(hookBehavior).deep.equal({ result: "ok" });
+ // expect(handler.getCallingQueueState()).eq("empty");
+ // });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Refund/views.tsx b/packages/taler-wallet-webextension/src/cta/Refund/views.tsx
new file mode 100644
index 000000000..ae4d728f3
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Refund/views.tsx
@@ -0,0 +1,123 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { Amount } from "../../components/Amount.js";
+import { Part } from "../../components/Part.js";
+import { Button } from "../../mui/Button.js";
+import { State } from "./index.js";
+import { TermsOfService } from "../../components/TermsOfService/index.js";
+
+export function IgnoredView(state: State.Ignored): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <section>
+ <p>
+ <i18n.Translate>You&apos;ve ignored the refund.</i18n.Translate>
+ </p>
+ </section>
+ </Fragment>
+ );
+}
+// export function InProgressView(state: State.InProgress): VNode {
+// const { i18n } = useTranslationContext();
+
+// return (
+// <Fragment>
+// <section>
+// <p>
+// <i18n.Translate>The refund is in progress.</i18n.Translate>
+// </p>
+// </section>
+// <section>
+// <Part
+// big
+// title={i18n.str`Total to refund`}
+// text={<Amount value={state.awaitingAmount} />}
+// kind="negative"
+// />
+// <Part
+// big
+// title={i18n.str`Refunded`}
+// text={<Amount value={state.amount} />}
+// kind="negative"
+// />
+// </section>
+// {state.products && state.products.length ? (
+// <section>
+// <ProductList products={state.products} />
+// </section>
+// ) : undefined}
+// </Fragment>
+// );
+// }
+export function ReadyView(state: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <section>
+ <p>
+ <i18n.Translate>
+ The merchant &quot;<b>{state.merchantName}</b>&quot; is offering you
+ a refund.
+ </i18n.Translate>
+ </p>
+ </section>
+ <section>
+ <Part
+ big
+ title={i18n.str`Order amount`}
+ text={<Amount value={state.amount} />}
+ kind="neutral"
+ />
+ {/* {Amounts.isNonZero(state.granted) && (
+ <Part
+ big
+ title={i18n.str`Already refunded`}
+ text={<Amount value={state.granted} />}
+ kind="neutral"
+ />
+ )}
+ <Part
+ big
+ title={i18n.str`Refund offered (without fee)`}
+ text={<Amount value={state.awaitingAmount} />}
+ kind="positive"
+ /> */}
+ </section>
+ {/* {state.products && state.products.length ? (
+ <section>
+ <ProductList products={state.products} />
+ </section>
+ ) : undefined} */}
+ <section>
+ <Button
+ variant="contained"
+ color="success"
+ onClick={state.accept.onClick}
+ >
+ <i18n.Translate>
+ {/* Accept &nbsp; <Amount value={state.awaitingAmount} /> */}
+ Accept
+ </i18n.Translate>
+ </Button>
+ </section>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx b/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx
deleted file mode 100644
index 389b183f0..000000000
--- a/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createExample } from '../test-utils';
-import { View as TestedComponent } from './Tip';
-
-
-export default {
- title: 'cta/tip',
- component: TestedComponent,
- argTypes: {
- },
-};
-
-export const Accepted = createExample(TestedComponent, {
- prepareTipResult: {
- accepted: true,
- merchantBaseUrl: '',
- exchangeBaseUrl: '',
- expirationTimestamp : {
- t_ms: 0
- },
- tipAmountEffective: 'USD:10',
- tipAmountRaw: 'USD:5',
- walletTipId: 'id'
- }
-});
-
-export const NotYetAccepted = createExample(TestedComponent, {
- prepareTipResult: {
- accepted: false,
- merchantBaseUrl: 'http://merchant.url/',
- exchangeBaseUrl: 'http://exchange.url/',
- expirationTimestamp : {
- t_ms: 0
- },
- tipAmountEffective: 'USD:10',
- tipAmountRaw: 'USD:5',
- walletTipId: 'id'
- }
-});
diff --git a/packages/taler-wallet-webextension/src/cta/Tip.tsx b/packages/taler-wallet-webextension/src/cta/Tip.tsx
deleted file mode 100644
index dc1feaed3..000000000
--- a/packages/taler-wallet-webextension/src/cta/Tip.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-/*
- This file is part of TALER
- (C) 2017 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Page shown to the user to accept or ignore a tip from a merchant.
- *
- * @author Florian Dold <dold@taler.net>
- */
-
-import { useEffect, useState } from "preact/hooks";
-import { PrepareTipResult } from "@gnu-taler/taler-util";
-import { AmountView } from "../renderHtml";
-import * as wxApi from "../wxApi";
-import { JSX } from "preact/jsx-runtime";
-import { h } from 'preact';
-
-interface Props {
- talerTipUri?: string
-}
-export interface ViewProps {
- prepareTipResult: PrepareTipResult;
- onAccept: () => void;
- onIgnore: () => void;
-
-}
-export function View({ prepareTipResult, onAccept, onIgnore }: ViewProps) {
- return <section class="main">
- <h1>GNU Taler Wallet</h1>
- <article class="fade">
- {prepareTipResult.accepted ? (
- <span>
- Tip from <code>{prepareTipResult.merchantBaseUrl}</code> accepted. Check
- your transactions list for more details.
- </span>
- ) : (
- <div>
- <p>
- The merchant <code>{prepareTipResult.merchantBaseUrl}</code> is
- offering you a tip of{" "}
- <strong>
- <AmountView amount={prepareTipResult.tipAmountEffective} />
- </strong>{" "}
- via the exchange <code>{prepareTipResult.exchangeBaseUrl}</code>
- </p>
- <button onClick={onAccept}>Accept tip</button>
- <button onClick={onIgnore}>Ignore</button>
- </div>
- )}
- </article>
- </section>
-
-}
-
-export function TipPage({ talerTipUri }: Props): JSX.Element {
- const [updateCounter, setUpdateCounter] = useState<number>(0);
- const [prepareTipResult, setPrepareTipResult] = useState<
- PrepareTipResult | undefined
- >(undefined);
-
- const [tipIgnored, setTipIgnored] = useState(false);
-
- useEffect(() => {
- if (!talerTipUri) return;
- const doFetch = async (): Promise<void> => {
- const p = await wxApi.prepareTip({ talerTipUri });
- setPrepareTipResult(p);
- };
- doFetch();
- }, [talerTipUri, updateCounter]);
-
- const doAccept = async () => {
- if (!prepareTipResult) {
- return;
- }
- await wxApi.acceptTip({ walletTipId: prepareTipResult?.walletTipId });
- setUpdateCounter(updateCounter + 1);
- };
-
- const doIgnore = () => {
- setTipIgnored(true);
- };
-
- if (!talerTipUri) {
- return <span>missing tip uri</span>;
- }
-
- if (tipIgnored) {
- return <span>You've ignored the tip.</span>;
- }
-
- if (!prepareTipResult) {
- return <span>Loading ...</span>;
- }
-
- return <View prepareTipResult={prepareTipResult}
- onAccept={doAccept} onIgnore={doIgnore}
- />
-}
diff --git a/packages/taler-wallet-webextension/src/cta/TransferCreate/index.ts b/packages/taler-wallet-webextension/src/cta/TransferCreate/index.ts
new file mode 100644
index 000000000..794d2ad1c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/TransferCreate/index.ts
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountJson, AmountString, TalerErrorDetail } from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { ButtonHandler, TextFieldHandler } from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+
+export interface Props {
+ amount: AmountString;
+ onClose: () => Promise<void>;
+ onSuccess: (tx: string) => Promise<void>;
+}
+
+export type State = State.Loading | State.LoadingUriError | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ cancel: ButtonHandler;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ create: ButtonHandler;
+ toBeReceived: AmountJson;
+ debitAmount: AmountJson;
+ subject: TextFieldHandler;
+ expiration: TextFieldHandler;
+ error: undefined;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ ready: ReadyView,
+};
+
+export const TransferCreatePage = compose(
+ "TransferCreatePage",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts b/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts
new file mode 100644
index 000000000..f092801ed
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts
@@ -0,0 +1,185 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountString,
+ Amounts,
+ TalerErrorCode,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { isFuture, parse } from "date-fns";
+import { useState } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { BackgroundError, WxApiType } from "../../wxApi.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ amount: amountStr,
+ onClose,
+ onSuccess,
+}: Props): State {
+ const api = useBackendContext();
+ const { pushAlertOnError } = useAlertContext();
+ const amount = Amounts.parseOrThrow(amountStr);
+ const { i18n } = useTranslationContext();
+
+ const [subject, setSubject] = useState<string | undefined>();
+ const [timestamp, setTimestamp] = useState<string | undefined>();
+
+ const hook = useAsyncAsHook(async () => {
+ const resp = await checkPeerPushDebitAndCheckMax(api, amountStr);
+ return resp;
+ });
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the max amount to transfer`,
+ hook,
+ ),
+ };
+ }
+
+ const { amountEffective, amountRaw } = hook.response;
+ const debitAmount = Amounts.parseOrThrow(amountEffective);
+ const toBeReceived = Amounts.parseOrThrow(amountRaw);
+
+ let purse_expiration: TalerProtocolTimestamp | undefined = undefined;
+ let timestampError: string | undefined = undefined;
+
+ const t =
+ timestamp === undefined
+ ? undefined
+ : parse(timestamp, "dd/MM/yyyy", new Date());
+
+ if (t !== undefined) {
+ if (Number.isNaN(t.getTime())) {
+ timestampError = 'Should have the format "dd/MM/yyyy"';
+ } else {
+ if (!isFuture(t)) {
+ timestampError = "Should be in the future";
+ } else {
+ purse_expiration = {
+ t_s: t.getTime() / 1000,
+ };
+ }
+ }
+ }
+
+ async function accept(): Promise<void> {
+ if (!subject || !purse_expiration) return;
+ const resp = await api.wallet.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: subject,
+ amount: amountStr,
+ purse_expiration,
+ },
+ },
+ );
+ onSuccess(resp.transactionId);
+ }
+
+ const unableToCreate =
+ !subject || Amounts.isZero(amount) || !purse_expiration;
+
+ return {
+ status: "ready",
+ cancel: {
+ onClick: pushAlertOnError(onClose),
+ },
+ subject: {
+ error:
+ subject === undefined
+ ? undefined
+ : !subject
+ ? "Can't be empty"
+ : undefined,
+ value: subject ?? "",
+ onInput: pushAlertOnError(async (e) => setSubject(e)),
+ },
+ expiration: {
+ error: timestampError,
+ value: timestamp === undefined ? "" : timestamp,
+ onInput: pushAlertOnError(async (e) => {
+ setTimestamp(e);
+ }),
+ },
+ create: {
+ onClick: unableToCreate ? undefined : pushAlertOnError(accept),
+ },
+ debitAmount,
+ toBeReceived,
+ error: undefined,
+ };
+}
+
+async function checkPeerPushDebitAndCheckMax(
+ api: WxApiType,
+ amountState: AmountString,
+) {
+ // FIXME : https://bugs.gnunet.org/view.php?id=7872
+ try {
+ return await api.wallet.call(WalletApiOperation.CheckPeerPushDebit, {
+ amount: amountState,
+ });
+ } catch (e) {
+ if (!(e instanceof BackgroundError)) {
+ throw e;
+ }
+ if (
+ !e.hasErrorCode(
+ TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
+ )
+ ) {
+ throw e;
+ }
+ const material = Amounts.parseOrThrow(
+ e.errorDetail.insufficientBalanceDetails.balanceMaterial,
+ );
+ const amount = Amounts.parseOrThrow(amountState);
+ const gap = Amounts.sub(
+ amount,
+ Amounts.parseOrThrow(
+ e.errorDetail.insufficientBalanceDetails.maxEffectiveSpendAmount,
+ ),
+ ).amount;
+ const newAmount = Amounts.sub(material, gap).amount;
+ if (Amounts.cmp(newAmount, amount) === 0) {
+ //insufficient balance and the exception didn't give
+ //a good response that allow us to try again
+ throw e;
+ }
+ if (Amounts.cmp(newAmount, amount) === 1) {
+ //how can this happen?
+ throw e;
+ }
+ return checkPeerPushDebitAndCheckMax(api, Amounts.stringify(newAmount));
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/cta/TransferCreate/stories.tsx b/packages/taler-wallet-webextension/src/cta/TransferCreate/stories.tsx
new file mode 100644
index 000000000..8e9fbbe63
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/TransferCreate/stories.tsx
@@ -0,0 +1,50 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { nullFunction } from "../../mui/handlers.js";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "transfer create",
+};
+
+export const Ready = tests.createExample(ReadyView, {
+ debitAmount: {
+ currency: "ARS",
+ value: 1,
+ fraction: 0,
+ },
+ expiration: {
+ value: "20/1/2022",
+ },
+ create: {},
+ cancel: {},
+ toBeReceived: {
+ currency: "ARS",
+ value: 1,
+ fraction: 0,
+ },
+ subject: {
+ value: "the subject",
+ onInput: nullFunction,
+ },
+});
diff --git a/packages/taler-wallet-webextension/src/cta/TransferCreate/test.ts b/packages/taler-wallet-webextension/src/cta/TransferCreate/test.ts
new file mode 100644
index 000000000..be753e492
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/TransferCreate/test.ts
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+
+describe("Transfer create states", () => {
+ it.skip("should assert", () => {
+ expect([]).deep.equals([]);
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/TransferCreate/views.tsx b/packages/taler-wallet-webextension/src/cta/TransferCreate/views.tsx
new file mode 100644
index 000000000..bc855f33d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/TransferCreate/views.tsx
@@ -0,0 +1,125 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, h, VNode } from "preact";
+import { Part } from "../../components/Part.js";
+import { Button } from "../../mui/Button.js";
+import { TextField } from "../../mui/TextField.js";
+import {
+ getAmountWithFee,
+ TransferCreationDetails,
+} from "../../wallet/Transaction.js";
+import { State } from "./index.js";
+
+export function ReadyView({
+ subject,
+ expiration,
+ toBeReceived,
+ debitAmount,
+ create,
+}: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+
+ async function oneDayExpiration() {
+ if (expiration.onInput) {
+ expiration.onInput(
+ format(new Date().getTime() + 1000 * 60 * 60 * 24, "dd/MM/yyyy"),
+ );
+ }
+ }
+
+ async function oneWeekExpiration() {
+ if (expiration.onInput) {
+ expiration.onInput(
+ format(new Date().getTime() + 1000 * 60 * 60 * 24 * 7, "dd/MM/yyyy"),
+ );
+ }
+ }
+ async function _30DaysExpiration() {
+ if (expiration.onInput) {
+ expiration.onInput(
+ format(new Date().getTime() + 1000 * 60 * 60 * 24 * 30, "dd/MM/yyyy"),
+ );
+ }
+ }
+ return (
+ <Fragment>
+ <section style={{ textAlign: "left" }}>
+ <p>
+ <TextField
+ label="Subject"
+ variant="filled"
+ helperText={i18n.str`Short description of the transfer`}
+ error={subject.error}
+ required
+ fullWidth
+ value={subject.value}
+ onChange={subject.onInput}
+ />
+ </p>
+ <p>
+ <TextField
+ label="Expiration"
+ variant="filled"
+ error={expiration.error}
+ required
+ fullWidth
+ value={expiration.value}
+ onChange={expiration.onInput}
+ />
+ <p>
+ <Button
+ variant="outlined"
+ disabled={!expiration.onInput}
+ onClick={oneDayExpiration}
+ >
+ 1 day
+ </Button>
+ <Button
+ variant="outlined"
+ disabled={!expiration.onInput}
+ onClick={oneWeekExpiration}
+ >
+ 1 week
+ </Button>
+ <Button
+ variant="outlined"
+ disabled={!expiration.onInput}
+ onClick={_30DaysExpiration}
+ >
+ 30 days
+ </Button>
+ </p>
+ </p>
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <TransferCreationDetails
+ amount={getAmountWithFee(debitAmount, toBeReceived, "debit")}
+ />
+ }
+ />
+ </section>
+ <section>
+ <Button onClick={create.onClick} variant="contained" color="success">
+ <i18n.Translate>Create</i18n.Translate>
+ </Button>
+ </section>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/cta/TransferPickup/index.ts b/packages/taler-wallet-webextension/src/cta/TransferPickup/index.ts
new file mode 100644
index 000000000..4e1301d6a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/TransferPickup/index.ts
@@ -0,0 +1,75 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ TalerErrorDetail,
+} from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { ButtonHandler } from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+
+export interface Props {
+ talerPayPushUri: string;
+ onClose: () => Promise<void>;
+ onSuccess: (tx: string) => Promise<void>;
+}
+
+export type State = State.Loading | State.LoadingUriError | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ cancel: ButtonHandler;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ effective: AmountJson;
+ exchangeBaseUrl: string;
+ raw: AmountJson;
+ summary: string | undefined;
+ expiration: AbsoluteTime | undefined;
+ error: undefined;
+ accept: ButtonHandler;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ ready: ReadyView,
+};
+
+export const TransferPickupPage = compose(
+ "TransferPickupPage",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts b/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts
new file mode 100644
index 000000000..67f6d9113
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts
@@ -0,0 +1,99 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ talerPayPushUri,
+ onClose,
+ onSuccess,
+}: Props): State {
+ const api = useBackendContext();
+ const { pushAlertOnError } = useAlertContext();
+ const { i18n } = useTranslationContext();
+ const hook = useAsyncAsHook(async () => {
+ return await api.wallet.call(WalletApiOperation.PreparePeerPushCredit, {
+ talerUri: talerPayPushUri,
+ });
+ }, []);
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the invoice payment status`,
+ hook,
+ ),
+ };
+ }
+
+ const {
+ contractTerms,
+ transactionId,
+ amountEffective,
+ amountRaw,
+ exchangeBaseUrl,
+ } = hook.response;
+
+ const effective = Amounts.parseOrThrow(amountEffective);
+ const raw = Amounts.parseOrThrow(amountRaw);
+ const summary: string = contractTerms.summary;
+ const expiration: TalerProtocolTimestamp = contractTerms.purse_expiration;
+
+ async function accept(): Promise<void> {
+ const resp = await api.wallet.call(
+ WalletApiOperation.ConfirmPeerPushCredit,
+ {
+ transactionId,
+ },
+ );
+ onSuccess(resp.transactionId);
+ }
+ return {
+ status: "ready",
+ effective,
+ exchangeBaseUrl,
+ raw,
+ error: undefined,
+ accept: {
+ onClick: pushAlertOnError(accept),
+ },
+ summary,
+ expiration: expiration
+ ? AbsoluteTime.fromProtocolTimestamp(expiration)
+ : undefined,
+ cancel: {
+ onClick: pushAlertOnError(onClose),
+ },
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/cta/TransferPickup/stories.tsx b/packages/taler-wallet-webextension/src/cta/TransferPickup/stories.tsx
new file mode 100644
index 000000000..4fb230cd9
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/TransferPickup/stories.tsx
@@ -0,0 +1,47 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+import { AbsoluteTime } from "@gnu-taler/taler-util";
+
+export default {
+ title: "transfer pickup",
+};
+
+export const Ready = tests.createExample(ReadyView, {
+ effective: {
+ currency: "ARS",
+ value: 1,
+ fraction: 0,
+ },
+ raw: {
+ currency: "ARS",
+ value: 1,
+ fraction: 0,
+ },
+ summary: "some subject",
+ expiration: AbsoluteTime.fromMilliseconds(
+ new Date().getTime() + 1000 * 60 * 60,
+ ),
+ accept: {},
+ cancel: {},
+});
diff --git a/packages/taler-wallet-webextension/src/cta/TransferPickup/test.ts b/packages/taler-wallet-webextension/src/cta/TransferPickup/test.ts
new file mode 100644
index 000000000..fa5b6979a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/TransferPickup/test.ts
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+
+describe("Transfer pickup states", () => {
+ it.skip("should assert", () => {
+ expect([]).deep.equals([]);
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx b/packages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx
new file mode 100644
index 000000000..caa1b485a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { Amount } from "../../components/Amount.js";
+import { Part } from "../../components/Part.js";
+import { Time } from "../../components/Time.js";
+import { Button } from "../../mui/Button.js";
+import {
+ getAmountWithFee,
+ TransferPickupDetails,
+} from "../../wallet/Transaction.js";
+import { State } from "./index.js";
+import { TermsOfService } from "../../components/TermsOfService/index.js";
+
+export function ReadyView({
+ accept,
+ summary,
+ expiration,
+ effective,
+ exchangeBaseUrl,
+ raw,
+}: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <section style={{ textAlign: "left" }}>
+ <Part title={i18n.str`Subject`} text={<div>{summary}</div>} />
+ <Part title={i18n.str`Amount`} text={<Amount value={raw} />} />
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <TransferPickupDetails
+ amount={getAmountWithFee(effective, raw, "credit")}
+ />
+ }
+ />
+
+ <Part
+ title={i18n.str`Valid until`}
+ text={<Time timestamp={expiration} format="dd MMMM yyyy, HH:mm" />}
+ kind="neutral"
+ />
+ </section>
+ <section>
+ <TermsOfService key="terms" exchangeUrl={exchangeBaseUrl} >
+ <Button variant="contained" color="success" onClick={accept.onClick}>
+ <i18n.Translate>
+ Receive &nbsp; {<Amount value={effective} />}
+ </i18n.Translate>
+ </Button>
+ </TermsOfService>
+ </section>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx
deleted file mode 100644
index 6ef72cbe6..000000000
--- a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx
+++ /dev/null
@@ -1,383 +0,0 @@
-/*
- This file is part of TALER
- (C) 2015-2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Page shown to the user to confirm creation
- * of a reserve, usually requested by the bank.
- *
- * @author Florian Dold
- */
-
-import { AmountJson, Amounts, ExchangeListItem, GetExchangeTosResult, i18n, WithdrawUriInfoResponse } from '@gnu-taler/taler-util';
-import { ExchangeWithdrawDetails } from '@gnu-taler/taler-wallet-core/src/operations/withdraw';
-import { useState } from "preact/hooks";
-import { Fragment } from 'preact/jsx-runtime';
-import { CheckboxOutlined } from '../components/CheckboxOutlined';
-import { ExchangeXmlTos } from '../components/ExchangeToS';
-import { LogoHeader } from '../components/LogoHeader';
-import { Part } from '../components/Part';
-import { SelectList } from '../components/SelectList';
-import { ButtonSuccess, ButtonWarning, LinkSuccess, LinkWarning, TermsOfService, WalletAction, WarningText } from '../components/styled';
-import { useAsyncAsHook } from '../hooks/useAsyncAsHook';
-import {
- acceptWithdrawal, getExchangeWithdrawalInfo, getWithdrawalDetailsForUri, setExchangeTosAccepted, listExchanges, getExchangeTos
-} from "../wxApi";
-import { wxMain } from '../wxBackend.js';
-
-interface Props {
- talerWithdrawUri?: string;
-}
-
-export interface ViewProps {
- details: GetExchangeTosResult;
- withdrawalFee: AmountJson;
- exchangeBaseUrl: string;
- amount: AmountJson;
- onSwitchExchange: (ex: string) => void;
- onWithdraw: () => Promise<void>;
- onReview: (b: boolean) => void;
- onAccept: (b: boolean) => void;
- reviewing: boolean;
- reviewed: boolean;
- confirmed: boolean;
- terms: {
- value?: TermsDocument;
- status: TermsStatus;
- };
- knownExchanges: ExchangeListItem[];
-
-};
-
-type TermsStatus = 'new' | 'accepted' | 'changed' | 'notfound';
-
-type TermsDocument = TermsDocumentXml | TermsDocumentHtml | TermsDocumentPlain | TermsDocumentJson | TermsDocumentPdf;
-
-interface TermsDocumentXml {
- type: 'xml',
- document: Document,
-}
-
-interface TermsDocumentHtml {
- type: 'html',
- href: URL,
-}
-
-interface TermsDocumentPlain {
- type: 'plain',
- content: string,
-}
-
-interface TermsDocumentJson {
- type: 'json',
- data: any,
-}
-
-interface TermsDocumentPdf {
- type: 'pdf',
- location: URL,
-}
-
-function amountToString(text: AmountJson) {
- const aj = Amounts.jsonifyAmount(text)
- const amount = Amounts.stringifyValue(aj)
- return `${amount} ${aj.currency}`
-}
-
-export function View({ details, withdrawalFee, exchangeBaseUrl, knownExchanges, amount, onWithdraw, onSwitchExchange, terms, reviewing, onReview, onAccept, reviewed, confirmed }: ViewProps) {
- const needsReview = terms.status === 'changed' || terms.status === 'new'
-
- const [switchingExchange, setSwitchingExchange] = useState<string | undefined>(undefined)
- const exchanges = knownExchanges.reduce((prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }), {})
-
- return (
- <WalletAction>
- <LogoHeader />
- <h2>
- {i18n.str`Digital cash withdrawal`}
- </h2>
- <section>
- <Part title="Total to withdraw" text={amountToString(Amounts.sub(amount, withdrawalFee).amount)} kind='positive' />
- <Part title="Chosen amount" text={amountToString(amount)} kind='neutral' />
- {Amounts.isNonZero(withdrawalFee) &&
- <Part title="Exchange fee" text={amountToString(withdrawalFee)} kind='negative' />
- }
- <Part title="Exchange" text={exchangeBaseUrl} kind='neutral' big />
- </section>
- {!reviewing &&
- <section>
- {switchingExchange !== undefined ? <Fragment>
- <div>
- <SelectList label="Known exchanges" list={exchanges} name="" onChange={onSwitchExchange} />
- </div>
- <LinkSuccess upperCased onClick={() => onSwitchExchange(switchingExchange)}>
- {i18n.str`Confirm exchange selection`}
- </LinkSuccess>
- </Fragment>
- : <LinkSuccess upperCased onClick={() => setSwitchingExchange("")}>
- {i18n.str`Switch exchange`}
- </LinkSuccess>}
-
- </section>
- }
- {!reviewing && reviewed &&
- <section>
- <LinkSuccess
- upperCased
- onClick={() => onReview(true)}
- >
- {i18n.str`Show terms of service`}
- </LinkSuccess>
- </section>
- }
- {terms.status === 'notfound' &&
- <section>
- <WarningText>
- {i18n.str`Exchange doesn't have terms of service`}
- </WarningText>
- </section>
- }
- {reviewing &&
- <section>
- {terms.status !== 'accepted' && terms.value && terms.value.type === 'xml' &&
- <TermsOfService>
- <ExchangeXmlTos doc={terms.value.document} />
- </TermsOfService>
- }
- {terms.status !== 'accepted' && terms.value && terms.value.type === 'plain' &&
- <div style={{ textAlign: 'left' }}>
- <pre>{terms.value.content}</pre>
- </div>
- }
- {terms.status !== 'accepted' && terms.value && terms.value.type === 'html' &&
- <iframe src={terms.value.href.toString()} />
- }
- {terms.status !== 'accepted' && terms.value && terms.value.type === 'pdf' &&
- <a href={terms.value.location.toString()} download="tos.pdf" >Download Terms of Service</a>
- }
- </section>}
- {reviewing && reviewed &&
- <section>
- <LinkSuccess
- upperCased
- onClick={() => onReview(false)}
- >
- {i18n.str`Hide terms of service`}
- </LinkSuccess>
- </section>
- }
- {(reviewing || reviewed) &&
- <section>
- <CheckboxOutlined
- name="terms"
- enabled={reviewed}
- label={i18n.str`I accept the exchange terms of service`}
- onToggle={() => {
- onAccept(!reviewed)
- onReview(false)
- }}
- />
- </section>
- }
-
- {/**
- * Main action section
- */}
- <section>
- {terms.status === 'new' && !reviewed && !reviewing &&
- <ButtonSuccess
- upperCased
- disabled={!exchangeBaseUrl}
- onClick={() => onReview(true)}
- >
- {i18n.str`Review exchange terms of service`}
- </ButtonSuccess>
- }
- {terms.status === 'changed' && !reviewed && !reviewing &&
- <ButtonWarning
- upperCased
- disabled={!exchangeBaseUrl}
- onClick={() => onReview(true)}
- >
- {i18n.str`Review new version of terms of service`}
- </ButtonWarning>
- }
- {(terms.status === 'accepted' || (needsReview && reviewed)) &&
- <ButtonSuccess
- upperCased
- disabled={!exchangeBaseUrl || confirmed}
- onClick={onWithdraw}
- >
- {i18n.str`Confirm withdrawal`}
- </ButtonSuccess>
- }
- {terms.status === 'notfound' &&
- <ButtonWarning
- upperCased
- disabled={!exchangeBaseUrl}
- onClick={onWithdraw}
- >
- {i18n.str`Withdraw anyway`}
- </ButtonWarning>
- }
- </section>
- </WalletAction>
- )
-}
-
-export function WithdrawPageWithParsedURI({ uri, uriInfo }: { uri: string, uriInfo: WithdrawUriInfoResponse }) {
- const [customExchange, setCustomExchange] = useState<string | undefined>(undefined)
- const [errorAccepting, setErrorAccepting] = useState<string | undefined>(undefined)
-
- const [reviewing, setReviewing] = useState<boolean>(false)
- const [reviewed, setReviewed] = useState<boolean>(false)
- const [confirmed, setConfirmed] = useState<boolean>(false)
-
- const knownExchangesHook = useAsyncAsHook(() => listExchanges())
-
- const knownExchanges = !knownExchangesHook || knownExchangesHook.hasError ? [] : knownExchangesHook.response.exchanges
- const withdrawAmount = Amounts.parseOrThrow(uriInfo.amount)
- const thisCurrencyExchanges = knownExchanges.filter(ex => ex.currency === withdrawAmount.currency)
-
- const exchange = customExchange || uriInfo.defaultExchangeBaseUrl || thisCurrencyExchanges[0]?.exchangeBaseUrl
- const detailsHook = useAsyncAsHook(async () => {
- if (!exchange) throw Error('no default exchange')
- const tos = await getExchangeTos(exchange, ['text/xml'])
- const info = await getExchangeWithdrawalInfo({
- exchangeBaseUrl: exchange,
- amount: withdrawAmount,
- tosAcceptedFormat: ['text/xml']
- })
- return { tos, info }
- })
-
- if (!detailsHook) {
- return <span><i18n.Translate>Getting withdrawal details.</i18n.Translate></span>;
- }
- if (detailsHook.hasError) {
- return <span><i18n.Translate>Problems getting details: {detailsHook.message}</i18n.Translate></span>;
- }
-
- const details = detailsHook.response
-
- const onAccept = async (): Promise<void> => {
- try {
- await setExchangeTosAccepted(exchange, details.tos.currentEtag)
- setReviewed(true)
- } catch (e) {
- if (e instanceof Error) {
- setErrorAccepting(e.message)
- }
- }
- }
-
- const onWithdraw = async (): Promise<void> => {
- setConfirmed(true)
- console.log("accepting exchange", exchange);
- try {
- const res = await acceptWithdrawal(uri, exchange);
- console.log("accept withdrawal response", res);
- if (res.confirmTransferUrl) {
- document.location.href = res.confirmTransferUrl;
- }
- } catch (e) {
- setConfirmed(false)
- }
- };
-
- const termsContent: TermsDocument | undefined = parseTermsOfServiceContent(details.tos.contentType, details.tos.content);
-
- const status: TermsStatus = !termsContent ? 'notfound' : (
- !details.tos.acceptedEtag ? 'new' : (
- details.tos.acceptedEtag !== details.tos.currentEtag ? 'changed' : 'accepted'
- ))
-
-
- return <View onWithdraw={onWithdraw}
- details={details.tos} amount={withdrawAmount}
- exchangeBaseUrl={exchange}
- withdrawalFee={details.info.withdrawFee} //FIXME
- terms={{
- status, value: termsContent
- }}
- onSwitchExchange={setCustomExchange}
- knownExchanges={knownExchanges}
- confirmed={confirmed}
- reviewed={reviewed} onAccept={onAccept}
- reviewing={reviewing} onReview={setReviewing}
- />
-}
-export function WithdrawPage({ talerWithdrawUri }: Props): JSX.Element {
- const uriInfoHook = useAsyncAsHook(() => !talerWithdrawUri ? Promise.reject(undefined) :
- getWithdrawalDetailsForUri({ talerWithdrawUri })
- )
-
- if (!talerWithdrawUri) {
- return <span><i18n.Translate>missing withdraw uri</i18n.Translate></span>;
- }
- if (!uriInfoHook) {
- return <span><i18n.Translate>Loading...</i18n.Translate></span>;
- }
- if (uriInfoHook.hasError) {
- return <span><i18n.Translate>This URI is not valid anymore: {uriInfoHook.message}</i18n.Translate></span>;
- }
- return <WithdrawPageWithParsedURI uri={talerWithdrawUri} uriInfo={uriInfoHook.response} />
-}
-
-function parseTermsOfServiceContent(type: string, text: string): TermsDocument | undefined {
- if (type === 'text/xml') {
- try {
- const document = new DOMParser().parseFromString(text, "text/xml")
- return { type: 'xml', document }
- } catch (e) {
- console.log(e)
- debugger;
- }
- } else if (type === 'text/html') {
- try {
- const href = new URL(text)
- return { type: 'html', href }
- } catch (e) {
- console.log(e)
- debugger;
- }
- } else if (type === 'text/json') {
- try {
- const data = JSON.parse(text)
- return { type: 'json', data }
- } catch (e) {
- console.log(e)
- debugger;
- }
- } else if (type === 'text/pdf') {
- try {
- const location = new URL(text)
- return { type: 'pdf', location }
- } catch (e) {
- console.log(e)
- debugger;
- }
- } else if (type === 'text/plain') {
- try {
- const content = text
- return { type: 'plain', content }
- } catch (e) {
- console.log(e)
- debugger;
- }
- }
- return undefined
-}
-
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts
new file mode 100644
index 000000000..1f8745a5d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts
@@ -0,0 +1,138 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountJson,
+ AmountString,
+ CurrencySpecification,
+ ExchangeListItem,
+ WithdrawalExchangeAccountDetails,
+} from "@gnu-taler/taler-util";
+import { Loading } from "../../components/Loading.js";
+import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js";
+import {
+ AmountFieldHandler,
+ ButtonHandler,
+ SelectFieldHandler,
+} from "../../mui/handlers.js";
+import { StateViewMap, compose } from "../../utils/index.js";
+import {
+ useComponentStateFromParams,
+ useComponentStateFromURI,
+} from "./state.js";
+
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { ExchangeSelectionPage } from "../../wallet/ExchangeSelection/index.js";
+import { NoExchangesView } from "../../wallet/ExchangeSelection/views.js";
+import { FinalStateOperation, SelectAmountView, SuccessView } from "./views.js";
+
+export interface PropsFromURI {
+ talerWithdrawUri: string | undefined;
+ cancel: () => Promise<void>;
+ onSuccess: (txid: string) => Promise<void>;
+}
+
+export interface PropsFromParams {
+ talerExchangeWithdrawUri: string | undefined;
+ amount: string | undefined;
+ cancel: () => Promise<void>;
+ onSuccess: (txid: string) => Promise<void>;
+ onAmountChanged: (amount: AmountString) => Promise<void>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | SelectExchangeState.NoExchangeFound
+ | SelectExchangeState.Selecting
+ | State.SelectAmount
+ | State.AlreadyCompleted
+ | State.Success;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface SelectAmount {
+ status: "select-amount";
+ error: undefined;
+ exchangeBaseUrl: string;
+ confirm: ButtonHandler;
+ amount: AmountFieldHandler;
+ currency: string;
+ }
+ export interface AlreadyCompleted {
+ status: "already-completed";
+ operationState: "confirmed" | "aborted" | "selected";
+ confirmTransferUrl?: string,
+ error: undefined;
+ }
+
+ export type Success = {
+ status: "success";
+ error: undefined;
+
+ currentExchange: ExchangeListItem;
+
+ chosenAmount: AmountJson;
+ withdrawalFee: AmountJson;
+ toBeReceived: AmountJson;
+
+ doWithdrawal: ButtonHandler;
+ doSelectExchange: ButtonHandler;
+
+ chooseCurrencies: string[];
+ selectedCurrency: string;
+ changeCurrency: (s: string) => void;
+ conversionInfo: {
+ spec: CurrencySpecification,
+ amount: AmountJson,
+ } | undefined;
+
+ ageRestriction?: SelectFieldHandler;
+
+ talerWithdrawUri?: string;
+ cancel: () => Promise<void>;
+ };
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ "select-amount": SelectAmountView,
+ "no-exchange-found": NoExchangesView,
+ "selecting-exchange": ExchangeSelectionPage,
+ success: SuccessView,
+ "already-completed": FinalStateOperation,
+};
+
+export const WithdrawPageFromURI = compose(
+ "WithdrawPageFromURI_Withdraw",
+ (p: PropsFromURI) => useComponentStateFromURI(p),
+ viewMapping,
+);
+export const WithdrawPageFromParams = compose(
+ "WithdrawPageFromParams",
+ (p: PropsFromParams) => useComponentStateFromParams(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts
new file mode 100644
index 000000000..f2fa04902
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts
@@ -0,0 +1,488 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountJson,
+ Amounts,
+ ExchangeFullDetails,
+ ExchangeListItem,
+ NotificationType,
+ parseWithdrawExchangeUri
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useCallback, useEffect, useState } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { useSelectedExchange } from "../../hooks/useSelectedExchange.js";
+import { RecursiveState } from "../../utils/index.js";
+import { PropsFromParams, PropsFromURI, State } from "./index.js";
+
+export function useComponentStateFromParams({
+ talerExchangeWithdrawUri: maybeTalerUri,
+ amount,
+ cancel,
+ onAmountChanged,
+ onSuccess,
+}: PropsFromParams): RecursiveState<State> {
+ const api = useBackendContext();
+ const { i18n } = useTranslationContext();
+ const paramsAmount = amount ? Amounts.parse(amount) : undefined;
+ const uriInfoHook = useAsyncAsHook(async () => {
+ const exchanges = await api.wallet.call(
+ WalletApiOperation.ListExchanges,
+ {},
+ );
+ const uri = maybeTalerUri
+ ? parseWithdrawExchangeUri(maybeTalerUri)
+ : undefined;
+ const exchangeByTalerUri = uri?.exchangeBaseUrl;
+ let ex: ExchangeFullDetails | undefined;
+ if (exchangeByTalerUri) {
+ await api.wallet.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: exchangeByTalerUri,
+ masterPub: uri.exchangePub,
+ });
+ const info = await api.wallet.call(
+ WalletApiOperation.GetExchangeDetailedInfo,
+ {
+ exchangeBaseUrl: exchangeByTalerUri,
+ },
+ );
+
+ ex = info.exchange;
+ }
+ const chosenAmount =
+ !uri || !uri.amount ? undefined : Amounts.parse(uri.amount);
+ return { amount: chosenAmount, exchanges, exchange: ex };
+ });
+
+ if (!uriInfoHook) return { status: "loading", error: undefined };
+
+ if (uriInfoHook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the list of exchanges`,
+ uriInfoHook,
+ ),
+ };
+ }
+
+ useEffect(() => {
+ uriInfoHook?.retry();
+ }, [amount]);
+
+ const exchangeByTalerUri = uriInfoHook.response.exchange?.exchangeBaseUrl;
+ const exchangeList = uriInfoHook.response.exchanges.exchanges;
+
+ const maybeAmount = uriInfoHook.response.amount ?? paramsAmount;
+
+ if (!maybeAmount) {
+ const exchangeBaseUrl =
+ uriInfoHook.response.exchange?.exchangeBaseUrl ??
+ (exchangeList.length > 0 ? exchangeList[0].exchangeBaseUrl : undefined);
+ const currency =
+ uriInfoHook.response.exchange?.currency ??
+ (exchangeList.length > 0 ? exchangeList[0].currency : undefined);
+
+ if (!exchangeBaseUrl) {
+ return {
+ status: "error",
+ error: {
+ message: i18n.str`Can't withdraw from exchange`,
+ description: i18n.str`Missing base URL`,
+ cause: undefined,
+ context: {},
+ type: "error",
+ },
+ };
+ }
+ if (!currency) {
+ return {
+ status: "error",
+ error: {
+ message: i18n.str`Can't withdraw from exchange`,
+ description: i18n.str`Missing unknown currency`,
+ cause: undefined,
+ context: {},
+ type: "error",
+ },
+ };
+ }
+ return () => {
+ const { pushAlertOnError } = useAlertContext();
+ const [amount, setAmount] = useState<AmountJson>(
+ Amounts.zeroOfCurrency(currency),
+ );
+ const isValid = Amounts.isNonZero(amount);
+ return {
+ status: "select-amount",
+ currency,
+ exchangeBaseUrl,
+ error: undefined,
+ confirm: {
+ onClick: isValid
+ ? pushAlertOnError(async () => {
+ onAmountChanged(Amounts.stringify(amount));
+ })
+ : undefined,
+ },
+ amount: {
+ value: amount,
+ onInput: pushAlertOnError(async (e) => {
+ setAmount(e);
+ }),
+ },
+ };
+ };
+ }
+ const chosenAmount = maybeAmount;
+
+ async function doManualWithdraw(
+ exchange: string,
+ ageRestricted: number | undefined,
+ ): Promise<{
+ transactionId: string;
+ confirmTransferUrl: string | undefined;
+ }> {
+ const res = await api.wallet.call(
+ WalletApiOperation.AcceptManualWithdrawal,
+ {
+ exchangeBaseUrl: exchange,
+ amount: Amounts.stringify(chosenAmount),
+ restrictAge: ageRestricted,
+ },
+ );
+ return {
+ confirmTransferUrl: undefined,
+ transactionId: res.transactionId,
+ };
+ }
+
+ return () =>
+ exchangeSelectionState(
+ doManualWithdraw,
+ cancel,
+ onSuccess,
+ undefined,
+ chosenAmount,
+ exchangeList,
+ exchangeByTalerUri,
+ );
+}
+
+export function useComponentStateFromURI({
+ talerWithdrawUri: maybeTalerUri,
+ cancel,
+ onSuccess,
+}: PropsFromURI): RecursiveState<State> {
+ const api = useBackendContext();
+ const { i18n } = useTranslationContext();
+ /**
+ * Ask the wallet about the withdraw URI
+ */
+ const uriInfoHook = useAsyncAsHook(async () => {
+ if (!maybeTalerUri) throw Error("ERROR_NO-URI-FOR-WITHDRAWAL");
+ const talerWithdrawUri = maybeTalerUri.startsWith("ext+")
+ ? maybeTalerUri.substring(4)
+ : maybeTalerUri;
+
+ const uriInfo = await api.wallet.call(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ {
+ talerWithdrawUri,
+ notifyChangeFromPendingTimeoutMs: 30 * 1000,
+ },
+ );
+ const {
+ amount,
+ defaultExchangeBaseUrl,
+ possibleExchanges,
+ operationId,
+ confirmTransferUrl,
+ status,
+ } = uriInfo;
+ const transaction = await api.wallet.call(
+ WalletApiOperation.GetWithdrawalTransactionByUri,
+ { talerWithdrawUri },
+ );
+ return {
+ talerWithdrawUri,
+ operationId,
+ status,
+ transaction,
+ confirmTransferUrl,
+ amount: Amounts.parseOrThrow(amount),
+ thisExchange: defaultExchangeBaseUrl,
+ exchanges: possibleExchanges,
+ };
+ });
+
+ const readyToListen = uriInfoHook && !uriInfoHook.hasError;
+
+ useEffect(() => {
+ if (!uriInfoHook) {
+ return;
+ }
+ return api.listener.onUpdateNotification(
+ [NotificationType.WithdrawalOperationTransition],
+ uriInfoHook.retry,
+ );
+ }, [readyToListen]);
+
+ if (!uriInfoHook) return { status: "loading", error: undefined };
+
+ if (uriInfoHook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load info from URI`,
+ uriInfoHook,
+ ),
+ };
+ }
+
+ const uri = uriInfoHook.response.talerWithdrawUri;
+ const chosenAmount = uriInfoHook.response.amount;
+ const defaultExchange = uriInfoHook.response.thisExchange;
+ const exchangeList = uriInfoHook.response.exchanges;
+
+ async function doManagedWithdraw(
+ exchange: string,
+ ageRestricted: number | undefined,
+ ): Promise<{
+ transactionId: string;
+ confirmTransferUrl: string | undefined;
+ }> {
+ const res = await api.wallet.call(
+ WalletApiOperation.AcceptBankIntegratedWithdrawal,
+ {
+ exchangeBaseUrl: exchange,
+ talerWithdrawUri: uri,
+ restrictAge: ageRestricted,
+ },
+ );
+ return {
+ confirmTransferUrl: res.confirmTransferUrl,
+ transactionId: res.transactionId,
+ };
+ }
+
+ if (uriInfoHook.response.status !== "pending") {
+ if (uriInfoHook.response.transaction) {
+ onSuccess(uriInfoHook.response.transaction.transactionId);
+ }
+ return {
+ status: "already-completed",
+ operationState: uriInfoHook.response.status,
+ confirmTransferUrl: uriInfoHook.response.confirmTransferUrl,
+ error: undefined,
+ };
+ }
+
+ return useCallback(() => {
+ return exchangeSelectionState(
+ doManagedWithdraw,
+ cancel,
+ onSuccess,
+ uri,
+ chosenAmount,
+ exchangeList,
+ defaultExchange,
+ );
+ }, []);
+}
+
+type ManualOrManagedWithdrawFunction = (
+ exchange: string,
+ ageRestricted: number | undefined,
+) => Promise<{ transactionId: string; confirmTransferUrl: string | undefined }>;
+
+function exchangeSelectionState(
+ doWithdraw: ManualOrManagedWithdrawFunction,
+ cancel: () => Promise<void>,
+ onSuccess: (txid: string) => Promise<void>,
+ talerWithdrawUri: string | undefined,
+ chosenAmount: AmountJson,
+ exchangeList: ExchangeListItem[],
+ exchangeSuggestedByTheBank: string | undefined,
+): RecursiveState<State> {
+ const api = useBackendContext();
+ const selectedExchange = useSelectedExchange({
+ currency: chosenAmount.currency,
+ defaultExchange: exchangeSuggestedByTheBank,
+ list: exchangeList,
+ });
+
+ if (selectedExchange.status !== "ready") {
+ return selectedExchange;
+ }
+
+ return useCallback(():
+ | State.Success
+ | State.LoadingUriError
+ | State.Loading => {
+ const { i18n } = useTranslationContext();
+ const { pushAlertOnError } = useAlertContext();
+ const [ageRestricted, setAgeRestricted] = useState(0);
+ const currentExchange = selectedExchange.selected;
+
+ const [selectedCurrency, setSelectedCurrency] = useState<string>(
+ chosenAmount.currency,
+ );
+ /**
+ * With the exchange and amount, ask the wallet the information
+ * about the withdrawal
+ */
+ const amountHook = useAsyncAsHook(async () => {
+ const info = await api.wallet.call(
+ WalletApiOperation.GetWithdrawalDetailsForAmount,
+ {
+ exchangeBaseUrl: currentExchange.exchangeBaseUrl,
+ amount: Amounts.stringify(chosenAmount),
+ restrictAge: ageRestricted,
+ },
+ );
+
+ const withdrawAmount = {
+ raw: Amounts.parseOrThrow(info.amountRaw),
+ effective: Amounts.parseOrThrow(info.amountEffective),
+ };
+
+ return {
+ amount: withdrawAmount,
+ ageRestrictionOptions: info.ageRestrictionOptions,
+ accounts: info.withdrawalAccountsList,
+ };
+ }, []);
+
+ const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false);
+
+ async function doWithdrawAndCheckError(): Promise<void> {
+ try {
+ setDoingWithdraw(true);
+ const res = await doWithdraw(
+ currentExchange.exchangeBaseUrl,
+ !ageRestricted ? undefined : ageRestricted,
+ );
+ if (res.confirmTransferUrl) {
+ document.location.href = res.confirmTransferUrl;
+ } else {
+ onSuccess(res.transactionId);
+ }
+ } catch (e) {
+ console.error(e);
+ // if (e instanceof TalerError) {
+ // }
+ }
+ setDoingWithdraw(false);
+ }
+
+ if (!amountHook) {
+ return { status: "loading", error: undefined };
+ }
+ if (amountHook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load the withdrawal details`,
+ amountHook,
+ ),
+ };
+ }
+ if (!amountHook.response) {
+ return { status: "loading", error: undefined };
+ }
+
+ const withdrawalFee = Amounts.sub(
+ amountHook.response.amount.raw,
+ amountHook.response.amount.effective,
+ ).amount;
+ const toBeReceived = amountHook.response.amount.effective;
+
+ const ageRestrictionOptions =
+ amountHook.response.ageRestrictionOptions?.reduce(
+ (p, c) => ({ ...p, [c]: `under ${c}` }),
+ {} as Record<string, string>,
+ );
+
+ const ageRestrictionEnabled = ageRestrictionOptions !== undefined;
+ if (ageRestrictionEnabled) {
+ ageRestrictionOptions["0"] = "Not restricted";
+ }
+
+ //TODO: calculate based on exchange info
+ const ageRestriction = ageRestrictionEnabled
+ ? {
+ list: ageRestrictionOptions,
+ value: String(ageRestricted),
+ onChange: pushAlertOnError(async (v: string) =>
+ setAgeRestricted(parseInt(v, 10)),
+ ),
+ }
+ : undefined;
+
+ const altCurrencies = amountHook.response.accounts
+ .filter((a) => !!a.currencySpecification)
+ .map((a) => a.currencySpecification!.name);
+ const chooseCurrencies =
+ altCurrencies.length === 0
+ ? []
+ : [toBeReceived.currency, ...altCurrencies];
+
+ const convAccount = amountHook.response.accounts.find((c) => {
+ return (
+ c.currencySpecification &&
+ c.currencySpecification.name === selectedCurrency
+ );
+ });
+ const conversionInfo = !convAccount
+ ? undefined
+ : {
+ spec: convAccount.currencySpecification!,
+ amount: Amounts.parseOrThrow(convAccount.transferAmount!),
+ };
+
+ return {
+ status: "success",
+ error: undefined,
+ doSelectExchange: selectedExchange.doSelect,
+ currentExchange,
+ toBeReceived,
+ chooseCurrencies,
+ selectedCurrency,
+ changeCurrency: (s) => {
+ setSelectedCurrency(s);
+ },
+ conversionInfo,
+ withdrawalFee,
+ chosenAmount,
+ talerWithdrawUri,
+ ageRestriction,
+ doWithdrawal: {
+ onClick: doingWithdraw
+ ? undefined
+ : pushAlertOnError(doWithdrawAndCheckError),
+ },
+ cancel,
+ };
+ }, []);
+}
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx
new file mode 100644
index 000000000..29f39054f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx
@@ -0,0 +1,327 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { CurrencySpecification, ExchangeListItem } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { nullFunction } from "../../mui/handlers.js";
+// import { TermsState } from "../../utils/index.js";
+import { SuccessView, FinalStateOperation } from "./views.js";
+
+export default {
+ title: "withdraw",
+};
+
+const ageRestrictionOptions: Record<string, string> = "6:12:18"
+ .split(":")
+ .reduce((p, c) => ({ ...p, [c]: `under ${c}` }), {});
+
+ageRestrictionOptions["0"] = "Not restricted";
+
+const ageRestrictionSelectField = {
+ list: ageRestrictionOptions,
+ value: "0",
+};
+
+export const TermsOfServiceNotYetLoaded = tests.createExample(SuccessView, {
+ error: undefined,
+ status: "success",
+ chosenAmount: {
+ currency: "USD",
+ value: 2,
+ fraction: 10000000,
+ },
+ doWithdrawal: { onClick: nullFunction },
+ currentExchange: {
+ exchangeBaseUrl: "https://exchange.demo.taler.net",
+ tos: {},
+ } as Partial<ExchangeListItem> as any,
+ withdrawalFee: {
+ currency: "USD",
+ fraction: 10000000,
+ value: 1,
+ },
+ doSelectExchange: {},
+ toBeReceived: {
+ currency: "USD",
+ fraction: 0,
+ value: 1,
+ },
+ chooseCurrencies: [],
+});
+
+export const AlreadyAborted = tests.createExample(FinalStateOperation, {
+ error: undefined,
+ status: "already-completed",
+ operationState: "aborted"
+});
+export const AlreadySelected = tests.createExample(FinalStateOperation, {
+ error: undefined,
+ status: "already-completed",
+ operationState: "selected"
+});
+export const AlreadyConfirmed = tests.createExample(FinalStateOperation, {
+ error: undefined,
+ status: "already-completed",
+ operationState: "confirmed"
+});
+
+
+export const WithSomeFee = tests.createExample(SuccessView, {
+ error: undefined,
+ status: "success",
+ chosenAmount: {
+ currency: "USD",
+ value: 2,
+ fraction: 10000000,
+ },
+ doWithdrawal: { onClick: nullFunction },
+ currentExchange: {
+ exchangeBaseUrl: "https://exchange.demo.taler.net",
+ tos: {},
+ } as Partial<ExchangeListItem> as any,
+ withdrawalFee: {
+ currency: "USD",
+ fraction: 10000000,
+ value: 1,
+ },
+ toBeReceived: {
+ currency: "USD",
+ fraction: 0,
+ value: 1,
+ },
+ doSelectExchange: {},
+ chooseCurrencies: [],
+});
+
+export const WithoutFee = tests.createExample(SuccessView, {
+ error: undefined,
+ status: "success",
+ chosenAmount: {
+ currency: "USD",
+ value: 2,
+ fraction: 0,
+ },
+ doWithdrawal: { onClick: nullFunction },
+ currentExchange: {
+ exchangeBaseUrl: "https://exchange.demo.taler.net",
+ tos: {},
+ } as Partial<ExchangeListItem> as any,
+ withdrawalFee: {
+ currency: "USD",
+ fraction: 0,
+ value: 0,
+ },
+ doSelectExchange: {},
+ toBeReceived: {
+ currency: "USD",
+ fraction: 0,
+ value: 2,
+ },
+ chooseCurrencies: [],
+});
+
+export const EditExchangeUntouched = tests.createExample(SuccessView, {
+ error: undefined,
+ status: "success",
+ chosenAmount: {
+ currency: "USD",
+ value: 2,
+ fraction: 10000000,
+ },
+ doWithdrawal: { onClick: nullFunction },
+ currentExchange: {
+ exchangeBaseUrl: "https://exchange.demo.taler.net",
+ tos: {},
+ } as Partial<ExchangeListItem> as any,
+ withdrawalFee: {
+ currency: "USD",
+ fraction: 0,
+ value: 0,
+ },
+ doSelectExchange: {},
+ toBeReceived: {
+ currency: "USD",
+ fraction: 0,
+ value: 2,
+ },
+ chooseCurrencies: [],
+});
+
+export const EditExchangeModified = tests.createExample(SuccessView, {
+ error: undefined,
+ status: "success",
+ chosenAmount: {
+ currency: "USD",
+ value: 2,
+ fraction: 10000000,
+ },
+ doWithdrawal: { onClick: nullFunction },
+ currentExchange: {
+ exchangeBaseUrl: "https://exchange.demo.taler.net",
+ tos: {},
+ } as Partial<ExchangeListItem> as any,
+ withdrawalFee: {
+ currency: "USD",
+ fraction: 0,
+ value: 0,
+ },
+ doSelectExchange: {},
+ toBeReceived: {
+ currency: "USD",
+ fraction: 0,
+ value: 2,
+ },
+ chooseCurrencies: [],
+});
+
+export const WithAgeRestriction = tests.createExample(SuccessView, {
+ error: undefined,
+ status: "success",
+ ageRestriction: ageRestrictionSelectField,
+ chosenAmount: {
+ currency: "USD",
+ value: 2,
+ fraction: 10000000,
+ },
+ doSelectExchange: {},
+ doWithdrawal: { onClick: nullFunction },
+ currentExchange: {
+ exchangeBaseUrl: "https://exchange.demo.taler.net",
+ tos: {},
+ } as Partial<ExchangeListItem> as any,
+ withdrawalFee: {
+ currency: "USD",
+ fraction: 0,
+ value: 0,
+ },
+ toBeReceived: {
+ currency: "USD",
+ fraction: 0,
+ value: 2,
+ },
+ chooseCurrencies: [],
+});
+
+export const WithAlternateCurrenciesNETZBON = tests.createExample(SuccessView, {
+ error: undefined,
+ status: "success",
+ chosenAmount: {
+ currency: "NETZBON",
+ value: 2,
+ fraction: 10000000,
+ },
+ chooseCurrencies: ["NETZBON", "EUR"],
+ selectedCurrency: "NETZBON",
+ doWithdrawal: { onClick: nullFunction },
+ currentExchange: {
+ exchangeBaseUrl: "https://exchange.netzbon.ch",
+ tos: {},
+ } as Partial<ExchangeListItem> as any,
+ withdrawalFee: {
+ currency: "NETZBON",
+ fraction: 10000000,
+ value: 1,
+ },
+ doSelectExchange: {},
+ toBeReceived: {
+ currency: "NETZBON",
+ fraction: 0,
+ value: 1,
+ },
+});
+
+export const WithAlternateCurrenciesEURO = tests.createExample(SuccessView, {
+ error: undefined,
+ status: "success",
+ chosenAmount: {
+ currency: "NETZBON",
+ value: 2,
+ fraction: 10000000,
+ },
+ chooseCurrencies: ["NETZBON", "EUR"],
+ selectedCurrency: "EUR",
+ changeCurrency: () => { },
+ conversionInfo: {
+ spec: {
+ name: "EUR"
+ } as CurrencySpecification,
+ amount: {
+ currency: "EUR",
+ fraction: 10000000,
+ value: 1,
+ }
+ },
+ doWithdrawal: { onClick: nullFunction },
+ currentExchange: {
+ exchangeBaseUrl: "https://exchange.netzbon.ch",
+ tos: {},
+ } as Partial<ExchangeListItem> as any,
+ withdrawalFee: {
+ currency: "NETZBON",
+ fraction: 10000000,
+ value: 1,
+ },
+ doSelectExchange: {},
+ toBeReceived: {
+ currency: "NETZBON",
+ fraction: 0,
+ value: 1,
+ },
+});
+
+export const WithAlternateCurrenciesEURO11 = tests.createExample(SuccessView, {
+ error: undefined,
+ status: "success",
+ chosenAmount: {
+ currency: "NETZBON",
+ value: 2,
+ fraction: 10000000,
+ },
+ chooseCurrencies: ["NETZBON", "EUR"],
+ selectedCurrency: "EUR",
+ changeCurrency: () => { },
+ conversionInfo: {
+ spec: {
+ name: "EUR"
+ } as CurrencySpecification,
+ amount: {
+ currency: "EUR",
+ fraction: 10000000,
+ value: 2,
+ }
+ },
+ doWithdrawal: { onClick: nullFunction },
+ currentExchange: {
+ exchangeBaseUrl: "https://exchange.netzbon.ch",
+ tos: {},
+ } as Partial<ExchangeListItem> as any,
+ withdrawalFee: {
+ currency: "NETZBON",
+ fraction: 10000000,
+ value: 1,
+ },
+ doSelectExchange: {},
+ toBeReceived: {
+ currency: "NETZBON",
+ fraction: 0,
+ value: 1,
+ },
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts
new file mode 100644
index 000000000..f90f7bed7
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts
@@ -0,0 +1,304 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AmountString,
+ Amounts,
+ ExchangeEntryStatus,
+ ExchangeListItem,
+ ExchangeTosStatus,
+ ScopeType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { expect } from "chai";
+import * as tests from "@gnu-taler/web-util/testing";
+import { createWalletApiMock } from "../../test-utils.js";
+import { useComponentStateFromURI } from "./state.js";
+
+const exchanges: ExchangeListItem[] = [
+ {
+ currency: "ARS",
+ exchangeBaseUrl: "http://exchange.demo.taler.net",
+ paytoUris: [],
+ tosStatus: ExchangeTosStatus.Accepted,
+ exchangeStatus: ExchangeEntryStatus.Used,
+ permanent: true,
+ auditors: [
+ {
+ auditor_pub: "pubpubpubpubpub",
+ auditor_url: "https://audotor.taler.net",
+ denomination_keys: [],
+ },
+ ],
+ denomFees: {
+ deposit: [],
+ refresh: [],
+ refund: [],
+ withdraw: [],
+ },
+ globalFees: [],
+ transferFees: {},
+ wireInfo: {
+ accounts: [],
+ feesForType: {},
+ },
+ } as Partial<ExchangeListItem> as ExchangeListItem,
+];
+
+const nullFunction = async (): Promise<void> => {
+ null;
+};
+
+describe("Withdraw CTA states", () => {
+ it("should tell the user that the URI is missing", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+
+ const props = {
+ talerWithdrawUri: undefined,
+ cancel: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentStateFromURI,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equals("loading");
+ },
+ ({ status, error }) => {
+ if (status != "error") expect.fail();
+ if (!error) expect.fail();
+ // if (!error.hasError) expect.fail();
+ // if (error.operational) expect.fail();
+ expect(error.description).eq("ERROR_NO-URI-FOR-WITHDRAWAL");
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should tell the user that there is not known exchange", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerWithdrawUri: "taler-withdraw://",
+ cancel: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ undefined,
+ {
+ status: "pending",
+ operationId: "123",
+ amount: "EUR:2" as AmountString,
+ possibleExchanges: [],
+ },
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetWithdrawalTransactionByUri,
+ undefined,
+ {
+ transactionId: "123"
+ } as any,
+ );
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentStateFromURI,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equals("loading");
+ },
+ ({ status, error }) => {
+ expect(status).equals("no-exchange-found");
+ expect(error).undefined;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should be able to withdraw if tos are ok", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerWithdrawUri: "taler-withdraw://",
+ cancel: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ undefined,
+ {
+ status: "pending",
+ operationId: "123",
+ amount: "ARS:2" as AmountString,
+ possibleExchanges: exchanges,
+ defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl,
+ },
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetWithdrawalTransactionByUri,
+ undefined,
+ {
+ transactionId: "123"
+ } as any,
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetWithdrawalDetailsForAmount,
+ undefined,
+ {
+ amountRaw: "ARS:2" as AmountString,
+ amountEffective: "ARS:2" as AmountString,
+ paytoUris: ["payto://"],
+ tosAccepted: true,
+ scopeInfo: {
+ currency: "ARS",
+ type: ScopeType.Exchange,
+ url: "http://asd"
+ },
+ withdrawalAccountsList: [],
+ ageRestrictionOptions: [],
+ numCoins: 42,
+ },
+ );
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentStateFromURI,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equals("loading");
+ },
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ (state) => {
+ expect(state.status).equals("success");
+ if (state.status !== "success") return;
+
+ expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2"));
+ expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"));
+ expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"));
+
+ expect(state.doWithdrawal.onClick).not.undefined;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it.skip("should accept the tos before withdraw", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = {
+ talerWithdrawUri: "taler-withdraw://",
+ cancel: nullFunction,
+ onSuccess: nullFunction,
+ };
+
+ const exchangeWithNewTos = exchanges.map((e) => ({
+ ...e,
+ tosStatus: ExchangeTosStatus.Proposed,
+ }));
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ undefined,
+ {
+ status: "pending",
+ operationId: "123",
+ amount: "ARS:2" as AmountString,
+ possibleExchanges: exchangeWithNewTos,
+ defaultExchangeBaseUrl: exchangeWithNewTos[0].exchangeBaseUrl,
+ },
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetWithdrawalDetailsForAmount,
+ undefined,
+ {
+ amountRaw: "ARS:2" as AmountString,
+ amountEffective: "ARS:2" as AmountString,
+ paytoUris: ["payto://"],
+ scopeInfo: {
+ currency: "ARS",
+ type: ScopeType.Exchange,
+ url: "http://asd"
+ },
+ tosAccepted: false,
+ withdrawalAccountsList: [],
+ ageRestrictionOptions: [],
+ numCoins: 42,
+ },
+ );
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.GetWithdrawalDetailsForUri,
+ undefined,
+ {
+ status: "pending",
+ operationId: "123",
+ amount: "ARS:2" as AmountString,
+ possibleExchanges: exchanges,
+ defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl,
+ },
+ );
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentStateFromURI,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equals("loading");
+ },
+ ({ status, error }) => {
+ expect(status).equals("loading");
+ expect(error).undefined;
+ },
+ (state) => {
+ expect(state.status).equals("success");
+ if (state.status !== "success") return;
+
+ expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2"));
+ expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"));
+ expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"));
+
+ expect(state.doWithdrawal.onClick).not.undefined;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx
new file mode 100644
index 000000000..aade67835
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx
@@ -0,0 +1,245 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Amount } from "../../components/Amount.js";
+import { AmountField } from "../../components/AmountField.js";
+import { Part } from "../../components/Part.js";
+import { QR } from "../../components/QR.js";
+import { SelectList } from "../../components/SelectList.js";
+import { TermsOfService } from "../../components/TermsOfService/index.js";
+import { Input, LinkSuccess, SvgIcon, WarningBox } from "../../components/styled/index.js";
+import { Button } from "../../mui/Button.js";
+import { Grid } from "../../mui/Grid.js";
+import editIcon from "../../svg/edit_24px.inline.svg";
+import {
+ ExchangeDetails,
+ WithdrawDetails,
+ getAmountWithFee,
+} from "../../wallet/Transaction.js";
+import { State } from "./index.js";
+import { EnabledBySettings } from "../../components/EnabledBySettings.js";
+
+export function FinalStateOperation(state: State.AlreadyCompleted): VNode {
+ const { i18n } = useTranslationContext();
+
+ switch (state.operationState) {
+ case "confirmed": return <WarningBox>
+ <div style={{ justifyContent: "center", lineHeight: "25px" }}>
+ <i18n.Translate>This operation has already been completed by another wallet.</i18n.Translate>
+ </div>
+ </WarningBox>
+ case "aborted": return <WarningBox>
+ <div style={{ justifyContent: "center", lineHeight: "25px" }}>
+ <i18n.Translate>This operation has already been aborted</i18n.Translate>
+ </div>
+ </WarningBox>
+ case "selected": return <WarningBox>
+ <div style={{ justifyContent: "center", lineHeight: "25px" }}>
+ <i18n.Translate>This operation has already been used by another wallet.</i18n.Translate>
+ </div>
+ <div style={{ justifyContent: "center", lineHeight: "25px" }}>
+ <i18n.Translate>It can be confirmed in</i18n.Translate>&nbsp;<a target="_bank" rel="noreferrer" href={state.confirmTransferUrl}>
+ <i18n.Translate>this page</i18n.Translate>
+ </a>
+ </div>
+ </WarningBox>
+ }
+}
+
+export function SuccessView(state: State.Success): VNode {
+ const { i18n } = useTranslationContext();
+ // const currentTosVersionIsAccepted =
+ // state.currentExchange.tosStatus === ExchangeTosStatus.Accepted;
+ return (
+ <Fragment>
+ <section style={{ textAlign: "left" }}>
+ <Part
+ title={
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ }}
+ >
+ <i18n.Translate>Exchange</i18n.Translate>
+ <EnabledBySettings name="showExchangeManagement">
+ <Button onClick={state.doSelectExchange.onClick} variant="text">
+ <SvgIcon
+ title="Edit"
+ dangerouslySetInnerHTML={{ __html: editIcon }}
+ color="black"
+ />
+ </Button>
+ </EnabledBySettings>
+ </div>
+ }
+ text={
+ <ExchangeDetails exchange={state.currentExchange.exchangeBaseUrl} />
+ }
+ kind="neutral"
+ big
+ />
+ {state.chooseCurrencies.length > 0 ?
+ <Fragment>
+ <p>
+ {state.chooseCurrencies.map(currency => {
+ return <Button variant={currency === state.selectedCurrency ? "contained" : "outlined"}
+ onClick={async () => {
+ state.changeCurrency(currency)
+ }}
+ >
+ {currency}
+ </Button>
+ })}
+ </p>
+ </Fragment>
+ : <Fragment />}
+
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <WithdrawDetails
+ conversion={state.conversionInfo?.amount}
+ amount={getAmountWithFee(
+ state.toBeReceived,
+ state.chosenAmount,
+ "credit",
+ )}
+ />
+ }
+ />
+ {state.ageRestriction && (
+ <Input>
+ <SelectList
+ label={i18n.str`Age restriction`}
+ list={state.ageRestriction.list}
+ name="age"
+ value={state.ageRestriction.value}
+ onChange={state.ageRestriction.onChange}
+ />
+ </Input>
+ )}
+ </section>
+
+ <section>
+ {/* <div> */}
+ <TermsOfService exchangeUrl={state.currentExchange.exchangeBaseUrl}>
+ <Button
+ variant="contained"
+ color="success"
+ disabled={!state.doWithdrawal.onClick}
+ onClick={state.doWithdrawal.onClick}
+ >
+ <i18n.Translate>
+ Withdraw &nbsp; <Amount value={state.toBeReceived} />
+ </i18n.Translate>
+ </Button>
+ </TermsOfService>
+ {/* </div>
+ <div style={{ marginTop: 20 }}>
+ <Button
+ variant="text"
+ color="success"
+
+ disabled={!state.doAbort.onClick}
+ onClick={state.doAbort.onClick}
+ >
+ <i18n.Translate>
+ Cancel
+ </i18n.Translate>
+ </Button>
+ </div> */}
+ </section>
+ {state.talerWithdrawUri ? (
+ <WithdrawWithMobile talerWithdrawUri={state.talerWithdrawUri} />
+ ) : undefined}
+ </Fragment>
+ );
+}
+
+function WithdrawWithMobile({
+ talerWithdrawUri,
+}: {
+ talerWithdrawUri: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [showQR, setShowQR] = useState<boolean>(false);
+
+ return (
+ <section>
+ <LinkSuccess upperCased onClick={() => setShowQR((qr) => !qr)}>
+ {!showQR ? i18n.str`Withdraw to a mobile phone` : i18n.str`Hide QR`}
+ </LinkSuccess>
+ {showQR && (
+ <div>
+ <QR text={talerWithdrawUri} />
+ <i18n.Translate>
+ Scan the QR code or &nbsp;
+ <a href={talerWithdrawUri}>
+ <i18n.Translate>click here</i18n.Translate>
+ </a>
+ </i18n.Translate>
+ </div>
+ )}
+ </section>
+ );
+}
+
+export function SelectAmountView({
+ currency,
+ amount,
+ exchangeBaseUrl,
+ confirm,
+}: State.SelectAmount): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <section style={{ textAlign: "left" }}>
+ <Part
+ title={
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ }}
+ >
+ <i18n.Translate>Exchange</i18n.Translate>
+ </div>
+ }
+ text={<ExchangeDetails exchange={exchangeBaseUrl} />}
+ kind="neutral"
+ big
+ />
+ <Grid container columns={2} justifyContent="space-between">
+ <AmountField label={i18n.str`Amount`} required handler={amount} />
+ </Grid>
+ </section>
+ <section>
+ <Button
+ variant="contained"
+ color="info"
+ disabled={!confirm.onClick}
+ onClick={confirm.onClick}
+ >
+ <i18n.Translate>See details</i18n.Translate>
+ </Button>
+ </section>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/cta/index.stories.ts b/packages/taler-wallet-webextension/src/cta/index.stories.ts
new file mode 100644
index 000000000..36e9cd1b9
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/cta/index.stories.ts
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export * as a1 from "./Deposit/stories.jsx";
+export * as a3 from "./Payment/stories.jsx";
+export * as a4 from "./Refund/stories.jsx";
+export * as a6 from "./Withdraw/stories.jsx";
+export * as a8 from "./InvoiceCreate/stories.js";
+export * as a9 from "./InvoicePay/stories.js";
+export * as a10 from "./TransferCreate/stories.js";
+export * as a11 from "./TransferPickup/stories.js";
diff --git a/packages/taler-wallet-webextension/src/cta/reset-required.tsx b/packages/taler-wallet-webextension/src/cta/reset-required.tsx
deleted file mode 100644
index e66c0db57..000000000
--- a/packages/taler-wallet-webextension/src/cta/reset-required.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- This file is part of TALER
- (C) 2017 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Page to inform the user when a database reset is required.
- *
- * @author Florian Dold
- */
-
-import { Component, JSX, h } from "preact";
-import * as wxApi from "../wxApi";
-
-interface State {
- /**
- * Did the user check the confirmation check box?
- */
- checked: boolean;
-
- /**
- * Do we actually need to reset the db?
- */
- resetRequired: boolean;
-}
-
-class ResetNotification extends Component<any, State> {
- constructor(props: any) {
- super(props);
- this.state = { checked: false, resetRequired: true };
- setInterval(() => this.update(), 500);
- }
- async update(): Promise<void> {
- const res = await wxApi.checkUpgrade();
- this.setState({ resetRequired: res.dbResetRequired });
- }
- render(): JSX.Element {
- if (this.state.resetRequired) {
- return (
- <div>
- <h1>Manual Reset Required</h1>
- <p>
- The wallet&apos;s database in your browser is incompatible with the{" "}
- currently installed wallet. Please reset manually.
- </p>
- <p>
- Once the database format has stabilized, we will provide automatic
- upgrades.
- </p>
- <input
- id="check"
- type="checkbox"
- checked={this.state.checked}
- onChange={() => {
- this.setState(prev => ({ checked: prev.checked }))
- }}
- />{" "}
- <label htmlFor="check">
- I understand that I will lose all my data
- </label>
- <br />
- <button
- class="pure-button"
- disabled={!this.state.checked}
- onClick={() => wxApi.resetDb()}
- >
- Reset
- </button>
- </div>
- );
- }
- return (
- <div>
- <h1>Everything is fine!</h1>A reset is not required anymore, you can
- close this page.
- </div>
- );
- }
-}
-
-/**
- * @deprecated to be removed
- */
-export function createResetRequiredPage(): JSX.Element {
- return <ResetNotification />;
-}
diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx b/packages/taler-wallet-webextension/src/cta/termsExample.ts
index 5e29a3e39..ba0bee89e 100644
--- a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx
+++ b/packages/taler-wallet-webextension/src/cta/termsExample.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -13,29 +13,14 @@
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+/* eslint-disable no-useless-escape */
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { amountFractionalBase, Amounts } from '@gnu-taler/taler-util';
-import { ExchangeRecord } from '@gnu-taler/taler-wallet-core';
-import { ExchangeWithdrawDetails } from '@gnu-taler/taler-wallet-core/src/operations/withdraw';
-import { getMaxListeners } from 'process';
-import { createExample } from '../test-utils';
-import { View as TestedComponent } from './Withdraw';
-
-
-export default {
- title: 'cta/withdraw',
- component: TestedComponent,
- argTypes: {
- onSwitchExchange: { action: 'onRetry' },
- },
-};
-
-const termsHtml = `<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export const termsHtml = `<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Terms Of Service &#8212; Taler Terms of Service</title>
@@ -48,14 +33,14 @@ const termsHtml = `<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
</div>
</body>
</html>
-`
-const termsPlain = `
+`;
+export const termsPlain = `
Terms Of Service
****************
Last Updated: 12.4.2019
-Welcome! Taler Systems SA (“we,” “our,” or “us”) provides a payment
+Welcome! Taler Systems S.A. (“we,” “our,” or “us”) provides a payment
service through our Internet presence (collectively the “Services”).
Before using our Services, please read the Terms of Service (the
“Terms” or the “Agreement”) carefully.
@@ -206,7 +191,7 @@ strong copyleft license, which means that any derivative works must be
distributed under the same license terms as the original software. If
you have any questions, you should review the GNU GPL’s full terms and
conditions at https://www.gnu.org/licenses/gpl-3.0.en.html. “Taler”
-itself is a trademark of Taler Systems SA. You are welcome to use the
+itself is a trademark of Taler Systems S.A.. You are welcome to use the
name in relation to processing payments using the Taler protocol,
assuming your use is compatible with an official release from the GNU
Project that is not older than two years.
@@ -432,16 +417,16 @@ Questions or comments
We welcome comments, questions, concerns, or suggestions. Please send
us a message on our contact page at legal@taler-systems.com.
-`
+`;
-const termsXml = `<?xml version="1.0" encoding="utf-8"?>
+export const termsXml = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE document PUBLIC "+//IDN docutils.sourceforge.net//DTD Docutils Generic//EN//XML" "http://docutils.sourceforge.net/docs/ref/docutils.dtd">
<!-- Generated by Docutils 0.14 -->
<document source="/home/grothoff/research/taler/exchange/contrib/tos/tos.rst">
<section ids="terms-of-service" names="terms\ of\ service">
<title>Terms Of Service</title>
<paragraph>Last Updated: 12.4.2019</paragraph>
- <paragraph>Welcome! Taler Systems SA (“we,” “our,” or “us”) provides a payment service
+ <paragraph>Welcome! Taler Systems S.A. (“we,” “our,” or “us”) provides a payment service
through our Internet presence (collectively the “Services”). Before using our
Services, please read the Terms of Service (the “Terms” or the “Agreement”)
carefully.</paragraph>
@@ -574,7 +559,7 @@ const termsXml = `<?xml version="1.0" encoding="utf-8"?>
license terms as the original software. If you have any questions, you should
review the GNU GPL’s full terms and conditions at
<reference refuri="https://www.gnu.org/licenses/gpl-3.0.en.html">https://www.gnu.org/licenses/gpl-3.0.en.html</reference>. “Taler” itself is a trademark
- of Taler Systems SA. You are welcome to use the name in relation to processing
+ of Taler Systems S.A.. You are welcome to use the name in relation to processing
payments using the Taler protocol, assuming your use is compatible with an
official release from the GNU Project that is not older than two years.</paragraph>
</section>
@@ -780,123 +765,7 @@ const termsXml = `<?xml version="1.0" encoding="utf-8"?>
</document>
`;
-export const NewTerms = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: 0,
- value: 0
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
-
- onSwitchExchange: async () => { },
- terms: {
- value: {
- type: 'xml',
- document: new DOMParser().parseFromString(termsXml, "text/xml"),
- },
- status: 'new'
- },
-})
-
-export const TermsReviewingPLAIN = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: 0,
- value: 0
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
-
- onSwitchExchange: async () => { },
- terms: {
- value: {
- type: 'plain',
- content: termsPlain
- },
- status: 'new'
- },
- reviewing: true
-})
-
-export const TermsReviewingHTML = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: 0,
- value: 0
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
-
- onSwitchExchange: async () => { },
- terms: {
- value: {
- type: 'html',
- href: new URL(`data:text/html;base64,${Buffer.from(termsHtml).toString('base64')}`),
- },
- status: 'new'
- },
- reviewing: true
-})
-
-const termsPdf = `
+export const termsPdf = `
%PDF-1.2
9 0 obj << >>
stream
@@ -909,306 +778,4 @@ endobj
trailer
<< /Root 3 0 R >>
%%EOF
-`
-
-export const TermsReviewingPDF = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: 0,
- value: 0
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
-
- onSwitchExchange: async () => { },
- terms: {
- value: {
- type: 'pdf',
- location: new URL(`data:text/html;base64,${Buffer.from(termsPdf).toString('base64')}`),
- },
- status: 'new'
- },
- reviewing: true
-})
-
-
-export const TermsReviewingXML = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: 0,
- value: 0
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
-
- onSwitchExchange: async () => { },
- terms: {
- value: {
- type: 'xml',
- document: new DOMParser().parseFromString(termsXml, "text/xml"),
- },
- status: 'new'
- },
- reviewing: true
-})
-
-export const NewTermsAccepted = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: 0,
- value: 0
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
- onSwitchExchange: async () => { },
- terms: {
- value: {
- type: 'xml',
- document: new DOMParser().parseFromString(termsXml, "text/xml"),
- },
- status: 'new'
- },
- reviewed: true
-})
-
-export const TermsShowAgainXML = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: 0,
- value: 0
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
-
- onSwitchExchange: async () => { },
- terms: {
- value: {
- type: 'xml',
- document: new DOMParser().parseFromString(termsXml, "text/xml"),
- },
- status: 'new'
- },
- reviewed: true,
- reviewing: true,
-})
-
-export const TermsChanged = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: 0,
- value: 0
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
-
- onSwitchExchange: async () => { },
- terms: {
- value: {
- type: 'xml',
- document: new DOMParser().parseFromString(termsXml, "text/xml"),
- },
- status: 'changed'
- },
-})
-
-export const TermsNotFound = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: 0,
- value: 0
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
-
- onSwitchExchange: async () => { },
- terms: {
- status: 'notfound'
- },
-})
-
-export const TermsAlreadyAccepted = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: amountFractionalBase * 0.5,
- value: 0
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
-
- onSwitchExchange: async () => { },
- terms: {
- status: 'accepted'
- },
-})
-
-
-export const WithoutFee = createExample(TestedComponent, {
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'exchange.demo.taler.net',
- paytoUris: ['asd'],
- }, {
- currency: 'USD',
- exchangeBaseUrl: 'exchange.test.taler.net',
- paytoUris: ['asd'],
- }],
- exchangeBaseUrl: 'exchange.demo.taler.net',
- details: {
- content: '',
- contentType: '',
- currentEtag: '',
- acceptedEtag: undefined,
- },
- withdrawalFee: {
- currency: 'USD',
- fraction: 0,
- value: 0,
- },
- amount: {
- currency: 'USD',
- value: 2,
- fraction: 10000000
- },
-
- onSwitchExchange: async () => { },
- terms: {
- value: {
- type: 'xml',
- document: new DOMParser().parseFromString(termsXml, "text/xml"),
- },
- status: 'accepted',
- }
-}) \ No newline at end of file
+`;
diff --git a/packages/taler-wallet-webextension/src/custom.d.ts b/packages/taler-wallet-webextension/src/custom.d.ts
index 1981067d4..1bcd2a8d0 100644
--- a/packages/taler-wallet-webextension/src/custom.d.ts
+++ b/packages/taler-wallet-webextension/src/custom.d.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -17,11 +17,17 @@ declare module "*.jpeg" {
const content: any;
export default content;
}
+declare module "*.jpg" {
+ const content: any;
+ export default content;
+}
declare module "*.png" {
const content: any;
export default content;
}
-declare module '*.svg' {
+declare module "*.svg" {
const content: any;
export default content;
}
+declare const __VERSION__: string;
+declare const __GIT_HASH__: string;
diff --git a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts
index 2131d45cb..bd430f2ef 100644
--- a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts
+++ b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts
@@ -1,48 +1,98 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { ExchangesListRespose } from "@gnu-taler/taler-util";
-import { useEffect, useState } from "preact/hooks";
-import * as wxApi from "../wxApi";
+import { TalerErrorDetail, TalerError } from "@gnu-taler/taler-util";
+import { useEffect, useMemo, useState } from "preact/hooks";
+import { BackgroundError } from "../wxApi.js";
-interface HookOk<T> {
+export interface HookOk<T> {
hasError: false;
response: T;
}
-interface HookError {
+export type HookError = HookGenericError | HookOperationalError;
+
+export interface HookGenericError {
hasError: true;
+ type: "error";
message: string;
}
+export interface HookOperationalError {
+ hasError: true;
+ type: "taler";
+ message: string;
+ details: TalerErrorDetail;
+}
+
+interface WithRetry {
+ retry: () => void;
+}
+
export type HookResponse<T> = HookOk<T> | HookError | undefined;
+export type HookResponseWithRetry<T> =
+ | ((HookOk<T> | HookError) & WithRetry)
+ | undefined;
-export function useAsyncAsHook<T> (fn: (() => Promise<T>)): HookResponse<T> {
+export function useAsyncAsHook<T>(
+ fn: () => Promise<T | false>,
+ deps?: unknown[],
+): HookResponseWithRetry<T> {
const [result, setHookResponse] = useState<HookResponse<T>>(undefined);
- useEffect(() => {
- async function doAsync() {
- try {
- const response = await fn();
- setHookResponse({ hasError: false, response });
- } catch (e) {
- if (e instanceof Error) {
- setHookResponse({ hasError: true, message: e.message });
- }
+
+ const args = useMemo(
+ () => ({
+ fn,
+ }),
+ deps || [],
+ );
+
+ async function doAsync(): Promise<void> {
+ try {
+ const response = await args.fn();
+ if (response === false) return;
+ setHookResponse({ hasError: false, response });
+ } catch (e) {
+ if (e instanceof TalerError) {
+ setHookResponse({
+ hasError: true,
+ type: "taler",
+ message: e.message,
+ details: e.errorDetail,
+ });
+ } else if (e instanceof BackgroundError) {
+ setHookResponse({
+ hasError: true,
+ type: "taler",
+ message: e.message,
+ details: e.errorDetail,
+ });
+ } else if (e instanceof Error) {
+ setHookResponse({
+ hasError: true,
+ type: "error",
+ message: e.message,
+ });
}
}
- doAsync()
- }, []);
- return result;
+ }
+
+ useEffect(() => {
+ doAsync();
+ }, [args]);
+
+ if (!result) return undefined;
+ return { ...result, retry: doAsync };
}
diff --git a/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts b/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts
index f3b1b3b5f..6288b6986 100644
--- a/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts
+++ b/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,37 +14,41 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { useEffect, useState } from "preact/hooks";
-import * as wxApi from "../wxApi";
-
+import { useBackendContext } from "../context/backend.js";
export interface BackupDeviceName {
name: string;
- update: (s:string) => Promise<void>
+ update: (s: string) => Promise<void>;
}
-
export function useBackupDeviceName(): BackupDeviceName {
const [status, setStatus] = useState<BackupDeviceName>({
- name: '',
- update: () => Promise.resolve()
- })
+ name: "",
+ update: () => Promise.resolve(),
+ });
+ const api = useBackendContext();
useEffect(() => {
- async function run() {
+ async function run(): Promise<void> {
//create a first list of backup info by currency
- const status = await wxApi.getBackupInfo()
-
- async function update(newName: string) {
- await wxApi.setWalletDeviceId(newName)
- setStatus(old => ({ ...old, name: newName }))
+ const status = await api.wallet.call(
+ WalletApiOperation.GetBackupInfo,
+ {},
+ );
+
+ async function update(newName: string): Promise<void> {
+ await api.wallet.call(WalletApiOperation.SetWalletDeviceId, {
+ walletDeviceId: newName,
+ });
+ setStatus((old) => ({ ...old, name: newName }));
}
- setStatus({ name: status.deviceId, update })
+ setStatus({ name: status.deviceId, update });
}
- run()
- }, [])
+ run();
+ }, []);
- return status
+ return status;
}
-
diff --git a/packages/taler-wallet-webextension/src/hooks/useBackupStatus.ts b/packages/taler-wallet-webextension/src/hooks/useBackupStatus.ts
deleted file mode 100644
index c46ab6a5f..000000000
--- a/packages/taler-wallet-webextension/src/hooks/useBackupStatus.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { ProviderInfo, ProviderPaymentPaid, ProviderPaymentStatus, ProviderPaymentType } from "@gnu-taler/taler-wallet-core";
-import { useEffect, useState } from "preact/hooks";
-import * as wxApi from "../wxApi";
-
-
-export interface BackupStatus {
- deviceName: string;
- providers: ProviderInfo[];
- sync: () => Promise<void>;
-}
-
-function getStatusTypeOrder(t: ProviderPaymentStatus) {
- return [
- ProviderPaymentType.InsufficientBalance,
- ProviderPaymentType.TermsChanged,
- ProviderPaymentType.Unpaid,
- ProviderPaymentType.Paid,
- ProviderPaymentType.Pending,
- ].indexOf(t.type)
-}
-
-function getStatusPaidOrder(a: ProviderPaymentPaid, b: ProviderPaymentPaid) {
- return a.paidUntil.t_ms === 'never' ? -1 :
- b.paidUntil.t_ms === 'never' ? 1 :
- a.paidUntil.t_ms - b.paidUntil.t_ms
-}
-
-export function useBackupStatus(): BackupStatus | undefined {
- const [status, setStatus] = useState<BackupStatus | undefined>(undefined)
-
- useEffect(() => {
- async function run() {
- //create a first list of backup info by currency
- const status = await wxApi.getBackupInfo()
-
- const providers = status.providers.sort((a, b) => {
- if (a.paymentStatus.type === ProviderPaymentType.Paid && b.paymentStatus.type === ProviderPaymentType.Paid) {
- return getStatusPaidOrder(a.paymentStatus, b.paymentStatus)
- }
- return getStatusTypeOrder(a.paymentStatus) - getStatusTypeOrder(b.paymentStatus)
- })
-
- async function sync() {
- await wxApi.syncAllProviders()
- }
-
- setStatus({ deviceName: status.deviceId, providers, sync })
- }
- run()
- }, [])
-
- return status
-}
-
-
diff --git a/packages/taler-wallet-webextension/src/hooks/useBalances.ts b/packages/taler-wallet-webextension/src/hooks/useBalances.ts
deleted file mode 100644
index 37424fb05..000000000
--- a/packages/taler-wallet-webextension/src/hooks/useBalances.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { BalancesResponse } from "@gnu-taler/taler-util";
-import { useEffect, useState } from "preact/hooks";
-import * as wxApi from "../wxApi";
-
-
-interface BalancesHookOk {
- hasError: false;
- response: BalancesResponse;
-}
-
-interface BalancesHookError {
- hasError: true;
- message: string;
-}
-
-export type BalancesHook = BalancesHookOk | BalancesHookError | undefined;
-
-export function useBalances(): BalancesHook {
- const [balance, setBalance] = useState<BalancesHook>(undefined);
- useEffect(() => {
- async function checkBalance() {
- try {
- const response = await wxApi.getBalance();
- console.log("got balance", balance);
- setBalance({ hasError: false, response });
- } catch (e) {
- console.error("could not retrieve balances", e);
- if (e instanceof Error) {
- setBalance({ hasError: true, message: e.message });
- }
- }
- }
- checkBalance()
- return wxApi.onUpdateNotification(checkBalance);
- }, []);
-
- return balance;
-}
diff --git a/packages/taler-wallet-webextension/src/hooks/useClipboardPermissions.ts b/packages/taler-wallet-webextension/src/hooks/useClipboardPermissions.ts
new file mode 100644
index 000000000..35b7148cc
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/hooks/useClipboardPermissions.ts
@@ -0,0 +1,76 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useEffect, useState } from "preact/hooks";
+import { useAlertContext } from "../context/alert.js";
+import { useBackendContext } from "../context/backend.js";
+import { ToggleHandler } from "../mui/handlers.js";
+import { platform } from "../platform/foreground.js";
+
+/**
+ * This is not implemented.
+ * Clipboard permission need to get ask the permission to the user
+ * based on user-intention
+ * @returns
+ */
+export function useClipboardPermissions(): ToggleHandler {
+ const [enabled, setEnabled] = useState(false);
+ const api = useBackendContext();
+ const { pushAlertOnError } = useAlertContext();
+
+ async function handleClipboardPerm(): Promise<void> {
+ if (!enabled) {
+ // We set permissions here, since apparently FF wants this to be done
+ // as the result of an input event ...
+ let granted: boolean;
+ try {
+ granted = await platform
+ .getPermissionsApi()
+ .requestClipboardPermissions();
+ } catch (lastError) {
+ setEnabled(false);
+ throw lastError;
+ }
+ setEnabled(granted);
+ } else {
+ // try {
+ // await api.background
+ // .call("toggleHeaderListener", false)
+ // .then((r) => setEnabled(r.newValue));
+ // } catch (e) {
+ // }
+ }
+ return;
+ }
+
+ // useEffect(() => {
+ // async function getValue(): Promise<void> {
+ // const res = await api.background.call(
+ // "containsHeaderListener",
+ // undefined,
+ // );
+ // setEnabled(res.newValue);
+ // }
+ // getValue();
+ // }, []);
+
+ return {
+ value: enabled,
+ button: {
+ onClick: pushAlertOnError(handleClipboardPerm),
+ },
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/hooks/useDiagnostics.ts b/packages/taler-wallet-webextension/src/hooks/useDiagnostics.ts
deleted file mode 100644
index 888d4d5f1..000000000
--- a/packages/taler-wallet-webextension/src/hooks/useDiagnostics.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { WalletDiagnostics } from "@gnu-taler/taler-util";
-import { useEffect, useState } from "preact/hooks";
-import * as wxApi from "../wxApi";
-
-export function useDiagnostics(): [WalletDiagnostics | undefined, boolean] {
- const [timedOut, setTimedOut] = useState(false);
- const [diagnostics, setDiagnostics] = useState<WalletDiagnostics | undefined>(
- undefined
- );
-
- useEffect(() => {
- let gotDiagnostics = false;
- setTimeout(() => {
- if (!gotDiagnostics) {
- console.error("timed out");
- setTimedOut(true);
- }
- }, 1000);
- const doFetch = async (): Promise<void> => {
- const d = await wxApi.getDiagnostics();
- console.log("got diagnostics", d);
- gotDiagnostics = true;
- setDiagnostics(d);
- };
- console.log("fetching diagnostics");
- doFetch();
- }, []);
- return [diagnostics, timedOut]
-} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/hooks/useExtendedPermissions.ts b/packages/taler-wallet-webextension/src/hooks/useExtendedPermissions.ts
deleted file mode 100644
index a92425760..000000000
--- a/packages/taler-wallet-webextension/src/hooks/useExtendedPermissions.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { useState, useEffect } from "preact/hooks";
-import * as wxApi from "../wxApi";
-import { getPermissionsApi } from "../compat";
-import { extendedPermissions } from "../permissions";
-
-
-export function useExtendedPermissions(): [boolean, () => void] {
- const [enabled, setEnabled] = useState(false);
-
- const toggle = () => {
- setEnabled(v => !v);
- handleExtendedPerm(enabled).then(result => {
- setEnabled(result);
- });
- };
-
- useEffect(() => {
- async function getExtendedPermValue(): Promise<void> {
- const res = await wxApi.getExtendedPermissions();
- setEnabled(res.newValue);
- }
- getExtendedPermValue();
- }, []);
- return [enabled, toggle];
-}
-
-async function handleExtendedPerm(isEnabled: boolean): Promise<boolean> {
- let nextVal: boolean | undefined;
-
- if (!isEnabled) {
- const granted = await new Promise<boolean>((resolve, reject) => {
- // We set permissions here, since apparently FF wants this to be done
- // as the result of an input event ...
- getPermissionsApi().request(extendedPermissions, (granted: boolean) => {
- if (chrome.runtime.lastError) {
- console.error("error requesting permissions");
- console.error(chrome.runtime.lastError);
- reject(chrome.runtime.lastError);
- return;
- }
- console.log("permissions granted:", granted);
- resolve(granted);
- });
- });
- const res = await wxApi.setExtendedPermissions(granted);
- nextVal = res.newValue;
- } else {
- const res = await wxApi.setExtendedPermissions(false);
- nextVal = res.newValue;
- }
- console.log("new permissions applied:", nextVal ?? false);
- return nextVal ?? false
-} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/hooks/useIsOnline.ts b/packages/taler-wallet-webextension/src/hooks/useIsOnline.ts
new file mode 100644
index 000000000..8d26bf3b6
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/hooks/useIsOnline.ts
@@ -0,0 +1,14 @@
+import { codecForBoolean } from "@gnu-taler/taler-util";
+import { buildStorageKey, useMemoryStorage } from "@gnu-taler/web-util/browser";
+import { platform } from "../platform/foreground.js";
+import { useEffect } from "preact/hooks";
+
+export function useIsOnline(): boolean {
+ const { value, update } = useMemoryStorage("online", true);
+ useEffect(() => {
+ return platform.listenNetworkConnectionState((state) => {
+ update(state === "on");
+ });
+ });
+ return value;
+}
diff --git a/packages/taler-wallet-webextension/src/hooks/useLocalStorage.ts b/packages/taler-wallet-webextension/src/hooks/useLocalStorage.ts
deleted file mode 100644
index 78a8b65d5..000000000
--- a/packages/taler-wallet-webextension/src/hooks/useLocalStorage.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { StateUpdater, useState } from "preact/hooks";
-
-export function useLocalStorage(key: string, initialValue?: string): [string | undefined, StateUpdater<string | undefined>] {
- const [storedValue, setStoredValue] = useState<string | undefined>((): string | undefined => {
- return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue;
- });
-
- const setValue = (value?: string | ((val?: string) => string | undefined)) => {
- setStoredValue(p => {
- const toStore = value instanceof Function ? value(p) : value
- if (typeof window !== "undefined") {
- if (!toStore) {
- window.localStorage.removeItem(key)
- } else {
- window.localStorage.setItem(key, toStore);
- }
- }
- return toStore
- })
- };
-
- return [storedValue, setValue];
-}
-
-//TODO: merge with the above function
-export function useNotNullLocalStorage(key: string, initialValue: string): [string, StateUpdater<string>] {
- const [storedValue, setStoredValue] = useState<string>((): string => {
- return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue;
- });
-
- const setValue = (value: string | ((val: string) => string)) => {
- const valueToStore = value instanceof Function ? value(storedValue) : value;
- setStoredValue(valueToStore);
- if (typeof window !== "undefined") {
- if (!valueToStore) {
- window.localStorage.removeItem(key)
- } else {
- window.localStorage.setItem(key, valueToStore);
- }
- }
- };
-
- return [storedValue, setValue];
-}
diff --git a/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts b/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts
index 6520848a5..e2ba5b285 100644
--- a/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts
+++ b/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,9 +14,10 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { ProviderInfo } from "@gnu-taler/taler-wallet-core";
+import { ProviderInfo } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { useEffect, useState } from "preact/hooks";
-import * as wxApi from "../wxApi";
+import { useBackendContext } from "../context/backend.js";
export interface ProviderStatus {
info?: ProviderInfo;
@@ -26,31 +27,40 @@ export interface ProviderStatus {
export function useProviderStatus(url: string): ProviderStatus | undefined {
const [status, setStatus] = useState<ProviderStatus | undefined>(undefined);
-
+ const api = useBackendContext();
useEffect(() => {
- async function run() {
+ async function run(): Promise<void> {
//create a first list of backup info by currency
- const status = await wxApi.getBackupInfo();
+ const status = await api.wallet.call(
+ WalletApiOperation.GetBackupInfo,
+ {},
+ );
- const providers = status.providers.filter(p => p.syncProviderBaseUrl === url);
+ const providers = status.providers.filter(
+ (p) => p.syncProviderBaseUrl === url,
+ );
const info = providers.length ? providers[0] : undefined;
- async function sync() {
+ async function sync(): Promise<void> {
if (info) {
- await wxApi.syncOneProvider(info.syncProviderBaseUrl);
+ await api.wallet.call(WalletApiOperation.RunBackupCycle, {
+ providers: [info.syncProviderBaseUrl],
+ });
}
}
- async function remove() {
+ async function remove(): Promise<void> {
if (info) {
- await wxApi.removeProvider(info.syncProviderBaseUrl);
+ await api.wallet.call(WalletApiOperation.RemoveBackupProvider, {
+ provider: info.syncProviderBaseUrl,
+ });
}
}
setStatus({ info, sync, remove });
}
run();
- }, []);
+ });
return status;
}
diff --git a/packages/taler-wallet-webextension/src/hooks/useSelectedExchange.ts b/packages/taler-wallet-webextension/src/hooks/useSelectedExchange.ts
new file mode 100644
index 000000000..6907a247d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/hooks/useSelectedExchange.ts
@@ -0,0 +1,141 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ExchangeListItem } from "@gnu-taler/taler-util";
+import { useState } from "preact/hooks";
+import { useAlertContext } from "../context/alert.js";
+import { ButtonHandler } from "../mui/handlers.js";
+
+type State = State.Ready | State.NoExchangeFound | State.Selecting;
+
+export namespace State {
+ export interface NoExchangeFound {
+ status: "no-exchange-found";
+ error: undefined;
+ currency: string;
+ defaultExchange: string | undefined;
+ }
+ export interface Ready {
+ status: "ready";
+ doSelect: ButtonHandler;
+ selected: ExchangeListItem;
+ }
+ export interface Selecting {
+ status: "selecting-exchange";
+ error: undefined;
+ onSelection: (url: string) => Promise<void>;
+ onCancel: () => Promise<void>;
+ list: ExchangeListItem[];
+ currency: string;
+ initialValue: string;
+ }
+}
+
+interface Props {
+ currency: string;
+ //there is a preference for the default at the initial state
+ defaultExchange?: string;
+ //list of exchanges
+ list: ExchangeListItem[];
+}
+
+export function useSelectedExchange({
+ currency,
+ defaultExchange,
+ list,
+}: Props): State {
+ const [isSelecting, setIsSelecting] = useState(false);
+ const [selectedExchange, setSelectedExchange] = useState<string | undefined>(
+ undefined,
+ );
+ const { pushAlertOnError } = useAlertContext();
+
+ if (!list.length) {
+ return {
+ status: "no-exchange-found",
+ error: undefined,
+ currency,
+ defaultExchange,
+ };
+ }
+
+ const exchangesWithThisCurrency = list.filter((e) => e.currency === currency);
+ if (!exchangesWithThisCurrency.length) {
+ // there should be at least one exchange for this currency
+ return {
+ status: "no-exchange-found",
+ error: undefined,
+ currency,
+ defaultExchange,
+ };
+ }
+
+ if (isSelecting) {
+ const currentExchange =
+ selectedExchange ??
+ defaultExchange ??
+ exchangesWithThisCurrency[0].exchangeBaseUrl;
+ return {
+ status: "selecting-exchange",
+ error: undefined,
+ list: exchangesWithThisCurrency,
+ currency,
+ initialValue: currentExchange,
+ onSelection: async (exchangeBaseUrl: string) => {
+ setIsSelecting(false);
+ setSelectedExchange(exchangeBaseUrl);
+ },
+ onCancel: async () => {
+ setIsSelecting(false);
+ },
+ };
+ }
+
+ {
+ const found = !selectedExchange
+ ? undefined
+ : list.find((e) => e.exchangeBaseUrl === selectedExchange);
+ if (found)
+ return {
+ status: "ready",
+ doSelect: {
+ onClick: pushAlertOnError(async () => setIsSelecting(true)),
+ },
+ selected: found,
+ };
+ }
+ {
+ const found = !defaultExchange
+ ? undefined
+ : list.find((e) => e.exchangeBaseUrl === defaultExchange);
+ if (found)
+ return {
+ status: "ready",
+ doSelect: {
+ onClick: pushAlertOnError(async () => setIsSelecting(true)),
+ },
+ selected: found,
+ };
+ }
+
+ return {
+ status: "ready",
+ doSelect: {
+ onClick: pushAlertOnError(async () => setIsSelecting(true)),
+ },
+ selected: exchangesWithThisCurrency[0],
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/hooks/useSettings.ts b/packages/taler-wallet-webextension/src/hooks/useSettings.ts
new file mode 100644
index 000000000..a79a71087
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/hooks/useSettings.ts
@@ -0,0 +1,64 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
+import { Settings, defaultSettings } from "../platform/api.js";
+import {
+ Codec,
+ buildCodecForObject,
+ codecForBoolean,
+} from "@gnu-taler/taler-util";
+
+function parse_json_or_undefined<T>(str: string | undefined): T | undefined {
+ if (str === undefined) return undefined;
+ try {
+ return JSON.parse(str);
+ } catch {
+ return undefined;
+ }
+}
+
+export const codecForSettings = (): Codec<Settings> =>
+ buildCodecForObject<Settings>()
+ .property("walletAllowHttp", codecForBoolean())
+ .property("injectTalerSupport", codecForBoolean())
+ .property("autoOpen", codecForBoolean())
+ .property("advancedMode", codecForBoolean())
+ .property("backup", codecForBoolean())
+ .property("langSelector", codecForBoolean())
+ .property("showJsonOnError", codecForBoolean())
+ .property("extendedAccountTypes", codecForBoolean())
+ .property("suspendIndividualTransaction", codecForBoolean())
+ .property("showRefeshTransactions", codecForBoolean())
+ .property("showExchangeManagement", codecForBoolean())
+ .property("selectTosFormat", codecForBoolean())
+ .property("showWalletActivity", codecForBoolean())
+ .build("Settings");
+
+const SETTINGS_KEY = buildStorageKey("wallet-settings", codecForSettings());
+
+export function useSettings(): [
+ Readonly<Settings>,
+ <T extends keyof Settings>(key: T, value: Settings[T]) => void,
+] {
+ const { value, update } = useLocalStorage(SETTINGS_KEY, defaultSettings);
+
+ function updateField<T extends keyof Settings>(k: T, v: Settings[T]) {
+ update({ ...value, [k]: v });
+ }
+
+ return [value, updateField];
+}
diff --git a/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.test.ts b/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.test.ts
new file mode 100644
index 000000000..0bb47530c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.test.ts
@@ -0,0 +1,58 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { expect } from "chai";
+import { h, VNode } from "preact";
+import { IoCProviderForTesting } from "../context/iocContext.js";
+import { useTalerActionURL } from "./useTalerActionURL.js";
+import * as tests from "@gnu-taler/web-util/testing";
+
+describe("useTalerActionURL hook", () => {
+ it("should be set url to undefined when dismiss", async () => {
+ const ctx = ({ children }: { children: any }): VNode => {
+ return h(IoCProviderForTesting, {
+ value: {
+ findTalerUriInActiveTab: async () => "asd",
+ findTalerUriInClipboard: async () => "qwe",
+ },
+ children,
+ });
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useTalerActionURL,
+ {},
+ [
+ ([url]) => {
+ expect(url).undefined;
+ },
+ ([url, setDismissed]) => {
+ expect(url).deep.equals({
+ location: "clipboard",
+ uri: "qwe",
+ });
+ setDismissed(true);
+ },
+ ([url]) => {
+ if (url !== undefined) throw Error("invalid");
+ expect(url).undefined;
+ },
+ ],
+ ctx,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.ts b/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.ts
index ff9cc029a..39b76c341 100644
--- a/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.ts
+++ b/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,46 +14,45 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { classifyTalerUri, TalerUriType } from "@gnu-taler/taler-util";
import { useEffect, useState } from "preact/hooks";
+import { useIocContext } from "../context/iocContext.js";
-export function useTalerActionURL(): [string | undefined, (s: boolean) => void] {
- const [talerActionUrl, setTalerActionUrl] = useState<string | undefined>(
- undefined
+export interface UriLocation {
+ uri: string;
+ location: "clipboard" | "activeTab";
+}
+
+export function useTalerActionURL(): [
+ UriLocation | undefined,
+ (s: boolean) => void,
+] {
+ const [talerActionUrl, setTalerActionUrl] = useState<UriLocation | undefined>(
+ undefined,
);
const [dismissed, setDismissed] = useState(false);
+ const { findTalerUriInActiveTab, findTalerUriInClipboard } = useIocContext();
useEffect(() => {
async function check(): Promise<void> {
- const talerUri = await findTalerUriInActiveTab();
- setTalerActionUrl(talerUri)
+ const clipUri = await findTalerUriInClipboard();
+ if (clipUri) {
+ setTalerActionUrl({
+ location: "clipboard",
+ uri: clipUri,
+ });
+ return;
+ }
+ const tabUri = await findTalerUriInActiveTab();
+ if (tabUri) {
+ setTalerActionUrl({
+ location: "activeTab",
+ uri: tabUri,
+ });
+ return;
+ }
}
check();
}, []);
+
const url = dismissed ? undefined : talerActionUrl;
return [url, setDismissed];
}
-
-async function findTalerUriInActiveTab(): Promise<string | undefined> {
- return new Promise((resolve, reject) => {
- chrome.tabs.executeScript(
- {
- code: `
- (() => {
- let x = document.querySelector("a[href^='taler://'") || document.querySelector("a[href^='taler+http://'");
- return x ? x.href.toString() : null;
- })();
- `,
- allFrames: false,
- },
- (result) => {
- if (chrome.runtime.lastError) {
- console.error(chrome.runtime.lastError);
- resolve(undefined);
- return;
- }
- console.log("got result", result);
- resolve(result[0]);
- },
- );
- });
-}
diff --git a/packages/taler-wallet-webextension/src/i18n/de.po b/packages/taler-wallet-webextension/src/i18n/de.po
index bb355403d..1a285499c 100644
--- a/packages/taler-wallet-webextension/src/i18n/de.po
+++ b/packages/taler-wallet-webextension/src/i18n/de.po
@@ -1,344 +1,2078 @@
-# This file is part of TALER
-# (C) 2016 GNUnet e.V.
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
#
-# TALER is free software; you can redistribute it and/or modify it under the
+# GNU Taler is free software; you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation; either version 3, or (at your option) any later version.
#
-# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
-# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
#
-#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Taler Wallet\n"
-"Report-Msgid-Bugs-To: \n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
-"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
-"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\n"
-"Language: \n"
+"PO-Revision-Date: 2023-11-25 17:24+0000\n"
+"Last-Translator: Stefan Kügel <skuegel@web.de>\n"
+"Language-Team: German <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/de/>\n"
+"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/NavigationBar.tsx:139
+#, c-format
+msgid "Balance"
+msgstr "Guthaben"
-#: src/util/wire.ts:37
+#: src/NavigationBar.tsx:142
#, c-format
-msgid "Invalid Wire"
+msgid "Backup"
+msgstr "Backup"
+
+#: src/NavigationBar.tsx:147
+#, c-format
+msgid "QR Reader and Taler URI"
msgstr ""
-#: src/util/wire.ts:42 src/util/wire.ts:45
+#: src/NavigationBar.tsx:154
+#, c-format
+msgid "Settings"
+msgstr "Einstellungen"
+
+#: src/NavigationBar.tsx:184
#, c-format
-msgid "Invalid Test Wire Detail"
+msgid "Dev"
+msgstr "Dev"
+
+#: src/mui/Typography.tsx:122
+#, c-format
+msgid "%1$s"
msgstr ""
-#: src/util/wire.ts:47
+#: src/components/PendingTransactions.tsx:74
#, c-format
-msgid "Test Wire Acct #%1$s on %2$s"
+msgid "PENDING OPERATIONS"
msgstr ""
-#: src/util/wire.ts:49
+#: src/components/Loading.tsx:36
+#, c-format
+msgid "Loading"
+msgstr "Lädt Daten"
+
+#: src/wallet/BackupPage.tsx:123
#, c-format
-msgid "Unknown Wire Detail"
+msgid "Could not load backup providers"
msgstr ""
-#: src/webex/pages/benchmark.tsx:52
+#: src/wallet/BackupPage.tsx:202
#, c-format
-msgid "Operation"
+msgid "No backup providers configured"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:205
+#, c-format
+msgid "Add provider"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:219
+#, c-format
+msgid "Sync all backups"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:221
+#, c-format
+msgid "Sync now"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:264
+#, c-format
+msgid "Last synced"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:269
+#, c-format
+msgid "Not synced"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:289
+#, c-format
+msgid "Expires in"
msgstr ""
-#: src/webex/pages/benchmark.tsx:53
+#: src/wallet/ProviderDetailPage.tsx:60
#, c-format
-msgid "time (ms/op)"
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
msgstr ""
-#: src/webex/pages/pay.tsx:130
+#: src/wallet/ProviderDetailPage.tsx:108
+#, c-format
+msgid "There is not known provider with url &quot;%1$s&quot;."
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:115
+#, c-format
+msgid "See providers"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:143
+#, c-format
+msgid "Last backup"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:148
+#, c-format
+msgid "Back up"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:154
+#, c-format
+msgid "Provider fee"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:157
+#, c-format
+msgid "per year"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:163
+#, c-format
+msgid "Extend"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:169
+#, c-format
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms "
+"of service"
+msgstr ""
+"Die AGB sind geändert worden, eine Weiternutzung des Diensts erfordert die "
+"Einwilligung in die neuen Allgemeinen Geschäftsbedingungen (AGB)"
+
+#: src/wallet/ProviderDetailPage.tsx:179
+#, c-format
+msgid "old"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:183
+#, c-format
+msgid "new"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:190
+#, c-format
+msgid "fee"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:198
+#, c-format
+msgid "storage"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:215
+#, c-format
+msgid "Remove provider"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:228
+#, c-format
+msgid "This provider has reported an error"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:242
+#, c-format
+msgid "There is conflict with another backup from %1$s"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:253
+#, c-format
+msgid "Backup is not readable"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:261
+#, c-format
+msgid "Unknown backup problem: %1$s"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:283
+#, c-format
+msgid "service paid"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format
+msgid "Backup valid until"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:57
+#, c-format
+msgid "Cancel"
+msgstr "Abbrechen"
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr "Seite der Reserve aufrufen"
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr "Seite für Zahlungen aufrufen"
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr "Seite für Rückerstattungen aufrufen"
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr "Seite der Aufwandsentschädigungen aufrufen"
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr "Abhebeseite öffnen"
+
+#: src/popup/NoBalanceHelp.tsx:43
#, fuzzy, c-format
-msgid "The merchant %1$s offers you to purchase:"
-msgstr "Der Händler %1$s möchte einen Vertrag über %2$s mit Ihnen abschließen."
+msgid "Get digital cash"
+msgstr "Digitales Bargeld abheben"
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr "Konnte die Umsatzanzeige nicht laden"
-#: src/webex/pages/pay.tsx:136
+#: src/popup/BalancePage.tsx:175
#, c-format
-msgid "The total price is %1$s (plus %2$s fees)."
+msgid "Add"
msgstr ""
-#: src/webex/pages/pay.tsx:141
+#: src/popup/BalancePage.tsx:179
#, c-format
-msgid "The total price is %1$s."
+msgid "Send %1$s"
msgstr ""
-#: src/webex/pages/pay.tsx:163
+#: src/popup/TalerActionFound.tsx:44
#, c-format
-msgid "Retry"
+msgid "Taler Action"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
msgstr ""
-#: src/webex/pages/pay.tsx:173
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr ""
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:158
#, fuzzy, c-format
-msgid "Confirm payment"
-msgstr "Bezahlung bestätigen"
+msgid "Could not load purchase proposal details"
+msgstr "Konnte die Umsatzanzeige nicht laden"
-#: src/webex/pages/popup.tsx:153
+#: src/components/ShowFullContractTermPopup.tsx:183
#, c-format
-msgid "Balance"
-msgstr "Saldo"
+msgid "Order Id"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr "Betrag"
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, fuzzy, c-format
+msgid "Exchanges"
+msgstr "Exchange"
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
+msgstr ""
-#: src/webex/pages/popup.tsx:154
+#: src/components/Part.tsx:160
#, c-format
-msgid "History"
-msgstr "Verlauf"
+msgid "Bitcoin address"
+msgstr ""
-#: src/webex/pages/popup.tsx:155
+#: src/components/Part.tsx:163
#, c-format
-msgid "Debug"
-msgstr "Debug"
+msgid "IBAN"
+msgstr ""
-#: src/webex/pages/popup.tsx:175
+#: src/cta/Deposit/views.tsx:38
#, fuzzy, c-format
-msgid "You have no balance to show. Need some %1$s getting started?"
-msgstr "Sie haben kein Digitalgeld. Wollen Sie %1$s? abheben?"
+msgid "Could not load deposit status"
+msgstr "Konnte die Umsatzanzeige nicht laden"
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr ""
-#: src/webex/pages/popup.tsx:238
+#: src/cta/Deposit/views.tsx:73
#, c-format
-msgid "%1$s incoming"
+msgid "To be received"
msgstr ""
-#: src/webex/pages/popup.tsx:250
+#: src/cta/Deposit/views.tsx:84
#, c-format
-msgid "%1$s being spent"
+msgid "Send &nbsp; %1$s"
msgstr ""
-#: src/webex/pages/popup.tsx:281
+#: src/components/BankDetailsByPaytoType.tsx:63
#, c-format
-msgid "Error: could not retrieve balance information."
+msgid "Bitcoin transfer details"
msgstr ""
-#: src/webex/pages/popup.tsx:390
+#: src/components/BankDetailsByPaytoType.tsx:66
#, c-format
-msgid "Invalid "
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an "
+"minimum amount."
msgstr ""
-#: src/webex/pages/popup.tsx:396
+#: src/components/BankDetailsByPaytoType.tsx:74
#, c-format
-msgid "Fees "
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two "
+"additional recipient and copy addresses and amounts"
msgstr ""
-#: src/webex/pages/popup.tsx:434
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid ""
+"Make sure the amount show %1$s BTC, else you have to change the base unit to "
+"BTC"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr "Verwendungszweck"
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr "Erneut versuchen"
+
+#: src/wallet/Transaction.tsx:224
#, c-format
-msgid "Refresh sessions has completed"
+msgid "Forget"
msgstr ""
-#: src/webex/pages/popup.tsx:451
+#: src/wallet/Transaction.tsx:241
#, c-format
-msgid "Order Refused"
+msgid "Caution!"
msgstr ""
-#: src/webex/pages/popup.tsx:465
+#: src/wallet/Transaction.tsx:244
#, c-format
-msgid "Order redirected"
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to "
+"get the coins form it."
msgstr ""
-#: src/webex/pages/popup.tsx:482
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr "Bestätigen"
+
+#: src/wallet/Transaction.tsx:267
+#, c-format
+msgid "Withdrawal"
+msgstr "Abheben"
+
+#: src/wallet/Transaction.tsx:286
#, c-format
-msgid "Payment aborted"
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
msgstr ""
-#: src/webex/pages/popup.tsx:512
+#: src/wallet/Transaction.tsx:298
#, c-format
-msgid "Payment Sent"
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and "
+"check there is no pending step."
msgstr ""
-#: src/webex/pages/popup.tsx:536
+#: src/wallet/Transaction.tsx:316
#, c-format
-msgid "Order accepted"
+msgid ""
+"Bank has confirmed the wire transfer. Waiting for the exchange to send the "
+"coins"
msgstr ""
-#: src/webex/pages/popup.tsx:547
+#: src/wallet/Transaction.tsx:325
#, c-format
-msgid "Reserve balance updated"
+msgid "Details"
msgstr ""
-#: src/webex/pages/popup.tsx:559
+#: src/wallet/Transaction.tsx:360
+#, c-format
+msgid "Payment"
+msgstr "Zahlung"
+
+#: src/wallet/Transaction.tsx:378
#, c-format
-msgid "Payment refund"
+msgid "Refunds"
msgstr ""
-#: src/webex/pages/popup.tsx:584
+#: src/wallet/Transaction.tsx:385
#, fuzzy, c-format
-msgid "Withdrawn"
-msgstr "Abheben bei %1$s"
+msgid "%1$s %2$s on %3$s"
+msgstr ""
+"%1$s\n"
+" möchte einen Vertrag über %2$s\n"
+" mit Ihnen abschließen."
-#: src/webex/pages/popup.tsx:596
+#: src/wallet/Transaction.tsx:415
#, c-format
-msgid "Tip Accepted"
+msgid ""
+"Merchant created a refund for this order but was not automatically picked up."
msgstr ""
-#: src/webex/pages/popup.tsx:606
+#: src/wallet/Transaction.tsx:420
#, c-format
-msgid "Tip Declined"
+msgid "Offer"
msgstr ""
-#: src/webex/pages/popup.tsx:615
+#: src/wallet/Transaction.tsx:431
#, c-format
-msgid "%1$s"
+msgid "Accept"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:496
+#, c-format
+msgid "Refresh"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr "Exchange"
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
msgstr ""
-#: src/webex/pages/popup.tsx:707
+#: src/wallet/Transaction.tsx:667
#, c-format
-msgid "Your wallet has no events recorded."
-msgstr "Ihre Geldbörse verzeichnet keine Vorkommnisse."
+msgid "Debit"
+msgstr ""
-#: src/webex/pages/return-coins.tsx:124
+#: src/wallet/Transaction.tsx:710
#, c-format
-msgid "Wire to bank account"
+msgid "Transfer"
msgstr ""
-#: src/webex/pages/return-coins.tsx:206
+#: src/wallet/Transaction.tsx:844
#, fuzzy, c-format
-msgid "Confirm"
-msgstr "Bezahlung bestätigen"
+msgid "Country"
+msgstr "Betrag"
-#: src/webex/pages/return-coins.tsx:209
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr "Datum"
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1074
+#, c-format
+msgid "Withdraw"
+msgstr "Abheben"
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr "Rückerstattet"
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1335
#, fuzzy, c-format
-msgid "Cancel"
-msgstr "Saldo"
+msgid "Total transfer"
+msgstr "Insgesamt abgehoben"
-#: src/webex/pages/withdraw.tsx:73
+#: src/cta/Payment/views.tsx:57
#, c-format
-msgid "Could not get details for withdraw operation:"
+msgid "Could not load pay status"
msgstr ""
-#: src/webex/pages/withdraw.tsx:89 src/webex/pages/withdraw.tsx:183
+#: src/cta/Payment/views.tsx:87
#, c-format
-msgid "Chose different exchange provider"
+msgid "Digital cash payment"
msgstr ""
-#: src/webex/pages/withdraw.tsx:109
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:364
#, c-format
msgid ""
-"Please select an exchange. You can review the details before after your "
-"selection."
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:366
+#, fuzzy, c-format
+msgid "Your current balance is not enough."
+msgstr "Es gibt kein Guthaben anzuzeigen."
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:34
+#, fuzzy, c-format
+msgid "Could not load refund status"
+msgstr "Konnte die Umsatzanzeige nicht laden"
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:76
+#, fuzzy, c-format
+msgid "Total to refund"
+msgstr "Insgesamt abgehoben"
+
+#: src/cta/Refund/views.tsx:106
+#, fuzzy, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr ""
+"Der Händler %1$s bietet Ihnen eine Aufwandsentschädigung von %2$s durch den "
+"Exchange %3$s"
+
+#: src/cta/Refund/views.tsx:115
+#, c-format
+msgid "Order amount"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:32
+#, fuzzy, c-format
+msgid "Could not load tip status"
+msgstr "Konnte die Umsatzanzeige nicht laden"
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:66
+#, fuzzy, c-format
+msgid "The merchant is offering you a tip"
+msgstr ""
+"Der Händler %1$s bietet Ihnen eine Aufwandsentschädigung von %2$s durch den "
+"Exchange %3$s"
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
msgstr ""
-#: src/webex/pages/withdraw.tsx:121
+#: src/cta/Tip/views.tsx:90
#, c-format
-msgid "Select %1$s"
+msgid "Receive &nbsp; %1$s"
msgstr ""
-#: src/webex/pages/withdraw.tsx:143
+#: src/cta/Tip/views.tsx:114
#, c-format
-msgid "Select custom exchange"
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
msgstr ""
-#: src/webex/pages/withdraw.tsx:163
+#: src/components/SelectList.tsx:66
#, c-format
-msgid "You are about to withdraw %1$s from your bank account into your wallet."
+msgid "Select one option"
msgstr ""
-#: src/webex/pages/withdraw.tsx:174
+#: src/components/TermsOfService/views.tsx:39
+#, fuzzy, c-format
+msgid "Could not load"
+msgstr "Konnte die Umsatzanzeige nicht laden"
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr "Allgemeine Geschäftsbedingungen (AGB) anzeigen"
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr "Ich akzeptiere die Allgemeinen Geschäftsbedingungen (AGB)"
+
+#: src/components/TermsOfService/views.tsx:107
+#, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr "Dieser Exchange hat keine Allgemeine Geschäftsbedingungen (AGB)"
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr "Allgemeine Geschäftsbedingungen (AGB) ansehen"
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr "Neue Version der Allgemeinen Geschäftsbedingungen (AGB) ansehen"
+
+#: src/components/TermsOfService/views.tsx:170
#, c-format
-msgid "Accept fees and withdraw"
+msgid "The exchange reply with a empty terms of service"
msgstr ""
+"Der Exchange wird mit Allgemeinen Geschäftsbedingungen ohne Inhalt antworten"
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr "Allgemeine Geschäftsbedingungen (AGB) herunterladen"
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr "Allgemeine Geschäftsbedingungen (AGB) verstecken"
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, fuzzy, c-format
+msgid "Could not load exchange fees"
+msgstr "Konnte die Umsatzanzeige nicht laden"
-#: src/webex/pages/withdraw.tsx:192
+#: src/wallet/ExchangeSelection/views.tsx:131
#, c-format
-msgid "Cancel withdraw operation"
+msgid "Close"
msgstr ""
-#: src/webex/renderHtml.tsx:249
+#: src/wallet/ExchangeSelection/views.tsx:160
#, fuzzy, c-format
-msgid "Withdrawal fees:"
-msgstr "Abheben bei"
+msgid "could not find any exchange"
+msgstr "Konnte die Umsatzanzeige nicht laden"
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, c-format
+msgid "Select %1$s exchange"
+msgstr ""
-#: src/webex/renderHtml.tsx:252
+#: src/wallet/ExchangeSelection/views.tsx:215
#, c-format
-msgid "Rounding loss:"
+msgid "Reset"
msgstr ""
-#: src/webex/renderHtml.tsx:254
+#: src/wallet/ExchangeSelection/views.tsx:218
#, c-format
-msgid "Earliest expiration (for deposit): %1$s"
+msgid "Use this exchange"
msgstr ""
-#: src/webex/renderHtml.tsx:262
+#: src/wallet/ExchangeSelection/views.tsx:230
#, c-format
-msgid "# Coins"
+msgid "Doesn&apos;t have auditors"
msgstr ""
-#: src/webex/renderHtml.tsx:263
+#: src/wallet/ExchangeSelection/views.tsx:241
#, c-format
-msgid "Value"
+msgid "currency"
msgstr ""
-#: src/webex/renderHtml.tsx:264
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:252
#, fuzzy, c-format
-msgid "Withdraw Fee"
+msgid "Deposits"
+msgstr "%1$s zahlen"
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, c-format
+msgid "Denomination"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, fuzzy, c-format
+msgid "Withdrawals"
+msgstr "Abheben"
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, c-format
+msgid "Coin operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and "
+"is valid for a period of time. The exchange will charge the indicated amount "
+"every time a coin is used in such operation."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, c-format
+msgid "Transfer operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is "
+"valid for a period of time. The exchange will charge the indicated amount "
+"every time a transfer is made."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, c-format
+msgid "Wallet operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:60
+#, c-format
+msgid "Could not get info of withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:145
+#, fuzzy, c-format
+msgid "Withdraw &nbsp; %1$s"
msgstr "Abheben bei %1$s"
-#: src/webex/renderHtml.tsx:265
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/cta/InvoicePay/views.tsx:63
#, c-format
-msgid "Refresh Fee"
+msgid "Could not finish the payment operation"
msgstr ""
-#: src/webex/renderHtml.tsx:266
+#: src/cta/TransferCreate/views.tsx:55
#, c-format
-msgid "Deposit Fee"
+msgid "Digital cash transfer"
msgstr ""
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr ""
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, fuzzy, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr "Manuelles Abheben"
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will "
+"send the coins to this wallet after receiving a wire transfer with the "
+"correct subject."
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr "Abhebung beginnen"
+
+#: src/wallet/DepositPage/views.tsx:38
#, fuzzy, c-format
+msgid "Could not load deposit balance"
+msgstr "Konnte die Umsatzanzeige nicht laden"
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:233
+#, fuzzy, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr "Einlösen %1$s %2$s"
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr "Allgemeine Geschäftsbedingungen (AGB) ansehen"
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr ""
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid ""
+"To complete the process you need to wire%1$s %2$s to the exchange bank "
+"account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a "
+"banking app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, fuzzy, c-format
+msgid "Could not toggle auto-open"
+msgstr "Konnte die Umsatzanzeige nicht laden"
+
+#: src/wallet/Settings.tsx:121
+#, fuzzy, c-format
+msgid "Could not toggle clipboard"
+msgstr "Konnte die Umsatzanzeige nicht laden"
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr ""
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr ""
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr ""
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr ""
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr ""
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr ""
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr ""
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr ""
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr ""
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr ""
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr ""
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr ""
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr ""
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr ""
+"Die Diagnostik ist abgeschlossen. Es war keine Kommunikation mit dem Wallet-"
+"Backend möglich."
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr "Ein Problem wurde festgestellt:"
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check "
+"the preference name %2$s)."
+msgstr ""
+"Bitte prüfen Sie ihre %1$s Einstellungen, für die Sie IndexedDB verwenden ("
+"preference name %2$s prüfen)."
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+"Die Datenbank des Wallets ist veraltet. Aktuell wird jedoch keine Migration "
+"auf eine neue Version unterstützt. Bitte wählen Sie %1$s zum Zurücksetzen "
+"der Wallet-Datenbank."
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr "Diagnostik wird durchgeführt"
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr "Debugging-Tools"
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE "
+"ALL YOUR COINS?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr "zurücksetzen"
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr "Noch zu vergeben"
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, fuzzy, c-format
+msgid "Could not load list of exchange"
+msgstr "Konnte die Umsatzanzeige nicht laden"
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
+#, c-format
+msgid "From my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:449
+#, c-format
+msgid "currency not provided"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:459
+#, c-format
+msgid "Specify the amount and the destination"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:483
+#, c-format
+msgid "Use previous destinations:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:503
+#, c-format
+msgid "Or specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:511
+#, c-format
+msgid "Specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:521
+#, c-format
+msgid "To my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:534
+#, c-format
+msgid "To another wallet"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:30
+#, fuzzy, c-format
+msgid "Could not load backup recovery information"
+msgstr "Konnte die Umsatzanzeige nicht laden"
+
+#: src/cta/Recovery/views.tsx:47
+#, c-format
+msgid "Digital wallet recovery"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:52
+#, c-format
+msgid "Import backup, show info"
+msgstr ""
+
+#: src/wallet/Application.tsx:189
+#, c-format
+msgid "All done, your transaction is in progress"
+msgstr ""
+
+#: src/components/EditableText.tsx:45
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/wallet/ManualWithdrawPage.tsx:102
+#, c-format
+msgid "Could not load the list of known exchanges"
+msgstr ""
+
+#~ msgid "Back"
+#~ msgstr "Zurück"
+
+#~ msgid ""
+#~ "To withdraw money you can start from your bank site or click the "
+#~ "\"withdraw\" button to use a known exchange."
+#~ msgstr ""
+#~ "Zum Abheben von digitalem Geld bitte von der Bank-Seite aus starten oder "
+#~ "\"Abheben\" drücken, um einen schon bekannten Exchange zu verwenden."
+
+#~ msgid "Enter URI"
+#~ msgstr "URI eingeben"
+
+#~ msgid "no balance"
+#~ msgstr "Kein Guthaben"
+
+#~ msgid "Withdraw anyway"
+#~ msgstr "Trotzdem abheben"
+
+#~ msgid "Invalid Wire"
+#~ msgstr "Ungültige Überweisung"
+
+#~ msgid "Invalid Test Wire Detail"
+#~ msgstr "Ungültige Überweisungsdaten"
+
+#~ msgid "Unknown Wire Detail"
+#~ msgstr "Unbekannte Überweisungsdaten"
+
+#~ msgid "The total price is %1$s (plus %2$s fees)."
+#~ msgstr "Gesamtbetrag %1$s (zuzüglich %2$s Gebühren)."
+
+#~ msgid "The total price is %1$s."
+#~ msgstr "Gesamter Zahlbetrag: %1$s."
+
+#~ msgid "Confirm payment"
+#~ msgstr "Zahlung bestätigen"
+
+#~ msgid "History"
+#~ msgstr "Verlauf"
+
+#~ msgid "%1$s incoming"
+#~ msgstr "%1$s empfangen"
+
+#~ msgid "%1$s being spent"
+#~ msgstr "%1$s ausgezahlt"
+
+#~ msgid "Your wallet has no events recorded."
+#~ msgstr "Ihre Geldbörse verzeichnet keine Vorkommnisse."
+
+#, fuzzy
#~ msgid "Bank requested reserve (%1$s) for %2$s."
#~ msgstr "Bank bestätig anlegen der Reserve (%1$s) bei %2$s"
-#, fuzzy, c-format
+#, fuzzy
#~ msgid "Started to withdraw %1$s from %2$s (%3$s)."
#~ msgstr "Reserve (%1$s) mit %2$s bei %3$s erzeugt"
-#, fuzzy, c-format
+#, fuzzy
#~ msgid "Merchant %1$s offered contract %2$s."
#~ msgstr ""
#~ "%1$s\n"
#~ " möchte einen Vertrag über %2$s\n"
#~ " mit Ihnen abschließen."
-#, fuzzy, c-format
+#, fuzzy
#~ msgid "Withdrew %1$s from %2$s ( %3$s)."
#~ msgstr "Reserve (%1$s) mit %2$s bei %3$s erzeugt"
-#, fuzzy, c-format
+#, fuzzy
#~ msgid "Paid %1$s to merchant %2$s.%3$s( %4$s)"
#~ msgstr "Reserve (%1$s) mit %2$s bei %3$s erzeugt"
-#, fuzzy, c-format
+#, fuzzy
#~ msgid "Merchant %1$s gave a refund over %2$s."
#~ msgstr ""
#~ "%1$s\n"
#~ " möchte einen Vertrag über %2$s\n"
#~ " mit Ihnen abschließen."
-#, fuzzy, c-format
-#~ msgid "Merchant %1$s gave a %2$s of %3$s."
-#~ msgstr ""
-#~ "%1$s\n"
-#~ " möchte einen Vertrag über %2$s\n"
-#~ " mit Ihnen abschließen."
-
-#, fuzzy, c-format
+#, fuzzy
#~ msgid "Submitting payment"
#~ msgstr "Bezahlung bestätigen"
-#, fuzzy, c-format
+#, fuzzy
#~ msgid "Aborting payment ..."
#~ msgstr "Bezahlung bestätigen"
-#, fuzzy, c-format
-#~ msgid "Retry Payment"
-#~ msgstr "Bezahlung bestätigen"
-
-#, fuzzy, c-format
+#, fuzzy
#~ msgid "Abort Payment"
#~ msgstr "Bezahlung bestätigen"
@@ -346,10 +2080,6 @@ msgstr ""
#~ msgid "You are about to purchase:"
#~ msgstr "Sie sind dabei, Folgendes zu kaufen:"
-#, fuzzy
-#~ msgid "Withdrawal fees: %1$s"
-#~ msgstr "Abheben bei %1$s"
-
#~ msgid "Wallet depleted reserve (%1$s) at %2$s"
#~ msgstr "Geldbörse hat die Reserve (%1$s) erschöpft"
diff --git a/packages/taler-wallet-webextension/src/i18n/en-US.po b/packages/taler-wallet-webextension/src/i18n/en-US.po
deleted file mode 100644
index 4fe38d5e9..000000000
--- a/packages/taler-wallet-webextension/src/i18n/en-US.po
+++ /dev/null
@@ -1,294 +0,0 @@
-# This file is part of TALER
-# (C) 2016 GNUnet e.V.
-#
-# TALER is free software; you can redistribute it and/or modify it under the
-# terms of the GNU General Public License as published by the Free Software
-# Foundation; either version 3, or (at your option) any later version.
-#
-# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along with
-# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-#
-#, fuzzy
-msgid ""
-msgstr ""
-"Project-Id-Version: Taler Wallet\n"
-"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2016-11-23 00:00+0100\n"
-"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
-"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\n"
-"Language: \n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-
-#: src/util/wire.ts:37
-#, c-format
-msgid "Invalid Wire"
-msgstr ""
-
-#: src/util/wire.ts:42 src/util/wire.ts:45
-#, c-format
-msgid "Invalid Test Wire Detail"
-msgstr ""
-
-#: src/util/wire.ts:47
-#, c-format
-msgid "Test Wire Acct #%1$s on %2$s"
-msgstr ""
-
-#: src/util/wire.ts:49
-#, c-format
-msgid "Unknown Wire Detail"
-msgstr ""
-
-#: src/webex/pages/benchmark.tsx:52
-#, c-format
-msgid "Operation"
-msgstr ""
-
-#: src/webex/pages/benchmark.tsx:53
-#, c-format
-msgid "time (ms/op)"
-msgstr ""
-
-#: src/webex/pages/pay.tsx:130
-#, c-format
-msgid "The merchant %1$s offers you to purchase:"
-msgstr ""
-
-#: src/webex/pages/pay.tsx:136
-#, c-format
-msgid "The total price is %1$s (plus %2$s fees)."
-msgstr ""
-
-#: src/webex/pages/pay.tsx:141
-#, c-format
-msgid "The total price is %1$s."
-msgstr ""
-
-#: src/webex/pages/pay.tsx:163
-#, c-format
-msgid "Retry"
-msgstr ""
-
-#: src/webex/pages/pay.tsx:173
-#, c-format
-msgid "Confirm payment"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:153
-#, c-format
-msgid "Balance"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:154
-#, c-format
-msgid "History"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:155
-#, c-format
-msgid "Debug"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:175
-#, c-format
-msgid "You have no balance to show. Need some %1$s getting started?"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:238
-#, c-format
-msgid "%1$s incoming"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:250
-#, c-format
-msgid "%1$s being spent"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:281
-#, c-format
-msgid "Error: could not retrieve balance information."
-msgstr ""
-
-#: src/webex/pages/popup.tsx:390
-#, c-format
-msgid "Invalid "
-msgstr ""
-
-#: src/webex/pages/popup.tsx:396
-#, c-format
-msgid "Fees "
-msgstr ""
-
-#: src/webex/pages/popup.tsx:434
-#, c-format
-msgid "Refresh sessions has completed"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:451
-#, c-format
-msgid "Order Refused"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:465
-#, c-format
-msgid "Order redirected"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:482
-#, c-format
-msgid "Payment aborted"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:512
-#, c-format
-msgid "Payment Sent"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:536
-#, c-format
-msgid "Order accepted"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:547
-#, c-format
-msgid "Reserve balance updated"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:559
-#, c-format
-msgid "Payment refund"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:584
-#, c-format
-msgid "Withdrawn"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:596
-#, c-format
-msgid "Tip Accepted"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:606
-#, c-format
-msgid "Tip Declined"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:615
-#, c-format
-msgid "%1$s"
-msgstr ""
-
-#: src/webex/pages/popup.tsx:707
-#, c-format
-msgid "Your wallet has no events recorded."
-msgstr ""
-
-#: src/webex/pages/return-coins.tsx:124
-#, c-format
-msgid "Wire to bank account"
-msgstr ""
-
-#: src/webex/pages/return-coins.tsx:206
-#, c-format
-msgid "Confirm"
-msgstr ""
-
-#: src/webex/pages/return-coins.tsx:209
-#, c-format
-msgid "Cancel"
-msgstr ""
-
-#: src/webex/pages/withdraw.tsx:73
-#, c-format
-msgid "Could not get details for withdraw operation:"
-msgstr ""
-
-#: src/webex/pages/withdraw.tsx:89 src/webex/pages/withdraw.tsx:183
-#, c-format
-msgid "Chose different exchange provider"
-msgstr ""
-
-#: src/webex/pages/withdraw.tsx:109
-#, c-format
-msgid ""
-"Please select an exchange. You can review the details before after your "
-"selection."
-msgstr ""
-
-#: src/webex/pages/withdraw.tsx:121
-#, c-format
-msgid "Select %1$s"
-msgstr ""
-
-#: src/webex/pages/withdraw.tsx:143
-#, c-format
-msgid "Select custom exchange"
-msgstr ""
-
-#: src/webex/pages/withdraw.tsx:163
-#, c-format
-msgid "You are about to withdraw %1$s from your bank account into your wallet."
-msgstr ""
-
-#: src/webex/pages/withdraw.tsx:174
-#, c-format
-msgid "Accept fees and withdraw"
-msgstr ""
-
-#: src/webex/pages/withdraw.tsx:192
-#, c-format
-msgid "Cancel withdraw operation"
-msgstr ""
-
-#: src/webex/renderHtml.tsx:249
-#, c-format
-msgid "Withdrawal fees:"
-msgstr ""
-
-#: src/webex/renderHtml.tsx:252
-#, c-format
-msgid "Rounding loss:"
-msgstr ""
-
-#: src/webex/renderHtml.tsx:254
-#, c-format
-msgid "Earliest expiration (for deposit): %1$s"
-msgstr ""
-
-#: src/webex/renderHtml.tsx:262
-#, c-format
-msgid "# Coins"
-msgstr ""
-
-#: src/webex/renderHtml.tsx:263
-#, c-format
-msgid "Value"
-msgstr ""
-
-#: src/webex/renderHtml.tsx:264
-#, c-format
-msgid "Withdraw Fee"
-msgstr ""
-
-#: src/webex/renderHtml.tsx:265
-#, c-format
-msgid "Refresh Fee"
-msgstr ""
-
-#: src/webex/renderHtml.tsx:266
-#, c-format
-msgid "Deposit Fee"
-msgstr ""
-
-#, fuzzy
-#~ msgid "DEBUG: Your balance on %1$s is %2$s KUDO. Get more at %3$s"
-#~ msgstr "DEBUG: Your balance is %2$s KUDO on %1$s. Get more at %3$s"
diff --git a/packages/taler-wallet-webextension/src/i18n/es.po b/packages/taler-wallet-webextension/src/i18n/es.po
new file mode 100644
index 000000000..ea1fa9803
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/i18n/es.po
@@ -0,0 +1,2197 @@
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-03-07 07:03+0000\n"
+"Last-Translator: Javier Sepulveda <javier.sepulveda@uv.es>\n"
+"Language-Team: Spanish <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/es/>\n"
+"Language: es\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/NavigationBar.tsx:139
+#, c-format
+msgid "Balance"
+msgstr "Saldo"
+
+#: src/NavigationBar.tsx:142
+#, c-format
+msgid "Backup"
+msgstr "Copia de seguridad"
+
+#: src/NavigationBar.tsx:147
+#, c-format
+msgid "QR Reader and Taler URI"
+msgstr "Lector QR y Taler URI"
+
+#: src/NavigationBar.tsx:154
+#, c-format
+msgid "Settings"
+msgstr "Configuración"
+
+#: src/NavigationBar.tsx:184
+#, c-format
+msgid "Dev"
+msgstr "Dev"
+
+#: src/mui/Typography.tsx:122
+#, c-format
+msgid "%1$s"
+msgstr "%1$s"
+
+#: src/components/PendingTransactions.tsx:74
+#, c-format
+msgid "PENDING OPERATIONS"
+msgstr "OPERACIONES PENDIENTES"
+
+#: src/components/Loading.tsx:36
+#, c-format
+msgid "Loading"
+msgstr "Cargando"
+
+#: src/wallet/BackupPage.tsx:123
+#, c-format
+msgid "Could not load backup providers"
+msgstr "No se pudo cargar los proveedores de copias de seguridad"
+
+#: src/wallet/BackupPage.tsx:202
+#, c-format
+msgid "No backup providers configured"
+msgstr "No hay proveedores de copias de seguridad configurados"
+
+#: src/wallet/BackupPage.tsx:205
+#, c-format
+msgid "Add provider"
+msgstr "Agregar proveedor"
+
+#: src/wallet/BackupPage.tsx:219
+#, c-format
+msgid "Sync all backups"
+msgstr "Sincronizar todas las copias de seguridad"
+
+#: src/wallet/BackupPage.tsx:221
+#, c-format
+msgid "Sync now"
+msgstr "Sincronizar ahora"
+
+#: src/wallet/BackupPage.tsx:264
+#, c-format
+msgid "Last synced"
+msgstr "Ultima vez sincronizado"
+
+#: src/wallet/BackupPage.tsx:269
+#, c-format
+msgid "Not synced"
+msgstr "No sincronizado"
+
+#: src/wallet/BackupPage.tsx:289
+#, c-format
+msgid "Expires in"
+msgstr "Expira en"
+
+#: src/wallet/ProviderDetailPage.tsx:60
+#, c-format
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
+msgstr "Hubo un error cargando los detalles del proveedor para \"%1$s\""
+
+#: src/wallet/ProviderDetailPage.tsx:108
+#, c-format
+msgid "There is not known provider with url &quot;%1$s&quot;."
+msgstr "No hay proveedor conocido con la URL \"%1$s\"."
+
+#: src/wallet/ProviderDetailPage.tsx:115
+#, c-format
+msgid "See providers"
+msgstr "Ver proveedores"
+
+#: src/wallet/ProviderDetailPage.tsx:143
+#, c-format
+msgid "Last backup"
+msgstr "Última copia de seguridad"
+
+#: src/wallet/ProviderDetailPage.tsx:148
+#, c-format
+msgid "Back up"
+msgstr "Copia de seguridad"
+
+#: src/wallet/ProviderDetailPage.tsx:154
+#, c-format
+msgid "Provider fee"
+msgstr "Tarifa del proveedor"
+
+#: src/wallet/ProviderDetailPage.tsx:157
+#, c-format
+msgid "per year"
+msgstr "por año"
+
+#: src/wallet/ProviderDetailPage.tsx:163
+#, c-format
+msgid "Extend"
+msgstr "Extender"
+
+#: src/wallet/ProviderDetailPage.tsx:169
+#, c-format
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms "
+"of service"
+msgstr ""
+"los términos han cambiado, extender el servicio implicará aceptar los nuevos "
+"términos de servicio"
+
+#: src/wallet/ProviderDetailPage.tsx:179
+#, c-format
+msgid "old"
+msgstr "viejo"
+
+#: src/wallet/ProviderDetailPage.tsx:183
+#, c-format
+msgid "new"
+msgstr "nuevo"
+
+#: src/wallet/ProviderDetailPage.tsx:190
+#, c-format
+msgid "fee"
+msgstr "tarifa"
+
+#: src/wallet/ProviderDetailPage.tsx:198
+#, c-format
+msgid "storage"
+msgstr "almacenamiento"
+
+#: src/wallet/ProviderDetailPage.tsx:215
+#, c-format
+msgid "Remove provider"
+msgstr "Eliminar proveedor"
+
+#: src/wallet/ProviderDetailPage.tsx:228
+#, c-format
+msgid "This provider has reported an error"
+msgstr "Este proveedor ha reportado un error"
+
+#: src/wallet/ProviderDetailPage.tsx:242
+#, c-format
+msgid "There is conflict with another backup from %1$s"
+msgstr "Hay un conflicto con otra copia de seguridad de %1$s"
+
+#: src/wallet/ProviderDetailPage.tsx:253
+#, c-format
+msgid "Backup is not readable"
+msgstr "La copia de seguridad no es legible"
+
+#: src/wallet/ProviderDetailPage.tsx:261
+#, c-format
+msgid "Unknown backup problem: %1$s"
+msgstr "Problema de copia de seguridad desconocido: %1$s"
+
+#: src/wallet/ProviderDetailPage.tsx:283
+#, c-format
+msgid "service paid"
+msgstr "servicio pagado"
+
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format
+msgid "Backup valid until"
+msgstr "Copia de seguridad válida hasta"
+
+#: src/wallet/AddNewActionView.tsx:57
+#, c-format
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr "Abrir página de reserva"
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr "Abrir página de pago"
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr "Abrir página de devolución"
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr "Abrir página de propina"
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr "Abrir página de retirada"
+
+#: src/popup/NoBalanceHelp.tsx:43
+#, c-format
+msgid "Get digital cash"
+msgstr "Retirar dinero digital"
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr "No se pudo cargar la página"
+
+#: src/popup/BalancePage.tsx:175
+#, c-format
+msgid "Add"
+msgstr "Agregar"
+
+#: src/popup/BalancePage.tsx:179
+#, c-format
+msgid "Send %1$s"
+msgstr "Envíar %1$s"
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr "Acción Taler"
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr "Esta página tiene una acción de pago."
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr "Esta página tiene una acción de retirada."
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr "Esta página tiene una acción de propina."
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr "Esta página tiene una acción de notificación de reserva."
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr "Notificar"
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr "Esta página tiene una acción de devolución."
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr "Esta página tiene una URI de Taler malformada."
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr "Descartar"
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr "Este popup está siendo cerrado y estás siendo redirigido a %1$s"
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, c-format
+msgid "Could not load purchase proposal details"
+msgstr "No se pudo cargar el detalle de la propuesta"
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, c-format
+msgid "Order Id"
+msgstr "Id de orden"
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr "Resumen"
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr "Monto"
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr "Comerciante"
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr "Jurisdicción"
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr "Dirección del comerciante"
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr "Logo"
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr "Siti web"
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr "Correo electrónico"
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr "Clave pública"
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr "Fecha de entrega"
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr "Ubicación de entrega"
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr "Productos"
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr "Creado en"
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr "Plazo de reembolso"
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr "Devolución automática"
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr "Plazo de pago"
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr "URL de cumplimiento"
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr "Mensaje de éxito"
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr "Máxima comisión de depósito"
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr "Máxima comisión"
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr "Edad mínima"
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr "Amortización de comisión de transferencia"
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr "Auditores"
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, c-format
+msgid "Exchanges"
+msgstr "Exchanges"
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
+msgstr "Cuenta bancaria"
+
+#: src/components/Part.tsx:160
+#, c-format
+msgid "Bitcoin address"
+msgstr "Dirección de Bitcoin"
+
+#: src/components/Part.tsx:163
+#, c-format
+msgid "IBAN"
+msgstr "IBAN"
+
+#: src/cta/Deposit/views.tsx:38
+#, c-format
+msgid "Could not load deposit status"
+msgstr "No se pudo cargar el estado del depósito"
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr "Depósito de dinero digital"
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr "Costo"
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr "Comisión"
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr "A recibir"
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr "Envíar %1$s"
+
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr "Detalle de transferencia Bitcoin"
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an "
+"minimum amount."
+msgstr ""
+"El exchange necesita una transacción con 3 salidas, una salida es hacia la "
+"cuenta del exchange y las otras dos son direcciones segwit falsas para "
+"metadata con el monto mínimo."
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two "
+"additional recipient and copy addresses and amounts"
+msgstr ""
+"En la billetera bitcoincore usar el botón \"Agregar destinatario\" para "
+"agregar dos destinatarios y copiar las direcciones y montos"
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid ""
+"Make sure the amount show %1$s BTC, else you have to change the base unit to "
+"BTC"
+msgstr ""
+"Asegurarse de que el monto muestre %1$s BTC, sino tendrá que cambiar la "
+"unidad a BTC"
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr "Cuenta"
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr "Banco anfitrión"
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr "Detalle de transferencia bancaria"
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr "Asunto"
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr "Nombre del receptor"
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr "No se pudo cargar información de la transacción"
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr "Hubo un error intentando completar la transacción"
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr "Esta transacción no está completada"
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr "Envíar"
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr "Reintentar"
+
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr "Olvidar"
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr "Cuidado!"
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to "
+"get the coins form it."
+msgstr ""
+"Si tú ya has transferido dinero al exchange, perderás la oportunidad de "
+"recibir las monedas desde este."
+
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr "Confirmar"
+
+#: src/wallet/Transaction.tsx:267
+#, c-format
+msgid "Withdrawal"
+msgstr "Extracción"
+
+#: src/wallet/Transaction.tsx:286
+#, c-format
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+"Asegúrate de usar el asunto correcto, de lo contrario el dinero no llegará a "
+"esta billetera."
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and "
+"check there is no pending step."
+msgstr ""
+"El banco todavía no confirmó la transferencia. Ir a %1$s %2$s y verificar "
+"que no hay pasos pendientes."
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid ""
+"Bank has confirmed the wire transfer. Waiting for the exchange to send the "
+"coins"
+msgstr ""
+"El banco confirmó la transferencia. Esperando que el exchange envíe las "
+"monedas"
+
+#: src/wallet/Transaction.tsx:325
+#, c-format
+msgid "Details"
+msgstr "Detalles"
+
+#: src/wallet/Transaction.tsx:360
+#, c-format
+msgid "Payment"
+msgstr "Pago"
+
+#: src/wallet/Transaction.tsx:378
+#, c-format
+msgid "Refunds"
+msgstr "Devoluciones"
+
+#: src/wallet/Transaction.tsx:385
+#, c-format
+msgid "%1$s %2$s on %3$s"
+msgstr "%1$s %2$s en %3$s"
+
+#: src/wallet/Transaction.tsx:415
+#, c-format
+msgid ""
+"Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+"El comerciante creó una devolución para esta orden pero no fue recogida "
+"automáticamente."
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr "Oferta"
+
+#: src/wallet/Transaction.tsx:431
+#, c-format
+msgid "Accept"
+msgstr "Aceptar"
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr "Comerciante"
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr "Id de factura"
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr "Depósito"
+
+#: src/wallet/Transaction.tsx:496
+#, c-format
+msgid "Refresh"
+msgstr "Actualizar"
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr "Propina"
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr "Devolución"
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr "Id de orden original"
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr "Resumen de compra"
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr "Copiar"
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr "Esconder QR"
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr "Mostrar QR"
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr "Crédito"
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr "Factura"
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr "Exchange"
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr "URI"
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr "Débito"
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr "Transferencia"
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr "País"
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr "Detalle de dirección"
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr "Número de edificio"
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr "Nombre de edificio"
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr "Calle"
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr "Código postal"
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr "Ubicación de ciudad"
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr "Ciudad"
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr "Distrito"
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr "Subdivisión de país"
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr "Fecha"
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr "Comisiones de transacción"
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr "Total"
+
+#: src/wallet/Transaction.tsx:1074
+#, c-format
+msgid "Withdraw"
+msgstr "Retirar"
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr "Precio"
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr "Reembolsado"
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr "Entrega"
+
+#: src/wallet/Transaction.tsx:1335
+#, c-format
+msgid "Total transfer"
+msgstr "Total transferido"
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr "No se pudo cargar el estado del pago"
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr "Pago con dinero digital"
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr "Compra"
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr "Recibo"
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr "Válido hasta"
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr "Lista de productos"
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr "Gratis"
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr "Ya pagado, estás siendo dirigido a %1$s"
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr "Ya pagado"
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr "Ya reclamado"
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr "Pagar con un teléfono móvil"
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr "Esconder QR"
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr "Escanear el código QR o %1$s"
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr "Pagar %1$s"
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr "No hay balance para esta divisa. Extraer dinero digital primero."
+
+#: src/cta/Payment/views.tsx:364
+#, c-format
+msgid ""
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+"No se encontraron suficientes monedas para pagar. Incluso si tuviera "
+"suficiente %1$s algunas restricciones se podrían aplicar."
+
+#: src/cta/Payment/views.tsx:366
+#, c-format
+msgid "Your current balance is not enough."
+msgstr "Tu balance no es suficiente."
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr "Mensaje del comerciante"
+
+#: src/cta/Refund/views.tsx:34
+#, c-format
+msgid "Could not load refund status"
+msgstr "No se pudo cargar el estado de la devolución"
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr "Devolución de dinero digital"
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr "Has ignorado la propina."
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr "El proceso de devolución está en progreso."
+
+#: src/cta/Refund/views.tsx:76
+#, c-format
+msgid "Total to refund"
+msgstr "Total para devolver"
+
+#: src/cta/Refund/views.tsx:106
+#, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr "El comerciante \"%1$s\" te está ofreciendo una devolución."
+
+#: src/cta/Refund/views.tsx:115
+#, c-format
+msgid "Order amount"
+msgstr "Monto de la orden"
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr "Ya devuelto"
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr "Devolución ofrecida"
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr "Aceptar %1$s"
+
+#: src/cta/Tip/views.tsx:32
+#, c-format
+msgid "Could not load tip status"
+msgstr "No se pudo cargar el estado de la propina"
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr "Propina con dinero digital"
+
+#: src/cta/Tip/views.tsx:66
+#, c-format
+msgid "The merchant is offering you a tip"
+msgstr "El comerciante te ofrece una propina"
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
+msgstr "URL del comerciante"
+
+#: src/cta/Tip/views.tsx:90
+#, c-format
+msgid "Receive &nbsp; %1$s"
+msgstr "Recibir %1$s"
+
+#: src/cta/Tip/views.tsx:114
+#, c-format
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
+msgstr ""
+"Propina de %1$s aceptada. Revisa tu lista de transacciones para más detalle."
+
+#: src/components/SelectList.tsx:66
+#, c-format
+msgid "Select one option"
+msgstr "Seleccione una opción"
+
+#: src/components/TermsOfService/views.tsx:39
+#, c-format
+msgid "Could not load"
+msgstr "No se pudo cargar"
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr "Mostrar términos de servicio"
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr "Yo acepto los términos de servicio del exchange"
+
+#: src/components/TermsOfService/views.tsx:107
+#, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr "El exchange no tiene los términos de servicio"
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr "Revisar los términos de servicio"
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr "Revisar los nuevos términos de servicio"
+
+#: src/components/TermsOfService/views.tsx:170
+#, c-format
+msgid "The exchange reply with a empty terms of service"
+msgstr "El exchange respondió con unos términos de servicio vacíos"
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr "Descargar los términos de servicio"
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr "Esconder los términos de servicio"
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, c-format
+msgid "Could not load exchange fees"
+msgstr "No se pudo cargar la comisión del exchange"
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr "Cerrar"
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, c-format
+msgid "could not find any exchange"
+msgstr "No se pudo encontrar ningún exchange"
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr "No se pudo encontrar ningún exchange para la divisa %1$s"
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr "Descripción de comisión de servicio"
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, c-format
+msgid "Select %1$s exchange"
+msgstr "Seleccionar exchange %1$s"
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr "Reiniciar"
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, c-format
+msgid "Use this exchange"
+msgstr "Usar este exchange"
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr "No tiene auditores"
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr "divisa"
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr "Operaciones"
+
+#: src/wallet/ExchangeSelection/views.tsx:252
+#, c-format
+msgid "Deposits"
+msgstr "Depósitos"
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, c-format
+msgid "Denomination"
+msgstr "Operaciones"
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr "Hasta"
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, c-format
+msgid "Withdrawals"
+msgstr "Retiradas"
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr "Divisa"
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, c-format
+msgid "Coin operations"
+msgstr "Operaciones de moneda"
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and "
+"is valid for a period of time. The exchange will charge the indicated amount "
+"every time a coin is used in such operation."
+msgstr ""
+"Toda operación en esta sección puede ser diferente por valor de denominación "
+"y es válida por un período. El exchange cobrará el monto indicado cada vez "
+"que una es usada en dicha operación."
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, c-format
+msgid "Transfer operations"
+msgstr "Operaciones de transferencia"
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is "
+"valid for a period of time. The exchange will charge the indicated amount "
+"every time a transfer is made."
+msgstr ""
+"Toda operación en esta sección puede ser diferente por tipo de transacción y "
+"es válida por un período. El exchange cobrará el monto indicado cada vez que "
+"se haga una transferencia."
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr "Operación"
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, c-format
+msgid "Wallet operations"
+msgstr "Operaciones de billetera"
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr "Característica"
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr "No se pudo obtener la información desde la URI"
+
+#: src/cta/Withdraw/views.tsx:60
+#, c-format
+msgid "Could not get info of withdrawal"
+msgstr "No se pudo obtener la información de retiro"
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr "Retirada de dinero digital"
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr "No se pudo completar la operación de retirada"
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr "Restricción etaria"
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr "Retirar %1$s"
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr "Retirar con un teléfono móvil"
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr "Factura digital"
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr "No se pudo completar la creación de la factura"
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr "Crear"
+
+#: src/cta/InvoicePay/views.tsx:63
+#, c-format
+msgid "Could not finish the payment operation"
+msgstr "No se pudo completar la operación de pago"
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr "Transferencia de dinero digital"
+
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr "No se pudo completar la operación de creación de transferencia"
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr "No se pudo completar la operación de recolección"
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr "Retirada Manual para %1$s"
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will "
+"send the coins to this wallet after receiving a wire transfer with the "
+"correct subject."
+msgstr ""
+"Elija un exchange desde donde las monedas serán retiradas. El exchange "
+"enviará las monedas a esta billetera después de recibir una transferencia "
+"bancaria con el asunto correcto."
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr "No se encontró exchange para %1$s"
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr "Agregar Exchange"
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr "Sin exchange configurado"
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr "No se pudo crear una reserva"
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr "Comenzar la retirada"
+
+#: src/wallet/DepositPage/views.tsx:38
+#, c-format
+msgid "Could not load deposit balance"
+msgstr "No se pudo cargar el balance de depósito"
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr "Se debería especificar una divisa o un monto"
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr "No hay suficiente balance para hacer un depósito para la divisa %1$s"
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr "Enviar %1$s a tu cuenta"
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr "No hay una cuenta para hacer un depósito para la divisa %1$s"
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr "Agregar cuenta"
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr "Seleccionar cuenta"
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr "Agregar otra cuenta"
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr "Comisión de depósito"
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr "Depósito total"
+
+#: src/wallet/DepositPage/views.tsx:233
+#, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr "Depositar %1$s %2$s"
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr "Agregar cuenta de banco para %1$s"
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr "Ingresar la URL de un exchange en el que confíes."
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr "No fue posible agregar esta cuenta"
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr "Seleccione un tipo de cuenta"
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr "Revisar los términos de servicio"
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr "URL del Exchange"
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr "Agregar exchange"
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr "Agregar nuevo exchange"
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr "Agregar exchange para %1$s"
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+"Un exchange ha sido encontrado! Revisa la información y haz clic en siguiente"
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr "Este exchange no coincide con la divisa %1$s esperada"
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr "No fue posible verificar este exchange"
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr "No fue posible agregar este exchange"
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr "cargando"
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr "Versión"
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr "Siguiente"
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr "Esperando confirmación"
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr "PENDIENTE"
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr "No se pudo cargar la lista de transacciones"
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr "No hay historial para esta divisa."
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr "Agregar proveedor de copias de seguridad"
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr "No se pudo conseguir la información del proveedor"
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr "Los proveedores de copias de seguridad pueden cobrarte por su servicio"
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr "URL"
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr "Nombre"
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr "URL del proveedor"
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr "Por favor revisa y acepta los términos de servicio del proveedor"
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr "Precios"
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr "Gratis"
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr "%1$s por año de servicio"
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr "Alamcenamiento"
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr "%1$s megabytes de almacenamiento por año de servicio"
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr "Aceptar los términos de servicio"
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr "No se pudo obtener la información de la URI payto"
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr "Revisar la URI"
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr "El exchange está listo para la retirada"
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid ""
+"To complete the process you need to wire%1$s %2$s to the exchange bank "
+"account"
+msgstr ""
+"Para completar el proceso necesitas transferir %1$s %2$s a la cuenta "
+"bancaria del exchange"
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a "
+"banking app installed that supports RFC 8905"
+msgstr ""
+"Alternativamente, también puedes escanear el código QR o abrir %1$s si "
+"tienes una App bancaria instalada que soporta RFC 8905"
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr "Cancelar retirada"
+
+#: src/wallet/Settings.tsx:115
+#, c-format
+msgid "Could not toggle auto-open"
+msgstr "No se pudo cambiar el auto-open"
+
+#: src/wallet/Settings.tsx:121
+#, c-format
+msgid "Could not toggle clipboard"
+msgstr "No se pudo cambiar portapapeles"
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr "Navegador"
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr "Abrir automáticamente la billetera basada en el contenido de la página"
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser."
+msgstr ""
+"Habilitar la opción de debajo, hará que el uso de la billetera sea mas "
+"rápido, pero requiere más permisos de tu navegador."
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr "Revisar el portapapeles automáticamente por Taler URI"
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr "Confianza"
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr "No hay exchanges todavía"
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr "Términos de servicio"
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr "ok"
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr "modificado"
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr "no aceptado"
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr "desconocido (el estado del exchange debería actualizarse)"
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr "Agregar un exchange"
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr "Solución de problemas"
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr "Modo desarrollador"
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr "Más información y opciones útiles para depuración"
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr "Pantalla"
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr "Lenguaje actual"
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr "Wallet core"
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr "Web Extension"
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr "Compatibilidad con Exchange"
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr "Compatibilidad con Merchant"
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr "Compatibilidad con Bank"
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr "Extensión del navegador instalada!"
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr "Puedes abrir GNU Taler Wallet usando la combinación %1$s."
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+"También fijando GNU Taler Wallet a to navegador Chrome permite un acceso "
+"rápido sin el teclado:"
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr "Haz click en el ícono de rompecabezas"
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr "Busca \"GNU Taler Wallet\""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr "Haz click en el ícono de fijar"
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr "Permisos"
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser.)"
+msgstr ""
+"(Habilitar esta opción de abajo hará el uso de la billetera mas rápido, pero "
+"requiere mas permisos de tu navegador)"
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr "Próximos pasos"
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr "Probar la demostración"
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr "Aprender como llenar tu billetera"
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr "El diagnóstico caducó. No nos pudimos comunicar con la billetera."
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr "Problemas detectados:"
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check "
+"the preference name %2$s)."
+msgstr ""
+"Por favor revisa en tu configuración %1$s que tienes IndexedDB habilitado ("
+"el nombre de la preferencia %2$s)."
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+"La base de datos de la billetera expiró. Por ahora la migración automática "
+"no está soportada. Por favor dirijasé a %1$s para reiniciar la base de datos "
+"de la billetera."
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr "Ejecutando diagnósticos"
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr "Herramientas de desarrollo"
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE "
+"ALL YOUR COINS?"
+msgstr ""
+"Quieres DESTRUIR IRREVOCABLEMENTE todo dentro de tu billetera y PERDER TODAS "
+"TUS MONEDAS?"
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr "Reiniciar"
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr "TESTING: Esto puede borrar todas tus monedas, proceder con precaución"
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr "Ejecutar GC"
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr "importar base de datos"
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr "exportar base de datos"
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr "Base de datos exportada a %1$s %2$s para descargar"
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr "Monedas"
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr "Operaciones pendientes"
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr "monedas usables"
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr "id"
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr "denominación"
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr "valor"
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr "estado"
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr "desde refresco?"
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr "cantidad de age key"
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr "monedas gastadas"
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr "hacer clic para mostrar"
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr "Escanear un código QR o ingresar taler:// URI debajo"
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr "Abierto"
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr "El URI no es válido. Taler URI debería comenzar con `taler://`"
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr "Intentar otro"
+
+#: src/wallet/DestinationSelection.tsx:183
+#, c-format
+msgid "Could not load list of exchange"
+msgstr "No se pudo cargar la lista de exchange"
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr "Elija una divisa para proceder o agregue otro exchange"
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr "Divisas conocidas"
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr "Indicar el monto y el origen"
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr "Cambiar divisa"
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr "Usar un origen previo:"
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr "O especificar el origen del dinero"
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr "Especificar el origen del dinero"
+
+#: src/wallet/DestinationSelection.tsx:380
+#, c-format
+msgid "From my bank account"
+msgstr "Desde mi cuenta de banco"
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
+msgstr "Desde otra billetera"
+
+#: src/wallet/DestinationSelection.tsx:449
+#, c-format
+msgid "currency not provided"
+msgstr "Divisa no provista"
+
+#: src/wallet/DestinationSelection.tsx:459
+#, c-format
+msgid "Specify the amount and the destination"
+msgstr "Especificar el monto y el destino"
+
+#: src/wallet/DestinationSelection.tsx:483
+#, c-format
+msgid "Use previous destinations:"
+msgstr "Usar destinos previos:"
+
+#: src/wallet/DestinationSelection.tsx:503
+#, c-format
+msgid "Or specify the destination of the money"
+msgstr "O especificar el destino del dinero"
+
+#: src/wallet/DestinationSelection.tsx:511
+#, c-format
+msgid "Specify the destination of the money"
+msgstr "Especificar el destino del dinero"
+
+#: src/wallet/DestinationSelection.tsx:521
+#, c-format
+msgid "To my bank account"
+msgstr "Hacia mi cuenta de banco"
+
+#: src/wallet/DestinationSelection.tsx:534
+#, c-format
+msgid "To another wallet"
+msgstr "Hacia otra billetera"
+
+#: src/cta/Recovery/views.tsx:30
+#, c-format
+msgid "Could not load backup recovery information"
+msgstr "No se pudo cargar la información de recuperación de copia de seguridad"
+
+#: src/cta/Recovery/views.tsx:47
+#, c-format
+msgid "Digital wallet recovery"
+msgstr "Recuperación de billetera digital"
+
+#: src/cta/Recovery/views.tsx:52
+#, c-format
+msgid "Import backup, show info"
+msgstr "Importar copia de seguridad, mostrar información"
+
+#: src/wallet/Application.tsx:189
+#, c-format
+msgid "All done, your transaction is in progress"
+msgstr "Todo completo, su transacción está en progreso"
+
+#: src/components/EditableText.tsx:45
+#, c-format
+msgid "Edit"
+msgstr "Editar"
+
+#: src/wallet/ManualWithdrawPage.tsx:102
+#, c-format
+msgid "Could not load the list of known exchanges"
+msgstr "No se pudo cargar la lista de exchange conocidos"
+
+#~ msgid "Back"
+#~ msgstr "Atrás"
+
+#~ msgid "You have no balance to show."
+#~ msgstr "No tienes balance para mostrar."
+
+#~ msgid ""
+#~ "To withdraw money you can start from your bank site or click the "
+#~ "\"withdraw\" button to use a known exchange."
+#~ msgstr ""
+#~ "Para retirar dinero puedes empezar desde el sitio de tu banco o cliquear "
+#~ "en el botón \"retirar\" para usar un exchange conocido."
+
+#~ msgid "Deposit %1$s"
+#~ msgstr "Depositar %1$s"
+
+#~ msgid "Enter URI"
+#~ msgstr "Ingresar URI"
+
+#~ msgid "Loading terms.."
+#~ msgstr "Cargando términos..."
+
+#~ msgid "Add exchange anyway"
+#~ msgstr "Agregar exchange de todas maneras"
+
+#~ msgid "back"
+#~ msgstr "volver"
+
+#~ msgid "no balance"
+#~ msgstr "sin balance"
+
+#~ msgid "There is no known bank account to send money to"
+#~ msgstr "No hay una cuenta bancaria conocida, donde enviar el dinero"
+
+#~ msgid "Bank account IBAN number"
+#~ msgstr "Número IBAN de cuenta bancaria"
+
+#~ msgid "Chosen amount"
+#~ msgstr "Elegir cantidad"
+
+#~ msgid "could not parse payto uri from exchange %1$s"
+#~ msgstr "No se pudo analizar la URI pagar-a del exchange %1$s"
+
+#~ msgid "Exchange fee"
+#~ msgstr "Comisión del exchange"
+
+#~ msgid "The bank is waiting for confirmation. Go to the %1$s"
+#~ msgstr "El banco espera la confirmación. Dirigete a %1$s"
+
+#~ msgid "Waiting for the coins to arrive"
+#~ msgstr "Esperando a que las monedas lleguen"
+
+#~ msgid "Total paid"
+#~ msgstr "Total pagado"
+
+#~ msgid "Purchase amount"
+#~ msgstr "Importe de la compra"
+
+#~ msgid "Total send"
+#~ msgstr "Total enviado"
+
+#~ msgid "Deposit amount"
+#~ msgstr "Cantidad a depositar"
+
+#~ msgid "Total refresh"
+#~ msgstr "Actualización total"
+
+#~ msgid "Total tip"
+#~ msgstr "Total de propina"
+
+#~ msgid "Thank you for installing the wallet."
+#~ msgstr "Gracias por haber instalado la billetera."
+
+#~ msgid "Could not load contract terms from merchant or wallet backend."
+#~ msgstr ""
+#~ "No se pudieron cargar los términos de contrato del comerciante o de la "
+#~ "billetera."
+
+#~ msgid "Processing"
+#~ msgstr "Procesando"
+
+#~ msgid "Your balance of %1$s is not enough to pay for this purchase"
+#~ msgstr "Tu balance de %1$s no es suficiente para pagar por esta compra"
+
+#~ msgid "Payment complete"
+#~ msgstr "Pago completado"
+
+#~ msgid "You are going to be redirected to $ %1$s"
+#~ msgstr "Vas a ser redirigido a %1$s"
+
+#~ msgid "You can close this page."
+#~ msgstr "Puedes cerrar esta página."
+
+#~ msgid "Total to pay"
+#~ msgstr "Total a pagar"
+
+#~ msgid "Refund Status"
+#~ msgstr "Estado del reembolso"
+
+#~ msgid "The product %1$s has received a total effective refund of"
+#~ msgstr "El producto %1$s ha recibido un reembolso total efectivo de"
+
+#~ msgid "The refund amount of %1$s could not be applied."
+#~ msgstr "El importe del reembolso de %1$s no pudo ser aplicado."
+
+#~ msgid "missing taler refund uri"
+#~ msgstr "falta la URI Taler de reembolso"
+
+#~ msgid "Error: %1$s"
+#~ msgstr "Error: %1$s"
+
+#~ msgid "Updating refund status"
+#~ msgstr "Actualizando el estado de reembolso"
+
+#~ msgid "Ignore"
+#~ msgstr "Ignorar"
+
+#~ msgid "missing tip uri"
+#~ msgstr "falta la URI de la propina"
+
+#~ msgid "Total to withdraw"
+#~ msgstr "Total a retirar"
+
+#~ msgid "Cancel exchange selection"
+#~ msgstr "Cancelar la selección de exchange"
+
+#~ msgid "Confirm exchange selection"
+#~ msgstr "Confirmar la selección de exchange"
+
+#~ msgid "Confirm withdrawal"
+#~ msgstr "Confirmar retirada"
+
+#~ msgid "Withdraw anyway"
+#~ msgstr "Retirar de todas maneras"
+
+#~ msgid "missing withdraw uri"
+#~ msgstr "falta la URI de retirada"
+
+#~ msgid "missing pay uri"
+#~ msgstr "falta la URI de pago"
+
+#~ msgid "Could not get the payment information for this order"
+#~ msgstr "No se pudo obtener la información de pago para esta orden"
+
+#~ msgid "Loading payment information"
+#~ msgstr "Cargado la información de pago"
+
+#~ msgid "You will now be sent back to the merchant you came from."
+#~ msgstr "Ahora serás enviado de nuevo al comerciante desde donde viniste."
+
+#~ msgid "Manual Reset Required"
+#~ msgstr "Reinicio Manual Necesario"
+
+#~ msgid ""
+#~ "The wallet&apos;s database in your browser is incompatible with the "
+#~ "currently installed wallet. Please reset manually."
+#~ msgstr ""
+#~ "La base de datos de billetera en tu navegador es incompatible con la "
+#~ "billetera instalada actualmente. Por favor reinicie manualmente."
+
+#~ msgid ""
+#~ "Once the database format has stabilized, we will provide automatic "
+#~ "upgrades."
+#~ msgstr ""
+#~ "Una vez que el formato de la base de datos se haya estabilizado, "
+#~ "proveeremos de actualizaciones automáticas."
+
+#~ msgid "I understand that I will lose all my data"
+#~ msgstr "Entiendo que perderé toda mi información"
+
+#~ msgid "Everything is fine!"
+#~ msgstr "Todo está bien!"
+
+#~ msgid "A reset is not required anymore, you can close this page."
+#~ msgstr "Un reinicio ya no es necesario, puede cerrar esta página."
+
+#~ msgid "Not implemented yet."
+#~ msgstr "Todavía no implementado."
diff --git a/packages/taler-wallet-webextension/src/i18n/fi.po b/packages/taler-wallet-webextension/src/i18n/fi.po
new file mode 100644
index 000000000..c6196b7f3
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/i18n/fi.po
@@ -0,0 +1,1967 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-03-20 00:10+0000\n"
+"Last-Translator: Sara Korpinen <sara.a.korpinen@gmail.com>\n"
+"Language-Team: Finnish <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/fi/>\n"
+"Language: fi\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/NavigationBar.tsx:139
+#, c-format
+msgid "Balance"
+msgstr "Saldo"
+
+#: src/NavigationBar.tsx:142
+#, c-format
+msgid "Backup"
+msgstr "Varmuuskopio"
+
+#: src/NavigationBar.tsx:147
+#, c-format
+msgid "QR Reader and Taler URI"
+msgstr "QR -lukija ja Taler URI"
+
+#: src/NavigationBar.tsx:154
+#, c-format
+msgid "Settings"
+msgstr "Asetukset"
+
+#: src/NavigationBar.tsx:184
+#, c-format
+msgid "Dev"
+msgstr "Kehitys"
+
+#: src/mui/Typography.tsx:122
+#, c-format
+msgid "%1$s"
+msgstr "%1$s"
+
+#: src/components/PendingTransactions.tsx:74
+#, c-format
+msgid "PENDING OPERATIONS"
+msgstr "ODOTTAVAT TOIMINNOT"
+
+#: src/components/Loading.tsx:36
+#, c-format
+msgid "Loading"
+msgstr "Lataa"
+
+#: src/wallet/BackupPage.tsx:123
+#, c-format
+msgid "Could not load backup providers"
+msgstr "Varmuuskopion tarjoajia ei voitu ladata"
+
+#: src/wallet/BackupPage.tsx:202
+#, c-format
+msgid "No backup providers configured"
+msgstr "Varmuuskopion tarjoajia ei ole määritetty"
+
+#: src/wallet/BackupPage.tsx:205
+#, c-format
+msgid "Add provider"
+msgstr "Lisää palveluntarjoaja"
+
+#: src/wallet/BackupPage.tsx:219
+#, c-format
+msgid "Sync all backups"
+msgstr "Synkronoi kaikki varmuuskopiot"
+
+#: src/wallet/BackupPage.tsx:221
+#, c-format
+msgid "Sync now"
+msgstr "Synkronoi nyt"
+
+#: src/wallet/BackupPage.tsx:264
+#, c-format
+msgid "Last synced"
+msgstr "Viimeksi synkronoitu"
+
+#: src/wallet/BackupPage.tsx:269
+#, c-format
+msgid "Not synced"
+msgstr "Ei synkronoitu"
+
+#: src/wallet/BackupPage.tsx:289
+#, c-format
+msgid "Expires in"
+msgstr "Vanhenee"
+
+#: src/wallet/ProviderDetailPage.tsx:60
+#, c-format
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
+msgstr "Virhe ladattaessa palveluntarjoajan tietoja kohteelle &quot; %1$s&quot;"
+
+#: src/wallet/ProviderDetailPage.tsx:108
+#, c-format
+msgid "There is not known provider with url &quot;%1$s&quot;."
+msgstr "Ei tunneta palveluntarjoajaa, jonka URL-osoite on &quot;%1$s&quot;."
+
+#: src/wallet/ProviderDetailPage.tsx:115
+#, c-format
+msgid "See providers"
+msgstr "Katso palveluntarjoajat"
+
+#: src/wallet/ProviderDetailPage.tsx:143
+#, c-format
+msgid "Last backup"
+msgstr "Viimeisin varmuuskopio"
+
+#: src/wallet/ProviderDetailPage.tsx:148
+#, c-format
+msgid "Back up"
+msgstr "Varmuuskopioi"
+
+#: src/wallet/ProviderDetailPage.tsx:154
+#, c-format
+msgid "Provider fee"
+msgstr "Palveluntarjoajan maksu"
+
+#: src/wallet/ProviderDetailPage.tsx:157
+#, c-format
+msgid "per year"
+msgstr "vuodessa"
+
+#: src/wallet/ProviderDetailPage.tsx:163
+#, c-format
+msgid "Extend"
+msgstr "Laajenna"
+
+#: src/wallet/ProviderDetailPage.tsx:169
+#, c-format
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms of "
+"service"
+msgstr ""
+"ehdot ovat muuttuneet, palvelun laajentaminen tarkoittaa uusien "
+"käyttöehtojen hyväksymistä"
+
+#: src/wallet/ProviderDetailPage.tsx:179
+#, c-format
+msgid "old"
+msgstr "vanha"
+
+#: src/wallet/ProviderDetailPage.tsx:183
+#, c-format
+msgid "new"
+msgstr "uusi"
+
+#: src/wallet/ProviderDetailPage.tsx:190
+#, c-format
+msgid "fee"
+msgstr "maksu"
+
+#: src/wallet/ProviderDetailPage.tsx:198
+#, c-format
+msgid "storage"
+msgstr "tila"
+
+#: src/wallet/ProviderDetailPage.tsx:215
+#, c-format
+msgid "Remove provider"
+msgstr "Poista palveluntarjoaja"
+
+#: src/wallet/ProviderDetailPage.tsx:228
+#, c-format
+msgid "This provider has reported an error"
+msgstr "Tämä palveluntarjoaja on ilmoittanut virheestä"
+
+#: src/wallet/ProviderDetailPage.tsx:242
+#, c-format
+msgid "There is conflict with another backup from %1$s"
+msgstr "Ristiriita toisen varmuuskopion kanssa kohteesta %1$s"
+
+#: src/wallet/ProviderDetailPage.tsx:253
+#, c-format
+msgid "Backup is not readable"
+msgstr "Varmuuskopiota ei voi lukea"
+
+#: src/wallet/ProviderDetailPage.tsx:261
+#, c-format
+msgid "Unknown backup problem: %1$s"
+msgstr "Tuntematon varmuuskopiointi ongelma: %1$s"
+
+#: src/wallet/ProviderDetailPage.tsx:283
+#, c-format
+msgid "service paid"
+msgstr "palvelu maksettu"
+
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format, fuzzy
+msgid "Backup valid until"
+msgstr "Varmuuskopio voimassa"
+
+#: src/wallet/AddNewActionView.tsx:57
+#, c-format
+msgid "Cancel"
+msgstr "Peruuta"
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr "Avaa varaussivu"
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr "Avaa maksusivu"
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr "Avaa hyvityssivu"
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr "Avaa tippi sivu"
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr "Avaa nostosivu"
+
+#: src/popup/NoBalanceHelp.tsx:43
+#, c-format
+msgid "Get digital cash"
+msgstr "Hanki digitaalista käteistä"
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr "Ei voitu ladata saldosivua"
+
+#: src/popup/BalancePage.tsx:175
+#, c-format
+msgid "Add"
+msgstr "Lisää"
+
+#: src/popup/BalancePage.tsx:179
+#, c-format
+msgid "Send %1$s"
+msgstr "Lähetä %1$s"
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr "Taler toiminta"
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr "Tällä sivulla on maksutoiminto."
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr "Tällä sivulla on nosto toiminto."
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr "Tällä sivulla on tippaus toiminto."
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr "Tällä sivulla on ilmoitus varaus toiminto."
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr "Ilmoita"
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr "Tällä sivulla on hyvitys toiminto."
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr "Tällä sivulla on väärin muotoiltu taler uri."
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr "Hylkää"
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr "tämä ponnahdusikkuna suljetaan ja sinut ohjataan osoitteeseen %1$s"
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, c-format
+msgid "Could not load purchase proposal details"
+msgstr "Ostoehdotuksen tietoja ei voitu ladata"
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, c-format
+msgid "Order Id"
+msgstr "Tilausnumero"
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr "Yhteenveto"
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr "Summa"
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr "Kauppiaan nimi"
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr "Kauppiaan toimivalta"
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr "Kauppiaan osoite"
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr "Kauppiaan logo"
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr "Kauppiaan nettisivut"
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr "Kauppiaan sähköposti"
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr "Kauppiaan julkinen avain"
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr "Toimituspäivä"
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr "Toimituspaikka"
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr "Tuotteet"
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr "Luotu"
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr "Palautuksen määräaika"
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr "Automaattinen palautus"
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr "Maksun määräaika"
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr "Toteutus-URL"
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr "Toteutusviesti"
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr "Max talletusmaksu"
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr "Max maksu"
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr "Alaikäraja"
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr "Pankkimaksun lyhennys"
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr "Tilintarkastajat"
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, c-format
+msgid "Exchanges"
+msgstr "Vaihdot"
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
+msgstr "Pankkitili"
+
+#: src/components/Part.tsx:160
+#, c-format
+msgid "Bitcoin address"
+msgstr "Bitcoin osoite"
+
+#: src/components/Part.tsx:163
+#, c-format
+msgid "IBAN"
+msgstr "IBAN"
+
+#: src/cta/Deposit/views.tsx:38
+#, c-format
+msgid "Could not load deposit status"
+msgstr "Talletuksen tilaa ei voitu ladata"
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr "Digitaalinen käteistalletus"
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr "Kustannus"
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr "Maksu"
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr "Vastaanotettava"
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr "Lähetä &nbsp; %1$s"
+
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr "Bitcoin -siirron tiedot"
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an minimum "
+"amount."
+msgstr ""
+"Pörssi tarvitsee tapahtuman, jossa on 3 lähtöä, joista yksi on vaihtotili ja "
+"kaksi muuta ovat segwit fake -osoitteita metatiedoille vähimmäismäärällä."
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional "
+"recipient and copy addresses and amounts"
+msgstr ""
+"Käytä bitcoincore-lompakossa &apos;Lisää vastaanottaja&apos; -painiketta "
+"lisätäksesi kaksi muuta vastaanottajaa ja kopioidaksesi osoitteet ja summat"
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC"
+msgstr ""
+"Varmista, että summa näyttää %1$s BTC, muuten sinun on vaihdettava "
+"perusyksikkö BTC:ksi"
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr "Tili"
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr "Pankin isäntä"
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr "Pankkisiirtotiedot"
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr "Aihe"
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr "Vastaanottajan nimi"
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr "Tapahtumatietoja ei voitu ladata"
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr "Tapahtuman suorittamisessa tapahtui virhe"
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr "Tätä tapahtumaa ei ole suoritettu loppuun"
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr "Lähetä"
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr "Yritä uudelleen"
+
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr "Unohda"
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr "Varoitus!"
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to get "
+"the coins form it."
+msgstr ""
+"Jos olet jo siirtänyt rahaa vaihtoon, menetät mahdollisuuden saada kolikot "
+"siitä."
+
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr "Vahvista"
+
+#: src/wallet/Transaction.tsx:267
+#, c-format
+msgid "Withdrawal"
+msgstr "Nosto"
+
+#: src/wallet/Transaction.tsx:286
+#, c-format
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+"Varmista, että käytät oikeaa aihetta, muuten rahat eivät tule tähän "
+"lompakkoon."
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check "
+"there is no pending step."
+msgstr ""
+"Pankki ei ole vielä vahvistanut pankkisiirtoa. Siirry kohtaan %1$s %2$s ja "
+"tarkista, ettei odottavaa vaihetta ole."
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:325
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:360
+#, c-format
+msgid "Payment"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:378
+#, c-format
+msgid "Refunds"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:385
+#, c-format
+msgid "%1$s %2$s on %3$s"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:415
+#, c-format
+msgid "Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:431
+#, c-format
+msgid "Accept"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:496
+#, c-format
+msgid "Refresh"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1074
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1335
+#, c-format
+msgid "Total transfer"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:364
+#, c-format
+msgid ""
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:366
+#, c-format
+msgid "Your current balance is not enough."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:34
+#, c-format
+msgid "Could not load refund status"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:76
+#, c-format
+msgid "Total to refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:106
+#, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:115
+#, c-format
+msgid "Order amount"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:32
+#, c-format
+msgid "Could not load tip status"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:66
+#, c-format
+msgid "The merchant is offering you a tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:90
+#, c-format
+msgid "Receive &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:114
+#, c-format
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
+msgstr ""
+
+#: src/components/SelectList.tsx:66
+#, c-format
+msgid "Select one option"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:39
+#, c-format
+msgid "Could not load"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:107
+#, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:170
+#, c-format
+msgid "The exchange reply with a empty terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, c-format
+msgid "Could not load exchange fees"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, c-format
+msgid "could not find any exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, c-format
+msgid "Select %1$s exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, c-format
+msgid "Use this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:252
+#, c-format
+msgid "Deposits"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, c-format
+msgid "Denomination"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, c-format
+msgid "Withdrawals"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, c-format
+msgid "Coin operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and is "
+"valid for a period of time. The exchange will charge the indicated amount every "
+"time a coin is used in such operation."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, c-format
+msgid "Transfer operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is valid "
+"for a period of time. The exchange will charge the indicated amount every time a "
+"transfer is made."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, c-format
+msgid "Wallet operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:60
+#, c-format
+msgid "Could not get info of withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/cta/InvoicePay/views.tsx:63
+#, c-format
+msgid "Could not finish the payment operation"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr ""
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will send "
+"the coins to this wallet after receiving a wire transfer with the correct "
+"subject."
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:38
+#, c-format
+msgid "Could not load deposit balance"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:233
+#, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr ""
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid "To complete the process you need to wire%1$s %2$s to the exchange bank account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a banking "
+"app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, c-format
+msgid "Could not toggle auto-open"
+msgstr ""
+
+#: src/wallet/Settings.tsx:121
+#, c-format
+msgid "Could not toggle clipboard"
+msgstr ""
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr ""
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr ""
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires more "
+"permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr ""
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr ""
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr ""
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr ""
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr ""
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr ""
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr ""
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr ""
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr ""
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr ""
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr ""
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires more "
+"permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check the "
+"preference name %2$s)."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL "
+"YOUR COINS?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr "Avoin"
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, c-format
+msgid "Could not load list of exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
+#, c-format
+msgid "From my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:449
+#, c-format
+msgid "currency not provided"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:459
+#, c-format
+msgid "Specify the amount and the destination"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:483
+#, c-format
+msgid "Use previous destinations:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:503
+#, c-format
+msgid "Or specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:511
+#, c-format
+msgid "Specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:521
+#, c-format
+msgid "To my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:534
+#, c-format
+msgid "To another wallet"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:30
+#, c-format
+msgid "Could not load backup recovery information"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:47
+#, c-format
+msgid "Digital wallet recovery"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:52
+#, c-format
+msgid "Import backup, show info"
+msgstr ""
+
+#: src/wallet/Application.tsx:189
+#, c-format
+msgid "All done, your transaction is in progress"
+msgstr ""
+
+#: src/components/EditableText.tsx:45
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/wallet/ManualWithdrawPage.tsx:102
+#, c-format
+msgid "Could not load the list of known exchanges"
+msgstr ""
diff --git a/packages/taler-wallet-webextension/src/i18n/fr.po b/packages/taler-wallet-webextension/src/i18n/fr.po
index 67b09de1a..462eb30f7 100644
--- a/packages/taler-wallet-webextension/src/i18n/fr.po
+++ b/packages/taler-wallet-webextension/src/i18n/fr.po
@@ -1,290 +1,1969 @@
-# This file is part of TALER
-# (C) 2016 GNUnet e.V.
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
#
-# TALER is free software; you can redistribute it and/or modify it under the
+# GNU Taler is free software; you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation; either version 3, or (at your option) any later version.
#
-# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
-# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
#
-#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Taler Wallet\n"
-"Report-Msgid-Bugs-To: \n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
-"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
-"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\n"
-"Language: \n"
+"PO-Revision-Date: 2024-02-28 08:07+0000\n"
+"Last-Translator: d0p1 <contact@d0p1.eu>\n"
+"Language-Team: French <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/fr/>\n"
+"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"Plural-Forms: nplurals=2; plural=(n!=1);\n"
+"X-Generator: Weblate 5.2.1\n"
-#: src/util/wire.ts:37
+#: src/NavigationBar.tsx:139
#, c-format
-msgid "Invalid Wire"
+msgid "Balance"
msgstr ""
-#: src/util/wire.ts:42 src/util/wire.ts:45
+#: src/NavigationBar.tsx:142
#, c-format
-msgid "Invalid Test Wire Detail"
+msgid "Backup"
msgstr ""
-#: src/util/wire.ts:47
+#: src/NavigationBar.tsx:147
#, c-format
-msgid "Test Wire Acct #%1$s on %2$s"
+msgid "QR Reader and Taler URI"
msgstr ""
-#: src/util/wire.ts:49
+#: src/NavigationBar.tsx:154
#, c-format
-msgid "Unknown Wire Detail"
+msgid "Settings"
msgstr ""
-#: src/webex/pages/benchmark.tsx:52
+#: src/NavigationBar.tsx:184
#, c-format
-msgid "Operation"
+msgid "Dev"
msgstr ""
-#: src/webex/pages/benchmark.tsx:53
+#: src/mui/Typography.tsx:122
#, c-format
-msgid "time (ms/op)"
+msgid "%1$s"
msgstr ""
-#: src/webex/pages/pay.tsx:130
+#: src/components/PendingTransactions.tsx:74
#, c-format
-msgid "The merchant %1$s offers you to purchase:"
+msgid "PENDING OPERATIONS"
msgstr ""
-#: src/webex/pages/pay.tsx:136
+#: src/components/Loading.tsx:36
#, c-format
-msgid "The total price is %1$s (plus %2$s fees)."
+msgid "Loading"
msgstr ""
-#: src/webex/pages/pay.tsx:141
+#: src/wallet/BackupPage.tsx:123
#, c-format
-msgid "The total price is %1$s."
+msgid "Could not load backup providers"
msgstr ""
-#: src/webex/pages/pay.tsx:163
+#: src/wallet/BackupPage.tsx:202
#, c-format
-msgid "Retry"
+msgid "No backup providers configured"
msgstr ""
-#: src/webex/pages/pay.tsx:173
+#: src/wallet/BackupPage.tsx:205
#, c-format
-msgid "Confirm payment"
+msgid "Add provider"
msgstr ""
-#: src/webex/pages/popup.tsx:153
+#: src/wallet/BackupPage.tsx:219
#, c-format
-msgid "Balance"
+msgid "Sync all backups"
msgstr ""
-#: src/webex/pages/popup.tsx:154
+#: src/wallet/BackupPage.tsx:221
#, c-format
-msgid "History"
+msgid "Sync now"
msgstr ""
-#: src/webex/pages/popup.tsx:155
+#: src/wallet/BackupPage.tsx:264
#, c-format
-msgid "Debug"
+msgid "Last synced"
msgstr ""
-#: src/webex/pages/popup.tsx:175
+#: src/wallet/BackupPage.tsx:269
#, c-format
-msgid "You have no balance to show. Need some %1$s getting started?"
+msgid "Not synced"
msgstr ""
-#: src/webex/pages/popup.tsx:238
+#: src/wallet/BackupPage.tsx:289
#, c-format
-msgid "%1$s incoming"
+msgid "Expires in"
msgstr ""
-#: src/webex/pages/popup.tsx:250
+#: src/wallet/ProviderDetailPage.tsx:60
#, c-format
-msgid "%1$s being spent"
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
msgstr ""
-#: src/webex/pages/popup.tsx:281
+#: src/wallet/ProviderDetailPage.tsx:108
#, c-format
-msgid "Error: could not retrieve balance information."
+msgid "There is not known provider with url &quot;%1$s&quot;."
msgstr ""
-#: src/webex/pages/popup.tsx:390
+#: src/wallet/ProviderDetailPage.tsx:115
#, c-format
-msgid "Invalid "
+msgid "See providers"
msgstr ""
-#: src/webex/pages/popup.tsx:396
+#: src/wallet/ProviderDetailPage.tsx:143
#, c-format
-msgid "Fees "
+msgid "Last backup"
msgstr ""
-#: src/webex/pages/popup.tsx:434
+#: src/wallet/ProviderDetailPage.tsx:148
#, c-format
-msgid "Refresh sessions has completed"
+msgid "Back up"
msgstr ""
-#: src/webex/pages/popup.tsx:451
+#: src/wallet/ProviderDetailPage.tsx:154
#, c-format
-msgid "Order Refused"
+msgid "Provider fee"
msgstr ""
-#: src/webex/pages/popup.tsx:465
+#: src/wallet/ProviderDetailPage.tsx:157
#, c-format
-msgid "Order redirected"
+msgid "per year"
msgstr ""
-#: src/webex/pages/popup.tsx:482
+#: src/wallet/ProviderDetailPage.tsx:163
#, c-format
-msgid "Payment aborted"
+msgid "Extend"
msgstr ""
-#: src/webex/pages/popup.tsx:512
+#: src/wallet/ProviderDetailPage.tsx:169
#, c-format
-msgid "Payment Sent"
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms "
+"of service"
msgstr ""
-#: src/webex/pages/popup.tsx:536
+#: src/wallet/ProviderDetailPage.tsx:179
#, c-format
-msgid "Order accepted"
+msgid "old"
msgstr ""
-#: src/webex/pages/popup.tsx:547
+#: src/wallet/ProviderDetailPage.tsx:183
#, c-format
-msgid "Reserve balance updated"
+msgid "new"
msgstr ""
-#: src/webex/pages/popup.tsx:559
+#: src/wallet/ProviderDetailPage.tsx:190
#, c-format
-msgid "Payment refund"
+msgid "fee"
msgstr ""
-#: src/webex/pages/popup.tsx:584
+#: src/wallet/ProviderDetailPage.tsx:198
#, c-format
-msgid "Withdrawn"
+msgid "storage"
msgstr ""
-#: src/webex/pages/popup.tsx:596
+#: src/wallet/ProviderDetailPage.tsx:215
#, c-format
-msgid "Tip Accepted"
+msgid "Remove provider"
msgstr ""
-#: src/webex/pages/popup.tsx:606
+#: src/wallet/ProviderDetailPage.tsx:228
#, c-format
-msgid "Tip Declined"
+msgid "This provider has reported an error"
msgstr ""
-#: src/webex/pages/popup.tsx:615
+#: src/wallet/ProviderDetailPage.tsx:242
#, c-format
-msgid "%1$s"
+msgid "There is conflict with another backup from %1$s"
msgstr ""
-#: src/webex/pages/popup.tsx:707
+#: src/wallet/ProviderDetailPage.tsx:253
#, c-format
-msgid "Your wallet has no events recorded."
+msgid "Backup is not readable"
msgstr ""
-#: src/webex/pages/return-coins.tsx:124
+#: src/wallet/ProviderDetailPage.tsx:261
#, c-format
-msgid "Wire to bank account"
+msgid "Unknown backup problem: %1$s"
msgstr ""
-#: src/webex/pages/return-coins.tsx:206
+#: src/wallet/ProviderDetailPage.tsx:283
#, c-format
-msgid "Confirm"
+msgid "service paid"
msgstr ""
-#: src/webex/pages/return-coins.tsx:209
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format
+msgid "Backup valid until"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:57
#, c-format
msgid "Cancel"
+msgstr "Annuler"
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr ""
+
+#: src/popup/NoBalanceHelp.tsx:43
+#, c-format
+msgid "Get digital cash"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:175
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:179
+#, c-format
+msgid "Send %1$s"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr ""
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, c-format
+msgid "Could not load purchase proposal details"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, c-format
+msgid "Order Id"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, c-format
+msgid "Exchanges"
+msgstr ""
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/Part.tsx:160
+#, c-format
+msgid "Bitcoin address"
+msgstr ""
+
+#: src/components/Part.tsx:163
+#, c-format
+msgid "IBAN"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:38
+#, c-format
+msgid "Could not load deposit status"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an "
+"minimum amount."
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two "
+"additional recipient and copy addresses and amounts"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid ""
+"Make sure the amount show %1$s BTC, else you have to change the base unit to "
+"BTC"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to "
+"get the coins form it."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr "Confirmer"
+
+#: src/wallet/Transaction.tsx:267
+#, c-format
+msgid "Withdrawal"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:286
+#, c-format
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and "
+"check there is no pending step."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid ""
+"Bank has confirmed the wire transfer. Waiting for the exchange to send the "
+"coins"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:325
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:360
+#, c-format
+msgid "Payment"
msgstr ""
-#: src/webex/pages/withdraw.tsx:73
+#: src/wallet/Transaction.tsx:378
#, c-format
-msgid "Could not get details for withdraw operation:"
+msgid "Refunds"
msgstr ""
-#: src/webex/pages/withdraw.tsx:89 src/webex/pages/withdraw.tsx:183
+#: src/wallet/Transaction.tsx:385
#, c-format
-msgid "Chose different exchange provider"
+msgid "%1$s %2$s on %3$s"
msgstr ""
-#: src/webex/pages/withdraw.tsx:109
+#: src/wallet/Transaction.tsx:415
#, c-format
msgid ""
-"Please select an exchange. You can review the details before after your "
-"selection."
+"Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:431
+#, c-format
+msgid "Accept"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:496
+#, c-format
+msgid "Refresh"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr "Exchange"
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1074
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1335
+#, c-format
+msgid "Total transfer"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:364
+#, c-format
+msgid ""
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:366
+#, c-format
+msgid "Your current balance is not enough."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:34
+#, c-format
+msgid "Could not load refund status"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:76
+#, c-format
+msgid "Total to refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:106
+#, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:115
+#, c-format
+msgid "Order amount"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:32
+#, c-format
+msgid "Could not load tip status"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:66
+#, c-format
+msgid "The merchant is offering you a tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:90
+#, c-format
+msgid "Receive &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:114
+#, c-format
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
+msgstr ""
+
+#: src/components/SelectList.tsx:66
+#, c-format
+msgid "Select one option"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:39
+#, c-format
+msgid "Could not load"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:107
+#, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:170
+#, c-format
+msgid "The exchange reply with a empty terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, c-format
+msgid "Could not load exchange fees"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr "Fermer"
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, c-format
+msgid "could not find any exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, c-format
+msgid "Select %1$s exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, c-format
+msgid "Use this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:252
+#, c-format
+msgid "Deposits"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, c-format
+msgid "Denomination"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, c-format
+msgid "Withdrawals"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, c-format
+msgid "Coin operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and "
+"is valid for a period of time. The exchange will charge the indicated amount "
+"every time a coin is used in such operation."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, c-format
+msgid "Transfer operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is "
+"valid for a period of time. The exchange will charge the indicated amount "
+"every time a transfer is made."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, c-format
+msgid "Wallet operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:60
+#, c-format
+msgid "Could not get info of withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/cta/InvoicePay/views.tsx:63
+#, c-format
+msgid "Could not finish the payment operation"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr ""
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will "
+"send the coins to this wallet after receiving a wire transfer with the "
+"correct subject."
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:38
+#, c-format
+msgid "Could not load deposit balance"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:233
+#, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr ""
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid ""
+"To complete the process you need to wire%1$s %2$s to the exchange bank "
+"account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a "
+"banking app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, c-format
+msgid "Could not toggle auto-open"
+msgstr ""
+
+#: src/wallet/Settings.tsx:121
+#, c-format
+msgid "Could not toggle clipboard"
+msgstr ""
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr ""
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr ""
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr ""
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr ""
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr ""
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr ""
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr ""
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr ""
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr ""
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr ""
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr ""
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr ""
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr ""
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check "
+"the preference name %2$s)."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE "
+"ALL YOUR COINS?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, c-format
+msgid "Could not load list of exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
+#, c-format
+msgid "From my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
msgstr ""
-#: src/webex/pages/withdraw.tsx:121
+#: src/wallet/DestinationSelection.tsx:449
#, c-format
-msgid "Select %1$s"
+msgid "currency not provided"
msgstr ""
-#: src/webex/pages/withdraw.tsx:143
+#: src/wallet/DestinationSelection.tsx:459
#, c-format
-msgid "Select custom exchange"
+msgid "Specify the amount and the destination"
msgstr ""
-#: src/webex/pages/withdraw.tsx:163
+#: src/wallet/DestinationSelection.tsx:483
#, c-format
-msgid "You are about to withdraw %1$s from your bank account into your wallet."
+msgid "Use previous destinations:"
msgstr ""
-#: src/webex/pages/withdraw.tsx:174
+#: src/wallet/DestinationSelection.tsx:503
#, c-format
-msgid "Accept fees and withdraw"
+msgid "Or specify the destination of the money"
msgstr ""
-#: src/webex/pages/withdraw.tsx:192
+#: src/wallet/DestinationSelection.tsx:511
#, c-format
-msgid "Cancel withdraw operation"
+msgid "Specify the destination of the money"
msgstr ""
-#: src/webex/renderHtml.tsx:249
+#: src/wallet/DestinationSelection.tsx:521
#, c-format
-msgid "Withdrawal fees:"
+msgid "To my bank account"
msgstr ""
-#: src/webex/renderHtml.tsx:252
+#: src/wallet/DestinationSelection.tsx:534
#, c-format
-msgid "Rounding loss:"
+msgid "To another wallet"
msgstr ""
-#: src/webex/renderHtml.tsx:254
+#: src/cta/Recovery/views.tsx:30
#, c-format
-msgid "Earliest expiration (for deposit): %1$s"
+msgid "Could not load backup recovery information"
msgstr ""
-#: src/webex/renderHtml.tsx:262
+#: src/cta/Recovery/views.tsx:47
#, c-format
-msgid "# Coins"
+msgid "Digital wallet recovery"
msgstr ""
-#: src/webex/renderHtml.tsx:263
+#: src/cta/Recovery/views.tsx:52
#, c-format
-msgid "Value"
+msgid "Import backup, show info"
msgstr ""
-#: src/webex/renderHtml.tsx:264
+#: src/wallet/Application.tsx:189
#, c-format
-msgid "Withdraw Fee"
+msgid "All done, your transaction is in progress"
msgstr ""
-#: src/webex/renderHtml.tsx:265
+#: src/components/EditableText.tsx:45
#, c-format
-msgid "Refresh Fee"
+msgid "Edit"
msgstr ""
-#: src/webex/renderHtml.tsx:266
+#: src/wallet/ManualWithdrawPage.tsx:102
#, c-format
-msgid "Deposit Fee"
+msgid "Could not load the list of known exchanges"
msgstr ""
diff --git a/packages/taler-wallet-webextension/src/i18n/it.po b/packages/taler-wallet-webextension/src/i18n/it.po
index 67b09de1a..e0568b8f8 100644
--- a/packages/taler-wallet-webextension/src/i18n/it.po
+++ b/packages/taler-wallet-webextension/src/i18n/it.po
@@ -1,290 +1,1969 @@
-# This file is part of TALER
-# (C) 2016 GNUnet e.V.
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
#
-# TALER is free software; you can redistribute it and/or modify it under the
+# GNU Taler is free software; you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation; either version 3, or (at your option) any later version.
#
-# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
-# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
#
-#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Taler Wallet\n"
-"Report-Msgid-Bugs-To: \n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
-"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
-"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\n"
-"Language: \n"
+"PO-Revision-Date: 2023-08-16 12:43+0000\n"
+"Last-Translator: Krystian Baran <kiszkot@murena.io>\n"
+"Language-Team: Italian <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/it/>\n"
+"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.13.1\n"
-#: src/util/wire.ts:37
+#: src/NavigationBar.tsx:139
#, c-format
-msgid "Invalid Wire"
+msgid "Balance"
+msgstr "Saldo"
+
+#: src/NavigationBar.tsx:142
+#, c-format
+msgid "Backup"
+msgstr ""
+
+#: src/NavigationBar.tsx:147
+#, c-format
+msgid "QR Reader and Taler URI"
+msgstr "Lettore QR e Taler URI"
+
+#: src/NavigationBar.tsx:154
+#, c-format
+msgid "Settings"
+msgstr "Impostazioni"
+
+#: src/NavigationBar.tsx:184
+#, c-format
+msgid "Dev"
msgstr ""
-#: src/util/wire.ts:42 src/util/wire.ts:45
+#: src/mui/Typography.tsx:122
#, c-format
-msgid "Invalid Test Wire Detail"
+msgid "%1$s"
msgstr ""
-#: src/util/wire.ts:47
+#: src/components/PendingTransactions.tsx:74
#, c-format
-msgid "Test Wire Acct #%1$s on %2$s"
+msgid "PENDING OPERATIONS"
msgstr ""
-#: src/util/wire.ts:49
+#: src/components/Loading.tsx:36
#, c-format
-msgid "Unknown Wire Detail"
+msgid "Loading"
msgstr ""
-#: src/webex/pages/benchmark.tsx:52
+#: src/wallet/BackupPage.tsx:123
#, c-format
-msgid "Operation"
+msgid "Could not load backup providers"
msgstr ""
-#: src/webex/pages/benchmark.tsx:53
+#: src/wallet/BackupPage.tsx:202
#, c-format
-msgid "time (ms/op)"
+msgid "No backup providers configured"
msgstr ""
-#: src/webex/pages/pay.tsx:130
+#: src/wallet/BackupPage.tsx:205
#, c-format
-msgid "The merchant %1$s offers you to purchase:"
+msgid "Add provider"
msgstr ""
-#: src/webex/pages/pay.tsx:136
+#: src/wallet/BackupPage.tsx:219
#, c-format
-msgid "The total price is %1$s (plus %2$s fees)."
+msgid "Sync all backups"
msgstr ""
-#: src/webex/pages/pay.tsx:141
+#: src/wallet/BackupPage.tsx:221
#, c-format
-msgid "The total price is %1$s."
+msgid "Sync now"
msgstr ""
-#: src/webex/pages/pay.tsx:163
+#: src/wallet/BackupPage.tsx:264
#, c-format
-msgid "Retry"
+msgid "Last synced"
msgstr ""
-#: src/webex/pages/pay.tsx:173
+#: src/wallet/BackupPage.tsx:269
#, c-format
-msgid "Confirm payment"
+msgid "Not synced"
msgstr ""
-#: src/webex/pages/popup.tsx:153
+#: src/wallet/BackupPage.tsx:289
#, c-format
-msgid "Balance"
+msgid "Expires in"
msgstr ""
-#: src/webex/pages/popup.tsx:154
+#: src/wallet/ProviderDetailPage.tsx:60
#, c-format
-msgid "History"
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
msgstr ""
-#: src/webex/pages/popup.tsx:155
+#: src/wallet/ProviderDetailPage.tsx:108
#, c-format
-msgid "Debug"
+msgid "There is not known provider with url &quot;%1$s&quot;."
msgstr ""
-#: src/webex/pages/popup.tsx:175
+#: src/wallet/ProviderDetailPage.tsx:115
#, c-format
-msgid "You have no balance to show. Need some %1$s getting started?"
+msgid "See providers"
msgstr ""
-#: src/webex/pages/popup.tsx:238
+#: src/wallet/ProviderDetailPage.tsx:143
#, c-format
-msgid "%1$s incoming"
+msgid "Last backup"
msgstr ""
-#: src/webex/pages/popup.tsx:250
+#: src/wallet/ProviderDetailPage.tsx:148
#, c-format
-msgid "%1$s being spent"
+msgid "Back up"
msgstr ""
-#: src/webex/pages/popup.tsx:281
+#: src/wallet/ProviderDetailPage.tsx:154
#, c-format
-msgid "Error: could not retrieve balance information."
+msgid "Provider fee"
msgstr ""
-#: src/webex/pages/popup.tsx:390
+#: src/wallet/ProviderDetailPage.tsx:157
#, c-format
-msgid "Invalid "
+msgid "per year"
msgstr ""
-#: src/webex/pages/popup.tsx:396
+#: src/wallet/ProviderDetailPage.tsx:163
#, c-format
-msgid "Fees "
+msgid "Extend"
msgstr ""
-#: src/webex/pages/popup.tsx:434
+#: src/wallet/ProviderDetailPage.tsx:169
#, c-format
-msgid "Refresh sessions has completed"
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms "
+"of service"
msgstr ""
-#: src/webex/pages/popup.tsx:451
+#: src/wallet/ProviderDetailPage.tsx:179
#, c-format
-msgid "Order Refused"
+msgid "old"
msgstr ""
-#: src/webex/pages/popup.tsx:465
+#: src/wallet/ProviderDetailPage.tsx:183
#, c-format
-msgid "Order redirected"
+msgid "new"
msgstr ""
-#: src/webex/pages/popup.tsx:482
+#: src/wallet/ProviderDetailPage.tsx:190
#, c-format
-msgid "Payment aborted"
+msgid "fee"
msgstr ""
-#: src/webex/pages/popup.tsx:512
+#: src/wallet/ProviderDetailPage.tsx:198
#, c-format
-msgid "Payment Sent"
+msgid "storage"
msgstr ""
-#: src/webex/pages/popup.tsx:536
+#: src/wallet/ProviderDetailPage.tsx:215
#, c-format
-msgid "Order accepted"
+msgid "Remove provider"
msgstr ""
-#: src/webex/pages/popup.tsx:547
+#: src/wallet/ProviderDetailPage.tsx:228
#, c-format
-msgid "Reserve balance updated"
+msgid "This provider has reported an error"
msgstr ""
-#: src/webex/pages/popup.tsx:559
+#: src/wallet/ProviderDetailPage.tsx:242
#, c-format
-msgid "Payment refund"
+msgid "There is conflict with another backup from %1$s"
msgstr ""
-#: src/webex/pages/popup.tsx:584
+#: src/wallet/ProviderDetailPage.tsx:253
#, c-format
-msgid "Withdrawn"
+msgid "Backup is not readable"
msgstr ""
-#: src/webex/pages/popup.tsx:596
+#: src/wallet/ProviderDetailPage.tsx:261
#, c-format
-msgid "Tip Accepted"
+msgid "Unknown backup problem: %1$s"
msgstr ""
-#: src/webex/pages/popup.tsx:606
+#: src/wallet/ProviderDetailPage.tsx:283
#, c-format
-msgid "Tip Declined"
+msgid "service paid"
msgstr ""
-#: src/webex/pages/popup.tsx:615
+#: src/wallet/ProviderDetailPage.tsx:290
#, c-format
-msgid "%1$s"
+msgid "Backup valid until"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:57
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr ""
+
+#: src/popup/NoBalanceHelp.tsx:43
+#, c-format
+msgid "Get digital cash"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:175
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:179
+#, c-format
+msgid "Send %1$s"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr ""
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, c-format
+msgid "Could not load purchase proposal details"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, c-format
+msgid "Order Id"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr "Importo"
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, c-format
+msgid "Exchanges"
+msgstr ""
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
msgstr ""
-#: src/webex/pages/popup.tsx:707
+#: src/components/Part.tsx:160
#, c-format
-msgid "Your wallet has no events recorded."
+msgid "Bitcoin address"
msgstr ""
-#: src/webex/pages/return-coins.tsx:124
+#: src/components/Part.tsx:163
#, c-format
-msgid "Wire to bank account"
+msgid "IBAN"
msgstr ""
-#: src/webex/pages/return-coins.tsx:206
+#: src/cta/Deposit/views.tsx:38
+#, c-format
+msgid "Could not load deposit status"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an "
+"minimum amount."
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two "
+"additional recipient and copy addresses and amounts"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid ""
+"Make sure the amount show %1$s BTC, else you have to change the base unit to "
+"BTC"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr "Soggetto"
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to "
+"get the coins form it."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:259
#, c-format
msgid "Confirm"
+msgstr "Confermare"
+
+#: src/wallet/Transaction.tsx:267
+#, c-format
+msgid "Withdrawal"
msgstr ""
-#: src/webex/pages/return-coins.tsx:209
+#: src/wallet/Transaction.tsx:286
#, c-format
-msgid "Cancel"
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and "
+"check there is no pending step."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid ""
+"Bank has confirmed the wire transfer. Waiting for the exchange to send the "
+"coins"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:325
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:360
+#, c-format
+msgid "Payment"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:378
+#, c-format
+msgid "Refunds"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:385
+#, c-format
+msgid "%1$s %2$s on %3$s"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:415
+#, c-format
+msgid ""
+"Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:431
+#, c-format
+msgid "Accept"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:496
+#, c-format
+msgid "Refresh"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr "Cambio"
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr "Data"
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1074
+#, c-format
+msgid "Withdraw"
+msgstr "Prelevare"
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr "Rimborsato"
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1335
+#, c-format
+msgid "Total transfer"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
msgstr ""
-#: src/webex/pages/withdraw.tsx:73
+#: src/cta/Payment/views.tsx:346
#, c-format
-msgid "Could not get details for withdraw operation:"
+msgid "Pay &nbsp; %1$s"
msgstr ""
-#: src/webex/pages/withdraw.tsx:89 src/webex/pages/withdraw.tsx:183
+#: src/cta/Payment/views.tsx:360
#, c-format
-msgid "Chose different exchange provider"
+msgid "You have no balance for this currency. Withdraw digital cash first."
msgstr ""
-#: src/webex/pages/withdraw.tsx:109
+#: src/cta/Payment/views.tsx:364
#, c-format
msgid ""
-"Please select an exchange. You can review the details before after your "
-"selection."
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:366
+#, c-format
+msgid "Your current balance is not enough."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:34
+#, c-format
+msgid "Could not load refund status"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:76
+#, c-format
+msgid "Total to refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:106
+#, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:115
+#, c-format
+msgid "Order amount"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:32
+#, c-format
+msgid "Could not load tip status"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:66
+#, c-format
+msgid "The merchant is offering you a tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:90
+#, c-format
+msgid "Receive &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:114
+#, c-format
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
+msgstr ""
+
+#: src/components/SelectList.tsx:66
+#, c-format
+msgid "Select one option"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:39
+#, c-format
+msgid "Could not load"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:107
+#, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:170
+#, c-format
+msgid "The exchange reply with a empty terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, c-format
+msgid "Could not load exchange fees"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, c-format
+msgid "could not find any exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, c-format
+msgid "Select %1$s exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, c-format
+msgid "Use this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:252
+#, c-format
+msgid "Deposits"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, c-format
+msgid "Denomination"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, c-format
+msgid "Withdrawals"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, c-format
+msgid "Coin operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and "
+"is valid for a period of time. The exchange will charge the indicated amount "
+"every time a coin is used in such operation."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, c-format
+msgid "Transfer operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is "
+"valid for a period of time. The exchange will charge the indicated amount "
+"every time a transfer is made."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, c-format
+msgid "Wallet operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:60
+#, c-format
+msgid "Could not get info of withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/cta/InvoicePay/views.tsx:63
+#, c-format
+msgid "Could not finish the payment operation"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr ""
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will "
+"send the coins to this wallet after receiving a wire transfer with the "
+"correct subject."
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr "Inizia a prelevare"
+
+#: src/wallet/DepositPage/views.tsx:38
+#, c-format
+msgid "Could not load deposit balance"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:233
+#, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr ""
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid ""
+"To complete the process you need to wire%1$s %2$s to the exchange bank "
+"account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a "
+"banking app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, c-format
+msgid "Could not toggle auto-open"
+msgstr ""
+
+#: src/wallet/Settings.tsx:121
+#, c-format
+msgid "Could not toggle clipboard"
+msgstr ""
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr ""
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr ""
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr ""
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr ""
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr "ok"
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr ""
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr ""
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr ""
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr ""
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr ""
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr ""
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr ""
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr ""
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check "
+"the preference name %2$s)."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE "
+"ALL YOUR COINS?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, c-format
+msgid "Could not load list of exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
+#, c-format
+msgid "From my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
msgstr ""
-#: src/webex/pages/withdraw.tsx:121
+#: src/wallet/DestinationSelection.tsx:449
#, c-format
-msgid "Select %1$s"
+msgid "currency not provided"
msgstr ""
-#: src/webex/pages/withdraw.tsx:143
+#: src/wallet/DestinationSelection.tsx:459
#, c-format
-msgid "Select custom exchange"
+msgid "Specify the amount and the destination"
msgstr ""
-#: src/webex/pages/withdraw.tsx:163
+#: src/wallet/DestinationSelection.tsx:483
#, c-format
-msgid "You are about to withdraw %1$s from your bank account into your wallet."
+msgid "Use previous destinations:"
msgstr ""
-#: src/webex/pages/withdraw.tsx:174
+#: src/wallet/DestinationSelection.tsx:503
#, c-format
-msgid "Accept fees and withdraw"
+msgid "Or specify the destination of the money"
msgstr ""
-#: src/webex/pages/withdraw.tsx:192
+#: src/wallet/DestinationSelection.tsx:511
#, c-format
-msgid "Cancel withdraw operation"
+msgid "Specify the destination of the money"
msgstr ""
-#: src/webex/renderHtml.tsx:249
+#: src/wallet/DestinationSelection.tsx:521
#, c-format
-msgid "Withdrawal fees:"
+msgid "To my bank account"
msgstr ""
-#: src/webex/renderHtml.tsx:252
+#: src/wallet/DestinationSelection.tsx:534
#, c-format
-msgid "Rounding loss:"
+msgid "To another wallet"
msgstr ""
-#: src/webex/renderHtml.tsx:254
+#: src/cta/Recovery/views.tsx:30
#, c-format
-msgid "Earliest expiration (for deposit): %1$s"
+msgid "Could not load backup recovery information"
msgstr ""
-#: src/webex/renderHtml.tsx:262
+#: src/cta/Recovery/views.tsx:47
#, c-format
-msgid "# Coins"
+msgid "Digital wallet recovery"
msgstr ""
-#: src/webex/renderHtml.tsx:263
+#: src/cta/Recovery/views.tsx:52
#, c-format
-msgid "Value"
+msgid "Import backup, show info"
msgstr ""
-#: src/webex/renderHtml.tsx:264
+#: src/wallet/Application.tsx:189
#, c-format
-msgid "Withdraw Fee"
+msgid "All done, your transaction is in progress"
msgstr ""
-#: src/webex/renderHtml.tsx:265
+#: src/components/EditableText.tsx:45
#, c-format
-msgid "Refresh Fee"
+msgid "Edit"
msgstr ""
-#: src/webex/renderHtml.tsx:266
+#: src/wallet/ManualWithdrawPage.tsx:102
#, c-format
-msgid "Deposit Fee"
+msgid "Could not load the list of known exchanges"
msgstr ""
diff --git a/packages/taler-wallet-webextension/src/i18n/ja.po b/packages/taler-wallet-webextension/src/i18n/ja.po
new file mode 100644
index 000000000..298ad6018
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/i18n/ja.po
@@ -0,0 +1,1976 @@
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2023-03-06 22:06+0000\n"
+"Last-Translator: Anonymous <noreply@weblate.org>\n"
+"Language-Team: Japanese <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/ja/>\n"
+"Language: ja\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.13.1\n"
+
+#: src/NavigationBar.tsx:139
+#, c-format
+msgid "Balance"
+msgstr "残高"
+
+#: src/NavigationBar.tsx:142
+#, c-format
+msgid "Backup"
+msgstr "バックアップ"
+
+#: src/NavigationBar.tsx:147
+#, c-format
+msgid "QR Reader and Taler URI"
+msgstr ""
+
+#: src/NavigationBar.tsx:154
+#, c-format
+msgid "Settings"
+msgstr "設定"
+
+#: src/NavigationBar.tsx:184
+#, c-format
+msgid "Dev"
+msgstr ""
+
+#: src/mui/Typography.tsx:122
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/PendingTransactions.tsx:74
+#, c-format
+msgid "PENDING OPERATIONS"
+msgstr ""
+
+#: src/components/Loading.tsx:36
+#, c-format
+msgid "Loading"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:123
+#, c-format
+msgid "Could not load backup providers"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:202
+#, c-format
+msgid "No backup providers configured"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:205
+#, c-format
+msgid "Add provider"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:219
+#, c-format
+msgid "Sync all backups"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:221
+#, c-format
+msgid "Sync now"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:264
+#, c-format
+msgid "Last synced"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:269
+#, c-format
+msgid "Not synced"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:289
+#, c-format
+msgid "Expires in"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:60
+#, c-format
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:108
+#, c-format
+msgid "There is not known provider with url &quot;%1$s&quot;."
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:115
+#, c-format
+msgid "See providers"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:143
+#, c-format
+msgid "Last backup"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:148
+#, c-format
+msgid "Back up"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:154
+#, c-format
+msgid "Provider fee"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:157
+#, c-format
+msgid "per year"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:163
+#, c-format
+msgid "Extend"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:169
+#, c-format
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms "
+"of service"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:179
+#, c-format
+msgid "old"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:183
+#, c-format
+msgid "new"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:190
+#, c-format
+msgid "fee"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:198
+#, c-format
+msgid "storage"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:215
+#, c-format
+msgid "Remove provider"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:228
+#, c-format
+msgid "This provider has reported an error"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:242
+#, c-format
+msgid "There is conflict with another backup from %1$s"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:253
+#, c-format
+msgid "Backup is not readable"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:261
+#, c-format
+msgid "Unknown backup problem: %1$s"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:283
+#, c-format
+msgid "service paid"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format
+msgid "Backup valid until"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:57
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr ""
+
+#: src/popup/NoBalanceHelp.tsx:43
+#, c-format
+msgid "Get digital cash"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:175
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:179
+#, c-format
+msgid "Send %1$s"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr ""
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, c-format
+msgid "Could not load purchase proposal details"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, c-format
+msgid "Order Id"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, c-format
+msgid "Exchanges"
+msgstr ""
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/Part.tsx:160
+#, c-format
+msgid "Bitcoin address"
+msgstr ""
+
+#: src/components/Part.tsx:163
+#, c-format
+msgid "IBAN"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:38
+#, c-format
+msgid "Could not load deposit status"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an "
+"minimum amount."
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two "
+"additional recipient and copy addresses and amounts"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid ""
+"Make sure the amount show %1$s BTC, else you have to change the base unit to "
+"BTC"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to "
+"get the coins form it."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:267
+#, c-format
+msgid "Withdrawal"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:286
+#, c-format
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and "
+"check there is no pending step."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid ""
+"Bank has confirmed the wire transfer. Waiting for the exchange to send the "
+"coins"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:325
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:360
+#, c-format
+msgid "Payment"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:378
+#, c-format
+msgid "Refunds"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:385
+#, c-format
+msgid "%1$s %2$s on %3$s"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:415
+#, c-format
+msgid ""
+"Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:431
+#, c-format
+msgid "Accept"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:496
+#, c-format
+msgid "Refresh"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1074
+#, fuzzy, c-format
+msgid "Withdraw"
+msgstr "撤退"
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1335
+#, c-format
+msgid "Total transfer"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:364
+#, c-format
+msgid ""
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:366
+#, fuzzy, c-format
+msgid "Your current balance is not enough."
+msgstr "表示するバランスがありません"
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:34
+#, c-format
+msgid "Could not load refund status"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:76
+#, c-format
+msgid "Total to refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:106
+#, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:115
+#, c-format
+msgid "Order amount"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:32
+#, c-format
+msgid "Could not load tip status"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:66
+#, c-format
+msgid "The merchant is offering you a tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:90
+#, c-format
+msgid "Receive &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:114
+#, c-format
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
+msgstr ""
+
+#: src/components/SelectList.tsx:66
+#, c-format
+msgid "Select one option"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:39
+#, c-format
+msgid "Could not load"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:107
+#, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:170
+#, c-format
+msgid "The exchange reply with a empty terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, c-format
+msgid "Could not load exchange fees"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, c-format
+msgid "could not find any exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, c-format
+msgid "Select %1$s exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, c-format
+msgid "Use this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:252
+#, c-format
+msgid "Deposits"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, c-format
+msgid "Denomination"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, fuzzy, c-format
+msgid "Withdrawals"
+msgstr "撤退"
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, c-format
+msgid "Coin operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and "
+"is valid for a period of time. The exchange will charge the indicated amount "
+"every time a coin is used in such operation."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, c-format
+msgid "Transfer operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is "
+"valid for a period of time. The exchange will charge the indicated amount "
+"every time a transfer is made."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, c-format
+msgid "Wallet operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:60
+#, c-format
+msgid "Could not get info of withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/cta/InvoicePay/views.tsx:63
+#, c-format
+msgid "Could not finish the payment operation"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr ""
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will "
+"send the coins to this wallet after receiving a wire transfer with the "
+"correct subject."
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:38
+#, c-format
+msgid "Could not load deposit balance"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:233
+#, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr ""
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid ""
+"To complete the process you need to wire%1$s %2$s to the exchange bank "
+"account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a "
+"banking app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, c-format
+msgid "Could not toggle auto-open"
+msgstr ""
+
+#: src/wallet/Settings.tsx:121
+#, c-format
+msgid "Could not toggle clipboard"
+msgstr ""
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr ""
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr ""
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr ""
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr ""
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr ""
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr ""
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr ""
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr ""
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr ""
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr ""
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr ""
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr ""
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr ""
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check "
+"the preference name %2$s)."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE "
+"ALL YOUR COINS?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, c-format
+msgid "Could not load list of exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
+#, c-format
+msgid "From my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:449
+#, c-format
+msgid "currency not provided"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:459
+#, c-format
+msgid "Specify the amount and the destination"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:483
+#, c-format
+msgid "Use previous destinations:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:503
+#, c-format
+msgid "Or specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:511
+#, c-format
+msgid "Specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:521
+#, c-format
+msgid "To my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:534
+#, c-format
+msgid "To another wallet"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:30
+#, c-format
+msgid "Could not load backup recovery information"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:47
+#, c-format
+msgid "Digital wallet recovery"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:52
+#, c-format
+msgid "Import backup, show info"
+msgstr ""
+
+#: src/wallet/Application.tsx:189
+#, c-format
+msgid "All done, your transaction is in progress"
+msgstr ""
+
+#: src/components/EditableText.tsx:45
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/wallet/ManualWithdrawPage.tsx:102
+#, c-format
+msgid "Could not load the list of known exchanges"
+msgstr ""
+
+#~ msgid ""
+#~ "To withdraw money you can start from your bank site or click the "
+#~ "\"withdraw\" button to use a known exchange."
+#~ msgstr ""
+#~ "お金を引き出すには、銀行のサイトから開始するか、[引き出し]ボタンをクリック"
+#~ "して既知の取引所を使用します。"
diff --git a/packages/taler-wallet-webextension/src/i18n/nl.po b/packages/taler-wallet-webextension/src/i18n/nl.po
new file mode 100644
index 000000000..4f11592dd
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/i18n/nl.po
@@ -0,0 +1,1953 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-03-02 16:54+0000\n"
+"Last-Translator: Midgard <midgard@users.noreply.weblate.taler.net>\n"
+"Language-Team: Dutch <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/nl/>\n"
+"Language: nl\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/NavigationBar.tsx:139
+#, c-format
+msgid "Balance"
+msgstr ""
+
+#: src/NavigationBar.tsx:142
+#, c-format
+msgid "Backup"
+msgstr ""
+
+#: src/NavigationBar.tsx:147
+#, c-format
+msgid "QR Reader and Taler URI"
+msgstr ""
+
+#: src/NavigationBar.tsx:154
+#, c-format
+msgid "Settings"
+msgstr ""
+
+#: src/NavigationBar.tsx:184
+#, c-format
+msgid "Dev"
+msgstr ""
+
+#: src/mui/Typography.tsx:122
+#, c-format
+msgid "%1$s"
+msgstr ""
+
+#: src/components/PendingTransactions.tsx:74
+#, c-format
+msgid "PENDING OPERATIONS"
+msgstr ""
+
+#: src/components/Loading.tsx:36
+#, c-format
+msgid "Loading"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:123
+#, c-format
+msgid "Could not load backup providers"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:202
+#, c-format
+msgid "No backup providers configured"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:205
+#, c-format
+msgid "Add provider"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:219
+#, c-format
+msgid "Sync all backups"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:221
+#, c-format
+msgid "Sync now"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:264
+#, c-format
+msgid "Last synced"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:269
+#, c-format
+msgid "Not synced"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:289
+#, c-format
+msgid "Expires in"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:60
+#, c-format
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:108
+#, c-format
+msgid "There is not known provider with url &quot;%1$s&quot;."
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:115
+#, c-format
+msgid "See providers"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:143
+#, c-format
+msgid "Last backup"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:148
+#, c-format
+msgid "Back up"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:154
+#, c-format
+msgid "Provider fee"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:157
+#, c-format
+msgid "per year"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:163
+#, c-format
+msgid "Extend"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:169
+#, c-format
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms of "
+"service"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:179
+#, c-format
+msgid "old"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:183
+#, c-format
+msgid "new"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:190
+#, c-format
+msgid "fee"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:198
+#, c-format
+msgid "storage"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:215
+#, c-format
+msgid "Remove provider"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:228
+#, c-format
+msgid "This provider has reported an error"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:242
+#, c-format
+msgid "There is conflict with another backup from %1$s"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:253
+#, c-format
+msgid "Backup is not readable"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:261
+#, c-format
+msgid "Unknown backup problem: %1$s"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:283
+#, c-format
+msgid "service paid"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format
+msgid "Backup valid until"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:57
+#, c-format
+msgid "Cancel"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr ""
+
+#: src/popup/NoBalanceHelp.tsx:43
+#, c-format
+msgid "Get digital cash"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:175
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:179
+#, c-format
+msgid "Send %1$s"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr ""
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, c-format
+msgid "Could not load purchase proposal details"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, c-format
+msgid "Order Id"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, c-format
+msgid "Exchanges"
+msgstr ""
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/Part.tsx:160
+#, c-format
+msgid "Bitcoin address"
+msgstr ""
+
+#: src/components/Part.tsx:163
+#, c-format
+msgid "IBAN"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:38
+#, c-format
+msgid "Could not load deposit status"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an minimum "
+"amount."
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional "
+"recipient and copy addresses and amounts"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to get "
+"the coins form it."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:267
+#, c-format
+msgid "Withdrawal"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:286
+#, c-format
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check "
+"there is no pending step."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:325
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:360
+#, c-format
+msgid "Payment"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:378
+#, c-format
+msgid "Refunds"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:385
+#, c-format
+msgid "%1$s %2$s on %3$s"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:415
+#, c-format
+msgid "Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:431
+#, c-format
+msgid "Accept"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:496
+#, c-format
+msgid "Refresh"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr "Beurs"
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1074
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1335
+#, c-format
+msgid "Total transfer"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:364
+#, c-format
+msgid ""
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:366
+#, c-format
+msgid "Your current balance is not enough."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:34
+#, c-format
+msgid "Could not load refund status"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:76
+#, c-format
+msgid "Total to refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:106
+#, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:115
+#, c-format
+msgid "Order amount"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:32
+#, c-format
+msgid "Could not load tip status"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:66
+#, c-format
+msgid "The merchant is offering you a tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:90
+#, c-format
+msgid "Receive &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:114
+#, c-format
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
+msgstr ""
+
+#: src/components/SelectList.tsx:66
+#, c-format
+msgid "Select one option"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:39
+#, c-format
+msgid "Could not load"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:107
+#, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:170
+#, c-format
+msgid "The exchange reply with a empty terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, c-format
+msgid "Could not load exchange fees"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, c-format
+msgid "could not find any exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, c-format
+msgid "Select %1$s exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, c-format
+msgid "Use this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:252
+#, c-format
+msgid "Deposits"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, c-format
+msgid "Denomination"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, c-format
+msgid "Withdrawals"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, c-format
+msgid "Coin operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and is "
+"valid for a period of time. The exchange will charge the indicated amount every "
+"time a coin is used in such operation."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, c-format
+msgid "Transfer operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is valid "
+"for a period of time. The exchange will charge the indicated amount every time a "
+"transfer is made."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, c-format
+msgid "Wallet operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:60
+#, c-format
+msgid "Could not get info of withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/cta/InvoicePay/views.tsx:63
+#, c-format
+msgid "Could not finish the payment operation"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr ""
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will send "
+"the coins to this wallet after receiving a wire transfer with the correct "
+"subject."
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:38
+#, c-format
+msgid "Could not load deposit balance"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:233
+#, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr ""
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid "To complete the process you need to wire%1$s %2$s to the exchange bank account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a banking "
+"app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, c-format
+msgid "Could not toggle auto-open"
+msgstr ""
+
+#: src/wallet/Settings.tsx:121
+#, c-format
+msgid "Could not toggle clipboard"
+msgstr ""
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr ""
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr ""
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires more "
+"permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr ""
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr ""
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr ""
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr ""
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr ""
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr ""
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr ""
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr ""
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr ""
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr ""
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr ""
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires more "
+"permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check the "
+"preference name %2$s)."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL "
+"YOUR COINS?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, c-format
+msgid "Could not load list of exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
+#, c-format
+msgid "From my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:449
+#, c-format
+msgid "currency not provided"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:459
+#, c-format
+msgid "Specify the amount and the destination"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:483
+#, c-format
+msgid "Use previous destinations:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:503
+#, c-format
+msgid "Or specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:511
+#, c-format
+msgid "Specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:521
+#, c-format
+msgid "To my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:534
+#, c-format
+msgid "To another wallet"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:30
+#, c-format
+msgid "Could not load backup recovery information"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:47
+#, c-format
+msgid "Digital wallet recovery"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:52
+#, c-format
+msgid "Import backup, show info"
+msgstr ""
+
+#: src/wallet/Application.tsx:189
+#, c-format
+msgid "All done, your transaction is in progress"
+msgstr ""
+
+#: src/components/EditableText.tsx:45
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/wallet/ManualWithdrawPage.tsx:102
+#, c-format
+msgid "Could not load the list of known exchanges"
+msgstr ""
diff --git a/packages/taler-wallet-webextension/src/i18n/poheader b/packages/taler-wallet-webextension/src/i18n/poheader
index 3ec704932..a793df7ab 100644
--- a/packages/taler-wallet-webextension/src/i18n/poheader
+++ b/packages/taler-wallet-webextension/src/i18n/poheader
@@ -1,16 +1,16 @@
-# This file is part of TALER
-# (C) 2016 GNUnet e.V.
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
#
-# TALER is free software; you can redistribute it and/or modify it under the
+# GNU Taler is free software; you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation; either version 3, or (at your option) any later version.
#
-# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
-# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
#
#, fuzzy
msgid ""
diff --git a/packages/taler-wallet-webextension/src/i18n/strings-prelude b/packages/taler-wallet-webextension/src/i18n/strings-prelude
index aa6602bd4..7d9d13136 100644
--- a/packages/taler-wallet-webextension/src/i18n/strings-prelude
+++ b/packages/taler-wallet-webextension/src/i18n/strings-prelude
@@ -1,17 +1,17 @@
/*
- This file is part of TALER
- (C) 2016 Inria
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
export const strings: {[s: string]: any} = {};
diff --git a/packages/taler-wallet-webextension/src/i18n/strings.ts b/packages/taler-wallet-webextension/src/i18n/strings.ts
index 5b1257830..e5281bf54 100644
--- a/packages/taler-wallet-webextension/src/i18n/strings.ts
+++ b/packages/taler-wallet-webextension/src/i18n/strings.ts
@@ -1,443 +1,3191 @@
-/*
- This file is part of TALER
- (C) 2016 Inria
+export const strings: any = {};
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-export const strings: { [s: string]: any } = {};
strings["de"] = {
domain: "messages",
locale_data: {
messages: {
"": {
domain: "messages",
- plural_forms: "nplurals=2; plural=(n != 1);",
- lang: "",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "de",
},
- "Invalid Wire": [""],
- "Invalid Test Wire Detail": [""],
- "Test Wire Acct #%1$s on %2$s": [""],
- "Unknown Wire Detail": [""],
- Operation: [""],
- "time (ms/op)": [""],
- "The merchant %1$s offers you to purchase:": [
- "Der Händler %1$s möchte einen Vertrag über %2$s mit Ihnen abschließen.",
- ],
- "The total price is %1$s (plus %2$s fees).": [""],
- "The total price is %1$s.": [""],
- Retry: [""],
- "Confirm payment": ["Bezahlung bestätigen"],
- Balance: ["Saldo"],
- History: ["Verlauf"],
- Debug: ["Debug"],
- "You have no balance to show. Need some %1$s getting started?": [
- "Sie haben kein Digitalgeld. Wollen Sie %1$s? abheben?",
- ],
- "%1$s incoming": [""],
- "%1$s being spent": [""],
- "Error: could not retrieve balance information.": [""],
- "Invalid ": [""],
- "Fees ": [""],
- "Refresh sessions has completed": [""],
- "Order Refused": [""],
- "Order redirected": [""],
- "Payment aborted": [""],
- "Payment Sent": [""],
- "Order accepted": [""],
- "Reserve balance updated": [""],
- "Payment refund": [""],
- Withdrawn: ["Abheben bei %1$s"],
- "Tip Accepted": [""],
- "Tip Declined": [""],
+ Balance: ["Guthaben"],
+ Backup: ["Backup"],
+ "QR Reader and Taler URI": [""],
+ Settings: ["Einstellungen"],
+ Dev: ["Dev"],
"%1$s": [""],
- "Your wallet has no events recorded.": [
- "Ihre Geldbörse verzeichnet keine Vorkommnisse.",
- ],
- "Wire to bank account": [""],
- Confirm: ["Bezahlung bestätigen"],
- Cancel: ["Saldo"],
- "Could not get details for withdraw operation:": [""],
- "Chose different exchange provider": [""],
- "Please select an exchange. You can review the details before after your selection.": [
+ "PENDING OPERATIONS": [""],
+ Loading: ["Lädt Daten"],
+ "Could not load backup providers": [""],
+ "No backup providers configured": [""],
+ "Add provider": [""],
+ "Sync all backups": [""],
+ "Sync now": [""],
+ "Last synced": [""],
+ "Not synced": [""],
+ "Expires in": [""],
+ "There was an error loading the provider detail for &quot; %1$s&quot;": [
"",
],
- "Select %1$s": [""],
- "Select custom exchange": [""],
- "You are about to withdraw %1$s from your bank account into your wallet.": [
+ "There is not known provider with url &quot;%1$s&quot;.": [""],
+ "See providers": [""],
+ "Last backup": [""],
+ "Back up": [""],
+ "Provider fee": [""],
+ "per year": [""],
+ Extend: [""],
+ "terms has changed, extending the service will imply accepting the new terms of service":
+ [""],
+ old: [""],
+ new: [""],
+ fee: [""],
+ storage: [""],
+ "Remove provider": [""],
+ "This provider has reported an error": [""],
+ "There is conflict with another backup from %1$s": [""],
+ "Backup is not readable": [""],
+ "Unknown backup problem: %1$s": [""],
+ "service paid": [""],
+ "Backup valid until": [""],
+ Cancel: ["Abbrechen"],
+ "Open reserve page": ["Seite der Reserve aufrufen"],
+ "Open pay page": ["Seite für Zahlungen aufrufen"],
+ "Open refund page": ["Seite für Rückerstattungen aufrufen"],
+ "Open tip page": ["Seite der Aufwandsentschädigungen aufrufen"],
+ "Open withdraw page": ["Abhebeseite öffnen"],
+ "Get digital cash": ["Digitales Bargeld abheben"],
+ "Could not load balance page": ["Konnte die Umsatzanzeige nicht laden"],
+ Add: [""],
+ "Send %1$s": [""],
+ "Taler Action": [""],
+ "This page has pay action.": [""],
+ "This page has a withdrawal action.": [""],
+ "This page has a tip action.": [""],
+ "This page has a notify reserve action.": [""],
+ Notify: [""],
+ "This page has a refund action.": [""],
+ "This page has a malformed taler uri.": [""],
+ Dismiss: [""],
+ "this popup is being closed and you are being redirected to %1$s": [""],
+ "Could not load purchase proposal details": [
+ "Konnte die Umsatzanzeige nicht laden",
+ ],
+ "Order Id": [""],
+ Summary: [""],
+ Amount: ["Betrag"],
+ "Merchant name": [""],
+ "Merchant jurisdiction": [""],
+ "Merchant address": [""],
+ "Merchant logo": [""],
+ "Merchant website": [""],
+ "Merchant email": [""],
+ "Merchant public key": [""],
+ "Delivery date": [""],
+ "Delivery location": [""],
+ Products: [""],
+ "Created at": [""],
+ "Refund deadline": [""],
+ "Auto refund": [""],
+ "Pay deadline": [""],
+ "Fulfillment URL": [""],
+ "Fulfillment message": [""],
+ "Max deposit fee": [""],
+ "Max fee": [""],
+ "Minimum age": [""],
+ "Wire fee amortization": [""],
+ Auditors: [""],
+ Exchanges: ["Exchange"],
+ "Bank account": [""],
+ "Bitcoin address": [""],
+ IBAN: [""],
+ "Could not load deposit status": ["Konnte die Umsatzanzeige nicht laden"],
+ "Digital cash deposit": [""],
+ Cost: [""],
+ Fee: [""],
+ "To be received": [""],
+ "Send &nbsp; %1$s": [""],
+ "Bitcoin transfer details": [""],
+ "The exchange need a transaction with 3 output, one output is the exchange account and the other two are segwit fake address for metadata with an minimum amount.":
+ [""],
+ "In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional recipient and copy addresses and amounts":
+ [""],
+ "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC":
+ [""],
+ Account: [""],
+ "Bank host": [""],
+ "Bank transfer details": [""],
+ Subject: ["Verwendungszweck"],
+ "Receiver name": [""],
+ "Could not load the transaction information": [""],
+ "There was an error trying to complete the transaction": [""],
+ "This transaction is not completed": [""],
+ Send: [""],
+ Retry: ["Erneut versuchen"],
+ Forget: [""],
+ "Caution!": [""],
+ "If you have already wired money to the exchange you will loose the chance to get the coins form it.":
+ [""],
+ Confirm: ["Bestätigen"],
+ Withdrawal: ["Abheben"],
+ "Make sure to use the correct subject, otherwise the money will not arrive in this wallet.":
+ [""],
+ "The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check there is no pending step.":
+ [""],
+ "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins":
+ [""],
+ Details: [""],
+ Payment: ["Zahlung"],
+ Refunds: [""],
+ "%1$s %2$s on %3$s": [
+ "%1$s\n möchte einen Vertrag über %2$s\n mit Ihnen abschließen.",
+ ],
+ "Merchant created a refund for this order but was not automatically picked up.":
+ [""],
+ Offer: [""],
+ Accept: [""],
+ Merchant: [""],
+ "Invoice ID": [""],
+ Deposit: [""],
+ Refresh: [""],
+ Tip: [""],
+ Refund: [""],
+ "Original order ID": [""],
+ "Purchase summary": [""],
+ copy: [""],
+ "hide qr": [""],
+ "show qr": [""],
+ Credit: [""],
+ Invoice: [""],
+ Exchange: ["Exchange"],
+ URI: [""],
+ Debit: [""],
+ Transfer: [""],
+ Country: ["Betrag"],
+ "Address lines": [""],
+ "Building number": [""],
+ "Building name": [""],
+ Street: [""],
+ "Post code": [""],
+ "Town location": [""],
+ Town: [""],
+ District: [""],
+ "Country subdivision": [""],
+ Date: [""],
+ "Transaction fees": [""],
+ Total: [""],
+ Withdraw: ["Abheben"],
+ Price: [""],
+ Refunded: [""],
+ Delivery: [""],
+ "Total transfer": ["Insgesamt abgehoben"],
+ "Could not load pay status": [""],
+ "Digital cash payment": [""],
+ Purchase: [""],
+ Receipt: [""],
+ "Valid until": [""],
+ "List of products": [""],
+ free: [""],
+ "Already paid, you are going to be redirected to %1$s": [""],
+ "Already paid": [""],
+ "Already claimed": [""],
+ "Pay with a mobile phone": [""],
+ "Hide QR": [""],
+ "Scan the QR code or &nbsp; %1$s": [""],
+ "Pay &nbsp; %1$s": [""],
+ "You have no balance for this currency. Withdraw digital cash first.": [
"",
],
- "Accept fees and withdraw": [""],
- "Cancel withdraw operation": [""],
- "Withdrawal fees:": ["Abheben bei"],
- "Rounding loss:": [""],
- "Earliest expiration (for deposit): %1$s": [""],
- "# Coins": [""],
- Value: [""],
- "Withdraw Fee": ["Abheben bei %1$s"],
- "Refresh Fee": [""],
- "Deposit Fee": [""],
+ "Could not find enough coins to pay. Even if you have enough %1$s some restriction may apply.":
+ [""],
+ "Your current balance is not enough.": [
+ "Es gibt kein Guthaben anzuzeigen.",
+ ],
+ "Merchant message": [""],
+ "Could not load refund status": ["Konnte die Umsatzanzeige nicht laden"],
+ "Digital cash refund": [""],
+ "You&apos;ve ignored the tip.": [""],
+ "The refund is in progress.": [""],
+ "Total to refund": ["Insgesamt abgehoben"],
+ "The merchant &quot;%1$s&quot; is offering you a refund.": [
+ "Der Händler %1$s bietet Ihnen eine Aufwandsentschädigung von %2$s durch den Exchange %3$s",
+ ],
+ "Order amount": [""],
+ "Already refunded": [""],
+ "Refund offered": [""],
+ "Accept &nbsp; %1$s": [""],
+ "Could not load tip status": ["Konnte die Umsatzanzeige nicht laden"],
+ "Digital cash tip": [""],
+ "The merchant is offering you a tip": [
+ "Der Händler %1$s bietet Ihnen eine Aufwandsentschädigung von %2$s durch den Exchange %3$s",
+ ],
+ "Merchant URL": [""],
+ "Receive &nbsp; %1$s": [""],
+ "Tip from %1$s accepted. Check your transactions list for more details.":
+ [""],
+ "Select one option": [""],
+ "Could not load": ["Konnte die Umsatzanzeige nicht laden"],
+ "Show terms of service": [""],
+ "I accept the exchange terms of service": [""],
+ "Exchange doesn&apos;t have terms of service": [""],
+ "Review exchange terms of service": [""],
+ "Review new version of terms of service": [""],
+ "The exchange reply with a empty terms of service": [""],
+ "Download Terms of Service": [""],
+ "Hide terms of service": [""],
+ "Could not load exchange fees": ["Konnte die Umsatzanzeige nicht laden"],
+ Close: [""],
+ "could not find any exchange": ["Konnte die Umsatzanzeige nicht laden"],
+ "could not find any exchange for the currency %1$s": [""],
+ "Service fee description": [""],
+ "Select %1$s exchange": [""],
+ Reset: [""],
+ "Use this exchange": [""],
+ "Doesn&apos;t have auditors": [""],
+ currency: [""],
+ Operations: [""],
+ Deposits: ["%1$s zahlen"],
+ Denomination: [""],
+ Until: [""],
+ Withdrawals: ["Abheben"],
+ Currency: [""],
+ "Coin operations": [""],
+ "Every operation in this section may be different by denomination value and is valid for a period of time. The exchange will charge the indicated amount every time a coin is used in such operation.":
+ [""],
+ "Transfer operations": [""],
+ "Every operation in this section may be different by transfer type and is valid for a period of time. The exchange will charge the indicated amount every time a transfer is made.":
+ [""],
+ Operation: [""],
+ "Wallet operations": [""],
+ Feature: [""],
+ "Could not get the info from the URI": [""],
+ "Could not get info of withdrawal": [""],
+ "Digital cash withdrawal": [""],
+ "Could not finish the withdrawal operation": [""],
+ "Age restriction": [""],
+ "Withdraw &nbsp; %1$s": ["Abheben bei %1$s"],
+ "Withdraw to a mobile phone": [""],
+ "Digital invoice": [""],
+ "Could not finish the invoice creation": [""],
+ Create: [""],
+ "Could not finish the payment operation": [""],
+ "Digital cash transfer": [""],
+ "Could not finish the transfer creation": [""],
+ "Could not finish the pickup operation": [""],
+ "Manual Withdrawal for %1$s": ["Manuelles Abheben"],
+ "Choose a exchange from where the coins will be withdrawn. The exchange will send the coins to this wallet after receiving a wire transfer with the correct subject.":
+ [""],
+ "No exchange found for %1$s": [""],
+ "Add Exchange": [""],
+ "No exchange configured": [""],
+ "Can&apos;t create the reserve": [""],
+ "Start withdrawal": ["Abhebung beginnen"],
+ "Could not load deposit balance": [
+ "Konnte die Umsatzanzeige nicht laden",
+ ],
+ "A currency or an amount should be indicated": [""],
+ "There is no enough balance to make a deposit for currency %1$s": [""],
+ "Send %1$s to your account": [""],
+ "There is no account to make a deposit for currency %1$s": [""],
+ "Add account": [""],
+ "Select account": [""],
+ "Add another account": [""],
+ "Deposit fee": [""],
+ "Total deposit": [""],
+ "Deposit&nbsp;%1$s %2$s": ["Einlösen %1$s %2$s"],
+ "Add bank account for %1$s": [""],
+ "Enter the URL of an exchange you trust.": [""],
+ "Unable add this account": [""],
+ "Select account type": [""],
+ "Review terms of service": [""],
+ "Exchange URL": [""],
+ "Add exchange": [""],
+ "Add new exchange": [""],
+ "Add exchange for %1$s": [""],
+ "An exchange has been found! Review the information and click next": [""],
+ "This exchange doesn&apos;t match the expected currency %1$s": [""],
+ "Unable to verify this exchange": [""],
+ "Unable to add this exchange": [""],
+ loading: [""],
+ Version: [""],
+ Next: [""],
+ "Waiting for confirmation": [""],
+ PENDING: [""],
+ "Could not load the list of transactions": [""],
+ "Your transaction history is empty for this currency.": [""],
+ "Add backup provider": [""],
+ "Could not get provider information": [""],
+ "Backup providers may charge for their service": [""],
+ URL: [""],
+ Name: [""],
+ "Provider URL": [""],
+ "Please review and accept this provider&apos;s terms of service": [""],
+ Pricing: [""],
+ "free of charge": [""],
+ "%1$s per year of service": [""],
+ Storage: [""],
+ "%1$s megabytes of storage per year of service": [""],
+ "Accept terms of service": [""],
+ "Could not parse the payto URI": [""],
+ "Please check the uri": [""],
+ "Exchange is ready for withdrawal": [""],
+ "To complete the process you need to wire%1$s %2$s to the exchange bank account":
+ [""],
+ "Alternative, you can also scan this QR code or open %1$s if you have a banking app installed that supports RFC 8905":
+ [""],
+ "Cancel withdrawal": [""],
+ "Could not toggle auto-open": ["Konnte die Umsatzanzeige nicht laden"],
+ "Could not toggle clipboard": ["Konnte die Umsatzanzeige nicht laden"],
+ Navigator: [""],
+ "Automatically open wallet based on page content": [""],
+ "Enabling this option below will make using the wallet faster, but requires more permissions from your browser.":
+ [""],
+ "Automatically check clipboard for Taler URI": [""],
+ Trust: [""],
+ "No exchange yet": [""],
+ "Term of Service": [""],
+ ok: [""],
+ changed: [""],
+ "not accepted": [""],
+ "unknown (exchange status should be updated)": [""],
+ "Add an exchange": [""],
+ Troubleshooting: [""],
+ "Developer mode": [""],
+ "More options and information useful for debugging": [""],
+ Display: [""],
+ "Current Language": [""],
+ "Wallet Core": [""],
+ "Web Extension": [""],
+ "Exchange compatibility": [""],
+ "Merchant compatibility": [""],
+ "Bank compatibility": [""],
+ "Browser Extension Installed!": [""],
+ "You can open the GNU Taler Wallet using the combination %1$s .": [""],
+ "Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick access without keyboard:":
+ [""],
+ "Click the puzzle icon": [""],
+ "Search for GNU Taler Wallet": [""],
+ "Click the pin icon": [""],
+ Permissions: [""],
+ "(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)":
+ [""],
+ "Next Steps": [""],
+ "Try the demo": [""],
+ "Learn how to top up your wallet balance": [""],
+ "Diagnostics timed out. Could not talk to the wallet backend.": [
+ "Die Diagnostik ist abgeschlossen. Es war keine Kommunikation mit dem Wallet-Backend möglich.",
+ ],
+ "Problems detected:": ["Ein Problem wurde festgestellt:"],
+ "Please check in your %1$s settings that you have IndexedDB enabled (check the preference name %2$s).":
+ [
+ "Bitte prüfen Sie ihre %1$s Einstellungen, für die Sie IndexedDB verwenden (preference name %2$s prüfen).",
+ ],
+ "Your wallet database is outdated. Currently automatic migration is not supported. Please go %1$s to reset the wallet database.":
+ [
+ "Die Datenbank des Wallets ist veraltet. Aktuell wird jedoch keine Migration auf eine neue Version unterstützt. Bitte wählen Sie %1$s zum Zurücksetzen der Wallet-Datenbank.",
+ ],
+ "Running diagnostics": ["Diagnostik wird durchgeführt"],
+ "Debug tools": ["Debugging-Tools"],
+ "Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL YOUR COINS?":
+ [""],
+ reset: ["zurücksetzen"],
+ "TESTING: This may delete all your coin, proceed with caution": [""],
+ "run gc": [""],
+ "import database": [""],
+ "export database": [""],
+ "Database exported at %1$s %2$s to download": [""],
+ Coins: [""],
+ "Pending operations": [""],
+ "usable coins": [""],
+ id: [""],
+ denom: [""],
+ value: [""],
+ status: [""],
+ "from refresh?": [""],
+ "age key count": [""],
+ "spent coins": [""],
+ "click to show": [""],
+ "Scan a QR code or enter taler:// URI below": [""],
+ Open: [""],
+ "URI is not valid. Taler URI should start with `taler://`": [""],
+ "Try another": [""],
+ "Could not load list of exchange": [
+ "Konnte die Umsatzanzeige nicht laden",
+ ],
+ "Choose a currency to proceed or add another exchange": [""],
+ "Known currencies": [""],
+ "Specify the amount and the origin": [""],
+ "Change currency": [""],
+ "Use previous origins:": [""],
+ "Or specify the origin of the money": [""],
+ "Specify the origin of the money": [""],
+ "From my bank account": [""],
+ "From another wallet": [""],
+ "currency not provided": [""],
+ "Specify the amount and the destination": [""],
+ "Use previous destinations:": [""],
+ "Or specify the destination of the money": [""],
+ "Specify the destination of the money": [""],
+ "To my bank account": [""],
+ "To another wallet": [""],
+ "Could not load backup recovery information": [
+ "Konnte die Umsatzanzeige nicht laden",
+ ],
+ "Digital wallet recovery": [""],
+ "Import backup, show info": [""],
+ "All done, your transaction is in progress": [""],
+ Edit: [""],
+ "Could not load the list of known exchanges": [""],
},
},
};
-strings["en-US"] = {
+strings["es"] = {
domain: "messages",
locale_data: {
messages: {
"": {
domain: "messages",
- plural_forms: "nplurals=2; plural=(n != 1);",
- lang: "",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "es",
+ },
+ Balance: ["Balance"],
+ Backup: ["Copia de seguridad"],
+ "QR Reader and Taler URI": ["Lector QR y Taler URI"],
+ Settings: ["Configuración"],
+ Dev: ["Dev"],
+ "%1$s": ["%1$s"],
+ "PENDING OPERATIONS": ["OPERACIONES PENDIENTES"],
+ Loading: ["Cargando"],
+ "Could not load backup providers": [
+ "No se pudo cargar los proveedores de copias de seguridad",
+ ],
+ "No backup providers configured": [
+ "No hay proveedores de copias de seguridad configurados",
+ ],
+ "Add provider": ["Agregar proveedor"],
+ "Sync all backups": ["Sincronizar todas las copias de seguridad"],
+ "Sync now": ["Sincronizar ahora"],
+ "Last synced": ["Ultima vez sincronizado"],
+ "Not synced": ["No sincronizado"],
+ "Expires in": ["Expira en"],
+ "There was an error loading the provider detail for &quot; %1$s&quot;": [
+ 'Hubo un error cargando los detalles del proveedor para "%1$s"',
+ ],
+ "There is not known provider with url &quot;%1$s&quot;.": [
+ 'No hay proveedor conocido con la URL "%1$s".',
+ ],
+ "See providers": ["Ver proveedores"],
+ "Last backup": ["Última copia de seguridad"],
+ "Back up": ["Copia de seguridad"],
+ "Provider fee": ["Tarifa del proveedor"],
+ "per year": ["por año"],
+ Extend: ["Extender"],
+ "terms has changed, extending the service will imply accepting the new terms of service":
+ [
+ "los términos han cambiado, extender el servicio implicará aceptar los nuevos términos de servicio",
+ ],
+ old: ["viejo"],
+ new: ["nuevo"],
+ fee: ["tarifa"],
+ storage: ["almacenamiento"],
+ "Remove provider": ["Eliminar proveedor"],
+ "This provider has reported an error": [
+ "Este proveedor ha reportado un error",
+ ],
+ "There is conflict with another backup from %1$s": [
+ "Hay un conflicto con otra copia de seguridad de %1$s",
+ ],
+ "Backup is not readable": ["La copia de seguridad no es legible"],
+ "Unknown backup problem: %1$s": [
+ "Problema de copia de seguridad desconocido: %1$s",
+ ],
+ "service paid": ["servicio pagado"],
+ "Backup valid until": ["Copia de seguridad válida hasta"],
+ Cancel: ["Cancelar"],
+ "Open reserve page": ["Abrir página de reserva"],
+ "Open pay page": ["Abrir página de pago"],
+ "Open refund page": ["Abrir página de devolución"],
+ "Open tip page": ["Abrir página de propina"],
+ "Open withdraw page": ["Abrir página de retirada"],
+ "Get digital cash": ["Retirar dinero digital"],
+ "Could not load balance page": ["No se pudo cargar la página"],
+ Add: ["Agregar"],
+ "Send %1$s": ["Envíar %1$s"],
+ "Taler Action": ["Acción Taler"],
+ "This page has pay action.": ["Esta página tiene una acción de pago."],
+ "This page has a withdrawal action.": [
+ "Esta página tiene una acción de retirada.",
+ ],
+ "This page has a tip action.": [
+ "Esta página tiene una acción de propina.",
+ ],
+ "This page has a notify reserve action.": [
+ "Esta página tiene una acción de notificación de reserva.",
+ ],
+ Notify: ["Notificar"],
+ "This page has a refund action.": [
+ "Esta página tiene una acción de devolución.",
+ ],
+ "This page has a malformed taler uri.": [
+ "Esta página tiene una URI de Taler malformada.",
+ ],
+ Dismiss: ["Descartar"],
+ "this popup is being closed and you are being redirected to %1$s": [
+ "Este popup está siendo cerrado y estás siendo redirigido a %1$s",
+ ],
+ "Could not load purchase proposal details": [
+ "No se pudo cargar el detalle de la propuesta",
+ ],
+ "Order Id": ["Id de orden"],
+ Summary: ["Resumen"],
+ Amount: ["Cantidad"],
+ "Merchant name": ["Comerciante"],
+ "Merchant jurisdiction": ["Jurisdicción"],
+ "Merchant address": ["Dirección del comerciante"],
+ "Merchant logo": ["Logo"],
+ "Merchant website": ["Siti web"],
+ "Merchant email": ["Correo electrónico"],
+ "Merchant public key": ["Clave pública"],
+ "Delivery date": ["Fecha de entrega"],
+ "Delivery location": ["Ubicación de entrega"],
+ Products: ["Productos"],
+ "Created at": ["Creado en"],
+ "Refund deadline": ["Plazo de devolución"],
+ "Auto refund": ["Devolución automática"],
+ "Pay deadline": ["Plazo de pago"],
+ "Fulfillment URL": ["URL de éxito"],
+ "Fulfillment message": ["Mensaje de éxito"],
+ "Max deposit fee": ["Máxima comisión de depósito"],
+ "Max fee": ["Máxima comisión"],
+ "Minimum age": ["Edad mínima"],
+ "Wire fee amortization": ["Amortización de comisión de transferencia"],
+ Auditors: ["Auditores"],
+ Exchanges: ["Exchanges"],
+ "Bank account": ["Cuenta del banco"],
+ "Bitcoin address": ["Dirección de Bitcoin"],
+ IBAN: ["IBAN"],
+ "Could not load deposit status": [
+ "No se pudo cargar el estado del depósito",
+ ],
+ "Digital cash deposit": ["Depósito de dinero digital"],
+ Cost: ["Costo"],
+ Fee: ["Comisión"],
+ "To be received": ["A recibir"],
+ "Send &nbsp; %1$s": ["Envíar %1$s"],
+ "Bitcoin transfer details": ["Detalle de transferencia Bitcoin"],
+ "The exchange need a transaction with 3 output, one output is the exchange account and the other two are segwit fake address for metadata with an minimum amount.":
+ [
+ "El exchange necesita una transacción con 3 salidas, una salida es hacia la cuenta del exchange y las otras dos son direcciones segwit falsas para metadata con el monto mínimo.",
+ ],
+ "In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional recipient and copy addresses and amounts":
+ [
+ 'En la billetera bitcoincore usar el botón "Agregar destinatario" para agregar dos destinatarios y copiar las direcciones y montos',
+ ],
+ "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC":
+ [
+ "Asegurarse de que el monto muestre %1$s BTC, sino tendrá que cambiar la unidad a BTC",
+ ],
+ Account: ["Cuenta"],
+ "Bank host": ["Banco anfitrión"],
+ "Bank transfer details": ["Detalle de transferencia bancaria"],
+ Subject: ["Asunto"],
+ "Receiver name": ["Nombre del receptor"],
+ "Could not load the transaction information": [
+ "No se pudo cargar información de la transacción",
+ ],
+ "There was an error trying to complete the transaction": [
+ "Hubo un error intentando completar la transacción",
+ ],
+ "This transaction is not completed": [
+ "Esta transacción no está completada",
+ ],
+ Send: ["Enviar"],
+ Retry: ["Reintentar"],
+ Forget: ["Olvidar"],
+ "Caution!": ["Cuidado!"],
+ "If you have already wired money to the exchange you will loose the chance to get the coins form it.":
+ [
+ "Si tú ya has transferido dinero al exchange, perderás la oportunidad de recibir las monedas desde este.",
+ ],
+ Confirm: ["Confirmar"],
+ Withdrawal: ["Retirada"],
+ "Make sure to use the correct subject, otherwise the money will not arrive in this wallet.":
+ [
+ "Asegúrate de usar el asunto correcto, de lo contrario el dinero no llegará a esta billetera.",
+ ],
+ "The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check there is no pending step.":
+ [
+ "El banco todavía no confirmó la transferencia. Ir a %1$s %2$s y verificar que no hay pasos pendientes.",
+ ],
+ "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins":
+ [
+ "El banco confirmó la transferencia. Esperando que el exchange envíe las monedas",
+ ],
+ Details: ["Detalles"],
+ Payment: ["Pago"],
+ Refunds: ["Devoluciones"],
+ "%1$s %2$s on %3$s": ["%1$s %2$s en %3$s"],
+ "Merchant created a refund for this order but was not automatically picked up.":
+ [
+ "El comerciante creó una devolución para esta orden pero no fue recogida automáticamente.",
+ ],
+ Offer: ["Oferta"],
+ Accept: ["Aceptar"],
+ Merchant: ["Comerciante"],
+ "Invoice ID": ["Id de factura"],
+ Deposit: ["Depósito"],
+ Refresh: ["Actualizar"],
+ Tip: ["Propina"],
+ Refund: ["Devolución"],
+ "Original order ID": ["Id de orden original"],
+ "Purchase summary": ["Resumen de compra"],
+ copy: ["Copiar"],
+ "hide qr": ["Esconder QR"],
+ "show qr": ["Mostrar QR"],
+ Credit: ["Crédito"],
+ Invoice: ["Factura"],
+ Exchange: ["Exchange"],
+ URI: ["URI"],
+ Debit: ["Débito"],
+ Transfer: ["Transferencia"],
+ Country: ["País"],
+ "Address lines": ["Detalle de dirección"],
+ "Building number": ["Número de edificio"],
+ "Building name": ["Nombre de edificio"],
+ Street: ["Calle"],
+ "Post code": ["Código postal"],
+ "Town location": ["Ubicación de la ciudad"],
+ Town: ["Ciudad"],
+ District: ["Distrito"],
+ "Country subdivision": ["Subdivisión de país"],
+ Date: ["Fecha"],
+ "Transaction fees": ["Comisiones de transacción"],
+ Total: ["Total"],
+ Withdraw: ["Retirar"],
+ Price: ["Precio"],
+ Refunded: ["Devuelto"],
+ Delivery: ["Entrega"],
+ "Total transfer": ["Total transferido"],
+ "Could not load pay status": ["No se pudo cargar el estado del pago"],
+ "Digital cash payment": ["Pago con dinero digital"],
+ Purchase: ["Compra"],
+ Receipt: ["Recibo"],
+ "Valid until": ["Válido hasta"],
+ "List of products": ["Lista de productos"],
+ free: ["Gratis"],
+ "Already paid, you are going to be redirected to %1$s": [
+ "Ya pagado, estás siendo dirigido a %1$s",
+ ],
+ "Already paid": ["Ya pagado"],
+ "Already claimed": ["Ya reclamado"],
+ "Pay with a mobile phone": ["Pagar con un teléfono móbil"],
+ "Hide QR": ["Esconder QR"],
+ "Scan the QR code or &nbsp; %1$s": ["Escanear el código QR o %1$s"],
+ "Pay &nbsp; %1$s": ["Pagar %1$s"],
+ "You have no balance for this currency. Withdraw digital cash first.": [
+ "No hay balance para esta divisa. Extraer dinero digital primero.",
+ ],
+ "Could not find enough coins to pay. Even if you have enough %1$s some restriction may apply.":
+ [
+ "No se encontraron suficientes monedas para pagar. Incluso si tuviera suficiente %1$s algunas restricciones se podrían aplicar.",
+ ],
+ "Your current balance is not enough.": ["Tu balance no es suficiente."],
+ "Merchant message": ["Mensaje del comerciante"],
+ "Could not load refund status": [
+ "No se pudo cargar el estado de la devolución",
+ ],
+ "Digital cash refund": ["Devolución de dinero digital"],
+ "You&apos;ve ignored the tip.": ["Has ignorado la propina."],
+ "The refund is in progress.": [
+ "El proceso de devolución está en progreso.",
+ ],
+ "Total to refund": ["Total para devolver"],
+ "The merchant &quot;%1$s&quot; is offering you a refund.": [
+ 'El comerciante "%1$s" te está ofreciendo una devolución.',
+ ],
+ "Order amount": ["Monto de la orden"],
+ "Already refunded": ["Ya devuelto"],
+ "Refund offered": ["Devolución ofrecida"],
+ "Accept &nbsp; %1$s": ["Aceptar %1$s"],
+ "Could not load tip status": [
+ "No se pudo cargar el estado de la propina",
+ ],
+ "Digital cash tip": ["Propina con dinero digital"],
+ "The merchant is offering you a tip": [
+ "El comerciante te ofrece una propina",
+ ],
+ "Merchant URL": ["URL del comerciante"],
+ "Receive &nbsp; %1$s": ["Recibir %1$s"],
+ "Tip from %1$s accepted. Check your transactions list for more details.":
+ [
+ "Propina de %1$s aceptada. Revisa tu lista de transacciones para más detalle.",
+ ],
+ "Select one option": ["Seleccione una opción"],
+ "Could not load": ["No se pudo cargar"],
+ "Show terms of service": ["Mostrar términos de servicio"],
+ "I accept the exchange terms of service": [
+ "Yo acepto los términos de servicio del exchange",
+ ],
+ "Exchange doesn&apos;t have terms of service": [
+ "El exchange no tiene los términos de servicio",
+ ],
+ "Review exchange terms of service": ["Revisar los términos de servicio"],
+ "Review new version of terms of service": [
+ "Revisar los nuevos términos de servicio",
+ ],
+ "The exchange reply with a empty terms of service": [
+ "El exchange respondió con unos términos de servicio vacíos",
+ ],
+ "Download Terms of Service": ["Descargar los términos de servicio"],
+ "Hide terms of service": ["Esconder los términos de servicio"],
+ "Could not load exchange fees": [
+ "No se pudo cargar la comisión del exchange",
+ ],
+ Close: ["Cerrar"],
+ "could not find any exchange": ["No se pudo encontrar ningún exchange"],
+ "could not find any exchange for the currency %1$s": [
+ "No se pudo encontrar ningún exchange para la divisa %1$s",
+ ],
+ "Service fee description": ["Descripción de comisión de servicio"],
+ "Select %1$s exchange": ["Seleccionar exchange %1$s"],
+ Reset: ["Reiniciar"],
+ "Use this exchange": ["Usar este exchange"],
+ "Doesn&apos;t have auditors": ["No tiene auditores"],
+ currency: ["Divisa"],
+ Operations: ["Operaciones"],
+ Deposits: ["Depósitos"],
+ Denomination: ["Operaciones"],
+ Until: ["Hasta"],
+ Withdrawals: ["Retiradas"],
+ Currency: ["Divisa"],
+ "Coin operations": ["Operaciones de moneda"],
+ "Every operation in this section may be different by denomination value and is valid for a period of time. The exchange will charge the indicated amount every time a coin is used in such operation.":
+ [
+ "Toda operación en esta sección puede ser diferente por valor de denominación y es válida por un período. El exchange cobrará el monto indicado cada vez que una es usada en dicha operación.",
+ ],
+ "Transfer operations": ["Operaciones de transferencia"],
+ "Every operation in this section may be different by transfer type and is valid for a period of time. The exchange will charge the indicated amount every time a transfer is made.":
+ [
+ "Toda operación en esta sección puede ser diferente por tipo de transacción y es válida por un período. El exchange cobrará el monto indicado cada vez que se haga una transferencia.",
+ ],
+ Operation: ["Operación"],
+ "Wallet operations": ["Operaciones de billetera"],
+ Feature: ["Característica"],
+ "Could not get the info from the URI": [
+ "No se pudo obtener la información desde la URI",
+ ],
+ "Could not get info of withdrawal": [
+ "No se pudo obtener la información de retiro",
+ ],
+ "Digital cash withdrawal": ["Retirada de dinero digital"],
+ "Could not finish the withdrawal operation": [
+ "No se pudo completar la operación de retirada",
+ ],
+ "Age restriction": ["Restricción etaria"],
+ "Withdraw &nbsp; %1$s": ["Retirar %1$s"],
+ "Withdraw to a mobile phone": ["Retirar con un teléfono móvil"],
+ "Digital invoice": ["Factura digital"],
+ "Could not finish the invoice creation": [
+ "No se pudo completar la creación de la factura",
+ ],
+ Create: ["Crear"],
+ "Could not finish the payment operation": [
+ "No se pudo completar la operación de pago",
+ ],
+ "Digital cash transfer": ["Transferencia de dinero digital"],
+ "Could not finish the transfer creation": [
+ "No se pudo completar la operación de creación de transferencia",
+ ],
+ "Could not finish the pickup operation": [
+ "No se pudo completar la operación de recolección",
+ ],
+ "Manual Withdrawal for %1$s": ["Retirada Manual para %1$s"],
+ "Choose a exchange from where the coins will be withdrawn. The exchange will send the coins to this wallet after receiving a wire transfer with the correct subject.":
+ [
+ "Elija un exchange desde donde las monedas serán retiradas. El exchange enviará las monedas a esta billetera después de recibir una transferencia bancaria con el asunto correcto.",
+ ],
+ "No exchange found for %1$s": ["No se encontró exchange para %1$s"],
+ "Add Exchange": ["Agregar Exchange"],
+ "No exchange configured": ["Sin exchange configurado"],
+ "Can&apos;t create the reserve": ["No se pudo crear una reserva"],
+ "Start withdrawal": ["Comenzar la retirada"],
+ "Could not load deposit balance": [
+ "No se pudo cargar el balance de depósito",
+ ],
+ "A currency or an amount should be indicated": [
+ "Se debería especificar una divisa o un monto",
+ ],
+ "There is no enough balance to make a deposit for currency %1$s": [
+ "No hay suficiente balance para hacer un depósito para la divisa %1$s",
+ ],
+ "Send %1$s to your account": ["Enviar %1$s a tu cuenta"],
+ "There is no account to make a deposit for currency %1$s": [
+ "No hay una cuenta para hacer un depósito para la divisa %1$s",
+ ],
+ "Add account": ["Agregar cuenta"],
+ "Select account": ["Seleccionar cuenta"],
+ "Add another account": ["Agregar otra cuenta"],
+ "Deposit fee": ["Comisión de depósito"],
+ "Total deposit": ["Depósito total"],
+ "Deposit&nbsp;%1$s %2$s": ["Depositar %1$s %2$s"],
+ "Add bank account for %1$s": ["Agregar cuenta de banco para %1$s"],
+ "Enter the URL of an exchange you trust.": [
+ "Ingresar la URL de un exchange en el que confíes.",
+ ],
+ "Unable add this account": ["No fue posible agregar esta cuenta"],
+ "Select account type": ["Seleccione un tipo de cuenta"],
+ "Review terms of service": ["Revisar los términos de servicio"],
+ "Exchange URL": ["Exchange URL"],
+ "Add exchange": ["Agregar exchange"],
+ "Add new exchange": ["Agregar nuevo exchange"],
+ "Add exchange for %1$s": ["Agregar exchange para %1$s"],
+ "An exchange has been found! Review the information and click next": [
+ "Un exchange ha sido encontrado! Revisa la información y haz clic en siguiente",
+ ],
+ "This exchange doesn&apos;t match the expected currency %1$s": [
+ "Este exchange no coincide con la divisa %1$s esperada",
+ ],
+ "Unable to verify this exchange": [
+ "No fue posible verificar este exchange",
+ ],
+ "Unable to add this exchange": ["No fue posible agregar este exchange"],
+ loading: ["cargando"],
+ Version: ["Versión"],
+ Next: ["Siguiente"],
+ "Waiting for confirmation": ["Esperando confirmación"],
+ PENDING: ["PENDIENTE"],
+ "Could not load the list of transactions": [
+ "No se pudo cargar la lista de transacciones",
+ ],
+ "Your transaction history is empty for this currency.": [
+ "No hay historial para esta divisa.",
+ ],
+ "Add backup provider": ["Agregar proveedor de copias de seguridad"],
+ "Could not get provider information": [
+ "No se pudo conseguir la información del proveedor",
+ ],
+ "Backup providers may charge for their service": [
+ "Los proveedores de copias de seguridad pueden cobrarte por su servicio",
+ ],
+ URL: ["URL"],
+ Name: ["Nombre"],
+ "Provider URL": ["URL del proveedor"],
+ "Please review and accept this provider&apos;s terms of service": [
+ "Por favor revisa y acepta los términos de servicio del proveedor",
+ ],
+ Pricing: ["Precios"],
+ "free of charge": ["Gratis"],
+ "%1$s per year of service": ["%1$s por año de servicio"],
+ Storage: ["Alamcenamiento"],
+ "%1$s megabytes of storage per year of service": [
+ "%1$s megabytes de almacenamiento por año de servicio",
+ ],
+ "Accept terms of service": ["Aceptar los términos de servicio"],
+ "Could not parse the payto URI": [
+ "No se pudo obtener la información de la URI payto",
+ ],
+ "Please check the uri": ["Revisar la URI"],
+ "Exchange is ready for withdrawal": [
+ "El exchange está listo para la retirada",
+ ],
+ "To complete the process you need to wire%1$s %2$s to the exchange bank account":
+ [
+ "Para completar el proceso necesitas transferir %1$s %2$s a la cuenta bancaria del exchange",
+ ],
+ "Alternative, you can also scan this QR code or open %1$s if you have a banking app installed that supports RFC 8905":
+ [
+ "Alternativamente, también puedes escanear el código QR o abrir %1$s si tienes una App bancaria instalada que soporta RFC 8905",
+ ],
+ "Cancel withdrawal": ["Cancelar retirada"],
+ "Could not toggle auto-open": ["No se pudo cambiar el auto-open"],
+ "Could not toggle clipboard": ["No se pudo cambiar portapapeles"],
+ Navigator: ["Navegador"],
+ "Automatically open wallet based on page content": [
+ "Abrir automáticamente la billetera basada en el contenido de la página",
+ ],
+ "Enabling this option below will make using the wallet faster, but requires more permissions from your browser.":
+ [
+ "Habilitar la opción de debajo, hará que el uso de la billetera sea mas rápido, pero requiere más permisos de tu navegador.",
+ ],
+ "Automatically check clipboard for Taler URI": [
+ "Revisar el portapapeles automáticamente por Taler URI",
+ ],
+ Trust: ["Confianza"],
+ "No exchange yet": ["No hay exchanges todavía"],
+ "Term of Service": ["Términos de servicio"],
+ ok: ["ok"],
+ changed: ["modificado"],
+ "not accepted": ["no aceptado"],
+ "unknown (exchange status should be updated)": [
+ "desconocido (el estado del exchange debería actualizarse)",
+ ],
+ "Add an exchange": ["Agregar un exchange"],
+ Troubleshooting: ["Solución de problemas"],
+ "Developer mode": ["Modo desarrollador"],
+ "More options and information useful for debugging": [
+ "Más información y opciones útiles para depuración",
+ ],
+ Display: ["Pantalla"],
+ "Current Language": ["Lenguaje actual"],
+ "Wallet Core": ["Wallet core"],
+ "Web Extension": ["Web Extension"],
+ "Exchange compatibility": ["Compatibilidad con Exchange"],
+ "Merchant compatibility": ["Compatibilidad con Merchant"],
+ "Bank compatibility": ["Compatibilidad con Bank"],
+ "Browser Extension Installed!": ["Extensión del navegador instalada!"],
+ "You can open the GNU Taler Wallet using the combination %1$s .": [
+ "Puedes abrir GNU Taler Wallet usando la combinación %1$s.",
+ ],
+ "Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick access without keyboard:":
+ [
+ "También fijando GNU Taler Wallet a to navegador Chrome permite un acceso rápido sin el teclado:",
+ ],
+ "Click the puzzle icon": ["Haz click en el ícono de rompecabezas"],
+ "Search for GNU Taler Wallet": ['Busca "GNU Taler Wallet"'],
+ "Click the pin icon": ["Haz click en el ícono de fijar"],
+ Permissions: ["Permisos"],
+ "(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)":
+ [
+ "(Habilitar esta opción de abajo hará el uso de la billetera mas rápido, pero requiere mas permisos de tu navegador)",
+ ],
+ "Next Steps": ["Próximos pasos"],
+ "Try the demo": ["Probar la demostración"],
+ "Learn how to top up your wallet balance": [
+ "Aprender como llenar tu billetera",
+ ],
+ "Diagnostics timed out. Could not talk to the wallet backend.": [
+ "El diagnóstico caducó. No nos pudimos comunicar con la billetera.",
+ ],
+ "Problems detected:": ["Problemas detectados:"],
+ "Please check in your %1$s settings that you have IndexedDB enabled (check the preference name %2$s).":
+ [
+ "Por favor revisa en tu configuración %1$s que tienes IndexedDB habilitado (el nombre de la preferencia %2$s).",
+ ],
+ "Your wallet database is outdated. Currently automatic migration is not supported. Please go %1$s to reset the wallet database.":
+ [
+ "La base de datos de la billetera expiró. Por ahora la migración automática no está soportada. Por favor dirijasé a %1$s para reiniciar la base de datos de la billetera.",
+ ],
+ "Running diagnostics": ["Ejecutando diagnósticos"],
+ "Debug tools": ["Herramientas de desarrollo"],
+ "Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL YOUR COINS?":
+ [
+ "Quieres DESTRUIR IRREVOCABLEMENTE todo dentro de tu billetera y PERDER TODAS TUS MONEDAS?",
+ ],
+ reset: ["Reiniciar"],
+ "TESTING: This may delete all your coin, proceed with caution": [
+ "TESTING: Esto puede borrar todas tus monedas, proceder con precaución",
+ ],
+ "run gc": ["Ejecutar GC"],
+ "import database": ["importar base de datos"],
+ "export database": ["exportar base de datos"],
+ "Database exported at %1$s %2$s to download": [
+ "Base de datos exportada a %1$s %2$s para descargar",
+ ],
+ Coins: ["Monedas"],
+ "Pending operations": ["Operaciones pendientes"],
+ "usable coins": ["monedas usables"],
+ id: ["id"],
+ denom: ["denominación"],
+ value: ["valor"],
+ status: ["estado"],
+ "from refresh?": ["desde refresco?"],
+ "age key count": ["cantidad de age key"],
+ "spent coins": ["monedas gastadas"],
+ "click to show": ["hacer clic para mostrar"],
+ "Scan a QR code or enter taler:// URI below": [
+ "Escanear un código QR o ingresar taler:// URI debajo",
+ ],
+ Open: ["Abrir"],
+ "URI is not valid. Taler URI should start with `taler://`": [
+ "El URI no es válido. Taler URI debería comenzar con `taler://`",
+ ],
+ "Try another": ["Intentar otro"],
+ "Could not load list of exchange": [
+ "No se pudo cargar la lista de exchange",
+ ],
+ "Choose a currency to proceed or add another exchange": [
+ "Elija una divisa para proceder o agregue otro exchange",
+ ],
+ "Known currencies": ["Divisas conocidas"],
+ "Specify the amount and the origin": ["Indicar el monto y el origen"],
+ "Change currency": ["Cambiar divisa"],
+ "Use previous origins:": ["Usar un origen previo:"],
+ "Or specify the origin of the money": [
+ "O especificar el origen del dinero",
+ ],
+ "Specify the origin of the money": ["Especificar el origen del dinero"],
+ "From my bank account": ["Desde mi cuenta de banco"],
+ "From another wallet": ["Desde otra billetera"],
+ "currency not provided": ["Divisa no provista"],
+ "Specify the amount and the destination": [
+ "Especificar el monto y el destino",
+ ],
+ "Use previous destinations:": ["Usar destinos previos:"],
+ "Or specify the destination of the money": [
+ "O especificar el destino del dinero",
+ ],
+ "Specify the destination of the money": [
+ "Especificar el destino del dinero",
+ ],
+ "To my bank account": ["Hacia mi cuenta de banco"],
+ "To another wallet": ["Hacia otra billetera"],
+ "Could not load backup recovery information": [
+ "No se pudo cargar la información de recuperación de copia de seguridad",
+ ],
+ "Digital wallet recovery": ["Recuperación de billetera digital"],
+ "Import backup, show info": [
+ "Importar copia de seguridad, mostrar información",
+ ],
+ "All done, your transaction is in progress": [
+ "Todo completo, su transacción está en progreso",
+ ],
+ Edit: ["Editar"],
+ "Could not load the list of known exchanges": [
+ "No se pudo cargar la lista de exchange conocidos",
+ ],
+ },
+ },
+};
+
+strings["fr"] = {
+ domain: "messages",
+ locale_data: {
+ messages: {
+ "": {
+ domain: "messages",
+ plural_forms: "nplurals=2; plural=(n!=1);",
+ lang: "fr",
},
- "Invalid Wire": [""],
- "Invalid Test Wire Detail": [""],
- "Test Wire Acct #%1$s on %2$s": [""],
- "Unknown Wire Detail": [""],
- Operation: [""],
- "time (ms/op)": [""],
- "The merchant %1$s offers you to purchase:": [""],
- "The total price is %1$s (plus %2$s fees).": [""],
- "The total price is %1$s.": [""],
- Retry: [""],
- "Confirm payment": [""],
Balance: [""],
- History: [""],
- Debug: [""],
- "You have no balance to show. Need some %1$s getting started?": [""],
- "%1$s incoming": [""],
- "%1$s being spent": [""],
- "Error: could not retrieve balance information.": [""],
- "Invalid ": [""],
- "Fees ": [""],
- "Refresh sessions has completed": [""],
- "Order Refused": [""],
- "Order redirected": [""],
- "Payment aborted": [""],
- "Payment Sent": [""],
- "Order accepted": [""],
- "Reserve balance updated": [""],
- "Payment refund": [""],
- Withdrawn: [""],
- "Tip Accepted": [""],
- "Tip Declined": [""],
+ Backup: [""],
+ "QR Reader and Taler URI": [""],
+ Settings: [""],
+ Dev: [""],
"%1$s": [""],
- "Your wallet has no events recorded.": [""],
- "Wire to bank account": [""],
- Confirm: [""],
- Cancel: [""],
- "Could not get details for withdraw operation:": [""],
- "Chose different exchange provider": [""],
- "Please select an exchange. You can review the details before after your selection.": [
+ "PENDING OPERATIONS": [""],
+ Loading: [""],
+ "Could not load backup providers": [""],
+ "No backup providers configured": [""],
+ "Add provider": [""],
+ "Sync all backups": [""],
+ "Sync now": [""],
+ "Last synced": [""],
+ "Not synced": [""],
+ "Expires in": [""],
+ "There was an error loading the provider detail for &quot; %1$s&quot;": [
"",
],
- "Select %1$s": [""],
- "Select custom exchange": [""],
- "You are about to withdraw %1$s from your bank account into your wallet.": [
+ "There is not known provider with url &quot;%1$s&quot;.": [""],
+ "See providers": [""],
+ "Last backup": [""],
+ "Back up": [""],
+ "Provider fee": [""],
+ "per year": [""],
+ Extend: [""],
+ "terms has changed, extending the service will imply accepting the new terms of service":
+ [""],
+ old: [""],
+ new: [""],
+ fee: [""],
+ storage: [""],
+ "Remove provider": [""],
+ "This provider has reported an error": [""],
+ "There is conflict with another backup from %1$s": [""],
+ "Backup is not readable": [""],
+ "Unknown backup problem: %1$s": [""],
+ "service paid": [""],
+ "Backup valid until": [""],
+ Cancel: [""],
+ "Open reserve page": [""],
+ "Open pay page": [""],
+ "Open refund page": [""],
+ "Open tip page": [""],
+ "Open withdraw page": [""],
+ "Get digital cash": [""],
+ "Could not load balance page": [""],
+ Add: [""],
+ "Send %1$s": [""],
+ "Taler Action": [""],
+ "This page has pay action.": [""],
+ "This page has a withdrawal action.": [""],
+ "This page has a tip action.": [""],
+ "This page has a notify reserve action.": [""],
+ Notify: [""],
+ "This page has a refund action.": [""],
+ "This page has a malformed taler uri.": [""],
+ Dismiss: [""],
+ "this popup is being closed and you are being redirected to %1$s": [""],
+ "Could not load purchase proposal details": [""],
+ "Order Id": [""],
+ Summary: [""],
+ Amount: [""],
+ "Merchant name": [""],
+ "Merchant jurisdiction": [""],
+ "Merchant address": [""],
+ "Merchant logo": [""],
+ "Merchant website": [""],
+ "Merchant email": [""],
+ "Merchant public key": [""],
+ "Delivery date": [""],
+ "Delivery location": [""],
+ Products: [""],
+ "Created at": [""],
+ "Refund deadline": [""],
+ "Auto refund": [""],
+ "Pay deadline": [""],
+ "Fulfillment URL": [""],
+ "Fulfillment message": [""],
+ "Max deposit fee": [""],
+ "Max fee": [""],
+ "Minimum age": [""],
+ "Wire fee amortization": [""],
+ Auditors: [""],
+ Exchanges: [""],
+ "Bank account": [""],
+ "Bitcoin address": [""],
+ IBAN: [""],
+ "Could not load deposit status": [""],
+ "Digital cash deposit": [""],
+ Cost: [""],
+ Fee: [""],
+ "To be received": [""],
+ "Send &nbsp; %1$s": [""],
+ "Bitcoin transfer details": [""],
+ "The exchange need a transaction with 3 output, one output is the exchange account and the other two are segwit fake address for metadata with an minimum amount.":
+ [""],
+ "In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional recipient and copy addresses and amounts":
+ [""],
+ "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC":
+ [""],
+ Account: [""],
+ "Bank host": [""],
+ "Bank transfer details": [""],
+ Subject: [""],
+ "Receiver name": [""],
+ "Could not load the transaction information": [""],
+ "There was an error trying to complete the transaction": [""],
+ "This transaction is not completed": [""],
+ Send: [""],
+ Retry: [""],
+ Forget: [""],
+ "Caution!": [""],
+ "If you have already wired money to the exchange you will loose the chance to get the coins form it.":
+ [""],
+ Confirm: ["Confirmer"],
+ Withdrawal: [""],
+ "Make sure to use the correct subject, otherwise the money will not arrive in this wallet.":
+ [""],
+ "The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check there is no pending step.":
+ [""],
+ "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins":
+ [""],
+ Details: [""],
+ Payment: [""],
+ Refunds: [""],
+ "%1$s %2$s on %3$s": [""],
+ "Merchant created a refund for this order but was not automatically picked up.":
+ [""],
+ Offer: [""],
+ Accept: [""],
+ Merchant: [""],
+ "Invoice ID": [""],
+ Deposit: [""],
+ Refresh: [""],
+ Tip: [""],
+ Refund: [""],
+ "Original order ID": [""],
+ "Purchase summary": [""],
+ copy: [""],
+ "hide qr": [""],
+ "show qr": [""],
+ Credit: [""],
+ Invoice: [""],
+ Exchange: [""],
+ URI: [""],
+ Debit: [""],
+ Transfer: [""],
+ Country: [""],
+ "Address lines": [""],
+ "Building number": [""],
+ "Building name": [""],
+ Street: [""],
+ "Post code": [""],
+ "Town location": [""],
+ Town: [""],
+ District: [""],
+ "Country subdivision": [""],
+ Date: [""],
+ "Transaction fees": [""],
+ Total: [""],
+ Withdraw: [""],
+ Price: [""],
+ Refunded: [""],
+ Delivery: [""],
+ "Total transfer": [""],
+ "Could not load pay status": [""],
+ "Digital cash payment": [""],
+ Purchase: [""],
+ Receipt: [""],
+ "Valid until": [""],
+ "List of products": [""],
+ free: [""],
+ "Already paid, you are going to be redirected to %1$s": [""],
+ "Already paid": [""],
+ "Already claimed": [""],
+ "Pay with a mobile phone": [""],
+ "Hide QR": [""],
+ "Scan the QR code or &nbsp; %1$s": [""],
+ "Pay &nbsp; %1$s": [""],
+ "You have no balance for this currency. Withdraw digital cash first.": [
"",
],
- "Accept fees and withdraw": [""],
- "Cancel withdraw operation": [""],
- "Withdrawal fees:": [""],
- "Rounding loss:": [""],
- "Earliest expiration (for deposit): %1$s": [""],
- "# Coins": [""],
- Value: [""],
- "Withdraw Fee": [""],
- "Refresh Fee": [""],
- "Deposit Fee": [""],
+ "Could not find enough coins to pay. Even if you have enough %1$s some restriction may apply.":
+ [""],
+ "Your current balance is not enough.": [""],
+ "Merchant message": [""],
+ "Could not load refund status": [""],
+ "Digital cash refund": [""],
+ "You&apos;ve ignored the tip.": [""],
+ "The refund is in progress.": [""],
+ "Total to refund": [""],
+ "The merchant &quot;%1$s&quot; is offering you a refund.": [""],
+ "Order amount": [""],
+ "Already refunded": [""],
+ "Refund offered": [""],
+ "Accept &nbsp; %1$s": [""],
+ "Could not load tip status": [""],
+ "Digital cash tip": [""],
+ "The merchant is offering you a tip": [""],
+ "Merchant URL": [""],
+ "Receive &nbsp; %1$s": [""],
+ "Tip from %1$s accepted. Check your transactions list for more details.":
+ [""],
+ "Select one option": [""],
+ "Could not load": [""],
+ "Show terms of service": [""],
+ "I accept the exchange terms of service": [""],
+ "Exchange doesn&apos;t have terms of service": [""],
+ "Review exchange terms of service": [""],
+ "Review new version of terms of service": [""],
+ "The exchange reply with a empty terms of service": [""],
+ "Download Terms of Service": [""],
+ "Hide terms of service": [""],
+ "Could not load exchange fees": [""],
+ Close: [""],
+ "could not find any exchange": [""],
+ "could not find any exchange for the currency %1$s": [""],
+ "Service fee description": [""],
+ "Select %1$s exchange": [""],
+ Reset: [""],
+ "Use this exchange": [""],
+ "Doesn&apos;t have auditors": [""],
+ currency: [""],
+ Operations: [""],
+ Deposits: [""],
+ Denomination: [""],
+ Until: [""],
+ Withdrawals: [""],
+ Currency: [""],
+ "Coin operations": [""],
+ "Every operation in this section may be different by denomination value and is valid for a period of time. The exchange will charge the indicated amount every time a coin is used in such operation.":
+ [""],
+ "Transfer operations": [""],
+ "Every operation in this section may be different by transfer type and is valid for a period of time. The exchange will charge the indicated amount every time a transfer is made.":
+ [""],
+ Operation: [""],
+ "Wallet operations": [""],
+ Feature: [""],
+ "Could not get the info from the URI": [""],
+ "Could not get info of withdrawal": [""],
+ "Digital cash withdrawal": [""],
+ "Could not finish the withdrawal operation": [""],
+ "Age restriction": [""],
+ "Withdraw &nbsp; %1$s": [""],
+ "Withdraw to a mobile phone": [""],
+ "Digital invoice": [""],
+ "Could not finish the invoice creation": [""],
+ Create: [""],
+ "Could not finish the payment operation": [""],
+ "Digital cash transfer": [""],
+ "Could not finish the transfer creation": [""],
+ "Could not finish the pickup operation": [""],
+ "Manual Withdrawal for %1$s": [""],
+ "Choose a exchange from where the coins will be withdrawn. The exchange will send the coins to this wallet after receiving a wire transfer with the correct subject.":
+ [""],
+ "No exchange found for %1$s": [""],
+ "Add Exchange": [""],
+ "No exchange configured": [""],
+ "Can&apos;t create the reserve": [""],
+ "Start withdrawal": [""],
+ "Could not load deposit balance": [""],
+ "A currency or an amount should be indicated": [""],
+ "There is no enough balance to make a deposit for currency %1$s": [""],
+ "Send %1$s to your account": [""],
+ "There is no account to make a deposit for currency %1$s": [""],
+ "Add account": [""],
+ "Select account": [""],
+ "Add another account": [""],
+ "Deposit fee": [""],
+ "Total deposit": [""],
+ "Deposit&nbsp;%1$s %2$s": [""],
+ "Add bank account for %1$s": [""],
+ "Enter the URL of an exchange you trust.": [""],
+ "Unable add this account": [""],
+ "Select account type": [""],
+ "Review terms of service": [""],
+ "Exchange URL": [""],
+ "Add exchange": [""],
+ "Add new exchange": [""],
+ "Add exchange for %1$s": [""],
+ "An exchange has been found! Review the information and click next": [""],
+ "This exchange doesn&apos;t match the expected currency %1$s": [""],
+ "Unable to verify this exchange": [""],
+ "Unable to add this exchange": [""],
+ loading: [""],
+ Version: [""],
+ Next: [""],
+ "Waiting for confirmation": [""],
+ PENDING: [""],
+ "Could not load the list of transactions": [""],
+ "Your transaction history is empty for this currency.": [""],
+ "Add backup provider": [""],
+ "Could not get provider information": [""],
+ "Backup providers may charge for their service": [""],
+ URL: [""],
+ Name: [""],
+ "Provider URL": [""],
+ "Please review and accept this provider&apos;s terms of service": [""],
+ Pricing: [""],
+ "free of charge": [""],
+ "%1$s per year of service": [""],
+ Storage: [""],
+ "%1$s megabytes of storage per year of service": [""],
+ "Accept terms of service": [""],
+ "Could not parse the payto URI": [""],
+ "Please check the uri": [""],
+ "Exchange is ready for withdrawal": [""],
+ "To complete the process you need to wire%1$s %2$s to the exchange bank account":
+ [""],
+ "Alternative, you can also scan this QR code or open %1$s if you have a banking app installed that supports RFC 8905":
+ [""],
+ "Cancel withdrawal": [""],
+ "Could not toggle auto-open": [""],
+ "Could not toggle clipboard": [""],
+ Navigator: [""],
+ "Automatically open wallet based on page content": [""],
+ "Enabling this option below will make using the wallet faster, but requires more permissions from your browser.":
+ [""],
+ "Automatically check clipboard for Taler URI": [""],
+ Trust: [""],
+ "No exchange yet": [""],
+ "Term of Service": [""],
+ ok: [""],
+ changed: [""],
+ "not accepted": [""],
+ "unknown (exchange status should be updated)": [""],
+ "Add an exchange": [""],
+ Troubleshooting: [""],
+ "Developer mode": [""],
+ "More options and information useful for debugging": [""],
+ Display: [""],
+ "Current Language": [""],
+ "Wallet Core": [""],
+ "Web Extension": [""],
+ "Exchange compatibility": [""],
+ "Merchant compatibility": [""],
+ "Bank compatibility": [""],
+ "Browser Extension Installed!": [""],
+ "You can open the GNU Taler Wallet using the combination %1$s .": [""],
+ "Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick access without keyboard:":
+ [""],
+ "Click the puzzle icon": [""],
+ "Search for GNU Taler Wallet": [""],
+ "Click the pin icon": [""],
+ Permissions: [""],
+ "(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)":
+ [""],
+ "Next Steps": [""],
+ "Try the demo": [""],
+ "Learn how to top up your wallet balance": [""],
+ "Diagnostics timed out. Could not talk to the wallet backend.": [""],
+ "Problems detected:": [""],
+ "Please check in your %1$s settings that you have IndexedDB enabled (check the preference name %2$s).":
+ [""],
+ "Your wallet database is outdated. Currently automatic migration is not supported. Please go %1$s to reset the wallet database.":
+ [""],
+ "Running diagnostics": [""],
+ "Debug tools": [""],
+ "Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL YOUR COINS?":
+ [""],
+ reset: [""],
+ "TESTING: This may delete all your coin, proceed with caution": [""],
+ "run gc": [""],
+ "import database": [""],
+ "export database": [""],
+ "Database exported at %1$s %2$s to download": [""],
+ Coins: [""],
+ "Pending operations": [""],
+ "usable coins": [""],
+ id: [""],
+ denom: [""],
+ value: [""],
+ status: [""],
+ "from refresh?": [""],
+ "age key count": [""],
+ "spent coins": [""],
+ "click to show": [""],
+ "Scan a QR code or enter taler:// URI below": [""],
+ Open: [""],
+ "URI is not valid. Taler URI should start with `taler://`": [""],
+ "Try another": [""],
+ "Could not load list of exchange": [""],
+ "Choose a currency to proceed or add another exchange": [""],
+ "Known currencies": [""],
+ "Specify the amount and the origin": [""],
+ "Change currency": [""],
+ "Use previous origins:": [""],
+ "Or specify the origin of the money": [""],
+ "Specify the origin of the money": [""],
+ "From my bank account": [""],
+ "From another wallet": [""],
+ "currency not provided": [""],
+ "Specify the amount and the destination": [""],
+ "Use previous destinations:": [""],
+ "Or specify the destination of the money": [""],
+ "Specify the destination of the money": [""],
+ "To my bank account": [""],
+ "To another wallet": [""],
+ "Could not load backup recovery information": [""],
+ "Digital wallet recovery": [""],
+ "Import backup, show info": [""],
+ "All done, your transaction is in progress": [""],
+ Edit: [""],
+ "Could not load the list of known exchanges": [""],
},
},
};
-strings["es"] = {
+strings["it"] = {
domain: "messages",
locale_data: {
messages: {
"": {
domain: "messages",
- plural_forms: "nplurals=2; plural=(n != 1);",
- lang: "",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "it",
},
- "Invalid Wire": [""],
- "Invalid Test Wire Detail": [""],
- "Test Wire Acct #%1$s on %2$s": [""],
- "Unknown Wire Detail": [""],
- Operation: [""],
- "time (ms/op)": [""],
- "The merchant %1$s offers you to purchase:": [""],
- "The total price is %1$s (plus %2$s fees).": [""],
- "The total price is %1$s.": [""],
- Retry: [""],
- "Confirm payment": [""],
Balance: [""],
- History: ["Historial"],
- Debug: [""],
- "You have no balance to show. Need some %1$s getting started?": [""],
- "%1$s incoming": [""],
- "%1$s being spent": [""],
- "Error: could not retrieve balance information.": [""],
- "Invalid ": [""],
- "Fees ": [""],
- "Refresh sessions has completed": [""],
- "Order Refused": [""],
- "Order redirected": [""],
- "Payment aborted": [""],
- "Payment Sent": [""],
- "Backup": ["Resguardo"],
- "Order accepted": [""],
- "Reserve balance updated": [""],
- "Payment refund": [""],
- Withdrawn: [""],
- "Tip Accepted": [""],
- "Tip Declined": [""],
+ Backup: [""],
+ "QR Reader and Taler URI": [""],
+ Settings: [""],
+ Dev: [""],
"%1$s": [""],
- "Your wallet has no events recorded.": [""],
- "Wire to bank account": [""],
- Confirm: ["Confirmar"],
- Cancel: ["Cancelar"],
- "Could not get details for withdraw operation:": [""],
- "Chose different exchange provider": [""],
- "Please select an exchange. You can review the details before after your selection.": [
+ "PENDING OPERATIONS": [""],
+ Loading: [""],
+ "Could not load backup providers": [""],
+ "No backup providers configured": [""],
+ "Add provider": [""],
+ "Sync all backups": [""],
+ "Sync now": [""],
+ "Last synced": [""],
+ "Not synced": [""],
+ "Expires in": [""],
+ "There was an error loading the provider detail for &quot; %1$s&quot;": [
"",
],
- "Select %1$s": [""],
- "Select custom exchange": [""],
- "You are about to withdraw %1$s from your bank account into your wallet.": [
+ "There is not known provider with url &quot;%1$s&quot;.": [""],
+ "See providers": [""],
+ "Last backup": [""],
+ "Back up": [""],
+ "Provider fee": [""],
+ "per year": [""],
+ Extend: [""],
+ "terms has changed, extending the service will imply accepting the new terms of service":
+ [""],
+ old: [""],
+ new: [""],
+ fee: [""],
+ storage: [""],
+ "Remove provider": [""],
+ "This provider has reported an error": [""],
+ "There is conflict with another backup from %1$s": [""],
+ "Backup is not readable": [""],
+ "Unknown backup problem: %1$s": [""],
+ "service paid": [""],
+ "Backup valid until": [""],
+ Cancel: [""],
+ "Open reserve page": [""],
+ "Open pay page": [""],
+ "Open refund page": [""],
+ "Open tip page": [""],
+ "Open withdraw page": [""],
+ "Get digital cash": [""],
+ "Could not load balance page": [""],
+ Add: [""],
+ "Send %1$s": [""],
+ "Taler Action": [""],
+ "This page has pay action.": [""],
+ "This page has a withdrawal action.": [""],
+ "This page has a tip action.": [""],
+ "This page has a notify reserve action.": [""],
+ Notify: [""],
+ "This page has a refund action.": [""],
+ "This page has a malformed taler uri.": [""],
+ Dismiss: [""],
+ "this popup is being closed and you are being redirected to %1$s": [""],
+ "Could not load purchase proposal details": [""],
+ "Order Id": [""],
+ Summary: [""],
+ Amount: [""],
+ "Merchant name": [""],
+ "Merchant jurisdiction": [""],
+ "Merchant address": [""],
+ "Merchant logo": [""],
+ "Merchant website": [""],
+ "Merchant email": [""],
+ "Merchant public key": [""],
+ "Delivery date": [""],
+ "Delivery location": [""],
+ Products: [""],
+ "Created at": [""],
+ "Refund deadline": [""],
+ "Auto refund": [""],
+ "Pay deadline": [""],
+ "Fulfillment URL": [""],
+ "Fulfillment message": [""],
+ "Max deposit fee": [""],
+ "Max fee": [""],
+ "Minimum age": [""],
+ "Wire fee amortization": [""],
+ Auditors: [""],
+ Exchanges: [""],
+ "Bank account": [""],
+ "Bitcoin address": [""],
+ IBAN: [""],
+ "Could not load deposit status": [""],
+ "Digital cash deposit": [""],
+ Cost: [""],
+ Fee: [""],
+ "To be received": [""],
+ "Send &nbsp; %1$s": [""],
+ "Bitcoin transfer details": [""],
+ "The exchange need a transaction with 3 output, one output is the exchange account and the other two are segwit fake address for metadata with an minimum amount.":
+ [""],
+ "In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional recipient and copy addresses and amounts":
+ [""],
+ "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC":
+ [""],
+ Account: [""],
+ "Bank host": [""],
+ "Bank transfer details": [""],
+ Subject: [""],
+ "Receiver name": [""],
+ "Could not load the transaction information": [""],
+ "There was an error trying to complete the transaction": [""],
+ "This transaction is not completed": [""],
+ Send: [""],
+ Retry: [""],
+ Forget: [""],
+ "Caution!": [""],
+ "If you have already wired money to the exchange you will loose the chance to get the coins form it.":
+ [""],
+ Confirm: ["Confermare"],
+ Withdrawal: [""],
+ "Make sure to use the correct subject, otherwise the money will not arrive in this wallet.":
+ [""],
+ "The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check there is no pending step.":
+ [""],
+ "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins":
+ [""],
+ Details: [""],
+ Payment: [""],
+ Refunds: [""],
+ "%1$s %2$s on %3$s": [""],
+ "Merchant created a refund for this order but was not automatically picked up.":
+ [""],
+ Offer: [""],
+ Accept: [""],
+ Merchant: [""],
+ "Invoice ID": [""],
+ Deposit: [""],
+ Refresh: [""],
+ Tip: [""],
+ Refund: [""],
+ "Original order ID": [""],
+ "Purchase summary": [""],
+ copy: [""],
+ "hide qr": [""],
+ "show qr": [""],
+ Credit: [""],
+ Invoice: [""],
+ Exchange: [""],
+ URI: [""],
+ Debit: [""],
+ Transfer: [""],
+ Country: [""],
+ "Address lines": [""],
+ "Building number": [""],
+ "Building name": [""],
+ Street: [""],
+ "Post code": [""],
+ "Town location": [""],
+ Town: [""],
+ District: [""],
+ "Country subdivision": [""],
+ Date: [""],
+ "Transaction fees": [""],
+ Total: [""],
+ Withdraw: [""],
+ Price: [""],
+ Refunded: [""],
+ Delivery: [""],
+ "Total transfer": [""],
+ "Could not load pay status": [""],
+ "Digital cash payment": [""],
+ Purchase: [""],
+ Receipt: [""],
+ "Valid until": [""],
+ "List of products": [""],
+ free: [""],
+ "Already paid, you are going to be redirected to %1$s": [""],
+ "Already paid": [""],
+ "Already claimed": [""],
+ "Pay with a mobile phone": [""],
+ "Hide QR": [""],
+ "Scan the QR code or &nbsp; %1$s": [""],
+ "Pay &nbsp; %1$s": [""],
+ "You have no balance for this currency. Withdraw digital cash first.": [
"",
],
- "Accept fees and withdraw": [""],
- "Cancel withdraw operation": [""],
- "Withdrawal fees:": [""],
- "Rounding loss:": [""],
- "Earliest expiration (for deposit): %1$s": [""],
- "# Coins": [""],
- Value: [""],
- "Withdraw Fee": [""],
- "Refresh Fee": [""],
- "Deposit Fee": [""],
+ "Could not find enough coins to pay. Even if you have enough %1$s some restriction may apply.":
+ [""],
+ "Your current balance is not enough.": [""],
+ "Merchant message": [""],
+ "Could not load refund status": [""],
+ "Digital cash refund": [""],
+ "You&apos;ve ignored the tip.": [""],
+ "The refund is in progress.": [""],
+ "Total to refund": [""],
+ "The merchant &quot;%1$s&quot; is offering you a refund.": [""],
+ "Order amount": [""],
+ "Already refunded": [""],
+ "Refund offered": [""],
+ "Accept &nbsp; %1$s": [""],
+ "Could not load tip status": [""],
+ "Digital cash tip": [""],
+ "The merchant is offering you a tip": [""],
+ "Merchant URL": [""],
+ "Receive &nbsp; %1$s": [""],
+ "Tip from %1$s accepted. Check your transactions list for more details.":
+ [""],
+ "Select one option": [""],
+ "Could not load": [""],
+ "Show terms of service": [""],
+ "I accept the exchange terms of service": [""],
+ "Exchange doesn&apos;t have terms of service": [""],
+ "Review exchange terms of service": [""],
+ "Review new version of terms of service": [""],
+ "The exchange reply with a empty terms of service": [""],
+ "Download Terms of Service": [""],
+ "Hide terms of service": [""],
+ "Could not load exchange fees": [""],
+ Close: [""],
+ "could not find any exchange": [""],
+ "could not find any exchange for the currency %1$s": [""],
+ "Service fee description": [""],
+ "Select %1$s exchange": [""],
+ Reset: [""],
+ "Use this exchange": [""],
+ "Doesn&apos;t have auditors": [""],
+ currency: [""],
+ Operations: [""],
+ Deposits: [""],
+ Denomination: [""],
+ Until: [""],
+ Withdrawals: [""],
+ Currency: [""],
+ "Coin operations": [""],
+ "Every operation in this section may be different by denomination value and is valid for a period of time. The exchange will charge the indicated amount every time a coin is used in such operation.":
+ [""],
+ "Transfer operations": [""],
+ "Every operation in this section may be different by transfer type and is valid for a period of time. The exchange will charge the indicated amount every time a transfer is made.":
+ [""],
+ Operation: [""],
+ "Wallet operations": [""],
+ Feature: [""],
+ "Could not get the info from the URI": [""],
+ "Could not get info of withdrawal": [""],
+ "Digital cash withdrawal": [""],
+ "Could not finish the withdrawal operation": [""],
+ "Age restriction": [""],
+ "Withdraw &nbsp; %1$s": [""],
+ "Withdraw to a mobile phone": [""],
+ "Digital invoice": [""],
+ "Could not finish the invoice creation": [""],
+ Create: [""],
+ "Could not finish the payment operation": [""],
+ "Digital cash transfer": [""],
+ "Could not finish the transfer creation": [""],
+ "Could not finish the pickup operation": [""],
+ "Manual Withdrawal for %1$s": [""],
+ "Choose a exchange from where the coins will be withdrawn. The exchange will send the coins to this wallet after receiving a wire transfer with the correct subject.":
+ [""],
+ "No exchange found for %1$s": [""],
+ "Add Exchange": [""],
+ "No exchange configured": [""],
+ "Can&apos;t create the reserve": [""],
+ "Start withdrawal": [""],
+ "Could not load deposit balance": [""],
+ "A currency or an amount should be indicated": [""],
+ "There is no enough balance to make a deposit for currency %1$s": [""],
+ "Send %1$s to your account": [""],
+ "There is no account to make a deposit for currency %1$s": [""],
+ "Add account": [""],
+ "Select account": [""],
+ "Add another account": [""],
+ "Deposit fee": [""],
+ "Total deposit": [""],
+ "Deposit&nbsp;%1$s %2$s": [""],
+ "Add bank account for %1$s": [""],
+ "Enter the URL of an exchange you trust.": [""],
+ "Unable add this account": [""],
+ "Select account type": [""],
+ "Review terms of service": [""],
+ "Exchange URL": [""],
+ "Add exchange": [""],
+ "Add new exchange": [""],
+ "Add exchange for %1$s": [""],
+ "An exchange has been found! Review the information and click next": [""],
+ "This exchange doesn&apos;t match the expected currency %1$s": [""],
+ "Unable to verify this exchange": [""],
+ "Unable to add this exchange": [""],
+ loading: [""],
+ Version: [""],
+ Next: [""],
+ "Waiting for confirmation": [""],
+ PENDING: [""],
+ "Could not load the list of transactions": [""],
+ "Your transaction history is empty for this currency.": [""],
+ "Add backup provider": [""],
+ "Could not get provider information": [""],
+ "Backup providers may charge for their service": [""],
+ URL: [""],
+ Name: [""],
+ "Provider URL": [""],
+ "Please review and accept this provider&apos;s terms of service": [""],
+ Pricing: [""],
+ "free of charge": [""],
+ "%1$s per year of service": [""],
+ Storage: [""],
+ "%1$s megabytes of storage per year of service": [""],
+ "Accept terms of service": [""],
+ "Could not parse the payto URI": [""],
+ "Please check the uri": [""],
+ "Exchange is ready for withdrawal": [""],
+ "To complete the process you need to wire%1$s %2$s to the exchange bank account":
+ [""],
+ "Alternative, you can also scan this QR code or open %1$s if you have a banking app installed that supports RFC 8905":
+ [""],
+ "Cancel withdrawal": [""],
+ "Could not toggle auto-open": [""],
+ "Could not toggle clipboard": [""],
+ Navigator: [""],
+ "Automatically open wallet based on page content": [""],
+ "Enabling this option below will make using the wallet faster, but requires more permissions from your browser.":
+ [""],
+ "Automatically check clipboard for Taler URI": [""],
+ Trust: [""],
+ "No exchange yet": [""],
+ "Term of Service": [""],
+ ok: [""],
+ changed: [""],
+ "not accepted": [""],
+ "unknown (exchange status should be updated)": [""],
+ "Add an exchange": [""],
+ Troubleshooting: [""],
+ "Developer mode": [""],
+ "More options and information useful for debugging": [""],
+ Display: [""],
+ "Current Language": [""],
+ "Wallet Core": [""],
+ "Web Extension": [""],
+ "Exchange compatibility": [""],
+ "Merchant compatibility": [""],
+ "Bank compatibility": [""],
+ "Browser Extension Installed!": [""],
+ "You can open the GNU Taler Wallet using the combination %1$s .": [""],
+ "Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick access without keyboard:":
+ [""],
+ "Click the puzzle icon": [""],
+ "Search for GNU Taler Wallet": [""],
+ "Click the pin icon": [""],
+ Permissions: [""],
+ "(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)":
+ [""],
+ "Next Steps": [""],
+ "Try the demo": [""],
+ "Learn how to top up your wallet balance": [""],
+ "Diagnostics timed out. Could not talk to the wallet backend.": [""],
+ "Problems detected:": [""],
+ "Please check in your %1$s settings that you have IndexedDB enabled (check the preference name %2$s).":
+ [""],
+ "Your wallet database is outdated. Currently automatic migration is not supported. Please go %1$s to reset the wallet database.":
+ [""],
+ "Running diagnostics": [""],
+ "Debug tools": [""],
+ "Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL YOUR COINS?":
+ [""],
+ reset: [""],
+ "TESTING: This may delete all your coin, proceed with caution": [""],
+ "run gc": [""],
+ "import database": [""],
+ "export database": [""],
+ "Database exported at %1$s %2$s to download": [""],
+ Coins: [""],
+ "Pending operations": [""],
+ "usable coins": [""],
+ id: [""],
+ denom: [""],
+ value: [""],
+ status: [""],
+ "from refresh?": [""],
+ "age key count": [""],
+ "spent coins": [""],
+ "click to show": [""],
+ "Scan a QR code or enter taler:// URI below": [""],
+ Open: [""],
+ "URI is not valid. Taler URI should start with `taler://`": [""],
+ "Try another": [""],
+ "Could not load list of exchange": [""],
+ "Choose a currency to proceed or add another exchange": [""],
+ "Known currencies": [""],
+ "Specify the amount and the origin": [""],
+ "Change currency": [""],
+ "Use previous origins:": [""],
+ "Or specify the origin of the money": [""],
+ "Specify the origin of the money": [""],
+ "From my bank account": [""],
+ "From another wallet": [""],
+ "currency not provided": [""],
+ "Specify the amount and the destination": [""],
+ "Use previous destinations:": [""],
+ "Or specify the destination of the money": [""],
+ "Specify the destination of the money": [""],
+ "To my bank account": [""],
+ "To another wallet": [""],
+ "Could not load backup recovery information": [""],
+ "Digital wallet recovery": [""],
+ "Import backup, show info": [""],
+ "All done, your transaction is in progress": [""],
+ Edit: [""],
+ "Could not load the list of known exchanges": [""],
},
},
};
-strings["fr"] = {
+strings["ja"] = {
domain: "messages",
locale_data: {
messages: {
"": {
domain: "messages",
- plural_forms: "nplurals=2; plural=(n != 1);",
- lang: "",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "ja",
},
- "Invalid Wire": [""],
- "Invalid Test Wire Detail": [""],
- "Test Wire Acct #%1$s on %2$s": [""],
- "Unknown Wire Detail": [""],
- Operation: [""],
- "time (ms/op)": [""],
- "The merchant %1$s offers you to purchase:": [""],
- "The total price is %1$s (plus %2$s fees).": [""],
- "The total price is %1$s.": [""],
- Retry: [""],
- "Confirm payment": [""],
- Balance: [""],
- History: [""],
- Debug: [""],
- "You have no balance to show. Need some %1$s getting started?": [""],
- "%1$s incoming": [""],
- "%1$s being spent": [""],
- "Error: could not retrieve balance information.": [""],
- "Invalid ": [""],
- "Fees ": [""],
- "Refresh sessions has completed": [""],
- "Order Refused": [""],
- "Order redirected": [""],
- "Payment aborted": [""],
- "Payment Sent": [""],
- "Order accepted": [""],
- "Reserve balance updated": [""],
- "Payment refund": [""],
- Withdrawn: [""],
- "Tip Accepted": [""],
- "Tip Declined": [""],
+ Balance: ["残高"],
+ Backup: ["バックアップ"],
+ "QR Reader and Taler URI": [""],
+ Settings: ["設定"],
+ Dev: [""],
"%1$s": [""],
- "Your wallet has no events recorded.": [""],
- "Wire to bank account": [""],
- Confirm: [""],
- Cancel: [""],
- "Could not get details for withdraw operation:": [""],
- "Chose different exchange provider": [""],
- "Please select an exchange. You can review the details before after your selection.": [
+ "PENDING OPERATIONS": [""],
+ Loading: [""],
+ "Could not load backup providers": [""],
+ "No backup providers configured": [""],
+ "Add provider": [""],
+ "Sync all backups": [""],
+ "Sync now": [""],
+ "Last synced": [""],
+ "Not synced": [""],
+ "Expires in": [""],
+ "There was an error loading the provider detail for &quot; %1$s&quot;": [
"",
],
- "Select %1$s": [""],
- "Select custom exchange": [""],
- "You are about to withdraw %1$s from your bank account into your wallet.": [
+ "There is not known provider with url &quot;%1$s&quot;.": [""],
+ "See providers": [""],
+ "Last backup": [""],
+ "Back up": [""],
+ "Provider fee": [""],
+ "per year": [""],
+ Extend: [""],
+ "terms has changed, extending the service will imply accepting the new terms of service":
+ [""],
+ old: [""],
+ new: [""],
+ fee: [""],
+ storage: [""],
+ "Remove provider": [""],
+ "This provider has reported an error": [""],
+ "There is conflict with another backup from %1$s": [""],
+ "Backup is not readable": [""],
+ "Unknown backup problem: %1$s": [""],
+ "service paid": [""],
+ "Backup valid until": [""],
+ Cancel: [""],
+ "Open reserve page": [""],
+ "Open pay page": [""],
+ "Open refund page": [""],
+ "Open tip page": [""],
+ "Open withdraw page": [""],
+ "Get digital cash": [""],
+ "Could not load balance page": [""],
+ Add: [""],
+ "Send %1$s": [""],
+ "Taler Action": [""],
+ "This page has pay action.": [""],
+ "This page has a withdrawal action.": [""],
+ "This page has a tip action.": [""],
+ "This page has a notify reserve action.": [""],
+ Notify: [""],
+ "This page has a refund action.": [""],
+ "This page has a malformed taler uri.": [""],
+ Dismiss: [""],
+ "this popup is being closed and you are being redirected to %1$s": [""],
+ "Could not load purchase proposal details": [""],
+ "Order Id": [""],
+ Summary: [""],
+ Amount: [""],
+ "Merchant name": [""],
+ "Merchant jurisdiction": [""],
+ "Merchant address": [""],
+ "Merchant logo": [""],
+ "Merchant website": [""],
+ "Merchant email": [""],
+ "Merchant public key": [""],
+ "Delivery date": [""],
+ "Delivery location": [""],
+ Products: [""],
+ "Created at": [""],
+ "Refund deadline": [""],
+ "Auto refund": [""],
+ "Pay deadline": [""],
+ "Fulfillment URL": [""],
+ "Fulfillment message": [""],
+ "Max deposit fee": [""],
+ "Max fee": [""],
+ "Minimum age": [""],
+ "Wire fee amortization": [""],
+ Auditors: [""],
+ Exchanges: [""],
+ "Bank account": [""],
+ "Bitcoin address": [""],
+ IBAN: [""],
+ "Could not load deposit status": [""],
+ "Digital cash deposit": [""],
+ Cost: [""],
+ Fee: [""],
+ "To be received": [""],
+ "Send &nbsp; %1$s": [""],
+ "Bitcoin transfer details": [""],
+ "The exchange need a transaction with 3 output, one output is the exchange account and the other two are segwit fake address for metadata with an minimum amount.":
+ [""],
+ "In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional recipient and copy addresses and amounts":
+ [""],
+ "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC":
+ [""],
+ Account: [""],
+ "Bank host": [""],
+ "Bank transfer details": [""],
+ Subject: [""],
+ "Receiver name": [""],
+ "Could not load the transaction information": [""],
+ "There was an error trying to complete the transaction": [""],
+ "This transaction is not completed": [""],
+ Send: [""],
+ Retry: [""],
+ Forget: [""],
+ "Caution!": [""],
+ "If you have already wired money to the exchange you will loose the chance to get the coins form it.":
+ [""],
+ Confirm: [""],
+ Withdrawal: [""],
+ "Make sure to use the correct subject, otherwise the money will not arrive in this wallet.":
+ [""],
+ "The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check there is no pending step.":
+ [""],
+ "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins":
+ [""],
+ Details: [""],
+ Payment: [""],
+ Refunds: [""],
+ "%1$s %2$s on %3$s": [""],
+ "Merchant created a refund for this order but was not automatically picked up.":
+ [""],
+ Offer: [""],
+ Accept: [""],
+ Merchant: [""],
+ "Invoice ID": [""],
+ Deposit: [""],
+ Refresh: [""],
+ Tip: [""],
+ Refund: [""],
+ "Original order ID": [""],
+ "Purchase summary": [""],
+ copy: [""],
+ "hide qr": [""],
+ "show qr": [""],
+ Credit: [""],
+ Invoice: [""],
+ Exchange: [""],
+ URI: [""],
+ Debit: [""],
+ Transfer: [""],
+ Country: [""],
+ "Address lines": [""],
+ "Building number": [""],
+ "Building name": [""],
+ Street: [""],
+ "Post code": [""],
+ "Town location": [""],
+ Town: [""],
+ District: [""],
+ "Country subdivision": [""],
+ Date: [""],
+ "Transaction fees": [""],
+ Total: [""],
+ Withdraw: ["撤退"],
+ Price: [""],
+ Refunded: [""],
+ Delivery: [""],
+ "Total transfer": [""],
+ "Could not load pay status": [""],
+ "Digital cash payment": [""],
+ Purchase: [""],
+ Receipt: [""],
+ "Valid until": [""],
+ "List of products": [""],
+ free: [""],
+ "Already paid, you are going to be redirected to %1$s": [""],
+ "Already paid": [""],
+ "Already claimed": [""],
+ "Pay with a mobile phone": [""],
+ "Hide QR": [""],
+ "Scan the QR code or &nbsp; %1$s": [""],
+ "Pay &nbsp; %1$s": [""],
+ "You have no balance for this currency. Withdraw digital cash first.": [
"",
],
- "Accept fees and withdraw": [""],
- "Cancel withdraw operation": [""],
- "Withdrawal fees:": [""],
- "Rounding loss:": [""],
- "Earliest expiration (for deposit): %1$s": [""],
- "# Coins": [""],
- Value: [""],
- "Withdraw Fee": [""],
- "Refresh Fee": [""],
- "Deposit Fee": [""],
+ "Could not find enough coins to pay. Even if you have enough %1$s some restriction may apply.":
+ [""],
+ "Your current balance is not enough.": ["表示するバランスがありません"],
+ "Merchant message": [""],
+ "Could not load refund status": [""],
+ "Digital cash refund": [""],
+ "You&apos;ve ignored the tip.": [""],
+ "The refund is in progress.": [""],
+ "Total to refund": [""],
+ "The merchant &quot;%1$s&quot; is offering you a refund.": [""],
+ "Order amount": [""],
+ "Already refunded": [""],
+ "Refund offered": [""],
+ "Accept &nbsp; %1$s": [""],
+ "Could not load tip status": [""],
+ "Digital cash tip": [""],
+ "The merchant is offering you a tip": [""],
+ "Merchant URL": [""],
+ "Receive &nbsp; %1$s": [""],
+ "Tip from %1$s accepted. Check your transactions list for more details.":
+ [""],
+ "Select one option": [""],
+ "Could not load": [""],
+ "Show terms of service": [""],
+ "I accept the exchange terms of service": [""],
+ "Exchange doesn&apos;t have terms of service": [""],
+ "Review exchange terms of service": [""],
+ "Review new version of terms of service": [""],
+ "The exchange reply with a empty terms of service": [""],
+ "Download Terms of Service": [""],
+ "Hide terms of service": [""],
+ "Could not load exchange fees": [""],
+ Close: [""],
+ "could not find any exchange": [""],
+ "could not find any exchange for the currency %1$s": [""],
+ "Service fee description": [""],
+ "Select %1$s exchange": [""],
+ Reset: [""],
+ "Use this exchange": [""],
+ "Doesn&apos;t have auditors": [""],
+ currency: [""],
+ Operations: [""],
+ Deposits: [""],
+ Denomination: [""],
+ Until: [""],
+ Withdrawals: ["撤退"],
+ Currency: [""],
+ "Coin operations": [""],
+ "Every operation in this section may be different by denomination value and is valid for a period of time. The exchange will charge the indicated amount every time a coin is used in such operation.":
+ [""],
+ "Transfer operations": [""],
+ "Every operation in this section may be different by transfer type and is valid for a period of time. The exchange will charge the indicated amount every time a transfer is made.":
+ [""],
+ Operation: [""],
+ "Wallet operations": [""],
+ Feature: [""],
+ "Could not get the info from the URI": [""],
+ "Could not get info of withdrawal": [""],
+ "Digital cash withdrawal": [""],
+ "Could not finish the withdrawal operation": [""],
+ "Age restriction": [""],
+ "Withdraw &nbsp; %1$s": [""],
+ "Withdraw to a mobile phone": [""],
+ "Digital invoice": [""],
+ "Could not finish the invoice creation": [""],
+ Create: [""],
+ "Could not finish the payment operation": [""],
+ "Digital cash transfer": [""],
+ "Could not finish the transfer creation": [""],
+ "Could not finish the pickup operation": [""],
+ "Manual Withdrawal for %1$s": [""],
+ "Choose a exchange from where the coins will be withdrawn. The exchange will send the coins to this wallet after receiving a wire transfer with the correct subject.":
+ [""],
+ "No exchange found for %1$s": [""],
+ "Add Exchange": [""],
+ "No exchange configured": [""],
+ "Can&apos;t create the reserve": [""],
+ "Start withdrawal": [""],
+ "Could not load deposit balance": [""],
+ "A currency or an amount should be indicated": [""],
+ "There is no enough balance to make a deposit for currency %1$s": [""],
+ "Send %1$s to your account": [""],
+ "There is no account to make a deposit for currency %1$s": [""],
+ "Add account": [""],
+ "Select account": [""],
+ "Add another account": [""],
+ "Deposit fee": [""],
+ "Total deposit": [""],
+ "Deposit&nbsp;%1$s %2$s": [""],
+ "Add bank account for %1$s": [""],
+ "Enter the URL of an exchange you trust.": [""],
+ "Unable add this account": [""],
+ "Select account type": [""],
+ "Review terms of service": [""],
+ "Exchange URL": [""],
+ "Add exchange": [""],
+ "Add new exchange": [""],
+ "Add exchange for %1$s": [""],
+ "An exchange has been found! Review the information and click next": [""],
+ "This exchange doesn&apos;t match the expected currency %1$s": [""],
+ "Unable to verify this exchange": [""],
+ "Unable to add this exchange": [""],
+ loading: [""],
+ Version: [""],
+ Next: [""],
+ "Waiting for confirmation": [""],
+ PENDING: [""],
+ "Could not load the list of transactions": [""],
+ "Your transaction history is empty for this currency.": [""],
+ "Add backup provider": [""],
+ "Could not get provider information": [""],
+ "Backup providers may charge for their service": [""],
+ URL: [""],
+ Name: [""],
+ "Provider URL": [""],
+ "Please review and accept this provider&apos;s terms of service": [""],
+ Pricing: [""],
+ "free of charge": [""],
+ "%1$s per year of service": [""],
+ Storage: [""],
+ "%1$s megabytes of storage per year of service": [""],
+ "Accept terms of service": [""],
+ "Could not parse the payto URI": [""],
+ "Please check the uri": [""],
+ "Exchange is ready for withdrawal": [""],
+ "To complete the process you need to wire%1$s %2$s to the exchange bank account":
+ [""],
+ "Alternative, you can also scan this QR code or open %1$s if you have a banking app installed that supports RFC 8905":
+ [""],
+ "Cancel withdrawal": [""],
+ "Could not toggle auto-open": [""],
+ "Could not toggle clipboard": [""],
+ Navigator: [""],
+ "Automatically open wallet based on page content": [""],
+ "Enabling this option below will make using the wallet faster, but requires more permissions from your browser.":
+ [""],
+ "Automatically check clipboard for Taler URI": [""],
+ Trust: [""],
+ "No exchange yet": [""],
+ "Term of Service": [""],
+ ok: [""],
+ changed: [""],
+ "not accepted": [""],
+ "unknown (exchange status should be updated)": [""],
+ "Add an exchange": [""],
+ Troubleshooting: [""],
+ "Developer mode": [""],
+ "More options and information useful for debugging": [""],
+ Display: [""],
+ "Current Language": [""],
+ "Wallet Core": [""],
+ "Web Extension": [""],
+ "Exchange compatibility": [""],
+ "Merchant compatibility": [""],
+ "Bank compatibility": [""],
+ "Browser Extension Installed!": [""],
+ "You can open the GNU Taler Wallet using the combination %1$s .": [""],
+ "Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick access without keyboard:":
+ [""],
+ "Click the puzzle icon": [""],
+ "Search for GNU Taler Wallet": [""],
+ "Click the pin icon": [""],
+ Permissions: [""],
+ "(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)":
+ [""],
+ "Next Steps": [""],
+ "Try the demo": [""],
+ "Learn how to top up your wallet balance": [""],
+ "Diagnostics timed out. Could not talk to the wallet backend.": [""],
+ "Problems detected:": [""],
+ "Please check in your %1$s settings that you have IndexedDB enabled (check the preference name %2$s).":
+ [""],
+ "Your wallet database is outdated. Currently automatic migration is not supported. Please go %1$s to reset the wallet database.":
+ [""],
+ "Running diagnostics": [""],
+ "Debug tools": [""],
+ "Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL YOUR COINS?":
+ [""],
+ reset: [""],
+ "TESTING: This may delete all your coin, proceed with caution": [""],
+ "run gc": [""],
+ "import database": [""],
+ "export database": [""],
+ "Database exported at %1$s %2$s to download": [""],
+ Coins: [""],
+ "Pending operations": [""],
+ "usable coins": [""],
+ id: [""],
+ denom: [""],
+ value: [""],
+ status: [""],
+ "from refresh?": [""],
+ "age key count": [""],
+ "spent coins": [""],
+ "click to show": [""],
+ "Scan a QR code or enter taler:// URI below": [""],
+ Open: [""],
+ "URI is not valid. Taler URI should start with `taler://`": [""],
+ "Try another": [""],
+ "Could not load list of exchange": [""],
+ "Choose a currency to proceed or add another exchange": [""],
+ "Known currencies": [""],
+ "Specify the amount and the origin": [""],
+ "Change currency": [""],
+ "Use previous origins:": [""],
+ "Or specify the origin of the money": [""],
+ "Specify the origin of the money": [""],
+ "From my bank account": [""],
+ "From another wallet": [""],
+ "currency not provided": [""],
+ "Specify the amount and the destination": [""],
+ "Use previous destinations:": [""],
+ "Or specify the destination of the money": [""],
+ "Specify the destination of the money": [""],
+ "To my bank account": [""],
+ "To another wallet": [""],
+ "Could not load backup recovery information": [""],
+ "Digital wallet recovery": [""],
+ "Import backup, show info": [""],
+ "All done, your transaction is in progress": [""],
+ Edit: [""],
+ "Could not load the list of known exchanges": [""],
},
},
};
-strings["it"] = {
+strings["sv"] = {
domain: "messages",
locale_data: {
messages: {
"": {
domain: "messages",
- plural_forms: "nplurals=2; plural=(n != 1);",
- lang: "",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "sv",
},
- "Invalid Wire": [""],
- "Invalid Test Wire Detail": [""],
- "Test Wire Acct #%1$s on %2$s": [""],
- "Unknown Wire Detail": [""],
- Operation: [""],
- "time (ms/op)": [""],
- "The merchant %1$s offers you to purchase:": [""],
- "The total price is %1$s (plus %2$s fees).": [""],
- "The total price is %1$s.": [""],
- Retry: [""],
- "Confirm payment": [""],
- Balance: [""],
- History: [""],
- Debug: [""],
- "You have no balance to show. Need some %1$s getting started?": [""],
- "%1$s incoming": [""],
- "%1$s being spent": [""],
- "Error: could not retrieve balance information.": [""],
- "Invalid ": [""],
- "Fees ": [""],
- "Refresh sessions has completed": [""],
- "Order Refused": [""],
- "Order redirected": [""],
- "Payment aborted": [""],
- "Payment Sent": [""],
- "Order accepted": [""],
- "Reserve balance updated": [""],
- "Payment refund": [""],
- Withdrawn: [""],
- "Tip Accepted": [""],
- "Tip Declined": [""],
+ Balance: ["Balans"],
+ Backup: [""],
+ "QR Reader and Taler URI": [""],
+ Settings: [""],
+ Dev: [""],
"%1$s": [""],
- "Your wallet has no events recorded.": [""],
- "Wire to bank account": [""],
- Confirm: [""],
- Cancel: [""],
- "Could not get details for withdraw operation:": [""],
- "Chose different exchange provider": [""],
- "Please select an exchange. You can review the details before after your selection.": [
+ "PENDING OPERATIONS": [""],
+ Loading: [""],
+ "Could not load backup providers": [""],
+ "No backup providers configured": [""],
+ "Add provider": [""],
+ "Sync all backups": [""],
+ "Sync now": [""],
+ "Last synced": [""],
+ "Not synced": [""],
+ "Expires in": [""],
+ "There was an error loading the provider detail for &quot; %1$s&quot;": [
"",
],
- "Select %1$s": [""],
- "Select custom exchange": [""],
- "You are about to withdraw %1$s from your bank account into your wallet.": [
+ "There is not known provider with url &quot;%1$s&quot;.": [""],
+ "See providers": [""],
+ "Last backup": [""],
+ "Back up": [""],
+ "Provider fee": [""],
+ "per year": [""],
+ Extend: [""],
+ "terms has changed, extending the service will imply accepting the new terms of service":
+ [""],
+ old: [""],
+ new: [""],
+ fee: [""],
+ storage: [""],
+ "Remove provider": [""],
+ "This provider has reported an error": [""],
+ "There is conflict with another backup from %1$s": [""],
+ "Backup is not readable": [""],
+ "Unknown backup problem: %1$s": [""],
+ "service paid": [""],
+ "Backup valid until": [""],
+ Cancel: ["Avbryt"],
+ "Open reserve page": [""],
+ "Open pay page": [""],
+ "Open refund page": [""],
+ "Open tip page": [""],
+ "Open withdraw page": ["Utbetalnings avgift"],
+ "Get digital cash": ["Utbetalnings avgifter:"],
+ "Could not load balance page": [""],
+ Add: [""],
+ "Send %1$s": ["Välj %1$s"],
+ "Taler Action": [""],
+ "This page has pay action.": [""],
+ "This page has a withdrawal action.": [""],
+ "This page has a tip action.": [""],
+ "This page has a notify reserve action.": [""],
+ Notify: [""],
+ "This page has a refund action.": [""],
+ "This page has a malformed taler uri.": [""],
+ Dismiss: [""],
+ "this popup is being closed and you are being redirected to %1$s": [""],
+ "Could not load purchase proposal details": [""],
+ "Order Id": [""],
+ Summary: [""],
+ Amount: [""],
+ "Merchant name": [""],
+ "Merchant jurisdiction": [""],
+ "Merchant address": [""],
+ "Merchant logo": [""],
+ "Merchant website": [""],
+ "Merchant email": [""],
+ "Merchant public key": [""],
+ "Delivery date": [""],
+ "Delivery location": [""],
+ Products: [""],
+ "Created at": [""],
+ "Refund deadline": [""],
+ "Auto refund": [""],
+ "Pay deadline": [""],
+ "Fulfillment URL": [""],
+ "Fulfillment message": [""],
+ "Max deposit fee": ["Depostitions avgift"],
+ "Max fee": [""],
+ "Minimum age": [""],
+ "Wire fee amortization": [""],
+ Auditors: [""],
+ Exchanges: ["Accepterade tjänsteleverantörer:"],
+ "Bank account": ["Övervisa till bank konto"],
+ "Bitcoin address": [""],
+ IBAN: [""],
+ "Could not load deposit status": [""],
+ "Digital cash deposit": [""],
+ Cost: [""],
+ Fee: [""],
+ "To be received": [""],
+ "Send &nbsp; %1$s": [""],
+ "Bitcoin transfer details": [""],
+ "The exchange need a transaction with 3 output, one output is the exchange account and the other two are segwit fake address for metadata with an minimum amount.":
+ [""],
+ "In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional recipient and copy addresses and amounts":
+ [""],
+ "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC":
+ [""],
+ Account: [""],
+ "Bank host": [""],
+ "Bank transfer details": [""],
+ Subject: [""],
+ "Receiver name": [""],
+ "Could not load the transaction information": [""],
+ "There was an error trying to complete the transaction": [""],
+ "This transaction is not completed": [""],
+ Send: [""],
+ Retry: [""],
+ Forget: [""],
+ "Caution!": [""],
+ "If you have already wired money to the exchange you will loose the chance to get the coins form it.":
+ [""],
+ Confirm: ["Bekräfta"],
+ Withdrawal: ["Utbetalnings avgift"],
+ "Make sure to use the correct subject, otherwise the money will not arrive in this wallet.":
+ [""],
+ "The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check there is no pending step.":
+ [""],
+ "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins":
+ [""],
+ Details: [""],
+ Payment: ["Godkän betalning"],
+ Refunds: [""],
+ "%1$s %2$s on %3$s": ["Säljaren %1$sgav en återbetalning på %2$s.\n"],
+ "Merchant created a refund for this order but was not automatically picked up.":
+ [""],
+ Offer: [""],
+ Accept: [""],
+ Merchant: [""],
+ "Invoice ID": [""],
+ Deposit: ["Depostitions avgift"],
+ Refresh: ["Återhämtnings avgift"],
+ Tip: [""],
+ Refund: [""],
+ "Original order ID": [""],
+ "Purchase summary": [""],
+ copy: [""],
+ "hide qr": [""],
+ "show qr": [""],
+ Credit: [""],
+ Invoice: [""],
+ Exchange: [""],
+ URI: [""],
+ Debit: [""],
+ Transfer: [""],
+ Country: [""],
+ "Address lines": [""],
+ "Building number": [""],
+ "Building name": [""],
+ Street: [""],
+ "Post code": [""],
+ "Town location": [""],
+ Town: [""],
+ District: [""],
+ "Country subdivision": [""],
+ Date: [""],
+ "Transaction fees": [""],
+ Total: [""],
+ Withdraw: ["Utbetalnings avgift"],
+ Price: [""],
+ Refunded: [""],
+ Delivery: [""],
+ "Total transfer": ["Utbetalnings avgift"],
+ "Could not load pay status": [""],
+ "Digital cash payment": [""],
+ Purchase: [""],
+ Receipt: [""],
+ "Valid until": [""],
+ "List of products": [""],
+ free: [""],
+ "Already paid, you are going to be redirected to %1$s": [""],
+ "Already paid": [""],
+ "Already claimed": [""],
+ "Pay with a mobile phone": [""],
+ "Hide QR": [""],
+ "Scan the QR code or &nbsp; %1$s": [""],
+ "Pay &nbsp; %1$s": [""],
+ "You have no balance for this currency. Withdraw digital cash first.": [
"",
],
- "Accept fees and withdraw": [""],
- "Cancel withdraw operation": [""],
- "Withdrawal fees:": [""],
- "Rounding loss:": [""],
- "Earliest expiration (for deposit): %1$s": [""],
- "# Coins": [""],
- Value: [""],
- "Withdraw Fee": [""],
- "Refresh Fee": [""],
- "Deposit Fee": [""],
+ "Could not find enough coins to pay. Even if you have enough %1$s some restriction may apply.":
+ [""],
+ "Your current balance is not enough.": [
+ "Du har ingen balans att visa. Behöver du\n %1$s att börja?\n",
+ ],
+ "Merchant message": [""],
+ "Could not load refund status": [""],
+ "Digital cash refund": [""],
+ "You&apos;ve ignored the tip.": [""],
+ "The refund is in progress.": [""],
+ "Total to refund": ["Utbetalnings avgift"],
+ "The merchant &quot;%1$s&quot; is offering you a refund.": [
+ "Säljaren %1$s erbjuder följande:",
+ ],
+ "Order amount": ["Återhämtnings avgift"],
+ "Already refunded": [""],
+ "Refund offered": [""],
+ "Accept &nbsp; %1$s": [""],
+ "Could not load tip status": [""],
+ "Digital cash tip": [""],
+ "The merchant is offering you a tip": [
+ "Säljaren %1$s erbjuder följande:",
+ ],
+ "Merchant URL": [""],
+ "Receive &nbsp; %1$s": [""],
+ "Tip from %1$s accepted. Check your transactions list for more details.":
+ [""],
+ "Select one option": [""],
+ "Could not load": [""],
+ "Show terms of service": [""],
+ "I accept the exchange terms of service": [""],
+ "Exchange doesn&apos;t have terms of service": [""],
+ "Review exchange terms of service": [""],
+ "Review new version of terms of service": [""],
+ "The exchange reply with a empty terms of service": [""],
+ "Download Terms of Service": [""],
+ "Hide terms of service": [""],
+ "Could not load exchange fees": [""],
+ Close: [""],
+ "could not find any exchange": ["Accepterade tjänsteleverantörer:"],
+ "could not find any exchange for the currency %1$s": [""],
+ "Service fee description": [""],
+ "Select %1$s exchange": ["Välj %1$s"],
+ Reset: [""],
+ "Use this exchange": ["Accepterade tjänsteleverantörer:"],
+ "Doesn&apos;t have auditors": [""],
+ currency: [""],
+ Operations: [""],
+ Deposits: ["Depostitions avgift"],
+ Denomination: [""],
+ Until: [""],
+ Withdrawals: ["Utbetalnings avgift"],
+ Currency: [""],
+ "Coin operations": [""],
+ "Every operation in this section may be different by denomination value and is valid for a period of time. The exchange will charge the indicated amount every time a coin is used in such operation.":
+ [""],
+ "Transfer operations": [""],
+ "Every operation in this section may be different by transfer type and is valid for a period of time. The exchange will charge the indicated amount every time a transfer is made.":
+ [""],
+ Operation: [""],
+ "Wallet operations": [""],
+ Feature: [""],
+ "Could not get the info from the URI": [""],
+ "Could not get info of withdrawal": [""],
+ "Digital cash withdrawal": [""],
+ "Could not finish the withdrawal operation": [""],
+ "Age restriction": [""],
+ "Withdraw &nbsp; %1$s": [""],
+ "Withdraw to a mobile phone": [""],
+ "Digital invoice": [""],
+ "Could not finish the invoice creation": [""],
+ Create: [""],
+ "Could not finish the payment operation": [""],
+ "Digital cash transfer": [""],
+ "Could not finish the transfer creation": [""],
+ "Could not finish the pickup operation": [""],
+ "Manual Withdrawal for %1$s": ["Utbetalnings avgift"],
+ "Choose a exchange from where the coins will be withdrawn. The exchange will send the coins to this wallet after receiving a wire transfer with the correct subject.":
+ [""],
+ "No exchange found for %1$s": ["Accepterade tjänsteleverantörer:"],
+ "Add Exchange": ["Accepterade tjänsteleverantörer:"],
+ "No exchange configured": [""],
+ "Can&apos;t create the reserve": [""],
+ "Start withdrawal": [""],
+ "Could not load deposit balance": [""],
+ "A currency or an amount should be indicated": [""],
+ "There is no enough balance to make a deposit for currency %1$s": [""],
+ "Send %1$s to your account": [""],
+ "There is no account to make a deposit for currency %1$s": [""],
+ "Add account": ["Övervisa till bank konto"],
+ "Select account": ["Övervisa till bank konto"],
+ "Add another account": ["Övervisa till bank konto"],
+ "Deposit fee": ["Depostitions avgift"],
+ "Total deposit": [""],
+ "Deposit&nbsp;%1$s %2$s": ["Depostitions avgift"],
+ "Add bank account for %1$s": ["Accepterade tjänsteleverantörer:"],
+ "Enter the URL of an exchange you trust.": [""],
+ "Unable add this account": [""],
+ "Select account type": [""],
+ "Review terms of service": [""],
+ "Exchange URL": [""],
+ "Add exchange": ["Accepterade tjänsteleverantörer:"],
+ "Add new exchange": ["Accepterade tjänsteleverantörer:"],
+ "Add exchange for %1$s": ["Accepterade tjänsteleverantörer:"],
+ "An exchange has been found! Review the information and click next": [""],
+ "This exchange doesn&apos;t match the expected currency %1$s": [""],
+ "Unable to verify this exchange": [""],
+ "Unable to add this exchange": [""],
+ loading: [""],
+ Version: [""],
+ Next: [""],
+ "Waiting for confirmation": [""],
+ PENDING: [""],
+ "Could not load the list of transactions": [""],
+ "Your transaction history is empty for this currency.": [""],
+ "Add backup provider": [""],
+ "Could not get provider information": [""],
+ "Backup providers may charge for their service": [""],
+ URL: [""],
+ Name: [""],
+ "Provider URL": [""],
+ "Please review and accept this provider&apos;s terms of service": [""],
+ Pricing: [""],
+ "free of charge": [""],
+ "%1$s per year of service": [""],
+ Storage: [""],
+ "%1$s megabytes of storage per year of service": [""],
+ "Accept terms of service": [""],
+ "Could not parse the payto URI": [""],
+ "Please check the uri": [""],
+ "Exchange is ready for withdrawal": ["Tjänsteleverantörer i plånboken:"],
+ "To complete the process you need to wire%1$s %2$s to the exchange bank account":
+ [""],
+ "Alternative, you can also scan this QR code or open %1$s if you have a banking app installed that supports RFC 8905":
+ [""],
+ "Cancel withdrawal": [""],
+ "Could not toggle auto-open": [""],
+ "Could not toggle clipboard": [""],
+ Navigator: [""],
+ "Automatically open wallet based on page content": [""],
+ "Enabling this option below will make using the wallet faster, but requires more permissions from your browser.":
+ [""],
+ "Automatically check clipboard for Taler URI": [""],
+ Trust: [""],
+ "No exchange yet": [""],
+ "Term of Service": [""],
+ ok: [""],
+ changed: [""],
+ "not accepted": [""],
+ "unknown (exchange status should be updated)": [""],
+ "Add an exchange": ["Accepterade tjänsteleverantörer:"],
+ Troubleshooting: [""],
+ "Developer mode": [""],
+ "More options and information useful for debugging": [""],
+ Display: [""],
+ "Current Language": [""],
+ "Wallet Core": [""],
+ "Web Extension": [""],
+ "Exchange compatibility": [""],
+ "Merchant compatibility": [""],
+ "Bank compatibility": [""],
+ "Browser Extension Installed!": [""],
+ "You can open the GNU Taler Wallet using the combination %1$s .": [""],
+ "Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick access without keyboard:":
+ [""],
+ "Click the puzzle icon": [""],
+ "Search for GNU Taler Wallet": [""],
+ "Click the pin icon": [""],
+ Permissions: [""],
+ "(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)":
+ [""],
+ "Next Steps": [""],
+ "Try the demo": [""],
+ "Learn how to top up your wallet balance": [""],
+ "Diagnostics timed out. Could not talk to the wallet backend.": [""],
+ "Problems detected:": [""],
+ "Please check in your %1$s settings that you have IndexedDB enabled (check the preference name %2$s).":
+ [""],
+ "Your wallet database is outdated. Currently automatic migration is not supported. Please go %1$s to reset the wallet database.":
+ [""],
+ "Running diagnostics": [""],
+ "Debug tools": [""],
+ "Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL YOUR COINS?":
+ [""],
+ reset: [""],
+ "TESTING: This may delete all your coin, proceed with caution": [""],
+ "run gc": [""],
+ "import database": [""],
+ "export database": [""],
+ "Database exported at %1$s %2$s to download": [""],
+ Coins: ["# Mynt"],
+ "Pending operations": [""],
+ "usable coins": [""],
+ id: [""],
+ denom: [""],
+ value: ["Värde"],
+ status: [""],
+ "from refresh?": [""],
+ "age key count": ["Övervisa till bank konto"],
+ "spent coins": [""],
+ "click to show": [""],
+ "Scan a QR code or enter taler:// URI below": [""],
+ Open: [""],
+ "URI is not valid. Taler URI should start with `taler://`": [""],
+ "Try another": [""],
+ "Could not load list of exchange": [""],
+ "Choose a currency to proceed or add another exchange": [""],
+ "Known currencies": [""],
+ "Specify the amount and the origin": [""],
+ "Change currency": [""],
+ "Use previous origins:": [""],
+ "Or specify the origin of the money": [""],
+ "Specify the origin of the money": [""],
+ "From my bank account": ["Övervisa till bank konto"],
+ "From another wallet": [""],
+ "currency not provided": [""],
+ "Specify the amount and the destination": [""],
+ "Use previous destinations:": [""],
+ "Or specify the destination of the money": [""],
+ "Specify the destination of the money": [""],
+ "To my bank account": ["Övervisa till bank konto"],
+ "To another wallet": [""],
+ "Could not load backup recovery information": [""],
+ "Digital wallet recovery": [""],
+ "Import backup, show info": [""],
+ "All done, your transaction is in progress": [""],
+ Edit: [""],
+ "Could not load the list of known exchanges": [""],
},
},
};
-strings["sv"] = {
+strings["tr"] = {
domain: "messages",
locale_data: {
messages: {
"": {
domain: "messages",
- plural_forms: "nplurals=2; plural=(n != 1);",
- lang: "",
+ plural_forms: "nplurals=2; plural=n != 1;",
+ lang: "tr",
},
- "Invalid Wire": [""],
- "Invalid Test Wire Detail": [""],
- "Test Wire Acct #%1$s on %2$s": [""],
- "Unknown Wire Detail": ["visa mer"],
- Operation: [""],
- "time (ms/op)": [""],
- "The merchant %1$s offers you to purchase:": [
- "Säljaren %1$s erbjuder följande:",
+ Balance: ["Bakiye"],
+ Backup: ["Yedekle"],
+ "QR Reader and Taler URI": [""],
+ Settings: ["Ayarlar"],
+ Dev: ["Gelişim"],
+ "%1$s": ["%1$s"],
+ "PENDING OPERATIONS": [""],
+ Loading: ["Yükleniyor"],
+ "Could not load backup providers": [
+ "Yedekleme sağlayıcıları yüklenemedi",
],
- "The total price is %1$s (plus %2$s fees).": [
- "Det totala priset är %1$s (plus %2$s avgifter).\n",
+ "No backup providers configured": [
+ "Yapılandırılmış yedekleme sağlayıcısı yok",
],
- "The total price is %1$s.": ["Det totala priset är %1$s."],
- Retry: [""],
- "Confirm payment": ["Godkän betalning"],
- Balance: ["Balans"],
- History: ["Historia"],
- Debug: [""],
- "You have no balance to show. Need some %1$s getting started?": [
- "Du har ingen balans att visa. Behöver du\n %1$s att börja?\n",
+ "Add provider": ["Sağlayıcı ekle"],
+ "Sync all backups": ["Tüm yedeklemeleri senkronize et"],
+ "Sync now": ["Şimdi senkronize et"],
+ "Last synced": ["Son Senkronizasyon"],
+ "Not synced": ["Senkronize Edilmedi"],
+ "Expires in": ["İçinde sona eriyor"],
+ "There was an error loading the provider detail for &quot; %1$s&quot;": [
+ "",
],
- "%1$s incoming": ["%1$s inkommande"],
- "%1$s being spent": [""],
- "Error: could not retrieve balance information.": [""],
- "Invalid ": [""],
- "Fees ": [""],
- "Refresh sessions has completed": [""],
- "Order Refused": [""],
- "Order redirected": [""],
- "Payment aborted": [""],
- "Payment Sent": [""],
- "Order accepted": [""],
- "Reserve balance updated": [""],
- "Payment refund": [""],
- Withdrawn: ["Utbetalnings avgift"],
- "Tip Accepted": [""],
- "Tip Declined": [""],
- "%1$s": [""],
- "Your wallet has no events recorded.": ["plånboken"],
- "Wire to bank account": ["Övervisa till bank konto"],
- Confirm: ["Bekräfta"],
- Cancel: ["Avbryt"],
- "Could not get details for withdraw operation:": [""],
- "Chose different exchange provider": ["Ändra tjänsteleverantörer"],
- "Please select an exchange. You can review the details before after your selection.": [
+ "There is not known provider with url &quot;%1$s&quot;.": [""],
+ "See providers": ["Sağlayıcı ekle"],
+ "Last backup": [""],
+ "Back up": [""],
+ "Provider fee": [""],
+ "per year": [""],
+ Extend: [""],
+ "terms has changed, extending the service will imply accepting the new terms of service":
+ [""],
+ old: [""],
+ new: [""],
+ fee: [""],
+ storage: [""],
+ "Remove provider": [""],
+ "This provider has reported an error": [""],
+ "There is conflict with another backup from %1$s": [""],
+ "Backup is not readable": [""],
+ "Unknown backup problem: %1$s": [""],
+ "service paid": [""],
+ "Backup valid until": [""],
+ Cancel: ["Bakiye"],
+ "Open reserve page": ["Rezerv sayfasını açın"],
+ "Open pay page": ["Ödeme sayfasını açın"],
+ "Open refund page": ["Geri ödeme sayfasını açın"],
+ "Open tip page": ["İkramiye sayfasını açın"],
+ "Open withdraw page": ["Para çekme sayfasını açın"],
+ "Get digital cash": [""],
+ "Could not load balance page": ["Bakiye sayfası yüklenemedi"],
+ Add: [""],
+ "Send %1$s": ["%1$s seçin"],
+ "Taler Action": ["Taler Eylemi"],
+ "This page has pay action.": ["Bu sayfada ödeme eylemi var."],
+ "This page has a withdrawal action.": [
+ "Bu sayfada para çekme eylemi var.",
+ ],
+ "This page has a tip action.": ["Bu sayfada bir ikramiye eylemi var."],
+ "This page has a notify reserve action.": [
+ "Bu sayfada bir rezervasyon bildir eylemi var.",
+ ],
+ Notify: ["Bildirin"],
+ "This page has a refund action.": [
+ "Bu sayfada bir geri ödeme eylemi var.",
+ ],
+ "This page has a malformed taler uri.": [
+ "Bu sayfada hatalı biçimlendirilmiş taler uri var.",
+ ],
+ Dismiss: ["Reddet"],
+ "this popup is being closed and you are being redirected to %1$s": [""],
+ "Could not load purchase proposal details": [
+ "Yedekleme sağlayıcıları yüklenemedi",
+ ],
+ "Order Id": ["Sipariş reddedildi"],
+ Summary: [""],
+ Amount: [""],
+ "Merchant name": [""],
+ "Merchant jurisdiction": [""],
+ "Merchant address": [""],
+ "Merchant logo": [""],
+ "Merchant website": [""],
+ "Merchant email": [""],
+ "Merchant public key": [""],
+ "Delivery date": [""],
+ "Delivery location": ["Taler Eylemi"],
+ Products: [""],
+ "Created at": [""],
+ "Refund deadline": [""],
+ "Auto refund": ["Ödeme iadesi"],
+ "Pay deadline": [""],
+ "Fulfillment URL": [""],
+ "Fulfillment message": [""],
+ "Max deposit fee": [""],
+ "Max fee": [""],
+ "Minimum age": [""],
+ "Wire fee amortization": [""],
+ Auditors: [""],
+ Exchanges: ["Exchange"],
+ "Bank account": [""],
+ "Bitcoin address": [""],
+ IBAN: [""],
+ "Could not load deposit status": ["Bakiye sayfası yüklenemedi"],
+ "Digital cash deposit": [""],
+ Cost: [""],
+ Fee: ["Ücretler"],
+ "To be received": [""],
+ "Send &nbsp; %1$s": [""],
+ "Bitcoin transfer details": [""],
+ "The exchange need a transaction with 3 output, one output is the exchange account and the other two are segwit fake address for metadata with an minimum amount.":
+ [""],
+ "In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional recipient and copy addresses and amounts":
+ [""],
+ "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC":
+ [""],
+ Account: [""],
+ "Bank host": [""],
+ "Bank transfer details": [""],
+ Subject: [""],
+ "Receiver name": [""],
+ "Could not load the transaction information": [""],
+ "There was an error trying to complete the transaction": [""],
+ "This transaction is not completed": [""],
+ Send: [""],
+ Retry: ["Yeniden deneyin"],
+ Forget: [""],
+ "Caution!": [""],
+ "If you have already wired money to the exchange you will loose the chance to get the coins form it.":
+ [""],
+ Confirm: ["Onaylamak"],
+ Withdrawal: ["Çekildi"],
+ "Make sure to use the correct subject, otherwise the money will not arrive in this wallet.":
+ [""],
+ "The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check there is no pending step.":
+ [""],
+ "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins":
+ [""],
+ Details: [""],
+ Payment: ["Ödeme gönderildi"],
+ Refunds: [""],
+ "%1$s %2$s on %3$s": [""],
+ "Merchant created a refund for this order but was not automatically picked up.":
+ [""],
+ Offer: [""],
+ Accept: ["İkramiye kabul edildi"],
+ Merchant: [""],
+ "Invoice ID": [""],
+ Deposit: [""],
+ Refresh: ["Ücreti yenile"],
+ Tip: [""],
+ Refund: [""],
+ "Original order ID": [""],
+ "Purchase summary": [""],
+ copy: [""],
+ "hide qr": [""],
+ "show qr": [""],
+ Credit: [""],
+ Invoice: [""],
+ Exchange: ["Exchange"],
+ URI: [""],
+ Debit: [""],
+ Transfer: [""],
+ Country: [""],
+ "Address lines": [""],
+ "Building number": [""],
+ "Building name": [""],
+ Street: [""],
+ "Post code": [""],
+ "Town location": [""],
+ Town: [""],
+ District: [""],
+ "Country subdivision": [""],
+ Date: [""],
+ "Transaction fees": [""],
+ Total: [""],
+ Withdraw: ["Para çek"],
+ Price: [""],
+ Refunded: [""],
+ Delivery: [""],
+ "Total transfer": ["Çekildi"],
+ "Could not load pay status": [""],
+ "Digital cash payment": [""],
+ Purchase: [""],
+ Receipt: [""],
+ "Valid until": [""],
+ "List of products": [""],
+ free: [""],
+ "Already paid, you are going to be redirected to %1$s": [""],
+ "Already paid": [""],
+ "Already claimed": [""],
+ "Pay with a mobile phone": [""],
+ "Hide QR": [""],
+ "Scan the QR code or &nbsp; %1$s": [""],
+ "Pay &nbsp; %1$s": [""],
+ "You have no balance for this currency. Withdraw digital cash first.": [
"",
],
- "Select %1$s": ["Välj %1$s"],
- "Select custom exchange": [""],
- "You are about to withdraw %1$s from your bank account into your wallet.": [
- "Du är på väg att ta ut\n %1$s från ditt bankkonto till din plånbok.\n",
- ],
- "Accept fees and withdraw": ["Acceptera avgifter och utbetala"],
- "Cancel withdraw operation": [""],
- "Withdrawal fees:": ["Utbetalnings avgifter:"],
- "Rounding loss:": [""],
- "Earliest expiration (for deposit): %1$s": [""],
- "# Coins": ["# Mynt"],
- Value: ["Värde"],
- "Withdraw Fee": ["Utbetalnings avgift"],
- "Refresh Fee": ["Återhämtnings avgift"],
- "Deposit Fee": ["Depostitions avgift"],
+ "Could not find enough coins to pay. Even if you have enough %1$s some restriction may apply.":
+ [""],
+ "Your current balance is not enough.": ["Gösterecek bakiyeniz yok."],
+ "Merchant message": [""],
+ "Could not load refund status": ["Bakiye sayfası yüklenemedi"],
+ "Digital cash refund": [""],
+ "You&apos;ve ignored the tip.": [""],
+ "The refund is in progress.": [""],
+ "Total to refund": ["Ödeme iadesi"],
+ "The merchant &quot;%1$s&quot; is offering you a refund.": [""],
+ "Order amount": ["Ücreti yenile"],
+ "Already refunded": ["Ödeme iadesi"],
+ "Refund offered": [""],
+ "Accept &nbsp; %1$s": [""],
+ "Could not load tip status": ["Bakiye sayfası yüklenemedi"],
+ "Digital cash tip": [""],
+ "The merchant is offering you a tip": [""],
+ "Merchant URL": [""],
+ "Receive &nbsp; %1$s": [""],
+ "Tip from %1$s accepted. Check your transactions list for more details.":
+ [""],
+ "Select one option": [""],
+ "Could not load": ["Bakiye sayfası yüklenemedi"],
+ "Show terms of service": ["Hizmet şartlarını göster"],
+ "I accept the exchange terms of service": [
+ "Hizmet şartlarını kabul ediyorum",
+ ],
+ "Exchange doesn&apos;t have terms of service": [
+ "Exchange'in hizmet şartları yok",
+ ],
+ "Review exchange terms of service": [
+ "Exchange'in hizmet şartlarını inceleyin",
+ ],
+ "Review new version of terms of service": [
+ "Hizmet şartlarının yeni sürümünü inceleyin",
+ ],
+ "The exchange reply with a empty terms of service": [""],
+ "Download Terms of Service": [""],
+ "Hide terms of service": [""],
+ "Could not load exchange fees": ["Bakiye sayfası yüklenemedi"],
+ Close: [""],
+ "could not find any exchange": ["Bakiye sayfası yüklenemedi"],
+ "could not find any exchange for the currency %1$s": [""],
+ "Service fee description": [""],
+ "Select %1$s exchange": ["Özel exchange'i seçin"],
+ Reset: [""],
+ "Use this exchange": ["Özel exchange'i seçin"],
+ "Doesn&apos;t have auditors": [""],
+ currency: [""],
+ Operations: ["Bekleyen işlemler"],
+ Deposits: ["Depozito %1$s"],
+ Denomination: ["Bekleyen işlemler"],
+ Until: [""],
+ Withdrawals: ["Çekildi"],
+ Currency: [""],
+ "Coin operations": ["Bekleyen işlemler"],
+ "Every operation in this section may be different by denomination value and is valid for a period of time. The exchange will charge the indicated amount every time a coin is used in such operation.":
+ [""],
+ "Transfer operations": ["Bekleyen işlemler"],
+ "Every operation in this section may be different by transfer type and is valid for a period of time. The exchange will charge the indicated amount every time a transfer is made.":
+ [""],
+ Operation: [""],
+ "Wallet operations": ["Bekleyen işlemler"],
+ Feature: [""],
+ "Could not get the info from the URI": [""],
+ "Could not get info of withdrawal": [
+ "Para çekme işlemi için ayrıntılar alınamadı:",
+ ],
+ "Digital cash withdrawal": [""],
+ "Could not finish the withdrawal operation": [""],
+ "Age restriction": [""],
+ "Withdraw &nbsp; %1$s": [""],
+ "Withdraw to a mobile phone": [""],
+ "Digital invoice": [""],
+ "Could not finish the invoice creation": [""],
+ Create: [""],
+ "Could not finish the payment operation": [
+ "Para çekme işlemi için ayrıntılar alınamadı:",
+ ],
+ "Digital cash transfer": [""],
+ "Could not finish the transfer creation": [
+ "Para çekme işlemi için ayrıntılar alınamadı:",
+ ],
+ "Could not finish the pickup operation": [
+ "Para çekme işlemi için ayrıntılar alınamadı:",
+ ],
+ "Manual Withdrawal for %1$s": ["Para çekme ücretleri:"],
+ "Choose a exchange from where the coins will be withdrawn. The exchange will send the coins to this wallet after receiving a wire transfer with the correct subject.":
+ [""],
+ "No exchange found for %1$s": [""],
+ "Add Exchange": [""],
+ "No exchange configured": [""],
+ "Can&apos;t create the reserve": [""],
+ "Start withdrawal": [""],
+ "Could not load deposit balance": ["Bakiye sayfası yüklenemedi"],
+ "A currency or an amount should be indicated": [""],
+ "There is no enough balance to make a deposit for currency %1$s": [""],
+ "Send %1$s to your account": [""],
+ "There is no account to make a deposit for currency %1$s": [""],
+ "Add account": [""],
+ "Select account": [""],
+ "Add another account": [""],
+ "Deposit fee": [""],
+ "Total deposit": [""],
+ "Deposit&nbsp;%1$s %2$s": ["Test Havale Hesap #%1$s üzerinde %2$s"],
+ "Add bank account for %1$s": [""],
+ "Enter the URL of an exchange you trust.": [""],
+ "Unable add this account": [""],
+ "Select account type": [""],
+ "Review terms of service": [""],
+ "Exchange URL": [""],
+ "Add exchange": [""],
+ "Add new exchange": [""],
+ "Add exchange for %1$s": [""],
+ "An exchange has been found! Review the information and click next": [""],
+ "This exchange doesn&apos;t match the expected currency %1$s": [""],
+ "Unable to verify this exchange": [""],
+ "Unable to add this exchange": [""],
+ loading: [""],
+ Version: [""],
+ Next: [""],
+ "Waiting for confirmation": [""],
+ PENDING: [""],
+ "Could not load the list of transactions": [""],
+ "Your transaction history is empty for this currency.": [""],
+ "Add backup provider": [""],
+ "Could not get provider information": [""],
+ "Backup providers may charge for their service": [""],
+ URL: [""],
+ Name: [""],
+ "Provider URL": [""],
+ "Please review and accept this provider&apos;s terms of service": [
+ "Hizmet şartlarını kabul ediyorum",
+ ],
+ Pricing: [""],
+ "free of charge": [""],
+ "%1$s per year of service": [""],
+ Storage: [""],
+ "%1$s megabytes of storage per year of service": [""],
+ "Accept terms of service": [""],
+ "Could not parse the payto URI": [""],
+ "Please check the uri": [""],
+ "Exchange is ready for withdrawal": [""],
+ "To complete the process you need to wire%1$s %2$s to the exchange bank account":
+ [""],
+ "Alternative, you can also scan this QR code or open %1$s if you have a banking app installed that supports RFC 8905":
+ [""],
+ "Cancel withdrawal": [""],
+ "Could not toggle auto-open": ["Bakiye sayfası yüklenemedi"],
+ "Could not toggle clipboard": ["Bakiye sayfası yüklenemedi"],
+ Navigator: [""],
+ "Automatically open wallet based on page content": [""],
+ "Enabling this option below will make using the wallet faster, but requires more permissions from your browser.":
+ [""],
+ "Automatically check clipboard for Taler URI": [""],
+ Trust: [""],
+ "No exchange yet": [""],
+ "Term of Service": [""],
+ ok: [""],
+ changed: [""],
+ "not accepted": [""],
+ "unknown (exchange status should be updated)": [""],
+ "Add an exchange": [""],
+ Troubleshooting: [""],
+ "Developer mode": [""],
+ "More options and information useful for debugging": [""],
+ Display: [""],
+ "Current Language": [""],
+ "Wallet Core": [""],
+ "Web Extension": [""],
+ "Exchange compatibility": [""],
+ "Merchant compatibility": [""],
+ "Bank compatibility": [""],
+ "Browser Extension Installed!": [""],
+ "You can open the GNU Taler Wallet using the combination %1$s .": [""],
+ "Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick access without keyboard:":
+ [""],
+ "Click the puzzle icon": [""],
+ "Search for GNU Taler Wallet": [""],
+ "Click the pin icon": [""],
+ Permissions: [""],
+ "(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)":
+ [""],
+ "Next Steps": [""],
+ "Try the demo": [""],
+ "Learn how to top up your wallet balance": [""],
+ "Diagnostics timed out. Could not talk to the wallet backend.": [
+ "Tanılar zaman aşımına uğradı. Cüzdan arka ucuyla konuşulamadı.",
+ ],
+ "Problems detected:": ["Tespit edilen sorunlar:"],
+ "Please check in your %1$s settings that you have IndexedDB enabled (check the preference name %2$s).":
+ [
+ "Lütfen %1$s ayarlarınızda, IndexedDB'nin etkinleştirildiğinizi kontrol edin (%2$s tercih adını kontrol edin).",
+ ],
+ "Your wallet database is outdated. Currently automatic migration is not supported. Please go %1$s to reset the wallet database.":
+ [
+ "Cüzdan veritabanınız eski. Şu anda otomatik aktarım desteklenmiyor. Cüzdan veritabanını sıfırlamak için lütfen %1$s gidin.",
+ ],
+ "Running diagnostics": ["Tanılamayı çalıştır"],
+ "Debug tools": ["Hata ayıklama araçları"],
+ "Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL YOUR COINS?":
+ [
+ "Cüzdanınızdaki her şeyi GERİ ALINAMAZ BİÇİMDE İMHA ETMEK ve TÜM PARALARINIZI KAYBETMEK mi istiyorsunuz?",
+ ],
+ reset: ["sıfırla"],
+ "TESTING: This may delete all your coin, proceed with caution": [""],
+ "run gc": [""],
+ "import database": ["veritabanını içe aktar"],
+ "export database": ["veritabanını dışa aktar"],
+ "Database exported at %1$s %2$s to download": [
+ "Veritabanı %1$s'de dışa aktarıldı-%2$s indirilecek",
+ ],
+ Coins: ["Madeni paralar"],
+ "Pending operations": ["Bekleyen işlemler"],
+ "usable coins": ["kullanılabilir madeni paralar"],
+ id: ["kimlik"],
+ denom: [""],
+ value: ["değer"],
+ status: ["durum"],
+ "from refresh?": ["yenilemeden mi?"],
+ "age key count": [""],
+ "spent coins": ["harcanan madeni paralar"],
+ "click to show": ["göstermek için tıklayın"],
+ "Scan a QR code or enter taler:// URI below": [""],
+ Open: [""],
+ "URI is not valid. Taler URI should start with `taler://`": [""],
+ "Try another": [""],
+ "Could not load list of exchange": ["Bakiye sayfası yüklenemedi"],
+ "Choose a currency to proceed or add another exchange": [""],
+ "Known currencies": [""],
+ "Specify the amount and the origin": [""],
+ "Change currency": [""],
+ "Use previous origins:": [""],
+ "Or specify the origin of the money": [""],
+ "Specify the origin of the money": [""],
+ "From my bank account": ["Banka hesabına havale yap"],
+ "From another wallet": [""],
+ "currency not provided": [""],
+ "Specify the amount and the destination": [""],
+ "Use previous destinations:": [""],
+ "Or specify the destination of the money": [""],
+ "Specify the destination of the money": [""],
+ "To my bank account": ["Banka hesabına havale yap"],
+ "To another wallet": [""],
+ "Could not load backup recovery information": [
+ "Yedekleme sağlayıcıları yüklenemedi",
+ ],
+ "Digital wallet recovery": [""],
+ "Import backup, show info": [""],
+ "All done, your transaction is in progress": [""],
+ Edit: [
+ "Lütfen bir exchange seçin. Detayları seçiminizden önce inceleyebilirsiniz.",
+ ],
+ "Could not load the list of known exchanges": [""],
},
},
};
diff --git a/packages/taler-wallet-webextension/src/i18n/sv.po b/packages/taler-wallet-webextension/src/i18n/sv.po
index c6a739789..bb3caea4b 100644
--- a/packages/taler-wallet-webextension/src/i18n/sv.po
+++ b/packages/taler-wallet-webextension/src/i18n/sv.po
@@ -1,351 +1,2061 @@
-# This file is part of TALER
-# (C) 2016 GNUnet e.V.
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
#
-# TALER is free software; you can redistribute it and/or modify it under the
+# GNU Taler is free software; you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation; either version 3, or (at your option) any later version.
#
-# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
-# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
#
-#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Taler Wallet\n"
-"Report-Msgid-Bugs-To: \n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
-"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
-"Last-Translator: Flo Reitz <EMAIL@ADDRESS>\n"
-"Language-Team: LANGUAGE <LL@li.org>\n"
-"Language: \n"
+"PO-Revision-Date: 2023-03-06 22:06+0000\n"
+"Last-Translator: Anonymous <noreply@weblate.org>\n"
+"Language-Team: Swedish <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/sv/>\n"
+"Language: sv\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 4.13.1\n"
+
+#: src/NavigationBar.tsx:139
+#, c-format
+msgid "Balance"
+msgstr "Balans"
+
+#: src/NavigationBar.tsx:142
+#, c-format
+msgid "Backup"
+msgstr ""
+
+#: src/NavigationBar.tsx:147
+#, c-format
+msgid "QR Reader and Taler URI"
+msgstr ""
+
+#: src/NavigationBar.tsx:154
+#, c-format
+msgid "Settings"
+msgstr ""
-#: src/util/wire.ts:37
+#: src/NavigationBar.tsx:184
#, c-format
-msgid "Invalid Wire"
+msgid "Dev"
+msgstr ""
+
+#: src/mui/Typography.tsx:122
+#, c-format
+msgid "%1$s"
msgstr ""
-#: src/util/wire.ts:42 src/util/wire.ts:45
+#: src/components/PendingTransactions.tsx:74
#, c-format
-msgid "Invalid Test Wire Detail"
+msgid "PENDING OPERATIONS"
msgstr ""
-#: src/util/wire.ts:47
+#: src/components/Loading.tsx:36
#, c-format
-msgid "Test Wire Acct #%1$s on %2$s"
+msgid "Loading"
msgstr ""
-#: src/util/wire.ts:49
+#: src/wallet/BackupPage.tsx:123
+#, c-format
+msgid "Could not load backup providers"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:202
+#, c-format
+msgid "No backup providers configured"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:205
+#, c-format
+msgid "Add provider"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:219
+#, c-format
+msgid "Sync all backups"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:221
+#, c-format
+msgid "Sync now"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:264
+#, c-format
+msgid "Last synced"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:269
+#, c-format
+msgid "Not synced"
+msgstr ""
+
+#: src/wallet/BackupPage.tsx:289
+#, c-format
+msgid "Expires in"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:60
+#, c-format
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:108
+#, c-format
+msgid "There is not known provider with url &quot;%1$s&quot;."
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:115
+#, c-format
+msgid "See providers"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:143
+#, c-format
+msgid "Last backup"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:148
+#, c-format
+msgid "Back up"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:154
+#, c-format
+msgid "Provider fee"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:157
+#, c-format
+msgid "per year"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:163
+#, c-format
+msgid "Extend"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:169
+#, c-format
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms "
+"of service"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:179
+#, c-format
+msgid "old"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:183
+#, c-format
+msgid "new"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:190
+#, c-format
+msgid "fee"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:198
+#, c-format
+msgid "storage"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:215
+#, c-format
+msgid "Remove provider"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:228
+#, c-format
+msgid "This provider has reported an error"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:242
+#, c-format
+msgid "There is conflict with another backup from %1$s"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:253
+#, c-format
+msgid "Backup is not readable"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:261
+#, c-format
+msgid "Unknown backup problem: %1$s"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:283
+#, c-format
+msgid "service paid"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format
+msgid "Backup valid until"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:57
+#, c-format
+msgid "Cancel"
+msgstr "Avbryt"
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:76
+#, fuzzy, c-format
+msgid "Open withdraw page"
+msgstr "Utbetalnings avgift"
+
+#: src/popup/NoBalanceHelp.tsx:43
#, fuzzy, c-format
-msgid "Unknown Wire Detail"
-msgstr "visa mer"
+msgid "Get digital cash"
+msgstr "Utbetalnings avgifter:"
-#: src/webex/pages/benchmark.tsx:52
+#: src/popup/BalancePage.tsx:138
#, c-format
-msgid "Operation"
+msgid "Could not load balance page"
msgstr ""
-#: src/webex/pages/benchmark.tsx:53
+#: src/popup/BalancePage.tsx:175
#, c-format
-msgid "time (ms/op)"
+msgid "Add"
msgstr ""
-#: src/webex/pages/pay.tsx:130
+#: src/popup/BalancePage.tsx:179
#, fuzzy, c-format
-msgid "The merchant %1$s offers you to purchase:"
-msgstr "Säljaren %1$s erbjuder följande:"
+msgid "Send %1$s"
+msgstr "Välj %1$s"
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr ""
-#: src/webex/pages/pay.tsx:136
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr ""
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, c-format
+msgid "Could not load purchase proposal details"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, c-format
+msgid "Order Id"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, fuzzy, c-format
+msgid "Max deposit fee"
+msgstr "Depostitions avgift"
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:419
#, fuzzy, c-format
-msgid "The total price is %1$s (plus %2$s fees)."
-msgstr "Det totala priset är %1$s (plus %2$s avgifter).\n"
+msgid "Exchanges"
+msgstr "Accepterade tjänsteleverantörer:"
-#: src/webex/pages/pay.tsx:141
+#: src/components/Part.tsx:148
#, fuzzy, c-format
-msgid "The total price is %1$s."
-msgstr "Det totala priset är %1$s."
+msgid "Bank account"
+msgstr "Övervisa till bank konto"
+
+#: src/components/Part.tsx:160
+#, c-format
+msgid "Bitcoin address"
+msgstr ""
+
+#: src/components/Part.tsx:163
+#, c-format
+msgid "IBAN"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:38
+#, c-format
+msgid "Could not load deposit status"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr ""
-#: src/webex/pages/pay.tsx:163
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an "
+"minimum amount."
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two "
+"additional recipient and copy addresses and amounts"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid ""
+"Make sure the amount show %1$s BTC, else you have to change the base unit to "
+"BTC"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:216
#, c-format
msgid "Retry"
msgstr ""
-#: src/webex/pages/pay.tsx:173
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to "
+"get the coins form it."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr "Bekräfta"
+
+#: src/wallet/Transaction.tsx:267
+#, fuzzy, c-format
+msgid "Withdrawal"
+msgstr "Utbetalnings avgift"
+
+#: src/wallet/Transaction.tsx:286
+#, c-format
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and "
+"check there is no pending step."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid ""
+"Bank has confirmed the wire transfer. Waiting for the exchange to send the "
+"coins"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:325
#, c-format
-msgid "Confirm payment"
+msgid "Details"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:360
+#, fuzzy, c-format
+msgid "Payment"
msgstr "Godkän betalning"
-#: src/webex/pages/popup.tsx:153
+#: src/wallet/Transaction.tsx:378
#, c-format
-msgid "Balance"
-msgstr "Balans"
+msgid "Refunds"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:385
+#, fuzzy, c-format
+msgid "%1$s %2$s on %3$s"
+msgstr "Säljaren %1$sgav en återbetalning på %2$s.\n"
+
+#: src/wallet/Transaction.tsx:415
+#, c-format
+msgid ""
+"Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:431
+#, c-format
+msgid "Accept"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:470
+#, fuzzy, c-format
+msgid "Deposit"
+msgstr "Depostitions avgift"
+
+#: src/wallet/Transaction.tsx:496
+#, fuzzy, c-format
+msgid "Refresh"
+msgstr "Återhämtnings avgift"
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1074
+#, fuzzy, c-format
+msgid "Withdraw"
+msgstr "Utbetalnings avgift"
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1335
+#, fuzzy, c-format
+msgid "Total transfer"
+msgstr "Utbetalnings avgift"
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr ""
-#: src/webex/pages/popup.tsx:154
+#: src/cta/Payment/views.tsx:280
#, c-format
-msgid "History"
-msgstr "Historia"
+msgid "Already claimed"
+msgstr ""
-#: src/webex/pages/popup.tsx:155
+#: src/cta/Payment/views.tsx:296
#, c-format
-msgid "Debug"
+msgid "Pay with a mobile phone"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:364
+#, c-format
+msgid ""
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
msgstr ""
-#: src/webex/pages/popup.tsx:175
+#: src/cta/Payment/views.tsx:366
#, fuzzy, c-format
-msgid "You have no balance to show. Need some %1$s getting started?"
+msgid "Your current balance is not enough."
msgstr ""
"Du har ingen balans att visa. Behöver du\n"
" %1$s att börja?\n"
-#: src/webex/pages/popup.tsx:238
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:34
+#, c-format
+msgid "Could not load refund status"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:76
+#, fuzzy, c-format
+msgid "Total to refund"
+msgstr "Utbetalnings avgift"
+
+#: src/cta/Refund/views.tsx:106
+#, fuzzy, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr "Säljaren %1$s erbjuder följande:"
+
+#: src/cta/Refund/views.tsx:115
+#, fuzzy, c-format
+msgid "Order amount"
+msgstr "Återhämtnings avgift"
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:32
+#, c-format
+msgid "Could not load tip status"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:66
+#, fuzzy, c-format
+msgid "The merchant is offering you a tip"
+msgstr "Säljaren %1$s erbjuder följande:"
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:90
+#, c-format
+msgid "Receive &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:114
+#, c-format
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
+msgstr ""
+
+#: src/components/SelectList.tsx:66
+#, c-format
+msgid "Select one option"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:39
+#, c-format
+msgid "Could not load"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:107
+#, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:170
+#, c-format
+msgid "The exchange reply with a empty terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, c-format
+msgid "Could not load exchange fees"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, fuzzy, c-format
+msgid "could not find any exchange"
+msgstr "Accepterade tjänsteleverantörer:"
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, fuzzy, c-format
+msgid "Select %1$s exchange"
+msgstr "Välj %1$s"
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, fuzzy, c-format
+msgid "Use this exchange"
+msgstr "Accepterade tjänsteleverantörer:"
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:252
#, fuzzy, c-format
-msgid "%1$s incoming"
-msgstr "%1$s inkommande"
+msgid "Deposits"
+msgstr "Depostitions avgift"
-#: src/webex/pages/popup.tsx:250
+#: src/wallet/ExchangeSelection/views.tsx:259
#, c-format
-msgid "%1$s being spent"
+msgid "Denomination"
msgstr ""
-#: src/webex/pages/popup.tsx:281
+#: src/wallet/ExchangeSelection/views.tsx:265
#, c-format
-msgid "Error: could not retrieve balance information."
+msgid "Until"
msgstr ""
-#: src/webex/pages/popup.tsx:390
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, fuzzy, c-format
+msgid "Withdrawals"
+msgstr "Utbetalnings avgift"
+
+#: src/wallet/ExchangeSelection/views.tsx:423
#, c-format
-msgid "Invalid "
+msgid "Currency"
msgstr ""
-#: src/webex/pages/popup.tsx:396
+#: src/wallet/ExchangeSelection/views.tsx:433
#, c-format
-msgid "Fees "
+msgid "Coin operations"
msgstr ""
-#: src/webex/pages/popup.tsx:434
+#: src/wallet/ExchangeSelection/views.tsx:436
#, c-format
-msgid "Refresh sessions has completed"
+msgid ""
+"Every operation in this section may be different by denomination value and "
+"is valid for a period of time. The exchange will charge the indicated amount "
+"every time a coin is used in such operation."
msgstr ""
-#: src/webex/pages/popup.tsx:451
+#: src/wallet/ExchangeSelection/views.tsx:545
#, c-format
-msgid "Order Refused"
+msgid "Transfer operations"
msgstr ""
-#: src/webex/pages/popup.tsx:465
+#: src/wallet/ExchangeSelection/views.tsx:548
#, c-format
-msgid "Order redirected"
+msgid ""
+"Every operation in this section may be different by transfer type and is "
+"valid for a period of time. The exchange will charge the indicated amount "
+"every time a transfer is made."
msgstr ""
-#: src/webex/pages/popup.tsx:482
+#: src/wallet/ExchangeSelection/views.tsx:563
#, c-format
-msgid "Payment aborted"
+msgid "Operation"
msgstr ""
-#: src/webex/pages/popup.tsx:512
+#: src/wallet/ExchangeSelection/views.tsx:583
#, c-format
-msgid "Payment Sent"
+msgid "Wallet operations"
msgstr ""
-#: src/webex/pages/popup.tsx:536
+#: src/wallet/ExchangeSelection/views.tsx:597
#, c-format
-msgid "Order accepted"
+msgid "Feature"
msgstr ""
-#: src/webex/pages/popup.tsx:547
+#: src/cta/Withdraw/views.tsx:47
#, c-format
-msgid "Reserve balance updated"
+msgid "Could not get the info from the URI"
msgstr ""
-#: src/webex/pages/popup.tsx:559
+#: src/cta/Withdraw/views.tsx:60
#, c-format
-msgid "Payment refund"
+msgid "Could not get info of withdrawal"
msgstr ""
-#: src/webex/pages/popup.tsx:584
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/cta/InvoicePay/views.tsx:63
+#, c-format
+msgid "Could not finish the payment operation"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr ""
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:149
#, fuzzy, c-format
-msgid "Withdrawn"
+msgid "Manual Withdrawal for %1$s"
msgstr "Utbetalnings avgift"
-#: src/webex/pages/popup.tsx:596
+#: src/wallet/CreateManualWithdraw.tsx:154
#, c-format
-msgid "Tip Accepted"
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will "
+"send the coins to this wallet after receiving a wire transfer with the "
+"correct subject."
msgstr ""
-#: src/webex/pages/popup.tsx:606
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, fuzzy, c-format
+msgid "No exchange found for %1$s"
+msgstr "Accepterade tjänsteleverantörer:"
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, fuzzy, c-format
+msgid "Add Exchange"
+msgstr "Accepterade tjänsteleverantörer:"
+
+#: src/wallet/CreateManualWithdraw.tsx:192
#, c-format
-msgid "Tip Declined"
+msgid "No exchange configured"
msgstr ""
-#: src/webex/pages/popup.tsx:615
+#: src/wallet/CreateManualWithdraw.tsx:210
#, c-format
-msgid "%1$s"
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:38
+#, c-format
+msgid "Could not load deposit balance"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
msgstr ""
-#: src/webex/pages/popup.tsx:707
+#: src/wallet/DepositPage/views.tsx:117
#, c-format
-msgid "Your wallet has no events recorded."
-msgstr "plånboken"
+msgid "Send %1$s to your account"
+msgstr ""
-#: src/webex/pages/return-coins.tsx:124
+#: src/wallet/DepositPage/views.tsx:121
#, c-format
-msgid "Wire to bank account"
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, fuzzy, c-format
+msgid "Add account"
+msgstr "Övervisa till bank konto"
+
+#: src/wallet/DepositPage/views.tsx:151
+#, fuzzy, c-format
+msgid "Select account"
msgstr "Övervisa till bank konto"
-#: src/webex/pages/return-coins.tsx:206
+#: src/wallet/DepositPage/views.tsx:163
+#, fuzzy, c-format
+msgid "Add another account"
+msgstr "Övervisa till bank konto"
+
+#: src/wallet/DepositPage/views.tsx:191
+#, fuzzy, c-format
+msgid "Deposit fee"
+msgstr "Depostitions avgift"
+
+#: src/wallet/DepositPage/views.tsx:205
#, c-format
-msgid "Confirm"
-msgstr "Bekräfta"
+msgid "Total deposit"
+msgstr ""
-#: src/webex/pages/return-coins.tsx:209
+#: src/wallet/DepositPage/views.tsx:233
+#, fuzzy, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr "Depostitions avgift"
+
+#: src/wallet/AddAccount/views.tsx:56
+#, fuzzy, c-format
+msgid "Add bank account for %1$s"
+msgstr "Accepterade tjänsteleverantörer:"
+
+#: src/wallet/AddAccount/views.tsx:59
#, c-format
-msgid "Cancel"
-msgstr "Avbryt"
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr ""
-#: src/webex/pages/withdraw.tsx:73
+#: src/wallet/ExchangeAddConfirm.tsx:45
#, c-format
-msgid "Could not get details for withdraw operation:"
+msgid "Exchange URL"
msgstr ""
-#: src/webex/pages/withdraw.tsx:89 src/webex/pages/withdraw.tsx:183
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, fuzzy, c-format
+msgid "Add exchange"
+msgstr "Accepterade tjänsteleverantörer:"
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, fuzzy, c-format
+msgid "Add new exchange"
+msgstr "Accepterade tjänsteleverantörer:"
+
+#: src/wallet/ExchangeSetUrl.tsx:116
#, fuzzy, c-format
-msgid "Chose different exchange provider"
-msgstr "Ändra tjänsteleverantörer"
+msgid "Add exchange for %1$s"
+msgstr "Accepterade tjänsteleverantörer:"
-#: src/webex/pages/withdraw.tsx:109
+#: src/wallet/ExchangeSetUrl.tsx:128
#, c-format
-msgid ""
-"Please select an exchange. You can review the details before after your "
-"selection."
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr ""
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
msgstr ""
-#: src/webex/pages/withdraw.tsx:121
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
#, fuzzy, c-format
-msgid "Select %1$s"
-msgstr "Välj %1$s"
+msgid "Exchange is ready for withdrawal"
+msgstr "Tjänsteleverantörer i plånboken:"
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid ""
+"To complete the process you need to wire%1$s %2$s to the exchange bank "
+"account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a "
+"banking app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, c-format
+msgid "Could not toggle auto-open"
+msgstr ""
+
+#: src/wallet/Settings.tsx:121
+#, c-format
+msgid "Could not toggle clipboard"
+msgstr ""
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr ""
-#: src/webex/pages/withdraw.tsx:143
+#: src/wallet/Settings.tsx:129
#, c-format
-msgid "Select custom exchange"
+msgid "Automatically open wallet based on page content"
msgstr ""
-#: src/webex/pages/withdraw.tsx:163
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr ""
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr ""
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr ""
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr ""
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr ""
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
#, fuzzy, c-format
-msgid "You are about to withdraw %1$s from your bank account into your wallet."
+msgid "Add an exchange"
+msgstr "Accepterade tjänsteleverantörer:"
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
msgstr ""
-"Du är på väg att ta ut\n"
-" %1$s från ditt bankkonto till din plånbok.\n"
-#: src/webex/pages/withdraw.tsx:174
+#: src/wallet/Settings.tsx:244
#, c-format
-msgid "Accept fees and withdraw"
-msgstr "Acceptera avgifter och utbetala"
+msgid "Developer mode"
+msgstr ""
-#: src/webex/pages/withdraw.tsx:192
+#: src/wallet/Settings.tsx:246
#, c-format
-msgid "Cancel withdraw operation"
+msgid "More options and information useful for debugging"
msgstr ""
-#: src/webex/renderHtml.tsx:249
+#: src/wallet/Settings.tsx:257
#, c-format
-msgid "Withdrawal fees:"
-msgstr "Utbetalnings avgifter:"
+msgid "Display"
+msgstr ""
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr ""
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check "
+"the preference name %2$s)."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE "
+"ALL YOUR COINS?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
-#: src/webex/renderHtml.tsx:252
+#: src/wallet/DeveloperPage.tsx:197
#, c-format
-msgid "Rounding loss:"
+msgid "import database"
msgstr ""
-#: src/webex/renderHtml.tsx:254
+#: src/wallet/DeveloperPage.tsx:219
#, c-format
-msgid "Earliest expiration (for deposit): %1$s"
+msgid "export database"
msgstr ""
-#: src/webex/renderHtml.tsx:262
+#: src/wallet/DeveloperPage.tsx:225
#, c-format
-msgid "# Coins"
+msgid "Database exported at %1$s %2$s to download"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:248
+#, fuzzy, c-format
+msgid "Coins"
msgstr "# Mynt"
-#: src/webex/renderHtml.tsx:263
+#: src/wallet/DeveloperPage.tsx:282
#, c-format
-msgid "Value"
-msgstr "Värde"
+msgid "Pending operations"
+msgstr ""
-#: src/webex/renderHtml.tsx:264
+#: src/wallet/DeveloperPage.tsx:328
#, c-format
-msgid "Withdraw Fee"
-msgstr "Utbetalnings avgift"
+msgid "usable coins"
+msgstr ""
-#: src/webex/renderHtml.tsx:265
+#: src/wallet/DeveloperPage.tsx:337
#, c-format
-msgid "Refresh Fee"
-msgstr "Återhämtnings avgift"
+msgid "id"
+msgstr ""
-#: src/webex/renderHtml.tsx:266
+#: src/wallet/DeveloperPage.tsx:340
#, c-format
-msgid "Deposit Fee"
-msgstr "Depostitions avgift"
+msgid "denom"
+msgstr ""
+#: src/wallet/DeveloperPage.tsx:343
#, fuzzy, c-format
-#~ msgid "Merchant %1$s offered contract %2$s."
-#~ msgstr "Säljaren %1$s erbjöd kontrakt %2$s.\n"
+msgid "value"
+msgstr "Värde"
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr ""
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:352
#, fuzzy, c-format
-#~ msgid "Merchant %1$s gave a refund over %2$s."
-#~ msgstr "Säljaren %1$sgav en återbetalning på %2$s.\n"
+msgid "age key count"
+msgstr "Övervisa till bank konto"
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, c-format
+msgid "Could not load list of exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
#, fuzzy, c-format
-#~ msgid "Merchant %1$s gave a %2$s of %3$s."
-#~ msgstr "Säljaren %1$sgav en återbetalning på %2$s.\n"
+msgid "From my bank account"
+msgstr "Övervisa till bank konto"
+#: src/wallet/DestinationSelection.tsx:395
#, c-format
-#~ msgid "help"
-#~ msgstr "hjälp"
+msgid "From another wallet"
+msgstr ""
+#: src/wallet/DestinationSelection.tsx:449
#, c-format
-#~ msgid "Payback"
-#~ msgstr "Återbetalning"
+msgid "currency not provided"
+msgstr ""
+#: src/wallet/DestinationSelection.tsx:459
#, c-format
-#~ msgid "Return Electronic Cash to Bank Account"
-#~ msgstr "Återlämna elektroniska pengar till bank konto"
+msgid "Specify the amount and the destination"
+msgstr ""
+#: src/wallet/DestinationSelection.tsx:483
+#, c-format
+msgid "Use previous destinations:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:503
+#, c-format
+msgid "Or specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:511
+#, c-format
+msgid "Specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:521
#, fuzzy, c-format
-#~ msgid "show more details"
-#~ msgstr "visa mer"
+msgid "To my bank account"
+msgstr "Övervisa till bank konto"
+#: src/wallet/DestinationSelection.tsx:534
#, c-format
-#~ msgid "Accepted exchanges:"
-#~ msgstr "Accepterade tjänsteleverantörer:"
+msgid "To another wallet"
+msgstr ""
+#: src/cta/Recovery/views.tsx:30
#, c-format
-#~ msgid "Exchanges in the wallet:"
-#~ msgstr "Tjänsteleverantörer i plånboken:"
+msgid "Could not load backup recovery information"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:47
+#, c-format
+msgid "Digital wallet recovery"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:52
+#, c-format
+msgid "Import backup, show info"
+msgstr ""
+
+#: src/wallet/Application.tsx:189
+#, c-format
+msgid "All done, your transaction is in progress"
+msgstr ""
+
+#: src/components/EditableText.tsx:45
+#, c-format
+msgid "Edit"
+msgstr ""
+#: src/wallet/ManualWithdrawPage.tsx:102
#, c-format
+msgid "Could not load the list of known exchanges"
+msgstr ""
+
+#, fuzzy
+#~ msgid "back"
+#~ msgstr "Återbetalning"
+
+#, fuzzy
+#~ msgid "no balance"
+#~ msgstr "Balans"
+
+#, fuzzy
+#~ msgid "Exchange fee"
+#~ msgstr "Tjänsteleverantörer i plånboken:"
+
+#, fuzzy
+#~ msgid "Deposit amount"
+#~ msgstr "Depostitions avgift"
+
+#, fuzzy
+#~ msgid "Withdraw anyway"
+#~ msgstr "Utbetalnings avgift"
+
+#, fuzzy
+#~ msgid "Unknown Wire Detail"
+#~ msgstr "visa mer"
+
+#~ msgid "The total price is %1$s (plus %2$s fees)."
+#~ msgstr "Det totala priset är %1$s (plus %2$s avgifter)."
+
+#, fuzzy
+#~ msgid "The total price is %1$s."
+#~ msgstr "Det totala priset är %1$s."
+
+#~ msgid "Confirm payment"
+#~ msgstr "Godkän betalning"
+
+#~ msgid "History"
+#~ msgstr "Historia"
+
+#, fuzzy
+#~ msgid "%1$s incoming"
+#~ msgstr "%1$s inkommande"
+
+#~ msgid "Your wallet has no events recorded."
+#~ msgstr "plånboken"
+
+#, fuzzy
+#~ msgid "Chose different exchange provider"
+#~ msgstr "Ändra tjänsteleverantörer"
+
+#~ msgid ""
+#~ "You are about to withdraw %1$s from your bank account into your wallet."
+#~ msgstr "Du är på väg att ta ut %1$s från ditt bankkonto till din plånbok."
+
+#~ msgid "Accept fees and withdraw"
+#~ msgstr "Acceptera avgifter och utbetala"
+
+#, fuzzy
+#~ msgid "Merchant %1$s offered contract %2$s."
+#~ msgstr "Säljaren %1$s erbjöd kontrakt %2$s.\n"
+
+#, fuzzy
+#~ msgid "Merchant %1$s gave a refund over %2$s."
+#~ msgstr "Säljaren %1$sgav en återbetalning på %2$s.\n"
+
+#~ msgid "help"
+#~ msgstr "hjälp"
+
+#~ msgid "Return Electronic Cash to Bank Account"
+#~ msgstr "Återlämna elektroniska pengar till bank konto"
+
+#, fuzzy
+#~ msgid "show more details"
+#~ msgstr "visa mer"
+
#~ msgid ""
#~ "You have insufficient funds of the requested currency in your wallet."
#~ msgstr "plånboken"
-#, c-format
#~ msgid ""
#~ "You do not have any funds from an exchange that is accepted by this "
#~ "merchant. None of the exchanges accepted by the merchant is known to your "
#~ "wallet."
#~ msgstr "plånboken"
-#, c-format
#~ msgid "Submitting payment"
#~ msgstr "Bekräftar betalning"
-#, c-format
#~ msgid ""
#~ "You already paid for this, clicking \"Confirm payment\" will not cost "
#~ "money again."
@@ -353,36 +2063,27 @@ msgstr "Depostitions avgift"
#~ "Du har redan betalat för det här, om du trycker \"Godkän betalning\" "
#~ "debiteras du inte igen"
-#, fuzzy, c-format
+#, fuzzy
#~ msgid "Aborting payment ..."
#~ msgstr "Bekräftar betalning"
-#, fuzzy, c-format
-#~ msgid "Abort Payment"
-#~ msgstr "Godkän betalning"
-
-#, c-format
#~ msgid "Select"
#~ msgstr "Välj"
-#, fuzzy, c-format
-#~ msgid "The exchange is trusted by the wallet."
-#~ msgstr "Tjänsteleverantörer i plånboken:"
-
-#, fuzzy, c-format
+#, fuzzy
#~ msgid ""
#~ "Your wallet (protocol version %1$s) might be outdated.%2$s The exchange "
#~ "has a higher, incompatible protocol version (%3$s)."
#~ msgstr "tjänsteleverantörer plånboken"
-#, fuzzy, c-format
+#, fuzzy
#~ msgid ""
#~ "The chosen exchange (protocol version %1$s might be outdated.%2$s The "
#~ "exchange has a lower, incompatible protocol version than your wallet "
#~ "(protocol version %3$s)."
#~ msgstr "tjänsteleverantörer plånboken"
-#, fuzzy, c-format
+#, fuzzy
#~ msgid ""
#~ "Oops, something went wrong. The wallet responded with error status (%1$s)."
#~ msgstr "plånboken"
diff --git a/packages/taler-wallet-webextension/src/i18n/taler-wallet-webex.pot b/packages/taler-wallet-webextension/src/i18n/taler-wallet-webex.pot
index 67b09de1a..daf1460a2 100644
--- a/packages/taler-wallet-webextension/src/i18n/taler-wallet-webex.pot
+++ b/packages/taler-wallet-webextension/src/i18n/taler-wallet-webex.pot
@@ -1,21 +1,12 @@
-# This file is part of TALER
-# (C) 2016 GNUnet e.V.
-#
-# TALER is free software; you can redistribute it and/or modify it under the
-# terms of the GNU General Public License as published by the Free Software
-# Foundation; either version 3, or (at your option) any later version.
-#
-# TALER is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License along with
-# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
-"Project-Id-Version: Taler Wallet\n"
+"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-11-23 00:00+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
@@ -25,266 +16,1937 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-#: src/util/wire.ts:37
+#: src/NavigationBar.tsx:139
#, c-format
-msgid "Invalid Wire"
+msgid "Balance"
msgstr ""
-#: src/util/wire.ts:42 src/util/wire.ts:45
+#: src/NavigationBar.tsx:142
#, c-format
-msgid "Invalid Test Wire Detail"
+msgid "Backup"
msgstr ""
-#: src/util/wire.ts:47
+#: src/NavigationBar.tsx:147
#, c-format
-msgid "Test Wire Acct #%1$s on %2$s"
+msgid "QR Reader and Taler URI"
msgstr ""
-#: src/util/wire.ts:49
+#: src/NavigationBar.tsx:154
#, c-format
-msgid "Unknown Wire Detail"
+msgid "Settings"
msgstr ""
-#: src/webex/pages/benchmark.tsx:52
+#: src/NavigationBar.tsx:184
#, c-format
-msgid "Operation"
+msgid "Dev"
msgstr ""
-#: src/webex/pages/benchmark.tsx:53
+#: src/mui/Typography.tsx:122
#, c-format
-msgid "time (ms/op)"
+msgid "%1$s"
msgstr ""
-#: src/webex/pages/pay.tsx:130
+#: src/components/PendingTransactions.tsx:74
#, c-format
-msgid "The merchant %1$s offers you to purchase:"
+msgid "PENDING OPERATIONS"
msgstr ""
-#: src/webex/pages/pay.tsx:136
+#: src/components/Loading.tsx:36
#, c-format
-msgid "The total price is %1$s (plus %2$s fees)."
+msgid "Loading"
msgstr ""
-#: src/webex/pages/pay.tsx:141
+#: src/wallet/BackupPage.tsx:123
#, c-format
-msgid "The total price is %1$s."
+msgid "Could not load backup providers"
msgstr ""
-#: src/webex/pages/pay.tsx:163
+#: src/wallet/BackupPage.tsx:202
#, c-format
-msgid "Retry"
+msgid "No backup providers configured"
msgstr ""
-#: src/webex/pages/pay.tsx:173
+#: src/wallet/BackupPage.tsx:205
#, c-format
-msgid "Confirm payment"
+msgid "Add provider"
msgstr ""
-#: src/webex/pages/popup.tsx:153
+#: src/wallet/BackupPage.tsx:219
#, c-format
-msgid "Balance"
+msgid "Sync all backups"
msgstr ""
-#: src/webex/pages/popup.tsx:154
+#: src/wallet/BackupPage.tsx:221
#, c-format
-msgid "History"
+msgid "Sync now"
msgstr ""
-#: src/webex/pages/popup.tsx:155
+#: src/wallet/BackupPage.tsx:264
#, c-format
-msgid "Debug"
+msgid "Last synced"
msgstr ""
-#: src/webex/pages/popup.tsx:175
+#: src/wallet/BackupPage.tsx:269
#, c-format
-msgid "You have no balance to show. Need some %1$s getting started?"
+msgid "Not synced"
msgstr ""
-#: src/webex/pages/popup.tsx:238
+#: src/wallet/BackupPage.tsx:289
#, c-format
-msgid "%1$s incoming"
+msgid "Expires in"
msgstr ""
-#: src/webex/pages/popup.tsx:250
+#: src/wallet/ProviderDetailPage.tsx:60
#, c-format
-msgid "%1$s being spent"
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
msgstr ""
-#: src/webex/pages/popup.tsx:281
+#: src/wallet/ProviderDetailPage.tsx:108
#, c-format
-msgid "Error: could not retrieve balance information."
+msgid "There is not known provider with url &quot;%1$s&quot;."
msgstr ""
-#: src/webex/pages/popup.tsx:390
+#: src/wallet/ProviderDetailPage.tsx:115
#, c-format
-msgid "Invalid "
+msgid "See providers"
msgstr ""
-#: src/webex/pages/popup.tsx:396
+#: src/wallet/ProviderDetailPage.tsx:143
#, c-format
-msgid "Fees "
+msgid "Last backup"
msgstr ""
-#: src/webex/pages/popup.tsx:434
+#: src/wallet/ProviderDetailPage.tsx:148
#, c-format
-msgid "Refresh sessions has completed"
+msgid "Back up"
msgstr ""
-#: src/webex/pages/popup.tsx:451
+#: src/wallet/ProviderDetailPage.tsx:154
#, c-format
-msgid "Order Refused"
+msgid "Provider fee"
msgstr ""
-#: src/webex/pages/popup.tsx:465
+#: src/wallet/ProviderDetailPage.tsx:157
#, c-format
-msgid "Order redirected"
+msgid "per year"
msgstr ""
-#: src/webex/pages/popup.tsx:482
+#: src/wallet/ProviderDetailPage.tsx:163
#, c-format
-msgid "Payment aborted"
+msgid "Extend"
msgstr ""
-#: src/webex/pages/popup.tsx:512
+#: src/wallet/ProviderDetailPage.tsx:169
#, c-format
-msgid "Payment Sent"
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms of "
+"service"
msgstr ""
-#: src/webex/pages/popup.tsx:536
+#: src/wallet/ProviderDetailPage.tsx:179
#, c-format
-msgid "Order accepted"
+msgid "old"
msgstr ""
-#: src/webex/pages/popup.tsx:547
+#: src/wallet/ProviderDetailPage.tsx:183
#, c-format
-msgid "Reserve balance updated"
+msgid "new"
msgstr ""
-#: src/webex/pages/popup.tsx:559
+#: src/wallet/ProviderDetailPage.tsx:190
#, c-format
-msgid "Payment refund"
+msgid "fee"
msgstr ""
-#: src/webex/pages/popup.tsx:584
+#: src/wallet/ProviderDetailPage.tsx:198
#, c-format
-msgid "Withdrawn"
+msgid "storage"
msgstr ""
-#: src/webex/pages/popup.tsx:596
+#: src/wallet/ProviderDetailPage.tsx:215
#, c-format
-msgid "Tip Accepted"
+msgid "Remove provider"
msgstr ""
-#: src/webex/pages/popup.tsx:606
+#: src/wallet/ProviderDetailPage.tsx:228
#, c-format
-msgid "Tip Declined"
+msgid "This provider has reported an error"
msgstr ""
-#: src/webex/pages/popup.tsx:615
+#: src/wallet/ProviderDetailPage.tsx:242
#, c-format
-msgid "%1$s"
+msgid "There is conflict with another backup from %1$s"
msgstr ""
-#: src/webex/pages/popup.tsx:707
+#: src/wallet/ProviderDetailPage.tsx:253
#, c-format
-msgid "Your wallet has no events recorded."
+msgid "Backup is not readable"
msgstr ""
-#: src/webex/pages/return-coins.tsx:124
+#: src/wallet/ProviderDetailPage.tsx:261
#, c-format
-msgid "Wire to bank account"
+msgid "Unknown backup problem: %1$s"
msgstr ""
-#: src/webex/pages/return-coins.tsx:206
+#: src/wallet/ProviderDetailPage.tsx:283
#, c-format
-msgid "Confirm"
+msgid "service paid"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format
+msgid "Backup valid until"
msgstr ""
-#: src/webex/pages/return-coins.tsx:209
+#: src/wallet/AddNewActionView.tsx:57
#, c-format
msgid "Cancel"
msgstr ""
-#: src/webex/pages/withdraw.tsx:73
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr ""
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr ""
+
+#: src/popup/NoBalanceHelp.tsx:43
+#, c-format
+msgid "Get digital cash"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:175
+#, c-format
+msgid "Add"
+msgstr ""
+
+#: src/popup/BalancePage.tsx:179
+#, c-format
+msgid "Send %1$s"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr ""
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, c-format
+msgid "Could not load purchase proposal details"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, c-format
+msgid "Order Id"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, c-format
+msgid "Exchanges"
+msgstr ""
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/Part.tsx:160
+#, c-format
+msgid "Bitcoin address"
+msgstr ""
+
+#: src/components/Part.tsx:163
+#, c-format
+msgid "IBAN"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:38
+#, c-format
+msgid "Could not load deposit status"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an minimum "
+"amount."
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional "
+"recipient and copy addresses and amounts"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to get "
+"the coins form it."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:267
+#, c-format
+msgid "Withdrawal"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:286
+#, c-format
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check "
+"there is no pending step."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:325
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:360
+#, c-format
+msgid "Payment"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:378
+#, c-format
+msgid "Refunds"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:385
+#, c-format
+msgid "%1$s %2$s on %3$s"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:415
+#, c-format
+msgid "Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:431
+#, c-format
+msgid "Accept"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:496
+#, c-format
+msgid "Refresh"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:542
#, c-format
-msgid "Could not get details for withdraw operation:"
+msgid "Refund"
msgstr ""
-#: src/webex/pages/withdraw.tsx:89 src/webex/pages/withdraw.tsx:183
+#: src/wallet/Transaction.tsx:555
#, c-format
-msgid "Chose different exchange provider"
+msgid "Original order ID"
msgstr ""
-#: src/webex/pages/withdraw.tsx:109
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1074
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1335
+#, c-format
+msgid "Total transfer"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:364
#, c-format
msgid ""
-"Please select an exchange. You can review the details before after your "
-"selection."
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:366
+#, c-format
+msgid "Your current balance is not enough."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:34
+#, c-format
+msgid "Could not load refund status"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:76
+#, c-format
+msgid "Total to refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:106
+#, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:115
+#, c-format
+msgid "Order amount"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:32
+#, c-format
+msgid "Could not load tip status"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:66
+#, c-format
+msgid "The merchant is offering you a tip"
msgstr ""
-#: src/webex/pages/withdraw.tsx:121
+#: src/cta/Tip/views.tsx:74
#, c-format
-msgid "Select %1$s"
+msgid "Merchant URL"
msgstr ""
-#: src/webex/pages/withdraw.tsx:143
+#: src/cta/Tip/views.tsx:90
#, c-format
-msgid "Select custom exchange"
+msgid "Receive &nbsp; %1$s"
msgstr ""
-#: src/webex/pages/withdraw.tsx:163
+#: src/cta/Tip/views.tsx:114
#, c-format
-msgid "You are about to withdraw %1$s from your bank account into your wallet."
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
msgstr ""
-#: src/webex/pages/withdraw.tsx:174
+#: src/components/SelectList.tsx:66
#, c-format
-msgid "Accept fees and withdraw"
+msgid "Select one option"
msgstr ""
-#: src/webex/pages/withdraw.tsx:192
+#: src/components/TermsOfService/views.tsx:39
#, c-format
-msgid "Cancel withdraw operation"
+msgid "Could not load"
msgstr ""
-#: src/webex/renderHtml.tsx:249
+#: src/components/TermsOfService/views.tsx:73
#, c-format
-msgid "Withdrawal fees:"
+msgid "Show terms of service"
msgstr ""
-#: src/webex/renderHtml.tsx:252
+#: src/components/TermsOfService/views.tsx:81
#, c-format
-msgid "Rounding loss:"
+msgid "I accept the exchange terms of service"
msgstr ""
-#: src/webex/renderHtml.tsx:254
+#: src/components/TermsOfService/views.tsx:107
#, c-format
-msgid "Earliest expiration (for deposit): %1$s"
+msgid "Exchange doesn&apos;t have terms of service"
msgstr ""
-#: src/webex/renderHtml.tsx:262
+#: src/components/TermsOfService/views.tsx:135
#, c-format
-msgid "# Coins"
+msgid "Review exchange terms of service"
msgstr ""
-#: src/webex/renderHtml.tsx:263
+#: src/components/TermsOfService/views.tsx:146
#, c-format
-msgid "Value"
+msgid "Review new version of terms of service"
msgstr ""
-#: src/webex/renderHtml.tsx:264
+#: src/components/TermsOfService/views.tsx:170
#, c-format
-msgid "Withdraw Fee"
+msgid "The exchange reply with a empty terms of service"
msgstr ""
-#: src/webex/renderHtml.tsx:265
+#: src/components/TermsOfService/views.tsx:193
#, c-format
-msgid "Refresh Fee"
+msgid "Download Terms of Service"
msgstr ""
-#: src/webex/renderHtml.tsx:266
+#: src/components/TermsOfService/views.tsx:204
#, c-format
-msgid "Deposit Fee"
+msgid "Hide terms of service"
msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, c-format
+msgid "Could not load exchange fees"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, c-format
+msgid "could not find any exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, c-format
+msgid "Select %1$s exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, c-format
+msgid "Use this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:252
+#, c-format
+msgid "Deposits"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, c-format
+msgid "Denomination"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, c-format
+msgid "Withdrawals"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, c-format
+msgid "Coin operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and is "
+"valid for a period of time. The exchange will charge the indicated amount every "
+"time a coin is used in such operation."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, c-format
+msgid "Transfer operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is valid "
+"for a period of time. The exchange will charge the indicated amount every time a "
+"transfer is made."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, c-format
+msgid "Wallet operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:60
+#, c-format
+msgid "Could not get info of withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/cta/InvoicePay/views.tsx:63
+#, c-format
+msgid "Could not finish the payment operation"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr ""
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will send "
+"the coins to this wallet after receiving a wire transfer with the correct "
+"subject."
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:38
+#, c-format
+msgid "Could not load deposit balance"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:233
+#, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr ""
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid "To complete the process you need to wire%1$s %2$s to the exchange bank account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a banking "
+"app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, c-format
+msgid "Could not toggle auto-open"
+msgstr ""
+
+#: src/wallet/Settings.tsx:121
+#, c-format
+msgid "Could not toggle clipboard"
+msgstr ""
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr ""
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr ""
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires more "
+"permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr ""
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr ""
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr ""
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr ""
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr ""
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr ""
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr ""
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr ""
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr ""
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr ""
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr ""
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires more "
+"permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check the "
+"preference name %2$s)."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL "
+"YOUR COINS?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, c-format
+msgid "Could not load list of exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
+#, c-format
+msgid "From my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:449
+#, c-format
+msgid "currency not provided"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:459
+#, c-format
+msgid "Specify the amount and the destination"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:483
+#, c-format
+msgid "Use previous destinations:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:503
+#, c-format
+msgid "Or specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:511
+#, c-format
+msgid "Specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:521
+#, c-format
+msgid "To my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:534
+#, c-format
+msgid "To another wallet"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:30
+#, c-format
+msgid "Could not load backup recovery information"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:47
+#, c-format
+msgid "Digital wallet recovery"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:52
+#, c-format
+msgid "Import backup, show info"
+msgstr ""
+
+#: src/wallet/Application.tsx:189
+#, c-format
+msgid "All done, your transaction is in progress"
+msgstr ""
+
+#: src/components/EditableText.tsx:45
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/wallet/ManualWithdrawPage.tsx:102
+#, c-format
+msgid "Could not load the list of known exchanges"
+msgstr ""
+
diff --git a/packages/taler-wallet-webextension/src/i18n/tr.po b/packages/taler-wallet-webextension/src/i18n/tr.po
new file mode 100644
index 000000000..5848b9f3a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/i18n/tr.po
@@ -0,0 +1,2087 @@
+# This file is part of GNU Taler
+# (C) 2022 Taler Systems S.A.
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under the
+# terms of the GNU General Public License as published by the Free Software
+# Foundation; either version 3, or (at your option) any later version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Taler Wallet\n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-03-08 01:14+0000\n"
+"Last-Translator: Alp <berna.alp@digitalekho.com>\n"
+"Language-Team: Turkish <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/tr/>\n"
+"Language: tr\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/NavigationBar.tsx:139
+#, c-format
+msgid "Balance"
+msgstr "Bakiye"
+
+#: src/NavigationBar.tsx:142
+#, c-format
+msgid "Backup"
+msgstr "Yedekle"
+
+#: src/NavigationBar.tsx:147
+#, c-format
+msgid "QR Reader and Taler URI"
+msgstr ""
+
+#: src/NavigationBar.tsx:154
+#, c-format
+msgid "Settings"
+msgstr "Ayarlar"
+
+#: src/NavigationBar.tsx:184
+#, c-format
+msgid "Dev"
+msgstr "Gelişim"
+
+#: src/mui/Typography.tsx:122
+#, fuzzy, c-format
+msgid "%1$s"
+msgstr "%1$s"
+
+#: src/components/PendingTransactions.tsx:74
+#, c-format
+msgid "PENDING OPERATIONS"
+msgstr ""
+
+#: src/components/Loading.tsx:36
+#, c-format
+msgid "Loading"
+msgstr "Yükleniyor"
+
+#: src/wallet/BackupPage.tsx:123
+#, c-format
+msgid "Could not load backup providers"
+msgstr "Yedekleme sağlayıcıları yüklenemedi"
+
+#: src/wallet/BackupPage.tsx:202
+#, c-format
+msgid "No backup providers configured"
+msgstr "Yapılandırılmış yedekleme sağlayıcısı yok"
+
+#: src/wallet/BackupPage.tsx:205
+#, c-format
+msgid "Add provider"
+msgstr "Sağlayıcı ekle"
+
+#: src/wallet/BackupPage.tsx:219
+#, c-format
+msgid "Sync all backups"
+msgstr "Tüm yedeklemeleri senkronize et"
+
+#: src/wallet/BackupPage.tsx:221
+#, c-format
+msgid "Sync now"
+msgstr "Şimdi senkronize et"
+
+#: src/wallet/BackupPage.tsx:264
+#, c-format
+msgid "Last synced"
+msgstr "Son Senkronizasyon"
+
+#: src/wallet/BackupPage.tsx:269
+#, c-format
+msgid "Not synced"
+msgstr "Senkronize Edilmedi"
+
+#: src/wallet/BackupPage.tsx:289
+#, c-format
+msgid "Expires in"
+msgstr "İçinde sona eriyor"
+
+#: src/wallet/ProviderDetailPage.tsx:60
+#, c-format
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:108
+#, c-format
+msgid "There is not known provider with url &quot;%1$s&quot;."
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:115
+#, fuzzy, c-format
+msgid "See providers"
+msgstr "Sağlayıcı ekle"
+
+#: src/wallet/ProviderDetailPage.tsx:143
+#, c-format
+msgid "Last backup"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:148
+#, c-format
+msgid "Back up"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:154
+#, c-format
+msgid "Provider fee"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:157
+#, c-format
+msgid "per year"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:163
+#, c-format
+msgid "Extend"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:169
+#, c-format
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms "
+"of service"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:179
+#, c-format
+msgid "old"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:183
+#, c-format
+msgid "new"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:190
+#, c-format
+msgid "fee"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:198
+#, c-format
+msgid "storage"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:215
+#, c-format
+msgid "Remove provider"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:228
+#, c-format
+msgid "This provider has reported an error"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:242
+#, c-format
+msgid "There is conflict with another backup from %1$s"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:253
+#, c-format
+msgid "Backup is not readable"
+msgstr ""
+
+#: src/wallet/ProviderDetailPage.tsx:261
+#, c-format
+msgid "Unknown backup problem: %1$s"
+msgstr "Bilinmeyen yedekleme problemi: %1$s"
+
+#: src/wallet/ProviderDetailPage.tsx:283
+#, c-format
+msgid "service paid"
+msgstr "hizmet ödendi"
+
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format
+msgid "Backup valid until"
+msgstr "Yedekleme geçerlilik süresi"
+
+#: src/wallet/AddNewActionView.tsx:57
+#, c-format
+msgid "Cancel"
+msgstr "İptal et"
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr "Rezerv sayfasını açın"
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr "Ödeme sayfasını açın"
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr "Geri ödeme sayfasını açın"
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr "İkramiye sayfasını açın"
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr "Para çekme sayfasını açın"
+
+#: src/popup/NoBalanceHelp.tsx:43
+#, c-format
+msgid "Get digital cash"
+msgstr "Dijital para alın"
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/popup/BalancePage.tsx:175
+#, c-format
+msgid "Add"
+msgstr "Ekle"
+
+#: src/popup/BalancePage.tsx:179
+#, c-format
+msgid "Send %1$s"
+msgstr "%1$s gönder"
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr "Taler Eylemi"
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr "Bu sayfada ödeme eylemi var."
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr "Bu sayfada para çekme eylemi var."
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr "Bu sayfada bir ikramiye eylemi var."
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr "Bu sayfada bir rezervasyon bildir eylemi var."
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr "Bildirin"
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr "Bu sayfada bir geri ödeme eylemi var."
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr "Bu sayfada hatalı biçimlendirilmiş taler uri var."
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr "Reddet"
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, fuzzy, c-format
+msgid "Could not load purchase proposal details"
+msgstr "Yedekleme sağlayıcıları yüklenemedi"
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, fuzzy, c-format
+msgid "Order Id"
+msgstr "Sipariş reddedildi"
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr "Satıcı web sitesi"
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr "Satıcı e-postası"
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, fuzzy, c-format
+msgid "Delivery location"
+msgstr "Taler Eylemi"
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, fuzzy, c-format
+msgid "Auto refund"
+msgstr "Ödeme iadesi"
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, fuzzy, c-format
+msgid "Exchanges"
+msgstr "Exchange"
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/Part.tsx:160
+#, c-format
+msgid "Bitcoin address"
+msgstr ""
+
+#: src/components/Part.tsx:163
+#, c-format
+msgid "IBAN"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:38
+#, fuzzy, c-format
+msgid "Could not load deposit status"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr "Ücretler"
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an "
+"minimum amount."
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two "
+"additional recipient and copy addresses and amounts"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid ""
+"Make sure the amount show %1$s BTC, else you have to change the base unit to "
+"BTC"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr "Yeniden deneyin"
+
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to "
+"get the coins form it."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr "Onaylamak"
+
+#: src/wallet/Transaction.tsx:267
+#, fuzzy, c-format
+msgid "Withdrawal"
+msgstr "Çekildi"
+
+#: src/wallet/Transaction.tsx:286
+#, c-format
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and "
+"check there is no pending step."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid ""
+"Bank has confirmed the wire transfer. Waiting for the exchange to send the "
+"coins"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:325
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:360
+#, fuzzy, c-format
+msgid "Payment"
+msgstr "Ödeme gönderildi"
+
+#: src/wallet/Transaction.tsx:378
+#, c-format
+msgid "Refunds"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:385
+#, c-format
+msgid "%1$s %2$s on %3$s"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:415
+#, c-format
+msgid ""
+"Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:431
+#, fuzzy, c-format
+msgid "Accept"
+msgstr "İkramiye kabul edildi"
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:496
+#, fuzzy, c-format
+msgid "Refresh"
+msgstr "Ücreti yenile"
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr "Exchange"
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1074
+#, c-format
+msgid "Withdraw"
+msgstr "Para çek"
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1335
+#, fuzzy, c-format
+msgid "Total transfer"
+msgstr "Çekildi"
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:364
+#, c-format
+msgid ""
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:366
+#, fuzzy, c-format
+msgid "Your current balance is not enough."
+msgstr "Gösterecek bakiyeniz yok."
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:34
+#, fuzzy, c-format
+msgid "Could not load refund status"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:76
+#, fuzzy, c-format
+msgid "Total to refund"
+msgstr "Ödeme iadesi"
+
+#: src/cta/Refund/views.tsx:106
+#, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:115
+#, fuzzy, c-format
+msgid "Order amount"
+msgstr "Ücreti yenile"
+
+#: src/cta/Refund/views.tsx:122
+#, fuzzy, c-format
+msgid "Already refunded"
+msgstr "Ödeme iadesi"
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:32
+#, fuzzy, c-format
+msgid "Could not load tip status"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:66
+#, c-format
+msgid "The merchant is offering you a tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:90
+#, c-format
+msgid "Receive &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:114
+#, c-format
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
+msgstr ""
+
+#: src/components/SelectList.tsx:66
+#, c-format
+msgid "Select one option"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:39
+#, fuzzy, c-format
+msgid "Could not load"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr "Hizmet şartlarını göster"
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr "Hizmet şartlarını kabul ediyorum"
+
+#: src/components/TermsOfService/views.tsx:107
+#, fuzzy, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr "Exchange'in hizmet şartları yok"
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr "Exchange'in hizmet şartlarını inceleyin"
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr "Hizmet şartlarının yeni sürümünü inceleyin"
+
+#: src/components/TermsOfService/views.tsx:170
+#, c-format
+msgid "The exchange reply with a empty terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, fuzzy, c-format
+msgid "Could not load exchange fees"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, fuzzy, c-format
+msgid "could not find any exchange"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, fuzzy, c-format
+msgid "Select %1$s exchange"
+msgstr "Özel exchange'i seçin"
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, fuzzy, c-format
+msgid "Use this exchange"
+msgstr "Özel exchange'i seçin"
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, fuzzy, c-format
+msgid "Operations"
+msgstr "Bekleyen işlemler"
+
+#: src/wallet/ExchangeSelection/views.tsx:252
+#, fuzzy, c-format
+msgid "Deposits"
+msgstr "Depozito %1$s"
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, fuzzy, c-format
+msgid "Denomination"
+msgstr "Bekleyen işlemler"
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, fuzzy, c-format
+msgid "Withdrawals"
+msgstr "Çekildi"
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, fuzzy, c-format
+msgid "Coin operations"
+msgstr "Bekleyen işlemler"
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and "
+"is valid for a period of time. The exchange will charge the indicated amount "
+"every time a coin is used in such operation."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, fuzzy, c-format
+msgid "Transfer operations"
+msgstr "Bekleyen işlemler"
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is "
+"valid for a period of time. The exchange will charge the indicated amount "
+"every time a transfer is made."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, fuzzy, c-format
+msgid "Wallet operations"
+msgstr "Bekleyen işlemler"
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr ""
+
+# #: src/wallet/Transaction.tsx:527
+# #, c-format
+# msgid "Cancel"
+# msgstr "İptal et"
+#: src/cta/Withdraw/views.tsx:60
+#, fuzzy, c-format
+msgid "Could not get info of withdrawal"
+msgstr "Para çekme işlemi için ayrıntılar alınamadı:"
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr ""
+
+# #: src/wallet/Transaction.tsx:527
+# #, c-format
+# msgid "Cancel"
+# msgstr "İptal et"
+#: src/cta/InvoicePay/views.tsx:63
+#, fuzzy, c-format
+msgid "Could not finish the payment operation"
+msgstr "Para çekme işlemi için ayrıntılar alınamadı:"
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr ""
+
+# #: src/wallet/Transaction.tsx:527
+# #, c-format
+# msgid "Cancel"
+# msgstr "İptal et"
+#: src/cta/TransferCreate/views.tsx:59
+#, fuzzy, c-format
+msgid "Could not finish the transfer creation"
+msgstr "Para çekme işlemi için ayrıntılar alınamadı:"
+
+# #: src/wallet/Transaction.tsx:527
+# #, c-format
+# msgid "Cancel"
+# msgstr "İptal et"
+#: src/cta/TransferPickup/views.tsx:57
+#, fuzzy, c-format
+msgid "Could not finish the pickup operation"
+msgstr "Para çekme işlemi için ayrıntılar alınamadı:"
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, fuzzy, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr "Para çekme ücretleri:"
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will "
+"send the coins to this wallet after receiving a wire transfer with the "
+"correct subject."
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:38
+#, fuzzy, c-format
+msgid "Could not load deposit balance"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:233
+#, fuzzy, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr "Test Havale Hesap #%1$s üzerinde %2$s"
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr ""
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, fuzzy, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr "Hizmet şartlarını kabul ediyorum"
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid ""
+"To complete the process you need to wire%1$s %2$s to the exchange bank "
+"account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a "
+"banking app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, fuzzy, c-format
+msgid "Could not toggle auto-open"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/wallet/Settings.tsx:121
+#, fuzzy, c-format
+msgid "Could not toggle clipboard"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr ""
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr ""
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr ""
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr ""
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr ""
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr ""
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr ""
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr ""
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr ""
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr ""
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr ""
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr ""
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr ""
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires "
+"more permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr "Tanılar zaman aşımına uğradı. Cüzdan arka ucuyla konuşulamadı."
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr "Tespit edilen sorunlar:"
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check "
+"the preference name %2$s)."
+msgstr ""
+"Lütfen %1$s ayarlarınızda, IndexedDB'nin etkinleştirildiğinizi kontrol edin "
+"(%2$s tercih adını kontrol edin)."
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+"Cüzdan veritabanınız eski. Şu anda otomatik aktarım desteklenmiyor. Cüzdan "
+"veritabanını sıfırlamak için lütfen %1$s gidin."
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr "Tanılamayı çalıştır"
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr "Hata ayıklama araçları"
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE "
+"ALL YOUR COINS?"
+msgstr ""
+"Cüzdanınızdaki her şeyi GERİ ALINAMAZ BİÇİMDE İMHA ETMEK ve TÜM PARALARINIZI "
+"KAYBETMEK mi istiyorsunuz?"
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr "sıfırla"
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr "veritabanını içe aktar"
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr "veritabanını dışa aktar"
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr "Veritabanı %1$s'de dışa aktarıldı-%2$s indirilecek"
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr "Madeni paralar"
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr "Bekleyen işlemler"
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr "kullanılabilir madeni paralar"
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr "kimlik"
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr "değer"
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr "durum"
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr "yenilemeden mi?"
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr "harcanan madeni paralar"
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr "göstermek için tıklayın"
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr "Açık"
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, fuzzy, c-format
+msgid "Could not load list of exchange"
+msgstr "Bakiye sayfası yüklenemedi"
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
+#, fuzzy, c-format
+msgid "From my bank account"
+msgstr "Banka hesabına havale yap"
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:449
+#, c-format
+msgid "currency not provided"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:459
+#, c-format
+msgid "Specify the amount and the destination"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:483
+#, c-format
+msgid "Use previous destinations:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:503
+#, c-format
+msgid "Or specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:511
+#, c-format
+msgid "Specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:521
+#, fuzzy, c-format
+msgid "To my bank account"
+msgstr "Banka hesabına havale yap"
+
+#: src/wallet/DestinationSelection.tsx:534
+#, c-format
+msgid "To another wallet"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:30
+#, fuzzy, c-format
+msgid "Could not load backup recovery information"
+msgstr "Yedekleme sağlayıcıları yüklenemedi"
+
+#: src/cta/Recovery/views.tsx:47
+#, c-format
+msgid "Digital wallet recovery"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:52
+#, c-format
+msgid "Import backup, show info"
+msgstr ""
+
+#: src/wallet/Application.tsx:189
+#, c-format
+msgid "All done, your transaction is in progress"
+msgstr ""
+
+#: src/components/EditableText.tsx:45
+#, c-format
+msgid "Edit"
+msgstr ""
+"Lütfen bir exchange seçin. Detayları seçiminizden önce inceleyebilirsiniz."
+
+#: src/wallet/ManualWithdrawPage.tsx:102
+#, c-format
+msgid "Could not load the list of known exchanges"
+msgstr ""
+
+#~ msgid "Back"
+#~ msgstr "Geri"
+
+#~ msgid ""
+#~ "To withdraw money you can start from your bank site or click the "
+#~ "\"withdraw\" button to use a known exchange."
+#~ msgstr ""
+#~ "Para çekmek için banka sitenizden başlayabilir veya bilinen bir "
+#~ "exchange'i kullanmak için \"para çek\" düğmesini tıklayabilirsiniz."
+
+#~ msgid "Enter URI"
+#~ msgstr "URI'yi girin"
+
+#, fuzzy
+#~ msgid "no balance"
+#~ msgstr "Bakiye"
+
+#, fuzzy
+#~ msgid "Deposit amount"
+#~ msgstr "Depozito Ücreti"
+
+#~ msgid "Debug"
+#~ msgstr "Hata ayıklama"
+
+#, fuzzy
+#~ msgid "You have no balance to show. Need some %1$s getting started?"
+#~ msgstr "Gösterecek bakiyeniz yok. Başlamak için %1$s'ye mi ihtiyacınız var?"
+
+#, fuzzy
+#~ msgid "%1$s incoming"
+#~ msgstr "%1$s gelen"
+
+#, fuzzy
+#~ msgid "%1$s being spent"
+#~ msgstr "%1$s harcanan"
+
+#~ msgid "Error: could not retrieve balance information."
+#~ msgstr "Hata: bakiye bilgisi alınamadı."
+
+#~ msgid "Invalid "
+#~ msgstr "Geçersiz "
+
+#~ msgid "Refresh sessions has completed"
+#~ msgstr "Oturumların yenilenmesi tamamlandı"
+
+#~ msgid "Order redirected"
+#~ msgstr "Sipariş yönlendirildi"
+
+#~ msgid "Payment aborted"
+#~ msgstr "Ödeme durduruldu"
+
+#~ msgid "Order accepted"
+#~ msgstr "Sipariş kabul edildi"
+
+#~ msgid "Reserve balance updated"
+#~ msgstr "Yedek bakiye güncellendi"
+
+#, fuzzy
+#~ msgid "Tip Declined"
+#~ msgstr "İkramiye red edildi"
+
+#~ msgid "Your wallet has no events recorded."
+#~ msgstr "Cüzdanınızda kayıtlı bir haraket yok."
+
+#~ msgid "Chose different exchange provider"
+#~ msgstr "Farklı bir exchange sağlayıcısı seçin"
+
+#, fuzzy
+#~ msgid ""
+#~ "You are about to withdraw %1$s from your bank account into your wallet."
+#~ msgstr "Banka hesabınızdan cüzdanınıza %1$s çekmek üzeresiniz."
+
+#~ msgid "Accept fees and withdraw"
+#~ msgstr "Ücretleri kabul edin ve para çekin"
+
+#~ msgid "Cancel withdraw operation"
+#~ msgstr "Para çekme işlemini iptal edin"
+
+#~ msgid "Rounding loss:"
+#~ msgstr "Yuvarlama kaybı:"
+
+#, fuzzy
+#~ msgid "Earliest expiration (for deposit): %1$s"
+#~ msgstr "En erken sona erme (depozito için): %1$s"
+
+#, fuzzy
+#~ msgid "# Coins"
+#~ msgstr "# Madeni para"
+
+#~ msgid "Value"
+#~ msgstr "Değer"
+
+#~ msgid "Withdraw Fee"
+#~ msgstr "Para çekme Ücreti"
diff --git a/packages/taler-wallet-webextension/src/i18n/uk.po b/packages/taler-wallet-webextension/src/i18n/uk.po
new file mode 100644
index 000000000..c4f5d6537
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/i18n/uk.po
@@ -0,0 +1,1956 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: languages@taler.net\n"
+"POT-Creation-Date: 2016-11-23 00:00+0100\n"
+"PO-Revision-Date: 2024-03-05 13:03+0000\n"
+"Last-Translator: Tim Vutor <flukes.ostrich0p@icloud.com>\n"
+"Language-Team: Ukrainian <https://weblate.taler.net/projects/gnu-taler/"
+"webextensions/uk/>\n"
+"Language: uk\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
+"X-Generator: Weblate 5.2.1\n"
+
+#: src/NavigationBar.tsx:139
+#, c-format
+msgid "Balance"
+msgstr "Баланс"
+
+#: src/NavigationBar.tsx:142
+#, c-format
+msgid "Backup"
+msgstr "Бекап"
+
+#: src/NavigationBar.tsx:147
+#, c-format
+msgid "QR Reader and Taler URI"
+msgstr "QR-читалка та Taler URI"
+
+#: src/NavigationBar.tsx:154
+#, c-format
+msgid "Settings"
+msgstr "Налаштування"
+
+#: src/NavigationBar.tsx:184
+#, c-format
+msgid "Dev"
+msgstr "Розробка"
+
+#: src/mui/Typography.tsx:122
+#, c-format, fuzzy
+msgid "%1$s"
+msgstr "%1$s"
+
+#: src/components/PendingTransactions.tsx:74
+#, c-format
+msgid "PENDING OPERATIONS"
+msgstr "НЕЗАВЕРШЕНІ ОПЕРАЦІЇ"
+
+#: src/components/Loading.tsx:36
+#, c-format
+msgid "Loading"
+msgstr "Завантаження"
+
+#: src/wallet/BackupPage.tsx:123
+#, c-format
+msgid "Could not load backup providers"
+msgstr "Не вдалося завантажити зберігачів резервних копій"
+
+#: src/wallet/BackupPage.tsx:202
+#, c-format
+msgid "No backup providers configured"
+msgstr "Не налаштовано жодного зберігача резервних копій"
+
+#: src/wallet/BackupPage.tsx:205
+#, c-format
+msgid "Add provider"
+msgstr "Додати зберігача"
+
+#: src/wallet/BackupPage.tsx:219
+#, c-format
+msgid "Sync all backups"
+msgstr "Синхронізувати всі резервні копії"
+
+#: src/wallet/BackupPage.tsx:221
+#, c-format
+msgid "Sync now"
+msgstr "Синхронізувати зараз"
+
+#: src/wallet/BackupPage.tsx:264
+#, c-format
+msgid "Last synced"
+msgstr "Останній раз синхронізовано"
+
+#: src/wallet/BackupPage.tsx:269
+#, c-format
+msgid "Not synced"
+msgstr "Не синхронізовано"
+
+#: src/wallet/BackupPage.tsx:289
+#, c-format
+msgid "Expires in"
+msgstr "Термін дії закінчується в"
+
+#: src/wallet/ProviderDetailPage.tsx:60
+#, c-format
+msgid "There was an error loading the provider detail for &quot; %1$s&quot;"
+msgstr "Виникла помилка при завантаженні інформації зберігача &quot; %1$s&quot;"
+
+#: src/wallet/ProviderDetailPage.tsx:108
+#, c-format
+msgid "There is not known provider with url &quot;%1$s&quot;."
+msgstr "Зберігач з посиланням &quot;%1$s&quot; невідомий."
+
+#: src/wallet/ProviderDetailPage.tsx:115
+#, c-format
+msgid "See providers"
+msgstr "Подивитись зберігачів"
+
+#: src/wallet/ProviderDetailPage.tsx:143
+#, c-format
+msgid "Last backup"
+msgstr "Остання резервна копія"
+
+#: src/wallet/ProviderDetailPage.tsx:148
+#, c-format
+msgid "Back up"
+msgstr "Зробити резервну копію"
+
+#: src/wallet/ProviderDetailPage.tsx:154
+#, c-format
+msgid "Provider fee"
+msgstr "Комісія зберігача"
+
+#: src/wallet/ProviderDetailPage.tsx:157
+#, c-format
+msgid "per year"
+msgstr "на рік"
+
+#: src/wallet/ProviderDetailPage.tsx:163
+#, c-format
+msgid "Extend"
+msgstr "Подовжити"
+
+#: src/wallet/ProviderDetailPage.tsx:169
+#, c-format
+msgid ""
+"terms has changed, extending the service will imply accepting the new terms of "
+"service"
+msgstr ""
+"умови надання послуг змінились, продовження послуги означатиме прийняття "
+"нових умов"
+
+#: src/wallet/ProviderDetailPage.tsx:179
+#, c-format
+msgid "old"
+msgstr "старий"
+
+#: src/wallet/ProviderDetailPage.tsx:183
+#, c-format
+msgid "new"
+msgstr "новий"
+
+#: src/wallet/ProviderDetailPage.tsx:190
+#, c-format
+msgid "fee"
+msgstr "комісія"
+
+#: src/wallet/ProviderDetailPage.tsx:198
+#, c-format
+msgid "storage"
+msgstr "сховище"
+
+#: src/wallet/ProviderDetailPage.tsx:215
+#, c-format
+msgid "Remove provider"
+msgstr "Видалити зберігача"
+
+#: src/wallet/ProviderDetailPage.tsx:228
+#, c-format
+msgid "This provider has reported an error"
+msgstr "Цей постачальник повідомив про помилку"
+
+#: src/wallet/ProviderDetailPage.tsx:242
+#, c-format
+msgid "There is conflict with another backup from %1$s"
+msgstr "Конфлікт з іншою резервною копією з %1$s"
+
+#: src/wallet/ProviderDetailPage.tsx:253
+#, c-format
+msgid "Backup is not readable"
+msgstr "Резервна копія пошкоджена або не може бути прочитана"
+
+#: src/wallet/ProviderDetailPage.tsx:261
+#, c-format
+msgid "Unknown backup problem: %1$s"
+msgstr "Невідома помилка резервного копіювання: %1$s"
+
+#: src/wallet/ProviderDetailPage.tsx:283
+#, c-format
+msgid "service paid"
+msgstr "послуга сплачена"
+
+#: src/wallet/ProviderDetailPage.tsx:290
+#, c-format
+msgid "Backup valid until"
+msgstr "Резервна копія дійсна до"
+
+#: src/wallet/AddNewActionView.tsx:57
+#, c-format
+msgid "Cancel"
+msgstr "Відмінити"
+
+#: src/wallet/AddNewActionView.tsx:68
+#, c-format
+msgid "Open reserve page"
+msgstr "Показати резерв"
+
+#: src/wallet/AddNewActionView.tsx:70
+#, c-format
+msgid "Open pay page"
+msgstr "Показати сторінку оплати"
+
+#: src/wallet/AddNewActionView.tsx:72
+#, c-format
+msgid "Open refund page"
+msgstr "Показати відшкодування"
+
+#: src/wallet/AddNewActionView.tsx:74
+#, c-format
+msgid "Open tip page"
+msgstr "Показати чайові"
+
+#: src/wallet/AddNewActionView.tsx:76
+#, c-format
+msgid "Open withdraw page"
+msgstr "Показати списання"
+
+#: src/popup/NoBalanceHelp.tsx:43
+#, c-format
+msgid "Get digital cash"
+msgstr "Отримати е-готівку"
+
+#: src/popup/BalancePage.tsx:138
+#, c-format
+msgid "Could not load balance page"
+msgstr "Не вдалося показати залишок"
+
+#: src/popup/BalancePage.tsx:175
+#, c-format
+msgid "Add"
+msgstr "Додати"
+
+#: src/popup/BalancePage.tsx:179
+#, c-format
+msgid "Send %1$s"
+msgstr "Переказати %1$s"
+
+#: src/popup/TalerActionFound.tsx:44
+#, c-format
+msgid "Taler Action"
+msgstr "Taler Дія"
+
+#: src/popup/TalerActionFound.tsx:49
+#, c-format
+msgid "This page has pay action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:63
+#, c-format
+msgid "This page has a withdrawal action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:79
+#, c-format
+msgid "This page has a tip action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:93
+#, c-format
+msgid "This page has a notify reserve action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:102
+#, c-format
+msgid "Notify"
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:109
+#, c-format
+msgid "This page has a refund action."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:123
+#, c-format
+msgid "This page has a malformed taler uri."
+msgstr ""
+
+#: src/popup/TalerActionFound.tsx:134
+#, c-format
+msgid "Dismiss"
+msgstr ""
+
+#: src/popup/Application.tsx:177
+#, c-format
+msgid "this popup is being closed and you are being redirected to %1$s"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:158
+#, c-format
+msgid "Could not load purchase proposal details"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:183
+#, c-format
+msgid "Order Id"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:189
+#, c-format
+msgid "Summary"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:195
+#, c-format
+msgid "Amount"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:203
+#, c-format
+msgid "Merchant name"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:209
+#, c-format
+msgid "Merchant jurisdiction"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:215
+#, c-format
+msgid "Merchant address"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:221
+#, c-format
+msgid "Merchant logo"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:234
+#, c-format
+msgid "Merchant website"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:240
+#, c-format
+msgid "Merchant email"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:246
+#, c-format
+msgid "Merchant public key"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:256
+#, c-format
+msgid "Delivery date"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:271
+#, c-format
+msgid "Delivery location"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:277
+#, c-format
+msgid "Products"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:289
+#, c-format
+msgid "Created at"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:304
+#, c-format
+msgid "Refund deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:319
+#, c-format
+msgid "Auto refund"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:339
+#, c-format
+msgid "Pay deadline"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:354
+#, c-format
+msgid "Fulfillment URL"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:360
+#, c-format
+msgid "Fulfillment message"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:370
+#, c-format
+msgid "Max deposit fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:378
+#, c-format
+msgid "Max fee"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:386
+#, c-format
+msgid "Minimum age"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:398
+#, c-format
+msgid "Wire fee amortization"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:404
+#, c-format
+msgid "Auditors"
+msgstr ""
+
+#: src/components/ShowFullContractTermPopup.tsx:419
+#, c-format
+msgid "Exchanges"
+msgstr ""
+
+#: src/components/Part.tsx:148
+#, c-format
+msgid "Bank account"
+msgstr ""
+
+#: src/components/Part.tsx:160
+#, c-format
+msgid "Bitcoin address"
+msgstr ""
+
+#: src/components/Part.tsx:163
+#, c-format
+msgid "IBAN"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:38
+#, c-format
+msgid "Could not load deposit status"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:52
+#, c-format
+msgid "Digital cash deposit"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:58
+#, c-format
+msgid "Cost"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:66
+#, c-format
+msgid "Fee"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:73
+#, c-format
+msgid "To be received"
+msgstr ""
+
+#: src/cta/Deposit/views.tsx:84
+#, c-format
+msgid "Send &nbsp; %1$s"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:63
+#, c-format
+msgid "Bitcoin transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:66
+#, c-format
+msgid ""
+"The exchange need a transaction with 3 output, one output is the exchange "
+"account and the other two are segwit fake address for metadata with an minimum "
+"amount."
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:74
+#, c-format
+msgid ""
+"In bitcoincore wallet use &apos;Add Recipient&apos; button to add two additional "
+"recipient and copy addresses and amounts"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:98
+#, c-format
+msgid "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:110
+#, c-format
+msgid "Account"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:116
+#, c-format
+msgid "Bank host"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:139
+#, c-format
+msgid "Bank transfer details"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:148
+#, c-format
+msgid "Subject"
+msgstr ""
+
+#: src/components/BankDetailsByPaytoType.tsx:154
+#, c-format
+msgid "Receiver name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:98
+#, c-format
+msgid "Could not load the transaction information"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:191
+#, c-format
+msgid "There was an error trying to complete the transaction"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:200
+#, c-format
+msgid "This transaction is not completed"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:209
+#, c-format
+msgid "Send"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:216
+#, c-format
+msgid "Retry"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:224
+#, c-format
+msgid "Forget"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:241
+#, c-format
+msgid "Caution!"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:244
+#, c-format
+msgid ""
+"If you have already wired money to the exchange you will loose the chance to get "
+"the coins form it."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:259
+#, c-format
+msgid "Confirm"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:267
+#, c-format
+msgid "Withdrawal"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:286
+#, c-format
+msgid ""
+"Make sure to use the correct subject, otherwise the money will not arrive in "
+"this wallet."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:298
+#, c-format
+msgid ""
+"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check "
+"there is no pending step."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:316
+#, c-format
+msgid "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:325
+#, c-format
+msgid "Details"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:360
+#, c-format
+msgid "Payment"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:378
+#, c-format
+msgid "Refunds"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:385
+#, c-format
+msgid "%1$s %2$s on %3$s"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:415
+#, c-format
+msgid "Merchant created a refund for this order but was not automatically picked up."
+msgstr ""
+
+#: src/wallet/Transaction.tsx:420
+#, c-format
+msgid "Offer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:431
+#, c-format
+msgid "Accept"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:438
+#, c-format
+msgid "Merchant"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:443
+#, c-format
+msgid "Invoice ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:470
+#, c-format
+msgid "Deposit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:496
+#, c-format
+msgid "Refresh"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:517
+#, c-format
+msgid "Tip"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:542
+#, c-format
+msgid "Refund"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:555
+#, c-format
+msgid "Original order ID"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:568
+#, c-format
+msgid "Purchase summary"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:593
+#, c-format
+msgid "copy"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:596
+#, c-format
+msgid "hide qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:608
+#, c-format
+msgid "show qr"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:620
+#, c-format
+msgid "Credit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:624
+#, c-format
+msgid "Invoice"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:635
+#, c-format
+msgid "Exchange"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:641
+#, c-format
+msgid "URI"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:667
+#, c-format
+msgid "Debit"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:710
+#, c-format
+msgid "Transfer"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:844
+#, c-format
+msgid "Country"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:852
+#, c-format
+msgid "Address lines"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:860
+#, c-format
+msgid "Building number"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:868
+#, c-format
+msgid "Building name"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:876
+#, c-format
+msgid "Street"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:884
+#, c-format
+msgid "Post code"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:892
+#, c-format
+msgid "Town location"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:900
+#, c-format
+msgid "Town"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:908
+#, c-format
+msgid "District"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:916
+#, c-format
+msgid "Country subdivision"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:935
+#, c-format
+msgid "Date"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:990
+#, c-format
+msgid "Transaction fees"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1004
+#, c-format
+msgid "Total"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1074
+#, c-format
+msgid "Withdraw"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1146
+#, c-format
+msgid "Price"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1156
+#, c-format
+msgid "Refunded"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1220
+#, c-format
+msgid "Delivery"
+msgstr ""
+
+#: src/wallet/Transaction.tsx:1335
+#, c-format
+msgid "Total transfer"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:57
+#, c-format
+msgid "Could not load pay status"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:87
+#, c-format
+msgid "Digital cash payment"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:119
+#, c-format
+msgid "Purchase"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:149
+#, c-format
+msgid "Receipt"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:156
+#, c-format
+msgid "Valid until"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:191
+#, c-format
+msgid "List of products"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:242
+#, c-format
+msgid "free"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:263
+#, c-format
+msgid "Already paid, you are going to be redirected to %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:274
+#, c-format
+msgid "Already paid"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:280
+#, c-format
+msgid "Already claimed"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:296
+#, c-format
+msgid "Pay with a mobile phone"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:298
+#, c-format
+msgid "Hide QR"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:305
+#, c-format
+msgid "Scan the QR code or &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:346
+#, c-format
+msgid "Pay &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Payment/views.tsx:360
+#, c-format
+msgid "You have no balance for this currency. Withdraw digital cash first."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:364
+#, c-format
+msgid ""
+"Could not find enough coins to pay. Even if you have enough %1$s some "
+"restriction may apply."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:366
+#, c-format
+msgid "Your current balance is not enough."
+msgstr ""
+
+#: src/cta/Payment/views.tsx:395
+#, c-format
+msgid "Merchant message"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:34
+#, c-format
+msgid "Could not load refund status"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:48
+#, c-format
+msgid "Digital cash refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:52
+#, c-format
+msgid "You&apos;ve ignored the tip."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:70
+#, c-format
+msgid "The refund is in progress."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:76
+#, c-format
+msgid "Total to refund"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:106
+#, c-format
+msgid "The merchant &quot;%1$s&quot; is offering you a refund."
+msgstr ""
+
+#: src/cta/Refund/views.tsx:115
+#, c-format
+msgid "Order amount"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:122
+#, c-format
+msgid "Already refunded"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:129
+#, c-format
+msgid "Refund offered"
+msgstr ""
+
+#: src/cta/Refund/views.tsx:145
+#, c-format
+msgid "Accept &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:32
+#, c-format
+msgid "Could not load tip status"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:45
+#, c-format
+msgid "Digital cash tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:66
+#, c-format
+msgid "The merchant is offering you a tip"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:74
+#, c-format
+msgid "Merchant URL"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:90
+#, c-format
+msgid "Receive &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Tip/views.tsx:114
+#, c-format
+msgid "Tip from %1$s accepted. Check your transactions list for more details."
+msgstr ""
+
+#: src/components/SelectList.tsx:66
+#, c-format
+msgid "Select one option"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:39
+#, c-format
+msgid "Could not load"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:73
+#, c-format
+msgid "Show terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:81
+#, c-format
+msgid "I accept the exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:107
+#, c-format
+msgid "Exchange doesn&apos;t have terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:135
+#, c-format
+msgid "Review exchange terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:146
+#, c-format
+msgid "Review new version of terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:170
+#, c-format
+msgid "The exchange reply with a empty terms of service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:193
+#, c-format
+msgid "Download Terms of Service"
+msgstr ""
+
+#: src/components/TermsOfService/views.tsx:204
+#, c-format
+msgid "Hide terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:117
+#, c-format
+msgid "Could not load exchange fees"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:131
+#, c-format
+msgid "Close"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:160
+#, c-format
+msgid "could not find any exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:166
+#, c-format
+msgid "could not find any exchange for the currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:186
+#, c-format
+msgid "Service fee description"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:201
+#, c-format
+msgid "Select %1$s exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:215
+#, c-format
+msgid "Reset"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:218
+#, c-format
+msgid "Use this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:230
+#, c-format
+msgid "Doesn&apos;t have auditors"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:241
+#, c-format
+msgid "currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:249
+#, c-format
+msgid "Operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:252
+#, c-format
+msgid "Deposits"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:259
+#, c-format
+msgid "Denomination"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:265
+#, c-format
+msgid "Until"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:274
+#, c-format
+msgid "Withdrawals"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:423
+#, c-format
+msgid "Currency"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:433
+#, c-format
+msgid "Coin operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:436
+#, c-format
+msgid ""
+"Every operation in this section may be different by denomination value and is "
+"valid for a period of time. The exchange will charge the indicated amount every "
+"time a coin is used in such operation."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:545
+#, c-format
+msgid "Transfer operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:548
+#, c-format
+msgid ""
+"Every operation in this section may be different by transfer type and is valid "
+"for a period of time. The exchange will charge the indicated amount every time a "
+"transfer is made."
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:563
+#, c-format
+msgid "Operation"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:583
+#, c-format
+msgid "Wallet operations"
+msgstr ""
+
+#: src/wallet/ExchangeSelection/views.tsx:597
+#, c-format
+msgid "Feature"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:47
+#, c-format
+msgid "Could not get the info from the URI"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:60
+#, c-format
+msgid "Could not get info of withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:74
+#, c-format
+msgid "Digital cash withdrawal"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:79
+#, c-format
+msgid "Could not finish the withdrawal operation"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:127
+#, c-format
+msgid "Age restriction"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:145
+#, c-format
+msgid "Withdraw &nbsp; %1$s"
+msgstr ""
+
+#: src/cta/Withdraw/views.tsx:179
+#, c-format
+msgid "Withdraw to a mobile phone"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:65
+#, c-format
+msgid "Digital invoice"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:69
+#, c-format
+msgid "Could not finish the invoice creation"
+msgstr ""
+
+#: src/cta/InvoiceCreate/views.tsx:130
+#, c-format
+msgid "Create"
+msgstr ""
+
+#: src/cta/InvoicePay/views.tsx:63
+#, c-format
+msgid "Could not finish the payment operation"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:55
+#, c-format
+msgid "Digital cash transfer"
+msgstr ""
+
+#: src/cta/TransferCreate/views.tsx:59
+#, c-format
+msgid "Could not finish the transfer creation"
+msgstr ""
+
+#: src/cta/TransferPickup/views.tsx:57
+#, c-format
+msgid "Could not finish the pickup operation"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:149
+#, c-format
+msgid "Manual Withdrawal for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:154
+#, c-format
+msgid ""
+"Choose a exchange from where the coins will be withdrawn. The exchange will send "
+"the coins to this wallet after receiving a wire transfer with the correct "
+"subject."
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:162
+#, c-format
+msgid "No exchange found for %1$s"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:170
+#, c-format
+msgid "Add Exchange"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:192
+#, c-format
+msgid "No exchange configured"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:210
+#, c-format
+msgid "Can&apos;t create the reserve"
+msgstr ""
+
+#: src/wallet/CreateManualWithdraw.tsx:277
+#, c-format
+msgid "Start withdrawal"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:38
+#, c-format
+msgid "Could not load deposit balance"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:51
+#, c-format
+msgid "A currency or an amount should be indicated"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:67
+#, c-format
+msgid "There is no enough balance to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:117
+#, c-format
+msgid "Send %1$s to your account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:121
+#, c-format
+msgid "There is no account to make a deposit for currency %1$s"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:127
+#, c-format
+msgid "Add account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:151
+#, c-format
+msgid "Select account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:163
+#, c-format
+msgid "Add another account"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:191
+#, c-format
+msgid "Deposit fee"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:205
+#, c-format
+msgid "Total deposit"
+msgstr ""
+
+#: src/wallet/DepositPage/views.tsx:233
+#, c-format
+msgid "Deposit&nbsp;%1$s %2$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:56
+#, c-format
+msgid "Add bank account for %1$s"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:59
+#, c-format
+msgid "Enter the URL of an exchange you trust."
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:66
+#, c-format
+msgid "Unable add this account"
+msgstr ""
+
+#: src/wallet/AddAccount/views.tsx:73
+#, c-format
+msgid "Select account type"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:42
+#, c-format
+msgid "Review terms of service"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:45
+#, c-format
+msgid "Exchange URL"
+msgstr ""
+
+#: src/wallet/ExchangeAddConfirm.tsx:70
+#, c-format
+msgid "Add exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:112
+#, c-format
+msgid "Add new exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:116
+#, c-format
+msgid "Add exchange for %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:128
+#, c-format
+msgid "An exchange has been found! Review the information and click next"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:135
+#, c-format
+msgid "This exchange doesn&apos;t match the expected currency %1$s"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:143
+#, c-format
+msgid "Unable to verify this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:151
+#, c-format
+msgid "Unable to add this exchange"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:167
+#, c-format
+msgid "loading"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:174
+#, c-format
+msgid "Version"
+msgstr ""
+
+#: src/wallet/ExchangeSetUrl.tsx:206
+#, c-format
+msgid "Next"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:201
+#, c-format
+msgid "Waiting for confirmation"
+msgstr ""
+
+#: src/components/TransactionItem.tsx:266
+#, c-format
+msgid "PENDING"
+msgstr ""
+
+#: src/wallet/History.tsx:75
+#, c-format
+msgid "Could not load the list of transactions"
+msgstr ""
+
+#: src/wallet/History.tsx:233
+#, c-format
+msgid "Your transaction history is empty for this currency."
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:127
+#, c-format
+msgid "Add backup provider"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:131
+#, c-format
+msgid "Could not get provider information"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:140
+#, c-format
+msgid "Backup providers may charge for their service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:147
+#, c-format
+msgid "URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:158
+#, c-format
+msgid "Name"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:212
+#, c-format
+msgid "Provider URL"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:218
+#, c-format
+msgid "Please review and accept this provider&apos;s terms of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:223
+#, c-format
+msgid "Pricing"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:226
+#, c-format
+msgid "free of charge"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:228
+#, c-format
+msgid "%1$s per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:235
+#, c-format
+msgid "Storage"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:238
+#, c-format
+msgid "%1$s megabytes of storage per year of service"
+msgstr ""
+
+#: src/wallet/ProviderAddPage.tsx:244
+#, c-format
+msgid "Accept terms of service"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:44
+#, c-format
+msgid "Could not parse the payto URI"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:45
+#, c-format
+msgid "Please check the uri"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:75
+#, c-format
+msgid "Exchange is ready for withdrawal"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:78
+#, c-format
+msgid "To complete the process you need to wire%1$s %2$s to the exchange bank account"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:87
+#, c-format
+msgid ""
+"Alternative, you can also scan this QR code or open %1$s if you have a banking "
+"app installed that supports RFC 8905"
+msgstr ""
+
+#: src/wallet/ReserveCreated.tsx:98
+#, c-format
+msgid "Cancel withdrawal"
+msgstr ""
+
+#: src/wallet/Settings.tsx:115
+#, c-format
+msgid "Could not toggle auto-open"
+msgstr ""
+
+#: src/wallet/Settings.tsx:121
+#, c-format
+msgid "Could not toggle clipboard"
+msgstr ""
+
+#: src/wallet/Settings.tsx:126
+#, c-format
+msgid "Navigator"
+msgstr ""
+
+#: src/wallet/Settings.tsx:129
+#, c-format
+msgid "Automatically open wallet based on page content"
+msgstr ""
+
+#: src/wallet/Settings.tsx:135
+#, c-format
+msgid ""
+"Enabling this option below will make using the wallet faster, but requires more "
+"permissions from your browser."
+msgstr ""
+
+#: src/wallet/Settings.tsx:145
+#, c-format
+msgid "Automatically check clipboard for Taler URI"
+msgstr ""
+
+#: src/wallet/Settings.tsx:162
+#, c-format
+msgid "Trust"
+msgstr ""
+
+#: src/wallet/Settings.tsx:166
+#, c-format
+msgid "No exchange yet"
+msgstr ""
+
+#: src/wallet/Settings.tsx:180
+#, c-format
+msgid "Term of Service"
+msgstr ""
+
+#: src/wallet/Settings.tsx:191
+#, c-format
+msgid "ok"
+msgstr ""
+
+#: src/wallet/Settings.tsx:197
+#, c-format
+msgid "changed"
+msgstr ""
+
+#: src/wallet/Settings.tsx:204
+#, c-format
+msgid "not accepted"
+msgstr ""
+
+#: src/wallet/Settings.tsx:210
+#, c-format
+msgid "unknown (exchange status should be updated)"
+msgstr ""
+
+#: src/wallet/Settings.tsx:236
+#, c-format
+msgid "Add an exchange"
+msgstr ""
+
+#: src/wallet/Settings.tsx:241
+#, c-format
+msgid "Troubleshooting"
+msgstr ""
+
+#: src/wallet/Settings.tsx:244
+#, c-format
+msgid "Developer mode"
+msgstr ""
+
+#: src/wallet/Settings.tsx:246
+#, c-format
+msgid "More options and information useful for debugging"
+msgstr ""
+
+#: src/wallet/Settings.tsx:257
+#, c-format
+msgid "Display"
+msgstr ""
+
+#: src/wallet/Settings.tsx:261
+#, c-format
+msgid "Current Language"
+msgstr ""
+
+#: src/wallet/Settings.tsx:274
+#, c-format
+msgid "Wallet Core"
+msgstr ""
+
+#: src/wallet/Settings.tsx:284
+#, c-format
+msgid "Web Extension"
+msgstr ""
+
+#: src/wallet/Settings.tsx:295
+#, c-format
+msgid "Exchange compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:299
+#, c-format
+msgid "Merchant compatibility"
+msgstr ""
+
+#: src/wallet/Settings.tsx:303
+#, c-format
+msgid "Bank compatibility"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:59
+#, c-format
+msgid "Browser Extension Installed!"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:63
+#, c-format
+msgid "You can open the GNU Taler Wallet using the combination %1$s ."
+msgstr ""
+
+#: src/wallet/Welcome.tsx:72
+#, c-format
+msgid ""
+"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick "
+"access without keyboard:"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:79
+#, c-format
+msgid "Click the puzzle icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:82
+#, c-format
+msgid "Search for GNU Taler Wallet"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:85
+#, c-format
+msgid "Click the pin icon"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:91
+#, c-format
+msgid "Permissions"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:100
+#, c-format
+msgid ""
+"(Enabling this option below will make using the wallet faster, but requires more "
+"permissions from your browser.)"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:110
+#, c-format
+msgid "Next Steps"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:113
+#, c-format
+msgid "Try the demo"
+msgstr ""
+
+#: src/wallet/Welcome.tsx:116
+#, c-format
+msgid "Learn how to top up your wallet balance"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:31
+#, c-format
+msgid "Diagnostics timed out. Could not talk to the wallet backend."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:52
+#, c-format
+msgid "Problems detected:"
+msgstr ""
+
+#: src/components/Diagnostics.tsx:61
+#, c-format
+msgid ""
+"Please check in your %1$s settings that you have IndexedDB enabled (check the "
+"preference name %2$s)."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:70
+#, c-format
+msgid ""
+"Your wallet database is outdated. Currently automatic migration is not "
+"supported. Please go %1$s to reset the wallet database."
+msgstr ""
+
+#: src/components/Diagnostics.tsx:83
+#, c-format
+msgid "Running diagnostics"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:163
+#, c-format
+msgid "Debug tools"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:170
+#, c-format
+msgid ""
+"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL "
+"YOUR COINS?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:176
+#, c-format
+msgid "reset"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:183
+#, c-format
+msgid "TESTING: This may delete all your coin, proceed with caution"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:189
+#, c-format
+msgid "run gc"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:197
+#, c-format
+msgid "import database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:219
+#, c-format
+msgid "export database"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:225
+#, c-format
+msgid "Database exported at %1$s %2$s to download"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:248
+#, c-format
+msgid "Coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:282
+#, c-format
+msgid "Pending operations"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:328
+#, c-format
+msgid "usable coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:337
+#, c-format
+msgid "id"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:340
+#, c-format
+msgid "denom"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:343
+#, c-format
+msgid "value"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:346
+#, c-format
+msgid "status"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:349
+#, c-format
+msgid "from refresh?"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:352
+#, c-format
+msgid "age key count"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:369
+#, c-format
+msgid "spent coins"
+msgstr ""
+
+#: src/wallet/DeveloperPage.tsx:373
+#, c-format
+msgid "click to show"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:108
+#, c-format
+msgid "Scan a QR code or enter taler:// URI below"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:122
+#, c-format
+msgid "Open"
+msgstr "Доступні"
+
+#: src/wallet/QrReader.tsx:128
+#, c-format
+msgid "URI is not valid. Taler URI should start with `taler://`"
+msgstr ""
+
+#: src/wallet/QrReader.tsx:133
+#, c-format
+msgid "Try another"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:183
+#, c-format
+msgid "Could not load list of exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:209
+#, c-format
+msgid "Choose a currency to proceed or add another exchange"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:217
+#, c-format
+msgid "Known currencies"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:318
+#, c-format
+msgid "Specify the amount and the origin"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:336
+#, c-format
+msgid "Change currency"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:344
+#, c-format
+msgid "Use previous origins:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:364
+#, c-format
+msgid "Or specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:372
+#, c-format
+msgid "Specify the origin of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:380
+#, c-format
+msgid "From my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:395
+#, c-format
+msgid "From another wallet"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:449
+#, c-format
+msgid "currency not provided"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:459
+#, c-format
+msgid "Specify the amount and the destination"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:483
+#, c-format
+msgid "Use previous destinations:"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:503
+#, c-format
+msgid "Or specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:511
+#, c-format
+msgid "Specify the destination of the money"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:521
+#, c-format
+msgid "To my bank account"
+msgstr ""
+
+#: src/wallet/DestinationSelection.tsx:534
+#, c-format
+msgid "To another wallet"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:30
+#, c-format
+msgid "Could not load backup recovery information"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:47
+#, c-format
+msgid "Digital wallet recovery"
+msgstr ""
+
+#: src/cta/Recovery/views.tsx:52
+#, c-format
+msgid "Import backup, show info"
+msgstr ""
+
+#: src/wallet/Application.tsx:189
+#, c-format
+msgid "All done, your transaction is in progress"
+msgstr ""
+
+#: src/components/EditableText.tsx:45
+#, c-format
+msgid "Edit"
+msgstr ""
+
+#: src/wallet/ManualWithdrawPage.tsx:102
+#, c-format
+msgid "Could not load the list of known exchanges"
+msgstr ""
diff --git a/packages/taler-wallet-webextension/src/mui/Alert.stories.tsx b/packages/taler-wallet-webextension/src/mui/Alert.stories.tsx
new file mode 100644
index 000000000..b0c2a2730
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Alert.stories.tsx
@@ -0,0 +1,107 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { css } from "@linaria/core";
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { Alert } from "./Alert.jsx";
+
+export default {
+ title: "alert",
+ component: Alert,
+};
+
+function Wrapper({ children }: { children: ComponentChildren }): VNode {
+ return (
+ <div
+ class={css`
+ & > * {
+ margin: 2em;
+ }
+ `}
+ >
+ {children}
+ </div>
+ );
+}
+
+export const BasicExample = (): VNode => (
+ <Wrapper>
+ <Alert severity="warning">this is an warning</Alert>
+ <Alert severity="error">this is an error</Alert>
+ <Alert severity="success">this is an success</Alert>
+ <Alert severity="info">this is an info</Alert>
+ </Wrapper>
+);
+
+export const WithTitle = (): VNode => (
+ <Wrapper>
+ <Alert title={"Warning" as TranslatedString} severity="warning">
+ this is an warning
+ </Alert>
+ <Alert title={"Error" as TranslatedString} severity="error">
+ this is an error
+ </Alert>
+ <Alert title={"Success" as TranslatedString} severity="success">
+ this is an success
+ </Alert>
+ <Alert title={"Info" as TranslatedString} severity="info">
+ this is an info
+ </Alert>
+ </Wrapper>
+);
+
+const showSomething = async function (): Promise<void> {
+ alert("closed");
+};
+
+export const WithAction = (): VNode => (
+ <Wrapper>
+ <Alert
+ title={"Warning" as TranslatedString}
+ severity="warning"
+ onClose={showSomething}
+ >
+ this is an warning
+ </Alert>
+ <Alert
+ title={"Error" as TranslatedString}
+ severity="error"
+ onClose={showSomething}
+ >
+ this is an error
+ </Alert>
+ <Alert
+ title={"Success" as TranslatedString}
+ severity="success"
+ onClose={showSomething}
+ >
+ this is an success
+ </Alert>
+ <Alert
+ title={"Info" as TranslatedString}
+ severity="info"
+ onClose={showSomething}
+ >
+ this is an info
+ </Alert>
+ </Wrapper>
+);
diff --git a/packages/taler-wallet-webextension/src/mui/Alert.tsx b/packages/taler-wallet-webextension/src/mui/Alert.tsx
new file mode 100644
index 000000000..22ea0b8ab
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Alert.tsx
@@ -0,0 +1,175 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { css } from "@linaria/core";
+import { ComponentChildren, h, VNode } from "preact";
+// eslint-disable-next-line import/extensions
+import CloseIcon from "../svg/close_24px.inline.svg";
+import ErrorOutlineIcon from "../svg/error_outline_outlined_24px.inline.svg";
+import InfoOutlinedIcon from "../svg/info_outlined_24px.inline.svg";
+import ReportProblemOutlinedIcon from "../svg/report_problem_outlined_24px.inline.svg";
+import SuccessOutlinedIcon from "../svg/success_outlined_24px.inline.svg";
+import { IconButton } from "./Button.js";
+import { darken, lighten } from "./colors/manipulation.js";
+import { Paper } from "./Paper.js";
+import { theme } from "./style.jsx";
+import { Typography } from "./Typography.js";
+
+const defaultIconMapping = {
+ success: SuccessOutlinedIcon,
+ warning: ReportProblemOutlinedIcon,
+ error: ErrorOutlineIcon,
+ info: InfoOutlinedIcon,
+};
+
+const baseStyle = css`
+ background-color: transparent;
+ display: flex;
+ padding: 6px 16px;
+`;
+
+const colorVariant = {
+ standard: css`
+ color: var(--color-light-06);
+ background-color: var(--color-background-light-09);
+ `,
+ outlined: css`
+ color: var(--color-light-06);
+ border-width: 1px;
+ border-style: solid;
+ border-color: var(--color-light);
+ `,
+ filled: css`
+ color: "#fff";
+ font-weight: ${theme.typography.fontWeightMedium};
+ background-color: var(--color-main);
+ `,
+};
+
+interface Props {
+ title?: TranslatedString;
+ variant?: "filled" | "outlined" | "standard";
+ role?: string;
+ onClose?: () => Promise<void>;
+ // icon: VNode;
+ severity?: "info" | "warning" | "success" | "error";
+ children: ComponentChildren;
+ icon?: boolean;
+}
+
+const getColor = theme.palette.mode === "light" ? darken : lighten;
+const getBackgroundColor = theme.palette.mode === "light" ? lighten : darken;
+
+function Icon({ svg }: { svg: VNode }): VNode {
+ return (
+ <div
+ class={css`
+ margin-right: 12px;
+ padding: 7px 0px;
+ display: flex;
+ font-size: 22px;
+ opacity: 0.9;
+ fill: currentColor;
+ `}
+ dangerouslySetInnerHTML={{ __html: svg as any }}
+ ></div>
+ );
+}
+
+function Action({ children }: { children: ComponentChildren }): VNode {
+ return (
+ <div
+ class={css`
+ display: flex;
+ align-items: flex-start;
+ padding: 4px 0px 0px 16px;
+ margin-left: auto;
+ margin-right: -8px;
+ `}
+ >
+ {children}
+ </div>
+ );
+}
+
+function Message({
+ title,
+ children,
+}: {
+ title?: TranslatedString;
+ children: ComponentChildren;
+}): VNode {
+ return (
+ <div
+ class={css`
+ padding: 8px 0px;
+ width: calc(100% - 48px - 36px);
+ `}
+ >
+ {title && (
+ <Typography
+ class={css`
+ font-weight: ${theme.typography.fontWeightBold};
+ `}
+ gutterBottom
+ >
+ {title}
+ </Typography>
+ )}
+ {children}
+ </div>
+ );
+}
+
+export function Alert({
+ variant = "standard",
+ severity = "success",
+ title,
+ children,
+ icon,
+ onClose,
+ ...rest
+}: Props): VNode {
+ return (
+ <Paper
+ class={[
+ theme.typography.body2,
+ baseStyle,
+ severity && colorVariant[variant],
+ ].join(" ")}
+ style={{
+ "--color-light-06": getColor(theme.palette[severity].light, 0.6),
+ "--color-background-light-09": getBackgroundColor(
+ theme.palette[severity].light,
+ 0.9,
+ ),
+ "--color-main": theme.palette[severity].main,
+ "--color-light": theme.palette[severity].light,
+ // ...(style as any),
+ textAlign: "left",
+ }}
+ elevation={1}
+ >
+ {icon != false ? <Icon svg={defaultIconMapping[severity]} /> : null}
+ <Message title={title}>{children}</Message>
+ {onClose && (
+ <Action>
+ <IconButton svg={CloseIcon} onClick={onClose} />
+ </Action>
+ )}
+ </Paper>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Avatar.tsx b/packages/taler-wallet-webextension/src/mui/Avatar.tsx
new file mode 100644
index 000000000..b6e37d2ce
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Avatar.tsx
@@ -0,0 +1,69 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { h, JSX, VNode, ComponentChildren } from "preact";
+// eslint-disable-next-line import/extensions
+import { theme } from "./style.jsx";
+
+const root = css`
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ font-family: ${theme.typography.fontFamily};
+ font-size: ${theme.typography.pxToRem(20)};
+ line-height: 1;
+ overflow: hidden;
+ user-select: none;
+`;
+
+// const colorStyle = css`
+// color: ${theme.palette.background.default};
+// background-color: ${theme.palette.mode === "light"
+// ? theme.palette.grey[400]
+// : theme.palette.grey[600]};
+// `;
+
+// const avatarImageStyle = css`
+// width: 100%;
+// height: 100%;
+// text-align: center;
+// object-fit: cover;
+// color: transparent;
+// text-indent: 10000;
+// `;
+
+interface Props extends JSX.HTMLAttributes<HTMLDivElement> {
+ variant?: "circular" | "rounded" | "square";
+ children?: ComponentChildren;
+}
+
+export function Avatar({ variant, children, ...rest }: Props): VNode {
+ const borderStyle =
+ variant === "square"
+ ? theme.shape.squareBorder
+ : variant === "rounded"
+ ? theme.shape.roundBorder
+ : theme.shape.circularBorder;
+ return (
+ <div class={[root, borderStyle].join(" ")} {...rest}>
+ {children}
+ </div>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Button.stories.tsx b/packages/taler-wallet-webextension/src/mui/Button.stories.tsx
new file mode 100644
index 000000000..5506caa42
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Button.stories.tsx
@@ -0,0 +1,163 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Button } from "./Button.js";
+import { Fragment, h, VNode } from "preact";
+import DeleteIcon from "../svg/delete_24px.inline.svg";
+import SendIcon from "../svg/send_24px.inline.svg";
+import { styled } from "@linaria/react";
+
+export default {
+ title: "Button",
+};
+
+const Stack = styled.div`
+ display: flex;
+ flex-direction: column;
+ & > button {
+ margin: 14px;
+ }
+ background-color: white;
+`;
+
+export const WithDelay = (): VNode => (
+ <Stack>
+ <Button
+ size="small"
+ variant="contained"
+ onClick={() =>
+ new Promise((resolve) => {
+ setTimeout(resolve, 2000);
+ })
+ }
+ >
+ Returns after 2 seconds
+ </Button>
+ <Button
+ size="small"
+ variant="contained"
+ onClick={() =>
+ new Promise((_, reject) => {
+ setTimeout(reject, 2000);
+ })
+ }
+ >
+ Fails after 2 seconds
+ </Button>
+ </Stack>
+);
+
+export const BasicExample = (): VNode => (
+ <Fragment>
+ <Stack>
+ <Button size="small" variant="text">
+ Text
+ </Button>
+ <Button size="small" variant="contained">
+ Contained
+ </Button>
+ <Button size="small" variant="outlined">
+ Outlined
+ </Button>
+ </Stack>
+ <Stack>
+ <Button variant="text">Text</Button>
+ <Button variant="contained">Contained</Button>
+ <Button variant="outlined">Outlined</Button>
+ </Stack>
+ <Stack>
+ <Button size="large" variant="text">
+ Text
+ </Button>
+ <Button size="large" variant="contained">
+ Contained
+ </Button>
+ <Button size="large" variant="outlined">
+ Outlined
+ </Button>
+ </Stack>
+ </Fragment>
+);
+
+export const Others = (): VNode => (
+ <Fragment>
+ <p>colors</p>
+ <Stack>
+ <Button color="secondary">Secondary</Button>
+ <Button variant="contained" color="success">
+ Success
+ </Button>
+ <Button variant="outlined" color="error">
+ Error
+ </Button>
+ </Stack>
+ <p>disabled</p>
+ <Stack>
+ <Button disabled variant="text">
+ Text
+ </Button>
+ <Button disabled variant="contained">
+ Contained
+ </Button>
+ <Button disabled variant="outlined">
+ Outlined
+ </Button>
+ </Stack>
+ </Fragment>
+);
+
+export const WithIcons = (): VNode => (
+ <Fragment>
+ <Stack>
+ <Button variant="outlined" size="small" startIcon={DeleteIcon}>
+ Delete
+ </Button>
+ <Button variant="contained" size="small" endIcon={SendIcon}>
+ Send
+ </Button>
+ <Button variant="text" size="small" endIcon={SendIcon}>
+ Send
+ </Button>
+ </Stack>
+ <Stack>
+ <Button variant="outlined" startIcon={DeleteIcon}>
+ Delete
+ </Button>
+ <Button variant="contained" endIcon={SendIcon}>
+ Send
+ </Button>
+ <Button variant="text" endIcon={SendIcon}>
+ Send
+ </Button>
+ </Stack>
+ <Stack>
+ <Button variant="outlined" size="large" startIcon={DeleteIcon}>
+ Delete
+ </Button>
+ <Button variant="contained" size="large" endIcon={SendIcon}>
+ Send
+ </Button>
+ <Button variant="text" size="large" endIcon={SendIcon}>
+ Send
+ </Button>
+ </Stack>
+ </Fragment>
+);
diff --git a/packages/taler-wallet-webextension/src/mui/Button.tsx b/packages/taler-wallet-webextension/src/mui/Button.tsx
new file mode 100644
index 000000000..1af281d42
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Button.tsx
@@ -0,0 +1,405 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { ComponentChildren, h, VNode, JSX } from "preact";
+import { css } from "@linaria/core";
+// eslint-disable-next-line import/extensions
+import {
+ theme,
+ Colors,
+ rippleEnabled,
+ rippleEnabledOutlined,
+} from "./style.js";
+// eslint-disable-next-line import/extensions
+import { alpha } from "./colors/manipulation.js";
+import { useState } from "preact/hooks";
+import { SafeHandler } from "./handlers.js";
+
+export const buttonBaseStyle = css`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ box-sizing: border-box;
+ background-color: transparent;
+ outline: 0;
+ border: 0;
+ margin: 0;
+ border-radius: 0;
+ padding: 0;
+ cursor: pointer;
+ user-select: none;
+ vertical-align: middle;
+ text-decoration: none;
+ color: inherit;
+`;
+
+interface Props {
+ children?: ComponentChildren;
+ disabled?: boolean;
+ disableElevation?: boolean;
+ disableFocusRipple?: boolean;
+ endIcon?: string | VNode;
+ fullWidth?: boolean;
+ style?: h.JSX.CSSProperties;
+ href?: string;
+ size?: "small" | "medium" | "large";
+ startIcon?: VNode | string;
+ variant?: "contained" | "outlined" | "text";
+ tooltip?: string;
+ color?: Colors;
+ onClick?: () => Promise<void>;
+ // onClick?: SafeHandler<void>;
+}
+
+const button = css`
+ min-width: 64px;
+ &:hover {
+ text-decoration: none;
+ background-color: var(--text-primary-alpha-opacity);
+ @media (hover: none) {
+ background-color: transparent;
+ }
+ }
+ &:disabled {
+ color: ${theme.palette.action.disabled};
+ }
+`;
+const colorIconVariant = {
+ outlined: css`
+ fill: var(--color-main);
+ `,
+ contained: css`
+ fill: var(--color-contrastText);
+ `,
+ text: css`
+ fill: var(--color-main);
+ `,
+};
+
+const colorVariant = {
+ outlined: css`
+ color: var(--color-main);
+ border: 1px solid var(--color-main-alpha-half);
+ background-color: var(--color-contrastText);
+ &:hover {
+ border: 1px solid var(--color-main);
+ background-color: var(--color-main-alpha-opacity);
+ }
+ &:disabled {
+ border: 1px solid ${theme.palette.action.disabledBackground};
+ }
+ `,
+ contained: css`
+ color: var(--color-contrastText);
+ background-color: var(--color-main);
+ box-shadow: ${theme.shadows[2]};
+ &:hover {
+ background-color: var(--color-grey-or-dark);
+ }
+ &:active {
+ box-shadow: ${theme.shadows[8]};
+ }
+ &:focus-visible {
+ box-shadow: ${theme.shadows[6]};
+ }
+ &:disabled {
+ color: ${theme.palette.action.disabled};
+ box-shadow: ${theme.shadows[0]};
+ background-color: ${theme.palette.action.disabledBackground};
+ }
+ `,
+ text: css`
+ color: var(--color-main);
+ &:hover {
+ background-color: var(--color-main-alpha-opacity);
+ }
+ `,
+};
+
+const sizeIconVariant = {
+ outlined: {
+ small: css`
+ padding: 3px;
+ font-size: ${theme.pxToRem(7)};
+ `,
+ medium: css`
+ padding: 5px;
+ `,
+ large: css`
+ padding: 7px;
+ font-size: ${theme.pxToRem(10)};
+ `,
+ },
+ contained: {
+ small: css`
+ padding: 4px;
+ font-size: ${theme.pxToRem(13)};
+ `,
+ medium: css`
+ padding: 6px;
+ `,
+ large: css`
+ padding: 8px;
+ font-size: ${theme.pxToRem(10)};
+ `,
+ },
+ text: {
+ small: css`
+ padding: 4px;
+ font-size: ${theme.pxToRem(13)};
+ `,
+ medium: css`
+ padding: 6px;
+ `,
+ large: css`
+ padding: 8px;
+ font-size: ${theme.pxToRem(15)};
+ `,
+ },
+};
+const sizeVariant = {
+ outlined: {
+ small: css`
+ padding: 3px 9px;
+ font-size: ${theme.pxToRem(13)};
+ `,
+ medium: css`
+ padding: 5px 15px;
+ `,
+ large: css`
+ padding: 7px 21px;
+ font-size: ${theme.pxToRem(15)};
+ `,
+ },
+ contained: {
+ small: css`
+ padding: 4px 10px;
+ font-size: ${theme.pxToRem(13)};
+ `,
+ medium: css`
+ padding: 6px 16px;
+ `,
+ large: css`
+ padding: 8px 22px;
+ font-size: ${theme.pxToRem(15)};
+ `,
+ },
+ text: {
+ small: css`
+ padding: 4px 5px;
+ font-size: ${theme.pxToRem(13)};
+ `,
+ medium: css`
+ padding: 6px 8px;
+ `,
+ large: css`
+ padding: 8px 11px;
+ font-size: ${theme.pxToRem(15)};
+ `,
+ },
+};
+
+const fullWidthStyle = css`
+ width: 100%;
+`;
+
+export function Button({
+ children,
+ disabled,
+ startIcon: sip,
+ endIcon: eip,
+ fullWidth,
+ tooltip,
+ variant = "text",
+ size = "medium",
+ style: parentStyle,
+ color = "primary",
+ onClick: doClick,
+}: Props): VNode {
+ const style = css`
+ user-select: none;
+ width: 24px;
+ height: 24px;
+ display: inline-block;
+ fill: currentColor;
+ flex-shrink: 0;
+ transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
+
+ & > svg {
+ font-size: 20;
+ }
+ `;
+
+ const startIcon = sip && (
+ <span
+ class={[
+ css`
+ margin-right: 8px;
+ margin-left: -4px;
+ mask: var(--image) no-repeat center;
+ `,
+ colorIconVariant[variant],
+ sizeIconVariant[variant][size],
+ style,
+ ].join(" ")}
+ //FIXME: check when sip can be a vnode
+ dangerouslySetInnerHTML={{ __html: sip as string }}
+ style={{
+ "--color-main": theme.palette[color].main,
+ "--color-contrastText": theme.palette[color].contrastText,
+ }}
+ />
+ );
+ const endIcon = eip && (
+ <span
+ class={[
+ css`
+ margin-right: -4px;
+ margin-left: 8px;
+ mask: var(--image) no-repeat center;
+ `,
+ colorIconVariant[variant],
+ sizeIconVariant[variant][size],
+ style,
+ ].join(" ")}
+ dangerouslySetInnerHTML={{ __html: eip as string }}
+ style={{
+ "--color-main": theme.palette[color].main,
+ "--color-contrastText": theme.palette[color].contrastText,
+ "--color-dark": theme.palette[color].dark,
+ }}
+ />
+ );
+ const [running, setRunning] = useState(false);
+
+ async function onClick(): Promise<void> {
+ if (!doClick || disabled || running) return;
+ setRunning(true);
+ try {
+ await doClick();
+ } finally {
+ setRunning(false);
+ }
+ }
+
+ return (
+ <ButtonBase
+ disabled={disabled || running || !doClick}
+ class={[
+ theme.typography.button,
+ theme.shape.roundBorder,
+ button,
+ fullWidth && fullWidthStyle,
+ colorVariant[variant],
+ sizeVariant[variant][size],
+ ].join(" ")}
+ containedRipple={variant === "contained"}
+ onClick={onClick}
+ style={{
+ ...parentStyle,
+ "--color-main": theme.palette[color].main,
+ "--color-contrastText": theme.palette[color].contrastText,
+ "--color-main-alpha-half": alpha(theme.palette[color].main, 0.5),
+ "--color-dark": theme.palette[color].dark,
+ "--color-light": theme.palette[color].light,
+ "--color-main-alpha-opacity": alpha(
+ theme.palette[color].main,
+ theme.palette.action.hoverOpacity,
+ ),
+ "--text-primary-alpha-opacity": alpha(
+ theme.palette.text.primary,
+ theme.palette.action.hoverOpacity,
+ ),
+ "--color-grey-or-dark": !color
+ ? theme.palette.grey.A100
+ : theme.palette[color].dark,
+ }}
+ title={tooltip}
+ >
+ {startIcon}
+ {children}
+ {endIcon}
+ </ButtonBase>
+ );
+}
+
+interface BaseProps extends JSX.HTMLAttributes<HTMLButtonElement> {
+ class: string;
+ onClick?: () => Promise<void>;
+ containedRipple?: boolean;
+ children?: ComponentChildren;
+ svg?: any;
+}
+
+function ButtonBase({
+ class: _class,
+ children,
+ containedRipple,
+ onClick,
+ svg,
+ ...rest
+}: BaseProps): VNode {
+ function doClick(): void {
+ if (onClick) onClick();
+ }
+ const classNames = [
+ buttonBaseStyle,
+ _class,
+ containedRipple ? rippleEnabled : rippleEnabledOutlined,
+ ].join(" ");
+ if (svg) {
+ return (
+ <button
+ onClick={doClick}
+ class={classNames}
+ dangerouslySetInnerHTML={{ __html: svg }}
+ {...rest}
+ />
+ );
+ }
+ return (
+ <button onClick={doClick} class={classNames} {...rest}>
+ {children}
+ </button>
+ );
+}
+
+export function IconButton({
+ svg,
+ onClick,
+}: {
+ svg: any;
+ onClick?: () => Promise<void>;
+}): VNode {
+ return (
+ <ButtonBase
+ onClick={onClick}
+ class={[
+ css`
+ text-align: center;
+ flex: 0 0 auto;
+ font-size: ${theme.typography.pxToRem(24)};
+ padding: 8px;
+ border-radius: 50%;
+ overflow: visible;
+ color: "inherit";
+ fill: currentColor;
+ `,
+ ].join(" ")}
+ svg={svg}
+ />
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Divider.tsx b/packages/taler-wallet-webextension/src/mui/Divider.tsx
new file mode 100644
index 000000000..6f5ae343e
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Divider.tsx
@@ -0,0 +1,20 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, Fragment, VNode } from "preact";
+
+export function Divider(): VNode {
+ return <Fragment />;
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Grid.stories.tsx b/packages/taler-wallet-webextension/src/mui/Grid.stories.tsx
new file mode 100644
index 000000000..d399cb825
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Grid.stories.tsx
@@ -0,0 +1,212 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Grid } from "./Grid.js";
+import { Fragment, h, VNode } from "preact";
+
+export default {
+ title: "grid",
+ component: Grid,
+};
+
+function Item({ children }: any): VNode {
+ return (
+ <div
+ style={{
+ padding: 10,
+ backgroundColor: "white",
+ textAlign: "center",
+ color: "back",
+ }}
+ >
+ {children}
+ </div>
+ );
+}
+
+function Wrapper({ children }: any): VNode {
+ return (
+ <div
+ style={{
+ display: "flex",
+ backgroundColor: "lightgray",
+ padding: 10,
+ width: "100%",
+ // width: 400,
+ // height: 400,
+ justifyContent: "center",
+ }}
+ >
+ <div style={{ flexGrow: 1 }}>{children}</div>
+ </div>
+ );
+}
+
+export const BasicExample = (): VNode => (
+ <Fragment>
+ <Wrapper>
+ <Grid container spacing={2}>
+ <Grid item xs={8}>
+ <Item>xs=8</Item>
+ </Grid>
+ <Grid item xs={4}>
+ <Item>xs=4</Item>
+ </Grid>
+ <Grid item xs={4}>
+ <Item>xs=4</Item>
+ </Grid>
+ <Grid item xs={8}>
+ <Item>xs=8</Item>
+ </Grid>
+ </Grid>
+ </Wrapper>
+ <Wrapper>
+ <Grid container spacing={2}>
+ <Grid item xs={6} md={8}>
+ <Item>xs=6 md=8</Item>
+ </Grid>
+ <Grid item xs={6} md={4}>
+ <Item>xs=6 md=4</Item>
+ </Grid>
+ <Grid item xs={6} md={4}>
+ <Item>xs=6 md=4</Item>
+ </Grid>
+ <Grid item xs={6} md={8}>
+ <Item>xs=6 md=8</Item>
+ </Grid>
+ </Grid>
+ </Wrapper>
+ </Fragment>
+);
+
+export const Responsive12ColumnsSize = (): VNode => (
+ <Fragment>
+ <Wrapper>
+ <p>Item size is responsive: xs=6 sm=4 md=2</p>
+ <Grid container spacing={1} columns={12}>
+ {Array.from(Array(6)).map((_, index) => (
+ <Grid item xs={6} sm={4} md={2} key={index}>
+ <Item>item {index}</Item>
+ </Grid>
+ ))}
+ </Grid>
+ </Wrapper>
+ <Wrapper>
+ <p>Item size is fixed</p>
+ <Grid container spacing={1} columns={12}>
+ {Array.from(Array(6)).map((_, index) => (
+ <Grid item xs={6} key={index}>
+ <Item>item {index}</Item>
+ </Grid>
+ ))}
+ </Grid>
+ </Wrapper>
+ </Fragment>
+);
+
+export const Responsive12Spacing = (): VNode => (
+ <Fragment>
+ <Wrapper>
+ <p>Item space is responsive: xs=1 sm=2 md=3</p>
+ <Grid container spacing={{ xs: 2, sm: 4, md: 6 }} columns={12}>
+ {Array.from(Array(6)).map((_, index) => (
+ <Grid item xs={6} key={index}>
+ <Item>item {index}</Item>
+ </Grid>
+ ))}
+ </Grid>
+ </Wrapper>
+ <Wrapper>
+ <p>Item space is fixed</p>
+ <Grid container spacing={1} columns={12}>
+ {Array.from(Array(6)).map((_, index) => (
+ <Grid item xs={6} key={index}>
+ <Item>item {index}</Item>
+ </Grid>
+ ))}
+ </Grid>
+ </Wrapper>
+
+ <Wrapper>
+ <p>Item row space is responsive: xs=6 sm=4 md=1</p>
+ <Grid
+ container
+ rowSpacing={{ xs: 6, sm: 3, md: 1 }}
+ columnSpacing={1}
+ columns={12}
+ >
+ {Array.from(Array(6)).map((_, index) => (
+ <Grid item xs={6} key={index}>
+ <Item>item {index}</Item>
+ </Grid>
+ ))}
+ </Grid>
+ </Wrapper>
+ <Wrapper>
+ <p>Item col space is responsive: xs=6 sm=3 md=1</p>
+ <Grid
+ container
+ columnSpacing={{ xs: 6, sm: 3, md: 1 }}
+ rowSpacing={1}
+ columns={12}
+ >
+ {Array.from(Array(6)).map((_, index) => (
+ <Grid item xs={6} key={index}>
+ <Item>item {index}</Item>
+ </Grid>
+ ))}
+ </Grid>
+ </Wrapper>
+ </Fragment>
+);
+
+export const ResponsiveAuthWidth = (): VNode => (
+ <Fragment>
+ <Wrapper>
+ <Grid container columns={12}>
+ <Grid item>
+ <Item>item 1</Item>
+ </Grid>
+ <Grid item xs={1}>
+ <Item>item 2 short</Item>
+ </Grid>
+ <Grid item>
+ <Item>item 3 with long text </Item>
+ </Grid>
+ <Grid item xs={"true"}>
+ <Item>item 4</Item>
+ </Grid>
+ </Grid>
+ </Wrapper>
+ </Fragment>
+);
+export const Example = (): VNode => (
+ <Wrapper>
+ <p>Item row space is responsive: xs=6 sm=4 md=1</p>
+ <Grid container rowSpacing={3} columnSpacing={1} columns={12}>
+ {Array.from(Array(6)).map((_, index) => (
+ <Grid item xs={6} key={index}>
+ <Item>item {index}</Item>
+ </Grid>
+ ))}
+ </Grid>
+ </Wrapper>
+);
diff --git a/packages/taler-wallet-webextension/src/mui/Grid.tsx b/packages/taler-wallet-webextension/src/mui/Grid.tsx
new file mode 100644
index 000000000..2db439778
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Grid.tsx
@@ -0,0 +1,347 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { h, JSX, VNode, ComponentChildren, createContext } from "preact";
+import { useContext } from "preact/hooks";
+// eslint-disable-next-line import/extensions
+import { theme } from "./style.js";
+
+type ResponsiveKeys = "xs" | "sm" | "md" | "lg" | "xl";
+
+export type ResponsiveSize = {
+ xs: number;
+ sm: number;
+ md: number;
+ lg: number;
+ xl: number;
+};
+
+const root = css`
+ box-sizing: border-box;
+`;
+const containerStyle = css`
+ display: flex;
+ flex-wrap: wrap;
+ width: 100%;
+`;
+const itemStyle = css`
+ margin: 0;
+`;
+const zeroMinWidthStyle = css`
+ min-width: 0px;
+`;
+
+type GridSizes = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
+type SpacingSizes = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
+
+export interface Props extends JSX.HTMLAttributes<HTMLDivElement> {
+ columns?: number | Partial<ResponsiveSize>;
+ container?: boolean;
+ item?: boolean;
+
+ direction?: "column-reverse" | "column" | "row-reverse" | "row";
+
+ lg?: GridSizes | "auto" | "true";
+ md?: GridSizes | "auto" | "true";
+ sm?: GridSizes | "auto" | "true";
+ xl?: GridSizes | "auto" | "true";
+ xs?: GridSizes | "auto" | "true";
+
+ wrap?: "nowrap" | "wrap-reverse" | "wrap";
+ spacing?: SpacingSizes | Partial<ResponsiveSize>;
+ columnSpacing?: SpacingSizes | Partial<ResponsiveSize>;
+ rowSpacing?: SpacingSizes | Partial<ResponsiveSize>;
+
+ alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
+ justifyContent?:
+ | "flex-start"
+ | "flex-end"
+ | "center"
+ | "space-around"
+ | "space-between"
+ | "space-evenly";
+
+ zeroMinWidth?: boolean;
+ children: ComponentChildren;
+}
+theme.breakpoints.up;
+
+function getOffset(val: number | string): string | number {
+ if (typeof val === "number") `${val}px`;
+ return val;
+}
+
+const columnGapVariant = css`
+ ${theme.breakpoints.up("xs")} {
+ width: calc(100% + var(--space-col-xs));
+ margin-left: calc(-1 * var(--space-col-xs));
+ & > div {
+ padding-left: var(--space-col-xs);
+ }
+ }
+ ${theme.breakpoints.up("sm")} {
+ width: calc(100% + var(--space-col-sm));
+ margin-left: calc(-1 * var(--space-col-sm));
+ & > div {
+ padding-left: var(--space-col-sm);
+ }
+ }
+ ${theme.breakpoints.up("md")} {
+ width: calc(100% + var(--space-col-md));
+ margin-left: calc(-1 * var(--space-col-md));
+ & > div {
+ padding-left: var(--space-col-md);
+ }
+ }
+ ${theme.breakpoints.up("lg")} {
+ width: calc(100% + var(--space-col-lg));
+ margin-left: calc(-1 * var(--space-col-lg));
+ & > div {
+ padding-left: var(--space-col-lg);
+ }
+ }
+ ${theme.breakpoints.up("xl")} {
+ width: calc(100% + var(--space-col-xl));
+ margin-left: calc(-1 * var(--space-col-xl));
+ & > div {
+ padding-left: var(--space-col-xl);
+ }
+ }
+`;
+const rowGapVariant = css`
+ ${theme.breakpoints.up("xs")} {
+ margin-top: calc(-1 * var(--space-row-xs));
+ & > div {
+ padding-top: var(--space-row-xs);
+ }
+ }
+ ${theme.breakpoints.up("sm")} {
+ margin-top: calc(-1 * var(--space-row-sm));
+ & > div {
+ padding-top: var(--space-row-sm);
+ }
+ }
+ ${theme.breakpoints.up("md")} {
+ margin-top: calc(-1 * var(--space-row-md));
+ & > div {
+ padding-top: var(--space-row-md);
+ }
+ }
+ ${theme.breakpoints.up("lg")} {
+ margin-top: calc(-1 * var(--space-row-lg));
+ & > div {
+ padding-top: var(--space-row-lg);
+ }
+ }
+ ${theme.breakpoints.up("xl")} {
+ margin-top: calc(-1 * var(--space-row-xl));
+ & > div {
+ padding-top: var(--space-row-xl);
+ }
+ }
+`;
+
+const sizeVariantXS = css`
+ ${theme.breakpoints.up("xs")} {
+ flex-basis: var(--relation-col-vs-xs);
+ flex-grow: 0;
+ max-width: var(--relation-col-vs-xs);
+ }
+`;
+const sizeVariantSM = css`
+ ${theme.breakpoints.up("sm")} {
+ flex-basis: var(--relation-col-vs-sm);
+ flex-grow: 0;
+ max-width: var(--relation-col-vs-sm);
+ }
+`;
+const sizeVariantMD = css`
+ ${theme.breakpoints.up("md")} {
+ flex-basis: var(--relation-col-vs-md);
+ flex-grow: 0;
+ max-width: var(--relation-col-vs-md);
+ }
+`;
+const sizeVariantLG = css`
+ ${theme.breakpoints.up("lg")} {
+ flex-basis: var(--relation-col-vs-lg);
+ flex-grow: 0;
+ max-width: var(--relation-col-vs-lg);
+ }
+`;
+const sizeVariantXL = css`
+ ${theme.breakpoints.up("xl")} {
+ flex-basis: var(--relation-col-vs-xl);
+ flex-grow: 0;
+ max-width: var(--relation-col-vs-xl);
+ }
+`;
+
+const sizeVariantExpand = css`
+ flex-basis: 0;
+ flex-grow: 1;
+ max-width: 100%;
+`;
+
+const sizeVariantAuto = css`
+ flex-basis: auto;
+ flex-grow: 0;
+ flex-shrink: 0;
+ max-width: none;
+ width: auto;
+`;
+
+const GridContext = createContext<Partial<ResponsiveSize>>(toResponsive(12));
+
+function toResponsive(
+ v: number | Partial<ResponsiveSize>,
+): Partial<ResponsiveSize> {
+ const p = typeof v === "number" ? { xs: v } : v;
+ const xs = p.xs;
+ const sm = p.sm || xs;
+ const md = p.md || sm;
+ const lg = p.lg || md;
+ const xl = p.xl || lg;
+ return {
+ xs,
+ sm,
+ md,
+ lg,
+ xl,
+ };
+}
+
+export function Grid({
+ columns: cp,
+ container = false,
+ item = false,
+ direction = "row",
+ lg,
+ md,
+ sm,
+ xl,
+ xs,
+ wrap = "wrap",
+ spacing = 0,
+ columnSpacing: csp,
+ rowSpacing: rsp,
+ alignItems,
+ justifyContent,
+ zeroMinWidth = false,
+ children,
+ onClick,
+ ...rest
+}: Props): VNode {
+ const cc = useContext(GridContext);
+ const columns = !cp ? cc : toResponsive(cp);
+
+ const rowSpacing = rsp ? toResponsive(rsp) : toResponsive(spacing);
+ const columnSpacing = csp ? toResponsive(csp) : toResponsive(spacing);
+
+ const ssize = toResponsive({ xs, md, lg, xl, sm } as any);
+
+ const spacingStyles = !container
+ ? {}
+ : {
+ "--space-col-xs": getOffset(theme.spacing(columnSpacing.xs)),
+ "--space-col-sm": getOffset(theme.spacing(columnSpacing.sm)),
+ "--space-col-md": getOffset(theme.spacing(columnSpacing.md)),
+ "--space-col-lg": getOffset(theme.spacing(columnSpacing.lg)),
+ "--space-col-xl": getOffset(theme.spacing(columnSpacing.xl)),
+
+ "--space-row-xs": getOffset(theme.spacing(rowSpacing.xs)),
+ "--space-row-sm": getOffset(theme.spacing(rowSpacing.sm)),
+ "--space-row-md": getOffset(theme.spacing(rowSpacing.md)),
+ "--space-row-lg": getOffset(theme.spacing(rowSpacing.lg)),
+ "--space-row-xl": getOffset(theme.spacing(rowSpacing.xl)),
+ };
+ const relationStyles = !item
+ ? {}
+ : {
+ "--relation-col-vs-xs": relation(columns, ssize, "xs"),
+ "--relation-col-vs-sm": relation(columns, ssize, "sm"),
+ "--relation-col-vs-md": relation(columns, ssize, "md"),
+ "--relation-col-vs-lg": relation(columns, ssize, "lg"),
+ "--relation-col-vs-xl": relation(columns, ssize, "xl"),
+ };
+
+ return (
+ <GridContext.Provider value={columns}>
+ <div
+ class={[
+ root,
+ container && containerStyle,
+ item && itemStyle,
+ zeroMinWidth && zeroMinWidthStyle,
+ xs &&
+ (xs === "auto"
+ ? sizeVariantAuto
+ : xs === "true"
+ ? sizeVariantExpand
+ : sizeVariantXS),
+ sm &&
+ (sm === "auto"
+ ? sizeVariantAuto
+ : sm === "true"
+ ? sizeVariantExpand
+ : sizeVariantSM),
+ md &&
+ (md === "auto"
+ ? sizeVariantAuto
+ : md === "true"
+ ? sizeVariantExpand
+ : sizeVariantMD),
+ lg &&
+ (lg === "auto"
+ ? sizeVariantAuto
+ : lg === "true"
+ ? sizeVariantExpand
+ : sizeVariantLG),
+ xl &&
+ (xl === "auto"
+ ? sizeVariantAuto
+ : xl === "true"
+ ? sizeVariantExpand
+ : sizeVariantXL),
+ container && columnGapVariant,
+ container && rowGapVariant,
+ ].join(" ")}
+ style={{
+ ...relationStyles,
+ ...spacingStyles,
+ justifyContent,
+ alignItems,
+ flexWrap: wrap,
+ cursor: onClick ? "pointer" : "inherit",
+ }}
+ onClick={onClick}
+ {...rest}
+ >
+ {children}
+ </div>
+ </GridContext.Provider>
+ );
+}
+function relation(
+ cols: Partial<ResponsiveSize>,
+ values: Partial<ResponsiveSize>,
+ size: ResponsiveKeys,
+): string {
+ const colsNum = typeof cols === "number" ? cols : cols[size] || 12;
+ return (
+ String(Math.round(((values[size] || 1) / colsNum) * 10e7) / 10e5) + "%"
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/InputFile.tsx b/packages/taler-wallet-webextension/src/mui/InputFile.tsx
new file mode 100644
index 000000000..40e9f9ace
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/InputFile.tsx
@@ -0,0 +1,78 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { useRef, useState } from "preact/hooks";
+import { Button } from "./Button.js";
+// import { MAX_IMAGE_SIZE as MAX_IMAGE_UPLOAD_SIZE } from "../../utils/constants";
+// import { InputProps, useField } from "./useField";
+
+const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024;
+
+interface Props {
+ children: ComponentChildren;
+ onChange: (v: string) => void;
+}
+
+export function InputFile<T>({ onChange, children }: Props): VNode {
+ const image = useRef<HTMLInputElement>(null);
+
+ const [sizeError, setSizeError] = useState(false);
+
+ return (
+ <div>
+ <p>
+ <Button
+ variant="contained"
+ onClick={async () => image.current?.click()}
+ >
+ {children}
+ </Button>
+ <input
+ ref={image}
+ style={{ display: "none" }}
+ type="file"
+ // name={String(name)}
+ onChange={(e) => {
+ const f: FileList | null = e.currentTarget.files;
+ if (!f || f.length != 1) {
+ return;
+ }
+ if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) {
+ setSizeError(true);
+ return;
+ }
+ setSizeError(false);
+ return f[0].arrayBuffer().then((b) => {
+ const b64 = btoa(
+ new Uint8Array(b).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ ),
+ );
+ return onChange(`data:${f[0].type};base64,${b64}` as any);
+ });
+ }}
+ />
+ </p>
+ {sizeError && <p>Image should be smaller than 1 MB</p>}
+ </div>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Menu.stories.tsx b/packages/taler-wallet-webextension/src/mui/Menu.stories.tsx
new file mode 100644
index 000000000..200af8f57
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Menu.stories.tsx
@@ -0,0 +1,171 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Menu, MenuItem } from "./Menu.jsx";
+import { Paper } from "./Paper.js";
+
+export default {
+ title: "menu",
+ component: Menu,
+};
+
+export const BasicExample = (): VNode => {
+ const [open, setOpen] = useState(false);
+ async function handleClose(): Promise<void> {
+ setOpen(false);
+ }
+ async function handleClick(): Promise<void> {
+ setOpen(true);
+ }
+ return (
+ <Menu open={open} onClose={handleClose} onClick={handleClick}>
+ <MenuItem onClick={handleClose}>Profile</MenuItem>
+ <MenuItem onClick={handleClose}>My account</MenuItem>
+ <MenuItem onClick={handleClose}>Logout</MenuItem>
+ </Menu>
+ );
+};
+
+import { styled } from "@linaria/react";
+import { theme } from "./style.js";
+import { Typography } from "./Typography.js";
+import { Divider } from "./Divider.js";
+
+const ListItemIcon = styled.div`
+ min-width: 36px;
+ color: ${theme.palette.action.active};
+ flex-shrink: 0;
+ display: inline-flex;
+`;
+
+const IconCut = (): VNode => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ width: "1em",
+ height: "1em",
+ }}
+ >
+ <path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3z" />
+ </svg>
+);
+
+const IconCopy = (): VNode => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ width: "1em",
+ height: "1em",
+ }}
+ >
+ <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
+ </svg>
+);
+
+const IconPaste = (): VNode => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ width: "1em",
+ height: "1em",
+ }}
+ >
+ <path d="M19 2h-4.18C14.4.84 13.3 0 12 0c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm7 18H5V4h2v3h10V4h2v16z" />
+ </svg>
+);
+
+const IconCloud = (): VNode => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ width: "1em",
+ height: "1em",
+ }}
+ >
+ <path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
+ </svg>
+);
+
+const ListItemText = styled.div`
+ flex: 1 1 auto;
+ min-width: 0px;
+ margin-top: 4px;
+ margin-bottom: 4px;
+`;
+
+export function IconMenu(): VNode {
+ return (
+ <div style={{ width: 320 }}>
+ <Paper>
+ <ul>
+ <MenuItem>
+ <ListItemIcon>
+ <IconCut />
+ </ListItemIcon>
+ <ListItemText>Cut</ListItemText>
+ <Typography variant="body2" /* color="text.secondary" */>
+ ⌘X
+ </Typography>
+ </MenuItem>
+ <MenuItem>
+ <ListItemIcon>
+ <IconCopy />
+ </ListItemIcon>
+ <ListItemText>Copy</ListItemText>
+ <Typography variant="body2" /* color="text.secondary" */>
+ ⌘C
+ </Typography>
+ </MenuItem>
+ <MenuItem>
+ <ListItemIcon>
+ <IconPaste />
+ </ListItemIcon>
+ <ListItemText>Paste</ListItemText>
+ <Typography variant="body2" /* color="text.secondary" */>
+ ⌘V
+ </Typography>
+ </MenuItem>
+ <Divider />
+ <MenuItem>
+ <ListItemIcon>
+ <IconCloud />
+ </ListItemIcon>
+ <ListItemText>Web Clipboard</ListItemText>
+ </MenuItem>
+ </ul>
+ </Paper>
+ </div>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Menu.tsx b/packages/taler-wallet-webextension/src/mui/Menu.tsx
new file mode 100644
index 000000000..dd8266931
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Menu.tsx
@@ -0,0 +1,135 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { h, VNode, Fragment, ComponentChildren } from "preact";
+import { buttonBaseStyle } from "./Button.js";
+import { alpha } from "./colors/manipulation.js";
+import { Paper } from "./Paper.js";
+// eslint-disable-next-line import/extensions
+import { Colors, ripple, theme } from "./style.js";
+
+interface Props {
+ children: ComponentChildren;
+ onClose: () => Promise<void>;
+ onClick: () => Promise<void>;
+ open?: boolean;
+}
+
+const menuPaper = css`
+ max-height: calc(100% - 96px);
+ -webkit-overflow-scrolling: touch;
+`;
+
+const menuList = css`
+ outline: 0px;
+`;
+
+export function Menu({ children, onClose, onClick, open }: Props): VNode {
+ return (
+ <Popover class={menuPaper}>
+ <ul class={menuList}>{children}</ul>
+ </Popover>
+ );
+}
+
+const popoverRoot = css``;
+
+const popoverPaper = css`
+ position: absolute;
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-width: 16px;
+ min-height: 16px;
+ max-width: calc(100% - 32px);
+ max-height: calc(100% - 32px);
+ outline: 0;
+`;
+
+function Popover({ children }: any): VNode {
+ return (
+ <div class={popoverRoot}>
+ <Paper class={popoverPaper}>{children}</Paper>
+ </div>
+ );
+}
+
+const root = css`
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ position: relative;
+ text-decoration: none;
+ min-height: 48px;
+ padding-top: 6px;
+ padding-bottom: 6px;
+ box-sizing: border-box;
+ white-space: nowrap;
+ appearance: none;
+
+ &:not([data-disableGutters]) {
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+
+ [data-dividers] {
+ border-bottom: 1px solid ${theme.palette.divider};
+ background-clip: padding-box;
+ }
+ &:hover {
+ text-decoration: none;
+ background-color: var(--color-main-alpha-half);
+ @media (hover: none) {
+ background-color: transparent;
+ }
+ }
+`;
+
+export function MenuItem({
+ children,
+ onClick,
+ color = "primary",
+}: {
+ children: ComponentChildren;
+ onClick?: () => Promise<void>;
+ color?: Colors;
+}): VNode {
+ function doClick(): void {
+ // if (onClick) onClick();
+ return;
+ }
+ return (
+ <li
+ onClick={doClick}
+ disabled={false}
+ role="menuitem"
+ class={[buttonBaseStyle, root, ripple].join(" ")}
+ style={{
+ "--color-main": theme.palette[color].main,
+ "--color-dark": theme.palette[color].dark,
+ "--color-grey-or-dark": !color
+ ? theme.palette.grey.A100
+ : theme.palette[color].dark,
+ "--color-main-alpha-half": alpha(theme.palette[color].main, 0.7),
+ "--color-main-alpha-opacity": alpha(
+ theme.palette[color].main,
+ theme.palette.action.hoverOpacity,
+ ),
+ }}
+ >
+ {children}
+ </li>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Modal.tsx b/packages/taler-wallet-webextension/src/mui/Modal.tsx
new file mode 100644
index 000000000..0ea1372fa
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Modal.tsx
@@ -0,0 +1,152 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { css } from "@linaria/core";
+import { h, JSX, VNode, ComponentChildren } from "preact";
+import { useCallback, useEffect, useRef, useState } from "preact/hooks";
+// eslint-disable-next-line import/extensions
+import { alpha } from "./colors/manipulation.js";
+import { ModalManager } from "./ModalManager.js";
+import { Portal } from "./Portal.js";
+// eslint-disable-next-line import/extensions
+import { theme } from "./style.js";
+
+const baseStyle = css`
+ position: fixed;
+ z-index: ${theme.zIndex.modal};
+ right: 0px;
+ bottom: 0px;
+ top: 0px;
+ left: 0px;
+`;
+
+interface Props {
+ class: string;
+ children: ComponentChildren;
+ open?: boolean;
+ exited?: boolean;
+ container?: VNode;
+}
+
+const defaultManager = new ModalManager();
+const manager = defaultManager;
+
+function getModal(): any {
+ return null; //TODO: fix
+}
+
+export function Modal({
+ open,
+ // exited,
+ class: _class,
+ children,
+
+ container,
+ ...rest
+}: Props): VNode {
+ const [exited, setExited] = useState(true);
+ const mountNodeRef = useRef<HTMLElement | undefined>(undefined);
+
+ const isTopModal = useCallback(
+ () => manager.isTopModal(getModal()),
+ [manager],
+ );
+
+ const handlePortalRef = useEventCallback<HTMLElement[], void>((node) => {
+ mountNodeRef.current = node;
+
+ if (!node) {
+ return;
+ }
+
+ // if (open && isTopModal()) {
+ // handleMounted();
+ // } else {
+ // ariaHidden(modalRef.current, true);
+ // }
+ });
+
+ return (
+ <Portal
+ // ref={mountNodeRef}
+ // container={container}
+ // disablePortal={disablePortal}
+ >
+ <div
+ class={[_class, baseStyle].join(" ")}
+ style={{
+ visibility: !open && exited ? "hidden" : "visible",
+ }}
+ >
+ {children}
+ </div>
+ </Portal>
+ );
+}
+
+function getOffsetTop(rect: any, vertical: any): number {
+ let offset = 0;
+
+ if (typeof vertical === "number") {
+ offset = vertical;
+ } else if (vertical === "center") {
+ offset = rect.height / 2;
+ } else if (vertical === "bottom") {
+ offset = rect.height;
+ }
+
+ return offset;
+}
+
+function getOffsetLeft(rect: any, horizontal: any): number {
+ let offset = 0;
+
+ if (typeof horizontal === "number") {
+ offset = horizontal;
+ } else if (horizontal === "center") {
+ offset = rect.width / 2;
+ } else if (horizontal === "right") {
+ offset = rect.width;
+ }
+
+ return offset;
+}
+
+function getTransformOriginValue(transformOrigin: any): string {
+ return [transformOrigin.horizontal, transformOrigin.vertical]
+ .map((n) => (typeof n === "number" ? `${n}px` : n))
+ .join(" ");
+}
+
+function resolveAnchorEl(anchorEl: any): any {
+ return typeof anchorEl === "function" ? anchorEl() : anchorEl;
+}
+
+function useEventCallback<Args extends unknown[], Return>(
+ fn: (...args: Args) => Return,
+): (...args: Args) => Return {
+ const ref = useRef(fn);
+ useEffect(() => {
+ ref.current = fn;
+ });
+ return useCallback(
+ (...args: Args) =>
+ // @ts-expect-error hide `this`
+ // tslint:disable-next-line:ban-comma-operator
+ (0, ref.current!)(...args),
+ [],
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/ModalManager.ts b/packages/taler-wallet-webextension/src/mui/ModalManager.ts
new file mode 100644
index 000000000..eee037467
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/ModalManager.ts
@@ -0,0 +1,328 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+////////////////////
+function ownerDocument(node: Node | null | undefined): Document {
+ return (node && node.ownerDocument) || document;
+}
+function ownerWindow(node: Node | undefined): Window {
+ const doc = ownerDocument(node);
+ return doc.defaultView || window;
+}
+// A change of the browser zoom change the scrollbar size.
+// Credit https://github.com/twbs/bootstrap/blob/488fd8afc535ca3a6ad4dc581f5e89217b6a36ac/js/src/util/scrollbar.js#L14-L18
+function getScrollbarSize(doc: Document): number {
+ // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes
+ const documentWidth = doc.documentElement.clientWidth;
+ return Math.abs(window.innerWidth - documentWidth);
+}
+
+/////////////////////
+
+export interface ManagedModalProps {
+ disableScrollLock?: boolean;
+}
+
+// Is a vertical scrollbar displayed?
+function isOverflowing(container: Element): boolean {
+ const doc = ownerDocument(container);
+
+ if (doc.body === container) {
+ return ownerWindow(container).innerWidth > doc.documentElement.clientWidth;
+ }
+
+ return container.scrollHeight > container.clientHeight;
+}
+
+export function ariaHidden(element: Element, show: boolean): void {
+ if (show) {
+ element.setAttribute("aria-hidden", "true");
+ } else {
+ element.removeAttribute("aria-hidden");
+ }
+}
+
+function getPaddingRight(element: Element): number {
+ return (
+ parseInt(ownerWindow(element).getComputedStyle(element).paddingRight, 10) ||
+ 0
+ );
+}
+
+function ariaHiddenSiblings(
+ container: Element,
+ mountElement: Element,
+ currentElement: Element,
+ elementsToExclude: readonly Element[] = [],
+ show: boolean,
+): void {
+ const blacklist = [mountElement, currentElement, ...elementsToExclude];
+ const blacklistTagNames = ["TEMPLATE", "SCRIPT", "STYLE"];
+
+ [].forEach.call(container.children, (element: Element) => {
+ if (
+ blacklist.indexOf(element) === -1 &&
+ blacklistTagNames.indexOf(element.tagName) === -1
+ ) {
+ ariaHidden(element, show);
+ }
+ });
+}
+
+function findIndexOf<T>(
+ items: readonly T[],
+ callback: (item: T) => boolean,
+): number {
+ let idx = -1;
+ items.some((item, index) => {
+ if (callback(item)) {
+ idx = index;
+ return true;
+ }
+ return false;
+ });
+ return idx;
+}
+
+function handleContainer(containerInfo: Container, props: ManagedModalProps) {
+ const restoreStyle: Array<{
+ /**
+ * CSS property name (HYPHEN CASE) to be modified.
+ */
+ property: string;
+ el: HTMLElement | SVGElement;
+ value: string;
+ }> = [];
+ const container = containerInfo.container;
+
+ if (!props.disableScrollLock) {
+ if (isOverflowing(container)) {
+ // Compute the size before applying overflow hidden to avoid any scroll jumps.
+ const scrollbarSize = getScrollbarSize(ownerDocument(container));
+
+ restoreStyle.push({
+ value: container.style.paddingRight,
+ property: "padding-right",
+ el: container,
+ });
+ // Use computed style, here to get the real padding to add our scrollbar width.
+ container.style.paddingRight = `${
+ getPaddingRight(container) + scrollbarSize
+ }px`;
+
+ // .mui-fixed is a global helper.
+ const fixedElements =
+ ownerDocument(container).querySelectorAll(".mui-fixed");
+ [].forEach.call(fixedElements, (element: HTMLElement | SVGElement) => {
+ restoreStyle.push({
+ value: element.style.paddingRight,
+ property: "padding-right",
+ el: element,
+ });
+ element.style.paddingRight = `${
+ getPaddingRight(element) + scrollbarSize
+ }px`;
+ });
+ }
+
+ // Improve Gatsby support
+ // https://css-tricks.com/snippets/css/force-vertical-scrollbar/
+ const parent = container.parentElement;
+ const containerWindow = ownerWindow(container);
+ const scrollContainer =
+ parent?.nodeName === "HTML" &&
+ containerWindow.getComputedStyle(parent).overflowY === "scroll"
+ ? parent
+ : container;
+
+ // Block the scroll even if no scrollbar is visible to account for mobile keyboard
+ // screensize shrink.
+ restoreStyle.push(
+ {
+ value: scrollContainer.style.overflow,
+ property: "overflow",
+ el: scrollContainer,
+ },
+ {
+ value: scrollContainer.style.overflowX,
+ property: "overflow-x",
+ el: scrollContainer,
+ },
+ {
+ value: scrollContainer.style.overflowY,
+ property: "overflow-y",
+ el: scrollContainer,
+ },
+ );
+
+ scrollContainer.style.overflow = "hidden";
+ }
+
+ const restore = () => {
+ restoreStyle.forEach(({ value, el, property }) => {
+ if (value) {
+ el.style.setProperty(property, value);
+ } else {
+ el.style.removeProperty(property);
+ }
+ });
+ };
+
+ return restore;
+}
+
+function getHiddenSiblings(container: Element) {
+ const hiddenSiblings: Element[] = [];
+ [].forEach.call(container.children, (element: Element) => {
+ if (element.getAttribute("aria-hidden") === "true") {
+ hiddenSiblings.push(element);
+ }
+ });
+ return hiddenSiblings;
+}
+
+interface Modal {
+ mount: Element;
+ modalRef: Element;
+}
+
+interface Container {
+ container: HTMLElement;
+ hiddenSiblings: Element[];
+ modals: Modal[];
+ restore: null | (() => void);
+}
+
+export class ModalManager {
+ private containers: Container[];
+
+ private modals: Modal[];
+
+ constructor() {
+ this.modals = [];
+ this.containers = [];
+ }
+
+ add(modal: Modal, container: HTMLElement): number {
+ let modalIndex = this.modals.indexOf(modal);
+ if (modalIndex !== -1) {
+ return modalIndex;
+ }
+
+ modalIndex = this.modals.length;
+ this.modals.push(modal);
+
+ // If the modal we are adding is already in the DOM.
+ if (modal.modalRef) {
+ ariaHidden(modal.modalRef, false);
+ }
+
+ const hiddenSiblings = getHiddenSiblings(container);
+ ariaHiddenSiblings(
+ container,
+ modal.mount,
+ modal.modalRef,
+ hiddenSiblings,
+ true,
+ );
+
+ const containerIndex = findIndexOf(
+ this.containers,
+ (item) => item.container === container,
+ );
+ if (containerIndex !== -1) {
+ this.containers[containerIndex].modals.push(modal);
+ return modalIndex;
+ }
+
+ this.containers.push({
+ modals: [modal],
+ container,
+ restore: null,
+ hiddenSiblings,
+ });
+
+ return modalIndex;
+ }
+
+ mount(modal: Modal, props: ManagedModalProps): void {
+ const containerIndex = findIndexOf(
+ this.containers,
+ (item) => item.modals.indexOf(modal) !== -1,
+ );
+ const containerInfo = this.containers[containerIndex];
+
+ if (!containerInfo.restore) {
+ containerInfo.restore = handleContainer(containerInfo, props);
+ }
+ }
+
+ remove(modal: Modal): number {
+ const modalIndex = this.modals.indexOf(modal);
+
+ if (modalIndex === -1) {
+ return modalIndex;
+ }
+
+ const containerIndex = findIndexOf(
+ this.containers,
+ (item) => item.modals.indexOf(modal) !== -1,
+ );
+ const containerInfo = this.containers[containerIndex];
+
+ containerInfo.modals.splice(containerInfo.modals.indexOf(modal), 1);
+ this.modals.splice(modalIndex, 1);
+
+ // If that was the last modal in a container, clean up the container.
+ if (containerInfo.modals.length === 0) {
+ // The modal might be closed before it had the chance to be mounted in the DOM.
+ if (containerInfo.restore) {
+ containerInfo.restore();
+ }
+
+ if (modal.modalRef) {
+ // In case the modal wasn't in the DOM yet.
+ ariaHidden(modal.modalRef, true);
+ }
+
+ ariaHiddenSiblings(
+ containerInfo.container,
+ modal.mount,
+ modal.modalRef,
+ containerInfo.hiddenSiblings,
+ false,
+ );
+ this.containers.splice(containerIndex, 1);
+ } else {
+ // Otherwise make sure the next top modal is visible to a screen reader.
+ const nextTop = containerInfo.modals[containerInfo.modals.length - 1];
+ // as soon as a modal is adding its modalRef is undefined. it can't set
+ // aria-hidden because the dom element doesn't exist either
+ // when modal was unmounted before modalRef gets null
+ if (nextTop.modalRef) {
+ ariaHidden(nextTop.modalRef, false);
+ }
+ }
+
+ return modalIndex;
+ }
+
+ isTopModal(modal: Modal): boolean {
+ return (
+ this.modals.length > 0 && this.modals[this.modals.length - 1] === modal
+ );
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Paper.stories.tsx b/packages/taler-wallet-webextension/src/mui/Paper.stories.tsx
new file mode 100644
index 000000000..b0e06d137
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Paper.stories.tsx
@@ -0,0 +1,148 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { h, VNode } from "preact";
+import { Paper } from "./Paper.js";
+
+export default {
+ title: "paper",
+ component: Paper,
+};
+
+export const BasicExample = (): VNode => (
+ <div
+ style={{
+ display: "flex",
+ wrap: "nowrap",
+ backgroundColor: "lightgray",
+ width: "100%",
+ padding: 10,
+ justifyContent: "space-between",
+ }}
+ >
+ <Paper elevation={0}>
+ <div style={{ height: 128, width: 128 }} />
+ </Paper>
+ <Paper>
+ <div style={{ height: 128, width: 128 }} />
+ </Paper>
+ <Paper elevation={3}>
+ <div style={{ height: 128, width: 128 }} />
+ </Paper>
+ <Paper elevation={8}>
+ <div style={{ height: 128, width: 128 }} />
+ </Paper>
+ </div>
+);
+
+export const Outlined = (): VNode => (
+ <div
+ style={{
+ display: "flex",
+ wrap: "nowrap",
+ backgroundColor: "lightgray",
+ width: "100%",
+ padding: 10,
+ justifyContent: "space-around",
+ }}
+ >
+ <Paper variant="outlined">
+ <div
+ style={{
+ textAlign: "center",
+ height: 128,
+ width: 128,
+ lineHeight: "128px",
+ }}
+ >
+ round
+ </div>
+ </Paper>
+ <Paper variant="outlined" square>
+ <div
+ style={{
+ textAlign: "center",
+ height: 128,
+ width: 128,
+ lineHeight: "128px",
+ }}
+ >
+ square
+ </div>
+ </Paper>
+ </div>
+);
+
+export const Elevation = (): VNode => (
+ <div
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ backgroundColor: "lightgray",
+ width: "100%",
+ padding: 50,
+ justifyContent: "space-around",
+ }}
+ >
+ {[0, 1, 2, 3, 4, 6, 8, 12, 16, 24].map((elevation) => (
+ <div style={{ marginTop: 50 }} key={elevation}>
+ <Paper elevation={elevation}>
+ <div
+ style={{
+ textAlign: "center",
+ height: 60,
+ lineHeight: "60px",
+ }}
+ >{`elevation=${elevation}`}</div>
+ </Paper>
+ </div>
+ ))}
+ </div>
+);
+
+export const ElevationDark = (): VNode => (
+ <div
+ class="theme-dark"
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ backgroundColor: "lightgray",
+ width: "100%",
+ padding: 50,
+ justifyContent: "space-around",
+ }}
+ >
+ to be implemented
+ {/* {[0, 1, 2, 3, 4, 6, 8, 12, 16, 24].map((elevation) => (
+ <div style={{ marginTop: 50 }} key={elevation}>
+ <Paper elevation={elevation}>
+ <div
+ style={{
+ textAlign: "center",
+ height: 60,
+ lineHeight: "60px",
+ }}
+ >{`elevation=${elevation}`}</div>
+ </Paper>
+ </div>
+ ))} */}
+ </div>
+);
diff --git a/packages/taler-wallet-webextension/src/mui/Paper.tsx b/packages/taler-wallet-webextension/src/mui/Paper.tsx
new file mode 100644
index 000000000..a44b1be0d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Paper.tsx
@@ -0,0 +1,85 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { h, JSX, VNode, ComponentChildren } from "preact";
+// eslint-disable-next-line import/extensions
+import { alpha } from "./colors/manipulation.js";
+// eslint-disable-next-line import/extensions
+import { theme } from "./style.js";
+
+const borderVariant = {
+ outlined: css`
+ border: 1px solid ${theme.palette.divider};
+ `,
+ elevation: css`
+ box-shadow: var(--theme-shadow-elevation);
+ `,
+};
+const baseStyle = css`
+ .theme-dark & {
+ background-image: var(--gradient-white-elevation);
+ }
+`;
+
+interface Props extends JSX.HTMLAttributes<HTMLDivElement> {
+ elevation?: number;
+ square?: boolean;
+ variant?: "elevation" | "outlined";
+ children?: ComponentChildren;
+}
+
+export function Paper({
+ elevation = 1,
+ square,
+ variant = "elevation",
+ children,
+ class: _class,
+ style,
+ ...rest
+}: Props): VNode {
+ return (
+ <div
+ class={[
+ _class,
+ baseStyle,
+ !square && theme.shape.roundBorder,
+ borderVariant[variant],
+ ].join(" ")}
+ style={{
+ "--theme-shadow-elevation": theme.shadows[elevation],
+ "--gradient-white-elevation": `linear-gradient(${alpha(
+ "#fff",
+ getOverlayAlpha(elevation),
+ )}, ${alpha("#fff", getOverlayAlpha(elevation))})`,
+ ...(style as any),
+ }}
+ {...rest}
+ >
+ {children}
+ </div>
+ );
+}
+
+// Inspired by https://github.com/material-components/material-components-ios/blob/bca36107405594d5b7b16265a5b0ed698f85a5ee/components/Elevation/src/UIColor%2BMaterialElevation.m#L61
+function getOverlayAlpha(elevation: number): number {
+ let alphaValue;
+ if (elevation < 1) {
+ alphaValue = 5.11916 * elevation ** 2;
+ } else {
+ alphaValue = 4.5 * Math.log(elevation + 1) + 2;
+ }
+ return Number((alphaValue / 100).toFixed(2));
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Popover.tsx b/packages/taler-wallet-webextension/src/mui/Popover.tsx
new file mode 100644
index 000000000..da551c65d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Popover.tsx
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { css } from "@linaria/core";
+import { h, VNode, ComponentChildren } from "preact";
+
+const baseStyle = css``;
+
+interface Props {
+ class: string;
+ children: ComponentChildren;
+}
+
+export function Popover({ class: _class, children, ...rest }: Props): VNode {
+ return (
+ <div class={[_class, baseStyle].join(" ")} style={{}} {...rest}>
+ {children}
+ </div>
+ );
+}
+
+function getOffsetTop(rect: any, vertical: any): number {
+ let offset = 0;
+
+ if (typeof vertical === "number") {
+ offset = vertical;
+ } else if (vertical === "center") {
+ offset = rect.height / 2;
+ } else if (vertical === "bottom") {
+ offset = rect.height;
+ }
+
+ return offset;
+}
+
+function getOffsetLeft(rect: any, horizontal: any): number {
+ let offset = 0;
+
+ if (typeof horizontal === "number") {
+ offset = horizontal;
+ } else if (horizontal === "center") {
+ offset = rect.width / 2;
+ } else if (horizontal === "right") {
+ offset = rect.width;
+ }
+
+ return offset;
+}
+
+function getTransformOriginValue(transformOrigin: any): string {
+ return [transformOrigin.horizontal, transformOrigin.vertical]
+ .map((n) => (typeof n === "number" ? `${n}px` : n))
+ .join(" ");
+}
+
+function resolveAnchorEl(anchorEl: any): any {
+ return typeof anchorEl === "function" ? anchorEl() : anchorEl;
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Portal.tsx b/packages/taler-wallet-webextension/src/mui/Portal.tsx
new file mode 100644
index 000000000..1d835abac
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Portal.tsx
@@ -0,0 +1,128 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { css } from "@linaria/core";
+import { createPortal, forwardRef } from "preact/compat";
+import {
+ h,
+ JSX,
+ VNode,
+ ComponentChildren,
+ RefObject,
+ isValidElement,
+ cloneElement,
+ Fragment,
+} from "preact";
+import { Ref, useEffect, useState } from "preact/hooks";
+
+import { theme } from "./style.js";
+
+const baseStyle = css`
+ position: fixed;
+ z-index: ${theme.zIndex.modal};
+ right: 0px;
+ bottom: 0px;
+ top: 0px;
+ left: 0px;
+`;
+
+interface Props {
+ // class: string;
+ children: ComponentChildren;
+ disablePortal?: boolean;
+ container?: VNode;
+}
+
+export const Portal = forwardRef(function Portal(
+ { container, disablePortal, children }: Props,
+ ref: Ref<any>,
+): VNode {
+ const [mountNode, setMountNode] = useState<HTMLElement | undefined>(
+ undefined,
+ );
+ const handleRef = null;
+ // useForkRef(
+ // isValidElement(children) ? children.ref : null,
+ // ref,
+ // );
+
+ useEffect(() => {
+ if (!disablePortal) {
+ setMountNode(getContainer(container) || document.body);
+ }
+ }, [container, disablePortal]);
+
+ useEffect(() => {
+ if (mountNode && !disablePortal) {
+ setRef(ref, mountNode);
+ return () => {
+ setRef(ref, null);
+ };
+ }
+
+ return undefined;
+ }, [ref, mountNode, disablePortal]);
+
+ if (disablePortal) {
+ if (isValidElement(children)) {
+ return cloneElement(children, {
+ ref: handleRef,
+ });
+ }
+ return <Fragment>{children}</Fragment>;
+ }
+
+ return mountNode ? (
+ createPortal(<Fragment>{children}</Fragment>, mountNode)
+ ) : (
+ <Fragment />
+ );
+} as any);
+
+function getContainer(container: any): any {
+ return typeof container === "function" ? container() : container;
+}
+
+// function useForkRef<Instance>(
+// refA: React.Ref<Instance> | null | undefined,
+// refB: React.Ref<Instance> | null | undefined,
+// ): React.Ref<Instance> | null {
+// /**
+// * This will create a new function if the ref props change and are defined.
+// * This means react will call the old forkRef with `null` and the new forkRef
+// * with the ref. Cleanup naturally emerges from this behavior.
+// */
+// return useMemo(() => {
+// if (refA == null && refB == null) {
+// return null;
+// }
+// return (refValue) => {
+// setRef(refA, refValue);
+// setRef(refB, refValue);
+// };
+// }, [refA, refB]);
+// }
+
+function setRef<T>(
+ ref: RefObject<T | null> | ((instance: T | null) => void) | null | undefined,
+ value: T | null,
+): void {
+ if (typeof ref === "function") {
+ ref(value);
+ } else if (ref) {
+ ref.current = value;
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx b/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx
new file mode 100644
index 000000000..1c41c2141
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx
@@ -0,0 +1,154 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { styled } from "@linaria/react";
+import { h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { TextField, Props } from "./TextField.js";
+
+export default {
+ title: "TextField",
+ component: TextField,
+};
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ & > * {
+ margin-bottom: 20px !important;
+ }
+`;
+
+const Input = (variant: Props["variant"]): VNode => {
+ const [value, onChange] = useState("");
+ return (
+ <Container>
+ <TextField variant={variant} label="Name" {...{ value, onChange }} />
+ <TextField
+ variant={variant}
+ type="password"
+ label="Password"
+ {...{ value, onChange }}
+ />
+ <TextField
+ disabled
+ variant={variant}
+ label="Country"
+ helperText="this is disabled"
+ value="disabled"
+ />
+ <TextField
+ error={"Error"}
+ variant={variant}
+ label="Something"
+ {...{ value, onChange }}
+ />
+ <TextField
+ error={"Error"}
+ disabled
+ variant={variant}
+ label="Disabled and Error"
+ value="disabled with error"
+ helperText="this field has an error"
+ />
+ <TextField
+ variant={variant}
+ required
+ label="Name"
+ {...{ value, onChange }}
+ helperText="this field is required"
+ />
+ </Container>
+ );
+};
+
+export const InputStandard = (): VNode => Input("standard");
+export const InputFilled = (): VNode => Input("filled");
+
+export const Color = (): VNode => {
+ const [value, onChange] = useState("");
+ return (
+ <Container>
+ <TextField
+ variant="standard"
+ label="Outlined secondary"
+ color="secondary"
+ {...{ value, onChange }}
+ />
+ <TextField
+ label="Filled success"
+ variant="standard"
+ color="success"
+ {...{ value, onChange }}
+ />
+ <TextField
+ label="Standard warning"
+ variant="standard"
+ color="warning"
+ {...{ value, onChange }}
+ />
+ </Container>
+ );
+};
+
+const Multiline = (variant: Props["variant"]): VNode => {
+ const [value, onChange] = useState("");
+ return (
+ <Container>
+ <TextField
+ {...{ value, onChange }}
+ label="Multiline"
+ variant={variant}
+ multiline
+ maxRows={4}
+ />
+ <TextField
+ {...{ value, onChange }}
+ label="Max row 4"
+ variant={variant}
+ multiline
+ rows={10}
+ />
+ <TextField
+ {...{ value, onChange }}
+ label="Row 10"
+ variant={variant}
+ multiline
+ />
+ </Container>
+ );
+};
+export const MultilineStandard = (): VNode => Multiline("standard");
+export const MultilineFilled = (): VNode => Multiline("filled");
+
+export const Select = (): VNode => {
+ const [value, onChange] = useState("");
+ return (
+ <Container>
+ <TextField
+ {...{ value, onChange }}
+ label="select"
+ variant="standard"
+ select
+ />
+ </Container>
+ );
+};
diff --git a/packages/taler-wallet-webextension/src/mui/TextField.tsx b/packages/taler-wallet-webextension/src/mui/TextField.tsx
new file mode 100644
index 000000000..ab29fb78d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/TextField.tsx
@@ -0,0 +1,97 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { ComponentChildren, h, VNode } from "preact";
+import { FormControl } from "./input/FormControl.js";
+import { FormHelperText } from "./input/FormHelperText.js";
+import { InputFilled } from "./input/InputFilled.js";
+import { InputLabel } from "./input/InputLabel.js";
+import { InputStandard } from "./input/InputStandard.js";
+import { SelectFilled } from "./input/SelectFilled.js";
+import { SelectOutlined } from "./input/SelectOutlined.js";
+import { SelectStandard } from "./input/SelectStandard.js";
+// eslint-disable-next-line import/extensions
+import { Colors } from "./style.js";
+
+export interface Props {
+ autoComplete?: string;
+ autoFocus?: boolean;
+ color?: Colors;
+ disabled?: boolean;
+ error?: string | Error;
+ fullWidth?: boolean;
+ helperText?: VNode | string;
+ id?: string;
+ label?: VNode | string;
+ margin?: "dense" | "normal" | "none";
+ maxRows?: number;
+ minRows?: number;
+ multiline?: boolean;
+ onChange?: (s: string) => void;
+ onInput?: (s: string) => string;
+ inputmode?: string;
+ min?: string;
+ step?: string;
+ placeholder?: string;
+ required?: boolean;
+
+ startAdornment?: VNode;
+ endAdornment?: VNode;
+
+ //FIXME: change to "grabFocus"
+ // focused?: boolean;
+ rows?: number;
+ select?: boolean;
+ type?: string;
+ value?: string;
+ variant?: "filled" | "outlined" | "standard";
+ children?: ComponentChildren;
+}
+
+const inputVariant = {
+ standard: InputStandard,
+ filled: InputFilled,
+ outlined: InputStandard,
+};
+
+const selectVariant = {
+ standard: SelectStandard,
+ filled: SelectFilled,
+ outlined: SelectStandard,
+};
+
+export function TextField({
+ label,
+ select,
+ helperText,
+ children,
+ variant = "filled",
+ ...props
+}: Props): VNode {
+ // htmlFor={id} id={inputLabelId}
+ const Input = select ? selectVariant[variant] : inputVariant[variant];
+ return (
+ <FormControl {...props}>
+ {label && <InputLabel>{label}</InputLabel>}
+ <Input {...props}>{children}</Input>
+ {helperText && (
+ <FormHelperText error={props.error}>{helperText}</FormHelperText>
+ )}
+ {props.error ? (
+ <FormHelperText error="true">{props.error}</FormHelperText>
+ ) : undefined}
+ </FormControl>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/Typography.tsx b/packages/taler-wallet-webextension/src/mui/Typography.tsx
new file mode 100644
index 000000000..c9c811c99
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/Typography.tsx
@@ -0,0 +1,125 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { ComponentChildren, h, VNode } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+// eslint-disable-next-line import/extensions
+import { theme } from "./style.js";
+
+type VariantEnum =
+ | "body1"
+ | "body2"
+ | "button"
+ | "caption"
+ | "h1"
+ | "h2"
+ | "h3"
+ | "h4"
+ | "h5"
+ | "h6"
+ | "inherit"
+ | "overline"
+ | "subtitle1"
+ | "subtitle2";
+
+interface Props {
+ align?: "center" | "inherit" | "justify" | "left" | "right";
+ gutterBottom?: boolean;
+ bold?: boolean;
+ inline?: boolean;
+ noWrap?: boolean;
+ paragraph?: boolean;
+ variant?: VariantEnum;
+ children: string[] | string;
+ class?: string;
+}
+
+const defaultVariantMapping = {
+ h1: "h1",
+ h2: "h2",
+ h3: "h3",
+ h4: "h4",
+ h5: "h5",
+ h6: "h6",
+ subtitle1: "h6",
+ subtitle2: "h6",
+ body1: "p",
+ body2: "p",
+ inherit: "p",
+};
+
+const root = css`
+ margin: 0;
+`;
+
+const noWrapStyle = css`
+ overflow: "hidden";
+ text-overflow: "ellipsis";
+ white-space: "nowrap";
+`;
+const gutterBottomStyle = css`
+ margin-bottom: 0.35em;
+`;
+const paragraphStyle = css`
+ margin-bottom: 16px;
+`;
+const boldStyle = css`
+ font-weight: bold;
+`;
+
+export function Typography({
+ align,
+ gutterBottom = false,
+ noWrap = false,
+ paragraph = false,
+ variant = "body1",
+ bold,
+ inline,
+ children,
+ class: _class,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const Component = inline
+ ? "span"
+ : paragraph === true
+ ? "p"
+ : defaultVariantMapping[variant as "h1"] || "span";
+
+ const alignStyle =
+ align == "inherit"
+ ? {}
+ : {
+ textAlign: align,
+ };
+
+ return h(
+ Component,
+ {
+ class: [
+ _class,
+ root,
+ noWrap && noWrapStyle,
+ gutterBottom && gutterBottomStyle,
+ paragraph && paragraphStyle,
+ bold && boldStyle,
+ theme.typography[variant as "button"], //FIXME: implement the rest of the typography and remove the casting
+ ].join(" "),
+ style: alignStyle,
+ },
+ <i18n.Translate>{children}</i18n.Translate>,
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/colors/constants.ts b/packages/taler-wallet-webextension/src/mui/colors/constants.ts
new file mode 100644
index 000000000..0013d6cca
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/colors/constants.ts
@@ -0,0 +1,342 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+export const amber = {
+ 50: "#fff8e1",
+ 100: "#ffecb3",
+ 200: "#ffe082",
+ 300: "#ffd54f",
+ 400: "#ffca28",
+ 500: "#ffc107",
+ 600: "#ffb300",
+ 700: "#ffa000",
+ 800: "#ff8f00",
+ 900: "#ff6f00",
+ A100: "#ffe57f",
+ A200: "#ffd740",
+ A400: "#ffc400",
+ A700: "#ffab00",
+};
+
+export const blueGrey = {
+ 50: "#eceff1",
+ 100: "#cfd8dc",
+ 200: "#b0bec5",
+ 300: "#90a4ae",
+ 400: "#78909c",
+ 500: "#607d8b",
+ 600: "#546e7a",
+ 700: "#455a64",
+ 800: "#37474f",
+ 900: "#263238",
+ A100: "#cfd8dc",
+ A200: "#b0bec5",
+ A400: "#78909c",
+ A700: "#455a64",
+};
+
+export const blue = {
+ 50: "#e3f2fd",
+ 100: "#bbdefb",
+ 200: "#90caf9",
+ 300: "#64b5f6",
+ 400: "#42a5f5",
+ 500: "#2196f3",
+ 600: "#1e88e5",
+ 700: "#1976d2",
+ 800: "#1565c0",
+ 900: "#0d47a1",
+ A100: "#82b1ff",
+ A200: "#448aff",
+ A400: "#2979ff",
+ A700: "#2962ff",
+};
+
+export const brown = {
+ 50: "#efebe9",
+ 100: "#d7ccc8",
+ 200: "#bcaaa4",
+ 300: "#a1887f",
+ 400: "#8d6e63",
+ 500: "#795548",
+ 600: "#6d4c41",
+ 700: "#5d4037",
+ 800: "#4e342e",
+ 900: "#3e2723",
+ A100: "#d7ccc8",
+ A200: "#bcaaa4",
+ A400: "#8d6e63",
+ A700: "#5d4037",
+};
+
+export const common = {
+ black: "#000",
+ white: "#fff",
+};
+
+export const cyan = {
+ 50: "#e0f7fa",
+ 100: "#b2ebf2",
+ 200: "#80deea",
+ 300: "#4dd0e1",
+ 400: "#26c6da",
+ 500: "#00bcd4",
+ 600: "#00acc1",
+ 700: "#0097a7",
+ 800: "#00838f",
+ 900: "#006064",
+ A100: "#84ffff",
+ A200: "#18ffff",
+ A400: "#00e5ff",
+ A700: "#00b8d4",
+};
+
+export const deepOrange = {
+ 50: "#fbe9e7",
+ 100: "#ffccbc",
+ 200: "#ffab91",
+ 300: "#ff8a65",
+ 400: "#ff7043",
+ 500: "#ff5722",
+ 600: "#f4511e",
+ 700: "#e64a19",
+ 800: "#d84315",
+ 900: "#bf360c",
+ A100: "#ff9e80",
+ A200: "#ff6e40",
+ A400: "#ff3d00",
+ A700: "#dd2c00",
+};
+
+export const deepPurple = {
+ 50: "#ede7f6",
+ 100: "#d1c4e9",
+ 200: "#b39ddb",
+ 300: "#9575cd",
+ 400: "#7e57c2",
+ 500: "#673ab7",
+ 600: "#5e35b1",
+ 700: "#512da8",
+ 800: "#4527a0",
+ 900: "#311b92",
+ A100: "#b388ff",
+ A200: "#7c4dff",
+ A400: "#651fff",
+ A700: "#6200ea",
+};
+
+export const green = {
+ 50: "#e8f5e9",
+ 100: "#c8e6c9",
+ 200: "#a5d6a7",
+ 300: "#81c784",
+ 400: "#66bb6a",
+ 500: "#4caf50",
+ 600: "#43a047",
+ 700: "#388e3c",
+ 800: "#2e7d32",
+ 900: "#1b5e20",
+ A100: "#b9f6ca",
+ A200: "#69f0ae",
+ A400: "#00e676",
+ A700: "#00c853",
+};
+
+export const grey = {
+ 50: "#fafafa",
+ 100: "#f5f5f5",
+ 200: "#eeeeee",
+ 300: "#e0e0e0",
+ 400: "#bdbdbd",
+ 500: "#9e9e9e",
+ 600: "#757575",
+ 700: "#616161",
+ 800: "#424242",
+ 900: "#212121",
+ A100: "#f5f5f5",
+ A200: "#eeeeee",
+ A400: "#bdbdbd",
+ A700: "#616161",
+};
+
+export const indigo = {
+ 50: "#e8eaf6",
+ 100: "#c5cae9",
+ 200: "#9fa8da",
+ 300: "#7986cb",
+ 400: "#5c6bc0",
+ 500: "#3f51b5",
+ 600: "#3949ab",
+ 700: "#303f9f",
+ 800: "#283593",
+ 900: "#1a237e",
+ A100: "#8c9eff",
+ A200: "#536dfe",
+ A400: "#3d5afe",
+ A700: "#304ffe",
+};
+
+export const lightBlue = {
+ 50: "#e1f5fe",
+ 100: "#b3e5fc",
+ 200: "#81d4fa",
+ 300: "#4fc3f7",
+ 400: "#29b6f6",
+ 500: "#03a9f4",
+ 600: "#039be5",
+ 700: "#0288d1",
+ 800: "#0277bd",
+ 900: "#01579b",
+ A100: "#80d8ff",
+ A200: "#40c4ff",
+ A400: "#00b0ff",
+ A700: "#0091ea",
+};
+
+export const lightGreen = {
+ 50: "#f1f8e9",
+ 100: "#dcedc8",
+ 200: "#c5e1a5",
+ 300: "#aed581",
+ 400: "#9ccc65",
+ 500: "#8bc34a",
+ 600: "#7cb342",
+ 700: "#689f38",
+ 800: "#558b2f",
+ 900: "#33691e",
+ A100: "#ccff90",
+ A200: "#b2ff59",
+ A400: "#76ff03",
+ A700: "#64dd17",
+};
+
+export const lime = {
+ 50: "#f9fbe7",
+ 100: "#f0f4c3",
+ 200: "#e6ee9c",
+ 300: "#dce775",
+ 400: "#d4e157",
+ 500: "#cddc39",
+ 600: "#c0ca33",
+ 700: "#afb42b",
+ 800: "#9e9d24",
+ 900: "#827717",
+ A100: "#f4ff81",
+ A200: "#eeff41",
+ A400: "#c6ff00",
+ A700: "#aeea00",
+};
+
+export const orange = {
+ 50: "#fff3e0",
+ 100: "#ffe0b2",
+ 200: "#ffcc80",
+ 300: "#ffb74d",
+ 400: "#ffa726",
+ 500: "#ff9800",
+ 600: "#fb8c00",
+ 700: "#f57c00",
+ 800: "#ef6c00",
+ 900: "#e65100",
+ A100: "#ffd180",
+ A200: "#ffab40",
+ A400: "#ff9100",
+ A700: "#ff6d00",
+};
+
+export const pink = {
+ 50: "#fce4ec",
+ 100: "#f8bbd0",
+ 200: "#f48fb1",
+ 300: "#f06292",
+ 400: "#ec407a",
+ 500: "#e91e63",
+ 600: "#d81b60",
+ 700: "#c2185b",
+ 800: "#ad1457",
+ 900: "#880e4f",
+ A100: "#ff80ab",
+ A200: "#ff4081",
+ A400: "#f50057",
+ A700: "#c51162",
+};
+
+export const purple = {
+ 50: "#f3e5f5",
+ 100: "#e1bee7",
+ 200: "#ce93d8",
+ 300: "#ba68c8",
+ 400: "#ab47bc",
+ 500: "#9c27b0",
+ 600: "#8e24aa",
+ 700: "#7b1fa2",
+ 800: "#6a1b9a",
+ 900: "#4a148c",
+ A100: "#ea80fc",
+ A200: "#e040fb",
+ A400: "#d500f9",
+ A700: "#aa00ff",
+};
+
+export const red = {
+ 50: "#ffebee",
+ 100: "#ffcdd2",
+ 200: "#ef9a9a",
+ 300: "#e57373",
+ 400: "#ef5350",
+ 500: "#f44336",
+ 600: "#e53935",
+ 700: "#d32f2f",
+ 800: "#c62828",
+ 900: "#b71c1c",
+ A100: "#ff8a80",
+ A200: "#ff5252",
+ A400: "#ff1744",
+ A700: "#d50000",
+};
+
+export const teal = {
+ 50: "#e0f2f1",
+ 100: "#b2dfdb",
+ 200: "#80cbc4",
+ 300: "#4db6ac",
+ 400: "#26a69a",
+ 500: "#009688",
+ 600: "#00897b",
+ 700: "#00796b",
+ 800: "#00695c",
+ 900: "#004d40",
+ A100: "#a7ffeb",
+ A200: "#64ffda",
+ A400: "#1de9b6",
+ A700: "#00bfa5",
+};
+
+export const yellow = {
+ 50: "#fffde7",
+ 100: "#fff9c4",
+ 200: "#fff59d",
+ 300: "#fff176",
+ 400: "#ffee58",
+ 500: "#ffeb3b",
+ 600: "#fdd835",
+ 700: "#fbc02d",
+ 800: "#f9a825",
+ 900: "#f57f17",
+ A100: "#ffff8d",
+ A200: "#ffff00",
+ A400: "#ffea00",
+ A700: "#ffd600",
+};
diff --git a/packages/taler-wallet-webextension/src/mui/colors/manipulation.test.ts b/packages/taler-wallet-webextension/src/mui/colors/manipulation.test.ts
new file mode 100644
index 000000000..78e9d9cf7
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/colors/manipulation.test.ts
@@ -0,0 +1,333 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { expect } from "chai";
+import {
+ recomposeColor,
+ hexToRgb,
+ rgbToHex,
+ hslToRgb,
+ darken,
+ decomposeColor,
+ emphasize,
+ alpha,
+ getContrastRatio,
+ getLuminance,
+ lighten,
+} from "./manipulation.js";
+
+describe("utils/colorManipulator", () => {
+ describe("recomposeColor", () => {
+ it("converts a decomposed rgb color object to a string` ", () => {
+ expect(
+ recomposeColor({
+ type: "rgb",
+ values: [255, 255, 255],
+ }),
+ ).to.equal("rgb(255, 255, 255)");
+ });
+
+ it("converts a decomposed rgba color object to a string` ", () => {
+ expect(
+ recomposeColor({
+ type: "rgba",
+ values: [255, 255, 255, 0.5],
+ }),
+ ).to.equal("rgba(255, 255, 255, 0.5)");
+ });
+
+ it("converts a decomposed hsl color object to a string` ", () => {
+ expect(
+ recomposeColor({
+ type: "hsl",
+ values: [100, 50, 25],
+ }),
+ ).to.equal("hsl(100, 50%, 25%)");
+ });
+
+ it("converts a decomposed hsla color object to a string` ", () => {
+ expect(
+ recomposeColor({
+ type: "hsla",
+ values: [100, 50, 25, 0.5],
+ }),
+ ).to.equal("hsla(100, 50%, 25%, 0.5)");
+ });
+ });
+
+ describe("hexToRgb", () => {
+ it("converts a short hex color to an rgb color` ", () => {
+ expect(hexToRgb("#9f3")).to.equal("rgb(153, 255, 51)");
+ });
+
+ it("converts a long hex color to an rgb color` ", () => {
+ expect(hexToRgb("#a94fd3")).to.equal("rgb(169, 79, 211)");
+ });
+
+ it("converts a long alpha hex color to an argb color` ", () => {
+ expect(hexToRgb("#111111f8")).to.equal("rgba(17, 17, 17, 0.973)");
+ });
+ });
+
+ describe("rgbToHex", () => {
+ it("converts an rgb color to a hex color` ", () => {
+ expect(rgbToHex("rgb(169, 79, 211)")).to.equal("#a94fd3");
+ });
+
+ it("converts an rgba color to a hex color` ", () => {
+ expect(rgbToHex("rgba(169, 79, 211, 1)")).to.equal("#a94fd3ff");
+ });
+
+ it("idempotent", () => {
+ expect(rgbToHex("#A94FD3")).to.equal("#A94FD3");
+ });
+ });
+
+ describe("hslToRgb", () => {
+ it("converts an hsl color to an rgb color` ", () => {
+ expect(hslToRgb("hsl(281, 60%, 57%)")).to.equal("rgb(169, 80, 211)");
+ });
+
+ it("converts an hsla color to an rgba color` ", () => {
+ expect(hslToRgb("hsla(281, 60%, 57%, 0.5)")).to.equal(
+ "rgba(169, 80, 211, 0.5)",
+ );
+ });
+
+ it("allow to convert values only", () => {
+ expect(hslToRgb("hsl(281, 60%, 57%)")).to.equal("rgb(169, 80, 211)");
+ });
+ });
+
+ describe("decomposeColor", () => {
+ it("converts an rgb color string to an object with `type` and `value` keys", () => {
+ const { type, values } = decomposeColor("rgb(255, 255, 255)");
+ expect(type).to.equal("rgb");
+ expect(values).to.deep.equal([255, 255, 255]);
+ });
+
+ it("converts an rgba color string to an object with `type` and `value` keys", () => {
+ const { type, values } = decomposeColor("rgba(255, 255, 255, 0.5)");
+ expect(type).to.equal("rgba");
+ expect(values).to.deep.equal([255, 255, 255, 0.5]);
+ });
+
+ it("converts an hsl color string to an object with `type` and `value` keys", () => {
+ const { type, values } = decomposeColor("hsl(100, 50%, 25%)");
+ expect(type).to.equal("hsl");
+ expect(values).to.deep.equal([100, 50, 25]);
+ });
+
+ it("converts an hsla color string to an object with `type` and `value` keys", () => {
+ const { type, values } = decomposeColor("hsla(100, 50%, 25%, 0.5)");
+ expect(type).to.equal("hsla");
+ expect(values).to.deep.equal([100, 50, 25, 0.5]);
+ });
+
+ it("converts rgba hex", () => {
+ const decomposed = decomposeColor("#111111f8");
+ expect(decomposed).to.deep.equal({
+ type: "rgba",
+ colorSpace: undefined,
+ values: [17, 17, 17, 0.973],
+ });
+ });
+ });
+
+ describe("getContrastRatio", () => {
+ it("returns a ratio for black : white", () => {
+ expect(getContrastRatio("#000", "#FFF")).to.equal(21);
+ });
+
+ it("returns a ratio for black : black", () => {
+ expect(getContrastRatio("#000", "#000")).to.equal(1);
+ });
+
+ it("returns a ratio for white : white", () => {
+ expect(getContrastRatio("#FFF", "#FFF")).to.equal(1);
+ });
+
+ it("returns a ratio for dark-grey : light-grey", () => {
+ expect(getContrastRatio("#707070", "#E5E5E5")).to.be.approximately(
+ 3.93,
+ 0.01,
+ );
+ });
+
+ it("returns a ratio for black : light-grey", () => {
+ expect(getContrastRatio("#000", "#888")).to.be.approximately(5.92, 0.01);
+ });
+ });
+
+ describe("getLuminance", () => {
+ it("returns a valid luminance for rgb white ", () => {
+ expect(getLuminance("rgba(255, 255, 255)")).to.equal(1);
+ expect(getLuminance("rgb(255, 255, 255)")).to.equal(1);
+ });
+
+ it("returns a valid luminance for rgb mid-grey", () => {
+ expect(getLuminance("rgba(127, 127, 127)")).to.equal(0.212);
+ expect(getLuminance("rgb(127, 127, 127)")).to.equal(0.212);
+ });
+
+ it("returns a valid luminance for an rgb color", () => {
+ expect(getLuminance("rgb(255, 127, 0)")).to.equal(0.364);
+ });
+
+ it("returns a valid luminance from an hsl color", () => {
+ expect(getLuminance("hsl(100, 100%, 50%)")).to.equal(0.735);
+ });
+
+ it("returns an equal luminance for the same color in different formats", () => {
+ const hsl = "hsl(100, 100%, 50%)";
+ const rgb = "rgb(85, 255, 0)";
+ expect(getLuminance(hsl)).to.equal(getLuminance(rgb));
+ });
+ });
+
+ describe("emphasize", () => {
+ it("lightens a dark rgb color with the coefficient provided", () => {
+ expect(emphasize("rgb(1, 2, 3)", 0.4)).to.equal(
+ lighten("rgb(1, 2, 3)", 0.4),
+ );
+ });
+
+ it("darkens a light rgb color with the coefficient provided", () => {
+ expect(emphasize("rgb(250, 240, 230)", 0.3)).to.equal(
+ darken("rgb(250, 240, 230)", 0.3),
+ );
+ });
+
+ it("lightens a dark rgb color with the coefficient 0.15 by default", () => {
+ expect(emphasize("rgb(1, 2, 3)")).to.equal(lighten("rgb(1, 2, 3)", 0.15));
+ });
+
+ it("darkens a light rgb color with the coefficient 0.15 by default", () => {
+ expect(emphasize("rgb(250, 240, 230)")).to.equal(
+ darken("rgb(250, 240, 230)", 0.15),
+ );
+ });
+ });
+
+ describe("alpha", () => {
+ it("converts an rgb color to an rgba color with the value provided", () => {
+ expect(alpha("rgb(1, 2, 3)", 0.4)).to.equal("rgba(1, 2, 3, 0.4)");
+ });
+
+ it("updates an rgba color with the alpha value provided", () => {
+ expect(alpha("rgba(255, 0, 0, 0.2)", 0.5)).to.equal(
+ "rgba(255, 0, 0, 0.5)",
+ );
+ });
+
+ it("converts an hsl color to an hsla color with the value provided", () => {
+ expect(alpha("hsl(0, 100%, 50%)", 0.1)).to.equal(
+ "hsla(0, 100%, 50%, 0.1)",
+ );
+ });
+
+ it("updates an hsla color with the alpha value provided", () => {
+ expect(alpha("hsla(0, 100%, 50%, 0.2)", 0.5)).to.equal(
+ "hsla(0, 100%, 50%, 0.5)",
+ );
+ });
+ });
+
+ describe("darken", () => {
+ it("doesn't modify rgb black", () => {
+ expect(darken("rgb(0, 0, 0)", 0.1)).to.equal("rgb(0, 0, 0)");
+ });
+
+ it("darkens rgb white to black when coefficient is 1", () => {
+ expect(darken("rgb(255, 255, 255)", 1)).to.equal("rgb(0, 0, 0)");
+ });
+
+ it("retains the alpha value in an rgba color", () => {
+ expect(darken("rgba(0, 0, 0, 0.5)", 0.1)).to.equal("rgba(0, 0, 0, 0.5)");
+ });
+
+ it("darkens rgb white by 10% when coefficient is 0.1", () => {
+ expect(darken("rgb(255, 255, 255)", 0.1)).to.equal("rgb(229, 229, 229)");
+ });
+
+ it("darkens rgb red by 50% when coefficient is 0.5", () => {
+ expect(darken("rgb(255, 0, 0)", 0.5)).to.equal("rgb(127, 0, 0)");
+ });
+
+ it("darkens rgb grey by 50% when coefficient is 0.5", () => {
+ expect(darken("rgb(127, 127, 127)", 0.5)).to.equal("rgb(63, 63, 63)");
+ });
+
+ it("doesn't modify rgb colors when coefficient is 0", () => {
+ expect(darken("rgb(255, 255, 255)", 0)).to.equal("rgb(255, 255, 255)");
+ });
+
+ it("darkens hsl red by 50% when coefficient is 0.5", () => {
+ expect(darken("hsl(0, 100%, 50%)", 0.5)).to.equal("hsl(0, 100%, 25%)");
+ });
+
+ it("doesn't modify hsl colors when coefficient is 0", () => {
+ expect(darken("hsl(0, 100%, 50%)", 0)).to.equal("hsl(0, 100%, 50%)");
+ });
+
+ it("doesn't modify hsl colors when l is 0%", () => {
+ expect(darken("hsl(0, 50%, 0%)", 0.5)).to.equal("hsl(0, 50%, 0%)");
+ });
+ });
+
+ describe("lighten", () => {
+ it("doesn't modify rgb white", () => {
+ expect(lighten("rgb(255, 255, 255)", 0.1)).to.equal("rgb(255, 255, 255)");
+ });
+
+ it("lightens rgb black to white when coefficient is 1", () => {
+ expect(lighten("rgb(0, 0, 0)", 1)).to.equal("rgb(255, 255, 255)");
+ });
+
+ it("retains the alpha value in an rgba color", () => {
+ expect(lighten("rgba(255, 255, 255, 0.5)", 0.1)).to.equal(
+ "rgba(255, 255, 255, 0.5)",
+ );
+ });
+
+ it("lightens rgb black by 10% when coefficient is 0.1", () => {
+ expect(lighten("rgb(0, 0, 0)", 0.1)).to.equal("rgb(25, 25, 25)");
+ });
+
+ it("lightens rgb red by 50% when coefficient is 0.5", () => {
+ expect(lighten("rgb(255, 0, 0)", 0.5)).to.equal("rgb(255, 127, 127)");
+ });
+
+ it("lightens rgb grey by 50% when coefficient is 0.5", () => {
+ expect(lighten("rgb(127, 127, 127)", 0.5)).to.equal("rgb(191, 191, 191)");
+ });
+
+ it("doesn't modify rgb colors when coefficient is 0", () => {
+ expect(lighten("rgb(127, 127, 127)", 0)).to.equal("rgb(127, 127, 127)");
+ });
+
+ it("lightens hsl red by 50% when coefficient is 0.5", () => {
+ expect(lighten("hsl(0, 100%, 50%)", 0.5)).to.equal("hsl(0, 100%, 75%)");
+ });
+
+ it("doesn't modify hsl colors when coefficient is 0", () => {
+ expect(lighten("hsl(0, 100%, 50%)", 0)).to.equal("hsl(0, 100%, 50%)");
+ });
+
+ it("doesn't modify hsl colors when `l` is 100%", () => {
+ expect(lighten("hsl(0, 50%, 100%)", 0.5)).to.equal("hsl(0, 50%, 100%)");
+ });
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/mui/colors/manipulation.ts b/packages/taler-wallet-webextension/src/mui/colors/manipulation.ts
new file mode 100644
index 000000000..f9bf9eb2b
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/colors/manipulation.ts
@@ -0,0 +1,328 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+export type ColorFormat = ColorFormatWithAlpha | ColorFormatWithoutAlpha;
+export type ColorFormatWithAlpha = "rgb" | "hsl";
+export type ColorFormatWithoutAlpha = "rgba" | "hsla";
+export type ColorObject = ColorObjectWithAlpha | ColorObjectWithoutAlpha;
+export interface ColorObjectWithAlpha {
+ type: ColorFormatWithAlpha;
+ values: [number, number, number];
+ colorSpace?: "srgb" | "display-p3" | "a98-rgb" | "prophoto-rgb" | "rec-2020";
+}
+export interface ColorObjectWithoutAlpha {
+ type: ColorFormatWithoutAlpha;
+ values: [number, number, number, number];
+ colorSpace?: "srgb" | "display-p3" | "a98-rgb" | "prophoto-rgb" | "rec-2020";
+}
+
+/**
+ * Returns a number whose value is limited to the given range.
+ * @param {number} value The value to be clamped
+ * @param {number} min The lower boundary of the output range
+ * @param {number} max The upper boundary of the output range
+ * @returns {number} A number in the range [min, max]
+ */
+function clamp(value: number, min = 0, max = 1): number {
+ // if (process.env.NODE_ENV !== 'production') {
+ // if (value < min || value > max) {
+ // console.error(`MUI: The value provided ${value} is out of range [${min}, ${max}].`);
+ // }
+ // }
+
+ return Math.min(Math.max(min, value), max);
+}
+
+/**
+ * Converts a color from CSS hex format to CSS rgb format.
+ * @param {string} color - Hex color, i.e. #nnn or #nnnnnn
+ * @returns {string} A CSS rgb color string
+ */
+export function hexToRgb(color: string): string {
+ color = color.substr(1);
+
+ const re = new RegExp(`.{1,${color.length >= 6 ? 2 : 1}}`, "g");
+ let colors = color.match(re);
+
+ if (colors && colors[0].length === 1) {
+ colors = colors.map((n) => n + n) as RegExpMatchArray;
+ }
+
+ return colors
+ ? `rgb${colors.length === 4 ? "a" : ""}(${colors
+ .map((n, index) => {
+ return index < 3
+ ? parseInt(n, 16)
+ : Math.round((parseInt(n, 16) / 255) * 1000) / 1000;
+ })
+ .join(", ")})`
+ : "";
+}
+
+function intToHex(int: number): string {
+ const hex = int.toString(16);
+ return hex.length === 1 ? `0${hex}` : hex;
+}
+
+/**
+ * Returns an object with the type and values of a color.
+ *
+ * Note: Does not support rgb % values.
+ * @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla()
+ * @returns {object} - A MUI color object: {type: string, values: number[]}
+ */
+export function decomposeColor(color: string): ColorObject {
+ const colorSpace = undefined;
+ if (color.charAt(0) === "#") {
+ return decomposeColor(hexToRgb(color));
+ }
+
+ const marker = color.indexOf("(");
+ const type = color.substring(0, marker);
+ // if (type != 'rgba' && type != 'hsla' && type != 'rgb' && type != 'hsl') {
+ // }
+
+ const values = color.substring(marker + 1, color.length - 1).split(",");
+ if (type == "rgb" || type == "hsl") {
+ return {
+ type,
+ colorSpace,
+ values: [
+ parseFloat(values[0]),
+ parseFloat(values[1]),
+ parseFloat(values[2]),
+ ],
+ };
+ }
+ if (type == "rgba" || type == "hsla") {
+ return {
+ type,
+ colorSpace,
+ values: [
+ parseFloat(values[0]),
+ parseFloat(values[1]),
+ parseFloat(values[2]),
+ parseFloat(values[3]),
+ ],
+ };
+ }
+ throw new Error(
+ `Unsupported '${color}' color. The following formats are supported: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla()`,
+ );
+}
+
+/**
+ * Converts a color object with type and values to a string.
+ * @param {object} color - Decomposed color
+ * @param {string} color.type - One of: 'rgb', 'rgba', 'hsl', 'hsla'
+ * @param {array} color.values - [n,n,n] or [n,n,n,n]
+ * @returns {string} A CSS color string
+ */
+export function recomposeColor(color: ColorObject): string {
+ const { type, values: valuesNum } = color;
+
+ const valuesStr: string[] = [];
+ if (type.indexOf("rgb") !== -1) {
+ // Only convert the first 3 values to int (i.e. not alpha)
+ valuesNum
+ .map((n, i) => (i < 3 ? parseInt(String(n), 10) : n))
+ .forEach((n, i) => (valuesStr[i] = String(n)));
+ } else if (type.indexOf("hsl") !== -1) {
+ valuesStr[0] = String(valuesNum[0]);
+ valuesStr[1] = `${valuesNum[1]}%`;
+ valuesStr[2] = `${valuesNum[2]}%`;
+ if (type === "hsla") {
+ valuesStr[3] = String(valuesNum[3]);
+ }
+ }
+
+ return `${type}(${valuesStr.join(", ")})`;
+}
+
+/**
+ * Converts a color from CSS rgb format to CSS hex format.
+ * @param {string} color - RGB color, i.e. rgb(n, n, n)
+ * @returns {string} A CSS rgb color string, i.e. #nnnnnn
+ */
+export function rgbToHex(color: string): string {
+ // Idempotent
+ if (color.indexOf("#") === 0) {
+ return color;
+ }
+
+ const { values } = decomposeColor(color);
+ return `#${values
+ .map((n, i) => intToHex(i === 3 ? Math.round(255 * n) : n))
+ .join("")}`;
+}
+
+/**
+ * Converts a color from hsl format to rgb format.
+ * @param {string} color - HSL color values
+ * @returns {string} rgb color values
+ */
+export function hslToRgb(color: string): string {
+ const colorObj = decomposeColor(color);
+ const { values } = colorObj;
+ const h = values[0];
+ const s = values[1] / 100;
+ const l = values[2] / 100;
+ const a = s * Math.min(l, 1 - l);
+ const f = (n: number, k = (n + h / 30) % 12): number =>
+ l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
+
+ if (colorObj.type === "hsla") {
+ return recomposeColor({
+ type: "rgba",
+ values: [
+ Math.round(f(0) * 255),
+ Math.round(f(8) * 255),
+ Math.round(f(4) * 255),
+ colorObj.values[3],
+ ],
+ });
+ }
+
+ return recomposeColor({
+ type: "rgb",
+ values: [
+ Math.round(f(0) * 255),
+ Math.round(f(8) * 255),
+ Math.round(f(4) * 255),
+ ],
+ });
+}
+/**
+ * The relative brightness of any point in a color space,
+ * normalized to 0 for darkest black and 1 for lightest white.
+ *
+ * Formula: https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests
+ * @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
+ * @returns {number} The relative brightness of the color in the range 0 - 1
+ */
+export function getLuminance(color: string): number {
+ const colorObj = decomposeColor(color);
+
+ const rgb2 =
+ colorObj.type === "hsl"
+ ? decomposeColor(hslToRgb(color)).values
+ : colorObj.values;
+ const rgb = rgb2.map((val) => {
+ val /= 255; // normalized
+ return val <= 0.03928 ? val / 12.92 : ((val + 0.055) / 1.055) ** 2.4;
+ }) as typeof rgb2;
+
+ // Truncate at 3 digits
+ return Number(
+ (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]).toFixed(3),
+ );
+}
+
+/**
+ * Calculates the contrast ratio between two colors.
+ *
+ * Formula: https://www.w3.org/TR/WCAG20-TECHS/G17.html#G17-tests
+ * @param {string} foreground - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla()
+ * @param {string} background - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla()
+ * @returns {number} A contrast ratio value in the range 0 - 21.
+ */
+export function getContrastRatio(
+ foreground: string,
+ background: string,
+): number {
+ const lumA = getLuminance(foreground);
+ const lumB = getLuminance(background);
+ return (Math.max(lumA, lumB) + 0.05) / (Math.min(lumA, lumB) + 0.05);
+}
+
+/**
+ * Sets the absolute transparency of a color.
+ * Any existing alpha values are overwritten.
+ * @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
+ * @param {number} value - value to set the alpha channel to in the range 0 - 1
+ * @returns {string} A CSS color string. Hex input values are returned as rgb
+ */
+export function alpha(color: string, value: number): string {
+ const colorObj = decomposeColor(color);
+ value = clamp(value);
+
+ if (colorObj.type === "rgb" || colorObj.type === "hsl") {
+ colorObj.type += "a";
+ }
+ colorObj.values[3] = value;
+
+ return recomposeColor(colorObj);
+}
+
+/**
+ * Darkens a color.
+ * @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
+ * @param {number} coefficient - multiplier in the range 0 - 1
+ * @returns {string} A CSS color string. Hex input values are returned as rgb
+ */
+export function darken(color: string, coefficient: number): string {
+ const colorObj = decomposeColor(color);
+ coefficient = clamp(coefficient);
+
+ if (colorObj.type.indexOf("hsl") !== -1) {
+ colorObj.values[2] *= 1 - coefficient;
+ } else if (
+ colorObj.type.indexOf("rgb") !== -1 ||
+ colorObj.type.indexOf("color") !== -1
+ ) {
+ for (let i = 0; i < 3; i += 1) {
+ colorObj.values[i] *= 1 - coefficient;
+ }
+ }
+ return recomposeColor(colorObj);
+}
+
+/**
+ * Lightens a color.
+ * @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
+ * @param {number} coefficient - multiplier in the range 0 - 1
+ * @returns {string} A CSS color string. Hex input values are returned as rgb
+ */
+export function lighten(color: string, coefficient: number): string {
+ const colorObj = decomposeColor(color);
+ coefficient = clamp(coefficient);
+
+ if (colorObj.type.indexOf("hsl") !== -1) {
+ colorObj.values[2] += (100 - colorObj.values[2]) * coefficient;
+ } else if (colorObj.type.indexOf("rgb") !== -1) {
+ for (let i = 0; i < 3; i += 1) {
+ colorObj.values[i] += (255 - colorObj.values[i]) * coefficient;
+ }
+ } else if (colorObj.type.indexOf("color") !== -1) {
+ for (let i = 0; i < 3; i += 1) {
+ colorObj.values[i] += (1 - colorObj.values[i]) * coefficient;
+ }
+ }
+
+ return recomposeColor(colorObj);
+}
+
+/**
+ * Darken or lighten a color, depending on its luminance.
+ * Light colors are darkened, dark colors are lightened.
+ * @param {string} color - CSS color, i.e. one of: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color()
+ * @param {number} coefficient=0.15 - multiplier in the range 0 - 1
+ * @returns {string} A CSS color string. Hex input values are returned as rgb
+ */
+export function emphasize(color: string, coefficient = 0.15): string {
+ return getLuminance(color) > 0.5
+ ? darken(color, coefficient)
+ : lighten(color, coefficient);
+}
diff --git a/packages/taler-wallet-webextension/src/mui/handlers.ts b/packages/taler-wallet-webextension/src/mui/handlers.ts
new file mode 100644
index 000000000..a194bd02a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/handlers.ts
@@ -0,0 +1,82 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { AmountJson } from "@gnu-taler/taler-util";
+
+export interface TextFieldHandler {
+ onInput?: SafeHandler<string>;
+ value: string;
+ error?: string | Error;
+}
+
+export interface AmountFieldHandler {
+ onInput?: SafeHandler<AmountJson>;
+ value: AmountJson;
+ error?: string | Error;
+}
+
+declare const __safe_handler: unique symbol;
+export type SafeHandler<T> = {
+ <Req extends T>(req: Req): Promise<void>;
+ (): Promise<void>;
+ [__safe_handler]: true;
+};
+
+type UnsafeHandler<T> = ((p: T) => Promise<void>) | ((p: T) => void);
+
+export function withSafe<T>(
+ handler: UnsafeHandler<T>,
+ onError: (e: Error) => void,
+): SafeHandler<T> {
+ const sh = async function (p: T): Promise<void> {
+ try {
+ await handler(p);
+ } catch (e) {
+ if (e instanceof Error) {
+ onError(e);
+ } else {
+ onError(new Error(String(e)));
+ }
+ }
+ };
+ return sh as SafeHandler<T>;
+}
+
+export const nullFunction = async function (): Promise<void> {
+ //do nothing
+} as SafeHandler<void>;
+
+//FIXME: UI button should required SafeHandler but
+//useStateComponent should not be required to create SafeHandlers
+//so this need to be split in two:
+// * ButtonHandlerUI => with i18n
+// * ButtonHandlerLogic => without i18n
+export interface ButtonHandler {
+ onClick?: SafeHandler<void>;
+ // error?: TalerError;
+}
+
+export interface ToggleHandler {
+ value?: boolean;
+ button: ButtonHandler;
+}
+
+export interface SelectFieldHandler {
+ onChange?: SafeHandler<string>;
+ error?: string;
+ value: string;
+ isDirty?: boolean;
+ list: Record<string, string>;
+}
diff --git a/packages/taler-wallet-webextension/src/mui/index.stories.tsx b/packages/taler-wallet-webextension/src/mui/index.stories.tsx
new file mode 100644
index 000000000..aa8dd2526
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/index.stories.tsx
@@ -0,0 +1,27 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export * as a1 from "./Button.stories.js";
+export * as a3 from "./Grid.stories.js";
+export * as a4 from "./Paper.stories.js";
+export * as a5 from "./TextField.stories.js";
+export * as a6 from "./Alert.stories.js";
+export * as a7 from "./Menu.stories.js";
diff --git a/packages/taler-wallet-webextension/src/mui/input/FormControl.tsx b/packages/taler-wallet-webextension/src/mui/input/FormControl.tsx
new file mode 100644
index 000000000..45f5a81d1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/input/FormControl.tsx
@@ -0,0 +1,176 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext, useMemo, useState } from "preact/hooks";
+// eslint-disable-next-line import/extensions
+import { Colors } from "../style.js";
+
+export interface Props {
+ color: Colors;
+ disabled: boolean;
+ error?: string | Error;
+ focused: boolean;
+ fullWidth: boolean;
+ hiddenLabel: boolean;
+ required: boolean;
+ variant: "filled" | "outlined" | "standard";
+ margin: "none" | "normal" | "dense";
+ size: "medium" | "small";
+ children: ComponentChildren;
+}
+
+export const root = css`
+ display: inline-flex;
+ flex-direction: column;
+ position: relative;
+ min-width: 0px;
+ padding: 0px;
+ margin: 0px;
+ border: 0px;
+ vertical-align: top;
+`;
+
+const marginVariant = {
+ none: "",
+ normal: css`
+ margin-top: 16px;
+ margin-bottom: 8px;
+ `,
+ dense: css`
+ margin-top: 8px;
+ margin-bottom: 4px;
+ `,
+};
+const fullWidthStyle = css`
+ width: 100%;
+`;
+
+export const FormControlContext = createContext<FCCProps | null>(null);
+
+export function FormControl({
+ color = "primary",
+ disabled = false,
+ error = undefined,
+ focused: visuallyFocused,
+ fullWidth = false,
+ hiddenLabel = false,
+ margin = "none",
+ required = false,
+ size = "medium",
+ variant = "filled",
+ children,
+}: Partial<Props>): VNode {
+ const [filled, setFilled] = useState(false);
+ const [focusedState, setFocused] = useState(visuallyFocused);
+ const focused =
+ focusedState !== undefined && !disabled ? focusedState : false;
+
+ const value: FCCProps = {
+ color,
+ disabled,
+ error,
+ filled,
+ focused,
+ fullWidth,
+ hiddenLabel,
+ size,
+ onBlur: () => {
+ setFocused(false);
+ },
+ onEmpty: () => {
+ setFilled(false);
+ },
+ onFilled: () => {
+ setFilled(true);
+ },
+ onFocus: () => {
+ setFocused(true);
+ },
+ required,
+ variant,
+ };
+
+ return (
+ <div
+ class={[
+ root,
+ marginVariant[margin],
+ fullWidth ? fullWidthStyle : "",
+ ].join(" ")}
+ >
+ <FormControlContext.Provider value={value}>
+ {children}
+ </FormControlContext.Provider>
+ </div>
+ );
+}
+
+export interface FCCProps {
+ // adornedStart,
+ // setAdornedStart,
+ color: Colors;
+ disabled: boolean;
+ error: string | undefined | Error;
+ filled: boolean;
+ focused: boolean;
+ fullWidth: boolean;
+ hiddenLabel: boolean;
+ size: "medium" | "small";
+ onBlur: () => void;
+ onEmpty: () => void;
+ onFilled: () => void;
+ onFocus: () => void;
+ // registerEffect,
+ required: boolean;
+ variant: "filled" | "outlined" | "standard";
+}
+
+const defaultContextValue: FCCProps = {
+ color: "primary",
+ disabled: false,
+ error: undefined,
+ filled: false,
+ focused: false,
+ fullWidth: false,
+ hiddenLabel: false,
+ size: "medium",
+ onBlur: () => null,
+ onEmpty: () => null,
+ onFilled: () => null,
+ onFocus: () => null,
+ required: false,
+ variant: "filled",
+};
+
+function withoutUndefinedProperties(obj: any): any {
+ return Object.keys(obj).reduce((acc, key) => {
+ const _acc: any = acc;
+ if (obj[key] !== undefined && obj[key] !== false) _acc[key] = obj[key];
+ return _acc;
+ }, {});
+}
+
+export function useFormControl(props: Partial<FCCProps> = {}): FCCProps {
+ const ctx = useContext(FormControlContext);
+ const cleanedProps = withoutUndefinedProperties(props);
+
+ return useMemo(() => {
+ return !ctx
+ ? { ...defaultContextValue, ...cleanedProps }
+ : { ...ctx, ...cleanedProps };
+ }, [cleanedProps, ctx]);
+}
diff --git a/packages/taler-wallet-webextension/src/mui/input/FormHelperText.tsx b/packages/taler-wallet-webextension/src/mui/input/FormHelperText.tsx
new file mode 100644
index 000000000..3b80b0f23
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/input/FormHelperText.tsx
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { ComponentChildren, h, VNode } from "preact";
+// eslint-disable-next-line import/extensions
+import { theme } from "../style.js";
+import { useFormControl } from "./FormControl.js";
+
+const root = css`
+ color: ${theme.palette.text.secondary};
+ text-align: left;
+ margin-top: 3px;
+ margin-bottom: 0px;
+ margin-right: 0px;
+ margin-left: 0px;
+`;
+const disabledStyle = css`
+ color: ${theme.palette.text.disabled};
+`;
+const errorStyle = css`
+ color: ${theme.palette.error.main};
+`;
+const sizeSmallStyle = css`
+ margin-top: 4px;
+`;
+const containedStyle = css`
+ margin-right: 14px;
+ margin-left: 14px;
+`;
+
+interface Props {
+ disabled?: boolean;
+ error?: string | Error;
+ filled?: boolean;
+ focused?: boolean;
+ margin?: "dense";
+ required?: boolean;
+ children: ComponentChildren;
+}
+export function FormHelperText({ children, ...props }: Props): VNode {
+ const fcs = useFormControl(props);
+ const contained = fcs.variant === "filled" || fcs.variant === "outlined";
+ return (
+ <p
+ class={[
+ root,
+ theme.typography.caption,
+ fcs.disabled && disabledStyle,
+ fcs.error && errorStyle,
+ fcs.size === "small" && sizeSmallStyle,
+ contained && containedStyle,
+ ].join(" ")}
+ >
+ {children}
+ </p>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/input/FormLabel.tsx b/packages/taler-wallet-webextension/src/mui/input/FormLabel.tsx
new file mode 100644
index 000000000..68fbdc38e
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/input/FormLabel.tsx
@@ -0,0 +1,85 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { ComponentChildren, h, VNode } from "preact";
+// eslint-disable-next-line import/extensions
+import { Colors, theme } from "../style.js";
+import { useFormControl } from "./FormControl.js";
+
+export interface Props {
+ class?: string;
+ disabled?: boolean;
+ error?: string;
+ filled?: boolean;
+ focused?: boolean;
+ required?: boolean;
+ color?: Colors;
+ children?: ComponentChildren;
+}
+
+const root = css`
+ color: ${theme.palette.text.secondary};
+ line-height: 1.4375em;
+ padding: 0px;
+ position: relative;
+ &[data-focused] {
+ color: var(--color-main);
+ }
+ &[data-disabled] {
+ color: ${theme.palette.text.disabled};
+ }
+ &[data-error] {
+ color: ${theme.palette.error.main};
+ }
+`;
+
+export function FormLabel({
+ disabled,
+ error,
+ filled,
+ focused,
+ required,
+ color,
+ class: _class,
+ children,
+ ...rest
+}: Props): VNode {
+ const fcs = useFormControl({
+ disabled,
+ error,
+ filled,
+ focused,
+ required,
+ color,
+ });
+ return (
+ <label
+ data-focused={!fcs.focused ? undefined : true}
+ data-error={!fcs.error ? undefined : true}
+ data-disabled={!fcs.disabled ? undefined : true}
+ class={[_class, root, theme.typography.body1].join(" ")}
+ {...rest}
+ style={{
+ "--color-main": theme.palette[fcs.color].main,
+ }}
+ >
+ {children}
+ {fcs.required && (
+ <span data-error={!fcs.error ? undefined : true}>&thinsp;{"*"}</span>
+ )}
+ </label>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/input/InputBase.tsx b/packages/taler-wallet-webextension/src/mui/input/InputBase.tsx
new file mode 100644
index 000000000..d811a3dbb
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/input/InputBase.tsx
@@ -0,0 +1,562 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { Fragment, h, JSX, VNode } from "preact";
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "preact/hooks";
+// eslint-disable-next-line import/extensions
+import { theme } from "../style.js";
+import { FormControlContext, useFormControl } from "./FormControl.js";
+
+const rootStyle = css`
+ color: ${theme.palette.text.primary};
+ line-height: 1.4375em;
+ box-sizing: border-box;
+ position: relative;
+ cursor: text;
+ display: inline-flex;
+ align-items: center;
+`;
+const rootDisabledStyle = css`
+ color: ${theme.palette.text.disabled};
+ cursor: default;
+`;
+const rootMultilineStyle = css`
+ padding: 4px 0 5px;
+`;
+const fullWidthStyle = css`
+ width: "100%";
+`;
+
+export function InputBaseRoot({
+ class: _class,
+ disabled,
+ error,
+ multiline,
+ focused,
+ fullWidth,
+ startAdornment,
+ endAdornment,
+ children,
+}: any): VNode {
+ const fcs = useFormControl({});
+ return (
+ <div
+ data-disabled={!disabled ? undefined : true}
+ data-focused={!focused ? undefined : true}
+ data-multiline={multiline}
+ data-hasStart={!!startAdornment}
+ data-hasEnd={!!endAdornment}
+ data-error={!error ? undefined : true}
+ class={[
+ _class,
+ rootStyle,
+ theme.typography.body1,
+ disabled && rootDisabledStyle,
+ multiline && rootMultilineStyle,
+ fullWidth && fullWidthStyle,
+ ].join(" ")}
+ style={{
+ "--color-main": theme.palette[fcs.color].main,
+ }}
+ >
+ {children}
+ </div>
+ );
+}
+
+const componentStyle = css`
+ font: inherit;
+ letter-spacing: inherit;
+ color: currentColor;
+ border: 0px;
+ box-sizing: content-box;
+ background: none;
+ height: 1.4375em;
+ margin: 0px;
+ -webkit-tap-highlight-color: transparent;
+ display: block;
+ min-width: 0px;
+ width: 100%;
+ animation-name: "auto-fill-cancel";
+ animation-duration: 10ms;
+
+ @keyframes auto-fill {
+ from {
+ display: block;
+ }
+ }
+ @keyframes auto-fill-cancel {
+ from {
+ display: block;
+ }
+ }
+ &::placeholder {
+ color: "currentColor";
+ opacity: ${theme.palette.mode === "light" ? 0.42 : 0.5};
+ transition: ${theme.transitions.create("opacity", {
+ duration: theme.transitions.duration.shorter,
+ })};
+ }
+ &:not(focus)::placeholder {
+ opacity: 0;
+ }
+ &:focus::placeholder {
+ opacity: ${theme.palette.mode === "light" ? 0.42 : 0.5};
+ }
+ &:focus {
+ outline: 0;
+ }
+ &:invalid {
+ box-shadow: none;
+ }
+ &::-webkit-search-decoration {
+ -webkit-appearance: none;
+ }
+ &:-webkit-autofill {
+ animation-duration: 5000s;
+ animation-name: auto-fill;
+ }
+ textarea {
+ height: "auto";
+ resize: "none";
+ padding: 0px;
+ padding-top: 0px;
+ }
+`;
+const componentDisabledStyle = css`
+ opacity: 1;
+ --webkit-text-fill-color: ${theme.palette.text.disabled};
+`;
+const componentSmallStyle = css`
+ padding-top: 1px;
+`;
+const componentMultilineStyle = css`
+ height: auto;
+ resize: none;
+ padding: 0px;
+ padding-top: 0px;
+`;
+const searchStyle = css`
+ -moz-appearance: textfield;
+ -webkit-appearance: textfield;
+`;
+
+export function InputBaseComponent({
+ disabled,
+ size,
+ multiline,
+ type,
+ class: _class,
+ startAdornment,
+ endAdornment,
+ ...props
+}: any): VNode {
+ return (
+ <Fragment>
+ {startAdornment}
+ <input
+ disabled={disabled}
+ type={type}
+ class={[
+ componentStyle,
+ _class,
+ disabled && componentDisabledStyle,
+ size === "small" && componentSmallStyle,
+ // multiline && componentMultilineStyle,
+ type === "search" && searchStyle,
+ ].join(" ")}
+ {...props}
+ />
+ {endAdornment}
+ </Fragment>
+ );
+}
+
+export function InputBase({
+ Root = InputBaseRoot,
+ Input,
+ onChange,
+ onInput,
+ name,
+ placeholder,
+ readOnly,
+ onKeyUp,
+ onKeyDown,
+ rows,
+ type = "text",
+ value,
+ maxRows,
+ minRows,
+ onClick,
+ ...props
+}: any): VNode {
+ const fcs = useFormControl(props);
+ // const [focused, setFocused] = useState(false);
+ useLayoutEffect(() => {
+ if (value && value !== "") {
+ fcs.onFilled();
+ } else {
+ fcs.onEmpty();
+ }
+ }, [value, fcs]);
+
+ const handleFocus = (event: JSX.TargetedFocusEvent<EventTarget>): void => {
+ // Fix a bug with IE11 where the focus/blur events are triggered
+ // while the component is disabled.
+ if (fcs.disabled) {
+ event.stopPropagation();
+ return;
+ }
+
+ // if (onFocus) {
+ // onFocus(event);
+ // }
+ // if (inputPropsProp.onFocus) {
+ // inputPropsProp.onFocus(event);
+ // }
+
+ fcs.onFocus();
+ };
+
+ const handleBlur = (): void => {
+ // if (onBlur) {
+ // onBlur(event);
+ // }
+ // if (inputPropsProp.onBlur) {
+ // inputPropsProp.onBlur(event);
+ // }
+
+ fcs.onBlur();
+ };
+
+ const handleChange = (
+ event: JSX.TargetedEvent<HTMLElement & { value?: string }>,
+ ): void => {
+ // if (inputPropsProp.onChange) {
+ // inputPropsProp.onChange(event, ...args);
+ // }
+
+ // Perform in the willUpdate
+ if (onChange) {
+ onChange(event.currentTarget.value);
+ }
+ };
+
+ const handleInput = (
+ event: JSX.TargetedEvent<HTMLElement & { value?: string }>,
+ ): void => {
+ // if (inputPropsProp.onChange) {
+ // inputPropsProp.onChange(event, ...args);
+ // }
+
+ // Perform in the willUpdate
+ if (onInput) {
+ event.currentTarget.value = onInput(event.currentTarget.value);
+ }
+ };
+
+ const handleClick = (
+ event: JSX.TargetedMouseEvent<HTMLElement & { value?: string }>,
+ ): void => {
+ // if (inputRef.current && event.currentTarget === event.target) {
+ // inputRef.current.focus();
+ // }
+
+ if (onClick) {
+ onClick(event.currentTarget.value);
+ }
+ };
+
+ const rowsProps = {
+ minRows: rows ? rows : minRows,
+ maxRows: rows ? rows : maxRows,
+ };
+ if (props.multiline) {
+ Input = TextareaAutoSize;
+ }
+
+ return (
+ <Root {...fcs} onClick={handleClick}>
+ <FormControlContext.Provider value={null}>
+ <Input
+ aria-invalid={fcs.error ? true : undefined}
+ // aria-describedby={}
+ disabled={fcs.disabled ? true : undefined}
+ name={name}
+ placeholder={!placeholder ? undefined : placeholder}
+ readOnly={readOnly}
+ required={fcs.required}
+ rows={rows}
+ value={value}
+ onKeyDown={onKeyDown}
+ onKeyUp={onKeyUp}
+ type={type}
+ onInput={handleInput}
+ onChange={handleChange}
+ onBlur={handleBlur}
+ onFocus={handleFocus}
+ {...rowsProps}
+ {...props}
+ />
+ </FormControlContext.Provider>
+ </Root>
+ );
+}
+const shadowStyle = css`
+ visibility: hidden;
+ position: absolute;
+ overflow: hidden;
+ height: 0px;
+ top: 0px;
+ left: 0px;
+ transform: translateZ(0);
+`;
+
+function ownerDocument(node: Node | null | undefined): Document {
+ return (node && node.ownerDocument) || document;
+}
+function ownerWindow(node: Node | null | undefined): Window {
+ const doc = ownerDocument(node);
+ return doc.defaultView || window;
+}
+function getStyleValue(
+ computedStyle: CSSStyleDeclaration,
+ property: any,
+): number {
+ return parseInt(computedStyle[property], 10) || 0;
+}
+
+function debounce(func: any, wait = 166): any {
+ let timeout: any;
+ function debounced(...args: any[]): void {
+ const later = () => {
+ func.apply({}, args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ }
+
+ debounced.clear = () => {
+ clearTimeout(timeout);
+ };
+
+ return debounced;
+}
+
+export function TextareaAutoSize({
+ // disabled,
+ // size,
+ onChange,
+ onInput,
+ value,
+ multiline,
+ focused,
+ disabled,
+ error,
+ minRows = 1,
+ maxRows,
+ style,
+ type,
+ class: _class,
+ ...props
+}: any): VNode {
+ // const { onChange, maxRows, minRows = 1, style, value, ...other } = props;
+
+ const { current: isControlled } = useRef(value != null);
+ const inputRef = useRef<HTMLTextAreaElement>(null);
+ // const handleRef = useForkRef(ref, inputRef);
+ const shadowRef = useRef<HTMLTextAreaElement>(null);
+ const renders = useRef(0);
+ const [state, setState] = useState<{ outerHeightStyle: any; overflow: any }>({
+ outerHeightStyle: undefined,
+ overflow: undefined,
+ });
+
+ const syncHeight = useCallback(() => {
+ const input = inputRef.current;
+ const inputShallow = shadowRef.current;
+ if (!input || !inputShallow) return;
+ const containerWindow = ownerWindow(input);
+ const computedStyle = containerWindow.getComputedStyle(input);
+
+ // If input's width is shrunk and it's not visible, don't sync height.
+ if (computedStyle.width === "0px") {
+ return;
+ }
+
+ inputShallow.style.width = computedStyle.width;
+ inputShallow.value = input.value || props.placeholder || "x";
+ if (inputShallow.value.slice(-1) === "\n") {
+ // Certain fonts which overflow the line height will cause the textarea
+ // to report a different scrollHeight depending on whether the last line
+ // is empty. Make it non-empty to avoid this issue.
+ inputShallow.value += " ";
+ }
+
+ const boxSizing: string = computedStyle["box-sizing" as any];
+ const padding =
+ getStyleValue(computedStyle, "padding-bottom") +
+ getStyleValue(computedStyle, "padding-top");
+ const border =
+ getStyleValue(computedStyle, "border-bottom-width") +
+ getStyleValue(computedStyle, "border-top-width");
+
+ // The height of the inner content
+ const innerHeight = inputShallow.scrollHeight;
+
+ // Measure height of a textarea with a single row
+ inputShallow.value = "x";
+ const singleRowHeight = inputShallow.scrollHeight;
+
+ // The height of the outer content
+ let outerHeight = innerHeight;
+
+ if (minRows) {
+ outerHeight = Math.max(Number(minRows) * singleRowHeight, outerHeight);
+ }
+ if (maxRows) {
+ outerHeight = Math.min(Number(maxRows) * singleRowHeight, outerHeight);
+ }
+ outerHeight = Math.max(outerHeight, singleRowHeight);
+
+ // Take the box sizing into account for applying this value as a style.
+ const outerHeightStyle =
+ outerHeight + (boxSizing === "border-box" ? padding + border : 0);
+ const overflow = Math.abs(outerHeight - innerHeight) <= 1;
+
+ setState((prevState) => {
+ // Need a large enough difference to update the height.
+ // This prevents infinite rendering loop.
+ if (
+ renders.current < 20 &&
+ ((outerHeightStyle > 0 &&
+ Math.abs((prevState.outerHeightStyle || 0) - outerHeightStyle) > 1) ||
+ prevState.overflow !== overflow)
+ ) {
+ renders.current += 1;
+ return {
+ overflow,
+ outerHeightStyle,
+ };
+ }
+
+ return prevState;
+ });
+ }, [maxRows, minRows, props.placeholder]);
+
+ useLayoutEffect(() => {
+ const handleResize = debounce(() => {
+ renders.current = 0;
+ syncHeight();
+ });
+ const containerWindow = ownerWindow(inputRef.current);
+ containerWindow.addEventListener("resize", handleResize);
+ let resizeObserver: any;
+
+ if (typeof ResizeObserver !== "undefined") {
+ resizeObserver = new ResizeObserver(handleResize);
+ resizeObserver.observe(inputRef.current);
+ }
+
+ return () => {
+ handleResize.clear();
+ containerWindow.removeEventListener("resize", handleResize);
+ if (resizeObserver) {
+ resizeObserver.disconnect();
+ }
+ };
+ }, [syncHeight]);
+
+ useLayoutEffect(() => {
+ syncHeight();
+ });
+
+ useLayoutEffect(() => {
+ renders.current = 0;
+ }, [value]);
+
+ const handleChange = (event: any): void => {
+ renders.current = 0;
+
+ if (!isControlled) {
+ syncHeight();
+ }
+
+ if (onChange) {
+ onChange(event.target.value);
+ }
+ };
+ const handleInput = (event: any): void => {
+ renders.current = 0;
+
+ if (!isControlled) {
+ syncHeight();
+ }
+
+ if (onInput) {
+ event.currentTarget.value = onInput(event.currentTarget.value);
+ }
+ };
+
+ return (
+ <Fragment>
+ <textarea
+ class={[
+ componentStyle,
+ componentMultilineStyle,
+ _class,
+ disabled && componentDisabledStyle,
+ // size === "small" && componentSmallStyle,
+ multiline && componentMultilineStyle,
+ type === "search" && searchStyle,
+ ].join(" ")}
+ value={value}
+ onChange={handleChange}
+ onInput={handleInput}
+ ref={inputRef}
+ // Apply the rows prop to get a "correct" first SSR paint
+ rows={minRows}
+ style={{
+ height: state.outerHeightStyle,
+ // Need a large enough difference to allow scrolling.
+ // This prevents infinite rendering loop.
+ overflow: state.overflow ? "hidden" : null,
+ ...style,
+ }}
+ {...props}
+ />
+
+ <textarea
+ aria-hidden
+ class={[
+ componentStyle,
+ componentMultilineStyle,
+ shadowStyle,
+ type === "search" && searchStyle,
+ ].join(" ")}
+ readOnly
+ ref={shadowRef}
+ tabIndex={-1}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/input/InputFilled.tsx b/packages/taler-wallet-webextension/src/mui/input/InputFilled.tsx
new file mode 100644
index 000000000..0707046f3
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/input/InputFilled.tsx
@@ -0,0 +1,199 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { h, VNode } from "preact";
+// eslint-disable-next-line import/extensions
+import { Colors, theme } from "../style.js";
+import { useFormControl } from "./FormControl.js";
+import { InputBase, InputBaseComponent, InputBaseRoot } from "./InputBase.js";
+
+export interface Props {
+ autoComplete?: string;
+ autoFocus?: boolean;
+ color?: Colors;
+ defaultValue?: string;
+ disabled?: boolean;
+ disableUnderline?: boolean;
+ error?: string | Error;
+ fullWidth?: boolean;
+ id?: string;
+ margin?: "dense" | "normal" | "none";
+ maxRows?: number;
+ minRows?: number;
+ multiline?: boolean;
+ name?: string;
+ onChange?: (s: string) => void;
+ placeholder?: string;
+ readOnly?: boolean;
+ required?: boolean;
+ rows?: number;
+ startAdornment?: VNode;
+ endAdornment?: VNode;
+ type?: string;
+ value?: string;
+}
+export function InputFilled({
+ type = "text",
+ multiline,
+ ...props
+}: Props): VNode {
+ const fcs = useFormControl(props);
+ return (
+ <InputBase
+ Root={Root}
+ Input={Input}
+ fullWidth={fcs.fullWidth}
+ multiline={multiline}
+ type={type}
+ {...props}
+ />
+ );
+}
+
+const light = theme.palette.mode === "light";
+const bottomLineColor = light
+ ? "rgba(0, 0, 0, 0.42)"
+ : "rgba(255, 255, 255, 0.7)";
+const backgroundColor = light
+ ? "rgba(0, 0, 0, 0.06)"
+ : "rgba(255, 255, 255, 0.09)";
+const backgroundColorHover = light
+ ? "rgba(0, 0, 0, 0.09)"
+ : "rgba(255, 255, 255, 0.13)";
+const backgroundColorDisabled = light
+ ? "rgba(0, 0, 0, 0.12)"
+ : "rgba(255, 255, 255, 0.12)";
+
+const formControlStyle = css`
+ label + & {
+ margin-top: 16px;
+ }
+`;
+
+const filledRootStyle = css`
+ position: relative;
+ background-color: ${backgroundColor};
+ border-top-left-radius: ${theme.shape.borderRadius}px;
+ border-top-right-radius: ${theme.shape.borderRadius}px;
+ transition: ${theme.transitions.create("background-color", {
+ duration: theme.transitions.duration.shorter,
+ easing: theme.transitions.easing.easeOut,
+})};
+ // when is not disabled underline
+ &:hover {
+ background-color: ${backgroundColorHover};
+ @media (hover: none) {
+ background-color: ${backgroundColor};
+ }
+ }
+ &[data-focused] {
+ background-color: ${backgroundColor};
+ }
+ &[data-disabled] {
+ background-color: ${backgroundColorDisabled};
+ }
+ &[data-multiline] {
+ padding: 25px 12px 8px;
+ }
+ /* &[data-hasStart] {
+ padding-left: 25px;
+ } */
+`;
+
+const underlineStyle = css`
+ // when is not disabled underline
+ &:after {
+ border-bottom: 2px solid var(--color-main);
+ left: 0px;
+ bottom: 0px;
+ content: "";
+ position: absolute;
+ right: 0px;
+ transform: scaleX(0);
+ transition: ${theme.transitions.create("transform", {
+ duration: theme.transitions.duration.shorter,
+ easing: theme.transitions.easing.easeOut,
+})};
+ pointer-events: none;
+ }
+ &[data-focused]:after {
+ transform: scaleX(1);
+ }
+ &[data-error]:after {
+ border-bottom-color: ${theme.palette.error.main};
+ transform: scaleY(1);
+ }
+ &:before {
+ border-bottom: 1px solid
+ ${theme.palette.mode === "light"
+ ? "rgba(0, 0, 0, 0.42)"
+ : "rgba(255, 255, 255, 0.7)"};
+ left: 0px;
+ bottom: 0px;
+ right: 0px;
+ content: "\\00a0";
+ position: absolute;
+ transition: ${theme.transitions.create("border-bottom-color", {
+ duration: theme.transitions.duration.shorter,
+ })};
+ pointer-events: none;
+ }
+ &:hover:not([data-disabled]:before) {
+ border-bottom: 2px solid var(--color-main);
+ @media (hover: none) {
+ border-bottom: 1px solid
+ ${theme.palette.mode === "light"
+ ? "rgba(0, 0, 0, 0.42)"
+ : "rgba(255, 255, 255, 0.7)"};
+ }
+ }
+ &[data-disabled]:before {
+ border-bottom-style: solid;
+ }
+`;
+
+function Root({
+ fullWidth,
+ disabled,
+ focused,
+ error,
+ children,
+ multiline,
+}: any): VNode {
+ return (
+ <InputBaseRoot
+ disabled={disabled}
+ focused={focused ? true : undefined}
+ fullWidth={fullWidth}
+ multiline={multiline}
+ error={error}
+ class={[filledRootStyle, underlineStyle].join(" ")}
+ >
+ {children}
+ </InputBaseRoot>
+ );
+}
+
+const filledBaseStyle = css`
+ padding-top: 25px;
+ padding-right: 12px;
+ padding-bottom: 8px;
+ padding-left: 12px;
+`;
+
+function Input(props: any): VNode {
+ return <InputBaseComponent class={[filledBaseStyle].join(" ")} {...props} />;
+}
diff --git a/packages/taler-wallet-webextension/src/mui/input/InputLabel.tsx b/packages/taler-wallet-webextension/src/mui/input/InputLabel.tsx
new file mode 100644
index 000000000..2d4743e59
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/input/InputLabel.tsx
@@ -0,0 +1,114 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { ComponentChildren, h, VNode } from "preact";
+// eslint-disable-next-line import/extensions
+import { Colors, theme } from "../style.js";
+import { useFormControl } from "./FormControl.js";
+import { FormLabel } from "./FormLabel.js";
+
+const root = css`
+ display: block;
+ transform-origin: top left;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+
+ &[data-form-control] {
+ position: absolute;
+ left: 0px;
+ top: 0px;
+ transform: translate(0, 20px) scale(1);
+ }
+ &[data-size="small"] {
+ transform: translate(0, 17px) scale(1);
+ }
+ &[data-shrink] {
+ transform: translate(0, -1.5px) scale(0.75);
+ transform-origin: top left;
+ max-width: 133%;
+ }
+ &:not([data-disable-animation]) {
+ transition: ${theme.transitions.create(
+ ["color", "transform", "max-width"],
+ {
+ duration: theme.transitions.duration.shorter,
+ easing: theme.transitions.easing.easeOut,
+ },
+ )};
+ }
+ &[data-variant="filled"] {
+ z-index: 1;
+ pointer-events: none;
+ transform: translate(12px, 16px) scale(1);
+ max-width: calc(100% - 24px);
+ &[data-size="small"] {
+ transform: translate(12px, 13px) scale(1);
+ }
+ &[data-shrink] {
+ user-select: none;
+ pointer-events: auto;
+ transform: translate(12px, 7px) scale(0.75);
+ max-width: calc(133% - 24px);
+ &[data-size="small"] {
+ transform: translate(12px, 4px) scale(0.75);
+ }
+ }
+ }
+ &[data-variant="outlined"] {
+ z-index: 1;
+ pointer-events: none;
+ transform: translate(14px, 16px) scale(1);
+ max-width: calc(100% - 24px);
+ &[data-size="small"] {
+ transform: translate(14px, 9px) scale(1);
+ }
+ &[data-shrink] {
+ user-select: none;
+ pointer-events: auto;
+ transform: translate(14px, -9px) scale(0.75);
+ max-width: calc(133% - 24px);
+ }
+ }
+`;
+
+interface InputLabelProps {
+ color: Colors;
+ disableAnimation: boolean;
+ disabled: boolean;
+ error?: string;
+ focused: boolean;
+ margin: boolean;
+ required: boolean;
+ shrink: boolean;
+ variant: "filled" | "outlined" | "standard";
+ children: ComponentChildren;
+}
+export function InputLabel(props: Partial<InputLabelProps>): VNode {
+ const fcs = useFormControl(props);
+ return (
+ <FormLabel
+ data-form-control={!!fcs}
+ data-size={fcs.size}
+ data-shrink={props.shrink || fcs.filled || fcs.focused ? true : undefined}
+ data-disable-animation={props.disableAnimation ? true : undefined}
+ data-variant={fcs.variant}
+ class={root}
+ {...props}
+ />
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/mui/input/InputStandard.tsx b/packages/taler-wallet-webextension/src/mui/input/InputStandard.tsx
new file mode 100644
index 000000000..7352c5ec1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/input/InputStandard.tsx
@@ -0,0 +1,142 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { h, VNode } from "preact";
+import { Colors, theme } from "../style.js";
+import { useFormControl } from "./FormControl.js";
+import { InputBase, InputBaseComponent, InputBaseRoot } from "./InputBase.js";
+
+export interface Props {
+ autoComplete?: string;
+ autoFocus?: boolean;
+ color?: Colors;
+ defaultValue?: string;
+ disabled?: boolean;
+ disableUnderline?: boolean;
+ endAdornment?: VNode;
+ error?: string | Error;
+ fullWidth?: boolean;
+ id?: string;
+ margin?: "dense" | "normal" | "none";
+ maxRows?: number;
+ minRows?: number;
+ multiline?: boolean;
+ name?: string;
+ onChange?: (s: string) => void;
+ placeholder?: string;
+ readOnly?: boolean;
+ required?: boolean;
+ rows?: number;
+ startAdornment?: VNode;
+ type?: string;
+ value?: string;
+}
+export function InputStandard({
+ type = "text",
+ multiline,
+ ...props
+}: Props): VNode {
+ const fcs = useFormControl(props);
+ return (
+ <InputBase
+ Root={Root}
+ Input={Input}
+ fullWidth={fcs.fullWidth}
+ multiline={multiline}
+ type={type}
+ {...props}
+ />
+ );
+}
+
+const rootStyle = css`
+ position: relative;
+ padding: 4px 0 5px;
+`;
+const formControlStyle = css`
+ label + & {
+ margin-top: 16px;
+ }
+`;
+const underlineStyle = css`
+ // when is not disabled underline
+ &:after {
+ border-bottom: 2px solid var(--color-main);
+ left: 0px;
+ bottom: 0px;
+ content: "";
+ position: absolute;
+ right: 0px;
+ transform: scaleX(0);
+ transition: ${theme.transitions.create("transform", {
+ duration: theme.transitions.duration.shorter,
+ easing: theme.transitions.easing.easeOut,
+})};
+ pointer-events: none;
+ }
+ &[data-focused]:after {
+ transform: scaleX(1);
+ }
+ &[data-error]:after {
+ border-bottom-color: ${theme.palette.error.main};
+ transform: scaleY(1);
+ }
+ &:before {
+ border-bottom: 1px solid
+ ${theme.palette.mode === "light"
+ ? "rgba(0, 0, 0, 0.42)"
+ : "rgba(255, 255, 255, 0.7)"};
+ left: 0px;
+ bottom: 0px;
+ right: 0px;
+ content: "\\00a0";
+ position: absolute;
+ transition: ${theme.transitions.create("border-bottom-color", {
+ duration: theme.transitions.duration.shorter,
+ })};
+ pointer-events: none;
+ }
+ &:hover:not([data-disabled]:before) {
+ border-bottom: 2px solid var(--color-main);
+ @media (hover: none) {
+ border-bottom: 1px solid
+ ${theme.palette.mode === "light"
+ ? "rgba(0, 0, 0, 0.42)"
+ : "rgba(255, 255, 255, 0.7)"};
+ }
+ }
+ &[data-disabled]:before {
+ border-bottom-style: solid;
+ }
+`;
+
+function Root({ fullWidth, disabled, focused, error, children }: any): VNode {
+ return (
+ <InputBaseRoot
+ disabled={disabled}
+ focused={focused ? true : undefined}
+ fullWidth={fullWidth}
+ error={error}
+ class={[rootStyle, formControlStyle, underlineStyle].join(" ")}
+ >
+ {children}
+ </InputBaseRoot>
+ );
+}
+
+function Input(props: any): VNode {
+ return <InputBaseComponent {...props} />;
+}
diff --git a/packages/taler-wallet-core/src/util/assertUnreachable.ts b/packages/taler-wallet-webextension/src/mui/input/SelectFilled.tsx
index ffdf88f04..0ae70d06a 100644
--- a/packages/taler-wallet-core/src/util/assertUnreachable.ts
+++ b/packages/taler-wallet-webextension/src/mui/input/SelectFilled.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 GNUnet e.V.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -13,7 +13,8 @@
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import { h, VNode } from "preact";
-export function assertUnreachable(x: never): never {
- throw new Error("Didn't expect to get here");
+export function SelectFilled(): VNode {
+ return <div />;
}
diff --git a/packages/taler-wallet-webextension/src/mui/input/SelectOutlined.tsx b/packages/taler-wallet-webextension/src/mui/input/SelectOutlined.tsx
new file mode 100644
index 000000000..72579aed2
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/input/SelectOutlined.tsx
@@ -0,0 +1,20 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+
+export function SelectOutlined(): VNode {
+ return <div />;
+}
diff --git a/packages/taler-wallet-webextension/src/mui/input/SelectStandard.tsx b/packages/taler-wallet-webextension/src/mui/input/SelectStandard.tsx
new file mode 100644
index 000000000..b0474a80b
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/input/SelectStandard.tsx
@@ -0,0 +1,200 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { h, VNode, Fragment } from "preact";
+import { useRef } from "preact/hooks";
+import { Paper } from "../Paper.js";
+
+function hasValue(value: any): boolean {
+ return value != null && !(Array.isArray(value) && value.length === 0);
+}
+
+const SelectSelect = css`
+ height: "auto";
+ min-height: "1.4374em";
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+`;
+
+const SelectIcon = css``;
+
+const SelectNativeInput = css`
+ bottom: 0px;
+ left: 0px;
+ position: "absolute";
+ opacity: 0px;
+ pointer-events: "none";
+ width: 100%;
+ box-sizing: border-box;
+`;
+
+// export function SelectStandard({ value }: any): VNode {
+// return (
+// <Fragment>
+// <div class={SelectSelect} role="button">
+// {!value ? (
+// // notranslate needed while Google Translate will not fix zero-width space issue
+// <span className="notranslate">&#8203;</span>
+// ) : (
+// value
+// )}
+// <input
+// class={SelectNativeInput}
+// aria-hidden
+// tabIndex={-1}
+// value={Array.isArray(value) ? value.join(",") : value}
+// />
+// </div>
+// </Fragment>
+// );
+// }
+function isFilled(obj: any, SSR = false): boolean {
+ return (
+ obj &&
+ ((hasValue(obj.value) && obj.value !== "") ||
+ (SSR && hasValue(obj.defaultValue) && obj.defaultValue !== ""))
+ );
+}
+function isEmpty(display: any): boolean {
+ return display == null || (typeof display === "string" && !display.trim());
+}
+
+export function SelectStandard({
+ value,
+ multiple,
+ displayEmpty,
+ onBlur,
+ onChange,
+ onClose,
+ onFocus,
+ onOpen,
+ renderValue,
+ menuMinWidthState,
+}: any): VNode {
+ const inputRef = useRef(null);
+ const displayRef = useRef(null);
+
+ let display;
+ let computeDisplay = false;
+ let foundMatch = false;
+ let displaySingle;
+ const displayMultiple: any[] = [];
+ if (isFilled({ value }) || displayEmpty) {
+ if (renderValue) {
+ display = renderValue(value);
+ } else {
+ computeDisplay = true;
+ }
+ }
+ if (computeDisplay) {
+ if (multiple) {
+ if (displayMultiple.length === 0) {
+ display = null;
+ } else {
+ display = displayMultiple.reduce((output, child, index) => {
+ output.push(child);
+ if (index < displayMultiple.length - 1) {
+ output.push(", ");
+ }
+ return output;
+ }, []);
+ }
+ } else {
+ display = displaySingle;
+ }
+ }
+
+ // Avoid performing a layout computation in the render method.
+ let menuMinWidth = menuMinWidthState;
+
+ // if (!autoWidth && isOpenControlled && displayNode) {
+ // menuMinWidth = displayNode.clientWidth;
+ // }
+
+ // let tabIndex;
+ // if (typeof tabIndexProp !== "undefined") {
+ // tabIndex = tabIndexProp;
+ // } else {
+ // tabIndex = disabled ? null : 0;
+ // }
+ const update = (open: any, event: any) => {
+ if (open) {
+ if (onOpen) {
+ onOpen(event);
+ }
+ } else if (onClose) {
+ onClose(event);
+ }
+
+ // if (!isOpenControlled) {
+ // setMenuMinWidthState(autoWidth ? null : displayNode.clientWidth);
+ // setOpenState(open);
+ // }
+ };
+
+ const handleMouseDown = (event: any) => {
+ // Ignore everything but left-click
+ if (event.button !== 0) {
+ return;
+ }
+ // Hijack the default focus behavior.
+ event.preventDefault();
+ // displayRef.current.focus();
+
+ update(true, event);
+ };
+ return (
+ <Fragment>
+ <div
+ class={css`
+ height: auto;
+ min-height: 14375em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ `}
+ >
+ {isEmpty(display) ? (
+ // notranslate needed while Google Translate will not fix zero-width space issue
+ <span class="notranslate">&#8203;</span>
+ ) : (
+ display
+ )}
+ </div>
+ <input
+ class={css`
+ bottom: 0px;
+ left: 0px;
+ position: "absolute";
+ opacity: 0;
+ pointer-events: none;
+ width: 100%;
+ box-sizing: border-box;
+ `}
+ />
+ <svg />
+ </Fragment>
+ );
+}
+
+// function Popover(): VNode {
+// return;
+// }
+
+// function Menu(): VNode {
+// return <Paper></Paper>;
+// }
diff --git a/packages/taler-wallet-webextension/src/mui/style.tsx b/packages/taler-wallet-webextension/src/mui/style.tsx
new file mode 100644
index 000000000..99adf2a76
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/style.tsx
@@ -0,0 +1,874 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { css } from "@linaria/core";
+import { darken, lighten } from "polished";
+import {
+ blue,
+ common,
+ green,
+ grey,
+ lightBlue,
+ orange,
+ purple,
+ red,
+ // eslint-disable-next-line import/extensions
+} from "./colors/constants.js";
+// eslint-disable-next-line import/extensions
+import { getContrastRatio } from "./colors/manipulation.js";
+
+export type Colors =
+ | "primary"
+ | "secondary"
+ | "success"
+ | "error"
+ | "info"
+ | "warning";
+
+export function round(value: number): number {
+ return Math.round(value * 1e5) / 1e5;
+}
+const fontSize = 14;
+const htmlFontSize = 16;
+const coef = fontSize / 14;
+export function pxToRem(size: number): string {
+ return `${(size / htmlFontSize) * coef}rem`;
+}
+
+export interface Spacing {
+ (): string;
+ (value?: number): string;
+ (topBottom: number, rightLeft: number): string;
+ (top: number, rightLeft: number, bottom: number): string;
+ (top: number, right: number, bottom: number, left: number): string;
+}
+
+const zIndex = {
+ mobileStepper: 1000,
+ speedDial: 1050,
+ appBar: 1100,
+ drawer: 1200,
+ modal: 1300,
+ snackbar: 1400,
+ tooltip: 1500,
+};
+
+export const theme = createTheme();
+
+export const ripple = css`
+ background-position: center;
+
+ transition: background 0.2s;
+
+ &:hover {
+ background: var(--color-main)
+ radial-gradient(circle, transparent 1%, var(--color-dark) 1%)
+ center/15000%;
+ }
+ &:active {
+ background-color: var(--color-main);
+ background-size: 100%;
+ transition: background 0s;
+ }
+`;
+
+export const rippleEnabled = css`
+ background-position: center;
+
+ transition: background 0.2s;
+
+ &:hover:enabled {
+ background: var(--color-main)
+ radial-gradient(circle, transparent 1%, var(--color-dark) 1%)
+ center/15000%;
+ }
+ &:active:enabled {
+ background-color: var(--color-main);
+ background-size: 100%;
+ transition: background 0s;
+ }
+`;
+
+export const rippleEnabledOutlined = css`
+ background-position: center;
+
+ transition: background 0.2s;
+
+ &:hover:enabled {
+ background: var(--color-contrastText)
+ radial-gradient(circle, transparent 1%, var(--color-light) 1%)
+ center/15000%;
+ }
+
+ &:active:enabled {
+ background-color: var(--color-contrastText);
+ background-size: 100%;
+ transition: background 0s;
+ }
+`;
+
+function createTheme() {
+ const light = {
+ // The colors used to style the text.
+ text: {
+ // The most important text.
+ primary: "rgba(0, 0, 0, 0.87)",
+ // Secondary text.
+ secondary: "rgba(0, 0, 0, 0.6)",
+ // Disabled text have even lower visual prominence.
+ disabled: "rgba(0, 0, 0, 0.38)",
+ },
+ // The color used to divide different elements.
+ divider: "rgba(0, 0, 0, 0.12)",
+ // The background colors used to style the surfaces.
+ // Consistency between these values is important.
+ background: {
+ paper: common.white,
+ default: common.white,
+ },
+ // The colors used to style the action elements.
+ action: {
+ // The color of an active action like an icon button.
+ active: "rgba(0, 0, 0, 0.54)",
+ // The color of an hovered action.
+ hover: "rgba(0, 0, 0, 0.04)",
+ hoverOpacity: 0.04,
+ // The color of a selected action.
+ selected: "rgba(0, 0, 0, 0.08)",
+ selectedOpacity: 0.08,
+ // The color of a disabled action.
+ disabled: "rgba(0, 0, 0, 0.26)",
+ // The background color of a disabled action.
+ disabledBackground: "rgba(0, 0, 0, 0.12)",
+ disabledOpacity: 0.38,
+ focus: "rgba(0, 0, 0, 0.12)",
+ focusOpacity: 0.12,
+ activatedOpacity: 0.12,
+ },
+ };
+
+ const dark = {
+ text: {
+ primary: common.white,
+ secondary: "rgba(255, 255, 255, 0.7)",
+ disabled: "rgba(255, 255, 255, 0.5)",
+ icon: "rgba(255, 255, 255, 0.5)",
+ },
+ divider: "rgba(255, 255, 255, 0.12)",
+ background: {
+ paper: "#121212",
+ default: "#121212",
+ },
+ action: {
+ active: common.white,
+ hover: "rgba(255, 255, 255, 0.08)",
+ hoverOpacity: 0.08,
+ selected: "rgba(255, 255, 255, 0.16)",
+ selectedOpacity: 0.16,
+ disabled: "rgba(255, 255, 255, 0.3)",
+ disabledBackground: "rgba(255, 255, 255, 0.12)",
+ disabledOpacity: 0.38,
+ focus: "rgba(255, 255, 255, 0.12)",
+ focusOpacity: 0.12,
+ activatedOpacity: 0.24,
+ },
+ };
+
+ const defaultFontFamily = '"Roboto", "Helvetica", "Arial", sans-serif';
+
+ const shadowKeyUmbraOpacity = 0.2;
+ const shadowKeyPenumbraOpacity = 0.14;
+ const shadowAmbientShadowOpacity = 0.12;
+
+ const typography = createTypography({});
+ const palette = createPalette({});
+ const shadows = createAllShadows();
+ const transitions = createTransitions({});
+ const breakpoints = createBreakpoints({});
+ const spacing = createSpacing();
+ const shape = {
+ roundBorder: css`
+ border-radius: 4px;
+ `,
+ squareBorder: css`
+ border-radius: 0px;
+ `,
+ circularBorder: css`
+ border-radius: 50%;
+ `,
+ borderRadius: 4,
+ };
+
+ /////////////////////
+ ///////////////////// SPACING
+ /////////////////////
+
+ function createUnaryUnit(theme: { spacing: number }, defaultValue: number) {
+ const themeSpacing = theme.spacing || defaultValue;
+
+ if (typeof themeSpacing === "number") {
+ return (abs: number | string) => {
+ if (typeof abs === "string") {
+ return abs;
+ }
+
+ return themeSpacing * abs;
+ };
+ }
+
+ if (Array.isArray(themeSpacing)) {
+ return (abs: number | string) => {
+ if (typeof abs === "string") {
+ return abs;
+ }
+
+ return themeSpacing[abs];
+ };
+ }
+
+ if (typeof themeSpacing === "function") {
+ return themeSpacing;
+ }
+
+ return (a: string | number) => "";
+ }
+
+ function createUnarySpacing(theme: { spacing: number }) {
+ return createUnaryUnit(theme, 8);
+ }
+
+ function createSpacing(spacingInput = 8): Spacing {
+ // Material Design layouts are visually balanced. Most measurements align to an 8dp grid, which aligns both spacing and the overall layout.
+ // Smaller components, such as icons, can align to a 4dp grid.
+ // https://material.io/design/layout/understanding-layout.html#usage
+ const transform = createUnarySpacing({
+ spacing: spacingInput,
+ });
+
+ const spacing = (
+ ...argsInput: ReadonlyArray<number | string | undefined>
+ ): string => {
+ const args = argsInput.length === 0 ? [1] : argsInput;
+
+ return args
+ .map((argument) => {
+ if (argument === undefined) return "";
+ const output = transform(argument);
+ return typeof output === "number" ? `${output}px` : output;
+ })
+ .join(" ");
+ };
+
+ return spacing;
+ }
+ /////////////////////
+ ///////////////////// BREAKPOINTS
+ /////////////////////
+ function createBreakpoints(breakpoints: any) {
+ const {
+ // The breakpoint **start** at this value.
+ // For instance with the first breakpoint xs: [xs, sm).
+ values = {
+ xs: 0,
+ sm: 600,
+ md: 900,
+ lg: 1200,
+ xl: 1536, // large screen
+ },
+ unit = "px",
+ step = 5,
+ // ...other
+ } = breakpoints;
+
+ const keys = Object.keys(values);
+
+ function up(key: any) {
+ const value = typeof values[key] === "number" ? values[key] : key;
+ return `@media (min-width:${value}${unit})`;
+ }
+
+ function down(key: any) {
+ const value = typeof values[key] === "number" ? values[key] : key;
+ return `@media (max-width:${value - step / 100}${unit})`;
+ }
+
+ function between(start: any, end: any) {
+ const endIndex = keys.indexOf(end);
+
+ return (
+ `@media (min-width:${
+ typeof values[start] === "number" ? values[start] : start
+ }${unit}) and ` +
+ `(max-width:${
+ (endIndex !== -1 && typeof values[keys[endIndex]] === "number"
+ ? values[keys[endIndex]]
+ : end) -
+ step / 100
+ }${unit})`
+ );
+ }
+
+ function only(key: any) {
+ if (keys.indexOf(key) + 1 < keys.length) {
+ return between(key, keys[keys.indexOf(key) + 1]);
+ }
+
+ return up(key);
+ }
+
+ function not(key: any) {
+ // handle first and last key separately, for better readability
+ const keyIndex = keys.indexOf(key);
+ if (keyIndex === 0) {
+ return up(keys[1]);
+ }
+ if (keyIndex === keys.length - 1) {
+ return down(keys[keyIndex]);
+ }
+
+ return between(key, keys[keys.indexOf(key) + 1]).replace(
+ "@media",
+ "@media not all and",
+ );
+ }
+
+ return {
+ keys,
+ values,
+ up,
+ down,
+ between,
+ only,
+ not,
+ unit,
+ // ...other,
+ };
+ }
+
+ /////////////////////
+ ///////////////////// SHADOWS
+ /////////////////////
+ function createShadow(...px: number[]): string {
+ return [
+ `${px[0]}px ${px[1]}px ${px[2]}px ${px[3]}px rgba(0,0,0,${shadowKeyUmbraOpacity})`,
+ `${px[4]}px ${px[5]}px ${px[6]}px ${px[7]}px rgba(0,0,0,${shadowKeyPenumbraOpacity})`,
+ `${px[8]}px ${px[9]}px ${px[10]}px ${px[11]}px rgba(0,0,0,${shadowAmbientShadowOpacity})`,
+ ].join(",");
+ }
+
+ function createAllShadows() {
+ // Values from https://github.com/material-components/material-components-web/blob/be8747f94574669cb5e7add1a7c54fa41a89cec7/packages/mdc-elevation/_variables.scss
+ return [
+ "none",
+ createShadow(0, 2, 1, -1, 0, 1, 1, 0, 0, 1, 3, 0),
+ createShadow(0, 3, 1, -2, 0, 2, 2, 0, 0, 1, 5, 0),
+ createShadow(0, 3, 3, -2, 0, 3, 4, 0, 0, 1, 8, 0),
+ createShadow(0, 2, 4, -1, 0, 4, 5, 0, 0, 1, 10, 0),
+ createShadow(0, 3, 5, -1, 0, 5, 8, 0, 0, 1, 14, 0),
+ createShadow(0, 3, 5, -1, 0, 6, 10, 0, 0, 1, 18, 0),
+ createShadow(0, 4, 5, -2, 0, 7, 10, 1, 0, 2, 16, 1),
+ createShadow(0, 5, 5, -3, 0, 8, 10, 1, 0, 3, 14, 2),
+ createShadow(0, 5, 6, -3, 0, 9, 12, 1, 0, 3, 16, 2),
+ createShadow(0, 6, 6, -3, 0, 10, 14, 1, 0, 4, 18, 3),
+ createShadow(0, 6, 7, -4, 0, 11, 15, 1, 0, 4, 20, 3),
+ createShadow(0, 7, 8, -4, 0, 12, 17, 2, 0, 5, 22, 4),
+ createShadow(0, 7, 8, -4, 0, 13, 19, 2, 0, 5, 24, 4),
+ createShadow(0, 7, 9, -4, 0, 14, 21, 2, 0, 5, 26, 4),
+ createShadow(0, 8, 9, -5, 0, 15, 22, 2, 0, 6, 28, 5),
+ createShadow(0, 8, 10, -5, 0, 16, 24, 2, 0, 6, 30, 5),
+ createShadow(0, 8, 11, -5, 0, 17, 26, 2, 0, 6, 32, 5),
+ createShadow(0, 9, 11, -5, 0, 18, 28, 2, 0, 7, 34, 6),
+ createShadow(0, 9, 12, -6, 0, 19, 29, 2, 0, 7, 36, 6),
+ createShadow(0, 10, 13, -6, 0, 20, 31, 3, 0, 8, 38, 7),
+ createShadow(0, 10, 13, -6, 0, 21, 33, 3, 0, 8, 40, 7),
+ createShadow(0, 10, 14, -6, 0, 22, 35, 3, 0, 8, 42, 7),
+ createShadow(0, 11, 14, -7, 0, 23, 36, 3, 0, 9, 44, 8),
+ createShadow(0, 11, 15, -7, 0, 24, 38, 3, 0, 9, 46, 8),
+ ];
+ }
+
+ /////////////////////
+ ///////////////////// TYPOGRAPHY
+ /////////////////////
+ /**
+ * @see @link{https://material.io/design/typography/the-type-system.html}
+ * @see @link{https://material.io/design/typography/understanding-typography.html}
+ */
+ function createTypography(typography: any) {
+ // const {
+ const fontFamily = defaultFontFamily,
+ // The default font size of the Material Specification.
+ fontSize = 14, // px
+ fontWeightLight = 300,
+ fontWeightRegular = 400,
+ fontWeightMedium = 500,
+ fontWeightBold = 700,
+ // Tell MUI what's the font-size on the html element.
+ // 16px is the default font-size used by browsers.
+ htmlFontSize = 16;
+ // Apply the CSS properties to all the variants.
+ // allVariants,
+ // pxToRem: pxToRem2,
+ // ...other
+ // } = typography;
+ const variants = {
+ // (fontWeight, size, lineHeight, letterSpacing, casing) =>
+ // h1: buildVariant(fontWeightLight, 96, 1.167, -1.5),
+ // h2: buildVariant(fontWeightLight, 60, 1.2, -0.5),
+ // h3: buildVariant(fontWeightRegular, 48, 1.167, 0),
+ // h4: buildVariant(fontWeightRegular, 34, 1.235, 0.25),
+ // h5: buildVariant(fontWeightRegular, 24, 1.334, 0),
+ // h6: buildVariant(fontWeightMedium, 20, 1.6, 0.15),
+ // subtitle1: buildVariant(fontWeightRegular, 16, 1.75, 0.15),
+ // subtitle2: buildVariant(fontWeightMedium, 14, 1.57, 0.1),
+ body1: css`
+ font-family: "Roboto", "Helvetica", "Arial", sans-serif;
+ font-weight: ${fontWeightRegular};
+ font-size: ${pxToRem(16)};
+ line-height: 1.5;
+ letter-spacing: ${round(0.15 / 16)}em;
+ `,
+ // body1: buildVariant(fontWeightRegular, 16, 1.5, 0.15),
+ body2: css`
+ font-family: "Roboto", "Helvetica", "Arial", sans-serif;
+ font-weight: ${fontWeightRegular};
+ font-size: ${pxToRem(14)};
+ line-height: 1.43;
+ letter-spacing: ${round(0.15 / 14)}em;
+ `,
+ // body2: buildVariant(fontWeightRegular, 14, 1.43, 0.15),
+ button: css`
+ font-family: "Roboto", "Helvetica", "Arial", sans-serif;
+ font-weight: ${fontWeightMedium};
+ font-size: ${pxToRem(14)};
+ line-height: 1.75;
+ letter-spacing: ${round(0.4 / 14)}em;
+ text-transform: uppercase;
+ `,
+ /* just of caseAllCaps */
+ // button: buildVariant(fontWeightMedium, 14, 1.75, 0.4, caseAllCaps),
+
+ caption: css`
+ font-family: "Roboto", "Helvetica", "Arial", sans-serif;
+ font-weight: ${fontWeightMedium};
+ font-size: ${pxToRem(12)};
+ line-height: 1.66;
+ letter-spacing: ${round(0.4 / 12)}em;
+ `,
+ // caption: buildVariant(fontWeightRegular, 12, 1.66, 0.4),
+ // overline: buildVariant(fontWeightRegular, 12, 2.66, 1, caseAllCaps),
+ };
+
+ return deepmerge(
+ {
+ htmlFontSize,
+ pxToRem,
+ fontFamily,
+ fontSize,
+ fontWeightLight,
+ fontWeightRegular,
+ fontWeightMedium,
+ fontWeightBold,
+ ...variants,
+ },
+ // other,
+ {
+ clone: false, // No need to clone deep
+ },
+ );
+ }
+
+ /////////////////////
+ ///////////////////// MIXINS
+ /////////////////////
+ // function createMixins(breakpoints: any, spacing: any, mixins: any) {
+ // return {
+ // toolbar: {
+ // minHeight: 56,
+ // [`${breakpoints.up("xs")} and (orientation: landscape)`]: {
+ // minHeight: 48,
+ // },
+ // [breakpoints.up("sm")]: {
+ // minHeight: 64,
+ // },
+ // },
+ // ...mixins,
+ // };
+ // }
+
+ /////////////////////
+ ///////////////////// TRANSITION
+ /////////////////////
+ function formatMs(milliseconds: number) {
+ return `${Math.round(milliseconds)}ms`;
+ }
+
+ function getAutoHeightDuration(height: number) {
+ if (!height) {
+ return 0;
+ }
+
+ const constant = height / 36;
+
+ // https://www.wolframalpha.com/input/?i=(4+%2B+15+*+(x+%2F+36+)+**+0.25+%2B+(x+%2F+36)+%2F+5)+*+10
+ return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10);
+ }
+
+ function createTransitions(inputTransitions: any) {
+ // Follow https://material.google.com/motion/duration-easing.html#duration-easing-natural-easing-curves
+ // to learn the context in which each easing should be used.
+ const easing = {
+ // This is the most common easing curve.
+ easeInOut: "cubic-bezier(0.4, 0, 0.2, 1)",
+ // Objects enter the screen at full velocity from off-screen and
+ // slowly decelerate to a resting point.
+ easeOut: "cubic-bezier(0.0, 0, 0.2, 1)",
+ // Objects leave the screen at full velocity. They do not decelerate when off-screen.
+ easeIn: "cubic-bezier(0.4, 0, 1, 1)",
+ // The sharp curve is used by objects that may return to the screen at any time.
+ sharp: "cubic-bezier(0.4, 0, 0.6, 1)",
+ };
+
+ // Follow https://material.io/guidelines/motion/duration-easing.html#duration-easing-common-durations
+ // to learn when use what timing
+ const duration = {
+ shortest: 150,
+ shorter: 200,
+ short: 250,
+ // most basic recommended timing
+ standard: 300,
+ // this is to be used in complex animations
+ complex: 375,
+ // recommended when something is entering screen
+ enteringScreen: 225,
+ // recommended when something is leaving screen
+ leavingScreen: 195,
+ };
+
+ const mergedEasing = {
+ ...easing,
+ ...inputTransitions.easing,
+ };
+
+ const mergedDuration = {
+ ...duration,
+ ...inputTransitions.duration,
+ };
+
+ const create = (props = ["all"], options = {} as any) => {
+ const {
+ duration: durationOption = mergedDuration.standard,
+ easing: easingOption = mergedEasing.easeInOut,
+ delay = 0,
+ // ...other
+ } = options;
+
+ return (Array.isArray(props) ? props : [props])
+ .map(
+ (animatedProp) =>
+ `${animatedProp} ${
+ typeof durationOption === "string"
+ ? durationOption
+ : formatMs(durationOption)
+ } ${easingOption} ${
+ typeof delay === "string" ? delay : formatMs(delay)
+ }`,
+ )
+ .join(",");
+ };
+
+ return {
+ getAutoHeightDuration,
+ create,
+ ...inputTransitions,
+ easing: mergedEasing,
+ duration: mergedDuration,
+ };
+ }
+
+ /////////////////////
+ ///////////////////// PALETTE
+ /////////////////////
+ function createPalette(palette: any) {
+ // const {
+ const mode: "light" | "dark" = "light";
+ const contrastThreshold = 3;
+ const tonalOffset = 0.2;
+ // ...other
+ // } = palette;
+
+ const primary = palette.primary || getDefaultPrimary(mode);
+ const secondary = palette.secondary || getDefaultSecondary(mode);
+ const error = palette.error || getDefaultError(mode);
+ const info = palette.info || getDefaultInfo(mode);
+ const success = palette.success || getDefaultSuccess(mode);
+ const warning = palette.warning || getDefaultWarning(mode);
+
+ // Use the same logic as
+ // Bootstrap: https://github.com/twbs/bootstrap/blob/1d6e3710dd447de1a200f29e8fa521f8a0908f70/scss/_functions.scss#L59
+ // and material-components-web https://github.com/material-components/material-components-web/blob/ac46b8863c4dab9fc22c4c662dc6bd1b65dd652f/packages/mdc-theme/_functions.scss#L54
+ function getContrastText(background: string): string {
+ const contrastText =
+ getContrastRatio(background, dark.text.primary) >= contrastThreshold
+ ? dark.text.primary
+ : light.text.primary;
+
+ return contrastText;
+ }
+
+ const augmentColor = ({
+ color,
+ name,
+ mainShade = 500,
+ lightShade = 300,
+ darkShade = 700,
+ }: any) => {
+ color = { ...color };
+ if (!color.main && color[mainShade]) {
+ color.main = color[mainShade];
+ }
+
+ addLightOrDark(color, "light", lightShade, tonalOffset);
+ addLightOrDark(color, "dark", darkShade, tonalOffset);
+ if (!color.contrastText) {
+ color.contrastText = getContrastText(color.main);
+ }
+
+ return color;
+ };
+
+ const modes = { dark, light };
+
+ // if (process.env.NODE_ENV !== "production") {
+ // if (!modes[mode]) {
+ // console.error(`MUI: The palette mode \`${mode}\` is not supported.`);
+ // }
+ // }
+ const paletteOutput = deepmerge(
+ {
+ // A collection of common colors.
+ common,
+ // The palette mode, can be light or dark.
+ mode,
+ // The colors used to represent primary interface elements for a user.
+ primary: augmentColor({ color: primary, name: "primary" }),
+ // The colors used to represent secondary interface elements for a user.
+ secondary: augmentColor({
+ color: secondary,
+ name: "secondary",
+ mainShade: "A400",
+ lightShade: "A200",
+ darkShade: "A700",
+ }),
+ // The colors used to represent interface elements that the user should be made aware of.
+ error: augmentColor({ color: error, name: "error" }),
+ // The colors used to represent potentially dangerous actions or important messages.
+ warning: augmentColor({ color: warning, name: "warning" }),
+ // The colors used to present information to the user that is neutral and not necessarily important.
+ info: augmentColor({ color: info, name: "info" }),
+ // The colors used to indicate the successful completion of an action that user triggered.
+ success: augmentColor({ color: success, name: "success" }),
+ // The grey colors.
+ grey,
+ // Used by `getContrastText()` to maximize the contrast between
+ // the background and the text.
+ contrastThreshold,
+ // Takes a background color and returns the text color that maximizes the contrast.
+ getContrastText,
+ // Generate a rich color object.
+ augmentColor,
+ // Used by the functions below to shift a color's luminance by approximately
+ // two indexes within its tonal palette.
+ // E.g., shift from Red 500 to Red 300 or Red 700.
+ tonalOffset,
+ // The light and dark mode object.
+ ...modes[mode],
+ },
+ // other:
+ {},
+ );
+
+ return paletteOutput;
+ }
+
+ function addLightOrDark(
+ intent: any,
+ direction: any,
+ shade: any,
+ tonalOffset: any,
+ ): void {
+ const tonalOffsetLight = tonalOffset.light || tonalOffset;
+ const tonalOffsetDark = tonalOffset.dark || tonalOffset * 1.5;
+
+ if (!intent[direction]) {
+ if (intent.hasOwnProperty(shade)) {
+ intent[direction] = intent[shade];
+ } else if (direction === "light") {
+ intent.light = lighten(intent.main, tonalOffsetLight);
+ } else if (direction === "dark") {
+ intent.dark = darken(intent.main, tonalOffsetDark);
+ }
+ }
+ }
+
+ function getDefaultPrimary(mode = "light") {
+ if (mode === "dark") {
+ return {
+ main: blue[200],
+ light: blue[50],
+ dark: blue[400],
+ };
+ }
+ return {
+ main: blue[700],
+ light: blue[400],
+ dark: blue[800],
+ };
+ }
+
+ function getDefaultSecondary(mode = "light") {
+ if (mode === "dark") {
+ return {
+ main: grey[200],
+ light: grey[50],
+ dark: grey[400],
+ };
+ }
+ return {
+ main: grey[300],
+ light: grey[100],
+ dark: grey[600],
+ };
+ }
+
+ function getDefaultError(mode = "light") {
+ if (mode === "dark") {
+ return {
+ main: red[500],
+ light: red[300],
+ dark: red[700],
+ };
+ }
+ return {
+ main: red[700],
+ light: red[400],
+ dark: red[800],
+ };
+ }
+
+ function getDefaultInfo(mode = "light") {
+ if (mode === "dark") {
+ return {
+ main: lightBlue[400],
+ light: lightBlue[300],
+ dark: lightBlue[700],
+ };
+ }
+ return {
+ main: lightBlue[700],
+ light: lightBlue[500],
+ dark: lightBlue[900],
+ };
+ }
+
+ function getDefaultSuccess(mode = "light") {
+ if (mode === "dark") {
+ return {
+ main: green[400],
+ light: green[300],
+ dark: green[700],
+ };
+ }
+ return {
+ main: green[800],
+ light: green[500],
+ dark: green[900],
+ };
+ }
+
+ function getDefaultWarning(mode = "light") {
+ if (mode === "dark") {
+ return {
+ main: orange[400],
+ light: orange[300],
+ dark: orange[700],
+ };
+ }
+ return {
+ main: "#ed6c02",
+ light: orange[500],
+ dark: orange[900],
+ };
+ }
+
+ /////////////////////
+ ///////////////////// DEEP MERGE
+ /////////////////////
+ function isPlainObject(item: unknown): item is Record<keyof any, unknown> {
+ return (
+ item !== null && typeof item === "object" && item.constructor === Object
+ );
+ }
+
+ interface DeepmergeOptions {
+ clone?: boolean;
+ }
+
+ function deepmerge<T>(
+ target: T,
+ source: unknown,
+ options: DeepmergeOptions = { clone: true },
+ ): T {
+ const output = options.clone ? { ...target } : target;
+
+ if (isPlainObject(target) && isPlainObject(source)) {
+ Object.keys(source).forEach((key) => {
+ // Avoid prototype pollution
+ if (key === "__proto__") {
+ return;
+ }
+
+ if (
+ isPlainObject(source[key]) &&
+ key in target &&
+ isPlainObject(target[key])
+ ) {
+ // Since `output` is a clone of `target` and we have narrowed `target` in this block we can cast to the same type.
+ (output as Record<keyof any, unknown>)[key] = deepmerge(
+ target[key],
+ source[key],
+ options,
+ );
+ } else {
+ (output as Record<keyof any, unknown>)[key] = source[key];
+ }
+ });
+ }
+
+ return output;
+ }
+ return {
+ typography,
+ palette,
+ shadows,
+ shape,
+ transitions,
+ breakpoints,
+ spacing,
+ pxToRem,
+ zIndex,
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/platform/api.ts b/packages/taler-wallet-webextension/src/platform/api.ts
new file mode 100644
index 000000000..3c116fab2
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/platform/api.ts
@@ -0,0 +1,337 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ CoreApiResponse,
+ TalerUri,
+ WalletNotification,
+ WalletRunConfig,
+} from "@gnu-taler/taler-util";
+import { WalletOperations } from "@gnu-taler/taler-wallet-core";
+import {
+ ExtensionOperations,
+ MessageFromExtension,
+} from "../taler-wallet-interaction-loader.js";
+import { BackgroundOperations } from "../wxApi.js";
+
+export interface Permissions {
+ /**
+ * List of named permissions.
+ */
+ permissions?: string[] | undefined;
+ /**
+ * List of origin permissions. Anything listed here must be a subset of a
+ * host that appears in the optional_permissions list in the manifest.
+ *
+ */
+ origins?: string[] | undefined;
+}
+
+/**
+ * Compatibility API that works on multiple browsers.
+ */
+export interface CrossBrowserPermissionsApi {
+ containsClipboardPermissions(): Promise<boolean>;
+ requestClipboardPermissions(): Promise<boolean>;
+ removeClipboardPermissions(): Promise<boolean>;
+}
+
+export enum ExtensionNotificationType {
+ SettingsChange = "settings-change",
+ ClearNotifications = "clear-notifications",
+}
+
+export interface SettingsChangeNotification {
+ type: ExtensionNotificationType.SettingsChange;
+
+ currentValue: Settings;
+}
+export interface ClearNotificaitonNotification {
+ type: ExtensionNotificationType.ClearNotifications;
+}
+
+export type ExtensionNotification =
+ | SettingsChangeNotification
+ | ClearNotificaitonNotification;
+
+export type MessageFromBackend =
+ | {
+ type: "wallet";
+ notification: WalletNotification;
+ }
+ | {
+ type: "web-extension";
+ notification: ExtensionNotification;
+ };
+
+export type MessageFromFrontend<
+ Op extends BackgroundOperations | WalletOperations | ExtensionOperations,
+> = Op extends BackgroundOperations
+ ? MessageFromFrontendBackground<keyof BackgroundOperations>
+ : Op extends ExtensionOperations
+ ? MessageFromExtension<keyof ExtensionOperations>
+ : Op extends WalletOperations
+ ? MessageFromFrontendWallet<keyof WalletOperations>
+ : never;
+
+export type MessageFromFrontendBackground<
+ Op extends keyof BackgroundOperations,
+> = {
+ channel: "background";
+ operation: Op;
+ payload: BackgroundOperations[Op]["request"];
+};
+
+export type MessageFromFrontendWallet<Op extends keyof WalletOperations> = {
+ channel: "wallet";
+ operation: Op;
+ payload: WalletOperations[Op]["request"];
+};
+
+export type MessageResponse = CoreApiResponse;
+
+export interface WalletWebExVersion {
+ version_name?: string | undefined;
+ version: string;
+}
+
+type F = WalletRunConfig["features"];
+type WebexWalletConfig = {
+ [P in keyof F as `wallet${Capitalize<P>}`]: F[P];
+};
+
+export interface Settings extends WebexWalletConfig {
+ injectTalerSupport: boolean;
+ autoOpen: boolean;
+ advancedMode: boolean;
+ backup: boolean;
+ langSelector: boolean;
+ showJsonOnError: boolean;
+ extendedAccountTypes: boolean;
+ showRefeshTransactions: boolean;
+ suspendIndividualTransaction: boolean;
+ showExchangeManagement: boolean;
+ selectTosFormat: boolean;
+ showWalletActivity: boolean;
+}
+
+export const defaultSettings: Settings = {
+ injectTalerSupport: true,
+ autoOpen: true,
+ advancedMode: false,
+ backup: false,
+ langSelector: false,
+ showRefeshTransactions: false,
+ suspendIndividualTransaction: false,
+ showJsonOnError: false,
+ extendedAccountTypes: false,
+ showExchangeManagement: false,
+ walletAllowHttp: false,
+ selectTosFormat: false,
+ showWalletActivity: false,
+};
+
+/**
+ * Compatibility helpers needed for browsers that don't implement
+ * WebExtension APIs consistently.
+ */
+export interface BackgroundPlatformAPI {
+ /**
+ *
+ */
+ getSettingsFromStorage(): Promise<Settings>;
+ /**
+ * Guarantee that the service workers don't die
+ */
+ keepAlive(cb: VoidFunction): void;
+ /**
+ * FIXME: should not be needed
+ *
+ * check if the platform is firefox
+ */
+ isFirefox(): boolean;
+
+ registerOnInstalled(callback: () => void): void;
+
+ /**
+ *
+ * Check if background process run as service worker. This is used from the
+ * wallet use different http api and crypto worker.
+ */
+ useServiceWorkerAsBackgroundProcess(): boolean;
+ /**
+ *
+ * Open a page into the wallet UI
+ * @param page
+ */
+ openWalletPage(page: string): void;
+ /**
+ *
+ * Register a callback to be called when the wallet is ready to start
+ * @param callback
+ */
+ notifyWhenAppIsReady(): Promise<void>;
+
+ /**
+ * Get the wallet version from manifest
+ */
+ getWalletWebExVersion(): WalletWebExVersion;
+ /**
+ * Backend API
+ */
+ registerAllIncomingConnections(): void;
+ /**
+ * Backend API
+ */
+ registerReloadOnNewVersion(): void;
+
+ /**
+ * Permission API for checking and add a listener
+ */
+ getPermissionsApi(): CrossBrowserPermissionsApi;
+ /**
+ * Used by the wallet backend to send notification about new information
+ * @param message
+ */
+ sendMessageToAllChannels(message: MessageFromBackend): void;
+
+ /**
+ * Backend API
+ *
+ * When a tab has been detected to have a Taler action the background process
+ * can use this function to redirect the tab to the wallet UI
+ *
+ * @param tabId
+ * @param page
+ */
+ redirectTabToWalletPage(tabId: number, page: string): void;
+ /**
+ * Use by the wallet backend to receive operations from frontend (popup & wallet)
+ * and send a response back.
+ *
+ * @param onNewMessage
+ */
+ listenToAllChannels(
+ notifyNewMessage: <Op extends WalletOperations | BackgroundOperations>(
+ message: MessageFromFrontend<Op> & { id: string },
+ ) => Promise<MessageResponse>,
+ ): void;
+
+ /**
+ * Change web extension Icon
+ */
+ setAlertedIcon(): void;
+ setNormalIcon(): void;
+}
+
+export interface ForegroundPlatformAPI {
+ /**
+ * Check if the extension is running under
+ * chrome incognito or firefox private mode.
+ */
+ runningOnPrivateMode(): boolean;
+ /**
+ * FIXME: should not be needed
+ *
+ * check if the platform is firefox
+ */
+ isFirefox(): boolean;
+
+ /**
+ * Permission API for checking and add a listener
+ */
+ getPermissionsApi(): CrossBrowserPermissionsApi;
+
+ /**
+ * Popup API
+ *
+ * Used when an TalerURI is found and open up from the popup UI.
+ * Closes the popup and open the URI into the wallet UI.
+ *
+ * @param talerUri
+ */
+ openWalletURIFromPopup(talerUri: TalerUri): void;
+
+ /**
+ * Popup API
+ *
+ * Open a page into the wallet UI and close the popup
+ * @param page
+ */
+ openWalletPageFromPopup(page: string): void;
+
+ /**
+ * Open a page and close the popup
+ * @param url
+ */
+ openNewURLFromPopup(url: URL): void;
+ /**
+ * Get the wallet version from manifest
+ */
+ getWalletWebExVersion(): WalletWebExVersion;
+
+ /**
+ * Popup API
+ *
+ * Read the current tab html and try to find any Taler URI or QR code present.
+ *
+ * @return Taler URI if found
+ */
+ findTalerUriInActiveTab(): Promise<string | undefined>;
+
+ /**
+ * Popup API
+ *
+ * Read the current tab html and try to find any Taler URI or QR code present.
+ *
+ * @return Taler URI if found
+ */
+ findTalerUriInClipboard(): Promise<string | undefined>;
+
+ /**
+ * Used from the frontend to send commands to the wallet
+ *
+ * @param operation
+ * @param payload
+ *
+ * @return response from the backend
+ */
+ sendMessageToBackground<Op extends WalletOperations | BackgroundOperations>(
+ message: MessageFromFrontend<Op>,
+ ): Promise<MessageResponse>;
+
+ /**
+ * Used by the wallet frontend to send notification about new information
+ * @param message
+ */
+ triggerWalletEvent(message: MessageFromBackend): void;
+
+ /**
+ * Used from the frontend to receive notifications about new information
+ * @param listener
+ * @return function to unsubscribe the listener
+ */
+ listenToWalletBackground(
+ listener: (message: MessageFromBackend) => void,
+ ): () => void;
+
+ /**
+ * Notify when platform went offline
+ */
+ listenNetworkConnectionState(
+ listener: (state: "on" | "off") => void,
+ ): () => void;
+}
diff --git a/packages/taler-wallet-webextension/src/platform/background.ts b/packages/taler-wallet-webextension/src/platform/background.ts
new file mode 100644
index 000000000..13808af2b
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/platform/background.ts
@@ -0,0 +1,23 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { BackgroundPlatformAPI } from "./api.js";
+
+// it should never be undefined :)
+export let platform: BackgroundPlatformAPI = undefined!;
+export function setupPlatform(impl: BackgroundPlatformAPI): void {
+ platform = impl;
+}
diff --git a/packages/taler-wallet-webextension/src/platform/chrome.ts b/packages/taler-wallet-webextension/src/platform/chrome.ts
new file mode 100644
index 000000000..e63040f5c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/platform/chrome.ts
@@ -0,0 +1,746 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Logger,
+ TalerError,
+ TalerErrorCode,
+ TalerUri,
+ TalerUriAction,
+ stringifyTalerUri,
+} from "@gnu-taler/taler-util";
+import { WalletOperations } from "@gnu-taler/taler-wallet-core";
+import { BackgroundOperations } from "../wxApi.js";
+import {
+ BackgroundPlatformAPI,
+ CrossBrowserPermissionsApi,
+ ExtensionNotificationType,
+ ForegroundPlatformAPI,
+ MessageFromBackend,
+ MessageFromFrontend,
+ MessageResponse,
+ Settings,
+ defaultSettings,
+} from "./api.js";
+
+const api: BackgroundPlatformAPI & ForegroundPlatformAPI = {
+ isFirefox,
+ getSettingsFromStorage,
+ findTalerUriInActiveTab,
+ findTalerUriInClipboard,
+ getPermissionsApi,
+ runningOnPrivateMode,
+ getWalletWebExVersion,
+ triggerWalletEvent,
+ listenToWalletBackground,
+ notifyWhenAppIsReady,
+ openWalletPage,
+ openWalletPageFromPopup,
+ openWalletURIFromPopup,
+ redirectTabToWalletPage,
+ registerAllIncomingConnections,
+ registerOnInstalled,
+ listenToAllChannels ,
+ registerReloadOnNewVersion,
+ sendMessageToAllChannels,
+ openNewURLFromPopup,
+ sendMessageToBackground,
+ useServiceWorkerAsBackgroundProcess,
+ keepAlive,
+ listenNetworkConnectionState,
+ setAlertedIcon,
+ setNormalIcon,
+};
+
+export default api;
+
+const logger = new Logger("chrome.ts");
+
+const WALLET_STORAGE_KEY = "wallet-settings";
+
+function jsonParseOrDefault(unparsed: string, def: unknown) {
+ if (!unparsed) return def;
+ try {
+ return JSON.parse(unparsed);
+ } catch (e) {
+ return def;
+ }
+}
+
+async function getSettingsFromStorage(): Promise<Settings> {
+ const data = await chrome.storage.local.get(WALLET_STORAGE_KEY);
+ if (!data) return defaultSettings;
+ const settings = data[WALLET_STORAGE_KEY];
+ return jsonParseOrDefault(settings, defaultSettings);
+}
+
+function keepAlive(callback: () => void): void {
+ if (extensionIsManifestV3()) {
+ chrome.alarms.create("wallet-worker", { periodInMinutes: 1 });
+
+ chrome.alarms.onAlarm.addListener((a) => {
+ logger.trace(`kee p alive alarm: ${a.name}`);
+ // callback()
+ });
+ // } else {
+ }
+ callback();
+}
+
+function isFirefox(): boolean {
+ return false;
+}
+
+export function containsClipboardPermissions(): Promise<boolean> {
+ return new Promise((res) => {
+ res(false);
+ // chrome.permissions.contains({ permissions: ["clipboardRead"] }, (resp) => {
+ // const le = chrome.runtime.lastError?.message;
+ // if (le) {
+ // rej(le);
+ // }
+ // res(resp);
+ // });
+ });
+}
+
+export async function requestClipboardPermissions(): Promise<boolean> {
+ return new Promise((res) => {
+ res(false);
+ // chrome.permissions.request({ permissions: ["clipboardRead"] }, (resp) => {
+ // const le = chrome.runtime.lastError?.message;
+ // if (le) {
+ // rej(le);
+ // }
+ // res(resp);
+ // });
+ });
+}
+
+export function removeClipboardPermissions(): Promise<boolean> {
+ return new Promise((res) => {
+ res(true);
+ // chrome.permissions.remove({ permissions: ["clipboardRead"] }, (resp) => {
+ // const le = chrome.runtime.lastError?.message;
+ // if (le) {
+ // rej(le);
+ // }
+ // res(resp);
+ // });
+ });
+}
+
+function getPermissionsApi(): CrossBrowserPermissionsApi {
+ return {
+ requestClipboardPermissions,
+ removeClipboardPermissions,
+ containsClipboardPermissions,
+ };
+}
+
+/**
+ *
+ * @param callback function to be called
+ */
+function notifyWhenAppIsReady(): Promise<void> {
+ return new Promise((resolve) => {
+ if (extensionIsManifestV3()) {
+ resolve();
+ } else {
+ window.addEventListener("load", () => {
+ resolve();
+ });
+ }
+ });
+}
+
+function openWalletURIFromPopup(uri: TalerUri): void {
+ const talerUri = stringifyTalerUri(uri);
+ //FIXME: this should redirect to just one place
+ // the target pathname should handle what happens if the endpoint is not there
+ // like "trying to open from popup but this uri is not handled"
+
+ let url: string | undefined = undefined;
+ switch (uri.type) {
+ case TalerUriAction.WithdrawExchange:
+ case TalerUriAction.Withdraw:
+ url = chrome.runtime.getURL(
+ `static/wallet.html#/cta/withdraw?talerUri=${encodeURIComponent(
+ talerUri,
+ )}`,
+ );
+ break;
+ case TalerUriAction.Restore:
+ url = chrome.runtime.getURL(
+ `static/wallet.html#/cta/recovery?talerUri=${encodeURIComponent(
+ talerUri,
+ )}`,
+ );
+ break;
+ case TalerUriAction.Pay:
+ url = chrome.runtime.getURL(
+ `static/wallet.html#/cta/pay?talerUri=${encodeURIComponent(talerUri)}`,
+ );
+ break;
+ case TalerUriAction.Refund:
+ url = chrome.runtime.getURL(
+ `static/wallet.html#/cta/refund?talerUri=${encodeURIComponent(
+ talerUri,
+ )}`,
+ );
+ break;
+ case TalerUriAction.PayPull:
+ url = chrome.runtime.getURL(
+ `static/wallet.html#/cta/invoice/pay?talerUri=${encodeURIComponent(
+ talerUri,
+ )}`,
+ );
+ break;
+ case TalerUriAction.PayPush:
+ url = chrome.runtime.getURL(
+ `static/wallet.html#/cta/transfer/pickup?talerUri=${encodeURIComponent(
+ talerUri,
+ )}`,
+ );
+ break;
+ case TalerUriAction.PayTemplate:
+ url = chrome.runtime.getURL(
+ `static/wallet.html#/cta/pay/template?talerUri=${encodeURIComponent(
+ talerUri,
+ )}`,
+ );
+ break;
+ case TalerUriAction.AddExchange:
+ url = chrome.runtime.getURL(
+ `static/wallet.html#/cta/add/exchange?talerUri=${encodeURIComponent(
+ talerUri,
+ )}`,
+ );
+ break;
+ case TalerUriAction.DevExperiment:
+ logger.warn(`taler://dev-experiment URIs are not allowed in headers`);
+ return;
+ default: {
+ const error: never = uri;
+ logger.warn(
+ `Response with HTTP 402 the Taler header "${error}", but header value is not a taler:// URI.`,
+ );
+ return;
+ }
+ }
+
+ chrome.tabs.update({ active: true, url }, () => {
+ window.close();
+ });
+}
+
+function openWalletPage(page: string): void {
+ const url = chrome.runtime.getURL(`/static/wallet.html#${page}`);
+ chrome.tabs.create({ active: true, url });
+}
+
+function openWalletPageFromPopup(page: string): void {
+ const url = chrome.runtime.getURL(`/static/wallet.html#${page}`);
+ chrome.tabs.create({ active: true, url }, () => {
+ window.close();
+ });
+}
+function openNewURLFromPopup(url: URL): void {
+ // const url = chrome.runtime.getURL(`/static/wallet.html#${page}`);
+ chrome.tabs.create({ active: true, url: url.href }, () => {
+ window.close();
+ });
+}
+
+let nextMessageIndex = 0;
+
+/**
+ * To be used by the foreground
+ * @param message
+ * @returns
+ */
+async function sendMessageToBackground<
+ Op extends WalletOperations | BackgroundOperations,
+>(message: MessageFromFrontend<Op>): Promise<MessageResponse> {
+ nextMessageIndex = (nextMessageIndex + 1) % (Number.MAX_SAFE_INTEGER - 100);
+ const messageWithId = { ...message, id: `id_${nextMessageIndex}` };
+
+ return new Promise<MessageResponse>((resolve, reject) => {
+ logger.trace("send operation to the wallet background", message);
+ let timedout = false;
+ const timerId = setTimeout(() => {
+ timedout = true;
+ reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, {
+ requestMethod: "wallet",
+ requestUrl: message.operation,
+ timeoutMs: 20 * 1000,
+ }));
+ }, 20 * 1000);
+ chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => {
+ if (timedout) {
+ return false; //already rejected
+ }
+ clearTimeout(timerId);
+ if (chrome.runtime.lastError) {
+ reject(chrome.runtime.lastError.message);
+ } else {
+ resolve(backgroundResponse);
+ }
+ // return true to keep the channel open
+ return true;
+ });
+ });
+}
+
+/**
+ * To be used by the foreground
+ */
+let notificationPort: chrome.runtime.Port | undefined;
+function listenToWalletBackground(listener: (message: MessageFromBackend) => void): () => void {
+ if (notificationPort === undefined) {
+ notificationPort = chrome.runtime.connect({ name: "notifications" });
+ }
+ notificationPort.onMessage.addListener(listener);
+ function removeListener(): void {
+ if (notificationPort !== undefined) {
+ notificationPort.onMessage.removeListener(listener);
+ }
+ }
+ return removeListener;
+}
+
+const allPorts: chrome.runtime.Port[] = [];
+
+function triggerWalletEvent(message: MessageFromBackend): void {
+ for (const notif of allPorts) {
+ // const message: MessageFromBackend = { type: msg.type };
+ try {
+ notif.postMessage(message);
+ } catch (e) {
+ logger.error("error posting a message", e);
+ }
+ }
+}
+
+function sendMessageToAllChannels(message: MessageFromBackend): void {
+ for (const notif of allPorts) {
+ // const message: MessageFromBackend = { type: msg.type };
+ try {
+ notif.postMessage(message);
+ } catch (e) {
+ logger.error("error posting a message", e);
+ }
+ }
+}
+
+function registerAllIncomingConnections(): void {
+ chrome.runtime.onConnect.addListener((port) => {
+ try {
+ allPorts.push(port);
+ port.onDisconnect.addListener((discoPort) => {
+ try {
+ const idx = allPorts.indexOf(discoPort);
+ if (idx >= 0) {
+ allPorts.splice(idx, 1);
+ }
+ } catch (e) {
+ logger.error("error trying to remove connection", e);
+ }
+ });
+ } catch (e) {
+ logger.error("error trying to save incoming connection", e);
+ }
+ });
+ chrome.storage.onChanged.addListener((event) => {
+ if (event[WALLET_STORAGE_KEY]) {
+ sendMessageToAllChannels({
+ type: "web-extension",
+ notification: {
+ type: ExtensionNotificationType.SettingsChange,
+ currentValue: jsonParseOrDefault(
+ event[WALLET_STORAGE_KEY].newValue,
+ defaultSettings,
+ ),
+ },
+ });
+ }
+ });
+}
+
+function listenToAllChannels(
+ notifyNewMessage: <Op extends WalletOperations | BackgroundOperations>(
+ message: MessageFromFrontend<Op> & { id: string },
+ ) => Promise<MessageResponse>,
+): void {
+ chrome.runtime.onMessage.addListener((message, sender, reply) => {
+ notifyNewMessage(message)
+ .then((apiResponse) => {
+ try {
+ reply(apiResponse);
+ } catch (e) {
+ logger.error(
+ "sending response to frontend failed",
+ message,
+ apiResponse,
+ e,
+ );
+ }
+ })
+ .catch((e) => {
+ logger.error("notify to background failed", e);
+ });
+
+ // keep the connection open
+ return true;
+ });
+}
+
+function registerReloadOnNewVersion(): void {
+ // Explicitly unload the extension page as soon as an update is available,
+ // so the update gets installed as soon as possible.
+ chrome.runtime.onUpdateAvailable.addListener((details) => {
+ logger.info("update available:", details);
+ chrome.runtime.reload();
+ });
+}
+
+// async function redirectCurrentTabToWalletPage(page: string): Promise<void> {
+// let queryOptions = { active: true, lastFocusedWindow: true };
+// let [tab] = await chrome.tabs.query(queryOptions);
+
+// return redirectTabToWalletPage(tab.id!, page);
+// }
+
+async function redirectTabToWalletPage(
+ tabId: number,
+ page: string,
+): Promise<void> {
+ const url = chrome.runtime.getURL(`/static/wallet.html#${page}`);
+ logger.trace("redirecting tabId: ", tabId, " to: ", url);
+ await chrome.tabs.update(tabId, { url });
+}
+
+interface WalletVersion {
+ version_name?: string | undefined;
+ version: string;
+}
+
+function getWalletWebExVersion(): WalletVersion {
+ const manifestData = chrome.runtime.getManifest();
+ return manifestData;
+}
+
+const alertIcons = {
+ "16": "/static/img/taler-alert-16.png",
+ "19": "/static/img/taler-alert-19.png",
+ "32": "/static/img/taler-alert-32.png",
+ "38": "/static/img/taler-alert-38.png",
+ "48": "/static/img/taler-alert-48.png",
+ "64": "/static/img/taler-alert-64.png",
+ "128": "/static/img/taler-alert-128.png",
+ "256": "/static/img/taler-alert-256.png",
+ "512": "/static/img/taler-alert-512.png",
+};
+const normalIcons = {
+ "16": "/static/img/taler-logo-16.png",
+ "19": "/static/img/taler-logo-19.png",
+ "32": "/static/img/taler-logo-32.png",
+ "38": "/static/img/taler-logo-38.png",
+ "48": "/static/img/taler-logo-48.png",
+ "64": "/static/img/taler-logo-64.png",
+ "128": "/static/img/taler-logo-128.png",
+ "256": "/static/img/taler-logo-256.png",
+ "512": "/static/img/taler-logo-512.png",
+};
+function setNormalIcon(): void {
+ if (extensionIsManifestV3()) {
+ chrome.action.setIcon({ path: normalIcons });
+ } else {
+ chrome.browserAction.setIcon({ path: normalIcons });
+ }
+}
+
+function setAlertedIcon(): void {
+ if (extensionIsManifestV3()) {
+ chrome.action.setIcon({ path: alertIcons });
+ } else {
+ chrome.browserAction.setIcon({ path: alertIcons });
+ }
+}
+
+interface OffscreenCanvasRenderingContext2D
+ extends CanvasState,
+ CanvasTransform,
+ CanvasCompositing,
+ CanvasImageSmoothing,
+ CanvasFillStrokeStyles,
+ CanvasShadowStyles,
+ CanvasFilters,
+ CanvasRect,
+ CanvasDrawPath,
+ CanvasUserInterface,
+ CanvasText,
+ CanvasDrawImage,
+ CanvasImageData,
+ CanvasPathDrawingStyles,
+ CanvasTextDrawingStyles,
+ CanvasPath {
+ readonly canvas: OffscreenCanvas;
+}
+declare const OffscreenCanvasRenderingContext2D: {
+ prototype: OffscreenCanvasRenderingContext2D;
+ new(): OffscreenCanvasRenderingContext2D;
+};
+
+interface OffscreenCanvas extends EventTarget {
+ width: number;
+ height: number;
+ getContext(
+ contextId: "2d",
+ contextAttributes?: CanvasRenderingContext2DSettings,
+ ): OffscreenCanvasRenderingContext2D | null;
+}
+declare const OffscreenCanvas: {
+ prototype: OffscreenCanvas;
+ new(width: number, height: number): OffscreenCanvas;
+};
+
+function createCanvas(size: number): OffscreenCanvas {
+ if (extensionIsManifestV3()) {
+ return new OffscreenCanvas(size, size);
+ } else {
+ const c = document.createElement("canvas");
+ c.height = size;
+ c.width = size;
+ return c;
+ }
+}
+
+async function createImage(size: number, file: string): Promise<ImageData> {
+ const r = await fetch(file);
+ const b = await r.blob();
+ const image = await createImageBitmap(b);
+ const canvas = createCanvas(size);
+ const canvasContext = canvas.getContext("2d")!;
+ canvasContext.clearRect(0, 0, canvas.width, canvas.height);
+ canvasContext.drawImage(image, 0, 0, canvas.width, canvas.height);
+ const imageData = canvasContext.getImageData(
+ 0,
+ 0,
+ canvas.width,
+ canvas.height,
+ );
+ return imageData;
+}
+
+async function registerIconChangeOnTalerContent(): Promise<void> {
+ const imgs = await Promise.all(
+ Object.entries(alertIcons).map(([key, value]) =>
+ createImage(parseInt(key, 10), value),
+ ),
+ );
+ const imageData = imgs.reduce(
+ (prev, cur) => ({ ...prev, [cur.width]: cur }),
+ {} as { [size: string]: ImageData },
+ );
+
+ if (chrome.declarativeContent) {
+ // using declarative content does not need host permission
+ // and is faster
+ const secureTalerUrlLookup = {
+ conditions: [
+ new chrome.declarativeContent.PageStateMatcher({
+ css: ["a[href^='taler://'"],
+ }),
+ ],
+ actions: [new chrome.declarativeContent.SetIcon({ imageData })],
+ };
+ const inSecureTalerUrlLookup = {
+ conditions: [
+ new chrome.declarativeContent.PageStateMatcher({
+ css: ["a[href^='taler+http://'"],
+ }),
+ ],
+ actions: [new chrome.declarativeContent.SetIcon({ imageData })],
+ };
+ chrome.declarativeContent.onPageChanged.removeRules(undefined, function () {
+ chrome.declarativeContent.onPageChanged.addRules([
+ secureTalerUrlLookup,
+ inSecureTalerUrlLookup,
+ ]);
+ });
+ return;
+ }
+
+ //this browser doesn't have declarativeContent
+ //we need host_permission and we will check the content for changing the icon
+ chrome.tabs.onUpdated.addListener(
+ async (tabId, info: chrome.tabs.TabChangeInfo) => {
+ if (tabId < 0) return;
+ if (info.status !== "complete") return;
+ const uri = await findTalerUriInTab(tabId);
+ if (uri) {
+ setAlertedIcon();
+ } else {
+ setNormalIcon();
+ }
+ },
+ );
+ chrome.tabs.onActivated.addListener(
+ async ({ tabId }: chrome.tabs.TabActiveInfo) => {
+ if (tabId < 0) return;
+ const uri = await findTalerUriInTab(tabId);
+ if (uri) {
+ setAlertedIcon();
+ } else {
+ setNormalIcon();
+ }
+ },
+ );
+}
+
+function registerOnInstalled(callback: () => void): void {
+ // This needs to be outside of main, as Firefox won't fire the event if
+ // the listener isn't created synchronously on loading the backend.
+ chrome.runtime.onInstalled.addListener(async (details) => {
+ logger.info(`onInstalled with reason: "${details.reason}"`);
+ if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) {
+ callback();
+ }
+ await registerIconChangeOnTalerContent();
+ });
+}
+
+function extensionIsManifestV3(): boolean {
+ return chrome.runtime.getManifest().manifest_version === 3;
+}
+
+function useServiceWorkerAsBackgroundProcess(): boolean {
+ return extensionIsManifestV3();
+}
+
+function searchForTalerLinks(): string | undefined {
+ let found;
+ found = document.querySelector("a[href^='taler://'");
+ if (found) return found.toString();
+ found = document.querySelector("a[href^='taler+http://'");
+ if (found) return found.toString();
+ return undefined;
+}
+
+async function getCurrentTab(): Promise<chrome.tabs.Tab> {
+ const queryOptions = { active: true, currentWindow: true };
+ return new Promise<chrome.tabs.Tab>((resolve, reject) => {
+ chrome.tabs.query(queryOptions, (tabs) => {
+ if (chrome.runtime.lastError) {
+ reject(chrome.runtime.lastError);
+ return;
+ }
+ resolve(tabs[0]);
+ });
+ });
+}
+
+async function findTalerUriInTab(tabId: number): Promise<string | undefined> {
+ if (extensionIsManifestV3()) {
+ // manifest v3
+ try {
+ const res = await chrome.scripting.executeScript({
+ target: { tabId, allFrames: true },
+ func: searchForTalerLinks,
+ args: [],
+ });
+ return res[0].result;
+ } catch (e) {
+ return;
+ }
+ } else {
+ return new Promise((resolve) => {
+ //manifest v2
+ chrome.tabs.executeScript(
+ tabId,
+ {
+ code: `
+ (() => {
+ let x = document.querySelector("a[href^='taler://'") || document.querySelector("a[href^='taler+http://'");
+ return x ? x.href.toString() : null;
+ })();
+ `,
+ allFrames: false,
+ },
+ (result) => {
+ if (chrome.runtime.lastError) {
+ logger.error(JSON.stringify(chrome.runtime.lastError));
+ resolve(undefined);
+ return;
+ }
+ resolve(result[0]);
+ },
+ );
+ });
+ }
+}
+
+// async function timeout(ms: number): Promise<void> {
+// return new Promise((resolve) => setTimeout(resolve, ms));
+// }
+async function findTalerUriInClipboard(): Promise<string | undefined> {
+ //FIXME: add clipboard feature
+ // try {
+ // //It looks like clipboard promise does not return, so we need a timeout
+ // const textInClipboard = await Promise.any([
+ // timeout(100),
+ // window.navigator.clipboard.readText(),
+ // ]);
+ // if (!textInClipboard) return;
+ // return textInClipboard.startsWith("taler://") ||
+ // textInClipboard.startsWith("taler+http://")
+ // ? textInClipboard
+ // : undefined;
+ // } catch (e) {
+ // logger.error("could not read clipboard", e);
+ // return undefined;
+ // }
+ return undefined;
+}
+
+async function findTalerUriInActiveTab(): Promise<string | undefined> {
+ const tab = await getCurrentTab();
+ if (!tab || tab.id === undefined) return;
+ return findTalerUriInTab(tab.id);
+}
+
+function listenNetworkConnectionState(
+ notify: (state: "on" | "off") => void,
+): () => void {
+ function notifyOffline() {
+ notify("off");
+ }
+ function notifyOnline() {
+ notify("on");
+ }
+ notify(window.navigator.onLine ? "on" : "off");
+ window.addEventListener("offline", notifyOffline);
+ window.addEventListener("online", notifyOnline);
+ return () => {
+ window.removeEventListener("offline", notifyOffline);
+ window.removeEventListener("online", notifyOnline);
+ };
+}
+
+function runningOnPrivateMode(): boolean {
+ return chrome.extension.inIncognitoContext;
+}
diff --git a/packages/taler-wallet-webextension/src/platform/dev.ts b/packages/taler-wallet-webextension/src/platform/dev.ts
new file mode 100644
index 000000000..d6e743147
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/platform/dev.ts
@@ -0,0 +1,218 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Logger, TalerUri } from "@gnu-taler/taler-util";
+import { WalletOperations } from "@gnu-taler/taler-wallet-core";
+import { BackgroundOperations } from "../wxApi.js";
+import {
+ BackgroundPlatformAPI,
+ ForegroundPlatformAPI,
+ MessageFromBackend,
+ MessageFromFrontend,
+ MessageResponse,
+ defaultSettings,
+} from "./api.js";
+
+const logger = new Logger("dev.ts");
+
+const api: BackgroundPlatformAPI & ForegroundPlatformAPI = {
+ runningOnPrivateMode: () => false,
+ isFirefox: () => false,
+ getSettingsFromStorage: () => Promise.resolve(defaultSettings),
+ keepAlive: (cb: VoidFunction) => cb(),
+ findTalerUriInActiveTab: async () => undefined,
+ findTalerUriInClipboard: async () => undefined,
+ listenNetworkConnectionState,
+ openNewURLFromPopup: () => undefined,
+ triggerWalletEvent: () => undefined,
+ setAlertedIcon: () => undefined,
+ setNormalIcon : () => undefined,
+ getPermissionsApi: () => ({
+ containsClipboardPermissions: async () => true,
+ removeClipboardPermissions: async () => false,
+ requestClipboardPermissions: async () => false,
+ }),
+
+ getWalletWebExVersion: () => ({
+ version: "none",
+ }),
+ notifyWhenAppIsReady: () => {
+ const knownFrames = ["popup", "wallet"];
+ let total = knownFrames.length;
+ return new Promise((fn) => {
+ function waitAndNotify(): void {
+ total--;
+ logger.trace(`waitAndNotify ${total}`);
+ if (total < 1) {
+ fn();
+ }
+ }
+ knownFrames.forEach((f) => {
+ const theFrame = window.frames[f as any];
+ if (theFrame.location.href === "about:blank") {
+ waitAndNotify();
+ } else {
+ theFrame.addEventListener("load", waitAndNotify);
+ }
+ });
+ });
+ },
+
+ openWalletPage: (page: string) => {
+ // @ts-ignore
+ window.parent.redirectWallet(`wallet.html#${page}`);
+ },
+ openWalletPageFromPopup: (page: string) => {
+ // @ts-ignore
+ window.parent.redirectWallet(`wallet.html#${page}`);
+ // close the popup
+ // @ts-ignore
+ window.parent.closePopup();
+ },
+ openWalletURIFromPopup: (page: TalerUri) => {
+ alert("openWalletURIFromPopup not implemented yet");
+ },
+ redirectTabToWalletPage: (tabId: number, page: string) => {
+ alert("redirectTabToWalletPage not implemented yet");
+ },
+ registerAllIncomingConnections: () => undefined,
+ registerOnInstalled: () => Promise.resolve(),
+ registerReloadOnNewVersion: () => undefined,
+
+ useServiceWorkerAsBackgroundProcess: () => false,
+
+ listenToAllChannels: (
+ notifyNewMessage: (message: any) => Promise<MessageResponse>,
+ ) => {
+ window.addEventListener(
+ "message",
+ (event: MessageEvent<IframeMessageType>) => {
+ if (event.data.type !== "command") return;
+ const sender = event.data.header.replyMe;
+
+ notifyNewMessage(event.data.body as any).then((resp) => {
+ logger.trace(`listenToAllChannels: from ${sender}`, event);
+ if (event.source) {
+ const msg: IframeMessageResponse = {
+ type: "response",
+ header: { responseId: sender },
+ body: resp,
+ };
+ window.parent.postMessage(msg);
+ }
+ });
+ },
+ );
+ },
+ sendMessageToAllChannels: (message: MessageFromBackend) => {
+ Array.from(window.frames).forEach((w) => {
+ try {
+ w.postMessage({
+ header: {},
+ body: message,
+ });
+ } catch (e) {
+ console.error(e);
+ }
+ });
+ },
+ listenToWalletBackground: (onNewMessage: (m: MessageFromBackend) => void) => {
+ function listener(event: MessageEvent<IframeMessageType>): void {
+ logger.trace(`listenToWalletBackground: `, event);
+ if (event.data.type !== "notification") return;
+ onNewMessage(event.data.body);
+ }
+ window.parent.addEventListener("message", listener);
+ return () => {
+ window.parent.removeEventListener("message", listener);
+ };
+ },
+
+ sendMessageToBackground: async <
+ Op extends WalletOperations | BackgroundOperations,
+ >(
+ payload: MessageFromFrontend<Op>,
+ ): Promise<MessageResponse> => {
+ const replyMe = `reply-${Math.floor(Math.random() * 100000)}`;
+ const message: IframeMessageCommand = {
+ type: "command",
+ header: { replyMe },
+ body: payload,
+ };
+
+ logger.trace(`sendMessageToBackground: `, message);
+
+ return new Promise((res, rej) => {
+ function listener(event: MessageEvent<IframeMessageType>): void {
+ if (
+ event.data.type !== "response" ||
+ event.data.header.responseId !== replyMe
+ ) {
+ return;
+ }
+ res(event.data.body);
+ window.parent.removeEventListener("message", listener);
+ }
+ window.parent.addEventListener("message", listener, {});
+ window.parent.postMessage(message);
+ });
+ },
+};
+
+type IframeMessageType =
+ | IframeMessageNotification
+ | IframeMessageResponse
+ | IframeMessageCommand;
+
+interface IframeMessageNotification {
+ type: "notification";
+ header: Record<string, never>;
+ body: MessageFromBackend;
+}
+interface IframeMessageResponse {
+ type: "response";
+ header: {
+ responseId: string;
+ };
+ body: MessageResponse;
+}
+
+interface IframeMessageCommand {
+ type: "command";
+ header: {
+ replyMe: string;
+ };
+ body: MessageFromFrontend<any>;
+}
+
+export default api;
+
+function listenNetworkConnectionState(
+ notify: (state: "on" | "off") => void,
+): () => void {
+ function notifyOffline() {
+ notify("off");
+ }
+ function notifyOnline() {
+ notify("on");
+ }
+ window.addEventListener("offline", notifyOffline);
+ window.addEventListener("online", notifyOnline);
+ return () => {
+ window.removeEventListener("offline", notifyOffline);
+ window.removeEventListener("online", notifyOnline);
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/platform/firefox.ts b/packages/taler-wallet-webextension/src/platform/firefox.ts
new file mode 100644
index 000000000..3d67423fd
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/platform/firefox.ts
@@ -0,0 +1,92 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ BackgroundPlatformAPI,
+ CrossBrowserPermissionsApi,
+ ForegroundPlatformAPI,
+ Permissions,
+ Settings,
+ defaultSettings,
+} from "./api.js";
+import chromePlatform, {
+ containsClipboardPermissions as chromeClipContains,
+ removeClipboardPermissions as chromeClipRemove,
+ requestClipboardPermissions as chromeClipRequest,
+} from "./chrome.js";
+
+const api: BackgroundPlatformAPI & ForegroundPlatformAPI = {
+ ...chromePlatform,
+ isFirefox,
+ getSettingsFromStorage,
+ getPermissionsApi,
+ notifyWhenAppIsReady,
+ redirectTabToWalletPage,
+ useServiceWorkerAsBackgroundProcess,
+};
+
+export default api;
+
+function isFirefox(): boolean {
+ return true;
+}
+
+function getPermissionsApi(): CrossBrowserPermissionsApi {
+ return {
+ containsClipboardPermissions: chromeClipContains,
+ removeClipboardPermissions: chromeClipRemove,
+ requestClipboardPermissions: chromeClipRequest,
+ };
+}
+
+async function getSettingsFromStorage(): Promise<Settings> {
+ //@ts-ignore
+ const data = await browser.storage.local.get("wallet-settings");
+ if (!data) return defaultSettings;
+ const settings = data["wallet-settings"];
+ if (!settings) return defaultSettings;
+ try {
+ const parsed = JSON.parse(settings);
+ return parsed;
+ } catch (e) {
+ return defaultSettings;
+ }
+}
+
+/**
+ *
+ * @param callback function to be called
+ */
+function notifyWhenAppIsReady(): Promise<void> {
+ return new Promise((resolve) => {
+ if (chrome.runtime && chrome.runtime.getManifest().manifest_version === 3) {
+ resolve();
+ } else {
+ window.addEventListener("load", () => {
+ resolve();
+ });
+ }
+ });
+}
+
+function redirectTabToWalletPage(tabId: number, page: string): void {
+ const url = chrome.runtime.getURL(`/static/wallet.html#${page}`);
+ chrome.tabs.update(tabId, { url, loadReplace: true } as any);
+}
+
+function useServiceWorkerAsBackgroundProcess(): false {
+ return false;
+}
diff --git a/packages/taler-wallet-webextension/src/platform/foreground.ts b/packages/taler-wallet-webextension/src/platform/foreground.ts
new file mode 100644
index 000000000..ae8dc8a95
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/platform/foreground.ts
@@ -0,0 +1,22 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ForegroundPlatformAPI } from "./api.js";
+
+export let platform: ForegroundPlatformAPI = undefined as any;
+export function setupPlatform(impl: ForegroundPlatformAPI): void {
+ platform = impl;
+}
diff --git a/packages/taler-wallet-webextension/src/popup/Application.tsx b/packages/taler-wallet-webextension/src/popup/Application.tsx
new file mode 100644
index 000000000..cbb9b50b2
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/popup/Application.tsx
@@ -0,0 +1,229 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Main entry point for extension pages.
+ *
+ * @author sebasjm
+ */
+
+import {
+ TranslationProvider,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { createHashHistory } from "history";
+import { ComponentChildren, Fragment, h, VNode } from "preact";
+import { route, Route, Router } from "preact-router";
+import { useEffect, useState } from "preact/hooks";
+import PendingTransactions from "../components/PendingTransactions.js";
+import { PopupBox } from "../components/styled/index.js";
+import { AlertProvider } from "../context/alert.js";
+import { IoCProviderForRuntime } from "../context/iocContext.js";
+import { useTalerActionURL } from "../hooks/useTalerActionURL.js";
+import { strings } from "../i18n/strings.js";
+import { Pages, PopupNavBar, PopupNavBarOptions } from "../NavigationBar.js";
+import { platform } from "../platform/foreground.js";
+import { BackupPage } from "../wallet/BackupPage.js";
+import { ProviderDetailPage } from "../wallet/ProviderDetailPage.js";
+import { BalancePage } from "./BalancePage.js";
+import { TalerActionFound } from "./TalerActionFound.js";
+
+export function Application(): VNode {
+ return (
+ <TranslationProvider source={strings}>
+ <IoCProviderForRuntime>
+ <ApplicationView />
+ </IoCProviderForRuntime>
+ </TranslationProvider>
+ );
+}
+function ApplicationView(): VNode {
+ const hash_history = createHashHistory();
+
+ const [action, setDismissed] = useTalerActionURL();
+
+ const actionUri = action?.uri;
+
+ useEffect(() => {
+ if (actionUri) {
+ route(Pages.cta({ action: encodeURIComponent(actionUri) }));
+ }
+ }, [actionUri]);
+
+ async function redirectToTxInfo(tid: string): Promise<void> {
+ redirectTo(Pages.balanceTransaction({ tid }));
+ }
+
+ function redirectToURL(str: string): void {
+ platform.openNewURLFromPopup(new URL(str))
+ }
+
+ return (
+ <Router history={hash_history}>
+ <Route
+ path={Pages.balance}
+ component={() => (
+ <PopupTemplate path="balance" goToTransaction={redirectToTxInfo} goToURL={redirectToURL}>
+ <BalancePage
+ goToWalletManualWithdraw={() => redirectTo(Pages.receiveCash({}))}
+ goToWalletDeposit={(currency: string) =>
+ redirectTo(Pages.sendCash({ amount: `${currency}:0` }))
+ }
+ goToWalletHistory={(currency: string) =>
+ redirectTo(Pages.balanceHistory({ currency }))
+ }
+ />
+ </PopupTemplate>
+ )}
+ />
+
+ <Route
+ path={Pages.cta.pattern}
+ component={function Action({ action }: { action: string }) {
+ // const [, setDismissed] = useTalerActionURL();
+
+ return (
+ <PopupTemplate goToURL={redirectToURL}>
+ <TalerActionFound
+ url={decodeURIComponent(action)}
+ onDismiss={() => {
+ setDismissed(true);
+ return redirectTo(Pages.balance);
+ }}
+ />
+ </PopupTemplate>
+ );
+ }}
+ />
+
+ <Route
+ path={Pages.backup}
+ component={() => (
+ <PopupTemplate path="backup" goToTransaction={redirectToTxInfo} goToURL={redirectToURL}>
+ <BackupPage
+ onAddProvider={() => redirectTo(Pages.backupProviderAdd)}
+ />
+ </PopupTemplate>
+ )}
+ />
+ <Route
+ path={Pages.backupProviderDetail.pattern}
+ component={({ pid }: { pid: string }) => (
+ <PopupTemplate path="backup" goToURL={redirectToURL}>
+ <ProviderDetailPage
+ onPayProvider={(uri: string) =>
+ redirectTo(`${Pages.ctaPay}?talerPayUri=${uri}`)
+ }
+ onWithdraw={(amount: string) =>
+ redirectTo(Pages.receiveCash({ amount }))
+ }
+ pid={pid}
+ onBack={() => redirectTo(Pages.backup)}
+ />
+ </PopupTemplate>
+ )}
+ />
+
+ <Route
+ path={Pages.balanceTransaction.pattern}
+ component={RedirectToWalletPage}
+ />
+ <Route
+ path={Pages.ctaWithdrawManual.pattern}
+ component={RedirectToWalletPage}
+ />
+ <Route
+ path={Pages.balanceDeposit.pattern}
+ component={RedirectToWalletPage}
+ />
+ <Route
+ path={Pages.balanceHistory.pattern}
+ component={RedirectToWalletPage}
+ />
+ <Route path={Pages.backupProviderAdd} component={RedirectToWalletPage} />
+ <Route
+ path={Pages.receiveCash.pattern}
+ component={RedirectToWalletPage}
+ />
+ <Route path={Pages.sendCash.pattern} component={RedirectToWalletPage} />
+ <Route path={Pages.ctaPayTemplate} component={RedirectToWalletPage} />
+ <Route path={Pages.ctaPay} component={RedirectToWalletPage} />
+ <Route path={Pages.qr} component={RedirectToWalletPage} />
+ <Route path={Pages.settings} component={RedirectToWalletPage} />
+ <Route
+ path={Pages.settingsExchangeAdd.pattern}
+ component={RedirectToWalletPage}
+ />
+ <Route path={Pages.dev} component={RedirectToWalletPage} />
+ <Route path={Pages.notifications} component={RedirectToWalletPage} />
+
+ <Route default component={Redirect} to={Pages.balance} />
+ </Router>
+ );
+}
+
+function RedirectToWalletPage(): VNode {
+ const page = (document.location.hash || "#/").replace("#", "");
+ const [showText, setShowText] = useState(false);
+ useEffect(() => {
+ platform.openWalletPageFromPopup(page);
+ setTimeout(() => {
+ setShowText(true);
+ }, 250);
+ });
+ const { i18n } = useTranslationContext();
+ if (!showText) return <Fragment />;
+ return (
+ <span>
+ <i18n.Translate>
+ this popup is being closed and you are being redirected to {page}
+ </i18n.Translate>
+ </span>
+ );
+}
+
+async function redirectTo(location: string): Promise<void> {
+ route(location);
+}
+
+function Redirect({ to }: { to: string }): null {
+ useEffect(() => {
+ route(to, true);
+ });
+ return null;
+}
+
+function PopupTemplate({
+ path,
+ children,
+ goToTransaction,
+ goToURL,
+}: {
+ path?: PopupNavBarOptions;
+ children: ComponentChildren;
+ goToTransaction?: (id: string) => Promise<void>;
+ goToURL: (s: string) => void;
+}): VNode {
+ return (
+ <Fragment>
+ <PendingTransactions goToTransaction={goToTransaction} goToURL={goToURL} />
+ <PopupNavBar path={path} />
+ <PopupBox>
+ <AlertProvider>{children}</AlertProvider>
+ </PopupBox>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx b/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx
deleted file mode 100644
index d256f6d98..000000000
--- a/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ProviderPaymentType } from '@gnu-taler/taler-wallet-core';
-import { addDays } from 'date-fns';
-import { BackupView as TestedComponent } from './BackupPage';
-import { createExample } from '../test-utils';
-
-export default {
- title: 'popup/backup/list',
- component: TestedComponent,
- argTypes: {
- onRetry: { action: 'onRetry' },
- onDelete: { action: 'onDelete' },
- onBack: { action: 'onBack' },
- }
-};
-
-
-export const LotOfProviders = createExample(TestedComponent, {
- providers: [{
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
- },
- "terms": {
- "annualFee": "ARS:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": addDays(new Date(), 13).getTime()
- }
- },
- "terms": {
- "annualFee": "ARS:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.Pending,
- },
- "terms": {
- "annualFee": "KUDOS:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.InsufficientBalance,
- },
- "terms": {
- "annualFee": "KUDOS:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.TermsChanged,
- newTerms: {
- annualFee: 'USD:2',
- storageLimitInMegabytes: 8,
- supportedProtocolVersion: '2',
- },
- oldTerms: {
- annualFee: 'USD:1',
- storageLimitInMegabytes: 16,
- supportedProtocolVersion: '1',
-
- },
- paidUntil: {
- t_ms: 'never'
- }
- },
- "terms": {
- "annualFee": "KUDOS:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.Unpaid,
- },
- "terms": {
- "annualFee": "KUDOS:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.Unpaid,
- },
- "terms": {
- "annualFee": "KUDOS:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }]
-});
-
-
-export const OneProvider = createExample(TestedComponent, {
- providers: [{
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
- },
- "terms": {
- "annualFee": "ARS:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }]
-});
-
-
-export const Empty = createExample(TestedComponent, {
- providers: []
-});
-
diff --git a/packages/taler-wallet-webextension/src/popup/BackupPage.tsx b/packages/taler-wallet-webextension/src/popup/BackupPage.tsx
deleted file mode 100644
index dcc5e5313..000000000
--- a/packages/taler-wallet-webextension/src/popup/BackupPage.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
-
-
-import { i18n, Timestamp } from "@gnu-taler/taler-util";
-import { ProviderInfo, ProviderPaymentStatus } from "@gnu-taler/taler-wallet-core";
-import { differenceInMonths, formatDuration, intervalToDuration } from "date-fns";
-import { Fragment, JSX, VNode, h } from "preact";
-import {
- BoldLight, ButtonPrimary, ButtonSuccess, Centered,
- CenteredText, CenteredBoldText, PopupBox, RowBorderGray,
- SmallText, SmallLightText
-} from "../components/styled";
-import { useBackupStatus } from "../hooks/useBackupStatus";
-import { Pages } from "../NavigationBar";
-
-interface Props {
- onAddProvider: () => void;
-}
-
-export function BackupPage({ onAddProvider }: Props): VNode {
- const status = useBackupStatus()
- if (!status) {
- return <div>Loading...</div>
- }
- return <BackupView providers={status.providers} onAddProvider={onAddProvider} onSyncAll={status.sync} />;
-}
-
-export interface ViewProps {
- providers: ProviderInfo[],
- onAddProvider: () => void;
- onSyncAll: () => Promise<void>;
-}
-
-export function BackupView({ providers, onAddProvider, onSyncAll }: ViewProps): VNode {
- return (
- <PopupBox>
- <section>
- {providers.map((provider) => <BackupLayout
- status={provider.paymentStatus}
- timestamp={provider.lastSuccessfulBackupTimestamp}
- id={provider.syncProviderBaseUrl}
- active={provider.active}
- title={provider.name}
- />
- )}
- {!providers.length && <Centered style={{marginTop: 100}}>
- <BoldLight>No backup providers configured</BoldLight>
- <ButtonSuccess onClick={onAddProvider}><i18n.Translate>Add provider</i18n.Translate></ButtonSuccess>
- </Centered>}
- </section>
- {!!providers.length && <footer>
- <div />
- <div>
- <ButtonPrimary onClick={onSyncAll}>{
- providers.length > 1 ?
- <i18n.Translate>Sync all backups</i18n.Translate> :
- <i18n.Translate>Sync now</i18n.Translate>
- }</ButtonPrimary>
- <ButtonSuccess onClick={onAddProvider}>Add provider</ButtonSuccess>
- </div>
- </footer>}
- </PopupBox>
- )
-}
-
-interface TransactionLayoutProps {
- status: ProviderPaymentStatus;
- timestamp?: Timestamp;
- title: string;
- id: string;
- active: boolean;
-}
-
-function BackupLayout(props: TransactionLayoutProps): JSX.Element {
- const date = !props.timestamp ? undefined : new Date(props.timestamp.t_ms);
- const dateStr = date?.toLocaleString([], {
- dateStyle: "medium",
- timeStyle: "short",
- } as any);
-
-
- return (
- <RowBorderGray>
- <div style={{ color: !props.active ? "grey" : undefined }}>
- <a href={Pages.provider_detail.replace(':pid', encodeURIComponent(props.id))}><span>{props.title}</span></a>
-
- {dateStr && <SmallText style={{marginTop: 5}}>Last synced: {dateStr}</SmallText>}
- {!dateStr && <SmallLightText style={{marginTop: 5}}>Not synced</SmallLightText>}
- </div>
- <div>
- {props.status?.type === 'paid' ?
- <ExpirationText until={props.status.paidUntil} /> :
- <div>{props.status.type}</div>
- }
- </div>
- </RowBorderGray>
- );
-}
-
-function ExpirationText({ until }: { until: Timestamp }) {
- return <Fragment>
- <CenteredText> Expires in </CenteredText>
- <CenteredBoldText {...({ color: colorByTimeToExpire(until) })}> {daysUntil(until)} </CenteredBoldText>
- </Fragment>
-}
-
-function colorByTimeToExpire(d: Timestamp) {
- if (d.t_ms === 'never') return 'rgb(28, 184, 65)'
- const months = differenceInMonths(d.t_ms, new Date())
- return months > 1 ? 'rgb(28, 184, 65)' : 'rgb(223, 117, 20)';
-}
-
-function daysUntil(d: Timestamp) {
- if (d.t_ms === 'never') return undefined
- const duration = intervalToDuration({
- start: d.t_ms,
- end: new Date(),
- })
- const str = formatDuration(duration, {
- delimiter: ', ',
- format: [
- duration?.years ? 'years' : (
- duration?.months ? 'months' : (
- duration?.days ? 'days' : (
- duration.hours ? 'hours' : 'minutes'
- )
- )
- )
- ]
- })
- return `${str}`
-} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx b/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx
index 382f9b549..626ad4977 100644
--- a/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx
+++ b/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,205 +15,229 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { createExample, NullLink } from '../test-utils';
-import { BalanceView as TestedComponent } from './BalancePage';
+import { AmountString, ScopeType } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { BalanceView as TestedComponent } from "./BalancePage.js";
export default {
- title: 'popup/balance',
- component: TestedComponent,
- argTypes: {
- }
+ title: "balance",
};
-
-export const NotYetLoaded = createExample(TestedComponent, {
+export const EmptyBalance = tests.createExample(TestedComponent, {
+ balances: [],
+ goToWalletManualWithdraw: {},
});
-export const GotError = createExample(TestedComponent, {
- balance: {
- hasError: true,
- message: 'Network error'
- },
- Linker: NullLink,
-});
-
-export const EmptyBalance = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: []
+export const SomeCoins = tests.createExample(TestedComponent, {
+ balances: [
+ {
+ flags: [],
+ available: "USD:10.5" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
},
- },
- Linker: NullLink,
+ ],
+ addAction: {},
+ goToWalletManualWithdraw: {},
});
-export const SomeCoins = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: [{
- available: 'USD:10.5',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- }]
+export const SomeCoinsInTreeCurrencies = tests.createExample(TestedComponent, {
+ balances: [
+ {
+ flags: [],
+ available: "EUR:1" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
},
- },
- Linker: NullLink,
-});
-
-export const SomeCoinsAndIncomingMoney = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: [{
- available: 'USD:2.23',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:5.11',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- }]
+ {
+ flags: [],
+ available: "TESTKUDOS:2000" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
},
- },
- Linker: NullLink,
-});
-
-export const SomeCoinsAndOutgoingMoney = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: [{
- available: 'USD:2.23',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:5.11',
- requiresUserInput: false
- }]
+ {
+ flags: [],
+ available: "JPY:4" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "EUR:15" as AmountString,
+ pendingOutgoing: "EUR:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
},
- },
- Linker: NullLink,
+ ],
+ goToWalletManualWithdraw: {},
+ addAction: {},
});
-export const SomeCoinsAndMovingMoney = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: [{
- available: 'USD:2.23',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:2',
- pendingOutgoing: 'USD:5.11',
- requiresUserInput: false
- }]
+export const NoCoinsInTreeCurrencies = tests.createExample(TestedComponent, {
+ balances: [
+ {
+ flags: [],
+ available: "EUR:3" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
},
- },
- Linker: NullLink,
-});
-
-export const SomeCoinsInTwoCurrencies = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: [{
- available: 'USD:2',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:5.1',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- },{
- available: 'EUR:4',
- hasPendingTransactions: false,
- pendingIncoming: 'EUR:0',
- pendingOutgoing: 'EUR:3.01',
- requiresUserInput: false
- }]
+ {
+ flags: [],
+ available: "USD:2" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
},
- },
- Linker: NullLink,
-});
-
-export const SomeCoinsInTreeCurrencies = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: [{
- available: 'USD:1',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- },{
- available: 'COL:2000',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- },{
- available: 'EUR:4',
- hasPendingTransactions: false,
- pendingIncoming: 'EUR:15',
- pendingOutgoing: 'EUR:0',
- requiresUserInput: false
- }]
+ {
+ flags: [],
+ available: "ARS:1" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "EUR:15" as AmountString,
+ pendingOutgoing: "EUR:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
},
- },
- Linker: NullLink,
+ ],
+ goToWalletManualWithdraw: {},
+ addAction: {},
});
-
-export const SomeCoinsInFiveCurrencies = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: [{
- available: 'USD:13451',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- },{
- available: 'EUR:202.02',
- hasPendingTransactions: false,
- pendingIncoming: 'EUR:0',
- pendingOutgoing: 'EUR:0',
- requiresUserInput: false
- },{
- available: 'ARS:30',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- },{
- available: 'JPY:51223233',
- hasPendingTransactions: false,
- pendingIncoming: 'EUR:0',
- pendingOutgoing: 'EUR:0',
- requiresUserInput: false
- },{
- available: 'JPY:51223233',
- hasPendingTransactions: false,
- pendingIncoming: 'EUR:0',
- pendingOutgoing: 'EUR:0',
- requiresUserInput: false
- },{
- available: 'DEMOKUDOS:6',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- },{
- available: 'TESTKUDOS:6',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:5',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- }]
+export const SomeCoinsInFiveCurrencies = tests.createExample(TestedComponent, {
+ balances: [
+ {
+ flags: [],
+ available: "USD:0" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ {
+ flags: [],
+ available: "ARS:13451" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ {
+ flags: [],
+ available: "EUR:202.02" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "EUR:0" as AmountString,
+ pendingOutgoing: "EUR:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ {
+ flags: [],
+ available: "JPY:0" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "EUR:0" as AmountString,
+ pendingOutgoing: "EUR:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ {
+ flags: [],
+ available: "JPY:51223233" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "EUR:0" as AmountString,
+ pendingOutgoing: "EUR:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ {
+ flags: [],
+ available: "DEMOKUDOS:6" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ {
+ flags: [],
+ available: "TESTKUDOS:6" as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: "USD:5" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "TESTKUDOS",
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
},
- },
- Linker: NullLink,
+ ],
+ goToWalletManualWithdraw: {},
+ addAction: {},
});
diff --git a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx
index 8e5c5c42e..93770312e 100644
--- a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx
+++ b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx
@@ -1,155 +1,195 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
- amountFractionalBase, Amounts,
- Balance, BalancesResponse,
- i18n
+ Amounts,
+ NotificationType,
+ WalletBalance,
} from "@gnu-taler/taler-util";
-import { JSX, h, Fragment } from "preact";
-import { ErrorMessage } from "../components/ErrorMessage";
-import { PopupBox, Centered, ButtonPrimary, ErrorBox, Middle } from "../components/styled/index";
-import { BalancesHook, useBalances } from "../hooks/useBalances";
-import { PageLink, renderAmount } from "../renderHtml";
-
-
-export function BalancePage({ goToWalletManualWithdraw }: { goToWalletManualWithdraw: () => void }) {
- const balance = useBalances()
- return <BalanceView balance={balance} Linker={PageLink} goToWalletManualWithdraw={goToWalletManualWithdraw} />
-}
-export interface BalanceViewProps {
- balance: BalancesHook;
- Linker: typeof PageLink;
- goToWalletManualWithdraw: () => void;
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { BalanceTable } from "../components/BalanceTable.js";
+import { ErrorAlertView } from "../components/CurrentAlerts.js";
+import { Loading } from "../components/Loading.js";
+import { MultiActionButton } from "../components/MultiActionButton.js";
+import {
+ ErrorAlert,
+ alertFromError,
+ useAlertContext,
+} from "../context/alert.js";
+import { useBackendContext } from "../context/backend.js";
+import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { Button } from "../mui/Button.js";
+import { ButtonHandler } from "../mui/handlers.js";
+import { StateViewMap, compose } from "../utils/index.js";
+import { AddNewActionView } from "../wallet/AddNewActionView.js";
+import { NoBalanceHelp } from "./NoBalanceHelp.js";
+
+export interface Props {
+ goToWalletDeposit: (currency: string) => Promise<void>;
+ goToWalletHistory: (currency: string) => Promise<void>;
+ goToWalletManualWithdraw: () => Promise<void>;
}
-function formatPending(entry: Balance): JSX.Element {
- let incoming: JSX.Element | undefined;
- let payment: JSX.Element | undefined;
-
- const available = Amounts.parseOrThrow(entry.available);
- const pendingIncoming = Amounts.parseOrThrow(entry.pendingIncoming);
- const pendingOutgoing = Amounts.parseOrThrow(entry.pendingOutgoing);
-
- if (!Amounts.isZero(pendingIncoming)) {
- incoming = (
- <span><i18n.Translate>
- <span style={{ color: "darkgreen" }} title="incoming amount">
- {"+"}
- {renderAmount(entry.pendingIncoming)}
- </span>{" "}
- </i18n.Translate></span>
- );
+export type State = State.Loading | State.Error | State.Action | State.Balances;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
}
- if (!Amounts.isZero(pendingOutgoing)) {
- payment = (
- <span><i18n.Translate>
- <span style={{ color: "darkred" }} title="outgoing amount">
- {"-"}
- {renderAmount(entry.pendingOutgoing)}
- </span>{" "}
- </i18n.Translate></span>
- );
+
+ export interface Error {
+ status: "error";
+ error: ErrorAlert;
}
- const l = [incoming, payment].filter((x) => x !== undefined);
- if (l.length === 0) {
- return <span />;
+ export interface Action {
+ status: "action";
+ error: undefined;
+ cancel: ButtonHandler;
}
- if (l.length === 1) {
- return <span>{l}</span>;
+ export interface Balances {
+ status: "balance";
+ error: undefined;
+ balances: WalletBalance[];
+ addAction: ButtonHandler;
+ goToWalletDeposit: (currency: string) => Promise<void>;
+ goToWalletHistory: (currency: string) => Promise<void>;
+ goToWalletManualWithdraw: ButtonHandler;
}
- return (
- <span>
- {l[0]}, {l[1]}
- </span>
+}
+
+function useComponentState({
+ goToWalletDeposit,
+ goToWalletHistory,
+ goToWalletManualWithdraw,
+}: Props): State {
+ const api = useBackendContext();
+ const { i18n } = useTranslationContext();
+ const { pushAlertOnError } = useAlertContext();
+ const [addingAction, setAddingAction] = useState(false);
+ const state = useAsyncAsHook(() =>
+ api.wallet.call(WalletApiOperation.GetBalances, {}),
);
+
+ useEffect(() =>
+ api.listener.onUpdateNotification(
+ [NotificationType.TransactionStateTransition],
+ state?.retry,
+ ),
+ );
+
+ if (!state) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (state.hasError) {
+ return {
+ status: "error",
+ error: alertFromError( i18n,
+ i18n.str`Could not load the balance`, state),
+ };
+ }
+ if (addingAction) {
+ return {
+ status: "action",
+ error: undefined,
+ cancel: {
+ onClick: pushAlertOnError(async () => setAddingAction(false)),
+ },
+ };
+ }
+ return {
+ status: "balance",
+ error: undefined,
+ balances: state.response.balances,
+ addAction: {
+ onClick: pushAlertOnError(async () => setAddingAction(true)),
+ },
+ goToWalletManualWithdraw: {
+ onClick: pushAlertOnError(goToWalletManualWithdraw),
+ },
+ goToWalletDeposit,
+ goToWalletHistory,
+ };
}
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ action: ActionView,
+ balance: BalanceView,
+};
+
+export const BalancePage = compose(
+ "BalancePage",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
+
+function ActionView({ cancel }: State.Action): VNode {
+ return <AddNewActionView onCancel={cancel.onClick!} />;
+}
-export function BalanceView({ balance, Linker, goToWalletManualWithdraw }: BalanceViewProps) {
-
- function Content() {
- if (!balance) {
- return <span />
- }
-
- if (balance.hasError) {
- return (<section>
- <ErrorBox>{balance.message}</ErrorBox>
- <p>
- Click <Linker pageName="welcome">here</Linker> for help and
- diagnostics.
- </p>
- </section>)
- }
- if (balance.response.balances.length === 0) {
- return (<section data-expanded>
- <Middle>
- <p><i18n.Translate>
- You have no balance to show. Need some{" "}
- <Linker pageName="/welcome">help</Linker> getting started?
- </i18n.Translate></p>
- </Middle>
- </section>)
- }
- return <section data-expanded data-centered>
- <table style={{width:'100%'}}>{balance.response.balances.map((entry) => {
- const av = Amounts.parseOrThrow(entry.available);
- // Create our number formatter.
- let formatter;
- try {
- formatter = new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: av.currency,
- currencyDisplay: 'symbol'
- // These options are needed to round to whole numbers if that's what you want.
- //minimumFractionDigits: 0, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1)
- //maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
- });
- } catch {
- formatter = new Intl.NumberFormat('en-US', {
- // style: 'currency',
- // currency: av.currency,
- // These options are needed to round to whole numbers if that's what you want.
- //minimumFractionDigits: 0, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1)
- //maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
- });
- }
-
- const v = formatter.format(av.value + av.fraction / amountFractionalBase);
- const fontSize = v.length < 8 ? '3em' : (v.length < 13 ? '2em' : '1em')
- return (<tr>
- <td style={{ height: 50, fontSize, width: '60%', textAlign: 'right', padding: 0 }}>{v}</td>
- <td style={{ maxWidth: '2em', overflowX: 'hidden' }}>{av.currency}</td>
- <td style={{ fontSize: 'small', color: 'gray' }}>{formatPending(entry)}</td>
- </tr>
- );
- })}</table>
- </section>
+export function BalanceView(state: State.Balances): VNode {
+ const { i18n } = useTranslationContext();
+ const currencyWithNonZeroAmount = state.balances
+ .filter((b) => !Amounts.isZero(b.available))
+ .map((b) => {
+ b.flags
+ return b.available.split(":")[0]
+ });
+
+ if (state.balances.length === 0) {
+ return (
+ <NoBalanceHelp
+ goToWalletManualWithdraw={state.goToWalletManualWithdraw}
+ />
+ );
}
- return <PopupBox>
- {/* <section> */}
- <Content />
- {/* </section> */}
- <footer>
- <div />
- <ButtonPrimary onClick={goToWalletManualWithdraw}>Withdraw</ButtonPrimary>
- </footer>
- </PopupBox>
+ return (
+ <Fragment>
+ <section>
+ <BalanceTable
+ balances={state.balances}
+ goToWalletHistory={state.goToWalletHistory}
+ />
+ </section>
+ <footer style={{ justifyContent: "space-between" }}>
+ <Button
+ variant="contained"
+ onClick={state.goToWalletManualWithdraw.onClick}
+ >
+ <i18n.Translate>Add</i18n.Translate>
+ </Button>
+ {currencyWithNonZeroAmount.length > 0 && (
+ <MultiActionButton
+ label={(s) => i18n.str`Send ${s}`}
+ actions={currencyWithNonZeroAmount}
+ onClick={(c) => state.goToWalletDeposit(c)}
+ />
+ )}
+ </footer>
+ </Fragment>
+ );
}
diff --git a/packages/taler-wallet-webextension/src/popup/Debug.tsx b/packages/taler-wallet-webextension/src/popup/Debug.tsx
deleted file mode 100644
index ccc747466..000000000
--- a/packages/taler-wallet-webextension/src/popup/Debug.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { JSX, h } from "preact";
-import { Diagnostics } from "../components/Diagnostics";
-import { useDiagnostics } from "../hooks/useDiagnostics.js";
-import * as wxApi from "../wxApi";
-
-
-export function DeveloperPage(props: any): JSX.Element {
- const [status, timedOut] = useDiagnostics();
- return (
- <div>
- <p>Debug tools:</p>
- <button onClick={openExtensionPage("/static/popup.html")}>wallet tab</button>
- <br />
- <button onClick={confirmReset}>reset</button>
- <Diagnostics diagnostics={status} timedOut={timedOut} />
- </div>
- );
-}
-
-export function reload(): void {
- try {
- chrome.runtime.reload();
- window.close();
- } catch (e) {
- // Functionality missing in firefox, ignore!
- }
-}
-
-export async function confirmReset(): Promise<void> {
- if (
- confirm(
- "Do you want to IRREVOCABLY DESTROY everything inside your" +
- " wallet and LOSE ALL YOUR COINS?",
- )
- ) {
- await wxApi.resetDb();
- window.close();
- }
-}
-
-export function openExtensionPage(page: string) {
- return () => {
- chrome.tabs.create({
- url: chrome.extension.getURL(page),
- });
- };
-}
-
diff --git a/packages/taler-wallet-webextension/src/popup/History.stories.tsx b/packages/taler-wallet-webextension/src/popup/History.stories.tsx
deleted file mode 100644
index daa263a81..000000000
--- a/packages/taler-wallet-webextension/src/popup/History.stories.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import {
- PaymentStatus,
- TransactionCommon, TransactionDeposit, TransactionPayment,
- TransactionRefresh, TransactionRefund, TransactionTip, TransactionType,
- TransactionWithdrawal,
- WithdrawalType
-} from '@gnu-taler/taler-util';
-import { createExample } from '../test-utils';
-import { HistoryView as TestedComponent } from './History';
-
-export default {
- title: 'popup/history/list',
- component: TestedComponent,
-};
-
-const commonTransaction = {
- amountRaw: 'USD:10',
- amountEffective: 'USD:9',
- pending: false,
- timestamp: {
- t_ms: new Date().getTime()
- },
- transactionId: '12',
-} as TransactionCommon
-
-const exampleData = {
- withdraw: {
- ...commonTransaction,
- type: TransactionType.Withdrawal,
- exchangeBaseUrl: 'http://exchange.demo.taler.net',
- withdrawalDetails: {
- confirmed: false,
- exchangePaytoUris: ['payto://x-taler-bank/bank/account'],
- type: WithdrawalType.ManualTransfer,
- }
- } as TransactionWithdrawal,
- payment: {
- ...commonTransaction,
- amountEffective: 'USD:11',
- type: TransactionType.Payment,
- info: {
- contractTermsHash: 'ASDZXCASD',
- merchant: {
- name: 'the merchant',
- },
- orderId: '2021.167-03NPY6MCYMVGT',
- products: [],
- summary: 'the summary',
- fulfillmentMessage: '',
- },
- proposalId: '1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0',
- status: PaymentStatus.Accepted,
- } as TransactionPayment,
- deposit: {
- ...commonTransaction,
- type: TransactionType.Deposit,
- depositGroupId: '#groupId',
- targetPaytoUri: 'payto://x-taler-bank/bank/account',
- } as TransactionDeposit,
- refresh: {
- ...commonTransaction,
- type: TransactionType.Refresh,
- exchangeBaseUrl: 'http://exchange.taler',
- } as TransactionRefresh,
- tip: {
- ...commonTransaction,
- type: TransactionType.Tip,
- merchantBaseUrl: 'http://merchant.taler',
- } as TransactionTip,
- refund: {
- ...commonTransaction,
- type: TransactionType.Refund,
- refundedTransactionId: 'payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0',
- info: {
- contractTermsHash: 'ASDZXCASD',
- merchant: {
- name: 'the merchant',
- },
- orderId: '2021.167-03NPY6MCYMVGT',
- products: [],
- summary: 'the summary',
- fulfillmentMessage: '',
- },
- } as TransactionRefund,
-}
-
-export const EmptyWithBalance = createExample(TestedComponent, {
- list: [],
- balances: [{
- available: 'TESTKUDOS:10',
- pendingIncoming: 'TESTKUDOS:0',
- pendingOutgoing: 'TESTKUDOS:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }]
-});
-
-export const EmptyWithNoBalance = createExample(TestedComponent, {
- list: [],
- balances: []
-});
-
-export const One = createExample(TestedComponent, {
- list: [exampleData.withdraw],
- balances: [{
- available: 'USD:10',
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }]
-});
-
-export const OnePending = createExample(TestedComponent, {
- list: [{
- ...exampleData.withdraw,
- pending: true,
- }],
- balances: [{
- available: 'USD:10',
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }]
-});
-
-export const Several = createExample(TestedComponent, {
- list: [
- exampleData.withdraw,
- exampleData.payment,
- exampleData.withdraw,
- exampleData.payment,
- exampleData.refresh,
- exampleData.refund,
- exampleData.tip,
- exampleData.deposit,
- ],
- balances: [{
- available: 'TESTKUDOS:10',
- pendingIncoming: 'TESTKUDOS:0',
- pendingOutgoing: 'TESTKUDOS:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }]
-});
-
-export const SeveralWithTwoCurrencies = createExample(TestedComponent, {
- list: [
- exampleData.withdraw,
- exampleData.payment,
- exampleData.withdraw,
- exampleData.payment,
- exampleData.refresh,
- exampleData.refund,
- exampleData.tip,
- exampleData.deposit,
- ],
- balances: [{
- available: 'TESTKUDOS:10',
- pendingIncoming: 'TESTKUDOS:0',
- pendingOutgoing: 'TESTKUDOS:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }, {
- available: 'USD:10',
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }]
-});
-
diff --git a/packages/taler-wallet-webextension/src/popup/History.tsx b/packages/taler-wallet-webextension/src/popup/History.tsx
deleted file mode 100644
index 1447da9b0..000000000
--- a/packages/taler-wallet-webextension/src/popup/History.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { AmountString, Balance, i18n, Transaction, TransactionsResponse } from "@gnu-taler/taler-util";
-import { h, JSX } from "preact";
-import { useEffect, useState } from "preact/hooks";
-import { PopupBox } from "../components/styled";
-import { TransactionItem } from "../components/TransactionItem";
-import { useBalances } from "../hooks/useBalances";
-import * as wxApi from "../wxApi";
-
-
-export function HistoryPage(props: any): JSX.Element {
- const [transactions, setTransactions] = useState<
- TransactionsResponse | undefined
- >(undefined);
- const balance = useBalances()
- const balanceWithoutError = balance?.hasError ? [] : (balance?.response.balances || [])
-
- useEffect(() => {
- const fetchData = async (): Promise<void> => {
- const res = await wxApi.getTransactions();
- setTransactions(res);
- };
- fetchData();
- }, []);
-
- if (!transactions) {
- return <div>Loading ...</div>;
- }
-
- return <HistoryView balances={balanceWithoutError} list={[...transactions.transactions].reverse()} />;
-}
-
-function amountToString(c: AmountString) {
- const idx = c.indexOf(':')
- return `${c.substring(idx + 1)} ${c.substring(0, idx)}`
-}
-
-
-
-export function HistoryView({ list, balances }: { list: Transaction[], balances: Balance[] }) {
- const multiCurrency = balances.length > 1
- return <PopupBox noPadding>
- {balances.length > 0 && <header>
- {multiCurrency ? <div class="title">
- Balance: <ul style={{ margin: 0 }}>
- {balances.map(b => <li>{b.available}</li>)}
- </ul>
- </div> : <div class="title">
- Balance: <span>{amountToString(balances[0].available)}</span>
- </div>}
- </header>}
- {list.length === 0 ? <section data-expanded data-centered>
- <p><i18n.Translate>
- You have no history yet, here you will be able to check your last transactions.
- </i18n.Translate></p>
- </section> :
- <section>
- {list.slice(0, 3).map((tx, i) => (
- <TransactionItem key={i} tx={tx} multiCurrency={multiCurrency} />
- ))}
- </section>
- }
- <footer style={{ justifyContent: 'space-around' }}>
- {list.length > 0 &&
- <a target="_blank"
- rel="noopener noreferrer"
- style={{ color: 'darkgreen', textDecoration: 'none' }}
- href={chrome.extension ? chrome.extension.getURL(`/static/wallet.html#/history`) : '#'}>VIEW MORE TRANSACTIONS</a>
- }
- </footer>
- </PopupBox>
-}
diff --git a/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx b/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx
new file mode 100644
index 000000000..c698066e7
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx
@@ -0,0 +1,53 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { css } from "@linaria/core";
+import { Fragment, h, VNode } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Alert } from "../mui/Alert.js";
+import { Button } from "../mui/Button.js";
+import { ButtonHandler } from "../mui/handlers.js";
+import { Paper } from "../mui/Paper.js";
+
+const margin = css`
+ margin: 1em;
+`;
+
+export function NoBalanceHelp({
+ goToWalletManualWithdraw,
+}: {
+ goToWalletManualWithdraw: ButtonHandler;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ return (<Fragment>
+
+ <Paper class={margin}>
+ <Alert title={i18n.str`Your wallet is empty.`} severity="info">
+ <Button
+ fullWidth
+ color="info"
+ variant="outlined"
+ onClick={goToWalletManualWithdraw.onClick}
+ >
+ <i18n.Translate>Get digital cash</i18n.Translate>
+ </Button>
+ </Alert>
+ </Paper>
+ <a target="_bank" rel="noreferrer" href="https://demo.taler.net/" style={{ display: "block" }}>
+ <i18n.Translate>Try the demo bank and withdraw test money.</i18n.Translate> »
+ </a>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/popup/ProviderAddPage.tsx b/packages/taler-wallet-webextension/src/popup/ProviderAddPage.tsx
deleted file mode 100644
index 55686ee97..000000000
--- a/packages/taler-wallet-webextension/src/popup/ProviderAddPage.tsx
+++ /dev/null
@@ -1,244 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- Amounts,
- BackupBackupProviderTerms,
- canonicalizeBaseUrl,
- i18n,
-} from "@gnu-taler/taler-util";
-import { VNode, h } from "preact";
-import { useEffect, useState } from "preact/hooks";
-import { Checkbox } from "../components/Checkbox";
-import { ErrorMessage } from "../components/ErrorMessage";
-import {
- Button,
- ButtonPrimary,
- Input,
- LightText,
- PopupBox,
- SmallLightText,
-} from "../components/styled/index";
-import * as wxApi from "../wxApi";
-
-interface Props {
- currency: string;
- onBack: () => void;
-}
-
-function getJsonIfOk(r: Response) {
- if (r.ok) {
- return r.json();
- } else {
- if (r.status >= 400 && r.status < 500) {
- throw new Error(`URL may not be right: (${r.status}) ${r.statusText}`);
- } else {
- throw new Error(
- `Try another server: (${r.status}) ${
- r.statusText || "internal server error"
- }`,
- );
- }
- }
-}
-
-export function ProviderAddPage({ onBack }: Props): VNode {
- const [verifying, setVerifying] = useState<
- | { url: string; name: string; provider: BackupBackupProviderTerms }
- | undefined
- >(undefined);
-
- async function getProviderInfo(
- url: string,
- ): Promise<BackupBackupProviderTerms> {
- return fetch(`${url}config`)
- .catch((e) => {
- throw new Error(`Network error`);
- })
- .then(getJsonIfOk);
- }
-
- if (!verifying) {
- return (
- <SetUrlView
- onCancel={onBack}
- onVerify={(url) => getProviderInfo(url)}
- onConfirm={(url, name) =>
- getProviderInfo(url)
- .then((provider) => {
- setVerifying({ url, name, provider });
- })
- .catch((e) => e.message)
- }
- />
- );
- }
- return (
- <ConfirmProviderView
- provider={verifying.provider}
- url={verifying.url}
- onCancel={() => {
- setVerifying(undefined);
- }}
- onConfirm={() => {
- wxApi.addBackupProvider(verifying.url, verifying.name).then(onBack);
- }}
- />
- );
-}
-
-export interface SetUrlViewProps {
- initialValue?: string;
- onCancel: () => void;
- onVerify: (s: string) => Promise<BackupBackupProviderTerms | undefined>;
- onConfirm: (url: string, name: string) => Promise<string | undefined>;
- withError?: string;
-}
-
-export function SetUrlView({
- initialValue,
- onCancel,
- onVerify,
- onConfirm,
- withError,
-}: SetUrlViewProps) {
- const [value, setValue] = useState<string>(initialValue || "");
- const [urlError, setUrlError] = useState(false);
- const [name, setName] = useState<string | undefined>(undefined);
- const [error, setError] = useState<string | undefined>(withError);
- useEffect(() => {
- try {
- const url = canonicalizeBaseUrl(value);
- onVerify(url)
- .then((r) => {
- setUrlError(false);
- setName(new URL(url).hostname);
- })
- .catch(() => {
- setUrlError(true);
- setName(undefined);
- });
- } catch {
- setUrlError(true);
- setName(undefined);
- }
- }, [value]);
- return (
- <PopupBox>
- <section>
- <h1> Add backup provider</h1>
- <ErrorMessage
- title={error && "Could not get provider information"}
- description={error}
- />
- <LightText> Backup providers may charge for their service</LightText>
- <p>
- <Input invalid={urlError}>
- <label>URL</label>
- <input
- type="text"
- placeholder="https://"
- value={value}
- onChange={(e) => setValue(e.currentTarget.value)}
- />
- </Input>
- <Input>
- <label>Name</label>
- <input
- type="text"
- disabled={name === undefined}
- value={name}
- onChange={(e) => setName(e.currentTarget.value)}
- />
- </Input>
- </p>
- </section>
- <footer>
- <Button onClick={onCancel}>
- <i18n.Translate> &lt; Back</i18n.Translate>
- </Button>
- <ButtonPrimary
- disabled={!value && !urlError}
- onClick={() => {
- const url = canonicalizeBaseUrl(value);
- return onConfirm(url, name!).then((r) =>
- r ? setError(r) : undefined,
- );
- }}
- >
- <i18n.Translate>Next</i18n.Translate>
- </ButtonPrimary>
- </footer>
- </PopupBox>
- );
-}
-
-export interface ConfirmProviderViewProps {
- provider: BackupBackupProviderTerms;
- url: string;
- onCancel: () => void;
- onConfirm: () => void;
-}
-export function ConfirmProviderView({
- url,
- provider,
- onCancel,
- onConfirm,
-}: ConfirmProviderViewProps) {
- const [accepted, setAccepted] = useState(false);
-
- return (
- <PopupBox>
- <section>
- <h1>Review terms of service</h1>
- <div>
- Provider URL:{" "}
- <a href={url} target="_blank">
- {url}
- </a>
- </div>
- <SmallLightText>
- Please review and accept this provider's terms of service
- </SmallLightText>
- <h2>1. Pricing</h2>
- <p>
- {Amounts.isZero(provider.annual_fee)
- ? "free of charge"
- : `${provider.annual_fee} per year of service`}
- </p>
- <h2>2. Storage</h2>
- <p>
- {provider.storage_limit_in_megabytes} megabytes of storage per year of
- service
- </p>
- <Checkbox
- label="Accept terms of service"
- name="terms"
- onToggle={() => setAccepted((old) => !old)}
- enabled={accepted}
- />
- </section>
- <footer>
- <Button onClick={onCancel}>
- <i18n.Translate> &lt; Back</i18n.Translate>
- </Button>
- <ButtonPrimary disabled={!accepted} onClick={onConfirm}>
- <i18n.Translate>Add provider</i18n.Translate>
- </ButtonPrimary>
- </footer>
- </PopupBox>
- );
-}
diff --git a/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx b/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx
deleted file mode 100644
index 2daf49e0c..000000000
--- a/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createExample } from '../test-utils';
-import { SetUrlView as TestedComponent } from './ProviderAddPage';
-
-export default {
- title: 'popup/backup/add',
- component: TestedComponent,
- argTypes: {
- onRetry: { action: 'onRetry' },
- onDelete: { action: 'onDelete' },
- onBack: { action: 'onBack' },
- }
-};
-
-
-export const Initial = createExample(TestedComponent, {
-});
-
-export const WithValue = createExample(TestedComponent, {
- initialValue: 'sync.demo.taler.net'
-});
-
-export const WithConnectionError = createExample(TestedComponent, {
- withError: 'Network error'
-});
-
-export const WithClientError = createExample(TestedComponent, {
- withError: 'URL may not be right: (404) Not Found'
-});
-
-export const WithServerError = createExample(TestedComponent, {
- withError: 'Try another server: (500) Internal Server Error'
-});
diff --git a/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx b/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx
deleted file mode 100644
index 4416608f8..000000000
--- a/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { ProviderPaymentType } from '@gnu-taler/taler-wallet-core';
-import { createExample } from '../test-utils';
-import { ProviderView as TestedComponent } from './ProviderDetailPage';
-
-export default {
- title: 'popup/backup/details',
- component: TestedComponent,
- argTypes: {
- onRetry: { action: 'onRetry' },
- onDelete: { action: 'onDelete' },
- onBack: { action: 'onBack' },
- }
-};
-
-
-export const Active = createExample(TestedComponent, {
- info: {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
- },
- "terms": {
- "annualFee": "EUR:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
-});
-
-export const ActiveErrorSync = createExample(TestedComponent, {
- info: {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- lastAttemptedBackupTimestamp: {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
- },
- lastError: {
- code: 2002,
- details: 'details',
- hint: 'error hint from the server',
- message: 'message'
- },
- "terms": {
- "annualFee": "EUR:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
-});
-
-export const ActiveBackupProblemUnreadable = createExample(TestedComponent, {
- info: {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
- },
- backupProblem: {
- type: 'backup-unreadable'
- },
- "terms": {
- "annualFee": "EUR:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
-});
-
-export const ActiveBackupProblemDevice = createExample(TestedComponent, {
- info: {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
- },
- backupProblem: {
- type: 'backup-conflicting-device',
- myDeviceId: 'my-device-id',
- otherDeviceId: 'other-device-id',
- backupTimestamp: {
- "t_ms": 1656599921000
- }
- },
- "terms": {
- "annualFee": "EUR:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
-});
-
-export const InactiveUnpaid = createExample(TestedComponent, {
- info: {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.Unpaid,
- },
- "terms": {
- "annualFee": "EUR:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
-});
-
-export const InactiveInsufficientBalance = createExample(TestedComponent, {
- info: {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.InsufficientBalance,
- },
- "terms": {
- "annualFee": "EUR:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
-});
-
-export const InactivePending = createExample(TestedComponent, {
- info: {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.Pending,
- },
- "terms": {
- "annualFee": "EUR:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
-});
-
-
-export const ActiveTermsChanged = createExample(TestedComponent, {
- info: {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.TermsChanged,
- paidUntil: {
- t_ms: 1656599921000
- },
- newTerms: {
- "annualFee": "EUR:10",
- "storageLimitInMegabytes": 8,
- "supportedProtocolVersion": "0.0"
- },
- oldTerms: {
- "annualFee": "EUR:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- },
- "terms": {
- "annualFee": "EUR:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
-});
-
diff --git a/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx b/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx
deleted file mode 100644
index 04adbb21c..000000000
--- a/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
-
-
-import { i18n, Timestamp } from "@gnu-taler/taler-util";
-import { ProviderInfo, ProviderPaymentStatus, ProviderPaymentType } from "@gnu-taler/taler-wallet-core";
-import { format, formatDuration, intervalToDuration } from "date-fns";
-import { Fragment, VNode, h } from "preact";
-import { ErrorMessage } from "../components/ErrorMessage";
-import { Button, ButtonDestructive, ButtonPrimary, PaymentStatus, PopupBox, SmallLightText } from "../components/styled";
-import { useProviderStatus } from "../hooks/useProviderStatus";
-
-interface Props {
- pid: string;
- onBack: () => void;
-}
-
-export function ProviderDetailPage({ pid, onBack }: Props): VNode {
- const status = useProviderStatus(pid)
- if (!status) {
- return <div><i18n.Translate>Loading...</i18n.Translate></div>
- }
- if (!status.info) {
- onBack()
- return <div />
- }
- return <ProviderView info={status.info}
- onSync={status.sync}
- onDelete={() => status.remove().then(onBack)}
- onBack={onBack}
- onExtend={() => { null }}
- />;
-}
-
-export interface ViewProps {
- info: ProviderInfo;
- onDelete: () => void;
- onSync: () => void;
- onBack: () => void;
- onExtend: () => void;
-}
-
-export function ProviderView({ info, onDelete, onSync, onBack, onExtend }: ViewProps): VNode {
- const lb = info?.lastSuccessfulBackupTimestamp
- const isPaid = info.paymentStatus.type === ProviderPaymentType.Paid || info.paymentStatus.type === ProviderPaymentType.TermsChanged
- return (
- <PopupBox>
- <Error info={info} />
- <header>
- <h3>{info.name} <SmallLightText>{info.syncProviderBaseUrl}</SmallLightText></h3>
- <PaymentStatus color={isPaid ? 'rgb(28, 184, 65)' : 'rgb(202, 60, 60)'}>{isPaid ? 'Paid' : 'Unpaid'}</PaymentStatus>
- </header>
- <section>
- <p><b>Last backup:</b> {lb == null || lb.t_ms == "never" ? "never" : format(lb.t_ms, 'dd MMM yyyy')} </p>
- <ButtonPrimary onClick={onSync}><i18n.Translate>Back up</i18n.Translate></ButtonPrimary>
- {info.terms && <Fragment>
- <p><b>Provider fee:</b> {info.terms && info.terms.annualFee} per year</p>
- </Fragment>
- }
- <p>{descriptionByStatus(info.paymentStatus)}</p>
- <ButtonPrimary disabled onClick={onExtend}><i18n.Translate>Extend</i18n.Translate></ButtonPrimary>
-
- {info.paymentStatus.type === ProviderPaymentType.TermsChanged && <div>
- <p><i18n.Translate>terms has changed, extending the service will imply accepting the new terms of service</i18n.Translate></p>
- <table>
- <thead>
- <tr>
- <td></td>
- <td><i18n.Translate>old</i18n.Translate></td>
- <td> -&gt;</td>
- <td><i18n.Translate>new</i18n.Translate></td>
- </tr>
- </thead>
- <tbody>
-
- <tr>
- <td><i18n.Translate>fee</i18n.Translate></td>
- <td>{info.paymentStatus.oldTerms.annualFee}</td>
- <td>-&gt;</td>
- <td>{info.paymentStatus.newTerms.annualFee}</td>
- </tr>
- <tr>
- <td><i18n.Translate>storage</i18n.Translate></td>
- <td>{info.paymentStatus.oldTerms.storageLimitInMegabytes}</td>
- <td>-&gt;</td>
- <td>{info.paymentStatus.newTerms.storageLimitInMegabytes}</td>
- </tr>
- </tbody>
- </table>
- </div>}
-
- </section>
- <footer>
- <Button onClick={onBack}><i18n.Translate> &lt; back</i18n.Translate></Button>
- <div>
- <ButtonDestructive onClick={onDelete}><i18n.Translate>remove provider</i18n.Translate></ButtonDestructive>
- </div>
- </footer>
- </PopupBox>
- )
-}
-
-function daysSince(d?: Timestamp) {
- if (!d || d.t_ms === 'never') return 'never synced'
- const duration = intervalToDuration({
- start: d.t_ms,
- end: new Date(),
- })
- const str = formatDuration(duration, {
- delimiter: ', ',
- format: [
- duration?.years ? i18n.str`years` : (
- duration?.months ? i18n.str`months` : (
- duration?.days ? i18n.str`days` : (
- duration?.hours ? i18n.str`hours` : (
- duration?.minutes ? i18n.str`minutes` : i18n.str`seconds`
- )
- )
- )
- )
- ]
- })
- return `synced ${str} ago`
-}
-
-function Error({ info }: { info: ProviderInfo }) {
- if (info.lastError) {
- return <ErrorMessage title={info.lastError.hint} />
- }
- if (info.backupProblem) {
- switch (info.backupProblem.type) {
- case "backup-conflicting-device":
- return <ErrorMessage title={<Fragment>
- <i18n.Translate>There is conflict with another backup from <b>{info.backupProblem.otherDeviceId}</b></i18n.Translate>
- </Fragment>} />
- case "backup-unreadable":
- return <ErrorMessage title="Backup is not readable" />
- default:
- return <ErrorMessage title={<Fragment>
- <i18n.Translate>Unknown backup problem: {JSON.stringify(info.backupProblem)}</i18n.Translate>
- </Fragment>} />
- }
- }
- return null
-}
-
-function colorByStatus(status: ProviderPaymentType) {
- switch (status) {
- case ProviderPaymentType.InsufficientBalance:
- return 'rgb(223, 117, 20)'
- case ProviderPaymentType.Unpaid:
- return 'rgb(202, 60, 60)'
- case ProviderPaymentType.Paid:
- return 'rgb(28, 184, 65)'
- case ProviderPaymentType.Pending:
- return 'gray'
- case ProviderPaymentType.InsufficientBalance:
- return 'rgb(202, 60, 60)'
- case ProviderPaymentType.TermsChanged:
- return 'rgb(202, 60, 60)'
- }
-}
-
-function descriptionByStatus(status: ProviderPaymentStatus) {
- switch (status.type) {
- // return i18n.str`no enough balance to make the payment`
- // return i18n.str`not paid yet`
- case ProviderPaymentType.Paid:
- case ProviderPaymentType.TermsChanged:
- if (status.paidUntil.t_ms === 'never') {
- return i18n.str`service paid`
- } else {
- return <Fragment>
- <b>Backup valid until:</b> {format(status.paidUntil.t_ms, 'dd MMM yyyy')}
- </Fragment>
- }
- case ProviderPaymentType.Unpaid:
- case ProviderPaymentType.InsufficientBalance:
- case ProviderPaymentType.Pending:
- return ''
- }
-}
diff --git a/packages/taler-wallet-webextension/src/popup/Settings.tsx b/packages/taler-wallet-webextension/src/popup/Settings.tsx
deleted file mode 100644
index 8595c87ff..000000000
--- a/packages/taler-wallet-webextension/src/popup/Settings.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
-
-
-import { i18n } from "@gnu-taler/taler-util";
-import { VNode, h } from "preact";
-import { Checkbox } from "../components/Checkbox";
-import { EditableText } from "../components/EditableText";
-import { SelectList } from "../components/SelectList";
-import { PopupBox } from "../components/styled";
-import { useDevContext } from "../context/devContext";
-import { useBackupDeviceName } from "../hooks/useBackupDeviceName";
-import { useExtendedPermissions } from "../hooks/useExtendedPermissions";
-import { useLang } from "../hooks/useLang";
-
-export function SettingsPage(): VNode {
- const [permissionsEnabled, togglePermissions] = useExtendedPermissions();
- const { devMode, toggleDevMode } = useDevContext()
- const { name, update } = useBackupDeviceName()
- const [lang, changeLang] = useLang()
- return <SettingsView
- lang={lang} changeLang={changeLang}
- deviceName={name} setDeviceName={update}
- permissionsEnabled={permissionsEnabled} togglePermissions={togglePermissions}
- developerMode={devMode} toggleDeveloperMode={toggleDevMode}
- />;
-}
-
-export interface ViewProps {
- lang: string;
- changeLang: (s: string) => void;
- deviceName: string;
- setDeviceName: (s: string) => Promise<void>;
- permissionsEnabled: boolean;
- togglePermissions: () => void;
- developerMode: boolean;
- toggleDeveloperMode: () => void;
-}
-
-import { strings as messages } from '../i18n/strings'
-
-type LangsNames = {
- [P in keyof typeof messages]: string
-}
-
-const names: LangsNames = {
- es: 'Español [es]',
- en: 'English [en]',
- fr: 'Français [fr]',
- de: 'Deutsch [de]',
- sv: 'Svenska [sv]',
- it: 'Italiano [it]',
-}
-
-
-export function SettingsView({ lang, changeLang, deviceName, setDeviceName, permissionsEnabled, togglePermissions, developerMode, toggleDeveloperMode }: ViewProps): VNode {
- return (
- <PopupBox>
- <section>
- {/* <h2><i18n.Translate>Wallet</i18n.Translate></h2> */}
- {/* <SelectList
- value={lang}
- onChange={changeLang}
- name="lang"
- list={names}
- label={i18n.str`Language`}
- description="(Choose your preferred lang)"
- />
- <EditableText
- value={deviceName}
- onChange={setDeviceName}
- name="device-id"
- label={i18n.str`Device name`}
- description="(This is how you will recognize the wallet in the backup provider)"
- /> */}
- <h2><i18n.Translate>Permissions</i18n.Translate></h2>
- <Checkbox label="Automatically open wallet based on page content"
- name="perm"
- description="(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)"
- enabled={permissionsEnabled} onToggle={togglePermissions}
- />
- <h2>Config</h2>
- <Checkbox label="Developer mode"
- name="devMode"
- description="(More options and information useful for debugging)"
- enabled={developerMode} onToggle={toggleDeveloperMode}
- />
- </section>
- <footer style={{ justifyContent: 'space-around' }}>
- <a target="_blank"
- rel="noopener noreferrer"
- style={{ color: 'darkgreen', textDecoration: 'none' }}
- href={chrome.extension ? chrome.extension.getURL(`/static/wallet.html#/settings`) : '#'}>VIEW MORE SETTINGS</a>
- </footer>
- </PopupBox>
- )
-} \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx b/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx
index 88c7c725e..0388664b3 100644
--- a/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx
+++ b/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,38 +15,33 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { createExample } from '../test-utils';
-import { TalerActionFound as TestedComponent } from './TalerActionFound';
+import * as tests from "@gnu-taler/web-util/testing";
+import { TalerActionFound as TestedComponent } from "./TalerActionFound.js";
export default {
- title: 'popup/TalerActionFound',
- component: TestedComponent,
+ title: "TalerActionFound",
};
-export const PayAction = createExample(TestedComponent, {
- url: 'taler://pay/something'
-});
-
-export const WithdrawalAction = createExample(TestedComponent, {
- url: 'taler://withdraw/something'
+export const PayAction = tests.createExample(TestedComponent, {
+ url: "taler://pay/something",
});
-export const TipAction = createExample(TestedComponent, {
- url: 'taler://tip/something'
+export const WithdrawalAction = tests.createExample(TestedComponent, {
+ url: "taler://withdraw/something",
});
-export const NotifyAction = createExample(TestedComponent, {
- url: 'taler://notify-reserve/something'
+export const NotifyAction = tests.createExample(TestedComponent, {
+ url: "taler://notify-reserve/something",
});
-export const RefundAction = createExample(TestedComponent, {
- url: 'taler://refund/something'
+export const RefundAction = tests.createExample(TestedComponent, {
+ url: "taler://refund/something",
});
-export const InvalidAction = createExample(TestedComponent, {
- url: 'taler://something/asd'
+export const InvalidAction = tests.createExample(TestedComponent, {
+ url: "taler://something/asd",
});
diff --git a/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx b/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx
index ef0ec341c..21373c7cd 100644
--- a/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx
+++ b/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx
@@ -1,98 +1,136 @@
-import { classifyTalerUri, TalerUriType } from "@gnu-taler/taler-util";
-import { ButtonPrimary, ButtonSuccess, PopupBox } from "../components/styled/index";
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { parseTalerUri, TalerUri, TalerUriAction } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { Title } from "../components/styled/index.js";
+import { Button } from "../mui/Button.js";
+import { platform } from "../platform/foreground.js";
export interface Props {
url: string;
- onDismiss: () => void;
+ onDismiss: () => Promise<void>;
}
-export function TalerActionFound({ url, onDismiss }: Props) {
- const uriType = classifyTalerUri(url);
- return <PopupBox>
- <section>
- <h1>Taler Action </h1>
- {uriType === TalerUriType.TalerPay && <div>
- <p>This page has pay action.</p>
- <ButtonSuccess onClick={() => { chrome.tabs.create({ "url": actionForTalerUri(uriType, url) }); }}>
- Open pay page
- </ButtonSuccess>
- </div>}
- {uriType === TalerUriType.TalerWithdraw && <div>
- <p>This page has a withdrawal action.</p>
- <ButtonSuccess onClick={() => { chrome.tabs.create({ "url": actionForTalerUri(uriType, url) }); }}>
- Open withdraw page
- </ButtonSuccess>
- </div>}
- {uriType === TalerUriType.TalerTip && <div>
- <p>This page has a tip action.</p>
- <ButtonSuccess onClick={() => { chrome.tabs.create({ "url": actionForTalerUri(uriType, url) }); }}>
- Open tip page
- </ButtonSuccess>
- </div>}
- {uriType === TalerUriType.TalerNotifyReserve && <div>
- <p>This page has a notify reserve action.</p>
- <ButtonSuccess onClick={() => { chrome.tabs.create({ "url": actionForTalerUri(uriType, url) }); }}>
- Notify
- </ButtonSuccess>
- </div>}
- {uriType === TalerUriType.TalerRefund && <div>
- <p>This page has a refund action.</p>
- <ButtonSuccess onClick={() => { chrome.tabs.create({ "url": actionForTalerUri(uriType, url) }); }}>
- Open refund page
- </ButtonSuccess>
- </div>}
- {uriType === TalerUriType.Unknown && <div>
- <p>This page has a malformed taler uri.</p>
- <p>{url}</p>
- </div>}
-
- </section>
- <footer>
- <div />
- <ButtonPrimary onClick={() => onDismiss()}> Dismiss </ButtonPrimary>
- </footer>
- </PopupBox>;
+function ContentByUriType({
+ uri,
+ onConfirm,
+}: {
+ uri: TalerUri;
+ onConfirm: () => Promise<void>;
+}) {
+ const { i18n } = useTranslationContext();
+ switch (uri.type) {
+ case TalerUriAction.WithdrawExchange:
+ case TalerUriAction.Withdraw:
+ return (
+ <div>
+ <p>
+ <i18n.Translate>This page has a withdrawal action.</i18n.Translate>
+ </p>
+ <Button variant="contained" color="success" onClick={onConfirm}>
+ <i18n.Translate>Open withdraw page</i18n.Translate>
+ </Button>
+ </div>
+ );
-}
+ case TalerUriAction.PayTemplate:
+ case TalerUriAction.Pay:
+ return (
+ <div>
+ <p>
+ <i18n.Translate>This page has pay action.</i18n.Translate>
+ </p>
+ <Button variant="contained" color="success" onClick={onConfirm}>
+ <i18n.Translate>Open pay page</i18n.Translate>
+ </Button>
+ </div>
+ );
-function actionForTalerUri(uriType: TalerUriType, talerUri: string): string | undefined {
- switch (uriType) {
- case TalerUriType.TalerWithdraw:
- return makeExtensionUrlWithParams("static/wallet.html#/withdraw", {
- talerWithdrawUri: talerUri,
- });
- case TalerUriType.TalerPay:
- return makeExtensionUrlWithParams("static/wallet.html#/pay", {
- talerPayUri: talerUri,
- });
- case TalerUriType.TalerTip:
- return makeExtensionUrlWithParams("static/wallet.html#/tip", {
- talerTipUri: talerUri,
- });
- case TalerUriType.TalerRefund:
- return makeExtensionUrlWithParams("static/wallet.html#/refund", {
- talerRefundUri: talerUri,
- });
- case TalerUriType.TalerNotifyReserve:
- // FIXME: implement
- break;
- default:
- console.warn(
- "Response with HTTP 402 has Taler header, but header value is not a taler:// URI.",
+ case TalerUriAction.Refund:
+ return (
+ <div>
+ <p>
+ <i18n.Translate>This page has a refund action.</i18n.Translate>
+ </p>
+ <Button variant="contained" color="success" onClick={onConfirm}>
+ <i18n.Translate>Open refund page</i18n.Translate>
+ </Button>
+ </div>
);
- break;
+ case TalerUriAction.AddExchange:
+ return (
+ <div>
+ <p>
+ <i18n.Translate>This page has a add exchange action.</i18n.Translate>
+ </p>
+ <Button variant="contained" color="success" onClick={onConfirm}>
+ <i18n.Translate>Open add exchange page</i18n.Translate>
+ </Button>
+ </div>
+ );
+
+ case TalerUriAction.DevExperiment:
+ case TalerUriAction.PayPull:
+ case TalerUriAction.PayPush:
+ case TalerUriAction.Restore:
+ return null;
+ default: {
+ const error: never = uri;
+ return null;
+ }
}
- return undefined;
}
-function makeExtensionUrlWithParams(
- url: string,
- params?: { [name: string]: string | undefined },
-): string {
- const innerUrl = new URL(chrome.extension.getURL("/" + url));
- if (params) {
- const hParams = Object.keys(params).map(k => `${k}=${params[k]}`).join('&')
- innerUrl.hash = innerUrl.hash + '?' + hParams
+export function TalerActionFound({ url, onDismiss }: Props): VNode {
+ const talerUri = parseTalerUri(url);
+ const { i18n } = useTranslationContext();
+ async function redirectToWallet(): Promise<void> {
+ platform.openWalletURIFromPopup(talerUri!);
}
- return innerUrl.href;
+ return (
+ <Fragment>
+ <section>
+ <Title>
+ <i18n.Translate>Taler Action</i18n.Translate>
+ </Title>
+ {!talerUri ? (
+ <div>
+ <p>
+ <i18n.Translate>
+ This page has a malformed taler uri.
+ </i18n.Translate>
+ </p>
+ </div>
+ ) : (
+ <ContentByUriType uri={talerUri} onConfirm={redirectToWallet} />
+ )}
+ </section>
+ <footer>
+ <div />
+ <Button variant="contained" onClick={onDismiss}>
+ <i18n.Translate>Dismiss</i18n.Translate>
+ </Button>
+ </footer>
+ </Fragment>
+ );
}
diff --git a/packages/taler-wallet-webextension/src/popup/index.stories.tsx b/packages/taler-wallet-webextension/src/popup/index.stories.tsx
new file mode 100644
index 000000000..ea7cee77d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/popup/index.stories.tsx
@@ -0,0 +1,23 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export * as a1 from "./Balance.stories.js";
+export * as a2 from "./TalerActionFound.stories.js";
diff --git a/packages/taler-wallet-webextension/src/popupEntryPoint.dev.tsx b/packages/taler-wallet-webextension/src/popupEntryPoint.dev.tsx
new file mode 100644
index 000000000..f0bc81399
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/popupEntryPoint.dev.tsx
@@ -0,0 +1,53 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Main entry point for extension pages.
+ *
+ * @author sebasjm
+ */
+
+import { setupI18n } from "@gnu-taler/taler-util";
+import { h, render } from "preact";
+import { strings } from "./i18n/strings.js";
+import { setupPlatform } from "./platform/foreground.js";
+import devAPI from "./platform/dev.js";
+import { Application } from "./popup/Application.js";
+
+setupPlatform(devAPI);
+
+function main(): void {
+ try {
+ const container = document.getElementById("container");
+ if (!container) {
+ throw Error("container not found, can't mount page contents");
+ }
+ render(<Application />, container);
+ } catch (e) {
+ console.error("got error", e);
+ if (e instanceof Error) {
+ document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`;
+ }
+ }
+}
+
+setupI18n("en", strings);
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", main);
+} else {
+ main();
+}
diff --git a/packages/taler-wallet-webextension/src/popupEntryPoint.tsx b/packages/taler-wallet-webextension/src/popupEntryPoint.tsx
index 070df554c..08915ea96 100644
--- a/packages/taler-wallet-webextension/src/popupEntryPoint.tsx
+++ b/packages/taler-wallet-webextension/src/popupEntryPoint.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -17,28 +17,25 @@
/**
* Main entry point for extension pages.
*
- * @author Florian Dold <dold@taler.net>
+ * @author sebasjm
*/
import { setupI18n } from "@gnu-taler/taler-util";
-import { createHashHistory } from "history";
-import { render, h, VNode } from "preact";
-import Router, { route, Route, getCurrentUrl } from "preact-router";
-import { useEffect, useState } from "preact/hooks";
-import { DevContextProvider } from "./context/devContext";
-import { useTalerActionURL } from "./hooks/useTalerActionURL";
-import { strings } from "./i18n/strings";
-import { BackupPage } from "./popup/BackupPage";
-import { BalancePage } from "./popup/BalancePage";
-import { DeveloperPage as DeveloperPage } from "./popup/Debug";
-import { HistoryPage } from "./popup/History";
-import {
- Pages, WalletNavBar
-} from "./NavigationBar";
-import { ProviderAddPage } from "./popup/ProviderAddPage";
-import { ProviderDetailPage } from "./popup/ProviderDetailPage";
-import { SettingsPage } from "./popup/Settings";
-import { TalerActionFound } from "./popup/TalerActionFound";
+import { h, render } from "preact";
+import { strings } from "./i18n/strings.js";
+import { setupPlatform } from "./platform/foreground.js";
+import chromeAPI from "./platform/chrome.js";
+import firefoxAPI from "./platform/firefox.js";
+import { Application } from "./popup/Application.js";
+
+//FIXME: create different entry point for any platform instead of
+//switching in runtime
+const isFirefox = typeof (window as any)["InstallTrigger"] !== "undefined";
+if (isFirefox) {
+ setupPlatform(firefoxAPI);
+} else {
+ setupPlatform(chromeAPI);
+}
function main(): void {
try {
@@ -55,77 +52,10 @@ function main(): void {
}
}
-setupI18n("en-US", strings);
+setupI18n("en", strings);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", main);
} else {
main();
}
-
-function Application() {
- const [talerActionUrl, setDismissed] = useTalerActionURL()
-
- useEffect(() => {
- if (talerActionUrl) route(Pages.cta)
- },[talerActionUrl])
-
- return (
- <div>
- <DevContextProvider>
- <WalletNavBar />
- <div style={{ width: 400, height: 290 }}>
- <Router history={createHashHistory()}>
- <Route path={Pages.dev} component={DeveloperPage} />
-
- <Route path={Pages.balance} component={BalancePage}
- goToWalletManualWithdraw={() => goToWalletPage(Pages.manual_withdraw)}
- />
- <Route path={Pages.settings} component={SettingsPage} />
- <Route path={Pages.cta} component={() => <TalerActionFound url={talerActionUrl!} onDismiss={() => {
- setDismissed(true)
- route(Pages.balance)
- }} />} />
-
- <Route path={Pages.transaction}
- component={({ tid }: { tid: string }) => goToWalletPage(Pages.transaction.replace(':tid', tid))}
- />
-
- <Route path={Pages.history} component={HistoryPage} />
- <Route path={Pages.backup} component={BackupPage}
- onAddProvider={() => {
- route(Pages.provider_add)
- }}
- />
- <Route path={Pages.provider_detail} component={ProviderDetailPage}
- onBack={() => {
- route(Pages.backup)
- }}
- />
- <Route path={Pages.provider_add} component={ProviderAddPage}
- onBack={() => {
- route(Pages.backup)
- }}
- />
- <Route default component={Redirect} to={Pages.balance} />
- </Router>
- </div>
- </DevContextProvider>
- </div>
- );
-}
-
-function goToWalletPage(page: Pages | string): null {
- chrome.tabs.create({
- active: true,
- url: chrome.extension.getURL(`/static/wallet.html#${page}`),
- })
- return null
-}
-
-function Redirect({ to }: { to: string }): null {
- useEffect(() => {
- route(to, true)
- })
- return null
-}
diff --git a/packages/taler-wallet-webextension/src/pwa/index.html b/packages/taler-wallet-webextension/src/pwa/index.html
new file mode 100644
index 000000000..c150ee68d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/index.html
@@ -0,0 +1,114 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <link rel="manifest" href="./manifest.json" />
+ <style>
+ .overlay {
+ position: absolute;
+ top: 0px;
+ display: none;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ color: white;
+ justify-content: center;
+ }
+ .overlay > iframe {
+ margin: auto;
+ }
+ </style>
+ </head>
+ <body>
+ <script>
+ function openPopup() {
+ document.getElementById("popup-overlay").style.display = "flex";
+ window.frames["popup"].location = "popup.html";
+ }
+ function closePopup() {
+ document.getElementById("popup-overlay").style.display = "none";
+ }
+ function redirectWallet(url) {
+ window.frames["wallet"].location = url;
+ }
+ function openWallet() {
+ redirectWallet("wallet.html");
+ }
+ function closeWallet() {
+ redirectWallet("about:blank");
+ }
+ function reloadWallet() {
+ window.frames["wallet"].location.reload()
+ }
+ function openPage() {
+ window.frames["other"].location =
+ document.getElementById("page-url").value;
+ }
+ </script>
+ <button value="asd" onclick="openPopup()">open popup</button>
+ <button value="asd" onclick="closeWallet();openWallet()">
+ restart
+ </button>
+ <button value="asd" onclick="reloadWallet()">
+ refresh
+ </button>
+ <br />
+ <iframe
+ id="wallet-window"
+ name="wallet"
+ src="wallet.html"
+ style="height: calc(100% - 30px)"
+ width="850"
+ height="90%"
+ >
+ </iframe>
+ <!-- <input id="page-url" type="text" />
+ <button onclick="openPage()">open</button> -->
+ <!-- <a
+ href='javascript:void(window.frames["other"].location = "http://bank.taler:5882")'
+ >open local bank</a
+ >
+ <hr />
+ <iframe
+ id="other-window"
+ name="other"
+ src="http://bank.taler:5882"
+ width="100%"
+ height="325"
+ >
+ </iframe> -->
+ <div class="overlay" id="popup-overlay" onclick="closePopup()">
+
+ <iframe
+ id="popup-window"
+ name="popup"
+ src="about:blank"
+ width="500"
+ height="325"
+ >
+ </iframe>
+ </div>
+ <!-- <hr />
+ <iframe src="tests.html" name="wallet" width="800" height="100%"> </iframe> -->
+ <!-- <hr />
+ <iframe src="stories.html" name="wallet" width="800" height="100%"> -->
+ <script type="module" src="background.dev.js"></script>
+ <script type="module">
+ if ("serviceWorker" in navigator) {
+ try {
+ const registration = await navigator.serviceWorker.register("sw.js", {
+ scope: "/app/",
+ });
+ if (registration.installing) {
+ console.log("Service worker installing");
+ } else if (registration.waiting) {
+ console.log("Service worker installed");
+ } else if (registration.active) {
+ console.log("Service worker active");
+ }
+ } catch (error) {
+ console.error(`Registration failed with ${error}`);
+ }
+ }
+ </script>
+ </body>
+</html>
diff --git a/packages/taler-wallet-webextension/src/pwa/manifest.json b/packages/taler-wallet-webextension/src/pwa/manifest.json
new file mode 100644
index 000000000..adf27e43f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/manifest.json
@@ -0,0 +1,35 @@
+{
+ "name": "GNU Taler Wallet",
+ "description": "Privacy preserving and transparent payments",
+ "author": "GNU Taler Developers",
+ "version": "0.9.3.13",
+ "id": "gnu-taler-wallet-web-spa-development",
+ "version_name": "0.9.3-dev.13",
+ "display": "minimal-ui",
+ "start_url": "./",
+ "manifest_version": 3,
+ "minimum_chrome_version": "88",
+ "icons": [
+ {
+ "src": "./static/img/taler-logo-48.png",
+ "type": "image/png",
+ "sizes": "48x48"
+ },
+ {
+ "src": "./static/img/taler-logo-128.png",
+ "type": "image/png",
+ "sizes": "128x128"
+ },
+ {
+ "src": "./static/img/taler-logo-512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "protocol_handlers": [
+ {
+ "protocol": "web+taler",
+ "url": "./wallet.html?type=%s"
+ }
+ ]
+}
diff --git a/packages/taler-wallet-webextension/src/pwa/popup.html b/packages/taler-wallet-webextension/src/pwa/popup.html
new file mode 100644
index 000000000..34d1d019c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/popup.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <style>
+ html {
+ font-family: sans-serif; /* 1 */
+ }
+ body {
+ margin: 0;
+ }
+ </style>
+ <style>
+ html {
+ }
+ h1 {
+ font-size: 2em;
+ }
+ input {
+ font: inherit;
+ }
+ body {
+ margin: 0;
+ font-size: 100%;
+ padding: 0;
+ overflow: hidden;
+ background-color: #f8faf7;
+ font-family: Arial, Helvetica, sans-serif;
+ }
+ </style>
+
+ <link rel="stylesheet" type="text/css" href="popupEntryPoint.dev.css" />
+ <script type="module" src="popupEntryPoint.dev.js"></script>
+ </head>
+
+ <body>
+ <taler-popup id="container" class="popup-container"></taler-popup>
+ </body>
+</html>
diff --git a/packages/taler-wallet-webextension/src/pwa/static/font/import.css b/packages/taler-wallet-webextension/src/pwa/static/font/import.css
new file mode 100644
index 000000000..d726ebc5a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/static/font/import.css
@@ -0,0 +1,35 @@
+@font-face {
+ font-family: "Roboto";
+ font-style: italic;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/static/font/roboto-italic-400.ttf) format("truetype");
+}
+@font-face {
+ font-family: "Roboto";
+ font-style: normal;
+ font-weight: 300;
+ font-display: swap;
+ src: url(/static/font/roboto-normal-300.ttf) format("truetype");
+}
+@font-face {
+ font-family: "Roboto";
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/static/font/roboto-normal-400.ttf) format("truetype");
+}
+@font-face {
+ font-family: "Roboto";
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(/static/font/roboto-normal-500.ttf) format("truetype");
+}
+@font-face {
+ font-family: "Roboto";
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url(/static/font/roboto-normal-700.ttf) format("truetype");
+}
diff --git a/packages/taler-wallet-webextension/src/pwa/static/font/roboto-italic-400.ttf b/packages/taler-wallet-webextension/src/pwa/static/font/roboto-italic-400.ttf
new file mode 100644
index 000000000..1e746d17f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/static/font/roboto-italic-400.ttf
Binary files differ
diff --git a/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-300.tff b/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-300.tff
new file mode 100644
index 000000000..ec821b577
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-300.tff
Binary files differ
diff --git a/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-400.ttf b/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-400.ttf
new file mode 100644
index 000000000..9d4b32b47
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-400.ttf
Binary files differ
diff --git a/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-500.ttf b/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-500.ttf
new file mode 100644
index 000000000..4b4e1c656
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-500.ttf
Binary files differ
diff --git a/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-700.ttf b/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-700.ttf
new file mode 100644
index 000000000..58d877c58
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/static/font/roboto-normal-700.ttf
Binary files differ
diff --git a/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-128.png b/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-128.png
new file mode 100644
index 000000000..a2f0c22eb
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-128.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-2022.svg b/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-2022.svg
new file mode 100644
index 000000000..2ac2785b8
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-2022.svg
@@ -0,0 +1,468 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="670"
+ height="300"
+ viewBox="0 0 201 90"
+ version="1.1"
+ id="svg8"
+ sodipodi:docname="taler-logo-2023.svg"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)">
+ <metadata
+ id="metadata67">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs854">
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20663">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20665"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20667">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20669"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20671">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20673"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20675">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20677"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20679">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20681"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20683">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20685"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20687">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20689"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20691">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20693"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20695">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20697"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20699">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20701"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20703">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20705"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20707">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20709"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20711">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20713"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20715">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20717"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20719">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20721"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20723">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20725"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20727">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20729"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20731">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20733"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20735">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20737"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ </defs>
+ <sodipodi:namedview
+ id="namedview852"
+ pagecolor="#000000"
+ bordercolor="#cccccc"
+ borderopacity="1"
+ inkscape:pageshadow="0"
+ inkscape:pageopacity="0"
+ inkscape:pagecheckerboard="false"
+ showgrid="false"
+ inkscape:zoom="0.46315494"
+ inkscape:cx="-659.30808"
+ inkscape:cy="83.54417"
+ inkscape:window-width="1920"
+ inkscape:window-height="1025"
+ inkscape:window-x="0"
+ inkscape:window-y="26"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="logo" />
+ <g
+ id="logo">
+ <g
+ id="circles"
+ style="display:inline;fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943"
+ transform="translate(180)">
+ <g
+ id="g4645"
+ inkscape:export-xdpi="98.304001"
+ inkscape:export-ydpi="98.304001">
+ <ellipse
+ transform="matrix(-0.99007841,-0.140516,0.16039263,-0.98705329,0,0)"
+ ry="75.234604"
+ rx="74.764656"
+ cy="-29.611343"
+ cx="101.25517"
+ id="path4580"
+ style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.1471165;stroke-opacity:1" />
+ <g
+ transform="rotate(-180,-107.57659,26.234233)"
+ id="g4622">
+ <path
+ id="path1306-7-63-9"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="M 45.48017,110.87571 A 35.545008,38.588202 0 0 0 9.9354536,149.46424 35.545008,38.588202 0 0 0 45.48017,188.05226 35.545008,38.588202 0 0 0 81.025385,149.46424 35.545008,38.588202 0 0 0 45.48017,110.87571 Z m -0.07061,4.90892 a 31.151221,33.78691 0 0 1 0.829048,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.827519,0.0595 31.151221,33.78691 0 0 1 0.825964,0.0839 31.151221,33.78691 0 0 1 0.823425,0.10767 31.151221,33.78691 0 0 1 0.820349,0.13097 31.151221,33.78691 0 0 1 0.816775,0.15478 31.151221,33.78691 0 0 1 0.813198,0.17859 31.151221,33.78691 0 0 1 0.808083,0.20188 31.151221,33.78691 0 0 1 0.802953,0.22518 31.151221,33.78691 0 0 1 0.797339,0.24796 31.151221,33.78691 0 0 1 0.790697,0.27125 31.151221,33.78691 0 0 1 0.783536,0.29403 31.151221,33.78691 0 0 1 0.776355,0.31629 31.151221,33.78691 0 0 1 0.768177,0.33854 31.151221,33.78691 0 0 1 0.760022,0.3608 31.151221,33.78691 0 0 1 0.750783,0.38255 31.151221,33.78691 0 0 1 0.740582,0.40429 31.151221,33.78691 0 0 1 0.73085,0.42499 31.151221,33.78691 0 0 1 0.7201,0.44622 31.151221,33.78691 0 0 1 0.70885,0.46692 31.151221,33.78691 0 0 1 0.6971,0.48764 31.151221,33.78691 0 0 1 0.68484,0.50678 31.151221,33.78691 0 0 1 0.67254,0.52697 31.151221,33.78691 0 0 1 0.65872,0.54613 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.58339 31.151221,33.78691 0 0 1 0.61678,0.601 31.151221,33.78691 0 0 1 0.60196,0.61911 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65277 31.151221,33.78691 0 0 1 0.5544,0.66932 31.151221,33.78691 0 0 1 0.53751,0.68486 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71488 31.151221,33.78691 0 0 1 0.48534,0.72885 31.151221,33.78691 0 0 1 0.46798,0.74284 31.151221,33.78691 0 0 1 0.44904,0.75629 31.151221,33.78691 0 0 1 0.43065,0.76872 31.151221,33.78691 0 0 1 0.41119,0.78114 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80391 31.151221,33.78691 0 0 1 0.35239,0.81376 31.151221,33.78691 0 0 1 0.33294,0.8241 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.84171 31.151221,33.78691 0 0 1 0.27107,0.85051 31.151221,33.78691 0 0 1 0.2501,0.85775 31.151221,33.78691 0 0 1 0.2286,0.86448 31.151221,33.78691 0 0 1 0.20765,0.8707 31.151221,33.78691 0 0 1 0.18616,0.8769 31.151221,33.78691 0 0 1 0.16468,0.88157 31.151221,33.78691 0 0 1 0.14268,0.8857 31.151221,33.78691 0 0 1 0.12073,0.88985 31.151221,33.78691 0 0 1 0.0992,0.89347 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.6509 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60173 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40916 31.151221,33.78691 0 0 1 -1.19526,2.31444 31.151221,33.78691 0 0 1 -1.3589,2.20624 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64252 31.151221,33.78691 0 0 1 -2.034,1.47428 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.221695,1.11037 31.151221,33.78691 0 0 1 -2.294328,0.91832 31.151221,33.78691 0 0 1 -2.354157,0.72006 31.151221,33.78691 0 0 1 -2.399184,0.51765 31.151221,33.78691 0 0 1 -2.428831,0.31163 31.151221,33.78691 0 0 1 -2.444189,0.10457 31.151221,33.78691 0 0 1 -2.44418,-0.10457 31.151221,33.78691 0 0 1 -2.428838,-0.31163 31.151221,33.78691 0 0 1 -2.399177,-0.51765 31.151221,33.78691 0 0 1 -2.354163,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91831 31.151221,33.78691 0 0 1 -2.221196,-1.11037 31.151221,33.78691 0 0 1 -2.134249,-1.29621 31.151221,33.78691 0 0 1 -2.033489,-1.47428 31.151221,33.78691 0 0 1 -1.920973,-1.64252 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20624 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.65091 31.151221,33.78691 0 0 1 0.09615,-2.65091 31.151221,33.78691 0 0 1 0.287434,-2.63433 31.151221,33.78691 0 0 1 0.477175,-2.60174 31.151221,33.78691 0 0 1 0.663854,-2.55358 31.151221,33.78691 0 0 1 0.846946,-2.48889 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31495 31.151221,33.78691 0 0 1 1.358905,-2.20572 31.151221,33.78691 0 0 1 1.513873,-2.08356 31.151221,33.78691 0 0 1 1.660643,-1.94793 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920973,-1.64252 31.151221,33.78691 0 0 1 2.033505,-1.47376 31.151221,33.78691 0 0 1 2.13425,-1.29621 31.151221,33.78691 0 0 1 2.221195,-1.11088 31.151221,33.78691 0 0 1 2.294841,-0.9178 31.151221,33.78691 0 0 1 2.354165,-0.72058 31.151221,33.78691 0 0 1 2.399176,-0.51713 31.151221,33.78691 0 0 1 2.428838,-0.31215 31.151221,33.78691 0 0 1 2.44418,-0.10405 z"
+ clip-path="url(#clipPath20735)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-0"
+ style="opacity:1;fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0376767;stroke-linejoin:round"
+ d="m 68.010803,105.31927 a 40.722405,43.678338 0 0 0 -40.72207,43.67871 40.722405,43.678338 0 0 0 40.72207,43.67812 40.722405,43.678338 0 0 0 40.722647,-43.67812 40.722405,43.678338 0 0 0 -40.722647,-43.67871 z m -0.0809,5.55644 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.94628,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12188 35.68863,38.243712 0 0 1 0.93984,0.14824 35.68863,38.243712 0 0 1 0.93574,0.1752 35.68863,38.243712 0 0 1 0.93165,0.20214 35.68863,38.243712 0 0 1 0.92578,0.22852 35.68863,38.243712 0 0 1 0.91992,0.25488 35.68863,38.243712 0 0 1 0.91348,0.28067 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33281 35.68863,38.243712 0 0 1 0.88945,0.35801 35.68863,38.243712 0 0 1 0.880067,0.3832 35.68863,38.243712 0 0 1 0.87071,0.4084 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52851 35.68863,38.243712 0 0 1 0.79863,0.55196 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59648 35.68863,38.243712 0 0 1 0.75469,0.61817 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68027 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71953 35.68863,38.243712 0 0 1 0.65391,0.73887 35.68863,38.243712 0 0 1 0.63515,0.75762 35.68863,38.243712 0 0 1 0.61582,0.77519 35.68863,38.243712 0 0 1 0.59649,0.79219 35.68863,38.243712 0 0 1 0.57715,0.80918 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84082 35.68863,38.243712 0 0 1 0.51446,0.85606 35.68863,38.243712 0 0 1 0.493358,0.87011 35.68863,38.243712 0 0 1 0.47109,0.88418 35.68863,38.243712 0 0 1 0.44941,0.89707 35.68863,38.243712 0 0 1 0.427152,0.90996 35.68863,38.243712 0 0 1 0.40372,0.9211 35.68863,38.243712 0 0 1 0.38145,0.93281 35.68863,38.243712 0 0 1 0.35742,0.94336 35.68863,38.243712 0 0 1 0.33399,0.95273 35.68863,38.243712 0 0 1 0.31054,0.9627 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97851 35.68863,38.243712 0 0 1 0.23789,0.98555 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99785 35.68863,38.243712 0 0 1 0.16348,1.00254 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01132 35.68863,38.243712 0 0 1 0.0885,1.01368 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01777 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98243 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89043 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.173062,2.72696 35.68863,38.243712 0 0 1 -1.369338,2.61973 35.68863,38.243712 0 0 1 -1.55683,2.49726 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46719 35.68863,38.243712 0 0 1 -2.54531,1.25684 35.68863,38.243712 0 0 1 -2.628507,1.03945 35.68863,38.243712 0 0 1 -2.69706,0.81504 35.68863,38.243712 0 0 1 -2.74864,0.58593 35.68863,38.243712 0 0 1 -2.78261,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58593 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03945 35.68863,38.243712 0 0 1 -2.54473,-1.25684 35.68863,38.243712 0 0 1 -2.44512,-1.46719 35.68863,38.243712 0 0 1 -2.32968,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49726 35.68863,38.243712 0 0 1 -1.36933,-2.61973 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89043 35.68863,38.243712 0 0 1 -0.54668,-2.94492 35.68863,38.243712 0 0 1 -0.3293,-2.98243 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00058 35.68863,38.243712 0 0 1 0.3293,-2.98184 35.68863,38.243712 0 0 1 0.54668,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81719 35.68863,38.243712 0 0 1 1.17305,-2.72695 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20489 35.68863,38.243712 0 0 1 2.05782,-2.03847 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.32968,-1.66817 35.68863,38.243712 0 0 1 2.44512,-1.46718 35.68863,38.243712 0 0 1 2.54473,-1.25743 35.68863,38.243712 0 0 1 2.6291,-1.03886 35.68863,38.243712 0 0 1 2.69707,-0.81563 35.68863,38.243712 0 0 1 2.74863,-0.58535 35.68863,38.243712 0 0 1 2.78262,-0.35332 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20731)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-5"
+ style="opacity:1;fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0376767;stroke-linejoin:round"
+ d="M 45.56102,105.31927 A 40.722405,43.678338 0 0 0 4.8389507,148.99798 40.722405,43.678338 0 0 0 45.56102,192.6761 40.722405,43.678338 0 0 0 86.283657,148.99798 40.722405,43.678338 0 0 0 45.56102,105.31927 Z m -0.0809,5.55644 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.94628,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12188 35.68863,38.243712 0 0 1 0.93984,0.14824 35.68863,38.243712 0 0 1 0.93574,0.1752 35.68863,38.243712 0 0 1 0.93165,0.20214 35.68863,38.243712 0 0 1 0.92578,0.22852 35.68863,38.243712 0 0 1 0.91992,0.25488 35.68863,38.243712 0 0 1 0.91348,0.28067 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33281 35.68863,38.243712 0 0 1 0.88945,0.35801 35.68863,38.243712 0 0 1 0.880067,0.3832 35.68863,38.243712 0 0 1 0.87071,0.4084 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52851 35.68863,38.243712 0 0 1 0.79863,0.55196 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59648 35.68863,38.243712 0 0 1 0.75469,0.61817 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68027 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71953 35.68863,38.243712 0 0 1 0.65391,0.73887 35.68863,38.243712 0 0 1 0.63515,0.75762 35.68863,38.243712 0 0 1 0.61582,0.77519 35.68863,38.243712 0 0 1 0.59649,0.79219 35.68863,38.243712 0 0 1 0.57715,0.80918 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84082 35.68863,38.243712 0 0 1 0.51446,0.85606 35.68863,38.243712 0 0 1 0.49336,0.87011 35.68863,38.243712 0 0 1 0.47109,0.88418 35.68863,38.243712 0 0 1 0.44941,0.89707 35.68863,38.243712 0 0 1 0.42715,0.90996 35.68863,38.243712 0 0 1 0.40371,0.9211 35.68863,38.243712 0 0 1 0.38145,0.93281 35.68863,38.243712 0 0 1 0.35742,0.94336 35.68863,38.243712 0 0 1 0.33399,0.95273 35.68863,38.243712 0 0 1 0.31054,0.9627 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97851 35.68863,38.243712 0 0 1 0.23789,0.98555 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99785 35.68863,38.243712 0 0 1 0.16348,1.00254 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01132 35.68863,38.243712 0 0 1 0.0885,1.01368 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01778 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98243 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89043 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.17305,2.72696 35.68863,38.243712 0 0 1 -1.36934,2.61972 35.68863,38.243712 0 0 1 -1.55683,2.49727 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46719 35.68863,38.243712 0 0 1 -2.54531,1.25683 35.68863,38.243712 0 0 1 -2.628507,1.03946 35.68863,38.243712 0 0 1 -2.69706,0.81504 35.68863,38.243712 0 0 1 -2.74864,0.58593 35.68863,38.243712 0 0 1 -2.78261,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58593 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03946 35.68863,38.243712 0 0 1 -2.54473,-1.25683 35.68863,38.243712 0 0 1 -2.44512,-1.46719 35.68863,38.243712 0 0 1 -2.32968,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49727 35.68863,38.243712 0 0 1 -1.36933,-2.61972 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89043 35.68863,38.243712 0 0 1 -0.546679,-2.94492 35.68863,38.243712 0 0 1 -0.3293004,-2.98243 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00059 35.68863,38.243712 0 0 1 0.3293004,-2.98184 35.68863,38.243712 0 0 1 0.546679,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81719 35.68863,38.243712 0 0 1 1.17305,-2.72695 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20489 35.68863,38.243712 0 0 1 2.05782,-2.03847 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.32968,-1.66817 35.68863,38.243712 0 0 1 2.44512,-1.46718 35.68863,38.243712 0 0 1 2.54473,-1.25743 35.68863,38.243712 0 0 1 2.6291,-1.03886 35.68863,38.243712 0 0 1 2.69707,-0.81563 35.68863,38.243712 0 0 1 2.74863,-0.58535 35.68863,38.243712 0 0 1 2.78262,-0.35332 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20727)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-6"
+ style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0427732;stroke-linejoin:round;stroke-opacity:1"
+ d="M 68.102923,99.029256 A 46.363577,49.444797 0 0 0 21.739728,148.47447 46.363577,49.444797 0 0 0 68.102923,197.91903 46.363577,49.444797 0 0 0 114.46677,148.47447 46.363577,49.444797 0 0 0 68.102923,99.029256 Z m -0.09212,6.290014 a 40.632485,43.292687 0 0 1 1.081384,0.0153 40.632485,43.292687 0 0 1 1.080714,0.0464 40.632485,43.292687 0 0 1 1.079381,0.0763 40.632485,43.292687 0 0 1 1.077365,0.10745 40.632485,43.292687 0 0 1 1.074041,0.13797 40.632485,43.292687 0 0 1 1.070034,0.16781 40.632485,43.292687 0 0 1 1.065366,0.19832 40.632485,43.292687 0 0 1 1.060709,0.22884 40.632485,43.292687 0 0 1 1.054026,0.25869 40.632485,43.292687 0 0 1 1.047355,0.28853 40.632485,43.292687 0 0 1 1.040022,0.31772 40.632485,43.292687 0 0 1 1.031346,0.34756 40.632485,43.292687 0 0 1 1.022009,0.37675 40.632485,43.292687 0 0 1 1.01266,0.40528 40.632485,43.292687 0 0 1 1.00198,0.43379 40.632485,43.292687 0 0 1 0.99134,0.46232 40.632485,43.292687 0 0 1 0.97929,0.49017 40.632485,43.292687 0 0 1 0.96598,0.51803 40.632485,43.292687 0 0 1 0.9533,0.54457 40.632485,43.292687 0 0 1 0.93927,0.57175 40.632485,43.292687 0 0 1 0.92461,0.5983 40.632485,43.292687 0 0 1 0.90926,0.62482 40.632485,43.292687 0 0 1 0.89328,0.64936 40.632485,43.292687 0 0 1 0.87724,0.67524 40.632485,43.292687 0 0 1 0.85922,0.69977 40.632485,43.292687 0 0 1 0.8419,0.72365 40.632485,43.292687 0 0 1 0.82388,0.74754 40.632485,43.292687 0 0 1 0.80452,0.77008 40.632485,43.292687 0 0 1 0.78518,0.7933 40.632485,43.292687 0 0 1 0.7645,0.81452 40.632485,43.292687 0 0 1 0.74449,0.83642 40.632485,43.292687 0 0 1 0.723143,0.85763 40.632485,43.292687 0 0 1 0.70113,0.87754 40.632485,43.292687 0 0 1 0.67912,0.89678 40.632485,43.292687 0 0 1 0.657112,0.916 40.632485,43.292687 0 0 1 0.63307,0.93392 40.632485,43.292687 0 0 1 0.6104,0.95183 40.632485,43.292687 0 0 1 0.58573,0.96907 40.632485,43.292687 0 0 1 0.56171,0.98499 40.632485,43.292687 0 0 1 0.53634,1.00091 40.632485,43.292687 0 0 1 0.51167,1.0155 40.632485,43.292687 0 0 1 0.48631,1.0301 40.632485,43.292687 0 0 1 0.45965,1.04269 40.632485,43.292687 0 0 1 0.43428,1.05597 40.632485,43.292687 0 0 1 0.40694,1.0679 40.632485,43.292687 0 0 1 0.38025,1.07852 40.632485,43.292687 0 0 1 0.35357,1.08979 40.632485,43.292687 0 0 1 0.32622,1.09908 40.632485,43.292687 0 0 1 0.29818,1.10769 40.632485,43.292687 0 0 1 0.27085,1.11566 40.632485,43.292687 0 0 1 0.24283,1.12362 40.632485,43.292687 0 0 1 0.2148,1.12959 40.632485,43.292687 0 0 1 0.18612,1.1349 40.632485,43.292687 0 0 1 0.15745,1.1402 40.632485,43.292687 0 0 1 0.12941,1.14485 40.632485,43.292687 0 0 1 0.10078,1.14749 40.632485,43.292687 0 0 1 0.072,1.15015 40.632485,43.292687 0 0 1 0.0434,1.15148 40.632485,43.292687 0 0 1 0.014,1.15214 40.632485,43.292687 0 0 1 -0.12477,3.39672 40.632485,43.292687 0 0 1 -0.37557,3.37617 40.632485,43.292687 0 0 1 -0.6224,3.33371 40.632485,43.292687 0 0 1 -0.86591,3.27203 40.632485,43.292687 0 0 1 -1.10406,3.18911 40.632485,43.292687 0 0 1 -1.33555,3.08697 40.632485,43.292687 0 0 1 -1.55903,2.96559 40.632485,43.292687 0 0 1 -1.77249,2.82696 40.632485,43.292687 0 0 1 -1.975302,2.66909 40.632485,43.292687 0 0 1 -2.165443,2.49664 40.632485,43.292687 0 0 1 -2.34287,2.30693 40.632485,43.292687 0 0 1 -2.50564,2.10463 40.632485,43.292687 0 0 1 -2.65309,1.88906 40.632485,43.292687 0 0 1 -2.78317,1.66089 40.632485,43.292687 0 0 1 -2.89791,1.42277 40.632485,43.292687 0 0 1 -2.992636,1.17668 40.632485,43.292687 0 0 1 -3.070676,0.92264 40.632485,43.292687 0 0 1 -3.129402,0.66329 40.632485,43.292687 0 0 1 -3.168078,0.3993 40.632485,43.292687 0 0 1 -3.188104,0.13399 40.632485,43.292687 0 0 1 -3.188094,-0.13399 40.632485,43.292687 0 0 1 -3.168088,-0.3993 40.632485,43.292687 0 0 1 -3.12939,-0.6633 40.632485,43.292687 0 0 1 -3.07069,-0.92264 40.632485,43.292687 0 0 1 -2.993303,-1.17668 40.632485,43.292687 0 0 1 -2.897244,-1.42277 40.632485,43.292687 0 0 1 -2.783837,-1.66088 40.632485,43.292687 0 0 1 -2.652404,-1.88906 40.632485,43.292687 0 0 1 -2.505648,-2.10463 40.632485,43.292687 0 0 1 -2.342886,-2.30694 40.632485,43.292687 0 0 1 -2.166082,-2.49664 40.632485,43.292687 0 0 1 -1.974639,-2.66909 40.632485,43.292687 0 0 1 -1.772504,-2.82696 40.632485,43.292687 0 0 1 -1.559022,-2.96558 40.632485,43.292687 0 0 1 -1.335549,-3.08697 40.632485,43.292687 0 0 1 -1.104725,-3.18911 40.632485,43.292687 0 0 1 -0.865908,-3.27203 40.632485,43.292687 0 0 1 -0.622408,-3.33371 40.632485,43.292687 0 0 1 -0.374918,-3.37617 40.632485,43.292687 0 0 1 -0.125409,-3.39672 40.632485,43.292687 0 0 1 0.125409,-3.39673 40.632485,43.292687 0 0 1 0.374918,-3.3755 40.632485,43.292687 0 0 1 0.622408,-3.33371 40.632485,43.292687 0 0 1 0.865908,-3.27203 40.632485,43.292687 0 0 1 1.104725,-3.18912 40.632485,43.292687 0 0 1 1.335549,-3.08696 40.632485,43.292687 0 0 1 1.559022,-2.96625 40.632485,43.292687 0 0 1 1.772504,-2.8263 40.632485,43.292687 0 0 1 1.974639,-2.66975 40.632485,43.292687 0 0 1 2.166082,-2.49598 40.632485,43.292687 0 0 1 2.342886,-2.3076 40.632485,43.292687 0 0 1 2.505648,-2.10463 40.632485,43.292687 0 0 1 2.652426,-1.88839 40.632485,43.292687 0 0 1 2.783836,-1.66089 40.632485,43.292687 0 0 1 2.897245,-1.42343 40.632485,43.292687 0 0 1 2.993303,-1.17602 40.632485,43.292687 0 0 1 3.070689,-0.9233 40.632485,43.292687 0 0 1 3.12939,-0.66263 40.632485,43.292687 0 0 1 3.16809,-0.39997 40.632485,43.292687 0 0 1 3.188093,-0.13332 z"
+ clip-path="url(#clipPath20723)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-63-6"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="m 67.929953,110.87572 a 35.545008,38.588202 0 0 0 -35.544717,38.58853 35.545008,38.588202 0 0 0 35.544717,38.58801 35.545008,38.588202 0 0 0 35.545217,-38.58801 35.545008,38.588202 0 0 0 -35.545217,-38.58853 z m -0.07061,4.90891 a 31.151221,33.78691 0 0 1 0.829048,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.827519,0.0595 31.151221,33.78691 0 0 1 0.825964,0.0839 31.151221,33.78691 0 0 1 0.823425,0.10768 31.151221,33.78691 0 0 1 0.820349,0.13096 31.151221,33.78691 0 0 1 0.816775,0.15478 31.151221,33.78691 0 0 1 0.813198,0.17859 31.151221,33.78691 0 0 1 0.808083,0.20189 31.151221,33.78691 0 0 1 0.802953,0.22518 31.151221,33.78691 0 0 1 0.797339,0.24795 31.151221,33.78691 0 0 1 0.790697,0.27125 31.151221,33.78691 0 0 1 0.783536,0.29403 31.151221,33.78691 0 0 1 0.776355,0.31629 31.151221,33.78691 0 0 1 0.768177,0.33854 31.151221,33.78691 0 0 1 0.760022,0.36081 31.151221,33.78691 0 0 1 0.750783,0.38255 31.151221,33.78691 0 0 1 0.740582,0.40428 31.151221,33.78691 0 0 1 0.73085,0.425 31.151221,33.78691 0 0 1 0.7201,0.44621 31.151221,33.78691 0 0 1 0.70885,0.46693 31.151221,33.78691 0 0 1 0.6971,0.48763 31.151221,33.78691 0 0 1 0.68484,0.50678 31.151221,33.78691 0 0 1 0.67254,0.52697 31.151221,33.78691 0 0 1 0.65872,0.54613 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.5834 31.151221,33.78691 0 0 1 0.61678,0.60099 31.151221,33.78691 0 0 1 0.60196,0.61912 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65276 31.151221,33.78691 0 0 1 0.5544,0.66933 31.151221,33.78691 0 0 1 0.53751,0.68485 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71488 31.151221,33.78691 0 0 1 0.48534,0.72886 31.151221,33.78691 0 0 1 0.46798,0.74283 31.151221,33.78691 0 0 1 0.44904,0.75629 31.151221,33.78691 0 0 1 0.43065,0.76872 31.151221,33.78691 0 0 1 0.41119,0.78114 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80392 31.151221,33.78691 0 0 1 0.35239,0.81375 31.151221,33.78691 0 0 1 0.33294,0.82411 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.8417 31.151221,33.78691 0 0 1 0.27107,0.85051 31.151221,33.78691 0 0 1 0.2501,0.85775 31.151221,33.78691 0 0 1 0.2286,0.86448 31.151221,33.78691 0 0 1 0.20765,0.8707 31.151221,33.78691 0 0 1 0.18616,0.8769 31.151221,33.78691 0 0 1 0.16468,0.88157 31.151221,33.78691 0 0 1 0.14268,0.8857 31.151221,33.78691 0 0 1 0.12073,0.88986 31.151221,33.78691 0 0 1 0.0992,0.89346 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.65091 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60172 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40917 31.151221,33.78691 0 0 1 -1.19526,2.31443 31.151221,33.78691 0 0 1 -1.3589,2.20624 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64252 31.151221,33.78691 0 0 1 -2.034,1.47428 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.221695,1.11038 31.151221,33.78691 0 0 1 -2.294328,0.91831 31.151221,33.78691 0 0 1 -2.354157,0.72006 31.151221,33.78691 0 0 1 -2.399184,0.51765 31.151221,33.78691 0 0 1 -2.428831,0.31163 31.151221,33.78691 0 0 1 -2.444189,0.10457 31.151221,33.78691 0 0 1 -2.44418,-0.10457 31.151221,33.78691 0 0 1 -2.428838,-0.31162 31.151221,33.78691 0 0 1 -2.399177,-0.51765 31.151221,33.78691 0 0 1 -2.354163,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91832 31.151221,33.78691 0 0 1 -2.221196,-1.11037 31.151221,33.78691 0 0 1 -2.134249,-1.29621 31.151221,33.78691 0 0 1 -2.033489,-1.47428 31.151221,33.78691 0 0 1 -1.920973,-1.64251 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20625 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.6509 31.151221,33.78691 0 0 1 0.09615,-2.65091 31.151221,33.78691 0 0 1 0.287434,-2.63434 31.151221,33.78691 0 0 1 0.477175,-2.60173 31.151221,33.78691 0 0 1 0.663854,-2.55359 31.151221,33.78691 0 0 1 0.846946,-2.48888 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31496 31.151221,33.78691 0 0 1 1.358905,-2.20572 31.151221,33.78691 0 0 1 1.513873,-2.08356 31.151221,33.78691 0 0 1 1.660643,-1.94793 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920973,-1.64252 31.151221,33.78691 0 0 1 2.033505,-1.47376 31.151221,33.78691 0 0 1 2.13425,-1.2962 31.151221,33.78691 0 0 1 2.221195,-1.11089 31.151221,33.78691 0 0 1 2.294841,-0.9178 31.151221,33.78691 0 0 1 2.354165,-0.72057 31.151221,33.78691 0 0 1 2.399176,-0.51714 31.151221,33.78691 0 0 1 2.428838,-0.31215 31.151221,33.78691 0 0 1 2.44418,-0.10404 z"
+ clip-path="url(#clipPath20719)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306"
+ style="opacity:1;fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.0376767;stroke-linejoin:round;stroke-opacity:1"
+ d="m 90.379694,105.31927 a 40.722405,43.678338 0 0 0 -40.72207,43.67871 40.722405,43.678338 0 0 0 40.72207,43.67812 40.722405,43.678338 0 0 0 40.722646,-43.67812 40.722405,43.678338 0 0 0 -40.722646,-43.67871 z m -0.0809,5.55644 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.94628,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12188 35.68863,38.243712 0 0 1 0.93984,0.14824 35.68863,38.243712 0 0 1 0.93574,0.1752 35.68863,38.243712 0 0 1 0.93165,0.20214 35.68863,38.243712 0 0 1 0.925784,0.22852 35.68863,38.243712 0 0 1 0.91992,0.25488 35.68863,38.243712 0 0 1 0.913482,0.28067 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33281 35.68863,38.243712 0 0 1 0.88945,0.35801 35.68863,38.243712 0 0 1 0.88007,0.3832 35.68863,38.243712 0 0 1 0.87071,0.4084 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52851 35.68863,38.243712 0 0 1 0.79863,0.55196 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59648 35.68863,38.243712 0 0 1 0.75469,0.61817 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68027 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71953 35.68863,38.243712 0 0 1 0.65391,0.73887 35.68863,38.243712 0 0 1 0.63515,0.75762 35.68863,38.243712 0 0 1 0.61582,0.77519 35.68863,38.243712 0 0 1 0.59649,0.79219 35.68863,38.243712 0 0 1 0.57715,0.80918 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84082 35.68863,38.243712 0 0 1 0.51446,0.85606 35.68863,38.243712 0 0 1 0.49336,0.87011 35.68863,38.243712 0 0 1 0.47109,0.88418 35.68863,38.243712 0 0 1 0.44941,0.89707 35.68863,38.243712 0 0 1 0.42715,0.90996 35.68863,38.243712 0 0 1 0.40371,0.9211 35.68863,38.243712 0 0 1 0.38145,0.93281 35.68863,38.243712 0 0 1 0.35742,0.94336 35.68863,38.243712 0 0 1 0.33399,0.95273 35.68863,38.243712 0 0 1 0.31054,0.9627 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97851 35.68863,38.243712 0 0 1 0.23789,0.98555 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99785 35.68863,38.243712 0 0 1 0.16348,1.00254 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01132 35.68863,38.243712 0 0 1 0.0885,1.01368 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01778 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98243 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89043 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.17305,2.72696 35.68863,38.243712 0 0 1 -1.36934,2.61972 35.68863,38.243712 0 0 1 -1.55683,2.49727 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46719 35.68863,38.243712 0 0 1 -2.54531,1.25683 35.68863,38.243712 0 0 1 -2.62851,1.03946 35.68863,38.243712 0 0 1 -2.697062,0.81504 35.68863,38.243712 0 0 1 -2.748644,0.58593 35.68863,38.243712 0 0 1 -2.78261,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58593 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03946 35.68863,38.243712 0 0 1 -2.54473,-1.25683 35.68863,38.243712 0 0 1 -2.44512,-1.46719 35.68863,38.243712 0 0 1 -2.32968,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49727 35.68863,38.243712 0 0 1 -1.36933,-2.61972 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89043 35.68863,38.243712 0 0 1 -0.54668,-2.94492 35.68863,38.243712 0 0 1 -0.3293,-2.98243 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00059 35.68863,38.243712 0 0 1 0.3293,-2.98184 35.68863,38.243712 0 0 1 0.54668,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81719 35.68863,38.243712 0 0 1 1.17305,-2.72695 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20489 35.68863,38.243712 0 0 1 2.05782,-2.03847 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.32968,-1.66817 35.68863,38.243712 0 0 1 2.44512,-1.46718 35.68863,38.243712 0 0 1 2.54473,-1.25743 35.68863,38.243712 0 0 1 2.6291,-1.03886 35.68863,38.243712 0 0 1 2.69707,-0.81563 35.68863,38.243712 0 0 1 2.74863,-0.58535 35.68863,38.243712 0 0 1 2.78262,-0.35332 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20715)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7"
+ style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0427732;stroke-linejoin:round;stroke-opacity:1"
+ d="M 90.531339,99.785944 A 46.363577,49.444797 0 0 0 44.168144,149.23116 46.363577,49.444797 0 0 0 90.531339,198.67572 46.363577,49.444797 0 0 0 136.89519,149.23116 46.363577,49.444797 0 0 0 90.531339,99.785944 Z m -0.09212,6.290016 a 40.632485,43.292687 0 0 1 1.081384,0.0153 40.632485,43.292687 0 0 1 1.080714,0.0464 40.632485,43.292687 0 0 1 1.079381,0.0763 40.632485,43.292687 0 0 1 1.077365,0.10745 40.632485,43.292687 0 0 1 1.074041,0.13797 40.632485,43.292687 0 0 1 1.070034,0.16781 40.632485,43.292687 0 0 1 1.065366,0.19832 40.632485,43.292687 0 0 1 1.060704,0.22884 40.632485,43.292687 0 0 1 1.054032,0.25868 40.632485,43.292687 0 0 1 1.04736,0.28854 40.632485,43.292687 0 0 1 1.04002,0.31772 40.632485,43.292687 0 0 1 1.03135,0.34756 40.632485,43.292687 0 0 1 1.02201,0.37675 40.632485,43.292687 0 0 1 1.01266,0.40527 40.632485,43.292687 0 0 1 1.00198,0.4338 40.632485,43.292687 0 0 1 0.99134,0.46231 40.632485,43.292687 0 0 1 0.97929,0.49018 40.632485,43.292687 0 0 1 0.96598,0.51803 40.632485,43.292687 0 0 1 0.9533,0.54456 40.632485,43.292687 0 0 1 0.93927,0.57176 40.632485,43.292687 0 0 1 0.92461,0.59829 40.632485,43.292687 0 0 1 0.90926,0.62483 40.632485,43.292687 0 0 1 0.89328,0.64936 40.632485,43.292687 0 0 1 0.87724,0.67523 40.632485,43.292687 0 0 1 0.85922,0.69978 40.632485,43.292687 0 0 1 0.8419,0.72365 40.632485,43.292687 0 0 1 0.82388,0.74753 40.632485,43.292687 0 0 1 0.80452,0.77009 40.632485,43.292687 0 0 1 0.78518,0.7933 40.632485,43.292687 0 0 1 0.7645,0.81452 40.632485,43.292687 0 0 1 0.74449,0.83642 40.632485,43.292687 0 0 1 0.72314,0.85763 40.632485,43.292687 0 0 1 0.70113,0.87754 40.632485,43.292687 0 0 1 0.67912,0.89677 40.632485,43.292687 0 0 1 0.65711,0.91601 40.632485,43.292687 0 0 1 0.63307,0.93392 40.632485,43.292687 0 0 1 0.6104,0.95183 40.632485,43.292687 0 0 1 0.58573,0.96907 40.632485,43.292687 0 0 1 0.56171,0.98499 40.632485,43.292687 0 0 1 0.53634,1.00091 40.632485,43.292687 0 0 1 0.51167,1.0155 40.632485,43.292687 0 0 1 0.48631,1.0301 40.632485,43.292687 0 0 1 0.45965,1.04269 40.632485,43.292687 0 0 1 0.43428,1.05597 40.632485,43.292687 0 0 1 0.40694,1.0679 40.632485,43.292687 0 0 1 0.38025,1.07851 40.632485,43.292687 0 0 1 0.35357,1.0898 40.632485,43.292687 0 0 1 0.32622,1.09908 40.632485,43.292687 0 0 1 0.29818,1.10769 40.632485,43.292687 0 0 1 0.27085,1.11566 40.632485,43.292687 0 0 1 0.24283,1.12362 40.632485,43.292687 0 0 1 0.2148,1.12959 40.632485,43.292687 0 0 1 0.18612,1.1349 40.632485,43.292687 0 0 1 0.15745,1.1402 40.632485,43.292687 0 0 1 0.12941,1.14484 40.632485,43.292687 0 0 1 0.10078,1.1475 40.632485,43.292687 0 0 1 0.072,1.15015 40.632485,43.292687 0 0 1 0.0434,1.15148 40.632485,43.292687 0 0 1 0.014,1.15214 40.632485,43.292687 0 0 1 -0.12477,3.39673 40.632485,43.292687 0 0 1 -0.37557,3.37616 40.632485,43.292687 0 0 1 -0.6224,3.33372 40.632485,43.292687 0 0 1 -0.86591,3.27203 40.632485,43.292687 0 0 1 -1.10406,3.18911 40.632485,43.292687 0 0 1 -1.33555,3.08697 40.632485,43.292687 0 0 1 -1.55903,2.96558 40.632485,43.292687 0 0 1 -1.77249,2.82696 40.632485,43.292687 0 0 1 -1.9753,2.66909 40.632485,43.292687 0 0 1 -2.16544,2.49664 40.632485,43.292687 0 0 1 -2.34287,2.30693 40.632485,43.292687 0 0 1 -2.50564,2.10464 40.632485,43.292687 0 0 1 -2.65309,1.88906 40.632485,43.292687 0 0 1 -2.78317,1.66088 40.632485,43.292687 0 0 1 -2.89791,1.42277 40.632485,43.292687 0 0 1 -2.99264,1.17668 40.632485,43.292687 0 0 1 -3.070682,0.92264 40.632485,43.292687 0 0 1 -3.129401,0.6633 40.632485,43.292687 0 0 1 -3.168078,0.3993 40.632485,43.292687 0 0 1 -3.188104,0.13399 40.632485,43.292687 0 0 1 -3.188094,-0.13399 40.632485,43.292687 0 0 1 -3.168088,-0.3993 40.632485,43.292687 0 0 1 -3.12939,-0.6633 40.632485,43.292687 0 0 1 -3.07069,-0.92264 40.632485,43.292687 0 0 1 -2.993303,-1.17668 40.632485,43.292687 0 0 1 -2.897244,-1.42277 40.632485,43.292687 0 0 1 -2.783837,-1.66088 40.632485,43.292687 0 0 1 -2.652404,-1.88906 40.632485,43.292687 0 0 1 -2.505648,-2.10464 40.632485,43.292687 0 0 1 -2.342886,-2.30693 40.632485,43.292687 0 0 1 -2.166082,-2.49664 40.632485,43.292687 0 0 1 -1.974639,-2.66909 40.632485,43.292687 0 0 1 -1.772504,-2.82696 40.632485,43.292687 0 0 1 -1.559022,-2.96558 40.632485,43.292687 0 0 1 -1.335549,-3.08697 40.632485,43.292687 0 0 1 -1.104725,-3.18911 40.632485,43.292687 0 0 1 -0.865908,-3.27203 40.632485,43.292687 0 0 1 -0.622408,-3.33372 40.632485,43.292687 0 0 1 -0.374918,-3.37616 40.632485,43.292687 0 0 1 -0.125409,-3.39673 40.632485,43.292687 0 0 1 0.125409,-3.39673 40.632485,43.292687 0 0 1 0.374918,-3.3755 40.632485,43.292687 0 0 1 0.622408,-3.33371 40.632485,43.292687 0 0 1 0.865908,-3.27203 40.632485,43.292687 0 0 1 1.104725,-3.18911 40.632485,43.292687 0 0 1 1.335549,-3.08697 40.632485,43.292687 0 0 1 1.559022,-2.96625 40.632485,43.292687 0 0 1 1.772504,-2.82629 40.632485,43.292687 0 0 1 1.974639,-2.66976 40.632485,43.292687 0 0 1 2.166082,-2.49597 40.632485,43.292687 0 0 1 2.342886,-2.3076 40.632485,43.292687 0 0 1 2.505648,-2.10463 40.632485,43.292687 0 0 1 2.652426,-1.8884 40.632485,43.292687 0 0 1 2.783836,-1.66089 40.632485,43.292687 0 0 1 2.897245,-1.42343 40.632485,43.292687 0 0 1 2.993303,-1.17601 40.632485,43.292687 0 0 1 3.070689,-0.92331 40.632485,43.292687 0 0 1 3.12939,-0.66263 40.632485,43.292687 0 0 1 3.16809,-0.39997 40.632485,43.292687 0 0 1 3.188093,-0.13332 z"
+ clip-path="url(#clipPath20711)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-63"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="m 90.144156,110.56416 a 35.545008,38.588202 0 0 0 -35.544717,38.58853 35.545008,38.588202 0 0 0 35.544717,38.58802 35.545008,38.588202 0 0 0 35.545224,-38.58802 35.545008,38.588202 0 0 0 -35.545224,-38.58853 z m -0.07061,4.90892 a 31.151221,33.78691 0 0 1 0.829048,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.827519,0.0595 31.151221,33.78691 0 0 1 0.825964,0.0839 31.151221,33.78691 0 0 1 0.823425,0.10767 31.151221,33.78691 0 0 1 0.820349,0.13097 31.151221,33.78691 0 0 1 0.816775,0.15478 31.151221,33.78691 0 0 1 0.813198,0.17859 31.151221,33.78691 0 0 1 0.808083,0.20188 31.151221,33.78691 0 0 1 0.802953,0.22518 31.151221,33.78691 0 0 1 0.797338,0.24796 31.151221,33.78691 0 0 1 0.7907,0.27125 31.151221,33.78691 0 0 1 0.783542,0.29403 31.151221,33.78691 0 0 1 0.77635,0.31629 31.151221,33.78691 0 0 1 0.76818,0.33854 31.151221,33.78691 0 0 1 0.76002,0.3608 31.151221,33.78691 0 0 1 0.75079,0.38255 31.151221,33.78691 0 0 1 0.74058,0.40429 31.151221,33.78691 0 0 1 0.73085,0.42499 31.151221,33.78691 0 0 1 0.7201,0.44622 31.151221,33.78691 0 0 1 0.70885,0.46692 31.151221,33.78691 0 0 1 0.6971,0.48764 31.151221,33.78691 0 0 1 0.68484,0.50678 31.151221,33.78691 0 0 1 0.67254,0.52697 31.151221,33.78691 0 0 1 0.65872,0.54613 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.58339 31.151221,33.78691 0 0 1 0.61678,0.601 31.151221,33.78691 0 0 1 0.60196,0.61911 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65277 31.151221,33.78691 0 0 1 0.5544,0.66932 31.151221,33.78691 0 0 1 0.53751,0.68486 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71488 31.151221,33.78691 0 0 1 0.48534,0.72885 31.151221,33.78691 0 0 1 0.46798,0.74284 31.151221,33.78691 0 0 1 0.44904,0.75629 31.151221,33.78691 0 0 1 0.43065,0.76872 31.151221,33.78691 0 0 1 0.41119,0.78114 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80391 31.151221,33.78691 0 0 1 0.35239,0.81376 31.151221,33.78691 0 0 1 0.33294,0.8241 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.84171 31.151221,33.78691 0 0 1 0.27107,0.85051 31.151221,33.78691 0 0 1 0.2501,0.85775 31.151221,33.78691 0 0 1 0.2286,0.86448 31.151221,33.78691 0 0 1 0.20765,0.8707 31.151221,33.78691 0 0 1 0.18616,0.8769 31.151221,33.78691 0 0 1 0.16468,0.88157 31.151221,33.78691 0 0 1 0.14268,0.8857 31.151221,33.78691 0 0 1 0.12073,0.88985 31.151221,33.78691 0 0 1 0.0992,0.89347 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.6509 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60173 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40916 31.151221,33.78691 0 0 1 -1.19526,2.31444 31.151221,33.78691 0 0 1 -1.3589,2.20624 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64252 31.151221,33.78691 0 0 1 -2.034,1.47428 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.2217,1.11037 31.151221,33.78691 0 0 1 -2.294332,0.91832 31.151221,33.78691 0 0 1 -2.354157,0.72006 31.151221,33.78691 0 0 1 -2.399184,0.51765 31.151221,33.78691 0 0 1 -2.428831,0.31163 31.151221,33.78691 0 0 1 -2.444189,0.10457 31.151221,33.78691 0 0 1 -2.44418,-0.10457 31.151221,33.78691 0 0 1 -2.428838,-0.31163 31.151221,33.78691 0 0 1 -2.399177,-0.51765 31.151221,33.78691 0 0 1 -2.354163,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91831 31.151221,33.78691 0 0 1 -2.221196,-1.11037 31.151221,33.78691 0 0 1 -2.134249,-1.29621 31.151221,33.78691 0 0 1 -2.033489,-1.47428 31.151221,33.78691 0 0 1 -1.920973,-1.64252 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20624 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.65091 31.151221,33.78691 0 0 1 0.09615,-2.65091 31.151221,33.78691 0 0 1 0.287434,-2.63433 31.151221,33.78691 0 0 1 0.477175,-2.60174 31.151221,33.78691 0 0 1 0.663854,-2.55358 31.151221,33.78691 0 0 1 0.846946,-2.48889 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31495 31.151221,33.78691 0 0 1 1.358905,-2.20572 31.151221,33.78691 0 0 1 1.513873,-2.08356 31.151221,33.78691 0 0 1 1.660643,-1.94793 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920973,-1.64252 31.151221,33.78691 0 0 1 2.033505,-1.47376 31.151221,33.78691 0 0 1 2.13425,-1.29621 31.151221,33.78691 0 0 1 2.221195,-1.11088 31.151221,33.78691 0 0 1 2.294841,-0.9178 31.151221,33.78691 0 0 1 2.354165,-0.72058 31.151221,33.78691 0 0 1 2.399176,-0.51713 31.151221,33.78691 0 0 1 2.428838,-0.31215 31.151221,33.78691 0 0 1 2.44418,-0.10405 z"
+ clip-path="url(#clipPath20707)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ d="m 119.20127,221.87113 c 15.58969,0 29.12922,9.40117 35.96102,23.20181 h -5.81736 c -6.31922,-10.6997 -17.45681,-17.80491 -30.14366,-17.80491 -19.690574,0 -35.652879,17.112 -35.652879,38.22056 0,10.3318 3.825597,19.70468 10.03957,26.58295 -1.342357,1.12091 -2.771532,2.1279 -4.275488,3.00675 -6.701874,-7.77494 -10.798502,-18.16843 -10.798502,-29.5897 0,-24.08921 18.216325,-43.61746 40.687299,-43.61746 z m 35.852,64.25471 c -6.86645,13.68013 -20.34561,22.98022 -35.852,22.98022 -1.0527,0 -2.0961,-0.0429 -3.12869,-0.12703 3.0522,-1.56117 5.91359,-3.48039 8.53831,-5.70731 10.32096,-1.68438 19.18598,-8.11363 24.60181,-17.14588 z"
+ id="path2350-0"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943"
+ clip-path="url(#clipPath20703)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ d="m 96.751486,221.87113 c 1.052607,0 2.095998,0.0429 3.128684,0.12706 -3.052192,1.56117 -5.913678,3.48036 -8.538403,5.70731 -17.123111,2.7943 -30.243159,18.646 -30.243159,37.78309 0,14.26457 7.29059,26.70203 18.093843,33.26893 -1.593656,0.26719 -3.226966,0.40695 -4.890748,0.40695 -1.239545,0 -2.46151,-0.0795 -3.663522,-0.22937 -8.907938,-8.00114 -14.573991,-20.01353 -14.573991,-33.44651 0,-24.08921 18.216324,-43.61746 40.687296,-43.61746 z m 5.409714,81.40059 c 10.32112,-1.68438 19.18608,-8.11394 24.60197,-17.14636 h 5.84051 c -6.86635,13.68031 -20.34554,22.9807 -35.852194,22.9807 -1.052703,0 -2.095999,-0.0429 -3.128684,-0.12703 3.052002,-1.56137 5.913836,-3.48022 8.538398,-5.70731 z m 24.7338,-58.19878 c -3.13939,-5.31472 -7.46755,-9.74275 -12.58451,-12.85327 1.59365,-0.26719 3.2269,-0.40695 4.89078,-0.40695 1.23945,0 2.46151,0.0795 3.66352,0.22937 4.01602,3.60724 7.3732,8.03011 9.84905,13.03085 z"
+ id="path2352-7"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943"
+ clip-path="url(#clipPath20699)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ d="m 74.301703,221.87113 c 1.064296,0 2.118804,0.0444 3.162607,0.13022 -3.046523,1.55896 -5.903162,3.47451 -8.52358,5.69681 -17.146992,2.77237 -30.291903,18.63524 -30.291903,37.79043 0,21.10857 15.962401,38.22057 35.652876,38.22057 12.599746,0 23.672446,-7.00705 30.013747,-17.5838 h 5.83852 c -6.86636,13.68031 -20.345616,22.9807 -35.852267,22.9807 -22.470907,0 -40.6872,-19.52825 -40.6872,-43.61747 0,-24.08921 18.216293,-43.61746 40.6872,-43.61746 z m 30.142787,23.20181 c -1.31192,-2.22057 -2.83098,-4.28705 -4.528878,-6.16651 1.342448,-1.12094 2.771378,-2.12838 4.275138,-3.00723 2.37299,2.75301 4.41888,5.83464 6.07249,9.17374 z"
+ id="path2354-0"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943"
+ clip-path="url(#clipPath20695)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-9"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0376767;stroke-linejoin:round;stroke-opacity:1"
+ d="m 119.12038,221.87113 a 40.722405,43.678338 0 0 0 -40.722073,43.67871 40.722405,43.678338 0 0 0 40.722073,43.67813 40.722405,43.678338 0 0 0 40.72263,-43.67813 40.722405,43.678338 0 0 0 -40.72263,-43.67871 z m -0.0809,5.55645 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.94628,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12187 35.68863,38.243712 0 0 1 0.93984,0.14824 35.68863,38.243712 0 0 1 0.93574,0.1752 35.68863,38.243712 0 0 1 0.93165,0.20215 35.68863,38.243712 0 0 1 0.92578,0.22851 35.68863,38.243712 0 0 1 0.91992,0.25489 35.68863,38.243712 0 0 1 0.91348,0.28066 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33281 35.68863,38.243712 0 0 1 0.88945,0.35801 35.68863,38.243712 0 0 1 0.88006,0.38321 35.68863,38.243712 0 0 1 0.87071,0.40839 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52852 35.68863,38.243712 0 0 1 0.79863,0.55195 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59649 35.68863,38.243712 0 0 1 0.75469,0.61816 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68027 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71954 35.68863,38.243712 0 0 1 0.65391,0.73886 35.68863,38.243712 0 0 1 0.63515,0.75762 35.68863,38.243712 0 0 1 0.61582,0.7752 35.68863,38.243712 0 0 1 0.59649,0.79218 35.68863,38.243712 0 0 1 0.57715,0.80918 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84082 35.68863,38.243712 0 0 1 0.51446,0.85606 35.68863,38.243712 0 0 1 0.49336,0.87012 35.68863,38.243712 0 0 1 0.47109,0.88417 35.68863,38.243712 0 0 1 0.44941,0.89708 35.68863,38.243712 0 0 1 0.42715,0.90996 35.68863,38.243712 0 0 1 0.40371,0.92109 35.68863,38.243712 0 0 1 0.38145,0.93281 35.68863,38.243712 0 0 1 0.35742,0.94336 35.68863,38.243712 0 0 1 0.33399,0.95274 35.68863,38.243712 0 0 1 0.31054,0.96269 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97852 35.68863,38.243712 0 0 1 0.23789,0.98554 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99785 35.68863,38.243712 0 0 1 0.16348,1.00254 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01133 35.68863,38.243712 0 0 1 0.0885,1.01367 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01778 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98242 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89044 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.17305,2.72696 35.68863,38.243712 0 0 1 -1.36934,2.61972 35.68863,38.243712 0 0 1 -1.55683,2.49727 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46718 35.68863,38.243712 0 0 1 -2.54531,1.25684 35.68863,38.243712 0 0 1 -2.6285,1.03945 35.68863,38.243712 0 0 1 -2.69706,0.81504 35.68863,38.243712 0 0 1 -2.74864,0.58594 35.68863,38.243712 0 0 1 -2.78261,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58594 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03945 35.68863,38.243712 0 0 1 -2.54473,-1.25684 35.68863,38.243712 0 0 1 -2.44512,-1.46718 35.68863,38.243712 0 0 1 -2.329683,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49727 35.68863,38.243712 0 0 1 -1.36933,-2.61972 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89044 35.68863,38.243712 0 0 1 -0.54668,-2.94492 35.68863,38.243712 0 0 1 -0.3293,-2.98242 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00059 35.68863,38.243712 0 0 1 0.3293,-2.98184 35.68863,38.243712 0 0 1 0.54668,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81719 35.68863,38.243712 0 0 1 1.17305,-2.72695 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20488 35.68863,38.243712 0 0 1 2.05782,-2.03848 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.329683,-1.66816 35.68863,38.243712 0 0 1 2.44512,-1.46719 35.68863,38.243712 0 0 1 2.54473,-1.25742 35.68863,38.243712 0 0 1 2.6291,-1.03887 35.68863,38.243712 0 0 1 2.69707,-0.81562 35.68863,38.243712 0 0 1 2.74863,-0.58536 35.68863,38.243712 0 0 1 2.78262,-0.35332 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20671)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-63-4"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="m 118.88484,227.11603 a 35.545008,38.588202 0 0 0 -35.544718,38.58853 35.545008,38.588202 0 0 0 35.544718,38.58802 35.545008,38.588202 0 0 0 35.54521,-38.58802 35.545008,38.588202 0 0 0 -35.54521,-38.58853 z m -0.0706,4.90891 a 31.151221,33.78691 0 0 1 0.82905,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.82752,0.0595 31.151221,33.78691 0 0 1 0.82596,0.0839 31.151221,33.78691 0 0 1 0.82342,0.10768 31.151221,33.78691 0 0 1 0.82035,0.13096 31.151221,33.78691 0 0 1 0.81678,0.15478 31.151221,33.78691 0 0 1 0.8132,0.17859 31.151221,33.78691 0 0 1 0.80808,0.20189 31.151221,33.78691 0 0 1 0.80295,0.22518 31.151221,33.78691 0 0 1 0.79734,0.24795 31.151221,33.78691 0 0 1 0.7907,0.27125 31.151221,33.78691 0 0 1 0.78353,0.29403 31.151221,33.78691 0 0 1 0.77636,0.31629 31.151221,33.78691 0 0 1 0.76818,0.33854 31.151221,33.78691 0 0 1 0.76002,0.36081 31.151221,33.78691 0 0 1 0.75078,0.38255 31.151221,33.78691 0 0 1 0.74058,0.40428 31.151221,33.78691 0 0 1 0.73085,0.425 31.151221,33.78691 0 0 1 0.7201,0.44622 31.151221,33.78691 0 0 1 0.70885,0.46692 31.151221,33.78691 0 0 1 0.6971,0.48763 31.151221,33.78691 0 0 1 0.68484,0.50678 31.151221,33.78691 0 0 1 0.67254,0.52698 31.151221,33.78691 0 0 1 0.65872,0.54612 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.5834 31.151221,33.78691 0 0 1 0.61678,0.60099 31.151221,33.78691 0 0 1 0.60196,0.61912 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65276 31.151221,33.78691 0 0 1 0.5544,0.66933 31.151221,33.78691 0 0 1 0.53751,0.68485 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71488 31.151221,33.78691 0 0 1 0.48534,0.72886 31.151221,33.78691 0 0 1 0.46798,0.74284 31.151221,33.78691 0 0 1 0.44904,0.75629 31.151221,33.78691 0 0 1 0.43065,0.76871 31.151221,33.78691 0 0 1 0.41119,0.78114 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80392 31.151221,33.78691 0 0 1 0.35239,0.81375 31.151221,33.78691 0 0 1 0.33294,0.82411 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.8417 31.151221,33.78691 0 0 1 0.27107,0.85051 31.151221,33.78691 0 0 1 0.2501,0.85775 31.151221,33.78691 0 0 1 0.2286,0.86448 31.151221,33.78691 0 0 1 0.20765,0.8707 31.151221,33.78691 0 0 1 0.18616,0.87691 31.151221,33.78691 0 0 1 0.16468,0.88156 31.151221,33.78691 0 0 1 0.14268,0.88571 31.151221,33.78691 0 0 1 0.12073,0.88985 31.151221,33.78691 0 0 1 0.0992,0.89347 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.6509 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60173 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40916 31.151221,33.78691 0 0 1 -1.19526,2.31443 31.151221,33.78691 0 0 1 -1.3589,2.20625 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64251 31.151221,33.78691 0 0 1 -2.034,1.47429 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.22169,1.11037 31.151221,33.78691 0 0 1 -2.29433,0.91832 31.151221,33.78691 0 0 1 -2.35416,0.72006 31.151221,33.78691 0 0 1 -2.39918,0.51765 31.151221,33.78691 0 0 1 -2.42883,0.31163 31.151221,33.78691 0 0 1 -2.44419,0.10456 31.151221,33.78691 0 0 1 -2.44418,-0.10456 31.151221,33.78691 0 0 1 -2.42884,-0.31163 31.151221,33.78691 0 0 1 -2.39918,-0.51765 31.151221,33.78691 0 0 1 -2.35416,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91831 31.151221,33.78691 0 0 1 -2.22119,-1.11038 31.151221,33.78691 0 0 1 -2.13425,-1.2962 31.151221,33.78691 0 0 1 -2.03349,-1.47428 31.151221,33.78691 0 0 1 -1.920975,-1.64252 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20624 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.65091 31.151221,33.78691 0 0 1 0.09615,-2.65091 31.151221,33.78691 0 0 1 0.287434,-2.63434 31.151221,33.78691 0 0 1 0.477175,-2.60173 31.151221,33.78691 0 0 1 0.663854,-2.55359 31.151221,33.78691 0 0 1 0.846946,-2.48888 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31495 31.151221,33.78691 0 0 1 1.358905,-2.20573 31.151221,33.78691 0 0 1 1.513873,-2.08355 31.151221,33.78691 0 0 1 1.660643,-1.94794 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920975,-1.64251 31.151221,33.78691 0 0 1 2.0335,-1.47377 31.151221,33.78691 0 0 1 2.13425,-1.2962 31.151221,33.78691 0 0 1 2.2212,-1.11089 31.151221,33.78691 0 0 1 2.29484,-0.9178 31.151221,33.78691 0 0 1 2.35416,-0.72057 31.151221,33.78691 0 0 1 2.39918,-0.51714 31.151221,33.78691 0 0 1 2.42884,-0.31215 31.151221,33.78691 0 0 1 2.44418,-0.10404 z"
+ clip-path="url(#clipPath20663)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-0-7"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0376767;stroke-linejoin:round"
+ d="m 96.751486,221.87113 a 40.722405,43.678338 0 0 0 -40.72207,43.67871 40.722405,43.678338 0 0 0 40.72207,43.67813 40.722405,43.678338 0 0 0 40.722634,-43.67813 40.722405,43.678338 0 0 0 -40.722634,-43.67871 z m -0.0809,5.55645 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.946284,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12187 35.68863,38.243712 0 0 1 0.93984,0.14825 35.68863,38.243712 0 0 1 0.93574,0.17519 35.68863,38.243712 0 0 1 0.93165,0.20215 35.68863,38.243712 0 0 1 0.92578,0.22851 35.68863,38.243712 0 0 1 0.91992,0.25489 35.68863,38.243712 0 0 1 0.91348,0.28066 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33282 35.68863,38.243712 0 0 1 0.88945,0.358 35.68863,38.243712 0 0 1 0.88006,0.38321 35.68863,38.243712 0 0 1 0.87071,0.40839 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52852 35.68863,38.243712 0 0 1 0.79863,0.55195 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59649 35.68863,38.243712 0 0 1 0.75469,0.61816 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68028 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71953 35.68863,38.243712 0 0 1 0.65391,0.73887 35.68863,38.243712 0 0 1 0.63515,0.75761 35.68863,38.243712 0 0 1 0.61582,0.7752 35.68863,38.243712 0 0 1 0.59649,0.79219 35.68863,38.243712 0 0 1 0.57715,0.80917 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84083 35.68863,38.243712 0 0 1 0.51446,0.85605 35.68863,38.243712 0 0 1 0.49336,0.87012 35.68863,38.243712 0 0 1 0.47109,0.88418 35.68863,38.243712 0 0 1 0.44941,0.89707 35.68863,38.243712 0 0 1 0.42715,0.90996 35.68863,38.243712 0 0 1 0.40371,0.92109 35.68863,38.243712 0 0 1 0.38145,0.93282 35.68863,38.243712 0 0 1 0.35742,0.94335 35.68863,38.243712 0 0 1 0.33399,0.95274 35.68863,38.243712 0 0 1 0.31054,0.96269 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97852 35.68863,38.243712 0 0 1 0.23789,0.98554 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99786 35.68863,38.243712 0 0 1 0.16348,1.00253 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01133 35.68863,38.243712 0 0 1 0.0885,1.01367 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01778 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98243 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89043 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.17305,2.72696 35.68863,38.243712 0 0 1 -1.36934,2.61972 35.68863,38.243712 0 0 1 -1.55683,2.49727 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46719 35.68863,38.243712 0 0 1 -2.54531,1.25683 35.68863,38.243712 0 0 1 -2.6285,1.03946 35.68863,38.243712 0 0 1 -2.69706,0.81504 35.68863,38.243712 0 0 1 -2.74864,0.58593 35.68863,38.243712 0 0 1 -2.782614,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58593 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03946 35.68863,38.243712 0 0 1 -2.54473,-1.25683 35.68863,38.243712 0 0 1 -2.44512,-1.46719 35.68863,38.243712 0 0 1 -2.32968,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49727 35.68863,38.243712 0 0 1 -1.36933,-2.61972 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89043 35.68863,38.243712 0 0 1 -0.54668,-2.94492 35.68863,38.243712 0 0 1 -0.3293,-2.98243 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00059 35.68863,38.243712 0 0 1 0.3293,-2.98184 35.68863,38.243712 0 0 1 0.54668,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81718 35.68863,38.243712 0 0 1 1.17305,-2.72696 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20488 35.68863,38.243712 0 0 1 2.05782,-2.03848 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.32968,-1.66816 35.68863,38.243712 0 0 1 2.44512,-1.46719 35.68863,38.243712 0 0 1 2.54473,-1.25742 35.68863,38.243712 0 0 1 2.6291,-1.03887 35.68863,38.243712 0 0 1 2.69707,-0.81562 35.68863,38.243712 0 0 1 2.74863,-0.58535 35.68863,38.243712 0 0 1 2.78262,-0.35333 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20687)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-63-9-8"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="m 74.220853,227.42758 a 35.545008,38.588202 0 0 0 -35.544717,38.58853 35.545008,38.588202 0 0 0 35.544717,38.58802 35.545008,38.588202 0 0 0 35.545217,-38.58802 35.545008,38.588202 0 0 0 -35.545217,-38.58853 z m -0.07061,4.90891 a 31.151221,33.78691 0 0 1 0.829048,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.827519,0.0595 31.151221,33.78691 0 0 1 0.825964,0.0839 31.151221,33.78691 0 0 1 0.823425,0.10768 31.151221,33.78691 0 0 1 0.820349,0.13096 31.151221,33.78691 0 0 1 0.816775,0.15478 31.151221,33.78691 0 0 1 0.813198,0.17859 31.151221,33.78691 0 0 1 0.808083,0.20189 31.151221,33.78691 0 0 1 0.802953,0.22518 31.151221,33.78691 0 0 1 0.797339,0.24795 31.151221,33.78691 0 0 1 0.790697,0.27125 31.151221,33.78691 0 0 1 0.783536,0.29403 31.151221,33.78691 0 0 1 0.776355,0.31629 31.151221,33.78691 0 0 1 0.768177,0.33854 31.151221,33.78691 0 0 1 0.760022,0.36081 31.151221,33.78691 0 0 1 0.750783,0.38255 31.151221,33.78691 0 0 1 0.740582,0.40428 31.151221,33.78691 0 0 1 0.73085,0.425 31.151221,33.78691 0 0 1 0.7201,0.44622 31.151221,33.78691 0 0 1 0.70885,0.46692 31.151221,33.78691 0 0 1 0.6971,0.48763 31.151221,33.78691 0 0 1 0.68484,0.50678 31.151221,33.78691 0 0 1 0.67254,0.52698 31.151221,33.78691 0 0 1 0.65872,0.54612 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.5834 31.151221,33.78691 0 0 1 0.61678,0.60099 31.151221,33.78691 0 0 1 0.60196,0.61912 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65276 31.151221,33.78691 0 0 1 0.5544,0.66933 31.151221,33.78691 0 0 1 0.53751,0.68485 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71488 31.151221,33.78691 0 0 1 0.48534,0.72886 31.151221,33.78691 0 0 1 0.467982,0.74283 31.151221,33.78691 0 0 1 0.44904,0.7563 31.151221,33.78691 0 0 1 0.43065,0.76871 31.151221,33.78691 0 0 1 0.41119,0.78114 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80392 31.151221,33.78691 0 0 1 0.35239,0.81375 31.151221,33.78691 0 0 1 0.33294,0.82411 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.8417 31.151221,33.78691 0 0 1 0.27107,0.85051 31.151221,33.78691 0 0 1 0.2501,0.85776 31.151221,33.78691 0 0 1 0.2286,0.86447 31.151221,33.78691 0 0 1 0.20765,0.8707 31.151221,33.78691 0 0 1 0.18616,0.87691 31.151221,33.78691 0 0 1 0.16468,0.88156 31.151221,33.78691 0 0 1 0.14268,0.88571 31.151221,33.78691 0 0 1 0.12073,0.88985 31.151221,33.78691 0 0 1 0.0992,0.89347 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.6509 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60173 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40916 31.151221,33.78691 0 0 1 -1.19526,2.31443 31.151221,33.78691 0 0 1 -1.358902,2.20625 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64252 31.151221,33.78691 0 0 1 -2.034,1.47428 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.221695,1.11037 31.151221,33.78691 0 0 1 -2.294328,0.91832 31.151221,33.78691 0 0 1 -2.354157,0.72006 31.151221,33.78691 0 0 1 -2.399184,0.51765 31.151221,33.78691 0 0 1 -2.428831,0.31163 31.151221,33.78691 0 0 1 -2.444189,0.10456 31.151221,33.78691 0 0 1 -2.44418,-0.10456 31.151221,33.78691 0 0 1 -2.428838,-0.31163 31.151221,33.78691 0 0 1 -2.399177,-0.51765 31.151221,33.78691 0 0 1 -2.354163,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91831 31.151221,33.78691 0 0 1 -2.221196,-1.11038 31.151221,33.78691 0 0 1 -2.134249,-1.2962 31.151221,33.78691 0 0 1 -2.033489,-1.47428 31.151221,33.78691 0 0 1 -1.920973,-1.64252 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20624 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.65091 31.151221,33.78691 0 0 1 0.09615,-2.65091 31.151221,33.78691 0 0 1 0.287434,-2.63434 31.151221,33.78691 0 0 1 0.477175,-2.60173 31.151221,33.78691 0 0 1 0.663854,-2.55359 31.151221,33.78691 0 0 1 0.846946,-2.48888 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31495 31.151221,33.78691 0 0 1 1.358905,-2.20573 31.151221,33.78691 0 0 1 1.513873,-2.08355 31.151221,33.78691 0 0 1 1.660643,-1.94794 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920973,-1.64251 31.151221,33.78691 0 0 1 2.033505,-1.47376 31.151221,33.78691 0 0 1 2.13425,-1.29621 31.151221,33.78691 0 0 1 2.221195,-1.11089 31.151221,33.78691 0 0 1 2.294841,-0.9178 31.151221,33.78691 0 0 1 2.354165,-0.72057 31.151221,33.78691 0 0 1 2.399176,-0.51714 31.151221,33.78691 0 0 1 2.428838,-0.31214 31.151221,33.78691 0 0 1 2.44418,-0.10405 z"
+ clip-path="url(#clipPath20691)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-63-6-2"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="m 96.670636,227.42758 a 35.545008,38.588202 0 0 0 -35.544717,38.58854 35.545008,38.588202 0 0 0 35.544717,38.58801 35.545008,38.588202 0 0 0 35.545214,-38.58801 35.545008,38.588202 0 0 0 -35.545214,-38.58854 z m -0.07061,4.90892 a 31.151221,33.78691 0 0 1 0.829048,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.827519,0.0595 31.151221,33.78691 0 0 1 0.825964,0.0839 31.151221,33.78691 0 0 1 0.823423,0.10767 31.151221,33.78691 0 0 1 0.82035,0.13097 31.151221,33.78691 0 0 1 0.81678,0.15477 31.151221,33.78691 0 0 1 0.81319,0.1786 31.151221,33.78691 0 0 1 0.80809,0.20188 31.151221,33.78691 0 0 1 0.80295,0.22518 31.151221,33.78691 0 0 1 0.79734,0.24796 31.151221,33.78691 0 0 1 0.7907,0.27125 31.151221,33.78691 0 0 1 0.78353,0.29403 31.151221,33.78691 0 0 1 0.77636,0.31628 31.151221,33.78691 0 0 1 0.76817,0.33855 31.151221,33.78691 0 0 1 0.76003,0.3608 31.151221,33.78691 0 0 1 0.75078,0.38255 31.151221,33.78691 0 0 1 0.74058,0.40429 31.151221,33.78691 0 0 1 0.73085,0.42499 31.151221,33.78691 0 0 1 0.7201,0.44622 31.151221,33.78691 0 0 1 0.70885,0.46692 31.151221,33.78691 0 0 1 0.6971,0.48763 31.151221,33.78691 0 0 1 0.68484,0.50679 31.151221,33.78691 0 0 1 0.67254,0.52697 31.151221,33.78691 0 0 1 0.65872,0.54612 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.5834 31.151221,33.78691 0 0 1 0.61678,0.601 31.151221,33.78691 0 0 1 0.60196,0.61911 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65276 31.151221,33.78691 0 0 1 0.5544,0.66933 31.151221,33.78691 0 0 1 0.53751,0.68486 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71487 31.151221,33.78691 0 0 1 0.48534,0.72886 31.151221,33.78691 0 0 1 0.46798,0.74284 31.151221,33.78691 0 0 1 0.44904,0.75629 31.151221,33.78691 0 0 1 0.43065,0.76872 31.151221,33.78691 0 0 1 0.41119,0.78113 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80392 31.151221,33.78691 0 0 1 0.35239,0.81375 31.151221,33.78691 0 0 1 0.33294,0.82411 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.84171 31.151221,33.78691 0 0 1 0.27107,0.8505 31.151221,33.78691 0 0 1 0.2501,0.85776 31.151221,33.78691 0 0 1 0.2286,0.86448 31.151221,33.78691 0 0 1 0.20765,0.87069 31.151221,33.78691 0 0 1 0.18616,0.87691 31.151221,33.78691 0 0 1 0.16468,0.88157 31.151221,33.78691 0 0 1 0.14268,0.8857 31.151221,33.78691 0 0 1 0.12073,0.88985 31.151221,33.78691 0 0 1 0.0992,0.89347 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.6509 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60173 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40917 31.151221,33.78691 0 0 1 -1.19526,2.31443 31.151221,33.78691 0 0 1 -1.3589,2.20624 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64252 31.151221,33.78691 0 0 1 -2.034,1.47428 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.22169,1.11037 31.151221,33.78691 0 0 1 -2.29433,0.91832 31.151221,33.78691 0 0 1 -2.35416,0.72006 31.151221,33.78691 0 0 1 -2.39918,0.51765 31.151221,33.78691 0 0 1 -2.428834,0.31163 31.151221,33.78691 0 0 1 -2.444189,0.10457 31.151221,33.78691 0 0 1 -2.44418,-0.10457 31.151221,33.78691 0 0 1 -2.428838,-0.31162 31.151221,33.78691 0 0 1 -2.399177,-0.51766 31.151221,33.78691 0 0 1 -2.354163,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91831 31.151221,33.78691 0 0 1 -2.221196,-1.11037 31.151221,33.78691 0 0 1 -2.134249,-1.29621 31.151221,33.78691 0 0 1 -2.033489,-1.47428 31.151221,33.78691 0 0 1 -1.920973,-1.64251 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20625 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.65091 31.151221,33.78691 0 0 1 0.09615,-2.6509 31.151221,33.78691 0 0 1 0.287434,-2.63435 31.151221,33.78691 0 0 1 0.477175,-2.60173 31.151221,33.78691 0 0 1 0.663854,-2.55359 31.151221,33.78691 0 0 1 0.846946,-2.48888 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31495 31.151221,33.78691 0 0 1 1.358905,-2.20572 31.151221,33.78691 0 0 1 1.513873,-2.08356 31.151221,33.78691 0 0 1 1.660643,-1.94793 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920973,-1.64252 31.151221,33.78691 0 0 1 2.033505,-1.47376 31.151221,33.78691 0 0 1 2.13425,-1.29621 31.151221,33.78691 0 0 1 2.221195,-1.11088 31.151221,33.78691 0 0 1 2.294841,-0.9178 31.151221,33.78691 0 0 1 2.354165,-0.72058 31.151221,33.78691 0 0 1 2.399176,-0.51714 31.151221,33.78691 0 0 1 2.428838,-0.31214 31.151221,33.78691 0 0 1 2.44418,-0.10405 z"
+ clip-path="url(#clipPath20675)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-6-6"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0427732;stroke-linejoin:round;stroke-opacity:1"
+ d="M 96.843606,215.58112 A 46.363577,49.444797 0 0 0 50.480411,265.02634 46.363577,49.444797 0 0 0 96.843606,314.4709 46.363577,49.444797 0 0 0 143.20744,265.02634 46.363577,49.444797 0 0 0 96.843606,215.58112 Z m -0.09212,6.29001 a 40.632485,43.292687 0 0 1 1.081384,0.0153 40.632485,43.292687 0 0 1 1.080714,0.0464 40.632485,43.292687 0 0 1 1.079381,0.0763 40.632485,43.292687 0 0 1 1.077365,0.10746 40.632485,43.292687 0 0 1 1.07404,0.13796 40.632485,43.292687 0 0 1 1.07004,0.16781 40.632485,43.292687 0 0 1 1.06536,0.19833 40.632485,43.292687 0 0 1 1.06071,0.22884 40.632485,43.292687 0 0 1 1.05403,0.25868 40.632485,43.292687 0 0 1 1.04735,0.28853 40.632485,43.292687 0 0 1 1.04002,0.31772 40.632485,43.292687 0 0 1 1.03135,0.34757 40.632485,43.292687 0 0 1 1.02201,0.37675 40.632485,43.292687 0 0 1 1.01266,0.40527 40.632485,43.292687 0 0 1 1.00198,0.43379 40.632485,43.292687 0 0 1 0.99134,0.46232 40.632485,43.292687 0 0 1 0.97929,0.49017 40.632485,43.292687 0 0 1 0.96598,0.51804 40.632485,43.292687 0 0 1 0.9533,0.54456 40.632485,43.292687 0 0 1 0.93927,0.57176 40.632485,43.292687 0 0 1 0.92461,0.59829 40.632485,43.292687 0 0 1 0.90926,0.62482 40.632485,43.292687 0 0 1 0.89328,0.64937 40.632485,43.292687 0 0 1 0.87724,0.67523 40.632485,43.292687 0 0 1 0.85922,0.69977 40.632485,43.292687 0 0 1 0.8419,0.72366 40.632485,43.292687 0 0 1 0.82388,0.74753 40.632485,43.292687 0 0 1 0.80452,0.77008 40.632485,43.292687 0 0 1 0.78518,0.7933 40.632485,43.292687 0 0 1 0.7645,0.81453 40.632485,43.292687 0 0 1 0.74449,0.83641 40.632485,43.292687 0 0 1 0.72314,0.85764 40.632485,43.292687 0 0 1 0.70113,0.87754 40.632485,43.292687 0 0 1 0.67912,0.89677 40.632485,43.292687 0 0 1 0.65711,0.91601 40.632485,43.292687 0 0 1 0.63307,0.93391 40.632485,43.292687 0 0 1 0.6104,0.95183 40.632485,43.292687 0 0 1 0.58573,0.96907 40.632485,43.292687 0 0 1 0.56171,0.98499 40.632485,43.292687 0 0 1 0.53633,1.00091 40.632485,43.292687 0 0 1 0.51167,1.01551 40.632485,43.292687 0 0 1 0.48631,1.03009 40.632485,43.292687 0 0 1 0.45965,1.0427 40.632485,43.292687 0 0 1 0.43428,1.05596 40.632485,43.292687 0 0 1 0.40694,1.0679 40.632485,43.292687 0 0 1 0.38025,1.07852 40.632485,43.292687 0 0 1 0.35357,1.08979 40.632485,43.292687 0 0 1 0.32622,1.09908 40.632485,43.292687 0 0 1 0.29818,1.1077 40.632485,43.292687 0 0 1 0.27085,1.11566 40.632485,43.292687 0 0 1 0.24283,1.12362 40.632485,43.292687 0 0 1 0.2148,1.12959 40.632485,43.292687 0 0 1 0.18612,1.13489 40.632485,43.292687 0 0 1 0.15745,1.14021 40.632485,43.292687 0 0 1 0.12941,1.14484 40.632485,43.292687 0 0 1 0.10078,1.1475 40.632485,43.292687 0 0 1 0.072,1.15015 40.632485,43.292687 0 0 1 0.0434,1.15148 40.632485,43.292687 0 0 1 0.014,1.15214 40.632485,43.292687 0 0 1 -0.12477,3.39672 40.632485,43.292687 0 0 1 -0.37557,3.37617 40.632485,43.292687 0 0 1 -0.6224,3.33371 40.632485,43.292687 0 0 1 -0.86591,3.27203 40.632485,43.292687 0 0 1 -1.10406,3.18911 40.632485,43.292687 0 0 1 -1.33555,3.08697 40.632485,43.292687 0 0 1 -1.55902,2.96559 40.632485,43.292687 0 0 1 -1.77249,2.82696 40.632485,43.292687 0 0 1 -1.9753,2.66909 40.632485,43.292687 0 0 1 -2.16544,2.49664 40.632485,43.292687 0 0 1 -2.34287,2.30693 40.632485,43.292687 0 0 1 -2.50564,2.10463 40.632485,43.292687 0 0 1 -2.65309,1.88906 40.632485,43.292687 0 0 1 -2.78317,1.66089 40.632485,43.292687 0 0 1 -2.89791,1.42276 40.632485,43.292687 0 0 1 -2.99264,1.17668 40.632485,43.292687 0 0 1 -3.07067,0.92265 40.632485,43.292687 0 0 1 -3.12941,0.66329 40.632485,43.292687 0 0 1 -3.168074,0.3993 40.632485,43.292687 0 0 1 -3.188104,0.13399 40.632485,43.292687 0 0 1 -3.188094,-0.13399 40.632485,43.292687 0 0 1 -3.168088,-0.3993 40.632485,43.292687 0 0 1 -3.12939,-0.6633 40.632485,43.292687 0 0 1 -3.07069,-0.92264 40.632485,43.292687 0 0 1 -2.993303,-1.17668 40.632485,43.292687 0 0 1 -2.897244,-1.42277 40.632485,43.292687 0 0 1 -2.783837,-1.66088 40.632485,43.292687 0 0 1 -2.652404,-1.88906 40.632485,43.292687 0 0 1 -2.505648,-2.10464 40.632485,43.292687 0 0 1 -2.342886,-2.30693 40.632485,43.292687 0 0 1 -2.166082,-2.49664 40.632485,43.292687 0 0 1 -1.974639,-2.66909 40.632485,43.292687 0 0 1 -1.772504,-2.82696 40.632485,43.292687 0 0 1 -1.559022,-2.96558 40.632485,43.292687 0 0 1 -1.335549,-3.08697 40.632485,43.292687 0 0 1 -1.104725,-3.18912 40.632485,43.292687 0 0 1 -0.865908,-3.27202 40.632485,43.292687 0 0 1 -0.622408,-3.33372 40.632485,43.292687 0 0 1 -0.374918,-3.37616 40.632485,43.292687 0 0 1 -0.125409,-3.39673 40.632485,43.292687 0 0 1 0.125409,-3.39672 40.632485,43.292687 0 0 1 0.374918,-3.37551 40.632485,43.292687 0 0 1 0.622408,-3.33371 40.632485,43.292687 0 0 1 0.865908,-3.27203 40.632485,43.292687 0 0 1 1.104725,-3.18911 40.632485,43.292687 0 0 1 1.335549,-3.08697 40.632485,43.292687 0 0 1 1.559022,-2.96625 40.632485,43.292687 0 0 1 1.772504,-2.82629 40.632485,43.292687 0 0 1 1.974639,-2.66976 40.632485,43.292687 0 0 1 2.166082,-2.49597 40.632485,43.292687 0 0 1 2.342886,-2.3076 40.632485,43.292687 0 0 1 2.505648,-2.10463 40.632485,43.292687 0 0 1 2.652426,-1.8884 40.632485,43.292687 0 0 1 2.783836,-1.66089 40.632485,43.292687 0 0 1 2.897245,-1.42342 40.632485,43.292687 0 0 1 2.993303,-1.17602 40.632485,43.292687 0 0 1 3.070689,-0.92331 40.632485,43.292687 0 0 1 3.12939,-0.66263 40.632485,43.292687 0 0 1 3.16809,-0.39997 40.632485,43.292687 0 0 1 3.188093,-0.13332 z"
+ clip-path="url(#clipPath20679)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-5-7"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0376767;stroke-linejoin:round"
+ d="m 74.301703,221.87113 a 40.722405,43.678338 0 0 0 -40.72207,43.67871 40.722405,43.678338 0 0 0 40.72207,43.67813 40.722405,43.678338 0 0 0 40.722637,-43.67813 40.722405,43.678338 0 0 0 -40.722637,-43.67871 z m -0.0809,5.55645 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.94628,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12187 35.68863,38.243712 0 0 1 0.93984,0.14824 35.68863,38.243712 0 0 1 0.93574,0.1752 35.68863,38.243712 0 0 1 0.93165,0.20215 35.68863,38.243712 0 0 1 0.92578,0.22851 35.68863,38.243712 0 0 1 0.91992,0.25489 35.68863,38.243712 0 0 1 0.91348,0.28066 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33281 35.68863,38.243712 0 0 1 0.88945,0.35801 35.68863,38.243712 0 0 1 0.880067,0.3832 35.68863,38.243712 0 0 1 0.87071,0.4084 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52852 35.68863,38.243712 0 0 1 0.79863,0.55195 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59649 35.68863,38.243712 0 0 1 0.75469,0.61816 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68027 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71954 35.68863,38.243712 0 0 1 0.65391,0.73886 35.68863,38.243712 0 0 1 0.63515,0.75762 35.68863,38.243712 0 0 1 0.61582,0.7752 35.68863,38.243712 0 0 1 0.59649,0.79218 35.68863,38.243712 0 0 1 0.57715,0.80918 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84082 35.68863,38.243712 0 0 1 0.51446,0.85606 35.68863,38.243712 0 0 1 0.49336,0.87012 35.68863,38.243712 0 0 1 0.47109,0.88417 35.68863,38.243712 0 0 1 0.44941,0.89708 35.68863,38.243712 0 0 1 0.42715,0.90996 35.68863,38.243712 0 0 1 0.40371,0.92109 35.68863,38.243712 0 0 1 0.38145,0.93281 35.68863,38.243712 0 0 1 0.35742,0.94336 35.68863,38.243712 0 0 1 0.33399,0.95274 35.68863,38.243712 0 0 1 0.31054,0.96269 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97852 35.68863,38.243712 0 0 1 0.23789,0.98554 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99785 35.68863,38.243712 0 0 1 0.16348,1.00254 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01133 35.68863,38.243712 0 0 1 0.0885,1.01367 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01778 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98242 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89044 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.17305,2.72696 35.68863,38.243712 0 0 1 -1.36934,2.61972 35.68863,38.243712 0 0 1 -1.55683,2.49727 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46718 35.68863,38.243712 0 0 1 -2.54531,1.25684 35.68863,38.243712 0 0 1 -2.628507,1.03945 35.68863,38.243712 0 0 1 -2.69706,0.81504 35.68863,38.243712 0 0 1 -2.74864,0.58594 35.68863,38.243712 0 0 1 -2.78261,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58594 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03945 35.68863,38.243712 0 0 1 -2.54473,-1.25684 35.68863,38.243712 0 0 1 -2.44512,-1.46718 35.68863,38.243712 0 0 1 -2.32968,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49727 35.68863,38.243712 0 0 1 -1.36933,-2.61972 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89044 35.68863,38.243712 0 0 1 -0.54668,-2.94492 35.68863,38.243712 0 0 1 -0.3293,-2.98242 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00059 35.68863,38.243712 0 0 1 0.3293,-2.98184 35.68863,38.243712 0 0 1 0.54668,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81719 35.68863,38.243712 0 0 1 1.17305,-2.72695 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20488 35.68863,38.243712 0 0 1 2.05782,-2.03848 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.32968,-1.66816 35.68863,38.243712 0 0 1 2.44512,-1.46719 35.68863,38.243712 0 0 1 2.54473,-1.25742 35.68863,38.243712 0 0 1 2.6291,-1.03887 35.68863,38.243712 0 0 1 2.69707,-0.81562 35.68863,38.243712 0 0 1 2.74863,-0.58536 35.68863,38.243712 0 0 1 2.78262,-0.35332 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20683)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-8"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0427732;stroke-linejoin:round;stroke-opacity:1"
+ d="m 119.27202,216.33781 a 46.363577,49.444797 0 0 0 -46.363193,49.44522 46.363577,49.444797 0 0 0 46.363193,49.44456 46.363577,49.444797 0 0 0 46.36384,-49.44456 46.363577,49.444797 0 0 0 -46.36384,-49.44522 z m -0.0921,6.29001 a 40.632485,43.292687 0 0 1 1.08139,0.0153 40.632485,43.292687 0 0 1 1.08071,0.0464 40.632485,43.292687 0 0 1 1.07938,0.0763 40.632485,43.292687 0 0 1 1.07737,0.10745 40.632485,43.292687 0 0 1 1.07404,0.13797 40.632485,43.292687 0 0 1 1.07003,0.16781 40.632485,43.292687 0 0 1 1.06537,0.19832 40.632485,43.292687 0 0 1 1.06071,0.22884 40.632485,43.292687 0 0 1 1.05402,0.25868 40.632485,43.292687 0 0 1 1.04736,0.28854 40.632485,43.292687 0 0 1 1.04002,0.31771 40.632485,43.292687 0 0 1 1.03134,0.34757 40.632485,43.292687 0 0 1 1.02201,0.37675 40.632485,43.292687 0 0 1 1.01266,0.40527 40.632485,43.292687 0 0 1 1.00198,0.4338 40.632485,43.292687 0 0 1 0.99134,0.46231 40.632485,43.292687 0 0 1 0.97929,0.49018 40.632485,43.292687 0 0 1 0.96598,0.51803 40.632485,43.292687 0 0 1 0.9533,0.54456 40.632485,43.292687 0 0 1 0.93927,0.57176 40.632485,43.292687 0 0 1 0.92461,0.59829 40.632485,43.292687 0 0 1 0.90926,0.62483 40.632485,43.292687 0 0 1 0.89328,0.64936 40.632485,43.292687 0 0 1 0.87724,0.67523 40.632485,43.292687 0 0 1 0.85922,0.69978 40.632485,43.292687 0 0 1 0.8419,0.72365 40.632485,43.292687 0 0 1 0.82388,0.74753 40.632485,43.292687 0 0 1 0.80452,0.77009 40.632485,43.292687 0 0 1 0.78518,0.7933 40.632485,43.292687 0 0 1 0.7645,0.81452 40.632485,43.292687 0 0 1 0.74449,0.83641 40.632485,43.292687 0 0 1 0.72314,0.85764 40.632485,43.292687 0 0 1 0.70113,0.87754 40.632485,43.292687 0 0 1 0.67912,0.89677 40.632485,43.292687 0 0 1 0.65711,0.91601 40.632485,43.292687 0 0 1 0.63307,0.93392 40.632485,43.292687 0 0 1 0.6104,0.95183 40.632485,43.292687 0 0 1 0.58573,0.96907 40.632485,43.292687 0 0 1 0.56171,0.98499 40.632485,43.292687 0 0 1 0.53634,1.00091 40.632485,43.292687 0 0 1 0.51167,1.0155 40.632485,43.292687 0 0 1 0.48631,1.0301 40.632485,43.292687 0 0 1 0.45965,1.04269 40.632485,43.292687 0 0 1 0.43428,1.05597 40.632485,43.292687 0 0 1 0.40694,1.0679 40.632485,43.292687 0 0 1 0.38025,1.07851 40.632485,43.292687 0 0 1 0.35357,1.0898 40.632485,43.292687 0 0 1 0.32622,1.09908 40.632485,43.292687 0 0 1 0.29818,1.10769 40.632485,43.292687 0 0 1 0.27085,1.11566 40.632485,43.292687 0 0 1 0.24283,1.12362 40.632485,43.292687 0 0 1 0.2148,1.12959 40.632485,43.292687 0 0 1 0.18612,1.1349 40.632485,43.292687 0 0 1 0.15745,1.1402 40.632485,43.292687 0 0 1 0.12941,1.14484 40.632485,43.292687 0 0 1 0.10078,1.1475 40.632485,43.292687 0 0 1 0.072,1.15015 40.632485,43.292687 0 0 1 0.0434,1.15148 40.632485,43.292687 0 0 1 0.014,1.15214 40.632485,43.292687 0 0 1 -0.12477,3.39673 40.632485,43.292687 0 0 1 -0.37557,3.37616 40.632485,43.292687 0 0 1 -0.6224,3.33371 40.632485,43.292687 0 0 1 -0.86591,3.27203 40.632485,43.292687 0 0 1 -1.10406,3.18912 40.632485,43.292687 0 0 1 -1.33555,3.08697 40.632485,43.292687 0 0 1 -1.55903,2.96558 40.632485,43.292687 0 0 1 -1.77249,2.82696 40.632485,43.292687 0 0 1 -1.9753,2.66909 40.632485,43.292687 0 0 1 -2.16544,2.49664 40.632485,43.292687 0 0 1 -2.34287,2.30693 40.632485,43.292687 0 0 1 -2.50564,2.10464 40.632485,43.292687 0 0 1 -2.65309,1.88906 40.632485,43.292687 0 0 1 -2.78317,1.66088 40.632485,43.292687 0 0 1 -2.89791,1.42277 40.632485,43.292687 0 0 1 -2.99263,1.17668 40.632485,43.292687 0 0 1 -3.07068,0.92264 40.632485,43.292687 0 0 1 -3.1294,0.66329 40.632485,43.292687 0 0 1 -3.16808,0.39931 40.632485,43.292687 0 0 1 -3.1881,0.13398 40.632485,43.292687 0 0 1 -3.1881,-0.13398 40.632485,43.292687 0 0 1 -3.16808,-0.39931 40.632485,43.292687 0 0 1 -3.12939,-0.66329 40.632485,43.292687 0 0 1 -3.07069,-0.92264 40.632485,43.292687 0 0 1 -2.99331,-1.17668 40.632485,43.292687 0 0 1 -2.89724,-1.42277 40.632485,43.292687 0 0 1 -2.783838,-1.66088 40.632485,43.292687 0 0 1 -2.652404,-1.88906 40.632485,43.292687 0 0 1 -2.505648,-2.10464 40.632485,43.292687 0 0 1 -2.342886,-2.30693 40.632485,43.292687 0 0 1 -2.166082,-2.49664 40.632485,43.292687 0 0 1 -1.974639,-2.66909 40.632485,43.292687 0 0 1 -1.772504,-2.82696 40.632485,43.292687 0 0 1 -1.559022,-2.96558 40.632485,43.292687 0 0 1 -1.335549,-3.08697 40.632485,43.292687 0 0 1 -1.104725,-3.18912 40.632485,43.292687 0 0 1 -0.865908,-3.27203 40.632485,43.292687 0 0 1 -0.622408,-3.33371 40.632485,43.292687 0 0 1 -0.374918,-3.37616 40.632485,43.292687 0 0 1 -0.125409,-3.39673 40.632485,43.292687 0 0 1 0.125409,-3.39673 40.632485,43.292687 0 0 1 0.374918,-3.3755 40.632485,43.292687 0 0 1 0.622408,-3.33371 40.632485,43.292687 0 0 1 0.865908,-3.27203 40.632485,43.292687 0 0 1 1.104725,-3.18911 40.632485,43.292687 0 0 1 1.335549,-3.08697 40.632485,43.292687 0 0 1 1.559022,-2.96625 40.632485,43.292687 0 0 1 1.772504,-2.8263 40.632485,43.292687 0 0 1 1.974639,-2.66975 40.632485,43.292687 0 0 1 2.166082,-2.49597 40.632485,43.292687 0 0 1 2.342886,-2.3076 40.632485,43.292687 0 0 1 2.505648,-2.10463 40.632485,43.292687 0 0 1 2.652426,-1.8884 40.632485,43.292687 0 0 1 2.783836,-1.66089 40.632485,43.292687 0 0 1 2.89724,-1.42343 40.632485,43.292687 0 0 1 2.99331,-1.17602 40.632485,43.292687 0 0 1 3.07069,-0.9233 40.632485,43.292687 0 0 1 3.12939,-0.66263 40.632485,43.292687 0 0 1 3.16809,-0.39997 40.632485,43.292687 0 0 1 3.18809,-0.13332 z"
+ clip-path="url(#clipPath20667)"
+ transform="translate(-251.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ </g>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-48.png b/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-48.png
new file mode 100644
index 000000000..f13a23c85
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-48.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-512.png b/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-512.png
new file mode 100644
index 000000000..be312ef55
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/static/img/taler-logo-512.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/src/pwa/stories.html b/packages/taler-wallet-webextension/src/pwa/stories.html
new file mode 100644
index 000000000..f18307669
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/stories.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Stories</title>
+ <link rel="stylesheet" type="text/css" href="stories.css" />
+ <link rel="stylesheet" type="text/css" href="/static/font/import.css" />
+ <script src="stories.js"></script>
+ </head>
+ <body>
+ <taler-stories id="container"></taler-stories>
+ </body>
+</html>
diff --git a/packages/taler-wallet-webextension/src/pwa/sw.js b/packages/taler-wallet-webextension/src/pwa/sw.js
new file mode 100644
index 000000000..2b2219578
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/sw.js
@@ -0,0 +1,6 @@
+console.log("sw: Service worker installed");
+
+self.addEventListener("fetch", (event) => {
+ // console.log("fetch event", event);
+ // event.respondWith(/* custom content goes here */);
+});
diff --git a/packages/taler-wallet-webextension/src/pwa/tests.html b/packages/taler-wallet-webextension/src/pwa/tests.html
new file mode 100644
index 000000000..383f13d03
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/tests.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Mocha Tests</title>
+ <link rel="stylesheet" href="/mocha.css" />
+ </head>
+ <body>
+ <div id="mocha"></div>
+ <script src="/mocha.js"></script>
+ <script>
+ mocha.setup("bdd");
+ </script>
+
+ <!-- load code you want to test here -->
+ <script src="stories.test.js"></script>
+ <script src="hooks/useTalerActionURL.test.js"></script>
+ <!-- load your test files here -->
+
+ <script>
+ mocha.run();
+ </script>
+ </body>
+</html>
diff --git a/packages/taler-wallet-webextension/src/pwa/wallet.html b/packages/taler-wallet-webextension/src/pwa/wallet.html
new file mode 100644
index 000000000..366615dff
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/pwa/wallet.html
@@ -0,0 +1,29 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <link rel="stylesheet" type="text/css" href="walletEntryPoint.dev.css" />
+ <style>
+ html {
+ font-family: sans-serif; /* 1 */
+ }
+ h1 {
+ font-size: 2em;
+ }
+ input {
+ font: inherit;
+ }
+ body {
+ margin: 0;
+ font-size: 100%;
+ padding: 0;
+ background-color: #f8faf7;
+ font-family: Arial, Helvetica, sans-serif;
+ }
+ </style>
+ <script type="module" src="walletEntryPoint.dev.js"></script>
+ </head>
+
+ <body>
+ <div id="container" class="wallet-container"></div>
+ </body>
+</html>
diff --git a/packages/taler-wallet-webextension/src/renderHtml.tsx b/packages/taler-wallet-webextension/src/renderHtml.tsx
deleted file mode 100644
index bbe8e465c..000000000
--- a/packages/taler-wallet-webextension/src/renderHtml.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 INRIA
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Helpers functions to render Taler-related data structures to HTML.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import {
- AmountJson,
- Amounts,
- amountFractionalBase,
-} from "@gnu-taler/taler-util";
-import { Component, ComponentChildren, JSX, h } from "preact";
-
-/**
- * Render amount as HTML, which non-breaking space between
- * decimal value and currency.
- */
-export function renderAmount(amount: AmountJson | string): JSX.Element {
- let a;
- if (typeof amount === "string") {
- a = Amounts.parse(amount);
- } else {
- a = amount;
- }
- if (!a) {
- return <span>(invalid amount)</span>;
- }
- const x = a.value + a.fraction / amountFractionalBase;
- return (
- <span>
- {x}&nbsp;{a.currency}
- </span>
- );
-}
-
-export const AmountView = ({
- amount,
-}: {
- amount: AmountJson | string;
-}): JSX.Element => renderAmount(amount);
-
-/**
- * Abbreviate a string to a given length, and show the full
- * string on hover as a tooltip.
- */
-export function abbrev(s: string, n = 5): JSX.Element {
- let sAbbrev = s;
- if (s.length > n) {
- sAbbrev = s.slice(0, n) + "..";
- }
- return (
- <span class="abbrev" title={s}>
- {sAbbrev}
- </span>
- );
-}
-
-interface CollapsibleState {
- collapsed: boolean;
-}
-
-interface CollapsibleProps {
- initiallyCollapsed: boolean;
- title: string;
-}
-
-/**
- * Component that shows/hides its children when clicking
- * a heading.
- */
-export class Collapsible extends Component<
- CollapsibleProps,
- CollapsibleState
-> {
- constructor(props: CollapsibleProps) {
- super(props);
- this.state = { collapsed: props.initiallyCollapsed };
- }
- render(): JSX.Element {
- const doOpen = (e: any): void => {
- this.setState({ collapsed: false });
- e.preventDefault();
- };
- const doClose = (e: any): void => {
- this.setState({ collapsed: true });
- e.preventDefault();
- };
- if (this.state.collapsed) {
- return (
- <h2>
- <a class="opener opener-collapsed" href="#" onClick={doOpen}>
- {" "}
- {this.props.title}
- </a>
- </h2>
- );
- }
- return (
- <div>
- <h2>
- <a class="opener opener-open" href="#" onClick={doClose}>
- {" "}
- {this.props.title}
- </a>
- </h2>
- {this.props.children}
- </div>
- );
- }
-}
-
-interface ExpanderTextProps {
- text: string;
-}
-
-/**
- * Show a heading with a toggle to show/hide the expandable content.
- */
-export function ExpanderText({ text }: ExpanderTextProps): JSX.Element {
- return <span>{text}</span>;
-}
-
-export interface LoadingButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
- isLoading: boolean;
-}
-
-export function ProgressButton({isLoading, ...rest}: LoadingButtonProps): JSX.Element {
- return (
- <button
- class="pure-button pure-button-primary"
- type="button"
- {...rest}
- >
- {isLoading ? (
- <span>
- <object
- class="svg-icon svg-baseline"
- data="/img/spinner-bars.svg"
- />
- </span>
- ) : null}{" "}
- {rest.children}
- </button>
- );
-}
-
-export function PageLink(
- props: { pageName: string, children?: ComponentChildren },
-): JSX.Element {
- const url = chrome.extension.getURL(`/static/wallet.html#/${props.pageName}`);
- return (
- <a
- class="actionLink"
- href={url}
- target="_blank"
- rel="noopener noreferrer"
- >
- {props.children}
- </a>
- );
-}
diff --git a/packages/taler-wallet-webextension/src/stories.test.ts b/packages/taler-wallet-webextension/src/stories.test.ts
new file mode 100644
index 000000000..b4af1bc1a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/stories.test.ts
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { setupI18n } from "@gnu-taler/taler-util";
+import { parseGroupImport } from "@gnu-taler/web-util/browser";
+import * as tests from "@gnu-taler/web-util/testing";
+import chromeAPI from "./platform/chrome.js";
+import { setupPlatform } from "./platform/foreground.js";
+
+import * as components from "./components/index.stories.js";
+import * as cta from "./cta/index.stories.js";
+import * as mui from "./mui/index.stories.js";
+import * as popup from "./popup/index.stories.js";
+import * as wallet from "./wallet/index.stories.js";
+// import { renderNodeOrBrowser } from "./test-utils.js";
+import { h, VNode, ComponentChildren } from "preact";
+import { AlertProvider } from "./context/alert.js";
+
+setupI18n("en", { en: {} });
+setupPlatform(chromeAPI);
+
+describe("All the examples:", () => {
+ const cms = parseGroupImport({ popup, wallet, cta, mui, components });
+ cms.forEach((group) => {
+ describe(`Example for group "${group.title}:"`, () => {
+ group.list.forEach((component) => {
+ describe(`Component ${component.name}:`, () => {
+ component.examples.forEach((example) => {
+ it(`should render example: ${example.name}`, () => {
+ tests.renderUI(example.render, DefaultTestingContext);
+ });
+ });
+ });
+ });
+ });
+ });
+});
+function DefaultTestingContext({
+ children,
+}: {
+ children: ComponentChildren;
+}): VNode {
+ //FIXME:
+ //some components push the alter in the UI function
+ //that's not correct, should be moved into the state function
+ // until then, we ran the tests with the alert provider
+ return h(AlertProvider, { children });
+}
diff --git a/packages/taler-wallet-webextension/src/stories.tsx b/packages/taler-wallet-webextension/src/stories.tsx
new file mode 100644
index 000000000..98ab301a3
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/stories.tsx
@@ -0,0 +1,87 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { Fragment, FunctionComponent, h } from "preact";
+import { LogoHeader } from "./components/LogoHeader.js";
+import {
+ PopupBox,
+ WalletAction,
+ WalletBox,
+} from "./components/styled/index.js";
+import { strings } from "./i18n/strings.js";
+import { PopupNavBar, WalletNavBar } from "./NavigationBar.js";
+
+import * as components from "./components/index.stories.js";
+import * as cta from "./cta/index.stories.js";
+import * as mui from "./mui/index.stories.js";
+import * as popup from "./popup/index.stories.js";
+import * as wallet from "./wallet/index.stories.js";
+
+import { renderStories } from "@gnu-taler/web-util/browser";
+import { AlertProvider } from "./context/alert.js";
+
+function main(): void {
+ renderStories(
+ { popup, wallet, cta, mui, components },
+ {
+ strings,
+ getWrapperForGroup,
+ },
+ );
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", main);
+} else {
+ main();
+}
+function getWrapperForGroup(group: string): FunctionComponent {
+ switch (group) {
+ case "popup":
+ return function PopupWrapper({ children }: any) {
+ return (
+ <AlertProvider>
+ <PopupNavBar />
+ <PopupBox>{children}</PopupBox>
+ </AlertProvider>
+ );
+ };
+ case "wallet":
+ return function WalletWrapper({ children }: any) {
+ return (
+ <AlertProvider>
+ <LogoHeader />
+ <WalletNavBar />
+ <WalletBox>{children}</WalletBox>
+ </AlertProvider>
+ );
+ };
+ case "cta":
+ return function WalletWrapper({ children }: any) {
+ return (
+ <AlertProvider>
+ <WalletAction>{children}</WalletAction>
+ </AlertProvider>
+ );
+ };
+ default:
+ return Fragment;
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/svg/check_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/check_24px.inline.svg
new file mode 100644
index 000000000..522695ef3
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/check_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/chevron-down.inline.svg b/packages/taler-wallet-webextension/src/svg/chevron-down.inline.svg
new file mode 100644
index 000000000..0e1c0aeda
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/chevron-down.inline.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92 92" enable-background="new 0 0 92 92" xml:space="preserve">
+ <path id="XMLID_467_" d="M46,63c-1.1,0-2.1-0.4-2.9-1.2l-25-26c-1.5-1.6-1.5-4.1,0.1-5.7c1.6-1.5,4.1-1.5,5.7,0.1l22.1,23l22.1-23
+ c1.5-1.6,4.1-1.6,5.7-0.1c1.6,1.5,1.6,4.1,0.1,5.7l-25,26C48.1,62.6,47.1,63,46,63z" />
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/close_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/close_24px.inline.svg
new file mode 100644
index 000000000..8b7eb6e84
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/close_24px.inline.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
+ <path d="M0 0h24v24H0z" fill="none" />
+ <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/delete_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/delete_24px.inline.svg
new file mode 100644
index 000000000..ead7caa9f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/delete_24px.inline.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+ <path d="M0 0h24v24H0z" fill="none" />
+ <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/download_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/download_24px.inline.svg
new file mode 100644
index 000000000..c4ec1c354
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/download_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/></g><g><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/edit_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/edit_24px.inline.svg
new file mode 100644
index 000000000..a4b3c9f6b
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/edit_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/error_outline_outlined_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/error_outline_outlined_24px.inline.svg
new file mode 100644
index 000000000..4a0daa1e9
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/error_outline_outlined_24px.inline.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
+ <path d="M11 15h2v2h-2v-2zm0-8h2v6h-2V7zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" />
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/index.tsx b/packages/taler-wallet-webextension/src/svg/index.tsx
new file mode 100644
index 000000000..7de1c7d4f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/index.tsx
@@ -0,0 +1,38 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { h, VNode } from "preact";
+
+export const CopyIcon = (): VNode => (
+ <svg height="16" viewBox="0 0 16 16" width="16">
+ <path
+ fill-rule="evenodd"
+ d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"
+ />
+ <path
+ fill-rule="evenodd"
+ d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"
+ />
+ </svg>
+);
+
+export const CopiedIcon = (): VNode => (
+ <svg height="16" viewBox="0 0 16 16" width="16">
+ <path
+ fill-rule="evenodd"
+ d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
+ />
+ </svg>
+);
diff --git a/packages/taler-wallet-webextension/src/svg/info_outlined_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/info_outlined_24px.inline.svg
new file mode 100644
index 000000000..80dad95cc
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/info_outlined_24px.inline.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
+ <path d="M0 0h24v24H0V0z" fill="none" />
+ <path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" />
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/logo-2021.inline.svg b/packages/taler-wallet-webextension/src/svg/logo-2021.inline.svg
new file mode 100644
index 000000000..8c5ff3e5b
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/logo-2021.inline.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">
+ <g fill="#0042b3" fill-rule="evenodd" stroke-width=".3">
+ <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />
+ <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />
+ <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />
+ </g>
+ <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/progress.inline.svg b/packages/taler-wallet-webextension/src/svg/progress.inline.svg
new file mode 100644
index 000000000..c7284a545
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/progress.inline.svg
@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin:auto;background:#fff;display:block;" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
+ <defs>
+ <clipPath id="progress-cp" x="0" y="0" width="100" height="100">
+ <rect x="0" y="0" width="0" height="100">
+ <animate attributeName="width" repeatCount="indefinite" dur="2s" values="0;100;100" keyTimes="0;0.5;1"></animate>
+ <animate attributeName="x" repeatCount="indefinite" dur="2s" values="0;0;100" keyTimes="0;0.5;1"></animate>
+ </rect>
+ </clipPath>
+ </defs>
+ <path fill="none" stroke="darkgrey" stroke-width="1.04" d="M10.000000000000004 44.019999999999996L89.99999999999999 44.019999999999996A5.98 5.98 0 0 1 95.97999999999999 50L95.97999999999999 50A5.98 5.98 0 0 1 89.99999999999999 55.980000000000004L10.000000000000004 55.980000000000004A5.98 5.98 0 0 1 4.020000000000003 50L4.020000000000003 50A5.98 5.98 0 0 1 10.000000000000004 44.019999999999996 Z"></path>
+ <path fill="#0042b2" clip-path="url(#progress-cp)" d="M10.000000000000004 45.54L90 45.54A4.460000000000001 4.460000000000001 0 0 1 94.46 50L94.46 50A4.460000000000001 4.460000000000001 0 0 1 90 54.46L10.000000000000004 54.46A4.460000000000001 4.460000000000001 0 0 1 5.540000000000003 50L5.540000000000003 50A4.460000000000001 4.460000000000001 0 0 1 10.000000000000004 45.54 Z"></path>
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/qr_code_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/qr_code_24px.inline.svg
new file mode 100644
index 000000000..c0c158359
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/qr_code_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M3,11h8V3H3V11z M5,5h4v4H5V5z"/><path d="M3,21h8v-8H3V21z M5,15h4v4H5V15z"/><path d="M13,3v8h8V3H13z M19,9h-4V5h4V9z"/><rect height="2" width="2" x="19" y="19"/><rect height="2" width="2" x="13" y="13"/><rect height="2" width="2" x="15" y="15"/><rect height="2" width="2" x="13" y="17"/><rect height="2" width="2" x="15" y="19"/><rect height="2" width="2" x="17" y="17"/><rect height="2" width="2" x="17" y="13"/><rect height="2" width="2" x="19" y="15"/></g></g></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/refresh_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/refresh_24px.inline.svg
new file mode 100644
index 000000000..b8d69f402
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/refresh_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/refresh_outlined_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/refresh_outlined_24px.inline.svg
new file mode 100644
index 000000000..1959c1aad
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/refresh_outlined_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/refresh_rounded_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/refresh_rounded_24px.inline.svg
new file mode 100644
index 000000000..46db14360
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/refresh_rounded_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17.65 6.35c-1.63-1.63-3.94-2.57-6.48-2.31-3.67.37-6.69 3.35-7.1 7.02C3.52 15.91 7.27 20 12 20c3.19 0 5.93-1.87 7.21-4.56.32-.67-.16-1.44-.9-1.44-.37 0-.72.2-.88.53-1.13 2.43-3.84 3.97-6.8 3.31-2.22-.49-4.01-2.3-4.48-4.52C5.31 9.44 8.26 6 12 6c1.66 0 3.14.69 4.22 1.78l-1.51 1.51c-.63.63-.19 1.71.7 1.71H19c.55 0 1-.45 1-1V6.41c0-.89-1.08-1.34-1.71-.71l-.64.65z"/></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/refresh_sharp_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/refresh_sharp_24px.inline.svg
new file mode 100644
index 000000000..1959c1aad
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/refresh_sharp_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/refresh_two_tone_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/refresh_two_tone_24px.inline.svg
new file mode 100644
index 000000000..1959c1aad
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/refresh_two_tone_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/report_problem_outlined_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/report_problem_outlined_24px.inline.svg
new file mode 100644
index 000000000..ed32f6f28
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/report_problem_outlined_24px.inline.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
+ <path d="M0 0h24v24H0V0z" fill="none" />
+ <path d="M12 5.99L19.53 19H4.47L12 5.99M12 2L1 21h22L12 2zm1 14h-2v2h2v-2zm0-6h-2v4h2v-4z" />
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/static/img/ri-bank-line.svg b/packages/taler-wallet-webextension/src/svg/ri-bank-line.inline.svg
index 8d987df79..8d987df79 100644
--- a/packages/taler-wallet-webextension/static/img/ri-bank-line.svg
+++ b/packages/taler-wallet-webextension/src/svg/ri-bank-line.inline.svg
diff --git a/packages/taler-wallet-webextension/static/img/ri-file-unknown-line.svg b/packages/taler-wallet-webextension/src/svg/ri-file-unknown-line.svg
index 5203d49f5..5203d49f5 100644
--- a/packages/taler-wallet-webextension/static/img/ri-file-unknown-line.svg
+++ b/packages/taler-wallet-webextension/src/svg/ri-file-unknown-line.svg
diff --git a/packages/taler-wallet-webextension/static/img/ri-hand-heart-line.svg b/packages/taler-wallet-webextension/src/svg/ri-hand-heart-line.svg
index a9c195eac..a9c195eac 100644
--- a/packages/taler-wallet-webextension/static/img/ri-hand-heart-line.svg
+++ b/packages/taler-wallet-webextension/src/svg/ri-hand-heart-line.svg
diff --git a/packages/taler-wallet-webextension/static/img/ri-refresh-line.svg b/packages/taler-wallet-webextension/src/svg/ri-refresh-line.svg
index 6efa8554b..6efa8554b 100644
--- a/packages/taler-wallet-webextension/static/img/ri-refresh-line.svg
+++ b/packages/taler-wallet-webextension/src/svg/ri-refresh-line.svg
diff --git a/packages/taler-wallet-webextension/static/img/ri-refund-2-line.svg b/packages/taler-wallet-webextension/src/svg/ri-refund-2-line.svg
index 5805daf09..5805daf09 100644
--- a/packages/taler-wallet-webextension/static/img/ri-refund-2-line.svg
+++ b/packages/taler-wallet-webextension/src/svg/ri-refund-2-line.svg
diff --git a/packages/taler-wallet-webextension/static/img/ri-shopping-cart-line.svg b/packages/taler-wallet-webextension/src/svg/ri-shopping-cart-line.svg
index 50dabf446..50dabf446 100644
--- a/packages/taler-wallet-webextension/static/img/ri-shopping-cart-line.svg
+++ b/packages/taler-wallet-webextension/src/svg/ri-shopping-cart-line.svg
diff --git a/packages/taler-wallet-webextension/src/svg/search_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/search_24px.inline.svg
new file mode 100644
index 000000000..d880cbf0f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/search_24px.inline.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" height="24" width="24">
+ <path fill-rule="evenodd" d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z" clip-rule="evenodd" />
+</svg>
+
diff --git a/packages/taler-wallet-webextension/src/svg/send_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/send_24px.inline.svg
new file mode 100644
index 000000000..6e41d1c9e
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/send_24px.inline.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+ <path d="M0 0h24v24H0z" fill="none" />
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/settings_black_24dp.inline.svg b/packages/taler-wallet-webextension/src/svg/settings_black_24dp.inline.svg
new file mode 100644
index 000000000..adcd50405
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/settings_black_24dp.inline.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000">
+ <g>
+ <path d="M0,0h24v24H0V0z" fill="none" />
+ <path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z" />
+ </g>
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/static/img/spinner-bars.svg b/packages/taler-wallet-webextension/src/svg/spinner-bars.svg
index f6f7dfcb3..f6f7dfcb3 100644
--- a/packages/taler-wallet-webextension/static/img/spinner-bars.svg
+++ b/packages/taler-wallet-webextension/src/svg/spinner-bars.svg
diff --git a/packages/taler-wallet-webextension/src/svg/success_outlined_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/success_outlined_24px.inline.svg
new file mode 100644
index 000000000..c6130b495
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/success_outlined_24px.inline.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
+ <path d="M0 0h24v24H0V0z" fill="none" />
+ <path d="M20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4C12.76,4 13.5,4.11 14.2, 4.31L15.77,2.74C14.61,2.26 13.34,2 12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0, 0 22,12M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z" />
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/taler-logo-2021-plain.svg b/packages/taler-wallet-webextension/src/svg/taler-logo-2021-plain.svg
new file mode 100644
index 000000000..6e3cc254f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/taler-logo-2021-plain.svg
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ width="670"
+ height="300"
+ viewBox="0 0 201 90"
+ version="1.1"
+ id="svg8">
+ <g
+ id="logo">
+ <g
+ id="circles"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943">
+ <path
+ d="m 86.662153,1.1211936 c 15.589697,0 29.129227,9.4011664 35.961027,23.2018054 h -5.81736 C 110.4866,13.623304 99.349002,6.5180852 86.662153,6.5180852 c -19.690571,0 -35.652876,17.1120008 -35.652876,38.2205688 0,10.331797 3.825597,19.704678 10.03957,26.582945 -1.342357,1.120912 -2.771532,2.127905 -4.275488,3.006754 C 50.071485,66.553412 45.974857,56.15992 45.974857,44.738654 c 0,-24.089211 18.216325,-43.6174604 40.687296,-43.6174604 z M 122.51416,65.375898 c -6.86645,13.680134 -20.34561,22.980218 -35.852007,22.980218 -1.052702,0 -2.096093,-0.04291 -3.128683,-0.127026 3.052192,-1.561167 5.913582,-3.480387 8.538307,-5.707305 10.320963,-1.684389 19.185983,-8.113638 24.601813,-17.145887 z"
+ id="path2350" />
+ <path
+ d="m 64.212372,1.1211936 c 1.052607,0 2.095998,0.042919 3.128684,0.1270583 C 64.288864,2.8094199 61.427378,4.728606 58.802653,6.9555572 41.679542,9.7498571 28.559494,25.601563 28.559494,44.738654 c 0,14.264563 7.29059,26.702023 18.093843,33.268925 -1.593656,0.26719 -3.226966,0.406948 -4.890748,0.406948 -1.239545,0 -2.46151,-0.07952 -3.663522,-0.229364 C 29.191129,70.184015 23.525076,58.171633 23.525076,44.738654 23.525076,20.649443 41.7414,1.1211936 64.212372,1.1211936 Z M 69.62209,82.521785 C 79.943207,80.837396 88.808164,74.407841 94.224059,65.375422 h 5.840511 c -6.866354,13.680305 -20.345548,22.980694 -35.852198,22.980694 -1.052703,0 -2.095999,-0.04291 -3.128684,-0.127026 3.052002,-1.561371 5.913836,-3.480218 8.538402,-5.707305 z M 94.355885,24.322999 c -3.13939,-5.314721 -7.467551,-9.74275 -12.584511,-12.853269 1.593656,-0.26719 3.226904,-0.406948 4.890779,-0.406948 1.239451,0 2.461512,0.07952 3.663524,0.229364 4.016018,3.607242 7.373195,8.030111 9.849053,13.030853 z"
+ id="path2352" />
+ <path
+ d="m 41.762589,1.1211936 c 1.064296,0 2.118804,0.044379 3.162607,0.1302161 -3.046523,1.558961 -5.903162,3.4745139 -8.52358,5.6968133 C 19.254624,9.7205882 6.1097128,25.583465 6.1097128,44.738654 c 0,21.108568 15.9624012,38.22057 35.6528762,38.22057 12.599746,0 23.672446,-7.007056 30.013748,-17.583802 h 5.838515 C 70.748498,79.055727 57.26924,88.356116 41.762589,88.356116 c -22.470907,0 -40.6871998,-19.52825 -40.6871998,-43.617462 0,-24.089211 18.2162928,-43.6174604 40.6871998,-43.6174604 z M 71.905375,24.322999 c -1.31192,-2.220567 -2.830984,-4.287049 -4.528877,-6.166508 1.342452,-1.120945 2.771374,-2.128381 4.275139,-3.00723 2.372984,2.753011 4.418875,5.834636 6.072489,9.173738 z"
+ id="path2354" />
+ </g>
+ <g
+ id="letters">
+ <path
+ d="m 76.135411,34.409066 h 9.161042 V 29.36588 H 61.857537 v 5.043186 h 9.161137 v 25.92317 h 5.116737 z"
+ id="path2346" />
+ <path
+ d="m 92.647571,52.856334 h 13.659009 l 2.93009,7.476072 h 5.36461 L 101.89122,29.144903 H 97.187186 L 84.477089,60.332406 h 5.199533 z m 11.802109,-4.822276 h -9.944771 l 4.951718,-12.386462 z"
+ id="path2362" />
+ <path
+ d="m 123.80641,29.366084 h -4.58038 v 30.966322 h 20.54728 v -4.910253 c -5.32227,0 -10.64463,0 -15.9669,0 z"
+ id="path2356" />
+ <path
+ d="m 166.4722,29.366084 h -21.37564 v 30.966322 h 21.58203 v -4.910253 h -16.54771 v -8.27275 h 14.48439 V 42.23925 h -14.48439 v -7.962811 h 16.34132 z"
+ id="path2360" />
+ <path
+ d="m 191.19035,39.474593 c 0,1.59947 -0.53646,2.87535 -1.61628,3.818883 -1.07281,0.95124 -2.52409,1.422837 -4.34678,1.422837 h -7.44851 V 34.276439 h 7.4073 c 1.9051,0 3.38376,0.435027 4.42939,1.312178 1.05226,0.870258 1.57488,2.167734 1.57488,3.885976 z m 6.06602,20.857813 -7.79911,-11.723191 c 1.01771,-0.294794 1.94631,-0.714813 2.78553,-1.260566 0.83885,-0.545619 1.56122,-1.209263 2.16629,-1.990627 0.60541,-0.781738 1.07981,-1.681096 1.42369,-2.698345 0.34378,-1.017553 0.51561,-2.175238 0.51561,-3.472883 0,-1.50409 -0.24743,-2.867948 -0.74267,-4.092048 -0.49515,-1.223794 -1.20344,-2.256186 -2.12499,-3.096734 -0.92173,-0.840446 -2.04957,-1.489252 -3.38375,-1.946452 -1.33447,-0.457267 -2.82692,-0.685476 -4.4774,-0.685476 h -12.87512 v 30.966322 h 5.03433 V 49.538522 h 6.37569 l 7.11829,10.793884 z"
+ id="path2358" />
+ </g>
+ </g>
+</svg>
diff --git a/packages/taler-wallet-webextension/src/svg/upload_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/upload_24px.inline.svg
new file mode 100644
index 000000000..c604ef78d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/upload_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24" viewBox="0 0 24 24" width="24"><g><rect fill="none" height="24" width="24"/></g><g><path d="M5,20h14v-2H5V20z M5,10h4v6h6v-6h4l-7-7L5,10z"/></g></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/warning_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/warning_24px.inline.svg
new file mode 100644
index 000000000..d27c4c6ec
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/warning_24px.inline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/svg/wifi.inline.svg b/packages/taler-wallet-webextension/src/svg/wifi.inline.svg
new file mode 100644
index 000000000..ad712435d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/svg/wifi.inline.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px">
+ <path d="M23.64 7c-.45-.34-4.93-4-11.64-4-1.5 0-2.89.19-4.15.48L18.18 13.8 23.64 7zm-6.6 8.22L3.27 1.44 2 2.72l2.05 2.06C1.91 5.76.59 6.82.36 7l11.63 14.49.01.01.01-.01 3.9-4.86 3.32 3.32 1.27-1.27-3.46-3.46z"></path>
+</svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts
new file mode 100644
index 000000000..3b7cbcbb7
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts
@@ -0,0 +1,372 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { CoreApiResponse, TalerError, TalerErrorCode } from "@gnu-taler/taler-util";
+import type { MessageFromBackend } from "./platform/api.js";
+
+/**
+ * This will modify all the pages that the user load when navigating with Web Extension enabled
+ *
+ * Can't do useful integration since it run in ISOLATED (or equivalent) mode.
+ *
+ * If taler support is expected, it will inject a script which will complete the integration.
+ */
+
+// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities#content_script_environment
+
+// ISOLATED mode in chromium browsers
+// https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#world
+// X-Ray vision in Firefox
+// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts#xray_vision_in_firefox
+
+// *** IMPORTANT ***
+
+// Content script lifecycle during navigation
+// In Firefox: Content scripts remain injected in a web page after the user has navigated away,
+// however, window object properties are destroyed.
+// In Chrome: Content scripts are destroyed when the user navigates away from a web page.
+
+const documentDocTypeIsHTML =
+ window.document.doctype && window.document.doctype.name === "html";
+const suffixIsNotXMLorPDF =
+ !window.location.pathname.endsWith(".xml") &&
+ !window.location.pathname.endsWith(".pdf");
+const rootElementIsHTML =
+ document.documentElement.nodeName &&
+ document.documentElement.nodeName.toLowerCase() === "html";
+// const pageAcceptsTalerSupport = document.head.querySelector(
+// "meta[name=taler-support]",
+// );
+
+
+
+function validateTalerUri(uri: string): boolean {
+ return (
+ !!uri && (uri.startsWith("taler://") || uri.startsWith("taler+http://"))
+ );
+}
+
+function convertURIToWebExtensionPath(uri: string) {
+ const url = new URL(
+ chrome.runtime.getURL(`static/wallet.html#/taler-uri/${encodeURIComponent(uri)}`),
+ );
+ return url.href;
+}
+
+// safe check, if one of this is true then taler handler is not useful
+// or not expected
+const shouldNotInject =
+ !documentDocTypeIsHTML ||
+ !suffixIsNotXMLorPDF ||
+ // !pageAcceptsTalerSupport ||
+ !rootElementIsHTML;
+
+const logger = {
+ debug: (...msg: any[]) => { },
+ info: (...msg: any[]) =>
+ console.log(`${new Date().toISOString()} TALER`, ...msg),
+ error: (...msg: any[]) =>
+ console.error(`${new Date().toISOString()} TALER`, ...msg),
+};
+
+// logger.debug = logger.info
+
+/**
+ */
+function redirectToTalerActionHandler(element: HTMLMetaElement) {
+ const name = element.getAttribute("name")
+ if (!name) return;
+ if (name !== "taler-uri") return;
+ const uri = element.getAttribute("content");
+ if (!uri) return;
+
+ if (!validateTalerUri(uri)) {
+ logger.error(`taler:// URI is invalid: ${uri}`);
+ return;
+ }
+
+ const walletPage = convertURIToWebExtensionPath(uri)
+ window.location.replace(walletPage)
+}
+
+function injectTalerSupportScript(head: HTMLHeadElement, trusted: boolean) {
+ const meta = head.querySelector("meta[name=taler-support]")
+ if (!meta) return;
+ const content = meta.getAttribute("content");
+ if (!content) return;
+ const features = content.split(",")
+
+ const debugEnabled = meta.getAttribute("debug") === "true";
+ const hijackEnabled = features.indexOf("uri") !== -1
+ const talerApiEnabled = features.indexOf("api") !== -1 && trusted
+
+ const scriptTag = document.createElement("script");
+ scriptTag.setAttribute("async", "false");
+ const url = new URL(
+ chrome.runtime.getURL("/dist/taler-wallet-interaction-support.js"),
+ );
+ url.searchParams.set("id", chrome.runtime.id);
+ if (debugEnabled) {
+ url.searchParams.set("debug", "true");
+ }
+ if (talerApiEnabled) {
+ url.searchParams.set("api", "true");
+ }
+ if (hijackEnabled) {
+ url.searchParams.set("hijack", "true");
+ }
+ scriptTag.src = url.href;
+
+ try {
+ head.insertBefore(scriptTag, head.children.length ? head.children[0] : null);
+ } catch (e) {
+ logger.info("inserting link handler failed!");
+ logger.error(e);
+ }
+}
+
+
+export interface ExtensionOperations {
+ isAutoOpenEnabled: {
+ request: void;
+ response: boolean;
+ };
+ isDomainTrusted: {
+ request: {
+ domain: string;
+ };
+ response: boolean;
+ };
+}
+
+export type MessageFromExtension<Op extends keyof ExtensionOperations> = {
+ channel: "extension";
+ operation: Op;
+ payload: ExtensionOperations[Op]["request"];
+};
+
+export type MessageResponse = CoreApiResponse;
+
+async function callBackground<Op extends keyof ExtensionOperations>(
+ operation: Op,
+ payload: ExtensionOperations[Op]["request"],
+): Promise<ExtensionOperations[Op]["response"]> {
+ const message: MessageFromExtension<Op> = {
+ channel: "extension",
+ operation,
+ payload,
+ };
+
+ const response = await sendMessageToBackground(message);
+ if (response.type === "error") {
+ throw new Error(`Background operation "${operation}" failed`);
+ }
+ return response.result as any;
+}
+
+
+let nextMessageIndex = 0;
+/**
+ *
+ * @param message
+ * @returns
+ */
+async function sendMessageToBackground<Op extends keyof ExtensionOperations>(
+ message: MessageFromExtension<Op>,
+): Promise<MessageResponse> {
+ const messageWithId = { ...message, id: `id_${nextMessageIndex++ % 1000}` };
+
+ if (!chrome.runtime.id) {
+ return Promise.reject(TalerError.fromDetail(TalerErrorCode.WALLET_CORE_NOT_AVAILABLE, {}))
+ }
+ return new Promise<any>((resolve, reject) => {
+ logger.debug("send operation to the wallet background", message, chrome.runtime.id);
+ let timedout = false;
+ const timerId = setTimeout(() => {
+ timedout = true;
+ reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, {
+ requestMethod: "wallet",
+ requestUrl: message.operation,
+ timeoutMs: 20 * 1000,
+ }))
+ }, 20 * 1000); //five seconds
+ try {
+ chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => {
+ if (timedout) {
+ return false; //already rejected
+ }
+ clearTimeout(timerId);
+ if (chrome.runtime.lastError) {
+ reject(chrome.runtime.lastError.message);
+ } else {
+ resolve(backgroundResponse);
+ }
+ // return true to keep the channel open
+ return true;
+ });
+ } catch (e) {
+ console.log(e)
+ }
+ });
+}
+
+let notificationPort: chrome.runtime.Port | undefined;
+function listenToWalletBackground(listener: (m: any) => void): () => void {
+ if (notificationPort === undefined) {
+ notificationPort = chrome.runtime.connect({ name: "notifications" });
+ }
+ notificationPort.onMessage.addListener(listener);
+ function removeListener(): void {
+ if (notificationPort !== undefined) {
+ notificationPort.onMessage.removeListener(listener);
+ }
+ }
+ return removeListener;
+}
+
+const loaderSettings = {
+ isAutoOpenEnabled: false,
+ isDomainTrusted: false,
+}
+
+function start(
+ onTalerMetaTagFound: (listener: (el: HTMLMetaElement) => void) => void,
+ onHeadReady: (listener: (el: HTMLHeadElement) => void) => void
+) {
+ // do not run everywhere, this is just expected to run on site
+ // that are aware of taler
+ if (shouldNotInject) return;
+
+ const isAutoOpenEnabled_promise = callBackground("isAutoOpenEnabled", undefined).then(result => {
+ loaderSettings.isAutoOpenEnabled = result;
+ return result;
+ })
+ const isDomainTrusted_promise = callBackground("isDomainTrusted", {
+ domain: window.location.origin
+ }).then(result => {
+ loaderSettings.isDomainTrusted = result;
+ return result;
+ })
+
+ onTalerMetaTagFound(async (el) => {
+ await isAutoOpenEnabled_promise;
+ if (!loaderSettings.isAutoOpenEnabled) {
+ return;
+ }
+ redirectToTalerActionHandler(el)
+ })
+
+ onHeadReady(async (el) => {
+ const trusted = await isDomainTrusted_promise
+ injectTalerSupportScript(el, trusted)
+ })
+
+ listenToWalletBackground((e: MessageFromBackend) => {
+ if (e.type === "web-extension" && e.notification.type === "settings-change") {
+ const settings = e.notification.currentValue
+ loaderSettings.isAutoOpenEnabled = settings.autoOpen
+ }
+ })
+
+}
+
+function isCorrectMetaElement(el: HTMLMetaElement): boolean {
+ const name = el.getAttribute("name")
+ if (!name) return false;
+ if (name !== "taler-uri") return false;
+ const uri = el.getAttribute("content");
+ if (!uri) return false;
+ return true
+}
+
+/**
+ * Tries to find taler meta tag ASAP and report
+ * @param notify
+ * @returns
+ */
+function notifyWhenTalerUriIsFound(notify: (el: HTMLMetaElement) => void) {
+ if (document.head) {
+ const element = document.head.querySelector("meta[name=taler-uri]")
+ if (!element) return;
+ if (!(element instanceof HTMLMetaElement)) return;
+
+ if (isCorrectMetaElement(element)) {
+ notify(element)
+ }
+ return;
+ }
+ const obs = new MutationObserver(async function (mutations) {
+ try {
+ mutations.forEach((mut) => {
+ if (mut.type === "childList") {
+ mut.addedNodes.forEach((added) => {
+ if (added instanceof HTMLMetaElement) {
+ if (isCorrectMetaElement(added)) {
+ notify(added)
+ obs.disconnect()
+ }
+ }
+ });
+ }
+ });
+ } catch (e) {
+ console.error(e)
+ }
+ })
+
+ obs.observe(document, {
+ childList: true,
+ subtree: true,
+ attributes: false,
+ })
+
+}
+
+/**
+ * Tries to find HEAD tag ASAP and report
+ * @param notify
+ * @returns
+ */
+function notifyWhenHeadIsFound(notify: (el: HTMLHeadElement) => void) {
+ if (document.head) {
+ notify(document.head)
+ return;
+ }
+ const obs = new MutationObserver(async function (mutations) {
+ try {
+ mutations.forEach((mut) => {
+ if (mut.type === "childList") {
+ mut.addedNodes.forEach((added) => {
+ if (added instanceof HTMLHeadElement) {
+ notify(added)
+ obs.disconnect()
+ }
+ });
+ }
+ });
+ } catch (e) {
+ console.error(e)
+ }
+ })
+
+ obs.observe(document, {
+ childList: true,
+ subtree: true,
+ attributes: false,
+ })
+}
+
+start(notifyWhenTalerUriIsFound, notifyWhenHeadIsFound);
diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts
new file mode 100644
index 000000000..8b15380f9
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts
@@ -0,0 +1,200 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * WARNING
+ *
+ * This script will be loaded and run in every page while the
+ * user us navigating. It must be short, simple and safe.
+ */
+(() => {
+ const logger = {
+ debug: (...msg: any[]) => { },
+ info: (...msg: any[]) =>
+ console.log(`${new Date().toISOString()} TALER`, ...msg),
+ error: (...msg: any[]) =>
+ console.error(`${new Date().toISOString()} TALER`, ...msg),
+ };
+
+ const documentDocTypeIsHTML =
+ window.document.doctype && window.document.doctype.name === "html";
+ const suffixIsNotXMLorPDF =
+ !window.location.pathname.endsWith(".xml") &&
+ !window.location.pathname.endsWith(".pdf");
+ const rootElementIsHTML =
+ document.documentElement.nodeName &&
+ document.documentElement.nodeName.toLowerCase() === "html";
+ const pageAcceptsTalerSupport = document.head.querySelector(
+ "meta[name=taler-support]",
+ );
+
+ // this is also checked by the loader
+ // but a double check will prevent running and breaking user navigation
+ // if loaded from other location
+ const shouldNotRun =
+ !documentDocTypeIsHTML ||
+ !suffixIsNotXMLorPDF ||
+ !pageAcceptsTalerSupport ||
+ !rootElementIsHTML;
+
+ interface Info {
+ extensionId: string;
+ protocol: string;
+ hostname: string;
+ }
+ interface API {
+ convertURIToWebExtensionPath: (uri: string) => string | undefined;
+ anchorOnClick: (ev: MouseEvent) => void;
+ registerProtocolHandler: () => void;
+ }
+ interface TalerSupport {
+ info: Readonly<Info>;
+ __internal: API;
+ }
+
+ function buildApi(config: Readonly<Info>): API {
+ /**
+ * Takes an anchor href that starts with taler:// and
+ * returns the path to the web-extension page
+ */
+ function convertURIToWebExtensionPath(uri: string): string | undefined {
+ if (!validateTalerUri(uri)) {
+ logger.error(`taler:// URI is invalid: ${uri}`);
+ return undefined;
+ }
+ const host = `${config.protocol}//${config.hostname}`;
+ const path = `static/wallet.html#/taler-uri/${encodeURIComponent(uri)}`;
+ return `${host}/${path}`;
+ }
+
+ function anchorOnClick(ev: MouseEvent) {
+ if (!(ev.currentTarget instanceof Element)) {
+ logger.debug(`onclick: registered in a link that is not an HTML element`);
+ return;
+ }
+ const hrefAttr = ev.currentTarget.attributes.getNamedItem("href");
+ if (!hrefAttr) {
+ logger.debug(`onclick: link didn't have href with taler:// uri`);
+ return;
+ }
+ const targetAttr = ev.currentTarget.attributes.getNamedItem("target");
+ const windowTarget =
+ targetAttr && targetAttr.value ? targetAttr.value : "_self";
+ const page = convertURIToWebExtensionPath(hrefAttr.value);
+ if (!page) {
+ logger.debug(`onclick: could not convert "${hrefAttr.value}" into path`);
+ return;
+ }
+ // we can use window.open, but maybe some browser will block it?
+ window.open(page, windowTarget);
+ ev.preventDefault();
+ ev.stopPropagation();
+ ev.stopImmediatePropagation();
+ return false;
+ }
+
+ function overrideAllAnchor(root: HTMLElement) {
+ const allAnchors = root.querySelectorAll("a[href^=taler]");
+ logger.debug(`registering taler protocol in ${allAnchors.length} links`);
+ allAnchors.forEach((link) => {
+ if (link instanceof HTMLElement) {
+ link.addEventListener("click", anchorOnClick);
+ }
+ });
+ }
+
+ function checkForNewAnchors(
+ mutations: MutationRecord[],
+ observer: MutationObserver,
+ ) {
+ mutations.forEach((mut) => {
+ if (mut.type === "childList") {
+ mut.addedNodes.forEach((added) => {
+ if (added instanceof HTMLElement) {
+ logger.debug(`new element`, added);
+ overrideAllAnchor(added);
+ }
+ });
+ }
+ });
+ }
+
+ /**
+ * Check of every anchor and observes for new one.
+ * Register the anchor handler when found
+ */
+ function registerProtocolHandler() {
+ if (document.body) overrideAllAnchor(document.body)
+ new MutationObserver(checkForNewAnchors).observe(document, {
+ childList: true,
+ subtree: true,
+ attributes: false,
+ });
+ }
+
+ return {
+ convertURIToWebExtensionPath,
+ anchorOnClick,
+ registerProtocolHandler,
+ };
+ }
+
+ function start() {
+ if (shouldNotRun) return;
+ if (!(document.currentScript instanceof HTMLScriptElement)) return;
+
+ const url = new URL(document.currentScript.src);
+ const { protocol, searchParams, hostname } = url;
+ const extensionId = searchParams.get("id") ?? "";
+ const debugEnabled = searchParams.get("debug") === "true";
+ const apiEnabled = searchParams.get("api") === "true";
+ const hijackEnabled = searchParams.get("hijack") === "true";
+
+ const info: Info = Object.freeze({
+ extensionId,
+ protocol,
+ hostname,
+ });
+
+ if (debugEnabled) {
+ logger.debug = logger.info;
+ }
+
+ const taler: TalerSupport = {
+ info,
+ __internal: buildApi(info),
+ };
+
+ if (apiEnabled) {
+ //@ts-ignore
+ window.taler = taler;
+ }
+
+ if (hijackEnabled) {
+ taler.__internal.registerProtocolHandler();
+ }
+ }
+
+ // utils functions
+ function validateTalerUri(uri: string): boolean {
+ return (
+ !!uri && (uri.startsWith("taler://") || uri.startsWith("taler+http://"))
+ );
+ }
+
+ start();
+})()
+
diff --git a/packages/taler-wallet-webextension/src/test-utils.ts b/packages/taler-wallet-webextension/src/test-utils.ts
index 6bf1be3ff..452cc578e 100644
--- a/packages/taler-wallet-webextension/src/test-utils.ts
+++ b/packages/taler-wallet-webextension/src/test-utils.ts
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -14,15 +14,201 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { ComponentChildren, FunctionalComponent, h as render } from 'preact';
+import { NotificationType, TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient, WalletNotification } from "@gnu-taler/taler-util";
+import {
+ WalletCoreApiClient,
+ WalletCoreOpKeys,
+ WalletCoreRequestType,
+ WalletCoreResponseType,
+} from "@gnu-taler/taler-wallet-core";
+import { ApiContextProvider, TranslationProvider, defaultRequestHandler } from "@gnu-taler/web-util/browser";
+import {
+ ComponentChildren,
+ FunctionalComponent,
+ VNode,
+ h as create,
+} from "preact";
+import { AlertProvider } from "./context/alert.js";
+import { BackendProvider } from "./context/backend.js";
+import { strings } from "./i18n/strings.js";
+import { nullFunction } from "./mui/handlers.js";
+import { BackgroundApiClient, wxApi } from "./wxApi.js";
-export function createExample<Props>(Component: FunctionalComponent<Props>, props: Partial<Props>) {
- const r = (args: any) => render(Component, args)
- r.args = props
- return r
+// export const nullFunction: any = () => null;
+
+interface MockHandler {
+ addWalletCallResponse<Op extends WalletCoreOpKeys>(
+ operation: Op,
+ payload?: Partial<WalletCoreRequestType<Op>>,
+ response?: WalletCoreResponseType<Op>,
+ callback?: () => void,
+ ): MockHandler;
+
+ getCallingQueueState(): "empty" | string;
+
+ notifyEventFromWallet(notif: WalletNotification): void;
}
+type CallRecord = WalletCallRecord | BackgroundCallRecord;
+interface WalletCallRecord {
+ source: "wallet";
+ callback: () => void;
+ operation: WalletCoreOpKeys;
+ payload?: WalletCoreRequestType<WalletCoreOpKeys>;
+ response?: WalletCoreResponseType<WalletCoreOpKeys>;
+}
+interface BackgroundCallRecord {
+ source: "background";
+ name: string;
+ args: any;
+ response: any;
+}
+
+type Subscriptions = {
+ [key in NotificationType]?: (d: WalletNotification) => void;
+};
+
+export function createWalletApiMock(): {
+ handler: MockHandler;
+ TestingContext: FunctionalComponent<{ children: ComponentChildren }>;
+} {
+ const calls = new Array<CallRecord>();
+ const subscriptions: Subscriptions = {};
+
+ const mock: typeof wxApi = {
+ wallet: new Proxy<WalletCoreApiClient>({} as any, {
+ get(target, name, receiver) {
+ const functionName = String(name);
+ if (functionName !== "call") {
+ throw Error(
+ `the only method in wallet api should be 'call': ${functionName}`,
+ );
+ }
+ return function (
+ operation: WalletCoreOpKeys,
+ payload: WalletCoreRequestType<WalletCoreOpKeys>,
+ ) {
+ const next = calls.shift();
+
+ if (!next) {
+ throw Error(
+ `wallet operation was called but none was expected: ${operation} (${JSON.stringify(
+ payload,
+ undefined,
+ 2,
+ )})`,
+ );
+ }
+ if (next.source !== "wallet") {
+ throw Error(`wallet operation expected`);
+ }
+ if (operation !== next.operation) {
+ //more checks, deep check payload
+ throw Error(
+ `wallet operation doesn't match: expected ${next.operation} actual ${operation}`,
+ );
+ }
+ next.callback();
+
+ return next.response ?? {};
+ };
+ },
+ }),
+ listener: {
+ trigger: () => {
+
+ },
+ onUpdateNotification(
+ mTypes: NotificationType[],
+ callback: ((d: WalletNotification) => void) | undefined,
+ ): () => void {
+ mTypes.forEach((m) => {
+ subscriptions[m] = callback;
+ });
+ return nullFunction;
+ },
+ },
+ background: new Proxy<BackgroundApiClient>({} as any, {
+ get(target, name, receiver) {
+ const functionName = String(name);
+ return function (...args: any) {
+ const next = calls.shift();
+ if (!next) {
+ throw Error(
+ `background operation was called but none was expected: ${functionName} (${JSON.stringify(
+ args,
+ undefined,
+ 2,
+ )})`,
+ );
+ }
+ if (next.source !== "background" || functionName !== next.name) {
+ //more checks, deep check args
+ throw Error(`background operation doesn't match`);
+ }
+ return next.response;
+ };
+ },
+ }),
+ };
+
+ const handler: MockHandler = {
+ addWalletCallResponse(operation, payload, response, cb) {
+ calls.push({
+ source: "wallet",
+ operation,
+ payload,
+ response,
+ callback: cb
+ ? cb
+ : () => {
+ null;
+ },
+ });
+ return handler;
+ },
+ notifyEventFromWallet(event: WalletNotification): void {
+ const callback = subscriptions[event.type];
+ if (!callback)
+ throw Error(`Expected to have a subscription for ${event}`);
+ return callback(event);
+ },
+ getCallingQueueState() {
+ return calls.length === 0 ? "empty" : `${calls.length} left`;
+ },
+ };
+
+ function TestingContext({
+ children: _cs,
+ }: {
+ children: ComponentChildren;
+ }): VNode {
+ let children = _cs;
+ children = create(AlertProvider, { children }, children);
+ const value = {
+ request: defaultRequestHandler,
+ bankCore: new TalerCoreBankHttpClient("/"),
+ bankIntegration: new TalerBankIntegrationHttpClient("/"),
+ bankWire: new TalerWireGatewayHttpClient("/",""),
+ bankRevenue: new TalerRevenueHttpClient("/"),
+ }
+ children = create(ApiContextProvider, { value, children }, children);
+ children = create(
+ TranslationProvider,
+ { children, source: strings, initial: "en", forceLang: "en" },
+ children,
+ );
+ return create(
+ BackendProvider,
+ {
+ wallet: mock.wallet,
+ background: mock.background,
+ listener: mock.listener,
+ children,
+ },
+ children,
+ );
+ }
-export function NullLink({ children }: { children?: ComponentChildren }) {
- return render('a', { children, href: 'javascript:void(0);' })
+ return { handler, TestingContext };
}
diff --git a/packages/taler-wallet-webextension/src/utils/index.ts b/packages/taler-wallet-webextension/src/utils/index.ts
new file mode 100644
index 000000000..d83e6f472
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/utils/index.ts
@@ -0,0 +1,119 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { createElement, VNode } from "preact";
+import { useCallback, useMemo } from "preact/hooks";
+
+function getJsonIfOk(r: Response): Promise<any> {
+ if (r.ok) {
+ return r.json();
+ }
+
+ if (r.status >= 400 && r.status < 500) {
+ throw new Error(`URL may not be right: (${r.status}) ${r.statusText}`);
+ }
+
+ throw new Error(
+ `Try another server: (${r.status}) ${r.statusText || "internal server error"
+ }`,
+ );
+}
+
+export async function queryToSlashConfig<T>(url: string): Promise<T> {
+ return fetch(new URL("config", url).href)
+ .catch(() => {
+ throw new Error(`Network error`);
+ })
+ .then(getJsonIfOk);
+}
+
+function timeout<T>(ms: number, promise: Promise<T>): Promise<T> {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ reject(
+ new Error(
+ `Timeout: the query took longer than ${Math.floor(ms / 1000)} secs`,
+ ),
+ );
+ }, ms);
+
+ promise
+ .then((value) => {
+ clearTimeout(timer);
+ resolve(value);
+ })
+ .catch((reason) => {
+ clearTimeout(timer);
+ reject(reason);
+ });
+ });
+}
+
+export async function queryToSlashKeys<T>(url: string): Promise<T> {
+ const endpoint = new URL("keys", url);
+
+ const query = fetch(endpoint.href)
+ .catch(() => {
+ throw new Error(`Network error`);
+ })
+ .then(getJsonIfOk);
+
+ return timeout(3000, query);
+}
+
+export type StateFunc<S> = (p: S) => VNode | null;
+
+export type StateViewMap<StateType extends { status: string }> = {
+ [S in StateType as S["status"]]: StateFunc<S>;
+};
+
+export type RecursiveState<S extends object> = S | (() => RecursiveState<S>);
+
+export function compose<SType extends { status: string }, PType>(
+ name: string,
+ hook: (p: PType) => RecursiveState<SType>,
+ viewMap: StateViewMap<SType>,
+): (p: PType) => VNode {
+ function withHook(stateHook: () => RecursiveState<SType>): () => VNode {
+ function TheComponent(): VNode {
+ //if the function is the same, do not compute
+ const state = stateHook();
+
+ if (typeof state === "function") {
+ const subComponent = withHook(state);
+ return createElement(subComponent, {});
+ }
+
+ const statusName = state.status as unknown as SType["status"];
+ const viewComponent = viewMap[statusName] as unknown as StateFunc<SType>;
+ return createElement(viewComponent, state);
+ }
+ // TheComponent.name = `${name}`;
+
+ return useMemo(() => {
+ return TheComponent
+ }, [stateHook]);
+ }
+
+ return (p: PType) => {
+ const h = withHook(() => hook(p));
+ return h();
+ };
+}
+
+export function assertUnreachable(x: never): never {
+ throw new Error("Didn't expect to get here");
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts
new file mode 100644
index 000000000..daa6b425d
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts
@@ -0,0 +1,88 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ SyncTermsOfServiceResponse,
+ TalerErrorDetail,
+} from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import {
+ ButtonHandler,
+ TextFieldHandler,
+ ToggleHandler,
+} from "../../mui/handlers.js";
+import { StateViewMap, compose } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ConfirmProviderView, SelectProviderView } from "./views.js";
+
+export interface Props {
+ onBack: () => Promise<void>;
+ onComplete: (pid: string) => Promise<void>;
+ onPaymentRequired: (uri: string) => Promise<void>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.ConfirmProvider
+ | State.SelectProvider;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface ConfirmProvider {
+ status: "confirm-provider";
+ error: undefined;
+ url: string;
+ provider: SyncTermsOfServiceResponse;
+ tos: ToggleHandler;
+ onCancel: ButtonHandler;
+ onAccept: ButtonHandler;
+ }
+
+ export interface SelectProvider {
+ status: "select-provider";
+ url: TextFieldHandler;
+ urlOk: boolean;
+ name: TextFieldHandler;
+ onConfirm: ButtonHandler;
+ onCancel: ButtonHandler;
+ error: undefined | TalerErrorDetail;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ "select-provider": SelectProviderView,
+ "confirm-provider": ConfirmProviderView,
+};
+
+export const AddBackupProviderPage = compose(
+ "AddBackupProvider",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts
new file mode 100644
index 000000000..75b8e53c0
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts
@@ -0,0 +1,263 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ canonicalizeBaseUrl,
+ Codec,
+ codecForSyncTermsOfServiceResponse,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useEffect, useState } from "preact/hooks";
+import { useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { assertUnreachable } from "../../utils/index.js";
+import { Props, State } from "./index.js";
+
+type UrlState<T> = UrlOk<T> | UrlError;
+
+interface UrlOk<T> {
+ status: "ok";
+ result: T;
+}
+type UrlError =
+ | UrlNetworkError
+ | UrlClientError
+ | UrlServerError
+ | UrlParsingError
+ | UrlReadError;
+
+interface UrlNetworkError {
+ status: "network-error";
+ href: string;
+}
+interface UrlClientError {
+ status: "client-error";
+ code: number;
+}
+interface UrlServerError {
+ status: "server-error";
+ code: number;
+}
+interface UrlParsingError {
+ status: "parsing-error";
+ json: any;
+}
+interface UrlReadError {
+ status: "url-error";
+}
+
+function useDebounceEffect(
+ time: number,
+ cb: undefined | (() => Promise<void>),
+ deps: Array<any>,
+): void {
+ const [currentTimer, setCurrentTimer] = useState<any>();
+ useEffect(() => {
+ if (currentTimer !== undefined) clearTimeout(currentTimer);
+ if (cb !== undefined) {
+ const tid = setTimeout(cb, time);
+ setCurrentTimer(tid);
+ }
+ }, deps);
+}
+
+function useUrlState<T>(
+ host: string | undefined,
+ path: string,
+ codec: Codec<T>,
+): UrlState<T> | undefined {
+ const [state, setState] = useState<UrlState<T> | undefined>();
+
+ let href: string | undefined;
+ try {
+ if (host) {
+ const isHttps =
+ host.startsWith("https://") && host.length > "https://".length;
+ const isHttp =
+ host.startsWith("http://") && host.length > "http://".length;
+ const withProto = isHttp || isHttps ? host : `https://${host}`;
+ const baseUrl = canonicalizeBaseUrl(withProto);
+ href = new URL(path, baseUrl).href;
+ }
+ } catch (e) {
+ setState({
+ status: "url-error",
+ });
+ }
+ const constHref = href;
+
+ async function checkURL() {
+ if (!constHref) {
+ return;
+ }
+ const req = await fetch(constHref).catch((e) => {
+ return setState({
+ status: "network-error",
+ href: constHref,
+ });
+ });
+ if (!req) return;
+
+ if (req.status >= 400 && req.status < 500) {
+ setState({
+ status: "client-error",
+ code: req.status,
+ });
+ return;
+ }
+ if (req.status > 500) {
+ setState({
+ status: "server-error",
+ code: req.status,
+ });
+ return;
+ }
+
+ const json = await req.json();
+ try {
+ const result = codec.decode(json);
+ setState({ status: "ok", result });
+ } catch (e: any) {
+ setState({ status: "parsing-error", json });
+ }
+ }
+
+ useDebounceEffect(
+ 500,
+ constHref == undefined ? undefined : checkURL,
+ [host, path],
+ );
+
+ return state;
+}
+
+export function useComponentState({
+ onBack,
+ onComplete,
+ onPaymentRequired,
+}: Props): State {
+ const api = useBackendContext();
+ const [url, setHost] = useState<string | undefined>();
+ const [name, setName] = useState<string | undefined>();
+ const [tos, setTos] = useState(false);
+ const { pushAlertOnError } = useAlertContext();
+ const urlState = useUrlState(
+ url,
+ "config",
+ codecForSyncTermsOfServiceResponse(),
+ );
+ const [showConfirm, setShowConfirm] = useState(false);
+
+ async function addBackupProvider(): Promise<void> {
+ if (!url || !name) return;
+
+ const resp = await api.wallet.call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: url,
+ name: name,
+ activate: true,
+ });
+
+ switch (resp.status) {
+ case "payment-required":
+ if (resp.talerUri) {
+ return onPaymentRequired(resp.talerUri);
+ } else {
+ return onComplete(url);
+ }
+ case "ok":
+ return onComplete(url);
+ default:
+ assertUnreachable(resp);
+ }
+ }
+
+ if (showConfirm && urlState && urlState.status === "ok") {
+ return {
+ status: "confirm-provider",
+ error: undefined,
+ onAccept: {
+ onClick: !tos ? undefined : pushAlertOnError(addBackupProvider),
+ },
+ onCancel: {
+ onClick: pushAlertOnError(onBack),
+ },
+ provider: urlState.result,
+ tos: {
+ value: tos,
+ button: {
+ onClick: pushAlertOnError(async () => setTos(!tos)),
+ },
+ },
+ url: url ?? "",
+ };
+ }
+
+ return {
+ status: "select-provider",
+ error: undefined,
+ name: {
+ value: name || "",
+ onInput: pushAlertOnError(async (e) => setName(e)),
+ error:
+ name === undefined ? undefined : !name ? "Can't be empty" : undefined,
+ },
+ onCancel: {
+ onClick: pushAlertOnError(onBack),
+ },
+ onConfirm: {
+ onClick:
+ !urlState || urlState.status !== "ok" || !name
+ ? undefined
+ : pushAlertOnError(async () => {
+ setShowConfirm(true);
+ }),
+ },
+ urlOk: urlState?.status === "ok",
+ url: {
+ value: url || "",
+ onInput: pushAlertOnError(async (e) => setHost(e)),
+ error: errorString(urlState),
+ },
+ };
+}
+
+function errorString(state: undefined | UrlState<any>): string | undefined {
+ if (!state) return state;
+ switch (state.status) {
+ case "ok":
+ return undefined;
+ case "client-error": {
+ switch (state.code) {
+ case 404:
+ return "Not found";
+ case 401:
+ return "Unauthorized";
+ case 403:
+ return "Forbidden";
+ default:
+ return `Server says it a client error: ${state.code}.`;
+ }
+ }
+ case "server-error":
+ return `Server had a problem ${state.code}.`;
+ case "parsing-error":
+ return `Server response doesn't have the right format.`;
+ case "network-error":
+ return `Unable to connect to ${state.href}.`;
+ case "url-error":
+ return "URL is not complete";
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/stories.tsx b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/stories.tsx
new file mode 100644
index 000000000..7ac92c6c9
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/stories.tsx
@@ -0,0 +1,110 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ConfirmProviderView, SelectProviderView } from "./views.js";
+import { AmountString } from "@gnu-taler/taler-util";
+
+export default {
+ title: "add backup provider",
+};
+
+export const DemoService = tests.createExample(ConfirmProviderView, {
+ url: "https://sync.demo.taler.net/",
+ provider: {
+ annual_fee: "KUDOS:0.1" as AmountString,
+ storage_limit_in_megabytes: 20,
+ version: "1",
+ },
+ tos: {
+ button: {},
+ },
+ onAccept: {},
+ onCancel: {},
+});
+
+export const FreeService = tests.createExample(ConfirmProviderView, {
+ url: "https://sync.taler:9667/",
+ provider: {
+ annual_fee: "ARS:0" as AmountString,
+ storage_limit_in_megabytes: 20,
+ version: "1",
+ },
+ tos: {
+ button: {},
+ },
+ onAccept: {},
+ onCancel: {},
+});
+
+export const Initial = tests.createExample(SelectProviderView, {
+ url: { value: "" },
+ name: { value: "" },
+ onCancel: {},
+ onConfirm: {},
+});
+
+export const WithValue = tests.createExample(SelectProviderView, {
+ url: {
+ value: "sync.demo.taler.net",
+ },
+ name: {
+ value: "Demo backup service",
+ },
+ onCancel: {},
+ onConfirm: {},
+});
+
+export const WithConnectionError = tests.createExample(SelectProviderView, {
+ url: {
+ value: "sync.demo.taler.net",
+ error: "Network error",
+ },
+ name: {
+ value: "Demo backup service",
+ },
+ onCancel: {},
+ onConfirm: {},
+});
+
+export const WithClientError = tests.createExample(SelectProviderView, {
+ url: {
+ value: "sync.demo.taler.net",
+ error: "URL may not be right: (404) Not Found",
+ },
+ name: {
+ value: "Demo backup service",
+ },
+ onCancel: {},
+ onConfirm: {},
+});
+
+export const WithServerError = tests.createExample(SelectProviderView, {
+ url: {
+ value: "sync.demo.taler.net",
+ error: "Try another server: (500) Internal Server Error",
+ },
+ name: {
+ value: "Demo backup service",
+ },
+ onCancel: {},
+ onConfirm: {},
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts
new file mode 100644
index 000000000..058f4f460
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+import * as tests from "@gnu-taler/web-util/testing";
+import { nullFunction } from "../../mui/handlers.js";
+import { createWalletApiMock } from "../../test-utils.js";
+import { Props } from "./index.js";
+import { useComponentState } from "./state.js";
+
+const props: Props = {
+ onBack: nullFunction,
+ onComplete: nullFunction,
+ onPaymentRequired: nullFunction,
+};
+describe("AddBackupProvider states", () => {
+ /**
+ * FIXME: this test has inconsistent behavior.
+ * it should always expect one state but for some reason
+ * (maybe race condition) it sometime expect 1 update when
+ * it should no update
+ */
+ it.skip("should start in 'select-provider' state", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ (state) => {
+ expect(state.status).equal("select-provider");
+ if (state.status !== "select-provider") return;
+ expect(state.name.value).eq("");
+ expect(state.url.value).eq("");
+ },
+ //FIXME: this shouldn't take 2 updates, just
+ // (state) => {
+ // expect(state.status).equal("select-provider");
+ // if (state.status !== "select-provider") return;
+ // expect(state.name.value).eq("");
+ // expect(state.url.value).eq("");
+ // },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/views.tsx b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/views.tsx
new file mode 100644
index 000000000..c67c288dc
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/views.tsx
@@ -0,0 +1,158 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import { Checkbox } from "../../components/Checkbox.js";
+import {
+ LightText,
+ SmallLightText,
+ SubTitle,
+ Title,
+} from "../../components/styled/index.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Button } from "../../mui/Button.js";
+import { TextField } from "../../mui/TextField.js";
+import { State } from "./index.js";
+
+export function ConfirmProviderView({
+ url,
+ provider,
+ tos,
+ onCancel,
+ onAccept,
+}: State.ConfirmProvider): VNode {
+ const { i18n } = useTranslationContext();
+ const noFee = Amounts.isZero(provider.annual_fee);
+ return (
+ <Fragment>
+ <section>
+ <Title>
+ <i18n.Translate>Review terms of service</i18n.Translate>
+ </Title>
+ <div>
+ <i18n.Translate>Provider URL</i18n.Translate>:{" "}
+ <a href={url} target="_blank" rel="noreferrer">
+ {url}
+ </a>
+ </div>
+ <SmallLightText>
+ <i18n.Translate>
+ Please review and accept this provider&apos;s terms of service
+ </i18n.Translate>
+ </SmallLightText>
+ <SubTitle>
+ 1. <i18n.Translate>Pricing</i18n.Translate>
+ </SubTitle>
+ <p>
+ {noFee ? (
+ <i18n.Translate>free of charge</i18n.Translate>
+ ) : (
+ <i18n.Translate>
+ {provider.annual_fee} per year of service
+ </i18n.Translate>
+ )}
+ </p>
+ <SubTitle>
+ 2. <i18n.Translate>Storage</i18n.Translate>
+ </SubTitle>
+ <p>
+ <i18n.Translate>
+ {provider.storage_limit_in_megabytes} megabytes of storage per year
+ of service
+ </i18n.Translate>
+ </p>
+ <Checkbox
+ label={i18n.str`Accept terms of service`}
+ name="terms"
+ onToggle={tos.button.onClick}
+ enabled={tos.value}
+ />
+ </section>
+ <footer>
+ <Button
+ variant="contained"
+ color="secondary"
+ onClick={onCancel.onClick}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ <Button variant="contained" color="primary" onClick={onAccept.onClick}>
+ {noFee ? (
+ <i18n.Translate>Add provider</i18n.Translate>
+ ) : (
+ <i18n.Translate>Pay</i18n.Translate>
+ )}
+ </Button>
+ </footer>
+ </Fragment>
+ );
+}
+
+export function SelectProviderView({
+ url,
+ name,
+ urlOk,
+ onCancel,
+ onConfirm,
+}: State.SelectProvider): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <section>
+ <Title>
+ <i18n.Translate>Add backup provider</i18n.Translate>
+ </Title>
+ <LightText>
+ <i18n.Translate>
+ Backup providers may charge for their service
+ </i18n.Translate>
+ </LightText>
+ <p>
+ <TextField
+ label={<i18n.Translate>URL</i18n.Translate>}
+ placeholder="https://"
+ color={urlOk ? "success" : undefined}
+ value={url.value}
+ error={url.error}
+ onChange={url.onInput}
+ />
+ </p>
+ <p>
+ <TextField
+ label={<i18n.Translate>Name</i18n.Translate>}
+ placeholder="provider name"
+ value={name.value}
+ error={name.error}
+ onChange={name.onInput}
+ />
+ </p>
+ </section>
+ <footer>
+ <Button
+ variant="contained"
+ color="secondary"
+ onClick={onCancel.onClick}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ <Button variant="contained" color="primary" onClick={onConfirm.onClick}>
+ <i18n.Translate>Next</i18n.Translate>
+ </Button>
+ </footer>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts b/packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts
new file mode 100644
index 000000000..94b32c157
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts
@@ -0,0 +1,92 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { OperationFailWithBody, OperationOk, TalerExchangeApi } from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { TextFieldHandler } from "../../mui/handlers.js";
+import { StateViewMap, compose } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ConfirmAddExchangeView, VerifyView } from "./views.js";
+
+export interface Props {
+ currency?: string;
+ onBack: () => Promise<void>;
+ noDebounce?: boolean;
+}
+
+export type State = State.Loading
+ | State.LoadingUriError
+ | State.Confirm
+ | State.Verify;
+
+export type CheckExchangeErrors = {
+ "invalid-version": string;
+ "invalid-currency": string;
+ "not-found": void;
+ "already-active": void;
+ "invalid-protocol": void;
+}
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+ export interface Confirm extends BaseInfo {
+ status: "confirm";
+ url: string;
+ onCancel: () => Promise<void>;
+ onConfirm: () => Promise<void>;
+ error: undefined;
+ }
+ export interface Verify extends BaseInfo {
+ status: "verify";
+ error: undefined;
+
+ onCancel: () => Promise<void>;
+ onAccept: () => Promise<void>;
+
+ url: TextFieldHandler,
+ loading: boolean;
+ knownExchanges: URL[],
+ result: OperationOk<TalerExchangeApi.ExchangeKeysResponse> | OperationFailWithBody<CheckExchangeErrors> | undefined,
+ expectedCurrency: string | undefined,
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ confirm: ConfirmAddExchangeView,
+ verify: VerifyView,
+};
+
+export const AddExchange = compose(
+ "AddExchange",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts b/packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts
new file mode 100644
index 000000000..4a04f762a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts
@@ -0,0 +1,198 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ExchangeEntryStatus, TalerExchangeHttpClient, canonicalizeBaseUrl, opKnownFailureWithBody } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { BrowserFetchHttpLib } from "@gnu-taler/web-util/browser";
+import { useCallback, useEffect, useState } from "preact/hooks";
+import { useBackendContext } from "../../context/backend.js";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { withSafe } from "../../mui/handlers.js";
+import { RecursiveState } from "../../utils/index.js";
+import { CheckExchangeErrors, Props, State } from "./index.js";
+
+function urlFromInput(str: string): URL {
+ let result: URL;
+ try {
+ result = new URL(str)
+ } catch (original) {
+ try {
+ result = new URL(`https://${str}`)
+ } catch (e) {
+ throw original
+ }
+ }
+ if (!result.pathname.endsWith("/")) {
+ result.pathname = result.pathname + "/";
+ }
+ result.search = "";
+ result.hash = "";
+ return result;
+}
+
+export function useComponentState({ onBack, currency, noDebounce }: Props): RecursiveState<State> {
+ const [verified, setVerified] = useState<string>();
+
+ const api = useBackendContext();
+ const hook = useAsyncAsHook(() =>
+ api.wallet.call(WalletApiOperation.ListExchanges, {}),
+ );
+ const walletExchanges = !hook ? [] : hook.hasError ? [] : hook.response.exchanges
+ const used = walletExchanges.filter(e => e.exchangeEntryStatus === ExchangeEntryStatus.Used);
+ const preset = walletExchanges.filter(e => e.exchangeEntryStatus === ExchangeEntryStatus.Preset);
+
+ if (!verified) {
+ return (): State => {
+ const checkExchangeBaseUrl_memo = useCallback(async function checkExchangeBaseUrl(str: string) {
+ const baseUrl = urlFromInput(str)
+ if (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") {
+ return opKnownFailureWithBody<CheckExchangeErrors>("invalid-protocol", undefined)
+ }
+ const found = used.findIndex((e) => e.exchangeBaseUrl === baseUrl.href);
+ if (found !== -1) {
+ return opKnownFailureWithBody<CheckExchangeErrors>("already-active", undefined);
+ }
+
+ /**
+ * FIXME: For some reason typescript doesn't like the next BrowserFetchHttpLib
+ *
+ * │ src/wallet/AddExchange/state.ts(68,63): error TS2345: Argument of type 'BrowserFetchHttpLib' is not assignable to parameter of ty
+ * │ Types of property 'fetch' are incompatible.
+ * │ Type '(requestUrl: string, options?: HttpRequestOptions | undefined) => Promise<HttpResponse>' is not assignable to type '(ur
+ * │ Types of parameters 'options' and 'opt' are incompatible.
+ * │ Type 'import("$PATH/wallet.git/packages/taler-util/lib/http-common", { wi
+ * │ Type 'import("$PATH/wallet.git/packages/taler-util/lib/http-common", {
+ * │ Types of property 'cancellationToken' are incompatible.
+ * │ Type 'import("$PATH/wallet.git/packages/taler-util/lib/Cancellation
+ * │ Type 'import("$PATH/wallet.git/packages/taler-util/lib/Cancellati
+ * │ Types have separate declarations of a private property '_isCancelled'.
+ *
+ */
+ const api = new TalerExchangeHttpClient(baseUrl.href, new BrowserFetchHttpLib() as any);
+ const config = await api.getConfig()
+ if (config.type === "fail") {
+ return opKnownFailureWithBody<CheckExchangeErrors>("not-found", undefined)
+ }
+ if (!api.isCompatible(config.body.version)) {
+ return opKnownFailureWithBody<CheckExchangeErrors>("invalid-version", config.body.version)
+ }
+ if (currency !== undefined && currency !== config.body.currency) {
+ return opKnownFailureWithBody<CheckExchangeErrors>("invalid-currency", config.body.currency)
+ }
+ const keys = await api.getKeys()
+ return keys
+ }, [used])
+
+ const { result, value: url, loading, update, error: requestError } = useDebounce(checkExchangeBaseUrl_memo, noDebounce ?? false)
+ const [inputError, setInputError] = useState<string>()
+
+ return {
+ status: "verify",
+ error: undefined,
+ onCancel: onBack,
+ expectedCurrency: currency,
+ onAccept: async () => {
+ if (!result || result.type !== "ok") return;
+ setVerified(result.body.base_url)
+ },
+ result,
+ loading,
+ knownExchanges: preset.map(e => new URL(e.exchangeBaseUrl)),
+ url: {
+ value: url ?? "",
+ error: inputError ?? requestError,
+ onInput: withSafe(update, (e) => {
+ setInputError(e.message)
+ })
+ },
+ };
+ }
+ }
+
+ async function onConfirm() {
+ if (!verified) return;
+ await api.wallet.call(WalletApiOperation.AddExchange, {
+ exchangeBaseUrl: canonicalizeBaseUrl(verified),
+ forceUpdate: true,
+ });
+ onBack();
+ }
+
+ return {
+ status: "confirm",
+ error: undefined,
+ onCancel: onBack,
+ onConfirm,
+ url: verified
+ };
+}
+
+
+
+function useDebounce<T>(
+ onTrigger: (v: string) => Promise<T>,
+ disabled: boolean,
+): {
+ loading: boolean;
+ error?: Error;
+ value: string | undefined;
+ result: T | undefined;
+ update: (s: string) => void;
+} {
+ const [value, setValue] = useState<string>();
+ const [dirty, setDirty] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState<T | undefined>(undefined);
+ const [error, setError] = useState<Error | undefined>(undefined);
+
+ const [handler, setHandler] = useState<number | undefined>(undefined);
+
+ if (!disabled) {
+ useEffect(() => {
+ if (!value) return;
+ clearTimeout(handler);
+ const h = setTimeout(async () => {
+ setDirty(true);
+ setLoading(true);
+ try {
+ const result = await onTrigger(value);
+ setResult(result);
+ setError(undefined);
+ setLoading(false);
+ } catch (er) {
+ if (er instanceof Error) {
+ setError(er);
+ } else {
+ // @ts-expect-error cause still not in typescript
+ setError(new Error('unkown error on debounce', { cause: er }))
+ }
+ setLoading(false);
+ setResult(undefined);
+ }
+ }, 500);
+ setHandler(h as unknown as number);
+ }, [value, setHandler, onTrigger]);
+ }
+
+ return {
+ error: dirty ? error : undefined,
+ loading: loading,
+ result: result,
+ value: value,
+ update: disabled ? onTrigger : setValue,
+ };
+}
+
diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx b/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx
new file mode 100644
index 000000000..f205b6415
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx
@@ -0,0 +1,27 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+
+export default {
+ title: "example",
+};
+
+// export const Ready = tests.createExample(ReadyView, {});
diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts b/packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts
new file mode 100644
index 000000000..d0e78a94e
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts
@@ -0,0 +1,209 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ ExchangeEntryStatus,
+ ExchangeTosStatus,
+ ExchangeUpdateStatus,
+ ScopeType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { nullFunction } from "../../mui/handlers.js";
+import { createWalletApiMock } from "../../test-utils.js";
+import { Props } from "./index.js";
+import { useComponentState } from "./state.js";
+const props: Props = {
+ onBack: nullFunction,
+ noDebounce: true,
+};
+
+describe("AddExchange states", () => {
+ it("should start in 'verify' state", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.ListExchanges,
+ {},
+ {
+ exchanges: [
+ {
+ exchangeBaseUrl: "http://exchange.local/",
+ ageRestrictionOptions: [],
+ scopeInfo: {
+ currency: "ARS",
+ type: ScopeType.Exchange,
+ url: "http://exchange.local/",
+ },
+ masterPub: "123qwe123",
+ currency: "ARS",
+ exchangeEntryStatus: ExchangeEntryStatus.Ephemeral,
+ tosStatus: ExchangeTosStatus.Pending,
+ exchangeUpdateStatus: ExchangeUpdateStatus.UnavailableUpdate,
+ paytoUris: [],
+ lastUpdateTimestamp: undefined,
+ noFees: false,
+ peerPaymentsDisabled: false,
+ },
+ ],
+ },
+ );
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ (state) => {
+ expect(state.status).equal("verify");
+ if (state.status !== "verify") return;
+ expect(state.url.value).eq("");
+ expect(state.expectedCurrency).is.undefined;
+ expect(state.result).is.undefined;
+ },
+ (state) => {
+ expect(state.status).equal("verify");
+ if (state.status !== "verify") return;
+ expect(state.url.value).eq("");
+ expect(state.expectedCurrency).is.undefined;
+ expect(state.result).is.undefined;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ // it("should not be able to add a known exchange", async () => {
+ // const { handler, TestingContext } = createWalletApiMock();
+
+ // handler.addWalletCallResponse(
+ // WalletApiOperation.ListExchanges,
+ // {},
+ // {
+ // exchanges: [
+ // {
+ // exchangeBaseUrl: "http://exchange.local/",
+ // ageRestrictionOptions: [],
+ // scopeInfo: undefined,
+ // currency: "ARS",
+ // exchangeEntryStatus: ExchangeEntryStatus.Used,
+ // tosStatus: ExchangeTosStatus.Pending,
+ // exchangeUpdateStatus: ExchangeUpdateStatus.Ready,
+ // paytoUris: [],
+ // },
+ // ],
+ // },
+ // );
+
+ // const hookBehavior = await tests.hookBehaveLikeThis(
+ // useComponentState,
+ // props,
+ // [
+ // (state) => {
+ // expect(state.status).equal("verify");
+ // if (state.status !== "verify") return;
+ // expect(state.url.value).eq("");
+ // expect(state.expectedCurrency).is.undefined;
+ // expect(state.result).is.undefined;
+ // },
+ // (state) => {
+ // expect(state.status).equal("verify");
+ // if (state.status !== "verify") return;
+ // expect(state.url.value).eq("");
+ // expect(state.expectedCurrency).is.undefined;
+ // expect(state.result).is.undefined;
+ // expect(state.error).is.undefined;
+ // expect(state.url.onInput).is.not.undefined;
+ // if (!state.url.onInput) return;
+ // state.url.onInput("http://exchange.local/");
+ // },
+ // (state) => {
+ // expect(state.status).equal("verify");
+ // if (state.status !== "verify") return;
+ // expect(state.url.value).eq("");
+ // expect(state.expectedCurrency).is.undefined;
+ // expect(state.result).is.undefined;
+ // expect(state.url.error).eq("This exchange is already active");
+ // expect(state.url.onInput).is.not.undefined;
+ // },
+ // ],
+ // TestingContext,
+ // );
+
+ // expect(hookBehavior).deep.equal({ result: "ok" });
+ // expect(handler.getCallingQueueState()).eq("empty");
+ // });
+
+ // it("should be able to add a preset exchange", async () => {
+ // const { handler, TestingContext } = createWalletApiMock();
+
+ // handler.addWalletCallResponse(
+ // WalletApiOperation.ListExchanges,
+ // {},
+ // {
+ // exchanges: [
+ // {
+ // exchangeBaseUrl: "http://exchange.local/",
+ // ageRestrictionOptions: [],
+ // scopeInfo: undefined,
+ // currency: "ARS",
+ // exchangeEntryStatus: ExchangeEntryStatus.Preset,
+ // tosStatus: ExchangeTosStatus.Pending,
+ // exchangeUpdateStatus: ExchangeUpdateStatus.Ready,
+ // paytoUris: [],
+ // },
+ // ],
+ // },
+ // );
+
+ // const hookBehavior = await tests.hookBehaveLikeThis(
+ // useComponentState,
+ // props,
+ // [
+ // (state) => {
+ // expect(state.status).equal("verify");
+ // if (state.status !== "verify") return;
+ // expect(state.url.value).eq("");
+ // expect(state.expectedCurrency).is.undefined;
+ // expect(state.result).is.undefined;
+ // },
+ // (state) => {
+ // expect(state.status).equal("verify");
+ // if (state.status !== "verify") return;
+ // expect(state.url.value).eq("");
+ // expect(state.expectedCurrency).is.undefined;
+ // expect(state.result).is.undefined;
+ // expect(state.error).is.undefined;
+ // expect(state.url.onInput).is.not.undefined;
+ // if (!state.url.onInput) return;
+ // state.url.onInput("http://exchange.local/");
+ // },
+ // ],
+ // TestingContext,
+ // );
+
+ // expect(hookBehavior).deep.equal({ result: "ok" });
+ // expect(handler.getCallingQueueState()).eq("empty");
+ // });
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx b/packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx
new file mode 100644
index 000000000..f6537bc68
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx
@@ -0,0 +1,251 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { ErrorMessage } from "../../components/ErrorMessage.js";
+import {
+ Input,
+ LightText,
+ SubTitle,
+ Title,
+ WarningBox,
+} from "../../components/styled/index.js";
+import { TermsOfService } from "../../components/TermsOfService/index.js";
+import { Button } from "../../mui/Button.js";
+import { State } from "./index.js";
+import { assertUnreachable } from "@gnu-taler/taler-util";
+
+export function VerifyView({
+ expectedCurrency,
+ onCancel,
+ onAccept,
+ result,
+ loading,
+ knownExchanges,
+ url,
+}: State.Verify): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <section>
+ {!expectedCurrency ? (
+ <Title>
+ <i18n.Translate>Add new exchange</i18n.Translate>
+ </Title>
+ ) : (
+ <SubTitle>
+ <i18n.Translate>Add exchange for {expectedCurrency}</i18n.Translate>
+ </SubTitle>
+ )}
+ {!result && (
+ <LightText>
+ <i18n.Translate>
+ Enter the URL of an exchange you trust.
+ </i18n.Translate>
+ </LightText>
+ )}
+ {(() => {
+ if (!result) return;
+ if (result.type == "ok") {
+ return (
+ <LightText>
+ <i18n.Translate>
+ An exchange has been found! Review the information and click
+ next
+ </i18n.Translate>
+ </LightText>
+ );
+ }
+ switch (result.case) {
+ case "already-active": {
+ return (
+ <WarningBox>
+ <i18n.Translate>
+ This exchange is already in your list.
+ </i18n.Translate>
+ </WarningBox>
+ );
+ }
+ case "invalid-protocol": {
+ return (
+ <WarningBox>
+ <i18n.Translate>
+ Only exchange accessible through "http" and "https" are
+ allowed.
+ </i18n.Translate>
+ </WarningBox>
+ );
+ }
+ case "invalid-version": {
+ return (
+ <WarningBox>
+ <i18n.Translate>
+ This exchange protocol version is not supported: "
+ {result.body}".
+ </i18n.Translate>
+ </WarningBox>
+ );
+ }
+ case "invalid-currency": {
+ return (
+ <WarningBox>
+ <i18n.Translate>
+ This exchange currency "{result.body}" doesn&apos;t match
+ the expected currency {expectedCurrency}.
+ </i18n.Translate>
+ </WarningBox>
+ );
+ }
+ case "not-found": {
+ return (
+ <WarningBox>
+ <i18n.Translate>
+ No exchange found in that URL.
+ </i18n.Translate>
+ </WarningBox>
+ );
+ }
+ default: {
+ assertUnreachable(result.case);
+ }
+ }
+ })()}
+ <p>
+ <Input invalid={result && result.type !== "ok"}>
+ <label>URL</label>
+ <input
+ type="text"
+ placeholder="https://"
+ value={url.value}
+ onInput={(e) => {
+ if (url.onInput) {
+ url.onInput(e.currentTarget.value);
+ }
+ }}
+ />
+ </Input>
+ {loading && (
+ <div>
+ <i18n.Translate>loading</i18n.Translate>...
+ </div>
+ )}
+ {result && result.type === "ok" && (
+ <Fragment>
+ <Input>
+ <label>
+ <i18n.Translate>Version</i18n.Translate>
+ </label>
+ <input type="text" disabled value={result.body.version} />
+ </Input>
+ <Input>
+ <label>
+ <i18n.Translate>Currency</i18n.Translate>
+ </label>
+ <input type="text" disabled value={result.body.currency} />
+ </Input>
+ </Fragment>
+ )}
+ </p>
+ {url.value && url.error && (
+ <ErrorMessage
+ title={i18n.str`Can't use the URL: "${url.value}"`}
+ description={url.error}
+ />
+ )}
+ </section>
+ <footer>
+ <Button variant="contained" color="secondary" onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ <Button
+ variant="contained"
+ disabled={!result || result.type !== "ok"}
+ onClick={onAccept}
+ >
+ <i18n.Translate>Next</i18n.Translate>
+ </Button>
+ </footer>
+ <section>
+ <ul>
+ {knownExchanges.map((ex) => {
+ return (
+ <li key={ex.href}>
+ <a
+ href="#"
+ onClick={(e) => {
+ if (url.onInput) {
+ url.onInput(ex.href);
+ }
+ e.preventDefault();
+ }}
+ >
+ {ex.href}
+ </a>
+ </li>
+ );
+ })}
+ </ul>
+ </section>
+ </Fragment>
+ );
+}
+
+export function ConfirmAddExchangeView({
+ url,
+ onCancel,
+ onConfirm,
+}: State.Confirm): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <section>
+ <Title>
+ <i18n.Translate>Review terms of service</i18n.Translate>
+ </Title>
+ <div>
+ <i18n.Translate>Exchange URL</i18n.Translate>:
+ <a href={url} target="_blank" rel="noreferrer">
+ {url}
+ </a>
+ </div>
+ </section>
+
+ <TermsOfService key="terms" exchangeUrl={url}>
+ <footer>
+ <Button
+ key="cancel"
+ variant="contained"
+ color="secondary"
+ onClick={onCancel}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ <Button
+ key="add"
+ variant="contained"
+ color="success"
+ onClick={onConfirm}
+ >
+ <i18n.Translate>Add exchange</i18n.Translate>
+ </Button>
+ </footer>
+ </TermsOfService>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/AddNewActionView.stories.tsx b/packages/taler-wallet-webextension/src/wallet/AddNewActionView.stories.tsx
new file mode 100644
index 000000000..704f9e9a1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddNewActionView.stories.tsx
@@ -0,0 +1,33 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { AddNewActionView as TestedComponent } from "./AddNewActionView.js";
+
+export default {
+ title: "add new action",
+ component: TestedComponent,
+ argTypes: {
+ setDeviceName: () => Promise.resolve(),
+ },
+};
+
+export const Initial = tests.createExample(TestedComponent, {});
diff --git a/packages/taler-wallet-webextension/src/wallet/AddNewActionView.tsx b/packages/taler-wallet-webextension/src/wallet/AddNewActionView.tsx
new file mode 100644
index 000000000..dd1777fd1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/AddNewActionView.tsx
@@ -0,0 +1,79 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { parseTalerUri, TalerUriAction } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { InputWithLabel } from "../components/styled/index.js";
+import { Button } from "../mui/Button.js";
+import { platform } from "../platform/foreground.js";
+
+export interface Props {
+ onCancel: () => Promise<void>;
+}
+
+export function AddNewActionView({ onCancel }: Props): VNode {
+ const [url, setUrl] = useState("");
+ const uri = parseTalerUri(url);
+ const { i18n } = useTranslationContext();
+
+ async function redirectToWallet(): Promise<void> {
+ platform.openWalletURIFromPopup(uri!);
+ }
+
+ return (
+ <Fragment>
+ <section>
+ <InputWithLabel invalid={url !== "" && !uri}>
+ <label>GNU Taler URI</label>
+ <div>
+ <input
+ style={{ width: "100%" }}
+ type="text"
+ value={url}
+ placeholder="taler://pay/...."
+ onInput={(e) => setUrl(e.currentTarget.value)}
+ />
+ </div>
+ </InputWithLabel>
+ </section>
+ <footer>
+ <Button variant="contained" color="secondary" onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ {uri && (
+ <Button
+ variant="contained"
+ color="success"
+ onClick={redirectToWallet}
+ >
+ {(() => {
+ switch (uri.type) {
+ case TalerUriAction.Pay:
+ return <i18n.Translate>Open pay page</i18n.Translate>;
+ case TalerUriAction.Refund:
+ return <i18n.Translate>Open refund page</i18n.Translate>;
+ case TalerUriAction.Withdraw:
+ return <i18n.Translate>Open withdraw page</i18n.Translate>;
+ }
+ return <Fragment />;
+ })()}
+ </Button>
+ )}
+ </footer>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/Application.tsx b/packages/taler-wallet-webextension/src/wallet/Application.tsx
new file mode 100644
index 000000000..884c2eab7
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/Application.tsx
@@ -0,0 +1,677 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Main entry point for extension pages.
+ *
+ * @author sebasjm
+ */
+
+import {
+ Amounts,
+ TalerUri,
+ TalerUriAction,
+ TranslatedString,
+ parseTalerUri,
+ stringifyTalerUri,
+} from "@gnu-taler/taler-util";
+import {
+ TranslationProvider,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { createHashHistory } from "history";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { Route, Router, route } from "preact-router";
+import { useEffect } from "preact/hooks";
+import {
+ Pages,
+ WalletNavBar,
+ WalletNavBarOptions,
+ getPathnameForTalerURI,
+} from "../NavigationBar.js";
+import { AlertView, CurrentAlerts } from "../components/CurrentAlerts.js";
+import { LogoHeader } from "../components/LogoHeader.js";
+import PendingTransactions from "../components/PendingTransactions.js";
+import {
+ LinkPrimary,
+ RedBanner,
+ SubTitle,
+ WalletAction,
+ WalletBox,
+} from "../components/styled/index.js";
+import { AlertProvider } from "../context/alert.js";
+import { IoCProviderForRuntime } from "../context/iocContext.js";
+import { DepositPage as DepositPageCTA } from "../cta/Deposit/index.js";
+import { InvoiceCreatePage } from "../cta/InvoiceCreate/index.js";
+import { InvoicePayPage } from "../cta/InvoicePay/index.js";
+import { PaymentPage } from "../cta/Payment/index.js";
+import { PaymentTemplatePage } from "../cta/PaymentTemplate/index.js";
+import { RecoveryPage } from "../cta/Recovery/index.js";
+import { RefundPage } from "../cta/Refund/index.js";
+import { TransferCreatePage } from "../cta/TransferCreate/index.js";
+import { TransferPickupPage } from "../cta/TransferPickup/index.js";
+import {
+ WithdrawPageFromParams,
+ WithdrawPageFromURI,
+} from "../cta/Withdraw/index.js";
+import { useIsOnline } from "../hooks/useIsOnline.js";
+import { strings } from "../i18n/strings.js";
+import CloseIcon from "../svg/close_24px.inline.svg";
+import { AddBackupProviderPage } from "./AddBackupProvider/index.js";
+import { AddExchange } from "./AddExchange/index.js";
+import { BackupPage } from "./BackupPage.js";
+import { DepositPage } from "./DepositPage/index.js";
+import { DestinationSelectionPage } from "./DestinationSelection/index.js";
+import { DeveloperPage } from "./DeveloperPage.js";
+import { HistoryPage } from "./History.js";
+import { NotificationsPage } from "./Notifications/index.js";
+import { ProviderDetailPage } from "./ProviderDetailPage.js";
+import { QrReaderPage } from "./QrReader.js";
+import { SettingsPage } from "./Settings.js";
+import { TransactionPage } from "./Transaction.js";
+import { WelcomePage } from "./Welcome.js";
+import { WalletActivity } from "../components/WalletActivity.js";
+import { EnabledBySettings } from "../components/EnabledBySettings.js";
+import { DevExperimentPage } from "../cta/DevExperiment/index.js";
+import { ConfirmAddExchangeView } from "./AddExchange/views.js";
+
+export function Application(): VNode {
+ const { i18n } = useTranslationContext();
+ const hash_history = createHashHistory();
+
+ async function redirectToTxInfo(tid: string): Promise<void> {
+ redirectTo(Pages.balanceTransaction({ tid }));
+ }
+ function redirectToURL(str: string): void {
+ window.location.href = new URL(str).href
+ }
+
+ return (
+ <TranslationProvider source={strings}>
+ <IoCProviderForRuntime>
+ <Router history={hash_history}>
+ <Route
+ path={Pages.welcome}
+ component={() => (
+ <WalletTemplate goToURL={redirectToURL}>
+ <WelcomePage />
+ </WalletTemplate>
+ )}
+ />
+
+ <Route
+ path={Pages.qr}
+ component={() => (
+ <WalletTemplate goToTransaction={redirectToTxInfo} goToURL={redirectToURL}>
+ <QrReaderPage
+ onDetected={(talerActionUrl: TalerUri) => {
+ redirectTo(
+ Pages.defaultCta({
+ uri: stringifyTalerUri(talerActionUrl),
+ }),
+ );
+ }}
+ />
+ </WalletTemplate>
+ )}
+ />
+
+ <Route
+ path={Pages.settings}
+ component={() => (
+ <WalletTemplate goToTransaction={redirectToTxInfo} goToURL={redirectToURL}>
+ <SettingsPage />
+ </WalletTemplate>
+ )}
+ />
+ <Route
+ path={Pages.notifications}
+ component={() => (
+ <WalletTemplate goToURL={redirectToURL}>
+ <NotificationsPage />
+ </WalletTemplate>
+ )}
+ />
+ {/**
+ * SETTINGS
+ */}
+ <Route
+ path={Pages.settingsExchangeAdd.pattern}
+ component={() => (
+ <WalletTemplate goToURL={redirectToURL}>
+ <AddExchange onBack={() => redirectTo(Pages.balance)} />
+ </WalletTemplate>
+ )}
+ />
+
+<Route
+ path={Pages.balanceHistory.pattern}
+ component={({ currency }: { currency?: string }) => (
+ <WalletTemplate path="balance" goToTransaction={redirectToTxInfo} goToURL={redirectToURL}>
+ <HistoryPage
+ currency={currency}
+ goToWalletDeposit={(currency: string) =>
+ redirectTo(Pages.sendCash({ amount: `${currency}:0` }))
+ }
+ goToWalletManualWithdraw={(currency?: string) =>
+ redirectTo(
+ Pages.receiveCash({
+ amount: !currency ? undefined : `${currency}:0`,
+ }),
+ )
+ }
+ />
+ </WalletTemplate>
+ )}
+ />
+ <Route
+ path={Pages.searchHistory.pattern}
+ component={({ currency }: { currency?: string }) => (
+ <WalletTemplate path="balance" goToTransaction={redirectToTxInfo} goToURL={redirectToURL}>
+ <HistoryPage
+ currency={currency}
+ search
+ goToWalletDeposit={(currency: string) =>
+ redirectTo(Pages.sendCash({ amount: `${currency}:0` }))
+ }
+ goToWalletManualWithdraw={(currency?: string) =>
+ redirectTo(
+ Pages.receiveCash({
+ amount: !currency ? undefined : `${currency}:0`,
+ }),
+ )
+ }
+ />
+ </WalletTemplate>
+ )}
+ />
+ <Route
+ path={Pages.sendCash.pattern}
+ component={({ amount }: { amount?: string }) => (
+ <WalletTemplate path="balance" goToURL={redirectToURL}>
+ <DestinationSelectionPage
+ type="send"
+ amount={amount}
+ goToWalletBankDeposit={(amount: string) =>
+ redirectTo(Pages.balanceDeposit({ amount }))
+ }
+ goToWalletWalletSend={(amount: string) =>
+ redirectTo(Pages.ctaTransferCreate({ amount }))
+ }
+ />
+ </WalletTemplate>
+ )}
+ />
+ <Route
+ path={Pages.receiveCash.pattern}
+ component={({ amount }: { amount?: string }) => (
+ <WalletTemplate path="balance" goToURL={redirectToURL}>
+ <DestinationSelectionPage
+ type="get"
+ amount={amount}
+ goToWalletManualWithdraw={(amount?: string) =>
+ redirectTo(Pages.ctaWithdrawManual({ amount }))
+ }
+ goToWalletWalletInvoice={(amount?: string) =>
+ redirectTo(Pages.ctaInvoiceCreate({ amount }))
+ }
+ />
+ </WalletTemplate>
+ )}
+ />
+
+ <Route
+ path={Pages.balanceTransaction.pattern}
+ component={({ tid }: { tid: string }) => (
+ <WalletTemplate path="balance" goToURL={redirectToURL}>
+ <TransactionPage
+ tid={tid}
+ goToWalletHistory={(currency?: string) =>
+ redirectTo(Pages.balanceHistory({ currency }))
+ }
+ />
+ </WalletTemplate>
+ )}
+ />
+
+ <Route
+ path={Pages.balanceDeposit.pattern}
+ component={({ amount }: { amount: string }) => (
+ <WalletTemplate path="balance" goToURL={redirectToURL}>
+ <DepositPage
+ amount={amount}
+ onCancel={(currency: string) => {
+ redirectTo(Pages.balanceHistory({ currency }));
+ }}
+ onSuccess={(currency: string) => {
+ redirectTo(Pages.balanceHistory({ currency }));
+ }}
+ />
+ </WalletTemplate>
+ )}
+ />
+
+ <Route
+ path={Pages.backup}
+ component={() => (
+ <WalletTemplate path="backup" goToTransaction={redirectToTxInfo} goToURL={redirectToURL}>
+ <BackupPage
+ onAddProvider={() => redirectTo(Pages.backupProviderAdd)}
+ />
+ </WalletTemplate>
+ )}
+ />
+ <Route
+ path={Pages.backupProviderDetail.pattern}
+ component={({ pid }: { pid: string }) => (
+ <WalletTemplate goToURL={redirectToURL}>
+ <ProviderDetailPage
+ pid={pid}
+ onPayProvider={(uri: string) =>
+ redirectTo(`${Pages.ctaPay}?talerPayUri=${uri}`)
+ }
+ onWithdraw={(amount: string) =>
+ redirectTo(Pages.receiveCash({ amount }))
+ }
+ onBack={() => redirectTo(Pages.backup)}
+ />
+ </WalletTemplate>
+ )}
+ />
+ <Route
+ path={Pages.backupProviderAdd}
+ component={() => (
+ <WalletTemplate goToURL={redirectToURL}>
+ <AddBackupProviderPage
+ onPaymentRequired={(uri: string) =>
+ redirectTo(`${Pages.ctaPay}?talerPayUri=${uri}`)
+ }
+ onComplete={(pid: string) =>
+ redirectTo(Pages.backupProviderDetail({ pid }))
+ }
+ onBack={() => redirectTo(Pages.backup)}
+ />
+ </WalletTemplate>
+ )}
+ />
+
+ {/**
+ * DEV
+ */}
+ <Route
+ path={Pages.dev}
+ component={() => (
+ <WalletTemplate path="dev" goToTransaction={redirectToTxInfo} goToURL={redirectToURL}>
+ <DeveloperPage />
+ </WalletTemplate>
+ )}
+ />
+
+ {/**
+ * CALL TO ACTION
+ */}
+ <Route
+ path={Pages.defaultCta.pattern}
+ component={({ uri }: { uri: string }) => {
+ const path = getPathnameForTalerURI(uri);
+ if (!path) {
+ return (
+ <CallToActionTemplate title={i18n.str`Taler URI handler`}>
+ <AlertView
+ alert={{
+ type: "warning",
+ message: i18n.str`Could not found a handler for the Taler URI`,
+ description: i18n.str`The uri read in the path parameter is not valid: "${uri}"`,
+ }}
+ />
+ </CallToActionTemplate>
+ );
+ }
+ return <Redirect to={path} />;
+ }}
+ />
+ <Route
+ path={Pages.ctaPay}
+ component={({ talerUri }: { talerUri: string }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash payment`}>
+ <PaymentPage
+ talerPayUri={decodeURIComponent(talerUri)}
+ goToWalletManualWithdraw={(amount?: string) =>
+ redirectTo(Pages.receiveCash({ amount }))
+ }
+ cancel={() => redirectTo(Pages.balance)}
+ onSuccess={(tid: string) =>
+ redirectTo(Pages.balanceTransaction({ tid }))
+ }
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaPayTemplate}
+ component={({ talerUri }: { talerUri: string }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash payment`}>
+ <PaymentTemplatePage
+ talerTemplateUri={decodeURIComponent(talerUri)}
+ goToWalletManualWithdraw={(amount?: string) =>
+ redirectTo(Pages.receiveCash({ amount }))
+ }
+ cancel={() => redirectTo(Pages.balance)}
+ onSuccess={(tid: string) =>
+ redirectTo(Pages.balanceTransaction({ tid }))
+ }
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaRefund}
+ component={({ talerUri }: { talerUri: string }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash refund`}>
+ <RefundPage
+ talerRefundUri={decodeURIComponent(talerUri)}
+ cancel={() => redirectTo(Pages.balance)}
+ onSuccess={(tid: string) =>
+ redirectTo(Pages.balanceTransaction({ tid }))
+ }
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaWithdraw}
+ component={({ talerUri }: { talerUri: string }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash withdrawal`}>
+ <WithdrawPageFromURI
+ talerWithdrawUri={decodeURIComponent(talerUri)}
+ cancel={() => redirectTo(Pages.balance)}
+ onSuccess={(tid: string) =>
+ redirectTo(Pages.balanceTransaction({ tid }))
+ }
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaWithdrawManual.pattern}
+ component={({
+ amount,
+ talerUri,
+ }: {
+ amount: string;
+ talerUri: string;
+ }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash withdrawal`}>
+ <WithdrawPageFromParams
+ onAmountChanged={async (newamount) => {
+ const page = `${Pages.ctaWithdrawManual({ amount: newamount })}?talerUri=${encodeURIComponent(talerUri)}`;
+ redirectTo(page);
+ }}
+ talerExchangeWithdrawUri={talerUri}
+ amount={amount}
+ cancel={() => redirectTo(Pages.balance)}
+ onSuccess={(tid: string) =>
+ redirectTo(Pages.balanceTransaction({ tid }))
+ }
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaDeposit}
+ component={({
+ amount,
+ talerUri,
+ }: {
+ amount: string;
+ talerUri: string;
+ }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash deposit`}>
+ <DepositPageCTA
+ amountStr={Amounts.stringify(Amounts.parseOrThrow(amount))}
+ talerDepositUri={decodeURIComponent(talerUri)}
+ cancel={() => redirectTo(Pages.balance)}
+ onSuccess={(tid: string) =>
+ redirectTo(Pages.balanceTransaction({ tid }))
+ }
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaInvoiceCreate.pattern}
+ component={({ amount }: { amount: string }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash invoice`}>
+ <InvoiceCreatePage
+ amount={Amounts.stringify(Amounts.parseOrThrow(amount))}
+ onClose={() => redirectTo(Pages.balance)}
+ onSuccess={(tid: string) =>
+ redirectTo(Pages.balanceTransaction({ tid }))
+ }
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaTransferCreate.pattern}
+ component={({ amount }: { amount: string }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash transfer`}>
+ <TransferCreatePage
+ amount={Amounts.stringify(Amounts.parseOrThrow(amount))}
+ onClose={() => redirectTo(Pages.balance)}
+ onSuccess={(tid: string) =>
+ redirectTo(Pages.balanceTransaction({ tid }))
+ }
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaInvoicePay}
+ component={({ talerUri }: { talerUri: string }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash invoice`}>
+ <InvoicePayPage
+ talerPayPullUri={decodeURIComponent(talerUri)}
+ goToWalletManualWithdraw={(amount?: string) =>
+ redirectTo(Pages.receiveCash({ amount }))
+ }
+ onClose={() => redirectTo(Pages.balance)}
+ onSuccess={(tid: string) =>
+ redirectTo(Pages.balanceTransaction({ tid }))
+ }
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaTransferPickup}
+ component={({ talerUri }: { talerUri: string }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash transfer`}>
+ <TransferPickupPage
+ talerPayPushUri={decodeURIComponent(talerUri)}
+ onClose={() => redirectTo(Pages.balance)}
+ onSuccess={(tid: string) =>
+ redirectTo(Pages.balanceTransaction({ tid }))
+ }
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaRecovery}
+ component={({ talerRecoveryUri }: { talerRecoveryUri: string }) => (
+ <CallToActionTemplate title={i18n.str`Digital cash recovery`}>
+ <RecoveryPage
+ talerRecoveryUri={decodeURIComponent(talerRecoveryUri)}
+ onCancel={() => redirectTo(Pages.balance)}
+ onSuccess={() => redirectTo(Pages.backup)}
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaExperiment}
+ component={({ talerUri }: { talerUri: string }) => (
+ <CallToActionTemplate title={i18n.str`Development experiment`}>
+ <DevExperimentPage
+ talerExperimentUri={decodeURIComponent(talerUri)}
+ onCancel={() => redirectTo(Pages.balanceHistory({}))}
+ onSuccess={() => redirectTo(Pages.balanceHistory({}))}
+ />
+ </CallToActionTemplate>
+ )}
+ />
+ <Route
+ path={Pages.ctaAddExchange}
+ component={({ talerUri }: { talerUri: string }) => {
+ const tUri = parseTalerUri(decodeURIComponent(talerUri))
+ const baseUrl = tUri?.type === TalerUriAction.AddExchange ? tUri.exchangeBaseUrl : undefined
+ if (!baseUrl) {
+ redirectTo(Pages.balanceHistory({}))
+ return <div>
+ invalid url {talerUri}
+ </div>
+ }
+ return <CallToActionTemplate title={i18n.str`Add exchange`}>
+ <ConfirmAddExchangeView
+ url={baseUrl}
+ status="confirm"
+ error={undefined}
+ onCancel={() => redirectTo(Pages.balanceHistory({}))}
+ onConfirm={() => redirectTo(Pages.balanceHistory({}))}
+ />
+ </CallToActionTemplate>
+ }}
+ />
+ {/**
+ * NOT FOUND
+ * all redirects should be at the end
+ */}
+ <Route
+ path={Pages.balance}
+ component={() => <Redirect to={Pages.balanceHistory({})} />}
+ />
+
+ <Route
+ default
+ component={() => <Redirect to={Pages.balanceHistory({})} />}
+ />
+ </Router>
+ <EnabledBySettings name="showWalletActivity">
+ <WalletActivity />
+ </EnabledBySettings>
+ </IoCProviderForRuntime>
+ </TranslationProvider>
+ );
+}
+
+async function redirectTo(location: string): Promise<void> {
+ route(location);
+}
+
+function Redirect({ to }: { to: string }): null {
+ useEffect(() => {
+ route(to, true);
+ });
+ return null;
+}
+
+// function matchesRoute(url: string, route: string): boolean {
+// type MatcherFunc = (
+// url: string,
+// route: string,
+// opts: any,
+// ) => Record<string, string> | false;
+
+// const internalPreactMatcher: MatcherFunc = (Router as any).exec;
+// const result = internalPreactMatcher(url, route, {});
+// return !result ? false : true;
+// }
+
+function CallToActionTemplate({
+ title,
+ children,
+}: {
+ title: TranslatedString;
+ children: ComponentChildren;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <WalletAction>
+ <LogoHeader />
+ <section style={{ display: "flex", justifyContent: "right", margin: 0 }}>
+ <LinkPrimary href={Pages.balance}>
+ <div
+ style={{
+ height: 24,
+ width: 24,
+ marginLeft: 4,
+ marginRight: 4,
+ border: "1px solid black",
+ borderRadius: 12,
+ }}
+ dangerouslySetInnerHTML={{ __html: CloseIcon }}
+ />
+ </LinkPrimary>
+ </section>
+ <SubTitle>{title}</SubTitle>
+ <AlertProvider>
+ <CurrentAlerts />
+ {children}
+ </AlertProvider>
+ <section style={{ display: "flex", justifyContent: "right" }}>
+ <LinkPrimary href={Pages.balance}>
+ <i18n.Translate>Return to wallet</i18n.Translate>
+ </LinkPrimary>
+ </section>
+ </WalletAction>
+ );
+}
+
+function WalletTemplate({
+ path,
+ children,
+ goToTransaction,
+ goToURL,
+}: {
+ path?: WalletNavBarOptions;
+ children: ComponentChildren;
+ goToTransaction?: (id: string) => Promise<void>;
+ goToURL: (url: string) => void;
+}): VNode {
+ const online = useIsOnline();
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ {!online && (
+ <div style={{ display: "flex", justifyContent: "center" }}>
+ <RedBanner>{i18n.str`Network is offline`}</RedBanner>
+ </div>
+ )}
+ <LogoHeader />
+ <WalletNavBar path={path} />
+ <PendingTransactions
+ goToTransaction={goToTransaction}
+ goToURL={goToURL} />
+ <WalletBox>
+ <AlertProvider>
+ <CurrentAlerts />
+ {children}
+ </AlertProvider>
+ </WalletBox>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx
index 9a53fefe2..cc7c9af67 100644
--- a/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,179 +15,183 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { ProviderPaymentType } from '@gnu-taler/taler-wallet-core';
-import { addDays } from 'date-fns';
-import { BackupView as TestedComponent } from './BackupPage';
-import { createExample } from '../test-utils';
+import {
+ AbsoluteTime,
+ AmountString,
+ ProviderPaymentType,
+ TalerPreciseTimestamp,
+} from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { addDays } from "date-fns";
+import {
+ ShowRecoveryInfo,
+ BackupView as TestedComponent,
+} from "./BackupPage.js";
export default {
- title: 'wallet/backup/list',
- component: TestedComponent,
- argTypes: {
- onRetry: { action: 'onRetry' },
- onDelete: { action: 'onDelete' },
- onBack: { action: 'onBack' },
- }
+ title: "backup",
};
-
-export const LotOfProviders = createExample(TestedComponent, {
- providers: [{
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
- },
- "terms": {
- "annualFee": "ARS:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
+export const LotOfProviders = tests.createExample(TestedComponent, {
+ providers: [
+ {
+ active: true,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.taler:9967/",
+ lastSuccessfulBackupTimestamp:
+ TalerPreciseTimestamp.fromSeconds(1625063925),
+ paymentProposalIds: [
+ "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG",
+ ],
+ paymentStatus: {
+ type: ProviderPaymentType.Paid,
+ paidUntil: AbsoluteTime.fromMilliseconds(1656599921000),
+ },
+ terms: {
+ annualFee: "ARS:1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
},
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": addDays(new Date(), 13).getTime()
- }
+ {
+ active: true,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.taler:9967/",
+ lastSuccessfulBackupTimestamp:
+ TalerPreciseTimestamp.fromSeconds(1625063925),
+ paymentProposalIds: [
+ "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG",
+ ],
+ paymentStatus: {
+ type: ProviderPaymentType.Paid,
+ paidUntil: AbsoluteTime.fromMilliseconds(
+ addDays(new Date(), 13).getTime(),
+ ),
+ },
+ terms: {
+ annualFee: "ARS:1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
},
- "terms": {
- "annualFee": "ARS:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.Pending,
+ {
+ active: false,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.demo.taler.net/",
+ paymentProposalIds: [],
+ paymentStatus: {
+ type: ProviderPaymentType.Pending,
+ talerUri: "taler://",
+ },
+ terms: {
+ annualFee: "KUDOS:0.1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
},
- "terms": {
- "annualFee": "KUDOS:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.InsufficientBalance,
+ {
+ active: false,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.demo.taler.net/",
+ paymentProposalIds: [],
+ paymentStatus: {
+ type: ProviderPaymentType.InsufficientBalance,
+ amount: "KUDOS:10" as AmountString,
+ },
+ terms: {
+ annualFee: "KUDOS:0.1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
},
- "terms": {
- "annualFee": "KUDOS:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.TermsChanged,
- newTerms: {
- annualFee: 'USD:2',
- storageLimitInMegabytes: 8,
- supportedProtocolVersion: '2',
- },
- oldTerms: {
- annualFee: 'USD:1',
+ {
+ active: false,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.demo.taler.net/",
+ paymentProposalIds: [],
+ paymentStatus: {
+ type: ProviderPaymentType.TermsChanged,
+ newTerms: {
+ annualFee: "USD:2" as AmountString,
+ storageLimitInMegabytes: 8,
+ supportedProtocolVersion: "2",
+ },
+ oldTerms: {
+ annualFee: "USD:1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "1",
+ },
+ paidUntil: AbsoluteTime.never(),
+ },
+ terms: {
+ annualFee: "KUDOS:0.1" as AmountString,
storageLimitInMegabytes: 16,
- supportedProtocolVersion: '1',
-
+ supportedProtocolVersion: "0.0",
},
- paidUntil: {
- t_ms: 'never'
- }
},
- "terms": {
- "annualFee": "KUDOS:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.Unpaid,
+ {
+ active: false,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.demo.taler.net/",
+ paymentProposalIds: [],
+ paymentStatus: {
+ type: ProviderPaymentType.Unpaid,
+ },
+ terms: {
+ annualFee: "KUDOS:0.1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
},
- "terms": {
- "annualFee": "KUDOS:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }, {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.Unpaid,
+ {
+ active: false,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.demo.taler.net/",
+ paymentProposalIds: [],
+ paymentStatus: {
+ type: ProviderPaymentType.Unpaid,
+ },
+ terms: {
+ annualFee: "KUDOS:0.1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
},
- "terms": {
- "annualFee": "KUDOS:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }]
+ ],
});
-
-export const OneProvider = createExample(TestedComponent, {
- providers: [{
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
+export const OneProvider = tests.createExample(TestedComponent, {
+ providers: [
+ {
+ active: true,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.taler:9967/",
+ lastSuccessfulBackupTimestamp:
+ TalerPreciseTimestamp.fromSeconds(1625063925),
+ paymentProposalIds: [
+ "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG",
+ ],
+ paymentStatus: {
+ type: ProviderPaymentType.Paid,
+ paidUntil: AbsoluteTime.fromMilliseconds(1656599921000),
+ },
+ terms: {
+ annualFee: "ARS:1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
},
- "terms": {
- "annualFee": "ARS:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }]
+ ],
});
-
-export const Empty = createExample(TestedComponent, {
- providers: []
+export const Empty = tests.createExample(TestedComponent, {
+ providers: [],
});
+export const Recovery = tests.createExample(ShowRecoveryInfo, {
+ info: "taler://recovery/ASLDKJASLKDJASD",
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx b/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx
index 712329bf8..8a3710f69 100644
--- a/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx
@@ -1,146 +1,356 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
-
-import { i18n, Timestamp } from "@gnu-taler/taler-util";
-import { ProviderInfo, ProviderPaymentStatus } from "@gnu-taler/taler-wallet-core";
-import { differenceInMonths, formatDuration, intervalToDuration } from "date-fns";
-import { Fragment, JSX, VNode, h } from "preact";
import {
- BoldLight, ButtonPrimary, ButtonSuccess, Centered,
- CenteredText, CenteredBoldText, PopupBox, RowBorderGray,
- SmallText, SmallLightText, WalletBox
-} from "../components/styled";
-import { useBackupStatus } from "../hooks/useBackupStatus";
-import { Pages } from "../NavigationBar";
+ AbsoluteTime,
+ ProviderInfo,
+ ProviderPaymentPaid,
+ ProviderPaymentStatus,
+ ProviderPaymentType,
+ stringifyRestoreUri,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import {
+ differenceInMonths,
+ formatDuration,
+ intervalToDuration,
+} from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { Pages } from "../NavigationBar.js";
+import { ErrorAlertView } from "../components/CurrentAlerts.js";
+import { Loading } from "../components/Loading.js";
+import { QR } from "../components/QR.js";
+import {
+ BoldLight,
+ Centered,
+ CenteredBoldText,
+ CenteredText,
+ RowBorderGray,
+ SmallLightText,
+ SmallText,
+ WarningBox,
+} from "../components/styled/index.js";
+import { alertFromError } from "../context/alert.js";
+import { useBackendContext } from "../context/backend.js";
+import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { Button } from "../mui/Button.js";
interface Props {
- onAddProvider: () => void;
+ onAddProvider: () => Promise<void>;
+}
+
+export function ShowRecoveryInfo({
+ info,
+ onClose,
+}: {
+ info: string;
+ onClose: () => Promise<void>;
+}): VNode {
+ const [display, setDisplay] = useState(false);
+ const [copied, setCopied] = useState(false);
+ async function copyText(): Promise<void> {
+ navigator.clipboard.writeText(info);
+ setCopied(true);
+ }
+ useEffect(() => {
+ if (copied) {
+ setTimeout(() => {
+ setCopied(false);
+ }, 1000);
+ }
+ }, [copied]);
+ return (
+ <Fragment>
+ <h2>Wallet Recovery</h2>
+ <WarningBox>Do not share this QR or URI with anyone</WarningBox>
+ <section>
+ <p>
+ The qr code can be scanned by another wallet to keep synchronized with
+ this wallet.
+ </p>
+ <Button variant="contained" onClick={async () => setDisplay((d) => !d)}>
+ {display ? "Hide" : "Show"} QR code
+ </Button>
+ {display && <QR text={JSON.stringify(info)} />}
+ </section>
+
+ <section>
+ <p>You can also use the string version</p>
+ <Button variant="contained" disabled={copied} onClick={copyText}>
+ Copy recovery URI
+ </Button>
+ </section>
+ <footer>
+ <div></div>
+ <div>
+ <Button variant="contained" onClick={onClose}>
+ Close
+ </Button>
+ </div>
+ </footer>
+ </Fragment>
+ );
}
export function BackupPage({ onAddProvider }: Props): VNode {
- const status = useBackupStatus()
+ const { i18n } = useTranslationContext();
+ const api = useBackendContext();
+ const status = useAsyncAsHook(() =>
+ api.wallet.call(WalletApiOperation.GetBackupInfo, {}),
+ );
+ const [recoveryInfo, setRecoveryInfo] = useState<string>("");
if (!status) {
- return <div>Loading...</div>
+ return <Loading />;
+ }
+ if (status.hasError) {
+ return (
+ <ErrorAlertView
+ error={alertFromError(
+ i18n,
+ i18n.str`Could not load backup providers`,
+ status,
+ )}
+ />
+ );
+ }
+
+ async function getRecoveryInfo(): Promise<void> {
+ const r = await api.wallet.call(
+ WalletApiOperation.ExportBackupRecovery,
+ {},
+ );
+ const str = stringifyRestoreUri({
+ walletRootPriv: r.walletRootPriv,
+ providers: r.providers.map((p) => p.url),
+ });
+ setRecoveryInfo(str);
}
- return <BackupView providers={status.providers} onAddProvider={onAddProvider} onSyncAll={status.sync} />;
+
+ const providers = status.response.providers.sort((a, b) => {
+ if (
+ a.paymentStatus.type === ProviderPaymentType.Paid &&
+ b.paymentStatus.type === ProviderPaymentType.Paid
+ ) {
+ return getStatusPaidOrder(a.paymentStatus, b.paymentStatus);
+ }
+ return (
+ getStatusTypeOrder(a.paymentStatus) - getStatusTypeOrder(b.paymentStatus)
+ );
+ });
+
+ if (recoveryInfo) {
+ return (
+ <ShowRecoveryInfo
+ info={recoveryInfo}
+ onClose={async () => setRecoveryInfo("")}
+ />
+ );
+ }
+
+ return (
+ <BackupView
+ providers={providers}
+ onAddProvider={onAddProvider}
+ onSyncAll={async () =>
+ api.wallet.call(WalletApiOperation.RunBackupCycle, {}).then()
+ }
+ onShowInfo={getRecoveryInfo}
+ />
+ );
}
export interface ViewProps {
- providers: ProviderInfo[],
- onAddProvider: () => void;
+ providers: ProviderInfo[];
+ onAddProvider: () => Promise<void>;
onSyncAll: () => Promise<void>;
+ onShowInfo: () => Promise<void>;
}
-export function BackupView({ providers, onAddProvider, onSyncAll }: ViewProps): VNode {
+export function BackupView({
+ providers,
+ onAddProvider,
+ onSyncAll,
+ onShowInfo,
+}: ViewProps): VNode {
+ const { i18n } = useTranslationContext();
return (
- <WalletBox>
+ <Fragment>
<section>
- {providers.map((provider) => <BackupLayout
- status={provider.paymentStatus}
- timestamp={provider.lastSuccessfulBackupTimestamp}
- id={provider.syncProviderBaseUrl}
- active={provider.active}
- title={provider.name}
- />
+ {providers.map((provider, idx) => (
+ <BackupLayout
+ key={idx}
+ status={provider.paymentStatus}
+ timestamp={
+ provider.lastSuccessfulBackupTimestamp
+ ? AbsoluteTime.fromPreciseTimestamp(
+ provider.lastSuccessfulBackupTimestamp,
+ )
+ : undefined
+ }
+ id={provider.syncProviderBaseUrl}
+ active={provider.active}
+ title={provider.name}
+ />
+ ))}
+ {!providers.length && (
+ <Centered style={{ marginTop: 100 }}>
+ <BoldLight>
+ <i18n.Translate>No backup providers configured</i18n.Translate>
+ </BoldLight>
+ <Button variant="contained" color="success" onClick={onAddProvider}>
+ <i18n.Translate>Add provider</i18n.Translate>
+ </Button>
+ </Centered>
)}
- {!providers.length && <Centered style={{ marginTop: 100 }}>
- <BoldLight>No backup providers configured</BoldLight>
- <ButtonSuccess onClick={onAddProvider}><i18n.Translate>Add provider</i18n.Translate></ButtonSuccess>
- </Centered>}
</section>
- {!!providers.length && <footer>
- <div />
- <div>
- <ButtonPrimary onClick={onSyncAll}>{
- providers.length > 1 ?
- <i18n.Translate>Sync all backups</i18n.Translate> :
- <i18n.Translate>Sync now</i18n.Translate>
- }</ButtonPrimary>
- <ButtonSuccess onClick={onAddProvider}>Add provider</ButtonSuccess>
- </div>
- </footer>}
- </WalletBox>
- )
+ {!!providers.length && (
+ <footer>
+ <div>
+ <Button variant="contained" onClick={onShowInfo}>
+ Show recovery
+ </Button>
+ </div>
+ <div>
+ <Button variant="contained" onClick={onSyncAll}>
+ {providers.length > 1
+ ? i18n.str`Sync all backups`
+ : i18n.str`Sync now`}
+ </Button>
+ <Button variant="contained" color="success" onClick={onAddProvider}>
+ <i18n.Translate>Add provider</i18n.Translate>
+ </Button>
+ </div>
+ </footer>
+ )}
+ </Fragment>
+ );
}
interface TransactionLayoutProps {
status: ProviderPaymentStatus;
- timestamp?: Timestamp;
+ timestamp?: AbsoluteTime;
title: string;
id: string;
active: boolean;
}
-function BackupLayout(props: TransactionLayoutProps): JSX.Element {
+function BackupLayout(props: TransactionLayoutProps): VNode {
+ const { i18n } = useTranslationContext();
const date = !props.timestamp ? undefined : new Date(props.timestamp.t_ms);
const dateStr = date?.toLocaleString([], {
dateStyle: "medium",
timeStyle: "short",
} as any);
-
return (
<RowBorderGray>
<div style={{ color: !props.active ? "grey" : undefined }}>
- <a href={Pages.provider_detail.replace(':pid', encodeURIComponent(props.id))}><span>{props.title}</span></a>
+ <a
+ href={Pages.backupProviderDetail({
+ pid: encodeURIComponent(props.id),
+ })}
+ >
+ <span>{props.title}</span>
+ </a>
- {dateStr && <SmallText style={{ marginTop: 5 }}>Last synced: {dateStr}</SmallText>}
- {!dateStr && <SmallLightText style={{ marginTop: 5 }}>Not synced</SmallLightText>}
+ {dateStr && (
+ <SmallText style={{ marginTop: 5 }}>
+ <i18n.Translate>Last synced</i18n.Translate>: {dateStr}
+ </SmallText>
+ )}
+ {!dateStr && (
+ <SmallLightText style={{ marginTop: 5 }}>
+ <i18n.Translate>Not synced</i18n.Translate>
+ </SmallLightText>
+ )}
</div>
<div>
- {props.status?.type === 'paid' ?
- <ExpirationText until={props.status.paidUntil} /> :
+ {props.status?.type === "paid" ? (
+ <ExpirationText until={props.status.paidUntil} />
+ ) : (
<div>{props.status.type}</div>
- }
+ )}
</div>
</RowBorderGray>
);
}
-function ExpirationText({ until }: { until: Timestamp }) {
- return <Fragment>
- <CenteredText> Expires in </CenteredText>
- <CenteredBoldText {...({ color: colorByTimeToExpire(until) })}> {daysUntil(until)} </CenteredBoldText>
- </Fragment>
+function ExpirationText({ until }: { until: AbsoluteTime }): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <CenteredText>
+ <i18n.Translate>Expires in</i18n.Translate>
+ </CenteredText>
+ <CenteredBoldText {...{ color: colorByTimeToExpire(until) }}>
+ {" "}
+ {daysUntil(until)}{" "}
+ </CenteredBoldText>
+ </Fragment>
+ );
}
-function colorByTimeToExpire(d: Timestamp) {
- if (d.t_ms === 'never') return 'rgb(28, 184, 65)'
- const months = differenceInMonths(d.t_ms, new Date())
- return months > 1 ? 'rgb(28, 184, 65)' : 'rgb(223, 117, 20)';
+function colorByTimeToExpire(d: AbsoluteTime): string {
+ if (d.t_ms === "never") return "rgb(28, 184, 65)";
+ const months = differenceInMonths(d.t_ms, new Date());
+ return months > 1 ? "rgb(28, 184, 65)" : "rgb(223, 117, 20)";
}
-function daysUntil(d: Timestamp) {
- if (d.t_ms === 'never') return undefined
+function daysUntil(d: AbsoluteTime): string {
+ if (d.t_ms === "never") return "";
const duration = intervalToDuration({
start: d.t_ms,
end: new Date(),
- })
+ });
const str = formatDuration(duration, {
- delimiter: ', ',
+ delimiter: ", ",
format: [
- duration?.years ? 'years' : (
- duration?.months ? 'months' : (
- duration?.days ? 'days' : (
- duration.hours ? 'hours' : 'minutes'
- )
- )
- )
- ]
- })
- return `${str}`
-} \ No newline at end of file
+ duration?.years
+ ? "years"
+ : duration?.months
+ ? "months"
+ : duration?.days
+ ? "days"
+ : duration.hours
+ ? "hours"
+ : "minutes",
+ ],
+ });
+ return `${str}`;
+}
+
+function getStatusTypeOrder(t: ProviderPaymentStatus): number {
+ return [
+ ProviderPaymentType.InsufficientBalance,
+ ProviderPaymentType.TermsChanged,
+ ProviderPaymentType.Unpaid,
+ ProviderPaymentType.Paid,
+ ProviderPaymentType.Pending,
+ ].indexOf(t.type);
+}
+
+function getStatusPaidOrder(
+ a: ProviderPaymentPaid,
+ b: ProviderPaymentPaid,
+): number {
+ return a.paidUntil.t_ms === "never"
+ ? -1
+ : b.paidUntil.t_ms === "never"
+ ? 1
+ : a.paidUntil.t_ms - b.paidUntil.t_ms;
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/Balance.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Balance.stories.tsx
deleted file mode 100644
index cccda203e..000000000
--- a/packages/taler-wallet-webextension/src/wallet/Balance.stories.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createExample, NullLink } from '../test-utils';
-import { BalanceView as TestedComponent } from './BalancePage';
-
-export default {
- title: 'wallet/balance',
- component: TestedComponent,
- argTypes: {
- }
-};
-
-
-export const NotYetLoaded = createExample(TestedComponent, {
-});
-
-export const GotError = createExample(TestedComponent, {
- balance: {
- hasError: true,
- message: 'Network error'
- },
- Linker: NullLink,
-});
-
-export const EmptyBalance = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: []
- },
- },
- Linker: NullLink,
-});
-
-export const SomeCoins = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: [{
- available: 'USD:10.5',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- }]
- },
- },
- Linker: NullLink,
-});
-
-export const SomeCoinsAndIncomingMoney = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: [{
- available: 'USD:2.23',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:5.11',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- }]
- },
- },
- Linker: NullLink,
-});
-
-export const SomeCoinsInTwoCurrencies = createExample(TestedComponent, {
- balance: {
- hasError: false,
- response: {
- balances: [{
- available: 'USD:2',
- hasPendingTransactions: false,
- pendingIncoming: 'USD:5',
- pendingOutgoing: 'USD:0',
- requiresUserInput: false
- },{
- available: 'EUR:4',
- hasPendingTransactions: false,
- pendingIncoming: 'EUR:5',
- pendingOutgoing: 'EUR:0',
- requiresUserInput: false
- }]
- },
- },
- Linker: NullLink,
-});
diff --git a/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx b/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx
deleted file mode 100644
index eb5a0447c..000000000
--- a/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import {
- amountFractionalBase, Amounts,
- Balance, BalancesResponse,
- i18n
-} from "@gnu-taler/taler-util";
-import { JSX } from "preact";
-import { ButtonPrimary, Centered, WalletBox } from "../components/styled/index";
-import { BalancesHook, useBalances } from "../hooks/useBalances";
-import { PageLink, renderAmount } from "../renderHtml";
-
-
-export function BalancePage({ goToWalletManualWithdraw }: { goToWalletManualWithdraw: () => void }) {
- const balance = useBalances()
- return <BalanceView balance={balance} Linker={PageLink} goToWalletManualWithdraw={goToWalletManualWithdraw} />
-}
-
-export interface BalanceViewProps {
- balance: BalancesHook;
- Linker: typeof PageLink;
- goToWalletManualWithdraw: () => void;
-}
-
-export function BalanceView({ balance, Linker, goToWalletManualWithdraw }: BalanceViewProps) {
- if (!balance) {
- return <span />
- }
-
- if (balance.hasError) {
- return (
- <div>
- <p>{i18n.str`Error: could not retrieve balance information.`}</p>
- <p>
- Click <Linker pageName="welcome">here</Linker> for help and
- diagnostics.
- </p>
- </div>
- )
- }
- if (balance.response.balances.length === 0) {
- return (
- <p><i18n.Translate>
- You have no balance to show. Need some{" "}
- <Linker pageName="/welcome">help</Linker> getting started?
- </i18n.Translate></p>
- )
- }
- return <ShowBalances wallet={balance.response}
- onWithdraw={goToWalletManualWithdraw}
- />
-}
-
-function formatPending(entry: Balance): JSX.Element {
- let incoming: JSX.Element | undefined;
- let payment: JSX.Element | undefined;
-
- const available = Amounts.parseOrThrow(entry.available);
- const pendingIncoming = Amounts.parseOrThrow(entry.pendingIncoming);
- const pendingOutgoing = Amounts.parseOrThrow(entry.pendingOutgoing);
-
- if (!Amounts.isZero(pendingIncoming)) {
- incoming = (
- <span><i18n.Translate>
- <span style={{ color: "darkgreen" }}>
- {"+"}
- {renderAmount(entry.pendingIncoming)}
- </span>{" "}
- incoming
- </i18n.Translate></span>
- );
- }
-
- const l = [incoming, payment].filter((x) => x !== undefined);
- if (l.length === 0) {
- return <span />;
- }
-
- if (l.length === 1) {
- return <span>({l})</span>;
- }
- return (
- <span>
- ({l[0]}, {l[1]})
- </span>
- );
-}
-
-
-function ShowBalances({ wallet, onWithdraw }: { wallet: BalancesResponse, onWithdraw: () => void }) {
- return <WalletBox>
- <section>
- <Centered>{wallet.balances.map((entry) => {
- const av = Amounts.parseOrThrow(entry.available);
- const v = av.value + av.fraction / amountFractionalBase;
- return (
- <p key={av.currency}>
- <span>
- <span style={{ fontSize: "5em", display: "block" }}>{v}</span>{" "}
- <span>{av.currency}</span>
- </span>
- {formatPending(entry)}
- </p>
- );
- })}</Centered>
- </section>
- <footer>
- <div />
- <ButtonPrimary onClick={onWithdraw} >Withdraw</ButtonPrimary>
- </footer>
- </WalletBox>
-}
diff --git a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.stories.tsx b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.stories.tsx
deleted file mode 100644
index 35da52392..000000000
--- a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.stories.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createExample } from '../test-utils';
-import { CreateManualWithdraw as TestedComponent } from './CreateManualWithdraw';
-
-export default {
- title: 'wallet/manual withdraw/creation',
- component: TestedComponent,
- argTypes: {
- }
-};
-
-
-export const InitialState = createExample(TestedComponent, {
-});
-
-export const WithExchangeFilled = createExample(TestedComponent, {
- currency: 'COL',
- initialExchange: 'http://exchange.taler:8081',
-});
-
-export const WithExchangeAndAmountFilled = createExample(TestedComponent, {
- currency: 'COL',
- initialExchange: 'http://exchange.taler:8081',
- initialAmount: '10'
-});
-
-export const WithExchangeError = createExample(TestedComponent, {
- initialExchange: 'http://exchange.tal',
- error: 'The exchange url seems invalid'
-});
-
-export const WithAmountError = createExample(TestedComponent, {
- currency: 'COL',
- initialExchange: 'http://exchange.taler:8081',
- initialAmount: 'e'
-});
diff --git a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx
deleted file mode 100644
index be2cbe41d..000000000
--- a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { VNode } from "preact";
-import { useEffect, useRef, useState } from "preact/hooks";
-import { ErrorMessage } from "../components/ErrorMessage";
-import { ButtonPrimary, Input, InputWithLabel, LightText, WalletBox } from "../components/styled";
-
-export interface Props {
- error: string | undefined;
- currency: string | undefined;
- initialExchange?: string;
- initialAmount?: string;
- onExchangeChange: (exchange: string) => void;
- onCreate: (exchangeBaseUrl: string, amount: AmountJson) => Promise<void>;
-}
-
-export function CreateManualWithdraw({ onExchangeChange, initialExchange, initialAmount, error, currency, onCreate }: Props): VNode {
- const [exchange, setExchange] = useState(initialExchange || "");
- const [amount, setAmount] = useState(initialAmount || "");
- const parsedAmount = Amounts.parse(`${currency}:${amount}`)
-
- let timeout = useRef<number | undefined>(undefined);
- useEffect(() => {
- if (timeout) window.clearTimeout(timeout.current)
- timeout.current = window.setTimeout(async () => {
- onExchangeChange(exchange)
- }, 1000);
- }, [exchange])
-
-
- return (
- <WalletBox>
- <section>
- <ErrorMessage title={error && "Can't create the reserve"} description={error} />
- <h2>Manual Withdrawal</h2>
- <LightText>Choose a exchange to create a reserve and then fill the reserve to withdraw the coins</LightText>
- <p>
- <Input invalid={!!exchange && !currency}>
- <label>Exchange</label>
- <input type="text" placeholder="https://" value={exchange} onChange={(e) => setExchange(e.currentTarget.value)} />
- <small>http://exchange.taler:8081</small>
- </Input>
- {currency && <InputWithLabel invalid={!!amount && !parsedAmount}>
- <label>Amount</label>
- <div>
- <div>{currency}</div>
- <input type="number" style={{ paddingLeft: `${currency.length}em` }} value={amount} onChange={e => setAmount(e.currentTarget.value)} />
- </div>
- </InputWithLabel>}
- </p>
- </section>
- <footer>
- <div />
- <ButtonPrimary disabled={!parsedAmount || !exchange} onClick={() => onCreate(exchange, parsedAmount!)}>Create</ButtonPrimary>
- </footer>
- </WalletBox>
- );
-}
diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts
new file mode 100644
index 000000000..838739ad1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts
@@ -0,0 +1,121 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountJson, PaytoUri } from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import {
+ AmountFieldHandler,
+ ButtonHandler,
+ SelectFieldHandler,
+} from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { ManageAccountPage } from "../ManageAccount/index.js";
+import { useComponentState } from "./state.js";
+import {
+ AmountOrCurrencyErrorView,
+ NoAccountToDepositView,
+ NoEnoughBalanceView,
+ ReadyView,
+} from "./views.js";
+
+export interface Props {
+ amount?: string;
+ onCancel: (currency: string) => void;
+ onSuccess: (currency: string) => void;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.AmountOrCurrencyError
+ | State.NoEnoughBalance
+ | State.Ready
+ | State.NoAccounts
+ | State.AddingAccount;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface AddingAccount {
+ status: "manage-account";
+ error: undefined;
+ currency: string;
+ onAccountAdded: (p: string) => void;
+ onCancel: () => void;
+ }
+
+ export interface AmountOrCurrencyError {
+ status: "amount-or-currency-error";
+ error: undefined;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+
+ export interface NoEnoughBalance extends BaseInfo {
+ status: "no-enough-balance";
+ currency: string;
+ }
+
+ export interface NoAccounts extends BaseInfo {
+ status: "no-accounts";
+ currency: string;
+ onAddAccount: ButtonHandler;
+ }
+
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ currency: string;
+
+ currentAccount: PaytoUri;
+ totalFee: AmountJson;
+ totalToDeposit: AmountJson;
+
+ amount: AmountFieldHandler;
+ account: SelectFieldHandler;
+ cancelHandler: ButtonHandler;
+ depositHandler: ButtonHandler;
+ onAddAccount: ButtonHandler;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ "amount-or-currency-error": AmountOrCurrencyErrorView,
+ "no-enough-balance": NoEnoughBalanceView,
+ "no-accounts": NoAccountToDepositView,
+ "manage-account": ManageAccountPage,
+ ready: ReadyView,
+};
+
+export const DepositPage = compose(
+ "DepositPage",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts
new file mode 100644
index 000000000..97b2ab517
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts
@@ -0,0 +1,276 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AmountJson,
+ Amounts,
+ DepositGroupFees,
+ KnownBankAccountsInfo,
+ parsePaytoUri,
+ PaytoUri,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useState } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { RecursiveState } from "../../utils/index.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ amount: amountStr,
+ onCancel,
+ onSuccess,
+}: Props): RecursiveState<State> {
+ const api = useBackendContext();
+ const { i18n } = useTranslationContext();
+ const { pushAlertOnError } = useAlertContext();
+ const parsed = amountStr === undefined ? undefined : Amounts.parse(amountStr);
+ const currency = parsed !== undefined ? parsed.currency : undefined;
+
+ const hook = useAsyncAsHook(async () => {
+ const { balances } = await api.wallet.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ const { accounts } = await api.wallet.call(
+ WalletApiOperation.ListKnownBankAccounts,
+ { currency },
+ );
+
+ return { accounts, balances };
+ });
+
+ const initialValue =
+ parsed !== undefined
+ ? parsed
+ : currency !== undefined
+ ? Amounts.zeroOfCurrency(currency)
+ : undefined;
+ // const [accountIdx, setAccountIdx] = useState<number>(0);
+ const [selectedAccount, setSelectedAccount] = useState<PaytoUri>();
+
+ const [addingAccount, setAddingAccount] = useState(false);
+
+ if (!currency) {
+ return {
+ status: "amount-or-currency-error",
+ error: undefined,
+ };
+ }
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(i18n,
+ i18n.str`Could not load balance information`, hook),
+ };
+ }
+ const { accounts, balances } = hook.response;
+
+ async function updateAccountFromList(accountStr: string): Promise<void> {
+ const uri = !accountStr ? undefined : parsePaytoUri(accountStr);
+ if (uri) {
+ setSelectedAccount(uri);
+ }
+ }
+
+ if (addingAccount) {
+ return {
+ status: "manage-account",
+ error: undefined,
+ currency,
+ onAccountAdded: (p: string) => {
+ updateAccountFromList(p);
+ setAddingAccount(false);
+ hook.retry();
+ },
+ onCancel: () => {
+ setAddingAccount(false);
+ hook.retry();
+ },
+ };
+ }
+
+ const bs = balances.filter((b) => b.available.startsWith(currency));
+ const balance =
+ bs.length > 0
+ ? Amounts.parseOrThrow(bs[0].available)
+ : Amounts.zeroOfCurrency(currency);
+
+ if (Amounts.isZero(balance)) {
+ return {
+ status: "no-enough-balance",
+ error: undefined,
+ currency,
+ };
+ }
+
+ if (accounts.length === 0) {
+ return {
+ status: "no-accounts",
+ error: undefined,
+ currency,
+ onAddAccount: {
+ onClick: pushAlertOnError(async () => {
+ setAddingAccount(true);
+ }),
+ },
+ };
+ }
+ const firstAccount = accounts[0].uri;
+ const currentAccount = !selectedAccount ? firstAccount : selectedAccount;
+
+ return () => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const [amount, setAmount] = useState<AmountJson>(
+ initialValue ?? ({} as any),
+ );
+ const amountStr = Amounts.stringify(amount);
+ const depositPaytoUri = stringifyPaytoUri(currentAccount);
+
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const hook = useAsyncAsHook(async () => {
+ const fee = await api.wallet.call(WalletApiOperation.PrepareDeposit, {
+ amount: amountStr,
+ depositPaytoUri,
+ });
+
+ return { fee };
+ }, [amountStr, depositPaytoUri]);
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load fee for amount ${amountStr}`,
+ hook,
+ ),
+ };
+ }
+
+ const { fee } = hook.response;
+
+ const accountMap = createLabelsForBankAccount(accounts);
+
+ const totalFee =
+ fee !== undefined
+ ? Amounts.sum([fee.fees.wire, fee.fees.coin, fee.fees.refresh]).amount
+ : Amounts.zeroOfCurrency(currency);
+
+ const totalToDeposit =
+ fee !== undefined
+ ? Amounts.sub(amount, totalFee).amount
+ : Amounts.zeroOfCurrency(currency);
+
+ const isDirty = amount !== initialValue;
+ const amountError = !isDirty
+ ? undefined
+ : Amounts.cmp(balance, amount) === -1
+ ? `Too much, your current balance is ${Amounts.stringifyValue(balance)}`
+ : undefined;
+
+ const unableToDeposit =
+ Amounts.isZero(totalToDeposit) || //deposit may be zero because of fee
+ fee === undefined || //no fee calculated yet
+ amountError !== undefined; //amount field may be invalid
+
+ async function doSend(): Promise<void> {
+ if (!currency) return;
+
+ const depositPaytoUri = stringifyPaytoUri(currentAccount);
+ const amountStr = Amounts.stringify(amount);
+ await api.wallet.call(WalletApiOperation.CreateDepositGroup, {
+ amount: amountStr,
+ depositPaytoUri,
+ });
+ onSuccess(currency);
+ }
+
+ return {
+ status: "ready",
+ error: undefined,
+ currency,
+ amount: {
+ value: amount,
+ onInput: pushAlertOnError(async (a) => setAmount(a)),
+ error: amountError,
+ },
+ onAddAccount: {
+ onClick: pushAlertOnError(async () => {
+ setAddingAccount(true);
+ }),
+ },
+ account: {
+ list: accountMap,
+ value: stringifyPaytoUri(currentAccount),
+ onChange: pushAlertOnError(updateAccountFromList),
+ },
+ currentAccount,
+ cancelHandler: {
+ onClick: pushAlertOnError(async () => {
+ onCancel(currency);
+ }),
+ },
+ depositHandler: {
+ onClick: unableToDeposit ? undefined : pushAlertOnError(doSend),
+ },
+ totalFee,
+ totalToDeposit,
+ };
+ };
+}
+
+export function labelForAccountType(id: string): string {
+ switch (id) {
+ case "":
+ return "Choose one";
+ case "x-taler-bank":
+ return "Taler Bank";
+ case "bitcoin":
+ return "Bitcoin";
+ case "iban":
+ return "IBAN";
+ default:
+ return id;
+ }
+}
+
+export function createLabelsForBankAccount(
+ knownBankAccounts: Array<KnownBankAccountsInfo>,
+): { [value: string]: string } {
+ const initialList: Record<string, string> = {};
+ if (!knownBankAccounts.length) return initialList;
+ return knownBankAccounts.reduce((prev, cur, i) => {
+ prev[stringifyPaytoUri(cur.uri)] = cur.alias;
+ return prev;
+ }, initialList);
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx
new file mode 100644
index 000000000..c23f83fdd
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx
@@ -0,0 +1,116 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { nullFunction } from "../../mui/handlers.js";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "deposit",
+};
+
+export const WithNoAccountForIBAN = tests.createExample(ReadyView, {
+ status: "ready",
+ account: {
+ list: {},
+ value: "",
+ onChange: nullFunction,
+ },
+ currentAccount: {
+ isKnown: true,
+ targetType: "iban",
+ iban: "ABCD1234",
+ params: {},
+ targetPath: "/ABCD1234",
+ },
+ currency: "USD",
+ amount: {
+ onInput: nullFunction,
+ value: Amounts.parseOrThrow("USD:10"),
+ },
+ onAddAccount: {},
+ cancelHandler: {},
+ depositHandler: {
+ onClick: nullFunction,
+ },
+ totalFee: Amounts.zeroOfCurrency("USD"),
+ totalToDeposit: Amounts.parseOrThrow("USD:10"),
+ // onCalculateFee: alwaysReturnFeeToOne,
+});
+
+export const WithIBANAccountTypeSelected = tests.createExample(ReadyView, {
+ status: "ready",
+ account: {
+ list: { asdlkajsdlk: "asdlkajsdlk", qwerqwer: "qwerqwer" },
+ value: "asdlkajsdlk",
+ onChange: nullFunction,
+ },
+ currentAccount: {
+ isKnown: true,
+ targetType: "iban",
+ iban: "ABCD1234",
+ params: {},
+ targetPath: "/ABCD1234",
+ },
+ currency: "USD",
+ amount: {
+ onInput: nullFunction,
+ value: Amounts.parseOrThrow("USD:10"),
+ },
+ onAddAccount: {},
+ cancelHandler: {},
+ depositHandler: {
+ onClick: nullFunction,
+ },
+ totalFee: Amounts.zeroOfCurrency("USD"),
+ totalToDeposit: Amounts.parseOrThrow("USD:10"),
+ // onCalculateFee: alwaysReturnFeeToOne,
+});
+
+export const NewBitcoinAccountTypeSelected = tests.createExample(ReadyView, {
+ status: "ready",
+ account: {
+ list: {},
+ value: "asdlkajsdlk",
+ onChange: nullFunction,
+ },
+ currentAccount: {
+ isKnown: true,
+ targetType: "iban",
+ iban: "ABCD1234",
+ params: {},
+ targetPath: "/ABCD1234",
+ },
+ onAddAccount: {},
+ currency: "USD",
+ amount: {
+ onInput: nullFunction,
+ value: Amounts.parseOrThrow("USD:10"),
+ },
+ cancelHandler: {},
+ depositHandler: {
+ onClick: nullFunction,
+ },
+ totalFee: Amounts.zeroOfCurrency("USD"),
+ totalToDeposit: Amounts.parseOrThrow("USD:10"),
+ // onCalculateFee: alwaysReturnFeeToOne,
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts
new file mode 100644
index 000000000..157cb868a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts
@@ -0,0 +1,431 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ Amounts,
+ AmountString,
+ DepositGroupFees,
+ parsePaytoUri,
+ PrepareDepositResponse,
+ ScopeType,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { expect } from "chai";
+import * as tests from "@gnu-taler/web-util/testing";
+import { nullFunction } from "../../mui/handlers.js";
+import { createWalletApiMock } from "../../test-utils.js";
+
+import { useComponentState } from "./state.js";
+
+const currency = "EUR";
+const amount = `${currency}:0`;
+const withoutFee = (): PrepareDepositResponse => ({
+ effectiveDepositAmount: `${currency}:5` as AmountString,
+ totalDepositCost: `${currency}:5` as AmountString,
+ fees: {
+ coin: Amounts.stringify(`${currency}:0`),
+ wire: Amounts.stringify(`${currency}:0`),
+ refresh: Amounts.stringify(`${currency}:0`),
+ },
+});
+
+const withSomeFee = (): PrepareDepositResponse => ({
+ effectiveDepositAmount: `${currency}:5` as AmountString,
+ totalDepositCost: `${currency}:5` as AmountString,
+ fees: {
+ coin: Amounts.stringify(`${currency}:1`),
+ wire: Amounts.stringify(`${currency}:1`),
+ refresh: Amounts.stringify(`${currency}:1`),
+ },
+});
+
+describe("DepositPage states", () => {
+ it("should have status 'no-enough-balance' when balance is empty", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = { amount, onCancel: nullFunction, onSuccess: nullFunction };
+
+ handler.addWalletCallResponse(WalletApiOperation.GetBalances, undefined, {
+ balances: [
+ {
+ flags: [],
+ available: `${currency}:0` as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: `${currency}:0` as AmountString,
+ pendingOutgoing: `${currency}:0` as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency,
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ ],
+ });
+ handler.addWalletCallResponse(
+ WalletApiOperation.ListKnownBankAccounts,
+ undefined,
+ {
+ accounts: [],
+ },
+ );
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equal("loading");
+ },
+ ({ status }) => {
+ expect(status).equal("no-enough-balance");
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should have status 'no-accounts' when balance is not empty and accounts is empty", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = { amount, onCancel: nullFunction, onSuccess: nullFunction };
+
+ handler.addWalletCallResponse(WalletApiOperation.GetBalances, undefined, {
+ balances: [
+ {
+ flags: [],
+ available: `${currency}:1` as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: `${currency}:0` as AmountString,
+ pendingOutgoing: `${currency}:0` as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency,
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ ],
+ });
+ handler.addWalletCallResponse(
+ WalletApiOperation.ListKnownBankAccounts,
+ undefined,
+ {
+ accounts: [],
+ },
+ );
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equal("loading");
+ },
+ ({ status }) => {
+ expect(status).equal("no-accounts");
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ const ibanPayto = {
+ uri: parsePaytoUri("payto://iban/ES8877998399652238")!,
+ kyc_completed: false,
+ currency: "EUR",
+ alias: "my iban account",
+ };
+ const talerBankPayto = {
+ uri: parsePaytoUri("payto://x-taler-bank/ES8877998399652238")!,
+ kyc_completed: false,
+ currency: "EUR",
+ alias: "my taler account",
+ };
+
+ it("should have status 'ready' but unable to deposit ", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = { amount, onCancel: nullFunction, onSuccess: nullFunction };
+
+ handler.addWalletCallResponse(WalletApiOperation.GetBalances, undefined, {
+ balances: [
+ {
+ flags: [],
+ available: `${currency}:1` as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: `${currency}:0` as AmountString,
+ pendingOutgoing: `${currency}:0` as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency,
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ ],
+ });
+ handler.addWalletCallResponse(
+ WalletApiOperation.ListKnownBankAccounts,
+ undefined,
+ {
+ accounts: [ibanPayto],
+ },
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.PrepareDeposit,
+ undefined,
+ withoutFee(),
+ );
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equal("loading");
+ },
+ ({ status }) => {
+ expect(status).equal("loading");
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.cancelHandler.onClick).not.undefined;
+ expect(state.currency).eq(currency);
+ expect(state.account.value).eq(stringifyPaytoUri(ibanPayto.uri));
+ expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:0"));
+ expect(state.depositHandler.onClick).undefined;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should not be able to deposit more than the balance ", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = { amount, onCancel: nullFunction, onSuccess: nullFunction };
+
+ handler.addWalletCallResponse(WalletApiOperation.GetBalances, undefined, {
+ balances: [
+ {
+ flags: [],
+ available: `${currency}:5` as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: `${currency}:0` as AmountString,
+ pendingOutgoing: `${currency}:0` as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency,
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ ],
+ });
+ handler.addWalletCallResponse(
+ WalletApiOperation.ListKnownBankAccounts,
+ undefined,
+ {
+ accounts: [talerBankPayto, ibanPayto],
+ },
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.PrepareDeposit,
+ undefined,
+ withoutFee(),
+ );
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.PrepareDeposit,
+ undefined,
+ withoutFee(),
+ );
+
+ const accountSelected = stringifyPaytoUri(ibanPayto.uri);
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equal("loading");
+ },
+ ({ status }) => {
+ expect(status).equal("loading");
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.cancelHandler.onClick).not.undefined;
+ expect(state.currency).eq(currency);
+ expect(state.account.value).eq(stringifyPaytoUri(talerBankPayto.uri));
+ expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:0"));
+ expect(state.depositHandler.onClick).undefined;
+ expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`));
+ expect(state.account.onChange).not.undefined;
+
+ state.account.onChange!(accountSelected);
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.cancelHandler.onClick).not.undefined;
+ expect(state.currency).eq(currency);
+ expect(state.account.value).eq(accountSelected);
+ expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:0"));
+ expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`));
+ expect(state.depositHandler.onClick).undefined;
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.cancelHandler.onClick).not.undefined;
+ expect(state.currency).eq(currency);
+ expect(state.account.value).eq(accountSelected);
+ expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:0"));
+ expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`));
+ expect(state.depositHandler.onClick).undefined;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should calculate the fee upon entering amount ", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+ const props = { amount, onCancel: nullFunction, onSuccess: nullFunction };
+
+ handler.addWalletCallResponse(WalletApiOperation.GetBalances, undefined, {
+ balances: [
+ {
+ flags: [],
+ available: `${currency}:10` as AmountString,
+ hasPendingTransactions: false,
+ pendingIncoming: `${currency}:0` as AmountString,
+ pendingOutgoing: `${currency}:0` as AmountString,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency,
+ type: ScopeType.Auditor,
+ url: "asd",
+ },
+ },
+ ],
+ });
+ handler.addWalletCallResponse(
+ WalletApiOperation.ListKnownBankAccounts,
+ undefined,
+ {
+ accounts: [talerBankPayto, ibanPayto],
+ },
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.PrepareDeposit,
+ undefined,
+ withoutFee(),
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.PrepareDeposit,
+ undefined,
+ withSomeFee(),
+ );
+ handler.addWalletCallResponse(
+ WalletApiOperation.PrepareDeposit,
+ undefined,
+ withSomeFee(),
+ );
+
+ const accountSelected = stringifyPaytoUri(ibanPayto.uri);
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equal("loading");
+ },
+ ({ status }) => {
+ expect(status).equal("loading");
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.cancelHandler.onClick).not.undefined;
+ expect(state.currency).eq(currency);
+ expect(state.account.value).eq(stringifyPaytoUri(talerBankPayto.uri));
+ expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:0"));
+ expect(state.depositHandler.onClick).undefined;
+ expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`));
+ expect(state.account.onChange).not.undefined;
+
+ state.account.onChange!(accountSelected);
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.cancelHandler.onClick).not.undefined;
+ expect(state.currency).eq(currency);
+ expect(state.account.value).eq(accountSelected);
+ expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:0"));
+ expect(state.depositHandler.onClick).undefined;
+ expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`));
+
+ expect(state.amount.onInput).not.undefined;
+ if (!state.amount.onInput) return;
+ state.amount.onInput(Amounts.parseOrThrow("EUR:10"));
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.cancelHandler.onClick).not.undefined;
+ expect(state.currency).eq(currency);
+ expect(state.account.value).eq(accountSelected);
+ expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:10"));
+ expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`));
+ expect(state.totalToDeposit).deep.eq(
+ Amounts.parseOrThrow(`${currency}:7`),
+ );
+ expect(state.depositHandler.onClick).not.undefined;
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ expect(state.cancelHandler.onClick).not.undefined;
+ expect(state.currency).eq(currency);
+ expect(state.account.value).eq(accountSelected);
+ expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:10"));
+ expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`));
+ expect(state.totalToDeposit).deep.eq(
+ Amounts.parseOrThrow(`${currency}:7`),
+ );
+ expect(state.depositHandler.onClick).not.undefined;
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx
new file mode 100644
index 000000000..908becb04
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx
@@ -0,0 +1,191 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Amounts, PaytoUri } from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import { AmountField } from "../../components/AmountField.js";
+import { ErrorMessage } from "../../components/ErrorMessage.js";
+import { SelectList } from "../../components/SelectList.js";
+import { Input, SubTitle, WarningBox } from "../../components/styled/index.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Button } from "../../mui/Button.js";
+import { Grid } from "../../mui/Grid.js";
+import { State } from "./index.js";
+
+export function AmountOrCurrencyErrorView(
+ p: State.AmountOrCurrencyError,
+): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <ErrorMessage
+ title={i18n.str`A currency or an amount should be indicated`}
+ />
+ );
+}
+
+export function NoEnoughBalanceView({
+ currency,
+}: State.NoEnoughBalance): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <ErrorMessage
+ title={i18n.str`There is no enough balance to make a deposit for currency ${currency}`}
+ />
+ );
+}
+
+function AccountDetails({ account }: { account: PaytoUri }): VNode {
+ if (account.isKnown) {
+ if (account.targetType === "bitcoin") {
+ return (
+ <dl>
+ <dt>Bitcoin</dt>
+ <dd>{account.targetPath}</dd>
+ </dl>
+ );
+ }
+ if (account.targetType === "x-taler-bank") {
+ return (
+ <dl>
+ <dt>Bank host</dt>
+ <dd>{account.targetPath.split("/")[0]}</dd>
+ <dt>Account name</dt>
+ <dd>{account.targetPath.split("/")[1]}</dd>
+ </dl>
+ );
+ }
+ if (account.targetType === "iban") {
+ return (
+ <dl>
+ <dt>IBAN</dt>
+ <dd>{account.targetPath}</dd>
+ </dl>
+ );
+ }
+ }
+ return <Fragment />;
+}
+
+export function NoAccountToDepositView({
+ currency,
+ onAddAccount,
+}: State.NoAccounts): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <SubTitle>
+ <i18n.Translate>Send {currency} to your account</i18n.Translate>
+ </SubTitle>
+
+ <WarningBox>
+ <i18n.Translate>
+ There is no account to make a deposit for currency {currency}
+ </i18n.Translate>
+ </WarningBox>
+
+ <Button onClick={onAddAccount.onClick} variant="contained">
+ <i18n.Translate>Add account</i18n.Translate>
+ </Button>
+ </Fragment>
+ );
+}
+
+export function ReadyView(state: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <SubTitle>
+ <i18n.Translate>Send {state.currency} to your account</i18n.Translate>
+ </SubTitle>
+ <section>
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ marginBottom: 16,
+ }}
+ >
+ <Input>
+ <SelectList
+ label={i18n.str`Select account`}
+ list={state.account.list}
+ name="account"
+ value={state.account.value}
+ onChange={state.account.onChange}
+ />
+ </Input>
+ <Button
+ onClick={state.onAddAccount.onClick}
+ variant="text"
+ style={{ marginLeft: "auto" }}
+ >
+ <i18n.Translate>Manage accounts</i18n.Translate>
+ </Button>
+ </div>
+
+ <p>
+ <AccountDetails account={state.currentAccount} />
+ </p>
+ <Grid container spacing={2} columns={1}>
+ <Grid item xs={1}>
+ <AmountField label={i18n.str`Amount`} handler={state.amount} />
+ </Grid>
+ <Grid item xs={1}>
+ <AmountField
+ label={i18n.str`Deposit fee`}
+ handler={{
+ value: state.totalFee,
+ }}
+ />
+ </Grid>
+ <Grid item xs={1}>
+ <AmountField
+ label={i18n.str`Total deposit`}
+ handler={{
+ value: state.totalToDeposit,
+ }}
+ />
+ </Grid>
+ </Grid>
+ </section>
+ <footer>
+ <Button
+ variant="contained"
+ color="secondary"
+ onClick={state.cancelHandler.onClick}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ {!state.depositHandler.onClick ? (
+ <Button variant="contained" disabled>
+ <i18n.Translate>Deposit</i18n.Translate>
+ </Button>
+ ) : (
+ <Button variant="contained" onClick={state.depositHandler.onClick}>
+ <i18n.Translate>
+ Deposit&nbsp;{Amounts.stringifyValue(state.totalToDeposit)}{" "}
+ {state.currency}
+ </i18n.Translate>
+ </Button>
+ )}
+ </footer>
+ </Fragment>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/index.ts b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/index.ts
new file mode 100644
index 000000000..b56fe5523
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/index.ts
@@ -0,0 +1,98 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import {
+ AmountFieldHandler,
+ ButtonHandler,
+ ToggleHandler,
+} from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ReadyView, SelectCurrencyView } from "./views.js";
+
+export type Props = PropsGet | PropsSend;
+
+interface PropsGet {
+ type: "get";
+ amount?: string;
+ goToWalletManualWithdraw: (amount: string) => void;
+ goToWalletWalletInvoice: (amount: string) => void;
+}
+interface PropsSend {
+ type: "send";
+ amount?: string;
+ goToWalletBankDeposit: (amount: string) => void;
+ goToWalletWalletSend: (amount: string) => void;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.Ready
+ | State.SelectCurrency;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface SelectCurrency {
+ status: "select-currency";
+ error: undefined;
+ currencies: Record<string, string>;
+ onCurrencySelected: (currency: string) => void;
+ }
+
+ export interface Ready {
+ status: "ready";
+ error: undefined;
+ type: Props["type"];
+ selectCurrency: ButtonHandler;
+ selectMax: ButtonHandler;
+ previous: Contact[];
+ goToBank: ButtonHandler;
+ goToWallet: ButtonHandler;
+ amountHandler: AmountFieldHandler;
+ }
+}
+
+export type Contact = {
+ icon_type: string;
+ name: string;
+ description: string;
+};
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ "select-currency": SelectCurrencyView,
+ ready: ReadyView,
+};
+
+export const DestinationSelectionPage = compose(
+ "DestinationSelectionPage",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts
new file mode 100644
index 000000000..d4e270a6c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts
@@ -0,0 +1,198 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Amounts } from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useState } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { RecursiveState, assertUnreachable } from "../../utils/index.js";
+import { Contact, Props, State } from "./index.js";
+
+export function useComponentState(props: Props): RecursiveState<State> {
+ const api = useBackendContext();
+ const { pushAlertOnError } = useAlertContext();
+
+ const parsedInitialAmount = !props.amount
+ ? undefined
+ : Amounts.parse(props.amount);
+
+ const hook = useAsyncAsHook(async () => {
+ if (!parsedInitialAmount) return undefined;
+ const balance = await api.wallet.call(WalletApiOperation.GetBalanceDetail, {
+ currency: parsedInitialAmount.currency,
+ });
+ return { balance };
+ });
+
+ const info = hook && !hook.hasError ? hook.response : undefined;
+
+ // const initialCurrency = parsedInitialAmount?.currency;
+
+ const [amount, setAmount] = useState(
+ !parsedInitialAmount ? undefined : parsedInitialAmount,
+ );
+ //FIXME: get this information from wallet
+ // eslint-disable-next-line no-constant-condition
+ const previous: Contact[] = true
+ ? []
+ : [
+ {
+ name: "International Bank",
+ icon_type: "bank",
+ description: "account ending with 3454",
+ },
+ {
+ name: "Max",
+ icon_type: "bank",
+ description: "account ending with 3454",
+ },
+ {
+ name: "Alex",
+ icon_type: "bank",
+ description: "account ending with 3454",
+ },
+ ];
+
+ if (!amount) {
+ return () => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const { i18n } = useTranslationContext();
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const hook = useAsyncAsHook(() =>
+ api.wallet.call(WalletApiOperation.ListExchanges, {}),
+ );
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(i18n,
+ i18n.str`Could not load exchanges`, hook),
+ };
+ }
+ const currencies: Record<string, string> = {};
+ hook.response.exchanges.forEach((e) => {
+ if (e.currency) {
+ currencies[e.currency] = e.currency;
+ }
+ });
+ currencies[""] = "Select a currency";
+
+ return {
+ status: "select-currency",
+ error: undefined,
+ onCurrencySelected: (c: string) => {
+ setAmount(Amounts.zeroOfCurrency(c));
+ },
+ currencies,
+ };
+ };
+ }
+
+ const currencyAndAmount = Amounts.stringify(amount);
+ const invalid = Amounts.isZero(amount);
+
+ switch (props.type) {
+ case "send":
+ return {
+ status: "ready",
+ error: undefined,
+ previous,
+ selectCurrency: {
+ onClick: pushAlertOnError(async () => {
+ setAmount(undefined);
+ }),
+ },
+ goToBank: {
+ onClick: invalid
+ ? undefined
+ : pushAlertOnError(async () => {
+ props.goToWalletBankDeposit(currencyAndAmount);
+ }),
+ },
+ selectMax: {
+ onClick: pushAlertOnError(async () => {
+ const resp = await api.wallet.call(
+ WalletApiOperation.GetMaxDepositAmount,
+ {
+ currency: amount.currency,
+ },
+ );
+ setAmount(Amounts.parseOrThrow(resp.effectiveAmount));
+ }),
+ },
+ goToWallet: {
+ onClick: invalid
+ ? undefined
+ : pushAlertOnError(async () => {
+ props.goToWalletWalletSend(currencyAndAmount);
+ }),
+ },
+ amountHandler: {
+ onInput: pushAlertOnError(async (s) => setAmount(s)),
+ value: amount,
+ },
+ type: props.type,
+ };
+ case "get":
+ return {
+ status: "ready",
+ error: undefined,
+ previous,
+ selectCurrency: {
+ onClick: pushAlertOnError(async () => {
+ setAmount(undefined);
+ }),
+ },
+ selectMax: {
+ onClick: invalid
+ ? undefined
+ : pushAlertOnError(async () => {
+ props.goToWalletManualWithdraw(currencyAndAmount);
+ }),
+ },
+ goToBank: {
+ onClick: invalid
+ ? undefined
+ : pushAlertOnError(async () => {
+ props.goToWalletManualWithdraw(currencyAndAmount);
+ }),
+ },
+ goToWallet: {
+ onClick: invalid
+ ? undefined
+ : pushAlertOnError(async () => {
+ props.goToWalletWalletInvoice(currencyAndAmount);
+ }),
+ },
+ amountHandler: {
+ onInput: pushAlertOnError(async (s) => setAmount(s)),
+ value: amount,
+ },
+ type: props.type,
+ };
+ default:
+ assertUnreachable(props);
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/stories.tsx b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/stories.tsx
new file mode 100644
index 000000000..e1ac958f7
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/stories.tsx
@@ -0,0 +1,65 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView, SelectCurrencyView } from "./views.js";
+
+export default {
+ title: "destination",
+};
+
+export const GetCash = tests.createExample(ReadyView, {
+ amountHandler: {
+ value: {
+ currency: "EUR",
+ fraction: 0,
+ value: 2,
+ },
+ },
+ goToBank: {},
+ selectMax: {},
+ goToWallet: {},
+ previous: [],
+ selectCurrency: {},
+ type: "get",
+});
+export const SendCash = tests.createExample(ReadyView, {
+ amountHandler: {
+ value: {
+ currency: "EUR",
+ fraction: 0,
+ value: 1,
+ },
+ },
+ selectMax: {},
+ goToBank: {},
+ goToWallet: {},
+ previous: [],
+ selectCurrency: {},
+ type: "send",
+});
+
+export const SelectCurrency = tests.createExample(SelectCurrencyView, {
+ currencies: {
+ "": "Select a currency",
+ USD: "USD",
+ },
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts
new file mode 100644
index 000000000..683378613
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts
@@ -0,0 +1,153 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ Amounts,
+ ExchangeEntryStatus,
+ ExchangeListItem,
+ ExchangeTosStatus,
+ ExchangeUpdateStatus,
+ ScopeType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import * as tests from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { nullFunction } from "../../mui/handlers.js";
+import { createWalletApiMock } from "../../test-utils.js";
+import { useComponentState } from "./state.js";
+
+const exchangeArs: ExchangeListItem = {
+ currency: "ARS",
+ exchangeBaseUrl: "http://",
+ masterPub: "123qwe123",
+ scopeInfo: {
+ currency: "ARS",
+ type: ScopeType.Exchange,
+ url: "http://",
+ },
+ tosStatus: ExchangeTosStatus.Accepted,
+ exchangeEntryStatus: ExchangeEntryStatus.Used,
+ exchangeUpdateStatus: ExchangeUpdateStatus.Initial,
+ paytoUris: [],
+ ageRestrictionOptions: [],
+ lastUpdateTimestamp: undefined,
+ noFees: false,
+ peerPaymentsDisabled: false,
+};
+
+describe("Destination selection states", () => {
+ it("should select currency if no amount specified", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+
+ handler.addWalletCallResponse(
+ WalletApiOperation.ListExchanges,
+ {},
+ {
+ exchanges: [exchangeArs],
+ },
+ );
+
+ const props = {
+ type: "get" as const,
+ goToWalletManualWithdraw: nullFunction,
+ goToWalletWalletInvoice: nullFunction,
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ ({ status }) => {
+ expect(status).equal("loading");
+ },
+ (state) => {
+ if (state.status !== "select-currency") expect.fail();
+ if (state.error) expect.fail();
+ expect(state.currencies).deep.eq({
+ ARS: "ARS",
+ "": "Select a currency",
+ });
+
+ state.onCurrencySelected(exchangeArs.currency!);
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ if (state.error) expect.fail();
+ expect(state.goToBank.onClick).eq(undefined);
+ expect(state.goToWallet.onClick).eq(undefined);
+
+ expect(state.amountHandler.value).deep.eq(
+ Amounts.parseOrThrow("ARS:0"),
+ );
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+
+ it("should be possible to start with an amount specified in request params", async () => {
+ const { handler, TestingContext } = createWalletApiMock();
+
+ const props = {
+ type: "get" as const,
+ goToWalletManualWithdraw: nullFunction,
+ goToWalletWalletInvoice: nullFunction,
+ amount: "ARS:2",
+ };
+
+ const hookBehavior = await tests.hookBehaveLikeThis(
+ useComponentState,
+ props,
+ [
+ // ({ status }) => {
+ // expect(status).equal("loading");
+ // },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ if (state.error) expect.fail();
+ expect(state.goToBank.onClick).not.eq(undefined);
+ expect(state.goToWallet.onClick).not.eq(undefined);
+
+ expect(state.amountHandler.value).deep.eq(
+ Amounts.parseOrThrow("ARS:2"),
+ );
+ },
+ (state) => {
+ if (state.status !== "ready") expect.fail();
+ if (state.error) expect.fail();
+ expect(state.goToBank.onClick).not.eq(undefined);
+ expect(state.goToWallet.onClick).not.eq(undefined);
+
+ expect(state.amountHandler.value).deep.eq(
+ Amounts.parseOrThrow("ARS:2"),
+ );
+ },
+ ],
+ TestingContext,
+ );
+
+ expect(hookBehavior).deep.equal({ result: "ok" });
+ expect(handler.getCallingQueueState()).eq("empty");
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx
new file mode 100644
index 000000000..8a74a20f1
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx
@@ -0,0 +1,430 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { styled } from "@linaria/react";
+import { Fragment, h, VNode } from "preact";
+import { AmountField } from "../../components/AmountField.js";
+import { EnabledBySettings } from "../../components/EnabledBySettings.js";
+import { SelectList } from "../../components/SelectList.js";
+import {
+ Input,
+ LightText,
+ LinkPrimary,
+ SvgIcon,
+} from "../../components/styled/index.js";
+import { Button } from "../../mui/Button.js";
+import { Grid } from "../../mui/Grid.js";
+import { Paper } from "../../mui/Paper.js";
+import { Pages } from "../../NavigationBar.js";
+import arrowIcon from "../../svg/chevron-down.inline.svg";
+import bankIcon from "../../svg/ri-bank-line.inline.svg";
+import { assertUnreachable } from "../../utils/index.js";
+import { Contact, State } from "./index.js";
+
+export function SelectCurrencyView({
+ currencies,
+ onCurrencySelected,
+}: State.SelectCurrency): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <h2>
+ <i18n.Translate>
+ Choose a currency to proceed or add another exchange
+ </i18n.Translate>
+ </h2>
+
+ <p>
+ <Input>
+ <SelectList
+ label={i18n.str`Known currencies`}
+ list={currencies}
+ name="lang"
+ value={""}
+ onChange={(v) => onCurrencySelected(v)}
+ />
+ </Input>
+ </p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div />
+ <LinkPrimary href={Pages.settingsExchangeAdd({})}>
+ <i18n.Translate>Add an exchange</i18n.Translate>
+ </LinkPrimary>
+ </div>
+ </Fragment>
+ );
+}
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ & > * {
+ margin: 8px;
+ }
+`;
+
+const ContactTable = styled.table`
+ width: 100%;
+ & > tr > td {
+ padding: 8px;
+ & > div:not([data-disabled]):hover {
+ background-color: lightblue;
+ }
+ color: black;
+ div[data-disabled] > * {
+ color: gray;
+ }
+ }
+
+ & > tr:nth-child(2n) {
+ background: #ebebeb;
+ }
+`;
+
+const MediaExample = styled.div`
+ text-size-adjust: 100%;
+ color: inherit;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ text-transform: none;
+ text-align: left;
+ box-sizing: border-box;
+ align-items: center;
+ display: flex;
+ padding: 8px 8px;
+
+ &[data-disabled]:hover {
+ cursor: inherit;
+ }
+ cursor: pointer;
+`;
+
+const MediaLeft = styled.div`
+ text-size-adjust: 100%;
+
+ color: inherit;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ text-transform: none;
+ text-align: left;
+ box-sizing: border-box;
+ padding-right: 8px;
+ display: block;
+`;
+
+const MediaBody = styled.div`
+ text-size-adjust: 100%;
+
+ font-family: inherit;
+ text-transform: none;
+ text-align: left;
+ box-sizing: border-box;
+ flex: 1 1;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.42857;
+`;
+const MediaRight = styled.div`
+ text-size-adjust: 100%;
+
+ color: inherit;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ text-transform: none;
+ text-align: left;
+ box-sizing: border-box;
+ padding-left: 8px;
+`;
+
+const CircleDiv = styled.div`
+ box-sizing: border-box;
+ align-items: center;
+ background-position: 50%;
+ background-repeat: no-repeat;
+ background-size: cover;
+ border-radius: 50%;
+ display: flex;
+ justify-content: center;
+ margin-left: auto;
+ margin-right: auto;
+ overflow: hidden;
+ text-align: center;
+ text-decoration: none;
+ text-transform: uppercase;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease,
+ color 0.15s ease;
+ font-size: 16px;
+ background-color: #86a7bd1a;
+ height: 40px;
+ line-height: 40px;
+ width: 40px;
+ border: none;
+`;
+
+export function ReadyView(props: State.Ready): VNode {
+ switch (props.type) {
+ case "get":
+ return ReadyGetView(props);
+ case "send":
+ return ReadySendView(props);
+ default:
+ assertUnreachable(props.type);
+ }
+}
+export function ReadyGetView({
+ amountHandler,
+ goToBank,
+ goToWallet,
+ selectCurrency,
+ previous,
+}: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Container>
+ <h1>
+ <i18n.Translate>Specify the amount and the origin</i18n.Translate>
+ </h1>
+ <Grid container columns={2} justifyContent="space-between">
+ <AmountField
+ label={i18n.str`Amount`}
+ required
+ handler={amountHandler}
+ />
+
+ <Button onClick={selectCurrency.onClick}>
+ <i18n.Translate>Change currency</i18n.Translate>
+ </Button>
+ </Grid>
+
+ <Grid container spacing={1} columns={1}>
+ {previous.length > 0 ? (
+ <Fragment>
+ <p>
+ <i18n.Translate>Use previous origins:</i18n.Translate>
+ </p>
+ <Grid item xs={1}>
+ <Paper style={{ padding: 8 }}>
+ <ContactTable>
+ {previous.map((info, i) => (
+ <tr key={i}>
+ <td>
+ <RowExample
+ info={info}
+ disabled={!amountHandler.onInput}
+ />
+ </td>
+ </tr>
+ ))}
+ </ContactTable>
+ </Paper>
+ </Grid>
+ </Fragment>
+ ) : undefined}
+ {previous.length > 0 ? (
+ <Grid item>
+ <p>
+ <i18n.Translate>
+ Or specify the origin of the money
+ </i18n.Translate>
+ </p>
+ </Grid>
+ ) : (
+ <Grid item>
+ <p>
+ <i18n.Translate>Specify the origin of the money</i18n.Translate>
+ </p>
+ </Grid>
+ )}
+ <Grid item container columns={2} spacing={1}>
+ <Grid item xs={1}>
+ <Paper style={{ padding: 8 }}>
+ <p>
+ <i18n.Translate>From my bank account</i18n.Translate>
+ </p>
+ <Button onClick={goToBank.onClick}>
+ <i18n.Translate>Withdraw</i18n.Translate>
+ </Button>
+ </Paper>
+ </Grid>
+ <Grid item xs={1}>
+ <Paper style={{ padding: 8 }}>
+ <p>
+ <i18n.Translate>From another wallet</i18n.Translate>
+ </p>
+ <Button onClick={goToWallet.onClick}>
+ <i18n.Translate>Invoice</i18n.Translate>
+ </Button>
+ </Paper>
+ </Grid>
+ <Grid item xs={1}>
+ <Paper style={{ padding: 8 }}>
+ <p>
+ <i18n.Translate>From a <pre style={{display:"inline"}}>taler://peer-push-credit</pre> URI</i18n.Translate>
+ </p>
+ <a href={Pages.qr}>
+ <i18n.Translate>Enter URI here</i18n.Translate>
+ </a>
+ </Paper>
+ </Grid>
+ </Grid>
+ </Grid>
+ </Container>
+ );
+}
+export function ReadySendView({
+ amountHandler,
+ goToBank,
+ goToWallet,
+ previous,
+ selectMax,
+}: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Container>
+ <h1>
+ <i18n.Translate>Specify the amount and the destination</i18n.Translate>
+ </h1>
+
+ <Grid container columns={2} justifyContent="space-between">
+ <AmountField
+ label={i18n.str`Amount`}
+ required
+ handler={amountHandler}
+ />
+ <EnabledBySettings name="advancedMode">
+ <Button onClick={selectMax.onClick}>
+ <i18n.Translate>Send all</i18n.Translate>
+ </Button>
+ </EnabledBySettings>
+ </Grid>
+
+ <Grid container spacing={1} columns={1}>
+ {previous.length > 0 ? (
+ <Fragment>
+ <p>
+ <i18n.Translate>Use previous destinations:</i18n.Translate>
+ </p>
+ <Grid item xs={1}>
+ <Paper style={{ padding: 8 }}>
+ <ContactTable>
+ {previous.map((info, i) => (
+ <tr key={i}>
+ <td>
+ <RowExample
+ info={info}
+ disabled={!amountHandler.onInput}
+ />
+ </td>
+ </tr>
+ ))}
+ </ContactTable>
+ </Paper>
+ </Grid>
+ </Fragment>
+ ) : undefined}
+ {previous.length > 0 ? (
+ <Grid item>
+ <p>
+ <i18n.Translate>
+ Or specify the destination of the money
+ </i18n.Translate>
+ </p>
+ </Grid>
+ ) : (
+ <Grid item>
+ <p>
+ <i18n.Translate>
+ Specify the destination of the money
+ </i18n.Translate>
+ </p>
+ </Grid>
+ )}
+ <Grid item container columns={2} spacing={1}>
+ <Grid item xs={1}>
+ <Paper style={{ padding: 8 }}>
+ <p>
+ <i18n.Translate>To my bank account</i18n.Translate>
+ </p>
+ <Button onClick={goToBank.onClick}>
+ <i18n.Translate>Deposit</i18n.Translate>
+ </Button>
+ </Paper>
+ </Grid>
+ <Grid item xs={1}>
+ <Paper style={{ padding: 8 }}>
+ <p>
+ <i18n.Translate>To another wallet</i18n.Translate>
+ </p>
+ <Button onClick={goToWallet.onClick}>
+ <i18n.Translate>Send</i18n.Translate>
+ </Button>
+ </Paper>
+ </Grid>
+ </Grid>
+ </Grid>
+ </Container>
+ );
+}
+
+function RowExample({
+ info,
+ disabled,
+}: {
+ info: Contact;
+ disabled?: boolean;
+}): VNode {
+ const icon = info.icon_type === "bank" ? bankIcon : undefined;
+ return (
+ <MediaExample data-disabled={disabled}>
+ <MediaLeft>
+ <CircleDiv>
+ {icon !== undefined ? (
+ <SvgIcon
+ title={info.name}
+ dangerouslySetInnerHTML={{
+ __html: icon,
+ }}
+ color="currentColor"
+ />
+ ) : (
+ <span>A</span>
+ )}
+ </CircleDiv>
+ </MediaLeft>
+ <MediaBody>
+ <span>{info.name}</span>
+ <LightText>{info.description}</LightText>
+ </MediaBody>
+ <MediaRight>
+ <SvgIcon
+ title="Select this contact"
+ dangerouslySetInnerHTML={{ __html: arrowIcon }}
+ color="currentColor"
+ transform="rotate(-90deg)"
+ />
+ </MediaRight>
+ </MediaExample>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.stories.tsx b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.stories.tsx
new file mode 100644
index 000000000..e7c9111fd
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.stories.tsx
@@ -0,0 +1,50 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AbsoluteTime } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { DeveloperPage as TestedComponent } from "./DeveloperPage.js";
+
+export default {
+ title: "developer",
+ component: TestedComponent,
+ argTypes: {
+ setDeviceName: () => Promise.resolve(),
+ },
+};
+
+export const AllOff = tests.createExample(TestedComponent, {
+ onDownloadDatabase: async () => "this is the content of the database",
+ operations: [
+ {
+ id: " ",
+ type: "exchange-update",
+ exchangeBaseUrl: "http://exchange.url.",
+ givesLifeness: false,
+ lastError: undefined,
+ timestampDue: AbsoluteTime.fromMilliseconds(123123213),
+ retryInfo: undefined,
+ isDue: false,
+ isLongpolling: false,
+ },
+ ],
+ coins: [],
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx
new file mode 100644
index 000000000..53380e263
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx
@@ -0,0 +1,690 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ CoinDumpJson,
+ CoinStatus,
+ ExchangeListItem,
+ ExchangeTosStatus,
+ LogLevel,
+ NotificationType,
+ ScopeType,
+ parseWithdrawUri,
+ stringifyWithdrawExchange,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { Checkbox } from "../components/Checkbox.js";
+import { SelectList } from "../components/SelectList.js";
+import { Time } from "../components/Time.js";
+import { DestructiveText, LinkPrimary, NotifyUpdateFadeOut, SubTitle, SuccessText, WarningText } from "../components/styled/index.js";
+import { useAlertContext } from "../context/alert.js";
+import { useBackendContext } from "../context/backend.js";
+import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { useSettings } from "../hooks/useSettings.js";
+import { Button } from "../mui/Button.js";
+import { Grid } from "../mui/Grid.js";
+import { Paper } from "../mui/Paper.js";
+import { TextField } from "../mui/TextField.js";
+import { Pages } from "../NavigationBar.js";
+import { CoinInfo } from "@gnu-taler/taler-wallet-core/dbless";
+import { ActiveTasksTable } from "../components/WalletActivity.js";
+
+type CoinsInfo = CoinDumpJson["coins"];
+type CalculatedCoinfInfo = {
+ // ageKeysCount: number | undefined;
+ denom_value: number;
+ denom_fraction: number;
+ //remain_value: number;
+ status: string;
+ from_refresh: boolean;
+ id: string;
+};
+
+type SplitedCoinInfo = {
+ spent: CalculatedCoinfInfo[];
+ usable: CalculatedCoinfInfo[];
+};
+
+export interface Props {
+ // FIXME: Pending operations don't exist anymore.
+}
+
+function hashObjectId(o: any): string {
+ return JSON.stringify(o);
+}
+
+export function DeveloperPage({ }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [downloadedDatabase, setDownloadedDatabase] = useState<
+ { time: Date; content: string } | undefined
+ >(undefined);
+ async function onExportDatabase(): Promise<void> {
+ const db = await api.wallet.call(WalletApiOperation.ExportDb, {});
+ const content = JSON.stringify(db);
+ setDownloadedDatabase({
+ time: new Date(),
+ content,
+ });
+ }
+ const api = useBackendContext();
+
+ const fileRef = useRef<HTMLInputElement>(null);
+ async function onImportDatabase(str: string): Promise<void> {
+ await api.wallet.call(WalletApiOperation.ImportDb, {
+ dump: JSON.parse(str),
+ });
+ }
+ const [settings, updateSettings] = useSettings();
+ const { safely } = useAlertContext();
+
+ const listenAllEvents = Array.from<NotificationType>({ length: 1 });
+ // listenAllEvents.includes = () => true
+
+ const hook = useAsyncAsHook(async () => {
+ const list = await api.wallet.call(WalletApiOperation.ListExchanges, {});
+ const version = await api.wallet.call(WalletApiOperation.GetVersion, {});
+ const coins = await api.wallet.call(WalletApiOperation.DumpCoins, {});
+ return { exchanges: list.exchanges, version, coins };
+ });
+ const exchangeList = hook && !hook.hasError ? hook.response.exchanges : [];
+ const coins = hook && !hook.hasError ? hook.response.coins.coins : [];
+
+ useEffect(() => {
+ return api.listener.onUpdateNotification(listenAllEvents, (ev) => {
+ console.log("event", ev)
+ return hook?.retry()
+ });
+ });
+
+ const currencies: { [ex: string]: string } = {};
+ const money_by_exchange = coins.reduce(
+ (prev, cur) => {
+ const denom = Amounts.parseOrThrow(cur.denom_value);
+ if (!prev[cur.exchange_base_url]) {
+ prev[cur.exchange_base_url] = [];
+ currencies[cur.exchange_base_url] = denom.currency;
+ }
+ prev[cur.exchange_base_url].push({
+ // ageKeysCount: cur.ageCommitmentProof?.proof.privateKeys.length,
+ denom_value: denom.value,
+ denom_fraction: denom.fraction,
+ // remain_value: parseFloat(
+ // Amounts.stringifyValue(Amounts.parseOrThrow(cur.remaining_value)),
+ // ),
+ status: cur.coin_status,
+ from_refresh: cur.refresh_parent_coin_pub !== undefined,
+ id: cur.coin_pub,
+ });
+ return prev;
+ },
+ {} as {
+ [exchange_name: string]: CalculatedCoinfInfo[];
+ },
+ );
+
+ const [tagName, setTagName] = useState("");
+ const [logLevel, setLogLevel] = useState("info");
+ return (
+ <div>
+ <p>
+ <i18n.Translate>Debug tools</i18n.Translate>:
+ </p>
+ <Grid container justifyContent="space-between" spacing={1} size={4}>
+ <Grid item>
+ <Button
+ variant="contained"
+ onClick={() =>
+ confirmReset(
+ i18n.str`Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL YOUR COINS?`,
+ () => api.background.call("resetDb", undefined),
+ )
+ }
+ >
+ <i18n.Translate>reset</i18n.Translate>
+ </Button>
+ </Grid>
+ <Grid item>
+ <Button
+ variant="contained"
+ onClick={() =>
+ confirmReset(
+ i18n.str`TESTING: This may delete all your coin, proceed with caution`,
+ () => api.background.call("runGarbageCollector", undefined),
+ )
+ }
+ >
+ <i18n.Translate>run gc</i18n.Translate>
+ </Button>
+ </Grid>
+ <Grid item>
+ <Button
+ variant="contained"
+ onClick={async () => fileRef?.current?.click()}
+ >
+ <i18n.Translate>import database</i18n.Translate>
+ </Button>
+ </Grid>
+ <Grid item>
+ <input
+ ref={fileRef}
+ style={{ display: "none" }}
+ type="file"
+ onChange={async (e) => {
+ const f: FileList | null = e.currentTarget.files;
+ if (!f || f.length != 1) {
+ return Promise.reject();
+ }
+ const buf = await f[0].arrayBuffer();
+ const str = new Uint8Array(buf).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ );
+ return onImportDatabase(str);
+ }}
+ />
+ <Button variant="contained" onClick={onExportDatabase}>
+ <i18n.Translate>export database</i18n.Translate>
+ </Button>
+ </Grid>
+ <Grid item>
+ <Button
+ variant="contained"
+ onClick={async () => {
+ const result = await Promise.all(
+ exchangeList.map(async (exchange) => {
+ const url = exchange.exchangeBaseUrl;
+ const oldKeys = JSON.stringify(
+ await (await fetch(`${url}keys`)).json(),
+ );
+ const newKeys = JSON.stringify(
+ await (
+ await fetch(`${url}keys`, { cache: "no-cache" })
+ ).json(),
+ );
+ return oldKeys !== newKeys;
+ }),
+ );
+ const ex = exchangeList.filter((e, i) => result[i]);
+ if (!ex.length) {
+ alert("no exchange was outdated");
+ } else {
+ alert(`found some exchange out of date: ${result.join(", ")}`);
+ }
+ }}
+ >
+ <i18n.Translate>Clear exchange key cache</i18n.Translate>
+ </Button>
+ </Grid>{" "}
+ </Grid>
+ {downloadedDatabase && (
+ <div>
+ <i18n.Translate>
+ Database exported at{" "}
+ <Time
+ timestamp={AbsoluteTime.fromMilliseconds(
+ downloadedDatabase.time.getTime(),
+ )}
+ format="yyyy/MM/dd HH:mm:ss"
+ />{" "}
+ <a
+ href={`data:text/plain;charset=utf-8;base64,${toBase64(
+ downloadedDatabase.content,
+ )}`}
+ download={`taler-wallet-database-${format(
+ downloadedDatabase.time,
+ "yyyy/MM/dd_HH:mm",
+ )}.json`}
+ >
+ <i18n.Translate>click here</i18n.Translate>
+ </a>{" "}
+ to download
+ </i18n.Translate>
+ </div>
+ )}
+ <Checkbox
+ label={i18n.str`Inject Taler support in all pages`}
+ name="inject"
+ description={
+ <i18n.Translate>
+ Enabling this option will make `window.taler` be available in all
+ sites
+ </i18n.Translate>
+ }
+ enabled={settings.injectTalerSupport!}
+ onToggle={safely("update support injection", async () => {
+ updateSettings("injectTalerSupport", !settings.injectTalerSupport);
+ })}
+ />
+
+
+ <SubTitle>
+ <i18n.Translate>Exchange Entries</i18n.Translate>
+ </SubTitle>
+ {!exchangeList || !exchangeList.length ? (
+ <div>
+ <i18n.Translate>No exchange yet</i18n.Translate>
+ </div>
+ ) : (
+ <Fragment>
+ <table>
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Currency</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>URL</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Status</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Terms of Service</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Last Update</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Actions</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {exchangeList.map((e, idx) => {
+ function TosStatus(): VNode {
+ switch (e.tosStatus) {
+ case ExchangeTosStatus.Accepted:
+ return (
+ <SuccessText>
+ <i18n.Translate>ok</i18n.Translate>
+ </SuccessText>
+ );
+ case ExchangeTosStatus.Pending:
+ return (
+ <WarningText>
+ <i18n.Translate>pending</i18n.Translate>
+ </WarningText>
+ );
+ case ExchangeTosStatus.Proposed:
+ return <i18n.Translate>proposed</i18n.Translate>;
+ default:
+ return (
+ <DestructiveText>
+ <i18n.Translate>
+ unknown (exchange status should be updated)
+ </i18n.Translate>
+ </DestructiveText>
+ );
+ }
+ }
+ const uri = !e.masterPub ? undefined : stringifyWithdrawExchange({
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ exchangePub: e.masterPub,
+ });
+ return (
+ <tr key={idx}>
+ <td>
+ <a href={!uri ? undefined : Pages.defaultCta({ uri })}>
+ {e.scopeInfo ? `${e.scopeInfo.currency} (${e.scopeInfo.type === ScopeType.Global ? "global" : "regional"})` : e.currency}
+ </a>
+ </td>
+ <td>
+ <a href={new URL(`/keys`, e.exchangeBaseUrl).href} target="_blank">{e.exchangeBaseUrl}</a>
+ </td>
+ <td>
+ {e.exchangeEntryStatus} / {e.exchangeUpdateStatus}
+ </td>
+ <td>
+ <TosStatus />
+ </td>
+ <td>
+ {e.lastUpdateTimestamp
+ ? AbsoluteTime.toIsoString(
+ AbsoluteTime.fromPreciseTimestamp(
+ e.lastUpdateTimestamp,
+ ),
+ )
+ : "never"}
+ </td>
+ <td>
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.UpdateExchangeEntry,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ force: true,
+ },
+ );
+ }}
+ >
+ Reload
+ </button>
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.DeleteExchange,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ },
+ );
+ }}
+ >
+ Delete
+ </button>
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.DeleteExchange,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ purge: true,
+ },
+ );
+ }}
+ >
+ Purge
+ </button>
+ {e.scopeInfo && e.masterPub && e.currency ?
+ (e.scopeInfo.type === ScopeType.Global ?
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.RemoveGlobalCurrencyExchange,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ currency: e.currency!,
+ exchangeMasterPub: e.masterPub!,
+ },
+ );
+ }}
+ >
+
+ Make regional
+ </button>
+ : e.scopeInfo.type === ScopeType.Auditor ?
+ undefined
+
+ : e.scopeInfo.type === ScopeType.Exchange ?
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.AddGlobalCurrencyExchange,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ currency: e.currency!,
+ exchangeMasterPub: e.masterPub!,
+ },
+ );
+ }}
+ >
+
+ Make global
+ </button>
+ : undefined) : undefined
+ }
+ <button
+ onClick={() => {
+ api.wallet.call(
+ WalletApiOperation.SetExchangeTosForgotten,
+ {
+ exchangeBaseUrl: e.exchangeBaseUrl,
+ },
+ );
+ }}
+ >
+ Forget ToS
+ </button>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </Fragment>
+ )}
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div />
+ <LinkPrimary href={Pages.settingsExchangeAdd({})}>
+ <i18n.Translate>Add an exchange</i18n.Translate>
+ </LinkPrimary>
+ </div>
+
+
+ <Paper style={{ padding: 10, margin: 10 }}>
+ <h3>Logging</h3>
+ <div>
+ <TextField
+ label="Tag name"
+ placeholder="wallet.ts"
+ variant="filled"
+ // error={subject.error}
+ required
+ value={tagName}
+ onChange={setTagName}
+ />
+ <SelectList
+ label={i18n.str`Log levels`}
+ list={{
+ trace: "TRACE",
+ info: "INFO",
+ error: "ERROR",
+ }}
+ name="logLevel"
+ value={logLevel}
+ onChange={(v) => setLogLevel(v)}
+ />
+ </div>
+ <Button
+ variant="contained"
+ onClick={async () => {
+ api.background.call("setLoggingLevel", {
+ tag: tagName,
+ level: logLevel as LogLevel,
+ });
+ }}
+ >
+ Set log level
+ </Button>
+ </Paper>
+
+ <br />
+ <p>
+ <i18n.Translate>Coins</i18n.Translate>:
+ </p>
+ {Object.keys(money_by_exchange).map((ex, idx) => {
+ const allcoins = money_by_exchange[ex];
+ allcoins.sort((a, b) => {
+ if (b.denom_value !== a.denom_value) {
+ return b.denom_value - a.denom_value;
+ }
+ return b.denom_fraction - a.denom_fraction;
+ });
+
+ const coins = allcoins.reduce(
+ (prev, cur) => {
+ if (cur.status === CoinStatus.Fresh) prev.usable.push(cur);
+ if (cur.status === CoinStatus.Dormant) prev.spent.push(cur);
+ return prev;
+ },
+ {
+ spent: [],
+ usable: [],
+ } as SplitedCoinInfo,
+ );
+
+ return (
+ <ShowAllCoins
+ key={idx}
+ coins={coins}
+ ex={ex}
+ currencies={currencies}
+ />
+ );
+ })}
+ <br />
+ <NotifyUpdateFadeOut>
+ <ActiveTasksTable />
+ </NotifyUpdateFadeOut>
+ </div>
+ );
+}
+
+function ShowAllCoins({
+ ex,
+ coins,
+ currencies,
+}: {
+ ex: string;
+ coins: SplitedCoinInfo;
+ currencies: { [ex: string]: string };
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [collapsedSpent, setCollapsedSpent] = useState(true);
+ const [collapsedUnspent, setCollapsedUnspent] = useState(false);
+ const totalUsable = coins.usable.reduce(
+ (prev, cur) =>
+ Amounts.add(prev, {
+ currency: "NONE",
+ fraction: cur.denom_fraction,
+ value: cur.denom_value,
+ }).amount,
+ Amounts.zeroOfCurrency("NONE"),
+ );
+ const totalSpent = coins.spent.reduce(
+ (prev, cur) =>
+ Amounts.add(prev, {
+ currency: "NONE",
+ fraction: cur.denom_fraction,
+ value: cur.denom_value,
+ }).amount,
+ Amounts.zeroOfCurrency("NONE"),
+ );
+ return (
+ <Fragment>
+ <p>
+ <b>{ex}</b>: {Amounts.stringifyValue(totalUsable)} {currencies[ex]}
+ </p>
+ <p>
+ spent: {Amounts.stringifyValue(totalSpent)} {currencies[ex]}
+ </p>
+ <p onClick={() => setCollapsedUnspent(true)}>
+ <b>
+ <i18n.Translate>usable coins</i18n.Translate>
+ </b>
+ </p>
+ {collapsedUnspent ? (
+ <div onClick={() => setCollapsedUnspent(false)}>click to show</div>
+ ) : (
+ <table>
+ <tr>
+ <td>
+ <i18n.Translate>id</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>denom</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>status</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>from refresh?</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>age key count</i18n.Translate>
+ </td>
+ </tr>
+ {coins.usable.map((c, idx) => {
+ return (
+ <tr key={idx}>
+ <td>{c.id.substring(0, 5)}</td>
+ <td>
+ {Amounts.stringifyValue({
+ value: c.denom_value,
+ fraction: c.denom_fraction,
+ currency: "ANY",
+ })}
+ </td>
+ <td>{c.status}</td>
+ <td>{c.from_refresh ? "true" : "false"}</td>
+ {/* <td>{String(c.ageKeysCount)}</td> */}
+ </tr>
+ );
+ })}
+ </table>
+ )}
+ <p onClick={() => setCollapsedSpent(true)}>
+ <i18n.Translate>spent coins</i18n.Translate>
+ </p>
+ {collapsedSpent ? (
+ <div onClick={() => setCollapsedSpent(false)}>
+ <i18n.Translate>click to show</i18n.Translate>
+ </div>
+ ) : (
+ <table>
+ <tr>
+ <td>
+ <i18n.Translate>id</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>denom</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>status</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>from refresh?</i18n.Translate>
+ </td>
+ </tr>
+ {coins.spent.map((c, idx) => {
+ return (
+ <tr key={idx}>
+ <td>{c.id.substring(0, 5)}</td>
+ <td>{c.denom_value}</td>
+ <td>{c.status}</td>
+ <td>{c.from_refresh ? "true" : "false"}</td>
+ </tr>
+ );
+ })}
+ </table>
+ )}
+ </Fragment>
+ );
+}
+
+function toBase64(str: string): string {
+ return btoa(
+ encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) {
+ return String.fromCharCode(parseInt(p1, 16));
+ }),
+ );
+}
+
+export async function confirmReset(
+ confirmTheResetMessage: string,
+ cb: () => Promise<void>,
+): Promise<void> {
+ if (confirm(confirmTheResetMessage)) {
+ await cb();
+ window.close();
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/index.ts b/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/index.ts
new file mode 100644
index 000000000..afbaf1945
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/index.ts
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+
+export interface Props {
+ p: string;
+}
+
+export type State = State.Loading | State.LoadingUriError | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ ready: ReadyView,
+};
+
+export const ComponentName = compose(
+ "ComponentName",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/state.ts b/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/state.ts
new file mode 100644
index 000000000..31a351579
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/state.ts
@@ -0,0 +1,24 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Props, State } from "./index.js";
+
+export function useComponentState({ p }: Props): State {
+ return {
+ status: "ready",
+ error: undefined,
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/stories.tsx b/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/stories.tsx
new file mode 100644
index 000000000..628e97c02
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/stories.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "example",
+};
+
+export const Ready = tests.createExample(ReadyView, {});
diff --git a/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/test.ts b/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/test.ts
new file mode 100644
index 000000000..eae4d4ca2
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/test.ts
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+
+describe("test description", () => {
+ it("should assert", () => {
+ expect([]).deep.equals([]);
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/views.tsx b/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/views.tsx
new file mode 100644
index 000000000..a98bfef60
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/EmptyComponentExample/views.tsx
@@ -0,0 +1,25 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { State } from "./index.js";
+
+export function ReadyView({ error }: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+
+ return <div />;
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts
new file mode 100644
index 000000000..d711f1ecc
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts
@@ -0,0 +1,115 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ DenomOperationMap,
+ ExchangeFullDetails,
+ ExchangeListItem,
+ FeeDescriptionPair,
+} from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { HookError } from "../../hooks/useAsyncAsHook.js";
+import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js";
+import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import {
+ ComparingView,
+ NoExchangesView,
+ PrivacyContentView,
+ ReadyView,
+ TosContentView,
+} from "./views.js";
+
+export interface Props {
+ list: ExchangeListItem[];
+ initialValue: string;
+ onCancel: () => Promise<void>;
+ onSelection: (exchange: string) => Promise<void>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingUriError
+ | State.Ready
+ | State.Comparing
+ | State.ShowingTos
+ | State.ShowingPrivacy
+ | SelectExchangeState.NoExchangeFound;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ exchanges: SelectFieldHandler;
+ selected: ExchangeFullDetails;
+ error: undefined;
+ onShowTerms: ButtonHandler;
+ onShowPrivacy: ButtonHandler;
+ }
+
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ onClose: ButtonHandler;
+ }
+
+ export interface Comparing extends BaseInfo {
+ status: "comparing";
+ coinOperationTimeline: DenomOperationMap<FeeDescriptionPair[]>;
+ wireFeeTimeline: Record<string, FeeDescriptionPair[]>;
+ globalFeeTimeline: FeeDescriptionPair[];
+ missingWireTYpe: string[];
+ newWireType: string[];
+ onReset: ButtonHandler;
+ onSelect: ButtonHandler;
+ }
+ export interface ShowingTos {
+ status: "showing-tos";
+ exchangeUrl: string;
+ onClose: ButtonHandler;
+ }
+ export interface ShowingPrivacy {
+ status: "showing-privacy";
+ exchangeUrl: string;
+ onClose: ButtonHandler;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ comparing: ComparingView,
+ "no-exchange-found": NoExchangesView,
+ "showing-tos": TosContentView,
+ "showing-privacy": PrivacyContentView,
+ ready: ReadyView,
+};
+
+export const ExchangeSelectionPage = compose(
+ "ExchangeSelectionPage",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts
new file mode 100644
index 000000000..d70b62de0
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts
@@ -0,0 +1,242 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { DenomOperationMap, FeeDescription } from "@gnu-taler/taler-util";
+import {
+ WalletApiOperation,
+ createPairTimeline,
+} from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useState } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ onCancel,
+ onSelection,
+ list: exchanges,
+ initialValue,
+}: Props): State {
+ const api = useBackendContext();
+ const { pushAlertOnError } = useAlertContext();
+ const { i18n } = useTranslationContext();
+ const initialValueIdx = exchanges.findIndex(
+ (e) => e.exchangeBaseUrl === initialValue,
+ );
+ if (initialValueIdx === -1) {
+ throw Error(
+ `wrong usage of ExchangeSelection component, currentExchange '${initialValue}' is not in the list of exchanges`,
+ );
+ }
+ const [value, setValue] = useState(String(initialValueIdx));
+
+ const selectedIdx = parseInt(value, 10);
+ const selectedExchange = exchanges[selectedIdx];
+
+ const comparingExchanges = selectedIdx !== initialValueIdx;
+
+ const initialExchange = comparingExchanges
+ ? exchanges[initialValueIdx]
+ : undefined;
+
+ const hook = useAsyncAsHook(async () => {
+ const selected = await api.wallet.call(
+ WalletApiOperation.GetExchangeDetailedInfo,
+ {
+ exchangeBaseUrl: selectedExchange.exchangeBaseUrl,
+ },
+ );
+
+ const original = !initialExchange
+ ? undefined
+ : await api.wallet.call(WalletApiOperation.GetExchangeDetailedInfo, {
+ exchangeBaseUrl: initialExchange.exchangeBaseUrl,
+ });
+
+ return {
+ exchanges,
+ selected: selected.exchange,
+ original: original?.exchange,
+ };
+ }, [selectedExchange, initialExchange]);
+
+ const [showingTos, setShowingTos] = useState<string | undefined>(undefined);
+ const [showingPrivacy, setShowingPrivacy] = useState<string | undefined>(
+ undefined,
+ );
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load exchange details info`,
+ hook,
+ ),
+ };
+ }
+
+ const { selected, original } = hook.response;
+
+ const exchangeMap = exchanges.reduce(
+ (prev, cur, idx) => ({ ...prev, [String(idx)]: cur.exchangeBaseUrl }),
+ {} as Record<string, string>,
+ );
+
+ if (showingPrivacy) {
+ return {
+ status: "showing-privacy",
+ onClose: {
+ onClick: pushAlertOnError(async () => setShowingPrivacy(undefined)),
+ },
+ exchangeUrl: showingPrivacy,
+ };
+ }
+ if (showingTos) {
+ return {
+ status: "showing-tos",
+ onClose: {
+ onClick: pushAlertOnError(async () => setShowingTos(undefined)),
+ },
+ exchangeUrl: showingTos,
+ };
+ }
+
+ if (!comparingExchanges || !original) {
+ // !original <=> selected == original
+ return {
+ status: "ready",
+ exchanges: {
+ list: exchangeMap,
+ value: value,
+ onChange: pushAlertOnError(async (v) => {
+ setValue(v);
+ }),
+ },
+ error: undefined,
+ onClose: {
+ onClick: pushAlertOnError(onCancel),
+ },
+ selected,
+ onShowPrivacy: {
+ onClick: pushAlertOnError(async () => {
+ setShowingPrivacy(selected.exchangeBaseUrl);
+ }),
+ },
+ onShowTerms: {
+ onClick: pushAlertOnError(async () => {
+ setShowingTos(selected.exchangeBaseUrl);
+ }),
+ },
+ };
+ }
+
+ // this may be expensive, useMemo
+ const coinOperationTimeline: DenomOperationMap<FeeDescription[]> = {
+ deposit: createPairTimeline(
+ selected.denomFees.deposit,
+ original.denomFees.deposit,
+ ),
+ refresh: createPairTimeline(
+ selected.denomFees.refresh,
+ original.denomFees.refresh,
+ ),
+ refund: createPairTimeline(
+ selected.denomFees.refund,
+ original.denomFees.refund,
+ ),
+ withdraw: createPairTimeline(
+ selected.denomFees.withdraw,
+ original.denomFees.withdraw,
+ ),
+ };
+
+ const globalFeeTimeline = createPairTimeline(
+ selected.globalFees,
+ original.globalFees,
+ );
+
+ const allWireType = Object.keys(selected.transferFees).concat(
+ Object.keys(original.transferFees),
+ );
+
+ const wireFeeTimeline: Record<string, FeeDescription[]> = {};
+
+ const missingWireTYpe: string[] = [];
+ const newWireType: string[] = [];
+
+ for (const wire of allWireType) {
+ const selectedWire = selected.transferFees[wire];
+ const originalWire = original.transferFees[wire];
+
+ if (!selectedWire) {
+ newWireType.push(wire);
+ continue;
+ }
+ if (!originalWire) {
+ missingWireTYpe.push(wire);
+ continue;
+ }
+
+ wireFeeTimeline[wire] = createPairTimeline(selectedWire, originalWire);
+ }
+
+ return {
+ status: "comparing",
+ exchanges: {
+ list: exchangeMap,
+ value: value,
+ onChange: pushAlertOnError(async (v) => {
+ setValue(v);
+ }),
+ },
+ error: undefined,
+ onReset: {
+ onClick: pushAlertOnError(async () => {
+ setValue(String(initialValue));
+ }),
+ },
+ onSelect: {
+ onClick: pushAlertOnError(async () => {
+ onSelection(selected.exchangeBaseUrl);
+ }),
+ },
+ onShowPrivacy: {
+ onClick: pushAlertOnError(async () => {
+ setShowingPrivacy(selected.exchangeBaseUrl);
+ }),
+ },
+ onShowTerms: {
+ onClick: pushAlertOnError(async () => {
+ setShowingTos(selected.exchangeBaseUrl);
+ }),
+ },
+ selected,
+ coinOperationTimeline,
+ wireFeeTimeline,
+ globalFeeTimeline,
+ missingWireTYpe,
+ newWireType,
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx
new file mode 100644
index 000000000..990e2790f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/stories.tsx
@@ -0,0 +1,563 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ComparingView, ReadyView, NoExchangesView } from "./views.js";
+
+export default {
+ title: "select exchange",
+};
+
+export const NoExchangeFound = tests.createExample(NoExchangesView, {
+ currency: "USD",
+ defaultExchange: "https://exchange.taler.ar",
+});
+
+export const Bitcoin1 = tests.createExample(ReadyView, {
+ exchanges: {
+ list: { "0": "https://exchange.taler.ar" },
+ value: "0",
+ },
+ selected: {
+ currency: "BITCOINBTC",
+ auditors: [],
+ exchangeBaseUrl: "https://exchange.taler.ar",
+ denomFees: timelineExample(),
+ transferFees: {},
+ globalFees: [],
+ } as any,
+ onShowPrivacy: {},
+ onShowTerms: {},
+ onClose: {},
+});
+export const Bitcoin2 = tests.createExample(ReadyView, {
+ exchanges: {
+ list: {
+ "https://exchange.taler.ar": "https://exchange.taler.ar",
+ "https://exchange-btc.taler.ar": "https://exchange-btc.taler.ar",
+ },
+ value: "https://exchange.taler.ar",
+ },
+ selected: {
+ currency: "BITCOINBTC",
+ auditors: [],
+ exchangeBaseUrl: "https://exchange.taler.ar",
+ denomFees: timelineExample(),
+ transferFees: {},
+ globalFees: [],
+ } as any,
+ onShowPrivacy: {},
+ onShowTerms: {},
+ onClose: {},
+});
+
+export const Kudos1 = tests.createExample(ReadyView, {
+ exchanges: {
+ list: {
+ "https://exchange-kudos.taler.ar": "https://exchange-kudos.taler.ar",
+ },
+ value: "https://exchange-kudos.taler.ar",
+ },
+ selected: {
+ currency: "BITCOINBTC",
+ auditors: [],
+ exchangeBaseUrl: "https://exchange.taler.ar",
+ denomFees: timelineExample(),
+ transferFees: {},
+ globalFees: [],
+ } as any,
+ onShowPrivacy: {},
+ onShowTerms: {},
+ onClose: {},
+});
+export const Kudos2 = tests.createExample(ReadyView, {
+ exchanges: {
+ list: {
+ "https://exchange-kudos.taler.ar": "https://exchange-kudos.taler.ar",
+ "https://exchange-kudos2.taler.ar": "https://exchange-kudos2.taler.ar",
+ },
+ value: "https://exchange-kudos.taler.ar",
+ },
+ selected: {
+ currency: "BITCOINBTC",
+ auditors: [],
+ exchangeBaseUrl: "https://exchange.taler.ar",
+ denomFees: timelineExample(),
+ transferFees: {},
+ globalFees: [],
+ } as any,
+ onShowPrivacy: {},
+ onShowTerms: {},
+ onClose: {},
+});
+export const ComparingBitcoin = tests.createExample(ComparingView, {
+ exchanges: {
+ list: { "http://exchange": "http://exchange" },
+ value: "http://exchange",
+ },
+ selected: {
+ currency: "BITCOINBTC",
+ auditors: [],
+ exchangeBaseUrl: "https://exchange.taler.ar",
+ transferFees: {},
+ globalFees: [],
+ } as any,
+ onReset: {},
+ onShowPrivacy: {},
+ onShowTerms: {},
+ onSelect: {},
+ error: undefined,
+ coinOperationTimeline: {
+ deposit: [],
+ refresh: [],
+ refund: [],
+ withdraw: [],
+ },
+ globalFeeTimeline: [],
+ newWireType: [],
+ missingWireTYpe: [],
+ wireFeeTimeline: {},
+});
+export const ComparingKudos = tests.createExample(ComparingView, {
+ exchanges: {
+ list: { "http://exchange": "http://exchange" },
+ value: "http://exchange",
+ },
+ selected: {
+ currency: "KUDOS",
+ auditors: [],
+ exchangeBaseUrl: "https://exchange.taler.ar",
+ transferFees: {},
+ globalFees: [],
+ } as any,
+ onReset: {},
+ onShowPrivacy: {},
+ onShowTerms: {},
+ onSelect: {},
+ error: undefined,
+ coinOperationTimeline: {
+ deposit: [],
+ refresh: [],
+ refund: [],
+ withdraw: [],
+ },
+ globalFeeTimeline: [],
+ newWireType: [],
+ missingWireTYpe: [],
+ wireFeeTimeline: {},
+});
+
+function timelineExample() {
+ return {
+ deposit: [
+ {
+ group: "0.1",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1916386904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "1",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1916386904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "10",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1916386904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "1000",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1916386904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "2",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1916386904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "5",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1916386904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ ],
+ refresh: [
+ {
+ group: "0.1",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "1",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "10",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "1000",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "2",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "5",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ ],
+ refund: [
+ {
+ group: "0.1",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "1",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "10",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "1000",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "2",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "5",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ ],
+ withdraw: [
+ {
+ group: "0.1",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "1",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "10",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "1000",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "2",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ {
+ group: "5",
+ from: {
+ t_ms: 1664098904000,
+ },
+ until: {
+ t_ms: 1758706904000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ ],
+ wad: [
+ {
+ group: "iban",
+ from: {
+ t_ms: 1640995200000,
+ },
+ until: {
+ t_ms: 1798761600000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ ],
+ wire: [
+ {
+ group: "iban",
+ from: {
+ t_ms: 1640995200000,
+ },
+ until: {
+ t_ms: 1798761600000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ ],
+ closing: [
+ {
+ group: "iban",
+ from: {
+ t_ms: 1640995200000,
+ },
+ until: {
+ t_ms: 1798761600000,
+ },
+ fee: {
+ currency: "KUDOS",
+ fraction: 1000000,
+ value: 0,
+ },
+ },
+ ],
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/test.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/test.ts
new file mode 100644
index 000000000..3c7235851
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/test.ts
@@ -0,0 +1,23 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AbsoluteTime, Amounts, DenominationInfo } from "@gnu-taler/taler-util";
+import { expect } from "chai";
diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx
new file mode 100644
index 000000000..6f67d84b7
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx
@@ -0,0 +1,931 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { FeeDescription, FeeDescriptionPair } from "@gnu-taler/taler-util";
+import { styled } from "@linaria/react";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { Amount } from "../../components/Amount.js";
+import { AlertView } from "../../components/CurrentAlerts.js";
+import { ErrorMessage } from "../../components/ErrorMessage.js";
+import { SelectList } from "../../components/SelectList.js";
+import { Input, SvgIcon } from "../../components/styled/index.js";
+import { TermsOfService } from "../../components/TermsOfService/index.js";
+import { Time } from "../../components/Time.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js";
+import { Button } from "../../mui/Button.js";
+import arrowDown from "../../svg/chevron-down.inline.svg";
+import { State } from "./index.js";
+
+const ButtonGroup = styled.div`
+ & > button {
+ margin-left: 8px;
+ margin-right: 8px;
+ }
+`;
+const ButtonGroupFooter = styled.div`
+ & {
+ display: flex;
+ justify-content: space-between;
+ }
+ & > button {
+ margin-left: 8px;
+ margin-right: 8px;
+ }
+`;
+
+const FeeDescriptionTable = styled.table`
+ & {
+ margin-bottom: 20px;
+ width: 100%;
+ border-collapse: collapse;
+ }
+ td {
+ padding: 8px;
+ }
+ td.fee {
+ text-align: center;
+ }
+ th.fee {
+ text-align: center;
+ }
+ td.value {
+ text-align: right;
+ width: 15%;
+ white-space: nowrap;
+ }
+ td.icon {
+ width: 24px;
+ }
+ td.icon > div {
+ width: 24px;
+ height: 24px;
+ margin: 0px;
+ }
+ td.expiration {
+ text-align: center;
+ }
+
+ tr[data-main="true"] {
+ background-color: #add8e662;
+ }
+ tr[data-main="true"] > td.value,
+ tr[data-main="true"] > td.expiration,
+ tr[data-main="true"] > td.fee {
+ border-bottom: lightgray solid 1px;
+ }
+ tr[data-hidden="true"] {
+ display: none;
+ }
+ tbody > tr.value[data-hasMore="true"],
+ tbody > tr.value[data-hasMore="true"] > td {
+ cursor: pointer;
+ }
+ th {
+ position: sticky;
+ top: 0;
+ background-color: white;
+ }
+`;
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ & > * {
+ margin-bottom: 20px;
+ }
+`;
+
+export function PrivacyContentView({
+ exchangeUrl,
+ onClose,
+}: State.ShowingPrivacy): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div>
+ <Button variant="outlined" onClick={onClose.onClick}>
+ <i18n.Translate>Close</i18n.Translate>
+ </Button>
+ <div>show privacy terms for {exchangeUrl}</div>
+ </div>
+ );
+}
+
+export function TosContentView({
+ exchangeUrl,
+ onClose,
+}: State.ShowingTos): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div>
+ <Button variant="outlined" onClick={onClose.onClick}>
+ <i18n.Translate>Close</i18n.Translate>
+ </Button>
+ <TermsOfService exchangeUrl={exchangeUrl} readOnly >
+ s
+ </TermsOfService>
+ </div>
+ );
+}
+
+export function NoExchangesView({
+ defaultExchange,
+ currency,
+}: SelectExchangeState.NoExchangeFound): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <p>
+ <AlertView
+ alert={{
+ type: "error",
+ message: i18n.str`There is no exchange available for currency ${currency}`,
+ description: i18n.str`You can add more exchanges from the settings.`,
+ cause: undefined,
+ context: undefined,
+ }}
+ />
+ </p>
+ {defaultExchange && (
+ <AlertView
+ alert={{
+ type: "warning",
+ message: i18n.str`Exchange ${defaultExchange} is not available`,
+ description: i18n.str`Exchange status can view accessed from the settings.`,
+ }}
+ />
+ )}
+ </Fragment>
+ );
+}
+
+export function ComparingView({
+ exchanges,
+ selected,
+ onReset,
+ onSelect,
+ coinOperationTimeline,
+ globalFeeTimeline,
+ wireFeeTimeline,
+ missingWireTYpe,
+ newWireType,
+ onShowPrivacy,
+ onShowTerms,
+}: State.Comparing): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Container>
+ <h2>
+ <i18n.Translate>Service fee description</i18n.Translate>
+ </h2>
+
+ <section>
+ <div
+ style={{
+ display: "flex",
+ flexWrap: "wrap",
+ alignItems: "center",
+ justifyContent: "space-between",
+ }}
+ >
+ <p>
+ <Input>
+ <SelectList
+ label={
+ <i18n.Translate>
+ Select {selected.currency} exchange
+ </i18n.Translate>
+ }
+ list={exchanges.list}
+ name="lang"
+ value={exchanges.value}
+ onChange={exchanges.onChange}
+ />
+ </Input>
+ </p>
+ <ButtonGroup>
+ <Button variant="outlined" onClick={onReset.onClick}>
+ <i18n.Translate>Reset</i18n.Translate>
+ </Button>
+ <Button variant="contained" onClick={onSelect.onClick}>
+ <i18n.Translate>Use this exchange</i18n.Translate>
+ </Button>
+ </ButtonGroup>
+ </div>
+ </section>
+ <section>
+ <dl>
+ <dt>
+ <i18n.Translate>Auditors</i18n.Translate>
+ </dt>
+ {selected.auditors.length === 0 ? (
+ <dd style={{ color: "red" }}>
+ <i18n.Translate>Doesn&apos;t have auditors</i18n.Translate>
+ </dd>
+ ) : (
+ selected.auditors.map((a) => {
+ <dd>{a.auditor_url}</dd>;
+ })
+ )}
+ </dl>
+ <table>
+ <tr>
+ <td>
+ <i18n.Translate>currency</i18n.Translate>
+ </td>
+ <td>{selected.currency}</td>
+ </tr>
+ </table>
+ </section>
+ <section>
+ <h2>
+ <i18n.Translate>Coin operations</i18n.Translate>
+ </h2>
+ <p>
+ <i18n.Translate>
+ Every operation in this section may be different by denomination
+ value and is valid for a period of time. The exchange will charge
+ the indicated amount every time a coin is used in such operation.
+ </i18n.Translate>
+ </p>
+ <p>
+ <i18n.Translate>Deposits</i18n.Translate>
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Denomination</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Current</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Selected</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeePairByValue
+ list={coinOperationTimeline.deposit}
+ sorting={(a, b) => Number(a) - Number(b)}
+ />
+ </tbody>
+ </FeeDescriptionTable>
+ <p>
+ <i18n.Translate>Withdrawals</i18n.Translate>
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Denomination</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Current</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Selected</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeePairByValue
+ list={coinOperationTimeline.withdraw}
+ sorting={(a, b) => Number(a) - Number(b)}
+ />
+ </tbody>
+ </FeeDescriptionTable>
+ <p>
+ <i18n.Translate>Refunds</i18n.Translate>
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Denomination</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Current</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Selected</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeePairByValue
+ list={coinOperationTimeline.refund}
+ sorting={(a, b) => Number(a) - Number(b)}
+ />
+ </tbody>
+ </FeeDescriptionTable>{" "}
+ <p>
+ <i18n.Translate>Refresh</i18n.Translate>
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Denomination</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Current</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Selected</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeePairByValue
+ list={coinOperationTimeline.refresh}
+ sorting={(a, b) => Number(a) - Number(b)}
+ />
+ </tbody>
+ </FeeDescriptionTable>{" "}
+ </section>
+ <section>
+ <h2>
+ <i18n.Translate>Transfer operations</i18n.Translate>
+ </h2>
+ <p>
+ <i18n.Translate>
+ Every operation in this section may be different by transfer type
+ and is valid for a period of time. The exchange will charge the
+ indicated amount every time a transfer is made.
+ </i18n.Translate>
+ </p>
+ {missingWireTYpe.map((type) => {
+ return (
+ <p key={type}>
+ Wire <b>{type}</b> is not supported for this exchange.
+ </p>
+ );
+ })}
+ {newWireType.map((type) => {
+ return (
+ <Fragment key={type}>
+ <p>
+ Wire <b>{type}</b> is not supported for the previous exchange.
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Operation</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Fee</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeeDescriptionByValue
+ list={selected.transferFees[type]}
+ />
+ </tbody>
+ </FeeDescriptionTable>
+ </Fragment>
+ );
+ })}
+ {Object.entries(wireFeeTimeline).map(([type, fees], idx) => {
+ return (
+ <Fragment key={idx}>
+ <p>{type}</p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Operation</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Current</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Selected</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeePairByValue
+ list={fees}
+ sorting={(a, b) => a.localeCompare(b)}
+ />
+ </tbody>
+ </FeeDescriptionTable>
+ </Fragment>
+ );
+ })}
+ </section>
+ <section>
+ <h2>
+ <i18n.Translate>Wallet operations</i18n.Translate>
+ </h2>
+ <p>
+ <i18n.Translate>
+ Every operation in this section may be different by transfer type
+ and is valid for a period of time. The exchange will charge the
+ indicated amount every time a transfer is made.
+ </i18n.Translate>
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Feature</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Current</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Selected</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeePairByValue
+ list={globalFeeTimeline}
+ sorting={(a, b) => a.localeCompare(b)}
+ />
+ </tbody>
+ </FeeDescriptionTable>
+ </section>
+ <section>
+ <ButtonGroupFooter>
+ <Button onClick={onShowPrivacy.onClick} variant="outlined">
+ Privacy policy
+ </Button>
+ <Button onClick={onShowTerms.onClick} variant="outlined">
+ Terms of service
+ </Button>
+ </ButtonGroupFooter>
+ </section>
+ <section>
+ <ButtonGroupFooter>
+ <Button onClick={onShowPrivacy.onClick} variant="outlined">
+ Privacy policy
+ </Button>
+ <Button onClick={onShowTerms.onClick} variant="outlined">
+ Terms of service
+ </Button>
+ </ButtonGroupFooter>
+ </section>
+ </Container>
+ );
+}
+
+export function ReadyView({
+ exchanges,
+ selected,
+ onClose,
+ onShowPrivacy,
+ onShowTerms,
+}: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Container>
+ <h2>
+ <i18n.Translate>Service fee description</i18n.Translate>
+ </h2>
+ <p>
+ All fee indicated below are in the same and only currency the exchange
+ works.
+ </p>
+ <section>
+ <div
+ style={{
+ display: "flex",
+ flexWrap: "wrap",
+ alignItems: "center",
+ justifyContent: "space-between",
+ }}
+ >
+ {Object.keys(exchanges.list).length === 1 ? (
+ <Fragment>
+ <p>Exchange: {selected.exchangeBaseUrl}</p>
+ </Fragment>
+ ) : (
+ <p>
+ <Input>
+ <SelectList
+ label={
+ <i18n.Translate>
+ Select {selected.currency} exchange
+ </i18n.Translate>
+ }
+ list={exchanges.list}
+ name="lang"
+ value={exchanges.value}
+ onChange={exchanges.onChange}
+ />
+ </Input>
+ </p>
+ )}
+ <Button variant="outlined" onClick={onClose.onClick}>
+ <i18n.Translate>Close</i18n.Translate>
+ </Button>
+ </div>
+ </section>
+ <section>
+ <dl>
+ <dt>Auditors</dt>
+ {selected.auditors.length === 0 ? (
+ <dd style={{ color: "red" }}>
+ <i18n.Translate>Doesn&apos;t have auditors</i18n.Translate>
+ </dd>
+ ) : (
+ selected.auditors.map((a) => {
+ <dd>{a.auditor_url}</dd>;
+ })
+ )}
+ </dl>
+ <table>
+ <tr>
+ <td>
+ <i18n.Translate>Currency</i18n.Translate>
+ </td>
+ <td>
+ <b>{selected.currency}</b>
+ </td>
+ </tr>
+ </table>
+ </section>
+ <section>
+ <h2>
+ <i18n.Translate>Coin operations</i18n.Translate>
+ </h2>
+ <p>
+ <i18n.Translate>
+ Every operation in this section may be different by denomination
+ value and is valid for a period of time. The exchange will charge
+ the indicated amount every time a coin is used in such operation.
+ </i18n.Translate>
+ </p>
+ <p>
+ <i18n.Translate>Deposits</i18n.Translate>
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Denomination</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Fee</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeeDescriptionByValue
+ list={selected.denomFees.deposit}
+ sorting={(a, b) => Number(a) - Number(b)}
+ />
+ </tbody>
+ </FeeDescriptionTable>
+ <p>
+ <i18n.Translate>Withdrawals</i18n.Translate>
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Denomination</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Fee</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeeDescriptionByValue
+ list={selected.denomFees.withdraw}
+ sorting={(a, b) => Number(a) - Number(b)}
+ />
+ </tbody>
+ </FeeDescriptionTable>
+ <p>
+ <i18n.Translate>Refunds</i18n.Translate>
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Denomination</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Fee</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeeDescriptionByValue
+ list={selected.denomFees.refund}
+ sorting={(a, b) => Number(a) - Number(b)}
+ />
+ </tbody>
+ </FeeDescriptionTable>{" "}
+ <p>
+ <i18n.Translate>Refresh</i18n.Translate>
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Denomination</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Fee</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeeDescriptionByValue
+ list={selected.denomFees.refresh}
+ sorting={(a, b) => Number(a) - Number(b)}
+ />
+ </tbody>
+ </FeeDescriptionTable>
+ </section>
+ <section>
+ <h2>
+ <i18n.Translate>Transfer operations</i18n.Translate>
+ </h2>
+ <p>
+ <i18n.Translate>
+ Every operation in this section may be different by transfer type
+ and is valid for a period of time. The exchange will charge the
+ indicated amount every time a transfer is made.
+ </i18n.Translate>
+ </p>
+ {Object.entries(selected.transferFees).map(([type, fees], idx) => {
+ return (
+ <Fragment key={idx}>
+ <p>{type}</p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Operation</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Fee</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeeDescriptionByValue list={fees} />
+ </tbody>
+ </FeeDescriptionTable>
+ </Fragment>
+ );
+ })}
+ </section>
+ <section>
+ <h2>
+ <i18n.Translate>Wallet operations</i18n.Translate>
+ </h2>
+ <p>
+ <i18n.Translate>
+ Every operation in this section may be different by transfer type
+ and is valid for a period of time. The exchange will charge the
+ indicated amount every time a transfer is made.
+ </i18n.Translate>
+ </p>
+ <FeeDescriptionTable>
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>
+ <i18n.Translate>Feature</i18n.Translate>
+ </th>
+ <th class="fee">
+ <i18n.Translate>Fee</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Until</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <RenderFeeDescriptionByValue list={selected.globalFees} />
+ </tbody>
+ </FeeDescriptionTable>
+ </section>
+ <section>
+ <ButtonGroupFooter>
+ <Button onClick={onShowPrivacy.onClick} variant="outlined">
+ Privacy policy
+ </Button>
+ <Button onClick={onShowTerms.onClick} variant="outlined">
+ Terms of service
+ </Button>
+ </ButtonGroupFooter>
+ </section>
+ </Container>
+ );
+}
+
+function FeeDescriptionRowsGroup({
+ infos,
+}: {
+ infos: FeeDescription[];
+}): VNode {
+ const [expanded, setExpand] = useState(false);
+ const hasMoreInfo = infos.length > 1;
+ return (
+ <Fragment>
+ {infos.map((info, idx) => {
+ const main = idx === 0;
+ return (
+ <tr
+ key={idx}
+ class="value"
+ data-hasMore={hasMoreInfo}
+ data-main={main}
+ data-hidden={!main && !expanded}
+ onClick={() => setExpand((p) => !p)}
+ >
+ <td class="icon">
+ {hasMoreInfo && main ? (
+ <SvgIcon
+ title="Select this contact"
+ dangerouslySetInnerHTML={{ __html: arrowDown }}
+ color="currentColor"
+ transform={expanded ? "" : "rotate(-90deg)"}
+ />
+ ) : undefined}
+ </td>
+ <td class="value">{main ? info.group : ""}</td>
+ {info.fee ? (
+ <td class="fee">{<Amount value={info.fee} hideCurrency />}</td>
+ ) : undefined}
+ <td class="expiration">
+ <Time timestamp={info.until} format="dd-MMM-yyyy" />
+ </td>
+ </tr>
+ );
+ })}
+ </Fragment>
+ );
+}
+
+function FeePairRowsGroup({ infos }: { infos: FeeDescriptionPair[] }): VNode {
+ const [expanded, setExpand] = useState(false);
+ const hasMoreInfo = infos.length > 1;
+ return (
+ <Fragment>
+ {infos.map((info, idx) => {
+ const main = idx === 0;
+ return (
+ <tr
+ key={idx}
+ class="value"
+ data-hasMore={hasMoreInfo}
+ data-main={main}
+ data-hidden={!main && !expanded}
+ onClick={() => setExpand((p) => !p)}
+ >
+ <td class="icon">
+ {hasMoreInfo && main ? (
+ <SvgIcon
+ title="Expand"
+ dangerouslySetInnerHTML={{ __html: arrowDown }}
+ color="currentColor"
+ transform={expanded ? "" : "rotate(-90deg)"}
+ />
+ ) : undefined}
+ </td>
+ <td class="value">{main ? info.group : ""}</td>
+ {info.left ? (
+ <td class="fee">{<Amount value={info.left} hideCurrency />}</td>
+ ) : (
+ <td class="fee"> --- </td>
+ )}
+ {info.right ? (
+ <td class="fee">{<Amount value={info.right} hideCurrency />}</td>
+ ) : (
+ <td class="fee"> --- </td>
+ )}
+ <td class="expiration">
+ <Time timestamp={info.until} format="dd-MMM-yyyy HH:mm:ss" />
+ </td>
+ </tr>
+ );
+ })}
+ </Fragment>
+ );
+}
+
+/**
+ * Group by value and then render using FeePairRowsGroup
+ * @param param0
+ * @returns
+ */
+function RenderFeePairByValue({
+ list,
+ sorting,
+}: {
+ list: FeeDescriptionPair[];
+ sorting?: (a: string, b: string) => number;
+}): VNode {
+ const grouped = list.reduce((prev, cur) => {
+ if (!prev[cur.group]) {
+ prev[cur.group] = [];
+ }
+ prev[cur.group].push(cur);
+ return prev;
+ }, {} as Record<string, FeeDescriptionPair[]>);
+ const p = Object.keys(grouped)
+ .sort(sorting)
+ .map((i, idx) => <FeePairRowsGroup key={idx} infos={grouped[i]} />);
+ return <Fragment>{p}</Fragment>;
+}
+/**
+ *
+ * Group by value and then render using FeeDescriptionRowsGroup
+ * @param param0
+ * @returns
+ */
+function RenderFeeDescriptionByValue({
+ list,
+ sorting,
+}: {
+ list: FeeDescription[];
+ sorting?: (a: string, b: string) => number;
+}): VNode {
+ const grouped = list.reduce((prev, cur) => {
+ if (!prev[cur.group]) {
+ prev[cur.group] = [];
+ }
+ prev[cur.group].push(cur);
+ return prev;
+ }, {} as Record<string, FeeDescription[]>);
+ const p = Object.keys(grouped)
+ .sort(sorting)
+ .map((i, idx) => <FeeDescriptionRowsGroup key={idx} infos={grouped[i]} />);
+ return <Fragment>{p}</Fragment>;
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/History.stories.tsx b/packages/taler-wallet-webextension/src/wallet/History.stories.tsx
index 0ac4be9a6..482b8d698 100644
--- a/packages/taler-wallet-webextension/src/wallet/History.stories.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/History.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,184 +15,608 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
import {
+ AmountString,
PaymentStatus,
- TransactionCommon, TransactionDeposit, TransactionPayment,
- TransactionRefresh, TransactionRefund, TransactionTip, TransactionType,
+ RefreshReason,
+ ScopeType,
+ TalerProtocolTimestamp,
+ TransactionCommon,
+ TransactionDeposit,
+ TransactionMajorState,
+ TransactionPayment,
+ TransactionPeerPullCredit,
+ TransactionPeerPullDebit,
+ TransactionPeerPushCredit,
+ TransactionPeerPushDebit,
+ TransactionRefresh,
+ TransactionRefund,
+ TransactionType,
TransactionWithdrawal,
- WithdrawalType
-} from '@gnu-taler/taler-util';
-import { HistoryView as TestedComponent } from './History';
-import { createExample } from '../test-utils';
-
+ WithdrawalType,
+} from "@gnu-taler/taler-util";
+import { HistoryView as TestedComponent } from "./History.js";
+import * as tests from "@gnu-taler/web-util/testing";
export default {
- title: 'wallet/history/list',
+ title: "history",
component: TestedComponent,
};
-let count = 0
-const commonTransaction = () => ({
- amountRaw: 'USD:10',
- amountEffective: 'USD:9',
- pending: false,
- timestamp: {
- t_ms: new Date().getTime() - (count++ * 1000 * 60 * 60 * 7)
- },
- transactionId: '12',
-} as TransactionCommon)
+let count = 0;
+const commonTransaction = (): TransactionCommon =>
+ ({
+ amountRaw: "USD:10",
+ amountEffective: "USD:9",
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ timestamp: TalerProtocolTimestamp.fromSeconds(
+ new Date().getTime() / 1000 - count++ * 60 * 60 * 7,
+ ),
+ transactionId: String(count),
+ }) as TransactionCommon;
const exampleData = {
withdraw: {
...commonTransaction(),
type: TransactionType.Withdrawal,
- exchangeBaseUrl: 'http://exchange.demo.taler.net',
+ exchangeBaseUrl: "http://exchange.demo.taler.net",
withdrawalDetails: {
+ reservePub: "A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG",
confirmed: false,
- exchangePaytoUris: ['payto://x-taler-bank/bank/account'],
+ exchangePaytoUris: ["payto://x-taler-bank/bank/account"],
type: WithdrawalType.ManualTransfer,
- }
+ reserveIsReady: false,
+ },
} as TransactionWithdrawal,
payment: {
...commonTransaction(),
- amountEffective: 'USD:11',
+ amountEffective: "USD:11" as AmountString,
type: TransactionType.Payment,
+ posConfirmation: undefined,
info: {
- contractTermsHash: 'ASDZXCASD',
+ contractTermsHash: "ASDZXCASD",
merchant: {
- name: 'Blog',
+ name: "Blog",
},
- orderId: '2021.167-03NPY6MCYMVGT',
+ orderId: "2021.167-03NPY6MCYMVGT",
products: [],
- summary: 'the summary',
- fulfillmentMessage: '',
+ summary: "the summary",
+ fulfillmentMessage: "",
},
- proposalId: '1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0',
+ refunds: [],
+ refundPending: undefined,
+ totalRefundEffective: "USD:0" as AmountString,
+ totalRefundRaw: "USD:0" as AmountString,
+ proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0",
status: PaymentStatus.Accepted,
+ refundQueryActive: false,
} as TransactionPayment,
deposit: {
...commonTransaction(),
type: TransactionType.Deposit,
- depositGroupId: '#groupId',
- targetPaytoUri: 'payto://x-taler-bank/bank/account',
+ depositGroupId: "#groupId",
+ targetPaytoUri: "payto://x-taler-bank/bank/account",
} as TransactionDeposit,
refresh: {
...commonTransaction(),
type: TransactionType.Refresh,
- exchangeBaseUrl: 'http://exchange.taler',
+ refreshInputAmount: "USD:1" as AmountString,
+ refreshOutputAmount: "USD:0.5" as AmountString,
+ exchangeBaseUrl: "http://exchange.taler",
+ refreshReason: RefreshReason.PayMerchant,
} as TransactionRefresh,
- tip: {
- ...commonTransaction(),
- type: TransactionType.Tip,
- merchantBaseUrl: 'http://ads.merchant.taler.net/',
- } as TransactionTip,
refund: {
...commonTransaction(),
type: TransactionType.Refund,
- refundedTransactionId: 'payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0',
- info: {
- contractTermsHash: 'ASDZXCASD',
+ refundedTransactionId:
+ "payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0",
+ paymentInfo: {
merchant: {
- name: 'the merchant',
+ name: "the merchant",
},
- orderId: '2021.167-03NPY6MCYMVGT',
- products: [],
- summary: 'the summary',
- fulfillmentMessage: '',
+ summary: "the summary",
},
+ refundPending: undefined,
} as TransactionRefund,
-}
-
-export const Empty = createExample(TestedComponent, {
- list: [],
- balances: [{
- available: 'TESTKUDOS:10',
- pendingIncoming: 'TESTKUDOS:0',
- pendingOutgoing: 'TESTKUDOS:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }]
-});
+ push_credit: {
+ ...commonTransaction(),
+ type: TransactionType.PeerPushCredit,
+ info: {
+ summary: "take this cash",
+ },
+ exchangeBaseUrl: "https://exchange.taler.net",
+ } as TransactionPeerPushCredit,
+ push_debit: {
+ ...commonTransaction(),
+ type: TransactionType.PeerPushDebit,
+ talerUri:
+ "taler://pay-push/exchange.taler.ar/HS585JK0QCXHJ8Z8QWZA3EBAY5WY7XNC1RR2MHJXSH2Z4WP0YPJ0",
+ info: {
+ summary: "take this cash",
+ },
+ exchangeBaseUrl: "https://exchange.taler.net",
+ } as TransactionPeerPushDebit,
+ pull_credit: {
+ ...commonTransaction(),
+ type: TransactionType.PeerPullCredit,
+ talerUri:
+ "taler://pay-push/exchange.taler.ar/HS585JK0QCXHJ8Z8QWZA3EBAY5WY7XNC1RR2MHJXSH2Z4WP0YPJ0",
+ info: {
+ summary: "pay me",
+ },
+ exchangeBaseUrl: "https://exchange.taler.net",
+ } as TransactionPeerPullCredit,
+ pull_debit: {
+ ...commonTransaction(),
+ type: TransactionType.PeerPullDebit,
+ info: {
+ summary: "pay me",
+ },
+ exchangeBaseUrl: "https://exchange.taler.net",
+ } as TransactionPeerPullDebit,
+};
+export const SomeBalanceWithNoTransactions = tests.createExample(
+ TestedComponent,
+ {
+ transactionsByDate: {
+ "11/11/11": [],
+ },
+ balances: [
+ {
+ available: "TESTKUDOS:10" as AmountString,
+ flags: [],
+ pendingIncoming: "TESTKUDOS:0" as AmountString,
+ pendingOutgoing: "TESTKUDOS:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ balanceIndex: 0,
+ },
+);
-export const One = createExample(TestedComponent, {
- list: [exampleData.withdraw],
- balances: [{
- available: 'USD:10',
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }]
+export const OneSimpleTransaction = tests.createExample(TestedComponent, {
+ transactionsByDate: {
+ "11/11/11": [exampleData.withdraw],
+ },
+ balances: [
+ {
+ flags: [],
+ available: "USD:10" as AmountString,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ balanceIndex: 0,
});
-export const OnePending = createExample(TestedComponent, {
- list: [{
- ...exampleData.withdraw,
- pending: true
- }],
- balances: [{
- available: 'USD:10',
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }]
+export const TwoTransactionsAndZeroBalance = tests.createExample(
+ TestedComponent,
+ {
+ transactionsByDate: {
+ "11/11/11": [exampleData.withdraw, exampleData.deposit],
+ },
+ balances: [
+ {
+ flags: [],
+ available: "USD:0" as AmountString,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ balanceIndex: 0,
+ },
+);
+
+export const OneTransactionPending = tests.createExample(TestedComponent, {
+ transactionsByDate: {
+ "11/11/11": [
+ {
+ ...exampleData.withdraw,
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
+ ],
+ },
+ balances: [
+ {
+ flags: [],
+ available: "USD:10" as AmountString,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ balanceIndex: 0,
});
-export const Several = createExample(TestedComponent, {
- list: [
- exampleData.withdraw,
- exampleData.payment,
- exampleData.withdraw,
- exampleData.payment,
+export const SomeTransactions = tests.createExample(TestedComponent, {
+ transactionsByDate: {
+ "11/11/11": [
+ exampleData.withdraw,
+ exampleData.payment,
+ exampleData.withdraw,
+ exampleData.payment,
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary:
+ "this is a long summary that may be cropped because its too long",
+ },
+ },
+ exampleData.refund,
+ exampleData.deposit,
+ ],
+ },
+ balances: [
{
- ...exampleData.payment,
- info: {
- ...exampleData.payment.info,
- summary: 'this is a long summary that may be cropped because its too long',
+ flags: [],
+ available: "USD:10" as AmountString,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
},
},
- exampleData.refund,
- exampleData.tip,
- exampleData.deposit,
],
- balances: [{
- available: 'TESTKUDOS:10',
- pendingIncoming: 'TESTKUDOS:0',
- pendingOutgoing: 'TESTKUDOS:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }]
+ balanceIndex: 0,
});
-export const SeveralWithTwoCurrencies = createExample(TestedComponent, {
- list: [
- exampleData.withdraw,
- exampleData.payment,
- exampleData.withdraw,
- exampleData.payment,
- exampleData.refresh,
- exampleData.refund,
- exampleData.tip,
- exampleData.deposit,
+export const SomeTransactionsInDifferentStates = tests.createExample(
+ TestedComponent,
+ {
+ transactionsByDate: {
+ "11/11/11": [
+ exampleData.withdraw,
+ {
+ ...exampleData.withdraw,
+ exchangeBaseUrl: "https://aborted/withdrawal",
+ txState: {
+ major: TransactionMajorState.Aborted,
+ },
+ },
+ {
+ ...exampleData.withdraw,
+ exchangeBaseUrl: "https://pending/withdrawal",
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
+ {
+ ...exampleData.withdraw,
+ exchangeBaseUrl: "https://failed/withdrawal",
+ txState: {
+ major: TransactionMajorState.Failed,
+ },
+ },
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "normal payment",
+ },
+ },
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "aborting in progress",
+ },
+ txState: {
+ major: TransactionMajorState.Aborting,
+ },
+ },
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "aborted payment",
+ },
+ txState: {
+ major: TransactionMajorState.Aborted,
+ },
+ },
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "pending payment",
+ },
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
+ {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "failed payment",
+ },
+ txState: {
+ major: TransactionMajorState.Failed,
+ },
+ },
+ exampleData.refund,
+ exampleData.deposit,
+ ],
+ },
+ balances: [
+ {
+ flags: [],
+ available: "USD:10" as AmountString,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ balanceIndex: 0,
+ },
+);
+
+export const SomeTransactionsWithTwoCurrencies = tests.createExample(
+ TestedComponent,
+ {
+ transactionsByDate: {
+ "11/11/11": [
+ exampleData.withdraw,
+ exampleData.payment,
+ exampleData.withdraw,
+ exampleData.payment,
+ exampleData.refresh,
+ exampleData.refund,
+ exampleData.deposit,
+ ],
+ },
+ balances: [
+ {
+ flags: [],
+ available: "USD:0" as AmountString,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ {
+ flags: [],
+ available: "TESTKUDOS:10" as AmountString,
+ pendingIncoming: "TESTKUDOS:0" as AmountString,
+ pendingOutgoing: "TESTKUDOS:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ balanceIndex: 0,
+ },
+);
+
+export const FiveOfficialCurrencies = tests.createExample(TestedComponent, {
+ transactionsByDate: {
+ "11/11/11": [exampleData.withdraw],
+ },
+ balances: [
+ {
+ flags: [],
+ available: "USD:1000" as AmountString,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ {
+ flags: [],
+ available: "EUR:881" as AmountString,
+ pendingIncoming: "TESTKUDOS:0" as AmountString,
+ pendingOutgoing: "TESTKUDOS:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ {
+ flags: [],
+ available: "COL:4043000.5" as AmountString,
+ pendingIncoming: "TESTKUDOS:0" as AmountString,
+ pendingOutgoing: "TESTKUDOS:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ {
+ flags: [],
+ available: "JPY:11564450.6" as AmountString,
+ pendingIncoming: "TESTKUDOS:0" as AmountString,
+ pendingOutgoing: "TESTKUDOS:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ {
+ flags: [],
+ available: "GBP:736" as AmountString,
+ pendingIncoming: "TESTKUDOS:0" as AmountString,
+ pendingOutgoing: "TESTKUDOS:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
],
- balances: [{
- available: 'TESTKUDOS:10',
- pendingIncoming: 'TESTKUDOS:0',
- pendingOutgoing: 'TESTKUDOS:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }, {
- available: 'USD:10',
- pendingIncoming: 'USD:0',
- pendingOutgoing: 'USD:0',
- hasPendingTransactions: false,
- requiresUserInput: false,
- }]
+ balanceIndex: 0,
});
+export const FiveOfficialCurrenciesWithHighValue = tests.createExample(
+ TestedComponent,
+ {
+ transactionsByDate: {
+ "11/11/11": [exampleData.withdraw],
+ },
+ balances: [
+ {
+ flags: [],
+ available: "USD:881001321230000" as AmountString,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ {
+ flags: [],
+ available: "EUR:10" as AmountString,
+ pendingIncoming: "TESTKUDOS:0" as AmountString,
+ pendingOutgoing: "TESTKUDOS:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ {
+ flags: [],
+ available: "COL:443000123123000.5123123" as AmountString,
+ pendingIncoming: "TESTKUDOS:0" as AmountString,
+ pendingOutgoing: "TESTKUDOS:0" as AmountString,
+ hasPendingTransactions: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ requiresUserInput: false,
+ },
+ {
+ flags: [],
+ available: "JPY:1564450000000.6123123" as AmountString,
+ pendingIncoming: "TESTKUDOS:0" as AmountString,
+ pendingOutgoing: "TESTKUDOS:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ {
+ flags: [],
+ available: "GBP:736001231231200.23123" as AmountString,
+ pendingIncoming: "TESTKUDOS:0" as AmountString,
+ pendingOutgoing: "TESTKUDOS:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ balanceIndex: 0,
+ },
+);
+
+export const PeerToPeer = tests.createExample(TestedComponent, {
+ transactionsByDate: {
+ "11/11/11": [
+ exampleData.pull_credit,
+ exampleData.pull_debit,
+ exampleData.push_credit,
+ exampleData.push_debit,
+ ],
+ },
+ balances: [
+ {
+ flags: [],
+ available: "USD:10" as AmountString,
+ pendingIncoming: "USD:0" as AmountString,
+ pendingOutgoing: "USD:0" as AmountString,
+ hasPendingTransactions: false,
+ requiresUserInput: false,
+ scopeInfo: {
+ currency: "Ásd",
+ type: ScopeType.Auditor,
+ url: "",
+ },
+ },
+ ],
+ balanceIndex: 0,
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/History.tsx b/packages/taler-wallet-webextension/src/wallet/History.tsx
index 8160f8574..f81e6db9f 100644
--- a/packages/taler-wallet-webextension/src/wallet/History.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/History.tsx
@@ -1,87 +1,371 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { AmountString, Balance, Transaction, TransactionsResponse } from "@gnu-taler/taler-util";
-import { format } from "date-fns";
-import { Fragment, h, JSX } from "preact";
+import {
+ AbsoluteTime,
+ Amounts,
+ NotificationType,
+ ScopeType,
+ Transaction,
+ WalletBalance,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { startOfDay } from "date-fns";
+import { Fragment, VNode, h } from "preact";
import { useEffect, useState } from "preact/hooks";
-import { DateSeparator, WalletBox } from "../components/styled";
-import { TransactionItem } from "../components/TransactionItem";
-import { useBalances } from "../hooks/useBalances";
-import * as wxApi from "../wxApi";
+import { ErrorAlertView } from "../components/CurrentAlerts.js";
+import { HistoryItem } from "../components/HistoryItem.js";
+import { Loading } from "../components/Loading.js";
+import { Time } from "../components/Time.js";
+import {
+ CenteredBoldText,
+ CenteredText,
+ DateSeparator,
+ NiceSelect,
+} from "../components/styled/index.js";
+import { alertFromError, useAlertContext } from "../context/alert.js";
+import { useBackendContext } from "../context/backend.js";
+import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { useSettings } from "../hooks/useSettings.js";
+import { Button } from "../mui/Button.js";
+import { NoBalanceHelp } from "../popup/NoBalanceHelp.js";
+import DownloadIcon from "../svg/download_24px.inline.svg";
+import UploadIcon from "../svg/upload_24px.inline.svg";
+import { TextField } from "../mui/TextField.js";
+import { TextFieldHandler } from "../mui/handlers.js";
+interface Props {
+ currency?: string;
+ search?: boolean;
+ goToWalletDeposit: (currency: string) => Promise<void>;
+ goToWalletManualWithdraw: (currency?: string) => Promise<void>;
+}
+export function HistoryPage({
+ currency: _c,
+ search: showSearch,
+ goToWalletManualWithdraw,
+ goToWalletDeposit,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const api = useBackendContext();
+ const [balanceIndex, setBalanceIndex] = useState<number>(0);
+ const [search, setSearch] = useState<string>();
-export function HistoryPage(props: any): JSX.Element {
- const [transactions, setTransactions] = useState<
- TransactionsResponse | undefined
- >(undefined);
- const balance = useBalances()
- const balanceWithoutError = balance?.hasError ? [] : (balance?.response.balances || [])
+ const [settings] = useSettings();
+ const state = useAsyncAsHook(async () => {
+ const b = await api.wallet.call(WalletApiOperation.GetBalances, {});
+ const balance =
+ b.balances.length > 0 ? b.balances[balanceIndex] : undefined;
+ const tx = await api.wallet.call(WalletApiOperation.GetTransactions, {
+ scopeInfo: showSearch ? undefined : balance?.scopeInfo,
+ sort: "descending",
+ includeRefreshes: settings.showRefeshTransactions,
+ search,
+ });
+ return { b, tx };
+ }, [balanceIndex, search]);
useEffect(() => {
- const fetchData = async (): Promise<void> => {
- const res = await wxApi.getTransactions();
- setTransactions(res);
- };
- fetchData();
- }, []);
-
- if (!transactions) {
- return <div>Loading ...</div>;
+ return api.listener.onUpdateNotification(
+ [NotificationType.TransactionStateTransition],
+ state?.retry,
+ );
+ });
+ const { pushAlertOnError } = useAlertContext();
+
+ if (!state) {
+ return <Loading />;
+ }
+
+ if (state.hasError) {
+ return (
+ <ErrorAlertView
+ error={alertFromError(
+ i18n,
+ i18n.str`Could not load the list of transactions`,
+ state,
+ )}
+ />
+ );
+ }
+
+ if (!state.response.b.balances.length) {
+ return (
+ <NoBalanceHelp
+ goToWalletManualWithdraw={{
+ onClick: pushAlertOnError(goToWalletManualWithdraw),
+ }}
+ />
+ );
+ }
+
+ const byDate = state.response.tx.transactions.reduce(
+ (rv, x) => {
+ const startDay =
+ x.timestamp.t_s === "never"
+ ? 0
+ : startOfDay(x.timestamp.t_s * 1000).getTime();
+ if (startDay) {
+ if (!rv[startDay]) {
+ rv[startDay] = [];
+ // datesWithTransaction.push(String(startDay));
+ }
+ rv[startDay].push(x);
+ }
+
+ return rv;
+ },
+ {} as { [x: string]: Transaction[] },
+ );
+
+ if (showSearch) {
+ return (
+ <FilteredHistoryView
+ search={{
+ value: search ?? "",
+ onInput: pushAlertOnError(async (d: string) => {
+ setSearch(d);
+ }),
+ }}
+ transactionsByDate={byDate}
+ />
+ );
}
- return <HistoryView balances={balanceWithoutError} list={[...transactions.transactions].reverse()} />;
+ return (
+ <HistoryView
+ balanceIndex={balanceIndex}
+ changeBalanceIndex={(b) => setBalanceIndex(b)}
+ balances={state.response.b.balances}
+ goToWalletManualWithdraw={goToWalletManualWithdraw}
+ goToWalletDeposit={goToWalletDeposit}
+ transactionsByDate={byDate}
+ />
+ );
}
-function amountToString(c: AmountString) {
- const idx = c.indexOf(':')
- return `${c.substring(idx + 1)} ${c.substring(0, idx)}`
+export function HistoryView({
+ balances,
+ balanceIndex,
+ changeBalanceIndex,
+ transactionsByDate,
+ goToWalletManualWithdraw,
+ goToWalletDeposit,
+}: {
+ balanceIndex: number;
+ changeBalanceIndex: (s: number) => void;
+ goToWalletDeposit: (currency: string) => Promise<void>;
+ goToWalletManualWithdraw: (currency?: string) => Promise<void>;
+ transactionsByDate: Record<string, Transaction[]>;
+ balances: WalletBalance[];
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ const balance = balances[balanceIndex];
+
+ const available = balance
+ ? Amounts.jsonifyAmount(balance.available)
+ : undefined;
+
+ const datesWithTransaction: string[] = Object.keys(transactionsByDate);
+
+ return (
+ <Fragment>
+ <section>
+ <div
+ style={{
+ display: "flex",
+ flexWrap: "wrap",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginRight: 20,
+ }}
+ >
+ <div>
+ <Button
+ tooltip="Transfer money to the wallet"
+ startIcon={DownloadIcon}
+ variant="contained"
+ onClick={() =>
+ goToWalletManualWithdraw(balance.scopeInfo.currency)
+ }
+ >
+ <i18n.Translate>Receive</i18n.Translate>
+ </Button>
+ {available && Amounts.isNonZero(available) && (
+ <Button
+ tooltip="Transfer money from the wallet"
+ startIcon={UploadIcon}
+ variant="outlined"
+ color="primary"
+ onClick={() => goToWalletDeposit(balance.scopeInfo.currency)}
+ >
+ <i18n.Translate>Send</i18n.Translate>
+ </Button>
+ )}
+ </div>
+ <div style={{ display: "flex", flexDirection: "column" }}>
+ <h3 style={{ marginBottom: 0 }}>Balance</h3>
+ <div
+ style={{
+ width: "fit-content",
+ display: "flex",
+ }}
+ >
+ {balances.length === 1 ? (
+ <CenteredText style={{ fontSize: "x-large", margin: 8 }}>
+ {balance.scopeInfo.currency}
+ </CenteredText>
+ ) : (
+ <NiceSelect style={{ flexDirection: "column" }}>
+ <select
+ style={{
+ fontSize: "x-large",
+ }}
+ value={balanceIndex}
+ onChange={(e) => {
+ changeBalanceIndex(
+ Number.parseInt(e.currentTarget.value, 10),
+ );
+ }}
+ >
+ {balances.map((entry, index) => {
+ return (
+ <option value={index} key={entry.scopeInfo.currency}>
+ {entry.scopeInfo.currency}
+ </option>
+ );
+ })}
+ </select>
+ <div style={{ fontSize: "small", color: "grey" }}>
+ {balance.scopeInfo.type === ScopeType.Exchange ||
+ balance.scopeInfo.type === ScopeType.Auditor
+ ? balance.scopeInfo.url
+ : undefined}
+ </div>
+ </NiceSelect>
+ )}
+ {available && (
+ <CenteredBoldText
+ style={{
+ display: "inline-block",
+ fontSize: "x-large",
+ margin: 8,
+ }}
+ >
+ {Amounts.stringifyValue(available, 2)}
+ </CenteredBoldText>
+ )}
+ </div>
+ </div>
+ </div>
+ </section>
+ {datesWithTransaction.length === 0 ? (
+ <section>
+ <i18n.Translate>
+ Your transaction history is empty for this currency.
+ </i18n.Translate>
+ </section>
+ ) : (
+ <section>
+ {datesWithTransaction.map((d, i) => {
+ return (
+ <Fragment key={i}>
+ <DateSeparator>
+ <Time
+ timestamp={AbsoluteTime.fromMilliseconds(
+ Number.parseInt(d, 10),
+ )}
+ format="dd MMMM yyyy"
+ />
+ </DateSeparator>
+ {transactionsByDate[d].map((tx, i) => (
+ <HistoryItem key={i} tx={tx} />
+ ))}
+ </Fragment>
+ );
+ })}
+ </section>
+ )}
+ </Fragment>
+ );
}
+export function FilteredHistoryView({
+ search,
+ transactionsByDate,
+}: {
+ search: TextFieldHandler;
+ transactionsByDate: Record<string, Transaction[]>;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const datesWithTransaction: string[] = Object.keys(transactionsByDate);
-export function HistoryView({ list, balances }: { list: Transaction[], balances: Balance[] }) {
- const byDate = list.reduce(function (rv, x) {
- const theDate = x.timestamp.t_ms === "never" ? "never" : format(x.timestamp.t_ms, 'dd MMMM yyyy');
- (rv[theDate] = rv[theDate] || []).push(x);
- return rv;
- }, {} as { [x: string]: Transaction[] });
-
- const multiCurrency = balances.length > 1
-
- return <WalletBox noPadding>
- {balances.length > 0 && <header>
- {balances.length === 1 && <div class="title">
- Balance: <span>{amountToString(balances[0].available)}</span>
- </div>}
- {balances.length > 1 && <div class="title">
- Balance: <ul style={{ margin: 0 }}>
- {balances.map(b => <li>{b.available}</li>)}
- </ul>
- </div>}
- </header>}
- <section>
- {Object.keys(byDate).map((d,i) => {
- return <Fragment key={i}>
- <DateSeparator>{d}</DateSeparator>
- {byDate[d].map((tx, i) => (
- <TransactionItem key={i} tx={tx} multiCurrency={multiCurrency}/>
- ))}
- </Fragment>
- })}
- </section>
- </WalletBox>
+ return (
+ <Fragment>
+ <section>
+ <div
+ style={{
+ display: "flex",
+ flexWrap: "wrap",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginRight: 20,
+ }}
+ >
+ <TextField
+ label="Search"
+ variant="filled"
+ error={search.error}
+ required
+ fullWidth
+ value={search.value}
+ onChange={search.onInput}
+ />
+ </div>
+ </section>
+ {datesWithTransaction.length === 0 ? (
+ <section>
+ <i18n.Translate>
+ Your transaction history is empty for this currency.
+ </i18n.Translate>
+ </section>
+ ) : (
+ <section>
+ {datesWithTransaction.map((d, i) => {
+ return (
+ <Fragment key={i}>
+ <DateSeparator>
+ <Time
+ timestamp={AbsoluteTime.fromMilliseconds(
+ Number.parseInt(d, 10),
+ )}
+ format="dd MMMM yyyy"
+ />
+ </DateSeparator>
+ {transactionsByDate[d].map((tx, i) => (
+ <HistoryItem key={i} tx={tx} />
+ ))}
+ </Fragment>
+ );
+ })}
+ </section>
+ )}
+ </Fragment>
+ );
}
diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/index.ts b/packages/taler-wallet-webextension/src/wallet/ManageAccount/index.ts
new file mode 100644
index 000000000..3a00d48ce
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/ManageAccount/index.ts
@@ -0,0 +1,80 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { KnownBankAccountsInfo } from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import {
+ ButtonHandler,
+ SelectFieldHandler,
+ TextFieldHandler,
+} from "../../mui/handlers.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+
+export interface Props {
+ currency: string;
+ onAccountAdded: (uri: string) => void;
+ onCancel: () => void;
+}
+
+export type State = State.Loading | State.LoadingUriError | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ currency: string;
+ accountType: SelectFieldHandler;
+ uri: TextFieldHandler;
+ alias: TextFieldHandler;
+ onAccountAdded: ButtonHandler;
+ onCancel: ButtonHandler;
+ accountByType: AccountByType;
+ deleteAccount: (a: KnownBankAccountsInfo) => Promise<void>;
+ }
+}
+
+export type AccountByType = {
+ [key: string]: KnownBankAccountsInfo[];
+};
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ ready: ReadyView,
+};
+
+export const ManageAccountPage = compose(
+ "ManageAccountPage",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts b/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts
new file mode 100644
index 000000000..a7b2fe90f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts
@@ -0,0 +1,145 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ KnownBankAccountsInfo,
+ parsePaytoUri,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useState } from "preact/hooks";
+import { alertFromError, useAlertContext } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { AccountByType, Props, State } from "./index.js";
+import { useSettings } from "../../hooks/useSettings.js";
+
+export function useComponentState({
+ currency,
+ onAccountAdded,
+ onCancel,
+}: Props): State {
+ const api = useBackendContext();
+ const { pushAlertOnError } = useAlertContext();
+ const { i18n } = useTranslationContext();
+ const hook = useAsyncAsHook(() =>
+ api.wallet.call(WalletApiOperation.ListKnownBankAccounts, { currency }),
+ );
+ const accountType: Record<string, string> = {
+ iban: "IBAN",
+ };
+ const [settings] = useSettings();
+ if (settings.extendedAccountTypes) {
+ accountType["bitcoin"] = "Bitcoin";
+ accountType["x-taler-bank"] = "Taler Bank";
+ }
+
+ const [payto, setPayto] = useState("");
+ const [alias, setAlias] = useState("");
+ const [type, setType] = useState("iban");
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load known bank accounts`,
+ hook),
+ };
+ }
+
+ const uri = parsePaytoUri(payto);
+ const found =
+ hook.response.accounts.findIndex(
+ (a) => stringifyPaytoUri(a.uri) === payto,
+ ) !== -1;
+
+ async function addAccount(): Promise<void> {
+ if (!uri || found) return;
+
+ const normalizedPayto = stringifyPaytoUri(uri);
+ await api.wallet.call(WalletApiOperation.AddKnownBankAccounts, {
+ alias,
+ currency,
+ payto: normalizedPayto,
+ });
+ onAccountAdded(payto);
+ }
+
+ const paytoUriError = found ? "that account is already present" : undefined;
+
+ const unableToAdd =
+ !type || !alias || paytoUriError !== undefined || uri === undefined;
+
+ const accountByType: AccountByType = {
+ iban: [],
+ bitcoin: [],
+ "x-taler-bank": [],
+ };
+
+ hook.response.accounts.forEach((acc) => {
+ accountByType[acc.uri.targetType].push(acc);
+ });
+
+ async function deleteAccount(account: KnownBankAccountsInfo): Promise<void> {
+ const payto = stringifyPaytoUri(account.uri);
+ await api.wallet.call(WalletApiOperation.ForgetKnownBankAccounts, {
+ payto,
+ });
+ hook?.retry();
+ }
+
+ return {
+ status: "ready",
+ error: undefined,
+ currency,
+ accountType: {
+ list: accountType,
+ value: type,
+ onChange: pushAlertOnError(async (v) => {
+ setType(v);
+ }),
+ },
+ alias: {
+ value: alias,
+ onInput: pushAlertOnError(async (v) => {
+ setAlias(v);
+ }),
+ },
+ uri: {
+ value: payto,
+ error: paytoUriError,
+ onInput: pushAlertOnError(async (v) => {
+ setPayto(v);
+ }),
+ },
+ accountByType,
+ deleteAccount: pushAlertOnError(deleteAccount),
+ onAccountAdded: {
+ onClick: unableToAdd ? undefined : pushAlertOnError(addAccount),
+ },
+ onCancel: {
+ onClick: pushAlertOnError(async () => onCancel()),
+ },
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/stories.tsx b/packages/taler-wallet-webextension/src/wallet/ManageAccount/stories.tsx
new file mode 100644
index 000000000..c01797e31
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/ManageAccount/stories.tsx
@@ -0,0 +1,207 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { nullFunction } from "../../mui/handlers.js";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "manage account",
+};
+
+export const JustTwoBitcoinAccounts = tests.createExample(ReadyView, {
+ status: "ready",
+ currency: "ARS",
+ accountType: {
+ list: {
+ iban: "IBAN",
+ bitcoin: "Bitcoin",
+ "x-taler-bank": "Taler Bank",
+ },
+ value: "bitcoin",
+ },
+ alias: {
+ value: "",
+ onInput: nullFunction,
+ },
+ uri: {
+ value: "",
+ onInput: nullFunction,
+ },
+ accountByType: {
+ iban: [],
+ "x-taler-bank": [],
+ bitcoin: [
+ {
+ alias: "my bitcoin addr",
+ currency: "BTC",
+ kyc_completed: false,
+ uri: {
+ targetType: "bitcoin",
+ segwitAddrs: [],
+ isKnown: true,
+ address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
+ targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
+ params: {},
+ },
+ },
+ {
+ alias: "my other addr",
+ currency: "BTC",
+ kyc_completed: true,
+ uri: {
+ targetType: "bitcoin",
+ segwitAddrs: [],
+ address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
+ isKnown: true,
+ targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
+ params: {},
+ },
+ },
+ ],
+ },
+ onAccountAdded: {},
+ onCancel: {},
+});
+
+export const WithAllTypeOfAccounts = tests.createExample(ReadyView, {
+ status: "ready",
+ currency: "ARS",
+ accountType: {
+ list: {
+ iban: "IBAN",
+ bitcoin: "Bitcoin",
+ "x-taler-bank": "Taler Bank",
+ },
+ value: "x-taler-bank",
+ },
+ alias: {
+ value: "",
+ onInput: nullFunction,
+ },
+ uri: {
+ value: "",
+ onInput: nullFunction,
+ },
+ accountByType: {
+ iban: [
+ {
+ alias: "my bank",
+ currency: "ARS",
+ kyc_completed: true,
+ uri: {
+ targetType: "iban",
+ iban: "ASDQWEQWE",
+ isKnown: true,
+ targetPath: "/ASDQWEQWE",
+ params: {},
+ },
+ },
+ ],
+ "x-taler-bank": [
+ {
+ alias: "my xtaler bank",
+ currency: "ARS",
+ kyc_completed: true,
+ uri: {
+ targetType: "x-taler-bank",
+ host: "localhost",
+ account: "123",
+ isKnown: true,
+ targetPath: "localhost/123",
+ params: {},
+ },
+ },
+ ],
+ bitcoin: [
+ {
+ alias: "my bitcoin addr",
+ currency: "BTC",
+ kyc_completed: false,
+ uri: {
+ targetType: "bitcoin",
+ segwitAddrs: [],
+ isKnown: true,
+ address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
+ targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
+ params: {},
+ },
+ },
+ {
+ alias: "my other addr",
+ currency: "BTC",
+ kyc_completed: true,
+ uri: {
+ targetType: "bitcoin",
+ segwitAddrs: [],
+ isKnown: true,
+ address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
+ targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
+ params: {},
+ },
+ },
+ ],
+ },
+ onAccountAdded: {},
+ onCancel: {},
+});
+
+export const AddingIbanAccount = tests.createExample(ReadyView, {
+ status: "ready",
+ currency: "ARS",
+ accountType: {
+ list: {
+ iban: "IBAN",
+ // bitcoin: "Bitcoin",
+ // "x-taler-bank": "Taler Bank",
+ },
+ value: "iban",
+ },
+ alias: {
+ value: "",
+ onInput: nullFunction,
+ },
+ uri: {
+ value: "",
+ onInput: nullFunction,
+ },
+ accountByType: {
+ iban: [
+ {
+ alias: "my bank",
+ currency: "ARS",
+ kyc_completed: true,
+ uri: {
+ targetType: "iban",
+ iban: "ASDQWEQWE",
+ bic: "SANDBOX",
+ isKnown: true,
+ targetPath: "SANDBOX/ASDQWEQWE",
+ params: {},
+ },
+ },
+ ],
+ "x-taler-bank": [],
+ bitcoin: [],
+ },
+ onAccountAdded: {},
+ onCancel: {},
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/test.ts b/packages/taler-wallet-webextension/src/wallet/ManageAccount/test.ts
new file mode 100644
index 000000000..868269ec0
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/ManageAccount/test.ts
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+
+describe("Manage Account states", () => {
+ it.skip("should create some tests", () => {
+ expect([]).deep.equals([]);
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx b/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx
new file mode 100644
index 000000000..7b80977f3
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx
@@ -0,0 +1,602 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ buildPayto,
+ KnownBankAccountsInfo,
+ PaytoUriBitcoin,
+ PaytoUriIBAN,
+ PaytoUriTalerBank,
+ stringifyPaytoUri,
+ validateIban,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { styled } from "@linaria/react";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorMessage } from "../../components/ErrorMessage.js";
+import { SubTitle, SvgIcon } from "../../components/styled/index.js";
+import { Button } from "../../mui/Button.js";
+import { TextFieldHandler } from "../../mui/handlers.js";
+import { TextField } from "../../mui/TextField.js";
+import checkIcon from "../../svg/check_24px.inline.svg";
+import deleteIcon from "../../svg/delete_24px.inline.svg";
+import warningIcon from "../../svg/warning_24px.inline.svg";
+import { State } from "./index.js";
+
+type AccountType = "bitcoin" | "x-taler-bank" | "iban";
+type ComponentFormByAccountType = {
+ [type in AccountType]: (props: { field: TextFieldHandler }) => VNode;
+};
+
+type ComponentListByAccountType = {
+ [type in AccountType]: (props: {
+ list: KnownBankAccountsInfo[];
+ onDelete: (a: KnownBankAccountsInfo) => Promise<void>;
+ }) => VNode;
+};
+
+const formComponentByAccountType: ComponentFormByAccountType = {
+ iban: IbanAddressAccount,
+ bitcoin: BitcoinAddressAccount,
+ "x-taler-bank": TalerBankAddressAccount,
+};
+const tableComponentByAccountType: ComponentListByAccountType = {
+ iban: IbanTable,
+ bitcoin: BitcoinTable,
+ "x-taler-bank": TalerBankTable,
+};
+
+const AccountTable = styled.table`
+ width: 100%;
+
+ border-collapse: separate;
+ border-spacing: 0px 10px;
+ tbody tr:nth-child(odd) > td:not(.actions, .kyc) {
+ background-color: lightgrey;
+ }
+ .actions,
+ .kyc {
+ width: 10px;
+ background-color: inherit;
+ }
+`;
+
+export function ReadyView({
+ currency,
+ error,
+ accountType,
+ accountByType,
+ alias,
+ onAccountAdded,
+ deleteAccount,
+ onCancel,
+ uri,
+}: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <Fragment>
+ <section>
+ <SubTitle>
+ <i18n.Translate>Known accounts for {currency}</i18n.Translate>
+ </SubTitle>
+ <p>
+ <i18n.Translate>
+ To add a new account first select the account type.
+ </i18n.Translate>
+ </p>
+
+ {error && (
+ <ErrorMessage
+ title={i18n.str`Unable add this account`}
+ description={error}
+ />
+ )}
+ <div style={{ width: "100%", display: "flex" }}>
+ {Object.entries(accountType.list).map(([key, name], idx) => (
+ <div
+ key={idx}
+ style={{
+ marginLeft: 8,
+ padding: 8,
+ borderTopLeftRadius: 5,
+ borderTopRightRadius: 5,
+ backgroundColor:
+ accountType.value === key ? "#0042b2" : "unset",
+ color: accountType.value === key ? "white" : "unset",
+ }}
+ onClick={() => {
+ if (accountType.onChange) {
+ accountType.onChange(key);
+ }
+ }}
+ >
+ {name}
+ </div>
+ ))}
+ </div>
+ <div style={{ border: "1px solid gray", padding: 8, borderRadius: 5 }}>
+ --- {uri.value} ---
+ <p>
+ <CustomFieldByAccountType
+ type={accountType.value as AccountType}
+ field={uri}
+ />
+ </p>
+ </div>
+ <p>
+ <TextField
+ label="Alias"
+ variant="filled"
+ placeholder="Easy to remember description"
+ fullWidth
+ disabled={accountType.value === ""}
+ value={alias.value}
+ onChange={alias.onInput}
+ />
+ </p>
+ </section>
+ <section>
+ <Button
+ variant="contained"
+ color="secondary"
+ onClick={onCancel.onClick}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ <Button
+ variant="contained"
+ onClick={onAccountAdded.onClick}
+ disabled={!onAccountAdded.onClick}
+ >
+ <i18n.Translate>Add</i18n.Translate>
+ </Button>
+ </section>
+ <section>
+ {Object.entries(accountByType).map(([type, list]) => {
+ const Table = tableComponentByAccountType[type as AccountType];
+ return <Table key={type} list={list} onDelete={deleteAccount} />;
+ })}
+ </section>
+ </Fragment>
+ );
+}
+
+function IbanTable({
+ list,
+ onDelete,
+}: {
+ list: KnownBankAccountsInfo[];
+ onDelete: (ac: KnownBankAccountsInfo) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ if (list.length === 0) return <Fragment />;
+ return (
+ <div>
+ <h1>
+ <i18n.Translate>IBAN accounts</i18n.Translate>
+ </h1>
+ <AccountTable>
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Alias</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Bank Id</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Int. Account Number</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Account name</i18n.Translate>
+ </th>
+ <th class="kyc">
+ <i18n.Translate>KYC</i18n.Translate>
+ </th>
+ <th class="actions"></th>
+ </tr>
+ </thead>
+ <tbody>
+ {list.map((account) => {
+ const p = account.uri as PaytoUriIBAN;
+ return (
+ <tr key={account.alias}>
+ <td>{account.alias}</td>
+ <td>{p.bic}</td>
+ <td>{p.iban}</td>
+ <td>{p.params["receiver-name"]}</td>
+ <td class="kyc">
+ {account.kyc_completed ? (
+ <SvgIcon
+ title={i18n.str`KYC done`}
+ dangerouslySetInnerHTML={{ __html: checkIcon }}
+ color="green"
+ />
+ ) : (
+ <SvgIcon
+ title={i18n.str`KYC missing`}
+ dangerouslySetInnerHTML={{ __html: warningIcon }}
+ color="orange"
+ />
+ )}
+ </td>
+ <td class="actions">
+ <Button
+ variant="outlined"
+ startIcon={deleteIcon}
+ size="small"
+ onClick={async () => onDelete(account)}
+ color="error"
+ >
+ Forget
+ </Button>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </AccountTable>
+ </div>
+ );
+}
+
+function TalerBankTable({
+ list,
+ onDelete,
+}: {
+ list: KnownBankAccountsInfo[];
+ onDelete: (ac: KnownBankAccountsInfo) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ if (list.length === 0) return <Fragment />;
+ return (
+ <div>
+ <h1>
+ <i18n.Translate>Taler accounts</i18n.Translate>
+ </h1>
+ <AccountTable>
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Alias</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Host</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Account</i18n.Translate>
+ </th>
+ <th class="kyc">
+ <i18n.Translate>KYC</i18n.Translate>
+ </th>
+ <th class="actions"></th>
+ </tr>
+ </thead>
+ <tbody>
+ {list.map((account) => {
+ const p = account.uri as PaytoUriTalerBank;
+ return (
+ <tr key={account.alias}>
+ <td>{account.alias}</td>
+ <td>{p.host}</td>
+ <td>{p.account}</td>
+ <td class="kyc">
+ {account.kyc_completed ? (
+ <SvgIcon
+ title={i18n.str`KYC done`}
+ dangerouslySetInnerHTML={{ __html: checkIcon }}
+ color="green"
+ />
+ ) : (
+ <SvgIcon
+ title={i18n.str`KYC missing`}
+ dangerouslySetInnerHTML={{ __html: warningIcon }}
+ color="orange"
+ />
+ )}
+ </td>
+ <td class="actions">
+ <Button
+ variant="outlined"
+ startIcon={deleteIcon}
+ size="small"
+ onClick={async () => onDelete(account)}
+ color="error"
+ >
+ Forget
+ </Button>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </AccountTable>
+ </div>
+ );
+}
+
+function BitcoinTable({
+ list,
+ onDelete,
+}: {
+ list: KnownBankAccountsInfo[];
+ onDelete: (ac: KnownBankAccountsInfo) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ if (list.length === 0) return <Fragment />;
+ return (
+ <div>
+ <h2>
+ <i18n.Translate>Bitcoin accounts</i18n.Translate>
+ </h2>
+ <AccountTable>
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Alias</i18n.Translate>
+ </th>
+ <th>
+ <i18n.Translate>Address</i18n.Translate>
+ </th>
+ <th class="kyc">
+ <i18n.Translate>KYC</i18n.Translate>
+ </th>
+ <th class="actions"></th>
+ </tr>
+ </thead>
+ <tbody>
+ {list.map((account) => {
+ const p = account.uri as PaytoUriBitcoin;
+ return (
+ <tr key={account.alias}>
+ <td>{account.alias}</td>
+ <td>{p.targetPath}</td>
+ <td class="kyc">
+ {account.kyc_completed ? (
+ <SvgIcon
+ title={i18n.str`KYC done`}
+ dangerouslySetInnerHTML={{ __html: checkIcon }}
+ color="green"
+ />
+ ) : (
+ <SvgIcon
+ title={i18n.str`KYC missing`}
+ dangerouslySetInnerHTML={{ __html: warningIcon }}
+ color="orange"
+ />
+ )}
+ </td>
+ <td class="actions">
+ <Button
+ variant="outlined"
+ startIcon={deleteIcon}
+ size="small"
+ onClick={async () => onDelete(account)}
+ color="error"
+ >
+ Forget
+ </Button>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </AccountTable>
+ </div>
+ );
+}
+
+function BitcoinAddressAccount({ field }: { field: TextFieldHandler }): VNode {
+ const { i18n } = useTranslationContext();
+ const [value, setValue] = useState<string | undefined>(undefined);
+ const errors = undefinedIfEmpty({
+ value: !value ? i18n.str`Can't be empty` : undefined,
+ });
+ return (
+ <Fragment>
+ <h3>
+ <i18n.Translate>Bitcoin Account</i18n.Translate>
+ </h3>
+ <TextField
+ label="Bitcoin address"
+ variant="standard"
+ fullWidth
+ value={value}
+ error={value !== undefined ? errors?.value : undefined}
+ disabled={!field.onInput}
+ onChange={(v) => {
+ setValue(v);
+ if (!errors && field.onInput) {
+ const p = buildPayto("bitcoin", v, undefined);
+ field.onInput(stringifyPaytoUri(p));
+ }
+ }}
+ />
+ </Fragment>
+ );
+}
+
+function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
+ return Object.keys(obj).some((k) => (obj as Record<string,unknown>)[k] !== undefined)
+ ? obj
+ : undefined;
+}
+
+function TalerBankAddressAccount({
+ field,
+}: {
+ field: TextFieldHandler;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [host, setHost] = useState<string | undefined>(undefined);
+ const [account, setAccount] = useState<string | undefined>(undefined);
+ const errors = undefinedIfEmpty({
+ host: !host ? i18n.str`Can't be empty` : undefined,
+ account: !account ? i18n.str`Can't be empty` : undefined,
+ });
+ return (
+ <Fragment>
+ <h3>
+ <i18n.Translate>Taler Bank</i18n.Translate>
+ </h3>
+ <TextField
+ label="Bank host"
+ variant="standard"
+ fullWidth
+ value={host}
+ error={host !== undefined ? errors?.host : undefined}
+ disabled={!field.onInput}
+ onChange={(v) => {
+ setHost(v);
+ if (!errors && field.onInput && account) {
+ const p = buildPayto("x-taler-bank", v, account);
+ field.onInput(stringifyPaytoUri(p));
+ }
+ }}
+ />
+ <TextField
+ label="Bank account"
+ variant="standard"
+ fullWidth
+ disabled={!field.onInput}
+ value={account}
+ error={account !== undefined ? errors?.account : undefined}
+ onChange={(v) => {
+ setAccount(v || "");
+ if (!errors && field.onInput && host) {
+ const p = buildPayto("x-taler-bank", host, v);
+ field.onInput(stringifyPaytoUri(p));
+ }
+ }}
+ />
+ </Fragment>
+ );
+}
+
+//Taken from libeufin and libeufin took it from the ISO20022 XSD schema
+// const bicRegex = /^[A-Z]{6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?$/;
+// const ibanRegex = /^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$/;
+
+function IbanAddressAccount({ field }: { field: TextFieldHandler }): VNode {
+ const { i18n } = useTranslationContext();
+ // const [bic, setBic] = useState<string | undefined>(undefined);
+ const [iban, setIban] = useState<string | undefined>(undefined);
+ const [name, setName] = useState<string | undefined>(undefined);
+ const bic = ""
+ const errorsFN = (iban:string | undefined, name: string | undefined) => undefinedIfEmpty({
+ // bic: !bic
+ // ? undefined
+ // : !bicRegex.test(bic)
+ // ? i18n.str`Invalid bic`
+ // : undefined,
+ iban: !iban
+ ? i18n.str`Can't be empty`
+ : validateIban(iban).type === "invalid"
+ ? i18n.str`Invalid iban`
+ : undefined,
+ name: !name ? i18n.str`Can't be empty` : undefined,
+ });
+ const errors = errorsFN(iban, name)
+
+ function sendUpdateIfNoErrors(
+ bic: string | undefined,
+ iban: string,
+ name: string,
+ ): void {
+ if (!field.onInput) return;
+ if (!errorsFN(iban, name)) {
+ const p = buildPayto("iban", iban, bic);
+ p.params["receiver-name"] = name;
+ field.onInput(stringifyPaytoUri(p));
+ } else {
+ field.onInput("")
+ }
+ }
+ return (
+ <Fragment>
+ <h3>
+ <i18n.Translate>International Bank Account Number</i18n.Translate>
+ </h3>
+ {/* <p>
+ <TextField
+ label="BIC"
+ variant="filled"
+ placeholder="BANKID"
+ fullWidth
+ value={bic}
+ error={bic !== undefined ? errors?.bic : undefined}
+ disabled={!field.onInput}
+ onChange={(v) => {
+ setBic(v);
+ sendUpdateIfNoErrors(v, iban || "", name || "");
+ }}
+ />
+ </p> */}
+ <p>
+ <TextField
+ label="IBAN"
+ variant="filled"
+ placeholder="XX123456"
+ fullWidth
+ required
+ value={iban}
+ error={iban !== undefined ? errors?.iban : undefined}
+ disabled={!field.onInput}
+ onChange={(v) => {
+ setIban(v);
+ sendUpdateIfNoErrors(bic, v, name || "");
+ }}
+ />
+ </p>
+ <p>
+ <TextField
+ label="Account name"
+ variant="filled"
+ placeholder="Name of the target bank account owner"
+ fullWidth
+ required
+ value={name}
+ error={name !== undefined ? errors?.name : undefined}
+ disabled={!field.onInput}
+ onChange={(v) => {
+ setName(v);
+ sendUpdateIfNoErrors(bic, iban || "", v);
+ }}
+ />
+ </p>
+ </Fragment>
+ );
+}
+
+function CustomFieldByAccountType({
+ type,
+ field,
+}: {
+ type: AccountType;
+ field: TextFieldHandler;
+}): VNode {
+ // const { i18n } = useTranslationContext();
+
+ const AccountForm = formComponentByAccountType[type];
+
+ return (
+ <div>
+ <AccountForm field={field} />
+ </div>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/ManualWithdrawPage.tsx b/packages/taler-wallet-webextension/src/wallet/ManualWithdrawPage.tsx
deleted file mode 100644
index dcc0002e6..000000000
--- a/packages/taler-wallet-webextension/src/wallet/ManualWithdrawPage.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
-
-
-import { VNode } from "preact";
-import { useEffect, useRef, useState } from "preact/hooks";
-import { CreateManualWithdraw } from "./CreateManualWithdraw";
-import * as wxApi from '../wxApi'
-import { AcceptManualWithdrawalResult, AmountJson, Amounts } from "@gnu-taler/taler-util";
-import { ReserveCreated } from "./ReserveCreated.js";
-import { route } from 'preact-router';
-import { Pages } from "../NavigationBar.js";
-
-interface Props {
-
-}
-
-export function ManualWithdrawPage({ }: Props): VNode {
- const [success, setSuccess] = useState<AcceptManualWithdrawalResult | undefined>(undefined)
- const [currency, setCurrency] = useState<string | undefined>(undefined)
- const [error, setError] = useState<string | undefined>(undefined)
-
- async function onExchangeChange(exchange: string | undefined) {
- if (!exchange) return
- try {
- const r = await fetch(`${exchange}/keys`)
- const j = await r.json()
- if (j.currency) {
- await wxApi.addExchange({
- exchangeBaseUrl: `${exchange}/`,
- forceUpdate: true
- })
- setCurrency(j.currency)
- }
- } catch (e) {
- setError('The exchange url seems invalid')
- setCurrency(undefined)
- }
- }
-
- async function doCreate(exchangeBaseUrl: string, amount: AmountJson) {
- try {
- const resp = await wxApi.acceptManualWithdrawal(exchangeBaseUrl, Amounts.stringify(amount))
- setSuccess(resp)
- } catch (e) {
- if (e instanceof Error) {
- setError(e.message)
- } else {
- setError('unexpected error')
- }
- setSuccess(undefined)
- }
- }
-
- if (success) {
- return <ReserveCreated reservePub={success.reservePub} paytos={success.exchangePaytoUris} onBack={() => {
- route(Pages.balance)
- }}/>
- }
-
- return <CreateManualWithdraw
- error={error} currency={currency}
- onCreate={doCreate} onExchangeChange={onExchangeChange}
- />;
-}
-
-
-
diff --git a/packages/taler-wallet-webextension/src/wallet/Notifications/index.ts b/packages/taler-wallet-webextension/src/wallet/Notifications/index.ts
new file mode 100644
index 000000000..22b3adb0f
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/Notifications/index.ts
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { UserAttentionUnreadList } from "@gnu-taler/taler-util";
+import { ErrorAlertView } from "../../components/CurrentAlerts.js";
+import { Loading } from "../../components/Loading.js";
+import { ErrorAlert } from "../../context/alert.js";
+import { compose, StateViewMap } from "../../utils/index.js";
+import { useComponentState } from "./state.js";
+import { ReadyView } from "./views.js";
+
+export type Props = object;
+
+export type State = State.Loading | State.LoadingUriError | State.Ready;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingUriError {
+ status: "error";
+ error: ErrorAlert;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ list: UserAttentionUnreadList;
+ }
+}
+
+const viewMapping: StateViewMap<State> = {
+ loading: Loading,
+ error: ErrorAlertView,
+ ready: ReadyView,
+};
+
+export const NotificationsPage = compose(
+ "NotificationsPage",
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/taler-wallet-webextension/src/wallet/Notifications/state.ts b/packages/taler-wallet-webextension/src/wallet/Notifications/state.ts
new file mode 100644
index 000000000..3ef8250ac
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/Notifications/state.ts
@@ -0,0 +1,57 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { alertFromError } from "../../context/alert.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState(p: Props): State {
+ const api = useBackendContext();
+ const { i18n } = useTranslationContext();
+ const hook = useAsyncAsHook(async () => {
+ return await api.wallet.call(
+ WalletApiOperation.GetUserAttentionRequests,
+ {},
+ );
+ });
+
+ if (!hook) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+
+ if (hook.hasError) {
+ return {
+ status: "error",
+ error: alertFromError(
+ i18n,
+ i18n.str`Could not load user attention request`,
+ hook,
+ ),
+ };
+ }
+
+ return {
+ status: "ready",
+ error: undefined,
+ list: hook.response.pending,
+ };
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/Notifications/stories.tsx b/packages/taler-wallet-webextension/src/wallet/Notifications/stories.tsx
new file mode 100644
index 000000000..7344f417c
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/Notifications/stories.tsx
@@ -0,0 +1,63 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import {
+ AbsoluteTime,
+ AttentionType,
+ TalerPreciseTimestamp,
+ TransactionIdStr,
+} from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "notifications",
+};
+
+export const Ready = tests.createExample(ReadyView, {
+ list: [
+ {
+ when: TalerPreciseTimestamp.now(),
+ read: false,
+ info: {
+ type: AttentionType.KycWithdrawal,
+ transactionId: "123" as TransactionIdStr,
+ },
+ },
+ {
+ when: TalerPreciseTimestamp.now(),
+ read: false,
+ info: {
+ type: AttentionType.MerchantRefund,
+ transactionId: "123" as TransactionIdStr,
+ },
+ },
+ {
+ when: TalerPreciseTimestamp.now(),
+ read: false,
+ info: {
+ type: AttentionType.BackupUnpaid,
+ provider_base_url: "http://sync.taler.net",
+ talerUri: "taler://payment/asdasdasd",
+ },
+ },
+ ],
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/Notifications/test.ts b/packages/taler-wallet-webextension/src/wallet/Notifications/test.ts
new file mode 100644
index 000000000..c4ce1efc7
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/Notifications/test.ts
@@ -0,0 +1,28 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { expect } from "chai";
+
+describe("Notifications states", () => {
+ it.skip("should create some tests", () => {
+ expect([]).deep.equals([]);
+ });
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/Notifications/views.tsx b/packages/taler-wallet-webextension/src/wallet/Notifications/views.tsx
new file mode 100644
index 000000000..03a08016a
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/Notifications/views.tsx
@@ -0,0 +1,211 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AttentionInfo,
+ AttentionType,
+} from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import {
+ Column,
+ DateSeparator,
+ HistoryRow,
+ LargeText,
+ SmallLightText,
+} from "../../components/styled/index.js";
+import { Time } from "../../components/Time.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Avatar } from "../../mui/Avatar.js";
+import { Button } from "../../mui/Button.js";
+import { Grid } from "../../mui/Grid.js";
+import { Pages } from "../../NavigationBar.js";
+import { assertUnreachable } from "../../utils/index.js";
+import { State } from "./index.js";
+
+const term = 1000 * 60 * 60 * 24;
+function normalizeToDay(x: number): number {
+ return Math.round(x / term) * term;
+}
+
+export function ReadyView({ list }: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+ if (list.length < 1) {
+ return (
+ <section>
+ <i18n.Translate>No notification left</i18n.Translate>
+ </section>
+ );
+ }
+
+ const byDate = list.reduce((rv, x) => {
+ const theDate =
+ x.when.t_s === "never" ? 0 : normalizeToDay(x.when.t_s * 1000);
+ if (theDate) {
+ (rv[theDate] = rv[theDate] || []).push(x);
+ }
+
+ return rv;
+ }, {} as { [x: string]: typeof list });
+ const datesWithNotifications = Object.keys(byDate);
+
+ return (
+ <section>
+ {datesWithNotifications.map((d, i) => {
+ return (
+ <Fragment key={i}>
+ <DateSeparator>
+ <Time
+ timestamp={AbsoluteTime.fromMilliseconds(
+ Number.parseInt(d, 10),
+ )}
+ format="dd MMMM yyyy"
+ />
+ </DateSeparator>
+ {byDate[d].map((n, i) => (
+ <NotificationItem
+ key={i}
+ info={n.info}
+ isRead={n.read}
+ timestamp={AbsoluteTime.fromPreciseTimestamp(n.when)}
+ />
+ ))}
+ </Fragment>
+ );
+ })}
+ </section>
+ );
+}
+
+function NotificationItem({
+ info,
+ isRead,
+ timestamp,
+}: {
+ info: AttentionInfo;
+ timestamp: AbsoluteTime;
+ isRead: boolean;
+}): VNode {
+ switch (info.type) {
+ case AttentionType.KycWithdrawal:
+ return (
+ <NotificationLayout
+ timestamp={timestamp}
+ href={Pages.balanceTransaction({ tid: info.transactionId })}
+ title="Withdrawal on hold"
+ subtitle="Know-your-customer validation is required"
+ iconPath={"K"}
+ isRead={isRead}
+ />
+ );
+ case AttentionType.MerchantRefund:
+ return (
+ <NotificationLayout
+ timestamp={timestamp}
+ href={Pages.balanceTransaction({ tid: info.transactionId })}
+ title="Merchant has refund your payment"
+ subtitle="Accept or deny refund"
+ iconPath={"K"}
+ isRead={isRead}
+ />
+ );
+ case AttentionType.BackupUnpaid:
+ return (
+ <NotificationLayout
+ timestamp={timestamp}
+ href={`${Pages.ctaPay}?talerPayUri=${info.talerUri}`}
+ title="Backup provider is unpaid"
+ subtitle="Complete the payment or remove the service provider"
+ iconPath={"K"}
+ isRead={isRead}
+ />
+ );
+ case AttentionType.AuditorDenominationsExpires:
+ return <div>not implemented</div>;
+ case AttentionType.AuditorKeyExpires:
+ return <div>not implemented</div>;
+ case AttentionType.AuditorTosChanged:
+ return <div>not implemented</div>;
+ case AttentionType.ExchangeDenominationsExpired:
+ return <div>not implemented</div>;
+ // case AttentionType.ExchangeDenominationsExpiresSoon:
+ // return <div>not implemented</div>;
+ case AttentionType.ExchangeKeyExpired:
+ return <div>not implemented</div>;
+ // case AttentionType.ExchangeKeyExpiresSoon:
+ // return <div>not implemented</div>;
+ case AttentionType.ExchangeTosChanged:
+ return <div>not implemented</div>;
+ case AttentionType.BackupExpiresSoon:
+ return <div>not implemented</div>;
+ case AttentionType.PushPaymentReceived:
+ return <div>not implemented</div>;
+ case AttentionType.PullPaymentPaid:
+ return <div>not implemented</div>;
+ default:
+ assertUnreachable(info);
+ }
+}
+
+function NotificationLayout(props: {
+ title: string;
+ href: string;
+ subtitle?: string;
+ timestamp: AbsoluteTime;
+ iconPath: string;
+ isRead: boolean;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <HistoryRow
+ href={props.href}
+ style={{
+ backgroundColor: props.isRead ? "lightcyan" : "inherit",
+ alignItems: "center",
+ }}
+ >
+ <Avatar
+ style={{
+ border: "solid gray 1px",
+ color: "gray",
+ boxSizing: "border-box",
+ }}
+ >
+ {props.iconPath}
+ </Avatar>
+ <Column>
+ <LargeText>
+ <div>{props.title}</div>
+ {props.subtitle && (
+ <div style={{ color: "gray", fontSize: "medium", marginTop: 5 }}>
+ {props.subtitle}
+ </div>
+ )}
+ </LargeText>
+ <SmallLightText style={{ marginTop: 5 }}>
+ <Time timestamp={props.timestamp} format="HH:mm" />
+ </SmallLightText>
+ </Column>
+ <Column>
+ <Grid>
+ <Button variant="outlined">
+ <i18n.Translate>Ignore</i18n.Translate>
+ </Button>
+ </Grid>
+ </Column>
+ </HistoryRow>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderAddConfirmProvider.stories.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderAddConfirmProvider.stories.tsx
index d1e76c053..e5c43e230 100644
--- a/packages/taler-wallet-webextension/src/wallet/ProviderAddConfirmProvider.stories.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/ProviderAddConfirmProvider.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,38 +15,37 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { createExample } from '../test-utils';
-import { ConfirmProviderView as TestedComponent } from './ProviderAddPage';
+import * as tests from "@gnu-taler/web-util/testing";
+import { ConfirmProviderView as TestedComponent } from "./ProviderAddPage.js";
export default {
- title: 'wallet/backup/confirm',
+ title: "confirm",
component: TestedComponent,
argTypes: {
- onRetry: { action: 'onRetry' },
- onDelete: { action: 'onDelete' },
- onBack: { action: 'onBack' },
- }
+ onRetry: { action: "onRetry" },
+ onDelete: { action: "onDelete" },
+ onBack: { action: "onBack" },
+ },
};
-
-export const DemoService = createExample(TestedComponent, {
- url: 'https://sync.demo.taler.net/',
+export const DemoService = tests.createExample(TestedComponent, {
+ url: "https://sync.demo.taler.net/",
provider: {
- annual_fee: 'KUDOS:0.1',
- storage_limit_in_megabytes: 20,
- supported_protocol_version: '1'
- }
+ annual_fee: "KUDOS:0.1",
+ storage_limit_in_megabytes: 20,
+ supported_protocol_version: "1",
+ },
});
-export const FreeService = createExample(TestedComponent, {
- url: 'https://sync.taler:9667/',
+export const FreeService = tests.createExample(TestedComponent, {
+ url: "https://sync.taler:9667/",
provider: {
- annual_fee: 'ARS:0',
- storage_limit_in_megabytes: 20,
- supported_protocol_version: '1'
- }
+ annual_fee: "ARS:0",
+ storage_limit_in_megabytes: 20,
+ supported_protocol_version: "1",
+ },
});
diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderAddPage.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderAddPage.tsx
index 1c7fdc829..6ade0718a 100644
--- a/packages/taler-wallet-webextension/src/wallet/ProviderAddPage.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/ProviderAddPage.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -16,43 +16,34 @@
import {
Amounts,
- BackupBackupProviderTerms,
canonicalizeBaseUrl,
- i18n,
} from "@gnu-taler/taler-util";
-import { VNode, h } from "preact";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { Fragment, h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
-import { Checkbox } from "../components/Checkbox";
-import { ErrorMessage } from "../components/ErrorMessage";
+import { Checkbox } from "../components/Checkbox.js";
+import { ErrorMessage } from "../components/ErrorMessage.js";
import {
- Button,
- ButtonPrimary,
Input,
LightText,
- WalletBox,
SmallLightText,
-} from "../components/styled/index";
-import * as wxApi from "../wxApi";
+ SubTitle,
+ Title,
+} from "../components/styled/index.js";
+import { useBackendContext } from "../context/backend.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Button } from "../mui/Button.js";
+import { queryToSlashConfig } from "../utils/index.js";
interface Props {
currency: string;
- onBack: () => void;
+ onBack: () => Promise<void>;
}
-function getJsonIfOk(r: Response) {
- if (r.ok) {
- return r.json();
- } else {
- if (r.status >= 400 && r.status < 500) {
- throw new Error(`URL may not be right: (${r.status}) ${r.statusText}`);
- } else {
- throw new Error(
- `Try another server: (${r.status}) ${
- r.statusText || "internal server error"
- }`,
- );
- }
- }
+interface BackupBackupProviderTerms {
+ annual_fee: string;
+ storage_limit_in_megabytes: number;
+ supported_protocol_version: string;
}
export function ProviderAddPage({ onBack }: Props): VNode {
@@ -60,24 +51,14 @@ export function ProviderAddPage({ onBack }: Props): VNode {
| { url: string; name: string; provider: BackupBackupProviderTerms }
| undefined
>(undefined);
-
- async function getProviderInfo(
- url: string,
- ): Promise<BackupBackupProviderTerms> {
- return fetch(`${url}config`)
- .catch((e) => {
- throw new Error(`Network error`);
- })
- .then(getJsonIfOk);
- }
-
+ const api = useBackendContext();
if (!verifying) {
return (
<SetUrlView
onCancel={onBack}
- onVerify={(url) => getProviderInfo(url)}
+ onVerify={(url) => queryToSlashConfig(url)}
onConfirm={(url, name) =>
- getProviderInfo(url)
+ queryToSlashConfig<BackupBackupProviderTerms>(url)
.then((provider) => {
setVerifying({ url, name, provider });
})
@@ -90,11 +71,17 @@ export function ProviderAddPage({ onBack }: Props): VNode {
<ConfirmProviderView
provider={verifying.provider}
url={verifying.url}
- onCancel={() => {
+ onCancel={async () => {
setVerifying(undefined);
}}
onConfirm={() => {
- wxApi.addBackupProvider(verifying.url, verifying.name).then(onBack);
+ return api.wallet
+ .call(WalletApiOperation.AddBackupProvider, {
+ backupProviderBaseUrl: verifying.url,
+ name: verifying.name,
+ activate: true,
+ })
+ .then(onBack);
}}
/>
);
@@ -102,7 +89,7 @@ export function ProviderAddPage({ onBack }: Props): VNode {
export interface SetUrlViewProps {
initialValue?: string;
- onCancel: () => void;
+ onCancel: () => Promise<void>;
onVerify: (s: string) => Promise<BackupBackupProviderTerms | undefined>;
onConfirm: (url: string, name: string) => Promise<string | undefined>;
withError?: string;
@@ -114,7 +101,8 @@ export function SetUrlView({
onVerify,
onConfirm,
withError,
-}: SetUrlViewProps) {
+}: SetUrlViewProps): VNode {
+ const { i18n } = useTranslationContext();
const [value, setValue] = useState<string>(initialValue || "");
const [urlError, setUrlError] = useState(false);
const [name, setName] = useState<string | undefined>(undefined);
@@ -135,19 +123,29 @@ export function SetUrlView({
setUrlError(true);
setName(undefined);
}
- }, [value]);
+ }, [onVerify, value]);
return (
- <WalletBox>
+ <Fragment>
<section>
- <h1> Add backup provider</h1>
- <ErrorMessage
- title={error && "Could not get provider information"}
- description={error}
- />
- <LightText> Backup providers may charge for their service</LightText>
+ <Title>
+ <i18n.Translate>Add backup provider</i18n.Translate>
+ </Title>
+ {error && (
+ <ErrorMessage
+ title={i18n.str`Could not get provider information`}
+ description={error}
+ />
+ )}
+ <LightText>
+ <i18n.Translate>
+ Backup providers may charge for their service
+ </i18n.Translate>
+ </LightText>
<p>
<Input invalid={urlError}>
- <label>URL</label>
+ <label>
+ <i18n.Translate>URL</i18n.Translate>
+ </label>
<input
type="text"
placeholder="https://"
@@ -156,7 +154,9 @@ export function SetUrlView({
/>
</Input>
<Input>
- <label>Name</label>
+ <label>
+ <i18n.Translate>Name</i18n.Translate>
+ </label>
<input
type="text"
disabled={name === undefined}
@@ -167,10 +167,11 @@ export function SetUrlView({
</p>
</section>
<footer>
- <Button onClick={onCancel}>
- <i18n.Translate> &lt; Back</i18n.Translate>
+ <Button variant="contained" color="secondary" onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
</Button>
- <ButtonPrimary
+ <Button
+ variant="contained"
disabled={!value && !urlError}
onClick={() => {
const url = canonicalizeBaseUrl(value);
@@ -180,65 +181,80 @@ export function SetUrlView({
}}
>
<i18n.Translate>Next</i18n.Translate>
- </ButtonPrimary>
+ </Button>
</footer>
- </WalletBox>
+ </Fragment>
);
}
export interface ConfirmProviderViewProps {
provider: BackupBackupProviderTerms;
url: string;
- onCancel: () => void;
- onConfirm: () => void;
+ onCancel: () => Promise<void>;
+ onConfirm: () => Promise<void>;
}
export function ConfirmProviderView({
url,
provider,
onCancel,
onConfirm,
-}: ConfirmProviderViewProps) {
+}: ConfirmProviderViewProps): VNode {
const [accepted, setAccepted] = useState(false);
+ const { i18n } = useTranslationContext();
return (
- <WalletBox>
+ <Fragment>
<section>
- <h1>Review terms of service</h1>
+ <Title>
+ <i18n.Translate>Review terms of service</i18n.Translate>
+ </Title>
<div>
- Provider URL:{" "}
- <a href={url} target="_blank">
+ <i18n.Translate>Provider URL</i18n.Translate>:{" "}
+ <a href={url} target="_blank" rel="noreferrer">
{url}
</a>
</div>
<SmallLightText>
- Please review and accept this provider's terms of service
+ <i18n.Translate>
+ Please review and accept this provider&apos;s terms of service
+ </i18n.Translate>
</SmallLightText>
- <h2>1. Pricing</h2>
+ <SubTitle>
+ 1. <i18n.Translate>Pricing</i18n.Translate>
+ </SubTitle>
<p>
- {Amounts.isZero(provider.annual_fee)
- ? "free of charge"
- : `${provider.annual_fee} per year of service`}
+ {Amounts.isZero(provider.annual_fee) ? (
+ i18n.str`free of charge`
+ ) : (
+ <i18n.Translate>
+ {provider.annual_fee} per year of service
+ </i18n.Translate>
+ )}
</p>
- <h2>2. Storage</h2>
+ <SubTitle>
+ 2. <i18n.Translate>Storage</i18n.Translate>
+ </SubTitle>
<p>
- {provider.storage_limit_in_megabytes} megabytes of storage per year of
- service
+ <i18n.Translate>
+ {provider.storage_limit_in_megabytes} megabytes of storage per year
+ of service
+ </i18n.Translate>
</p>
<Checkbox
- label="Accept terms of service"
+ label={i18n.str`Accept terms of service`}
name="terms"
- onToggle={() => setAccepted((old) => !old)}
+ onToggle={async () => setAccepted((old) => !old)}
enabled={accepted}
/>
</section>
<footer>
- <Button onClick={onCancel}>
- <i18n.Translate> &lt; Back</i18n.Translate>
+ <Button variant="contained" color="secondary" onClick={onCancel}>
+ <i18n.Translate>Cancel</i18n.Translate>
</Button>
- <ButtonPrimary disabled={!accepted} onClick={onConfirm}>
+ <Button variant="contained" disabled={!accepted} onClick={onConfirm}>
<i18n.Translate>Add provider</i18n.Translate>
- </ButtonPrimary>
+ </Button>
</footer>
- </WalletBox>
+ </Fragment>
);
}
diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderAddSetUrl.stories.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderAddSetUrl.stories.tsx
index 4890e5e9c..d35a0ff99 100644
--- a/packages/taler-wallet-webextension/src/wallet/ProviderAddSetUrl.stories.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/ProviderAddSetUrl.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,39 +15,37 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { createExample } from '../test-utils';
-import { SetUrlView as TestedComponent } from './ProviderAddPage';
+import * as tests from "@gnu-taler/web-util/testing";
+import { SetUrlView as TestedComponent } from "./ProviderAddPage.js";
export default {
- title: 'wallet/backup/add',
+ title: "add",
component: TestedComponent,
argTypes: {
- onRetry: { action: 'onRetry' },
- onDelete: { action: 'onDelete' },
- onBack: { action: 'onBack' },
- }
+ onRetry: { action: "onRetry" },
+ onDelete: { action: "onDelete" },
+ onBack: { action: "onBack" },
+ },
};
+export const Initial = tests.createExample(TestedComponent, {});
-export const Initial = createExample(TestedComponent, {
-});
-
-export const WithValue = createExample(TestedComponent, {
- initialValue: 'sync.demo.taler.net'
-});
+export const WithValue = tests.createExample(TestedComponent, {
+ initialValue: "sync.demo.taler.net",
+});
-export const WithConnectionError = createExample(TestedComponent, {
- withError: 'Network error'
-});
+export const WithConnectionError = tests.createExample(TestedComponent, {
+ withError: "Network error",
+});
-export const WithClientError = createExample(TestedComponent, {
- withError: 'URL may not be right: (404) Not Found'
-});
+export const WithClientError = tests.createExample(TestedComponent, {
+ withError: "URL may not be right: (404) Not Found",
+});
-export const WithServerError = createExample(TestedComponent, {
- withError: 'Try another server: (500) Internal Server Error'
-});
+export const WithServerError = tests.createExample(TestedComponent, {
+ withError: "Try another server: (500) Internal Server Error",
+});
diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx
index 67ff83442..d4ee09b89 100644
--- a/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,224 +15,218 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { ProviderPaymentType } from '@gnu-taler/taler-wallet-core';
-import { createExample } from '../test-utils';
-import { ProviderView as TestedComponent } from './ProviderDetailPage';
+import {
+ AbsoluteTime,
+ AmountString,
+ ProviderPaymentType,
+ TalerPreciseTimestamp,
+} from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import { ProviderView as TestedComponent } from "./ProviderDetailPage.js";
export default {
- title: 'wallet/backup/details',
+ title: "provider details",
component: TestedComponent,
argTypes: {
- onRetry: { action: 'onRetry' },
- onDelete: { action: 'onDelete' },
- onBack: { action: 'onBack' },
- }
+ onRetry: { action: "onRetry" },
+ onDelete: { action: "onDelete" },
+ onBack: { action: "onBack" },
+ },
};
-
-export const Active = createExample(TestedComponent, {
+export const Active = tests.createExample(TestedComponent, {
info: {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
+ active: true,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.taler:9967/",
+ lastSuccessfulBackupTimestamp:
+ TalerPreciseTimestamp.fromSeconds(1625063925),
+ paymentProposalIds: [
+ "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG",
],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
- },
- "terms": {
- "annualFee": "EUR:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
+ paymentStatus: {
+ type: ProviderPaymentType.Paid,
+ paidUntil: AbsoluteTime.fromMilliseconds(1656599921000),
+ },
+ terms: {
+ annualFee: "EUR:1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
+ },
});
-export const ActiveErrorSync = createExample(TestedComponent, {
+export const ActiveErrorSync = tests.createExample(TestedComponent, {
info: {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- lastAttemptedBackupTimestamp: {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
+ active: true,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.taler:9967/",
+ lastSuccessfulBackupTimestamp:
+ TalerPreciseTimestamp.fromSeconds(1625063925),
+ lastAttemptedBackupTimestamp:
+ TalerPreciseTimestamp.fromSeconds(1625063925078),
+ paymentProposalIds: [
+ "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG",
],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
+ paymentStatus: {
+ type: ProviderPaymentType.Paid,
+ paidUntil: AbsoluteTime.fromMilliseconds(1656599921000),
},
lastError: {
code: 2002,
- details: 'details',
- hint: 'error hint from the server',
- message: 'message'
- },
- "terms": {
- "annualFee": "EUR:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
+ details: "details",
+ when: AbsoluteTime.now(),
+ hint: "error hint from the server",
+ message: "message",
+ },
+ terms: {
+ annualFee: "EUR:1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
+ },
});
-export const ActiveBackupProblemUnreadable = createExample(TestedComponent, {
- info: {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
- ],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
+export const ActiveBackupProblemUnreadable = tests.createExample(
+ TestedComponent,
+ {
+ info: {
+ active: true,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.taler:9967/",
+ lastSuccessfulBackupTimestamp:
+ TalerPreciseTimestamp.fromSeconds(1625063925),
+ paymentProposalIds: [
+ "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG",
+ ],
+ paymentStatus: {
+ type: ProviderPaymentType.Paid,
+ paidUntil: AbsoluteTime.fromMilliseconds(1656599921000),
+ },
+ backupProblem: {
+ type: "backup-unreadable",
+ },
+ terms: {
+ annualFee: "EUR:1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
},
- backupProblem: {
- type: 'backup-unreadable'
- },
- "terms": {
- "annualFee": "EUR:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
-});
+ },
+);
-export const ActiveBackupProblemDevice = createExample(TestedComponent, {
+export const ActiveBackupProblemDevice = tests.createExample(TestedComponent, {
info: {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.taler:9967/",
- "lastSuccessfulBackupTimestamp": {
- "t_ms": 1625063925078
- },
- "paymentProposalIds": [
- "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG"
+ active: true,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.taler:9967/",
+ lastSuccessfulBackupTimestamp:
+ TalerPreciseTimestamp.fromSeconds(1625063925078),
+ paymentProposalIds: [
+ "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG",
],
- "paymentStatus": {
- "type": ProviderPaymentType.Paid,
- "paidUntil": {
- "t_ms": 1656599921000
- }
+ paymentStatus: {
+ type: ProviderPaymentType.Paid,
+ paidUntil: AbsoluteTime.fromMilliseconds(1656599921000),
},
backupProblem: {
- type: 'backup-conflicting-device',
- myDeviceId: 'my-device-id',
- otherDeviceId: 'other-device-id',
- backupTimestamp: {
- "t_ms": 1656599921000
- }
- },
- "terms": {
- "annualFee": "EUR:1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
+ type: "backup-conflicting-device",
+ myDeviceId: "my-device-id",
+ otherDeviceId: "other-device-id",
+ backupTimestamp: AbsoluteTime.fromMilliseconds(1656599921000),
+ },
+ terms: {
+ annualFee: "EUR:1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
+ },
});
-export const InactiveUnpaid = createExample(TestedComponent, {
+export const InactiveUnpaid = tests.createExample(TestedComponent, {
info: {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.Unpaid,
- },
- "terms": {
- "annualFee": "EUR:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
+ active: false,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.demo.taler.net/",
+ paymentProposalIds: [],
+ paymentStatus: {
+ type: ProviderPaymentType.Unpaid,
+ },
+ terms: {
+ annualFee: "EUR:0.1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
+ },
});
-export const InactiveInsufficientBalance = createExample(TestedComponent, {
- info: {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.InsufficientBalance,
- },
- "terms": {
- "annualFee": "EUR:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
-});
+export const InactiveInsufficientBalance = tests.createExample(
+ TestedComponent,
+ {
+ info: {
+ active: false,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.demo.taler.net/",
+ paymentProposalIds: [],
+ paymentStatus: {
+ type: ProviderPaymentType.InsufficientBalance,
+ amount: "EUR:123" as AmountString,
+ },
+ terms: {
+ annualFee: "EUR:0.1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
+ },
+ },
+);
-export const InactivePending = createExample(TestedComponent, {
+export const InactivePending = tests.createExample(TestedComponent, {
info: {
- "active": false,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.Pending,
- },
- "terms": {
- "annualFee": "EUR:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
+ active: false,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.demo.taler.net/",
+ paymentProposalIds: [],
+ paymentStatus: {
+ type: ProviderPaymentType.Pending,
+ talerUri: "taler://pay/sad",
+ },
+ terms: {
+ annualFee: "EUR:0.1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
+ },
});
-
-export const ActiveTermsChanged = createExample(TestedComponent, {
+export const ActiveTermsChanged = tests.createExample(TestedComponent, {
info: {
- "active": true,
- name:'sync.demo',
- "syncProviderBaseUrl": "http://sync.demo.taler.net/",
- "paymentProposalIds": [],
- "paymentStatus": {
- "type": ProviderPaymentType.TermsChanged,
- paidUntil: {
- t_ms: 1656599921000
- },
+ active: true,
+ name: "sync.demo",
+ syncProviderBaseUrl: "http://sync.demo.taler.net/",
+ paymentProposalIds: [],
+ paymentStatus: {
+ type: ProviderPaymentType.TermsChanged,
+ paidUntil: AbsoluteTime.fromMilliseconds(1656599921000),
newTerms: {
- "annualFee": "EUR:10",
- "storageLimitInMegabytes": 8,
- "supportedProtocolVersion": "0.0"
+ annualFee: "EUR:10" as AmountString,
+ storageLimitInMegabytes: 8,
+ supportedProtocolVersion: "0.0",
},
oldTerms: {
- "annualFee": "EUR:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- },
- "terms": {
- "annualFee": "EUR:0.1",
- "storageLimitInMegabytes": 16,
- "supportedProtocolVersion": "0.0"
- }
- }
+ annualFee: "EUR:0.1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
+ },
+ terms: {
+ annualFee: "EUR:0.1" as AmountString,
+ storageLimitInMegabytes: 16,
+ supportedProtocolVersion: "0.0",
+ },
+ },
});
-
diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx
index c45458eb7..d628b68e8 100644
--- a/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx
@@ -1,195 +1,328 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
-
-import { i18n, Timestamp } from "@gnu-taler/taler-util";
-import { ProviderInfo, ProviderPaymentStatus, ProviderPaymentType } from "@gnu-taler/taler-wallet-core";
-import { format, formatDuration, intervalToDuration } from "date-fns";
+import * as utils from "@gnu-taler/taler-util";
+import {
+ AbsoluteTime,
+ ProviderInfo,
+ ProviderPaymentStatus,
+ ProviderPaymentType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
-import { ErrorMessage } from "../components/ErrorMessage";
-import { Button, ButtonDestructive, ButtonPrimary, PaymentStatus, WalletBox, SmallLightText } from "../components/styled";
-import { useProviderStatus } from "../hooks/useProviderStatus";
+import { ErrorAlertView } from "../components/CurrentAlerts.js";
+import { ErrorMessage } from "../components/ErrorMessage.js";
+import { Loading } from "../components/Loading.js";
+import { Time } from "../components/Time.js";
+import { PaymentStatus, SmallLightText } from "../components/styled/index.js";
+import { alertFromError } from "../context/alert.js";
+import { useBackendContext } from "../context/backend.js";
+import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { Button } from "../mui/Button.js";
interface Props {
pid: string;
- onBack: () => void;
+ onBack: () => Promise<void>;
+ onPayProvider: (uri: string) => Promise<void>;
+ onWithdraw: (amount: string) => Promise<void>;
}
-export function ProviderDetailPage({ pid, onBack }: Props): VNode {
- const status = useProviderStatus(pid)
- if (!status) {
- return <div><i18n.Translate>Loading...</i18n.Translate></div>
+export function ProviderDetailPage({
+ pid: providerURL,
+ onBack,
+ onPayProvider,
+ onWithdraw,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const api = useBackendContext();
+ async function getProviderInfo(): Promise<ProviderInfo | null> {
+ //create a first list of backup info by currency
+ const status = await api.wallet.call(WalletApiOperation.GetBackupInfo, {});
+
+ const providers = status.providers.filter(
+ (p) => p.syncProviderBaseUrl === providerURL,
+ );
+ return providers.length ? providers[0] : null;
}
- if (!status.info) {
- onBack()
- return <div />
+
+ const state = useAsyncAsHook(getProviderInfo);
+
+ if (!state) {
+ return <Loading />;
}
- return <ProviderView info={status.info}
- onSync={status.sync}
- onDelete={() => status.remove().then(onBack)}
- onBack={onBack}
- onExtend={() => { null }}
- />;
+ if (state.hasError) {
+ return (
+ <ErrorAlertView
+ error={alertFromError(
+ i18n,
+ i18n.str`There was an error loading the provider detail for &quot;${providerURL}&quot;`,
+ state,
+ )}
+ />
+ );
+ }
+ const info = state.response;
+ if (info === null) {
+ return (
+ <Fragment>
+ <section>
+ <p>
+ <i18n.Translate>
+ There is not known provider with url &quot;{providerURL}&quot;.
+ </i18n.Translate>
+ </p>
+ </section>
+ <footer>
+ <Button variant="contained" color="secondary" onClick={onBack}>
+ <i18n.Translate>See providers</i18n.Translate>
+ </Button>
+ <div />
+ </footer>
+ </Fragment>
+ );
+ }
+
+ return (
+ <ProviderView
+ info={info}
+ onSync={async () =>
+ api.wallet
+ .call(WalletApiOperation.RunBackupCycle, {
+ providers: [providerURL],
+ })
+ .then()
+ }
+ onPayProvider={async () => {
+ if (info.paymentStatus.type !== ProviderPaymentType.Pending) return;
+ if (!info.paymentStatus.talerUri) return;
+ onPayProvider(info.paymentStatus.talerUri);
+ }}
+ onWithdraw={async () => {
+ if (info.paymentStatus.type !== ProviderPaymentType.InsufficientBalance)
+ return;
+ onWithdraw(info.paymentStatus.amount);
+ }}
+ onDelete={() =>
+ api.wallet
+ .call(WalletApiOperation.RemoveBackupProvider, {
+ provider: providerURL,
+ })
+ .then(onBack)
+ }
+ onBack={onBack}
+ onExtend={async () => {
+ null;
+ }}
+ />
+ );
}
export interface ViewProps {
info: ProviderInfo;
- onDelete: () => void;
- onSync: () => void;
- onBack: () => void;
- onExtend: () => void;
+ onDelete: () => Promise<void>;
+ onSync: () => Promise<void>;
+ onBack: () => Promise<void>;
+ onExtend: () => Promise<void>;
+ onPayProvider: () => Promise<void>;
+ onWithdraw: () => Promise<void>;
}
-export function ProviderView({ info, onDelete, onSync, onBack, onExtend }: ViewProps): VNode {
- const lb = info?.lastSuccessfulBackupTimestamp
- const isPaid = info.paymentStatus.type === ProviderPaymentType.Paid || info.paymentStatus.type === ProviderPaymentType.TermsChanged
+export function ProviderView({
+ info,
+ onDelete,
+ onPayProvider,
+ onWithdraw,
+ onSync,
+ onBack,
+ onExtend,
+}: ViewProps): VNode {
+ const { i18n } = useTranslationContext();
+ const lb = info.lastSuccessfulBackupTimestamp
+ ? AbsoluteTime.fromPreciseTimestamp(info.lastSuccessfulBackupTimestamp)
+ : undefined;
+ const isPaid =
+ info.paymentStatus.type === ProviderPaymentType.Paid ||
+ info.paymentStatus.type === ProviderPaymentType.TermsChanged;
return (
- <WalletBox>
+ <Fragment>
<Error info={info} />
<header>
- <h3>{info.name} <SmallLightText>{info.syncProviderBaseUrl}</SmallLightText></h3>
- <PaymentStatus color={isPaid ? 'rgb(28, 184, 65)' : 'rgb(202, 60, 60)'}>{isPaid ? 'Paid' : 'Unpaid'}</PaymentStatus>
+ <h3>
+ {info.name}{" "}
+ <SmallLightText>{info.syncProviderBaseUrl}</SmallLightText>
+ </h3>
+ <PaymentStatus color={isPaid ? "rgb(28, 184, 65)" : "rgb(202, 60, 60)"}>
+ {isPaid ? "Paid" : "Unpaid"}
+ </PaymentStatus>
</header>
<section>
- <p><b>Last backup:</b> {lb == null || lb.t_ms == "never" ? "never" : format(lb.t_ms, 'dd MMM yyyy')} </p>
- <ButtonPrimary onClick={onSync}><i18n.Translate>Back up</i18n.Translate></ButtonPrimary>
- {info.terms && <Fragment>
- <p><b>Provider fee:</b> {info.terms && info.terms.annualFee} per year</p>
- </Fragment>
- }
- <p>{descriptionByStatus(info.paymentStatus)}</p>
- <ButtonPrimary disabled onClick={onExtend}><i18n.Translate>Extend</i18n.Translate></ButtonPrimary>
-
- {info.paymentStatus.type === ProviderPaymentType.TermsChanged && <div>
- <p><i18n.Translate>terms has changed, extending the service will imply accepting the new terms of service</i18n.Translate></p>
- <table>
- <thead>
- <tr>
- <td></td>
- <td><i18n.Translate>old</i18n.Translate></td>
- <td> -&gt;</td>
- <td><i18n.Translate>new</i18n.Translate></td>
- </tr>
- </thead>
- <tbody>
-
- <tr>
- <td><i18n.Translate>fee</i18n.Translate></td>
- <td>{info.paymentStatus.oldTerms.annualFee}</td>
- <td>-&gt;</td>
- <td>{info.paymentStatus.newTerms.annualFee}</td>
- </tr>
- <tr>
- <td><i18n.Translate>storage</i18n.Translate></td>
- <td>{info.paymentStatus.oldTerms.storageLimitInMegabytes}</td>
- <td>-&gt;</td>
- <td>{info.paymentStatus.newTerms.storageLimitInMegabytes}</td>
- </tr>
- </tbody>
- </table>
- </div>}
+ <p>
+ <b>
+ <i18n.Translate>Last backup</i18n.Translate>:
+ </b>{" "}
+ <Time timestamp={lb} format="dd MMMM yyyy" />
+ </p>
+ <Button variant="contained" onClick={onSync}>
+ <i18n.Translate>Back up</i18n.Translate>
+ </Button>
+ {info.terms && (
+ <Fragment>
+ <p>
+ <b>
+ <i18n.Translate>Provider fee</i18n.Translate>:
+ </b>{" "}
+ {info.terms && info.terms.annualFee}{" "}
+ <i18n.Translate>per year</i18n.Translate>
+ </p>
+ </Fragment>
+ )}
+ <p>{descriptionByStatus(info.paymentStatus, i18n)}</p>
+ <Button variant="contained" disabled onClick={onExtend}>
+ <i18n.Translate>Extend</i18n.Translate>
+ </Button>
+ {info.paymentStatus.type === ProviderPaymentType.TermsChanged && (
+ <div>
+ <p>
+ <i18n.Translate>
+ terms has changed, extending the service will imply accepting
+ the new terms of service
+ </i18n.Translate>
+ </p>
+ <table>
+ <thead>
+ <tr>
+ <td>&nbsp;</td>
+ <td>
+ <i18n.Translate>old</i18n.Translate>
+ </td>
+ <td> -&gt;</td>
+ <td>
+ <i18n.Translate>new</i18n.Translate>
+ </td>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>
+ <i18n.Translate>fee</i18n.Translate>
+ </td>
+ <td>{info.paymentStatus.oldTerms.annualFee}</td>
+ <td>-&gt;</td>
+ <td>{info.paymentStatus.newTerms.annualFee}</td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>storage</i18n.Translate>
+ </td>
+ <td>{info.paymentStatus.oldTerms.storageLimitInMegabytes}</td>
+ <td>-&gt;</td>
+ <td>{info.paymentStatus.newTerms.storageLimitInMegabytes}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ )}
</section>
<footer>
- <Button onClick={onBack}><i18n.Translate> &lt; back</i18n.Translate></Button>
+ <Button variant="contained" color="secondary" onClick={onBack}>
+ <i18n.Translate>See providers</i18n.Translate>
+ </Button>
<div>
- <ButtonDestructive onClick={onDelete}><i18n.Translate>remove provider</i18n.Translate></ButtonDestructive>
+ <Button variant="contained" color="error" onClick={onDelete}>
+ <i18n.Translate>Remove provider</i18n.Translate>
+ </Button>
+ {info.paymentStatus.type === ProviderPaymentType.Pending &&
+ info.paymentStatus.talerUri ? (
+ <Button variant="contained" color="primary" onClick={onPayProvider}>
+ <i18n.Translate>Pay</i18n.Translate>
+ </Button>
+ ) : undefined}
+ {info.paymentStatus.type ===
+ ProviderPaymentType.InsufficientBalance ? (
+ <Button variant="contained" color="primary" onClick={onWithdraw}>
+ <i18n.Translate>Withdraw</i18n.Translate>
+ </Button>
+ ) : undefined}
</div>
</footer>
- </WalletBox>
- )
-}
-
-function daysSince(d?: Timestamp) {
- if (!d || d.t_ms === 'never') return 'never synced'
- const duration = intervalToDuration({
- start: d.t_ms,
- end: new Date(),
- })
- const str = formatDuration(duration, {
- delimiter: ', ',
- format: [
- duration?.years ? i18n.str`years` : (
- duration?.months ? i18n.str`months` : (
- duration?.days ? i18n.str`days` : (
- duration?.hours ? i18n.str`hours` : (
- duration?.minutes ? i18n.str`minutes` : i18n.str`seconds`
- )
- )
- )
- )
- ]
- })
- return `synced ${str} ago`
+ </Fragment>
+ );
}
-function Error({ info }: { info: ProviderInfo }) {
+function Error({ info }: { info: ProviderInfo }): VNode {
+ const { i18n } = useTranslationContext();
if (info.lastError) {
- return <ErrorMessage title={info.lastError.hint} />
+ return (
+ <ErrorMessage
+ title={i18n.str`This provider has reported an error`}
+ description={info.lastError.hint}
+ />
+ );
}
if (info.backupProblem) {
switch (info.backupProblem.type) {
case "backup-conflicting-device":
- return <ErrorMessage title={<Fragment>
- <i18n.Translate>There is conflict with another backup from <b>{info.backupProblem.otherDeviceId}</b></i18n.Translate>
- </Fragment>} />
+ return (
+ <ErrorMessage
+ title={i18n.str`There is conflict with another backup from &quot;${info.backupProblem.otherDeviceId}&quot;`}
+ />
+ );
case "backup-unreadable":
- return <ErrorMessage title="Backup is not readable" />
+ return <ErrorMessage title={i18n.str`Backup is not readable`} />;
default:
- return <ErrorMessage title={<Fragment>
- <i18n.Translate>Unknown backup problem: {JSON.stringify(info.backupProblem)}</i18n.Translate>
- </Fragment>} />
+ return (
+ <ErrorMessage
+ title={i18n.str`Unknown backup problem: ${JSON.stringify(
+ info.backupProblem,
+ )}`}
+ />
+ );
}
}
- return null
+ return <Fragment />;
}
-function colorByStatus(status: ProviderPaymentType) {
- switch (status) {
- case ProviderPaymentType.InsufficientBalance:
- return 'rgb(223, 117, 20)'
- case ProviderPaymentType.Unpaid:
- return 'rgb(202, 60, 60)'
- case ProviderPaymentType.Paid:
- return 'rgb(28, 184, 65)'
- case ProviderPaymentType.Pending:
- return 'gray'
- case ProviderPaymentType.InsufficientBalance:
- return 'rgb(202, 60, 60)'
- case ProviderPaymentType.TermsChanged:
- return 'rgb(202, 60, 60)'
- }
-}
-
-function descriptionByStatus(status: ProviderPaymentStatus) {
+function descriptionByStatus(
+ status: ProviderPaymentStatus,
+ i18n: typeof utils.i18n,
+): VNode {
switch (status.type) {
- // return i18n.str`no enough balance to make the payment`
- // return i18n.str`not paid yet`
case ProviderPaymentType.Paid:
case ProviderPaymentType.TermsChanged:
- if (status.paidUntil.t_ms === 'never') {
- return i18n.str`service paid`
- } else {
- return <Fragment>
- <b>Backup valid until:</b> {format(status.paidUntil.t_ms, 'dd MMM yyyy')}
- </Fragment>
+ if (status.paidUntil.t_ms === "never") {
+ return (
+ <span>
+ <i18n.Translate>service paid</i18n.Translate>
+ </span>
+ );
}
+ return (
+ <Fragment>
+ <b>
+ <i18n.Translate>Backup valid until</i18n.Translate>:
+ </b>{" "}
+ <Time timestamp={status.paidUntil} format="dd MMM yyyy" />
+ </Fragment>
+ );
+
case ProviderPaymentType.Unpaid:
case ProviderPaymentType.InsufficientBalance:
case ProviderPaymentType.Pending:
- return ''
+ return <span />;
}
}
diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.stories.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.stories.tsx
new file mode 100644
index 000000000..8fc6985b0
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/QrReader.stories.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { QrReaderPage } from "./QrReader.js";
+
+export default {
+ title: "qr reader",
+};
+
+export const Reading = tests.createExample(QrReaderPage, {});
diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx
new file mode 100644
index 000000000..a01ea6967
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx
@@ -0,0 +1,392 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ assertUnreachable,
+ parseTalerUri,
+ TalerUri,
+ TalerUriAction,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { css } from "@linaria/core";
+import { styled } from "@linaria/react";
+import jsQR, * as pr from "jsqr";
+import { h, VNode } from "preact";
+import { useRef, useState } from "preact/hooks";
+import { EnabledBySettings } from "../components/EnabledBySettings.js";
+import { Alert } from "../mui/Alert.js";
+import { Button } from "../mui/Button.js";
+import { Grid } from "../mui/Grid.js";
+import { InputFile } from "../mui/InputFile.js";
+import { TextField } from "../mui/TextField.js";
+
+const QrCanvas = css`
+ width: 80%;
+ margin-left: auto;
+ margin-right: auto;
+ padding: 8px;
+ background-color: black;
+`;
+
+const LINE_COLOR = "#FF3B58";
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ & > * {
+ margin-bottom: 20px;
+ }
+`;
+
+export interface Props {
+ onDetected: (url: TalerUri) => void;
+}
+
+type XY = { x: number; y: number };
+
+function drawLine(
+ canvas: CanvasRenderingContext2D,
+ begin: XY,
+ end: XY,
+ color: string,
+) {
+ canvas.beginPath();
+ canvas.moveTo(begin.x, begin.y);
+ canvas.lineTo(end.x, end.y);
+ canvas.lineWidth = 4;
+ canvas.strokeStyle = color;
+ canvas.stroke();
+}
+
+function drawBox(context: CanvasRenderingContext2D, code: pr.QRCode) {
+ drawLine(
+ context,
+ code.location.topLeftCorner,
+ code.location.topRightCorner,
+ LINE_COLOR,
+ );
+ drawLine(
+ context,
+ code.location.topRightCorner,
+ code.location.bottomRightCorner,
+ LINE_COLOR,
+ );
+ drawLine(
+ context,
+ code.location.bottomRightCorner,
+ code.location.bottomLeftCorner,
+ LINE_COLOR,
+ );
+ drawLine(
+ context,
+ code.location.bottomLeftCorner,
+ code.location.topLeftCorner,
+ LINE_COLOR,
+ );
+}
+
+const SCAN_PER_SECONDS = 3;
+const TIME_BETWEEN_FRAMES = 1000 / SCAN_PER_SECONDS;
+
+async function delay(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function drawIntoCanvasAndGetQR(
+ tag: HTMLVideoElement | HTMLImageElement,
+ canvas: HTMLCanvasElement,
+): string | undefined {
+ const context = canvas.getContext("2d");
+ if (!context) {
+ throw Error("no 2d canvas context");
+ }
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ context.drawImage(tag, 0, 0, canvas.width, canvas.height);
+ const imgData = context.getImageData(0, 0, canvas.width, canvas.height);
+ const code = jsQR.default(imgData.data, canvas.width, canvas.height, {
+ inversionAttempts: "attemptBoth",
+ });
+ if (code) {
+ drawBox(context, code);
+ return code.data;
+ }
+ return undefined;
+}
+
+async function readNextFrame(
+ video: HTMLVideoElement,
+ canvas: HTMLCanvasElement,
+): Promise<string | undefined> {
+ const requestFrame =
+ "requestVideoFrameCallback" in video
+ ? video.requestVideoFrameCallback.bind(video)
+ : requestAnimationFrame;
+
+ return new Promise<string | undefined>((ok, bad) => {
+ requestFrame(() => {
+ try {
+ const code = drawIntoCanvasAndGetQR(video, canvas);
+ ok(code);
+ } catch (error) {
+ bad(error);
+ }
+ });
+ });
+}
+
+async function createCanvasFromVideo(
+ video: HTMLVideoElement,
+ canvas: HTMLCanvasElement,
+): Promise<string> {
+ const context = canvas.getContext("2d", {
+ willReadFrequently: true,
+ });
+ if (!context) {
+ throw Error("no 2d canvas context");
+ }
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+
+ let last = Date.now();
+
+ let found: string | undefined = undefined;
+ while (!found) {
+ const timeSinceLast = Date.now() - last;
+ if (timeSinceLast < TIME_BETWEEN_FRAMES) {
+ await delay(TIME_BETWEEN_FRAMES - timeSinceLast);
+ }
+ last = Date.now();
+ found = await readNextFrame(video, canvas);
+ }
+ video.pause();
+ return found;
+}
+
+async function createCanvasFromFile(
+ source: string,
+ canvas: HTMLCanvasElement,
+): Promise<string | undefined> {
+ const img = new Image(300, 300);
+ img.src = source;
+ canvas.width = img.width;
+ canvas.height = img.height;
+ return new Promise<string | undefined>((ok, bad) => {
+ img.addEventListener("load", () => {
+ try {
+ const code = drawIntoCanvasAndGetQR(img, canvas);
+ ok(code);
+ } catch (error) {
+ bad(error);
+ }
+ });
+ });
+}
+
+async function waitUntilReady(video: HTMLVideoElement): Promise<void> {
+ return new Promise((ok, _bad) => {
+ if (video.readyState === video.HAVE_ENOUGH_DATA) {
+ return ok();
+ }
+ setTimeout(waitUntilReady, 100);
+ });
+}
+
+export function QrReaderPage({ onDetected }: Props): VNode {
+ const videoRef = useRef<HTMLVideoElement>(null);
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const [error, setError] = useState<TranslatedString | undefined>();
+ const [value, setValue] = useState("");
+ const [show, setShow] = useState<"canvas" | "video" | "nothing">("nothing");
+
+ const { i18n } = useTranslationContext();
+
+ function onChangeDetect(str: string) {
+ if (str) {
+ const uri = parseTalerUri(str);
+ if (!uri) {
+ setError(
+ i18n.str`URI is not valid. Taler URI should start with "taler://"`,
+ );
+ } else {
+ onDetected(uri);
+ setError(undefined);
+ }
+ } else {
+ setError(undefined);
+ }
+ setValue(str);
+ }
+
+ function onChange(str: string) {
+ if (str) {
+ if (!parseTalerUri(str)) {
+ setError(
+ i18n.str`URI is not valid. Taler URI should start with "taler://"`,
+ );
+ } else {
+ setError(undefined);
+ }
+ } else {
+ setError(undefined);
+ }
+ setValue(str);
+ }
+
+ async function startVideo() {
+ if (!videoRef.current || !canvasRef.current) {
+ return;
+ }
+ const video = videoRef.current;
+ if (!video || !video.played) return;
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: "environment" },
+ audio: false,
+ });
+ setShow("video");
+ setError(undefined);
+ video.srcObject = stream;
+ await video.play();
+ await waitUntilReady(video);
+ try {
+ const code = await createCanvasFromVideo(video, canvasRef.current);
+ if (code) {
+ onChangeDetect(code);
+ setShow("canvas");
+ }
+ stream.getTracks().forEach((e) => {
+ e.stop();
+ });
+ } catch (error) {
+ setError(i18n.str`something unexpected happen: ${error}`);
+ }
+ }
+
+ async function onFileRead(fileContent: string) {
+ if (!canvasRef.current) {
+ return;
+ }
+ setShow("nothing");
+ setError(undefined);
+ try {
+ const code = await createCanvasFromFile(fileContent, canvasRef.current);
+ if (code) {
+ onChangeDetect(code);
+ setShow("canvas");
+ } else {
+ setError(i18n.str`Could not found a QR code in the file`);
+ }
+ } catch (error) {
+ setError(i18n.str`something unexpected happen: ${error}`);
+ }
+ }
+ const uri = parseTalerUri(value);
+
+ return (
+ <Container>
+ <section>
+ <h1>
+ <i18n.Translate>
+ Scan a QR code or enter taler:// URI below
+ </i18n.Translate>
+ </h1>
+ <div style={{ justifyContent: "space-between", display: "flex" }}>
+ <div style={{ width: "75%" }}>
+ <TextField
+ label="Taler URI"
+ variant="filled"
+ fullWidth
+ value={value}
+ onChange={onChange}
+ />
+ </div>
+ {uri && (
+ <Button
+ disabled={!!error}
+ variant="contained"
+ color="success"
+ onClick={async () => {
+ if (uri) onDetected(uri);
+ }}
+ >
+ {(function (talerUri: TalerUri): VNode {
+ switch (talerUri.type) {
+ case TalerUriAction.Pay:
+ return <i18n.Translate>Pay invoice</i18n.Translate>;
+ case TalerUriAction.Withdraw:
+ return (
+ <i18n.Translate>Withdrawal from bank</i18n.Translate>
+ );
+ case TalerUriAction.Refund:
+ return <i18n.Translate>Claim refund</i18n.Translate>;
+ case TalerUriAction.PayPull:
+ return <i18n.Translate>Pay invoice</i18n.Translate>;
+ case TalerUriAction.PayPush:
+ return <i18n.Translate>Accept payment</i18n.Translate>;
+ case TalerUriAction.PayTemplate:
+ return <i18n.Translate>Complete order</i18n.Translate>;
+ case TalerUriAction.Restore:
+ return <i18n.Translate>Restore wallet</i18n.Translate>;
+ case TalerUriAction.DevExperiment:
+ return <i18n.Translate>Enable experiment</i18n.Translate>;
+ case TalerUriAction.WithdrawExchange:
+ return (
+ <i18n.Translate>Withdraw from exchange</i18n.Translate>
+ );
+ case TalerUriAction.AddExchange:
+ return <i18n.Translate>Add exchange</i18n.Translate>;
+ default: {
+ assertUnreachable(talerUri);
+ }
+ }
+ })(uri)}
+ </Button>
+ )}
+ </div>
+ <Grid container justifyContent="space-around" columns={2}>
+ <Grid item xs={2}>
+ <p>{error && <Alert severity="error">{error}</Alert>}</p>
+ </Grid>
+ <Grid item xs={2}>
+ <p>
+ <Button variant="contained" onClick={startVideo}>
+ Use Camera
+ </Button>
+ </p>
+ </Grid>
+ <EnabledBySettings name="advancedMode">
+ <Grid item xs={2}>
+ <InputFile onChange={onFileRead}>Read QR from file</InputFile>
+ </Grid>
+ </EnabledBySettings>
+ </Grid>
+ </section>
+ <div>
+ <video
+ ref={videoRef}
+ style={{ display: show === "video" ? "unset" : "none" }}
+ playsInline={true}
+ />
+ <canvas
+ id="este"
+ class={QrCanvas}
+ ref={canvasRef}
+ style={{ display: show === "canvas" ? "unset " : "none" }}
+ />
+ </div>
+ </Container>
+ );
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx b/packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx
deleted file mode 100644
index e01336e02..000000000
--- a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { Fragment, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { QR } from "../components/QR";
-import { ButtonBox, FontIcon, WalletBox } from "../components/styled";
-
-export interface Props {
- reservePub: string;
- paytos: string[];
- onBack: () => void;
-}
-
-export function ReserveCreated({ reservePub, paytos, onBack }: Props): VNode {
- const [opened, setOpened] = useState(-1)
- return (
- <WalletBox>
- <section>
- <h2>Reserve created!</h2>
- <p>Now you need to send money to the exchange to one of the following accounts</p>
- <p>To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.</p>
- </section>
- <section>
- <ul>
- {paytos.map((href, idx) => {
- const url = new URL(href)
- return <li key={idx}><p>
- <a href="" onClick={(e) => { setOpened(o => o === idx ? -1 : idx); e.preventDefault() }}>{url.pathname}</a>
- {opened === idx && <Fragment>
- <p>If your system supports RFC 8905, you can do this by opening <a href={href}>this URI</a> or scan the QR with your wallet</p>
- <QR text={href} />
- </Fragment>}
- </p></li>
- })}
- </ul>
- </section>
- <footer>
- <ButtonBox onClick={onBack}><FontIcon>&#x2190;</FontIcon></ButtonBox>
- <div />
- </footer>
- </WalletBox>
- );
-}
diff --git a/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx
index a04a0b4fd..cd43c4526 100644
--- a/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,39 +15,77 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
-import { createExample } from '../test-utils';
-import { SettingsView as TestedComponent } from './Settings';
+import * as tests from "@gnu-taler/web-util/testing";
+import { SettingsView as TestedComponent } from "./Settings.js";
+import { WalletCoreVersion } from "@gnu-taler/taler-util";
export default {
- title: 'wallet/settings',
+ title: "settings",
component: TestedComponent,
argTypes: {
setDeviceName: () => Promise.resolve(),
- }
+ },
+};
+
+const version = {
+ coreVersion: {
+ exchange: "12:0:0",
+ merchant: "2:0:1",
+ bank: "0:0:0",
+ hash: "d439c3e1bc743f2aa47de4457953dba6ecb0e20f",
+ version: "1:2:3",
+ devMode: false,
+ bankConversionApiRange: "0:0:0",
+ bankIntegrationApiRange: "0:0:0",
+ corebankApiRange: "0:0:0",
+ implementationGitHash: "d439c3e1bc743f2aa47de4457953dba6ecb0e20f",
+ implementationSemver: "0.9.0-dev.1",
+ } satisfies WalletCoreVersion,
+ webexVersion: {
+ version: "0.9.0.13",
+ hash: "d439c3e1bc743f2aa47de4457953dba6ecb0e20f",
+ },
};
-export const AllOff = createExample(TestedComponent, {
- deviceName: 'this-is-the-device-name',
+export const AllOff = tests.createExample(TestedComponent, {
+ deviceName: "this-is-the-device-name",
+ advanceToggle: { value: false, button: {} },
+ autoOpenToggle: { value: false, button: {} },
+ langToggle: { value: false, button: {} },
setDeviceName: () => Promise.resolve(),
+ ...version,
});
-export const OneChecked = createExample(TestedComponent, {
- deviceName: 'this-is-the-device-name',
- permissionsEnabled: true,
+export const OneChecked = tests.createExample(TestedComponent, {
+ deviceName: "this-is-the-device-name",
+ advanceToggle: { value: false, button: {} },
+ autoOpenToggle: { value: false, button: {} },
+ langToggle: { value: false, button: {} },
setDeviceName: () => Promise.resolve(),
+ ...version,
});
-export const WithOneExchange = createExample(TestedComponent, {
- deviceName: 'this-is-the-device-name',
- permissionsEnabled: true,
+export const WithOneExchange = tests.createExample(TestedComponent, {
+ deviceName: "this-is-the-device-name",
+ advanceToggle: { value: false, button: {} },
+ autoOpenToggle: { value: false, button: {} },
+ langToggle: { value: false, button: {} },
setDeviceName: () => Promise.resolve(),
- knownExchanges: [{
- currency: 'USD',
- exchangeBaseUrl: 'http://exchange.taler',
- paytoUris: ['payto://x-taler-bank/bank.rpi.sebasjm.com/exchangeminator']
- }]
+ ...version,
});
+
+export const WithExchangeInDifferentState = tests.createExample(
+ TestedComponent,
+ {
+ deviceName: "this-is-the-device-name",
+ advanceToggle: { value: false, button: {} },
+ autoOpenToggle: { value: false, button: {} },
+ langToggle: { value: false, button: {} },
+ setDeviceName: () => Promise.resolve(),
+ ...version,
+ },
+);
diff --git a/packages/taler-wallet-webextension/src/wallet/Settings.tsx b/packages/taler-wallet-webextension/src/wallet/Settings.tsx
index 8d18586b1..0d0a31a2d 100644
--- a/packages/taler-wallet-webextension/src/wallet/Settings.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Settings.tsx
@@ -1,107 +1,290 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-*/
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ LibtoolVersion,
+ TranslatedString,
+ WalletCoreVersion
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { Checkbox } from "../components/Checkbox.js";
+import { EnabledBySettings } from "../components/EnabledBySettings.js";
+import { Part } from "../components/Part.js";
+import { SelectList } from "../components/SelectList.js";
+import {
+ Input,
+ SubTitle,
+ WarningBox
+} from "../components/styled/index.js";
+import { useAlertContext } from "../context/alert.js";
+import { useBackendContext } from "../context/backend.js";
+import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { useBackupDeviceName } from "../hooks/useBackupDeviceName.js";
+import { useSettings } from "../hooks/useSettings.js";
+import { ToggleHandler } from "../mui/handlers.js";
+import { Settings } from "../platform/api.js";
+import { platform } from "../platform/foreground.js";
+import { WALLET_CORE_SUPPORTED_VERSION } from "../wxApi.js";
-import { ExchangeListItem, i18n } from "@gnu-taler/taler-util";
-import { VNode, h, Fragment } from "preact";
-import { Checkbox } from "../components/Checkbox";
-import { EditableText } from "../components/EditableText";
-import { SelectList } from "../components/SelectList";
-import { ButtonPrimary, ButtonSuccess, WalletBox } from "../components/styled";
-import { useDevContext } from "../context/devContext";
-import { useBackupDeviceName } from "../hooks/useBackupDeviceName";
-import { useExtendedPermissions } from "../hooks/useExtendedPermissions";
-import { useAsyncAsHook } from "../hooks/useAsyncAsHook";
-import { useLang } from "../hooks/useLang";
-import * as wxApi from "../wxApi";
+const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
export function SettingsPage(): VNode {
- const [permissionsEnabled, togglePermissions] = useExtendedPermissions();
- const { devMode, toggleDevMode } = useDevContext()
- const { name, update } = useBackupDeviceName()
- const [lang, changeLang] = useLang()
- const exchangesHook = useAsyncAsHook(() => wxApi.listExchanges());
+ const [settings, updateSettings] = useSettings();
+ const { safely } = useAlertContext();
+ const { name, update } = useBackupDeviceName();
+ const webex = platform.getWalletWebExVersion();
+ const api = useBackendContext();
- return <SettingsView
- lang={lang} changeLang={changeLang}
- knownExchanges={!exchangesHook || exchangesHook.hasError ? [] : exchangesHook.response.exchanges}
- deviceName={name} setDeviceName={update}
- permissionsEnabled={permissionsEnabled} togglePermissions={togglePermissions}
- developerMode={devMode} toggleDeveloperMode={toggleDevMode}
- />;
+ const hook = useAsyncAsHook(async () => {
+ const version = await api.wallet.call(WalletApiOperation.GetVersion, {});
+ return { version };
+ });
+
+ const version = hook && !hook.hasError ? hook.response.version : undefined
+
+ return (
+ <SettingsView
+ deviceName={name}
+ setDeviceName={update}
+ autoOpenToggle={{
+ value: settings.autoOpen,
+ button: {
+ onClick: safely("update support injection", async () => {
+ updateSettings("autoOpen", !settings.autoOpen);
+ }),
+ },
+ }}
+ advanceToggle={{
+ value: settings.advancedMode,
+ button: {
+ onClick: safely("update advance mode", async () => {
+ updateSettings("advancedMode", !settings.advancedMode);
+ }),
+ },
+ }}
+ langToggle={{
+ value: settings.langSelector,
+ button: {
+ onClick: safely("update lang selector", async () => {
+ updateSettings("langSelector", !settings.langSelector);
+ }),
+ },
+ }}
+ webexVersion={{
+ version: webex.version,
+ hash: GIT_HASH,
+ }}
+ coreVersion={version}
+ />
+ );
}
export interface ViewProps {
- lang: string;
- changeLang: (s: string) => void;
deviceName: string;
setDeviceName: (s: string) => Promise<void>;
- permissionsEnabled: boolean;
- togglePermissions: () => void;
- developerMode: boolean;
- toggleDeveloperMode: () => void;
- knownExchanges: Array<ExchangeListItem>;
+ autoOpenToggle: ToggleHandler;
+ advanceToggle: ToggleHandler;
+ langToggle: ToggleHandler;
+ coreVersion: WalletCoreVersion | undefined;
+ webexVersion: {
+ version: string;
+ hash: string | undefined;
+ };
}
-import { strings as messages } from '../i18n/strings'
-
-type LangsNames = {
- [P in keyof typeof messages]: string
-}
-
-const names: LangsNames = {
- es: 'Español [es]',
- en: 'English [en]',
- fr: 'Français [fr]',
- de: 'Deutsch [de]',
- sv: 'Svenska [sv]',
- it: 'Italiano [it]',
-}
+export function SettingsView({
+ autoOpenToggle,
+ advanceToggle,
+ langToggle,
+ coreVersion,
+ webexVersion,
+}: ViewProps): VNode {
+ const { i18n, lang, supportedLang, changeLanguage } = useTranslationContext();
+ const api = useBackendContext();
-export function SettingsView({ knownExchanges, lang, changeLang, deviceName, setDeviceName, permissionsEnabled, togglePermissions, developerMode, toggleDeveloperMode }: ViewProps): VNode {
return (
- <WalletBox>
+ <Fragment>
<section>
+ <SubTitle>
+ <i18n.Translate>Navigator</i18n.Translate>
+ </SubTitle>
+ <Checkbox
+ label={i18n.str`Automatically open wallet`}
+ name="autoOpen"
+ description={
+ <i18n.Translate>
+ Open the wallet when a payment action is found.
+ </i18n.Translate>
+ }
+ enabled={autoOpenToggle.value!}
+ onToggle={autoOpenToggle.button.onClick!}
+ />
- <h2><i18n.Translate>Known exchanges</i18n.Translate></h2>
- {!knownExchanges || !knownExchanges.length ? <div>
- No exchange yet!
- </div> :
- <table>
- {knownExchanges.map(e => <tr>
- <td>{e.currency}</td>
- <td><a href={e.exchangeBaseUrl}>{e.exchangeBaseUrl}</a></td>
- </tr>)}
- </table>
- }
-
- <h2><i18n.Translate>Permissions</i18n.Translate></h2>
- <Checkbox label="Automatically open wallet based on page content"
- name="perm"
- description="(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)"
- enabled={permissionsEnabled} onToggle={togglePermissions}
+ <SubTitle>
+ <i18n.Translate>Version Info</i18n.Translate>
+ </SubTitle>
+ <Part
+ title={i18n.str`Web Extension`}
+ text={
+ <span>
+ {webexVersion.version}{" "}
+ <EnabledBySettings name="advancedMode">
+ {webexVersion.hash}
+ </EnabledBySettings>
+ </span>
+ }
/>
- <h2>Config</h2>
- <Checkbox label="Developer mode"
+ {coreVersion && (
+ <Fragment>
+ {LibtoolVersion.compare(
+ coreVersion.version,
+ WALLET_CORE_SUPPORTED_VERSION,
+ )?.compatible ? undefined : (
+ <WarningBox>
+ <i18n.Translate>
+ The version of wallet core is not supported. (supported
+ version: {WALLET_CORE_SUPPORTED_VERSION}, wallet version: {coreVersion.version})
+ </i18n.Translate>
+ </WarningBox>
+ )}
+ <EnabledBySettings name="advancedMode">
+ <Part
+ title={i18n.str`Exchange compatibility`}
+ text={<span>{coreVersion.exchange}</span>}
+ />
+ <Part
+ title={i18n.str`Merchant compatibility`}
+ text={<span>{coreVersion.merchant}</span>}
+ />
+ <Part
+ title={i18n.str`Bank compatibility`}
+ text={<span>{coreVersion.bank}</span>}
+ />
+ <Part
+ title={i18n.str`Wallet Core compatibility`}
+ text={<span>{coreVersion.version}</span>}
+ />
+ </EnabledBySettings>
+ </Fragment>
+ )}
+ <SubTitle>
+ <i18n.Translate>Settings</i18n.Translate>
+ </SubTitle>
+ <Checkbox
+ label={i18n.str`Enable developer mode`}
name="devMode"
- description="(More options and information useful for debugging)"
- enabled={developerMode} onToggle={toggleDeveloperMode}
+ description={i18n.str`Show more information and options in the UI`}
+ enabled={advanceToggle.value!}
+ onToggle={advanceToggle.button.onClick!}
/>
+ <EnabledBySettings name="advancedMode">
+ <AdvanceSettings />
+ </EnabledBySettings>
+ <EnabledBySettings name="langSelector">
+ <SubTitle>
+ <i18n.Translate>Display</i18n.Translate>
+ </SubTitle>
+ <Input>
+ <SelectList
+ label={<i18n.Translate>Current Language</i18n.Translate>}
+ list={supportedLang}
+ name="lang"
+ value={lang}
+ onChange={(v) => changeLanguage(v)}
+ />
+ </Input>
+ </EnabledBySettings>
+ </section>
+ </Fragment>
+ );
+}
+
+type Info = { label: TranslatedString; description: TranslatedString };
+type Options = {
+ [k in keyof Settings]?: Info;
+};
+function AdvanceSettings(): VNode {
+ const [settings, updateSettings] = useSettings();
+ const api = useBackendContext();
+ const { i18n } = useTranslationContext();
+ const o: Options = {
+ backup: {
+ label: i18n.str`Show backup feature`,
+ description: i18n.str`Backup integration still in beta.`,
+ },
+ suspendIndividualTransaction: {
+ label: i18n.str`Show suspend/resume transaction`,
+ description: i18n.str`Prevent transaction from doing network request.`,
+ },
+ showRefeshTransactions: {
+ label: i18n.str`Show refresh transaction type in the transaction list`,
+ description: i18n.str`Refresh transaction will be hidden by default if the refresh operation doesn't have fee.`,
+ },
+ extendedAccountTypes: {
+ label: i18n.str`Show more account types on deposit`,
+ description: i18n.str`Extends the UI to more payment target types.`,
+ },
+ showJsonOnError: {
+ label: i18n.str`Show JSON on error`,
+ description: i18n.str`Print more information about the error. Useful for debugging.`,
+ },
+ walletAllowHttp: {
+ label: i18n.str`Allow HTTP connections`,
+ description: i18n.str`Using HTTP connection may be faster but unsafe (wallet restart required)`,
+ },
+ langSelector: {
+ label: i18n.str`Lang selector`,
+ description: i18n.str`Allows to manually change the language of the UI. Otherwise it will be automatically selected by your browser configuration.`,
+ },
+ showExchangeManagement: {
+ label: i18n.str`Edit exchange management`,
+ description: i18n.str`Allows to see the list of exchange, remove, add and switch before withdrawal.`,
+ },
+ selectTosFormat: {
+ label: i18n.str`Select terms of service format`,
+ description: i18n.str`Allows to render the terms of service on different format selected by the user.`,
+ },
+ showWalletActivity: {
+ label: i18n.str`Show wallet activity`,
+ description: i18n.str`Show the wallet notification and observability event in the UI.`,
+ },
+ };
+ return (
+ <Fragment>
+ <section>
+ {Object.entries(o).map(([name, { label, description }]) => {
+ const settingsName = name as keyof Settings;
+ return (
+ <Checkbox
+ label={label}
+ name={name}
+ key={name}
+ description={description}
+ enabled={settings[settingsName]}
+ onToggle={async () => {
+ updateSettings(settingsName, !settings[settingsName]);
+ await api.background.call("reinitWallet", undefined);
+ }}
+ />
+ );
+ })}
</section>
- </WalletBox>
- )
+ </Fragment>
+ );
}
diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx
index 535509cef..194f0e0bb 100644
--- a/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,262 +15,612 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
import {
+ AbsoluteTime,
+ AmountString,
PaymentStatus,
- TransactionCommon, TransactionDeposit, TransactionPayment,
- TransactionRefresh, TransactionRefund, TransactionTip, TransactionType,
+ RefreshReason,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TransactionCommon,
+ TransactionDeposit,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionPayment,
+ TransactionPeerPullCredit,
+ TransactionPeerPullDebit,
+ TransactionPeerPushCredit,
+ TransactionPeerPushDebit,
+ TransactionRefresh,
+ TransactionRefund,
+ TransactionType,
TransactionWithdrawal,
+ WithdrawalDetails,
WithdrawalType
-} from '@gnu-taler/taler-util';
-import { createExample } from '../test-utils';
-import { TransactionView as TestedComponent } from './Transaction';
+} from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import beer from "../../static-dev/beer.png";
+import { TransactionView as TestedComponent } from "./Transaction.js";
export default {
- title: 'wallet/history/details',
+ title: "transaction details",
component: TestedComponent,
argTypes: {
- onRetry: { action: 'onRetry' },
- onDelete: { action: 'onDelete' },
- onBack: { action: 'onBack' },
- }
+ onRetry: { action: "onRetry" },
+ onDelete: { action: "onDelete" },
+ onCancel: { action: "onCancel" },
+ onBack: { action: "onBack" },
+ },
};
-const commonTransaction = {
- amountRaw: 'KUDOS:11',
- amountEffective: 'KUDOS:9.2',
- pending: false,
- timestamp: {
- t_ms: new Date().getTime()
+const commonTransaction: TransactionCommon = {
+ error: undefined,
+ amountRaw: "KUDOS:11" as AmountString,
+ amountEffective: "KUDOS:9.2" as AmountString,
+ txState: {
+ major: TransactionMajorState.Done,
},
- transactionId: '12',
-} as TransactionCommon
+ txActions: [],
+ timestamp: TalerProtocolTimestamp.now(),
+ transactionId: "txn:deposit:12" as TransactionIdStr,
+ type: TransactionType.Deposit,
+} as Omit<
+ Omit<Omit<TransactionCommon, "extendedStatus">, "frozen">,
+ "pending"
+> as TransactionCommon;
+
+import merchantIcon from "../../static-dev/merchant-icon.jpeg";
const exampleData = {
withdraw: {
...commonTransaction,
type: TransactionType.Withdrawal,
- exchangeBaseUrl: 'http://exchange.taler',
+ exchangeBaseUrl: "http://exchange.taler",
withdrawalDetails: {
+ reserveIsReady: false,
confirmed: false,
- exchangePaytoUris: ['payto://x-taler-bank/bank/account'],
+ reservePub: "A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG",
+ exchangePaytoUris: ["payto://x-taler-bank/bank.demo.taler.net/Exchange"],
type: WithdrawalType.ManualTransfer,
- }
+ },
} as TransactionWithdrawal,
payment: {
...commonTransaction,
- amountEffective: 'KUDOS:11',
+ amountEffective: "KUDOS:12" as AmountString,
type: TransactionType.Payment,
+ posConfirmation: undefined,
info: {
- contractTermsHash: 'ASDZXCASD',
+ contractTermsHash: "ASDZXCASD",
merchant: {
- name: 'the merchant',
+ name: "the merchant",
+ logo: merchantIcon,
+ website: "https://www.themerchant.taler",
+ email: "contact@merchant.taler",
},
- orderId: '2021.167-03NPY6MCYMVGT',
+ orderId: "2021.167-03NPY6MCYMVGT",
products: [],
summary: "Essay: Why the Devil's Advocate Doesn't Help Reach the Truth",
- fulfillmentMessage: '',
+ fulfillmentMessage: "",
+ // delivery_date: { t_s: 1 },
+ // delivery_location: {
+ // address_lines: [""],
+ // },
},
- proposalId: '1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0',
+ refunds: [],
+ refundPending: undefined,
+ totalRefundEffective: "KUDOS:0" as AmountString,
+ totalRefundRaw: "KUDOS:0" as AmountString,
+ proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0",
status: PaymentStatus.Accepted,
+ refundQueryActive: false,
} as TransactionPayment,
deposit: {
...commonTransaction,
type: TransactionType.Deposit,
- depositGroupId: '#groupId',
- targetPaytoUri: 'payto://x-taler-bank/bank/account',
+ wireTransferDeadline: {
+ t_s: new Date().getTime() / 1000,
+ },
+ depositGroupId: "#groupId",
+ targetPaytoUri: "payto://x-taler-bank/bank.demo.taler.net/Exchange",
} as TransactionDeposit,
refresh: {
...commonTransaction,
type: TransactionType.Refresh,
- exchangeBaseUrl: 'http://exchange.taler',
+ refreshInputAmount: "KUDOS:1" as AmountString,
+ refreshOutputAmount: "KUDOS:0.5" as AmountString,
+ exchangeBaseUrl: "http://exchange.taler",
+ refreshReason: RefreshReason.Manual,
} as TransactionRefresh,
- tip: {
- ...commonTransaction,
- type: TransactionType.Tip,
- merchantBaseUrl: 'http://merchant.taler',
- } as TransactionTip,
refund: {
...commonTransaction,
type: TransactionType.Refund,
- refundedTransactionId: 'payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0',
- info: {
- contractTermsHash: 'ASDZXCASD',
+ refundedTransactionId:
+ "payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0",
+ paymentInfo: {
merchant: {
- name: 'the merchant',
+ name: "The merchant",
},
- orderId: '2021.167-03NPY6MCYMVGT',
- products: [],
- summary: 'the summary',
- fulfillmentMessage: '',
+ summary: "Essay: Why the Devil's Advocate Doesn't Help Reach the Truth",
+ summary_i18n: {},
},
+ refundPending: undefined,
} as TransactionRefund,
-}
+ push_credit: {
+ ...commonTransaction,
+ type: TransactionType.PeerPushCredit,
+ info: {
+ expiration: {
+ t_s: new Date().getTime() / 1000 + 2 * 60 * 60,
+ },
+ summary: "take this money",
+ completed: true,
+ },
+ kycUrl: undefined,
+ exchangeBaseUrl: "https://exchange.taler.net",
+ } as TransactionPeerPushCredit,
+ push_debit: {
+ ...commonTransaction,
+ type: TransactionType.PeerPushDebit,
+ talerUri:
+ "taler://pay-push/exchange.taler.ar/HS585JK0QCXHJ8Z8QWZA3EBAY5WY7XNC1RR2MHJXSH2Z4WP0YPJ0",
+ info: {
+ expiration: {
+ t_s: new Date().getTime() / 1000 + 2 * 60 * 60,
+ },
+ summary: "take this money",
+ completed: true,
+ },
+ exchangeBaseUrl: "https://exchange.taler.net",
+ } as TransactionPeerPushDebit,
+ pull_credit: {
+ ...commonTransaction,
+ type: TransactionType.PeerPullCredit,
+ talerUri:
+ "taler://pay-push/exchange.taler.ar/HS585JK0QCXHJ8Z8QWZA3EBAY5WY7XNC1RR2MHJXSH2Z4WP0YPJ0",
+ info: {
+ expiration: {
+ t_s: new Date().getTime() / 1000 + 2 * 60 * 60,
+ },
+ summary: "pay me, please?",
+ completed: true,
+ },
+ kycUrl: undefined,
+ exchangeBaseUrl: "https://exchange.taler.net",
+ } as TransactionPeerPullCredit,
+ pull_debit: {
+ ...commonTransaction,
+ type: TransactionType.PeerPullDebit,
+ info: {
+ expiration: {
+ t_s: new Date().getTime() / 1000 + 2 * 60 * 60,
+ },
+ summary: "pay me, please?",
+ completed: true,
+ },
+ exchangeBaseUrl: "https://exchange.taler.net",
+ } as TransactionPeerPullDebit,
+};
const transactionError = {
- code: 2000,
- details: "details",
- hint: "this is a hint for the error",
- message: 'message'
-}
+ code: 7005,
+ details: {
+ requestUrl:
+ "http://merchant-backend.taler:9966/orders/2021.340-02AD5XCC97MQM/pay",
+ httpStatusCode: 410,
+ errorResponse: {
+ code: 2161,
+ hint: "The payment is too late, the offer has expired.",
+ },
+ },
+ when: AbsoluteTime.now(),
+ hint: "Error: WALLET_UNEXPECTED_REQUEST_ERROR",
+ message: "Unexpected error code in response",
+};
-export const Withdraw = createExample(TestedComponent, {
- transaction: exampleData.withdraw
+export const Withdraw = tests.createExample(TestedComponent, {
+ transaction: exampleData.withdraw,
});
-export const WithdrawError = createExample(TestedComponent, {
+export const WithdrawFiveMinutesAgo = tests.createExample(
+ TestedComponent,
+ () => ({
+ transaction: {
+ ...exampleData.withdraw,
+ timestamp: TalerPreciseTimestamp.fromSeconds(
+ new Date().getTime() / 1000 - 60 * 5,
+ ),
+ },
+ }),
+);
+
+export const WithdrawFiveMinutesAgoAndPending = tests.createExample(
+ TestedComponent,
+ () => ({
+ transaction: {
+ ...exampleData.withdraw,
+ timestamp: TalerPreciseTimestamp.fromSeconds(
+ new Date().getTime() / 1000 - 60 * 5,
+ ),
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
+ }),
+);
+
+export const WithdrawError = tests.createExample(TestedComponent, {
transaction: {
...exampleData.withdraw,
error: transactionError,
},
});
-export const WithdrawPending = createExample(TestedComponent, {
- transaction: { ...exampleData.withdraw, pending: true },
+export const WithdrawErrorKYC = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.withdraw,
+ txState: {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ },
+ kycUrl:
+ "http://localhost:6666/oauth/v2/login?client_id=taler-exchange&redirect_uri=http%3A%2F%2Flocalhost%3A8081%2F%2Fkyc-proof%2F59WFS5VXXY3CEE25BM45XPB7ZCDQZNZ46PJCMNXK05P65T9M1X90%2FKYC-PROVIDER-MYPROV%2F1",
+ },
});
+export const WithdrawPendingManual = tests.createExample(
+ TestedComponent,
+ () => ({
+ transaction: {
+ ...exampleData.withdraw,
+ withdrawalDetails: {
+ type: WithdrawalType.ManualTransfer,
+ exchangePaytoUris: ["payto://iban/ES8877998399652238"],
+ reservePub: "A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG",
+ exchangeCreditAccountDetails: [{
+ paytoUri: "payto://IBAN/1231231231",
+ },
+ {
+ paytoUri: "payto://IBAN/2342342342",
+ }],
+ } as WithdrawalDetails,
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
+ }),
+);
+
+export const WithdrawPendingTalerBankUnconfirmed = tests.createExample(
+ TestedComponent,
+ {
+ transaction: {
+ ...exampleData.withdraw,
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: false,
+ reserveIsReady: false,
+ reservePub: "A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG",
+ bankConfirmationUrl: "http://bank.demo.taler.net",
+ },
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
+ },
+);
+
+export const WithdrawPendingTalerBankConfirmed = tests.createExample(
+ TestedComponent,
+ {
+ transaction: {
+ ...exampleData.withdraw,
+ withdrawalDetails: {
+ type: WithdrawalType.TalerBankIntegrationApi,
+ confirmed: true,
+ reserveIsReady: false,
+ reservePub: "A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG",
+ },
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
+ },
+);
-export const Payment = createExample(TestedComponent, {
- transaction: exampleData.payment
+export const Payment = tests.createExample(TestedComponent, {
+ transaction: exampleData.payment,
});
-export const PaymentError = createExample(TestedComponent, {
+export const PaymentWithPosConfirmation = tests.createExample(TestedComponent, {
transaction: {
...exampleData.payment,
- error: transactionError
+ posConfirmation: "123123\n3345345\n567567",
},
});
-export const PaymentWithoutFee = createExample(TestedComponent, {
+export const PaymentError = tests.createExample(TestedComponent, {
transaction: {
...exampleData.payment,
- amountRaw: 'KUDOS:11',
+ error: transactionError,
+ },
+});
- }
+export const PaymentWithRefund = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.payment,
+ amountRaw: "KUDOS:12" as AmountString,
+ totalRefundEffective: "KUDOS:1" as AmountString,
+ totalRefundRaw: "KUDOS:1" as AmountString,
+ refunds: [
+ {
+ transactionId: "1123123",
+ amountRaw: "KUDOS:1" as AmountString,
+ amountEffective: "KUDOS:1" as AmountString,
+ timestamp: TalerProtocolTimestamp.fromSeconds(1546546544),
+ },
+ ],
+ },
});
-export const PaymentPending = createExample(TestedComponent, {
- transaction: { ...exampleData.payment, pending: true },
+export const PaymentWithDeliveryDate = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.payment,
+ amountRaw: "KUDOS:12" as AmountString,
+ info: {
+ ...exampleData.payment.info,
+ // delivery_date: {
+ // t_s: new Date().getTime() / 1000,
+ // },
+ },
+ },
});
-export const PaymentWithProducts = createExample(TestedComponent, {
+export const PaymentWithDeliveryAddr = tests.createExample(TestedComponent, {
transaction: {
...exampleData.payment,
+ amountRaw: "KUDOS:12" as AmountString,
info: {
...exampleData.payment.info,
- summary: 'this order has 5 products',
- products: [{
- description: 't-shirt',
- unit: 'shirts',
- quantity: 1,
- }, {
- description: 't-shirt',
- unit: 'shirts',
- quantity: 1,
- }, {
- description: 'e-book',
- }, {
- description: 'beer',
- unit: 'pint',
- quantity: 15,
- }, {
- description: 'beer',
- unit: 'pint',
- quantity: 15,
- }]
- }
- } as TransactionPayment,
+ // delivery_location: {
+ // country: "Argentina",
+ // street: "Elm Street",
+ // district: "CABA",
+ // post_code: "1101",
+ // },
+ },
+ },
});
-export const PaymentWithLongSummary = createExample(TestedComponent, {
+export const PaymentWithDeliveryFull = tests.createExample(TestedComponent, {
transaction: {
...exampleData.payment,
+ amountRaw: "KUDOS:12" as AmountString,
info: {
...exampleData.payment.info,
- summary: 'this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, ',
- products: [{
- description: 'an xl sized t-shirt with some drawings on it, color pink',
- unit: 'shirts',
- quantity: 1,
- }, {
- description: 'beer',
- unit: 'pint',
- quantity: 15,
- }]
- }
+ // delivery_date: {
+ // t_s: new Date().getTime() / 1000,
+ // },
+ // delivery_location: {
+ // country: "Argentina",
+ // street: "Elm Street",
+ // district: "CABA",
+ // post_code: "1101",
+ // },
+ },
+ },
+});
+
+export const PaymentWithRefundPending = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.payment,
+ amountRaw: "KUDOS:12" as AmountString,
+ refundPending: "KUDOS:3" as AmountString,
+ totalRefundEffective: "KUDOS:1" as AmountString,
+ totalRefundRaw: "KUDOS:1" as AmountString,
+ },
+});
+
+export const PaymentWithFeeAndRefund = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.payment,
+ amountRaw: "KUDOS:11" as AmountString,
+ totalRefundEffective: "KUDOS:1" as AmountString,
+ totalRefundRaw: "KUDOS:1" as AmountString,
+ },
+});
+
+export const PaymentWithFeeAndRefundFee = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.payment,
+ amountRaw: "KUDOS:11" as AmountString,
+ totalRefundEffective: "KUDOS:1" as AmountString,
+ totalRefundRaw: "KUDOS:2" as AmountString,
+ },
+});
+
+export const PaymentWithoutFee = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.payment,
+ amountRaw: "KUDOS:12" as AmountString,
+ },
+});
+
+export const PaymentPending = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.payment,
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
+});
+
+export const PaymentWithProducts = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary: "summary of 5 products",
+ products: [
+ {
+ description: "t-shirt",
+ unit: "shirts",
+ quantity: 1,
+ },
+ {
+ description: "t-shirt",
+ unit: "shirts",
+ quantity: 1,
+ },
+ {
+ description: "e-book",
+ },
+ {
+ description: "beer",
+ unit: "pint",
+ quantity: 15,
+ image: beer,
+ },
+ {
+ description: "beer",
+ unit: "pint",
+ quantity: 15,
+ image: beer,
+ },
+ ],
+ },
} as TransactionPayment,
});
+export const PaymentWithLongSummary = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.payment,
+ info: {
+ ...exampleData.payment.info,
+ summary:
+ "this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, ",
+ products: [
+ {
+ description:
+ "an xl sized t-shirt with some drawings on it, color pink",
+ unit: "shirts",
+ quantity: 1,
+ },
+ {
+ description: "beer",
+ unit: "pint",
+ quantity: 15,
+ },
+ ],
+ },
+ } as TransactionPayment,
+});
-export const Deposit = createExample(TestedComponent, {
- transaction: exampleData.deposit
+export const Deposit = tests.createExample(TestedComponent, {
+ transaction: exampleData.deposit,
+});
+export const DepositTalerBank = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.deposit,
+ targetPaytoUri: "payto://x-taler-bank/bank.demo.taler.net/Exchange",
+ },
+});
+export const DepositBitcoin = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.deposit,
+ amountRaw: "BITCOINBTC:0.0000011" as AmountString,
+ amountEffective: "BITCOINBTC:0.00000092" as AmountString,
+ targetPaytoUri:
+ "payto://bitcoin/bcrt1q6ps8qs6v8tkqrnru4xqqqa6rfwcx5ufpdfqht4?amount=BTC:0.1&subject=0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00",
+ },
+});
+export const DepositIBAN = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.deposit,
+ targetPaytoUri: "payto://iban/ES8877998399652238",
+ },
});
-export const DepositError = createExample(TestedComponent, {
+export const DepositError = tests.createExample(TestedComponent, {
transaction: {
...exampleData.deposit,
- error: transactionError
+ error: transactionError,
},
});
-export const DepositPending = createExample(TestedComponent, {
- transaction: { ...exampleData.deposit, pending: true }
+export const DepositPending = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.deposit,
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
});
-export const Refresh = createExample(TestedComponent, {
- transaction: exampleData.refresh
+export const Refresh = tests.createExample(TestedComponent, {
+ transaction: exampleData.refresh,
});
-export const RefreshError = createExample(TestedComponent, {
+export const RefreshError = tests.createExample(TestedComponent, {
transaction: {
...exampleData.refresh,
- error: transactionError
+ error: transactionError,
},
});
-export const Tip = createExample(TestedComponent, {
- transaction: exampleData.tip
+export const Refund = tests.createExample(TestedComponent, {
+ transaction: exampleData.refund,
});
-export const TipError = createExample(TestedComponent, {
+export const RefundError = tests.createExample(TestedComponent, {
transaction: {
- ...exampleData.tip,
- error: transactionError
+ ...exampleData.refund,
+ error: transactionError,
},
});
-export const TipPending = createExample(TestedComponent, {
- transaction: { ...exampleData.tip, pending: true }
+export const RefundPending = tests.createExample(TestedComponent, {
+ transaction: {
+ ...exampleData.refund,
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
});
-export const Refund = createExample(TestedComponent, {
- transaction: exampleData.refund
+export const InvoiceCreditComplete = tests.createExample(TestedComponent, {
+ transaction: { ...exampleData.pull_credit },
});
-export const RefundError = createExample(TestedComponent, {
+export const InvoiceCreditIncomplete = tests.createExample(TestedComponent, {
transaction: {
- ...exampleData.refund,
- error: transactionError
+ ...exampleData.pull_credit,
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
},
});
-export const RefundPending = createExample(TestedComponent, {
- transaction: { ...exampleData.refund, pending: true }
+export const InvoiceDebit = tests.createExample(TestedComponent, {
+ transaction: { ...exampleData.pull_debit },
});
-export const RefundWithProducts = createExample(TestedComponent, {
+export const TransferCredit = tests.createExample(TestedComponent, {
+ transaction: { ...exampleData.push_credit },
+});
+
+export const TransferDebitComplete = tests.createExample(TestedComponent, {
+ transaction: { ...exampleData.push_debit },
+});
+export const TransferDebitIncomplete = tests.createExample(TestedComponent, {
transaction: {
- ...exampleData.refund,
- info: {
- ...exampleData.refund.info,
- products: [{
- description: 't-shirt',
- }, {
- description: 'beer',
- }]
- }
- } as TransactionRefund,
+ ...exampleData.push_debit,
+ txState: {
+ major: TransactionMajorState.Pending,
+ },
+ },
});
diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx
index 8a97ad50c..1f0293352 100644
--- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx
@@ -1,233 +1,2030 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { AmountJson, AmountLike, Amounts, i18n, Transaction, TransactionType } from "@gnu-taler/taler-util";
-import { format } from "date-fns";
-import { Fragment, JSX, VNode, h } from "preact";
-import { route } from 'preact-router';
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ AmountString,
+ DenomLossEventType,
+ MerchantInfo,
+ NotificationType,
+ OrderShortInfo,
+ parsePaytoUri,
+ PaytoUri,
+ stringifyPaytoUri,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ Transaction,
+ TransactionAction,
+ TransactionDeposit,
+ TransactionIdStr,
+ TransactionInternalWithdrawal,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionType,
+ TransactionWithdrawal,
+ TranslatedString,
+ WithdrawalType,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { styled } from "@linaria/react";
+import { isPast } from "date-fns";
+import { ComponentChildren, Fragment, h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
-import * as wxApi from "../wxApi";
-import { Pages } from "../NavigationBar";
-import emptyImg from "../../static/img/empty.png"
-import { Button, ButtonBox, ButtonBoxDestructive, ButtonDestructive, ButtonPrimary, ExtraLargeText, FontIcon, LargeText, ListOfProducts, PopupBox, Row, RowBorderGray, SmallLightText, WalletBox, WarningBox } from "../components/styled";
-import { ErrorMessage } from "../components/ErrorMessage";
-import { Part } from "../components/Part";
-
-export function TransactionPage({ tid }: { tid: string; }): JSX.Element {
- const [transaction, setTransaction] = useState<
- Transaction | undefined
- >(undefined);
-
- useEffect(() => {
- const fetchData = async (): Promise<void> => {
- const res = await wxApi.getTransactions();
- const ts = res.transactions.filter(t => t.transactionId === tid);
- if (ts.length === 1) {
- setTransaction(ts[0]);
- } else {
- route(Pages.history);
- }
- };
- fetchData();
- }, []);
+import { Amount } from "../components/Amount.js";
+import { BankDetailsByPaytoType } from "../components/BankDetailsByPaytoType.js";
+import { AlertView, ErrorAlertView } from "../components/CurrentAlerts.js";
+import { EnabledBySettings } from "../components/EnabledBySettings.js";
+import { Loading } from "../components/Loading.js";
+import { Kind, Part, PartPayto } from "../components/Part.js";
+import { QR } from "../components/QR.js";
+import { ShowFullContractTermPopup } from "../components/ShowFullContractTermPopup.js";
+import {
+ CenteredDialog,
+ ErrorBox,
+ InfoBox,
+ Link,
+ Overlay,
+ SmallLightText,
+ SubTitle,
+ SvgIcon,
+ WarningBox,
+} from "../components/styled/index.js";
+import { Time } from "../components/Time.js";
+import { alertFromError, useAlertContext } from "../context/alert.js";
+import { useBackendContext } from "../context/backend.js";
+import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
+import { useSettings } from "../hooks/useSettings.js";
+import { Button } from "../mui/Button.js";
+import { SafeHandler } from "../mui/handlers.js";
+import { Pages } from "../NavigationBar.js";
+import refreshIcon from "../svg/refresh_24px.inline.svg";
+import { assertUnreachable } from "../utils/index.js";
+
+interface Props {
+ tid: string;
+ goToWalletHistory: (currency?: string) => Promise<void>;
+}
+
+export function TransactionPage({ tid, goToWalletHistory }: Props): VNode {
+ const transactionId = tid as TransactionIdStr; //FIXME: validate
+ const { i18n } = useTranslationContext();
+ const api = useBackendContext();
+ const state = useAsyncAsHook(
+ () =>
+ api.wallet.call(WalletApiOperation.GetTransactionById, {
+ transactionId,
+ }),
+ [transactionId],
+ );
+
+ useEffect(() =>
+ api.listener.onUpdateNotification(
+ [NotificationType.TransactionStateTransition],
+ state?.retry,
+ ),
+ );
- if (!transaction) {
- return <div><i18n.Translate>Loading ...</i18n.Translate></div>;
+ if (!state) {
+ return <Loading />;
}
- return <TransactionView
- transaction={transaction}
- onDelete={() => wxApi.deleteTransaction(tid).then(_ => history.go(-1))}
- onRetry={() => wxApi.retryTransaction(tid).then(_ => history.go(-1))}
- onBack={() => { route(Pages.history) }} />;
+
+ if (state.hasError) {
+ return (
+ <ErrorAlertView
+ error={alertFromError(
+ i18n,
+ i18n.str`Could not load transaction information`,
+ state,
+ )}
+ />
+ );
+ }
+
+ const currency = Amounts.parse(state.response.amountRaw)?.currency;
+
+ return (
+ <TransactionView
+ transaction={state.response}
+ onCancel={async () => {
+ await api.wallet.call(WalletApiOperation.FailTransaction, {
+ transactionId,
+ });
+ goToWalletHistory(currency);
+ }}
+ onSuspend={async () => {
+ await api.wallet.call(WalletApiOperation.SuspendTransaction, {
+ transactionId,
+ });
+ goToWalletHistory(currency);
+ }}
+ onResume={async () => {
+ await api.wallet.call(WalletApiOperation.ResumeTransaction, {
+ transactionId,
+ });
+ goToWalletHistory(currency);
+ }}
+ onAbort={async () => {
+ await api.wallet.call(WalletApiOperation.AbortTransaction, {
+ transactionId,
+ });
+ goToWalletHistory(currency);
+ }}
+ onRetry={async () => {
+ await api.wallet.call(WalletApiOperation.RetryTransaction, {
+ transactionId,
+ });
+ goToWalletHistory(currency);
+ }}
+ onDelete={async () => {
+ await api.wallet.call(WalletApiOperation.DeleteTransaction, {
+ transactionId,
+ });
+ goToWalletHistory(currency);
+ }}
+ onRefund={async (transactionId) => {
+ await api.wallet.call(WalletApiOperation.StartRefundQuery, {
+ transactionId,
+ });
+ }}
+ onBack={() => goToWalletHistory(currency)}
+ />
+ );
}
export interface WalletTransactionProps {
transaction: Transaction;
- onDelete: () => void;
- onRetry: () => void;
- onBack: () => void;
+ onCancel: () => Promise<void>;
+ onSuspend: () => Promise<void>;
+ onResume: () => Promise<void>;
+ onAbort: () => Promise<void>;
+ onDelete: () => Promise<void>;
+ onRetry: () => Promise<void>;
+ onRefund: (id: TransactionIdStr) => Promise<void>;
+ onBack: () => Promise<void>;
}
-export function TransactionView({ transaction, onDelete, onRetry, onBack }: WalletTransactionProps) {
+const PurchaseDetailsTable = styled.table`
+ width: 100%;
- function TransactionTemplate({ children }: { children: VNode[] }) {
- return <WalletBox>
- <section style={{ padding: 8, textAlign: 'center'}}>
- <ErrorMessage title={transaction?.error?.hint} />
- {transaction.pending && <WarningBox>This transaction is not completed</WarningBox>}
- </section>
- <section>
- <div style={{ textAlign: 'center' }}>
- {children}
- </div>
+ & > tr > td:nth-child(2n) {
+ text-align: right;
+ }
+`;
+
+type TransactionTemplateProps = Omit<
+ Omit<WalletTransactionProps, "onRefund">,
+ "onBack"
+> & {
+ children: ComponentChildren;
+};
+
+function TransactionTemplate({
+ transaction,
+ onDelete,
+ onRetry,
+ onAbort,
+ onResume,
+ onSuspend,
+ onCancel,
+ children,
+}: TransactionTemplateProps): VNode {
+ const { i18n } = useTranslationContext();
+ const [confirmBeforeForget, setConfirmBeforeForget] = useState(false);
+ const [confirmBeforeCancel, setConfirmBeforeCancel] = useState(false);
+ const { safely } = useAlertContext();
+ const [settings] = useSettings();
+
+ async function doCheckBeforeForget(): Promise<void> {
+ if (
+ transaction.txState.major === TransactionMajorState.Pending &&
+ transaction.type === TransactionType.Withdrawal
+ ) {
+ setConfirmBeforeForget(true);
+ } else {
+ onDelete();
+ }
+ }
+
+ async function doCheckBeforeCancel(): Promise<void> {
+ setConfirmBeforeCancel(true);
+ }
+
+ const showButton = getShowButtonStates(transaction);
+
+ return (
+ <Fragment>
+ <section style={{ padding: 8, textAlign: "center" }}>
+ {transaction?.error &&
+ // FIXME: wallet core should stop sending this error on KYC
+ transaction.error.code !==
+ TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED ? (
+ <ErrorAlertView
+ error={alertFromError(
+ i18n,
+ i18n.str`There was an error trying to complete the transaction.`,
+ transaction.error,
+ )}
+ />
+ ) : undefined}
+ {transaction.txState.major === TransactionMajorState.Pending &&
+ (transaction.txState.minor === TransactionMinorState.KycRequired ? (
+ <AlertView
+ alert={{
+ type: "warning",
+ message: i18n.str`KYC check required for the transaction to complete.`,
+ description:
+ transaction.kycUrl &&
+ typeof transaction.kycUrl === "string" ? (
+ <div>
+ <i18n.Translate>
+ Follow this link to the{` `}
+ <a
+ rel="noreferrer"
+ target="_bank"
+ href={transaction.kycUrl}
+ >
+ KYC verifier.
+ </a>
+ </i18n.Translate>
+ </div>
+ ) : (
+ i18n.str`No additional information has been provided.`
+ ),
+ }}
+ />
+ ) : transaction.txState.minor ===
+ TransactionMinorState.AmlRequired ? (
+ <WarningBox>
+ <i18n.Translate>
+ The transaction has been blocked since the account required an
+ AML check.
+ </i18n.Translate>
+ </WarningBox>
+ ) : (
+ <WarningBox>
+ <div style={{ justifyContent: "center", lineHeight: "25px" }}>
+ <i18n.Translate>
+ This transaction is not completed
+ </i18n.Translate>
+ <Link onClick={onRetry} style={{ padding: 0 }}>
+ <SvgIcon
+ title={i18n.str`Retry`}
+ dangerouslySetInnerHTML={{ __html: refreshIcon }}
+ color="black"
+ />
+ </Link>
+ </div>
+ </WarningBox>
+ ))}
+ {transaction.txState.major === TransactionMajorState.Aborted && (
+ <InfoBox>
+ <i18n.Translate>This transaction was aborted.</i18n.Translate>
+ </InfoBox>
+ )}
+ {transaction.txState.major === TransactionMajorState.Failed && (
+ <ErrorBox>
+ <i18n.Translate>This transaction failed.</i18n.Translate>
+ </ErrorBox>
+ )}
+ {confirmBeforeForget ? (
+ <Overlay>
+ <CenteredDialog>
+ <header>
+ <i18n.Translate>Caution!</i18n.Translate>
+ </header>
+ <section>
+ <i18n.Translate>
+ If you have already wired money to the exchange you will loose
+ the chance to get the coins form it.
+ </i18n.Translate>
+ </section>
+ <footer>
+ <Button
+ variant="contained"
+ color="secondary"
+ onClick={
+ (async () =>
+ setConfirmBeforeForget(false)) as SafeHandler<void>
+ }
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+
+ <Button
+ variant="contained"
+ color="error"
+ onClick={safely("delete transaction", onDelete)}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </Button>
+ </footer>
+ </CenteredDialog>
+ </Overlay>
+ ) : undefined}
+ {confirmBeforeCancel ? (
+ <Overlay>
+ <CenteredDialog>
+ <header>
+ <i18n.Translate>Caution!</i18n.Translate>
+ </header>
+ <section>
+ <i18n.Translate>
+ Doing a cancellation while the transaction still active might
+ result in lost coins. Do you still want to cancel the
+ transaction?
+ </i18n.Translate>
+ </section>
+ <footer>
+ <Button
+ variant="contained"
+ color="secondary"
+ onClick={
+ (async () =>
+ setConfirmBeforeCancel(false)) as SafeHandler<void>
+ }
+ >
+ <i18n.Translate>No</i18n.Translate>
+ </Button>
+
+ <Button
+ variant="contained"
+ color="error"
+ onClick={safely("cancel active transaction", onCancel)}
+ >
+ <i18n.Translate>Yes</i18n.Translate>
+ </Button>
+ </footer>
+ </CenteredDialog>
+ </Overlay>
+ ) : undefined}
</section>
+ <section>{children}</section>
<footer>
- <ButtonBox onClick={onBack}><i18n.Translate> <FontIcon>&#x2190;</FontIcon> </i18n.Translate></ButtonBox>
+ <div />
<div>
- {transaction?.error ? <ButtonPrimary onClick={onRetry}><i18n.Translate>retry</i18n.Translate></ButtonPrimary> : null}
- <ButtonBoxDestructive onClick={onDelete}><i18n.Translate>&#x1F5D1;</i18n.Translate></ButtonBoxDestructive>
+ {showButton.abort && (
+ <Button
+ variant="contained"
+ onClick={safely("abort transaction", onAbort)}
+ >
+ <i18n.Translate>Abort</i18n.Translate>
+ </Button>
+ )}
+ {showButton.resume && settings.suspendIndividualTransaction && (
+ <Button
+ variant="contained"
+ onClick={safely("resume transaction", onResume)}
+ >
+ <i18n.Translate>Resume</i18n.Translate>
+ </Button>
+ )}
+ {showButton.suspend && settings.suspendIndividualTransaction && (
+ <Button
+ variant="contained"
+ onClick={safely("suspend transaction", onSuspend)}
+ >
+ <i18n.Translate>Suspend</i18n.Translate>
+ </Button>
+ )}
+ {showButton.fail && (
+ <Button
+ variant="contained"
+ color="error"
+ onClick={doCheckBeforeCancel as SafeHandler<void>}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ )}
+ {showButton.remove && (
+ <Button
+ variant="contained"
+ color="error"
+ onClick={doCheckBeforeForget as SafeHandler<void>}
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </Button>
+ )}
</div>
</footer>
- </WalletBox>
- }
+ </Fragment>
+ );
+}
- function amountToString(text: AmountLike) {
- const aj = Amounts.jsonifyAmount(text)
- const amount = Amounts.stringifyValue(aj)
- return `${amount} ${aj.currency}`
- }
+export function TransactionView({
+ transaction,
+ onDelete,
+ onAbort,
+ // onBack,
+ onResume,
+ onSuspend,
+ onRetry,
+ onRefund,
+ onCancel,
+}: WalletTransactionProps): VNode {
+ const { i18n } = useTranslationContext();
+ const { safely } = useAlertContext();
+ const raw = Amounts.parseOrThrow(transaction.amountRaw);
+ const effective = Amounts.parseOrThrow(transaction.amountEffective);
- if (transaction.type === TransactionType.Withdrawal) {
- const fee = Amounts.sub(
- Amounts.parseOrThrow(transaction.amountRaw),
- Amounts.parseOrThrow(transaction.amountEffective),
- ).amount
- return <TransactionTemplate>
- <h2>Withdrawal</h2>
- <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div>
- <br />
- <Part title="Total withdrawn" text={amountToString(transaction.amountEffective)} kind='positive' />
- <Part title="Chosen amount" text={amountToString(transaction.amountRaw)} kind='neutral' />
- <Part title="Exchange fee" text={amountToString(fee)} kind='negative' />
- <Part title="Exchange" text={new URL(transaction.exchangeBaseUrl).hostname} kind='neutral' />
- </TransactionTemplate>
- }
-
- const showLargePic = () => {
+ if (
+ transaction.type === TransactionType.Withdrawal ||
+ transaction.type === TransactionType.InternalWithdrawal
+ ) {
+ // const conversion =
+ // transaction.withdrawalDetails.type === WithdrawalType.ManualTransfer
+ // ? transaction.withdrawalDetails.exchangeCreditAccountDetails ?? []
+ // : [];
+ const blockedByKycOrAml =
+ transaction.txState.minor === TransactionMinorState.KycRequired ||
+ transaction.txState.minor === TransactionMinorState.AmlRequired;
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Withdrawal`}
+ total={effective}
+ kind="positive"
+ >
+ {transaction.exchangeBaseUrl}
+ </Header>
+ {transaction.txState.major !== TransactionMajorState.Pending ||
+ blockedByKycOrAml ? undefined : transaction.withdrawalDetails.type ===
+ WithdrawalType.ManualTransfer &&
+ transaction.withdrawalDetails.exchangeCreditAccountDetails ? (
+ <Fragment>
+ <InfoBox>
+ {transaction.withdrawalDetails.exchangeCreditAccountDetails
+ .length > 1 ? (
+ <span>
+ <i18n.Translate>
+ Now the payment service provider is waiting for{" "}
+ <Amount value={raw} /> to be transferred. Select one of the
+ accounts and use the information below to complete the
+ operation by making a wire transfer from your bank account.
+ </i18n.Translate>
+ </span>
+ ) : (
+ <span>
+ <i18n.Translate>
+ Now the payment service provider is waiting for{" "}
+ <Amount value={raw} /> to be transferred. Use the
+ information below to complete the operation by making a wire
+ transfer from your bank account.
+ </i18n.Translate>
+ </span>
+ )}
+ </InfoBox>
+ <BankDetailsByPaytoType
+ amount={raw}
+ accounts={
+ transaction.withdrawalDetails.exchangeCreditAccountDetails ?? []
+ }
+ subject={transaction.withdrawalDetails.reservePub}
+ />
+ </Fragment>
+ ) : (
+ //integrated bank withdrawal
+ <ShowWithdrawalDetailForBankIntegrated transaction={transaction} />
+ )}
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <WithdrawDetails
+ amount={getAmountWithFee(effective, raw, "credit")}
+ />
+ }
+ />
+ </TransactionTemplate>
+ );
}
if (transaction.type === TransactionType.Payment) {
- const fee = Amounts.sub(
- Amounts.parseOrThrow(transaction.amountEffective),
- Amounts.parseOrThrow(transaction.amountRaw),
- ).amount
-
- return <TransactionTemplate>
- <h2>Payment </h2>
- <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div>
- <br />
- <Part big title="Total paid" text={amountToString(transaction.amountEffective)} kind='negative' />
- <Part big title="Purchase amount" text={amountToString(transaction.amountRaw)} kind='neutral' />
- <Part big title="Fee" text={amountToString(fee)} kind='negative' />
- <Part title="Merchant" text={transaction.info.merchant.name} kind='neutral' />
- <Part title="Purchase" text={transaction.info.summary} kind='neutral' />
- <Part title="Receipt" text={`#${transaction.info.orderId}`} kind='neutral' />
+ const pendingRefund =
+ transaction.refundPending === undefined
+ ? undefined
+ : Amounts.parseOrThrow(transaction.refundPending);
- <div>
- {transaction.info.products && transaction.info.products.length > 0 &&
- <ListOfProducts>
- {transaction.info.products.map((p, k) => <RowBorderGray key={k}>
- <a href="#" onClick={showLargePic}>
- <img src={p.image ? p.image : emptyImg} />
- </a>
+ const effectiveRefund = Amounts.parseOrThrow(
+ transaction.totalRefundEffective,
+ );
+
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onRetry={onRetry}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ total={effective}
+ type={i18n.str`Payment`}
+ kind="negative"
+ >
+ {transaction.info.fulfillmentUrl ? (
+ <a
+ href={transaction.info.fulfillmentUrl}
+ target="_bank"
+ rel="noreferrer"
+ >
+ {transaction.info.summary}
+ </a>
+ ) : (
+ transaction.info.summary
+ )}
+ </Header>
+ <br />
+ {transaction.refunds.length > 0 ? (
+ <Part
+ title={i18n.str`Refunds`}
+ text={
+ <table>
+ {transaction.refunds.map((r, i) => {
+ return (
+ <tr key={i}>
+ <td>
+ <i18n.Translate>
+ {<Amount value={r.amountEffective} />}{" "}
+ <a
+ href={Pages.balanceTransaction({
+ tid: r.transactionId,
+ })}
+ >
+ was refunded
+ </a>{" "}
+ on{" "}
+ {
+ <Time
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ r.timestamp,
+ )}
+ format="dd MMMM yyyy"
+ />
+ }
+ .
+ </i18n.Translate>
+ </td>
+ </tr>
+ );
+ })}
+ </table>
+ }
+ kind="neutral"
+ />
+ ) : undefined}
+ {pendingRefund !== undefined && Amounts.isNonZero(pendingRefund) && (
+ <InfoBox>
+ {transaction.refundQueryActive ? (
+ <i18n.Translate>Refund is in progress.</i18n.Translate>
+ ) : (
+ <i18n.Translate>
+ Merchant created a refund for this order but was not
+ automatically picked up.
+ </i18n.Translate>
+ )}
+ <Part
+ title={i18n.str`Offer`}
+ text={<Amount value={pendingRefund} />}
+ kind="positive"
+ />
+ {transaction.refundQueryActive ? undefined : (
<div>
- {p.quantity && p.quantity > 0 && <SmallLightText>x {p.quantity} {p.unit}</SmallLightText>}
- <div>{p.description}</div>
+ <div />
+ <div>
+ <Button
+ variant="contained"
+ onClick={safely("refund transaction", () =>
+ onRefund(transaction.transactionId),
+ )}
+ >
+ <i18n.Translate>Accept</i18n.Translate>
+ </Button>
+ </div>
</div>
- </RowBorderGray>)}
- </ListOfProducts>
- }
- </div>
- </TransactionTemplate>
+ )}
+ </InfoBox>
+ )}
+ {transaction.posConfirmation ? (
+ <AlertView
+ alert={{
+ type: "info",
+ message: i18n.str`Confirmation code`,
+ description: <pre>{transaction.posConfirmation}</pre>,
+ }}
+ />
+ ) : undefined}
+ <Part
+ title={i18n.str`Merchant`}
+ text={<MerchantDetails merchant={transaction.info.merchant} />}
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Invoice ID`}
+ text={transaction.info.orderId as TranslatedString}
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <PurchaseDetails
+ price={getAmountWithFee(effective, raw, "debit")}
+ effectiveRefund={effectiveRefund}
+ info={transaction.info}
+ />
+ }
+ kind="neutral"
+ />
+ <ShowFullContractTermPopup transactionId={transaction.transactionId} />
+ </TransactionTemplate>
+ );
}
if (transaction.type === TransactionType.Deposit) {
- const fee = Amounts.sub(
- Amounts.parseOrThrow(transaction.amountRaw),
- Amounts.parseOrThrow(transaction.amountEffective),
- ).amount
- return <TransactionTemplate>
- <h2>Deposit </h2>
- <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div>
- <br />
- <Part big title="Total deposit" text={amountToString(transaction.amountEffective)} kind='negative' />
- <Part big title="Purchase amount" text={amountToString(transaction.amountRaw)} kind='neutral' />
- <Part big title="Fee" text={amountToString(fee)} kind='negative' />
- </TransactionTemplate>
+ const payto = parsePaytoUri(transaction.targetPaytoUri);
+
+ const wireTime = AbsoluteTime.fromProtocolTimestamp(
+ transaction.wireTransferDeadline,
+ );
+ const shouldBeWired = wireTime.t_ms !== "never" && isPast(wireTime.t_ms);
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Deposit`}
+ total={effective}
+ kind="negative"
+ >
+ {!payto ? transaction.targetPaytoUri : <NicePayto payto={payto} />}
+ </Header>
+ {payto && <PartPayto payto={payto} kind="neutral" />}
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <DepositDetails
+ amount={getAmountWithFee(effective, raw, "debit")}
+ />
+ }
+ kind="neutral"
+ />
+ {!shouldBeWired ? (
+ <Part
+ title={i18n.str`Wire transfer deadline.`}
+ text={
+ <Time timestamp={wireTime} format="dd MMMM yyyy 'at' HH:mm" />
+ }
+ kind="neutral"
+ />
+ ) : transaction.wireTransferProgress === 0 ? (
+ <AlertView
+ alert={{
+ type: "warning",
+ message: i18n.str`Wire transfer is not initiated.`,
+ description: i18n.str` `,
+ }}
+ />
+ ) : transaction.wireTransferProgress === 100 ? (
+ <Fragment>
+ <AlertView
+ alert={{
+ type: "success",
+ message: i18n.str`Wire transfer completed.`,
+ description: i18n.str` `,
+ }}
+ />
+ <Part
+ title={i18n.str`Transfer details`}
+ text={
+ <TrackingDepositDetails
+ trackingState={transaction.trackingState}
+ />
+ }
+ kind="neutral"
+ />
+ </Fragment>
+ ) : (
+ <AlertView
+ alert={{
+ type: "info",
+ message: i18n.str`Wire transfer in progress.`,
+ description: i18n.str` `,
+ }}
+ />
+ )}
+ </TransactionTemplate>
+ );
}
if (transaction.type === TransactionType.Refresh) {
- const fee = Amounts.sub(
- Amounts.parseOrThrow(transaction.amountRaw),
- Amounts.parseOrThrow(transaction.amountEffective),
- ).amount
- return <TransactionTemplate>
- <h2>Refresh</h2>
- <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div>
- <br />
- <Part big title="Total refresh" text={amountToString(transaction.amountEffective)} kind='negative' />
- <Part big title="Refresh amount" text={amountToString(transaction.amountRaw)} kind='neutral' />
- <Part big title="Fee" text={amountToString(fee)} kind='negative' />
- </TransactionTemplate>
- }
-
- if (transaction.type === TransactionType.Tip) {
- const fee = Amounts.sub(
- Amounts.parseOrThrow(transaction.amountRaw),
- Amounts.parseOrThrow(transaction.amountEffective),
- ).amount
- return <TransactionTemplate>
- <h2>Tip</h2>
- <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div>
- <br />
- <Part big title="Total tip" text={amountToString(transaction.amountEffective)} kind='positive' />
- <Part big title="Received amount" text={amountToString(transaction.amountRaw)} kind='neutral' />
- <Part big title="Fee" text={amountToString(fee)} kind='negative' />
- </TransactionTemplate>
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Refresh`}
+ total={effective}
+ kind="negative"
+ >
+ {"Refresh"}
+ </Header>
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <RefreshDetails
+ amount={getAmountWithFee(effective, raw, "debit")}
+ />
+ }
+ />
+ </TransactionTemplate>
+ );
}
if (transaction.type === TransactionType.Refund) {
- const fee = Amounts.sub(
- Amounts.parseOrThrow(transaction.amountRaw),
- Amounts.parseOrThrow(transaction.amountEffective),
- ).amount
- return <TransactionTemplate>
- <h2>Refund</h2>
- <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div>
- <br />
- <Part big title="Total refund" text={amountToString(transaction.amountEffective)} kind='positive' />
- <Part big title="Refund amount" text={amountToString(transaction.amountRaw)} kind='neutral' />
- <Part big title="Fee" text={amountToString(fee)} kind='negative' />
- <Part title="Merchant" text={transaction.info.merchant.name} kind='neutral' />
- <Part title="Purchase" text={transaction.info.summary} kind='neutral' />
- <Part title="Receipt" text={`#${transaction.info.orderId}`} kind='neutral' />
-
- <p>
- {transaction.info.summary}
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Refund`}
+ total={effective}
+ kind="positive"
+ >
+ {transaction.paymentInfo ? (
+ <a
+ href={Pages.balanceTransaction({
+ tid: transaction.refundedTransactionId,
+ })}
+ >
+ {transaction.paymentInfo.summary}
+ </a>
+ ) : (
+ <span style={{ color: "gray" }}>-- deleted --</span>
+ )}
+ </Header>
+
+ <Part
+ title={i18n.str`Merchant`}
+ text={
+ (transaction.paymentInfo
+ ? transaction.paymentInfo.merchant.name
+ : "-- deleted --") as TranslatedString
+ }
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Purchase summary`}
+ text={
+ (transaction.paymentInfo
+ ? transaction.paymentInfo.summary
+ : "-- deleted --") as TranslatedString
+ }
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <RefundDetails
+ amount={getAmountWithFee(effective, raw, "credit")}
+ />
+ }
+ />
+ </TransactionTemplate>
+ );
+ }
+
+ if (transaction.type === TransactionType.PeerPullCredit) {
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Credit`}
+ total={effective}
+ kind="positive"
+ >
+ <i18n.Translate>Invoice</i18n.Translate>
+ </Header>
+
+ {transaction.info.summary ? (
+ <Part
+ title={i18n.str`Subject`}
+ text={transaction.info.summary as TranslatedString}
+ kind="neutral"
+ />
+ ) : undefined}
+ <Part
+ title={i18n.str`Exchange`}
+ text={transaction.exchangeBaseUrl as TranslatedString}
+ kind="neutral"
+ />
+ {transaction.txState.major === TransactionMajorState.Pending &&
+ transaction.txState.minor === TransactionMinorState.Ready &&
+ transaction.talerUri &&
+ !transaction.error && (
+ <Part
+ title={i18n.str`URI`}
+ text={<ShowQrWithCopy text={transaction.talerUri} />}
+ kind="neutral"
+ />
+ )}
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <InvoiceCreationDetails
+ amount={getAmountWithFee(effective, raw, "credit")}
+ />
+ }
+ />
+ </TransactionTemplate>
+ );
+ }
+
+ if (transaction.type === TransactionType.PeerPullDebit) {
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Debit`}
+ total={effective}
+ kind="negative"
+ >
+ <i18n.Translate>Invoice</i18n.Translate>
+ </Header>
+
+ {transaction.info.summary ? (
+ <Part
+ title={i18n.str`Subject`}
+ text={transaction.info.summary as TranslatedString}
+ kind="neutral"
+ />
+ ) : undefined}
+ <Part
+ title={i18n.str`Exchange`}
+ text={transaction.exchangeBaseUrl as TranslatedString}
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <InvoicePaymentDetails
+ amount={getAmountWithFee(effective, raw, "debit")}
+ />
+ }
+ />
+ </TransactionTemplate>
+ );
+ }
+
+ if (transaction.type === TransactionType.PeerPushDebit) {
+ const total = Amounts.parseOrThrow(transaction.amountEffective);
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Debit`}
+ total={total}
+ kind="negative"
+ >
+ <i18n.Translate>Transfer</i18n.Translate>
+ </Header>
+
+ {transaction.info.summary ? (
+ <Part
+ title={i18n.str`Subject`}
+ text={transaction.info.summary as TranslatedString}
+ kind="neutral"
+ />
+ ) : undefined}
+ <Part
+ title={i18n.str`Exchange`}
+ text={transaction.exchangeBaseUrl as TranslatedString}
+ kind="neutral"
+ />
+ {transaction.talerUri && (
+ <Part
+ title={i18n.str`URI`}
+ text={<ShowQrWithCopy text={transaction.talerUri} />}
+ kind="neutral"
+ />
+ )}
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <TransferCreationDetails
+ amount={getAmountWithFee(effective, raw, "debit")}
+ />
+ }
+ />
+ </TransactionTemplate>
+ );
+ }
+
+ if (transaction.type === TransactionType.PeerPushCredit) {
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Credit`}
+ total={effective}
+ kind="positive"
+ >
+ <i18n.Translate>Transfer</i18n.Translate>
+ </Header>
+
+ {transaction.info.summary ? (
+ <Part
+ title={i18n.str`Subject`}
+ text={transaction.info.summary as TranslatedString}
+ kind="neutral"
+ />
+ ) : undefined}
+ <Part
+ title={i18n.str`Exchange`}
+ text={transaction.exchangeBaseUrl as TranslatedString}
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Details`}
+ text={
+ <TransferPickupDetails
+ amount={getAmountWithFee(effective, raw, "credit")}
+ />
+ }
+ />
+ </TransactionTemplate>
+ );
+ }
+
+ if (transaction.type === TransactionType.DenomLoss) {
+ switch (transaction.lossEventType) {
+ case DenomLossEventType.DenomExpired: {
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Debit`}
+ total={effective}
+ kind="negative"
+ >
+ <i18n.Translate>Lost</i18n.Translate>
+ </Header>
+
+ <Part
+ title={i18n.str`Exchange`}
+ text={transaction.exchangeBaseUrl as TranslatedString}
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Reason`}
+ text={i18n.str`Denomination expired.`}
+ />
+ </TransactionTemplate>
+ );
+ }
+ case DenomLossEventType.DenomVanished: {
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Debit`}
+ total={effective}
+ kind="negative"
+ >
+ <i18n.Translate>Lost</i18n.Translate>
+ </Header>
+
+ <Part
+ title={i18n.str`Exchange`}
+ text={transaction.exchangeBaseUrl as TranslatedString}
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Reason`}
+ text={i18n.str`Denomination vanished.`}
+ />
+ </TransactionTemplate>
+ );
+ }
+ case DenomLossEventType.DenomUnoffered: {
+ return (
+ <TransactionTemplate
+ transaction={transaction}
+ onDelete={onDelete}
+ onRetry={onRetry}
+ onAbort={onAbort}
+ onResume={onResume}
+ onSuspend={onSuspend}
+ onCancel={onCancel}
+ >
+ <Header
+ timestamp={transaction.timestamp}
+ type={i18n.str`Debit`}
+ total={effective}
+ kind="negative"
+ >
+ <i18n.Translate>Lost</i18n.Translate>
+ </Header>
+
+ <Part
+ title={i18n.str`Exchange`}
+ text={transaction.exchangeBaseUrl as TranslatedString}
+ kind="neutral"
+ />
+ <Part
+ title={i18n.str`Reason`}
+ text={i18n.str`Denomination is unoffered.`}
+ />
+ </TransactionTemplate>
+ );
+ }
+ default: {
+ assertUnreachable(transaction.lossEventType);
+ }
+ }
+ }
+ if (transaction.type === TransactionType.Recoup) {
+ throw Error("recoup transaction not implemented");
+ }
+ assertUnreachable(transaction);
+}
+
+export function MerchantDetails({
+ merchant,
+}: {
+ merchant: MerchantInfo;
+}): VNode {
+ return (
+ <div style={{ display: "flex", flexDirection: "row" }}>
+ {merchant.logo && (
+ <div>
+ <img
+ src={merchant.logo}
+ style={{ width: 64, height: 64, margin: 4 }}
+ />
+ </div>
+ )}
+ <div>
+ <p style={{ marginTop: 0 }}>{merchant.name}</p>
+ {merchant.website && (
+ <a
+ href={merchant.website}
+ target="_blank"
+ style={{ textDecorationColor: "gray" }}
+ rel="noreferrer"
+ >
+ <SmallLightText>{merchant.website}</SmallLightText>
+ </a>
+ )}
+ {merchant.email && (
+ <a
+ href={`mailto:${merchant.email}`}
+ style={{ textDecorationColor: "gray" }}
+ >
+ <SmallLightText>{merchant.email}</SmallLightText>
+ </a>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export function ExchangeDetails({ exchange }: { exchange: string }): VNode {
+ return (
+ <div>
+ <p style={{ marginTop: 0 }}>
+ <a rel="noreferrer" target="_blank" href={exchange}>
+ {exchange}
+ </a>
</p>
+ </div>
+ );
+}
+
+export interface AmountWithFee {
+ value: AmountJson;
+ fee: AmountJson;
+ total: AmountJson;
+ maxFrac: number;
+}
+
+export function getAmountWithFee(
+ effective: AmountJson,
+ raw: AmountJson,
+ direction: "credit" | "debit",
+): AmountWithFee {
+ const total = direction === "credit" ? effective : raw;
+ const value = direction === "debit" ? effective : raw;
+ const fee = Amounts.sub(value, total).amount;
+
+ const maxFrac = [effective, raw, fee]
+ .map((a) => Amounts.maxFractionalDigits(a))
+ .reduce((c, p) => Math.max(c, p), 0);
+
+ return {
+ total,
+ value,
+ fee,
+ maxFrac,
+ };
+}
+
+export function InvoiceCreationDetails({
+ amount,
+}: {
+ amount: AmountWithFee;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <PurchaseDetailsTable>
+ <tr>
+ <td>
+ <i18n.Translate>Invoice</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.value} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+
+ {Amounts.isNonZero(amount.fee) && (
+ <Fragment>
+ <tr>
+ <td>
+ <i18n.Translate>Fees</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.fee} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Total</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.total} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ </Fragment>
+ )}
+ </PurchaseDetailsTable>
+ );
+}
+
+export function InvoicePaymentDetails({
+ amount,
+}: {
+ amount: AmountWithFee;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <PurchaseDetailsTable>
+ <tr>
+ <td>
+ <i18n.Translate>Invoice</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.total} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+
+ {Amounts.isNonZero(amount.fee) && (
+ <Fragment>
+ <tr>
+ <td>
+ <i18n.Translate>Fees</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.fee} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Total</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.value} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ </Fragment>
+ )}
+ </PurchaseDetailsTable>
+ );
+}
+
+export function TransferCreationDetails({
+ amount,
+}: {
+ amount: AmountWithFee;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <PurchaseDetailsTable>
+ <tr>
+ <td>
+ <i18n.Translate>Sent</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.value} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+
+ {Amounts.isNonZero(amount.fee) && (
+ <Fragment>
+ <tr>
+ <td>
+ <i18n.Translate>Fees</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.fee} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Transfer</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.total} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ </Fragment>
+ )}
+ </PurchaseDetailsTable>
+ );
+}
+
+export function TransferPickupDetails({
+ amount,
+}: {
+ amount: AmountWithFee;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <PurchaseDetailsTable>
+ <tr>
+ <td>
+ <i18n.Translate>Transfer</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.value} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+
+ {Amounts.isNonZero(amount.fee) && (
+ <Fragment>
+ <tr>
+ <td>
+ <i18n.Translate>Fees</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.fee} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Total</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.total} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ </Fragment>
+ )}
+ </PurchaseDetailsTable>
+ );
+}
+
+export function WithdrawDetails({
+ conversion,
+ amount,
+}: {
+ conversion?: AmountJson;
+ amount: AmountWithFee;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <PurchaseDetailsTable>
+ {conversion ? (
+ <Fragment>
+ <tr>
+ <td>
+ <i18n.Translate>Transfer</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={conversion} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ {conversion.fraction === amount.value.fraction &&
+ conversion.value === amount.value.value ? undefined : (
+ <tr>
+ <td>
+ <i18n.Translate>Converted</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.value} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ )}
+ </Fragment>
+ ) : (
+ <tr>
+ <td>
+ <i18n.Translate>Transfer</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.value} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ )}
+ {Amounts.isNonZero(amount.fee) && (
+ <Fragment>
+ <tr>
+ <td>
+ <i18n.Translate>Fees</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.fee} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Total</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.total} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ </Fragment>
+ )}
+ </PurchaseDetailsTable>
+ );
+}
+
+export function PurchaseDetails({
+ price,
+ effectiveRefund,
+ info: _info,
+}: {
+ price: AmountWithFee;
+ effectiveRefund?: AmountJson;
+ info: OrderShortInfo;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ const total = Amounts.add(price.value, price.fee).amount;
+
+ return (
+ <PurchaseDetailsTable>
+ <tr>
+ <td>
+ <i18n.Translate>Price</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={price.total} />
+ </td>
+ </tr>
+ {Amounts.isNonZero(price.fee) && (
+ <Fragment>
+ <tr>
+ <td>
+ <i18n.Translate>Fees</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={price.fee} />
+ </td>
+ </tr>
+ {effectiveRefund && Amounts.isNonZero(effectiveRefund) ? (
+ <Fragment>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Subtotal</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={price.total} />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Refunded</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={effectiveRefund} negative />
+ </td>
+ </tr>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Total</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={Amounts.sub(total, effectiveRefund).amount} />
+ </td>
+ </tr>
+ </Fragment>
+ ) : (
+ <Fragment>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Total</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={price.value} />
+ </td>
+ </tr>
+ </Fragment>
+ )}
+ </Fragment>
+ )}
+
+ {/* {hasProducts && (
+ <tr>
+ <td colSpan={2}>
+ <PartCollapsible
+ big
+ title={i18n.str`Products`}
+ text={
+ <ListOfProducts>
+ {info.products?.map((p, k) => (
+ <Row key={k}>
+ <a href="#" onClick={showLargePic}>
+ <img src={p.image ? p.image : emptyImg} />
+ </a>
+ <div>
+ {p.quantity && p.quantity > 0 && (
+ <SmallLightText>
+ x {p.quantity} {p.unit}
+ </SmallLightText>
+ )}
+ <div>{p.description}</div>
+ </div>
+ </Row>
+ ))}
+ </ListOfProducts>
+ }
+ />
+ </td>
+ </tr>
+ )} */}
+ {/* {hasShipping && (
+ <tr>
+ <td colSpan={2}>
+ <PartCollapsible
+ big
+ title={i18n.str`Delivery`}
+ text={
+ <DeliveryDetails
+ date={info.delivery_date}
+ location={info.delivery_location}
+ />
+ }
+ />
+ </td>
+ </tr>
+ )} */}
+ </PurchaseDetailsTable>
+ );
+}
+
+function RefundDetails({ amount }: { amount: AmountWithFee }): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <PurchaseDetailsTable>
+ <tr>
+ <td>
+ <i18n.Translate>Refund</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.value} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+
+ {Amounts.isNonZero(amount.fee) && (
+ <Fragment>
+ <tr>
+ <td>
+ <i18n.Translate>Fees</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.fee} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Total</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.total} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ </Fragment>
+ )}
+ </PurchaseDetailsTable>
+ );
+}
+
+type AmountAmountByWireTransferByWire = {
+ id: string;
+ amount: AmountString;
+}[];
+
+function calculateAmountByWireTransfer(
+ state: TransactionDeposit["trackingState"],
+): AmountAmountByWireTransferByWire {
+ const allTracking = Object.values(state ?? {});
+
+ //group tracking by wtid, sum amounts
+ const trackByWtid = allTracking.reduce(
+ (prev, cur) => {
+ const fee = Amounts.parseOrThrow(cur.wireFee);
+ const raw = Amounts.parseOrThrow(cur.amountRaw);
+ const total = !prev[cur.wireTransferId]
+ ? raw
+ : Amounts.add(prev[cur.wireTransferId].total, raw).amount;
+
+ prev[cur.wireTransferId] = {
+ total,
+ fee,
+ };
+ return prev;
+ },
+ {} as Record<string, { total: AmountJson; fee: AmountJson }>,
+ );
+
+ //remove wire fee from total amount
+ return Object.entries(trackByWtid).map(([id, info]) => ({
+ id,
+ amount: Amounts.stringify(Amounts.sub(info.total, info.fee).amount),
+ }));
+}
+
+function TrackingDepositDetails({
+ trackingState,
+}: {
+ trackingState: TransactionDeposit["trackingState"];
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ const wireTransfers = calculateAmountByWireTransfer(trackingState);
+
+ return (
+ <PurchaseDetailsTable>
+ <tr>
+ <td>
+ <i18n.Translate>Transfer identification</i18n.Translate>
+ </td>
+ <td>
+ <i18n.Translate>Amount</i18n.Translate>
+ </td>
+ </tr>
+
+ {wireTransfers.map((wire) => (
+ <tr key={wire.id}>
+ <td>{wire.id}</td>
+ <td>
+ <Amount value={wire.amount} />
+ </td>
+ </tr>
+ ))}
+ </PurchaseDetailsTable>
+ );
+}
+
+function DepositDetails({ amount }: { amount: AmountWithFee }): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <PurchaseDetailsTable>
+ <tr>
+ <td>
+ <i18n.Translate>Sent</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.value} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+
+ {Amounts.isNonZero(amount.fee) && (
+ <Fragment>
+ <tr>
+ <td>
+ <i18n.Translate>Fees</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.fee} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Total</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.total} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ </Fragment>
+ )}
+ </PurchaseDetailsTable>
+ );
+}
+
+function RefreshDetails({ amount }: { amount: AmountWithFee }): VNode {
+ const { i18n } = useTranslationContext();
+
+ return (
+ <PurchaseDetailsTable>
+ <tr>
+ <td>
+ <i18n.Translate>Refresh</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.value} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Fees</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.fee} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ <tr>
+ <td colSpan={2}>
+ <hr />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <i18n.Translate>Total</i18n.Translate>
+ </td>
+ <td>
+ <Amount value={amount.total} maxFracSize={amount.maxFrac} />
+ </td>
+ </tr>
+ </PurchaseDetailsTable>
+ );
+}
+
+function Header({
+ timestamp,
+ total,
+ children,
+ kind,
+ type,
+}: {
+ timestamp: TalerPreciseTimestamp;
+ total: AmountJson;
+ children: ComponentChildren;
+ kind: Kind;
+ type: TranslatedString;
+}): VNode {
+ return (
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ flexDirection: "row",
+ }}
+ >
<div>
- {transaction.info.products && transaction.info.products.length > 0 &&
- <ListOfProducts>
- {transaction.info.products.map((p, k) => <RowBorderGray key={k}>
- <a href="#" onClick={showLargePic}>
- <img src={p.image ? p.image : emptyImg} />
+ <SubTitle>{children}</SubTitle>
+ <Time
+ timestamp={AbsoluteTime.fromPreciseTimestamp(timestamp)}
+ format="dd MMMM yyyy, HH:mm"
+ />
+ </div>
+ <div>
+ <SubTitle>
+ <Part
+ title={type}
+ text={<Amount value={total} negative={kind === "negative"} />}
+ kind={kind}
+ />
+ </SubTitle>
+ </div>
+ </div>
+ );
+}
+
+function NicePayto({ payto }: { payto: PaytoUri }): VNode {
+ if (payto.isKnown) {
+ switch (payto.targetType) {
+ case "bitcoin": {
+ return <div>{payto.targetPath.substring(0, 20)}...</div>;
+ }
+ case "x-taler-bank": {
+ const url = new URL("/", `https://${payto.host}`);
+ return (
+ <Fragment>
+ <div>{"payto.account"}</div>
+ <SmallLightText>
+ <a href={url.href} target="_bank" rel="noreferrer">
+ {url.href}
</a>
- <div>
- {p.quantity && p.quantity > 0 && <SmallLightText>x {p.quantity} {p.unit}</SmallLightText>}
- <div>{p.description}</div>
- </div>
- </RowBorderGray>)}
- </ListOfProducts>
- }
+ </SmallLightText>
+ </Fragment>
+ );
+ }
+ case "iban": {
+ return <div>{payto.targetPath.substring(0, 20)}</div>;
+ }
+ }
+ }
+ return <Fragment>{stringifyPaytoUri(payto)}</Fragment>;
+}
+
+function ShowQrWithCopy({ text }: { text: string }): VNode {
+ const [showing, setShowing] = useState(false);
+ const { i18n } = useTranslationContext();
+ async function copy(): Promise<void> {
+ navigator.clipboard.writeText(text);
+ }
+ async function toggle(): Promise<void> {
+ setShowing((s) => !s);
+ }
+ if (showing) {
+ return (
+ <div>
+ <QR text={text} />
+ <Button onClick={copy as SafeHandler<void>}>
+ <i18n.Translate>copy</i18n.Translate>
+ </Button>
+ <Button onClick={toggle as SafeHandler<void>}>
+ <i18n.Translate>hide qr</i18n.Translate>
+ </Button>
</div>
- </TransactionTemplate>
+ );
}
+ return (
+ <div>
+ <div>{text.substring(0, 64)}...</div>
+ <Button onClick={copy as SafeHandler<void>}>
+ <i18n.Translate>copy</i18n.Translate>
+ </Button>
+ <Button onClick={toggle as SafeHandler<void>}>
+ <i18n.Translate>show qr</i18n.Translate>
+ </Button>
+ </div>
+ );
+}
+function getShowButtonStates(transaction: Transaction) {
+ let abort = false;
+ let fail = false;
+ let resume = false;
+ let remove = false;
+ let suspend = false;
+
+ transaction.txActions.forEach((a) => {
+ switch (a) {
+ case TransactionAction.Delete:
+ remove = true;
+ break;
+ case TransactionAction.Suspend:
+ suspend = true;
+ break;
+ case TransactionAction.Resume:
+ resume = true;
+ break;
+ case TransactionAction.Abort:
+ abort = true;
+ break;
+ case TransactionAction.Fail:
+ fail = true;
+ break;
+ case TransactionAction.Retry:
+ break;
+ default:
+ assertUnreachable(a);
+ break;
+ }
+ });
+ return { abort, fail, resume, remove, suspend };
+}
+
+function ShowWithdrawalDetailForBankIntegrated({
+ transaction,
+}: {
+ transaction: TransactionWithdrawal | TransactionInternalWithdrawal;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [showDetails, setShowDetails] = useState(false);
+ if (
+ transaction.txState.major !== TransactionMajorState.Pending ||
+ transaction.withdrawalDetails.type === WithdrawalType.ManualTransfer
+ ) {
+ return <Fragment />;
+ }
+ const raw = Amounts.parseOrThrow(transaction.amountRaw);
+ return (
+ <Fragment>
+ <EnabledBySettings name="advancedMode">
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ setShowDetails(!showDetails);
+ }}
+ >
+ Show details.
+ </a>
+ </EnabledBySettings>
- return <div></div>
+ {showDetails && (
+ <BankDetailsByPaytoType
+ amount={raw}
+ accounts={
+ transaction.withdrawalDetails.exchangeCreditAccountDetails ?? []
+ }
+ subject={transaction.withdrawalDetails.reservePub}
+ />
+ )}
+ {!transaction.withdrawalDetails.confirmed &&
+ transaction.withdrawalDetails.bankConfirmationUrl ? (
+ <InfoBox>
+ <div style={{ display: "block" }}>
+ <i18n.Translate>
+ Wire transfer need a confirmation. Go to the{" "}
+ <a
+ href={transaction.withdrawalDetails.bankConfirmationUrl}
+ target="_blank"
+ rel="noreferrer"
+ style={{ display: "inline" }}
+ >
+ <i18n.Translate>bank site</i18n.Translate>
+ </a>{" "}
+ and check wire transfer operation to exchange account is complete.
+ </i18n.Translate>
+ </div>
+ </InfoBox>
+ ) : undefined}
+ {transaction.withdrawalDetails.confirmed &&
+ !transaction.withdrawalDetails.reserveIsReady && (
+ <InfoBox>
+ <i18n.Translate>
+ Bank has confirmed the wire transfer. Waiting for the exchange to
+ send the coins.
+ </i18n.Translate>
+ </InfoBox>
+ )}
+ {transaction.withdrawalDetails.confirmed &&
+ transaction.withdrawalDetails.reserveIsReady && (
+ <InfoBox>
+ <i18n.Translate>
+ Exchange is ready to send the coins, withdrawal in progress.
+ </i18n.Translate>
+ </InfoBox>
+ )}
+ </Fragment>
+ );
}
diff --git a/packages/taler-wallet-webextension/src/wallet/Welcome.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Welcome.stories.tsx
index 6579450b3..dfce1c14b 100644
--- a/packages/taler-wallet-webextension/src/wallet/Welcome.stories.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Welcome.stories.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -15,36 +15,26 @@
*/
/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-
-import { createExample } from '../test-utils';
-import { View as TestedComponent } from './Welcome';
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import * as tests from "@gnu-taler/web-util/testing";
+import { View as TestedComponent } from "./Welcome.js";
export default {
- title: 'wallet/welcome',
+ title: "welcome",
component: TestedComponent,
};
-export const Normal = createExample(TestedComponent, {
- permissionsEnabled: true,
- diagnostics: {
- errors: [],
- walletManifestVersion: '1.0',
- walletManifestDisplayVersion: '1.0',
- firefoxIdbProblem: false,
- dbOutdated: false,
- }
+export const Normal = tests.createExample(TestedComponent, {
+ permissionToggle: { value: true, button: {} },
});
-export const TimedoutDiagnostics = createExample(TestedComponent, {
- timedOut: true,
- permissionsEnabled: false,
+export const TimedoutDiagnostics = tests.createExample(TestedComponent, {
+ permissionToggle: { value: true, button: {} },
});
-export const RunningDiagnostics = createExample(TestedComponent, {
- permissionsEnabled: false,
+export const RunningDiagnostics = tests.createExample(TestedComponent, {
+ permissionToggle: { value: true, button: {} },
});
-
diff --git a/packages/taler-wallet-webextension/src/wallet/Welcome.tsx b/packages/taler-wallet-webextension/src/wallet/Welcome.tsx
index d11070d9a..6a57fe18a 100644
--- a/packages/taler-wallet-webextension/src/wallet/Welcome.tsx
+++ b/packages/taler-wallet-webextension/src/wallet/Welcome.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2019 Taler Systems SA
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -17,53 +17,82 @@
/**
* Welcome page, shown on first installs.
*
- * @author Florian Dold
+ * @author sebasjm
*/
-import { JSX } from "preact/jsx-runtime";
-import { Checkbox } from "../components/Checkbox";
-import { useExtendedPermissions } from "../hooks/useExtendedPermissions";
-import { Diagnostics } from "../components/Diagnostics";
-import { WalletBox } from "../components/styled";
-import { useDiagnostics } from "../hooks/useDiagnostics";
import { WalletDiagnostics } from "@gnu-taler/taler-util";
-import { h } from 'preact';
+import { Fragment, h, VNode } from "preact";
+import { Checkbox } from "../components/Checkbox.js";
+import { SubTitle, Title } from "../components/styled/index.js";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useSettings } from "../hooks/useSettings.js";
+import { ToggleHandler } from "../mui/handlers.js";
+import { platform } from "../platform/foreground.js";
+import { useAlertContext } from "../context/alert.js";
-export function WelcomePage() {
- const [permissionsEnabled, togglePermissions] = useExtendedPermissions()
- const [diagnostics, timedOut] = useDiagnostics()
- return <View
- permissionsEnabled={permissionsEnabled} togglePermissions={togglePermissions}
- diagnostics={diagnostics} timedOut={timedOut}
- />
+export function WelcomePage(): VNode {
+ const [settings, updateSettings] = useSettings();
+ const { safely } = useAlertContext();
+ return (
+ <View
+ permissionToggle={{
+ value: settings.injectTalerSupport,
+ button: {
+ onClick: safely("update support injection", async () =>
+ updateSettings("injectTalerSupport", !settings.injectTalerSupport),
+ ),
+ },
+ }}
+ />
+ );
}
export interface ViewProps {
- permissionsEnabled: boolean,
- togglePermissions: () => void,
- diagnostics: WalletDiagnostics | undefined,
- timedOut: boolean,
+ permissionToggle: ToggleHandler;
}
-export function View({ permissionsEnabled, togglePermissions, diagnostics, timedOut }: ViewProps): JSX.Element {
- return (<WalletBox>
- <h1>Browser Extension Installed!</h1>
- <div>
- <p>Thank you for installing the wallet.</p>
- <Diagnostics diagnostics={diagnostics} timedOut={timedOut} />
- <h2>Permissions</h2>
- <Checkbox label="Automatically open wallet based on page content"
- name="perm"
- description="(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)"
- enabled={permissionsEnabled} onToggle={togglePermissions}
- />
- <h2>Next Steps</h2>
- <a href="https://demo.taler.net/" style={{ display: "block" }}>
- Try the demo »
- </a>
- <a href="https://demo.taler.net/" style={{ display: "block" }}>
- Learn how to top up your wallet balance »
- </a>
- </div>
- </WalletBox>
+export function View({
+ permissionToggle,
+}: ViewProps): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <Title>
+ <i18n.Translate>GNU Taler Wallet installed!</i18n.Translate>
+ </Title>
+ <div>
+ <p>
+ <i18n.Translate>
+ You can open the wallet using the combination{" "}
+ <pre style="font-weight: bold; display: inline;">&lt;ALT+W&gt;</pre>
+ .
+ </i18n.Translate>
+ </p>
+ <Fragment>
+ <p>
+ <i18n.Translate>
+ Also pinning the GNU Taler Wallet to your browser allows
+ you to quick access without keyboard:
+ </i18n.Translate>
+ </p>
+ <ol style={{ paddingLeft: 40 }}>
+ <li>
+ <i18n.Translate>Click the puzzle icon</i18n.Translate>
+ </li>
+ <li>
+ <i18n.Translate>Search for GNU Taler Wallet</i18n.Translate>
+ </li>
+ <li>
+ <i18n.Translate>Click the pin icon</i18n.Translate>
+ </li>
+ </ol>
+ </Fragment>
+ <SubTitle>
+ <i18n.Translate>Next Steps</i18n.Translate>
+ </SubTitle>
+ <a href="https://demo.taler.net/" style={{ display: "block" }}>
+ <i18n.Translate>Try the demo</i18n.Translate> »
+ </a>
+ </div>
+ </Fragment>
);
}
diff --git a/packages/taler-wallet-webextension/src/wallet/index.stories.tsx b/packages/taler-wallet-webextension/src/wallet/index.stories.tsx
new file mode 100644
index 000000000..89bb75b29
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/wallet/index.stories.tsx
@@ -0,0 +1,36 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export * as a1 from "./Backup.stories.js";
+export * as a4 from "./DepositPage/stories.js";
+export * as a7 from "./History.stories.js";
+export * as a8 from "./AddBackupProvider/stories.js";
+export * as a10 from "./ProviderDetail.stories.js";
+export * as a12 from "./Settings.stories.js";
+export * as a13 from "./Transaction.stories.js";
+export * as a14 from "./Welcome.stories.js";
+export * as a15 from "./AddNewActionView.stories.js";
+export * as a16 from "./DeveloperPage.stories.js";
+export * as a17 from "./QrReader.stories.js";
+export * as a18 from "./DestinationSelection/stories.js";
+export * as a19 from "./ExchangeSelection/stories.js";
+export * as a20 from "./ManageAccount/stories.js";
+export * as a21 from "./Notifications/stories.js";
diff --git a/packages/taler-wallet-webextension/src/walletEntryPoint.dev.tsx b/packages/taler-wallet-webextension/src/walletEntryPoint.dev.tsx
new file mode 100644
index 000000000..60a5970e4
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/walletEntryPoint.dev.tsx
@@ -0,0 +1,53 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Main entry point for extension pages.
+ *
+ * @author sebasjm
+ */
+
+import { setupI18n } from "@gnu-taler/taler-util";
+import { h, render } from "preact";
+import { strings } from "./i18n/strings.js";
+import { setupPlatform } from "./platform/foreground.js";
+import devAPI from "./platform/dev.js";
+import { Application } from "./wallet/Application.js";
+
+setupPlatform(devAPI);
+
+function main(): void {
+ try {
+ const container = document.getElementById("container");
+ if (!container) {
+ throw Error("container not found, can't mount page contents");
+ }
+ render(<Application />, container);
+ } catch (e) {
+ console.error("got error", e);
+ if (e instanceof Error) {
+ document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`;
+ }
+ }
+}
+
+setupI18n("en", strings);
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", main);
+} else {
+ main();
+}
diff --git a/packages/taler-wallet-webextension/src/walletEntryPoint.tsx b/packages/taler-wallet-webextension/src/walletEntryPoint.tsx
index 023ee94c5..1bd42796b 100644
--- a/packages/taler-wallet-webextension/src/walletEntryPoint.tsx
+++ b/packages/taler-wallet-webextension/src/walletEntryPoint.tsx
@@ -1,6 +1,6 @@
/*
This file is part of GNU Taler
- (C) 2020 Taler Systems S.A.
+ (C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@@ -17,33 +17,26 @@
/**
* Main entry point for extension pages.
*
- * @author Florian Dold <dold@taler.net>
+ * @author sebasjm
*/
import { setupI18n } from "@gnu-taler/taler-util";
-import { createHashHistory } from 'history';
-import { Fragment, h, render } from "preact";
-import Router, { route, Route } from "preact-router";
-import { useEffect } from "preact/hooks";
-import { LogoHeader } from "./components/LogoHeader";
-import { DevContextProvider } from "./context/devContext";
-import { PayPage } from "./cta/Pay";
-import { RefundPage } from "./cta/Refund";
-import { TipPage } from './cta/Tip';
-import { WithdrawPage } from "./cta/Withdraw";
-import { strings } from "./i18n/strings";
-import {
- Pages, WalletNavBar
-} from "./NavigationBar";
-import { BalancePage } from "./wallet/BalancePage";
-import { HistoryPage } from "./wallet/History";
-import { SettingsPage } from "./wallet/Settings";
-import { TransactionPage } from './wallet/Transaction';
-import { WelcomePage } from "./wallet/Welcome";
-import { BackupPage } from './wallet/BackupPage';
-import { DeveloperPage } from "./popup/Debug.js";
-import { ManualWithdrawPage } from "./wallet/ManualWithdrawPage.js";
-
+import { h, render } from "preact";
+import { strings } from "./i18n/strings.js";
+import { setupPlatform } from "./platform/foreground.js";
+import chromeAPI from "./platform/chrome.js";
+import firefoxAPI from "./platform/firefox.js";
+import { Application } from "./wallet/Application.js";
+
+const isFirefox = typeof (window as any)["InstallTrigger"] !== "undefined";
+
+//FIXME: create different entry point for any platform instead of
+//switching in runtime
+if (isFirefox) {
+ setupPlatform(firefoxAPI);
+} else {
+ setupPlatform(chromeAPI);
+}
function main(): void {
try {
@@ -60,60 +53,10 @@ function main(): void {
}
}
-setupI18n("en-US", strings);
+setupI18n("en", strings);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", main);
} else {
main();
}
-
-function withLogoAndNavBar(Component: any) {
- return (props: any) => <Fragment>
- <LogoHeader />
- <WalletNavBar />
- <Component {...props} />
- </Fragment>
-}
-
-function Application() {
- return <div>
- <DevContextProvider>
- <Router history={createHashHistory()} >
-
- <Route path={Pages.welcome} component={withLogoAndNavBar(WelcomePage)} />
-
- <Route path={Pages.history} component={withLogoAndNavBar(HistoryPage)} />
- <Route path={Pages.transaction} component={withLogoAndNavBar(TransactionPage)} />
- <Route path={Pages.balance} component={withLogoAndNavBar(BalancePage)}
- goToWalletManualWithdraw={() => route(Pages.manual_withdraw)}
- />
- <Route path={Pages.settings} component={withLogoAndNavBar(SettingsPage)} />
- <Route path={Pages.backup} component={withLogoAndNavBar(BackupPage)} />
-
- <Route path={Pages.manual_withdraw} component={withLogoAndNavBar(ManualWithdrawPage)} />
-
- <Route path={Pages.reset_required} component={() => <div>no yet implemented</div>} />
- <Route path={Pages.payback} component={() => <div>no yet implemented</div>} />
- <Route path={Pages.return_coins} component={() => <div>no yet implemented</div>} />
-
- <Route path={Pages.dev} component={withLogoAndNavBar(DeveloperPage)} />
-
- {/** call to action */}
- <Route path={Pages.pay} component={PayPage} />
- <Route path={Pages.refund} component={RefundPage} />
- <Route path={Pages.tips} component={TipPage} />
- <Route path={Pages.withdraw} component={WithdrawPage} />
-
- <Route default component={Redirect} to={Pages.history} />
- </Router>
- </DevContextProvider>
- </div>
-}
-
-function Redirect({ to }: { to: string }): null {
- useEffect(() => {
- route(to, true)
- })
- return null
-}
diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts
index 92597cbd2..195efecd4 100644
--- a/packages/taler-wallet-webextension/src/wxApi.ts
+++ b/packages/taler-wallet-webextension/src/wxApi.ts
@@ -1,17 +1,17 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
@@ -22,343 +22,215 @@
* Imports.
*/
import {
+ AbsoluteTime,
CoreApiResponse,
- ConfirmPayResult,
- BalancesResponse,
- TransactionsResponse,
- ApplyRefundResponse,
- PreparePayResult,
- AcceptWithdrawalResponse,
- WalletDiagnostics,
- GetWithdrawalDetailsForUriRequest,
- WithdrawUriInfoResponse,
- PrepareTipRequest,
- PrepareTipResult,
- AcceptTipRequest,
- DeleteTransactionRequest,
- RetryTransactionRequest,
- SetWalletDeviceIdRequest,
- GetExchangeWithdrawalInfo,
- AcceptExchangeTosRequest,
- AcceptManualWithdrawalResult,
- AcceptManualWithdrawalRequest,
- AmountJson,
- ExchangesListRespose,
- AddExchangeRequest,
- GetExchangeTosResult,
+ DetailsMap,
+ Logger,
+ LogLevel,
+ NotificationType,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ WalletNotification
} from "@gnu-taler/taler-util";
-import { AddBackupProviderRequest, BackupProviderState, OperationFailedError, RemoveBackupProviderRequest } from "@gnu-taler/taler-wallet-core";
-import { BackupInfo } from "@gnu-taler/taler-wallet-core";
-import { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core/src/operations/withdraw";
-
-export interface ExtendedPermissionsResponse {
- newValue: boolean;
-}
-
-/**
- * Response with information about available version upgrades.
- */
-export interface UpgradeResponse {
- /**
- * Is a reset required because of a new DB version
- * that can't be automatically upgraded?
- */
- dbResetRequired: boolean;
-
- /**
- * Current database version.
- */
- currentDbVersion: string;
-
- /**
- * Old db version (if applicable).
- */
- oldDbVersion: string;
-}
-
-async function callBackend(operation: string, payload: any): Promise<any> {
- return new Promise<any>((resolve, reject) => {
- chrome.runtime.sendMessage({ operation, payload, id: "(none)" }, (resp) => {
- if (chrome.runtime.lastError) {
- console.log("Error calling backend");
- reject(
- new Error(
- `Error contacting backend: chrome.runtime.lastError.message`,
- ),
- );
- }
- console.log("got response", resp);
- const r = resp as CoreApiResponse;
- if (r.type === "error") {
- reject(new OperationFailedError(r.error));
- return;
- }
- resolve(r.result);
- });
- });
-}
-
-/**
- * Start refreshing a coin.
- */
-export function refresh(coinPub: string): Promise<void> {
- return callBackend("refresh-coin", { coinPub });
-}
-
-/**
- * Pay for a proposal.
- */
-export function confirmPay(
- proposalId: string,
- sessionId: string | undefined,
-): Promise<ConfirmPayResult> {
- return callBackend("confirmPay", { proposalId, sessionId });
-}
-
-/**
- * Check upgrade information
- */
-export function checkUpgrade(): Promise<UpgradeResponse> {
- return callBackend("check-upgrade", {});
-}
-
-/**
- * Reset database
- */
-export function resetDb(): Promise<void> {
- return callBackend("reset-db", {});
-}
-
-/**
- * Get balances for all currencies/exchanges.
- */
-export function getBalance(): Promise<BalancesResponse> {
- return callBackend("getBalances", {});
-}
-
-/**
- * Retrieve the full event history for this wallet.
- */
-export function getTransactions(): Promise<TransactionsResponse> {
- return callBackend("getTransactions", {});
-}
-
-interface CurrencyInfo {
- name: string;
- baseUrl: string;
- pub: string;
-}
-interface ListOfKnownCurrencies {
- auditors: CurrencyInfo[],
- exchanges: CurrencyInfo[],
-}
-
-/**
- * Get a list of currencies from known auditors and exchanges
- */
-export function listKnownCurrencies(): Promise<ListOfKnownCurrencies> {
- return callBackend("listCurrencies", {}).then(result => {
- console.log("result list", result)
- const auditors = result.trustedAuditors.map((a: Record<string, string>) => ({
- name: a.currency,
- baseUrl: a.auditorBaseUrl,
- pub: a.auditorPub,
- }))
- const exchanges = result.trustedExchanges.map((a: Record<string, string>) => ({
- name: a.currency,
- baseUrl: a.exchangeBaseUrl,
- pub: a.exchangeMasterPub,
- }))
- return { auditors, exchanges }
- });
-}
-
-export function listExchanges(): Promise<ExchangesListRespose> {
- return callBackend("listExchanges", {})
-}
-
-/**
- * Get information about the current state of wallet backups.
- */
-export function getBackupInfo(): Promise<BackupInfo> {
- return callBackend("getBackupInfo", {})
-}
-
-/**
- * Add a backup provider and activate it
- */
-export function addBackupProvider(backupProviderBaseUrl: string, name: string): Promise<void> {
- return callBackend("addBackupProvider", {
- backupProviderBaseUrl, activate: true, name
- } as AddBackupProviderRequest)
-}
-
-export function setWalletDeviceId(walletDeviceId: string): Promise<void> {
- return callBackend("setWalletDeviceId", {
- walletDeviceId
- } as SetWalletDeviceIdRequest)
-}
-
-export function syncAllProviders(): Promise<void> {
- return callBackend("runBackupCycle", {})
-}
-
-export function syncOneProvider(url: string): Promise<void> {
- return callBackend("runBackupCycle", { providers: [url] })
-}
-export function removeProvider(url: string): Promise<void> {
- return callBackend("removeBackupProvider", { provider: url } as RemoveBackupProviderRequest)
-}
-export function extendedProvider(url: string): Promise<void> {
- return callBackend("extendBackupProvider", { provider: url })
-}
-
-/**
- * Retry a transaction
- * @param transactionId
- * @returns
- */
-export function retryTransaction(transactionId: string): Promise<void> {
- return callBackend("retryTransaction", {
- transactionId
- } as RetryTransactionRequest);
-}
-
-/**
- * Permanently delete a transaction from the transaction list
- */
-export function deleteTransaction(transactionId: string): Promise<void> {
- return callBackend("deleteTransaction", {
- transactionId
- } as DeleteTransactionRequest);
-}
+import {
+ WalletCoreApiClient,
+ WalletCoreOpKeys,
+ WalletCoreRequestType,
+ WalletCoreResponseType,
+} from "@gnu-taler/taler-wallet-core";
+import {
+ ExtensionNotification,
+ MessageFromBackend,
+ MessageFromFrontendBackground,
+ MessageFromFrontendWallet,
+} from "./platform/api.js";
+import { platform } from "./platform/foreground.js";
/**
- * Download a refund and accept it.
+ *
+ * @author sebasjm
*/
-export function applyRefund(
- talerRefundUri: string,
-): Promise<ApplyRefundResponse> {
- return callBackend("applyRefund", { talerRefundUri });
-}
-/**
- * Get details about a pay operation.
- */
-export function preparePay(talerPayUri: string): Promise<PreparePayResult> {
- return callBackend("preparePay", { talerPayUri });
-}
+const logger = new Logger("wxApi");
-/**
- * Get details about a withdraw operation.
- */
-export function acceptWithdrawal(
- talerWithdrawUri: string,
- selectedExchange: string,
-): Promise<AcceptWithdrawalResponse> {
- return callBackend("acceptBankIntegratedWithdrawal", {
- talerWithdrawUri,
- exchangeBaseUrl: selectedExchange,
- });
-}
+export const WALLET_CORE_SUPPORTED_VERSION = "4:0:0"
-/**
- * Create a reserve into the exchange that expect the amount indicated
- * @param exchangeBaseUrl
- * @param amount
- * @returns
- */
-export function acceptManualWithdrawal(
- exchangeBaseUrl: string,
- amount: string,
-): Promise<AcceptManualWithdrawalResult> {
- return callBackend("acceptManualWithdrawal", {
- amount, exchangeBaseUrl
- });
+export interface ExtendedPermissionsResponse {
+ newValue: boolean;
}
-export function setExchangeTosAccepted(
- exchangeBaseUrl: string,
- etag: string | undefined
-): Promise<void> {
- return callBackend("setExchangeTosAccepted", {
- exchangeBaseUrl, etag
- } as AcceptExchangeTosRequest)
+export interface BackgroundOperations {
+ resetDb: {
+ request: void;
+ response: void;
+ };
+ runGarbageCollector: {
+ request: void;
+ response: void;
+ };
+ reinitWallet: {
+ request: void;
+ response: void;
+ };
+ getNotifications: {
+ request: void;
+ response: WalletEvent[];
+ };
+ clearNotifications: {
+ request: void;
+ response: void;
+ };
+ setLoggingLevel: {
+ request: {
+ tag?: string;
+ level: LogLevel;
+ };
+ response: void;
+ };
}
+export type WalletEvent = { notification: WalletNotification, when: AbsoluteTime }
-/**
- * Get diagnostics information
- */
-export function getDiagnostics(): Promise<WalletDiagnostics> {
- return callBackend("wxGetDiagnostics", {});
+export interface BackgroundApiClient {
+ call<Op extends keyof BackgroundOperations>(
+ operation: Op,
+ payload: BackgroundOperations[Op]["request"],
+ ): Promise<BackgroundOperations[Op]["response"]>;
}
-/**
- * Get diagnostics information
- */
-export function setExtendedPermissions(
- value: boolean,
-): Promise<ExtendedPermissionsResponse> {
- return callBackend("wxSetExtendedPermissions", { value });
-}
+export class BackgroundError<T = any> extends Error {
+ public readonly errorDetail: TalerErrorDetail & T;
+ public readonly cause: Error;
-/**
- * Get diagnostics information
- */
-export function getExtendedPermissions(): Promise<ExtendedPermissionsResponse> {
- return callBackend("wxGetExtendedPermissions", {});
-}
+ constructor(title: string, e: TalerErrorDetail & T, cause: Error) {
+ super(title);
+ this.errorDetail = e;
+ this.cause = cause;
+ }
-/**
- * Get diagnostics information
- */
-export function getWithdrawalDetailsForUri(
- req: GetWithdrawalDetailsForUriRequest,
-): Promise<WithdrawUriInfoResponse> {
- return callBackend("getWithdrawalDetailsForUri", req);
+ hasErrorCode<C extends keyof DetailsMap>(
+ code: C,
+ ): this is BackgroundError<DetailsMap[C]> {
+ return this.errorDetail.code === code;
+ }
}
-
/**
- * Get diagnostics information
+ * BackgroundApiClient integration with browser platform
*/
-export function getExchangeWithdrawalInfo(
- req: GetExchangeWithdrawalInfo,
-): Promise<ExchangeWithdrawDetails> {
- return callBackend("getExchangeWithdrawalInfo", req);
-}
-export function getExchangeTos(
- exchangeBaseUrl: string,
- acceptedFormat: string[],
-): Promise<GetExchangeTosResult> {
- return callBackend("getExchangeTos", {
- exchangeBaseUrl, acceptedFormat
- });
-}
-
-export function addExchange(
- req: AddExchangeRequest,
-): Promise<void> {
- return callBackend("addExchange", req);
-}
+class BackgroundApiClientImpl implements BackgroundApiClient {
+ async call<Op extends keyof BackgroundOperations>(
+ operation: Op,
+ payload: BackgroundOperations[Op]["request"],
+ ): Promise<BackgroundOperations[Op]["response"]> {
+ let response: CoreApiResponse;
+ const message: MessageFromFrontendBackground<Op> = {
+ channel: "background",
+ operation,
+ payload,
+ };
-export function prepareTip(req: PrepareTipRequest): Promise<PrepareTipResult> {
- return callBackend("prepareTip", req);
-}
-
-export function acceptTip(req: AcceptTipRequest): Promise<void> {
- return callBackend("acceptTip", req);
+ try {
+ response = await platform.sendMessageToBackground(message);
+ } catch (error) {
+ if (error instanceof Error) {
+ throw new BackgroundError(operation, {
+ code: TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR,
+ when: AbsoluteTime.now(),
+ }, error);
+ }
+ throw error;
+ }
+ if (response.type === "error") {
+ throw new BackgroundError(
+ `Background operation "${operation}" failed`,
+ response.error,
+ TalerError.fromUncheckedDetail(response.error),
+ );
+ }
+ logger.trace("response", response);
+ return response.result as any;
+ }
+}
+
+/**
+ * WalletCoreApiClient integration with browser platform
+ */
+class WalletApiClientImpl implements WalletCoreApiClient {
+ async call<Op extends WalletCoreOpKeys>(
+ operation: Op,
+ payload: WalletCoreRequestType<Op>,
+ ): Promise<WalletCoreResponseType<Op>> {
+ let response: CoreApiResponse;
+ try {
+ const message: MessageFromFrontendWallet<Op> = {
+ channel: "wallet",
+ operation,
+ payload,
+ };
+ response = await platform.sendMessageToBackground(message);
+ } catch (error) {
+ if (error instanceof Error) {
+ throw new BackgroundError(operation, {
+ code: TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR,
+ when: AbsoluteTime.now(),
+ }, error);
+ }
+ throw error;
+ }
+ if (response.type === "error") {
+ throw new BackgroundError(
+ `Wallet operation "${operation}" failed`,
+ response.error,
+ TalerError.fromUncheckedDetail(response.error)
+ );
+ }
+ logger.trace("got response", response);
+ return response.result as any;
+ }
+}
+
+function onUpdateNotification(
+ messageTypes: Array<NotificationType>,
+ doCallback: undefined | ((n: WalletNotification) => void),
+): () => void {
+ //if no callback, then ignore
+ if (!doCallback)
+ return () => {
+ return;
+ };
+ const onNewMessage = (message: MessageFromBackend): void => {
+ const shouldNotify = message.type === "wallet" && messageTypes.includes(message.notification.type);
+ if (shouldNotify) {
+ doCallback(message.notification);
+ }
+ };
+ return platform.listenToWalletBackground(onNewMessage);
}
-export function onUpdateNotification(f: () => void): () => void {
- const port = chrome.runtime.connect({ name: "notifications" });
- const listener = (): void => {
- f();
- };
- port.onMessage.addListener(listener);
- return () => {
- port.onMessage.removeListener(listener);
+export type WxApiType = {
+ wallet: WalletCoreApiClient;
+ background: BackgroundApiClient;
+ listener: {
+ trigger: (d: ExtensionNotification) => void;
+ onUpdateNotification: typeof onUpdateNotification;
};
-}
+};
+
+function trigger(w: ExtensionNotification) {
+ platform.triggerWalletEvent({
+ type: "web-extension",
+ notification: w,
+ })
+}
+
+export const wxApi = {
+ wallet: new WalletApiClientImpl(),
+ background: new BackgroundApiClientImpl(),
+ listener: {
+ trigger,
+ onUpdateNotification,
+ },
+};
diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts
index 4004f04f6..315ab5332 100644
--- a/packages/taler-wallet-webextension/src/wxBackend.ts
+++ b/packages/taler-wallet-webextension/src/wxBackend.ts
@@ -1,17 +1,17 @@
/*
- This file is part of TALER
- (C) 2016 GNUnet e.V.
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
- TALER is free software; you can redistribute it and/or modify it under the
+ GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
@@ -23,28 +23,43 @@
/**
* Imports.
*/
-import { isFirefox, getPermissionsApi } from "./compat";
-import { extendedPermissions } from "./permissions";
import {
+ AbsoluteTime,
+ BalanceFlag,
+ LogLevel,
+ Logger,
+ NotificationType,
OpenedPromise,
+ SetTimeoutTimerAPI,
+ TalerError,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TransactionMajorState,
+ TransactionMinorState,
+ WalletNotification,
+ getErrorDetailFromException,
+ makeErrorDetail,
openPromise,
- openTalerDatabase,
- makeErrorDetails,
- deleteTalerDatabase,
+ setGlobalLogLevelFromString,
+ setLogLevelFromString,
+} from "@gnu-taler/taler-util";
+import { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
+import {
DbAccess,
- WalletStoresV1,
+ SynchronousCryptoWorkerFactoryPlain,
Wallet,
+ WalletApiOperation,
+ WalletOperations,
+ WalletStoresV1,
+ deleteTalerDatabase,
+ exportDb,
+ importDb,
} from "@gnu-taler/taler-wallet-core";
-import {
- classifyTalerUri,
- CoreApiResponse,
- CoreApiResponseSuccess,
- TalerErrorCode,
- TalerUriType,
- WalletDiagnostics,
-} from "@gnu-taler/taler-util";
-import { BrowserHttpLib } from "./browserHttpLib";
-import { BrowserCryptoWorkerFactory } from "./browserCryptoWorkerFactory";
+import { MessageFromFrontend, MessageResponse } from "./platform/api.js";
+import { platform } from "./platform/background.js";
+import { ExtensionOperations } from "./taler-wallet-interaction-loader.js";
+import { BackgroundOperations, WalletEvent } from "./wxApi.js";
+import { BrowserFetchHttpLib } from "@gnu-taler/web-util/browser";
/**
* Currently active wallet instance. Might be unloaded and
@@ -56,185 +71,206 @@ let currentWallet: Wallet | undefined;
let currentDatabase: DbAccess<typeof WalletStoresV1> | undefined;
-/**
- * Last version if an outdated DB, if applicable.
- */
-let outdatedDbVersion: number | undefined;
-
const walletInit: OpenedPromise<void> = openPromise<void>();
-const notificationPorts: chrome.runtime.Port[] = [];
+const logger = new Logger("wxBackend.ts");
-async function getDiagnostics(): Promise<WalletDiagnostics> {
- const manifestData = chrome.runtime.getManifest();
- const errors: string[] = [];
- let firefoxIdbProblem = false;
- let dbOutdated = false;
- try {
- await walletInit.promise;
- } catch (e) {
- errors.push("Error during wallet initialization: " + e);
- if (
- currentDatabase === undefined &&
- outdatedDbVersion === undefined &&
- isFirefox()
- ) {
- firefoxIdbProblem = true;
- }
- }
- if (!currentWallet) {
- errors.push("Could not create wallet backend.");
+type BackendHandlerType = {
+ [Op in keyof BackgroundOperations]: (
+ req: BackgroundOperations[Op]["request"],
+ ) => Promise<BackgroundOperations[Op]["response"]>;
+};
+
+type ExtensionHandlerType = {
+ [Op in keyof ExtensionOperations]: (
+ req: ExtensionOperations[Op]["request"],
+ ) => Promise<ExtensionOperations[Op]["response"]>;
+};
+
+async function resetDb(): Promise<void> {
+ await deleteTalerDatabase(indexedDB as any);
+ await reinitWallet();
+}
+
+//FIXME: maybe circular buffer
+const notifications: WalletEvent[] = [];
+async function getNotifications(): Promise<WalletEvent[]> {
+ return notifications;
+}
+
+async function clearNotifications(): Promise<void> {
+ notifications.splice(0, notifications.length);
+}
+
+async function runGarbageCollector(): Promise<void> {
+ const dbBeforeGc = currentDatabase;
+ if (!dbBeforeGc) {
+ throw Error("no current db before running gc");
}
- if (!currentDatabase) {
- errors.push("Could not open database");
+ const dump = await exportDb(indexedDB as any);
+
+ await deleteTalerDatabase(indexedDB as any);
+ logger.info("cleaned");
+ await reinitWallet();
+ logger.info("init");
+
+ const dbAfterGc = currentDatabase;
+ if (!dbAfterGc) {
+ throw Error("no current db before running gc");
}
- if (outdatedDbVersion !== undefined) {
- errors.push(`Outdated DB version: ${outdatedDbVersion}`);
- dbOutdated = true;
+ await importDb(dbAfterGc.idbHandle(), dump);
+ logger.info("imported");
+}
+
+const extensionHandlers: ExtensionHandlerType = {
+ isAutoOpenEnabled,
+ isDomainTrusted,
+};
+
+async function isAutoOpenEnabled(): Promise<boolean> {
+ const settings = await platform.getSettingsFromStorage();
+ return settings.autoOpen === true;
+}
+
+async function isDomainTrusted(): Promise<boolean> {
+ const settings = await platform.getSettingsFromStorage();
+ return settings.injectTalerSupport === true;
+}
+
+const backendHandlers: BackendHandlerType = {
+ resetDb,
+ runGarbageCollector,
+ getNotifications,
+ clearNotifications,
+ reinitWallet,
+ setLoggingLevel,
+};
+
+async function setLoggingLevel({
+ tag,
+ level,
+}: {
+ tag?: string;
+ level: LogLevel;
+}): Promise<void> {
+ logger.info(`setting ${tag} to ${level}`);
+ if (!tag) {
+ setGlobalLogLevelFromString(level);
+ } else {
+ setLogLevelFromString(tag, level);
}
- const diagnostics: WalletDiagnostics = {
- walletManifestDisplayVersion: manifestData.version_name || "(undefined)",
- walletManifestVersion: manifestData.version,
- errors,
- firefoxIdbProblem,
- dbOutdated,
- };
- return diagnostics;
}
+let nextMessageIndex = 0;
-async function dispatch(
- req: any,
- sender: any,
- sendResponse: any,
-): Promise<void> {
- let r: CoreApiResponse;
-
- const wrapResponse = (result: unknown): CoreApiResponseSuccess => {
- return {
- type: "response",
- id: req.id,
- operation: req.operation,
- result,
- };
- };
+async function dispatch<
+ Op extends WalletOperations | BackgroundOperations | ExtensionOperations,
+>(req: MessageFromFrontend<Op> & { id: string }): Promise<MessageResponse> {
+ nextMessageIndex = (nextMessageIndex + 1) % (Number.MAX_SAFE_INTEGER - 100);
- switch (req.operation) {
- case "wxGetDiagnostics": {
- r = wrapResponse(await getDiagnostics());
- break;
- }
- case "reset-db": {
- await deleteTalerDatabase(indexedDB);
- r = wrapResponse(await reinitWallet());
- break;
- }
- case "wxGetExtendedPermissions": {
- const res = await new Promise((resolve, reject) => {
- getPermissionsApi().contains(extendedPermissions, (result: boolean) => {
- resolve(result);
- });
- });
- r = wrapResponse({ newValue: res });
- break;
+ switch (req.channel) {
+ case "background": {
+ const handler = backendHandlers[req.operation] as (req: any) => any;
+ if (!handler) {
+ return {
+ type: "error",
+ id: req.id,
+ operation: String(req.operation),
+ error: getErrorDetailFromException(
+ Error(`unknown background operation`),
+ ),
+ };
+ }
+ try {
+ const result = await handler(req.payload);
+ return {
+ type: "response",
+ id: req.id,
+ operation: String(req.operation),
+ result,
+ };
+ } catch (er) {
+ return {
+ type: "error",
+ id: req.id,
+ error: getErrorDetailFromException(er),
+ operation: String(req.operation),
+ };
+ }
}
- case "wxSetExtendedPermissions": {
- const newVal = req.payload.value;
- console.log("new extended permissions value", newVal);
- if (newVal) {
- setupHeaderListener();
- r = wrapResponse({ newValue: true });
- } else {
- await new Promise<void>((resolve, reject) => {
- getPermissionsApi().remove(extendedPermissions, (rem) => {
- console.log("permissions removed:", rem);
- resolve();
- });
- });
- r = wrapResponse({ newVal: false });
+ case "extension": {
+ const handler = extensionHandlers[req.operation] as (req: any) => any;
+ if (!handler) {
+ return {
+ type: "error",
+ id: req.id,
+ operation: String(req.operation),
+ error: getErrorDetailFromException(
+ Error(`unknown extension operation`),
+ ),
+ };
+ }
+ try {
+ const result = await handler(req.payload);
+ return {
+ type: "response",
+ id: req.id,
+ operation: String(req.operation),
+ result,
+ };
+ } catch (er) {
+ return {
+ type: "error",
+ id: req.id,
+ error: getErrorDetailFromException(er),
+ operation: String(req.operation),
+ };
}
- break;
}
- default: {
+ case "wallet": {
const w = currentWallet;
if (!w) {
- r = {
+ const lastError: TalerErrorDetail =
+ walletInit.lastError instanceof TalerError
+ ? walletInit.lastError.errorDetail
+ : undefined;
+
+ return {
type: "error",
id: req.id,
operation: req.operation,
- error: makeErrorDetails(
+ error: makeErrorDetail(
TalerErrorCode.WALLET_CORE_NOT_AVAILABLE,
- "wallet core not available",
- {},
+ { lastError },
+ `wallet core not available${
+ !lastError ? "" : `,last error: ${lastError.hint}`
+ }`,
),
};
- break;
}
- r = await w.handleCoreApiRequest(req.operation, req.id, req.payload);
- break;
+ //multiple client can create the same id, send the wallet an unique key
+ const newId = `${req.id}_${nextMessageIndex}`;
+ const resp = await w.handleCoreApiRequest(
+ req.operation,
+ newId,
+ req.payload,
+ );
+ //return to the client the original id
+ resp.id = req.id;
+ return resp;
}
}
- try {
- sendResponse(r);
- } catch (e) {
- // might fail if tab disconnected
- }
-}
-
-function getTab(tabId: number): Promise<chrome.tabs.Tab> {
- return new Promise((resolve, reject) => {
- chrome.tabs.get(tabId, (tab: chrome.tabs.Tab) => resolve(tab));
- });
-}
-
-function setBadgeText(options: chrome.browserAction.BadgeTextDetails): void {
- // not supported by all browsers ...
- if (chrome && chrome.browserAction && chrome.browserAction.setBadgeText) {
- chrome.browserAction.setBadgeText(options);
- } else {
- console.warn("can't set badge text, not supported", options);
- }
-}
-
-function waitMs(timeoutMs: number): Promise<void> {
- return new Promise((resolve, reject) => {
- const bgPage = chrome.extension.getBackgroundPage();
- if (!bgPage) {
- reject("fatal: no background page");
- return;
- }
- bgPage.setTimeout(() => resolve(), timeoutMs);
- });
-}
-
-function makeSyncWalletRedirect(
- url: string,
- tabId: number,
- oldUrl: string,
- params?: { [name: string]: string | undefined },
-): Record<string, unknown> {
- const innerUrl = new URL(chrome.extension.getURL(url));
- if (params) {
- const hParams = Object.keys(params)
- .map((k) => `${k}=${params[k]}`)
- .join("&");
- innerUrl.hash = innerUrl.hash + "?" + hParams;
- }
- if (isFirefox()) {
- // Some platforms don't support the sync redirect (yet), so fall back to
- // async redirect after a timeout.
- const doit = async (): Promise<void> => {
- await waitMs(150);
- const tab = await getTab(tabId);
- if (tab.url === oldUrl) {
- chrome.tabs.update(tabId, { url: innerUrl.href });
- }
- };
- doit();
- }
- console.log("redirecting to", innerUrl.href);
- chrome.tabs.update(tabId, { url: innerUrl.href });
- return { redirectUrl: innerUrl.href };
+ const anyReq = req as any;
+ return {
+ type: "error",
+ id: anyReq.id,
+ operation: String(anyReq.operation),
+ error: getErrorDetailFromException(
+ Error(
+ `unknown channel ${anyReq.channel}, should be "background", "extension" or "wallet"`,
+ ),
+ ),
+ };
}
async function reinitWallet(): Promise<void> {
@@ -243,170 +279,80 @@ async function reinitWallet(): Promise<void> {
currentWallet = undefined;
}
currentDatabase = undefined;
- setBadgeText({ text: "" });
- try {
- currentDatabase = await openTalerDatabase(indexedDB, reinitWallet);
- } catch (e) {
- console.error("could not open database", e);
- walletInit.reject(e);
- return;
+ // setBadgeText({ text: "" });
+ let cryptoWorker;
+ let timer;
+
+ const httpFactory = (): HttpRequestLibrary => {
+ return new BrowserFetchHttpLib({
+ // enableThrottling: false,
+ });
+ };
+
+ if (platform.useServiceWorkerAsBackgroundProcess()) {
+ cryptoWorker = new SynchronousCryptoWorkerFactoryPlain();
+ timer = new SetTimeoutTimerAPI();
+ } else {
+ // We could (should?) use the BrowserCryptoWorkerFactory here,
+ // but right now we don't, to have less platform differences.
+ // cryptoWorker = new BrowserCryptoWorkerFactory();
+ cryptoWorker = new SynchronousCryptoWorkerFactoryPlain();
+ timer = new SetTimeoutTimerAPI();
}
- const http = new BrowserHttpLib();
- console.log("setting wallet");
+
+ const settings = await platform.getSettingsFromStorage();
+ logger.info("Setting up wallet");
const wallet = await Wallet.create(
- currentDatabase,
- http,
- new BrowserCryptoWorkerFactory(),
+ indexedDB as any,
+ httpFactory as any,
+ timer,
+ cryptoWorker,
);
try {
- await wallet.handleCoreApiRequest("initWallet", "native-init", {});
+ await wallet.handleCoreApiRequest("initWallet", "native-init", {
+ config: {
+ testing: {
+ emitObservabilityEvents: settings.showWalletActivity,
+ devModeActive: settings.advancedMode,
+ },
+ features: {
+ allowHttp: settings.walletAllowHttp,
+ },
+ },
+ });
} catch (e) {
- console.error("could not initialize wallet", e);
+ logger.error("could not initialize wallet", e);
walletInit.reject(e);
return;
}
- wallet.addNotificationListener((x) => {
- for (const x of notificationPorts) {
- try {
- x.postMessage({ type: "notification" });
- } catch (e) {
- console.error(e);
- }
+ wallet.addNotificationListener((message) => {
+ if (settings.showWalletActivity) {
+ notifications.push({
+ notification: message,
+ when: AbsoluteTime.now(),
+ });
}
- });
- wallet.runTaskLoop().catch((e) => {
- console.log("error during wallet task loop", e);
- });
- // Useful for debugging in the background page.
- (window as any).talerWallet = wallet;
- currentWallet = wallet;
- walletInit.resolve();
-}
-try {
- // This needs to be outside of main, as Firefox won't fire the event if
- // the listener isn't created synchronously on loading the backend.
- chrome.runtime.onInstalled.addListener((details) => {
- console.log("onInstalled with reason", details.reason);
- if (details.reason === "install") {
- const url = chrome.extension.getURL("/static/wallet.html#/welcome");
- chrome.tabs.create({ active: true, url: url });
- }
- });
-} catch (e) {
- console.error(e);
-}
+ processWalletNotification(message);
-function headerListener(
- details: chrome.webRequest.WebResponseHeadersDetails,
-): chrome.webRequest.BlockingResponse | undefined {
- console.log("header listener");
- if (chrome.runtime.lastError) {
- console.error(chrome.runtime.lastError);
- return;
- }
- const wallet = currentWallet;
- if (!wallet) {
- console.warn("wallet not available while handling header");
- return;
- }
- console.log("in header listener");
- if (
- details.statusCode === 402 ||
- details.statusCode === 202 ||
- details.statusCode === 200
- ) {
- console.log(`got 402/202 from ${details.url}`);
- for (const header of details.responseHeaders || []) {
- if (header.name.toLowerCase() === "taler") {
- const talerUri = header.value || "";
- const uriType = classifyTalerUri(talerUri);
- switch (uriType) {
- case TalerUriType.TalerWithdraw:
- return makeSyncWalletRedirect(
- "/static/wallet.html#/withdraw",
- details.tabId,
- details.url,
- {
- talerWithdrawUri: talerUri,
- },
- );
- case TalerUriType.TalerPay:
- return makeSyncWalletRedirect(
- "/static/wallet.html#/pay",
- details.tabId,
- details.url,
- {
- talerPayUri: talerUri,
- },
- );
- case TalerUriType.TalerTip:
- return makeSyncWalletRedirect(
- "/static/wallet.html#/tip",
- details.tabId,
- details.url,
- {
- talerTipUri: talerUri,
- },
- );
- case TalerUriType.TalerRefund:
- return makeSyncWalletRedirect(
- "/static/wallet.html#/refund",
- details.tabId,
- details.url,
- {
- talerRefundUri: talerUri,
- },
- );
- case TalerUriType.TalerNotifyReserve:
- Promise.resolve().then(() => {
- const w = currentWallet;
- if (!w) {
- return;
- }
- // FIXME: Is this still useful?
- // handleNotifyReserve(w);
- });
- break;
- default:
- console.warn(
- "Response with HTTP 402 has Taler header, but header value is not a taler:// URI.",
- );
- break;
- }
- }
- }
- }
- return;
-}
+ platform.sendMessageToAllChannels({
+ type: "wallet",
+ notification: message,
+ });
+ });
-function setupHeaderListener(): void {
- console.log("setting up header listener");
- // Handlers for catching HTTP requests
- getPermissionsApi().contains(extendedPermissions, (result: boolean) => {
- if (
- "webRequest" in chrome &&
- "onHeadersReceived" in chrome.webRequest &&
- chrome.webRequest.onHeadersReceived.hasListener(headerListener)
- ) {
- chrome.webRequest.onHeadersReceived.removeListener(headerListener);
- }
- if (result) {
- console.log("actually adding listener");
- chrome.webRequest.onHeadersReceived.addListener(
- headerListener,
- { urls: ["<all_urls>"] },
- ["responseHeaders", "blocking"],
- );
- }
- if ("webRequest" in chrome) {
- chrome.webRequest.handlerBehaviorChanged(() => {
- if (chrome.runtime.lastError) {
- console.error(chrome.runtime.lastError);
- }
- });
- }
+ platform.keepAlive(() => {
+ return wallet.runTaskLoop().catch((e) => {
+ logger.error("error during wallet task loop", e);
+ });
});
+ // Useful for debugging in the background page.
+ if (typeof window !== "undefined") {
+ (window as any).talerWallet = wallet;
+ }
+ currentWallet = wallet;
+ updateIconBasedOnBalance();
+ return walletInit.resolve();
}
/**
@@ -415,44 +361,74 @@ function setupHeaderListener(): void {
* Sets up all event handlers and other machinery.
*/
export async function wxMain(): Promise<void> {
- // Explicitly unload the extension page as soon as an update is available,
- // so the update gets installed as soon as possible.
- chrome.runtime.onUpdateAvailable.addListener((details) => {
- console.log("update available:", details);
- chrome.runtime.reload();
- });
- reinitWallet();
+ logger.trace("starting");
+ const afterWalletIsInitialized = reinitWallet();
+
+ logger.trace("reload on new version");
+ platform.registerReloadOnNewVersion();
// Handlers for messages coming directly from the content
// script on the page
- chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
- dispatch(req, sender, sendResponse);
- return true;
+ logger.trace("listen all channels");
+ platform.listenToAllChannels(async (message) => {
+ //wait until wallet is initialized
+ await afterWalletIsInitialized;
+ const result = await dispatch(message);
+ return result;
});
- chrome.runtime.onConnect.addListener((port) => {
- notificationPorts.push(port);
- port.onDisconnect.addListener((discoPort) => {
- const idx = notificationPorts.indexOf(discoPort);
- if (idx >= 0) {
- notificationPorts.splice(idx, 1);
- }
- });
- });
+ logger.trace("register all incoming connections");
+ platform.registerAllIncomingConnections();
+ logger.trace("redirect if first start");
try {
- setupHeaderListener();
+ platform.registerOnInstalled(() => {
+ platform.openWalletPage("/welcome");
+ });
} catch (e) {
- console.log(e);
+ console.error(e);
}
+}
+
+async function updateIconBasedOnBalance() {
+ const balance = await currentWallet?.client.call(
+ WalletApiOperation.GetBalances,
+ {},
+ );
+ if (balance) {
+ let showAlert = false;
+ for (const b of balance.balances) {
+ if (b.flags.length > 0) {
+ console.log("b.flags", JSON.stringify(b.flags))
+ showAlert = true;
+ break;
+ }
+ }
- // On platforms that support it, also listen to external
- // modification of permissions.
- getPermissionsApi().addPermissionsListener((perm) => {
- if (chrome.runtime.lastError) {
- console.error(chrome.runtime.lastError);
- return;
+ if (showAlert) {
+ platform.setAlertedIcon();
+ } else {
+ platform.setNormalIcon();
}
- setupHeaderListener();
- });
+ }
+}
+
+/**
+ * All the actions triggered by notification that need to be
+ * run in the background.
+ *
+ * @param message
+ */
+async function processWalletNotification(message: WalletNotification) {
+ if (
+ message.type === NotificationType.TransactionStateTransition &&
+ (message.newTxState.minor === TransactionMinorState.KycRequired ||
+ message.oldTxState.minor === TransactionMinorState.KycRequired ||
+ message.newTxState.minor === TransactionMinorState.AmlRequired ||
+ message.oldTxState.minor === TransactionMinorState.AmlRequired ||
+ message.newTxState.minor === TransactionMinorState.BankConfirmTransfer ||
+ message.oldTxState.minor === TransactionMinorState.BankConfirmTransfer)
+ ) {
+ await updateIconBasedOnBalance();
+ }
}
diff --git a/packages/taler-wallet-webextension/static-dev/beer.png b/packages/taler-wallet-webextension/static-dev/beer.png
new file mode 100644
index 000000000..1116db7e6
--- /dev/null
+++ b/packages/taler-wallet-webextension/static-dev/beer.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static-dev/merchant-icon.jpeg b/packages/taler-wallet-webextension/static-dev/merchant-icon.jpeg
new file mode 100644
index 000000000..1777936c8
--- /dev/null
+++ b/packages/taler-wallet-webextension/static-dev/merchant-icon.jpeg
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/font/import.css b/packages/taler-wallet-webextension/static/font/import.css
new file mode 100644
index 000000000..05edddb51
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/font/import.css
@@ -0,0 +1,35 @@
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/static/font/roboto-italic-400.ttf) format('truetype');
+}
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ font-display: swap;
+ src: url(/static/font/roboto-normal-300.ttf) format('truetype');
+}
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url(/static/font/roboto-normal-400.ttf) format('truetype');
+}
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url(/static/font/roboto-normal-500.ttf) format('truetype');
+}
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url(/static/font/roboto-normal-700.ttf) format('truetype');
+}
diff --git a/packages/taler-wallet-webextension/static/font/roboto-italic-400.ttf b/packages/taler-wallet-webextension/static/font/roboto-italic-400.ttf
new file mode 100644
index 000000000..1e746d17f
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/font/roboto-italic-400.ttf
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/font/roboto-normal-300.tff b/packages/taler-wallet-webextension/static/font/roboto-normal-300.tff
new file mode 100644
index 000000000..ec821b577
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/font/roboto-normal-300.tff
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/font/roboto-normal-400.ttf b/packages/taler-wallet-webextension/static/font/roboto-normal-400.ttf
new file mode 100644
index 000000000..9d4b32b47
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/font/roboto-normal-400.ttf
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/font/roboto-normal-500.ttf b/packages/taler-wallet-webextension/static/font/roboto-normal-500.ttf
new file mode 100644
index 000000000..4b4e1c656
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/font/roboto-normal-500.ttf
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/font/roboto-normal-700.ttf b/packages/taler-wallet-webextension/static/font/roboto-normal-700.ttf
new file mode 100644
index 000000000..58d877c58
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/font/roboto-normal-700.ttf
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/chevron-down.svg b/packages/taler-wallet-webextension/static/img/chevron-down.svg
deleted file mode 100644
index 36adbc1c6..000000000
--- a/packages/taler-wallet-webextension/static/img/chevron-down.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
- width="92px" height="92px" viewBox="0 0 92 92" enable-background="new 0 0 92 92" xml:space="preserve">
-<path id="XMLID_467_" d="M46,63c-1.1,0-2.1-0.4-2.9-1.2l-25-26c-1.5-1.6-1.5-4.1,0.1-5.7c1.6-1.5,4.1-1.5,5.7,0.1l22.1,23l22.1-23
- c1.5-1.6,4.1-1.6,5.7-0.1c1.6,1.5,1.6,4.1,0.1,5.7l-25,26C48.1,62.6,47.1,63,46,63z"/>
-</svg>
diff --git a/packages/taler-wallet-webextension/static/img/icon.png b/packages/taler-wallet-webextension/static/img/icon.png
deleted file mode 100644
index b4733bebc..000000000
--- a/packages/taler-wallet-webextension/static/img/icon.png
+++ /dev/null
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/logo-2015-medium.png b/packages/taler-wallet-webextension/static/img/logo-2015-medium.png
deleted file mode 100644
index acf84baaf..000000000
--- a/packages/taler-wallet-webextension/static/img/logo-2015-medium.png
+++ /dev/null
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/logo-2021.svg b/packages/taler-wallet-webextension/static/img/logo-2021.svg
deleted file mode 100644
index e72611eba..000000000
--- a/packages/taler-wallet-webextension/static/img/logo-2021.svg
+++ /dev/null
@@ -1 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns="http://www.w3.org/2000/svg" width="670" height="300" viewBox="0 0 201 90"><g fill="#0042b3" fill-rule="evenodd" stroke-width=".3"><path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z"/><path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z"/><path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z"/></g><path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z"/></svg> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/static/img/logo.png b/packages/taler-wallet-webextension/static/img/logo.png
deleted file mode 120000
index 1ddb87d2c..000000000
--- a/packages/taler-wallet-webextension/static/img/logo.png
+++ /dev/null
@@ -1 +0,0 @@
-logo-2015-medium.png \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/static/img/taler-alert-128.png b/packages/taler-wallet-webextension/static/img/taler-alert-128.png
new file mode 100644
index 000000000..b49347936
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-alert-128.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-alert-16.png b/packages/taler-wallet-webextension/static/img/taler-alert-16.png
new file mode 100644
index 000000000..3772174fd
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-alert-16.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-alert-19.png b/packages/taler-wallet-webextension/static/img/taler-alert-19.png
new file mode 100644
index 000000000..9998e0034
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-alert-19.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-alert-256.png b/packages/taler-wallet-webextension/static/img/taler-alert-256.png
new file mode 100644
index 000000000..e20b8ccb6
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-alert-256.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-alert-32.png b/packages/taler-wallet-webextension/static/img/taler-alert-32.png
new file mode 100644
index 000000000..b02ad9a79
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-alert-32.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-alert-38.png b/packages/taler-wallet-webextension/static/img/taler-alert-38.png
new file mode 100644
index 000000000..aa71ddedd
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-alert-38.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-alert-48.png b/packages/taler-wallet-webextension/static/img/taler-alert-48.png
new file mode 100644
index 000000000..67516b582
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-alert-48.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-alert-512.png b/packages/taler-wallet-webextension/static/img/taler-alert-512.png
new file mode 100644
index 000000000..ac2bef530
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-alert-512.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-alert-64.png b/packages/taler-wallet-webextension/static/img/taler-alert-64.png
new file mode 100644
index 000000000..316cdaee7
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-alert-64.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-logo-128.png b/packages/taler-wallet-webextension/static/img/taler-logo-128.png
new file mode 100644
index 000000000..a2f0c22eb
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-logo-128.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-logo-16.png b/packages/taler-wallet-webextension/static/img/taler-logo-16.png
new file mode 100644
index 000000000..eb42bad1c
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-logo-16.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-logo-19.png b/packages/taler-wallet-webextension/static/img/taler-logo-19.png
new file mode 100644
index 000000000..8c8c6ae88
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-logo-19.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-logo-2022.svg b/packages/taler-wallet-webextension/static/img/taler-logo-2022.svg
new file mode 100644
index 000000000..2ac2785b8
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-logo-2022.svg
@@ -0,0 +1,468 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="670"
+ height="300"
+ viewBox="0 0 201 90"
+ version="1.1"
+ id="svg8"
+ sodipodi:docname="taler-logo-2023.svg"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)">
+ <metadata
+ id="metadata67">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs854">
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20663">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20665"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20667">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20669"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20671">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20673"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20675">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20677"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20679">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20681"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20683">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20685"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20687">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20689"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20691">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20693"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20695">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20697"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20699">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20701"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20703">
+ <rect
+ style="fill:#00ff00;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20705"
+ width="164.73636"
+ height="53.465477"
+ x="12.38413"
+ y="263.48923" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20707">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20709"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20711">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20713"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20715">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20717"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20719">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20721"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20723">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20725"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20727">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20729"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20731">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20733"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ <clipPath
+ clipPathUnits="userSpaceOnUse"
+ id="clipPath20735">
+ <rect
+ style="opacity:1;fill:#00ff00;fill-opacity:1;stroke:#df373a;stroke-width:0.103816;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ id="rect20737"
+ width="164.73636"
+ height="53.465477"
+ x="-16.523348"
+ y="98.188889" />
+ </clipPath>
+ </defs>
+ <sodipodi:namedview
+ id="namedview852"
+ pagecolor="#000000"
+ bordercolor="#cccccc"
+ borderopacity="1"
+ inkscape:pageshadow="0"
+ inkscape:pageopacity="0"
+ inkscape:pagecheckerboard="false"
+ showgrid="false"
+ inkscape:zoom="0.46315494"
+ inkscape:cx="-659.30808"
+ inkscape:cy="83.54417"
+ inkscape:window-width="1920"
+ inkscape:window-height="1025"
+ inkscape:window-x="0"
+ inkscape:window-y="26"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="logo" />
+ <g
+ id="logo">
+ <g
+ id="circles"
+ style="display:inline;fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943"
+ transform="translate(180)">
+ <g
+ id="g4645"
+ inkscape:export-xdpi="98.304001"
+ inkscape:export-ydpi="98.304001">
+ <ellipse
+ transform="matrix(-0.99007841,-0.140516,0.16039263,-0.98705329,0,0)"
+ ry="75.234604"
+ rx="74.764656"
+ cy="-29.611343"
+ cx="101.25517"
+ id="path4580"
+ style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.1471165;stroke-opacity:1" />
+ <g
+ transform="rotate(-180,-107.57659,26.234233)"
+ id="g4622">
+ <path
+ id="path1306-7-63-9"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="M 45.48017,110.87571 A 35.545008,38.588202 0 0 0 9.9354536,149.46424 35.545008,38.588202 0 0 0 45.48017,188.05226 35.545008,38.588202 0 0 0 81.025385,149.46424 35.545008,38.588202 0 0 0 45.48017,110.87571 Z m -0.07061,4.90892 a 31.151221,33.78691 0 0 1 0.829048,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.827519,0.0595 31.151221,33.78691 0 0 1 0.825964,0.0839 31.151221,33.78691 0 0 1 0.823425,0.10767 31.151221,33.78691 0 0 1 0.820349,0.13097 31.151221,33.78691 0 0 1 0.816775,0.15478 31.151221,33.78691 0 0 1 0.813198,0.17859 31.151221,33.78691 0 0 1 0.808083,0.20188 31.151221,33.78691 0 0 1 0.802953,0.22518 31.151221,33.78691 0 0 1 0.797339,0.24796 31.151221,33.78691 0 0 1 0.790697,0.27125 31.151221,33.78691 0 0 1 0.783536,0.29403 31.151221,33.78691 0 0 1 0.776355,0.31629 31.151221,33.78691 0 0 1 0.768177,0.33854 31.151221,33.78691 0 0 1 0.760022,0.3608 31.151221,33.78691 0 0 1 0.750783,0.38255 31.151221,33.78691 0 0 1 0.740582,0.40429 31.151221,33.78691 0 0 1 0.73085,0.42499 31.151221,33.78691 0 0 1 0.7201,0.44622 31.151221,33.78691 0 0 1 0.70885,0.46692 31.151221,33.78691 0 0 1 0.6971,0.48764 31.151221,33.78691 0 0 1 0.68484,0.50678 31.151221,33.78691 0 0 1 0.67254,0.52697 31.151221,33.78691 0 0 1 0.65872,0.54613 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.58339 31.151221,33.78691 0 0 1 0.61678,0.601 31.151221,33.78691 0 0 1 0.60196,0.61911 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65277 31.151221,33.78691 0 0 1 0.5544,0.66932 31.151221,33.78691 0 0 1 0.53751,0.68486 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71488 31.151221,33.78691 0 0 1 0.48534,0.72885 31.151221,33.78691 0 0 1 0.46798,0.74284 31.151221,33.78691 0 0 1 0.44904,0.75629 31.151221,33.78691 0 0 1 0.43065,0.76872 31.151221,33.78691 0 0 1 0.41119,0.78114 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80391 31.151221,33.78691 0 0 1 0.35239,0.81376 31.151221,33.78691 0 0 1 0.33294,0.8241 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.84171 31.151221,33.78691 0 0 1 0.27107,0.85051 31.151221,33.78691 0 0 1 0.2501,0.85775 31.151221,33.78691 0 0 1 0.2286,0.86448 31.151221,33.78691 0 0 1 0.20765,0.8707 31.151221,33.78691 0 0 1 0.18616,0.8769 31.151221,33.78691 0 0 1 0.16468,0.88157 31.151221,33.78691 0 0 1 0.14268,0.8857 31.151221,33.78691 0 0 1 0.12073,0.88985 31.151221,33.78691 0 0 1 0.0992,0.89347 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.6509 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60173 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40916 31.151221,33.78691 0 0 1 -1.19526,2.31444 31.151221,33.78691 0 0 1 -1.3589,2.20624 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64252 31.151221,33.78691 0 0 1 -2.034,1.47428 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.221695,1.11037 31.151221,33.78691 0 0 1 -2.294328,0.91832 31.151221,33.78691 0 0 1 -2.354157,0.72006 31.151221,33.78691 0 0 1 -2.399184,0.51765 31.151221,33.78691 0 0 1 -2.428831,0.31163 31.151221,33.78691 0 0 1 -2.444189,0.10457 31.151221,33.78691 0 0 1 -2.44418,-0.10457 31.151221,33.78691 0 0 1 -2.428838,-0.31163 31.151221,33.78691 0 0 1 -2.399177,-0.51765 31.151221,33.78691 0 0 1 -2.354163,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91831 31.151221,33.78691 0 0 1 -2.221196,-1.11037 31.151221,33.78691 0 0 1 -2.134249,-1.29621 31.151221,33.78691 0 0 1 -2.033489,-1.47428 31.151221,33.78691 0 0 1 -1.920973,-1.64252 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20624 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.65091 31.151221,33.78691 0 0 1 0.09615,-2.65091 31.151221,33.78691 0 0 1 0.287434,-2.63433 31.151221,33.78691 0 0 1 0.477175,-2.60174 31.151221,33.78691 0 0 1 0.663854,-2.55358 31.151221,33.78691 0 0 1 0.846946,-2.48889 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31495 31.151221,33.78691 0 0 1 1.358905,-2.20572 31.151221,33.78691 0 0 1 1.513873,-2.08356 31.151221,33.78691 0 0 1 1.660643,-1.94793 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920973,-1.64252 31.151221,33.78691 0 0 1 2.033505,-1.47376 31.151221,33.78691 0 0 1 2.13425,-1.29621 31.151221,33.78691 0 0 1 2.221195,-1.11088 31.151221,33.78691 0 0 1 2.294841,-0.9178 31.151221,33.78691 0 0 1 2.354165,-0.72058 31.151221,33.78691 0 0 1 2.399176,-0.51713 31.151221,33.78691 0 0 1 2.428838,-0.31215 31.151221,33.78691 0 0 1 2.44418,-0.10405 z"
+ clip-path="url(#clipPath20735)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-0"
+ style="opacity:1;fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0376767;stroke-linejoin:round"
+ d="m 68.010803,105.31927 a 40.722405,43.678338 0 0 0 -40.72207,43.67871 40.722405,43.678338 0 0 0 40.72207,43.67812 40.722405,43.678338 0 0 0 40.722647,-43.67812 40.722405,43.678338 0 0 0 -40.722647,-43.67871 z m -0.0809,5.55644 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.94628,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12188 35.68863,38.243712 0 0 1 0.93984,0.14824 35.68863,38.243712 0 0 1 0.93574,0.1752 35.68863,38.243712 0 0 1 0.93165,0.20214 35.68863,38.243712 0 0 1 0.92578,0.22852 35.68863,38.243712 0 0 1 0.91992,0.25488 35.68863,38.243712 0 0 1 0.91348,0.28067 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33281 35.68863,38.243712 0 0 1 0.88945,0.35801 35.68863,38.243712 0 0 1 0.880067,0.3832 35.68863,38.243712 0 0 1 0.87071,0.4084 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52851 35.68863,38.243712 0 0 1 0.79863,0.55196 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59648 35.68863,38.243712 0 0 1 0.75469,0.61817 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68027 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71953 35.68863,38.243712 0 0 1 0.65391,0.73887 35.68863,38.243712 0 0 1 0.63515,0.75762 35.68863,38.243712 0 0 1 0.61582,0.77519 35.68863,38.243712 0 0 1 0.59649,0.79219 35.68863,38.243712 0 0 1 0.57715,0.80918 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84082 35.68863,38.243712 0 0 1 0.51446,0.85606 35.68863,38.243712 0 0 1 0.493358,0.87011 35.68863,38.243712 0 0 1 0.47109,0.88418 35.68863,38.243712 0 0 1 0.44941,0.89707 35.68863,38.243712 0 0 1 0.427152,0.90996 35.68863,38.243712 0 0 1 0.40372,0.9211 35.68863,38.243712 0 0 1 0.38145,0.93281 35.68863,38.243712 0 0 1 0.35742,0.94336 35.68863,38.243712 0 0 1 0.33399,0.95273 35.68863,38.243712 0 0 1 0.31054,0.9627 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97851 35.68863,38.243712 0 0 1 0.23789,0.98555 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99785 35.68863,38.243712 0 0 1 0.16348,1.00254 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01132 35.68863,38.243712 0 0 1 0.0885,1.01368 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01777 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98243 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89043 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.173062,2.72696 35.68863,38.243712 0 0 1 -1.369338,2.61973 35.68863,38.243712 0 0 1 -1.55683,2.49726 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46719 35.68863,38.243712 0 0 1 -2.54531,1.25684 35.68863,38.243712 0 0 1 -2.628507,1.03945 35.68863,38.243712 0 0 1 -2.69706,0.81504 35.68863,38.243712 0 0 1 -2.74864,0.58593 35.68863,38.243712 0 0 1 -2.78261,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58593 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03945 35.68863,38.243712 0 0 1 -2.54473,-1.25684 35.68863,38.243712 0 0 1 -2.44512,-1.46719 35.68863,38.243712 0 0 1 -2.32968,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49726 35.68863,38.243712 0 0 1 -1.36933,-2.61973 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89043 35.68863,38.243712 0 0 1 -0.54668,-2.94492 35.68863,38.243712 0 0 1 -0.3293,-2.98243 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00058 35.68863,38.243712 0 0 1 0.3293,-2.98184 35.68863,38.243712 0 0 1 0.54668,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81719 35.68863,38.243712 0 0 1 1.17305,-2.72695 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20489 35.68863,38.243712 0 0 1 2.05782,-2.03847 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.32968,-1.66817 35.68863,38.243712 0 0 1 2.44512,-1.46718 35.68863,38.243712 0 0 1 2.54473,-1.25743 35.68863,38.243712 0 0 1 2.6291,-1.03886 35.68863,38.243712 0 0 1 2.69707,-0.81563 35.68863,38.243712 0 0 1 2.74863,-0.58535 35.68863,38.243712 0 0 1 2.78262,-0.35332 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20731)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-5"
+ style="opacity:1;fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0376767;stroke-linejoin:round"
+ d="M 45.56102,105.31927 A 40.722405,43.678338 0 0 0 4.8389507,148.99798 40.722405,43.678338 0 0 0 45.56102,192.6761 40.722405,43.678338 0 0 0 86.283657,148.99798 40.722405,43.678338 0 0 0 45.56102,105.31927 Z m -0.0809,5.55644 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.94628,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12188 35.68863,38.243712 0 0 1 0.93984,0.14824 35.68863,38.243712 0 0 1 0.93574,0.1752 35.68863,38.243712 0 0 1 0.93165,0.20214 35.68863,38.243712 0 0 1 0.92578,0.22852 35.68863,38.243712 0 0 1 0.91992,0.25488 35.68863,38.243712 0 0 1 0.91348,0.28067 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33281 35.68863,38.243712 0 0 1 0.88945,0.35801 35.68863,38.243712 0 0 1 0.880067,0.3832 35.68863,38.243712 0 0 1 0.87071,0.4084 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52851 35.68863,38.243712 0 0 1 0.79863,0.55196 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59648 35.68863,38.243712 0 0 1 0.75469,0.61817 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68027 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71953 35.68863,38.243712 0 0 1 0.65391,0.73887 35.68863,38.243712 0 0 1 0.63515,0.75762 35.68863,38.243712 0 0 1 0.61582,0.77519 35.68863,38.243712 0 0 1 0.59649,0.79219 35.68863,38.243712 0 0 1 0.57715,0.80918 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84082 35.68863,38.243712 0 0 1 0.51446,0.85606 35.68863,38.243712 0 0 1 0.49336,0.87011 35.68863,38.243712 0 0 1 0.47109,0.88418 35.68863,38.243712 0 0 1 0.44941,0.89707 35.68863,38.243712 0 0 1 0.42715,0.90996 35.68863,38.243712 0 0 1 0.40371,0.9211 35.68863,38.243712 0 0 1 0.38145,0.93281 35.68863,38.243712 0 0 1 0.35742,0.94336 35.68863,38.243712 0 0 1 0.33399,0.95273 35.68863,38.243712 0 0 1 0.31054,0.9627 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97851 35.68863,38.243712 0 0 1 0.23789,0.98555 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99785 35.68863,38.243712 0 0 1 0.16348,1.00254 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01132 35.68863,38.243712 0 0 1 0.0885,1.01368 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01778 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98243 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89043 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.17305,2.72696 35.68863,38.243712 0 0 1 -1.36934,2.61972 35.68863,38.243712 0 0 1 -1.55683,2.49727 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46719 35.68863,38.243712 0 0 1 -2.54531,1.25683 35.68863,38.243712 0 0 1 -2.628507,1.03946 35.68863,38.243712 0 0 1 -2.69706,0.81504 35.68863,38.243712 0 0 1 -2.74864,0.58593 35.68863,38.243712 0 0 1 -2.78261,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58593 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03946 35.68863,38.243712 0 0 1 -2.54473,-1.25683 35.68863,38.243712 0 0 1 -2.44512,-1.46719 35.68863,38.243712 0 0 1 -2.32968,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49727 35.68863,38.243712 0 0 1 -1.36933,-2.61972 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89043 35.68863,38.243712 0 0 1 -0.546679,-2.94492 35.68863,38.243712 0 0 1 -0.3293004,-2.98243 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00059 35.68863,38.243712 0 0 1 0.3293004,-2.98184 35.68863,38.243712 0 0 1 0.546679,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81719 35.68863,38.243712 0 0 1 1.17305,-2.72695 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20489 35.68863,38.243712 0 0 1 2.05782,-2.03847 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.32968,-1.66817 35.68863,38.243712 0 0 1 2.44512,-1.46718 35.68863,38.243712 0 0 1 2.54473,-1.25743 35.68863,38.243712 0 0 1 2.6291,-1.03886 35.68863,38.243712 0 0 1 2.69707,-0.81563 35.68863,38.243712 0 0 1 2.74863,-0.58535 35.68863,38.243712 0 0 1 2.78262,-0.35332 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20727)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-6"
+ style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0427732;stroke-linejoin:round;stroke-opacity:1"
+ d="M 68.102923,99.029256 A 46.363577,49.444797 0 0 0 21.739728,148.47447 46.363577,49.444797 0 0 0 68.102923,197.91903 46.363577,49.444797 0 0 0 114.46677,148.47447 46.363577,49.444797 0 0 0 68.102923,99.029256 Z m -0.09212,6.290014 a 40.632485,43.292687 0 0 1 1.081384,0.0153 40.632485,43.292687 0 0 1 1.080714,0.0464 40.632485,43.292687 0 0 1 1.079381,0.0763 40.632485,43.292687 0 0 1 1.077365,0.10745 40.632485,43.292687 0 0 1 1.074041,0.13797 40.632485,43.292687 0 0 1 1.070034,0.16781 40.632485,43.292687 0 0 1 1.065366,0.19832 40.632485,43.292687 0 0 1 1.060709,0.22884 40.632485,43.292687 0 0 1 1.054026,0.25869 40.632485,43.292687 0 0 1 1.047355,0.28853 40.632485,43.292687 0 0 1 1.040022,0.31772 40.632485,43.292687 0 0 1 1.031346,0.34756 40.632485,43.292687 0 0 1 1.022009,0.37675 40.632485,43.292687 0 0 1 1.01266,0.40528 40.632485,43.292687 0 0 1 1.00198,0.43379 40.632485,43.292687 0 0 1 0.99134,0.46232 40.632485,43.292687 0 0 1 0.97929,0.49017 40.632485,43.292687 0 0 1 0.96598,0.51803 40.632485,43.292687 0 0 1 0.9533,0.54457 40.632485,43.292687 0 0 1 0.93927,0.57175 40.632485,43.292687 0 0 1 0.92461,0.5983 40.632485,43.292687 0 0 1 0.90926,0.62482 40.632485,43.292687 0 0 1 0.89328,0.64936 40.632485,43.292687 0 0 1 0.87724,0.67524 40.632485,43.292687 0 0 1 0.85922,0.69977 40.632485,43.292687 0 0 1 0.8419,0.72365 40.632485,43.292687 0 0 1 0.82388,0.74754 40.632485,43.292687 0 0 1 0.80452,0.77008 40.632485,43.292687 0 0 1 0.78518,0.7933 40.632485,43.292687 0 0 1 0.7645,0.81452 40.632485,43.292687 0 0 1 0.74449,0.83642 40.632485,43.292687 0 0 1 0.723143,0.85763 40.632485,43.292687 0 0 1 0.70113,0.87754 40.632485,43.292687 0 0 1 0.67912,0.89678 40.632485,43.292687 0 0 1 0.657112,0.916 40.632485,43.292687 0 0 1 0.63307,0.93392 40.632485,43.292687 0 0 1 0.6104,0.95183 40.632485,43.292687 0 0 1 0.58573,0.96907 40.632485,43.292687 0 0 1 0.56171,0.98499 40.632485,43.292687 0 0 1 0.53634,1.00091 40.632485,43.292687 0 0 1 0.51167,1.0155 40.632485,43.292687 0 0 1 0.48631,1.0301 40.632485,43.292687 0 0 1 0.45965,1.04269 40.632485,43.292687 0 0 1 0.43428,1.05597 40.632485,43.292687 0 0 1 0.40694,1.0679 40.632485,43.292687 0 0 1 0.38025,1.07852 40.632485,43.292687 0 0 1 0.35357,1.08979 40.632485,43.292687 0 0 1 0.32622,1.09908 40.632485,43.292687 0 0 1 0.29818,1.10769 40.632485,43.292687 0 0 1 0.27085,1.11566 40.632485,43.292687 0 0 1 0.24283,1.12362 40.632485,43.292687 0 0 1 0.2148,1.12959 40.632485,43.292687 0 0 1 0.18612,1.1349 40.632485,43.292687 0 0 1 0.15745,1.1402 40.632485,43.292687 0 0 1 0.12941,1.14485 40.632485,43.292687 0 0 1 0.10078,1.14749 40.632485,43.292687 0 0 1 0.072,1.15015 40.632485,43.292687 0 0 1 0.0434,1.15148 40.632485,43.292687 0 0 1 0.014,1.15214 40.632485,43.292687 0 0 1 -0.12477,3.39672 40.632485,43.292687 0 0 1 -0.37557,3.37617 40.632485,43.292687 0 0 1 -0.6224,3.33371 40.632485,43.292687 0 0 1 -0.86591,3.27203 40.632485,43.292687 0 0 1 -1.10406,3.18911 40.632485,43.292687 0 0 1 -1.33555,3.08697 40.632485,43.292687 0 0 1 -1.55903,2.96559 40.632485,43.292687 0 0 1 -1.77249,2.82696 40.632485,43.292687 0 0 1 -1.975302,2.66909 40.632485,43.292687 0 0 1 -2.165443,2.49664 40.632485,43.292687 0 0 1 -2.34287,2.30693 40.632485,43.292687 0 0 1 -2.50564,2.10463 40.632485,43.292687 0 0 1 -2.65309,1.88906 40.632485,43.292687 0 0 1 -2.78317,1.66089 40.632485,43.292687 0 0 1 -2.89791,1.42277 40.632485,43.292687 0 0 1 -2.992636,1.17668 40.632485,43.292687 0 0 1 -3.070676,0.92264 40.632485,43.292687 0 0 1 -3.129402,0.66329 40.632485,43.292687 0 0 1 -3.168078,0.3993 40.632485,43.292687 0 0 1 -3.188104,0.13399 40.632485,43.292687 0 0 1 -3.188094,-0.13399 40.632485,43.292687 0 0 1 -3.168088,-0.3993 40.632485,43.292687 0 0 1 -3.12939,-0.6633 40.632485,43.292687 0 0 1 -3.07069,-0.92264 40.632485,43.292687 0 0 1 -2.993303,-1.17668 40.632485,43.292687 0 0 1 -2.897244,-1.42277 40.632485,43.292687 0 0 1 -2.783837,-1.66088 40.632485,43.292687 0 0 1 -2.652404,-1.88906 40.632485,43.292687 0 0 1 -2.505648,-2.10463 40.632485,43.292687 0 0 1 -2.342886,-2.30694 40.632485,43.292687 0 0 1 -2.166082,-2.49664 40.632485,43.292687 0 0 1 -1.974639,-2.66909 40.632485,43.292687 0 0 1 -1.772504,-2.82696 40.632485,43.292687 0 0 1 -1.559022,-2.96558 40.632485,43.292687 0 0 1 -1.335549,-3.08697 40.632485,43.292687 0 0 1 -1.104725,-3.18911 40.632485,43.292687 0 0 1 -0.865908,-3.27203 40.632485,43.292687 0 0 1 -0.622408,-3.33371 40.632485,43.292687 0 0 1 -0.374918,-3.37617 40.632485,43.292687 0 0 1 -0.125409,-3.39672 40.632485,43.292687 0 0 1 0.125409,-3.39673 40.632485,43.292687 0 0 1 0.374918,-3.3755 40.632485,43.292687 0 0 1 0.622408,-3.33371 40.632485,43.292687 0 0 1 0.865908,-3.27203 40.632485,43.292687 0 0 1 1.104725,-3.18912 40.632485,43.292687 0 0 1 1.335549,-3.08696 40.632485,43.292687 0 0 1 1.559022,-2.96625 40.632485,43.292687 0 0 1 1.772504,-2.8263 40.632485,43.292687 0 0 1 1.974639,-2.66975 40.632485,43.292687 0 0 1 2.166082,-2.49598 40.632485,43.292687 0 0 1 2.342886,-2.3076 40.632485,43.292687 0 0 1 2.505648,-2.10463 40.632485,43.292687 0 0 1 2.652426,-1.88839 40.632485,43.292687 0 0 1 2.783836,-1.66089 40.632485,43.292687 0 0 1 2.897245,-1.42343 40.632485,43.292687 0 0 1 2.993303,-1.17602 40.632485,43.292687 0 0 1 3.070689,-0.9233 40.632485,43.292687 0 0 1 3.12939,-0.66263 40.632485,43.292687 0 0 1 3.16809,-0.39997 40.632485,43.292687 0 0 1 3.188093,-0.13332 z"
+ clip-path="url(#clipPath20723)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-63-6"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="m 67.929953,110.87572 a 35.545008,38.588202 0 0 0 -35.544717,38.58853 35.545008,38.588202 0 0 0 35.544717,38.58801 35.545008,38.588202 0 0 0 35.545217,-38.58801 35.545008,38.588202 0 0 0 -35.545217,-38.58853 z m -0.07061,4.90891 a 31.151221,33.78691 0 0 1 0.829048,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.827519,0.0595 31.151221,33.78691 0 0 1 0.825964,0.0839 31.151221,33.78691 0 0 1 0.823425,0.10768 31.151221,33.78691 0 0 1 0.820349,0.13096 31.151221,33.78691 0 0 1 0.816775,0.15478 31.151221,33.78691 0 0 1 0.813198,0.17859 31.151221,33.78691 0 0 1 0.808083,0.20189 31.151221,33.78691 0 0 1 0.802953,0.22518 31.151221,33.78691 0 0 1 0.797339,0.24795 31.151221,33.78691 0 0 1 0.790697,0.27125 31.151221,33.78691 0 0 1 0.783536,0.29403 31.151221,33.78691 0 0 1 0.776355,0.31629 31.151221,33.78691 0 0 1 0.768177,0.33854 31.151221,33.78691 0 0 1 0.760022,0.36081 31.151221,33.78691 0 0 1 0.750783,0.38255 31.151221,33.78691 0 0 1 0.740582,0.40428 31.151221,33.78691 0 0 1 0.73085,0.425 31.151221,33.78691 0 0 1 0.7201,0.44621 31.151221,33.78691 0 0 1 0.70885,0.46693 31.151221,33.78691 0 0 1 0.6971,0.48763 31.151221,33.78691 0 0 1 0.68484,0.50678 31.151221,33.78691 0 0 1 0.67254,0.52697 31.151221,33.78691 0 0 1 0.65872,0.54613 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.5834 31.151221,33.78691 0 0 1 0.61678,0.60099 31.151221,33.78691 0 0 1 0.60196,0.61912 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65276 31.151221,33.78691 0 0 1 0.5544,0.66933 31.151221,33.78691 0 0 1 0.53751,0.68485 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71488 31.151221,33.78691 0 0 1 0.48534,0.72886 31.151221,33.78691 0 0 1 0.46798,0.74283 31.151221,33.78691 0 0 1 0.44904,0.75629 31.151221,33.78691 0 0 1 0.43065,0.76872 31.151221,33.78691 0 0 1 0.41119,0.78114 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80392 31.151221,33.78691 0 0 1 0.35239,0.81375 31.151221,33.78691 0 0 1 0.33294,0.82411 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.8417 31.151221,33.78691 0 0 1 0.27107,0.85051 31.151221,33.78691 0 0 1 0.2501,0.85775 31.151221,33.78691 0 0 1 0.2286,0.86448 31.151221,33.78691 0 0 1 0.20765,0.8707 31.151221,33.78691 0 0 1 0.18616,0.8769 31.151221,33.78691 0 0 1 0.16468,0.88157 31.151221,33.78691 0 0 1 0.14268,0.8857 31.151221,33.78691 0 0 1 0.12073,0.88986 31.151221,33.78691 0 0 1 0.0992,0.89346 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.65091 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60172 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40917 31.151221,33.78691 0 0 1 -1.19526,2.31443 31.151221,33.78691 0 0 1 -1.3589,2.20624 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64252 31.151221,33.78691 0 0 1 -2.034,1.47428 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.221695,1.11038 31.151221,33.78691 0 0 1 -2.294328,0.91831 31.151221,33.78691 0 0 1 -2.354157,0.72006 31.151221,33.78691 0 0 1 -2.399184,0.51765 31.151221,33.78691 0 0 1 -2.428831,0.31163 31.151221,33.78691 0 0 1 -2.444189,0.10457 31.151221,33.78691 0 0 1 -2.44418,-0.10457 31.151221,33.78691 0 0 1 -2.428838,-0.31162 31.151221,33.78691 0 0 1 -2.399177,-0.51765 31.151221,33.78691 0 0 1 -2.354163,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91832 31.151221,33.78691 0 0 1 -2.221196,-1.11037 31.151221,33.78691 0 0 1 -2.134249,-1.29621 31.151221,33.78691 0 0 1 -2.033489,-1.47428 31.151221,33.78691 0 0 1 -1.920973,-1.64251 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20625 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.6509 31.151221,33.78691 0 0 1 0.09615,-2.65091 31.151221,33.78691 0 0 1 0.287434,-2.63434 31.151221,33.78691 0 0 1 0.477175,-2.60173 31.151221,33.78691 0 0 1 0.663854,-2.55359 31.151221,33.78691 0 0 1 0.846946,-2.48888 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31496 31.151221,33.78691 0 0 1 1.358905,-2.20572 31.151221,33.78691 0 0 1 1.513873,-2.08356 31.151221,33.78691 0 0 1 1.660643,-1.94793 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920973,-1.64252 31.151221,33.78691 0 0 1 2.033505,-1.47376 31.151221,33.78691 0 0 1 2.13425,-1.2962 31.151221,33.78691 0 0 1 2.221195,-1.11089 31.151221,33.78691 0 0 1 2.294841,-0.9178 31.151221,33.78691 0 0 1 2.354165,-0.72057 31.151221,33.78691 0 0 1 2.399176,-0.51714 31.151221,33.78691 0 0 1 2.428838,-0.31215 31.151221,33.78691 0 0 1 2.44418,-0.10404 z"
+ clip-path="url(#clipPath20719)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306"
+ style="opacity:1;fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:#df373a;stroke-width:0.0376767;stroke-linejoin:round;stroke-opacity:1"
+ d="m 90.379694,105.31927 a 40.722405,43.678338 0 0 0 -40.72207,43.67871 40.722405,43.678338 0 0 0 40.72207,43.67812 40.722405,43.678338 0 0 0 40.722646,-43.67812 40.722405,43.678338 0 0 0 -40.722646,-43.67871 z m -0.0809,5.55644 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.94628,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12188 35.68863,38.243712 0 0 1 0.93984,0.14824 35.68863,38.243712 0 0 1 0.93574,0.1752 35.68863,38.243712 0 0 1 0.93165,0.20214 35.68863,38.243712 0 0 1 0.925784,0.22852 35.68863,38.243712 0 0 1 0.91992,0.25488 35.68863,38.243712 0 0 1 0.913482,0.28067 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33281 35.68863,38.243712 0 0 1 0.88945,0.35801 35.68863,38.243712 0 0 1 0.88007,0.3832 35.68863,38.243712 0 0 1 0.87071,0.4084 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52851 35.68863,38.243712 0 0 1 0.79863,0.55196 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59648 35.68863,38.243712 0 0 1 0.75469,0.61817 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68027 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71953 35.68863,38.243712 0 0 1 0.65391,0.73887 35.68863,38.243712 0 0 1 0.63515,0.75762 35.68863,38.243712 0 0 1 0.61582,0.77519 35.68863,38.243712 0 0 1 0.59649,0.79219 35.68863,38.243712 0 0 1 0.57715,0.80918 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84082 35.68863,38.243712 0 0 1 0.51446,0.85606 35.68863,38.243712 0 0 1 0.49336,0.87011 35.68863,38.243712 0 0 1 0.47109,0.88418 35.68863,38.243712 0 0 1 0.44941,0.89707 35.68863,38.243712 0 0 1 0.42715,0.90996 35.68863,38.243712 0 0 1 0.40371,0.9211 35.68863,38.243712 0 0 1 0.38145,0.93281 35.68863,38.243712 0 0 1 0.35742,0.94336 35.68863,38.243712 0 0 1 0.33399,0.95273 35.68863,38.243712 0 0 1 0.31054,0.9627 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97851 35.68863,38.243712 0 0 1 0.23789,0.98555 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99785 35.68863,38.243712 0 0 1 0.16348,1.00254 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01132 35.68863,38.243712 0 0 1 0.0885,1.01368 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01778 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98243 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89043 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.17305,2.72696 35.68863,38.243712 0 0 1 -1.36934,2.61972 35.68863,38.243712 0 0 1 -1.55683,2.49727 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46719 35.68863,38.243712 0 0 1 -2.54531,1.25683 35.68863,38.243712 0 0 1 -2.62851,1.03946 35.68863,38.243712 0 0 1 -2.697062,0.81504 35.68863,38.243712 0 0 1 -2.748644,0.58593 35.68863,38.243712 0 0 1 -2.78261,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58593 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03946 35.68863,38.243712 0 0 1 -2.54473,-1.25683 35.68863,38.243712 0 0 1 -2.44512,-1.46719 35.68863,38.243712 0 0 1 -2.32968,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49727 35.68863,38.243712 0 0 1 -1.36933,-2.61972 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89043 35.68863,38.243712 0 0 1 -0.54668,-2.94492 35.68863,38.243712 0 0 1 -0.3293,-2.98243 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00059 35.68863,38.243712 0 0 1 0.3293,-2.98184 35.68863,38.243712 0 0 1 0.54668,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81719 35.68863,38.243712 0 0 1 1.17305,-2.72695 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20489 35.68863,38.243712 0 0 1 2.05782,-2.03847 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.32968,-1.66817 35.68863,38.243712 0 0 1 2.44512,-1.46718 35.68863,38.243712 0 0 1 2.54473,-1.25743 35.68863,38.243712 0 0 1 2.6291,-1.03886 35.68863,38.243712 0 0 1 2.69707,-0.81563 35.68863,38.243712 0 0 1 2.74863,-0.58535 35.68863,38.243712 0 0 1 2.78262,-0.35332 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20715)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7"
+ style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0427732;stroke-linejoin:round;stroke-opacity:1"
+ d="M 90.531339,99.785944 A 46.363577,49.444797 0 0 0 44.168144,149.23116 46.363577,49.444797 0 0 0 90.531339,198.67572 46.363577,49.444797 0 0 0 136.89519,149.23116 46.363577,49.444797 0 0 0 90.531339,99.785944 Z m -0.09212,6.290016 a 40.632485,43.292687 0 0 1 1.081384,0.0153 40.632485,43.292687 0 0 1 1.080714,0.0464 40.632485,43.292687 0 0 1 1.079381,0.0763 40.632485,43.292687 0 0 1 1.077365,0.10745 40.632485,43.292687 0 0 1 1.074041,0.13797 40.632485,43.292687 0 0 1 1.070034,0.16781 40.632485,43.292687 0 0 1 1.065366,0.19832 40.632485,43.292687 0 0 1 1.060704,0.22884 40.632485,43.292687 0 0 1 1.054032,0.25868 40.632485,43.292687 0 0 1 1.04736,0.28854 40.632485,43.292687 0 0 1 1.04002,0.31772 40.632485,43.292687 0 0 1 1.03135,0.34756 40.632485,43.292687 0 0 1 1.02201,0.37675 40.632485,43.292687 0 0 1 1.01266,0.40527 40.632485,43.292687 0 0 1 1.00198,0.4338 40.632485,43.292687 0 0 1 0.99134,0.46231 40.632485,43.292687 0 0 1 0.97929,0.49018 40.632485,43.292687 0 0 1 0.96598,0.51803 40.632485,43.292687 0 0 1 0.9533,0.54456 40.632485,43.292687 0 0 1 0.93927,0.57176 40.632485,43.292687 0 0 1 0.92461,0.59829 40.632485,43.292687 0 0 1 0.90926,0.62483 40.632485,43.292687 0 0 1 0.89328,0.64936 40.632485,43.292687 0 0 1 0.87724,0.67523 40.632485,43.292687 0 0 1 0.85922,0.69978 40.632485,43.292687 0 0 1 0.8419,0.72365 40.632485,43.292687 0 0 1 0.82388,0.74753 40.632485,43.292687 0 0 1 0.80452,0.77009 40.632485,43.292687 0 0 1 0.78518,0.7933 40.632485,43.292687 0 0 1 0.7645,0.81452 40.632485,43.292687 0 0 1 0.74449,0.83642 40.632485,43.292687 0 0 1 0.72314,0.85763 40.632485,43.292687 0 0 1 0.70113,0.87754 40.632485,43.292687 0 0 1 0.67912,0.89677 40.632485,43.292687 0 0 1 0.65711,0.91601 40.632485,43.292687 0 0 1 0.63307,0.93392 40.632485,43.292687 0 0 1 0.6104,0.95183 40.632485,43.292687 0 0 1 0.58573,0.96907 40.632485,43.292687 0 0 1 0.56171,0.98499 40.632485,43.292687 0 0 1 0.53634,1.00091 40.632485,43.292687 0 0 1 0.51167,1.0155 40.632485,43.292687 0 0 1 0.48631,1.0301 40.632485,43.292687 0 0 1 0.45965,1.04269 40.632485,43.292687 0 0 1 0.43428,1.05597 40.632485,43.292687 0 0 1 0.40694,1.0679 40.632485,43.292687 0 0 1 0.38025,1.07851 40.632485,43.292687 0 0 1 0.35357,1.0898 40.632485,43.292687 0 0 1 0.32622,1.09908 40.632485,43.292687 0 0 1 0.29818,1.10769 40.632485,43.292687 0 0 1 0.27085,1.11566 40.632485,43.292687 0 0 1 0.24283,1.12362 40.632485,43.292687 0 0 1 0.2148,1.12959 40.632485,43.292687 0 0 1 0.18612,1.1349 40.632485,43.292687 0 0 1 0.15745,1.1402 40.632485,43.292687 0 0 1 0.12941,1.14484 40.632485,43.292687 0 0 1 0.10078,1.1475 40.632485,43.292687 0 0 1 0.072,1.15015 40.632485,43.292687 0 0 1 0.0434,1.15148 40.632485,43.292687 0 0 1 0.014,1.15214 40.632485,43.292687 0 0 1 -0.12477,3.39673 40.632485,43.292687 0 0 1 -0.37557,3.37616 40.632485,43.292687 0 0 1 -0.6224,3.33372 40.632485,43.292687 0 0 1 -0.86591,3.27203 40.632485,43.292687 0 0 1 -1.10406,3.18911 40.632485,43.292687 0 0 1 -1.33555,3.08697 40.632485,43.292687 0 0 1 -1.55903,2.96558 40.632485,43.292687 0 0 1 -1.77249,2.82696 40.632485,43.292687 0 0 1 -1.9753,2.66909 40.632485,43.292687 0 0 1 -2.16544,2.49664 40.632485,43.292687 0 0 1 -2.34287,2.30693 40.632485,43.292687 0 0 1 -2.50564,2.10464 40.632485,43.292687 0 0 1 -2.65309,1.88906 40.632485,43.292687 0 0 1 -2.78317,1.66088 40.632485,43.292687 0 0 1 -2.89791,1.42277 40.632485,43.292687 0 0 1 -2.99264,1.17668 40.632485,43.292687 0 0 1 -3.070682,0.92264 40.632485,43.292687 0 0 1 -3.129401,0.6633 40.632485,43.292687 0 0 1 -3.168078,0.3993 40.632485,43.292687 0 0 1 -3.188104,0.13399 40.632485,43.292687 0 0 1 -3.188094,-0.13399 40.632485,43.292687 0 0 1 -3.168088,-0.3993 40.632485,43.292687 0 0 1 -3.12939,-0.6633 40.632485,43.292687 0 0 1 -3.07069,-0.92264 40.632485,43.292687 0 0 1 -2.993303,-1.17668 40.632485,43.292687 0 0 1 -2.897244,-1.42277 40.632485,43.292687 0 0 1 -2.783837,-1.66088 40.632485,43.292687 0 0 1 -2.652404,-1.88906 40.632485,43.292687 0 0 1 -2.505648,-2.10464 40.632485,43.292687 0 0 1 -2.342886,-2.30693 40.632485,43.292687 0 0 1 -2.166082,-2.49664 40.632485,43.292687 0 0 1 -1.974639,-2.66909 40.632485,43.292687 0 0 1 -1.772504,-2.82696 40.632485,43.292687 0 0 1 -1.559022,-2.96558 40.632485,43.292687 0 0 1 -1.335549,-3.08697 40.632485,43.292687 0 0 1 -1.104725,-3.18911 40.632485,43.292687 0 0 1 -0.865908,-3.27203 40.632485,43.292687 0 0 1 -0.622408,-3.33372 40.632485,43.292687 0 0 1 -0.374918,-3.37616 40.632485,43.292687 0 0 1 -0.125409,-3.39673 40.632485,43.292687 0 0 1 0.125409,-3.39673 40.632485,43.292687 0 0 1 0.374918,-3.3755 40.632485,43.292687 0 0 1 0.622408,-3.33371 40.632485,43.292687 0 0 1 0.865908,-3.27203 40.632485,43.292687 0 0 1 1.104725,-3.18911 40.632485,43.292687 0 0 1 1.335549,-3.08697 40.632485,43.292687 0 0 1 1.559022,-2.96625 40.632485,43.292687 0 0 1 1.772504,-2.82629 40.632485,43.292687 0 0 1 1.974639,-2.66976 40.632485,43.292687 0 0 1 2.166082,-2.49597 40.632485,43.292687 0 0 1 2.342886,-2.3076 40.632485,43.292687 0 0 1 2.505648,-2.10463 40.632485,43.292687 0 0 1 2.652426,-1.8884 40.632485,43.292687 0 0 1 2.783836,-1.66089 40.632485,43.292687 0 0 1 2.897245,-1.42343 40.632485,43.292687 0 0 1 2.993303,-1.17601 40.632485,43.292687 0 0 1 3.070689,-0.92331 40.632485,43.292687 0 0 1 3.12939,-0.66263 40.632485,43.292687 0 0 1 3.16809,-0.39997 40.632485,43.292687 0 0 1 3.188093,-0.13332 z"
+ clip-path="url(#clipPath20711)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-63"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="m 90.144156,110.56416 a 35.545008,38.588202 0 0 0 -35.544717,38.58853 35.545008,38.588202 0 0 0 35.544717,38.58802 35.545008,38.588202 0 0 0 35.545224,-38.58802 35.545008,38.588202 0 0 0 -35.545224,-38.58853 z m -0.07061,4.90892 a 31.151221,33.78691 0 0 1 0.829048,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.827519,0.0595 31.151221,33.78691 0 0 1 0.825964,0.0839 31.151221,33.78691 0 0 1 0.823425,0.10767 31.151221,33.78691 0 0 1 0.820349,0.13097 31.151221,33.78691 0 0 1 0.816775,0.15478 31.151221,33.78691 0 0 1 0.813198,0.17859 31.151221,33.78691 0 0 1 0.808083,0.20188 31.151221,33.78691 0 0 1 0.802953,0.22518 31.151221,33.78691 0 0 1 0.797338,0.24796 31.151221,33.78691 0 0 1 0.7907,0.27125 31.151221,33.78691 0 0 1 0.783542,0.29403 31.151221,33.78691 0 0 1 0.77635,0.31629 31.151221,33.78691 0 0 1 0.76818,0.33854 31.151221,33.78691 0 0 1 0.76002,0.3608 31.151221,33.78691 0 0 1 0.75079,0.38255 31.151221,33.78691 0 0 1 0.74058,0.40429 31.151221,33.78691 0 0 1 0.73085,0.42499 31.151221,33.78691 0 0 1 0.7201,0.44622 31.151221,33.78691 0 0 1 0.70885,0.46692 31.151221,33.78691 0 0 1 0.6971,0.48764 31.151221,33.78691 0 0 1 0.68484,0.50678 31.151221,33.78691 0 0 1 0.67254,0.52697 31.151221,33.78691 0 0 1 0.65872,0.54613 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.58339 31.151221,33.78691 0 0 1 0.61678,0.601 31.151221,33.78691 0 0 1 0.60196,0.61911 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65277 31.151221,33.78691 0 0 1 0.5544,0.66932 31.151221,33.78691 0 0 1 0.53751,0.68486 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71488 31.151221,33.78691 0 0 1 0.48534,0.72885 31.151221,33.78691 0 0 1 0.46798,0.74284 31.151221,33.78691 0 0 1 0.44904,0.75629 31.151221,33.78691 0 0 1 0.43065,0.76872 31.151221,33.78691 0 0 1 0.41119,0.78114 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80391 31.151221,33.78691 0 0 1 0.35239,0.81376 31.151221,33.78691 0 0 1 0.33294,0.8241 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.84171 31.151221,33.78691 0 0 1 0.27107,0.85051 31.151221,33.78691 0 0 1 0.2501,0.85775 31.151221,33.78691 0 0 1 0.2286,0.86448 31.151221,33.78691 0 0 1 0.20765,0.8707 31.151221,33.78691 0 0 1 0.18616,0.8769 31.151221,33.78691 0 0 1 0.16468,0.88157 31.151221,33.78691 0 0 1 0.14268,0.8857 31.151221,33.78691 0 0 1 0.12073,0.88985 31.151221,33.78691 0 0 1 0.0992,0.89347 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.6509 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60173 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40916 31.151221,33.78691 0 0 1 -1.19526,2.31444 31.151221,33.78691 0 0 1 -1.3589,2.20624 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64252 31.151221,33.78691 0 0 1 -2.034,1.47428 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.2217,1.11037 31.151221,33.78691 0 0 1 -2.294332,0.91832 31.151221,33.78691 0 0 1 -2.354157,0.72006 31.151221,33.78691 0 0 1 -2.399184,0.51765 31.151221,33.78691 0 0 1 -2.428831,0.31163 31.151221,33.78691 0 0 1 -2.444189,0.10457 31.151221,33.78691 0 0 1 -2.44418,-0.10457 31.151221,33.78691 0 0 1 -2.428838,-0.31163 31.151221,33.78691 0 0 1 -2.399177,-0.51765 31.151221,33.78691 0 0 1 -2.354163,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91831 31.151221,33.78691 0 0 1 -2.221196,-1.11037 31.151221,33.78691 0 0 1 -2.134249,-1.29621 31.151221,33.78691 0 0 1 -2.033489,-1.47428 31.151221,33.78691 0 0 1 -1.920973,-1.64252 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20624 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.65091 31.151221,33.78691 0 0 1 0.09615,-2.65091 31.151221,33.78691 0 0 1 0.287434,-2.63433 31.151221,33.78691 0 0 1 0.477175,-2.60174 31.151221,33.78691 0 0 1 0.663854,-2.55358 31.151221,33.78691 0 0 1 0.846946,-2.48889 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31495 31.151221,33.78691 0 0 1 1.358905,-2.20572 31.151221,33.78691 0 0 1 1.513873,-2.08356 31.151221,33.78691 0 0 1 1.660643,-1.94793 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920973,-1.64252 31.151221,33.78691 0 0 1 2.033505,-1.47376 31.151221,33.78691 0 0 1 2.13425,-1.29621 31.151221,33.78691 0 0 1 2.221195,-1.11088 31.151221,33.78691 0 0 1 2.294841,-0.9178 31.151221,33.78691 0 0 1 2.354165,-0.72058 31.151221,33.78691 0 0 1 2.399176,-0.51713 31.151221,33.78691 0 0 1 2.428838,-0.31215 31.151221,33.78691 0 0 1 2.44418,-0.10405 z"
+ clip-path="url(#clipPath20707)"
+ transform="translate(-177.74838,-111.36079)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ d="m 119.20127,221.87113 c 15.58969,0 29.12922,9.40117 35.96102,23.20181 h -5.81736 c -6.31922,-10.6997 -17.45681,-17.80491 -30.14366,-17.80491 -19.690574,0 -35.652879,17.112 -35.652879,38.22056 0,10.3318 3.825597,19.70468 10.03957,26.58295 -1.342357,1.12091 -2.771532,2.1279 -4.275488,3.00675 -6.701874,-7.77494 -10.798502,-18.16843 -10.798502,-29.5897 0,-24.08921 18.216325,-43.61746 40.687299,-43.61746 z m 35.852,64.25471 c -6.86645,13.68013 -20.34561,22.98022 -35.852,22.98022 -1.0527,0 -2.0961,-0.0429 -3.12869,-0.12703 3.0522,-1.56117 5.91359,-3.48039 8.53831,-5.70731 10.32096,-1.68438 19.18598,-8.11363 24.60181,-17.14588 z"
+ id="path2350-0"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943"
+ clip-path="url(#clipPath20703)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ d="m 96.751486,221.87113 c 1.052607,0 2.095998,0.0429 3.128684,0.12706 -3.052192,1.56117 -5.913678,3.48036 -8.538403,5.70731 -17.123111,2.7943 -30.243159,18.646 -30.243159,37.78309 0,14.26457 7.29059,26.70203 18.093843,33.26893 -1.593656,0.26719 -3.226966,0.40695 -4.890748,0.40695 -1.239545,0 -2.46151,-0.0795 -3.663522,-0.22937 -8.907938,-8.00114 -14.573991,-20.01353 -14.573991,-33.44651 0,-24.08921 18.216324,-43.61746 40.687296,-43.61746 z m 5.409714,81.40059 c 10.32112,-1.68438 19.18608,-8.11394 24.60197,-17.14636 h 5.84051 c -6.86635,13.68031 -20.34554,22.9807 -35.852194,22.9807 -1.052703,0 -2.095999,-0.0429 -3.128684,-0.12703 3.052002,-1.56137 5.913836,-3.48022 8.538398,-5.70731 z m 24.7338,-58.19878 c -3.13939,-5.31472 -7.46755,-9.74275 -12.58451,-12.85327 1.59365,-0.26719 3.2269,-0.40695 4.89078,-0.40695 1.23945,0 2.46151,0.0795 3.66352,0.22937 4.01602,3.60724 7.3732,8.03011 9.84905,13.03085 z"
+ id="path2352-7"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943"
+ clip-path="url(#clipPath20699)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ d="m 74.301703,221.87113 c 1.064296,0 2.118804,0.0444 3.162607,0.13022 -3.046523,1.55896 -5.903162,3.47451 -8.52358,5.69681 -17.146992,2.77237 -30.291903,18.63524 -30.291903,37.79043 0,21.10857 15.962401,38.22057 35.652876,38.22057 12.599746,0 23.672446,-7.00705 30.013747,-17.5838 h 5.83852 c -6.86636,13.68031 -20.345616,22.9807 -35.852267,22.9807 -22.470907,0 -40.6872,-19.52825 -40.6872,-43.61747 0,-24.08921 18.216293,-43.61746 40.6872,-43.61746 z m 30.142787,23.20181 c -1.31192,-2.22057 -2.83098,-4.28705 -4.528878,-6.16651 1.342448,-1.12094 2.771378,-2.12838 4.275138,-3.00723 2.37299,2.75301 4.41888,5.83464 6.07249,9.17374 z"
+ id="path2354-0"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943"
+ clip-path="url(#clipPath20695)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-9"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0376767;stroke-linejoin:round;stroke-opacity:1"
+ d="m 119.12038,221.87113 a 40.722405,43.678338 0 0 0 -40.722073,43.67871 40.722405,43.678338 0 0 0 40.722073,43.67813 40.722405,43.678338 0 0 0 40.72263,-43.67813 40.722405,43.678338 0 0 0 -40.72263,-43.67871 z m -0.0809,5.55645 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.94628,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12187 35.68863,38.243712 0 0 1 0.93984,0.14824 35.68863,38.243712 0 0 1 0.93574,0.1752 35.68863,38.243712 0 0 1 0.93165,0.20215 35.68863,38.243712 0 0 1 0.92578,0.22851 35.68863,38.243712 0 0 1 0.91992,0.25489 35.68863,38.243712 0 0 1 0.91348,0.28066 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33281 35.68863,38.243712 0 0 1 0.88945,0.35801 35.68863,38.243712 0 0 1 0.88006,0.38321 35.68863,38.243712 0 0 1 0.87071,0.40839 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52852 35.68863,38.243712 0 0 1 0.79863,0.55195 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59649 35.68863,38.243712 0 0 1 0.75469,0.61816 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68027 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71954 35.68863,38.243712 0 0 1 0.65391,0.73886 35.68863,38.243712 0 0 1 0.63515,0.75762 35.68863,38.243712 0 0 1 0.61582,0.7752 35.68863,38.243712 0 0 1 0.59649,0.79218 35.68863,38.243712 0 0 1 0.57715,0.80918 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84082 35.68863,38.243712 0 0 1 0.51446,0.85606 35.68863,38.243712 0 0 1 0.49336,0.87012 35.68863,38.243712 0 0 1 0.47109,0.88417 35.68863,38.243712 0 0 1 0.44941,0.89708 35.68863,38.243712 0 0 1 0.42715,0.90996 35.68863,38.243712 0 0 1 0.40371,0.92109 35.68863,38.243712 0 0 1 0.38145,0.93281 35.68863,38.243712 0 0 1 0.35742,0.94336 35.68863,38.243712 0 0 1 0.33399,0.95274 35.68863,38.243712 0 0 1 0.31054,0.96269 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97852 35.68863,38.243712 0 0 1 0.23789,0.98554 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99785 35.68863,38.243712 0 0 1 0.16348,1.00254 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01133 35.68863,38.243712 0 0 1 0.0885,1.01367 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01778 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98242 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89044 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.17305,2.72696 35.68863,38.243712 0 0 1 -1.36934,2.61972 35.68863,38.243712 0 0 1 -1.55683,2.49727 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46718 35.68863,38.243712 0 0 1 -2.54531,1.25684 35.68863,38.243712 0 0 1 -2.6285,1.03945 35.68863,38.243712 0 0 1 -2.69706,0.81504 35.68863,38.243712 0 0 1 -2.74864,0.58594 35.68863,38.243712 0 0 1 -2.78261,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58594 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03945 35.68863,38.243712 0 0 1 -2.54473,-1.25684 35.68863,38.243712 0 0 1 -2.44512,-1.46718 35.68863,38.243712 0 0 1 -2.329683,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49727 35.68863,38.243712 0 0 1 -1.36933,-2.61972 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89044 35.68863,38.243712 0 0 1 -0.54668,-2.94492 35.68863,38.243712 0 0 1 -0.3293,-2.98242 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00059 35.68863,38.243712 0 0 1 0.3293,-2.98184 35.68863,38.243712 0 0 1 0.54668,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81719 35.68863,38.243712 0 0 1 1.17305,-2.72695 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20488 35.68863,38.243712 0 0 1 2.05782,-2.03848 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.329683,-1.66816 35.68863,38.243712 0 0 1 2.44512,-1.46719 35.68863,38.243712 0 0 1 2.54473,-1.25742 35.68863,38.243712 0 0 1 2.6291,-1.03887 35.68863,38.243712 0 0 1 2.69707,-0.81562 35.68863,38.243712 0 0 1 2.74863,-0.58536 35.68863,38.243712 0 0 1 2.78262,-0.35332 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20671)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-63-4"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="m 118.88484,227.11603 a 35.545008,38.588202 0 0 0 -35.544718,38.58853 35.545008,38.588202 0 0 0 35.544718,38.58802 35.545008,38.588202 0 0 0 35.54521,-38.58802 35.545008,38.588202 0 0 0 -35.54521,-38.58853 z m -0.0706,4.90891 a 31.151221,33.78691 0 0 1 0.82905,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.82752,0.0595 31.151221,33.78691 0 0 1 0.82596,0.0839 31.151221,33.78691 0 0 1 0.82342,0.10768 31.151221,33.78691 0 0 1 0.82035,0.13096 31.151221,33.78691 0 0 1 0.81678,0.15478 31.151221,33.78691 0 0 1 0.8132,0.17859 31.151221,33.78691 0 0 1 0.80808,0.20189 31.151221,33.78691 0 0 1 0.80295,0.22518 31.151221,33.78691 0 0 1 0.79734,0.24795 31.151221,33.78691 0 0 1 0.7907,0.27125 31.151221,33.78691 0 0 1 0.78353,0.29403 31.151221,33.78691 0 0 1 0.77636,0.31629 31.151221,33.78691 0 0 1 0.76818,0.33854 31.151221,33.78691 0 0 1 0.76002,0.36081 31.151221,33.78691 0 0 1 0.75078,0.38255 31.151221,33.78691 0 0 1 0.74058,0.40428 31.151221,33.78691 0 0 1 0.73085,0.425 31.151221,33.78691 0 0 1 0.7201,0.44622 31.151221,33.78691 0 0 1 0.70885,0.46692 31.151221,33.78691 0 0 1 0.6971,0.48763 31.151221,33.78691 0 0 1 0.68484,0.50678 31.151221,33.78691 0 0 1 0.67254,0.52698 31.151221,33.78691 0 0 1 0.65872,0.54612 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.5834 31.151221,33.78691 0 0 1 0.61678,0.60099 31.151221,33.78691 0 0 1 0.60196,0.61912 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65276 31.151221,33.78691 0 0 1 0.5544,0.66933 31.151221,33.78691 0 0 1 0.53751,0.68485 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71488 31.151221,33.78691 0 0 1 0.48534,0.72886 31.151221,33.78691 0 0 1 0.46798,0.74284 31.151221,33.78691 0 0 1 0.44904,0.75629 31.151221,33.78691 0 0 1 0.43065,0.76871 31.151221,33.78691 0 0 1 0.41119,0.78114 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80392 31.151221,33.78691 0 0 1 0.35239,0.81375 31.151221,33.78691 0 0 1 0.33294,0.82411 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.8417 31.151221,33.78691 0 0 1 0.27107,0.85051 31.151221,33.78691 0 0 1 0.2501,0.85775 31.151221,33.78691 0 0 1 0.2286,0.86448 31.151221,33.78691 0 0 1 0.20765,0.8707 31.151221,33.78691 0 0 1 0.18616,0.87691 31.151221,33.78691 0 0 1 0.16468,0.88156 31.151221,33.78691 0 0 1 0.14268,0.88571 31.151221,33.78691 0 0 1 0.12073,0.88985 31.151221,33.78691 0 0 1 0.0992,0.89347 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.6509 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60173 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40916 31.151221,33.78691 0 0 1 -1.19526,2.31443 31.151221,33.78691 0 0 1 -1.3589,2.20625 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64251 31.151221,33.78691 0 0 1 -2.034,1.47429 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.22169,1.11037 31.151221,33.78691 0 0 1 -2.29433,0.91832 31.151221,33.78691 0 0 1 -2.35416,0.72006 31.151221,33.78691 0 0 1 -2.39918,0.51765 31.151221,33.78691 0 0 1 -2.42883,0.31163 31.151221,33.78691 0 0 1 -2.44419,0.10456 31.151221,33.78691 0 0 1 -2.44418,-0.10456 31.151221,33.78691 0 0 1 -2.42884,-0.31163 31.151221,33.78691 0 0 1 -2.39918,-0.51765 31.151221,33.78691 0 0 1 -2.35416,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91831 31.151221,33.78691 0 0 1 -2.22119,-1.11038 31.151221,33.78691 0 0 1 -2.13425,-1.2962 31.151221,33.78691 0 0 1 -2.03349,-1.47428 31.151221,33.78691 0 0 1 -1.920975,-1.64252 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20624 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.65091 31.151221,33.78691 0 0 1 0.09615,-2.65091 31.151221,33.78691 0 0 1 0.287434,-2.63434 31.151221,33.78691 0 0 1 0.477175,-2.60173 31.151221,33.78691 0 0 1 0.663854,-2.55359 31.151221,33.78691 0 0 1 0.846946,-2.48888 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31495 31.151221,33.78691 0 0 1 1.358905,-2.20573 31.151221,33.78691 0 0 1 1.513873,-2.08355 31.151221,33.78691 0 0 1 1.660643,-1.94794 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920975,-1.64251 31.151221,33.78691 0 0 1 2.0335,-1.47377 31.151221,33.78691 0 0 1 2.13425,-1.2962 31.151221,33.78691 0 0 1 2.2212,-1.11089 31.151221,33.78691 0 0 1 2.29484,-0.9178 31.151221,33.78691 0 0 1 2.35416,-0.72057 31.151221,33.78691 0 0 1 2.39918,-0.51714 31.151221,33.78691 0 0 1 2.42884,-0.31215 31.151221,33.78691 0 0 1 2.44418,-0.10404 z"
+ clip-path="url(#clipPath20663)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-0-7"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0376767;stroke-linejoin:round"
+ d="m 96.751486,221.87113 a 40.722405,43.678338 0 0 0 -40.72207,43.67871 40.722405,43.678338 0 0 0 40.72207,43.67813 40.722405,43.678338 0 0 0 40.722634,-43.67813 40.722405,43.678338 0 0 0 -40.722634,-43.67871 z m -0.0809,5.55645 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.946284,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12187 35.68863,38.243712 0 0 1 0.93984,0.14825 35.68863,38.243712 0 0 1 0.93574,0.17519 35.68863,38.243712 0 0 1 0.93165,0.20215 35.68863,38.243712 0 0 1 0.92578,0.22851 35.68863,38.243712 0 0 1 0.91992,0.25489 35.68863,38.243712 0 0 1 0.91348,0.28066 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33282 35.68863,38.243712 0 0 1 0.88945,0.358 35.68863,38.243712 0 0 1 0.88006,0.38321 35.68863,38.243712 0 0 1 0.87071,0.40839 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52852 35.68863,38.243712 0 0 1 0.79863,0.55195 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59649 35.68863,38.243712 0 0 1 0.75469,0.61816 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68028 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71953 35.68863,38.243712 0 0 1 0.65391,0.73887 35.68863,38.243712 0 0 1 0.63515,0.75761 35.68863,38.243712 0 0 1 0.61582,0.7752 35.68863,38.243712 0 0 1 0.59649,0.79219 35.68863,38.243712 0 0 1 0.57715,0.80917 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84083 35.68863,38.243712 0 0 1 0.51446,0.85605 35.68863,38.243712 0 0 1 0.49336,0.87012 35.68863,38.243712 0 0 1 0.47109,0.88418 35.68863,38.243712 0 0 1 0.44941,0.89707 35.68863,38.243712 0 0 1 0.42715,0.90996 35.68863,38.243712 0 0 1 0.40371,0.92109 35.68863,38.243712 0 0 1 0.38145,0.93282 35.68863,38.243712 0 0 1 0.35742,0.94335 35.68863,38.243712 0 0 1 0.33399,0.95274 35.68863,38.243712 0 0 1 0.31054,0.96269 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97852 35.68863,38.243712 0 0 1 0.23789,0.98554 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99786 35.68863,38.243712 0 0 1 0.16348,1.00253 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01133 35.68863,38.243712 0 0 1 0.0885,1.01367 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01778 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98243 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89043 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.17305,2.72696 35.68863,38.243712 0 0 1 -1.36934,2.61972 35.68863,38.243712 0 0 1 -1.55683,2.49727 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46719 35.68863,38.243712 0 0 1 -2.54531,1.25683 35.68863,38.243712 0 0 1 -2.6285,1.03946 35.68863,38.243712 0 0 1 -2.69706,0.81504 35.68863,38.243712 0 0 1 -2.74864,0.58593 35.68863,38.243712 0 0 1 -2.782614,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58593 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03946 35.68863,38.243712 0 0 1 -2.54473,-1.25683 35.68863,38.243712 0 0 1 -2.44512,-1.46719 35.68863,38.243712 0 0 1 -2.32968,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49727 35.68863,38.243712 0 0 1 -1.36933,-2.61972 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89043 35.68863,38.243712 0 0 1 -0.54668,-2.94492 35.68863,38.243712 0 0 1 -0.3293,-2.98243 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00059 35.68863,38.243712 0 0 1 0.3293,-2.98184 35.68863,38.243712 0 0 1 0.54668,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81718 35.68863,38.243712 0 0 1 1.17305,-2.72696 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20488 35.68863,38.243712 0 0 1 2.05782,-2.03848 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.32968,-1.66816 35.68863,38.243712 0 0 1 2.44512,-1.46719 35.68863,38.243712 0 0 1 2.54473,-1.25742 35.68863,38.243712 0 0 1 2.6291,-1.03887 35.68863,38.243712 0 0 1 2.69707,-0.81562 35.68863,38.243712 0 0 1 2.74863,-0.58535 35.68863,38.243712 0 0 1 2.78262,-0.35333 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20687)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-63-9-8"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="m 74.220853,227.42758 a 35.545008,38.588202 0 0 0 -35.544717,38.58853 35.545008,38.588202 0 0 0 35.544717,38.58802 35.545008,38.588202 0 0 0 35.545217,-38.58802 35.545008,38.588202 0 0 0 -35.545217,-38.58853 z m -0.07061,4.90891 a 31.151221,33.78691 0 0 1 0.829048,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.827519,0.0595 31.151221,33.78691 0 0 1 0.825964,0.0839 31.151221,33.78691 0 0 1 0.823425,0.10768 31.151221,33.78691 0 0 1 0.820349,0.13096 31.151221,33.78691 0 0 1 0.816775,0.15478 31.151221,33.78691 0 0 1 0.813198,0.17859 31.151221,33.78691 0 0 1 0.808083,0.20189 31.151221,33.78691 0 0 1 0.802953,0.22518 31.151221,33.78691 0 0 1 0.797339,0.24795 31.151221,33.78691 0 0 1 0.790697,0.27125 31.151221,33.78691 0 0 1 0.783536,0.29403 31.151221,33.78691 0 0 1 0.776355,0.31629 31.151221,33.78691 0 0 1 0.768177,0.33854 31.151221,33.78691 0 0 1 0.760022,0.36081 31.151221,33.78691 0 0 1 0.750783,0.38255 31.151221,33.78691 0 0 1 0.740582,0.40428 31.151221,33.78691 0 0 1 0.73085,0.425 31.151221,33.78691 0 0 1 0.7201,0.44622 31.151221,33.78691 0 0 1 0.70885,0.46692 31.151221,33.78691 0 0 1 0.6971,0.48763 31.151221,33.78691 0 0 1 0.68484,0.50678 31.151221,33.78691 0 0 1 0.67254,0.52698 31.151221,33.78691 0 0 1 0.65872,0.54612 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.5834 31.151221,33.78691 0 0 1 0.61678,0.60099 31.151221,33.78691 0 0 1 0.60196,0.61912 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65276 31.151221,33.78691 0 0 1 0.5544,0.66933 31.151221,33.78691 0 0 1 0.53751,0.68485 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71488 31.151221,33.78691 0 0 1 0.48534,0.72886 31.151221,33.78691 0 0 1 0.467982,0.74283 31.151221,33.78691 0 0 1 0.44904,0.7563 31.151221,33.78691 0 0 1 0.43065,0.76871 31.151221,33.78691 0 0 1 0.41119,0.78114 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80392 31.151221,33.78691 0 0 1 0.35239,0.81375 31.151221,33.78691 0 0 1 0.33294,0.82411 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.8417 31.151221,33.78691 0 0 1 0.27107,0.85051 31.151221,33.78691 0 0 1 0.2501,0.85776 31.151221,33.78691 0 0 1 0.2286,0.86447 31.151221,33.78691 0 0 1 0.20765,0.8707 31.151221,33.78691 0 0 1 0.18616,0.87691 31.151221,33.78691 0 0 1 0.16468,0.88156 31.151221,33.78691 0 0 1 0.14268,0.88571 31.151221,33.78691 0 0 1 0.12073,0.88985 31.151221,33.78691 0 0 1 0.0992,0.89347 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.6509 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60173 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40916 31.151221,33.78691 0 0 1 -1.19526,2.31443 31.151221,33.78691 0 0 1 -1.358902,2.20625 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64252 31.151221,33.78691 0 0 1 -2.034,1.47428 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.221695,1.11037 31.151221,33.78691 0 0 1 -2.294328,0.91832 31.151221,33.78691 0 0 1 -2.354157,0.72006 31.151221,33.78691 0 0 1 -2.399184,0.51765 31.151221,33.78691 0 0 1 -2.428831,0.31163 31.151221,33.78691 0 0 1 -2.444189,0.10456 31.151221,33.78691 0 0 1 -2.44418,-0.10456 31.151221,33.78691 0 0 1 -2.428838,-0.31163 31.151221,33.78691 0 0 1 -2.399177,-0.51765 31.151221,33.78691 0 0 1 -2.354163,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91831 31.151221,33.78691 0 0 1 -2.221196,-1.11038 31.151221,33.78691 0 0 1 -2.134249,-1.2962 31.151221,33.78691 0 0 1 -2.033489,-1.47428 31.151221,33.78691 0 0 1 -1.920973,-1.64252 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20624 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.65091 31.151221,33.78691 0 0 1 0.09615,-2.65091 31.151221,33.78691 0 0 1 0.287434,-2.63434 31.151221,33.78691 0 0 1 0.477175,-2.60173 31.151221,33.78691 0 0 1 0.663854,-2.55359 31.151221,33.78691 0 0 1 0.846946,-2.48888 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31495 31.151221,33.78691 0 0 1 1.358905,-2.20573 31.151221,33.78691 0 0 1 1.513873,-2.08355 31.151221,33.78691 0 0 1 1.660643,-1.94794 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920973,-1.64251 31.151221,33.78691 0 0 1 2.033505,-1.47376 31.151221,33.78691 0 0 1 2.13425,-1.29621 31.151221,33.78691 0 0 1 2.221195,-1.11089 31.151221,33.78691 0 0 1 2.294841,-0.9178 31.151221,33.78691 0 0 1 2.354165,-0.72057 31.151221,33.78691 0 0 1 2.399176,-0.51714 31.151221,33.78691 0 0 1 2.428838,-0.31214 31.151221,33.78691 0 0 1 2.44418,-0.10405 z"
+ clip-path="url(#clipPath20691)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-63-6-2"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0330856;stroke-linejoin:round;stroke-opacity:1"
+ d="m 96.670636,227.42758 a 35.545008,38.588202 0 0 0 -35.544717,38.58854 35.545008,38.588202 0 0 0 35.544717,38.58801 35.545008,38.588202 0 0 0 35.545214,-38.58801 35.545008,38.588202 0 0 0 -35.545214,-38.58854 z m -0.07061,4.90892 a 31.151221,33.78691 0 0 1 0.829048,0.0119 31.151221,33.78691 0 0 1 0.82854,0.0362 31.151221,33.78691 0 0 1 0.827519,0.0595 31.151221,33.78691 0 0 1 0.825964,0.0839 31.151221,33.78691 0 0 1 0.823423,0.10767 31.151221,33.78691 0 0 1 0.82035,0.13097 31.151221,33.78691 0 0 1 0.81678,0.15477 31.151221,33.78691 0 0 1 0.81319,0.1786 31.151221,33.78691 0 0 1 0.80809,0.20188 31.151221,33.78691 0 0 1 0.80295,0.22518 31.151221,33.78691 0 0 1 0.79734,0.24796 31.151221,33.78691 0 0 1 0.7907,0.27125 31.151221,33.78691 0 0 1 0.78353,0.29403 31.151221,33.78691 0 0 1 0.77636,0.31628 31.151221,33.78691 0 0 1 0.76817,0.33855 31.151221,33.78691 0 0 1 0.76003,0.3608 31.151221,33.78691 0 0 1 0.75078,0.38255 31.151221,33.78691 0 0 1 0.74058,0.40429 31.151221,33.78691 0 0 1 0.73085,0.42499 31.151221,33.78691 0 0 1 0.7201,0.44622 31.151221,33.78691 0 0 1 0.70885,0.46692 31.151221,33.78691 0 0 1 0.6971,0.48763 31.151221,33.78691 0 0 1 0.68484,0.50679 31.151221,33.78691 0 0 1 0.67254,0.52697 31.151221,33.78691 0 0 1 0.65872,0.54612 31.151221,33.78691 0 0 1 0.64545,0.56476 31.151221,33.78691 0 0 1 0.63165,0.5834 31.151221,33.78691 0 0 1 0.61678,0.601 31.151221,33.78691 0 0 1 0.60196,0.61911 31.151221,33.78691 0 0 1 0.58611,0.63568 31.151221,33.78691 0 0 1 0.57078,0.65276 31.151221,33.78691 0 0 1 0.5544,0.66933 31.151221,33.78691 0 0 1 0.53751,0.68486 31.151221,33.78691 0 0 1 0.52066,0.69987 31.151221,33.78691 0 0 1 0.50379,0.71487 31.151221,33.78691 0 0 1 0.48534,0.72886 31.151221,33.78691 0 0 1 0.46798,0.74284 31.151221,33.78691 0 0 1 0.44904,0.75629 31.151221,33.78691 0 0 1 0.43065,0.76872 31.151221,33.78691 0 0 1 0.41119,0.78113 31.151221,33.78691 0 0 1 0.39227,0.79253 31.151221,33.78691 0 0 1 0.37284,0.80392 31.151221,33.78691 0 0 1 0.35239,0.81375 31.151221,33.78691 0 0 1 0.33294,0.82411 31.151221,33.78691 0 0 1 0.31198,0.83342 31.151221,33.78691 0 0 1 0.29153,0.84171 31.151221,33.78691 0 0 1 0.27107,0.8505 31.151221,33.78691 0 0 1 0.2501,0.85776 31.151221,33.78691 0 0 1 0.2286,0.86448 31.151221,33.78691 0 0 1 0.20765,0.87069 31.151221,33.78691 0 0 1 0.18616,0.87691 31.151221,33.78691 0 0 1 0.16468,0.88157 31.151221,33.78691 0 0 1 0.14268,0.8857 31.151221,33.78691 0 0 1 0.12073,0.88985 31.151221,33.78691 0 0 1 0.0992,0.89347 31.151221,33.78691 0 0 1 0.0772,0.89554 31.151221,33.78691 0 0 1 0.0553,0.89761 31.151221,33.78691 0 0 1 0.0332,0.89865 31.151221,33.78691 0 0 1 0.0108,0.89917 31.151221,33.78691 0 0 1 -0.0957,2.6509 31.151221,33.78691 0 0 1 -0.28795,2.63486 31.151221,33.78691 0 0 1 -0.47716,2.60173 31.151221,33.78691 0 0 1 -0.66385,2.55359 31.151221,33.78691 0 0 1 -0.84644,2.48888 31.151221,33.78691 0 0 1 -1.0239,2.40917 31.151221,33.78691 0 0 1 -1.19526,2.31443 31.151221,33.78691 0 0 1 -1.3589,2.20624 31.151221,33.78691 0 0 1 -1.51437,2.08304 31.151221,33.78691 0 0 1 -1.66015,1.94845 31.151221,33.78691 0 0 1 -1.79618,1.8004 31.151221,33.78691 0 0 1 -1.92098,1.64252 31.151221,33.78691 0 0 1 -2.034,1.47428 31.151221,33.78691 0 0 1 -2.13375,1.2962 31.151221,33.78691 0 0 1 -2.22169,1.11037 31.151221,33.78691 0 0 1 -2.29433,0.91832 31.151221,33.78691 0 0 1 -2.35416,0.72006 31.151221,33.78691 0 0 1 -2.39918,0.51765 31.151221,33.78691 0 0 1 -2.428834,0.31163 31.151221,33.78691 0 0 1 -2.444189,0.10457 31.151221,33.78691 0 0 1 -2.44418,-0.10457 31.151221,33.78691 0 0 1 -2.428838,-0.31162 31.151221,33.78691 0 0 1 -2.399177,-0.51766 31.151221,33.78691 0 0 1 -2.354163,-0.72006 31.151221,33.78691 0 0 1 -2.29484,-0.91831 31.151221,33.78691 0 0 1 -2.221196,-1.11037 31.151221,33.78691 0 0 1 -2.134249,-1.29621 31.151221,33.78691 0 0 1 -2.033489,-1.47428 31.151221,33.78691 0 0 1 -1.920973,-1.64251 31.151221,33.78691 0 0 1 -1.796194,-1.8004 31.151221,33.78691 0 0 1 -1.660643,-1.94845 31.151221,33.78691 0 0 1 -1.513873,-2.08304 31.151221,33.78691 0 0 1 -1.358905,-2.20625 31.151221,33.78691 0 0 1 -1.195236,-2.31443 31.151221,33.78691 0 0 1 -1.023911,-2.40916 31.151221,33.78691 0 0 1 -0.846946,-2.48888 31.151221,33.78691 0 0 1 -0.663854,-2.55359 31.151221,33.78691 0 0 1 -0.477175,-2.60173 31.151221,33.78691 0 0 1 -0.287434,-2.63486 31.151221,33.78691 0 0 1 -0.09615,-2.65091 31.151221,33.78691 0 0 1 0.09615,-2.6509 31.151221,33.78691 0 0 1 0.287434,-2.63435 31.151221,33.78691 0 0 1 0.477175,-2.60173 31.151221,33.78691 0 0 1 0.663854,-2.55359 31.151221,33.78691 0 0 1 0.846946,-2.48888 31.151221,33.78691 0 0 1 1.023911,-2.40916 31.151221,33.78691 0 0 1 1.195236,-2.31495 31.151221,33.78691 0 0 1 1.358905,-2.20572 31.151221,33.78691 0 0 1 1.513873,-2.08356 31.151221,33.78691 0 0 1 1.660643,-1.94793 31.151221,33.78691 0 0 1 1.796194,-1.80092 31.151221,33.78691 0 0 1 1.920973,-1.64252 31.151221,33.78691 0 0 1 2.033505,-1.47376 31.151221,33.78691 0 0 1 2.13425,-1.29621 31.151221,33.78691 0 0 1 2.221195,-1.11088 31.151221,33.78691 0 0 1 2.294841,-0.9178 31.151221,33.78691 0 0 1 2.354165,-0.72058 31.151221,33.78691 0 0 1 2.399176,-0.51714 31.151221,33.78691 0 0 1 2.428838,-0.31214 31.151221,33.78691 0 0 1 2.44418,-0.10405 z"
+ clip-path="url(#clipPath20675)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-6-6"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0427732;stroke-linejoin:round;stroke-opacity:1"
+ d="M 96.843606,215.58112 A 46.363577,49.444797 0 0 0 50.480411,265.02634 46.363577,49.444797 0 0 0 96.843606,314.4709 46.363577,49.444797 0 0 0 143.20744,265.02634 46.363577,49.444797 0 0 0 96.843606,215.58112 Z m -0.09212,6.29001 a 40.632485,43.292687 0 0 1 1.081384,0.0153 40.632485,43.292687 0 0 1 1.080714,0.0464 40.632485,43.292687 0 0 1 1.079381,0.0763 40.632485,43.292687 0 0 1 1.077365,0.10746 40.632485,43.292687 0 0 1 1.07404,0.13796 40.632485,43.292687 0 0 1 1.07004,0.16781 40.632485,43.292687 0 0 1 1.06536,0.19833 40.632485,43.292687 0 0 1 1.06071,0.22884 40.632485,43.292687 0 0 1 1.05403,0.25868 40.632485,43.292687 0 0 1 1.04735,0.28853 40.632485,43.292687 0 0 1 1.04002,0.31772 40.632485,43.292687 0 0 1 1.03135,0.34757 40.632485,43.292687 0 0 1 1.02201,0.37675 40.632485,43.292687 0 0 1 1.01266,0.40527 40.632485,43.292687 0 0 1 1.00198,0.43379 40.632485,43.292687 0 0 1 0.99134,0.46232 40.632485,43.292687 0 0 1 0.97929,0.49017 40.632485,43.292687 0 0 1 0.96598,0.51804 40.632485,43.292687 0 0 1 0.9533,0.54456 40.632485,43.292687 0 0 1 0.93927,0.57176 40.632485,43.292687 0 0 1 0.92461,0.59829 40.632485,43.292687 0 0 1 0.90926,0.62482 40.632485,43.292687 0 0 1 0.89328,0.64937 40.632485,43.292687 0 0 1 0.87724,0.67523 40.632485,43.292687 0 0 1 0.85922,0.69977 40.632485,43.292687 0 0 1 0.8419,0.72366 40.632485,43.292687 0 0 1 0.82388,0.74753 40.632485,43.292687 0 0 1 0.80452,0.77008 40.632485,43.292687 0 0 1 0.78518,0.7933 40.632485,43.292687 0 0 1 0.7645,0.81453 40.632485,43.292687 0 0 1 0.74449,0.83641 40.632485,43.292687 0 0 1 0.72314,0.85764 40.632485,43.292687 0 0 1 0.70113,0.87754 40.632485,43.292687 0 0 1 0.67912,0.89677 40.632485,43.292687 0 0 1 0.65711,0.91601 40.632485,43.292687 0 0 1 0.63307,0.93391 40.632485,43.292687 0 0 1 0.6104,0.95183 40.632485,43.292687 0 0 1 0.58573,0.96907 40.632485,43.292687 0 0 1 0.56171,0.98499 40.632485,43.292687 0 0 1 0.53633,1.00091 40.632485,43.292687 0 0 1 0.51167,1.01551 40.632485,43.292687 0 0 1 0.48631,1.03009 40.632485,43.292687 0 0 1 0.45965,1.0427 40.632485,43.292687 0 0 1 0.43428,1.05596 40.632485,43.292687 0 0 1 0.40694,1.0679 40.632485,43.292687 0 0 1 0.38025,1.07852 40.632485,43.292687 0 0 1 0.35357,1.08979 40.632485,43.292687 0 0 1 0.32622,1.09908 40.632485,43.292687 0 0 1 0.29818,1.1077 40.632485,43.292687 0 0 1 0.27085,1.11566 40.632485,43.292687 0 0 1 0.24283,1.12362 40.632485,43.292687 0 0 1 0.2148,1.12959 40.632485,43.292687 0 0 1 0.18612,1.13489 40.632485,43.292687 0 0 1 0.15745,1.14021 40.632485,43.292687 0 0 1 0.12941,1.14484 40.632485,43.292687 0 0 1 0.10078,1.1475 40.632485,43.292687 0 0 1 0.072,1.15015 40.632485,43.292687 0 0 1 0.0434,1.15148 40.632485,43.292687 0 0 1 0.014,1.15214 40.632485,43.292687 0 0 1 -0.12477,3.39672 40.632485,43.292687 0 0 1 -0.37557,3.37617 40.632485,43.292687 0 0 1 -0.6224,3.33371 40.632485,43.292687 0 0 1 -0.86591,3.27203 40.632485,43.292687 0 0 1 -1.10406,3.18911 40.632485,43.292687 0 0 1 -1.33555,3.08697 40.632485,43.292687 0 0 1 -1.55902,2.96559 40.632485,43.292687 0 0 1 -1.77249,2.82696 40.632485,43.292687 0 0 1 -1.9753,2.66909 40.632485,43.292687 0 0 1 -2.16544,2.49664 40.632485,43.292687 0 0 1 -2.34287,2.30693 40.632485,43.292687 0 0 1 -2.50564,2.10463 40.632485,43.292687 0 0 1 -2.65309,1.88906 40.632485,43.292687 0 0 1 -2.78317,1.66089 40.632485,43.292687 0 0 1 -2.89791,1.42276 40.632485,43.292687 0 0 1 -2.99264,1.17668 40.632485,43.292687 0 0 1 -3.07067,0.92265 40.632485,43.292687 0 0 1 -3.12941,0.66329 40.632485,43.292687 0 0 1 -3.168074,0.3993 40.632485,43.292687 0 0 1 -3.188104,0.13399 40.632485,43.292687 0 0 1 -3.188094,-0.13399 40.632485,43.292687 0 0 1 -3.168088,-0.3993 40.632485,43.292687 0 0 1 -3.12939,-0.6633 40.632485,43.292687 0 0 1 -3.07069,-0.92264 40.632485,43.292687 0 0 1 -2.993303,-1.17668 40.632485,43.292687 0 0 1 -2.897244,-1.42277 40.632485,43.292687 0 0 1 -2.783837,-1.66088 40.632485,43.292687 0 0 1 -2.652404,-1.88906 40.632485,43.292687 0 0 1 -2.505648,-2.10464 40.632485,43.292687 0 0 1 -2.342886,-2.30693 40.632485,43.292687 0 0 1 -2.166082,-2.49664 40.632485,43.292687 0 0 1 -1.974639,-2.66909 40.632485,43.292687 0 0 1 -1.772504,-2.82696 40.632485,43.292687 0 0 1 -1.559022,-2.96558 40.632485,43.292687 0 0 1 -1.335549,-3.08697 40.632485,43.292687 0 0 1 -1.104725,-3.18912 40.632485,43.292687 0 0 1 -0.865908,-3.27202 40.632485,43.292687 0 0 1 -0.622408,-3.33372 40.632485,43.292687 0 0 1 -0.374918,-3.37616 40.632485,43.292687 0 0 1 -0.125409,-3.39673 40.632485,43.292687 0 0 1 0.125409,-3.39672 40.632485,43.292687 0 0 1 0.374918,-3.37551 40.632485,43.292687 0 0 1 0.622408,-3.33371 40.632485,43.292687 0 0 1 0.865908,-3.27203 40.632485,43.292687 0 0 1 1.104725,-3.18911 40.632485,43.292687 0 0 1 1.335549,-3.08697 40.632485,43.292687 0 0 1 1.559022,-2.96625 40.632485,43.292687 0 0 1 1.772504,-2.82629 40.632485,43.292687 0 0 1 1.974639,-2.66976 40.632485,43.292687 0 0 1 2.166082,-2.49597 40.632485,43.292687 0 0 1 2.342886,-2.3076 40.632485,43.292687 0 0 1 2.505648,-2.10463 40.632485,43.292687 0 0 1 2.652426,-1.8884 40.632485,43.292687 0 0 1 2.783836,-1.66089 40.632485,43.292687 0 0 1 2.897245,-1.42342 40.632485,43.292687 0 0 1 2.993303,-1.17602 40.632485,43.292687 0 0 1 3.070689,-0.92331 40.632485,43.292687 0 0 1 3.12939,-0.66263 40.632485,43.292687 0 0 1 3.16809,-0.39997 40.632485,43.292687 0 0 1 3.188093,-0.13332 z"
+ clip-path="url(#clipPath20679)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-5-7"
+ style="fill:#0042b3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0376767;stroke-linejoin:round"
+ d="m 74.301703,221.87113 a 40.722405,43.678338 0 0 0 -40.72207,43.67871 40.722405,43.678338 0 0 0 40.72207,43.67813 40.722405,43.678338 0 0 0 40.722637,-43.67813 40.722405,43.678338 0 0 0 -40.722637,-43.67871 z m -0.0809,5.55645 a 35.68863,38.243712 0 0 1 0.94981,0.0135 35.68863,38.243712 0 0 1 0.94922,0.041 35.68863,38.243712 0 0 1 0.94805,0.0674 35.68863,38.243712 0 0 1 0.94628,0.0949 35.68863,38.243712 0 0 1 0.94336,0.12187 35.68863,38.243712 0 0 1 0.93984,0.14824 35.68863,38.243712 0 0 1 0.93574,0.1752 35.68863,38.243712 0 0 1 0.93165,0.20215 35.68863,38.243712 0 0 1 0.92578,0.22851 35.68863,38.243712 0 0 1 0.91992,0.25489 35.68863,38.243712 0 0 1 0.91348,0.28066 35.68863,38.243712 0 0 1 0.90586,0.30703 35.68863,38.243712 0 0 1 0.89765,0.33281 35.68863,38.243712 0 0 1 0.88945,0.35801 35.68863,38.243712 0 0 1 0.880067,0.3832 35.68863,38.243712 0 0 1 0.87071,0.4084 35.68863,38.243712 0 0 1 0.86015,0.43301 35.68863,38.243712 0 0 1 0.84844,0.45762 35.68863,38.243712 0 0 1 0.8373,0.48105 35.68863,38.243712 0 0 1 0.825,0.50508 35.68863,38.243712 0 0 1 0.81211,0.52852 35.68863,38.243712 0 0 1 0.79863,0.55195 35.68863,38.243712 0 0 1 0.78458,0.57363 35.68863,38.243712 0 0 1 0.7705,0.59649 35.68863,38.243712 0 0 1 0.75469,0.61816 35.68863,38.243712 0 0 1 0.73946,0.63926 35.68863,38.243712 0 0 1 0.72363,0.66035 35.68863,38.243712 0 0 1 0.70664,0.68027 35.68863,38.243712 0 0 1 0.68965,0.70078 35.68863,38.243712 0 0 1 0.67148,0.71954 35.68863,38.243712 0 0 1 0.65391,0.73886 35.68863,38.243712 0 0 1 0.63515,0.75762 35.68863,38.243712 0 0 1 0.61582,0.7752 35.68863,38.243712 0 0 1 0.59649,0.79218 35.68863,38.243712 0 0 1 0.57715,0.80918 35.68863,38.243712 0 0 1 0.55605,0.825 35.68863,38.243712 0 0 1 0.53613,0.84082 35.68863,38.243712 0 0 1 0.51446,0.85606 35.68863,38.243712 0 0 1 0.49336,0.87012 35.68863,38.243712 0 0 1 0.47109,0.88417 35.68863,38.243712 0 0 1 0.44941,0.89708 35.68863,38.243712 0 0 1 0.42715,0.90996 35.68863,38.243712 0 0 1 0.40371,0.92109 35.68863,38.243712 0 0 1 0.38145,0.93281 35.68863,38.243712 0 0 1 0.35742,0.94336 35.68863,38.243712 0 0 1 0.33399,0.95274 35.68863,38.243712 0 0 1 0.31054,0.96269 35.68863,38.243712 0 0 1 0.28653,0.9709 35.68863,38.243712 0 0 1 0.26191,0.97852 35.68863,38.243712 0 0 1 0.23789,0.98554 35.68863,38.243712 0 0 1 0.21328,0.99258 35.68863,38.243712 0 0 1 0.18867,0.99785 35.68863,38.243712 0 0 1 0.16348,1.00254 35.68863,38.243712 0 0 1 0.13828,1.00723 35.68863,38.243712 0 0 1 0.11367,1.01133 35.68863,38.243712 0 0 1 0.0885,1.01367 35.68863,38.243712 0 0 1 0.0633,1.01601 35.68863,38.243712 0 0 1 0.0381,1.01719 35.68863,38.243712 0 0 1 0.0123,1.01778 35.68863,38.243712 0 0 1 -0.10957,3.00058 35.68863,38.243712 0 0 1 -0.32988,2.98242 35.68863,38.243712 0 0 1 -0.54668,2.94492 35.68863,38.243712 0 0 1 -0.76055,2.89044 35.68863,38.243712 0 0 1 -0.96972,2.81718 35.68863,38.243712 0 0 1 -1.17305,2.72696 35.68863,38.243712 0 0 1 -1.36934,2.61972 35.68863,38.243712 0 0 1 -1.55683,2.49727 35.68863,38.243712 0 0 1 -1.73496,2.35781 35.68863,38.243712 0 0 1 -1.90196,2.20547 35.68863,38.243712 0 0 1 -2.05781,2.03789 35.68863,38.243712 0 0 1 -2.20078,1.85918 35.68863,38.243712 0 0 1 -2.33027,1.66875 35.68863,38.243712 0 0 1 -2.44454,1.46718 35.68863,38.243712 0 0 1 -2.54531,1.25684 35.68863,38.243712 0 0 1 -2.628507,1.03945 35.68863,38.243712 0 0 1 -2.69706,0.81504 35.68863,38.243712 0 0 1 -2.74864,0.58594 35.68863,38.243712 0 0 1 -2.78261,0.35274 35.68863,38.243712 0 0 1 -2.8002,0.11836 35.68863,38.243712 0 0 1 -2.80019,-0.11836 35.68863,38.243712 0 0 1 -2.78262,-0.35274 35.68863,38.243712 0 0 1 -2.74863,-0.58594 35.68863,38.243712 0 0 1 -2.69707,-0.81504 35.68863,38.243712 0 0 1 -2.6291,-1.03945 35.68863,38.243712 0 0 1 -2.54473,-1.25684 35.68863,38.243712 0 0 1 -2.44512,-1.46718 35.68863,38.243712 0 0 1 -2.32968,-1.66875 35.68863,38.243712 0 0 1 -2.20078,-1.85918 35.68863,38.243712 0 0 1 -2.05782,-2.03789 35.68863,38.243712 0 0 1 -1.90253,-2.20547 35.68863,38.243712 0 0 1 -1.73438,-2.35781 35.68863,38.243712 0 0 1 -1.55684,-2.49727 35.68863,38.243712 0 0 1 -1.36933,-2.61972 35.68863,38.243712 0 0 1 -1.17305,-2.72696 35.68863,38.243712 0 0 1 -0.97031,-2.81718 35.68863,38.243712 0 0 1 -0.76055,-2.89044 35.68863,38.243712 0 0 1 -0.54668,-2.94492 35.68863,38.243712 0 0 1 -0.3293,-2.98242 35.68863,38.243712 0 0 1 -0.11015,-3.00058 35.68863,38.243712 0 0 1 0.11015,-3.00059 35.68863,38.243712 0 0 1 0.3293,-2.98184 35.68863,38.243712 0 0 1 0.54668,-2.94492 35.68863,38.243712 0 0 1 0.76055,-2.89043 35.68863,38.243712 0 0 1 0.97031,-2.81719 35.68863,38.243712 0 0 1 1.17305,-2.72695 35.68863,38.243712 0 0 1 1.36933,-2.62031 35.68863,38.243712 0 0 1 1.55684,-2.49668 35.68863,38.243712 0 0 1 1.73438,-2.3584 35.68863,38.243712 0 0 1 1.90253,-2.20488 35.68863,38.243712 0 0 1 2.05782,-2.03848 35.68863,38.243712 0 0 1 2.20078,-1.85918 35.68863,38.243712 0 0 1 2.32968,-1.66816 35.68863,38.243712 0 0 1 2.44512,-1.46719 35.68863,38.243712 0 0 1 2.54473,-1.25742 35.68863,38.243712 0 0 1 2.6291,-1.03887 35.68863,38.243712 0 0 1 2.69707,-0.81562 35.68863,38.243712 0 0 1 2.74863,-0.58536 35.68863,38.243712 0 0 1 2.78262,-0.35332 35.68863,38.243712 0 0 1 2.80019,-0.11777 z"
+ clip-path="url(#clipPath20683)"
+ transform="translate(-206.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path1306-7-8"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0427732;stroke-linejoin:round;stroke-opacity:1"
+ d="m 119.27202,216.33781 a 46.363577,49.444797 0 0 0 -46.363193,49.44522 46.363577,49.444797 0 0 0 46.363193,49.44456 46.363577,49.444797 0 0 0 46.36384,-49.44456 46.363577,49.444797 0 0 0 -46.36384,-49.44522 z m -0.0921,6.29001 a 40.632485,43.292687 0 0 1 1.08139,0.0153 40.632485,43.292687 0 0 1 1.08071,0.0464 40.632485,43.292687 0 0 1 1.07938,0.0763 40.632485,43.292687 0 0 1 1.07737,0.10745 40.632485,43.292687 0 0 1 1.07404,0.13797 40.632485,43.292687 0 0 1 1.07003,0.16781 40.632485,43.292687 0 0 1 1.06537,0.19832 40.632485,43.292687 0 0 1 1.06071,0.22884 40.632485,43.292687 0 0 1 1.05402,0.25868 40.632485,43.292687 0 0 1 1.04736,0.28854 40.632485,43.292687 0 0 1 1.04002,0.31771 40.632485,43.292687 0 0 1 1.03134,0.34757 40.632485,43.292687 0 0 1 1.02201,0.37675 40.632485,43.292687 0 0 1 1.01266,0.40527 40.632485,43.292687 0 0 1 1.00198,0.4338 40.632485,43.292687 0 0 1 0.99134,0.46231 40.632485,43.292687 0 0 1 0.97929,0.49018 40.632485,43.292687 0 0 1 0.96598,0.51803 40.632485,43.292687 0 0 1 0.9533,0.54456 40.632485,43.292687 0 0 1 0.93927,0.57176 40.632485,43.292687 0 0 1 0.92461,0.59829 40.632485,43.292687 0 0 1 0.90926,0.62483 40.632485,43.292687 0 0 1 0.89328,0.64936 40.632485,43.292687 0 0 1 0.87724,0.67523 40.632485,43.292687 0 0 1 0.85922,0.69978 40.632485,43.292687 0 0 1 0.8419,0.72365 40.632485,43.292687 0 0 1 0.82388,0.74753 40.632485,43.292687 0 0 1 0.80452,0.77009 40.632485,43.292687 0 0 1 0.78518,0.7933 40.632485,43.292687 0 0 1 0.7645,0.81452 40.632485,43.292687 0 0 1 0.74449,0.83641 40.632485,43.292687 0 0 1 0.72314,0.85764 40.632485,43.292687 0 0 1 0.70113,0.87754 40.632485,43.292687 0 0 1 0.67912,0.89677 40.632485,43.292687 0 0 1 0.65711,0.91601 40.632485,43.292687 0 0 1 0.63307,0.93392 40.632485,43.292687 0 0 1 0.6104,0.95183 40.632485,43.292687 0 0 1 0.58573,0.96907 40.632485,43.292687 0 0 1 0.56171,0.98499 40.632485,43.292687 0 0 1 0.53634,1.00091 40.632485,43.292687 0 0 1 0.51167,1.0155 40.632485,43.292687 0 0 1 0.48631,1.0301 40.632485,43.292687 0 0 1 0.45965,1.04269 40.632485,43.292687 0 0 1 0.43428,1.05597 40.632485,43.292687 0 0 1 0.40694,1.0679 40.632485,43.292687 0 0 1 0.38025,1.07851 40.632485,43.292687 0 0 1 0.35357,1.0898 40.632485,43.292687 0 0 1 0.32622,1.09908 40.632485,43.292687 0 0 1 0.29818,1.10769 40.632485,43.292687 0 0 1 0.27085,1.11566 40.632485,43.292687 0 0 1 0.24283,1.12362 40.632485,43.292687 0 0 1 0.2148,1.12959 40.632485,43.292687 0 0 1 0.18612,1.1349 40.632485,43.292687 0 0 1 0.15745,1.1402 40.632485,43.292687 0 0 1 0.12941,1.14484 40.632485,43.292687 0 0 1 0.10078,1.1475 40.632485,43.292687 0 0 1 0.072,1.15015 40.632485,43.292687 0 0 1 0.0434,1.15148 40.632485,43.292687 0 0 1 0.014,1.15214 40.632485,43.292687 0 0 1 -0.12477,3.39673 40.632485,43.292687 0 0 1 -0.37557,3.37616 40.632485,43.292687 0 0 1 -0.6224,3.33371 40.632485,43.292687 0 0 1 -0.86591,3.27203 40.632485,43.292687 0 0 1 -1.10406,3.18912 40.632485,43.292687 0 0 1 -1.33555,3.08697 40.632485,43.292687 0 0 1 -1.55903,2.96558 40.632485,43.292687 0 0 1 -1.77249,2.82696 40.632485,43.292687 0 0 1 -1.9753,2.66909 40.632485,43.292687 0 0 1 -2.16544,2.49664 40.632485,43.292687 0 0 1 -2.34287,2.30693 40.632485,43.292687 0 0 1 -2.50564,2.10464 40.632485,43.292687 0 0 1 -2.65309,1.88906 40.632485,43.292687 0 0 1 -2.78317,1.66088 40.632485,43.292687 0 0 1 -2.89791,1.42277 40.632485,43.292687 0 0 1 -2.99263,1.17668 40.632485,43.292687 0 0 1 -3.07068,0.92264 40.632485,43.292687 0 0 1 -3.1294,0.66329 40.632485,43.292687 0 0 1 -3.16808,0.39931 40.632485,43.292687 0 0 1 -3.1881,0.13398 40.632485,43.292687 0 0 1 -3.1881,-0.13398 40.632485,43.292687 0 0 1 -3.16808,-0.39931 40.632485,43.292687 0 0 1 -3.12939,-0.66329 40.632485,43.292687 0 0 1 -3.07069,-0.92264 40.632485,43.292687 0 0 1 -2.99331,-1.17668 40.632485,43.292687 0 0 1 -2.89724,-1.42277 40.632485,43.292687 0 0 1 -2.783838,-1.66088 40.632485,43.292687 0 0 1 -2.652404,-1.88906 40.632485,43.292687 0 0 1 -2.505648,-2.10464 40.632485,43.292687 0 0 1 -2.342886,-2.30693 40.632485,43.292687 0 0 1 -2.166082,-2.49664 40.632485,43.292687 0 0 1 -1.974639,-2.66909 40.632485,43.292687 0 0 1 -1.772504,-2.82696 40.632485,43.292687 0 0 1 -1.559022,-2.96558 40.632485,43.292687 0 0 1 -1.335549,-3.08697 40.632485,43.292687 0 0 1 -1.104725,-3.18912 40.632485,43.292687 0 0 1 -0.865908,-3.27203 40.632485,43.292687 0 0 1 -0.622408,-3.33371 40.632485,43.292687 0 0 1 -0.374918,-3.37616 40.632485,43.292687 0 0 1 -0.125409,-3.39673 40.632485,43.292687 0 0 1 0.125409,-3.39673 40.632485,43.292687 0 0 1 0.374918,-3.3755 40.632485,43.292687 0 0 1 0.622408,-3.33371 40.632485,43.292687 0 0 1 0.865908,-3.27203 40.632485,43.292687 0 0 1 1.104725,-3.18911 40.632485,43.292687 0 0 1 1.335549,-3.08697 40.632485,43.292687 0 0 1 1.559022,-2.96625 40.632485,43.292687 0 0 1 1.772504,-2.8263 40.632485,43.292687 0 0 1 1.974639,-2.66975 40.632485,43.292687 0 0 1 2.166082,-2.49597 40.632485,43.292687 0 0 1 2.342886,-2.3076 40.632485,43.292687 0 0 1 2.505648,-2.10463 40.632485,43.292687 0 0 1 2.652426,-1.8884 40.632485,43.292687 0 0 1 2.783836,-1.66089 40.632485,43.292687 0 0 1 2.89724,-1.42343 40.632485,43.292687 0 0 1 2.99331,-1.17602 40.632485,43.292687 0 0 1 3.07069,-0.9233 40.632485,43.292687 0 0 1 3.12939,-0.66263 40.632485,43.292687 0 0 1 3.16809,-0.39997 40.632485,43.292687 0 0 1 3.18809,-0.13332 z"
+ clip-path="url(#clipPath20667)"
+ transform="translate(-251.48906,-227.91266)"
+ inkscape:export-filename="C:\Users\a116178\Downloads\Taler\taler 512.png"
+ inkscape:export-xdpi="96.231026"
+ inkscape:export-ydpi="96.231026"
+ inkscape:connector-curvature="0" />
+ </g>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/packages/taler-wallet-webextension/static/img/taler-logo-256.png b/packages/taler-wallet-webextension/static/img/taler-logo-256.png
new file mode 100644
index 000000000..7aa6c7bdf
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-logo-256.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-logo-32.png b/packages/taler-wallet-webextension/static/img/taler-logo-32.png
new file mode 100644
index 000000000..c5dbf317f
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-logo-32.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-logo-38.png b/packages/taler-wallet-webextension/static/img/taler-logo-38.png
new file mode 100644
index 000000000..13ee01042
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-logo-38.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-logo-48.png b/packages/taler-wallet-webextension/static/img/taler-logo-48.png
new file mode 100644
index 000000000..f13a23c85
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-logo-48.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-logo-512.png b/packages/taler-wallet-webextension/static/img/taler-logo-512.png
new file mode 100644
index 000000000..be312ef55
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-logo-512.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/img/taler-logo-64.png b/packages/taler-wallet-webextension/static/img/taler-logo-64.png
new file mode 100644
index 000000000..b8a685ae7
--- /dev/null
+++ b/packages/taler-wallet-webextension/static/img/taler-logo-64.png
Binary files differ
diff --git a/packages/taler-wallet-webextension/static/popup.html b/packages/taler-wallet-webextension/static/popup.html
index e3c0b1589..aca71cbf8 100644
--- a/packages/taler-wallet-webextension/static/popup.html
+++ b/packages/taler-wallet-webextension/static/popup.html
@@ -9,8 +9,8 @@
body {
margin: 0;
}
- </style>
- <style>
+ </style>
+ <style>
html {
}
h1 {
@@ -27,8 +27,9 @@
background-color: #f8faf7;
font-family: Arial, Helvetica, sans-serif;
}
- </style>
+ </style>
<link rel="stylesheet" type="text/css" href="/dist/popupEntryPoint.css" />
+ <link rel="stylesheet" type="text/css" href="/static/font/import.css" />
<link rel="icon" href="/static/img/icon.png" />
<script src="/dist/popupEntryPoint.js"></script>
</head>
diff --git a/packages/taler-wallet-webextension/static/wallet.html b/packages/taler-wallet-webextension/static/wallet.html
index a1c069d74..3025901d8 100644
--- a/packages/taler-wallet-webextension/static/wallet.html
+++ b/packages/taler-wallet-webextension/static/wallet.html
@@ -1,15 +1,41 @@
<!DOCTYPE html>
<html>
- <head>
- <meta charset="utf-8" />
- <link rel="stylesheet" type="text/css" href="/static/style/pure.css" />
- <link rel="stylesheet" type="text/css" href="/static/style/wallet.css" />
- <link rel="stylesheet" type="text/css" href="/dist/popupEntryPoint.css" />
- <link rel="icon" href="/static/img/icon.png" />
- <script src="/dist/walletEntryPoint.js"></script>
- </head>
-
- <body>
- <div id="container" class="wallet-container"></div>
- </body>
-</html>
+
+<head>
+ <title>GNU Taler Wallet - WebExtension</title>
+ <meta charset="utf-8" />
+ <meta name="taler-support" content="uri,api" />
+ <link rel="stylesheet" type="text/css" href="/dist/walletEntryPoint.css" />
+ <link rel="stylesheet" type="text/css" href="/static/font/import.css" />
+ <link rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" />
+ <script src="/dist/walletEntryPoint.js"></script>
+ <style>
+ html {
+ font-family: sans-serif;
+ /* 1 */
+ }
+
+ h1 {
+ font-size: 2em;
+ }
+
+ input {
+ font: inherit;
+ }
+
+ body {
+ margin: 0;
+ font-size: 100%;
+ padding: 0;
+ background-color: #f8faf7;
+ font-family: Arial, Helvetica, sans-serif;
+ }
+ </style>
+</head>
+
+<body>
+ <div id="container" class="wallet-container"></div>
+</body>
+
+</html> \ No newline at end of file
diff --git a/packages/taler-wallet-webextension/test.mjs b/packages/taler-wallet-webextension/test.mjs
new file mode 100755
index 000000000..2fd007c2a
--- /dev/null
+++ b/packages/taler-wallet-webextension/test.mjs
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { build, getFilesInDirectory } from "@gnu-taler/web-util/build";
+
+const allTestFiles = getFilesInDirectory("src", /.test.tsx?$/);
+
+await build({
+ type: "test",
+ source: {
+ js: allTestFiles.files,
+ assets: [],
+ },
+ destination: "./dist/test",
+ css: "linaria",
+ // no need to produce css
+ // linariaPlugin: () => {},
+});
diff --git a/packages/taler-wallet-webextension/tests/i18n.test.tsx b/packages/taler-wallet-webextension/tests/i18n.test.tsx
deleted file mode 100644
index ae8b44bb0..000000000
--- a/packages/taler-wallet-webextension/tests/i18n.test.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2020 Taler Systems SA
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-// import * as test from "ava";
-import { internalSetStrings, i18n, Translate } from "@gnu-taler/taler-util";
-import { render, configure } from "enzyme";
-import { h } from 'preact';
-import Adapter from 'enzyme-adapter-preact-pure';
-
-configure({ adapter: new Adapter() });
-
-const testStrings = {
- domain: "messages",
- locale_data: {
- messages: {
- str1: ["foo1"],
- str2: [""],
- "str3 %1$s / %2$s": ["foo3 %2$s ; %1$s"],
- "": {
- domain: "messages",
- plural_forms: "nplurals=2; plural=(n != 1);",
- lang: "",
- },
- },
- },
-};
-
-test("str translation", (done) => {
-
- // Alias, so we nly use the function for lookups, not for string extranction.
- const strAlias = i18n.str;
- const TranslateAlias = Translate;
- internalSetStrings(testStrings);
- expect(strAlias`str1`).toEqual("foo1");
- expect(strAlias`str2`).toEqual("str2");
- const a = "a";
- const b = "b";
- expect(strAlias`str3 ${a} / ${b}`).toEqual("foo3 b ; a");
- const r = render(<Translate>str1</Translate>);
- expect(r.text()).toEqual("foo1");
-
- const r2 = render(
- <TranslateAlias>
- str3 <span>{a}</span> / <span>{b}</span>
- </TranslateAlias>,
- );
- expect(r2.text()).toEqual("foo3 b ; a");
-
- done();
-});
-
-// test.default("existing str translation", (t) => {
-// internalSetStrings(strings);
-// t.pass();
-// });
diff --git a/packages/taler-wallet-webextension/tests/stories.test.tsx b/packages/taler-wallet-webextension/tests/stories.test.tsx
deleted file mode 100644
index 0122dfaeb..000000000
--- a/packages/taler-wallet-webextension/tests/stories.test.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2021 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
-*
-* @author Sebastian Javier Marchano (sebasjm)
-*/
-import { mount } from 'enzyme';
-import { h } from 'preact';
-
-import fs from 'fs';
-
-function getFiles(dir: string, files_: string[] = []) {
- const files = fs.readdirSync(dir);
- for (const i in files) {
- const name = dir + '/' + files[i];
- if (fs.statSync(name).isDirectory()) {
- getFiles(name, files_);
- } else {
- files_.push(name);
- }
- }
- return files_;
-}
-
-const re = RegExp('.*\.stories.tsx')
-
-import { setupI18n } from '@gnu-taler/taler-util';
-setupI18n('en',{'en':{}})
-
-it('render every story', () => {
- // jest.spyOn(i18n, 'useTranslationContext').mockImplementation(() => ({ changeLanguage: () => null, lang: 'en' }));
-
- getFiles('./src').filter(f => re.test(f)).map(f => {
- // const f = "./src/paths/instance/transfers/list/List.stories.tsx";
- // eslint-disable-next-line @typescript-eslint/no-var-requires
- const s = require(`../${f}`)
-
- delete s.default
-
- Object.keys(s).forEach(k => {
- const Component = s[k];
- expect(() => {
- try {
- let p = mount(<Component {...Component.args} /> as any)
- p.mount()
- p.unmount()
- p.mount();
- } catch (e) {
- console.log(e)
- throw e
- }
- }).not.toThrow() //`problem rendering ${f} example ${k}`
-
- })
- })
-});
diff --git a/packages/taler-wallet-webextension/trim-extension.cjs b/packages/taler-wallet-webextension/trim-extension.cjs
new file mode 100644
index 000000000..256a9f48d
--- /dev/null
+++ b/packages/taler-wallet-webextension/trim-extension.cjs
@@ -0,0 +1,25 @@
+// Simple plugin to trim extensions from the filename of relative import statements.
+// Required to get linaria to work with `moduleResolution: "Node16"` imports.
+// @author Florian Dold
+//
+
+module.exports = function ({ types: t }) {
+ return {
+ name: "trim-extension",
+ visitor: {
+ ImportDeclaration: (x) => {
+ const src = x.node.source;
+ if (src.value.startsWith(".")) {
+ if (src.value.endsWith(".js")) {
+ const newVal = src.value.replace(/[.]js$/, "");
+ x.node.source = t.stringLiteral(newVal);
+ }
+ }
+ if (src.value.endsWith(".jsx")) {
+ const newVal = src.value.replace(/[.]jsx$/, "");
+ x.node.source = t.stringLiteral(newVal);
+ }
+ },
+ },
+ };
+};
diff --git a/packages/taler-wallet-webextension/tsconfig.json b/packages/taler-wallet-webextension/tsconfig.json
index cff3d8857..2c34816e6 100644
--- a/packages/taler-wallet-webextension/tsconfig.json
+++ b/packages/taler-wallet-webextension/tsconfig.json
@@ -1,18 +1,22 @@
{
"compilerOptions": {
"composite": true,
- "lib": ["es6", "DOM"],
- "jsx": "react-jsx",
- "jsxImportSource": "preact",
- "moduleResolution": "Node",
- "module": "ESNext",
- "target": "ES6",
+ "lib": ["es2020", "DOM"],
+ "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
+ "jsxFactory": "h" /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */,
+ "jsxFragmentFactory": "Fragment", // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#custom-jsx-factories
+ "moduleResolution": "Node16",
+ "module": "Node16",
+ "target": "ES2020",
+ "skipLibCheck": true,
+ "preserveSymlinks": true,
"noImplicitAny": true,
"outDir": "lib",
"noEmitOnError": true,
"strict": true,
"incremental": true,
"sourceMap": true,
+ "strictNullChecks": true,
"esModuleInterop": true,
"importHelpers": true,
"rootDir": "./src",
diff --git a/packages/web-util/README.md b/packages/web-util/README.md
new file mode 100644
index 000000000..d1465aa71
--- /dev/null
+++ b/packages/web-util/README.md
@@ -0,0 +1,3 @@
+# web-util
+
+Common utilities for other web applications in this repository.
diff --git a/packages/web-util/bin/taler-web-cli.mjs b/packages/web-util/bin/taler-web-cli.mjs
new file mode 100755
index 000000000..4e89cf46d
--- /dev/null
+++ b/packages/web-util/bin/taler-web-cli.mjs
@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { main } from '../lib/cli.cjs';
+main();
diff --git a/packages/web-util/build.mjs b/packages/web-util/build.mjs
new file mode 100755
index 000000000..02d077571
--- /dev/null
+++ b/packages/web-util/build.mjs
@@ -0,0 +1,209 @@
+#!/usr/bin/env node
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import esbuild from "esbuild";
+import path from "path";
+import fs from "fs";
+
+// eslint-disable-next-line no-undef
+const BASE = process.cwd();
+
+let GIT_ROOT = BASE;
+while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") {
+ GIT_ROOT = path.join(GIT_ROOT, "../");
+}
+if (GIT_ROOT === "/") {
+ // eslint-disable-next-line no-undef
+ console.log("not found");
+ // eslint-disable-next-line no-undef
+ process.exit(1);
+}
+const GIT_HASH = GIT_ROOT === "/" ? undefined : git_hash();
+
+let _package = JSON.parse(fs.readFileSync(path.join(BASE, "package.json")));
+
+function git_hash() {
+ const rev = fs
+ .readFileSync(path.join(GIT_ROOT, ".git", "HEAD"))
+ .toString()
+ .trim()
+ .split(/.*[: ]/)
+ .slice(-1)[0];
+ if (rev.indexOf("/") === -1) {
+ return rev;
+ } else {
+ return fs.readFileSync(path.join(GIT_ROOT, ".git", rev)).toString().trim();
+ }
+}
+
+/**
+ * Problem:
+ * No loader is configured for ".node" files: ../../node_modules/.pnpm/fsevents@2.3.3/node_modules/fsevents/fsevents.node
+ *
+ * Reference:
+ * https://github.com/evanw/esbuild/issues/1051#issuecomment-806325487
+ *
+ * kept as reference, need to be tested on MacOs
+ */
+const nativeNodeModulesPlugin = {
+ name: 'native-node-modules',
+ setup(build) {
+ // If a ".node" file is imported within a module in the "file" namespace, resolve
+ // it to an absolute path and put it into the "node-file" virtual namespace.
+ build.onResolve({ filter: /\.node$/, namespace: 'file' }, args => ({
+ path: require.resolve(args.path, { paths: [args.resolveDir] }),
+ namespace: 'node-file',
+ }))
+
+ // Files in the "node-file" virtual namespace call "require()" on the
+ // path from esbuild of the ".node" file in the output directory.
+ build.onLoad({ filter: /.*/, namespace: 'node-file' }, args => ({
+ contents: `
+ import path from ${JSON.stringify(args.path)}
+ try { module.exports = require(path) }
+ catch {}
+ `,
+ }))
+
+ // If a ".node" file is imported within a module in the "node-file" namespace, put
+ // it in the "file" namespace where esbuild's default loading behavior will handle
+ // it. It is already an absolute path since we resolved it to one above.
+ build.onResolve({ filter: /\.node$/, namespace: 'node-file' }, args => ({
+ path: args.path,
+ namespace: 'file',
+ }))
+
+ // Tell esbuild's default loading behavior to use the "file" loader for
+ // these ".node" files.
+ let opts = build.initialOptions
+ opts.loader = opts.loader || {}
+ opts.loader['.node'] = 'file'
+ },
+}
+
+const buildConfigBase = {
+ outdir: "lib",
+ bundle: true,
+ minify: false,
+ target: ["es2020"],
+ loader: {
+ ".key": "text",
+ ".crt": "text",
+ ".node": "file",
+ ".html": "text",
+ ".svg": "dataurl",
+ },
+ sourcemap: true,
+ define: {
+ __VERSION__: `"${_package.version}"`,
+ __GIT_HASH__: `"${GIT_HASH}"`,
+ },
+ //plugins: [nativeNodeModulesPlugin],
+};
+
+/**
+ * Build time libraries, under node runtime
+ */
+const buildConfigBuild = {
+ ...buildConfigBase,
+ entryPoints: ["src/index.build.ts"],
+ outExtension: {
+ ".js": ".mjs",
+ },
+ format: "esm",
+ platform: "node",
+ external: ["esbuild"],
+ // https://github.com/evanw/esbuild/issues/1921
+ // How to fix "Dynamic require of "os" is not supported"
+ // esbuild cannot convert external "static" commonjs require statements to static esm imports
+ banner: {
+ js: `
+ import { fileURLToPath } from 'url';
+ import { createRequire as topLevelCreateRequire } from 'module';
+ const require = topLevelCreateRequire(import.meta.url);
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = path.dirname(__filename);
+`,
+ },
+};
+
+/**
+ * Development libraries, under node runtime
+ */
+const buildConfigTesting = {
+ ...buildConfigBase,
+ entryPoints: ["src/index.testing.ts"],
+ outExtension: {
+ ".js": ".mjs",
+ },
+ format: "esm",
+ platform: "browser",
+ external: ["preact", "@gnu-taler/taler-util", "jed", "swr", "axios"],
+ jsxFactory: "h",
+ jsxFragment: "Fragment",
+};
+
+/**
+ * Testing libraries, under node runtime
+ */
+const buildConfigNode = {
+ ...buildConfigBase,
+ entryPoints: ["src/index.node.ts", "src/cli.ts"],
+ outExtension: {
+ ".js": ".cjs",
+ },
+ format: "cjs",
+ platform: "node",
+ external: ["preact"],
+
+};
+
+/**
+ * Support libraries, under browser runtime
+ */
+const buildConfigBrowser = {
+ ...buildConfigBase,
+ entryPoints: [
+ "src/tests/mock.ts",
+ "src/tests/swr.ts",
+ "src/index.browser.ts",
+ "src/live-reload.ts",
+ "src/stories.tsx",
+ ],
+ outExtension: {
+ ".js": ".mjs",
+ },
+ format: "esm",
+ platform: "browser",
+ external: ["preact", "@gnu-taler/taler-util", "jed", "swr", "axios"],
+ jsxFactory: "h",
+ jsxFragment: "Fragment",
+};
+
+[
+ buildConfigNode,
+ buildConfigBrowser,
+ buildConfigBuild,
+ buildConfigTesting,
+].forEach((config) => {
+ esbuild.build(config).catch((e) => {
+ // eslint-disable-next-line no-undef
+ console.log(e);
+ // eslint-disable-next-line no-undef
+ process.exit(1);
+ });
+});
diff --git a/packages/web-util/create_certificate.sh b/packages/web-util/create_certificate.sh
new file mode 100644
index 000000000..980aaf642
--- /dev/null
+++ b/packages/web-util/create_certificate.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -eu
+org=localhost-ca
+domain=localhost
+
+rm -rf keys
+mkdir keys
+cd keys
+
+openssl genpkey -algorithm RSA -out ca.key
+openssl req -x509 -key ca.key -out ca.crt \
+ -subj "/CN=$org/O=$org"
+
+openssl genpkey -algorithm RSA -out "$domain".key
+openssl req -new -key "$domain".key -out "$domain".csr \
+ -subj "/CN=$domain/O=$org"
+
+openssl x509 -req -in "$domain".csr -days 365 -out "$domain".crt \
+ -CA ca.crt -CAkey ca.key -CAcreateserial \
+ -extfile <(cat <<END
+basicConstraints = CA:FALSE
+subjectKeyIdentifier = hash
+authorityKeyIdentifier = keyid,issuer
+subjectAltName = DNS:$domain
+END
+ )
+
+sudo cp ca.crt /usr/local/share/ca-certificates/testing.crt
+sudo update-ca-certificates
+
+
+echo '
+## Chrome
+1. go to chrome://settings/certificates
+2. tab "authorities"
+3. button "import"
+4. choose "ca.crt"
+5. trust for identify websites
+
+## Firefox
+1. go to about:preferences#privacy
+2. button "view certificates"
+3. button "import"
+4. choose "ca.crt"
+5. trust for identify websites
+'
+
+echo done!
diff --git a/packages/web-util/package.json b/packages/web-util/package.json
new file mode 100644
index 000000000..e9a8247ea
--- /dev/null
+++ b/packages/web-util/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "@gnu-taler/web-util",
+ "version": "0.10.6",
+ "description": "Generic helper functionality for GNU Taler Web Apps",
+ "type": "module",
+ "types": "./lib/index.node.d.ts",
+ "main": "./dist/taler-web-cli.cjs",
+ "author": "Sebastian Marchano",
+ "license": "AGPL-3.0-or-later",
+ "private": false,
+ "exports": {
+ "./browser": {
+ "types": "./lib/index.browser.js",
+ "default": "./lib/index.browser.mjs"
+ },
+ "./build": {
+ "types": "./lib/index.build.js",
+ "default": "./lib/index.build.mjs"
+ },
+ "./node": {
+ "types": "./lib/index.node.js",
+ "default": "./lib/index.node.cjs"
+ },
+ "./testing": {
+ "types": "./lib/index.testing.js",
+ "default": "./lib/index.testing.mjs"
+ }
+ },
+ "scripts": {
+ "compile": "tsc && ./build.mjs",
+ "build": "tsc && ./build.mjs",
+ "clean": "rm -rf dist lib tsconfig.tsbuildinfo",
+ "pretty": "prettier --write src"
+ },
+ "devDependencies": {
+ "@babel/preset-react": "^7.22.3",
+ "@babel/preset-typescript": "^7.21.5",
+ "@gnu-taler/taler-util": "workspace:*",
+ "@heroicons/react": "^2.0.17",
+ "@linaria/babel-preset": "5.0.4",
+ "@linaria/core": "5.0.2",
+ "@linaria/esbuild": "5.0.4",
+ "@linaria/react": "5.0.3",
+ "@types/express": "^4.17.14",
+ "@types/node": "^18.11.17",
+ "@types/web": "^0.0.82",
+ "@types/ws": "^8.5.3",
+ "autoprefixer": "^10.4.14",
+ "chokidar": "^3.5.3",
+ "date-fns": "2.29.3",
+ "esbuild": "^0.19.9",
+ "express": "^4.18.2",
+ "postcss": "^8.4.23",
+ "postcss-load-config": "^4.0.1",
+ "preact": "10.11.3",
+ "preact-render-to-string": "^5.2.6",
+ "prettier": "^3.1.1",
+ "sass": "1.56.1",
+ "swr": "2.0.3",
+ "tslib": "^2.6.2",
+ "typescript": "^5.3.3",
+ "ws": "7.4.5"
+ },
+ "dependencies": {
+ "@babel/core": "7.18.9",
+ "@babel/helper-compilation-targets": "7.18.9",
+ "@types/chrome": "0.0.197",
+ "tailwindcss": "^3.3.2"
+ }
+}
diff --git a/packages/web-util/src/assets/lang.svg b/packages/web-util/src/assets/lang.svg
new file mode 100644
index 000000000..dd72ce65e
--- /dev/null
+++ b/packages/web-util/src/assets/lang.svg
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 2411.2 2794" style="enable-background:new 0 0 2411.2 2794;" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#FFFFFF;}
+ .st1{fill-rule:evenodd;clip-rule:evenodd;}
+ .st2{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
+</style>
+<g id="Layer_2">
+</g>
+<g id="Layer_x5F_1_x5F_1">
+ <g>
+ <polygon points="1204.6,359.2 271.8,30 271.8,2060.1 1204.6,1758.3 "/>
+ <polygon class="st0" points="1182.2,358.1 2150.6,29 2150.6,2059 1182.2,1757.3 "/>
+ <polygon class="st0" points="30,2415.4 1182.2,2031.4 1182.2,357.9 30,742 "/>
+ <polygon points="1707.2,2440.7 1870.5,2709.4 1956.6,2459.8 "/>
+ <g>
+ <path d="M421.7,934.8c-6.1-6,8,49.1,27.6,68.9c34.8,35.1,61.9,39.6,76.4,40.2c32,1.3,71.5-8,94.9-17.8
+ c22.7-9.7,62.4-30,77.5-59.6c3.2-6.3,11.9-17,6.4-43.2c-4.2-20.2-17-27.3-32.7-26.2c-15.7,1.1-63.2,13.7-86.1,20.8
+ c-23,7-70.3,21.4-90.9,25.8C474.3,948.2,429,941.7,421.7,934.8z"/>
+ <path d="M1003.1,1593.7c-9.1-3.3-196.9-81.1-223.6-93.9c-21.8-10.5-75.2-33.1-100.4-43.3c70.8-109.2,115.5-191.6,121.5-204.1
+ c11-23,86-169.6,87.7-178.7c1.7-9.1,3.8-42.9,2.2-51c-1.7-8.2-29.1,7.6-66.4,20.2c-37.4,12.6-108.4,58.8-135.8,64.6
+ c-27.5,5.7-115.5,39.1-160.5,54c-45,14.9-130.2,40.9-165.2,50.4c-35.1,9.5-65.7,10.2-85.3,16.2c0,0,2.6,27.5,7.8,35.7
+ c5.2,8.2,23.7,28.4,45.3,34.1c21.6,5.7,57.3,3.4,73.6-0.3c16.3-3.8,44.4-17.5,48.2-23.6c3.8-6.1-2-24.9,4.5-30.6
+ c6.5-5.6,92.2-25.7,124.6-35.4c32.4-10,156.3-52.6,173.1-50.5c-5.3,17.7-105,215.1-137.1,274c-32.1,58.9-218.6,318-258.3,363.6
+ c-30.1,34.7-103.2,123.5-128.5,143.6c6.4,1.8,51.6-2.1,59.9-7.2c51.3-31.6,136.9-138.1,164.4-170.5
+ c81.9-96,153.8-196.8,210.8-283.4h0.1c11.1,4.6,100.9,77.8,124.4,94c23.4,16.2,115.9,67.8,136,76.4c20,8.7,97.1,44.2,100.3,32.2
+ C1029.4,1668,1012.2,1597.1,1003.1,1593.7z"/>
+ </g>
+ <path class="st1" d="M569,2572c18,11,35,20,54,29c38,19,81,39,122,54c56,21,112,38,168,51c31,7,65,13,98,18c3,0,92,11,110,11h90
+ c35-3,68-5,103-10c28-4,59-9,89-16c22-5,45-10,67-17c21-6,45-14,68-22c15-5,31-12,47-18c13-6,29-13,44-19c18-8,39-19,59-29
+ c16-8,34-18,51-28c13-7,43-30,59-30c18,0,30,16,30,30c0,29-39,38-57,51c-19,13-42,23-62,34c-40,21-81,39-120,54
+ c-51,19-107,37-157,49c-19,4-38,9-57,12c-10,2-114,18-143,18h-132c-35-3-72-7-107-12c-31-5-64-11-95-18c-24-5-50-12-73-19
+ c-40-11-79-25-117-40c-69-26-141-60-209-105c-12-8-13-16-13-25c0-15,11-29,29-29C531,2546,563,2569,569,2572z"/>
+ <path class="st1" d="M1151,2009L61,2372V764l1090-363V2009z M1212,354v1680c-1,5-3,10-7,15c-2,3-6,7-9,8c-25,10-1151,388-1166,388
+ c-12,0-23-8-29-21c0-1-1-2-1-4V739c2-5,3-12,7-16c8-11,22-13,31-16c17-6,1126-378,1142-378C1190,329,1212,336,1212,354z"/>
+ <path class="st1" d="M2120,2017l-907-282V380l907-308V2017z M2181,32v2023c-1,23-17,33-32,33c-13,0-107-32-123-37
+ c-126-39-253-78-378-117c-28-9-57-18-84-27c-24-7-50-15-74-23c-107-33-216-66-323-102c-4-1-14-15-14-18V351c2-5,4-11,9-15
+ c8-9,351-123,486-168c36-13,487-168,501-168C2167,0,2181,13,2181,32z"/>
+ <polygon points="2411.2,2440.7 1199.5,2054.5 1204.6,373.2 2411.2,757.2 "/>
+ <g>
+ <path class="st2" d="M1800.3,1124.6L1681.4,1412l218.6,66.3L1800.3,1124.6z M1729,853.2l156.1,47.3l284.4,1025l-160.3-48.7
+ l-57.6-210.4L1620.2,1566l-71.3,171.4l-160.4-48.7L1729,853.2z"/>
+ </g>
+ </g>
+</g>
+</svg>
diff --git a/packages/web-util/src/assets/logo-2021.svg b/packages/web-util/src/assets/logo-2021.svg
new file mode 100644
index 000000000..8c5ff3e5b
--- /dev/null
+++ b/packages/web-util/src/assets/logo-2021.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">
+ <g fill="#0042b3" fill-rule="evenodd" stroke-width=".3">
+ <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />
+ <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />
+ <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />
+ </g>
+ <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />
+</svg> \ No newline at end of file
diff --git a/packages/web-util/src/assets/logo-white.svg b/packages/web-util/src/assets/logo-white.svg
new file mode 100644
index 000000000..cb1f023c5
--- /dev/null
+++ b/packages/web-util/src/assets/logo-white.svg
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ width="670"
+ height="300"
+ viewBox="0 0 201 90"
+ version="1.1"
+ id="svg8">
+ <g
+ id="logo">
+ <g
+ id="circles"
+ style="fill:#FFF;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.327943">
+ <path
+ d="m 86.662153,1.1211936 c 15.589697,0 29.129227,9.4011664 35.961027,23.2018054 h -5.81736 C 110.4866,13.623304 99.349002,6.5180852 86.662153,6.5180852 c -19.690571,0 -35.652876,17.1120008 -35.652876,38.2205688 0,10.331797 3.825597,19.704678 10.03957,26.582945 -1.342357,1.120912 -2.771532,2.127905 -4.275488,3.006754 C 50.071485,66.553412 45.974857,56.15992 45.974857,44.738654 c 0,-24.089211 18.216325,-43.6174604 40.687296,-43.6174604 z M 122.51416,65.375898 c -6.86645,13.680134 -20.34561,22.980218 -35.852007,22.980218 -1.052702,0 -2.096093,-0.04291 -3.128683,-0.127026 3.052192,-1.561167 5.913582,-3.480387 8.538307,-5.707305 10.320963,-1.684389 19.185983,-8.113638 24.601813,-17.145887 z"
+ id="path2350" />
+ <path
+ d="m 64.212372,1.1211936 c 1.052607,0 2.095998,0.042919 3.128684,0.1270583 C 64.288864,2.8094199 61.427378,4.728606 58.802653,6.9555572 41.679542,9.7498571 28.559494,25.601563 28.559494,44.738654 c 0,14.264563 7.29059,26.702023 18.093843,33.268925 -1.593656,0.26719 -3.226966,0.406948 -4.890748,0.406948 -1.239545,0 -2.46151,-0.07952 -3.663522,-0.229364 C 29.191129,70.184015 23.525076,58.171633 23.525076,44.738654 23.525076,20.649443 41.7414,1.1211936 64.212372,1.1211936 Z M 69.62209,82.521785 C 79.943207,80.837396 88.808164,74.407841 94.224059,65.375422 h 5.840511 c -6.866354,13.680305 -20.345548,22.980694 -35.852198,22.980694 -1.052703,0 -2.095999,-0.04291 -3.128684,-0.127026 3.052002,-1.561371 5.913836,-3.480218 8.538402,-5.707305 z M 94.355885,24.322999 c -3.13939,-5.314721 -7.467551,-9.74275 -12.584511,-12.853269 1.593656,-0.26719 3.226904,-0.406948 4.890779,-0.406948 1.239451,0 2.461512,0.07952 3.663524,0.229364 4.016018,3.607242 7.373195,8.030111 9.849053,13.030853 z"
+ id="path2352" />
+ <path
+ d="m 41.762589,1.1211936 c 1.064296,0 2.118804,0.044379 3.162607,0.1302161 -3.046523,1.558961 -5.903162,3.4745139 -8.52358,5.6968133 C 19.254624,9.7205882 6.1097128,25.583465 6.1097128,44.738654 c 0,21.108568 15.9624012,38.22057 35.6528762,38.22057 12.599746,0 23.672446,-7.007056 30.013748,-17.583802 h 5.838515 C 70.748498,79.055727 57.26924,88.356116 41.762589,88.356116 c -22.470907,0 -40.6871998,-19.52825 -40.6871998,-43.617462 0,-24.089211 18.2162928,-43.6174604 40.6871998,-43.6174604 z M 71.905375,24.322999 c -1.31192,-2.220567 -2.830984,-4.287049 -4.528877,-6.166508 1.342452,-1.120945 2.771374,-2.128381 4.275139,-3.00723 2.372984,2.753011 4.418875,5.834636 6.072489,9.173738 z"
+ id="path2354" />
+ </g>
+ <g
+ id="letters"
+ style="fill:#FFF">
+ <path
+ d="m 76.135411,34.409066 h 9.161042 V 29.36588 H 61.857537 v 5.043186 h 9.161137 v 25.92317 h 5.116737 z"
+ id="path2346" />
+ <path
+ d="m 92.647571,52.856334 h 13.659009 l 2.93009,7.476072 h 5.36461 L 101.89122,29.144903 H 97.187186 L 84.477089,60.332406 h 5.199533 z m 11.802109,-4.822276 h -9.944771 l 4.951718,-12.386462 z"
+ id="path2362" />
+ <path
+ d="m 123.80641,29.366084 h -4.58038 v 30.966322 h 20.54728 v -4.910253 c -5.32227,0 -10.64463,0 -15.9669,0 z"
+ id="path2356" />
+ <path
+ d="m 166.4722,29.366084 h -21.37564 v 30.966322 h 21.58203 v -4.910253 h -16.54771 v -8.27275 h 14.48439 V 42.23925 h -14.48439 v -7.962811 h 16.34132 z"
+ id="path2360" />
+ <path
+ d="m 191.19035,39.474593 c 0,1.59947 -0.53646,2.87535 -1.61628,3.818883 -1.07281,0.95124 -2.52409,1.422837 -4.34678,1.422837 h -7.44851 V 34.276439 h 7.4073 c 1.9051,0 3.38376,0.435027 4.42939,1.312178 1.05226,0.870258 1.57488,2.167734 1.57488,3.885976 z m 6.06602,20.857813 -7.79911,-11.723191 c 1.01771,-0.294794 1.94631,-0.714813 2.78553,-1.260566 0.83885,-0.545619 1.56122,-1.209263 2.16629,-1.990627 0.60541,-0.781738 1.07981,-1.681096 1.42369,-2.698345 0.34378,-1.017553 0.51561,-2.175238 0.51561,-3.472883 0,-1.50409 -0.24743,-2.867948 -0.74267,-4.092048 -0.49515,-1.223794 -1.20344,-2.256186 -2.12499,-3.096734 -0.92173,-0.840446 -2.04957,-1.489252 -3.38375,-1.946452 -1.33447,-0.457267 -2.82692,-0.685476 -4.4774,-0.685476 h -12.87512 v 30.966322 h 5.03433 V 49.538522 h 6.37569 l 7.11829,10.793884 z"
+ id="path2358" />
+ </g>
+ </g>
+</svg>
diff --git a/packages/web-util/src/cli.ts b/packages/web-util/src/cli.ts
new file mode 100644
index 000000000..05a22bc8a
--- /dev/null
+++ b/packages/web-util/src/cli.ts
@@ -0,0 +1,50 @@
+import { setGlobalLogLevelFromString } from "@gnu-taler/taler-util";
+import { clk } from "@gnu-taler/taler-util/clk";
+import { serve } from "./serve.js";
+
+export const walletCli = clk
+ .program("wallet", {
+ help: "Command line interface for the GNU Taler wallet.",
+ })
+ .maybeOption("log", ["-L", "--log"], clk.STRING, {
+ help: "configure log level (NONE, ..., TRACE)",
+ onPresentHandler: (x) => {
+ setGlobalLogLevelFromString(x);
+ },
+ })
+ .flag("version", ["-v", "--version"], {
+ onPresentHandler: printVersion,
+ })
+ .flag("verbose", ["-V", "--verbose"], {
+ help: "Enable verbose output.",
+ });
+
+walletCli
+ .subcommand("serve", "serve", { help: "Create a server." })
+ .maybeOption("folder", ["-F", "--folder"], clk.STRING, {
+ help: "should complete",
+ // default: "./dist"
+ })
+ .maybeOption("port", ["-P", "--port"], clk.INT, {
+ help: "should complete",
+ // default: 8000
+ })
+ .flag("development", ["-D", "--dev"], {
+ help: "should complete",
+ })
+ .action(async (args) => {
+ return serve({
+ folder: args.serve.folder || "./dist",
+ port: args.serve.port || 8000,
+ });
+ });
+
+declare const __VERSION__: string;
+function printVersion(): void {
+ console.log(__VERSION__);
+ process.exit(0);
+}
+
+export function main(): void {
+ walletCli.run();
+}
diff --git a/packages/web-util/src/components/Attention.tsx b/packages/web-util/src/components/Attention.tsx
new file mode 100644
index 000000000..4172c0c9b
--- /dev/null
+++ b/packages/web-util/src/components/Attention.tsx
@@ -0,0 +1,80 @@
+import { Duration, TranslatedString, assertUnreachable } from "@gnu-taler/taler-util";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+
+interface Props {
+ type?: "info" | "success" | "warning" | "danger" | "low",
+ onClose?: () => void,
+ title: TranslatedString,
+ children?: ComponentChildren,
+ timeout?: Duration,
+}
+export function Attention({ type = "info", title, children, onClose, timeout = Duration.getForever() }: Props): VNode {
+
+ return <div class={`group attention-${type} mt-2 shadow-lg`}>
+ {timeout.d_ms === "forever" ? undefined : <style>{`
+ .progress {
+ animation: notificationTimeoutBar ${Math.round(timeout.d_ms / 1000)}s ease-in-out;
+ animation-fill-mode:both;
+ }
+
+ @keyframes notificationTimeoutBar {
+ 0% { width: 0; }
+ 100% { width: 100%; }
+ }
+ `}</style>
+ }
+
+ <div data-timed={timeout.d_ms !== "forever"} class="rounded-md data-[timed=true]:rounded-b-none group-[.attention-info]:bg-blue-50 group-[.attention-low]:bg-gray-100 group-[.attention-warning]:bg-yellow-50 group-[.attention-danger]:bg-red-50 group-[.attention-success]:bg-green-50 p-4 shadow">
+ <div class="flex">
+ <div >
+ {type === "low" ? undefined :
+ <svg xmlns="http://www.w3.org/2000/svg" stroke="none" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 group-[.attention-info]:text-blue-400 group-[.attention-warning]:text-yellow-400 group-[.attention-danger]:text-red-400 group-[.attention-success]:text-green-400">
+ {(() => {
+ switch (type) {
+ case "info":
+ return <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" />
+ case "warning":
+ return <path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" />
+ case "danger":
+ return <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" />
+ case "success":
+ return <path fill-rule="evenodd" d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 016 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 012.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 00.322-1.672V3a.75.75 0 01.75-.75 2.25 2.25 0 012.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 01-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 00-1.423-.23h-.777zM2.331 10.977a11.969 11.969 0 00-.831 4.398 12 12 0 00.52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 01-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227z" />
+ default:
+ assertUnreachable(type)
+ }
+ })()}
+ </svg>
+ }
+ </div>
+ <div class="ml-3 w-full">
+ <h3 class="text-sm font-bold group-[.attention-info]:text-blue-800 group-[.attention-success]:text-green-800 group-[.attention-warning]:text-yellow-800 group-[.attention-danger]:text-red-800">
+ {title}
+ </h3>
+ <div class="mt-2 text-sm group-[.attention-info]:text-blue-700 group-[.attention-warning]:text-yellow-700 group-[.attention-danger]:text-red-700 group-[.attention-success]:text-green-700">
+ {children}
+ </div>
+ </div>
+ {onClose &&
+ <div>
+ <button type="button" class="font-semibold items-center rounded bg-transparent px-2 py-1 text-xs text-gray-900 hover:bg-gray-50"
+ onClick={(e) => {
+ e.preventDefault();
+ onClose();
+ }}
+ >
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+ <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
+ </svg>
+ </button>
+ </div>
+ }
+ </div>
+ </div>
+ {timeout.d_ms === "forever" ? undefined :
+ <div class="meter group-[.attention-info]:bg-blue-50 group-[.attention-low]:bg-gray-100 group-[.attention-warning]:bg-yellow-50 group-[.attention-danger]:bg-red-50 group-[.attention-success]:bg-green-50 h-1 relative overflow-hidden -mt-1">
+ <span class="w-full h-full block"><span class="h-full block progress group-[.attention-info]:bg-blue-600 group-[.attention-low]:bg-gray-600 group-[.attention-warning]:bg-yellow-600 group-[.attention-danger]:bg-red-600 group-[.attention-success]:bg-green-600"></span></span>
+ </div>
+ }
+
+ </div>
+}
diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx
new file mode 100644
index 000000000..ea0ea2f38
--- /dev/null
+++ b/packages/web-util/src/components/Button.tsx
@@ -0,0 +1,131 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AbsoluteTime, OperationFail, OperationOk, OperationResult, TalerError, TranslatedString } from "@gnu-taler/taler-util";
+// import { NotificationMessage, notifyInfo } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { HTMLAttributes, useEffect, useState, useTransition } from "preact/compat";
+import { NotificationMessage, buildUnifiedRequestErrorMessage, notifyInfo, useTranslationContext } from "../index.browser.js";
+// import { useBankCoreApiContext } from "../context/config.js";
+
+// function errorMap<T extends OperationFail<unknown>>(resp: T, map: (d: T["case"]) => TranslatedString): void {
+
+export interface ButtonHandler<T extends OperationResult<A, B>, A, B> {
+ onClick: () => Promise<T | undefined>,
+ onNotification: (n: NotificationMessage) => void;
+ onOperationSuccess: ((result: T extends OperationOk<any> ? T : never) => void) | ((result: T extends OperationOk<any> ? T : never) => TranslatedString | undefined),
+ onOperationFail: (d: T extends OperationFail<any> ? T : never) => TranslatedString;
+ onOperationComplete?: () => void;
+}
+
+interface Props<T extends OperationResult<A, B>, A, B> extends HTMLAttributes<HTMLButtonElement> {
+ handler: ButtonHandler<T, A, B> | undefined,
+}
+
+/**
+ * This button accept an async function and report a notification
+ * on error or success.
+ *
+ * When the async function is running the inner text will change into
+ * a "loading" animation.
+ *
+ * @param param0
+ * @returns
+ */
+export function Button<T extends OperationResult<A, B>, A, B>({
+ handler,
+ children,
+ disabled,
+ onClick: clickEvent,
+ ...rest
+}: Props<T, A, B>): VNode {
+ const { i18n } = useTranslationContext();
+ const [running, setRunning] = useState(false)
+ return <button {...rest} disabled={disabled || running} onClick={(e) => {
+ e.preventDefault();
+ if (!handler) { return; }
+ setRunning(true)
+ handler.onClick().then((resp) => {
+ if (resp) {
+ if (resp.type === "ok") {
+ const result: OperationOk<any> = resp
+ // @ts-expect-error this is an operationOk
+ const msg = handler.onOperationSuccess(result)
+ if (msg) {
+ notifyInfo(msg)
+ }
+ }
+ if (resp.type === "fail") {
+ // @ts-expect-error this is an operationFail
+ const error: OperationFail<any> = resp;
+ // @ts-expect-error this is an operationFail
+ const title = handler.onOperationFail(error)
+ handler.onNotification({
+ title,
+ type: "error",
+ description: error.detail.hint as TranslatedString,
+ debug: error.detail,
+ when: AbsoluteTime.now(),
+ })
+ }
+ }
+ if (handler.onOperationComplete) {
+ handler.onOperationComplete()
+ }
+ setRunning(false)
+ }).catch(error => {
+ console.error(error)
+
+ if (error instanceof TalerError) {
+ handler.onNotification(buildUnifiedRequestErrorMessage(i18n, error))
+ } else {
+ const description = (error instanceof Error ?
+ error.message : String(error)) as TranslatedString
+
+ handler.onNotification({
+ title: i18n.str`Operation failed`,
+ type: "error",
+ description,
+ when: AbsoluteTime.now(),
+ })
+ }
+
+ if (handler.onOperationComplete) {
+ handler.onOperationComplete()
+ }
+ setRunning(false)
+ })
+ }} >
+ {running ? <Wait /> : children}
+ </button>
+}
+
+function Wait(): VNode {
+ return <Fragment>
+ <style>{`
+ #l1 { width: 120px;
+ height: 20px;
+ -webkit-mask: radial-gradient(circle closest-side, currentColor 90%, #0000) left/20% 100%;
+ background: linear-gradient(currentColor 0 0) left/0% 100% no-repeat #ddd;
+ animation: l17 2s infinite steps(6);
+ }
+ @keyframes l17 {
+ 100% {background-size:120% 100%}
+`}
+ </style>
+ <div id="l1" />
+ </Fragment>
+}
diff --git a/packages/web-util/src/components/CopyButton.tsx b/packages/web-util/src/components/CopyButton.tsx
new file mode 100644
index 000000000..dbb38b474
--- /dev/null
+++ b/packages/web-util/src/components/CopyButton.tsx
@@ -0,0 +1,56 @@
+import { ComponentChildren, h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+
+export function CopyIcon(): VNode {
+ return (
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
+ </svg>
+ )
+};
+
+export function CopiedIcon(): VNode {
+ return (
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
+ </svg>
+ )
+};
+
+export function CopyButton({ class: clazz, children, getContent }: { children?: ComponentChildren, class: string, getContent: () => string }): VNode {
+ const [copied, setCopied] = useState(false);
+ function copyText(): void {
+ if (!navigator.clipboard && !window.isSecureContext) {
+ alert('clipboard is not available on insecure context (http)')
+ }
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(getContent() || "");
+ setCopied(true);
+ }
+ }
+ useEffect(() => {
+ if (copied) {
+ setTimeout(() => {
+ setCopied(false);
+ }, 1000);
+ }
+ }, [copied]);
+
+ if (!copied) {
+ return (
+ <button class={clazz} onClick={e => {
+ e.preventDefault()
+ copyText()
+ }} >
+ <CopyIcon />
+ {children}
+ </button>
+ );
+ }
+ return (
+ <button class={clazz} disabled>
+ <CopiedIcon />
+ {children}
+ </button>
+ );
+}
diff --git a/packages/web-util/src/components/ErrorLoading.tsx b/packages/web-util/src/components/ErrorLoading.tsx
new file mode 100644
index 000000000..7089266b9
--- /dev/null
+++ b/packages/web-util/src/components/ErrorLoading.tsx
@@ -0,0 +1,147 @@
+/*
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TalerError, TalerErrorCode, assertUnreachable } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import { Attention } from "./Attention.js";
+import { useTranslationContext } from "../index.browser.js";
+
+export function ErrorLoading({ error, showDetail }: { error: TalerError, showDetail?: boolean }): VNode {
+ const { i18n } = useTranslationContext()
+ switch (error.errorDetail.code) {
+ //////////////////
+ // Every error that can be produce in a Http Request
+ //////////////////
+ case TalerErrorCode.GENERIC_TIMEOUT: {
+ if (error.hasErrorCode(TalerErrorCode.GENERIC_TIMEOUT)) {
+ const { requestMethod, requestUrl, timeoutMs } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`The request reached a timeout, check your connection.`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: {
+ if (error.hasErrorCode(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR)) {
+ const { requestMethod, requestUrl, timeoutMs } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`The request was cancelled.`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT)) {
+ const { requestMethod, requestUrl, timeoutMs } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`The request reached a timeout, check your connection.`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED)) {
+ const { requestMethod, requestUrl, throttleStats } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`A lot of request were made to the same server and this action was throttled`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, throttleStats }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE)) {
+ const { requestMethod, requestUrl, httpStatusCode, validationError } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`The response of the request is malformed.`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, validationError }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_NETWORK_ERROR: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_NETWORK_ERROR)) {
+ const { requestMethod, requestUrl } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`Could not complete the request due to a network problem.`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR)) {
+ const { requestMethod, requestUrl, httpStatusCode, errorResponse } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`Unexpected request error`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, errorResponse }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ //////////////////
+ // Every other error
+ //////////////////
+ // case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: {
+ // return <Attention type="danger" title={i18n.str``}>
+ // </Attention>
+ // }
+ //////////////////
+ // Default message for unhandled case
+ //////////////////
+ default: return <Attention type="danger" title={i18n.str`Unexpected error`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify(error.errorDetail, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+}
+
diff --git a/packages/web-util/src/components/ErrorLoadingMerchant.tsx b/packages/web-util/src/components/ErrorLoadingMerchant.tsx
new file mode 100644
index 000000000..7089266b9
--- /dev/null
+++ b/packages/web-util/src/components/ErrorLoadingMerchant.tsx
@@ -0,0 +1,147 @@
+/*
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TalerError, TalerErrorCode, assertUnreachable } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import { Attention } from "./Attention.js";
+import { useTranslationContext } from "../index.browser.js";
+
+export function ErrorLoading({ error, showDetail }: { error: TalerError, showDetail?: boolean }): VNode {
+ const { i18n } = useTranslationContext()
+ switch (error.errorDetail.code) {
+ //////////////////
+ // Every error that can be produce in a Http Request
+ //////////////////
+ case TalerErrorCode.GENERIC_TIMEOUT: {
+ if (error.hasErrorCode(TalerErrorCode.GENERIC_TIMEOUT)) {
+ const { requestMethod, requestUrl, timeoutMs } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`The request reached a timeout, check your connection.`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: {
+ if (error.hasErrorCode(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR)) {
+ const { requestMethod, requestUrl, timeoutMs } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`The request was cancelled.`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT)) {
+ const { requestMethod, requestUrl, timeoutMs } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`The request reached a timeout, check your connection.`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED)) {
+ const { requestMethod, requestUrl, throttleStats } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`A lot of request were made to the same server and this action was throttled`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, throttleStats }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE)) {
+ const { requestMethod, requestUrl, httpStatusCode, validationError } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`The response of the request is malformed.`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, validationError }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_NETWORK_ERROR: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_NETWORK_ERROR)) {
+ const { requestMethod, requestUrl } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`Could not complete the request due to a network problem.`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: {
+ if (error.hasErrorCode(TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR)) {
+ const { requestMethod, requestUrl, httpStatusCode, errorResponse } = error.errorDetail
+ return <Attention type="danger" title={i18n.str`Unexpected request error`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, errorResponse }, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+ assertUnreachable(1 as never)
+ }
+ //////////////////
+ // Every other error
+ //////////////////
+ // case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: {
+ // return <Attention type="danger" title={i18n.str``}>
+ // </Attention>
+ // }
+ //////////////////
+ // Default message for unhandled case
+ //////////////////
+ default: return <Attention type="danger" title={i18n.str`Unexpected error`}>
+ {error.message}
+ {showDetail &&
+ <pre class="whitespace-break-spaces ">
+ {JSON.stringify(error.errorDetail, undefined, 2)}
+ </pre>
+ }
+ </Attention>
+ }
+}
+
diff --git a/packages/web-util/src/components/Footer.tsx b/packages/web-util/src/components/Footer.tsx
new file mode 100644
index 000000000..58dd2a60a
--- /dev/null
+++ b/packages/web-util/src/components/Footer.tsx
@@ -0,0 +1,48 @@
+import { useTranslationContext } from "../index.browser.js";
+import { h } from "preact";
+
+export function Footer({ testingUrlKey, VERSION, GIT_HASH }: { VERSION?: string, GIT_HASH?: string, testingUrlKey?: string }) {
+ const { i18n } = useTranslationContext()
+
+ const testingUrl = (testingUrlKey && typeof localStorage !== "undefined") && localStorage.getItem(testingUrlKey) ?
+ localStorage.getItem(testingUrlKey) ?? undefined :
+ undefined
+ const versionText = VERSION
+ ? GIT_HASH
+ ? <a href={`https://git.taler.net/wallet-core.git/tree/?id=${GIT_HASH}`} target="_blank" rel="noreferrer noopener">
+ Version {VERSION} ({GIT_HASH.substring(0, 8)})
+ </a>
+ : VERSION
+ : "";
+ return (
+ <footer class="bottom-4 my-4 mx-8 bg-slate-200">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">
+ <i18n.Translate>
+ Learn more about <a target="_blank" rel="noreferrer noopener" class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a>
+ </i18n.Translate>
+ </p>
+ </div>
+ <div style="flex-grow:1" />
+ <p class="text-xs leading-5 text-gray-400">
+ Copyright &copy; 2014&mdash;2023 Taler Systems SA. {versionText}{" "}
+ </p>
+ {testingUrlKey && testingUrl &&
+
+ <p class="text-xs leading-5 text-gray-300">
+ Testing with {testingUrl}{" "}
+ <a
+ href=""
+ onClick={(e) => {
+ e.preventDefault();
+ localStorage.removeItem(testingUrlKey);
+ window.location.reload();
+ }}
+ >
+ stop testing
+ </a>
+ </p>
+ }
+ </footer>
+ );
+}
diff --git a/packages/web-util/src/components/Header.tsx b/packages/web-util/src/components/Header.tsx
new file mode 100644
index 000000000..29f4a4949
--- /dev/null
+++ b/packages/web-util/src/components/Header.tsx
@@ -0,0 +1,183 @@
+import { useState } from "preact/hooks";
+import { LangSelector, useNotifications, useTranslationContext } from "../index.browser.js";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import logo from "../assets/logo-2021.svg";
+
+interface Props {
+ title: string;
+ iconLinkURL: string;
+ profileURL?: string;
+ notificationURL?: string;
+ children?: ComponentChildren;
+ onLogout: (() => void) | undefined;
+ sites: Array<Array<string>>;
+ supportedLangs: string[]
+}
+
+export function Header({ title, profileURL, notificationURL, iconLinkURL, sites, onLogout, children }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [open, setOpen] = useState(false)
+ const ns = useNotifications();
+
+ return <Fragment>
+ <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400">
+ <div class="flex flex-row h-16 items-center ">
+ <div class="flex px-2 justify-start">
+ <div class="flex-shrink-0 bg-white rounded-lg">
+ <a href={iconLinkURL ?? "#"} name="logo">
+ <img
+ class="h-8 w-auto"
+ src={logo}
+ alt="GNU Taler"
+ style={{ height: "1.5rem", margin: ".5rem" }}
+ />
+ </a>
+ </div>
+ <span class="flex items-center text-white text-lg font-bold ml-4">
+ {title}
+ </span>
+ </div>
+ <div class="flex-1 ml-6 ">
+ <div class="flex flex-1 space-x-4">
+ {sites.map((site) => {
+ if (site.length !== 2) return;
+ const [name, url] = site
+ return <a href={url} name={`site header ${name}`} class="hidden sm:block text-white hover:bg-indigo-500 hover:bg-opacity-75 rounded-md py-2 px-3 text-sm font-medium">{name}</a>
+ })}
+ </div>
+ </div>
+ <div class="flex justify-end">
+ {!notificationURL ? undefined :
+ <a href={notificationURL} name="notifications" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 mr-2 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" aria-controls="mobile-menu" aria-expanded="false">
+ <span class="absolute -inset-0.5"></span>
+ <span class="sr-only"><i18n.Translate>Show notifications</i18n.Translate></span>
+ {ns.length > 0 ?
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-10 h-10">
+ <path d="M5.85 3.5a.75.75 0 0 0-1.117-1 9.719 9.719 0 0 0-2.348 4.876.75.75 0 0 0 1.479.248A8.219 8.219 0 0 1 5.85 3.5ZM19.267 2.5a.75.75 0 1 0-1.118 1 8.22 8.22 0 0 1 1.987 4.124.75.75 0 0 0 1.48-.248A9.72 9.72 0 0 0 19.266 2.5Z" />
+ <path fill-rule="evenodd" d="M12 2.25A6.75 6.75 0 0 0 5.25 9v.75a8.217 8.217 0 0 1-2.119 5.52.75.75 0 0 0 .298 1.206c1.544.57 3.16.99 4.831 1.243a3.75 3.75 0 1 0 7.48 0 24.583 24.583 0 0 0 4.83-1.244.75.75 0 0 0 .298-1.205 8.217 8.217 0 0 1-2.118-5.52V9A6.75 6.75 0 0 0 12 2.25ZM9.75 18c0-.034 0-.067.002-.1a25.05 25.05 0 0 0 4.496 0l.002.1a2.25 2.25 0 1 1-4.5 0Z" clip-rule="evenodd" />
+ </svg>
+ :
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
+ </svg>
+ }
+ </a>
+ }
+ {!profileURL ? undefined :
+ <a href={profileURL} name="profile" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 mr-2 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" aria-controls="mobile-menu" aria-expanded="false">
+ <span class="absolute -inset-0.5"></span>
+ <span class="sr-only"><i18n.Translate>Open profile</i18n.Translate></span>
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
+ </svg>
+ </a>
+ }
+ <button type="button" name="toggle sidebar" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" aria-controls="mobile-menu" aria-expanded="false"
+ onClick={(e) => {
+ setOpen(!open)
+ }}>
+ <span class="absolute -inset-0.5"></span>
+ <span class="sr-only"><i18n.Translate>Open settings</i18n.Translate></span>
+ <svg class="block h-10 w-10" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
+ </svg>
+ </button>
+ </div>
+ </div>
+ </header>
+
+ {
+ open &&
+ <div class="relative z-10" name="sidebar overlay" aria-labelledby="slide-over-title" role="dialog" aria-modal="true"
+ onClick={() => {
+ setOpen(false)
+ }}>
+ <div class="fixed inset-0"></div>
+
+ <div class="fixed inset-0 overflow-hidden">
+ <div class="absolute inset-0 overflow-hidden">
+ <div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
+ <div class="pointer-events-auto w-screen max-w-md" >
+ <div class="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl" onClick={(e) => {
+ //do not trigger close if clicking inside the sidebar
+ e.stopPropagation();
+ }}>
+ <div class="px-4 sm:px-6" >
+ <div class="flex items-start justify-between" >
+ <h2 class="text-base font-semibold leading-6 text-gray-900" id="slide-over-title">
+ <i18n.Translate>Menu</i18n.Translate>
+ </h2>
+ <div class="ml-3 flex h-7 items-center">
+ <button type="button" name="close sidebar" class="relative rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
+ onClick={(e) => {
+ setOpen(false)
+ }}
+
+ >
+ <span class="absolute -inset-2.5"></span>
+ <span class="sr-only">
+ <i18n.Translate>Close panel</i18n.Translate>
+ </span>
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="relative mt-6 flex-1 px-4 sm:px-6">
+ <nav class="flex flex-1 flex-col" aria-label="Sidebar">
+ <ul role="list" class="flex flex-1 flex-col gap-y-7">
+ {onLogout ?
+ <li>
+ <a href="#"
+ name="logout"
+ class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
+ onClick={() => {
+ onLogout();
+ setOpen(false)
+ }}
+ >
+ <svg class="h-6 w-6 shrink-0 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
+ </svg>
+ <i18n.Translate>Log out</i18n.Translate>
+ </a>
+ </li>
+ : undefined}
+ <li>
+ <LangSelector />
+ </li>
+ {/* CHILDREN */}
+ {children}
+ {/* /CHILDREN */}
+ {sites.length > 0 ?
+ <li class="block sm:hidden">
+ <div class="text-xs font-semibold leading-6 text-gray-400">
+ <i18n.Translate>Sites</i18n.Translate>
+ </div>
+ <ul role="list" class="space-y-1">
+ {sites.map(([name, url]) => {
+ return <li>
+ <a href={url} name={`site ${name}`} target="_blank" rel="noopener noreferrer" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
+ <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white text-gray-400 border-gray-200 group-hover:border-indigo-600 group-hover:text-indigo-600">&gt;</span>
+ <span class="truncate">{name}</span>
+ </a>
+ </li>
+ })}
+ </ul>
+ </li>
+ : undefined
+ }
+ </ul>
+ </nav>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ }
+ </Fragment >
+}
diff --git a/packages/web-util/src/components/LangSelector.tsx b/packages/web-util/src/components/LangSelector.tsx
new file mode 100644
index 000000000..8e5a82f75
--- /dev/null
+++ b/packages/web-util/src/components/LangSelector.tsx
@@ -0,0 +1,115 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { Fragment, h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+// import { strings as messages } from "../i18n/strings.js";
+import langIcon from "../assets/lang.svg";
+import { useTranslationContext } from "../index.browser.js";
+
+type LangsNames = {
+ [P: string]: string;
+};
+
+const names: LangsNames = {
+ es: "Español [es]",
+ en: "English [en]",
+ fr: "Français [fr]",
+ de: "Deutsch [de]",
+ sv: "Svenska [sv]",
+ it: "Italiano [it]",
+};
+
+function getLangName(s: keyof LangsNames | string): string {
+ if (names[s]) return names[s];
+ return String(s);
+}
+
+export function LangSelector({ }: {}): VNode {
+ const [updatingLang, setUpdatingLang] = useState(false);
+ const { lang, changeLanguage, completeness, supportedLang } = useTranslationContext();
+ const [hidden, setHidden] = useState(true);
+
+ useEffect(() => {
+ function bodyKeyPress(event: KeyboardEvent) {
+ if (event.code === "Escape") setHidden(true);
+ }
+ function bodyOnClick(event: Event) {
+ setHidden(true);
+ }
+ document.body.addEventListener("click", bodyOnClick);
+ document.body.addEventListener("keydown", bodyKeyPress as any);
+ return () => {
+ document.body.removeEventListener("keydown", bodyKeyPress as any);
+ document.body.removeEventListener("click", bodyOnClick);
+ };
+ }, []);
+ return (
+ <div>
+ <div class="relative mt-2">
+ <button type="button" class="relative w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label"
+ onClick={(e) => {
+ setHidden(!hidden);
+ e.stopPropagation()
+ }}>
+ <span class="flex items-center">
+ <img alt="language" class="h-5 w-5 flex-shrink-0 rounded-full" src={langIcon} />
+ <span class="ml-3 block truncate">{getLangName(lang)}</span>
+ </span>
+ <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
+ <svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+ <path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" />
+ </svg>
+ </span>
+ </button>
+
+ {!hidden &&
+ <ul class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" tabIndex={-1} role="listbox" aria-labelledby="listbox-label" aria-activedescendant="listbox-option-3">
+ {Object.keys(supportedLang)
+ .filter((l) => l !== lang)
+ .map((lang) => (
+ <li class="text-gray-900 hover:bg-indigo-600 hover:text-white cursor-pointer relative select-none py-2 pl-3 pr-9" role="option"
+ onClick={() => {
+ changeLanguage(lang);
+ setUpdatingLang(false);
+ setHidden(true)
+ }}
+ >
+ <span class="font-normal truncate flex justify-between ">
+ <span>{getLangName(lang)}</span>
+ <span>{(completeness as any)[lang]}%</span>
+ </span>
+
+ <span class="text-indigo-600 absolute inset-y-0 right-0 flex items-center pr-4">
+ {/* <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+ <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
+ </svg> */}
+ </span>
+ </li>
+ ))}
+
+ </ul>
+ }
+
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web-util/src/components/Loading.tsx b/packages/web-util/src/components/Loading.tsx
new file mode 100644
index 000000000..c5dcd90c1
--- /dev/null
+++ b/packages/web-util/src/components/Loading.tsx
@@ -0,0 +1,45 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { h, VNode } from "preact";
+
+export function Loading(): VNode {
+ return (
+ <div
+ class="columns is-centered is-vcentered"
+ style={{
+ width: "100%",
+ height: "200px",
+ display: "flex",
+ margin: "auto",
+ justifyContent: "center",
+ }}
+ >
+ <Spinner />
+ </div>
+ );
+}
+
+function Spinner(): VNode {
+ return (
+ <div class="lds-ring" style={{ margin: "auto" }}>
+ <div />
+ <div />
+ <div />
+ <div />
+ </div>
+ );
+}
diff --git a/packages/web-util/src/components/NotificationBanner.tsx b/packages/web-util/src/components/NotificationBanner.tsx
new file mode 100644
index 000000000..31d5a5d01
--- /dev/null
+++ b/packages/web-util/src/components/NotificationBanner.tsx
@@ -0,0 +1,33 @@
+import { h, Fragment, VNode } from "preact";
+import { Attention } from "./Attention.js";
+import { Notification } from "../index.browser.js";
+
+export function LocalNotificationBanner({ notification, showDebug }: { notification?: Notification, showDebug?: boolean }): VNode {
+ if (!notification) return <Fragment />
+ switch (notification.message.type) {
+ case "error":
+ return <div class="relative">
+ <div class="fixed top-0 left-0 right-0 z-20 w-full p-4">
+ <Attention type="danger" title={notification.message.title} onClose={() => {
+ notification.acknowledge()
+ }}>
+ {notification.message.description &&
+ <div class="mt-2 text-sm text-red-700">
+ {notification.message.description}
+ </div>
+ }
+ {showDebug && <pre class="whitespace-break-spaces ">
+ {notification.message.debug}
+ </pre>}
+ </Attention>
+ </div>
+ </div>
+ case "info":
+ return <div class="relative">
+ <div class="fixed top-0 left-0 right-0 z-20 w-full p-4">
+ <Attention type="success" title={notification.message.title} onClose={() => {
+ notification.acknowledge();
+ }} /></div></div>
+ }
+}
+
diff --git a/packages/web-util/src/components/ShowInputErrorLabel.tsx b/packages/web-util/src/components/ShowInputErrorLabel.tsx
new file mode 100644
index 000000000..c5840cad9
--- /dev/null
+++ b/packages/web-util/src/components/ShowInputErrorLabel.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Fragment, h, VNode } from "preact";
+
+export function ShowInputErrorLabel({
+ isDirty,
+ message,
+}: {
+ message: string | undefined;
+ isDirty: boolean;
+}): VNode {
+ if (message && isDirty)
+ return <div class="text-base" style={{ color: "red" }}>{message}</div>;
+ return <div class="text-base" style={{ }}> </div>;
+}
diff --git a/packages/web-util/src/components/ToastBanner.tsx b/packages/web-util/src/components/ToastBanner.tsx
new file mode 100644
index 000000000..ece26285f
--- /dev/null
+++ b/packages/web-util/src/components/ToastBanner.tsx
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { Fragment, VNode, h } from "preact"
+import { Attention, GLOBAL_NOTIFICATION_TIMEOUT as GLOBAL_TOAST_TIMEOUT, Notification, useNotifications } from "../index.browser.js"
+
+/**
+ * Toasts should be considered when displaying these types of information to the user:
+ *
+ * Low attention messages that do not require user action
+ * Singular status updates
+ * Confirmations
+ * Information that does not need to be followed up
+ *
+ * Do not use toasts if the information contains the following:
+ *
+ * High attention and crtitical information
+ * Time-sensitive information
+ * Requires user action or input
+ * Batch updates
+ *
+ * @returns
+ */
+export function ToastBanner(): VNode {
+ const notifs = useNotifications()
+ if (notifs.length === 0) return <Fragment />
+ const show = notifs.filter(e => !e.message.ack && !e.message.timeout)
+ if (show.length === 0) return <Fragment />
+ return <AttentionByType msg={show[0]} />
+}
+
+function AttentionByType({ msg }: { msg: Notification }) {
+ switch (msg.message.type) {
+ case "error":
+ return <Attention type="danger" title={msg.message.title} onClose={() => {
+ msg.acknowledge()
+ }} timeout={GLOBAL_TOAST_TIMEOUT}>
+ {msg.message.description &&
+ <div class="mt-2 text-sm text-red-700">
+ {msg.message.description}
+ </div>
+ }
+ </Attention>
+ case "info":
+ return <Attention type="success" title={msg.message.title} onClose={() => {
+ msg.acknowledge();
+ }} timeout={GLOBAL_TOAST_TIMEOUT} />
+ }
+}
diff --git a/packages/web-util/src/components/index.ts b/packages/web-util/src/components/index.ts
new file mode 100644
index 000000000..d7ea41874
--- /dev/null
+++ b/packages/web-util/src/components/index.ts
@@ -0,0 +1,12 @@
+export * as utils from "./utils.js";
+export * from "./Attention.js";
+export * from "./CopyButton.js";
+export * from "./ErrorLoading.js";
+export * from "./LangSelector.js";
+export * from "./Loading.js";
+export * from "./Header.js";
+export * from "./Footer.js";
+export * from "./Button.js";
+export * from "./ShowInputErrorLabel.js";
+export * from "./NotificationBanner.js";
+export * from "./ToastBanner.js";
diff --git a/packages/web-util/src/components/utils.ts b/packages/web-util/src/components/utils.ts
new file mode 100644
index 000000000..75c3fc0fe
--- /dev/null
+++ b/packages/web-util/src/components/utils.ts
@@ -0,0 +1,111 @@
+import { createElement, VNode } from "preact";
+
+export type StateFunc<S> = (p: S) => VNode;
+
+export type StateViewMap<StateType extends { status: string }> = {
+ [S in StateType as S["status"]]: StateFunc<S>;
+};
+
+export type RecursiveState<S extends object> = S | (() => RecursiveState<S>);
+
+export function compose<SType extends { status: string }, PType>(
+ hook: (p: PType) => RecursiveState<SType>,
+ viewMap: StateViewMap<SType>,
+): (p: PType) => VNode {
+
+ function withHook(stateHook: () => RecursiveState<SType>): () => VNode {
+ function ComposedComponent(): VNode {
+ const state = stateHook();
+
+ if (typeof state === "function") {
+ const subComponent = withHook(state);
+ return createElement(subComponent, {});
+ }
+
+ const statusName = state.status as unknown as SType["status"];
+ const viewComponent = viewMap[statusName] as unknown as StateFunc<SType>;
+ return createElement(viewComponent, state);
+ }
+
+ return ComposedComponent;
+ }
+
+ return (p: PType) => {
+ const h = withHook(() => hook(p));
+ return h();
+ };
+}
+
+export function recursive<PType>(
+ hook: (p: PType) => RecursiveState<VNode>,
+): (p: PType) => VNode {
+
+ function withHook(stateHook: () => RecursiveState<VNode>): () => VNode {
+ function ComposedComponent(): VNode {
+ const state = stateHook();
+
+ if (typeof state === "function") {
+ const subComponent = withHook(state);
+ return createElement(subComponent, {});
+ }
+
+ return state;
+ }
+
+ return ComposedComponent;
+ }
+
+ return (p: PType) => {
+ const h = withHook(() => hook(p));
+ return h();
+ };
+}
+
+
+
+/**
+ *
+ * @param obj VNode
+ * @returns
+ */
+export function saveVNodeForInspection<T>(obj: T): T {
+ // @ts-ignore
+ window["showVNodeInfo"] = function showVNodeInfo() {
+ inspect(obj);
+ };
+ return obj;
+}
+function inspect(obj: any) {
+ if (!obj) return;
+ if (obj.__c && obj.__c.__H) {
+ const componentName = obj.__c.constructor.name;
+ const hookState = obj.__c.__H;
+ const stateList = hookState.__ as Array<any>;
+ console.log("==============", componentName);
+ stateList.forEach((hook) => {
+ const { __: value, c: context, __h: factory, __H: args } = hook;
+ if (typeof context !== "undefined") {
+ const { __c: contextId } = context;
+ console.log("context:", contextId, hook);
+ } else if (typeof factory === "function") {
+ console.log("memo:", value, "deps:", args);
+ } else if (typeof value === "function") {
+ const effectName = value.name;
+ console.log("effect:", effectName, "deps:", args);
+ } else if (typeof value.current !== "undefined") {
+ const ref = value.current;
+ console.log("ref:", ref instanceof Element ? ref.outerHTML : ref);
+ } else if (value instanceof Array) {
+ console.log("state:", value[0]);
+ } else {
+ console.log(hook);
+ }
+ });
+ }
+ const children = obj.__k;
+ if (children instanceof Array) {
+ children.forEach((e) => inspect(e));
+ } else {
+ inspect(children);
+ }
+}
diff --git a/packages/web-util/src/context/activity.ts b/packages/web-util/src/context/activity.ts
new file mode 100644
index 000000000..422b25909
--- /dev/null
+++ b/packages/web-util/src/context/activity.ts
@@ -0,0 +1,68 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ObservabilityEvent, TalerAuthenticationHttpClient, TalerBankConversionHttpClient, TalerCoreBankHttpClient, TalerMerchantInstanceHttpClient, TalerMerchantManagementHttpClient } from "@gnu-taler/taler-util";
+
+type Listener<Event> = (e: Event) => void;
+type Unsuscriber = () => void;
+export type Subscriber<Event> = (fn: Listener<Event>) => Unsuscriber;
+
+export class ActiviyTracker<Event> {
+ private observers = new Array<Listener<Event>>();
+ constructor() {
+ this.notify = this.notify.bind(this)
+ this.subscribe = this.subscribe.bind(this)
+ }
+ notify(data: Event): void {
+ this.observers.forEach((observer) => observer(data))
+ }
+ subscribe(func: Listener<Event>): Unsuscriber {
+ this.observers.push(func);
+ return () => {
+ this.observers.forEach((observer, index) => {
+ if (observer === func) {
+ this.observers.splice(index, 1);
+ }
+ });
+ };
+ }
+}
+
+/**
+ * build http client with cache breaker due to SWR
+ * @param url
+ * @returns
+ */
+export interface APIClient<T, C> {
+ getRemoteConfig(): Promise<C>;
+ VERSION: string;
+ lib: T,
+ onActivity: Subscriber<ObservabilityEvent>;
+ cancelRequest(id: string): void;
+}
+
+export interface MerchantLib {
+ instance: TalerMerchantManagementHttpClient;
+ authenticate: TalerAuthenticationHttpClient;
+ subInstanceApi: (instanceId: string) => MerchantLib;
+}
+
+export interface BankLib {
+ bank: TalerCoreBankHttpClient;
+ conversion: TalerBankConversionHttpClient;
+ auth: (user: string) => TalerAuthenticationHttpClient;
+}
+
diff --git a/packages/web-util/src/context/api.ts b/packages/web-util/src/context/api.ts
new file mode 100644
index 000000000..c1eaa37f8
--- /dev/null
+++ b/packages/web-util/src/context/api.ts
@@ -0,0 +1,49 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient } from "@gnu-taler/taler-util";
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+import { defaultRequestHandler } from "../utils/request.js";
+
+interface Type {
+ /**
+ * @deprecated this show not be used
+ */
+ request: typeof defaultRequestHandler;
+ bankCore: TalerCoreBankHttpClient,
+ bankIntegration: TalerBankIntegrationHttpClient,
+ bankWire: TalerWireGatewayHttpClient,
+ bankRevenue: TalerRevenueHttpClient,
+}
+
+const Context = createContext<Type>({ request: defaultRequestHandler } as any);
+
+export const useApiContext = (): Type => useContext(Context);
+export const ApiContextProvider = ({
+ children,
+ value,
+}: {
+ value: Type;
+ children: ComponentChildren;
+}): VNode => {
+ return h(Context.Provider, { value, children });
+};
diff --git a/packages/web-util/src/context/bank-api.ts b/packages/web-util/src/context/bank-api.ts
new file mode 100644
index 000000000..3f6a32f4b
--- /dev/null
+++ b/packages/web-util/src/context/bank-api.ts
@@ -0,0 +1,224 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ CacheEvictor,
+ LibtoolVersion,
+ ObservabilityEvent,
+ ObservableHttpClientLibrary,
+ TalerAuthenticationHttpClient,
+ TalerBankConversionCacheEviction,
+ TalerBankConversionHttpClient,
+ TalerCoreBankCacheEviction,
+ TalerCoreBankHttpClient,
+ TalerCorebankApi,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import {
+ ComponentChildren,
+ FunctionComponent,
+ VNode,
+ createContext,
+ h,
+} from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { APIClient, ActiviyTracker, BankLib, Subscriber } from "./activity.js";
+import { useTranslationContext } from "./translation.js";
+import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type BankContextType = {
+ url: URL;
+ config: TalerCorebankApi.Config;
+ lib: BankLib;
+ hints: VersionHint[];
+ onActivity: Subscriber<ObservabilityEvent>;
+ cancelRequest: (eventId: string) => void;
+};
+
+// @ts-expect-error default value to undefined, should it be another thing?
+const BankContext = createContext<BankContextType>(undefined);
+
+export const useBankCoreApiContext = (): BankContextType =>
+ useContext(BankContext);
+
+enum VersionHint {
+ NONE,
+}
+
+type Evictors = {
+ conversion?: CacheEvictor<TalerBankConversionCacheEviction>;
+ bank?: CacheEvictor<TalerCoreBankCacheEviction>;
+};
+
+type ConfigResult<T> =
+ | undefined
+ | { type: "ok"; config: T; hints: VersionHint[] }
+ | { type: "incompatible"; result: T; supported: string }
+ | { type: "error"; error: TalerError };
+
+const CONFIG_FAIL_TRY_AGAIN_MS = 5000;
+
+export const BankApiProvider = ({
+ baseUrl,
+ children,
+ frameOnError,
+ evictors = {},
+}: {
+ baseUrl: URL;
+ children: ComponentChildren;
+ evictors?: Evictors;
+ frameOnError: FunctionComponent<{ children: ComponentChildren }>;
+}): VNode => {
+ const [checked, setChecked] =
+ useState<ConfigResult<TalerCorebankApi.Config>>();
+ const { i18n } = useTranslationContext();
+
+ const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } =
+ buildBankApiClient(baseUrl, evictors);
+
+ useEffect(() => {
+ let keepRetrying = true;
+ async function testConfig(): Promise<void> {
+ try {
+ const config = await getRemoteConfig();
+ if (LibtoolVersion.compare(VERSION, config.version)) {
+ setChecked({ type: "ok", config, hints: [] });
+ } else {
+ setChecked({
+ type: "incompatible",
+ result: config,
+ supported: VERSION,
+ });
+ }
+ } catch (error) {
+ if (error instanceof TalerError) {
+ if (keepRetrying) {
+ setTimeout(() => {
+ testConfig();
+ }, CONFIG_FAIL_TRY_AGAIN_MS);
+ }
+ setChecked({ type: "error", error });
+ } else {
+ setChecked({ type: "error", error: TalerError.fromException(error) });
+ }
+ }
+ }
+ testConfig();
+ return () => {
+ // on unload, stop retry
+ keepRetrying = false;
+ };
+ }, []);
+
+ if (checked === undefined) {
+ return h(frameOnError, {
+ children: h("div", {}, "checking compatibility with server..."),
+ });
+ }
+ if (checked.type === "error") {
+ return h(frameOnError, {
+ children: h(ErrorLoading, { error: checked.error, showDetail: true }),
+ });
+ }
+ if (checked.type === "incompatible") {
+ return h(frameOnError, {
+ children: h(
+ "div",
+ {},
+ i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`,
+ ),
+ });
+ }
+
+ const value: BankContextType = {
+ url: baseUrl,
+ config: checked.config,
+ onActivity: onActivity,
+ lib,
+ cancelRequest,
+ hints: checked.hints,
+ };
+ return h(BankContext.Provider, {
+ value,
+ children,
+ });
+};
+
+function buildBankApiClient(
+ url: URL,
+ evictors: Evictors,
+): APIClient<BankLib, TalerCorebankApi.Config> {
+ const httpFetch = new BrowserFetchHttpLib({
+ enableThrottling: true,
+ requireTls: false,
+ });
+ const tracker = new ActiviyTracker<ObservabilityEvent>();
+ const httpLib = new ObservableHttpClientLibrary(httpFetch, {
+ observe(ev) {
+ tracker.notify(ev);
+ },
+ });
+
+ const bank = new TalerCoreBankHttpClient(url.href, httpLib, evictors.bank);
+ const conversion = new TalerBankConversionHttpClient(
+ bank.getConversionInfoAPI().href,
+ httpLib,
+ evictors.conversion,
+ );
+ const auth = (user: string) =>
+ new TalerAuthenticationHttpClient(
+ bank.getAuthenticationAPI(user).href,
+ httpLib,
+ );
+
+ async function getRemoteConfig(): Promise<TalerCorebankApi.Config> {
+ const resp = await bank.getConfig();
+ if (resp.type === "fail") {
+ throw TalerError.fromUncheckedDetail(resp.detail);
+ }
+ return resp.body;
+ }
+
+ return {
+ getRemoteConfig,
+ VERSION: bank.PROTOCOL_VERSION,
+ lib: {
+ bank,
+ conversion,
+ auth,
+ },
+ onActivity: tracker.subscribe,
+ cancelRequest: httpLib.cancelRequest,
+ };
+}
+
+export const BankApiProviderTesting = ({
+ children,
+ value,
+}: {
+ value: BankContextType;
+ children: ComponentChildren;
+}): VNode => {
+ return h(BankContext.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/web-util/src/context/index.ts b/packages/web-util/src/context/index.ts
new file mode 100644
index 000000000..0e28b844a
--- /dev/null
+++ b/packages/web-util/src/context/index.ts
@@ -0,0 +1,10 @@
+export { ApiContextProvider, useApiContext } from "./api.js";
+export {
+ InternationalizationAPI,
+ TranslationProvider,
+ useTranslationContext
+} from "./translation.js";
+export * from "./bank-api.js";
+export * from "./merchant-api.js";
+export * from "./navigation.js";
+export * from "./wallet-integration.js";
diff --git a/packages/web-util/src/context/merchant-api.ts b/packages/web-util/src/context/merchant-api.ts
new file mode 100644
index 000000000..03c95d48e
--- /dev/null
+++ b/packages/web-util/src/context/merchant-api.ts
@@ -0,0 +1,228 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ CacheEvictor,
+ LibtoolVersion,
+ ObservabilityEvent,
+ ObservableHttpClientLibrary,
+ TalerAuthenticationHttpClient,
+ TalerError,
+ TalerMerchantApi,
+ TalerMerchantInstanceCacheEviction,
+ TalerMerchantManagementCacheEviction,
+ TalerMerchantManagementHttpClient,
+} from "@gnu-taler/taler-util";
+import {
+ ComponentChildren,
+ FunctionComponent,
+ VNode,
+ createContext,
+ h,
+} from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { BrowserFetchHttpLib } from "../index.browser.js";
+import {
+ APIClient,
+ ActiviyTracker,
+ MerchantLib,
+ Subscriber,
+} from "./activity.js";
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type MerchantContextType = {
+ url: URL;
+ config: TalerMerchantApi.VersionResponse;
+ lib: MerchantLib;
+ hints: VersionHint[];
+ onActivity: Subscriber<ObservabilityEvent>;
+ cancelRequest: (eventId: string) => void;
+ changeBackend: (url: URL) => void;
+};
+
+// FIXME: below
+// @ts-expect-error default value to undefined, should it be another thing?
+const MerchantContext = createContext<MerchantContextType>(undefined);
+
+export const useMerchantApiContext = (): MerchantContextType =>
+ useContext(MerchantContext);
+
+enum VersionHint {
+ NONE,
+}
+
+type Evictors = {
+ management?: CacheEvictor<
+ TalerMerchantManagementCacheEviction | TalerMerchantInstanceCacheEviction
+ >;
+};
+
+type ConfigResult<T> =
+ | undefined
+ | { type: "ok"; config: T; hints: VersionHint[] }
+ | ConfigResultFail<T>;
+
+export type ConfigResultFail<T> =
+ | { type: "incompatible"; result: T; supported: string }
+ | { type: "error"; error: TalerError };
+
+const CONFIG_FAIL_TRY_AGAIN_MS = 5000;
+
+export const MerchantApiProvider = ({
+ baseUrl,
+ children,
+ evictors = {},
+ frameOnError,
+}: {
+ baseUrl: URL;
+ evictors?: Evictors;
+ children: ComponentChildren;
+ frameOnError: FunctionComponent<{
+ state: ConfigResultFail<TalerMerchantApi.VersionResponse> | undefined;
+ }>;
+}): VNode => {
+ const [checked, setChecked] =
+ useState<ConfigResult<TalerMerchantApi.VersionResponse>>();
+
+ const [merchantEndpoint, changeMerchantEndpoint] = useState(baseUrl);
+
+ const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } =
+ buildMerchantApiClient(merchantEndpoint, evictors);
+
+ useEffect(() => {
+ let keepRetrying = true;
+ async function testConfig(): Promise<void> {
+ try {
+ const config = await getRemoteConfig();
+ if (LibtoolVersion.compare(VERSION, config.version)) {
+ setChecked({ type: "ok", config, hints: [] });
+ } else {
+ setChecked({
+ type: "incompatible",
+ result: config,
+ supported: VERSION,
+ });
+ }
+ } catch (error) {
+ if (error instanceof TalerError) {
+ if (keepRetrying) {
+ setTimeout(() => {
+ testConfig();
+ }, CONFIG_FAIL_TRY_AGAIN_MS);
+ }
+ setChecked({ type: "error", error });
+ } else {
+ setChecked({ type: "error", error: TalerError.fromException(error) });
+ }
+ }
+ }
+ testConfig();
+ return () => {
+ // on unload, stop retry
+ keepRetrying = false;
+ };
+ }, []);
+
+ if (!checked || checked.type !== "ok") {
+ return h(frameOnError, { state: checked }, []);
+ }
+
+ const value: MerchantContextType = {
+ url: merchantEndpoint,
+ config: checked.config,
+ onActivity: onActivity,
+ lib,
+ cancelRequest,
+ changeBackend: changeMerchantEndpoint,
+ hints: checked.hints,
+ };
+ return h(MerchantContext.Provider, {
+ value,
+ children,
+ });
+};
+
+function buildMerchantApiClient(
+ url: URL,
+ evictors: Evictors,
+): APIClient<MerchantLib, TalerMerchantApi.VersionResponse> {
+ const httpFetch = new BrowserFetchHttpLib({
+ enableThrottling: true,
+ requireTls: false,
+ });
+ const tracker = new ActiviyTracker<ObservabilityEvent>();
+
+ const httpLib = new ObservableHttpClientLibrary(httpFetch, {
+ observe(ev) {
+ tracker.notify(ev);
+ },
+ });
+
+ const instance = new TalerMerchantManagementHttpClient(
+ url.href,
+ httpLib,
+ evictors.management,
+ );
+ const authenticate = new TalerAuthenticationHttpClient(
+ instance.getAuthenticationAPI().href,
+ httpLib,
+ );
+
+ function getSubInstanceAPI(instanceId: string): MerchantLib {
+ const api = buildMerchantApiClient(
+ instance.getSubInstanceAPI(instanceId) as URL,
+ evictors,
+ );
+ return api.lib;
+ }
+
+ async function getRemoteConfig(): Promise<TalerMerchantApi.VersionResponse> {
+ const resp = await instance.getConfig();
+ if (resp.type === "fail") {
+ throw TalerError.fromUncheckedDetail(resp.detail);
+ }
+ return resp.body;
+ }
+
+ return {
+ getRemoteConfig,
+ VERSION: instance.PROTOCOL_VERSION,
+ lib: {
+ instance,
+ authenticate,
+ subInstanceApi: getSubInstanceAPI,
+ },
+ onActivity: tracker.subscribe,
+ cancelRequest: httpLib.cancelRequest,
+ };
+}
+
+export const MerchantApiProviderTesting = ({
+ children,
+ value,
+}: {
+ value: MerchantContextType;
+ children: ComponentChildren;
+}): VNode => {
+ return h(MerchantContext.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/web-util/src/context/navigation.ts b/packages/web-util/src/context/navigation.ts
new file mode 100644
index 000000000..a2fe3ff12
--- /dev/null
+++ b/packages/web-util/src/context/navigation.ts
@@ -0,0 +1,102 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext, useEffect, useState } from "preact/hooks";
+import { AppLocation, ObjectOf, Location, findMatch, RouteDefinition } from "../utils/route.js";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>(
+ pagesMap: T,
+): Location<T> | undefined {
+ const pageList = Object.keys(pagesMap as object) as Array<keyof T>;
+ const { path, params } = useNavigationContext();
+
+ return findMatch(pagesMap, pageList, path, params);
+}
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+export type Type = {
+ path: string;
+ params: Record<string, string>;
+ navigateTo: (path: AppLocation) => void;
+ // addNavigationListener: (listener: (path: string, params: Record<string, string>) => void) => (() => void);
+};
+
+// @ts-expect-error should not be used without provider
+const Context = createContext<Type>(undefined);
+
+export const useNavigationContext = (): Type => useContext(Context);
+
+function getPathAndParamsFromWindow() {
+ const path =
+ typeof window !== "undefined" ? window.location.hash.substring(1) : "/";
+ const params: Record<string, string> = {};
+ if (typeof window !== "undefined") {
+ for (const [key, value] of new URLSearchParams(window.location.search)) {
+ params[key] = value;
+ }
+ }
+ return { path, params };
+}
+
+const { path: initialPath, params: initialParams } =
+ getPathAndParamsFromWindow();
+
+// there is a possibility that if the browser does a redirection
+// (which doesn't go through navigatTo function) and that executed
+// too early (before addEventListener runs) it won't be taking
+// into account
+const PopStateEventType = "popstate";
+
+export const BrowserHashNavigationProvider = ({
+ children,
+}: {
+ children: ComponentChildren;
+}): VNode => {
+ const [{ path, params }, setState] = useState({
+ path: initialPath,
+ params: initialParams,
+ });
+ if (typeof window === "undefined") {
+ throw Error(
+ "Can't use BrowserHashNavigationProvider if there is no window object",
+ );
+ }
+ function navigateTo(path: string) {
+ const { params } = getPathAndParamsFromWindow();
+ setState({ path, params });
+ window.location.href = path;
+ }
+
+ useEffect(() => {
+ function eventListener() {
+ setState(getPathAndParamsFromWindow());
+ }
+ window.addEventListener(PopStateEventType, eventListener);
+ return () => {
+ window.removeEventListener(PopStateEventType, eventListener);
+ };
+ }, []);
+ return h(Context.Provider, {
+ value: { path, params, navigateTo },
+ children,
+ });
+};
diff --git a/packages/web-util/src/context/translation.ts b/packages/web-util/src/context/translation.ts
new file mode 100644
index 000000000..2725dc7e1
--- /dev/null
+++ b/packages/web-util/src/context/translation.ts
@@ -0,0 +1,119 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { i18n, setupI18n } from "@gnu-taler/taler-util";
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext, useEffect } from "preact/hooks";
+import { useLang } from "../hooks/index.js";
+import { Locale } from "date-fns";
+import {
+ es as esLocale,
+ enGB as enLocale,
+ fr as frLocale,
+ de as deLocale
+} from "date-fns/locale"
+
+export type InternationalizationAPI = typeof i18n;
+
+interface Type {
+ lang: string;
+ supportedLang: { [id in keyof typeof supportedLang]: string };
+ changeLanguage: (l: string) => void;
+ i18n: InternationalizationAPI;
+ dateLocale: Locale,
+ completeness: { [id in keyof typeof supportedLang]: number }
+}
+
+const supportedLang = {
+ es: "Espanol [es]",
+ en: "English [en]",
+ fr: "Francais [fr]",
+ de: "Deutsch [de]",
+ sv: "Svenska [sv]",
+ it: "Italiane [it]",
+};
+
+const initial: Type = {
+ lang: "en",
+ supportedLang,
+ changeLanguage: () => {
+ // do not change anything
+ },
+ i18n,
+ dateLocale: enLocale,
+ completeness: {
+ de: 0,
+ en: 0,
+ es: 0,
+ fr: 0,
+ it: 0,
+ sv: 0,
+ }
+};
+const Context = createContext<Type>(initial);
+
+interface Props {
+ initial?: string;
+ children: ComponentChildren;
+ forceLang?: string;
+ source: Record<string, any>;
+ completeness?: Record<string, number>;
+}
+
+// Outmost UI wrapper.
+export const TranslationProvider = ({
+ initial,
+ children,
+ forceLang,
+ source,
+ completeness: completenessProp
+}: Props): VNode => {
+ const completeness = {
+ en: 100,
+ de: !completenessProp || !completenessProp["de"] ? 0 : completenessProp["de"],
+ es: !completenessProp || !completenessProp["es"] ? 0 : completenessProp["es"],
+ fr: !completenessProp || !completenessProp["fr"] ? 0 : completenessProp["fr"],
+ it: !completenessProp || !completenessProp["it"] ? 0 : completenessProp["it"],
+ sv: !completenessProp || !completenessProp["sv"] ? 0 : completenessProp["sv"],
+ }
+ const { value: lang, update: changeLanguage } = useLang(initial, completeness);
+
+ useEffect(() => {
+ if (forceLang) {
+ changeLanguage(forceLang);
+ }
+ });
+ useEffect(() => {
+ setupI18n(lang, source);
+ }, [lang]);
+ if (forceLang) {
+ setupI18n(forceLang, source);
+ } else {
+ setupI18n(lang, source);
+ }
+
+ const dateLocale = lang === "es" ? esLocale :
+ lang === "fr" ? frLocale :
+ lang === "de" ? deLocale :
+ enLocale;
+
+ return h(Context.Provider, {
+ value: { lang, changeLanguage, supportedLang, i18n, dateLocale, completeness },
+ children,
+ });
+};
+
+export const useTranslationContext = (): Type => useContext(Context);
diff --git a/packages/web-util/src/context/wallet-integration.ts b/packages/web-util/src/context/wallet-integration.ts
new file mode 100644
index 000000000..e14988ed1
--- /dev/null
+++ b/packages/web-util/src/context/wallet-integration.ts
@@ -0,0 +1,83 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { stringifyTalerUri, TalerUri } from "@gnu-taler/taler-util";
+import { ComponentChildren, createContext, h, VNode } from "preact";
+import { useContext } from "preact/hooks";
+
+/**
+ * https://docs.taler.net/design-documents/039-taler-browser-integration.html
+ *
+ * @param uri
+ */
+function createHeadMetaTag(uri: TalerUri, onNotFound?: () => void) {
+ const meta = document.createElement("meta");
+ meta.setAttribute("name", "taler-uri");
+ meta.setAttribute("content", stringifyTalerUri(uri));
+
+ document.head.appendChild(meta);
+
+ let walletFound = false;
+ window.addEventListener("beforeunload", () => {
+ walletFound = true;
+ });
+ setTimeout(() => {
+ if (!walletFound && onNotFound) {
+ onNotFound();
+ }
+ }, 10); //very short timeout
+}
+interface Type {
+ /**
+ * Tell the active wallet that an action is found
+ *
+ * @param uri
+ * @returns
+ */
+ publishTalerAction: (uri: TalerUri, onNotFound?: () => void) => void;
+}
+
+// @ts-expect-error default value to undefined, should it be another thing?
+const Context = createContext<Type>(undefined);
+
+export const useTalerWalletIntegrationAPI = (): Type => useContext(Context);
+
+export const TalerWalletIntegrationBrowserProvider = ({
+ children,
+}: {
+ children: ComponentChildren;
+}): VNode => {
+ const value: Type = {
+ publishTalerAction: createHeadMetaTag,
+ };
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
+
+export const TalerWalletIntegrationTestingProvider = ({
+ children,
+ value,
+}: {
+ children: ComponentChildren;
+ value: Type;
+}): VNode => {
+ return h(Context.Provider, {
+ value,
+ children,
+ });
+};
diff --git a/packages/web-util/src/custom.d.ts b/packages/web-util/src/custom.d.ts
new file mode 100644
index 000000000..6049ac6a9
--- /dev/null
+++ b/packages/web-util/src/custom.d.ts
@@ -0,0 +1,12 @@
+declare module "*.crt" {
+ const content: string;
+ export default content;
+}
+declare module "*.key" {
+ const content: string;
+ export default content;
+}
+declare module "*.html" {
+ const content: string;
+ export default content;
+}
diff --git a/packages/web-util/src/declaration.d.ts b/packages/web-util/src/declaration.d.ts
new file mode 100644
index 000000000..c8ba3d576
--- /dev/null
+++ b/packages/web-util/src/declaration.d.ts
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+declare module "*.css" {
+ const mapping: Record<string, string>;
+ export default mapping;
+}
+declare module "*.svg" {
+ const content: any;
+ export default content;
+}
+declare module "*.jpeg" {
+ const content: any;
+ export default content;
+}
+declare module "*.png" {
+ const content: any;
+ export default content;
+}
+
+declare const __VERSION__: string;
+declare const __GIT_HASH__: string;
diff --git a/packages/web-util/src/forms/Calendar.tsx b/packages/web-util/src/forms/Calendar.tsx
new file mode 100644
index 000000000..a0df639f3
--- /dev/null
+++ b/packages/web-util/src/forms/Calendar.tsx
@@ -0,0 +1,119 @@
+import { AbsoluteTime } from "@gnu-taler/taler-util"
+import { add as dateAdd, sub as dateSub, eachDayOfInterval, endOfMonth, endOfWeek, format, getMonth, getYear, isSameDay, isSameMonth, startOfDay, startOfMonth, startOfWeek } from "date-fns"
+import { VNode, h } from "preact"
+import { useState } from "preact/hooks"
+import { useTranslationContext } from "../index.browser.js"
+
+export function Calendar({ value, onChange }: { value: AbsoluteTime | undefined, onChange: (v: AbsoluteTime) => void }): VNode {
+ const today = startOfDay(new Date())
+ const selected = !value ? today : new Date(AbsoluteTime.toStampMs(value))
+ const [showingDate, setShowingDate] = useState(selected)
+ const month = getMonth(showingDate)
+ const year = getYear(showingDate)
+
+ const start = startOfWeek(startOfMonth(showingDate));
+ const end = endOfWeek(endOfMonth(showingDate));
+ const daysInMonth = eachDayOfInterval({ start, end });
+ const { i18n } = useTranslationContext()
+ const monthNames = [
+ i18n.str`January`,
+ i18n.str`February`,
+ i18n.str`March`,
+ i18n.str`April`,
+ i18n.str`May`,
+ i18n.str`June`,
+ i18n.str`July`,
+ i18n.str`August`,
+ i18n.str`September`,
+ i18n.str`October`,
+ i18n.str`November`,
+ i18n.str`December`,
+ ]
+ return <div class="text-center p-2">
+ <div class="flex items-center text-gray-900">
+ <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm"
+ onClick={() => {
+ setShowingDate(dateSub(showingDate, { years: 1 }))
+ }}>
+ <span class="sr-only">
+ {i18n.str`Previous year`}
+ </span>
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+ <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
+ </svg>
+ </button>
+ <div class="flex-auto text-sm font-semibold">{year}</div>
+ <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm"
+ onClick={() => {
+ setShowingDate(dateAdd(showingDate, { years: 1 }))
+ }}>
+ <span class="sr-only">
+ {i18n.str`Next year`}
+ </span>
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+ <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
+ </svg>
+ </button>
+ </div>
+ <div class="mt-4 flex items-center text-gray-900">
+ <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm"
+ onClick={() => {
+ setShowingDate(dateSub(showingDate, { months: 1 }))
+ }}>
+ <span class="sr-only">
+ {i18n.str`Previous month`}
+ </span>
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+ <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
+ </svg>
+ </button>
+ <div class="flex-auto text-sm font-semibold">{monthNames[month]}</div>
+ <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 rounded-sm "
+ onClick={() => {
+ setShowingDate(dateAdd(showingDate, { months: 1 }))
+ }}>
+ <span class="sr-only">
+ {i18n.str`Next month`}
+ </span>
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+ <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
+ </svg>
+ </button>
+ </div>
+ <div class="mt-6 grid grid-cols-7 text-xs leading-6 text-gray-500">
+ <div>M</div>
+ <div>T</div>
+ <div>W</div>
+ <div>T</div>
+ <div>F</div>
+ <div>S</div>
+ <div>S</div>
+ </div>
+ <div class="isolate mt-2">
+ <div class="grid grid-cols-7 gap-px rounded-lg bg-gray-200 text-sm shadow ring-1 ring-gray-200">
+ {daysInMonth.map(current => (
+ <button type="button"
+ data-month={isSameMonth(current, showingDate)}
+ data-today={isSameDay(current, today)}
+ data-selected={isSameDay(current, selected)}
+ onClick={() => {
+ onChange(AbsoluteTime.fromStampMs(current.getTime()))
+ }}
+ class="text-gray-400 hover:bg-gray-700 focus:z-10 py-1.5
+ data-[month=false]:bg-gray-100 data-[month=true]:bg-white
+ data-[today=true]:font-semibold
+ data-[month=true]:text-gray-900
+ data-[today=true]:bg-red-300 data-[today=true]:hover:bg-red-200
+ data-[month=true]:hover:bg-gray-200
+ data-[selected=true]:!bg-blue-400 data-[selected=true]:hover:!bg-blue-300 ">
+ <time dateTime={format(current, "yyyy-MM-dd")}
+ class="mx-auto flex h-7 w-7 py-4 px-5 sm:px-8 items-center justify-center rounded-full">
+ {format(current, "dd")}
+ </time>
+ </button>
+ ))}
+ </div>
+ {daysInMonth.length < 40 ? <div class="w-7 h-7 m-1.5" /> : undefined}
+ </div>
+ </div>
+}
diff --git a/packages/web-util/src/forms/Caption.tsx b/packages/web-util/src/forms/Caption.tsx
new file mode 100644
index 000000000..8facddec3
--- /dev/null
+++ b/packages/web-util/src/forms/Caption.tsx
@@ -0,0 +1,32 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { VNode, h } from "preact";
+import {
+ LabelWithTooltipMaybeRequired
+} from "./InputLine.js";
+
+interface Props {
+ label: TranslatedString;
+ tooltip?: TranslatedString;
+ help?: TranslatedString;
+ before?: VNode;
+ after?: VNode;
+}
+
+export function Caption({ before, after, label, tooltip, help }: Props): VNode {
+ return (
+ <div class="sm:col-span-6 flex">
+ {before !== undefined && (
+ <span class="pointer-events-none flex items-center pr-2">{before}</span>
+ )}
+ <LabelWithTooltipMaybeRequired label={label} tooltip={tooltip} />
+ {after !== undefined && (
+ <span class="pointer-events-none flex items-center pl-2">{after}</span>
+ )}
+ {help && (
+ <p class="mt-2 text-sm text-gray-500" id="email-description">
+ {help}
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/DefaultForm.tsx b/packages/web-util/src/forms/DefaultForm.tsx
new file mode 100644
index 000000000..1155401f5
--- /dev/null
+++ b/packages/web-util/src/forms/DefaultForm.tsx
@@ -0,0 +1,83 @@
+import { Fragment, h } from "preact";
+import { FormProvider, FormProviderProps, FormState } from "./FormProvider.js";
+import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js";
+import { TranslatedString } from "@gnu-taler/taler-util";
+
+/**
+ * Flexible form uses a DoubleColumForm for design
+ * and may have a dynamic properties defined by
+ * behavior function.
+ */
+export interface FlexibleForm<T extends object> {
+ design: DoubleColumnForm;
+ behavior?: (form: Partial<T>) => FormState<T>;
+}
+
+/**
+ * Double column form
+ *
+ * Form with sections, every sections have a title and may
+ * have a description.
+ * Every sections contain a set of fields.
+ */
+export type DoubleColumnForm = Array<DoubleColumnFormSection | undefined>;
+
+export type DoubleColumnFormSection = {
+ title: TranslatedString;
+ description?: TranslatedString;
+ fields: UIFormField[];
+};
+
+/**
+ * Form Provider implementation that use FlexibleForm
+ * to defined behavior and fields.
+ */
+export function DefaultForm<T extends object>({
+ initial,
+ onUpdate,
+ form,
+ onSubmit,
+ children,
+ readOnly,
+}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm<T> }) {
+ return (
+ <FormProvider
+ initial={initial}
+ onUpdate={onUpdate}
+ onSubmit={onSubmit}
+ readOnly={readOnly}
+ computeFormState={form.behavior}
+ >
+ <div class="space-y-10 divide-y -mt-5 divide-gray-900/10">
+ {form.design.map((section, i) => {
+ if (!section) return <Fragment />;
+ return (
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ {section.title}
+ </h2>
+ {section.description && (
+ <p class="mt-1 text-sm leading-6 text-gray-600">
+ {section.description}
+ </p>
+ )}
+ </div>
+ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2">
+ <div class="p-3">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <RenderAllFieldsByUiConfig
+ key={i}
+ fields={section.fields}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ {children}
+ </FormProvider>
+ );
+}
diff --git a/packages/web-util/src/forms/Dialog.tsx b/packages/web-util/src/forms/Dialog.tsx
new file mode 100644
index 000000000..7b41fe487
--- /dev/null
+++ b/packages/web-util/src/forms/Dialog.tsx
@@ -0,0 +1,15 @@
+import { ComponentChildren, VNode, h } from "preact";
+
+export function Dialog({ children, onClose }: { onClose?: () => void; children: ComponentChildren }): VNode {
+ return <div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true" onClick={onClose}>
+ <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
+
+ <div class="fixed inset-0 z-10 w-screen overflow-y-auto">
+ <div class="flex min-h-full items-center justify-center p-4 text-center ">
+ <div class="relative transform overflow-hidden rounded-lg bg-white p-1 text-left shadow-xl transition-all" onClick={(e) => e.stopPropagation()}>
+ {children}
+ </div>
+ </div>
+ </div>
+ </div>
+}
diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx
new file mode 100644
index 000000000..f4616525b
--- /dev/null
+++ b/packages/web-util/src/forms/FormProvider.tsx
@@ -0,0 +1,148 @@
+import {
+ AbsoluteTime,
+ AmountJson,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { ComponentChildren, VNode, createContext, h } from "preact";
+import {
+ MutableRef,
+ useState
+} from "preact/hooks";
+
+export interface FormType<T extends object> {
+ value: MutableRef<Partial<T>>;
+ initial?: Partial<T>;
+ readOnly?: boolean;
+ onUpdate?: (v: Partial<T>) => void;
+ computeFormState?: (v: Partial<T>) => FormState<T>;
+}
+
+//@ts-ignore
+export const FormContext = createContext<FormType<any>>({});
+
+/**
+ * Map of {[field]:FieldUIOptions}
+ * for every field of type
+ * - any native (string, number, etc...)
+ * - absoluteTime
+ * - amountJson
+ *
+ * except for:
+ * - object => recurse into
+ * - array => behavior result and element field
+ */
+export type FormState<T extends object | undefined> = {
+ [field in keyof T]?: T[field] extends AbsoluteTime
+ ? FieldUIOptions
+ : T[field] extends AmountJson
+ ? FieldUIOptions
+ : T[field] extends Array<infer P extends object>
+ ? InputArrayFieldState<P>
+ : T[field] extends (object | undefined)
+ ? FormState<T[field]>
+ : FieldUIOptions;
+};
+
+/**
+ * Properties that can be defined by design or by computing state
+ */
+export type FieldUIOptions = {
+ /* text to be shown next to the field */
+ error?: TranslatedString;
+ /* instruction to be shown in the field */
+ placeholder?: TranslatedString;
+ /* long text help to be shown on demand */
+ tooltip?: TranslatedString;
+ /* short text to be shown next to the field*/
+
+ help?: TranslatedString;
+ /* should show as disabled and readonly */
+ disabled?: boolean;
+ /* should not show */
+ hidden?: boolean;
+
+ /* show a mark as required*/
+ required?: boolean;
+}
+
+/**
+ * properties only to be defined on design time
+ */
+export interface UIFormProps<T extends object, K extends keyof T> extends FieldUIOptions {
+
+ // property name of the object
+ name: K;
+
+ // label if the field
+ label: TranslatedString;
+ before?: Addon;
+ after?: Addon;
+
+ // converter to string and back
+ converter?: StringConverter<T[K]>;
+}
+
+export interface IconAddon {
+ type: "icon";
+ icon: VNode;
+}
+export interface ButtonAddon {
+ type: "button";
+ onClick: () => void;
+ children: ComponentChildren;
+}
+export interface TextAddon {
+ type: "text";
+ text: TranslatedString;
+}
+export type Addon = IconAddon | ButtonAddon | TextAddon;
+
+export interface StringConverter<T> {
+ toStringUI: (v?: T) => string;
+ fromStringUI: (v?: string) => T;
+}
+
+export interface InputArrayFieldState<P extends object> extends FieldUIOptions {
+ elements?: FormState<P>[];
+}
+
+export type FormProviderProps<T extends object> = Omit<FormType<T>, "value"> & {
+ onSubmit?: (v: Partial<T>, s: FormState<T> | undefined) => void;
+ children?: ComponentChildren;
+}
+
+export function FormProvider<T extends object>({
+ children,
+ initial,
+ onUpdate: notify,
+ onSubmit,
+ computeFormState,
+ readOnly,
+}: FormProviderProps<T>): VNode {
+
+ const [state, setState] = useState<Partial<T>>(initial ?? {});
+ const value = { current: state };
+ const onUpdate = (v: typeof state) => {
+ setState(v);
+ if (notify) notify(v);
+ };
+ return (
+ <FormContext.Provider
+ value={{ initial, value, onUpdate, computeFormState, readOnly }}
+ >
+ <form
+ onSubmit={(e) => {
+ e.preventDefault();
+ //@ts-ignore
+ if (onSubmit)
+ onSubmit(
+ value.current,
+ !computeFormState ? undefined : computeFormState(value.current),
+ );
+ }}
+ >
+ {children}
+ </form>
+ </FormContext.Provider>
+ );
+}
diff --git a/packages/web-util/src/forms/Group.tsx b/packages/web-util/src/forms/Group.tsx
new file mode 100644
index 000000000..0645f6d97
--- /dev/null
+++ b/packages/web-util/src/forms/Group.tsx
@@ -0,0 +1,41 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { VNode, h } from "preact";
+import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
+import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js";
+
+interface Props {
+ before?: TranslatedString;
+ after?: TranslatedString;
+ tooltipBefore?: TranslatedString;
+ tooltipAfter?: TranslatedString;
+ fields: UIFormField[];
+}
+
+export function Group({
+ before,
+ after,
+ tooltipAfter,
+ tooltipBefore,
+ fields,
+}: Props): VNode {
+ return (
+ <div class="sm:col-span-6 p-4 rounded-lg border-r-2 border-2 bg-gray-50">
+ <div class="pb-4">
+ {before && (
+ <LabelWithTooltipMaybeRequired
+ label={before}
+ tooltip={tooltipBefore}
+ />
+ )}
+ </div>
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-6">
+ <RenderAllFieldsByUiConfig fields={fields} />
+ </div>
+ <div class="pt-4">
+ {after && (
+ <LabelWithTooltipMaybeRequired label={after} tooltip={tooltipAfter} />
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx
new file mode 100644
index 000000000..6245cf27c
--- /dev/null
+++ b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx
@@ -0,0 +1,60 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Absolute Time",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ today: AbsoluteTime;
+}
+const initial: TargetObject = {
+ today: AbsoluteTime.now()
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "absoluteTime",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "today",
+ pattern: "dd/MM/yyyy HH:mm"
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputAbsoluteTime.tsx b/packages/web-util/src/forms/InputAbsoluteTime.tsx
new file mode 100644
index 000000000..ee18e5592
--- /dev/null
+++ b/packages/web-util/src/forms/InputAbsoluteTime.tsx
@@ -0,0 +1,77 @@
+import { AbsoluteTime } from "@gnu-taler/taler-util";
+import { InputLine } from "./InputLine.js";
+import { Fragment, VNode, h } from "preact";
+import { format, parse } from "date-fns";
+import { Dialog } from "./Dialog.js";
+import { Calendar } from "./Calendar.js";
+import { useState } from "preact/hooks";
+import { useField } from "./useField.js";
+import { UIFormProps } from "./FormProvider.js";
+import { TimePicker } from "./TimePicker.js";
+
+export function InputAbsoluteTime<T extends object, K extends keyof T>(
+ props: { pattern?: string } & UIFormProps<T, K>,
+): VNode {
+ const pattern = props.pattern ?? "dd/MM/yyyy";
+ const [open, setOpen] = useState(false)
+ const { value, onChange } = useField<T, K>(props.name);
+ return (
+ <Fragment>
+
+ <InputLine<T, K>
+ type="text"
+ after={{
+ type: "button",
+ onClick: () => {
+ setOpen(true)
+ },
+ // icon: <CalendarIcon class="h-6 w-6" />,
+ children: (
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
+ </svg>)
+ }}
+ converter={{
+ //@ts-ignore
+ fromStringUI: (v): AbsoluteTime | undefined => {
+ if (!v) return undefined;
+ try {
+ const t_ms = parse(v, pattern, Date.now()).getTime();
+ return AbsoluteTime.fromMilliseconds(t_ms);
+ } catch (e) {
+ return undefined;
+ }
+ },
+ //@ts-ignore
+ toStringUI: (v: AbsoluteTime | undefined) => {
+ return !v || !v.t_ms
+ ? undefined
+ : v.t_ms === "never"
+ ? "never"
+ : format(v.t_ms, pattern);
+ },
+ }}
+ {...props}
+ />
+ {open &&
+ <Dialog onClose={() => setOpen(false)}>
+ <Calendar value={value as AbsoluteTime ?? AbsoluteTime.now()}
+ onChange={(v) => {
+ onChange(v as any)
+ setOpen(false)
+ }} />
+ </Dialog>
+ }
+ {/* {open &&
+ <Dialog onClose={() => setOpen(false)} >
+ <TimePicker value={value as AbsoluteTime ?? AbsoluteTime.now()}
+ onChange={(v) => {
+ onChange(v as any)
+ }}
+ onConfirm={() => {
+ setOpen(false)
+ }} />
+ </Dialog>} */}
+ </Fragment>
+ );
+}
diff --git a/packages/web-util/src/forms/InputAmount.stories.tsx b/packages/web-util/src/forms/InputAmount.stories.tsx
new file mode 100644
index 000000000..c9f12a437
--- /dev/null
+++ b/packages/web-util/src/forms/InputAmount.stories.tsx
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Amount",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ amount: AmountJson;
+}
+const initial: TargetObject = {
+ amount: Amounts.parseOrThrow("USD:10")
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "amount",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "amount",
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputAmount.tsx b/packages/web-util/src/forms/InputAmount.tsx
new file mode 100644
index 000000000..7a8c08f76
--- /dev/null
+++ b/packages/web-util/src/forms/InputAmount.tsx
@@ -0,0 +1,36 @@
+import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util";
+import { VNode, h } from "preact";
+import { UIFormProps } from "./FormProvider.js";
+import { InputLine } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputAmount<T extends object, K extends keyof T>(
+ props: { currency?: string } & UIFormProps<T, K>,
+): VNode {
+ const { value } = useField<T, K>(props.name);
+ const currency =
+ !value || !(value as any).currency
+ ? props.currency
+ : (value as any).currency;
+ return (
+ <InputLine<T, K>
+ type="text"
+ before={{
+ type: "text",
+ text: currency as TranslatedString,
+ }}
+ converter={{
+ //@ts-ignore
+ fromStringUI: (v): AmountJson => {
+
+ return Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency);
+ },
+ //@ts-ignore
+ toStringUI: (v: AmountJson) => {
+ return v === undefined ? "" : Amounts.stringifyValue(v);
+ },
+ }}
+ {...props}
+ />
+ );
+}
diff --git a/packages/web-util/src/forms/InputArray.stories.tsx b/packages/web-util/src/forms/InputArray.stories.tsx
new file mode 100644
index 000000000..8dbd3ff07
--- /dev/null
+++ b/packages/web-util/src/forms/InputArray.stories.tsx
@@ -0,0 +1,79 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Array",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ people: {
+ name: string;
+ age: number;
+ }[];
+}
+const initial: TargetObject = {
+ people: [{
+ name: "me",
+ age: 17,
+ }]
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "array",
+ props: {
+ label: "People" as TranslatedString,
+ name: "comment",
+ fields: [{
+ type: "text",
+ props: {
+ label: "the name" as TranslatedString,
+ name: "name",
+ }
+ }, {
+ type: "integer",
+ props: {
+ label: "the age" as TranslatedString,
+ name: "age",
+ }
+ }],
+ labelField: "name"
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx
new file mode 100644
index 000000000..7d9a1b378
--- /dev/null
+++ b/packages/web-util/src/forms/InputArray.tsx
@@ -0,0 +1,186 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { FormProvider, UIFormProps } from "./FormProvider.js";
+import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
+import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js";
+import { useField } from "./useField.js";
+
+function Option({
+ label,
+ disabled,
+ isFirst,
+ isLast,
+ isSelected,
+ onClick,
+}: {
+ label: TranslatedString;
+ isFirst?: boolean;
+ isLast?: boolean;
+ isSelected?: boolean;
+ disabled?: boolean;
+ onClick: () => void;
+}): VNode {
+ let clazz = "relative flex border p-4 focus:outline-none disabled:text-grey";
+ if (isFirst) {
+ clazz += " rounded-tl-md rounded-tr-md ";
+ }
+ if (isLast) {
+ clazz += " rounded-bl-md rounded-br-md ";
+ }
+ if (isSelected) {
+ clazz += " z-10 border-indigo-200 bg-indigo-50 ";
+ } else {
+ clazz += " border-gray-200";
+ }
+ if (disabled) {
+ clazz +=
+ " cursor-not-allowed bg-gray-50 text-gray-500 ring-gray-200 text-gray";
+ } else {
+ clazz += " cursor-pointer";
+ }
+ return (
+ <label class={clazz}>
+ <input
+ type="radio"
+ name="privacy-setting"
+ checked={isSelected}
+ disabled={disabled}
+ onClick={onClick}
+ class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200 focus:ring-indigo-600"
+ aria-labelledby="privacy-setting-0-label"
+ aria-describedby="privacy-setting-0-description"
+ />
+ <span class="ml-3 flex flex-col">
+ <span
+ id="privacy-setting-0-label"
+ disabled
+ class="block text-sm font-medium"
+ >
+ {label}
+ </span>
+ {/* <!-- Checked: "text-indigo-700", Not Checked: "text-gray-500" --> */}
+ {/* <span
+ id="privacy-setting-0-description"
+ class="block text-sm"
+ >
+ This project would be available to anyone who has the link
+ </span> */}
+ </span>
+ </label>
+ );
+}
+
+export function InputArray<T extends object, K extends keyof T>(
+ props: {
+ fields: UIFormField[];
+ labelField: string;
+ } & UIFormProps<T, K>,
+): VNode {
+ const { fields, labelField, name, label, required, tooltip } = props;
+ const { value, onChange, state } = useField<T, K>(name);
+ const list = (value ?? []) as Array<Record<string, string | undefined>>;
+ const [selectedIndex, setSelected] = useState<number | undefined>(undefined);
+ const selected =
+ selectedIndex === undefined ? undefined : list[selectedIndex];
+
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+
+ <div class="-space-y-px rounded-md bg-white ">
+ {list.map((v, idx) => {
+ return (
+ <Option
+ label={v[labelField] as TranslatedString}
+ isSelected={selectedIndex === idx}
+ isLast={idx === list.length - 1}
+ disabled={selectedIndex !== undefined && selectedIndex !== idx}
+ isFirst={idx === 0}
+ onClick={() => {
+ setSelected(selectedIndex === idx ? undefined : idx);
+ }}
+ />
+ );
+ })}
+ {!state.disabled &&
+ <div class="pt-2">
+ <Option
+ label={"Add..." as TranslatedString}
+ isSelected={selectedIndex === list.length}
+ isLast
+ isFirst
+ disabled={
+ selectedIndex !== undefined && selectedIndex !== list.length
+ }
+ onClick={() => {
+ setSelected(
+ selectedIndex === list.length ? undefined : list.length,
+ );
+ }}
+ />
+ </div>
+ }
+ </div>
+ {selectedIndex !== undefined && (
+ /**
+ * This form provider act as a substate of the parent form
+ * Consider creating an InnerFormProvider since not every feature is expected
+ */
+ <FormProvider
+ initial={selected}
+ readOnly={state.disabled}
+ computeFormState={(v) => {
+ // current state is ignored
+ // the state is defined by the parent form
+
+ // elements should be present in the state object since this is expected to be an array
+ //@ts-ignore
+ return state.elements[selectedIndex];
+ }}
+ onSubmit={(v) => {
+ const newValue = [...list];
+ newValue.splice(selectedIndex, 1, v);
+ onChange(newValue as T[K]);
+ setSelected(undefined);
+ }}
+ onUpdate={(v) => {
+ const newValue = [...list];
+ newValue.splice(selectedIndex, 1, v);
+ onChange(newValue as T[K]);
+ }}
+ >
+ <div class="px-4 py-6">
+ <div class="grid grid-cols-1 gap-y-8 ">
+ <RenderAllFieldsByUiConfig fields={fields} />
+ </div>
+ </div>
+ </FormProvider>
+ )}
+ {selectedIndex !== undefined && (
+ <div class="flex items-center pt-3">
+ <div class="flex-auto">
+ {selected !== undefined && (
+ <button
+ type="button"
+ onClick={() => {
+ const newValue = [...list];
+ newValue.splice(selectedIndex, 1);
+ onChange(newValue as T[K]);
+ setSelected(undefined);
+ }}
+ class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
+ >
+ Remove
+ </button>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx
new file mode 100644
index 000000000..b950d3d02
--- /dev/null
+++ b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx
@@ -0,0 +1,69 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Choice Horizontal",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ comment: string;
+}
+const initial: TargetObject = {
+ comment: "0"
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "choiceHorizontal",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "comment",
+ choices: [{
+ label: "first choice" as TranslatedString,
+ value: "1"
+ }, {
+ label: "second choice" as TranslatedString,
+ value: "2"
+ }, {
+ label: "third choice" as TranslatedString,
+ value: "3"
+ },],
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.tsx
new file mode 100644
index 000000000..778b73c75
--- /dev/null
+++ b/packages/web-util/src/forms/InputChoiceHorizontal.tsx
@@ -0,0 +1,87 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import { UIFormProps } from "./FormProvider.js";
+import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export interface ChoiceH<V> {
+ label: TranslatedString;
+ value: V;
+}
+
+export function InputChoiceHorizontal<T extends object, K extends keyof T>(
+ props: {
+ choices: ChoiceH<T[K]>[];
+ } & UIFormProps<T, K>,
+): VNode {
+ const {
+ choices,
+ name,
+ label,
+ tooltip,
+ help,
+ placeholder,
+ required,
+ before,
+ after,
+ converter,
+ } = props;
+ const { value, onChange, state, isDirty } = useField<T, K>(name);
+ if (state.hidden) {
+ return <Fragment />;
+ }
+
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ <fieldset class="mt-2">
+ <div class="isolate inline-flex rounded-md shadow-sm">
+ {choices.map((choice, idx) => {
+ const isFirst = idx === 0;
+ const isLast = idx === choices.length - 1;
+ let clazz =
+ "relative inline-flex items-center px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:z-10";
+ if (choice.value === value) {
+ clazz +=
+ " text-white bg-indigo-600 hover:bg-indigo-500 ring-2 ring-indigo-600 hover:ring-indigo-500";
+ } else {
+ clazz += " hover:bg-gray-100 border-gray-300";
+ }
+ if (isFirst) {
+ clazz += " rounded-l-md";
+ } else {
+ clazz += " -ml-px";
+ }
+ if (isLast) {
+ clazz += " rounded-r-md";
+ }
+ return (
+ <button
+ type="button"
+ disabled={state.disabled}
+ label={choice.label}
+ class={clazz}
+ onClick={(e) => {
+ onChange(
+ (value === choice.value ? undefined : choice.value) as T[K],
+ );
+ }}
+ >
+ {choice.label}
+ </button>
+ );
+ })}
+ </div>
+ </fieldset>
+ {help && (
+ <p class="mt-2 text-sm text-gray-500" id="email-description">
+ {help}
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx
new file mode 100644
index 000000000..ed5170d17
--- /dev/null
+++ b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx
@@ -0,0 +1,69 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Choice Stacked",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ comment: string;
+}
+const initial: TargetObject = {
+ comment: "some initial comment"
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "choiceStacked",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "comment",
+ choices: [{
+ label: "first choice" as TranslatedString,
+ value: "1"
+ }, {
+ label: "second choice" as TranslatedString,
+ value: "2"
+ }, {
+ label: "third choice" as TranslatedString,
+ value: "3"
+ },],
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputChoiceStacked.tsx b/packages/web-util/src/forms/InputChoiceStacked.tsx
new file mode 100644
index 000000000..234bb2255
--- /dev/null
+++ b/packages/web-util/src/forms/InputChoiceStacked.tsx
@@ -0,0 +1,113 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import { UIFormProps } from "./FormProvider.js";
+import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export interface ChoiceS<V> {
+ label: TranslatedString;
+ description?: TranslatedString;
+ value: V;
+}
+
+export function InputChoiceStacked<T extends object, K extends keyof T>(
+ props: {
+ choices: ChoiceS<T[K]>[];
+ } & UIFormProps<T, K>,
+): VNode {
+ const {
+ choices,
+ name,
+ label,
+ tooltip,
+ help,
+ placeholder,
+ required,
+ before,
+ after,
+ converter,
+ } = props;
+ const { value, onChange, state, isDirty } = useField<T, K>(name);
+ if (state.hidden) {
+ return <Fragment />;
+ }
+
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ <fieldset class="mt-2">
+ <div class="space-y-4">
+ {choices.map((choice) => {
+ // const currentValue = !converter
+ // ? choice.value
+ // : converter.fromStringUI(choice.value) ?? "";
+
+ let clazz =
+ "border relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex sm:justify-between";
+ if (choice.value === value) {
+ clazz +=
+ " border-transparent border-indigo-600 ring-2 ring-indigo-600";
+ } else {
+ clazz += " border-gray-300";
+ }
+
+ return (
+ <label class={clazz}>
+ <input
+ type="radio"
+ name="server-size"
+ // defaultValue={choice.value}
+ disabled={state.disabled}
+ value={
+ (!converter
+ ? (choice.value as string)
+ : converter?.toStringUI(choice.value)) ?? ""
+ }
+ onClick={(e) => {
+ onChange(
+ (value === choice.value
+ ? undefined
+ : choice.value) as T[K],
+ );
+ }}
+ class="sr-only"
+ aria-labelledby="server-size-0-label"
+ aria-describedby="server-size-0-description-0 server-size-0-description-1"
+ />
+ <span class="flex items-center">
+ <span class="flex flex-col text-sm">
+ <span
+ id="server-size-0-label"
+ class="font-medium text-gray-900"
+ >
+ {choice.label}
+ </span>
+ {choice.description !== undefined && (
+ <span
+ id="server-size-0-description-0"
+ class="text-gray-500"
+ >
+ <span class="block sm:inline">
+ {choice.description}
+ </span>
+ </span>
+ )}
+ </span>
+ </span>
+ </label>
+ );
+ })}
+ </div>
+ </fieldset>
+ {help && (
+ <p class="mt-2 text-sm text-gray-500" id="email-description">
+ {help}
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputFile.stories.tsx b/packages/web-util/src/forms/InputFile.stories.tsx
new file mode 100644
index 000000000..ba06debf9
--- /dev/null
+++ b/packages/web-util/src/forms/InputFile.stories.tsx
@@ -0,0 +1,64 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input File",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ comment: string;
+}
+const initial: TargetObject = {
+ comment: "some initial comment"
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "file",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "comment",
+ required: true,
+ maxBites: 2 * 1024 * 1024,
+ accept: ".png",
+ tooltip: "this is a very long tooltip that explain what the field does without being short" as TranslatedString,
+ help: "Max size of 2 mega bytes" as TranslatedString,
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputFile.tsx b/packages/web-util/src/forms/InputFile.tsx
new file mode 100644
index 000000000..6337d0902
--- /dev/null
+++ b/packages/web-util/src/forms/InputFile.tsx
@@ -0,0 +1,106 @@
+import { Fragment, VNode, h } from "preact";
+import { UIFormProps } from "./FormProvider.js";
+import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputFile<T extends object, K extends keyof T>(
+ props: { maxBites: number; accept?: string } & UIFormProps<T, K>,
+): VNode {
+ const {
+ name,
+ label,
+ placeholder,
+ tooltip,
+ required,
+ help: propsHelp,
+ maxBites,
+ accept,
+ } = props;
+ const { value, onChange, state } = useField<T, K>(name);
+ const help = propsHelp ?? state.help
+ if (state.hidden) {
+ return <div />;
+ }
+ return (
+ <div class="col-span-full">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ tooltip={tooltip}
+ required={required}
+ />
+ {!value || !(value as string).startsWith("data:image/") ? (
+ <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1">
+ <div class="text-center">
+ <svg
+ class="mx-auto h-12 w-12 text-gray-300"
+ viewBox="0 0 24 24"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ {!state.disabled &&
+ <div class="my-2 flex text-sm leading-6 text-gray-600">
+ <label
+ for="file-upload"
+ class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500"
+ >
+ <span>Upload a file</span>
+ <input
+ id="file-upload"
+ name="file-upload"
+ type="file"
+ class="sr-only"
+ accept={accept}
+ onChange={(e) => {
+ const f: FileList | null = e.currentTarget.files;
+ if (!f || f.length != 1) {
+ return onChange(undefined!);
+ }
+ if (f[0].size > maxBites) {
+ return onChange(undefined!);
+ }
+ return f[0].arrayBuffer().then((b) => {
+ const b64 = window.btoa(
+ new Uint8Array(b).reduce(
+ (data, byte) => data + String.fromCharCode(byte),
+ "",
+ ),
+ );
+ return onChange(`data:${f[0].type};base64,${b64}` as any);
+ });
+ }}
+ />
+ </label>
+ {/* <p class="pl-1">or drag and drop</p> */}
+ </div>
+ }
+ </div>
+ </div>
+ ) : (
+ <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative">
+ <img
+ src={value as string}
+ class=" h-24 w-full object-cover relative"
+ />
+
+ {!state.disabled &&
+ <div
+ class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer "
+ onClick={() => {
+ onChange(undefined!);
+ }}
+ >
+ Clear
+ </div>
+ }
+ </div>
+ )}
+ {help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputInteger.stories.tsx b/packages/web-util/src/forms/InputInteger.stories.tsx
new file mode 100644
index 000000000..bd1a467ab
--- /dev/null
+++ b/packages/web-util/src/forms/InputInteger.stories.tsx
@@ -0,0 +1,55 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Integer",
+};
+
+
+type TargetObject = {
+ age: number;
+}
+const initial: TargetObject = {
+ age: 5,
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "integer",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "age",
+ tooltip: "just numbers" as TranslatedString,
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputInteger.tsx b/packages/web-util/src/forms/InputInteger.tsx
new file mode 100644
index 000000000..a6a02ad43
--- /dev/null
+++ b/packages/web-util/src/forms/InputInteger.tsx
@@ -0,0 +1,24 @@
+import { VNode, h } from "preact";
+import { InputLine } from "./InputLine.js";
+import { UIFormProps } from "./FormProvider.js";
+
+export function InputInteger<T extends object, K extends keyof T>(
+ props: UIFormProps<T, K>,
+): VNode {
+ return (
+ <InputLine
+ type="number"
+ converter={{
+ //@ts-ignore
+ fromStringUI: (v): number => {
+ return !v ? 0 : Number.parseInt(v, 10);
+ },
+ //@ts-ignore
+ toStringUI: (v?: number): string => {
+ return v === undefined ? "" : String(v);
+ },
+ }}
+ {...props}
+ />
+ );
+}
diff --git a/packages/web-util/src/forms/InputLine.stories.tsx b/packages/web-util/src/forms/InputLine.stories.tsx
new file mode 100644
index 000000000..da41a221e
--- /dev/null
+++ b/packages/web-util/src/forms/InputLine.stories.tsx
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Line",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ comment: string;
+}
+const initial: TargetObject = {
+ comment: "some initial comment"
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "text",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "comment",
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx
new file mode 100644
index 000000000..b8879f9ec
--- /dev/null
+++ b/packages/web-util/src/forms/InputLine.tsx
@@ -0,0 +1,268 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { UIFormProps } from "./FormProvider.js";
+import { useField } from "./useField.js";
+
+//@ts-ignore
+const TooltipIcon = (
+ <svg
+ class="w-5 h-5"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+ </svg>
+);
+
+export function LabelWithTooltipMaybeRequired({
+ label,
+ required,
+ tooltip,
+}: {
+ label: TranslatedString;
+ required?: boolean;
+ tooltip?: TranslatedString;
+}): VNode {
+ const Label = (
+ <Fragment>
+ <div class="flex justify-between">
+ <label
+ htmlFor="email"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ {label}
+ </label>
+ </div>
+ </Fragment>
+ );
+ const WithTooltip = tooltip ? (
+ <div class="relative flex flex-grow items-stretch focus-within:z-10">
+ {Label}
+ <span class="relative flex items-center group pl-2">
+ {TooltipIcon}
+ <div class="absolute bottom-0 -ml-10 hidden flex-col items-center mb-6 group-hover:flex w-28">
+ <div class="relative z-10 p-2 text-xs leading-none text-white whitespace-no-wrap bg-black shadow-lg">
+ {tooltip}
+ </div>
+ <div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div>
+ </div>
+ </span>
+ </div>
+ ) : (
+ Label
+ );
+ if (required) {
+ return (
+ <div class="flex justify-between">
+ {WithTooltip}
+ <span class="text-sm leading-6 text-red-600">*</span>
+ </div>
+ );
+ }
+ return WithTooltip;
+}
+
+function InputWrapper<T extends object, K extends keyof T>({
+ children,
+ label,
+ tooltip,
+ before,
+ after,
+ help,
+ error,
+ disabled,
+ required,
+}: { error?: string; disabled: boolean, children: ComponentChildren } & UIFormProps<T, K>): VNode {
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ <div class="relative mt-2 flex rounded-md shadow-sm">
+ {before &&
+ (before.type === "text" ? (
+ <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
+ {before.text}
+ </span>
+ ) : before.type === "icon" ? (
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ {before.icon}
+ </div>
+ ) : before.type === "button" ? (
+ <button
+ type="button"
+ disabled={disabled}
+ onClick={before.onClick}
+ class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-l-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
+ >
+ {before.children}
+ </button>
+ ) : undefined)}
+
+ {children}
+
+ {after &&
+ (after.type === "text" ? (
+ <span class="inline-flex items-center rounded-r-md border border-l-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
+ {after.text}
+ </span>
+ ) : after.type === "icon" ? (
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
+ {after.icon}
+ </div>
+ ) : after.type === "button" ? (
+ <button
+ type="button"
+ disabled={disabled}
+ onClick={after.onClick}
+ class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
+ >
+ {after.children}
+ </button>
+ ) : undefined)}
+ </div>
+ {error && (
+ <p class="mt-2 text-sm text-red-600" id="email-error">
+ {error}
+ </p>
+ )}
+ {help && (
+ <p class="mt-2 text-sm text-gray-500" id="email-description">
+ {help}
+ </p>
+ )}
+ </div>
+ );
+}
+
+function defaultToString(v: unknown) {
+ return v === undefined ? "" : typeof v !== "object" ? String(v) : "";
+}
+function defaultFromString(v: string) {
+ return v;
+}
+
+type InputType = "text" | "text-area" | "password" | "email" | "number";
+
+export function InputLine<T extends object, K extends keyof T>(
+ props: { type: InputType } & UIFormProps<T, K>,
+): VNode {
+ const { name, placeholder, before, after, converter, type } = props;
+ const { value, onChange, state, isDirty } = useField<T, K>(name);
+
+ const [text, setText] = useState("")
+ const fromString: (s: string) => any =
+ converter?.fromStringUI ?? defaultFromString;
+ const toString: (s: any) => string = converter?.toStringUI ?? defaultToString;
+
+ useEffect(() => {
+ const newValue = toString(value)
+ if (newValue) {
+ setText(newValue)
+ }
+ }, [value])
+
+ if (state.hidden) return <div />;
+
+ let clazz =
+ "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200";
+ if (before) {
+ switch (before.type) {
+ case "icon": {
+ clazz += " pl-10";
+ break;
+ }
+ case "button": {
+ clazz += " rounded-none rounded-r-md ";
+ break;
+ }
+ case "text": {
+ clazz += " min-w-0 flex-1 rounded-r-md rounded-none ";
+ break;
+ }
+ }
+ }
+ if (after) {
+ switch (after.type) {
+ case "icon": {
+ clazz += " pr-10";
+ break;
+ }
+ case "button": {
+ clazz += " rounded-none rounded-l-md";
+ break;
+ }
+ case "text": {
+ clazz += " min-w-0 flex-1 rounded-l-md rounded-none ";
+ break;
+ }
+ }
+ }
+ const showError = isDirty && state.error;
+ if (showError) {
+ clazz +=
+ " text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500";
+ } else {
+ clazz +=
+ " text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600";
+ }
+
+ if (type === "text-area") {
+ return (
+ <InputWrapper<T, K>
+ {...props}
+ help={props.help ?? state.help}
+ disabled={state.disabled ?? false}
+ error={showError ? state.error : undefined}
+ >
+ <textarea
+ rows={4}
+ name={String(name)}
+ onChange={(e) => {
+ onChange(fromString(e.currentTarget.value));
+ }}
+ placeholder={placeholder ? placeholder : undefined}
+ value={toString(value) ?? ""}
+ // defaultValue={toString(value)}
+ disabled={state.disabled}
+ aria-invalid={showError}
+ // aria-describedby="email-error"
+ class={clazz}
+ />
+ </InputWrapper>
+ );
+ }
+
+ return (
+ <InputWrapper<T, K> {...props}
+ help={props.help ?? state.help}
+ disabled={state.disabled ?? false} error={showError ? state.error : undefined}
+ >
+ <input
+ name={String(name)}
+ type={type}
+ onChange={(e) => {
+ setText(e.currentTarget.value)
+ }}
+ placeholder={placeholder ? placeholder : undefined}
+ value={text}
+ onBlur={() => {
+ onChange(fromString(text));
+ }}
+ // defaultValue={toString(value)}
+ disabled={state.disabled}
+ aria-invalid={showError}
+ // aria-describedby="email-error"
+ class={clazz}
+ />
+ </InputWrapper>
+ );
+}
diff --git a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx
new file mode 100644
index 000000000..6ce5445c0
--- /dev/null
+++ b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx
@@ -0,0 +1,90 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Select Multiple",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ pets: string[];
+ things: string[];
+}
+const initial: TargetObject = {
+ pets: [],
+ things: [],
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "selectMultiple",
+ props: {
+ label: "allow diplicates" as TranslatedString,
+ name: "pets",
+ placeholder: "search..." as TranslatedString,
+ choices: [{
+ label: "one label" as TranslatedString,
+ value: "one"
+ }, {
+ label: "two label" as TranslatedString,
+ value: "two"
+ }, {
+ label: "five label" as TranslatedString,
+ value: "five"
+ }]
+ },
+ }, {
+ type: "selectMultiple",
+ props: {
+ label: "unique values" as TranslatedString,
+ name: "things",
+ unique: true,
+ placeholder: "search..." as TranslatedString,
+ choices: [{
+ label: "one label" as TranslatedString,
+ value: "one"
+ }, {
+ label: "two label" as TranslatedString,
+ value: "two"
+ }, {
+ label: "five label" as TranslatedString,
+ value: "five"
+ }]
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx b/packages/web-util/src/forms/InputSelectMultiple.tsx
new file mode 100644
index 000000000..a67eb23b7
--- /dev/null
+++ b/packages/web-util/src/forms/InputSelectMultiple.tsx
@@ -0,0 +1,154 @@
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { UIFormProps } from "./FormProvider.js";
+import { ChoiceS } from "./InputChoiceStacked.js";
+import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputSelectMultiple<T extends object, K extends keyof T>(
+ props: {
+ choices: ChoiceS<T[K]>[];
+ unique?: boolean;
+ max?: number;
+ } & UIFormProps<T, K>,
+): VNode {
+ const { name, label, choices, placeholder, tooltip, required, unique, max } =
+ props;
+ const { value, onChange, state } = useField<T, K>(name);
+
+ const [filter, setFilter] = useState<string | undefined>(undefined);
+ const regex = new RegExp(`.*${filter}.*`, "i");
+ const choiceMap = choices.reduce((prev, curr) => {
+ return { ...prev, [curr.value as string]: curr.label };
+ }, {} as Record<string, string>);
+
+ const list = (value ?? []) as string[];
+ const filteredChoices =
+ filter === undefined
+ ? undefined
+ : choices.filter((v) => {
+ return regex.test(v.label);
+ });
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ {list.map((v, idx) => {
+ return (
+ <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600">
+ {choiceMap[v]}
+ <button
+ type="button"
+ disabled={state.disabled}
+ onClick={() => {
+ const newValue = [...list];
+ newValue.splice(idx, 1);
+ onChange(newValue as T[K]);
+ setFilter(undefined);
+ }}
+ class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20"
+ >
+ <span class="sr-only">Remove</span>
+ <svg
+ viewBox="0 0 14 14"
+ class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75"
+ >
+ <path d="M4 4l6 6m0-6l-6 6" />
+ </svg>
+ <span class="absolute -inset-1"></span>
+ </button>
+ </span>
+ );
+ })}
+
+ {!state.disabled && <div class="relative mt-2">
+ <input
+ id="combobox"
+ type="text"
+ value={filter ?? ""}
+ onChange={(e) => {
+ setFilter(e.currentTarget.value);
+ }}
+ placeholder={placeholder}
+ class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ role="combobox"
+ aria-controls="options"
+ aria-expanded="false"
+ />
+ <button
+ type="button"
+ disabled={state.disabled}
+ onClick={() => {
+ setFilter(filter === undefined ? "" : undefined);
+ }}
+ class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
+ >
+ <svg
+ class="h-5 w-5 text-gray-400"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </button>
+
+ {filteredChoices !== undefined && (
+ <ul
+ class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
+ id="options"
+ role="listbox"
+ >
+ {filteredChoices.map((v, idx) => {
+ return (
+ <li
+ class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"
+ id="option-0"
+ role="option"
+ onClick={() => {
+ setFilter(undefined);
+ if (unique && list.indexOf(v.value as string) !== -1) {
+ return;
+ }
+ if (max !== undefined && list.length >= max) {
+ return;
+ }
+ const newValue = [...list];
+ newValue.splice(0, 0, v.value as string);
+ onChange(newValue as T[K]);
+ }}
+
+ // tabindex="-1"
+ >
+ {/* <!-- Selected: "font-semibold" --> */}
+ <span class="block truncate">{v.label}</span>
+
+ {/* <!--
+ Checkmark, only display for selected option.
+
+ Active: "text-white", Not Active: "text-indigo-600"
+ --> */}
+ </li>
+ );
+ })}
+
+ {/* <!--
+ Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation.
+
+ Active: "text-white bg-indigo-600", Not Active: "text-gray-900"
+ --> */}
+
+ {/* <!-- More items... --> */}
+ </ul>
+ )}
+ </div>}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputSelectOne.stories.tsx b/packages/web-util/src/forms/InputSelectOne.stories.tsx
new file mode 100644
index 000000000..9e9029a77
--- /dev/null
+++ b/packages/web-util/src/forms/InputSelectOne.stories.tsx
@@ -0,0 +1,70 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Select One",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ things: string;
+}
+const initial: TargetObject = {
+ things: "one"
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "selectOne",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "things",
+ placeholder: "search..." as TranslatedString,
+ choices: [{
+ label: "one label" as TranslatedString,
+ value: "one"
+ }, {
+ label: "two label" as TranslatedString,
+ value: "two"
+ }, {
+ label: "five label" as TranslatedString,
+ value: "five"
+ }]
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputSelectOne.tsx b/packages/web-util/src/forms/InputSelectOne.tsx
new file mode 100644
index 000000000..d100b079d
--- /dev/null
+++ b/packages/web-util/src/forms/InputSelectOne.tsx
@@ -0,0 +1,135 @@
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { UIFormProps } from "./FormProvider.js";
+import { ChoiceS } from "./InputChoiceStacked.js";
+import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputSelectOne<T extends object, K extends keyof T>(
+ props: {
+ choices: ChoiceS<T[K]>[];
+ } & UIFormProps<T, K>,
+): VNode {
+ const { name, label, choices, placeholder, tooltip, required } = props;
+ const { value, onChange } = useField<T, K>(name);
+
+ const [filter, setFilter] = useState<string | undefined>(undefined);
+ const regex = new RegExp(`.*${filter}.*`, "i");
+ const choiceMap = choices.reduce((prev, curr) => {
+ return { ...prev, [curr.value as string]: curr.label };
+ }, {} as Record<string, string>);
+
+ const filteredChoices =
+ filter === undefined
+ ? undefined
+ : choices.filter((v) => {
+ return regex.test(v.label);
+ });
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ {value ? (
+ <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 font-medium text-gray-600">
+ {choiceMap[value as string]}
+ <button
+ type="button"
+ onClick={() => {
+ onChange(undefined!);
+ }}
+ class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20"
+ >
+ <span class="sr-only">Remove</span>
+ <svg
+ viewBox="0 0 14 14"
+ class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75"
+ >
+ <path d="M4 4l6 6m0-6l-6 6" />
+ </svg>
+ <span class="absolute -inset-1"></span>
+ </button>
+ </span>
+ ) : (
+ <div class="relative mt-2">
+ <input
+ id="combobox"
+ type="text"
+ value={filter ?? ""}
+ onChange={(e) => {
+ setFilter(e.currentTarget.value);
+ }}
+ placeholder={placeholder}
+ class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ role="combobox"
+ aria-controls="options"
+ aria-expanded="false"
+ />
+ <button
+ type="button"
+ onClick={() => {
+ setFilter(filter === undefined ? "" : undefined);
+ }}
+ class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
+ >
+ <svg
+ class="h-5 w-5 text-gray-400"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </button>
+
+ {filteredChoices !== undefined && (
+ <ul
+ class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
+ id="options"
+ role="listbox"
+ >
+ {filteredChoices.map((v, idx) => {
+ return (
+ <li
+ class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"
+ id="option-0"
+ role="option"
+ onClick={() => {
+ setFilter(undefined);
+ onChange(v.value as T[K]);
+ }}
+
+ // tabindex="-1"
+ >
+ {/* <!-- Selected: "font-semibold" --> */}
+ <span class="block truncate">{v.label}</span>
+
+ {/* <!--
+ Checkmark, only display for selected option.
+
+ Active: "text-white", Not Active: "text-indigo-600"
+ --> */}
+ </li>
+ );
+ })}
+
+ {/* <!--
+ Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation.
+
+ Active: "text-white bg-indigo-600", Not Active: "text-gray-900"
+ --> */}
+
+ {/* <!-- More items... --> */}
+ </ul>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputText.stories.tsx b/packages/web-util/src/forms/InputText.stories.tsx
new file mode 100644
index 000000000..04ab8a1c6
--- /dev/null
+++ b/packages/web-util/src/forms/InputText.stories.tsx
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Text",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ comment: string;
+}
+const initial: TargetObject = {
+ comment: "some initial comment"
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "text",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "comment",
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputText.tsx b/packages/web-util/src/forms/InputText.tsx
new file mode 100644
index 000000000..1c0c04225
--- /dev/null
+++ b/packages/web-util/src/forms/InputText.tsx
@@ -0,0 +1,9 @@
+import { VNode, h } from "preact";
+import { UIFormProps } from "./FormProvider.js";
+import { InputLine } from "./InputLine.js";
+
+export function InputText<T extends object, K extends keyof T>(
+ props: UIFormProps<T, K>,
+): VNode {
+ return <InputLine type="text" {...props} />;
+}
diff --git a/packages/web-util/src/forms/InputTextArea.stories.tsx b/packages/web-util/src/forms/InputTextArea.stories.tsx
new file mode 100644
index 000000000..c8c3eb088
--- /dev/null
+++ b/packages/web-util/src/forms/InputTextArea.stories.tsx
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ DefaultForm as TestedComponent,
+ FlexibleForm,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Text Area",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ comment: string;
+}
+const initial: TargetObject = {
+ comment: "some initial comment"
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "text",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "comment",
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputTextArea.tsx b/packages/web-util/src/forms/InputTextArea.tsx
new file mode 100644
index 000000000..6b76d8329
--- /dev/null
+++ b/packages/web-util/src/forms/InputTextArea.tsx
@@ -0,0 +1,9 @@
+import { VNode, h } from "preact";
+import { InputLine } from "./InputLine.js";
+import { UIFormProps } from "./FormProvider.js";
+
+export function InputTextArea<T extends object, K extends keyof T>(
+ props: UIFormProps<T, K>,
+): VNode {
+ return <InputLine type="text-area" {...props} />;
+}
diff --git a/packages/web-util/src/forms/InputToggle.stories.tsx b/packages/web-util/src/forms/InputToggle.stories.tsx
new file mode 100644
index 000000000..ca6857618
--- /dev/null
+++ b/packages/web-util/src/forms/InputToggle.stories.tsx
@@ -0,0 +1,59 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import * as tests from "@gnu-taler/web-util/testing";
+import {
+ FlexibleForm,
+ DefaultForm as TestedComponent,
+} from "./DefaultForm.js";
+
+export default {
+ title: "Input Toggle",
+};
+
+export namespace Simplest {
+ export interface Form {
+ comment: string;
+ }
+}
+
+type TargetObject = {
+ comment: string;
+}
+const initial: TargetObject = {
+ comment: "some initial comment"
+}
+
+const form: FlexibleForm<TargetObject> = {
+ design: [{
+ title: "this is a simple form" as TranslatedString,
+ fields: [{
+ type: "toggle",
+ props: {
+ label: "label of the field" as TranslatedString,
+ name: "comment",
+ },
+ }]
+ }]
+}
+
+export const SimpleComment = tests.createExample(TestedComponent, { initial, form });
diff --git a/packages/web-util/src/forms/InputToggle.tsx b/packages/web-util/src/forms/InputToggle.tsx
new file mode 100644
index 000000000..56b29c502
--- /dev/null
+++ b/packages/web-util/src/forms/InputToggle.tsx
@@ -0,0 +1,38 @@
+import { VNode, h } from "preact";
+import { UIFormProps } from "./FormProvider.js";
+import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputToggle<T extends object, K extends keyof T>(
+ props: UIFormProps<T, K>,
+): VNode {
+ const {
+ name,
+ label,
+ tooltip,
+ help,
+ placeholder,
+ required,
+ before,
+ after,
+ converter,
+ } = props;
+ const { value, onChange, state, isDirty } = useField<T, K>(name);
+
+ const isOn = !!value
+ return <div class="sm:col-span-6">
+ <div class="flex items-center justify-between">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ <button type="button" data-enabled={isOn}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description"
+ onClick={() => { onChange(!isOn as any); }}>
+ <span aria-hidden="true" data-enabled={isOn} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
+ </button>
+ </div>
+ </div>
+}
diff --git a/packages/web-util/src/forms/TimePicker.tsx b/packages/web-util/src/forms/TimePicker.tsx
new file mode 100644
index 000000000..5e4e7a8fa
--- /dev/null
+++ b/packages/web-util/src/forms/TimePicker.tsx
@@ -0,0 +1,109 @@
+import { AbsoluteTime } from "@gnu-taler/taler-util"
+import { getHours, getMinutes, getSeconds, setHours } from "date-fns"
+import { Fragment, VNode, h } from "preact"
+import { useTranslationContext } from "../index.browser.js"
+
+export function TimePicker({ value, onChange, onConfirm }: { value: AbsoluteTime | undefined, onChange: (v: AbsoluteTime) => void, onConfirm: () => void }): VNode {
+ const date = !value ? new Date() : new Date(AbsoluteTime.toStampMs(value))
+ const hours = getHours(date) % 12
+ const minutes = getMinutes(date)
+ const seconds = getSeconds(date)
+
+ const { i18n } = useTranslationContext()
+
+ return <Fragment>
+ <div class="flex flex-col bg-white rounded-t-sm justify-around" >
+ {/* time selection */}
+ <div id="" class="bg-[#3b71ca] dark:bg-zinc-700 h-24 rounded-t-lg p-12 flex flex-row items-center justify-center">
+ <div class="flex w-full justify-evenly">
+ <div class="">
+ <span class="relative h-full">
+ <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none "
+ style="pointer-events: none;">
+ {new String(hours).padStart(2, "0")}
+ </button>
+ </span>
+ <span type="button" class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " >:</span>
+ <span class="relative h-full">
+ <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " >
+ {new String(minutes).padStart(2, "0")}
+ </button>
+ </span>
+ <span type="button" class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " >:</span>
+ <span class="relative h-full">
+ <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " >
+ {new String(seconds).padStart(2, "0")}
+ </button>
+ </span>
+ </div>
+ <div class="flex flex-col justify-center text-[18px] text-[#ffffff8a] ">
+ <button type="button" class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" >
+ AM
+ </button>
+ <button type="button" class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" >
+ PM
+ </button>
+ </div>
+ </div>
+ </div>
+ {/* clock */}
+ <div id="" class="mt-2 min-w-[310px] max-w-[325px] min-h-[305px] overflow-x-hidden h-full flex justify-center mx-auto flex-col items-center dark:bg-zinc-500" >
+ <div class="relative rounded-[100%] w-[260px] h-[260px] cursor-default my-0 mx-auto bg-[#00000012] dark:bg-zinc-600/50 animate-[show-up-clock_350ms_linear]" >
+
+ <span class="top-1/2 left-1/2 w-[6px] h-[6px] -translate-y-1/2 -translate-x-1/2 rounded-[50%] bg-[#3b71ca] absolute" ></span>
+ <div class="bg-[#3b71ca] bottom-1/2 h-2/5 left-[calc(50%-1px)] rtl:!left-auto origin-[center_bottom_0] rtl:!origin-[50%_50%_0] w-[2px] absolute" style={{ transform: "rotateZ(60deg)", height: "calc(35% + 1px)" }}>
+ {/* <div class="-top-[21px] -left-[15px] w-[4px] border-[14px] border-solid border-[#3b71ca] h-[4px] box-content rounded-[100%] absolute" style="background-color: rgb(25, 118, 210);"></div> */}
+ </div>
+
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 12).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 114px; bottom: 224px;">
+ <span>0</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 1).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 169px; bottom: 209.263px;">
+ <span >1</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 2).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" data-selected={true} style="left: 209.263px; bottom: 169px;" >
+ <span >2</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 3).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 224px; bottom: 114px;">
+ <span >3</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 4).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 209.263px; bottom: 59px;">
+ <span >4</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 5).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 169px; bottom: 18.7372px;">
+ <span >5</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 6).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 114px; bottom: 4px;">
+ <span >6</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 7).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 59px; bottom: 18.7372px;">
+ <span >7</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 8).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 18.7372px; bottom: 59px;">
+ <span >8</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 9).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 4px; bottom: 114px;">
+ <span >9</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 10).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 18.7372px; bottom: 169px;">
+ <span >10</span>
+ </span>
+ <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 11).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 59px; bottom: 209.263px;">
+ <span >11</span>
+ </span>
+ </div>
+ </div>
+ </div>
+ <div id="" class="rounded-b-lg flex justify-between items-center w-full h-[56px] px-[12px] bg-white dark:bg-zinc-500">
+ <div class="w-full flex justify-end">
+ <button
+ type="submit"
+ onClick={onConfirm}
+ class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ </Fragment>
+}
diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts
new file mode 100644
index 000000000..3b8620bfb
--- /dev/null
+++ b/packages/web-util/src/forms/forms.ts
@@ -0,0 +1,134 @@
+import { h as create, Fragment, VNode } from "preact";
+import { Caption } from "./Caption.js";
+import { FormProvider } from "./FormProvider.js";
+import { Group } from "./Group.js";
+import { InputAbsoluteTime } from "./InputAbsoluteTime.js";
+import { InputAmount } from "./InputAmount.js";
+import { InputArray } from "./InputArray.js";
+import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js";
+import { InputChoiceStacked } from "./InputChoiceStacked.js";
+import { InputFile } from "./InputFile.js";
+import { InputInteger } from "./InputInteger.js";
+import { InputLine } from "./InputLine.js";
+import { InputSelectMultiple } from "./InputSelectMultiple.js";
+import { InputSelectOne } from "./InputSelectOne.js";
+import { InputText } from "./InputText.js";
+import { InputTextArea } from "./InputTextArea.js";
+import { InputToggle } from "./InputToggle.js";
+
+/**
+ * Constrain the type with the ui props
+ */
+type FieldType<T extends object = any, K extends keyof T = any> = {
+ group: Parameters<typeof Group>[0];
+ caption: Parameters<typeof Caption>[0];
+ array: Parameters<typeof InputArray<T, K>>[0];
+ file: Parameters<typeof InputFile<T, K>>[0];
+ selectOne: Parameters<typeof InputSelectOne<T, K>>[0];
+ selectMultiple: Parameters<typeof InputSelectMultiple<T, K>>[0];
+ text: Parameters<typeof InputText<T, K>>[0];
+ textArea: Parameters<typeof InputTextArea<T, K>>[0];
+ choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0];
+ choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0];
+ absoluteTime: Parameters<typeof InputAbsoluteTime<T, K>>[0];
+ integer: Parameters<typeof InputInteger<T, K>>[0];
+ toggle: Parameters<typeof InputToggle<T, K>>[0];
+ amount: Parameters<typeof InputAmount<T, K>>[0];
+};
+
+/**
+ * List all the form fields so typescript can type-check the form instance
+ */
+export type UIFormField =
+ | { type: "group"; props: FieldType["group"] }
+ | { type: "caption"; props: FieldType["caption"] }
+ | { type: "array"; props: FieldType["array"] }
+ | { type: "file"; props: FieldType["file"] }
+ | { type: "amount"; props: FieldType["amount"] }
+ | { type: "selectOne"; props: FieldType["selectOne"] }
+ | { type: "selectMultiple"; props: FieldType["selectMultiple"] }
+ | { type: "text"; props: FieldType["text"] }
+ | { type: "textArea"; props: FieldType["textArea"] }
+ | { type: "choiceStacked"; props: FieldType["choiceStacked"] }
+ | { type: "choiceHorizontal"; props: FieldType["choiceHorizontal"] }
+ | { type: "integer"; props: FieldType["integer"] }
+ | { type: "toggle"; props: FieldType["toggle"] }
+ | { type: "absoluteTime"; props: FieldType["absoluteTime"] };
+
+type FieldComponentFunction<key extends keyof FieldType> = (
+ props: FieldType[key],
+) => VNode;
+
+type UIFormFieldMap = {
+ [key in keyof FieldType]: FieldComponentFunction<key>;
+};
+
+/**
+ * Maps input type with component implementation
+ */
+const UIFormConfiguration: UIFormFieldMap = {
+ group: Group,
+ caption: Caption,
+ //@ts-ignore
+ array: InputArray,
+ text: InputText,
+ //@ts-ignore
+ file: InputFile,
+ textArea: InputTextArea,
+ //@ts-ignore
+ absoluteTime: InputAbsoluteTime,
+ //@ts-ignore
+ choiceStacked: InputChoiceStacked,
+ //@ts-ignore
+ choiceHorizontal: InputChoiceHorizontal,
+ integer: InputInteger,
+ //@ts-ignore
+ selectOne: InputSelectOne,
+ //@ts-ignore
+ selectMultiple: InputSelectMultiple,
+ //@ts-ignore
+ toggle: InputToggle,
+ //@ts-ignore
+ amount: InputAmount,
+};
+
+export function RenderAllFieldsByUiConfig({
+ fields,
+}: {
+ fields: UIFormField[];
+}): VNode {
+ return create(
+ Fragment,
+ {},
+ fields.map((field, i) => {
+ const Component = UIFormConfiguration[
+ field.type
+ ] as FieldComponentFunction<any>;
+ return Component(field.props);
+ }),
+ );
+}
+
+type FormSet<T extends object> = {
+ Provider: typeof FormProvider<T>;
+ InputLine: <K extends keyof T>() => typeof InputLine<T, K>;
+ InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal<T, K>;
+};
+
+/**
+ * Helper function that created a typed object.
+ *
+ * @returns
+ */
+export function createNewForm<T extends object>() {
+ const res: FormSet<T> = {
+ Provider: FormProvider,
+ InputLine: () => InputLine,
+ InputChoiceHorizontal: () => InputChoiceHorizontal,
+ };
+ return {
+ Provider: res.Provider,
+ InputLine: res.InputLine(),
+ InputChoiceHorizontal: res.InputChoiceHorizontal(),
+ };
+}
diff --git a/packages/web-util/src/forms/index.stories.ts b/packages/web-util/src/forms/index.stories.ts
new file mode 100644
index 000000000..55878cb02
--- /dev/null
+++ b/packages/web-util/src/forms/index.stories.ts
@@ -0,0 +1,13 @@
+export * as a1 from "./InputAmount.stories.js";
+export * as a2 from "./InputArray.stories.js";
+export * as a3 from "./InputChoiceHorizontal.stories.js";
+export * as a4 from "./InputChoiceStacked.stories.js";
+export * as a5 from "./InputAbsoluteTime.stories.js";
+export * as a6 from "./InputFile.stories.js";
+export * as a7 from "./InputInteger.stories.js";
+export * as a8 from "./InputLine.stories.js";
+export * as a9 from "./InputSelectMultiple.stories.js";
+export * as a10 from "./InputSelectOne.stories.js";
+export * as a11 from "./InputText.stories.js";
+export * as a12 from "./InputTextArea.stories.js";
+export * as a13 from "./InputToggle.stories.js";
diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts
new file mode 100644
index 000000000..4ff71f197
--- /dev/null
+++ b/packages/web-util/src/forms/index.ts
@@ -0,0 +1,23 @@
+export * from "./Calendar.js"
+export * from "./Caption.js"
+export * from "./DefaultForm.js"
+export * from "./Dialog.js"
+export * from "./FormProvider.js"
+export * from "./Group.js"
+export * from "./InputAbsoluteTime.js"
+export * from "./InputAmount.js"
+export * from "./InputArray.js"
+export * from "./InputChoiceHorizontal.js"
+export * from "./InputChoiceStacked.js"
+export * from "./InputFile.js"
+export * from "./InputInteger.js"
+export * from "./InputLine.js"
+export * from "./InputSelectMultiple.js"
+export * from "./InputSelectOne.js"
+export * from "./InputText.js"
+export * from "./InputTextArea.js"
+export * from "./InputToggle.js"
+export * from "./TimePicker.js"
+export * from "./forms.js"
+export * from "./useField.js"
+
diff --git a/packages/web-util/src/forms/useField.ts b/packages/web-util/src/forms/useField.ts
new file mode 100644
index 000000000..eed8cebea
--- /dev/null
+++ b/packages/web-util/src/forms/useField.ts
@@ -0,0 +1,93 @@
+import { useContext, useState } from "preact/compat";
+import { FieldUIOptions, FormContext } from "./FormProvider.js";
+
+export interface InputFieldHandler<Type> {
+ value: Type;
+ onChange: (s: Type) => void;
+ state: FieldUIOptions;
+ isDirty: boolean;
+}
+
+export function useField<T extends object, K extends keyof T>(
+ name: K,
+): InputFieldHandler<T[K]> {
+ const {
+ value: formValue,
+ computeFormState,
+ onUpdate: notifyUpdate,
+ readOnly: readOnlyForm,
+ } = useContext(FormContext);
+
+ type P = typeof name;
+ type V = T[P];
+ const formState = computeFormState ? computeFormState(formValue.current) : {};
+
+ const fieldValue = readField(formValue.current, String(name)) as V;
+ // console.log("USE FIELD", String(name), formValue.current, fieldValue);
+ const [currentValue, setCurrentValue] = useState<any | undefined>(fieldValue);
+ const fieldState =
+ readField<Partial<FieldUIOptions>>(formState, String(name)) ?? {};
+
+ //compute default state
+ const state = {
+ disabled: readOnlyForm ? true : (fieldState.disabled ?? false),
+ hidden: fieldState.hidden ?? false,
+ error: fieldState.error,
+ help: fieldState.help,
+ elements: "elements" in fieldState ? fieldState.elements ?? [] : [],
+ };
+
+ function onChange(value: V): void {
+ setCurrentValue(value);
+ formValue.current = setValueDeeper(
+ formValue.current,
+ String(name).split("."),
+ value,
+ );
+ if (notifyUpdate) {
+ notifyUpdate(formValue.current);
+ }
+ }
+
+ return {
+ value: fieldValue,
+ onChange,
+ isDirty: currentValue !== undefined,
+ state,
+ };
+}
+
+/**
+ * read the field of an object an support accessing it using '.'
+ *
+ * @param object
+ * @param name
+ * @returns
+ */
+function readField<T>(
+ object: any,
+ name: string,
+ debug?: boolean,
+): T | undefined {
+ return name.split(".").reduce((prev, current) => {
+ if (debug) {
+ console.log(
+ "READ",
+ name,
+ prev,
+ current,
+ prev ? prev[current] : undefined,
+ );
+ }
+ return prev ? prev[current] : undefined;
+ }, object);
+}
+
+function setValueDeeper(object: any, names: string[], value: any): any {
+ if (names.length === 0) return value;
+ const [head, ...rest] = names;
+ if (object === undefined) {
+ return { [head]: setValueDeeper({}, rest, value) };
+ }
+ return { ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) };
+}
diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts
new file mode 100644
index 000000000..ba1b6e222
--- /dev/null
+++ b/packages/web-util/src/hooks/index.ts
@@ -0,0 +1,13 @@
+export { useLang } from "./useLang.js";
+export { useLocalStorage, buildStorageKey, StorageKey, StorageState } from "./useLocalStorage.js";
+export { useMemoryStorage } from "./useMemoryStorage.js";
+export * from "./useNotifications.js";
+export {
+ useAsyncAsHook,
+ HookError,
+ HookOk,
+ HookResponse,
+ HookResponseWithRetry,
+ HookGenericError,
+ HookOperationalError,
+} from "./useAsyncAsHook.js";
diff --git a/packages/web-util/src/hooks/useAsyncAsHook.ts b/packages/web-util/src/hooks/useAsyncAsHook.ts
new file mode 100644
index 000000000..48d29aa45
--- /dev/null
+++ b/packages/web-util/src/hooks/useAsyncAsHook.ts
@@ -0,0 +1,91 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { TalerErrorDetail } from "@gnu-taler/taler-util";
+// import { TalerError } from "@gnu-taler/taler-wallet-core";
+import { useEffect, useMemo, useState } from "preact/hooks";
+
+export interface HookOk<T> {
+ hasError: false;
+ response: T;
+}
+
+export type HookError = HookGenericError | HookOperationalError;
+
+export interface HookGenericError {
+ hasError: true;
+ operational: false;
+ message: string;
+}
+
+export interface HookOperationalError {
+ hasError: true;
+ operational: true;
+ details: TalerErrorDetail;
+}
+
+interface WithRetry {
+ retry: () => void;
+}
+
+export type HookResponse<T> = HookOk<T> | HookError | undefined;
+export type HookResponseWithRetry<T> =
+ | ((HookOk<T> | HookError) & WithRetry)
+ | undefined;
+
+export function useAsyncAsHook<T>(
+ fn: () => Promise<T | false>,
+ deps?: any[],
+): HookResponseWithRetry<T> {
+ const [result, setHookResponse] = useState<HookResponse<T>>(undefined);
+
+ const args = useMemo(
+ () => ({
+ fn,
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }),
+ deps || [],
+ );
+
+ async function doAsync(): Promise<void> {
+ try {
+ const response = await args.fn();
+ if (response === false) return;
+ setHookResponse({ hasError: false, response });
+ } catch (e) {
+ // if (e instanceof TalerError) {
+ // setHookResponse({
+ // hasError: true,
+ // operational: true,
+ // details: e.errorDetail,
+ // });
+ // } else
+ if (e instanceof Error) {
+ setHookResponse({
+ hasError: true,
+ operational: false,
+ message: e.message,
+ });
+ }
+ }
+ }
+
+ useEffect(() => {
+ doAsync();
+ }, [args]);
+
+ if (!result) return undefined;
+ return { ...result, retry: doAsync };
+}
diff --git a/packages/web-util/src/hooks/useLang.ts b/packages/web-util/src/hooks/useLang.ts
new file mode 100644
index 000000000..5b1be0309
--- /dev/null
+++ b/packages/web-util/src/hooks/useLang.ts
@@ -0,0 +1,61 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ StorageState,
+ buildStorageKey,
+ useLocalStorage,
+} from "./useLocalStorage.js";
+
+const MIN_LANG_COVERAGE_THRESHOLD = 90;
+/**
+ * choose the best from the browser config based on the completeness
+ * on the translation
+ */
+function getBrowserLang(completeness: Record<string, number>): string | undefined {
+ if (typeof window === "undefined") return undefined;
+
+ if (window.navigator.language) {
+ if (completeness[window.navigator.language] >= MIN_LANG_COVERAGE_THRESHOLD) {
+ return window.navigator.language
+ }
+ }
+ if (window.navigator.languages) {
+ const match = Object.entries(completeness).filter(([code, value]) => {
+ if (value < MIN_LANG_COVERAGE_THRESHOLD) return false; //do not consider langs below 90%
+ return window.navigator.languages.findIndex(l => l.startsWith(code)) !== -1
+ }).map(([code, value]) => ({ code, value }))
+
+ if (match.length > 0) {
+ let max = match[0]
+ match.forEach(v => {
+ if (v.value > max.value) {
+ max = v
+ }
+ })
+ return max.code
+ }
+ };
+
+ return undefined;
+}
+
+const langPreferenceKey = buildStorageKey("lang-preference");
+
+export function useLang(initial: string | undefined, completeness: Record<string, number>): Required<StorageState> {
+ const defaultValue = (getBrowserLang(completeness) || initial || "en").substring(0, 2);
+ return useLocalStorage(langPreferenceKey, defaultValue);
+}
diff --git a/packages/web-util/src/hooks/useLocalStorage.ts b/packages/web-util/src/hooks/useLocalStorage.ts
new file mode 100644
index 000000000..abd80bacc
--- /dev/null
+++ b/packages/web-util/src/hooks/useLocalStorage.ts
@@ -0,0 +1,139 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { AbsoluteTime, Codec, codecForString } from "@gnu-taler/taler-util";
+import { useEffect, useState } from "preact/hooks";
+import {
+ ObservableMap,
+ browserStorageMap,
+ localStorageMap,
+ memoryMap,
+} from "../utils/observable.js";
+
+declare const opaque_StorageKey: unique symbol;
+
+export type StorageKey<Key> = {
+ id: string;
+ [opaque_StorageKey]: true;
+ codec: Codec<Key>;
+};
+
+export function buildStorageKey<Key>(
+ name: string,
+ codec: Codec<Key>,
+): StorageKey<Key>;
+export function buildStorageKey(name: string): StorageKey<string>;
+export function buildStorageKey<Key = string>(
+ name: string,
+ codec?: Codec<Key>,
+): StorageKey<Key> {
+ return {
+ id: name,
+ codec: codec ?? (codecForString() as Codec<Key>),
+ } as StorageKey<Key>;
+}
+
+export interface StorageState<Type = string> {
+ value?: Type;
+ update: (s: Type) => void;
+ reset: () => void;
+}
+
+const supportLocalStorage = typeof window !== "undefined";
+const supportBrowserStorage =
+ typeof chrome !== "undefined" && typeof chrome.storage !== "undefined";
+
+/**
+ * Build setting storage
+ */
+const storage: ObservableMap<string, string> = (function buildStorage() {
+ if (supportBrowserStorage) {
+ //browser storage is like local storage but
+ //with app sync.
+ //Works for almost every browser
+ if (supportLocalStorage) {
+ return browserStorageMap(localStorageMap());
+ } else {
+ // service worker doesn't have local storage
+ return browserStorageMap(memoryMap<string>());
+ }
+ } else if (supportLocalStorage) {
+ // fallback if browser is too old
+ return localStorageMap();
+ } else {
+ // new need to save settings somewhere
+ return memoryMap<string>();
+ }
+})();
+//with initial value
+export function useLocalStorage<Type = string>(
+ key: StorageKey<Type>,
+ defaultValue: Type,
+): Required<StorageState<Type>>;
+//without initial value
+export function useLocalStorage<Type = string>(
+ key: StorageKey<Type>,
+): StorageState<Type>;
+// impl
+export function useLocalStorage<Type = string>(
+ key: StorageKey<Type>,
+ defaultValue?: Type,
+): StorageState<Type> {
+ const current = convert(storage.get(key.id), key, defaultValue);
+
+ const [_, setStoredValue] = useState(AbsoluteTime.now().t_ms);
+
+ useEffect(() => {
+ return storage.onUpdate(key.id, () => {
+ // const newValue = storage.get(key.id);
+ setStoredValue(AbsoluteTime.now().t_ms);
+ });
+ }, [key.id]);
+
+ const setValue = (value?: Type): void => {
+ if (value === undefined) {
+ storage.delete(key.id);
+ } else {
+ storage.set(
+ key.id,
+ key.codec ? JSON.stringify(value) : (value as string),
+ );
+ }
+ };
+
+ return {
+ value: current,
+ update: setValue,
+ reset: () => {
+ setValue(defaultValue);
+ },
+ };
+}
+
+function convert<Type>(updated: string | undefined, key: StorageKey<Type>, defaultValue?: Type): Type | undefined {
+ if (updated === undefined) return defaultValue; //optional
+ try {
+ return key.codec.decode(JSON.parse(updated));
+ } catch (e) {
+ //decode error
+ return defaultValue;
+ }
+}
diff --git a/packages/web-util/src/hooks/useMemoryStorage.ts b/packages/web-util/src/hooks/useMemoryStorage.ts
new file mode 100644
index 000000000..ef186392f
--- /dev/null
+++ b/packages/web-util/src/hooks/useMemoryStorage.ts
@@ -0,0 +1,71 @@
+/*
+ This file is part of GNU Anastasis
+ (C) 2021-2022 Anastasis SARL
+
+ GNU Anastasis is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useEffect, useState } from "preact/hooks";
+import { ObservableMap, memoryMap } from "../utils/observable.js";
+import { StorageKey, StorageState } from "./useLocalStorage.js";
+
+const storage: ObservableMap<string, any> = memoryMap<any>();
+
+//with initial value
+export function useMemoryStorage<Type = string>(
+ key: string,
+ defaultValue: Type,
+): Required<StorageState<Type>>;
+//with initial value
+export function useMemoryStorage<Type = string>(
+ key: string,
+): StorageState<Type>;
+// impl
+export function useMemoryStorage<Type = string>(
+ key: string,
+ defaultValue?: Type,
+): StorageState<Type> {
+ const [storedValue, setStoredValue] = useState<Type | undefined>(
+ (): Type | undefined => {
+ const prev = storage.get(key);
+ return prev === undefined ? defaultValue : prev;
+ },
+ );
+
+ useEffect(() => {
+ return storage.onUpdate(key, () => {
+ const newValue = storage.get(key);
+ setStoredValue(newValue === undefined ? defaultValue : newValue);
+ });
+ }, [key]);
+
+ const setValue = (value?: Type): void => {
+ if (value === undefined) {
+ storage.delete(key);
+ } else {
+ storage.set(key, value);
+ }
+ };
+
+ return {
+ value: storedValue,
+ update: setValue,
+ reset: () => {
+ setValue(defaultValue);
+ },
+ };
+}
diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts
new file mode 100644
index 000000000..99f4f2699
--- /dev/null
+++ b/packages/web-util/src/hooks/useNotifications.ts
@@ -0,0 +1,348 @@
+import {
+ AbsoluteTime,
+ Duration,
+ OperationFail,
+ OperationOk,
+ OperationResult,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { useEffect, useState } from "preact/hooks";
+import { ButtonHandler } from "../components/Button.js";
+import {
+ InternationalizationAPI,
+ memoryMap,
+ useTranslationContext,
+} from "../index.browser.js";
+
+export type NotificationMessage = ErrorNotification | InfoNotification;
+
+export interface ErrorNotification {
+ type: "error";
+ title: TranslatedString;
+ ack?: boolean;
+ timeout?: boolean;
+ description?: TranslatedString;
+ debug?: any;
+ when: AbsoluteTime;
+}
+export interface InfoNotification {
+ type: "info";
+ title: TranslatedString;
+ ack?: boolean;
+ timeout?: boolean;
+ when: AbsoluteTime;
+}
+
+const storage = memoryMap<Map<string, NotificationMessage>>();
+const NOTIFICATION_KEY = "notification";
+
+export const GLOBAL_NOTIFICATION_TIMEOUT = Duration.fromSpec({
+ seconds: 5,
+});
+
+function updateInStorage(n: NotificationMessage) {
+ const h = hash(n);
+ const mem = storage.get(NOTIFICATION_KEY) ?? new Map();
+ const newState = new Map(mem);
+ newState.set(h, n);
+ storage.set(NOTIFICATION_KEY, newState);
+}
+
+export function notify(notif: NotificationMessage): void {
+ const currentState: Map<string, NotificationMessage> =
+ storage.get(NOTIFICATION_KEY) ?? new Map();
+ const newState = currentState.set(hash(notif), notif);
+
+ if (GLOBAL_NOTIFICATION_TIMEOUT.d_ms !== "forever") {
+ setTimeout(() => {
+ notif.timeout = true;
+ updateInStorage(notif);
+ }, GLOBAL_NOTIFICATION_TIMEOUT.d_ms);
+ }
+
+ storage.set(NOTIFICATION_KEY, newState);
+}
+export function notifyError(
+ title: TranslatedString,
+ description: TranslatedString | undefined,
+ debug?: any,
+) {
+ notify({
+ type: "error" as const,
+ title,
+ description,
+ debug,
+ when: AbsoluteTime.now(),
+ });
+}
+export function notifyException(title: TranslatedString, ex: Error) {
+ notify({
+ type: "error" as const,
+ title,
+ description: ex.message as TranslatedString,
+ debug: ex.stack,
+ when: AbsoluteTime.now(),
+ });
+}
+export function notifyInfo(title: TranslatedString) {
+ notify({
+ type: "info" as const,
+ title,
+ when: AbsoluteTime.now(),
+ });
+}
+
+export type Notification = {
+ message: NotificationMessage;
+ acknowledge: () => void;
+};
+
+export function useNotifications(): Notification[] {
+ const [, setLastUpdate] = useState<number>();
+ const value = storage.get(NOTIFICATION_KEY) ?? new Map();
+
+ useEffect(() => {
+ return storage.onUpdate(NOTIFICATION_KEY, () => {
+ setLastUpdate(Date.now())
+ // const mem = storage.get(NOTIFICATION_KEY) ?? new Map();
+ // setter(structuredClone(mem));
+ });
+ });
+
+ return Array.from(value.values()).map((message, idx) => {
+ return {
+ message,
+ acknowledge: () => {
+ message.ack = true;
+ updateInStorage(message);
+ },
+ };
+ });
+}
+
+function hashCode(str: string): string {
+ if (str.length === 0) return "0";
+ let hash = 0;
+ let chr;
+ for (let i = 0; i < str.length; i++) {
+ chr = str.charCodeAt(i);
+ hash = (hash << 5) - hash + chr;
+ hash |= 0; // Convert to 32bit integer
+ }
+ return hash.toString(16);
+}
+
+function hash(msg: NotificationMessage): string {
+ let str = (msg.type + ":" + msg.title) as string;
+ if (msg.type === "error") {
+ if (msg.description) {
+ str += ":" + msg.description;
+ }
+ if (msg.debug) {
+ str += ":" + msg.debug;
+ }
+ }
+ return hashCode(str);
+}
+
+function errorMap<T extends OperationFail<unknown>>(
+ resp: T,
+ map: (d: T["case"]) => TranslatedString,
+): void {
+ notify({
+ type: "error",
+ title: map(resp.case),
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+}
+
+export type ErrorNotificationHandler = (
+ cb: (notify: typeof errorMap) => Promise<void>,
+) => Promise<void>;
+
+/**
+ * @deprecated use useLocalNotificationHandler
+ *
+ * @returns
+ */
+export function useLocalNotification(): [
+ Notification | undefined,
+ (n: NotificationMessage) => void,
+ ErrorNotificationHandler,
+] {
+ const { i18n } = useTranslationContext();
+
+ const [value, setter] = useState<NotificationMessage>();
+ const notif = !value
+ ? undefined
+ : {
+ message: value,
+ acknowledge: () => {
+ setter(undefined);
+ },
+ };
+
+ async function errorHandling(cb: (notify: typeof errorMap) => Promise<void>) {
+ try {
+ return await cb(errorMap);
+ } catch (error: unknown) {
+ if (error instanceof TalerError) {
+ notify(buildUnifiedRequestErrorMessage(i18n, error));
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString,
+ );
+ }
+ }
+ }
+ return [notif, setter, errorHandling];
+}
+
+type HandlerMaker = <T extends OperationResult<A, B>, A, B>(
+ onClick: () => Promise<T | undefined>,
+ onOperationSuccess:
+ | ((result: T extends OperationOk<any> ? T : never) => void)
+ | ((
+ result: T extends OperationOk<any> ? T : never,
+ ) => TranslatedString | undefined),
+ onOperationFail: (
+ d: T extends OperationFail<any> ? T : never,
+ ) => TranslatedString,
+ onOperationComplete?: () => void,
+) => ButtonHandler<T, A, B>;
+
+export function useLocalNotificationHandler(): [
+ Notification | undefined,
+ HandlerMaker,
+ (n: NotificationMessage) => void,
+] {
+ const [value, setter] = useState<NotificationMessage>();
+ const notif = !value
+ ? undefined
+ : {
+ message: value,
+ acknowledge: () => {
+ setter(undefined);
+ },
+ };
+
+ function makeHandler<T extends OperationResult<A, B>, A, B>(
+ onClick: () => Promise<T | undefined>,
+ onOperationSuccess:
+ | ((result: T extends OperationOk<any> ? T : never) => void)
+ | ((
+ result: T extends OperationOk<any> ? T : never,
+ ) => TranslatedString | undefined),
+ onOperationFail: (
+ d: T extends OperationFail<any> ? T : never,
+ ) => TranslatedString,
+ onOperationComplete?: () => void,
+ ): ButtonHandler<T, A, B> {
+ return {
+ onClick,
+ onNotification: setter,
+ onOperationFail,
+ onOperationSuccess,
+ onOperationComplete,
+ };
+ }
+
+ return [notif, makeHandler, setter];
+}
+
+export function buildUnifiedRequestErrorMessage(
+ i18n: InternationalizationAPI,
+ cause: TalerError,
+): ErrorNotification {
+ let result: ErrorNotification;
+ switch (cause.errorDetail.code) {
+ case TalerErrorCode.GENERIC_TIMEOUT: {
+ result = {
+ type: "error",
+ title: i18n.str`Request timeout`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: {
+ result = {
+ type: "error",
+ title: i18n.str`Request cancelled`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: {
+ result = {
+ type: "error",
+ title: i18n.str`Request timeout`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: {
+ result = {
+ type: "error",
+ title: i18n.str`Request throttled`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: {
+ result = {
+ type: "error",
+ title: i18n.str`Malformed response`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ case TalerErrorCode.WALLET_NETWORK_ERROR: {
+ result = {
+ type: "error",
+ title: i18n.str`Network error`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: {
+ result = {
+ type: "error",
+ title: i18n.str`Unexpected request error`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ default: {
+ result = {
+ type: "error",
+ title: i18n.str`Unexpected error`,
+ description: cause.message as TranslatedString,
+ debug: JSON.stringify(cause.errorDetail, undefined, 2),
+ when: AbsoluteTime.now(),
+ };
+ break;
+ }
+ }
+ return result;
+}
diff --git a/packages/web-util/src/index.browser.ts b/packages/web-util/src/index.browser.ts
new file mode 100644
index 000000000..2f3b57b8d
--- /dev/null
+++ b/packages/web-util/src/index.browser.ts
@@ -0,0 +1,10 @@
+export * from "./hooks/index.js";
+export * from "./utils/request.js";
+export * from "./utils/http-impl.browser.js";
+export * from "./utils/http-impl.sw.js";
+export * from "./utils/observable.js";
+export * from "./utils/route.js";
+export * from "./context/index.js";
+export * from "./components/index.js";
+export * from "./forms/index.js";
+export { renderStories, parseGroupImport } from "./stories.js";
diff --git a/packages/web-util/src/index.build.ts b/packages/web-util/src/index.build.ts
new file mode 100644
index 000000000..c0c5fc179
--- /dev/null
+++ b/packages/web-util/src/index.build.ts
@@ -0,0 +1,327 @@
+import esbuild, { PluginBuild } from "esbuild";
+import linaria from "@linaria/esbuild";
+import fs from "fs";
+import path from "path";
+import postcss from "postcss";
+import sass from "sass";
+import postcssrc from "postcss-load-config";
+
+// this should give us the current directory where
+// the project is being built
+const BASE = process.cwd();
+
+type Assets = {
+ base: string;
+ files: string[];
+};
+
+export function getFilesInDirectory(startPath: string, regex?: RegExp): Assets {
+ if (!fs.existsSync(startPath)) {
+ return {
+ base: startPath,
+ files: [],
+ };
+ }
+ const files = fs.readdirSync(startPath);
+ const result = files
+ .flatMap((file) => {
+ const filename = path.join(startPath, file);
+
+ const stat = fs.lstatSync(filename);
+ if (stat.isDirectory()) {
+ return getFilesInDirectory(filename, regex).files;
+ }
+ if (!regex || regex.test(filename)) {
+ return [filename];
+ }
+ return [];
+ })
+ .filter((x) => !!x);
+
+ return {
+ base: startPath,
+ files: result,
+ };
+}
+
+let GIT_ROOT = BASE;
+while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") {
+ GIT_ROOT = path.join(GIT_ROOT, "../");
+}
+if (GIT_ROOT === "/") {
+ // eslint-disable-next-line no-undef
+ console.log("not found");
+ // eslint-disable-next-line no-undef
+ process.exit(1);
+}
+const GIT_HASH = git_hash();
+
+const buf = fs.readFileSync(path.join(BASE, "package.json"));
+let _package = JSON.parse(buf.toString("utf-8"));
+
+function git_hash() {
+ const rev = fs
+ .readFileSync(path.join(GIT_ROOT, ".git", "HEAD"))
+ .toString()
+ .trim()
+ .split(/.*[: ]/)
+ .slice(-1)[0];
+ if (rev.indexOf("/") === -1) {
+ return rev;
+ } else {
+ return fs.readFileSync(path.join(GIT_ROOT, ".git", rev)).toString().trim();
+ }
+}
+
+// FIXME: Put this into some helper library.
+function copyFilesPlugin(assets: Assets | Assets[]) {
+ return {
+ name: "copy-files",
+ setup(build: PluginBuild) {
+ if (!assets || (assets instanceof Array && !assets.length)) {
+ return;
+ }
+ const list = assets instanceof Array ? assets : [assets];
+
+ const outDir = build.initialOptions.outdir;
+ if (outDir === undefined) {
+ throw Error("esbuild build options does not specify outdir");
+ }
+ build.onEnd(() => {
+ list.forEach((as) => {
+ for (const file of as.files) {
+ const destination = path.join(outDir, path.relative(as.base, file));
+ const dirname = path.dirname(destination);
+ if (!fs.existsSync(dirname)) {
+ fs.mkdirSync(dirname, { recursive: true });
+ }
+ fs.copyFileSync(file, destination);
+ }
+ });
+ });
+ },
+ };
+}
+
+const DEFAULT_SASS_FILTER = /\.(s[ac]ss|css)$/;
+
+const sassPlugin: esbuild.Plugin = {
+ name: "custom-build-sass",
+ setup(build) {
+ build.onLoad({ filter: DEFAULT_SASS_FILTER }, ({ path: file }) => {
+ const resolveDir = path.dirname(file);
+ const { css: contents } = sass.compile(file, { loadPaths: ["./"] });
+
+ return {
+ resolveDir,
+ loader: "css",
+ contents,
+ };
+ });
+ },
+};
+
+
+/**
+ * Problem:
+ * No loader is configured for ".node" files: ../../node_modules/.pnpm/fsevents@2.3.3/node_modules/fsevents/fsevents.node
+ *
+ * Reference:
+ * https://github.com/evanw/esbuild/issues/1051#issuecomment-806325487
+ */
+const nativeNodeModulesPlugin: esbuild.Plugin = {
+ name: 'native-node-modules',
+ setup(build) {
+ // If a ".node" file is imported within a module in the "file" namespace, resolve
+ // it to an absolute path and put it into the "node-file" virtual namespace.
+ build.onResolve({ filter: /\.node$/, namespace: 'file' }, args => ({
+ path: require.resolve(args.path, { paths: [args.resolveDir] }),
+ namespace: 'node-file',
+ }))
+
+ // Files in the "node-file" virtual namespace call "require()" on the
+ // path from esbuild of the ".node" file in the output directory.
+ build.onLoad({ filter: /.*/, namespace: 'node-file' }, args => ({
+ contents: `
+ import path from ${JSON.stringify(args.path)}
+ try { module.exports = require(path) }
+ catch {}
+ `,
+ }))
+
+ // If a ".node" file is imported within a module in the "node-file" namespace, put
+ // it in the "file" namespace where esbuild's default loading behavior will handle
+ // it. It is already an absolute path since we resolved it to one above.
+ build.onResolve({ filter: /\.node$/, namespace: 'node-file' }, args => ({
+ path: args.path,
+ namespace: 'file',
+ }))
+
+ // Tell esbuild's default loading behavior to use the "file" loader for
+ // these ".node" files.
+ let opts = build.initialOptions
+ opts.loader = opts.loader || {}
+ opts.loader['.node'] = 'file'
+ },
+}
+
+
+const postCssPlugin: esbuild.Plugin = {
+ name: "custom-build-postcss",
+ setup(build) {
+ build.onLoad({ filter: DEFAULT_SASS_FILTER }, async ({ path: file }) => {
+ const resolveDir = path.dirname(file);
+ const sourceBuffer = fs.readFileSync(file);
+ const source = sourceBuffer.toString("utf-8");
+ const postCssConfig = await postcssrc();
+ postCssConfig.options.from = file;
+
+ const { css: contents } = await postcss(postCssConfig.plugins).process(
+ source,
+ postCssConfig.options,
+ );
+
+ return {
+ resolveDir,
+ loader: "css",
+ contents,
+ };
+ });
+ },
+};
+
+/**
+ * This should be able to load the plugin but but some reason it does not work
+ *
+ * text: "Cannot find module '../plugins/preeval'\n" +
+ *
+ */
+function linariaPlugin() {
+ const linariaCssPlugin: esbuild.Plugin = (linaria as any)({
+ babelOptions: {
+ presets: ["@babel/preset-typescript", "@babel/preset-react", "@linaria"],
+ },
+ sourceMap: true,
+ });
+ return linariaCssPlugin;
+}
+
+const defaultEsBuildConfig: esbuild.BuildOptions = {
+ bundle: true,
+ loader: {
+ ".svg": "file",
+ ".inline.svg": "text",
+ ".png": "dataurl",
+ ".jpeg": "dataurl",
+ ".ttf": "file",
+ ".woff": "file",
+ ".woff2": "file",
+ ".eot": "file",
+ },
+ target: ["es2020"],
+ format: "esm",
+ platform: "browser",
+ jsxFactory: "h",
+ jsxFragment: "Fragment",
+ alias: {
+ react: "preact/compat",
+ "react-dom": "preact/compat",
+ },
+ define: {
+ __VERSION__: `"${_package.version}"`,
+ __GIT_HASH__: `"${GIT_HASH}"`,
+ },
+};
+
+export interface BuildParams {
+ type: "development" | "test" | "production";
+ source: {
+ assets: Assets | Assets[];
+ js: string[];
+ };
+ public?: string;
+ destination: string;
+ css: "sass" | "postcss" | "linaria";
+ linariaPlugin?: () => esbuild.Plugin;
+}
+
+export function computeConfig(params: BuildParams): esbuild.BuildOptions {
+ const plugins: Array<esbuild.Plugin> = [
+ copyFilesPlugin(params.source.assets),
+ ];
+
+ if (params.css) {
+ switch (params.css) {
+ case "sass": {
+ plugins.push(sassPlugin);
+ break;
+ }
+ case "postcss": {
+ plugins.push(postCssPlugin);
+ break;
+ }
+
+ case "linaria": {
+ if (params.linariaPlugin) {
+ plugins.push(params.linariaPlugin());
+ }
+ break;
+ }
+
+ default: {
+ const cssType: never = params.css;
+ throw Error(`not supported: ${cssType}`);
+ }
+ }
+ }
+ if (!params.type) {
+ throw Error(
+ `missing build type, it should be "test", "development" or "production"`,
+ );
+ }
+
+ // if (!params.source.js) {
+ // throw Error(`no javascript entry points, nothing to compile?`);
+ // }
+ if (!params.destination) {
+ throw Error(`missing destination folder`);
+ }
+
+ return {
+ ...defaultEsBuildConfig,
+ entryPoints: params.source.js,
+ publicPath: params.public,
+ outdir: params.destination,
+ minify: false, //params.type === "production",
+ sourcemap: true, //params.type !== "production",
+ define: {
+ ...defaultEsBuildConfig.define,
+ "process.env.NODE_ENV": JSON.stringify(params.type),
+ },
+ plugins,
+ };
+}
+
+/**
+ * Build sources for prod environment
+ */
+export function build(config: BuildParams) {
+ return esbuild.build(computeConfig(config));
+}
+
+const LIVE_RELOAD_SCRIPT =
+ "./node_modules/@gnu-taler/web-util/lib/live-reload.mjs";
+
+/**
+ * Do startup for development environment
+ */
+export function initializeDev(
+ config: BuildParams,
+): () => Promise<esbuild.BuildResult> {
+ function buildDevelopment() {
+ const result = computeConfig(config);
+ result.inject = [LIVE_RELOAD_SCRIPT];
+ return esbuild.build(result);
+ }
+ return buildDevelopment;
+}
diff --git a/packages/web-util/src/index.node.ts b/packages/web-util/src/index.node.ts
new file mode 100644
index 000000000..d5111edf3
--- /dev/null
+++ b/packages/web-util/src/index.node.ts
@@ -0,0 +1 @@
+export { serve } from "./serve.js";
diff --git a/packages/web-util/src/index.testing.ts b/packages/web-util/src/index.testing.ts
new file mode 100644
index 000000000..2349debc5
--- /dev/null
+++ b/packages/web-util/src/index.testing.ts
@@ -0,0 +1,3 @@
+export * from "./tests/hook.js";
+export * from "./tests/swr.js";
+export * from "./tests/mock.js";
diff --git a/packages/web-util/src/keys/ca.crt b/packages/web-util/src/keys/ca.crt
new file mode 100644
index 000000000..d0fd544a6
--- /dev/null
+++ b/packages/web-util/src/keys/ca.crt
@@ -0,0 +1,14 @@
+-----BEGIN CERTIFICATE-----
+MIICODCCAaGgAwIBAgIUH8AY7kGN1yzGEwQOZKeL26ZOQHAwDQYJKoZIhvcNAQEL
+BQAwLjEVMBMGA1UEAwwMbG9jYWxob3N0LWNhMRUwEwYDVQQKDAxsb2NhbGhvc3Qt
+Y2EwHhcNMjIxMTMwMjIwNjAxWhcNMjIxMjMwMjIwNjAxWjAuMRUwEwYDVQQDDAxs
+b2NhbGhvc3QtY2ExFTATBgNVBAoMDGxvY2FsaG9zdC1jYTCBnzANBgkqhkiG9w0B
+AQEFAAOBjQAwgYkCgYEAo2gw/oYcKxrSeDbVTTFX8pZA8fojGMwcQlSmeYMUrhtn
++PkXEvCTyMWcreLg2Y4sgdOjvK0ZM7OXnf/jx4fDiMpGy5BHT2ZJRWPzSh6UmNUy
+kyeRAkDB3gCyQSHmmL1rEFOuwmq1yoT0FlIyTQ+mWrs5yg7QTe1rRyFWXHIt1TMC
+AwEAAaNTMFEwHQYDVR0OBBYEFO1Op1KRMkVkzadGy2TZFQlwG9FFMB8GA1UdIwQY
+MBaAFO1Op1KRMkVkzadGy2TZFQlwG9FFMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
+hvcNAQELBQADgYEAIdePTdDsD8IBFfHze9YVU+VZg3aNO5F/6QJPy/8InejQU0V8
+9Cod19SEh3Kdlpa4QLvZH1cX+ac7bvhL0JaZg0dsz8UaZ8xrkEPx6JJAwgCiv/Ir
+YqhoRd4fv/c6/B0yqD4Dhoy/jGkxfvc8XDnAuAP0uRttGwvsvHS9cSkHYFo=
+-----END CERTIFICATE-----
diff --git a/packages/web-util/src/keys/ca.key b/packages/web-util/src/keys/ca.key
new file mode 100644
index 000000000..8699ccb10
--- /dev/null
+++ b/packages/web-util/src/keys/ca.key
@@ -0,0 +1,16 @@
+-----BEGIN PRIVATE KEY-----
+MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAKNoMP6GHCsa0ng2
+1U0xV/KWQPH6IxjMHEJUpnmDFK4bZ/j5FxLwk8jFnK3i4NmOLIHTo7ytGTOzl53/
+48eHw4jKRsuQR09mSUVj80oelJjVMpMnkQJAwd4AskEh5pi9axBTrsJqtcqE9BZS
+Mk0Pplq7OcoO0E3ta0chVlxyLdUzAgMBAAECgYABkiDWcYeXynw3d595TH4h8NvS
+96qatGuZH6MyC9aJDe5j8FEOd42UIoItEb9DmCBJZzVtvOQ/IPzWIf2Yj2+LvydI
+qEA6ucroa9F9KG9T9ywNJfqM8fNzARQEAzK4/PglbT+n27hkNIm35BOA8PIUuBiD
+pT6D0L0LHfNs6NkRAQJBAM9RS9ApnRmo4qV8kNJvysBJ/NO8PdLT47XIA2uPaAAT
+O9NjrxGHaP0is+PIuwgTi9T5lyprpQss2yS9O7rN5PMCQQDJx0CMjkPDbelbWeH2
+nOvyxLLCev69ae6zVrMPcE7vRPohlJTSK/kgouLr0F6lomK9HVugD7VgrQHuj9am
+UV7BAkBhCHnlejSvl95M+lqGRBCvo3GUYJzHGqmPoYgIRdy1fEsaC6QbHjfDkwSD
+bqYrh4qBKjjYf/2Fl38SWQelzUyFAkBoht27cl9MN/3xIsjZ1kSsiJUKBmk8ekn7
+gWhVERry/EqPZscJcVonO/pNqq29JDf+O90hN8IACN+9U6ogknqBAkAr3SowHLyD
+LfTrEDxeoAd2+K7gGKyrK3gyIISbuWtluONNPqenuFFHXxehwJ72VplNkpUZP4Bt
+TQcIW9zIYT5r
+-----END PRIVATE KEY-----
diff --git a/packages/web-util/src/keys/ca.srl b/packages/web-util/src/keys/ca.srl
new file mode 100644
index 000000000..a53ff9b36
--- /dev/null
+++ b/packages/web-util/src/keys/ca.srl
@@ -0,0 +1 @@
+7488FC4F9D5E2BB55DEA16CF051F4E99ACA25241
diff --git a/packages/web-util/src/keys/localhost.crt b/packages/web-util/src/keys/localhost.crt
new file mode 100644
index 000000000..e32f2e24a
--- /dev/null
+++ b/packages/web-util/src/keys/localhost.crt
@@ -0,0 +1,15 @@
+-----BEGIN CERTIFICATE-----
+MIICRTCCAa6gAwIBAgIUdIj8T51eK7Vd6hbPBR9OmayiUkEwDQYJKoZIhvcNAQEL
+BQAwLjEVMBMGA1UEAwwMbG9jYWxob3N0LWNhMRUwEwYDVQQKDAxsb2NhbGhvc3Qt
+Y2EwHhcNMjIxMTMwMjIwNjAyWhcNMjMxMTMwMjIwNjAyWjArMRIwEAYDVQQDDAls
+b2NhbGhvc3QxFTATBgNVBAoMDGxvY2FsaG9zdC1jYTCBnzANBgkqhkiG9w0BAQEF
+AAOBjQAwgYkCgYEAvir90pl9q6qUsBsBz7jjdw0r1DDPeViqkSyDDTt4Lw2zYJbn
+Z7kxcTvNHNRWdtsWSzY/43ERCJu6nX60kMiML3NV00ty2VpaYeW9J5ozXgNbb+5P
+esLHrIHmnOIUj46jyiHjDKs+hgrfcrFg7W7ndjW3dCAvkeAV+mncz59pFvkCAwEA
+AaNjMGEwCQYDVR0TBAIwADAdBgNVHQ4EFgQUXADNSPivlIUBpKyd/XirIcqxqFgw
+HwYDVR0jBBgwFoAU7U6nUpEyRWTNp0bLZNkVCXAb0UUwFAYDVR0RBA0wC4IJbG9j
+YWxob3N0MA0GCSqGSIb3DQEBCwUAA4GBAClcLuKFnRJjAgP8652jJscYMLWYEkv3
+j9kChErpKZNKiv+VlWKPiOvhZVAl+/YEsBOKXpRFX3CuLCdGtuv7b6NaH7yEXaZn
+9MVIrYMRub3k0gVAhu3z3VXuvHFXdTms3KRlGdPdQV2xgpQJczDNnd7idp/GyI4j
+KqBo0UxuWZBJ
+-----END CERTIFICATE-----
diff --git a/packages/web-util/src/keys/localhost.csr b/packages/web-util/src/keys/localhost.csr
new file mode 100644
index 000000000..5f821f8b5
--- /dev/null
+++ b/packages/web-util/src/keys/localhost.csr
@@ -0,0 +1,10 @@
+-----BEGIN CERTIFICATE REQUEST-----
+MIIBajCB1AIBADArMRIwEAYDVQQDDAlsb2NhbGhvc3QxFTATBgNVBAoMDGxvY2Fs
+aG9zdC1jYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAvir90pl9q6qUsBsB
+z7jjdw0r1DDPeViqkSyDDTt4Lw2zYJbnZ7kxcTvNHNRWdtsWSzY/43ERCJu6nX60
+kMiML3NV00ty2VpaYeW9J5ozXgNbb+5PesLHrIHmnOIUj46jyiHjDKs+hgrfcrFg
+7W7ndjW3dCAvkeAV+mncz59pFvkCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4GBADJW
+Ww+l4E///54fz82AE5x8U114Yk32EbB1qOfGLyXgoXySGyLuiNu40SXxioKa/Gpn
+Z92o5JIrMVWUroPzMKAMXdAsixkaBGrT5RYzR9ztfy59djxp0f7dlL3ZxDO8JHOw
+aTJXJxKEfYdv0oFhkx/u4ki6BsaqG9mQfsFXtlUp
+-----END CERTIFICATE REQUEST-----
diff --git a/packages/web-util/src/keys/localhost.key b/packages/web-util/src/keys/localhost.key
new file mode 100644
index 000000000..c9b1cb6c8
--- /dev/null
+++ b/packages/web-util/src/keys/localhost.key
@@ -0,0 +1,16 @@
+-----BEGIN PRIVATE KEY-----
+MIICeQIBADANBgkqhkiG9w0BAQEFAASCAmMwggJfAgEAAoGBAL4q/dKZfauqlLAb
+Ac+443cNK9Qwz3lYqpEsgw07eC8Ns2CW52e5MXE7zRzUVnbbFks2P+NxEQibup1+
+tJDIjC9zVdNLctlaWmHlvSeaM14DW2/uT3rCx6yB5pziFI+Oo8oh4wyrPoYK33Kx
+YO1u53Y1t3QgL5HgFfpp3M+faRb5AgMBAAECgYEAh1xgqdxZqKzWA3hl1K7dMmus
+q/BGbjCf0JAnhG61QID3EqS3eIxI1jnj6UZ3eUi/WK/3z/Q2VLNMpTiAXKJzrUP0
+8m7yO87AeUxhy0rvtWEVmd8NBQjJKD2iElgy6tR9QUsgTXer9xuQf0sHRQb1psNU
+11WsBnwdzeEEzquORVUCQQDtJx/HjHDVTDF02W5B23J4oqwuu1EDCVDqNJiYSDSt
+2Dh0IdvSKJyh9lXIoY+kbbEui8uPPnhPKM1LIRfiv7FHAkEAzUf1mvTBNUGCwjZu
+qy/oKDR7TlEbdyDJY1F0JPquyim/CenRtM8VAH22Tni8+bSSpnHknytvKfaC0YFb
+VN8VvwJBAKTdJgKbZ3Vg2qDY5wVxgUrMC9cQ8Wii+VVX6x0yVSzlu5lAUIjxIrKV
+hV1Ms4cjmqE5HfIfA5REUTOBdhF0IdECQQC/1lia19Ha7/6/eljP17RQJkN5O+i7
+2kL5crxkdnRz7rFeFUlpfAB3dgOxr7mCbZKCw3rQmKmJAJreKNHuLZBHAkEAwYZ4
+tc4mWjtw4AMDK59o8d8ANObyuVaIy6I54NZ0ogg+0nzrXii9LkZZhAWwVSN9BdXa
+TYVu0J5fGxDZVAm0zQ==
+-----END PRIVATE KEY-----
diff --git a/packages/web-util/src/live-reload.ts b/packages/web-util/src/live-reload.ts
new file mode 100644
index 000000000..cd3a7540d
--- /dev/null
+++ b/packages/web-util/src/live-reload.ts
@@ -0,0 +1,81 @@
+/* eslint-disable no-undef */
+
+function setupLiveReload(): void {
+ const stopWs = localStorage.getItem("stop-ws")
+ if (!!stopWs) return;
+ const protocol = window.location.protocol === "http:" ? "ws:" : "wss:";
+ const ws = new WebSocket(`${protocol}//${window.location.hostname}:${window.location.port}/ws`);
+
+ ws.addEventListener("message", (message) => {
+ try {
+ const event = JSON.parse(message.data);
+ if (event.type === "file-updated-start") {
+ showReloadOverlay();
+ return;
+ }
+ if (event.type === "file-updated-done") {
+ window.location.reload();
+ return;
+ }
+ if (event.type === "file-updated-failed") {
+ const h1 = document.getElementById("overlay-text");
+ if (h1) {
+ h1.innerHTML = "compilation failed";
+ h1.style.color = "red";
+ h1.style.margin = "";
+ }
+ const div = document.getElementById("overlay");
+ if (div) {
+ const content = JSON.stringify(event.data, undefined, 2);
+ const pre = document.createElement("pre");
+ pre.id = "error-text";
+ pre.style.margin = "";
+ pre.textContent = content;
+ div.style.backgroundColor = "rgba(0,0,0,0.8)";
+ div.style.flexDirection = "column";
+ div.appendChild(pre);
+ }
+ console.error(event.data.error);
+ return;
+ }
+ if (event.type === "file-updated") {
+ window.location.reload();
+ return;
+ }
+ } catch (e) {
+ return;
+ }
+ console.log("unsupported", message);
+ });
+
+ ws.addEventListener("error", (error) => {
+ console.error(error);
+ });
+ ws.addEventListener("close", (message) => {
+ setTimeout(setupLiveReload, 1500);
+ });
+}
+setupLiveReload();
+
+function showReloadOverlay(): void {
+ const d = document.createElement("div");
+ d.id = "overlay";
+ d.style.position = "absolute";
+ d.style.width = "100%";
+ d.style.height = "100%";
+ d.style.color = "white";
+ d.style.backgroundColor = "rgba(0,0,0,0.5)";
+ d.style.display = "flex";
+ d.style.zIndex = String(Number.MAX_SAFE_INTEGER);
+ d.style.justifyContent = "center";
+ const h = document.createElement("h1");
+ h.id = "overlay-text";
+ h.style.margin = "auto";
+ h.innerHTML = "reloading...";
+ d.appendChild(h);
+ if (document.body.firstChild) {
+ document.body.insertBefore(d, document.body.firstChild);
+ } else {
+ document.body.appendChild(d);
+ }
+}
diff --git a/packages/web-util/src/serve.ts b/packages/web-util/src/serve.ts
new file mode 100644
index 000000000..1daea15bf
--- /dev/null
+++ b/packages/web-util/src/serve.ts
@@ -0,0 +1,133 @@
+import { Logger } from "@gnu-taler/taler-util";
+import chokidar from "chokidar";
+import express from "express";
+import https from "https";
+import http from "http";
+import { parse } from "url";
+import WebSocket from "ws";
+
+import locahostCrt from "./keys/localhost.crt";
+import locahostKey from "./keys/localhost.key";
+import storiesHtml from "./stories.html";
+
+import path from "path";
+
+const httpServerOptions = {
+ key: locahostKey,
+ cert: locahostCrt,
+};
+
+const logger = new Logger("serve.ts");
+
+const PATHS = {
+ WS: "/ws",
+ EXAMPLE: "/examples",
+ APP: "/app",
+};
+
+export async function serve(opts: {
+ folder: string;
+ port: number;
+ source?: string;
+ tls?: boolean;
+ examplesLocationJs?: string;
+ examplesLocationCss?: string;
+ onSourceUpdate?: () => Promise<void>;
+}): Promise<void> {
+ const app = express();
+
+ app.use(PATHS.APP, express.static(opts.folder));
+
+ const httpServer = http.createServer(app);
+ const httpPort = opts.port;
+ let httpsServer: typeof httpServer | undefined;
+ let httpsPort: number | undefined;
+ const servers = [httpServer];
+ if (opts.tls) {
+ httpsServer = https.createServer(httpServerOptions, app);
+ httpsPort = opts.port + 1;
+ servers.push(httpsServer)
+ }
+
+ logger.info(`Dev server. Endpoints:`);
+ logger.info(` ${PATHS.APP}: where root application can be tested`);
+ logger.info(` ${PATHS.EXAMPLE}: where examples can be found and browse`);
+ logger.info(` ${PATHS.WS}: websocket for live reloading`);
+
+ const wss = new WebSocket.Server({ noServer: true });
+
+ wss.on("connection", function connection(ws) {
+ ws.send("welcome");
+ });
+
+ servers.forEach(function addWSHandler(server) {
+ server.on("upgrade", function upgrade(request, socket, head) {
+ const { pathname } = parse(request.url || "");
+ if (pathname === PATHS.WS) {
+ wss.handleUpgrade(request, socket, head, function done(ws) {
+ wss.emit("connection", ws, request);
+ });
+ } else {
+ socket.destroy();
+ }
+ });
+ });
+
+ const sendToAllClients = function (data: object): void {
+ wss.clients.forEach(function each(client) {
+ if (client.readyState === WebSocket.OPEN) {
+ client.send(JSON.stringify(data));
+ }
+ });
+ };
+ const watchingFolder = opts.source ?? opts.folder;
+ logger.info(`watching ${watchingFolder} for changes`);
+
+ chokidar.watch(watchingFolder).on("change", (path, stats) => {
+ logger.info(`changed: ${path}`);
+
+ if (opts.onSourceUpdate) {
+ sendToAllClients({ type: "file-updated-start", data: { path } });
+ opts
+ .onSourceUpdate()
+ .then((result) => {
+ sendToAllClients({
+ type: "file-updated-done",
+ data: { path, result },
+ });
+ })
+ .catch((error) => {
+ sendToAllClients({
+ type: "file-updated-failed",
+ data: { path, error: JSON.stringify(error) },
+ });
+ });
+ } else {
+ sendToAllClients({ type: "file-change", data: { path } });
+ }
+ });
+
+ if (opts.onSourceUpdate) opts.onSourceUpdate();
+
+ app.get(PATHS.EXAMPLE, function (req: any, res: any) {
+ res.set("Content-Type", "text/html");
+ res.send(
+ storiesHtml
+ .replace(
+ "__EXAMPLES_JS_FILE_LOCATION__",
+ opts.examplesLocationJs ?? `.${PATHS.APP}/stories.js`,
+ )
+ .replace(
+ "__EXAMPLES_CSS_FILE_LOCATION__",
+ opts.examplesLocationCss ?? `.${PATHS.APP}/stories.css`,
+ ),
+ );
+ });
+
+ logger.info(`Serving ${opts.folder} on ${httpPort}: plain HTTP`);
+ httpServer.listen(httpPort);
+ if (httpsServer !== undefined) {
+ logger.info(`Serving ${opts.folder} on ${httpsPort}: HTTP + TLS`);
+ httpsServer.listen(httpsPort);
+ }
+}
diff --git a/packages/web-util/src/stories.html b/packages/web-util/src/stories.html
new file mode 100644
index 000000000..b4a36fc19
--- /dev/null
+++ b/packages/web-util/src/stories.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>WebUtils: Stories</title>
+ <meta charset="utf-8" />
+ <link
+ rel="icon"
+ href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
+ />
+ <link
+ rel="stylesheet"
+ type="text/css"
+ href="__EXAMPLES_CSS_FILE_LOCATION__"
+ />
+ <script type="module" src="__EXAMPLES_JS_FILE_LOCATION__"></script>
+ </head>
+ <body>
+ <taler-stories id="container"></taler-stories>
+ </body>
+</html>
diff --git a/packages/web-util/src/stories.tsx b/packages/web-util/src/stories.tsx
new file mode 100644
index 000000000..d9c2406eb
--- /dev/null
+++ b/packages/web-util/src/stories.tsx
@@ -0,0 +1,578 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+import { setupI18n } from "@gnu-taler/taler-util";
+import {
+ ComponentChild,
+ ComponentChildren,
+ Fragment,
+ FunctionalComponent,
+ FunctionComponent,
+ h,
+ JSX,
+ render,
+ VNode,
+} from "preact";
+import { useEffect, useErrorBoundary, useState } from "preact/hooks";
+import { ExampleItemSetup } from "./tests/hook.js";
+
+const Page: FunctionalComponent = ({ children }): VNode => {
+ return (
+ <div
+ style={{
+ fontFamily: "Arial, Helvetica, sans-serif",
+ width: "100%",
+ display: "flex",
+ flexDirection: "row",
+ }}
+ >
+ {children}
+ </div>
+ );
+};
+
+const SideBar: FunctionalComponent<{ width: number }> = ({
+ width,
+ children,
+}): VNode => {
+ return (
+ <div
+ style={{
+ minWidth: width,
+ height: "calc(100vh - 20px)",
+ overflowX: "hidden",
+ overflowY: "visible",
+ scrollBehavior: "smooth",
+ }}
+ >
+ {children}
+ </div>
+ );
+};
+
+const ResizeHandleDiv: FunctionalComponent<
+ JSX.HTMLAttributes<HTMLDivElement>
+> = ({ children, ...props }): VNode => {
+ return (
+ <div
+ {...props}
+ style={{
+ width: 10,
+ backgroundColor: "#ddd",
+ cursor: "ew-resize",
+ }}
+ >
+ {children}
+ </div>
+ );
+};
+
+const Content: FunctionalComponent = ({ children }): VNode => {
+ return (
+ <div
+ style={{
+ width: "100%",
+ padding: 20,
+ }}
+ >
+ {children}
+ </div>
+ );
+};
+
+function findByGroupComponentName(
+ allExamples: Group[],
+ group: string,
+ component: string,
+ name: string,
+): ExampleItem | undefined {
+ const gl = allExamples.filter((e) => e.title === group);
+ if (gl.length === 0) {
+ return undefined;
+ }
+ const cl = gl[0].list.filter((l) => l.name === component);
+ if (cl.length === 0) {
+ return undefined;
+ }
+ const el = cl[0].examples.filter((c) => c.name === name);
+ if (el.length === 0) {
+ return undefined;
+ }
+ return el[0];
+}
+
+function getContentForExample(
+ item: ExampleItem | undefined,
+ allExamples: Group[],
+): FunctionalComponent {
+ if (!item)
+ return function SelectExampleMessage() {
+ return <div>select example from the list on the left</div>;
+ };
+ const example = findByGroupComponentName(
+ allExamples,
+ item.group,
+ item.component,
+ item.name,
+ );
+ if (!example) {
+ return function ExampleNotFoundMessage() {
+ return <div>example not found</div>;
+ };
+ }
+ return () => example.render.component(example.render.props);
+}
+
+function ExampleList({
+ name,
+ list,
+ selected,
+ onSelectStory,
+}: {
+ name: string;
+ list: {
+ name: string;
+ examples: ExampleItem[];
+ }[];
+ selected: ExampleItem | undefined;
+ onSelectStory: (i: ExampleItem, id: string) => void;
+}): VNode {
+ const [isOpen, setOpen] = useState(selected && selected.group === name);
+ return (
+ <ol style={{ padding: 4, margin: 0 }}>
+ <div
+ style={{ backgroundColor: "lightcoral", cursor: "pointer" }}
+ onClick={() => setOpen(!isOpen)}
+ >
+ {name}
+ </div>
+ <div style={{ display: isOpen ? undefined : "none" }}>
+ {list.map((k) => (
+ <li key={k.name}>
+ <dl style={{ margin: 0 }}>
+ <dt>{k.name}</dt>
+ {k.examples.map((r, i) => {
+ const e = encodeURIComponent;
+ const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`;
+ const isSelected =
+ selected &&
+ selected.component === r.component &&
+ selected.group === r.group &&
+ selected.name === r.name;
+ return (
+ <dd
+ id={eId}
+ key={r.name}
+ style={{
+ backgroundColor: isSelected
+ ? "green"
+ : i % 2
+ ? "lightgray"
+ : "lightblue",
+ marginLeft: "1em",
+ padding: 4,
+ cursor: "pointer",
+ borderRadius: 4,
+ marginBottom: 4,
+ }}
+ >
+ <a
+ href={`#${eId}`}
+ style={{ color: "black" }}
+ onClick={(e) => {
+ e.preventDefault();
+ location.hash = `#${eId}`;
+ onSelectStory(r, eId);
+ history.pushState({}, "", `#${eId}`);
+ }}
+ >
+ {r.name}
+ </a>
+ </dd>
+ );
+ })}
+ </dl>
+ </li>
+ ))}
+ </div>
+ </ol>
+ );
+}
+
+/**
+ * Prevents the UI from redirecting and inform the dev
+ * where the <a /> should have redirected
+ * @returns
+ */
+function PreventLinkNavigation({
+ children,
+}: {
+ children: ComponentChildren;
+}): VNode {
+ return (
+ <div
+ onClick={(e) => {
+ let t: any = e.target;
+ do {
+ if (t.localName === "a" && t.getAttribute("href")) {
+ alert(`should navigate to: ${t.attributes.href.value}`);
+ e.stopImmediatePropagation();
+ e.stopPropagation();
+ e.preventDefault();
+ return false;
+ }
+ } while ((t = t.parentNode));
+ return true;
+ }}
+ >
+ {children}
+ </div>
+ );
+}
+
+function ErrorReport({
+ children,
+ selected,
+}: {
+ children: ComponentChild;
+ selected: ExampleItem | undefined;
+}): VNode {
+ const [error, resetError] = useErrorBoundary();
+ //if there is an error, reset when unloading this component
+ useEffect(() => (error ? resetError : undefined));
+ if (error) {
+ return (
+ <div>
+ <p>Error was thrown trying to render</p>
+ {selected && (
+ <ul>
+ <li>
+ <b>group</b>: {selected.group}
+ </li>
+ <li>
+ <b>component</b>: {selected.component}
+ </li>
+ <li>
+ <b>example</b>: {selected.name}
+ </li>
+ <li>
+ <b>args</b>:{" "}
+ <pre>{JSON.stringify(selected.render.props, undefined, 2)}</pre>
+ </li>
+ </ul>
+ )}
+ <p>{error.message}</p>
+ <pre>{error.stack}</pre>
+ </div>
+ );
+ }
+ return <Fragment>{children}</Fragment>;
+}
+
+function getSelectionFromLocationHash(
+ hash: string,
+ allExamples: Group[],
+): ExampleItem | undefined {
+ if (!hash) return undefined;
+ const parts = hash.substring(1).split("-");
+ if (parts.length < 3) return undefined;
+ return findByGroupComponentName(
+ allExamples,
+ decodeURIComponent(parts[0]),
+ decodeURIComponent(parts[1]),
+ decodeURIComponent(parts[2]),
+ );
+}
+
+function parseExampleImport(
+ group: string,
+ componentName: string,
+ im: MaybeComponent,
+): ComponentItem {
+ const examples: ExampleItem[] = Object.entries(im)
+ .filter(([k]) => k !== "default")
+ .map(([exampleName, exampleValue]): ExampleItem => {
+ if (!exampleValue) {
+ throw Error(
+ `example "${exampleName}" from component "${componentName}" in group "${group}" is undefined`,
+ );
+ }
+
+ if (typeof exampleValue === "function") {
+ return {
+ group,
+ component: componentName,
+ name: exampleName,
+ render: {
+ component: exampleValue as FunctionComponent,
+ props: {},
+ contextProps: {},
+ },
+ };
+ }
+ const v: any = exampleValue;
+ if (
+ "component" in v &&
+ typeof v.component === "function" &&
+ "props" in v
+ ) {
+ return {
+ group,
+ component: componentName,
+ name: exampleName,
+ render: v,
+ };
+ }
+ throw Error(
+ `example "${exampleName}" from component "${componentName}" in group "${group}" doesn't follow one of the two ways of example`,
+ );
+ });
+ return {
+ name: componentName,
+ examples,
+ };
+}
+
+export function parseGroupImport(
+ groups: Record<string, ComponentOrFolder>,
+): Group[] {
+ return Object.entries(groups).map(([groupName, value]) => {
+ return {
+ title: groupName,
+ list: Object.entries(value).flatMap(([key, value]) =>
+ folder(groupName, value),
+ ),
+ };
+ });
+}
+
+export interface Group {
+ title: string;
+ list: ComponentItem[];
+}
+
+export interface ComponentItem<Props extends object = {}> {
+ name: string;
+ examples: ExampleItem<Props>[];
+}
+
+export interface ExampleItem<Props extends object = {}> {
+ group: string;
+ component: string;
+ name: string;
+ render: ExampleItemSetup<Props>;
+}
+
+type ComponentOrFolder = MaybeComponent | MaybeFolder;
+interface MaybeFolder {
+ default?: { title: string };
+ // [exampleName: string]: FunctionalComponent;
+}
+interface MaybeComponent {
+ // default?: undefined;
+ [exampleName: string]: undefined | object;
+}
+
+function folder(groupName: string, value: ComponentOrFolder): ComponentItem[] {
+ let title: string | undefined = undefined;
+ try {
+ title =
+ typeof value === "object" &&
+ typeof value.default === "object" &&
+ value.default !== undefined &&
+ "title" in value.default &&
+ typeof value.default.title === "string"
+ ? value.default.title
+ : undefined;
+ } catch (e) {
+ throw Error(
+ `Could not defined if it is component or folder ${groupName}: ${JSON.stringify(
+ value,
+ undefined,
+ 2,
+ )}`,
+ );
+ }
+ if (title) {
+ const c = parseExampleImport(groupName, title, value as MaybeComponent);
+ return [c];
+ }
+ return Object.entries(value).flatMap(([subkey, value]) =>
+ folder(groupName, value),
+ );
+}
+
+interface Props {
+ getWrapperForGroup: (name: string) => FunctionComponent;
+ examplesInGroups: Group[];
+ langs: Record<string, object>;
+}
+
+function Application({
+ langs,
+ examplesInGroups,
+ getWrapperForGroup,
+}: Props): VNode {
+ const url = new URL(window.location.href);
+ const initialSelection = getSelectionFromLocationHash(
+ url.hash,
+ examplesInGroups,
+ );
+
+ const currentLang = url.searchParams.get("lang") || "en";
+
+ if (!langs["en"]) {
+ langs["en"] = {};
+ }
+ setupI18n(currentLang, langs);
+
+ const [selected, updateSelected] = useState<ExampleItem | undefined>(
+ initialSelection,
+ );
+ const [sidebarWidth, setSidebarWidth] = useState(200);
+ useEffect(() => {
+ if (url.hash) {
+ const hash = url.hash.substring(1);
+ const found = document.getElementById(hash);
+ if (found) {
+ setTimeout(() => {
+ found.scrollIntoView({
+ block: "center",
+ });
+ }, 50);
+ }
+ }
+ }, []);
+
+ const GroupWrapper = getWrapperForGroup(selected?.group || "default");
+ const ExampleContent = getContentForExample(selected, examplesInGroups);
+
+ //style={{ "--with-size": `${sidebarWidth}px` }}
+ return (
+ <Page>
+ {/* <LiveReload /> */}
+ <SideBar width={sidebarWidth}>
+ <div>
+ Language:
+ <select
+ value={currentLang}
+ onChange={(e) => {
+ const url = new URL(window.location.href);
+ url.searchParams.set("lang", e.currentTarget.value);
+ window.location.href = url.href;
+ }}
+ >
+ {Object.keys(langs).map((l) => (
+ <option key={l}>{l}</option>
+ ))}
+ </select>
+ </div>
+ {examplesInGroups.map((group) => (
+ <ExampleList
+ key={group.title}
+ name={group.title}
+ list={group.list}
+ selected={selected}
+ onSelectStory={(item, htmlId) => {
+ document.getElementById(htmlId)?.scrollIntoView({
+ block: "center",
+ });
+ updateSelected(item);
+ }}
+ />
+ ))}
+ <hr />
+ </SideBar>
+ {/* <ResizeHandle
+ onUpdate={(x) => {
+ setSidebarWidth((s) => s + x);
+ }}
+ /> */}
+ <Content>
+ <ErrorReport selected={selected}>
+ <PreventLinkNavigation>
+ <GroupWrapper>
+ <ExampleContent />
+ </GroupWrapper>
+ </PreventLinkNavigation>
+ </ErrorReport>
+ </Content>
+ </Page>
+ );
+}
+
+export interface Options {
+ id?: string;
+ strings?: any;
+ getWrapperForGroup?: (name: string) => FunctionComponent;
+}
+
+export function renderStories(
+ groups: Record<string, ComponentOrFolder>,
+ options: Options = {},
+): void {
+ const examples = parseGroupImport(groups);
+
+ try {
+ const cid = options.id ?? "container";
+ const container = document.getElementById(cid);
+ if (!container) {
+ throw Error(
+ `container with id ${cid} not found, can't mount page contents`,
+ );
+ }
+ render(
+ <Application
+ examplesInGroups={examples}
+ getWrapperForGroup={options.getWrapperForGroup ?? (() => Fragment)}
+ langs={options.strings ?? { en: {} }}
+ />,
+ container,
+ );
+ } catch (e) {
+ console.error("got error", e);
+ if (e instanceof Error) {
+ document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`;
+ }
+ }
+}
+
+function ResizeHandle({ onUpdate }: { onUpdate: (x: number) => void }): VNode {
+ const [start, setStart] = useState<number | undefined>(undefined);
+ return (
+ <ResizeHandleDiv
+ onMouseDown={(e: any) => {
+ setStart(e.pageX);
+ console.log("active", e.pageX);
+ return false;
+ }}
+ onMouseMove={(e: any) => {
+ if (start !== undefined) {
+ onUpdate(e.pageX - start);
+ }
+ return false;
+ }}
+ onMouseUp={() => {
+ setStart(undefined);
+ return false;
+ }}
+ />
+ );
+}
diff --git a/packages/web-util/src/tests/hook.ts b/packages/web-util/src/tests/hook.ts
new file mode 100644
index 000000000..59f17ba8d
--- /dev/null
+++ b/packages/web-util/src/tests/hook.ts
@@ -0,0 +1,325 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Fragment,
+ FunctionComponent,
+ FunctionalComponent,
+ VNode,
+ h as create,
+ options,
+ render as renderIntoDom,
+} from "preact";
+import { render as renderToString } from "preact-render-to-string";
+
+// This library is expected to be included in testing environment only
+// When doing tests we want the requestAnimationFrame to be as fast as possible.
+// without this option the RAF will timeout after 100ms making the tests slower
+options.requestAnimationFrame = (fn: () => void) => {
+ return fn();
+};
+
+export type ExampleItemSetup<Props extends object = {}> = {
+ component: FunctionalComponent<Props>;
+ props: Props;
+ contextProps: object;
+};
+
+/**
+ *
+ * @param Component component to be tested
+ * @param props allow partial props for easier example setup
+ * @param contextProps if the context requires params for this example
+ * @returns
+ */
+export function createExample<T extends object, Props extends object>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props> | (() => Partial<Props>),
+ contextProps?: T | (() => T),
+): ExampleItemSetup<Props> {
+ const evaluatedProps = typeof props === "function" ? props() : props;
+ const Render = (args: any): VNode => create(Component, args);
+ const evaluatedContextProps =
+ typeof contextProps === "function" ? contextProps() : contextProps;
+ return {
+ component: Render,
+ props: evaluatedProps as Props,
+ contextProps: !evaluatedContextProps ? {} : evaluatedContextProps,
+ };
+}
+
+/**
+ * Should render HTML on node and browser
+ * Browser: mount update and unmount
+ * Node: render to string
+ *
+ * @param Component
+ * @param args
+ */
+export function renderUI(example: ExampleItemSetup<any>, Context?: any): void {
+ const vdom = !Context
+ ? create(example.component, example.props)
+ : create(Context, {
+ ...example.contextProps,
+ children: [create(example.component, example.props)],
+ });
+
+ if (typeof window === "undefined") {
+ renderToString(vdom);
+ } else {
+ const div = document.createElement("div");
+ document.body.appendChild(div);
+ renderIntoDom(vdom, div);
+ renderIntoDom(null, div);
+ document.body.removeChild(div);
+ }
+}
+
+/**
+ * No need to render.
+ * Should mount, update and run effects.
+ *
+ * Browser: mount update and unmount
+ * Node: mount on a mock virtual dom
+ *
+ * Mounting hook doesn't use DOM api so is
+ * safe to use normal mounting api in node
+ *
+ * @param Component
+ * @param props
+ * @param Context
+ */
+function renderHook(
+ Component: FunctionComponent,
+ Context?: ({ children }: { children: any }) => VNode | null,
+): void {
+ const vdom = !Context
+ ? create(Component, {})
+ : create(Context, { children: [create(Component, {})] });
+
+ //use normal mounting API since we expect
+ //useEffect to be called ( and similar APIs )
+ renderIntoDom(vdom, {} as Element);
+}
+
+type RecursiveState<S> = S | (() => RecursiveState<S>);
+
+interface Mounted<T> {
+ pullLastResultOrThrow: () => Exclude<T, VoidFunction>;
+ assertNoPendingUpdate: () => Promise<boolean>;
+ waitForStateUpdate: () => Promise<boolean>;
+}
+
+/**
+ * Manual API mount the hook and return testing API
+ * Consider using hookBehaveLikeThis() function
+ *
+ * @param hookToBeTested
+ * @param Context
+ *
+ * @returns testing API
+ */
+function mountHook<T extends object>(
+ hookToBeTested: () => RecursiveState<T>,
+ Context?: ({ children }: { children: any }) => VNode | null,
+): Mounted<T> {
+ let lastResult: Exclude<T, VoidFunction> | Error | null = null;
+
+ const listener: Array<() => void> = [];
+
+ // component that's going to hold the hook
+ function Component(): VNode {
+ try {
+ let componentOrResult = hookToBeTested();
+
+ // special loop
+ // since Taler use a special type of hook that can return
+ // a function and it will be treated as a composed component
+ // then tests should be aware of it and reproduce the same behavior
+ while (typeof componentOrResult === "function") {
+ componentOrResult = componentOrResult();
+ }
+ //typecheck fails here
+ const l: Exclude<T, () => void> = componentOrResult as any;
+ lastResult = l;
+ } catch (e) {
+ if (e instanceof Error) {
+ lastResult = e;
+ } else {
+ lastResult = new Error(`mounting the hook throw an exception: ${e}`);
+ }
+ }
+
+ // notify to everyone waiting for an update and clean the queue
+ listener.splice(0, listener.length).forEach((cb) => cb());
+ return create(Fragment, {});
+ }
+
+ renderHook(Component, Context);
+
+ function pullLastResult(): Exclude<T | Error | null, VoidFunction> {
+ const copy: Exclude<T | Error | null, VoidFunction> = lastResult;
+ lastResult = null;
+ return copy;
+ }
+
+ function pullLastResultOrThrow(): Exclude<T, VoidFunction> {
+ const r = pullLastResult();
+ if (r instanceof Error) throw r;
+ //sanity check
+ if (!r) throw Error("there was no last result");
+ return r;
+ }
+
+ async function assertNoPendingUpdate(): Promise<boolean> {
+ await new Promise((res, rej) => {
+ const tid = setTimeout(() => {
+ res(true);
+ }, 10);
+
+ listener.push(() => {
+ clearTimeout(tid);
+ res(false);
+ // Error(`Expecting no pending result but the hook got updated.
+ // If the update was not intended you need to check the hook dependencies
+ // (or dependencies of the internal state) but otherwise make
+ // sure to consume the result before ending the test.`),
+ // );
+ });
+ });
+
+ const r = pullLastResult();
+ if (r) {
+ return Promise.resolve(false);
+ }
+ return Promise.resolve(true);
+ // This may happen because the hook did a new update but the test didn't consume the result using pullLastResult`);
+ }
+ async function waitForStateUpdate(): Promise<boolean> {
+ return await new Promise((res, rej) => {
+ const tid = setTimeout(() => {
+ res(false);
+ }, 10);
+
+ listener.push(() => {
+ clearTimeout(tid);
+ res(true);
+ });
+ });
+ }
+
+ return {
+ pullLastResultOrThrow,
+ waitForStateUpdate,
+ assertNoPendingUpdate,
+ };
+}
+
+export const nullFunction = (): void => {
+ null;
+};
+export const nullAsyncFunction = (): Promise<void> => {
+ return Promise.resolve();
+};
+
+type HookTestResult = HookTestResultOk | HookTestResultError;
+
+interface HookTestResultOk {
+ result: "ok";
+}
+interface HookTestResultError {
+ result: "fail";
+ error: string;
+ index: number;
+}
+
+/**
+ * Main testing driver.
+ * It will assert that there are no more and no less hook updates than expected.
+ *
+ * @param hookFunction hook function to be tested
+ * @param props initial props for the hook
+ * @param checks step by step state validation
+ * @param Context additional testing context for overrides
+ *
+ * @returns testing result, should also be checked to be "ok"
+ */
+// eslint-disable-next-line @typescript-eslint/ban-types
+export async function hookBehaveLikeThis<T extends object, PropsType>(
+ hookFunction: (p: PropsType) => RecursiveState<T>,
+ props: PropsType,
+ checks: Array<(state: Exclude<T, VoidFunction>) => void>,
+ Context?: ({ children }: { children: any }) => VNode | null,
+): Promise<HookTestResult> {
+ const { pullLastResultOrThrow, waitForStateUpdate, assertNoPendingUpdate } =
+ mountHook<T>(() => hookFunction(props), Context);
+
+ const [firstCheck, ...restOfTheChecks] = checks;
+ {
+ const state = pullLastResultOrThrow();
+ const checkError = firstCheck(state);
+ if (checkError !== undefined) {
+ return {
+ result: "fail",
+ index: 0,
+ error: `First check returned with error: ${checkError}`,
+ };
+ }
+ }
+
+ let index = 1;
+ for (const check of restOfTheChecks) {
+ const hasNext = await waitForStateUpdate();
+ if (!hasNext) {
+ return {
+ result: "fail",
+ error: "Component didn't update and the test expected one more state",
+ index,
+ };
+ }
+ const state = pullLastResultOrThrow();
+ const checkError = check(state);
+ if (checkError !== undefined) {
+ return {
+ result: "fail",
+ index,
+ error: `Check returned with error: ${checkError}`,
+ };
+ }
+ index++;
+ }
+
+ const hasNext = await waitForStateUpdate();
+ if (hasNext) {
+ return {
+ result: "fail",
+ index,
+ error: "Component updated and test didn't expect more states",
+ };
+ }
+ const noMoreUpdates = await assertNoPendingUpdate();
+ if (noMoreUpdates === false) {
+ return {
+ result: "fail",
+ index,
+ error: "Component was updated but the test does not cover the update",
+ };
+ }
+
+ return {
+ result: "ok",
+ };
+}
diff --git a/packages/web-util/src/tests/mock.ts b/packages/web-util/src/tests/mock.ts
new file mode 100644
index 000000000..d09e8b4a6
--- /dev/null
+++ b/packages/web-util/src/tests/mock.ts
@@ -0,0 +1,503 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { Logger } from "@gnu-taler/taler-util";
+
+type HttpMethod =
+ | "get"
+ | "GET"
+ | "delete"
+ | "DELETE"
+ | "head"
+ | "HEAD"
+ | "options"
+ | "OPTIONS"
+ | "post"
+ | "POST"
+ | "put"
+ | "PUT"
+ | "patch"
+ | "PATCH"
+ | "purge"
+ | "PURGE"
+ | "link"
+ | "LINK"
+ | "unlink"
+ | "UNLINK";
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export type Query<Req, Res> = {
+ method: HttpMethod;
+ url: string;
+ code?: number;
+};
+
+type ExpectationValues = {
+ query: Query<any, any>;
+ auth?: string;
+ params?: {
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ request?: object;
+ qparam?: Record<string, string>;
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ response?: object;
+ };
+};
+
+type TestValues = {
+ currentExpectedQuery: ExpectationValues | undefined;
+ lastQuery: ExpectationValues | undefined;
+};
+
+const logger = new Logger("testing/mock.ts");
+
+type MockedResponse = {
+ queryMade: ExpectationValues;
+ expectedQuery?: ExpectationValues;
+};
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export abstract class MockEnvironment {
+ expectations: Array<ExpectationValues> = [];
+ queriesMade: Array<ExpectationValues> = [];
+ index = 0;
+
+ debug: boolean;
+ constructor(debug: boolean) {
+ this.debug = debug;
+ this.saveRequestAndGetMockedResponse.bind(this);
+ }
+
+ public addRequestExpectation<
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ RequestType extends object,
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ ResponseType extends object,
+ >(
+ query: Query<RequestType, ResponseType>,
+ params: {
+ auth?: string;
+ request?: RequestType;
+ qparam?: any;
+ response?: ResponseType;
+ },
+ ): void {
+ const expected = { query, params, auth: params.auth };
+ this.expectations.push(expected);
+ if (this.debug) {
+ logger.info("saving query as expected", expected);
+ }
+ }
+
+ public saveRequestAndGetMockedResponse<
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ RequestType extends object,
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ ResponseType extends object,
+ >(
+ query: Query<RequestType, ResponseType>,
+ params: {
+ auth?: string;
+ request?: RequestType;
+ qparam?: any;
+ response?: ResponseType;
+ },
+ ): MockedResponse {
+ const queryMade = { query, params, auth: params.auth };
+ this.queriesMade.push(queryMade);
+ const expectedQuery = this.expectations[this.index];
+ if (!expectedQuery) {
+ if (this.debug) {
+ logger.info("unexpected query made", queryMade);
+ }
+ return { queryMade };
+ }
+
+ if (this.debug) {
+ logger.info("tracking query made", {
+ queryMade,
+ expectedQuery,
+ });
+ }
+ this.index++;
+ return { queryMade, expectedQuery };
+ }
+
+ public assertJustExpectedRequestWereMade(): AssertStatus {
+ let queryNumber = 0;
+
+ while (queryNumber < this.expectations.length) {
+ const r = this.assertNextRequest(queryNumber);
+ if (r.result !== "ok") return r;
+ queryNumber++;
+ }
+ return this.assertNoMoreRequestWereMade(queryNumber);
+ }
+
+ private getLastTestValues(idx: number): TestValues {
+ const currentExpectedQuery = this.expectations[idx];
+ const lastQuery = this.queriesMade[idx];
+
+ return { currentExpectedQuery, lastQuery };
+ }
+
+ private assertNoMoreRequestWereMade(idx: number): AssertStatus {
+ const { currentExpectedQuery, lastQuery } = this.getLastTestValues(idx);
+
+ if (lastQuery !== undefined) {
+ return {
+ result: "error-did-one-more",
+ made: lastQuery,
+ };
+ }
+ if (currentExpectedQuery !== undefined) {
+ return {
+ result: "error-did-one-less",
+ expected: currentExpectedQuery,
+ };
+ }
+
+ return {
+ result: "ok",
+ };
+ }
+
+ private assertNextRequest(index: number): AssertStatus {
+ const { currentExpectedQuery, lastQuery } = this.getLastTestValues(index);
+
+ if (!currentExpectedQuery) {
+ return {
+ result: "error-query-missing",
+ };
+ }
+
+ if (!lastQuery) {
+ return {
+ result: "error-did-one-less",
+ expected: currentExpectedQuery,
+ };
+ }
+
+ if (lastQuery.query.method) {
+ if (currentExpectedQuery.query.method !== lastQuery.query.method) {
+ return {
+ result: "error-difference",
+ diff: "method",
+ last: lastQuery.query.method,
+ expected: currentExpectedQuery.query.method,
+ index,
+ };
+ }
+ if (currentExpectedQuery.query.url !== lastQuery.query.url) {
+ return {
+ result: "error-difference",
+ diff: "url",
+ last: lastQuery.query.url,
+ expected: currentExpectedQuery.query.url,
+ index,
+ };
+ }
+ }
+ if (
+ !deepEquals(
+ currentExpectedQuery.params?.request,
+ lastQuery.params?.request,
+ )
+ ) {
+ return {
+ result: "error-difference",
+ diff: "query-body",
+ expected: currentExpectedQuery.params?.request,
+ last: lastQuery.params?.request,
+ index,
+ };
+ }
+ if (
+ !deepEquals(currentExpectedQuery.params?.qparam, lastQuery.params?.qparam)
+ ) {
+ return {
+ result: "error-difference",
+ diff: "query-params",
+ expected: currentExpectedQuery.params?.qparam,
+ last: lastQuery.params?.qparam,
+ index,
+ };
+ }
+ if (!deepEquals(currentExpectedQuery.auth, lastQuery.auth)) {
+ return {
+ result: "error-difference",
+ diff: "query-auth",
+ expected: currentExpectedQuery.auth,
+ last: lastQuery.auth,
+ index,
+ };
+ }
+
+ return {
+ result: "ok",
+ };
+ }
+}
+
+type AssertStatus =
+ | AssertOk
+ | AssertQueryNotMadeButExpected
+ | AssertQueryMadeButNotExpected
+ | AssertQueryMissing
+ | AssertExpectedQueryMethodMismatch
+ | AssertExpectedQueryUrlMismatch
+ | AssertExpectedQueryAuthMismatch
+ | AssertExpectedQueryBodyMismatch
+ | AssertExpectedQueryParamsMismatch;
+
+interface AssertOk {
+ result: "ok";
+}
+
+//trying to assert for a expected query but there is
+//no expected query in the queue
+interface AssertQueryMissing {
+ result: "error-query-missing";
+}
+
+//tested component did one more query that expected
+interface AssertQueryNotMadeButExpected {
+ result: "error-did-one-more";
+ made: ExpectationValues;
+}
+
+//tested component didn't make an expected query
+interface AssertQueryMadeButNotExpected {
+ result: "error-did-one-less";
+ expected: ExpectationValues;
+}
+
+interface AssertExpectedQueryMethodMismatch {
+ result: "error-difference";
+ diff: "method";
+ last: string;
+ expected: string;
+ index: number;
+}
+interface AssertExpectedQueryUrlMismatch {
+ result: "error-difference";
+ diff: "url";
+ last: string;
+ expected: string;
+ index: number;
+}
+interface AssertExpectedQueryAuthMismatch {
+ result: "error-difference";
+ diff: "query-auth";
+ last: string | undefined;
+ expected: string | undefined;
+ index: number;
+}
+interface AssertExpectedQueryBodyMismatch {
+ result: "error-difference";
+ diff: "query-body";
+ last: any;
+ expected: any;
+ index: number;
+}
+interface AssertExpectedQueryParamsMismatch {
+ result: "error-difference";
+ diff: "query-params";
+ last: any;
+ expected: any;
+ index: number;
+}
+
+/**
+ * helpers
+ *
+ */
+export type Tester = (a: any, b: any) => boolean | undefined;
+
+function deepEquals(
+ a: unknown,
+ b: unknown,
+ aStack: Array<unknown> = [],
+ bStack: Array<unknown> = [],
+): boolean {
+ //one if the element is null or undefined
+ if (a === null || b === null || b === undefined || a === undefined) {
+ return a === b;
+ }
+ //both are errors
+ if (a instanceof Error && b instanceof Error) {
+ return a.message == b.message;
+ }
+ //is the same object
+ if (Object.is(a, b)) {
+ return true;
+ }
+ //both the same class
+ const name = Object.prototype.toString.call(a);
+ if (name != Object.prototype.toString.call(b)) {
+ return false;
+ }
+ //
+ switch (name) {
+ case "[object Boolean]":
+ case "[object String]":
+ case "[object Number]":
+ if (typeof a !== typeof b) {
+ // One is a primitive, one a `new Primitive()`
+ return false;
+ } else if (typeof a !== "object" && typeof b !== "object") {
+ // both are proper primitives
+ return Object.is(a, b);
+ } else {
+ // both are `new Primitive()`s
+ return Object.is(a.valueOf(), b.valueOf());
+ }
+ case "[object Date]": {
+ const _a = a as Date;
+ const _b = b as Date;
+ return _a == _b;
+ }
+ case "[object RegExp]": {
+ const _a = a as RegExp;
+ const _b = b as RegExp;
+ return _a.source === _b.source && _a.flags === _b.flags;
+ }
+ case "[object Array]": {
+ const _a = a as Array<any>;
+ const _b = b as Array<any>;
+ if (_a.length !== _b.length) {
+ return false;
+ }
+ }
+ }
+ if (typeof a !== "object" || typeof b !== "object") {
+ return false;
+ }
+
+ if (
+ typeof a === "object" &&
+ typeof b === "object" &&
+ !Array.isArray(a) &&
+ !Array.isArray(b) &&
+ hasIterator(a) &&
+ hasIterator(b)
+ ) {
+ return iterable(a, b);
+ }
+
+ // Used to detect circular references.
+ let length = aStack.length;
+ while (length--) {
+ if (aStack[length] === a) {
+ return bStack[length] === b;
+ } else if (bStack[length] === b) {
+ return false;
+ }
+ }
+ aStack.push(a);
+ bStack.push(b);
+
+ const aKeys = allKeysFromObject(a);
+ const bKeys = allKeysFromObject(b);
+ let keySize = aKeys.length;
+
+ //same number of keys
+ if (bKeys.length !== keySize) {
+ return false;
+ }
+
+ let keyIterator: string;
+ while (keySize--) {
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ const _a = a as Record<string, object>;
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ const _b = b as Record<string, object>;
+
+ keyIterator = aKeys[keySize];
+
+ const de = deepEquals(_a[keyIterator], _b[keyIterator], aStack, bStack);
+ if (!de) {
+ return false;
+ }
+ }
+
+ aStack.pop();
+ bStack.pop();
+
+ return true;
+}
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+function allKeysFromObject(obj: object): Array<string> {
+ const keys = [];
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ keys.push(key);
+ }
+ }
+ return keys;
+}
+
+const IteratorSymbol = Symbol.iterator;
+
+function hasIterator(object: any): boolean {
+ return !!(object != null && object[IteratorSymbol]);
+}
+
+function iterable(
+ a: unknown,
+ b: unknown,
+ aStack: Array<unknown> = [],
+ bStack: Array<unknown> = [],
+): boolean {
+ if (a === null || b === null || b === undefined || a === undefined) {
+ return a === b;
+ }
+ if (a.constructor !== b.constructor) {
+ return false;
+ }
+ let length = aStack.length;
+ while (length--) {
+ if (aStack[length] === a) {
+ return bStack[length] === b;
+ }
+ }
+ aStack.push(a);
+ bStack.push(b);
+
+ const aIterator = (a as any)[IteratorSymbol]();
+ const bIterator = (b as any)[IteratorSymbol]();
+
+ const nextA = aIterator.next();
+ while (nextA.done) {
+ const nextB = bIterator.next();
+ if (nextB.done || !deepEquals(nextA.value, nextB.value)) {
+ return false;
+ }
+ }
+ if (!bIterator.next().done) {
+ return false;
+ }
+
+ // Remove the first value from the stack of traversed values.
+ aStack.pop();
+ bStack.pop();
+ return true;
+}
diff --git a/packages/web-util/src/tests/swr.ts b/packages/web-util/src/tests/swr.ts
new file mode 100644
index 000000000..d5f4341f3
--- /dev/null
+++ b/packages/web-util/src/tests/swr.ts
@@ -0,0 +1,105 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { ComponentChildren, FunctionalComponent, h, VNode } from "preact";
+import { MockEnvironment } from "./mock.js";
+import { SWRConfig } from "swr";
+import * as swr__internal from "swr/_internal";
+import { Logger } from "@gnu-taler/taler-util";
+import { buildRequestFailed, RequestError } from "../index.browser.js";
+
+const logger = new Logger("tests/swr.ts");
+
+/**
+ * Helper for hook that use SWR inside.
+ *
+ * buildTestingContext() will return a testing context
+ *
+ * @deprecated do not use it, it will be removed
+ */
+export class SwrMockEnvironment extends MockEnvironment {
+ constructor(debug = false) {
+ super(debug);
+ }
+
+ public buildTestingContext(): FunctionalComponent<{
+ children: ComponentChildren;
+ }> {
+ const __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE =
+ this.saveRequestAndGetMockedResponse.bind(this);
+
+ function testingFetcher(params: any): any {
+ const url = JSON.stringify(params);
+ const mocked = __SAVE_REQUEST_AND_GET_MOCKED_RESPONSE<any, any>(
+ {
+ method: "get",
+ url,
+ },
+ {},
+ );
+
+ //unexpected query
+ if (!mocked.expectedQuery) return undefined;
+ const status = mocked.expectedQuery.query.code ?? 200;
+ const requestPayload = mocked.expectedQuery.params?.request;
+ const responsePayload = mocked.expectedQuery.params?.response;
+ //simulated error
+ if (status >= 400) {
+ const error = buildRequestFailed(
+ url,
+ JSON.stringify(responsePayload),
+ status,
+ requestPayload,
+ );
+ //example error handling from https://swr.vercel.app/docs/error-handling
+ throw new RequestError(error);
+ }
+ return responsePayload;
+ }
+
+ const value: Partial<swr__internal.PublicConfiguration> & {
+ provider: () => Map<any, any>;
+ } = {
+ use: [
+ (useSWRNext) => {
+ return (key, fetcher, config) => {
+ //prevent the request
+ //use the testing fetcher instead
+ return useSWRNext(key, testingFetcher, config);
+ };
+ },
+ ],
+ fetcher: testingFetcher,
+ //These options are set for ending the test faster
+ //otherwise SWR will create timeouts that will live after the test finished
+ loadingTimeout: 0,
+ dedupingInterval: 0,
+ shouldRetryOnError: false,
+ errorRetryInterval: 0,
+ errorRetryCount: 0,
+ //clean cache for every test
+ provider: () => new Map(),
+ };
+
+ return function TestingContext({
+ children,
+ }: {
+ children: ComponentChildren;
+ }): VNode {
+ return h(SWRConfig, { value }, children);
+ };
+ }
+}
diff --git a/packages/web-util/src/utils/base64.ts b/packages/web-util/src/utils/base64.ts
new file mode 100644
index 000000000..0e075880f
--- /dev/null
+++ b/packages/web-util/src/utils/base64.ts
@@ -0,0 +1,243 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+
+export function base64encode(str: string): string {
+ return base64EncArr(strToUTF8Arr(str))
+}
+
+export function base64decode(str: string): string {
+ return UTF8ArrToStr(base64DecToArr(str))
+}
+
+// from https://developer.mozilla.org/en-US/docs/Glossary/Base64
+
+// Array of bytes to Base64 string decoding
+function b64ToUint6(nChr: number): number {
+ return nChr > 64 && nChr < 91
+ ? nChr - 65
+ : nChr > 96 && nChr < 123
+ ? nChr - 71
+ : nChr > 47 && nChr < 58
+ ? nChr + 4
+ : nChr === 43
+ ? 62
+ : nChr === 47
+ ? 63
+ : 0;
+}
+
+function base64DecToArr(sBase64: string, nBlocksSize?: number): Uint8Array {
+ const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, ""); // Only necessary if the base64 includes whitespace such as line breaks.
+ const nInLen = sB64Enc.length;
+ const nOutLen = nBlocksSize
+ ? Math.ceil(((nInLen * 3 + 1) >> 2) / nBlocksSize) * nBlocksSize
+ : (nInLen * 3 + 1) >> 2;
+ const taBytes = new Uint8Array(nOutLen);
+
+ let nMod3;
+ let nMod4;
+ let nUint24 = 0;
+ let nOutIdx = 0;
+ for (let nInIdx = 0; nInIdx < nInLen; nInIdx++) {
+ nMod4 = nInIdx & 3;
+ nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (6 * (3 - nMod4));
+ if (nMod4 === 3 || nInLen - nInIdx === 1) {
+ nMod3 = 0;
+ while (nMod3 < 3 && nOutIdx < nOutLen) {
+ taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255;
+ nMod3++;
+ nOutIdx++;
+ }
+ nUint24 = 0;
+ }
+ }
+
+ return taBytes;
+}
+
+/* Base64 string to array encoding */
+function uint6ToB64(nUint6: number): number {
+ return nUint6 < 26
+ ? nUint6 + 65
+ : nUint6 < 52
+ ? nUint6 + 71
+ : nUint6 < 62
+ ? nUint6 - 4
+ : nUint6 === 62
+ ? 43
+ : nUint6 === 63
+ ? 47
+ : 65;
+}
+
+function base64EncArr(aBytes: Uint8Array): string {
+ let nMod3 = 2;
+ let sB64Enc = "";
+
+ const nLen = aBytes.length;
+ let nUint24 = 0;
+ for (let nIdx = 0; nIdx < nLen; nIdx++) {
+ nMod3 = nIdx % 3;
+ // To break your base64 into several 80-character lines, add:
+ // if (nIdx > 0 && ((nIdx * 4) / 3) % 76 === 0) {
+ // sB64Enc += "\r\n";
+ // }
+
+ nUint24 |= aBytes[nIdx] << ((16 >>> nMod3) & 24);
+ if (nMod3 === 2 || aBytes.length - nIdx === 1) {
+ sB64Enc += String.fromCodePoint(
+ uint6ToB64((nUint24 >>> 18) & 63),
+ uint6ToB64((nUint24 >>> 12) & 63),
+ uint6ToB64((nUint24 >>> 6) & 63),
+ uint6ToB64(nUint24 & 63)
+ );
+ nUint24 = 0;
+ }
+ }
+ return (
+ sB64Enc.substring(0, sB64Enc.length - 2 + nMod3) +
+ (nMod3 === 2 ? "" : nMod3 === 1 ? "=" : "==")
+ );
+}
+
+/* UTF-8 array to JS string and vice versa */
+
+function UTF8ArrToStr(aBytes: Uint8Array): string {
+ let sView = "";
+ let nPart;
+ const nLen = aBytes.length;
+ for (let nIdx = 0; nIdx < nLen; nIdx++) {
+ nPart = aBytes[nIdx];
+ sView += String.fromCodePoint(
+ nPart > 251 && nPart < 254 && nIdx + 5 < nLen /* six bytes */
+ ? /* (nPart - 252 << 30) may be not so safe in ECMAScript! So…: */
+ (nPart - 252) * 1073741824 +
+ ((aBytes[++nIdx] - 128) << 24) +
+ ((aBytes[++nIdx] - 128) << 18) +
+ ((aBytes[++nIdx] - 128) << 12) +
+ ((aBytes[++nIdx] - 128) << 6) +
+ aBytes[++nIdx] -
+ 128
+ : nPart > 247 && nPart < 252 && nIdx + 4 < nLen /* five bytes */
+ ? ((nPart - 248) << 24) +
+ ((aBytes[++nIdx] - 128) << 18) +
+ ((aBytes[++nIdx] - 128) << 12) +
+ ((aBytes[++nIdx] - 128) << 6) +
+ aBytes[++nIdx] -
+ 128
+ : nPart > 239 && nPart < 248 && nIdx + 3 < nLen /* four bytes */
+ ? ((nPart - 240) << 18) +
+ ((aBytes[++nIdx] - 128) << 12) +
+ ((aBytes[++nIdx] - 128) << 6) +
+ aBytes[++nIdx] -
+ 128
+ : nPart > 223 && nPart < 240 && nIdx + 2 < nLen /* three bytes */
+ ? ((nPart - 224) << 12) +
+ ((aBytes[++nIdx] - 128) << 6) +
+ aBytes[++nIdx] -
+ 128
+ : nPart > 191 && nPart < 224 && nIdx + 1 < nLen /* two bytes */
+ ? ((nPart - 192) << 6) + aBytes[++nIdx] - 128
+ : /* nPart < 127 ? */ /* one byte */
+ nPart
+ );
+ }
+ return sView;
+}
+
+function strToUTF8Arr(sDOMStr: string): Uint8Array {
+ let nChr;
+ const nStrLen = sDOMStr.length;
+ let nArrLen = 0;
+
+ /* mapping… */
+ for (let nMapIdx = 0; nMapIdx < nStrLen; nMapIdx++) {
+ nChr = sDOMStr.codePointAt(nMapIdx);
+ if (nChr === undefined) {
+ throw Error(`No char at ${nMapIdx} on string with length: ${sDOMStr.length}`)
+ }
+
+ if (nChr >= 0x10000) {
+ nMapIdx++;
+ }
+
+ nArrLen +=
+ nChr < 0x80
+ ? 1
+ : nChr < 0x800
+ ? 2
+ : nChr < 0x10000
+ ? 3
+ : nChr < 0x200000
+ ? 4
+ : nChr < 0x4000000
+ ? 5
+ : 6;
+ }
+
+ const aBytes = new Uint8Array(nArrLen);
+
+ /* transcription… */
+ let nIdx = 0;
+ let nChrIdx = 0;
+ while (nIdx < nArrLen) {
+ nChr = sDOMStr.codePointAt(nChrIdx);
+ if (nChr === undefined) {
+ throw Error(`No char at ${nChrIdx} on string with length: ${sDOMStr.length}`)
+ }
+ if (nChr < 128) {
+ /* one byte */
+ aBytes[nIdx++] = nChr;
+ } else if (nChr < 0x800) {
+ /* two bytes */
+ aBytes[nIdx++] = 192 + (nChr >>> 6);
+ aBytes[nIdx++] = 128 + (nChr & 63);
+ } else if (nChr < 0x10000) {
+ /* three bytes */
+ aBytes[nIdx++] = 224 + (nChr >>> 12);
+ aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63);
+ aBytes[nIdx++] = 128 + (nChr & 63);
+ } else if (nChr < 0x200000) {
+ /* four bytes */
+ aBytes[nIdx++] = 240 + (nChr >>> 18);
+ aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63);
+ aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63);
+ aBytes[nIdx++] = 128 + (nChr & 63);
+ nChrIdx++;
+ } else if (nChr < 0x4000000) {
+ /* five bytes */
+ aBytes[nIdx++] = 248 + (nChr >>> 24);
+ aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63);
+ aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63);
+ aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63);
+ aBytes[nIdx++] = 128 + (nChr & 63);
+ nChrIdx++;
+ } /* if (nChr <= 0x7fffffff) */ else {
+ /* six bytes */
+ aBytes[nIdx++] = 252 + (nChr >>> 30);
+ aBytes[nIdx++] = 128 + ((nChr >>> 24) & 63);
+ aBytes[nIdx++] = 128 + ((nChr >>> 18) & 63);
+ aBytes[nIdx++] = 128 + ((nChr >>> 12) & 63);
+ aBytes[nIdx++] = 128 + ((nChr >>> 6) & 63);
+ aBytes[nIdx++] = 128 + (nChr & 63);
+ nChrIdx++;
+ }
+ nChrIdx++;
+ }
+
+ return aBytes;
+}
diff --git a/packages/web-util/src/utils/http-impl.browser.ts b/packages/web-util/src/utils/http-impl.browser.ts
new file mode 100644
index 000000000..1e5496071
--- /dev/null
+++ b/packages/web-util/src/utils/http-impl.browser.ts
@@ -0,0 +1,251 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ Logger,
+ RequestThrottler,
+ TalerErrorCode,
+ TalerError,
+ Duration,
+} from "@gnu-taler/taler-util";
+
+import {
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+ Headers,
+ getDefaultHeaders,
+ encodeBody,
+ DEFAULT_REQUEST_TIMEOUT_MS,
+ HttpLibArgs,
+} from "@gnu-taler/taler-util/http";
+
+const logger = new Logger("browserHttpLib");
+
+/**
+ * An implementation of the [[HttpRequestLibrary]] using the
+ * browser's XMLHttpRequest.
+ *
+ * @deprecated use BrowserFetchHttpLib
+ */
+export class BrowserHttpLibDepreacted implements HttpRequestLibrary {
+ private throttle = new RequestThrottler();
+ private throttlingEnabled = true;
+ private requireTls = false;
+
+ constructor(args?: HttpLibArgs) {
+ this.throttlingEnabled = args?.enableThrottling ?? true;
+ this.requireTls = args?.requireTls ?? false;
+ }
+
+ fetch(
+ requestUrl: string,
+ options?: HttpRequestOptions,
+ ): Promise<HttpResponse> {
+ const requestMethod = options?.method ?? "GET";
+ const requestBody = options?.body;
+ const requestHeader = options?.headers;
+ const requestTimeout =
+ options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS);
+
+ const parsedUrl = new URL(requestUrl);
+ if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
+ {
+ requestMethod,
+ requestUrl,
+ throttleStats: this.throttle.getThrottleStats(requestUrl),
+ },
+ `request to origin ${parsedUrl.origin} was throttled`,
+ );
+ }
+ if (this.requireTls && parsedUrl.protocol !== "https:") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_NETWORK_ERROR,
+ {
+ requestMethod: requestMethod,
+ requestUrl: requestUrl,
+ },
+ `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`,
+ );
+ }
+
+ let myBody: ArrayBuffer | undefined =
+ requestMethod === "POST" || requestMethod === "PUT" || requestMethod === "PATCH"
+ ? encodeBody(requestBody)
+ : undefined;
+
+ const requestHeadersMap = getDefaultHeaders(requestMethod);
+ if (requestHeader) {
+ Object.entries(requestHeader).forEach(([key, value]) => {
+ if (value === undefined) return;
+ requestHeadersMap[key] = value
+ })
+ }
+
+ return new Promise<HttpResponse>((resolve, reject) => {
+ const myRequest = new XMLHttpRequest();
+
+ myRequest.onerror = (e) => {
+ logger.error("http request error");
+ reject(
+ TalerError.fromDetail(
+ TalerErrorCode.WALLET_NETWORK_ERROR,
+ {
+ requestUrl,
+ requestMethod,
+ },
+ "Could not make request",
+ ),
+ );
+ };
+
+ myRequest.open(requestMethod, requestUrl);
+
+ let timeoutId: any | undefined;
+ if (requestTimeout.d_ms !== "forever") {
+ timeoutId = setTimeout(() => {
+ myRequest.abort();
+ reject(
+ TalerError.fromDetail(
+ TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT,
+ {
+ requestUrl,
+ requestMethod,
+ timeoutMs: requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms
+ },
+ `request to ${requestUrl} timed out`,
+ ),
+ );
+ }, requestTimeout.d_ms);
+ }
+
+ Object.keys(requestHeadersMap).forEach((headerName) => {
+ myRequest.setRequestHeader(headerName, requestHeadersMap[headerName]);
+ });
+
+ myRequest.responseType = "arraybuffer";
+ myRequest.send(myBody);
+
+ myRequest.addEventListener("readystatechange", (e) => {
+ if (myRequest.readyState === XMLHttpRequest.DONE) {
+ if (myRequest.status === 0) {
+ const exc = TalerError.fromDetail(
+ TalerErrorCode.WALLET_NETWORK_ERROR,
+ {
+ requestUrl,
+ requestMethod,
+ },
+ "HTTP request failed (status 0, maybe URI scheme was wrong?)",
+ );
+ reject(exc);
+ return;
+ }
+ const makeText = async (): Promise<string> => {
+ const td = new TextDecoder();
+ return td.decode(myRequest.response);
+ };
+ let responseJson: unknown = undefined;
+ const makeJson = async (): Promise<any> => {
+ if (responseJson === undefined) {
+ try {
+ const td = new TextDecoder();
+ const responseString = td.decode(myRequest.response);
+ responseJson = JSON.parse(responseString);
+ } catch (e) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl,
+ requestMethod,
+ httpStatusCode: myRequest.status,
+ },
+ "Invalid JSON from HTTP response",
+ );
+ }
+ }
+ if (responseJson === null || typeof responseJson !== "object") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl,
+ requestMethod,
+ httpStatusCode: myRequest.status,
+ },
+ "Invalid JSON from HTTP response",
+ );
+ }
+ return responseJson;
+ };
+
+ const headers = myRequest.getAllResponseHeaders();
+ const arr = headers.trim().split(/[\r\n]+/);
+
+ // Create a map of header names to values
+ const headerMap: Headers = new Headers();
+ arr.forEach(function (line) {
+ const parts = line.split(": ");
+ const headerName = parts.shift();
+ if (!headerName) {
+ logger.warn("skipping invalid header");
+ return;
+ }
+ const value = parts.join(": ");
+ headerMap.set(headerName, value);
+ });
+ const resp: HttpResponse = {
+ requestUrl: requestUrl,
+ status: myRequest.status,
+ headers: headerMap,
+ requestMethod: requestMethod,
+ json: makeJson,
+ text: makeText,
+ bytes: async () => myRequest.response,
+ };
+ resolve(resp);
+ }
+ });
+ });
+ }
+
+ get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
+ return this.fetch(url, {
+ method: "GET",
+ ...opt,
+ });
+ }
+
+ postJson(
+ url: string,
+ body: any,
+ opt?: HttpRequestOptions,
+ ): Promise<HttpResponse> {
+ return this.fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ ...opt,
+ });
+ }
+
+ stop(): void {
+ // Nothing to do
+ }
+}
diff --git a/packages/web-util/src/utils/http-impl.sw.ts b/packages/web-util/src/utils/http-impl.sw.ts
new file mode 100644
index 000000000..3c4b8b587
--- /dev/null
+++ b/packages/web-util/src/utils/http-impl.sw.ts
@@ -0,0 +1,215 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ Duration,
+ RequestThrottler,
+ TalerError,
+ TalerErrorCode
+} from "@gnu-taler/taler-util";
+
+import {
+ DEFAULT_REQUEST_TIMEOUT_MS,
+ Headers,
+ HttpLibArgs,
+ HttpRequestLibrary,
+ HttpRequestOptions,
+ HttpResponse,
+ encodeBody,
+ getDefaultHeaders,
+} from "@gnu-taler/taler-util/http";
+
+/**
+ * An implementation of the [[HttpRequestLibrary]] using the
+ * browser's XMLHttpRequest.
+ */
+export class BrowserFetchHttpLib implements HttpRequestLibrary {
+ private throttle = new RequestThrottler();
+ private throttlingEnabled = true;
+ private requireTls = false;
+
+ public constructor(args?: HttpLibArgs) {
+ this.throttlingEnabled = args?.enableThrottling ?? true;
+ this.requireTls = args?.requireTls ?? false;
+ }
+
+ async fetch(
+ requestUrl: string,
+ options?: HttpRequestOptions,
+ ): Promise<HttpResponse> {
+ const requestMethod = options?.method ?? "GET";
+ const requestBody = options?.body;
+ const requestHeader = options?.headers;
+ const requestTimeout =
+ options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS);
+ const requestCancel = options?.cancellationToken;
+
+ const parsedUrl = new URL(requestUrl);
+ if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED,
+ {
+ requestMethod,
+ requestUrl,
+ throttleStats: this.throttle.getThrottleStats(requestUrl),
+ },
+ `request to origin ${parsedUrl.origin} was throttled`,
+ );
+ }
+ if (this.requireTls && parsedUrl.protocol !== "https:") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_NETWORK_ERROR,
+ {
+ requestMethod: requestMethod,
+ requestUrl: requestUrl,
+ },
+ `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`,
+ );
+ }
+
+ const myBody: ArrayBuffer | undefined =
+ requestMethod === "POST" || requestMethod === "PUT" || requestMethod === "PATCH"
+ ? encodeBody(requestBody)
+ : undefined;
+
+ const requestHeadersMap = getDefaultHeaders(requestMethod);
+ if (requestHeader) {
+ Object.entries(requestHeader).forEach(([key, value]) => {
+ if (value === undefined) return;
+ requestHeadersMap[key] = value
+ })
+ }
+
+ const controller = new AbortController();
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
+ if (requestTimeout.d_ms !== "forever") {
+ timeoutId = setTimeout(() => {
+ controller.abort(TalerErrorCode.GENERIC_TIMEOUT);
+ }, requestTimeout.d_ms);
+ }
+ if (requestCancel) {
+ requestCancel.onCancelled(() => {
+ controller.abort(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR)
+ });
+ }
+
+ try {
+ const response = await fetch(requestUrl, {
+ headers: requestHeadersMap,
+ body: myBody,
+ method: requestMethod,
+ signal: controller.signal,
+ });
+
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ const headerMap = new Headers();
+ response.headers.forEach((value, key) => {
+ headerMap.set(key, value);
+ });
+ return {
+ headers: headerMap,
+ status: response.status,
+ requestMethod,
+ requestUrl,
+ json: makeJsonHandler(response, requestUrl, requestMethod),
+ text: makeTextHandler(response, requestUrl, requestMethod),
+ bytes: async () => (await response.blob()).arrayBuffer(),
+ };
+ } catch (e) {
+ if (controller.signal) {
+ throw TalerError.fromDetail(
+ controller.signal.reason,
+ {
+ requestUrl,
+ requestMethod,
+ timeoutMs: requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms
+ },
+ `HTTP request failed.`,
+ );
+ }
+ throw e;
+ }
+ }
+
+}
+
+function makeTextHandler(
+ response: Response,
+ requestUrl: string,
+ requestMethod: string,
+) {
+ return async function getTextFromResponse(): Promise<any> {
+ let respText;
+ try {
+ respText = await response.text();
+ } catch (e) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl,
+ requestMethod,
+ httpStatusCode: response.status,
+ },
+ "Invalid text from HTTP response",
+ );
+ }
+ return respText;
+ };
+}
+
+function makeJsonHandler(
+ response: Response,
+ requestUrl: string,
+ requestMethod: string,
+) {
+ let responseJson: unknown = undefined;
+ return async function getJsonFromResponse(): Promise<any> {
+ if (responseJson === undefined) {
+ try {
+ responseJson = await response.json();
+ } catch (e) {
+ const message = e instanceof Error ? `Invalid JSON from HTTP response: ${e.message}` : "Invalid JSON from HTTP response"
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl,
+ requestMethod,
+ httpStatusCode: response.status,
+ },
+ message,
+ );
+ }
+ }
+ if (responseJson === null || typeof responseJson !== "object") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE,
+ {
+ requestUrl,
+ requestMethod,
+ httpStatusCode: response.status,
+ },
+ "Invalid JSON from HTTP response: null or not object",
+ );
+ }
+ return responseJson;
+ };
+}
diff --git a/packages/web-util/src/utils/observable.ts b/packages/web-util/src/utils/observable.ts
new file mode 100644
index 000000000..16a33ae72
--- /dev/null
+++ b/packages/web-util/src/utils/observable.ts
@@ -0,0 +1,283 @@
+import { isArrayBufferView } from "util/types";
+
+export type ObservableMap<K, V> = Map<K, V> & {
+ onAnyUpdate: (callback: () => void) => () => void;
+ onUpdate: (key: string, callback: () => void) => () => void;
+};
+
+//FIXME: allow different type for different properties
+export function memoryMap<T>(
+ backend: Map<string, T> = new Map<string, T>(),
+): ObservableMap<string, T> {
+ const obs = new EventTarget();
+ const theMemoryMap: ObservableMap<string, T> = {
+ onAnyUpdate: (handler) => {
+ obs.addEventListener(`update`, handler);
+ obs.addEventListener(`clear`, handler);
+ return () => {
+ obs.removeEventListener(`update`, handler);
+ obs.removeEventListener(`clear`, handler);
+ };
+ },
+ onUpdate: (key, handler) => {
+ obs.addEventListener(`update-${key}`, handler);
+ obs.addEventListener(`clear`, handler);
+ return () => {
+ obs.removeEventListener(`update-${key}`, handler);
+ obs.removeEventListener(`clear`, handler);
+ };
+ },
+ delete: (key: string) => {
+ const result = backend.delete(key);
+ //@ts-ignore
+ theMemoryMap.size = backend.length;
+ obs.dispatchEvent(new Event(`update-${key}`));
+ obs.dispatchEvent(new Event(`update`));
+ return result;
+ },
+ set: (key: string, value: T) => {
+ backend.set(key, value);
+ //@ts-ignore
+ theMemoryMap.size = backend.length;
+ obs.dispatchEvent(new Event(`update-${key}`));
+ obs.dispatchEvent(new Event(`update`));
+ return theMemoryMap;
+ },
+ clear: () => {
+ backend.clear();
+ obs.dispatchEvent(new Event(`clear`));
+ },
+ entries: backend.entries.bind(backend),
+ forEach: backend.forEach.bind(backend),
+ get: backend.get.bind(backend),
+ has: backend.has.bind(backend),
+ keys: backend.keys.bind(backend),
+ size: backend.size,
+ values: backend.values.bind(backend),
+ [Symbol.iterator]: backend[Symbol.iterator],
+ [Symbol.toStringTag]: "theMemoryMap",
+ };
+ return theMemoryMap;
+}
+
+//FIXME: change this implementation to match the
+// browser storage. instead of creating a sync implementation
+// of observable map it should reuse the memoryMap and
+// sync the state with local storage
+export function localStorageMap(): ObservableMap<string, string> {
+ const obs = new EventTarget();
+ const theLocalStorageMap: ObservableMap<string, string> = {
+ onAnyUpdate: (handler) => {
+ obs.addEventListener(`update`, handler);
+ obs.addEventListener(`clear`, handler);
+ window.addEventListener("storage", handler);
+ return () => {
+ window.removeEventListener("storage", handler);
+ obs.removeEventListener(`update`, handler);
+ obs.removeEventListener(`clear`, handler);
+ };
+ },
+ onUpdate: (key, handler) => {
+ obs.addEventListener(`update-${key}`, handler);
+ obs.addEventListener(`clear`, handler);
+ function handleStorageEvent(ev: StorageEvent) {
+ if (ev.key === null || ev.key === key) {
+ handler();
+ }
+ }
+ window.addEventListener("storage", handleStorageEvent);
+ return () => {
+ window.removeEventListener("storage", handleStorageEvent);
+ obs.removeEventListener(`update-${key}`, handler);
+ obs.removeEventListener(`clear`, handler);
+ };
+ },
+ delete: (key: string) => {
+ const exists = localStorage.getItem(key) !== null;
+ localStorage.removeItem(key);
+ //@ts-ignore
+ theLocalStorageMap.size = localStorage.length;
+ obs.dispatchEvent(new Event(`update-${key}`));
+ obs.dispatchEvent(new Event(`update`));
+ return exists;
+ },
+ set: (key: string, v: string) => {
+ localStorage.setItem(key, v);
+ //@ts-ignore
+ theLocalStorageMap.size = localStorage.length;
+ obs.dispatchEvent(new Event(`update-${key}`));
+ obs.dispatchEvent(new Event(`update`));
+ return theLocalStorageMap;
+ },
+ clear: () => {
+ localStorage.clear();
+ obs.dispatchEvent(new Event(`clear`));
+ },
+ entries: (): IterableIterator<[string, string]> => {
+ let index = 0;
+ const total = localStorage.length;
+ return {
+ next() {
+ if (index === total) return { done: true, value: undefined };
+ const key = localStorage.key(index);
+ if (key === null) {
+ //we are going from 0 until last, this should not happen
+ throw Error("key cant be null");
+ }
+ const item = localStorage.getItem(key);
+ if (item === null) {
+ //the key exist, this should not happen
+ throw Error("value cant be null");
+ }
+ index = index + 1;
+ return { done: false, value: [key, item] };
+ },
+ [Symbol.iterator]() {
+ return this;
+ },
+ };
+ },
+ forEach: (cb) => {
+ for (let index = 0; index < localStorage.length; index++) {
+ const key = localStorage.key(index);
+ if (key === null) {
+ //we are going from 0 until last, this should not happen
+ throw Error("key cant be null");
+ }
+ const item = localStorage.getItem(key);
+ if (item === null) {
+ //the key exist, this should not happen
+ throw Error("value cant be null");
+ }
+ cb(key, item, theLocalStorageMap);
+ }
+ },
+ get: (key: string) => {
+ const item = localStorage.getItem(key);
+ if (item === null) return undefined;
+ return item;
+ },
+ has: (key: string) => {
+ return localStorage.getItem(key) === null;
+ },
+ keys: () => {
+ let index = 0;
+ const total = localStorage.length;
+ return {
+ next() {
+ if (index === total) return { done: true, value: undefined };
+ const key = localStorage.key(index);
+ if (key === null) {
+ //we are going from 0 until last, this should not happen
+ throw Error("key cant be null");
+ }
+ index = index + 1;
+ return { done: false, value: key };
+ },
+ [Symbol.iterator]() {
+ return this;
+ },
+ };
+ },
+ size: localStorage.length,
+ values: () => {
+ let index = 0;
+ const total = localStorage.length;
+ return {
+ next() {
+ if (index === total) return { done: true, value: undefined };
+ const key = localStorage.key(index);
+ if (key === null) {
+ //we are going from 0 until last, this should not happen
+ throw Error("key cant be null");
+ }
+ const item = localStorage.getItem(key);
+ if (item === null) {
+ //the key exist, this should not happen
+ throw Error("value cant be null");
+ }
+ index = index + 1;
+ return { done: false, value: item };
+ },
+ [Symbol.iterator]() {
+ return this;
+ },
+ };
+ },
+ [Symbol.iterator]: function (): IterableIterator<[string, string]> {
+ return theLocalStorageMap.entries();
+ },
+ [Symbol.toStringTag]: "theLocalStorageMap",
+ };
+ return theLocalStorageMap;
+}
+
+const isFirefox =
+ typeof (window as any) !== "undefined" &&
+ typeof (window as any)["InstallTrigger"] !== "undefined";
+
+async function getAllContent() {
+ //Firefox and Chrome has different storage api
+ if (isFirefox) {
+ // @ts-ignore
+ return browser.storage.local.get();
+ } else {
+ return chrome.storage.local.get();
+ }
+}
+
+async function updateContent(obj: Record<string, any>) {
+ if (isFirefox) {
+ // @ts-ignore
+ return browser.storage.local.set(obj);
+ } else {
+ return chrome.storage.local.set(obj);
+ }
+}
+type Changes = { [key: string]: { oldValue?: any; newValue?: any } };
+function onBrowserStorageUpdate(cb: (changes: Changes) => void): void {
+ if (isFirefox) {
+ // @ts-ignore
+ browser.storage.local.onChanged.addListener(cb);
+ } else {
+ chrome.storage.local.onChanged.addListener(cb);
+ }
+}
+
+export function browserStorageMap(
+ backend: ObservableMap<string, string>,
+): ObservableMap<string, string> {
+ getAllContent().then(content => {
+ Object.entries(content ?? {}).forEach(([k, v]) => {
+ backend.set(k, v as string);
+ });
+ })
+
+ backend.onAnyUpdate(async () => {
+ const result: Record<string, string> = {};
+ for (const [key, value] of backend.entries()) {
+ result[key] = value;
+ }
+ await updateContent(result);
+ });
+
+ onBrowserStorageUpdate((changes) => {
+ //another chrome instance made the change
+ const changedItems = Object.keys(changes);
+ if (changedItems.length === 0) {
+ backend.clear();
+ } else {
+ for (const key of changedItems) {
+ if (!changes[key].newValue) {
+ backend.delete(key);
+ } else {
+ if (changes[key].newValue !== changes[key].oldValue) {
+ backend.set(key, changes[key].newValue);
+ }
+ }
+ }
+ }
+ });
+
+ return backend;
+}
diff --git a/packages/web-util/src/utils/request.ts b/packages/web-util/src/utils/request.ts
new file mode 100644
index 000000000..23d3af468
--- /dev/null
+++ b/packages/web-util/src/utils/request.ts
@@ -0,0 +1,477 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { HttpStatusCode } from "@gnu-taler/taler-util";
+import { base64encode } from "./base64.js";
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export enum ErrorType {
+ CLIENT,
+ SERVER,
+ UNREADABLE,
+ TIMEOUT,
+ UNEXPECTED,
+}
+
+
+
+/**
+ *
+ * @param baseUrl URL where the service is located
+ * @param endpoint endpoint of the service to be called
+ * @param options auth, method and params
+ * @deprecated do not use it, it will be removed
+ * @returns
+ */
+export async function defaultRequestHandler<T>(
+ baseUrl: string,
+ endpoint: string,
+ options: RequestOptions = {},
+): Promise<HttpResponseOk<T>> {
+ const requestHeaders: Record<string, string> = {};
+ if (options.token) {
+ requestHeaders.Authorization = `Bearer secret-token:${options.token}`;
+ } else if (options.basicAuth) {
+ requestHeaders.Authorization = `Basic ${base64encode(
+ `${options.basicAuth.username}:${options.basicAuth.password}`,
+ )}`;
+ }
+ requestHeaders["Content-Type"] =
+ !options.contentType || options.contentType === "json" ? "application/json" : "text/plain";
+
+ if (options.talerAmlOfficerSignature) {
+ requestHeaders["Taler-AML-Officer-Signature"] =
+ options.talerAmlOfficerSignature;
+ }
+
+ const requestMethod = options?.method ?? "GET";
+ const requestBody = options?.data;
+ const requestTimeout = options?.timeout ?? 5 * 1000;
+ const requestParams = options.params ?? {};
+ const requestPreventCache = options.preventCache ?? false;
+ const requestPreventCors = options.preventCors ?? false;
+
+ const validURL = validateURL(baseUrl, endpoint);
+
+ if (!validURL) {
+ const error: HttpResponseUnexpectedError = {
+ info: {
+ url: `${baseUrl}${endpoint}`,
+ payload: {},
+ hasToken: !!options.token,
+ status: 0,
+ options,
+ },
+ type: ErrorType.UNEXPECTED,
+ exception: undefined,
+ loading: false,
+ message: `invalid URL: "${baseUrl}${endpoint}"`,
+ };
+ throw new RequestError(error)
+ }
+
+ Object.entries(requestParams).forEach(([key, value]) => {
+ validURL.searchParams.set(key, String(value));
+ });
+
+ let payload: BodyInit | undefined = undefined;
+ if (requestBody != null) {
+ if (typeof requestBody === "string") {
+ payload = requestBody;
+ } else if (requestBody instanceof ArrayBuffer) {
+ payload = requestBody;
+ } else if (ArrayBuffer.isView(requestBody)) {
+ payload = requestBody;
+ } else if (typeof requestBody === "object") {
+ payload = JSON.stringify(requestBody);
+ } else {
+ const error: HttpResponseUnexpectedError = {
+ info: {
+ url: validURL.href,
+ payload: {},
+ hasToken: !!options.token,
+ status: 0,
+ options,
+ },
+ type: ErrorType.UNEXPECTED,
+ exception: undefined,
+ loading: false,
+ message: `unsupported request body type: "${typeof requestBody}"`,
+ };
+ throw new RequestError(error)
+ }
+ }
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => {
+ controller.abort("HTTP_REQUEST_TIMEOUT");
+ }, requestTimeout);
+
+ let response;
+ try {
+ response = await fetch(validURL.href, {
+ headers: requestHeaders,
+ method: requestMethod,
+ credentials: "omit",
+ mode: requestPreventCors ? "no-cors" : "cors",
+ cache: requestPreventCache ? "no-cache" : "default",
+ body: payload,
+ signal: controller.signal,
+ });
+ } catch (ex) {
+ const info: RequestInfo = {
+ payload,
+ url: validURL.href,
+ hasToken: !!options.token,
+ status: 0,
+ options,
+ };
+
+ if (ex instanceof Error) {
+ if (ex.message === "HTTP_REQUEST_TIMEOUT") {
+ const error: HttpRequestTimeoutError = {
+ info,
+ type: ErrorType.TIMEOUT,
+ message: "request timeout",
+ };
+ throw new RequestError(error);
+ }
+ }
+
+ const error: HttpResponseUnexpectedError = {
+ info,
+ type: ErrorType.UNEXPECTED,
+ exception: ex,
+ loading: false,
+ message: (ex instanceof Error ? ex.message : ""),
+ };
+ throw new RequestError(error);
+ }
+
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ const headerMap = new Headers();
+ response.headers.forEach((value, key) => {
+ headerMap.set(key, value);
+ });
+
+ if (response.ok) {
+ const result = await buildRequestOk<T>(
+ response,
+ validURL.href,
+ payload,
+ !!options.token,
+ options,
+ );
+ return result;
+ } else {
+ const dataTxt = await response.text();
+ const error = buildRequestFailed(
+ validURL.href,
+ dataTxt,
+ response.status,
+ payload,
+ options,
+ );
+ throw new RequestError(error);
+ }
+}
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export type HttpResponse<T, ErrorDetail> =
+ | HttpResponseOk<T>
+ | HttpResponseLoading<T>
+ | HttpError<ErrorDetail>;
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export type HttpResponsePaginated<T, ErrorDetail> =
+ | HttpResponseOkPaginated<T>
+ | HttpResponseLoading<T>
+ | HttpError<ErrorDetail>;
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export interface RequestInfo {
+ url: string;
+ hasToken: boolean;
+ payload: any;
+ status: number;
+ options: RequestOptions;
+}
+
+interface HttpResponseLoading<T> {
+ ok?: false;
+ loading: true;
+ clientError?: false;
+ serverError?: false;
+
+ data?: T;
+}
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export interface HttpResponseOk<T> {
+ ok: true;
+ loading?: false;
+ clientError?: false;
+ serverError?: false;
+
+ data: T;
+ info?: RequestInfo;
+}
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export type HttpResponseOkPaginated<T> = HttpResponseOk<T> & WithPagination;
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export interface WithPagination {
+ loadMore: () => void;
+ loadMorePrev: () => void;
+ isReachingEnd?: boolean;
+ isReachingStart?: boolean;
+}
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export type HttpError<ErrorDetail> =
+ | HttpRequestTimeoutError
+ | HttpResponseClientError<ErrorDetail>
+ | HttpResponseServerError<ErrorDetail>
+ | HttpResponseUnreadableError
+ | HttpResponseUnexpectedError;
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export interface HttpResponseServerError<ErrorDetail> {
+ ok?: false;
+ loading?: false;
+ type: ErrorType.SERVER;
+ payload: ErrorDetail;
+ status: HttpStatusCode;
+ message: string;
+ info: RequestInfo;
+}
+interface HttpRequestTimeoutError {
+ ok?: false;
+ loading?: false;
+ type: ErrorType.TIMEOUT;
+
+ info: RequestInfo;
+
+ message: string;
+}
+interface HttpResponseClientError<ErrorDetail> {
+ ok?: false;
+ loading?: false;
+ type: ErrorType.CLIENT;
+
+ info: RequestInfo;
+ status: HttpStatusCode;
+ payload: ErrorDetail;
+ message: string;
+}
+
+interface HttpResponseUnexpectedError {
+ ok?: false;
+ loading: false;
+ type: ErrorType.UNEXPECTED;
+
+ info: RequestInfo;
+ status?: HttpStatusCode;
+ exception: unknown;
+ message: string;
+}
+
+interface HttpResponseUnreadableError {
+ ok?: false;
+ loading: false;
+ type: ErrorType.UNREADABLE;
+
+ info: RequestInfo;
+ status: HttpStatusCode;
+ exception: unknown;
+ body: string;
+ message: string;
+}
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export class RequestError<ErrorDetail> extends Error {
+ /**
+ * @deprecated use cause
+ */
+ info: HttpError<ErrorDetail>;
+ cause: HttpError<ErrorDetail>;
+ constructor(d: HttpError<ErrorDetail>) {
+ super(d.message);
+ this.info = d;
+ this.cause = d;
+ }
+}
+
+type Methods = "GET" | "POST" | "PATCH" | "DELETE" | "PUT";
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export interface RequestOptions {
+ method?: Methods;
+ token?: string;
+ basicAuth?: {
+ username: string;
+ password: string;
+ };
+ preventCache?: boolean;
+ preventCors?: boolean;
+ data?: any;
+ params?: unknown;
+ timeout?: number;
+ contentType?: "text" | "json";
+ talerAmlOfficerSignature?: string;
+}
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+async function buildRequestOk<T>(
+ response: Response,
+ url: string,
+ payload: any,
+ hasToken: boolean,
+ options: RequestOptions,
+): Promise<HttpResponseOk<T>> {
+ const dataTxt = await response.text();
+ const data = dataTxt ? JSON.parse(dataTxt) : undefined;
+ return {
+ ok: true,
+ data,
+ info: {
+ payload,
+ url,
+ hasToken,
+ options,
+ status: response.status,
+ },
+ };
+}
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+export function buildRequestFailed<ErrorDetail>(
+ url: string,
+ dataTxt: string,
+ status: number,
+ payload: any,
+ maybeOptions?: RequestOptions,
+):
+ | HttpResponseClientError<ErrorDetail>
+ | HttpResponseServerError<ErrorDetail>
+ | HttpResponseUnreadableError
+ | HttpResponseUnexpectedError {
+ const options = maybeOptions ?? {};
+ const info: RequestInfo = {
+ payload,
+ url,
+ hasToken: !!options.token,
+ options,
+ status: status || 0,
+ };
+
+ // const dataTxt = await response.text();
+ try {
+ const data = dataTxt ? JSON.parse(dataTxt) : undefined;
+ const errorCode = !data || !data.code ? "" : `(code: ${data.code})`;
+ const errorHint =
+ !data || !data.hint ? "Not hint." : `${data.hint} ${errorCode}`;
+
+ if (status && status >= 400 && status < 500) {
+ const message =
+ data === undefined
+ ? `Client error (${status}) without data.`
+ : errorHint;
+
+ const error: HttpResponseClientError<ErrorDetail> = {
+ type: ErrorType.CLIENT,
+ status,
+ info,
+ message,
+ payload: data,
+ };
+ return error;
+ }
+ if (status && status >= 500 && status < 600) {
+ const message =
+ data === undefined
+ ? `Server error (${status}) without data.`
+ : errorHint;
+ const error: HttpResponseServerError<ErrorDetail> = {
+ type: ErrorType.SERVER,
+ status,
+ info,
+ message,
+ payload: data,
+ };
+ return error;
+ }
+ return {
+ info,
+ loading: false,
+ type: ErrorType.UNEXPECTED,
+ status,
+ exception: undefined,
+ message: `http status code not handled: ${status}`,
+ };
+ } catch (ex) {
+ const error: HttpResponseUnreadableError = {
+ info,
+ loading: false,
+ status,
+ type: ErrorType.UNREADABLE,
+ exception: ex,
+ body: dataTxt,
+ message: "Could not parse body as json",
+ };
+
+ return error;
+ }
+}
+
+/**
+ * @deprecated do not use it, it will be removed
+ */
+function validateURL(baseUrl: string, endpoint: string): URL | undefined {
+ try {
+ return new URL(`${baseUrl}${endpoint}`)
+ } catch (ex) {
+ return undefined
+ }
+
+} \ No newline at end of file
diff --git a/packages/web-util/src/utils/route.ts b/packages/web-util/src/utils/route.ts
new file mode 100644
index 000000000..4f8a020f6
--- /dev/null
+++ b/packages/web-util/src/utils/route.ts
@@ -0,0 +1,129 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+declare const __location: unique symbol;
+/**
+ * special string that defined a location in the application
+ *
+ * this help to prevent wrong path
+ */
+export type AppLocation = string & {
+ [__location]: true;
+};
+
+export type EmptyObject = Record<string, never>;
+
+export function urlPattern<
+ T extends Record<string, string | undefined> = EmptyObject,
+>(pattern: RegExp, reverse: (p: T) => string): RouteDefinition<T> {
+ const url = reverse as (p: T) => AppLocation;
+ return {
+ pattern: new RegExp(pattern),
+ url,
+ };
+}
+
+/**
+ * defines a location in the app
+ *
+ * pattern: how a string will trigger this location
+ * url(): how a state serialize to a location
+ */
+
+export type ObjectOf<T> = Record<string, T> | EmptyObject;
+
+export type RouteDefinition<
+ T extends ObjectOf<string | undefined> = EmptyObject,
+> = {
+ pattern: RegExp;
+ url: (p: T) => AppLocation;
+};
+
+const nullRountDef = {
+ pattern: new RegExp(/.*/),
+ url: () => "" as AppLocation,
+};
+export function buildNullRoutDefinition<
+ T extends ObjectOf<string>,
+>(): RouteDefinition<T> {
+ return nullRountDef;
+}
+
+/**
+ * Search path in the pageList
+ * get the values from the path found
+ * add params from searchParams
+ *
+ * @param path
+ * @param params
+ */
+export function findMatch<T extends ObjectOf<RouteDefinition>>(
+ pagesMap: T,
+ pageList: Array<keyof T>,
+ path: string,
+ params: Record<string, string>,
+): Location<T> | undefined {
+ for (let idx = 0; idx < pageList.length; idx++) {
+ const name = pageList[idx];
+ const found = pagesMap[name].pattern.exec(path);
+ if (found !== null) {
+ const values = {} as Record<string, unknown>;
+
+ Object.entries(params).forEach(([key, value]) => {
+ values[key] = value;
+ });
+
+ if (found.groups !== undefined) {
+ Object.entries(found.groups).forEach(([key, value]) => {
+ values[key] = value;
+ });
+ }
+
+ // @ts-expect-error values is a map string which is equivalent to the RouteParamsType
+ return { name, parent: pagesMap, values };
+ }
+ }
+ return undefined;
+}
+
+/**
+ * get the type of the params of a location
+ *
+ */
+type RouteParamsType<
+ RouteType,
+ Key extends keyof RouteType,
+> = RouteType[Key] extends RouteDefinition<infer ParamType> ? ParamType : never;
+
+/**
+ * Helps to create a map of a type with the key
+ */
+type MapKeyValue<Type> = {
+ [Key in keyof Type]: Key extends string
+ ? {
+ parent: Type;
+ name: Key;
+ values: RouteParamsType<Type, Key>;
+ }
+ : never;
+};
+
+/**
+ * create a enumeration of value of a mapped type
+ */
+type EnumerationOf<T> = T[keyof T];
+
+export type Location<T> = EnumerationOf<MapKeyValue<T>>;
diff --git a/packages/web-util/tsconfig.json b/packages/web-util/tsconfig.json
new file mode 100644
index 000000000..a315dda1c
--- /dev/null
+++ b/packages/web-util/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "declaration": true,
+ "declarationMap": true,
+ "target": "ES2020",
+ "module": "Node16",
+ "jsx": "react",
+ "jsxFactory": "h",
+ "jsxFragmentFactory": "Fragment",
+ "moduleResolution": "Node16",
+ "sourceMap": true,
+ "lib": ["DOM", "ES2020"],
+ "outDir": "lib",
+ "preserveSymlinks": true,
+ "skipLibCheck": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "strict": true,
+ "strictPropertyInitialization": false,
+ "noImplicitAny": true,
+ "noImplicitThis": true,
+ "incremental": true,
+ "esModuleInterop": true,
+ "importHelpers": true,
+ "rootDir": "./src",
+ "typeRoots": ["./node_modules/@types"]
+ },
+ "include": ["src/**/*"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd6d7ae58..b1c5511c8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,7998 +1,6523 @@
-lockfileVersion: 5.3
+lockfileVersion: '6.1'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
importers:
.:
- specifiers:
- '@linaria/esbuild': ^3.0.0-beta.7
- '@linaria/shaker': ^3.0.0-beta.7
- esbuild: ^0.12.21
devDependencies:
- '@linaria/esbuild': 3.0.0-beta.7
- '@linaria/shaker': 3.0.0-beta.7
- esbuild: 0.12.21
+ '@babel/core':
+ specifier: 7.13.16
+ version: 7.13.16
+ '@linaria/esbuild':
+ specifier: ^3.0.0-beta.15
+ version: 3.0.0-beta.23
+ '@linaria/shaker':
+ specifier: ^3.0.0-beta.15
+ version: 3.0.0-beta.23
+ esbuild:
+ specifier: ^0.19.9
+ version: 0.19.9
+ prettier:
+ specifier: ^3.1.1
+ version: 3.1.1
+ typedoc:
+ specifier: ^0.25.4
+ version: 0.25.4(typescript@5.3.3)
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
+
+ packages/aml-backoffice-ui:
+ dependencies:
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@gnu-taler/web-util':
+ specifier: workspace:*
+ version: link:../web-util
+ '@headlessui/react':
+ specifier: ^1.7.14
+ version: 1.7.14(react-dom@18.2.0)(react@18.2.0)
+ '@heroicons/react':
+ specifier: ^2.0.17
+ version: 2.0.17(react@18.2.0)
+ date-fns:
+ specifier: 2.29.3
+ version: 2.29.3
+ history:
+ specifier: 4.10.1
+ version: 4.10.1
+ jed:
+ specifier: 1.1.1
+ version: 1.1.1
+ preact:
+ specifier: 10.11.3
+ version: 10.11.3
+ swr:
+ specifier: 2.2.2
+ version: 2.2.2(react@18.2.0)
+ devDependencies:
+ '@gnu-taler/pogen':
+ specifier: ^0.0.5
+ version: link:../pogen
+ '@tailwindcss/forms':
+ specifier: ^0.5.3
+ version: 0.5.3(tailwindcss@3.3.2)
+ '@tailwindcss/typography':
+ specifier: ^0.5.9
+ version: 0.5.9(tailwindcss@3.3.2)
+ '@types/chai':
+ specifier: ^4.3.0
+ version: 4.3.3
+ '@types/history':
+ specifier: ^4.7.8
+ version: 4.7.11
+ '@types/mocha':
+ specifier: ^10.0.1
+ version: 10.0.1
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^6.19.0
+ version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/parser':
+ specifier: ^6.19.0
+ version: 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ autoprefixer:
+ specifier: ^10.4.14
+ version: 10.4.14(postcss@8.4.23)
+ chai:
+ specifier: ^4.3.6
+ version: 4.3.6
+ esbuild:
+ specifier: ^0.19.9
+ version: 0.19.9
+ eslint:
+ specifier: ^8.56.0
+ version: 8.56.0
+ eslint-config-prettier:
+ specifier: ^9.1.0
+ version: 9.1.0(eslint@8.56.0)
+ eslint-plugin-react:
+ specifier: ^7.33.2
+ version: 7.33.2(eslint@8.56.0)
+ mocha:
+ specifier: ^9.2.0
+ version: 9.2.2
+ po2json:
+ specifier: ^0.4.5
+ version: 0.4.5
+ postcss:
+ specifier: ^8.4.23
+ version: 8.4.23
+ postcss-cli:
+ specifier: ^10.1.0
+ version: 10.1.0(postcss@8.4.23)
+ tailwindcss:
+ specifier: ^3.3.2
+ version: 3.3.2
+ typescript:
+ specifier: 5.3.3
+ version: 5.3.3
+
+ packages/anastasis-cli:
+ dependencies:
+ '@gnu-taler/anastasis-core':
+ specifier: workspace:*
+ version: link:../anastasis-core
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ tslib:
+ specifier: ^2.6.2
+ version: 2.6.2
+ devDependencies:
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ prettier:
+ specifier: ^3.1.1
+ version: 3.1.1
+ typedoc:
+ specifier: ^0.25.4
+ version: 0.25.4(typescript@5.3.3)
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
packages/anastasis-core:
- specifiers:
- '@gnu-taler/taler-util': workspace:^0.8.3
- ava: ^3.15.0
- fetch-ponyfill: ^7.1.0
- fflate: ^0.6.0
- hash-wasm: ^4.9.0
- node-fetch: ^3.0.0
- typescript: ^4.4.3
- dependencies:
- '@gnu-taler/taler-util': link:../taler-util
- fetch-ponyfill: 7.1.0
- fflate: 0.6.0
- hash-wasm: 4.9.0
- node-fetch: 3.0.0
+ dependencies:
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ fflate:
+ specifier: ^0.8.1
+ version: 0.8.1
+ tslib:
+ specifier: ^2.6.2
+ version: 2.6.2
devDependencies:
- ava: 3.15.0
- typescript: 4.4.3
+ ava:
+ specifier: ^6.0.1
+ version: 6.0.1(@ava/typescript@4.1.0)
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
packages/anastasis-webui:
- specifiers:
- '@creativebulma/bulma-tooltip': ^1.2.0
- '@gnu-taler/taler-util': workspace:^0.8.3
- '@storybook/addon-a11y': ^6.2.9
- '@storybook/addon-actions': ^6.2.9
- '@storybook/addon-essentials': ^6.2.9
- '@storybook/addon-links': ^6.2.9
- '@storybook/preact': ^6.2.9
- '@storybook/preset-scss': ^1.0.3
- '@types/enzyme': ^3.10.5
- '@types/jest': ^26.0.8
- '@typescript-eslint/eslint-plugin': ^2.25.0
- '@typescript-eslint/parser': ^2.25.0
- anastasis-core: workspace:^0.0.1
- bulma: ^0.9.3
- bulma-checkbox: ^1.1.1
- bulma-radio: ^1.1.1
- enzyme: ^3.11.0
- enzyme-adapter-preact-pure: ^3.1.0
- eslint: ^6.8.0
- eslint-config-preact: ^1.1.1
- jed: 1.1.1
- jest: ^26.2.2
- jest-preset-preact: ^4.0.2
- preact: ^10.3.1
- preact-cli: ^3.2.2
- preact-render-to-string: ^5.1.4
- preact-router: ^3.2.1
- sass: ^1.32.13
- sass-loader: ^10.1.1
- sirv-cli: ^1.0.0-next.3
- typescript: ^3.7.5
- dependencies:
- '@gnu-taler/taler-util': link:../taler-util
- anastasis-core: link:../anastasis-core
- jed: 1.1.1
- preact: 10.5.14
- preact-render-to-string: 5.1.19_preact@10.5.14
- preact-router: 3.2.1_preact@10.5.14
+ dependencies:
+ '@gnu-taler/anastasis-core':
+ specifier: workspace:*
+ version: link:../anastasis-core
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@gnu-taler/web-util':
+ specifier: workspace:*
+ version: link:../web-util
+ date-fns:
+ specifier: 2.29.2
+ version: 2.29.2
+ jed:
+ specifier: 1.1.1
+ version: 1.1.1
+ jssha:
+ specifier: ^3.3.0
+ version: 3.3.0
+ preact:
+ specifier: 10.11.3
+ version: 10.11.3
+ preact-router:
+ specifier: ^3.2.1
+ version: 3.2.1(preact@10.11.3)
+ qrcode-generator:
+ specifier: ^1.4.4
+ version: 1.4.4
+ devDependencies:
+ '@creativebulma/bulma-tooltip':
+ specifier: ^1.2.0
+ version: 1.2.0
+ '@types/chai':
+ specifier: ^4.3.0
+ version: 4.3.3
+ '@types/mocha':
+ specifier: ^9.0.0
+ version: 9.1.1
+ bulma:
+ specifier: ^0.9.3
+ version: 0.9.4
+ bulma-checkbox:
+ specifier: ^1.1.1
+ version: 1.2.1
+ bulma-radio:
+ specifier: ^1.1.1
+ version: 1.2.0
+ chai:
+ specifier: ^4.3.6
+ version: 4.3.6
+ mocha:
+ specifier: ^9.2.0
+ version: 9.2.2
+ sass:
+ specifier: 1.56.1
+ version: 1.56.1
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
+
+ packages/auditor-backoffice-ui:
+ dependencies:
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@gnu-taler/web-util':
+ specifier: workspace:*
+ version: link:../web-util
+ date-fns:
+ specifier: 2.29.3
+ version: 2.29.3
+ history:
+ specifier: 4.10.1
+ version: 4.10.1
+ jed:
+ specifier: 1.1.1
+ version: 1.1.1
+ preact:
+ specifier: 10.11.3
+ version: 10.11.3
+ preact-router:
+ specifier: 3.2.1
+ version: 3.2.1(preact@10.11.3)
+ qrcode-generator:
+ specifier: 1.4.4
+ version: 1.4.4
+ swr:
+ specifier: 2.2.2
+ version: 2.2.2(react@18.2.0)
+ yup:
+ specifier: ^0.32.9
+ version: 0.32.11
devDependencies:
- '@creativebulma/bulma-tooltip': 1.2.0
- '@storybook/addon-a11y': 6.3.7
- '@storybook/addon-actions': 6.3.7
- '@storybook/addon-essentials': 6.3.7_typescript@3.9.10
- '@storybook/addon-links': 6.3.12
- '@storybook/preact': 6.3.7_preact@10.5.14+typescript@3.9.10
- '@storybook/preset-scss': 1.0.3_sass-loader@10.2.0
- '@types/enzyme': 3.10.9
- '@types/jest': 26.0.24
- '@typescript-eslint/eslint-plugin': 2.34.0_2b015b1c4b7c4a3ed9a197dc233b1a35
- '@typescript-eslint/parser': 2.34.0_eslint@6.8.0+typescript@3.9.10
- bulma: 0.9.3
- bulma-checkbox: 1.1.1
- bulma-radio: 1.1.1
- enzyme: 3.11.0
- enzyme-adapter-preact-pure: 3.1.0_enzyme@3.11.0+preact@10.5.14
- eslint: 6.8.0
- eslint-config-preact: 1.1.4_eslint@6.8.0+typescript@3.9.10
- jest: 26.6.3
- jest-preset-preact: 4.0.2_9b3f24ae35a87c3c82fffbe3fdf70e1e
- preact-cli: 3.2.2_8d1b4ee21ca5a56b4aabd4a3e659b2d7
- sass: 1.43.2
- sass-loader: 10.2.0_sass@1.43.2
- sirv-cli: 1.0.14
- typescript: 3.9.10
+ '@creativebulma/bulma-tooltip':
+ specifier: ^1.2.0
+ version: 1.2.0
+ '@gnu-taler/pogen':
+ specifier: ^0.0.5
+ version: link:../pogen
+ '@types/chai':
+ specifier: ^4.3.0
+ version: 4.3.3
+ '@types/history':
+ specifier: ^4.7.8
+ version: 4.7.11
+ '@types/mocha':
+ specifier: ^8.2.3
+ version: 8.2.3
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^4.22.0
+ version: 4.33.0(@typescript-eslint/parser@4.33.0)(eslint@7.32.0)(typescript@5.3.3)
+ '@typescript-eslint/parser':
+ specifier: ^4.22.0
+ version: 4.33.0(eslint@7.32.0)(typescript@5.3.3)
+ base64-inline-loader:
+ specifier: ^1.1.1
+ version: 1.1.1(webpack@4.47.0)
+ bulma:
+ specifier: ^0.9.2
+ version: 0.9.4
+ bulma-checkbox:
+ specifier: ^1.1.1
+ version: 1.2.1
+ bulma-radio:
+ specifier: ^1.1.1
+ version: 1.2.0
+ bulma-responsive-tables:
+ specifier: ^1.2.3
+ version: 1.2.5
+ bulma-switch-control:
+ specifier: ^1.1.1
+ version: 1.2.2
+ bulma-timeline:
+ specifier: ^3.0.4
+ version: 3.0.5
+ bulma-upload-control:
+ specifier: ^1.2.0
+ version: 1.2.0
+ chai:
+ specifier: ^4.3.6
+ version: 4.3.6
+ dotenv:
+ specifier: ^8.2.0
+ version: 8.6.0
+ eslint:
+ specifier: ^7.25.0
+ version: 7.32.0
+ eslint-config-preact:
+ specifier: ^1.1.4
+ version: 1.3.0(@typescript-eslint/eslint-plugin@4.33.0)(eslint@7.32.0)(typescript@5.3.3)
+ eslint-plugin-header:
+ specifier: ^3.1.1
+ version: 3.1.1(eslint@7.32.0)
+ html-webpack-inline-chunk-plugin:
+ specifier: ^1.1.1
+ version: 1.1.1
+ html-webpack-inline-source-plugin:
+ specifier: 0.0.10
+ version: 0.0.10
+ html-webpack-skip-assets-plugin:
+ specifier: ^1.0.1
+ version: 1.0.3(html-webpack-plugin@5.5.4)(webpack@4.47.0)
+ inline-chunk-html-plugin:
+ specifier: ^1.1.1
+ version: 1.1.1
+ mocha:
+ specifier: ^9.2.0
+ version: 9.2.2
+ preact-render-to-string:
+ specifier: ^5.2.6
+ version: 5.2.6(preact@10.11.3)
+ sass:
+ specifier: 1.56.1
+ version: 1.56.1
+ source-map-support:
+ specifier: ^0.5.21
+ version: 0.5.21
+ typedoc:
+ specifier: ^0.25.4
+ version: 0.25.4(typescript@5.3.3)
+ typescript:
+ specifier: 5.3.3
+ version: 5.3.3
+
+ packages/bank-ui:
+ dependencies:
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@gnu-taler/web-util':
+ specifier: workspace:*
+ version: link:../web-util
+ date-fns:
+ specifier: 2.29.3
+ version: 2.29.3
+ jed:
+ specifier: 1.1.1
+ version: 1.1.1
+ preact:
+ specifier: 10.11.3
+ version: 10.11.3
+ qrcode-generator:
+ specifier: ^1.4.4
+ version: 1.4.4
+ swr:
+ specifier: 2.0.3
+ version: 2.0.3(react@18.2.0)
+ devDependencies:
+ '@gnu-taler/pogen':
+ specifier: ^0.0.5
+ version: link:../pogen
+ '@tailwindcss/forms':
+ specifier: ^0.5.3
+ version: 0.5.3(tailwindcss@3.3.2)
+ '@tailwindcss/typography':
+ specifier: ^0.5.9
+ version: 0.5.9(tailwindcss@3.3.2)
+ '@types/chai':
+ specifier: ^4.3.0
+ version: 4.3.3
+ '@types/history':
+ specifier: ^4.7.8
+ version: 4.7.11
+ '@types/mocha':
+ specifier: ^10.0.1
+ version: 10.0.1
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^6.19.0
+ version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/parser':
+ specifier: ^6.19.0
+ version: 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ autoprefixer:
+ specifier: ^10.4.14
+ version: 10.4.14(postcss@8.4.33)
+ chai:
+ specifier: ^4.3.6
+ version: 4.3.6
+ esbuild:
+ specifier: ^0.19.9
+ version: 0.19.9
+ eslint:
+ specifier: ^8.56.0
+ version: 8.56.0
+ eslint-config-prettier:
+ specifier: ^9.1.0
+ version: 9.1.0(eslint@8.56.0)
+ eslint-plugin-react:
+ specifier: ^7.33.2
+ version: 7.33.2(eslint@8.56.0)
+ mocha:
+ specifier: 9.2.0
+ version: 9.2.0
+ po2json:
+ specifier: ^0.4.5
+ version: 0.4.5
+ tailwindcss:
+ specifier: ^3.3.2
+ version: 3.3.2
+ typescript:
+ specifier: 5.3.3
+ version: 5.3.3
+
+ packages/challenger-ui:
+ devDependencies:
+ '@gnu-taler/pogen':
+ specifier: ^0.0.5
+ version: link:../pogen
+ '@gnu-taler/web-util':
+ specifier: workspace:*
+ version: link:../web-util
+ '@tailwindcss/forms':
+ specifier: ^0.5.3
+ version: 0.5.3(tailwindcss@3.3.2)
+ '@tailwindcss/typography':
+ specifier: ^0.5.9
+ version: 0.5.9(tailwindcss@3.3.2)
+ autoprefixer:
+ specifier: ^10.4.14
+ version: 10.4.14(postcss@8.4.23)
+ esbuild:
+ specifier: ^0.19.9
+ version: 0.19.9
+ po2json:
+ specifier: ^0.4.5
+ version: 0.4.5
+ postcss:
+ specifier: ^8.4.23
+ version: 8.4.23
+ postcss-cli:
+ specifier: ^10.1.0
+ version: 10.1.0(postcss@8.4.23)
+ tailwindcss:
+ specifier: ^3.3.2
+ version: 3.3.2
packages/idb-bridge:
- specifiers:
- '@rollup/plugin-commonjs': ^17.1.0
- '@rollup/plugin-json': ^4.1.0
- '@rollup/plugin-node-resolve': ^11.2.0
- '@types/node': ^14.14.22
- ava: ^3.15.0
- esm: ^3.2.25
- prettier: ^2.2.1
- rimraf: ^3.0.2
- rollup: ^2.37.1
- tslib: ^2.1.0
- typescript: ^4.1.3
- dependencies:
- tslib: 2.1.0
+ dependencies:
+ tslib:
+ specifier: ^2.6.2
+ version: 2.6.2
+ optionalDependencies:
+ better-sqlite3:
+ specifier: 9.4.0
+ version: 9.4.0
devDependencies:
- '@rollup/plugin-commonjs': 17.1.0_rollup@2.37.1
- '@rollup/plugin-json': 4.1.0_rollup@2.37.1
- '@rollup/plugin-node-resolve': 11.2.0_rollup@2.37.1
- '@types/node': 14.14.22
- ava: 3.15.0
- esm: 3.2.25
- prettier: 2.2.1
- rimraf: 3.0.2
- rollup: 2.37.1
- typescript: 4.1.3
+ '@types/better-sqlite3':
+ specifier: ^7.6.8
+ version: 7.6.8
+ '@types/node':
+ specifier: ^20.4.1
+ version: 20.4.1
+ ava:
+ specifier: ^6.0.1
+ version: 6.0.1(@ava/typescript@4.1.0)
+ prettier:
+ specifier: ^3.1.1
+ version: 3.1.1
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
+
+ packages/merchant-backend-ui:
+ dependencies:
+ date-fns:
+ specifier: ^2.21.1
+ version: 2.29.3
+ preact:
+ specifier: 10.11.3
+ version: 10.11.3
+ qrcode-generator:
+ specifier: ^1.4.4
+ version: 1.4.4
+ devDependencies:
+ '@babel/core':
+ specifier: 7.18.9
+ version: 7.18.9
+ '@gnu-taler/pogen':
+ specifier: ^0.0.5
+ version: link:../pogen
+ '@linaria/babel-preset':
+ specifier: 3.0.0-beta.22
+ version: 3.0.0-beta.22
+ '@linaria/core':
+ specifier: 3.0.0-beta.22
+ version: 3.0.0-beta.22
+ '@linaria/react':
+ specifier: 3.0.0-beta.22
+ version: 3.0.0-beta.22(react@18.2.0)
+ '@linaria/shaker':
+ specifier: 3.0.0-beta.22
+ version: 3.0.0-beta.22
+ '@linaria/webpack-loader':
+ specifier: 3.0.0-beta.22
+ version: 3.0.0-beta.22(webpack@4.47.0)
+ '@types/mocha':
+ specifier: ^8.2.2
+ version: 8.2.3
+ '@types/mustache':
+ specifier: ^4.1.2
+ version: 4.2.1
+ '@types/node':
+ specifier: ^20.11.13
+ version: 20.11.13
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^4.22.0
+ version: 4.33.0(@typescript-eslint/parser@4.33.0)(eslint@7.32.0)(typescript@5.3.3)
+ '@typescript-eslint/parser':
+ specifier: ^4.22.0
+ version: 4.33.0(eslint@7.32.0)(typescript@5.3.3)
+ babel-loader:
+ specifier: ^8.2.2
+ version: 8.2.5(@babel/core@7.18.9)(webpack@4.47.0)
+ base64-inline-loader:
+ specifier: ^1.1.1
+ version: 1.1.1(webpack@4.47.0)
+ eslint:
+ specifier: ^7.25.0
+ version: 7.32.0
+ eslint-config-preact:
+ specifier: ^1.1.4
+ version: 1.3.0(@typescript-eslint/eslint-plugin@4.33.0)(eslint@7.32.0)(typescript@5.3.3)
+ eslint-plugin-header:
+ specifier: ^3.1.1
+ version: 3.1.1(eslint@7.32.0)
+ mustache:
+ specifier: ^4.2.0
+ version: 4.2.0
+ po2json:
+ specifier: ^0.4.5
+ version: 0.4.5
+ preact-render-to-string:
+ specifier: ^5.1.19
+ version: 5.2.6(preact@10.11.3)
+ sirv-cli:
+ specifier: ^1.0.11
+ version: 1.0.14
+ ts-node:
+ specifier: ^10.9.1
+ version: 10.9.1(@types/node@20.11.13)(typescript@5.3.3)
+ tslib:
+ specifier: 2.6.2
+ version: 2.6.2
+ typescript:
+ specifier: 5.3.3
+ version: 5.3.3
+
+ packages/merchant-backoffice-ui:
+ dependencies:
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@gnu-taler/web-util':
+ specifier: workspace:*
+ version: link:../web-util
+ date-fns:
+ specifier: 2.29.3
+ version: 2.29.3
+ history:
+ specifier: 4.10.1
+ version: 4.10.1
+ jed:
+ specifier: 1.1.1
+ version: 1.1.1
+ preact:
+ specifier: 10.11.3
+ version: 10.11.3
+ preact-router:
+ specifier: 3.2.1
+ version: 3.2.1(preact@10.11.3)
+ qrcode-generator:
+ specifier: 1.4.4
+ version: 1.4.4
+ swr:
+ specifier: 2.2.2
+ version: 2.2.2(react@18.2.0)
+ yup:
+ specifier: ^0.32.9
+ version: 0.32.11
+ devDependencies:
+ '@creativebulma/bulma-tooltip':
+ specifier: ^1.2.0
+ version: 1.2.0
+ '@gnu-taler/pogen':
+ specifier: ^0.0.5
+ version: link:../pogen
+ '@types/chai':
+ specifier: ^4.3.0
+ version: 4.3.3
+ '@types/history':
+ specifier: ^4.7.8
+ version: 4.7.11
+ '@types/mocha':
+ specifier: ^8.2.3
+ version: 8.2.3
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^6.19.0
+ version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/parser':
+ specifier: ^6.19.0
+ version: 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ base64-inline-loader:
+ specifier: ^1.1.1
+ version: 1.1.1(webpack@4.47.0)
+ bulma:
+ specifier: ^0.9.2
+ version: 0.9.4
+ bulma-checkbox:
+ specifier: ^1.1.1
+ version: 1.2.1
+ bulma-radio:
+ specifier: ^1.1.1
+ version: 1.2.0
+ bulma-responsive-tables:
+ specifier: ^1.2.3
+ version: 1.2.5
+ bulma-switch-control:
+ specifier: ^1.1.1
+ version: 1.2.2
+ bulma-timeline:
+ specifier: ^3.0.4
+ version: 3.0.5
+ bulma-upload-control:
+ specifier: ^1.2.0
+ version: 1.2.0
+ chai:
+ specifier: ^4.3.6
+ version: 4.3.6
+ dotenv:
+ specifier: ^8.2.0
+ version: 8.6.0
+ eslint:
+ specifier: ^8.56.0
+ version: 8.56.0
+ eslint-config-prettier:
+ specifier: ^9.1.0
+ version: 9.1.0(eslint@8.56.0)
+ eslint-plugin-react:
+ specifier: ^7.33.2
+ version: 7.33.2(eslint@8.56.0)
+ html-webpack-inline-chunk-plugin:
+ specifier: ^1.1.1
+ version: 1.1.1
+ html-webpack-inline-source-plugin:
+ specifier: 0.0.10
+ version: 0.0.10
+ html-webpack-skip-assets-plugin:
+ specifier: ^1.0.1
+ version: 1.0.3(html-webpack-plugin@5.5.4)(webpack@4.47.0)
+ inline-chunk-html-plugin:
+ specifier: ^1.1.1
+ version: 1.1.1
+ mocha:
+ specifier: ^9.2.0
+ version: 9.2.2
+ preact-render-to-string:
+ specifier: ^5.2.6
+ version: 5.2.6(preact@10.11.3)
+ sass:
+ specifier: 1.56.1
+ version: 1.56.1
+ source-map-support:
+ specifier: ^0.5.21
+ version: 0.5.21
+ typedoc:
+ specifier: ^0.25.4
+ version: 0.25.4(typescript@5.3.3)
+ typescript:
+ specifier: 5.3.3
+ version: 5.3.3
packages/pogen:
- specifiers:
- '@types/node': ^14.14.22
- typescript: ^4.1.3
dependencies:
- '@types/node': 14.14.22
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ glob:
+ specifier: ^10.3.10
+ version: 10.3.10
devDependencies:
- typescript: 4.1.3
+ po2json:
+ specifier: ^0.4.5
+ version: 0.4.5
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
+
+ packages/taler-harness:
+ dependencies:
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@gnu-taler/taler-wallet-core':
+ specifier: workspace:*
+ version: link:../taler-wallet-core
+ tslib:
+ specifier: ^2.6.2
+ version: 2.6.2
+ devDependencies:
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ esbuild:
+ specifier: ^0.19.9
+ version: 0.19.9
+ prettier:
+ specifier: ^3.1.1
+ version: 3.1.1
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
packages/taler-util:
- specifiers:
- '@types/node': ^14.14.22
- ava: ^3.15.0
- big-integer: ^1.6.48
- esbuild: ^0.9.2
- jed: ^1.1.1
- prettier: ^2.2.1
- rimraf: ^3.0.2
- tslib: ^2.1.0
- typescript: ^4.2.3
- dependencies:
- big-integer: 1.6.48
- jed: 1.1.1
- tslib: 2.1.0
+ dependencies:
+ big-integer:
+ specifier: ^1.6.52
+ version: 1.6.52
+ fflate:
+ specifier: ^0.8.1
+ version: 0.8.1
+ follow-redirects:
+ specifier: ^1.15.5
+ version: 1.15.5
+ hash-wasm:
+ specifier: ^4.11.0
+ version: 4.11.0
+ jed:
+ specifier: ^1.1.1
+ version: 1.1.1
+ tslib:
+ specifier: ^2.6.2
+ version: 2.6.2
devDependencies:
- '@types/node': 14.14.34
- ava: 3.15.0
- esbuild: 0.9.2
- prettier: 2.2.1
- rimraf: 3.0.2
- typescript: 4.2.3
+ '@types/follow-redirects':
+ specifier: ^1.14.4
+ version: 1.14.4
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^6.19.0
+ version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/parser':
+ specifier: ^6.19.0
+ version: 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ ava:
+ specifier: ^6.0.1
+ version: 6.0.1(@ava/typescript@4.1.0)
+ esbuild:
+ specifier: ^0.19.9
+ version: 0.19.9
+ eslint:
+ specifier: ^8.56.0
+ version: 8.56.0
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
packages/taler-wallet-cli:
- specifiers:
- '@gnu-taler/taler-util': workspace:*
- '@gnu-taler/taler-wallet-core': workspace:*
- '@rollup/plugin-commonjs': ^17.0.0
- '@rollup/plugin-json': ^4.1.0
- '@rollup/plugin-node-resolve': ^11.1.0
- '@rollup/plugin-replace': ^2.3.4
- '@types/node': ^14.14.22
- axios: ^0.21.1
- cancellationtoken: ^2.2.0
- prettier: ^2.2.1
- rimraf: ^3.0.2
- rollup: ^2.37.1
- rollup-plugin-sourcemaps: ^0.6.3
- rollup-plugin-terser: ^7.0.2
- source-map-support: ^0.5.19
- tslib: ^2.1.0
- typedoc: ^0.20.16
- typescript: ^4.1.3
- dependencies:
- '@gnu-taler/taler-util': link:../taler-util
- '@gnu-taler/taler-wallet-core': link:../taler-wallet-core
- axios: 0.21.1
- cancellationtoken: 2.2.0
- source-map-support: 0.5.19
- tslib: 2.1.0
+ dependencies:
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@gnu-taler/taler-wallet-core':
+ specifier: workspace:*
+ version: link:../taler-wallet-core
+ tslib:
+ specifier: ^2.6.2
+ version: 2.6.2
devDependencies:
- '@rollup/plugin-commonjs': 17.0.0_rollup@2.37.1
- '@rollup/plugin-json': 4.1.0_rollup@2.37.1
- '@rollup/plugin-node-resolve': 11.1.0_rollup@2.37.1
- '@rollup/plugin-replace': 2.3.4_rollup@2.37.1
- '@types/node': 14.14.22
- prettier: 2.2.1
- rimraf: 3.0.2
- rollup: 2.37.1
- rollup-plugin-sourcemaps: 0.6.3_38ff52cc32daa1ae80c428f8a47a4e22
- rollup-plugin-terser: 7.0.2_rollup@2.37.1
- typedoc: 0.20.16_typescript@4.1.3
- typescript: 4.1.3
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ prettier:
+ specifier: ^3.1.1
+ version: 3.1.1
+ typedoc:
+ specifier: ^0.25.4
+ version: 0.25.4(typescript@5.3.3)
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
packages/taler-wallet-core:
- specifiers:
- '@ava/typescript': ^1.1.1
- '@gnu-taler/idb-bridge': workspace:*
- '@gnu-taler/pogen': workspace:*
- '@gnu-taler/taler-util': workspace:*
- '@microsoft/api-extractor': ^7.13.0
- '@types/node': ^14.14.22
- '@typescript-eslint/eslint-plugin': ^4.14.0
- '@typescript-eslint/parser': ^4.14.0
- ava: ^3.15.0
- axios: ^0.21.1
- big-integer: ^1.6.48
- eslint: ^7.18.0
- eslint-config-airbnb-typescript: ^12.0.0
- eslint-plugin-import: ^2.22.1
- eslint-plugin-jsx-a11y: ^6.4.1
- eslint-plugin-react: ^7.22.0
- eslint-plugin-react-hooks: ^4.2.0
- fflate: ^0.6.0
- jed: ^1.1.1
- nyc: ^15.1.0
- po2json: ^0.4.5
- prettier: ^2.2.1
- rimraf: ^3.0.2
- rollup: ^2.37.1
- rollup-plugin-sourcemaps: ^0.6.3
- source-map-resolve: ^0.6.0
- source-map-support: ^0.5.19
- tslib: ^2.1.0
- typedoc: ^0.20.16
- typescript: ^4.1.3
- dependencies:
- '@gnu-taler/idb-bridge': link:../idb-bridge
- '@gnu-taler/taler-util': link:../taler-util
- '@types/node': 14.14.22
- axios: 0.21.1
- big-integer: 1.6.48
- fflate: 0.6.0
- source-map-support: 0.5.19
- tslib: 2.1.0
+ dependencies:
+ '@gnu-taler/idb-bridge':
+ specifier: workspace:*
+ version: link:../idb-bridge
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ big-integer:
+ specifier: ^1.6.52
+ version: 1.6.52
+ fflate:
+ specifier: ^0.8.1
+ version: 0.8.1
+ tslib:
+ specifier: ^2.6.2
+ version: 2.6.2
devDependencies:
- '@ava/typescript': 1.1.1
- '@gnu-taler/pogen': link:../pogen
- '@microsoft/api-extractor': 7.13.0
- '@typescript-eslint/eslint-plugin': 4.14.0_980e7d90d2d08155204a38366bd3b934
- '@typescript-eslint/parser': 4.14.0_eslint@7.18.0+typescript@4.1.3
- ava: 3.15.0
- eslint: 7.18.0
- eslint-config-airbnb-typescript: 12.0.0_aa91c0ea1e61103ae60b9cd49dfd9775
- eslint-plugin-import: 2.22.1_eslint@7.18.0
- eslint-plugin-jsx-a11y: 6.4.1_eslint@7.18.0
- eslint-plugin-react: 7.22.0_eslint@7.18.0
- eslint-plugin-react-hooks: 4.2.0_eslint@7.18.0
- jed: 1.1.1
- nyc: 15.1.0
- po2json: 0.4.5
- prettier: 2.2.1
- rimraf: 3.0.2
- rollup: 2.37.1
- rollup-plugin-sourcemaps: 0.6.3_38ff52cc32daa1ae80c428f8a47a4e22
- source-map-resolve: 0.6.0
- typedoc: 0.20.16_typescript@4.1.3
- typescript: 4.1.3
+ '@ava/typescript':
+ specifier: ^4.1.0
+ version: 4.1.0
+ '@gnu-taler/pogen':
+ specifier: workspace:*
+ version: link:../pogen
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^5.36.1
+ version: 5.41.0(@typescript-eslint/parser@5.41.0)(eslint@8.26.0)(typescript@5.3.3)
+ '@typescript-eslint/parser':
+ specifier: ^5.36.1
+ version: 5.41.0(eslint@8.26.0)(typescript@5.3.3)
+ ava:
+ specifier: ^6.0.1
+ version: 6.0.1(@ava/typescript@4.1.0)
+ c8:
+ specifier: ^8.0.1
+ version: 8.0.1
+ eslint:
+ specifier: ^8.8.0
+ version: 8.26.0
+ eslint-config-airbnb-typescript:
+ specifier: ^17.1.0
+ version: 17.1.0(@typescript-eslint/eslint-plugin@5.41.0)(@typescript-eslint/parser@5.41.0)(eslint-plugin-import@2.29.1)(eslint@8.26.0)
+ eslint-plugin-import:
+ specifier: ^2.29.1
+ version: 2.29.1(@typescript-eslint/parser@5.41.0)(eslint@8.26.0)
+ eslint-plugin-jsx-a11y:
+ specifier: ^6.8.0
+ version: 6.8.0(eslint@8.26.0)
+ eslint-plugin-react:
+ specifier: ^7.33.2
+ version: 7.33.2(eslint@8.26.0)
+ eslint-plugin-react-hooks:
+ specifier: ^4.3.0
+ version: 4.6.0(eslint@8.26.0)
+ jed:
+ specifier: ^1.1.1
+ version: 1.1.1
+ po2json:
+ specifier: ^0.4.5
+ version: 0.4.5
+ prettier:
+ specifier: ^3.1.1
+ version: 3.1.1
+ typedoc:
+ specifier: ^0.25.4
+ version: 0.25.4(typescript@5.3.3)
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
packages/taler-wallet-embedded:
- specifiers:
- '@gnu-taler/taler-util': workspace:*
- '@gnu-taler/taler-wallet-core': workspace:*
- '@rollup/plugin-commonjs': ^17.0.0
- '@rollup/plugin-json': ^4.1.0
- '@rollup/plugin-node-resolve': ^11.1.0
- '@rollup/plugin-replace': ^2.3.4
- '@types/node': ^14.14.22
- prettier: ^2.2.1
- rimraf: ^3.0.2
- rollup: ^2.43.0
- rollup-plugin-sourcemaps: ^0.6.3
- rollup-plugin-terser: ^7.0.2
- tslib: ^2.1.0
- typescript: ^4.2.3
- dependencies:
- '@gnu-taler/taler-util': link:../taler-util
- '@gnu-taler/taler-wallet-core': link:../taler-wallet-core
- tslib: 2.2.0
+ dependencies:
+ '@gnu-taler/anastasis-core':
+ specifier: workspace:*
+ version: link:../anastasis-core
+ '@gnu-taler/idb-bridge':
+ specifier: workspace:*
+ version: link:../idb-bridge
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@gnu-taler/taler-wallet-core':
+ specifier: workspace:*
+ version: link:../taler-wallet-core
+ tslib:
+ specifier: ^2.6.2
+ version: 2.6.2
devDependencies:
- '@rollup/plugin-commonjs': 17.1.0_rollup@2.43.0
- '@rollup/plugin-json': 4.1.0_rollup@2.43.0
- '@rollup/plugin-node-resolve': 11.2.0_rollup@2.43.0
- '@rollup/plugin-replace': 2.4.2_rollup@2.43.0
- '@types/node': 14.17.1
- prettier: 2.2.1
- rimraf: 3.0.2
- rollup: 2.43.0
- rollup-plugin-sourcemaps: 0.6.3_6efbbae6640434994627e0ab519821c6
- rollup-plugin-terser: 7.0.2_rollup@2.43.0
- typescript: 4.2.3
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ esbuild:
+ specifier: ^0.19.9
+ version: 0.19.9
+ prettier:
+ specifier: ^3.1.1
+ version: 3.1.1
packages/taler-wallet-webextension:
- specifiers:
- '@babel/core': 7.13.16
- '@babel/plugin-transform-react-jsx-source': ^7.12.13
- '@babel/preset-typescript': ^7.13.0
- '@gnu-taler/taler-util': workspace:*
- '@gnu-taler/taler-wallet-core': workspace:*
- '@linaria/babel-preset': 3.0.0-beta.4
- '@linaria/core': 3.0.0-beta.4
- '@linaria/react': 3.0.0-beta.4
- '@linaria/rollup': 3.0.0-beta.4
- '@linaria/webpack-loader': 3.0.0-beta.4
- '@rollup/plugin-alias': ^3.1.2
- '@rollup/plugin-commonjs': ^17.0.0
- '@rollup/plugin-image': ^2.0.6
- '@rollup/plugin-json': ^4.1.0
- '@rollup/plugin-node-resolve': ^11.1.0
- '@rollup/plugin-replace': ^2.3.4
- '@storybook/addon-a11y': ^6.2.9
- '@storybook/addon-essentials': ^6.2.9
- '@storybook/preact': ^6.2.9
- '@testing-library/preact': ^2.0.1
- '@types/chrome': ^0.0.128
- '@types/enzyme': ^3.10.8
- '@types/history': ^4.7.8
- '@types/jest': ^26.0.23
- '@types/node': ^14.14.22
- ava: 3.15.0
- babel-loader: ^8.2.2
- babel-plugin-transform-react-jsx: ^6.24.1
- date-fns: ^2.22.1
- enzyme: ^3.11.0
- enzyme-adapter-preact-pure: ^3.1.0
- history: 4.10.1
- jest: ^26.6.3
- jest-preset-preact: ^4.0.2
- preact: ^10.5.13
- preact-cli: ^3.0.5
- preact-render-to-string: ^5.1.19
- preact-router: ^3.2.1
- qrcode-generator: ^1.4.4
- rimraf: ^3.0.2
- rollup: ^2.37.1
- rollup-plugin-css-only: ^3.1.0
- rollup-plugin-ignore: ^1.0.9
- rollup-plugin-sourcemaps: ^0.6.3
- rollup-plugin-terser: ^7.0.2
- storybook-dark-mode: ^1.0.8
- tslib: ^2.1.0
- typescript: ^4.1.3
- dependencies:
- '@gnu-taler/taler-util': link:../taler-util
- '@gnu-taler/taler-wallet-core': link:../taler-wallet-core
- date-fns: 2.23.0
- history: 4.10.1
- preact: 10.5.14
- preact-router: 3.2.1_preact@10.5.14
- qrcode-generator: 1.4.4
- tslib: 2.3.1
+ dependencies:
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@gnu-taler/taler-wallet-core':
+ specifier: workspace:*
+ version: link:../taler-wallet-core
+ date-fns:
+ specifier: ^2.29.2
+ version: 2.29.3
+ history:
+ specifier: 4.10.1
+ version: 4.10.1
+ jsqr:
+ specifier: ^1.4.0
+ version: 1.4.0
+ preact:
+ specifier: 10.11.3
+ version: 10.11.3
+ preact-router:
+ specifier: 3.2.1
+ version: 3.2.1(preact@10.11.3)
+ qrcode-generator:
+ specifier: ^1.4.4
+ version: 1.4.4
+ tslib:
+ specifier: ^2.6.2
+ version: 2.6.2
devDependencies:
- '@babel/core': 7.13.16
- '@babel/plugin-transform-react-jsx-source': 7.14.5_@babel+core@7.13.16
- '@babel/preset-typescript': 7.15.0_@babel+core@7.13.16
- '@linaria/babel-preset': 3.0.0-beta.4_@babel+core@7.13.16
- '@linaria/core': 3.0.0-beta.4
- '@linaria/react': 3.0.0-beta.4
- '@linaria/rollup': 3.0.0-beta.4_@babel+core@7.13.16
- '@linaria/webpack-loader': 3.0.0-beta.4_@babel+core@7.13.16
- '@rollup/plugin-alias': 3.1.5_rollup@2.56.2
- '@rollup/plugin-commonjs': 17.1.0_rollup@2.56.2
- '@rollup/plugin-image': 2.1.0_rollup@2.56.2
- '@rollup/plugin-json': 4.1.0_rollup@2.56.2
- '@rollup/plugin-node-resolve': 11.2.1_rollup@2.56.2
- '@rollup/plugin-replace': 2.4.2_rollup@2.56.2
- '@storybook/addon-a11y': 6.3.7
- '@storybook/addon-essentials': 6.3.7_d95124e751df81c32a1d4f8e491e43a1
- '@storybook/preact': 6.3.7_9cd0ede338ef3d2deb8dbc69bc115c66
- '@testing-library/preact': 2.0.1_preact@10.5.14
- '@types/chrome': 0.0.128
- '@types/enzyme': 3.10.9
- '@types/history': 4.7.9
- '@types/jest': 26.0.24
- '@types/node': 14.17.10
- ava: 3.15.0
- babel-loader: 8.2.2_@babel+core@7.13.16
- babel-plugin-transform-react-jsx: 6.24.1
- enzyme: 3.11.0
- enzyme-adapter-preact-pure: 3.1.0_enzyme@3.11.0+preact@10.5.14
- jest: 26.6.3
- jest-preset-preact: 4.0.2_9b3f24ae35a87c3c82fffbe3fdf70e1e
- preact-cli: 3.2.2_517d24bd855b57d7e424aceed04e063b
- preact-render-to-string: 5.1.19_preact@10.5.14
- rimraf: 3.0.2
- rollup: 2.56.2
- rollup-plugin-css-only: 3.1.0_rollup@2.56.2
- rollup-plugin-ignore: 1.0.9
- rollup-plugin-sourcemaps: 0.6.3_87d168520bd21f84b7cb8eb331bc7479
- rollup-plugin-terser: 7.0.2_rollup@2.56.2
- storybook-dark-mode: 1.0.8
- typescript: 4.3.5
+ '@babel/preset-react':
+ specifier: ^7.22.3
+ version: 7.22.3(@babel/core@7.18.9)
+ '@babel/preset-typescript':
+ specifier: 7.18.6
+ version: 7.18.6(@babel/core@7.18.9)
+ '@gnu-taler/pogen':
+ specifier: workspace:*
+ version: link:../pogen
+ '@gnu-taler/web-util':
+ specifier: workspace:*
+ version: link:../web-util
+ '@linaria/babel-preset':
+ specifier: 5.0.4
+ version: 5.0.4
+ '@linaria/core':
+ specifier: 5.0.2
+ version: 5.0.2
+ '@linaria/esbuild':
+ specifier: 5.0.4
+ version: 5.0.4(esbuild@0.19.9)
+ '@linaria/react':
+ specifier: 5.0.3
+ version: 5.0.3(react@18.2.0)
+ '@linaria/shaker':
+ specifier: 5.0.3
+ version: 5.0.3
+ '@types/chai':
+ specifier: ^4.3.0
+ version: 4.3.3
+ '@types/chrome':
+ specifier: 0.0.197
+ version: 0.0.197
+ '@types/history':
+ specifier: ^4.7.8
+ version: 4.7.11
+ '@types/mocha':
+ specifier: ^9.0.0
+ version: 9.1.1
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^6.19.0
+ version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/parser':
+ specifier: ^6.19.0
+ version: 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ chai:
+ specifier: ^4.3.6
+ version: 4.3.6
+ esbuild:
+ specifier: ^0.19.9
+ version: 0.19.9
+ eslint:
+ specifier: ^8.56.0
+ version: 8.56.0
+ eslint-config-prettier:
+ specifier: ^9.1.0
+ version: 9.1.0(eslint@8.56.0)
+ eslint-plugin-react:
+ specifier: ^7.33.2
+ version: 7.33.2(eslint@8.56.0)
+ mocha:
+ specifier: ^9.2.0
+ version: 9.2.2
+ nyc:
+ specifier: ^15.1.0
+ version: 15.1.0
+ polished:
+ specifier: ^4.1.4
+ version: 4.2.2
+ preact-cli:
+ specifier: ^3.3.5
+ version: 3.4.1(eslint@8.56.0)(preact-render-to-string@5.2.6)(preact@10.11.3)
+ preact-render-to-string:
+ specifier: ^5.1.19
+ version: 5.2.6(preact@10.11.3)
+ typescript:
+ specifier: 5.3.3
+ version: 5.3.3
+ web-ext:
+ specifier: ^7.11.0
+ version: 7.11.0
+
+ packages/web-util:
+ dependencies:
+ '@babel/core':
+ specifier: 7.18.9
+ version: 7.18.9
+ '@babel/helper-compilation-targets':
+ specifier: 7.18.9
+ version: 7.18.9(@babel/core@7.18.9)
+ '@types/chrome':
+ specifier: 0.0.197
+ version: 0.0.197
+ tailwindcss:
+ specifier: ^3.3.2
+ version: 3.3.2
+ devDependencies:
+ '@babel/preset-react':
+ specifier: ^7.22.3
+ version: 7.22.3(@babel/core@7.18.9)
+ '@babel/preset-typescript':
+ specifier: ^7.21.5
+ version: 7.21.5(@babel/core@7.18.9)
+ '@gnu-taler/taler-util':
+ specifier: workspace:*
+ version: link:../taler-util
+ '@heroicons/react':
+ specifier: ^2.0.17
+ version: 2.0.17(react@18.2.0)
+ '@linaria/babel-preset':
+ specifier: 5.0.4
+ version: 5.0.4
+ '@linaria/core':
+ specifier: 5.0.2
+ version: 5.0.2
+ '@linaria/esbuild':
+ specifier: 5.0.4
+ version: 5.0.4(esbuild@0.19.9)
+ '@linaria/react':
+ specifier: 5.0.3
+ version: 5.0.3(react@18.2.0)
+ '@types/express':
+ specifier: ^4.17.14
+ version: 4.17.14
+ '@types/node':
+ specifier: ^18.11.17
+ version: 18.11.17
+ '@types/web':
+ specifier: ^0.0.82
+ version: 0.0.82
+ '@types/ws':
+ specifier: ^8.5.3
+ version: 8.5.3
+ autoprefixer:
+ specifier: ^10.4.14
+ version: 10.4.14(postcss@8.4.23)
+ chokidar:
+ specifier: ^3.5.3
+ version: 3.5.3
+ date-fns:
+ specifier: 2.29.3
+ version: 2.29.3
+ esbuild:
+ specifier: ^0.19.9
+ version: 0.19.9
+ express:
+ specifier: ^4.18.2
+ version: 4.18.2
+ postcss:
+ specifier: ^8.4.23
+ version: 8.4.23
+ postcss-load-config:
+ specifier: ^4.0.1
+ version: 4.0.1(postcss@8.4.23)
+ preact:
+ specifier: 10.11.3
+ version: 10.11.3
+ preact-render-to-string:
+ specifier: ^5.2.6
+ version: 5.2.6(preact@10.11.3)
+ prettier:
+ specifier: ^3.1.1
+ version: 3.1.1
+ sass:
+ specifier: 1.56.1
+ version: 1.56.1
+ swr:
+ specifier: 2.0.3
+ version: 2.0.3(react@18.2.0)
+ tslib:
+ specifier: ^2.6.2
+ version: 2.6.2
+ typescript:
+ specifier: ^5.3.3
+ version: 5.3.3
+ ws:
+ specifier: 7.4.5
+ version: 7.4.5
packages:
- /@apideck/better-ajv-errors/0.2.5_ajv@8.6.2:
- resolution: {integrity: sha512-Pm1fAqCT8OEfBVLddU3fWZ/URWpGGhkvlsBIgn9Y2jJlcNumo0gNzPsQswDJTiA8HcKpCjOhWQOgkA9kXR4Ghg==}
+ /@aashutoshrathi/word-wrap@1.2.6:
+ resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /@alloc/quick-lru@5.2.0:
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
+ /@ampproject/remapping@2.2.0:
+ resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==}
+ engines: {node: '>=6.0.0'}
+ dependencies:
+ '@jridgewell/gen-mapping': 0.1.1
+ '@jridgewell/trace-mapping': 0.3.19
+
+ /@apideck/better-ajv-errors@0.3.6(ajv@8.11.0):
+ resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==}
engines: {node: '>=10'}
peerDependencies:
ajv: '>=8'
dependencies:
- ajv: 8.6.2
- json-schema: 0.3.0
- jsonpointer: 4.1.0
+ ajv: 8.11.0
+ json-schema: 0.4.0
+ jsonpointer: 5.0.1
leven: 3.1.0
dev: true
- /@ava/typescript/1.1.1:
- resolution: {integrity: sha512-KbLUAe2cWXK63WLK6LnOJonjwEDU/8MNXCOA1ooX/YFZgKRmeAD1kZu+2K0ks5fnOCEcckNQAooyBNGdZUmMQA==}
- engines: {node: '>=10.18.0 <11 || >=12.14.0 <13 || >=13.5.0'}
+ /@ava/typescript@4.1.0:
+ resolution: {integrity: sha512-1iWZQ/nr9iflhLK9VN8H+1oDZqe93qxNnyYUz+jTzkYPAHc5fdZXBrqmNIgIfFhWYXK5OaQ5YtC7OmLeTNhVEg==}
+ engines: {node: ^14.19 || ^16.15 || ^18 || ^20}
dependencies:
- escape-string-regexp: 2.0.0
+ escape-string-regexp: 5.0.0
+ execa: 7.2.0
dev: true
- /@babel/code-frame/7.10.4:
- resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==}
+ /@babel/code-frame@7.12.11:
+ resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==}
dependencies:
- '@babel/highlight': 7.14.5
+ '@babel/highlight': 7.23.4
dev: true
- /@babel/code-frame/7.12.11:
- resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==}
+ /@babel/code-frame@7.18.6:
+ resolution: {integrity: sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@babel/highlight': 7.10.4
+ '@babel/highlight': 7.18.6
dev: true
- /@babel/code-frame/7.12.13:
- resolution: {integrity: sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==}
+ /@babel/code-frame@7.21.4:
+ resolution: {integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@babel/highlight': 7.14.0
- dev: true
+ '@babel/highlight': 7.18.6
- /@babel/code-frame/7.14.5:
- resolution: {integrity: sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==}
+ /@babel/code-frame@7.23.5:
+ resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/highlight': 7.14.5
+ '@babel/highlight': 7.23.4
+ chalk: 2.4.2
+
+ /@babel/compat-data@7.19.4:
+ resolution: {integrity: sha512-CHIGpJcUQ5lU9KrPHTjBMhVwQG6CQjxfg36fGXl3qk/Gik1WwWachaXFuo0uCWJT/mStOKtcbFJCaVLihC1CMw==}
+ engines: {node: '>=6.9.0'}
dev: true
- /@babel/compat-data/7.15.0:
- resolution: {integrity: sha512-0NqAC1IJE0S0+lL1SWFMxMkz1pKCNCjI4tr2Zx4LJSXxCLAdr6KyArnY+sno5m3yH9g737ygOyPABDsnXkpxiA==}
+ /@babel/compat-data@7.21.7:
+ resolution: {integrity: sha512-KYMqFYTaenzMK4yUtf4EW9wc4N9ef80FsbMtkwool5zpwl4YrT1SdWYSTRcT94KO4hannogdS+LxY7L+arP3gA==}
+ engines: {node: '>=6.9.0'}
+
+ /@babel/compat-data@7.23.5:
+ resolution: {integrity: sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==}
engines: {node: '>=6.9.0'}
dev: true
- /@babel/core/7.12.9:
- resolution: {integrity: sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==}
+ /@babel/core@7.13.16:
+ resolution: {integrity: sha512-sXHpixBiWWFti0AV2Zq7avpTasr6sIAu7Y396c608541qAU2ui4a193m0KSQmfPSKFZLnQ3cvlKDOm3XkuXm3Q==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/code-frame': 7.14.5
- '@babel/generator': 7.15.0
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helpers': 7.15.3
- '@babel/parser': 7.15.3
- '@babel/template': 7.14.5
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
- convert-source-map: 1.8.0
- debug: 4.3.2
+ '@babel/code-frame': 7.18.6
+ '@babel/generator': 7.19.6
+ '@babel/helper-compilation-targets': 7.18.9(@babel/core@7.13.16)
+ '@babel/helper-module-transforms': 7.19.6
+ '@babel/helpers': 7.19.4
+ '@babel/parser': 7.19.6
+ '@babel/template': 7.18.10
+ '@babel/traverse': 7.19.6
+ '@babel/types': 7.19.4
+ convert-source-map: 1.9.0
+ debug: 4.3.4
gensync: 1.0.0-beta.2
- json5: 2.2.0
- lodash: 4.17.21
- resolve: 1.20.0
- semver: 5.7.1
+ json5: 2.2.1
+ semver: 6.3.0
source-map: 0.5.7
transitivePeerDependencies:
- supports-color
dev: true
- /@babel/core/7.13.16:
- resolution: {integrity: sha512-sXHpixBiWWFti0AV2Zq7avpTasr6sIAu7Y396c608541qAU2ui4a193m0KSQmfPSKFZLnQ3cvlKDOm3XkuXm3Q==}
+ /@babel/core@7.18.9:
+ resolution: {integrity: sha512-1LIb1eL8APMy91/IMW+31ckrfBM4yCoLaVzoDhZUKSM4cu1L1nIidyxkCgzPAgrC5WEz36IPEr/eSeSF9pIn+g==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/code-frame': 7.14.5
- '@babel/generator': 7.15.0
- '@babel/helper-compilation-targets': 7.15.0_@babel+core@7.13.16
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helpers': 7.15.3
- '@babel/parser': 7.15.3
- '@babel/template': 7.14.5
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
- convert-source-map: 1.8.0
- debug: 4.3.2
+ '@ampproject/remapping': 2.2.0
+ '@babel/code-frame': 7.21.4
+ '@babel/generator': 7.21.5
+ '@babel/helper-compilation-targets': 7.18.9(@babel/core@7.18.9)
+ '@babel/helper-module-transforms': 7.21.5
+ '@babel/helpers': 7.21.5
+ '@babel/parser': 7.21.8
+ '@babel/template': 7.20.7
+ '@babel/traverse': 7.21.5
+ '@babel/types': 7.21.5
+ convert-source-map: 1.9.0
+ debug: 4.3.4
gensync: 1.0.0-beta.2
- json5: 2.2.0
+ json5: 2.2.3
semver: 6.3.0
- source-map: 0.5.7
+ transitivePeerDependencies:
+ - supports-color
+
+ /@babel/core@7.22.1:
+ resolution: {integrity: sha512-Hkqu7J4ynysSXxmAahpN1jjRwVJ+NdpraFLIWflgjpVob3KNyK3/tIUc7Q7szed8WMp0JNa7Qtd1E9Oo22F9gA==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@ampproject/remapping': 2.2.0
+ '@babel/code-frame': 7.23.5
+ '@babel/generator': 7.23.5
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.22.1)
+ '@babel/helpers': 7.23.5
+ '@babel/parser': 7.23.5
+ '@babel/template': 7.22.15
+ '@babel/traverse': 7.23.5
+ '@babel/types': 7.23.5
+ convert-source-map: 1.9.0
+ debug: 4.3.4
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
transitivePeerDependencies:
- supports-color
dev: true
- /@babel/core/7.15.0:
- resolution: {integrity: sha512-tXtmTminrze5HEUPn/a0JtOzzfp0nk+UEXQ/tqIJo3WDGypl/2OFQEMll/zSFU8f/lfmfLXvTaORHF3cfXIQMw==}
+ /@babel/core@7.23.5:
+ resolution: {integrity: sha512-Cwc2XjUrG4ilcfOw4wBAK+enbdgwAcAJCfGUItPBKR7Mjw4aEfAFYrLxeRp4jWgtNIKn3n2AlBOfwwafl+42/g==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/code-frame': 7.14.5
- '@babel/generator': 7.15.0
- '@babel/helper-compilation-targets': 7.15.0_@babel+core@7.15.0
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helpers': 7.15.3
- '@babel/parser': 7.15.3
- '@babel/template': 7.14.5
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
- convert-source-map: 1.8.0
- debug: 4.3.2
+ '@ampproject/remapping': 2.2.0
+ '@babel/code-frame': 7.23.5
+ '@babel/generator': 7.23.5
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.5)
+ '@babel/helpers': 7.23.5
+ '@babel/parser': 7.23.5
+ '@babel/template': 7.22.15
+ '@babel/traverse': 7.23.5
+ '@babel/types': 7.23.5
+ convert-source-map: 2.0.0
+ debug: 4.3.4
gensync: 1.0.0-beta.2
- json5: 2.2.0
- semver: 6.3.0
- source-map: 0.5.7
+ json5: 2.2.3
+ semver: 6.3.1
transitivePeerDependencies:
- supports-color
dev: true
- /@babel/generator/7.15.0:
- resolution: {integrity: sha512-eKl4XdMrbpYvuB505KTta4AV9g+wWzmVBW69tX0H2NwKVKd2YJbKgyK6M8j/rgLbmHOYJn6rUklV677nOyJrEQ==}
+ /@babel/eslint-parser@7.19.1(@babel/core@7.18.9)(eslint@7.32.0):
+ resolution: {integrity: sha512-AqNf2QWt1rtu2/1rLswy6CDP7H9Oh3mMhk177Y67Rg8d7RD9WfOLLv8CGn6tisFvS2htm86yIe1yLF6I1UDaGQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0}
+ peerDependencies:
+ '@babel/core': '>=7.11.0'
+ eslint: ^7.5.0 || ^8.0.0
+ dependencies:
+ '@babel/core': 7.18.9
+ '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1
+ eslint: 7.32.0
+ eslint-visitor-keys: 2.1.0
+ semver: 6.3.1
+ dev: true
+
+ /@babel/generator@7.19.6:
+ resolution: {integrity: sha512-oHGRUQeoX1QrKeJIKVe0hwjGqNnVYsM5Nep5zo0uE0m42sLH+Fsd2pStJ5sRM1bNyTUUoz0pe2lTeMJrb/taTA==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/types': 7.15.0
+ '@babel/types': 7.22.4
+ '@jridgewell/gen-mapping': 0.3.2
jsesc: 2.5.2
- source-map: 0.5.7
dev: true
- /@babel/helper-annotate-as-pure/7.14.5:
- resolution: {integrity: sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==}
+ /@babel/generator@7.21.5:
+ resolution: {integrity: sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/types': 7.15.0
+ '@babel/types': 7.23.5
+ '@jridgewell/gen-mapping': 0.3.3
+ '@jridgewell/trace-mapping': 0.3.19
+ jsesc: 2.5.2
+
+ /@babel/generator@7.22.3:
+ resolution: {integrity: sha512-C17MW4wlk//ES/CJDL51kPNwl+qiBQyN7b9SKyVp11BLGFeSPoVaHrv+MNt8jwQFhQWowW88z1eeBx3pFz9v8A==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/types': 7.23.5
+ '@jridgewell/gen-mapping': 0.3.3
+ '@jridgewell/trace-mapping': 0.3.19
+ jsesc: 2.5.2
dev: true
- /@babel/helper-builder-binary-assignment-operator-visitor/7.14.5:
- resolution: {integrity: sha512-YTA/Twn0vBXDVGJuAX6PwW7x5zQei1luDDo2Pl6q1qZ7hVNl0RZrhHCQG/ArGpR29Vl7ETiB8eJyrvpuRp300w==}
+ /@babel/generator@7.23.5:
+ resolution: {integrity: sha512-BPssCHrBD+0YrxviOa3QzpqwhNIXKEtOa2jQrm4FlmkC2apYgRnQcmPWiGZDlGxiNtltnUFolMe8497Esry+jA==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/helper-explode-assignable-expression': 7.14.5
- '@babel/types': 7.15.0
+ '@babel/types': 7.23.5
+ '@jridgewell/gen-mapping': 0.3.3
+ '@jridgewell/trace-mapping': 0.3.19
+ jsesc: 2.5.2
+
+ /@babel/helper-annotate-as-pure@7.22.5:
+ resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/types': 7.23.5
dev: true
- /@babel/helper-compilation-targets/7.15.0:
- resolution: {integrity: sha512-h+/9t0ncd4jfZ8wsdAsoIxSa61qhBYlycXiHWqJaQBCXAhDCMbPRSMTGnZIkkmt1u4ag+UQmuqcILwqKzZ4N2A==}
+ /@babel/helper-builder-binary-assignment-operator-visitor@7.22.15:
+ resolution: {integrity: sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0
dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/helper-validator-option': 7.14.5
- browserslist: 4.16.8
- semver: 6.3.0
+ '@babel/types': 7.23.5
dev: true
- /@babel/helper-compilation-targets/7.15.0_@babel+core@7.13.16:
- resolution: {integrity: sha512-h+/9t0ncd4jfZ8wsdAsoIxSa61qhBYlycXiHWqJaQBCXAhDCMbPRSMTGnZIkkmt1u4ag+UQmuqcILwqKzZ4N2A==}
+ /@babel/helper-compilation-targets@7.18.9(@babel/core@7.13.16):
+ resolution: {integrity: sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
- '@babel/compat-data': 7.15.0
+ '@babel/compat-data': 7.21.7
'@babel/core': 7.13.16
- '@babel/helper-validator-option': 7.14.5
- browserslist: 4.16.8
- semver: 6.3.0
+ '@babel/helper-validator-option': 7.21.0
+ browserslist: 4.22.2
+ semver: 6.3.1
dev: true
- /@babel/helper-compilation-targets/7.15.0_@babel+core@7.15.0:
- resolution: {integrity: sha512-h+/9t0ncd4jfZ8wsdAsoIxSa61qhBYlycXiHWqJaQBCXAhDCMbPRSMTGnZIkkmt1u4ag+UQmuqcILwqKzZ4N2A==}
+ /@babel/helper-compilation-targets@7.18.9(@babel/core@7.18.9):
+ resolution: {integrity: sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/core': 7.15.0
- '@babel/helper-validator-option': 7.14.5
- browserslist: 4.16.8
- semver: 6.3.0
+ '@babel/compat-data': 7.21.7
+ '@babel/core': 7.18.9
+ '@babel/helper-validator-option': 7.21.0
+ browserslist: 4.22.2
+ semver: 6.3.1
+
+ /@babel/helper-compilation-targets@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+ dependencies:
+ '@babel/compat-data': 7.21.7
+ '@babel/core': 7.22.1
+ '@babel/helper-validator-option': 7.21.0
+ browserslist: 4.22.2
+ semver: 6.3.1
dev: true
- /@babel/helper-create-class-features-plugin/7.15.0:
- resolution: {integrity: sha512-MdmDXgvTIi4heDVX/e9EFfeGpugqm9fobBVg/iioE8kueXrOHdRDe36FAY7SnE9xXLVeYCoJR/gdrBEIHRC83Q==}
+ /@babel/helper-compilation-targets@7.21.5(@babel/core@7.22.1):
+ resolution: {integrity: sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-function-name': 7.14.5
- '@babel/helper-member-expression-to-functions': 7.15.0
- '@babel/helper-optimise-call-expression': 7.14.5
- '@babel/helper-replace-supers': 7.15.0
- '@babel/helper-split-export-declaration': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/compat-data': 7.23.5
+ '@babel/core': 7.22.1
+ '@babel/helper-validator-option': 7.23.5
+ browserslist: 4.22.2
+ lru-cache: 5.1.1
+ semver: 6.3.1
+ dev: true
+
+ /@babel/helper-compilation-targets@7.22.15:
+ resolution: {integrity: sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/compat-data': 7.23.5
+ '@babel/helper-validator-option': 7.23.5
+ browserslist: 4.22.2
+ lru-cache: 5.1.1
+ semver: 6.3.1
dev: true
- /@babel/helper-create-class-features-plugin/7.15.0_@babel+core@7.13.16:
- resolution: {integrity: sha512-MdmDXgvTIi4heDVX/e9EFfeGpugqm9fobBVg/iioE8kueXrOHdRDe36FAY7SnE9xXLVeYCoJR/gdrBEIHRC83Q==}
+ /@babel/helper-create-class-features-plugin@7.23.5(@babel/core@7.18.9):
+ resolution: {integrity: sha512-QELlRWxSpgdwdJzSJn4WAhKC+hvw/AtHbbrIoncKHkhKKR/luAlKkgBDcri1EzWAo8f8VvYVryEHN4tax/V67A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-function-name': 7.14.5
- '@babel/helper-member-expression-to-functions': 7.15.0
- '@babel/helper-optimise-call-expression': 7.14.5
- '@babel/helper-replace-supers': 7.15.0
- '@babel/helper-split-export-declaration': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-member-expression-to-functions': 7.23.0
+ '@babel/helper-optimise-call-expression': 7.22.5
+ '@babel/helper-replace-supers': 7.22.20(@babel/core@7.18.9)
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
+ '@babel/helper-split-export-declaration': 7.22.6
+ semver: 6.3.1
dev: true
- /@babel/helper-create-class-features-plugin/7.15.0_@babel+core@7.15.0:
- resolution: {integrity: sha512-MdmDXgvTIi4heDVX/e9EFfeGpugqm9fobBVg/iioE8kueXrOHdRDe36FAY7SnE9xXLVeYCoJR/gdrBEIHRC83Q==}
+ /@babel/helper-create-class-features-plugin@7.23.5(@babel/core@7.22.1):
+ resolution: {integrity: sha512-QELlRWxSpgdwdJzSJn4WAhKC+hvw/AtHbbrIoncKHkhKKR/luAlKkgBDcri1EzWAo8f8VvYVryEHN4tax/V67A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-function-name': 7.14.5
- '@babel/helper-member-expression-to-functions': 7.15.0
- '@babel/helper-optimise-call-expression': 7.14.5
- '@babel/helper-replace-supers': 7.15.0
- '@babel/helper-split-export-declaration': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-member-expression-to-functions': 7.23.0
+ '@babel/helper-optimise-call-expression': 7.22.5
+ '@babel/helper-replace-supers': 7.22.20(@babel/core@7.22.1)
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
+ '@babel/helper-split-export-declaration': 7.22.6
+ semver: 6.3.1
dev: true
- /@babel/helper-create-regexp-features-plugin/7.14.5:
- resolution: {integrity: sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A==}
+ /@babel/helper-create-class-features-plugin@7.23.5(@babel/core@7.23.5):
+ resolution: {integrity: sha512-QELlRWxSpgdwdJzSJn4WAhKC+hvw/AtHbbrIoncKHkhKKR/luAlKkgBDcri1EzWAo8f8VvYVryEHN4tax/V67A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
- '@babel/helper-annotate-as-pure': 7.14.5
- regexpu-core: 4.7.1
+ '@babel/core': 7.23.5
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-member-expression-to-functions': 7.23.0
+ '@babel/helper-optimise-call-expression': 7.22.5
+ '@babel/helper-replace-supers': 7.22.20(@babel/core@7.23.5)
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
+ '@babel/helper-split-export-declaration': 7.22.6
+ semver: 6.3.1
dev: true
- /@babel/helper-create-regexp-features-plugin/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A==}
+ /@babel/helper-create-regexp-features-plugin@7.22.15(@babel/core@7.18.9):
+ resolution: {integrity: sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-annotate-as-pure': 7.14.5
- regexpu-core: 4.7.1
+ '@babel/core': 7.18.9
+ '@babel/helper-annotate-as-pure': 7.22.5
+ regexpu-core: 5.3.2
+ semver: 6.3.1
dev: true
- /@babel/helper-create-regexp-features-plugin/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A==}
+ /@babel/helper-create-regexp-features-plugin@7.22.15(@babel/core@7.22.1):
+ resolution: {integrity: sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-annotate-as-pure': 7.14.5
- regexpu-core: 4.7.1
+ '@babel/core': 7.22.1
+ '@babel/helper-annotate-as-pure': 7.22.5
+ regexpu-core: 5.3.2
+ semver: 6.3.1
dev: true
- /@babel/helper-define-polyfill-provider/0.1.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg==}
+ /@babel/helper-create-regexp-features-plugin@7.22.15(@babel/core@7.23.5):
+ resolution: {integrity: sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.4.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-compilation-targets': 7.15.0_@babel+core@7.15.0
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/traverse': 7.15.0
- debug: 4.3.2
- lodash.debounce: 4.0.8
- resolve: 1.20.0
- semver: 6.3.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-annotate-as-pure': 7.22.5
+ regexpu-core: 5.3.2
+ semver: 6.3.1
dev: true
- /@babel/helper-define-polyfill-provider/0.2.3:
- resolution: {integrity: sha512-RH3QDAfRMzj7+0Nqu5oqgO5q9mFtQEVvCRsi8qCEfzLR9p2BHfn5FzhSB2oj1fF7I2+DcTORkYaQ6aTR9Cofew==}
+ /@babel/helper-define-polyfill-provider@0.3.3(@babel/core@7.22.1):
+ resolution: {integrity: sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==}
peerDependencies:
'@babel/core': ^7.4.0-0
dependencies:
- '@babel/helper-compilation-targets': 7.15.0
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/traverse': 7.15.0
- debug: 4.3.2
+ '@babel/core': 7.22.1
+ '@babel/helper-compilation-targets': 7.18.9(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
+ debug: 4.3.4
lodash.debounce: 4.0.8
- resolve: 1.20.0
- semver: 6.3.0
+ resolve: 1.22.8
+ semver: 6.3.1
transitivePeerDependencies:
- supports-color
dev: true
- /@babel/helper-define-polyfill-provider/0.2.3_@babel+core@7.13.16:
- resolution: {integrity: sha512-RH3QDAfRMzj7+0Nqu5oqgO5q9mFtQEVvCRsi8qCEfzLR9p2BHfn5FzhSB2oj1fF7I2+DcTORkYaQ6aTR9Cofew==}
+ /@babel/helper-define-polyfill-provider@0.4.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-WBrLmuPP47n7PNwsZ57pqam6G/RGo1vw/87b0Blc53tZNGZ4x7YvZ6HgQe2vo1W/FR20OgjeZuGXzudPiXHFug==}
peerDependencies:
- '@babel/core': ^7.4.0-0
+ '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-compilation-targets': 7.15.0_@babel+core@7.13.16
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/traverse': 7.15.0
- debug: 4.3.2
+ '@babel/core': 7.18.9
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ debug: 4.3.4
lodash.debounce: 4.0.8
- resolve: 1.20.0
- semver: 6.3.0
+ resolve: 1.22.8
transitivePeerDependencies:
- supports-color
dev: true
- /@babel/helper-define-polyfill-provider/0.2.3_@babel+core@7.15.0:
- resolution: {integrity: sha512-RH3QDAfRMzj7+0Nqu5oqgO5q9mFtQEVvCRsi8qCEfzLR9p2BHfn5FzhSB2oj1fF7I2+DcTORkYaQ6aTR9Cofew==}
+ /@babel/helper-define-polyfill-provider@0.4.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-WBrLmuPP47n7PNwsZ57pqam6G/RGo1vw/87b0Blc53tZNGZ4x7YvZ6HgQe2vo1W/FR20OgjeZuGXzudPiXHFug==}
peerDependencies:
- '@babel/core': ^7.4.0-0
+ '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-compilation-targets': 7.15.0_@babel+core@7.15.0
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/traverse': 7.15.0
- debug: 4.3.2
+ '@babel/core': 7.23.5
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ debug: 4.3.4
lodash.debounce: 4.0.8
- resolve: 1.20.0
- semver: 6.3.0
+ resolve: 1.22.8
transitivePeerDependencies:
- supports-color
dev: true
- /@babel/helper-explode-assignable-expression/7.14.5:
- resolution: {integrity: sha512-Htb24gnGJdIGT4vnRKMdoXiOIlqOLmdiUYpAQ0mYfgVT/GDm8GOYhgi4GL+hMKrkiPRohO4ts34ELFsGAPQLDQ==}
+ /@babel/helper-environment-visitor@7.18.9:
+ resolution: {integrity: sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==}
engines: {node: '>=6.9.0'}
- dependencies:
- '@babel/types': 7.15.0
dev: true
- /@babel/helper-function-name/7.14.5:
- resolution: {integrity: sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==}
+ /@babel/helper-environment-visitor@7.22.20:
+ resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==}
engines: {node: '>=6.9.0'}
- dependencies:
- '@babel/helper-get-function-arity': 7.14.5
- '@babel/template': 7.14.5
- '@babel/types': 7.15.0
- dev: true
- /@babel/helper-get-function-arity/7.14.5:
- resolution: {integrity: sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==}
+ /@babel/helper-function-name@7.19.0:
+ resolution: {integrity: sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/types': 7.15.0
+ '@babel/template': 7.22.15
+ '@babel/types': 7.23.5
dev: true
- /@babel/helper-hoist-variables/7.14.5:
- resolution: {integrity: sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==}
+ /@babel/helper-function-name@7.23.0:
+ resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/types': 7.15.0
- dev: true
+ '@babel/template': 7.22.15
+ '@babel/types': 7.23.5
- /@babel/helper-member-expression-to-functions/7.15.0:
- resolution: {integrity: sha512-Jq8H8U2kYiafuj2xMTPQwkTBnEEdGKpT35lJEQsRRjnG0LW3neucsaMWLgKcwu3OHKNeYugfw+Z20BXBSEs2Lg==}
+ /@babel/helper-hoist-variables@7.18.6:
+ resolution: {integrity: sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/types': 7.15.0
+ '@babel/types': 7.23.5
dev: true
- /@babel/helper-module-imports/7.14.5:
- resolution: {integrity: sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==}
+ /@babel/helper-hoist-variables@7.22.5:
+ resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/types': 7.15.0
- dev: true
+ '@babel/types': 7.23.5
- /@babel/helper-module-transforms/7.15.0:
- resolution: {integrity: sha512-RkGiW5Rer7fpXv9m1B3iHIFDZdItnO2/BLfWVW/9q7+KqQSDY5kUfQEbzdXM1MVhJGcugKV7kRrNVzNxmk7NBg==}
+ /@babel/helper-member-expression-to-functions@7.23.0:
+ resolution: {integrity: sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-replace-supers': 7.15.0
- '@babel/helper-simple-access': 7.14.8
- '@babel/helper-split-export-declaration': 7.14.5
- '@babel/helper-validator-identifier': 7.14.9
- '@babel/template': 7.14.5
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/types': 7.23.5
dev: true
- /@babel/helper-optimise-call-expression/7.14.5:
- resolution: {integrity: sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==}
+ /@babel/helper-module-imports@7.18.6:
+ resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/types': 7.15.0
+ '@babel/types': 7.23.5
dev: true
- /@babel/helper-plugin-utils/7.10.4:
- resolution: {integrity: sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==}
+ /@babel/helper-module-imports@7.21.4:
+ resolution: {integrity: sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/types': 7.23.5
dev: true
- /@babel/helper-plugin-utils/7.14.5:
- resolution: {integrity: sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==}
+ /@babel/helper-module-imports@7.22.15:
+ resolution: {integrity: sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==}
engines: {node: '>=6.9.0'}
- dev: true
+ dependencies:
+ '@babel/types': 7.23.5
- /@babel/helper-remap-async-to-generator/7.14.5:
- resolution: {integrity: sha512-rLQKdQU+HYlxBwQIj8dk4/0ENOUEhA/Z0l4hN8BexpvmSMN9oA9EagjnhnDpNsRdWCfjwa4mn/HyBXO9yhQP6A==}
+ /@babel/helper-module-transforms@7.19.6:
+ resolution: {integrity: sha512-fCmcfQo/KYr/VXXDIyd3CBGZ6AFhPFy1TfSEJ+PilGVlQT6jcbqtHAM4C1EciRqMza7/TpOUZliuSH+U6HAhJw==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-wrap-function': 7.14.5
- '@babel/types': 7.15.0
+ '@babel/helper-environment-visitor': 7.18.9
+ '@babel/helper-module-imports': 7.18.6
+ '@babel/helper-simple-access': 7.19.4
+ '@babel/helper-split-export-declaration': 7.18.6
+ '@babel/helper-validator-identifier': 7.19.1
+ '@babel/template': 7.18.10
+ '@babel/traverse': 7.19.6
+ '@babel/types': 7.19.4
transitivePeerDependencies:
- supports-color
dev: true
- /@babel/helper-replace-supers/7.15.0:
- resolution: {integrity: sha512-6O+eWrhx+HEra/uJnifCwhwMd6Bp5+ZfZeJwbqUTuqkhIT6YcRhiZCOOFChRypOIe0cV46kFrRBlm+t5vHCEaA==}
+ /@babel/helper-module-transforms@7.21.5:
+ resolution: {integrity: sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==}
engines: {node: '>=6.9.0'}
dependencies:
- '@babel/helper-member-expression-to-functions': 7.15.0
- '@babel/helper-optimise-call-expression': 7.14.5
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-simple-access': 7.22.5
+ '@babel/helper-split-export-declaration': 7.22.6
+ '@babel/helper-validator-identifier': 7.22.20
+ '@babel/template': 7.22.15
+ '@babel/traverse': 7.23.5
+ '@babel/types': 7.23.5
transitivePeerDependencies:
- supports-color
- dev: true
- /@babel/helper-simple-access/7.14.8:
- resolution: {integrity: sha512-TrFN4RHh9gnWEU+s7JloIho2T76GPwRHhdzOWLqTrMnlas8T9O7ec+oEDNsRXndOmru9ymH9DFrEOxpzPoSbdg==}
+ /@babel/helper-module-transforms@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==}
engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/types': 7.15.0
+ '@babel/core': 7.18.9
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-simple-access': 7.22.5
+ '@babel/helper-split-export-declaration': 7.22.6
+ '@babel/helper-validator-identifier': 7.22.20
dev: true
- /@babel/helper-skip-transparent-expression-wrappers/7.14.5:
- resolution: {integrity: sha512-dmqZB7mrb94PZSAOYtr+ZN5qt5owZIAgqtoTuqiFbHFtxgEcmQlRJVI+bO++fciBunXtB6MK7HrzrfcAzIz2NQ==}
+ /@babel/helper-module-transforms@7.23.3(@babel/core@7.22.1):
+ resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==}
engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/types': 7.15.0
+ '@babel/core': 7.22.1
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-simple-access': 7.22.5
+ '@babel/helper-split-export-declaration': 7.22.6
+ '@babel/helper-validator-identifier': 7.22.20
dev: true
- /@babel/helper-split-export-declaration/7.14.5:
- resolution: {integrity: sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==}
+ /@babel/helper-module-transforms@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==}
engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/types': 7.15.0
- dev: true
-
- /@babel/helper-validator-identifier/7.12.11:
- resolution: {integrity: sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==}
+ '@babel/core': 7.23.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-simple-access': 7.22.5
+ '@babel/helper-split-export-declaration': 7.22.6
+ '@babel/helper-validator-identifier': 7.22.20
dev: true
- /@babel/helper-validator-identifier/7.14.0:
- resolution: {integrity: sha512-V3ts7zMSu5lfiwWDVWzRDGIN+lnCEUdaXgtVHJgLb1rGaA6jMrtB9EmE7L18foXJIE8Un/A/h6NJfGQp/e1J4A==}
- dev: true
-
- /@babel/helper-validator-identifier/7.14.9:
- resolution: {integrity: sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==}
+ /@babel/helper-optimise-call-expression@7.22.5:
+ resolution: {integrity: sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==}
engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/types': 7.23.5
dev: true
- /@babel/helper-validator-option/7.14.5:
- resolution: {integrity: sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==}
+ /@babel/helper-plugin-utils@7.19.0:
+ resolution: {integrity: sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==}
engines: {node: '>=6.9.0'}
dev: true
- /@babel/helper-wrap-function/7.14.5:
- resolution: {integrity: sha512-YEdjTCq+LNuNS1WfxsDCNpgXkJaIyqco6DAelTUjT4f2KIWC1nBcaCaSdHTBqQVLnTBexBcVcFhLSU1KnYuePQ==}
+ /@babel/helper-plugin-utils@7.21.5:
+ resolution: {integrity: sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==}
engines: {node: '>=6.9.0'}
- dependencies:
- '@babel/helper-function-name': 7.14.5
- '@babel/template': 7.14.5
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
- transitivePeerDependencies:
- - supports-color
dev: true
- /@babel/helpers/7.15.3:
- resolution: {integrity: sha512-HwJiz52XaS96lX+28Tnbu31VeFSQJGOeKHJeaEPQlTl7PnlhFElWPj8tUXtqFIzeN86XxXoBr+WFAyK2PPVz6g==}
+ /@babel/helper-plugin-utils@7.22.5:
+ resolution: {integrity: sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==}
engines: {node: '>=6.9.0'}
- dependencies:
- '@babel/template': 7.14.5
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
- transitivePeerDependencies:
- - supports-color
- dev: true
-
- /@babel/highlight/7.10.4:
- resolution: {integrity: sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==}
- dependencies:
- '@babel/helper-validator-identifier': 7.12.11
- chalk: 2.4.2
- js-tokens: 4.0.0
dev: true
- /@babel/highlight/7.14.0:
- resolution: {integrity: sha512-YSCOwxvTYEIMSGaBQb5kDDsCopDdiUGsqpatp3fOlI4+2HQSkTmEVWnVuySdAC5EWCqSWWTv0ib63RjR7dTBdg==}
- dependencies:
- '@babel/helper-validator-identifier': 7.14.0
- chalk: 2.4.2
- js-tokens: 4.0.0
- dev: true
-
- /@babel/highlight/7.14.5:
- resolution: {integrity: sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==}
+ /@babel/helper-remap-async-to-generator@7.22.20(@babel/core@7.18.9):
+ resolution: {integrity: sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==}
engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/helper-validator-identifier': 7.14.9
- chalk: 2.4.2
- js-tokens: 4.0.0
- dev: true
-
- /@babel/parser/7.15.3:
- resolution: {integrity: sha512-O0L6v/HvqbdJawj0iBEfVQMc3/6WP+AeOsovsIgBFyJaG+W2w7eqvZB7puddATmWuARlm1SX7DwxJ/JJUnDpEA==}
- engines: {node: '>=6.0.0'}
- hasBin: true
+ '@babel/core': 7.18.9
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-wrap-function': 7.22.20
dev: true
- /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.14.5:
- resolution: {integrity: sha512-ZoJS2XCKPBfTmL122iP6NM9dOg+d4lc9fFk3zxc8iDjvt8Pk4+TlsHSKhIPf6X+L5ORCdBzqMZDjL/WHj7WknQ==}
+ /@babel/helper-remap-async-to-generator@7.22.20(@babel/core@7.22.1):
+ resolution: {integrity: sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.13.0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-skip-transparent-expression-wrappers': 7.14.5
- '@babel/plugin-proposal-optional-chaining': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-wrap-function': 7.22.20
dev: true
- /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-ZoJS2XCKPBfTmL122iP6NM9dOg+d4lc9fFk3zxc8iDjvt8Pk4+TlsHSKhIPf6X+L5ORCdBzqMZDjL/WHj7WknQ==}
+ /@babel/helper-remap-async-to-generator@7.22.20(@babel/core@7.23.5):
+ resolution: {integrity: sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.13.0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-skip-transparent-expression-wrappers': 7.14.5
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.13.16
+ '@babel/core': 7.23.5
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-wrap-function': 7.22.20
dev: true
- /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-ZoJS2XCKPBfTmL122iP6NM9dOg+d4lc9fFk3zxc8iDjvt8Pk4+TlsHSKhIPf6X+L5ORCdBzqMZDjL/WHj7WknQ==}
+ /@babel/helper-replace-supers@7.22.20(@babel/core@7.18.9):
+ resolution: {integrity: sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.13.0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-skip-transparent-expression-wrappers': 7.14.5
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.15.0
+ '@babel/core': 7.18.9
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-member-expression-to-functions': 7.23.0
+ '@babel/helper-optimise-call-expression': 7.22.5
dev: true
- /@babel/plugin-proposal-async-generator-functions/7.14.9:
- resolution: {integrity: sha512-d1lnh+ZnKrFKwtTYdw320+sQWCTwgkB9fmUhNXRADA4akR6wLjaruSGnIEUjpt9HCOwTr4ynFTKu19b7rFRpmw==}
+ /@babel/helper-replace-supers@7.22.20(@babel/core@7.22.1):
+ resolution: {integrity: sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-remap-async-to-generator': 7.14.5
- '@babel/plugin-syntax-async-generators': 7.8.4
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-member-expression-to-functions': 7.23.0
+ '@babel/helper-optimise-call-expression': 7.22.5
dev: true
- /@babel/plugin-proposal-async-generator-functions/7.14.9_@babel+core@7.13.16:
- resolution: {integrity: sha512-d1lnh+ZnKrFKwtTYdw320+sQWCTwgkB9fmUhNXRADA4akR6wLjaruSGnIEUjpt9HCOwTr4ynFTKu19b7rFRpmw==}
+ /@babel/helper-replace-supers@7.22.20(@babel/core@7.23.5):
+ resolution: {integrity: sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-remap-async-to-generator': 7.14.5
- '@babel/plugin-syntax-async-generators': 7.8.4_@babel+core@7.13.16
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-member-expression-to-functions': 7.23.0
+ '@babel/helper-optimise-call-expression': 7.22.5
dev: true
- /@babel/plugin-proposal-async-generator-functions/7.14.9_@babel+core@7.15.0:
- resolution: {integrity: sha512-d1lnh+ZnKrFKwtTYdw320+sQWCTwgkB9fmUhNXRADA4akR6wLjaruSGnIEUjpt9HCOwTr4ynFTKu19b7rFRpmw==}
+ /@babel/helper-simple-access@7.19.4:
+ resolution: {integrity: sha512-f9Xq6WqBFqaDfbCzn2w85hwklswz5qsKlh7f08w4Y9yhJHpnNC0QemtSkK5YyOY8kPGvyiwdzZksGUhnGdaUIg==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-remap-async-to-generator': 7.14.5
- '@babel/plugin-syntax-async-generators': 7.8.4_@babel+core@7.15.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/types': 7.23.5
dev: true
- /@babel/plugin-proposal-class-properties/7.14.5:
- resolution: {integrity: sha512-q/PLpv5Ko4dVc1LYMpCY7RVAAO4uk55qPwrIuJ5QJ8c6cVuAmhu7I/49JOppXL6gXf7ZHzpRVEUZdYoPLM04Gg==}
+ /@babel/helper-simple-access@7.22.5:
+ resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-create-class-features-plugin': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- transitivePeerDependencies:
- - supports-color
- dev: true
+ '@babel/types': 7.23.5
- /@babel/plugin-proposal-class-properties/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-q/PLpv5Ko4dVc1LYMpCY7RVAAO4uk55qPwrIuJ5QJ8c6cVuAmhu7I/49JOppXL6gXf7ZHzpRVEUZdYoPLM04Gg==}
+ /@babel/helper-skip-transparent-expression-wrappers@7.22.5:
+ resolution: {integrity: sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/types': 7.23.5
dev: true
- /@babel/plugin-proposal-class-properties/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-q/PLpv5Ko4dVc1LYMpCY7RVAAO4uk55qPwrIuJ5QJ8c6cVuAmhu7I/49JOppXL6gXf7ZHzpRVEUZdYoPLM04Gg==}
+ /@babel/helper-split-export-declaration@7.18.6:
+ resolution: {integrity: sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/types': 7.23.5
dev: true
- /@babel/plugin-proposal-class-static-block/7.14.5:
- resolution: {integrity: sha512-KBAH5ksEnYHCegqseI5N9skTdxgJdmDoAOc0uXa+4QMYKeZD0w5IARh4FMlTNtaHhbB8v+KzMdTgxMMzsIy6Yg==}
+ /@babel/helper-split-export-declaration@7.22.6:
+ resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.12.0
dependencies:
- '@babel/helper-create-class-features-plugin': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-class-static-block': 7.14.5
- transitivePeerDependencies:
- - supports-color
- dev: true
+ '@babel/types': 7.23.5
- /@babel/plugin-proposal-class-static-block/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-KBAH5ksEnYHCegqseI5N9skTdxgJdmDoAOc0uXa+4QMYKeZD0w5IARh4FMlTNtaHhbB8v+KzMdTgxMMzsIy6Yg==}
+ /@babel/helper-string-parser@7.19.4:
+ resolution: {integrity: sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.12.0
- dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-class-static-block': 7.14.5_@babel+core@7.13.16
- transitivePeerDependencies:
- - supports-color
dev: true
- /@babel/plugin-proposal-class-static-block/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-KBAH5ksEnYHCegqseI5N9skTdxgJdmDoAOc0uXa+4QMYKeZD0w5IARh4FMlTNtaHhbB8v+KzMdTgxMMzsIy6Yg==}
+ /@babel/helper-string-parser@7.23.4:
+ resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.12.0
- dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-class-static-block': 7.14.5_@babel+core@7.15.0
- transitivePeerDependencies:
- - supports-color
- dev: true
- /@babel/plugin-proposal-decorators/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-LYz5nvQcvYeRVjui1Ykn28i+3aUiXwQ/3MGoEy0InTaz1pJo/lAzmIDXX+BQny/oufgHzJ6vnEEiXQ8KZjEVFg==}
+ /@babel/helper-validator-identifier@7.19.1:
+ resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
- dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-decorators': 7.14.5_@babel+core@7.15.0
- transitivePeerDependencies:
- - supports-color
dev: true
- /@babel/plugin-proposal-dynamic-import/7.14.5:
- resolution: {integrity: sha512-ExjiNYc3HDN5PXJx+bwC50GIx/KKanX2HiggnIUAYedbARdImiCU4RhhHfdf0Kd7JNXGpsBBBCOm+bBVy3Gb0g==}
+ /@babel/helper-validator-identifier@7.22.20:
+ resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
- dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-dynamic-import': 7.8.3
- dev: true
- /@babel/plugin-proposal-dynamic-import/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-ExjiNYc3HDN5PXJx+bwC50GIx/KKanX2HiggnIUAYedbARdImiCU4RhhHfdf0Kd7JNXGpsBBBCOm+bBVy3Gb0g==}
+ /@babel/helper-validator-option@7.18.6:
+ resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
- dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.13.16
dev: true
- /@babel/plugin-proposal-dynamic-import/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-ExjiNYc3HDN5PXJx+bwC50GIx/KKanX2HiggnIUAYedbARdImiCU4RhhHfdf0Kd7JNXGpsBBBCOm+bBVy3Gb0g==}
+ /@babel/helper-validator-option@7.21.0:
+ resolution: {integrity: sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
- dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- dev: true
- /@babel/plugin-proposal-export-default-from/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-T8KZ5abXvKMjF6JcoXjgac3ElmXf0AWzJwi2O/42Jk+HmCky3D9+i1B7NPP1FblyceqTevKeV/9szeikFoaMDg==}
+ /@babel/helper-validator-option@7.23.5:
+ resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
- dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-export-default-from': 7.14.5_@babel+core@7.15.0
dev: true
- /@babel/plugin-proposal-export-namespace-from/7.14.5:
- resolution: {integrity: sha512-g5POA32bXPMmSBu5Dx/iZGLGnKmKPc5AiY7qfZgurzrCYgIztDlHFbznSNCoQuv57YQLnQfaDi7dxCtLDIdXdA==}
+ /@babel/helper-wrap-function@7.22.20:
+ resolution: {integrity: sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-export-namespace-from': 7.8.3
+ '@babel/helper-function-name': 7.23.0
+ '@babel/template': 7.22.15
+ '@babel/types': 7.23.5
dev: true
- /@babel/plugin-proposal-export-namespace-from/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-g5POA32bXPMmSBu5Dx/iZGLGnKmKPc5AiY7qfZgurzrCYgIztDlHFbznSNCoQuv57YQLnQfaDi7dxCtLDIdXdA==}
+ /@babel/helpers@7.19.4:
+ resolution: {integrity: sha512-G+z3aOx2nfDHwX/kyVii5fJq+bgscg89/dJNWpYeKeBv3v9xX8EIabmx1k6u9LS04H7nROFVRVK+e3k0VHp+sw==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-export-namespace-from': 7.8.3_@babel+core@7.13.16
+ '@babel/template': 7.18.10
+ '@babel/traverse': 7.19.6
+ '@babel/types': 7.22.4
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /@babel/plugin-proposal-export-namespace-from/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-g5POA32bXPMmSBu5Dx/iZGLGnKmKPc5AiY7qfZgurzrCYgIztDlHFbznSNCoQuv57YQLnQfaDi7dxCtLDIdXdA==}
+ /@babel/helpers@7.21.5:
+ resolution: {integrity: sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-export-namespace-from': 7.8.3_@babel+core@7.15.0
- dev: true
+ '@babel/template': 7.22.15
+ '@babel/traverse': 7.23.5
+ '@babel/types': 7.23.5
+ transitivePeerDependencies:
+ - supports-color
- /@babel/plugin-proposal-json-strings/7.14.5:
- resolution: {integrity: sha512-NSq2fczJYKVRIsUJyNxrVUMhB27zb7N7pOFGQOhBKJrChbGcgEAqyZrmZswkPk18VMurEeJAaICbfm57vUeTbQ==}
+ /@babel/helpers@7.23.5:
+ resolution: {integrity: sha512-oO7us8FzTEsG3U6ag9MfdF1iA/7Z6dz+MtFhifZk8C8o453rGJFFWUP1t+ULM9TUIAzC9uxXEiXjOiVMyd7QPg==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-json-strings': 7.8.3
+ '@babel/template': 7.22.15
+ '@babel/traverse': 7.23.5
+ '@babel/types': 7.23.5
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /@babel/plugin-proposal-json-strings/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-NSq2fczJYKVRIsUJyNxrVUMhB27zb7N7pOFGQOhBKJrChbGcgEAqyZrmZswkPk18VMurEeJAaICbfm57vUeTbQ==}
+ /@babel/highlight@7.18.6:
+ resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-json-strings': 7.8.3_@babel+core@7.13.16
- dev: true
+ '@babel/helper-validator-identifier': 7.22.20
+ chalk: 2.4.2
+ js-tokens: 4.0.0
- /@babel/plugin-proposal-json-strings/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-NSq2fczJYKVRIsUJyNxrVUMhB27zb7N7pOFGQOhBKJrChbGcgEAqyZrmZswkPk18VMurEeJAaICbfm57vUeTbQ==}
+ /@babel/highlight@7.23.4:
+ resolution: {integrity: sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==}
engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-json-strings': 7.8.3_@babel+core@7.15.0
- dev: true
+ '@babel/helper-validator-identifier': 7.22.20
+ chalk: 2.4.2
+ js-tokens: 4.0.0
- /@babel/plugin-proposal-logical-assignment-operators/7.14.5:
- resolution: {integrity: sha512-YGn2AvZAo9TwyhlLvCCWxD90Xq8xJ4aSgaX3G5D/8DW94L8aaT+dS5cSP+Z06+rCJERGSr9GxMBZ601xoc2taw==}
- engines: {node: '>=6.9.0'}
- peerDependencies:
- '@babel/core': ^7.0.0-0
+ /@babel/parser@7.19.6:
+ resolution: {integrity: sha512-h1IUp81s2JYJ3mRkdxJgs4UvmSsRvDrx5ICSJbPvtWYv5i1nTBGcBpnog+89rAFMwvvru6E5NUHdBe01UeSzYA==}
+ engines: {node: '>=6.0.0'}
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-logical-assignment-operators': 7.10.4
+ '@babel/types': 7.22.4
dev: true
- /@babel/plugin-proposal-logical-assignment-operators/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-YGn2AvZAo9TwyhlLvCCWxD90Xq8xJ4aSgaX3G5D/8DW94L8aaT+dS5cSP+Z06+rCJERGSr9GxMBZ601xoc2taw==}
+ /@babel/parser@7.21.8:
+ resolution: {integrity: sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==}
+ engines: {node: '>=6.0.0'}
+ dependencies:
+ '@babel/types': 7.23.5
+
+ /@babel/parser@7.23.5:
+ resolution: {integrity: sha512-hOOqoiNXrmGdFbhgCzu6GiURxUgM27Xwd/aPuu8RfHEZPBzL1Z54okAHAQjXfcQNwvrlkAmAp4SlRTZ45vlthQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+ dependencies:
+ '@babel/types': 7.23.5
+
+ /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-logical-assignment-operators': 7.10.4_@babel+core@7.13.16
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-proposal-logical-assignment-operators/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-YGn2AvZAo9TwyhlLvCCWxD90Xq8xJ4aSgaX3G5D/8DW94L8aaT+dS5cSP+Z06+rCJERGSr9GxMBZ601xoc2taw==}
+ /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-logical-assignment-operators': 7.10.4_@babel+core@7.15.0
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-proposal-nullish-coalescing-operator/7.14.5:
- resolution: {integrity: sha512-gun/SOnMqjSb98Nkaq2rTKMwervfdAoz6NphdY0vTfuzMfryj+tDGb2n6UkDKwez+Y8PZDhE3D143v6Gepp4Hg==}
+ /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-proposal-nullish-coalescing-operator/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-gun/SOnMqjSb98Nkaq2rTKMwervfdAoz6NphdY0vTfuzMfryj+tDGb2n6UkDKwez+Y8PZDhE3D143v6Gepp4Hg==}
+ /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.13.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.13.16
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
+ '@babel/plugin-proposal-optional-chaining': 7.18.9(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-nullish-coalescing-operator/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-gun/SOnMqjSb98Nkaq2rTKMwervfdAoz6NphdY0vTfuzMfryj+tDGb2n6UkDKwez+Y8PZDhE3D143v6Gepp4Hg==}
+ /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.13.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.15.0
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
+ '@babel/plugin-transform-optional-chaining': 7.23.4(@babel/core@7.18.9)
dev: true
- /@babel/plugin-proposal-numeric-separator/7.14.5:
- resolution: {integrity: sha512-yiclALKe0vyZRZE0pS6RXgjUOt87GWv6FYa5zqj15PvhOGFO69R5DusPlgK/1K5dVnCtegTiWu9UaBSrLLJJBg==}
+ /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.13.0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-numeric-separator': 7.10.4
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
+ '@babel/plugin-transform-optional-chaining': 7.23.4(@babel/core@7.23.5)
dev: true
- /@babel/plugin-proposal-numeric-separator/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-yiclALKe0vyZRZE0pS6RXgjUOt87GWv6FYa5zqj15PvhOGFO69R5DusPlgK/1K5dVnCtegTiWu9UaBSrLLJJBg==}
+ /@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-XaJak1qcityzrX0/IU5nKHb34VaibwP3saKqG6a/tppelgllOH13LUann4ZCIBcVOeE6H18K4Vx9QKkVww3z/w==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-numeric-separator': 7.10.4_@babel+core@7.13.16
+ '@babel/core': 7.18.9
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-proposal-numeric-separator/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-yiclALKe0vyZRZE0pS6RXgjUOt87GWv6FYa5zqj15PvhOGFO69R5DusPlgK/1K5dVnCtegTiWu9UaBSrLLJJBg==}
+ /@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-XaJak1qcityzrX0/IU5nKHb34VaibwP3saKqG6a/tppelgllOH13LUann4ZCIBcVOeE6H18K4Vx9QKkVww3z/w==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-numeric-separator': 7.10.4_@babel+core@7.15.0
+ '@babel/core': 7.23.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-proposal-object-rest-spread/7.12.1_@babel+core@7.12.9:
- resolution: {integrity: sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA==}
+ /@babel/plugin-proposal-async-generator-functions@7.19.1(@babel/core@7.22.1):
+ resolution: {integrity: sha512-0yu8vNATgLy4ivqMNBIwb1HebCelqN7YX8SL3FDXORv/RqT0zEEWUCH4GH44JsSrvCu6GqnAdR5EBFAPeNBB4Q==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.12.9
- '@babel/helper-plugin-utils': 7.10.4
- '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.12.9
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.12.9
+ '@babel/core': 7.22.1
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.22.1)
+ '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-object-rest-spread/7.14.7:
- resolution: {integrity: sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==}
+ /@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/helper-compilation-targets': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-object-rest-spread': 7.8.3
- '@babel/plugin-transform-parameters': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-proposal-object-rest-spread/7.14.7_@babel+core@7.13.16:
- resolution: {integrity: sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==}
+ /@babel/plugin-proposal-class-static-block@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.12.0
dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/core': 7.13.16
- '@babel/helper-compilation-targets': 7.15.0_@babel+core@7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.13.16
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.13.16
+ '@babel/core': 7.22.1
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-object-rest-spread/7.14.7_@babel+core@7.15.0:
- resolution: {integrity: sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==}
+ /@babel/plugin-proposal-decorators@7.19.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-PKWforYpkVkogpOW0RaPuh7eQ7AoFgBJP+d87tQCRY2LVbvyGtfRM7RtrhCBsNgZb+2EY28SeWB6p2xe1Z5oAw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/core': 7.15.0
- '@babel/helper-compilation-targets': 7.15.0_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.15.0
+ '@babel/core': 7.22.1
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-replace-supers': 7.22.20(@babel/core@7.22.1)
+ '@babel/helper-split-export-declaration': 7.22.6
+ '@babel/plugin-syntax-decorators': 7.19.0(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-optional-catch-binding/7.14.5:
- resolution: {integrity: sha512-3Oyiixm0ur7bzO5ybNcZFlmVsygSIQgdOa7cTfOYCMY+wEPAYhZAJxi3mixKFCTCKUhQXuCTtQ1MzrpL3WT8ZQ==}
+ /@babel/plugin-proposal-dynamic-import@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-optional-catch-binding': 7.8.3
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-optional-catch-binding/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-3Oyiixm0ur7bzO5ybNcZFlmVsygSIQgdOa7cTfOYCMY+wEPAYhZAJxi3mixKFCTCKUhQXuCTtQ1MzrpL3WT8ZQ==}
+ /@babel/plugin-proposal-export-namespace-from@7.18.9(@babel/core@7.18.9):
+ resolution: {integrity: sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-optional-catch-binding': 7.8.3_@babel+core@7.13.16
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.18.9)
dev: true
- /@babel/plugin-proposal-optional-catch-binding/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-3Oyiixm0ur7bzO5ybNcZFlmVsygSIQgdOa7cTfOYCMY+wEPAYhZAJxi3mixKFCTCKUhQXuCTtQ1MzrpL3WT8ZQ==}
+ /@babel/plugin-proposal-export-namespace-from@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-optional-catch-binding': 7.8.3_@babel+core@7.15.0
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-optional-chaining/7.14.5:
- resolution: {integrity: sha512-ycz+VOzo2UbWNI1rQXxIuMOzrDdHGrI23fRiz/Si2R4kv2XZQ1BK8ccdHwehMKBlcH/joGW/tzrUmo67gbJHlQ==}
+ /@babel/plugin-proposal-json-strings@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-skip-transparent-expression-wrappers': 7.14.5
- '@babel/plugin-syntax-optional-chaining': 7.8.3
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-optional-chaining/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-ycz+VOzo2UbWNI1rQXxIuMOzrDdHGrI23fRiz/Si2R4kv2XZQ1BK8ccdHwehMKBlcH/joGW/tzrUmo67gbJHlQ==}
+ /@babel/plugin-proposal-logical-assignment-operators@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-skip-transparent-expression-wrappers': 7.14.5
- '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.13.16
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-optional-chaining/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-ycz+VOzo2UbWNI1rQXxIuMOzrDdHGrI23fRiz/Si2R4kv2XZQ1BK8ccdHwehMKBlcH/joGW/tzrUmo67gbJHlQ==}
+ /@babel/plugin-proposal-nullish-coalescing-operator@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-skip-transparent-expression-wrappers': 7.14.5
- '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.15.0
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-private-methods/7.14.5:
- resolution: {integrity: sha512-838DkdUA1u+QTCplatfq4B7+1lnDa/+QMI89x5WZHBcnNv+47N8QEj2k9I2MUU9xIv8XJ4XvPCviM/Dj7Uwt9g==}
+ /@babel/plugin-proposal-numeric-separator@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-create-class-features-plugin': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-private-methods/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-838DkdUA1u+QTCplatfq4B7+1lnDa/+QMI89x5WZHBcnNv+47N8QEj2k9I2MUU9xIv8XJ4XvPCviM/Dj7Uwt9g==}
+ /@babel/plugin-proposal-object-rest-spread@7.19.4(@babel/core@7.22.1):
+ resolution: {integrity: sha512-wHmj6LDxVDnL+3WhXteUBaoM1aVILZODAUjg11kHqG4cOlfgMQGxw6aCgvrXrmaJR3Bn14oZhImyCPZzRpC93Q==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/compat-data': 7.23.5
+ '@babel/core': 7.22.1
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.22.1)
+ '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-private-methods/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-838DkdUA1u+QTCplatfq4B7+1lnDa/+QMI89x5WZHBcnNv+47N8QEj2k9I2MUU9xIv8XJ4XvPCviM/Dj7Uwt9g==}
+ /@babel/plugin-proposal-optional-catch-binding@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-private-property-in-object/7.14.5:
- resolution: {integrity: sha512-62EyfyA3WA0mZiF2e2IV9mc9Ghwxcg8YTu8BS4Wss4Y3PY725OmS9M0qLORbJwLqFtGh+jiE4wAmocK2CTUK2Q==}
+ /@babel/plugin-proposal-optional-chaining@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-create-class-features-plugin': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-private-property-in-object': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
+ '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-private-property-in-object/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-62EyfyA3WA0mZiF2e2IV9mc9Ghwxcg8YTu8BS4Wss4Y3PY725OmS9M0qLORbJwLqFtGh+jiE4wAmocK2CTUK2Q==}
+ /@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-private-property-in-object': 7.14.5_@babel+core@7.13.16
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-proposal-private-property-in-object/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-62EyfyA3WA0mZiF2e2IV9mc9Ghwxcg8YTu8BS4Wss4Y3PY725OmS9M0qLORbJwLqFtGh+jiE4wAmocK2CTUK2Q==}
+ /@babel/plugin-proposal-private-property-in-object@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-private-property-in-object': 7.14.5_@babel+core@7.15.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.22.1)
dev: true
- /@babel/plugin-proposal-unicode-property-regex/7.14.5:
- resolution: {integrity: sha512-6axIeOU5LnY471KenAB9vI8I5j7NQ2d652hIYwVyRfgaZT5UpiqFKCuVXCDMSrU+3VFafnu2c5m3lrWIlr6A5Q==}
- engines: {node: '>=4'}
+ /@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.18.9):
+ resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-create-regexp-features-plugin': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
dev: true
- /@babel/plugin-proposal-unicode-property-regex/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-6axIeOU5LnY471KenAB9vI8I5j7NQ2d652hIYwVyRfgaZT5UpiqFKCuVXCDMSrU+3VFafnu2c5m3lrWIlr6A5Q==}
- engines: {node: '>=4'}
+ /@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.23.5):
+ resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-create-regexp-features-plugin': 7.14.5_@babel+core@7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
dev: true
- /@babel/plugin-proposal-unicode-property-regex/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-6axIeOU5LnY471KenAB9vI8I5j7NQ2d652hIYwVyRfgaZT5UpiqFKCuVXCDMSrU+3VFafnu2c5m3lrWIlr6A5Q==}
+ /@babel/plugin-proposal-unicode-property-regex@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==}
engines: {node: '>=4'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-create-regexp-features-plugin': 7.14.5_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-async-generators/7.8.4:
+ /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.18.9):
resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-async-generators/7.8.4_@babel+core@7.13.16:
+ /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.22.1):
resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-async-generators/7.8.4_@babel+core@7.15.0:
+ /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.23.5):
resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-bigint/7.8.3_@babel+core@7.15.0:
- resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==}
+ /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.18.9):
+ resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-class-properties/7.12.13:
+ /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.22.1):
resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-class-properties/7.12.13_@babel+core@7.13.16:
+ /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.23.5):
resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-class-properties/7.12.13_@babel+core@7.15.0:
- resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==}
+ /@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.18.9):
+ resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-class-static-block/7.14.5:
+ /@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.22.1):
resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-class-static-block/7.14.5_@babel+core@7.13.16:
+ /@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.23.5):
resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-class-static-block/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==}
+ /@babel/plugin-syntax-decorators@7.19.0(@babel/core@7.18.9):
+ resolution: {integrity: sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.21.5
dev: true
- /@babel/plugin-syntax-decorators/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-c4sZMRWL4GSvP1EXy0woIP7m4jkVcEuG8R1TOZxPBPtp4FSM/kiPZub9UIs/Jrb5ZAOzvTUSGYrWsrSu1JvoPw==}
+ /@babel/plugin-syntax-decorators@7.19.0(@babel/core@7.22.1):
+ resolution: {integrity: sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.21.5
dev: true
- /@babel/plugin-syntax-dynamic-import/7.8.3:
+ /@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.18.9):
resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-dynamic-import/7.8.3_@babel+core@7.13.16:
+ /@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.22.1):
resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-dynamic-import/7.8.3_@babel+core@7.15.0:
+ /@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.23.5):
resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-export-default-from/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-snWDxjuaPEobRBnhpqEfZ8RMxDbHt8+87fiEioGuE+Uc0xAKgSD8QiuL3lF93hPVQfZFAcYwrrf+H5qUhike3Q==}
- engines: {node: '>=6.9.0'}
+ /@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-export-namespace-from/7.8.3:
+ /@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.22.1):
resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-export-namespace-from/7.8.3_@babel+core@7.13.16:
+ /@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.23.5):
resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-export-namespace-from/7.8.3_@babel+core@7.15.0:
- resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==}
+ /@babel/plugin-syntax-import-assertions@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-import-meta/7.10.4_@babel+core@7.15.0:
- resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==}
+ /@babel/plugin-syntax-import-assertions@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-json-strings/7.8.3:
- resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==}
+ /@babel/plugin-syntax-import-assertions@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-json-strings/7.8.3_@babel+core@7.13.16:
- resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==}
+ /@babel/plugin-syntax-import-attributes@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-json-strings/7.8.3_@babel+core@7.15.0:
- resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==}
+ /@babel/plugin-syntax-import-attributes@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-jsx/7.12.1_@babel+core@7.12.9:
- resolution: {integrity: sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg==}
+ /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.12.9
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-jsx/7.14.5:
- resolution: {integrity: sha512-ohuFIsOMXJnbOMRfX7/w7LocdR6R7whhuRD4ax8IipLcLPlZGJKkBxgHp++U4N/vKyU16/YDQr2f5seajD3jIw==}
- engines: {node: '>=6.9.0'}
+ /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-jsx/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-ohuFIsOMXJnbOMRfX7/w7LocdR6R7whhuRD4ax8IipLcLPlZGJKkBxgHp++U4N/vKyU16/YDQr2f5seajD3jIw==}
- engines: {node: '>=6.9.0'}
+ /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ dev: true
+
+ /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.22.1):
+ resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+ dependencies:
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-jsx/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-ohuFIsOMXJnbOMRfX7/w7LocdR6R7whhuRD4ax8IipLcLPlZGJKkBxgHp++U4N/vKyU16/YDQr2f5seajD3jIw==}
+ /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+ dependencies:
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ dev: true
+
+ /@babel/plugin-syntax-jsx@7.21.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.21.5
dev: true
- /@babel/plugin-syntax-logical-assignment-operators/7.10.4:
- resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==}
+ /@babel/plugin-syntax-jsx@7.21.4(@babel/core@7.22.1):
+ resolution: {integrity: sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.21.5
dev: true
- /@babel/plugin-syntax-logical-assignment-operators/7.10.4_@babel+core@7.13.16:
+ /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.18.9):
resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-logical-assignment-operators/7.10.4_@babel+core@7.15.0:
+ /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.22.1):
resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-nullish-coalescing-operator/7.8.3:
- resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==}
+ /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-nullish-coalescing-operator/7.8.3_@babel+core@7.13.16:
+ /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.18.9):
resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-nullish-coalescing-operator/7.8.3_@babel+core@7.15.0:
+ /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.22.1):
resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-numeric-separator/7.10.4:
- resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==}
+ /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.13.16:
+ /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.18.9):
resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.15.0:
+ /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.22.1):
resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-object-rest-spread/7.8.3:
- resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==}
+ /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-object-rest-spread/7.8.3_@babel+core@7.12.9:
+ /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.18.9):
resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.12.9
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-object-rest-spread/7.8.3_@babel+core@7.13.16:
+ /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.22.1):
resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-object-rest-spread/7.8.3_@babel+core@7.15.0:
+ /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.23.5):
resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-optional-catch-binding/7.8.3:
+ /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.18.9):
resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-optional-catch-binding/7.8.3_@babel+core@7.13.16:
+ /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.22.1):
resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-optional-catch-binding/7.8.3_@babel+core@7.15.0:
+ /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.23.5):
resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-optional-chaining/7.8.3:
+ /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.18.9):
resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.13.16:
+ /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.22.1):
resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.15.0:
+ /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.23.5):
resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-private-property-in-object/7.14.5:
+ /@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.18.9):
resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-private-property-in-object/7.14.5_@babel+core@7.13.16:
+ /@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.22.1):
resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-private-property-in-object/7.14.5_@babel+core@7.15.0:
+ /@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.23.5):
resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-top-level-await/7.14.5:
+ /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.18.9):
resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-top-level-await/7.14.5_@babel+core@7.13.16:
+ /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.22.1):
resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-top-level-await/7.14.5_@babel+core@7.15.0:
+ /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.23.5):
resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-typescript/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-u6OXzDaIXjEstBRRoBCQ/uKQKlbuaeE5in0RvWdA4pN6AhqxTIwUsnHPU1CFZA/amYObMsuWhYfRl3Ch90HD0Q==}
+ /@babel/plugin-syntax-typescript@7.20.0(@babel/core@7.18.9):
+ resolution: {integrity: sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-syntax-typescript/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-u6OXzDaIXjEstBRRoBCQ/uKQKlbuaeE5in0RvWdA4pN6AhqxTIwUsnHPU1CFZA/amYObMsuWhYfRl3Ch90HD0Q==}
+ /@babel/plugin-syntax-typescript@7.20.0(@babel/core@7.22.1):
+ resolution: {integrity: sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-arrow-functions/7.14.5:
- resolution: {integrity: sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A==}
+ /@babel/plugin-syntax-typescript@7.21.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-xz0D39NvhQn4t4RNsHmDnnsaQizIlUkdtYvLs8La1BlfjQ6JEwxkJGeqJMW2tAXx+q6H+WFuUTXNdYVpEya0YA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-arrow-functions/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A==}
+ /@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.18.9):
+ resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-arrow-functions/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A==}
+ /@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.23.5):
+ resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-async-to-generator/7.14.5:
- resolution: {integrity: sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA==}
+ /@babel/plugin-transform-arrow-functions@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-remap-async-to-generator': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-async-to-generator/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA==}
+ /@babel/plugin-transform-arrow-functions@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-remap-async-to-generator': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-async-to-generator/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA==}
+ /@babel/plugin-transform-arrow-functions@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-remap-async-to-generator': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-block-scoped-functions/7.14.5:
- resolution: {integrity: sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ==}
+ /@babel/plugin-transform-async-generator-functions@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-efdkfPhHYTtn0G6n2ddrESE91fgXxjlqLsnUtPWnJs4a4mZIbUaK7ffqKIIUKXSHwcDvaCVX6GXkaJJFqtX7jw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.18.9)
+ '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-block-scoped-functions/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ==}
+ /@babel/plugin-transform-async-generator-functions@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-efdkfPhHYTtn0G6n2ddrESE91fgXxjlqLsnUtPWnJs4a4mZIbUaK7ffqKIIUKXSHwcDvaCVX6GXkaJJFqtX7jw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.23.5)
+ '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-block-scoped-functions/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ==}
+ /@babel/plugin-transform-async-to-generator@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.22.1)
dev: true
- /@babel/plugin-transform-block-scoping/7.15.3:
- resolution: {integrity: sha512-nBAzfZwZb4DkaGtOes1Up1nOAp9TDRRFw4XBzBBSG9QK7KVFmYzgj9o9sbPv7TX5ofL4Auq4wZnxCoPnI/lz2Q==}
+ /@babel/plugin-transform-async-to-generator@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-block-scoping/7.15.3_@babel+core@7.13.16:
- resolution: {integrity: sha512-nBAzfZwZb4DkaGtOes1Up1nOAp9TDRRFw4XBzBBSG9QK7KVFmYzgj9o9sbPv7TX5ofL4Auq4wZnxCoPnI/lz2Q==}
+ /@babel/plugin-transform-async-to-generator@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-block-scoping/7.15.3_@babel+core@7.15.0:
- resolution: {integrity: sha512-nBAzfZwZb4DkaGtOes1Up1nOAp9TDRRFw4XBzBBSG9QK7KVFmYzgj9o9sbPv7TX5ofL4Auq4wZnxCoPnI/lz2Q==}
+ /@babel/plugin-transform-block-scoped-functions@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-classes/7.14.9:
- resolution: {integrity: sha512-NfZpTcxU3foGWbl4wxmZ35mTsYJy8oQocbeIMoDAGGFarAmSQlL+LWMkDx/tj6pNotpbX3rltIA4dprgAPOq5A==}
+ /@babel/plugin-transform-block-scoped-functions@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-function-name': 7.14.5
- '@babel/helper-optimise-call-expression': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-replace-supers': 7.15.0
- '@babel/helper-split-export-declaration': 7.14.5
- globals: 11.12.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-classes/7.14.9_@babel+core@7.13.16:
- resolution: {integrity: sha512-NfZpTcxU3foGWbl4wxmZ35mTsYJy8oQocbeIMoDAGGFarAmSQlL+LWMkDx/tj6pNotpbX3rltIA4dprgAPOq5A==}
+ /@babel/plugin-transform-block-scoped-functions@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-function-name': 7.14.5
- '@babel/helper-optimise-call-expression': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-replace-supers': 7.15.0
- '@babel/helper-split-export-declaration': 7.14.5
- globals: 11.12.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-classes/7.14.9_@babel+core@7.15.0:
- resolution: {integrity: sha512-NfZpTcxU3foGWbl4wxmZ35mTsYJy8oQocbeIMoDAGGFarAmSQlL+LWMkDx/tj6pNotpbX3rltIA4dprgAPOq5A==}
+ /@babel/plugin-transform-block-scoping@7.19.4(@babel/core@7.22.1):
+ resolution: {integrity: sha512-934S2VLLlt2hRJwPf4MczaOr4hYF0z+VKPwqTNxyKX7NthTiPfhuKFWQZHXRM0vh/wo/VyXB3s4bZUNA08l+tQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-function-name': 7.14.5
- '@babel/helper-optimise-call-expression': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-replace-supers': 7.15.0
- '@babel/helper-split-export-declaration': 7.14.5
- globals: 11.12.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-computed-properties/7.14.5:
- resolution: {integrity: sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg==}
+ /@babel/plugin-transform-block-scoping@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-computed-properties/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg==}
+ /@babel/plugin-transform-block-scoping@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-computed-properties/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg==}
+ /@babel/plugin-transform-class-properties@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-destructuring/7.14.7:
- resolution: {integrity: sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw==}
+ /@babel/plugin-transform-class-properties@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-destructuring/7.14.7_@babel+core@7.13.16:
- resolution: {integrity: sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw==}
+ /@babel/plugin-transform-class-static-block@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.12.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-destructuring/7.14.7_@babel+core@7.15.0:
- resolution: {integrity: sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw==}
+ /@babel/plugin-transform-class-static-block@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.12.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-dotall-regex/7.14.5:
- resolution: {integrity: sha512-loGlnBdj02MDsFaHhAIJzh7euK89lBrGIdM9EAtHFo6xKygCUGuuWe07o1oZVk287amtW1n0808sQM99aZt3gw==}
+ /@babel/plugin-transform-classes@7.19.0(@babel/core@7.22.1):
+ resolution: {integrity: sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-create-regexp-features-plugin': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-optimise-call-expression': 7.22.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-replace-supers': 7.22.20(@babel/core@7.22.1)
+ '@babel/helper-split-export-declaration': 7.22.6
+ globals: 11.12.0
dev: true
- /@babel/plugin-transform-dotall-regex/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-loGlnBdj02MDsFaHhAIJzh7euK89lBrGIdM9EAtHFo6xKygCUGuuWe07o1oZVk287amtW1n0808sQM99aZt3gw==}
+ /@babel/plugin-transform-classes@7.23.5(@babel/core@7.18.9):
+ resolution: {integrity: sha512-jvOTR4nicqYC9yzOHIhXG5emiFEOpappSJAl73SDSEDcybD+Puuze8Tnpb9p9qEyYup24tq891gkaygIFvWDqg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-create-regexp-features-plugin': 7.14.5_@babel+core@7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-optimise-call-expression': 7.22.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-replace-supers': 7.22.20(@babel/core@7.18.9)
+ '@babel/helper-split-export-declaration': 7.22.6
+ globals: 11.12.0
dev: true
- /@babel/plugin-transform-dotall-regex/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-loGlnBdj02MDsFaHhAIJzh7euK89lBrGIdM9EAtHFo6xKygCUGuuWe07o1oZVk287amtW1n0808sQM99aZt3gw==}
+ /@babel/plugin-transform-classes@7.23.5(@babel/core@7.23.5):
+ resolution: {integrity: sha512-jvOTR4nicqYC9yzOHIhXG5emiFEOpappSJAl73SDSEDcybD+Puuze8Tnpb9p9qEyYup24tq891gkaygIFvWDqg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-create-regexp-features-plugin': 7.14.5_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-optimise-call-expression': 7.22.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-replace-supers': 7.22.20(@babel/core@7.23.5)
+ '@babel/helper-split-export-declaration': 7.22.6
+ globals: 11.12.0
dev: true
- /@babel/plugin-transform-duplicate-keys/7.14.5:
- resolution: {integrity: sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A==}
+ /@babel/plugin-transform-computed-properties@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-duplicate-keys/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A==}
+ /@babel/plugin-transform-computed-properties@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/template': 7.22.15
dev: true
- /@babel/plugin-transform-duplicate-keys/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A==}
+ /@babel/plugin-transform-computed-properties@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/template': 7.22.15
dev: true
- /@babel/plugin-transform-exponentiation-operator/7.14.5:
- resolution: {integrity: sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==}
+ /@babel/plugin-transform-destructuring@7.19.4(@babel/core@7.22.1):
+ resolution: {integrity: sha512-t0j0Hgidqf0aM86dF8U+vXYReUgJnlv4bZLsyoPnwZNrGY+7/38o8YjaELrvHeVfTZao15kjR0PVv0nju2iduA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-builder-binary-assignment-operator-visitor': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-exponentiation-operator/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==}
+ /@babel/plugin-transform-destructuring@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-builder-binary-assignment-operator-visitor': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-exponentiation-operator/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==}
+ /@babel/plugin-transform-destructuring@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-builder-binary-assignment-operator-visitor': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-for-of/7.14.5:
- resolution: {integrity: sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA==}
+ /@babel/plugin-transform-dotall-regex@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-for-of/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA==}
+ /@babel/plugin-transform-dotall-regex@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-for-of/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA==}
+ /@babel/plugin-transform-dotall-regex@7.23.3(@babel/core@7.22.1):
+ resolution: {integrity: sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-function-name/7.14.5:
- resolution: {integrity: sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ==}
+ /@babel/plugin-transform-dotall-regex@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-function-name': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-function-name/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ==}
+ /@babel/plugin-transform-duplicate-keys@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-function-name': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-function-name/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ==}
+ /@babel/plugin-transform-duplicate-keys@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-function-name': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-literals/7.14.5:
- resolution: {integrity: sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A==}
+ /@babel/plugin-transform-duplicate-keys@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-literals/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A==}
+ /@babel/plugin-transform-dynamic-import@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-literals/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A==}
+ /@babel/plugin-transform-dynamic-import@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-member-expression-literals/7.14.5:
- resolution: {integrity: sha512-WkNXxH1VXVTKarWFqmso83xl+2V3Eo28YY5utIkbsmXoItO8Q3aZxN4BTS2k0hz9dGUloHK26mJMyQEYfkn/+Q==}
+ /@babel/plugin-transform-exponentiation-operator@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-builder-binary-assignment-operator-visitor': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-member-expression-literals/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-WkNXxH1VXVTKarWFqmso83xl+2V3Eo28YY5utIkbsmXoItO8Q3aZxN4BTS2k0hz9dGUloHK26mJMyQEYfkn/+Q==}
+ /@babel/plugin-transform-exponentiation-operator@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-builder-binary-assignment-operator-visitor': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-member-expression-literals/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-WkNXxH1VXVTKarWFqmso83xl+2V3Eo28YY5utIkbsmXoItO8Q3aZxN4BTS2k0hz9dGUloHK26mJMyQEYfkn/+Q==}
+ /@babel/plugin-transform-exponentiation-operator@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-builder-binary-assignment-operator-visitor': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-modules-amd/7.14.5:
- resolution: {integrity: sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g==}
+ /@babel/plugin-transform-export-namespace-from@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- babel-plugin-dynamic-import-node: 2.3.3
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-modules-amd/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g==}
+ /@babel/plugin-transform-export-namespace-from@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- babel-plugin-dynamic-import-node: 2.3.3
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-modules-amd/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g==}
+ /@babel/plugin-transform-for-of@7.18.8(@babel/core@7.22.1):
+ resolution: {integrity: sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- babel-plugin-dynamic-import-node: 2.3.3
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-modules-commonjs/7.15.0:
- resolution: {integrity: sha512-3H/R9s8cXcOGE8kgMlmjYYC9nqr5ELiPkJn4q0mypBrjhYQoc+5/Maq69vV4xRPWnkzZuwJPf5rArxpB/35Cig==}
+ /@babel/plugin-transform-for-of@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-X8jSm8X1CMwxmK878qsUGJRmbysKNbdpTv/O1/v0LuY/ZkZrng5WYiekYSdg9m09OTmDDUWeEDsTE+17WYbAZw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-simple-access': 7.14.8
- babel-plugin-dynamic-import-node: 2.3.3
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-modules-commonjs/7.15.0_@babel+core@7.13.16:
- resolution: {integrity: sha512-3H/R9s8cXcOGE8kgMlmjYYC9nqr5ELiPkJn4q0mypBrjhYQoc+5/Maq69vV4xRPWnkzZuwJPf5rArxpB/35Cig==}
+ /@babel/plugin-transform-for-of@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-X8jSm8X1CMwxmK878qsUGJRmbysKNbdpTv/O1/v0LuY/ZkZrng5WYiekYSdg9m09OTmDDUWeEDsTE+17WYbAZw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-simple-access': 7.14.8
- babel-plugin-dynamic-import-node: 2.3.3
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-modules-commonjs/7.15.0_@babel+core@7.15.0:
- resolution: {integrity: sha512-3H/R9s8cXcOGE8kgMlmjYYC9nqr5ELiPkJn4q0mypBrjhYQoc+5/Maq69vV4xRPWnkzZuwJPf5rArxpB/35Cig==}
+ /@babel/plugin-transform-function-name@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-simple-access': 7.14.8
- babel-plugin-dynamic-import-node: 2.3.3
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-compilation-targets': 7.18.9(@babel/core@7.22.1)
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-modules-systemjs/7.14.5:
- resolution: {integrity: sha512-mNMQdvBEE5DcMQaL5LbzXFMANrQjd2W7FPzg34Y4yEz7dBgdaC+9B84dSO+/1Wba98zoDbInctCDo4JGxz1VYA==}
+ /@babel/plugin-transform-function-name@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-hoist-variables': 7.14.5
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-validator-identifier': 7.14.9
- babel-plugin-dynamic-import-node: 2.3.3
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-modules-systemjs/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-mNMQdvBEE5DcMQaL5LbzXFMANrQjd2W7FPzg34Y4yEz7dBgdaC+9B84dSO+/1Wba98zoDbInctCDo4JGxz1VYA==}
+ /@babel/plugin-transform-function-name@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-hoist-variables': 7.14.5
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-validator-identifier': 7.14.9
- babel-plugin-dynamic-import-node: 2.3.3
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-modules-systemjs/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-mNMQdvBEE5DcMQaL5LbzXFMANrQjd2W7FPzg34Y4yEz7dBgdaC+9B84dSO+/1Wba98zoDbInctCDo4JGxz1VYA==}
+ /@babel/plugin-transform-json-strings@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-hoist-variables': 7.14.5
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-validator-identifier': 7.14.9
- babel-plugin-dynamic-import-node: 2.3.3
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-modules-umd/7.14.5:
- resolution: {integrity: sha512-RfPGoagSngC06LsGUYyM9QWSXZ8MysEjDJTAea1lqRjNECE3y0qIJF/qbvJxc4oA4s99HumIMdXOrd+TdKaAAA==}
+ /@babel/plugin-transform-json-strings@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-modules-umd/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-RfPGoagSngC06LsGUYyM9QWSXZ8MysEjDJTAea1lqRjNECE3y0qIJF/qbvJxc4oA4s99HumIMdXOrd+TdKaAAA==}
+ /@babel/plugin-transform-literals@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-modules-umd/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-RfPGoagSngC06LsGUYyM9QWSXZ8MysEjDJTAea1lqRjNECE3y0qIJF/qbvJxc4oA4s99HumIMdXOrd+TdKaAAA==}
+ /@babel/plugin-transform-literals@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-module-transforms': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-named-capturing-groups-regex/7.14.9:
- resolution: {integrity: sha512-l666wCVYO75mlAtGFfyFwnWmIXQm3kSH0C3IRnJqWcZbWkoihyAdDhFm2ZWaxWTqvBvhVFfJjMRQ0ez4oN1yYA==}
+ /@babel/plugin-transform-literals@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-create-regexp-features-plugin': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-named-capturing-groups-regex/7.14.9_@babel+core@7.13.16:
- resolution: {integrity: sha512-l666wCVYO75mlAtGFfyFwnWmIXQm3kSH0C3IRnJqWcZbWkoihyAdDhFm2ZWaxWTqvBvhVFfJjMRQ0ez4oN1yYA==}
+ /@babel/plugin-transform-logical-assignment-operators@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-create-regexp-features-plugin': 7.14.5_@babel+core@7.13.16
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-named-capturing-groups-regex/7.14.9_@babel+core@7.15.0:
- resolution: {integrity: sha512-l666wCVYO75mlAtGFfyFwnWmIXQm3kSH0C3IRnJqWcZbWkoihyAdDhFm2ZWaxWTqvBvhVFfJjMRQ0ez4oN1yYA==}
+ /@babel/plugin-transform-logical-assignment-operators@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-create-regexp-features-plugin': 7.14.5_@babel+core@7.15.0
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-new-target/7.14.5:
- resolution: {integrity: sha512-Nx054zovz6IIRWEB49RDRuXGI4Gy0GMgqG0cII9L3MxqgXz/+rgII+RU58qpo4g7tNEx1jG7rRVH4ihZoP4esQ==}
+ /@babel/plugin-transform-member-expression-literals@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-new-target/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-Nx054zovz6IIRWEB49RDRuXGI4Gy0GMgqG0cII9L3MxqgXz/+rgII+RU58qpo4g7tNEx1jG7rRVH4ihZoP4esQ==}
+ /@babel/plugin-transform-member-expression-literals@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-new-target/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-Nx054zovz6IIRWEB49RDRuXGI4Gy0GMgqG0cII9L3MxqgXz/+rgII+RU58qpo4g7tNEx1jG7rRVH4ihZoP4esQ==}
+ /@babel/plugin-transform-member-expression-literals@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-object-assign/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-lvhjk4UN9xJJYB1mI5KC0/o1D5EcJXdbhVe+4fSk08D6ZN+iuAIs7LJC+71h8av9Ew4+uRq9452v9R93SFmQlQ==}
+ /@babel/plugin-transform-modules-amd@7.19.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-uG3od2mXvAtIFQIh0xrpLH6r5fpSQN04gIVovl+ODLdUMANokxQLZnPBHcjmv3GxRjnqwLuHvppjjcelqUFZvg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-object-super/7.14.5:
- resolution: {integrity: sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg==}
+ /@babel/plugin-transform-modules-amd@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-replace-supers': 7.15.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-object-super/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg==}
+ /@babel/plugin-transform-modules-amd@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-replace-supers': 7.15.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-object-super/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg==}
+ /@babel/plugin-transform-modules-commonjs@7.19.6(@babel/core@7.18.9):
+ resolution: {integrity: sha512-8PIa1ym4XRTKuSsOUXqDG0YaOlEuTVvHMe5JCfgBMOtHvJKw/4NGovEGN33viISshG/rZNVrACiBmPQLvWN8xQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-replace-supers': 7.15.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-simple-access': 7.22.5
dev: true
- /@babel/plugin-transform-parameters/7.14.5:
- resolution: {integrity: sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==}
+ /@babel/plugin-transform-modules-commonjs@7.19.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-8PIa1ym4XRTKuSsOUXqDG0YaOlEuTVvHMe5JCfgBMOtHvJKw/4NGovEGN33viISshG/rZNVrACiBmPQLvWN8xQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-simple-access': 7.22.5
dev: true
- /@babel/plugin-transform-parameters/7.14.5_@babel+core@7.12.9:
- resolution: {integrity: sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==}
+ /@babel/plugin-transform-modules-commonjs@7.21.5(@babel/core@7.18.9):
+ resolution: {integrity: sha512-OVryBEgKUbtqMoB7eG2rs6UFexJi6Zj6FDXx+esBLPTCxCNxAY9o+8Di7IsUGJ+AVhp5ncK0fxWUBd0/1gPhrQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.12.9
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-simple-access': 7.22.5
dev: true
- /@babel/plugin-transform-parameters/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==}
+ /@babel/plugin-transform-modules-commonjs@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-simple-access': 7.22.5
dev: true
- /@babel/plugin-transform-parameters/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==}
+ /@babel/plugin-transform-modules-commonjs@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-simple-access': 7.22.5
dev: true
- /@babel/plugin-transform-property-literals/7.14.5:
- resolution: {integrity: sha512-r1uilDthkgXW8Z1vJz2dKYLV1tuw2xsbrp3MrZmD99Wh9vsfKoob+JTgri5VUb/JqyKRXotlOtwgu4stIYCmnw==}
+ /@babel/plugin-transform-modules-systemjs@7.19.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-fqGLBepcc3kErfR9R3DnVpURmckXP7gj7bAlrTQyBxrigFqszZCkFkcoxzCp2v32XmwXLvbw+8Yq9/b+QqksjQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-hoist-variables': 7.22.5
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-validator-identifier': 7.22.20
dev: true
- /@babel/plugin-transform-property-literals/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-r1uilDthkgXW8Z1vJz2dKYLV1tuw2xsbrp3MrZmD99Wh9vsfKoob+JTgri5VUb/JqyKRXotlOtwgu4stIYCmnw==}
+ /@babel/plugin-transform-modules-systemjs@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-hoist-variables': 7.22.5
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-validator-identifier': 7.22.20
dev: true
- /@babel/plugin-transform-property-literals/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-r1uilDthkgXW8Z1vJz2dKYLV1tuw2xsbrp3MrZmD99Wh9vsfKoob+JTgri5VUb/JqyKRXotlOtwgu4stIYCmnw==}
+ /@babel/plugin-transform-modules-systemjs@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-hoist-variables': 7.22.5
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-validator-identifier': 7.22.20
dev: true
- /@babel/plugin-transform-react-display-name/7.15.1_@babel+core@7.15.0:
- resolution: {integrity: sha512-yQZ/i/pUCJAHI/LbtZr413S3VT26qNrEm0M5RRxQJA947/YNYwbZbBaXGDrq6CG5QsZycI1VIP6d7pQaBfP+8Q==}
+ /@babel/plugin-transform-modules-umd@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-react-jsx-development/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-rdwG/9jC6QybWxVe2UVOa7q6cnTpw8JRRHOxntG/h6g/guAOe6AhtQHJuJh5FwmnXIT1bdm5vC2/5huV8ZOorQ==}
+ /@babel/plugin-transform-modules-umd@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-transform-react-jsx': 7.14.9_@babel+core@7.15.0
+ '@babel/core': 7.18.9
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-react-jsx-source/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-1TpSDnD9XR/rQ2tzunBVPThF5poaYT9GqP+of8fAtguYuI/dm2RkrMBDemsxtY0XBzvW7nXjYM0hRyKX9QYj7Q==}
+ /@babel/plugin-transform-modules-umd@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-react-jsx/7.14.9:
- resolution: {integrity: sha512-30PeETvS+AeD1f58i1OVyoDlVYQhap/K20ZrMjLmmzmC2AYR/G43D4sdJAaDAqCD3MYpSWbmrz3kES158QSLjw==}
+ /@babel/plugin-transform-named-capturing-groups-regex@7.19.1(@babel/core@7.22.1):
+ resolution: {integrity: sha512-oWk9l9WItWBQYS4FgXD4Uyy5kq898lvkXpXQxoJEY1RnvPk4R/Dvu2ebXU9q8lP+rlMwUQTFf2Ok6d78ODa0kw==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-jsx': 7.14.5
- '@babel/types': 7.15.0
+ '@babel/core': 7.22.1
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-react-jsx/7.14.9_@babel+core@7.13.16:
- resolution: {integrity: sha512-30PeETvS+AeD1f58i1OVyoDlVYQhap/K20ZrMjLmmzmC2AYR/G43D4sdJAaDAqCD3MYpSWbmrz3kES158QSLjw==}
+ /@babel/plugin-transform-named-capturing-groups-regex@7.22.5(@babel/core@7.18.9):
+ resolution: {integrity: sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-jsx': 7.14.5_@babel+core@7.13.16
- '@babel/types': 7.15.0
+ '@babel/core': 7.18.9
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-react-jsx/7.14.9_@babel+core@7.15.0:
- resolution: {integrity: sha512-30PeETvS+AeD1f58i1OVyoDlVYQhap/K20ZrMjLmmzmC2AYR/G43D4sdJAaDAqCD3MYpSWbmrz3kES158QSLjw==}
+ /@babel/plugin-transform-named-capturing-groups-regex@7.22.5(@babel/core@7.23.5):
+ resolution: {integrity: sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-jsx': 7.14.5_@babel+core@7.15.0
- '@babel/types': 7.15.0
+ '@babel/core': 7.23.5
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-react-pure-annotations/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-3X4HpBJimNxW4rhUy/SONPyNQHp5YRr0HhJdT2OH1BRp0of7u3Dkirc7x9FRJMKMqTBI079VZ1hzv7Ouuz///g==}
+ /@babel/plugin-transform-new-target@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-annotate-as-pure': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-regenerator/7.14.5:
- resolution: {integrity: sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg==}
+ /@babel/plugin-transform-new-target@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- regenerator-transform: 0.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-regenerator/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg==}
+ /@babel/plugin-transform-new-target@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- regenerator-transform: 0.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-regenerator/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg==}
+ /@babel/plugin-transform-nullish-coalescing-operator@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- regenerator-transform: 0.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-reserved-words/7.14.5:
- resolution: {integrity: sha512-cv4F2rv1nD4qdexOGsRQXJrOcyb5CrgjUH9PKrrtyhSDBNWGxd0UIitjyJiWagS+EbUGjG++22mGH1Pub8D6Vg==}
+ /@babel/plugin-transform-nullish-coalescing-operator@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-reserved-words/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-cv4F2rv1nD4qdexOGsRQXJrOcyb5CrgjUH9PKrrtyhSDBNWGxd0UIitjyJiWagS+EbUGjG++22mGH1Pub8D6Vg==}
+ /@babel/plugin-transform-numeric-separator@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-reserved-words/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-cv4F2rv1nD4qdexOGsRQXJrOcyb5CrgjUH9PKrrtyhSDBNWGxd0UIitjyJiWagS+EbUGjG++22mGH1Pub8D6Vg==}
+ /@babel/plugin-transform-numeric-separator@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-runtime/7.15.0:
- resolution: {integrity: sha512-sfHYkLGjhzWTq6xsuQ01oEsUYjkHRux9fW1iUA68dC7Qd8BS1Unq4aZ8itmQp95zUzIcyR2EbNMTzAicFj+guw==}
+ /@babel/plugin-transform-object-assign@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-mQisZ3JfqWh2gVXvfqYCAAyRs6+7oev+myBsTwW5RnPhYXOTuCEw2oe3YgxlXMViXUS53lG8koulI7mJ+8JE+A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-module-imports': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
- babel-plugin-polyfill-corejs2: 0.2.2
- babel-plugin-polyfill-corejs3: 0.2.4
- babel-plugin-polyfill-regenerator: 0.2.2
- semver: 6.3.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-shorthand-properties/7.14.5:
- resolution: {integrity: sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g==}
+ /@babel/plugin-transform-object-rest-spread@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/compat-data': 7.23.5
+ '@babel/core': 7.18.9
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-shorthand-properties/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g==}
+ /@babel/plugin-transform-object-rest-spread@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/compat-data': 7.23.5
+ '@babel/core': 7.23.5
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-shorthand-properties/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g==}
+ /@babel/plugin-transform-object-super@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-replace-supers': 7.22.20(@babel/core@7.22.1)
dev: true
- /@babel/plugin-transform-spread/7.14.6:
- resolution: {integrity: sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag==}
+ /@babel/plugin-transform-object-super@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-skip-transparent-expression-wrappers': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-replace-supers': 7.22.20(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-spread/7.14.6_@babel+core@7.13.16:
- resolution: {integrity: sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag==}
+ /@babel/plugin-transform-object-super@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-skip-transparent-expression-wrappers': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-replace-supers': 7.22.20(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-spread/7.14.6_@babel+core@7.15.0:
- resolution: {integrity: sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag==}
+ /@babel/plugin-transform-optional-catch-binding@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-skip-transparent-expression-wrappers': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-sticky-regex/7.14.5:
- resolution: {integrity: sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A==}
+ /@babel/plugin-transform-optional-catch-binding@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-sticky-regex/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A==}
+ /@babel/plugin-transform-optional-chaining@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
+ '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-sticky-regex/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A==}
+ /@babel/plugin-transform-optional-chaining@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
+ '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-template-literals/7.14.5:
- resolution: {integrity: sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg==}
+ /@babel/plugin-transform-parameters@7.18.8(@babel/core@7.22.1):
+ resolution: {integrity: sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-template-literals/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg==}
+ /@babel/plugin-transform-parameters@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-template-literals/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg==}
+ /@babel/plugin-transform-parameters@7.23.3(@babel/core@7.22.1):
+ resolution: {integrity: sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-typeof-symbol/7.14.5:
- resolution: {integrity: sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw==}
+ /@babel/plugin-transform-parameters@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-typeof-symbol/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw==}
+ /@babel/plugin-transform-private-methods@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-typeof-symbol/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw==}
+ /@babel/plugin-transform-private-methods@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-typescript/7.15.0_@babel+core@7.13.16:
- resolution: {integrity: sha512-WIIEazmngMEEHDaPTx0IZY48SaAmjVWe3TRSX7cmJXn0bEv9midFzAjxiruOWYIVf5iQ10vFx7ASDpgEO08L5w==}
+ /@babel/plugin-transform-private-property-in-object@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-typescript': 7.14.5_@babel+core@7.13.16
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-typescript/7.15.0_@babel+core@7.15.0:
- resolution: {integrity: sha512-WIIEazmngMEEHDaPTx0IZY48SaAmjVWe3TRSX7cmJXn0bEv9midFzAjxiruOWYIVf5iQ10vFx7ASDpgEO08L5w==}
+ /@babel/plugin-transform-private-property-in-object@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-create-class-features-plugin': 7.15.0_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-syntax-typescript': 7.14.5_@babel+core@7.15.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.23.5)
dev: true
- /@babel/plugin-transform-unicode-escapes/7.14.5:
- resolution: {integrity: sha512-crTo4jATEOjxj7bt9lbYXcBAM3LZaUrbP2uUdxb6WIorLmjNKSpHfIybgY4B8SRpbf8tEVIWH3Vtm7ayCrKocA==}
+ /@babel/plugin-transform-property-literals@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-unicode-escapes/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-crTo4jATEOjxj7bt9lbYXcBAM3LZaUrbP2uUdxb6WIorLmjNKSpHfIybgY4B8SRpbf8tEVIWH3Vtm7ayCrKocA==}
+ /@babel/plugin-transform-property-literals@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-unicode-escapes/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-crTo4jATEOjxj7bt9lbYXcBAM3LZaUrbP2uUdxb6WIorLmjNKSpHfIybgY4B8SRpbf8tEVIWH3Vtm7ayCrKocA==}
+ /@babel/plugin-transform-property-literals@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-unicode-regex/7.14.5:
- resolution: {integrity: sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw==}
+ /@babel/plugin-transform-react-display-name@7.18.6(@babel/core@7.18.9):
+ resolution: {integrity: sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-create-regexp-features-plugin': 7.14.5
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/plugin-transform-unicode-regex/7.14.5_@babel+core@7.13.16:
- resolution: {integrity: sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw==}
+ /@babel/plugin-transform-react-jsx-development@7.18.6(@babel/core@7.18.9):
+ resolution: {integrity: sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-create-regexp-features-plugin': 7.14.5_@babel+core@7.13.16
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.18.9
+ '@babel/plugin-transform-react-jsx': 7.22.3(@babel/core@7.18.9)
dev: true
- /@babel/plugin-transform-unicode-regex/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw==}
+ /@babel/plugin-transform-react-jsx@7.19.0(@babel/core@7.22.1):
+ resolution: {integrity: sha512-UVEvX3tXie3Szm3emi1+G63jyw1w5IcMY0FSKM+CRnKRI5Mr1YbCNgsSTwoTwKphQEG9P+QqmuRFneJPZuHNhg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-create-regexp-features-plugin': 7.14.5_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
+ '@babel/core': 7.22.1
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.22.1)
+ '@babel/types': 7.23.5
dev: true
- /@babel/preset-env/7.15.0:
- resolution: {integrity: sha512-FhEpCNFCcWW3iZLg0L2NPE9UerdtsCR6ZcsGHUX6Om6kbCQeL5QZDqFDmeNHC6/fy6UH3jEge7K4qG5uC9In0Q==}
+ /@babel/plugin-transform-react-jsx@7.22.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-JEulRWG2f04a7L8VWaOngWiK6p+JOSpB+DAtwfJgOaej1qdbNxqtK7MwTBHjUA10NeFcszlFNqCdbRcirzh2uQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/helper-compilation-targets': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-validator-option': 7.14.5
- '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.14.5
- '@babel/plugin-proposal-async-generator-functions': 7.14.9
- '@babel/plugin-proposal-class-properties': 7.14.5
- '@babel/plugin-proposal-class-static-block': 7.14.5
- '@babel/plugin-proposal-dynamic-import': 7.14.5
- '@babel/plugin-proposal-export-namespace-from': 7.14.5
- '@babel/plugin-proposal-json-strings': 7.14.5
- '@babel/plugin-proposal-logical-assignment-operators': 7.14.5
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5
- '@babel/plugin-proposal-numeric-separator': 7.14.5
- '@babel/plugin-proposal-object-rest-spread': 7.14.7
- '@babel/plugin-proposal-optional-catch-binding': 7.14.5
- '@babel/plugin-proposal-optional-chaining': 7.14.5
- '@babel/plugin-proposal-private-methods': 7.14.5
- '@babel/plugin-proposal-private-property-in-object': 7.14.5
- '@babel/plugin-proposal-unicode-property-regex': 7.14.5
- '@babel/plugin-syntax-async-generators': 7.8.4
- '@babel/plugin-syntax-class-properties': 7.12.13
- '@babel/plugin-syntax-class-static-block': 7.14.5
- '@babel/plugin-syntax-dynamic-import': 7.8.3
- '@babel/plugin-syntax-export-namespace-from': 7.8.3
- '@babel/plugin-syntax-json-strings': 7.8.3
- '@babel/plugin-syntax-logical-assignment-operators': 7.10.4
- '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3
- '@babel/plugin-syntax-numeric-separator': 7.10.4
- '@babel/plugin-syntax-object-rest-spread': 7.8.3
- '@babel/plugin-syntax-optional-catch-binding': 7.8.3
- '@babel/plugin-syntax-optional-chaining': 7.8.3
- '@babel/plugin-syntax-private-property-in-object': 7.14.5
- '@babel/plugin-syntax-top-level-await': 7.14.5
- '@babel/plugin-transform-arrow-functions': 7.14.5
- '@babel/plugin-transform-async-to-generator': 7.14.5
- '@babel/plugin-transform-block-scoped-functions': 7.14.5
- '@babel/plugin-transform-block-scoping': 7.15.3
- '@babel/plugin-transform-classes': 7.14.9
- '@babel/plugin-transform-computed-properties': 7.14.5
- '@babel/plugin-transform-destructuring': 7.14.7
- '@babel/plugin-transform-dotall-regex': 7.14.5
- '@babel/plugin-transform-duplicate-keys': 7.14.5
- '@babel/plugin-transform-exponentiation-operator': 7.14.5
- '@babel/plugin-transform-for-of': 7.14.5
- '@babel/plugin-transform-function-name': 7.14.5
- '@babel/plugin-transform-literals': 7.14.5
- '@babel/plugin-transform-member-expression-literals': 7.14.5
- '@babel/plugin-transform-modules-amd': 7.14.5
- '@babel/plugin-transform-modules-commonjs': 7.15.0
- '@babel/plugin-transform-modules-systemjs': 7.14.5
- '@babel/plugin-transform-modules-umd': 7.14.5
- '@babel/plugin-transform-named-capturing-groups-regex': 7.14.9
- '@babel/plugin-transform-new-target': 7.14.5
- '@babel/plugin-transform-object-super': 7.14.5
- '@babel/plugin-transform-parameters': 7.14.5
- '@babel/plugin-transform-property-literals': 7.14.5
- '@babel/plugin-transform-regenerator': 7.14.5
- '@babel/plugin-transform-reserved-words': 7.14.5
- '@babel/plugin-transform-shorthand-properties': 7.14.5
- '@babel/plugin-transform-spread': 7.14.6
- '@babel/plugin-transform-sticky-regex': 7.14.5
- '@babel/plugin-transform-template-literals': 7.14.5
- '@babel/plugin-transform-typeof-symbol': 7.14.5
- '@babel/plugin-transform-unicode-escapes': 7.14.5
- '@babel/plugin-transform-unicode-regex': 7.14.5
- '@babel/preset-modules': 0.1.4
- '@babel/types': 7.15.0
- babel-plugin-polyfill-corejs2: 0.2.2
- babel-plugin-polyfill-corejs3: 0.2.4
- babel-plugin-polyfill-regenerator: 0.2.2
- core-js-compat: 3.16.2
- semver: 6.3.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.18.9)
+ '@babel/types': 7.23.5
dev: true
- /@babel/preset-env/7.15.0_@babel+core@7.13.16:
- resolution: {integrity: sha512-FhEpCNFCcWW3iZLg0L2NPE9UerdtsCR6ZcsGHUX6Om6kbCQeL5QZDqFDmeNHC6/fy6UH3jEge7K4qG5uC9In0Q==}
+ /@babel/plugin-transform-react-pure-annotations@7.18.6(@babel/core@7.18.9):
+ resolution: {integrity: sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/core': 7.13.16
- '@babel/helper-compilation-targets': 7.15.0_@babel+core@7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-validator-option': 7.14.5
- '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-async-generator-functions': 7.14.9_@babel+core@7.13.16
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-class-static-block': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-dynamic-import': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-export-namespace-from': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-json-strings': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-logical-assignment-operators': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-numeric-separator': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.13.16
- '@babel/plugin-proposal-optional-catch-binding': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-private-methods': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-private-property-in-object': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-proposal-unicode-property-regex': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-syntax-async-generators': 7.8.4_@babel+core@7.13.16
- '@babel/plugin-syntax-class-properties': 7.12.13_@babel+core@7.13.16
- '@babel/plugin-syntax-class-static-block': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.13.16
- '@babel/plugin-syntax-export-namespace-from': 7.8.3_@babel+core@7.13.16
- '@babel/plugin-syntax-json-strings': 7.8.3_@babel+core@7.13.16
- '@babel/plugin-syntax-logical-assignment-operators': 7.10.4_@babel+core@7.13.16
- '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.13.16
- '@babel/plugin-syntax-numeric-separator': 7.10.4_@babel+core@7.13.16
- '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.13.16
- '@babel/plugin-syntax-optional-catch-binding': 7.8.3_@babel+core@7.13.16
- '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.13.16
- '@babel/plugin-syntax-private-property-in-object': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-syntax-top-level-await': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-arrow-functions': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-async-to-generator': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-block-scoped-functions': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-block-scoping': 7.15.3_@babel+core@7.13.16
- '@babel/plugin-transform-classes': 7.14.9_@babel+core@7.13.16
- '@babel/plugin-transform-computed-properties': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-destructuring': 7.14.7_@babel+core@7.13.16
- '@babel/plugin-transform-dotall-regex': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-duplicate-keys': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-exponentiation-operator': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-for-of': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-function-name': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-literals': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-member-expression-literals': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-modules-amd': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-modules-commonjs': 7.15.0_@babel+core@7.13.16
- '@babel/plugin-transform-modules-systemjs': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-modules-umd': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-named-capturing-groups-regex': 7.14.9_@babel+core@7.13.16
- '@babel/plugin-transform-new-target': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-object-super': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-property-literals': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-regenerator': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-reserved-words': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-shorthand-properties': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-spread': 7.14.6_@babel+core@7.13.16
- '@babel/plugin-transform-sticky-regex': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-template-literals': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-typeof-symbol': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-unicode-escapes': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-unicode-regex': 7.14.5_@babel+core@7.13.16
- '@babel/preset-modules': 0.1.4_@babel+core@7.13.16
- '@babel/types': 7.15.0
- babel-plugin-polyfill-corejs2: 0.2.2_@babel+core@7.13.16
- babel-plugin-polyfill-corejs3: 0.2.4_@babel+core@7.13.16
- babel-plugin-polyfill-regenerator: 0.2.2_@babel+core@7.13.16
- core-js-compat: 3.16.2
- semver: 6.3.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/preset-env/7.15.0_@babel+core@7.15.0:
- resolution: {integrity: sha512-FhEpCNFCcWW3iZLg0L2NPE9UerdtsCR6ZcsGHUX6Om6kbCQeL5QZDqFDmeNHC6/fy6UH3jEge7K4qG5uC9In0Q==}
+ /@babel/plugin-transform-regenerator@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/core': 7.15.0
- '@babel/helper-compilation-targets': 7.15.0_@babel+core@7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-validator-option': 7.14.5
- '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-async-generator-functions': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-class-static-block': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-dynamic-import': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-export-namespace-from': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-json-strings': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-logical-assignment-operators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-numeric-separator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-proposal-optional-catch-binding': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-private-methods': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-private-property-in-object': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-unicode-property-regex': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-async-generators': 7.8.4_@babel+core@7.15.0
- '@babel/plugin-syntax-class-properties': 7.12.13_@babel+core@7.15.0
- '@babel/plugin-syntax-class-static-block': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-export-namespace-from': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-json-strings': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-logical-assignment-operators': 7.10.4_@babel+core@7.15.0
- '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-numeric-separator': 7.10.4_@babel+core@7.15.0
- '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-optional-catch-binding': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-private-property-in-object': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-top-level-await': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-arrow-functions': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-async-to-generator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-block-scoped-functions': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-block-scoping': 7.15.3_@babel+core@7.15.0
- '@babel/plugin-transform-classes': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-computed-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-destructuring': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-transform-dotall-regex': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-duplicate-keys': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-exponentiation-operator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-for-of': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-function-name': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-literals': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-member-expression-literals': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-modules-amd': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-modules-commonjs': 7.15.0_@babel+core@7.15.0
- '@babel/plugin-transform-modules-systemjs': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-modules-umd': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-named-capturing-groups-regex': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-new-target': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-object-super': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-property-literals': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-regenerator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-reserved-words': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-shorthand-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-spread': 7.14.6_@babel+core@7.15.0
- '@babel/plugin-transform-sticky-regex': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-template-literals': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-typeof-symbol': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-unicode-escapes': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-unicode-regex': 7.14.5_@babel+core@7.15.0
- '@babel/preset-modules': 0.1.4_@babel+core@7.15.0
- '@babel/types': 7.15.0
- babel-plugin-polyfill-corejs2: 0.2.2_@babel+core@7.15.0
- babel-plugin-polyfill-corejs3: 0.2.4_@babel+core@7.15.0
- babel-plugin-polyfill-regenerator: 0.2.2_@babel+core@7.15.0
- core-js-compat: 3.16.2
- semver: 6.3.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ regenerator-transform: 0.15.2
dev: true
- /@babel/preset-modules/0.1.4:
- resolution: {integrity: sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg==}
+ /@babel/plugin-transform-regenerator@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-proposal-unicode-property-regex': 7.14.5
- '@babel/plugin-transform-dotall-regex': 7.14.5
- '@babel/types': 7.15.0
- esutils: 2.0.3
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ regenerator-transform: 0.15.2
dev: true
- /@babel/preset-modules/0.1.4_@babel+core@7.13.16:
- resolution: {integrity: sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg==}
+ /@babel/plugin-transform-regenerator@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-proposal-unicode-property-regex': 7.14.5_@babel+core@7.13.16
- '@babel/plugin-transform-dotall-regex': 7.14.5_@babel+core@7.13.16
- '@babel/types': 7.15.0
- esutils: 2.0.3
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ regenerator-transform: 0.15.2
dev: true
- /@babel/preset-modules/0.1.4_@babel+core@7.15.0:
- resolution: {integrity: sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg==}
+ /@babel/plugin-transform-reserved-words@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/plugin-proposal-unicode-property-regex': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-dotall-regex': 7.14.5_@babel+core@7.15.0
- '@babel/types': 7.15.0
- esutils: 2.0.3
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/preset-react/7.14.5_@babel+core@7.15.0:
- resolution: {integrity: sha512-XFxBkjyObLvBaAvkx1Ie95Iaq4S/GUEIrejyrntQ/VCMKUYvKLoyKxOBzJ2kjA3b6rC9/KL6KXfDC2GqvLiNqQ==}
+ /@babel/plugin-transform-reserved-words@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-validator-option': 7.14.5
- '@babel/plugin-transform-react-display-name': 7.15.1_@babel+core@7.15.0
- '@babel/plugin-transform-react-jsx': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-react-jsx-development': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-react-pure-annotations': 7.14.5_@babel+core@7.15.0
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/preset-typescript/7.15.0_@babel+core@7.13.16:
- resolution: {integrity: sha512-lt0Y/8V3y06Wq/8H/u0WakrqciZ7Fz7mwPDHWUJAXlABL5hiUG42BNlRXiELNjeWjO5rWmnNKlx+yzJvxezHow==}
+ /@babel/plugin-transform-reserved-words@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-validator-option': 7.14.5
- '@babel/plugin-transform-typescript': 7.15.0_@babel+core@7.13.16
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@babel/preset-typescript/7.15.0_@babel+core@7.15.0:
- resolution: {integrity: sha512-lt0Y/8V3y06Wq/8H/u0WakrqciZ7Fz7mwPDHWUJAXlABL5hiUG42BNlRXiELNjeWjO5rWmnNKlx+yzJvxezHow==}
+ /@babel/plugin-transform-runtime@7.19.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-PRH37lz4JU156lYFW1p8OxE5i7d6Sl/zV58ooyr+q1J1lnQPyg5tIiXlIwNVhJaY4W3TmOtdc8jqdXQcB1v5Yw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-plugin-utils': 7.14.5
- '@babel/helper-validator-option': 7.14.5
- '@babel/plugin-transform-typescript': 7.15.0_@babel+core@7.15.0
+ '@babel/core': 7.22.1
+ '@babel/helper-module-imports': 7.21.4
+ '@babel/helper-plugin-utils': 7.21.5
+ babel-plugin-polyfill-corejs2: 0.3.3(@babel/core@7.22.1)
+ babel-plugin-polyfill-corejs3: 0.6.0(@babel/core@7.22.1)
+ babel-plugin-polyfill-regenerator: 0.4.1(@babel/core@7.22.1)
+ semver: 6.3.0
transitivePeerDependencies:
- supports-color
dev: true
- /@babel/register/7.15.3_@babel+core@7.15.0:
- resolution: {integrity: sha512-mj4IY1ZJkorClxKTImccn4T81+UKTo4Ux0+OFSV9hME1ooqS9UV+pJ6BjD0qXPK4T3XW/KNa79XByjeEMZz+fw==}
+ /@babel/plugin-transform-runtime@7.23.4(@babel/core@7.18.9):
+ resolution: {integrity: sha512-ITwqpb6V4btwUG0YJR82o2QvmWrLgDnx/p2A3CTPYGaRgULkDiC0DRA2C4jlRB9uXGUEfaSS/IGHfVW+ohzYDw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- clone-deep: 4.0.1
- find-cache-dir: 2.1.0
- make-dir: 2.1.0
- pirates: 4.0.1
- source-map-support: 0.5.19
- dev: true
-
- /@babel/runtime-corejs3/7.15.3:
- resolution: {integrity: sha512-30A3lP+sRL6ml8uhoJSs+8jwpKzbw8CqBvDc1laeptxPm5FahumJxirigcbD2qTs71Sonvj1cyZB0OKGAmxQ+A==}
- engines: {node: '>=6.9.0'}
- dependencies:
- core-js-pure: 3.16.2
- regenerator-runtime: 0.13.9
- dev: true
-
- /@babel/runtime/7.12.5:
- resolution: {integrity: sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==}
- dependencies:
- regenerator-runtime: 0.13.7
- dev: true
-
- /@babel/runtime/7.15.3:
- resolution: {integrity: sha512-OvwMLqNXkCXSz1kSm58sEsNuhqOx/fKpnUnKnFB5v8uDda5bLNEHNgKPvhDN6IU0LDcnHQ90LlJ0Q6jnyBSIBA==}
- engines: {node: '>=6.9.0'}
- dependencies:
- regenerator-runtime: 0.13.9
-
- /@babel/template/7.14.5:
- resolution: {integrity: sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==}
- engines: {node: '>=6.9.0'}
- dependencies:
- '@babel/code-frame': 7.14.5
- '@babel/parser': 7.15.3
- '@babel/types': 7.15.0
+ '@babel/core': 7.18.9
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ babel-plugin-polyfill-corejs2: 0.4.6(@babel/core@7.18.9)
+ babel-plugin-polyfill-corejs3: 0.8.6(@babel/core@7.18.9)
+ babel-plugin-polyfill-regenerator: 0.5.3(@babel/core@7.18.9)
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /@babel/traverse/7.15.0:
- resolution: {integrity: sha512-392d8BN0C9eVxVWd8H6x9WfipgVH5IaIoLp23334Sc1vbKKWINnvwRpb4us0xtPaCumlwbTtIYNA0Dv/32sVFw==}
+ /@babel/plugin-transform-runtime@7.23.4(@babel/core@7.23.5):
+ resolution: {integrity: sha512-ITwqpb6V4btwUG0YJR82o2QvmWrLgDnx/p2A3CTPYGaRgULkDiC0DRA2C4jlRB9uXGUEfaSS/IGHfVW+ohzYDw==}
engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/code-frame': 7.14.5
- '@babel/generator': 7.15.0
- '@babel/helper-function-name': 7.14.5
- '@babel/helper-hoist-variables': 7.14.5
- '@babel/helper-split-export-declaration': 7.14.5
- '@babel/parser': 7.15.3
- '@babel/types': 7.15.0
- debug: 4.3.2
- globals: 11.12.0
+ '@babel/core': 7.23.5
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ babel-plugin-polyfill-corejs2: 0.4.6(@babel/core@7.23.5)
+ babel-plugin-polyfill-corejs3: 0.8.6(@babel/core@7.23.5)
+ babel-plugin-polyfill-regenerator: 0.5.3(@babel/core@7.23.5)
+ semver: 6.3.1
transitivePeerDependencies:
- supports-color
dev: true
- /@babel/types/7.15.0:
- resolution: {integrity: sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ==}
+ /@babel/plugin-transform-shorthand-properties@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==}
engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-validator-identifier': 7.14.9
- to-fast-properties: 2.0.0
- dev: true
-
- /@base2/pretty-print-object/1.0.0:
- resolution: {integrity: sha512-4Th98KlMHr5+JkxfcoDT//6vY8vM+iSPrLNpHhRyLx2CFYi8e2RfqPLdpbnpo0Q5lQC5hNB79yes07zb02fvCw==}
- dev: true
-
- /@bcoe/v8-coverage/0.2.3:
- resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
- dev: true
-
- /@cnakazawa/watch/1.0.4:
- resolution: {integrity: sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==}
- engines: {node: '>=0.1.95'}
- hasBin: true
- dependencies:
- exec-sh: 0.3.6
- minimist: 1.2.5
- dev: true
-
- /@concordance/react/2.0.0:
- resolution: {integrity: sha512-huLSkUuM2/P+U0uy2WwlKuixMsTODD8p4JVQBI4VKeopkiN0C7M3N9XYVawb4M+4spN5RrO/eLhk7KoQX6nsfA==}
- engines: {node: '>=6.12.3 <7 || >=8.9.4 <9 || >=10.0.0'}
- dependencies:
- arrify: 1.0.1
- dev: true
-
- /@creativebulma/bulma-tooltip/1.2.0:
- resolution: {integrity: sha512-ooImbeXEBxf77cttbzA7X5rC5aAWm9UsXIGViFOnsqB+6M944GkB28S5R4UWRqjFd2iW4zGEkEifAU+q43pt2w==}
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@emotion/cache/10.0.29:
- resolution: {integrity: sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==}
+ /@babel/plugin-transform-shorthand-properties@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@emotion/sheet': 0.9.4
- '@emotion/stylis': 0.8.5
- '@emotion/utils': 0.11.3
- '@emotion/weak-memoize': 0.2.5
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@emotion/core/10.1.1:
- resolution: {integrity: sha512-ZMLG6qpXR8x031NXD8HJqugy/AZSkAuMxxqB46pmAR7ze47MhNJ56cdoX243QPZdGctrdfo+s08yZTiwaUcRKA==}
+ /@babel/plugin-transform-shorthand-properties@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- react: '>=16.3.0'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/runtime': 7.15.3
- '@emotion/cache': 10.0.29
- '@emotion/css': 10.0.27
- '@emotion/serialize': 0.11.16
- '@emotion/sheet': 0.9.4
- '@emotion/utils': 0.11.3
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@emotion/core/10.1.1_react@16.14.0:
- resolution: {integrity: sha512-ZMLG6qpXR8x031NXD8HJqugy/AZSkAuMxxqB46pmAR7ze47MhNJ56cdoX243QPZdGctrdfo+s08yZTiwaUcRKA==}
+ /@babel/plugin-transform-spread@7.19.0(@babel/core@7.22.1):
+ resolution: {integrity: sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- react: '>=16.3.0'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/runtime': 7.15.3
- '@emotion/cache': 10.0.29
- '@emotion/css': 10.0.27
- '@emotion/serialize': 0.11.16
- '@emotion/sheet': 0.9.4
- '@emotion/utils': 0.11.3
- react: 16.14.0
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
dev: true
- /@emotion/css/10.0.27:
- resolution: {integrity: sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==}
+ /@babel/plugin-transform-spread@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@emotion/serialize': 0.11.16
- '@emotion/utils': 0.11.3
- babel-plugin-emotion: 10.2.2
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
dev: true
- /@emotion/hash/0.8.0:
- resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
- dev: true
-
- /@emotion/is-prop-valid/0.8.8:
- resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
+ /@babel/plugin-transform-spread@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@emotion/memoize': 0.7.4
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-skip-transparent-expression-wrappers': 7.22.5
dev: true
- /@emotion/memoize/0.7.4:
- resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==}
- dev: true
-
- /@emotion/serialize/0.11.16:
- resolution: {integrity: sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==}
+ /@babel/plugin-transform-sticky-regex@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@emotion/hash': 0.8.0
- '@emotion/memoize': 0.7.4
- '@emotion/unitless': 0.7.5
- '@emotion/utils': 0.11.3
- csstype: 2.6.17
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@emotion/sheet/0.9.4:
- resolution: {integrity: sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==}
- dev: true
-
- /@emotion/styled-base/10.0.31_5f216699bc8c1f24088b3bf77b7cbbdf:
- resolution: {integrity: sha512-wTOE1NcXmqMWlyrtwdkqg87Mu6Rj1MaukEoEmEkHirO5IoHDJ8LgCQL4MjJODgxWxXibGR3opGp1p7YvkNEdXQ==}
+ /@babel/plugin-transform-sticky-regex@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@emotion/core': ^10.0.28
- react: '>=16.3.0'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/runtime': 7.15.3
- '@emotion/core': 10.1.1_react@16.14.0
- '@emotion/is-prop-valid': 0.8.8
- '@emotion/serialize': 0.11.16
- '@emotion/utils': 0.11.3
- react: 16.14.0
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@emotion/styled-base/10.0.31_@emotion+core@10.1.1:
- resolution: {integrity: sha512-wTOE1NcXmqMWlyrtwdkqg87Mu6Rj1MaukEoEmEkHirO5IoHDJ8LgCQL4MjJODgxWxXibGR3opGp1p7YvkNEdXQ==}
+ /@babel/plugin-transform-sticky-regex@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@emotion/core': ^10.0.28
- react: '>=16.3.0'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/runtime': 7.15.3
- '@emotion/core': 10.1.1
- '@emotion/is-prop-valid': 0.8.8
- '@emotion/serialize': 0.11.16
- '@emotion/utils': 0.11.3
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@emotion/styled/10.0.27_5f216699bc8c1f24088b3bf77b7cbbdf:
- resolution: {integrity: sha512-iK/8Sh7+NLJzyp9a5+vIQIXTYxfT4yB/OJbjzQanB2RZpvmzBQOHZWhpAMZWYEKRNNbsD6WfBw5sVWkb6WzS/Q==}
+ /@babel/plugin-transform-template-literals@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@emotion/core': ^10.0.27
- react: '>=16.3.0'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@emotion/core': 10.1.1_react@16.14.0
- '@emotion/styled-base': 10.0.31_5f216699bc8c1f24088b3bf77b7cbbdf
- babel-plugin-emotion: 10.2.2
- react: 16.14.0
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.21.5
dev: true
- /@emotion/styled/10.0.27_@emotion+core@10.1.1:
- resolution: {integrity: sha512-iK/8Sh7+NLJzyp9a5+vIQIXTYxfT4yB/OJbjzQanB2RZpvmzBQOHZWhpAMZWYEKRNNbsD6WfBw5sVWkb6WzS/Q==}
+ /@babel/plugin-transform-template-literals@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@emotion/core': ^10.0.27
- react: '>=16.3.0'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@emotion/core': 10.1.1
- '@emotion/styled-base': 10.0.31_@emotion+core@10.1.1
- babel-plugin-emotion: 10.2.2
- dev: true
-
- /@emotion/stylis/0.8.5:
- resolution: {integrity: sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==}
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@emotion/unitless/0.7.5:
- resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==}
- dev: true
-
- /@emotion/utils/0.11.3:
- resolution: {integrity: sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==}
- dev: true
-
- /@emotion/weak-memoize/0.2.5:
- resolution: {integrity: sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==}
- dev: true
-
- /@eslint/eslintrc/0.3.0:
- resolution: {integrity: sha512-1JTKgrOKAHVivSvOYw+sJOunkBjUOvjqWk1DPja7ZFhIS2mX/4EgTT8M7eTK9jrKhL/FvXXEbQwIs3pg1xp3dg==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ /@babel/plugin-transform-template-literals@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- ajv: 6.12.6
- debug: 4.3.1
- espree: 7.3.1
- globals: 12.4.0
- ignore: 4.0.6
- import-fresh: 3.3.0
- js-yaml: 3.14.1
- lodash: 4.17.20
- minimatch: 3.0.4
- strip-json-comments: 3.1.1
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@istanbuljs/load-nyc-config/1.1.0:
- resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
- engines: {node: '>=8'}
+ /@babel/plugin-transform-typeof-symbol@7.18.9(@babel/core@7.22.1):
+ resolution: {integrity: sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- camelcase: 5.3.1
- find-up: 4.1.0
- get-package-type: 0.1.0
- js-yaml: 3.14.1
- resolve-from: 5.0.0
- dev: true
-
- /@istanbuljs/schema/0.1.2:
- resolution: {integrity: sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==}
- engines: {node: '>=8'}
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@istanbuljs/schema/0.1.3:
- resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
- engines: {node: '>=8'}
- dev: true
-
- /@jest/console/26.6.2:
- resolution: {integrity: sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g==}
- engines: {node: '>= 10.14.2'}
+ /@babel/plugin-transform-typeof-symbol@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@jest/types': 26.6.2
- '@types/node': 14.17.10
- chalk: 4.1.2
- jest-message-util: 26.6.2
- jest-util: 26.6.2
- slash: 3.0.0
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@jest/console/27.1.0:
- resolution: {integrity: sha512-+Vl+xmLwAXLNlqT61gmHEixeRbS4L8MUzAjtpBCOPWH+izNI/dR16IeXjkXJdRtIVWVSf9DO1gdp67B1XorZhQ==}
- engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ /@babel/plugin-transform-typeof-symbol@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@jest/types': 27.1.0
- '@types/node': 14.17.10
- chalk: 4.1.2
- jest-message-util: 27.1.0
- jest-util: 27.1.0
- slash: 3.0.0
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@jest/core/26.6.3:
- resolution: {integrity: sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw==}
- engines: {node: '>= 10.14.2'}
+ /@babel/plugin-transform-typescript@7.20.13(@babel/core@7.18.9):
+ resolution: {integrity: sha512-O7I/THxarGcDZxkgWKMUrk7NK1/WbHAg3Xx86gqS6x9MTrNL6AwIluuZ96ms4xeDe6AVx6rjHbWHP7x26EPQBA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@jest/console': 26.6.2
- '@jest/reporters': 26.6.2
- '@jest/test-result': 26.6.2
- '@jest/transform': 26.6.2
- '@jest/types': 26.6.2
- '@types/node': 14.17.10
- ansi-escapes: 4.3.2
- chalk: 4.1.2
- exit: 0.1.2
- graceful-fs: 4.2.8
- jest-changed-files: 26.6.2
- jest-config: 26.6.3
- jest-haste-map: 26.6.2
- jest-message-util: 26.6.2
- jest-regex-util: 26.0.0
- jest-resolve: 26.6.2
- jest-resolve-dependencies: 26.6.3
- jest-runner: 26.6.3
- jest-runtime: 26.6.3
- jest-snapshot: 26.6.2
- jest-util: 26.6.2
- jest-validate: 26.6.2
- jest-watcher: 26.6.2
- micromatch: 4.0.4
- p-each-series: 2.2.0
- rimraf: 3.0.2
- slash: 3.0.0
- strip-ansi: 6.0.0
- transitivePeerDependencies:
- - bufferutil
- - canvas
- - supports-color
- - ts-node
- - utf-8-validate
+ '@babel/core': 7.18.9
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-typescript': 7.20.0(@babel/core@7.18.9)
dev: true
- /@jest/environment/26.6.2:
- resolution: {integrity: sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA==}
- engines: {node: '>= 10.14.2'}
+ /@babel/plugin-transform-typescript@7.20.13(@babel/core@7.22.1):
+ resolution: {integrity: sha512-O7I/THxarGcDZxkgWKMUrk7NK1/WbHAg3Xx86gqS6x9MTrNL6AwIluuZ96ms4xeDe6AVx6rjHbWHP7x26EPQBA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@jest/fake-timers': 26.6.2
- '@jest/types': 26.6.2
- '@types/node': 14.17.10
- jest-mock: 26.6.2
+ '@babel/core': 7.22.1
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-typescript': 7.20.0(@babel/core@7.22.1)
dev: true
- /@jest/fake-timers/26.6.2:
- resolution: {integrity: sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA==}
- engines: {node: '>= 10.14.2'}
+ /@babel/plugin-transform-typescript@7.22.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-pyjnCIniO5PNaEuGxT28h0HbMru3qCVrMqVgVOz/krComdIrY9W6FCLBq9NWHY8HDGaUlan+UhmZElDENIfCcw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@jest/types': 26.6.2
- '@sinonjs/fake-timers': 6.0.1
- '@types/node': 14.17.10
- jest-message-util: 26.6.2
- jest-mock: 26.6.2
- jest-util: 26.6.2
+ '@babel/core': 7.18.9
+ '@babel/helper-annotate-as-pure': 7.22.5
+ '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-syntax-typescript': 7.21.4(@babel/core@7.18.9)
dev: true
- /@jest/globals/26.6.2:
- resolution: {integrity: sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA==}
- engines: {node: '>= 10.14.2'}
+ /@babel/plugin-transform-unicode-escapes@7.18.10(@babel/core@7.22.1):
+ resolution: {integrity: sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@jest/environment': 26.6.2
- '@jest/types': 26.6.2
- expect: 26.6.2
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@jest/reporters/26.6.2:
- resolution: {integrity: sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@bcoe/v8-coverage': 0.2.3
- '@jest/console': 26.6.2
- '@jest/test-result': 26.6.2
- '@jest/transform': 26.6.2
- '@jest/types': 26.6.2
- chalk: 4.1.2
- collect-v8-coverage: 1.0.1
- exit: 0.1.2
- glob: 7.1.7
- graceful-fs: 4.2.8
- istanbul-lib-coverage: 3.0.0
- istanbul-lib-instrument: 4.0.3
- istanbul-lib-report: 3.0.0
- istanbul-lib-source-maps: 4.0.0
- istanbul-reports: 3.0.2
- jest-haste-map: 26.6.2
- jest-resolve: 26.6.2
- jest-util: 26.6.2
- jest-worker: 26.6.2
- slash: 3.0.0
- source-map: 0.6.1
- string-length: 4.0.2
- terminal-link: 2.1.1
- v8-to-istanbul: 7.1.2
- optionalDependencies:
- node-notifier: 8.0.2
- transitivePeerDependencies:
- - supports-color
- dev: true
-
- /@jest/source-map/26.6.2:
- resolution: {integrity: sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA==}
- engines: {node: '>= 10.14.2'}
+ /@babel/plugin-transform-unicode-escapes@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- callsites: 3.1.0
- graceful-fs: 4.2.8
- source-map: 0.6.1
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@jest/test-result/26.6.2:
- resolution: {integrity: sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ==}
- engines: {node: '>= 10.14.2'}
+ /@babel/plugin-transform-unicode-escapes@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@jest/console': 26.6.2
- '@jest/types': 26.6.2
- '@types/istanbul-lib-coverage': 2.0.3
- collect-v8-coverage: 1.0.1
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@jest/test-result/27.1.0:
- resolution: {integrity: sha512-Aoz00gpDL528ODLghat3QSy6UBTD5EmmpjrhZZMK/v1Q2/rRRqTGnFxHuEkrD4z/Py96ZdOHxIWkkCKRpmnE1A==}
- engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ /@babel/plugin-transform-unicode-property-regex@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@jest/console': 27.1.0
- '@jest/types': 27.1.0
- '@types/istanbul-lib-coverage': 2.0.3
- collect-v8-coverage: 1.0.1
+ '@babel/core': 7.18.9
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@jest/test-sequencer/26.6.3:
- resolution: {integrity: sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw==}
- engines: {node: '>= 10.14.2'}
+ /@babel/plugin-transform-unicode-property-regex@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@jest/test-result': 26.6.2
- graceful-fs: 4.2.8
- jest-haste-map: 26.6.2
- jest-runner: 26.6.3
- jest-runtime: 26.6.3
- transitivePeerDependencies:
- - bufferutil
- - canvas
- - supports-color
- - ts-node
- - utf-8-validate
+ '@babel/core': 7.23.5
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@jest/transform/26.6.2:
- resolution: {integrity: sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA==}
- engines: {node: '>= 10.14.2'}
+ /@babel/plugin-transform-unicode-regex@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.15.0
- '@jest/types': 26.6.2
- babel-plugin-istanbul: 6.0.0
- chalk: 4.1.2
- convert-source-map: 1.8.0
- fast-json-stable-stringify: 2.1.0
- graceful-fs: 4.2.8
- jest-haste-map: 26.6.2
- jest-regex-util: 26.0.0
- jest-util: 26.6.2
- micromatch: 4.0.4
- pirates: 4.0.1
- slash: 3.0.0
- source-map: 0.6.1
- write-file-atomic: 3.0.3
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@jest/types/26.6.2:
- resolution: {integrity: sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==}
- engines: {node: '>= 10.14.2'}
+ /@babel/plugin-transform-unicode-regex@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@types/istanbul-lib-coverage': 2.0.3
- '@types/istanbul-reports': 3.0.1
- '@types/node': 14.17.10
- '@types/yargs': 15.0.14
- chalk: 4.1.2
+ '@babel/core': 7.18.9
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@jest/types/27.1.0:
- resolution: {integrity: sha512-pRP5cLIzN7I7Vp6mHKRSaZD7YpBTK7hawx5si8trMKqk4+WOdK8NEKOTO2G8PKWD1HbKMVckVB6/XHh/olhf2g==}
- engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ /@babel/plugin-transform-unicode-regex@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@types/istanbul-lib-coverage': 2.0.3
- '@types/istanbul-reports': 3.0.1
- '@types/node': 14.17.10
- '@types/yargs': 16.0.4
- chalk: 4.1.2
+ '@babel/core': 7.23.5
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@linaria/babel-preset/3.0.0-beta.4_@babel+core@7.13.16:
- resolution: {integrity: sha512-Bjsk4VZUQXK3u04MuLlyP/+/tDd7bWeLXYCOnq4US9H2QFRdka97fm6hH34SRinoHm9fSPCHrj9d+KtY8ge2wg==}
+ /@babel/plugin-transform-unicode-sets-regex@7.23.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7'
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/generator': 7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.13.16
- '@babel/template': 7.14.5
- '@linaria/core': 3.0.0-beta.4
- '@linaria/logger': 3.0.0-beta.3
- cosmiconfig: 5.2.1
- source-map: 0.6.1
- stylis: 3.5.4
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.18.9)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@linaria/babel-preset/3.0.0-beta.7:
- resolution: {integrity: sha512-NE5f//T9ywXZA+W+Pw1YjWKdzskUpaV7GVkxXhkxLM2la1+S4xOoZR1rzW77bR1C9GFFwzZTeb8XP85whb2ZqQ==}
+ /@babel/plugin-transform-unicode-sets-regex@7.23.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7'
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/generator': 7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3
- '@babel/template': 7.14.5
- '@linaria/core': 3.0.0-beta.4
- '@linaria/logger': 3.0.0-beta.3
- cosmiconfig: 5.2.1
- source-map: 0.7.3
- stylis: 3.5.4
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.23.5)
+ '@babel/helper-plugin-utils': 7.22.5
dev: true
- /@linaria/babel-preset/3.0.0-beta.7_@babel+core@7.13.16:
- resolution: {integrity: sha512-NE5f//T9ywXZA+W+Pw1YjWKdzskUpaV7GVkxXhkxLM2la1+S4xOoZR1rzW77bR1C9GFFwzZTeb8XP85whb2ZqQ==}
+ /@babel/preset-env@7.19.4(@babel/core@7.22.1):
+ resolution: {integrity: sha512-5QVOTXUdqTCjQuh2GGtdd7YEhoRXBMVGROAtsBeLGIbIz3obCBIfRMT1I3ZKkMgNzwkyCkftDXSSkHxnfVf4qg==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@babel/generator': 7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.13.16
- '@babel/template': 7.14.5
- '@linaria/core': 3.0.0-beta.4
- '@linaria/logger': 3.0.0-beta.3
- cosmiconfig: 5.2.1
- source-map: 0.7.3
- stylis: 3.5.4
+ '@babel/compat-data': 7.19.4
+ '@babel/core': 7.22.1
+ '@babel/helper-compilation-targets': 7.21.5(@babel/core@7.22.1)
+ '@babel/helper-plugin-utils': 7.21.5
+ '@babel/helper-validator-option': 7.21.0
+ '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.18.9(@babel/core@7.22.1)
+ '@babel/plugin-proposal-async-generator-functions': 7.19.1(@babel/core@7.22.1)
+ '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-class-static-block': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-dynamic-import': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-export-namespace-from': 7.18.9(@babel/core@7.22.1)
+ '@babel/plugin-proposal-json-strings': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-logical-assignment-operators': 7.18.9(@babel/core@7.22.1)
+ '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-numeric-separator': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-object-rest-spread': 7.19.4(@babel/core@7.22.1)
+ '@babel/plugin-proposal-optional-catch-binding': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-optional-chaining': 7.18.9(@babel/core@7.22.1)
+ '@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-private-property-in-object': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-unicode-property-regex': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.22.1)
+ '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.22.1)
+ '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.22.1)
+ '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.22.1)
+ '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.22.1)
+ '@babel/plugin-syntax-import-assertions': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.22.1)
+ '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.22.1)
+ '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.22.1)
+ '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.22.1)
+ '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.22.1)
+ '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.22.1)
+ '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.22.1)
+ '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.22.1)
+ '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.22.1)
+ '@babel/plugin-transform-arrow-functions': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-async-to-generator': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-block-scoped-functions': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-block-scoping': 7.19.4(@babel/core@7.22.1)
+ '@babel/plugin-transform-classes': 7.19.0(@babel/core@7.22.1)
+ '@babel/plugin-transform-computed-properties': 7.18.9(@babel/core@7.22.1)
+ '@babel/plugin-transform-destructuring': 7.19.4(@babel/core@7.22.1)
+ '@babel/plugin-transform-dotall-regex': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-duplicate-keys': 7.18.9(@babel/core@7.22.1)
+ '@babel/plugin-transform-exponentiation-operator': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-for-of': 7.18.8(@babel/core@7.22.1)
+ '@babel/plugin-transform-function-name': 7.18.9(@babel/core@7.22.1)
+ '@babel/plugin-transform-literals': 7.18.9(@babel/core@7.22.1)
+ '@babel/plugin-transform-member-expression-literals': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-modules-amd': 7.19.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-modules-commonjs': 7.19.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-modules-systemjs': 7.19.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-modules-umd': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-named-capturing-groups-regex': 7.19.1(@babel/core@7.22.1)
+ '@babel/plugin-transform-new-target': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-object-super': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-parameters': 7.18.8(@babel/core@7.22.1)
+ '@babel/plugin-transform-property-literals': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-regenerator': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-reserved-words': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-shorthand-properties': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-spread': 7.19.0(@babel/core@7.22.1)
+ '@babel/plugin-transform-sticky-regex': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-template-literals': 7.18.9(@babel/core@7.22.1)
+ '@babel/plugin-transform-typeof-symbol': 7.18.9(@babel/core@7.22.1)
+ '@babel/plugin-transform-unicode-escapes': 7.18.10(@babel/core@7.22.1)
+ '@babel/plugin-transform-unicode-regex': 7.18.6(@babel/core@7.22.1)
+ '@babel/preset-modules': 0.1.5(@babel/core@7.22.1)
+ '@babel/types': 7.22.4
+ babel-plugin-polyfill-corejs2: 0.3.3(@babel/core@7.22.1)
+ babel-plugin-polyfill-corejs3: 0.6.0(@babel/core@7.22.1)
+ babel-plugin-polyfill-regenerator: 0.4.1(@babel/core@7.22.1)
+ core-js-compat: 3.26.0
+ semver: 6.3.0
transitivePeerDependencies:
- supports-color
dev: true
- /@linaria/core/3.0.0-beta.4:
- resolution: {integrity: sha512-NzxeMDxRt57nR6tLFZ8xIstp5ld9JQPIyp9+TKtQZhoX3oJuUru+S4vXPr1Gach6VaqKKKT5T6fmJgJl9MMprw==}
- dev: true
-
- /@linaria/esbuild/3.0.0-beta.7:
- resolution: {integrity: sha512-ImgwFz/dEe3ea3s4m3QiZV7ssEv8oQVgeMxju0FrkEDg02mWBS8tvU3WTJy0hjb9mUfR8fqJbOZSV33dTrtcww==}
+ /@babel/preset-env@7.23.5(@babel/core@7.18.9):
+ resolution: {integrity: sha512-0d/uxVD6tFGWXGDSfyMD1p2otoaKmu6+GD+NfAx0tMaH+dxORnp7T9TaVQ6mKyya7iBtCIVxHjWT7MuzzM9z+A==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@linaria/babel-preset': 3.0.0-beta.7
- esbuild: 0.12.21
+ '@babel/compat-data': 7.23.5
+ '@babel/core': 7.18.9
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-validator-option': 7.23.5
+ '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.18.9)
+ '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.18.9)
+ '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.18.9)
+ '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.18.9)
+ '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.18.9)
+ '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.18.9)
+ '@babel/plugin-syntax-import-assertions': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-syntax-import-attributes': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.18.9)
+ '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.18.9)
+ '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.18.9)
+ '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.18.9)
+ '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.18.9)
+ '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.18.9)
+ '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.18.9)
+ '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.18.9)
+ '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.18.9)
+ '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.18.9)
+ '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.18.9)
+ '@babel/plugin-transform-arrow-functions': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-async-generator-functions': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-async-to-generator': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-block-scoped-functions': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-block-scoping': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-class-properties': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-class-static-block': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-classes': 7.23.5(@babel/core@7.18.9)
+ '@babel/plugin-transform-computed-properties': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-destructuring': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-dotall-regex': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-duplicate-keys': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-dynamic-import': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-exponentiation-operator': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-export-namespace-from': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-for-of': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-function-name': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-json-strings': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-literals': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-logical-assignment-operators': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-member-expression-literals': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-modules-amd': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-modules-systemjs': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-modules-umd': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-named-capturing-groups-regex': 7.22.5(@babel/core@7.18.9)
+ '@babel/plugin-transform-new-target': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-nullish-coalescing-operator': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-numeric-separator': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-object-rest-spread': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-object-super': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-optional-catch-binding': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-optional-chaining': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-private-methods': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-private-property-in-object': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-property-literals': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-regenerator': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-reserved-words': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-shorthand-properties': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-spread': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-sticky-regex': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-template-literals': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-typeof-symbol': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-unicode-escapes': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-unicode-property-regex': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-unicode-regex': 7.23.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-unicode-sets-regex': 7.23.3(@babel/core@7.18.9)
+ '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.18.9)
+ babel-plugin-polyfill-corejs2: 0.4.6(@babel/core@7.18.9)
+ babel-plugin-polyfill-corejs3: 0.8.6(@babel/core@7.18.9)
+ babel-plugin-polyfill-regenerator: 0.5.3(@babel/core@7.18.9)
+ core-js-compat: 3.33.3
+ semver: 6.3.1
transitivePeerDependencies:
- supports-color
dev: true
- /@linaria/logger/3.0.0-beta.3:
- resolution: {integrity: sha512-Z2k0RJuA4PffcZcwBN1By8FmcCvcFUe9GHc846B6hNP09zDVhHSFLKJN9NfXJCzJ/9PifOxSUKyOjLtpv3EhGA==}
+ /@babel/preset-env@7.23.5(@babel/core@7.23.5):
+ resolution: {integrity: sha512-0d/uxVD6tFGWXGDSfyMD1p2otoaKmu6+GD+NfAx0tMaH+dxORnp7T9TaVQ6mKyya7iBtCIVxHjWT7MuzzM9z+A==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- debug: 4.3.2
+ '@babel/compat-data': 7.23.5
+ '@babel/core': 7.23.5
+ '@babel/helper-compilation-targets': 7.22.15
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/helper-validator-option': 7.23.5
+ '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.23.5)
+ '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.23.5)
+ '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.23.5)
+ '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.23.5)
+ '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.23.5)
+ '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.23.5)
+ '@babel/plugin-syntax-import-assertions': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-syntax-import-attributes': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.23.5)
+ '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.23.5)
+ '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.23.5)
+ '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.23.5)
+ '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.23.5)
+ '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.23.5)
+ '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.23.5)
+ '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.23.5)
+ '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.23.5)
+ '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.5)
+ '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.23.5)
+ '@babel/plugin-transform-arrow-functions': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-async-generator-functions': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-async-to-generator': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-block-scoped-functions': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-block-scoping': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-class-properties': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-class-static-block': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-classes': 7.23.5(@babel/core@7.23.5)
+ '@babel/plugin-transform-computed-properties': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-destructuring': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-dotall-regex': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-duplicate-keys': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-dynamic-import': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-exponentiation-operator': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-export-namespace-from': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-for-of': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-function-name': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-json-strings': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-literals': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-logical-assignment-operators': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-member-expression-literals': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-modules-amd': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-modules-systemjs': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-modules-umd': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-named-capturing-groups-regex': 7.22.5(@babel/core@7.23.5)
+ '@babel/plugin-transform-new-target': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-nullish-coalescing-operator': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-numeric-separator': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-object-rest-spread': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-object-super': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-optional-catch-binding': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-optional-chaining': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-parameters': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-private-methods': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-private-property-in-object': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-property-literals': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-regenerator': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-reserved-words': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-shorthand-properties': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-spread': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-sticky-regex': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-template-literals': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-typeof-symbol': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-unicode-escapes': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-unicode-property-regex': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-unicode-regex': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-unicode-sets-regex': 7.23.3(@babel/core@7.23.5)
+ '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.23.5)
+ babel-plugin-polyfill-corejs2: 0.4.6(@babel/core@7.23.5)
+ babel-plugin-polyfill-corejs3: 0.8.6(@babel/core@7.23.5)
+ babel-plugin-polyfill-regenerator: 0.5.3(@babel/core@7.23.5)
+ core-js-compat: 3.33.3
+ semver: 6.3.1
transitivePeerDependencies:
- supports-color
dev: true
- /@linaria/preeval/3.0.0-beta.7:
- resolution: {integrity: sha512-fTFgxtjBGKh9P2rsqhBnHTqFo1d8VzznmXKpILM0ClAclVn+FB+KJl+nWyEbQ8nT9/ceRoTwbdZHZ/M3DFHG+w==}
+ /@babel/preset-modules@0.1.5(@babel/core@7.22.1):
+ resolution: {integrity: sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==}
peerDependencies:
- '@babel/core': '>=7'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@linaria/babel-preset': 3.0.0-beta.7
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/plugin-proposal-unicode-property-regex': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-dotall-regex': 7.23.3(@babel/core@7.22.1)
+ '@babel/types': 7.23.5
+ esutils: 2.0.3
dev: true
- /@linaria/react/3.0.0-beta.4:
- resolution: {integrity: sha512-RSlFO3W+77PCtFnDFKVdITLUdBude+zICtumticDU76l5Xc5i0lEUXPDvYjD7s9+uk70K4GSNvgh7bqhkGgxKA==}
+ /@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.18.9):
+ resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==}
peerDependencies:
- react: '>=16'
+ '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0
dependencies:
- '@emotion/is-prop-valid': 0.8.8
- '@linaria/core': 3.0.0-beta.4
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/types': 7.23.5
+ esutils: 2.0.3
dev: true
- /@linaria/rollup/3.0.0-beta.4_@babel+core@7.13.16:
- resolution: {integrity: sha512-L24jxXf3SX+VTS/Dwx6YLblKekykgzdtEwcf97yxSix7wHaI1XELs2t1+QoWGJ6PURP6EY5Bv0qaN2pYR3VoGQ==}
+ /@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.23.5):
+ resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==}
peerDependencies:
- '@babel/core': '>=7'
+ '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0
dependencies:
- '@babel/core': 7.13.16
- '@linaria/babel-preset': 3.0.0-beta.7_@babel+core@7.13.16
- '@rollup/pluginutils': 4.1.1
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.23.5
+ '@babel/helper-plugin-utils': 7.22.5
+ '@babel/types': 7.23.5
+ esutils: 2.0.3
dev: true
- /@linaria/shaker/3.0.0-beta.7:
- resolution: {integrity: sha512-fWDbbKcS8EiAnoNhTQa+2Or7QyR3Ofyqjtcrb1aeLN2ecVI2u7B5jAZgVPg9euTnhQ2ieUU3G9hh1INkoGKS/A==}
+ /@babel/preset-react@7.22.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-lxDz1mnZ9polqClBCVBjIVUypoB4qV3/tZUDb/IlYbW1kiiLaXaX+bInbRjl+lNQ/iUZraQ3+S8daEmoELMWug==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/generator': 7.15.0
- '@babel/plugin-transform-runtime': 7.15.0
- '@babel/plugin-transform-template-literals': 7.14.5
- '@babel/preset-env': 7.15.0
- '@linaria/babel-preset': 3.0.0-beta.7
- '@linaria/logger': 3.0.0-beta.3
- '@linaria/preeval': 3.0.0-beta.7
- babel-plugin-transform-react-remove-prop-types: 0.4.24
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.21.5
+ '@babel/helper-validator-option': 7.21.0
+ '@babel/plugin-transform-react-display-name': 7.18.6(@babel/core@7.18.9)
+ '@babel/plugin-transform-react-jsx': 7.22.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-react-jsx-development': 7.18.6(@babel/core@7.18.9)
+ '@babel/plugin-transform-react-pure-annotations': 7.18.6(@babel/core@7.18.9)
dev: true
- /@linaria/webpack-loader/3.0.0-beta.4_@babel+core@7.13.16:
- resolution: {integrity: sha512-v2Z4QgkBwddKwS/M0ISkLKQBPBNfoyw4AGCBFsjFO5ov/icNrmIy8BKUFCi/yqWu8mk/krmvzPyOBSp4DpFsIA==}
+ /@babel/preset-typescript@7.18.6(@babel/core@7.18.9):
+ resolution: {integrity: sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
dependencies:
- '@linaria/webpack4-loader': 3.0.0-beta.7_@babel+core@7.13.16
- '@linaria/webpack5-loader': 3.0.0-beta.7_@babel+core@7.13.16
- transitivePeerDependencies:
- - '@babel/core'
- - supports-color
- - webpack
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.19.0
+ '@babel/helper-validator-option': 7.18.6
+ '@babel/plugin-transform-typescript': 7.20.13(@babel/core@7.18.9)
dev: true
- /@linaria/webpack4-loader/3.0.0-beta.7_@babel+core@7.13.16:
- resolution: {integrity: sha512-B2c5vr9b8igcILM/ZcxE9Vu0J2w7NS9xERTvGD7Kp4TdLnFRpALMTJgYqlk3Gxq4T7RlAEi1vu8kHx65mXqA6g==}
+ /@babel/preset-typescript@7.18.6(@babel/core@7.22.1):
+ resolution: {integrity: sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@linaria/babel-preset': 3.0.0-beta.7_@babel+core@7.13.16
- '@linaria/logger': 3.0.0-beta.3
- cosmiconfig: 5.2.1
- enhanced-resolve: 4.5.0
- find-yarn-workspace-root: 1.2.1
- loader-utils: 1.4.0
- mkdirp: 0.5.5
- normalize-path: 3.0.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.22.1
+ '@babel/helper-plugin-utils': 7.19.0
+ '@babel/helper-validator-option': 7.18.6
+ '@babel/plugin-transform-typescript': 7.20.13(@babel/core@7.22.1)
dev: true
- /@linaria/webpack5-loader/3.0.0-beta.7_@babel+core@7.13.16:
- resolution: {integrity: sha512-s2C44ml1fjDFjEJS1PFXjgCklOd3KWiG4Z3l+nUuCidncn9abnv18rDkiukUcKGwwAGJ3NhgfhU9SwXPIYFfMw==}
+ /@babel/preset-typescript@7.21.5(@babel/core@7.18.9):
+ resolution: {integrity: sha512-iqe3sETat5EOrORXiQ6rWfoOg2y68Cs75B9wNxdPW4kixJxh7aXQE1KPdWLDniC24T/6dSnguF33W9j/ZZQcmA==}
+ engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': '>=7'
- webpack: '>=5'
+ '@babel/core': ^7.0.0-0
dependencies:
- '@babel/core': 7.13.16
- '@linaria/babel-preset': 3.0.0-beta.7_@babel+core@7.13.16
- '@linaria/logger': 3.0.0-beta.3
- cosmiconfig: 5.2.1
- enhanced-resolve: 5.8.2
- find-yarn-workspace-root: 1.2.1
- loader-utils: 2.0.0
- mkdirp: 0.5.5
- normalize-path: 3.0.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/core': 7.18.9
+ '@babel/helper-plugin-utils': 7.21.5
+ '@babel/helper-validator-option': 7.21.0
+ '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-modules-commonjs': 7.21.5(@babel/core@7.18.9)
+ '@babel/plugin-transform-typescript': 7.22.3(@babel/core@7.18.9)
dev: true
- /@mdn/browser-compat-data/3.3.14:
- resolution: {integrity: sha512-n2RC9d6XatVbWFdHLimzzUJxJ1KY8LdjqrW6YvGPiRmsHkhOUx74/Ct10x5Yo7bC/Jvqx7cDEW8IMPv/+vwEzA==}
+ /@babel/regjsgen@0.8.0:
+ resolution: {integrity: sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==}
dev: true
- /@mdx-js/loader/1.6.22:
- resolution: {integrity: sha512-9CjGwy595NaxAYp0hF9B/A0lH6C8Rms97e2JS9d3jVUtILn6pT5i5IV965ra3lIWc7Rs1GG1tBdVF7dCowYe6Q==}
+ /@babel/runtime@7.18.9:
+ resolution: {integrity: sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@mdx-js/mdx': 1.6.22
- '@mdx-js/react': 1.6.22
- loader-utils: 2.0.0
- transitivePeerDependencies:
- - react
- - supports-color
+ regenerator-runtime: 0.13.10
dev: true
- /@mdx-js/mdx/1.6.22:
- resolution: {integrity: sha512-AMxuLxPz2j5/6TpF/XSdKpQP1NlG0z11dFOlq+2IP/lSgl11GY8ji6S/rgsViN/L0BDvHvUMruRb7ub+24LUYA==}
+ /@babel/runtime@7.19.4:
+ resolution: {integrity: sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@babel/core': 7.12.9
- '@babel/plugin-syntax-jsx': 7.12.1_@babel+core@7.12.9
- '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.12.9
- '@mdx-js/util': 1.6.22
- babel-plugin-apply-mdx-type-prop: 1.6.22_@babel+core@7.12.9
- babel-plugin-extract-import-names: 1.6.22
- camelcase-css: 2.0.1
- detab: 2.0.4
- hast-util-raw: 6.0.1
- lodash.uniq: 4.5.0
- mdast-util-to-hast: 10.0.1
- remark-footnotes: 2.0.0
- remark-mdx: 1.6.22
- remark-parse: 8.0.3
- remark-squeeze-paragraphs: 4.0.0
- style-to-object: 0.3.0
- unified: 9.2.0
- unist-builder: 2.0.3
- unist-util-visit: 2.0.3
- transitivePeerDependencies:
- - supports-color
- dev: true
-
- /@mdx-js/react/1.6.22:
- resolution: {integrity: sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==}
- peerDependencies:
- react: ^16.13.1 || ^17.0.0
- dev: true
-
- /@mdx-js/util/1.6.22:
- resolution: {integrity: sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA==}
- dev: true
+ regenerator-runtime: 0.13.10
- /@microsoft/api-extractor-model/7.12.1:
- resolution: {integrity: sha512-Hw+kYfUb1gt6xPWGFW8APtLVWeNEWz4JE6PbLkSHw/j+G1hAaStzgxhBx3GOAWM/G0SCDGVJOpd5YheVOyu/KQ==}
+ /@babel/runtime@7.21.0:
+ resolution: {integrity: sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@microsoft/tsdoc': 0.12.24
- '@rushstack/node-core-library': 3.35.2
+ regenerator-runtime: 0.13.11
dev: true
- /@microsoft/api-extractor/7.13.0:
- resolution: {integrity: sha512-T+14VIhB91oJIett5AZ02VWYmz/01VHFWkcAOWiErIQ8AiFhJZoGqTjGxoi8ZpEEBuAj2EGVYojORwLc/+aiDQ==}
- hasBin: true
+ /@babel/runtime@7.23.6:
+ resolution: {integrity: sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@microsoft/api-extractor-model': 7.12.1
- '@microsoft/tsdoc': 0.12.24
- '@rushstack/node-core-library': 3.35.2
- '@rushstack/rig-package': 0.2.9
- '@rushstack/ts-command-line': 4.7.8
- colors: 1.2.5
- lodash: 4.17.20
- resolve: 1.17.0
- semver: 7.3.4
- source-map: 0.6.1
- typescript: 4.1.3
- dev: true
-
- /@microsoft/tsdoc/0.12.24:
- resolution: {integrity: sha512-Mfmij13RUTmHEMi9vRUhMXD7rnGR2VvxeNYtaGtaJ4redwwjT4UXYJ+nzmVJF7hhd4pn/Fx5sncDKxMVFJSWPg==}
+ regenerator-runtime: 0.14.0
dev: true
- /@mrmlnc/readdir-enhanced/2.2.1:
- resolution: {integrity: sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==}
- engines: {node: '>=4'}
+ /@babel/template@7.18.10:
+ resolution: {integrity: sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==}
+ engines: {node: '>=6.9.0'}
dependencies:
- call-me-maybe: 1.0.1
- glob-to-regexp: 0.3.0
+ '@babel/code-frame': 7.18.6
+ '@babel/parser': 7.19.6
+ '@babel/types': 7.22.4
dev: true
- /@nodelib/fs.scandir/2.1.4:
- resolution: {integrity: sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==}
- engines: {node: '>= 8'}
+ /@babel/template@7.20.7:
+ resolution: {integrity: sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@nodelib/fs.stat': 2.0.4
- run-parallel: 1.2.0
- dev: true
+ '@babel/code-frame': 7.23.5
+ '@babel/parser': 7.23.5
+ '@babel/types': 7.23.5
- /@nodelib/fs.scandir/2.1.5:
- resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
- engines: {node: '>= 8'}
+ /@babel/template@7.22.15:
+ resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@nodelib/fs.stat': 2.0.5
- run-parallel: 1.2.0
- dev: true
-
- /@nodelib/fs.stat/1.1.3:
- resolution: {integrity: sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==}
- engines: {node: '>= 6'}
- dev: true
-
- /@nodelib/fs.stat/2.0.4:
- resolution: {integrity: sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==}
- engines: {node: '>= 8'}
- dev: true
+ '@babel/code-frame': 7.23.5
+ '@babel/parser': 7.23.5
+ '@babel/types': 7.23.5
- /@nodelib/fs.stat/2.0.5:
- resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
- engines: {node: '>= 8'}
- dev: true
-
- /@nodelib/fs.walk/1.2.6:
- resolution: {integrity: sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==}
- engines: {node: '>= 8'}
+ /@babel/traverse@7.19.6:
+ resolution: {integrity: sha512-6l5HrUCzFM04mfbG09AagtYyR2P0B71B1wN7PfSPiksDPz2k5H9CBC1tcZpz2M8OxbKTPccByoOJ22rUKbpmQQ==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@nodelib/fs.scandir': 2.1.4
- fastq: 1.11.0
+ '@babel/code-frame': 7.18.6
+ '@babel/generator': 7.22.3
+ '@babel/helper-environment-visitor': 7.18.9
+ '@babel/helper-function-name': 7.19.0
+ '@babel/helper-hoist-variables': 7.18.6
+ '@babel/helper-split-export-declaration': 7.18.6
+ '@babel/parser': 7.19.6
+ '@babel/types': 7.22.4
+ debug: 4.3.4
+ globals: 11.12.0
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /@nodelib/fs.walk/1.2.8:
- resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
- engines: {node: '>= 8'}
+ /@babel/traverse@7.21.5:
+ resolution: {integrity: sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@nodelib/fs.scandir': 2.1.5
- fastq: 1.12.0
- dev: true
+ '@babel/code-frame': 7.23.5
+ '@babel/generator': 7.23.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-hoist-variables': 7.22.5
+ '@babel/helper-split-export-declaration': 7.22.6
+ '@babel/parser': 7.23.5
+ '@babel/types': 7.23.5
+ debug: 4.3.4
+ globals: 11.12.0
+ transitivePeerDependencies:
+ - supports-color
- /@npmcli/move-file/1.1.2:
- resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==}
- engines: {node: '>=10'}
+ /@babel/traverse@7.23.5:
+ resolution: {integrity: sha512-czx7Xy5a6sapWWRx61m1Ke1Ra4vczu1mCTtJam5zRTBOonfdJ+S/B6HYmGYu3fJtr8GGET3si6IhgWVBhJ/m8w==}
+ engines: {node: '>=6.9.0'}
dependencies:
- mkdirp: 1.0.4
- rimraf: 3.0.2
- dev: true
-
- /@polka/url/1.0.0-next.17:
- resolution: {integrity: sha512-0p1rCgM3LLbAdwBnc7gqgnvjHg9KpbhcSphergHShlkWz8EdPawoMJ3/VbezI0mGC5eKCDzMaPgF9Yca6cKvrg==}
- dev: true
-
- /@popperjs/core/2.9.3:
- resolution: {integrity: sha512-xDu17cEfh7Kid/d95kB6tZsLOmSWKCZKtprnhVepjsSaCij+lM3mItSJDuuHDMbCWTh8Ejmebwb+KONcCJ0eXQ==}
- dev: true
+ '@babel/code-frame': 7.23.5
+ '@babel/generator': 7.23.5
+ '@babel/helper-environment-visitor': 7.22.20
+ '@babel/helper-function-name': 7.23.0
+ '@babel/helper-hoist-variables': 7.22.5
+ '@babel/helper-split-export-declaration': 7.22.6
+ '@babel/parser': 7.23.5
+ '@babel/types': 7.23.5
+ debug: 4.3.4
+ globals: 11.12.0
+ transitivePeerDependencies:
+ - supports-color
- /@preact/async-loader/3.0.1_preact@10.5.14:
- resolution: {integrity: sha512-BoUN24hxEfAQYnWjliAmkZLuliv+ONQi7AWn+/+VOJHTIHmbFiXrvmSxITf7PDkKiK0a5xy4OErZtVVLlk96Tg==}
- engines: {node: '>=8'}
- peerDependencies:
- preact: '>= 10.0.0'
+ /@babel/types@7.19.4:
+ resolution: {integrity: sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw==}
+ engines: {node: '>=6.9.0'}
dependencies:
- kleur: 4.1.4
- loader-utils: 2.0.0
- preact: 10.5.14
- dev: true
-
- /@prefresh/babel-plugin/0.4.1:
- resolution: {integrity: sha512-gj3ekiYtHlZNz0zFI1z6a9mcYX80Qacw84+2++7V1skvO7kQoV2ux56r8bJkTBbKMVxwAgaYrxxIdUCYlclE7Q==}
+ '@babel/helper-string-parser': 7.19.4
+ '@babel/helper-validator-identifier': 7.19.1
+ to-fast-properties: 2.0.0
dev: true
- /@prefresh/core/1.3.2_preact@10.5.14:
- resolution: {integrity: sha512-Iv+uI698KDgWsrKpLvOgN3hmAMyvhVgn09mcnhZ98BUNdg/qrxE7tcUf5yFCImkgqED5/Dcn8G5hFy4IikEDvg==}
- peerDependencies:
- preact: ^10.0.0
+ /@babel/types@7.21.5:
+ resolution: {integrity: sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==}
+ engines: {node: '>=6.9.0'}
dependencies:
- preact: 10.5.14
- dev: true
+ '@babel/helper-string-parser': 7.23.4
+ '@babel/helper-validator-identifier': 7.22.20
+ to-fast-properties: 2.0.0
- /@prefresh/utils/1.1.1:
- resolution: {integrity: sha512-MUhT5m2XNN5NsZl4GnpuvlzLo6VSTa/+wBfBd3fiWUvHGhv0GF9hnA1pd//v0uJaKwUnVRQ1hYElxCV7DtYsCQ==}
+ /@babel/types@7.22.4:
+ resolution: {integrity: sha512-Tx9x3UBHTTsMSW85WB2kphxYQVvrZ/t1FxD88IpSgIjiUJlCm9z+xWIDwyo1vffTwSqteqyznB8ZE9vYYk16zA==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/helper-string-parser': 7.23.4
+ '@babel/helper-validator-identifier': 7.22.20
+ to-fast-properties: 2.0.0
dev: true
- /@prefresh/webpack/3.3.2_b4d84c08f02729896cbfdece19209372:
- resolution: {integrity: sha512-1cX0t5G7IXWO2164sl2O32G02BzDl6C4UUZWfDb0x1CQM1g3It9PSLWd+rIlHfSg4MEU9YHM8e6/OK8uavRJhA==}
- peerDependencies:
- '@prefresh/babel-plugin': ^0.4.0
- preact: ^10.4.0
- webpack: ^4.0.0 || ^5.0.0
+ /@babel/types@7.23.5:
+ resolution: {integrity: sha512-ON5kSOJwVO6xXVRTvOI0eOnWe7VdUcIpsovGo9U/Br4Ie4UVFQTboO2cYnDhAGU6Fp+UxSiT+pMft0SMHfuq6w==}
+ engines: {node: '>=6.9.0'}
dependencies:
- '@prefresh/babel-plugin': 0.4.1
- '@prefresh/core': 1.3.2_preact@10.5.14
- '@prefresh/utils': 1.1.1
- preact: 10.5.14
- webpack: 4.46.0
+ '@babel/helper-string-parser': 7.23.4
+ '@babel/helper-validator-identifier': 7.22.20
+ to-fast-properties: 2.0.0
+
+ /@bcoe/v8-coverage@0.2.3:
+ resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
dev: true
- /@reach/router/1.3.4:
- resolution: {integrity: sha512-+mtn9wjlB9NN2CNnnC/BRYtwdKBfSyyasPYraNAyvaV1occr/5NnB4CVzjEZipNHwYebQwcndGUmpFzxAUoqSA==}
- peerDependencies:
- react: 15.x || 16.x || 16.4.0-alpha.0911da3
- react-dom: 15.x || 16.x || 16.4.0-alpha.0911da3
- dependencies:
- create-react-context: 0.3.0_prop-types@15.7.2
- invariant: 2.2.4
- prop-types: 15.7.2
- react-lifecycles-compat: 3.0.4
+ /@creativebulma/bulma-tooltip@1.2.0:
+ resolution: {integrity: sha512-ooImbeXEBxf77cttbzA7X5rC5aAWm9UsXIGViFOnsqB+6M944GkB28S5R4UWRqjFd2iW4zGEkEifAU+q43pt2w==}
dev: true
- /@reach/router/1.3.4_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-+mtn9wjlB9NN2CNnnC/BRYtwdKBfSyyasPYraNAyvaV1occr/5NnB4CVzjEZipNHwYebQwcndGUmpFzxAUoqSA==}
- peerDependencies:
- react: 15.x || 16.x || 16.4.0-alpha.0911da3
- react-dom: 15.x || 16.x || 16.4.0-alpha.0911da3
+ /@cspotcode/source-map-support@0.8.1:
+ resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
+ engines: {node: '>=12'}
dependencies:
- create-react-context: 0.3.0_prop-types@15.7.2+react@16.14.0
- invariant: 2.2.4
- prop-types: 15.7.2
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- react-lifecycles-compat: 3.0.4
+ '@jridgewell/trace-mapping': 0.3.9
dev: true
- /@rollup/plugin-alias/3.1.5_rollup@2.56.2:
- resolution: {integrity: sha512-yzUaSvCC/LJPbl9rnzX3HN7vy0tq7EzHoEiQl1ofh4n5r2Rd5bj/+zcJgaGA76xbw95/JjWQyvHg9rOJp2y0oQ==}
- engines: {node: '>=8.0.0'}
- peerDependencies:
- rollup: ^1.20.0||^2.0.0
- dependencies:
- rollup: 2.56.2
- slash: 3.0.0
+ /@devicefarmer/adbkit-logcat@2.1.3:
+ resolution: {integrity: sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw==}
+ engines: {node: '>= 4'}
dev: true
- /@rollup/plugin-babel/5.3.0_@babel+core@7.15.0+rollup@2.56.2:
- resolution: {integrity: sha512-9uIC8HZOnVLrLHxayq/PTzw+uS25E14KPUBh5ktF+18Mjo5yK0ToMMx6epY0uEgkjwJw0aBW4x2horYXh8juWw==}
- engines: {node: '>= 10.0.0'}
- peerDependencies:
- '@babel/core': ^7.0.0
- '@types/babel__core': ^7.1.9
- rollup: ^1.20.0||^2.0.0
- peerDependenciesMeta:
- '@types/babel__core':
- optional: true
- dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-module-imports': 7.14.5
- '@rollup/pluginutils': 3.1.0_rollup@2.56.2
- rollup: 2.56.2
+ /@devicefarmer/adbkit-monkey@1.2.1:
+ resolution: {integrity: sha512-ZzZY/b66W2Jd6NHbAhLyDWOEIBWC11VizGFk7Wx7M61JZRz7HR9Cq5P+65RKWUU7u6wgsE8Lmh9nE4Mz+U2eTg==}
+ engines: {node: '>= 0.10.4'}
dev: true
- /@rollup/plugin-commonjs/17.0.0_rollup@2.37.1:
- resolution: {integrity: sha512-/omBIJG1nHQc+bgkYDuLpb/V08QyutP9amOrJRUSlYJZP+b/68gM//D8sxJe3Yry2QnYIr3QjR3x4AlxJEN3GA==}
- engines: {node: '>= 8.0.0'}
- peerDependencies:
- rollup: ^2.30.0
+ /@devicefarmer/adbkit@3.2.3:
+ resolution: {integrity: sha512-wK9rVrabs4QU0oK8Jnwi+HRBEm+s1x/o63kgthUe0y7K1bfcYmgLuQf41/adsj/5enddlSxzkJavl2EwOu+r1g==}
+ engines: {node: '>= 0.10.4'}
+ hasBin: true
dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.37.1
- commondir: 1.0.1
- estree-walker: 2.0.2
- glob: 7.1.6
- is-reference: 1.2.1
- magic-string: 0.25.7
- resolve: 1.19.0
- rollup: 2.37.1
+ '@devicefarmer/adbkit-logcat': 2.1.3
+ '@devicefarmer/adbkit-monkey': 1.2.1
+ bluebird: 3.7.2
+ commander: 9.5.0
+ debug: 4.3.4
+ node-forge: 1.3.1
+ split: 1.0.1
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /@rollup/plugin-commonjs/17.1.0_rollup@2.37.1:
- resolution: {integrity: sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew==}
- engines: {node: '>= 8.0.0'}
- peerDependencies:
- rollup: ^2.30.0
+ /@emotion/is-prop-valid@0.8.8:
+ resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==}
dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.37.1
- commondir: 1.0.1
- estree-walker: 2.0.2
- glob: 7.1.6
- is-reference: 1.2.1
- magic-string: 0.25.7
- resolve: 1.20.0
- rollup: 2.37.1
+ '@emotion/memoize': 0.7.4
dev: true
- /@rollup/plugin-commonjs/17.1.0_rollup@2.43.0:
- resolution: {integrity: sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew==}
- engines: {node: '>= 8.0.0'}
- peerDependencies:
- rollup: ^2.30.0
+ /@emotion/is-prop-valid@1.2.1:
+ resolution: {integrity: sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==}
dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.43.0
- commondir: 1.0.1
- estree-walker: 2.0.2
- glob: 7.1.6
- is-reference: 1.2.1
- magic-string: 0.25.7
- resolve: 1.20.0
- rollup: 2.43.0
+ '@emotion/memoize': 0.8.1
dev: true
- /@rollup/plugin-commonjs/17.1.0_rollup@2.56.2:
- resolution: {integrity: sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew==}
- engines: {node: '>= 8.0.0'}
- peerDependencies:
- rollup: ^2.30.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.56.2
- commondir: 1.0.1
- estree-walker: 2.0.2
- glob: 7.1.6
- is-reference: 1.2.1
- magic-string: 0.25.7
- resolve: 1.20.0
- rollup: 2.56.2
+ /@emotion/memoize@0.7.4:
+ resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==}
dev: true
- /@rollup/plugin-image/2.1.0_rollup@2.56.2:
- resolution: {integrity: sha512-IiRhjv65A4Rb/9R+gTP2JdIciumkc8c+3xFoUfw3PUkX77SqqzvJ028AfX856E3ZdExMrqY9C9ZVXN46w6rh9A==}
- engines: {node: '>= 8.0.0'}
- peerDependencies:
- rollup: ^1.20.0 || ^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.56.2
- mini-svg-data-uri: 1.3.3
- rollup: 2.56.2
+ /@emotion/memoize@0.8.1:
+ resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==}
dev: true
- /@rollup/plugin-json/4.1.0_rollup@2.37.1:
- resolution: {integrity: sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==}
- peerDependencies:
- rollup: ^1.20.0 || ^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.37.1
- rollup: 2.37.1
+ /@esbuild/android-arm64@0.19.9:
+ resolution: {integrity: sha512-q4cR+6ZD0938R19MyEW3jEsMzbb/1rulLXiNAJQADD/XYp7pT+rOS5JGxvpRW8dFDEfjW4wLgC/3FXIw4zYglQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/plugin-json/4.1.0_rollup@2.43.0:
- resolution: {integrity: sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==}
- peerDependencies:
- rollup: ^1.20.0 || ^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.43.0
- rollup: 2.43.0
+ /@esbuild/android-arm@0.19.9:
+ resolution: {integrity: sha512-jkYjjq7SdsWuNI6b5quymW0oC83NN5FdRPuCbs9HZ02mfVdAP8B8eeqLSYU3gb6OJEaY5CQabtTFbqBf26H3GA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/plugin-json/4.1.0_rollup@2.56.2:
- resolution: {integrity: sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==}
- peerDependencies:
- rollup: ^1.20.0 || ^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.56.2
- rollup: 2.56.2
+ /@esbuild/android-x64@0.19.9:
+ resolution: {integrity: sha512-KOqoPntWAH6ZxDwx1D6mRntIgZh9KodzgNOy5Ebt9ghzffOk9X2c1sPwtM9P+0eXbefnDhqYfkh5PLP5ULtWFA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/plugin-node-resolve/11.1.0_rollup@2.37.1:
- resolution: {integrity: sha512-ouBBppRdWJKCllDXGzJ7ZIkYbaq+5TmyP0smt1vdJCFfoZhLi31vhpmjLhyo8lreHf4RoeSNllaWrvSqHpHRog==}
- engines: {node: '>= 10.0.0'}
- peerDependencies:
- rollup: ^1.20.0||^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.37.1
- '@types/resolve': 1.17.1
- builtin-modules: 3.2.0
- deepmerge: 4.2.2
- is-module: 1.0.0
- resolve: 1.19.0
- rollup: 2.37.1
+ /@esbuild/darwin-arm64@0.19.9:
+ resolution: {integrity: sha512-KBJ9S0AFyLVx2E5D8W0vExqRW01WqRtczUZ8NRu+Pi+87opZn5tL4Y0xT0mA4FtHctd0ZgwNoN639fUUGlNIWw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/plugin-node-resolve/11.2.0_rollup@2.37.1:
- resolution: {integrity: sha512-qHjNIKYt5pCcn+5RUBQxK8krhRvf1HnyVgUCcFFcweDS7fhkOLZeYh0mhHK6Ery8/bb9tvN/ubPzmfF0qjDCTA==}
- engines: {node: '>= 10.0.0'}
- peerDependencies:
- rollup: ^1.20.0||^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.37.1
- '@types/resolve': 1.17.1
- builtin-modules: 3.2.0
- deepmerge: 4.2.2
- is-module: 1.0.0
- resolve: 1.20.0
- rollup: 2.37.1
+ /@esbuild/darwin-x64@0.19.9:
+ resolution: {integrity: sha512-vE0VotmNTQaTdX0Q9dOHmMTao6ObjyPm58CHZr1UK7qpNleQyxlFlNCaHsHx6Uqv86VgPmR4o2wdNq3dP1qyDQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/plugin-node-resolve/11.2.0_rollup@2.43.0:
- resolution: {integrity: sha512-qHjNIKYt5pCcn+5RUBQxK8krhRvf1HnyVgUCcFFcweDS7fhkOLZeYh0mhHK6Ery8/bb9tvN/ubPzmfF0qjDCTA==}
- engines: {node: '>= 10.0.0'}
- peerDependencies:
- rollup: ^1.20.0||^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.43.0
- '@types/resolve': 1.17.1
- builtin-modules: 3.2.0
- deepmerge: 4.2.2
- is-module: 1.0.0
- resolve: 1.20.0
- rollup: 2.43.0
+ /@esbuild/freebsd-arm64@0.19.9:
+ resolution: {integrity: sha512-uFQyd/o1IjiEk3rUHSwUKkqZwqdvuD8GevWF065eqgYfexcVkxh+IJgwTaGZVu59XczZGcN/YMh9uF1fWD8j1g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/plugin-node-resolve/11.2.1_rollup@2.56.2:
- resolution: {integrity: sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==}
- engines: {node: '>= 10.0.0'}
- peerDependencies:
- rollup: ^1.20.0||^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.56.2
- '@types/resolve': 1.17.1
- builtin-modules: 3.2.0
- deepmerge: 4.2.2
- is-module: 1.0.0
- resolve: 1.20.0
- rollup: 2.56.2
+ /@esbuild/freebsd-x64@0.19.9:
+ resolution: {integrity: sha512-WMLgWAtkdTbTu1AWacY7uoj/YtHthgqrqhf1OaEWnZb7PQgpt8eaA/F3LkV0E6K/Lc0cUr/uaVP/49iE4M4asA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/plugin-replace/2.3.4_rollup@2.37.1:
- resolution: {integrity: sha512-waBhMzyAtjCL1GwZes2jaE9MjuQ/DQF2BatH3fRivUF3z0JBFrU0U6iBNC/4WR+2rLKhaAhPWDNPYp4mI6RqdQ==}
- peerDependencies:
- rollup: ^1.20.0 || ^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.37.1
- magic-string: 0.25.7
- rollup: 2.37.1
+ /@esbuild/linux-arm64@0.19.9:
+ resolution: {integrity: sha512-PiPblfe1BjK7WDAKR1Cr9O7VVPqVNpwFcPWgfn4xu0eMemzRp442hXyzF/fSwgrufI66FpHOEJk0yYdPInsmyQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/plugin-replace/2.4.2_rollup@2.43.0:
- resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==}
- peerDependencies:
- rollup: ^1.20.0 || ^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.43.0
- magic-string: 0.25.7
- rollup: 2.43.0
+ /@esbuild/linux-arm@0.19.9:
+ resolution: {integrity: sha512-C/ChPohUYoyUaqn1h17m/6yt6OB14hbXvT8EgM1ZWaiiTYz7nWZR0SYmMnB5BzQA4GXl3BgBO1l8MYqL/He3qw==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/plugin-replace/2.4.2_rollup@2.56.2:
- resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==}
- peerDependencies:
- rollup: ^1.20.0 || ^2.0.0
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.56.2
- magic-string: 0.25.7
- rollup: 2.56.2
+ /@esbuild/linux-ia32@0.19.9:
+ resolution: {integrity: sha512-f37i/0zE0MjDxijkPSQw1CO/7C27Eojqb+r3BbHVxMLkj8GCa78TrBZzvPyA/FNLUMzP3eyHCVkAopkKVja+6Q==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/pluginutils/3.1.0_rollup@2.37.1:
- resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
- engines: {node: '>= 8.0.0'}
- peerDependencies:
- rollup: ^1.20.0||^2.0.0
- dependencies:
- '@types/estree': 0.0.39
- estree-walker: 1.0.1
- picomatch: 2.2.2
- rollup: 2.37.1
+ /@esbuild/linux-loong64@0.19.9:
+ resolution: {integrity: sha512-t6mN147pUIf3t6wUt3FeumoOTPfmv9Cc6DQlsVBpB7eCpLOqQDyWBP1ymXn1lDw4fNUSb/gBcKAmvTP49oIkaA==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/pluginutils/3.1.0_rollup@2.43.0:
- resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
- engines: {node: '>= 8.0.0'}
- peerDependencies:
- rollup: ^1.20.0||^2.0.0
- dependencies:
- '@types/estree': 0.0.39
- estree-walker: 1.0.1
- picomatch: 2.2.2
- rollup: 2.43.0
+ /@esbuild/linux-mips64el@0.19.9:
+ resolution: {integrity: sha512-jg9fujJTNTQBuDXdmAg1eeJUL4Jds7BklOTkkH80ZgQIoCTdQrDaHYgbFZyeTq8zbY+axgptncko3v9p5hLZtw==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/pluginutils/3.1.0_rollup@2.56.2:
- resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
- engines: {node: '>= 8.0.0'}
- peerDependencies:
- rollup: ^1.20.0||^2.0.0
- dependencies:
- '@types/estree': 0.0.39
- estree-walker: 1.0.1
- picomatch: 2.2.2
- rollup: 2.56.2
+ /@esbuild/linux-ppc64@0.19.9:
+ resolution: {integrity: sha512-tkV0xUX0pUUgY4ha7z5BbDS85uI7ABw3V1d0RNTii7E9lbmV8Z37Pup2tsLV46SQWzjOeyDi1Q7Wx2+QM8WaCQ==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+ requiresBuild: true
dev: true
+ optional: true
- /@rollup/pluginutils/4.1.1:
- resolution: {integrity: sha512-clDjivHqWGXi7u+0d2r2sBi4Ie6VLEAzWMIkvJLnDmxoOhBYOTfzGbOQBA32THHm11/LiJbd01tJUpJsbshSWQ==}
- engines: {node: '>= 8.0.0'}
- dependencies:
- estree-walker: 2.0.2
- picomatch: 2.3.0
+ /@esbuild/linux-riscv64@0.19.9:
+ resolution: {integrity: sha512-DfLp8dj91cufgPZDXr9p3FoR++m3ZJ6uIXsXrIvJdOjXVREtXuQCjfMfvmc3LScAVmLjcfloyVtpn43D56JFHg==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+ requiresBuild: true
dev: true
+ optional: true
- /@rushstack/node-core-library/3.35.2:
- resolution: {integrity: sha512-SPd0uG7mwsf3E30np9afCUhtaM1SBpibrbxOXPz82KWV6SQiPUtXeQfhXq9mSnGxOb3WLWoSDe7AFxQNex3+kQ==}
- dependencies:
- '@types/node': 10.17.13
- colors: 1.2.5
- fs-extra: 7.0.1
- import-lazy: 4.0.0
- jju: 1.4.0
- resolve: 1.17.0
- semver: 7.3.4
- timsort: 0.3.0
- z-schema: 3.18.4
+ /@esbuild/linux-s390x@0.19.9:
+ resolution: {integrity: sha512-zHbglfEdC88KMgCWpOl/zc6dDYJvWGLiUtmPRsr1OgCViu3z5GncvNVdf+6/56O2Ca8jUU+t1BW261V6kp8qdw==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+ requiresBuild: true
dev: true
+ optional: true
- /@rushstack/rig-package/0.2.9:
- resolution: {integrity: sha512-4tqsZ/m+BjeNAGeAJYzPF53CT96TsAYeZ3Pq3T4tb1pGGM3d3TWfkmALZdKNhpRlAeShKUrb/o/f/0sAuK/1VQ==}
- dependencies:
- '@types/node': 10.17.13
- resolve: 1.17.0
- strip-json-comments: 3.1.1
+ /@esbuild/linux-x64@0.19.9:
+ resolution: {integrity: sha512-JUjpystGFFmNrEHQnIVG8hKwvA2DN5o7RqiO1CVX8EN/F/gkCjkUMgVn6hzScpwnJtl2mPR6I9XV1oW8k9O+0A==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
dev: true
+ optional: true
- /@rushstack/ts-command-line/4.7.8:
- resolution: {integrity: sha512-8ghIWhkph7NnLCMDJtthpsb7TMOsVGXVDvmxjE/CeklTqjbbUFBjGXizJfpbEkRQTELuZQ2+vGn7sGwIWKN2uA==}
- dependencies:
- '@types/argparse': 1.0.38
- argparse: 1.0.10
- colors: 1.2.5
- string-argv: 0.3.1
+ /@esbuild/netbsd-x64@0.19.9:
+ resolution: {integrity: sha512-GThgZPAwOBOsheA2RUlW5UeroRfESwMq/guy8uEe3wJlAOjpOXuSevLRd70NZ37ZrpO6RHGHgEHvPg1h3S1Jug==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+ requiresBuild: true
dev: true
+ optional: true
- /@sindresorhus/is/0.14.0:
- resolution: {integrity: sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==}
- engines: {node: '>=6'}
+ /@esbuild/openbsd-x64@0.19.9:
+ resolution: {integrity: sha512-Ki6PlzppaFVbLnD8PtlVQfsYw4S9n3eQl87cqgeIw+O3sRr9IghpfSKY62mggdt1yCSZ8QWvTZ9jo9fjDSg9uw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+ requiresBuild: true
dev: true
+ optional: true
- /@sinonjs/commons/1.8.3:
- resolution: {integrity: sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==}
- dependencies:
- type-detect: 4.0.8
+ /@esbuild/sunos-x64@0.19.9:
+ resolution: {integrity: sha512-MLHj7k9hWh4y1ddkBpvRj2b9NCBhfgBt3VpWbHQnXRedVun/hC7sIyTGDGTfsGuXo4ebik2+3ShjcPbhtFwWDw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+ requiresBuild: true
dev: true
+ optional: true
- /@sinonjs/fake-timers/6.0.1:
- resolution: {integrity: sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==}
- dependencies:
- '@sinonjs/commons': 1.8.3
+ /@esbuild/win32-arm64@0.19.9:
+ resolution: {integrity: sha512-GQoa6OrQ8G08guMFgeXPH7yE/8Dt0IfOGWJSfSH4uafwdC7rWwrfE6P9N8AtPGIjUzdo2+7bN8Xo3qC578olhg==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
dev: true
+ optional: true
- /@storybook/addon-a11y/6.3.7:
- resolution: {integrity: sha512-Z5Lhxm8r5CkPW9FYf6zmAk9c7IhUeUQZxKZeEWGZdOvcjQ32rtg4IYvO2SHgWNrEKBdxxFm3pMiyK3wylQLfsQ==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- react:
- optional: true
- react-dom:
- optional: true
- dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/channels': 6.3.7
- '@storybook/client-api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/theming': 6.3.7
- axe-core: 4.3.2
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- react-sizeme: 3.0.1
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
- transitivePeerDependencies:
- - '@types/react'
+ /@esbuild/win32-ia32@0.19.9:
+ resolution: {integrity: sha512-UOozV7Ntykvr5tSOlGCrqU3NBr3d8JqPes0QWN2WOXfvkWVGRajC+Ym0/Wj88fUgecUCLDdJPDF0Nna2UK3Qtg==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
dev: true
+ optional: true
- /@storybook/addon-actions/6.3.7:
- resolution: {integrity: sha512-CEAmztbVt47Gw1o6Iw0VP20tuvISCEKk9CS/rCjHtb4ubby6+j/bkp3pkEUQIbyLdHiLWFMz0ZJdyA/U6T6jCw==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- react:
- optional: true
- react-dom:
- optional: true
- dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/client-api': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/theming': 6.3.7
- core-js: 3.16.2
- fast-deep-equal: 3.1.3
- global: 4.4.0
- lodash: 4.17.21
- polished: 4.1.3
- prop-types: 15.7.2
- react-inspector: 5.1.1
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
- uuid-browser: 3.1.0
- transitivePeerDependencies:
- - '@types/react'
+ /@esbuild/win32-x64@0.19.9:
+ resolution: {integrity: sha512-oxoQgglOP7RH6iasDrhY+R/3cHrfwIDvRlT4CGChflq6twk8iENeVvMJjmvBb94Ik1Z+93iGO27err7w6l54GQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
dev: true
+ optional: true
- /@storybook/addon-backgrounds/6.3.7:
- resolution: {integrity: sha512-NH95pDNILgCXeegbckG+P3zxT5SPmgkAq29P+e3gX7YBOTc6885YCFMJLFpuDMwW4lA0ovXosp4PaUHLsBnLDg==}
+ /@eslint-community/eslint-utils@4.4.0(eslint@8.56.0):
+ resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- react:
- optional: true
- react-dom:
- optional: true
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/theming': 6.3.7
- core-js: 3.16.2
- global: 4.4.0
- memoizerific: 1.11.3
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
- transitivePeerDependencies:
- - '@types/react'
+ eslint: 8.56.0
+ eslint-visitor-keys: 3.4.3
dev: true
- /@storybook/addon-controls/6.3.7:
- resolution: {integrity: sha512-VHOv5XZ0MQ45k6X7AUrMIxGkm7sgIiPwsvajnoeMe7UwS3ngbTb0Q0raLqI/L5jLM/jyQwfpUO9isA6cztGTEQ==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- react:
- optional: true
- react-dom:
- optional: true
- dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/client-api': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/node-logger': 6.3.7
- '@storybook/theming': 6.3.7
- core-js: 3.16.2
- ts-dedent: 2.2.0
- transitivePeerDependencies:
- - '@types/react'
- dev: true
-
- /@storybook/addon-docs/6.3.7_typescript@3.9.10:
- resolution: {integrity: sha512-cyuyoLuB5ELhbrXgnZneDCHqNq1wSdWZ4dzdHy1E5WwLPEhLlD6INfEsm8gnDIb4IncYuzMhK3XYBDd7d3ijOg==}
- peerDependencies:
- '@storybook/angular': 6.3.7
- '@storybook/vue': 6.3.7
- '@storybook/vue3': 6.3.7
- '@storybook/web-components': 6.3.7
- lit: ^2.0.0-rc.1
- lit-html: ^1.4.1 || ^2.0.0-rc.3
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- svelte: ^3.31.2
- sveltedoc-parser: ^4.1.0
- vue: ^2.6.10 || ^3.0.0
- webpack: '*'
- peerDependenciesMeta:
- '@storybook/angular':
- optional: true
- '@storybook/vue':
- optional: true
- '@storybook/vue3':
- optional: true
- '@storybook/web-components':
- optional: true
- lit:
- optional: true
- lit-html:
- optional: true
- react:
- optional: true
- react-dom:
- optional: true
- svelte:
- optional: true
- sveltedoc-parser:
- optional: true
- vue:
- optional: true
- webpack:
- optional: true
- dependencies:
- '@babel/core': 7.15.0
- '@babel/generator': 7.15.0
- '@babel/parser': 7.15.3
- '@babel/plugin-transform-react-jsx': 7.14.9_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@jest/transform': 26.6.2
- '@mdx-js/loader': 1.6.22
- '@mdx-js/mdx': 1.6.22
- '@mdx-js/react': 1.6.22
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/builder-webpack4': 6.3.7_typescript@3.9.10
- '@storybook/client-api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/core': 6.3.7_36f75bb62e0c484c1a06658ad2872463
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/csf-tools': 6.3.7_@babel+core@7.15.0
- '@storybook/node-logger': 6.3.7
- '@storybook/postinstall': 6.3.7
- '@storybook/source-loader': 6.3.7
- '@storybook/theming': 6.3.7
- acorn: 7.4.1
- acorn-jsx: 5.3.2_acorn@7.4.1
- acorn-walk: 7.2.0
- core-js: 3.16.2
- doctrine: 3.0.0
- escodegen: 2.0.0
- fast-deep-equal: 3.1.3
- global: 4.4.0
- html-tags: 3.1.0
- js-string-escape: 1.0.1
- loader-utils: 2.0.0
- lodash: 4.17.21
- p-limit: 3.1.0
- prettier: 2.2.1
- prop-types: 15.7.2
- react-element-to-jsx-string: 14.3.2
- regenerator-runtime: 0.13.9
- remark-external-links: 8.0.0
- remark-slug: 6.1.0
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
- transitivePeerDependencies:
- - '@storybook/builder-webpack5'
- - '@storybook/manager-webpack5'
- - '@types/react'
- - supports-color
- - typescript
- - webpack-cli
- - webpack-command
+ /@eslint-community/regexpp@4.10.0:
+ resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
dev: true
- /@storybook/addon-docs/6.3.7_typescript@4.3.5:
- resolution: {integrity: sha512-cyuyoLuB5ELhbrXgnZneDCHqNq1wSdWZ4dzdHy1E5WwLPEhLlD6INfEsm8gnDIb4IncYuzMhK3XYBDd7d3ijOg==}
- peerDependencies:
- '@storybook/angular': 6.3.7
- '@storybook/vue': 6.3.7
- '@storybook/vue3': 6.3.7
- '@storybook/web-components': 6.3.7
- lit: ^2.0.0-rc.1
- lit-html: ^1.4.1 || ^2.0.0-rc.3
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- svelte: ^3.31.2
- sveltedoc-parser: ^4.1.0
- vue: ^2.6.10 || ^3.0.0
- webpack: '*'
- peerDependenciesMeta:
- '@storybook/angular':
- optional: true
- '@storybook/vue':
- optional: true
- '@storybook/vue3':
- optional: true
- '@storybook/web-components':
- optional: true
- lit:
- optional: true
- lit-html:
- optional: true
- react:
- optional: true
- react-dom:
- optional: true
- svelte:
- optional: true
- sveltedoc-parser:
- optional: true
- vue:
- optional: true
- webpack:
- optional: true
+ /@eslint/eslintrc@0.4.3:
+ resolution: {integrity: sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==}
+ engines: {node: ^10.12.0 || >=12.0.0}
dependencies:
- '@babel/core': 7.15.0
- '@babel/generator': 7.15.0
- '@babel/parser': 7.15.3
- '@babel/plugin-transform-react-jsx': 7.14.9_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@jest/transform': 26.6.2
- '@mdx-js/loader': 1.6.22
- '@mdx-js/mdx': 1.6.22
- '@mdx-js/react': 1.6.22
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/builder-webpack4': 6.3.7_typescript@4.3.5
- '@storybook/client-api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/core': 6.3.7_f0b419a7e119055c71dcaf6063a7ba7a
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/csf-tools': 6.3.7_@babel+core@7.15.0
- '@storybook/node-logger': 6.3.7
- '@storybook/postinstall': 6.3.7
- '@storybook/source-loader': 6.3.7
- '@storybook/theming': 6.3.7
- acorn: 7.4.1
- acorn-jsx: 5.3.2_acorn@7.4.1
- acorn-walk: 7.2.0
- core-js: 3.16.2
- doctrine: 3.0.0
- escodegen: 2.0.0
- fast-deep-equal: 3.1.3
- global: 4.4.0
- html-tags: 3.1.0
- js-string-escape: 1.0.1
- loader-utils: 2.0.0
- lodash: 4.17.21
- p-limit: 3.1.0
- prettier: 2.2.1
- prop-types: 15.7.2
- react-element-to-jsx-string: 14.3.2
- regenerator-runtime: 0.13.9
- remark-external-links: 8.0.0
- remark-slug: 6.1.0
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
+ ajv: 6.12.6
+ debug: 4.3.4
+ espree: 7.3.1
+ globals: 13.21.0
+ ignore: 4.0.6
+ import-fresh: 3.3.0
+ js-yaml: 3.14.1
+ minimatch: 3.1.2
+ strip-json-comments: 3.1.1
transitivePeerDependencies:
- - '@storybook/builder-webpack5'
- - '@storybook/manager-webpack5'
- - '@types/react'
- supports-color
- - typescript
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/addon-essentials/6.3.7_d95124e751df81c32a1d4f8e491e43a1:
- resolution: {integrity: sha512-ZWAW3qMFrrpfSekmCZibp/ivnohFLJdJweiIA0CLnuCNuuK9kQdpFahWdvyBy5NlCj3UJwB7epTZYZyHqYW7UQ==}
- peerDependencies:
- '@babel/core': ^7.9.6
- '@storybook/vue': 6.3.7
- '@storybook/web-components': 6.3.7
- babel-loader: ^8.0.0
- lit-html: ^1.4.1 || ^2.0.0-rc.3
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- webpack: '*'
- peerDependenciesMeta:
- '@storybook/vue':
- optional: true
- '@storybook/web-components':
- optional: true
- lit-html:
- optional: true
- react:
- optional: true
- react-dom:
- optional: true
- webpack:
- optional: true
+ /@eslint/eslintrc@1.3.3:
+ resolution: {integrity: sha512-uj3pT6Mg+3t39fvLrj8iuCIJ38zKO9FpGtJ4BBJebJhEwjoT+KLVNCcHT5QC9NGRIEi7fZ0ZR8YRb884auB4Lg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
- '@babel/core': 7.13.16
- '@storybook/addon-actions': 6.3.7
- '@storybook/addon-backgrounds': 6.3.7
- '@storybook/addon-controls': 6.3.7
- '@storybook/addon-docs': 6.3.7_typescript@4.3.5
- '@storybook/addon-measure': 2.0.0_a4b77c99d63b159b69a1438c89904ed9
- '@storybook/addon-toolbars': 6.3.7
- '@storybook/addon-viewport': 6.3.7
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/node-logger': 6.3.7
- babel-loader: 8.2.2_@babel+core@7.13.16
- core-js: 3.16.2
- regenerator-runtime: 0.13.9
- storybook-addon-outline: 1.4.1
- ts-dedent: 2.2.0
+ ajv: 6.12.6
+ debug: 4.3.4
+ espree: 9.4.0
+ globals: 13.21.0
+ ignore: 5.2.0
+ import-fresh: 3.3.0
+ js-yaml: 4.1.0
+ minimatch: 3.1.2
+ strip-json-comments: 3.1.1
transitivePeerDependencies:
- - '@storybook/angular'
- - '@storybook/builder-webpack5'
- - '@storybook/components'
- - '@storybook/core-events'
- - '@storybook/manager-webpack5'
- - '@storybook/theming'
- - '@storybook/vue3'
- - '@types/react'
- - lit
- supports-color
- - svelte
- - sveltedoc-parser
- - typescript
- - vue
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/addon-essentials/6.3.7_typescript@3.9.10:
- resolution: {integrity: sha512-ZWAW3qMFrrpfSekmCZibp/ivnohFLJdJweiIA0CLnuCNuuK9kQdpFahWdvyBy5NlCj3UJwB7epTZYZyHqYW7UQ==}
- peerDependencies:
- '@babel/core': ^7.9.6
- '@storybook/vue': 6.3.7
- '@storybook/web-components': 6.3.7
- babel-loader: ^8.0.0
- lit-html: ^1.4.1 || ^2.0.0-rc.3
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- webpack: '*'
- peerDependenciesMeta:
- '@storybook/vue':
- optional: true
- '@storybook/web-components':
- optional: true
- lit-html:
- optional: true
- react:
- optional: true
- react-dom:
- optional: true
- webpack:
- optional: true
+ /@eslint/eslintrc@2.1.4:
+ resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
- '@storybook/addon-actions': 6.3.7
- '@storybook/addon-backgrounds': 6.3.7
- '@storybook/addon-controls': 6.3.7
- '@storybook/addon-docs': 6.3.7_typescript@3.9.10
- '@storybook/addon-measure': 2.0.0_a4b77c99d63b159b69a1438c89904ed9
- '@storybook/addon-toolbars': 6.3.7
- '@storybook/addon-viewport': 6.3.7
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/node-logger': 6.3.7
- core-js: 3.16.2
- regenerator-runtime: 0.13.9
- storybook-addon-outline: 1.4.1
- ts-dedent: 2.2.0
+ ajv: 6.12.6
+ debug: 4.3.4
+ espree: 9.6.1
+ globals: 13.24.0
+ ignore: 5.3.0
+ import-fresh: 3.3.0
+ js-yaml: 4.1.0
+ minimatch: 3.1.2
+ strip-json-comments: 3.1.1
transitivePeerDependencies:
- - '@storybook/angular'
- - '@storybook/builder-webpack5'
- - '@storybook/components'
- - '@storybook/core-events'
- - '@storybook/manager-webpack5'
- - '@storybook/theming'
- - '@storybook/vue3'
- - '@types/react'
- - lit
- supports-color
- - svelte
- - sveltedoc-parser
- - typescript
- - vue
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/addon-links/6.3.12:
- resolution: {integrity: sha512-NfOGEm0+QxIrAXCa05LOXmxLtI+RlcDqHXZ1jNNj8mjeRoG1nX3qhkB8PWWIBbPuz+bktLV9ox8UZj0W6+ZPOQ==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- react:
- optional: true
- react-dom:
- optional: true
- dependencies:
- '@storybook/addons': 6.3.12
- '@storybook/client-logger': 6.3.12
- '@storybook/core-events': 6.3.12
- '@storybook/csf': 0.0.1
- '@storybook/router': 6.3.12
- '@types/qs': 6.9.7
- core-js: 3.16.2
- global: 4.4.0
- prop-types: 15.7.2
- qs: 6.10.1
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- dev: true
-
- /@storybook/addon-measure/2.0.0_a4b77c99d63b159b69a1438c89904ed9:
- resolution: {integrity: sha512-ZhdT++cX+L9LwjhGYggvYUUVQH/MGn2rwbrAwCMzA/f2QTFvkjxzX8nDgMxIhaLCDC+gHIxfJG2wrWN0jkBr3g==}
- peerDependencies:
- '@storybook/addons': ^6.3.0
- '@storybook/api': ^6.3.0
- '@storybook/components': ^6.3.0
- '@storybook/core-events': ^6.3.0
- '@storybook/theming': ^6.3.0
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- react:
- optional: true
- react-dom:
- optional: true
- dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
+ /@eslint/js@8.56.0:
+ resolution: {integrity: sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
- /@storybook/addon-toolbars/6.3.7:
- resolution: {integrity: sha512-UTIurbl2WXj/jSOj7ndqQ/WtG7kSpGp62T7gwEZTZ+h/3sJn+bixofBD/7+sXa4hWW07YgTXV547DMhzp5bygg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- react:
- optional: true
- react-dom:
- optional: true
- dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/client-api': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/theming': 6.3.7
- core-js: 3.16.2
- regenerator-runtime: 0.13.9
- transitivePeerDependencies:
- - '@types/react'
+ /@fluent/syntax@0.19.0:
+ resolution: {integrity: sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==}
+ engines: {node: '>=14.0.0', npm: '>=7.0.0'}
dev: true
- /@storybook/addon-viewport/6.3.7:
- resolution: {integrity: sha512-Hdv2QoVVfe/YuMVQKVVnfCCuEoTqTa8Ck7AOKz31VSAliBFhXewP51oKhw9F6mTyvCozMHX6EBtBzN06KyrPyw==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- react:
- optional: true
- react-dom:
- optional: true
- dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/theming': 6.3.7
- core-js: 3.16.2
- global: 4.4.0
- memoizerific: 1.11.3
- prop-types: 15.7.2
- regenerator-runtime: 0.13.9
- transitivePeerDependencies:
- - '@types/react'
- dev: true
-
- /@storybook/addons/6.3.12:
- resolution: {integrity: sha512-UgoMyr7Qr0FS3ezt8u6hMEcHgyynQS9ucr5mAwZky3wpXRPFyUTmMto9r4BBUdqyUvTUj/LRKIcmLBfj+/l0Fg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@storybook/api': 6.3.12
- '@storybook/channels': 6.3.12
- '@storybook/client-logger': 6.3.12
- '@storybook/core-events': 6.3.12
- '@storybook/router': 6.3.12
- '@storybook/theming': 6.3.12
- core-js: 3.16.2
- global: 4.4.0
- regenerator-runtime: 0.13.9
- dev: true
-
- /@storybook/addons/6.3.7:
- resolution: {integrity: sha512-9stVjTcc52bqqh7YQex/LpSjJ4e2Czm4/ZYDjIiNy0p4OZEx+yLhL5mZzMWh2NQd6vv+pHASBSxf2IeaR5511A==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@storybook/api': 6.3.7
- '@storybook/channels': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/router': 6.3.7
- '@storybook/theming': 6.3.7
- core-js: 3.16.2
- global: 4.4.0
- regenerator-runtime: 0.13.9
- dev: true
-
- /@storybook/addons/6.3.7_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-9stVjTcc52bqqh7YQex/LpSjJ4e2Czm4/ZYDjIiNy0p4OZEx+yLhL5mZzMWh2NQd6vv+pHASBSxf2IeaR5511A==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@storybook/api': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/channels': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/router': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/theming': 6.3.7_react-dom@16.14.0+react@16.14.0
- core-js: 3.16.2
- global: 4.4.0
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- regenerator-runtime: 0.13.9
- dev: true
-
- /@storybook/api/6.3.12:
- resolution: {integrity: sha512-LScRXUeCWEW/OP+jiooNMQICVdusv7azTmULxtm72fhkXFRiQs2CdRNTiqNg46JLLC9z95f1W+pGK66X6HiiQA==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@reach/router': 1.3.4
- '@storybook/channels': 6.3.12
- '@storybook/client-logger': 6.3.12
- '@storybook/core-events': 6.3.12
- '@storybook/csf': 0.0.1
- '@storybook/router': 6.3.12
- '@storybook/semver': 7.3.2
- '@storybook/theming': 6.3.12
- '@types/reach__router': 1.3.9
- core-js: 3.16.2
- fast-deep-equal: 3.1.3
- global: 4.4.0
- lodash: 4.17.21
- memoizerific: 1.11.3
- qs: 6.10.1
- regenerator-runtime: 0.13.9
- store2: 2.12.0
- telejson: 5.3.3
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
+ /@gar/promisify@1.1.3:
+ resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
dev: true
- /@storybook/api/6.3.7:
- resolution: {integrity: sha512-57al8mxmE9agXZGo8syRQ8UhvGnDC9zkuwkBPXchESYYVkm3Mc54RTvdAOYDiy85VS4JxiGOywHayCaRwgUddQ==}
+ /@headlessui/react@1.7.14(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-znzdq9PG8rkwcu9oQ2FwIy0ZFtP9Z7ycS+BAqJ3R5EIqC/0bJGvhT7193rFf+45i9nnPsYvCQVW4V/bB9Xc+gA==}
+ engines: {node: '>=10'}
peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
+ react: ^16 || ^17 || ^18
+ react-dom: ^16 || ^17 || ^18
dependencies:
- '@reach/router': 1.3.4
- '@storybook/channels': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/router': 6.3.7
- '@storybook/semver': 7.3.2
- '@storybook/theming': 6.3.7
- '@types/reach__router': 1.3.9
- core-js: 3.16.2
- fast-deep-equal: 3.1.3
- global: 4.4.0
- lodash: 4.17.21
- memoizerific: 1.11.3
- qs: 6.10.1
- regenerator-runtime: 0.13.9
- store2: 2.12.0
- telejson: 5.3.3
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
- dev: true
+ client-only: 0.0.1
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
- /@storybook/api/6.3.7_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-57al8mxmE9agXZGo8syRQ8UhvGnDC9zkuwkBPXchESYYVkm3Mc54RTvdAOYDiy85VS4JxiGOywHayCaRwgUddQ==}
+ /@heroicons/react@2.0.17(react@18.2.0):
+ resolution: {integrity: sha512-90GMZktkA53YbNzHp6asVEDevUQCMtxWH+2UK2S8OpnLEu7qckTJPhNxNQG52xIR1WFTwFqtH6bt7a60ZNcLLA==}
peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
+ react: '>= 16'
dependencies:
- '@reach/router': 1.3.4_react-dom@16.14.0+react@16.14.0
- '@storybook/channels': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/router': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/semver': 7.3.2
- '@storybook/theming': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@types/reach__router': 1.3.9
- core-js: 3.16.2
- fast-deep-equal: 3.1.3
- global: 4.4.0
- lodash: 4.17.21
- memoizerific: 1.11.3
- qs: 6.10.1
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- regenerator-runtime: 0.13.9
- store2: 2.12.0
- telejson: 5.3.3
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
- dev: true
+ react: 18.2.0
- /@storybook/builder-webpack4/6.3.7_6b8328ae33be7bccbaedcbeca6bc1253:
- resolution: {integrity: sha512-M5envblMzAUrNqP1+ouKiL8iSIW/90+kBRU2QeWlZoZl1ib+fiFoKk06cgbaC70Bx1lU8nOnI/VBvB5pLhXLaw==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@humanwhocodes/config-array@0.11.13:
+ resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==}
+ engines: {node: '>=10.10.0'}
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-decorators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-export-default-from': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-private-methods': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-arrow-functions': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-block-scoping': 7.15.3_@babel+core@7.15.0
- '@babel/plugin-transform-classes': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-destructuring': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-transform-for-of': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-shorthand-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-spread': 7.14.6_@babel+core@7.15.0
- '@babel/plugin-transform-template-literals': 7.14.5_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/api': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/channels': 6.3.7
- '@storybook/client-api': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/core-common': 6.3.7_6b8328ae33be7bccbaedcbeca6bc1253
- '@storybook/core-events': 6.3.7
- '@storybook/node-logger': 6.3.7
- '@storybook/router': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/semver': 7.3.2
- '@storybook/theming': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/ui': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@types/node': 14.17.10
- '@types/webpack': 4.41.30
- autoprefixer: 9.8.6
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- babel-plugin-macros: 2.8.0
- babel-plugin-polyfill-corejs3: 0.1.7_@babel+core@7.15.0
- case-sensitive-paths-webpack-plugin: 2.4.0
- core-js: 3.16.2
- css-loader: 3.6.0_webpack@4.46.0
- dotenv-webpack: 1.8.0_webpack@4.46.0
- file-loader: 6.2.0_webpack@4.46.0
- find-up: 5.0.0
- fork-ts-checker-webpack-plugin: 4.1.6
- fs-extra: 9.1.0
- glob: 7.1.7
- glob-promise: 3.4.0_glob@7.1.7
- global: 4.4.0
- html-webpack-plugin: 4.5.2_webpack@4.46.0
- pnp-webpack-plugin: 1.6.4_typescript@3.9.10
- postcss: 7.0.36
- postcss-flexbugs-fixes: 4.2.1
- postcss-loader: 4.3.0_postcss@7.0.36+webpack@4.46.0
- raw-loader: 4.0.2_webpack@4.46.0
- react: 16.14.0
- react-dev-utils: 11.0.4
- react-dom: 16.14.0_react@16.14.0
- stable: 0.1.8
- style-loader: 1.3.0_webpack@4.46.0
- terser-webpack-plugin: 4.2.3_webpack@4.46.0
- ts-dedent: 2.2.0
- typescript: 3.9.10
- url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- webpack-dev-middleware: 3.7.3_webpack@4.46.0
- webpack-filter-warnings-plugin: 1.2.1_webpack@4.46.0
- webpack-hot-middleware: 2.25.0
- webpack-virtual-modules: 0.2.2
+ '@humanwhocodes/object-schema': 2.0.1
+ debug: 4.3.4
+ minimatch: 3.1.2
transitivePeerDependencies:
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/builder-webpack4/6.3.7_8073bd74a106ff14517e8eecceb690e6:
- resolution: {integrity: sha512-M5envblMzAUrNqP1+ouKiL8iSIW/90+kBRU2QeWlZoZl1ib+fiFoKk06cgbaC70Bx1lU8nOnI/VBvB5pLhXLaw==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@humanwhocodes/config-array@0.11.6:
+ resolution: {integrity: sha512-jJr+hPTJYKyDILJfhNSHsjiwXYf26Flsz8DvNndOsHs5pwSnpGUEy8yzF0JYhCEvTDdV2vuOK5tt8BVhwO5/hg==}
+ engines: {node: '>=10.10.0'}
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-decorators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-export-default-from': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-private-methods': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-arrow-functions': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-block-scoping': 7.15.3_@babel+core@7.15.0
- '@babel/plugin-transform-classes': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-destructuring': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-transform-for-of': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-shorthand-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-spread': 7.14.6_@babel+core@7.15.0
- '@babel/plugin-transform-template-literals': 7.14.5_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/api': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/channels': 6.3.7
- '@storybook/client-api': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/core-common': 6.3.7_8073bd74a106ff14517e8eecceb690e6
- '@storybook/core-events': 6.3.7
- '@storybook/node-logger': 6.3.7
- '@storybook/router': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/semver': 7.3.2
- '@storybook/theming': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/ui': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@types/node': 14.17.10
- '@types/webpack': 4.41.30
- autoprefixer: 9.8.6
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- babel-plugin-macros: 2.8.0
- babel-plugin-polyfill-corejs3: 0.1.7_@babel+core@7.15.0
- case-sensitive-paths-webpack-plugin: 2.4.0
- core-js: 3.16.2
- css-loader: 3.6.0_webpack@4.46.0
- dotenv-webpack: 1.8.0_webpack@4.46.0
- file-loader: 6.2.0_webpack@4.46.0
- find-up: 5.0.0
- fork-ts-checker-webpack-plugin: 4.1.6
- fs-extra: 9.1.0
- glob: 7.1.7
- glob-promise: 3.4.0_glob@7.1.7
- global: 4.4.0
- html-webpack-plugin: 4.5.2_webpack@4.46.0
- pnp-webpack-plugin: 1.6.4_typescript@4.3.5
- postcss: 7.0.36
- postcss-flexbugs-fixes: 4.2.1
- postcss-loader: 4.3.0_postcss@7.0.36+webpack@4.46.0
- raw-loader: 4.0.2_webpack@4.46.0
- react: 16.14.0
- react-dev-utils: 11.0.4
- react-dom: 16.14.0_react@16.14.0
- stable: 0.1.8
- style-loader: 1.3.0_webpack@4.46.0
- terser-webpack-plugin: 4.2.3_webpack@4.46.0
- ts-dedent: 2.2.0
- typescript: 4.3.5
- url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- webpack-dev-middleware: 3.7.3_webpack@4.46.0
- webpack-filter-warnings-plugin: 1.2.1_webpack@4.46.0
- webpack-hot-middleware: 2.25.0
- webpack-virtual-modules: 0.2.2
+ '@humanwhocodes/object-schema': 1.2.1
+ debug: 4.3.4
+ minimatch: 3.1.2
transitivePeerDependencies:
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/builder-webpack4/6.3.7_typescript@3.9.10:
- resolution: {integrity: sha512-M5envblMzAUrNqP1+ouKiL8iSIW/90+kBRU2QeWlZoZl1ib+fiFoKk06cgbaC70Bx1lU8nOnI/VBvB5pLhXLaw==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@humanwhocodes/config-array@0.5.0:
+ resolution: {integrity: sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==}
+ engines: {node: '>=10.10.0'}
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-decorators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-export-default-from': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-private-methods': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-arrow-functions': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-block-scoping': 7.15.3_@babel+core@7.15.0
- '@babel/plugin-transform-classes': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-destructuring': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-transform-for-of': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-shorthand-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-spread': 7.14.6_@babel+core@7.15.0
- '@babel/plugin-transform-template-literals': 7.14.5_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/channels': 6.3.7
- '@storybook/client-api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/core-common': 6.3.7_typescript@3.9.10
- '@storybook/core-events': 6.3.7
- '@storybook/node-logger': 6.3.7
- '@storybook/router': 6.3.7
- '@storybook/semver': 7.3.2
- '@storybook/theming': 6.3.7
- '@storybook/ui': 6.3.7
- '@types/node': 14.17.10
- '@types/webpack': 4.41.30
- autoprefixer: 9.8.6
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- babel-plugin-macros: 2.8.0
- babel-plugin-polyfill-corejs3: 0.1.7_@babel+core@7.15.0
- case-sensitive-paths-webpack-plugin: 2.4.0
- core-js: 3.16.2
- css-loader: 3.6.0_webpack@4.46.0
- dotenv-webpack: 1.8.0_webpack@4.46.0
- file-loader: 6.2.0_webpack@4.46.0
- find-up: 5.0.0
- fork-ts-checker-webpack-plugin: 4.1.6
- fs-extra: 9.1.0
- glob: 7.1.7
- glob-promise: 3.4.0_glob@7.1.7
- global: 4.4.0
- html-webpack-plugin: 4.5.2_webpack@4.46.0
- pnp-webpack-plugin: 1.6.4_typescript@3.9.10
- postcss: 7.0.36
- postcss-flexbugs-fixes: 4.2.1
- postcss-loader: 4.3.0_postcss@7.0.36+webpack@4.46.0
- raw-loader: 4.0.2_webpack@4.46.0
- react-dev-utils: 11.0.4
- stable: 0.1.8
- style-loader: 1.3.0_webpack@4.46.0
- terser-webpack-plugin: 4.2.3_webpack@4.46.0
- ts-dedent: 2.2.0
- typescript: 3.9.10
- url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- webpack-dev-middleware: 3.7.3_webpack@4.46.0
- webpack-filter-warnings-plugin: 1.2.1_webpack@4.46.0
- webpack-hot-middleware: 2.25.0
- webpack-virtual-modules: 0.2.2
+ '@humanwhocodes/object-schema': 1.2.1
+ debug: 4.3.4
+ minimatch: 3.1.2
transitivePeerDependencies:
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/builder-webpack4/6.3.7_typescript@4.3.5:
- resolution: {integrity: sha512-M5envblMzAUrNqP1+ouKiL8iSIW/90+kBRU2QeWlZoZl1ib+fiFoKk06cgbaC70Bx1lU8nOnI/VBvB5pLhXLaw==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
- dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-decorators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-export-default-from': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-private-methods': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-arrow-functions': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-block-scoping': 7.15.3_@babel+core@7.15.0
- '@babel/plugin-transform-classes': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-destructuring': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-transform-for-of': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-shorthand-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-spread': 7.14.6_@babel+core@7.15.0
- '@babel/plugin-transform-template-literals': 7.14.5_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/channels': 6.3.7
- '@storybook/client-api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/core-common': 6.3.7_typescript@4.3.5
- '@storybook/core-events': 6.3.7
- '@storybook/node-logger': 6.3.7
- '@storybook/router': 6.3.7
- '@storybook/semver': 7.3.2
- '@storybook/theming': 6.3.7
- '@storybook/ui': 6.3.7
- '@types/node': 14.17.10
- '@types/webpack': 4.41.30
- autoprefixer: 9.8.6
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- babel-plugin-macros: 2.8.0
- babel-plugin-polyfill-corejs3: 0.1.7_@babel+core@7.15.0
- case-sensitive-paths-webpack-plugin: 2.4.0
- core-js: 3.16.2
- css-loader: 3.6.0_webpack@4.46.0
- dotenv-webpack: 1.8.0_webpack@4.46.0
- file-loader: 6.2.0_webpack@4.46.0
- find-up: 5.0.0
- fork-ts-checker-webpack-plugin: 4.1.6
- fs-extra: 9.1.0
- glob: 7.1.7
- glob-promise: 3.4.0_glob@7.1.7
- global: 4.4.0
- html-webpack-plugin: 4.5.2_webpack@4.46.0
- pnp-webpack-plugin: 1.6.4_typescript@4.3.5
- postcss: 7.0.36
- postcss-flexbugs-fixes: 4.2.1
- postcss-loader: 4.3.0_postcss@7.0.36+webpack@4.46.0
- raw-loader: 4.0.2_webpack@4.46.0
- react-dev-utils: 11.0.4
- stable: 0.1.8
- style-loader: 1.3.0_webpack@4.46.0
- terser-webpack-plugin: 4.2.3_webpack@4.46.0
- ts-dedent: 2.2.0
- typescript: 4.3.5
- url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- webpack-dev-middleware: 3.7.3_webpack@4.46.0
- webpack-filter-warnings-plugin: 1.2.1_webpack@4.46.0
- webpack-hot-middleware: 2.25.0
- webpack-virtual-modules: 0.2.2
- transitivePeerDependencies:
- - '@types/react'
- - supports-color
- - webpack-cli
- - webpack-command
+ /@humanwhocodes/module-importer@1.0.1:
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
dev: true
- /@storybook/channel-postmessage/6.3.7:
- resolution: {integrity: sha512-Cmw8HRkeSF1yUFLfEIUIkUICyCXX8x5Ol/5QPbiW9HPE2hbZtYROCcg4bmWqdq59N0Tp9FQNSn+9ZygPgqQtNw==}
- dependencies:
- '@storybook/channels': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- core-js: 3.16.2
- global: 4.4.0
- qs: 6.10.1
- telejson: 5.3.3
+ /@humanwhocodes/object-schema@1.2.1:
+ resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
dev: true
- /@storybook/channels/6.3.12:
- resolution: {integrity: sha512-l4sA+g1PdUV8YCbgs47fIKREdEQAKNdQIZw0b7BfTvY9t0x5yfBywgQhYON/lIeiNGz2OlIuD+VUtqYfCtNSyw==}
- dependencies:
- core-js: 3.16.2
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
+ /@humanwhocodes/object-schema@2.0.1:
+ resolution: {integrity: sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==}
dev: true
- /@storybook/channels/6.3.7:
- resolution: {integrity: sha512-aErXO+SRO8MPp2wOkT2n9d0fby+8yM35tq1tI633B4eQsM74EykbXPv7EamrYPqp1AI4BdiloyEpr0hmr2zlvg==}
+ /@isaacs/cliui@8.0.2:
+ resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
+ engines: {node: '>=12'}
dependencies:
- core-js: 3.16.2
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
- dev: true
+ string-width: 5.1.2
+ string-width-cjs: /string-width@4.2.3
+ strip-ansi: 7.1.0
+ strip-ansi-cjs: /strip-ansi@6.0.1
+ wrap-ansi: 8.1.0
+ wrap-ansi-cjs: /wrap-ansi@7.0.0
- /@storybook/client-api/6.3.7:
- resolution: {integrity: sha512-8wOH19cMIwIIYhZy5O5Wl8JT1QOL5kNuamp9GPmg5ff4DtnG+/uUslskRvsnKyjPvl+WbIlZtBVWBiawVdd/yQ==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
+ /@istanbuljs/load-nyc-config@1.1.0:
+ resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
+ engines: {node: '>=8'}
dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/channels': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@types/qs': 6.9.7
- '@types/webpack-env': 1.16.2
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- memoizerific: 1.11.3
- qs: 6.10.1
- regenerator-runtime: 0.13.9
- stable: 0.1.8
- store2: 2.12.0
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
+ camelcase: 5.3.1
+ find-up: 4.1.0
+ get-package-type: 0.1.0
+ js-yaml: 3.14.1
+ resolve-from: 5.0.0
dev: true
- /@storybook/client-api/6.3.7_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-8wOH19cMIwIIYhZy5O5Wl8JT1QOL5kNuamp9GPmg5ff4DtnG+/uUslskRvsnKyjPvl+WbIlZtBVWBiawVdd/yQ==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/channels': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@types/qs': 6.9.7
- '@types/webpack-env': 1.16.2
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- memoizerific: 1.11.3
- qs: 6.10.1
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- regenerator-runtime: 0.13.9
- stable: 0.1.8
- store2: 2.12.0
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
+ /@istanbuljs/schema@0.1.3:
+ resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
+ engines: {node: '>=8'}
dev: true
- /@storybook/client-logger/6.3.12:
- resolution: {integrity: sha512-zNDsamZvHnuqLznDdP9dUeGgQ9TyFh4ray3t1VGO7ZqWVZ2xtVCCXjDvMnOXI2ifMpX5UsrOvshIPeE9fMBmiQ==}
+ /@jridgewell/gen-mapping@0.1.1:
+ resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==}
+ engines: {node: '>=6.0.0'}
dependencies:
- core-js: 3.16.2
- global: 4.4.0
- dev: true
+ '@jridgewell/set-array': 1.1.2
+ '@jridgewell/sourcemap-codec': 1.4.15
- /@storybook/client-logger/6.3.7:
- resolution: {integrity: sha512-BQRErHE3nIEuUJN/3S3dO1LzxAknOgrFeZLd4UVcH/fvjtS1F4EkhcbH+jNyUWvcWGv66PZYN0oFPEn/g3Savg==}
+ /@jridgewell/gen-mapping@0.3.2:
+ resolution: {integrity: sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==}
+ engines: {node: '>=6.0.0'}
dependencies:
- core-js: 3.16.2
- global: 4.4.0
+ '@jridgewell/set-array': 1.1.2
+ '@jridgewell/sourcemap-codec': 1.4.15
+ '@jridgewell/trace-mapping': 0.3.19
dev: true
- /@storybook/components/6.3.7:
- resolution: {integrity: sha512-O7LIg9Z18G0AJqXX7Shcj0uHqwXlSA5UkHgaz9A7mqqqJNl6m6FwwTWcxR1acUfYVNkO+czgpqZHNrOF6rky1A==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
+ /@jridgewell/gen-mapping@0.3.3:
+ resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==}
+ engines: {node: '>=6.0.0'}
dependencies:
- '@popperjs/core': 2.9.3
- '@storybook/client-logger': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/theming': 6.3.7
- '@types/color-convert': 2.0.0
- '@types/overlayscrollbars': 1.12.1
- '@types/react-syntax-highlighter': 11.0.5
- color-convert: 2.0.1
- core-js: 3.16.2
- fast-deep-equal: 3.1.3
- global: 4.4.0
- lodash: 4.17.21
- markdown-to-jsx: 7.1.3
- memoizerific: 1.11.3
- overlayscrollbars: 1.13.1
- polished: 4.1.3
- prop-types: 15.7.2
- react-colorful: 5.3.0
- react-popper-tooltip: 3.1.1
- react-syntax-highlighter: 13.5.3
- react-textarea-autosize: 8.3.3
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
- transitivePeerDependencies:
- - '@types/react'
- dev: true
+ '@jridgewell/set-array': 1.1.2
+ '@jridgewell/sourcemap-codec': 1.4.15
+ '@jridgewell/trace-mapping': 0.3.19
- /@storybook/components/6.3.7_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-O7LIg9Z18G0AJqXX7Shcj0uHqwXlSA5UkHgaz9A7mqqqJNl6m6FwwTWcxR1acUfYVNkO+czgpqZHNrOF6rky1A==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@popperjs/core': 2.9.3
- '@storybook/client-logger': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/theming': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@types/color-convert': 2.0.0
- '@types/overlayscrollbars': 1.12.1
- '@types/react-syntax-highlighter': 11.0.5
- color-convert: 2.0.1
- core-js: 3.16.2
- fast-deep-equal: 3.1.3
- global: 4.4.0
- lodash: 4.17.21
- markdown-to-jsx: 7.1.3_react@16.14.0
- memoizerific: 1.11.3
- overlayscrollbars: 1.13.1
- polished: 4.1.3
- prop-types: 15.7.2
- react: 16.14.0
- react-colorful: 5.3.0_react-dom@16.14.0+react@16.14.0
- react-dom: 16.14.0_react@16.14.0
- react-popper-tooltip: 3.1.1_react-dom@16.14.0+react@16.14.0
- react-syntax-highlighter: 13.5.3_react@16.14.0
- react-textarea-autosize: 8.3.3_react@16.14.0
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- util-deprecate: 1.0.2
- transitivePeerDependencies:
- - '@types/react'
- dev: true
+ /@jridgewell/resolve-uri@3.1.1:
+ resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
+ engines: {node: '>=6.0.0'}
- /@storybook/core-client/6.3.7_3c33386fd9b1d5f07f48f07869b17b73:
- resolution: {integrity: sha512-M/4A65yV+Y4lsCQXX4BtQO/i3BcMPrU5FkDG8qJd3dkcx2fUlFvGWqQPkcTZE+MPVvMEGl/AsEZSADzah9+dAg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- webpack: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
- dependencies:
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/client-api': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/ui': 6.3.7_react-dom@16.14.0+react@16.14.0
- airbnb-js-shims: 2.2.1
- ansi-to-html: 0.6.15
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- qs: 6.10.1
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- typescript: 3.9.10
- unfetch: 4.2.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- transitivePeerDependencies:
- - '@types/react'
- dev: true
+ /@jridgewell/set-array@1.1.2:
+ resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==}
+ engines: {node: '>=6.0.0'}
- /@storybook/core-client/6.3.7_6b8328ae33be7bccbaedcbeca6bc1253:
- resolution: {integrity: sha512-M/4A65yV+Y4lsCQXX4BtQO/i3BcMPrU5FkDG8qJd3dkcx2fUlFvGWqQPkcTZE+MPVvMEGl/AsEZSADzah9+dAg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- webpack: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@jridgewell/source-map@0.3.2:
+ resolution: {integrity: sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==}
dependencies:
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/client-api': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/ui': 6.3.7_react-dom@16.14.0+react@16.14.0
- airbnb-js-shims: 2.2.1
- ansi-to-html: 0.6.15
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- qs: 6.10.1
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- typescript: 3.9.10
- unfetch: 4.2.0
- util-deprecate: 1.0.2
- transitivePeerDependencies:
- - '@types/react'
+ '@jridgewell/gen-mapping': 0.3.3
+ '@jridgewell/trace-mapping': 0.3.19
dev: true
- /@storybook/core-client/6.3.7_70d39b49be06a92d1ddadaf2d0a6de02:
- resolution: {integrity: sha512-M/4A65yV+Y4lsCQXX4BtQO/i3BcMPrU5FkDG8qJd3dkcx2fUlFvGWqQPkcTZE+MPVvMEGl/AsEZSADzah9+dAg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- webpack: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@jridgewell/source-map@0.3.5:
+ resolution: {integrity: sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==}
dependencies:
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/client-api': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/ui': 6.3.7_react-dom@16.14.0+react@16.14.0
- airbnb-js-shims: 2.2.1
- ansi-to-html: 0.6.15
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- qs: 6.10.1
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- typescript: 4.3.5
- unfetch: 4.2.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- transitivePeerDependencies:
- - '@types/react'
+ '@jridgewell/gen-mapping': 0.3.3
+ '@jridgewell/trace-mapping': 0.3.20
dev: true
- /@storybook/core-client/6.3.7_8073bd74a106ff14517e8eecceb690e6:
- resolution: {integrity: sha512-M/4A65yV+Y4lsCQXX4BtQO/i3BcMPrU5FkDG8qJd3dkcx2fUlFvGWqQPkcTZE+MPVvMEGl/AsEZSADzah9+dAg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- webpack: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
- dependencies:
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/client-api': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/ui': 6.3.7_react-dom@16.14.0+react@16.14.0
- airbnb-js-shims: 2.2.1
- ansi-to-html: 0.6.15
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- qs: 6.10.1
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- typescript: 4.3.5
- unfetch: 4.2.0
- util-deprecate: 1.0.2
- transitivePeerDependencies:
- - '@types/react'
- dev: true
+ /@jridgewell/sourcemap-codec@1.4.15:
+ resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
- /@storybook/core-client/6.3.7_typescript@3.9.10:
- resolution: {integrity: sha512-M/4A65yV+Y4lsCQXX4BtQO/i3BcMPrU5FkDG8qJd3dkcx2fUlFvGWqQPkcTZE+MPVvMEGl/AsEZSADzah9+dAg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- webpack: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@jridgewell/trace-mapping@0.3.19:
+ resolution: {integrity: sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==}
dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/client-api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/ui': 6.3.7
- airbnb-js-shims: 2.2.1
- ansi-to-html: 0.6.15
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- qs: 6.10.1
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- typescript: 3.9.10
- unfetch: 4.2.0
- util-deprecate: 1.0.2
- transitivePeerDependencies:
- - '@types/react'
- dev: true
+ '@jridgewell/resolve-uri': 3.1.1
+ '@jridgewell/sourcemap-codec': 1.4.15
- /@storybook/core-client/6.3.7_typescript@3.9.10+webpack@4.46.0:
- resolution: {integrity: sha512-M/4A65yV+Y4lsCQXX4BtQO/i3BcMPrU5FkDG8qJd3dkcx2fUlFvGWqQPkcTZE+MPVvMEGl/AsEZSADzah9+dAg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- webpack: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@jridgewell/trace-mapping@0.3.20:
+ resolution: {integrity: sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==}
dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/client-api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/ui': 6.3.7
- airbnb-js-shims: 2.2.1
- ansi-to-html: 0.6.15
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- qs: 6.10.1
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- typescript: 3.9.10
- unfetch: 4.2.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- transitivePeerDependencies:
- - '@types/react'
+ '@jridgewell/resolve-uri': 3.1.1
+ '@jridgewell/sourcemap-codec': 1.4.15
dev: true
- /@storybook/core-client/6.3.7_typescript@4.3.5:
- resolution: {integrity: sha512-M/4A65yV+Y4lsCQXX4BtQO/i3BcMPrU5FkDG8qJd3dkcx2fUlFvGWqQPkcTZE+MPVvMEGl/AsEZSADzah9+dAg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- webpack: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@jridgewell/trace-mapping@0.3.9:
+ resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/client-api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/ui': 6.3.7
- airbnb-js-shims: 2.2.1
- ansi-to-html: 0.6.15
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- qs: 6.10.1
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- typescript: 4.3.5
- unfetch: 4.2.0
- util-deprecate: 1.0.2
- transitivePeerDependencies:
- - '@types/react'
+ '@jridgewell/resolve-uri': 3.1.1
+ '@jridgewell/sourcemap-codec': 1.4.15
dev: true
- /@storybook/core-client/6.3.7_typescript@4.3.5+webpack@4.46.0:
- resolution: {integrity: sha512-M/4A65yV+Y4lsCQXX4BtQO/i3BcMPrU5FkDG8qJd3dkcx2fUlFvGWqQPkcTZE+MPVvMEGl/AsEZSADzah9+dAg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- webpack: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
- dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/channel-postmessage': 6.3.7
- '@storybook/client-api': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/csf': 0.0.1
- '@storybook/ui': 6.3.7
- airbnb-js-shims: 2.2.1
- ansi-to-html: 0.6.15
- core-js: 3.16.2
- global: 4.4.0
- lodash: 4.17.21
- qs: 6.10.1
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- typescript: 4.3.5
- unfetch: 4.2.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- transitivePeerDependencies:
- - '@types/react'
+ /@leichtgewicht/ip-codec@2.0.4:
+ resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==}
dev: true
- /@storybook/core-common/6.3.7_6b8328ae33be7bccbaedcbeca6bc1253:
- resolution: {integrity: sha512-exLoqRPPsAefwyjbsQBLNFrlPCcv69Q/pclqmIm7FqAPR7f3CKP1rqsHY0PnemizTL/+cLX5S7mY90gI6wpNug==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@linaria/babel-preset@3.0.0-beta.22:
+ resolution: {integrity: sha512-0Igie3stlsKT+XKYdhW7Yy8MNg2gZ5ShOosgTkpz3Agwtz+Osgb/4JNMnqGlAUaVULtMpKLc7mF/NbbhAEEF3Q==}
+ engines: {node: ^12.16.0 || >=13.7.0}
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-decorators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-export-default-from': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-private-methods': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-arrow-functions': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-block-scoping': 7.15.3_@babel+core@7.15.0
- '@babel/plugin-transform-classes': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-destructuring': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-transform-for-of': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-shorthand-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-spread': 7.14.6_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- '@babel/register': 7.15.3_@babel+core@7.15.0
- '@storybook/node-logger': 6.3.7
- '@storybook/semver': 7.3.2
- '@types/glob-base': 0.3.0
- '@types/micromatch': 4.0.2
- '@types/node': 14.17.10
- '@types/pretty-hrtime': 1.0.1
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- babel-plugin-macros: 3.1.0
- babel-plugin-polyfill-corejs3: 0.1.7_@babel+core@7.15.0
- chalk: 4.1.2
- core-js: 3.16.2
- express: 4.17.1
- file-system-cache: 1.0.5
+ '@babel/core': 7.18.9
+ '@babel/generator': 7.21.5
+ '@babel/plugin-proposal-export-namespace-from': 7.18.9(@babel/core@7.18.9)
+ '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-modules-commonjs': 7.19.6(@babel/core@7.18.9)
+ '@babel/template': 7.20.7
+ '@babel/traverse': 7.21.5
+ '@linaria/core': 3.0.0-beta.22
+ '@linaria/logger': 3.0.0-beta.20
+ '@linaria/utils': 3.0.0-beta.20
+ cosmiconfig: 5.2.1
find-up: 5.0.0
- fork-ts-checker-webpack-plugin: 6.3.2
- glob: 7.1.7
- glob-base: 0.3.0
- interpret: 2.2.0
- json5: 2.2.0
- lazy-universal-dotenv: 3.0.1
- micromatch: 4.0.4
- pkg-dir: 5.0.0
- pretty-hrtime: 1.0.3
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- resolve-from: 5.0.0
- ts-dedent: 2.2.0
- typescript: 3.9.10
- util-deprecate: 1.0.2
- webpack: 4.46.0
+ source-map: 0.7.4
+ stylis: 3.5.4
transitivePeerDependencies:
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/core-common/6.3.7_8073bd74a106ff14517e8eecceb690e6:
- resolution: {integrity: sha512-exLoqRPPsAefwyjbsQBLNFrlPCcv69Q/pclqmIm7FqAPR7f3CKP1rqsHY0PnemizTL/+cLX5S7mY90gI6wpNug==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
- dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-decorators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-export-default-from': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-private-methods': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-arrow-functions': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-block-scoping': 7.15.3_@babel+core@7.15.0
- '@babel/plugin-transform-classes': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-destructuring': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-transform-for-of': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-shorthand-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-spread': 7.14.6_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- '@babel/register': 7.15.3_@babel+core@7.15.0
- '@storybook/node-logger': 6.3.7
- '@storybook/semver': 7.3.2
- '@types/glob-base': 0.3.0
- '@types/micromatch': 4.0.2
- '@types/node': 14.17.10
- '@types/pretty-hrtime': 1.0.1
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- babel-plugin-macros: 3.1.0
- babel-plugin-polyfill-corejs3: 0.1.7_@babel+core@7.15.0
- chalk: 4.1.2
- core-js: 3.16.2
- express: 4.17.1
- file-system-cache: 1.0.5
+ /@linaria/babel-preset@3.0.0-beta.23:
+ resolution: {integrity: sha512-NhxUZokEq12RLpDo4v/f59dB9A/1BbLgGLFotnrDzNBHfylm0qXSIIel68pZOXUB5lVdPJHqZWcT2zxbpGW6fA==}
+ engines: {node: ^12.16.0 || >=13.7.0}
+ dependencies:
+ '@babel/core': 7.18.9
+ '@babel/generator': 7.23.5
+ '@babel/plugin-proposal-export-namespace-from': 7.18.9(@babel/core@7.18.9)
+ '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.18.9)
+ '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.18.9)
+ '@babel/template': 7.22.15
+ '@babel/traverse': 7.23.5
+ '@linaria/core': 3.0.0-beta.22
+ '@linaria/logger': 3.0.0-beta.20
+ '@linaria/utils': 3.0.0-beta.20
+ cosmiconfig: 5.2.1
find-up: 5.0.0
- fork-ts-checker-webpack-plugin: 6.3.2
- glob: 7.1.7
- glob-base: 0.3.0
- interpret: 2.2.0
- json5: 2.2.0
- lazy-universal-dotenv: 3.0.1
- micromatch: 4.0.4
- pkg-dir: 5.0.0
- pretty-hrtime: 1.0.3
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- resolve-from: 5.0.0
- ts-dedent: 2.2.0
- typescript: 4.3.5
- util-deprecate: 1.0.2
- webpack: 4.46.0
+ source-map: 0.7.4
+ stylis: 3.5.4
transitivePeerDependencies:
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/core-common/6.3.7_typescript@3.9.10:
- resolution: {integrity: sha512-exLoqRPPsAefwyjbsQBLNFrlPCcv69Q/pclqmIm7FqAPR7f3CKP1rqsHY0PnemizTL/+cLX5S7mY90gI6wpNug==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
- dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-decorators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-export-default-from': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-private-methods': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-arrow-functions': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-block-scoping': 7.15.3_@babel+core@7.15.0
- '@babel/plugin-transform-classes': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-destructuring': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-transform-for-of': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-shorthand-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-spread': 7.14.6_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- '@babel/register': 7.15.3_@babel+core@7.15.0
- '@storybook/node-logger': 6.3.7
- '@storybook/semver': 7.3.2
- '@types/glob-base': 0.3.0
- '@types/micromatch': 4.0.2
- '@types/node': 14.17.10
- '@types/pretty-hrtime': 1.0.1
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- babel-plugin-macros: 3.1.0
- babel-plugin-polyfill-corejs3: 0.1.7_@babel+core@7.15.0
- chalk: 4.1.2
- core-js: 3.16.2
- express: 4.17.1
- file-system-cache: 1.0.5
- find-up: 5.0.0
- fork-ts-checker-webpack-plugin: 6.3.2
- glob: 7.1.7
- glob-base: 0.3.0
- interpret: 2.2.0
- json5: 2.2.0
- lazy-universal-dotenv: 3.0.1
- micromatch: 4.0.4
- pkg-dir: 5.0.0
- pretty-hrtime: 1.0.3
- resolve-from: 5.0.0
- ts-dedent: 2.2.0
- typescript: 3.9.10
- util-deprecate: 1.0.2
- webpack: 4.46.0
+ /@linaria/babel-preset@5.0.4:
+ resolution: {integrity: sha512-OMhlD6gc/+6DFLkadoavlxCtTIElo/UdDMeQH6I/CAL3hgfmHyIXJPrGObTa7jvQKddUaKvFIHGAVB7pz6J8Qg==}
+ engines: {node: '>=16.0.0'}
+ dependencies:
+ '@babel/core': 7.23.5
+ '@babel/generator': 7.23.5
+ '@babel/helper-module-imports': 7.22.15
+ '@babel/template': 7.22.15
+ '@babel/traverse': 7.23.5
+ '@babel/types': 7.23.5
+ '@linaria/core': 5.0.2
+ '@linaria/logger': 5.0.0
+ '@linaria/shaker': 5.0.3
+ '@linaria/tags': 5.0.2
+ '@linaria/utils': 5.0.2
+ cosmiconfig: 8.1.3
+ happy-dom: 10.8.0
+ source-map: 0.7.4
+ stylis: 3.5.4
+ ts-invariant: 0.10.3
transitivePeerDependencies:
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/core-common/6.3.7_typescript@4.3.5:
- resolution: {integrity: sha512-exLoqRPPsAefwyjbsQBLNFrlPCcv69Q/pclqmIm7FqAPR7f3CKP1rqsHY0PnemizTL/+cLX5S7mY90gI6wpNug==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@linaria/core@3.0.0-beta.22:
+ resolution: {integrity: sha512-BPSecW8QmhQ0y+5cWXEja+MTmLsuo0T1PjqRlSWsmDgjJFFObqCnPEgbR1KNtQb3Msmx1/9q3dYKpA5Zk3g8KQ==}
+ engines: {node: ^12.16.0 || >=13.7.0}
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-decorators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-export-default-from': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-nullish-coalescing-operator': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-proposal-optional-chaining': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-private-methods': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-arrow-functions': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-block-scoping': 7.15.3_@babel+core@7.15.0
- '@babel/plugin-transform-classes': 7.14.9_@babel+core@7.15.0
- '@babel/plugin-transform-destructuring': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-transform-for-of': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-parameters': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-shorthand-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-spread': 7.14.6_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- '@babel/register': 7.15.3_@babel+core@7.15.0
- '@storybook/node-logger': 6.3.7
- '@storybook/semver': 7.3.2
- '@types/glob-base': 0.3.0
- '@types/micromatch': 4.0.2
- '@types/node': 14.17.10
- '@types/pretty-hrtime': 1.0.1
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- babel-plugin-macros: 3.1.0
- babel-plugin-polyfill-corejs3: 0.1.7_@babel+core@7.15.0
- chalk: 4.1.2
- core-js: 3.16.2
- express: 4.17.1
- file-system-cache: 1.0.5
- find-up: 5.0.0
- fork-ts-checker-webpack-plugin: 6.3.2
- glob: 7.1.7
- glob-base: 0.3.0
- interpret: 2.2.0
- json5: 2.2.0
- lazy-universal-dotenv: 3.0.1
- micromatch: 4.0.4
- pkg-dir: 5.0.0
- pretty-hrtime: 1.0.3
- resolve-from: 5.0.0
- ts-dedent: 2.2.0
- typescript: 4.3.5
- util-deprecate: 1.0.2
- webpack: 4.46.0
+ '@linaria/logger': 3.0.0-beta.20
+ '@linaria/utils': 3.0.0-beta.20
transitivePeerDependencies:
- supports-color
- - webpack-cli
- - webpack-command
- dev: true
-
- /@storybook/core-events/6.3.12:
- resolution: {integrity: sha512-SXfD7xUUMazaeFkB92qOTUV8Y/RghE4SkEYe5slAdjeocSaH7Nz2WV0rqNEgChg0AQc+JUI66no8L9g0+lw4Gw==}
- dependencies:
- core-js: 3.16.2
dev: true
- /@storybook/core-events/6.3.7:
- resolution: {integrity: sha512-l5Hlhe+C/dqxjobemZ6DWBhTOhQoFF3F1Y4kjFGE7pGZl/mas4M72I5I/FUcYCmbk2fbLfZX8hzKkUqS1hdyLA==}
+ /@linaria/core@5.0.2:
+ resolution: {integrity: sha512-l5jQq7w9kDvonfr/0MBF47Dagx9Y9f/o5Q8j3zv7GepwG/yHQdbjKr0tq07rx2fSDDX7Nbqlxk6k9Ymir/NGpg==}
+ engines: {node: '>=16.0.0'}
dependencies:
- core-js: 3.16.2
+ '@linaria/logger': 5.0.0
+ '@linaria/tags': 5.0.2
+ '@linaria/utils': 5.0.2
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /@storybook/core-server/6.3.7_36f75bb62e0c484c1a06658ad2872463:
- resolution: {integrity: sha512-m5OPD/rmZA7KFewkXzXD46/i1ngUoFO4LWOiAY/wR6RQGjYXGMhSa5UYFF6MNwSbiGS5YieHkR5crB1HP47AhQ==}
- peerDependencies:
- '@storybook/builder-webpack5': 6.3.7
- '@storybook/manager-webpack5': 6.3.7
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- '@storybook/builder-webpack5':
- optional: true
- '@storybook/manager-webpack5':
- optional: true
- typescript:
- optional: true
+ /@linaria/esbuild@3.0.0-beta.23:
+ resolution: {integrity: sha512-5hYMPSXo/dsRwq/UszRa17vL5mARa9e/+qKkG8loju0DNN/73kyVkGRcVHw+EMftXnY2x3y7RIJ3E8fWtFvDfg==}
+ engines: {node: ^12.16.0 || >=13.7.0}
dependencies:
- '@storybook/builder-webpack4': 6.3.7_typescript@3.9.10
- '@storybook/core-client': 6.3.7_typescript@3.9.10+webpack@4.46.0
- '@storybook/core-common': 6.3.7_typescript@3.9.10
- '@storybook/csf-tools': 6.3.7_@babel+core@7.15.0
- '@storybook/manager-webpack4': 6.3.7_typescript@3.9.10
- '@storybook/node-logger': 6.3.7
- '@storybook/semver': 7.3.2
- '@types/node': 14.17.10
- '@types/node-fetch': 2.5.12
- '@types/pretty-hrtime': 1.0.1
- '@types/webpack': 4.41.30
- better-opn: 2.1.1
- boxen: 4.2.0
- chalk: 4.1.2
- cli-table3: 0.6.0
- commander: 6.2.1
- compression: 1.7.4
- core-js: 3.16.2
- cpy: 8.1.2
- detect-port: 1.3.0
- express: 4.17.1
- file-system-cache: 1.0.5
- fs-extra: 9.1.0
- globby: 11.0.4
- ip: 1.1.5
- node-fetch: 2.6.1
- pretty-hrtime: 1.0.3
- prompts: 2.4.1
- regenerator-runtime: 0.13.9
- serve-favicon: 2.5.0
- ts-dedent: 2.2.0
- typescript: 3.9.10
- util-deprecate: 1.0.2
- webpack: 4.46.0
+ '@babel/core': 7.22.1
+ '@linaria/babel-preset': 3.0.0-beta.23
+ esbuild: 0.12.29
transitivePeerDependencies:
- - '@babel/core'
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/core-server/6.3.7_6b8328ae33be7bccbaedcbeca6bc1253:
- resolution: {integrity: sha512-m5OPD/rmZA7KFewkXzXD46/i1ngUoFO4LWOiAY/wR6RQGjYXGMhSa5UYFF6MNwSbiGS5YieHkR5crB1HP47AhQ==}
+ /@linaria/esbuild@5.0.4(esbuild@0.19.9):
+ resolution: {integrity: sha512-sIPxeH3TQrIfNBz3wCtxTcu/M5dS2SOBSFps+3EVz1LOkIdy5YAOSWL1i1KWUavSg1cs467Ujxq9Nu79k1SayQ==}
+ engines: {node: '>=16.0.0'}
peerDependencies:
- '@storybook/builder-webpack5': 6.3.7
- '@storybook/manager-webpack5': 6.3.7
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- '@storybook/builder-webpack5':
- optional: true
- '@storybook/manager-webpack5':
- optional: true
- typescript:
- optional: true
+ esbuild: '>=0.12.0'
dependencies:
- '@storybook/builder-webpack4': 6.3.7_6b8328ae33be7bccbaedcbeca6bc1253
- '@storybook/core-client': 6.3.7_3c33386fd9b1d5f07f48f07869b17b73
- '@storybook/core-common': 6.3.7_6b8328ae33be7bccbaedcbeca6bc1253
- '@storybook/csf-tools': 6.3.7
- '@storybook/manager-webpack4': 6.3.7_6b8328ae33be7bccbaedcbeca6bc1253
- '@storybook/node-logger': 6.3.7
- '@storybook/semver': 7.3.2
- '@types/node': 14.17.10
- '@types/node-fetch': 2.5.12
- '@types/pretty-hrtime': 1.0.1
- '@types/webpack': 4.41.30
- better-opn: 2.1.1
- boxen: 4.2.0
- chalk: 4.1.2
- cli-table3: 0.6.0
- commander: 6.2.1
- compression: 1.7.4
- core-js: 3.16.2
- cpy: 8.1.2
- detect-port: 1.3.0
- express: 4.17.1
- file-system-cache: 1.0.5
- fs-extra: 9.1.0
- globby: 11.0.4
- ip: 1.1.5
- node-fetch: 2.6.1
- pretty-hrtime: 1.0.3
- prompts: 2.4.1
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- regenerator-runtime: 0.13.9
- serve-favicon: 2.5.0
- ts-dedent: 2.2.0
- typescript: 3.9.10
- util-deprecate: 1.0.2
- webpack: 4.46.0
+ '@babel/core': 7.23.5
+ '@linaria/babel-preset': 5.0.4
+ '@linaria/utils': 5.0.2
+ esbuild: 0.19.9
transitivePeerDependencies:
- - '@babel/core'
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/core-server/6.3.7_f0b419a7e119055c71dcaf6063a7ba7a:
- resolution: {integrity: sha512-m5OPD/rmZA7KFewkXzXD46/i1ngUoFO4LWOiAY/wR6RQGjYXGMhSa5UYFF6MNwSbiGS5YieHkR5crB1HP47AhQ==}
- peerDependencies:
- '@storybook/builder-webpack5': 6.3.7
- '@storybook/manager-webpack5': 6.3.7
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- '@storybook/builder-webpack5':
- optional: true
- '@storybook/manager-webpack5':
- optional: true
- typescript:
- optional: true
+ /@linaria/logger@3.0.0-beta.20:
+ resolution: {integrity: sha512-wCxWnldCHf7HXdLG3QtbKyBur+z5V1qZTouSEvcVYDfd4aSRPOi/jLdwsZlsUq2PFGpA3jW6JnreZJ/vxuEl7g==}
+ engines: {node: ^12.16.0 || >=13.7.0}
dependencies:
- '@storybook/builder-webpack4': 6.3.7_typescript@4.3.5
- '@storybook/core-client': 6.3.7_typescript@4.3.5+webpack@4.46.0
- '@storybook/core-common': 6.3.7_typescript@4.3.5
- '@storybook/csf-tools': 6.3.7_@babel+core@7.15.0
- '@storybook/manager-webpack4': 6.3.7_typescript@4.3.5
- '@storybook/node-logger': 6.3.7
- '@storybook/semver': 7.3.2
- '@types/node': 14.17.10
- '@types/node-fetch': 2.5.12
- '@types/pretty-hrtime': 1.0.1
- '@types/webpack': 4.41.30
- better-opn: 2.1.1
- boxen: 4.2.0
- chalk: 4.1.2
- cli-table3: 0.6.0
- commander: 6.2.1
- compression: 1.7.4
- core-js: 3.16.2
- cpy: 8.1.2
- detect-port: 1.3.0
- express: 4.17.1
- file-system-cache: 1.0.5
- fs-extra: 9.1.0
- globby: 11.0.4
- ip: 1.1.5
- node-fetch: 2.6.1
- pretty-hrtime: 1.0.3
- prompts: 2.4.1
- regenerator-runtime: 0.13.9
- serve-favicon: 2.5.0
- ts-dedent: 2.2.0
- typescript: 4.3.5
- util-deprecate: 1.0.2
- webpack: 4.46.0
+ debug: 4.3.4
+ picocolors: 1.0.0
transitivePeerDependencies:
- - '@babel/core'
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/core-server/6.3.7_f904207d8bce108657a1649c78f72ef8:
- resolution: {integrity: sha512-m5OPD/rmZA7KFewkXzXD46/i1ngUoFO4LWOiAY/wR6RQGjYXGMhSa5UYFF6MNwSbiGS5YieHkR5crB1HP47AhQ==}
- peerDependencies:
- '@storybook/builder-webpack5': 6.3.7
- '@storybook/manager-webpack5': 6.3.7
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- '@storybook/builder-webpack5':
- optional: true
- '@storybook/manager-webpack5':
- optional: true
- typescript:
- optional: true
+ /@linaria/logger@5.0.0:
+ resolution: {integrity: sha512-PZd5H0I4F84U0kXSE+vD75ltIGDxEA6gMDNWS2aDZFitmdlQM5rIXqvKFrp5NsHa7a3AH+I2Hxm0dg60WZF7vg==}
+ engines: {node: '>=16.0.0'}
dependencies:
- '@storybook/builder-webpack4': 6.3.7_8073bd74a106ff14517e8eecceb690e6
- '@storybook/core-client': 6.3.7_70d39b49be06a92d1ddadaf2d0a6de02
- '@storybook/core-common': 6.3.7_8073bd74a106ff14517e8eecceb690e6
- '@storybook/csf-tools': 6.3.7_@babel+core@7.13.16
- '@storybook/manager-webpack4': 6.3.7_8073bd74a106ff14517e8eecceb690e6
- '@storybook/node-logger': 6.3.7
- '@storybook/semver': 7.3.2
- '@types/node': 14.17.10
- '@types/node-fetch': 2.5.12
- '@types/pretty-hrtime': 1.0.1
- '@types/webpack': 4.41.30
- better-opn: 2.1.1
- boxen: 4.2.0
- chalk: 4.1.2
- cli-table3: 0.6.0
- commander: 6.2.1
- compression: 1.7.4
- core-js: 3.16.2
- cpy: 8.1.2
- detect-port: 1.3.0
- express: 4.17.1
- file-system-cache: 1.0.5
- fs-extra: 9.1.0
- globby: 11.0.4
- ip: 1.1.5
- node-fetch: 2.6.1
- pretty-hrtime: 1.0.3
- prompts: 2.4.1
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- regenerator-runtime: 0.13.9
- serve-favicon: 2.5.0
- ts-dedent: 2.2.0
- typescript: 4.3.5
- util-deprecate: 1.0.2
- webpack: 4.46.0
+ debug: 4.3.4
+ picocolors: 1.0.0
transitivePeerDependencies:
- - '@babel/core'
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/core/6.3.7_36f75bb62e0c484c1a06658ad2872463:
- resolution: {integrity: sha512-YTVLPXqgyBg7TALNxQ+cd+GtCm/NFjxr/qQ1mss1T9GCMR0IjE0d0trgOVHHLAO8jCVlK8DeuqZCCgZFTXulRw==}
- peerDependencies:
- '@storybook/builder-webpack5': 6.3.7
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- '@storybook/builder-webpack5':
- optional: true
- typescript:
- optional: true
+ /@linaria/preeval@3.0.0-beta.23:
+ resolution: {integrity: sha512-TAIN6GPFCahoIH3FHsHk5NM+iaBp5Snniqirk8mailjGGprswl14Z7lgGPzzKZiA5HBzWKW4Wpe1N8W2vSjJTw==}
+ engines: {node: ^12.16.0 || >=13.7.0}
dependencies:
- '@storybook/core-client': 6.3.7_typescript@3.9.10
- '@storybook/core-server': 6.3.7_36f75bb62e0c484c1a06658ad2872463
- typescript: 3.9.10
+ '@linaria/babel-preset': 3.0.0-beta.23
transitivePeerDependencies:
- - '@babel/core'
- - '@storybook/manager-webpack5'
- - '@types/react'
- supports-color
- - webpack
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/core/6.3.7_6b8328ae33be7bccbaedcbeca6bc1253:
- resolution: {integrity: sha512-YTVLPXqgyBg7TALNxQ+cd+GtCm/NFjxr/qQ1mss1T9GCMR0IjE0d0trgOVHHLAO8jCVlK8DeuqZCCgZFTXulRw==}
+ /@linaria/react@3.0.0-beta.22(react@18.2.0):
+ resolution: {integrity: sha512-14rnb/zkzhFhJM3hbBOzLLS0bu01mOg23Rv2nGQUt5CWd+HOhksmqzqBtC/ijeVlY2hRI0rJJcng9r07LGGAPA==}
+ engines: {node: ^12.16.0 || >=13.7.0}
peerDependencies:
- '@storybook/builder-webpack5': 6.3.7
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- '@storybook/builder-webpack5':
- optional: true
- typescript:
- optional: true
+ react: '>=16'
dependencies:
- '@storybook/core-client': 6.3.7_6b8328ae33be7bccbaedcbeca6bc1253
- '@storybook/core-server': 6.3.7_6b8328ae33be7bccbaedcbeca6bc1253
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- typescript: 3.9.10
+ '@emotion/is-prop-valid': 0.8.8
+ '@linaria/core': 3.0.0-beta.22
+ react: 18.2.0
+ ts-invariant: 0.10.3
transitivePeerDependencies:
- - '@babel/core'
- - '@storybook/manager-webpack5'
- - '@types/react'
- supports-color
- - webpack
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/core/6.3.7_f0b419a7e119055c71dcaf6063a7ba7a:
- resolution: {integrity: sha512-YTVLPXqgyBg7TALNxQ+cd+GtCm/NFjxr/qQ1mss1T9GCMR0IjE0d0trgOVHHLAO8jCVlK8DeuqZCCgZFTXulRw==}
+ /@linaria/react@5.0.3(react@18.2.0):
+ resolution: {integrity: sha512-faTQHnUlrAz0Lodu+rr6Yx59rX0nqFOrZ5/IdlfQcTRz9VebyVL4vtA3AOecmn1YFZjMpqjopT0OzNz6GknQSg==}
+ engines: {node: '>=16.0.0'}
peerDependencies:
- '@storybook/builder-webpack5': 6.3.7
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- '@storybook/builder-webpack5':
- optional: true
- typescript:
- optional: true
+ react: '>=16'
dependencies:
- '@storybook/core-client': 6.3.7_typescript@4.3.5
- '@storybook/core-server': 6.3.7_f0b419a7e119055c71dcaf6063a7ba7a
- typescript: 4.3.5
+ '@emotion/is-prop-valid': 1.2.1
+ '@linaria/core': 5.0.2
+ '@linaria/tags': 5.0.2
+ '@linaria/utils': 5.0.2
+ minimatch: 9.0.3
+ react: 18.2.0
+ react-html-attributes: 1.4.6
+ ts-invariant: 0.10.3
transitivePeerDependencies:
- - '@babel/core'
- - '@storybook/manager-webpack5'
- - '@types/react'
- supports-color
- - webpack
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/core/6.3.7_f904207d8bce108657a1649c78f72ef8:
- resolution: {integrity: sha512-YTVLPXqgyBg7TALNxQ+cd+GtCm/NFjxr/qQ1mss1T9GCMR0IjE0d0trgOVHHLAO8jCVlK8DeuqZCCgZFTXulRw==}
- peerDependencies:
- '@storybook/builder-webpack5': 6.3.7
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- '@storybook/builder-webpack5':
- optional: true
- typescript:
- optional: true
+ /@linaria/shaker@3.0.0-beta.22:
+ resolution: {integrity: sha512-NOi71i/XfBJpBOT5eepRvv6B64IMdjsKwv+vxLW+IuFHx3wnqXgZsgimNK2qoXbpqy9xWsSEeB/4QA4m8GCUKQ==}
+ engines: {node: ^12.16.0 || >=13.7.0}
dependencies:
- '@storybook/core-client': 6.3.7_8073bd74a106ff14517e8eecceb690e6
- '@storybook/core-server': 6.3.7_f904207d8bce108657a1649c78f72ef8
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- typescript: 4.3.5
+ '@babel/core': 7.18.9
+ '@babel/generator': 7.23.5
+ '@babel/plugin-transform-runtime': 7.23.4(@babel/core@7.18.9)
+ '@babel/plugin-transform-template-literals': 7.23.3(@babel/core@7.18.9)
+ '@babel/preset-env': 7.23.5(@babel/core@7.18.9)
+ '@linaria/babel-preset': 3.0.0-beta.22
+ '@linaria/logger': 3.0.0-beta.20
+ '@linaria/preeval': 3.0.0-beta.23
+ babel-plugin-transform-react-remove-prop-types: 0.4.24
+ ts-invariant: 0.10.3
transitivePeerDependencies:
- - '@babel/core'
- - '@storybook/manager-webpack5'
- - '@types/react'
- supports-color
- - webpack
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/csf-tools/6.3.7:
- resolution: {integrity: sha512-A7yGsrYwh+vwVpmG8dHpEimX021RbZd9VzoREcILH56u8atssdh/rseljyWlRes3Sr4QgtLvDB7ggoJ+XDZH7w==}
+ /@linaria/shaker@3.0.0-beta.23:
+ resolution: {integrity: sha512-kG57X747GM/CqTs+wYx6hMHgzVNt7U/ydh7iO/NwUjIunr359oWwdH2Zjq27xR58TvK9sYXfmFVS8w3IB9fRWQ==}
+ engines: {node: ^12.16.0 || >=13.7.0}
dependencies:
- '@babel/generator': 7.15.0
- '@babel/parser': 7.15.3
- '@babel/plugin-transform-react-jsx': 7.14.9
- '@babel/preset-env': 7.15.0
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
- '@mdx-js/mdx': 1.6.22
- '@storybook/csf': 0.0.1
- core-js: 3.16.2
- fs-extra: 9.1.0
- js-string-escape: 1.0.1
- lodash: 4.17.21
- prettier: 2.2.1
- regenerator-runtime: 0.13.9
+ '@babel/core': 7.22.1
+ '@babel/generator': 7.19.6
+ '@babel/plugin-transform-runtime': 7.19.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-template-literals': 7.18.9(@babel/core@7.22.1)
+ '@babel/preset-env': 7.19.4(@babel/core@7.22.1)
+ '@linaria/babel-preset': 3.0.0-beta.23
+ '@linaria/logger': 3.0.0-beta.20
+ '@linaria/preeval': 3.0.0-beta.23
+ babel-plugin-transform-react-remove-prop-types: 0.4.24
+ ts-invariant: 0.10.3
transitivePeerDependencies:
- - '@babel/core'
- supports-color
dev: true
- /@storybook/csf-tools/6.3.7_@babel+core@7.13.16:
- resolution: {integrity: sha512-A7yGsrYwh+vwVpmG8dHpEimX021RbZd9VzoREcILH56u8atssdh/rseljyWlRes3Sr4QgtLvDB7ggoJ+XDZH7w==}
+ /@linaria/shaker@5.0.3:
+ resolution: {integrity: sha512-2a3pzYs09Iz88e+VG4OAQVRSIjxkbj7S4ju81ZTJVbZIWSR1kGsbX5OtJkRrh/AbKRrrUMW0DBS4PPgd0fks4A==}
+ engines: {node: '>=16.0.0'}
dependencies:
- '@babel/generator': 7.15.0
- '@babel/parser': 7.15.3
- '@babel/plugin-transform-react-jsx': 7.14.9_@babel+core@7.13.16
- '@babel/preset-env': 7.15.0_@babel+core@7.13.16
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
- '@mdx-js/mdx': 1.6.22
- '@storybook/csf': 0.0.1
- core-js: 3.16.2
- fs-extra: 9.1.0
- js-string-escape: 1.0.1
- lodash: 4.17.21
- prettier: 2.2.1
- regenerator-runtime: 0.13.9
+ '@babel/core': 7.23.5
+ '@babel/generator': 7.23.5
+ '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.23.5)
+ '@babel/plugin-transform-runtime': 7.23.4(@babel/core@7.23.5)
+ '@babel/plugin-transform-template-literals': 7.23.3(@babel/core@7.23.5)
+ '@babel/preset-env': 7.23.5(@babel/core@7.23.5)
+ '@linaria/logger': 5.0.0
+ '@linaria/utils': 5.0.2
+ babel-plugin-transform-react-remove-prop-types: 0.4.24
+ ts-invariant: 0.10.3
transitivePeerDependencies:
- - '@babel/core'
- supports-color
dev: true
- /@storybook/csf-tools/6.3.7_@babel+core@7.15.0:
- resolution: {integrity: sha512-A7yGsrYwh+vwVpmG8dHpEimX021RbZd9VzoREcILH56u8atssdh/rseljyWlRes3Sr4QgtLvDB7ggoJ+XDZH7w==}
+ /@linaria/tags@5.0.2:
+ resolution: {integrity: sha512-opcORl2sA6WkBjTNLHTgpet97dNKnwPRX/QGGZMykBsvGH3AsnEg/bT31cKBMBhL+YBGQsCdBmolxvCkWPOXQw==}
+ engines: {node: '>=16.0.0'}
dependencies:
- '@babel/generator': 7.15.0
- '@babel/parser': 7.15.3
- '@babel/plugin-transform-react-jsx': 7.14.9_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
- '@mdx-js/mdx': 1.6.22
- '@storybook/csf': 0.0.1
- core-js: 3.16.2
- fs-extra: 9.1.0
- js-string-escape: 1.0.1
- lodash: 4.17.21
- prettier: 2.2.1
- regenerator-runtime: 0.13.9
+ '@babel/generator': 7.23.5
+ '@linaria/logger': 5.0.0
+ '@linaria/utils': 5.0.2
transitivePeerDependencies:
- - '@babel/core'
- supports-color
dev: true
- /@storybook/csf/0.0.1:
- resolution: {integrity: sha512-USTLkZze5gkel8MYCujSRBVIrUQ3YPBrLOx7GNk/0wttvVtlzWXAq9eLbQ4p/NicGxP+3T7KPEMVV//g+yubpw==}
- dependencies:
- lodash: 4.17.21
+ /@linaria/utils@3.0.0-beta.20:
+ resolution: {integrity: sha512-SKRC9dBApzu0kTksVtGZ7eJz1vMu7xew/JEAjQj6XTQDblzWpTPyKQHBOGXNkqXjIB8PwAqWfvKzKapzaOwQaQ==}
+ engines: {node: ^12.16.0 || >=13.7.0}
dev: true
- /@storybook/manager-webpack4/6.3.7_6b8328ae33be7bccbaedcbeca6bc1253:
- resolution: {integrity: sha512-cwUdO3oklEtx6y+ZOl2zHvflICK85emiXBQGgRcCsnwWQRBZOMh+tCgOSZj4jmISVpT52RtT9woG4jKe15KBig==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@linaria/utils@5.0.2:
+ resolution: {integrity: sha512-ZL8Yz4IIr9A8a5+o5LRnEpgdzIkyj4yKJrhw5Zv8wwvL+d/MHUK0q+l/KvxBmuYdcF+YYVHZ9eeBHTQBSL7r/w==}
+ engines: {node: '>=16.0.0'}
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-transform-template-literals': 7.14.5_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/core-client': 6.3.7_3c33386fd9b1d5f07f48f07869b17b73
- '@storybook/core-common': 6.3.7_6b8328ae33be7bccbaedcbeca6bc1253
- '@storybook/node-logger': 6.3.7
- '@storybook/theming': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/ui': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@types/node': 14.17.10
- '@types/webpack': 4.41.30
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- case-sensitive-paths-webpack-plugin: 2.4.0
- chalk: 4.1.2
- core-js: 3.16.2
- css-loader: 3.6.0_webpack@4.46.0
- dotenv-webpack: 1.8.0_webpack@4.46.0
- express: 4.17.1
- file-loader: 6.2.0_webpack@4.46.0
- file-system-cache: 1.0.5
+ '@babel/core': 7.23.5
+ '@babel/generator': 7.23.5
+ '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.23.5)
+ '@babel/template': 7.22.15
+ '@babel/traverse': 7.23.5
+ '@babel/types': 7.23.5
+ '@linaria/logger': 5.0.0
+ babel-merge: 3.0.0(@babel/core@7.23.5)
find-up: 5.0.0
- fs-extra: 9.1.0
- html-webpack-plugin: 4.5.2_webpack@4.46.0
- node-fetch: 2.6.1
- pnp-webpack-plugin: 1.6.4_typescript@3.9.10
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- read-pkg-up: 7.0.1
- regenerator-runtime: 0.13.9
- resolve-from: 5.0.0
- style-loader: 1.3.0_webpack@4.46.0
- telejson: 5.3.3
- terser-webpack-plugin: 4.2.3_webpack@4.46.0
- ts-dedent: 2.2.0
- typescript: 3.9.10
- url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- webpack-dev-middleware: 3.7.3_webpack@4.46.0
- webpack-virtual-modules: 0.2.2
+ minimatch: 9.0.3
transitivePeerDependencies:
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/manager-webpack4/6.3.7_8073bd74a106ff14517e8eecceb690e6:
- resolution: {integrity: sha512-cwUdO3oklEtx6y+ZOl2zHvflICK85emiXBQGgRcCsnwWQRBZOMh+tCgOSZj4jmISVpT52RtT9woG4jKe15KBig==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ /@linaria/webpack-loader@3.0.0-beta.22(webpack@4.47.0):
+ resolution: {integrity: sha512-oSChk+9MfcoF1M3Thx++aB1IjAaq7gS643i4995GSm1fs53i6QeUpCvIlWClDtRADmBzHSdMKIt0/vLoESvBoQ==}
+ engines: {node: ^12.16.0 || >=13.7.0}
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-transform-template-literals': 7.14.5_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/core-client': 6.3.7_70d39b49be06a92d1ddadaf2d0a6de02
- '@storybook/core-common': 6.3.7_8073bd74a106ff14517e8eecceb690e6
- '@storybook/node-logger': 6.3.7
- '@storybook/theming': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/ui': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@types/node': 14.17.10
- '@types/webpack': 4.41.30
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- case-sensitive-paths-webpack-plugin: 2.4.0
- chalk: 4.1.2
- core-js: 3.16.2
- css-loader: 3.6.0_webpack@4.46.0
- dotenv-webpack: 1.8.0_webpack@4.46.0
- express: 4.17.1
- file-loader: 6.2.0_webpack@4.46.0
- file-system-cache: 1.0.5
- find-up: 5.0.0
- fs-extra: 9.1.0
- html-webpack-plugin: 4.5.2_webpack@4.46.0
- node-fetch: 2.6.1
- pnp-webpack-plugin: 1.6.4_typescript@4.3.5
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- read-pkg-up: 7.0.1
- regenerator-runtime: 0.13.9
- resolve-from: 5.0.0
- style-loader: 1.3.0_webpack@4.46.0
- telejson: 5.3.3
- terser-webpack-plugin: 4.2.3_webpack@4.46.0
- ts-dedent: 2.2.0
- typescript: 4.3.5
- url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- webpack-dev-middleware: 3.7.3_webpack@4.46.0
- webpack-virtual-modules: 0.2.2
+ '@linaria/webpack4-loader': 3.0.0-beta.23(webpack@4.47.0)
+ '@linaria/webpack5-loader': 3.0.0-beta.23(webpack@4.47.0)
transitivePeerDependencies:
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
+ - webpack
dev: true
- /@storybook/manager-webpack4/6.3.7_typescript@3.9.10:
- resolution: {integrity: sha512-cwUdO3oklEtx6y+ZOl2zHvflICK85emiXBQGgRcCsnwWQRBZOMh+tCgOSZj4jmISVpT52RtT9woG4jKe15KBig==}
+ /@linaria/webpack4-loader@3.0.0-beta.23(webpack@4.47.0):
+ resolution: {integrity: sha512-I1pwrRKpGCARWbPwTFqOKLrkyxrZ+huYC3WH4pMllfoY+fv3O2dmDH6vKrZ582mQ5Uo/H3FmHBt8CLaMBv3pmg==}
+ engines: {node: ^12.16.0 || >=13.7.0}
peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ webpack: '>=4.0.0 <5.0.0'
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-transform-template-literals': 7.14.5_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@storybook/addons': 6.3.7
- '@storybook/core-client': 6.3.7_typescript@3.9.10+webpack@4.46.0
- '@storybook/core-common': 6.3.7_typescript@3.9.10
- '@storybook/node-logger': 6.3.7
- '@storybook/theming': 6.3.7
- '@storybook/ui': 6.3.7
- '@types/node': 14.17.10
- '@types/webpack': 4.41.30
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- case-sensitive-paths-webpack-plugin: 2.4.0
- chalk: 4.1.2
- core-js: 3.16.2
- css-loader: 3.6.0_webpack@4.46.0
- dotenv-webpack: 1.8.0_webpack@4.46.0
- express: 4.17.1
- file-loader: 6.2.0_webpack@4.46.0
- file-system-cache: 1.0.5
- find-up: 5.0.0
- fs-extra: 9.1.0
- html-webpack-plugin: 4.5.2_webpack@4.46.0
- node-fetch: 2.6.1
- pnp-webpack-plugin: 1.6.4_typescript@3.9.10
- read-pkg-up: 7.0.1
- regenerator-runtime: 0.13.9
- resolve-from: 5.0.0
- style-loader: 1.3.0_webpack@4.46.0
- telejson: 5.3.3
- terser-webpack-plugin: 4.2.3_webpack@4.46.0
- ts-dedent: 2.2.0
- typescript: 3.9.10
- url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- webpack-dev-middleware: 3.7.3_webpack@4.46.0
- webpack-virtual-modules: 0.2.2
+ '@linaria/babel-preset': 3.0.0-beta.23
+ '@linaria/logger': 3.0.0-beta.20
+ enhanced-resolve: 4.5.0
+ loader-utils: 1.4.0
+ mkdirp: 0.5.6
+ webpack: 4.47.0
transitivePeerDependencies:
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/manager-webpack4/6.3.7_typescript@4.3.5:
- resolution: {integrity: sha512-cwUdO3oklEtx6y+ZOl2zHvflICK85emiXBQGgRcCsnwWQRBZOMh+tCgOSZj4jmISVpT52RtT9woG4jKe15KBig==}
+ /@linaria/webpack5-loader@3.0.0-beta.23(webpack@4.47.0):
+ resolution: {integrity: sha512-yIjhnDT1otwfx6JAA9HNfDzim7N93z9++8apzXE57GXg5wRO2hlajruatclpUDcMOsodS9p2+mMXd8GGR8CGCA==}
+ engines: {node: ^12.16.0 || >=13.7.0}
peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ webpack: ^5.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-transform-template-literals': 7.14.5_@babel+core@7.15.0
- '@babel/preset-react': 7.14.5_@babel+core@7.15.0
- '@storybook/addons': 6.3.7
- '@storybook/core-client': 6.3.7_typescript@4.3.5+webpack@4.46.0
- '@storybook/core-common': 6.3.7_typescript@4.3.5
- '@storybook/node-logger': 6.3.7
- '@storybook/theming': 6.3.7
- '@storybook/ui': 6.3.7
- '@types/node': 14.17.10
- '@types/webpack': 4.41.30
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- case-sensitive-paths-webpack-plugin: 2.4.0
- chalk: 4.1.2
- core-js: 3.16.2
- css-loader: 3.6.0_webpack@4.46.0
- dotenv-webpack: 1.8.0_webpack@4.46.0
- express: 4.17.1
- file-loader: 6.2.0_webpack@4.46.0
- file-system-cache: 1.0.5
- find-up: 5.0.0
- fs-extra: 9.1.0
- html-webpack-plugin: 4.5.2_webpack@4.46.0
- node-fetch: 2.6.1
- pnp-webpack-plugin: 1.6.4_typescript@4.3.5
- read-pkg-up: 7.0.1
- regenerator-runtime: 0.13.9
- resolve-from: 5.0.0
- style-loader: 1.3.0_webpack@4.46.0
- telejson: 5.3.3
- terser-webpack-plugin: 4.2.3_webpack@4.46.0
- ts-dedent: 2.2.0
- typescript: 4.3.5
- url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
- util-deprecate: 1.0.2
- webpack: 4.46.0
- webpack-dev-middleware: 3.7.3_webpack@4.46.0
- webpack-virtual-modules: 0.2.2
+ '@linaria/babel-preset': 3.0.0-beta.23
+ '@linaria/logger': 3.0.0-beta.20
+ enhanced-resolve: 5.10.0
+ mkdirp: 0.5.6
+ webpack: 4.47.0
transitivePeerDependencies:
- - '@types/react'
- supports-color
- - webpack-cli
- - webpack-command
dev: true
- /@storybook/node-logger/6.3.7:
- resolution: {integrity: sha512-YXHCblruRe6HcNefDOpuXJoaybHnnSryIVP9Z+gDv6OgLAMkyxccTIaQL9dbc/eI4ywgzAz4kD8t1RfVwXNVXw==}
+ /@mapbox/node-pre-gyp@1.0.11:
+ resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
+ hasBin: true
dependencies:
- '@types/npmlog': 4.1.3
- chalk: 4.1.2
- core-js: 3.16.2
- npmlog: 4.1.2
- pretty-hrtime: 1.0.3
+ detect-libc: 2.0.2
+ https-proxy-agent: 5.0.1
+ make-dir: 3.1.0
+ node-fetch: 2.7.0
+ nopt: 5.0.0
+ npmlog: 5.0.1
+ rimraf: 3.0.2
+ semver: 7.5.4
+ tar: 6.2.0
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
dev: true
- /@storybook/postinstall/6.3.7:
- resolution: {integrity: sha512-HgTj7WdWo2cXrGfEhi5XYZA+G4vIzECtJHK22GEL9QxJth60Ah/dE94VqpTlyhSpzP74ZFUgr92+pP9o+j3CCw==}
- dependencies:
- core-js: 3.16.2
+ /@mdn/browser-compat-data@3.3.14:
+ resolution: {integrity: sha512-n2RC9d6XatVbWFdHLimzzUJxJ1KY8LdjqrW6YvGPiRmsHkhOUx74/Ct10x5Yo7bC/Jvqx7cDEW8IMPv/+vwEzA==}
dev: true
- /@storybook/preact/6.3.7_9cd0ede338ef3d2deb8dbc69bc115c66:
- resolution: {integrity: sha512-mP6+e1toCd59ALUNsiJWQN0CuOVV7faaMcAs21T+GJeU5igEWbRpe/ixKdMdu7RqHA9jAHOeMZfjSNhBkvnwAw==}
- engines: {node: '>=10.13.0'}
- hasBin: true
- peerDependencies:
- '@babel/core': '*'
- preact: ^8.0.0||^10.0.0
- webpack: '*'
- dependencies:
- '@babel/core': 7.13.16
- '@babel/plugin-transform-react-jsx': 7.14.9_@babel+core@7.13.16
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/core': 6.3.7_f904207d8bce108657a1649c78f72ef8
- '@storybook/core-common': 6.3.7_8073bd74a106ff14517e8eecceb690e6
- '@types/webpack-env': 1.16.2
- core-js: 3.16.2
- global: 4.4.0
- preact: 10.5.14
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- read-pkg-up: 7.0.1
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- transitivePeerDependencies:
- - '@storybook/builder-webpack5'
- - '@storybook/manager-webpack5'
- - '@types/react'
- - supports-color
- - typescript
- - webpack-cli
- - webpack-command
+ /@mdn/browser-compat-data@4.2.1:
+ resolution: {integrity: sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==}
dev: true
- /@storybook/preact/6.3.7_preact@10.5.14+typescript@3.9.10:
- resolution: {integrity: sha512-mP6+e1toCd59ALUNsiJWQN0CuOVV7faaMcAs21T+GJeU5igEWbRpe/ixKdMdu7RqHA9jAHOeMZfjSNhBkvnwAw==}
- engines: {node: '>=10.13.0'}
- hasBin: true
- peerDependencies:
- '@babel/core': '*'
- preact: ^8.0.0||^10.0.0
- webpack: '*'
- dependencies:
- '@babel/plugin-transform-react-jsx': 7.14.9
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/core': 6.3.7_6b8328ae33be7bccbaedcbeca6bc1253
- '@storybook/core-common': 6.3.7_6b8328ae33be7bccbaedcbeca6bc1253
- '@types/webpack-env': 1.16.2
- core-js: 3.16.2
- global: 4.4.0
- preact: 10.5.14
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- read-pkg-up: 7.0.1
- regenerator-runtime: 0.13.9
- ts-dedent: 2.2.0
- transitivePeerDependencies:
- - '@storybook/builder-webpack5'
- - '@storybook/manager-webpack5'
- - '@types/react'
- - supports-color
- - typescript
- - webpack-cli
- - webpack-command
+ /@mdn/browser-compat-data@5.5.7:
+ resolution: {integrity: sha512-DoHTZ/TjtNfUu9eiqJd+x3IcCQrhS+yOYU436TKUnlE36jZwNbK535D1CmTsSYdi/UcdCWNm5KRQZ9g1tlZCPw==}
dev: true
- /@storybook/preset-scss/1.0.3_sass-loader@10.2.0:
- resolution: {integrity: sha512-o9Iz6wxPeNENrQa2mKlsDKynBfqU2uWaRP80HeWp4TkGgf7/x3DVF2O7yi9N0x/PI1qzzTTpxlQ90D62XmpiTw==}
- peerDependencies:
- css-loader: '*'
- sass-loader: '*'
- style-loader: '*'
+ /@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1:
+ resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==}
dependencies:
- sass-loader: 10.2.0_sass@1.43.2
+ eslint-scope: 5.1.1
dev: true
- /@storybook/router/6.3.12:
- resolution: {integrity: sha512-G/pNGCnrJRetCwyEZulHPT+YOcqEj/vkPVDTUfii2qgqukup6K0cjwgd7IukAURnAnnzTi1gmgFuEKUi8GE/KA==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
+ /@nodelib/fs.scandir@2.1.5:
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
dependencies:
- '@reach/router': 1.3.4
- '@storybook/client-logger': 6.3.12
- '@types/reach__router': 1.3.9
- core-js: 3.16.2
- fast-deep-equal: 3.1.3
- global: 4.4.0
- lodash: 4.17.21
- memoizerific: 1.11.3
- qs: 6.10.1
- ts-dedent: 2.2.0
- dev: true
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
- /@storybook/router/6.3.7:
- resolution: {integrity: sha512-6tthN8op7H0NoYoE1SkQFJKC42pC4tGaoPn7kEql8XGeUJnxPtVFjrnywlTrRnKuxZKIvbilQBAwDml97XH23Q==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
+ /@nodelib/fs.stat@2.0.5:
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+
+ /@nodelib/fs.walk@1.2.8:
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
dependencies:
- '@reach/router': 1.3.4
- '@storybook/client-logger': 6.3.7
- '@types/reach__router': 1.3.9
- core-js: 3.16.2
- fast-deep-equal: 3.1.3
- global: 4.4.0
- lodash: 4.17.21
- memoizerific: 1.11.3
- qs: 6.10.1
- ts-dedent: 2.2.0
- dev: true
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.13.0
- /@storybook/router/6.3.7_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-6tthN8op7H0NoYoE1SkQFJKC42pC4tGaoPn7kEql8XGeUJnxPtVFjrnywlTrRnKuxZKIvbilQBAwDml97XH23Q==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
+ /@npmcli/fs@1.1.1:
+ resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==}
dependencies:
- '@reach/router': 1.3.4_react-dom@16.14.0+react@16.14.0
- '@storybook/client-logger': 6.3.7
- '@types/reach__router': 1.3.9
- core-js: 3.16.2
- fast-deep-equal: 3.1.3
- global: 4.4.0
- lodash: 4.17.21
- memoizerific: 1.11.3
- qs: 6.10.1
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- ts-dedent: 2.2.0
+ '@gar/promisify': 1.1.3
+ semver: 7.5.4
dev: true
- /@storybook/semver/7.3.2:
- resolution: {integrity: sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==}
+ /@npmcli/move-file@1.1.2:
+ resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==}
engines: {node: '>=10'}
- hasBin: true
dependencies:
- core-js: 3.16.2
- find-up: 4.1.0
+ mkdirp: 1.0.4
+ rimraf: 3.0.2
dev: true
- /@storybook/source-loader/6.3.7:
- resolution: {integrity: sha512-0xQTq90jwx7W7MJn/idEBCGPOyxi/3No5j+5YdfJsSGJRuMFH3jt6pGgdeZ4XA01cmmIX6bZ+fB9al6yAzs91w==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/csf': 0.0.1
- core-js: 3.16.2
- estraverse: 5.2.0
- global: 4.4.0
- loader-utils: 2.0.0
- lodash: 4.17.21
- prettier: 2.2.1
- regenerator-runtime: 0.13.9
- dev: true
+ /@pkgjs/parseargs@0.11.0:
+ resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
+ engines: {node: '>=14'}
+ requiresBuild: true
+ optional: true
- /@storybook/theming/6.3.12:
- resolution: {integrity: sha512-wOJdTEa/VFyFB2UyoqyYGaZdym6EN7RALuQOAMT6zHA282FBmKw8nL5DETHEbctpnHdcrMC/391teK4nNSrdOA==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@emotion/core': 10.1.1
- '@emotion/is-prop-valid': 0.8.8
- '@emotion/styled': 10.0.27_@emotion+core@10.1.1
- '@storybook/client-logger': 6.3.12
- core-js: 3.16.2
- deep-object-diff: 1.1.0
- emotion-theming: 10.0.27_@emotion+core@10.1.1
- global: 4.4.0
- memoizerific: 1.11.3
- polished: 4.1.3
- resolve-from: 5.0.0
- ts-dedent: 2.2.0
+ /@pnpm/config.env-replace@1.1.0:
+ resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
+ engines: {node: '>=12.22.0'}
dev: true
- /@storybook/theming/6.3.7:
- resolution: {integrity: sha512-GXBdw18JJd5jLLcRonAZWvGGdwEXByxF1IFNDSOYCcpHWsMgTk4XoLjceu9MpXET04pVX51LbVPLCvMdggoohg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
+ /@pnpm/network.ca-file@1.0.2:
+ resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==}
+ engines: {node: '>=12.22.0'}
dependencies:
- '@emotion/core': 10.1.1
- '@emotion/is-prop-valid': 0.8.8
- '@emotion/styled': 10.0.27_@emotion+core@10.1.1
- '@storybook/client-logger': 6.3.7
- core-js: 3.16.2
- deep-object-diff: 1.1.0
- emotion-theming: 10.0.27_@emotion+core@10.1.1
- global: 4.4.0
- memoizerific: 1.11.3
- polished: 4.1.3
- resolve-from: 5.0.0
- ts-dedent: 2.2.0
+ graceful-fs: 4.2.10
dev: true
- /@storybook/theming/6.3.7_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-GXBdw18JJd5jLLcRonAZWvGGdwEXByxF1IFNDSOYCcpHWsMgTk4XoLjceu9MpXET04pVX51LbVPLCvMdggoohg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
+ /@pnpm/npm-conf@2.2.2:
+ resolution: {integrity: sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==}
+ engines: {node: '>=12'}
dependencies:
- '@emotion/core': 10.1.1_react@16.14.0
- '@emotion/is-prop-valid': 0.8.8
- '@emotion/styled': 10.0.27_5f216699bc8c1f24088b3bf77b7cbbdf
- '@storybook/client-logger': 6.3.7
- core-js: 3.16.2
- deep-object-diff: 1.1.0
- emotion-theming: 10.0.27_5f216699bc8c1f24088b3bf77b7cbbdf
- global: 4.4.0
- memoizerific: 1.11.3
- polished: 4.1.3
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- resolve-from: 5.0.0
- ts-dedent: 2.2.0
- dev: true
-
- /@storybook/ui/6.3.7:
- resolution: {integrity: sha512-PBeRO8qtwAbtHvxUgNtz/ChUR6qnN+R37dMaIs3Y96jbks1fS2K9Mt7W5s1HnUbWbg2KsZMv9D4VYPBasY+Isw==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@emotion/core': 10.1.1
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/channels': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/core-events': 6.3.7
- '@storybook/router': 6.3.7
- '@storybook/semver': 7.3.2
- '@storybook/theming': 6.3.7
- '@types/markdown-to-jsx': 6.11.3
- copy-to-clipboard: 3.3.1
- core-js: 3.16.2
- core-js-pure: 3.16.2
- downshift: 6.1.7
- emotion-theming: 10.0.27_@emotion+core@10.1.1
- fuse.js: 3.6.1
- global: 4.4.0
- lodash: 4.17.21
- markdown-to-jsx: 6.11.4
- memoizerific: 1.11.3
- polished: 4.1.3
- qs: 6.10.1
- react-draggable: 4.4.3
- react-helmet-async: 1.0.9
- react-sizeme: 3.0.1
- regenerator-runtime: 0.13.9
- resolve-from: 5.0.0
- store2: 2.12.0
- transitivePeerDependencies:
- - '@types/react'
- dev: true
-
- /@storybook/ui/6.3.7_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-PBeRO8qtwAbtHvxUgNtz/ChUR6qnN+R37dMaIs3Y96jbks1fS2K9Mt7W5s1HnUbWbg2KsZMv9D4VYPBasY+Isw==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@emotion/core': 10.1.1_react@16.14.0
- '@storybook/addons': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/api': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/channels': 6.3.7
- '@storybook/client-logger': 6.3.7
- '@storybook/components': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/core-events': 6.3.7
- '@storybook/router': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@storybook/semver': 7.3.2
- '@storybook/theming': 6.3.7_react-dom@16.14.0+react@16.14.0
- '@types/markdown-to-jsx': 6.11.3
- copy-to-clipboard: 3.3.1
- core-js: 3.16.2
- core-js-pure: 3.16.2
- downshift: 6.1.7_react@16.14.0
- emotion-theming: 10.0.27_5f216699bc8c1f24088b3bf77b7cbbdf
- fuse.js: 3.6.1
- global: 4.4.0
- lodash: 4.17.21
- markdown-to-jsx: 6.11.4_react@16.14.0
- memoizerific: 1.11.3
- polished: 4.1.3
- qs: 6.10.1
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- react-draggable: 4.4.3
- react-helmet-async: 1.0.9_react-dom@16.14.0+react@16.14.0
- react-sizeme: 3.0.1_react-dom@16.14.0+react@16.14.0
- regenerator-runtime: 0.13.9
- resolve-from: 5.0.0
- store2: 2.12.0
- transitivePeerDependencies:
- - '@types/react'
+ '@pnpm/config.env-replace': 1.1.0
+ '@pnpm/network.ca-file': 1.0.2
+ config-chain: 1.1.13
dev: true
- /@surma/rollup-plugin-off-main-thread/1.4.2:
- resolution: {integrity: sha512-yBMPqmd1yEJo/280PAMkychuaALyQ9Lkb5q1ck3mjJrFuEobIfhnQ4J3mbvBoISmR3SWMWV+cGB/I0lCQee79A==}
- dependencies:
- ejs: 2.7.4
- magic-string: 0.25.7
+ /@polka/url@1.0.0-next.21:
+ resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
dev: true
- /@szmarczak/http-timer/1.1.2:
- resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==}
- engines: {node: '>=6'}
+ /@preact/async-loader@3.0.1(preact@10.11.3):
+ resolution: {integrity: sha512-BoUN24hxEfAQYnWjliAmkZLuliv+ONQi7AWn+/+VOJHTIHmbFiXrvmSxITf7PDkKiK0a5xy4OErZtVVLlk96Tg==}
+ engines: {node: '>=8'}
+ peerDependencies:
+ preact: '>= 10.0.0'
dependencies:
- defer-to-connect: 1.1.3
+ kleur: 4.1.5
+ loader-utils: 2.0.3
+ preact: 10.11.3
dev: true
- /@testing-library/dom/7.31.2:
- resolution: {integrity: sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==}
- engines: {node: '>=10'}
- dependencies:
- '@babel/code-frame': 7.14.5
- '@babel/runtime': 7.15.3
- '@types/aria-query': 4.2.2
- aria-query: 4.2.2
- chalk: 4.1.2
- dom-accessibility-api: 0.5.7
- lz-string: 1.4.4
- pretty-format: 26.6.2
+ /@prefresh/babel-plugin@0.4.4:
+ resolution: {integrity: sha512-/EvgIFMDL+nd20WNvMO0JQnzIl1EJPgmSaSYrZUww7A+aSdKsi37aL07TljrZR1cBMuzFxcr4xvqsUQLFJEukw==}
dev: true
- /@testing-library/preact/2.0.1_preact@10.5.14:
- resolution: {integrity: sha512-79kwVOY+3caoLgaPbiPzikjgY0Aya7Fc7TvGtR1upCnz2wrtmPDnN2t9vO7I7vDP2zoA+feSwOH5Q0BFErhaaQ==}
- engines: {node: '>= 10'}
+ /@prefresh/core@1.4.1(preact@10.11.3):
+ resolution: {integrity: sha512-og1vaBj3LMJagVncNrDb37Gqc0cWaUcDbpVt5hZtsN4i2Iwzd/5hyTsDHvlMirhSym3wL9ihU0Xa2VhSaOue7g==}
peerDependencies:
- preact: '>=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0'
+ preact: ^10.0.0
dependencies:
- '@testing-library/dom': 7.31.2
- preact: 10.5.14
+ preact: 10.11.3
dev: true
- /@tootallnate/once/1.1.2:
- resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
- engines: {node: '>= 6'}
+ /@prefresh/utils@1.1.3:
+ resolution: {integrity: sha512-Mb9abhJTOV4yCfkXrMrcgFiFT7MfNOw8sDa+XyZBdq/Ai2p4Zyxqsb3EgHLOEdHpMj6J9aiZ54W8H6FTam1u+A==}
dev: true
- /@trysound/sax/0.1.1:
- resolution: {integrity: sha512-Z6DoceYb/1xSg5+e+ZlPZ9v0N16ZvZ+wYMraFue4HYrE4ttONKtsvruIRf6t9TBR0YvSOfi1hUU0fJfBLCDYow==}
- engines: {node: '>=10.13.0'}
+ /@prefresh/webpack@3.3.4(@prefresh/babel-plugin@0.4.4)(preact@10.11.3)(webpack@4.46.0):
+ resolution: {integrity: sha512-RiXS/hvXDup5cQw/267kxkKie81kxaAB7SFbkr8ppshobDEzwgUN1tbGbHNx6Uari0Ql2XByC6HIgQGpaq2Q7w==}
+ peerDependencies:
+ '@prefresh/babel-plugin': ^0.4.0
+ preact: ^10.4.0
+ webpack: ^4.0.0 || ^5.0.0
+ dependencies:
+ '@prefresh/babel-plugin': 0.4.4
+ '@prefresh/core': 1.4.1(preact@10.11.3)
+ '@prefresh/utils': 1.1.3
+ preact: 10.11.3
+ webpack: 4.46.0
dev: true
- /@types/argparse/1.0.38:
- resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==}
+ /@rollup/plugin-babel@5.3.1(@babel/core@7.18.9)(rollup@2.79.1):
+ resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
+ engines: {node: '>= 10.0.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+ '@types/babel__core': ^7.1.9
+ rollup: ^1.20.0||^2.0.0
+ peerDependenciesMeta:
+ '@types/babel__core':
+ optional: true
+ dependencies:
+ '@babel/core': 7.18.9
+ '@babel/helper-module-imports': 7.22.15
+ '@rollup/pluginutils': 3.1.0(rollup@2.79.1)
+ rollup: 2.79.1
dev: true
- /@types/aria-query/4.2.2:
- resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==}
+ /@rollup/plugin-node-resolve@11.2.1(rollup@2.79.1):
+ resolution: {integrity: sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==}
+ engines: {node: '>= 10.0.0'}
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0
+ dependencies:
+ '@rollup/pluginutils': 3.1.0(rollup@2.79.1)
+ '@types/resolve': 1.17.1
+ builtin-modules: 3.3.0
+ deepmerge: 4.2.2
+ is-module: 1.0.0
+ resolve: 1.22.8
+ rollup: 2.79.1
dev: true
- /@types/babel__core/7.1.15:
- resolution: {integrity: sha512-bxlMKPDbY8x5h6HBwVzEOk2C8fb6SLfYQ5Jw3uBYuYF1lfWk/kbLd81la82vrIkBb0l+JdmrZaDikPrNxpS/Ew==}
+ /@rollup/plugin-replace@2.4.2(rollup@2.79.1):
+ resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==}
+ peerDependencies:
+ rollup: ^1.20.0 || ^2.0.0
dependencies:
- '@babel/parser': 7.15.3
- '@babel/types': 7.15.0
- '@types/babel__generator': 7.6.3
- '@types/babel__template': 7.4.1
- '@types/babel__traverse': 7.14.2
+ '@rollup/pluginutils': 3.1.0(rollup@2.79.1)
+ magic-string: 0.25.9
+ rollup: 2.79.1
dev: true
- /@types/babel__generator/7.6.3:
- resolution: {integrity: sha512-/GWCmzJWqV7diQW54smJZzWbSFf4QYtF71WCKhcx6Ru/tFyQIY2eiiITcCAeuPbNSvT9YCGkVMqqvSk2Z0mXiA==}
+ /@rollup/pluginutils@3.1.0(rollup@2.79.1):
+ resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
+ engines: {node: '>= 8.0.0'}
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0
dependencies:
- '@babel/types': 7.15.0
+ '@types/estree': 0.0.39
+ estree-walker: 1.0.1
+ picomatch: 2.3.1
+ rollup: 2.79.1
dev: true
- /@types/babel__template/7.4.1:
- resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==}
+ /@rollup/pluginutils@4.2.1:
+ resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
+ engines: {node: '>= 8.0.0'}
dependencies:
- '@babel/parser': 7.15.3
- '@babel/types': 7.15.0
+ estree-walker: 2.0.2
+ picomatch: 2.3.1
dev: true
- /@types/babel__traverse/7.14.2:
- resolution: {integrity: sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==}
- dependencies:
- '@babel/types': 7.15.0
+ /@sindresorhus/is@0.14.0:
+ resolution: {integrity: sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /@sindresorhus/is@5.6.0:
+ resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
+ engines: {node: '>=14.16'}
dev: true
- /@types/braces/3.0.1:
- resolution: {integrity: sha512-+euflG6ygo4bn0JHtn4pYqcXwRtLvElQ7/nnjDu7iYG56H0+OhCd7d6Ug0IE3WcFpZozBKW2+80FUbv5QGk5AQ==}
+ /@sindresorhus/merge-streams@1.0.0:
+ resolution: {integrity: sha512-rUV5WyJrJLoloD4NDN1V1+LDMDWOa4OTsT4yYJwQNpTU6FWxkxHpL7eu4w+DmiH8x/EAM1otkPE1+LaspIbplw==}
+ engines: {node: '>=18'}
dev: true
- /@types/cheerio/0.22.30:
- resolution: {integrity: sha512-t7ZVArWZlq3dFa9Yt33qFBQIK4CQd1Q3UJp0V+UhP6vgLWLM6Qug7vZuRSGXg45zXeB1Fm5X2vmBkEX58LV2Tw==}
+ /@surma/rollup-plugin-off-main-thread@2.2.3:
+ resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
dependencies:
- '@types/node': 14.17.10
+ ejs: 3.1.8
+ json5: 2.2.3
+ magic-string: 0.25.9
+ string.prototype.matchall: 4.0.10
dev: true
- /@types/chrome/0.0.128:
- resolution: {integrity: sha512-eGc599TDtersMBW1cSnExHm0IHrXrO5xdk6Sa2Dq30ED+hR1rpT1ez0NNcCgvGO52nmktGfyvd3Uyquzv3LL4g==}
+ /@szmarczak/http-timer@1.1.2:
+ resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==}
+ engines: {node: '>=6'}
dependencies:
- '@types/filesystem': 0.0.32
- '@types/har-format': 1.2.7
+ defer-to-connect: 1.1.3
dev: true
- /@types/color-convert/2.0.0:
- resolution: {integrity: sha512-m7GG7IKKGuJUXvkZ1qqG3ChccdIM/qBBo913z+Xft0nKCX4hAU/IxKwZBU4cpRZ7GS5kV4vOblUkILtSShCPXQ==}
+ /@szmarczak/http-timer@5.0.1:
+ resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
+ engines: {node: '>=14.16'}
dependencies:
- '@types/color-name': 1.1.1
+ defer-to-connect: 2.0.1
dev: true
- /@types/color-name/1.1.1:
- resolution: {integrity: sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==}
+ /@tailwindcss/forms@0.5.3(tailwindcss@3.3.2):
+ resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==}
+ peerDependencies:
+ tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1'
+ dependencies:
+ mini-svg-data-uri: 1.4.4
+ tailwindcss: 3.3.2
dev: true
- /@types/enzyme/3.10.9:
- resolution: {integrity: sha512-dx5UvcWe2Vtye6S9Hw2rFB7Ul9uMXOAje2FAbXvVYieQDNle9qPAo7DfvFMSztZ9NFiD3dVZ4JsRYGTrSLynJg==}
+ /@tailwindcss/typography@0.5.9(tailwindcss@3.3.2):
+ resolution: {integrity: sha512-t8Sg3DyynFysV9f4JDOVISGsjazNb48AeIYQwcL+Bsq5uf4RYL75C1giZ43KISjeDGBaTN3Kxh7Xj/vRSMJUUg==}
+ peerDependencies:
+ tailwindcss: '>=3.0.0 || insiders'
dependencies:
- '@types/cheerio': 0.22.30
- '@types/react': 17.0.19
+ lodash.castarray: 4.4.0
+ lodash.isplainobject: 4.0.6
+ lodash.merge: 4.6.2
+ postcss-selector-parser: 6.0.10
+ tailwindcss: 3.3.2
dev: true
- /@types/eslint-visitor-keys/1.0.0:
- resolution: {integrity: sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==}
+ /@trysound/sax@0.2.0:
+ resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
+ engines: {node: '>=10.13.0'}
dev: true
- /@types/estree/0.0.39:
- resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
+ /@tsconfig/node10@1.0.9:
+ resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}
dev: true
- /@types/estree/0.0.50:
- resolution: {integrity: sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==}
+ /@tsconfig/node12@1.0.11:
+ resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
dev: true
- /@types/filesystem/0.0.32:
- resolution: {integrity: sha512-Yuf4jR5YYMR2DVgwuCiP11s0xuVRyPKmz8vo6HBY3CGdeMj8af93CFZX+T82+VD1+UqHOxTq31lO7MI7lepBtQ==}
- dependencies:
- '@types/filewriter': 0.0.29
+ /@tsconfig/node14@1.0.3:
+ resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
dev: true
- /@types/filewriter/0.0.29:
- resolution: {integrity: sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ==}
+ /@tsconfig/node16@1.0.3:
+ resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==}
dev: true
- /@types/glob-base/0.3.0:
- resolution: {integrity: sha1-pYHWiDR+EOUN18F9byiAoQNUMZ0=}
+ /@types/better-sqlite3@7.6.8:
+ resolution: {integrity: sha512-ASndM4rdGrzk7iXXqyNC4fbwt4UEjpK0i3j4q4FyeQrLAthfB6s7EF135ZJE0qQxtKIMFwmyT6x0switET7uIw==}
+ dependencies:
+ '@types/node': 20.11.13
dev: true
- /@types/glob/7.1.4:
- resolution: {integrity: sha512-w+LsMxKyYQm347Otw+IfBXOv9UWVjpHpCDdbBMt8Kz/xbvCYNjP+0qPh91Km3iKfSRLBB0P7fAMf0KHrPu+MyA==}
+ /@types/body-parser@1.19.2:
+ resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==}
dependencies:
- '@types/minimatch': 3.0.5
- '@types/node': 14.17.10
+ '@types/connect': 3.4.35
+ '@types/node': 20.11.13
dev: true
- /@types/graceful-fs/4.1.5:
- resolution: {integrity: sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==}
+ /@types/bonjour@3.5.10:
+ resolution: {integrity: sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==}
dependencies:
- '@types/node': 14.17.10
+ '@types/node': 20.11.13
dev: true
- /@types/har-format/1.2.7:
- resolution: {integrity: sha512-/TPzUG0tJn5x1TUcVLlDx2LqbE58hyOzDVAc9kf8SpOEmguHjU6bKUyfqb211AdqLOmU/SNyXvLKPNP5qTlfRw==}
+ /@types/chai@4.3.3:
+ resolution: {integrity: sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==}
dev: true
- /@types/hast/2.3.2:
- resolution: {integrity: sha512-Op5W7jYgZI7AWKY5wQ0/QNMzQM7dGQPyW1rXKNiymVCy5iTfdPuGu4HhYNOM2sIv8gUfIuIdcYlXmAepwaowow==}
+ /@types/chrome@0.0.197:
+ resolution: {integrity: sha512-m1NfS5bOjaypyqQfaX6CxmJodZVcvj5+Mt/K94EBHkflYjPNmXHAzbxfifdLMa0YM3PDyOxohoTS5ug/e6p5jA==}
dependencies:
- '@types/unist': 2.0.6
- dev: true
+ '@types/filesystem': 0.0.32
+ '@types/har-format': 1.2.9
- /@types/history/4.7.9:
- resolution: {integrity: sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==}
+ /@types/connect-history-api-fallback@1.3.5:
+ resolution: {integrity: sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==}
+ dependencies:
+ '@types/express-serve-static-core': 4.17.31
+ '@types/node': 20.11.13
dev: true
- /@types/html-minifier-terser/5.1.2:
- resolution: {integrity: sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==}
+ /@types/connect@3.4.35:
+ resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
+ dependencies:
+ '@types/node': 20.11.13
dev: true
- /@types/is-function/1.0.0:
- resolution: {integrity: sha512-iTs9HReBu7evG77Q4EC8hZnqRt57irBDkK9nvmHroiOIVwYMQc4IvYvdRgwKfYepunIY7Oh/dBuuld+Gj9uo6w==}
+ /@types/estree@0.0.39:
+ resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
dev: true
- /@types/istanbul-lib-coverage/2.0.3:
- resolution: {integrity: sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==}
+ /@types/express-serve-static-core@4.17.31:
+ resolution: {integrity: sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==}
+ dependencies:
+ '@types/node': 20.11.13
+ '@types/qs': 6.9.7
+ '@types/range-parser': 1.2.4
dev: true
- /@types/istanbul-lib-report/3.0.0:
- resolution: {integrity: sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==}
+ /@types/express@4.17.14:
+ resolution: {integrity: sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==}
dependencies:
- '@types/istanbul-lib-coverage': 2.0.3
+ '@types/body-parser': 1.19.2
+ '@types/express-serve-static-core': 4.17.31
+ '@types/qs': 6.9.7
+ '@types/serve-static': 1.15.0
dev: true
- /@types/istanbul-reports/3.0.1:
- resolution: {integrity: sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==}
+ /@types/filesystem@0.0.32:
+ resolution: {integrity: sha512-Yuf4jR5YYMR2DVgwuCiP11s0xuVRyPKmz8vo6HBY3CGdeMj8af93CFZX+T82+VD1+UqHOxTq31lO7MI7lepBtQ==}
dependencies:
- '@types/istanbul-lib-report': 3.0.0
- dev: true
+ '@types/filewriter': 0.0.29
+
+ /@types/filewriter@0.0.29:
+ resolution: {integrity: sha512-BsPXH/irW0ht0Ji6iw/jJaK8Lj3FJemon2gvEqHKpCdDCeemHa+rI3WBGq5z7cDMZgoLjY40oninGxqk+8NzNQ==}
- /@types/jest/26.0.24:
- resolution: {integrity: sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==}
+ /@types/follow-redirects@1.14.4:
+ resolution: {integrity: sha512-GWXfsD0Jc1RWiFmMuMFCpXMzi9L7oPDVwxUnZdg89kDNnqsRfUKXEtUYtA98A6lig1WXH/CYY/fvPW9HuN5fTA==}
dependencies:
- jest-diff: 26.6.2
- pretty-format: 26.6.2
+ '@types/node': 20.11.13
dev: true
- /@types/json-schema/7.0.7:
- resolution: {integrity: sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==}
+ /@types/har-format@1.2.9:
+ resolution: {integrity: sha512-rffW6MhQ9yoa75bdNi+rjZBAvu2HhehWJXlhuWXnWdENeuKe82wUgAwxYOb7KRKKmxYN+D/iRKd2NDQMLqlUmg==}
+
+ /@types/history@4.7.11:
+ resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
dev: true
- /@types/json-schema/7.0.9:
- resolution: {integrity: sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==}
+ /@types/html-minifier-terser@6.1.0:
+ resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==}
dev: true
- /@types/json5/0.0.29:
- resolution: {integrity: sha1-7ihweulOEdK4J7y+UnC86n8+ce4=}
+ /@types/http-cache-semantics@4.0.4:
+ resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
dev: true
- /@types/markdown-to-jsx/6.11.3:
- resolution: {integrity: sha512-30nFYpceM/ZEvhGiqWjm5quLUxNeld0HCzJEXMZZDpq53FPkS85mTwkWtCXzCqq8s5JYLgM5W392a02xn8Bdaw==}
+ /@types/http-proxy@1.17.9:
+ resolution: {integrity: sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==}
dependencies:
- '@types/react': 17.0.19
+ '@types/node': 20.11.13
dev: true
- /@types/mdast/3.0.8:
- resolution: {integrity: sha512-HdUXWDNtDenuVJFrV2xBCLEMiw1Vn7FMuJxqJC5oBvC2adA3pgtp6CPCIMQdz3pmWxGuJjT+hOp6FnOXy6dXoQ==}
- dependencies:
- '@types/unist': 2.0.6
+ /@types/istanbul-lib-coverage@2.0.6:
+ resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
dev: true
- /@types/micromatch/4.0.2:
- resolution: {integrity: sha512-oqXqVb0ci19GtH0vOA/U2TmHTcRY9kuZl4mqUxe0QmJAlIW13kzhuK5pi1i9+ngav8FjpSb9FVS/GE00GLX1VA==}
- dependencies:
- '@types/braces': 3.0.1
+ /@types/json-schema@7.0.11:
+ resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
dev: true
- /@types/minimatch/3.0.5:
- resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
+ /@types/json-schema@7.0.15:
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
dev: true
- /@types/node-fetch/2.5.12:
- resolution: {integrity: sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==}
+ /@types/json5@0.0.29:
+ resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ dev: true
+
+ /@types/keyv@3.1.4:
+ resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
dependencies:
- '@types/node': 14.17.10
- form-data: 3.0.1
+ '@types/node': 20.11.13
dev: true
- /@types/node/10.17.13:
- resolution: {integrity: sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==}
+ /@types/lodash@4.14.186:
+ resolution: {integrity: sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==}
+ dev: false
+
+ /@types/mime@3.0.1:
+ resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
dev: true
- /@types/node/14.14.22:
- resolution: {integrity: sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw==}
+ /@types/minimatch@3.0.5:
+ resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
+ dev: true
- /@types/node/14.14.34:
- resolution: {integrity: sha512-dBPaxocOK6UVyvhbnpFIj2W+S+1cBTkHQbFQfeeJhoKFbzYcVUGHvddeWPSucKATb3F0+pgDq0i6ghEaZjsugA==}
+ /@types/mocha@10.0.1:
+ resolution: {integrity: sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==}
dev: true
- /@types/node/14.17.1:
- resolution: {integrity: sha512-/tpUyFD7meeooTRwl3sYlihx2BrJE7q9XF71EguPFIySj9B7qgnRtHsHTho+0AUm4m1SvWGm6uSncrR94q6Vtw==}
+ /@types/mocha@8.2.3:
+ resolution: {integrity: sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==}
dev: true
- /@types/node/14.17.10:
- resolution: {integrity: sha512-09x2d6kNBwjHgyh3jOUE2GE4DFoxDriDvWdu6mFhMP1ysynGYazt4ecZmJlL6/fe4Zi2vtYvTvtL7epjQQrBhA==}
+ /@types/mocha@9.1.1:
+ resolution: {integrity: sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==}
dev: true
- /@types/normalize-package-data/2.4.1:
- resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
+ /@types/mustache@4.2.1:
+ resolution: {integrity: sha512-gFAlWL9Ik21nJioqjlGCnNYbf9zHi0sVbaZ/1hQEBcCEuxfLJDvz4bVJSV6v6CUaoLOz0XEIoP7mSrhJ6o237w==}
dev: true
- /@types/npmlog/4.1.3:
- resolution: {integrity: sha512-1TcL7YDYCtnHmLhTWbum+IIwLlvpaHoEKS2KNIngEwLzwgDeHaebaEHHbQp8IqzNQ9IYiboLKUjAf7MZqG63+w==}
+ /@types/node@18.11.17:
+ resolution: {integrity: sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==}
+
+ /@types/node@20.11.13:
+ resolution: {integrity: sha512-5G4zQwdiQBSWYTDAH1ctw2eidqdhMJaNsiIDKHFr55ihz5Trl2qqR8fdrT732yPBho5gkNxXm67OxWFBqX9aPg==}
+ dependencies:
+ undici-types: 5.26.5
dev: true
- /@types/overlayscrollbars/1.12.1:
- resolution: {integrity: sha512-V25YHbSoKQN35UasHf0EKD9U2vcmexRSp78qa8UglxFH8H3D+adEa9zGZwrqpH4TdvqeMrgMqVqsLB4woAryrQ==}
+ /@types/node@20.4.1:
+ resolution: {integrity: sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg==}
dev: true
- /@types/parse-json/4.0.0:
+ /@types/parse-json@4.0.0:
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
dev: true
- /@types/parse5/5.0.3:
- resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==}
+ /@types/q@1.5.5:
+ resolution: {integrity: sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ==}
dev: true
- /@types/prettier/2.3.2:
- resolution: {integrity: sha512-eI5Yrz3Qv4KPUa/nSIAi0h+qX0XyewOliug5F2QAtuRg6Kjg6jfmxe1GIwoIRhZspD1A0RP8ANrPwvEXXtRFog==}
+ /@types/qs@6.9.7:
+ resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==}
dev: true
- /@types/pretty-hrtime/1.0.1:
- resolution: {integrity: sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==}
+ /@types/range-parser@1.2.4:
+ resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==}
dev: true
- /@types/prop-types/15.7.4:
- resolution: {integrity: sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==}
+ /@types/resolve@1.17.1:
+ resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==}
+ dependencies:
+ '@types/node': 20.11.13
dev: true
- /@types/q/1.5.5:
- resolution: {integrity: sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ==}
+ /@types/responselike@1.0.0:
+ resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
+ dependencies:
+ '@types/node': 20.11.13
dev: true
- /@types/qs/6.9.7:
- resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==}
+ /@types/retry@0.12.0:
+ resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
dev: true
- /@types/reach__router/1.3.9:
- resolution: {integrity: sha512-N6rqQqTTAV/zKLfK3iq9Ww3wqCEhTZvsilhl0zI09zETdVq1QGmJH6+/xnj8AFUWIrle2Cqo+PGM/Ltr1vBb9w==}
- dependencies:
- '@types/react': 17.0.19
+ /@types/semver@7.3.12:
+ resolution: {integrity: sha512-WwA1MW0++RfXmCr12xeYOOC5baSC9mSb0ZqCquFzKhcoF4TvHu5MKOuXsncgZcpVFhB1pXd5hZmM0ryAoCp12A==}
dev: true
- /@types/react-syntax-highlighter/11.0.5:
- resolution: {integrity: sha512-VIOi9i2Oj5XsmWWoB72p3KlZoEbdRAcechJa8Ztebw7bDl2YmR+odxIqhtJGp1q2EozHs02US+gzxJ9nuf56qg==}
- dependencies:
- '@types/react': 17.0.19
+ /@types/semver@7.5.6:
+ resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==}
dev: true
- /@types/react/17.0.19:
- resolution: {integrity: sha512-sX1HisdB1/ZESixMTGnMxH9TDe8Sk709734fEQZzCV/4lSu9kJCPbo2PbTRoZM+53Pp0P10hYVyReUueGwUi4A==}
+ /@types/serve-index@1.9.1:
+ resolution: {integrity: sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==}
dependencies:
- '@types/prop-types': 15.7.4
- '@types/scheduler': 0.16.2
- csstype: 3.0.8
+ '@types/express': 4.17.14
dev: true
- /@types/resolve/1.17.1:
- resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==}
+ /@types/serve-static@1.15.0:
+ resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==}
dependencies:
- '@types/node': 14.17.10
+ '@types/mime': 3.0.1
+ '@types/node': 20.11.13
dev: true
- /@types/scheduler/0.16.2:
- resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
+ /@types/sockjs@0.3.33:
+ resolution: {integrity: sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==}
+ dependencies:
+ '@types/node': 20.11.13
dev: true
- /@types/source-list-map/0.1.2:
+ /@types/source-list-map@0.1.2:
resolution: {integrity: sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==}
dev: true
- /@types/stack-utils/2.0.1:
- resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
- dev: true
-
- /@types/tapable/1.0.8:
+ /@types/tapable@1.0.8:
resolution: {integrity: sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==}
dev: true
- /@types/trusted-types/2.0.2:
+ /@types/trusted-types@2.0.2:
resolution: {integrity: sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==}
dev: true
- /@types/uglify-js/3.13.1:
- resolution: {integrity: sha512-O3MmRAk6ZuAKa9CHgg0Pr0+lUOqoMLpc9AS4R8ano2auvsg7IE8syF3Xh/NPr26TWklxYcqoEEFdzLLs1fV9PQ==}
+ /@types/uglify-js@3.17.1:
+ resolution: {integrity: sha512-GkewRA4i5oXacU/n4MA9+bLgt5/L3F1mKrYvFGm7r2ouLXhRKjuWwo9XHNnbx6WF3vlGW21S3fCvgqxvxXXc5g==}
dependencies:
source-map: 0.6.1
dev: true
- /@types/unist/2.0.6:
- resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==}
- dev: true
-
- /@types/webpack-env/1.16.2:
- resolution: {integrity: sha512-vKx7WNQNZDyJveYcHAm9ZxhqSGLYwoyLhrHjLBOkw3a7cT76sTdjgtwyijhk1MaHyRIuSztcVwrUOO/NEu68Dw==}
+ /@types/web@0.0.82:
+ resolution: {integrity: sha512-mktv7gA7V9mGKhAR9MXikOeNjsf3UptliH2yBFbNOqqbmse8II8irigyVJrW072ReHzPYSkJms9A5wZq3We5rw==}
dev: true
- /@types/webpack-sources/3.2.0:
+ /@types/webpack-sources@3.2.0:
resolution: {integrity: sha512-Ft7YH3lEVRQ6ls8k4Ff1oB4jN6oy/XmU6tQISKdhfh+1mR+viZFphS6WL0IrtDOzvefmJg5a0s7ZQoRXwqTEFg==}
dependencies:
- '@types/node': 14.17.10
+ '@types/node': 20.11.13
'@types/source-list-map': 0.1.2
- source-map: 0.7.3
+ source-map: 0.7.4
dev: true
- /@types/webpack/4.41.30:
- resolution: {integrity: sha512-GUHyY+pfuQ6haAfzu4S14F+R5iGRwN6b2FRNJY7U0NilmFAqbsOfK6j1HwuLBAqwRIT+pVdNDJGJ6e8rpp0KHA==}
+ /@types/webpack@4.41.33:
+ resolution: {integrity: sha512-PPajH64Ft2vWevkerISMtnZ8rTs4YmRbs+23c402J0INmxDKCrhZNvwZYtzx96gY2wAtXdrK1BS2fiC8MlLr3g==}
dependencies:
- '@types/node': 14.17.10
+ '@types/node': 20.11.13
'@types/tapable': 1.0.8
- '@types/uglify-js': 3.13.1
+ '@types/uglify-js': 3.17.1
'@types/webpack-sources': 3.2.0
anymatch: 3.1.2
source-map: 0.6.1
dev: true
- /@types/yargs-parser/20.2.1:
- resolution: {integrity: sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==}
+ /@types/ws@8.5.3:
+ resolution: {integrity: sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==}
+ dependencies:
+ '@types/node': 20.11.13
dev: true
- /@types/yargs/15.0.14:
- resolution: {integrity: sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==}
+ /@types/yauzl@2.10.3:
+ resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
dependencies:
- '@types/yargs-parser': 20.2.1
+ '@types/node': 20.11.13
dev: true
- /@types/yargs/16.0.4:
- resolution: {integrity: sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==}
+ /@typescript-eslint/eslint-plugin@4.33.0(@typescript-eslint/parser@4.33.0)(eslint@7.32.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg==}
+ engines: {node: ^10.12.0 || >=12.0.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^4.0.0
+ eslint: ^5.0.0 || ^6.0.0 || ^7.0.0
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
dependencies:
- '@types/yargs-parser': 20.2.1
+ '@typescript-eslint/experimental-utils': 4.33.0(eslint@7.32.0)(typescript@5.3.3)
+ '@typescript-eslint/parser': 4.33.0(eslint@7.32.0)(typescript@5.3.3)
+ '@typescript-eslint/scope-manager': 4.33.0
+ debug: 4.3.4
+ eslint: 7.32.0
+ functional-red-black-tree: 1.0.1
+ ignore: 5.2.0
+ regexpp: 3.2.0
+ semver: 7.3.8
+ tsutils: 3.21.0(typescript@5.3.3)
+ typescript: 5.3.3
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /@typescript-eslint/eslint-plugin/2.34.0_2b015b1c4b7c4a3ed9a197dc233b1a35:
- resolution: {integrity: sha512-4zY3Z88rEE99+CNvTbXSyovv2z9PNOVffTWD2W8QF5s2prBQtwN2zadqERcrHpcR7O/+KMI3fcTAmUUhK/iQcQ==}
- engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
+ /@typescript-eslint/eslint-plugin@5.41.0(@typescript-eslint/parser@5.41.0)(eslint@8.26.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-DXUS22Y57/LAFSg3x7Vi6RNAuLpTXwxB9S2nIA7msBb/Zt8p7XqMwdpdc1IU7CkOQUPgAqR5fWvxuKCbneKGmA==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
- '@typescript-eslint/parser': ^2.0.0
- eslint: ^5.0.0 || ^6.0.0
+ '@typescript-eslint/parser': ^5.0.0
+ eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- '@typescript-eslint/experimental-utils': 2.34.0_eslint@6.8.0+typescript@3.9.10
- '@typescript-eslint/parser': 2.34.0_eslint@6.8.0+typescript@3.9.10
- eslint: 6.8.0
- functional-red-black-tree: 1.0.1
- regexpp: 3.1.0
- tsutils: 3.19.1_typescript@3.9.10
- typescript: 3.9.10
+ '@typescript-eslint/parser': 5.41.0(eslint@8.26.0)(typescript@5.3.3)
+ '@typescript-eslint/scope-manager': 5.41.0
+ '@typescript-eslint/type-utils': 5.41.0(eslint@8.26.0)(typescript@5.3.3)
+ '@typescript-eslint/utils': 5.41.0(eslint@8.26.0)(typescript@5.3.3)
+ debug: 4.3.4
+ eslint: 8.26.0
+ ignore: 5.2.0
+ regexpp: 3.2.0
+ semver: 7.3.8
+ tsutils: 3.21.0(typescript@5.3.3)
+ typescript: 5.3.3
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/eslint-plugin/4.14.0_980e7d90d2d08155204a38366bd3b934:
- resolution: {integrity: sha512-IJ5e2W7uFNfg4qh9eHkHRUCbgZ8VKtGwD07kannJvM5t/GU8P8+24NX8gi3Hf5jST5oWPY8kyV1s/WtfiZ4+Ww==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ /@typescript-eslint/eslint-plugin@6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==}
+ engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
- '@typescript-eslint/parser': ^4.0.0
- eslint: ^5.0.0 || ^6.0.0 || ^7.0.0
+ '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha
+ eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- '@typescript-eslint/experimental-utils': 4.14.0_eslint@7.18.0+typescript@4.1.3
- '@typescript-eslint/parser': 4.14.0_eslint@7.18.0+typescript@4.1.3
- '@typescript-eslint/scope-manager': 4.14.0
- debug: 4.3.1
- eslint: 7.18.0
- functional-red-black-tree: 1.0.1
- lodash: 4.17.20
- regexpp: 3.1.0
- semver: 7.3.4
- tsutils: 3.19.1_typescript@4.1.3
- typescript: 4.1.3
+ '@eslint-community/regexpp': 4.10.0
+ '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/scope-manager': 6.19.0
+ '@typescript-eslint/type-utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ '@typescript-eslint/visitor-keys': 6.19.0
+ debug: 4.3.4
+ eslint: 8.56.0
+ graphemer: 1.4.0
+ ignore: 5.3.0
+ natural-compare: 1.4.0
+ semver: 7.5.4
+ ts-api-utils: 1.0.3(typescript@5.3.3)
+ typescript: 5.3.3
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/experimental-utils/2.34.0_eslint@6.8.0+typescript@3.9.10:
- resolution: {integrity: sha512-eS6FTkq+wuMJ+sgtuNTtcqavWXqsflWcfBnlYhg/nS4aZ1leewkXGbvBhaapn1q6qf4M71bsR1tez5JTRMuqwA==}
- engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
+ /@typescript-eslint/experimental-utils@4.33.0(eslint@7.32.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-zeQjOoES5JFjTnAhI5QY7ZviczMzDptls15GFsI6jyUOq0kOf9+WonkhtlIhh0RgHRnqj5gdNxW5j1EvAyYg6Q==}
+ engines: {node: ^10.12.0 || >=12.0.0}
peerDependencies:
eslint: '*'
dependencies:
- '@types/json-schema': 7.0.9
- '@typescript-eslint/typescript-estree': 2.34.0_typescript@3.9.10
- eslint: 6.8.0
+ '@types/json-schema': 7.0.11
+ '@typescript-eslint/scope-manager': 4.33.0
+ '@typescript-eslint/types': 4.33.0
+ '@typescript-eslint/typescript-estree': 4.33.0(typescript@5.3.3)
+ eslint: 7.32.0
eslint-scope: 5.1.1
- eslint-utils: 2.1.0
+ eslint-utils: 3.0.0(eslint@7.32.0)
transitivePeerDependencies:
- supports-color
- typescript
dev: true
- /@typescript-eslint/experimental-utils/4.14.0_eslint@7.18.0+typescript@4.1.3:
- resolution: {integrity: sha512-6i6eAoiPlXMKRbXzvoQD5Yn9L7k9ezzGRvzC/x1V3650rUk3c3AOjQyGYyF9BDxQQDK2ElmKOZRD0CbtdkMzQQ==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ /@typescript-eslint/experimental-utils@5.41.0(eslint@7.32.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-/qxT2Kd2q/A22JVIllvws4rvc00/3AT4rAo/0YgEN28y+HPhbJbk6X4+MAHEoZzpNyAOugIT7D/OLnKBW8FfhA==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
- eslint: '*'
+ eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies:
- '@types/json-schema': 7.0.7
- '@typescript-eslint/scope-manager': 4.14.0
- '@typescript-eslint/types': 4.14.0
- '@typescript-eslint/typescript-estree': 4.14.0_typescript@4.1.3
- eslint: 7.18.0
- eslint-scope: 5.1.1
- eslint-utils: 2.1.0
+ '@typescript-eslint/utils': 5.41.0(eslint@7.32.0)(typescript@5.3.3)
+ eslint: 7.32.0
transitivePeerDependencies:
- supports-color
- typescript
dev: true
- /@typescript-eslint/parser/2.34.0_eslint@6.8.0+typescript@3.9.10:
- resolution: {integrity: sha512-03ilO0ucSD0EPTw2X4PntSIRFtDPWjrVq7C3/Z3VQHRC7+13YB55rcJI3Jt+YgeHbjUdJPcPa7b23rXCBokuyA==}
- engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
+ /@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA==}
+ engines: {node: ^10.12.0 || >=12.0.0}
peerDependencies:
- eslint: ^5.0.0 || ^6.0.0
+ eslint: ^5.0.0 || ^6.0.0 || ^7.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- '@types/eslint-visitor-keys': 1.0.0
- '@typescript-eslint/experimental-utils': 2.34.0_eslint@6.8.0+typescript@3.9.10
- '@typescript-eslint/typescript-estree': 2.34.0_typescript@3.9.10
- eslint: 6.8.0
- eslint-visitor-keys: 1.3.0
- typescript: 3.9.10
+ '@typescript-eslint/scope-manager': 4.33.0
+ '@typescript-eslint/types': 4.33.0
+ '@typescript-eslint/typescript-estree': 4.33.0(typescript@5.3.3)
+ debug: 4.3.4
+ eslint: 7.32.0
+ typescript: 5.3.3
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/parser/4.14.0_eslint@7.18.0+typescript@4.1.3:
- resolution: {integrity: sha512-sUDeuCjBU+ZF3Lzw0hphTyScmDDJ5QVkyE21pRoBo8iDl7WBtVFS+WDN3blY1CH3SBt7EmYCw6wfmJjF0l/uYg==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ /@typescript-eslint/parser@5.41.0(eslint@8.26.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-HQVfix4+RL5YRWZboMD1pUfFN8MpRH4laziWkkAzyO1fvNOY/uinZcvo3QiFJVS/siNHupV8E5+xSwQZrl6PZA==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
- eslint: ^5.0.0 || ^6.0.0 || ^7.0.0
+ eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- '@typescript-eslint/scope-manager': 4.14.0
- '@typescript-eslint/types': 4.14.0
- '@typescript-eslint/typescript-estree': 4.14.0_typescript@4.1.3
- debug: 4.3.1
- eslint: 7.18.0
- typescript: 4.1.3
+ '@typescript-eslint/scope-manager': 5.41.0
+ '@typescript-eslint/types': 5.41.0
+ '@typescript-eslint/typescript-estree': 5.41.0(typescript@5.3.3)
+ debug: 4.3.4
+ eslint: 8.26.0
+ typescript: 5.3.3
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/parser/4.4.1_eslint@7.18.0+typescript@4.1.3:
- resolution: {integrity: sha512-S0fuX5lDku28Au9REYUsV+hdJpW/rNW0gWlc4SXzF/kdrRaAVX9YCxKpziH7djeWT/HFAjLZcnY7NJD8xTeUEg==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ /@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-1DyBLG5SH7PYCd00QlroiW60YJ4rWMuUGa/JBV0iZuqi4l4IK3twKPq5ZkEebmGqRjXWVgsUzfd3+nZveewgow==}
+ engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
- eslint: ^5.0.0 || ^6.0.0 || ^7.0.0
+ eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- '@typescript-eslint/scope-manager': 4.4.1
- '@typescript-eslint/types': 4.4.1
- '@typescript-eslint/typescript-estree': 4.4.1_typescript@4.1.3
- debug: 4.3.1
- eslint: 7.18.0
- typescript: 4.1.3
+ '@typescript-eslint/scope-manager': 6.19.0
+ '@typescript-eslint/types': 6.19.0
+ '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3)
+ '@typescript-eslint/visitor-keys': 6.19.0
+ debug: 4.3.4
+ eslint: 8.56.0
+ typescript: 5.3.3
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/scope-manager/4.14.0:
- resolution: {integrity: sha512-/J+LlRMdbPh4RdL4hfP1eCwHN5bAhFAGOTsvE6SxsrM/47XQiPSgF5MDgLyp/i9kbZV9Lx80DW0OpPkzL+uf8Q==}
+ /@typescript-eslint/scope-manager@4.33.0:
+ resolution: {integrity: sha512-5IfJHpgTsTZuONKbODctL4kKuQje/bzBRkwHE8UOZ4f89Zeddg+EGZs8PD8NcN4LdM3ygHWYB3ukPAYjvl/qbQ==}
engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
dependencies:
- '@typescript-eslint/types': 4.14.0
- '@typescript-eslint/visitor-keys': 4.14.0
+ '@typescript-eslint/types': 4.33.0
+ '@typescript-eslint/visitor-keys': 4.33.0
dev: true
- /@typescript-eslint/scope-manager/4.4.1:
- resolution: {integrity: sha512-2oD/ZqD4Gj41UdFeWZxegH3cVEEH/Z6Bhr/XvwTtGv66737XkR4C9IqEkebCuqArqBJQSj4AgNHHiN1okzD/wQ==}
- engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
+ /@typescript-eslint/scope-manager@5.41.0:
+ resolution: {integrity: sha512-xOxPJCnuktUkY2xoEZBKXO5DBCugFzjrVndKdUnyQr3+9aDWZReKq9MhaoVnbL+maVwWJu/N0SEtrtEUNb62QQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
- '@typescript-eslint/types': 4.4.1
- '@typescript-eslint/visitor-keys': 4.4.1
+ '@typescript-eslint/types': 5.41.0
+ '@typescript-eslint/visitor-keys': 5.41.0
dev: true
- /@typescript-eslint/types/4.14.0:
- resolution: {integrity: sha512-VsQE4VvpldHrTFuVPY1ZnHn/Txw6cZGjL48e+iBxTi2ksa9DmebKjAeFmTVAYoSkTk7gjA7UqJ7pIsyifTsI4A==}
- engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
+ /@typescript-eslint/scope-manager@6.19.0:
+ resolution: {integrity: sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ dependencies:
+ '@typescript-eslint/types': 6.19.0
+ '@typescript-eslint/visitor-keys': 6.19.0
dev: true
- /@typescript-eslint/types/4.4.1:
- resolution: {integrity: sha512-KNDfH2bCyax5db+KKIZT4rfA8rEk5N0EJ8P0T5AJjo5xrV26UAzaiqoJCxeaibqc0c/IvZxp7v2g3difn2Pn3w==}
- engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
+ /@typescript-eslint/type-utils@5.41.0(eslint@8.26.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-L30HNvIG6A1Q0R58e4hu4h+fZqaO909UcnnPbwKiN6Rc3BUEx6ez2wgN7aC0cBfcAjZfwkzE+E2PQQ9nEuoqfA==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: '*'
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ dependencies:
+ '@typescript-eslint/typescript-estree': 5.41.0(typescript@5.3.3)
+ '@typescript-eslint/utils': 5.41.0(eslint@8.26.0)(typescript@5.3.3)
+ debug: 4.3.4
+ eslint: 8.26.0
+ tsutils: 3.21.0(typescript@5.3.3)
+ typescript: 5.3.3
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /@typescript-eslint/typescript-estree/2.34.0_typescript@3.9.10:
- resolution: {integrity: sha512-OMAr+nJWKdlVM9LOqCqh3pQQPwxHAN7Du8DR6dmwCrAmxtiXQnhHJ6tBNtf+cggqfo51SG/FCwnKhXCIM7hnVg==}
- engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
+ /@typescript-eslint/type-utils@6.19.0(eslint@8.56.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==}
+ engines: {node: ^16.0.0 || >=18.0.0}
peerDependencies:
+ eslint: ^7.0.0 || ^8.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- debug: 4.3.2
- eslint-visitor-keys: 1.3.0
- glob: 7.1.7
- is-glob: 4.0.1
- lodash: 4.17.21
- semver: 7.3.5
- tsutils: 3.19.1_typescript@3.9.10
- typescript: 3.9.10
+ '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3)
+ '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3)
+ debug: 4.3.4
+ eslint: 8.56.0
+ ts-api-utils: 1.0.3(typescript@5.3.3)
+ typescript: 5.3.3
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/typescript-estree/4.14.0_typescript@4.1.3:
- resolution: {integrity: sha512-wRjZ5qLao+bvS2F7pX4qi2oLcOONIB+ru8RGBieDptq/SudYwshveORwCVU4/yMAd4GK7Fsf8Uq1tjV838erag==}
+ /@typescript-eslint/types@4.33.0:
+ resolution: {integrity: sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ==}
+ engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
+ dev: true
+
+ /@typescript-eslint/types@5.41.0:
+ resolution: {integrity: sha512-5BejraMXMC+2UjefDvrH0Fo/eLwZRV6859SXRg+FgbhA0R0l6lDqDGAQYhKbXhPN2ofk2kY5sgGyLNL907UXpA==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ dev: true
+
+ /@typescript-eslint/types@6.19.0:
+ resolution: {integrity: sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ dev: true
+
+ /@typescript-eslint/typescript-estree@4.33.0(typescript@5.3.3):
+ resolution: {integrity: sha512-rkWRY1MPFzjwnEVHsxGemDzqqddw2QbTJlICPD9p9I9LfsO8fdmfQPOX3uKfUaGRDFJbfrtm/sXhVXN4E+bzCA==}
engines: {node: ^10.12.0 || >=12.0.0}
peerDependencies:
typescript: '*'
@@ -8000,58 +6525,174 @@ packages:
typescript:
optional: true
dependencies:
- '@typescript-eslint/types': 4.14.0
- '@typescript-eslint/visitor-keys': 4.14.0
- debug: 4.3.1
- globby: 11.0.2
- is-glob: 4.0.1
- lodash: 4.17.20
- semver: 7.3.4
- tsutils: 3.19.1_typescript@4.1.3
- typescript: 4.1.3
+ '@typescript-eslint/types': 4.33.0
+ '@typescript-eslint/visitor-keys': 4.33.0
+ debug: 4.3.4
+ globby: 11.1.0
+ is-glob: 4.0.3
+ semver: 7.5.4
+ tsutils: 3.21.0(typescript@5.3.3)
+ typescript: 5.3.3
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/typescript-estree/4.4.1_typescript@4.1.3:
- resolution: {integrity: sha512-wP/V7ScKzgSdtcY1a0pZYBoCxrCstLrgRQ2O9MmCUZDtmgxCO/TCqOTGRVwpP4/2hVfqMz/Vw1ZYrG8cVxvN3g==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ /@typescript-eslint/typescript-estree@5.41.0(typescript@5.3.3):
+ resolution: {integrity: sha512-SlzFYRwFSvswzDSQ/zPkIWcHv8O5y42YUskko9c4ki+fV6HATsTODUPbRbcGDFYP86gaJL5xohUEytvyNNcXWg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
- '@typescript-eslint/types': 4.4.1
- '@typescript-eslint/visitor-keys': 4.4.1
- debug: 4.3.1
- globby: 11.0.2
- is-glob: 4.0.1
- lodash: 4.17.20
- semver: 7.3.4
- tsutils: 3.19.1_typescript@4.1.3
- typescript: 4.1.3
+ '@typescript-eslint/types': 5.41.0
+ '@typescript-eslint/visitor-keys': 5.41.0
+ debug: 4.3.4
+ globby: 11.1.0
+ is-glob: 4.0.3
+ semver: 7.5.4
+ tsutils: 3.21.0(typescript@5.3.3)
+ typescript: 5.3.3
transitivePeerDependencies:
- supports-color
dev: true
- /@typescript-eslint/visitor-keys/4.14.0:
- resolution: {integrity: sha512-MeHHzUyRI50DuiPgV9+LxcM52FCJFYjJiWHtXlbyC27b80mfOwKeiKI+MHOTEpcpfmoPFm/vvQS88bYIx6PZTA==}
- engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
+ /@typescript-eslint/typescript-estree@6.19.0(typescript@5.3.3):
+ resolution: {integrity: sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ dependencies:
+ '@typescript-eslint/types': 6.19.0
+ '@typescript-eslint/visitor-keys': 6.19.0
+ debug: 4.3.4
+ globby: 11.1.0
+ is-glob: 4.0.3
+ minimatch: 9.0.3
+ semver: 7.5.4
+ ts-api-utils: 1.0.3(typescript@5.3.3)
+ typescript: 5.3.3
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@typescript-eslint/utils@5.41.0(eslint@7.32.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-QlvfwaN9jaMga9EBazQ+5DDx/4sAdqDkcs05AsQHMaopluVCUyu1bTRUVKzXbgjDlrRAQrYVoi/sXJ9fmG+KLQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
+ dependencies:
+ '@types/json-schema': 7.0.11
+ '@types/semver': 7.3.12
+ '@typescript-eslint/scope-manager': 5.41.0
+ '@typescript-eslint/types': 5.41.0
+ '@typescript-eslint/typescript-estree': 5.41.0(typescript@5.3.3)
+ eslint: 7.32.0
+ eslint-scope: 5.1.1
+ eslint-utils: 3.0.0(eslint@7.32.0)
+ semver: 7.5.4
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+ dev: true
+
+ /@typescript-eslint/utils@5.41.0(eslint@8.26.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-QlvfwaN9jaMga9EBazQ+5DDx/4sAdqDkcs05AsQHMaopluVCUyu1bTRUVKzXbgjDlrRAQrYVoi/sXJ9fmG+KLQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
+ dependencies:
+ '@types/json-schema': 7.0.11
+ '@types/semver': 7.3.12
+ '@typescript-eslint/scope-manager': 5.41.0
+ '@typescript-eslint/types': 5.41.0
+ '@typescript-eslint/typescript-estree': 5.41.0(typescript@5.3.3)
+ eslint: 8.26.0
+ eslint-scope: 5.1.1
+ eslint-utils: 3.0.0(eslint@8.26.0)
+ semver: 7.5.4
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+ dev: true
+
+ /@typescript-eslint/utils@6.19.0(eslint@8.56.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ peerDependencies:
+ eslint: ^7.0.0 || ^8.0.0
dependencies:
- '@typescript-eslint/types': 4.14.0
- eslint-visitor-keys: 2.0.0
+ '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0)
+ '@types/json-schema': 7.0.15
+ '@types/semver': 7.5.6
+ '@typescript-eslint/scope-manager': 6.19.0
+ '@typescript-eslint/types': 6.19.0
+ '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3)
+ eslint: 8.56.0
+ semver: 7.5.4
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
dev: true
- /@typescript-eslint/visitor-keys/4.4.1:
- resolution: {integrity: sha512-H2JMWhLaJNeaylSnMSQFEhT/S/FsJbebQALmoJxMPMxLtlVAMy2uJP/Z543n9IizhjRayLSqoInehCeNW9rWcw==}
+ /@typescript-eslint/visitor-keys@4.33.0:
+ resolution: {integrity: sha512-uqi/2aSz9g2ftcHWf8uLPJA70rUv6yuMW5Bohw+bwcuzaxQIHaKFZCKGoGXIrc9vkTJ3+0txM73K0Hq3d5wgIg==}
engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
dependencies:
- '@typescript-eslint/types': 4.4.1
- eslint-visitor-keys: 2.0.0
+ '@typescript-eslint/types': 4.33.0
+ eslint-visitor-keys: 2.1.0
+ dev: true
+
+ /@typescript-eslint/visitor-keys@5.41.0:
+ resolution: {integrity: sha512-vilqeHj267v8uzzakbm13HkPMl7cbYpKVjgFWZPIOHIJHZtinvypUhJ5xBXfWYg4eFKqztbMMpOgFpT9Gfx4fw==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ dependencies:
+ '@typescript-eslint/types': 5.41.0
+ eslint-visitor-keys: 3.4.3
+ dev: true
+
+ /@typescript-eslint/visitor-keys@6.19.0:
+ resolution: {integrity: sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==}
+ engines: {node: ^16.0.0 || >=18.0.0}
+ dependencies:
+ '@typescript-eslint/types': 6.19.0
+ eslint-visitor-keys: 3.4.3
dev: true
- /@webassemblyjs/ast/1.9.0:
+ /@ungap/promise-all-settled@1.1.2:
+ resolution: {integrity: sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==}
+ dev: true
+
+ /@ungap/structured-clone@1.2.0:
+ resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
+ dev: true
+
+ /@vercel/nft@0.24.4:
+ resolution: {integrity: sha512-KjYAZty7boH5fi5udp6p+lNu6nawgs++pHW+3koErMgbRkkHuToGX/FwjN5clV1FcaM3udfd4zW/sUapkMgpZw==}
+ engines: {node: '>=16'}
+ hasBin: true
+ dependencies:
+ '@mapbox/node-pre-gyp': 1.0.11
+ '@rollup/pluginutils': 4.2.1
+ acorn: 8.11.2
+ async-sema: 3.1.1
+ bindings: 1.5.0
+ estree-walker: 2.0.2
+ glob: 7.2.3
+ graceful-fs: 4.2.11
+ micromatch: 4.0.5
+ node-gyp-build: 4.7.1
+ resolve-from: 5.0.0
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+ dev: true
+
+ /@webassemblyjs/ast@1.9.0:
resolution: {integrity: sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==}
dependencies:
'@webassemblyjs/helper-module-context': 1.9.0
@@ -8059,39 +6700,39 @@ packages:
'@webassemblyjs/wast-parser': 1.9.0
dev: true
- /@webassemblyjs/floating-point-hex-parser/1.9.0:
+ /@webassemblyjs/floating-point-hex-parser@1.9.0:
resolution: {integrity: sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==}
dev: true
- /@webassemblyjs/helper-api-error/1.9.0:
+ /@webassemblyjs/helper-api-error@1.9.0:
resolution: {integrity: sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==}
dev: true
- /@webassemblyjs/helper-buffer/1.9.0:
+ /@webassemblyjs/helper-buffer@1.9.0:
resolution: {integrity: sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==}
dev: true
- /@webassemblyjs/helper-code-frame/1.9.0:
+ /@webassemblyjs/helper-code-frame@1.9.0:
resolution: {integrity: sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA==}
dependencies:
'@webassemblyjs/wast-printer': 1.9.0
dev: true
- /@webassemblyjs/helper-fsm/1.9.0:
+ /@webassemblyjs/helper-fsm@1.9.0:
resolution: {integrity: sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw==}
dev: true
- /@webassemblyjs/helper-module-context/1.9.0:
+ /@webassemblyjs/helper-module-context@1.9.0:
resolution: {integrity: sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==}
dependencies:
'@webassemblyjs/ast': 1.9.0
dev: true
- /@webassemblyjs/helper-wasm-bytecode/1.9.0:
+ /@webassemblyjs/helper-wasm-bytecode@1.9.0:
resolution: {integrity: sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==}
dev: true
- /@webassemblyjs/helper-wasm-section/1.9.0:
+ /@webassemblyjs/helper-wasm-section@1.9.0:
resolution: {integrity: sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==}
dependencies:
'@webassemblyjs/ast': 1.9.0
@@ -8100,23 +6741,23 @@ packages:
'@webassemblyjs/wasm-gen': 1.9.0
dev: true
- /@webassemblyjs/ieee754/1.9.0:
+ /@webassemblyjs/ieee754@1.9.0:
resolution: {integrity: sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==}
dependencies:
'@xtuc/ieee754': 1.2.0
dev: true
- /@webassemblyjs/leb128/1.9.0:
+ /@webassemblyjs/leb128@1.9.0:
resolution: {integrity: sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==}
dependencies:
'@xtuc/long': 4.2.2
dev: true
- /@webassemblyjs/utf8/1.9.0:
+ /@webassemblyjs/utf8@1.9.0:
resolution: {integrity: sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==}
dev: true
- /@webassemblyjs/wasm-edit/1.9.0:
+ /@webassemblyjs/wasm-edit@1.9.0:
resolution: {integrity: sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==}
dependencies:
'@webassemblyjs/ast': 1.9.0
@@ -8129,7 +6770,7 @@ packages:
'@webassemblyjs/wast-printer': 1.9.0
dev: true
- /@webassemblyjs/wasm-gen/1.9.0:
+ /@webassemblyjs/wasm-gen@1.9.0:
resolution: {integrity: sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==}
dependencies:
'@webassemblyjs/ast': 1.9.0
@@ -8139,7 +6780,7 @@ packages:
'@webassemblyjs/utf8': 1.9.0
dev: true
- /@webassemblyjs/wasm-opt/1.9.0:
+ /@webassemblyjs/wasm-opt@1.9.0:
resolution: {integrity: sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==}
dependencies:
'@webassemblyjs/ast': 1.9.0
@@ -8148,7 +6789,7 @@ packages:
'@webassemblyjs/wasm-parser': 1.9.0
dev: true
- /@webassemblyjs/wasm-parser/1.9.0:
+ /@webassemblyjs/wasm-parser@1.9.0:
resolution: {integrity: sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==}
dependencies:
'@webassemblyjs/ast': 1.9.0
@@ -8159,7 +6800,7 @@ packages:
'@webassemblyjs/utf8': 1.9.0
dev: true
- /@webassemblyjs/wast-parser/1.9.0:
+ /@webassemblyjs/wast-parser@1.9.0:
resolution: {integrity: sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==}
dependencies:
'@webassemblyjs/ast': 1.9.0
@@ -8170,7 +6811,7 @@ packages:
'@xtuc/long': 4.2.2
dev: true
- /@webassemblyjs/wast-printer/1.9.0:
+ /@webassemblyjs/wast-printer@1.9.0:
resolution: {integrity: sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==}
dependencies:
'@webassemblyjs/ast': 1.9.0
@@ -8178,114 +6819,201 @@ packages:
'@xtuc/long': 4.2.2
dev: true
- /@xtuc/ieee754/1.2.0:
+ /@xtuc/ieee754@1.2.0:
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
dev: true
- /@xtuc/long/4.2.2:
+ /@xtuc/long@4.2.2:
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
dev: true
- /abab/2.0.5:
- resolution: {integrity: sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==}
+ /abab@2.0.6:
+ resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
dev: true
- /accepts/1.3.7:
- resolution: {integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==}
- engines: {node: '>= 0.6'}
+ /abbrev@1.1.1:
+ resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
+ dev: true
+
+ /abort-controller@3.0.0:
+ resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
+ engines: {node: '>=6.5'}
dependencies:
- mime-types: 2.1.32
- negotiator: 0.6.2
+ event-target-shim: 5.0.1
dev: true
- /acorn-es7-plugin/1.1.7:
- resolution: {integrity: sha1-8u4fMiipDurRJF+asZIusucdM2s=}
+ /accepts@1.3.8:
+ resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+ engines: {node: '>= 0.6'}
+ dependencies:
+ mime-types: 2.1.35
+ negotiator: 0.6.3
dev: true
- /acorn-globals/4.3.4:
+ /acorn-globals@4.3.4:
resolution: {integrity: sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==}
dependencies:
acorn: 6.4.2
acorn-walk: 6.2.0
dev: true
- /acorn-globals/6.0.0:
- resolution: {integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==}
+ /acorn-jsx@5.3.2(acorn@7.4.1):
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies:
acorn: 7.4.1
- acorn-walk: 7.2.0
dev: true
- /acorn-jsx/5.3.1_acorn@7.4.1:
- resolution: {integrity: sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==}
+ /acorn-jsx@5.3.2(acorn@8.11.2):
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies:
- acorn: 7.4.1
+ acorn: 8.11.2
dev: true
- /acorn-jsx/5.3.2_acorn@7.4.1:
+ /acorn-jsx@5.3.2(acorn@8.8.1):
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies:
- acorn: 7.4.1
+ acorn: 8.8.1
dev: true
- /acorn-walk/6.2.0:
+ /acorn-walk@6.2.0:
resolution: {integrity: sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==}
engines: {node: '>=0.4.0'}
dev: true
- /acorn-walk/7.2.0:
- resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==}
+ /acorn-walk@8.2.0:
+ resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==}
engines: {node: '>=0.4.0'}
dev: true
- /acorn-walk/8.1.1:
- resolution: {integrity: sha512-FbJdceMlPHEAWJOILDk1fXD8lnTlEIWFkqtfk+MvmL5q/qlHfN7GEHcsFZWt/Tea9jRNPWUZG4G976nqAAmU9w==}
+ /acorn-walk@8.3.1:
+ resolution: {integrity: sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==}
engines: {node: '>=0.4.0'}
dev: true
- /acorn/5.7.4:
- resolution: {integrity: sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==}
+ /acorn@6.4.2:
+ resolution: {integrity: sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==}
engines: {node: '>=0.4.0'}
hasBin: true
dev: true
- /acorn/6.4.2:
- resolution: {integrity: sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==}
+ /acorn@7.4.1:
+ resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
engines: {node: '>=0.4.0'}
- hasBin: true
dev: true
- /acorn/7.4.1:
- resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
+ /acorn@8.11.2:
+ resolution: {integrity: sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==}
engines: {node: '>=0.4.0'}
hasBin: true
dev: true
- /acorn/8.4.1:
- resolution: {integrity: sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA==}
+ /acorn@8.8.1:
+ resolution: {integrity: sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==}
+ engines: {node: '>=0.4.0'}
+ dev: true
+
+ /acorn@8.8.2:
+ resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==}
engines: {node: '>=0.4.0'}
hasBin: true
dev: true
- /address/1.1.2:
- resolution: {integrity: sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA==}
- engines: {node: '>= 0.12.0'}
+ /addons-linter@6.21.0(node-fetch@3.3.1):
+ resolution: {integrity: sha512-4GBn14BR16FZE7dog6uz+1HO6V3B+mAVxmbwxRhed2y5eyrwIW832TmEpku+5A5bbovBZ4gilXEtBsl6A1AMmg==}
+ engines: {node: '>=16.0.0'}
+ hasBin: true
+ dependencies:
+ '@fluent/syntax': 0.19.0
+ '@mdn/browser-compat-data': 5.5.7
+ addons-moz-compare: 1.3.0
+ addons-scanner-utils: 9.9.0(node-fetch@3.3.1)
+ ajv: 8.12.0
+ chalk: 4.1.2
+ cheerio: 1.0.0-rc.12
+ columnify: 1.6.0
+ common-tags: 1.8.2
+ deepmerge: 4.3.1
+ eslint: 8.56.0
+ eslint-plugin-no-unsanitized: 4.0.2(eslint@8.56.0)
+ eslint-visitor-keys: 3.4.3
+ espree: 9.6.1
+ esprima: 4.0.1
+ fast-json-patch: 3.1.1
+ glob: 10.3.10
+ image-size: 1.1.1
+ is-mergeable-object: 1.1.1
+ jed: 1.1.1
+ json-merge-patch: 1.0.2
+ os-locale: 5.0.0
+ pino: 8.17.2
+ postcss: 8.4.33
+ relaxed-json: 1.0.3
+ semver: 7.5.4
+ sha.js: 2.4.11
+ source-map-support: 0.5.21
+ tosource: 1.0.0
+ upath: 2.0.1
+ yargs: 17.7.2
+ yauzl: 2.10.0
+ transitivePeerDependencies:
+ - body-parser
+ - express
+ - node-fetch
+ - safe-compare
+ - supports-color
dev: true
- /agent-base/6.0.2:
+ /addons-moz-compare@1.3.0:
+ resolution: {integrity: sha512-/rXpQeaY0nOKhNx00pmZXdk5Mu+KhVlL3/pSBuAYwrxRrNiTvI/9xfQI8Lmm7DMMl+PDhtfAHY/0ibTpdeoQQQ==}
+ dev: true
+
+ /addons-scanner-utils@9.9.0(node-fetch@3.3.1):
+ resolution: {integrity: sha512-YDP10U3sEZMuIgnjXMiAYgUU64jTbxmhpUXMlhi1nKO4Etz+ctGWoTUst7IQRoLWaY9y2r1KZDG3jALxLA1n7Q==}
+ peerDependencies:
+ body-parser: 1.20.2
+ express: 4.18.2
+ node-fetch: 2.6.11
+ safe-compare: 1.1.4
+ peerDependenciesMeta:
+ body-parser:
+ optional: true
+ express:
+ optional: true
+ node-fetch:
+ optional: true
+ safe-compare:
+ optional: true
+ dependencies:
+ '@types/yauzl': 2.10.3
+ common-tags: 1.8.2
+ first-chunk-stream: 3.0.0
+ node-fetch: 3.3.1
+ strip-bom-stream: 4.0.0
+ upath: 2.0.1
+ yauzl: 2.10.0
+ dev: true
+
+ /adm-zip@0.5.10:
+ resolution: {integrity: sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==}
+ engines: {node: '>=6.0'}
+ dev: true
+
+ /agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
dependencies:
- debug: 4.3.2
+ debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: true
- /aggregate-error/3.1.0:
+ /aggregate-error@3.1.0:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
dependencies:
@@ -8293,29 +7021,7 @@ packages:
indent-string: 4.0.0
dev: true
- /airbnb-js-shims/2.2.1:
- resolution: {integrity: sha512-wJNXPH66U2xjgo1Zwyjf9EydvJ2Si94+vSdk6EERcBfB2VZkeltpqIats0cqIZMLCXP3zcyaUKGYQeIBT6XjsQ==}
- dependencies:
- array-includes: 3.1.3
- array.prototype.flat: 1.2.4
- array.prototype.flatmap: 1.2.4
- es5-shim: 4.5.15
- es6-shim: 0.35.6
- function.prototype.name: 1.1.4
- globalthis: 1.0.2
- object.entries: 1.1.4
- object.fromentries: 2.0.4
- object.getownpropertydescriptors: 2.1.2
- object.values: 1.1.4
- promise.allsettled: 1.0.4
- promise.prototype.finally: 3.1.2
- string.prototype.matchall: 4.0.5
- string.prototype.padend: 3.1.2
- string.prototype.padstart: 3.1.2
- symbol.prototype.description: 1.0.5
- dev: true
-
- /ajv-errors/1.0.1_ajv@6.12.6:
+ /ajv-errors@1.0.1(ajv@6.12.6):
resolution: {integrity: sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==}
peerDependencies:
ajv: '>=5.0.0'
@@ -8323,7 +7029,18 @@ packages:
ajv: 6.12.6
dev: true
- /ajv-keywords/3.5.2_ajv@6.12.6:
+ /ajv-formats@2.1.1(ajv@8.11.0):
+ resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+ dependencies:
+ ajv: 8.11.0
+ dev: true
+
+ /ajv-keywords@3.5.2(ajv@6.12.6):
resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==}
peerDependencies:
ajv: ^6.9.1
@@ -8331,7 +7048,16 @@ packages:
ajv: 6.12.6
dev: true
- /ajv/6.12.6:
+ /ajv-keywords@5.1.0(ajv@8.11.0):
+ resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==}
+ peerDependencies:
+ ajv: ^8.8.2
+ dependencies:
+ ajv: 8.11.0
+ fast-deep-equal: 3.1.3
+ dev: true
+
+ /ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
dependencies:
fast-deep-equal: 3.1.3
@@ -8340,8 +7066,8 @@ packages:
uri-js: 4.4.1
dev: true
- /ajv/7.0.3:
- resolution: {integrity: sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ==}
+ /ajv@8.11.0:
+ resolution: {integrity: sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==}
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
@@ -8349,8 +7075,8 @@ packages:
uri-js: 4.4.1
dev: true
- /ajv/8.6.2:
- resolution: {integrity: sha512-9807RlWAgT564wT+DjeyU5OFMPjmzxVobvDFmNAhY+5zD6A2ly3jDp6sgnfyDtlIQ+7H97oc/DGCzzfu9rjw9w==}
+ /ajv@8.12.0:
+ resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==}
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
@@ -8358,274 +7084,291 @@ packages:
uri-js: 4.4.1
dev: true
- /alphanum-sort/1.0.2:
- resolution: {integrity: sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=}
+ /alphanum-sort@1.0.2:
+ resolution: {integrity: sha512-0FcBfdcmaumGPQ0qPn7Q5qTgz/ooXgIyp1rf8ik5bGX8mpE2YHjC0P/eyQvxu1GURYQgq9ozf2mteQ5ZD9YiyQ==}
dev: true
- /ansi-align/3.0.0:
- resolution: {integrity: sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==}
+ /ansi-align@3.0.1:
+ resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
dependencies:
- string-width: 3.1.0
+ string-width: 4.2.3
dev: true
- /ansi-colors/3.2.4:
+ /ansi-colors@3.2.4:
resolution: {integrity: sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA==}
engines: {node: '>=6'}
dev: true
- /ansi-colors/4.1.1:
+ /ansi-colors@4.1.1:
resolution: {integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==}
engines: {node: '>=6'}
dev: true
- /ansi-escapes/4.3.2:
- resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
- engines: {node: '>=8'}
- dependencies:
- type-fest: 0.21.3
+ /ansi-colors@4.1.3:
+ resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
+ engines: {node: '>=6'}
dev: true
- /ansi-html/0.0.7:
- resolution: {integrity: sha1-gTWEAhliqenm/QOflA0S9WynhZ4=}
+ /ansi-html-community@0.0.8:
+ resolution: {integrity: sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==}
engines: {'0': node >= 0.8.0}
- hasBin: true
dev: true
- /ansi-regex/2.1.1:
- resolution: {integrity: sha1-w7M6te42DYbg5ijwRorn7yfWVN8=}
+ /ansi-regex@2.1.1:
+ resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==}
engines: {node: '>=0.10.0'}
dev: true
- /ansi-regex/4.1.0:
- resolution: {integrity: sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==}
- engines: {node: '>=6'}
- dev: true
-
- /ansi-regex/5.0.0:
- resolution: {integrity: sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==}
+ /ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
+
+ /ansi-regex@6.0.1:
+ resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==}
+ engines: {node: '>=12'}
+
+ /ansi-sequence-parser@1.1.1:
+ resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==}
dev: true
- /ansi-styles/1.0.0:
- resolution: {integrity: sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=}
+ /ansi-styles@1.0.0:
+ resolution: {integrity: sha512-3iF4FIKdxaVYT3JqQuY3Wat/T2t7TRbbQ94Fu50ZUCbLy4TFbTzr90NOHQodQkNqmeEGCw8WbeP78WNi6SKYUA==}
engines: {node: '>=0.8.0'}
dev: true
- /ansi-styles/3.2.1:
+ /ansi-styles@3.2.1:
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
engines: {node: '>=4'}
dependencies:
color-convert: 1.9.3
- dev: true
- /ansi-styles/4.3.0:
+ /ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
dependencies:
color-convert: 2.0.1
- dev: true
- /ansi-styles/5.2.0:
- resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
- engines: {node: '>=10'}
- dev: true
+ /ansi-styles@6.2.1:
+ resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
+ engines: {node: '>=12'}
- /ansi-to-html/0.6.15:
- resolution: {integrity: sha512-28ijx2aHJGdzbs+O5SNQF65r6rrKYnkuwTYm8lZlChuoJ9P1vVzIpWO20sQTqTPDXYp6NFwk326vApTtLVFXpQ==}
- engines: {node: '>=8.0.0'}
- hasBin: true
- dependencies:
- entities: 2.2.0
- dev: true
+ /any-promise@1.3.0:
+ resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
- /anymatch/2.0.0:
+ /anymatch@2.0.0:
resolution: {integrity: sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==}
+ requiresBuild: true
dependencies:
micromatch: 3.1.10
normalize-path: 2.1.1
+ transitivePeerDependencies:
+ - supports-color
dev: true
+ optional: true
- /anymatch/3.1.2:
+ /anymatch@3.1.2:
resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==}
engines: {node: '>= 8'}
dependencies:
normalize-path: 3.0.0
- picomatch: 2.3.0
- dev: true
+ picomatch: 2.3.1
- /app-root-dir/1.0.2:
- resolution: {integrity: sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg=}
- dev: true
-
- /append-transform/2.0.0:
+ /append-transform@2.0.0:
resolution: {integrity: sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==}
engines: {node: '>=8'}
dependencies:
- default-require-extensions: 3.0.0
+ default-require-extensions: 3.0.1
dev: true
- /aproba/1.2.0:
+ /aproba@1.2.0:
resolution: {integrity: sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==}
dev: true
- /archy/1.0.0:
- resolution: {integrity: sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=}
+ /aproba@2.0.0:
+ resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==}
+ dev: true
+
+ /archy@1.0.0:
+ resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==}
dev: true
- /are-we-there-yet/1.1.5:
- resolution: {integrity: sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==}
+ /are-we-there-yet@2.0.0:
+ resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==}
+ engines: {node: '>=10'}
dependencies:
delegates: 1.0.0
- readable-stream: 2.3.7
+ readable-stream: 3.6.2
dev: true
- /argparse/1.0.10:
+ /arg@4.1.3:
+ resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
+ dev: true
+
+ /arg@5.0.2:
+ resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
+ /argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
dependencies:
sprintf-js: 1.0.3
dev: true
- /aria-query/4.2.2:
- resolution: {integrity: sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==}
- engines: {node: '>=6.0'}
+ /argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ dev: true
+
+ /aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
dependencies:
- '@babel/runtime': 7.15.3
- '@babel/runtime-corejs3': 7.15.3
+ dequal: 2.0.3
dev: true
- /arr-diff/4.0.0:
- resolution: {integrity: sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=}
+ /arr-diff@4.0.0:
+ resolution: {integrity: sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==}
engines: {node: '>=0.10.0'}
dev: true
- /arr-flatten/1.1.0:
+ /arr-flatten@1.1.0:
resolution: {integrity: sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==}
engines: {node: '>=0.10.0'}
dev: true
- /arr-union/3.1.0:
- resolution: {integrity: sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=}
+ /arr-union@3.1.0:
+ resolution: {integrity: sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==}
engines: {node: '>=0.10.0'}
dev: true
- /array-equal/1.0.0:
- resolution: {integrity: sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=}
+ /array-buffer-byte-length@1.0.0:
+ resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==}
+ dependencies:
+ call-bind: 1.0.5
+ is-array-buffer: 3.0.2
dev: true
- /array-find-index/1.0.2:
- resolution: {integrity: sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=}
- engines: {node: '>=0.10.0'}
+ /array-differ@4.0.0:
+ resolution: {integrity: sha512-Q6VPTLMsmXZ47ENG3V+wQyZS1ZxXMxFyYzA+Z/GMrJ6yIutAIEf9wTyroTzmGjNfox9/h3GdGBCVh43GVFx4Uw==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
- /array-flatten/1.1.1:
- resolution: {integrity: sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=}
+ /array-equal@1.0.0:
+ resolution: {integrity: sha512-H3LU5RLiSsGXPhN+Nipar0iR0IofH+8r89G2y1tBKxQ/agagKyAjhkAFDRBfodP2caPrNKHpAWNIM/c9yeL7uA==}
dev: true
- /array-flatten/2.1.2:
- resolution: {integrity: sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==}
+ /array-find-index@1.0.2:
+ resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==}
+ engines: {node: '>=0.10.0'}
dev: true
- /array-includes/3.1.2:
- resolution: {integrity: sha512-w2GspexNQpx+PutG3QpT437/BenZBj0M/MZGn5mzv/MofYqo0xmRHzn4lFsoDlWJ+THYsGJmFlW68WlDFx7VRw==}
- engines: {node: '>= 0.4'}
- dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.0-next.2
- get-intrinsic: 1.0.2
- is-string: 1.0.5
+ /array-flatten@1.1.1:
+ resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
dev: true
- /array-includes/3.1.3:
- resolution: {integrity: sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==}
- engines: {node: '>= 0.4'}
- dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
- get-intrinsic: 1.1.1
- is-string: 1.0.7
+ /array-flatten@2.1.2:
+ resolution: {integrity: sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==}
dev: true
- /array-union/1.0.2:
- resolution: {integrity: sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=}
- engines: {node: '>=0.10.0'}
+ /array-includes@3.1.7:
+ resolution: {integrity: sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==}
+ engines: {node: '>= 0.4'}
dependencies:
- array-uniq: 1.0.3
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ get-intrinsic: 1.2.2
+ is-string: 1.0.7
dev: true
- /array-union/2.1.0:
+ /array-union@2.1.0:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'}
dev: true
- /array-uniq/1.0.3:
- resolution: {integrity: sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=}
- engines: {node: '>=0.10.0'}
+ /array-union@3.0.1:
+ resolution: {integrity: sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==}
+ engines: {node: '>=12'}
dev: true
- /array-unique/0.3.2:
- resolution: {integrity: sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=}
+ /array-unique@0.3.2:
+ resolution: {integrity: sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==}
engines: {node: '>=0.10.0'}
dev: true
- /array.prototype.filter/1.0.0:
- resolution: {integrity: sha512-TfO1gz+tLm+Bswq0FBOXPqAchtCr2Rn48T8dLJoRFl8NoEosjZmzptmuo1X8aZBzZcqsR1W8U761tjACJtngTQ==}
+ /array.prototype.findlastindex@1.2.3:
+ resolution: {integrity: sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
- es-array-method-boxes-properly: 1.0.0
- is-string: 1.0.7
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ es-shim-unscopables: 1.0.2
+ get-intrinsic: 1.2.2
dev: true
- /array.prototype.flat/1.2.4:
- resolution: {integrity: sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==}
+ /array.prototype.flat@1.3.2:
+ resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ es-shim-unscopables: 1.0.2
dev: true
- /array.prototype.flatmap/1.2.4:
- resolution: {integrity: sha512-r9Z0zYoxqHz60vvQbWEdXIEtCwHF0yxaWfno9qzXeNHvfyl3BZqygmGzb84dsubyaXLH4husF+NFgMSdpZhk2Q==}
+ /array.prototype.flatmap@1.3.2:
+ resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
- function-bind: 1.1.1
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ es-shim-unscopables: 1.0.2
dev: true
- /array.prototype.map/1.0.3:
- resolution: {integrity: sha512-nNcb30v0wfDyIe26Yif3PcV1JXQp4zEeEfupG7L4SRjnD6HLbO5b2a7eVSba53bOx4YCHYMBHt+Fp4vYstneRA==}
+ /array.prototype.reduce@1.0.4:
+ resolution: {integrity: sha512-WnM+AjG/DvLRLo4DDl+r+SvCzYtD2Jd9oeBYMcEaI7t3fFrHY9M53/wdLcTvmZNQ70IU6Htj0emFkZ5TS+lrdw==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
es-array-method-boxes-properly: 1.0.0
is-string: 1.0.7
dev: true
- /arrgv/1.0.2:
- resolution: {integrity: sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw==}
- engines: {node: '>=8.0.0'}
+ /array.prototype.tosorted@1.1.2:
+ resolution: {integrity: sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==}
+ dependencies:
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ es-shim-unscopables: 1.0.2
+ get-intrinsic: 1.2.2
dev: true
- /arrify/1.0.1:
- resolution: {integrity: sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=}
- engines: {node: '>=0.10.0'}
+ /arraybuffer.prototype.slice@1.0.2:
+ resolution: {integrity: sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ array-buffer-byte-length: 1.0.0
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ get-intrinsic: 1.2.2
+ is-array-buffer: 3.0.2
+ is-shared-array-buffer: 1.0.2
dev: true
- /arrify/2.0.1:
- resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
- engines: {node: '>=8'}
+ /arrgv@1.0.2:
+ resolution: {integrity: sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw==}
+ engines: {node: '>=8.0.0'}
dev: true
- /asn1.js/5.4.1:
+ /arrify@3.0.0:
+ resolution: {integrity: sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
dependencies:
bn.js: 4.12.0
@@ -8634,220 +7377,229 @@ packages:
safer-buffer: 2.1.2
dev: true
- /asn1/0.2.4:
- resolution: {integrity: sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==}
+ /asn1@0.2.6:
+ resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
dependencies:
safer-buffer: 2.1.2
dev: true
- /assert-plus/1.0.0:
- resolution: {integrity: sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=}
+ /assert-plus@1.0.0:
+ resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
engines: {node: '>=0.8'}
dev: true
- /assert/1.5.0:
+ /assert@1.5.0:
resolution: {integrity: sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==}
dependencies:
object-assign: 4.1.1
util: 0.10.3
dev: true
- /assign-symbols/1.0.0:
- resolution: {integrity: sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=}
+ /assertion-error@1.1.0:
+ resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
+ dev: true
+
+ /assign-symbols@1.0.0:
+ resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==}
engines: {node: '>=0.10.0'}
dev: true
- /ast-metadata-inferer/0.7.0:
+ /ast-metadata-inferer@0.7.0:
resolution: {integrity: sha512-OkMLzd8xelb3gmnp6ToFvvsHLtS6CbagTkFQvQ+ZYFe3/AIl9iKikNR9G7pY3GfOR/2Xc222hwBjzI7HLkE76Q==}
dependencies:
'@mdn/browser-compat-data': 3.3.14
dev: true
- /ast-types-flow/0.0.7:
- resolution: {integrity: sha1-9wtzXGvKGlycItmCw+Oef+ujva0=}
+ /ast-types-flow@0.0.8:
+ resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
dev: true
- /astral-regex/1.0.0:
- resolution: {integrity: sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==}
- engines: {node: '>=4'}
- dev: true
-
- /astral-regex/2.0.0:
+ /astral-regex@2.0.0:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'}
dev: true
- /async-each/1.0.3:
- resolution: {integrity: sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==}
+ /async-each@1.0.6:
+ resolution: {integrity: sha512-c646jH1avxr+aVpndVMeAfYw7wAa6idufrlN3LPA4PmKS0QEGp6PIC9nwz0WQkkvBGAMEki3pFdtxaF39J9vvg==}
+ requiresBuild: true
dev: true
+ optional: true
- /async-limiter/1.0.1:
+ /async-limiter@1.0.1:
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
dev: true
- /async/2.6.3:
- resolution: {integrity: sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==}
+ /async-sema@3.1.1:
+ resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==}
+ dev: true
+
+ /async@3.2.4:
+ resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==}
+ dev: true
+
+ /asynciterator.prototype@1.0.0:
+ resolution: {integrity: sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==}
dependencies:
- lodash: 4.17.21
+ has-symbols: 1.0.3
dev: true
- /asynckit/0.4.0:
- resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=}
+ /asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: true
- /at-least-node/1.0.0:
+ /at-least-node@1.0.0:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
engines: {node: '>= 4.0.0'}
dev: true
- /atob/2.1.2:
+ /atob@2.1.2:
resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==}
engines: {node: '>= 4.5.0'}
hasBin: true
dev: true
- /autoprefixer/10.3.1_postcss@8.3.6:
- resolution: {integrity: sha512-L8AmtKzdiRyYg7BUXJTzigmhbQRCXFKz6SA1Lqo0+AR2FBbQ4aTAPFSDlOutnFkjhiz8my4agGXog1xlMjPJ6A==}
+ /atomic-sleep@1.0.0:
+ resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
+ engines: {node: '>=8.0.0'}
+ dev: true
+
+ /autoprefixer@10.4.14(postcss@8.4.23):
+ resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+ dependencies:
+ browserslist: 4.21.5
+ caniuse-lite: 1.0.30001482
+ fraction.js: 4.2.0
+ normalize-range: 0.1.2
+ picocolors: 1.0.0
+ postcss: 8.4.23
+ postcss-value-parser: 4.2.0
+ dev: true
+
+ /autoprefixer@10.4.14(postcss@8.4.32):
+ resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==}
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
postcss: ^8.1.0
dependencies:
- browserslist: 4.16.8
- caniuse-lite: 1.0.30001251
- colorette: 1.3.0
- fraction.js: 4.1.1
+ browserslist: 4.21.5
+ caniuse-lite: 1.0.30001482
+ fraction.js: 4.2.0
normalize-range: 0.1.2
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ picocolors: 1.0.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /autoprefixer/9.8.6:
- resolution: {integrity: sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg==}
+ /autoprefixer@10.4.14(postcss@8.4.33):
+ resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==}
+ engines: {node: ^10 || ^12 || >=14}
hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
dependencies:
- browserslist: 4.16.8
- caniuse-lite: 1.0.30001251
- colorette: 1.3.0
+ browserslist: 4.21.5
+ caniuse-lite: 1.0.30001482
+ fraction.js: 4.2.0
normalize-range: 0.1.2
- num2fraction: 1.2.2
- postcss: 7.0.36
- postcss-value-parser: 4.1.0
+ picocolors: 1.0.0
+ postcss: 8.4.33
+ postcss-value-parser: 4.2.0
dev: true
- /ava/3.15.0:
- resolution: {integrity: sha512-HGAnk1SHPk4Sx6plFAUkzV/XC1j9+iQhOzt4vBly18/yo0AV8Oytx7mtJd/CR8igCJ5p160N/Oo/cNJi2uSeWA==}
- engines: {node: '>=10.18.0 <11 || >=12.14.0 <12.17.0 || >=12.17.0 <13 || >=14.0.0 <15 || >=15'}
+ /ava@6.0.1(@ava/typescript@4.1.0):
+ resolution: {integrity: sha512-9zR0wOwlcJdOWwHOKnpi0GrPRLTlxDFapGalP4rGD0oQRKxDVoucBBWvxVQ/2cPv10Hx1PkDXLJH5iUzhPn0/g==}
+ engines: {node: ^18.18 || ^20.8 || ^21}
hasBin: true
+ peerDependencies:
+ '@ava/typescript': '*'
+ peerDependenciesMeta:
+ '@ava/typescript':
+ optional: true
dependencies:
- '@concordance/react': 2.0.0
- acorn: 8.4.1
- acorn-walk: 8.1.1
- ansi-styles: 5.2.0
+ '@ava/typescript': 4.1.0
+ '@vercel/nft': 0.24.4
+ acorn: 8.11.2
+ acorn-walk: 8.3.1
+ ansi-styles: 6.2.1
arrgv: 1.0.2
- arrify: 2.0.1
- callsites: 3.1.0
- chalk: 4.1.2
- chokidar: 3.5.2
+ arrify: 3.0.0
+ callsites: 4.1.0
+ cbor: 9.0.1
+ chalk: 5.3.0
chunkd: 2.0.1
- ci-info: 2.0.0
+ ci-info: 4.0.0
ci-parallel-vars: 1.0.1
- clean-yaml-object: 0.1.0
- cli-cursor: 3.1.0
- cli-truncate: 2.1.0
- code-excerpt: 3.0.0
+ cli-truncate: 4.0.0
+ code-excerpt: 4.0.0
common-path-prefix: 3.0.0
concordance: 5.0.4
- convert-source-map: 1.8.0
currently-unhandled: 0.4.1
- debug: 4.3.2
- del: 6.0.0
- emittery: 0.8.1
- equal-length: 1.0.1
- figures: 3.2.0
- globby: 11.0.4
- ignore-by-default: 2.0.0
- import-local: 3.0.2
- indent-string: 4.0.0
- is-error: 2.2.2
+ debug: 4.3.4
+ emittery: 1.0.1
+ figures: 6.0.1
+ globby: 14.0.0
+ ignore-by-default: 2.1.0
+ indent-string: 5.0.0
is-plain-object: 5.0.0
is-promise: 4.0.0
- lodash: 4.17.21
- matcher: 3.0.0
- md5-hex: 3.0.1
- mem: 8.1.1
+ matcher: 5.0.0
+ memoize: 10.0.0
ms: 2.1.3
- ora: 5.4.1
- p-event: 4.2.0
- p-map: 4.0.0
- picomatch: 2.3.0
- pkg-conf: 3.1.0
- plur: 4.0.0
- pretty-ms: 7.0.1
- read-pkg: 5.2.0
+ p-map: 6.0.0
+ package-config: 5.0.0
+ picomatch: 3.0.1
+ plur: 5.1.0
+ pretty-ms: 8.0.0
resolve-cwd: 3.0.0
- slash: 3.0.0
- source-map-support: 0.5.19
- stack-utils: 2.0.3
- strip-ansi: 6.0.0
- supertap: 2.0.0
- temp-dir: 2.0.0
- trim-off-newlines: 1.0.1
- update-notifier: 5.1.0
- write-file-atomic: 3.0.3
- yargs: 16.2.0
+ stack-utils: 2.0.6
+ strip-ansi: 7.1.0
+ supertap: 3.0.1
+ temp-dir: 3.0.0
+ write-file-atomic: 5.0.1
+ yargs: 17.7.2
transitivePeerDependencies:
+ - encoding
- supports-color
dev: true
- /aws-sign2/0.7.0:
- resolution: {integrity: sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=}
+ /available-typed-arrays@1.0.5:
+ resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==}
+ engines: {node: '>= 0.4'}
dev: true
- /aws4/1.11.0:
- resolution: {integrity: sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==}
+ /aws-sign2@0.7.0:
+ resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==}
dev: true
- /axe-core/4.1.1:
- resolution: {integrity: sha512-5Kgy8Cz6LPC9DJcNb3yjAXTu3XihQgEdnIg50c//zOC/MyLP0Clg+Y8Sh9ZjjnvBrDZU4DgXS9C3T9r4/scGZQ==}
- engines: {node: '>=4'}
+ /aws4@1.11.0:
+ resolution: {integrity: sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==}
dev: true
- /axe-core/4.3.2:
- resolution: {integrity: sha512-5LMaDRWm8ZFPAEdzTYmgjjEdj1YnQcpfrVajO/sn/LhbpGp0Y0H64c2hLZI1gRMxfA+w1S71Uc/nHaOXgcCvGg==}
+ /axe-core@4.7.0:
+ resolution: {integrity: sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==}
engines: {node: '>=4'}
dev: true
- /axios/0.21.1:
- resolution: {integrity: sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==}
+ /axios@0.21.4:
+ resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==}
dependencies:
- follow-redirects: 1.14.2
+ follow-redirects: 1.15.2
transitivePeerDependencies:
- debug
-
- /axobject-query/2.2.0:
- resolution: {integrity: sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==}
dev: true
- /babel-eslint/10.1.0_eslint@6.8.0:
- resolution: {integrity: sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==}
- engines: {node: '>=6'}
- deprecated: babel-eslint is now @babel/eslint-parser. This package will no longer receive updates.
- peerDependencies:
- eslint: '>= 4.12.1'
+ /axobject-query@3.2.1:
+ resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==}
dependencies:
- '@babel/code-frame': 7.14.5
- '@babel/parser': 7.15.3
- '@babel/traverse': 7.15.0
- '@babel/types': 7.15.0
- eslint: 6.8.0
- eslint-visitor-keys: 1.3.0
- resolve: 1.20.0
- transitivePeerDependencies:
- - supports-color
+ dequal: 2.0.3
dev: true
- /babel-esm-plugin/0.9.0_webpack@4.46.0:
+ /babel-esm-plugin@0.9.0(webpack@4.46.0):
resolution: {integrity: sha512-OyPyLI6LUuUqNm3HNUldAkynWrLzXkhcZo4fGTsieCgHqvbCoCIMMOwJmfG9Lmp91S7WDIuUr0mvOeI8pAb/pw==}
peerDependencies:
webpack: ^4.28.4
@@ -8857,328 +7609,187 @@ packages:
webpack: 4.46.0
dev: true
- /babel-helper-builder-react-jsx/6.26.0:
- resolution: {integrity: sha1-Of+DE7dci2Xc7/HzHTg+D/KkCKA=}
- dependencies:
- babel-runtime: 6.26.0
- babel-types: 6.26.0
- esutils: 2.0.3
- dev: true
-
- /babel-jest/26.6.3_@babel+core@7.15.0:
- resolution: {integrity: sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==}
- engines: {node: '>= 10.14.2'}
- peerDependencies:
- '@babel/core': ^7.0.0
- dependencies:
- '@babel/core': 7.15.0
- '@jest/transform': 26.6.2
- '@jest/types': 26.6.2
- '@types/babel__core': 7.1.15
- babel-plugin-istanbul: 6.0.0
- babel-preset-jest: 26.6.2_@babel+core@7.15.0
- chalk: 4.1.2
- graceful-fs: 4.2.8
- slash: 3.0.0
- transitivePeerDependencies:
- - supports-color
- dev: true
-
- /babel-loader/8.2.2_@babel+core@7.13.16:
- resolution: {integrity: sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g==}
+ /babel-loader@8.2.5(@babel/core@7.18.9)(webpack@4.47.0):
+ resolution: {integrity: sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==}
engines: {node: '>= 8.9'}
peerDependencies:
'@babel/core': ^7.0.0
webpack: '>=2'
dependencies:
- '@babel/core': 7.13.16
- find-cache-dir: 3.3.1
- loader-utils: 1.4.0
+ '@babel/core': 7.18.9
+ find-cache-dir: 3.3.2
+ loader-utils: 2.0.3
make-dir: 3.1.0
schema-utils: 2.7.1
+ webpack: 4.47.0
dev: true
- /babel-loader/8.2.2_be352a5a80662835a7707f972edfcfde:
- resolution: {integrity: sha512-JvTd0/D889PQBtUXJ2PXaKU/pjZDMtHA9V2ecm+eNRmmBCMR09a+fmpGTNwnJtFmFl5Ei7Vy47LjBb+L0wQ99g==}
+ /babel-loader@8.2.5(@babel/core@7.22.1)(webpack@4.46.0):
+ resolution: {integrity: sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==}
engines: {node: '>= 8.9'}
peerDependencies:
'@babel/core': ^7.0.0
webpack: '>=2'
dependencies:
- '@babel/core': 7.15.0
- find-cache-dir: 3.3.1
- loader-utils: 1.4.0
+ '@babel/core': 7.22.1
+ find-cache-dir: 3.3.2
+ loader-utils: 2.0.3
make-dir: 3.1.0
schema-utils: 2.7.1
webpack: 4.46.0
dev: true
- /babel-plugin-apply-mdx-type-prop/1.6.22_@babel+core@7.12.9:
- resolution: {integrity: sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ==}
+ /babel-merge@3.0.0(@babel/core@7.23.5):
+ resolution: {integrity: sha512-eBOBtHnzt9xvnjpYNI5HmaPp/b2vMveE5XggzqHnQeHJ8mFIBrBv6WZEVIj5jJ2uwTItkqKo9gWzEEcBxEq0yw==}
peerDependencies:
- '@babel/core': ^7.11.6
- dependencies:
- '@babel/core': 7.12.9
- '@babel/helper-plugin-utils': 7.10.4
- '@mdx-js/util': 1.6.22
- dev: true
-
- /babel-plugin-dynamic-import-node/2.3.3:
- resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==}
- dependencies:
- object.assign: 4.1.2
- dev: true
-
- /babel-plugin-emotion/10.2.2:
- resolution: {integrity: sha512-SMSkGoqTbTyUTDeuVuPIWifPdUGkTk1Kf9BWRiXIOIcuyMfsdp2EjeiiFvOzX8NOBvEh/ypKYvUh2rkgAJMCLA==}
- dependencies:
- '@babel/helper-module-imports': 7.14.5
- '@emotion/hash': 0.8.0
- '@emotion/memoize': 0.7.4
- '@emotion/serialize': 0.11.16
- babel-plugin-macros: 2.8.0
- babel-plugin-syntax-jsx: 6.18.0
- convert-source-map: 1.8.0
- escape-string-regexp: 1.0.5
- find-root: 1.1.0
- source-map: 0.5.7
- dev: true
-
- /babel-plugin-extract-import-names/1.6.22:
- resolution: {integrity: sha512-yJ9BsJaISua7d8zNT7oRG1ZLBJCIdZ4PZqmH8qa9N5AK01ifk3fnkc98AXhtzE7UkfCsEumvoQWgoYLhOnJ7jQ==}
- dependencies:
- '@babel/helper-plugin-utils': 7.10.4
- dev: true
-
- /babel-plugin-istanbul/6.0.0:
- resolution: {integrity: sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ==}
- engines: {node: '>=8'}
- dependencies:
- '@babel/helper-plugin-utils': 7.14.5
- '@istanbuljs/load-nyc-config': 1.1.0
- '@istanbuljs/schema': 0.1.3
- istanbul-lib-instrument: 4.0.3
- test-exclude: 6.0.0
- transitivePeerDependencies:
- - supports-color
- dev: true
-
- /babel-plugin-jest-hoist/26.6.2:
- resolution: {integrity: sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@babel/template': 7.14.5
- '@babel/types': 7.15.0
- '@types/babel__core': 7.1.15
- '@types/babel__traverse': 7.14.2
- dev: true
-
- /babel-plugin-macros/2.8.0:
- resolution: {integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==}
+ '@babel/core': ^7.0.0
dependencies:
- '@babel/runtime': 7.15.3
- cosmiconfig: 6.0.0
- resolve: 1.20.0
+ '@babel/core': 7.23.5
+ deepmerge: 2.2.1
+ object.omit: 3.0.0
dev: true
- /babel-plugin-macros/3.1.0:
+ /babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
dependencies:
- '@babel/runtime': 7.15.3
- cosmiconfig: 7.0.0
- resolve: 1.20.0
- dev: true
-
- /babel-plugin-polyfill-corejs2/0.2.2:
- resolution: {integrity: sha512-kISrENsJ0z5dNPq5eRvcctITNHYXWOA4DUZRFYCz3jYCcvTb/A546LIddmoGNMVYg2U38OyFeNosQwI9ENTqIQ==}
- peerDependencies:
- '@babel/core': ^7.0.0-0
- dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/helper-define-polyfill-provider': 0.2.3
- semver: 6.3.0
- transitivePeerDependencies:
- - supports-color
+ '@babel/runtime': 7.19.4
+ cosmiconfig: 7.0.1
+ resolve: 1.22.8
dev: true
- /babel-plugin-polyfill-corejs2/0.2.2_@babel+core@7.13.16:
- resolution: {integrity: sha512-kISrENsJ0z5dNPq5eRvcctITNHYXWOA4DUZRFYCz3jYCcvTb/A546LIddmoGNMVYg2U38OyFeNosQwI9ENTqIQ==}
+ /babel-plugin-polyfill-corejs2@0.3.3(@babel/core@7.22.1):
+ resolution: {integrity: sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/core': 7.13.16
- '@babel/helper-define-polyfill-provider': 0.2.3_@babel+core@7.13.16
- semver: 6.3.0
+ '@babel/compat-data': 7.23.5
+ '@babel/core': 7.22.1
+ '@babel/helper-define-polyfill-provider': 0.3.3(@babel/core@7.22.1)
+ semver: 6.3.1
transitivePeerDependencies:
- supports-color
dev: true
- /babel-plugin-polyfill-corejs2/0.2.2_@babel+core@7.15.0:
- resolution: {integrity: sha512-kISrENsJ0z5dNPq5eRvcctITNHYXWOA4DUZRFYCz3jYCcvTb/A546LIddmoGNMVYg2U38OyFeNosQwI9ENTqIQ==}
+ /babel-plugin-polyfill-corejs2@0.4.6(@babel/core@7.18.9):
+ resolution: {integrity: sha512-jhHiWVZIlnPbEUKSSNb9YoWcQGdlTLq7z1GHL4AjFxaoOUMuuEVJ+Y4pAaQUGOGk93YsVCKPbqbfw3m0SM6H8Q==}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
dependencies:
- '@babel/compat-data': 7.15.0
- '@babel/core': 7.15.0
- '@babel/helper-define-polyfill-provider': 0.2.3_@babel+core@7.15.0
- semver: 6.3.0
+ '@babel/compat-data': 7.23.5
+ '@babel/core': 7.18.9
+ '@babel/helper-define-polyfill-provider': 0.4.3(@babel/core@7.18.9)
+ semver: 6.3.1
transitivePeerDependencies:
- supports-color
dev: true
- /babel-plugin-polyfill-corejs3/0.1.7_@babel+core@7.15.0:
- resolution: {integrity: sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw==}
+ /babel-plugin-polyfill-corejs2@0.4.6(@babel/core@7.23.5):
+ resolution: {integrity: sha512-jhHiWVZIlnPbEUKSSNb9YoWcQGdlTLq7z1GHL4AjFxaoOUMuuEVJ+Y4pAaQUGOGk93YsVCKPbqbfw3m0SM6H8Q==}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-define-polyfill-provider': 0.1.5_@babel+core@7.15.0
- core-js-compat: 3.16.2
+ '@babel/compat-data': 7.23.5
+ '@babel/core': 7.23.5
+ '@babel/helper-define-polyfill-provider': 0.4.3(@babel/core@7.23.5)
+ semver: 6.3.1
transitivePeerDependencies:
- supports-color
dev: true
- /babel-plugin-polyfill-corejs3/0.2.4:
- resolution: {integrity: sha512-z3HnJE5TY/j4EFEa/qpQMSbcUJZ5JQi+3UFjXzn6pQCmIKc5Ug5j98SuYyH+m4xQnvKlMDIW4plLfgyVnd0IcQ==}
+ /babel-plugin-polyfill-corejs3@0.6.0(@babel/core@7.22.1):
+ resolution: {integrity: sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-define-polyfill-provider': 0.2.3
- core-js-compat: 3.16.2
+ '@babel/core': 7.22.1
+ '@babel/helper-define-polyfill-provider': 0.3.3(@babel/core@7.22.1)
+ core-js-compat: 3.33.3
transitivePeerDependencies:
- supports-color
dev: true
- /babel-plugin-polyfill-corejs3/0.2.4_@babel+core@7.13.16:
- resolution: {integrity: sha512-z3HnJE5TY/j4EFEa/qpQMSbcUJZ5JQi+3UFjXzn6pQCmIKc5Ug5j98SuYyH+m4xQnvKlMDIW4plLfgyVnd0IcQ==}
+ /babel-plugin-polyfill-corejs3@0.8.6(@babel/core@7.18.9):
+ resolution: {integrity: sha512-leDIc4l4tUgU7str5BWLS2h8q2N4Nf6lGZP6UrNDxdtfF2g69eJ5L0H7S8A5Ln/arfFAfHor5InAdZuIOwZdgQ==}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-define-polyfill-provider': 0.2.3_@babel+core@7.13.16
- core-js-compat: 3.16.2
+ '@babel/core': 7.18.9
+ '@babel/helper-define-polyfill-provider': 0.4.3(@babel/core@7.18.9)
+ core-js-compat: 3.33.3
transitivePeerDependencies:
- supports-color
dev: true
- /babel-plugin-polyfill-corejs3/0.2.4_@babel+core@7.15.0:
- resolution: {integrity: sha512-z3HnJE5TY/j4EFEa/qpQMSbcUJZ5JQi+3UFjXzn6pQCmIKc5Ug5j98SuYyH+m4xQnvKlMDIW4plLfgyVnd0IcQ==}
+ /babel-plugin-polyfill-corejs3@0.8.6(@babel/core@7.23.5):
+ resolution: {integrity: sha512-leDIc4l4tUgU7str5BWLS2h8q2N4Nf6lGZP6UrNDxdtfF2g69eJ5L0H7S8A5Ln/arfFAfHor5InAdZuIOwZdgQ==}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-define-polyfill-provider': 0.2.3_@babel+core@7.15.0
- core-js-compat: 3.16.2
+ '@babel/core': 7.23.5
+ '@babel/helper-define-polyfill-provider': 0.4.3(@babel/core@7.23.5)
+ core-js-compat: 3.33.3
transitivePeerDependencies:
- supports-color
dev: true
- /babel-plugin-polyfill-regenerator/0.2.2:
- resolution: {integrity: sha512-Goy5ghsc21HgPDFtzRkSirpZVW35meGoTmTOb2bxqdl60ghub4xOidgNTHaZfQ2FaxQsKmwvXtOAkcIS4SMBWg==}
+ /babel-plugin-polyfill-regenerator@0.4.1(@babel/core@7.22.1):
+ resolution: {integrity: sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
- '@babel/helper-define-polyfill-provider': 0.2.3
+ '@babel/core': 7.22.1
+ '@babel/helper-define-polyfill-provider': 0.3.3(@babel/core@7.22.1)
transitivePeerDependencies:
- supports-color
dev: true
- /babel-plugin-polyfill-regenerator/0.2.2_@babel+core@7.13.16:
- resolution: {integrity: sha512-Goy5ghsc21HgPDFtzRkSirpZVW35meGoTmTOb2bxqdl60ghub4xOidgNTHaZfQ2FaxQsKmwvXtOAkcIS4SMBWg==}
+ /babel-plugin-polyfill-regenerator@0.5.3(@babel/core@7.18.9):
+ resolution: {integrity: sha512-8sHeDOmXC8csczMrYEOf0UTNa4yE2SxV5JGeT/LP1n0OYVDUUFPxG9vdk2AlDlIit4t+Kf0xCtpgXPBwnn/9pw==}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
dependencies:
- '@babel/core': 7.13.16
- '@babel/helper-define-polyfill-provider': 0.2.3_@babel+core@7.13.16
+ '@babel/core': 7.18.9
+ '@babel/helper-define-polyfill-provider': 0.4.3(@babel/core@7.18.9)
transitivePeerDependencies:
- supports-color
dev: true
- /babel-plugin-polyfill-regenerator/0.2.2_@babel+core@7.15.0:
- resolution: {integrity: sha512-Goy5ghsc21HgPDFtzRkSirpZVW35meGoTmTOb2bxqdl60ghub4xOidgNTHaZfQ2FaxQsKmwvXtOAkcIS4SMBWg==}
+ /babel-plugin-polyfill-regenerator@0.5.3(@babel/core@7.23.5):
+ resolution: {integrity: sha512-8sHeDOmXC8csczMrYEOf0UTNa4yE2SxV5JGeT/LP1n0OYVDUUFPxG9vdk2AlDlIit4t+Kf0xCtpgXPBwnn/9pw==}
peerDependencies:
- '@babel/core': ^7.0.0-0
+ '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
dependencies:
- '@babel/core': 7.15.0
- '@babel/helper-define-polyfill-provider': 0.2.3_@babel+core@7.15.0
+ '@babel/core': 7.23.5
+ '@babel/helper-define-polyfill-provider': 0.4.3(@babel/core@7.23.5)
transitivePeerDependencies:
- supports-color
dev: true
- /babel-plugin-syntax-jsx/6.18.0:
- resolution: {integrity: sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY=}
- dev: true
-
- /babel-plugin-transform-react-jsx/6.24.1:
- resolution: {integrity: sha1-hAoCjn30YN/DotKfDA2R9jduZqM=}
- dependencies:
- babel-helper-builder-react-jsx: 6.26.0
- babel-plugin-syntax-jsx: 6.18.0
- babel-runtime: 6.26.0
- dev: true
-
- /babel-plugin-transform-react-remove-prop-types/0.4.24:
+ /babel-plugin-transform-react-remove-prop-types@0.4.24:
resolution: {integrity: sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==}
dev: true
- /babel-preset-current-node-syntax/1.0.1_@babel+core@7.15.0:
- resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==}
- peerDependencies:
- '@babel/core': ^7.0.0
- dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-syntax-async-generators': 7.8.4_@babel+core@7.15.0
- '@babel/plugin-syntax-bigint': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-class-properties': 7.12.13_@babel+core@7.15.0
- '@babel/plugin-syntax-import-meta': 7.10.4_@babel+core@7.15.0
- '@babel/plugin-syntax-json-strings': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-logical-assignment-operators': 7.10.4_@babel+core@7.15.0
- '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-numeric-separator': 7.10.4_@babel+core@7.15.0
- '@babel/plugin-syntax-object-rest-spread': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-optional-catch-binding': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-optional-chaining': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-syntax-top-level-await': 7.14.5_@babel+core@7.15.0
- dev: true
-
- /babel-preset-jest/26.6.2_@babel+core@7.15.0:
- resolution: {integrity: sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ==}
- engines: {node: '>= 10.14.2'}
- peerDependencies:
- '@babel/core': ^7.0.0
- dependencies:
- '@babel/core': 7.15.0
- babel-plugin-jest-hoist: 26.6.2
- babel-preset-current-node-syntax: 1.0.1_@babel+core@7.15.0
- dev: true
-
- /babel-runtime/6.26.0:
- resolution: {integrity: sha1-llxwWGaOgrVde/4E/yM3vItWR/4=}
- dependencies:
- core-js: 2.6.12
- regenerator-runtime: 0.11.1
- dev: true
+ /balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
- /babel-types/6.26.0:
- resolution: {integrity: sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=}
+ /base64-inline-loader@1.1.1(webpack@4.47.0):
+ resolution: {integrity: sha512-v/bHvXQ8sW28t9ZwBsFGgyqZw2bpT49/dtPTtlmixoSM/s9wnOngOKFVQLRH8BfMTy6jTl5m5CdvqpZt8y5d6g==}
+ engines: {node: '>=6.2', npm: '>=3.8'}
+ peerDependencies:
+ webpack: ^4.x
dependencies:
- babel-runtime: 6.26.0
- esutils: 2.0.3
- lodash: 4.17.21
- to-fast-properties: 1.0.3
- dev: true
-
- /bail/1.0.5:
- resolution: {integrity: sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==}
+ file-loader: 1.1.11(webpack@4.47.0)
+ loader-utils: 1.4.0
+ mime-types: 2.1.35
+ webpack: 4.47.0
dev: true
- /balanced-match/1.0.2:
- resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
- dev: true
+ /base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+ requiresBuild: true
- /base/0.11.2:
+ /base@0.11.2:
resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -9191,152 +7802,152 @@ packages:
pascalcase: 0.1.1
dev: true
- /base64-js/1.5.1:
- resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
- dev: true
-
- /batch-processor/1.0.0:
- resolution: {integrity: sha1-dclcMrdI4IUNEMKxaPa9vpiRrOg=}
- dev: true
-
- /batch/0.6.1:
- resolution: {integrity: sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=}
+ /batch@0.6.1:
+ resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==}
dev: true
- /bcrypt-pbkdf/1.0.2:
- resolution: {integrity: sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=}
+ /bcrypt-pbkdf@1.0.2:
+ resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
dependencies:
tweetnacl: 0.14.5
dev: true
- /better-opn/2.1.1:
- resolution: {integrity: sha512-kIPXZS5qwyKiX/HcRvDYfmBQUa8XP17I0mYZZ0y4UhpYOSvtsLHDYqmomS+Mj20aDvD3knEiQ0ecQy2nhio3yA==}
- engines: {node: '>8.0.0'}
+ /better-sqlite3@9.4.0:
+ resolution: {integrity: sha512-5kynxekMxSjCMiFyUBLHggFcJkCmiZi6fUkiGz/B5GZOvdRWQJD0klqCx5/Y+bm2AKP7I/DHbSFx26AxjruWNg==}
+ requiresBuild: true
dependencies:
- open: 7.4.2
- dev: true
+ bindings: 1.5.0
+ prebuild-install: 7.1.1
+ dev: false
+ optional: true
- /big-integer/1.6.48:
- resolution: {integrity: sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w==}
+ /big-integer@1.6.52:
+ resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
engines: {node: '>=0.6'}
dev: false
- /big.js/3.2.0:
+ /big.js@3.2.0:
resolution: {integrity: sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==}
dev: true
- /big.js/5.2.2:
+ /big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
dev: true
- /binary-extensions/1.13.1:
+ /binary-extensions@1.13.1:
resolution: {integrity: sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==}
engines: {node: '>=0.10.0'}
+ requiresBuild: true
dev: true
+ optional: true
- /binary-extensions/2.2.0:
+ /binary-extensions@2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
- dev: true
- /bindings/1.5.0:
+ /bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
+ requiresBuild: true
dependencies:
file-uri-to-path: 1.0.0
- dev: true
- optional: true
- /bl/4.1.0:
+ /bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+ requiresBuild: true
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.0
- dev: true
- /bluebird/3.7.2:
+ /bluebird@3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
dev: true
- /blueimp-md5/2.18.0:
- resolution: {integrity: sha512-vE52okJvzsVWhcgUHOv+69OG3Mdg151xyn41aVQN/5W5S+S43qZhxECtYLAEHMSFWX6Mv5IZrzj3T5+JqXfj5Q==}
+ /blueimp-md5@2.19.0:
+ resolution: {integrity: sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==}
dev: true
- /bn.js/4.12.0:
+ /bn.js@4.12.0:
resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==}
dev: true
- /bn.js/5.2.0:
- resolution: {integrity: sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==}
+ /bn.js@5.2.1:
+ resolution: {integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==}
dev: true
- /body-parser/1.19.0:
- resolution: {integrity: sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==}
- engines: {node: '>= 0.8'}
+ /body-parser@1.20.1:
+ resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==}
+ engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dependencies:
- bytes: 3.1.0
+ bytes: 3.1.2
content-type: 1.0.4
debug: 2.6.9
- depd: 1.1.2
- http-errors: 1.7.2
+ depd: 2.0.0
+ destroy: 1.2.0
+ http-errors: 2.0.0
iconv-lite: 0.4.24
- on-finished: 2.3.0
- qs: 6.7.0
- raw-body: 2.4.0
+ on-finished: 2.4.1
+ qs: 6.11.0
+ raw-body: 2.5.1
type-is: 1.6.18
+ unpipe: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /bonjour/3.5.0:
- resolution: {integrity: sha1-jokKGD2O6aI5OzhExpGkK897yfU=}
+ /bonjour-service@1.0.14:
+ resolution: {integrity: sha512-HIMbgLnk1Vqvs6B4Wq5ep7mxvj9sGz5d1JJyDNSGNIdA/w2MCz6GTjWTdjqOJV1bEPj+6IkxDvWNFKEBxNt4kQ==}
dependencies:
array-flatten: 2.1.2
- deep-equal: 1.1.1
dns-equal: 1.0.0
- dns-txt: 2.0.2
- multicast-dns: 6.2.3
- multicast-dns-service-types: 1.1.0
+ fast-deep-equal: 3.1.3
+ multicast-dns: 7.2.5
dev: true
- /boolbase/1.0.0:
- resolution: {integrity: sha1-aN/1++YMUes3cl6p4+0xDcwed24=}
+ /boolbase@1.0.0:
+ resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
dev: true
- /boxen/4.2.0:
- resolution: {integrity: sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==}
- engines: {node: '>=8'}
- dependencies:
- ansi-align: 3.0.0
- camelcase: 5.3.1
- chalk: 3.0.0
- cli-boxes: 2.2.1
- string-width: 4.2.2
- term-size: 2.2.1
- type-fest: 0.8.1
- widest-line: 3.1.0
- dev: true
-
- /boxen/5.0.1:
- resolution: {integrity: sha512-49VBlw+PrWEF51aCmy7QIteYPIFZxSpvqBdP/2itCPPlJ49kj9zg/XPRFrdkne2W+CfwXUls8exMvu1RysZpKA==}
+ /boxen@5.1.2:
+ resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==}
engines: {node: '>=10'}
dependencies:
- ansi-align: 3.0.0
- camelcase: 6.2.0
+ ansi-align: 3.0.1
+ camelcase: 6.3.0
chalk: 4.1.2
cli-boxes: 2.2.1
- string-width: 4.2.2
+ string-width: 4.2.3
type-fest: 0.20.2
widest-line: 3.1.0
wrap-ansi: 7.0.0
dev: true
- /brace-expansion/1.1.11:
+ /boxen@7.1.1:
+ resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==}
+ engines: {node: '>=14.16'}
+ dependencies:
+ ansi-align: 3.0.1
+ camelcase: 7.0.1
+ chalk: 5.3.0
+ cli-boxes: 3.0.0
+ string-width: 5.1.2
+ type-fest: 2.19.0
+ widest-line: 4.0.1
+ wrap-ansi: 8.1.0
+ dev: true
+
+ /brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
- dev: true
- /braces/2.3.2:
+ /brace-expansion@2.0.1:
+ resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
+ dependencies:
+ balanced-match: 1.0.2
+
+ /braces@2.3.2:
resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -9350,24 +7961,29 @@ packages:
snapdragon-node: 2.1.1
split-string: 3.1.0
to-regex: 3.0.2
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /braces/3.0.2:
+ /braces@3.0.2:
resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
engines: {node: '>=8'}
dependencies:
fill-range: 7.0.1
- dev: true
- /brorand/1.1.0:
- resolution: {integrity: sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=}
+ /brorand@1.1.0:
+ resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==}
dev: true
- /browser-process-hrtime/1.0.0:
+ /browser-process-hrtime@1.0.0:
resolution: {integrity: sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==}
dev: true
- /browserify-aes/1.2.0:
+ /browser-stdout@1.3.1:
+ resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==}
+ dev: true
+
+ /browserify-aes@1.2.0:
resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==}
dependencies:
buffer-xor: 1.0.3
@@ -9378,7 +7994,7 @@ packages:
safe-buffer: 5.2.1
dev: true
- /browserify-cipher/1.0.1:
+ /browserify-cipher@1.0.1:
resolution: {integrity: sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==}
dependencies:
browserify-aes: 1.2.0
@@ -9386,90 +8002,89 @@ packages:
evp_bytestokey: 1.0.3
dev: true
- /browserify-des/1.0.2:
+ /browserify-des@1.0.2:
resolution: {integrity: sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==}
dependencies:
cipher-base: 1.0.4
- des.js: 1.0.1
+ des.js: 1.1.0
inherits: 2.0.4
safe-buffer: 5.2.1
dev: true
- /browserify-rsa/4.1.0:
+ /browserify-rsa@4.1.0:
resolution: {integrity: sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==}
dependencies:
- bn.js: 5.2.0
+ bn.js: 5.2.1
randombytes: 2.1.0
dev: true
- /browserify-sign/4.2.1:
+ /browserify-sign@4.2.1:
resolution: {integrity: sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==}
dependencies:
- bn.js: 5.2.0
+ bn.js: 5.2.1
browserify-rsa: 4.1.0
create-hash: 1.2.0
create-hmac: 1.1.7
elliptic: 6.5.4
inherits: 2.0.4
parse-asn1: 5.1.6
- readable-stream: 3.6.0
+ readable-stream: 3.6.2
safe-buffer: 5.2.1
dev: true
- /browserify-zlib/0.2.0:
+ /browserify-zlib@0.2.0:
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
dependencies:
pako: 1.0.11
dev: true
- /browserlist/1.0.1:
- resolution: {integrity: sha512-nYq9jiWv+qXcgrJxQzivfEc7Wo2GvAKkeRViE5L3cUJpq4SZO6NZR710I/8T+OjE5BPECbzpm8rpUkwslE3nTg==}
- hasBin: true
+ /browserslist@4.21.4:
+ resolution: {integrity: sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
dependencies:
- chalk: 2.4.2
+ caniuse-lite: 1.0.30001570
+ electron-to-chromium: 1.4.597
+ node-releases: 2.0.13
+ update-browserslist-db: 1.0.13(browserslist@4.21.4)
dev: true
- /browserslist/4.14.2:
- resolution: {integrity: sha512-HI4lPveGKUR0x2StIz+2FXfDk9SfVMrxn6PLh1JeGUwcuoDkdKZebWiyLRJ68iIPDpMI4JLVDf7S7XzslgWOhw==}
+ /browserslist@4.21.5:
+ resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
- hasBin: true
dependencies:
- caniuse-lite: 1.0.30001251
- electron-to-chromium: 1.3.813
- escalade: 3.1.1
- node-releases: 1.1.75
+ caniuse-lite: 1.0.30001570
+ electron-to-chromium: 1.4.284
+ node-releases: 2.0.10
+ update-browserslist-db: 1.0.10(browserslist@4.21.5)
dev: true
- /browserslist/4.16.8:
- resolution: {integrity: sha512-sc2m9ohR/49sWEbPj14ZSSZqp+kbi16aLao42Hmn3Z8FpjuMaq2xCA2l4zl9ITfyzvnvyE0hcg62YkIGKxgaNQ==}
+ /browserslist@4.22.2:
+ resolution: {integrity: sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
dependencies:
- caniuse-lite: 1.0.30001251
- colorette: 1.3.0
- electron-to-chromium: 1.3.813
- escalade: 3.1.1
- node-releases: 1.1.75
+ caniuse-lite: 1.0.30001570
+ electron-to-chromium: 1.4.613
+ node-releases: 2.0.14
+ update-browserslist-db: 1.0.13(browserslist@4.22.2)
+
+ /buffer-crc32@0.2.13:
+ resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
dev: true
- /bser/2.1.1:
- resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
- dependencies:
- node-int64: 0.4.0
+ /buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
dev: true
- /buffer-from/1.1.2:
+ /buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
-
- /buffer-indexof/1.1.1:
- resolution: {integrity: sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g==}
dev: true
- /buffer-xor/1.0.3:
- resolution: {integrity: sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=}
+ /buffer-xor@1.0.3:
+ resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==}
dev: true
- /buffer/4.9.2:
+ /buffer@4.9.2:
resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==}
dependencies:
base64-js: 1.5.1
@@ -9477,96 +8092,160 @@ packages:
isarray: 1.0.0
dev: true
- /buffer/5.7.1:
+ /buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+ requiresBuild: true
+ dependencies:
+ base64-js: 1.5.1
+ ieee754: 1.2.1
+
+ /buffer@6.0.3:
+ resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: true
- /builtin-modules/3.2.0:
- resolution: {integrity: sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==}
+ /builtin-modules@3.3.0:
+ resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==}
engines: {node: '>=6'}
dev: true
- /builtin-status-codes/3.0.0:
- resolution: {integrity: sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=}
+ /builtin-status-codes@3.0.0:
+ resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==}
dev: true
- /builtins/1.0.3:
- resolution: {integrity: sha1-y5T662HIaWRR2zZTThQi+U8K7og=}
+ /builtins@5.0.1:
+ resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==}
+ dependencies:
+ semver: 7.5.4
dev: true
- /bulma-checkbox/1.1.1:
- resolution: {integrity: sha512-16aTRbXQBCdfk8nrWSVJCasD28FudeVF+G+mZfMJc2N/xTcU4XXjzQ6Iya1neKOgXkXQMx9nJOH2n8H7LRztNg==}
+ /bulma-checkbox@1.2.1:
+ resolution: {integrity: sha512-Ad7kSzwYwHLYyow92IJPz9jgolDDo5ivlFdSBe7W4LR9WnLt/Gd2iE07m3uhoU/g37oIZcMHNC33ZxJKqAuSzQ==}
dependencies:
- bulma: 0.9.3
+ bulma: 0.9.4
dev: true
- /bulma-radio/1.1.1:
- resolution: {integrity: sha512-aIHuMbpBGyZYx8KxbQRdjIy/0M9WHWz5VyxMggwxmCadnN0gd7gC/G96WUy9mhaoIfo9yX/Cf8pKQNinKH+w7w==}
+ /bulma-radio@1.2.0:
+ resolution: {integrity: sha512-rIzqALGakpKf9Eju4sGMt2Pwnn7X+AdYh6itjsCxLCJ/Ext4Cdd/M7uevQlXDy0MSwrQBMBLR8buSToBCuI+zA==}
dependencies:
- bulma: 0.9.3
+ bulma: 0.9.4
dev: true
- /bulma/0.9.3:
- resolution: {integrity: sha512-0d7GNW1PY4ud8TWxdNcP6Cc8Bu7MxcntD/RRLGWuiw/s0a9P+XlH/6QoOIrmbj6o8WWJzJYhytiu9nFjTszk1g==}
+ /bulma-responsive-tables@1.2.5:
+ resolution: {integrity: sha512-8/qYiv21cJnGYMkjIF52iKCV/B6XIswu58Vwi3/TS+wLavvA7OFXhBy0quxOnqPNqnovHly2dTCyVCqHLJU7Sg==}
+ dependencies:
+ bulma: 0.9.4
dev: true
- /bytes/3.0.0:
- resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=}
+ /bulma-switch-control@1.2.2:
+ resolution: {integrity: sha512-1eHlga1Z4RBRU6DIxNiwb6+I9n9vDkj9/MmwS4pL68P7STE1vbwRutxh9oFeFWuxLXGNLILJEXJXiwyEjT9upw==}
+ dependencies:
+ bulma: 0.9.4
+ dev: true
+
+ /bulma-timeline@3.0.5:
+ resolution: {integrity: sha512-gBwdx7PmAEZ/+5zSn/ZBJBqkenT8wi+9nlD0uP8lXa3rGcbhG+fp8Oz98+3O10LQPlUEdKPYEUxtQ55okRfhTQ==}
+ dev: true
+
+ /bulma-upload-control@1.2.0:
+ resolution: {integrity: sha512-2raueVPVoG3KjHH+7Aok44nGSPIl76qzdkLKX/ziHAOwbiXBrlEYHXca8Hk0UDa0KElLiPT6Eb2Cvz+8FFUwBw==}
+ dependencies:
+ bulma: 0.9.4
+ dev: true
+
+ /bulma@0.9.4:
+ resolution: {integrity: sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ==}
+ dev: true
+
+ /bunyan@1.8.15:
+ resolution: {integrity: sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==}
+ engines: {'0': node >=0.10.0}
+ hasBin: true
+ optionalDependencies:
+ dtrace-provider: 0.8.8
+ moment: 2.30.1
+ mv: 2.1.1
+ safe-json-stringify: 1.2.0
+ dev: true
+
+ /bytes@3.0.0:
+ resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==}
engines: {node: '>= 0.8'}
dev: true
- /bytes/3.1.0:
- resolution: {integrity: sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==}
+ /bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
dev: true
- /cacache/12.0.4:
+ /c8@8.0.1:
+ resolution: {integrity: sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==}
+ engines: {node: '>=12'}
+ hasBin: true
+ dependencies:
+ '@bcoe/v8-coverage': 0.2.3
+ '@istanbuljs/schema': 0.1.3
+ find-up: 5.0.0
+ foreground-child: 2.0.0
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-reports: 3.1.6
+ rimraf: 3.0.2
+ test-exclude: 6.0.0
+ v8-to-istanbul: 9.2.0
+ yargs: 17.7.2
+ yargs-parser: 21.1.1
+ dev: true
+
+ /cacache@12.0.4:
resolution: {integrity: sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==}
dependencies:
bluebird: 3.7.2
chownr: 1.1.4
figgy-pudding: 3.5.2
- glob: 7.1.7
- graceful-fs: 4.2.8
+ glob: 7.2.3
+ graceful-fs: 4.2.11
infer-owner: 1.0.4
lru-cache: 5.1.1
mississippi: 3.0.0
- mkdirp: 0.5.5
+ mkdirp: 0.5.6
move-concurrently: 1.0.1
- promise-inflight: 1.0.1
+ promise-inflight: 1.0.1(bluebird@3.7.2)
rimraf: 2.7.1
ssri: 6.0.2
unique-filename: 1.1.1
y18n: 4.0.3
dev: true
- /cacache/15.2.0:
- resolution: {integrity: sha512-uKoJSHmnrqXgthDFx/IU6ED/5xd+NNGe+Bb+kLZy7Ku4P+BaiWEUflAKPZ7eAzsYGcsAGASJZsybXp+quEcHTw==}
+ /cacache@15.3.0:
+ resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==}
engines: {node: '>= 10'}
dependencies:
+ '@npmcli/fs': 1.1.1
'@npmcli/move-file': 1.1.2
chownr: 2.0.0
fs-minipass: 2.1.0
- glob: 7.1.7
+ glob: 7.2.3
infer-owner: 1.0.4
lru-cache: 6.0.0
- minipass: 3.1.3
+ minipass: 3.3.6
minipass-collect: 1.0.2
minipass-flush: 1.0.5
minipass-pipeline: 1.2.4
mkdirp: 1.0.4
p-map: 4.0.0
- promise-inflight: 1.0.1
+ promise-inflight: 1.0.1(bluebird@3.7.2)
rimraf: 3.0.2
ssri: 8.0.1
- tar: 6.1.10
+ tar: 6.1.11
unique-filename: 1.1.1
+ transitivePeerDependencies:
+ - bluebird
dev: true
- /cache-base/1.0.1:
+ /cache-base@1.0.1:
resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -9581,11 +8260,29 @@ packages:
unset-value: 1.0.0
dev: true
- /cacheable-request/6.1.0:
+ /cacheable-lookup@7.0.0:
+ resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==}
+ engines: {node: '>=14.16'}
+ dev: true
+
+ /cacheable-request@10.2.14:
+ resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==}
+ engines: {node: '>=14.16'}
+ dependencies:
+ '@types/http-cache-semantics': 4.0.4
+ get-stream: 6.0.1
+ http-cache-semantics: 4.1.1
+ keyv: 4.5.4
+ mimic-response: 4.0.0
+ normalize-url: 8.0.0
+ responselike: 3.0.0
+ dev: true
+
+ /cacheable-request@6.1.0:
resolution: {integrity: sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==}
engines: {node: '>=8'}
dependencies:
- clone-response: 1.0.2
+ clone-response: 1.0.3
get-stream: 5.2.0
http-cache-semantics: 4.1.0
keyv: 3.1.0
@@ -9594,7 +8291,7 @@ packages:
responselike: 1.0.2
dev: true
- /caching-transform/4.0.0:
+ /caching-transform@4.0.0:
resolution: {integrity: sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==}
engines: {node: '>=8'}
dependencies:
@@ -9604,109 +8301,118 @@ packages:
write-file-atomic: 3.0.3
dev: true
- /call-bind/1.0.2:
- resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
+ /call-bind@1.0.5:
+ resolution: {integrity: sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==}
dependencies:
- function-bind: 1.1.1
- get-intrinsic: 1.1.1
+ function-bind: 1.1.2
+ get-intrinsic: 1.2.2
+ set-function-length: 1.1.1
dev: true
- /call-me-maybe/1.0.1:
- resolution: {integrity: sha1-JtII6onje1y95gJQoV8DHBak1ms=}
- dev: true
-
- /caller-callsite/2.0.0:
- resolution: {integrity: sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=}
+ /caller-callsite@2.0.0:
+ resolution: {integrity: sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ==}
engines: {node: '>=4'}
dependencies:
callsites: 2.0.0
dev: true
- /caller-path/2.0.0:
- resolution: {integrity: sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=}
+ /caller-path@2.0.0:
+ resolution: {integrity: sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A==}
engines: {node: '>=4'}
dependencies:
caller-callsite: 2.0.0
dev: true
- /callsites/2.0.0:
- resolution: {integrity: sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=}
+ /callsites@2.0.0:
+ resolution: {integrity: sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ==}
engines: {node: '>=4'}
dev: true
- /callsites/3.1.0:
+ /callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
dev: true
- /camel-case/3.0.0:
- resolution: {integrity: sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=}
+ /callsites@4.1.0:
+ resolution: {integrity: sha512-aBMbD1Xxay75ViYezwT40aQONfr+pSXTHwNKvIXhXD6+LY3F1dLIcceoC5OZKBVHbXcysz1hL9D2w0JJIMXpUw==}
+ engines: {node: '>=12.20'}
+ dev: true
+
+ /camel-case@3.0.0:
+ resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==}
dependencies:
no-case: 2.3.2
upper-case: 1.1.3
dev: true
- /camel-case/4.1.2:
+ /camel-case@4.1.2:
resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==}
dependencies:
pascal-case: 3.1.2
- tslib: 2.3.1
+ tslib: 2.6.2
dev: true
- /camelcase-css/2.0.1:
+ /camelcase-css@2.0.1:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
- dev: true
- /camelcase/5.3.1:
+ /camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
dev: true
- /camelcase/6.2.0:
- resolution: {integrity: sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==}
+ /camelcase@6.3.0:
+ resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
dev: true
- /cancellationtoken/2.2.0:
- resolution: {integrity: sha512-uF4sHE5uh2VdEZtIRJKGoXAD9jm7bFY0tDRCzH4iLp262TOJ2lrtNHjMG2zc8H+GICOpELIpM7CGW5JeWnb3Hg==}
- dev: false
+ /camelcase@7.0.1:
+ resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==}
+ engines: {node: '>=14.16'}
+ dev: true
- /caniuse-api/3.0.0:
+ /caniuse-api@3.0.0:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
dependencies:
- browserslist: 4.16.8
- caniuse-lite: 1.0.30001251
+ browserslist: 4.22.2
+ caniuse-lite: 1.0.30001570
lodash.memoize: 4.1.2
lodash.uniq: 4.5.0
dev: true
- /caniuse-lite/1.0.30001251:
- resolution: {integrity: sha512-HOe1r+9VkU4TFmnU70z+r7OLmtR+/chB1rdcJUeQlAinjEeb0cKL20tlAtOagNZhbrtLnCvV19B4FmF1rgzl6A==}
+ /caniuse-lite@1.0.30001482:
+ resolution: {integrity: sha512-F1ZInsg53cegyjroxLNW9DmrEQ1SuGRTO1QlpA0o2/6OpQ0gFeDRoq1yFmnr8Sakn9qwwt9DmbxHB6w167OSuQ==}
dev: true
- /capture-exit/2.0.0:
- resolution: {integrity: sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==}
- engines: {node: 6.* || 8.* || >= 10.*}
- dependencies:
- rsvp: 4.8.5
- dev: true
+ /caniuse-lite@1.0.30001570:
+ resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==}
- /case-sensitive-paths-webpack-plugin/2.4.0:
- resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==}
- engines: {node: '>=4'}
+ /caseless@0.12.0:
+ resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
dev: true
- /caseless/0.12.0:
- resolution: {integrity: sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=}
+ /cbor@9.0.1:
+ resolution: {integrity: sha512-/TQOWyamDxvVIv+DY9cOLNuABkoyz8K/F3QE56539pGVYohx0+MEA1f4lChFTX79dBTBS7R1PF6ovH7G+VtBfQ==}
+ engines: {node: '>=16'}
+ dependencies:
+ nofilter: 3.1.0
dev: true
- /ccount/1.1.0:
- resolution: {integrity: sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==}
+ /chai@4.3.6:
+ resolution: {integrity: sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==}
+ engines: {node: '>=4'}
+ dependencies:
+ assertion-error: 1.1.0
+ check-error: 1.0.2
+ deep-eql: 3.0.1
+ get-func-name: 2.0.0
+ loupe: 2.3.4
+ pathval: 1.1.1
+ type-detect: 4.0.8
dev: true
- /chalk/0.4.0:
- resolution: {integrity: sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=}
+ /chalk@0.4.0:
+ resolution: {integrity: sha512-sQfYDlfv2DGVtjdoQqxS0cEZDroyG8h6TamA6rvxwlrU5BaSLDx9xhatBYl2pxZ7gmpNaPFVwBtdGdu5rQ+tYQ==}
engines: {node: '>=0.8.0'}
dependencies:
ansi-styles: 1.0.0
@@ -9714,7 +8420,7 @@ packages:
strip-ansi: 0.1.1
dev: true
- /chalk/2.4.1:
+ /chalk@2.4.1:
resolution: {integrity: sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==}
engines: {node: '>=4'}
dependencies:
@@ -9723,16 +8429,15 @@ packages:
supports-color: 5.5.0
dev: true
- /chalk/2.4.2:
+ /chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
dependencies:
ansi-styles: 3.2.1
escape-string-regexp: 1.0.5
supports-color: 5.5.0
- dev: true
- /chalk/3.0.0:
+ /chalk@3.0.0:
resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
engines: {node: '>=8'}
dependencies:
@@ -9740,15 +8445,7 @@ packages:
supports-color: 7.2.0
dev: true
- /chalk/4.1.0:
- resolution: {integrity: sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==}
- engines: {node: '>=10'}
- dependencies:
- ansi-styles: 4.3.0
- supports-color: 7.2.0
- dev: true
-
- /chalk/4.1.2:
+ /chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
dependencies:
@@ -9756,130 +8453,137 @@ packages:
supports-color: 7.2.0
dev: true
- /char-regex/1.0.2:
- resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
- engines: {node: '>=10'}
- dev: true
-
- /character-entities-legacy/1.1.4:
- resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==}
+ /chalk@5.3.0:
+ resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
dev: true
- /character-entities/1.2.4:
- resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==}
+ /check-error@1.0.2:
+ resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==}
dev: true
- /character-reference-invalid/1.1.4:
- resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==}
- dev: true
-
- /chardet/0.7.0:
- resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
- dev: true
-
- /cheerio-select/1.5.0:
- resolution: {integrity: sha512-qocaHPv5ypefh6YNxvnbABM07KMxExbtbfuJoIie3iZXX1ERwYmJcIiRrr9H05ucQP1k28dav8rpdDgjQd8drg==}
+ /cheerio-select@2.1.0:
+ resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
dependencies:
- css-select: 4.1.3
- css-what: 5.0.1
- domelementtype: 2.2.0
- domhandler: 4.2.0
- domutils: 2.7.0
+ boolbase: 1.0.0
+ css-select: 5.1.0
+ css-what: 6.1.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.1.0
dev: true
- /cheerio/1.0.0-rc.10:
- resolution: {integrity: sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw==}
+ /cheerio@1.0.0-rc.12:
+ resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==}
engines: {node: '>= 6'}
dependencies:
- cheerio-select: 1.5.0
- dom-serializer: 1.3.2
- domhandler: 4.2.0
- htmlparser2: 6.1.0
- parse5: 6.0.1
- parse5-htmlparser2-tree-adapter: 6.0.1
- tslib: 2.3.1
+ cheerio-select: 2.1.0
+ dom-serializer: 2.0.0
+ domhandler: 5.0.3
+ domutils: 3.1.0
+ htmlparser2: 8.0.2
+ parse5: 7.1.2
+ parse5-htmlparser2-tree-adapter: 7.0.0
dev: true
- /chokidar/2.1.8:
+ /chokidar@2.1.8:
resolution: {integrity: sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==}
- deprecated: Chokidar 2 will break on node v14+. Upgrade to chokidar 3 with 15x less dependencies.
+ deprecated: Chokidar 2 does not receive security updates since 2019. Upgrade to chokidar 3 with 15x fewer dependencies
+ requiresBuild: true
dependencies:
anymatch: 2.0.0
- async-each: 1.0.3
+ async-each: 1.0.6
braces: 2.3.2
glob-parent: 3.1.0
inherits: 2.0.4
is-binary-path: 1.0.1
- is-glob: 4.0.1
+ is-glob: 4.0.3
normalize-path: 3.0.0
path-is-absolute: 1.0.1
readdirp: 2.2.1
upath: 1.2.0
optionalDependencies:
fsevents: 1.2.13
+ transitivePeerDependencies:
+ - supports-color
dev: true
+ optional: true
- /chokidar/3.5.2:
- resolution: {integrity: sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==}
+ /chokidar@3.5.3:
+ resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
engines: {node: '>= 8.10.0'}
dependencies:
anymatch: 3.1.2
braces: 3.0.2
glob-parent: 5.1.2
is-binary-path: 2.1.0
- is-glob: 4.0.1
+ is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
- fsevents: 2.3.2
- dev: true
+ fsevents: 2.3.3
- /chownr/1.1.4:
+ /chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
- dev: true
+ requiresBuild: true
- /chownr/2.0.0:
+ /chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
dev: true
- /chrome-trace-event/1.0.3:
+ /chrome-launcher@0.15.1:
+ resolution: {integrity: sha512-UugC8u59/w2AyX5sHLZUHoxBAiSiunUhZa3zZwMH6zPVis0C3dDKiRWyUGIo14tTbZHGVviWxv3PQWZ7taZ4fg==}
+ engines: {node: '>=12.13.0'}
+ hasBin: true
+ dependencies:
+ '@types/node': 20.11.13
+ escape-string-regexp: 4.0.0
+ is-wsl: 2.2.0
+ lighthouse-logger: 1.4.2
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /chrome-trace-event@1.0.3:
resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==}
engines: {node: '>=6.0'}
dev: true
- /chunkd/2.0.1:
+ /chunkd@2.0.1:
resolution: {integrity: sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ==}
dev: true
- /ci-env/1.16.0:
- resolution: {integrity: sha512-ucF9caQEX5wQlY449KZBIJPx91+kRg9tJ3tWSc4+KzrvC5KNiPm/3g1noP8VhdI3046+Vw3jLmKAD0fjCRJTmw==}
+ /ci-env@1.17.0:
+ resolution: {integrity: sha512-NtTjhgSEqv4Aj90TUYHQLxHdnCPXnjdtuGG1X8lTfp/JqeXTdw0FTWl/vUAPuvbWZTF8QVpv6ASe/XacE+7R2A==}
dev: true
- /ci-info/2.0.0:
+ /ci-info@2.0.0:
resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==}
dev: true
- /ci-info/3.2.0:
- resolution: {integrity: sha512-dVqRX7fLUm8J6FgHJ418XuIgDLZDkYcDFTeL6TA2gt5WlIZUQrrH6EZrNClwT/H0FateUsZkGIOPRrLbP+PR9A==}
+ /ci-info@3.9.0:
+ resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
+ engines: {node: '>=8'}
dev: true
- /ci-parallel-vars/1.0.1:
+ /ci-info@4.0.0:
+ resolution: {integrity: sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /ci-parallel-vars@1.0.1:
resolution: {integrity: sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==}
dev: true
- /cipher-base/1.0.4:
+ /cipher-base@1.0.4:
resolution: {integrity: sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==}
dependencies:
inherits: 2.0.4
safe-buffer: 5.2.1
dev: true
- /cjs-module-lexer/0.6.0:
- resolution: {integrity: sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==}
- dev: true
-
- /class-utils/0.3.6:
+ /class-utils@0.3.6:
resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -9889,92 +8593,85 @@ packages:
static-extend: 0.1.2
dev: true
- /classnames/2.3.1:
- resolution: {integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==}
+ /clean-css@4.2.4:
+ resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==}
+ engines: {node: '>= 4.0'}
+ dependencies:
+ source-map: 0.6.1
dev: true
- /clean-css/4.2.3:
- resolution: {integrity: sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==}
- engines: {node: '>= 4.0'}
+ /clean-css@5.3.3:
+ resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
+ engines: {node: '>= 10.0'}
dependencies:
source-map: 0.6.1
dev: true
- /clean-stack/2.2.0:
+ /clean-stack@2.2.0:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'}
dev: true
- /clean-yaml-object/0.1.0:
- resolution: {integrity: sha1-Y/sRDcLOGoTcIfbZM0h20BCui2g=}
- engines: {node: '>=0.10.0'}
- dev: true
-
- /cli-boxes/2.2.1:
+ /cli-boxes@2.2.1:
resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==}
engines: {node: '>=6'}
dev: true
- /cli-cursor/3.1.0:
+ /cli-boxes@3.0.0:
+ resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
dependencies:
restore-cursor: 3.1.0
dev: true
- /cli-spinners/2.6.0:
- resolution: {integrity: sha512-t+4/y50K/+4xcCRosKkA7W4gTr1MySvLV0q+PxmG7FJ5g+66ChKurYjxBCjHggHH3HA5Hh9cy+lcUGWDqVH+4Q==}
+ /cli-spinners@2.7.0:
+ resolution: {integrity: sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw==}
engines: {node: '>=6'}
dev: true
- /cli-table3/0.6.0:
- resolution: {integrity: sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ==}
- engines: {node: 10.* || >= 12.*}
+ /cli-truncate@4.0.0:
+ resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
+ engines: {node: '>=18'}
dependencies:
- object-assign: 4.1.1
- string-width: 4.2.2
- optionalDependencies:
- colors: 1.4.0
- dev: true
-
- /cli-truncate/2.1.0:
- resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
- engines: {node: '>=8'}
- dependencies:
- slice-ansi: 3.0.0
- string-width: 4.2.2
+ slice-ansi: 5.0.0
+ string-width: 7.0.0
dev: true
- /cli-width/3.0.0:
- resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==}
- engines: {node: '>= 10'}
- dev: true
-
- /cliui/5.0.0:
- resolution: {integrity: sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==}
- dependencies:
- string-width: 3.1.0
- strip-ansi: 5.2.0
- wrap-ansi: 5.1.0
- dev: true
+ /client-only@0.0.1:
+ resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+ dev: false
- /cliui/6.0.0:
+ /cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
dependencies:
- string-width: 4.2.2
- strip-ansi: 6.0.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
wrap-ansi: 6.2.0
dev: true
- /cliui/7.0.4:
+ /cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
dependencies:
- string-width: 4.2.2
- strip-ansi: 6.0.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 7.0.0
+ dev: true
+
+ /cliui@8.0.1:
+ resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+ engines: {node: '>=12'}
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
wrap-ansi: 7.0.0
dev: true
- /clone-deep/4.0.1:
+ /clone-deep@4.0.1:
resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
engines: {node: '>=6'}
dependencies:
@@ -9983,23 +8680,18 @@ packages:
shallow-clone: 3.0.1
dev: true
- /clone-response/1.0.2:
- resolution: {integrity: sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=}
+ /clone-response@1.0.3:
+ resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
dependencies:
mimic-response: 1.0.1
dev: true
- /clone/1.0.4:
- resolution: {integrity: sha1-2jCcwmPfFZlMaIypAheco8fNfH4=}
+ /clone@1.0.4:
+ resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
dev: true
- /co/4.6.0:
- resolution: {integrity: sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=}
- engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
- dev: true
-
- /coa/2.0.2:
+ /coa@2.0.2:
resolution: {integrity: sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==}
engines: {node: '>= 4.0'}
dependencies:
@@ -10008,359 +8700,358 @@ packages:
q: 1.5.1
dev: true
- /code-excerpt/3.0.0:
- resolution: {integrity: sha512-VHNTVhd7KsLGOqfX3SyeO8RyYPMp1GJOg194VITk04WMYCv4plV68YWe6TJZxd9MhobjtpMRnVky01gqZsalaw==}
- engines: {node: '>=10'}
+ /code-excerpt@4.0.0:
+ resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
- convert-to-spaces: 1.0.2
+ convert-to-spaces: 2.0.1
dev: true
- /code-point-at/1.1.0:
- resolution: {integrity: sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=}
- engines: {node: '>=0.10.0'}
- dev: true
-
- /collapse-white-space/1.0.6:
- resolution: {integrity: sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==}
- dev: true
-
- /collect-v8-coverage/1.0.1:
- resolution: {integrity: sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==}
- dev: true
-
- /collection-visit/1.0.0:
- resolution: {integrity: sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=}
+ /collection-visit@1.0.0:
+ resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==}
engines: {node: '>=0.10.0'}
dependencies:
map-visit: 1.0.0
object-visit: 1.0.1
dev: true
- /color-convert/1.9.3:
+ /color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies:
color-name: 1.1.3
- dev: true
- /color-convert/2.0.1:
+ /color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
dependencies:
color-name: 1.1.4
- dev: true
- /color-name/1.1.3:
- resolution: {integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=}
- dev: true
+ /color-name@1.1.3:
+ resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
- /color-name/1.1.4:
+ /color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
- dev: true
- /color-string/1.6.0:
- resolution: {integrity: sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==}
+ /color-string@1.9.1:
+ resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.2
dev: true
- /color/3.2.1:
+ /color-support@1.1.3:
+ resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
+ hasBin: true
+ dev: true
+
+ /color@3.2.1:
resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==}
dependencies:
color-convert: 1.9.3
- color-string: 1.6.0
+ color-string: 1.9.1
dev: true
- /colord/2.7.0:
- resolution: {integrity: sha512-pZJBqsHz+pYyw3zpX6ZRXWoCHM1/cvFikY9TV8G3zcejCaKE0lhankoj8iScyrrePA8C7yJ5FStfA9zbcOnw7Q==}
+ /colord@2.9.3:
+ resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==}
dev: true
- /colorette/1.3.0:
- resolution: {integrity: sha512-ecORCqbSFP7Wm8Y6lyqMJjexBQqXSF7SSeaTyGGphogUjBlFP9m9o08wy86HL2uB7fMTxtOUzLMk7ogKcxMg1w==}
+ /colorette@2.0.19:
+ resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
dev: true
- /colors/1.2.5:
- resolution: {integrity: sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==}
- engines: {node: '>=0.1.90'}
- dev: true
-
- /colors/1.4.0:
- resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
- engines: {node: '>=0.1.90'}
+ /columnify@1.6.0:
+ resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==}
+ engines: {node: '>=8.0.0'}
+ dependencies:
+ strip-ansi: 6.0.1
+ wcwidth: 1.0.1
dev: true
- /combined-stream/1.0.8:
+ /combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
dependencies:
delayed-stream: 1.0.0
dev: true
- /comma-separated-tokens/1.0.8:
- resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
- dev: true
-
- /commander/2.17.1:
+ /commander@2.17.1:
resolution: {integrity: sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==}
dev: true
- /commander/2.19.0:
+ /commander@2.19.0:
resolution: {integrity: sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==}
dev: true
- /commander/2.20.3:
+ /commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
dev: true
- /commander/4.1.1:
- resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
- engines: {node: '>= 6'}
+ /commander@2.9.0:
+ resolution: {integrity: sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==}
+ engines: {node: '>= 0.6.x'}
+ dependencies:
+ graceful-readlink: 1.0.1
dev: true
- /commander/6.2.1:
- resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
+ /commander@4.1.1:
+ resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
- dev: true
- /commander/7.2.0:
+ /commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
dev: true
- /common-path-prefix/3.0.0:
+ /commander@8.3.0:
+ resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+ engines: {node: '>= 12'}
+ dev: true
+
+ /commander@9.5.0:
+ resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
+ engines: {node: ^12.20.0 || >=14}
+ dev: true
+
+ /common-path-prefix@3.0.0:
resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==}
dev: true
- /common-tags/1.8.0:
- resolution: {integrity: sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==}
+ /common-tags@1.8.2:
+ resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==}
engines: {node: '>=4.0.0'}
dev: true
- /commondir/1.0.1:
- resolution: {integrity: sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=}
+ /commondir@1.0.1:
+ resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
dev: true
- /component-emitter/1.3.0:
+ /component-emitter@1.3.0:
resolution: {integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==}
dev: true
- /compressible/2.0.18:
+ /compressible@2.0.18:
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
engines: {node: '>= 0.6'}
dependencies:
- mime-db: 1.49.0
+ mime-db: 1.52.0
dev: true
- /compression-webpack-plugin/6.1.1_webpack@4.46.0:
+ /compression-webpack-plugin@6.1.1(webpack@4.46.0):
resolution: {integrity: sha512-BEHft9M6lwOqVIQFMS/YJGmeCYXVOakC5KzQk05TFpMBlODByh1qNsZCWjUBxCQhUP9x0WfGidxTbGkjbWO/TQ==}
engines: {node: '>= 10.13.0'}
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
dependencies:
- cacache: 15.2.0
- find-cache-dir: 3.3.1
+ cacache: 15.3.0
+ find-cache-dir: 3.3.2
schema-utils: 3.1.1
serialize-javascript: 5.0.1
webpack: 4.46.0
webpack-sources: 1.4.3
+ transitivePeerDependencies:
+ - bluebird
dev: true
- /compression/1.7.4:
+ /compression@1.7.4:
resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==}
engines: {node: '>= 0.8.0'}
dependencies:
- accepts: 1.3.7
+ accepts: 1.3.8
bytes: 3.0.0
compressible: 2.0.18
debug: 2.6.9
on-headers: 1.0.2
safe-buffer: 5.1.2
vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /compute-scroll-into-view/1.0.17:
- resolution: {integrity: sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==}
- dev: true
-
- /concat-map/0.0.1:
- resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
- dev: true
+ /concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
- /concat-stream/1.6.2:
+ /concat-stream@1.6.2:
resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==}
engines: {'0': node >= 0.8}
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
typedarray: 0.0.6
dev: true
- /concordance/5.0.4:
+ /concordance@5.0.4:
resolution: {integrity: sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==}
engines: {node: '>=10.18.0 <11 || >=12.14.0 <13 || >=14'}
dependencies:
date-time: 3.1.0
esutils: 2.0.3
- fast-diff: 1.2.0
+ fast-diff: 1.3.0
js-string-escape: 1.0.1
lodash: 4.17.21
md5-hex: 3.0.1
- semver: 7.3.5
+ semver: 7.5.4
well-known-symbols: 2.0.0
dev: true
- /configstore/5.0.1:
+ /config-chain@1.1.13:
+ resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
+ dependencies:
+ ini: 1.3.8
+ proto-list: 1.2.4
+ dev: true
+
+ /configstore@5.0.1:
resolution: {integrity: sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==}
engines: {node: '>=8'}
dependencies:
dot-prop: 5.3.0
- graceful-fs: 4.2.8
+ graceful-fs: 4.2.11
make-dir: 3.1.0
unique-string: 2.0.0
write-file-atomic: 3.0.3
xdg-basedir: 4.0.0
dev: true
- /confusing-browser-globals/1.0.10:
- resolution: {integrity: sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==}
+ /configstore@6.0.0:
+ resolution: {integrity: sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==}
+ engines: {node: '>=12'}
+ dependencies:
+ dot-prop: 6.0.1
+ graceful-fs: 4.2.11
+ unique-string: 3.0.0
+ write-file-atomic: 3.0.3
+ xdg-basedir: 5.1.0
+ dev: true
+
+ /confusing-browser-globals@1.0.11:
+ resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==}
dev: true
- /connect-history-api-fallback/1.6.0:
- resolution: {integrity: sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==}
+ /connect-history-api-fallback@2.0.0:
+ resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==}
engines: {node: '>=0.8'}
dev: true
- /console-browserify/1.2.0:
+ /console-browserify@1.2.0:
resolution: {integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==}
dev: true
- /console-clear/1.1.1:
+ /console-clear@1.1.1:
resolution: {integrity: sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==}
engines: {node: '>=4'}
dev: true
- /console-control-strings/1.1.0:
- resolution: {integrity: sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=}
+ /console-control-strings@1.1.0:
+ resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
dev: true
- /constants-browserify/1.0.0:
- resolution: {integrity: sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=}
- dev: true
-
- /contains-path/0.1.0:
- resolution: {integrity: sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=}
- engines: {node: '>=0.10.0'}
+ /constants-browserify@1.0.0:
+ resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
dev: true
- /content-disposition/0.5.3:
- resolution: {integrity: sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==}
+ /content-disposition@0.5.4:
+ resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
dependencies:
- safe-buffer: 5.1.2
+ safe-buffer: 5.2.1
dev: true
- /content-type/1.0.4:
+ /content-type@1.0.4:
resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==}
engines: {node: '>= 0.6'}
dev: true
- /convert-source-map/1.7.0:
- resolution: {integrity: sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==}
- dependencies:
- safe-buffer: 5.1.2
- dev: true
+ /convert-source-map@1.9.0:
+ resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
- /convert-source-map/1.8.0:
- resolution: {integrity: sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==}
- dependencies:
- safe-buffer: 5.1.2
+ /convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
dev: true
- /convert-to-spaces/1.0.2:
- resolution: {integrity: sha1-fj5Iu+bZl7FBfdyihoIEtNPYVxU=}
- engines: {node: '>= 4'}
+ /convert-to-spaces@2.0.1:
+ resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
- /cookie-signature/1.0.6:
- resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=}
+ /cookie-signature@1.0.6:
+ resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
dev: true
- /cookie/0.4.0:
- resolution: {integrity: sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==}
+ /cookie@0.5.0:
+ resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
dev: true
- /copy-concurrently/1.0.5:
+ /copy-concurrently@1.0.5:
resolution: {integrity: sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==}
dependencies:
aproba: 1.2.0
fs-write-stream-atomic: 1.0.10
iferr: 0.1.5
- mkdirp: 0.5.5
+ mkdirp: 0.5.6
rimraf: 2.7.1
run-queue: 1.0.3
dev: true
- /copy-descriptor/0.1.1:
- resolution: {integrity: sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=}
+ /copy-descriptor@0.1.1:
+ resolution: {integrity: sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==}
engines: {node: '>=0.10.0'}
dev: true
- /copy-to-clipboard/3.3.1:
- resolution: {integrity: sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw==}
- dependencies:
- toggle-selection: 1.0.6
- dev: true
-
- /copy-webpack-plugin/6.4.1_webpack@4.46.0:
+ /copy-webpack-plugin@6.4.1(webpack@4.46.0):
resolution: {integrity: sha512-MXyPCjdPVx5iiWyl40Va3JGh27bKzOTNY3NjUTrosD2q7dR/cLD0013uqJ3BpFbUjyONINjb6qI7nDIJujrMbA==}
engines: {node: '>= 10.13.0'}
peerDependencies:
webpack: ^4.37.0 || ^5.0.0
dependencies:
- cacache: 15.2.0
- fast-glob: 3.2.7
- find-cache-dir: 3.3.1
+ cacache: 15.3.0
+ fast-glob: 3.3.1
+ find-cache-dir: 3.3.2
glob-parent: 5.1.2
- globby: 11.0.4
- loader-utils: 2.0.0
+ globby: 11.1.0
+ loader-utils: 2.0.3
normalize-path: 3.0.0
p-limit: 3.1.0
schema-utils: 3.1.1
serialize-javascript: 5.0.1
webpack: 4.46.0
webpack-sources: 1.4.3
+ transitivePeerDependencies:
+ - bluebird
dev: true
- /core-js-compat/3.16.2:
- resolution: {integrity: sha512-4lUshXtBXsdmp8cDWh6KKiHUg40AjiuPD3bOWkNVsr1xkAhpUqCjaZ8lB1bKx9Gb5fXcbRbFJ4f4qpRIRTuJqQ==}
+ /core-js-compat@3.26.0:
+ resolution: {integrity: sha512-piOX9Go+Z4f9ZiBFLnZ5VrOpBl0h7IGCkiFUN11QTe6LjAvOT3ifL/5TdoizMh99hcGy5SoLyWbapIY/PIb/3A==}
dependencies:
- browserslist: 4.16.8
- semver: 7.0.0
+ browserslist: 4.22.2
dev: true
- /core-js-pure/3.16.2:
- resolution: {integrity: sha512-oxKe64UH049mJqrKkynWp6Vu0Rlm/BTXO/bJZuN2mmR3RtOFNepLlSWDd1eo16PzHpQAoNG97rLU1V/YxesJjw==}
- requiresBuild: true
+ /core-js-compat@3.33.3:
+ resolution: {integrity: sha512-cNzGqFsh3Ot+529GIXacjTJ7kegdt5fPXxCBVS1G0iaZpuo/tBz399ymceLJveQhFFZ8qThHiP3fzuoQjKN2ow==}
+ dependencies:
+ browserslist: 4.22.2
dev: true
- /core-js/2.6.12:
- resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==}
- deprecated: core-js@<3.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Please, upgrade your dependencies to the actual version of core-js.
+ /core-js@3.26.0:
+ resolution: {integrity: sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw==}
requiresBuild: true
dev: true
- /core-js/3.16.2:
- resolution: {integrity: sha512-P0KPukO6OjMpjBtHSceAZEWlDD1M2Cpzpg6dBbrjFqFhBHe/BwhxaP820xKOjRn/lZRQirrCusIpLS/n2sgXLQ==}
+ /core-js@3.29.0:
+ resolution: {integrity: sha512-VG23vuEisJNkGl6XQmFJd3rEG/so/CNatqeE+7uZAwTSwFeB/qaO0be8xZYUNWprJ/GIwL8aMt9cj1kvbpTZhg==}
requiresBuild: true
dev: true
- /core-util-is/1.0.2:
- resolution: {integrity: sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=}
+ /core-util-is@1.0.2:
+ resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
dev: true
- /cosmiconfig/5.2.1:
+ /core-util-is@1.0.3:
+ resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+ dev: true
+
+ /cosmiconfig@5.2.1:
resolution: {integrity: sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==}
engines: {node: '>=4'}
dependencies:
@@ -10370,9 +9061,9 @@ packages:
parse-json: 4.0.0
dev: true
- /cosmiconfig/6.0.0:
- resolution: {integrity: sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==}
- engines: {node: '>=8'}
+ /cosmiconfig@7.0.1:
+ resolution: {integrity: sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==}
+ engines: {node: '>=10'}
dependencies:
'@types/parse-json': 4.0.0
import-fresh: 3.3.0
@@ -10381,50 +9072,24 @@ packages:
yaml: 1.10.2
dev: true
- /cosmiconfig/7.0.0:
- resolution: {integrity: sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==}
- engines: {node: '>=10'}
+ /cosmiconfig@8.1.3:
+ resolution: {integrity: sha512-/UkO2JKI18b5jVMJUp0lvKFMpa/Gye+ZgZjKD+DGEN9y7NRcf/nK1A0sp67ONmKtnDCNMS44E6jrk0Yc3bDuUw==}
+ engines: {node: '>=14'}
dependencies:
- '@types/parse-json': 4.0.0
import-fresh: 3.3.0
+ js-yaml: 4.1.0
parse-json: 5.2.0
path-type: 4.0.0
- yaml: 1.10.2
- dev: true
-
- /cp-file/7.0.0:
- resolution: {integrity: sha512-0Cbj7gyvFVApzpK/uhCtQ/9kE9UnYpxMzaq5nQQC/Dh4iaj5fxp7iEFIullrYwzj8nf0qnsI1Qsx34hAeAebvw==}
- engines: {node: '>=8'}
- dependencies:
- graceful-fs: 4.2.8
- make-dir: 3.1.0
- nested-error-stacks: 2.1.0
- p-event: 4.2.0
dev: true
- /cpy/8.1.2:
- resolution: {integrity: sha512-dmC4mUesv0OYH2kNFEidtf/skUwv4zePmGeepjyyJ0qTo5+8KhA1o99oIAwVVLzQMAeDJml74d6wPPKb6EZUTg==}
- engines: {node: '>=8'}
- dependencies:
- arrify: 2.0.1
- cp-file: 7.0.0
- globby: 9.2.0
- has-glob: 1.0.0
- junk: 3.1.0
- nested-error-stacks: 2.1.0
- p-all: 2.1.0
- p-filter: 2.1.0
- p-map: 3.0.0
- dev: true
-
- /create-ecdh/4.0.4:
+ /create-ecdh@4.0.4:
resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==}
dependencies:
bn.js: 4.12.0
elliptic: 6.5.4
dev: true
- /create-hash/1.2.0:
+ /create-hash@1.2.0:
resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==}
dependencies:
cipher-base: 1.0.4
@@ -10434,7 +9099,7 @@ packages:
sha.js: 2.4.11
dev: true
- /create-hmac/1.1.7:
+ /create-hmac@1.1.7:
resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==}
dependencies:
cipher-base: 1.0.4
@@ -10445,79 +9110,57 @@ packages:
sha.js: 2.4.11
dev: true
- /create-react-context/0.3.0_prop-types@15.7.2:
- resolution: {integrity: sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw==}
- peerDependencies:
- prop-types: ^15.0.0
- react: ^0.14.0 || ^15.0.0 || ^16.0.0
- dependencies:
- gud: 1.0.0
- prop-types: 15.7.2
- warning: 4.0.3
- dev: true
-
- /create-react-context/0.3.0_prop-types@15.7.2+react@16.14.0:
- resolution: {integrity: sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw==}
- peerDependencies:
- prop-types: ^15.0.0
- react: ^0.14.0 || ^15.0.0 || ^16.0.0
- dependencies:
- gud: 1.0.0
- prop-types: 15.7.2
- react: 16.14.0
- warning: 4.0.3
+ /create-require@1.1.1:
+ resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
dev: true
- /critters-webpack-plugin/2.5.0:
+ /critters-webpack-plugin@2.5.0(html-webpack-plugin@3.2.0):
resolution: {integrity: sha512-O41TSPV2orAfrV6kSVC0SivZCtVkeypCNKb7xtrbqE/CfjrHeRaFaGuxglcjOI2IGf+oNg6E+ZoOktdlhXPTIQ==}
+ peerDependencies:
+ html-webpack-plugin: '*'
+ peerDependenciesMeta:
+ html-webpack-plugin:
+ optional: true
dependencies:
css: 2.2.4
cssnano: 4.1.11
+ html-webpack-plugin: 3.2.0(webpack@4.46.0)
jsdom: 12.2.0
- minimatch: 3.0.4
+ minimatch: 3.1.2
parse5: 4.0.0
- postcss: 7.0.36
+ postcss: 7.0.39
pretty-bytes: 4.0.2
webpack-log: 2.0.0
webpack-sources: 1.4.3
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
dev: true
- /cross-spawn-promise/0.10.2:
+ /cross-spawn-promise@0.10.2:
resolution: {integrity: sha512-74PXJf6DYaab2klRS+D+9qxKJL1Weo3/ao9OPoH6NFzxtINSa/HE2mcyAPu1fpEmRTPD4Gdmpg3xEXQSgI8lpg==}
engines: {node: '>=4'}
dependencies:
cross-spawn: 5.1.0
dev: true
- /cross-spawn/5.1.0:
- resolution: {integrity: sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=}
+ /cross-spawn@5.1.0:
+ resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}
dependencies:
lru-cache: 4.1.5
shebang-command: 1.2.0
which: 1.3.1
dev: true
- /cross-spawn/6.0.5:
- resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==}
- engines: {node: '>=4.8'}
- dependencies:
- nice-try: 1.0.5
- path-key: 2.0.1
- semver: 5.7.1
- shebang-command: 1.2.0
- which: 1.3.1
- dev: true
-
- /cross-spawn/7.0.3:
+ /cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
- dev: true
- /crypto-browserify/3.12.0:
+ /crypto-browserify@3.12.0:
resolution: {integrity: sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==}
dependencies:
browserify-cipher: 1.0.1
@@ -10533,83 +9176,63 @@ packages:
randomfill: 1.0.4
dev: true
- /crypto-random-string/2.0.0:
+ /crypto-random-string@2.0.0:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
engines: {node: '>=8'}
dev: true
- /css-color-names/0.0.4:
- resolution: {integrity: sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=}
+ /crypto-random-string@4.0.0:
+ resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==}
+ engines: {node: '>=12'}
+ dependencies:
+ type-fest: 1.4.0
dev: true
- /css-color-names/1.0.1:
- resolution: {integrity: sha512-/loXYOch1qU1biStIFsHH8SxTmOseh1IJqFvy8IujXOm1h+QjUdDhkzOrR5HG8K8mlxREj0yfi8ewCHx0eMxzA==}
+ /css-color-names@0.0.4:
+ resolution: {integrity: sha512-zj5D7X1U2h2zsXOAM8EyUREBnnts6H+Jm+d1M2DbiQQcUtnqgQsMrdo8JW9R80YFUmIdBZeMu5wvYM7hcgWP/Q==}
dev: true
- /css-declaration-sorter/4.0.1:
+ /css-declaration-sorter@4.0.1:
resolution: {integrity: sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA==}
engines: {node: '>4'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
timsort: 0.3.0
dev: true
- /css-declaration-sorter/6.1.1_postcss@8.3.6:
- resolution: {integrity: sha512-BZ1aOuif2Sb7tQYY1GeCjG7F++8ggnwUkH5Ictw0mrdpqpEd+zWmcPdstnH2TItlb74FqR0DrVEieon221T/1Q==}
- engines: {node: '>= 10'}
+ /css-declaration-sorter@6.3.1(postcss@8.4.32):
+ resolution: {integrity: sha512-fBffmak0bPAnyqc/HO8C3n2sHrp9wcqQz6ES9koRF2/mLOVAx9zIQ3Y7R29sYCteTPqMCwns4WYQoCX91Xl3+w==}
+ engines: {node: ^10 || ^12 || >=14}
peerDependencies:
postcss: ^8.0.9
dependencies:
- postcss: 8.3.6
- timsort: 0.3.0
+ postcss: 8.4.32
dev: true
- /css-loader/3.6.0_webpack@4.46.0:
- resolution: {integrity: sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ==}
- engines: {node: '>= 8.9.0'}
- peerDependencies:
- webpack: ^4.0.0 || ^5.0.0
- dependencies:
- camelcase: 5.3.1
- cssesc: 3.0.0
- icss-utils: 4.1.1
- loader-utils: 1.4.0
- normalize-path: 3.0.0
- postcss: 7.0.36
- postcss-modules-extract-imports: 2.0.0
- postcss-modules-local-by-default: 3.0.3
- postcss-modules-scope: 2.2.0
- postcss-modules-values: 3.0.0
- postcss-value-parser: 4.1.0
- schema-utils: 2.7.1
- semver: 6.3.0
- webpack: 4.46.0
- dev: true
-
- /css-loader/5.2.7_webpack@4.46.0:
+ /css-loader@5.2.7(webpack@4.46.0):
resolution: {integrity: sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==}
engines: {node: '>= 10.13.0'}
peerDependencies:
webpack: ^4.27.0 || ^5.0.0
dependencies:
- icss-utils: 5.1.0_postcss@8.3.6
- loader-utils: 2.0.0
- postcss: 8.3.6
- postcss-modules-extract-imports: 3.0.0_postcss@8.3.6
- postcss-modules-local-by-default: 4.0.0_postcss@8.3.6
- postcss-modules-scope: 3.0.0_postcss@8.3.6
- postcss-modules-values: 4.0.0_postcss@8.3.6
- postcss-value-parser: 4.1.0
+ icss-utils: 5.1.0(postcss@8.4.32)
+ loader-utils: 2.0.3
+ postcss: 8.4.32
+ postcss-modules-extract-imports: 3.0.0(postcss@8.4.32)
+ postcss-modules-local-by-default: 4.0.0(postcss@8.4.32)
+ postcss-modules-scope: 3.0.0(postcss@8.4.32)
+ postcss-modules-values: 4.0.0(postcss@8.4.32)
+ postcss-value-parser: 4.2.0
schema-utils: 3.1.1
- semver: 7.3.5
+ semver: 7.5.4
webpack: 4.46.0
dev: true
- /css-select-base-adapter/0.1.1:
+ /css-select-base-adapter@0.1.1:
resolution: {integrity: sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==}
dev: true
- /css-select/2.1.0:
+ /css-select@2.1.0:
resolution: {integrity: sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==}
dependencies:
boolbase: 1.0.0
@@ -10618,17 +9241,27 @@ packages:
nth-check: 1.0.2
dev: true
- /css-select/4.1.3:
- resolution: {integrity: sha512-gT3wBNd9Nj49rAbmtFHj1cljIAOLYSX1nZ8CB7TBO3INYckygm5B7LISU/szY//YmdiSLbJvDLOx9VnMVpMBxA==}
+ /css-select@4.3.0:
+ resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
dependencies:
boolbase: 1.0.0
- css-what: 5.0.1
- domhandler: 4.2.0
- domutils: 2.7.0
- nth-check: 2.0.0
+ css-what: 6.1.0
+ domhandler: 4.3.1
+ domutils: 2.8.0
+ nth-check: 2.1.1
dev: true
- /css-tree/1.0.0-alpha.37:
+ /css-select@5.1.0:
+ resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
+ dependencies:
+ boolbase: 1.0.0
+ css-what: 6.1.0
+ domhandler: 5.0.3
+ domutils: 3.1.0
+ nth-check: 2.1.1
+ dev: true
+
+ /css-tree@1.0.0-alpha.37:
resolution: {integrity: sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==}
engines: {node: '>=8.0.0'}
dependencies:
@@ -10636,7 +9269,7 @@ packages:
source-map: 0.6.1
dev: true
- /css-tree/1.1.3:
+ /css-tree@1.1.3:
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
engines: {node: '>=8.0.0'}
dependencies:
@@ -10644,17 +9277,21 @@ packages:
source-map: 0.6.1
dev: true
- /css-what/3.4.2:
+ /css-what@3.4.2:
resolution: {integrity: sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==}
engines: {node: '>= 6'}
dev: true
- /css-what/5.0.1:
- resolution: {integrity: sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg==}
+ /css-what@6.1.0:
+ resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
engines: {node: '>= 6'}
dev: true
- /css/2.2.4:
+ /css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+ dev: true
+
+ /css@2.2.4:
resolution: {integrity: sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==}
dependencies:
inherits: 2.0.4
@@ -10663,19 +9300,18 @@ packages:
urix: 0.1.0
dev: true
- /cssesc/3.0.0:
+ /cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
- dev: true
- /cssnano-preset-default/4.0.8:
+ /cssnano-preset-default@4.0.8:
resolution: {integrity: sha512-LdAyHuq+VRyeVREFmuxUZR1TXjQm8QQU/ktoo/x7bz+SdOge1YKc5eMN6pRW7YWBmyq59CqYba1dJ5cUukEjLQ==}
engines: {node: '>=6.9.0'}
dependencies:
css-declaration-sorter: 4.0.1
cssnano-util-raw-cache: 4.0.1
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-calc: 7.0.5
postcss-colormin: 4.0.3
postcss-convert-values: 4.0.1
@@ -10705,216 +9341,193 @@ packages:
postcss-unique-selectors: 4.0.1
dev: true
- /cssnano-preset-default/5.1.4_postcss@8.3.6:
- resolution: {integrity: sha512-sPpQNDQBI3R/QsYxQvfB4mXeEcWuw0wGtKtmS5eg8wudyStYMgKOQT39G07EbW1LB56AOYrinRS9f0ig4Y3MhQ==}
+ /cssnano-preset-default@5.2.12(postcss@8.4.32):
+ resolution: {integrity: sha512-OyCBTZi+PXgylz9HAA5kHyoYhfGcYdwFmyaJzWnzxuGRtnMw/kR6ilW9XzlzlRAtB6PLT/r+prYgkef7hngFew==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- css-declaration-sorter: 6.1.1_postcss@8.3.6
- cssnano-utils: 2.0.1_postcss@8.3.6
- postcss: 8.3.6
- postcss-calc: 8.0.0_postcss@8.3.6
- postcss-colormin: 5.2.0_postcss@8.3.6
- postcss-convert-values: 5.0.1_postcss@8.3.6
- postcss-discard-comments: 5.0.1_postcss@8.3.6
- postcss-discard-duplicates: 5.0.1_postcss@8.3.6
- postcss-discard-empty: 5.0.1_postcss@8.3.6
- postcss-discard-overridden: 5.0.1_postcss@8.3.6
- postcss-merge-longhand: 5.0.2_postcss@8.3.6
- postcss-merge-rules: 5.0.2_postcss@8.3.6
- postcss-minify-font-values: 5.0.1_postcss@8.3.6
- postcss-minify-gradients: 5.0.2_postcss@8.3.6
- postcss-minify-params: 5.0.1_postcss@8.3.6
- postcss-minify-selectors: 5.1.0_postcss@8.3.6
- postcss-normalize-charset: 5.0.1_postcss@8.3.6
- postcss-normalize-display-values: 5.0.1_postcss@8.3.6
- postcss-normalize-positions: 5.0.1_postcss@8.3.6
- postcss-normalize-repeat-style: 5.0.1_postcss@8.3.6
- postcss-normalize-string: 5.0.1_postcss@8.3.6
- postcss-normalize-timing-functions: 5.0.1_postcss@8.3.6
- postcss-normalize-unicode: 5.0.1_postcss@8.3.6
- postcss-normalize-url: 5.0.2_postcss@8.3.6
- postcss-normalize-whitespace: 5.0.1_postcss@8.3.6
- postcss-ordered-values: 5.0.2_postcss@8.3.6
- postcss-reduce-initial: 5.0.1_postcss@8.3.6
- postcss-reduce-transforms: 5.0.1_postcss@8.3.6
- postcss-svgo: 5.0.2_postcss@8.3.6
- postcss-unique-selectors: 5.0.1_postcss@8.3.6
- dev: true
-
- /cssnano-util-get-arguments/4.0.0:
- resolution: {integrity: sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8=}
- engines: {node: '>=6.9.0'}
- dev: true
-
- /cssnano-util-get-match/4.0.0:
- resolution: {integrity: sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0=}
- engines: {node: '>=6.9.0'}
- dev: true
-
- /cssnano-util-raw-cache/4.0.1:
+ css-declaration-sorter: 6.3.1(postcss@8.4.32)
+ cssnano-utils: 3.1.0(postcss@8.4.32)
+ postcss: 8.4.32
+ postcss-calc: 8.2.4(postcss@8.4.32)
+ postcss-colormin: 5.3.0(postcss@8.4.32)
+ postcss-convert-values: 5.1.2(postcss@8.4.32)
+ postcss-discard-comments: 5.1.2(postcss@8.4.32)
+ postcss-discard-duplicates: 5.1.0(postcss@8.4.32)
+ postcss-discard-empty: 5.1.1(postcss@8.4.32)
+ postcss-discard-overridden: 5.1.0(postcss@8.4.32)
+ postcss-merge-longhand: 5.1.6(postcss@8.4.32)
+ postcss-merge-rules: 5.1.2(postcss@8.4.32)
+ postcss-minify-font-values: 5.1.0(postcss@8.4.32)
+ postcss-minify-gradients: 5.1.1(postcss@8.4.32)
+ postcss-minify-params: 5.1.3(postcss@8.4.32)
+ postcss-minify-selectors: 5.2.1(postcss@8.4.32)
+ postcss-normalize-charset: 5.1.0(postcss@8.4.32)
+ postcss-normalize-display-values: 5.1.0(postcss@8.4.32)
+ postcss-normalize-positions: 5.1.1(postcss@8.4.32)
+ postcss-normalize-repeat-style: 5.1.1(postcss@8.4.32)
+ postcss-normalize-string: 5.1.0(postcss@8.4.32)
+ postcss-normalize-timing-functions: 5.1.0(postcss@8.4.32)
+ postcss-normalize-unicode: 5.1.0(postcss@8.4.32)
+ postcss-normalize-url: 5.1.0(postcss@8.4.32)
+ postcss-normalize-whitespace: 5.1.1(postcss@8.4.32)
+ postcss-ordered-values: 5.1.3(postcss@8.4.32)
+ postcss-reduce-initial: 5.1.0(postcss@8.4.32)
+ postcss-reduce-transforms: 5.1.0(postcss@8.4.32)
+ postcss-svgo: 5.1.0(postcss@8.4.32)
+ postcss-unique-selectors: 5.1.1(postcss@8.4.32)
+ dev: true
+
+ /cssnano-util-get-arguments@4.0.0:
+ resolution: {integrity: sha512-6RIcwmV3/cBMG8Aj5gucQRsJb4vv4I4rn6YjPbVWd5+Pn/fuG+YseGvXGk00XLkoZkaj31QOD7vMUpNPC4FIuw==}
+ engines: {node: '>=6.9.0'}
+ dev: true
+
+ /cssnano-util-get-match@4.0.0:
+ resolution: {integrity: sha512-JPMZ1TSMRUPVIqEalIBNoBtAYbi8okvcFns4O0YIhcdGebeYZK7dMyHJiQ6GqNBA9kE0Hym4Aqym5rPdsV/4Cw==}
+ engines: {node: '>=6.9.0'}
+ dev: true
+
+ /cssnano-util-raw-cache@4.0.1:
resolution: {integrity: sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA==}
engines: {node: '>=6.9.0'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
dev: true
- /cssnano-util-same-parent/4.0.1:
+ /cssnano-util-same-parent@4.0.1:
resolution: {integrity: sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q==}
engines: {node: '>=6.9.0'}
dev: true
- /cssnano-utils/2.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-i8vLRZTnEH9ubIyfdZCAdIdgnHAUeQeByEeQ2I7oTilvP9oHO6RScpeq3GsFUVqeB8uZgOQ9pw8utofNn32hhQ==}
+ /cssnano-utils@3.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
+ postcss: 8.4.32
dev: true
- /cssnano/4.1.11:
+ /cssnano@4.1.11:
resolution: {integrity: sha512-6gZm2htn7xIPJOHY824ERgj8cNPgPxyCSnkXc4v7YvNW+TdVfzgngHcEhy/8D11kUWRUMbke+tC+AUcUsnMz2g==}
engines: {node: '>=6.9.0'}
dependencies:
cosmiconfig: 5.2.1
cssnano-preset-default: 4.0.8
is-resolvable: 1.1.0
- postcss: 7.0.36
+ postcss: 7.0.39
dev: true
- /cssnano/5.0.8_postcss@8.3.6:
- resolution: {integrity: sha512-Lda7geZU0Yu+RZi2SGpjYuQz4HI4/1Y+BhdD0jL7NXAQ5larCzVn+PUGuZbDMYz904AXXCOgO5L1teSvgu7aFg==}
+ /cssnano@5.1.13(postcss@8.4.32):
+ resolution: {integrity: sha512-S2SL2ekdEz6w6a2epXn4CmMKU4K3KpcyXLKfAYc9UQQqJRkD/2eLUG0vJ3Db/9OvO5GuAdgXw3pFbR6abqghDQ==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- cssnano-preset-default: 5.1.4_postcss@8.3.6
- is-resolvable: 1.1.0
- lilconfig: 2.0.3
- postcss: 8.3.6
+ cssnano-preset-default: 5.2.12(postcss@8.4.32)
+ lilconfig: 2.1.0
+ postcss: 8.4.32
yaml: 1.10.2
dev: true
- /csso/4.2.0:
+ /csso@4.2.0:
resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==}
engines: {node: '>=8.0.0'}
dependencies:
css-tree: 1.1.3
dev: true
- /cssom/0.3.8:
+ /cssom@0.3.8:
resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==}
dev: true
- /cssom/0.4.4:
- resolution: {integrity: sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==}
- dev: true
-
- /cssstyle/1.4.0:
+ /cssstyle@1.4.0:
resolution: {integrity: sha512-GBrLZYZ4X4x6/QEoBnIrqb8B/f5l4+8me2dkom/j1Gtbxy0kBv6OGzKuAsGM75bkGwGAFkt56Iwg28S3XTZgSA==}
dependencies:
cssom: 0.3.8
dev: true
- /cssstyle/2.3.0:
- resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==}
- engines: {node: '>=8'}
- dependencies:
- cssom: 0.3.8
- dev: true
-
- /csstype/2.6.17:
- resolution: {integrity: sha512-u1wmTI1jJGzCJzWndZo8mk4wnPTZd1eOIYTYvuEyOQGfmDl3TrabCCfKnOC86FZwW/9djqTl933UF/cS425i9A==}
- dev: true
-
- /csstype/3.0.8:
- resolution: {integrity: sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==}
- dev: true
-
- /currently-unhandled/0.4.1:
- resolution: {integrity: sha1-mI3zP+qxke95mmE2nddsF635V+o=}
+ /currently-unhandled@0.4.1:
+ resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==}
engines: {node: '>=0.10.0'}
dependencies:
array-find-index: 1.0.2
dev: true
- /cyclist/1.0.1:
- resolution: {integrity: sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=}
+ /cyclist@1.0.2:
+ resolution: {integrity: sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==}
dev: true
- /damerau-levenshtein/1.0.6:
- resolution: {integrity: sha512-JVrozIeElnj3QzfUIt8tB8YMluBJom4Vw9qTPpjGYQ9fYlB3D/rb6OordUxf3xeFB35LKWs0xqcO5U6ySvBtug==}
+ /damerau-levenshtein@1.0.8:
+ resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
dev: true
- /dashdash/1.14.1:
- resolution: {integrity: sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=}
+ /dashdash@1.14.1:
+ resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==}
engines: {node: '>=0.10'}
dependencies:
assert-plus: 1.0.0
dev: true
- /data-uri-to-buffer/3.0.1:
- resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==}
- engines: {node: '>= 6'}
- dev: false
+ /data-uri-to-buffer@4.0.1:
+ resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
+ engines: {node: '>= 12'}
+ dev: true
- /data-urls/1.1.0:
+ /data-urls@1.1.0:
resolution: {integrity: sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==}
dependencies:
- abab: 2.0.5
+ abab: 2.0.6
whatwg-mimetype: 2.3.0
whatwg-url: 7.1.0
dev: true
- /data-urls/2.0.0:
- resolution: {integrity: sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==}
- engines: {node: '>=10'}
- dependencies:
- abab: 2.0.5
- whatwg-mimetype: 2.3.0
- whatwg-url: 8.7.0
- dev: true
-
- /date-fns/2.23.0:
- resolution: {integrity: sha512-5ycpauovVyAk0kXNZz6ZoB9AYMZB4DObse7P3BPWmyEjXNORTI8EJ6X0uaSAq4sCHzM1uajzrkr6HnsLQpxGXA==}
+ /date-fns@2.29.2:
+ resolution: {integrity: sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA==}
engines: {node: '>=0.11'}
dev: false
- /date-time/3.1.0:
+ /date-fns@2.29.3:
+ resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
+ engines: {node: '>=0.11'}
+
+ /date-time@3.1.0:
resolution: {integrity: sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==}
engines: {node: '>=6'}
dependencies:
time-zone: 1.0.0
dev: true
- /debug/2.6.9:
+ /debounce@1.2.1:
+ resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
+ dev: true
+
+ /debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
dependencies:
ms: 2.0.0
dev: true
- /debug/3.2.7:
+ /debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
- dependencies:
- ms: 2.1.3
- dev: true
-
- /debug/4.3.1:
- resolution: {integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==}
- engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
- ms: 2.1.2
+ ms: 2.1.3
dev: true
- /debug/4.3.2:
- resolution: {integrity: sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==}
+ /debug@4.3.3(supports-color@8.1.1):
+ resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
@@ -10923,10 +9536,11 @@ packages:
optional: true
dependencies:
ms: 2.1.2
+ supports-color: 8.1.1
dev: true
- /debug/4.3.2_supports-color@6.1.0:
- resolution: {integrity: sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==}
+ /debug@4.3.4:
+ resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
@@ -10935,112 +9549,151 @@ packages:
optional: true
dependencies:
ms: 2.1.2
- supports-color: 6.1.0
- dev: true
- /decamelize/1.2.0:
- resolution: {integrity: sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=}
+ /decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
dev: true
- /decimal.js/10.3.1:
- resolution: {integrity: sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==}
+ /decamelize@4.0.0:
+ resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==}
+ engines: {node: '>=10'}
dev: true
- /decode-uri-component/0.2.0:
- resolution: {integrity: sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=}
+ /decamelize@6.0.0:
+ resolution: {integrity: sha512-Fv96DCsdOgB6mdGl67MT5JaTNKRzrzill5OH5s8bjYJXVlcXyPYGyPsUkWyGV5p1TXI5esYIYMMeDJL0hEIwaA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dev: true
+
+ /decode-uri-component@0.2.2:
+ resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
dev: true
- /decompress-response/3.3.0:
- resolution: {integrity: sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=}
+ /decompress-response@3.3.0:
+ resolution: {integrity: sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==}
engines: {node: '>=4'}
dependencies:
mimic-response: 1.0.1
dev: true
- /deep-equal/1.1.1:
- resolution: {integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==}
+ /decompress-response@6.0.0:
+ resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
+ engines: {node: '>=10'}
+ requiresBuild: true
dependencies:
- is-arguments: 1.1.1
- is-date-object: 1.0.5
- is-regex: 1.1.4
- object-is: 1.1.5
- object-keys: 1.1.1
- regexp.prototype.flags: 1.3.1
+ mimic-response: 3.1.0
+
+ /deep-eql@3.0.1:
+ resolution: {integrity: sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==}
+ engines: {node: '>=0.12'}
+ dependencies:
+ type-detect: 4.0.8
dev: true
- /deep-extend/0.6.0:
+ /deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
- dev: true
+ requiresBuild: true
- /deep-is/0.1.3:
- resolution: {integrity: sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=}
+ /deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
- /deep-object-diff/1.1.0:
- resolution: {integrity: sha512-b+QLs5vHgS+IoSNcUE4n9HP2NwcHj7aqnJWsjPtuG75Rh5TOaGt0OjAYInh77d5T16V5cRDC+Pw/6ZZZiETBGw==}
+ /deepcopy@1.0.0:
+ resolution: {integrity: sha512-WJrecobaoqqgQHtvRI2/VCzWoWXPAnFYyAkF/spmL46lZMnd0gW0gLGuyeFVSrqt2B3s0oEEj6i+j2L/2QiS4g==}
+ dependencies:
+ type-detect: 4.0.8
dev: true
- /deepcopy/1.0.0:
- resolution: {integrity: sha512-WJrecobaoqqgQHtvRI2/VCzWoWXPAnFYyAkF/spmL46lZMnd0gW0gLGuyeFVSrqt2B3s0oEEj6i+j2L/2QiS4g==}
+ /deepcopy@2.1.0:
+ resolution: {integrity: sha512-8cZeTb1ZKC3bdSCP6XOM1IsTczIO73fdqtwa2B0N15eAz7gmyhQo+mc5gnFuulsgN3vIQYmTgbmQVKalH1dKvQ==}
dependencies:
type-detect: 4.0.8
dev: true
- /deepmerge/4.2.2:
+ /deepmerge@2.2.1:
+ resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /deepmerge@4.2.2:
resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==}
engines: {node: '>=0.10.0'}
dev: true
- /default-gateway/4.2.0:
- resolution: {integrity: sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA==}
- engines: {node: '>=6'}
+ /deepmerge@4.3.1:
+ resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /default-gateway@6.0.3:
+ resolution: {integrity: sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==}
+ engines: {node: '>= 10'}
dependencies:
- execa: 1.0.0
- ip-regex: 2.1.0
+ execa: 5.1.1
dev: true
- /default-require-extensions/3.0.0:
- resolution: {integrity: sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==}
+ /default-require-extensions@3.0.1:
+ resolution: {integrity: sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==}
engines: {node: '>=8'}
dependencies:
strip-bom: 4.0.0
dev: true
- /defaults/1.0.3:
- resolution: {integrity: sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=}
+ /defaults@1.0.4:
+ resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
dependencies:
clone: 1.0.4
dev: true
- /defer-to-connect/1.1.3:
+ /defer-to-connect@1.1.3:
resolution: {integrity: sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==}
dev: true
- /define-properties/1.1.3:
- resolution: {integrity: sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==}
+ /defer-to-connect@2.0.1:
+ resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /define-data-property@1.1.1:
+ resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==}
engines: {node: '>= 0.4'}
dependencies:
+ get-intrinsic: 1.2.2
+ gopd: 1.0.1
+ has-property-descriptors: 1.0.1
+ dev: true
+
+ /define-lazy-prop@2.0.0:
+ resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /define-properties@1.2.1:
+ resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ define-data-property: 1.1.1
+ has-property-descriptors: 1.0.1
object-keys: 1.1.1
dev: true
- /define-property/0.2.5:
- resolution: {integrity: sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=}
+ /define-property@0.2.5:
+ resolution: {integrity: sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==}
engines: {node: '>=0.10.0'}
dependencies:
is-descriptor: 0.1.6
dev: true
- /define-property/1.0.0:
- resolution: {integrity: sha1-dp66rz9KY6rTr56NMEybvnm/sOY=}
+ /define-property@1.0.0:
+ resolution: {integrity: sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==}
engines: {node: '>=0.10.0'}
dependencies:
is-descriptor: 1.0.2
dev: true
- /define-property/2.0.2:
+ /define-property@2.0.2:
resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -11048,97 +9701,70 @@ packages:
isobject: 3.0.1
dev: true
- /del/4.1.1:
- resolution: {integrity: sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==}
- engines: {node: '>=6'}
- dependencies:
- '@types/glob': 7.1.4
- globby: 6.1.0
- is-path-cwd: 2.2.0
- is-path-in-cwd: 2.1.0
- p-map: 2.1.0
- pify: 4.0.1
- rimraf: 2.7.1
+ /delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
dev: true
- /del/6.0.0:
- resolution: {integrity: sha512-1shh9DQ23L16oXSZKB2JxpL7iMy2E0S9d517ptA1P8iw0alkPtQcrKH7ru31rYtKwF499HkTu+DRzq3TCKDFRQ==}
- engines: {node: '>=10'}
- dependencies:
- globby: 11.0.4
- graceful-fs: 4.2.8
- is-glob: 4.0.1
- is-path-cwd: 2.2.0
- is-path-inside: 3.0.3
- p-map: 4.0.0
- rimraf: 3.0.2
- slash: 3.0.0
+ /delegates@1.0.0:
+ resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
dev: true
- /delayed-stream/1.0.0:
- resolution: {integrity: sha1-3zrhmayt+31ECqrgsp4icrJOxhk=}
- engines: {node: '>=0.4.0'}
+ /depd@1.1.2:
+ resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
+ engines: {node: '>= 0.6'}
dev: true
- /delegates/1.0.0:
- resolution: {integrity: sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=}
+ /depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
dev: true
- /depd/1.1.2:
- resolution: {integrity: sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=}
- engines: {node: '>= 0.6'}
+ /dependency-graph@0.11.0:
+ resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==}
+ engines: {node: '>= 0.6.0'}
dev: true
- /des.js/1.0.1:
- resolution: {integrity: sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==}
+ /dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /des.js@1.1.0:
+ resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==}
dependencies:
inherits: 2.0.4
minimalistic-assert: 1.0.1
dev: true
- /destroy/1.0.4:
- resolution: {integrity: sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=}
- dev: true
-
- /detab/2.0.4:
- resolution: {integrity: sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g==}
- dependencies:
- repeat-string: 1.6.1
+ /destroy@1.2.0:
+ resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
+ engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dev: true
- /detect-newline/3.1.0:
- resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
+ /detect-libc@2.0.2:
+ resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
engines: {node: '>=8'}
- dev: true
+ requiresBuild: true
- /detect-node/2.1.0:
+ /detect-node@2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
dev: true
- /detect-port-alt/1.1.6:
- resolution: {integrity: sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==}
- engines: {node: '>= 4.2.1'}
- hasBin: true
- dependencies:
- address: 1.1.2
- debug: 2.6.9
- dev: true
+ /didyoumean@1.2.2:
+ resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
- /detect-port/1.3.0:
- resolution: {integrity: sha512-E+B1gzkl2gqxt1IhUzwjrxBKRqx1UzC3WLONHinn8S3T6lwV/agVCyitiFOsGJ/eYuEUBvD71MZHy3Pv1G9doQ==}
- engines: {node: '>= 4.2.1'}
- hasBin: true
- dependencies:
- address: 1.1.2
- debug: 2.6.9
+ /diff@4.0.2:
+ resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
+ engines: {node: '>=0.3.1'}
dev: true
- /diff-sequences/26.6.2:
- resolution: {integrity: sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==}
- engines: {node: '>= 10.14.2'}
+ /diff@5.0.0:
+ resolution: {integrity: sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==}
+ engines: {node: '>=0.3.1'}
dev: true
- /diffie-hellman/5.0.3:
+ /diffie-hellman@5.0.3:
resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==}
dependencies:
bn.js: 4.12.0
@@ -11146,260 +9772,229 @@ packages:
randombytes: 2.1.0
dev: true
- /dir-glob/2.2.2:
- resolution: {integrity: sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==}
- engines: {node: '>=4'}
- dependencies:
- path-type: 3.0.0
- dev: true
-
- /dir-glob/3.0.1:
+ /dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
dependencies:
path-type: 4.0.0
dev: true
- /discontinuous-range/1.0.0:
- resolution: {integrity: sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=}
- dev: true
+ /dlv@1.1.3:
+ resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
- /dns-equal/1.0.0:
- resolution: {integrity: sha1-s55/HabrCnW6nBcySzR1PEfgZU0=}
+ /dns-equal@1.0.0:
+ resolution: {integrity: sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==}
dev: true
- /dns-packet/1.3.4:
- resolution: {integrity: sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA==}
- dependencies:
- ip: 1.1.5
- safe-buffer: 5.2.1
- dev: true
-
- /dns-txt/2.0.2:
- resolution: {integrity: sha1-uR2Ab10nGI5Ks+fRB9iBocxGQrY=}
- dependencies:
- buffer-indexof: 1.1.1
- dev: true
-
- /doctrine/1.5.0:
- resolution: {integrity: sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=}
- engines: {node: '>=0.10.0'}
+ /dns-packet@5.4.0:
+ resolution: {integrity: sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g==}
+ engines: {node: '>=6'}
dependencies:
- esutils: 2.0.3
- isarray: 1.0.0
+ '@leichtgewicht/ip-codec': 2.0.4
dev: true
- /doctrine/2.1.0:
+ /doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
dependencies:
esutils: 2.0.3
dev: true
- /doctrine/3.0.0:
+ /doctrine@3.0.0:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
dependencies:
esutils: 2.0.3
dev: true
- /dom-accessibility-api/0.5.7:
- resolution: {integrity: sha512-ml3lJIq9YjUfM9TUnEPvEYWFSwivwIGBPKpewX7tii7fwCazA8yCioGdqQcNsItPpfFvSJ3VIdMQPj60LJhcQA==}
- dev: true
-
- /dom-converter/0.2.0:
+ /dom-converter@0.2.0:
resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==}
dependencies:
utila: 0.4.0
dev: true
- /dom-serializer/0.2.2:
+ /dom-serializer@0.2.2:
resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==}
dependencies:
- domelementtype: 2.2.0
+ domelementtype: 2.3.0
entities: 2.2.0
dev: true
- /dom-serializer/1.3.2:
- resolution: {integrity: sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==}
+ /dom-serializer@1.4.1:
+ resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
dependencies:
- domelementtype: 2.2.0
- domhandler: 4.2.0
+ domelementtype: 2.3.0
+ domhandler: 4.3.1
entities: 2.2.0
dev: true
- /dom-walk/0.1.2:
- resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==}
+ /dom-serializer@2.0.0:
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
dev: true
- /domain-browser/1.2.0:
+ /domain-browser@1.2.0:
resolution: {integrity: sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==}
engines: {node: '>=0.4', npm: '>=1.2'}
dev: true
- /domelementtype/1.3.1:
+ /domelementtype@1.3.1:
resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==}
dev: true
- /domelementtype/2.2.0:
- resolution: {integrity: sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==}
+ /domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
dev: true
- /domexception/1.0.1:
+ /domexception@1.0.1:
resolution: {integrity: sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==}
dependencies:
webidl-conversions: 4.0.2
dev: true
- /domexception/2.0.1:
- resolution: {integrity: sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==}
- engines: {node: '>=8'}
+ /domhandler@4.3.1:
+ resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
+ engines: {node: '>= 4'}
dependencies:
- webidl-conversions: 5.0.0
+ domelementtype: 2.3.0
dev: true
- /domhandler/4.2.0:
- resolution: {integrity: sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA==}
+ /domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dependencies:
- domelementtype: 2.2.0
+ domelementtype: 2.3.0
dev: true
- /domutils/1.7.0:
+ /domutils@1.7.0:
resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==}
dependencies:
dom-serializer: 0.2.2
domelementtype: 1.3.1
dev: true
- /domutils/2.7.0:
- resolution: {integrity: sha512-8eaHa17IwJUPAiB+SoTYBo5mCdeMgdcAoXJ59m6DT1vw+5iLS3gNoqYaRowaBKtGVrOF1Jz4yDTgYKLK2kvfJg==}
+ /domutils@2.8.0:
+ resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
+ dependencies:
+ dom-serializer: 1.4.1
+ domelementtype: 2.3.0
+ domhandler: 4.3.1
+ dev: true
+
+ /domutils@3.1.0:
+ resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
dependencies:
- dom-serializer: 1.3.2
- domelementtype: 2.2.0
- domhandler: 4.2.0
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
dev: true
- /dot-case/3.0.4:
+ /dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
dependencies:
no-case: 3.0.4
- tslib: 2.3.1
+ tslib: 2.6.2
dev: true
- /dot-prop/5.3.0:
+ /dot-prop@5.3.0:
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
engines: {node: '>=8'}
dependencies:
is-obj: 2.0.0
dev: true
- /dotenv-defaults/1.1.1:
- resolution: {integrity: sha512-6fPRo9o/3MxKvmRZBD3oNFdxODdhJtIy1zcJeUSCs6HCy4tarUpd+G67UTU9tF6OWXeSPqsm4fPAB+2eY9Rt9Q==}
- dependencies:
- dotenv: 6.2.0
- dev: true
-
- /dotenv-expand/5.1.0:
- resolution: {integrity: sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==}
- dev: true
-
- /dotenv-webpack/1.8.0_webpack@4.46.0:
- resolution: {integrity: sha512-o8pq6NLBehtrqA8Jv8jFQNtG9nhRtVqmoD4yWbgUyoU3+9WBlPe+c2EAiaJok9RB28QvrWvdWLZGeTT5aATDMg==}
- peerDependencies:
- webpack: ^1 || ^2 || ^3 || ^4
+ /dot-prop@6.0.1:
+ resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
+ engines: {node: '>=10'}
dependencies:
- dotenv-defaults: 1.1.1
- webpack: 4.46.0
+ is-obj: 2.0.0
dev: true
- /dotenv/6.2.0:
- resolution: {integrity: sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==}
- engines: {node: '>=6'}
+ /dotenv@16.0.3:
+ resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==}
+ engines: {node: '>=12'}
dev: true
- /dotenv/8.6.0:
+ /dotenv@8.6.0:
resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==}
engines: {node: '>=10'}
dev: true
- /downshift/6.1.7:
- resolution: {integrity: sha512-cVprZg/9Lvj/uhYRxELzlu1aezRcgPWBjTvspiGTVEU64gF5pRdSRKFVLcxqsZC637cLAGMbL40JavEfWnqgNg==}
- peerDependencies:
- react: '>=16.12.0'
+ /dtrace-provider@0.8.8:
+ resolution: {integrity: sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==}
+ engines: {node: '>=0.10'}
+ requiresBuild: true
dependencies:
- '@babel/runtime': 7.15.3
- compute-scroll-into-view: 1.0.17
- prop-types: 15.7.2
- react-is: 17.0.2
- tslib: 2.3.1
+ nan: 2.18.0
dev: true
+ optional: true
- /downshift/6.1.7_react@16.14.0:
- resolution: {integrity: sha512-cVprZg/9Lvj/uhYRxELzlu1aezRcgPWBjTvspiGTVEU64gF5pRdSRKFVLcxqsZC637cLAGMbL40JavEfWnqgNg==}
- peerDependencies:
- react: '>=16.12.0'
- dependencies:
- '@babel/runtime': 7.15.3
- compute-scroll-into-view: 1.0.17
- prop-types: 15.7.2
- react: 16.14.0
- react-is: 17.0.2
- tslib: 2.3.1
+ /duplexer3@0.1.5:
+ resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==}
dev: true
- /duplexer/0.1.2:
+ /duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
dev: true
- /duplexer3/0.1.4:
- resolution: {integrity: sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=}
- dev: true
-
- /duplexify/3.7.1:
+ /duplexify@3.7.1:
resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==}
dependencies:
end-of-stream: 1.4.4
inherits: 2.0.4
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
stream-shift: 1.0.1
dev: true
- /ecc-jsbn/0.1.2:
- resolution: {integrity: sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=}
+ /eastasianwidth@0.2.0:
+ resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+
+ /ecc-jsbn@0.1.2:
+ resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==}
dependencies:
jsbn: 0.1.1
safer-buffer: 2.1.2
dev: true
- /ee-first/1.1.1:
- resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
+ /ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+ dependencies:
+ safe-buffer: 5.2.1
+ dev: true
+
+ /ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: true
- /ejs-loader/0.5.0:
+ /ejs-loader@0.5.0:
resolution: {integrity: sha512-iirFqlP3tiFoedNZ7dQcjvechunl054VbW6Ki38T/pabgXMAncduSE0ZXLeVGn1NbmcUJF9Z5TC0EvQ4RIpP9Q==}
dependencies:
- loader-utils: 2.0.0
+ loader-utils: 2.0.3
lodash: 4.17.21
dev: true
- /ejs/2.7.4:
- resolution: {integrity: sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==}
+ /ejs@3.1.8:
+ resolution: {integrity: sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==}
engines: {node: '>=0.10.0'}
- requiresBuild: true
+ dependencies:
+ jake: 10.8.5
dev: true
- /electron-to-chromium/1.3.813:
- resolution: {integrity: sha512-YcSRImHt6JZZ2sSuQ4Bzajtk98igQ0iKkksqlzZLzbh4p0OIyJRSvUbsgqfcR8txdfsoYCc4ym306t4p2kP/aw==}
+ /electron-to-chromium@1.4.284:
+ resolution: {integrity: sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==}
dev: true
- /element-resize-detector/1.2.3:
- resolution: {integrity: sha512-+dhNzUgLpq9ol5tyhoG7YLoXL3ssjfFW+0gpszXPwRU6NjGr1fVHMEAF8fVzIiRJq57Nre0RFeIjJwI8Nh2NmQ==}
- dependencies:
- batch-processor: 1.0.0
+ /electron-to-chromium@1.4.597:
+ resolution: {integrity: sha512-0XOQNqHhg2YgRVRUrS4M4vWjFCFIP2ETXcXe/0KIQBjXE9Cpy+tgzzYfuq6HGai3hWq0YywtG+5XK8fyG08EjA==}
dev: true
- /elliptic/6.5.4:
+ /electron-to-chromium@1.4.613:
+ resolution: {integrity: sha512-r4x5+FowKG6q+/Wj0W9nidx7QO31BJwmR2uEo+Qh3YLGQ8SbBAFuDFpTxzly/I2gsbrFwBuIjrMp423L3O5U3w==}
+
+ /elliptic@6.5.4:
resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==}
dependencies:
bn.js: 4.12.0
@@ -11411,325 +10006,276 @@ packages:
minimalistic-crypto-utils: 1.0.1
dev: true
- /emittery/0.7.2:
- resolution: {integrity: sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ==}
- engines: {node: '>=10'}
+ /emittery@1.0.1:
+ resolution: {integrity: sha512-2ID6FdrMD9KDLldGesP6317G78K7km/kMcwItRtVFva7I/cSEOIaLpewaUb+YLXVwdAp3Ctfxh/V5zIl1sj7dQ==}
+ engines: {node: '>=14.16'}
dev: true
- /emittery/0.8.1:
- resolution: {integrity: sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==}
- engines: {node: '>=10'}
- dev: true
-
- /emoji-regex/6.1.1:
- resolution: {integrity: sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4=}
- dev: true
-
- /emoji-regex/7.0.3:
- resolution: {integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==}
+ /emoji-regex@10.3.0:
+ resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==}
dev: true
- /emoji-regex/8.0.0:
+ /emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
- dev: true
- /emoji-regex/9.2.0:
- resolution: {integrity: sha512-DNc3KFPK18bPdElMJnf/Pkv5TXhxFU3YFDEuGLDRtPmV4rkmCjBkCSEp22u6rBHdSN9Vlp/GK7k98prmE1Jgug==}
- dev: true
+ /emoji-regex@9.2.2:
+ resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
- /emojis-list/2.1.0:
- resolution: {integrity: sha1-TapNnbAPmBmIDHn6RXrlsJof04k=}
+ /emojis-list@2.1.0:
+ resolution: {integrity: sha512-knHEZMgs8BB+MInokmNTg/OyPlAddghe1YBgNwJBc5zsJi/uyIcXoSDsL/W9ymOsBoBGdPIHXYJ9+qKFwRwDng==}
engines: {node: '>= 0.10'}
dev: true
- /emojis-list/3.0.0:
+ /emojis-list@3.0.0:
resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==}
engines: {node: '>= 4'}
dev: true
- /emotion-theming/10.0.27_5f216699bc8c1f24088b3bf77b7cbbdf:
- resolution: {integrity: sha512-MlF1yu/gYh8u+sLUqA0YuA9JX0P4Hb69WlKc/9OLo+WCXuX6sy/KoIa+qJimgmr2dWqnypYKYPX37esjDBbhdw==}
- peerDependencies:
- '@emotion/core': ^10.0.27
- react: '>=16.3.0'
- dependencies:
- '@babel/runtime': 7.15.3
- '@emotion/core': 10.1.1_react@16.14.0
- '@emotion/weak-memoize': 0.2.5
- hoist-non-react-statics: 3.3.2
- react: 16.14.0
- dev: true
-
- /emotion-theming/10.0.27_@emotion+core@10.1.1:
- resolution: {integrity: sha512-MlF1yu/gYh8u+sLUqA0YuA9JX0P4Hb69WlKc/9OLo+WCXuX6sy/KoIa+qJimgmr2dWqnypYKYPX37esjDBbhdw==}
- peerDependencies:
- '@emotion/core': ^10.0.27
- react: '>=16.3.0'
- dependencies:
- '@babel/runtime': 7.15.3
- '@emotion/core': 10.1.1
- '@emotion/weak-memoize': 0.2.5
- hoist-non-react-statics: 3.3.2
- dev: true
-
- /encodeurl/1.0.2:
- resolution: {integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=}
+ /encodeurl@1.0.2:
+ resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
dev: true
- /encoding/0.1.13:
+ /encoding@0.1.13:
resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
dependencies:
- iconv-lite: 0.6.2
+ iconv-lite: 0.6.3
dev: true
- /end-of-stream/1.4.4:
+ /end-of-stream@1.4.4:
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
+ requiresBuild: true
dependencies:
once: 1.4.0
- dev: true
- /enhanced-resolve/4.5.0:
+ /enhanced-resolve@4.5.0:
resolution: {integrity: sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==}
engines: {node: '>=6.9.0'}
dependencies:
- graceful-fs: 4.2.8
+ graceful-fs: 4.2.11
memory-fs: 0.5.0
tapable: 1.1.3
dev: true
- /enhanced-resolve/5.8.2:
- resolution: {integrity: sha512-F27oB3WuHDzvR2DOGNTaYy0D5o0cnrv8TeI482VM4kYgQd/FT9lUQwuNsJ0oOHtBUq7eiW5ytqzp7nBFknL+GA==}
+ /enhanced-resolve@5.10.0:
+ resolution: {integrity: sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==}
engines: {node: '>=10.13.0'}
dependencies:
- graceful-fs: 4.2.8
- tapable: 2.2.0
+ graceful-fs: 4.2.11
+ tapable: 2.2.1
dev: true
- /enquirer/2.3.6:
+ /enquirer@2.3.6:
resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==}
engines: {node: '>=8.6'}
dependencies:
- ansi-colors: 4.1.1
+ ansi-colors: 4.1.3
dev: true
- /entities/2.2.0:
+ /entities@2.2.0:
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
dev: true
- /envinfo/7.8.1:
- resolution: {integrity: sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==}
- engines: {node: '>=4'}
- hasBin: true
- dev: true
-
- /enzyme-adapter-preact-pure/3.1.0_enzyme@3.11.0+preact@10.5.14:
- resolution: {integrity: sha512-IyhHVOe4TtgnQX/iF1PGvhNFXZCBJ8amRQ30Atb6actyelMh4tQAXEjj06FaO5c+frChgDw3YxtLxGEs1Mhv2A==}
- peerDependencies:
- enzyme: ^3.8.0
- preact: ^10.0.0
- dependencies:
- array.prototype.flatmap: 1.2.4
- enzyme: 3.11.0
- preact: 10.5.14
- dev: true
-
- /enzyme-shallow-equal/1.0.4:
- resolution: {integrity: sha512-MttIwB8kKxypwHvRynuC3ahyNc+cFbR8mjVIltnmzQ0uKGqmsfO4bfBuLxb0beLNPhjblUEYvEbsg+VSygvF1Q==}
- dependencies:
- has: 1.0.3
- object-is: 1.1.5
+ /entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
dev: true
- /enzyme/3.11.0:
- resolution: {integrity: sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==}
- dependencies:
- array.prototype.flat: 1.2.4
- cheerio: 1.0.0-rc.10
- enzyme-shallow-equal: 1.0.4
- function.prototype.name: 1.1.4
- has: 1.0.3
- html-element-map: 1.3.1
- is-boolean-object: 1.1.2
- is-callable: 1.2.4
- is-number-object: 1.0.6
- is-regex: 1.1.4
- is-string: 1.0.7
- is-subset: 0.1.1
- lodash.escape: 4.0.1
- lodash.isequal: 4.5.0
- object-inspect: 1.11.0
- object-is: 1.1.5
- object.assign: 4.1.2
- object.entries: 1.1.4
- object.values: 1.1.4
- raf: 3.4.1
- rst-selector-parser: 2.2.3
- string.prototype.trim: 1.2.4
- dev: true
-
- /equal-length/1.0.1:
- resolution: {integrity: sha1-IcoRLUirJLTh5//A5TOdMf38J0w=}
+ /envinfo@7.8.1:
+ resolution: {integrity: sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==}
engines: {node: '>=4'}
dev: true
- /errno/0.1.8:
+ /errno@0.1.8:
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
hasBin: true
dependencies:
prr: 1.0.1
dev: true
- /error-ex/1.3.2:
+ /error-ex@1.3.2:
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
dependencies:
is-arrayish: 0.2.1
dev: true
- /es-abstract/1.17.7:
- resolution: {integrity: sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g==}
+ /es-abstract@1.22.3:
+ resolution: {integrity: sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==}
engines: {node: '>= 0.4'}
dependencies:
+ array-buffer-byte-length: 1.0.0
+ arraybuffer.prototype.slice: 1.0.2
+ available-typed-arrays: 1.0.5
+ call-bind: 1.0.5
+ es-set-tostringtag: 2.0.2
es-to-primitive: 1.2.1
- function-bind: 1.1.1
- has: 1.0.3
- has-symbols: 1.0.1
- is-callable: 1.2.2
- is-regex: 1.1.1
- object-inspect: 1.9.0
+ function.prototype.name: 1.1.6
+ get-intrinsic: 1.2.2
+ get-symbol-description: 1.0.0
+ globalthis: 1.0.3
+ gopd: 1.0.1
+ has-property-descriptors: 1.0.1
+ has-proto: 1.0.1
+ has-symbols: 1.0.3
+ hasown: 2.0.0
+ internal-slot: 1.0.6
+ is-array-buffer: 3.0.2
+ is-callable: 1.2.7
+ is-negative-zero: 2.0.2
+ is-regex: 1.1.4
+ is-shared-array-buffer: 1.0.2
+ is-string: 1.0.7
+ is-typed-array: 1.1.12
+ is-weakref: 1.0.2
+ object-inspect: 1.13.1
object-keys: 1.1.1
- object.assign: 4.1.2
- string.prototype.trimend: 1.0.3
- string.prototype.trimstart: 1.0.3
+ object.assign: 4.1.5
+ regexp.prototype.flags: 1.5.1
+ safe-array-concat: 1.0.1
+ safe-regex-test: 1.0.0
+ string.prototype.trim: 1.2.8
+ string.prototype.trimend: 1.0.7
+ string.prototype.trimstart: 1.0.7
+ typed-array-buffer: 1.0.0
+ typed-array-byte-length: 1.0.0
+ typed-array-byte-offset: 1.0.0
+ typed-array-length: 1.0.4
+ unbox-primitive: 1.0.2
+ which-typed-array: 1.1.13
+ dev: true
+
+ /es-array-method-boxes-properly@1.0.0:
+ resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==}
dev: true
- /es-abstract/1.18.0-next.2:
- resolution: {integrity: sha512-Ih4ZMFHEtZupnUh6497zEL4y2+w8+1ljnCyaTa+adcoafI1GOvMwFlDjBLfWR7y9VLfrjRJe9ocuHY1PSR9jjw==}
- engines: {node: '>= 0.4'}
+ /es-iterator-helpers@1.0.15:
+ resolution: {integrity: sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==}
dependencies:
- call-bind: 1.0.2
- es-to-primitive: 1.2.1
- function-bind: 1.1.1
- get-intrinsic: 1.0.2
- has: 1.0.3
- has-symbols: 1.0.1
- is-callable: 1.2.2
- is-negative-zero: 2.0.1
- is-regex: 1.1.1
- object-inspect: 1.9.0
- object-keys: 1.1.1
- object.assign: 4.1.2
- string.prototype.trimend: 1.0.3
- string.prototype.trimstart: 1.0.3
+ asynciterator.prototype: 1.0.0
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ es-set-tostringtag: 2.0.2
+ function-bind: 1.1.2
+ get-intrinsic: 1.2.2
+ globalthis: 1.0.3
+ has-property-descriptors: 1.0.1
+ has-proto: 1.0.1
+ has-symbols: 1.0.3
+ internal-slot: 1.0.6
+ iterator.prototype: 1.1.2
+ safe-array-concat: 1.0.1
dev: true
- /es-abstract/1.18.5:
- resolution: {integrity: sha512-DDggyJLoS91CkJjgauM5c0yZMjiD1uK3KcaCeAmffGwZ+ODWzOkPN4QwRbsK5DOFf06fywmyLci3ZD8jLGhVYA==}
+ /es-set-tostringtag@2.0.2:
+ resolution: {integrity: sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- es-to-primitive: 1.2.1
- function-bind: 1.1.1
- get-intrinsic: 1.1.1
- has: 1.0.3
- has-symbols: 1.0.2
- internal-slot: 1.0.3
- is-callable: 1.2.4
- is-negative-zero: 2.0.1
- is-regex: 1.1.4
- is-string: 1.0.7
- object-inspect: 1.11.0
- object-keys: 1.1.1
- object.assign: 4.1.2
- string.prototype.trimend: 1.0.4
- string.prototype.trimstart: 1.0.4
- unbox-primitive: 1.0.1
- dev: true
-
- /es-array-method-boxes-properly/1.0.0:
- resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==}
+ get-intrinsic: 1.2.2
+ has-tostringtag: 1.0.0
+ hasown: 2.0.0
dev: true
- /es-get-iterator/1.1.2:
- resolution: {integrity: sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ==}
+ /es-shim-unscopables@1.0.2:
+ resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==}
dependencies:
- call-bind: 1.0.2
- get-intrinsic: 1.1.1
- has-symbols: 1.0.2
- is-arguments: 1.1.1
- is-map: 2.0.2
- is-set: 2.0.2
- is-string: 1.0.7
- isarray: 2.0.5
+ hasown: 2.0.0
dev: true
- /es-to-primitive/1.2.1:
+ /es-to-primitive@1.2.1:
resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==}
engines: {node: '>= 0.4'}
dependencies:
- is-callable: 1.2.4
+ is-callable: 1.2.7
is-date-object: 1.0.5
is-symbol: 1.0.4
dev: true
- /es5-shim/4.5.15:
- resolution: {integrity: sha512-FYpuxEjMeDvU4rulKqFdukQyZSTpzhg4ScQHrAosrlVpR6GFyaw14f74yn2+4BugniIS0Frpg7TvwZocU4ZMTw==}
- engines: {node: '>=0.4.0'}
- dev: true
-
- /es6-error/4.1.1:
+ /es6-error@4.1.1:
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
dev: true
- /es6-shim/0.35.6:
- resolution: {integrity: sha512-EmTr31wppcaIAgblChZiuN/l9Y7DPyw8Xtbg7fIVngn6zMW+IEBJDJngeKC3x6wr0V/vcA2wqeFnaw1bFJbDdA==}
+ /es6-promisify@7.0.0:
+ resolution: {integrity: sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==}
+ engines: {node: '>=6'}
dev: true
- /esbuild/0.12.21:
- resolution: {integrity: sha512-7hyXbU3g94aREufI/5nls7Xcc+RGQeZWZApm6hoBaFvt2BPtpT4TjFMQ9Tb1jU8XyBGz00ShmiyflCogphMHFQ==}
+ /esbuild@0.12.29:
+ resolution: {integrity: sha512-w/XuoBCSwepyiZtIRsKsetiLDUVGPVw1E/R3VTFSecIy8UR7Cq3SOtwKHJMFoVqqVG36aGkzh4e8BvpO1Fdc7g==}
hasBin: true
requiresBuild: true
dev: true
- /esbuild/0.9.2:
- resolution: {integrity: sha512-xE3oOILjnmN8PSjkG3lT9NBbd1DbxNqolJ5qNyrLhDWsFef3yTp/KTQz1C/x7BYFKbtrr9foYtKA6KA1zuNAUQ==}
+ /esbuild@0.19.9:
+ resolution: {integrity: sha512-U9CHtKSy+EpPsEBa+/A2gMs/h3ylBC0H0KSqIg7tpztHerLi6nrrcoUJAkNCEPumx8yJ+Byic4BVwHgRbN0TBg==}
+ engines: {node: '>=12'}
hasBin: true
requiresBuild: true
- dev: true
-
- /escalade/3.1.1:
+ optionalDependencies:
+ '@esbuild/android-arm': 0.19.9
+ '@esbuild/android-arm64': 0.19.9
+ '@esbuild/android-x64': 0.19.9
+ '@esbuild/darwin-arm64': 0.19.9
+ '@esbuild/darwin-x64': 0.19.9
+ '@esbuild/freebsd-arm64': 0.19.9
+ '@esbuild/freebsd-x64': 0.19.9
+ '@esbuild/linux-arm': 0.19.9
+ '@esbuild/linux-arm64': 0.19.9
+ '@esbuild/linux-ia32': 0.19.9
+ '@esbuild/linux-loong64': 0.19.9
+ '@esbuild/linux-mips64el': 0.19.9
+ '@esbuild/linux-ppc64': 0.19.9
+ '@esbuild/linux-riscv64': 0.19.9
+ '@esbuild/linux-s390x': 0.19.9
+ '@esbuild/linux-x64': 0.19.9
+ '@esbuild/netbsd-x64': 0.19.9
+ '@esbuild/openbsd-x64': 0.19.9
+ '@esbuild/sunos-x64': 0.19.9
+ '@esbuild/win32-arm64': 0.19.9
+ '@esbuild/win32-ia32': 0.19.9
+ '@esbuild/win32-x64': 0.19.9
+ dev: true
+
+ /escalade@3.1.1:
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
engines: {node: '>=6'}
- dev: true
- /escape-goat/2.1.1:
+ /escape-goat@2.1.1:
resolution: {integrity: sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==}
engines: {node: '>=8'}
dev: true
- /escape-html/1.0.3:
- resolution: {integrity: sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=}
+ /escape-goat@4.0.0:
+ resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==}
+ engines: {node: '>=12'}
dev: true
- /escape-string-regexp/1.0.5:
- resolution: {integrity: sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=}
- engines: {node: '>=0.8.0'}
+ /escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
dev: true
- /escape-string-regexp/2.0.0:
+ /escape-string-regexp@1.0.5:
+ resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
+ engines: {node: '>=0.8.0'}
+
+ /escape-string-regexp@2.0.0:
resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
engines: {node: '>=8'}
dev: true
- /escape-string-regexp/4.0.0:
+ /escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
dev: true
- /escodegen/1.14.3:
+ /escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /escodegen@1.14.3:
resolution: {integrity: sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==}
engines: {node: '>=4.0'}
- hasBin: true
dependencies:
esprima: 4.0.1
estraverse: 4.3.0
@@ -11739,234 +10285,314 @@ packages:
source-map: 0.6.1
dev: true
- /escodegen/2.0.0:
- resolution: {integrity: sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==}
- engines: {node: '>=6.0'}
- hasBin: true
+ /eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.29.1)(eslint@8.26.0):
+ resolution: {integrity: sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==}
+ engines: {node: ^10.12.0 || >=12.0.0}
+ peerDependencies:
+ eslint: ^7.32.0 || ^8.2.0
+ eslint-plugin-import: ^2.25.2
dependencies:
- esprima: 4.0.1
- estraverse: 5.2.0
- esutils: 2.0.3
- optionator: 0.8.3
- optionalDependencies:
- source-map: 0.6.1
+ confusing-browser-globals: 1.0.11
+ eslint: 8.26.0
+ eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.41.0)(eslint@8.26.0)
+ object.assign: 4.1.5
+ object.entries: 1.1.7
+ semver: 6.3.1
dev: true
- /eslint-config-airbnb-base/14.2.0_d4477e7d44043beb7952cd76bd313965:
- resolution: {integrity: sha512-Snswd5oC6nJaevs3nZoLSTvGJBvzTfnBqOIArkf3cbyTyq9UD79wOk8s+RiL6bhca0p/eRO6veczhf6A/7Jy8Q==}
- engines: {node: '>= 6'}
+ /eslint-config-airbnb-typescript@17.1.0(@typescript-eslint/eslint-plugin@5.41.0)(@typescript-eslint/parser@5.41.0)(eslint-plugin-import@2.29.1)(eslint@8.26.0):
+ resolution: {integrity: sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==}
peerDependencies:
- eslint: ^5.16.0 || ^6.8.0 || ^7.2.0
- eslint-plugin-import: ^2.21.2
+ '@typescript-eslint/eslint-plugin': ^5.13.0 || ^6.0.0
+ '@typescript-eslint/parser': ^5.0.0 || ^6.0.0
+ eslint: ^7.32.0 || ^8.2.0
+ eslint-plugin-import: ^2.25.3
dependencies:
- confusing-browser-globals: 1.0.10
- eslint: 7.18.0
- eslint-plugin-import: 2.22.1_eslint@7.18.0
- object.assign: 4.1.2
- object.entries: 1.1.3
+ '@typescript-eslint/eslint-plugin': 5.41.0(@typescript-eslint/parser@5.41.0)(eslint@8.26.0)(typescript@5.3.3)
+ '@typescript-eslint/parser': 5.41.0(eslint@8.26.0)(typescript@5.3.3)
+ eslint: 8.26.0
+ eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1)(eslint@8.26.0)
+ eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.41.0)(eslint@8.26.0)
dev: true
- /eslint-config-airbnb-typescript/12.0.0_aa91c0ea1e61103ae60b9cd49dfd9775:
- resolution: {integrity: sha512-TUCVru1Z09eKnVAX5i3XoNzjcCOU3nDQz2/jQGkg1jVYm+25fKClveziSl16celfCq+npU0MBPW/ZnXdGFZ9lw==}
+ /eslint-config-preact@1.3.0(@typescript-eslint/eslint-plugin@4.33.0)(eslint@7.32.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-yHYXg5qNzEJd3D/30AmsIW0W8MuY858KpApXp7xxBF08IYUljSKCOqMx+dVucXHQnAm7+11wOnMkgVHIBAechw==}
peerDependencies:
- '@typescript-eslint/eslint-plugin': ^4.4.1
+ eslint: 6.x || 7.x || 8.x
dependencies:
- '@typescript-eslint/eslint-plugin': 4.14.0_980e7d90d2d08155204a38366bd3b934
- '@typescript-eslint/parser': 4.4.1_eslint@7.18.0+typescript@4.1.3
- eslint-config-airbnb: 18.2.0_8b932c4aedefa0fbb298d8c6e2d8003e
- eslint-config-airbnb-base: 14.2.0_d4477e7d44043beb7952cd76bd313965
+ '@babel/core': 7.18.9
+ '@babel/eslint-parser': 7.19.1(@babel/core@7.18.9)(eslint@7.32.0)
+ '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.18.9)
+ '@babel/plugin-syntax-decorators': 7.19.0(@babel/core@7.18.9)
+ '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.18.9)
+ eslint: 7.32.0
+ eslint-plugin-compat: 4.0.2(eslint@7.32.0)
+ eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@4.33.0)(eslint@7.32.0)(typescript@5.3.3)
+ eslint-plugin-react: 7.33.2(eslint@7.32.0)
+ eslint-plugin-react-hooks: 4.6.0(eslint@7.32.0)
transitivePeerDependencies:
- - eslint
- - eslint-plugin-import
- - eslint-plugin-jsx-a11y
- - eslint-plugin-react
- - eslint-plugin-react-hooks
+ - '@typescript-eslint/eslint-plugin'
+ - jest
- supports-color
- typescript
dev: true
- /eslint-config-airbnb/18.2.0_8b932c4aedefa0fbb298d8c6e2d8003e:
- resolution: {integrity: sha512-Fz4JIUKkrhO0du2cg5opdyPKQXOI2MvF8KUvN2710nJMT6jaRUpRE2swrJftAjVGL7T1otLM5ieo5RqS1v9Udg==}
- engines: {node: '>= 6'}
+ /eslint-config-prettier@9.1.0(eslint@8.56.0):
+ resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==}
+ hasBin: true
peerDependencies:
- eslint: ^5.16.0 || ^6.8.0 || ^7.2.0
- eslint-plugin-import: ^2.21.2
- eslint-plugin-jsx-a11y: ^6.3.0
- eslint-plugin-react: ^7.20.0
- eslint-plugin-react-hooks: ^4 || ^3 || ^2.3.0 || ^1.7.0
- dependencies:
- eslint: 7.18.0
- eslint-config-airbnb-base: 14.2.0_d4477e7d44043beb7952cd76bd313965
- eslint-plugin-import: 2.22.1_eslint@7.18.0
- eslint-plugin-jsx-a11y: 6.4.1_eslint@7.18.0
- eslint-plugin-react: 7.22.0_eslint@7.18.0
- eslint-plugin-react-hooks: 4.2.0_eslint@7.18.0
- object.assign: 4.1.2
- object.entries: 1.1.3
- dev: true
-
- /eslint-config-preact/1.1.4_eslint@6.8.0+typescript@3.9.10:
- resolution: {integrity: sha512-j00/BpjPpVoaX8UTpXFPAsfBIzuwJX+sBvgPFyb53Lqi31fM0Oiq516qYXRyaZ7q1BRCjO8s67NCLal6v/Z8Lg==}
- peerDependencies:
- eslint: 6.x || 7.x
- dependencies:
- babel-eslint: 10.1.0_eslint@6.8.0
- eslint: 6.8.0
- eslint-plugin-compat: 3.13.0_eslint@6.8.0
- eslint-plugin-jest: 23.20.0_eslint@6.8.0+typescript@3.9.10
- eslint-plugin-react: 7.22.0_eslint@6.8.0
- eslint-plugin-react-hooks: 4.2.0_eslint@6.8.0
- transitivePeerDependencies:
- - supports-color
- - typescript
+ eslint: '>=7.0.0'
+ dependencies:
+ eslint: 8.56.0
dev: true
- /eslint-import-resolver-node/0.3.4:
- resolution: {integrity: sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==}
+ /eslint-import-resolver-node@0.3.9:
+ resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==}
dependencies:
- debug: 2.6.9
- resolve: 1.19.0
+ debug: 3.2.7
+ is-core-module: 2.13.1
+ resolve: 1.22.8
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /eslint-module-utils/2.6.0:
- resolution: {integrity: sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==}
+ /eslint-module-utils@2.8.0(@typescript-eslint/parser@5.41.0)(eslint-import-resolver-node@0.3.9)(eslint@8.26.0):
+ resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==}
engines: {node: '>=4'}
+ peerDependencies:
+ '@typescript-eslint/parser': '*'
+ eslint: '*'
+ eslint-import-resolver-node: '*'
+ eslint-import-resolver-typescript: '*'
+ eslint-import-resolver-webpack: '*'
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
+ eslint:
+ optional: true
+ eslint-import-resolver-node:
+ optional: true
+ eslint-import-resolver-typescript:
+ optional: true
+ eslint-import-resolver-webpack:
+ optional: true
dependencies:
- debug: 2.6.9
- pkg-dir: 2.0.0
+ '@typescript-eslint/parser': 5.41.0(eslint@8.26.0)(typescript@5.3.3)
+ debug: 3.2.7
+ eslint: 8.26.0
+ eslint-import-resolver-node: 0.3.9
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /eslint-plugin-compat/3.13.0_eslint@6.8.0:
- resolution: {integrity: sha512-cv8IYMuTXm7PIjMVDN2y4k/KVnKZmoNGHNq27/9dLstOLydKblieIv+oe2BN2WthuXnFNhaNvv3N1Bvl4dbIGA==}
+ /eslint-plugin-compat@4.0.2(eslint@7.32.0):
+ resolution: {integrity: sha512-xqvoO54CLTVaEYGMzhu35Wzwk/As7rCvz/2dqwnFiWi0OJccEtGIn+5qq3zqIu9nboXlpdBN579fZcItC73Ycg==}
engines: {node: '>=9.x'}
peerDependencies:
- eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ eslint: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies:
- '@mdn/browser-compat-data': 3.3.14
+ '@mdn/browser-compat-data': 4.2.1
ast-metadata-inferer: 0.7.0
- browserslist: 4.16.8
- caniuse-lite: 1.0.30001251
- core-js: 3.16.2
- eslint: 6.8.0
+ browserslist: 4.22.2
+ caniuse-lite: 1.0.30001570
+ core-js: 3.26.0
+ eslint: 7.32.0
find-up: 5.0.0
lodash.memoize: 4.1.2
semver: 7.3.5
dev: true
- /eslint-plugin-import/2.22.1_eslint@7.18.0:
- resolution: {integrity: sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==}
+ /eslint-plugin-header@3.1.1(eslint@7.32.0):
+ resolution: {integrity: sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==}
+ peerDependencies:
+ eslint: '>=7.7.0'
+ dependencies:
+ eslint: 7.32.0
+ dev: true
+
+ /eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.41.0)(eslint@8.26.0):
+ resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==}
engines: {node: '>=4'}
peerDependencies:
- eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0
+ '@typescript-eslint/parser': '*'
+ eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
+ peerDependenciesMeta:
+ '@typescript-eslint/parser':
+ optional: true
dependencies:
- array-includes: 3.1.2
- array.prototype.flat: 1.2.4
- contains-path: 0.1.0
- debug: 2.6.9
- doctrine: 1.5.0
- eslint: 7.18.0
- eslint-import-resolver-node: 0.3.4
- eslint-module-utils: 2.6.0
- has: 1.0.3
- minimatch: 3.0.4
- object.values: 1.1.2
- read-pkg-up: 2.0.0
- resolve: 1.19.0
- tsconfig-paths: 3.9.0
+ '@typescript-eslint/parser': 5.41.0(eslint@8.26.0)(typescript@5.3.3)
+ array-includes: 3.1.7
+ array.prototype.findlastindex: 1.2.3
+ array.prototype.flat: 1.3.2
+ array.prototype.flatmap: 1.3.2
+ debug: 3.2.7
+ doctrine: 2.1.0
+ eslint: 8.26.0
+ eslint-import-resolver-node: 0.3.9
+ eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.41.0)(eslint-import-resolver-node@0.3.9)(eslint@8.26.0)
+ hasown: 2.0.0
+ is-core-module: 2.13.1
+ is-glob: 4.0.3
+ minimatch: 3.1.2
+ object.fromentries: 2.0.7
+ object.groupby: 1.0.1
+ object.values: 1.1.7
+ semver: 6.3.1
+ tsconfig-paths: 3.15.0
+ transitivePeerDependencies:
+ - eslint-import-resolver-typescript
+ - eslint-import-resolver-webpack
+ - supports-color
dev: true
- /eslint-plugin-jest/23.20.0_eslint@6.8.0+typescript@3.9.10:
- resolution: {integrity: sha512-+6BGQt85OREevBDWCvhqj1yYA4+BFK4XnRZSGJionuEYmcglMZYLNNBBemwzbqUAckURaHdJSBcjHPyrtypZOw==}
- engines: {node: '>=8'}
+ /eslint-plugin-jest@25.7.0(@typescript-eslint/eslint-plugin@4.33.0)(eslint@7.32.0)(typescript@5.3.3):
+ resolution: {integrity: sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==}
+ engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
peerDependencies:
- eslint: '>=5'
+ '@typescript-eslint/eslint-plugin': ^4.0.0 || ^5.0.0
+ eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
+ jest: '*'
+ peerDependenciesMeta:
+ '@typescript-eslint/eslint-plugin':
+ optional: true
+ jest:
+ optional: true
dependencies:
- '@typescript-eslint/experimental-utils': 2.34.0_eslint@6.8.0+typescript@3.9.10
- eslint: 6.8.0
+ '@typescript-eslint/eslint-plugin': 4.33.0(@typescript-eslint/parser@4.33.0)(eslint@7.32.0)(typescript@5.3.3)
+ '@typescript-eslint/experimental-utils': 5.41.0(eslint@7.32.0)(typescript@5.3.3)
+ eslint: 7.32.0
transitivePeerDependencies:
- supports-color
- typescript
dev: true
- /eslint-plugin-jsx-a11y/6.4.1_eslint@7.18.0:
- resolution: {integrity: sha512-0rGPJBbwHoGNPU73/QCLP/vveMlM1b1Z9PponxO87jfr6tuH5ligXbDT6nHSSzBC8ovX2Z+BQu7Bk5D/Xgq9zg==}
+ /eslint-plugin-jsx-a11y@6.8.0(eslint@8.26.0):
+ resolution: {integrity: sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==}
engines: {node: '>=4.0'}
peerDependencies:
- eslint: ^3 || ^4 || ^5 || ^6 || ^7
- dependencies:
- '@babel/runtime': 7.12.5
- aria-query: 4.2.2
- array-includes: 3.1.2
- ast-types-flow: 0.0.7
- axe-core: 4.1.1
- axobject-query: 2.2.0
- damerau-levenshtein: 1.0.6
- emoji-regex: 9.2.0
- eslint: 7.18.0
- has: 1.0.3
- jsx-ast-utils: 3.2.0
- language-tags: 1.0.5
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
+ dependencies:
+ '@babel/runtime': 7.23.6
+ aria-query: 5.3.0
+ array-includes: 3.1.7
+ array.prototype.flatmap: 1.3.2
+ ast-types-flow: 0.0.8
+ axe-core: 4.7.0
+ axobject-query: 3.2.1
+ damerau-levenshtein: 1.0.8
+ emoji-regex: 9.2.2
+ es-iterator-helpers: 1.0.15
+ eslint: 8.26.0
+ hasown: 2.0.0
+ jsx-ast-utils: 3.3.5
+ language-tags: 1.0.9
+ minimatch: 3.1.2
+ object.entries: 1.1.7
+ object.fromentries: 2.0.7
+ dev: true
+
+ /eslint-plugin-no-unsanitized@4.0.2(eslint@8.56.0):
+ resolution: {integrity: sha512-Pry0S9YmHoz8NCEMRQh7N0Yexh2MYCNPIlrV52hTmS7qXnTghWsjXouF08bgsrrZqaW9tt1ZiK3j5NEmPE+EjQ==}
+ peerDependencies:
+ eslint: ^6 || ^7 || ^8
+ dependencies:
+ eslint: 8.56.0
dev: true
- /eslint-plugin-react-hooks/4.2.0_eslint@6.8.0:
- resolution: {integrity: sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ==}
+ /eslint-plugin-react-hooks@4.6.0(eslint@7.32.0):
+ resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==}
engines: {node: '>=10'}
peerDependencies:
- eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
dependencies:
- eslint: 6.8.0
+ eslint: 7.32.0
dev: true
- /eslint-plugin-react-hooks/4.2.0_eslint@7.18.0:
- resolution: {integrity: sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ==}
+ /eslint-plugin-react-hooks@4.6.0(eslint@8.26.0):
+ resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==}
engines: {node: '>=10'}
peerDependencies:
- eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
dependencies:
- eslint: 7.18.0
+ eslint: 8.26.0
dev: true
- /eslint-plugin-react/7.22.0_eslint@6.8.0:
- resolution: {integrity: sha512-p30tuX3VS+NWv9nQot9xIGAHBXR0+xJVaZriEsHoJrASGCJZDJ8JLNM0YqKqI0AKm6Uxaa1VUHoNEibxRCMQHA==}
+ /eslint-plugin-react@7.33.2(eslint@7.32.0):
+ resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==}
engines: {node: '>=4'}
peerDependencies:
- eslint: ^3 || ^4 || ^5 || ^6 || ^7
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
dependencies:
- array-includes: 3.1.2
- array.prototype.flatmap: 1.2.4
+ array-includes: 3.1.7
+ array.prototype.flatmap: 1.3.2
+ array.prototype.tosorted: 1.1.2
doctrine: 2.1.0
- eslint: 6.8.0
- has: 1.0.3
- jsx-ast-utils: 3.2.0
- object.entries: 1.1.3
- object.fromentries: 2.0.3
- object.values: 1.1.2
- prop-types: 15.7.2
- resolve: 1.19.0
- string.prototype.matchall: 4.0.3
- dev: true
-
- /eslint-plugin-react/7.22.0_eslint@7.18.0:
- resolution: {integrity: sha512-p30tuX3VS+NWv9nQot9xIGAHBXR0+xJVaZriEsHoJrASGCJZDJ8JLNM0YqKqI0AKm6Uxaa1VUHoNEibxRCMQHA==}
+ es-iterator-helpers: 1.0.15
+ eslint: 7.32.0
+ estraverse: 5.3.0
+ jsx-ast-utils: 3.3.5
+ minimatch: 3.1.2
+ object.entries: 1.1.7
+ object.fromentries: 2.0.7
+ object.hasown: 1.1.3
+ object.values: 1.1.7
+ prop-types: 15.8.1
+ resolve: 2.0.0-next.5
+ semver: 6.3.1
+ string.prototype.matchall: 4.0.10
+ dev: true
+
+ /eslint-plugin-react@7.33.2(eslint@8.26.0):
+ resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==}
engines: {node: '>=4'}
peerDependencies:
- eslint: ^3 || ^4 || ^5 || ^6 || ^7
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
dependencies:
- array-includes: 3.1.2
- array.prototype.flatmap: 1.2.4
+ array-includes: 3.1.7
+ array.prototype.flatmap: 1.3.2
+ array.prototype.tosorted: 1.1.2
doctrine: 2.1.0
- eslint: 7.18.0
- has: 1.0.3
- jsx-ast-utils: 3.2.0
- object.entries: 1.1.3
- object.fromentries: 2.0.3
- object.values: 1.1.2
- prop-types: 15.7.2
- resolve: 1.19.0
- string.prototype.matchall: 4.0.3
- dev: true
-
- /eslint-scope/4.0.3:
+ es-iterator-helpers: 1.0.15
+ eslint: 8.26.0
+ estraverse: 5.3.0
+ jsx-ast-utils: 3.3.5
+ minimatch: 3.1.2
+ object.entries: 1.1.7
+ object.fromentries: 2.0.7
+ object.hasown: 1.1.3
+ object.values: 1.1.7
+ prop-types: 15.8.1
+ resolve: 2.0.0-next.5
+ semver: 6.3.1
+ string.prototype.matchall: 4.0.10
+ dev: true
+
+ /eslint-plugin-react@7.33.2(eslint@8.56.0):
+ resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==}
+ engines: {node: '>=4'}
+ peerDependencies:
+ eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
+ dependencies:
+ array-includes: 3.1.7
+ array.prototype.flatmap: 1.3.2
+ array.prototype.tosorted: 1.1.2
+ doctrine: 2.1.0
+ es-iterator-helpers: 1.0.15
+ eslint: 8.56.0
+ estraverse: 5.3.0
+ jsx-ast-utils: 3.3.5
+ minimatch: 3.1.2
+ object.entries: 1.1.7
+ object.fromentries: 2.0.7
+ object.hasown: 1.1.3
+ object.values: 1.1.7
+ prop-types: 15.8.1
+ resolve: 2.0.0-next.5
+ semver: 6.3.1
+ string.prototype.matchall: 4.0.10
+ dev: true
+
+ /eslint-scope@4.0.3:
resolution: {integrity: sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==}
engines: {node: '>=4.0.0'}
dependencies:
@@ -11974,7 +10600,7 @@ packages:
estraverse: 4.3.0
dev: true
- /eslint-scope/5.1.1:
+ /eslint-scope@5.1.1:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
engines: {node: '>=8.0.0'}
dependencies:
@@ -11982,234 +10608,320 @@ packages:
estraverse: 4.3.0
dev: true
- /eslint-utils/1.4.3:
- resolution: {integrity: sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==}
- engines: {node: '>=6'}
+ /eslint-scope@7.1.1:
+ resolution: {integrity: sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
- eslint-visitor-keys: 1.3.0
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+ dev: true
+
+ /eslint-scope@7.2.2:
+ resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
dev: true
- /eslint-utils/2.1.0:
+ /eslint-utils@2.1.0:
resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==}
engines: {node: '>=6'}
dependencies:
eslint-visitor-keys: 1.3.0
dev: true
- /eslint-visitor-keys/1.3.0:
+ /eslint-utils@3.0.0(eslint@7.32.0):
+ resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==}
+ engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0}
+ peerDependencies:
+ eslint: '>=5'
+ dependencies:
+ eslint: 7.32.0
+ eslint-visitor-keys: 2.1.0
+ dev: true
+
+ /eslint-utils@3.0.0(eslint@8.26.0):
+ resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==}
+ engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0}
+ peerDependencies:
+ eslint: '>=5'
+ dependencies:
+ eslint: 8.26.0
+ eslint-visitor-keys: 2.1.0
+ dev: true
+
+ /eslint-visitor-keys@1.3.0:
resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==}
engines: {node: '>=4'}
dev: true
- /eslint-visitor-keys/2.0.0:
- resolution: {integrity: sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==}
+ /eslint-visitor-keys@2.1.0:
+ resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==}
engines: {node: '>=10'}
dev: true
- /eslint/6.8.0:
- resolution: {integrity: sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==}
- engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1}
- hasBin: true
+ /eslint-visitor-keys@3.3.0:
+ resolution: {integrity: sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ dev: true
+
+ /eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ dev: true
+
+ /eslint@7.32.0:
+ resolution: {integrity: sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==}
+ engines: {node: ^10.12.0 || >=12.0.0}
dependencies:
- '@babel/code-frame': 7.14.5
+ '@babel/code-frame': 7.12.11
+ '@eslint/eslintrc': 0.4.3
+ '@humanwhocodes/config-array': 0.5.0
ajv: 6.12.6
- chalk: 2.4.2
- cross-spawn: 6.0.5
- debug: 4.3.2
+ chalk: 4.1.2
+ cross-spawn: 7.0.3
+ debug: 4.3.4
doctrine: 3.0.0
+ enquirer: 2.3.6
+ escape-string-regexp: 4.0.0
eslint-scope: 5.1.1
- eslint-utils: 1.4.3
- eslint-visitor-keys: 1.3.0
- espree: 6.2.1
- esquery: 1.3.1
+ eslint-utils: 2.1.0
+ eslint-visitor-keys: 2.1.0
+ espree: 7.3.1
+ esquery: 1.4.0
esutils: 2.0.3
- file-entry-cache: 5.0.1
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 6.0.1
functional-red-black-tree: 1.0.1
glob-parent: 5.1.2
- globals: 12.4.0
+ globals: 13.17.0
ignore: 4.0.6
import-fresh: 3.3.0
imurmurhash: 0.1.4
- inquirer: 7.3.3
- is-glob: 4.0.1
+ is-glob: 4.0.3
js-yaml: 3.14.1
json-stable-stringify-without-jsonify: 1.0.1
- levn: 0.3.0
- lodash: 4.17.21
- minimatch: 3.0.4
- mkdirp: 0.5.5
+ levn: 0.4.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.2
natural-compare: 1.4.0
- optionator: 0.8.3
+ optionator: 0.9.1
progress: 2.0.3
- regexpp: 2.0.1
- semver: 6.3.0
- strip-ansi: 5.2.0
+ regexpp: 3.2.0
+ semver: 7.3.8
+ strip-ansi: 6.0.1
strip-json-comments: 3.1.1
- table: 5.4.6
+ table: 6.8.0
text-table: 0.2.0
- v8-compile-cache: 2.2.0
+ v8-compile-cache: 2.3.0
transitivePeerDependencies:
- supports-color
dev: true
- /eslint/7.18.0:
- resolution: {integrity: sha512-fbgTiE8BfUJZuBeq2Yi7J3RB3WGUQ9PNuNbmgi6jt9Iv8qrkxfy19Ds3OpL1Pm7zg3BtTVhvcUZbIRQ0wmSjAQ==}
- engines: {node: ^10.12.0 || >=12.0.0}
- hasBin: true
+ /eslint@8.26.0:
+ resolution: {integrity: sha512-kzJkpaw1Bfwheq4VXUezFriD1GxszX6dUekM7Z3aC2o4hju+tsR/XyTC3RcoSD7jmy9VkPU3+N6YjVU2e96Oyg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
- '@babel/code-frame': 7.12.11
- '@eslint/eslintrc': 0.3.0
+ '@eslint/eslintrc': 1.3.3
+ '@humanwhocodes/config-array': 0.11.6
+ '@humanwhocodes/module-importer': 1.0.1
+ '@nodelib/fs.walk': 1.2.8
ajv: 6.12.6
- chalk: 4.1.0
+ chalk: 4.1.2
cross-spawn: 7.0.3
- debug: 4.3.1
+ debug: 4.3.4
doctrine: 3.0.0
- enquirer: 2.3.6
- eslint-scope: 5.1.1
- eslint-utils: 2.1.0
- eslint-visitor-keys: 2.0.0
- espree: 7.3.1
- esquery: 1.3.1
+ escape-string-regexp: 4.0.0
+ eslint-scope: 7.1.1
+ eslint-utils: 3.0.0(eslint@8.26.0)
+ eslint-visitor-keys: 3.3.0
+ espree: 9.4.0
+ esquery: 1.4.0
esutils: 2.0.3
- file-entry-cache: 6.0.0
- functional-red-black-tree: 1.0.1
- glob-parent: 5.1.1
- globals: 12.4.0
- ignore: 4.0.6
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 6.0.1
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ globals: 13.17.0
+ grapheme-splitter: 1.0.4
+ ignore: 5.2.0
import-fresh: 3.3.0
imurmurhash: 0.1.4
- is-glob: 4.0.1
- js-yaml: 3.14.1
+ is-glob: 4.0.3
+ is-path-inside: 3.0.3
+ js-sdsl: 4.1.5
+ js-yaml: 4.1.0
json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1
- lodash: 4.17.20
- minimatch: 3.0.4
+ lodash.merge: 4.6.2
+ minimatch: 3.1.2
natural-compare: 1.4.0
optionator: 0.9.1
- progress: 2.0.3
- regexpp: 3.1.0
- semver: 7.3.4
- strip-ansi: 6.0.0
+ regexpp: 3.2.0
+ strip-ansi: 6.0.1
strip-json-comments: 3.1.1
- table: 6.0.7
text-table: 0.2.0
- v8-compile-cache: 2.2.0
transitivePeerDependencies:
- supports-color
dev: true
- /esm/3.2.25:
+ /eslint@8.56.0:
+ resolution: {integrity: sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ hasBin: true
+ dependencies:
+ '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0)
+ '@eslint-community/regexpp': 4.10.0
+ '@eslint/eslintrc': 2.1.4
+ '@eslint/js': 8.56.0
+ '@humanwhocodes/config-array': 0.11.13
+ '@humanwhocodes/module-importer': 1.0.1
+ '@nodelib/fs.walk': 1.2.8
+ '@ungap/structured-clone': 1.2.0
+ ajv: 6.12.6
+ chalk: 4.1.2
+ cross-spawn: 7.0.3
+ debug: 4.3.4
+ doctrine: 3.0.0
+ escape-string-regexp: 4.0.0
+ eslint-scope: 7.2.2
+ eslint-visitor-keys: 3.4.3
+ espree: 9.6.1
+ esquery: 1.5.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 6.0.1
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ globals: 13.24.0
+ graphemer: 1.4.0
+ ignore: 5.3.0
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ is-path-inside: 3.0.3
+ js-yaml: 4.1.0
+ json-stable-stringify-without-jsonify: 1.0.1
+ levn: 0.4.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.2
+ natural-compare: 1.4.0
+ optionator: 0.9.3
+ strip-ansi: 6.0.1
+ text-table: 0.2.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /esm@3.2.25:
resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==}
engines: {node: '>=6'}
dev: true
- /espree/6.2.1:
- resolution: {integrity: sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==}
- engines: {node: '>=6.0.0'}
+ /espree@7.3.1:
+ resolution: {integrity: sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==}
+ engines: {node: ^10.12.0 || >=12.0.0}
dependencies:
acorn: 7.4.1
- acorn-jsx: 5.3.2_acorn@7.4.1
+ acorn-jsx: 5.3.2(acorn@7.4.1)
eslint-visitor-keys: 1.3.0
dev: true
- /espree/7.3.1:
- resolution: {integrity: sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ /espree@9.4.0:
+ resolution: {integrity: sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
- acorn: 7.4.1
- acorn-jsx: 5.3.1_acorn@7.4.1
- eslint-visitor-keys: 1.3.0
+ acorn: 8.8.1
+ acorn-jsx: 5.3.2(acorn@8.8.1)
+ eslint-visitor-keys: 3.3.0
dev: true
- /esprima/4.0.1:
+ /espree@9.6.1:
+ resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ dependencies:
+ acorn: 8.11.2
+ acorn-jsx: 5.3.2(acorn@8.11.2)
+ eslint-visitor-keys: 3.4.3
+ dev: true
+
+ /esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
dev: true
- /esquery/1.3.1:
- resolution: {integrity: sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==}
+ /esquery@1.4.0:
+ resolution: {integrity: sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==}
+ engines: {node: '>=0.10'}
+ dependencies:
+ estraverse: 5.3.0
+ dev: true
+
+ /esquery@1.5.0:
+ resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==}
engines: {node: '>=0.10'}
dependencies:
- estraverse: 5.2.0
+ estraverse: 5.3.0
dev: true
- /esrecurse/4.3.0:
+ /esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
engines: {node: '>=4.0'}
dependencies:
- estraverse: 5.2.0
+ estraverse: 5.3.0
dev: true
- /estraverse/4.3.0:
+ /estraverse@4.3.0:
resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==}
engines: {node: '>=4.0'}
dev: true
- /estraverse/5.2.0:
- resolution: {integrity: sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==}
+ /estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
dev: true
- /estree-walker/1.0.1:
+ /estree-walker@1.0.1:
resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==}
dev: true
- /estree-walker/2.0.2:
+ /estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
dev: true
- /esutils/2.0.3:
+ /esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
dev: true
- /etag/1.8.1:
- resolution: {integrity: sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=}
+ /etag@1.8.1:
+ resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
dev: true
- /eventemitter3/4.0.7:
+ /event-target-shim@5.0.1:
+ resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
dev: true
- /events/3.3.0:
+ /events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
dev: true
- /eventsource/1.1.0:
- resolution: {integrity: sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg==}
- engines: {node: '>=0.12.0'}
- dependencies:
- original: 1.0.2
- dev: true
-
- /evp_bytestokey/1.0.3:
+ /evp_bytestokey@1.0.3:
resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==}
dependencies:
md5.js: 1.3.5
safe-buffer: 5.2.1
dev: true
- /exec-sh/0.3.6:
- resolution: {integrity: sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w==}
- dev: true
-
- /execa/1.0.0:
- resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
- engines: {node: '>=6'}
- dependencies:
- cross-spawn: 6.0.5
- get-stream: 4.1.0
- is-stream: 1.1.0
- npm-run-path: 2.0.2
- p-finally: 1.0.0
- signal-exit: 3.0.3
- strip-eof: 1.0.0
- dev: true
-
- /execa/4.1.0:
+ /execa@4.1.0:
resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==}
engines: {node: '>=10'}
dependencies:
@@ -12220,17 +10932,42 @@ packages:
merge-stream: 2.0.0
npm-run-path: 4.0.1
onetime: 5.1.2
- signal-exit: 3.0.3
+ signal-exit: 3.0.7
strip-final-newline: 2.0.0
dev: true
- /exit/0.1.2:
- resolution: {integrity: sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=}
- engines: {node: '>= 0.8.0'}
+ /execa@5.1.1:
+ resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
+ engines: {node: '>=10'}
+ dependencies:
+ cross-spawn: 7.0.3
+ get-stream: 6.0.1
+ human-signals: 2.1.0
+ is-stream: 2.0.1
+ merge-stream: 2.0.0
+ npm-run-path: 4.0.1
+ onetime: 5.1.2
+ signal-exit: 3.0.7
+ strip-final-newline: 2.0.0
+ dev: true
+
+ /execa@7.2.0:
+ resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==}
+ engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0}
+ dependencies:
+ cross-spawn: 7.0.3
+ get-stream: 6.0.1
+ human-signals: 4.3.1
+ is-stream: 3.0.0
+ merge-stream: 2.0.0
+ npm-run-path: 5.1.0
+ onetime: 6.0.0
+ signal-exit: 3.0.7
+ strip-final-newline: 3.0.0
dev: true
- /expand-brackets/2.1.4:
- resolution: {integrity: sha1-t3c14xXOMPa27/D4OwQVGiJEliI=}
+ /expand-brackets@2.1.4:
+ resolution: {integrity: sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==}
engines: {node: '>=0.10.0'}
dependencies:
debug: 2.6.9
@@ -12240,85 +10977,76 @@ packages:
regex-not: 1.0.2
snapdragon: 0.8.2
to-regex: 3.0.2
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /expect/26.6.2:
- resolution: {integrity: sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@jest/types': 26.6.2
- ansi-styles: 4.3.0
- jest-get-type: 26.3.0
- jest-matcher-utils: 26.6.2
- jest-message-util: 26.6.2
- jest-regex-util: 26.0.0
- dev: true
+ /expand-template@2.0.3:
+ resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
+ engines: {node: '>=6'}
+ requiresBuild: true
+ dev: false
+ optional: true
- /express/4.17.1:
- resolution: {integrity: sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==}
+ /express@4.18.2:
+ resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
engines: {node: '>= 0.10.0'}
dependencies:
- accepts: 1.3.7
+ accepts: 1.3.8
array-flatten: 1.1.1
- body-parser: 1.19.0
- content-disposition: 0.5.3
+ body-parser: 1.20.1
+ content-disposition: 0.5.4
content-type: 1.0.4
- cookie: 0.4.0
+ cookie: 0.5.0
cookie-signature: 1.0.6
debug: 2.6.9
- depd: 1.1.2
+ depd: 2.0.0
encodeurl: 1.0.2
escape-html: 1.0.3
etag: 1.8.1
- finalhandler: 1.1.2
+ finalhandler: 1.2.0
fresh: 0.5.2
+ http-errors: 2.0.0
merge-descriptors: 1.0.1
methods: 1.1.2
- on-finished: 2.3.0
+ on-finished: 2.4.1
parseurl: 1.3.3
path-to-regexp: 0.1.7
proxy-addr: 2.0.7
- qs: 6.7.0
+ qs: 6.11.0
range-parser: 1.2.1
- safe-buffer: 5.1.2
- send: 0.17.1
- serve-static: 1.14.1
- setprototypeof: 1.1.1
- statuses: 1.5.0
+ safe-buffer: 5.2.1
+ send: 0.18.0
+ serve-static: 1.15.0
+ setprototypeof: 1.2.0
+ statuses: 2.0.1
type-is: 1.6.18
utils-merge: 1.0.1
vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /extend-shallow/2.0.1:
- resolution: {integrity: sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=}
+ /extend-shallow@2.0.1:
+ resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
engines: {node: '>=0.10.0'}
dependencies:
is-extendable: 0.1.1
dev: true
- /extend-shallow/3.0.2:
- resolution: {integrity: sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=}
+ /extend-shallow@3.0.2:
+ resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==}
engines: {node: '>=0.10.0'}
dependencies:
assign-symbols: 1.0.0
is-extendable: 1.0.1
dev: true
- /extend/3.0.2:
+ /extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
dev: true
- /external-editor/3.1.0:
- resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
- engines: {node: '>=4'}
- dependencies:
- chardet: 0.7.0
- iconv-lite: 0.4.24
- tmp: 0.0.33
- dev: true
-
- /extglob/2.0.4:
+ /extglob@2.0.4:
resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -12330,175 +11058,143 @@ packages:
regex-not: 1.0.2
snapdragon: 0.8.2
to-regex: 3.0.2
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /extsprintf/1.3.0:
- resolution: {integrity: sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=}
+ /extsprintf@1.3.0:
+ resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==}
engines: {'0': node >=0.6.0}
dev: true
- /fast-async/6.3.8:
- resolution: {integrity: sha512-TjlooyqrYm/gOXjD2UHNwfrWkvTbzU105Nk4bvcRTeRoL+wIeK6rqbqDg3CN9z5p37cE2iXhP6SxQFz8OVIaUg==}
- dependencies:
- nodent-compiler: 3.2.13
- nodent-runtime: 3.2.1
- dev: true
-
- /fast-deep-equal/3.1.3:
+ /fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
dev: true
- /fast-diff/1.2.0:
- resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==}
+ /fast-diff@1.3.0:
+ resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
dev: true
- /fast-glob/2.2.7:
- resolution: {integrity: sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==}
- engines: {node: '>=4.0.0'}
+ /fast-glob@3.3.1:
+ resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
+ engines: {node: '>=8.6.0'}
dependencies:
- '@mrmlnc/readdir-enhanced': 2.2.1
- '@nodelib/fs.stat': 1.1.3
- glob-parent: 3.1.0
- is-glob: 4.0.1
- merge2: 1.4.1
- micromatch: 3.1.10
- dev: true
-
- /fast-glob/3.2.5:
- resolution: {integrity: sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==}
- engines: {node: '>=8'}
- dependencies:
- '@nodelib/fs.stat': 2.0.4
- '@nodelib/fs.walk': 1.2.6
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
- micromatch: 4.0.2
- picomatch: 2.2.2
- dev: true
+ micromatch: 4.0.5
- /fast-glob/3.2.7:
- resolution: {integrity: sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==}
- engines: {node: '>=8'}
+ /fast-glob@3.3.2:
+ resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
+ engines: {node: '>=8.6.0'}
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
- micromatch: 4.0.4
+ micromatch: 4.0.5
dev: true
- /fast-json-stable-stringify/2.1.0:
- resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+ /fast-json-patch@3.1.1:
+ resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==}
dev: true
- /fast-levenshtein/2.0.6:
- resolution: {integrity: sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=}
+ /fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
dev: true
- /fastq/1.11.0:
- resolution: {integrity: sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==}
- dependencies:
- reusify: 1.0.4
+ /fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
dev: true
- /fastq/1.12.0:
- resolution: {integrity: sha512-VNX0QkHK3RsXVKr9KrlUv/FoTa0NdbYoHHl7uXHv2rzyHSlxjdNAKug2twd9luJxpcyNeAgf5iPPMutJO67Dfg==}
- dependencies:
- reusify: 1.0.4
+ /fast-redact@3.3.0:
+ resolution: {integrity: sha512-6T5V1QK1u4oF+ATxs1lWUmlEk6P2T9HqJG3e2DnHOdVgZy2rFJBoEnrIedcTXlkAHU/zKC+7KETJ+KGGKwxgMQ==}
+ engines: {node: '>=6'}
dev: true
- /fault/1.0.4:
- resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==}
+ /fastq@1.13.0:
+ resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==}
dependencies:
- format: 0.2.2
- dev: true
+ reusify: 1.0.4
- /faye-websocket/0.11.4:
+ /faye-websocket@0.11.4:
resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==}
engines: {node: '>=0.8.0'}
dependencies:
websocket-driver: 0.7.4
dev: true
- /fb-watchman/2.0.1:
- resolution: {integrity: sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==}
+ /fd-slicer@1.1.0:
+ resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
dependencies:
- bser: 2.1.1
+ pend: 1.2.0
dev: true
- /fetch-blob/3.1.2:
- resolution: {integrity: sha512-hunJbvy/6OLjCD0uuhLdp0mMPzP/yd2ssd1t2FCJsaA7wkWhpbp9xfuNVpv7Ll4jFhzp6T4LAupSiV9uOeg0VQ==}
+ /fetch-blob@3.2.0:
+ resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
dependencies:
- web-streams-polyfill: 3.1.1
- dev: false
-
- /fetch-ponyfill/7.1.0:
- resolution: {integrity: sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==}
- dependencies:
- node-fetch: 2.6.1
- dev: false
+ node-domexception: 1.0.0
+ web-streams-polyfill: 3.3.3
+ dev: true
- /fflate/0.6.0:
- resolution: {integrity: sha512-u4AdW/Xx7iinDhYQuS0B0vvbUX7JWXO07jEvYUlbNZvtoiDLkDvHR17LSwxhbawjZVDXczzLHAQUDSllISm4/A==}
+ /fflate@0.8.1:
+ resolution: {integrity: sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==}
dev: false
- /figgy-pudding/3.5.2:
+ /figgy-pudding@3.5.2:
resolution: {integrity: sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==}
dev: true
- /figures/3.2.0:
- resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
- engines: {node: '>=8'}
+ /figures@6.0.1:
+ resolution: {integrity: sha512-0oY/olScYD4IhQ8u//gCPA4F3mlTn2dacYmiDm/mbDQvpmLjV4uH+zhsQ5IyXRyvqkvtUkXkNdGvg5OFJTCsuQ==}
+ engines: {node: '>=18'}
dependencies:
- escape-string-regexp: 1.0.5
+ is-unicode-supported: 2.0.0
dev: true
- /file-entry-cache/5.0.1:
- resolution: {integrity: sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==}
- engines: {node: '>=4'}
+ /file-entry-cache@6.0.1:
+ resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
+ engines: {node: ^10.12.0 || >=12.0.0}
dependencies:
- flat-cache: 2.0.1
+ flat-cache: 3.0.4
dev: true
- /file-entry-cache/6.0.0:
- resolution: {integrity: sha512-fqoO76jZ3ZnYrXLDRxBR1YvOvc0k844kcOg40bgsPrE25LAb/PDqTY+ho64Xh2c8ZXgIKldchCFHczG2UVRcWA==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ /file-loader@1.1.11(webpack@4.47.0):
+ resolution: {integrity: sha512-TGR4HU7HUsGg6GCOPJnFk06RhWgEWFLAGWiT6rcD+GRC2keU3s9RGJ+b3Z6/U73jwwNb2gKLJ7YCrp+jvU4ALg==}
+ engines: {node: '>= 4.3 < 5.0.0 || >= 5.10'}
+ peerDependencies:
+ webpack: ^2.0.0 || ^3.0.0 || ^4.0.0
dependencies:
- flat-cache: 3.0.4
+ loader-utils: 1.4.0
+ schema-utils: 0.4.7
+ webpack: 4.47.0
dev: true
- /file-loader/6.2.0_webpack@4.46.0:
+ /file-loader@6.2.0(webpack@4.46.0):
resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==}
engines: {node: '>= 10.13.0'}
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
dependencies:
- loader-utils: 2.0.0
+ loader-utils: 2.0.3
schema-utils: 3.1.1
webpack: 4.46.0
dev: true
- /file-system-cache/1.0.5:
- resolution: {integrity: sha1-hCWbNqK7uNPW6xAh0xMv/mTP/08=}
- dependencies:
- bluebird: 3.7.2
- fs-extra: 0.30.0
- ramda: 0.21.0
- dev: true
-
- /file-uri-to-path/1.0.0:
+ /file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
- dev: true
- optional: true
+ requiresBuild: true
- /filesize/6.1.0:
- resolution: {integrity: sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg==}
- engines: {node: '>= 0.4.0'}
+ /filelist@1.0.4:
+ resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
+ dependencies:
+ minimatch: 5.1.6
dev: true
- /fill-range/4.0.0:
- resolution: {integrity: sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=}
+ /fill-range@4.0.0:
+ resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==}
engines: {node: '>=0.10.0'}
dependencies:
extend-shallow: 2.0.1
@@ -12507,27 +11203,28 @@ packages:
to-regex-range: 2.1.1
dev: true
- /fill-range/7.0.1:
+ /fill-range@7.0.1:
resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
engines: {node: '>=8'}
dependencies:
to-regex-range: 5.0.1
- dev: true
- /finalhandler/1.1.2:
- resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
+ /finalhandler@1.2.0:
+ resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==}
engines: {node: '>= 0.8'}
dependencies:
debug: 2.6.9
encodeurl: 1.0.2
escape-html: 1.0.3
- on-finished: 2.3.0
+ on-finished: 2.4.1
parseurl: 1.3.3
- statuses: 1.5.0
+ statuses: 2.0.1
unpipe: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /find-cache-dir/2.1.0:
+ /find-cache-dir@2.1.0:
resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==}
engines: {node: '>=6'}
dependencies:
@@ -12536,8 +11233,8 @@ packages:
pkg-dir: 3.0.0
dev: true
- /find-cache-dir/3.3.1:
- resolution: {integrity: sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==}
+ /find-cache-dir@3.3.2:
+ resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==}
engines: {node: '>=8'}
dependencies:
commondir: 1.0.1
@@ -12545,25 +11242,19 @@ packages:
pkg-dir: 4.2.0
dev: true
- /find-root/1.1.0:
- resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
+ /find-up-simple@1.0.0:
+ resolution: {integrity: sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==}
+ engines: {node: '>=18'}
dev: true
- /find-up/2.1.0:
- resolution: {integrity: sha1-RdG35QbHF93UgndaK3eSCjwMV6c=}
- engines: {node: '>=4'}
- dependencies:
- locate-path: 2.0.0
- dev: true
-
- /find-up/3.0.0:
+ /find-up@3.0.0:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
dependencies:
locate-path: 3.0.0
dev: true
- /find-up/4.1.0:
+ /find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
dependencies:
@@ -12571,7 +11262,7 @@ packages:
path-exists: 4.0.0
dev: true
- /find-up/5.0.0:
+ /find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
dependencies:
@@ -12579,587 +11270,625 @@ packages:
path-exists: 4.0.0
dev: true
- /find-yarn-workspace-root/1.2.1:
- resolution: {integrity: sha512-dVtfb0WuQG+8Ag2uWkbG79hOUzEsRrhBzgfn86g2sJPkzmcpGdghbNTfUKGTxymFrY/tLIodDzLoW9nOJ4FY8Q==}
+ /firefox-profile@4.3.2:
+ resolution: {integrity: sha512-/C+Eqa0YgIsQT2p66p7Ygzqe7NlE/GNTbhw2SBCm5V3OsWDPITNdTPEcH2Q2fe7eMpYYNPKdUcuVioZBZiR6kA==}
+ hasBin: true
dependencies:
- fs-extra: 4.0.3
- micromatch: 3.1.10
+ adm-zip: 0.5.10
+ fs-extra: 9.0.1
+ ini: 2.0.0
+ minimist: 1.2.8
+ xml2js: 0.5.0
dev: true
- /flat-cache/2.0.1:
- resolution: {integrity: sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==}
- engines: {node: '>=4'}
- dependencies:
- flatted: 2.0.2
- rimraf: 2.6.3
- write: 1.0.3
+ /first-chunk-stream@3.0.0:
+ resolution: {integrity: sha512-LNRvR4hr/S8cXXkIY5pTgVP7L3tq6LlYWcg9nWBuW7o1NMxKZo6oOVa/6GIekMGI0Iw7uC+HWimMe9u/VAeKqw==}
+ engines: {node: '>=8'}
dev: true
- /flat-cache/3.0.4:
+ /flat-cache@3.0.4:
resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==}
engines: {node: ^10.12.0 || >=12.0.0}
dependencies:
- flatted: 3.1.1
+ flatted: 3.2.7
rimraf: 3.0.2
dev: true
- /flatted/2.0.2:
- resolution: {integrity: sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==}
+ /flat@5.0.2:
+ resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==}
+ hasBin: true
dev: true
- /flatted/3.1.1:
- resolution: {integrity: sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==}
+ /flatted@3.2.7:
+ resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==}
dev: true
- /flush-write-stream/1.1.1:
+ /flush-write-stream@1.1.1:
resolution: {integrity: sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==}
dependencies:
inherits: 2.0.4
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
+ dev: true
+
+ /follow-redirects@1.15.2:
+ resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
dev: true
- /follow-redirects/1.14.2:
- resolution: {integrity: sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA==}
+ /follow-redirects@1.15.5:
+ resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
+ dev: false
- /for-each/0.3.3:
+ /for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
dependencies:
- is-callable: 1.2.4
+ is-callable: 1.2.7
dev: true
- /for-in/1.0.2:
- resolution: {integrity: sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=}
+ /for-in@1.0.2:
+ resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==}
engines: {node: '>=0.10.0'}
dev: true
- /foreground-child/2.0.0:
+ /foreground-child@2.0.0:
resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==}
engines: {node: '>=8.0.0'}
dependencies:
cross-spawn: 7.0.3
- signal-exit: 3.0.3
+ signal-exit: 3.0.7
dev: true
- /forever-agent/0.6.1:
- resolution: {integrity: sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=}
+ /foreground-child@3.1.1:
+ resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
+ engines: {node: '>=14'}
+ dependencies:
+ cross-spawn: 7.0.3
+ signal-exit: 4.1.0
+
+ /forever-agent@0.6.1:
+ resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}
dev: true
- /fork-ts-checker-webpack-plugin/4.1.6:
+ /fork-ts-checker-webpack-plugin@4.1.6(eslint@8.56.0)(typescript@4.6.4)(webpack@4.46.0):
resolution: {integrity: sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw==}
engines: {node: '>=6.11.5', yarn: '>=1.0.0'}
+ peerDependencies:
+ eslint: '>= 6'
+ typescript: '>= 2.7'
+ vue-template-compiler: '*'
+ webpack: '>= 4'
+ peerDependenciesMeta:
+ eslint:
+ optional: true
+ vue-template-compiler:
+ optional: true
dependencies:
- '@babel/code-frame': 7.14.5
+ '@babel/code-frame': 7.23.5
chalk: 2.4.2
+ eslint: 8.56.0
micromatch: 3.1.10
- minimatch: 3.0.4
- semver: 5.7.1
+ minimatch: 3.1.2
+ semver: 5.7.2
tapable: 1.1.3
+ typescript: 4.6.4
+ webpack: 4.46.0
worker-rpc: 0.1.1
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /fork-ts-checker-webpack-plugin/6.3.2:
- resolution: {integrity: sha512-L3n1lrV20pRa7ocAuM2YW4Ux1yHM8+dV4shqPdHf1xoeG5KQhp3o0YySvNsBKBISQOCN4N2Db9DV4xYN6xXwyQ==}
- engines: {node: '>=10', yarn: '>=1.0.0'}
- dependencies:
- '@babel/code-frame': 7.14.5
- '@types/json-schema': 7.0.9
- chalk: 4.1.2
- chokidar: 3.5.2
- cosmiconfig: 6.0.0
- deepmerge: 4.2.2
- fs-extra: 9.1.0
- glob: 7.1.7
- memfs: 3.2.2
- minimatch: 3.0.4
- schema-utils: 2.7.0
- semver: 7.3.5
- tapable: 1.1.3
+ /form-data-encoder@2.1.4:
+ resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==}
+ engines: {node: '>= 14.17'}
dev: true
- /form-data/2.3.3:
+ /form-data@2.3.3:
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
engines: {node: '>= 0.12'}
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
- mime-types: 2.1.32
+ mime-types: 2.1.35
dev: true
- /form-data/3.0.1:
- resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
- engines: {node: '>= 6'}
+ /formdata-polyfill@4.0.10:
+ resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
+ engines: {node: '>=12.20.0'}
dependencies:
- asynckit: 0.4.0
- combined-stream: 1.0.8
- mime-types: 2.1.32
+ fetch-blob: 3.2.0
dev: true
- /format/0.2.2:
- resolution: {integrity: sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs=}
- engines: {node: '>=0.4.x'}
- dev: true
-
- /forwarded/0.2.0:
+ /forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
dev: true
- /fraction.js/4.1.1:
- resolution: {integrity: sha512-MHOhvvxHTfRFpF1geTK9czMIZ6xclsEor2wkIGYYq+PxcQqT7vStJqjhe6S1TenZrMZzo+wlqOufBDVepUEgPg==}
+ /fraction.js@4.2.0:
+ resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
dev: true
- /fragment-cache/0.2.1:
- resolution: {integrity: sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=}
+ /fragment-cache@0.2.1:
+ resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==}
engines: {node: '>=0.10.0'}
dependencies:
map-cache: 0.2.2
dev: true
- /fresh/0.5.2:
- resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=}
+ /fresh@0.5.2:
+ resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
dev: true
- /from2/2.3.0:
- resolution: {integrity: sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=}
+ /from2@2.3.0:
+ resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==}
dependencies:
inherits: 2.0.4
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
dev: true
- /fromentries/1.3.2:
+ /fromentries@1.3.2:
resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==}
dev: true
- /fs-extra/0.30.0:
- resolution: {integrity: sha1-8jP/zAjU2n1DLapEl3aYnbHfk/A=}
+ /fs-constants@1.0.0:
+ resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /fs-extra@11.1.0:
+ resolution: {integrity: sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==}
+ engines: {node: '>=14.14'}
dependencies:
- graceful-fs: 4.2.8
- jsonfile: 2.4.0
- klaw: 1.3.1
- path-is-absolute: 1.0.1
- rimraf: 2.7.1
+ graceful-fs: 4.2.11
+ jsonfile: 6.1.0
+ universalify: 2.0.0
dev: true
- /fs-extra/4.0.3:
- resolution: {integrity: sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==}
+ /fs-extra@11.1.1:
+ resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==}
+ engines: {node: '>=14.14'}
dependencies:
- graceful-fs: 4.2.8
- jsonfile: 4.0.0
- universalify: 0.1.2
+ graceful-fs: 4.2.11
+ jsonfile: 6.1.0
+ universalify: 2.0.0
dev: true
- /fs-extra/7.0.1:
- resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
- engines: {node: '>=6 <7 || >=8'}
+ /fs-extra@9.0.1:
+ resolution: {integrity: sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==}
+ engines: {node: '>=10'}
dependencies:
- graceful-fs: 4.2.4
- jsonfile: 4.0.0
- universalify: 0.1.2
+ at-least-node: 1.0.0
+ graceful-fs: 4.2.11
+ jsonfile: 6.1.0
+ universalify: 1.0.0
dev: true
- /fs-extra/9.1.0:
+ /fs-extra@9.1.0:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
dependencies:
at-least-node: 1.0.0
- graceful-fs: 4.2.8
+ graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.0
dev: true
- /fs-minipass/1.2.7:
+ /fs-minipass@1.2.7:
resolution: {integrity: sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==}
dependencies:
minipass: 2.9.0
dev: true
- /fs-minipass/2.1.0:
+ /fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
dependencies:
- minipass: 3.1.3
+ minipass: 3.3.6
dev: true
- /fs-monkey/1.0.3:
+ /fs-monkey@1.0.3:
resolution: {integrity: sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==}
dev: true
- /fs-write-stream-atomic/1.0.10:
- resolution: {integrity: sha1-tH31NJPvkR33VzHnCp3tAYnbQMk=}
+ /fs-write-stream-atomic@1.0.10:
+ resolution: {integrity: sha512-gehEzmPn2nAwr39eay+x3X34Ra+M2QlVUTLhkXPjWdeO8RF9kszk116avgBJM3ZyNHgHXBNx+VmPaFC36k0PzA==}
dependencies:
- graceful-fs: 4.2.8
+ graceful-fs: 4.2.11
iferr: 0.1.5
imurmurhash: 0.1.4
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
dev: true
- /fs.realpath/1.0.0:
- resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=}
- dev: true
+ /fs.realpath@1.0.0:
+ resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
- /fsevents/1.2.13:
+ /fsevents@1.2.13:
resolution: {integrity: sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==}
engines: {node: '>= 4.0'}
os: [darwin]
- deprecated: fsevents 1 will break on node v14+ and could be using insecure binaries. Upgrade to fsevents 2.
+ deprecated: The v1 package contains DANGEROUS / INSECURE binaries. Upgrade to safe fsevents v2
requiresBuild: true
dependencies:
bindings: 1.5.0
- nan: 2.15.0
+ nan: 2.18.0
dev: true
optional: true
- /fsevents/2.1.3:
- resolution: {integrity: sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==}
- engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
- os: [darwin]
- deprecated: '"Please update to latest v2.3 or v2.2"'
- dev: true
- optional: true
-
- /fsevents/2.3.2:
- resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ /fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
- dev: true
optional: true
- /function-bind/1.1.1:
- resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
- dev: true
+ /function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
- /function.prototype.name/1.1.4:
- resolution: {integrity: sha512-iqy1pIotY/RmhdFZygSSlW0wko2yxkSCKqsuv4pr8QESohpYyG/Z7B/XXvPRKTJS//960rgguE5mSRUsDdaJrQ==}
+ /function.prototype.name@1.1.6:
+ resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
- functions-have-names: 1.2.2
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ functions-have-names: 1.2.3
dev: true
- /functional-red-black-tree/1.0.1:
- resolution: {integrity: sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=}
+ /functional-red-black-tree@1.0.1:
+ resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==}
dev: true
- /functions-have-names/1.2.2:
- resolution: {integrity: sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA==}
+ /functions-have-names@1.2.3:
+ resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true
- /fuse.js/3.6.1:
- resolution: {integrity: sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw==}
- engines: {node: '>=6'}
+ /fx-runner@1.4.0:
+ resolution: {integrity: sha512-rci1g6U0rdTg6bAaBboP7XdRu01dzTAaKXxFf+PUqGuCv6Xu7o8NZdY1D5MvKGIjb6EdS1g3VlXOgksir1uGkg==}
+ hasBin: true
+ dependencies:
+ commander: 2.9.0
+ shell-quote: 1.7.3
+ spawn-sync: 1.0.15
+ when: 3.7.7
+ which: 1.2.4
+ winreg: 0.0.12
dev: true
- /gauge/2.7.4:
- resolution: {integrity: sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=}
+ /gauge@3.0.2:
+ resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==}
+ engines: {node: '>=10'}
dependencies:
- aproba: 1.2.0
+ aproba: 2.0.0
+ color-support: 1.1.3
console-control-strings: 1.1.0
has-unicode: 2.0.1
object-assign: 4.1.1
- signal-exit: 3.0.3
- string-width: 1.0.2
- strip-ansi: 3.0.1
- wide-align: 1.1.3
+ signal-exit: 3.0.7
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wide-align: 1.1.5
dev: true
- /gensync/1.0.0-beta.2:
+ /gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
- dev: true
- /get-caller-file/2.0.5:
+ /get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
dev: true
- /get-intrinsic/1.0.2:
- resolution: {integrity: sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg==}
- dependencies:
- function-bind: 1.1.1
- has: 1.0.3
- has-symbols: 1.0.1
+ /get-east-asian-width@1.2.0:
+ resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==}
+ engines: {node: '>=18'}
dev: true
- /get-intrinsic/1.1.1:
- resolution: {integrity: sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==}
+ /get-func-name@2.0.0:
+ resolution: {integrity: sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==}
+ dev: true
+
+ /get-intrinsic@1.2.2:
+ resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==}
dependencies:
- function-bind: 1.1.1
- has: 1.0.3
- has-symbols: 1.0.2
+ function-bind: 1.1.2
+ has-proto: 1.0.1
+ has-symbols: 1.0.3
+ hasown: 2.0.0
dev: true
- /get-own-enumerable-property-symbols/3.0.2:
+ /get-own-enumerable-property-symbols@3.0.2:
resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==}
dev: true
- /get-package-type/0.1.0:
+ /get-package-type@0.1.0:
resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
engines: {node: '>=8.0.0'}
dev: true
- /get-port/3.2.0:
- resolution: {integrity: sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=}
+ /get-port@3.2.0:
+ resolution: {integrity: sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==}
engines: {node: '>=4'}
dev: true
- /get-port/5.1.1:
+ /get-port@5.1.1:
resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==}
engines: {node: '>=8'}
dev: true
- /get-stream/4.1.0:
+ /get-stdin@9.0.0:
+ resolution: {integrity: sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /get-stream@4.1.0:
resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==}
engines: {node: '>=6'}
dependencies:
pump: 3.0.0
dev: true
- /get-stream/5.2.0:
+ /get-stream@5.2.0:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
dependencies:
pump: 3.0.0
dev: true
- /get-symbol-description/1.0.0:
+ /get-stream@6.0.1:
+ resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /get-symbol-description@1.0.0:
resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- get-intrinsic: 1.1.1
+ call-bind: 1.0.5
+ get-intrinsic: 1.2.2
dev: true
- /get-value/2.0.6:
- resolution: {integrity: sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=}
+ /get-value@2.0.6:
+ resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==}
engines: {node: '>=0.10.0'}
dev: true
- /getpass/0.1.7:
- resolution: {integrity: sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=}
+ /getpass@0.1.7:
+ resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==}
dependencies:
assert-plus: 1.0.0
dev: true
- /gettext-parser/1.1.0:
+ /gettext-parser@1.1.0:
resolution: {integrity: sha1-LFpmONiTk0ubVQN9CtgstwBLJnk=}
dependencies:
encoding: 0.1.13
dev: true
- /github-slugger/1.3.0:
- resolution: {integrity: sha512-gwJScWVNhFYSRDvURk/8yhcFBee6aFjye2a7Lhb2bUyRulpIoek9p0I9Kt7PT67d/nUlZbFu8L9RLiA0woQN8Q==}
- dependencies:
- emoji-regex: 6.1.1
- dev: true
+ /github-from-package@0.0.0:
+ resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
+ requiresBuild: true
+ dev: false
+ optional: true
- /gittar/0.1.1:
- resolution: {integrity: sha1-1pk+phYKhsi3895yKmH3O8meFLQ=}
+ /gittar@0.1.1:
+ resolution: {integrity: sha512-p+XuqWJpW9ahUuNTptqeFjudFq31o6Jd+maMBarkMAR5U3K9c7zJB4sQ4BV8mIqrTOV29TtqikDhnZfCD4XNfQ==}
engines: {node: '>=4'}
dependencies:
- mkdirp: 0.5.5
+ mkdirp: 0.5.6
tar: 4.4.19
dev: true
- /glob-base/0.3.0:
- resolution: {integrity: sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=}
- engines: {node: '>=0.10.0'}
- dependencies:
- glob-parent: 2.0.0
- is-glob: 2.0.1
- dev: true
-
- /glob-parent/2.0.0:
- resolution: {integrity: sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=}
- dependencies:
- is-glob: 2.0.1
- dev: true
-
- /glob-parent/3.1.0:
- resolution: {integrity: sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=}
+ /glob-parent@3.1.0:
+ resolution: {integrity: sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==}
+ requiresBuild: true
dependencies:
is-glob: 3.1.0
path-dirname: 1.0.2
dev: true
+ optional: true
- /glob-parent/5.1.1:
- resolution: {integrity: sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==}
+ /glob-parent@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
dependencies:
- is-glob: 4.0.1
- dev: true
+ is-glob: 4.0.3
- /glob-parent/5.1.2:
- resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
- engines: {node: '>= 6'}
+ /glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
dependencies:
- is-glob: 4.0.1
+ is-glob: 4.0.3
+
+ /glob-to-regexp@0.4.1:
+ resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
dev: true
- /glob-promise/3.4.0_glob@7.1.7:
- resolution: {integrity: sha512-q08RJ6O+eJn+dVanerAndJwIcumgbDdYiUT7zFQl3Wm1xD6fBKtah7H8ZJChj4wP+8C+QfeVy8xautR7rdmKEw==}
- engines: {node: '>=4'}
- peerDependencies:
- glob: '*'
+ /glob@10.3.10:
+ resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ hasBin: true
dependencies:
- '@types/glob': 7.1.4
- glob: 7.1.7
- dev: true
+ foreground-child: 3.1.1
+ jackspeak: 2.3.6
+ minimatch: 9.0.3
+ minipass: 7.0.4
+ path-scurry: 1.10.1
- /glob-to-regexp/0.3.0:
- resolution: {integrity: sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=}
+ /glob@6.0.4:
+ resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==}
+ requiresBuild: true
+ dependencies:
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.2
+ once: 1.4.0
+ path-is-absolute: 1.0.1
dev: true
+ optional: true
- /glob/7.1.6:
+ /glob@7.1.6:
resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==}
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
- minimatch: 3.0.4
+ minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
- dev: true
- /glob/7.1.7:
- resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==}
+ /glob@7.2.0:
+ resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
- minimatch: 3.0.4
+ minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
dev: true
- /global-dirs/3.0.0:
- resolution: {integrity: sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==}
- engines: {node: '>=10'}
+ /glob@7.2.3:
+ resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
dependencies:
- ini: 2.0.0
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.2
+ once: 1.4.0
+ path-is-absolute: 1.0.1
dev: true
- /global-modules/2.0.0:
- resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==}
- engines: {node: '>=6'}
+ /glob@8.1.0:
+ resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
+ engines: {node: '>=12'}
dependencies:
- global-prefix: 3.0.0
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 5.1.6
+ once: 1.4.0
dev: true
- /global-prefix/3.0.0:
- resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==}
- engines: {node: '>=6'}
+ /global-dirs@3.0.0:
+ resolution: {integrity: sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==}
+ engines: {node: '>=10'}
dependencies:
- ini: 1.3.8
- kind-of: 6.0.3
- which: 1.3.1
+ ini: 2.0.0
dev: true
- /global/4.4.0:
- resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
+ /globals@11.12.0:
+ resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
+ engines: {node: '>=4'}
+
+ /globals@13.17.0:
+ resolution: {integrity: sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw==}
+ engines: {node: '>=8'}
dependencies:
- min-document: 2.19.0
- process: 0.11.10
+ type-fest: 0.20.2
dev: true
- /globals/11.12.0:
- resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
- engines: {node: '>=4'}
+ /globals@13.21.0:
+ resolution: {integrity: sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==}
+ engines: {node: '>=8'}
+ dependencies:
+ type-fest: 0.20.2
dev: true
- /globals/12.4.0:
- resolution: {integrity: sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==}
+ /globals@13.24.0:
+ resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
engines: {node: '>=8'}
dependencies:
- type-fest: 0.8.1
+ type-fest: 0.20.2
dev: true
- /globalthis/1.0.2:
- resolution: {integrity: sha512-ZQnSFO1la8P7auIOQECnm0sSuoMeaSq0EEdXMBFF2QJO4uNcwbyhSgG3MruWNbFTqCLmxVwGOl7LZ9kASvHdeQ==}
+ /globalthis@1.0.3:
+ resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
engines: {node: '>= 0.4'}
dependencies:
- define-properties: 1.1.3
+ define-properties: 1.2.1
dev: true
- /globby/11.0.1:
- resolution: {integrity: sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ==}
+ /globby@11.1.0:
+ resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
engines: {node: '>=10'}
dependencies:
array-union: 2.1.0
dir-glob: 3.0.1
- fast-glob: 3.2.7
- ignore: 5.1.8
+ fast-glob: 3.3.2
+ ignore: 5.3.0
merge2: 1.4.1
slash: 3.0.0
dev: true
- /globby/11.0.2:
- resolution: {integrity: sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og==}
- engines: {node: '>=10'}
+ /globby@13.2.2:
+ resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
- array-union: 2.1.0
dir-glob: 3.0.1
- fast-glob: 3.2.5
- ignore: 5.1.8
+ fast-glob: 3.3.1
+ ignore: 5.2.4
merge2: 1.4.1
- slash: 3.0.0
+ slash: 4.0.0
dev: true
- /globby/11.0.4:
- resolution: {integrity: sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==}
- engines: {node: '>=10'}
+ /globby@14.0.0:
+ resolution: {integrity: sha512-/1WM/LNHRAOH9lZta77uGbq0dAEQM+XjNesWwhlERDVenqothRbnzTrL3/LrIoEPPjeUHC3vrS6TwoyxeHs7MQ==}
+ engines: {node: '>=18'}
dependencies:
- array-union: 2.1.0
- dir-glob: 3.0.1
- fast-glob: 3.2.7
- ignore: 5.1.8
- merge2: 1.4.1
- slash: 3.0.0
+ '@sindresorhus/merge-streams': 1.0.0
+ fast-glob: 3.3.2
+ ignore: 5.3.0
+ path-type: 5.0.0
+ slash: 5.1.0
+ unicorn-magic: 0.1.0
dev: true
- /globby/6.1.0:
- resolution: {integrity: sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=}
- engines: {node: '>=0.10.0'}
+ /gopd@1.0.1:
+ resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
dependencies:
- array-union: 1.0.2
- glob: 7.1.7
- object-assign: 4.1.1
- pify: 2.3.0
- pinkie-promise: 2.0.1
+ get-intrinsic: 1.2.2
dev: true
- /globby/9.2.0:
- resolution: {integrity: sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==}
- engines: {node: '>=6'}
+ /got@12.6.1:
+ resolution: {integrity: sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==}
+ engines: {node: '>=14.16'}
dependencies:
- '@types/glob': 7.1.4
- array-union: 1.0.2
- dir-glob: 2.2.2
- fast-glob: 2.2.7
- glob: 7.1.7
- ignore: 4.0.6
- pify: 4.0.1
- slash: 2.0.0
+ '@sindresorhus/is': 5.6.0
+ '@szmarczak/http-timer': 5.0.1
+ cacheable-lookup: 7.0.0
+ cacheable-request: 10.2.14
+ decompress-response: 6.0.0
+ form-data-encoder: 2.1.4
+ get-stream: 6.0.1
+ http2-wrapper: 2.2.1
+ lowercase-keys: 3.0.0
+ p-cancelable: 3.0.0
+ responselike: 3.0.0
dev: true
- /got/9.6.0:
+ /got@9.6.0:
resolution: {integrity: sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==}
engines: {node: '>=8.6'}
dependencies:
'@sindresorhus/is': 0.14.0
'@szmarczak/http-timer': 1.1.2
+ '@types/keyv': 3.1.4
+ '@types/responselike': 1.0.0
cacheable-request: 6.1.0
decompress-response: 3.3.0
- duplexer3: 0.1.4
+ duplexer3: 0.1.5
get-stream: 4.1.0
lowercase-keys: 1.0.1
mimic-response: 1.0.1
@@ -13168,122 +11897,117 @@ packages:
url-parse-lax: 3.0.0
dev: true
- /graceful-fs/4.2.4:
- resolution: {integrity: sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==}
+ /graceful-fs@4.2.10:
+ resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
dev: true
- /graceful-fs/4.2.8:
- resolution: {integrity: sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==}
+ /graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
dev: true
- /growly/1.3.0:
- resolution: {integrity: sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=}
+ /graceful-readlink@1.0.1:
+ resolution: {integrity: sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==}
dev: true
- optional: true
- /gud/1.0.0:
- resolution: {integrity: sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==}
+ /grapheme-splitter@1.0.4:
+ resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==}
dev: true
- /gzip-size/5.1.1:
- resolution: {integrity: sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA==}
- engines: {node: '>=6'}
- dependencies:
- duplexer: 0.1.2
- pify: 4.0.1
+ /graphemer@1.4.0:
+ resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
dev: true
- /gzip-size/6.0.0:
+ /growl@1.10.5:
+ resolution: {integrity: sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==}
+ engines: {node: '>=4.x'}
+ dev: true
+
+ /growly@1.3.0:
+ resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==}
+ dev: true
+
+ /gzip-size@6.0.0:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'}
dependencies:
duplexer: 0.1.2
dev: true
- /handle-thing/2.0.1:
+ /handle-thing@2.0.1:
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
dev: true
- /handlebars/4.7.6:
- resolution: {integrity: sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==}
- engines: {node: '>=0.4.7'}
- hasBin: true
+ /happy-dom@10.8.0:
+ resolution: {integrity: sha512-ux5UfhNA9ANGf4keV7FCd9GqeQr3Bz1u9qnoPtTL0NcO1MEOeUXIUwNTB9r84Z7Q8/bsgkwi6K114zjYvnCmag==}
dependencies:
- minimist: 1.2.5
- neo-async: 2.6.2
- source-map: 0.6.1
- wordwrap: 1.0.0
- optionalDependencies:
- uglify-js: 3.12.5
+ css.escape: 1.5.1
+ entities: 4.5.0
+ iconv-lite: 0.6.3
+ webidl-conversions: 7.0.0
+ whatwg-encoding: 2.0.0
+ whatwg-mimetype: 3.0.0
dev: true
- /har-schema/2.0.0:
- resolution: {integrity: sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=}
+ /har-schema@2.0.0:
+ resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==}
engines: {node: '>=4'}
dev: true
- /har-validator/5.1.5:
+ /har-validator@5.1.5:
resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==}
engines: {node: '>=6'}
- deprecated: this library is no longer supported
dependencies:
ajv: 6.12.6
har-schema: 2.0.0
dev: true
- /harmony-reflect/1.6.2:
- resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==}
+ /has-bigints@1.0.2:
+ resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
dev: true
- /has-bigints/1.0.1:
- resolution: {integrity: sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==}
- dev: true
-
- /has-color/0.1.7:
- resolution: {integrity: sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=}
+ /has-color@0.1.7:
+ resolution: {integrity: sha512-kaNz5OTAYYmt646Hkqw50/qyxP2vFnTVu5AQ1Zmk22Kk5+4Qx6BpO8+u7IKsML5fOsFk0ZT0AcCJNYwcvaLBvw==}
engines: {node: '>=0.10.0'}
dev: true
- /has-flag/3.0.0:
- resolution: {integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0=}
+ /has-flag@3.0.0:
+ resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
- dev: true
- /has-flag/4.0.0:
+ /has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
dev: true
- /has-glob/1.0.0:
- resolution: {integrity: sha1-mqqe7b/7G6OZCnsAEPtnjuAIEgc=}
- engines: {node: '>=0.10.0'}
+ /has-property-descriptors@1.0.1:
+ resolution: {integrity: sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==}
dependencies:
- is-glob: 3.1.0
+ get-intrinsic: 1.2.2
dev: true
- /has-symbols/1.0.1:
- resolution: {integrity: sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==}
+ /has-proto@1.0.1:
+ resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==}
engines: {node: '>= 0.4'}
dev: true
- /has-symbols/1.0.2:
- resolution: {integrity: sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==}
+ /has-symbols@1.0.3:
+ resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
engines: {node: '>= 0.4'}
dev: true
- /has-tostringtag/1.0.0:
+ /has-tostringtag@1.0.0:
resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==}
engines: {node: '>= 0.4'}
dependencies:
- has-symbols: 1.0.2
+ has-symbols: 1.0.3
dev: true
- /has-unicode/2.0.1:
- resolution: {integrity: sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=}
+ /has-unicode@2.0.1:
+ resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
dev: true
- /has-value/0.3.1:
- resolution: {integrity: sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=}
+ /has-value@0.3.1:
+ resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==}
engines: {node: '>=0.10.0'}
dependencies:
get-value: 2.0.6
@@ -13291,8 +12015,8 @@ packages:
isobject: 2.1.0
dev: true
- /has-value/1.0.0:
- resolution: {integrity: sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=}
+ /has-value@1.0.0:
+ resolution: {integrity: sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==}
engines: {node: '>=0.10.0'}
dependencies:
get-value: 2.0.6
@@ -13300,229 +12024,153 @@ packages:
isobject: 3.0.1
dev: true
- /has-values/0.1.4:
- resolution: {integrity: sha1-bWHeldkd/Km5oCCJrThL/49it3E=}
+ /has-values@0.1.4:
+ resolution: {integrity: sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==}
engines: {node: '>=0.10.0'}
dev: true
- /has-values/1.0.0:
- resolution: {integrity: sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=}
+ /has-values@1.0.0:
+ resolution: {integrity: sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==}
engines: {node: '>=0.10.0'}
dependencies:
is-number: 3.0.0
kind-of: 4.0.0
dev: true
- /has-yarn/2.1.0:
+ /has-yarn@2.1.0:
resolution: {integrity: sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==}
engines: {node: '>=8'}
dev: true
- /has/1.0.3:
+ /has-yarn@3.0.0:
+ resolution: {integrity: sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dev: true
+
+ /has@1.0.3:
resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
engines: {node: '>= 0.4.0'}
dependencies:
- function-bind: 1.1.1
+ function-bind: 1.1.2
dev: true
- /hash-base/3.1.0:
+ /hash-base@3.1.0:
resolution: {integrity: sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==}
engines: {node: '>=4'}
dependencies:
inherits: 2.0.4
- readable-stream: 3.6.0
+ readable-stream: 3.6.2
safe-buffer: 5.2.1
dev: true
- /hash-wasm/4.9.0:
- resolution: {integrity: sha512-7SW7ejyfnRxuOc7ptQHSf4LDoZaWOivfzqw+5rpcQku0nHfmicPKE51ra9BiRLAmT8+gGLestr1XroUkqdjL6w==}
+ /hash-wasm@4.11.0:
+ resolution: {integrity: sha512-HVusNXlVqHe0fzIzdQOGolnFN6mX/fqcrSAOcTBXdvzrXVHwTz11vXeKRmkR5gTuwVpvHZEIyKoePDvuAR+XwQ==}
dev: false
- /hash.js/1.1.7:
+ /hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
dependencies:
inherits: 2.0.4
minimalistic-assert: 1.0.1
dev: true
- /hasha/5.2.2:
+ /hasha@5.2.2:
resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==}
engines: {node: '>=8'}
dependencies:
- is-stream: 2.0.0
+ is-stream: 2.0.1
type-fest: 0.8.1
dev: true
- /hast-to-hyperscript/9.0.1:
- resolution: {integrity: sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA==}
- dependencies:
- '@types/unist': 2.0.6
- comma-separated-tokens: 1.0.8
- property-information: 5.6.0
- space-separated-tokens: 1.1.5
- style-to-object: 0.3.0
- unist-util-is: 4.1.0
- web-namespaces: 1.1.4
- dev: true
-
- /hast-util-from-parse5/6.0.1:
- resolution: {integrity: sha512-jeJUWiN5pSxW12Rh01smtVkZgZr33wBokLzKLwinYOUfSzm1Nl/c3GUGebDyOKjdsRgMvoVbV0VpAcpjF4NrJA==}
- dependencies:
- '@types/parse5': 5.0.3
- hastscript: 6.0.0
- property-information: 5.6.0
- vfile: 4.2.1
- vfile-location: 3.2.0
- web-namespaces: 1.1.4
- dev: true
-
- /hast-util-parse-selector/2.2.5:
- resolution: {integrity: sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==}
- dev: true
-
- /hast-util-raw/6.0.1:
- resolution: {integrity: sha512-ZMuiYA+UF7BXBtsTBNcLBF5HzXzkyE6MLzJnL605LKE8GJylNjGc4jjxazAHUtcwT5/CEt6afRKViYB4X66dig==}
- dependencies:
- '@types/hast': 2.3.2
- hast-util-from-parse5: 6.0.1
- hast-util-to-parse5: 6.0.0
- html-void-elements: 1.0.5
- parse5: 6.0.1
- unist-util-position: 3.1.0
- vfile: 4.2.1
- web-namespaces: 1.1.4
- xtend: 4.0.2
- zwitch: 1.0.5
- dev: true
-
- /hast-util-to-parse5/6.0.0:
- resolution: {integrity: sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ==}
+ /hasown@2.0.0:
+ resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==}
+ engines: {node: '>= 0.4'}
dependencies:
- hast-to-hyperscript: 9.0.1
- property-information: 5.6.0
- web-namespaces: 1.1.4
- xtend: 4.0.2
- zwitch: 1.0.5
- dev: true
+ function-bind: 1.1.2
- /hastscript/6.0.0:
- resolution: {integrity: sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==}
- dependencies:
- '@types/hast': 2.3.2
- comma-separated-tokens: 1.0.8
- hast-util-parse-selector: 2.2.5
- property-information: 5.6.0
- space-separated-tokens: 1.1.5
- dev: true
-
- /he/1.2.0:
+ /he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
dev: true
- /hex-color-regex/1.1.0:
+ /hex-color-regex@1.1.0:
resolution: {integrity: sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==}
dev: true
- /highlight.js/10.7.3:
- resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
- dev: true
-
- /history/4.10.1:
+ /history@4.10.1:
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==}
dependencies:
- '@babel/runtime': 7.15.3
+ '@babel/runtime': 7.19.4
loose-envify: 1.4.0
resolve-pathname: 3.0.0
- tiny-invariant: 1.1.0
+ tiny-invariant: 1.3.1
tiny-warning: 1.0.3
value-equal: 1.0.1
dev: false
- /hmac-drbg/1.0.1:
- resolution: {integrity: sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=}
+ /hmac-drbg@1.0.1:
+ resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==}
dependencies:
hash.js: 1.1.7
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
dev: true
- /hoist-non-react-statics/3.3.2:
- resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
- dependencies:
- react-is: 16.13.1
- dev: true
-
- /hosted-git-info/2.8.9:
- resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
- dev: true
-
- /hpack.js/2.1.6:
- resolution: {integrity: sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=}
+ /hpack.js@2.1.6:
+ resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==}
dependencies:
inherits: 2.0.4
obuf: 1.1.2
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
wbuf: 1.7.3
dev: true
- /hsl-regex/1.0.0:
- resolution: {integrity: sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4=}
+ /hsl-regex@1.0.0:
+ resolution: {integrity: sha512-M5ezZw4LzXbBKMruP+BNANf0k+19hDQMgpzBIYnya//Al+fjNct9Wf3b1WedLqdEs2hKBvxq/jh+DsHJLj0F9A==}
dev: true
- /hsla-regex/1.0.0:
- resolution: {integrity: sha1-wc56MWjIxmFAM6S194d/OyJfnDg=}
+ /hsla-regex@1.0.0:
+ resolution: {integrity: sha512-7Wn5GMLuHBjZCb2bTmnDOycho0p/7UVaAeqXZGbHrBCl6Yd/xDhQJAXe6Ga9AXJH2I5zY1dEdYw2u1UptnSBJA==}
dev: true
- /html-element-map/1.3.1:
- resolution: {integrity: sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg==}
- dependencies:
- array.prototype.filter: 1.0.0
- call-bind: 1.0.2
+ /html-element-attributes@1.3.1:
+ resolution: {integrity: sha512-UrRKgp5sQmRnDy4TEwAUsu14XBUlzKB8U3hjIYDjcZ3Hbp86Jtftzxfgrv6E/ii/h78tsaZwAnAE8HwnHr0dPA==}
dev: true
- /html-encoding-sniffer/1.0.2:
+ /html-encoding-sniffer@1.0.2:
resolution: {integrity: sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==}
dependencies:
whatwg-encoding: 1.0.5
dev: true
- /html-encoding-sniffer/2.0.1:
- resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==}
- engines: {node: '>=10'}
- dependencies:
- whatwg-encoding: 1.0.5
+ /html-entities@2.3.3:
+ resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
dev: true
- /html-entities/1.4.0:
- resolution: {integrity: sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==}
- dev: true
-
- /html-escaper/2.0.2:
+ /html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
dev: true
- /html-minifier-terser/5.1.1:
- resolution: {integrity: sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==}
- engines: {node: '>=6'}
+ /html-minifier-terser@6.1.0:
+ resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==}
+ engines: {node: '>=12'}
hasBin: true
dependencies:
camel-case: 4.1.2
- clean-css: 4.2.3
- commander: 4.1.1
+ clean-css: 5.3.3
+ commander: 8.3.0
he: 1.2.0
param-case: 3.0.4
relateurl: 0.2.7
- terser: 4.8.0
+ terser: 5.26.0
dev: true
- /html-minifier/3.5.21:
+ /html-minifier@3.5.21:
resolution: {integrity: sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==}
engines: {node: '>=4'}
- hasBin: true
dependencies:
camel-case: 3.0.0
- clean-css: 4.2.3
+ clean-css: 4.2.4
commander: 2.17.1
he: 1.2.0
param-case: 2.1.1
@@ -13530,24 +12178,29 @@ packages:
uglify-js: 3.4.10
dev: true
- /html-tags/3.1.0:
- resolution: {integrity: sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==}
- engines: {node: '>=8'}
+ /html-webpack-exclude-assets-plugin@0.0.7:
+ resolution: {integrity: sha512-gaYKMGBPDts3Fb1WXyDEEcS/0TSRg2IDl3EsbQL2AkKWTqdjSKwfQ8Iz0RhPiWErJfqhq5/wkhoYyjQoG55pug==}
+ engines: {node: '>=4.0.0'}
dev: true
- /html-void-elements/1.0.5:
- resolution: {integrity: sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==}
+ /html-webpack-inline-chunk-plugin@1.1.1:
+ resolution: {integrity: sha512-0cor73re8PI/BG2W+jCkZxob8duoLG1COQLFZ3cT8G1VBzyKE7wQRpLkl79BjJw3epntnVg17n2z7Y6McJijaA==}
+ dependencies:
+ lodash: 4.17.21
+ source-map-url: 0.4.1
dev: true
- /html-webpack-exclude-assets-plugin/0.0.7:
- resolution: {integrity: sha512-gaYKMGBPDts3Fb1WXyDEEcS/0TSRg2IDl3EsbQL2AkKWTqdjSKwfQ8Iz0RhPiWErJfqhq5/wkhoYyjQoG55pug==}
- engines: {node: '>=4.0.0'}
+ /html-webpack-inline-source-plugin@0.0.10:
+ resolution: {integrity: sha512-0ZNU57u7283vrXSF5a4VDnVOMWiSwypKIp1z/XfXWoVHLA1r3Xmyxx5+Lz+mnthz/UvxL1OAf41w5UIF68Jngw==}
+ dependencies:
+ escape-string-regexp: 1.0.5
+ slash: 1.0.0
+ source-map-url: 0.4.1
dev: true
- /html-webpack-plugin/3.2.0_webpack@4.46.0:
- resolution: {integrity: sha1-sBq71yOsqqeze2r0SS69oD2d03s=}
+ /html-webpack-plugin@3.2.0(webpack@4.46.0):
+ resolution: {integrity: sha512-Br4ifmjQojUP4EmHnRBoUIYcZ9J7M4bTMcm7u6xoIAIuq2Nte4TzXX0533owvkQKQD1WeMTTTyD4Ni4QKxS0Bg==}
engines: {node: '>=6.9'}
- deprecated: 3.x is no longer supported
peerDependencies:
webpack: ^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0
dependencies:
@@ -13561,223 +12214,247 @@ packages:
webpack: 4.46.0
dev: true
- /html-webpack-plugin/4.5.2_webpack@4.46.0:
- resolution: {integrity: sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A==}
- engines: {node: '>=6.9'}
+ /html-webpack-plugin@5.5.4(webpack@4.47.0):
+ resolution: {integrity: sha512-3wNSaVVxdxcu0jd4FpQFoICdqgxs4zIQQvj+2yQKFfBOnLETQ6X5CDWdeasuGlSsooFlMkEioWDTqBv1wvw5Iw==}
+ engines: {node: '>=10.13.0'}
peerDependencies:
- webpack: ^4.0.0 || ^5.0.0
+ webpack: ^5.20.0
dependencies:
- '@types/html-minifier-terser': 5.1.2
- '@types/tapable': 1.0.8
- '@types/webpack': 4.41.30
- html-minifier-terser: 5.1.1
- loader-utils: 1.4.0
+ '@types/html-minifier-terser': 6.1.0
+ html-minifier-terser: 6.1.0
lodash: 4.17.21
- pretty-error: 2.1.2
- tapable: 1.1.3
- util.promisify: 1.0.0
- webpack: 4.46.0
+ pretty-error: 4.0.0
+ tapable: 2.2.1
+ webpack: 4.47.0
dev: true
- /htmlparser2/6.1.0:
+ /html-webpack-skip-assets-plugin@1.0.3(html-webpack-plugin@5.5.4)(webpack@4.47.0):
+ resolution: {integrity: sha512-vpdh+JZGlE1Df3IftH2gw5P7b6yfTsahcOIJnkkkj5iJU9dUStXgzgALoXWwl8+17wWgFm3edcJzeYTJBYfMAw==}
+ peerDependencies:
+ html-webpack-plugin: '>=3.0.0'
+ webpack: '>=3.0.0'
+ dependencies:
+ html-webpack-plugin: 5.5.4(webpack@4.47.0)
+ minimatch: 3.0.4
+ webpack: 4.47.0
+ dev: true
+
+ /htmlparser2@6.1.0:
resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
dependencies:
- domelementtype: 2.2.0
- domhandler: 4.2.0
- domutils: 2.7.0
+ domelementtype: 2.3.0
+ domhandler: 4.3.1
+ domutils: 2.8.0
entities: 2.2.0
dev: true
- /http-cache-semantics/4.1.0:
+ /htmlparser2@8.0.2:
+ resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.1.0
+ entities: 4.5.0
+ dev: true
+
+ /http-cache-semantics@4.1.0:
resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==}
dev: true
- /http-deceiver/1.2.7:
- resolution: {integrity: sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=}
+ /http-cache-semantics@4.1.1:
+ resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
dev: true
- /http-errors/1.6.3:
- resolution: {integrity: sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=}
- engines: {node: '>= 0.6'}
- dependencies:
- depd: 1.1.2
- inherits: 2.0.3
- setprototypeof: 1.1.0
- statuses: 1.5.0
+ /http-deceiver@1.2.7:
+ resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==}
dev: true
- /http-errors/1.7.2:
- resolution: {integrity: sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==}
+ /http-errors@1.6.3:
+ resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==}
engines: {node: '>= 0.6'}
dependencies:
depd: 1.1.2
inherits: 2.0.3
- setprototypeof: 1.1.1
+ setprototypeof: 1.1.0
statuses: 1.5.0
- toidentifier: 1.0.0
dev: true
- /http-errors/1.7.3:
- resolution: {integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==}
- engines: {node: '>= 0.6'}
+ /http-errors@2.0.0:
+ resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
+ engines: {node: '>= 0.8'}
dependencies:
- depd: 1.1.2
+ depd: 2.0.0
inherits: 2.0.4
- setprototypeof: 1.1.1
- statuses: 1.5.0
- toidentifier: 1.0.0
- dev: true
-
- /http-parser-js/0.5.3:
- resolution: {integrity: sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg==}
+ setprototypeof: 1.2.0
+ statuses: 2.0.1
+ toidentifier: 1.0.1
dev: true
- /http-proxy-agent/4.0.1:
- resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==}
- engines: {node: '>= 6'}
- dependencies:
- '@tootallnate/once': 1.1.2
- agent-base: 6.0.2
- debug: 4.3.2
- transitivePeerDependencies:
- - supports-color
+ /http-parser-js@0.5.8:
+ resolution: {integrity: sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==}
dev: true
- /http-proxy-middleware/0.19.1_debug@4.3.2:
- resolution: {integrity: sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==}
- engines: {node: '>=4.0.0'}
+ /http-proxy-middleware@2.0.6(@types/express@4.17.14):
+ resolution: {integrity: sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ '@types/express': ^4.17.13
+ peerDependenciesMeta:
+ '@types/express':
+ optional: true
dependencies:
- http-proxy: 1.18.1_debug@4.3.2
- is-glob: 4.0.1
- lodash: 4.17.21
- micromatch: 3.1.10
+ '@types/express': 4.17.14
+ '@types/http-proxy': 1.17.9
+ http-proxy: 1.18.1
+ is-glob: 4.0.3
+ is-plain-obj: 3.0.0
+ micromatch: 4.0.5
transitivePeerDependencies:
- debug
dev: true
- /http-proxy/1.18.1_debug@4.3.2:
+ /http-proxy@1.18.1:
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
engines: {node: '>=8.0.0'}
dependencies:
eventemitter3: 4.0.7
- follow-redirects: 1.14.2
+ follow-redirects: 1.15.2
requires-port: 1.0.0
transitivePeerDependencies:
- debug
dev: true
- /http-signature/1.2.0:
- resolution: {integrity: sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=}
+ /http-signature@1.2.0:
+ resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==}
engines: {node: '>=0.8', npm: '>=1.3.7'}
dependencies:
assert-plus: 1.0.0
- jsprim: 1.4.1
- sshpk: 1.16.1
+ jsprim: 1.4.2
+ sshpk: 1.17.0
dev: true
- /https-browserify/1.0.0:
- resolution: {integrity: sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=}
+ /http2-wrapper@2.2.1:
+ resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
+ engines: {node: '>=10.19.0'}
+ dependencies:
+ quick-lru: 5.1.1
+ resolve-alpn: 1.2.1
dev: true
- /https-proxy-agent/5.0.0:
- resolution: {integrity: sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==}
+ /https-browserify@1.0.0:
+ resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==}
+ dev: true
+
+ /https-proxy-agent@5.0.1:
+ resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
dependencies:
agent-base: 6.0.2
- debug: 4.3.2
+ debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: true
- /human-signals/1.1.1:
+ /human-signals@1.1.1:
resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
engines: {node: '>=8.12.0'}
dev: true
- /iconv-lite/0.4.24:
+ /human-signals@2.1.0:
+ resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
+ engines: {node: '>=10.17.0'}
+ dev: true
+
+ /human-signals@4.3.1:
+ resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==}
+ engines: {node: '>=14.18.0'}
+ dev: true
+
+ /iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
dependencies:
safer-buffer: 2.1.2
dev: true
- /iconv-lite/0.6.2:
- resolution: {integrity: sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==}
+ /iconv-lite@0.6.3:
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
dependencies:
safer-buffer: 2.1.2
dev: true
- /icss-utils/4.1.1:
- resolution: {integrity: sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==}
- engines: {node: '>= 6'}
- dependencies:
- postcss: 7.0.36
- dev: true
-
- /icss-utils/5.1.0_postcss@8.3.6:
+ /icss-utils@5.1.0(postcss@8.4.32):
resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
dependencies:
- postcss: 8.3.6
- dev: true
-
- /idb/6.1.2:
- resolution: {integrity: sha512-1DNDVu3yDhAZkFDlJf0t7r+GLZ248F5pTAtA7V0oVG3yjmV125qZOx3g0XpAEkGZVYQiFDAsSOnGet2bhugc3w==}
+ postcss: 8.4.32
dev: true
- /identity-obj-proxy/3.0.0:
- resolution: {integrity: sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=}
- engines: {node: '>=4'}
- dependencies:
- harmony-reflect: 1.6.2
+ /idb@7.1.0:
+ resolution: {integrity: sha512-Wsk07aAxDsntgYJY4h0knZJuTxM73eQ4reRAO+Z1liOh8eMCJ/MoDS8fCui1vGT9mnjtl1sOu3I2i/W1swPYZg==}
dev: true
- /ieee754/1.2.1:
+ /ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
- dev: true
+ requiresBuild: true
- /iferr/0.1.5:
- resolution: {integrity: sha1-xg7taebY/bazEEofy8ocGS3FtQE=}
+ /iferr@0.1.5:
+ resolution: {integrity: sha512-DUNFN5j7Tln0D+TxzloUjKB+CtVu6myn0JEFak6dG18mNt9YkQ6lzGCdafwofISZ1lLF3xRHJ98VKy9ynkcFaA==}
dev: true
- /ignore-by-default/2.0.0:
- resolution: {integrity: sha512-+mQSgMRiFD3L3AOxLYOCxjIq4OnAmo5CIuC+lj5ehCJcPtV++QacEV7FdpzvYxH6DaOySWzQU6RR0lPLy37ckA==}
+ /ignore-by-default@2.1.0:
+ resolution: {integrity: sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw==}
engines: {node: '>=10 <11 || >=12 <13 || >=14'}
dev: true
- /ignore/4.0.6:
+ /ignore@4.0.6:
resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==}
engines: {node: '>= 4'}
dev: true
- /ignore/5.1.8:
- resolution: {integrity: sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==}
+ /ignore@5.2.0:
+ resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==}
engines: {node: '>= 4'}
dev: true
- /immer/8.0.1:
- resolution: {integrity: sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==}
+ /ignore@5.2.4:
+ resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
+ engines: {node: '>= 4'}
dev: true
- /import-cwd/3.0.0:
- resolution: {integrity: sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==}
- engines: {node: '>=8'}
+ /ignore@5.3.0:
+ resolution: {integrity: sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==}
+ engines: {node: '>= 4'}
+ dev: true
+
+ /image-size@1.1.1:
+ resolution: {integrity: sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==}
+ engines: {node: '>=16.x'}
+ hasBin: true
dependencies:
- import-from: 3.0.0
+ queue: 6.0.2
dev: true
- /import-fresh/2.0.0:
- resolution: {integrity: sha1-2BNVwVYS04bGH53dOSLUMEgipUY=}
+ /immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+ dev: true
+
+ /immutable@4.1.0:
+ resolution: {integrity: sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==}
+ dev: true
+
+ /import-fresh@2.0.0:
+ resolution: {integrity: sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==}
engines: {node: '>=4'}
dependencies:
caller-path: 2.0.0
resolve-from: 3.0.0
dev: true
- /import-fresh/3.3.0:
+ /import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
dependencies:
@@ -13785,285 +12462,204 @@ packages:
resolve-from: 4.0.0
dev: true
- /import-from/3.0.0:
- resolution: {integrity: sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==}
- engines: {node: '>=8'}
- dependencies:
- resolve-from: 5.0.0
- dev: true
-
- /import-lazy/2.1.0:
- resolution: {integrity: sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=}
+ /import-lazy@2.1.0:
+ resolution: {integrity: sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A==}
engines: {node: '>=4'}
dev: true
- /import-lazy/4.0.0:
+ /import-lazy@4.0.0:
resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==}
engines: {node: '>=8'}
dev: true
- /import-local/2.0.0:
- resolution: {integrity: sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==}
- engines: {node: '>=6'}
- hasBin: true
- dependencies:
- pkg-dir: 3.0.0
- resolve-cwd: 2.0.0
- dev: true
-
- /import-local/3.0.2:
- resolution: {integrity: sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA==}
- engines: {node: '>=8'}
- hasBin: true
- dependencies:
- pkg-dir: 4.2.0
- resolve-cwd: 3.0.0
- dev: true
-
- /imurmurhash/0.1.4:
- resolution: {integrity: sha1-khi5srkoojixPcT7a21XbyMUU+o=}
+ /imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
dev: true
- /indent-string/4.0.0:
+ /indent-string@4.0.0:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
dev: true
- /indexes-of/1.0.1:
- resolution: {integrity: sha1-8w9xbI4r00bHtn0985FVZqfAVgc=}
+ /indent-string@5.0.0:
+ resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /indexes-of@1.0.1:
+ resolution: {integrity: sha512-bup+4tap3Hympa+JBJUG7XuOsdNQ6fxt0MHyXMKuLBKn0OqsTfvUxkUrroEX1+B2VsSHvCjiIcZVxRtYa4nllA==}
dev: true
- /infer-owner/1.0.4:
+ /infer-owner@1.0.4:
resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==}
dev: true
- /inflight/1.0.6:
- resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=}
+ /inflight@1.0.6:
+ resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
dependencies:
once: 1.4.0
wrappy: 1.0.2
- dev: true
- /inherits/2.0.1:
- resolution: {integrity: sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=}
+ /inherits@2.0.1:
+ resolution: {integrity: sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==}
dev: true
- /inherits/2.0.3:
- resolution: {integrity: sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=}
+ /inherits@2.0.3:
+ resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==}
dev: true
- /inherits/2.0.4:
+ /inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
- dev: true
- /ini/1.3.8:
+ /ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
- dev: true
+ requiresBuild: true
- /ini/2.0.0:
+ /ini@2.0.0:
resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==}
engines: {node: '>=10'}
dev: true
- /inline-style-parser/0.1.1:
- resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==}
+ /inline-chunk-html-plugin@1.1.1:
+ resolution: {integrity: sha512-6W1eGIj8z/Yla6xJx5il6jJfCxMZS3kVkbiLQThbbjdsDLRIWkUVmpnhfW2l6WAwCW+qfy0zoXVGBZM1E5XF3g==}
dev: true
- /inquirer/7.3.3:
- resolution: {integrity: sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==}
- engines: {node: '>=8.0.0'}
- dependencies:
- ansi-escapes: 4.3.2
- chalk: 4.1.2
- cli-cursor: 3.1.0
- cli-width: 3.0.0
- external-editor: 3.1.0
- figures: 3.2.0
- lodash: 4.17.21
- mute-stream: 0.0.8
- run-async: 2.4.1
- rxjs: 6.6.7
- string-width: 4.2.2
- strip-ansi: 6.0.0
- through: 2.3.8
- dev: true
-
- /internal-ip/4.3.0:
- resolution: {integrity: sha512-S1zBo1D6zcsyuC6PMmY5+55YMILQ9av8lotMx447Bq6SAgo/sDK6y6uUKmuYhW7eacnIhFfsPmCNYdDzsnnDCg==}
- engines: {node: '>=6'}
- dependencies:
- default-gateway: 4.2.0
- ipaddr.js: 1.9.1
- dev: true
-
- /internal-slot/1.0.2:
- resolution: {integrity: sha512-2cQNfwhAfJIkU4KZPkDI+Gj5yNNnbqi40W9Gge6dfnk4TocEVm00B3bdiL+JINrbGJil2TeHvM4rETGzk/f/0g==}
+ /internal-slot@1.0.6:
+ resolution: {integrity: sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==}
engines: {node: '>= 0.4'}
dependencies:
- es-abstract: 1.17.7
- has: 1.0.3
+ get-intrinsic: 1.2.2
+ hasown: 2.0.0
side-channel: 1.0.4
dev: true
- /internal-slot/1.0.3:
- resolution: {integrity: sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==}
- engines: {node: '>= 0.4'}
- dependencies:
- get-intrinsic: 1.1.1
- has: 1.0.3
- side-channel: 1.0.4
+ /invert-kv@3.0.1:
+ resolution: {integrity: sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw==}
+ engines: {node: '>=8'}
dev: true
- /interpret/1.4.0:
- resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==}
- engines: {node: '>= 0.10'}
+ /ip@1.1.8:
+ resolution: {integrity: sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==}
dev: true
- /interpret/2.2.0:
- resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
+ /ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
dev: true
- /invariant/2.2.4:
- resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
- dependencies:
- loose-envify: 1.4.0
- dev: true
-
- /ip-regex/2.1.0:
- resolution: {integrity: sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=}
- engines: {node: '>=4'}
- dev: true
-
- /ip/1.1.5:
- resolution: {integrity: sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=}
- dev: true
-
- /ipaddr.js/1.9.1:
- resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
- engines: {node: '>= 0.10'}
+ /ipaddr.js@2.0.1:
+ resolution: {integrity: sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==}
+ engines: {node: '>= 10'}
dev: true
- /irregular-plurals/3.3.0:
- resolution: {integrity: sha512-MVBLKUTangM3EfRPFROhmWQQKRDsrgI83J8GS3jXy+OwYqiR2/aoWndYQ5416jLE3uaGgLH7ncme3X9y09gZ3g==}
+ /irregular-plurals@3.5.0:
+ resolution: {integrity: sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==}
engines: {node: '>=8'}
dev: true
- /is-absolute-url/2.1.0:
- resolution: {integrity: sha1-UFMN+4T8yap9vnhS6Do3uTufKqY=}
+ /is-absolute-url@2.1.0:
+ resolution: {integrity: sha512-vOx7VprsKyllwjSkLV79NIhpyLfr3jAp7VaTCMXOJHu4m0Ew1CZ2fcjASwmV1jI3BWuWHB013M48eyeldk9gYg==}
engines: {node: '>=0.10.0'}
dev: true
- /is-absolute-url/3.0.3:
- resolution: {integrity: sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==}
- engines: {node: '>=8'}
+ /is-absolute@0.1.7:
+ resolution: {integrity: sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ is-relative: 0.1.3
dev: true
- /is-accessor-descriptor/0.1.6:
- resolution: {integrity: sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=}
+ /is-accessor-descriptor@0.1.6:
+ resolution: {integrity: sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==}
engines: {node: '>=0.10.0'}
dependencies:
kind-of: 3.2.2
dev: true
- /is-accessor-descriptor/1.0.0:
+ /is-accessor-descriptor@1.0.0:
resolution: {integrity: sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==}
engines: {node: '>=0.10.0'}
dependencies:
kind-of: 6.0.3
dev: true
- /is-alphabetical/1.0.4:
- resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==}
- dev: true
-
- /is-alphanumerical/1.0.4:
- resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==}
+ /is-array-buffer@3.0.2:
+ resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==}
dependencies:
- is-alphabetical: 1.0.4
- is-decimal: 1.0.4
+ call-bind: 1.0.5
+ get-intrinsic: 1.2.2
+ is-typed-array: 1.1.12
dev: true
- /is-arguments/1.1.1:
- resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==}
- engines: {node: '>= 0.4'}
- dependencies:
- call-bind: 1.0.2
- has-tostringtag: 1.0.0
+ /is-arrayish@0.2.1:
+ resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
dev: true
- /is-arrayish/0.2.1:
- resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=}
+ /is-arrayish@0.3.2:
+ resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
dev: true
- /is-arrayish/0.3.2:
- resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
+ /is-async-function@2.0.0:
+ resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-tostringtag: 1.0.0
dev: true
- /is-bigint/1.0.4:
+ /is-bigint@1.0.4:
resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==}
dependencies:
- has-bigints: 1.0.1
+ has-bigints: 1.0.2
dev: true
- /is-binary-path/1.0.1:
- resolution: {integrity: sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=}
+ /is-binary-path@1.0.1:
+ resolution: {integrity: sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q==}
engines: {node: '>=0.10.0'}
+ requiresBuild: true
dependencies:
binary-extensions: 1.13.1
dev: true
+ optional: true
- /is-binary-path/2.1.0:
+ /is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
dependencies:
binary-extensions: 2.2.0
- dev: true
- /is-boolean-object/1.1.2:
+ /is-boolean-object@1.1.2:
resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
+ call-bind: 1.0.5
has-tostringtag: 1.0.0
dev: true
- /is-buffer/1.1.6:
+ /is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
dev: true
- /is-buffer/2.0.5:
- resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
- engines: {node: '>=4'}
- dev: true
-
- /is-callable/1.2.2:
- resolution: {integrity: sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==}
- engines: {node: '>= 0.4'}
- dev: true
-
- /is-callable/1.2.4:
- resolution: {integrity: sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==}
+ /is-callable@1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
dev: true
- /is-ci/2.0.0:
+ /is-ci@2.0.0:
resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==}
- hasBin: true
dependencies:
ci-info: 2.0.0
dev: true
- /is-ci/3.0.0:
- resolution: {integrity: sha512-kDXyttuLeslKAHYL/K28F2YkM3x5jvFPEw3yXbRptXydjD9rpLEz+C5K5iutY9ZiUu6AP41JdvRQwF4Iqs4ZCQ==}
+ /is-ci@3.0.1:
+ resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==}
hasBin: true
dependencies:
- ci-info: 3.2.0
+ ci-info: 3.9.0
dev: true
- /is-color-stop/1.1.0:
- resolution: {integrity: sha1-z/9HGu5N1cnhWFmPvhKWe1za00U=}
+ /is-color-stop@1.1.0:
+ resolution: {integrity: sha512-H1U8Vz0cfXNujrJzEcvvwMDW9Ra+biSYA3ThdQvAnMLJkEHQXn6bWzLkxHtVYJ+Sdbx0b6finn3jZiaVe7MAHA==}
dependencies:
css-color-names: 0.0.4
hex-color-regex: 1.1.0
@@ -14073,44 +12669,33 @@ packages:
rgba-regex: 1.0.0
dev: true
- /is-core-module/2.2.0:
- resolution: {integrity: sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==}
+ /is-core-module@2.13.1:
+ resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==}
dependencies:
- has: 1.0.3
- dev: true
+ hasown: 2.0.0
- /is-core-module/2.6.0:
- resolution: {integrity: sha512-wShG8vs60jKfPWpF2KZRaAtvt3a20OAn7+IJ6hLPECpSABLcKtFKTTI4ZtH5QcBruBHlq+WsdHWyz0BCZW7svQ==}
- dependencies:
- has: 1.0.3
- dev: true
-
- /is-data-descriptor/0.1.4:
- resolution: {integrity: sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=}
+ /is-data-descriptor@0.1.4:
+ resolution: {integrity: sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==}
engines: {node: '>=0.10.0'}
dependencies:
kind-of: 3.2.2
dev: true
- /is-data-descriptor/1.0.0:
+ /is-data-descriptor@1.0.0:
resolution: {integrity: sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==}
engines: {node: '>=0.10.0'}
dependencies:
kind-of: 6.0.3
dev: true
- /is-date-object/1.0.5:
+ /is-date-object@1.0.5:
resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==}
engines: {node: '>= 0.4'}
dependencies:
has-tostringtag: 1.0.0
dev: true
- /is-decimal/1.0.4:
- resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==}
- dev: true
-
- /is-descriptor/0.1.6:
+ /is-descriptor@0.1.6:
resolution: {integrity: sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -14119,7 +12704,7 @@ packages:
kind-of: 5.1.0
dev: true
- /is-descriptor/1.0.2:
+ /is-descriptor@1.0.2:
resolution: {integrity: sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -14128,102 +12713,70 @@ packages:
kind-of: 6.0.3
dev: true
- /is-directory/0.3.1:
- resolution: {integrity: sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=}
+ /is-directory@0.3.1:
+ resolution: {integrity: sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw==}
engines: {node: '>=0.10.0'}
dev: true
- /is-docker/2.2.1:
+ /is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
- hasBin: true
- dev: true
-
- /is-dom/1.1.0:
- resolution: {integrity: sha512-u82f6mvhYxRPKpw8V1N0W8ce1xXwOrQtgGcxl6UCL5zBmZu3is/18K0rR7uFCnMDuAsS/3W54mGL4vsaFUQlEQ==}
- dependencies:
- is-object: 1.0.2
- is-window: 1.0.2
- dev: true
-
- /is-error/2.2.2:
- resolution: {integrity: sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==}
dev: true
- /is-extendable/0.1.1:
- resolution: {integrity: sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=}
+ /is-extendable@0.1.1:
+ resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
engines: {node: '>=0.10.0'}
dev: true
- /is-extendable/1.0.1:
+ /is-extendable@1.0.1:
resolution: {integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==}
engines: {node: '>=0.10.0'}
dependencies:
is-plain-object: 2.0.4
dev: true
- /is-extglob/1.0.0:
- resolution: {integrity: sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=}
+ /is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
- dev: true
-
- /is-extglob/2.1.1:
- resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=}
- engines: {node: '>=0.10.0'}
- dev: true
- /is-fullwidth-code-point/1.0.0:
- resolution: {integrity: sha1-754xOG8DGn8NZDr4L95QxFfvAMs=}
- engines: {node: '>=0.10.0'}
+ /is-finalizationregistry@1.0.2:
+ resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==}
dependencies:
- number-is-nan: 1.0.1
+ call-bind: 1.0.5
dev: true
- /is-fullwidth-code-point/2.0.0:
- resolution: {integrity: sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=}
- engines: {node: '>=4'}
- dev: true
-
- /is-fullwidth-code-point/3.0.0:
+ /is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
- dev: true
-
- /is-function/1.0.2:
- resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==}
- dev: true
- /is-generator-fn/2.1.0:
- resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==}
- engines: {node: '>=6'}
+ /is-fullwidth-code-point@4.0.0:
+ resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==}
+ engines: {node: '>=12'}
dev: true
- /is-glob/2.0.1:
- resolution: {integrity: sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=}
- engines: {node: '>=0.10.0'}
+ /is-generator-function@1.0.10:
+ resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==}
+ engines: {node: '>= 0.4'}
dependencies:
- is-extglob: 1.0.0
+ has-tostringtag: 1.0.0
dev: true
- /is-glob/3.1.0:
- resolution: {integrity: sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=}
+ /is-glob@3.1.0:
+ resolution: {integrity: sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==}
engines: {node: '>=0.10.0'}
+ requiresBuild: true
dependencies:
is-extglob: 2.1.1
dev: true
+ optional: true
- /is-glob/4.0.1:
- resolution: {integrity: sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==}
+ /is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
dependencies:
is-extglob: 2.1.1
- dev: true
- /is-hexadecimal/1.0.4:
- resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==}
- dev: true
-
- /is-installed-globally/0.4.0:
+ /is-installed-globally@0.4.0:
resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==}
engines: {node: '>=10'}
dependencies:
@@ -14231,879 +12784,408 @@ packages:
is-path-inside: 3.0.3
dev: true
- /is-interactive/1.0.0:
+ /is-interactive@1.0.0:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'}
dev: true
- /is-map/2.0.2:
+ /is-map@2.0.2:
resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==}
dev: true
- /is-module/1.0.0:
- resolution: {integrity: sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=}
+ /is-mergeable-object@1.1.1:
+ resolution: {integrity: sha512-CPduJfuGg8h8vW74WOxHtHmtQutyQBzR+3MjQ6iDHIYdbOnm1YC7jv43SqCoU8OPGTJD4nibmiryA4kmogbGrA==}
dev: true
- /is-negative-zero/2.0.1:
- resolution: {integrity: sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==}
+ /is-module@1.0.0:
+ resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+ dev: true
+
+ /is-negative-zero@2.0.2:
+ resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==}
engines: {node: '>= 0.4'}
dev: true
- /is-npm/5.0.0:
+ /is-npm@5.0.0:
resolution: {integrity: sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA==}
engines: {node: '>=10'}
dev: true
- /is-number-object/1.0.6:
- resolution: {integrity: sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==}
+ /is-npm@6.0.0:
+ resolution: {integrity: sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dev: true
+
+ /is-number-object@1.0.7:
+ resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==}
engines: {node: '>= 0.4'}
dependencies:
has-tostringtag: 1.0.0
dev: true
- /is-number/3.0.0:
- resolution: {integrity: sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=}
+ /is-number@3.0.0:
+ resolution: {integrity: sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==}
engines: {node: '>=0.10.0'}
dependencies:
kind-of: 3.2.2
dev: true
- /is-number/7.0.0:
+ /is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
- dev: true
- /is-obj/1.0.1:
- resolution: {integrity: sha1-PkcprB9f3gJc19g6iW2rn09n2w8=}
+ /is-obj@1.0.1:
+ resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==}
engines: {node: '>=0.10.0'}
dev: true
- /is-obj/2.0.0:
+ /is-obj@2.0.0:
resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==}
engines: {node: '>=8'}
dev: true
- /is-object/1.0.2:
- resolution: {integrity: sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==}
- dev: true
-
- /is-path-cwd/2.2.0:
- resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==}
- engines: {node: '>=6'}
- dev: true
-
- /is-path-in-cwd/2.1.0:
- resolution: {integrity: sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==}
- engines: {node: '>=6'}
- dependencies:
- is-path-inside: 2.1.0
- dev: true
-
- /is-path-inside/2.1.0:
- resolution: {integrity: sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==}
- engines: {node: '>=6'}
- dependencies:
- path-is-inside: 1.0.2
- dev: true
-
- /is-path-inside/3.0.3:
+ /is-path-inside@3.0.3:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
dev: true
- /is-plain-obj/2.1.0:
+ /is-plain-obj@2.1.0:
resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
engines: {node: '>=8'}
dev: true
- /is-plain-object/2.0.4:
+ /is-plain-obj@3.0.0:
+ resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /is-plain-object@2.0.4:
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
engines: {node: '>=0.10.0'}
dependencies:
isobject: 3.0.1
dev: true
- /is-plain-object/3.0.1:
- resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==}
- engines: {node: '>=0.10.0'}
- dev: true
-
- /is-plain-object/5.0.0:
+ /is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
dev: true
- /is-potential-custom-element-name/1.0.1:
- resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
- dev: true
-
- /is-promise/4.0.0:
+ /is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
dev: true
- /is-reference/1.2.1:
- resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
- dependencies:
- '@types/estree': 0.0.50
- dev: true
-
- /is-regex/1.1.1:
- resolution: {integrity: sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==}
- engines: {node: '>= 0.4'}
- dependencies:
- has-symbols: 1.0.1
- dev: true
-
- /is-regex/1.1.4:
+ /is-regex@1.1.4:
resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
+ call-bind: 1.0.5
has-tostringtag: 1.0.0
dev: true
- /is-regexp/1.0.0:
- resolution: {integrity: sha1-/S2INUXEa6xaYz57mgnof6LLUGk=}
+ /is-regexp@1.0.0:
+ resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==}
engines: {node: '>=0.10.0'}
dev: true
- /is-resolvable/1.1.0:
- resolution: {integrity: sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==}
+ /is-relative@0.1.3:
+ resolution: {integrity: sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA==}
+ engines: {node: '>=0.10.0'}
dev: true
- /is-root/2.1.0:
- resolution: {integrity: sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==}
- engines: {node: '>=6'}
+ /is-resolvable@1.1.0:
+ resolution: {integrity: sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==}
dev: true
- /is-set/2.0.2:
+ /is-set@2.0.2:
resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==}
dev: true
- /is-stream/1.1.0:
- resolution: {integrity: sha1-EtSj3U5o4Lec6428hBc66A2RykQ=}
- engines: {node: '>=0.10.0'}
- dev: true
-
- /is-stream/2.0.0:
- resolution: {integrity: sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==}
- engines: {node: '>=8'}
+ /is-shared-array-buffer@1.0.2:
+ resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==}
+ dependencies:
+ call-bind: 1.0.5
dev: true
- /is-stream/2.0.1:
+ /is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
dev: true
- /is-string/1.0.5:
- resolution: {integrity: sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==}
- engines: {node: '>= 0.4'}
+ /is-stream@3.0.0:
+ resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
- /is-string/1.0.7:
+ /is-string@1.0.7:
resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==}
engines: {node: '>= 0.4'}
dependencies:
has-tostringtag: 1.0.0
dev: true
- /is-subset/0.1.1:
- resolution: {integrity: sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=}
+ /is-symbol@1.0.4:
+ resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-symbols: 1.0.3
dev: true
- /is-symbol/1.0.4:
- resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==}
+ /is-typed-array@1.1.12:
+ resolution: {integrity: sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==}
engines: {node: '>= 0.4'}
dependencies:
- has-symbols: 1.0.2
+ which-typed-array: 1.1.13
dev: true
- /is-typedarray/1.0.0:
- resolution: {integrity: sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=}
+ /is-typedarray@1.0.0:
+ resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==}
dev: true
- /is-unicode-supported/0.1.0:
+ /is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
dev: true
- /is-whitespace-character/1.0.4:
- resolution: {integrity: sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w==}
+ /is-unicode-supported@2.0.0:
+ resolution: {integrity: sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==}
+ engines: {node: '>=18'}
dev: true
- /is-window/1.0.2:
- resolution: {integrity: sha1-LIlspT25feRdPDMTOmXYyfVjSA0=}
+ /is-utf8@0.2.1:
+ resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==}
dev: true
- /is-windows/1.0.2:
- resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
- engines: {node: '>=0.10.0'}
+ /is-weakmap@2.0.1:
+ resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==}
dev: true
- /is-word-character/1.0.4:
- resolution: {integrity: sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA==}
+ /is-weakref@1.0.2:
+ resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==}
+ dependencies:
+ call-bind: 1.0.5
+ dev: true
+
+ /is-weakset@2.0.2:
+ resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==}
+ dependencies:
+ call-bind: 1.0.5
+ get-intrinsic: 1.2.2
dev: true
- /is-wsl/1.1.0:
- resolution: {integrity: sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=}
+ /is-windows@1.0.2:
+ resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /is-wsl@1.1.0:
+ resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==}
engines: {node: '>=4'}
dev: true
- /is-wsl/2.2.0:
+ /is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
dependencies:
is-docker: 2.2.1
dev: true
- /is-yarn-global/0.3.0:
+ /is-yarn-global@0.3.0:
resolution: {integrity: sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==}
dev: true
- /isarray/1.0.0:
- resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=}
+ /is-yarn-global@0.4.1:
+ resolution: {integrity: sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /isarray@1.0.0:
+ resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
dev: true
- /isarray/2.0.5:
+ /isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
dev: true
- /isexe/2.0.0:
- resolution: {integrity: sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=}
+ /isexe@1.1.2:
+ resolution: {integrity: sha512-d2eJzK691yZwPHcv1LbeAOa91yMJ9QmfTgSO1oXB65ezVhXQsxBac2vEB4bMVms9cGzaA99n6V2viHMq82VLDw==}
dev: true
- /isobject/2.1.0:
- resolution: {integrity: sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=}
+ /isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ /isobject@2.1.0:
+ resolution: {integrity: sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==}
engines: {node: '>=0.10.0'}
dependencies:
isarray: 1.0.0
dev: true
- /isobject/3.0.1:
- resolution: {integrity: sha1-TkMekrEalzFjaqH5yNHMvP2reN8=}
- engines: {node: '>=0.10.0'}
- dev: true
-
- /isobject/4.0.0:
- resolution: {integrity: sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==}
+ /isobject@3.0.1:
+ resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
engines: {node: '>=0.10.0'}
dev: true
- /isomorphic-unfetch/3.1.0:
+ /isomorphic-unfetch@3.1.0:
resolution: {integrity: sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==}
dependencies:
- node-fetch: 2.6.1
+ node-fetch: 2.6.7
unfetch: 4.2.0
+ transitivePeerDependencies:
+ - encoding
+ dev: true
+
+ /isstream@0.1.2:
+ resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==}
dev: true
- /isstream/0.1.2:
- resolution: {integrity: sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=}
+ /istanbul-lib-coverage@3.2.0:
+ resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==}
+ engines: {node: '>=8'}
dev: true
- /istanbul-lib-coverage/3.0.0:
- resolution: {integrity: sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==}
+ /istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
dev: true
- /istanbul-lib-hook/3.0.0:
+ /istanbul-lib-hook@3.0.0:
resolution: {integrity: sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==}
engines: {node: '>=8'}
dependencies:
append-transform: 2.0.0
dev: true
- /istanbul-lib-instrument/4.0.3:
+ /istanbul-lib-instrument@4.0.3:
resolution: {integrity: sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==}
engines: {node: '>=8'}
dependencies:
- '@babel/core': 7.15.0
+ '@babel/core': 7.18.9
'@istanbuljs/schema': 0.1.3
- istanbul-lib-coverage: 3.0.0
- semver: 6.3.0
+ istanbul-lib-coverage: 3.2.0
+ semver: 6.3.1
transitivePeerDependencies:
- supports-color
dev: true
- /istanbul-lib-processinfo/2.0.2:
- resolution: {integrity: sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==}
+ /istanbul-lib-processinfo@2.0.3:
+ resolution: {integrity: sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==}
engines: {node: '>=8'}
dependencies:
archy: 1.0.0
cross-spawn: 7.0.3
- istanbul-lib-coverage: 3.0.0
- make-dir: 3.1.0
+ istanbul-lib-coverage: 3.2.0
p-map: 3.0.0
rimraf: 3.0.2
- uuid: 3.4.0
+ uuid: 8.3.2
dev: true
- /istanbul-lib-report/3.0.0:
+ /istanbul-lib-report@3.0.0:
resolution: {integrity: sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==}
engines: {node: '>=8'}
dependencies:
- istanbul-lib-coverage: 3.0.0
+ istanbul-lib-coverage: 3.2.0
make-dir: 3.1.0
supports-color: 7.2.0
dev: true
- /istanbul-lib-source-maps/4.0.0:
- resolution: {integrity: sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==}
- engines: {node: '>=8'}
+ /istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
dependencies:
- debug: 4.3.2
- istanbul-lib-coverage: 3.0.0
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+ dev: true
+
+ /istanbul-lib-source-maps@4.0.1:
+ resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==}
+ engines: {node: '>=10'}
+ dependencies:
+ debug: 4.3.4
+ istanbul-lib-coverage: 3.2.0
source-map: 0.6.1
transitivePeerDependencies:
- supports-color
dev: true
- /istanbul-reports/3.0.2:
- resolution: {integrity: sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==}
+ /istanbul-reports@3.1.5:
+ resolution: {integrity: sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==}
engines: {node: '>=8'}
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.0
dev: true
- /iterate-iterator/1.0.1:
- resolution: {integrity: sha512-3Q6tudGN05kbkDQDI4CqjaBf4qf85w6W6GnuZDtUVYwKgtC1q8yxYX7CZed7N+tLzQqS6roujWvszf13T+n9aw==}
- dev: true
-
- /iterate-value/1.0.2:
- resolution: {integrity: sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==}
- dependencies:
- es-get-iterator: 1.1.2
- iterate-iterator: 1.0.1
- dev: true
-
- /jed/1.1.1:
- resolution: {integrity: sha1-elSbvZ/+FYWwzQoZHiAwVb7ldLQ=}
-
- /jest-changed-files/26.6.2:
- resolution: {integrity: sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@jest/types': 26.6.2
- execa: 4.1.0
- throat: 5.0.0
- dev: true
-
- /jest-cli/26.6.3:
- resolution: {integrity: sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg==}
- engines: {node: '>= 10.14.2'}
- hasBin: true
- dependencies:
- '@jest/core': 26.6.3
- '@jest/test-result': 26.6.2
- '@jest/types': 26.6.2
- chalk: 4.1.2
- exit: 0.1.2
- graceful-fs: 4.2.8
- import-local: 3.0.2
- is-ci: 2.0.0
- jest-config: 26.6.3
- jest-util: 26.6.2
- jest-validate: 26.6.2
- prompts: 2.4.1
- yargs: 15.4.1
- transitivePeerDependencies:
- - bufferutil
- - canvas
- - supports-color
- - ts-node
- - utf-8-validate
- dev: true
-
- /jest-config/26.6.3:
- resolution: {integrity: sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg==}
- engines: {node: '>= 10.14.2'}
- peerDependencies:
- ts-node: '>=9.0.0'
- peerDependenciesMeta:
- ts-node:
- optional: true
- dependencies:
- '@babel/core': 7.15.0
- '@jest/test-sequencer': 26.6.3
- '@jest/types': 26.6.2
- babel-jest: 26.6.3_@babel+core@7.15.0
- chalk: 4.1.2
- deepmerge: 4.2.2
- glob: 7.1.7
- graceful-fs: 4.2.8
- jest-environment-jsdom: 26.6.2
- jest-environment-node: 26.6.2
- jest-get-type: 26.3.0
- jest-jasmine2: 26.6.3
- jest-regex-util: 26.0.0
- jest-resolve: 26.6.2
- jest-util: 26.6.2
- jest-validate: 26.6.2
- micromatch: 4.0.4
- pretty-format: 26.6.2
- transitivePeerDependencies:
- - bufferutil
- - canvas
- - supports-color
- - utf-8-validate
- dev: true
-
- /jest-diff/26.6.2:
- resolution: {integrity: sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- chalk: 4.1.2
- diff-sequences: 26.6.2
- jest-get-type: 26.3.0
- pretty-format: 26.6.2
- dev: true
-
- /jest-docblock/26.0.0:
- resolution: {integrity: sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- detect-newline: 3.1.0
- dev: true
-
- /jest-each/26.6.2:
- resolution: {integrity: sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A==}
- engines: {node: '>= 10.14.2'}
+ /istanbul-reports@3.1.6:
+ resolution: {integrity: sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==}
+ engines: {node: '>=8'}
dependencies:
- '@jest/types': 26.6.2
- chalk: 4.1.2
- jest-get-type: 26.3.0
- jest-util: 26.6.2
- pretty-format: 26.6.2
- dev: true
-
- /jest-environment-jsdom/26.6.2:
- resolution: {integrity: sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@jest/environment': 26.6.2
- '@jest/fake-timers': 26.6.2
- '@jest/types': 26.6.2
- '@types/node': 14.17.10
- jest-mock: 26.6.2
- jest-util: 26.6.2
- jsdom: 16.7.0
- transitivePeerDependencies:
- - bufferutil
- - canvas
- - supports-color
- - utf-8-validate
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
dev: true
- /jest-environment-node/26.6.2:
- resolution: {integrity: sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag==}
- engines: {node: '>= 10.14.2'}
+ /iterator.prototype@1.1.2:
+ resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==}
dependencies:
- '@jest/environment': 26.6.2
- '@jest/fake-timers': 26.6.2
- '@jest/types': 26.6.2
- '@types/node': 14.17.10
- jest-mock: 26.6.2
- jest-util: 26.6.2
- dev: true
-
- /jest-get-type/26.3.0:
- resolution: {integrity: sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==}
- engines: {node: '>= 10.14.2'}
+ define-properties: 1.2.1
+ get-intrinsic: 1.2.2
+ has-symbols: 1.0.3
+ reflect.getprototypeof: 1.0.4
+ set-function-name: 2.0.1
dev: true
- /jest-haste-map/26.6.2:
- resolution: {integrity: sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w==}
- engines: {node: '>= 10.14.2'}
+ /jackspeak@2.3.6:
+ resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==}
+ engines: {node: '>=14'}
dependencies:
- '@jest/types': 26.6.2
- '@types/graceful-fs': 4.1.5
- '@types/node': 14.17.10
- anymatch: 3.1.2
- fb-watchman: 2.0.1
- graceful-fs: 4.2.8
- jest-regex-util: 26.0.0
- jest-serializer: 26.6.2
- jest-util: 26.6.2
- jest-worker: 26.6.2
- micromatch: 4.0.4
- sane: 4.1.0
- walker: 1.0.7
+ '@isaacs/cliui': 8.0.2
optionalDependencies:
- fsevents: 2.3.2
- dev: true
-
- /jest-jasmine2/26.6.3:
- resolution: {integrity: sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@babel/traverse': 7.15.0
- '@jest/environment': 26.6.2
- '@jest/source-map': 26.6.2
- '@jest/test-result': 26.6.2
- '@jest/types': 26.6.2
- '@types/node': 14.17.10
- chalk: 4.1.2
- co: 4.6.0
- expect: 26.6.2
- is-generator-fn: 2.1.0
- jest-each: 26.6.2
- jest-matcher-utils: 26.6.2
- jest-message-util: 26.6.2
- jest-runtime: 26.6.3
- jest-snapshot: 26.6.2
- jest-util: 26.6.2
- pretty-format: 26.6.2
- throat: 5.0.0
- transitivePeerDependencies:
- - bufferutil
- - canvas
- - supports-color
- - ts-node
- - utf-8-validate
- dev: true
-
- /jest-leak-detector/26.6.2:
- resolution: {integrity: sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- jest-get-type: 26.3.0
- pretty-format: 26.6.2
- dev: true
-
- /jest-matcher-utils/26.6.2:
- resolution: {integrity: sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- chalk: 4.1.2
- jest-diff: 26.6.2
- jest-get-type: 26.3.0
- pretty-format: 26.6.2
- dev: true
-
- /jest-message-util/26.6.2:
- resolution: {integrity: sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@babel/code-frame': 7.14.5
- '@jest/types': 26.6.2
- '@types/stack-utils': 2.0.1
- chalk: 4.1.2
- graceful-fs: 4.2.8
- micromatch: 4.0.4
- pretty-format: 26.6.2
- slash: 3.0.0
- stack-utils: 2.0.3
- dev: true
-
- /jest-message-util/27.1.0:
- resolution: {integrity: sha512-Eck8NFnJ5Sg36R9XguD65cf2D5+McC+NF5GIdEninoabcuoOfWrID5qJhufq5FB0DRKoiyxB61hS7MKoMD0trQ==}
- engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
- dependencies:
- '@babel/code-frame': 7.14.5
- '@jest/types': 27.1.0
- '@types/stack-utils': 2.0.1
- chalk: 4.1.2
- graceful-fs: 4.2.8
- micromatch: 4.0.4
- pretty-format: 27.1.0
- slash: 3.0.0
- stack-utils: 2.0.3
- dev: true
-
- /jest-mock/26.6.2:
- resolution: {integrity: sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@jest/types': 26.6.2
- '@types/node': 14.17.10
- dev: true
-
- /jest-pnp-resolver/1.2.2_jest-resolve@26.6.2:
- resolution: {integrity: sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==}
- engines: {node: '>=6'}
- peerDependencies:
- jest-resolve: '*'
- peerDependenciesMeta:
- jest-resolve:
- optional: true
- dependencies:
- jest-resolve: 26.6.2
- dev: true
-
- /jest-preset-preact/4.0.2_9b3f24ae35a87c3c82fffbe3fdf70e1e:
- resolution: {integrity: sha512-Grgu1scmHcNcU9pKOS4FX8pVPxfqmlKCc6SWkOEg17JiBhvYjVdyxsPw22v/P98iYc6Y+357JSoh5f0lyASr1Q==}
- peerDependencies:
- jest: 26.x
- preact: 10.x
- preact-render-to-string: 5.x
- dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-react-jsx': 7.14.9_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- babel-jest: 26.6.3_@babel+core@7.15.0
- identity-obj-proxy: 3.0.0
- isomorphic-unfetch: 3.1.0
- jest: 26.6.3
- jest-watch-typeahead: 0.6.4_jest@26.6.3
- preact: 10.5.14
- preact-render-to-string: 5.1.19_preact@10.5.14
- transitivePeerDependencies:
- - supports-color
- dev: true
-
- /jest-regex-util/26.0.0:
- resolution: {integrity: sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==}
- engines: {node: '>= 10.14.2'}
- dev: true
-
- /jest-regex-util/27.0.6:
- resolution: {integrity: sha512-SUhPzBsGa1IKm8hx2F4NfTGGp+r7BXJ4CulsZ1k2kI+mGLG+lxGrs76veN2LF/aUdGosJBzKgXmNCw+BzFqBDQ==}
- engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
- dev: true
-
- /jest-resolve-dependencies/26.6.3:
- resolution: {integrity: sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@jest/types': 26.6.2
- jest-regex-util: 26.0.0
- jest-snapshot: 26.6.2
- dev: true
-
- /jest-resolve/26.6.2:
- resolution: {integrity: sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@jest/types': 26.6.2
- chalk: 4.1.2
- graceful-fs: 4.2.8
- jest-pnp-resolver: 1.2.2_jest-resolve@26.6.2
- jest-util: 26.6.2
- read-pkg-up: 7.0.1
- resolve: 1.20.0
- slash: 3.0.0
- dev: true
-
- /jest-runner/26.6.3:
- resolution: {integrity: sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@jest/console': 26.6.2
- '@jest/environment': 26.6.2
- '@jest/test-result': 26.6.2
- '@jest/types': 26.6.2
- '@types/node': 14.17.10
- chalk: 4.1.2
- emittery: 0.7.2
- exit: 0.1.2
- graceful-fs: 4.2.8
- jest-config: 26.6.3
- jest-docblock: 26.0.0
- jest-haste-map: 26.6.2
- jest-leak-detector: 26.6.2
- jest-message-util: 26.6.2
- jest-resolve: 26.6.2
- jest-runtime: 26.6.3
- jest-util: 26.6.2
- jest-worker: 26.6.2
- source-map-support: 0.5.19
- throat: 5.0.0
- transitivePeerDependencies:
- - bufferutil
- - canvas
- - supports-color
- - ts-node
- - utf-8-validate
- dev: true
-
- /jest-runtime/26.6.3:
- resolution: {integrity: sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw==}
- engines: {node: '>= 10.14.2'}
- hasBin: true
- dependencies:
- '@jest/console': 26.6.2
- '@jest/environment': 26.6.2
- '@jest/fake-timers': 26.6.2
- '@jest/globals': 26.6.2
- '@jest/source-map': 26.6.2
- '@jest/test-result': 26.6.2
- '@jest/transform': 26.6.2
- '@jest/types': 26.6.2
- '@types/yargs': 15.0.14
- chalk: 4.1.2
- cjs-module-lexer: 0.6.0
- collect-v8-coverage: 1.0.1
- exit: 0.1.2
- glob: 7.1.7
- graceful-fs: 4.2.8
- jest-config: 26.6.3
- jest-haste-map: 26.6.2
- jest-message-util: 26.6.2
- jest-mock: 26.6.2
- jest-regex-util: 26.0.0
- jest-resolve: 26.6.2
- jest-snapshot: 26.6.2
- jest-util: 26.6.2
- jest-validate: 26.6.2
- slash: 3.0.0
- strip-bom: 4.0.0
- yargs: 15.4.1
- transitivePeerDependencies:
- - bufferutil
- - canvas
- - supports-color
- - ts-node
- - utf-8-validate
- dev: true
-
- /jest-serializer/26.6.2:
- resolution: {integrity: sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@types/node': 14.17.10
- graceful-fs: 4.2.8
- dev: true
-
- /jest-snapshot/26.6.2:
- resolution: {integrity: sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@babel/types': 7.15.0
- '@jest/types': 26.6.2
- '@types/babel__traverse': 7.14.2
- '@types/prettier': 2.3.2
- chalk: 4.1.2
- expect: 26.6.2
- graceful-fs: 4.2.8
- jest-diff: 26.6.2
- jest-get-type: 26.3.0
- jest-haste-map: 26.6.2
- jest-matcher-utils: 26.6.2
- jest-message-util: 26.6.2
- jest-resolve: 26.6.2
- natural-compare: 1.4.0
- pretty-format: 26.6.2
- semver: 7.3.5
- dev: true
-
- /jest-util/26.6.2:
- resolution: {integrity: sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@jest/types': 26.6.2
- '@types/node': 14.17.10
- chalk: 4.1.2
- graceful-fs: 4.2.8
- is-ci: 2.0.0
- micromatch: 4.0.4
- dev: true
-
- /jest-util/27.1.0:
- resolution: {integrity: sha512-edSLD2OneYDKC6gZM1yc+wY/877s/fuJNoM1k3sOEpzFyeptSmke3SLnk1dDHk9CgTA+58mnfx3ew3J11Kes/w==}
- engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
- dependencies:
- '@jest/types': 27.1.0
- '@types/node': 14.17.10
- chalk: 4.1.2
- graceful-fs: 4.2.8
- is-ci: 3.0.0
- picomatch: 2.3.0
- dev: true
-
- /jest-validate/26.6.2:
- resolution: {integrity: sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@jest/types': 26.6.2
- camelcase: 6.2.0
- chalk: 4.1.2
- jest-get-type: 26.3.0
- leven: 3.1.0
- pretty-format: 26.6.2
- dev: true
+ '@pkgjs/parseargs': 0.11.0
- /jest-watch-typeahead/0.6.4_jest@26.6.3:
- resolution: {integrity: sha512-tGxriteVJqonyrDj/xZHa0E2glKMiglMLQqISLCjxLUfeueRBh9VoRF2FKQyYO2xOqrWDTg7781zUejx411ZXA==}
+ /jake@10.8.5:
+ resolution: {integrity: sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==}
engines: {node: '>=10'}
- peerDependencies:
- jest: ^26.0.0 || ^27.0.0
dependencies:
- ansi-escapes: 4.3.2
+ async: 3.2.4
chalk: 4.1.2
- jest: 26.6.3
- jest-regex-util: 27.0.6
- jest-watcher: 27.1.0
- slash: 3.0.0
- string-length: 4.0.2
- strip-ansi: 6.0.0
+ filelist: 1.0.4
+ minimatch: 3.1.2
dev: true
- /jest-watcher/26.6.2:
- resolution: {integrity: sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ==}
- engines: {node: '>= 10.14.2'}
- dependencies:
- '@jest/test-result': 26.6.2
- '@jest/types': 26.6.2
- '@types/node': 14.17.10
- ansi-escapes: 4.3.2
- chalk: 4.1.2
- jest-util: 26.6.2
- string-length: 4.0.2
- dev: true
+ /jed@1.1.1:
+ resolution: {integrity: sha512-z35ZSEcXHxLW4yumw0dF6L464NT36vmx3wxJw8MDpraBcWuNVgUPZgPJKcu1HekNgwlMFNqol7i/IpSbjhqwqA==}
- /jest-watcher/27.1.0:
- resolution: {integrity: sha512-ivaWTrA46aHWdgPDgPypSHiNQjyKnLBpUIHeBaGg11U+pDzZpkffGlcB1l1a014phmG0mHgkOHtOgiqJQM6yKQ==}
- engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
- dependencies:
- '@jest/test-result': 27.1.0
- '@jest/types': 27.1.0
- '@types/node': 14.17.10
- ansi-escapes: 4.3.2
- chalk: 4.1.2
- jest-util: 27.1.0
- string-length: 4.0.2
- dev: true
-
- /jest-worker/26.6.2:
+ /jest-worker@26.6.2:
resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==}
engines: {node: '>= 10.13.0'}
dependencies:
- '@types/node': 14.17.10
+ '@types/node': 20.11.13
merge-stream: 2.0.0
supports-color: 7.2.0
dev: true
- /jest/26.6.3:
- resolution: {integrity: sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q==}
- engines: {node: '>= 10.14.2'}
+ /jiti@1.18.2:
+ resolution: {integrity: sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==}
hasBin: true
- dependencies:
- '@jest/core': 26.6.3
- import-local: 3.0.2
- jest-cli: 26.6.3
- transitivePeerDependencies:
- - bufferutil
- - canvas
- - supports-color
- - ts-node
- - utf-8-validate
+
+ /jose@4.13.1:
+ resolution: {integrity: sha512-MSJQC5vXco5Br38mzaQKiq9mwt7lwj2eXpgpRyQYNHYt2lq1PjkWa7DLXX0WVcQLE9HhMh3jPiufS7fhJf+CLQ==}
dev: true
- /jju/1.4.0:
- resolution: {integrity: sha1-o6vicYryQaKykE+EpiWXDzia4yo=}
+ /js-sdsl@4.1.5:
+ resolution: {integrity: sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q==}
dev: true
- /js-string-escape/1.0.1:
- resolution: {integrity: sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8=}
+ /js-string-escape@1.0.1:
+ resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==}
engines: {node: '>= 0.8'}
dev: true
- /js-tokens/4.0.0:
+ /js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
- /js-yaml/3.14.1:
+ /js-yaml@3.14.1:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
hasBin: true
dependencies:
@@ -15111,15 +13193,22 @@ packages:
esprima: 4.0.1
dev: true
- /jsbn/0.1.1:
- resolution: {integrity: sha1-peZUwuWi3rXyAdls77yoDA7y9RM=}
+ /js-yaml@4.1.0:
+ resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
+ hasBin: true
+ dependencies:
+ argparse: 2.0.1
+ dev: true
+
+ /jsbn@0.1.1:
+ resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==}
dev: true
- /jsdom/12.2.0:
+ /jsdom@12.2.0:
resolution: {integrity: sha512-QPOggIJ8fquWPLaYYMoh+zqUmdphDtu1ju0QGTitZT1Yd8I5qenPpXM1etzUegu3MjVp8XPzgZxdn8Yj7e40ig==}
engines: {node: '>=8'}
dependencies:
- abab: 2.0.5
+ abab: 2.0.6
acorn: 6.4.2
acorn-globals: 4.3.4
array-equal: 1.0.0
@@ -15129,11 +13218,11 @@ packages:
domexception: 1.0.1
escodegen: 1.14.3
html-encoding-sniffer: 1.0.2
- nwsapi: 2.2.0
+ nwsapi: 2.2.2
parse5: 5.1.0
pn: 1.1.0
request: 2.88.2
- request-promise-native: 1.0.9_request@2.88.2
+ request-promise-native: 1.0.9(request@2.88.2)
saxes: 3.1.11
symbol-tree: 3.2.4
tough-cookie: 2.5.0
@@ -15144,281 +13233,268 @@ packages:
whatwg-url: 7.1.0
ws: 6.2.2
xml-name-validator: 3.0.0
- dev: true
-
- /jsdom/16.7.0:
- resolution: {integrity: sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==}
- engines: {node: '>=10'}
- peerDependencies:
- canvas: ^2.5.0
- peerDependenciesMeta:
- canvas:
- optional: true
- dependencies:
- abab: 2.0.5
- acorn: 8.4.1
- acorn-globals: 6.0.0
- cssom: 0.4.4
- cssstyle: 2.3.0
- data-urls: 2.0.0
- decimal.js: 10.3.1
- domexception: 2.0.1
- escodegen: 2.0.0
- form-data: 3.0.1
- html-encoding-sniffer: 2.0.1
- http-proxy-agent: 4.0.1
- https-proxy-agent: 5.0.0
- is-potential-custom-element-name: 1.0.1
- nwsapi: 2.2.0
- parse5: 6.0.1
- saxes: 5.0.1
- symbol-tree: 3.2.4
- tough-cookie: 4.0.0
- w3c-hr-time: 1.0.2
- w3c-xmlserializer: 2.0.0
- webidl-conversions: 6.1.0
- whatwg-encoding: 1.0.5
- whatwg-mimetype: 2.3.0
- whatwg-url: 8.7.0
- ws: 7.5.3
- xml-name-validator: 3.0.0
transitivePeerDependencies:
- bufferutil
- - supports-color
- utf-8-validate
dev: true
- /jsesc/0.5.0:
- resolution: {integrity: sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=}
+ /jsesc@0.5.0:
+ resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==}
hasBin: true
dev: true
- /jsesc/2.5.2:
+ /jsesc@2.5.2:
resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==}
engines: {node: '>=4'}
hasBin: true
+
+ /json-buffer@3.0.0:
+ resolution: {integrity: sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==}
+ dev: true
+
+ /json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
dev: true
- /json-buffer/3.0.0:
- resolution: {integrity: sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=}
+ /json-merge-patch@1.0.2:
+ resolution: {integrity: sha512-M6Vp2GN9L7cfuMXiWOmHj9bEFbeC250iVtcKQbqVgEsDVYnIsrNsbU+h/Y/PkbBQCtEa4Bez+Ebv0zfbC8ObLg==}
+ dependencies:
+ fast-deep-equal: 3.1.3
dev: true
- /json-parse-better-errors/1.0.2:
+ /json-parse-better-errors@1.0.2:
resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==}
dev: true
- /json-parse-even-better-errors/2.3.1:
+ /json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
dev: true
- /json-schema-traverse/0.4.1:
+ /json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
dev: true
- /json-schema-traverse/1.0.0:
+ /json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
dev: true
- /json-schema/0.2.3:
- resolution: {integrity: sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=}
+ /json-schema@0.4.0:
+ resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
dev: true
- /json-schema/0.3.0:
- resolution: {integrity: sha512-TYfxx36xfl52Rf1LU9HyWSLGPdYLL+SQ8/E/0yVyKG8wCCDaSrhPap0vEdlsZWRaS6tnKKLPGiEJGiREVC8kxQ==}
+ /json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
dev: true
- /json-stable-stringify-without-jsonify/1.0.1:
- resolution: {integrity: sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=}
+ /json-stringify-safe@5.0.1:
+ resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
dev: true
- /json-stringify-safe/5.0.1:
- resolution: {integrity: sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=}
- dev: true
-
- /json3/3.3.3:
- resolution: {integrity: sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA==}
- dev: true
-
- /json5/0.5.1:
- resolution: {integrity: sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=}
+ /json5@0.5.1:
+ resolution: {integrity: sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw==}
hasBin: true
dev: true
- /json5/1.0.1:
- resolution: {integrity: sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==}
+ /json5@1.0.2:
+ resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
hasBin: true
dependencies:
- minimist: 1.2.5
+ minimist: 1.2.7
dev: true
- /json5/2.1.3:
- resolution: {integrity: sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==}
+ /json5@2.2.1:
+ resolution: {integrity: sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==}
engines: {node: '>=6'}
- hasBin: true
- dependencies:
- minimist: 1.2.5
dev: true
- /json5/2.2.0:
- resolution: {integrity: sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==}
+ /json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
hasBin: true
- dependencies:
- minimist: 1.2.5
- dev: true
- /jsonfile/2.4.0:
- resolution: {integrity: sha1-NzaitCi4e72gzIO1P6PWM6NcKug=}
- optionalDependencies:
- graceful-fs: 4.2.8
+ /jsonc-parser@3.2.0:
+ resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
dev: true
- /jsonfile/4.0.0:
- resolution: {integrity: sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=}
- optionalDependencies:
- graceful-fs: 4.2.8
- dev: true
-
- /jsonfile/6.1.0:
+ /jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
dependencies:
universalify: 2.0.0
optionalDependencies:
- graceful-fs: 4.2.8
+ graceful-fs: 4.2.11
dev: true
- /jsonpointer/4.1.0:
- resolution: {integrity: sha512-CXcRvMyTlnR53xMcKnuMzfCA5i/nfblTnnr74CZb6C4vG39eu6w51t7nKmU5MfLfbTgGItliNyjO/ciNPDqClg==}
+ /jsonpointer@5.0.1:
+ resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
engines: {node: '>=0.10.0'}
dev: true
- /jsprim/1.4.1:
- resolution: {integrity: sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=}
- engines: {'0': node >=0.6.0}
+ /jsonwebtoken@9.0.0:
+ resolution: {integrity: sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==}
+ engines: {node: '>=12', npm: '>=6'}
+ dependencies:
+ jws: 3.2.2
+ lodash: 4.17.21
+ ms: 2.1.3
+ semver: 7.5.4
+ dev: true
+
+ /jsprim@1.4.2:
+ resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==}
+ engines: {node: '>=0.6.0'}
dependencies:
assert-plus: 1.0.0
extsprintf: 1.3.0
- json-schema: 0.2.3
+ json-schema: 0.4.0
verror: 1.10.0
dev: true
- /jsx-ast-utils/3.2.0:
- resolution: {integrity: sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q==}
+ /jsqr@1.4.0:
+ resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==}
+ dev: false
+
+ /jssha@3.3.0:
+ resolution: {integrity: sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==}
+ dev: false
+
+ /jsx-ast-utils@3.3.5:
+ resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
dependencies:
- array-includes: 3.1.3
- object.assign: 4.1.2
+ array-includes: 3.1.7
+ array.prototype.flat: 1.3.2
+ object.assign: 4.1.5
+ object.values: 1.1.7
dev: true
- /junk/3.1.0:
- resolution: {integrity: sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==}
- engines: {node: '>=8'}
+ /jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
dev: true
- /keyv/3.1.0:
+ /jwa@1.4.1:
+ resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==}
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+ dev: true
+
+ /jws@3.2.2:
+ resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
+ dependencies:
+ jwa: 1.4.1
+ safe-buffer: 5.2.1
+ dev: true
+
+ /keyv@3.1.0:
resolution: {integrity: sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==}
dependencies:
json-buffer: 3.0.0
dev: true
- /killable/1.0.1:
- resolution: {integrity: sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg==}
+ /keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ dependencies:
+ json-buffer: 3.0.1
dev: true
- /kind-of/3.2.2:
- resolution: {integrity: sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=}
+ /kind-of@3.2.2:
+ resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==}
engines: {node: '>=0.10.0'}
dependencies:
is-buffer: 1.1.6
dev: true
- /kind-of/4.0.0:
- resolution: {integrity: sha1-IIE989cSkosgc3hpGkUGb65y3Vc=}
+ /kind-of@4.0.0:
+ resolution: {integrity: sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==}
engines: {node: '>=0.10.0'}
dependencies:
is-buffer: 1.1.6
dev: true
- /kind-of/5.1.0:
+ /kind-of@5.1.0:
resolution: {integrity: sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==}
engines: {node: '>=0.10.0'}
dev: true
- /kind-of/6.0.3:
+ /kind-of@6.0.3:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
dev: true
- /klaw/1.3.1:
- resolution: {integrity: sha1-QIhDO0azsbolnXh4XY6W9zugJDk=}
- optionalDependencies:
- graceful-fs: 4.2.8
- dev: true
-
- /kleur/3.0.3:
+ /kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
dev: true
- /kleur/4.1.4:
- resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==}
+ /kleur@4.1.5:
+ resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
dev: true
- /klona/2.0.4:
- resolution: {integrity: sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==}
+ /klona@2.0.5:
+ resolution: {integrity: sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==}
engines: {node: '>= 8'}
dev: true
- /language-subtag-registry/0.3.21:
- resolution: {integrity: sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg==}
+ /language-subtag-registry@0.3.22:
+ resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==}
dev: true
- /language-tags/1.0.5:
- resolution: {integrity: sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=}
+ /language-tags@1.0.9:
+ resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
+ engines: {node: '>=0.10'}
dependencies:
- language-subtag-registry: 0.3.21
+ language-subtag-registry: 0.3.22
dev: true
- /last-call-webpack-plugin/3.0.0:
+ /last-call-webpack-plugin@3.0.0:
resolution: {integrity: sha512-7KI2l2GIZa9p2spzPIVZBYyNKkN+e/SQPpnjlTiPhdbDW3F86tdKKELxKpzJ5sgU19wQWsACULZmpTPYHeWO5w==}
dependencies:
lodash: 4.17.21
webpack-sources: 1.4.3
dev: true
- /latest-version/5.1.0:
+ /latest-version@5.1.0:
resolution: {integrity: sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==}
engines: {node: '>=8'}
dependencies:
package-json: 6.5.0
dev: true
- /lazy-universal-dotenv/3.0.1:
- resolution: {integrity: sha512-prXSYk799h3GY3iOWnC6ZigYzMPjxN2svgjJ9shk7oMadSNX3wXy0B6F32PMJv7qtMnrIbUxoEHzbutvxR2LBQ==}
- engines: {node: '>=6.0.0', npm: '>=6.0.0', yarn: '>=1.0.0'}
+ /latest-version@7.0.0:
+ resolution: {integrity: sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==}
+ engines: {node: '>=14.16'}
+ dependencies:
+ package-json: 8.1.1
+ dev: true
+
+ /lcid@3.1.1:
+ resolution: {integrity: sha512-M6T051+5QCGLBQb8id3hdvIW8+zeFV2FyBGFS9IEK5H9Wt4MueD4bW1eWikpHgZp+5xR3l5c8pZUkQsIA0BFZg==}
+ engines: {node: '>=8'}
dependencies:
- '@babel/runtime': 7.15.3
- app-root-dir: 1.0.2
- core-js: 3.16.2
- dotenv: 8.6.0
- dotenv-expand: 5.1.0
+ invert-kv: 3.0.1
dev: true
- /leven/3.1.0:
+ /leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
dev: true
- /levn/0.3.0:
- resolution: {integrity: sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=}
+ /levn@0.3.0:
+ resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==}
engines: {node: '>= 0.8.0'}
dependencies:
prelude-ls: 1.1.2
type-check: 0.3.2
dev: true
- /levn/0.4.1:
+ /levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
dependencies:
@@ -15426,43 +13502,45 @@ packages:
type-check: 0.4.0
dev: true
- /lilconfig/2.0.3:
- resolution: {integrity: sha512-EHKqr/+ZvdKCifpNrJCKxBTgk5XupZA3y/aCPY9mxfgBzmgh93Mt/WqjjQ38oMxXuvDokaKiM3lAgvSH2sjtHg==}
- engines: {node: '>=10'}
+ /lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+ dependencies:
+ immediate: 3.0.6
dev: true
- /lines-and-columns/1.1.6:
- resolution: {integrity: sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=}
+ /lighthouse-logger@1.4.2:
+ resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
+ dependencies:
+ debug: 2.6.9
+ marky: 1.2.5
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /load-json-file/2.0.0:
- resolution: {integrity: sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=}
- engines: {node: '>=4'}
- dependencies:
- graceful-fs: 4.2.4
- parse-json: 2.2.0
- pify: 2.3.0
- strip-bom: 3.0.0
+ /lilconfig@2.1.0:
+ resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
+ engines: {node: '>=10'}
+
+ /lines-and-columns@1.2.4:
+ resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+
+ /lines-and-columns@2.0.4:
+ resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
- /load-json-file/5.3.0:
- resolution: {integrity: sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw==}
- engines: {node: '>=6'}
- dependencies:
- graceful-fs: 4.2.8
- parse-json: 4.0.0
- pify: 4.0.1
- strip-bom: 3.0.0
- type-fest: 0.3.1
+ /load-json-file@7.0.1:
+ resolution: {integrity: sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
- /loader-runner/2.4.0:
+ /loader-runner@2.4.0:
resolution: {integrity: sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==}
engines: {node: '>=4.3.0 <5.0.0 || >=5.10'}
dev: true
- /loader-utils/0.2.17:
- resolution: {integrity: sha1-+G5jdNQyBabmxg6RlvF8Apm/s0g=}
+ /loader-utils@0.2.17:
+ resolution: {integrity: sha512-tiv66G0SmiOx+pLWMtGEkfSEejxvb6N6uRrQjfWJIT79W9GMpgKeCAmm9aVBKtd4WEgntciI8CsGqjpDoCWJug==}
dependencies:
big.js: 3.2.0
emojis-list: 2.1.0
@@ -15470,38 +13548,39 @@ packages:
object-assign: 4.1.1
dev: true
- /loader-utils/1.4.0:
+ /loader-utils@1.4.0:
resolution: {integrity: sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==}
engines: {node: '>=4.0.0'}
dependencies:
big.js: 5.2.2
emojis-list: 3.0.0
- json5: 1.0.1
+ json5: 1.0.2
+ dev: true
+
+ /loader-utils@1.4.2:
+ resolution: {integrity: sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==}
+ engines: {node: '>=4.0.0'}
+ dependencies:
+ big.js: 5.2.2
+ emojis-list: 3.0.0
+ json5: 1.0.2
dev: true
- /loader-utils/2.0.0:
- resolution: {integrity: sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==}
+ /loader-utils@2.0.3:
+ resolution: {integrity: sha512-THWqIsn8QRnvLl0shHYVBN9syumU8pYWEHPTmkiVGd+7K5eFNVSY6AJhRvgGF70gg1Dz+l/k8WicvFCxdEs60A==}
engines: {node: '>=8.9.0'}
dependencies:
big.js: 5.2.2
emojis-list: 3.0.0
- json5: 2.2.0
+ json5: 2.2.3
dev: true
- /local-access/1.1.0:
+ /local-access@1.1.0:
resolution: {integrity: sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==}
engines: {node: '>=6'}
dev: true
- /locate-path/2.0.0:
- resolution: {integrity: sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=}
- engines: {node: '>=4'}
- dependencies:
- p-locate: 2.0.0
- path-exists: 3.0.0
- dev: true
-
- /locate-path/3.0.0:
+ /locate-path@3.0.0:
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
engines: {node: '>=6'}
dependencies:
@@ -15509,61 +13588,64 @@ packages:
path-exists: 3.0.0
dev: true
- /locate-path/5.0.0:
+ /locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
dependencies:
p-locate: 4.1.0
dev: true
- /locate-path/6.0.0:
+ /locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
dependencies:
p-locate: 5.0.0
dev: true
- /lodash.debounce/4.0.8:
- resolution: {integrity: sha1-gteb/zCmfEAF/9XiUVMArZyk168=}
+ /lodash-es@4.17.21:
+ resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
+ dev: false
+
+ /lodash.castarray@4.4.0:
+ resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
dev: true
- /lodash.escape/4.0.1:
- resolution: {integrity: sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg=}
+ /lodash.debounce@4.0.8:
+ resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
dev: true
- /lodash.flattendeep/4.4.0:
- resolution: {integrity: sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=}
+ /lodash.flattendeep@4.4.0:
+ resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==}
dev: true
- /lodash.get/4.4.2:
- resolution: {integrity: sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=}
+ /lodash.isplainobject@4.0.6:
+ resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
dev: true
- /lodash.isequal/4.5.0:
- resolution: {integrity: sha1-QVxEePK8wwEgwizhDtMib30+GOA=}
+ /lodash.memoize@4.1.2:
+ resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
dev: true
- /lodash.memoize/4.1.2:
- resolution: {integrity: sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=}
+ /lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
- /lodash.sortby/4.7.0:
- resolution: {integrity: sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=}
+ /lodash.sortby@4.7.0:
+ resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
dev: true
- /lodash.uniq/4.5.0:
- resolution: {integrity: sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=}
+ /lodash.truncate@4.4.2:
+ resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
dev: true
- /lodash/4.17.20:
- resolution: {integrity: sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==}
+ /lodash.uniq@4.5.0:
+ resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
dev: true
- /lodash/4.17.21:
+ /lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
- dev: true
- /log-symbols/4.1.0:
+ /log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
dependencies:
@@ -15571,185 +13653,145 @@ packages:
is-unicode-supported: 0.1.0
dev: true
- /loglevel/1.7.1:
- resolution: {integrity: sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw==}
- engines: {node: '>= 0.6.0'}
- dev: true
-
- /loose-envify/1.4.0:
+ /loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
- hasBin: true
dependencies:
js-tokens: 4.0.0
- /lower-case/1.1.4:
- resolution: {integrity: sha1-miyr0bno4K6ZOkv31YdcOcQujqw=}
+ /loupe@2.3.4:
+ resolution: {integrity: sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==}
+ dependencies:
+ get-func-name: 2.0.0
dev: true
- /lower-case/2.0.2:
+ /lower-case@1.1.4:
+ resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==}
+ dev: true
+
+ /lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
dependencies:
- tslib: 2.3.1
+ tslib: 2.6.2
dev: true
- /lowercase-keys/1.0.1:
+ /lowercase-keys@1.0.1:
resolution: {integrity: sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==}
engines: {node: '>=0.10.0'}
dev: true
- /lowercase-keys/2.0.0:
+ /lowercase-keys@2.0.0:
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
engines: {node: '>=8'}
dev: true
- /lowlight/1.20.0:
- resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
- dependencies:
- fault: 1.0.4
- highlight.js: 10.7.3
+ /lowercase-keys@3.0.0:
+ resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
- /lru-cache/4.1.5:
+ /lru-cache@10.1.0:
+ resolution: {integrity: sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==}
+ engines: {node: 14 || >=16.14}
+
+ /lru-cache@4.1.5:
resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==}
dependencies:
pseudomap: 1.0.2
yallist: 2.1.2
dev: true
- /lru-cache/5.1.1:
+ /lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
dependencies:
yallist: 3.1.1
dev: true
- /lru-cache/6.0.0:
+ /lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
dependencies:
yallist: 4.0.0
- dev: true
- /lunr/2.3.9:
+ /lunr@2.3.9:
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
dev: true
- /lz-string/1.4.4:
- resolution: {integrity: sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=}
- hasBin: true
- dev: true
-
- /magic-string/0.25.7:
- resolution: {integrity: sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==}
+ /magic-string@0.25.9:
+ resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
dependencies:
sourcemap-codec: 1.4.8
dev: true
- /make-dir/2.1.0:
+ /make-dir@2.1.0:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'}
dependencies:
pify: 4.0.1
- semver: 5.7.1
+ semver: 5.7.2
dev: true
- /make-dir/3.1.0:
+ /make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
dependencies:
- semver: 6.3.0
+ semver: 6.3.1
dev: true
- /makeerror/1.0.11:
- resolution: {integrity: sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw=}
+ /make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
dependencies:
- tmpl: 1.0.4
+ semver: 7.5.4
dev: true
- /map-age-cleaner/0.1.3:
+ /make-error@1.3.6:
+ resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
+ dev: true
+
+ /map-age-cleaner@0.1.3:
resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==}
engines: {node: '>=6'}
dependencies:
p-defer: 1.0.0
dev: true
- /map-cache/0.2.2:
- resolution: {integrity: sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=}
+ /map-cache@0.2.2:
+ resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==}
engines: {node: '>=0.10.0'}
dev: true
- /map-or-similar/1.5.0:
- resolution: {integrity: sha1-beJlMXSt+12e3DPGnT6Sobdvrwg=}
- dev: true
-
- /map-visit/1.0.0:
- resolution: {integrity: sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=}
+ /map-visit@1.0.0:
+ resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==}
engines: {node: '>=0.10.0'}
dependencies:
object-visit: 1.0.1
dev: true
- /markdown-escapes/1.0.4:
- resolution: {integrity: sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==}
- dev: true
-
- /markdown-to-jsx/6.11.4:
- resolution: {integrity: sha512-3lRCD5Sh+tfA52iGgfs/XZiw33f7fFX9Bn55aNnVNUd2GzLDkOWyKYYD8Yju2B1Vn+feiEdgJs8T6Tg0xNokPw==}
- engines: {node: '>= 4'}
- peerDependencies:
- react: '>= 0.14.0'
- dependencies:
- prop-types: 15.7.2
- unquote: 1.1.1
- dev: true
-
- /markdown-to-jsx/6.11.4_react@16.14.0:
- resolution: {integrity: sha512-3lRCD5Sh+tfA52iGgfs/XZiw33f7fFX9Bn55aNnVNUd2GzLDkOWyKYYD8Yju2B1Vn+feiEdgJs8T6Tg0xNokPw==}
- engines: {node: '>= 4'}
- peerDependencies:
- react: '>= 0.14.0'
- dependencies:
- prop-types: 15.7.2
- react: 16.14.0
- unquote: 1.1.1
- dev: true
-
- /markdown-to-jsx/7.1.3:
- resolution: {integrity: sha512-jtQ6VyT7rMT5tPV0g2EJakEnXLiPksnvlYtwQsVVZ611JsWGN8bQ1tVSDX4s6JllfEH6wmsYxNjTUAMrPmNA8w==}
- engines: {node: '>= 10'}
- peerDependencies:
- react: '>= 0.14.0'
- dev: true
-
- /markdown-to-jsx/7.1.3_react@16.14.0:
- resolution: {integrity: sha512-jtQ6VyT7rMT5tPV0g2EJakEnXLiPksnvlYtwQsVVZ611JsWGN8bQ1tVSDX4s6JllfEH6wmsYxNjTUAMrPmNA8w==}
- engines: {node: '>= 10'}
- peerDependencies:
- react: '>= 0.14.0'
- dependencies:
- react: 16.14.0
+ /marked@4.3.0:
+ resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==}
+ engines: {node: '>= 12'}
+ hasBin: true
dev: true
- /marked/1.2.7:
- resolution: {integrity: sha512-No11hFYcXr/zkBvL6qFmAp1z6BKY3zqLMHny/JN/ey+al7qwCM2+CMBL9BOgqMxZU36fz4cCWfn2poWIf7QRXA==}
- engines: {node: '>= 8.16.2'}
- hasBin: true
+ /marky@1.2.5:
+ resolution: {integrity: sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==}
dev: true
- /matcher/3.0.0:
- resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
- engines: {node: '>=10'}
+ /matcher@5.0.0:
+ resolution: {integrity: sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
- escape-string-regexp: 4.0.0
+ escape-string-regexp: 5.0.0
dev: true
- /md5-hex/3.0.1:
+ /md5-hex@3.0.1:
resolution: {integrity: sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==}
engines: {node: '>=8'}
dependencies:
- blueimp-md5: 2.18.0
+ blueimp-md5: 2.19.0
dev: true
- /md5.js/1.3.5:
+ /md5.js@1.3.5:
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
dependencies:
hash-base: 3.1.0
@@ -15757,111 +13799,79 @@ packages:
safe-buffer: 5.2.1
dev: true
- /mdast-squeeze-paragraphs/4.0.0:
- resolution: {integrity: sha512-zxdPn69hkQ1rm4J+2Cs2j6wDEv7O17TfXTJ33tl/+JPIoEmtV9t2ZzBM5LPHE8QlHsmVD8t3vPKCyY3oH+H8MQ==}
- dependencies:
- unist-util-remove: 2.1.0
- dev: true
-
- /mdast-util-definitions/4.0.0:
- resolution: {integrity: sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==}
- dependencies:
- unist-util-visit: 2.0.3
- dev: true
-
- /mdast-util-to-hast/10.0.1:
- resolution: {integrity: sha512-BW3LM9SEMnjf4HXXVApZMt8gLQWVNXc3jryK0nJu/rOXPOnlkUjmdkDlmxMirpbU9ILncGFIwLH/ubnWBbcdgA==}
- dependencies:
- '@types/mdast': 3.0.8
- '@types/unist': 2.0.6
- mdast-util-definitions: 4.0.0
- mdurl: 1.0.1
- unist-builder: 2.0.3
- unist-util-generated: 1.1.6
- unist-util-position: 3.1.0
- unist-util-visit: 2.0.3
- dev: true
-
- /mdast-util-to-string/1.1.0:
- resolution: {integrity: sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A==}
- dev: true
-
- /mdn-data/2.0.14:
+ /mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
dev: true
- /mdn-data/2.0.4:
+ /mdn-data@2.0.4:
resolution: {integrity: sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==}
dev: true
- /mdurl/1.0.1:
- resolution: {integrity: sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=}
- dev: true
-
- /media-typer/0.3.0:
- resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=}
+ /media-typer@0.3.0:
+ resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
dev: true
- /mem/8.1.1:
- resolution: {integrity: sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==}
- engines: {node: '>=10'}
+ /mem@5.1.1:
+ resolution: {integrity: sha512-qvwipnozMohxLXG1pOqoLiZKNkC4r4qqRucSoDwXowsNGDSULiqFTRUF05vcZWnwJSG22qTsynQhxbaMtnX9gw==}
+ engines: {node: '>=8'}
dependencies:
map-age-cleaner: 0.1.3
- mimic-fn: 3.1.0
+ mimic-fn: 2.1.0
+ p-is-promise: 2.1.0
dev: true
- /memfs/3.2.2:
- resolution: {integrity: sha512-RE0CwmIM3CEvpcdK3rZ19BC4E6hv9kADkMN5rPduRak58cNArWLi/9jFLsa4rhsjfVxMP3v0jO7FHXq7SvFY5Q==}
+ /memfs@3.4.7:
+ resolution: {integrity: sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==}
engines: {node: '>= 4.0.0'}
dependencies:
fs-monkey: 1.0.3
dev: true
- /memoizerific/1.11.3:
- resolution: {integrity: sha1-fIekZGREwy11Q4VwkF8tvRsagFo=}
+ /memoize@10.0.0:
+ resolution: {integrity: sha512-H6cBLgsi6vMWOcCpvVCdFFnl3kerEXbrYh9q+lY6VXvQSmM6CkmV08VOwT+WE2tzIEqRPFfAq3fm4v/UIW6mSA==}
+ engines: {node: '>=18'}
dependencies:
- map-or-similar: 1.5.0
+ mimic-function: 5.0.0
dev: true
- /memory-fs/0.4.1:
- resolution: {integrity: sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=}
+ /memory-fs@0.4.1:
+ resolution: {integrity: sha512-cda4JKCxReDXFXRqOHPQscuIYg1PvxbE2S2GP45rnwfEK+vZaXC8C1OFvdHIbgw0DLzowXGVoxLaAmlgRy14GQ==}
dependencies:
errno: 0.1.8
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
dev: true
- /memory-fs/0.5.0:
+ /memory-fs@0.5.0:
resolution: {integrity: sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==}
engines: {node: '>=4.3.0 <5.0.0 || >=5.10'}
dependencies:
errno: 0.1.8
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
dev: true
- /merge-descriptors/1.0.1:
- resolution: {integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=}
+ /merge-descriptors@1.0.1:
+ resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==}
dev: true
- /merge-stream/2.0.0:
+ /merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
dev: true
- /merge2/1.4.1:
+ /merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
- dev: true
- /methods/1.1.2:
- resolution: {integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=}
+ /methods@1.1.2:
+ resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
dev: true
- /microevent.ts/0.1.1:
+ /microevent.ts@0.1.1:
resolution: {integrity: sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g==}
dev: true
- /micromatch/3.1.10:
+ /micromatch@3.1.10:
resolution: {integrity: sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -15878,25 +13888,18 @@ packages:
regex-not: 1.0.2
snapdragon: 0.8.2
to-regex: 3.0.2
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /micromatch/4.0.2:
- resolution: {integrity: sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==}
- engines: {node: '>=8'}
- dependencies:
- braces: 3.0.2
- picomatch: 2.2.2
- dev: true
-
- /micromatch/4.0.4:
- resolution: {integrity: sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==}
+ /micromatch@4.0.5:
+ resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
engines: {node: '>=8.6'}
dependencies:
braces: 3.0.2
- picomatch: 2.3.0
- dev: true
+ picomatch: 2.3.1
- /miller-rabin/4.0.1:
+ /miller-rabin@4.0.1:
resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
hasBin: true
dependencies:
@@ -15904,136 +13907,174 @@ packages:
brorand: 1.1.0
dev: true
- /mime-db/1.49.0:
- resolution: {integrity: sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==}
+ /mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: true
- /mime-types/2.1.32:
- resolution: {integrity: sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==}
+ /mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
- mime-db: 1.49.0
+ mime-db: 1.52.0
dev: true
- /mime/1.6.0:
+ /mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
- hasBin: true
dev: true
- /mime/2.5.2:
- resolution: {integrity: sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==}
- engines: {node: '>=4.0.0'}
- hasBin: true
- dev: true
-
- /mimic-fn/2.1.0:
+ /mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
dev: true
- /mimic-fn/3.1.0:
- resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==}
- engines: {node: '>=8'}
+ /mimic-fn@4.0.0:
+ resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
+ engines: {node: '>=12'}
dev: true
- /mimic-response/1.0.1:
+ /mimic-function@5.0.0:
+ resolution: {integrity: sha512-RBfQ+9X9DpXdEoK7Bu+KeEU6vFhumEIiXKWECPzRBmDserEq4uR2b/VCm0LwpMSosoq2k+Zuxj/GzOr0Fn6h/g==}
+ engines: {node: '>=18'}
+ dev: true
+
+ /mimic-response@1.0.1:
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
engines: {node: '>=4'}
dev: true
- /min-document/2.19.0:
- resolution: {integrity: sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=}
- dependencies:
- dom-walk: 0.1.2
+ /mimic-response@3.1.0:
+ resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
+ engines: {node: '>=10'}
+ requiresBuild: true
+
+ /mimic-response@4.0.0:
+ resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: true
- /mini-css-extract-plugin/1.6.2_webpack@4.46.0:
+ /mini-css-extract-plugin@1.6.2(webpack@4.46.0):
resolution: {integrity: sha512-WhDvO3SjGm40oV5y26GjMJYjd2UMqrLAGKy5YS2/3QKJy2F7jgynuHTir/tgUUOiNQu5saXHdc8reo7YuhhT4Q==}
engines: {node: '>= 10.13.0'}
peerDependencies:
webpack: ^4.4.0 || ^5.0.0
dependencies:
- loader-utils: 2.0.0
+ loader-utils: 2.0.3
schema-utils: 3.1.1
webpack: 4.46.0
webpack-sources: 1.4.3
dev: true
- /mini-svg-data-uri/1.3.3:
- resolution: {integrity: sha512-+fA2oRcR1dJI/7ITmeQJDrYWks0wodlOz0pAEhKYJ2IVc1z0AnwJUsKY2fzFmPAM3Jo9J0rBx8JAA9QQSJ5PuA==}
- hasBin: true
+ /mini-svg-data-uri@1.4.4:
+ resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
dev: true
- /minimalistic-assert/1.0.1:
+ /minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
dev: true
- /minimalistic-crypto-utils/1.0.1:
- resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=}
+ /minimalistic-crypto-utils@1.0.1:
+ resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==}
dev: true
- /minimatch/3.0.4:
+ /minimatch@3.0.4:
resolution: {integrity: sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==}
dependencies:
brace-expansion: 1.1.11
dev: true
- /minimist/1.2.5:
- resolution: {integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==}
+ /minimatch@3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+ dependencies:
+ brace-expansion: 1.1.11
+
+ /minimatch@4.2.1:
+ resolution: {integrity: sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==}
+ engines: {node: '>=10'}
+ dependencies:
+ brace-expansion: 1.1.11
+ dev: true
+
+ /minimatch@5.1.6:
+ resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
+ engines: {node: '>=10'}
+ dependencies:
+ brace-expansion: 2.0.1
+ dev: true
+
+ /minimatch@9.0.3:
+ resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==}
+ engines: {node: '>=16 || 14 >=14.17'}
+ dependencies:
+ brace-expansion: 2.0.1
+
+ /minimist@1.2.7:
+ resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==}
dev: true
- /minipass-collect/1.0.2:
+ /minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ /minipass-collect@1.0.2:
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
engines: {node: '>= 8'}
dependencies:
- minipass: 3.1.3
+ minipass: 3.3.6
dev: true
- /minipass-flush/1.0.5:
+ /minipass-flush@1.0.5:
resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==}
engines: {node: '>= 8'}
dependencies:
- minipass: 3.1.3
+ minipass: 3.3.6
dev: true
- /minipass-pipeline/1.2.4:
+ /minipass-pipeline@1.2.4:
resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==}
engines: {node: '>=8'}
dependencies:
- minipass: 3.1.3
+ minipass: 3.3.6
dev: true
- /minipass/2.9.0:
+ /minipass@2.9.0:
resolution: {integrity: sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==}
dependencies:
safe-buffer: 5.2.1
yallist: 3.1.1
dev: true
- /minipass/3.1.3:
- resolution: {integrity: sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==}
+ /minipass@3.3.6:
+ resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
dependencies:
yallist: 4.0.0
dev: true
- /minizlib/1.3.3:
+ /minipass@5.0.0:
+ resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /minipass@7.0.4:
+ resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ /minizlib@1.3.3:
resolution: {integrity: sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==}
dependencies:
minipass: 2.9.0
dev: true
- /minizlib/2.1.2:
+ /minizlib@2.1.2:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
dependencies:
- minipass: 3.1.3
+ minipass: 3.3.6
yallist: 4.0.0
dev: true
- /mississippi/3.0.0:
+ /mississippi@3.0.0:
resolution: {integrity: sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==}
engines: {node: '>=4.0.0'}
dependencies:
@@ -16049,7 +14090,7 @@ packages:
through2: 2.0.5
dev: true
- /mixin-deep/1.3.2:
+ /mixin-deep@1.3.2:
resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -16057,83 +14098,196 @@ packages:
is-extendable: 1.0.1
dev: true
- /mkdirp/0.5.5:
- resolution: {integrity: sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==}
+ /mkdirp-classic@0.5.3:
+ resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /mkdirp@0.5.6:
+ resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
dependencies:
- minimist: 1.2.5
+ minimist: 1.2.8
dev: true
- /mkdirp/1.0.4:
+ /mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
hasBin: true
dev: true
- /moo/0.5.1:
- resolution: {integrity: sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==}
+ /mocha@9.2.0:
+ resolution: {integrity: sha512-kNn7E8g2SzVcq0a77dkphPsDSN7P+iYkqE0ZsGCYWRsoiKjOt+NvXfaagik8vuDa6W5Zw3qxe8Jfpt5qKf+6/Q==}
+ engines: {node: '>= 12.0.0'}
+ hasBin: true
+ dependencies:
+ '@ungap/promise-all-settled': 1.1.2
+ ansi-colors: 4.1.1
+ browser-stdout: 1.3.1
+ chokidar: 3.5.3
+ debug: 4.3.3(supports-color@8.1.1)
+ diff: 5.0.0
+ escape-string-regexp: 4.0.0
+ find-up: 5.0.0
+ glob: 7.2.0
+ growl: 1.10.5
+ he: 1.2.0
+ js-yaml: 4.1.0
+ log-symbols: 4.1.0
+ minimatch: 3.0.4
+ ms: 2.1.3
+ nanoid: 3.2.0
+ serialize-javascript: 6.0.0
+ strip-json-comments: 3.1.1
+ supports-color: 8.1.1
+ which: 2.0.2
+ workerpool: 6.2.0
+ yargs: 16.2.0
+ yargs-parser: 20.2.4
+ yargs-unparser: 2.0.0
dev: true
- /move-concurrently/1.0.1:
- resolution: {integrity: sha1-viwAX9oy4LKa8fBdfEszIUxwH5I=}
+ /mocha@9.2.2:
+ resolution: {integrity: sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==}
+ engines: {node: '>= 12.0.0'}
+ hasBin: true
+ dependencies:
+ '@ungap/promise-all-settled': 1.1.2
+ ansi-colors: 4.1.1
+ browser-stdout: 1.3.1
+ chokidar: 3.5.3
+ debug: 4.3.3(supports-color@8.1.1)
+ diff: 5.0.0
+ escape-string-regexp: 4.0.0
+ find-up: 5.0.0
+ glob: 7.2.0
+ growl: 1.10.5
+ he: 1.2.0
+ js-yaml: 4.1.0
+ log-symbols: 4.1.0
+ minimatch: 4.2.1
+ ms: 2.1.3
+ nanoid: 3.3.1
+ serialize-javascript: 6.0.0
+ strip-json-comments: 3.1.1
+ supports-color: 8.1.1
+ which: 2.0.2
+ workerpool: 6.2.0
+ yargs: 16.2.0
+ yargs-parser: 20.2.4
+ yargs-unparser: 2.0.0
+ dev: true
+
+ /moment@2.30.1:
+ resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /move-concurrently@1.0.1:
+ resolution: {integrity: sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==}
dependencies:
aproba: 1.2.0
copy-concurrently: 1.0.5
fs-write-stream-atomic: 1.0.10
- mkdirp: 0.5.5
+ mkdirp: 0.5.6
rimraf: 2.7.1
run-queue: 1.0.3
dev: true
- /mri/1.1.6:
- resolution: {integrity: sha512-oi1b3MfbyGa7FJMP9GmLTttni5JoICpYBRlq+x5V16fZbLsnL9N3wFqqIm/nIG43FjUFkFh9Epzp/kzUGUnJxQ==}
+ /mri@1.2.0:
+ resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
dev: true
- /ms/2.0.0:
- resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=}
+ /mrmime@1.0.1:
+ resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==}
+ engines: {node: '>=10'}
dev: true
- /ms/2.1.1:
- resolution: {integrity: sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==}
+ /ms@2.0.0:
+ resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
dev: true
- /ms/2.1.2:
+ /ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
- dev: true
- /ms/2.1.3:
+ /ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true
- /multicast-dns-service-types/1.1.0:
- resolution: {integrity: sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=}
+ /multicast-dns@7.2.5:
+ resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==}
+ dependencies:
+ dns-packet: 5.4.0
+ thunky: 1.1.0
dev: true
- /multicast-dns/6.2.3:
- resolution: {integrity: sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g==}
- hasBin: true
+ /multimatch@6.0.0:
+ resolution: {integrity: sha512-I7tSVxHGPlmPN/enE3mS1aOSo6bWBfls+3HmuEeCUBCE7gWnm3cBXCBkpurzFjVRwC6Kld8lLaZ1Iv5vOcjvcQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
- dns-packet: 1.3.4
- thunky: 1.1.0
+ '@types/minimatch': 3.0.5
+ array-differ: 4.0.0
+ array-union: 3.0.1
+ minimatch: 3.1.2
dev: true
- /mute-stream/0.0.8:
- resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
+ /mustache@4.2.0:
+ resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
dev: true
- /nan/2.15.0:
- resolution: {integrity: sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==}
+ /mv@2.1.1:
+ resolution: {integrity: sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==}
+ engines: {node: '>=0.8.0'}
+ requiresBuild: true
+ dependencies:
+ mkdirp: 0.5.6
+ ncp: 2.0.0
+ rimraf: 2.4.5
+ dev: true
+ optional: true
+
+ /mz@2.7.0:
+ resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+ dependencies:
+ any-promise: 1.3.0
+ object-assign: 4.1.1
+ thenify-all: 1.6.0
+
+ /nan@2.18.0:
+ resolution: {integrity: sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==}
+ requiresBuild: true
dev: true
optional: true
- /nanoid/3.1.25:
- resolution: {integrity: sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q==}
+ /nanoclone@0.2.1:
+ resolution: {integrity: sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==}
+ dev: false
+
+ /nanoid@3.2.0:
+ resolution: {integrity: sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
dev: true
- /nanomatch/1.2.13:
+ /nanoid@3.3.1:
+ resolution: {integrity: sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ dev: true
+
+ /nanoid@3.3.6:
+ resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+
+ /nanoid@3.3.7:
+ resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+ dev: true
+
+ /nanomatch@1.2.13:
resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -16148,80 +14302,113 @@ packages:
regex-not: 1.0.2
snapdragon: 0.8.2
to-regex: 3.0.2
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /native-url/0.3.4:
+ /napi-build-utils@1.0.2:
+ resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==}
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /native-url@0.3.4:
resolution: {integrity: sha512-6iM8R99ze45ivyH8vybJ7X0yekIcPf5GgLV5K0ENCbmRcaRIDoj37BC8iLEmaaBfqqb8enuZ5p0uhY+lVAbAcA==}
dependencies:
querystring: 0.2.1
dev: true
- /natural-compare/1.4.0:
- resolution: {integrity: sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=}
+ /natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true
- /nearley/2.20.1:
- resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
+ /ncp@2.0.0:
+ resolution: {integrity: sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==}
hasBin: true
- dependencies:
- commander: 2.20.3
- moo: 0.5.1
- railroad-diagrams: 1.0.0
- randexp: 0.4.6
+ requiresBuild: true
dev: true
+ optional: true
- /negotiator/0.6.2:
- resolution: {integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==}
+ /negotiator@0.6.3:
+ resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
dev: true
- /neo-async/2.6.2:
+ /neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
dev: true
- /nested-error-stacks/2.1.0:
- resolution: {integrity: sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==}
- dev: true
-
- /nice-try/1.0.5:
- resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
- dev: true
-
- /no-case/2.3.2:
+ /no-case@2.3.2:
resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==}
dependencies:
lower-case: 1.1.4
dev: true
- /no-case/3.0.4:
+ /no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
dependencies:
lower-case: 2.0.2
- tslib: 2.3.1
+ tslib: 2.6.2
+ dev: true
+
+ /node-abi@3.52.0:
+ resolution: {integrity: sha512-JJ98b02z16ILv7859irtXn4oUaFWADtvkzy2c0IAatNVX2Mc9Yoh8z6hZInn3QwvMEYhHuQloYi+TTQy67SIdQ==}
+ engines: {node: '>=10'}
+ requiresBuild: true
+ dependencies:
+ semver: 7.5.4
+ dev: false
+ optional: true
+
+ /node-domexception@1.0.0:
+ resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
+ engines: {node: '>=10.5.0'}
+ dev: true
+
+ /node-fetch@2.6.7:
+ resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==}
+ engines: {node: 4.x || >=6.0.0}
+ peerDependencies:
+ encoding: ^0.1.0
+ peerDependenciesMeta:
+ encoding:
+ optional: true
+ dependencies:
+ whatwg-url: 5.0.0
dev: true
- /node-fetch/2.6.1:
- resolution: {integrity: sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==}
+ /node-fetch@2.7.0:
+ resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
+ peerDependencies:
+ encoding: ^0.1.0
+ peerDependenciesMeta:
+ encoding:
+ optional: true
+ dependencies:
+ whatwg-url: 5.0.0
+ dev: true
- /node-fetch/3.0.0:
- resolution: {integrity: sha512-bKMI+C7/T/SPU1lKnbQbwxptpCrG9ashG+VkytmXCPZyuM9jB6VU+hY0oi4lC8LxTtAeWdckNCTa3nrGsAdA3Q==}
+ /node-fetch@3.3.1:
+ resolution: {integrity: sha512-cRVc/kyto/7E5shrWca1Wsea4y6tL9iYJE5FBCius3JQfb/4P4I295PfhgbJQBLTx6lATE4z+wK0rPM4VS2uow==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
- data-uri-to-buffer: 3.0.1
- fetch-blob: 3.1.2
- dev: false
+ data-uri-to-buffer: 4.0.1
+ fetch-blob: 3.2.0
+ formdata-polyfill: 4.0.10
+ dev: true
- /node-forge/0.10.0:
- resolution: {integrity: sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==}
- engines: {node: '>= 6.0.0'}
+ /node-forge@1.3.1:
+ resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
+ engines: {node: '>= 6.13.0'}
dev: true
- /node-int64/0.4.0:
- resolution: {integrity: sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=}
+ /node-gyp-build@4.7.1:
+ resolution: {integrity: sha512-wTSrZ+8lsRRa3I3H8Xr65dLWSgCvY2l4AOnaeKdPA9TB/WYMPaTcrzf3rXvFoVvjKNVnu0CcWSx54qq9GKRUYg==}
+ hasBin: true
dev: true
- /node-libs-browser/2.2.1:
+ /node-libs-browser@2.2.1:
resolution: {integrity: sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==}
dependencies:
assert: 1.5.0
@@ -16238,190 +14425,171 @@ packages:
process: 0.11.10
punycode: 1.4.1
querystring-es3: 0.2.1
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
stream-browserify: 2.0.2
stream-http: 2.8.3
string_decoder: 1.3.0
timers-browserify: 2.0.12
tty-browserify: 0.0.0
- url: 0.11.0
+ url: 0.11.1
util: 0.11.1
vm-browserify: 1.1.2
dev: true
- /node-modules-regexp/1.0.0:
- resolution: {integrity: sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA=}
- engines: {node: '>=0.10.0'}
- dev: true
-
- /node-notifier/8.0.2:
- resolution: {integrity: sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg==}
+ /node-notifier@10.0.1:
+ resolution: {integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==}
dependencies:
growly: 1.3.0
is-wsl: 2.2.0
- semver: 7.3.5
+ semver: 7.5.4
shellwords: 0.1.1
uuid: 8.3.2
which: 2.0.2
dev: true
- optional: true
- /node-preload/0.2.1:
+ /node-preload@0.2.1:
resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==}
engines: {node: '>=8'}
dependencies:
process-on-spawn: 1.0.0
dev: true
- /node-releases/1.1.75:
- resolution: {integrity: sha512-Qe5OUajvqrqDSy6wrWFmMwfJ0jVgwiw4T3KqmbTcZ62qW0gQkheXYhcFM1+lOVcGUoRxcEcfyvFMAnDgaF1VWw==}
+ /node-releases@2.0.10:
+ resolution: {integrity: sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==}
dev: true
- /nodent-compiler/3.2.13:
- resolution: {integrity: sha512-nzzWPXZwSdsWie34om+4dLrT/5l1nT/+ig1v06xuSgMtieJVAnMQFuZihUwREM+M7dFso9YoHfDmweexEXXrrw==}
- engines: {'0': n, '1': o, '2': d, '3': e, '4': ' ', '5': '>', '6': '=', '7': ' ', '8': '0', '9': ., '10': '1', '11': '0', '12': ., '13': '0'}
- dependencies:
- acorn: 5.7.4
- acorn-es7-plugin: 1.1.7
- nodent-transform: 3.2.9
- source-map: 0.5.7
+ /node-releases@2.0.13:
+ resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==}
dev: true
- /nodent-runtime/3.2.1:
- resolution: {integrity: sha512-7Ws63oC+215smeKJQCxzrK21VFVlCFBkwl0MOObt0HOpVQXs3u483sAmtkF33nNqZ5rSOQjB76fgyPBmAUrtCA==}
- requiresBuild: true
- dev: true
+ /node-releases@2.0.14:
+ resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
- /nodent-transform/3.2.9:
- resolution: {integrity: sha512-4a5FH4WLi+daH/CGD5o/JWRR8W5tlCkd3nrDSkxbOzscJTyTUITltvOJeQjg3HJ1YgEuNyiPhQbvbtRjkQBByQ==}
+ /nofilter@3.1.0:
+ resolution: {integrity: sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==}
+ engines: {node: '>=12.19'}
dev: true
- /nomnom/1.8.1:
+ /nomnom@1.8.1:
resolution: {integrity: sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=}
- deprecated: Package no longer supported. Contact support@npmjs.com for more info.
dependencies:
chalk: 0.4.0
underscore: 1.6.0
dev: true
- /normalize-package-data/2.5.0:
- resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
+ /nopt@5.0.0:
+ resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==}
+ engines: {node: '>=6'}
+ hasBin: true
dependencies:
- hosted-git-info: 2.8.9
- resolve: 1.20.0
- semver: 5.7.1
- validate-npm-package-license: 3.0.4
+ abbrev: 1.1.1
dev: true
- /normalize-path/2.1.1:
- resolution: {integrity: sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=}
+ /normalize-path@2.1.1:
+ resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==}
engines: {node: '>=0.10.0'}
+ requiresBuild: true
dependencies:
remove-trailing-separator: 1.1.0
dev: true
+ optional: true
- /normalize-path/3.0.0:
+ /normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
- dev: true
- /normalize-range/0.1.2:
- resolution: {integrity: sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=}
+ /normalize-range@0.1.2:
+ resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
engines: {node: '>=0.10.0'}
dev: true
- /normalize-url/3.3.0:
+ /normalize-url@3.3.0:
resolution: {integrity: sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==}
engines: {node: '>=6'}
dev: true
- /normalize-url/4.5.1:
+ /normalize-url@4.5.1:
resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==}
engines: {node: '>=8'}
dev: true
- /normalize-url/6.1.0:
+ /normalize-url@6.1.0:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
engines: {node: '>=10'}
dev: true
- /npm-run-path/2.0.2:
- resolution: {integrity: sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=}
- engines: {node: '>=4'}
- dependencies:
- path-key: 2.0.1
+ /normalize-url@8.0.0:
+ resolution: {integrity: sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==}
+ engines: {node: '>=14.16'}
dev: true
- /npm-run-path/4.0.1:
+ /npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
dependencies:
path-key: 3.1.1
dev: true
- /npmlog/4.1.2:
- resolution: {integrity: sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==}
+ /npm-run-path@5.1.0:
+ resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dependencies:
+ path-key: 4.0.0
+ dev: true
+
+ /npmlog@5.0.1:
+ resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==}
dependencies:
- are-we-there-yet: 1.1.5
+ are-we-there-yet: 2.0.0
console-control-strings: 1.1.0
- gauge: 2.7.4
+ gauge: 3.0.2
set-blocking: 2.0.0
dev: true
- /nth-check/1.0.2:
+ /nth-check@1.0.2:
resolution: {integrity: sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==}
dependencies:
boolbase: 1.0.0
dev: true
- /nth-check/2.0.0:
- resolution: {integrity: sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==}
+ /nth-check@2.1.1:
+ resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
dependencies:
boolbase: 1.0.0
dev: true
- /num2fraction/1.2.2:
- resolution: {integrity: sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=}
- dev: true
-
- /number-is-nan/1.0.1:
- resolution: {integrity: sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=}
- engines: {node: '>=0.10.0'}
+ /nwsapi@2.2.2:
+ resolution: {integrity: sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==}
dev: true
- /nwsapi/2.2.0:
- resolution: {integrity: sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==}
- dev: true
-
- /nyc/15.1.0:
+ /nyc@15.1.0:
resolution: {integrity: sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==}
engines: {node: '>=8.9'}
- hasBin: true
dependencies:
'@istanbuljs/load-nyc-config': 1.1.0
- '@istanbuljs/schema': 0.1.2
+ '@istanbuljs/schema': 0.1.3
caching-transform: 4.0.0
- convert-source-map: 1.7.0
+ convert-source-map: 1.9.0
decamelize: 1.2.0
- find-cache-dir: 3.3.1
+ find-cache-dir: 3.3.2
find-up: 4.1.0
foreground-child: 2.0.0
get-package-type: 0.1.0
- glob: 7.1.6
- istanbul-lib-coverage: 3.0.0
+ glob: 7.2.3
+ istanbul-lib-coverage: 3.2.0
istanbul-lib-hook: 3.0.0
istanbul-lib-instrument: 4.0.3
- istanbul-lib-processinfo: 2.0.2
+ istanbul-lib-processinfo: 2.0.3
istanbul-lib-report: 3.0.0
- istanbul-lib-source-maps: 4.0.0
- istanbul-reports: 3.0.2
+ istanbul-lib-source-maps: 4.0.1
+ istanbul-reports: 3.1.5
make-dir: 3.1.0
node-preload: 0.2.1
p-map: 3.0.0
process-on-spawn: 1.0.0
resolve-from: 5.0.0
rimraf: 3.0.2
- signal-exit: 3.0.3
+ signal-exit: 3.0.7
spawn-wrap: 2.0.0
test-exclude: 6.0.0
yargs: 15.4.1
@@ -16429,17 +14597,16 @@ packages:
- supports-color
dev: true
- /oauth-sign/0.9.0:
+ /oauth-sign@0.9.0:
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
dev: true
- /object-assign/4.1.1:
- resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=}
+ /object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
- dev: true
- /object-copy/0.1.0:
- resolution: {integrity: sha1-fn2Fi3gb18mRpBupde04EnVOmYw=}
+ /object-copy@0.1.0:
+ resolution: {integrity: sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==}
engines: {node: '>=0.10.0'}
dependencies:
copy-descriptor: 0.1.1
@@ -16447,189 +14614,181 @@ packages:
kind-of: 3.2.2
dev: true
- /object-inspect/1.11.0:
- resolution: {integrity: sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==}
- dev: true
-
- /object-inspect/1.9.0:
- resolution: {integrity: sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==}
- dev: true
+ /object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
- /object-is/1.1.5:
- resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==}
- engines: {node: '>= 0.4'}
- dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
+ /object-inspect@1.13.1:
+ resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==}
dev: true
- /object-keys/1.1.1:
+ /object-keys@1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
dev: true
- /object-visit/1.0.1:
- resolution: {integrity: sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=}
+ /object-visit@1.0.1:
+ resolution: {integrity: sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==}
engines: {node: '>=0.10.0'}
dependencies:
isobject: 3.0.1
dev: true
- /object.assign/4.1.2:
- resolution: {integrity: sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==}
+ /object.assign@4.1.5:
+ resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- has-symbols: 1.0.2
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ has-symbols: 1.0.3
object-keys: 1.1.1
dev: true
- /object.entries/1.1.3:
- resolution: {integrity: sha512-ym7h7OZebNS96hn5IJeyUmaWhaSM4SVtAPPfNLQEI2MYWCO2egsITb9nab2+i/Pwibx+R0mtn+ltKJXRSeTMGg==}
+ /object.entries@1.1.7:
+ resolution: {integrity: sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.0-next.2
- has: 1.0.3
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
dev: true
- /object.entries/1.1.4:
- resolution: {integrity: sha512-h4LWKWE+wKQGhtMjZEBud7uLGhqyLwj8fpHOarZhD2uY3C9cRtk57VQ89ke3moByLXMedqs3XCHzyb4AmA2DjA==}
+ /object.fromentries@2.0.7:
+ resolution: {integrity: sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
dev: true
- /object.fromentries/2.0.3:
- resolution: {integrity: sha512-IDUSMXs6LOSJBWE++L0lzIbSqHl9KDCfff2x/JSEIDtEUavUnyMYC2ZGay/04Zq4UT8lvd4xNhU4/YHKibAOlw==}
- engines: {node: '>= 0.4'}
+ /object.getownpropertydescriptors@2.1.4:
+ resolution: {integrity: sha512-sccv3L/pMModT6dJAYF3fzGMVcb38ysQ0tEE6ixv2yXJDtEIPph268OlAdJj5/qZMZDq2g/jqvwppt36uS/uQQ==}
+ engines: {node: '>= 0.8'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.0-next.2
- has: 1.0.3
+ array.prototype.reduce: 1.0.4
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
dev: true
- /object.fromentries/2.0.4:
- resolution: {integrity: sha512-EsFBshs5RUUpQEY1D4q/m59kMfz4YJvxuNCJcv/jWwOJr34EaVnG11ZrZa0UHB3wnzV1wx8m58T4hQL8IuNXlQ==}
- engines: {node: '>= 0.4'}
+ /object.groupby@1.0.1:
+ resolution: {integrity: sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
- has: 1.0.3
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ get-intrinsic: 1.2.2
dev: true
- /object.getownpropertydescriptors/2.1.2:
- resolution: {integrity: sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ==}
- engines: {node: '>= 0.8'}
+ /object.hasown@1.1.3:
+ resolution: {integrity: sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA==}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
dev: true
- /object.pick/1.3.0:
- resolution: {integrity: sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=}
+ /object.omit@3.0.0:
+ resolution: {integrity: sha512-EO+BCv6LJfu+gBIF3ggLicFebFLN5zqzz/WWJlMFfkMyGth+oBkhxzDl0wx2W4GkLzuQs/FsSkXZb2IMWQqmBQ==}
engines: {node: '>=0.10.0'}
dependencies:
- isobject: 3.0.1
+ is-extendable: 1.0.1
dev: true
- /object.values/1.1.2:
- resolution: {integrity: sha512-MYC0jvJopr8EK6dPBiO8Nb9mvjdypOachO5REGk6MXzujbBrAisKo3HmdEI6kZDL6fC31Mwee/5YbtMebixeag==}
- engines: {node: '>= 0.4'}
+ /object.pick@1.3.0:
+ resolution: {integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==}
+ engines: {node: '>=0.10.0'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.0-next.2
- has: 1.0.3
+ isobject: 3.0.1
dev: true
- /object.values/1.1.4:
- resolution: {integrity: sha512-TnGo7j4XSnKQoK3MfvkzqKCi0nVe/D9I9IjwTNYdb/fxYHpjrluHVOgw0AF6jrRFGMPHdfuidR09tIDiIvnaSg==}
+ /object.values@1.1.7:
+ resolution: {integrity: sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
dev: true
- /obuf/1.1.2:
+ /obuf@1.1.2:
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
dev: true
- /on-finished/2.3.0:
- resolution: {integrity: sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=}
+ /on-exit-leak-free@2.1.2:
+ resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
+ engines: {node: '>=14.0.0'}
+ dev: true
+
+ /on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
dependencies:
ee-first: 1.1.1
dev: true
- /on-headers/1.0.2:
+ /on-headers@1.0.2:
resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==}
engines: {node: '>= 0.8'}
dev: true
- /once/1.4.0:
- resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=}
+ /once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies:
wrappy: 1.0.2
- dev: true
- /onetime/5.1.2:
+ /onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
dependencies:
mimic-fn: 2.1.0
dev: true
- /onigasm/2.2.5:
- resolution: {integrity: sha512-F+th54mPc0l1lp1ZcFMyL/jTs2Tlq4SqIHKIXGZOR/VkHkF9A7Fr5rRr5+ZG/lWeRsyrClLYRq7s/yFQ/XhWCA==}
+ /onetime@6.0.0:
+ resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
+ engines: {node: '>=12'}
dependencies:
- lru-cache: 5.1.1
+ mimic-fn: 4.0.0
dev: true
- /open/7.4.2:
- resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
- engines: {node: '>=8'}
+ /open@8.4.0:
+ resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==}
+ engines: {node: '>=12'}
dependencies:
+ define-lazy-prop: 2.0.0
is-docker: 2.2.1
is-wsl: 2.2.0
dev: true
- /opener/1.5.2:
- resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
- hasBin: true
+ /open@8.4.2:
+ resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
+ engines: {node: '>=12'}
+ dependencies:
+ define-lazy-prop: 2.0.0
+ is-docker: 2.2.1
+ is-wsl: 2.2.0
dev: true
- /opn/5.5.0:
- resolution: {integrity: sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA==}
- engines: {node: '>=4'}
- dependencies:
- is-wsl: 1.1.0
+ /opener@1.5.2:
+ resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
dev: true
- /optimize-css-assets-webpack-plugin/6.0.1_webpack@4.46.0:
+ /optimize-css-assets-webpack-plugin@6.0.1(webpack@4.46.0):
resolution: {integrity: sha512-BshV2UZPfggZLdUfN3zFBbG4sl/DynUI+YCB6fRRDWaqO2OiWN8GPcp4Y0/fEV6B3k9Hzyk3czve3V/8B/SzKQ==}
peerDependencies:
webpack: ^4.0.0
dependencies:
- cssnano: 5.0.8_postcss@8.3.6
+ cssnano: 5.1.13(postcss@8.4.32)
last-call-webpack-plugin: 3.0.0
- postcss: 8.3.6
+ postcss: 8.4.32
webpack: 4.46.0
dev: true
- /optionator/0.8.3:
+ /optionator@0.8.3:
resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==}
engines: {node: '>= 0.8.0'}
dependencies:
- deep-is: 0.1.3
+ deep-is: 0.1.4
fast-levenshtein: 2.0.6
levn: 0.3.0
prelude-ls: 1.1.2
@@ -16637,11 +14796,11 @@ packages:
word-wrap: 1.2.3
dev: true
- /optionator/0.9.1:
+ /optionator@0.9.1:
resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==}
engines: {node: '>= 0.8.0'}
dependencies:
- deep-is: 0.1.3
+ deep-is: 0.1.4
fast-levenshtein: 2.0.6
levn: 0.4.1
prelude-ls: 1.2.1
@@ -16649,226 +14808,209 @@ packages:
word-wrap: 1.2.3
dev: true
- /ora/5.4.1:
+ /optionator@0.9.3:
+ resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
+ engines: {node: '>= 0.8.0'}
+ dependencies:
+ '@aashutoshrathi/word-wrap': 1.2.6
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ dev: true
+
+ /ora@5.4.1:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
dependencies:
bl: 4.1.0
chalk: 4.1.2
cli-cursor: 3.1.0
- cli-spinners: 2.6.0
+ cli-spinners: 2.7.0
is-interactive: 1.0.0
is-unicode-supported: 0.1.0
log-symbols: 4.1.0
- strip-ansi: 6.0.0
+ strip-ansi: 6.0.1
wcwidth: 1.0.1
dev: true
- /original/1.0.2:
- resolution: {integrity: sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==}
- dependencies:
- url-parse: 1.5.3
+ /os-browserify@0.3.0:
+ resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==}
dev: true
- /os-browserify/0.3.0:
- resolution: {integrity: sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=}
- dev: true
-
- /os-tmpdir/1.0.2:
- resolution: {integrity: sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=}
- engines: {node: '>=0.10.0'}
- dev: true
-
- /overlayscrollbars/1.13.1:
- resolution: {integrity: sha512-gIQfzgGgu1wy80EB4/6DaJGHMEGmizq27xHIESrzXq0Y/J0Ay1P3DWk6tuVmEPIZH15zaBlxeEJOqdJKmowHCQ==}
+ /os-locale@5.0.0:
+ resolution: {integrity: sha512-tqZcNEDAIZKBEPnHPlVDvKrp7NzgLi7jRmhKiUoa2NUmhl13FtkAGLUVR+ZsYvApBQdBfYm43A4tXXQ4IrYLBA==}
+ engines: {node: '>=10'}
+ dependencies:
+ execa: 4.1.0
+ lcid: 3.1.1
+ mem: 5.1.1
dev: true
- /p-all/2.1.0:
- resolution: {integrity: sha512-HbZxz5FONzz/z2gJfk6bFca0BCiSRF8jU3yCsWOen/vR6lZjfPOu/e7L3uFzTW1i0H8TlC3vqQstEJPQL4/uLA==}
- engines: {node: '>=6'}
- dependencies:
- p-map: 2.1.0
+ /os-shim@0.1.3:
+ resolution: {integrity: sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==}
+ engines: {node: '>= 0.4.0'}
dev: true
- /p-cancelable/1.1.0:
+ /p-cancelable@1.1.0:
resolution: {integrity: sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==}
engines: {node: '>=6'}
dev: true
- /p-defer/1.0.0:
- resolution: {integrity: sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=}
- engines: {node: '>=4'}
- dev: true
-
- /p-each-series/2.2.0:
- resolution: {integrity: sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA==}
- engines: {node: '>=8'}
- dev: true
-
- /p-event/4.2.0:
- resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==}
- engines: {node: '>=8'}
- dependencies:
- p-timeout: 3.2.0
+ /p-cancelable@3.0.0:
+ resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==}
+ engines: {node: '>=12.20'}
dev: true
- /p-filter/2.1.0:
- resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==}
- engines: {node: '>=8'}
- dependencies:
- p-map: 2.1.0
- dev: true
-
- /p-finally/1.0.0:
- resolution: {integrity: sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=}
+ /p-defer@1.0.0:
+ resolution: {integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==}
engines: {node: '>=4'}
dev: true
- /p-limit/1.3.0:
- resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==}
- engines: {node: '>=4'}
- dependencies:
- p-try: 1.0.0
+ /p-is-promise@2.1.0:
+ resolution: {integrity: sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==}
+ engines: {node: '>=6'}
dev: true
- /p-limit/2.3.0:
+ /p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
dependencies:
p-try: 2.2.0
dev: true
- /p-limit/3.1.0:
+ /p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
dependencies:
yocto-queue: 0.1.0
dev: true
- /p-locate/2.0.0:
- resolution: {integrity: sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=}
- engines: {node: '>=4'}
- dependencies:
- p-limit: 1.3.0
- dev: true
-
- /p-locate/3.0.0:
+ /p-locate@3.0.0:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'}
dependencies:
p-limit: 2.3.0
dev: true
- /p-locate/4.1.0:
+ /p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
dependencies:
p-limit: 2.3.0
dev: true
- /p-locate/5.0.0:
+ /p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
dependencies:
p-limit: 3.1.0
dev: true
- /p-map/2.1.0:
- resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==}
- engines: {node: '>=6'}
- dev: true
-
- /p-map/3.0.0:
+ /p-map@3.0.0:
resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==}
engines: {node: '>=8'}
dependencies:
aggregate-error: 3.1.0
dev: true
- /p-map/4.0.0:
+ /p-map@4.0.0:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
dependencies:
aggregate-error: 3.1.0
dev: true
- /p-retry/3.0.1:
- resolution: {integrity: sha512-XE6G4+YTTkT2a0UWb2kjZe8xNwf8bIbnqpc/IS/idOBVhyves0mK5OJgeocjx7q5pvX/6m23xuzVPYT1uGM73w==}
- engines: {node: '>=6'}
- dependencies:
- retry: 0.12.0
+ /p-map@6.0.0:
+ resolution: {integrity: sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==}
+ engines: {node: '>=16'}
dev: true
- /p-timeout/3.2.0:
- resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
+ /p-retry@4.6.2:
+ resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
engines: {node: '>=8'}
dependencies:
- p-finally: 1.0.0
- dev: true
-
- /p-try/1.0.0:
- resolution: {integrity: sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=}
- engines: {node: '>=4'}
+ '@types/retry': 0.12.0
+ retry: 0.13.1
dev: true
- /p-try/2.2.0:
+ /p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
dev: true
- /package-hash/4.0.0:
+ /package-config@5.0.0:
+ resolution: {integrity: sha512-GYTTew2slBcYdvRHqjhwaaydVMvn/qrGC323+nKclYioNSLTDUM/lGgtGTgyHVtYcozb+XkE8CNhwcraOmZ9Mg==}
+ engines: {node: '>=18'}
+ dependencies:
+ find-up-simple: 1.0.0
+ load-json-file: 7.0.1
+ dev: true
+
+ /package-hash@4.0.0:
resolution: {integrity: sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==}
engines: {node: '>=8'}
dependencies:
- graceful-fs: 4.2.4
+ graceful-fs: 4.2.11
hasha: 5.2.2
lodash.flattendeep: 4.4.0
release-zalgo: 1.0.0
dev: true
- /package-json/6.5.0:
+ /package-json@6.5.0:
resolution: {integrity: sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==}
engines: {node: '>=8'}
dependencies:
got: 9.6.0
- registry-auth-token: 4.2.1
+ registry-auth-token: 4.2.2
registry-url: 5.1.0
- semver: 6.3.0
+ semver: 6.3.1
+ dev: true
+
+ /package-json@8.1.1:
+ resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==}
+ engines: {node: '>=14.16'}
+ dependencies:
+ got: 12.6.1
+ registry-auth-token: 5.0.2
+ registry-url: 6.0.1
+ semver: 7.5.4
dev: true
- /pako/1.0.11:
+ /pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
dev: true
- /parallel-transform/1.2.0:
+ /parallel-transform@1.2.0:
resolution: {integrity: sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==}
dependencies:
- cyclist: 1.0.1
+ cyclist: 1.0.2
inherits: 2.0.4
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
dev: true
- /param-case/2.1.1:
- resolution: {integrity: sha1-35T9jPZTHs915r75oIWPvHK+Ikc=}
+ /param-case@2.1.1:
+ resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==}
dependencies:
no-case: 2.3.2
dev: true
- /param-case/3.0.4:
+ /param-case@3.0.4:
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
dependencies:
dot-case: 3.0.4
- tslib: 2.3.1
+ tslib: 2.6.2
dev: true
- /parent-module/1.0.1:
+ /parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
dependencies:
callsites: 3.1.0
dev: true
- /parse-asn1/5.1.6:
+ /parse-asn1@5.1.6:
resolution: {integrity: sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==}
dependencies:
asn1.js: 5.4.1
@@ -16878,151 +15020,139 @@ packages:
safe-buffer: 5.2.1
dev: true
- /parse-entities/2.0.0:
- resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==}
- dependencies:
- character-entities: 1.2.4
- character-entities-legacy: 1.1.4
- character-reference-invalid: 1.1.4
- is-alphanumerical: 1.0.4
- is-decimal: 1.0.4
- is-hexadecimal: 1.0.4
- dev: true
-
- /parse-json/2.2.0:
- resolution: {integrity: sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=}
- engines: {node: '>=0.10.0'}
- dependencies:
- error-ex: 1.3.2
- dev: true
-
- /parse-json/4.0.0:
- resolution: {integrity: sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=}
+ /parse-json@4.0.0:
+ resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==}
engines: {node: '>=4'}
dependencies:
error-ex: 1.3.2
json-parse-better-errors: 1.0.2
dev: true
- /parse-json/5.2.0:
+ /parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
dependencies:
- '@babel/code-frame': 7.14.5
+ '@babel/code-frame': 7.23.5
error-ex: 1.3.2
json-parse-even-better-errors: 2.3.1
- lines-and-columns: 1.1.6
+ lines-and-columns: 1.2.4
dev: true
- /parse-ms/2.1.0:
- resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==}
- engines: {node: '>=6'}
+ /parse-json@6.0.2:
+ resolution: {integrity: sha512-SA5aMiaIjXkAiBrW/yPgLgQAQg42f7K3ACO+2l/zOvtQBwX58DMUsFJXelW2fx3yMBmWOVkR6j1MGsdSbCA4UA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dependencies:
+ '@babel/code-frame': 7.23.5
+ error-ex: 1.3.2
+ json-parse-even-better-errors: 2.3.1
+ lines-and-columns: 2.0.4
+ dev: true
+
+ /parse-ms@3.0.0:
+ resolution: {integrity: sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==}
+ engines: {node: '>=12'}
dev: true
- /parse5-htmlparser2-tree-adapter/6.0.1:
- resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==}
+ /parse5-htmlparser2-tree-adapter@7.0.0:
+ resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
dependencies:
- parse5: 6.0.1
+ domhandler: 5.0.3
+ parse5: 7.1.2
dev: true
- /parse5/4.0.0:
+ /parse5@4.0.0:
resolution: {integrity: sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==}
dev: true
- /parse5/5.1.0:
+ /parse5@5.1.0:
resolution: {integrity: sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==}
dev: true
- /parse5/6.0.1:
- resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
+ /parse5@7.1.2:
+ resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
+ dependencies:
+ entities: 4.5.0
dev: true
- /parseurl/1.3.3:
+ /parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
dev: true
- /pascal-case/3.1.2:
+ /pascal-case@3.1.2:
resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==}
dependencies:
no-case: 3.0.4
- tslib: 2.3.1
+ tslib: 2.6.2
dev: true
- /pascalcase/0.1.1:
- resolution: {integrity: sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=}
+ /pascalcase@0.1.1:
+ resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==}
engines: {node: '>=0.10.0'}
dev: true
- /path-browserify/0.0.1:
+ /path-browserify@0.0.1:
resolution: {integrity: sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==}
dev: true
- /path-dirname/1.0.2:
- resolution: {integrity: sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=}
+ /path-dirname@1.0.2:
+ resolution: {integrity: sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==}
+ requiresBuild: true
dev: true
+ optional: true
- /path-exists/3.0.0:
- resolution: {integrity: sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=}
+ /path-exists@3.0.0:
+ resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
engines: {node: '>=4'}
dev: true
- /path-exists/4.0.0:
+ /path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
dev: true
- /path-is-absolute/1.0.1:
- resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=}
+ /path-is-absolute@1.0.1:
+ resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
- dev: true
-
- /path-is-inside/1.0.2:
- resolution: {integrity: sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=}
- dev: true
- /path-key/2.0.1:
- resolution: {integrity: sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=}
- engines: {node: '>=4'}
- dev: true
-
- /path-key/3.1.1:
+ /path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
- dev: true
- /path-parse/1.0.6:
- resolution: {integrity: sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==}
+ /path-key@4.0.0:
+ resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
+ engines: {node: '>=12'}
dev: true
- /path-parse/1.0.7:
+ /path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
- dev: true
-
- /path-to-regexp/0.1.7:
- resolution: {integrity: sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=}
- dev: true
- /path-type/2.0.0:
- resolution: {integrity: sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=}
- engines: {node: '>=4'}
+ /path-scurry@1.10.1:
+ resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==}
+ engines: {node: '>=16 || 14 >=14.17'}
dependencies:
- pify: 2.3.0
- dev: true
+ lru-cache: 10.1.0
+ minipass: 7.0.4
- /path-type/3.0.0:
- resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==}
- engines: {node: '>=4'}
- dependencies:
- pify: 3.0.0
+ /path-to-regexp@0.1.7:
+ resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==}
dev: true
- /path-type/4.0.0:
+ /path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
dev: true
- /pbkdf2/3.1.2:
+ /path-type@5.0.0:
+ resolution: {integrity: sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /pathval@1.1.1:
+ resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
+ dev: true
+
+ /pbkdf2@3.1.2:
resolution: {integrity: sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==}
engines: {node: '>=0.12'}
dependencies:
@@ -17033,137 +15163,107 @@ packages:
sha.js: 2.4.11
dev: true
- /performance-now/2.1.0:
- resolution: {integrity: sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=}
+ /pend@1.2.0:
+ resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
dev: true
- /picomatch/2.2.2:
- resolution: {integrity: sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==}
- engines: {node: '>=8.6'}
+ /performance-now@2.1.0:
+ resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
dev: true
- /picomatch/2.3.0:
- resolution: {integrity: sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==}
- engines: {node: '>=8.6'}
+ /picocolors@0.2.1:
+ resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==}
dev: true
- /pify/2.3.0:
- resolution: {integrity: sha1-7RQaasBDqEnqWISY59yosVMw6Qw=}
- engines: {node: '>=0.10.0'}
- dev: true
+ /picocolors@1.0.0:
+ resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
- /pify/3.0.0:
- resolution: {integrity: sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=}
- engines: {node: '>=4'}
- dev: true
+ /picomatch@2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
- /pify/4.0.1:
- resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
- engines: {node: '>=6'}
+ /picomatch@3.0.1:
+ resolution: {integrity: sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==}
+ engines: {node: '>=10'}
dev: true
- /pinkie-promise/2.0.1:
- resolution: {integrity: sha1-ITXW36ejWMBprJsXh3YogihFD/o=}
+ /pify@2.3.0:
+ resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
- dependencies:
- pinkie: 2.0.4
- dev: true
- /pinkie/2.0.4:
- resolution: {integrity: sha1-clVrgM+g1IqXToDnckjoDtT3+HA=}
- engines: {node: '>=0.10.0'}
+ /pify@4.0.1:
+ resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
+ engines: {node: '>=6'}
dev: true
- /pirates/4.0.1:
- resolution: {integrity: sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA==}
- engines: {node: '>= 6'}
+ /pino-abstract-transport@1.1.0:
+ resolution: {integrity: sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==}
dependencies:
- node-modules-regexp: 1.0.0
+ readable-stream: 4.5.2
+ split2: 4.2.0
dev: true
- /pkg-conf/3.1.0:
- resolution: {integrity: sha512-m0OTbR/5VPNPqO1ph6Fqbj7Hv6QU7gR/tQW40ZqrL1rjgCU85W6C1bJn0BItuJqnR98PWzw7Z8hHeChD1WrgdQ==}
- engines: {node: '>=6'}
- dependencies:
- find-up: 3.0.0
- load-json-file: 5.3.0
+ /pino-std-serializers@6.2.2:
+ resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==}
dev: true
- /pkg-dir/2.0.0:
- resolution: {integrity: sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=}
- engines: {node: '>=4'}
+ /pino@8.17.2:
+ resolution: {integrity: sha512-LA6qKgeDMLr2ux2y/YiUt47EfgQ+S9LznBWOJdN3q1dx2sv0ziDLUBeVpyVv17TEcGCBuWf0zNtg3M5m1NhhWQ==}
+ hasBin: true
dependencies:
- find-up: 2.1.0
- dev: true
+ atomic-sleep: 1.0.0
+ fast-redact: 3.3.0
+ on-exit-leak-free: 2.1.2
+ pino-abstract-transport: 1.1.0
+ pino-std-serializers: 6.2.2
+ process-warning: 3.0.0
+ quick-format-unescaped: 4.0.4
+ real-require: 0.2.0
+ safe-stable-stringify: 2.4.3
+ sonic-boom: 3.8.0
+ thread-stream: 2.4.1
+ dev: true
+
+ /pirates@4.0.5:
+ resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==}
+ engines: {node: '>= 6'}
- /pkg-dir/3.0.0:
+ /pkg-dir@3.0.0:
resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==}
engines: {node: '>=6'}
dependencies:
find-up: 3.0.0
dev: true
- /pkg-dir/4.2.0:
+ /pkg-dir@4.2.0:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
dependencies:
find-up: 4.1.0
dev: true
- /pkg-dir/5.0.0:
- resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==}
- engines: {node: '>=10'}
- dependencies:
- find-up: 5.0.0
- dev: true
-
- /pkg-up/3.1.0:
- resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
- engines: {node: '>=8'}
- dependencies:
- find-up: 3.0.0
- dev: true
-
- /plur/4.0.0:
- resolution: {integrity: sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==}
- engines: {node: '>=10'}
+ /plur@5.1.0:
+ resolution: {integrity: sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
- irregular-plurals: 3.3.0
+ irregular-plurals: 3.5.0
dev: true
- /pn/1.1.0:
+ /pn@1.1.0:
resolution: {integrity: sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==}
dev: true
- /pnp-webpack-plugin/1.6.4_typescript@3.9.10:
- resolution: {integrity: sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg==}
- engines: {node: '>=6'}
- dependencies:
- ts-pnp: 1.2.0_typescript@3.9.10
- transitivePeerDependencies:
- - typescript
- dev: true
-
- /pnp-webpack-plugin/1.6.4_typescript@4.3.5:
- resolution: {integrity: sha512-7Wjy+9E3WwLOEL30D+m8TSTF7qJJUJLONBnwQp0518siuMxUQUbgZwssaFX+QKlZkjHZcw/IpZCt/H0srrntSg==}
- engines: {node: '>=6'}
- dependencies:
- ts-pnp: 1.2.0_typescript@4.3.5
- transitivePeerDependencies:
- - typescript
- dev: true
-
- /pnp-webpack-plugin/1.7.0_typescript@4.4.3:
+ /pnp-webpack-plugin@1.7.0(typescript@4.6.4):
resolution: {integrity: sha512-2Rb3vm+EXble/sMXNSu6eoBx8e79gKqhNq9F5ZWW6ERNCTE/Q0wQNne5541tE5vKjfM8hpNCYL+LGc1YTfI0dg==}
engines: {node: '>=6'}
dependencies:
- ts-pnp: 1.2.0_typescript@4.3.5
+ ts-pnp: 1.2.0(typescript@4.6.4)
transitivePeerDependencies:
- typescript
dev: true
- /po2json/0.4.5:
- resolution: {integrity: sha1-R7spUtoy1Yob4vJWpZjuvAt0URg=}
+ /po2json@0.4.5:
+ resolution: {integrity: sha512-JH0hgi1fC0t9UvdiyS7kcVly0N1WNey4R2YZ/jPaxQKYm6Cfej7ZTgiEy8LP2JwoEhONceiNS8JH5mWPQkiXeA==}
engines: {node: '>= 0.8.0'}
hasBin: true
dependencies:
@@ -17171,650 +15271,661 @@ packages:
nomnom: 1.8.1
dev: true
- /polished/4.1.3:
- resolution: {integrity: sha512-ocPAcVBUOryJEKe0z2KLd1l9EBa1r5mSwlKpExmrLzsnIzJo4axsoU9O2BjOTkDGDT4mZ0WFE5XKTlR3nLnZOA==}
+ /polished@4.2.2:
+ resolution: {integrity: sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==}
engines: {node: '>=10'}
dependencies:
- '@babel/runtime': 7.15.3
- dev: true
-
- /portfinder/1.0.28:
- resolution: {integrity: sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA==}
- engines: {node: '>= 0.12.0'}
- dependencies:
- async: 2.6.3
- debug: 3.2.7
- mkdirp: 0.5.5
+ '@babel/runtime': 7.18.9
dev: true
- /posix-character-classes/0.1.1:
- resolution: {integrity: sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=}
+ /posix-character-classes@0.1.1:
+ resolution: {integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==}
engines: {node: '>=0.10.0'}
dev: true
- /postcss-calc/7.0.5:
+ /postcss-calc@7.0.5:
resolution: {integrity: sha512-1tKHutbGtLtEZF6PT4JSihCHfIVldU72mZ8SdZHIYriIZ9fh9k9aWSppaT8rHsyI3dX+KSR+W+Ix9BMY3AODrg==}
dependencies:
- postcss: 7.0.36
- postcss-selector-parser: 6.0.6
- postcss-value-parser: 4.1.0
+ postcss: 7.0.39
+ postcss-selector-parser: 6.0.12
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-calc/8.0.0_postcss@8.3.6:
- resolution: {integrity: sha512-5NglwDrcbiy8XXfPM11F3HeC6hoT9W7GUH/Zi5U/p7u3Irv4rHhdDcIZwG0llHXV4ftsBjpfWMXAnXNl4lnt8g==}
+ /postcss-calc@8.2.4(postcss@8.4.32):
+ resolution: {integrity: sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==}
peerDependencies:
postcss: ^8.2.2
dependencies:
- postcss: 8.3.6
- postcss-selector-parser: 6.0.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
+ postcss-selector-parser: 6.0.12
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-colormin/4.0.3:
+ /postcss-cli@10.1.0(postcss@8.4.23):
+ resolution: {integrity: sha512-Zu7PLORkE9YwNdvOeOVKPmWghprOtjFQU3srMUGbdz3pHJiFh7yZ4geiZFMkjMfB0mtTFR3h8RemR62rPkbOPA==}
+ engines: {node: '>=14'}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.0.0
+ dependencies:
+ chokidar: 3.5.3
+ dependency-graph: 0.11.0
+ fs-extra: 11.1.1
+ get-stdin: 9.0.0
+ globby: 13.2.2
+ picocolors: 1.0.0
+ postcss: 8.4.23
+ postcss-load-config: 4.0.1(postcss@8.4.23)
+ postcss-reporter: 7.0.5(postcss@8.4.23)
+ pretty-hrtime: 1.0.3
+ read-cache: 1.0.0
+ slash: 5.0.1
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - ts-node
+ dev: true
+
+ /postcss-colormin@4.0.3:
resolution: {integrity: sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw==}
engines: {node: '>=6.9.0'}
dependencies:
- browserslist: 4.16.8
+ browserslist: 4.22.2
color: 3.2.1
has: 1.0.3
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-colormin/5.2.0_postcss@8.3.6:
- resolution: {integrity: sha512-+HC6GfWU3upe5/mqmxuqYZ9B2Wl4lcoUUNkoaX59nEWV4EtADCMiBqui111Bu8R8IvaZTmqmxrqOAqjbHIwXPw==}
+ /postcss-colormin@5.3.0(postcss@8.4.32):
+ resolution: {integrity: sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- browserslist: 4.16.8
+ browserslist: 4.22.2
caniuse-api: 3.0.0
- colord: 2.7.0
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ colord: 2.9.3
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-convert-values/4.0.1:
+ /postcss-convert-values@4.0.1:
resolution: {integrity: sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ==}
engines: {node: '>=6.9.0'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-convert-values/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-C3zR1Do2BkKkCgC0g3sF8TS0koF2G+mN8xxayZx3f10cIRmTaAnpgpRQZjNekTZxM2ciSPoh2IWJm0VZx8NoQg==}
+ /postcss-convert-values@5.1.2(postcss@8.4.32):
+ resolution: {integrity: sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ browserslist: 4.22.2
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-discard-comments/4.0.2:
+ /postcss-discard-comments@4.0.2:
resolution: {integrity: sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg==}
engines: {node: '>=6.9.0'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
dev: true
- /postcss-discard-comments/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-lgZBPTDvWrbAYY1v5GYEv8fEO/WhKOu/hmZqmCYfrpD6eyDWWzAOsl2rF29lpvziKO02Gc5GJQtlpkTmakwOWg==}
+ /postcss-discard-comments@5.1.2(postcss@8.4.32):
+ resolution: {integrity: sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
+ postcss: 8.4.32
dev: true
- /postcss-discard-duplicates/4.0.2:
+ /postcss-discard-duplicates@4.0.2:
resolution: {integrity: sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ==}
engines: {node: '>=6.9.0'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
dev: true
- /postcss-discard-duplicates/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-svx747PWHKOGpAXXQkCc4k/DsWo+6bc5LsVrAsw+OU+Ibi7klFZCyX54gjYzX4TH+f2uzXjRviLARxkMurA2bA==}
+ /postcss-discard-duplicates@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
+ postcss: 8.4.32
dev: true
- /postcss-discard-empty/4.0.1:
+ /postcss-discard-empty@4.0.1:
resolution: {integrity: sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w==}
engines: {node: '>=6.9.0'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
dev: true
- /postcss-discard-empty/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-vfU8CxAQ6YpMxV2SvMcMIyF2LX1ZzWpy0lqHDsOdaKKLQVQGVP1pzhrI9JlsO65s66uQTfkQBKBD/A5gp9STFw==}
+ /postcss-discard-empty@5.1.1(postcss@8.4.32):
+ resolution: {integrity: sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
+ postcss: 8.4.32
dev: true
- /postcss-discard-overridden/4.0.1:
+ /postcss-discard-overridden@4.0.1:
resolution: {integrity: sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg==}
engines: {node: '>=6.9.0'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
dev: true
- /postcss-discard-overridden/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-Y28H7y93L2BpJhrdUR2SR2fnSsT+3TVx1NmVQLbcnZWwIUpJ7mfcTC6Za9M2PG6w8j7UQRfzxqn8jU2VwFxo3Q==}
+ /postcss-discard-overridden@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
+ postcss: 8.4.32
dev: true
- /postcss-flexbugs-fixes/4.2.1:
- resolution: {integrity: sha512-9SiofaZ9CWpQWxOwRh1b/r85KD5y7GgvsNt1056k6OYLvWUun0czCvogfJgylC22uJTwW1KzY3Gz65NZRlvoiQ==}
+ /postcss-import@15.1.0(postcss@8.4.23):
+ resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ postcss: ^8.0.0
dependencies:
- postcss: 7.0.36
- dev: true
+ postcss: 8.4.23
+ postcss-value-parser: 4.2.0
+ read-cache: 1.0.0
+ resolve: 1.22.8
- /postcss-load-config/3.1.0:
- resolution: {integrity: sha512-ipM8Ds01ZUophjDTQYSVP70slFSYg3T0/zyfII5vzhN6V57YSxMgG5syXuwi5VtS8wSf3iL30v0uBdoIVx4Q0g==}
+ /postcss-js@4.0.1(postcss@8.4.23):
+ resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==}
+ engines: {node: ^12 || ^14 || >= 16}
+ peerDependencies:
+ postcss: ^8.4.21
+ dependencies:
+ camelcase-css: 2.0.1
+ postcss: 8.4.23
+
+ /postcss-load-config@3.1.4(postcss@8.4.32):
+ resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
engines: {node: '>= 10'}
peerDependencies:
+ postcss: '>=8.0.9'
ts-node: '>=9.0.0'
peerDependenciesMeta:
+ postcss:
+ optional: true
ts-node:
optional: true
dependencies:
- import-cwd: 3.0.0
- lilconfig: 2.0.3
+ lilconfig: 2.1.0
+ postcss: 8.4.32
yaml: 1.10.2
dev: true
- /postcss-loader/4.3.0_postcss@7.0.36+webpack@4.46.0:
- resolution: {integrity: sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==}
- engines: {node: '>= 10.13.0'}
+ /postcss-load-config@4.0.1(postcss@8.4.23):
+ resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==}
+ engines: {node: '>= 14'}
peerDependencies:
- postcss: ^7.0.0 || ^8.0.1
- webpack: ^4.0.0 || ^5.0.0
+ postcss: '>=8.0.9'
+ ts-node: '>=9.0.0'
+ peerDependenciesMeta:
+ postcss:
+ optional: true
+ ts-node:
+ optional: true
dependencies:
- cosmiconfig: 7.0.0
- klona: 2.0.4
- loader-utils: 2.0.0
- postcss: 7.0.36
- schema-utils: 3.1.1
- semver: 7.3.5
- webpack: 4.46.0
- dev: true
+ lilconfig: 2.1.0
+ postcss: 8.4.23
+ yaml: 2.2.2
- /postcss-loader/4.3.0_postcss@8.3.6+webpack@4.46.0:
+ /postcss-loader@4.3.0(postcss@8.4.32)(webpack@4.46.0):
resolution: {integrity: sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==}
engines: {node: '>= 10.13.0'}
peerDependencies:
postcss: ^7.0.0 || ^8.0.1
webpack: ^4.0.0 || ^5.0.0
dependencies:
- cosmiconfig: 7.0.0
- klona: 2.0.4
- loader-utils: 2.0.0
- postcss: 8.3.6
+ cosmiconfig: 7.0.1
+ klona: 2.0.5
+ loader-utils: 2.0.3
+ postcss: 8.4.32
schema-utils: 3.1.1
- semver: 7.3.5
+ semver: 7.5.4
webpack: 4.46.0
dev: true
- /postcss-merge-longhand/4.0.11:
+ /postcss-merge-longhand@4.0.11:
resolution: {integrity: sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw==}
engines: {node: '>=6.9.0'}
dependencies:
css-color-names: 0.0.4
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
stylehacks: 4.0.3
dev: true
- /postcss-merge-longhand/5.0.2_postcss@8.3.6:
- resolution: {integrity: sha512-BMlg9AXSI5G9TBT0Lo/H3PfUy63P84rVz3BjCFE9e9Y9RXQZD3+h3YO1kgTNsNJy7bBc1YQp8DmSnwLIW5VPcw==}
+ /postcss-merge-longhand@5.1.6(postcss@8.4.32):
+ resolution: {integrity: sha512-6C/UGF/3T5OE2CEbOuX7iNO63dnvqhGZeUnKkDeifebY0XqkkvrctYSZurpNE902LDf2yKwwPFgotnfSoPhQiw==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- css-color-names: 1.0.1
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
- stylehacks: 5.0.1_postcss@8.3.6
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
+ stylehacks: 5.1.0(postcss@8.4.32)
dev: true
- /postcss-merge-rules/4.0.3:
+ /postcss-merge-rules@4.0.3:
resolution: {integrity: sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ==}
engines: {node: '>=6.9.0'}
dependencies:
- browserslist: 4.16.8
+ browserslist: 4.22.2
caniuse-api: 3.0.0
cssnano-util-same-parent: 4.0.1
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-selector-parser: 3.1.2
vendors: 1.0.4
dev: true
- /postcss-merge-rules/5.0.2_postcss@8.3.6:
- resolution: {integrity: sha512-5K+Md7S3GwBewfB4rjDeol6V/RZ8S+v4B66Zk2gChRqLTCC8yjnHQ601omj9TKftS19OPGqZ/XzoqpzNQQLwbg==}
+ /postcss-merge-rules@5.1.2(postcss@8.4.32):
+ resolution: {integrity: sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- browserslist: 4.16.8
+ browserslist: 4.22.2
caniuse-api: 3.0.0
- cssnano-utils: 2.0.1_postcss@8.3.6
- postcss: 8.3.6
- postcss-selector-parser: 6.0.6
- vendors: 1.0.4
+ cssnano-utils: 3.1.0(postcss@8.4.32)
+ postcss: 8.4.32
+ postcss-selector-parser: 6.0.12
dev: true
- /postcss-minify-font-values/4.0.2:
+ /postcss-minify-font-values@4.0.2:
resolution: {integrity: sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg==}
engines: {node: '>=6.9.0'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-minify-font-values/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-7JS4qIsnqaxk+FXY1E8dHBDmraYFWmuL6cgt0T1SWGRO5bzJf8sUoelwa4P88LEWJZweHevAiDKxHlofuvtIoA==}
+ /postcss-minify-font-values@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-minify-gradients/4.0.2:
+ /postcss-minify-gradients@4.0.2:
resolution: {integrity: sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q==}
engines: {node: '>=6.9.0'}
dependencies:
cssnano-util-get-arguments: 4.0.0
is-color-stop: 1.1.0
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-minify-gradients/5.0.2_postcss@8.3.6:
- resolution: {integrity: sha512-7Do9JP+wqSD6Prittitt2zDLrfzP9pqKs2EcLX7HJYxsxCOwrrcLt4x/ctQTsiOw+/8HYotAoqNkrzItL19SdQ==}
+ /postcss-minify-gradients@5.1.1(postcss@8.4.32):
+ resolution: {integrity: sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- colord: 2.7.0
- cssnano-utils: 2.0.1_postcss@8.3.6
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ colord: 2.9.3
+ cssnano-utils: 3.1.0(postcss@8.4.32)
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-minify-params/4.0.2:
+ /postcss-minify-params@4.0.2:
resolution: {integrity: sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg==}
engines: {node: '>=6.9.0'}
dependencies:
alphanum-sort: 1.0.2
- browserslist: 4.16.8
+ browserslist: 4.22.2
cssnano-util-get-arguments: 4.0.0
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
uniqs: 2.0.0
dev: true
- /postcss-minify-params/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-4RUC4k2A/Q9mGco1Z8ODc7h+A0z7L7X2ypO1B6V8057eVK6mZ6xwz6QN64nHuHLbqbclkX1wyzRnIrdZehTEHw==}
+ /postcss-minify-params@5.1.3(postcss@8.4.32):
+ resolution: {integrity: sha512-bkzpWcjykkqIujNL+EVEPOlLYi/eZ050oImVtHU7b4lFS82jPnsCb44gvC6pxaNt38Els3jWYDHTjHKf0koTgg==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- alphanum-sort: 1.0.2
- browserslist: 4.16.8
- cssnano-utils: 2.0.1_postcss@8.3.6
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
- uniqs: 2.0.0
+ browserslist: 4.22.2
+ cssnano-utils: 3.1.0(postcss@8.4.32)
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-minify-selectors/4.0.2:
+ /postcss-minify-selectors@4.0.2:
resolution: {integrity: sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g==}
engines: {node: '>=6.9.0'}
dependencies:
alphanum-sort: 1.0.2
has: 1.0.3
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-selector-parser: 3.1.2
dev: true
- /postcss-minify-selectors/5.1.0_postcss@8.3.6:
- resolution: {integrity: sha512-NzGBXDa7aPsAcijXZeagnJBKBPMYLaJJzB8CQh6ncvyl2sIndLVWfbcDi0SBjRWk5VqEjXvf8tYwzoKf4Z07og==}
+ /postcss-minify-selectors@5.2.1(postcss@8.4.32):
+ resolution: {integrity: sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- alphanum-sort: 1.0.2
- postcss: 8.3.6
- postcss-selector-parser: 6.0.6
+ postcss: 8.4.32
+ postcss-selector-parser: 6.0.12
dev: true
- /postcss-modules-extract-imports/2.0.0:
- resolution: {integrity: sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==}
- engines: {node: '>= 6'}
- dependencies:
- postcss: 7.0.36
- dev: true
-
- /postcss-modules-extract-imports/3.0.0_postcss@8.3.6:
+ /postcss-modules-extract-imports@3.0.0(postcss@8.4.32):
resolution: {integrity: sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
dependencies:
- postcss: 8.3.6
- dev: true
-
- /postcss-modules-local-by-default/3.0.3:
- resolution: {integrity: sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==}
- engines: {node: '>= 6'}
- dependencies:
- icss-utils: 4.1.1
- postcss: 7.0.36
- postcss-selector-parser: 6.0.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
dev: true
- /postcss-modules-local-by-default/4.0.0_postcss@8.3.6:
+ /postcss-modules-local-by-default@4.0.0(postcss@8.4.32):
resolution: {integrity: sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
dependencies:
- icss-utils: 5.1.0_postcss@8.3.6
- postcss: 8.3.6
- postcss-selector-parser: 6.0.6
- postcss-value-parser: 4.1.0
- dev: true
-
- /postcss-modules-scope/2.2.0:
- resolution: {integrity: sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==}
- engines: {node: '>= 6'}
- dependencies:
- postcss: 7.0.36
- postcss-selector-parser: 6.0.6
+ icss-utils: 5.1.0(postcss@8.4.32)
+ postcss: 8.4.32
+ postcss-selector-parser: 6.0.12
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-modules-scope/3.0.0_postcss@8.3.6:
+ /postcss-modules-scope@3.0.0(postcss@8.4.32):
resolution: {integrity: sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
dependencies:
- postcss: 8.3.6
- postcss-selector-parser: 6.0.6
+ postcss: 8.4.32
+ postcss-selector-parser: 6.0.12
dev: true
- /postcss-modules-values/3.0.0:
- resolution: {integrity: sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==}
- dependencies:
- icss-utils: 4.1.1
- postcss: 7.0.36
- dev: true
-
- /postcss-modules-values/4.0.0_postcss@8.3.6:
+ /postcss-modules-values@4.0.0(postcss@8.4.32):
resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==}
engines: {node: ^10 || ^12 || >= 14}
peerDependencies:
postcss: ^8.1.0
dependencies:
- icss-utils: 5.1.0_postcss@8.3.6
- postcss: 8.3.6
+ icss-utils: 5.1.0(postcss@8.4.32)
+ postcss: 8.4.32
dev: true
- /postcss-normalize-charset/4.0.1:
+ /postcss-nested@6.0.1(postcss@8.4.23):
+ resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+ dependencies:
+ postcss: 8.4.23
+ postcss-selector-parser: 6.0.12
+
+ /postcss-normalize-charset@4.0.1:
resolution: {integrity: sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g==}
engines: {node: '>=6.9.0'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
dev: true
- /postcss-normalize-charset/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-6J40l6LNYnBdPSk+BHZ8SF+HAkS4q2twe5jnocgd+xWpz/mx/5Sa32m3W1AA8uE8XaXN+eg8trIlfu8V9x61eg==}
+ /postcss-normalize-charset@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
+ postcss: 8.4.32
dev: true
- /postcss-normalize-display-values/4.0.2:
+ /postcss-normalize-display-values@4.0.2:
resolution: {integrity: sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ==}
engines: {node: '>=6.9.0'}
dependencies:
cssnano-util-get-match: 4.0.0
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-normalize-display-values/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-uupdvWk88kLDXi5HEyI9IaAJTE3/Djbcrqq8YgjvAVuzgVuqIk3SuJWUisT2gaJbZm1H9g5k2w1xXilM3x8DjQ==}
+ /postcss-normalize-display-values@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- cssnano-utils: 2.0.1_postcss@8.3.6
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-normalize-positions/4.0.2:
+ /postcss-normalize-positions@4.0.2:
resolution: {integrity: sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA==}
engines: {node: '>=6.9.0'}
dependencies:
cssnano-util-get-arguments: 4.0.0
has: 1.0.3
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-normalize-positions/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-rvzWAJai5xej9yWqlCb1OWLd9JjW2Ex2BCPzUJrbaXmtKtgfL8dBMOOMTX6TnvQMtjk3ei1Lswcs78qKO1Skrg==}
+ /postcss-normalize-positions@5.1.1(postcss@8.4.32):
+ resolution: {integrity: sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-normalize-repeat-style/4.0.2:
+ /postcss-normalize-repeat-style@4.0.2:
resolution: {integrity: sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q==}
engines: {node: '>=6.9.0'}
dependencies:
cssnano-util-get-arguments: 4.0.0
cssnano-util-get-match: 4.0.0
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-normalize-repeat-style/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-syZ2itq0HTQjj4QtXZOeefomckiV5TaUO6ReIEabCh3wgDs4Mr01pkif0MeVwKyU/LHEkPJnpwFKRxqWA/7O3w==}
+ /postcss-normalize-repeat-style@5.1.1(postcss@8.4.32):
+ resolution: {integrity: sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- cssnano-utils: 2.0.1_postcss@8.3.6
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-normalize-string/4.0.2:
+ /postcss-normalize-string@4.0.2:
resolution: {integrity: sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA==}
engines: {node: '>=6.9.0'}
dependencies:
has: 1.0.3
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-normalize-string/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-Ic8GaQ3jPMVl1OEn2U//2pm93AXUcF3wz+OriskdZ1AOuYV25OdgS7w9Xu2LO5cGyhHCgn8dMXh9bO7vi3i9pA==}
+ /postcss-normalize-string@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-normalize-timing-functions/4.0.2:
+ /postcss-normalize-timing-functions@4.0.2:
resolution: {integrity: sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A==}
engines: {node: '>=6.9.0'}
dependencies:
cssnano-util-get-match: 4.0.0
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-normalize-timing-functions/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-cPcBdVN5OsWCNEo5hiXfLUnXfTGtSFiBU9SK8k7ii8UD7OLuznzgNRYkLZow11BkQiiqMcgPyh4ZqXEEUrtQ1Q==}
+ /postcss-normalize-timing-functions@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- cssnano-utils: 2.0.1_postcss@8.3.6
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-normalize-unicode/4.0.1:
+ /postcss-normalize-unicode@4.0.1:
resolution: {integrity: sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg==}
engines: {node: '>=6.9.0'}
dependencies:
- browserslist: 4.16.8
- postcss: 7.0.36
+ browserslist: 4.22.2
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-normalize-unicode/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-kAtYD6V3pK0beqrU90gpCQB7g6AOfP/2KIPCVBKJM2EheVsBQmx/Iof+9zR9NFKLAx4Pr9mDhogB27pmn354nA==}
+ /postcss-normalize-unicode@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- browserslist: 4.16.8
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ browserslist: 4.22.2
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-normalize-url/4.0.1:
+ /postcss-normalize-url@4.0.1:
resolution: {integrity: sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA==}
engines: {node: '>=6.9.0'}
dependencies:
is-absolute-url: 2.1.0
normalize-url: 3.3.0
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-normalize-url/5.0.2_postcss@8.3.6:
- resolution: {integrity: sha512-k4jLTPUxREQ5bpajFQZpx8bCF2UrlqOTzP9kEqcEnOfwsRshWs2+oAFIHfDQB8GO2PaUaSE0NlTAYtbluZTlHQ==}
+ /postcss-normalize-url@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- is-absolute-url: 3.0.3
normalize-url: 6.1.0
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-normalize-whitespace/4.0.2:
+ /postcss-normalize-whitespace@4.0.2:
resolution: {integrity: sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA==}
engines: {node: '>=6.9.0'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-normalize-whitespace/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-iPklmI5SBnRvwceb/XH568yyzK0qRVuAG+a1HFUsFRf11lEJTiQQa03a4RSCQvLKdcpX7XsI1Gen9LuLoqwiqA==}
+ /postcss-normalize-whitespace@5.1.1(postcss@8.4.32):
+ resolution: {integrity: sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-ordered-values/4.1.2:
+ /postcss-ordered-values@4.1.2:
resolution: {integrity: sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw==}
engines: {node: '>=6.9.0'}
dependencies:
cssnano-util-get-arguments: 4.0.0
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-ordered-values/5.0.2_postcss@8.3.6:
- resolution: {integrity: sha512-8AFYDSOYWebJYLyJi3fyjl6CqMEG/UVworjiyK1r573I56kb3e879sCJLGvR3merj+fAdPpVplXKQZv+ey6CgQ==}
+ /postcss-ordered-values@5.1.3(postcss@8.4.32):
+ resolution: {integrity: sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- cssnano-utils: 2.0.1_postcss@8.3.6
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ cssnano-utils: 3.1.0(postcss@8.4.32)
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-reduce-initial/4.0.3:
+ /postcss-reduce-initial@4.0.3:
resolution: {integrity: sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA==}
engines: {node: '>=6.9.0'}
dependencies:
- browserslist: 4.16.8
+ browserslist: 4.22.2
caniuse-api: 3.0.0
has: 1.0.3
- postcss: 7.0.36
+ postcss: 7.0.39
dev: true
- /postcss-reduce-initial/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-zlCZPKLLTMAqA3ZWH57HlbCjkD55LX9dsRyxlls+wfuRfqCi5mSlZVan0heX5cHr154Dq9AfbH70LyhrSAezJw==}
+ /postcss-reduce-initial@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- browserslist: 4.16.8
+ browserslist: 4.22.2
caniuse-api: 3.0.0
- postcss: 8.3.6
+ postcss: 8.4.32
dev: true
- /postcss-reduce-transforms/4.0.2:
+ /postcss-reduce-transforms@4.0.2:
resolution: {integrity: sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg==}
engines: {node: '>=6.9.0'}
dependencies:
cssnano-util-get-match: 4.0.0
has: 1.0.3
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
dev: true
- /postcss-reduce-transforms/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-a//FjoPeFkRuAguPscTVmRQUODP+f3ke2HqFNgGPwdYnpeC29RZdCBvGRGTsKpMURb/I3p6jdKoBQ2zI+9Q7kA==}
+ /postcss-reduce-transforms@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- cssnano-utils: 2.0.1_postcss@8.3.6
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
dev: true
- /postcss-selector-parser/3.1.2:
+ /postcss-reporter@7.0.5(postcss@8.4.23):
+ resolution: {integrity: sha512-glWg7VZBilooZGOFPhN9msJ3FQs19Hie7l5a/eE6WglzYqVeH3ong3ShFcp9kDWJT1g2Y/wd59cocf9XxBtkWA==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ postcss: ^8.1.0
+ dependencies:
+ picocolors: 1.0.0
+ postcss: 8.4.23
+ thenby: 1.3.4
+ dev: true
+
+ /postcss-selector-parser@3.1.2:
resolution: {integrity: sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA==}
engines: {node: '>=8'}
dependencies:
@@ -17823,192 +15934,103 @@ packages:
uniq: 1.0.1
dev: true
- /postcss-selector-parser/6.0.6:
- resolution: {integrity: sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg==}
+ /postcss-selector-parser@6.0.10:
+ resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
dev: true
- /postcss-svgo/4.0.3:
+ /postcss-selector-parser@6.0.12:
+ resolution: {integrity: sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg==}
+ engines: {node: '>=4'}
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ /postcss-svgo@4.0.3:
resolution: {integrity: sha512-NoRbrcMWTtUghzuKSoIm6XV+sJdvZ7GZSc3wdBN0W19FTtp2ko8NqLsgoh/m9CzNhU3KLPvQmjIwtaNFkaFTvw==}
engines: {node: '>=6.9.0'}
dependencies:
- postcss: 7.0.36
+ postcss: 7.0.39
postcss-value-parser: 3.3.1
svgo: 1.3.2
dev: true
- /postcss-svgo/5.0.2_postcss@8.3.6:
- resolution: {integrity: sha512-YzQuFLZu3U3aheizD+B1joQ94vzPfE6BNUcSYuceNxlVnKKsOtdo6hL9/zyC168Q8EwfLSgaDSalsUGa9f2C0A==}
+ /postcss-svgo@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- postcss: 8.3.6
- postcss-value-parser: 4.1.0
- svgo: 2.4.0
+ postcss: 8.4.32
+ postcss-value-parser: 4.2.0
+ svgo: 2.8.0
dev: true
- /postcss-unique-selectors/4.0.1:
+ /postcss-unique-selectors@4.0.1:
resolution: {integrity: sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg==}
engines: {node: '>=6.9.0'}
dependencies:
alphanum-sort: 1.0.2
- postcss: 7.0.36
+ postcss: 7.0.39
uniqs: 2.0.0
dev: true
- /postcss-unique-selectors/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-gwi1NhHV4FMmPn+qwBNuot1sG1t2OmacLQ/AX29lzyggnjd+MnVD5uqQmpXO3J17KGL2WAxQruj1qTd3H0gG/w==}
+ /postcss-unique-selectors@5.1.1(postcss@8.4.32):
+ resolution: {integrity: sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- alphanum-sort: 1.0.2
- postcss: 8.3.6
- postcss-selector-parser: 6.0.6
- uniqs: 2.0.0
+ postcss: 8.4.32
+ postcss-selector-parser: 6.0.12
dev: true
- /postcss-value-parser/3.3.1:
+ /postcss-value-parser@3.3.1:
resolution: {integrity: sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==}
dev: true
- /postcss-value-parser/4.1.0:
- resolution: {integrity: sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==}
- dev: true
+ /postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
- /postcss/7.0.36:
- resolution: {integrity: sha512-BebJSIUMwJHRH0HAQoxN4u1CN86glsrwsW0q7T+/m44eXOUAxSNdHRkNZPYz5vVUbg17hFgOQDE7fZk7li3pZw==}
+ /postcss@7.0.39:
+ resolution: {integrity: sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==}
engines: {node: '>=6.0.0'}
dependencies:
- chalk: 2.4.2
+ picocolors: 0.2.1
source-map: 0.6.1
- supports-color: 6.1.0
dev: true
- /postcss/8.3.6:
- resolution: {integrity: sha512-wG1cc/JhRgdqB6WHEuyLTedf3KIRuD0hG6ldkFEZNCjRxiC+3i6kkWUUbiJQayP28iwG35cEmAbe98585BYV0A==}
+ /postcss@8.4.23:
+ resolution: {integrity: sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==}
engines: {node: ^10 || ^12 || >=14}
dependencies:
- colorette: 1.3.0
- nanoid: 3.1.25
- source-map-js: 0.6.2
+ nanoid: 3.3.6
+ picocolors: 1.0.0
+ source-map-js: 1.0.2
+
+ /postcss@8.4.32:
+ resolution: {integrity: sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==}
+ engines: {node: ^10 || ^12 || >=14}
+ dependencies:
+ nanoid: 3.3.7
+ picocolors: 1.0.0
+ source-map-js: 1.0.2
dev: true
- /preact-cli/3.2.2_517d24bd855b57d7e424aceed04e063b:
- resolution: {integrity: sha512-42aUanAb/AqHHvnfb/IwJw9UhY5iuHkGRBv3TrTsQMrq0Ee8Z84r+HS8wjGI0aHHb0R8tnHI0hhllWgmNhjB/Q==}
- engines: {node: '>=12'}
- hasBin: true
- peerDependencies:
- less-loader: ^7.3.0
- preact: '*'
- preact-render-to-string: '*'
- sass-loader: ^10.2.0
- stylus-loader: ^4.3.3
- peerDependenciesMeta:
- less-loader:
- optional: true
- sass-loader:
- optional: true
- stylus-loader:
- optional: true
+ /postcss@8.4.33:
+ resolution: {integrity: sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==}
+ engines: {node: ^10 || ^12 || >=14}
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-decorators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-object-assign': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-react-jsx': 7.14.9_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- '@preact/async-loader': 3.0.1_preact@10.5.14
- '@prefresh/babel-plugin': 0.4.1
- '@prefresh/webpack': 3.3.2_b4d84c08f02729896cbfdece19209372
- autoprefixer: 10.3.1_postcss@8.3.6
- babel-esm-plugin: 0.9.0_webpack@4.46.0
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
- babel-plugin-macros: 3.1.0
- babel-plugin-transform-react-remove-prop-types: 0.4.24
- browserlist: 1.0.1
- browserslist: 4.16.8
- compression-webpack-plugin: 6.1.1_webpack@4.46.0
- console-clear: 1.1.1
- copy-webpack-plugin: 6.4.1_webpack@4.46.0
- critters-webpack-plugin: 2.5.0
- cross-spawn-promise: 0.10.2
- css-loader: 5.2.7_webpack@4.46.0
- ejs-loader: 0.5.0
- envinfo: 7.8.1
- esm: 3.2.25
- fast-async: 6.3.8
- file-loader: 6.2.0_webpack@4.46.0
- fork-ts-checker-webpack-plugin: 4.1.6
- get-port: 5.1.1
- gittar: 0.1.1
- glob: 7.1.7
- html-webpack-exclude-assets-plugin: 0.0.7
- html-webpack-plugin: 3.2.0_webpack@4.46.0
- ip: 1.1.5
- isomorphic-unfetch: 3.1.0
- kleur: 4.1.4
- loader-utils: 2.0.0
- mini-css-extract-plugin: 1.6.2_webpack@4.46.0
- minimatch: 3.0.4
- native-url: 0.3.4
- optimize-css-assets-webpack-plugin: 6.0.1_webpack@4.46.0
- ora: 5.4.1
- pnp-webpack-plugin: 1.7.0_typescript@4.4.3
- postcss: 8.3.6
- postcss-load-config: 3.1.0
- postcss-loader: 4.3.0_postcss@8.3.6+webpack@4.46.0
- preact: 10.5.14
- preact-render-to-string: 5.1.19_preact@10.5.14
- progress-bar-webpack-plugin: 2.1.0_webpack@4.46.0
- promise-polyfill: 8.2.0
- prompts: 2.4.1
- raw-loader: 4.0.2_webpack@4.46.0
- react-refresh: 0.10.0
- rimraf: 3.0.2
- sade: 1.7.4
- size-plugin: 3.0.0_webpack@4.46.0
- source-map: 0.7.3
- stack-trace: 0.0.10
- style-loader: 2.0.0_webpack@4.46.0
- terser-webpack-plugin: 4.2.3_webpack@4.46.0
- typescript: 4.4.3
- update-notifier: 5.1.0
- url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
- validate-npm-package-name: 3.0.0
- webpack: 4.46.0
- webpack-bundle-analyzer: 4.4.2
- webpack-dev-server: 3.11.2_webpack@4.46.0
- webpack-fix-style-only-entries: 0.6.1
- webpack-merge: 5.8.0
- webpack-plugin-replace: 1.2.0
- which: 2.0.2
- workbox-cacheable-response: 6.2.4
- workbox-core: 6.2.4
- workbox-precaching: 6.2.4
- workbox-routing: 6.2.4
- workbox-strategies: 6.2.4
- workbox-webpack-plugin: 6.2.4_webpack@4.46.0
- transitivePeerDependencies:
- - '@types/babel__core'
- - bufferutil
- - debug
- - supports-color
- - ts-node
- - utf-8-validate
- - webpack-cli
- - webpack-command
+ nanoid: 3.3.7
+ picocolors: 1.0.0
+ source-map-js: 1.0.2
dev: true
- /preact-cli/3.2.2_8d1b4ee21ca5a56b4aabd4a3e659b2d7:
- resolution: {integrity: sha512-42aUanAb/AqHHvnfb/IwJw9UhY5iuHkGRBv3TrTsQMrq0Ee8Z84r+HS8wjGI0aHHb0R8tnHI0hhllWgmNhjB/Q==}
+ /preact-cli@3.4.1(eslint@8.56.0)(preact-render-to-string@5.2.6)(preact@10.11.3):
+ resolution: {integrity: sha512-/4be0PuBmAIAox9u8GLJublFpEymq7Lk4JW4PEPz9ErFH/ncZf/oBPhECtXGq9IPqNOEe4r2l8sA+3uqKVwBfw==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
@@ -18025,212 +16047,225 @@ packages:
stylus-loader:
optional: true
dependencies:
- '@babel/core': 7.15.0
- '@babel/plugin-proposal-class-properties': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-decorators': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-proposal-object-rest-spread': 7.14.7_@babel+core@7.15.0
- '@babel/plugin-syntax-dynamic-import': 7.8.3_@babel+core@7.15.0
- '@babel/plugin-transform-object-assign': 7.14.5_@babel+core@7.15.0
- '@babel/plugin-transform-react-jsx': 7.14.9_@babel+core@7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/preset-typescript': 7.15.0_@babel+core@7.15.0
- '@preact/async-loader': 3.0.1_preact@10.5.14
- '@prefresh/babel-plugin': 0.4.1
- '@prefresh/webpack': 3.3.2_b4d84c08f02729896cbfdece19209372
- autoprefixer: 10.3.1_postcss@8.3.6
- babel-esm-plugin: 0.9.0_webpack@4.46.0
- babel-loader: 8.2.2_be352a5a80662835a7707f972edfcfde
+ '@babel/core': 7.22.1
+ '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-decorators': 7.19.6(@babel/core@7.22.1)
+ '@babel/plugin-proposal-object-rest-spread': 7.19.4(@babel/core@7.22.1)
+ '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.22.1)
+ '@babel/plugin-transform-object-assign': 7.18.6(@babel/core@7.22.1)
+ '@babel/plugin-transform-react-jsx': 7.19.0(@babel/core@7.22.1)
+ '@babel/preset-env': 7.19.4(@babel/core@7.22.1)
+ '@babel/preset-typescript': 7.18.6(@babel/core@7.22.1)
+ '@preact/async-loader': 3.0.1(preact@10.11.3)
+ '@prefresh/babel-plugin': 0.4.4
+ '@prefresh/webpack': 3.3.4(@prefresh/babel-plugin@0.4.4)(preact@10.11.3)(webpack@4.46.0)
+ '@types/webpack': 4.41.33
+ autoprefixer: 10.4.14(postcss@8.4.32)
+ babel-esm-plugin: 0.9.0(webpack@4.46.0)
+ babel-loader: 8.2.5(@babel/core@7.22.1)(webpack@4.46.0)
babel-plugin-macros: 3.1.0
babel-plugin-transform-react-remove-prop-types: 0.4.24
- browserlist: 1.0.1
- browserslist: 4.16.8
- compression-webpack-plugin: 6.1.1_webpack@4.46.0
+ browserslist: 4.21.4
+ compression-webpack-plugin: 6.1.1(webpack@4.46.0)
console-clear: 1.1.1
- copy-webpack-plugin: 6.4.1_webpack@4.46.0
- critters-webpack-plugin: 2.5.0
+ copy-webpack-plugin: 6.4.1(webpack@4.46.0)
+ critters-webpack-plugin: 2.5.0(html-webpack-plugin@3.2.0)
cross-spawn-promise: 0.10.2
- css-loader: 5.2.7_webpack@4.46.0
+ css-loader: 5.2.7(webpack@4.46.0)
+ dotenv: 16.0.3
ejs-loader: 0.5.0
envinfo: 7.8.1
esm: 3.2.25
- fast-async: 6.3.8
- file-loader: 6.2.0_webpack@4.46.0
- fork-ts-checker-webpack-plugin: 4.1.6
+ file-loader: 6.2.0(webpack@4.46.0)
+ fork-ts-checker-webpack-plugin: 4.1.6(eslint@8.56.0)(typescript@4.6.4)(webpack@4.46.0)
get-port: 5.1.1
gittar: 0.1.1
- glob: 7.1.7
+ glob: 8.1.0
html-webpack-exclude-assets-plugin: 0.0.7
- html-webpack-plugin: 3.2.0_webpack@4.46.0
- ip: 1.1.5
+ html-webpack-plugin: 3.2.0(webpack@4.46.0)
+ ip: 1.1.8
isomorphic-unfetch: 3.1.0
- kleur: 4.1.4
- loader-utils: 2.0.0
- mini-css-extract-plugin: 1.6.2_webpack@4.46.0
- minimatch: 3.0.4
+ kleur: 4.1.5
+ loader-utils: 2.0.3
+ mini-css-extract-plugin: 1.6.2(webpack@4.46.0)
+ minimatch: 3.1.2
native-url: 0.3.4
- optimize-css-assets-webpack-plugin: 6.0.1_webpack@4.46.0
+ optimize-css-assets-webpack-plugin: 6.0.1(webpack@4.46.0)
ora: 5.4.1
- pnp-webpack-plugin: 1.7.0_typescript@4.4.3
- postcss: 8.3.6
- postcss-load-config: 3.1.0
- postcss-loader: 4.3.0_postcss@8.3.6+webpack@4.46.0
- preact: 10.5.14
- preact-render-to-string: 5.1.19_preact@10.5.14
- progress-bar-webpack-plugin: 2.1.0_webpack@4.46.0
- promise-polyfill: 8.2.0
- prompts: 2.4.1
- raw-loader: 4.0.2_webpack@4.46.0
+ pnp-webpack-plugin: 1.7.0(typescript@4.6.4)
+ postcss: 8.4.32
+ postcss-load-config: 3.1.4(postcss@8.4.32)
+ postcss-loader: 4.3.0(postcss@8.4.32)(webpack@4.46.0)
+ preact: 10.11.3
+ preact-render-to-string: 5.2.6(preact@10.11.3)
+ progress-bar-webpack-plugin: 2.1.0(webpack@4.46.0)
+ promise-polyfill: 8.2.3
+ prompts: 2.4.2
+ raw-loader: 4.0.2(webpack@4.46.0)
react-refresh: 0.10.0
rimraf: 3.0.2
- sade: 1.7.4
- sass-loader: 10.2.0_sass@1.43.2
- size-plugin: 3.0.0_webpack@4.46.0
- source-map: 0.7.3
+ sade: 1.8.1
+ size-plugin: 3.0.0(webpack@4.46.0)
+ source-map: 0.7.4
stack-trace: 0.0.10
- style-loader: 2.0.0_webpack@4.46.0
- terser-webpack-plugin: 4.2.3_webpack@4.46.0
- typescript: 4.4.3
+ style-loader: 2.0.0(webpack@4.46.0)
+ terser-webpack-plugin: 4.2.3(webpack@4.46.0)
+ typescript: 4.6.4
update-notifier: 5.1.0
- url-loader: 4.1.1_file-loader@6.2.0+webpack@4.46.0
- validate-npm-package-name: 3.0.0
+ url-loader: 4.1.1(file-loader@6.2.0)(webpack@4.46.0)
+ validate-npm-package-name: 4.0.0
webpack: 4.46.0
- webpack-bundle-analyzer: 4.4.2
- webpack-dev-server: 3.11.2_webpack@4.46.0
+ webpack-bundle-analyzer: 4.6.1
+ webpack-dev-server: 4.11.1(webpack@4.46.0)
webpack-fix-style-only-entries: 0.6.1
+ webpack-manifest-plugin: 4.1.1(webpack@4.46.0)
webpack-merge: 5.8.0
webpack-plugin-replace: 1.2.0
which: 2.0.2
- workbox-cacheable-response: 6.2.4
- workbox-core: 6.2.4
- workbox-precaching: 6.2.4
- workbox-routing: 6.2.4
- workbox-strategies: 6.2.4
- workbox-webpack-plugin: 6.2.4_webpack@4.46.0
+ workbox-cacheable-response: 6.5.4
+ workbox-core: 6.5.4
+ workbox-precaching: 6.5.4
+ workbox-routing: 6.5.4
+ workbox-strategies: 6.5.4
+ workbox-webpack-plugin: 6.5.4(webpack@4.46.0)
transitivePeerDependencies:
- '@types/babel__core'
+ - bluebird
- bufferutil
- debug
+ - encoding
+ - eslint
- supports-color
- ts-node
- utf-8-validate
+ - vue-template-compiler
- webpack-cli
- webpack-command
dev: true
- /preact-render-to-string/5.1.19_preact@10.5.14:
- resolution: {integrity: sha512-bj8sn/oytIKO6RtOGSS/1+5CrQyRSC99eLUnEVbqUa6MzJX5dYh7wu9bmT0d6lm/Vea21k9KhCQwvr2sYN3rrQ==}
+ /preact-render-to-string@5.2.6(preact@10.11.3):
+ resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
peerDependencies:
preact: '>=10'
dependencies:
- preact: 10.5.14
+ preact: 10.11.3
pretty-format: 3.8.0
+ dev: true
- /preact-router/3.2.1_preact@10.5.14:
+ /preact-router@3.2.1(preact@10.11.3):
resolution: {integrity: sha512-KEN2VN1DxUlTwzW5IFkF13YIA2OdQ2OvgJTkQREF+AA2NrHRLaGbB68EjS4IeZOa1shvQ1FvEm3bSLta4sXBhg==}
peerDependencies:
preact: '>=10'
dependencies:
- preact: 10.5.14
+ preact: 10.11.3
dev: false
- /preact/10.5.14:
- resolution: {integrity: sha512-KojoltCrshZ099ksUZ2OQKfbH66uquFoxHSbnwKbTJHeQNvx42EmC7wQVWNuDt6vC5s3nudRHFtKbpY4ijKlaQ==}
+ /preact@10.11.3:
+ resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==}
+
+ /prebuild-install@7.1.1:
+ resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==}
+ engines: {node: '>=10'}
+ hasBin: true
+ requiresBuild: true
+ dependencies:
+ detect-libc: 2.0.2
+ expand-template: 2.0.3
+ github-from-package: 0.0.0
+ minimist: 1.2.8
+ mkdirp-classic: 0.5.3
+ napi-build-utils: 1.0.2
+ node-abi: 3.52.0
+ pump: 3.0.0
+ rc: 1.2.8
+ simple-get: 4.0.1
+ tar-fs: 2.1.1
+ tunnel-agent: 0.6.0
dev: false
+ optional: true
- /prelude-ls/1.1.2:
- resolution: {integrity: sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=}
+ /prelude-ls@1.1.2:
+ resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==}
engines: {node: '>= 0.8.0'}
dev: true
- /prelude-ls/1.2.1:
+ /prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
dev: true
- /prepend-http/2.0.0:
- resolution: {integrity: sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=}
+ /prepend-http@2.0.0:
+ resolution: {integrity: sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==}
engines: {node: '>=4'}
dev: true
- /prettier/2.2.1:
- resolution: {integrity: sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==}
- engines: {node: '>=10.13.0'}
+ /prettier@3.1.1:
+ resolution: {integrity: sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==}
+ engines: {node: '>=14'}
hasBin: true
dev: true
- /pretty-bytes/4.0.2:
- resolution: {integrity: sha1-sr+C5zUNZcbDOqlaqlpPYyf2HNk=}
+ /pretty-bytes@4.0.2:
+ resolution: {integrity: sha512-yJAF+AjbHKlxQ8eezMd/34Mnj/YTQ3i6kLzvVsH4l/BfIFtp444n0wVbnsn66JimZ9uBofv815aRp1zCppxlWw==}
engines: {node: '>=4'}
dev: true
- /pretty-bytes/5.6.0:
+ /pretty-bytes@5.6.0:
resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
engines: {node: '>=6'}
dev: true
- /pretty-error/2.1.2:
+ /pretty-error@2.1.2:
resolution: {integrity: sha512-EY5oDzmsX5wvuynAByrmY0P0hcp+QpnAKbJng2A2MPjVKXCxrDSUkzghVJ4ZGPIv+JC4gX8fPUWscC0RtjsWGw==}
dependencies:
lodash: 4.17.21
renderkid: 2.0.7
dev: true
- /pretty-format/26.6.2:
- resolution: {integrity: sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==}
- engines: {node: '>= 10'}
+ /pretty-error@4.0.0:
+ resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==}
dependencies:
- '@jest/types': 26.6.2
- ansi-regex: 5.0.0
- ansi-styles: 4.3.0
- react-is: 17.0.2
+ lodash: 4.17.21
+ renderkid: 3.0.0
dev: true
- /pretty-format/27.1.0:
- resolution: {integrity: sha512-4aGaud3w3rxAO6OXmK3fwBFQ0bctIOG3/if+jYEFGNGIs0EvuidQm3bZ9mlP2/t9epLNC/12czabfy7TZNSwVA==}
- engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
- dependencies:
- '@jest/types': 27.1.0
- ansi-regex: 5.0.0
- ansi-styles: 5.2.0
- react-is: 17.0.2
+ /pretty-format@3.8.0:
+ resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
dev: true
- /pretty-format/3.8.0:
- resolution: {integrity: sha1-v77VbV6ad2ZF9LH/eqGjrE+jw4U=}
-
- /pretty-hrtime/1.0.3:
- resolution: {integrity: sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=}
+ /pretty-hrtime@1.0.3:
+ resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==}
engines: {node: '>= 0.8'}
dev: true
- /pretty-ms/7.0.1:
- resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==}
- engines: {node: '>=10'}
+ /pretty-ms@8.0.0:
+ resolution: {integrity: sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==}
+ engines: {node: '>=14.16'}
dependencies:
- parse-ms: 2.1.0
+ parse-ms: 3.0.0
dev: true
- /prismjs/1.24.1:
- resolution: {integrity: sha512-mNPsedLuk90RVJioIky8ANZEwYm5w9LcvCXrxHlwf4fNVSn8jEipMybMkWUyyF0JhnC+C4VcOVSBuHRKs1L5Ow==}
- dev: true
-
- /process-nextick-args/2.0.1:
+ /process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
dev: true
- /process-on-spawn/1.0.0:
+ /process-on-spawn@1.0.0:
resolution: {integrity: sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==}
engines: {node: '>=8'}
dependencies:
fromentries: 1.3.2
dev: true
- /process/0.11.10:
- resolution: {integrity: sha1-czIwDoQBYb2j5podHZGn1LwW8YI=}
+ /process-warning@3.0.0:
+ resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
+ dev: true
+
+ /process@0.11.10:
+ resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
dev: true
- /progress-bar-webpack-plugin/2.1.0_webpack@4.46.0:
+ /progress-bar-webpack-plugin@2.1.0(webpack@4.46.0):
resolution: {integrity: sha512-UtlZbnxpYk1wufEWfhIjRn2U52zlY38uvnzFhs8rRxJxC1hSqw88JNR2Mbpqq9Kix8L1nGb3uQ+/1BiUWbigAg==}
peerDependencies:
webpack: ^1.3.0 || ^2 || ^3 || ^4 || ^5
@@ -18240,71 +16275,58 @@ packages:
webpack: 4.46.0
dev: true
- /progress/2.0.3:
+ /progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
dev: true
- /promise-inflight/1.0.1:
- resolution: {integrity: sha1-mEcocL8igTL8vdhoEputEsPAKeM=}
- dev: true
-
- /promise-polyfill/8.2.0:
- resolution: {integrity: sha512-k/TC0mIcPVF6yHhUvwAp7cvL6I2fFV7TzF1DuGPI8mBh4QQazf36xCKEHKTZKRysEoTQoQdKyP25J8MPJp7j5g==}
- dev: true
-
- /promise.allsettled/1.0.4:
- resolution: {integrity: sha512-o73CbvQh/OnPFShxHcHxk0baXR2a1m4ozb85ha0H14VEoi/EJJLa9mnPfEWJx9RjA9MLfhdjZ8I6HhWtBa64Ag==}
- engines: {node: '>= 0.4'}
+ /promise-inflight@1.0.1(bluebird@3.7.2):
+ resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==}
+ peerDependencies:
+ bluebird: '*'
+ peerDependenciesMeta:
+ bluebird:
+ optional: true
dependencies:
- array.prototype.map: 1.0.3
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
- get-intrinsic: 1.1.1
- iterate-value: 1.0.2
+ bluebird: 3.7.2
dev: true
- /promise.prototype.finally/3.1.2:
- resolution: {integrity: sha512-A2HuJWl2opDH0EafgdjwEw7HysI8ff/n4lW4QEVBCUXFk9QeGecBWv0Deph0UmLe3tTNYegz8MOjsVuE6SMoJA==}
- engines: {node: '>= 0.4'}
- dependencies:
- define-properties: 1.1.3
- es-abstract: 1.18.5
- function-bind: 1.1.1
+ /promise-polyfill@8.2.3:
+ resolution: {integrity: sha512-Og0+jCRQetV84U8wVjMNccfGCnMQ9mGs9Hv78QFe+pSDD3gWTpz0y+1QCuxy5d/vBFuZ3iwP2eycAkvqIMPmWg==}
dev: true
- /prompts/2.4.0:
- resolution: {integrity: sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ==}
- engines: {node: '>= 6'}
+ /promise-toolbox@0.21.0:
+ resolution: {integrity: sha512-NV8aTmpwrZv+Iys54sSFOBx3tuVaOBvvrft5PNppnxy9xpU/akHbaWIril22AB22zaPgrgwKdD0KsrM0ptUtpg==}
+ engines: {node: '>=6'}
dependencies:
- kleur: 3.0.3
- sisteransi: 1.0.5
+ make-error: 1.3.6
dev: true
- /prompts/2.4.1:
- resolution: {integrity: sha512-EQyfIuO2hPDsX1L/blblV+H7I0knhgAd82cVneCwcdND9B8AuCDuRcBH6yIcG4dFzlOUqbazQqwGjx5xmsNLuQ==}
+ /prompts@2.4.2:
+ resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
dependencies:
kleur: 3.0.3
sisteransi: 1.0.5
dev: true
- /prop-types/15.7.2:
- resolution: {integrity: sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==}
+ /prop-types@15.8.1:
+ resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
dev: true
- /property-information/5.6.0:
- resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
- dependencies:
- xtend: 4.0.2
+ /property-expr@2.0.5:
+ resolution: {integrity: sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==}
+ dev: false
+
+ /proto-list@1.2.4:
+ resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
dev: true
- /proxy-addr/2.0.7:
+ /proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
dependencies:
@@ -18312,19 +16334,19 @@ packages:
ipaddr.js: 1.9.1
dev: true
- /prr/1.0.1:
- resolution: {integrity: sha1-0/wRS6BplaRexok/SEzrHXj19HY=}
+ /prr@1.0.1:
+ resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
dev: true
- /pseudomap/1.0.2:
- resolution: {integrity: sha1-8FKijacOYYkX7wqKw0wa5aaChrM=}
+ /pseudomap@1.0.2:
+ resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==}
dev: true
- /psl/1.8.0:
- resolution: {integrity: sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==}
+ /psl@1.9.0:
+ resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
dev: true
- /public-encrypt/4.0.3:
+ /public-encrypt@4.0.3:
resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==}
dependencies:
bn.js: 4.12.0
@@ -18335,21 +16357,21 @@ packages:
safe-buffer: 5.2.1
dev: true
- /pump/2.0.1:
+ /pump@2.0.1:
resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==}
dependencies:
end-of-stream: 1.4.4
once: 1.4.0
dev: true
- /pump/3.0.0:
+ /pump@3.0.0:
resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
+ requiresBuild: true
dependencies:
end-of-stream: 1.4.4
once: 1.4.0
- dev: true
- /pumpify/1.5.1:
+ /pumpify@1.5.1:
resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==}
dependencies:
duplexify: 3.7.1
@@ -18357,461 +16379,174 @@ packages:
pump: 2.0.1
dev: true
- /punycode/1.3.2:
- resolution: {integrity: sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=}
- dev: true
-
- /punycode/1.4.1:
- resolution: {integrity: sha1-wNWmOycYgArY4esPpSachN1BhF4=}
+ /punycode@1.4.1:
+ resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==}
dev: true
- /punycode/2.1.1:
+ /punycode@2.1.1:
resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==}
engines: {node: '>=6'}
dev: true
- /pupa/2.1.1:
+ /pupa@2.1.1:
resolution: {integrity: sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==}
engines: {node: '>=8'}
dependencies:
escape-goat: 2.1.1
dev: true
- /q/1.5.1:
- resolution: {integrity: sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=}
+ /pupa@3.1.0:
+ resolution: {integrity: sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==}
+ engines: {node: '>=12.20'}
+ dependencies:
+ escape-goat: 4.0.0
+ dev: true
+
+ /q@1.5.1:
+ resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==}
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
dev: true
- /qrcode-generator/1.4.4:
+ /qrcode-generator@1.4.4:
resolution: {integrity: sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==}
dev: false
- /qs/6.10.1:
- resolution: {integrity: sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==}
+ /qs@6.11.0:
+ resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
engines: {node: '>=0.6'}
dependencies:
side-channel: 1.0.4
dev: true
- /qs/6.5.2:
- resolution: {integrity: sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==}
+ /qs@6.11.2:
+ resolution: {integrity: sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==}
engines: {node: '>=0.6'}
+ dependencies:
+ side-channel: 1.0.4
dev: true
- /qs/6.7.0:
- resolution: {integrity: sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==}
+ /qs@6.5.3:
+ resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==}
engines: {node: '>=0.6'}
dev: true
- /querystring-es3/0.2.1:
- resolution: {integrity: sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=}
+ /querystring-es3@0.2.1:
+ resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==}
engines: {node: '>=0.4.x'}
dev: true
- /querystring/0.2.0:
- resolution: {integrity: sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=}
- engines: {node: '>=0.4.x'}
- deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
- dev: true
-
- /querystring/0.2.1:
+ /querystring@0.2.1:
resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==}
engines: {node: '>=0.4.x'}
- deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
dev: true
- /querystringify/2.2.0:
- resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
- dev: true
-
- /queue-microtask/1.2.3:
+ /queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
- dev: true
- /raf/3.4.1:
- resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
+ /queue@6.0.2:
+ resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
dependencies:
- performance-now: 2.1.0
- dev: true
-
- /railroad-diagrams/1.0.0:
- resolution: {integrity: sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=}
+ inherits: 2.0.4
dev: true
- /ramda/0.21.0:
- resolution: {integrity: sha1-oAGr7bP/YQd9T/HVd9RN536NCjU=}
+ /quick-format-unescaped@4.0.4:
+ resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
dev: true
- /randexp/0.4.6:
- resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
- engines: {node: '>=0.12'}
- dependencies:
- discontinuous-range: 1.0.0
- ret: 0.1.15
+ /quick-lru@5.1.1:
+ resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
+ engines: {node: '>=10'}
dev: true
- /randombytes/2.1.0:
+ /randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
dependencies:
safe-buffer: 5.2.1
dev: true
- /randomfill/1.0.4:
+ /randomfill@1.0.4:
resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==}
dependencies:
randombytes: 2.1.0
safe-buffer: 5.2.1
dev: true
- /range-parser/1.2.1:
+ /range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
dev: true
- /raw-body/2.4.0:
- resolution: {integrity: sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==}
+ /raw-body@2.5.1:
+ resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==}
engines: {node: '>= 0.8'}
dependencies:
- bytes: 3.1.0
- http-errors: 1.7.2
+ bytes: 3.1.2
+ http-errors: 2.0.0
iconv-lite: 0.4.24
unpipe: 1.0.0
dev: true
- /raw-loader/4.0.2_webpack@4.46.0:
+ /raw-loader@4.0.2(webpack@4.46.0):
resolution: {integrity: sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==}
engines: {node: '>= 10.13.0'}
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
dependencies:
- loader-utils: 2.0.0
+ loader-utils: 2.0.3
schema-utils: 3.1.1
webpack: 4.46.0
dev: true
- /rc/1.2.8:
+ /rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
+ requiresBuild: true
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
- minimist: 1.2.5
+ minimist: 1.2.8
strip-json-comments: 2.0.1
- dev: true
-
- /react-colorful/5.3.0:
- resolution: {integrity: sha512-zWE5E88zmjPXFhv6mGnRZqKin9s5vip1O3IIGynY9EhZxN8MATUxZkT3e/9OwTEm4DjQBXc6PFWP6AetY+Px+A==}
- peerDependencies:
- react: '>=16.8.0'
- react-dom: '>=16.8.0'
- dev: true
-
- /react-colorful/5.3.0_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-zWE5E88zmjPXFhv6mGnRZqKin9s5vip1O3IIGynY9EhZxN8MATUxZkT3e/9OwTEm4DjQBXc6PFWP6AetY+Px+A==}
- peerDependencies:
- react: '>=16.8.0'
- react-dom: '>=16.8.0'
- dependencies:
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- dev: true
-
- /react-dev-utils/11.0.4:
- resolution: {integrity: sha512-dx0LvIGHcOPtKbeiSUM4jqpBl3TcY7CDjZdfOIcKeznE7BWr9dg0iPG90G5yfVQ+p/rGNMXdbfStvzQZEVEi4A==}
- engines: {node: '>=10'}
- dependencies:
- '@babel/code-frame': 7.10.4
- address: 1.1.2
- browserslist: 4.14.2
- chalk: 2.4.2
- cross-spawn: 7.0.3
- detect-port-alt: 1.1.6
- escape-string-regexp: 2.0.0
- filesize: 6.1.0
- find-up: 4.1.0
- fork-ts-checker-webpack-plugin: 4.1.6
- global-modules: 2.0.0
- globby: 11.0.1
- gzip-size: 5.1.1
- immer: 8.0.1
- is-root: 2.1.0
- loader-utils: 2.0.0
- open: 7.4.2
- pkg-up: 3.1.0
- prompts: 2.4.0
- react-error-overlay: 6.0.9
- recursive-readdir: 2.2.2
- shell-quote: 1.7.2
- strip-ansi: 6.0.0
- text-table: 0.2.0
- dev: true
- /react-dom/16.14.0_react@16.14.0:
- resolution: {integrity: sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==}
+ /react-dom@18.2.0(react@18.2.0):
+ resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
peerDependencies:
- react: ^16.14.0
+ react: ^18.2.0
dependencies:
loose-envify: 1.4.0
- object-assign: 4.1.1
- prop-types: 15.7.2
- react: 16.14.0
- scheduler: 0.19.1
- dev: true
-
- /react-draggable/4.4.3:
- resolution: {integrity: sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==}
- dependencies:
- classnames: 2.3.1
- prop-types: 15.7.2
- dev: true
-
- /react-element-to-jsx-string/14.3.2:
- resolution: {integrity: sha512-WZbvG72cjLXAxV7VOuSzuHEaI3RHj10DZu8EcKQpkKcAj7+qAkG5XUeSdX5FXrA0vPrlx0QsnAzZEBJwzV0e+w==}
- peerDependencies:
- react: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1
- react-dom: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1
- dependencies:
- '@base2/pretty-print-object': 1.0.0
- is-plain-object: 3.0.1
- dev: true
-
- /react-error-overlay/6.0.9:
- resolution: {integrity: sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==}
- dev: true
-
- /react-fast-compare/3.2.0:
- resolution: {integrity: sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==}
- dev: true
-
- /react-helmet-async/1.0.9:
- resolution: {integrity: sha512-N+iUlo9WR3/u9qGMmP4jiYfaD6pe9IvDTapZLFJz2D3xlTlCM1Bzy4Ab3g72Nbajo/0ZyW+W9hdz8Hbe4l97pQ==}
- peerDependencies:
- react: ^16.6.0 || ^17.0.0
- react-dom: ^16.6.0 || ^17.0.0
- dependencies:
- '@babel/runtime': 7.15.3
- invariant: 2.2.4
- prop-types: 15.7.2
- react-fast-compare: 3.2.0
- shallowequal: 1.1.0
- dev: true
+ react: 18.2.0
+ scheduler: 0.23.0
+ dev: false
- /react-helmet-async/1.0.9_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-N+iUlo9WR3/u9qGMmP4jiYfaD6pe9IvDTapZLFJz2D3xlTlCM1Bzy4Ab3g72Nbajo/0ZyW+W9hdz8Hbe4l97pQ==}
- peerDependencies:
- react: ^16.6.0 || ^17.0.0
- react-dom: ^16.6.0 || ^17.0.0
+ /react-html-attributes@1.4.6:
+ resolution: {integrity: sha512-uS3MmThNKFH2EZUQQw4k5pIcU7XIr208UE5dktrj/GOH1CMagqxDl4DCLpt3o2l9x+IB5nVYBeN3Cr4IutBXAg==}
dependencies:
- '@babel/runtime': 7.15.3
- invariant: 2.2.4
- prop-types: 15.7.2
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- react-fast-compare: 3.2.0
- shallowequal: 1.1.0
+ html-element-attributes: 1.3.1
dev: true
- /react-inspector/5.1.1:
- resolution: {integrity: sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==}
- peerDependencies:
- react: ^16.8.4 || ^17.0.0
- dependencies:
- '@babel/runtime': 7.15.3
- is-dom: 1.1.0
- prop-types: 15.7.2
- dev: true
-
- /react-is/16.13.1:
+ /react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true
- /react-is/17.0.2:
- resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
- dev: true
-
- /react-lifecycles-compat/3.0.4:
- resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
- dev: true
-
- /react-popper-tooltip/3.1.1:
- resolution: {integrity: sha512-EnERAnnKRptQBJyaee5GJScWNUKQPDD2ywvzZyUjst/wj5U64C8/CnSYLNEmP2hG0IJ3ZhtDxE8oDN+KOyavXQ==}
- peerDependencies:
- react: ^16.6.0 || ^17.0.0
- react-dom: ^16.6.0 || ^17.0.0
- dependencies:
- '@babel/runtime': 7.15.3
- '@popperjs/core': 2.9.3
- react-popper: 2.2.5_@popperjs+core@2.9.3
- dev: true
-
- /react-popper-tooltip/3.1.1_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-EnERAnnKRptQBJyaee5GJScWNUKQPDD2ywvzZyUjst/wj5U64C8/CnSYLNEmP2hG0IJ3ZhtDxE8oDN+KOyavXQ==}
- peerDependencies:
- react: ^16.6.0 || ^17.0.0
- react-dom: ^16.6.0 || ^17.0.0
- dependencies:
- '@babel/runtime': 7.15.3
- '@popperjs/core': 2.9.3
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- react-popper: 2.2.5_6bb145cab7dfe893f5ebfae476998f0c
- dev: true
-
- /react-popper/2.2.5_6bb145cab7dfe893f5ebfae476998f0c:
- resolution: {integrity: sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==}
- peerDependencies:
- '@popperjs/core': ^2.0.0
- react: ^16.8.0 || ^17
- dependencies:
- '@popperjs/core': 2.9.3
- react: 16.14.0
- react-fast-compare: 3.2.0
- warning: 4.0.3
- dev: true
-
- /react-popper/2.2.5_@popperjs+core@2.9.3:
- resolution: {integrity: sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==}
- peerDependencies:
- '@popperjs/core': ^2.0.0
- react: ^16.8.0 || ^17
- dependencies:
- '@popperjs/core': 2.9.3
- react-fast-compare: 3.2.0
- warning: 4.0.3
- dev: true
-
- /react-refresh/0.10.0:
+ /react-refresh@0.10.0:
resolution: {integrity: sha512-PgidR3wST3dDYKr6b4pJoqQFpPGNKDSCDx4cZoshjXipw3LzO7mG1My2pwEzz2JVkF+inx3xRpDeQLFQGH/hsQ==}
engines: {node: '>=0.10.0'}
dev: true
- /react-sizeme/3.0.1:
- resolution: {integrity: sha512-9Hf1NLgSbny1bha77l9HwvwwxQUJxFUqi44Ih+y3evA+PezBpGdCGlnvye6avss2cIgs9PgdYgMnfuzJWn/RUw==}
- peerDependencies:
- react: ^0.14.0 || ^15.0.0-0 || ^16.0.0 || ^17.0.0
- react-dom: ^0.14.0 || ^15.0.0-0 || ^16.0.0 || ^17.0.0
- dependencies:
- element-resize-detector: 1.2.3
- invariant: 2.2.4
- shallowequal: 1.1.0
- throttle-debounce: 3.0.1
- dev: true
-
- /react-sizeme/3.0.1_react-dom@16.14.0+react@16.14.0:
- resolution: {integrity: sha512-9Hf1NLgSbny1bha77l9HwvwwxQUJxFUqi44Ih+y3evA+PezBpGdCGlnvye6avss2cIgs9PgdYgMnfuzJWn/RUw==}
- peerDependencies:
- react: ^0.14.0 || ^15.0.0-0 || ^16.0.0 || ^17.0.0
- react-dom: ^0.14.0 || ^15.0.0-0 || ^16.0.0 || ^17.0.0
- dependencies:
- element-resize-detector: 1.2.3
- invariant: 2.2.4
- react: 16.14.0
- react-dom: 16.14.0_react@16.14.0
- shallowequal: 1.1.0
- throttle-debounce: 3.0.1
- dev: true
-
- /react-syntax-highlighter/13.5.3:
- resolution: {integrity: sha512-crPaF+QGPeHNIblxxCdf2Lg936NAHKhNhuMzRL3F9ct6aYXL3NcZtCL0Rms9+qVo6Y1EQLdXGypBNSbPL/r+qg==}
- peerDependencies:
- react: '>= 0.14.0'
- dependencies:
- '@babel/runtime': 7.15.3
- highlight.js: 10.7.3
- lowlight: 1.20.0
- prismjs: 1.24.1
- refractor: 3.4.0
- dev: true
-
- /react-syntax-highlighter/13.5.3_react@16.14.0:
- resolution: {integrity: sha512-crPaF+QGPeHNIblxxCdf2Lg936NAHKhNhuMzRL3F9ct6aYXL3NcZtCL0Rms9+qVo6Y1EQLdXGypBNSbPL/r+qg==}
- peerDependencies:
- react: '>= 0.14.0'
- dependencies:
- '@babel/runtime': 7.15.3
- highlight.js: 10.7.3
- lowlight: 1.20.0
- prismjs: 1.24.1
- react: 16.14.0
- refractor: 3.4.0
- dev: true
-
- /react-textarea-autosize/8.3.3:
- resolution: {integrity: sha512-2XlHXK2TDxS6vbQaoPbMOfQ8GK7+irc2fVK6QFIcC8GOnH3zI/v481n+j1L0WaPVvKxwesnY93fEfH++sus2rQ==}
- engines: {node: '>=10'}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- dependencies:
- '@babel/runtime': 7.15.3
- use-composed-ref: 1.1.0
- use-latest: 1.2.0
- transitivePeerDependencies:
- - '@types/react'
- dev: true
-
- /react-textarea-autosize/8.3.3_react@16.14.0:
- resolution: {integrity: sha512-2XlHXK2TDxS6vbQaoPbMOfQ8GK7+irc2fVK6QFIcC8GOnH3zI/v481n+j1L0WaPVvKxwesnY93fEfH++sus2rQ==}
- engines: {node: '>=10'}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- dependencies:
- '@babel/runtime': 7.15.3
- react: 16.14.0
- use-composed-ref: 1.1.0_react@16.14.0
- use-latest: 1.2.0_react@16.14.0
- transitivePeerDependencies:
- - '@types/react'
- dev: true
-
- /react/16.14.0:
- resolution: {integrity: sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==}
+ /react@18.2.0:
+ resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'}
dependencies:
loose-envify: 1.4.0
- object-assign: 4.1.1
- prop-types: 15.7.2
- dev: true
-
- /read-pkg-up/2.0.0:
- resolution: {integrity: sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=}
- engines: {node: '>=4'}
- dependencies:
- find-up: 2.1.0
- read-pkg: 2.0.0
- dev: true
-
- /read-pkg-up/7.0.1:
- resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==}
- engines: {node: '>=8'}
- dependencies:
- find-up: 4.1.0
- read-pkg: 5.2.0
- type-fest: 0.8.1
- dev: true
-
- /read-pkg/2.0.0:
- resolution: {integrity: sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=}
- engines: {node: '>=4'}
- dependencies:
- load-json-file: 2.0.0
- normalize-package-data: 2.5.0
- path-type: 2.0.0
- dev: true
- /read-pkg/5.2.0:
- resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
- engines: {node: '>=8'}
+ /read-cache@1.0.0:
+ resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
dependencies:
- '@types/normalize-package-data': 2.4.1
- normalize-package-data: 2.5.0
- parse-json: 5.2.0
- type-fest: 0.6.0
- dev: true
+ pify: 2.3.0
- /readable-stream/2.3.7:
- resolution: {integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==}
+ /readable-stream@2.3.8:
+ resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
dependencies:
- core-util-is: 1.0.2
+ core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
@@ -18820,82 +16555,99 @@ packages:
util-deprecate: 1.0.2
dev: true
- /readable-stream/3.6.0:
+ /readable-stream@3.6.0:
resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==}
engines: {node: '>= 6'}
+ requiresBuild: true
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
+ /readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
+
+ /readable-stream@4.5.2:
+ resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ dependencies:
+ abort-controller: 3.0.0
+ buffer: 6.0.3
+ events: 3.3.0
+ process: 0.11.10
+ string_decoder: 1.3.0
dev: true
- /readdirp/2.2.1:
+ /readdirp@2.2.1:
resolution: {integrity: sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==}
engines: {node: '>=0.10'}
+ requiresBuild: true
dependencies:
- graceful-fs: 4.2.8
+ graceful-fs: 4.2.11
micromatch: 3.1.10
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
+ transitivePeerDependencies:
+ - supports-color
dev: true
+ optional: true
- /readdirp/3.6.0:
+ /readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
dependencies:
- picomatch: 2.3.0
- dev: true
+ picomatch: 2.3.1
- /rechoir/0.6.2:
- resolution: {integrity: sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=}
- engines: {node: '>= 0.10'}
- dependencies:
- resolve: 1.19.0
+ /real-require@0.2.0:
+ resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
+ engines: {node: '>= 12.13.0'}
dev: true
- /recursive-readdir/2.2.2:
- resolution: {integrity: sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg==}
- engines: {node: '>=0.10.0'}
- dependencies:
- minimatch: 3.0.4
- dev: true
-
- /refractor/3.4.0:
- resolution: {integrity: sha512-dBeD02lC5eytm9Gld2Mx0cMcnR+zhSnsTfPpWqFaMgUMJfC9A6bcN3Br/NaXrnBJcuxnLFR90k1jrkaSyV8umg==}
+ /reflect.getprototypeof@1.0.4:
+ resolution: {integrity: sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==}
+ engines: {node: '>= 0.4'}
dependencies:
- hastscript: 6.0.0
- parse-entities: 2.0.0
- prismjs: 1.24.1
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ get-intrinsic: 1.2.2
+ globalthis: 1.0.3
+ which-builtin-type: 1.1.3
dev: true
- /regenerate-unicode-properties/8.2.0:
- resolution: {integrity: sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==}
+ /regenerate-unicode-properties@10.1.0:
+ resolution: {integrity: sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==}
engines: {node: '>=4'}
dependencies:
regenerate: 1.4.2
dev: true
- /regenerate/1.4.2:
+ /regenerate@1.4.2:
resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==}
dev: true
- /regenerator-runtime/0.11.1:
- resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==}
- dev: true
+ /regenerator-runtime@0.13.10:
+ resolution: {integrity: sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==}
- /regenerator-runtime/0.13.7:
- resolution: {integrity: sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==}
+ /regenerator-runtime@0.13.11:
+ resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
dev: true
- /regenerator-runtime/0.13.9:
- resolution: {integrity: sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==}
+ /regenerator-runtime@0.14.0:
+ resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==}
+ dev: true
- /regenerator-transform/0.14.5:
- resolution: {integrity: sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==}
+ /regenerator-transform@0.15.2:
+ resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==}
dependencies:
- '@babel/runtime': 7.15.3
+ '@babel/runtime': 7.23.6
dev: true
- /regex-not/1.0.2:
+ /regex-not@1.0.2:
resolution: {integrity: sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -18903,162 +16655,125 @@ packages:
safe-regex: 1.1.0
dev: true
- /regexp.prototype.flags/1.3.1:
- resolution: {integrity: sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA==}
+ /regexp.prototype.flags@1.5.1:
+ resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ set-function-name: 2.0.1
dev: true
- /regexpp/2.0.1:
- resolution: {integrity: sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==}
- engines: {node: '>=6.5.0'}
- dev: true
-
- /regexpp/3.1.0:
- resolution: {integrity: sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==}
+ /regexpp@3.2.0:
+ resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==}
engines: {node: '>=8'}
dev: true
- /regexpu-core/4.7.1:
- resolution: {integrity: sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==}
+ /regexpu-core@5.3.2:
+ resolution: {integrity: sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==}
engines: {node: '>=4'}
dependencies:
+ '@babel/regjsgen': 0.8.0
regenerate: 1.4.2
- regenerate-unicode-properties: 8.2.0
- regjsgen: 0.5.2
- regjsparser: 0.6.9
- unicode-match-property-ecmascript: 1.0.4
- unicode-match-property-value-ecmascript: 1.2.0
+ regenerate-unicode-properties: 10.1.0
+ regjsparser: 0.9.1
+ unicode-match-property-ecmascript: 2.0.0
+ unicode-match-property-value-ecmascript: 2.1.0
dev: true
- /registry-auth-token/4.2.1:
- resolution: {integrity: sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==}
+ /registry-auth-token@4.2.2:
+ resolution: {integrity: sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg==}
engines: {node: '>=6.0.0'}
dependencies:
rc: 1.2.8
dev: true
- /registry-url/5.1.0:
+ /registry-auth-token@5.0.2:
+ resolution: {integrity: sha512-o/3ikDxtXaA59BmZuZrJZDJv8NMDGSj+6j6XaeBmHw8eY1i1qd9+6H+LjVvQXx3HN6aRCGa1cUdJ9RaJZUugnQ==}
+ engines: {node: '>=14'}
+ dependencies:
+ '@pnpm/npm-conf': 2.2.2
+ dev: true
+
+ /registry-url@5.1.0:
resolution: {integrity: sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==}
engines: {node: '>=8'}
dependencies:
rc: 1.2.8
dev: true
- /regjsgen/0.5.2:
- resolution: {integrity: sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==}
+ /registry-url@6.0.1:
+ resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==}
+ engines: {node: '>=12'}
+ dependencies:
+ rc: 1.2.8
dev: true
- /regjsparser/0.6.9:
- resolution: {integrity: sha512-ZqbNRz1SNjLAiYuwY0zoXW8Ne675IX5q+YHioAGbCw4X96Mjl2+dcX9B2ciaeyYjViDAfvIjFpQjJgLttTEERQ==}
+ /regjsparser@0.9.1:
+ resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==}
hasBin: true
dependencies:
jsesc: 0.5.0
dev: true
- /relateurl/0.2.7:
- resolution: {integrity: sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=}
+ /relateurl@0.2.7:
+ resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==}
engines: {node: '>= 0.10'}
dev: true
- /release-zalgo/1.0.0:
- resolution: {integrity: sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=}
- engines: {node: '>=4'}
- dependencies:
- es6-error: 4.1.1
- dev: true
-
- /remark-external-links/8.0.0:
- resolution: {integrity: sha512-5vPSX0kHoSsqtdftSHhIYofVINC8qmp0nctkeU9YoJwV3YfiBRiI6cbFRJ0oI/1F9xS+bopXG0m2KS8VFscuKA==}
- dependencies:
- extend: 3.0.2
- is-absolute-url: 3.0.3
- mdast-util-definitions: 4.0.0
- space-separated-tokens: 1.1.5
- unist-util-visit: 2.0.3
- dev: true
-
- /remark-footnotes/2.0.0:
- resolution: {integrity: sha512-3Clt8ZMH75Ayjp9q4CorNeyjwIxHFcTkaektplKGl2A1jNGEUey8cKL0ZC5vJwfcD5GFGsNLImLG/NGzWIzoMQ==}
- dev: true
-
- /remark-mdx/1.6.22:
- resolution: {integrity: sha512-phMHBJgeV76uyFkH4rvzCftLfKCr2RZuF+/gmVcaKrpsihyzmhXjA0BEMDaPTXG5y8qZOKPVo83NAOX01LPnOQ==}
+ /relaxed-json@1.0.3:
+ resolution: {integrity: sha512-b7wGPo7o2KE/g7SqkJDDbav6zmrEeP4TK2VpITU72J/M949TLe/23y/ZHJo+pskcGM52xIfFoT9hydwmgr1AEg==}
+ engines: {node: '>= 0.10.0'}
+ hasBin: true
dependencies:
- '@babel/core': 7.12.9
- '@babel/helper-plugin-utils': 7.10.4
- '@babel/plugin-proposal-object-rest-spread': 7.12.1_@babel+core@7.12.9
- '@babel/plugin-syntax-jsx': 7.12.1_@babel+core@7.12.9
- '@mdx-js/util': 1.6.22
- is-alphabetical: 1.0.4
- remark-parse: 8.0.3
- unified: 9.2.0
- transitivePeerDependencies:
- - supports-color
+ chalk: 2.4.2
+ commander: 2.20.3
dev: true
- /remark-parse/8.0.3:
- resolution: {integrity: sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==}
+ /release-zalgo@1.0.0:
+ resolution: {integrity: sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==}
+ engines: {node: '>=4'}
dependencies:
- ccount: 1.1.0
- collapse-white-space: 1.0.6
- is-alphabetical: 1.0.4
- is-decimal: 1.0.4
- is-whitespace-character: 1.0.4
- is-word-character: 1.0.4
- markdown-escapes: 1.0.4
- parse-entities: 2.0.0
- repeat-string: 1.6.1
- state-toggle: 1.0.3
- trim: 0.0.1
- trim-trailing-lines: 1.1.4
- unherit: 1.1.3
- unist-util-remove-position: 2.0.1
- vfile-location: 3.2.0
- xtend: 4.0.2
+ es6-error: 4.1.1
dev: true
- /remark-slug/6.1.0:
- resolution: {integrity: sha512-oGCxDF9deA8phWvxFuyr3oSJsdyUAxMFbA0mZ7Y1Sas+emILtO+e5WutF9564gDsEN4IXaQXm5pFo6MLH+YmwQ==}
- dependencies:
- github-slugger: 1.3.0
- mdast-util-to-string: 1.1.0
- unist-util-visit: 2.0.3
+ /remove-trailing-separator@1.1.0:
+ resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==}
+ requiresBuild: true
dev: true
+ optional: true
- /remark-squeeze-paragraphs/4.0.0:
- resolution: {integrity: sha512-8qRqmL9F4nuLPIgl92XUuxI3pFxize+F1H0e/W3llTk0UsjJaj01+RrirkMw7P21RKe4X6goQhYRSvNWX+70Rw==}
+ /renderkid@2.0.7:
+ resolution: {integrity: sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==}
dependencies:
- mdast-squeeze-paragraphs: 4.0.0
- dev: true
-
- /remove-trailing-separator/1.1.0:
- resolution: {integrity: sha1-wkvOKig62tW8P1jg1IJJuSN52O8=}
+ css-select: 4.3.0
+ dom-converter: 0.2.0
+ htmlparser2: 6.1.0
+ lodash: 4.17.21
+ strip-ansi: 3.0.1
dev: true
- /renderkid/2.0.7:
- resolution: {integrity: sha512-oCcFyxaMrKsKcTY59qnCAtmDVSLfPbrv6A3tVbPdFMMrv5jaK10V6m40cKsoPNhAqN6rmHW9sswW4o3ruSrwUQ==}
+ /renderkid@3.0.0:
+ resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==}
dependencies:
- css-select: 4.1.3
+ css-select: 4.3.0
dom-converter: 0.2.0
htmlparser2: 6.1.0
lodash: 4.17.21
- strip-ansi: 3.0.1
+ strip-ansi: 6.0.1
dev: true
- /repeat-element/1.1.4:
+ /repeat-element@1.1.4:
resolution: {integrity: sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==}
engines: {node: '>=0.10.0'}
dev: true
- /repeat-string/1.6.1:
- resolution: {integrity: sha1-jcrkcOHIirwtYA//Sndihtp15jc=}
+ /repeat-string@1.6.1:
+ resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==}
engines: {node: '>=0.10'}
dev: true
- /request-promise-core/1.1.4_request@2.88.2:
+ /request-promise-core@1.1.4(request@2.88.2):
resolution: {integrity: sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==}
engines: {node: '>=0.10.0'}
peerDependencies:
@@ -19068,23 +16783,21 @@ packages:
request: 2.88.2
dev: true
- /request-promise-native/1.0.9_request@2.88.2:
+ /request-promise-native@1.0.9(request@2.88.2):
resolution: {integrity: sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==}
engines: {node: '>=0.12.0'}
- deprecated: request-promise-native has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142
peerDependencies:
request: ^2.34
dependencies:
request: 2.88.2
- request-promise-core: 1.1.4_request@2.88.2
+ request-promise-core: 1.1.4(request@2.88.2)
stealthy-require: 1.1.1
tough-cookie: 2.5.0
dev: true
- /request/2.88.2:
+ /request@2.88.2:
resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==}
engines: {node: '>= 6'}
- deprecated: request has been deprecated, see https://github.com/request/request/issues/3142
dependencies:
aws-sign2: 0.7.0
aws4: 1.11.0
@@ -19098,584 +16811,484 @@ packages:
is-typedarray: 1.0.0
isstream: 0.1.2
json-stringify-safe: 5.0.1
- mime-types: 2.1.32
+ mime-types: 2.1.35
oauth-sign: 0.9.0
performance-now: 2.1.0
- qs: 6.5.2
+ qs: 6.5.3
safe-buffer: 5.2.1
tough-cookie: 2.5.0
tunnel-agent: 0.6.0
uuid: 3.4.0
dev: true
- /require-directory/2.1.1:
- resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
+ /require-directory@2.1.1:
+ resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
dev: true
- /require-from-string/2.0.2:
+ /require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
dev: true
- /require-main-filename/2.0.0:
+ /require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
dev: true
- /requires-port/1.0.0:
- resolution: {integrity: sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=}
+ /requires-port@1.0.0:
+ resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
dev: true
- /resolve-cwd/2.0.0:
- resolution: {integrity: sha1-AKn3OHVW4nA46uIyyqNypqWbZlo=}
- engines: {node: '>=4'}
- dependencies:
- resolve-from: 3.0.0
+ /resolve-alpn@1.2.1:
+ resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
dev: true
- /resolve-cwd/3.0.0:
+ /resolve-cwd@3.0.0:
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
engines: {node: '>=8'}
dependencies:
resolve-from: 5.0.0
dev: true
- /resolve-from/3.0.0:
- resolution: {integrity: sha1-six699nWiBvItuZTM17rywoYh0g=}
+ /resolve-from@3.0.0:
+ resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==}
engines: {node: '>=4'}
dev: true
- /resolve-from/4.0.0:
+ /resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
dev: true
- /resolve-from/5.0.0:
+ /resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
dev: true
- /resolve-pathname/3.0.0:
+ /resolve-pathname@3.0.0:
resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==}
dev: false
- /resolve-url/0.2.1:
- resolution: {integrity: sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=}
+ /resolve-url@0.2.1:
+ resolution: {integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==}
deprecated: https://github.com/lydell/resolve-url#deprecated
dev: true
- /resolve/1.17.0:
- resolution: {integrity: sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==}
+ /resolve@1.22.2:
+ resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==}
+ hasBin: true
dependencies:
- path-parse: 1.0.6
- dev: true
+ is-core-module: 2.13.1
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
- /resolve/1.19.0:
- resolution: {integrity: sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==}
+ /resolve@1.22.8:
+ resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==}
+ hasBin: true
dependencies:
- is-core-module: 2.2.0
- path-parse: 1.0.6
- dev: true
+ is-core-module: 2.13.1
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
- /resolve/1.20.0:
- resolution: {integrity: sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==}
+ /resolve@2.0.0-next.5:
+ resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
+ hasBin: true
dependencies:
- is-core-module: 2.6.0
+ is-core-module: 2.13.1
path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
dev: true
- /responselike/1.0.2:
- resolution: {integrity: sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=}
+ /responselike@1.0.2:
+ resolution: {integrity: sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==}
dependencies:
lowercase-keys: 1.0.1
dev: true
- /restore-cursor/3.1.0:
+ /responselike@3.0.0:
+ resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==}
+ engines: {node: '>=14.16'}
+ dependencies:
+ lowercase-keys: 3.0.0
+ dev: true
+
+ /restore-cursor@3.1.0:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
dependencies:
onetime: 5.1.2
- signal-exit: 3.0.3
+ signal-exit: 3.0.7
dev: true
- /ret/0.1.15:
+ /ret@0.1.15:
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
engines: {node: '>=0.12'}
dev: true
- /retry/0.12.0:
- resolution: {integrity: sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=}
+ /retry@0.13.1:
+ resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
dev: true
- /reusify/1.0.4:
+ /reusify@1.0.4:
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
- dev: true
- /rgb-regex/1.0.1:
- resolution: {integrity: sha1-wODWiC3w4jviVKR16O3UGRX+rrE=}
+ /rgb-regex@1.0.1:
+ resolution: {integrity: sha512-gDK5mkALDFER2YLqH6imYvK6g02gpNGM4ILDZ472EwWfXZnC2ZEpoB2ECXTyOVUKuk/bPJZMzwQPBYICzP+D3w==}
dev: true
- /rgba-regex/1.0.0:
- resolution: {integrity: sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=}
+ /rgba-regex@1.0.0:
+ resolution: {integrity: sha512-zgn5OjNQXLUTdq8m17KdaicF6w89TZs8ZU8y0AYENIU6wG8GG6LLm0yLSiPY8DmaYmHdgRW8rnApjoT0fQRfMg==}
dev: true
- /rimraf/2.6.3:
- resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
+ /rimraf@2.4.5:
+ resolution: {integrity: sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==}
hasBin: true
+ requiresBuild: true
dependencies:
- glob: 7.1.7
+ glob: 6.0.4
dev: true
+ optional: true
- /rimraf/2.7.1:
+ /rimraf@2.7.1:
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
hasBin: true
dependencies:
- glob: 7.1.7
+ glob: 7.2.3
dev: true
- /rimraf/3.0.2:
+ /rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
- hasBin: true
dependencies:
- glob: 7.1.7
+ glob: 7.2.3
dev: true
- /ripemd160/2.0.2:
+ /ripemd160@2.0.2:
resolution: {integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==}
dependencies:
hash-base: 3.1.0
inherits: 2.0.4
dev: true
- /rollup-plugin-css-only/3.1.0_rollup@2.56.2:
- resolution: {integrity: sha512-TYMOE5uoD76vpj+RTkQLzC9cQtbnJNktHPB507FzRWBVaofg7KhIqq1kGbcVOadARSozWF883Ho9KpSPKH8gqA==}
- engines: {node: '>=10.12.0'}
- peerDependencies:
- rollup: 1 || 2
- dependencies:
- '@rollup/pluginutils': 4.1.1
- rollup: 2.56.2
- dev: true
-
- /rollup-plugin-ignore/1.0.9:
- resolution: {integrity: sha512-+Q2jmD4gbO3ByFuljkDEfpEcYvll7J5+ZadUuk/Pu35x2KGrbHxKtt3+s+dPIgXX1mG7zCxG4s/NdRqztR5Ruw==}
- dev: true
-
- /rollup-plugin-sourcemaps/0.6.3_38ff52cc32daa1ae80c428f8a47a4e22:
- resolution: {integrity: sha512-paFu+nT1xvuO1tPFYXGe+XnQvg4Hjqv/eIhG8i5EspfYYPBKL57X7iVbfv55aNVASg3dzWvES9dmWsL2KhfByw==}
- engines: {node: '>=10.0.0'}
- peerDependencies:
- '@types/node': '>=10.0.0'
- rollup: '>=0.31.2'
- peerDependenciesMeta:
- '@types/node':
- optional: true
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.37.1
- '@types/node': 14.14.22
- rollup: 2.37.1
- source-map-resolve: 0.6.0
- dev: true
-
- /rollup-plugin-sourcemaps/0.6.3_6efbbae6640434994627e0ab519821c6:
- resolution: {integrity: sha512-paFu+nT1xvuO1tPFYXGe+XnQvg4Hjqv/eIhG8i5EspfYYPBKL57X7iVbfv55aNVASg3dzWvES9dmWsL2KhfByw==}
- engines: {node: '>=10.0.0'}
- peerDependencies:
- '@types/node': '>=10.0.0'
- rollup: '>=0.31.2'
- peerDependenciesMeta:
- '@types/node':
- optional: true
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.43.0
- '@types/node': 14.17.1
- rollup: 2.43.0
- source-map-resolve: 0.6.0
- dev: true
-
- /rollup-plugin-sourcemaps/0.6.3_87d168520bd21f84b7cb8eb331bc7479:
- resolution: {integrity: sha512-paFu+nT1xvuO1tPFYXGe+XnQvg4Hjqv/eIhG8i5EspfYYPBKL57X7iVbfv55aNVASg3dzWvES9dmWsL2KhfByw==}
- engines: {node: '>=10.0.0'}
- peerDependencies:
- '@types/node': '>=10.0.0'
- rollup: '>=0.31.2'
- peerDependenciesMeta:
- '@types/node':
- optional: true
- dependencies:
- '@rollup/pluginutils': 3.1.0_rollup@2.56.2
- '@types/node': 14.17.10
- rollup: 2.56.2
- source-map-resolve: 0.6.0
- dev: true
-
- /rollup-plugin-terser/7.0.2_rollup@2.37.1:
+ /rollup-plugin-terser@7.0.2(rollup@2.79.1):
resolution: {integrity: sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==}
peerDependencies:
rollup: ^2.0.0
dependencies:
- '@babel/code-frame': 7.12.13
+ '@babel/code-frame': 7.23.5
jest-worker: 26.6.2
- rollup: 2.37.1
+ rollup: 2.79.1
serialize-javascript: 4.0.0
- terser: 5.4.0
+ terser: 5.15.1
dev: true
- /rollup-plugin-terser/7.0.2_rollup@2.43.0:
- resolution: {integrity: sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==}
- peerDependencies:
- rollup: ^2.0.0
- dependencies:
- '@babel/code-frame': 7.12.13
- jest-worker: 26.6.2
- rollup: 2.43.0
- serialize-javascript: 4.0.0
- terser: 5.4.0
- dev: true
-
- /rollup-plugin-terser/7.0.2_rollup@2.56.2:
- resolution: {integrity: sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==}
- peerDependencies:
- rollup: ^2.0.0
- dependencies:
- '@babel/code-frame': 7.12.13
- jest-worker: 26.6.2
- rollup: 2.56.2
- serialize-javascript: 4.0.0
- terser: 5.4.0
- dev: true
-
- /rollup/2.37.1:
- resolution: {integrity: sha512-V3ojEeyGeSdrMSuhP3diBb06P+qV4gKQeanbDv+Qh/BZbhdZ7kHV0xAt8Yjk4GFshq/WjO7R4c7DFM20AwTFVQ==}
- engines: {node: '>=10.0.0'}
- hasBin: true
- optionalDependencies:
- fsevents: 2.1.3
- dev: true
-
- /rollup/2.43.0:
- resolution: {integrity: sha512-FRsYGqlo1iF/w3bv319iStAK0hyhhwon35Cbo7sGUoXaOpsZFy6Lel7UoGb5bNDE4OsoWjMH94WiVvpOM26l3g==}
+ /rollup@2.79.1:
+ resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==}
engines: {node: '>=10.0.0'}
- hasBin: true
optionalDependencies:
- fsevents: 2.3.2
+ fsevents: 2.3.3
dev: true
- /rollup/2.56.2:
- resolution: {integrity: sha512-s8H00ZsRi29M2/lGdm1u8DJpJ9ML8SUOpVVBd33XNeEeL3NVaTiUcSBHzBdF3eAyR0l7VSpsuoVUGrRHq7aPwQ==}
- engines: {node: '>=10.0.0'}
- hasBin: true
- optionalDependencies:
- fsevents: 2.3.2
- dev: true
-
- /rst-selector-parser/2.2.3:
- resolution: {integrity: sha1-gbIw6i/MYGbInjRy3nlChdmwPZE=}
- dependencies:
- lodash.flattendeep: 4.4.0
- nearley: 2.20.1
- dev: true
-
- /rsvp/4.8.5:
- resolution: {integrity: sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==}
- engines: {node: 6.* || >= 7.*}
- dev: true
-
- /run-async/2.4.1:
- resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
- engines: {node: '>=0.12.0'}
- dev: true
-
- /run-parallel/1.2.0:
+ /run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies:
queue-microtask: 1.2.3
- dev: true
- /run-queue/1.0.3:
- resolution: {integrity: sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=}
+ /run-queue@1.0.3:
+ resolution: {integrity: sha512-ntymy489o0/QQplUDnpYAYUsO50K9SBrIVaKCWDOJzYJts0f9WH9RFJkyagebkw5+y1oi00R7ynNW/d12GBumg==}
dependencies:
aproba: 1.2.0
dev: true
- /rxjs/6.6.7:
- resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==}
- engines: {npm: '>=2.0.0'}
+ /sade@1.8.1:
+ resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
+ engines: {node: '>=6'}
dependencies:
- tslib: 1.14.1
+ mri: 1.2.0
dev: true
- /sade/1.7.4:
- resolution: {integrity: sha512-y5yauMD93rX840MwUJr7C1ysLFBgMspsdTo4UVrDg3fXDvtwOyIqykhVAAm6fk/3au77773itJStObgK+LKaiA==}
- engines: {node: '>= 6'}
+ /safe-array-concat@1.0.1:
+ resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==}
+ engines: {node: '>=0.4'}
dependencies:
- mri: 1.1.6
- dev: true
-
- /safe-buffer/5.1.1:
- resolution: {integrity: sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==}
+ call-bind: 1.0.5
+ get-intrinsic: 1.2.2
+ has-symbols: 1.0.3
+ isarray: 2.0.5
dev: true
- /safe-buffer/5.1.2:
+ /safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
dev: true
- /safe-buffer/5.2.1:
+ /safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+ requiresBuild: true
+
+ /safe-json-stringify@1.2.0:
+ resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==}
+ requiresBuild: true
dev: true
+ optional: true
- /safe-regex/1.1.0:
- resolution: {integrity: sha1-QKNmnzsHfR6UPURinhV91IAjvy4=}
+ /safe-regex-test@1.0.0:
+ resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==}
dependencies:
- ret: 0.1.15
+ call-bind: 1.0.5
+ get-intrinsic: 1.2.2
+ is-regex: 1.1.4
dev: true
- /safer-buffer/2.1.2:
- resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ /safe-regex@1.1.0:
+ resolution: {integrity: sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==}
+ dependencies:
+ ret: 0.1.15
dev: true
- /sane/4.1.0:
- resolution: {integrity: sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==}
- engines: {node: 6.* || 8.* || >= 10.*}
- deprecated: some dependency vulnerabilities fixed, support for node < 10 dropped, and newer ECMAScript syntax/features added
- hasBin: true
- dependencies:
- '@cnakazawa/watch': 1.0.4
- anymatch: 2.0.0
- capture-exit: 2.0.0
- exec-sh: 0.3.6
- execa: 1.0.0
- fb-watchman: 2.0.1
- micromatch: 3.1.10
- minimist: 1.2.5
- walker: 1.0.7
+ /safe-stable-stringify@2.4.3:
+ resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==}
+ engines: {node: '>=10'}
dev: true
- /sass-loader/10.2.0_sass@1.43.2:
- resolution: {integrity: sha512-kUceLzC1gIHz0zNJPpqRsJyisWatGYNFRmv2CKZK2/ngMJgLqxTbXwe/hJ85luyvZkgqU3VlJ33UVF2T/0g6mw==}
- engines: {node: '>= 10.13.0'}
- peerDependencies:
- fibers: '>= 3.1.0'
- node-sass: ^4.0.0 || ^5.0.0 || ^6.0.0
- sass: ^1.3.0
- webpack: ^4.36.0 || ^5.0.0
- peerDependenciesMeta:
- fibers:
- optional: true
- node-sass:
- optional: true
- sass:
- optional: true
- dependencies:
- klona: 2.0.4
- loader-utils: 2.0.0
- neo-async: 2.6.2
- sass: 1.43.2
- schema-utils: 3.1.1
- semver: 7.3.5
+ /safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
dev: true
- /sass/1.43.2:
- resolution: {integrity: sha512-DncYhjl3wBaPMMJR0kIUaH3sF536rVrOcqqVGmTZHQRRzj7LQlyGV7Mb8aCKFyILMr5VsPHwRYtyKpnKYlmQSQ==}
- engines: {node: '>=8.9.0'}
- hasBin: true
+ /sass@1.56.1:
+ resolution: {integrity: sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==}
+ engines: {node: '>=12.0.0'}
dependencies:
- chokidar: 3.5.2
+ chokidar: 3.5.3
+ immutable: 4.1.0
+ source-map-js: 1.0.2
dev: true
- /sax/1.2.4:
+ /sax@1.2.4:
resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
dev: true
- /saxes/3.1.11:
+ /saxes@3.1.11:
resolution: {integrity: sha512-Ydydq3zC+WYDJK1+gRxRapLIED9PWeSuuS41wqyoRmzvhhh9nc+QQrVMKJYzJFULazeGhzSV0QleN2wD3boh2g==}
engines: {node: '>=8'}
dependencies:
xmlchars: 2.2.0
dev: true
- /saxes/5.0.1:
- resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==}
- engines: {node: '>=10'}
- dependencies:
- xmlchars: 2.2.0
- dev: true
-
- /scheduler/0.19.1:
- resolution: {integrity: sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==}
+ /scheduler@0.23.0:
+ resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
dependencies:
loose-envify: 1.4.0
- object-assign: 4.1.1
- dev: true
+ dev: false
- /schema-utils/1.0.0:
- resolution: {integrity: sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==}
+ /schema-utils@0.4.7:
+ resolution: {integrity: sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==}
engines: {node: '>= 4'}
dependencies:
ajv: 6.12.6
- ajv-errors: 1.0.1_ajv@6.12.6
- ajv-keywords: 3.5.2_ajv@6.12.6
+ ajv-keywords: 3.5.2(ajv@6.12.6)
dev: true
- /schema-utils/2.7.0:
- resolution: {integrity: sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==}
- engines: {node: '>= 8.9.0'}
+ /schema-utils@1.0.0:
+ resolution: {integrity: sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==}
+ engines: {node: '>= 4'}
dependencies:
- '@types/json-schema': 7.0.9
ajv: 6.12.6
- ajv-keywords: 3.5.2_ajv@6.12.6
+ ajv-errors: 1.0.1(ajv@6.12.6)
+ ajv-keywords: 3.5.2(ajv@6.12.6)
dev: true
- /schema-utils/2.7.1:
+ /schema-utils@2.7.1:
resolution: {integrity: sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==}
engines: {node: '>= 8.9.0'}
dependencies:
- '@types/json-schema': 7.0.9
+ '@types/json-schema': 7.0.11
ajv: 6.12.6
- ajv-keywords: 3.5.2_ajv@6.12.6
+ ajv-keywords: 3.5.2(ajv@6.12.6)
dev: true
- /schema-utils/3.1.1:
+ /schema-utils@3.1.1:
resolution: {integrity: sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==}
engines: {node: '>= 10.13.0'}
dependencies:
- '@types/json-schema': 7.0.9
+ '@types/json-schema': 7.0.15
ajv: 6.12.6
- ajv-keywords: 3.5.2_ajv@6.12.6
+ ajv-keywords: 3.5.2(ajv@6.12.6)
+ dev: true
+
+ /schema-utils@4.0.0:
+ resolution: {integrity: sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==}
+ engines: {node: '>= 12.13.0'}
+ dependencies:
+ '@types/json-schema': 7.0.15
+ ajv: 8.11.0
+ ajv-formats: 2.1.1(ajv@8.11.0)
+ ajv-keywords: 5.1.0(ajv@8.11.0)
dev: true
- /select-hose/2.0.0:
- resolution: {integrity: sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=}
+ /select-hose@2.0.0:
+ resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==}
dev: true
- /selfsigned/1.10.11:
- resolution: {integrity: sha512-aVmbPOfViZqOZPgRBT0+3u4yZFHpmnIghLMlAcb5/xhp5ZtB/RVnKhz5vl2M32CLXAqR4kha9zfhNg0Lf/sxKA==}
+ /selfsigned@2.1.1:
+ resolution: {integrity: sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==}
+ engines: {node: '>=10'}
dependencies:
- node-forge: 0.10.0
+ node-forge: 1.3.1
dev: true
- /semiver/1.1.0:
+ /semiver@1.1.0:
resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==}
engines: {node: '>=6'}
dev: true
- /semver-diff/3.1.1:
+ /semver-diff@3.1.1:
resolution: {integrity: sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==}
engines: {node: '>=8'}
dependencies:
- semver: 6.3.0
+ semver: 6.3.1
dev: true
- /semver/5.7.1:
- resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
+ /semver-diff@4.0.0:
+ resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==}
+ engines: {node: '>=12'}
+ dependencies:
+ semver: 7.5.4
+ dev: true
+
+ /semver@5.7.2:
+ resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
dev: true
- /semver/6.3.0:
+ /semver@6.3.0:
resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
+
+ /semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
- dev: true
- /semver/7.0.0:
- resolution: {integrity: sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==}
+ /semver@7.3.5:
+ resolution: {integrity: sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==}
+ engines: {node: '>=10'}
hasBin: true
+ dependencies:
+ lru-cache: 6.0.0
dev: true
- /semver/7.3.4:
- resolution: {integrity: sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==}
+ /semver@7.3.8:
+ resolution: {integrity: sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==}
engines: {node: '>=10'}
hasBin: true
dependencies:
lru-cache: 6.0.0
dev: true
- /semver/7.3.5:
- resolution: {integrity: sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==}
+ /semver@7.5.4:
+ resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==}
engines: {node: '>=10'}
hasBin: true
+ requiresBuild: true
dependencies:
lru-cache: 6.0.0
- dev: true
- /send/0.17.1:
- resolution: {integrity: sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==}
+ /send@0.18.0:
+ resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
engines: {node: '>= 0.8.0'}
dependencies:
debug: 2.6.9
- depd: 1.1.2
- destroy: 1.0.4
+ depd: 2.0.0
+ destroy: 1.2.0
encodeurl: 1.0.2
escape-html: 1.0.3
etag: 1.8.1
fresh: 0.5.2
- http-errors: 1.7.3
+ http-errors: 2.0.0
mime: 1.6.0
- ms: 2.1.1
- on-finished: 2.3.0
+ ms: 2.1.3
+ on-finished: 2.4.1
range-parser: 1.2.1
- statuses: 1.5.0
+ statuses: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /serialize-error/7.0.1:
+ /serialize-error@7.0.1:
resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
engines: {node: '>=10'}
dependencies:
type-fest: 0.13.1
dev: true
- /serialize-javascript/4.0.0:
+ /serialize-javascript@4.0.0:
resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==}
dependencies:
randombytes: 2.1.0
dev: true
- /serialize-javascript/5.0.1:
+ /serialize-javascript@5.0.1:
resolution: {integrity: sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==}
dependencies:
randombytes: 2.1.0
dev: true
- /serve-favicon/2.5.0:
- resolution: {integrity: sha1-k10kDN/g9YBTB/3+ln2IlCosvPA=}
- engines: {node: '>= 0.8.0'}
+ /serialize-javascript@6.0.0:
+ resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==}
dependencies:
- etag: 1.8.1
- fresh: 0.5.2
- ms: 2.1.1
- parseurl: 1.3.3
- safe-buffer: 5.1.1
+ randombytes: 2.1.0
dev: true
- /serve-index/1.9.1:
- resolution: {integrity: sha1-03aNabHn2C5c4FD/9bRTvqEqkjk=}
+ /serve-index@1.9.1:
+ resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==}
engines: {node: '>= 0.8.0'}
dependencies:
- accepts: 1.3.7
+ accepts: 1.3.8
batch: 0.6.1
debug: 2.6.9
escape-html: 1.0.3
http-errors: 1.6.3
- mime-types: 2.1.32
+ mime-types: 2.1.35
parseurl: 1.3.3
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /serve-static/1.14.1:
- resolution: {integrity: sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==}
+ /serve-static@1.15.0:
+ resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==}
engines: {node: '>= 0.8.0'}
dependencies:
encodeurl: 1.0.2
escape-html: 1.0.3
parseurl: 1.3.3
- send: 0.17.1
+ send: 0.18.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
dev: true
- /set-blocking/2.0.0:
- resolution: {integrity: sha1-BF+XgtARrppoA93TgrJDkrPYkPc=}
+ /set-function-length@1.1.1:
+ resolution: {integrity: sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ define-data-property: 1.1.1
+ get-intrinsic: 1.2.2
+ gopd: 1.0.1
+ has-property-descriptors: 1.0.1
dev: true
- /set-value/2.0.1:
+ /set-function-name@2.0.1:
+ resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ define-data-property: 1.1.1
+ functions-have-names: 1.2.3
+ has-property-descriptors: 1.0.1
+ dev: true
+
+ /set-value@2.0.1:
resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -19685,19 +17298,19 @@ packages:
split-string: 3.1.0
dev: true
- /setimmediate/1.0.5:
- resolution: {integrity: sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=}
+ /setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
dev: true
- /setprototypeof/1.1.0:
+ /setprototypeof@1.1.0:
resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==}
dev: true
- /setprototypeof/1.1.1:
- resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==}
+ /setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
dev: true
- /sha.js/2.4.11:
+ /sha.js@2.4.11:
resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==}
hasBin: true
dependencies:
@@ -19705,139 +17318,144 @@ packages:
safe-buffer: 5.2.1
dev: true
- /shallow-clone/3.0.1:
+ /shallow-clone@3.0.1:
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
engines: {node: '>=8'}
dependencies:
kind-of: 6.0.3
dev: true
- /shallowequal/1.1.0:
- resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
- dev: true
-
- /shebang-command/1.2.0:
- resolution: {integrity: sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=}
+ /shebang-command@1.2.0:
+ resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}
dependencies:
shebang-regex: 1.0.0
dev: true
- /shebang-command/2.0.0:
+ /shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
dependencies:
shebang-regex: 3.0.0
- dev: true
- /shebang-regex/1.0.0:
- resolution: {integrity: sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=}
+ /shebang-regex@1.0.0:
+ resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==}
engines: {node: '>=0.10.0'}
dev: true
- /shebang-regex/3.0.0:
+ /shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
- dev: true
-
- /shell-quote/1.7.2:
- resolution: {integrity: sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==}
- dev: true
- /shelljs/0.8.4:
- resolution: {integrity: sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==}
- engines: {node: '>=4'}
- hasBin: true
- dependencies:
- glob: 7.1.6
- interpret: 1.4.0
- rechoir: 0.6.2
+ /shell-quote@1.7.3:
+ resolution: {integrity: sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==}
dev: true
- /shellwords/0.1.1:
+ /shellwords@0.1.1:
resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==}
dev: true
- optional: true
- /shiki-languages/0.2.7:
- resolution: {integrity: sha512-REmakh7pn2jCn9GDMRSK36oDgqhh+rSvJPo77sdWTOmk44C5b0XlYPwJZcFOMJWUZJE0c7FCbKclw4FLwUKLRw==}
+ /shiki@0.14.6:
+ resolution: {integrity: sha512-R4koBBlQP33cC8cpzX0hAoOURBHJILp4Aaduh2eYi+Vj8ZBqtK/5SWNEHBS3qwUMu8dqOtI/ftno3ESfNeVW9g==}
dependencies:
- vscode-textmate: 5.2.0
+ ansi-sequence-parser: 1.1.1
+ jsonc-parser: 3.2.0
+ vscode-oniguruma: 1.7.0
+ vscode-textmate: 8.0.0
dev: true
- /shiki-themes/0.2.7:
- resolution: {integrity: sha512-ZMmboDYw5+SEpugM8KGUq3tkZ0vXg+k60XX6NngDK7gc1Sv6YLUlanpvG3evm57uKJvfXsky/S5MzSOTtYKLjA==}
+ /side-channel@1.0.4:
+ resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
dependencies:
- json5: 2.1.3
- vscode-textmate: 5.2.0
+ call-bind: 1.0.5
+ get-intrinsic: 1.2.2
+ object-inspect: 1.13.1
dev: true
- /shiki/0.2.7:
- resolution: {integrity: sha512-bwVc7cdtYYHEO9O+XJ8aNOskKRfaQd5Y4ovLRfbQkmiLSUaR+bdlssbZUUhbQ0JAFMYcTcJ5tjG5KtnufttDHQ==}
+ /sign-addon@5.3.0:
+ resolution: {integrity: sha512-7nHlCzhQgVMLBNiXVEgbG/raq48awOW0lYMN5uo1BaB3mp0+k8M8pvDwbfTlr3apcxZJsk9HQsAW1POwoJugpQ==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
dependencies:
- onigasm: 2.2.5
- shiki-languages: 0.2.7
- shiki-themes: 0.2.7
- vscode-textmate: 5.2.0
+ common-tags: 1.8.2
+ core-js: 3.29.0
+ deepcopy: 2.1.0
+ es6-error: 4.1.1
+ es6-promisify: 7.0.0
+ jsonwebtoken: 9.0.0
+ mz: 2.7.0
+ request: 2.88.2
+ source-map-support: 0.5.21
+ stream-to-promise: 3.0.0
dev: true
- /side-channel/1.0.4:
- resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
- dependencies:
- call-bind: 1.0.2
- get-intrinsic: 1.1.1
- object-inspect: 1.11.0
+ /signal-exit@3.0.7:
+ resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
dev: true
- /signal-exit/3.0.3:
- resolution: {integrity: sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==}
- dev: true
+ /signal-exit@4.1.0:
+ resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+ engines: {node: '>=14'}
- /simple-swizzle/0.2.2:
- resolution: {integrity: sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=}
+ /simple-concat@1.0.1:
+ resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
+ requiresBuild: true
+ dev: false
+ optional: true
+
+ /simple-get@4.0.1:
+ resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
+ requiresBuild: true
+ dependencies:
+ decompress-response: 6.0.0
+ once: 1.4.0
+ simple-concat: 1.0.1
+ dev: false
+ optional: true
+
+ /simple-swizzle@0.2.2:
+ resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
dependencies:
is-arrayish: 0.3.2
dev: true
- /sirv-cli/1.0.14:
+ /sirv-cli@1.0.14:
resolution: {integrity: sha512-yyUTNr984ANKDloqepkYbBSqvx3buwYg2sQKPWjSU+IBia5loaoka2If8N9CMwt8AfP179cdEl7kYJ//iWJHjQ==}
engines: {node: '>= 10'}
- hasBin: true
dependencies:
console-clear: 1.1.1
get-port: 3.2.0
kleur: 3.0.3
local-access: 1.1.0
- sade: 1.7.4
+ sade: 1.8.1
semiver: 1.1.0
- sirv: 1.0.14
+ sirv: 1.0.19
tinydate: 1.3.0
dev: true
- /sirv/1.0.14:
- resolution: {integrity: sha512-czTFDFjK9lXj0u9mJ3OmJoXFztoilYS+NdRPcJoT182w44wSEkHSiO7A2517GLJ8wKM4GjCm2OXE66Dhngbzjg==}
+ /sirv@1.0.19:
+ resolution: {integrity: sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ==}
engines: {node: '>= 10'}
dependencies:
- '@polka/url': 1.0.0-next.17
- mime: 2.5.2
+ '@polka/url': 1.0.0-next.21
+ mrmime: 1.0.1
totalist: 1.1.0
dev: true
- /sisteransi/1.0.5:
+ /sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
dev: true
- /size-plugin/3.0.0_webpack@4.46.0:
+ /size-plugin@3.0.0(webpack@4.46.0):
resolution: {integrity: sha512-RPMSkgbvmS1e5XUwPNFZre7DLqcK9MhWARIm8UmGLgbUCAs173JLI6DPHco68wvo0cUdft8+GGRaJtNl5RWfew==}
peerDependencies:
webpack: '*'
dependencies:
- axios: 0.21.1
+ axios: 0.21.4
chalk: 2.4.2
- ci-env: 1.16.0
+ ci-env: 1.17.0
escape-string-regexp: 1.0.5
- glob: 7.1.7
- minimatch: 3.0.4
+ glob: 7.2.3
+ minimatch: 3.1.2
pretty-bytes: 5.6.0
util.promisify: 1.1.1
webpack: 4.46.0
@@ -19845,35 +17463,32 @@ packages:
- debug
dev: true
- /slash/2.0.0:
- resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==}
- engines: {node: '>=6'}
+ /slash@1.0.0:
+ resolution: {integrity: sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==}
+ engines: {node: '>=0.10.0'}
dev: true
- /slash/3.0.0:
+ /slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
dev: true
- /slice-ansi/2.1.0:
- resolution: {integrity: sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==}
- engines: {node: '>=6'}
- dependencies:
- ansi-styles: 3.2.1
- astral-regex: 1.0.0
- is-fullwidth-code-point: 2.0.0
+ /slash@4.0.0:
+ resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==}
+ engines: {node: '>=12'}
dev: true
- /slice-ansi/3.0.0:
- resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==}
- engines: {node: '>=8'}
- dependencies:
- ansi-styles: 4.3.0
- astral-regex: 2.0.0
- is-fullwidth-code-point: 3.0.0
+ /slash@5.0.1:
+ resolution: {integrity: sha512-ywNzUOiXwetmLvTUiCBZpLi+vxqN3i+zDqjs2HHfUSV3wN4UJxVVKWrS1JZDeiJIeBFNgB5pmioC2g0IUTL+rQ==}
+ engines: {node: '>=14.16'}
dev: true
- /slice-ansi/4.0.0:
+ /slash@5.1.0:
+ resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
+ engines: {node: '>=14.16'}
+ dev: true
+
+ /slice-ansi@4.0.0:
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
engines: {node: '>=10'}
dependencies:
@@ -19882,7 +17497,15 @@ packages:
is-fullwidth-code-point: 3.0.0
dev: true
- /snapdragon-node/2.1.1:
+ /slice-ansi@5.0.0:
+ resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
+ engines: {node: '>=12'}
+ dependencies:
+ ansi-styles: 6.2.1
+ is-fullwidth-code-point: 4.0.0
+ dev: true
+
+ /snapdragon-node@2.1.1:
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -19891,14 +17514,14 @@ packages:
snapdragon-util: 3.0.1
dev: true
- /snapdragon-util/3.0.1:
+ /snapdragon-util@3.0.1:
resolution: {integrity: sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==}
engines: {node: '>=0.10.0'}
dependencies:
kind-of: 3.2.2
dev: true
- /snapdragon/0.8.2:
+ /snapdragon@0.8.2:
resolution: {integrity: sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -19910,93 +17533,89 @@ packages:
source-map: 0.5.7
source-map-resolve: 0.5.3
use: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /sockjs-client/1.5.1:
- resolution: {integrity: sha512-VnVAb663fosipI/m6pqRXakEOw7nvd7TUgdr3PlR/8V2I95QIdwT8L4nMxhyU8SmDBHYXU1TOElaKOmKLfYzeQ==}
+ /sockjs@0.3.24:
+ resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==}
dependencies:
- debug: 3.2.7
- eventsource: 1.1.0
faye-websocket: 0.11.4
- inherits: 2.0.4
- json3: 3.3.3
- url-parse: 1.5.3
+ uuid: 8.3.2
+ websocket-driver: 0.7.4
dev: true
- /sockjs/0.3.21:
- resolution: {integrity: sha512-DhbPFGpxjc6Z3I+uX07Id5ZO2XwYsWOrYjaSeieES78cq+JaJvVe5q/m1uvjIQhXinhIeCFRH6JgXe+mvVMyXw==}
+ /sonic-boom@3.8.0:
+ resolution: {integrity: sha512-ybz6OYOUjoQQCQ/i4LU8kaToD8ACtYP+Cj5qd2AO36bwbdewxWJ3ArmJ2cr6AvxlL2o0PqnCcPGUgkILbfkaCA==}
dependencies:
- faye-websocket: 0.11.4
- uuid: 3.4.0
- websocket-driver: 0.7.4
+ atomic-sleep: 1.0.0
dev: true
- /source-list-map/2.0.1:
+ /source-list-map@2.0.1:
resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==}
dev: true
- /source-map-js/0.6.2:
- resolution: {integrity: sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug==}
+ /source-map-js@1.0.2:
+ resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
- dev: true
- /source-map-resolve/0.5.3:
+ /source-map-resolve@0.5.3:
resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==}
+ deprecated: See https://github.com/lydell/source-map-resolve#deprecated
dependencies:
atob: 2.1.2
- decode-uri-component: 0.2.0
+ decode-uri-component: 0.2.2
resolve-url: 0.2.1
source-map-url: 0.4.1
urix: 0.1.0
dev: true
- /source-map-resolve/0.6.0:
- resolution: {integrity: sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==}
- dependencies:
- atob: 2.1.2
- decode-uri-component: 0.2.0
- dev: true
-
- /source-map-support/0.5.19:
- resolution: {integrity: sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==}
+ /source-map-support@0.5.21:
+ resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
+ dev: true
- /source-map-url/0.4.1:
+ /source-map-url@0.4.1:
resolution: {integrity: sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==}
dev: true
- /source-map/0.5.7:
- resolution: {integrity: sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=}
+ /source-map@0.5.7:
+ resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==}
engines: {node: '>=0.10.0'}
dev: true
- /source-map/0.6.1:
+ /source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
+ dev: true
- /source-map/0.7.3:
- resolution: {integrity: sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==}
+ /source-map@0.7.4:
+ resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==}
engines: {node: '>= 8'}
dev: true
- /source-map/0.8.0-beta.0:
+ /source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
dependencies:
whatwg-url: 7.1.0
dev: true
- /sourcemap-codec/1.4.8:
+ /sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
dev: true
- /space-separated-tokens/1.1.5:
- resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
+ /spawn-sync@1.0.15:
+ resolution: {integrity: sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw==}
+ requiresBuild: true
+ dependencies:
+ concat-stream: 1.6.2
+ os-shim: 0.1.3
dev: true
- /spawn-wrap/2.0.0:
+ /spawn-wrap@2.0.0:
resolution: {integrity: sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==}
engines: {node: '>=8'}
dependencies:
@@ -20004,75 +17623,63 @@ packages:
is-windows: 1.0.2
make-dir: 3.1.0
rimraf: 3.0.2
- signal-exit: 3.0.3
+ signal-exit: 3.0.7
which: 2.0.2
dev: true
- /spdx-correct/3.1.1:
- resolution: {integrity: sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==}
- dependencies:
- spdx-expression-parse: 3.0.1
- spdx-license-ids: 3.0.10
- dev: true
-
- /spdx-exceptions/2.3.0:
- resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==}
- dev: true
-
- /spdx-expression-parse/3.0.1:
- resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==}
- dependencies:
- spdx-exceptions: 2.3.0
- spdx-license-ids: 3.0.10
- dev: true
-
- /spdx-license-ids/3.0.10:
- resolution: {integrity: sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==}
- dev: true
-
- /spdy-transport/3.0.0_supports-color@6.1.0:
+ /spdy-transport@3.0.0:
resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==}
dependencies:
- debug: 4.3.2_supports-color@6.1.0
+ debug: 4.3.4
detect-node: 2.1.0
hpack.js: 2.1.6
obuf: 1.1.2
- readable-stream: 3.6.0
+ readable-stream: 3.6.2
wbuf: 1.7.3
transitivePeerDependencies:
- supports-color
dev: true
- /spdy/4.0.2_supports-color@6.1.0:
+ /spdy@4.0.2:
resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==}
engines: {node: '>=6.0.0'}
dependencies:
- debug: 4.3.2_supports-color@6.1.0
+ debug: 4.3.4
handle-thing: 2.0.1
http-deceiver: 1.2.7
select-hose: 2.0.0
- spdy-transport: 3.0.0_supports-color@6.1.0
+ spdy-transport: 3.0.0
transitivePeerDependencies:
- supports-color
dev: true
- /split-string/3.1.0:
+ /split-string@3.1.0:
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
engines: {node: '>=0.10.0'}
dependencies:
extend-shallow: 3.0.2
dev: true
- /sprintf-js/1.0.3:
- resolution: {integrity: sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=}
+ /split2@4.2.0:
+ resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
+ engines: {node: '>= 10.x'}
+ dev: true
+
+ /split@1.0.1:
+ resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==}
+ dependencies:
+ through: 2.3.8
dev: true
- /sshpk/1.16.1:
- resolution: {integrity: sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==}
+ /sprintf-js@1.0.3:
+ resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
+ dev: true
+
+ /sshpk@1.17.0:
+ resolution: {integrity: sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==}
engines: {node: '>=0.10.0'}
- hasBin: true
dependencies:
- asn1: 0.2.4
+ asn1: 0.2.6
assert-plus: 1.0.0
bcrypt-pbkdf: 1.0.2
dashdash: 1.14.1
@@ -20083,258 +17690,177 @@ packages:
tweetnacl: 0.14.5
dev: true
- /ssri/6.0.2:
+ /ssri@6.0.2:
resolution: {integrity: sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==}
dependencies:
figgy-pudding: 3.5.2
dev: true
- /ssri/8.0.1:
+ /ssri@8.0.1:
resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==}
engines: {node: '>= 8'}
dependencies:
- minipass: 3.1.3
+ minipass: 3.3.6
dev: true
- /stable/0.1.8:
+ /stable@0.1.8:
resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==}
dev: true
- /stack-trace/0.0.10:
- resolution: {integrity: sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=}
+ /stack-trace@0.0.10:
+ resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
dev: true
- /stack-utils/2.0.3:
- resolution: {integrity: sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw==}
+ /stack-utils@2.0.6:
+ resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
dependencies:
escape-string-regexp: 2.0.0
dev: true
- /state-toggle/1.0.3:
- resolution: {integrity: sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==}
- dev: true
-
- /static-extend/0.1.2:
- resolution: {integrity: sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=}
+ /static-extend@0.1.2:
+ resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==}
engines: {node: '>=0.10.0'}
dependencies:
define-property: 0.2.5
object-copy: 0.1.0
dev: true
- /statuses/1.5.0:
- resolution: {integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=}
+ /statuses@1.5.0:
+ resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'}
dev: true
- /stealthy-require/1.1.1:
- resolution: {integrity: sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=}
- engines: {node: '>=0.10.0'}
- dev: true
-
- /store2/2.12.0:
- resolution: {integrity: sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw==}
- dev: true
-
- /storybook-addon-outline/1.4.1:
- resolution: {integrity: sha512-Qvv9X86CoONbi+kYY78zQcTGmCgFaewYnOVR6WL7aOFJoW7TrLiIc/O4hH5X9PsEPZFqjfXEPUPENWVUQim6yw==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- react-dom: ^16.8.0 || ^17.0.0
- dependencies:
- '@storybook/addons': 6.3.7
- '@storybook/api': 6.3.7
- '@storybook/components': 6.3.7
- '@storybook/core-events': 6.3.7
- ts-dedent: 2.2.0
- transitivePeerDependencies:
- - '@types/react'
+ /statuses@2.0.1:
+ resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
+ engines: {node: '>= 0.8'}
dev: true
- /storybook-dark-mode/1.0.8:
- resolution: {integrity: sha512-uY6yTSd1vYE0YwlON50u+iIoNF/fmMj59ww1cpd/naUcmOmCjwawViKFG5YjichwdR/yJ5ybWRUF0tnRQfaSiw==}
- peerDependencies:
- '@storybook/addons': ^6.0.0
- '@storybook/api': ^6.0.0
- '@storybook/components': ^6.0.0
- '@storybook/core-events': ^6.0.0
- '@storybook/theming': ^6.0.0
- dependencies:
- fast-deep-equal: 3.1.3
- memoizerific: 1.11.3
+ /stealthy-require@1.1.1:
+ resolution: {integrity: sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==}
+ engines: {node: '>=0.10.0'}
dev: true
- /stream-browserify/2.0.2:
+ /stream-browserify@2.0.2:
resolution: {integrity: sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==}
dependencies:
inherits: 2.0.4
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
dev: true
- /stream-each/1.2.3:
+ /stream-each@1.2.3:
resolution: {integrity: sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==}
dependencies:
end-of-stream: 1.4.4
stream-shift: 1.0.1
dev: true
- /stream-http/2.8.3:
+ /stream-http@2.8.3:
resolution: {integrity: sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==}
dependencies:
builtin-status-codes: 3.0.0
inherits: 2.0.4
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
to-arraybuffer: 1.0.1
xtend: 4.0.2
dev: true
- /stream-shift/1.0.1:
+ /stream-shift@1.0.1:
resolution: {integrity: sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==}
dev: true
- /string-argv/0.3.1:
- resolution: {integrity: sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==}
- engines: {node: '>=0.6.19'}
- dev: true
-
- /string-length/4.0.2:
- resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==}
- engines: {node: '>=10'}
+ /stream-to-array@2.3.0:
+ resolution: {integrity: sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==}
dependencies:
- char-regex: 1.0.2
- strip-ansi: 6.0.0
+ any-promise: 1.3.0
dev: true
- /string-width/1.0.2:
- resolution: {integrity: sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=}
- engines: {node: '>=0.10.0'}
- dependencies:
- code-point-at: 1.1.0
- is-fullwidth-code-point: 1.0.0
- strip-ansi: 3.0.1
- dev: true
-
- /string-width/3.1.0:
- resolution: {integrity: sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==}
- engines: {node: '>=6'}
+ /stream-to-promise@3.0.0:
+ resolution: {integrity: sha512-h+7wLeFiYegOdgTfTxjRsrT7/Op7grnKEIHWgaO1RTHwcwk7xRreMr3S8XpDfDMesSxzgM2V4CxNCFAGo6ssnA==}
+ engines: {node: '>= 10'}
dependencies:
- emoji-regex: 7.0.3
- is-fullwidth-code-point: 2.0.0
- strip-ansi: 5.2.0
+ any-promise: 1.3.0
+ end-of-stream: 1.4.4
+ stream-to-array: 2.3.0
dev: true
- /string-width/4.2.0:
- resolution: {integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==}
+ /string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
- strip-ansi: 6.0.0
- dev: true
+ strip-ansi: 6.0.1
- /string-width/4.2.2:
- resolution: {integrity: sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==}
- engines: {node: '>=8'}
+ /string-width@5.1.2:
+ resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
+ engines: {node: '>=12'}
dependencies:
- emoji-regex: 8.0.0
- is-fullwidth-code-point: 3.0.0
- strip-ansi: 6.0.0
- dev: true
+ eastasianwidth: 0.2.0
+ emoji-regex: 9.2.2
+ strip-ansi: 7.1.0
- /string.prototype.matchall/4.0.3:
- resolution: {integrity: sha512-OBxYDA2ifZQ2e13cP82dWFMaCV9CGF8GzmN4fljBVw5O5wep0lu4gacm1OL6MjROoUnB8VbkWRThqkV2YFLNxw==}
+ /string-width@7.0.0:
+ resolution: {integrity: sha512-GPQHj7row82Hjo9hKZieKcHIhaAIKOJvFSIZXuCU9OASVZrMNUaZuz++SPVrBjnLsnk4k+z9f2EIypgxf2vNFw==}
+ engines: {node: '>=18'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.0-next.2
- has-symbols: 1.0.1
- internal-slot: 1.0.2
- regexp.prototype.flags: 1.3.1
- side-channel: 1.0.4
+ emoji-regex: 10.3.0
+ get-east-asian-width: 1.2.0
+ strip-ansi: 7.1.0
dev: true
- /string.prototype.matchall/4.0.5:
- resolution: {integrity: sha512-Z5ZaXO0svs0M2xd/6By3qpeKpLKd9mO4v4q3oMEQrk8Ck4xOD5d5XeBOOjGrmVZZ/AHB1S0CgG4N5r1G9N3E2Q==}
+ /string.prototype.matchall@4.0.10:
+ resolution: {integrity: sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
- get-intrinsic: 1.1.1
- has-symbols: 1.0.2
- internal-slot: 1.0.3
- regexp.prototype.flags: 1.3.1
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ get-intrinsic: 1.2.2
+ has-symbols: 1.0.3
+ internal-slot: 1.0.6
+ regexp.prototype.flags: 1.5.1
+ set-function-name: 2.0.1
side-channel: 1.0.4
dev: true
- /string.prototype.padend/3.1.2:
- resolution: {integrity: sha512-/AQFLdYvePENU3W5rgurfWSMU6n+Ww8n/3cUt7E+vPBB/D7YDG8x+qjoFs4M/alR2bW7Qg6xMjVwWUOvuQ0XpQ==}
+ /string.prototype.trim@1.2.8:
+ resolution: {integrity: sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==}
engines: {node: '>= 0.4'}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
dev: true
- /string.prototype.padstart/3.1.2:
- resolution: {integrity: sha512-HDpngIP3pd0DeazrfqzuBrQZa+D2arKWquEHfGt5LzVjd+roLC3cjqVI0X8foaZz5rrrhcu8oJAQamW8on9dqw==}
- engines: {node: '>= 0.4'}
+ /string.prototype.trimend@1.0.7:
+ resolution: {integrity: sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
dev: true
- /string.prototype.trim/1.2.4:
- resolution: {integrity: sha512-hWCk/iqf7lp0/AgTF7/ddO1IWtSNPASjlzCicV5irAVdE1grjsneK26YG6xACMBEdCvO8fUST0UzDMh/2Qy+9Q==}
- engines: {node: '>= 0.4'}
+ /string.prototype.trimstart@1.0.7:
+ resolution: {integrity: sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- es-abstract: 1.18.5
+ call-bind: 1.0.5
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
dev: true
- /string.prototype.trimend/1.0.3:
- resolution: {integrity: sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw==}
- dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- dev: true
-
- /string.prototype.trimend/1.0.4:
- resolution: {integrity: sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==}
- dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- dev: true
-
- /string.prototype.trimstart/1.0.3:
- resolution: {integrity: sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg==}
- dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- dev: true
-
- /string.prototype.trimstart/1.0.4:
- resolution: {integrity: sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==}
- dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
- dev: true
-
- /string_decoder/1.1.1:
+ /string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
dependencies:
safe-buffer: 5.1.2
dev: true
- /string_decoder/1.3.0:
+ /string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+ requiresBuild: true
dependencies:
safe-buffer: 5.2.1
- dev: true
- /stringify-object/3.3.0:
+ /stringify-object@3.3.0:
resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==}
engines: {node: '>=4'}
dependencies:
@@ -20343,165 +17869,176 @@ packages:
is-regexp: 1.0.0
dev: true
- /strip-ansi/0.1.1:
- resolution: {integrity: sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=}
+ /strip-ansi@0.1.1:
+ resolution: {integrity: sha512-behete+3uqxecWlDAm5lmskaSaISA+ThQ4oNNBDTBJt0x2ppR6IPqfZNuj6BLaLJ/Sji4TPZlcRyOis8wXQTLg==}
engines: {node: '>=0.8.0'}
hasBin: true
dev: true
- /strip-ansi/3.0.1:
- resolution: {integrity: sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=}
+ /strip-ansi@3.0.1:
+ resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==}
engines: {node: '>=0.10.0'}
dependencies:
ansi-regex: 2.1.1
dev: true
- /strip-ansi/5.2.0:
- resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==}
- engines: {node: '>=6'}
+ /strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+ dependencies:
+ ansi-regex: 5.0.1
+
+ /strip-ansi@7.1.0:
+ resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
+ engines: {node: '>=12'}
+ dependencies:
+ ansi-regex: 6.0.1
+
+ /strip-bom-buf@2.0.0:
+ resolution: {integrity: sha512-gLFNHucd6gzb8jMsl5QmZ3QgnUJmp7qn4uUSHNwEXumAp7YizoGYw19ZUVfuq4aBOQUtyn2k8X/CwzWB73W2lQ==}
+ engines: {node: '>=8'}
dependencies:
- ansi-regex: 4.1.0
+ is-utf8: 0.2.1
dev: true
- /strip-ansi/6.0.0:
- resolution: {integrity: sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==}
+ /strip-bom-stream@4.0.0:
+ resolution: {integrity: sha512-0ApK3iAkHv6WbgLICw/J4nhwHeDZsBxIIsOD+gHgZICL6SeJ0S9f/WZqemka9cjkTyMN5geId6e8U5WGFAn3cQ==}
engines: {node: '>=8'}
dependencies:
- ansi-regex: 5.0.0
+ first-chunk-stream: 3.0.0
+ strip-bom-buf: 2.0.0
dev: true
- /strip-bom/3.0.0:
- resolution: {integrity: sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=}
+ /strip-bom@3.0.0:
+ resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
dev: true
- /strip-bom/4.0.0:
+ /strip-bom@4.0.0:
resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==}
engines: {node: '>=8'}
dev: true
- /strip-comments/2.0.1:
- resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==}
- engines: {node: '>=10'}
+ /strip-bom@5.0.0:
+ resolution: {integrity: sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A==}
+ engines: {node: '>=12'}
dev: true
- /strip-eof/1.0.0:
- resolution: {integrity: sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=}
- engines: {node: '>=0.10.0'}
+ /strip-comments@2.0.1:
+ resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==}
+ engines: {node: '>=10'}
dev: true
- /strip-final-newline/2.0.0:
+ /strip-final-newline@2.0.0:
resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
engines: {node: '>=6'}
dev: true
- /strip-json-comments/2.0.1:
- resolution: {integrity: sha1-PFMZQukIwml8DsNEhYwobHygpgo=}
- engines: {node: '>=0.10.0'}
+ /strip-final-newline@3.0.0:
+ resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
+ engines: {node: '>=12'}
dev: true
- /strip-json-comments/3.1.1:
+ /strip-json-comments@2.0.1:
+ resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
+ engines: {node: '>=0.10.0'}
+ requiresBuild: true
+
+ /strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
dev: true
- /style-loader/1.3.0_webpack@4.46.0:
- resolution: {integrity: sha512-V7TCORko8rs9rIqkSrlMfkqA63DfoGBBJmK1kKGCcSi+BWb4cqz0SRsnp4l6rU5iwOEd0/2ePv68SV22VXon4Q==}
- engines: {node: '>= 8.9.0'}
- peerDependencies:
- webpack: ^4.0.0 || ^5.0.0
- dependencies:
- loader-utils: 2.0.0
- schema-utils: 2.7.1
- webpack: 4.46.0
+ /strip-json-comments@5.0.0:
+ resolution: {integrity: sha512-V1LGY4UUo0jgwC+ELQ2BNWfPa17TIuwBLg+j1AA/9RPzKINl1lhxVEu2r+ZTTO8aetIsUzE5Qj6LMSBkoGYKKw==}
+ engines: {node: '>=14.16'}
dev: true
- /style-loader/2.0.0_webpack@4.46.0:
+ /style-loader@2.0.0(webpack@4.46.0):
resolution: {integrity: sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==}
engines: {node: '>= 10.13.0'}
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
dependencies:
- loader-utils: 2.0.0
+ loader-utils: 2.0.3
schema-utils: 3.1.1
webpack: 4.46.0
dev: true
- /style-to-object/0.3.0:
- resolution: {integrity: sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==}
- dependencies:
- inline-style-parser: 0.1.1
- dev: true
-
- /stylehacks/4.0.3:
+ /stylehacks@4.0.3:
resolution: {integrity: sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g==}
engines: {node: '>=6.9.0'}
dependencies:
- browserslist: 4.16.8
- postcss: 7.0.36
+ browserslist: 4.22.2
+ postcss: 7.0.39
postcss-selector-parser: 3.1.2
dev: true
- /stylehacks/5.0.1_postcss@8.3.6:
- resolution: {integrity: sha512-Es0rVnHIqbWzveU1b24kbw92HsebBepxfcqe5iix7t9j0PQqhs0IxXVXv0pY2Bxa08CgMkzD6OWql7kbGOuEdA==}
+ /stylehacks@5.1.0(postcss@8.4.32):
+ resolution: {integrity: sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q==}
engines: {node: ^10 || ^12 || >=14.0}
peerDependencies:
postcss: ^8.2.15
dependencies:
- browserslist: 4.16.8
- postcss: 8.3.6
- postcss-selector-parser: 6.0.6
+ browserslist: 4.22.2
+ postcss: 8.4.32
+ postcss-selector-parser: 6.0.12
dev: true
- /stylis/3.5.4:
+ /stylis@3.5.4:
resolution: {integrity: sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q==}
dev: true
- /supertap/2.0.0:
- resolution: {integrity: sha512-jRzcXlCeDYvKoZGA5oRhYyR3jUIYu0enkSxtmAgHRlD7HwrovTpH4bDSi0py9FtuA8si9cW/fKommJHuaoDHJA==}
- engines: {node: '>=10'}
+ /sucrase@3.32.0:
+ resolution: {integrity: sha512-ydQOU34rpSyj2TGyz4D2p8rbktIOZ8QY9s+DGLvFU1i5pWJE8vkpruCjGCMHsdXwnD7JDcS+noSwM/a7zyNFDQ==}
+ engines: {node: '>=8'}
+ hasBin: true
dependencies:
- arrify: 2.0.1
- indent-string: 4.0.0
+ '@jridgewell/gen-mapping': 0.3.3
+ commander: 4.1.1
+ glob: 7.1.6
+ lines-and-columns: 1.2.4
+ mz: 2.7.0
+ pirates: 4.0.5
+ ts-interface-checker: 0.1.13
+
+ /supertap@3.0.1:
+ resolution: {integrity: sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dependencies:
+ indent-string: 5.0.0
js-yaml: 3.14.1
serialize-error: 7.0.1
- strip-ansi: 6.0.0
+ strip-ansi: 7.1.0
dev: true
- /supports-color/5.5.0:
+ /supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
dependencies:
has-flag: 3.0.0
- dev: true
- /supports-color/6.1.0:
- resolution: {integrity: sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==}
- engines: {node: '>=6'}
- dependencies:
- has-flag: 3.0.0
- dev: true
-
- /supports-color/7.2.0:
+ /supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
dependencies:
has-flag: 4.0.0
dev: true
- /supports-hyperlinks/2.2.0:
- resolution: {integrity: sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ==}
- engines: {node: '>=8'}
+ /supports-color@8.1.1:
+ resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
+ engines: {node: '>=10'}
dependencies:
has-flag: 4.0.0
- supports-color: 7.2.0
dev: true
- /svgo/1.3.2:
+ /supports-preserve-symlinks-flag@1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+
+ /svgo@1.3.2:
resolution: {integrity: sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==}
engines: {node: '>=4.0.0'}
- deprecated: This SVGO version is no longer supported. Upgrade to v2.x.x.
- hasBin: true
dependencies:
chalk: 2.4.2
coa: 2.0.2
@@ -20510,73 +18047,127 @@ packages:
css-tree: 1.0.0-alpha.37
csso: 4.2.0
js-yaml: 3.14.1
- mkdirp: 0.5.5
- object.values: 1.1.4
+ mkdirp: 0.5.6
+ object.values: 1.1.7
sax: 1.2.4
stable: 0.1.8
unquote: 1.1.1
util.promisify: 1.0.1
dev: true
- /svgo/2.4.0:
- resolution: {integrity: sha512-W25S1UUm9Lm9VnE0TvCzL7aso/NCzDEaXLaElCUO/KaVitw0+IBicSVfM1L1c0YHK5TOFh73yQ2naCpVHEQ/OQ==}
+ /svgo@2.8.0:
+ resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==}
engines: {node: '>=10.13.0'}
- hasBin: true
dependencies:
- '@trysound/sax': 0.1.1
- colorette: 1.3.0
+ '@trysound/sax': 0.2.0
commander: 7.2.0
- css-select: 4.1.3
+ css-select: 4.3.0
css-tree: 1.1.3
csso: 4.2.0
+ picocolors: 1.0.0
stable: 0.1.8
dev: true
- /symbol-tree/3.2.4:
- resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
- dev: true
-
- /symbol.prototype.description/1.0.5:
- resolution: {integrity: sha512-x738iXRYsrAt9WBhRCVG5BtIC3B7CUkFwbHW2zOvGtwM33s7JjrCDyq8V0zgMYVb5ymsL8+qkzzpANH63CPQaQ==}
- engines: {node: '>= 0.11.15'}
+ /swr@2.0.3(react@18.2.0):
+ resolution: {integrity: sha512-sGvQDok/AHEWTPfhUWXEHBVEXmgGnuahyhmRQbjl9XBYxT/MSlAzvXEKQpyM++bMPaI52vcWS2HiKNaW7+9OFw==}
+ engines: {pnpm: '7'}
+ peerDependencies:
+ react: ^16.11.0 || ^17.0.0 || ^18.0.0
dependencies:
- call-bind: 1.0.2
- get-symbol-description: 1.0.0
- has-symbols: 1.0.2
- object.getownpropertydescriptors: 2.1.2
- dev: true
+ react: 18.2.0
+ use-sync-external-store: 1.2.0(react@18.2.0)
- /table/5.4.6:
- resolution: {integrity: sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==}
- engines: {node: '>=6.0.0'}
+ /swr@2.2.2(react@18.2.0):
+ resolution: {integrity: sha512-CbR41AoMD4TQBQw9ic3GTXspgfM9Y8Mdhb5Ob4uIKXhWqnRLItwA5fpGvB7SmSw3+zEjb0PdhiEumtUvYoQ+bQ==}
+ peerDependencies:
+ react: ^16.11.0 || ^17.0.0 || ^18.0.0
dependencies:
- ajv: 6.12.6
- lodash: 4.17.21
- slice-ansi: 2.1.0
- string-width: 3.1.0
+ client-only: 0.0.1
+ react: 18.2.0
+ use-sync-external-store: 1.2.0(react@18.2.0)
+ dev: false
+
+ /symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
dev: true
- /table/6.0.7:
- resolution: {integrity: sha512-rxZevLGTUzWna/qBLObOe16kB2RTnnbhciwgPbMMlazz1yZGVEgnZK762xyVdVznhqxrfCeBMmMkgOOaPwjH7g==}
+ /table@6.8.0:
+ resolution: {integrity: sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==}
engines: {node: '>=10.0.0'}
dependencies:
- ajv: 7.0.3
- lodash: 4.17.20
+ ajv: 8.11.0
+ lodash.truncate: 4.4.2
slice-ansi: 4.0.0
- string-width: 4.2.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
dev: true
- /tapable/1.1.3:
+ /tailwindcss@3.3.2:
+ resolution: {integrity: sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ arg: 5.0.2
+ chokidar: 3.5.3
+ didyoumean: 1.2.2
+ dlv: 1.1.3
+ fast-glob: 3.3.1
+ glob-parent: 6.0.2
+ is-glob: 4.0.3
+ jiti: 1.18.2
+ lilconfig: 2.1.0
+ micromatch: 4.0.5
+ normalize-path: 3.0.0
+ object-hash: 3.0.0
+ picocolors: 1.0.0
+ postcss: 8.4.23
+ postcss-import: 15.1.0(postcss@8.4.23)
+ postcss-js: 4.0.1(postcss@8.4.23)
+ postcss-load-config: 4.0.1(postcss@8.4.23)
+ postcss-nested: 6.0.1(postcss@8.4.23)
+ postcss-selector-parser: 6.0.12
+ postcss-value-parser: 4.2.0
+ resolve: 1.22.2
+ sucrase: 3.32.0
+ transitivePeerDependencies:
+ - ts-node
+
+ /tapable@1.1.3:
resolution: {integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==}
engines: {node: '>=6'}
dev: true
- /tapable/2.2.0:
- resolution: {integrity: sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw==}
+ /tapable@2.2.1:
+ resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'}
dev: true
- /tar/4.4.19:
+ /tar-fs@2.1.1:
+ resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==}
+ requiresBuild: true
+ dependencies:
+ chownr: 1.1.4
+ mkdirp-classic: 0.5.3
+ pump: 3.0.0
+ tar-stream: 2.2.0
+ dev: false
+ optional: true
+
+ /tar-stream@2.2.0:
+ resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
+ engines: {node: '>=6'}
+ requiresBuild: true
+ dependencies:
+ bl: 4.1.0
+ end-of-stream: 1.4.4
+ fs-constants: 1.0.0
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ dev: false
+ optional: true
+
+ /tar@4.4.19:
resolution: {integrity: sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==}
engines: {node: '>=4.5'}
dependencies:
@@ -20584,42 +18175,46 @@ packages:
fs-minipass: 1.2.7
minipass: 2.9.0
minizlib: 1.3.3
- mkdirp: 0.5.5
+ mkdirp: 0.5.6
safe-buffer: 5.2.1
yallist: 3.1.1
dev: true
- /tar/6.1.10:
- resolution: {integrity: sha512-kvvfiVvjGMxeUNB6MyYv5z7vhfFRwbwCXJAeL0/lnbrttBVqcMOnpHUf0X42LrPMR8mMpgapkJMchFH4FSHzNA==}
+ /tar@6.1.11:
+ resolution: {integrity: sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==}
engines: {node: '>= 10'}
dependencies:
chownr: 2.0.0
fs-minipass: 2.1.0
- minipass: 3.1.3
+ minipass: 3.3.6
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
dev: true
- /telejson/5.3.3:
- resolution: {integrity: sha512-PjqkJZpzEggA9TBpVtJi1LVptP7tYtXB6rEubwlHap76AMjzvOdKX41CxyaW7ahhzDU1aftXnMCx5kAPDZTQBA==}
+ /tar@6.2.0:
+ resolution: {integrity: sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==}
+ engines: {node: '>=10'}
dependencies:
- '@types/is-function': 1.0.0
- global: 4.4.0
- is-function: 1.0.2
- is-regex: 1.1.4
- is-symbol: 1.0.4
- isobject: 4.0.0
- lodash: 4.17.21
- memoizerific: 1.11.3
+ chownr: 2.0.0
+ fs-minipass: 2.1.0
+ minipass: 5.0.0
+ minizlib: 2.1.2
+ mkdirp: 1.0.4
+ yallist: 4.0.0
dev: true
- /temp-dir/2.0.0:
+ /temp-dir@2.0.0:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'}
dev: true
- /tempy/0.6.0:
+ /temp-dir@3.0.0:
+ resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==}
+ engines: {node: '>=14.16'}
+ dev: true
+
+ /tempy@0.6.0:
resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==}
engines: {node: '>=10'}
dependencies:
@@ -20629,20 +18224,25 @@ packages:
unique-string: 2.0.0
dev: true
- /term-size/2.2.1:
- resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
- engines: {node: '>=8'}
- dev: true
-
- /terminal-link/2.1.1:
- resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==}
- engines: {node: '>=8'}
+ /terser-webpack-plugin@1.4.5(webpack@4.46.0):
+ resolution: {integrity: sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==}
+ engines: {node: '>= 6.9.0'}
+ peerDependencies:
+ webpack: ^4.0.0
dependencies:
- ansi-escapes: 4.3.2
- supports-hyperlinks: 2.2.0
+ cacache: 12.0.4
+ find-cache-dir: 2.1.0
+ is-wsl: 1.1.0
+ schema-utils: 1.0.0
+ serialize-javascript: 4.0.0
+ source-map: 0.6.1
+ terser: 4.8.1
+ webpack: 4.46.0
+ webpack-sources: 1.4.3
+ worker-farm: 1.7.0
dev: true
- /terser-webpack-plugin/1.4.5_webpack@4.46.0:
+ /terser-webpack-plugin@1.4.5(webpack@4.47.0):
resolution: {integrity: sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==}
engines: {node: '>= 6.9.0'}
peerDependencies:
@@ -20654,179 +18254,184 @@ packages:
schema-utils: 1.0.0
serialize-javascript: 4.0.0
source-map: 0.6.1
- terser: 4.8.0
- webpack: 4.46.0
+ terser: 4.8.1
+ webpack: 4.47.0
webpack-sources: 1.4.3
worker-farm: 1.7.0
dev: true
- /terser-webpack-plugin/4.2.3_webpack@4.46.0:
+ /terser-webpack-plugin@4.2.3(webpack@4.46.0):
resolution: {integrity: sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==}
engines: {node: '>= 10.13.0'}
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
dependencies:
- cacache: 15.2.0
- find-cache-dir: 3.3.1
+ cacache: 15.3.0
+ find-cache-dir: 3.3.2
jest-worker: 26.6.2
p-limit: 3.1.0
schema-utils: 3.1.1
serialize-javascript: 5.0.1
source-map: 0.6.1
- terser: 5.7.1
+ terser: 5.15.1
webpack: 4.46.0
webpack-sources: 1.4.3
+ transitivePeerDependencies:
+ - bluebird
dev: true
- /terser/4.8.0:
- resolution: {integrity: sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==}
+ /terser@4.8.1:
+ resolution: {integrity: sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==}
engines: {node: '>=6.0.0'}
hasBin: true
dependencies:
+ acorn: 8.11.2
commander: 2.20.3
source-map: 0.6.1
- source-map-support: 0.5.19
+ source-map-support: 0.5.21
dev: true
- /terser/5.4.0:
- resolution: {integrity: sha512-3dZunFLbCJis9TAF2VnX+VrQLctRUmt1p3W2kCsJuZE4ZgWqh//+1MZ62EanewrqKoUf4zIaDGZAvml4UDc0OQ==}
+ /terser@5.15.1:
+ resolution: {integrity: sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw==}
engines: {node: '>=10'}
- hasBin: true
dependencies:
+ '@jridgewell/source-map': 0.3.2
+ acorn: 8.11.2
commander: 2.20.3
- source-map: 0.7.3
- source-map-support: 0.5.19
+ source-map-support: 0.5.21
dev: true
- /terser/5.7.1:
- resolution: {integrity: sha512-b3e+d5JbHAe/JSjwsC3Zn55wsBIM7AsHLjKxT31kGCldgbpFePaFo+PiddtO6uwRZWRw7sPXmAN8dTW61xmnSg==}
+ /terser@5.26.0:
+ resolution: {integrity: sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==}
engines: {node: '>=10'}
hasBin: true
dependencies:
+ '@jridgewell/source-map': 0.3.5
+ acorn: 8.11.2
commander: 2.20.3
- source-map: 0.7.3
- source-map-support: 0.5.19
+ source-map-support: 0.5.21
dev: true
- /test-exclude/6.0.0:
+ /test-exclude@6.0.0:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
dependencies:
'@istanbuljs/schema': 0.1.3
- glob: 7.1.7
- minimatch: 3.0.4
+ glob: 7.2.3
+ minimatch: 3.1.2
dev: true
- /text-table/0.2.0:
- resolution: {integrity: sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=}
+ /text-table@0.2.0:
+ resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
- /throat/5.0.0:
- resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==}
+ /thenby@1.3.4:
+ resolution: {integrity: sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==}
dev: true
- /throttle-debounce/3.0.1:
- resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==}
- engines: {node: '>=10'}
- dev: true
+ /thenify-all@1.6.0:
+ resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+ engines: {node: '>=0.8'}
+ dependencies:
+ thenify: 3.3.1
- /through/2.3.8:
- resolution: {integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=}
+ /thenify@3.3.1:
+ resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+ dependencies:
+ any-promise: 1.3.0
+
+ /thread-stream@2.4.1:
+ resolution: {integrity: sha512-d/Ex2iWd1whipbT681JmTINKw0ZwOUBZm7+Gjs64DHuX34mmw8vJL2bFAaNacaW72zYiTJxSHi5abUuOi5nsfg==}
+ dependencies:
+ real-require: 0.2.0
dev: true
- /through2/2.0.5:
+ /through2@2.0.5:
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
dependencies:
- readable-stream: 2.3.7
+ readable-stream: 2.3.8
xtend: 4.0.2
dev: true
- /thunky/1.1.0:
+ /through@2.3.8:
+ resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
+ dev: true
+
+ /thunky@1.1.0:
resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==}
dev: true
- /time-zone/1.0.0:
- resolution: {integrity: sha1-mcW/VZWJZq9tBtg73zgA3IL67F0=}
+ /time-zone@1.0.0:
+ resolution: {integrity: sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==}
engines: {node: '>=4'}
dev: true
- /timers-browserify/2.0.12:
+ /timers-browserify@2.0.12:
resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==}
engines: {node: '>=0.6.0'}
dependencies:
setimmediate: 1.0.5
dev: true
- /timsort/0.3.0:
- resolution: {integrity: sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=}
+ /timsort@0.3.0:
+ resolution: {integrity: sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==}
dev: true
- /tiny-invariant/1.1.0:
- resolution: {integrity: sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==}
+ /tiny-invariant@1.3.1:
+ resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
dev: false
- /tiny-warning/1.0.3:
+ /tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
dev: false
- /tinydate/1.3.0:
+ /tinydate@1.3.0:
resolution: {integrity: sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==}
engines: {node: '>=4'}
dev: true
- /tmp/0.0.33:
- resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
- engines: {node: '>=0.6.0'}
+ /tmp@0.2.1:
+ resolution: {integrity: sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==}
+ engines: {node: '>=8.17.0'}
dependencies:
- os-tmpdir: 1.0.2
- dev: true
-
- /tmpl/1.0.4:
- resolution: {integrity: sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=}
- dev: true
-
- /to-arraybuffer/1.0.1:
- resolution: {integrity: sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=}
+ rimraf: 3.0.2
dev: true
- /to-fast-properties/1.0.3:
- resolution: {integrity: sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=}
- engines: {node: '>=0.10.0'}
+ /to-arraybuffer@1.0.1:
+ resolution: {integrity: sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==}
dev: true
- /to-fast-properties/2.0.0:
- resolution: {integrity: sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=}
+ /to-fast-properties@2.0.0:
+ resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'}
- dev: true
- /to-object-path/0.3.0:
- resolution: {integrity: sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=}
+ /to-object-path@0.3.0:
+ resolution: {integrity: sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==}
engines: {node: '>=0.10.0'}
dependencies:
kind-of: 3.2.2
dev: true
- /to-readable-stream/1.0.0:
+ /to-readable-stream@1.0.0:
resolution: {integrity: sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==}
engines: {node: '>=6'}
dev: true
- /to-regex-range/2.1.1:
- resolution: {integrity: sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=}
+ /to-regex-range@2.1.1:
+ resolution: {integrity: sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==}
engines: {node: '>=0.10.0'}
dependencies:
is-number: 3.0.0
repeat-string: 1.6.1
dev: true
- /to-regex-range/5.0.1:
+ /to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
dependencies:
is-number: 7.0.0
- dev: true
- /to-regex/3.0.2:
+ /to-regex@3.0.2:
resolution: {integrity: sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -20836,93 +18441,98 @@ packages:
safe-regex: 1.1.0
dev: true
- /toggle-selection/1.0.6:
- resolution: {integrity: sha1-bkWxJj8gF/oKzH2J14sVuL932jI=}
+ /toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
dev: true
- /toidentifier/1.0.0:
- resolution: {integrity: sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==}
- engines: {node: '>=0.6'}
+ /toposort@1.0.7:
+ resolution: {integrity: sha512-FclLrw8b9bMWf4QlCJuHBEVhSRsqDj6u3nIjAzPeJvgl//1hBlffdlk0MALceL14+koWEdU4ofRAXofbODxQzg==}
dev: true
- /toposort/1.0.7:
- resolution: {integrity: sha1-LmhELZ9k7HILjMieZEOsbKqVACk=}
+ /toposort@2.0.2:
+ resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==}
+ dev: false
+
+ /tosource@1.0.0:
+ resolution: {integrity: sha512-N6g8eQ1eerw6Y1pBhdgkubWIiPFwXa2POSUrlL8jth5CyyEWNWzoGKRkO3CaO7Jx27hlJP54muB3btIAbx4MPg==}
+ engines: {node: '>=0.4.0'}
dev: true
- /totalist/1.1.0:
+ /totalist@1.1.0:
resolution: {integrity: sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==}
engines: {node: '>=6'}
dev: true
- /tough-cookie/2.5.0:
+ /tough-cookie@2.5.0:
resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==}
engines: {node: '>=0.8'}
dependencies:
- psl: 1.8.0
+ psl: 1.9.0
punycode: 2.1.1
dev: true
- /tough-cookie/4.0.0:
- resolution: {integrity: sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==}
- engines: {node: '>=6'}
- dependencies:
- psl: 1.8.0
- punycode: 2.1.1
- universalify: 0.1.2
+ /tr46@0.0.3:
+ resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: true
- /tr46/1.0.1:
- resolution: {integrity: sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=}
+ /tr46@1.0.1:
+ resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
dependencies:
punycode: 2.1.1
dev: true
- /tr46/2.1.0:
- resolution: {integrity: sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==}
- engines: {node: '>=8'}
+ /ts-api-utils@1.0.3(typescript@5.3.3):
+ resolution: {integrity: sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==}
+ engines: {node: '>=16.13.0'}
+ peerDependencies:
+ typescript: '>=4.2.0'
dependencies:
- punycode: 2.1.1
- dev: true
-
- /trim-off-newlines/1.0.1:
- resolution: {integrity: sha1-n5up2e+odkw4dpi8v+sshI8RrbM=}
- engines: {node: '>=0.10.0'}
- dev: true
-
- /trim-trailing-lines/1.1.4:
- resolution: {integrity: sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ==}
+ typescript: 5.3.3
dev: true
- /trim/0.0.1:
- resolution: {integrity: sha1-WFhUf2spB1fulczMZm+1AITEYN0=}
- dev: true
+ /ts-interface-checker@0.1.13:
+ resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
- /trough/1.0.5:
- resolution: {integrity: sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==}
- dev: true
-
- /ts-dedent/2.2.0:
- resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
- engines: {node: '>=6.10'}
- dev: true
-
- /ts-essentials/2.0.12:
- resolution: {integrity: sha512-3IVX4nI6B5cc31/GFFE+i8ey/N2eA0CZDbo6n0yrz0zDX8ZJ8djmU1p+XRz7G3is0F3bB3pu2pAroFdAWQKU3w==}
+ /ts-invariant@0.10.3:
+ resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ tslib: 2.6.2
dev: true
- /ts-pnp/1.2.0_typescript@3.9.10:
- resolution: {integrity: sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==}
- engines: {node: '>=6'}
+ /ts-node@10.9.1(@types/node@20.11.13)(typescript@5.3.3):
+ resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==}
+ hasBin: true
peerDependencies:
- typescript: '*'
+ '@swc/core': '>=1.2.50'
+ '@swc/wasm': '>=1.2.50'
+ '@types/node': '*'
+ typescript: '>=2.7'
peerDependenciesMeta:
- typescript:
+ '@swc/core':
+ optional: true
+ '@swc/wasm':
optional: true
dependencies:
- typescript: 3.9.10
- dev: true
-
- /ts-pnp/1.2.0_typescript@4.3.5:
+ '@cspotcode/source-map-support': 0.8.1
+ '@tsconfig/node10': 1.0.9
+ '@tsconfig/node12': 1.0.11
+ '@tsconfig/node14': 1.0.3
+ '@tsconfig/node16': 1.0.3
+ '@types/node': 20.11.13
+ acorn: 8.8.1
+ acorn-walk: 8.2.0
+ arg: 4.1.3
+ create-require: 1.1.1
+ diff: 4.0.2
+ make-error: 1.3.6
+ typescript: 5.3.3
+ v8-compile-cache-lib: 3.0.1
+ yn: 3.1.1
+ dev: true
+
+ /ts-pnp@1.2.0(typescript@4.6.4):
resolution: {integrity: sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==}
engines: {node: '>=6'}
peerDependencies:
@@ -20931,270 +18541,238 @@ packages:
typescript:
optional: true
dependencies:
- typescript: 4.3.5
+ typescript: 4.6.4
dev: true
- /tsconfig-paths/3.9.0:
- resolution: {integrity: sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==}
+ /tsconfig-paths@3.15.0:
+ resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
dependencies:
'@types/json5': 0.0.29
- json5: 1.0.1
- minimist: 1.2.5
+ json5: 1.0.2
+ minimist: 1.2.8
strip-bom: 3.0.0
dev: true
- /tslib/1.14.1:
+ /tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
dev: true
- /tslib/2.1.0:
- resolution: {integrity: sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==}
- dev: false
-
- /tslib/2.2.0:
- resolution: {integrity: sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==}
- dev: false
+ /tslib@2.6.2:
+ resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
- /tslib/2.3.1:
- resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==}
-
- /tsutils/3.19.1_typescript@3.9.10:
- resolution: {integrity: sha512-GEdoBf5XI324lu7ycad7s6laADfnAqCw6wLGI+knxvw9vsIYBaJfYdmeCEG3FMMUiSm3OGgNb+m6utsWf5h9Vw==}
- engines: {node: '>= 6'}
- peerDependencies:
- typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta'
- dependencies:
- tslib: 1.14.1
- typescript: 3.9.10
- dev: true
-
- /tsutils/3.19.1_typescript@4.1.3:
- resolution: {integrity: sha512-GEdoBf5XI324lu7ycad7s6laADfnAqCw6wLGI+knxvw9vsIYBaJfYdmeCEG3FMMUiSm3OGgNb+m6utsWf5h9Vw==}
+ /tsutils@3.21.0(typescript@5.3.3):
+ resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
engines: {node: '>= 6'}
peerDependencies:
typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta'
dependencies:
tslib: 1.14.1
- typescript: 4.1.3
+ typescript: 5.3.3
dev: true
- /tty-browserify/0.0.0:
- resolution: {integrity: sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=}
+ /tty-browserify@0.0.0:
+ resolution: {integrity: sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==}
dev: true
- /tunnel-agent/0.6.0:
- resolution: {integrity: sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=}
+ /tunnel-agent@0.6.0:
+ resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
+ requiresBuild: true
dependencies:
safe-buffer: 5.2.1
- dev: true
- /tweetnacl/0.14.5:
- resolution: {integrity: sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=}
+ /tweetnacl@0.14.5:
+ resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
dev: true
- /type-check/0.3.2:
- resolution: {integrity: sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=}
+ /type-check@0.3.2:
+ resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==}
engines: {node: '>= 0.8.0'}
dependencies:
prelude-ls: 1.1.2
dev: true
- /type-check/0.4.0:
+ /type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
dependencies:
prelude-ls: 1.2.1
dev: true
- /type-detect/4.0.8:
+ /type-detect@4.0.8:
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
engines: {node: '>=4'}
dev: true
- /type-fest/0.13.1:
+ /type-fest@0.13.1:
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
engines: {node: '>=10'}
dev: true
- /type-fest/0.16.0:
+ /type-fest@0.16.0:
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
engines: {node: '>=10'}
dev: true
- /type-fest/0.20.2:
+ /type-fest@0.20.2:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
dev: true
- /type-fest/0.21.3:
- resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
- engines: {node: '>=10'}
- dev: true
-
- /type-fest/0.3.1:
- resolution: {integrity: sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==}
- engines: {node: '>=6'}
+ /type-fest@0.8.1:
+ resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==}
+ engines: {node: '>=8'}
dev: true
- /type-fest/0.6.0:
- resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==}
- engines: {node: '>=8'}
+ /type-fest@1.4.0:
+ resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==}
+ engines: {node: '>=10'}
dev: true
- /type-fest/0.8.1:
- resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==}
- engines: {node: '>=8'}
+ /type-fest@2.19.0:
+ resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
+ engines: {node: '>=12.20'}
dev: true
- /type-is/1.6.18:
+ /type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
dependencies:
media-typer: 0.3.0
- mime-types: 2.1.32
+ mime-types: 2.1.35
dev: true
- /typedarray-to-buffer/3.1.5:
- resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==}
+ /typed-array-buffer@1.0.0:
+ resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==}
+ engines: {node: '>= 0.4'}
dependencies:
- is-typedarray: 1.0.0
- dev: true
-
- /typedarray/0.0.6:
- resolution: {integrity: sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=}
+ call-bind: 1.0.5
+ get-intrinsic: 1.2.2
+ is-typed-array: 1.1.12
dev: true
- /typedoc-default-themes/0.12.4:
- resolution: {integrity: sha512-EZiXBUpogsYWe0dLgy47J8yRZCd+HAn9woGzO28XJxxSCSwZRYGKeQiw1KjyIcm3cBtLWUXiPD5+Bgx24GgZjg==}
- engines: {node: '>= 8'}
+ /typed-array-byte-length@1.0.0:
+ resolution: {integrity: sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.5
+ for-each: 0.3.3
+ has-proto: 1.0.1
+ is-typed-array: 1.1.12
dev: true
- /typedoc/0.20.16_typescript@4.1.3:
- resolution: {integrity: sha512-xqIL8lT6ZE3QpP0GN30ckeTR05NSEkrP2pXQlNhC0OFkbvnjqJtDUcWSmCO15BuYyu4qsEbZT+tKYFEAt9Jxew==}
- engines: {node: '>= 10.8.0'}
- hasBin: true
- peerDependencies:
- typescript: 3.9.x || 4.0.x || 4.1.x
+ /typed-array-byte-offset@1.0.0:
+ resolution: {integrity: sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==}
+ engines: {node: '>= 0.4'}
dependencies:
- colors: 1.4.0
- fs-extra: 9.1.0
- handlebars: 4.7.6
- lodash: 4.17.20
- lunr: 2.3.9
- marked: 1.2.7
- minimatch: 3.0.4
- progress: 2.0.3
- shelljs: 0.8.4
- shiki: 0.2.7
- typedoc-default-themes: 0.12.4
- typescript: 4.1.3
+ available-typed-arrays: 1.0.5
+ call-bind: 1.0.5
+ for-each: 0.3.3
+ has-proto: 1.0.1
+ is-typed-array: 1.1.12
dev: true
- /typescript/3.9.10:
- resolution: {integrity: sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==}
- engines: {node: '>=4.2.0'}
- hasBin: true
+ /typed-array-length@1.0.4:
+ resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==}
+ dependencies:
+ call-bind: 1.0.5
+ for-each: 0.3.3
+ is-typed-array: 1.1.12
dev: true
- /typescript/4.1.3:
- resolution: {integrity: sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==}
- engines: {node: '>=4.2.0'}
- hasBin: true
+ /typedarray-to-buffer@3.1.5:
+ resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==}
+ dependencies:
+ is-typedarray: 1.0.0
dev: true
- /typescript/4.2.3:
- resolution: {integrity: sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==}
- engines: {node: '>=4.2.0'}
- hasBin: true
+ /typedarray@0.0.6:
+ resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
dev: true
- /typescript/4.3.5:
- resolution: {integrity: sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==}
- engines: {node: '>=4.2.0'}
+ /typedoc@0.25.4(typescript@5.3.3):
+ resolution: {integrity: sha512-Du9ImmpBCw54bX275yJrxPVnjdIyJO/84co0/L9mwe0R3G4FSR6rQ09AlXVRvZEGMUg09+z/usc8mgygQ1aidA==}
+ engines: {node: '>= 16'}
hasBin: true
+ peerDependencies:
+ typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x
+ dependencies:
+ lunr: 2.3.9
+ marked: 4.3.0
+ minimatch: 9.0.3
+ shiki: 0.14.6
+ typescript: 5.3.3
dev: true
- /typescript/4.4.3:
- resolution: {integrity: sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA==}
+ /typescript@4.6.4:
+ resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==}
engines: {node: '>=4.2.0'}
hasBin: true
dev: true
- /uglify-js/3.12.5:
- resolution: {integrity: sha512-SgpgScL4T7Hj/w/GexjnBHi3Ien9WS1Rpfg5y91WXMj9SY997ZCQU76mH4TpLwwfmMvoOU8wiaRkIf6NaH3mtg==}
- engines: {node: '>=0.8.0'}
+ /typescript@5.3.3:
+ resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==}
+ engines: {node: '>=14.17'}
hasBin: true
dev: true
- optional: true
- /uglify-js/3.4.10:
+ /uglify-js@3.4.10:
resolution: {integrity: sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==}
engines: {node: '>=0.8.0'}
- hasBin: true
dependencies:
commander: 2.19.0
source-map: 0.6.1
dev: true
- /unbox-primitive/1.0.1:
- resolution: {integrity: sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==}
+ /unbox-primitive@1.0.2:
+ resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
dependencies:
- function-bind: 1.1.1
- has-bigints: 1.0.1
- has-symbols: 1.0.2
+ call-bind: 1.0.5
+ has-bigints: 1.0.2
+ has-symbols: 1.0.3
which-boxed-primitive: 1.0.2
dev: true
- /underscore/1.6.0:
- resolution: {integrity: sha1-izixDKze9jM3uLJOT/htRa6lKag=}
+ /underscore@1.6.0:
+ resolution: {integrity: sha512-z4o1fvKUojIWh9XuaVLUDdf86RQiq13AC1dmHbTpoyuu+bquHms76v16CjycCbec87J7z0k//SiQVk0sMdFmpQ==}
dev: true
- /unfetch/4.2.0:
- resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
+ /undici-types@5.26.5:
+ resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
dev: true
- /unherit/1.1.3:
- resolution: {integrity: sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ==}
- dependencies:
- inherits: 2.0.4
- xtend: 4.0.2
+ /unfetch@4.2.0:
+ resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
dev: true
- /unicode-canonical-property-names-ecmascript/1.0.4:
- resolution: {integrity: sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==}
+ /unicode-canonical-property-names-ecmascript@2.0.0:
+ resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==}
engines: {node: '>=4'}
dev: true
- /unicode-match-property-ecmascript/1.0.4:
- resolution: {integrity: sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==}
+ /unicode-match-property-ecmascript@2.0.0:
+ resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==}
engines: {node: '>=4'}
dependencies:
- unicode-canonical-property-names-ecmascript: 1.0.4
- unicode-property-aliases-ecmascript: 1.1.0
+ unicode-canonical-property-names-ecmascript: 2.0.0
+ unicode-property-aliases-ecmascript: 2.1.0
dev: true
- /unicode-match-property-value-ecmascript/1.2.0:
- resolution: {integrity: sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==}
+ /unicode-match-property-value-ecmascript@2.1.0:
+ resolution: {integrity: sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==}
engines: {node: '>=4'}
dev: true
- /unicode-property-aliases-ecmascript/1.1.0:
- resolution: {integrity: sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==}
+ /unicode-property-aliases-ecmascript@2.1.0:
+ resolution: {integrity: sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==}
engines: {node: '>=4'}
dev: true
- /unified/9.2.0:
- resolution: {integrity: sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==}
- dependencies:
- bail: 1.0.5
- extend: 3.0.2
- is-buffer: 2.0.5
- is-plain-obj: 2.1.0
- trough: 1.0.5
- vfile: 4.2.1
+ /unicorn-magic@0.1.0:
+ resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
+ engines: {node: '>=18'}
dev: true
- /union-value/1.0.1:
+ /union-value@1.0.1:
resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==}
engines: {node: '>=0.10.0'}
dependencies:
@@ -21204,119 +18782,115 @@ packages:
set-value: 2.0.1
dev: true
- /uniq/1.0.1:
- resolution: {integrity: sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=}
+ /uniq@1.0.1:
+ resolution: {integrity: sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==}
dev: true
- /uniqs/2.0.0:
- resolution: {integrity: sha1-/+3ks2slKQaW5uFl1KWe25mOawI=}
+ /uniqs@2.0.0:
+ resolution: {integrity: sha512-mZdDpf3vBV5Efh29kMw5tXoup/buMgxLzOt/XKFKcVmi+15ManNQWr6HfZ2aiZTYlYixbdNJ0KFmIZIv52tHSQ==}
dev: true
- /unique-filename/1.1.1:
+ /unique-filename@1.1.1:
resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==}
dependencies:
unique-slug: 2.0.2
dev: true
- /unique-slug/2.0.2:
+ /unique-slug@2.0.2:
resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==}
dependencies:
imurmurhash: 0.1.4
dev: true
- /unique-string/2.0.0:
+ /unique-string@2.0.0:
resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==}
engines: {node: '>=8'}
dependencies:
crypto-random-string: 2.0.0
dev: true
- /unist-builder/2.0.3:
- resolution: {integrity: sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==}
- dev: true
-
- /unist-util-generated/1.1.6:
- resolution: {integrity: sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==}
- dev: true
-
- /unist-util-is/4.1.0:
- resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==}
- dev: true
-
- /unist-util-position/3.1.0:
- resolution: {integrity: sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==}
- dev: true
-
- /unist-util-remove-position/2.0.1:
- resolution: {integrity: sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA==}
- dependencies:
- unist-util-visit: 2.0.3
- dev: true
-
- /unist-util-remove/2.1.0:
- resolution: {integrity: sha512-J8NYPyBm4baYLdCbjmf1bhPu45Cr1MWTm77qd9istEkzWpnN6O9tMsEbB2JhNnBCqGENRqEWomQ+He6au0B27Q==}
- dependencies:
- unist-util-is: 4.1.0
- dev: true
-
- /unist-util-stringify-position/2.0.3:
- resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==}
- dependencies:
- '@types/unist': 2.0.6
- dev: true
-
- /unist-util-visit-parents/3.1.1:
- resolution: {integrity: sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==}
- dependencies:
- '@types/unist': 2.0.6
- unist-util-is: 4.1.0
- dev: true
-
- /unist-util-visit/2.0.3:
- resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==}
+ /unique-string@3.0.0:
+ resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==}
+ engines: {node: '>=12'}
dependencies:
- '@types/unist': 2.0.6
- unist-util-is: 4.1.0
- unist-util-visit-parents: 3.1.1
+ crypto-random-string: 4.0.0
dev: true
- /universalify/0.1.2:
- resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
- engines: {node: '>= 4.0.0'}
+ /universalify@1.0.0:
+ resolution: {integrity: sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==}
+ engines: {node: '>= 10.0.0'}
dev: true
- /universalify/2.0.0:
+ /universalify@2.0.0:
resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
engines: {node: '>= 10.0.0'}
dev: true
- /unpipe/1.0.0:
- resolution: {integrity: sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=}
+ /unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
dev: true
- /unquote/1.1.1:
- resolution: {integrity: sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ=}
+ /unquote@1.1.1:
+ resolution: {integrity: sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==}
dev: true
- /unset-value/1.0.0:
- resolution: {integrity: sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=}
+ /unset-value@1.0.0:
+ resolution: {integrity: sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==}
engines: {node: '>=0.10.0'}
dependencies:
has-value: 0.3.1
isobject: 3.0.1
dev: true
- /upath/1.2.0:
+ /upath@1.2.0:
resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==}
engines: {node: '>=4'}
+ requiresBuild: true
+ dev: true
+
+ /upath@2.0.1:
+ resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /update-browserslist-db@1.0.10(browserslist@4.21.5):
+ resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+ dependencies:
+ browserslist: 4.21.5
+ escalade: 3.1.1
+ picocolors: 1.0.0
dev: true
- /update-notifier/5.1.0:
+ /update-browserslist-db@1.0.13(browserslist@4.21.4):
+ resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+ dependencies:
+ browserslist: 4.21.4
+ escalade: 3.1.1
+ picocolors: 1.0.0
+ dev: true
+
+ /update-browserslist-db@1.0.13(browserslist@4.22.2):
+ resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+ dependencies:
+ browserslist: 4.22.2
+ escalade: 3.1.1
+ picocolors: 1.0.0
+
+ /update-notifier@5.1.0:
resolution: {integrity: sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==}
engines: {node: '>=10'}
dependencies:
- boxen: 5.0.1
+ boxen: 5.1.2
chalk: 4.1.2
configstore: 5.0.1
has-yarn: 2.1.0
@@ -21327,27 +18901,47 @@ packages:
is-yarn-global: 0.3.0
latest-version: 5.1.0
pupa: 2.1.1
- semver: 7.3.5
+ semver: 7.5.4
semver-diff: 3.1.1
xdg-basedir: 4.0.0
dev: true
- /upper-case/1.1.3:
- resolution: {integrity: sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=}
+ /update-notifier@6.0.2:
+ resolution: {integrity: sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==}
+ engines: {node: '>=14.16'}
+ dependencies:
+ boxen: 7.1.1
+ chalk: 5.3.0
+ configstore: 6.0.0
+ has-yarn: 3.0.0
+ import-lazy: 4.0.0
+ is-ci: 3.0.1
+ is-installed-globally: 0.4.0
+ is-npm: 6.0.0
+ is-yarn-global: 0.4.1
+ latest-version: 7.0.0
+ pupa: 3.1.0
+ semver: 7.5.4
+ semver-diff: 4.0.0
+ xdg-basedir: 5.1.0
+ dev: true
+
+ /upper-case@1.1.3:
+ resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==}
dev: true
- /uri-js/4.4.1:
+ /uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
dependencies:
punycode: 2.1.1
dev: true
- /urix/0.1.0:
- resolution: {integrity: sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=}
+ /urix@0.1.0:
+ resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==}
deprecated: Please see https://github.com/lydell/urix#deprecated
dev: true
- /url-loader/4.1.1_file-loader@6.2.0+webpack@4.46.0:
+ /url-loader@4.1.1(file-loader@6.2.0)(webpack@4.46.0):
resolution: {integrity: sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==}
engines: {node: '>= 10.13.0'}
peerDependencies:
@@ -21357,216 +18951,137 @@ packages:
file-loader:
optional: true
dependencies:
- file-loader: 6.2.0_webpack@4.46.0
- loader-utils: 2.0.0
- mime-types: 2.1.32
+ file-loader: 6.2.0(webpack@4.46.0)
+ loader-utils: 2.0.3
+ mime-types: 2.1.35
schema-utils: 3.1.1
webpack: 4.46.0
dev: true
- /url-parse-lax/3.0.0:
- resolution: {integrity: sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=}
+ /url-parse-lax@3.0.0:
+ resolution: {integrity: sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==}
engines: {node: '>=4'}
dependencies:
prepend-http: 2.0.0
dev: true
- /url-parse/1.5.3:
- resolution: {integrity: sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==}
- dependencies:
- querystringify: 2.2.0
- requires-port: 1.0.0
- dev: true
-
- /url/0.11.0:
- resolution: {integrity: sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=}
- dependencies:
- punycode: 1.3.2
- querystring: 0.2.0
- dev: true
-
- /use-composed-ref/1.1.0:
- resolution: {integrity: sha512-my1lNHGWsSDAhhVAT4MKs6IjBUtG6ZG11uUqexPH9PptiIZDQOzaF4f5tEbJ2+7qvNbtXNBbU3SfmN+fXlWDhg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- dependencies:
- ts-essentials: 2.0.12
- dev: true
-
- /use-composed-ref/1.1.0_react@16.14.0:
- resolution: {integrity: sha512-my1lNHGWsSDAhhVAT4MKs6IjBUtG6ZG11uUqexPH9PptiIZDQOzaF4f5tEbJ2+7qvNbtXNBbU3SfmN+fXlWDhg==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0
- dependencies:
- react: 16.14.0
- ts-essentials: 2.0.12
- dev: true
-
- /use-isomorphic-layout-effect/1.1.1:
- resolution: {integrity: sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- '@types/react':
- optional: true
- dev: true
-
- /use-isomorphic-layout-effect/1.1.1_react@16.14.0:
- resolution: {integrity: sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- '@types/react':
- optional: true
- dependencies:
- react: 16.14.0
- dev: true
-
- /use-latest/1.2.0:
- resolution: {integrity: sha512-d2TEuG6nSLKQLAfW3By8mKr8HurOlTkul0sOpxbClIv4SQ4iOd7BYr7VIzdbktUCnv7dua/60xzd8igMU6jmyw==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- '@types/react':
- optional: true
+ /url@0.11.1:
+ resolution: {integrity: sha512-rWS3H04/+mzzJkv0eZ7vEDGiQbgquI1fGfOad6zKvgYQi1SzMmhl7c/DdRGxhaWrVH6z0qWITo8rpnxK/RfEhA==}
dependencies:
- use-isomorphic-layout-effect: 1.1.1
+ punycode: 1.4.1
+ qs: 6.11.2
dev: true
- /use-latest/1.2.0_react@16.14.0:
- resolution: {integrity: sha512-d2TEuG6nSLKQLAfW3By8mKr8HurOlTkul0sOpxbClIv4SQ4iOd7BYr7VIzdbktUCnv7dua/60xzd8igMU6jmyw==}
+ /use-sync-external-store@1.2.0(react@18.2.0):
+ resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
- '@types/react': '*'
- react: ^16.8.0 || ^17.0.0
- peerDependenciesMeta:
- '@types/react':
- optional: true
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
- react: 16.14.0
- use-isomorphic-layout-effect: 1.1.1_react@16.14.0
- dev: true
+ react: 18.2.0
- /use/3.1.1:
+ /use@3.1.1:
resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==}
engines: {node: '>=0.10.0'}
dev: true
- /util-deprecate/1.0.2:
- resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=}
- dev: true
+ /util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ requiresBuild: true
- /util.promisify/1.0.0:
+ /util.promisify@1.0.0:
resolution: {integrity: sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==}
dependencies:
- define-properties: 1.1.3
- object.getownpropertydescriptors: 2.1.2
+ define-properties: 1.2.1
+ object.getownpropertydescriptors: 2.1.4
dev: true
- /util.promisify/1.0.1:
+ /util.promisify@1.0.1:
resolution: {integrity: sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==}
dependencies:
- define-properties: 1.1.3
- es-abstract: 1.18.5
- has-symbols: 1.0.2
- object.getownpropertydescriptors: 2.1.2
+ define-properties: 1.2.1
+ es-abstract: 1.22.3
+ has-symbols: 1.0.3
+ object.getownpropertydescriptors: 2.1.4
dev: true
- /util.promisify/1.1.1:
+ /util.promisify@1.1.1:
resolution: {integrity: sha512-/s3UsZUrIfa6xDhr7zZhnE9SLQ5RIXyYfiVnMMyMDzOc8WhWN4Nbh36H842OyurKbCDAesZOJaVyvmSl6fhGQw==}
dependencies:
- call-bind: 1.0.2
- define-properties: 1.1.3
+ call-bind: 1.0.5
+ define-properties: 1.2.1
for-each: 0.3.3
- has-symbols: 1.0.2
- object.getownpropertydescriptors: 2.1.2
+ has-symbols: 1.0.3
+ object.getownpropertydescriptors: 2.1.4
dev: true
- /util/0.10.3:
- resolution: {integrity: sha1-evsa/lCAUkZInj23/g7TeTNqwPk=}
+ /util@0.10.3:
+ resolution: {integrity: sha512-5KiHfsmkqacuKjkRkdV7SsfDJ2EGiPsK92s2MhNSY0craxjTdKTtqKsJaCWp4LW33ZZ0OPUv1WO/TFvNQRiQxQ==}
dependencies:
inherits: 2.0.1
dev: true
- /util/0.11.1:
+ /util@0.11.1:
resolution: {integrity: sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==}
dependencies:
inherits: 2.0.3
dev: true
- /utila/0.4.0:
- resolution: {integrity: sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=}
+ /utila@0.4.0:
+ resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==}
dev: true
- /utils-merge/1.0.1:
- resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=}
+ /utils-merge@1.0.1:
+ resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
dev: true
- /uuid-browser/3.1.0:
- resolution: {integrity: sha1-DwWkCu90+eWVHiDvv0SxGHHlZBA=}
- dev: true
-
- /uuid/3.4.0:
+ /uuid@3.4.0:
resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==}
- deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.
- hasBin: true
dev: true
- /uuid/8.3.2:
+ /uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
- hasBin: true
dev: true
- optional: true
- /v8-compile-cache/2.2.0:
- resolution: {integrity: sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==}
+ /v8-compile-cache-lib@3.0.1:
+ resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
dev: true
- /v8-to-istanbul/7.1.2:
- resolution: {integrity: sha512-TxNb7YEUwkLXCQYeudi6lgQ/SZrzNO4kMdlqVxaZPUIUjCv6iSSypUQX70kNBSERpQ8fk48+d61FXk+tgqcWow==}
- engines: {node: '>=10.10.0'}
- dependencies:
- '@types/istanbul-lib-coverage': 2.0.3
- convert-source-map: 1.8.0
- source-map: 0.7.3
+ /v8-compile-cache@2.3.0:
+ resolution: {integrity: sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==}
dev: true
- /validate-npm-package-license/3.0.4:
- resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
+ /v8-to-istanbul@9.2.0:
+ resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==}
+ engines: {node: '>=10.12.0'}
dependencies:
- spdx-correct: 3.1.1
- spdx-expression-parse: 3.0.1
+ '@jridgewell/trace-mapping': 0.3.20
+ '@types/istanbul-lib-coverage': 2.0.6
+ convert-source-map: 2.0.0
dev: true
- /validate-npm-package-name/3.0.0:
- resolution: {integrity: sha1-X6kS2B630MdK/BQN5zF/DKffQ34=}
+ /validate-npm-package-name@4.0.0:
+ resolution: {integrity: sha512-mzR0L8ZDktZjpX4OB46KT+56MAhl4EIazWP/+G/HPGuvfdaqg4YsCdtOm6U9+LOFyYDoh4dpnpxZRB9MQQns5Q==}
+ engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
dependencies:
- builtins: 1.0.3
+ builtins: 5.0.1
dev: true
- /validator/8.2.0:
- resolution: {integrity: sha512-Yw5wW34fSv5spzTXNkokD6S6/Oq92d8q/t14TqsS3fAiA1RYnxSFSIZ+CY3n6PGGRCq5HhJTSepQvFUS2QUDxA==}
- engines: {node: '>= 0.10'}
- dev: true
-
- /value-equal/1.0.1:
+ /value-equal@1.0.1:
resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==}
dev: false
- /vary/1.1.2:
- resolution: {integrity: sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=}
+ /vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
dev: true
- /vendors/1.0.4:
+ /vendors@1.0.4:
resolution: {integrity: sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w==}
dev: true
- /verror/1.10.0:
- resolution: {integrity: sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=}
+ /verror@1.10.0:
+ resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
engines: {'0': node >=0.6.0}
dependencies:
assert-plus: 1.0.0
@@ -21574,214 +19089,215 @@ packages:
extsprintf: 1.3.0
dev: true
- /vfile-location/3.2.0:
- resolution: {integrity: sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA==}
- dev: true
-
- /vfile-message/2.0.4:
- resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==}
- dependencies:
- '@types/unist': 2.0.6
- unist-util-stringify-position: 2.0.3
- dev: true
-
- /vfile/4.2.1:
- resolution: {integrity: sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==}
- dependencies:
- '@types/unist': 2.0.6
- is-buffer: 2.0.5
- unist-util-stringify-position: 2.0.3
- vfile-message: 2.0.4
+ /vm-browserify@1.1.2:
+ resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==}
dev: true
- /vm-browserify/1.1.2:
- resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==}
+ /vscode-oniguruma@1.7.0:
+ resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==}
dev: true
- /vscode-textmate/5.2.0:
- resolution: {integrity: sha512-Uw5ooOQxRASHgu6C7GVvUxisKXfSgW4oFlO+aa+PAkgmH89O3CXxEEzNRNtHSqtXFTl0nAC1uYj0GMSH27uwtQ==}
+ /vscode-textmate@8.0.0:
+ resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==}
dev: true
- /w3c-hr-time/1.0.2:
+ /w3c-hr-time@1.0.2:
resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==}
dependencies:
browser-process-hrtime: 1.0.0
dev: true
- /w3c-xmlserializer/2.0.0:
- resolution: {integrity: sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==}
- engines: {node: '>=10'}
- dependencies:
- xml-name-validator: 3.0.0
- dev: true
-
- /walker/1.0.7:
- resolution: {integrity: sha1-L3+bj9ENZ3JisYqITijRlhjgKPs=}
- dependencies:
- makeerror: 1.0.11
- dev: true
-
- /warning/4.0.3:
- resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
- dependencies:
- loose-envify: 1.4.0
- dev: true
-
- /watchpack-chokidar2/2.0.1:
+ /watchpack-chokidar2@2.0.1:
resolution: {integrity: sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==}
+ requiresBuild: true
dependencies:
chokidar: 2.1.8
+ transitivePeerDependencies:
+ - supports-color
dev: true
optional: true
- /watchpack/1.7.5:
+ /watchpack@1.7.5:
resolution: {integrity: sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==}
dependencies:
- graceful-fs: 4.2.8
+ graceful-fs: 4.2.11
neo-async: 2.6.2
optionalDependencies:
- chokidar: 3.5.2
+ chokidar: 3.5.3
watchpack-chokidar2: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /watchpack@2.4.0:
+ resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==}
+ engines: {node: '>=10.13.0'}
+ dependencies:
+ glob-to-regexp: 0.4.1
+ graceful-fs: 4.2.11
dev: true
- /wbuf/1.7.3:
+ /wbuf@1.7.3:
resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==}
dependencies:
minimalistic-assert: 1.0.1
dev: true
- /wcwidth/1.0.1:
- resolution: {integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=}
+ /wcwidth@1.0.1:
+ resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
dependencies:
- defaults: 1.0.3
+ defaults: 1.0.4
dev: true
- /web-namespaces/1.1.4:
- resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==}
+ /web-ext@7.11.0:
+ resolution: {integrity: sha512-EG6YXHITNDJB/h6Rc5FF08eMoN45sZPBBIIlEraBzxJ0RdJZ8Z3xvUUawbDwt+mowfv9X0XRWlLSwdWbRKgojg==}
+ engines: {node: '>=14.0.0', npm: '>=6.9.0'}
+ hasBin: true
+ dependencies:
+ '@babel/runtime': 7.21.0
+ '@devicefarmer/adbkit': 3.2.3
+ addons-linter: 6.21.0(node-fetch@3.3.1)
+ bunyan: 1.8.15
+ camelcase: 7.0.1
+ chrome-launcher: 0.15.1
+ debounce: 1.2.1
+ decamelize: 6.0.0
+ es6-error: 4.1.1
+ firefox-profile: 4.3.2
+ fs-extra: 11.1.0
+ fx-runner: 1.4.0
+ import-fresh: 3.3.0
+ jose: 4.13.1
+ mkdirp: 1.0.4
+ multimatch: 6.0.0
+ mz: 2.7.0
+ node-fetch: 3.3.1
+ node-notifier: 10.0.1
+ open: 8.4.2
+ parse-json: 6.0.2
+ promise-toolbox: 0.21.0
+ sign-addon: 5.3.0
+ source-map-support: 0.5.21
+ strip-bom: 5.0.0
+ strip-json-comments: 5.0.0
+ tmp: 0.2.1
+ update-notifier: 6.0.2
+ watchpack: 2.4.0
+ ws: 8.13.0
+ yargs: 17.7.1
+ zip-dir: 2.0.0
+ transitivePeerDependencies:
+ - body-parser
+ - bufferutil
+ - express
+ - safe-compare
+ - supports-color
+ - utf-8-validate
dev: true
- /web-streams-polyfill/3.1.1:
- resolution: {integrity: sha512-Czi3fG883e96T4DLEPRvufrF2ydhOOW1+1a6c3gNjH2aIh50DNFBdfwh2AKoOf1rXvpvavAoA11Qdq9+BKjE0Q==}
+ /web-streams-polyfill@3.3.3:
+ resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
- dev: false
+ dev: true
- /webidl-conversions/4.0.2:
- resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
+ /webidl-conversions@3.0.1:
+ resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: true
- /webidl-conversions/5.0.0:
- resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==}
- engines: {node: '>=8'}
+ /webidl-conversions@4.0.2:
+ resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
dev: true
- /webidl-conversions/6.1.0:
- resolution: {integrity: sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==}
- engines: {node: '>=10.4'}
+ /webidl-conversions@7.0.0:
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+ engines: {node: '>=12'}
dev: true
- /webpack-bundle-analyzer/4.4.2:
- resolution: {integrity: sha512-PIagMYhlEzFfhMYOzs5gFT55DkUdkyrJi/SxJp8EF3YMWhS+T9vvs2EoTetpk5qb6VsCq02eXTlRDOydRhDFAQ==}
+ /webpack-bundle-analyzer@4.6.1:
+ resolution: {integrity: sha512-oKz9Oz9j3rUciLNfpGFjOb49/jEpXNmWdVH8Ls//zNcnLlQdTGXQQMsBbb/gR7Zl8WNLxVCq+0Hqbx3zv6twBw==}
engines: {node: '>= 10.13.0'}
- hasBin: true
dependencies:
- acorn: 8.4.1
- acorn-walk: 8.1.1
+ acorn: 8.8.2
+ acorn-walk: 8.2.0
chalk: 4.1.2
- commander: 6.2.1
+ commander: 7.2.0
gzip-size: 6.0.0
lodash: 4.17.21
opener: 1.5.2
- sirv: 1.0.14
- ws: 7.5.3
+ sirv: 1.0.19
+ ws: 7.5.9
transitivePeerDependencies:
- bufferutil
- utf-8-validate
dev: true
- /webpack-dev-middleware/3.7.3_webpack@4.46.0:
- resolution: {integrity: sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ==}
- engines: {node: '>= 6'}
+ /webpack-dev-middleware@5.3.3(webpack@4.46.0):
+ resolution: {integrity: sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==}
+ engines: {node: '>= 12.13.0'}
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
dependencies:
- memory-fs: 0.4.1
- mime: 2.5.2
- mkdirp: 0.5.5
+ colorette: 2.0.19
+ memfs: 3.4.7
+ mime-types: 2.1.35
range-parser: 1.2.1
+ schema-utils: 4.0.0
webpack: 4.46.0
- webpack-log: 2.0.0
dev: true
- /webpack-dev-server/3.11.2_webpack@4.46.0:
- resolution: {integrity: sha512-A80BkuHRQfCiNtGBS1EMf2ChTUs0x+B3wGDFmOeT4rmJOHhHTCH2naNxIHhmkr0/UillP4U3yeIyv1pNp+QDLQ==}
- engines: {node: '>= 6.11.5'}
+ /webpack-dev-server@4.11.1(webpack@4.46.0):
+ resolution: {integrity: sha512-lILVz9tAUy1zGFwieuaQtYiadImb5M3d+H+L1zDYalYoDl0cksAB1UNyuE5MMWJrG6zR1tXkCP2fitl7yoUJiw==}
+ engines: {node: '>= 12.13.0'}
hasBin: true
peerDependencies:
- webpack: ^4.0.0 || ^5.0.0
+ webpack: ^4.37.0 || ^5.0.0
webpack-cli: '*'
peerDependenciesMeta:
webpack-cli:
optional: true
dependencies:
- ansi-html: 0.0.7
- bonjour: 3.5.0
- chokidar: 2.1.8
+ '@types/bonjour': 3.5.10
+ '@types/connect-history-api-fallback': 1.3.5
+ '@types/express': 4.17.14
+ '@types/serve-index': 1.9.1
+ '@types/serve-static': 1.15.0
+ '@types/sockjs': 0.3.33
+ '@types/ws': 8.5.3
+ ansi-html-community: 0.0.8
+ bonjour-service: 1.0.14
+ chokidar: 3.5.3
+ colorette: 2.0.19
compression: 1.7.4
- connect-history-api-fallback: 1.6.0
- debug: 4.3.2_supports-color@6.1.0
- del: 4.1.1
- express: 4.17.1
- html-entities: 1.4.0
- http-proxy-middleware: 0.19.1_debug@4.3.2
- import-local: 2.0.0
- internal-ip: 4.3.0
- ip: 1.1.5
- is-absolute-url: 3.0.3
- killable: 1.0.1
- loglevel: 1.7.1
- opn: 5.5.0
- p-retry: 3.0.1
- portfinder: 1.0.28
- schema-utils: 1.0.0
- selfsigned: 1.10.11
- semver: 6.3.0
+ connect-history-api-fallback: 2.0.0
+ default-gateway: 6.0.3
+ express: 4.18.2
+ graceful-fs: 4.2.11
+ html-entities: 2.3.3
+ http-proxy-middleware: 2.0.6(@types/express@4.17.14)
+ ipaddr.js: 2.0.1
+ open: 8.4.0
+ p-retry: 4.6.2
+ rimraf: 3.0.2
+ schema-utils: 4.0.0
+ selfsigned: 2.1.1
serve-index: 1.9.1
- sockjs: 0.3.21
- sockjs-client: 1.5.1
- spdy: 4.0.2_supports-color@6.1.0
- strip-ansi: 3.0.1
- supports-color: 6.1.0
- url: 0.11.0
- webpack: 4.46.0
- webpack-dev-middleware: 3.7.3_webpack@4.46.0
- webpack-log: 2.0.0
- ws: 6.2.2
- yargs: 13.3.2
- dev: true
-
- /webpack-filter-warnings-plugin/1.2.1_webpack@4.46.0:
- resolution: {integrity: sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg==}
- engines: {node: '>= 4.3 < 5.0.0 || >= 5.10'}
- peerDependencies:
- webpack: ^2.0.0 || ^3.0.0 || ^4.0.0
- dependencies:
+ sockjs: 0.3.24
+ spdy: 4.0.2
webpack: 4.46.0
+ webpack-dev-middleware: 5.3.3(webpack@4.46.0)
+ ws: 8.10.0
+ transitivePeerDependencies:
+ - bufferutil
+ - debug
+ - supports-color
+ - utf-8-validate
dev: true
- /webpack-fix-style-only-entries/0.6.1:
+ /webpack-fix-style-only-entries@0.6.1:
resolution: {integrity: sha512-wyIhoxS3DD3Fr9JA8hQPA+ZmaWnqPxx12Nv166wcsI/0fbReqyEtiIk2llOFYIg57WVS3XX5cZJxw2ji70R0sA==}
dev: true
- /webpack-hot-middleware/2.25.0:
- resolution: {integrity: sha512-xs5dPOrGPCzuRXNi8F6rwhawWvQQkeli5Ro48PRuQh8pYPCPmNnltP9itiUPT4xI8oW+y0m59lyyeQk54s5VgA==}
- dependencies:
- ansi-html: 0.0.7
- html-entities: 1.4.0
- querystring: 0.2.1
- strip-ansi: 3.0.1
- dev: true
-
- /webpack-log/2.0.0:
+ /webpack-log@2.0.0:
resolution: {integrity: sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg==}
engines: {node: '>= 6'}
dependencies:
@@ -21789,7 +19305,18 @@ packages:
uuid: 3.4.0
dev: true
- /webpack-merge/5.8.0:
+ /webpack-manifest-plugin@4.1.1(webpack@4.46.0):
+ resolution: {integrity: sha512-YXUAwxtfKIJIKkhg03MKuiFAD72PlrqCiwdwO4VEXdRO5V0ORCNwaOwAZawPZalCbmH9kBDmXnNeQOw+BIEiow==}
+ engines: {node: '>=12.22.0'}
+ peerDependencies:
+ webpack: ^4.44.2 || ^5.47.0
+ dependencies:
+ tapable: 2.2.1
+ webpack: 4.46.0
+ webpack-sources: 2.3.1
+ dev: true
+
+ /webpack-merge@5.8.0:
resolution: {integrity: sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==}
engines: {node: '>=10.0.0'}
dependencies:
@@ -21797,25 +19324,27 @@ packages:
wildcard: 2.0.0
dev: true
- /webpack-plugin-replace/1.2.0:
+ /webpack-plugin-replace@1.2.0:
resolution: {integrity: sha512-1HA3etHpJW55qonJqv84o5w5GY7iqF8fqSHpTWdNwarj1llkkt4jT4QSvYs1hoaU8Lu5akDnq/spHHO5mXwo1w==}
engines: {node: '>=4'}
dev: true
- /webpack-sources/1.4.3:
+ /webpack-sources@1.4.3:
resolution: {integrity: sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==}
dependencies:
source-list-map: 2.0.1
source-map: 0.6.1
dev: true
- /webpack-virtual-modules/0.2.2:
- resolution: {integrity: sha512-kDUmfm3BZrei0y+1NTHJInejzxfhtU8eDj2M7OKb2IWrPFAeO1SOH2KuQ68MSZu9IGEHcxbkKKR1v18FrUSOmA==}
+ /webpack-sources@2.3.1:
+ resolution: {integrity: sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==}
+ engines: {node: '>=10.13.0'}
dependencies:
- debug: 3.2.7
+ source-list-map: 2.0.1
+ source-map: 0.6.1
dev: true
- /webpack/4.46.0:
+ /webpack@4.46.0:
resolution: {integrity: sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==}
engines: {node: '>=6.11.5'}
hasBin: true
@@ -21834,55 +19363,116 @@ packages:
'@webassemblyjs/wasm-parser': 1.9.0
acorn: 6.4.2
ajv: 6.12.6
- ajv-keywords: 3.5.2_ajv@6.12.6
+ ajv-keywords: 3.5.2(ajv@6.12.6)
chrome-trace-event: 1.0.3
enhanced-resolve: 4.5.0
eslint-scope: 4.0.3
json-parse-better-errors: 1.0.2
loader-runner: 2.4.0
- loader-utils: 1.4.0
+ loader-utils: 1.4.2
memory-fs: 0.4.1
micromatch: 3.1.10
- mkdirp: 0.5.5
+ mkdirp: 0.5.6
neo-async: 2.6.2
node-libs-browser: 2.2.1
schema-utils: 1.0.0
tapable: 1.1.3
- terser-webpack-plugin: 1.4.5_webpack@4.46.0
+ terser-webpack-plugin: 1.4.5(webpack@4.46.0)
watchpack: 1.7.5
webpack-sources: 1.4.3
+ transitivePeerDependencies:
+ - supports-color
dev: true
- /websocket-driver/0.7.4:
+ /webpack@4.47.0:
+ resolution: {integrity: sha512-td7fYwgLSrky3fI1EuU5cneU4+pbH6GgOfuKNS1tNPcfdGinGELAqsb/BP4nnvZyKSG2i/xFGU7+n2PvZA8HJQ==}
+ engines: {node: '>=6.11.5'}
+ hasBin: true
+ peerDependencies:
+ webpack-cli: '*'
+ webpack-command: '*'
+ peerDependenciesMeta:
+ webpack-cli:
+ optional: true
+ webpack-command:
+ optional: true
+ dependencies:
+ '@webassemblyjs/ast': 1.9.0
+ '@webassemblyjs/helper-module-context': 1.9.0
+ '@webassemblyjs/wasm-edit': 1.9.0
+ '@webassemblyjs/wasm-parser': 1.9.0
+ acorn: 6.4.2
+ ajv: 6.12.6
+ ajv-keywords: 3.5.2(ajv@6.12.6)
+ chrome-trace-event: 1.0.3
+ enhanced-resolve: 4.5.0
+ eslint-scope: 4.0.3
+ json-parse-better-errors: 1.0.2
+ loader-runner: 2.4.0
+ loader-utils: 1.4.2
+ memory-fs: 0.4.1
+ micromatch: 3.1.10
+ mkdirp: 0.5.6
+ neo-async: 2.6.2
+ node-libs-browser: 2.2.1
+ schema-utils: 1.0.0
+ tapable: 1.1.3
+ terser-webpack-plugin: 1.4.5(webpack@4.47.0)
+ watchpack: 1.7.5
+ webpack-sources: 1.4.3
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /websocket-driver@0.7.4:
resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==}
engines: {node: '>=0.8.0'}
dependencies:
- http-parser-js: 0.5.3
+ http-parser-js: 0.5.8
safe-buffer: 5.2.1
websocket-extensions: 0.1.4
dev: true
- /websocket-extensions/0.1.4:
+ /websocket-extensions@0.1.4:
resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==}
engines: {node: '>=0.8.0'}
dev: true
- /well-known-symbols/2.0.0:
+ /well-known-symbols@2.0.0:
resolution: {integrity: sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==}
engines: {node: '>=6'}
dev: true
- /whatwg-encoding/1.0.5:
+ /whatwg-encoding@1.0.5:
resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==}
dependencies:
iconv-lite: 0.4.24
dev: true
- /whatwg-mimetype/2.3.0:
+ /whatwg-encoding@2.0.0:
+ resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
+ engines: {node: '>=12'}
+ dependencies:
+ iconv-lite: 0.6.3
+ dev: true
+
+ /whatwg-mimetype@2.3.0:
resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==}
dev: true
- /whatwg-url/7.1.0:
+ /whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /whatwg-url@5.0.0:
+ resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
+ dependencies:
+ tr46: 0.0.3
+ webidl-conversions: 3.0.1
+ dev: true
+
+ /whatwg-url@7.1.0:
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
dependencies:
lodash.sortby: 4.7.0
@@ -21890,302 +19480,357 @@ packages:
webidl-conversions: 4.0.2
dev: true
- /whatwg-url/8.7.0:
- resolution: {integrity: sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==}
- engines: {node: '>=10'}
- dependencies:
- lodash: 4.17.21
- tr46: 2.1.0
- webidl-conversions: 6.1.0
+ /when@3.7.7:
+ resolution: {integrity: sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw==}
dev: true
- /which-boxed-primitive/1.0.2:
+ /which-boxed-primitive@1.0.2:
resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
dependencies:
is-bigint: 1.0.4
is-boolean-object: 1.1.2
- is-number-object: 1.0.6
+ is-number-object: 1.0.7
is-string: 1.0.7
is-symbol: 1.0.4
dev: true
- /which-module/2.0.0:
- resolution: {integrity: sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=}
+ /which-builtin-type@1.1.3:
+ resolution: {integrity: sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ function.prototype.name: 1.1.6
+ has-tostringtag: 1.0.0
+ is-async-function: 2.0.0
+ is-date-object: 1.0.5
+ is-finalizationregistry: 1.0.2
+ is-generator-function: 1.0.10
+ is-regex: 1.1.4
+ is-weakref: 1.0.2
+ isarray: 2.0.5
+ which-boxed-primitive: 1.0.2
+ which-collection: 1.0.1
+ which-typed-array: 1.1.13
+ dev: true
+
+ /which-collection@1.0.1:
+ resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==}
+ dependencies:
+ is-map: 2.0.2
+ is-set: 2.0.2
+ is-weakmap: 2.0.1
+ is-weakset: 2.0.2
+ dev: true
+
+ /which-module@2.0.0:
+ resolution: {integrity: sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==}
+ dev: true
+
+ /which-typed-array@1.1.13:
+ resolution: {integrity: sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ available-typed-arrays: 1.0.5
+ call-bind: 1.0.5
+ for-each: 0.3.3
+ gopd: 1.0.1
+ has-tostringtag: 1.0.0
+ dev: true
+
+ /which@1.2.4:
+ resolution: {integrity: sha512-zDRAqDSBudazdfM9zpiI30Fu9ve47htYXcGi3ln0wfKu2a7SmrT6F3VDoYONu//48V8Vz4TdCRNPjtvyRO3yBA==}
+ hasBin: true
+ dependencies:
+ is-absolute: 0.1.7
+ isexe: 1.1.2
dev: true
- /which/1.3.1:
+ /which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
dependencies:
isexe: 2.0.0
dev: true
- /which/2.0.2:
+ /which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
dependencies:
isexe: 2.0.0
- dev: true
- /wide-align/1.1.3:
- resolution: {integrity: sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==}
+ /wide-align@1.1.5:
+ resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
dependencies:
- string-width: 1.0.2
+ string-width: 4.2.3
dev: true
- /widest-line/3.1.0:
+ /widest-line@3.1.0:
resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==}
engines: {node: '>=8'}
dependencies:
- string-width: 4.2.2
+ string-width: 4.2.3
+ dev: true
+
+ /widest-line@4.0.1:
+ resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==}
+ engines: {node: '>=12'}
+ dependencies:
+ string-width: 5.1.2
dev: true
- /wildcard/2.0.0:
+ /wildcard@2.0.0:
resolution: {integrity: sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==}
dev: true
- /word-wrap/1.2.3:
- resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==}
- engines: {node: '>=0.10.0'}
+ /winreg@0.0.12:
+ resolution: {integrity: sha512-typ/+JRmi7RqP1NanzFULK36vczznSNN8kWVA9vIqXyv8GhghUlwhGp1Xj3Nms1FsPcNnsQrJOR10N58/nQ9hQ==}
dev: true
- /wordwrap/1.0.0:
- resolution: {integrity: sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=}
+ /word-wrap@1.2.3:
+ resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==}
+ engines: {node: '>=0.10.0'}
dev: true
- /workbox-background-sync/6.2.4:
- resolution: {integrity: sha512-uoGgm1PZU6THRzXKlMEntrdA4Xkp6SCfxI7re4heN+yGrtAZq6zMKYhZmsdeW+YGnXS3y5xj7WV03b5TDgLh6A==}
+ /workbox-background-sync@6.5.4:
+ resolution: {integrity: sha512-0r4INQZMyPky/lj4Ou98qxcThrETucOde+7mRGJl13MPJugQNKeZQOdIJe/1AchOP23cTqHcN/YVpD6r8E6I8g==}
dependencies:
- idb: 6.1.2
- workbox-core: 6.2.4
+ idb: 7.1.0
+ workbox-core: 6.5.4
dev: true
- /workbox-broadcast-update/6.2.4:
- resolution: {integrity: sha512-0EpML2lbxNkiZUoap4BJDA0Hfz36MhtUd/rRhFvF6YWoRbTQ8tc6tMaRgM1EBIUmIN2OX9qQlkqe5SGGt4lfXQ==}
+ /workbox-broadcast-update@6.5.4:
+ resolution: {integrity: sha512-I/lBERoH1u3zyBosnpPEtcAVe5lwykx9Yg1k6f8/BGEPGaMMgZrwVrqL1uA9QZ1NGGFoyE6t9i7lBjOlDhFEEw==}
dependencies:
- workbox-core: 6.2.4
+ workbox-core: 6.5.4
dev: true
- /workbox-build/6.2.4:
- resolution: {integrity: sha512-01ZbY1BHi+yYvu4yDGZBw9xm1bWyZW0QGWPxiksvSPAsNH/z/NwgtWW14YEroFyG98mmXb7pufWlwl40zE1KTw==}
+ /workbox-build@6.5.4:
+ resolution: {integrity: sha512-kgRevLXEYvUW9WS4XoziYqZ8Q9j/2ziJYEtTrjdz5/L/cTUa2XfyMP2i7c3p34lgqJ03+mTiz13SdFef2POwbA==}
engines: {node: '>=10.0.0'}
dependencies:
- '@apideck/better-ajv-errors': 0.2.5_ajv@8.6.2
- '@babel/core': 7.15.0
- '@babel/preset-env': 7.15.0_@babel+core@7.15.0
- '@babel/runtime': 7.15.3
- '@rollup/plugin-babel': 5.3.0_@babel+core@7.15.0+rollup@2.56.2
- '@rollup/plugin-node-resolve': 11.2.1_rollup@2.56.2
- '@rollup/plugin-replace': 2.4.2_rollup@2.56.2
- '@surma/rollup-plugin-off-main-thread': 1.4.2
- ajv: 8.6.2
- common-tags: 1.8.0
+ '@apideck/better-ajv-errors': 0.3.6(ajv@8.11.0)
+ '@babel/core': 7.18.9
+ '@babel/preset-env': 7.23.5(@babel/core@7.18.9)
+ '@babel/runtime': 7.23.6
+ '@rollup/plugin-babel': 5.3.1(@babel/core@7.18.9)(rollup@2.79.1)
+ '@rollup/plugin-node-resolve': 11.2.1(rollup@2.79.1)
+ '@rollup/plugin-replace': 2.4.2(rollup@2.79.1)
+ '@surma/rollup-plugin-off-main-thread': 2.2.3
+ ajv: 8.11.0
+ common-tags: 1.8.2
fast-json-stable-stringify: 2.1.0
fs-extra: 9.1.0
- glob: 7.1.7
+ glob: 7.2.3
lodash: 4.17.21
pretty-bytes: 5.6.0
- rollup: 2.56.2
- rollup-plugin-terser: 7.0.2_rollup@2.56.2
+ rollup: 2.79.1
+ rollup-plugin-terser: 7.0.2(rollup@2.79.1)
source-map: 0.8.0-beta.0
- source-map-url: 0.4.1
stringify-object: 3.3.0
strip-comments: 2.0.1
tempy: 0.6.0
upath: 1.2.0
- workbox-background-sync: 6.2.4
- workbox-broadcast-update: 6.2.4
- workbox-cacheable-response: 6.2.4
- workbox-core: 6.2.4
- workbox-expiration: 6.2.4
- workbox-google-analytics: 6.2.4
- workbox-navigation-preload: 6.2.4
- workbox-precaching: 6.2.4
- workbox-range-requests: 6.2.4
- workbox-recipes: 6.2.4
- workbox-routing: 6.2.4
- workbox-strategies: 6.2.4
- workbox-streams: 6.2.4
- workbox-sw: 6.2.4
- workbox-window: 6.2.4
+ workbox-background-sync: 6.5.4
+ workbox-broadcast-update: 6.5.4
+ workbox-cacheable-response: 6.5.4
+ workbox-core: 6.5.4
+ workbox-expiration: 6.5.4
+ workbox-google-analytics: 6.5.4
+ workbox-navigation-preload: 6.5.4
+ workbox-precaching: 6.5.4
+ workbox-range-requests: 6.5.4
+ workbox-recipes: 6.5.4
+ workbox-routing: 6.5.4
+ workbox-strategies: 6.5.4
+ workbox-streams: 6.5.4
+ workbox-sw: 6.5.4
+ workbox-window: 6.5.4
transitivePeerDependencies:
- '@types/babel__core'
- supports-color
dev: true
- /workbox-cacheable-response/6.2.4:
- resolution: {integrity: sha512-KZSzAOmgWsrk15Wu+geCUSGLIyyzHaORKjH5JnR6qcVZAsm0JXUu2m2OZGqjQ+/eyQwrGdXXqAMW+4wQvTXccg==}
+ /workbox-cacheable-response@6.5.4:
+ resolution: {integrity: sha512-DCR9uD0Fqj8oB2TSWQEm1hbFs/85hXXoayVwFKLVuIuxwJaihBsLsp4y7J9bvZbqtPJ1KlCkmYVGQKrBU4KAug==}
dependencies:
- workbox-core: 6.2.4
+ workbox-core: 6.5.4
dev: true
- /workbox-core/6.2.4:
- resolution: {integrity: sha512-Nu8X4R4Is3g8uzEJ6qwbW2CGVpzntW/cSf8OfsQGIKQR0nt84FAKzP2cLDaNLp3L/iV9TuhZgCTZzkMiap5/OQ==}
+ /workbox-core@6.5.4:
+ resolution: {integrity: sha512-OXYb+m9wZm8GrORlV2vBbE5EC1FKu71GGp0H4rjmxmF4/HLbMCoTFws87M3dFwgpmg0v00K++PImpNQ6J5NQ6Q==}
dev: true
- /workbox-expiration/6.2.4:
- resolution: {integrity: sha512-EdOBLunrE3+Ff50y7AYDbiwtiLDvB+oEIkL1Wd9G5d176YVqFfgPfMRzJQ7fN+Yy2NfmsFME0Bw+dQruYekWsQ==}
+ /workbox-expiration@6.5.4:
+ resolution: {integrity: sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ==}
dependencies:
- idb: 6.1.2
- workbox-core: 6.2.4
+ idb: 7.1.0
+ workbox-core: 6.5.4
dev: true
- /workbox-google-analytics/6.2.4:
- resolution: {integrity: sha512-+PWmTouoGGcDupaxM193F2NmgrF597Pyt9eHIDxfed+x+JSSeUkETlbAKwB8rnBHkAjs8JQcvStEP/IpueNKpQ==}
+ /workbox-google-analytics@6.5.4:
+ resolution: {integrity: sha512-8AU1WuaXsD49249Wq0B2zn4a/vvFfHkpcFfqAFHNHwln3jK9QUYmzdkKXGIZl9wyKNP+RRX30vcgcyWMcZ9VAg==}
dependencies:
- workbox-background-sync: 6.2.4
- workbox-core: 6.2.4
- workbox-routing: 6.2.4
- workbox-strategies: 6.2.4
+ workbox-background-sync: 6.5.4
+ workbox-core: 6.5.4
+ workbox-routing: 6.5.4
+ workbox-strategies: 6.5.4
dev: true
- /workbox-navigation-preload/6.2.4:
- resolution: {integrity: sha512-y2dOSsaSdEimqhCmBIFR6kBp+GZbtNtWCBaMFwfKxTAul2uyllKcTKBHnZ9IzxULue6o6voV+I2U8Y8tO8n+eA==}
+ /workbox-navigation-preload@6.5.4:
+ resolution: {integrity: sha512-IIwf80eO3cr8h6XSQJF+Hxj26rg2RPFVUmJLUlM0+A2GzB4HFbQyKkrgD5y2d84g2IbJzP4B4j5dPBRzamHrng==}
dependencies:
- workbox-core: 6.2.4
+ workbox-core: 6.5.4
dev: true
- /workbox-precaching/6.2.4:
- resolution: {integrity: sha512-7POznbVc8EG/mkbXzeb94x3B1VJruPgXvXFgS0NJ3GRugkO4ULs/DpIIb+ycs7uJIKY9EzLS7VXvElr3rMSozQ==}
+ /workbox-precaching@6.5.4:
+ resolution: {integrity: sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg==}
dependencies:
- workbox-core: 6.2.4
- workbox-routing: 6.2.4
- workbox-strategies: 6.2.4
+ workbox-core: 6.5.4
+ workbox-routing: 6.5.4
+ workbox-strategies: 6.5.4
dev: true
- /workbox-range-requests/6.2.4:
- resolution: {integrity: sha512-q4jjTXD1QOKbrHnzV3nxdZtIpOiVoIP5QyVmjuJrybVnAZurtyKcqirTQcAcT/zlTvgwm07zcTTk9o/zIB6DmA==}
+ /workbox-range-requests@6.5.4:
+ resolution: {integrity: sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg==}
dependencies:
- workbox-core: 6.2.4
+ workbox-core: 6.5.4
dev: true
- /workbox-recipes/6.2.4:
- resolution: {integrity: sha512-z7oECGrt940dw1Bv0xIDJEXY1xARiaxsIedeJOutZFkbgaC/yWG61VTr/hmkeJ8Nx6jnY6W7Rc0iOUvg4sePag==}
+ /workbox-recipes@6.5.4:
+ resolution: {integrity: sha512-QZNO8Ez708NNwzLNEXTG4QYSKQ1ochzEtRLGaq+mr2PyoEIC1xFW7MrWxrONUxBFOByksds9Z4//lKAX8tHyUA==}
dependencies:
- workbox-cacheable-response: 6.2.4
- workbox-core: 6.2.4
- workbox-expiration: 6.2.4
- workbox-precaching: 6.2.4
- workbox-routing: 6.2.4
- workbox-strategies: 6.2.4
+ workbox-cacheable-response: 6.5.4
+ workbox-core: 6.5.4
+ workbox-expiration: 6.5.4
+ workbox-precaching: 6.5.4
+ workbox-routing: 6.5.4
+ workbox-strategies: 6.5.4
dev: true
- /workbox-routing/6.2.4:
- resolution: {integrity: sha512-jHnOmpeH4MOWR4eXv6l608npD2y6IFv7yFJ1bT9/RbB8wq2vXHXJQ0ExTZRTWGbVltSG22wEU+MQ8VebDDwDeg==}
+ /workbox-routing@6.5.4:
+ resolution: {integrity: sha512-apQswLsbrrOsBUWtr9Lf80F+P1sHnQdYodRo32SjiByYi36IDyL2r7BH1lJtFX8fwNHDa1QOVY74WKLLS6o5Pg==}
dependencies:
- workbox-core: 6.2.4
+ workbox-core: 6.5.4
dev: true
- /workbox-strategies/6.2.4:
- resolution: {integrity: sha512-DKgGC3ruceDuu2o+Ae5qmJy0p0q21mFP+RrkdqKrjyf2u8cJvvtvt1eIt4nevKc5BESiKxmhC2h+TZpOSzUDvA==}
+ /workbox-strategies@6.5.4:
+ resolution: {integrity: sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw==}
dependencies:
- workbox-core: 6.2.4
+ workbox-core: 6.5.4
dev: true
- /workbox-streams/6.2.4:
- resolution: {integrity: sha512-yG6zV7S2NmYT6koyb7/DoPsyUAat9kD+rOmjP2SbBCtJdLu6ZIi1lgN4/rOkxEby/+Xb4OE4RmCSIZdMyjEmhQ==}
+ /workbox-streams@6.5.4:
+ resolution: {integrity: sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg==}
dependencies:
- workbox-core: 6.2.4
- workbox-routing: 6.2.4
+ workbox-core: 6.5.4
+ workbox-routing: 6.5.4
dev: true
- /workbox-sw/6.2.4:
- resolution: {integrity: sha512-OlWLHNNM+j44sN2OaVXnVcf2wwhJUzcHlXrTrbWDu1JWnrQJ/rLicdc/sbxkZoyE0EbQm7Xr1BXcOjsB7PNlXQ==}
+ /workbox-sw@6.5.4:
+ resolution: {integrity: sha512-vo2RQo7DILVRoH5LjGqw3nphavEjK4Qk+FenXeUsknKn14eCNedHOXWbmnvP4ipKhlE35pvJ4yl4YYf6YsJArA==}
dev: true
- /workbox-webpack-plugin/6.2.4_webpack@4.46.0:
- resolution: {integrity: sha512-G6yeOZDYEbtqgNasqwxHFnma0Vp237kMxpsf8JV/YIhvhUuMwnh1WKv4VnFeqmYaWW/ITx0qj92IEMWB/O1mAA==}
+ /workbox-webpack-plugin@6.5.4(webpack@4.46.0):
+ resolution: {integrity: sha512-LmWm/zoaahe0EGmMTrSLUi+BjyR3cdGEfU3fS6PN1zKFYbqAKuQ+Oy/27e4VSXsyIwAw8+QDfk1XHNGtZu9nQg==}
engines: {node: '>=10.0.0'}
peerDependencies:
webpack: ^4.4.0 || ^5.9.0
dependencies:
fast-json-stable-stringify: 2.1.0
pretty-bytes: 5.6.0
- source-map-url: 0.4.1
upath: 1.2.0
webpack: 4.46.0
webpack-sources: 1.4.3
- workbox-build: 6.2.4
+ workbox-build: 6.5.4
transitivePeerDependencies:
- '@types/babel__core'
- supports-color
dev: true
- /workbox-window/6.2.4:
- resolution: {integrity: sha512-9jD6THkwGEASj1YP56ZBHYJ147733FoGpJlMamYk38k/EBFE75oc6K3Vs2tGOBx5ZGq54+mHSStnlrtFG3IiOg==}
+ /workbox-window@6.5.4:
+ resolution: {integrity: sha512-HnLZJDwYBE+hpG25AQBO8RUWBJRaCsI9ksQJEp3aCOFCaG5kqaToAYXFRAHxzRluM2cQbGzdQF5rjKPWPA1fug==}
dependencies:
'@types/trusted-types': 2.0.2
- workbox-core: 6.2.4
+ workbox-core: 6.5.4
dev: true
- /worker-farm/1.7.0:
+ /worker-farm@1.7.0:
resolution: {integrity: sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==}
dependencies:
errno: 0.1.8
dev: true
- /worker-rpc/0.1.1:
+ /worker-rpc@0.1.1:
resolution: {integrity: sha512-P1WjMrUB3qgJNI9jfmpZ/htmBEjFh//6l/5y8SD9hg1Ef5zTTVVoRjTrTEzPrNBQvmhMxkoTsjOXN10GWU7aCg==}
dependencies:
microevent.ts: 0.1.1
dev: true
- /wrap-ansi/5.1.0:
- resolution: {integrity: sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==}
- engines: {node: '>=6'}
- dependencies:
- ansi-styles: 3.2.1
- string-width: 3.1.0
- strip-ansi: 5.2.0
+ /workerpool@6.2.0:
+ resolution: {integrity: sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==}
dev: true
- /wrap-ansi/6.2.0:
+ /wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
dependencies:
ansi-styles: 4.3.0
- string-width: 4.2.2
- strip-ansi: 6.0.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
dev: true
- /wrap-ansi/7.0.0:
+ /wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
dependencies:
ansi-styles: 4.3.0
- string-width: 4.2.2
- strip-ansi: 6.0.0
- dev: true
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
- /wrappy/1.0.2:
- resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
- dev: true
+ /wrap-ansi@8.1.0:
+ resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
+ engines: {node: '>=12'}
+ dependencies:
+ ansi-styles: 6.2.1
+ string-width: 5.1.2
+ strip-ansi: 7.1.0
+
+ /wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
- /write-file-atomic/3.0.3:
+ /write-file-atomic@3.0.3:
resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==}
dependencies:
imurmurhash: 0.1.4
is-typedarray: 1.0.0
- signal-exit: 3.0.3
+ signal-exit: 3.0.7
typedarray-to-buffer: 3.1.5
dev: true
- /write/1.0.3:
- resolution: {integrity: sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==}
- engines: {node: '>=4'}
+ /write-file-atomic@5.0.1:
+ resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
+ engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
dependencies:
- mkdirp: 0.5.5
+ imurmurhash: 0.1.4
+ signal-exit: 4.1.0
dev: true
- /ws/6.2.2:
+ /ws@6.2.2:
resolution: {integrity: sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ^5.0.2
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
dependencies:
async-limiter: 1.0.1
dev: true
- /ws/7.5.3:
- resolution: {integrity: sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==}
+ /ws@7.4.5:
+ resolution: {integrity: sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==}
engines: {node: '>=8.3.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -22197,58 +19842,111 @@ packages:
optional: true
dev: true
- /xdg-basedir/4.0.0:
+ /ws@7.5.9:
+ resolution: {integrity: sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==}
+ engines: {node: '>=8.3.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ^5.0.2
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ dev: true
+
+ /ws@8.10.0:
+ resolution: {integrity: sha512-+s49uSmZpvtAsd2h37vIPy1RBusaLawVe8of+GyEPsaJTCMpj/2v8NpeK1SHXjBlQ95lQTmQofOJnFiLoaN3yw==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ^5.0.2
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ dev: true
+
+ /ws@8.13.0:
+ resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ dev: true
+
+ /xdg-basedir@4.0.0:
resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==}
engines: {node: '>=8'}
dev: true
- /xml-name-validator/3.0.0:
+ /xdg-basedir@5.1.0:
+ resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /xml-name-validator@3.0.0:
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
dev: true
- /xmlchars/2.2.0:
+ /xml2js@0.5.0:
+ resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==}
+ engines: {node: '>=4.0.0'}
+ dependencies:
+ sax: 1.2.4
+ xmlbuilder: 11.0.1
+ dev: true
+
+ /xmlbuilder@11.0.1:
+ resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
+ engines: {node: '>=4.0'}
+ dev: true
+
+ /xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
dev: true
- /xtend/4.0.2:
+ /xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
dev: true
- /y18n/4.0.3:
+ /y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
dev: true
- /y18n/5.0.8:
+ /y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
dev: true
- /yallist/2.1.2:
- resolution: {integrity: sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=}
+ /yallist@2.1.2:
+ resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==}
dev: true
- /yallist/3.1.1:
+ /yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
dev: true
- /yallist/4.0.0:
+ /yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
- dev: true
- /yaml/1.10.2:
+ /yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
dev: true
- /yargs-parser/13.1.2:
- resolution: {integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==}
- dependencies:
- camelcase: 5.3.1
- decamelize: 1.2.0
- dev: true
+ /yaml@2.2.2:
+ resolution: {integrity: sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==}
+ engines: {node: '>= 14'}
- /yargs-parser/18.1.3:
+ /yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
dependencies:
@@ -22256,27 +19954,32 @@ packages:
decamelize: 1.2.0
dev: true
- /yargs-parser/20.2.9:
+ /yargs-parser@20.2.4:
+ resolution: {integrity: sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
dev: true
- /yargs/13.3.2:
- resolution: {integrity: sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==}
+ /yargs-parser@21.1.1:
+ resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /yargs-unparser@2.0.0:
+ resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==}
+ engines: {node: '>=10'}
dependencies:
- cliui: 5.0.0
- find-up: 3.0.0
- get-caller-file: 2.0.5
- require-directory: 2.1.1
- require-main-filename: 2.0.0
- set-blocking: 2.0.0
- string-width: 3.1.0
- which-module: 2.0.0
- y18n: 4.0.3
- yargs-parser: 13.1.2
+ camelcase: 6.3.0
+ decamelize: 4.0.0
+ flat: 5.0.2
+ is-plain-obj: 2.1.0
dev: true
- /yargs/15.4.1:
+ /yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
dependencies:
@@ -22287,13 +19990,13 @@ packages:
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
- string-width: 4.2.2
+ string-width: 4.2.3
which-module: 2.0.0
y18n: 4.0.3
yargs-parser: 18.1.3
dev: true
- /yargs/16.2.0:
+ /yargs@16.2.0:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}
dependencies:
@@ -22301,27 +20004,70 @@ packages:
escalade: 3.1.1
get-caller-file: 2.0.5
require-directory: 2.1.1
- string-width: 4.2.2
+ string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 20.2.9
dev: true
- /yocto-queue/0.1.0:
+ /yargs@17.7.1:
+ resolution: {integrity: sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==}
+ engines: {node: '>=12'}
+ dependencies:
+ cliui: 8.0.1
+ escalade: 3.1.1
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 21.1.1
+ dev: true
+
+ /yargs@17.7.2:
+ resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+ engines: {node: '>=12'}
+ dependencies:
+ cliui: 8.0.1
+ escalade: 3.1.1
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 21.1.1
+ dev: true
+
+ /yauzl@2.10.0:
+ resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+ dependencies:
+ buffer-crc32: 0.2.13
+ fd-slicer: 1.1.0
+ dev: true
+
+ /yn@3.1.1:
+ resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
dev: true
- /z-schema/3.18.4:
- resolution: {integrity: sha512-DUOKC/IhbkdLKKiV89gw9DUauTV8U/8yJl1sjf6MtDmzevLKOF2duNJ495S3MFVjqZarr+qNGCPbkg4mu4PpLw==}
- hasBin: true
+ /yup@0.32.11:
+ resolution: {integrity: sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==}
+ engines: {node: '>=10'}
dependencies:
- lodash.get: 4.4.2
- lodash.isequal: 4.5.0
- validator: 8.2.0
- optionalDependencies:
- commander: 2.20.3
- dev: true
+ '@babel/runtime': 7.19.4
+ '@types/lodash': 4.14.186
+ lodash: 4.17.21
+ lodash-es: 4.17.21
+ nanoclone: 0.2.1
+ property-expr: 2.0.5
+ toposort: 2.0.2
+ dev: false
- /zwitch/1.0.5:
- resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
+ /zip-dir@2.0.0:
+ resolution: {integrity: sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg==}
+ dependencies:
+ async: 3.2.4
+ jszip: 3.10.1
dev: true
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 936040e69..cc6a9ab1e 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -14,13 +14,16 @@
"path": "packages/taler-wallet-cli/"
},
{
- "path": "packages/taler-wallet-android/"
+ "path": "packages/taler-wallet-embedded/"
},
{
"path": "packages/taler-util/"
},
{
"path": "packages/taler-wallet-webextension//"
+ },
+ {
+ "path": "packages/anastasis-core/"
}
],
"files": []